[
  {
    "path": ".dockerignore",
    "content": "####\n# Go\n####\n\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# GraphQL generated output\npkg/models/generated_*.go\nui/v2.5/src/core/generated-graphql.ts\n\n####\n# Jetbrains\n####\n\n# User-specific stuff\n.idea/**/workspace.xml\n.idea/**/tasks.xml\n.idea/**/usage.statistics.xml\n.idea/**/dictionaries\n.idea/**/shelf\n\n# Generated files\n.idea/**/contentModel.xml\n\n# Sensitive or high-churn files\n.idea/**/dataSources/\n.idea/**/dataSources.ids\n.idea/**/dataSources.local.xml\n.idea/**/sqlDataSources.xml\n.idea/**/dynamic.xml\n.idea/**/uiDesigner.xml\n.idea/**/dbnavigator.xml\n\n####\n# Random\n####\n\nui/v2.5/node_modules\nui/v2.5/build\n\n*.db\n\nstash\ndist\n\ndocker\n"
  },
  {
    "path": ".gitattributes",
    "content": "go.mod text eol=lf\ngo.sum text eol=lf\n*.go text eol=lf\nvendor/** -text\nui/v2.5/**/*.ts* text eol=lf\nui/v2.5/**/*.scss text eol=lf\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: Create a report to help us fix the bug\nlabels: [\"bug report\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this bug report!\n  - type: textarea\n    id: description\n    attributes:\n      label: Describe the bug\n      description: Provide a clear and concise description of what the bug is.\n    validations:\n      required: true\n  - type: textarea\n    id: reproduction\n    attributes:\n      label: Steps to reproduce\n      description: Detail the steps that would replicate this issue.\n      placeholder: |\n        1. Go to '...'\n        2. Click on '....'\n        3. Scroll down to '....'\n        4. See error\n    validations:\n      required: true\n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected behaviour\n      description: Provide clear and concise description of what you expected to happen.\n    validations:\n      required: true\n  - type: textarea\n    id: context\n    attributes:\n      label: Screenshots or additional context\n      description: Provide any additional context and SFW screenshots here to help us solve this issue.\n    validations:\n      required: false\n  - type: input\n    id: stashversion\n    attributes:\n      label: Stash version\n      description: This can be found in Settings > About.\n      placeholder: (e.g. v0.28.1)\n    validations:\n      required: true\n  - type: input\n    id: devicedetails\n    attributes:\n      label: Device details\n      description: |\n        If this is an issue that occurs when using the Stash interface, please provide details of the device/browser used which presents the reported issue.\n      placeholder: (e.g. Firefox 97 (64-bit) on Windows 11)\n    validations:\n      required: false\n  - type: textarea\n    id: logs\n    attributes:\n      label: Relevant log output\n      description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.\n      render: shell"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Community forum\n    url: https://discourse.stashapp.cc\n    about: Start a discussion on the community forum.\n  - name: Community Discord\n    url: https://discord.gg/Y8MNsvQBvZ\n    about: Chat with the community on Discord.\n  - name: Documentation\n    url: https://docs.stashapp.cc\n    about: Check out documentation for help and information."
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Request a new feature or idea to be added to Stash\nlabels: [\"feature request\"]\nbody:\n  - type: textarea\n    id: description\n    attributes:\n      label: Describe the feature you'd like\n      description: Provide a clear description of the feature you'd like implemented\n    validations:\n      required: true\n  - type: textarea\n    id: benefits\n    attributes:\n      label: Describe the benefits this would bring to existing users\n      description: |\n        Explain the measurable benefits this feature would achieve for existing users.\n        The benefits should be described in terms of outcomes for users, not specific implementations.\n    validations:\n      required: true\n  - type: textarea\n    id: already_possible\n    attributes:\n      label: Is there an existing way to achieve this goal?\n      description: |\n        Yes/No. If Yes, describe how your proposed feature differs from or improves upon the current method\n    validations:\n      required: true\n  - type: checkboxes\n    id: confirm-search\n    attributes:\n      label: Have you searched for an existing open/closed issue?\n      description: |\n        To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/stashapp/stash/issues?q=is%3Aissue) for any existing issues that cover the core request or benefit of your proposal.\n      options:\n        - label: I have searched for existing issues and none cover the core request of my proposal\n          required: true\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional context\n      description: Add any other context or screenshots about the feature request here.\n    validations:\n      required: false"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE/BugFix.md",
    "content": "---\nname: Bug Fix\nabout: Add a bug fix this project!\ntitle: \"[Bug Fix] Short Form Title (50 chars or less.)\"\nlabels: bug\nassignees: 'WithoutPants, bnkai, Leopere'\n\n---\n<!-- Please make sure to read https://github.com/stashapp/stash/docs/CONTRIBUTING.md and check that you understand and have followed it as best as possible -->\n<!-- Explain what your bugfix seeks to remedy in a short paragraph. -->\n# Scope\n\n<!-- Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick in when this is merged -->\n## Closes/Fixes Issues\n\n<!-- What have you tested specifically and what possible impacts/areas there are that may need retesting by others. -->\n## Other testing QA Notes\n\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE/Feature.md",
    "content": "---\nname: Feature Addition\nabout: Add a feature to this project!\ntitle: \"[Feature] Short Form Title (50 chars or less.)\"\nlabels: enhancement\nassignees: 'WithoutPants, bnkai, Leopere'\n\n---\n<!-- Please make sure to read https://github.com/stashapp/stash/docs/CONTRIBUTING.md and check that you understand and have followed it as best as possible\nExplain what your feature does in a short paragraph. -->\n# Scope\n\n<!-- Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick in when this is merged -->\n## Closes/Fixes Issues\n\n<!-- What have you tested specifically and what possible impacts/areas there are that may need retesting by others. -->\n## Other testing QA Notes\n"
  },
  {
    "path": ".github/workflows/build-compiler.yml",
    "content": "name: Compiler Build\n\non:\n  workflow_dispatch:\n\nenv:\n  COMPILER_IMAGE: ghcr.io/stashapp/compiler:13\n\njobs:\n  build-compiler:\n    runs-on: ubuntu-24.04\n    steps:\n    - uses: actions/checkout@v6\n    - uses: docker/login-action@v3\n      with:\n        registry: ghcr.io\n        username: ${{ github.repository_owner }}\n        password: ${{ secrets.GITHUB_TOKEN }}\n    - uses: docker/setup-buildx-action@v3\n    - uses: docker/build-push-action@v6\n      with:\n        push: true\n        context: \"{{defaultContext}}:docker/compiler\"\n        tags: |\n          ${{ env.COMPILER_IMAGE }}\n          ghcr.io/stashapp/compiler:latest\n        cache-from: type=gha,scope=all,mode=max\n        cache-to: type=gha,scope=all,mode=max"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build\n\non:\n  push:\n    branches:\n      - develop\n      - master\n      - 'releases/**'\n  pull_request:\n  release:\n    types: [ published ]\n\nconcurrency:\n  group: ${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  COMPILER_IMAGE: ghcr.io/stashapp/compiler:13\n\njobs:\n  # Job 1: Generate code and build UI\n  # Runs natively (no Docker) — go generate/gqlgen and node don't need cross-compilers.\n  # Produces artifacts (generated Go files + UI build) consumed by test and build jobs.\n  generate:\n    runs-on: ubuntu-24.04\n    steps:\n    - uses: actions/checkout@v6\n    - name: Setup Go\n      uses: actions/setup-go@v6\n\n    # pnpm version is read from the packageManager field in package.json\n    # very broken (4.3, 4.4)\n    - name: Install pnpm\n      uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061\n      with:\n        package_json_file: ui/v2.5/package.json\n\n    - name: Setup Node.js\n      uses: actions/setup-node@v6\n      with:\n        node-version: '20'\n        cache: 'pnpm'\n        cache-dependency-path: ui/v2.5/pnpm-lock.yaml\n\n    - name: Install UI dependencies\n      run: cd ui/v2.5 && pnpm install --frozen-lockfile\n\n    - name: Generate\n      run: make generate\n\n    - name: Cache UI build\n      uses: actions/cache@v5\n      id: cache-ui\n      with:\n        path: ui/v2.5/build\n        key: ${{ runner.os }}-ui-build-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }}\n\n    - name: Validate UI\n      # skip UI validation for pull requests if UI is unchanged\n      if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}\n      run: make validate-ui\n\n    - name: Build UI\n      # skip UI build for pull requests if UI is unchanged (UI was cached)\n      if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}\n      run: make ui\n\n    # Bundle generated Go files + UI build for downstream jobs (test + build)\n    - name: Upload generated artifacts\n      uses: actions/upload-artifact@v7\n      with:\n        name: generated\n        retention-days: 1\n        path: |\n          internal/api/generated_exec.go\n          internal/api/generated_models.go\n          ui/v2.5/build/\n          ui/login/locales/\n\n  # Job 2: Integration tests\n  # Runs natively (no Docker) — only needs Go + GCC (for CGO/SQLite), both on ubuntu-22.04.\n  # Runs in parallel with the build matrix jobs.\n  test:\n    needs: generate\n    runs-on: ubuntu-24.04\n    steps:\n    - uses: actions/checkout@v6\n\n    - name: Setup Go\n      uses: actions/setup-go@v6\n      with:\n        go-version-file: 'go.mod'\n\n    # Places generated Go files + UI build into the working tree so the build compiles\n    - name: Download generated artifacts\n      uses: actions/download-artifact@v8\n      with:\n        name: generated\n\n    - name: Test Backend\n      run: make it\n\n  # Job 3: Cross-compile for all platforms\n  # Each platform gets its own runner and Docker container (ghcr.io/stashapp/compiler:13).\n  # Each build-cc-* make target is self-contained (sets its own GOOS/GOARCH/CC),\n  # so running them in separate containers is functionally identical to one container.\n  # Runs in parallel with the test job.\n  build:\n    needs: generate\n    runs-on: ubuntu-24.04\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n        - platform: windows\n          make-target: build-cc-windows\n          artifact-paths: |\n            dist/stash-win.exe\n          tag: win\n        - platform: macos\n          make-target: build-cc-macos\n          artifact-paths: |\n            dist/stash-macos\n            dist/Stash.app.zip\n          tag: osx\n        - platform: linux\n          make-target: build-cc-linux\n          artifact-paths: |\n            dist/stash-linux\n          tag: linux\n        - platform: linux-arm64v8\n          make-target: build-cc-linux-arm64v8\n          artifact-paths: |\n            dist/stash-linux-arm64v8\n          tag: arm\n        - platform: linux-arm32v7\n          make-target: build-cc-linux-arm32v7\n          artifact-paths: |\n            dist/stash-linux-arm32v7\n          tag: arm\n        - platform: linux-arm32v6\n          make-target: build-cc-linux-arm32v6\n          artifact-paths: |\n            dist/stash-linux-arm32v6\n          tag: arm\n        - platform: freebsd\n          make-target: build-cc-freebsd\n          artifact-paths: |\n            dist/stash-freebsd\n          tag: freebsd\n\n    steps:\n    - uses: actions/checkout@v6\n      with:\n        fetch-depth: 1\n        fetch-tags: true\n\n    - name: Download generated artifacts\n      uses: actions/download-artifact@v8\n      with:\n        name: generated\n\n    - name: Cache Go build\n      uses: actions/cache@v5\n      with:\n        path: .go-cache\n        key: ${{ runner.os }}-go-cache-${{ matrix.platform }}-${{ hashFiles('go.mod', '**/go.sum') }}\n\n    # kept seperate to test timings\n    - name: pull compiler image\n      run: docker pull $COMPILER_IMAGE\n\n    - name: Start build container\n      env:\n        official-build: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/develop') || (github.event_name == 'release' && github.ref != 'refs/tags/latest_develop') }}\n      run: |\n        mkdir -p .go-cache\n        docker run -d --name build --mount type=bind,source=\"$(pwd)\",target=/stash,consistency=delegated --mount type=bind,source=\"$(pwd)/.go-cache\",target=/root/.cache/go-build,consistency=delegated --env OFFICIAL_BUILD=${{ env.official-build }} -w /stash $COMPILER_IMAGE tail -f /dev/null\n\n    - name: Build (${{ matrix.platform }})\n      run: docker exec -t build /bin/bash -c \"make ${{ matrix.make-target }}\"\n\n    - name: Cleanup build container\n      run: docker rm -f -v build\n\n    - name: Upload build artifact\n      uses: actions/upload-artifact@v7\n      with:\n        name: build-${{ matrix.platform }}\n        retention-days: 1\n        path: ${{ matrix.artifact-paths }}\n\n  # Job 4: Release\n  # Waits for both test and build to pass, then collects all platform artifacts\n  # into dist/ for checksums, GitHub releases, and multi-arch Docker push.\n  release:\n    needs: [test, build]\n    runs-on: ubuntu-24.04\n    steps:\n    - uses: actions/checkout@v6\n      with:\n        fetch-depth: 0\n        fetch-tags: true\n\n    # Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories\n    - name: Download all build artifacts\n      uses: actions/download-artifact@v8\n      with:\n        path: artifacts\n\n    # Reassemble platform binaries from matrix job artifacts into a single dist/ directory\n    # make sure that artifacts have executable bit set\n    # upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root\n    - name: Collect binaries\n      run: |\n        mkdir -p dist\n        cp artifacts/build-*/* dist/\n        chmod +x dist/*\n\n    - name: Zip UI\n      run: |\n        cd artifacts/generated/ui/v2.5/build && zip -r ../../../../../dist/stash-ui.zip .\n\n    - name: Generate checksums\n      run: |\n        git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1\n        sha1sum dist/Stash.app.zip dist/stash-* dist/stash-ui.zip | sed 's/dist\\///g' | tee -a CHECKSUMS_SHA1\n        echo \"STASH_VERSION=$(git describe --tags --exclude latest_develop)\" >> $GITHUB_ENV\n        echo \"RELEASE_DATE=$(date +'%Y-%m-%d %H:%M:%S %Z')\" >> $GITHUB_ENV\n\n    - name: Upload Windows binary\n      # only upload binaries for pull requests\n      if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}\n      uses: actions/upload-artifact@v7\n      with:\n        name: stash-win.exe\n        path: dist/stash-win.exe\n\n    - name: Upload macOS binary\n      # only upload binaries for pull requests\n      if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}\n      uses: actions/upload-artifact@v7\n      with:\n        name: stash-macos\n        path: dist/stash-macos\n\n    - name: Upload Linux binary\n      # only upload binaries for pull requests\n      if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}\n      uses: actions/upload-artifact@v7\n      with:\n        name: stash-linux\n        path: dist/stash-linux\n\n    - name: Upload UI\n      # only upload for pull requests\n      if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}\n      uses: actions/upload-artifact@v7\n      with:\n        name: stash-ui.zip\n        path: dist/stash-ui.zip\n\n    - name: Update latest_develop tag\n      if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}\n      run: git tag -f latest_develop; git push -f --tags\n\n    - name: Development Release\n      if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}\n      uses: marvinpinto/action-automatic-releases@v1.1.2\n      with:\n        repo_token: \"${{ secrets.GITHUB_TOKEN }}\"\n        prerelease: true\n        automatic_release_tag: latest_develop\n        title: \"${{ env.STASH_VERSION }}: Latest development build\"\n        files: |\n          dist/Stash.app.zip\n          dist/stash-macos\n          dist/stash-win.exe\n          dist/stash-linux\n          dist/stash-linux-arm64v8\n          dist/stash-linux-arm32v7\n          dist/stash-linux-arm32v6\n          dist/stash-freebsd\n          dist/stash-ui.zip\n          CHECKSUMS_SHA1\n\n    - name: Master release\n      # NOTE: this isn't perfect, but should cover most scenarios\n      # DON'T create tag names starting with \"v\" if they are not stable releases\n      if: ${{ github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v') }}\n      uses: WithoutPants/github-release@v2.0.4\n      with:\n        token: \"${{ secrets.GITHUB_TOKEN }}\"\n        allow_override: true\n        files: |\n          dist/Stash.app.zip\n          dist/stash-macos\n          dist/stash-win.exe\n          dist/stash-linux\n          dist/stash-linux-arm64v8\n          dist/stash-linux-arm32v7\n          dist/stash-linux-arm32v6\n          dist/stash-freebsd\n          dist/stash-ui.zip\n          CHECKSUMS_SHA1\n        gzip: false\n\n    - name: Development Docker\n      if: ${{ github.repository == 'stashapp/stash' && github.event_name == 'push' && github.ref == 'refs/heads/develop' }}\n      env:\n        DOCKER_CLI_EXPERIMENTAL: enabled\n        DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}\n        DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}\n      run: |\n        docker run --rm --privileged tonistiigi/binfmt\n        docker info\n        docker buildx create --name builder --use\n        docker buildx inspect --bootstrap\n        docker buildx ls\n        bash ./docker/ci/x86_64/docker_push.sh development\n\n    - name: Release Docker\n      # NOTE: this isn't perfect, but should cover most scenarios\n      # DON'T create tag names starting with \"v\" if they are not stable releases\n      if: ${{ github.repository == 'stashapp/stash' && github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v') }}\n      env:\n        DOCKER_CLI_EXPERIMENTAL: enabled\n        DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}\n        DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}\n      run: |\n        docker run --rm --privileged tonistiigi/binfmt\n        docker info\n        docker buildx create --name builder --use\n        docker buildx inspect --bootstrap\n        docker buildx ls\n        bash ./docker/ci/x86_64/docker_push.sh latest \"${{ github.event.release.tag_name }}\"\n"
  },
  {
    "path": ".github/workflows/golangci-lint.yml",
    "content": "name: Lint (golangci-lint)\non:\n  push:\n    tags:\n      - v*\n    branches:\n      - master\n      - develop\n      - 'releases/**'\n  pull_request:\n\njobs:\n  golangci:\n    name: lint\n    runs-on: ubuntu-latest\n    steps:\n      # no tags or depth needed for lint\n      - uses: actions/checkout@v6\n      - uses: actions/setup-go@v6\n\n      # generate-backend runs natively (just go generate + touch-ui) — no Docker needed\n      - name: Generate Backend\n        run: make generate-backend\n\n      ## WARN\n      ## using v1, update in a later PR\n      - name: Run golangci-lint\n        uses: golangci/golangci-lint-action@v6"
  },
  {
    "path": ".gitignore",
    "content": "####\n# Go\n####\n\n# Vendored dependencies\nvendor\n\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# GraphQL generated output\ninternal/api/generated_*.go\n\n# Generated locale files\nui/login/locales/*\n\n####\n# Visual Studio\n####\n/.vs\n\n# User-specific stuff\n.idea/**/workspace.xml\n.idea/**/tasks.xml\n.idea/**/usage.statistics.xml\n.idea/**/dictionaries\n.idea/**/shelf\n.vscode\n.devcontainer\n\n# Generated files\n.idea/**/contentModel.xml\n\n# Sensitive or high-churn files\n.idea/**/dataSources/\n.idea/**/dataSources.ids\n.idea/**/dataSources.local.xml\n.idea/**/sqlDataSources.xml\n.idea/**/dynamic.xml\n.idea/**/uiDesigner.xml\n.idea/**/dbnavigator.xml\n\n####\n# Random\n####\n\nnode_modules\n\n*.db\n\n/stash\n/Stash.app\n/phasher\ndist\n.DS_Store\n/.local*\n"
  },
  {
    "path": ".golangci.yml",
    "content": "# options for analysis running\nrun:\n  timeout: 5m\n\nlinters:\n  disable-all: true\n  enable:\n    # Default set of linters from golangci-lint\n    - errcheck\n    - gosimple\n    - govet\n    - ineffassign\n    - staticcheck\n    - typecheck\n    - unused\n    # Linters added by the stash project.\n    # - contextcheck\n    - copyloopvar\n    - dogsled\n    - errchkjson\n    - errorlint\n    # - exhaustive\n    - gocritic\n    # - goerr113\n    - gofmt\n    # - gomnd\n    # - ifshort\n    - misspell\n    # - nakedret\n    - noctx\n    - revive\n    - rowserrcheck\n    - sqlclosecheck\n\n# Project-specific linter overrides\nlinters-settings:\n  gofmt:\n    simplify: false\n\n  errorlint:\n    # Disable errorf because there are false positives, where you don't want to wrap\n    #  an error.\n    errorf: false\n    asserts: true\n    comparison: true\n\n  revive:\n    ignore-generated-header: true\n    severity: error\n    confidence: 0.8\n    rules:\n      - name: blank-imports\n        disabled: true\n      - name: context-as-argument\n      - name: context-keys-type\n      - name: dot-imports\n      - name: error-return\n      - name: error-strings\n      - name: error-naming\n      - name: exported\n        disabled: true\n      - name: if-return\n        disabled: true\n      - name: increment-decrement\n      - name: var-naming\n        disabled: true\n      - name: var-declaration\n      - name: package-comments\n      - name: range\n      - name: receiver-naming\n      - name: time-naming\n      - name: unexported-return\n        disabled: true\n      - name: indent-error-flow\n        disabled: true\n      - name: errorf\n      - name: empty-block\n        disabled: true\n      - name: superfluous-else\n      - name: unused-parameter\n        disabled: true\n      - name: unreachable-code\n      - name: redefines-builtin-id\n\n  rowserrcheck:\n    packages:\n      - github.com/jmoiron/sqlx\n"
  },
  {
    "path": ".gqlgenc.yml",
    "content": "model:\n  package: graphql\n  filename: ./pkg/stashbox/graphql/generated_models.go\nclient:\n  package: graphql\n  filename: ./pkg/stashbox/graphql/generated_client.go\nmodels:\n  Date:\n    model: github.com/99designs/gqlgen/graphql.String\nendpoint:\n  # This points to stashdb.org currently, but can be directed at any stash-box \n  # instance. It is used for generation only.\n  url: https://stashdb.org/graphql\nquery:  \n   - \"./graphql/stash-box/*.graphql\"\ngenerate:\n  clientV2: false\n  clientInterfaceName: \"StashBoxGraphQLClient\"\n"
  },
  {
    "path": ".idea/codeStyles/codeStyleConfig.xml",
    "content": "<component name=\"ProjectCodeStyleConfiguration\">\n  <state>\n    <option name=\"PREFERRED_PROJECT_CODE_STYLE\" value=\"Default\" />\n  </state>\n</component>"
  },
  {
    "path": ".idea/dataSources.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"DataSourceManagerImpl\" format=\"xml\" multifile-model=\"true\">\n    <data-source source=\"LOCAL\" name=\"stash-go\" uuid=\"b8d0eb6d-e8e4-4865-8c0f-2798f78345a7\">\n      <driver-ref>sqlite.xerial</driver-ref>\n      <synchronize>true</synchronize>\n      <jdbc-driver>org.sqlite.JDBC</jdbc-driver>\n      <jdbc-url>jdbc:sqlite:$USER_HOME$/.stash/stash-go.sqlite</jdbc-url>\n      <driver-properties>\n        <property name=\"enable_load_extension\" value=\"true\" />\n      </driver-properties>\n    </data-source>\n  </component>\n</project>"
  },
  {
    "path": ".idea/encodings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"Encoding\" addBOMForNewFiles=\"with NO BOM\" />\n</project>"
  },
  {
    "path": ".idea/go.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"WEB_MODULE\" version=\"4\">\n  <component name=\"Go\" enabled=\"true\" />\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <excludeFolder url=\"file://$MODULE_DIR$/certs\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/dist\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/ui/v2.5/build\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/ui/v2.5/node_modules\" />\n    </content>\n    <orderEntry type=\"inheritedJdk\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": ".idea/misc.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"JavaScriptSettings\">\n    <option name=\"languageLevel\" value=\"ES6\" />\n  </component>\n</project>"
  },
  {
    "path": ".idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/go.iml\" filepath=\"$PROJECT_DIR$/.idea/go.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": ".idea/sqldialects.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"SqlDialectMappings\">\n    <file url=\"PROJECT\" dialect=\"SQLite\" />\n  </component>\n</project>"
  },
  {
    "path": ".idea/vcs.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"VcsDirectoryMappings\">\n    <mapping directory=\"$PROJECT_DIR$\" vcs=\"Git\" />\n  </component>\n</project>"
  },
  {
    "path": ".mockery.yml",
    "content": "dir: ./pkg/models\nname: \".*ReaderWriter\"\noutpkg: mocks\noutput: ./pkg/models/mocks\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "Makefile",
    "content": "IS_WIN_SHELL =\nifeq (${SHELL}, sh.exe)\n  IS_WIN_SHELL = true\nendif\nifeq (${SHELL}, cmd)\n  IS_WIN_SHELL = true\nendif\n\nifdef IS_WIN_SHELL\n  RM := del /s /q\n  RMDIR := rmdir /s /q\n  NOOP := @@\nelse\n  RM := rm -f\n  RMDIR := rm -rf\n  NOOP := @:\nendif\n\n# set LDFLAGS environment variable to any extra ldflags required\nLDFLAGS := $(LDFLAGS)\n\n# set OUTPUT environment variable to generate a specific binary name\n# this will apply to both `stash` and `phasher`, so build them separately\n# alternatively use STASH_OUTPUT or PHASHER_OUTPUT to set the value individually\nifdef OUTPUT\n  STASH_OUTPUT := $(OUTPUT)\n  PHASHER_OUTPUT := $(OUTPUT)\nendif\nifdef STASH_OUTPUT\n  STASH_OUTPUT := -o $(STASH_OUTPUT)\nendif\nifdef PHASHER_OUTPUT\n  PHASHER_OUTPUT := -o $(PHASHER_OUTPUT)\nendif\n\n# set GO_BUILD_FLAGS environment variable to any extra build flags required\nGO_BUILD_FLAGS := $(GO_BUILD_FLAGS)\n\n# set GO_BUILD_TAGS environment variable to any extra build tags required\nGO_BUILD_TAGS := $(GO_BUILD_TAGS)\nGO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions\n\n# set STASH_NOLEGACY environment variable or uncomment to disable legacy browser support\n# STASH_NOLEGACY := true\n\n# set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps\n# STASH_SOURCEMAPS := true\n\nexport CGO_ENABLED := 1\n\n# define COMPILER_IMAGE for cross-compilation docker container\nifndef COMPILER_IMAGE\n  COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest\nendif\n\n.PHONY: release\nrelease: pre-ui generate ui build-release\n\n# targets to set various build flags\n# use combinations on the make command-line to configure a build, e.g.:\n# for a static-pie release build: `make flags-static-pie flags-release stash`\n# for a static windows debug build: `make flags-static-windows stash`\n\n# $(NOOP) prevents \"nothing to be done\" warnings\n\n.PHONY: flags-release\nflags-release:\n\t$(NOOP)\n\t$(eval LDFLAGS += -s -w)\n\t$(eval GO_BUILD_FLAGS += -trimpath)\n\n.PHONY: flags-pie\nflags-pie:\n\t$(NOOP)\n\t$(eval GO_BUILD_FLAGS += -buildmode=pie)\n\n.PHONY: flags-static\nflags-static:\n\t$(NOOP)\n\t$(eval LDFLAGS += -extldflags=-static)\n\t$(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo netgo)\n\n.PHONY: flags-static-pie\nflags-static-pie:\n\t$(NOOP)\n\t$(eval LDFLAGS += -extldflags=-static-pie)\n\t$(eval GO_BUILD_FLAGS += -buildmode=pie)\n\t$(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo netgo)\n\n# identical to flags-static-pie, but excluding netgo, which is not needed on windows\n.PHONY: flags-static-windows\nflags-static-windows:\n\t$(NOOP)\n\t$(eval LDFLAGS += -extldflags=-static-pie)\n\t$(eval GO_BUILD_FLAGS += -buildmode=pie)\n\t$(eval GO_BUILD_TAGS += sqlite_omit_load_extension osusergo)\n\n.PHONY: build-info\nbuild-info:\nifndef BUILD_DATE\n\t$(eval BUILD_DATE := $(shell go run scripts/getDate.go))\nendif\nifndef GITHASH\n\t$(eval GITHASH := $(shell git rev-parse --short HEAD))\nendif\nifndef STASH_VERSION\n\t$(eval STASH_VERSION := $(shell git describe --tags --exclude latest_develop))\nendif\nifndef OFFICIAL_BUILD\n\t$(eval OFFICIAL_BUILD := false)\nendif\n\n.PHONY: build-flags\nbuild-flags: build-info\n\t$(eval BUILD_LDFLAGS := $(LDFLAGS))\n\t$(eval BUILD_LDFLAGS += -X 'github.com/stashapp/stash/internal/build.buildstamp=$(BUILD_DATE)')\n\t$(eval BUILD_LDFLAGS += -X 'github.com/stashapp/stash/internal/build.githash=$(GITHASH)')\n\t$(eval BUILD_LDFLAGS += -X 'github.com/stashapp/stash/internal/build.version=$(STASH_VERSION)')\n\t$(eval BUILD_LDFLAGS += -X 'github.com/stashapp/stash/internal/build.officialBuild=$(OFFICIAL_BUILD)')\n\t$(eval BUILD_FLAGS := -v -tags \"$(GO_BUILD_TAGS)\" $(GO_BUILD_FLAGS) -ldflags \"$(BUILD_LDFLAGS)\")\n\n.PHONY: stash\nstash: build-flags\n\tgo build $(STASH_OUTPUT) $(BUILD_FLAGS) ./cmd/stash\n\n.PHONY: phasher\nphasher: build-flags\n\tgo build $(PHASHER_OUTPUT) $(BUILD_FLAGS) ./cmd/phasher\n\n# builds dynamically-linked debug binaries\n.PHONY: build\nbuild: stash\n\n# builds dynamically-linked PIE release binaries\n.PHONY: build-release\nbuild-release: flags-release flags-pie build\n\n# compile and bundle into Stash.app\n# for when on macOS itself\n.PHONY: stash-macapp\nstash-macapp: STASH_OUTPUT := -o stash\nstash-macapp: flags-release flags-pie stash\n\trm -rf Stash.app\n\tcp -R scripts/macos-bundle Stash.app\n\tmkdir Stash.app/Contents/MacOS\n\tcp stash Stash.app/Contents/MacOS/stash\n\n# build-cc- targets should be run within the compiler docker container\n\n.PHONY: build-cc-windows\nbuild-cc-windows: export GOOS := windows\nbuild-cc-windows: export GOARCH := amd64\nbuild-cc-windows: export CC := x86_64-w64-mingw32-gcc\nbuild-cc-windows: STASH_OUTPUT := -o dist/stash-win.exe\nbuild-cc-windows: PHASHER_OUTPUT :=-o dist/phasher-win.exe\nbuild-cc-windows: flags-release\nbuild-cc-windows: flags-static-windows\nbuild-cc-windows: build\n\n.PHONY: build-cc-macos-intel\nbuild-cc-macos-intel: export GOOS := darwin\nbuild-cc-macos-intel: export GOARCH := amd64\nbuild-cc-macos-intel: export CC := o64-clang\nbuild-cc-macos-intel: STASH_OUTPUT := -o dist/stash-macos-intel\nbuild-cc-macos-intel: PHASHER_OUTPUT := -o dist/phasher-macos-intel\nbuild-cc-macos-intel: flags-release\n# can't use static build for macOS\nbuild-cc-macos-intel: flags-pie\nbuild-cc-macos-intel: build\n\n.PHONY: build-cc-macos-arm\nbuild-cc-macos-arm: export GOOS := darwin\nbuild-cc-macos-arm: export GOARCH := arm64\nbuild-cc-macos-arm: export CC := oa64e-clang\nbuild-cc-macos-arm: STASH_OUTPUT := -o dist/stash-macos-arm\nbuild-cc-macos-arm: PHASHER_OUTPUT := -o dist/phasher-macos-arm\nbuild-cc-macos-arm: flags-release\n# can't use static build for macOS\nbuild-cc-macos-arm: flags-pie\nbuild-cc-macos-arm: build\n\n.PHONY: build-cc-macos\nbuild-cc-macos:\n\tmake build-cc-macos-arm\n\tmake build-cc-macos-intel\n\n\t# Combine into universal binaries\n\tlipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm\n\trm dist/stash-macos-intel dist/stash-macos-arm\n\n\t# Place into bundle and zip up\n\trm -rf dist/Stash.app\n\tcp -R scripts/macos-bundle dist/Stash.app\n\tmkdir dist/Stash.app/Contents/MacOS\n\tcp dist/stash-macos dist/Stash.app/Contents/MacOS/stash\n\tcd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app\n\trm -rf dist/Stash.app\n\n.PHONY: build-cc-macos-phasher\nbuild-cc-macos-phasher:\n\tmake build-cc-macos-arm\n\tmake build-cc-macos-intel\n\n\t# Combine into universal binaries\n\tlipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm\n\trm dist/phasher-macos-intel dist/phasher-macos-arm\n\t# do not bundle phasher\n\n.PHONY: build-cc-freebsd\nbuild-cc-freebsd: export GOOS := freebsd\nbuild-cc-freebsd: export GOARCH := amd64\nbuild-cc-freebsd: export CC := clang -target x86_64-unknown-freebsd12.0 --sysroot=/opt/cross-freebsd\nbuild-cc-freebsd: STASH_OUTPUT := -o dist/stash-freebsd\nbuild-cc-freebsd: PHASHER_OUTPUT := -o dist/phasher-freebsd\nbuild-cc-freebsd: flags-release\nbuild-cc-freebsd: flags-static-pie\nbuild-cc-freebsd: build\n\n.PHONY: build-cc-linux\nbuild-cc-linux: export GOOS := linux\nbuild-cc-linux: export GOARCH := amd64\nbuild-cc-linux: STASH_OUTPUT := -o dist/stash-linux\nbuild-cc-linux: PHASHER_OUTPUT := -o dist/phasher-linux\nbuild-cc-linux: flags-release\nbuild-cc-linux: flags-static-pie\nbuild-cc-linux: build\n\n.PHONY: build-cc-linux-arm64v8\nbuild-cc-linux-arm64v8: export GOOS := linux\nbuild-cc-linux-arm64v8: export GOARCH := arm64\nbuild-cc-linux-arm64v8: export CC := aarch64-linux-gnu-gcc\nbuild-cc-linux-arm64v8: STASH_OUTPUT := -o dist/stash-linux-arm64v8\nbuild-cc-linux-arm64v8: PHASHER_OUTPUT := -o dist/phasher-linux-arm64v8\nbuild-cc-linux-arm64v8: flags-release\nbuild-cc-linux-arm64v8: flags-static-pie\nbuild-cc-linux-arm64v8: build\n\n.PHONY: build-cc-linux-arm32v7\nbuild-cc-linux-arm32v7: export GOOS := linux\nbuild-cc-linux-arm32v7: export GOARCH := arm\nbuild-cc-linux-arm32v7: export GOARM := 7\nbuild-cc-linux-arm32v7: export CC := arm-linux-gnueabi-gcc -march=armv7-a\nbuild-cc-linux-arm32v7: STASH_OUTPUT := -o dist/stash-linux-arm32v7\nbuild-cc-linux-arm32v7: PHASHER_OUTPUT := -o dist/phasher-linux-arm32v7\nbuild-cc-linux-arm32v7: flags-release\nbuild-cc-linux-arm32v7: flags-static\nbuild-cc-linux-arm32v7: build\n\n.PHONY: build-cc-linux-arm32v6\nbuild-cc-linux-arm32v6: export GOOS := linux\nbuild-cc-linux-arm32v6: export GOARCH := arm\nbuild-cc-linux-arm32v6: export GOARM := 6\nbuild-cc-linux-arm32v6: export CC := arm-linux-gnueabi-gcc\nbuild-cc-linux-arm32v6: STASH_OUTPUT := -o dist/stash-linux-arm32v6\nbuild-cc-linux-arm32v6: PHASHER_OUTPUT := -o dist/phasher-linux-arm32v6\nbuild-cc-linux-arm32v6: flags-release\nbuild-cc-linux-arm32v6: flags-static\nbuild-cc-linux-arm32v6: build\n\n.PHONY: build-cc-all\nbuild-cc-all:\n\tmake build-cc-windows\n\tmake build-cc-macos\n\tmake build-cc-linux\n\tmake build-cc-linux-arm64v8\n\tmake build-cc-linux-arm32v7\n\tmake build-cc-linux-arm32v6\n\tmake build-cc-freebsd\n\n.PHONY: touch-ui\ntouch-ui:\nifdef IS_WIN_SHELL\n\t@if not exist \"ui\\\\v2.5\\\\build\" mkdir ui\\\\v2.5\\\\build\n\t@type nul >> ui/v2.5/build/index.html\nelse\n\t@mkdir -p ui/v2.5/build\n\t@touch ui/v2.5/build/index.html\nendif\n\n# Regenerates GraphQL files\n.PHONY: generate\ngenerate: generate-backend generate-ui\n\n.PHONY: generate-ui\ngenerate-ui:\n\tcd ui/v2.5 && npm run gqlgen\n\n.PHONY: generate-backend\ngenerate-backend: touch-ui\n\tgo generate ./cmd/stash\n\n.PHONY: generate-login-locale\ngenerate-login-locale:\n\tgo generate ./ui\n\n.PHONY: generate-dataloaders\ngenerate-dataloaders:\n\tgo generate ./internal/api/loaders\n\n# Regenerates stash-box client files\n.PHONY: generate-stash-box-client\ngenerate-stash-box-client:\n\tgo run github.com/Yamashou/gqlgenc\n\n# Runs gofmt -w on the project's source code, modifying any files that do not match its style.\n.PHONY: fmt\nfmt:\n\tgo fmt ./...\n\n.PHONY: lint\nlint:\n\tgolangci-lint run\n\n# runs unit tests - excluding integration tests\n.PHONY: test\ntest:\n\tgo test ./...\n\n# runs all tests - including integration tests\n.PHONY: it\nit:\n\t$(eval GO_BUILD_TAGS += integration)\n\tgo test -tags \"$(GO_BUILD_TAGS)\" ./...\n\n# generates test mocks\n.PHONY: generate-test-mocks\ngenerate-test-mocks:\n\tgo run github.com/vektra/mockery/v2\n\n# runs server\n# sets the config file to use the local dev config\n.PHONY: server-start\nserver-start: export STASH_CONFIG_FILE := config.yml\nserver-start: build-flags\nifdef IS_WIN_SHELL\n\t@if not exist \".local\" mkdir .local\nelse\n\t@mkdir -p .local\nendif\n\tcd .local && go run $(BUILD_FLAGS) ../cmd/stash\n\n# removes local dev config files\n.PHONY: server-clean\nserver-clean:\n\t$(RMDIR) .local\n\n# installs UI dependencies. Run when first cloning repository, or if UI\n# dependencies have changed\n# If CI is set, configures pnpm to use a local store to avoid\n# putting .pnpm-store in /stash\n# NOTE: to run in the docker build container, using the existing\n# node_modules folder, rename the .modules.yaml to .modules.yaml.bak\n# and a new one will be generated. This will need to be reversed after\n# building.\n.PHONY: pre-ui\npre-ui:\nifdef CI\n\tcd ui/v2.5 && pnpm config set store-dir ~/.pnpm-store && pnpm install --frozen-lockfile\nelse\n\tcd ui/v2.5 && pnpm install --frozen-lockfile\nendif\n\n.PHONY: ui-env\nui-env: build-info\n\t$(eval export VITE_APP_DATE := $(BUILD_DATE))\n\t$(eval export VITE_APP_GITHASH := $(GITHASH))\n\t$(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION))\nifdef STASH_NOLEGACY\n\t$(eval export VITE_APP_NOLEGACY := true)\nendif\nifdef STASH_SOURCEMAPS\n\t$(eval export VITE_APP_SOURCEMAPS := true)\nendif\n\n.PHONY: ui\nui: ui-only generate-login-locale\n\n.PHONY: ui-only\nui-only: ui-env\n\tcd ui/v2.5 && npm run build\n\n.PHONY: zip-ui\nzip-ui:\n\trm -f dist/stash-ui.zip\n\tcd ui/v2.5/build && zip -r ../../../dist/stash-ui.zip .\n\n.PHONY: ui-start\nui-start: ui-env\n\tcd ui/v2.5 && npm run start -- --host\n\n.PHONY: fmt-ui\nfmt-ui:\n\tcd ui/v2.5 && npm run format\n\n# runs all of the frontend PR-acceptance steps\n.PHONY: validate-ui\nvalidate-ui:\n\tcd ui/v2.5 && npm run validate\n\n# these targets run the same steps as fmt-ui and validate-ui, but only on files that have changed\nfmt-ui-quick:\n\tcd ui/v2.5 && \\\n\tfiles=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \\\n\tif [ -n \"$$files\" ]; then \\\n\t  npm run prettier -- --write $$files; \\\n\tfi\n\n# does not run tsc checks, as they are slow\nvalidate-ui-quick:\n\tcd ui/v2.5 && \\\n\ttsfiles=$$(git diff --name-only --relative --diff-filter d src | grep -e \"\\.tsx\\?\\$$\"); \\\n\tscssfiles=$$(git diff --name-only --relative --diff-filter d src | grep \"\\.scss\"); \\\n\tprettyfiles=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \\\n\tif [ -n \"$$tsfiles\" ]; then npm run eslint -- $$tsfiles; fi && \\\n\tif [ -n \"$$scssfiles\" ]; then npm run stylelint -- $$scssfiles; fi && \\\n\tif [ -n \"$$prettyfiles\" ]; then npm run prettier -- --check $$prettyfiles; fi\n\n# runs all of the backend PR-acceptance steps\n.PHONY: validate-backend\nvalidate-backend: lint it\n\n# runs all of the tests and checks required for a PR to be accepted\n.PHONY: validate\nvalidate: validate-ui validate-backend\n\n# locally builds and tags a 'stash/build' docker image\n.PHONY: docker-build\ndocker-build: build-info\n\tdocker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/build -f docker/build/x86_64/Dockerfile .\n\n# locally builds and tags a 'stash/cuda-build' docker image\n.PHONY: docker-cuda-build\ndocker-cuda-build: build-info\n\tdocker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/cuda-build -f docker/build/x86_64/Dockerfile-CUDA .\n\n# start the build container - for cross compilation\n# this is adapted from the github actions build.yml file\n.PHONY: start-compiler-container\nstart-compiler-container:\n\tdocker run -d --name build --mount type=bind,source=\"$(PWD)\",target=/stash,consistency=delegated $(EXTRA_CONTAINER_ARGS) -w /stash $(COMPILER_IMAGE) tail -f /dev/null\n\n# run the cross-compilation using\n# docker exec -t build /bin/bash -c \"make build-cc-<platform>\"\n\n.PHONY: remove-compiler-container\nremove-compiler-container:\n\tdocker rm -f -v build"
  },
  {
    "path": "README.md",
    "content": "# Stash\n\n[![Build](https://github.com/stashapp/stash/actions/workflows/build.yml/badge.svg?branch=develop&event=push)](https://github.com/stashapp/stash/actions/workflows/build.yml)\n[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub')\n[![GitHub Sponsors](https://img.shields.io/github/sponsors/stashapp?logo=github)](https://github.com/sponsors/stashapp)\n[![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp)\n[![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash)\n[![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt)\n[![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest)\n[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty)\n\n### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.**\n\n![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png)\n\n* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.\n* Stash supports a wide variety of both video and image formats.\n* You can tag videos and find them later.\n* Stash provides statistics about performers, tags, studios and more.\n\nYou can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action.\n\nFor further information you can consult the [documentation](https://docs.stashapp.cc) or access the in-app manual from within the application (also available at [docs.stashapp.cc/in-app-manual](https://docs.stashapp.cc/in-app-manual)).\n\n# Installing Stash\n\nStep-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/).\n\n#### Windows Users:\n\nAs of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._  \nAt least Windows 10 or Server 2016 is required.\n\n#### Mac Users:\n\nAs of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.  \nStash can still be run through docker on older versions of macOS.\n\n<img src=\"docs/readme_assets/windows_logo.svg\" width=\"100%\" height=\"75\"> Windows | <img src=\"docs/readme_assets/mac_logo.svg\" width=\"100%\" height=\"75\"> macOS | <img src=\"docs/readme_assets/linux_logo.svg\" width=\"100%\" height=\"75\"> Linux | <img src=\"docs/readme_assets/docker_logo.svg\" width=\"100%\" height=\"75\"> Docker\n:---:|:---:|:---:|:---:\n[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>\n\nDownload links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page.\n\n## First Run\n\n#### Windows/macOS Users: Security Prompt\n\nOn Windows or macOS, running the app might present a security prompt since the application binary isn't yet signed. \n\n- On Windows, bypass this by clicking \"more info\" and then the \"run anyway\" button.\n- On macOS, Control+Click the app, click \"Open\", and then \"Open\" again.\n\n#### ffmpeg\n\nStash requires FFmpeg. If you don't have it installed, Stash will prompt you to download a copy during setup. It is recommended that Linux users install `ffmpeg` from their distro's package manager.\n\n# Usage\n\n## Quickstart Guide\n\nStash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`.\n\nOn first run, Stash will prompt you for some configuration options and media directories to index, called \"Scanning\" in Stash. After scanning, your media will be available for browsing, curating, editing, and tagging.\n\nStash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Manual/Scraping.md), which integrate directly into Stash. Identifying an entire collection will typically require a mix of multiple sources:\n- The stashapp team maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/).\n- Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in this guide to [Accessing Stash-Boxes](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stash-boxes/).\n- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to `Settings → Metadata Providers → Available Scrapers → Community (stable)`. These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).\n- All of the above methods of scraping data into Stash are also covered in more detail in our [Guide to Scraping](https://docs.stashapp.cc/beginner-guides/guide-to-scraping/).\n\n<sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>\n\n# Translation\n\n[![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](https://translate.codeberg.org/engage/stash/)\n\nStash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks!\n\nThe badge below shows the current translation status of Stash across all supported languages:\n\n[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/)\n\n# Support & Resources\n\nNeed help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.\n\n- Documentation\n  - Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting.\n  - In-app manual: press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online: https://docs.stashapp.cc/in-app-manual.\n  - FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers.\n  - Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-to’s and tips.\n  \n- Community & discussion\n  - Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions.\n  - Discord: https://discord.gg/2TsNFKt - real-time chat and community support.\n  - GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions.\n  - Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space.\n\n- Community scrapers & plugins\n  - Metadata sources: https://docs.stashapp.cc/metadata-sources/\n  - Plugins: https://docs.stashapp.cc/plugins/\n  - Themes: https://docs.stashapp.cc/themes/\n  - Other projects: https://docs.stashapp.cc/other-projects/\n\n# For Developers\n\nPull requests are welcome! \n\nSee [Development](docs/DEVELOPMENT.md) and [Contributing](docs/CONTRIBUTING.md) for information on working with the codebase, getting a local development setup, and contributing changes.\n"
  },
  {
    "path": "cmd/phasher/main.go",
    "content": "// TODO: document in README.md\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\n\tflag \"github.com/spf13/pflag\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/hash/imagephash\"\n\t\"github.com/stashapp/stash/pkg/hash/videophash\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc customUsage() {\n\tfmt.Fprintf(os.Stderr, \"Usage:\\n\")\n\tfmt.Fprintf(os.Stderr, \"%s [OPTIONS] FILE...\\n\\nOptions:\\n\", os.Args[0])\n\tflag.PrintDefaults()\n}\n\nfunc printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {\n\t// Determine if this is a video or image file based on extension\n\text := filepath.Ext(inputfile)\n\text = ext[1:] // remove the leading dot\n\n\t// Common image extensions\n\timageExts := map[string]bool{\n\t\t\"jpg\": true, \"jpeg\": true, \"png\": true, \"gif\": true, \"webp\": true, \"bmp\": true, \"avif\": true,\n\t}\n\n\tif imageExts[ext] {\n\t\treturn printImagePhash(ff, inputfile, quiet)\n\t}\n\n\treturn printVideoPhash(ff, ffp, inputfile, quiet)\n}\n\nfunc printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {\n\tffvideoFile, err := ffp.NewVideoFile(inputfile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// All we need for videophash.Generate() is\n\t// videoFile.Path (from BaseFile)\n\t// videoFile.Duration\n\t// The rest of the struct isn't needed.\n\tvf := &models.VideoFile{\n\t\tBaseFile: &models.BaseFile{Path: inputfile},\n\t\tDuration: ffvideoFile.FileDuration,\n\t}\n\n\tphash, err := videophash.Generate(ff, vf)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif *quiet {\n\t\tfmt.Printf(\"%x\\n\", *phash)\n\t} else {\n\t\tfmt.Printf(\"%x %v\\n\", *phash, vf.Path)\n\t}\n\treturn nil\n}\n\nfunc printImagePhash(ff *ffmpeg.FFMpeg, inputfile string, quiet *bool) error {\n\timgFile := &models.ImageFile{\n\t\tBaseFile: &models.BaseFile{Path: inputfile},\n\t}\n\n\tphash, err := imagephash.Generate(ff, imgFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif *quiet {\n\t\tfmt.Printf(\"%x\\n\", *phash)\n\t} else {\n\t\tfmt.Printf(\"%x %v\\n\", *phash, imgFile.Path)\n\t}\n\treturn nil\n}\n\nfunc getPaths() (string, string) {\n\tffmpegPath, _ := exec.LookPath(\"ffmpeg\")\n\tffprobePath, _ := exec.LookPath(\"ffprobe\")\n\n\treturn ffmpegPath, ffprobePath\n}\n\nfunc main() {\n\tflag.Usage = customUsage\n\tquiet := flag.BoolP(\"quiet\", \"q\", false, \"print only the phash\")\n\thelp := flag.BoolP(\"help\", \"h\", false, \"print this help output\")\n\tflag.Parse()\n\n\tif *help {\n\t\tflag.Usage()\n\t\tos.Exit(2)\n\t}\n\n\targs := flag.Args()\n\n\tif len(args) < 1 {\n\t\tfmt.Fprintf(os.Stderr, \"Missing FILE argument.\\n\")\n\t\tflag.Usage()\n\t\tos.Exit(2)\n\t}\n\n\tif len(args) > 1 {\n\t\tfmt.Fprintln(os.Stderr, \"Files will be processed sequentially! If required, use e.g. GNU Parallel to run concurrently.\")\n\t\tfmt.Fprintf(os.Stderr, \"Example: parallel %v ::: *.mp4\\n\", os.Args[0])\n\t}\n\n\tffmpegPath, ffprobePath := getPaths()\n\tencoder := ffmpeg.NewEncoder(ffmpegPath)\n\t// don't need to InitHWSupport, phashing doesn't use hw acceleration\n\tffprobe := ffmpeg.NewFFProbe(ffprobePath)\n\n\tfor _, item := range args {\n\t\tif err := printPhash(encoder, ffprobe, item, quiet); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t}\n\t}\n\n}\n"
  },
  {
    "path": "cmd/stash/main.go",
    "content": "//go:generate go run github.com/99designs/gqlgen\npackage main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"runtime/debug\"\n\t\"runtime/pprof\"\n\t\"syscall\"\n\n\t\"github.com/spf13/pflag\"\n\n\t\"github.com/stashapp/stash/internal/api\"\n\t\"github.com/stashapp/stash/internal/build\"\n\t\"github.com/stashapp/stash/internal/desktop\"\n\t\"github.com/stashapp/stash/internal/log\"\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/ui\"\n\n\t_ \"github.com/golang-migrate/migrate/v4/database/sqlite3\"\n\t_ \"github.com/golang-migrate/migrate/v4/source/file\"\n)\n\nvar exitCode = 0\n\nfunc main() {\n\tdefer func() {\n\t\tif exitCode != 0 {\n\t\t\tos.Exit(exitCode)\n\t\t}\n\t}()\n\n\tdefer recoverPanic()\n\n\tinitLogTemp()\n\n\thelpFlag := false\n\tpflag.BoolVarP(&helpFlag, \"help\", \"h\", false, \"show this help text and exit\")\n\n\tversionFlag := false\n\tpflag.BoolVarP(&versionFlag, \"version\", \"v\", false, \"show version number and exit\")\n\n\tcpuProfilePath := \"\"\n\tpflag.StringVar(&cpuProfilePath, \"cpuprofile\", \"\", \"write cpu profile to file\")\n\n\tpflag.Parse()\n\n\tif helpFlag {\n\t\tpflag.Usage()\n\t\treturn\n\t}\n\n\tif versionFlag {\n\t\tfmt.Println(build.VersionString())\n\t\treturn\n\t}\n\n\tcfg, err := config.Initialize()\n\tif err != nil {\n\t\texitError(fmt.Errorf(\"config initialization error: %w\", err))\n\t\treturn\n\t}\n\n\tl := initLog(cfg)\n\n\tif cpuProfilePath != \"\" {\n\t\tif err := initProfiling(cpuProfilePath); err != nil {\n\t\t\texitError(err)\n\t\t\treturn\n\t\t}\n\t\tdefer pprof.StopCPUProfile()\n\t}\n\n\t// initialise desktop.IsDesktop here so that it doesn't get affected by\n\t// ffmpeg hardware checks later on\n\tdesktop.InitIsDesktop()\n\n\tmgr, err := manager.Initialize(cfg, l)\n\tif err != nil {\n\t\texitError(fmt.Errorf(\"manager initialization error: %w\", err))\n\t\treturn\n\t}\n\tdefer mgr.Shutdown()\n\n\tserver, err := api.Initialize()\n\tif err != nil {\n\t\texitError(fmt.Errorf(\"api initialization error: %w\", err))\n\t\treturn\n\t}\n\tdefer server.Shutdown()\n\n\texit := make(chan int)\n\n\tgo func() {\n\t\terr := server.Start()\n\t\tif !errors.Is(err, http.ErrServerClosed) {\n\t\t\texitError(fmt.Errorf(\"http server error: %w\", err))\n\t\t\texit <- 1\n\t\t}\n\t}()\n\n\tgo handleSignals(exit)\n\tdesktop.Start(exit, &ui.FaviconProvider)\n\n\texitCode = <-exit\n}\n\n// initLogTemp initializes a temporary logger for use before the config is loaded.\n// Logs only error level message to stderr.\nfunc initLogTemp() *log.Logger {\n\tl := log.NewLogger()\n\tl.Init(\"\", true, \"Error\", 0)\n\tlogger.Logger = l\n\n\treturn l\n}\n\nfunc initLog(cfg *config.Config) *log.Logger {\n\tl := log.NewLogger()\n\tl.Init(cfg.GetLogFile(), cfg.GetLogOut(), cfg.GetLogLevel(), cfg.GetLogFileMaxSize())\n\tlogger.Logger = l\n\n\treturn l\n}\n\nfunc initProfiling(path string) error {\n\tf, err := os.Create(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to create CPU profile file: %v\", err)\n\t}\n\n\tif err = pprof.StartCPUProfile(f); err != nil {\n\t\treturn fmt.Errorf(\"could not start CPU profiling: %v\", err)\n\t}\n\n\tlogger.Infof(\"profiling to %s\", path)\n\n\treturn nil\n}\n\nfunc recoverPanic() {\n\tif err := recover(); err != nil {\n\t\texitCode = 1\n\t\tlogger.Errorf(\"panic: %v\\n%s\", err, debug.Stack())\n\t\tif desktop.IsDesktop() {\n\t\t\tdesktop.FatalError(fmt.Errorf(\"Panic: %v\", err))\n\t\t}\n\t}\n}\n\nfunc exitError(err error) {\n\texitCode = 1\n\tlogger.Error(err)\n\t// #5784 - log to stdout as well as the logger\n\t// this does mean that it will log twice if the logger is set to stdout\n\tfmt.Println(err)\n\tif desktop.IsDesktop() {\n\t\tdesktop.FatalError(err)\n\t}\n}\n\nfunc handleSignals(exit chan<- int) {\n\t// handle signals\n\tsignals := make(chan os.Signal, 1)\n\tsignal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)\n\n\t<-signals\n\texit <- 0\n}\n"
  },
  {
    "path": "cmd/stash/main_test.go",
    "content": "package main\n\nimport \"testing\"\n\nfunc TestStub(t *testing.T) {\n\n}\n"
  },
  {
    "path": "docker/build/x86_64/Dockerfile",
    "content": "# This dockerfile should be built with `make docker-build` from the stash root.\n\n# Build Frontend\nFROM node:24-alpine AS frontend\nRUN apk add --no-cache make git\n## cache node_modules separately\nCOPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/\nWORKDIR /stash\nCOPY Makefile /stash/\nCOPY ./graphql /stash/graphql/\nCOPY ./ui /stash/ui/\n# pnpm install with npm\nRUN npm install -g pnpm\nRUN make pre-ui\nRUN make generate-ui\nARG GITHASH\nARG STASH_VERSION\nRUN BUILD_DATE=$(date +\"%Y-%m-%d %H:%M:%S\") make ui-only\n\n# Build Backend\nFROM golang:1.24.3-alpine AS backend\nRUN apk add --no-cache make alpine-sdk\nWORKDIR /stash\nCOPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/\nCOPY ./graphql /stash/graphql/\nCOPY ./scripts /stash/scripts/\nCOPY ./pkg /stash/pkg/\nCOPY ./cmd /stash/cmd/\nCOPY ./internal /stash/internal/\n# needed for generate-login-locale\nCOPY ./ui /stash/ui/\nRUN make generate-backend generate-login-locale\nCOPY --from=frontend /stash /stash/\nARG GITHASH\nARG STASH_VERSION\nRUN make flags-release flags-pie stash\n\n# Final Runnable Image\nFROM alpine:latest\nRUN apk add --no-cache ca-certificates vips-tools ffmpeg\nCOPY --from=backend /stash/stash /usr/bin/\nENV STASH_CONFIG_FILE=/root/.stash/config.yml\nEXPOSE 9999\nENTRYPOINT [\"stash\"]\n"
  },
  {
    "path": "docker/build/x86_64/Dockerfile-CUDA",
    "content": "# This dockerfile should be built with `make docker-cuda-build` from the stash root.\nARG CUDA_VERSION=12.8.0\n\n# Build Frontend\nFROM node:20-alpine AS frontend\nRUN apk add --no-cache make git\n## cache node_modules separately\nCOPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/\nWORKDIR /stash\nCOPY Makefile /stash/\nCOPY ./graphql /stash/graphql/\nCOPY ./ui /stash/ui/\n# pnpm install with npm\nRUN npm install -g pnpm\nRUN make pre-ui\nRUN make generate-ui\nARG GITHASH\nARG STASH_VERSION\nRUN BUILD_DATE=$(date +\"%Y-%m-%d %H:%M:%S\") make ui-only\n\n# Build Backend\nFROM golang:1.24.3-bullseye AS backend\nRUN apt update && apt install -y build-essential golang\nWORKDIR /stash\nCOPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/\nCOPY ./graphql /stash/graphql/\nCOPY ./scripts /stash/scripts/\nCOPY ./pkg /stash/pkg/\nCOPY ./cmd /stash/cmd\nCOPY ./internal /stash/internal\n# needed for generate-login-locale\nCOPY ./ui /stash/ui/\nRUN make generate-backend generate-login-locale\nCOPY --from=frontend /stash /stash/\nARG GITHASH\nARG STASH_VERSION\nRUN make flags-release flags-pie stash\n\n# Final Runnable Image\nFROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu24.04\nRUN apt update && apt upgrade -y && apt install -y \\\n    # stash dependencies\n    ca-certificates libvips-tools ffmpeg \\\n    # intel dependencies\n    intel-media-va-driver-non-free vainfo \\\n    # python tools\n    python3 python3-pip && \\\n  # cleanup\n  apt autoremove -y && apt clean && \\\n  rm -rf /var/lib/apt/lists/*\nCOPY --from=backend --chmod=555 /stash/stash /usr/bin/\n\n# NVENC Patch\nRUN mkdir -p /usr/local/bin /patched-lib\nADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh /usr/local/bin/patch.sh\nADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh\n\nENV LANG=C.UTF-8\nENV NVIDIA_VISIBLE_DEVICES=all\nENV NVIDIA_DRIVER_CAPABILITIES=video,utility\nENV STASH_CONFIG_FILE=/root/.stash/config.yml\nEXPOSE 9999\nENTRYPOINT [\"docker-entrypoint.sh\", \"stash\"]\n\n# vim: ft=dockerfile\n"
  },
  {
    "path": "docker/build/x86_64/README.md",
    "content": "# Introduction\n\nThis dockerfile is used to build a stash docker container using the current source code. This is ideal for testing your current branch in docker. Note that it does not include python, so python-based scrapers will not work in this image. The production docker images distributed by the project contain python and the necessary packages.\n\n# Building the docker container\n\nFrom the top-level directory (should contain `tools.go` file):\n\n```\nmake docker-build\n\n```\n\n# Running the docker container\n\n## Using docker-compose\n\nSee the `README.md` file in `docker/production` for instructions on how to get docker-compose if needed.\n\nThe `stash/build` container can be run with the `docker-compose.yml` file in `docker/production` by changing the `image` value to be `stash/build`. See the instructions in `docker/production` for how to run docker-compose.\n\n## Using `docker run`\n\nAfter building the container:\n\n```\ndocker run \\\n -e STASH_STASH=/data/ \\\n -e STASH_METADATA=/metadata/ \\\n -e STASH_CACHE=/cache/ \\\n -e STASH_GENERATED=/generated/ \\\n -v <path to config dir>:/root/.stash \\\n -v <path to media>:/data \\\n -v <path to metadata>:/metadata \\\n -v <path to cache>:/cache \\\n -v <path to generated>:/generated \\\n -p 9999:9999 \\\n stash/build:latest \n```\n\nChange the `<xxx>` to the appropriate paths. Note that the `<path to media>` directory should be separate from the cache, generated and metadata directories. It is recommended to have the cache, generated and metadata directories in the same parent directory, for example:\n\n```\n/stash\n  /config\n  /metadata\n  /generated\n  /cache\n/media\n```\n\nUsing this example directory structure, the above command would be:\n\n```\ndocker run \\\n -e STASH_STASH=/data/ \\\n -e STASH_METADATA=/metadata/ \\\n -e STASH_CACHE=/cache/ \\\n -e STASH_GENERATED=/generated/ \\\n -v /stash/config:/root/.stash \\\n -v /media:/data \\\n -v /stash/metadata:/metadata \\\n -v /stash/cache:/cache \\\n -v /stash/generated:/generated \\\n -p 9999:9999 \\\n stash/build:latest \n```\n"
  },
  {
    "path": "docker/ci/x86_64/Dockerfile",
    "content": "FROM --platform=$BUILDPLATFORM alpine:latest AS binary\nARG TARGETPLATFORM\nWORKDIR /\nCOPY stash-*  /\nRUN if [ \"$TARGETPLATFORM\" = \"linux/arm/v6\" ];   then BIN=stash-linux-arm32v6; \\\n    elif [ \"$TARGETPLATFORM\" = \"linux/arm/v7\" ]; then BIN=stash-linux-arm32v7; \\\n    elif [ \"$TARGETPLATFORM\" = \"linux/arm64\" ];  then BIN=stash-linux-arm64v8; \\\n    elif [ \"$TARGETPLATFORM\" = \"linux/amd64\" ];  then BIN=stash-linux; \\\n    fi; \\\n    mv $BIN /stash\n\nFROM --platform=$TARGETPLATFORM alpine:latest AS app\nCOPY --from=binary /stash /usr/bin/\n\nRUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \\\n    && pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools\nENV STASH_CONFIG_FILE=/root/.stash/config.yml\n\n# Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys\nLABEL org.opencontainers.image.title=\"Stash\" \\\n    org.opencontainers.image.description=\"An organizer for your porn, written in Go.\" \\\n    org.opencontainers.image.url=\"https://stashapp.cc\" \\\n    org.opencontainers.image.documentation=\"https://docs.stashapp.cc\" \\\n    org.opencontainers.image.source=\"https://github.com/stashapp/stash\" \\\n    org.opencontainers.image.licenses=\"AGPL-3.0\"\n\nEXPOSE 9999\nCMD [\"stash\"]\n"
  },
  {
    "path": "docker/ci/x86_64/README.md",
    "content": "This Dockerfile is used by CI to build the `stashapp/stash` Docker image. It must be run after cross-compiling - that is, `stash-linux` must exist in the `dist` directory. This image must be built from the `dist` directory.\n"
  },
  {
    "path": "docker/ci/x86_64/docker_push.sh",
    "content": "#!/bin/bash\n\nDOCKER_TAGS=\"\"\n\nfor TAG in \"$@\"\ndo\n\tDOCKER_TAGS=\"$DOCKER_TAGS -t stashapp/stash:$TAG\"\ndone\n\necho \"$DOCKER_PASSWORD\" | docker login -u \"$DOCKER_USERNAME\" --password-stdin\n\n# must build the image from dist directory\ndocker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 --push $DOCKER_TAGS -f docker/ci/x86_64/Dockerfile dist/\n\n"
  },
  {
    "path": "docker/compiler/Dockerfile",
    "content": "### OSXCROSS\nFROM debian:bookworm AS osxcross\n# add osxcross\nWORKDIR /tmp/osxcross\nARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b\nADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz\n\nARG OSX_SDK_VERSION=11.3\nARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz\nARG OSX_SDK_DOWNLOAD_URL=https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE}\nADD --checksum=sha256:cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE}\n\nENV UNATTENDED=yes \\\n    SDK_VERSION=${OSX_SDK_VERSION} \\\n    OSX_VERSION_MIN=10.10\nRUN apt update && \\\n  apt install -y --no-install-recommends \\\n  bash ca-certificates clang cmake git patch libssl-dev bzip2 cpio libbz2-dev libxml2-dev make python3 xz-utils zlib1g-dev\n# lzma-dev libxml2-dev xz\nRUN tar --strip=1 -C /tmp/osxcross -xf /tmp/osxcross.tar.gz\nRUN ./build.sh\n\n### FREEBSD cross-compilation stage\n# use alpine for cacheable image since apt is notorous for not caching\nFROM alpine:3 AS freebsd\n# match golang latest\n# https://go.dev/wiki/FreeBSD\nARG FREEBSD_VERSION=12.4\nADD --checksum=sha256:581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8 \\\n  http://ftp-archive.freebsd.org/mirror/FreeBSD-Archive/old-releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz \\\n  /tmp/base.txz\n\nWORKDIR /opt/cross-freebsd\nRUN apk add --no-cache tar xz\nRUN tar -xf /tmp/base.txz --strip-components=1 ./usr/lib ./usr/include ./lib\nRUN cd /opt/cross-freebsd/usr/lib && \\\n    find . -type l -exec sh -c ' \\\n      for link; do \\\n        target=$(readlink \"$link\"); \\\n        case \"$target\" in \\\n          /lib/*) ln -sf \"/opt/cross-freebsd$target\" \"$link\";; \\\n        esac; \\\n      done \\\n    ' sh {} + && \\\n    ln -s libc++.a libstdc++.a && \\\n    ln -s libc++.so libstdc++.so\n\n### BUILDER\nFROM golang:1.24.3 AS builder\nENV PATH=/opt/osx-ndk-x86/bin:$PATH\n\n# copy in nodejs instead of using nodesource :thumbsup:\nCOPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local\n# copy in osxcross\nCOPY --from=osxcross /tmp/osxcross/target/lib /usr/lib\nCOPY --from=osxcross /tmp/osxcross/target /opt/osx-ndk-x86\n# copy in cross-freebsd \nCOPY --from=freebsd /opt/cross-freebsd /opt/cross-freebsd\n\n# pnpm install with npm\nRUN npm install -g pnpm\n\n# git for getting hash\n# make and bash for building\n\n# clang for macos\n# zip for stashapp.zip\n# gcc-extensions for cross-arch build\n# we still target arm soft float?\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends \\\n        git make bash \\\n        clang zip \\\n        gcc-mingw-w64 \\\n        gcc-arm-linux-gnueabi \\\n        libc-dev-armel-cross linux-libc-dev-armel-cross \\\n        gcc-aarch64-linux-gnu libc-dev-arm64-cross && \\\n    rm -rf /var/lib/apt/lists/*;\nRUN git config --global safe.directory '*'\n# To test locally:\n# make generate\n# make ui\n# cd docker/compiler\n# docker build . -t ghcr.io/stashapp/compiler:latest\n# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t ghcr.io/stashapp/compiler:latest make build-cc-all\n# # binaries will show up in /dist"
  },
  {
    "path": "docker/compiler/Makefile",
    "content": "host=ghcr.io\nuser=stashapp\nrepo=compiler\nversion=13\n\nVERSION_IMAGE = ${host}/${user}/${repo}:${version}\nLATEST_IMAGE = ${host}/${user}/${repo}:latest\n\nlatest:\n\tdocker build -t ${LATEST_IMAGE} .\n\nbuild:\n\tdocker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .\n\nbuild-no-cache:\n\tdocker build --no-cache -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .\n\n# requires docker login ghcr.io\n# echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin\npush:\n\tdocker push ${VERSION_IMAGE}\n\tdocker push ${LATEST_IMAGE}"
  },
  {
    "path": "docker/compiler/README.md",
    "content": "Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser\n\nWhen the Dockerfile is changed, the version number should be incremented in [.github/workflows/build-compiler.yml](../../.github/workflows/build-compiler.yml) and the workflow [manually ran](). `env: COMPILER_IMAGE` in [.github/workflows/build.yml](../../.github/workflows/build.yml) also needs to be updated to pull the correct image tag."
  },
  {
    "path": "docker/production/README.md",
    "content": "# Docker Installation (for most 64-bit GNU/Linux systems)\nStashApp is supported on most systems that support Docker. Your OS likely ships with or makes available the necessary packages.\n\n## Dependencies\nOnly `docker` is required. For the most part your understanding of the technologies can be superficial. So long as you can follow commands and are open to reading a bit, you should be fine.\n\nInstallation instructions are available below, and if your distributions's repository ships a current version of docker, you may use that.\nhttps://docs.docker.com/engine/install/\n\nOn some distributions, `docker compose` is shipped seperately, usually as `docker-cli-compose`. docker-compose is not recommended.\n\n### Get the docker-compose.yml file\n\nNow you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you:\n\n```\nmkdir stashapp && cd stashapp\ncurl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml\n```\n\nOnce you have that file where you want it, modify the settings as you please, and then run:\n\n```\ndocker compose up -d\n```\n\nInstalling StashApp this way will by default bind stash to port 9999. This is available in your web browser locally at http://localhost:9999 or on your network as http://YOUR-LOCAL-IP:9999\n\nGood luck and have fun!\n\n### Docker\nDocker is effectively a cross-platform software package repository. It allows you to ship an entire environment in what's referred to as a container. Containers are intended to hold everything that is needed to run an application from one place to another, making it easy for everyone along the way to reproduce the environment.\n\nThe StashApp docker container ships with everything you need to automatically run stash, including ffmpeg.\n\n### docker compose\nDocker Compose lets you specify how and where to run your containers, and to manage their environment. The docker-compose.yml file in this folder gets you a fully working instance of StashApp exactly as you would need it to have a reasonable instance for testing / developing on. If you are deploying a live instance for production, a [reverse proxy](https://docs.stashapp.cc/guides/reverse-proxy/) (such as NGINX or Traefik) is recommended, but not required.\n\nThe latest version is always recommended.\n"
  },
  {
    "path": "docker/production/docker-compose.yml",
    "content": "# APPNICENAME=Stash\n# APPDESCRIPTION=An organizer for your porn, written in Go\nservices:\n  stash:\n    image: stashapp/stash:latest\n    container_name: stash\n    restart: unless-stopped\n    ## the container's port must be the same with the STASH_PORT in the environment section\n    ports:\n      - \"9999:9999\"\n    ## If you intend to use stash's DLNA functionality uncomment the below network mode and comment out the above ports section\n    # network_mode: host\n    logging:\n      driver: \"json-file\"\n      options:\n        max-file: \"10\"\n        max-size: \"2m\"\n    environment:\n      - STASH_STASH=/data/\n      - STASH_GENERATED=/generated/\n      - STASH_METADATA=/metadata/\n      - STASH_CACHE=/cache/\n      ## Adjust below to change default port (9999)\n      - STASH_PORT=9999\n    volumes:\n      - /etc/localtime:/etc/localtime:ro\n      ## Adjust below paths (the left part) to your liking.\n      ## E.g. you can change ./config:/root/.stash to ./stash:/root/.stash\n      ## The left part is the path on your host, the right part is the path in the stash container.\n\n      ## Keep configs, scrapers, and plugins here.\n      - ./config:/root/.stash\n      ## Point this at your collection.\n      ## The left side is where your collection is on your host, the right side is where it will be in stash.\n      - ./data:/data\n      ## This is where your stash's metadata lives\n      - ./metadata:/metadata\n      ## Any other cache content.\n      - ./cache:/cache\n      ## Where to store binary blob data (scene covers, images)\n      - ./blobs:/blobs\n      ## Where to store generated content (screenshots,previews,transcodes,sprites)\n      - ./generated:/generated\n"
  },
  {
    "path": "docs/CONTRIBUTING.md",
    "content": "## Goals and design vision\n\nThe goal of stash is to be:\n- an application for organising and viewing adult content - currently this is videos and images, in future this will be extended to include audio and text content\n  - organising includes scraping of metadata from websites and metadata repositories\n- free and open-source\n- portable and offline - can be run on a USB stick without needing to install dependencies (with the exception of ffmpeg)\n- minimal, but highly extensible. The core feature set should be the minimum required to achieve the primary goal, while being extensible enough to extend via plugins\n- easy to learn and use, with minimal technical knowledge required\n\nThe core stash system is not intended for:\n- managing downloading of content\n- managing content on external websites\n- publically sharing content\n\nOther requirements:\n- support as many video and image formats as possible\n- interfaces with external systems (for example stash-box) should be made as generic as possible. \n\nDesign considerations:\n- features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (eg DLNA, filename parser). Such features should be considered for third-party plugins instead.\n\n## Technical Debt\nPlease be sure to consider how heavily your contribution impacts the maintainability of the project long term, sometimes less is more.  We don't want to merge collossal pull requests with hundreds of dependencies by a driveby contributor.\n\n## Contributor Checklist\nPlease make sure that you've considered the following before you submit your Pull Requests as ready for merging.\n* I've run Code linters and [gofmt](https://golang.org/cmd/gofmt/) to make sure that my code is readable.\n* I have read through formerly submitted [pull requests](https://github.com/stashapp/stash/pulls) and [git issues](https://github.com/stashapp/stash/issues) to make sure that this contribution is required and isn't a duplicate. Also, so that I can manage to close any git Issues needing closed relating to this feature submission.\n* I  commented adequately on my code with the expectation in mind that anyone else should be able to look at this code I've submitted and know exactly what's happening and what the expectations are.\n\n### Legal Agreements\n* I acknowledge that if applicable to me, submitting and subsequent acceptance of this Pull Request I, the code contributor of this Pull Request, agree and acknowledge my understanding that the new code license has now been updated to [AGPL](/LICENSE.md). I agree that all code before this Pull Request, which I've previously submitted, is now to be re-licensed under the new license AGPL and no longer the former MIT license.\n\n**In case you were unable to follow any of the above include an explanation as to why not in your Pull Request.**\n"
  },
  {
    "path": "docs/DEVELOPMENT.md",
    "content": "# Building from Source\n\n## Pre-requisites\n\n* [Go](https://golang.org/dl/)\n* [GolangCI](https://golangci-lint.run/) - A meta-linter which runs several linters in parallel\n  * To install, follow the [local installation instructions](https://golangci-lint.run/welcome/install/#local-installation)\n* [nodejs](https://nodejs.org/en/download) - nodejs runtime\n  * corepack/[pnpm](https://pnpm.io/installation) - nodejs package manager (included with nodejs)\n\n## Environment\n\n### Windows\n\n1. Download and install [Go for Windows](https://golang.org/dl/)\n2. Download and extract [MinGW64](https://sourceforge.net/projects/mingw-w64/files/) (scroll down and select x86_64-posix-seh, don't use the autoinstaller, it doesn't work)\n3. Search for \"Advanced System Settings\" and open the System Properties dialog.\n    1. Click the `Environment Variables` button\n    2. Under System Variables find `Path`. Edit and add `C:\\MinGW\\bin` (replace with the correct path to where you extracted MingW64).\n\nNOTE: The `make` command in Windows will be `mingw32-make` with MinGW. For example, `make pre-ui` will be `mingw32-make pre-ui`.\n\n### macOS\n\n1. If you don't have it already, install the [Homebrew package manager](https://brew.sh).\n2. Install dependencies: `brew install go git gcc make node ffmpeg`\n\n### Linux\n\n#### Arch Linux\n\n1. Install dependencies: `sudo pacman -S go git gcc make nodejs ffmpeg --needed`\n\n#### Ubuntu\n\n1. Install dependencies: `sudo apt-get install golang git gcc nodejs ffmpeg -y`\n\n### OpenBSD\n\n1. Install dependencies `doas pkg_add gmake go git node cmake ffmpeg`\n2. Follow the instructions below to build a release, but replace the final step `make build-release` with `gmake flags-release stash`, to [avoid the PIE buildmode](https://github.com/golang/go/issues/59866).\n\nNOTE: The `make` command in OpenBSD will be `gmake`. For example, `make pre-ui` will be `gmake pre-ui`.\n\n## Commands\n\n* `make pre-ui` - Installs the UI dependencies. This only needs to be run once after cloning the repository, or if the dependencies are updated.\n* `make generate` - Generates Go and UI GraphQL files. Requires `make pre-ui` to have been run.\n* `make generate-stash-box-client` - Generate Go files for the Stash-box client code.\n* `make ui` - Builds the UI. Requires `make pre-ui` to have been run.\n* `make stash` - Builds the `stash` binary (make sure to build the UI as well... see below)\n* `make stash-macapp` - Builds the `Stash.app` macOS app (only works when on macOS, for cross-compilation see below)\n* `make phasher` - Builds the `phasher` binary\n* `make build` - Builds both the `stash` and `phasher` binaries, alias for `make stash phasher`\n* `make build-release` - Builds release versions (debug information removed) of both the `stash` and `phasher` binaries, alias for `make flags-release flags-pie build`\n* `make docker-build` - Locally builds and tags a complete 'stash/build' docker image\n* `make docker-cuda-build` - Locally builds and tags a complete 'stash/cuda-build' docker image\n* `make validate` - Runs all of the tests and checks required to submit a PR\n* `make lint` - Runs `golangci-lint` on the backend\n* `make it` - Runs all unit and integration tests\n* `make fmt` - Formats the Go source code\n* `make fmt-ui` - Formats the UI source code\n* `make validate-ui` - Runs tests and checks for the UI only\n* `make fmt-ui-quick` - (experimental) Formats only changed UI source code\n* `make validate-ui-quick` - (experimental) Runs tests and checks of changed UI code\n* `make server-start` - Runs a development stash server in the `.local` directory\n* `make server-clean` - Removes the `.local` directory and all of its contents\n* `make ui-start` - Runs the UI in development mode. Requires a running Stash server to connect to - the server URL can be changed from the default of `http://localhost:9999` using the environment variable `VITE_APP_PLATFORM_URL`, but keep in mind that authentication cannot be used since the session authorization cookie cannot be sent cross-origin. The UI runs on port `3000` or the next available port.\n\nWhen building, you can optionally prepend `flags-*` targets to the target list in your `make` command to use different build flags:\n\n* `flags-release` (e.g. `make flags-release stash`) - Remove debug information from the binary.\n* `flags-pie` (e.g. `make flags-pie build`) - Build a PIE (Position Independent Executable) binary. This provides increased security, but it is unsupported on some systems (notably 32-bit ARM and OpenBSD).\n* `flags-static` (e.g. `make flags-static phasher`) - Build a statically linked binary (the default is a dynamically linked binary).\n* `flags-static-pie` (e.g. `make flags-static-pie stash`) - Build a statically linked PIE binary (using `flags-static` and `flags-pie` separately will not work).\n* `flags-static-windows` (e.g. `make flags-static-windows build`) - Identical to `flags-static-pie`, but does not enable the `netgo` build tag, which is not needed for static builds on Windows.\n\n## Local development quickstart\n\n1. Run `make pre-ui` to install UI dependencies\n2. Run `make generate` to create generated files\n3. In one terminal, run `make server-start` to run the server code\n4. In a separate terminal, run `make ui-start` to run the UI in development mode\n5. Open the UI in a browser: `http://localhost:3000/`\n\nChanges to the UI code can be seen by reloading the browser page.\n\nChanges to the backend code require a server restart (`CTRL-C` in the server terminal, followed by `make server-start` again) to be seen.\n\nOn first launch:\n\n1. On the \"Stash Setup Wizard\" screen, choose a directory with some files to test with\n2. Press \"Next\" to use the default locations for the database and generated content\n3. Press the \"Confirm\" and \"Finish\" buttons to get into the UI\n4. On the side menu, navigate to \"Tasks -> Library -> Scan\" and press the \"Scan\" button\n5. You're all set! Set any other configurations you'd like and test your code changes.\n\nTo start fresh with new configuration:\n\n1. Stop the server (`CTRL-C` in the server terminal)\n2. Run `make server-clean` to clear all config, database, and generated files (under `.local`)\n3. Run `make server-start` to restart the server\n4. Follow the \"On first launch\" steps above\n\n## Building a release\n\nSimply run `make` or `make release`, or equivalently:\n\n1. Run `make pre-ui` to install UI dependencies\n2. Run `make generate` to create generated files\n3. Run `make ui` to build the frontend\n4. Run `make build-release` to build a release executable for your current platform\n\n## Cross-compiling\n\nThis project uses a modification of the [CI-GoReleaser](https://github.com/bep/dockerfiles/tree/master/ci-goreleaser) Docker container for cross-compilation, defined in `docker/compiler/Dockerfile`.\n\nTo cross-compile the app yourself:\n\n1. Run `make pre-ui`, `make generate` and `make ui` outside the container, to generate files and build the UI.\n2. Pull the latest compiler image from GHCR: `docker pull ghcr.io/stashapp/compiler`\n3. Run `docker run --rm --mount type=bind,source=\"$(pwd)\",target=/stash -w /stash -it ghcr.io/stashapp/compiler /bin/bash` to open a shell inside the container.\n4. From inside the container, run `make build-cc-all` to build for all platforms, or run `make build-cc-{platform}` to build for a specific platform (have a look at the `Makefile` for the list of targets).\n5. You will find the compiled binaries in `dist/`.\n\nNOTE: Since the container is run as UID 0 (root), the resulting binaries (and the `dist/` folder itself, if it had to be created) will be owned by root.\n\n## Profiling\n\nStash can be profiled using the `--cpuprofile <output profile filename>` command line flag.\n\nThe resulting file can then be used with pprof as follows:\n\n`go tool pprof <path to binary> <path to profile filename>`\n\nWith `graphviz` installed and in the path, a call graph can be generated with:\n\n`go tool pprof -svg <path to binary> <path to profile filename> > <output svg file>`\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/stashapp/stash\n\ngo 1.24.3\n\nrequire (\n\tgithub.com/99designs/gqlgen v0.17.73\n\tgithub.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552\n\tgithub.com/Yamashou/gqlgenc v0.32.1\n\tgithub.com/anacrolix/dms v1.2.2\n\tgithub.com/antchfx/htmlquery v1.3.5\n\tgithub.com/asticode/go-astisub v0.25.1\n\tgithub.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d\n\tgithub.com/chromedp/chromedp v0.14.2\n\tgithub.com/corona10/goimagehash v1.1.0\n\tgithub.com/disintegration/imaging v1.6.2\n\tgithub.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d\n\tgithub.com/doug-martin/goqu/v9 v9.18.0\n\tgithub.com/go-chi/chi/v5 v5.2.2\n\tgithub.com/go-chi/cors v1.2.1\n\tgithub.com/go-chi/httplog v0.3.1\n\tgithub.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4\n\tgithub.com/golang-jwt/jwt/v4 v4.5.2\n\tgithub.com/golang-migrate/migrate/v4 v4.16.2\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/securecookie v1.1.1\n\tgithub.com/gorilla/sessions v1.2.1\n\tgithub.com/gorilla/websocket v1.5.0\n\tgithub.com/hashicorp/golang-lru/v2 v2.0.7\n\tgithub.com/hasura/go-graphql-client v0.13.1\n\tgithub.com/jinzhu/copier v0.4.0\n\tgithub.com/jmoiron/sqlx v1.4.0\n\tgithub.com/json-iterator/go v1.1.12\n\tgithub.com/kermieisinthehouse/gosx-notifier v0.1.2\n\tgithub.com/kermieisinthehouse/systray v1.2.4\n\tgithub.com/knadh/koanf/parsers/yaml v1.1.0\n\tgithub.com/knadh/koanf/providers/env v1.1.0\n\tgithub.com/knadh/koanf/providers/file v1.2.0\n\tgithub.com/knadh/koanf/providers/posflag v1.0.1\n\tgithub.com/knadh/koanf/v2 v2.2.1\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0\n\tgithub.com/mattn/go-sqlite3 v1.14.22\n\tgithub.com/mitchellh/mapstructure v1.5.0\n\tgithub.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007\n\tgithub.com/pkg/browser v0.0.0-20210911075715-681adbf594b8\n\tgithub.com/remeh/sizedwaitgroup v1.0.0\n\tgithub.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd\n\tgithub.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06\n\tgithub.com/sirupsen/logrus v1.9.3\n\tgithub.com/spf13/cast v1.6.0\n\tgithub.com/spf13/pflag v1.0.6\n\tgithub.com/stretchr/testify v1.10.0\n\tgithub.com/tidwall/gjson v1.16.0\n\tgithub.com/vearutop/statigz v1.4.0\n\tgithub.com/vektah/dataloaden v0.3.0\n\tgithub.com/vektah/gqlparser/v2 v2.5.27\n\tgithub.com/vektra/mockery/v2 v2.10.0\n\tgithub.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e\n\tgithub.com/zencoder/go-dash/v3 v3.0.2\n\tgolang.org/x/crypto v0.45.0\n\tgolang.org/x/image v0.18.0\n\tgolang.org/x/net v0.47.0\n\tgolang.org/x/sys v0.38.0\n\tgolang.org/x/term v0.37.0\n\tgolang.org/x/text v0.31.0\n\tgolang.org/x/time v0.10.0\n\tgopkg.in/guregu/null.v4 v4.0.0\n\tgopkg.in/natefinch/lumberjack.v2 v2.2.1\n\tgopkg.in/yaml.v2 v2.4.0\n)\n\nrequire (\n\tgithub.com/agnivade/levenshtein v1.2.1 // indirect\n\tgithub.com/antchfx/xpath v1.3.5 // indirect\n\tgithub.com/asticode/go-astikit v0.20.0 // indirect\n\tgithub.com/asticode/go-astits v1.8.0 // indirect\n\tgithub.com/chromedp/sysutil v1.1.0 // indirect\n\tgithub.com/coder/websocket v1.8.12 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/dlclark/regexp2 v1.7.0 // indirect\n\tgithub.com/fsnotify/fsnotify v1.9.0 // indirect\n\tgithub.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect\n\tgithub.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.4.0 // indirect\n\tgithub.com/gobwas/httphead v0.1.0 // indirect\n\tgithub.com/gobwas/pool v0.2.1 // indirect\n\tgithub.com/gobwas/ws v1.4.0 // indirect\n\tgithub.com/goccy/go-yaml v1.18.0 // indirect\n\tgithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect\n\tgithub.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect\n\tgithub.com/hashicorp/errwrap v1.1.0 // indirect\n\tgithub.com/hashicorp/go-multierror v1.1.1 // indirect\n\tgithub.com/hashicorp/hcl v1.0.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/knadh/koanf/maps v0.1.2 // indirect\n\tgithub.com/magiconair/properties v1.8.7 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mitchellh/copystructure v1.2.0 // indirect\n\tgithub.com/mitchellh/go-homedir v1.1.0 // indirect\n\tgithub.com/mitchellh/reflectwalk v1.0.2 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect\n\tgithub.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.1.0 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/rs/zerolog v1.30.0 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/sosodev/duration v1.3.1 // indirect\n\tgithub.com/spf13/afero v1.9.5 // indirect\n\tgithub.com/spf13/cobra v1.7.0 // indirect\n\tgithub.com/spf13/jwalterweatherman v1.1.0 // indirect\n\tgithub.com/spf13/viper v1.16.0 // indirect\n\tgithub.com/stretchr/objx v0.5.2 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/tidwall/match v1.1.1 // indirect\n\tgithub.com/tidwall/pretty v1.2.1 // indirect\n\tgithub.com/urfave/cli/v2 v2.27.6 // indirect\n\tgithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect\n\tgo.uber.org/atomic v1.11.0 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.3 // indirect\n\tgolang.org/x/mod v0.29.0 // indirect\n\tgolang.org/x/sync v0.18.0 // indirect\n\tgolang.org/x/tools v0.38.0 // indirect\n\tgopkg.in/ini.v1 v1.67.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=\ncloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=\ncloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=\ncloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=\ncloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=\ncloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=\ncloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=\ncloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=\ncloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=\ncloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=\ncloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=\ncloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=\ncloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=\ncloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=\ncloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=\ncloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=\ncloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=\ncloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=\ncloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=\ncloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=\ncloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=\ncloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=\ncloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=\ncloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=\ncloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=\ncloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=\ncloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=\ncloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=\ncloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=\ncloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=\ncloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=\ncloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=\ncloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=\ncloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=\ncloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=\ncloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=\ncloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=\ncloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=\ncloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=\ncloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=\ncloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=\ncloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=\ncloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=\ncloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=\ncloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=\ncloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=\ncloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=\ncloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=\ndmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=\nfilippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=\nfilippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\ngithub.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg=\ngithub.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=\ngithub.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=\ngithub.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=\ngithub.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=\ngithub.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=\ngithub.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=\ngithub.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=\ngithub.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=\ngithub.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 h1:eukVk+mGmbSZppLw8WJGpEUgMC570eb32y7FOsPW4Kc=\ngithub.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552/go.mod h1:LKbO1i6L1lSlwWx4NHWVECxubHNKFz2YQoEMGXAFVy8=\ngithub.com/Yamashou/gqlgenc v0.32.1 h1:EHs9//xQxXlyltkSFXM+fhO2rTXcWNw6FPKRJ6t+iQQ=\ngithub.com/Yamashou/gqlgenc v0.32.1/go.mod h1:o5SxKt9d3+oUZ2i0V3CW8lHFyunfLR+KcKHubS4zf5E=\ngithub.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=\ngithub.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=\ngithub.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/anacrolix/dms v1.2.2 h1:0mk2/DXNqa5KDDbaLgFPf3oMV6VCGdFNh3d/gt4oafM=\ngithub.com/anacrolix/dms v1.2.2/go.mod h1:msPKAoppoNRfrYplJqx63FZ+VipDZ4Xsj3KzIQxyU7k=\ngithub.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=\ngithub.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=\ngithub.com/anacrolix/ffprobe v1.0.0/go.mod h1:BIw+Bjol6CWjm/CRWrVLk2Vy+UYlkgmBZ05vpSYqZPw=\ngithub.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=\ngithub.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=\ngithub.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=\ngithub.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=\ngithub.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=\ngithub.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=\ngithub.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=\ngithub.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=\ngithub.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=\ngithub.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=\ngithub.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=\ngithub.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=\ngithub.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=\ngithub.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=\ngithub.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=\ngithub.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=\ngithub.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=\ngithub.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=\ngithub.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=\ngithub.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=\ngithub.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=\ngithub.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=\ngithub.com/asticode/go-astisub v0.25.1 h1:RZMGfZPp7CXOkI6g+zCU7DRLuciGPGup921uKZnMXPI=\ngithub.com/asticode/go-astisub v0.25.1/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=\ngithub.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg=\ngithub.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=\ngithub.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=\ngithub.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=\ngithub.com/bool64/dev v0.2.28 h1:6ayDfrB/jnNr2iQAZHI+uT3Qi6rErSbJYQs1y8rSrwM=\ngithub.com/bool64/dev v0.2.28/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=\ngithub.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=\ngithub.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=\ngithub.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=\ngithub.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=\ngithub.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=\ngithub.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=\ngithub.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=\ngithub.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=\ngithub.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=\ngithub.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=\ngithub.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=\ngithub.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=\ngithub.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=\ngithub.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=\ngithub.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=\ngithub.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=\ngithub.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=\ngithub.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=\ngithub.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=\ngithub.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=\ngithub.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=\ngithub.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=\ngithub.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=\ngithub.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=\ngithub.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=\ngithub.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=\ngithub.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=\ngithub.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=\ngithub.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=\ngithub.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=\ngithub.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=\ngithub.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=\ngithub.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=\ngithub.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=\ngithub.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=\ngithub.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=\ngithub.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=\ngithub.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=\ngithub.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=\ngithub.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=\ngithub.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=\ngithub.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=\ngithub.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=\ngithub.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw=\ngithub.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=\ngithub.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=\ngithub.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=\ngithub.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY=\ngithub.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=\ngithub.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=\ngithub.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=\ngithub.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=\ngithub.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=\ngithub.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=\ngithub.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=\ngithub.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=\ngithub.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=\ngithub.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=\ngithub.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=\ngithub.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=\ngithub.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=\ngithub.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=\ngithub.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=\ngithub.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=\ngithub.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=\ngithub.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=\ngithub.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=\ngithub.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=\ngithub.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=\ngithub.com/go-chi/httplog v0.3.1 h1:uC3IUWCZagtbCinb3ypFh36SEcgd6StWw2Bu0XSXRtg=\ngithub.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsyxoP5m8=\ngithub.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=\ngithub.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=\ngithub.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=\ngithub.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=\ngithub.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=\ngithub.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=\ngithub.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=\ngithub.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=\ngithub.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=\ngithub.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=\ngithub.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=\ngithub.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=\ngithub.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=\ngithub.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=\ngithub.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=\ngithub.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=\ngithub.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=\ngithub.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=\ngithub.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=\ngithub.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=\ngithub.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=\ngithub.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=\ngithub.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=\ngithub.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=\ngithub.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=\ngithub.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=\ngithub.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=\ngithub.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=\ngithub.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=\ngithub.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=\ngithub.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=\ngithub.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=\ngithub.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=\ngithub.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=\ngithub.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=\ngithub.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=\ngithub.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=\ngithub.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=\ngithub.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=\ngithub.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=\ngithub.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=\ngithub.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=\ngithub.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=\ngithub.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=\ngithub.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=\ngithub.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=\ngithub.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=\ngithub.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=\ngithub.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=\ngithub.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=\ngithub.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=\ngithub.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=\ngithub.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=\ngithub.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=\ngithub.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=\ngithub.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=\ngithub.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=\ngithub.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=\ngithub.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=\ngithub.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=\ngithub.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=\ngithub.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=\ngithub.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=\ngithub.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=\ngithub.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=\ngithub.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=\ngithub.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=\ngithub.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=\ngithub.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=\ngithub.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=\ngithub.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=\ngithub.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=\ngithub.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=\ngithub.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=\ngithub.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=\ngithub.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=\ngithub.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=\ngithub.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=\ngithub.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=\ngithub.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=\ngithub.com/hasura/go-graphql-client v0.13.1 h1:kKbjhxhpwz58usVl+Xvgah/TDha5K2akNTRQdsEHN6U=\ngithub.com/hasura/go-graphql-client v0.13.1/go.mod h1:k7FF7h53C+hSNFRG3++DdVZWIuHdCaTbI7siTJ//zGQ=\ngithub.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=\ngithub.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=\ngithub.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=\ngithub.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=\ngithub.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=\ngithub.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=\ngithub.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=\ngithub.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=\ngithub.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=\ngithub.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=\ngithub.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=\ngithub.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=\ngithub.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=\ngithub.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=\ngithub.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q05f4oB+hUZmsPodxQ=\ngithub.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho=\ngithub.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s=\ngithub.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=\ngithub.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=\ngithub.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=\ngithub.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=\ngithub.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=\ngithub.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=\ngithub.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=\ngithub.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=\ngithub.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y=\ngithub.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk=\ngithub.com/knadh/koanf/v2 v2.2.1 h1:jaleChtw85y3UdBnI0wCqcg1sj1gPoz6D3caGNHtrNE=\ngithub.com/knadh/koanf/v2 v2.2.1/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=\ngithub.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=\ngithub.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=\ngithub.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=\ngithub.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=\ngithub.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=\ngithub.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=\ngithub.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=\ngithub.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=\ngithub.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=\ngithub.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=\ngithub.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=\ngithub.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=\ngithub.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=\ngithub.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=\ngithub.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=\ngithub.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=\ngithub.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=\ngithub.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=\ngithub.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=\ngithub.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=\ngithub.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=\ngithub.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=\ngithub.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=\ngithub.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=\ngithub.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=\ngithub.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=\ngithub.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=\ngithub.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=\ngithub.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=\ngithub.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=\ngithub.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=\ngithub.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=\ngithub.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 h1:Ohgj9L0EYOgXxkDp+bczlMBiulwmqYzQpvQNUdtt3oc=\ngithub.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007/go.mod h1:wKCOWMb6iNlvKiOToY2cNuaovSXvIiv1zDi9QDR7aGQ=\ngithub.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=\ngithub.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=\ngithub.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=\ngithub.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=\ngithub.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=\ngithub.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=\ngithub.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=\ngithub.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=\ngithub.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=\ngithub.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=\ngithub.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=\ngithub.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=\ngithub.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=\ngithub.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=\ngithub.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=\ngithub.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=\ngithub.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=\ngithub.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=\ngithub.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=\ngithub.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=\ngithub.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=\ngithub.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=\ngithub.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=\ngithub.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=\ngithub.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=\ngithub.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=\ngithub.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=\ngithub.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E=\ngithub.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo=\ngithub.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=\ngithub.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=\ngithub.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=\ngithub.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=\ngithub.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=\ngithub.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=\ngithub.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=\ngithub.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=\ngithub.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=\ngithub.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=\ngithub.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=\ngithub.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=\ngithub.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=\ngithub.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=\ngithub.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM=\ngithub.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=\ngithub.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=\ngithub.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=\ngithub.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=\ngithub.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=\ngithub.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=\ngithub.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=\ngithub.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=\ngithub.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=\ngithub.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=\ngithub.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=\ngithub.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=\ngithub.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=\ngithub.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=\ngithub.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=\ngithub.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=\ngithub.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=\ngithub.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=\ngithub.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=\ngithub.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=\ngithub.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4=\ngithub.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=\ngithub.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=\ngithub.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=\ngithub.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=\ngithub.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=\ngithub.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU=\ngithub.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=\ngithub.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=\ngithub.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\ngithub.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg=\ngithub.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=\ngithub.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=\ngithub.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=\ngithub.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=\ngithub.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=\ngithub.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=\ngithub.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=\ngithub.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84=\ngithub.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=\ngithub.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=\ngithub.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=\ngithub.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks=\ngithub.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4=\ngithub.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=\ngithub.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e h1:GruPsb+44XvYAzuAgJW1d1WHqmcI73L2XSjsbx/eJZw=\ngithub.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e/go.mod h1:wA8kQ8WFipMciY9WcWzqQgZordm/P7l8IZdvx1crwmc=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=\ngithub.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=\ngithub.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=\ngithub.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngithub.com/zencoder/go-dash/v3 v3.0.2 h1:oP1+dOh+Gp57PkvdCyMfbHtrHaxfl3w4kR3KBBbuqQE=\ngithub.com/zencoder/go-dash/v3 v3.0.2/go.mod h1:30R5bKy1aUYY45yesjtZ9l8trNc2TwNqbS17WVQmCzk=\ngo.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=\ngo.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=\ngo.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=\ngo.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=\ngo.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=\ngo.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=\ngo.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=\ngo.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=\ngo.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=\ngo.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=\ngo.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=\ngo.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=\ngo.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=\ngo.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=\ngo.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=\ngolang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=\ngolang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=\ngolang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=\ngolang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=\ngolang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=\ngolang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=\ngolang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=\ngolang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=\ngolang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=\ngolang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=\ngolang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=\ngolang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=\ngolang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=\ngolang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=\ngolang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=\ngolang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=\ngolang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=\ngolang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=\ngolang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=\ngolang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=\ngolang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190415214537-1da14a5a36f2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=\ngolang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=\ngolang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=\ngolang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=\ngolang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=\ngolang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=\ngolang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=\ngolang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=\ngolang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=\ngolang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190415145633-3fd5a3612ccd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=\ngolang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=\ngolang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=\ngolang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=\ngolang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=\ngolang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=\ngolang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=\ngolang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=\ngolang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=\ngolang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=\ngolang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=\ngolang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=\ngolang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=\ngolang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=\ngolang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=\ngolang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=\ngoogle.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=\ngoogle.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=\ngoogle.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=\ngoogle.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=\ngoogle.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=\ngoogle.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=\ngoogle.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=\ngoogle.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=\ngoogle.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=\ngoogle.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=\ngoogle.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=\ngoogle.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=\ngoogle.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=\ngoogle.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=\ngoogle.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=\ngoogle.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=\ngoogle.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=\ngoogle.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=\ngoogle.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=\ngoogle.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=\ngoogle.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=\ngoogle.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=\ngoogle.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=\ngoogle.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=\ngoogle.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=\ngoogle.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=\ngoogle.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=\ngoogle.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=\ngoogle.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=\ngoogle.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=\ngoogle.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=\ngoogle.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=\ngoogle.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=\ngoogle.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=\ngoogle.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=\ngoogle.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=\ngoogle.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=\ngoogle.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=\ngoogle.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=\ngoogle.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=\ngoogle.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=\ngoogle.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=\ngoogle.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=\ngoogle.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=\ngoogle.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=\ngoogle.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=\ngoogle.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=\ngoogle.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=\ngoogle.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=\ngoogle.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=\ngoogle.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=\ngoogle.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=\ngoogle.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=\ngoogle.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=\ngoogle.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=\ngoogle.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=\ngoogle.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=\ngoogle.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=\ngoogle.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=\ngoogle.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=\ngoogle.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=\ngoogle.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=\ngoogle.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=\ngoogle.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=\ngoogle.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=\ngoogle.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=\ngoogle.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=\ngoogle.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=\ngoogle.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=\ngoogle.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=\ngoogle.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=\ngoogle.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=\ngoogle.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=\ngoogle.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=\ngoogle.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=\ngoogle.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=\ngoogle.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=\ngoogle.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=\ngoogle.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=\ngoogle.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=\ngopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=\ngopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=\ngopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/ini.v1 v1.66.3/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=\ngopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=\ngopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nhonnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\nhonnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=\nhonnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=\nrsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=\nrsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=\nrsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=\n"
  },
  {
    "path": "gqlgen.yml",
    "content": "# Refer to https://gqlgen.com/config/ for detailed .gqlgen.yml documentation.\n\nschema:\n  - \"graphql/schema/types/*.graphql\"\n  - \"graphql/schema/*.graphql\"\nexec:\n  filename: internal/api/generated_exec.go\nmodel:\n  filename: internal/api/generated_models.go\n\nstruct_tag: gqlgen\n\nautobind:\n  - github.com/stashapp/stash/internal/api\n  - github.com/stashapp/stash/pkg/models\n  - github.com/stashapp/stash/pkg/plugin\n  - github.com/stashapp/stash/pkg/scraper\n  - github.com/stashapp/stash/internal/identify\n  - github.com/stashapp/stash/internal/dlna\n  - github.com/stashapp/stash/pkg/stashbox\n\nmodels:\n  # Scalars\n  ID:\n    model:\n      - github.com/99designs/gqlgen/graphql.ID\n      - github.com/99designs/gqlgen/graphql.IntID\n      - github.com/stashapp/stash/pkg/models.FileID\n      - github.com/stashapp/stash/pkg/models.FolderID\n  Int64:\n    model: github.com/99designs/gqlgen/graphql.Int64\n  Timestamp:\n    model: github.com/stashapp/stash/internal/api.Timestamp\n  BoolMap:\n    model: github.com/stashapp/stash/internal/api.BoolMap\n  PluginConfigMap:\n    model: github.com/stashapp/stash/internal/api.PluginConfigMap\n  File:\n    model: github.com/stashapp/stash/internal/api.File\n  VideoFile:\n    fields:\n      # override float fields - #1572\n      duration:\n        fieldName: DurationFinite\n      frame_rate:\n        fieldName: FrameRateFinite\n  # movie is group under the hood\n  Movie:\n    model: github.com/stashapp/stash/pkg/models.Group\n  MovieFilterType:\n    model: github.com/stashapp/stash/pkg/models.GroupFilterType\n  # autobind on config causes generation issues\n  BlobsStorageType:\n    model: github.com/stashapp/stash/internal/manager/config.BlobsStorageType\n  StashConfig:\n    model: github.com/stashapp/stash/internal/manager/config.StashConfig\n  StashConfigInput:\n    model: github.com/stashapp/stash/internal/manager/config.StashConfigInput\n  StashBoxInput:\n    model: github.com/stashapp/stash/internal/manager/config.StashBoxInput\n  ConfigImageLightboxResult:\n    model: github.com/stashapp/stash/internal/manager/config.ConfigImageLightboxResult\n  ImageLightboxDisplayMode:\n    model: github.com/stashapp/stash/internal/manager/config.ImageLightboxDisplayMode\n  ImageLightboxScrollMode:\n    model: github.com/stashapp/stash/internal/manager/config.ImageLightboxScrollMode\n  ConfigDisableDropdownCreate:\n    model: github.com/stashapp/stash/internal/manager/config.ConfigDisableDropdownCreate\n  ScanMetadataOptions:\n    model:  github.com/stashapp/stash/internal/manager/config.ScanMetadataOptions\n  CleanGeneratedInput:\n    model: github.com/stashapp/stash/internal/manager/task.CleanGeneratedOptions\n  AutoTagMetadataOptions:\n    model: github.com/stashapp/stash/internal/manager/config.AutoTagMetadataOptions\n  SystemStatus:\n    model: github.com/stashapp/stash/internal/manager.SystemStatus\n  SystemStatusEnum:\n    model: github.com/stashapp/stash/internal/manager.SystemStatusEnum\n  ImportDuplicateEnum:\n    model: github.com/stashapp/stash/internal/manager.ImportDuplicateEnum\n  SetupInput:\n    model: github.com/stashapp/stash/internal/manager.SetupInput\n  MigrateInput:\n    model: github.com/stashapp/stash/internal/manager.MigrateInput\n  ScanMetadataInput:\n    model: github.com/stashapp/stash/internal/manager.ScanMetadataInput\n  GenerateMetadataInput:\n    model: github.com/stashapp/stash/internal/manager.GenerateMetadataInput\n  GeneratePreviewOptionsInput:\n    model: github.com/stashapp/stash/internal/manager.GeneratePreviewOptionsInput\n  AutoTagMetadataInput:\n    model: github.com/stashapp/stash/internal/manager.AutoTagMetadataInput\n  CleanMetadataInput:\n    model: github.com/stashapp/stash/internal/manager.CleanMetadataInput\n  StashBoxBatchTagInput:\n    model: github.com/stashapp/stash/internal/manager.StashBoxBatchTagInput\n  SceneStreamEndpoint:\n    model: github.com/stashapp/stash/internal/manager.SceneStreamEndpoint\n  ExportObjectTypeInput:\n    model: github.com/stashapp/stash/internal/manager.ExportObjectTypeInput\n  ExportObjectsInput:\n    model: github.com/stashapp/stash/internal/manager.ExportObjectsInput\n  ImportObjectsInput:\n    model: github.com/stashapp/stash/internal/manager.ImportObjectsInput\n  ScanMetaDataFilterInput:\n    model: github.com/stashapp/stash/internal/manager.ScanMetaDataFilterInput\n  # renamed types\n  BulkUpdateIdMode:\n    model: github.com/stashapp/stash/pkg/models.RelationshipUpdateMode\n  DLNAStatus:\n    model: github.com/stashapp/stash/internal/dlna.Status\n  DLNAIP:\n    model: github.com/stashapp/stash/internal/dlna.Dlnaip\n  IdentifySource:\n    model: github.com/stashapp/stash/internal/identify.Source\n  IdentifyMetadataTaskOptions:\n    model: github.com/stashapp/stash/internal/identify.Options\n  IdentifyMetadataInput:\n    model: github.com/stashapp/stash/internal/identify.Options\n  IdentifyMetadataOptions:\n    model: github.com/stashapp/stash/internal/identify.MetadataOptions\n  IdentifyFieldOptions:\n    model: github.com/stashapp/stash/internal/identify.FieldOptions\n  IdentifyFieldStrategy:\n    model: github.com/stashapp/stash/internal/identify.FieldStrategy\n  ScraperSource:\n    model: github.com/stashapp/stash/pkg/scraper.Source\n  IdentifySourceInput:\n    model: github.com/stashapp/stash/internal/identify.Source\n  IdentifyFieldOptionsInput:\n    model: github.com/stashapp/stash/internal/identify.FieldOptions\n  IdentifyMetadataOptionsInput:\n    model: github.com/stashapp/stash/internal/identify.MetadataOptions\n  ScraperSourceInput:\n    model: github.com/stashapp/stash/pkg/scraper.Source\n  SavedFindFilterType:\n    model: github.com/stashapp/stash/pkg/models.FindFilterType\n  # force resolvers\n  ConfigResult:\n    fields:\n      plugins:\n        resolver: true\n  Performer:\n    fields:\n      career_length:\n        resolver: true\n  \n"
  },
  {
    "path": "graphql/schema/schema.graphql",
    "content": "\"The query root for this schema\"\ntype Query {\n  # Filters\n  findSavedFilter(id: ID!): SavedFilter\n  findSavedFilters(mode: FilterMode): [SavedFilter!]!\n  findDefaultFilter(mode: FilterMode!): SavedFilter\n    @deprecated(reason: \"default filter now stored in UI config\")\n\n  \"Find a file by its id or path\"\n  findFile(id: ID, path: String): BaseFile!\n\n  \"Queries for Files\"\n  findFiles(\n    file_filter: FileFilterType\n    filter: FindFilterType\n    ids: [ID!]\n  ): FindFilesResultType!\n\n  \"Find a file by its id or path\"\n  findFolder(id: ID, path: String): Folder!\n\n  \"Queries for Files\"\n  findFolders(\n    folder_filter: FolderFilterType\n    filter: FindFilterType\n    ids: [ID!]\n  ): FindFoldersResultType!\n\n  \"Find a scene by ID or Checksum\"\n  findScene(id: ID, checksum: String): Scene\n  findSceneByHash(input: SceneHashInput!): Scene\n\n  \"A function which queries Scene objects\"\n  findScenes(\n    scene_filter: SceneFilterType\n    scene_ids: [Int!] @deprecated(reason: \"use ids\")\n    ids: [ID!]\n    filter: FindFilterType\n  ): FindScenesResultType!\n\n  findScenesByPathRegex(filter: FindFilterType): FindScenesResultType!\n\n  \"\"\"\n  Returns any groups of scenes that are perceptual duplicates within the queried distance\n  and the difference between their duration is smaller than durationDiff\n  \"\"\"\n  findDuplicateScenes(\n    distance: Int\n    \"\"\"\n    Max difference in seconds between files in order to be considered for similarity matching.\n    Fractional seconds are ok: 0.5 will mean only files that have durations within 0.5 seconds between them will be matched based on PHash distance.\n    \"\"\"\n    duration_diff: Float\n  ): [[Scene!]!]!\n\n  \"Return valid stream paths\"\n  sceneStreams(id: ID): [SceneStreamEndpoint!]!\n\n  parseSceneFilenames(\n    filter: FindFilterType\n    config: SceneParserInput!\n  ): SceneParserResultType!\n\n  \"A function which queries SceneMarker objects\"\n  findSceneMarkers(\n    scene_marker_filter: SceneMarkerFilterType\n    filter: FindFilterType\n    ids: [ID!]\n  ): FindSceneMarkersResultType!\n\n  findImage(id: ID, checksum: String): Image\n\n  \"A function which queries Scene objects\"\n  findImages(\n    image_filter: ImageFilterType\n    image_ids: [Int!] @deprecated(reason: \"use ids\")\n    ids: [ID!]\n    filter: FindFilterType\n  ): FindImagesResultType!\n\n  \"Find a performer by ID\"\n  findPerformer(id: ID!): Performer\n  \"A function which queries Performer objects\"\n  findPerformers(\n    performer_filter: PerformerFilterType\n    filter: FindFilterType\n    performer_ids: [Int!] @deprecated(reason: \"use ids\")\n    ids: [ID!]\n  ): FindPerformersResultType!\n\n  \"Find a studio by ID\"\n  findStudio(id: ID!): Studio\n  \"A function which queries Studio objects\"\n  findStudios(\n    studio_filter: StudioFilterType\n    filter: FindFilterType\n    ids: [ID!]\n  ): FindStudiosResultType!\n\n  \"Find a movie by ID\"\n  findMovie(id: ID!): Movie @deprecated(reason: \"Use findGroup instead\")\n  \"A function which queries Movie objects\"\n  findMovies(\n    movie_filter: MovieFilterType\n    filter: FindFilterType\n    ids: [ID!]\n  ): FindMoviesResultType! @deprecated(reason: \"Use findGroups instead\")\n\n  \"Find a group by ID\"\n  findGroup(id: ID!): Group\n  \"A function which queries Group objects\"\n  findGroups(\n    group_filter: GroupFilterType\n    filter: FindFilterType\n    ids: [ID!]\n  ): FindGroupsResultType!\n\n  findGallery(id: ID!): Gallery\n  findGalleries(\n    gallery_filter: GalleryFilterType\n    filter: FindFilterType\n    ids: [ID!]\n  ): FindGalleriesResultType!\n\n  findTag(id: ID!): Tag\n  findTags(\n    tag_filter: TagFilterType\n    filter: FindFilterType\n    ids: [ID!]\n  ): FindTagsResultType!\n\n  \"Retrieve random scene markers for the wall\"\n  markerWall(q: String): [SceneMarker!]!\n  \"Retrieve random scenes for the wall\"\n  sceneWall(q: String): [Scene!]!\n\n  \"Get marker strings\"\n  markerStrings(q: String, sort: String): [MarkerStringsResultType]!\n  \"Get stats\"\n  stats: StatsResultType!\n  \"Organize scene markers by tag for a given scene ID\"\n  sceneMarkerTags(scene_id: ID!): [SceneMarkerTag!]!\n\n  logs: [LogEntry!]!\n\n  # Scrapers\n\n  \"List available scrapers\"\n  listScrapers(types: [ScrapeContentType!]!): [Scraper!]!\n\n  \"Scrape for a single scene\"\n  scrapeSingleScene(\n    source: ScraperSourceInput!\n    input: ScrapeSingleSceneInput!\n  ): [ScrapedScene!]!\n  \"Scrape for multiple scenes\"\n  scrapeMultiScenes(\n    source: ScraperSourceInput!\n    input: ScrapeMultiScenesInput!\n  ): [[ScrapedScene!]!]!\n\n  \"Scrape for a single studio\"\n  scrapeSingleStudio(\n    source: ScraperSourceInput!\n    input: ScrapeSingleStudioInput!\n  ): [ScrapedStudio!]!\n\n  \"Scrape for a single tag\"\n  scrapeSingleTag(\n    source: ScraperSourceInput!\n    input: ScrapeSingleTagInput!\n  ): [ScrapedTag!]!\n\n  \"Scrape for a single performer\"\n  scrapeSinglePerformer(\n    source: ScraperSourceInput!\n    input: ScrapeSinglePerformerInput!\n  ): [ScrapedPerformer!]!\n  \"Scrape for multiple performers\"\n  scrapeMultiPerformers(\n    source: ScraperSourceInput!\n    input: ScrapeMultiPerformersInput!\n  ): [[ScrapedPerformer!]!]!\n\n  \"Scrape for a single gallery\"\n  scrapeSingleGallery(\n    source: ScraperSourceInput!\n    input: ScrapeSingleGalleryInput!\n  ): [ScrapedGallery!]!\n\n  \"Scrape for a single movie\"\n  scrapeSingleMovie(\n    source: ScraperSourceInput!\n    input: ScrapeSingleMovieInput!\n  ): [ScrapedMovie!]! @deprecated(reason: \"Use scrapeSingleGroup instead\")\n\n  \"Scrape for a single group\"\n  scrapeSingleGroup(\n    source: ScraperSourceInput!\n    input: ScrapeSingleGroupInput!\n  ): [ScrapedGroup!]!\n\n  \"Scrape for a single image\"\n  scrapeSingleImage(\n    source: ScraperSourceInput!\n    input: ScrapeSingleImageInput!\n  ): [ScrapedImage!]!\n\n  \"Scrapes content based on a URL\"\n  scrapeURL(url: String!, ty: ScrapeContentType!): ScrapedContent\n\n  \"Scrapes a complete performer record based on a URL\"\n  scrapePerformerURL(url: String!): ScrapedPerformer\n  \"Scrapes a complete scene record based on a URL\"\n  scrapeSceneURL(url: String!): ScrapedScene\n  \"Scrapes a complete gallery record based on a URL\"\n  scrapeGalleryURL(url: String!): ScrapedGallery\n  \"Scrapes a complete image record based on a URL\"\n  scrapeImageURL(url: String!): ScrapedImage\n  \"Scrapes a complete movie record based on a URL\"\n  scrapeMovieURL(url: String!): ScrapedMovie\n    @deprecated(reason: \"Use scrapeGroupURL instead\")\n  \"Scrapes a complete group record based on a URL\"\n  scrapeGroupURL(url: String!): ScrapedGroup\n\n  # Plugins\n  \"List loaded plugins\"\n  plugins: [Plugin!]\n  \"List available plugin operations\"\n  pluginTasks: [PluginTask!]\n\n  # Packages\n  \"List installed packages\"\n  installedPackages(type: PackageType!): [Package!]!\n  \"List available packages\"\n  availablePackages(type: PackageType!, source: String!): [Package!]!\n\n  # Config\n  \"Returns the current, complete configuration\"\n  configuration: ConfigResult!\n  \"Returns an array of paths for the given path\"\n  directory(\n    \"The directory path to list\"\n    path: String\n    \"Desired collation locale. Determines the order of the directory result. eg. 'en-US', 'pt-BR', ...\"\n    locale: String = \"en\"\n  ): Directory!\n  validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult!\n\n  # System status\n  systemStatus: SystemStatus!\n\n  # Job status\n  jobQueue: [Job!]\n  findJob(input: FindJobInput!): Job\n\n  dlnaStatus: DLNAStatus!\n\n  # Get everything\n\n  allScenes: [Scene!]! @deprecated(reason: \"Use findScenes instead\")\n  allSceneMarkers: [SceneMarker!]!\n    @deprecated(reason: \"Use findSceneMarkers instead\")\n  allImages: [Image!]! @deprecated(reason: \"Use findImages instead\")\n  allGalleries: [Gallery!]! @deprecated(reason: \"Use findGalleries instead\")\n\n  allPerformers: [Performer!]!\n  allTags: [Tag!]! @deprecated(reason: \"Use findTags instead\")\n  allStudios: [Studio!]! @deprecated(reason: \"Use findStudios instead\")\n  allMovies: [Movie!]! @deprecated(reason: \"Use findGroups instead\")\n\n  # Get everything with minimal metadata\n\n  # Version\n  version: Version!\n\n  # LatestVersion\n  latestversion: LatestVersion!\n}\n\ntype Mutation {\n  setup(input: SetupInput!): Boolean!\n\n  \"Migrates the schema to the required version. Returns the job ID\"\n  migrate(input: MigrateInput!): ID!\n\n  \"Downloads and installs ffmpeg and ffprobe binaries into the configuration directory. Returns the job ID.\"\n  downloadFFMpeg: ID!\n\n  sceneCreate(input: SceneCreateInput!): Scene\n  sceneUpdate(input: SceneUpdateInput!): Scene\n  sceneMerge(input: SceneMergeInput!): Scene\n  bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]\n  sceneDestroy(input: SceneDestroyInput!): Boolean!\n  scenesDestroy(input: ScenesDestroyInput!): Boolean!\n  scenesUpdate(input: [SceneUpdateInput!]!): [Scene]\n\n  \"Increments the o-counter for a scene. Returns the new value\"\n  sceneIncrementO(id: ID!): Int! @deprecated(reason: \"Use sceneAddO instead\")\n  \"Decrements the o-counter for a scene. Returns the new value\"\n  sceneDecrementO(id: ID!): Int! @deprecated(reason: \"Use sceneRemoveO instead\")\n\n  \"Increments the o-counter for a scene. Uses the current time if none provided.\"\n  sceneAddO(id: ID!, times: [Timestamp!]): HistoryMutationResult!\n  \"Decrements the o-counter for a scene, removing the last recorded time if specific time not provided. Returns the new value\"\n  sceneDeleteO(id: ID!, times: [Timestamp!]): HistoryMutationResult!\n\n  \"Resets the o-counter for a scene to 0. Returns the new value\"\n  sceneResetO(id: ID!): Int!\n\n  \"Sets the resume time point (if provided) and adds the provided duration to the scene's play duration\"\n  sceneSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean!\n\n  \"Resets the resume time point and play duration\"\n  sceneResetActivity(\n    id: ID!\n    reset_resume: Boolean\n    reset_duration: Boolean\n  ): Boolean!\n\n  \"Increments the play count for the scene. Returns the new play count value.\"\n  sceneIncrementPlayCount(id: ID!): Int!\n    @deprecated(reason: \"Use sceneAddPlay instead\")\n\n  \"Increments the play count for the scene. Uses the current time if none provided.\"\n  sceneAddPlay(id: ID!, times: [Timestamp!]): HistoryMutationResult!\n  \"Decrements the play count for the scene, removing the specific times or the last recorded time if not provided.\"\n  sceneDeletePlay(id: ID!, times: [Timestamp!]): HistoryMutationResult!\n  \"Resets the play count for a scene to 0. Returns the new play count value.\"\n  sceneResetPlayCount(id: ID!): Int!\n\n  \"Generates screenshot at specified time in seconds. Leave empty to generate default screenshot\"\n  sceneGenerateScreenshot(id: ID!, at: Float): String!\n\n  sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker\n  sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker\n  bulkSceneMarkerUpdate(input: BulkSceneMarkerUpdateInput!): [SceneMarker!]\n  sceneMarkerDestroy(id: ID!): Boolean!\n  sceneMarkersDestroy(ids: [ID!]!): Boolean!\n\n  sceneAssignFile(input: AssignSceneFileInput!): Boolean!\n\n  imageUpdate(input: ImageUpdateInput!): Image\n  bulkImageUpdate(input: BulkImageUpdateInput!): [Image!]\n  imageDestroy(input: ImageDestroyInput!): Boolean!\n  imagesDestroy(input: ImagesDestroyInput!): Boolean!\n  imagesUpdate(input: [ImageUpdateInput!]!): [Image]\n\n  \"Increments the o-counter for an image. Returns the new value\"\n  imageIncrementO(id: ID!): Int!\n  \"Decrements the o-counter for an image. Returns the new value\"\n  imageDecrementO(id: ID!): Int!\n  \"Resets the o-counter for a image to 0. Returns the new value\"\n  imageResetO(id: ID!): Int!\n\n  galleryCreate(input: GalleryCreateInput!): Gallery\n  galleryUpdate(input: GalleryUpdateInput!): Gallery\n  bulkGalleryUpdate(input: BulkGalleryUpdateInput!): [Gallery!]\n  galleryDestroy(input: GalleryDestroyInput!): Boolean!\n  galleriesUpdate(input: [GalleryUpdateInput!]!): [Gallery]\n\n  addGalleryImages(input: GalleryAddInput!): Boolean!\n  removeGalleryImages(input: GalleryRemoveInput!): Boolean!\n  setGalleryCover(input: GallerySetCoverInput!): Boolean!\n  resetGalleryCover(input: GalleryResetCoverInput!): Boolean!\n\n  galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter\n  galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter\n  galleryChapterDestroy(id: ID!): Boolean!\n\n  performerCreate(input: PerformerCreateInput!): Performer\n  performerUpdate(input: PerformerUpdateInput!): Performer\n  performerDestroy(input: PerformerDestroyInput!): Boolean!\n  performersDestroy(ids: [ID!]!): Boolean!\n  bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]\n  performerMerge(input: PerformerMergeInput!): Performer!\n\n  studioCreate(input: StudioCreateInput!): Studio\n  studioUpdate(input: StudioUpdateInput!): Studio\n  studioDestroy(input: StudioDestroyInput!): Boolean!\n  studiosDestroy(ids: [ID!]!): Boolean!\n  bulkStudioUpdate(input: BulkStudioUpdateInput!): [Studio!]\n\n  movieCreate(input: MovieCreateInput!): Movie\n    @deprecated(reason: \"Use groupCreate instead\")\n  movieUpdate(input: MovieUpdateInput!): Movie\n    @deprecated(reason: \"Use groupUpdate instead\")\n  movieDestroy(input: MovieDestroyInput!): Boolean!\n    @deprecated(reason: \"Use groupDestroy instead\")\n  moviesDestroy(ids: [ID!]!): Boolean!\n    @deprecated(reason: \"Use groupsDestroy instead\")\n  bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!]\n    @deprecated(reason: \"Use bulkGroupUpdate instead\")\n\n  groupCreate(input: GroupCreateInput!): Group\n  groupUpdate(input: GroupUpdateInput!): Group\n  groupDestroy(input: GroupDestroyInput!): Boolean!\n  groupsDestroy(ids: [ID!]!): Boolean!\n  bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!]\n\n  addGroupSubGroups(input: GroupSubGroupAddInput!): Boolean!\n  removeGroupSubGroups(input: GroupSubGroupRemoveInput!): Boolean!\n\n  \"Reorder sub groups within a group. Returns true if successful.\"\n  reorderSubGroups(input: ReorderSubGroupsInput!): Boolean!\n\n  tagCreate(input: TagCreateInput!): Tag\n  tagUpdate(input: TagUpdateInput!): Tag\n  tagDestroy(input: TagDestroyInput!): Boolean!\n  tagsDestroy(ids: [ID!]!): Boolean!\n  tagsMerge(input: TagsMergeInput!): Tag\n  bulkTagUpdate(input: BulkTagUpdateInput!): [Tag!]\n\n  \"\"\"\n  Moves the given files to the given destination. Returns true if successful.\n  Either the destination_folder or destination_folder_id must be provided.\n  If both are provided, the destination_folder_id takes precedence.\n  Destination folder must be a subfolder of one of the stash library paths.\n  If provided, destination_basename must be a valid filename with an extension that\n  matches one of the media extensions.\n  Creates folder hierarchy if needed.\n  \"\"\"\n  moveFiles(input: MoveFilesInput!): Boolean!\n  deleteFiles(ids: [ID!]!): Boolean!\n  \"Deletes file entries from the database without deleting the files from the filesystem\"\n  destroyFiles(ids: [ID!]!): Boolean!\n\n  fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean!\n  \"Reveal the file in the system file manager\"\n  revealFileInFileManager(id: ID!): Boolean!\n  \"Reveal the folder in the system file manager\"\n  revealFolderInFileManager(id: ID!): Boolean!\n\n  # Saved filters\n  saveFilter(input: SaveFilterInput!): SavedFilter!\n  destroySavedFilter(input: DestroyFilterInput!): Boolean!\n  setDefaultFilter(input: SetDefaultFilterInput!): Boolean!\n    @deprecated(reason: \"now uses UI config\")\n\n  \"Change general configuration options\"\n  configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult!\n  configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult!\n  configureDLNA(input: ConfigDLNAInput!): ConfigDLNAResult!\n  configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!\n  configureDefaults(\n    input: ConfigDefaultSettingsInput!\n  ): ConfigDefaultSettingsResult!\n\n  \"overwrites the entire plugin configuration for the given plugin\"\n  configurePlugin(plugin_id: ID!, input: Map!): Map!\n\n  \"\"\"\n  overwrites the UI configuration\n  if input is provided, then the entire UI configuration is replaced\n  if partial is provided, then the partial UI configuration is merged into the existing UI configuration\n  \"\"\"\n  configureUI(input: Map, partial: Map): Map!\n  \"\"\"\n  sets a single UI key value\n  key is a dot separated path to the value\n  \"\"\"\n  configureUISetting(key: String!, value: Any): Map!\n\n  \"Generate and set (or clear) API key\"\n  generateAPIKey(input: GenerateAPIKeyInput!): String!\n\n  \"Returns a link to download the result\"\n  exportObjects(input: ExportObjectsInput!): String\n\n  \"Performs an incremental import. Returns the job ID\"\n  importObjects(input: ImportObjectsInput!): ID!\n\n  \"Start an full import. Completely wipes the database and imports from the metadata directory. Returns the job ID\"\n  metadataImport: ID!\n  \"Start a full export. Outputs to the metadata directory. Returns the job ID\"\n  metadataExport: ID!\n  \"Start a scan. Returns the job ID\"\n  metadataScan(input: ScanMetadataInput!): ID!\n  \"Start generating content. Returns the job ID\"\n  metadataGenerate(input: GenerateMetadataInput!): ID!\n  \"Start auto-tagging. Returns the job ID\"\n  metadataAutoTag(input: AutoTagMetadataInput!): ID!\n  \"Clean metadata. Returns the job ID\"\n  metadataClean(input: CleanMetadataInput!): ID!\n  \"Clean generated files. Returns the job ID\"\n  metadataCleanGenerated(input: CleanGeneratedInput!): ID!\n  \"Identifies scenes using scrapers. Returns the job ID\"\n  metadataIdentify(input: IdentifyMetadataInput!): ID!\n\n  \"Migrate generated files for the current hash naming\"\n  migrateHashNaming: ID!\n  \"Migrates legacy scene screenshot files into the blob storage\"\n  migrateSceneScreenshots(input: MigrateSceneScreenshotsInput!): ID!\n  \"Migrates blobs from the old storage system to the current one\"\n  migrateBlobs(input: MigrateBlobsInput!): ID!\n\n  \"Anonymise the database in a separate file. Optionally returns a link to download the database file\"\n  anonymiseDatabase(input: AnonymiseDatabaseInput!): String\n\n  \"Optimises the database. Returns the job ID\"\n  optimiseDatabase: ID!\n\n  \"Reload scrapers\"\n  reloadScrapers: Boolean!\n\n  \"\"\"\n  Enable/disable plugins - enabledMap is a map of plugin IDs to enabled booleans.\n  Plugins not in the map are not affected.\n  \"\"\"\n  setPluginsEnabled(enabledMap: BoolMap!): Boolean!\n\n  \"\"\"\n  Run a plugin task.\n  If task_name is provided, then the task must exist in the plugin config and the tasks configuration\n  will be used to run the plugin.\n  If no task_name is provided, then the plugin will be executed with the arguments provided only.\n  Returns the job ID\n  \"\"\"\n  runPluginTask(\n    plugin_id: ID!\n    \"if provided, then the default args will be applied\"\n    task_name: String\n    \"displayed in the task queue\"\n    description: String\n    args: [PluginArgInput!] @deprecated(reason: \"Use args_map instead\")\n    args_map: Map\n  ): ID!\n\n  \"\"\"\n  Runs a plugin operation. The operation is run immediately and does not use the job queue.\n  Returns a map of the result.\n  \"\"\"\n  runPluginOperation(plugin_id: ID!, args: Map): Any\n\n  reloadPlugins: Boolean!\n\n  \"\"\"\n  Installs the given packages.\n  If a package is already installed, it will be updated if needed..\n  If an error occurs when installing a package, the job will continue to install the remaining packages.\n  Returns the job ID\n  \"\"\"\n  installPackages(type: PackageType!, packages: [PackageSpecInput!]!): ID!\n  \"\"\"\n  Updates the given packages.\n  If a package is not installed, it will not be installed.\n  If a package does not need to be updated, it will not be updated.\n  If no packages are provided, all packages of the given type will be updated.\n  If an error occurs when updating a package, the job will continue to update the remaining packages.\n  Returns the job ID.\n  \"\"\"\n  updatePackages(type: PackageType!, packages: [PackageSpecInput!]): ID!\n  \"\"\"\n  Uninstalls the given packages.\n  If an error occurs when uninstalling a package, the job will continue to uninstall the remaining packages.\n  Returns the job ID\n  \"\"\"\n  uninstallPackages(type: PackageType!, packages: [PackageSpecInput!]!): ID!\n\n  stopJob(job_id: ID!): Boolean!\n  stopAllJobs: Boolean!\n\n  \"Submit fingerprints to stash-box instance\"\n  submitStashBoxFingerprints(\n    input: StashBoxFingerprintSubmissionInput!\n  ): Boolean!\n\n  \"Submit scene as draft to stash-box instance\"\n  submitStashBoxSceneDraft(input: StashBoxDraftSubmissionInput!): ID\n  \"Submit performer as draft to stash-box instance\"\n  submitStashBoxPerformerDraft(input: StashBoxDraftSubmissionInput!): ID\n\n  \"Backup the database. Optionally returns a link to download the database file\"\n  backupDatabase(input: BackupDatabaseInput!): String\n\n  \"DANGEROUS: Execute an arbitrary SQL statement that returns rows.\"\n  querySQL(sql: String!, args: [Any]): SQLQueryResult!\n\n  \"DANGEROUS: Execute an arbitrary SQL statement without returning any rows.\"\n  execSQL(sql: String!, args: [Any]): SQLExecResult!\n\n  \"Run batch performer tag task. Returns the job ID.\"\n  stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String!\n  \"Run batch studio tag task. Returns the job ID.\"\n  stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String!\n  \"Run batch tag tag task. Returns the job ID.\"\n  stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String!\n\n  \"Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default\"\n  enableDLNA(input: EnableDLNAInput!): Boolean!\n  \"Disables DLNA for an optional duration. Has no effect if DLNA is disabled by default\"\n  disableDLNA(input: DisableDLNAInput!): Boolean!\n  \"Enables an IP address for DLNA for an optional duration\"\n  addTempDLNAIP(input: AddTempDLNAIPInput!): Boolean!\n  \"Removes an IP address from the temporary DLNA whitelist\"\n  removeTempDLNAIP(input: RemoveTempDLNAIPInput!): Boolean!\n}\n\ntype Subscription {\n  \"Update from the metadata manager\"\n  jobsSubscribe: JobStatusUpdate!\n\n  loggingSubscribe: [LogEntry!]!\n\n  scanCompleteSubscribe: Boolean!\n}\n\nschema {\n  query: Query\n  mutation: Mutation\n  subscription: Subscription\n}\n"
  },
  {
    "path": "graphql/schema/types/config.graphql",
    "content": "input SetupInput {\n  \"Empty to indicate $HOME/.stash/config.yml default\"\n  configLocation: String!\n  stashes: [StashConfigInput!]!\n  \"True if SFW content mode is enabled\"\n  sfwContentMode: Boolean\n  \"Empty to indicate default\"\n  databaseFile: String!\n  \"Empty to indicate default\"\n  generatedLocation: String!\n  \"Empty to indicate default\"\n  cacheLocation: String!\n  storeBlobsInDatabase: Boolean!\n  \"Empty to indicate default - only applicable if storeBlobsInDatabase is false\"\n  blobsLocation: String!\n}\n\nenum StreamingResolutionEnum {\n  \"240p\"\n  LOW\n  \"480p\"\n  STANDARD\n  \"720p\"\n  STANDARD_HD\n  \"1080p\"\n  FULL_HD\n  \"4k\"\n  FOUR_K\n  \"Original\"\n  ORIGINAL\n}\n\nenum PreviewPreset {\n  \"X264_ULTRAFAST\"\n  ultrafast\n  \"X264_VERYFAST\"\n  veryfast\n  \"X264_FAST\"\n  fast\n  \"X264_MEDIUM\"\n  medium\n  \"X264_SLOW\"\n  slow\n  \"X264_SLOWER\"\n  slower\n  \"X264_VERYSLOW\"\n  veryslow\n}\n\nenum HashAlgorithm {\n  MD5\n  \"oshash\"\n  OSHASH\n}\n\nenum BlobsStorageType {\n  # blobs are stored in the database\n  \"Database\"\n  DATABASE\n  # blobs are stored in the filesystem under the configured blobs directory\n  \"Filesystem\"\n  FILESYSTEM\n}\n\ninput ConfigGeneralInput {\n  \"Array of file paths to content\"\n  stashes: [StashConfigInput!]\n  \"Path to the SQLite database\"\n  databasePath: String\n  \"Path to backup directory\"\n  backupDirectoryPath: String\n  \"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted\"\n  deleteTrashPath: String\n  \"Path to generated files\"\n  generatedPath: String\n  \"Path to import/export files\"\n  metadataPath: String\n  \"Path to scrapers\"\n  scrapersPath: String\n  \"Path to plugins\"\n  pluginsPath: String\n  \"Path to cache\"\n  cachePath: String\n  \"Path to blobs - required for filesystem blob storage\"\n  blobsPath: String\n  \"Where to store blobs\"\n  blobsStorage: BlobsStorageType\n  \"Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory\"\n  ffmpegPath: String\n  \"Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory\"\n  ffprobePath: String\n  \"Whether to calculate MD5 checksums for scene video files\"\n  calculateMD5: Boolean\n  \"Hash algorithm to use for generated file naming\"\n  videoFileNamingAlgorithm: HashAlgorithm\n  \"Number of parallel tasks to start during scan/generate\"\n  parallelTasks: Int\n  \"Include audio stream in previews\"\n  previewAudio: Boolean\n  \"Number of segments in a preview file\"\n  previewSegments: Int\n  \"Preview segment duration, in seconds\"\n  previewSegmentDuration: Float\n  \"Duration of start of video to exclude when generating previews\"\n  previewExcludeStart: String\n  \"Duration of end of video to exclude when generating previews\"\n  previewExcludeEnd: String\n  \"Preset when generating preview\"\n  previewPreset: PreviewPreset\n  \"Transcode Hardware Acceleration\"\n  transcodeHardwareAcceleration: Boolean\n  \"Max generated transcode size\"\n  maxTranscodeSize: StreamingResolutionEnum\n  \"Max streaming transcode size\"\n  maxStreamingTranscodeSize: StreamingResolutionEnum\n\n  \"\"\"\n  ffmpeg transcode input args - injected before input file\n  These are applied to generated transcodes (previews and transcodes)\n  \"\"\"\n  transcodeInputArgs: [String!]\n  \"\"\"\n  ffmpeg transcode output args - injected before output file\n  These are applied to generated transcodes (previews and transcodes)\n  \"\"\"\n  transcodeOutputArgs: [String!]\n\n  \"\"\"\n  ffmpeg stream input args - injected before input file\n  These are applied when live transcoding\n  \"\"\"\n  liveTranscodeInputArgs: [String!]\n  \"\"\"\n  ffmpeg stream output args - injected before output file\n  These are applied when live transcoding\n  \"\"\"\n  liveTranscodeOutputArgs: [String!]\n\n  \"whether to include range in generated funscript heatmaps\"\n  drawFunscriptHeatmapRange: Boolean\n\n  \"Write image thumbnails to disk when generating on the fly\"\n  writeImageThumbnails: Boolean\n  \"Create Image Clips from Video extensions when Videos are disabled in Library\"\n  createImageClipsFromVideos: Boolean\n  \"Username\"\n  username: String\n  \"Password\"\n  password: String\n  \"Maximum session cookie age\"\n  maxSessionAge: Int\n  \"Name of the log file\"\n  logFile: String\n  \"Whether to also output to stderr\"\n  logOut: Boolean\n  \"Minimum log level\"\n  logLevel: String\n  \"Whether to log http access\"\n  logAccess: Boolean\n  \"Maximum log size\"\n  logFileMaxSize: Int\n  \"True if galleries should be created from folders with images\"\n  createGalleriesFromFolders: Boolean\n  \"Regex used to identify images as gallery covers\"\n  galleryCoverRegex: String\n  \"Array of video file extensions\"\n  videoExtensions: [String!]\n  \"Array of image file extensions\"\n  imageExtensions: [String!]\n  \"Array of gallery zip file extensions\"\n  galleryExtensions: [String!]\n  \"Array of file regexp to exclude from Video Scans\"\n  excludes: [String!]\n  \"Array of file regexp to exclude from Image Scans\"\n  imageExcludes: [String!]\n  \"Custom Performer Image Location\"\n  customPerformerImageLocation: String\n  \"Stash-box instances used for tagging\"\n  stashBoxes: [StashBoxInput!]\n  \"Python path - resolved using path if unset\"\n  pythonPath: String\n\n  \"Source of scraper packages\"\n  scraperPackageSources: [PackageSourceInput!]\n  \"Source of plugin packages\"\n  pluginPackageSources: [PackageSourceInput!]\n\n  \"Size of the longest dimension for each sprite in pixels\"\n  spriteScreenshotSize: Int\n\n  \"True if sprite generation should use the sprite interval and min/max sprites settings instead of the default\"\n  useCustomSpriteInterval: Boolean\n  \"Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true\"\n  spriteInterval: Float\n  \"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true\"\n  minimumSprites: Int\n  \"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true\"\n  maximumSprites: Int\n}\n\ntype ConfigGeneralResult {\n  \"Array of file paths to content\"\n  stashes: [StashConfig!]!\n  \"Path to the SQLite database\"\n  databasePath: String!\n  \"Path to backup directory\"\n  backupDirectoryPath: String!\n  \"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted\"\n  deleteTrashPath: String!\n  \"Path to generated files\"\n  generatedPath: String!\n  \"Path to import/export files\"\n  metadataPath: String!\n  \"Path to the config file used\"\n  configFilePath: String!\n  \"Path to scrapers\"\n  scrapersPath: String!\n  \"Path to plugins\"\n  pluginsPath: String!\n  \"Path to cache\"\n  cachePath: String!\n  \"Path to blobs - required for filesystem blob storage\"\n  blobsPath: String!\n  \"Where to store blobs\"\n  blobsStorage: BlobsStorageType!\n  \"Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory\"\n  ffmpegPath: String!\n  \"Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory\"\n  ffprobePath: String!\n  \"Whether to calculate MD5 checksums for scene video files\"\n  calculateMD5: Boolean!\n  \"Hash algorithm to use for generated file naming\"\n  videoFileNamingAlgorithm: HashAlgorithm!\n  \"Number of parallel tasks to start during scan/generate\"\n  parallelTasks: Int!\n  \"Include audio stream in previews\"\n  previewAudio: Boolean!\n  \"Number of segments in a preview file\"\n  previewSegments: Int!\n  \"Preview segment duration, in seconds\"\n  previewSegmentDuration: Float!\n  \"Duration of start of video to exclude when generating previews\"\n  previewExcludeStart: String!\n  \"Duration of end of video to exclude when generating previews\"\n  previewExcludeEnd: String!\n  \"Preset when generating preview\"\n  previewPreset: PreviewPreset!\n  \"Transcode Hardware Acceleration\"\n  transcodeHardwareAcceleration: Boolean!\n  \"Max generated transcode size\"\n  maxTranscodeSize: StreamingResolutionEnum\n  \"Max streaming transcode size\"\n  maxStreamingTranscodeSize: StreamingResolutionEnum\n\n  \"\"\"\n  ffmpeg transcode input args - injected before input file\n  These are applied to generated transcodes (previews and transcodes)\n  \"\"\"\n  transcodeInputArgs: [String!]!\n  \"\"\"\n  ffmpeg transcode output args - injected before output file\n  These are applied to generated transcodes (previews and transcodes)\n  \"\"\"\n  transcodeOutputArgs: [String!]!\n\n  \"\"\"\n  ffmpeg stream input args - injected before input file\n  These are applied when live transcoding\n  \"\"\"\n  liveTranscodeInputArgs: [String!]!\n  \"\"\"\n  ffmpeg stream output args - injected before output file\n  These are applied when live transcoding\n  \"\"\"\n  liveTranscodeOutputArgs: [String!]!\n\n  \"whether to include range in generated funscript heatmaps\"\n  drawFunscriptHeatmapRange: Boolean!\n\n  \"Write image thumbnails to disk when generating on the fly\"\n  writeImageThumbnails: Boolean!\n  \"Create Image Clips from Video extensions when Videos are disabled in Library\"\n  createImageClipsFromVideos: Boolean!\n  \"API Key\"\n  apiKey: String!\n  \"Username\"\n  username: String!\n  \"Password\"\n  password: String!\n  \"Maximum session cookie age\"\n  maxSessionAge: Int!\n  \"Name of the log file\"\n  logFile: String\n  \"Whether to also output to stderr\"\n  logOut: Boolean!\n  \"Minimum log level\"\n  logLevel: String!\n  \"Whether to log http access\"\n  logAccess: Boolean!\n  \"Maximum log size\"\n  logFileMaxSize: Int!\n  \"True if sprite generation should use the sprite interval and min/max sprites settings instead of the default\"\n  useCustomSpriteInterval: Boolean!\n  \"Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true\"\n  spriteInterval: Float!\n  \"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true\"\n  minimumSprites: Int!\n  \"Maximum number of sprites to be generated - only used if useCustomSpriteInterval is true\"\n  maximumSprites: Int!\n  \"Size of the longest dimension for each sprite in pixels\"\n  spriteScreenshotSize: Int!\n  \"Array of video file extensions\"\n  videoExtensions: [String!]!\n  \"Array of image file extensions\"\n  imageExtensions: [String!]!\n  \"Array of gallery zip file extensions\"\n  galleryExtensions: [String!]!\n  \"True if galleries should be created from folders with images\"\n  createGalleriesFromFolders: Boolean!\n  \"Regex used to identify images as gallery covers\"\n  galleryCoverRegex: String!\n  \"Array of file regexp to exclude from Video Scans\"\n  excludes: [String!]!\n  \"Array of file regexp to exclude from Image Scans\"\n  imageExcludes: [String!]!\n  \"Custom Performer Image Location\"\n  customPerformerImageLocation: String\n  \"Stash-box instances used for tagging\"\n  stashBoxes: [StashBox!]!\n  \"Python path - resolved using path if unset\"\n  pythonPath: String!\n\n  \"Source of scraper packages\"\n  scraperPackageSources: [PackageSource!]!\n  \"Source of plugin packages\"\n  pluginPackageSources: [PackageSource!]!\n}\n\ninput ConfigDisableDropdownCreateInput {\n  performer: Boolean\n  tag: Boolean\n  studio: Boolean\n  movie: Boolean\n  gallery: Boolean\n}\n\nenum ImageLightboxDisplayMode {\n  ORIGINAL\n  FIT_XY\n  FIT_X\n}\n\nenum ImageLightboxScrollMode {\n  ZOOM\n  PAN_Y\n}\n\ninput ConfigImageLightboxInput {\n  slideshowDelay: Int\n  displayMode: ImageLightboxDisplayMode\n  scaleUp: Boolean\n  resetZoomOnNav: Boolean\n  scrollMode: ImageLightboxScrollMode\n  scrollAttemptsBeforeChange: Int\n  disableAnimation: Boolean\n}\n\ntype ConfigImageLightboxResult {\n  slideshowDelay: Int\n  displayMode: ImageLightboxDisplayMode\n  scaleUp: Boolean\n  resetZoomOnNav: Boolean\n  scrollMode: ImageLightboxScrollMode\n  scrollAttemptsBeforeChange: Int!\n  disableAnimation: Boolean\n}\n\ninput ConfigInterfaceInput {\n  \"True if SFW content mode is enabled\"\n  sfwContentMode: Boolean\n\n  \"Ordered list of items that should be shown in the menu\"\n  menuItems: [String!]\n\n  \"Enable sound on mouseover previews\"\n  soundOnPreview: Boolean\n\n  \"Show title and tags in wall view\"\n  wallShowTitle: Boolean\n  \"Wall playback type\"\n  wallPlayback: String\n\n  \"Show scene scrubber by default\"\n  showScrubber: Boolean\n\n  \"Maximum duration (in seconds) in which a scene video will loop in the scene player\"\n  maximumLoopDuration: Int\n  \"If true, video will autostart on load in the scene player\"\n  autostartVideo: Boolean\n  \"If true, video will autostart when loading from play random or play selected\"\n  autostartVideoOnPlaySelected: Boolean\n  \"If true, next scene in playlist will be played at video end by default\"\n  continuePlaylistDefault: Boolean\n\n  \"If true, studio overlays will be shown as text instead of logo images\"\n  showStudioAsText: Boolean\n\n  \"Custom CSS\"\n  css: String\n  cssEnabled: Boolean\n\n  \"Custom Javascript\"\n  javascript: String\n  javascriptEnabled: Boolean\n\n  \"Custom Locales\"\n  customLocales: String\n  customLocalesEnabled: Boolean\n\n  \"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting\"\n  disableCustomizations: Boolean\n\n  \"Interface language\"\n  language: String\n\n  imageLightbox: ConfigImageLightboxInput\n\n  \"Set to true to disable creating new objects via the dropdown menus\"\n  disableDropdownCreate: ConfigDisableDropdownCreateInput\n\n  \"Handy Connection Key\"\n  handyKey: String\n  \"Funscript Time Offset\"\n  funscriptOffset: Int\n  \"Whether to use Stash Hosted Funscript\"\n  useStashHostedFunscript: Boolean\n  \"True if we should not auto-open a browser window on startup\"\n  noBrowser: Boolean\n  \"True if we should send notifications to the desktop\"\n  notificationsEnabled: Boolean\n}\n\ntype ConfigDisableDropdownCreate {\n  performer: Boolean!\n  tag: Boolean!\n  studio: Boolean!\n  movie: Boolean!\n  gallery: Boolean!\n}\n\ntype ConfigInterfaceResult {\n  \"True if SFW content mode is enabled\"\n  sfwContentMode: Boolean!\n\n  \"Ordered list of items that should be shown in the menu\"\n  menuItems: [String!]\n\n  \"Enable sound on mouseover previews\"\n  soundOnPreview: Boolean\n\n  \"Show title and tags in wall view\"\n  wallShowTitle: Boolean\n  \"Wall playback type\"\n  wallPlayback: String\n\n  \"Show scene scrubber by default\"\n  showScrubber: Boolean\n\n  \"Maximum duration (in seconds) in which a scene video will loop in the scene player\"\n  maximumLoopDuration: Int\n  \"True if we should not auto-open a browser window on startup\"\n  noBrowser: Boolean\n  \"True if we should send desktop notifications\"\n  notificationsEnabled: Boolean\n  \"If true, video will autostart on load in the scene player\"\n  autostartVideo: Boolean\n  \"If true, video will autostart when loading from play random or play selected\"\n  autostartVideoOnPlaySelected: Boolean\n  \"If true, next scene in playlist will be played at video end by default\"\n  continuePlaylistDefault: Boolean\n\n  \"If true, studio overlays will be shown as text instead of logo images\"\n  showStudioAsText: Boolean\n\n  \"Custom CSS\"\n  css: String\n  cssEnabled: Boolean\n\n  \"Custom Javascript\"\n  javascript: String\n  javascriptEnabled: Boolean\n\n  \"Custom Locales\"\n  customLocales: String\n  customLocalesEnabled: Boolean\n\n  \"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting\"\n  disableCustomizations: Boolean\n\n  \"Interface language\"\n  language: String\n\n  imageLightbox: ConfigImageLightboxResult!\n\n  \"Fields are true if creating via dropdown menus are disabled\"\n  disableDropdownCreate: ConfigDisableDropdownCreate!\n\n  \"Handy Connection Key\"\n  handyKey: String\n  \"Funscript Time Offset\"\n  funscriptOffset: Int\n  \"Whether to use Stash Hosted Funscript\"\n  useStashHostedFunscript: Boolean\n}\n\ninput ConfigDLNAInput {\n  serverName: String\n  \"True if DLNA service should be enabled by default\"\n  enabled: Boolean\n  \"Defaults to 1338\"\n  port: Int\n  \"List of IPs whitelisted for DLNA service\"\n  whitelistedIPs: [String!]\n  \"List of interfaces to run DLNA on. Empty for all\"\n  interfaces: [String!]\n  \"Order to sort videos\"\n  videoSortOrder: String\n}\n\ntype ConfigDLNAResult {\n  serverName: String!\n  \"True if DLNA service should be enabled by default\"\n  enabled: Boolean!\n  \"Defaults to 1338\"\n  port: Int!\n  \"List of IPs whitelisted for DLNA service\"\n  whitelistedIPs: [String!]!\n  \"List of interfaces to run DLNA on. Empty for all\"\n  interfaces: [String!]!\n  \"Order to sort videos\"\n  videoSortOrder: String!\n}\n\ninput ConfigScrapingInput {\n  \"Scraper user agent string\"\n  scraperUserAgent: String\n  \"Scraper CDP path. Path to chrome executable or remote address\"\n  scraperCDPPath: String\n  \"Whether the scraper should check for invalid certificates\"\n  scraperCertCheck: Boolean\n  \"Tags blacklist during scraping\"\n  excludeTagPatterns: [String!]\n}\n\ntype ConfigScrapingResult {\n  \"Scraper user agent string\"\n  scraperUserAgent: String\n  \"Scraper CDP path. Path to chrome executable or remote address\"\n  scraperCDPPath: String\n  \"Whether the scraper should check for invalid certificates\"\n  scraperCertCheck: Boolean!\n  \"Tags blacklist during scraping\"\n  excludeTagPatterns: [String!]!\n}\n\ntype ConfigDefaultSettingsResult {\n  scan: ScanMetadataOptions\n  identify: IdentifyMetadataTaskOptions\n  autoTag: AutoTagMetadataOptions\n  generate: GenerateMetadataOptions\n\n  \"If true, delete file checkbox will be checked by default\"\n  deleteFile: Boolean\n  \"If true, delete generated supporting files checkbox will be checked by default\"\n  deleteGenerated: Boolean\n}\n\ninput ConfigDefaultSettingsInput {\n  scan: ScanMetadataInput\n  identify: IdentifyMetadataInput\n  autoTag: AutoTagMetadataInput\n  generate: GenerateMetadataInput\n\n  \"If true, delete file checkbox will be checked by default\"\n  deleteFile: Boolean\n  \"If true, delete generated files checkbox will be checked by default\"\n  deleteGenerated: Boolean\n}\n\n\"All configuration settings\"\ntype ConfigResult {\n  general: ConfigGeneralResult!\n  interface: ConfigInterfaceResult!\n  dlna: ConfigDLNAResult!\n  scraping: ConfigScrapingResult!\n  defaults: ConfigDefaultSettingsResult!\n  ui: Map!\n  plugins(include: [ID!]): PluginConfigMap!\n}\n\n\"Directory structure of a path\"\ntype Directory {\n  path: String!\n  parent: String\n  directories: [String!]!\n}\n\n\"Stash configuration details\"\ninput StashConfigInput {\n  path: String!\n  excludeVideo: Boolean!\n  excludeImage: Boolean!\n}\n\ntype StashConfig {\n  path: String!\n  excludeVideo: Boolean!\n  excludeImage: Boolean!\n}\n\ninput GenerateAPIKeyInput {\n  clear: Boolean\n}\n\ntype StashBoxValidationResult {\n  valid: Boolean!\n  status: String!\n}\n"
  },
  {
    "path": "graphql/schema/types/dlna.graphql",
    "content": "type DLNAIP {\n  ipAddress: String!\n  \"Time until IP will be no longer allowed/disallowed\"\n  until: Time\n}\n\ntype DLNAStatus {\n  running: Boolean!\n  \"If not currently running, time until it will be started. If running, time until it will be stopped\"\n  until: Time\n  recentIPAddresses: [String!]!\n  allowedIPAddresses: [DLNAIP!]!\n}\n\ninput EnableDLNAInput {\n  \"Duration to enable, in minutes. 0 or null for indefinite.\"\n  duration: Int\n}\n\ninput DisableDLNAInput {\n  \"Duration to enable, in minutes. 0 or null for indefinite.\"\n  duration: Int\n}\n\ninput AddTempDLNAIPInput {\n  address: String!\n  \"Duration to enable, in minutes. 0 or null for indefinite.\"\n  duration: Int\n}\n\ninput RemoveTempDLNAIPInput {\n  address: String!\n}\n"
  },
  {
    "path": "graphql/schema/types/file.graphql",
    "content": "type Fingerprint {\n  type: String!\n  value: String!\n}\n\ntype Folder {\n  id: ID!\n  path: String!\n  basename: String!\n\n  parent_folder_id: ID @deprecated(reason: \"Use parent_folder instead\")\n  zip_file_id: ID @deprecated(reason: \"Use zip_file instead\")\n\n  parent_folder: Folder\n  \"Returns all parent folders in order from immediate parent to top-level\"\n  parent_folders: [Folder!]!\n  zip_file: BasicFile\n\n  mod_time: Time!\n\n  created_at: Time!\n  updated_at: Time!\n}\n\ninterface BaseFile {\n  id: ID!\n  path: String!\n  basename: String!\n\n  parent_folder_id: ID! @deprecated(reason: \"Use parent_folder instead\")\n  zip_file_id: ID @deprecated(reason: \"Use zip_file instead\")\n\n  parent_folder: Folder!\n  zip_file: BasicFile\n\n  mod_time: Time!\n  size: Int64!\n\n  fingerprint(type: String!): String\n  fingerprints: [Fingerprint!]!\n\n  created_at: Time!\n  updated_at: Time!\n}\n\ntype BasicFile implements BaseFile {\n  id: ID!\n  path: String!\n  basename: String!\n\n  parent_folder_id: ID! @deprecated(reason: \"Use parent_folder instead\")\n  zip_file_id: ID @deprecated(reason: \"Use zip_file instead\")\n\n  parent_folder: Folder!\n  zip_file: BasicFile\n\n  mod_time: Time!\n  size: Int64!\n\n  fingerprint(type: String!): String\n  fingerprints: [Fingerprint!]!\n\n  created_at: Time!\n  updated_at: Time!\n}\n\ntype VideoFile implements BaseFile {\n  id: ID!\n  path: String!\n  basename: String!\n\n  parent_folder_id: ID! @deprecated(reason: \"Use parent_folder instead\")\n  zip_file_id: ID @deprecated(reason: \"Use zip_file instead\")\n\n  parent_folder: Folder!\n  zip_file: BasicFile\n\n  mod_time: Time!\n  size: Int64!\n\n  fingerprint(type: String!): String\n  fingerprints: [Fingerprint!]!\n\n  format: String!\n  width: Int!\n  height: Int!\n  duration: Float!\n  video_codec: String!\n  audio_codec: String!\n  frame_rate: Float!\n  bit_rate: Int!\n\n  created_at: Time!\n  updated_at: Time!\n}\n\ntype ImageFile implements BaseFile {\n  id: ID!\n  path: String!\n  basename: String!\n\n  parent_folder_id: ID! @deprecated(reason: \"Use parent_folder instead\")\n  zip_file_id: ID @deprecated(reason: \"Use zip_file instead\")\n\n  parent_folder: Folder!\n  zip_file: BasicFile\n\n  mod_time: Time!\n  size: Int64!\n\n  fingerprint(type: String!): String\n  fingerprints: [Fingerprint!]!\n\n  format: String!\n  width: Int!\n  height: Int!\n\n  created_at: Time!\n  updated_at: Time!\n}\n\nunion VisualFile = VideoFile | ImageFile\n\ntype GalleryFile implements BaseFile {\n  id: ID!\n  path: String!\n  basename: String!\n\n  parent_folder_id: ID! @deprecated(reason: \"Use parent_folder instead\")\n  zip_file_id: ID @deprecated(reason: \"Use zip_file instead\")\n\n  parent_folder: Folder!\n  zip_file: BasicFile\n\n  mod_time: Time!\n  size: Int64!\n\n  fingerprint(type: String!): String\n  fingerprints: [Fingerprint!]!\n\n  created_at: Time!\n  updated_at: Time!\n}\n\ninput MoveFilesInput {\n  ids: [ID!]!\n  \"valid for single or multiple file ids\"\n  destination_folder: String\n\n  \"valid for single or multiple file ids\"\n  destination_folder_id: ID\n\n  \"valid only for single file id. If empty, existing basename is used\"\n  destination_basename: String\n}\n\ninput SetFingerprintsInput {\n  type: String!\n  \"a null value will remove the fingerprint\"\n  value: String\n}\n\ninput FileSetFingerprintsInput {\n  id: ID!\n  \"only supplied fingerprint types will be modified\"\n  fingerprints: [SetFingerprintsInput!]!\n}\n\ntype FindFilesResultType {\n  count: Int!\n\n  \"Total megapixels of any image files\"\n  megapixels: Float!\n  \"Total duration in seconds of any video files\"\n  duration: Float!\n\n  \"Total file size in bytes\"\n  size: Int!\n\n  files: [BaseFile!]!\n}\n\ntype FindFoldersResultType {\n  count: Int!\n  folders: [Folder!]!\n}\n"
  },
  {
    "path": "graphql/schema/types/filters.graphql",
    "content": "enum SortDirectionEnum {\n  ASC\n  DESC\n}\n\ninput FindFilterType {\n  q: String\n  page: Int\n  \"use per_page = -1 to indicate all results. Defaults to 25.\"\n  per_page: Int\n  # TODO - this should be refactored to not use a string\n  sort: String\n  direction: SortDirectionEnum\n}\n\ntype SavedFindFilterType {\n  q: String\n  page: Int\n  \"\"\"\n  use per_page = -1 to indicate all results. Defaults to 25.\n  \"\"\"\n  per_page: Int\n  sort: String\n  direction: SortDirectionEnum\n}\n\nenum ResolutionEnum {\n  \"144p\"\n  VERY_LOW\n  \"240p\"\n  LOW\n  \"360p\"\n  R360P\n  \"480p\"\n  STANDARD\n  \"540p\"\n  WEB_HD\n  \"720p\"\n  STANDARD_HD\n  \"1080p\"\n  FULL_HD\n  \"1440p\"\n  QUAD_HD\n  \"1920p\"\n  VR_HD @deprecated(reason: \"Use 4K instead\")\n  \"4K\"\n  FOUR_K\n  \"5K\"\n  FIVE_K\n  \"6K\"\n  SIX_K\n  \"7K\"\n  SEVEN_K\n  \"8K\"\n  EIGHT_K\n  \"8K+\"\n  HUGE\n}\n\ninput ResolutionCriterionInput {\n  value: ResolutionEnum!\n  modifier: CriterionModifier!\n}\n\nenum OrientationEnum {\n  \"Landscape\"\n  LANDSCAPE\n  \"Portrait\"\n  PORTRAIT\n  \"Square\"\n  SQUARE\n}\n\ninput OrientationCriterionInput {\n  value: [OrientationEnum!]!\n}\n\ninput DuplicationCriterionInput {\n  duplicated: Boolean @deprecated(reason: \"Use phash field instead\")\n  \"Currently unimplemented. Intended for phash distance matching.\"\n  distance: Int\n  \"Filter by phash duplication\"\n  phash: Boolean\n  \"Filter by URL duplication\"\n  url: Boolean\n  \"Filter by Stash ID duplication\"\n  stash_id: Boolean\n  \"Filter by title duplication\"\n  title: Boolean\n}\n\ninput FileDuplicationCriterionInput {\n  duplicated: Boolean @deprecated(reason: \"Use phash field instead\")\n  \"Currently unimplemented. Intended for phash distance matching.\"\n  distance: Int\n  \"Filter by phash duplication\"\n  phash: Boolean\n}\n\ninput StashIDCriterionInput {\n  \"\"\"\n  If present, this value is treated as a predicate.\n  That is, it will filter based on stash_id with the matching endpoint\n  \"\"\"\n  endpoint: String\n  stash_id: String\n  modifier: CriterionModifier!\n}\n\ninput StashIDsCriterionInput {\n  \"\"\"\n  If present, this value is treated as a predicate.\n  That is, it will filter based on stash_ids with the matching endpoint\n  \"\"\"\n  endpoint: String\n  stash_ids: [String]\n  modifier: CriterionModifier!\n}\n\ninput CustomFieldCriterionInput {\n  field: String!\n  value: [Any!]\n  modifier: CriterionModifier!\n}\n\ninput PerformerFilterType {\n  AND: PerformerFilterType\n  OR: PerformerFilterType\n  NOT: PerformerFilterType\n\n  name: StringCriterionInput\n  disambiguation: StringCriterionInput\n  details: StringCriterionInput\n\n  \"Filter by favorite\"\n  filter_favorites: Boolean\n  \"Filter by birth year\"\n  birth_year: IntCriterionInput\n  \"Filter by age\"\n  age: IntCriterionInput\n  \"Filter by ethnicity\"\n  ethnicity: StringCriterionInput\n  \"Filter by country\"\n  country: StringCriterionInput\n  \"Filter by eye color\"\n  eye_color: StringCriterionInput\n  \"Filter by height in cm\"\n  height_cm: IntCriterionInput\n  \"Filter by measurements\"\n  measurements: StringCriterionInput\n  \"Filter by fake tits value\"\n  fake_tits: StringCriterionInput\n  \"Filter by penis length value\"\n  penis_length: FloatCriterionInput\n  \"Filter by circumcision\"\n  circumcised: CircumcisionCriterionInput\n  \"Deprecated: use career_start and career_end. This filter is non-functional.\"\n  career_length: StringCriterionInput\n    @deprecated(reason: \"Use career_start and career_end\")\n  \"Filter by career start\"\n  career_start: DateCriterionInput\n  \"Filter by career end\"\n  career_end: DateCriterionInput\n  \"Filter by tattoos\"\n  tattoos: StringCriterionInput\n  \"Filter by piercings\"\n  piercings: StringCriterionInput\n  \"Filter by aliases\"\n  aliases: StringCriterionInput\n  \"Filter by gender\"\n  gender: GenderCriterionInput\n  \"Filter to only include performers missing this property\"\n  is_missing: String\n  \"Filter to only include performers with these tags\"\n  tags: HierarchicalMultiCriterionInput\n  \"Filter by tag count\"\n  tag_count: IntCriterionInput\n  \"Filter by scene count\"\n  scene_count: IntCriterionInput\n  \"Filter by marker count (via scene)\"\n  marker_count: IntCriterionInput\n  \"Filter by image count\"\n  image_count: IntCriterionInput\n  \"Filter by gallery count\"\n  gallery_count: IntCriterionInput\n  \"Filter by play count\"\n  play_count: IntCriterionInput\n  \"Filter by o count\"\n  o_counter: IntCriterionInput\n  \"Filter by StashID\"\n  stash_id_endpoint: StashIDCriterionInput\n    @deprecated(reason: \"use stash_ids_endpoint instead\")\n  \"Filter by StashIDs\"\n  stash_ids_endpoint: StashIDsCriterionInput\n  # rating expressed as 1-100\n  rating100: IntCriterionInput\n  \"Filter by url\"\n  url: StringCriterionInput\n  \"Filter by hair color\"\n  hair_color: StringCriterionInput\n  \"Filter by weight\"\n  weight: IntCriterionInput\n  \"Filter by death year\"\n  death_year: IntCriterionInput\n  \"Filter by studios where performer appears in scene/image/gallery\"\n  studios: HierarchicalMultiCriterionInput\n  \"Filter by groups where performer appears in scene\"\n  groups: HierarchicalMultiCriterionInput\n  \"Filter by performers where performer appears with another performer in scene/image/gallery\"\n  performers: MultiCriterionInput\n  \"Filter by autotag ignore value\"\n  ignore_auto_tag: Boolean\n  \"Filter by birthdate\"\n  birthdate: DateCriterionInput\n  \"Filter by death date\"\n  death_date: DateCriterionInput\n  \"Filter by related scenes that meet this criteria\"\n  scenes_filter: SceneFilterType\n  \"Filter by related images that meet this criteria\"\n  images_filter: ImageFilterType\n  \"Filter by related galleries that meet this criteria\"\n  galleries_filter: GalleryFilterType\n  \"Filter by related tags that meet this criteria\"\n  tags_filter: TagFilterType\n  \"Filter by related scene markers (via scene) that meet this criteria\"\n  markers_filter: SceneMarkerFilterType\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n\n  custom_fields: [CustomFieldCriterionInput!]\n}\n\ninput SceneMarkerFilterType {\n  \"Filter to only include scene markers with these tags\"\n  tags: HierarchicalMultiCriterionInput\n  \"Filter to only include scene markers attached to a scene with these tags\"\n  scene_tags: HierarchicalMultiCriterionInput\n  \"Filter to only include scene markers with these performers\"\n  performers: MultiCriterionInput\n  \"Filter to only include scene markers from these scenes\"\n  scenes: MultiCriterionInput\n  \"Filter by duration (in seconds)\"\n  duration: FloatCriterionInput\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n  \"Filter by scene date\"\n  scene_date: DateCriterionInput\n  \"Filter by scene creation time\"\n  scene_created_at: TimestampCriterionInput\n  \"Filter by scene last update time\"\n  scene_updated_at: TimestampCriterionInput\n  \"Filter by related scenes that meet this criteria\"\n  scene_filter: SceneFilterType\n}\n\ninput SceneFilterType {\n  AND: SceneFilterType\n  OR: SceneFilterType\n  NOT: SceneFilterType\n\n  id: IntCriterionInput\n  title: StringCriterionInput\n  code: StringCriterionInput\n  details: StringCriterionInput\n  director: StringCriterionInput\n\n  \"Filter by file oshash\"\n  oshash: StringCriterionInput\n  \"Filter by file checksum\"\n  checksum: StringCriterionInput\n  \"Filter by file phash\"\n  phash: StringCriterionInput @deprecated(reason: \"Use phash_distance instead\")\n  \"Filter by file phash distance\"\n  phash_distance: PhashDistanceCriterionInput\n  \"Filter by path\"\n  path: StringCriterionInput\n  \"Filter by file count\"\n  file_count: IntCriterionInput\n  # rating expressed as 1-100\n  rating100: IntCriterionInput\n  \"Filter by organized\"\n  organized: Boolean\n  \"Filter by o-counter\"\n  o_counter: IntCriterionInput\n  \"Filter Scenes by duplication criteria\"\n  duplicated: DuplicationCriterionInput\n  \"Filter by resolution\"\n  resolution: ResolutionCriterionInput\n  \"Filter by orientation\"\n  orientation: OrientationCriterionInput\n  \"Filter by frame rate\"\n  framerate: IntCriterionInput\n  \"Filter by bit rate\"\n  bitrate: IntCriterionInput\n  \"Filter by video codec\"\n  video_codec: StringCriterionInput\n  \"Filter by audio codec\"\n  audio_codec: StringCriterionInput\n  \"Filter by duration (in seconds)\"\n  duration: IntCriterionInput\n  \"Filter to only include scenes which have markers. `true` or `false`\"\n  has_markers: String\n  \"Filter to only include scenes missing this property\"\n  is_missing: String\n  \"Filter to only include scenes with this studio\"\n  studios: HierarchicalMultiCriterionInput\n  \"Filter to only include scenes with this movie\"\n  movies: MultiCriterionInput @deprecated(reason: \"use groups instead\")\n  \"Filter to only include scenes with this group\"\n  groups: HierarchicalMultiCriterionInput\n  \"Filter to only include scenes with this gallery\"\n  galleries: MultiCriterionInput\n  \"Filter to only include scenes with these tags\"\n  tags: HierarchicalMultiCriterionInput\n  \"Filter by tag count\"\n  tag_count: IntCriterionInput\n  \"Filter to only include scenes with performers with these tags\"\n  performer_tags: HierarchicalMultiCriterionInput\n  \"Filter scenes that have performers that have been favorited\"\n  performer_favorite: Boolean\n  \"Filter scenes by performer age at time of scene\"\n  performer_age: IntCriterionInput\n  \"Filter to only include scenes with these performers\"\n  performers: MultiCriterionInput\n  \"Filter by performer count\"\n  performer_count: IntCriterionInput\n  \"Filter by StashID\"\n  stash_id_endpoint: StashIDCriterionInput\n    @deprecated(reason: \"use stash_ids_endpoint instead\")\n  \"Filter by StashIDs\"\n  stash_ids_endpoint: StashIDsCriterionInput\n  \"Filter by StashID count\"\n  stash_id_count: IntCriterionInput\n  \"Filter by url\"\n  url: StringCriterionInput\n  \"Filter by interactive\"\n  interactive: Boolean\n  \"Filter by InteractiveSpeed\"\n  interactive_speed: IntCriterionInput\n  \"Filter by captions\"\n  captions: StringCriterionInput\n  \"Filter by resume time\"\n  resume_time: IntCriterionInput\n  \"Filter by play count\"\n  play_count: IntCriterionInput\n  \"Filter by play duration (in seconds)\"\n  play_duration: IntCriterionInput\n  \"Filter by scene last played time\"\n  last_played_at: TimestampCriterionInput\n  \"Filter by date\"\n  date: DateCriterionInput\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n\n  \"Filter by related galleries that meet this criteria\"\n  galleries_filter: GalleryFilterType\n  \"Filter by related performers that meet this criteria\"\n  performers_filter: PerformerFilterType\n  \"Filter by related studios that meet this criteria\"\n  studios_filter: StudioFilterType\n  \"Filter by related tags that meet this criteria\"\n  tags_filter: TagFilterType\n  \"Filter by related movies that meet this criteria\"\n  movies_filter: MovieFilterType\n    @deprecated(reason: \"use groups_filter instead\")\n  \"Filter by related groups that meet this criteria\"\n  groups_filter: GroupFilterType\n  \"Filter by related markers that meet this criteria\"\n  markers_filter: SceneMarkerFilterType\n  \"Filter by related files that meet this criteria\"\n  files_filter: FileFilterType\n\n  custom_fields: [CustomFieldCriterionInput!]\n}\n\ninput MovieFilterType {\n  AND: MovieFilterType\n  OR: MovieFilterType\n  NOT: MovieFilterType\n\n  name: StringCriterionInput\n  director: StringCriterionInput\n  synopsis: StringCriterionInput\n\n  \"Filter by duration (in seconds)\"\n  duration: IntCriterionInput\n  # rating expressed as 1-100\n  rating100: IntCriterionInput\n  \"Filter to only include movies with this studio\"\n  studios: HierarchicalMultiCriterionInput\n  \"Filter to only include movies missing this property\"\n  is_missing: String\n  \"Filter by url\"\n  url: StringCriterionInput\n  \"Filter to only include movies where performer appears in a scene\"\n  performers: MultiCriterionInput\n  \"Filter to only include movies with these tags\"\n  tags: HierarchicalMultiCriterionInput\n  \"Filter by tag count\"\n  tag_count: IntCriterionInput\n  \"Filter by date\"\n  date: DateCriterionInput\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n\n  \"Filter by related scenes that meet this criteria\"\n  scenes_filter: SceneFilterType\n  \"Filter by related studios that meet this criteria\"\n  studios_filter: StudioFilterType\n}\n\ninput GroupFilterType {\n  AND: GroupFilterType\n  OR: GroupFilterType\n  NOT: GroupFilterType\n\n  name: StringCriterionInput\n  director: StringCriterionInput\n  synopsis: StringCriterionInput\n\n  \"Filter by duration (in seconds)\"\n  duration: IntCriterionInput\n  # rating expressed as 1-100\n  rating100: IntCriterionInput\n  \"Filter to only include groups with this studio\"\n  studios: HierarchicalMultiCriterionInput\n  \"Filter to only include groups missing this property\"\n  is_missing: String\n  \"Filter by url\"\n  url: StringCriterionInput\n  \"Filter to only include groups where performer appears in a scene\"\n  performers: MultiCriterionInput\n  \"Filter to only include groups with these tags\"\n  tags: HierarchicalMultiCriterionInput\n  \"Filter by tag count\"\n  tag_count: IntCriterionInput\n  \"Filter by date\"\n  date: DateCriterionInput\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n  \"Filter by o-counter\"\n  o_counter: IntCriterionInput\n\n  \"Filter by containing groups\"\n  containing_groups: HierarchicalMultiCriterionInput\n  \"Filter by sub groups\"\n  sub_groups: HierarchicalMultiCriterionInput\n  \"Filter by number of containing groups the group has\"\n  containing_group_count: IntCriterionInput\n  \"Filter by number of sub-groups the group has\"\n  sub_group_count: IntCriterionInput\n  \"Filter by number of scenes the group has\"\n  scene_count: IntCriterionInput\n\n  \"Filter by related scenes that meet this criteria\"\n  scenes_filter: SceneFilterType\n  \"Filter by related studios that meet this criteria\"\n  studios_filter: StudioFilterType\n\n  \"Filter by custom fields\"\n  custom_fields: [CustomFieldCriterionInput!]\n}\n\ninput StudioFilterType {\n  AND: StudioFilterType\n  OR: StudioFilterType\n  NOT: StudioFilterType\n\n  name: StringCriterionInput\n  details: StringCriterionInput\n  \"Filter to only include studios with this parent studio\"\n  parents: MultiCriterionInput\n  \"Filter by StashID\"\n  stash_id_endpoint: StashIDCriterionInput\n    @deprecated(reason: \"use stash_ids_endpoint instead\")\n  \"Filter by StashIDs\"\n  stash_ids_endpoint: StashIDsCriterionInput\n  \"Filter to only include studios with these tags\"\n  tags: HierarchicalMultiCriterionInput\n  \"Filter to only include studios missing this property\"\n  is_missing: String\n  # rating expressed as 1-100\n  rating100: IntCriterionInput\n  \"Filter by favorite\"\n  favorite: Boolean\n  \"Filter by scene count\"\n  scene_count: IntCriterionInput\n  \"Filter by image count\"\n  image_count: IntCriterionInput\n  \"Filter by gallery count\"\n  gallery_count: IntCriterionInput\n  \"Filter by group count\"\n  group_count: IntCriterionInput\n  \"Filter by tag count\"\n  tag_count: IntCriterionInput\n  \"Filter by url\"\n  url: StringCriterionInput\n  \"Filter by studio aliases\"\n  aliases: StringCriterionInput\n  \"Filter by subsidiary studio count\"\n  child_count: IntCriterionInput\n  \"Filter by autotag ignore value\"\n  ignore_auto_tag: Boolean\n  \"Filter by organized\"\n  organized: Boolean\n  \"Filter by related scenes that meet this criteria\"\n  scenes_filter: SceneFilterType\n  \"Filter by related images that meet this criteria\"\n  images_filter: ImageFilterType\n  \"Filter by related galleries that meet this criteria\"\n  galleries_filter: GalleryFilterType\n  \"Filter by related groups that meet this criteria\"\n  groups_filter: GroupFilterType\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n\n  custom_fields: [CustomFieldCriterionInput!]\n}\n\ninput GalleryFilterType {\n  AND: GalleryFilterType\n  OR: GalleryFilterType\n  NOT: GalleryFilterType\n\n  id: IntCriterionInput\n  title: StringCriterionInput\n  details: StringCriterionInput\n\n  \"Filter by file checksum\"\n  checksum: StringCriterionInput\n  \"Filter by path\"\n  path: StringCriterionInput\n  \"Filter by zip-file count\"\n  file_count: IntCriterionInput\n  \"Filter to only include galleries missing this property\"\n  is_missing: String\n  \"Filter to include/exclude galleries that were created from zip\"\n  is_zip: Boolean\n  # rating expressed as 1-100\n  rating100: IntCriterionInput\n  \"Filter by organized\"\n  organized: Boolean\n  \"Filter by average image resolution\"\n  average_resolution: ResolutionCriterionInput\n  \"Filter to only include galleries that have chapters. `true` or `false`\"\n  has_chapters: String\n  \"Filter to only include galleries with these scenes\"\n  scenes: MultiCriterionInput\n  \"Filter to only include galleries with this studio\"\n  studios: HierarchicalMultiCriterionInput\n  \"Filter to only include galleries with these tags\"\n  tags: HierarchicalMultiCriterionInput\n  \"Filter by tag count\"\n  tag_count: IntCriterionInput\n  \"Filter to only include galleries with performers with these tags\"\n  performer_tags: HierarchicalMultiCriterionInput\n  \"Filter to only include galleries with these performers\"\n  performers: MultiCriterionInput\n  \"Filter by performer count\"\n  performer_count: IntCriterionInput\n  \"Filter galleries that have performers that have been favorited\"\n  performer_favorite: Boolean\n  \"Filter galleries by performer age at time of gallery\"\n  performer_age: IntCriterionInput\n  \"Filter by number of images in this gallery\"\n  image_count: IntCriterionInput\n  \"Filter by url\"\n  url: StringCriterionInput\n  \"Filter by date\"\n  date: DateCriterionInput\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n  \"Filter by studio code\"\n  code: StringCriterionInput\n  \"Filter by photographer\"\n  photographer: StringCriterionInput\n\n  \"Filter by related scenes that meet this criteria\"\n  scenes_filter: SceneFilterType\n  \"Filter by related images that meet this criteria\"\n  images_filter: ImageFilterType\n  \"Filter by related performers that meet this criteria\"\n  performers_filter: PerformerFilterType\n  \"Filter by related studios that meet this criteria\"\n  studios_filter: StudioFilterType\n  \"Filter by related tags that meet this criteria\"\n  tags_filter: TagFilterType\n  \"Filter by related files that meet this criteria\"\n  files_filter: FileFilterType\n  \"Filter by related folders that meet this criteria\"\n  folders_filter: FolderFilterType\n  \"Filter by parent folder of the zip or folder the gallery is in\"\n  parent_folder: HierarchicalMultiCriterionInput\n\n  custom_fields: [CustomFieldCriterionInput!]\n}\n\ninput TagFilterType {\n  AND: TagFilterType\n  OR: TagFilterType\n  NOT: TagFilterType\n\n  \"Filter by tag name\"\n  name: StringCriterionInput\n\n  \"Filter by tag sort_name\"\n  sort_name: StringCriterionInput\n\n  \"Filter by tag aliases\"\n  aliases: StringCriterionInput\n\n  \"Filter by favorite\"\n  favorite: Boolean\n\n  \"Filter by tag description\"\n  description: StringCriterionInput\n\n  \"Filter to only include tags missing this property\"\n  is_missing: String\n\n  \"Filter by number of scenes with this tag\"\n  scene_count: IntCriterionInput\n\n  \"Filter by number of images with this tag\"\n  image_count: IntCriterionInput\n\n  \"Filter by number of galleries with this tag\"\n  gallery_count: IntCriterionInput\n\n  \"Filter by number of performers with this tag\"\n  performer_count: IntCriterionInput\n\n  \"Filter by number of studios with this tag\"\n  studio_count: IntCriterionInput\n\n  \"Filter by number of movies with this tag\"\n  movie_count: IntCriterionInput\n\n  \"Filter by number of group with this tag\"\n  group_count: IntCriterionInput\n\n  \"Filter by number of markers with this tag\"\n  marker_count: IntCriterionInput\n\n  \"Filter by parent tags\"\n  parents: HierarchicalMultiCriterionInput\n\n  \"Filter by child tags\"\n  children: HierarchicalMultiCriterionInput\n\n  \"Filter by number of parent tags the tag has\"\n  parent_count: IntCriterionInput\n\n  \"Filter by number of child tags the tag has\"\n  child_count: IntCriterionInput\n\n  \"Filter by autotag ignore value\"\n  ignore_auto_tag: Boolean\n\n  \"Filter by StashID\"\n  stash_id_endpoint: StashIDCriterionInput\n    @deprecated(reason: \"use stash_ids_endpoint instead\")\n\n  \"Filter by StashID\"\n  stash_ids_endpoint: StashIDsCriterionInput\n\n  \"Filter by related scenes that meet this criteria\"\n  scenes_filter: SceneFilterType\n  \"Filter by related images that meet this criteria\"\n  images_filter: ImageFilterType\n  \"Filter by related galleries that meet this criteria\"\n  galleries_filter: GalleryFilterType\n  \"Filter by related groups that meet this criteria\"\n  groups_filter: GroupFilterType\n  \"Filter by related performers that meet this criteria\"\n  performers_filter: PerformerFilterType\n  \"Filter by related studios that meet this criteria\"\n  studios_filter: StudioFilterType\n  \"Filter by related scene markers that meet this criteria\"\n  markers_filter: SceneMarkerFilterType\n\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n\n  custom_fields: [CustomFieldCriterionInput!]\n}\n\ninput ImageFilterType {\n  AND: ImageFilterType\n  OR: ImageFilterType\n  NOT: ImageFilterType\n\n  title: StringCriterionInput\n  details: StringCriterionInput\n\n  \" Filter by image id\"\n  id: IntCriterionInput\n  \"Filter by file checksum\"\n  checksum: StringCriterionInput\n  \"Filter by file phash distance\"\n  phash_distance: PhashDistanceCriterionInput\n  \"Filter by path\"\n  path: StringCriterionInput\n  \"Filter by file count\"\n  file_count: IntCriterionInput\n  # rating expressed as 1-100\n  rating100: IntCriterionInput\n  \"Filter by date\"\n  date: DateCriterionInput\n  \"Filter by url\"\n  url: StringCriterionInput\n  \"Filter by organized\"\n  organized: Boolean\n  \"Filter by o-counter\"\n  o_counter: IntCriterionInput\n  \"Filter by resolution\"\n  resolution: ResolutionCriterionInput\n  \"Filter by orientation\"\n  orientation: OrientationCriterionInput\n  \"Filter to only include images missing this property\"\n  is_missing: String\n  \"Filter to only include images with this studio\"\n  studios: HierarchicalMultiCriterionInput\n  \"Filter to only include images with these tags\"\n  tags: HierarchicalMultiCriterionInput\n  \"Filter by tag count\"\n  tag_count: IntCriterionInput\n  \"Filter to only include images with performers with these tags\"\n  performer_tags: HierarchicalMultiCriterionInput\n  \"Filter to only include images with these performers\"\n  performers: MultiCriterionInput\n  \"Filter by performer count\"\n  performer_count: IntCriterionInput\n  \"Filter images that have performers that have been favorited\"\n  performer_favorite: Boolean\n  \"Filter images by performer age at time of image\"\n  performer_age: IntCriterionInput\n  \"Filter to only include images with these galleries\"\n  galleries: MultiCriterionInput\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n  \"Filter by studio code\"\n  code: StringCriterionInput\n  \"Filter by photographer\"\n  photographer: StringCriterionInput\n\n  \"Filter by related galleries that meet this criteria\"\n  galleries_filter: GalleryFilterType\n  \"Filter by related performers that meet this criteria\"\n  performers_filter: PerformerFilterType\n  \"Filter by related studios that meet this criteria\"\n  studios_filter: StudioFilterType\n  \"Filter by related tags that meet this criteria\"\n  tags_filter: TagFilterType\n  \"Filter by related files that meet this criteria\"\n  files_filter: FileFilterType\n  \"Filter by custom fields\"\n  custom_fields: [CustomFieldCriterionInput!]\n}\n\ninput FileFilterType {\n  AND: FileFilterType\n  OR: FileFilterType\n  NOT: FileFilterType\n\n  path: StringCriterionInput\n  basename: StringCriterionInput\n  dir: StringCriterionInput\n\n  parent_folder: HierarchicalMultiCriterionInput\n  zip_file: MultiCriterionInput\n\n  \"Filter by modification time\"\n  mod_time: TimestampCriterionInput\n\n  \"Filter files by duplication criteria (only phash applies to files)\"\n  duplicated: FileDuplicationCriterionInput\n\n  \"find files based on hash\"\n  hashes: [FingerprintFilterInput!]\n\n  video_file_filter: VideoFileFilterInput\n  image_file_filter: ImageFileFilterInput\n\n  scene_count: IntCriterionInput\n  image_count: IntCriterionInput\n  gallery_count: IntCriterionInput\n\n  \"Filter by related scenes that meet this criteria\"\n  scenes_filter: SceneFilterType\n  \"Filter by related images that meet this criteria\"\n  images_filter: ImageFilterType\n  \"Filter by related galleries that meet this criteria\"\n  galleries_filter: GalleryFilterType\n\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n}\n\ninput FolderFilterType {\n  AND: FolderFilterType\n  OR: FolderFilterType\n  NOT: FolderFilterType\n\n  path: StringCriterionInput\n  basename: StringCriterionInput\n\n  parent_folder: HierarchicalMultiCriterionInput\n  zip_file: MultiCriterionInput\n\n  \"Filter by modification time\"\n  mod_time: TimestampCriterionInput\n\n  gallery_count: IntCriterionInput\n\n  \"Filter by files that meet this criteria\"\n  files_filter: FileFilterType\n  \"Filter by related galleries that meet this criteria\"\n  galleries_filter: GalleryFilterType\n\n  \"Filter by creation time\"\n  created_at: TimestampCriterionInput\n  \"Filter by last update time\"\n  updated_at: TimestampCriterionInput\n}\n\ninput VideoFileFilterInput {\n  resolution: ResolutionCriterionInput\n  orientation: OrientationCriterionInput\n  framerate: IntCriterionInput\n  bitrate: IntCriterionInput\n  format: StringCriterionInput\n  video_codec: StringCriterionInput\n  audio_codec: StringCriterionInput\n\n  \"in seconds\"\n  duration: IntCriterionInput\n\n  captions: StringCriterionInput\n\n  interactive: Boolean\n  interactive_speed: IntCriterionInput\n}\n\ninput ImageFileFilterInput {\n  format: StringCriterionInput\n  resolution: ResolutionCriterionInput\n  orientation: OrientationCriterionInput\n}\n\ninput FingerprintFilterInput {\n  type: String!\n  value: String!\n  \"Hamming distance - defaults to 0\"\n  distance: Int\n}\n\nenum CriterionModifier {\n  \"=\"\n  EQUALS\n  \"!=\"\n  NOT_EQUALS\n  \">\"\n  GREATER_THAN\n  \"<\"\n  LESS_THAN\n  \"IS NULL\"\n  IS_NULL\n  \"IS NOT NULL\"\n  NOT_NULL\n  \"INCLUDES ALL\"\n  INCLUDES_ALL\n  INCLUDES\n  EXCLUDES\n  \"MATCHES REGEX\"\n  MATCHES_REGEX\n  \"NOT MATCHES REGEX\"\n  NOT_MATCHES_REGEX\n  \">= AND <=\"\n  BETWEEN\n  \"< OR >\"\n  NOT_BETWEEN\n}\n\ninput StringCriterionInput {\n  value: String!\n  modifier: CriterionModifier!\n}\n\ninput IntCriterionInput {\n  value: Int!\n  value2: Int\n  modifier: CriterionModifier!\n}\n\ninput FloatCriterionInput {\n  value: Float!\n  value2: Float\n  modifier: CriterionModifier!\n}\n\ninput MultiCriterionInput {\n  value: [ID!]\n  modifier: CriterionModifier!\n  excludes: [ID!]\n}\n\ninput GenderCriterionInput {\n  value: GenderEnum\n  value_list: [GenderEnum!]\n  modifier: CriterionModifier!\n}\n\ninput CircumcisionCriterionInput {\n  value: [CircumcisedEnum!]\n  modifier: CriterionModifier!\n}\n\ninput HierarchicalMultiCriterionInput {\n  value: [ID!]\n  modifier: CriterionModifier!\n  depth: Int\n  excludes: [ID!]\n}\n\ninput DateCriterionInput {\n  value: String!\n  value2: String\n  modifier: CriterionModifier!\n}\n\ninput TimestampCriterionInput {\n  value: String!\n  value2: String\n  modifier: CriterionModifier!\n}\n\ninput PhashDistanceCriterionInput {\n  value: String!\n  modifier: CriterionModifier!\n  distance: Int\n}\n\nenum FilterMode {\n  SCENES\n  PERFORMERS\n  STUDIOS\n  GALLERIES\n  SCENE_MARKERS\n  MOVIES\n  GROUPS\n  TAGS\n  IMAGES\n}\n\ntype SavedFilter {\n  id: ID!\n  mode: FilterMode!\n  name: String!\n  \"JSON-encoded filter string\"\n  filter: String!\n    @deprecated(reason: \"use find_filter and object_filter instead\")\n  find_filter: SavedFindFilterType\n  # maps to any of the AnyFilterInput types\n  # using a generic Map instead of creating and maintaining match types for inputs\n  object_filter: Map\n  # generic map for ui options\n  ui_options: Map\n}\n\ninput SaveFilterInput {\n  \"provide ID to overwrite existing filter\"\n  id: ID\n  mode: FilterMode!\n  name: String!\n  find_filter: FindFilterType\n  object_filter: Map\n  # generic map for ui options\n  ui_options: Map\n}\n\ninput DestroyFilterInput {\n  id: ID!\n}\n\ninput SetDefaultFilterInput {\n  mode: FilterMode!\n  \"null to clear\"\n  find_filter: FindFilterType\n  object_filter: Map\n  # generic map for ui options\n  ui_options: Map\n}\n"
  },
  {
    "path": "graphql/schema/types/gallery-chapter.graphql",
    "content": "type GalleryChapter {\n  id: ID!\n  gallery: Gallery!\n  title: String!\n  image_index: Int!\n  created_at: Time!\n  updated_at: Time!\n}\n\ninput GalleryChapterCreateInput {\n  gallery_id: ID!\n  title: String!\n  image_index: Int!\n}\n\ninput GalleryChapterUpdateInput {\n  id: ID!\n  gallery_id: ID\n  title: String\n  image_index: Int\n}\n\ntype FindGalleryChaptersResultType {\n  count: Int!\n  chapters: [GalleryChapter!]!\n}\n"
  },
  {
    "path": "graphql/schema/types/gallery.graphql",
    "content": "type GalleryPathsType {\n  cover: String!\n  preview: String! # Resolver\n}\n\n\"Gallery type\"\ntype Gallery {\n  id: ID!\n  title: String\n  code: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]!\n  date: String\n  details: String\n  photographer: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean!\n  created_at: Time!\n  updated_at: Time!\n\n  files: [GalleryFile!]!\n  folder: Folder\n\n  chapters: [GalleryChapter!]!\n  scenes: [Scene!]!\n  studio: Studio\n  image_count: Int!\n  tags: [Tag!]!\n  performers: [Performer!]!\n\n  cover: Image\n\n  paths: GalleryPathsType! # Resolver\n  custom_fields: Map!\n  image(index: Int!): Image!\n}\n\ninput GalleryCreateInput {\n  title: String!\n  code: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  date: String\n  details: String\n  photographer: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean\n  scene_ids: [ID!]\n  studio_id: ID\n  tag_ids: [ID!]\n  performer_ids: [ID!]\n\n  custom_fields: Map\n}\n\ninput GalleryUpdateInput {\n  clientMutationId: String\n  id: ID!\n  title: String\n  code: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  date: String\n  details: String\n  photographer: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean\n  scene_ids: [ID!]\n  studio_id: ID\n  tag_ids: [ID!]\n  performer_ids: [ID!]\n\n  primary_file_id: ID\n\n  custom_fields: CustomFieldsInput\n}\n\ninput BulkGalleryUpdateInput {\n  clientMutationId: String\n  ids: [ID!]\n  code: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: BulkUpdateStrings\n  date: String\n  details: String\n  photographer: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean\n  scene_ids: BulkUpdateIds\n  studio_id: ID\n  tag_ids: BulkUpdateIds\n  performer_ids: BulkUpdateIds\n\n  custom_fields: CustomFieldsInput\n}\n\ninput GalleryDestroyInput {\n  ids: [ID!]!\n  \"\"\"\n  If true, then the zip file will be deleted if the gallery is zip-file-based.\n  If gallery is folder-based, then any files not associated with other\n  galleries will be deleted, along with the folder, if it is not empty.\n  \"\"\"\n  delete_file: Boolean\n  delete_generated: Boolean\n  \"If true, delete the file entry from the database if the file is not assigned to any other objects\"\n  destroy_file_entry: Boolean\n}\n\ntype FindGalleriesResultType {\n  count: Int!\n  galleries: [Gallery!]!\n}\n\ninput GalleryAddInput {\n  gallery_id: ID!\n  image_ids: [ID!]!\n}\n\ninput GalleryRemoveInput {\n  gallery_id: ID!\n  image_ids: [ID!]!\n}\n\ninput GallerySetCoverInput {\n  gallery_id: ID!\n  cover_image_id: ID!\n}\n\ninput GalleryResetCoverInput {\n  gallery_id: ID!\n}\n"
  },
  {
    "path": "graphql/schema/types/group.graphql",
    "content": "\"GroupDescription represents a relationship to a group with a description of the relationship\"\ntype GroupDescription {\n  group: Group!\n  description: String\n}\n\ntype Group {\n  id: ID!\n  name: String!\n  aliases: String\n  \"Duration in seconds\"\n  duration: Int\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  studio: Studio\n  director: String\n  synopsis: String\n  urls: [String!]!\n  tags: [Tag!]!\n  created_at: Time!\n  updated_at: Time!\n\n  containing_groups: [GroupDescription!]!\n  sub_groups: [GroupDescription!]!\n\n  front_image_path: String # Resolver\n  back_image_path: String # Resolver\n  scene_count(depth: Int): Int! # Resolver\n  performer_count(depth: Int): Int! # Resolver\n  sub_group_count(depth: Int): Int! # Resolver\n  scenes: [Scene!]!\n  o_counter: Int # Resolver\n  custom_fields: Map!\n}\n\ninput GroupDescriptionInput {\n  group_id: ID!\n  description: String\n}\n\ninput GroupCreateInput {\n  name: String!\n  aliases: String\n  \"Duration in seconds\"\n  duration: Int\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  studio_id: ID\n  director: String\n  synopsis: String\n  urls: [String!]\n  tag_ids: [ID!]\n\n  containing_groups: [GroupDescriptionInput!]\n  sub_groups: [GroupDescriptionInput!]\n\n  \"This should be a URL or a base64 encoded data URL\"\n  front_image: String\n  \"This should be a URL or a base64 encoded data URL\"\n  back_image: String\n\n  custom_fields: Map\n}\n\ninput GroupUpdateInput {\n  id: ID!\n  name: String\n  aliases: String\n  duration: Int\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  studio_id: ID\n  director: String\n  synopsis: String\n  urls: [String!]\n  tag_ids: [ID!]\n\n  containing_groups: [GroupDescriptionInput!]\n  sub_groups: [GroupDescriptionInput!]\n\n  \"This should be a URL or a base64 encoded data URL\"\n  front_image: String\n  \"This should be a URL or a base64 encoded data URL\"\n  back_image: String\n\n  custom_fields: CustomFieldsInput\n}\n\ninput BulkUpdateGroupDescriptionsInput {\n  groups: [GroupDescriptionInput!]!\n  mode: BulkUpdateIdMode!\n}\n\ninput BulkGroupUpdateInput {\n  clientMutationId: String\n  ids: [ID!]\n  # rating expressed as 1-100\n  rating100: Int\n  date: String\n  synopsis: String\n  studio_id: ID\n  director: String\n  urls: BulkUpdateStrings\n  tag_ids: BulkUpdateIds\n\n  containing_groups: BulkUpdateGroupDescriptionsInput\n  sub_groups: BulkUpdateGroupDescriptionsInput\n\n  custom_fields: CustomFieldsInput\n}\n\ninput GroupDestroyInput {\n  id: ID!\n}\n\ninput ReorderSubGroupsInput {\n  \"ID of the group to reorder sub groups for\"\n  group_id: ID!\n  \"\"\"\n  IDs of the sub groups to reorder. These must be a subset of the current sub groups.\n  Sub groups will be inserted in this order at the insert_index\n  \"\"\"\n  sub_group_ids: [ID!]!\n  \"The sub-group ID at which to insert the sub groups\"\n  insert_at_id: ID!\n  \"If true, the sub groups will be inserted after the insert_index, otherwise they will be inserted before\"\n  insert_after: Boolean\n}\n\ntype FindGroupsResultType {\n  count: Int!\n  groups: [Group!]!\n}\n\ninput GroupSubGroupAddInput {\n  containing_group_id: ID!\n  sub_groups: [GroupDescriptionInput!]!\n  \"The index at which to insert the sub groups. If not provided, the sub groups will be appended to the end\"\n  insert_index: Int\n}\n\ninput GroupSubGroupRemoveInput {\n  containing_group_id: ID!\n  sub_group_ids: [ID!]!\n}\n"
  },
  {
    "path": "graphql/schema/types/image.graphql",
    "content": "type Image {\n  id: ID!\n  title: String\n  code: String\n  # rating expressed as 1-100\n  rating100: Int\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]!\n  date: String\n  details: String\n  photographer: String\n  o_counter: Int\n  organized: Boolean!\n  created_at: Time!\n  updated_at: Time!\n\n  files: [ImageFile!]! @deprecated(reason: \"Use visual_files\")\n  visual_files: [VisualFile!]!\n  paths: ImagePathsType! # Resolver\n  galleries: [Gallery!]!\n  studio: Studio\n  tags: [Tag!]!\n  performers: [Performer!]!\n  custom_fields: Map!\n}\n\ntype ImageFileType {\n  mod_time: Time!\n  size: Int!\n  width: Int!\n  height: Int!\n}\n\ntype ImagePathsType {\n  thumbnail: String # Resolver\n  preview: String # Resolver\n  image: String # Resolver\n}\n\ninput ImageUpdateInput {\n  clientMutationId: String\n  id: ID!\n  title: String\n  code: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  date: String\n  details: String\n  photographer: String\n\n  studio_id: ID\n  performer_ids: [ID!]\n  tag_ids: [ID!]\n  gallery_ids: [ID!]\n\n  primary_file_id: ID\n  custom_fields: CustomFieldsInput\n}\n\ninput BulkImageUpdateInput {\n  clientMutationId: String\n  ids: [ID!]\n  title: String\n  code: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean\n  url: String @deprecated(reason: \"Use urls\")\n  urls: BulkUpdateStrings\n  date: String\n  details: String\n  photographer: String\n\n  studio_id: ID\n  performer_ids: BulkUpdateIds\n  tag_ids: BulkUpdateIds\n  gallery_ids: BulkUpdateIds\n  custom_fields: CustomFieldsInput\n}\n\ninput ImageDestroyInput {\n  id: ID!\n  delete_file: Boolean\n  delete_generated: Boolean\n  \"If true, delete the file entry from the database if the file is not assigned to any other objects\"\n  destroy_file_entry: Boolean\n}\n\ninput ImagesDestroyInput {\n  ids: [ID!]!\n  delete_file: Boolean\n  delete_generated: Boolean\n  \"If true, delete the file entry from the database if the file is not assigned to any other objects\"\n  destroy_file_entry: Boolean\n}\n\ntype FindImagesResultType {\n  count: Int!\n  \"Total megapixels of the images\"\n  megapixels: Float!\n  \"Total file size in bytes\"\n  filesize: Float!\n  images: [Image!]!\n}\n"
  },
  {
    "path": "graphql/schema/types/job.graphql",
    "content": "enum JobStatus {\n  READY\n  RUNNING\n  FINISHED\n  STOPPING\n  CANCELLED\n  FAILED\n}\n\ntype Job {\n  id: ID!\n  status: JobStatus!\n  subTasks: [String!]\n  description: String!\n  progress: Float\n  startTime: Time\n  endTime: Time\n  addTime: Time!\n  error: String\n}\n\ninput FindJobInput {\n  id: ID!\n}\n\nenum JobStatusUpdateType {\n  ADD\n  REMOVE\n  UPDATE\n}\n\ntype JobStatusUpdate {\n  type: JobStatusUpdateType!\n  job: Job!\n}\n"
  },
  {
    "path": "graphql/schema/types/logging.graphql",
    "content": "enum LogLevel {\n  Trace\n  Debug\n  Info\n  Progress\n  Warning\n  Error\n}\n\ntype LogEntry {\n  time: Time!\n  level: LogLevel!\n  message: String!\n}\n"
  },
  {
    "path": "graphql/schema/types/metadata.graphql",
    "content": "input GenerateMetadataInput {\n  covers: Boolean\n  sprites: Boolean\n  previews: Boolean\n  imagePreviews: Boolean\n  previewOptions: GeneratePreviewOptionsInput\n  markers: Boolean\n  markerImagePreviews: Boolean\n  markerScreenshots: Boolean\n  transcodes: Boolean\n  \"Generate transcodes even if not required\"\n  forceTranscodes: Boolean\n  \"Generate video phashes during scan\"\n  phashes: Boolean\n  interactiveHeatmapsSpeeds: Boolean\n  \"Generate image phashes during scan\"\n  imagePhashes: Boolean\n  imageThumbnails: Boolean\n  clipPreviews: Boolean\n\n  \"scene ids to generate for\"\n  sceneIDs: [ID!]\n  \"marker ids to generate for\"\n  markerIDs: [ID!]\n  \"image ids to generate for\"\n  imageIDs: [ID!]\n  \"gallery ids to generate for\"\n  galleryIDs: [ID!]\n  \"paths to run generate on, in addition to the other ID lists\"\n  paths: [String!]\n\n  \"overwrite existing media\"\n  overwrite: Boolean\n}\n\ninput GeneratePreviewOptionsInput {\n  \"Number of segments in a preview file\"\n  previewSegments: Int\n  \"Preview segment duration, in seconds\"\n  previewSegmentDuration: Float\n  \"Duration of start of video to exclude when generating previews\"\n  previewExcludeStart: String\n  \"Duration of end of video to exclude when generating previews\"\n  previewExcludeEnd: String\n  \"Preset when generating preview\"\n  previewPreset: PreviewPreset\n}\n\ntype GenerateMetadataOptions {\n  covers: Boolean\n  sprites: Boolean\n  previews: Boolean\n  imagePreviews: Boolean\n  previewOptions: GeneratePreviewOptions\n  markers: Boolean\n  markerImagePreviews: Boolean\n  markerScreenshots: Boolean\n  transcodes: Boolean\n  phashes: Boolean\n  interactiveHeatmapsSpeeds: Boolean\n  imageThumbnails: Boolean\n  clipPreviews: Boolean\n}\n\ntype GeneratePreviewOptions {\n  \"Number of segments in a preview file\"\n  previewSegments: Int\n  \"Preview segment duration, in seconds\"\n  previewSegmentDuration: Float\n  \"Duration of start of video to exclude when generating previews\"\n  previewExcludeStart: String\n  \"Duration of end of video to exclude when generating previews\"\n  previewExcludeEnd: String\n  \"Preset when generating preview\"\n  previewPreset: PreviewPreset\n}\n\n\"Filter options for meta data scannning\"\ninput ScanMetaDataFilterInput {\n  \"If set, files with a modification time before this time point are ignored by the scan\"\n  minModTime: Timestamp\n}\n\ninput ScanMetadataInput {\n  paths: [String!]\n\n  \"Forces a rescan on files even if modification time is unchanged\"\n  rescan: Boolean\n  \"Generate covers during scan\"\n  scanGenerateCovers: Boolean\n  \"Generate previews during scan\"\n  scanGeneratePreviews: Boolean\n  \"Generate image previews during scan\"\n  scanGenerateImagePreviews: Boolean\n  \"Generate sprites during scan\"\n  scanGenerateSprites: Boolean\n  \"Generate video phashes during scan\"\n  scanGeneratePhashes: Boolean\n  \"Generate image phashes during scan\"\n  scanGenerateImagePhashes: Boolean\n  \"Generate image thumbnails during scan\"\n  scanGenerateThumbnails: Boolean\n  \"Generate image clip previews during scan\"\n  scanGenerateClipPreviews: Boolean\n\n  \"Filter options for the scan\"\n  filter: ScanMetaDataFilterInput\n}\n\ntype ScanMetadataOptions {\n  \"Forces a rescan on files even if modification time is unchanged\"\n  rescan: Boolean!\n  \"Generate covers during scan\"\n  scanGenerateCovers: Boolean!\n  \"Generate previews during scan\"\n  scanGeneratePreviews: Boolean!\n  \"Generate image previews during scan\"\n  scanGenerateImagePreviews: Boolean!\n  \"Generate sprites during scan\"\n  scanGenerateSprites: Boolean!\n  \"Generate video phashes during scan\"\n  scanGeneratePhashes: Boolean!\n  \"Generate image phashes during scan\"\n  scanGenerateImagePhashes: Boolean\n  \"Generate image thumbnails during scan\"\n  scanGenerateThumbnails: Boolean!\n  \"Generate image clip previews during scan\"\n  scanGenerateClipPreviews: Boolean!\n}\n\ninput CleanMetadataInput {\n  paths: [String!]\n\n  \"\"\"\n  Don't check zip file contents when determining whether to clean a file.\n  This can significantly speed up the clean process, but will potentially miss removed files within zip files.\n  Where users do not modify zip files contents directly, this should be safe to use.\n  Defaults to false.\n  \"\"\"\n  ignoreZipFileContents: Boolean\n\n  \"Do a dry run. Don't delete any files\"\n  dryRun: Boolean!\n}\n\ninput CleanGeneratedInput {\n  \"Clean blob files without blob entries\"\n  blobFiles: Boolean\n  \"Clean sprite and vtt files without scene entries\"\n  sprites: Boolean\n  \"Clean preview files without scene entries\"\n  screenshots: Boolean\n  \"Clean scene transcodes without scene entries\"\n  transcodes: Boolean\n\n  \"Clean marker files without marker entries\"\n  markers: Boolean\n\n  \"Clean image thumbnails/clips without image entries\"\n  imageThumbnails: Boolean\n\n  \"Do a dry run. Don't delete any files\"\n  dryRun: Boolean\n}\n\ninput AutoTagMetadataInput {\n  \"Paths to tag, null for all files\"\n  paths: [String!]\n  \"\"\"\n  IDs of performers to tag files with, or \"*\" for all\n  \"\"\"\n  performers: [String!]\n  \"\"\"\n  IDs of studios to tag files with, or \"*\" for all\n  \"\"\"\n  studios: [String!]\n  \"\"\"\n  IDs of tags to tag files with, or \"*\" for all\n  \"\"\"\n  tags: [String!]\n}\n\ntype AutoTagMetadataOptions {\n  \"\"\"\n  IDs of performers to tag files with, or \"*\" for all\n  \"\"\"\n  performers: [String!]\n  \"\"\"\n  IDs of studios to tag files with, or \"*\" for all\n  \"\"\"\n  studios: [String!]\n  \"\"\"\n  IDs of tags to tag files with, or \"*\" for all\n  \"\"\"\n  tags: [String!]\n}\n\nenum IdentifyFieldStrategy {\n  \"Never sets the field value\"\n  IGNORE\n  \"\"\"\n  For multi-value fields, merge with existing.\n  For single-value fields, ignore if already set\n  \"\"\"\n  MERGE\n  \"\"\"\n  Always replaces the value if a value is found.\n  For multi-value fields, any existing values are removed and replaced with the\n  scraped values.\n  \"\"\"\n  OVERWRITE\n}\n\ninput IdentifyFieldOptionsInput {\n  field: String!\n  strategy: IdentifyFieldStrategy!\n  \"creates missing objects if needed - only applicable for performers, tags and studios\"\n  createMissing: Boolean\n}\n\ninput IdentifyMetadataOptionsInput {\n  \"any fields missing from here are defaulted to MERGE and createMissing false\"\n  fieldOptions: [IdentifyFieldOptionsInput!]\n  \"defaults to true if not provided\"\n  setCoverImage: Boolean\n  setOrganized: Boolean\n  \"defaults to true if not provided\"\n  includeMalePerformers: Boolean @deprecated(reason: \"Use performerGenders\")\n  \"Filter to only include performers with these genders. If not provided, all genders are included.\"\n  performerGenders: [GenderEnum!]\n  \"defaults to true if not provided\"\n  skipMultipleMatches: Boolean\n  \"tag to tag skipped multiple matches with\"\n  skipMultipleMatchTag: String\n  \"defaults to true if not provided\"\n  skipSingleNamePerformers: Boolean\n  \"tag to tag skipped single name performers with\"\n  skipSingleNamePerformerTag: String\n}\n\ninput IdentifySourceInput {\n  source: ScraperSourceInput!\n  \"Options defined for a source override the defaults\"\n  options: IdentifyMetadataOptionsInput\n}\n\ninput IdentifyMetadataInput {\n  \"An ordered list of sources to identify items with. Only the first source that finds a match is used.\"\n  sources: [IdentifySourceInput!]!\n  \"Options defined here override the configured defaults\"\n  options: IdentifyMetadataOptionsInput\n\n  \"scene ids to identify\"\n  sceneIDs: [ID!]\n\n  \"paths of scenes to identify - ignored if scene ids are set\"\n  paths: [String!]\n}\n\n# types for default options\ntype IdentifyFieldOptions {\n  field: String!\n  strategy: IdentifyFieldStrategy!\n  \"creates missing objects if needed - only applicable for performers, tags and studios\"\n  createMissing: Boolean\n}\n\ntype IdentifyMetadataOptions {\n  \"any fields missing from here are defaulted to MERGE and createMissing false\"\n  fieldOptions: [IdentifyFieldOptions!]\n  \"defaults to true if not provided\"\n  setCoverImage: Boolean\n  setOrganized: Boolean\n  \"defaults to true if not provided\"\n  includeMalePerformers: Boolean @deprecated(reason: \"Use performerGenders\")\n  \"Filter to only include performers with these genders. If not provided, all genders are included.\"\n  performerGenders: [GenderEnum!]\n  \"defaults to true if not provided\"\n  skipMultipleMatches: Boolean\n  \"tag to tag skipped multiple matches with\"\n  skipMultipleMatchTag: String\n  \"defaults to true if not provided\"\n  skipSingleNamePerformers: Boolean\n  \"tag to tag skipped single name performers with\"\n  skipSingleNamePerformerTag: String\n}\n\ntype IdentifySource {\n  source: ScraperSource!\n  \"Options defined for a source override the defaults\"\n  options: IdentifyMetadataOptions\n}\n\ntype IdentifyMetadataTaskOptions {\n  \"An ordered list of sources to identify items with. Only the first source that finds a match is used.\"\n  sources: [IdentifySource!]!\n  \"Options defined here override the configured defaults\"\n  options: IdentifyMetadataOptions\n}\n\ninput ExportObjectTypeInput {\n  ids: [String!]\n  all: Boolean\n}\n\ninput ExportObjectsInput {\n  scenes: ExportObjectTypeInput\n  images: ExportObjectTypeInput\n  studios: ExportObjectTypeInput\n  performers: ExportObjectTypeInput\n  tags: ExportObjectTypeInput\n  groups: ExportObjectTypeInput\n  movies: ExportObjectTypeInput @deprecated(reason: \"Use groups instead\")\n  galleries: ExportObjectTypeInput\n  includeDependencies: Boolean\n}\n\nenum ImportDuplicateEnum {\n  IGNORE\n  OVERWRITE\n  FAIL\n}\n\nenum ImportMissingRefEnum {\n  IGNORE\n  FAIL\n  CREATE\n}\n\ninput ImportObjectsInput {\n  file: Upload!\n  duplicateBehaviour: ImportDuplicateEnum!\n  missingRefBehaviour: ImportMissingRefEnum!\n}\n\ninput BackupDatabaseInput {\n  download: Boolean\n  \"If true, blob files will be included in the backup. This can significantly increase the size of the backup and the time it takes to create it, but allows for a complete backup of the system that can be restored without needing access to the original media files.\"\n  includeBlobs: Boolean\n}\n\ninput AnonymiseDatabaseInput {\n  download: Boolean\n}\n\nenum SystemStatusEnum {\n  SETUP\n  NEEDS_MIGRATION\n  OK\n}\n\ntype SystemStatus {\n  databaseSchema: Int\n  databasePath: String\n  configPath: String\n  appSchema: Int!\n  status: SystemStatusEnum!\n  os: String!\n  workingDir: String!\n  homeDir: String!\n  ffmpegPath: String\n  ffprobePath: String\n}\n\ninput MigrateInput {\n  backupPath: String!\n}\n\ninput CustomFieldsInput {\n  \"If populated, the entire custom fields map will be replaced with this value\"\n  full: Map\n  \"If populated, only the keys in this map will be updated\"\n  partial: Map\n  \"Remove any keys in this list\"\n  remove: [String!]\n}\n"
  },
  {
    "path": "graphql/schema/types/migration.graphql",
    "content": "input MigrateSceneScreenshotsInput {\n  # if true, delete screenshot files after migrating\n  deleteFiles: Boolean\n  # if true, overwrite existing covers with the covers from the screenshots directory\n  overwriteExisting: Boolean\n}\n\ninput MigrateBlobsInput {\n  # if true, delete blob data from old storage system\n  deleteOld: Boolean\n}\n"
  },
  {
    "path": "graphql/schema/types/movie.graphql",
    "content": "type Movie {\n  id: ID!\n  name: String!\n  aliases: String\n  \"Duration in seconds\"\n  duration: Int\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  studio: Studio\n  director: String\n  synopsis: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]!\n  tags: [Tag!]!\n  created_at: Time!\n  updated_at: Time!\n\n  front_image_path: String # Resolver\n  back_image_path: String # Resolver\n  scene_count(depth: Int): Int! # Resolver\n  scenes: [Scene!]!\n}\n\ninput MovieCreateInput {\n  name: String!\n  aliases: String\n  \"Duration in seconds\"\n  duration: Int\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  studio_id: ID\n  director: String\n  synopsis: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  tag_ids: [ID!]\n  \"This should be a URL or a base64 encoded data URL\"\n  front_image: String\n  \"This should be a URL or a base64 encoded data URL\"\n  back_image: String\n}\n\ninput MovieUpdateInput {\n  id: ID!\n  name: String\n  aliases: String\n  duration: Int\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  studio_id: ID\n  director: String\n  synopsis: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  tag_ids: [ID!]\n  \"This should be a URL or a base64 encoded data URL\"\n  front_image: String\n  \"This should be a URL or a base64 encoded data URL\"\n  back_image: String\n}\n\ninput BulkMovieUpdateInput {\n  clientMutationId: String\n  ids: [ID!]\n  # rating expressed as 1-100\n  rating100: Int\n  studio_id: ID\n  director: String\n  urls: BulkUpdateStrings\n  tag_ids: BulkUpdateIds\n}\n\ninput MovieDestroyInput {\n  id: ID!\n}\n\ntype FindMoviesResultType {\n  count: Int!\n  movies: [Movie!]!\n}\n"
  },
  {
    "path": "graphql/schema/types/package.graphql",
    "content": "enum PackageType {\n  Scraper\n  Plugin\n}\n\ntype Package {\n  package_id: String!\n  name: String!\n  version: String\n  date: Timestamp\n  requires: [Package!]!\n\n  sourceURL: String!\n\n  \"The version of this package currently available from the remote source\"\n  source_package: Package\n\n  metadata: Map!\n}\n\ninput PackageSpecInput {\n  id: String!\n  sourceURL: String!\n}\n\ntype PackageSource {\n  name: String\n  url: String!\n  local_path: String\n}\n\ninput PackageSourceInput {\n  name: String\n  url: String!\n  local_path: String\n}\n"
  },
  {
    "path": "graphql/schema/types/performer.graphql",
    "content": "enum GenderEnum {\n  MALE\n  FEMALE\n  TRANSGENDER_MALE\n  TRANSGENDER_FEMALE\n  INTERSEX\n  NON_BINARY\n}\n\nenum CircumcisedEnum {\n  CUT\n  UNCUT\n}\n\ntype Performer {\n  id: ID!\n  name: String!\n  disambiguation: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  gender: GenderEnum\n  twitter: String @deprecated(reason: \"Use urls\")\n  instagram: String @deprecated(reason: \"Use urls\")\n  birthdate: String\n  ethnicity: String\n  country: String\n  eye_color: String\n  height_cm: Int\n  measurements: String\n  fake_tits: String\n  penis_length: Float\n  circumcised: CircumcisedEnum\n  career_length: String @deprecated(reason: \"Use career_start and career_end\")\n  career_start: String\n  career_end: String\n  tattoos: String\n  piercings: String\n  alias_list: [String!]!\n  favorite: Boolean!\n  tags: [Tag!]!\n  ignore_auto_tag: Boolean!\n\n  image_path: String # Resolver\n  scene_count: Int! # Resolver\n  image_count: Int! # Resolver\n  gallery_count: Int! # Resolver\n  group_count: Int! # Resolver\n  movie_count: Int! @deprecated(reason: \"use group_count instead\") # Resolver\n  performer_count: Int! # Resolver\n  o_counter: Int # Resolver\n  scenes: [Scene!]!\n  stash_ids: [StashID!]!\n  # rating expressed as 1-100\n  rating100: Int\n  details: String\n  death_date: String\n  hair_color: String\n  weight: Int\n  created_at: Time!\n  updated_at: Time!\n  groups: [Group!]!\n  movies: [Movie!]! @deprecated(reason: \"use groups instead\")\n\n  custom_fields: Map!\n}\n\ninput PerformerCreateInput {\n  name: String!\n  disambiguation: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  gender: GenderEnum\n  birthdate: String\n  ethnicity: String\n  country: String\n  eye_color: String\n  height_cm: Int\n  measurements: String\n  fake_tits: String\n  penis_length: Float\n  circumcised: CircumcisedEnum\n  career_length: String @deprecated(reason: \"Use career_start and career_end\")\n  career_start: String\n  career_end: String\n  tattoos: String\n  piercings: String\n  \"Duplicate aliases and those equal to name will be ignored (case-insensitive)\"\n  alias_list: [String!]\n  twitter: String @deprecated(reason: \"Use urls\")\n  instagram: String @deprecated(reason: \"Use urls\")\n  favorite: Boolean\n  tag_ids: [ID!]\n  \"This should be a URL or a base64 encoded data URL\"\n  image: String\n  stash_ids: [StashIDInput!]\n  # rating expressed as 1-100\n  rating100: Int\n  details: String\n  death_date: String\n  hair_color: String\n  weight: Int\n  ignore_auto_tag: Boolean\n\n  custom_fields: Map\n}\n\ninput PerformerUpdateInput {\n  id: ID!\n  name: String\n  disambiguation: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  gender: GenderEnum\n  birthdate: String\n  ethnicity: String\n  country: String\n  eye_color: String\n  height_cm: Int\n  measurements: String\n  fake_tits: String\n  penis_length: Float\n  circumcised: CircumcisedEnum\n  career_length: String @deprecated(reason: \"Use career_start and career_end\")\n  career_start: String\n  career_end: String\n  tattoos: String\n  piercings: String\n  \"Duplicate aliases and those equal to name will be ignored (case-insensitive)\"\n  alias_list: [String!]\n  twitter: String @deprecated(reason: \"Use urls\")\n  instagram: String @deprecated(reason: \"Use urls\")\n  favorite: Boolean\n  tag_ids: [ID!]\n  \"This should be a URL or a base64 encoded data URL\"\n  image: String\n  stash_ids: [StashIDInput!]\n  # rating expressed as 1-100\n  rating100: Int\n  details: String\n  death_date: String\n  hair_color: String\n  weight: Int\n  ignore_auto_tag: Boolean\n\n  custom_fields: CustomFieldsInput\n}\n\ninput BulkUpdateStrings {\n  values: [String!]\n  mode: BulkUpdateIdMode!\n}\n\ninput BulkPerformerUpdateInput {\n  clientMutationId: String\n  ids: [ID!]\n  disambiguation: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: BulkUpdateStrings\n  gender: GenderEnum\n  birthdate: String\n  ethnicity: String\n  country: String\n  eye_color: String\n  height_cm: Int\n  measurements: String\n  fake_tits: String\n  penis_length: Float\n  circumcised: CircumcisedEnum\n  career_length: String @deprecated(reason: \"Use career_start and career_end\")\n  career_start: String\n  career_end: String\n  tattoos: String\n  piercings: String\n  \"Duplicate aliases and those equal to name will result in an error (case-insensitive)\"\n  alias_list: BulkUpdateStrings\n  twitter: String @deprecated(reason: \"Use urls\")\n  instagram: String @deprecated(reason: \"Use urls\")\n  favorite: Boolean\n  tag_ids: BulkUpdateIds\n  # rating expressed as 1-100\n  rating100: Int\n  details: String\n  death_date: String\n  hair_color: String\n  weight: Int\n  ignore_auto_tag: Boolean\n\n  custom_fields: CustomFieldsInput\n}\n\ninput PerformerDestroyInput {\n  id: ID!\n}\n\ntype FindPerformersResultType {\n  count: Int!\n  performers: [Performer!]!\n}\n\ninput PerformerMergeInput {\n  source: [ID!]!\n  destination: ID!\n  # values defined here will override values in the destination\n  values: PerformerUpdateInput\n}\n"
  },
  {
    "path": "graphql/schema/types/plugin.graphql",
    "content": "type PluginPaths {\n  # path to javascript files\n  javascript: [String!]\n  # path to css files\n  css: [String!]\n}\n\ntype Plugin {\n  id: ID!\n  name: String!\n  description: String\n  url: String\n  version: String\n\n  enabled: Boolean!\n\n  tasks: [PluginTask!]\n  hooks: [PluginHook!]\n  settings: [PluginSetting!]\n\n  \"\"\"\n  Plugin IDs of plugins that this plugin depends on.\n  Applies only for UI plugins to indicate css/javascript load order.\n  \"\"\"\n  requires: [ID!]\n\n  paths: PluginPaths!\n}\n\ntype PluginTask {\n  name: String!\n  description: String\n  plugin: Plugin!\n}\n\ntype PluginHook {\n  name: String!\n  description: String\n  hooks: [String!]\n  plugin: Plugin!\n}\n\ntype PluginResult {\n  error: String\n  result: String\n}\n\ninput PluginArgInput {\n  key: String!\n  value: PluginValueInput\n}\n\ninput PluginValueInput {\n  str: String\n  i: Int\n  b: Boolean\n  f: Float\n  o: [PluginArgInput!]\n  a: [PluginValueInput!]\n}\n\nenum PluginSettingTypeEnum {\n  STRING\n  NUMBER\n  BOOLEAN\n}\n\ntype PluginSetting {\n  name: String!\n  display_name: String\n  description: String\n  type: PluginSettingTypeEnum!\n}\n"
  },
  {
    "path": "graphql/schema/types/scalars.graphql",
    "content": "\"An RFC3339 timestamp\"\nscalar Time\n\n\"\"\"\nTimestamp is a point in time. It is always output as RFC3339-compatible time points.\nIt can be input as a RFC3339 string, or as \"<4h\" for \"4 hours in the past\" or \">5m\"\nfor \"5 minutes in the future\"\n\"\"\"\nscalar Timestamp\n\n\"A String -> Any map\"\nscalar Map\n\n\"A String -> Boolean map\"\nscalar BoolMap\n\n\"A plugin ID -> Map (String -> Any map) map\"\nscalar PluginConfigMap\n\nscalar Any\n\nscalar Int64\n\n\"A multipart file upload\"\nscalar Upload\n"
  },
  {
    "path": "graphql/schema/types/scene-marker-tag.graphql",
    "content": "type SceneMarkerTag {\n  tag: Tag!\n  scene_markers: [SceneMarker!]!\n}\n"
  },
  {
    "path": "graphql/schema/types/scene-marker.graphql",
    "content": "type SceneMarker {\n  id: ID!\n  scene: Scene!\n  title: String!\n  \"The required start time of the marker (in seconds). Supports decimals.\"\n  seconds: Float!\n  \"The optional end time of the marker (in seconds). Supports decimals.\"\n  end_seconds: Float\n  primary_tag: Tag!\n  tags: [Tag!]!\n  created_at: Time!\n  updated_at: Time!\n\n  \"The path to stream this marker\"\n  stream: String! # Resolver\n  \"The path to the preview image for this marker\"\n  preview: String! # Resolver\n  \"The path to the screenshot image for this marker\"\n  screenshot: String! # Resolver\n}\n\ninput SceneMarkerCreateInput {\n  title: String!\n  \"The required start time of the marker (in seconds). Supports decimals.\"\n  seconds: Float!\n  \"The optional end time of the marker (in seconds). Supports decimals.\"\n  end_seconds: Float\n  scene_id: ID!\n  primary_tag_id: ID!\n  tag_ids: [ID!]\n}\n\ninput SceneMarkerUpdateInput {\n  id: ID!\n  title: String\n  \"The start time of the marker (in seconds). Supports decimals.\"\n  seconds: Float\n  \"The end time of the marker (in seconds). Supports decimals.\"\n  end_seconds: Float\n  scene_id: ID\n  primary_tag_id: ID\n  tag_ids: [ID!]\n}\n\ninput BulkSceneMarkerUpdateInput {\n  ids: [ID!]\n  title: String\n  primary_tag_id: ID\n  tag_ids: BulkUpdateIds\n}\n\ntype FindSceneMarkersResultType {\n  count: Int!\n  scene_markers: [SceneMarker!]!\n}\n\ntype MarkerStringsResultType {\n  count: Int!\n  id: ID!\n  title: String!\n}\n"
  },
  {
    "path": "graphql/schema/types/scene.graphql",
    "content": "type SceneFileType {\n  size: String\n  duration: Float\n  video_codec: String\n  audio_codec: String\n  width: Int\n  height: Int\n  framerate: Float\n  bitrate: Int\n}\n\ntype ScenePathsType {\n  screenshot: String # Resolver\n  preview: String # Resolver\n  stream: String # Resolver\n  webp: String # Resolver\n  vtt: String # Resolver\n  sprite: String # Resolver\n  funscript: String # Resolver\n  interactive_heatmap: String # Resolver\n  caption: String # Resolver\n}\n\ntype SceneMovie {\n  movie: Movie!\n  scene_index: Int\n}\n\ntype SceneGroup {\n  group: Group!\n  scene_index: Int\n}\n\ntype VideoCaption {\n  language_code: String!\n  caption_type: String!\n}\n\ntype Scene {\n  id: ID!\n  title: String\n  code: String\n  details: String\n  director: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]!\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean!\n  o_counter: Int\n  interactive: Boolean!\n  interactive_speed: Int\n  captions: [VideoCaption!]\n  created_at: Time!\n  updated_at: Time!\n  \"The last time play count was updated\"\n  last_played_at: Time\n  \"The time index a scene was left at\"\n  resume_time: Float\n  \"The total time a scene has spent playing\"\n  play_duration: Float\n  \"The number ot times a scene has been played\"\n  play_count: Int\n\n  \"Times a scene was played\"\n  play_history: [Time!]!\n  \"Times the o counter was incremented\"\n  o_history: [Time!]!\n\n  files: [VideoFile!]!\n  paths: ScenePathsType! # Resolver\n  scene_markers: [SceneMarker!]!\n  galleries: [Gallery!]!\n  studio: Studio\n  groups: [SceneGroup!]!\n  movies: [SceneMovie!]! @deprecated(reason: \"Use groups\")\n  tags: [Tag!]!\n  performers: [Performer!]!\n  stash_ids: [StashID!]!\n\n  custom_fields: Map!\n\n  \"Return valid stream paths\"\n  sceneStreams: [SceneStreamEndpoint!]!\n}\n\ninput SceneMovieInput {\n  movie_id: ID!\n  scene_index: Int\n}\n\ninput SceneGroupInput {\n  group_id: ID!\n  scene_index: Int\n}\n\ninput SceneCreateInput {\n  title: String\n  code: String\n  details: String\n  director: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean\n  studio_id: ID\n  gallery_ids: [ID!]\n  performer_ids: [ID!]\n  groups: [SceneGroupInput!]\n  movies: [SceneMovieInput!] @deprecated(reason: \"Use groups\")\n  tag_ids: [ID!]\n  \"This should be a URL or a base64 encoded data URL\"\n  cover_image: String\n  stash_ids: [StashIDInput!]\n\n  \"\"\"\n  The first id will be assigned as primary.\n  Files will be reassigned from existing scenes if applicable.\n  Files must not already be primary for another scene.\n  \"\"\"\n  file_ids: [ID!]\n\n  custom_fields: Map\n}\n\ninput SceneUpdateInput {\n  clientMutationId: String\n  id: ID!\n  title: String\n  code: String\n  details: String\n  director: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  o_counter: Int\n    @deprecated(reason: \"Unsupported - Use sceneIncrementO/sceneDecrementO\")\n  organized: Boolean\n  studio_id: ID\n  gallery_ids: [ID!]\n  performer_ids: [ID!]\n  groups: [SceneGroupInput!]\n  movies: [SceneMovieInput!] @deprecated(reason: \"Use groups\")\n  tag_ids: [ID!]\n  \"This should be a URL or a base64 encoded data URL\"\n  cover_image: String\n  stash_ids: [StashIDInput!]\n\n  \"The time index a scene was left at\"\n  resume_time: Float\n  \"The total time a scene has spent playing\"\n  play_duration: Float\n  \"The number ot times a scene has been played\"\n  play_count: Int\n    @deprecated(\n      reason: \"Unsupported - Use sceneIncrementPlayCount/sceneDecrementPlayCount\"\n    )\n\n  primary_file_id: ID\n\n  custom_fields: CustomFieldsInput\n}\n\nenum BulkUpdateIdMode {\n  SET\n  ADD\n  REMOVE\n}\n\ninput BulkUpdateIds {\n  ids: [ID!]\n  mode: BulkUpdateIdMode!\n}\n\ninput BulkSceneUpdateInput {\n  clientMutationId: String\n  ids: [ID!]\n  title: String\n  code: String\n  details: String\n  director: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: BulkUpdateStrings\n  date: String\n  # rating expressed as 1-100\n  rating100: Int\n  organized: Boolean\n  studio_id: ID\n  gallery_ids: BulkUpdateIds\n  performer_ids: BulkUpdateIds\n  tag_ids: BulkUpdateIds\n  group_ids: BulkUpdateIds\n  movie_ids: BulkUpdateIds @deprecated(reason: \"Use group_ids\")\n\n  custom_fields: CustomFieldsInput\n}\n\ninput SceneDestroyInput {\n  id: ID!\n  delete_file: Boolean\n  delete_generated: Boolean\n  \"If true, delete the file entry from the database if the file is not assigned to any other objects\"\n  destroy_file_entry: Boolean\n}\n\ninput ScenesDestroyInput {\n  ids: [ID!]!\n  delete_file: Boolean\n  delete_generated: Boolean\n  \"If true, delete the file entry from the database if the file is not assigned to any other objects\"\n  destroy_file_entry: Boolean\n}\n\ntype FindScenesResultType {\n  count: Int!\n  \"Total duration in seconds\"\n  duration: Float!\n  \"Total file size in bytes\"\n  filesize: Float!\n  scenes: [Scene!]!\n}\n\ninput SceneParserInput {\n  ignoreWords: [String!]\n  whitespaceCharacters: String\n  capitalizeTitle: Boolean\n  ignoreOrganized: Boolean\n}\n\ntype SceneMovieID {\n  movie_id: ID!\n  scene_index: String\n}\n\ntype SceneParserResult {\n  scene: Scene!\n  title: String\n  code: String\n  details: String\n  director: String\n  url: String\n  date: String\n  # rating expressed as 1-5\n  rating: Int @deprecated(reason: \"Use 1-100 range with rating100\")\n  # rating expressed as 1-100\n  rating100: Int\n  studio_id: ID\n  gallery_ids: [ID!]\n  performer_ids: [ID!]\n  movies: [SceneMovieID!]\n  tag_ids: [ID!]\n}\n\ntype SceneParserResultType {\n  count: Int!\n  results: [SceneParserResult!]!\n}\n\ninput SceneHashInput {\n  checksum: String\n  oshash: String\n}\n\ntype SceneStreamEndpoint {\n  url: String!\n  mime_type: String\n  label: String\n}\n\ninput AssignSceneFileInput {\n  scene_id: ID!\n  file_id: ID!\n}\n\ninput SceneMergeInput {\n  \"\"\"\n  If destination scene has no files, then the primary file of the\n  first source scene will be assigned as primary\n  \"\"\"\n  source: [ID!]!\n  destination: ID!\n  # values defined here will override values in the destination\n  values: SceneUpdateInput\n\n  # if true, the source history will be combined with the destination\n  play_history: Boolean\n  o_history: Boolean\n}\n\ntype HistoryMutationResult {\n  count: Int!\n  history: [Time!]!\n}\n"
  },
  {
    "path": "graphql/schema/types/scraped-group.graphql",
    "content": "\"A movie from a scraping operation...\"\ntype ScrapedMovie {\n  stored_id: ID\n  name: String\n  aliases: String\n  duration: String\n  date: String\n  rating: String\n  director: String\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  synopsis: String\n  studio: ScrapedStudio\n  tags: [ScrapedTag!]\n\n  \"This should be a base64 encoded data URL\"\n  front_image: String\n  \"This should be a base64 encoded data URL\"\n  back_image: String\n}\n\ninput ScrapedMovieInput {\n  name: String\n  aliases: String\n  duration: String\n  date: String\n  rating: String\n  director: String\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  synopsis: String\n  # not including tags for the input\n}\n\n\"A group from a scraping operation...\"\ntype ScrapedGroup {\n  stored_id: ID\n  name: String\n  aliases: String\n  duration: String\n  date: String\n  rating: String\n  director: String\n  urls: [String!]\n  synopsis: String\n  studio: ScrapedStudio\n  tags: [ScrapedTag!]\n\n  \"This should be a base64 encoded data URL\"\n  front_image: String\n  \"This should be a base64 encoded data URL\"\n  back_image: String\n}\n\ninput ScrapedGroupInput {\n  name: String\n  aliases: String\n  duration: String\n  date: String\n  rating: String\n  director: String\n  urls: [String!]\n  synopsis: String\n  # not including tags for the input\n}\n"
  },
  {
    "path": "graphql/schema/types/scraped-performer.graphql",
    "content": "\"A performer from a scraping operation...\"\ntype ScrapedPerformer {\n  \"Set if performer matched\"\n  stored_id: ID\n  name: String\n  disambiguation: String\n  gender: String\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  twitter: String @deprecated(reason: \"use urls\")\n  instagram: String @deprecated(reason: \"use urls\")\n  birthdate: String\n  ethnicity: String\n  country: String\n  eye_color: String\n  height: String\n  measurements: String\n  fake_tits: String\n  penis_length: String\n  circumcised: String\n  career_length: String @deprecated(reason: \"Use career_start and career_end\")\n  career_start: String\n  career_end: String\n  tattoos: String\n  piercings: String\n  # aliases must be comma-delimited to be parsed correctly\n  aliases: String\n  tags: [ScrapedTag!]\n\n  \"This should be a base64 encoded data URL\"\n  image: String @deprecated(reason: \"use images instead\")\n  images: [String!]\n  details: String\n  death_date: String\n  hair_color: String\n  weight: String\n  remote_site_id: String\n}\n\ninput ScrapedPerformerInput {\n  \"Set if performer matched\"\n  stored_id: ID\n  name: String\n  disambiguation: String\n  gender: String\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  twitter: String @deprecated(reason: \"use urls\")\n  instagram: String @deprecated(reason: \"use urls\")\n  birthdate: String\n  ethnicity: String\n  country: String\n  eye_color: String\n  height: String\n  measurements: String\n  fake_tits: String\n  penis_length: String\n  circumcised: String\n  career_length: String @deprecated(reason: \"Use career_start and career_end\")\n  career_start: String\n  career_end: String\n  tattoos: String\n  piercings: String\n  aliases: String\n\n  # not including tags for the input\n  # not including image for the input\n  details: String\n  death_date: String\n  hair_color: String\n  weight: String\n  remote_site_id: String\n}\n"
  },
  {
    "path": "graphql/schema/types/scraper.graphql",
    "content": "enum ScrapeType {\n  \"From text query\"\n  NAME\n  \"From existing object\"\n  FRAGMENT\n  \"From URL\"\n  URL\n}\n\n\"Type of the content a scraper generates\"\nenum ScrapeContentType {\n  GALLERY\n  IMAGE\n  MOVIE\n  GROUP\n  PERFORMER\n  SCENE\n}\n\n\"Scraped Content is the forming union over the different scrapers\"\nunion ScrapedContent =\n    ScrapedStudio\n  | ScrapedTag\n  | ScrapedScene\n  | ScrapedGallery\n  | ScrapedImage\n  | ScrapedMovie\n  | ScrapedGroup\n  | ScrapedPerformer\n\ntype ScraperSpec {\n  \"URLs matching these can be scraped with\"\n  urls: [String!]\n  supported_scrapes: [ScrapeType!]!\n}\n\ntype Scraper {\n  id: ID!\n  name: String!\n  \"Details for performer scraper\"\n  performer: ScraperSpec\n  \"Details for scene scraper\"\n  scene: ScraperSpec\n  \"Details for gallery scraper\"\n  gallery: ScraperSpec\n  \"Details for image scraper\"\n  image: ScraperSpec\n  \"Details for movie scraper\"\n  movie: ScraperSpec @deprecated(reason: \"use group\")\n  \"Details for group scraper\"\n  group: ScraperSpec\n}\n\ntype ScrapedStudio {\n  \"Set if studio matched\"\n  stored_id: ID\n  name: String!\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  parent: ScrapedStudio\n  image: String\n  details: String\n  \"Aliases must be comma-delimited to be parsed correctly\"\n  aliases: String\n  tags: [ScrapedTag!]\n\n  remote_site_id: String\n}\n\ntype ScrapedTag {\n  \"Set if tag matched\"\n  stored_id: ID\n  name: String!\n  description: String\n  alias_list: [String!]\n  parent: ScrapedTag\n  \"Remote site ID, if applicable\"\n  remote_site_id: String\n}\n\ntype ScrapedScene {\n  title: String\n  code: String\n  details: String\n  director: String\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  date: String\n\n  \"This should be a base64 encoded data URL\"\n  image: String\n\n  file: SceneFileType # Resolver\n  studio: ScrapedStudio\n  tags: [ScrapedTag!]\n  performers: [ScrapedPerformer!]\n  movies: [ScrapedMovie!] @deprecated(reason: \"use groups\")\n  groups: [ScrapedGroup!]\n\n  remote_site_id: String\n  duration: Int\n  fingerprints: [StashBoxFingerprint!]\n}\n\ninput ScrapedSceneInput {\n  title: String\n  code: String\n  details: String\n  director: String\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  date: String\n\n  # no image, file, duration or relationships\n\n  remote_site_id: String\n}\n\ntype ScrapedGallery {\n  title: String\n  code: String\n  details: String\n  photographer: String\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  date: String\n\n  studio: ScrapedStudio\n  tags: [ScrapedTag!]\n  performers: [ScrapedPerformer!]\n}\n\ninput ScrapedGalleryInput {\n  title: String\n  code: String\n  details: String\n  photographer: String\n  url: String @deprecated(reason: \"use urls\")\n  urls: [String!]\n  date: String\n\n  # no studio, tags or performers\n}\n\ntype ScrapedImage {\n  title: String\n  code: String\n  details: String\n  photographer: String\n  urls: [String!]\n  date: String\n  studio: ScrapedStudio\n  tags: [ScrapedTag!]\n  performers: [ScrapedPerformer!]\n}\n\ninput ScrapedImageInput {\n  title: String\n  code: String\n  details: String\n  urls: [String!]\n  date: String\n}\n\ninput ScraperSourceInput {\n  \"Index of the configured stash-box instance to use. Should be unset if scraper_id is set\"\n  stash_box_index: Int @deprecated(reason: \"use stash_box_endpoint\")\n  \"Stash-box endpoint\"\n  stash_box_endpoint: String\n  \"Scraper ID to scrape with. Should be unset if stash_box_endpoint/stash_box_index is set\"\n  scraper_id: ID\n}\n\ntype ScraperSource {\n  \"Index of the configured stash-box instance to use. Should be unset if scraper_id is set\"\n  stash_box_index: Int @deprecated(reason: \"use stash_box_endpoint\")\n  \"Stash-box endpoint\"\n  stash_box_endpoint: String\n  \"Scraper ID to scrape with. Should be unset if stash_box_endpoint/stash_box_index is set\"\n  scraper_id: ID\n}\n\ninput ScrapeSingleSceneInput {\n  \"Instructs to query by string\"\n  query: String\n  \"Instructs to query by scene fingerprints\"\n  scene_id: ID\n  \"Instructs to query by scene fragment\"\n  scene_input: ScrapedSceneInput\n}\n\ninput ScrapeMultiScenesInput {\n  \"Instructs to query by scene fingerprints\"\n  scene_ids: [ID!]\n}\n\ninput ScrapeSingleStudioInput {\n  \"\"\"\n  Query can be either a name or a Stash ID\n  \"\"\"\n  query: String\n}\n\ninput ScrapeSingleTagInput {\n  \"\"\"\n  Query can be either a name or a Stash ID\n  \"\"\"\n  query: String\n}\n\ninput ScrapeSinglePerformerInput {\n  \"Instructs to query by string\"\n  query: String\n  \"Instructs to query by performer id\"\n  performer_id: ID\n  \"Instructs to query by performer fragment\"\n  performer_input: ScrapedPerformerInput\n}\n\ninput ScrapeMultiPerformersInput {\n  \"Instructs to query by scene fingerprints\"\n  performer_ids: [ID!]\n}\n\ninput ScrapeSingleGalleryInput {\n  \"Instructs to query by string\"\n  query: String\n  \"Instructs to query by gallery id\"\n  gallery_id: ID\n  \"Instructs to query by gallery fragment\"\n  gallery_input: ScrapedGalleryInput\n}\n\ninput ScrapeSingleImageInput {\n  \"Instructs to query by string\"\n  query: String\n  \"Instructs to query by image id\"\n  image_id: ID\n  \"Instructs to query by image fragment\"\n  image_input: ScrapedImageInput\n}\n\ninput ScrapeSingleMovieInput {\n  \"Instructs to query by string\"\n  query: String\n  \"Instructs to query by movie id\"\n  movie_id: ID\n  \"Instructs to query by movie fragment\"\n  movie_input: ScrapedMovieInput\n}\n\ninput ScrapeSingleGroupInput {\n  \"Instructs to query by string\"\n  query: String\n  \"Instructs to query by group id\"\n  group_id: ID\n  \"Instructs to query by group fragment\"\n  group_input: ScrapedGroupInput\n}\n\ninput StashBoxSceneQueryInput {\n  \"Index of the configured stash-box instance to use\"\n  stash_box_index: Int @deprecated(reason: \"use stash_box_endpoint\")\n  \"Endpoint of the stash-box instance to use\"\n  stash_box_endpoint: String\n  \"Instructs query by scene fingerprints\"\n  scene_ids: [ID!]\n  \"Query by query string\"\n  q: String\n}\n\ninput StashBoxPerformerQueryInput {\n  \"Index of the configured stash-box instance to use\"\n  stash_box_index: Int @deprecated(reason: \"use stash_box_endpoint\")\n  \"Endpoint of the stash-box instance to use\"\n  stash_box_endpoint: String\n  \"Instructs query by scene fingerprints\"\n  performer_ids: [ID!]\n  \"Query by query string\"\n  q: String\n}\n\ntype StashBoxPerformerQueryResult {\n  query: String!\n  results: [ScrapedPerformer!]!\n}\n\ntype StashBoxFingerprint {\n  algorithm: String!\n  hash: String!\n  duration: Int!\n}\n\n\"\"\"\nAccepts either ids, or a combination of names and stash_ids.\nIf none are set, then all existing items will be tagged.\n\"\"\"\ninput StashBoxBatchTagInput {\n  \"Stash endpoint to use for the tagging\"\n  endpoint: Int @deprecated(reason: \"use stash_box_endpoint\")\n  \"Endpoint of the stash-box instance to use\"\n  stash_box_endpoint: String\n  \"Fields to exclude when executing the tagging\"\n  exclude_fields: [String!]\n  \"Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false\"\n  refresh: Boolean!\n  \"If batch adding studios, should their parent studios also be created?\"\n  createParent: Boolean!\n  \"\"\"\n  IDs in stash of the items to update.\n  If set, names and stash_ids fields will be ignored.\n  \"\"\"\n  ids: [ID!]\n  \"Names of the items in the stash-box instance to search for and create\"\n  names: [String!]\n  \"Stash IDs of the items in the stash-box instance to search for and create\"\n  stash_ids: [String!]\n  \"IDs in stash of the performers to update\"\n  performer_ids: [ID!] @deprecated(reason: \"use ids\")\n  \"Names of the performers in the stash-box instance to search for and create\"\n  performer_names: [String!] @deprecated(reason: \"use names\")\n}\n"
  },
  {
    "path": "graphql/schema/types/sql.graphql",
    "content": "type SQLQueryResult {\n  \"The column names, in the order they appear in the result set.\"\n  columns: [String!]!\n  \"The returned rows.\"\n  rows: [[Any]!]!\n}\n\ntype SQLExecResult {\n  \"\"\"\n  The number of rows affected by the query, usually an UPDATE, INSERT, or DELETE.\n  Not all queries or databases support this feature.\n  \"\"\"\n  rows_affected: Int64\n  \"\"\"\n  The integer generated by the database in response to a command.\n  Typically this will be from an \"auto increment\" column when inserting a new row.\n  Not all databases support this feature, and the syntax of such statements varies.\n  \"\"\"\n  last_insert_id: Int64\n}\n"
  },
  {
    "path": "graphql/schema/types/stash-box.graphql",
    "content": "type StashBox {\n  endpoint: String!\n  api_key: String!\n  name: String!\n  max_requests_per_minute: Int!\n}\n\ninput StashBoxInput {\n  endpoint: String!\n  api_key: String!\n  name: String!\n  # defaults to 240\n  max_requests_per_minute: Int\n}\n\ntype StashID {\n  endpoint: String!\n  stash_id: String!\n  updated_at: Time!\n}\n\ninput StashIDInput {\n  endpoint: String!\n  stash_id: String!\n  updated_at: Time\n}\n\ninput StashBoxFingerprintSubmissionInput {\n  scene_ids: [String!]!\n  stash_box_index: Int @deprecated(reason: \"use stash_box_endpoint\")\n  stash_box_endpoint: String\n}\n\ninput StashBoxDraftSubmissionInput {\n  id: String!\n  stash_box_index: Int @deprecated(reason: \"use stash_box_endpoint\")\n  stash_box_endpoint: String\n}\n"
  },
  {
    "path": "graphql/schema/types/stats.graphql",
    "content": "type StatsResultType {\n  scene_count: Int!\n  scenes_size: Float!\n  scenes_duration: Float!\n  image_count: Int!\n  images_size: Float!\n  gallery_count: Int!\n  performer_count: Int!\n  studio_count: Int!\n  group_count: Int!\n  movie_count: Int! @deprecated(reason: \"use group_count instead\")\n  tag_count: Int!\n  total_o_count: Int!\n  total_play_duration: Float!\n  total_play_count: Int!\n  scenes_played: Int!\n}\n"
  },
  {
    "path": "graphql/schema/types/studio.graphql",
    "content": "type Studio {\n  id: ID!\n  name: String!\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]!\n  parent_studio: Studio\n  child_studios: [Studio!]!\n  aliases: [String!]!\n  tags: [Tag!]!\n  ignore_auto_tag: Boolean!\n  organized: Boolean!\n\n  image_path: String # Resolver\n  scene_count(depth: Int): Int! # Resolver\n  image_count(depth: Int): Int! # Resolver\n  gallery_count(depth: Int): Int! # Resolver\n  performer_count(depth: Int): Int! # Resolver\n  group_count(depth: Int): Int! # Resolver\n  movie_count(depth: Int): Int! @deprecated(reason: \"use group_count instead\") # Resolver\n  stash_ids: [StashID!]!\n  # rating expressed as 1-100\n  rating100: Int\n  favorite: Boolean!\n  details: String\n  created_at: Time!\n  updated_at: Time!\n  groups: [Group!]!\n  movies: [Movie!]! @deprecated(reason: \"use groups instead\")\n  o_counter: Int\n\n  custom_fields: Map!\n}\n\ninput StudioCreateInput {\n  name: String!\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  parent_id: ID\n  \"This should be a URL or a base64 encoded data URL\"\n  image: String\n  stash_ids: [StashIDInput!]\n  # rating expressed as 1-100\n  rating100: Int\n  favorite: Boolean\n  details: String\n  \"Duplicate aliases and those equal to name will be ignored (case-insensitive)\"\n  aliases: [String!]\n  tag_ids: [ID!]\n  ignore_auto_tag: Boolean\n  organized: Boolean\n\n  custom_fields: Map\n}\n\ninput StudioUpdateInput {\n  id: ID!\n  name: String\n  url: String @deprecated(reason: \"Use urls\")\n  urls: [String!]\n  parent_id: ID\n  \"This should be a URL or a base64 encoded data URL\"\n  image: String\n  stash_ids: [StashIDInput!]\n  # rating expressed as 1-100\n  rating100: Int\n  favorite: Boolean\n  details: String\n  \"Duplicate aliases and those equal to name will be ignored (case-insensitive)\"\n  aliases: [String!]\n  tag_ids: [ID!]\n  ignore_auto_tag: Boolean\n  organized: Boolean\n\n  custom_fields: CustomFieldsInput\n}\n\ninput BulkStudioUpdateInput {\n  ids: [ID!]!\n  url: String @deprecated(reason: \"Use urls\")\n  urls: BulkUpdateStrings\n  parent_id: ID\n  # rating expressed as 1-100\n  rating100: Int\n  favorite: Boolean\n  details: String\n  tag_ids: BulkUpdateIds\n  ignore_auto_tag: Boolean\n  organized: Boolean\n}\n\ninput StudioDestroyInput {\n  id: ID!\n}\n\ntype FindStudiosResultType {\n  count: Int!\n  studios: [Studio!]!\n}\n"
  },
  {
    "path": "graphql/schema/types/tag.graphql",
    "content": "type Tag {\n  id: ID!\n  name: String!\n  \"Value that does not appear in the UI but overrides name for sorting\"\n  sort_name: String\n  description: String\n  aliases: [String!]!\n  ignore_auto_tag: Boolean!\n  created_at: Time!\n  updated_at: Time!\n  favorite: Boolean!\n  stash_ids: [StashID!]!\n  image_path: String # Resolver\n  scene_count(depth: Int): Int! # Resolver\n  scene_marker_count(depth: Int): Int! # Resolver\n  image_count(depth: Int): Int! # Resolver\n  gallery_count(depth: Int): Int! # Resolver\n  performer_count(depth: Int): Int! # Resolver\n  studio_count(depth: Int): Int! # Resolver\n  group_count(depth: Int): Int! # Resolver\n  movie_count(depth: Int): Int! @deprecated(reason: \"use group_count instead\") # Resolver\n  parents: [Tag!]!\n  children: [Tag!]!\n\n  parent_count: Int! # Resolver\n  child_count: Int! # Resolver\n  custom_fields: Map!\n}\n\ninput TagCreateInput {\n  name: String!\n  \"Value that does not appear in the UI but overrides name for sorting\"\n  sort_name: String\n  description: String\n  \"Duplicate aliases and those equal to name will be ignored (case-insensitive)\"\n  aliases: [String!]\n  ignore_auto_tag: Boolean\n  favorite: Boolean\n  \"This should be a URL or a base64 encoded data URL\"\n  image: String\n  stash_ids: [StashIDInput!]\n\n  parent_ids: [ID!]\n  child_ids: [ID!]\n\n  custom_fields: Map\n}\n\ninput TagUpdateInput {\n  id: ID!\n  name: String\n  \"Value that does not appear in the UI but overrides name for sorting\"\n  sort_name: String\n  description: String\n  \"Duplicate aliases and those equal to name will be ignored (case-insensitive)\"\n  aliases: [String!]\n  ignore_auto_tag: Boolean\n  favorite: Boolean\n  \"This should be a URL or a base64 encoded data URL\"\n  image: String\n  stash_ids: [StashIDInput!]\n\n  parent_ids: [ID!]\n  child_ids: [ID!]\n\n  custom_fields: CustomFieldsInput\n}\n\ninput TagDestroyInput {\n  id: ID!\n}\n\ntype FindTagsResultType {\n  count: Int!\n  tags: [Tag!]!\n}\n\ninput TagsMergeInput {\n  source: [ID!]!\n  destination: ID!\n  # values defined here will override values in the destination\n  values: TagUpdateInput\n}\n\ninput BulkTagUpdateInput {\n  ids: [ID!]\n  description: String\n  \"Duplicate aliases and those equal to name will result in an error (case-insensitive)\"\n  aliases: BulkUpdateStrings\n  ignore_auto_tag: Boolean\n  favorite: Boolean\n\n  parent_ids: BulkUpdateIds\n  child_ids: BulkUpdateIds\n}\n"
  },
  {
    "path": "graphql/schema/types/version.graphql",
    "content": "type Version {\n  version: String\n  hash: String!\n  build_time: String!\n}\n\ntype LatestVersion {\n  version: String!\n  shorthash: String!\n  release_date: String!\n  url: String!\n}\n"
  },
  {
    "path": "graphql/stash-box/query.graphql",
    "content": "fragment URLFragment on URL {\n  url\n  type\n}\n\nfragment ImageFragment on Image {\n  id\n  url\n  width\n  height\n}\n\nfragment StudioFragment on Studio {\n  name\n  id\n  aliases\n  urls {\n    ...URLFragment\n  }\n  parent {\n    name\n    id\n  }\n  images {\n    ...ImageFragment\n  }\n}\n\nfragment TagFragment on Tag {\n  name\n  id\n  description\n  aliases\n  category {\n    id\n    name\n    description\n  }\n}\n\nfragment MeasurementsFragment on Measurements {\n  band_size\n  cup_size\n  waist\n  hip\n}\n\nfragment BodyModificationFragment on BodyModification {\n  location\n  description\n}\n\nfragment PerformerFragment on Performer {\n  id\n  name\n  disambiguation\n  aliases\n  gender\n  merged_ids\n  deleted\n  merged_into_id\n  urls {\n    ...URLFragment\n  }\n  images {\n    ...ImageFragment\n  }\n  birth_date\n  death_date\n  ethnicity\n  country\n  eye_color\n  hair_color\n  height\n  measurements {\n    ...MeasurementsFragment\n  }\n  breast_type\n  career_start_year\n  career_end_year\n  tattoos {\n    ...BodyModificationFragment\n  }\n  piercings {\n    ...BodyModificationFragment\n  }\n}\n\nfragment PerformerAppearanceFragment on PerformerAppearance {\n  as\n  performer {\n    ...PerformerFragment\n  }\n}\n\nfragment FingerprintFragment on Fingerprint {\n  algorithm\n  hash\n  duration\n}\n\nfragment SceneFragment on Scene {\n  id\n  title\n  code\n  details\n  director\n  duration\n  date\n  urls {\n    ...URLFragment\n  }\n  images {\n    ...ImageFragment\n  }\n  studio {\n    ...StudioFragment\n  }\n  tags {\n    ...TagFragment\n  }\n  performers {\n    ...PerformerAppearanceFragment\n  }\n  fingerprints {\n    ...FingerprintFragment\n  }\n}\n\nquery FindScenesBySceneFingerprints(\n  $fingerprints: [[FingerprintQueryInput!]!]!\n) {\n  findScenesBySceneFingerprints(fingerprints: $fingerprints) {\n    ...SceneFragment\n  }\n}\n\nquery SearchScene($term: String!) {\n  searchScene(term: $term) {\n    ...SceneFragment\n  }\n}\n\nquery SearchPerformer($term: String!) {\n  searchPerformer(term: $term) {\n    ...PerformerFragment\n  }\n}\n\nquery FindPerformerByID($id: ID!) {\n  findPerformer(id: $id) {\n    ...PerformerFragment\n  }\n}\n\nquery FindSceneByID($id: ID!) {\n  findScene(id: $id) {\n    ...SceneFragment\n  }\n}\n\nquery FindStudio($id: ID, $name: String) {\n  findStudio(id: $id, name: $name) {\n    ...StudioFragment\n  }\n}\n\nquery FindTag($id: ID, $name: String) {\n  findTag(id: $id, name: $name) {\n    ...TagFragment\n  }\n}\n\nquery QueryTags($input: TagQueryInput!) {\n  queryTags(input: $input) {\n    count\n    tags {\n      ...TagFragment\n    }\n  }\n}\n\nmutation SubmitFingerprint($input: FingerprintSubmission!) {\n  submitFingerprint(input: $input)\n}\n\nquery Me {\n  me {\n    name\n  }\n}\n\nmutation SubmitSceneDraft($input: SceneDraftInput!) {\n  submitSceneDraft(input: $input) {\n    id\n  }\n}\n\nmutation SubmitPerformerDraft($input: PerformerDraftInput!) {\n  submitPerformerDraft(input: $input) {\n    id\n  }\n}\n"
  },
  {
    "path": "internal/api/authentication.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/session\"\n)\n\nconst (\n\ttripwireActivatedErrMsg = \"Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. \" +\n\t\t\"More information and fixes are available at https://discourse.stashapp.cc/t/-/1658\"\n\n\texternalAccessErrMsg = \"You have attempted to access Stash over the internet, and authentication is not enabled. \" +\n\t\t\"This is extremely dangerous! The whole world can see your your stash page and browse your files! \" +\n\t\t\"Stash is not answering any other requests to protect your privacy. \" +\n\t\t\"Please read the log entry or visit https://discourse.stashapp.cc/t/-/1658\"\n)\n\nfunc allowUnauthenticated(r *http.Request) bool {\n\t// #2715 - allow access to UI files\n\treturn strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == \"/css\" || strings.HasPrefix(r.URL.Path, \"/assets\")\n}\n\nfunc authenticateHandler() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tc := config.GetInstance()\n\n\t\t\t// error if external access tripwire activated\n\t\t\tif accessErr := session.CheckExternalAccessTripwire(c); accessErr != nil {\n\t\t\t\thttp.Error(w, tripwireActivatedErrMsg, http.StatusForbidden)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tr = session.SetLocalRequest(r)\n\n\t\t\tuserID, err := manager.GetInstance().SessionStore.Authenticate(w, r)\n\t\t\tif err != nil {\n\t\t\t\tif !errors.Is(err, session.ErrUnauthorized) {\n\t\t\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// unauthorized error\n\t\t\t\tw.Header().Add(\"WWW-Authenticate\", \"FormBased\")\n\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := session.CheckAllowPublicWithoutAuth(c, r); err != nil {\n\t\t\t\tvar accessErr session.ExternalAccessError\n\t\t\t\tif errors.As(err, &accessErr) {\n\t\t\t\t\tsession.LogExternalAccessError(accessErr)\n\n\t\t\t\t\terr := c.ActivatePublicAccessTripwire(net.IP(accessErr).String())\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Errorf(\"Error activating public access tripwire: %v\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\thttp.Error(w, externalAccessErrMsg, http.StatusForbidden)\n\t\t\t\t} else {\n\t\t\t\t\tlogger.Errorf(\"Error checking external access security: %v\", err)\n\t\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tctx := r.Context()\n\n\t\t\tif c.HasCredentials() {\n\t\t\t\t// authentication is required\n\t\t\t\tif userID == \"\" && !allowUnauthenticated(r) {\n\t\t\t\t\t// if graphql or a non-webpage was requested, we just return a forbidden error\n\t\t\t\t\text := path.Ext(r.URL.Path)\n\t\t\t\t\tif r.URL.Path == gqlEndpoint || (ext != \"\" && ext != \".html\") {\n\t\t\t\t\t\tw.Header().Add(\"WWW-Authenticate\", \"FormBased\")\n\t\t\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tprefix := getProxyPrefix(r)\n\n\t\t\t\t\t// otherwise redirect to the login page\n\t\t\t\t\treturnURL := url.URL{\n\t\t\t\t\t\tPath:     prefix + r.URL.Path,\n\t\t\t\t\t\tRawQuery: r.URL.RawQuery,\n\t\t\t\t\t}\n\t\t\t\t\tq := make(url.Values)\n\t\t\t\t\tq.Set(returnURLParam, returnURL.String())\n\t\t\t\t\tu := url.URL{\n\t\t\t\t\t\tPath:     prefix + loginEndpoint,\n\t\t\t\t\t\tRawQuery: q.Encode(),\n\t\t\t\t\t}\n\t\t\t\t\thttp.Redirect(w, r, u.String(), http.StatusFound)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tctx = session.SetCurrentUserID(ctx, userID)\n\n\t\t\tr = r.WithContext(ctx)\n\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/api/bool_map.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n)\n\nfunc MarshalBoolMap(val map[string]bool) graphql.Marshaler {\n\treturn graphql.WriterFunc(func(w io.Writer) {\n\t\terr := json.NewEncoder(w).Encode(val)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t})\n}\n\nfunc UnmarshalBoolMap(v interface{}) (map[string]bool, error) {\n\tm, ok := v.(map[string]interface{})\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"%T is not a map\", v)\n\t}\n\n\tresult := make(map[string]bool)\n\tfor k, v := range m {\n\t\tkey := k\n\t\tval, ok := v.(bool)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"key %s (%T) is not a bool\", k, v)\n\t\t}\n\n\t\tresult[key] = val\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/api/changeset_translator.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n)\n\nconst updateInputField = \"input\"\n\nfunc getArgumentMap(ctx context.Context) map[string]interface{} {\n\trctx := graphql.GetFieldContext(ctx)\n\treqCtx := graphql.GetOperationContext(ctx)\n\treturn rctx.Field.ArgumentMap(reqCtx.Variables)\n}\n\nfunc getUpdateInputMap(ctx context.Context) map[string]interface{} {\n\treturn getNamedUpdateInputMap(ctx, updateInputField)\n}\n\nfunc getNamedUpdateInputMap(ctx context.Context, field string) map[string]interface{} {\n\targs := getArgumentMap(ctx)\n\n\t// field can be qualified\n\tfields := strings.Split(field, \".\")\n\n\tcurrArgs := args\n\n\tfor _, f := range fields {\n\t\tv, found := currArgs[f]\n\t\tif !found {\n\t\t\tcurrArgs = nil\n\t\t\tbreak\n\t\t}\n\n\t\tcurrArgs, _ = v.(map[string]interface{})\n\t\tif currArgs == nil {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif currArgs != nil {\n\t\treturn currArgs\n\t}\n\n\treturn make(map[string]interface{})\n}\n\nfunc getUpdateInputMaps(ctx context.Context) []map[string]interface{} {\n\targs := getArgumentMap(ctx)\n\n\tinput := args[updateInputField]\n\tvar ret []map[string]interface{}\n\tif input != nil {\n\t\t// convert []interface{} into []map[string]interface{}\n\t\tiSlice, _ := input.([]interface{})\n\t\tfor _, i := range iSlice {\n\t\t\tm, _ := i.(map[string]interface{})\n\t\t\tif m != nil {\n\t\t\t\tret = append(ret, m)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret\n}\n\ntype changesetTranslator struct {\n\tinputMap map[string]interface{}\n}\n\nfunc (t changesetTranslator) hasField(field string) bool {\n\tif t.inputMap == nil {\n\t\treturn false\n\t}\n\n\t_, found := t.inputMap[field]\n\treturn found\n}\n\nfunc (t changesetTranslator) getFields() []string {\n\tvar ret []string\n\tfor k := range t.inputMap {\n\t\tret = append(ret, k)\n\t}\n\n\treturn ret\n}\n\nfunc (t changesetTranslator) string(value *string) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\n\treturn strings.TrimSpace(*value)\n}\n\nfunc (t changesetTranslator) optionalString(value *string, field string) models.OptionalString {\n\tif !t.hasField(field) {\n\t\treturn models.OptionalString{}\n\t}\n\n\tif value == nil {\n\t\treturn models.NewOptionalStringPtr(nil)\n\t}\n\n\ttrimmed := strings.TrimSpace(*value)\n\treturn models.NewOptionalString(trimmed)\n}\n\nfunc (t changesetTranslator) optionalDate(value *string, field string) (models.OptionalDate, error) {\n\tif !t.hasField(field) {\n\t\treturn models.OptionalDate{}, nil\n\t}\n\n\tif value == nil || *value == \"\" {\n\t\treturn models.OptionalDate{\n\t\t\tSet:  true,\n\t\t\tNull: true,\n\t\t}, nil\n\t}\n\n\tdate, err := models.ParseDate(*value)\n\tif err != nil {\n\t\treturn models.OptionalDate{}, err\n\t}\n\n\treturn models.NewOptionalDate(date), nil\n}\n\nfunc (t changesetTranslator) datePtr(value *string) (*models.Date, error) {\n\tif value == nil || *value == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tdate, err := models.ParseDate(*value)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &date, nil\n}\n\nfunc (t changesetTranslator) intPtrFromString(value *string) (*int, error) {\n\tif value == nil || *value == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tvv, err := strconv.Atoi(*value)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting %v to int: %w\", *value, err)\n\t}\n\treturn &vv, nil\n}\n\nfunc (t changesetTranslator) optionalInt(value *int, field string) models.OptionalInt {\n\tif !t.hasField(field) {\n\t\treturn models.OptionalInt{}\n\t}\n\n\treturn models.NewOptionalIntPtr(value)\n}\n\nfunc (t changesetTranslator) optionalIntFromString(value *string, field string) (models.OptionalInt, error) {\n\tif !t.hasField(field) {\n\t\treturn models.OptionalInt{}, nil\n\t}\n\n\tif value == nil {\n\t\treturn models.OptionalInt{\n\t\t\tSet:  true,\n\t\t\tNull: true,\n\t\t}, nil\n\t}\n\n\tvv, err := strconv.Atoi(*value)\n\tif err != nil {\n\t\treturn models.OptionalInt{}, fmt.Errorf(\"converting %v to int: %w\", *value, err)\n\t}\n\treturn models.NewOptionalInt(vv), nil\n}\n\nfunc (t changesetTranslator) bool(value *bool) bool {\n\tif value == nil {\n\t\treturn false\n\t}\n\n\treturn *value\n}\n\nfunc (t changesetTranslator) optionalBool(value *bool, field string) models.OptionalBool {\n\tif !t.hasField(field) {\n\t\treturn models.OptionalBool{}\n\t}\n\n\treturn models.NewOptionalBoolPtr(value)\n}\n\nfunc (t changesetTranslator) optionalFloat64(value *float64, field string) models.OptionalFloat64 {\n\tif !t.hasField(field) {\n\t\treturn models.OptionalFloat64{}\n\t}\n\n\treturn models.NewOptionalFloat64Ptr(value)\n}\n\nfunc (t changesetTranslator) fileIDPtrFromString(value *string) (*models.FileID, error) {\n\tif value == nil || *value == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tvv, err := strconv.Atoi(*value)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting %v to int: %w\", *value, err)\n\t}\n\n\tid := models.FileID(vv)\n\treturn &id, nil\n}\n\nfunc (t changesetTranslator) fileIDSliceFromStringSlice(value []string) ([]models.FileID, error) {\n\tints, err := stringslice.StringSliceToIntSlice(value)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfileIDs := make([]models.FileID, len(ints))\n\tfor i, v := range ints {\n\t\tfileIDs[i] = models.FileID(v)\n\t}\n\n\treturn fileIDs, nil\n}\n\nfunc (t changesetTranslator) relatedIds(value []string) (models.RelatedIDs, error) {\n\tids, err := stringslice.StringSliceToIntSlice(value)\n\tif err != nil {\n\t\treturn models.RelatedIDs{}, err\n\t}\n\n\treturn models.NewRelatedIDs(ids), nil\n}\n\nfunc (t changesetTranslator) updateIds(value []string, field string) (*models.UpdateIDs, error) {\n\tif !t.hasField(field) {\n\t\treturn nil, nil\n\t}\n\n\tids, err := stringslice.StringSliceToIntSlice(value)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &models.UpdateIDs{\n\t\tIDs:  ids,\n\t\tMode: models.RelationshipUpdateModeSet,\n\t}, nil\n}\n\nfunc (t changesetTranslator) updateIdsBulk(value *BulkUpdateIds, field string) (*models.UpdateIDs, error) {\n\tif !t.hasField(field) || value == nil {\n\t\treturn nil, nil\n\t}\n\n\tids, err := stringslice.StringSliceToIntSlice(value.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids [%v]: %w\", value.Ids, err)\n\t}\n\n\treturn &models.UpdateIDs{\n\t\tIDs:  ids,\n\t\tMode: value.Mode,\n\t}, nil\n}\n\nfunc (t changesetTranslator) optionalURLs(value []string, legacyValue *string) *models.UpdateStrings {\n\tconst (\n\t\tlegacyField = \"url\"\n\t\tfield       = \"urls\"\n\t)\n\n\t// prefer urls over url\n\tif t.hasField(field) {\n\t\treturn t.updateStrings(value, field)\n\t} else if t.hasField(legacyField) {\n\t\tvar valueSlice []string\n\t\tif legacyValue != nil {\n\t\t\tvalueSlice = []string{*legacyValue}\n\t\t}\n\t\treturn t.updateStrings(valueSlice, legacyField)\n\t}\n\n\treturn nil\n}\n\nfunc (t changesetTranslator) optionalURLsBulk(value *BulkUpdateStrings, legacyValue *string) *models.UpdateStrings {\n\tconst (\n\t\tlegacyField = \"url\"\n\t\tfield       = \"urls\"\n\t)\n\n\t// prefer urls over url\n\tif t.hasField(\"urls\") {\n\t\treturn t.updateStringsBulk(value, field)\n\t} else if t.hasField(legacyField) {\n\t\tvar valueSlice []string\n\t\tif legacyValue != nil {\n\t\t\tvalueSlice = []string{*legacyValue}\n\t\t}\n\t\treturn t.updateStrings(valueSlice, legacyField)\n\t}\n\n\treturn nil\n}\n\nfunc (t changesetTranslator) updateStrings(value []string, field string) *models.UpdateStrings {\n\tif !t.hasField(field) {\n\t\treturn nil\n\t}\n\n\t// Trim whitespace from each string\n\ttrimmedValues := make([]string, len(value))\n\tfor i, v := range value {\n\t\ttrimmedValues[i] = strings.TrimSpace(v)\n\t}\n\n\treturn &models.UpdateStrings{\n\t\tValues: trimmedValues,\n\t\tMode:   models.RelationshipUpdateModeSet,\n\t}\n}\n\nfunc (t changesetTranslator) updateStringsBulk(value *BulkUpdateStrings, field string) *models.UpdateStrings {\n\tif !t.hasField(field) || value == nil {\n\t\treturn nil\n\t}\n\n\t// Trim whitespace from each string\n\ttrimmedValues := make([]string, len(value.Values))\n\tfor i, v := range value.Values {\n\t\ttrimmedValues[i] = strings.TrimSpace(v)\n\t}\n\n\treturn &models.UpdateStrings{\n\t\tValues: trimmedValues,\n\t\tMode:   value.Mode,\n\t}\n}\n\nfunc (t changesetTranslator) updateStashIDs(value models.StashIDInputs, field string) *models.UpdateStashIDs {\n\tif !t.hasField(field) {\n\t\treturn nil\n\t}\n\n\treturn &models.UpdateStashIDs{\n\t\tStashIDs: value.ToStashIDs(),\n\t\tMode:     models.RelationshipUpdateModeSet,\n\t}\n}\n\nfunc (t changesetTranslator) relatedGroupsFromMovies(value []models.SceneMovieInput) (models.RelatedGroups, error) {\n\tgroupsScenes, err := models.GroupsScenesFromInput(value)\n\tif err != nil {\n\t\treturn models.RelatedGroups{}, err\n\t}\n\n\treturn models.NewRelatedGroups(groupsScenes), nil\n}\n\nfunc groupsScenesFromGroupInput(input []models.SceneGroupInput) ([]models.GroupsScenes, error) {\n\tret := make([]models.GroupsScenes, len(input))\n\n\tfor i, v := range input {\n\t\tmID, err := strconv.Atoi(v.GroupID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid group ID: %s\", v.GroupID)\n\t\t}\n\n\t\tret[i] = models.GroupsScenes{\n\t\t\tGroupID:    mID,\n\t\t\tSceneIndex: v.SceneIndex,\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t changesetTranslator) relatedGroups(value []models.SceneGroupInput) (models.RelatedGroups, error) {\n\tgroupsScenes, err := groupsScenesFromGroupInput(value)\n\tif err != nil {\n\t\treturn models.RelatedGroups{}, err\n\t}\n\n\treturn models.NewRelatedGroups(groupsScenes), nil\n}\n\nfunc (t changesetTranslator) updateGroupIDsFromMovies(value []models.SceneMovieInput, field string) (*models.UpdateGroupIDs, error) {\n\tif !t.hasField(field) {\n\t\treturn nil, nil\n\t}\n\n\tgroupsScenes, err := models.GroupsScenesFromInput(value)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &models.UpdateGroupIDs{\n\t\tGroups: groupsScenes,\n\t\tMode:   models.RelationshipUpdateModeSet,\n\t}, nil\n}\n\nfunc (t changesetTranslator) updateGroupIDs(value []models.SceneGroupInput, field string) (*models.UpdateGroupIDs, error) {\n\tif !t.hasField(field) {\n\t\treturn nil, nil\n\t}\n\n\tgroupsScenes, err := groupsScenesFromGroupInput(value)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &models.UpdateGroupIDs{\n\t\tGroups: groupsScenes,\n\t\tMode:   models.RelationshipUpdateModeSet,\n\t}, nil\n}\n\nfunc (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field string) (*models.UpdateGroupIDs, error) {\n\tif !t.hasField(field) || value == nil {\n\t\treturn nil, nil\n\t}\n\n\tids, err := stringslice.StringSliceToIntSlice(value.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids [%v]: %w\", value.Ids, err)\n\t}\n\n\tgroups := make([]models.GroupsScenes, len(ids))\n\tfor i, id := range ids {\n\t\tgroups[i] = models.GroupsScenes{GroupID: id}\n\t}\n\n\treturn &models.UpdateGroupIDs{\n\t\tGroups: groups,\n\t\tMode:   value.Mode,\n\t}, nil\n}\n\nfunc groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.GroupIDDescription, error) {\n\tret := make([]models.GroupIDDescription, len(input))\n\n\tfor i, v := range input {\n\t\tgID, err := strconv.Atoi(v.GroupID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid group ID: %s\", v.GroupID)\n\t\t}\n\n\t\tret[i] = models.GroupIDDescription{\n\t\t\tGroupID: gID,\n\t\t}\n\t\tif v.Description != nil {\n\t\t\tret[i].Description = strings.TrimSpace(*v.Description)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t changesetTranslator) groupIDDescriptions(value []*GroupDescriptionInput) (models.RelatedGroupDescriptions, error) {\n\tgroupsScenes, err := groupsDescriptionsFromGroupInput(value)\n\tif err != nil {\n\t\treturn models.RelatedGroupDescriptions{}, err\n\t}\n\n\treturn models.NewRelatedGroupDescriptions(groupsScenes), nil\n}\n\nfunc (t changesetTranslator) updateGroupIDDescriptions(value []*GroupDescriptionInput, field string) (*models.UpdateGroupDescriptions, error) {\n\tif !t.hasField(field) {\n\t\treturn nil, nil\n\t}\n\n\tgroupsScenes, err := groupsDescriptionsFromGroupInput(value)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &models.UpdateGroupDescriptions{\n\t\tGroups: groupsScenes,\n\t\tMode:   models.RelationshipUpdateModeSet,\n\t}, nil\n}\n\nfunc (t changesetTranslator) updateGroupIDDescriptionsBulk(value *BulkUpdateGroupDescriptionsInput, field string) (*models.UpdateGroupDescriptions, error) {\n\tif !t.hasField(field) || value == nil {\n\t\treturn nil, nil\n\t}\n\n\tgroups, err := groupsDescriptionsFromGroupInput(value.Groups)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &models.UpdateGroupDescriptions{\n\t\tGroups: groups,\n\t\tMode:   value.Mode,\n\t}, nil\n}\n"
  },
  {
    "path": "internal/api/check_version.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"golang.org/x/sys/cpu\"\n\n\t\"github.com/stashapp/stash/internal/build\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// we use the github REST V3 API as no login is required\nconst apiReleases string = \"https://api.github.com/repos/stashapp/stash/releases\"\nconst apiTags string = \"https://api.github.com/repos/stashapp/stash/tags\"\nconst apiAcceptHeader string = \"application/vnd.github.v3+json\"\nconst developmentTag string = \"latest_develop\"\nconst defaultSHLength int = 8 // default length of SHA short hash returned by <git rev-parse --short HEAD>\n\nvar stashReleases = func() map[string]string {\n\treturn map[string]string{\n\t\t\"darwin/amd64\":  \"stash-macos\",\n\t\t\"darwin/arm64\":  \"stash-macos\",\n\t\t\"linux/amd64\":   \"stash-linux\",\n\t\t\"windows/amd64\": \"stash-win.exe\",\n\t\t\"linux/arm\":     \"stash-linux-arm32v6\",\n\t\t\"linux/arm64\":   \"stash-linux-arm64v8\",\n\t\t\"linux/armv7\":   \"stash-linux-arm32v7\",\n\t}\n}\n\n// isMacOSBundle checks if the application is running from within a macOS .app bundle\nfunc isMacOSBundle() bool {\n\texec, err := os.Executable()\n\treturn err == nil && strings.Contains(exec, \"Stash.app/\")\n}\n\n// getWantedRelease determines which release variant to download based on platform and bundle type\nfunc getWantedRelease(platform string) string {\n\trelease := stashReleases()[platform]\n\n\t// On macOS, check if running from .app bundle\n\tif runtime.GOOS == \"darwin\" && isMacOSBundle() {\n\t\treturn \"Stash.app.zip\"\n\t}\n\n\treturn release\n}\n\ntype githubReleasesResponse struct {\n\tUrl              string\n\tAssets_url       string\n\tUpload_url       string\n\tHtml_url         string\n\tId               int64\n\tNode_id          string\n\tTag_name         string\n\tTarget_commitish string\n\tName             string\n\tDraft            bool\n\tAuthor           githubAuthor\n\tPrerelease       bool\n\tCreated_at       string\n\tPublished_at     string\n\tAssets           []githubAsset\n\tTarball_url      string\n\tZipball_url      string\n\tBody             string\n}\n\ntype githubAuthor struct {\n\tLogin               string\n\tId                  int64\n\tNode_id             string\n\tAvatar_url          string\n\tGravatar_id         string\n\tUrl                 string\n\tHtml_url            string\n\tFollowers_url       string\n\tFollowing_url       string\n\tGists_url           string\n\tStarred_url         string\n\tSubscriptions_url   string\n\tOrganizations_url   string\n\tRepos_url           string\n\tEvents_url          string\n\tReceived_events_url string\n\tType                string\n\tSite_admin          bool\n}\n\ntype githubAsset struct {\n\tUrl                  string\n\tId                   int64\n\tNode_id              string\n\tName                 string\n\tLabel                string\n\tUploader             githubAuthor\n\tContent_type         string\n\tState                string\n\tSize                 int64\n\tDownload_count       int64\n\tCreated_at           string\n\tUpdated_at           string\n\tBrowser_download_url string\n}\n\ntype githubTagResponse struct {\n\tName        string\n\tZipball_url string\n\tTarball_url string\n\tCommit      struct {\n\t\tSha string\n\t\tUrl string\n\t}\n\tNode_id string\n}\n\ntype LatestRelease struct {\n\tVersion   string\n\tHash      string\n\tShortHash string\n\tDate      string\n\tUrl       string\n}\n\nfunc makeGithubRequest(ctx context.Context, url string, output interface{}) error {\n\ttransport := &http.Transport{Proxy: http.ProxyFromEnvironment}\n\n\tclient := &http.Client{\n\t\tTimeout:   3 * time.Second,\n\t\tTransport: transport,\n\t}\n\n\treq, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\n\treq.Header.Add(\"Accept\", apiAcceptHeader) // gh api recommendation , send header with api version\n\tlogger.Debugf(\"Github API request: %s\", url)\n\tresponse, err := client.Do(req)\n\n\tif err != nil {\n\t\t//lint:ignore ST1005 Github is a proper capitalized noun\n\t\treturn fmt.Errorf(\"Github API request failed: %w\", err)\n\t}\n\n\tif response.StatusCode != http.StatusOK {\n\t\t//lint:ignore ST1005 Github is a proper capitalized noun\n\t\treturn fmt.Errorf(\"Github API request failed: %s\", response.Status)\n\t}\n\n\tdefer response.Body.Close()\n\n\tdata, err := io.ReadAll(response.Body)\n\tif err != nil {\n\t\t//lint:ignore ST1005 Github is a proper capitalized noun\n\t\treturn fmt.Errorf(\"Github API read response failed: %w\", err)\n\t}\n\n\terr = json.Unmarshal(data, output)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unmarshalling Github API response failed: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// GetLatestRelease gets latest release information from github API\n// If running a build from the \"master\" branch, then the latest full release\n// is used, otherwise it uses the release that is tagged with \"latest_develop\"\n// which is the latest pre-release build.\nfunc GetLatestRelease(ctx context.Context) (*LatestRelease, error) {\n\tarch := runtime.GOARCH\n\n\t// https://en.wikipedia.org/wiki/Comparison_of_ARM_cores\n\t// armv6 doesn't support any of these features\n\tisARMv7 := cpu.ARM.HasNEON || cpu.ARM.HasVFPv3 || cpu.ARM.HasVFPv3D16 || cpu.ARM.HasVFPv4\n\tif arch == \"arm\" && isARMv7 {\n\t\tarch = \"armv7\"\n\t}\n\n\tplatform := fmt.Sprintf(\"%s/%s\", runtime.GOOS, arch)\n\twantedRelease := getWantedRelease(platform)\n\n\turl := apiReleases\n\tif build.IsDevelop() {\n\t\t// get the release tagged with the development tag\n\t\turl += \"/tags/\" + developmentTag\n\t} else {\n\t\t// just get the latest full release\n\t\turl += \"/latest\"\n\t}\n\n\tvar release githubReleasesResponse\n\terr := makeGithubRequest(ctx, url, &release)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tversion := release.Name\n\tif release.Prerelease {\n\t\t// find version in prerelease name\n\t\tre := regexp.MustCompile(`v[\\w-\\.]+-\\d+-g[0-9a-f]+`)\n\t\tif match := re.FindString(version); match != \"\" {\n\t\t\tversion = match\n\t\t}\n\t}\n\n\tlatestHash, err := getReleaseHash(ctx, release.Tag_name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar releaseDate string\n\tif publishedAt, err := time.Parse(time.RFC3339, release.Published_at); err == nil {\n\t\treleaseDate = publishedAt.Format(\"2006-01-02\")\n\t}\n\n\tvar releaseUrl string\n\tif wantedRelease != \"\" {\n\t\tfor _, asset := range release.Assets {\n\t\t\tif asset.Name == wantedRelease {\n\t\t\t\treleaseUrl = asset.Browser_download_url\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\t_, githash, _ := build.Version()\n\tshLength := len(githash)\n\tif shLength == 0 {\n\t\tshLength = defaultSHLength\n\t}\n\n\treturn &LatestRelease{\n\t\tVersion:   version,\n\t\tHash:      latestHash,\n\t\tShortHash: latestHash[:shLength],\n\t\tDate:      releaseDate,\n\t\tUrl:       releaseUrl,\n\t}, nil\n}\n\nfunc getReleaseHash(ctx context.Context, tagName string) (string, error) {\n\t// Start with a small page size if not searching for latest_develop\n\tperPage := 10\n\tif tagName == developmentTag {\n\t\tperPage = 100\n\t}\n\n\t// Limit to 5 pages, ie 500 tags - should be plenty\n\tfor page := 1; page <= 5; {\n\t\turl := fmt.Sprintf(\"%s?per_page=%d&page=%d\", apiTags, perPage, page)\n\t\ttags := []githubTagResponse{}\n\t\terr := makeGithubRequest(ctx, url, &tags)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tif tag.Name == tagName {\n\t\t\t\tif len(tag.Commit.Sha) != 40 {\n\t\t\t\t\treturn \"\", errors.New(\"invalid Github API response\")\n\t\t\t\t}\n\t\t\t\treturn tag.Commit.Sha, nil\n\t\t\t}\n\t\t}\n\n\t\tif len(tags) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\t// if not found in the first 10, search again on page 1 with the first 100\n\t\tif perPage == 10 {\n\t\t\tperPage = 100\n\t\t} else {\n\t\t\tpage++\n\t\t}\n\t}\n\n\treturn \"\", errors.New(\"invalid Github API response\")\n}\n\nfunc printLatestVersion(ctx context.Context) {\n\tlatestRelease, err := GetLatestRelease(ctx)\n\tif err != nil {\n\t\tlogger.Errorf(\"Couldn't retrieve latest version: %v\", err)\n\t} else {\n\t\t_, githash, _ := build.Version()\n\t\tswitch {\n\t\tcase githash == \"\":\n\t\t\tlogger.Infof(\"Latest version: %s (%s)\", latestRelease.Version, latestRelease.ShortHash)\n\t\tcase githash == latestRelease.ShortHash:\n\t\t\tlogger.Infof(\"Version %s (%s) is already the latest released\", latestRelease.Version, latestRelease.ShortHash)\n\t\tdefault:\n\t\t\tlogger.Infof(\"New version available: %s (%s)\", latestRelease.Version, latestRelease.ShortHash)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/api/context_keys.go",
    "content": "package api\n\n// https://stackoverflow.com/questions/40891345/fix-should-not-use-basic-type-string-as-key-in-context-withvalue-golint\n\ntype key int\n\nconst (\n\tgalleryKey key = 0\n\tperformerKey\n\tsceneKey\n\tstudioKey\n\tgroupKey\n\ttagKey\n\tdownloadKey\n\timageKey\n\tpluginKey\n)\n"
  },
  {
    "path": "internal/api/custom_fields.go",
    "content": "package api\n\nimport \"github.com/stashapp/stash/pkg/models\"\n\nfunc handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput {\n\tret := input\n\t// convert json.Numbers to int/float\n\tret.Full = convertMapJSONNumbers(ret.Full)\n\tret.Partial = convertMapJSONNumbers(ret.Partial)\n\n\treturn ret\n}\n"
  },
  {
    "path": "internal/api/dir_list.go",
    "content": "package api\n\nimport (\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"golang.org/x/text/collate\"\n)\n\ntype dirLister []fs.DirEntry\n\nfunc (s dirLister) Len() int {\n\treturn len(s)\n}\n\nfunc (s dirLister) Swap(i, j int) {\n\ts[j], s[i] = s[i], s[j]\n}\n\nfunc (s dirLister) Bytes(i int) []byte {\n\treturn []byte(s[i].Name())\n}\n\n// listDir will return the contents of a given directory path as a string slice\nfunc listDir(col *collate.Collator, path string) ([]string, error) {\n\tvar dirPaths []string\n\tdirPath := path\n\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tdirPath = filepath.Dir(path)\n\t\tdirFiles, err := os.ReadDir(dirPath)\n\t\tif err != nil {\n\t\t\treturn dirPaths, err\n\t\t}\n\n\t\t// Filter dir contents by last path fragment if the dir isn't an exact match\n\t\tbase := strings.ToLower(filepath.Base(path))\n\t\tif base != \".\" && base != string(filepath.Separator) {\n\t\t\tfor _, file := range dirFiles {\n\t\t\t\tif strings.HasPrefix(strings.ToLower(file.Name()), base) {\n\t\t\t\t\tfiles = append(files, file)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tfiles = dirFiles\n\t\t}\n\t}\n\n\tif col != nil {\n\t\tcol.Sort(dirLister(files))\n\t}\n\n\tfor _, file := range files {\n\t\tif !file.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tdirPaths = append(dirPaths, filepath.Join(dirPath, file.Name()))\n\t}\n\treturn dirPaths, nil\n}\n"
  },
  {
    "path": "internal/api/doc.go",
    "content": "// Package api provides the HTTP and Graphql API for the application.\npackage api\n"
  },
  {
    "path": "internal/api/error.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/vektah/gqlparser/v2/gqlerror\"\n)\n\nfunc gqlErrorHandler(ctx context.Context, e error) *gqlerror.Error {\n\tif !errors.Is(ctx.Err(), context.Canceled) {\n\t\t// log all errors - for now just log the error message\n\t\t// we can potentially add more context later\n\t\tfc := graphql.GetFieldContext(ctx)\n\t\tif fc != nil {\n\t\t\tlogger.Errorf(\"%s: %v\", fc.Path(), e)\n\n\t\t\t// log the args in debug level\n\t\t\tlogger.DebugFunc(func() (string, []interface{}) {\n\t\t\t\tvar args interface{}\n\t\t\t\targs = fc.Args\n\n\t\t\t\ts, _ := json.Marshal(args)\n\t\t\t\tif len(s) > 0 {\n\t\t\t\t\targs = string(s)\n\t\t\t\t}\n\n\t\t\t\treturn \"%s: %v\", []interface{}{\n\t\t\t\t\tfc.Path(),\n\t\t\t\t\targs,\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n\n\t// we may also want to transform the error message for the response\n\t// for now just return the original error\n\treturn graphql.DefaultErrorPresenter(ctx, e)\n}\n"
  },
  {
    "path": "internal/api/fields.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n)\n\ntype queryFields []string\n\nfunc collectQueryFields(ctx context.Context) queryFields {\n\tfields := graphql.CollectAllFields(ctx)\n\treturn queryFields(fields)\n}\n\nfunc (f queryFields) Has(field string) bool {\n\tfor _, v := range f {\n\t\tif v == field {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/api/images.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/hash\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype imageBox struct {\n\tbox   fs.FS\n\tfiles []string\n}\n\nvar imageBoxExts = []string{\n\t\".jpg\",\n\t\".jpeg\",\n\t\".png\",\n\t\".gif\",\n\t\".svg\",\n\t\".webp\",\n\t\".avif\",\n}\n\nfunc newImageBox(box fs.FS) (*imageBox, error) {\n\tret := &imageBox{\n\t\tbox: box,\n\t}\n\n\terr := fs.WalkDir(box, \".\", func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\tbaseName := strings.ToLower(d.Name())\n\t\tfor _, ext := range imageBoxExts {\n\t\t\tif strings.HasSuffix(baseName, ext) {\n\t\t\t\tret.files = append(ret.files, path)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n\n\treturn ret, err\n}\n\nfunc (box *imageBox) GetRandomImageByName(name string) ([]byte, error) {\n\tfiles := box.files\n\tif len(files) == 0 {\n\t\treturn nil, errors.New(\"box is empty\")\n\t}\n\n\tindex := hash.IntFromString(name) % uint64(len(files))\n\timg, err := box.box.Open(files[index])\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer img.Close()\n\n\treturn io.ReadAll(img)\n}\n\nvar performerBox *imageBox\nvar performerBoxMale *imageBox\nvar performerBoxCustom *imageBox\n\nfunc init() {\n\tvar err error\n\tperformerBox, err = newImageBox(static.Sub(static.Performer))\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"loading performer images: %v\", err))\n\t}\n\tperformerBoxMale, err = newImageBox(static.Sub(static.PerformerMale))\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"loading male performer images: %v\", err))\n\t}\n}\n\nfunc initCustomPerformerImages(customPath string) {\n\tif customPath != \"\" {\n\t\tlogger.Debugf(\"Loading custom performer images from %s\", customPath)\n\t\tvar err error\n\t\tperformerBoxCustom, err = newImageBox(os.DirFS(customPath))\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"error loading custom performer images from %s: %v\", customPath, err)\n\t\t}\n\t} else {\n\t\tperformerBoxCustom = nil\n\t}\n}\n\nfunc getDefaultPerformerImage(name string, gender *models.GenderEnum, sfwMode bool) []byte {\n\t// try the custom box first if we have one\n\tif performerBoxCustom != nil {\n\t\tret, err := performerBoxCustom.GetRandomImageByName(name)\n\t\tif err == nil {\n\t\t\treturn ret\n\t\t}\n\t\tlogger.Warnf(\"error loading custom default performer image: %v\", err)\n\t}\n\n\tif sfwMode {\n\t\treturn static.ReadAll(static.DefaultSFWPerformerImage)\n\t}\n\n\tvar g models.GenderEnum\n\tif gender != nil {\n\t\tg = *gender\n\t}\n\n\tvar box *imageBox\n\tswitch g {\n\tcase models.GenderEnumFemale, models.GenderEnumTransgenderFemale:\n\t\tbox = performerBox\n\tcase models.GenderEnumMale, models.GenderEnumTransgenderMale:\n\t\tbox = performerBoxMale\n\tdefault:\n\t\tbox = performerBox\n\t}\n\n\tret, err := box.GetRandomImageByName(name)\n\tif err != nil {\n\t\tlogger.Warnf(\"error loading default performer image: %v\", err)\n\t}\n\treturn ret\n}\n"
  },
  {
    "path": "internal/api/input.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n)\n\n// TODO - apply handleIDs to other resolvers that accept ID lists\n\n// handleIDList validates and converts a list of string IDs to integers\nfunc handleIDList(idList []string, field string) ([]int, error) {\n\tif err := validateIDList(idList); err != nil {\n\t\treturn nil, fmt.Errorf(\"validating %s: %w\", field, err)\n\t}\n\n\tids, err := stringslice.StringSliceToIntSlice(idList)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting %s: %w\", field, err)\n\t}\n\n\treturn ids, nil\n}\n\n// validateIDList returns an error if there are any duplicate ids in the list\nfunc validateIDList(ids []string) error {\n\tseen := make(map[string]struct{})\n\tfor _, id := range ids {\n\t\tif _, exists := seen[id]; exists {\n\t\t\treturn fmt.Errorf(\"duplicate id found: %s\", id)\n\t\t}\n\t\tseen[id] = struct{}{}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/api/json.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// jsonNumberToNumber converts a JSON number to either a float64 or int64.\nfunc jsonNumberToNumber(n json.Number) interface{} {\n\tif strings.Contains(string(n), \".\") {\n\t\tf, _ := n.Float64()\n\t\treturn f\n\t}\n\tret, _ := n.Int64()\n\treturn ret\n}\n\n// anyJSONNumberToNumber converts a JSON number using jsonNumberToNumber, otherwise it returns the existing value\nfunc anyJSONNumberToNumber(v any) any {\n\tif n, ok := v.(json.Number); ok {\n\t\treturn jsonNumberToNumber(n)\n\t}\n\n\treturn v\n}\n\n// ConvertMapJSONNumbers converts all JSON numbers in a map to either float64 or int64.\nfunc convertMapJSONNumbers(m map[string]interface{}) (ret map[string]interface{}) {\n\tif m == nil {\n\t\treturn nil\n\t}\n\n\tret = make(map[string]interface{})\n\tfor k, v := range m {\n\t\tif n, ok := v.(json.Number); ok {\n\t\t\tret[k] = jsonNumberToNumber(n)\n\t\t} else if mm, ok := v.(map[string]interface{}); ok {\n\t\t\tret[k] = convertMapJSONNumbers(mm)\n\t\t} else {\n\t\t\tret[k] = v\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc convertCustomFieldCriterionValues(c models.CustomFieldCriterionInput) models.CustomFieldCriterionInput {\n\tnv := make([]any, len(c.Value))\n\tfor i, v := range c.Value {\n\t\tnv[i] = anyJSONNumberToNumber(v)\n\t}\n\tc.Value = nv\n\treturn c\n}\n\nfunc convertCustomFieldCriterionInputJSONNumbers(c []models.CustomFieldCriterionInput) []models.CustomFieldCriterionInput {\n\tret := make([]models.CustomFieldCriterionInput, len(c))\n\tfor i, v := range c {\n\t\tret[i] = convertCustomFieldCriterionValues(v)\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "internal/api/json_test.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestConvertMapJSONNumbers(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    map[string]interface{}\n\t\texpected map[string]interface{}\n\t}{\n\t\t{\n\t\t\tname: \"Convert JSON numbers to numbers\",\n\t\t\tinput: map[string]interface{}{\n\t\t\t\t\"int\":    json.Number(\"12\"),\n\t\t\t\t\"float\":  json.Number(\"12.34\"),\n\t\t\t\t\"string\": \"foo\",\n\t\t\t},\n\t\t\texpected: map[string]interface{}{\n\t\t\t\t\"int\":    int64(12),\n\t\t\t\t\"float\":  12.34,\n\t\t\t\t\"string\": \"foo\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Convert JSON numbers to numbers in nested maps\",\n\t\t\tinput: map[string]interface{}{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"int\":           json.Number(\"56\"),\n\t\t\t\t\t\"float\":         json.Number(\"56.78\"),\n\t\t\t\t\t\"nested-string\": \"bar\",\n\t\t\t\t},\n\t\t\t\t\"int\":    json.Number(\"12\"),\n\t\t\t\t\"float\":  json.Number(\"12.34\"),\n\t\t\t\t\"string\": \"foo\",\n\t\t\t},\n\t\t\texpected: map[string]interface{}{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"int\":           int64(56),\n\t\t\t\t\t\"float\":         56.78,\n\t\t\t\t\t\"nested-string\": \"bar\",\n\t\t\t\t},\n\t\t\t\t\"int\":    int64(12),\n\t\t\t\t\"float\":  12.34,\n\t\t\t\t\"string\": \"foo\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := convertMapJSONNumbers(tt.input)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/api/loaders/customfieldsloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// CustomFieldsLoaderConfig captures the config to create a new CustomFieldsLoader\ntype CustomFieldsLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]models.CustomFieldMap, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewCustomFieldsLoader creates a new CustomFieldsLoader given a fetch, wait, and maxBatch\nfunc NewCustomFieldsLoader(config CustomFieldsLoaderConfig) *CustomFieldsLoader {\n\treturn &CustomFieldsLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// CustomFieldsLoader batches and caches requests\ntype CustomFieldsLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]models.CustomFieldMap, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]models.CustomFieldMap\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *customFieldsLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype customFieldsLoaderBatch struct {\n\tkeys    []int\n\tdata    []models.CustomFieldMap\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a CustomFieldMap by key, batching and caching will be applied automatically\nfunc (l *CustomFieldsLoader) Load(key int) (models.CustomFieldMap, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a CustomFieldMap.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *CustomFieldsLoader) LoadThunk(key int) func() (models.CustomFieldMap, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (models.CustomFieldMap, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &customFieldsLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (models.CustomFieldMap, error) {\n\t\t<-batch.done\n\n\t\tvar data models.CustomFieldMap\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *CustomFieldsLoader) LoadAll(keys []int) ([]models.CustomFieldMap, []error) {\n\tresults := make([]func() (models.CustomFieldMap, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tcustomFieldMaps := make([]models.CustomFieldMap, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tcustomFieldMaps[i], errors[i] = thunk()\n\t}\n\treturn customFieldMaps, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a CustomFieldMaps.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *CustomFieldsLoader) LoadAllThunk(keys []int) func() ([]models.CustomFieldMap, []error) {\n\tresults := make([]func() (models.CustomFieldMap, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]models.CustomFieldMap, []error) {\n\t\tcustomFieldMaps := make([]models.CustomFieldMap, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tcustomFieldMaps[i], errors[i] = thunk()\n\t\t}\n\t\treturn customFieldMaps, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *CustomFieldsLoader) Prime(key int, value models.CustomFieldMap) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\tl.unsafeSet(key, value)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *CustomFieldsLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *CustomFieldsLoader) unsafeSet(key int, value models.CustomFieldMap) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]models.CustomFieldMap{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *customFieldsLoaderBatch) keyIndex(l *CustomFieldsLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *customFieldsLoaderBatch) startTimer(l *CustomFieldsLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *customFieldsLoaderBatch) end(l *CustomFieldsLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/dataloaders.go",
    "content": "// Package loaders contains the dataloaders used by the resolver in [api].\n// They are generated with `make generate-dataloaders`.\n// The dataloaders are used to batch requests to the database.\n\n//go:generate go run github.com/vektah/dataloaden SceneLoader int *github.com/stashapp/stash/pkg/models.Scene\n//go:generate go run github.com/vektah/dataloaden GalleryLoader int *github.com/stashapp/stash/pkg/models.Gallery\n//go:generate go run github.com/vektah/dataloaden ImageLoader int *github.com/stashapp/stash/pkg/models.Image\n//go:generate go run github.com/vektah/dataloaden PerformerLoader int *github.com/stashapp/stash/pkg/models.Performer\n//go:generate go run github.com/vektah/dataloaden StudioLoader int *github.com/stashapp/stash/pkg/models.Studio\n//go:generate go run github.com/vektah/dataloaden TagLoader int *github.com/stashapp/stash/pkg/models.Tag\n//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group\n//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File\n//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder\n//go:generate go run github.com/vektah/dataloaden FolderParentFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID\n//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID\n//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID\n//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID\n//go:generate go run github.com/vektah/dataloaden CustomFieldsLoader int github.com/stashapp/stash/pkg/models.CustomFieldMap\n//go:generate go run github.com/vektah/dataloaden SceneOCountLoader int int\n//go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int\n//go:generate go run github.com/vektah/dataloaden SceneOHistoryLoader int []time.Time\n//go:generate go run github.com/vektah/dataloaden ScenePlayHistoryLoader int []time.Time\n//go:generate go run github.com/vektah/dataloaden SceneLastPlayedLoader int *time.Time\npackage loaders\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype contextKey struct{ name string }\n\nvar (\n\tloadersCtxKey = &contextKey{\"loaders\"}\n)\n\nconst (\n\twait     = 1 * time.Millisecond\n\tmaxBatch = 100\n)\n\ntype Loaders struct {\n\tSceneByID         *SceneLoader\n\tSceneFiles        *SceneFileIDsLoader\n\tScenePlayCount    *ScenePlayCountLoader\n\tSceneOCount       *SceneOCountLoader\n\tScenePlayHistory  *ScenePlayHistoryLoader\n\tSceneOHistory     *SceneOHistoryLoader\n\tSceneLastPlayed   *SceneLastPlayedLoader\n\tSceneCustomFields *CustomFieldsLoader\n\n\tImageFiles   *ImageFileIDsLoader\n\tGalleryFiles *GalleryFileIDsLoader\n\n\tGalleryByID         *GalleryLoader\n\tGalleryCustomFields *CustomFieldsLoader\n\tImageByID           *ImageLoader\n\tImageCustomFields   *CustomFieldsLoader\n\n\tPerformerByID         *PerformerLoader\n\tPerformerCustomFields *CustomFieldsLoader\n\n\tStudioByID         *StudioLoader\n\tStudioCustomFields *CustomFieldsLoader\n\n\tTagByID         *TagLoader\n\tTagCustomFields *CustomFieldsLoader\n\n\tGroupByID         *GroupLoader\n\tGroupCustomFields *CustomFieldsLoader\n\n\tFileByID *FileLoader\n\n\tFolderByID            *FolderLoader\n\tFolderParentFolderIDs *FolderParentFolderIDsLoader\n}\n\ntype Middleware struct {\n\tRepository models.Repository\n}\n\nfunc (m Middleware) Middleware(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\t\tldrs := Loaders{\n\t\t\tSceneByID: &SceneLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchScenes(ctx),\n\t\t\t},\n\t\t\tGalleryByID: &GalleryLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchGalleries(ctx),\n\t\t\t},\n\t\t\tGalleryCustomFields: &CustomFieldsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchGalleryCustomFields(ctx),\n\t\t\t},\n\t\t\tImageByID: &ImageLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchImages(ctx),\n\t\t\t},\n\t\t\tImageCustomFields: &CustomFieldsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchImageCustomFields(ctx),\n\t\t\t},\n\t\t\tPerformerByID: &PerformerLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchPerformers(ctx),\n\t\t\t},\n\t\t\tPerformerCustomFields: &CustomFieldsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchPerformerCustomFields(ctx),\n\t\t\t},\n\t\t\tStudioCustomFields: &CustomFieldsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchStudioCustomFields(ctx),\n\t\t\t},\n\t\t\tSceneCustomFields: &CustomFieldsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchSceneCustomFields(ctx),\n\t\t\t},\n\t\t\tStudioByID: &StudioLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchStudios(ctx),\n\t\t\t},\n\t\t\tTagByID: &TagLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchTags(ctx),\n\t\t\t},\n\t\t\tTagCustomFields: &CustomFieldsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchTagCustomFields(ctx),\n\t\t\t},\n\t\t\tGroupByID: &GroupLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchGroups(ctx),\n\t\t\t},\n\t\t\tGroupCustomFields: &CustomFieldsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchGroupCustomFields(ctx),\n\t\t\t},\n\t\t\tFileByID: &FileLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchFiles(ctx),\n\t\t\t},\n\t\t\tFolderByID: &FolderLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchFolders(ctx),\n\t\t\t},\n\t\t\tFolderParentFolderIDs: &FolderParentFolderIDsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchFoldersParentFolderIDs(ctx),\n\t\t\t},\n\t\t\tSceneFiles: &SceneFileIDsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchScenesFileIDs(ctx),\n\t\t\t},\n\t\t\tImageFiles: &ImageFileIDsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchImagesFileIDs(ctx),\n\t\t\t},\n\t\t\tGalleryFiles: &GalleryFileIDsLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchGalleriesFileIDs(ctx),\n\t\t\t},\n\t\t\tScenePlayCount: &ScenePlayCountLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchScenesPlayCount(ctx),\n\t\t\t},\n\t\t\tSceneOCount: &SceneOCountLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchScenesOCount(ctx),\n\t\t\t},\n\t\t\tScenePlayHistory: &ScenePlayHistoryLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchScenesPlayHistory(ctx),\n\t\t\t},\n\t\t\tSceneLastPlayed: &SceneLastPlayedLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchScenesLastPlayed(ctx),\n\t\t\t},\n\t\t\tSceneOHistory: &SceneOHistoryLoader{\n\t\t\t\twait:     wait,\n\t\t\t\tmaxBatch: maxBatch,\n\t\t\t\tfetch:    m.fetchScenesOHistory(ctx),\n\t\t\t},\n\t\t}\n\n\t\tnewCtx := context.WithValue(r.Context(), loadersCtxKey, ldrs)\n\t\tnext.ServeHTTP(w, r.WithContext(newCtx))\n\t})\n}\n\nfunc From(ctx context.Context) Loaders {\n\treturn ctx.Value(loadersCtxKey).(Loaders)\n}\n\nfunc toErrorSlice(err error) []error {\n\tif err != nil {\n\t\treturn []error{err}\n\t}\n\n\treturn nil\n}\n\nfunc (m Middleware) fetchScenes(ctx context.Context) func(keys []int) ([]*models.Scene, []error) {\n\treturn func(keys []int) (ret []*models.Scene, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Scene.FindMany(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {\n\treturn func(keys []int) (ret []models.CustomFieldMap, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Scene.GetCustomFieldsBulk(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) {\n\treturn func(keys []int) (ret []*models.Image, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Image.FindMany(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchImageCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {\n\treturn func(keys []int) (ret []models.CustomFieldMap, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Image.GetCustomFieldsBulk(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) {\n\treturn func(keys []int) (ret []*models.Gallery, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Gallery.FindMany(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchPerformers(ctx context.Context) func(keys []int) ([]*models.Performer, []error) {\n\treturn func(keys []int) (ret []*models.Performer, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Performer.FindMany(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchPerformerCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {\n\treturn func(keys []int) (ret []models.CustomFieldMap, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Performer.GetCustomFieldsBulk(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*models.Studio, []error) {\n\treturn func(keys []int) (ret []*models.Studio, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Studio.FindMany(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchStudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {\n\treturn func(keys []int) (ret []models.CustomFieldMap, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Studio.GetCustomFieldsBulk(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) {\n\treturn func(keys []int) (ret []*models.Tag, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Tag.FindMany(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {\n\treturn func(keys []int) (ret []models.CustomFieldMap, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Tag.GetCustomFieldsBulk(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchGroupCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {\n\treturn func(keys []int) (ret []models.CustomFieldMap, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Group.GetCustomFieldsBulk(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {\n\treturn func(keys []int) (ret []models.CustomFieldMap, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys)\n\t\t\treturn err\n\t\t})\n\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) {\n\treturn func(keys []int) (ret []*models.Group, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Group.FindMany(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchFiles(ctx context.Context) func(keys []models.FileID) ([]models.File, []error) {\n\treturn func(keys []models.FileID) (ret []models.File, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.File.Find(ctx, keys...)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderID) ([]*models.Folder, []error) {\n\treturn func(keys []models.FolderID) (ret []*models.Folder, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Folder.FindMany(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {\n\treturn func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {\n\treturn func(keys []int) (ret [][]models.FileID, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Scene.GetManyFileIDs(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchImagesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {\n\treturn func(keys []int) (ret [][]models.FileID, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Image.GetManyFileIDs(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchGalleriesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {\n\treturn func(keys []int) (ret [][]models.FileID, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Gallery.GetManyFileIDs(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchScenesOCount(ctx context.Context) func(keys []int) ([]int, []error) {\n\treturn func(keys []int) (ret []int, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Scene.GetManyOCount(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchScenesPlayCount(ctx context.Context) func(keys []int) ([]int, []error) {\n\treturn func(keys []int) (ret []int, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Scene.GetManyViewCount(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchScenesOHistory(ctx context.Context) func(keys []int) ([][]time.Time, []error) {\n\treturn func(keys []int) (ret [][]time.Time, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Scene.GetManyODates(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchScenesPlayHistory(ctx context.Context) func(keys []int) ([][]time.Time, []error) {\n\treturn func(keys []int) (ret [][]time.Time, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Scene.GetManyViewDates(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n\nfunc (m Middleware) fetchScenesLastPlayed(ctx context.Context) func(keys []int) ([]*time.Time, []error) {\n\treturn func(keys []int) (ret []*time.Time, errs []error) {\n\t\terr := m.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tret, err = m.Repository.Scene.GetManyLastViewed(ctx, keys)\n\t\t\treturn err\n\t\t})\n\t\treturn ret, toErrorSlice(err)\n\t}\n}\n"
  },
  {
    "path": "internal/api/loaders/fileloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// FileLoaderConfig captures the config to create a new FileLoader\ntype FileLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []models.FileID) ([]models.File, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewFileLoader creates a new FileLoader given a fetch, wait, and maxBatch\nfunc NewFileLoader(config FileLoaderConfig) *FileLoader {\n\treturn &FileLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// FileLoader batches and caches requests\ntype FileLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []models.FileID) ([]models.File, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[models.FileID]models.File\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *fileLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype fileLoaderBatch struct {\n\tkeys    []models.FileID\n\tdata    []models.File\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a File by key, batching and caching will be applied automatically\nfunc (l *FileLoader) Load(key models.FileID) (models.File, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a File.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *FileLoader) LoadThunk(key models.FileID) func() (models.File, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (models.File, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &fileLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (models.File, error) {\n\t\t<-batch.done\n\n\t\tvar data models.File\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *FileLoader) LoadAll(keys []models.FileID) ([]models.File, []error) {\n\tresults := make([]func() (models.File, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tfiles := make([]models.File, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tfiles[i], errors[i] = thunk()\n\t}\n\treturn files, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Files.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *FileLoader) LoadAllThunk(keys []models.FileID) func() ([]models.File, []error) {\n\tresults := make([]func() (models.File, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]models.File, []error) {\n\t\tfiles := make([]models.File, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tfiles[i], errors[i] = thunk()\n\t\t}\n\t\treturn files, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *FileLoader) Prime(key models.FileID, value models.File) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\tl.unsafeSet(key, value)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *FileLoader) Clear(key models.FileID) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *FileLoader) unsafeSet(key models.FileID, value models.File) {\n\tif l.cache == nil {\n\t\tl.cache = map[models.FileID]models.File{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *fileLoaderBatch) keyIndex(l *FileLoader, key models.FileID) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *fileLoaderBatch) startTimer(l *FileLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *fileLoaderBatch) end(l *FileLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/folderloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// FolderLoaderConfig captures the config to create a new FolderLoader\ntype FolderLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []models.FolderID) ([]*models.Folder, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewFolderLoader creates a new FolderLoader given a fetch, wait, and maxBatch\nfunc NewFolderLoader(config FolderLoaderConfig) *FolderLoader {\n\treturn &FolderLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// FolderLoader batches and caches requests\ntype FolderLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []models.FolderID) ([]*models.Folder, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[models.FolderID]*models.Folder\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *folderLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype folderLoaderBatch struct {\n\tkeys    []models.FolderID\n\tdata    []*models.Folder\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Folder by key, batching and caching will be applied automatically\nfunc (l *FolderLoader) Load(key models.FolderID) (*models.Folder, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Folder.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *FolderLoader) LoadThunk(key models.FolderID) func() (*models.Folder, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*models.Folder, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &folderLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*models.Folder, error) {\n\t\t<-batch.done\n\n\t\tvar data *models.Folder\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *FolderLoader) LoadAll(keys []models.FolderID) ([]*models.Folder, []error) {\n\tresults := make([]func() (*models.Folder, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tfolders := make([]*models.Folder, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tfolders[i], errors[i] = thunk()\n\t}\n\treturn folders, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Folders.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *FolderLoader) LoadAllThunk(keys []models.FolderID) func() ([]*models.Folder, []error) {\n\tresults := make([]func() (*models.Folder, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*models.Folder, []error) {\n\t\tfolders := make([]*models.Folder, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tfolders[i], errors[i] = thunk()\n\t\t}\n\t\treturn folders, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *FolderLoader) Prime(key models.FolderID, value *models.Folder) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *FolderLoader) Clear(key models.FolderID) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *FolderLoader) unsafeSet(key models.FolderID, value *models.Folder) {\n\tif l.cache == nil {\n\t\tl.cache = map[models.FolderID]*models.Folder{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *folderLoaderBatch) keyIndex(l *FolderLoader, key models.FolderID) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *folderLoaderBatch) startTimer(l *FolderLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *folderLoaderBatch) end(l *FolderLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/folderparentfolderidsloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader\ntype FolderParentFolderIDsLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []models.FolderID) ([][]models.FolderID, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch\nfunc NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderParentFolderIDsLoader {\n\treturn &FolderParentFolderIDsLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// FolderParentFolderIDsLoader batches and caches requests\ntype FolderParentFolderIDsLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []models.FolderID) ([][]models.FolderID, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[models.FolderID][]models.FolderID\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *folderParentFolderIDsLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype folderParentFolderIDsLoaderBatch struct {\n\tkeys    []models.FolderID\n\tdata    [][]models.FolderID\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a FolderID by key, batching and caching will be applied automatically\nfunc (l *FolderParentFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a FolderID.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *FolderParentFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() ([]models.FolderID, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() ([]models.FolderID, error) {\n\t\t<-batch.done\n\n\t\tvar data []models.FolderID\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *FolderParentFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) {\n\tresults := make([]func() ([]models.FolderID, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tfolderIDs := make([][]models.FolderID, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tfolderIDs[i], errors[i] = thunk()\n\t}\n\treturn folderIDs, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a FolderIDs.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *FolderParentFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) {\n\tresults := make([]func() ([]models.FolderID, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([][]models.FolderID, []error) {\n\t\tfolderIDs := make([][]models.FolderID, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tfolderIDs[i], errors[i] = thunk()\n\t\t}\n\t\treturn folderIDs, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *FolderParentFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := make([]models.FolderID, len(value))\n\t\tcopy(cpy, value)\n\t\tl.unsafeSet(key, cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *FolderParentFolderIDsLoader) Clear(key models.FolderID) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *FolderParentFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) {\n\tif l.cache == nil {\n\t\tl.cache = map[models.FolderID][]models.FolderID{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderParentFolderIDsLoader, key models.FolderID) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderParentFolderIDsLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *folderParentFolderIDsLoaderBatch) end(l *FolderParentFolderIDsLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/galleryfileidsloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// GalleryFileIDsLoaderConfig captures the config to create a new GalleryFileIDsLoader\ntype GalleryFileIDsLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([][]models.FileID, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewGalleryFileIDsLoader creates a new GalleryFileIDsLoader given a fetch, wait, and maxBatch\nfunc NewGalleryFileIDsLoader(config GalleryFileIDsLoaderConfig) *GalleryFileIDsLoader {\n\treturn &GalleryFileIDsLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// GalleryFileIDsLoader batches and caches requests\ntype GalleryFileIDsLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([][]models.FileID, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int][]models.FileID\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *galleryFileIDsLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype galleryFileIDsLoaderBatch struct {\n\tkeys    []int\n\tdata    [][]models.FileID\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a FileID by key, batching and caching will be applied automatically\nfunc (l *GalleryFileIDsLoader) Load(key int) ([]models.FileID, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a FileID.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *GalleryFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() ([]models.FileID, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &galleryFileIDsLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() ([]models.FileID, error) {\n\t\t<-batch.done\n\n\t\tvar data []models.FileID\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *GalleryFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {\n\tresults := make([]func() ([]models.FileID, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tfileIDs := make([][]models.FileID, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tfileIDs[i], errors[i] = thunk()\n\t}\n\treturn fileIDs, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a FileIDs.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *GalleryFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {\n\tresults := make([]func() ([]models.FileID, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([][]models.FileID, []error) {\n\t\tfileIDs := make([][]models.FileID, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tfileIDs[i], errors[i] = thunk()\n\t\t}\n\t\treturn fileIDs, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *GalleryFileIDsLoader) Prime(key int, value []models.FileID) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := make([]models.FileID, len(value))\n\t\tcopy(cpy, value)\n\t\tl.unsafeSet(key, cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *GalleryFileIDsLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *GalleryFileIDsLoader) unsafeSet(key int, value []models.FileID) {\n\tif l.cache == nil {\n\t\tl.cache = map[int][]models.FileID{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *galleryFileIDsLoaderBatch) keyIndex(l *GalleryFileIDsLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *galleryFileIDsLoaderBatch) startTimer(l *GalleryFileIDsLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *galleryFileIDsLoaderBatch) end(l *GalleryFileIDsLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/galleryloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// GalleryLoaderConfig captures the config to create a new GalleryLoader\ntype GalleryLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]*models.Gallery, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewGalleryLoader creates a new GalleryLoader given a fetch, wait, and maxBatch\nfunc NewGalleryLoader(config GalleryLoaderConfig) *GalleryLoader {\n\treturn &GalleryLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// GalleryLoader batches and caches requests\ntype GalleryLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]*models.Gallery, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]*models.Gallery\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *galleryLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype galleryLoaderBatch struct {\n\tkeys    []int\n\tdata    []*models.Gallery\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Gallery by key, batching and caching will be applied automatically\nfunc (l *GalleryLoader) Load(key int) (*models.Gallery, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Gallery.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *GalleryLoader) LoadThunk(key int) func() (*models.Gallery, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*models.Gallery, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &galleryLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*models.Gallery, error) {\n\t\t<-batch.done\n\n\t\tvar data *models.Gallery\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *GalleryLoader) LoadAll(keys []int) ([]*models.Gallery, []error) {\n\tresults := make([]func() (*models.Gallery, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tgallerys := make([]*models.Gallery, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tgallerys[i], errors[i] = thunk()\n\t}\n\treturn gallerys, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Gallerys.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *GalleryLoader) LoadAllThunk(keys []int) func() ([]*models.Gallery, []error) {\n\tresults := make([]func() (*models.Gallery, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*models.Gallery, []error) {\n\t\tgallerys := make([]*models.Gallery, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tgallerys[i], errors[i] = thunk()\n\t\t}\n\t\treturn gallerys, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *GalleryLoader) Prime(key int, value *models.Gallery) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *GalleryLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *GalleryLoader) unsafeSet(key int, value *models.Gallery) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]*models.Gallery{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *galleryLoaderBatch) keyIndex(l *GalleryLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *galleryLoaderBatch) startTimer(l *GalleryLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *galleryLoaderBatch) end(l *GalleryLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/grouploader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// GroupLoaderConfig captures the config to create a new GroupLoader\ntype GroupLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]*models.Group, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewGroupLoader creates a new GroupLoader given a fetch, wait, and maxBatch\nfunc NewGroupLoader(config GroupLoaderConfig) *GroupLoader {\n\treturn &GroupLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// GroupLoader batches and caches requests\ntype GroupLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]*models.Group, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]*models.Group\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *groupLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype groupLoaderBatch struct {\n\tkeys    []int\n\tdata    []*models.Group\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Group by key, batching and caching will be applied automatically\nfunc (l *GroupLoader) Load(key int) (*models.Group, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Group.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *GroupLoader) LoadThunk(key int) func() (*models.Group, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*models.Group, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &groupLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*models.Group, error) {\n\t\t<-batch.done\n\n\t\tvar data *models.Group\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *GroupLoader) LoadAll(keys []int) ([]*models.Group, []error) {\n\tresults := make([]func() (*models.Group, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tgroups := make([]*models.Group, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tgroups[i], errors[i] = thunk()\n\t}\n\treturn groups, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Groups.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *GroupLoader) LoadAllThunk(keys []int) func() ([]*models.Group, []error) {\n\tresults := make([]func() (*models.Group, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*models.Group, []error) {\n\t\tgroups := make([]*models.Group, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tgroups[i], errors[i] = thunk()\n\t\t}\n\t\treturn groups, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *GroupLoader) Prime(key int, value *models.Group) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *GroupLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *GroupLoader) unsafeSet(key int, value *models.Group) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]*models.Group{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *groupLoaderBatch) keyIndex(l *GroupLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *groupLoaderBatch) startTimer(l *GroupLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *groupLoaderBatch) end(l *GroupLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/imagefileidsloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// ImageFileIDsLoaderConfig captures the config to create a new ImageFileIDsLoader\ntype ImageFileIDsLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([][]models.FileID, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewImageFileIDsLoader creates a new ImageFileIDsLoader given a fetch, wait, and maxBatch\nfunc NewImageFileIDsLoader(config ImageFileIDsLoaderConfig) *ImageFileIDsLoader {\n\treturn &ImageFileIDsLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// ImageFileIDsLoader batches and caches requests\ntype ImageFileIDsLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([][]models.FileID, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int][]models.FileID\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *imageFileIDsLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype imageFileIDsLoaderBatch struct {\n\tkeys    []int\n\tdata    [][]models.FileID\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a FileID by key, batching and caching will be applied automatically\nfunc (l *ImageFileIDsLoader) Load(key int) ([]models.FileID, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a FileID.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *ImageFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() ([]models.FileID, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &imageFileIDsLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() ([]models.FileID, error) {\n\t\t<-batch.done\n\n\t\tvar data []models.FileID\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *ImageFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {\n\tresults := make([]func() ([]models.FileID, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tfileIDs := make([][]models.FileID, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tfileIDs[i], errors[i] = thunk()\n\t}\n\treturn fileIDs, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a FileIDs.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *ImageFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {\n\tresults := make([]func() ([]models.FileID, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([][]models.FileID, []error) {\n\t\tfileIDs := make([][]models.FileID, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tfileIDs[i], errors[i] = thunk()\n\t\t}\n\t\treturn fileIDs, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *ImageFileIDsLoader) Prime(key int, value []models.FileID) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := make([]models.FileID, len(value))\n\t\tcopy(cpy, value)\n\t\tl.unsafeSet(key, cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *ImageFileIDsLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *ImageFileIDsLoader) unsafeSet(key int, value []models.FileID) {\n\tif l.cache == nil {\n\t\tl.cache = map[int][]models.FileID{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *imageFileIDsLoaderBatch) keyIndex(l *ImageFileIDsLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *imageFileIDsLoaderBatch) startTimer(l *ImageFileIDsLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *imageFileIDsLoaderBatch) end(l *ImageFileIDsLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/imageloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// ImageLoaderConfig captures the config to create a new ImageLoader\ntype ImageLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]*models.Image, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewImageLoader creates a new ImageLoader given a fetch, wait, and maxBatch\nfunc NewImageLoader(config ImageLoaderConfig) *ImageLoader {\n\treturn &ImageLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// ImageLoader batches and caches requests\ntype ImageLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]*models.Image, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]*models.Image\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *imageLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype imageLoaderBatch struct {\n\tkeys    []int\n\tdata    []*models.Image\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Image by key, batching and caching will be applied automatically\nfunc (l *ImageLoader) Load(key int) (*models.Image, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Image.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *ImageLoader) LoadThunk(key int) func() (*models.Image, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*models.Image, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &imageLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*models.Image, error) {\n\t\t<-batch.done\n\n\t\tvar data *models.Image\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *ImageLoader) LoadAll(keys []int) ([]*models.Image, []error) {\n\tresults := make([]func() (*models.Image, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\timages := make([]*models.Image, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\timages[i], errors[i] = thunk()\n\t}\n\treturn images, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Images.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *ImageLoader) LoadAllThunk(keys []int) func() ([]*models.Image, []error) {\n\tresults := make([]func() (*models.Image, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*models.Image, []error) {\n\t\timages := make([]*models.Image, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\timages[i], errors[i] = thunk()\n\t\t}\n\t\treturn images, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *ImageLoader) Prime(key int, value *models.Image) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *ImageLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *ImageLoader) unsafeSet(key int, value *models.Image) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]*models.Image{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *imageLoaderBatch) keyIndex(l *ImageLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *imageLoaderBatch) startTimer(l *ImageLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *imageLoaderBatch) end(l *ImageLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/performerloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// PerformerLoaderConfig captures the config to create a new PerformerLoader\ntype PerformerLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]*models.Performer, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewPerformerLoader creates a new PerformerLoader given a fetch, wait, and maxBatch\nfunc NewPerformerLoader(config PerformerLoaderConfig) *PerformerLoader {\n\treturn &PerformerLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// PerformerLoader batches and caches requests\ntype PerformerLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]*models.Performer, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]*models.Performer\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *performerLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype performerLoaderBatch struct {\n\tkeys    []int\n\tdata    []*models.Performer\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Performer by key, batching and caching will be applied automatically\nfunc (l *PerformerLoader) Load(key int) (*models.Performer, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Performer.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *PerformerLoader) LoadThunk(key int) func() (*models.Performer, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*models.Performer, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &performerLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*models.Performer, error) {\n\t\t<-batch.done\n\n\t\tvar data *models.Performer\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *PerformerLoader) LoadAll(keys []int) ([]*models.Performer, []error) {\n\tresults := make([]func() (*models.Performer, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tperformers := make([]*models.Performer, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tperformers[i], errors[i] = thunk()\n\t}\n\treturn performers, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Performers.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *PerformerLoader) LoadAllThunk(keys []int) func() ([]*models.Performer, []error) {\n\tresults := make([]func() (*models.Performer, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*models.Performer, []error) {\n\t\tperformers := make([]*models.Performer, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tperformers[i], errors[i] = thunk()\n\t\t}\n\t\treturn performers, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *PerformerLoader) Prime(key int, value *models.Performer) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *PerformerLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *PerformerLoader) unsafeSet(key int, value *models.Performer) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]*models.Performer{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *performerLoaderBatch) keyIndex(l *PerformerLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *performerLoaderBatch) startTimer(l *PerformerLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *performerLoaderBatch) end(l *PerformerLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/scenefileidsloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// SceneFileIDsLoaderConfig captures the config to create a new SceneFileIDsLoader\ntype SceneFileIDsLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([][]models.FileID, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewSceneFileIDsLoader creates a new SceneFileIDsLoader given a fetch, wait, and maxBatch\nfunc NewSceneFileIDsLoader(config SceneFileIDsLoaderConfig) *SceneFileIDsLoader {\n\treturn &SceneFileIDsLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// SceneFileIDsLoader batches and caches requests\ntype SceneFileIDsLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([][]models.FileID, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int][]models.FileID\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *sceneFileIDsLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype sceneFileIDsLoaderBatch struct {\n\tkeys    []int\n\tdata    [][]models.FileID\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a FileID by key, batching and caching will be applied automatically\nfunc (l *SceneFileIDsLoader) Load(key int) ([]models.FileID, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a FileID.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() ([]models.FileID, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &sceneFileIDsLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() ([]models.FileID, error) {\n\t\t<-batch.done\n\n\t\tvar data []models.FileID\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *SceneFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {\n\tresults := make([]func() ([]models.FileID, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tfileIDs := make([][]models.FileID, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tfileIDs[i], errors[i] = thunk()\n\t}\n\treturn fileIDs, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a FileIDs.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {\n\tresults := make([]func() ([]models.FileID, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([][]models.FileID, []error) {\n\t\tfileIDs := make([][]models.FileID, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tfileIDs[i], errors[i] = thunk()\n\t\t}\n\t\treturn fileIDs, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *SceneFileIDsLoader) Prime(key int, value []models.FileID) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := make([]models.FileID, len(value))\n\t\tcopy(cpy, value)\n\t\tl.unsafeSet(key, cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *SceneFileIDsLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *SceneFileIDsLoader) unsafeSet(key int, value []models.FileID) {\n\tif l.cache == nil {\n\t\tl.cache = map[int][]models.FileID{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *sceneFileIDsLoaderBatch) keyIndex(l *SceneFileIDsLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *sceneFileIDsLoaderBatch) startTimer(l *SceneFileIDsLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *sceneFileIDsLoaderBatch) end(l *SceneFileIDsLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/scenelastplayedloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\n// SceneLastPlayedLoaderConfig captures the config to create a new SceneLastPlayedLoader\ntype SceneLastPlayedLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]*time.Time, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewSceneLastPlayedLoader creates a new SceneLastPlayedLoader given a fetch, wait, and maxBatch\nfunc NewSceneLastPlayedLoader(config SceneLastPlayedLoaderConfig) *SceneLastPlayedLoader {\n\treturn &SceneLastPlayedLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// SceneLastPlayedLoader batches and caches requests\ntype SceneLastPlayedLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]*time.Time, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]*time.Time\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *sceneLastPlayedLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype sceneLastPlayedLoaderBatch struct {\n\tkeys    []int\n\tdata    []*time.Time\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Time by key, batching and caching will be applied automatically\nfunc (l *SceneLastPlayedLoader) Load(key int) (*time.Time, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Time.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneLastPlayedLoader) LoadThunk(key int) func() (*time.Time, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*time.Time, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &sceneLastPlayedLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*time.Time, error) {\n\t\t<-batch.done\n\n\t\tvar data *time.Time\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *SceneLastPlayedLoader) LoadAll(keys []int) ([]*time.Time, []error) {\n\tresults := make([]func() (*time.Time, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\ttimes := make([]*time.Time, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\ttimes[i], errors[i] = thunk()\n\t}\n\treturn times, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Times.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneLastPlayedLoader) LoadAllThunk(keys []int) func() ([]*time.Time, []error) {\n\tresults := make([]func() (*time.Time, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*time.Time, []error) {\n\t\ttimes := make([]*time.Time, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\ttimes[i], errors[i] = thunk()\n\t\t}\n\t\treturn times, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *SceneLastPlayedLoader) Prime(key int, value *time.Time) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *SceneLastPlayedLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *SceneLastPlayedLoader) unsafeSet(key int, value *time.Time) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]*time.Time{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *sceneLastPlayedLoaderBatch) keyIndex(l *SceneLastPlayedLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *sceneLastPlayedLoaderBatch) startTimer(l *SceneLastPlayedLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *sceneLastPlayedLoaderBatch) end(l *SceneLastPlayedLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/sceneloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// SceneLoaderConfig captures the config to create a new SceneLoader\ntype SceneLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]*models.Scene, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewSceneLoader creates a new SceneLoader given a fetch, wait, and maxBatch\nfunc NewSceneLoader(config SceneLoaderConfig) *SceneLoader {\n\treturn &SceneLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// SceneLoader batches and caches requests\ntype SceneLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]*models.Scene, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]*models.Scene\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *sceneLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype sceneLoaderBatch struct {\n\tkeys    []int\n\tdata    []*models.Scene\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Scene by key, batching and caching will be applied automatically\nfunc (l *SceneLoader) Load(key int) (*models.Scene, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Scene.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneLoader) LoadThunk(key int) func() (*models.Scene, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*models.Scene, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &sceneLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*models.Scene, error) {\n\t\t<-batch.done\n\n\t\tvar data *models.Scene\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *SceneLoader) LoadAll(keys []int) ([]*models.Scene, []error) {\n\tresults := make([]func() (*models.Scene, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tscenes := make([]*models.Scene, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tscenes[i], errors[i] = thunk()\n\t}\n\treturn scenes, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Scenes.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneLoader) LoadAllThunk(keys []int) func() ([]*models.Scene, []error) {\n\tresults := make([]func() (*models.Scene, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*models.Scene, []error) {\n\t\tscenes := make([]*models.Scene, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tscenes[i], errors[i] = thunk()\n\t\t}\n\t\treturn scenes, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *SceneLoader) Prime(key int, value *models.Scene) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *SceneLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *SceneLoader) unsafeSet(key int, value *models.Scene) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]*models.Scene{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *sceneLoaderBatch) keyIndex(l *SceneLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *sceneLoaderBatch) startTimer(l *SceneLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *sceneLoaderBatch) end(l *SceneLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/sceneocountloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\n// SceneOCountLoaderConfig captures the config to create a new SceneOCountLoader\ntype SceneOCountLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]int, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewSceneOCountLoader creates a new SceneOCountLoader given a fetch, wait, and maxBatch\nfunc NewSceneOCountLoader(config SceneOCountLoaderConfig) *SceneOCountLoader {\n\treturn &SceneOCountLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// SceneOCountLoader batches and caches requests\ntype SceneOCountLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]int, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]int\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *sceneOCountLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype sceneOCountLoaderBatch struct {\n\tkeys    []int\n\tdata    []int\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a int by key, batching and caching will be applied automatically\nfunc (l *SceneOCountLoader) Load(key int) (int, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a int.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneOCountLoader) LoadThunk(key int) func() (int, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (int, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &sceneOCountLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (int, error) {\n\t\t<-batch.done\n\n\t\tvar data int\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *SceneOCountLoader) LoadAll(keys []int) ([]int, []error) {\n\tresults := make([]func() (int, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tints := make([]int, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tints[i], errors[i] = thunk()\n\t}\n\treturn ints, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a ints.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneOCountLoader) LoadAllThunk(keys []int) func() ([]int, []error) {\n\tresults := make([]func() (int, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]int, []error) {\n\t\tints := make([]int, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tints[i], errors[i] = thunk()\n\t\t}\n\t\treturn ints, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *SceneOCountLoader) Prime(key int, value int) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\tl.unsafeSet(key, value)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *SceneOCountLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *SceneOCountLoader) unsafeSet(key int, value int) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]int{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *sceneOCountLoaderBatch) keyIndex(l *SceneOCountLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *sceneOCountLoaderBatch) startTimer(l *SceneOCountLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *sceneOCountLoaderBatch) end(l *SceneOCountLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/sceneohistoryloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\n// SceneOHistoryLoaderConfig captures the config to create a new SceneOHistoryLoader\ntype SceneOHistoryLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([][]time.Time, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewSceneOHistoryLoader creates a new SceneOHistoryLoader given a fetch, wait, and maxBatch\nfunc NewSceneOHistoryLoader(config SceneOHistoryLoaderConfig) *SceneOHistoryLoader {\n\treturn &SceneOHistoryLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// SceneOHistoryLoader batches and caches requests\ntype SceneOHistoryLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([][]time.Time, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int][]time.Time\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *sceneOHistoryLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype sceneOHistoryLoaderBatch struct {\n\tkeys    []int\n\tdata    [][]time.Time\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Time by key, batching and caching will be applied automatically\nfunc (l *SceneOHistoryLoader) Load(key int) ([]time.Time, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Time.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneOHistoryLoader) LoadThunk(key int) func() ([]time.Time, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() ([]time.Time, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &sceneOHistoryLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() ([]time.Time, error) {\n\t\t<-batch.done\n\n\t\tvar data []time.Time\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *SceneOHistoryLoader) LoadAll(keys []int) ([][]time.Time, []error) {\n\tresults := make([]func() ([]time.Time, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\ttimes := make([][]time.Time, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\ttimes[i], errors[i] = thunk()\n\t}\n\treturn times, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Times.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *SceneOHistoryLoader) LoadAllThunk(keys []int) func() ([][]time.Time, []error) {\n\tresults := make([]func() ([]time.Time, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([][]time.Time, []error) {\n\t\ttimes := make([][]time.Time, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\ttimes[i], errors[i] = thunk()\n\t\t}\n\t\treturn times, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *SceneOHistoryLoader) Prime(key int, value []time.Time) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := make([]time.Time, len(value))\n\t\tcopy(cpy, value)\n\t\tl.unsafeSet(key, cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *SceneOHistoryLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *SceneOHistoryLoader) unsafeSet(key int, value []time.Time) {\n\tif l.cache == nil {\n\t\tl.cache = map[int][]time.Time{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *sceneOHistoryLoaderBatch) keyIndex(l *SceneOHistoryLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *sceneOHistoryLoaderBatch) startTimer(l *SceneOHistoryLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *sceneOHistoryLoaderBatch) end(l *SceneOHistoryLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/sceneplaycountloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\n// ScenePlayCountLoaderConfig captures the config to create a new ScenePlayCountLoader\ntype ScenePlayCountLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]int, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewScenePlayCountLoader creates a new ScenePlayCountLoader given a fetch, wait, and maxBatch\nfunc NewScenePlayCountLoader(config ScenePlayCountLoaderConfig) *ScenePlayCountLoader {\n\treturn &ScenePlayCountLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// ScenePlayCountLoader batches and caches requests\ntype ScenePlayCountLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]int, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]int\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *scenePlayCountLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype scenePlayCountLoaderBatch struct {\n\tkeys    []int\n\tdata    []int\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a int by key, batching and caching will be applied automatically\nfunc (l *ScenePlayCountLoader) Load(key int) (int, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a int.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *ScenePlayCountLoader) LoadThunk(key int) func() (int, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (int, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &scenePlayCountLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (int, error) {\n\t\t<-batch.done\n\n\t\tvar data int\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *ScenePlayCountLoader) LoadAll(keys []int) ([]int, []error) {\n\tresults := make([]func() (int, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tints := make([]int, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tints[i], errors[i] = thunk()\n\t}\n\treturn ints, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a ints.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *ScenePlayCountLoader) LoadAllThunk(keys []int) func() ([]int, []error) {\n\tresults := make([]func() (int, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]int, []error) {\n\t\tints := make([]int, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tints[i], errors[i] = thunk()\n\t\t}\n\t\treturn ints, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *ScenePlayCountLoader) Prime(key int, value int) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\tl.unsafeSet(key, value)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *ScenePlayCountLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *ScenePlayCountLoader) unsafeSet(key int, value int) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]int{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *scenePlayCountLoaderBatch) keyIndex(l *ScenePlayCountLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *scenePlayCountLoaderBatch) startTimer(l *ScenePlayCountLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *scenePlayCountLoaderBatch) end(l *ScenePlayCountLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/sceneplayhistoryloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\n// ScenePlayHistoryLoaderConfig captures the config to create a new ScenePlayHistoryLoader\ntype ScenePlayHistoryLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([][]time.Time, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewScenePlayHistoryLoader creates a new ScenePlayHistoryLoader given a fetch, wait, and maxBatch\nfunc NewScenePlayHistoryLoader(config ScenePlayHistoryLoaderConfig) *ScenePlayHistoryLoader {\n\treturn &ScenePlayHistoryLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// ScenePlayHistoryLoader batches and caches requests\ntype ScenePlayHistoryLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([][]time.Time, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int][]time.Time\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *scenePlayHistoryLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype scenePlayHistoryLoaderBatch struct {\n\tkeys    []int\n\tdata    [][]time.Time\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Time by key, batching and caching will be applied automatically\nfunc (l *ScenePlayHistoryLoader) Load(key int) ([]time.Time, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Time.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *ScenePlayHistoryLoader) LoadThunk(key int) func() ([]time.Time, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() ([]time.Time, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &scenePlayHistoryLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() ([]time.Time, error) {\n\t\t<-batch.done\n\n\t\tvar data []time.Time\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *ScenePlayHistoryLoader) LoadAll(keys []int) ([][]time.Time, []error) {\n\tresults := make([]func() ([]time.Time, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\ttimes := make([][]time.Time, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\ttimes[i], errors[i] = thunk()\n\t}\n\treturn times, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Times.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *ScenePlayHistoryLoader) LoadAllThunk(keys []int) func() ([][]time.Time, []error) {\n\tresults := make([]func() ([]time.Time, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([][]time.Time, []error) {\n\t\ttimes := make([][]time.Time, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\ttimes[i], errors[i] = thunk()\n\t\t}\n\t\treturn times, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *ScenePlayHistoryLoader) Prime(key int, value []time.Time) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := make([]time.Time, len(value))\n\t\tcopy(cpy, value)\n\t\tl.unsafeSet(key, cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *ScenePlayHistoryLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *ScenePlayHistoryLoader) unsafeSet(key int, value []time.Time) {\n\tif l.cache == nil {\n\t\tl.cache = map[int][]time.Time{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *scenePlayHistoryLoaderBatch) keyIndex(l *ScenePlayHistoryLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *scenePlayHistoryLoaderBatch) startTimer(l *ScenePlayHistoryLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *scenePlayHistoryLoaderBatch) end(l *ScenePlayHistoryLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/studioloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// StudioLoaderConfig captures the config to create a new StudioLoader\ntype StudioLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]*models.Studio, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewStudioLoader creates a new StudioLoader given a fetch, wait, and maxBatch\nfunc NewStudioLoader(config StudioLoaderConfig) *StudioLoader {\n\treturn &StudioLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// StudioLoader batches and caches requests\ntype StudioLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]*models.Studio, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]*models.Studio\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *studioLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype studioLoaderBatch struct {\n\tkeys    []int\n\tdata    []*models.Studio\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Studio by key, batching and caching will be applied automatically\nfunc (l *StudioLoader) Load(key int) (*models.Studio, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Studio.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *StudioLoader) LoadThunk(key int) func() (*models.Studio, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*models.Studio, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &studioLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*models.Studio, error) {\n\t\t<-batch.done\n\n\t\tvar data *models.Studio\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *StudioLoader) LoadAll(keys []int) ([]*models.Studio, []error) {\n\tresults := make([]func() (*models.Studio, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\tstudios := make([]*models.Studio, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\tstudios[i], errors[i] = thunk()\n\t}\n\treturn studios, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Studios.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *StudioLoader) LoadAllThunk(keys []int) func() ([]*models.Studio, []error) {\n\tresults := make([]func() (*models.Studio, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*models.Studio, []error) {\n\t\tstudios := make([]*models.Studio, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\tstudios[i], errors[i] = thunk()\n\t\t}\n\t\treturn studios, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *StudioLoader) Prime(key int, value *models.Studio) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *StudioLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *StudioLoader) unsafeSet(key int, value *models.Studio) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]*models.Studio{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *studioLoaderBatch) keyIndex(l *StudioLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *studioLoaderBatch) startTimer(l *StudioLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *studioLoaderBatch) end(l *StudioLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/loaders/tagloader_gen.go",
    "content": "// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.\n\npackage loaders\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// TagLoaderConfig captures the config to create a new TagLoader\ntype TagLoaderConfig struct {\n\t// Fetch is a method that provides the data for the loader\n\tFetch func(keys []int) ([]*models.Tag, []error)\n\n\t// Wait is how long wait before sending a batch\n\tWait time.Duration\n\n\t// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit\n\tMaxBatch int\n}\n\n// NewTagLoader creates a new TagLoader given a fetch, wait, and maxBatch\nfunc NewTagLoader(config TagLoaderConfig) *TagLoader {\n\treturn &TagLoader{\n\t\tfetch:    config.Fetch,\n\t\twait:     config.Wait,\n\t\tmaxBatch: config.MaxBatch,\n\t}\n}\n\n// TagLoader batches and caches requests\ntype TagLoader struct {\n\t// this method provides the data for the loader\n\tfetch func(keys []int) ([]*models.Tag, []error)\n\n\t// how long to done before sending a batch\n\twait time.Duration\n\n\t// this will limit the maximum number of keys to send in one batch, 0 = no limit\n\tmaxBatch int\n\n\t// INTERNAL\n\n\t// lazily created cache\n\tcache map[int]*models.Tag\n\n\t// the current batch. keys will continue to be collected until timeout is hit,\n\t// then everything will be sent to the fetch method and out to the listeners\n\tbatch *tagLoaderBatch\n\n\t// mutex to prevent races\n\tmu sync.Mutex\n}\n\ntype tagLoaderBatch struct {\n\tkeys    []int\n\tdata    []*models.Tag\n\terror   []error\n\tclosing bool\n\tdone    chan struct{}\n}\n\n// Load a Tag by key, batching and caching will be applied automatically\nfunc (l *TagLoader) Load(key int) (*models.Tag, error) {\n\treturn l.LoadThunk(key)()\n}\n\n// LoadThunk returns a function that when called will block waiting for a Tag.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *TagLoader) LoadThunk(key int) func() (*models.Tag, error) {\n\tl.mu.Lock()\n\tif it, ok := l.cache[key]; ok {\n\t\tl.mu.Unlock()\n\t\treturn func() (*models.Tag, error) {\n\t\t\treturn it, nil\n\t\t}\n\t}\n\tif l.batch == nil {\n\t\tl.batch = &tagLoaderBatch{done: make(chan struct{})}\n\t}\n\tbatch := l.batch\n\tpos := batch.keyIndex(l, key)\n\tl.mu.Unlock()\n\n\treturn func() (*models.Tag, error) {\n\t\t<-batch.done\n\n\t\tvar data *models.Tag\n\t\tif pos < len(batch.data) {\n\t\t\tdata = batch.data[pos]\n\t\t}\n\n\t\tvar err error\n\t\t// its convenient to be able to return a single error for everything\n\t\tif len(batch.error) == 1 {\n\t\t\terr = batch.error[0]\n\t\t} else if batch.error != nil {\n\t\t\terr = batch.error[pos]\n\t\t}\n\n\t\tif err == nil {\n\t\t\tl.mu.Lock()\n\t\t\tl.unsafeSet(key, data)\n\t\t\tl.mu.Unlock()\n\t\t}\n\n\t\treturn data, err\n\t}\n}\n\n// LoadAll fetches many keys at once. It will be broken into appropriate sized\n// sub batches depending on how the loader is configured\nfunc (l *TagLoader) LoadAll(keys []int) ([]*models.Tag, []error) {\n\tresults := make([]func() (*models.Tag, error), len(keys))\n\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\n\ttags := make([]*models.Tag, len(keys))\n\terrors := make([]error, len(keys))\n\tfor i, thunk := range results {\n\t\ttags[i], errors[i] = thunk()\n\t}\n\treturn tags, errors\n}\n\n// LoadAllThunk returns a function that when called will block waiting for a Tags.\n// This method should be used if you want one goroutine to make requests to many\n// different data loaders without blocking until the thunk is called.\nfunc (l *TagLoader) LoadAllThunk(keys []int) func() ([]*models.Tag, []error) {\n\tresults := make([]func() (*models.Tag, error), len(keys))\n\tfor i, key := range keys {\n\t\tresults[i] = l.LoadThunk(key)\n\t}\n\treturn func() ([]*models.Tag, []error) {\n\t\ttags := make([]*models.Tag, len(keys))\n\t\terrors := make([]error, len(keys))\n\t\tfor i, thunk := range results {\n\t\t\ttags[i], errors[i] = thunk()\n\t\t}\n\t\treturn tags, errors\n\t}\n}\n\n// Prime the cache with the provided key and value. If the key already exists, no change is made\n// and false is returned.\n// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)\nfunc (l *TagLoader) Prime(key int, value *models.Tag) bool {\n\tl.mu.Lock()\n\tvar found bool\n\tif _, found = l.cache[key]; !found {\n\t\t// make a copy when writing to the cache, its easy to pass a pointer in from a loop var\n\t\t// and end up with the whole cache pointing to the same value.\n\t\tcpy := *value\n\t\tl.unsafeSet(key, &cpy)\n\t}\n\tl.mu.Unlock()\n\treturn !found\n}\n\n// Clear the value at key from the cache, if it exists\nfunc (l *TagLoader) Clear(key int) {\n\tl.mu.Lock()\n\tdelete(l.cache, key)\n\tl.mu.Unlock()\n}\n\nfunc (l *TagLoader) unsafeSet(key int, value *models.Tag) {\n\tif l.cache == nil {\n\t\tl.cache = map[int]*models.Tag{}\n\t}\n\tl.cache[key] = value\n}\n\n// keyIndex will return the location of the key in the batch, if its not found\n// it will add the key to the batch\nfunc (b *tagLoaderBatch) keyIndex(l *TagLoader, key int) int {\n\tfor i, existingKey := range b.keys {\n\t\tif key == existingKey {\n\t\t\treturn i\n\t\t}\n\t}\n\n\tpos := len(b.keys)\n\tb.keys = append(b.keys, key)\n\tif pos == 0 {\n\t\tgo b.startTimer(l)\n\t}\n\n\tif l.maxBatch != 0 && pos >= l.maxBatch-1 {\n\t\tif !b.closing {\n\t\t\tb.closing = true\n\t\t\tl.batch = nil\n\t\t\tgo b.end(l)\n\t\t}\n\t}\n\n\treturn pos\n}\n\nfunc (b *tagLoaderBatch) startTimer(l *TagLoader) {\n\ttime.Sleep(l.wait)\n\tl.mu.Lock()\n\n\t// we must have hit a batch limit and are already finalizing this batch\n\tif b.closing {\n\t\tl.mu.Unlock()\n\t\treturn\n\t}\n\n\tl.batch = nil\n\tl.mu.Unlock()\n\n\tb.end(l)\n}\n\nfunc (b *tagLoaderBatch) end(l *TagLoader) {\n\tb.data, b.error = l.fetch(b.keys)\n\tclose(b.done)\n}\n"
  },
  {
    "path": "internal/api/locale.go",
    "content": "package api\n\nimport (\n\t\"golang.org/x/text/collate\"\n\t\"golang.org/x/text/language\"\n)\n\n// matcher defines a matcher for the languages we support\nvar matcher = language.NewMatcher([]language.Tag{\n\tlanguage.MustParse(\"en-US\"), // The first language is used as fallback.\n\tlanguage.MustParse(\"en-GB\"),\n\tlanguage.MustParse(\"en-AU\"),\n\tlanguage.MustParse(\"es-ES\"),\n\tlanguage.MustParse(\"de-DE\"),\n\tlanguage.MustParse(\"it-IT\"),\n\tlanguage.MustParse(\"fr-FR\"),\n\tlanguage.MustParse(\"fi-FI\"),\n\tlanguage.MustParse(\"pt-BR\"),\n\tlanguage.MustParse(\"sv-SE\"),\n\tlanguage.MustParse(\"zh-CN\"),\n\tlanguage.MustParse(\"zh-TW\"),\n\tlanguage.MustParse(\"hr-HR\"),\n\tlanguage.MustParse(\"nl-NL\"),\n\tlanguage.MustParse(\"ru-RU\"),\n\tlanguage.MustParse(\"tr-TR\"),\n\tlanguage.MustParse(\"da-DK\"),\n\tlanguage.MustParse(\"pl-PL\"),\n\tlanguage.MustParse(\"ko-KR\"),\n\tlanguage.MustParse(\"cs-CZ\"),\n\tlanguage.MustParse(\"bn-BD\"),\n\tlanguage.MustParse(\"et-EE\"),\n\tlanguage.MustParse(\"fa-IR\"),\n\tlanguage.MustParse(\"hu-HU\"),\n\tlanguage.MustParse(\"ro-RO\"),\n\tlanguage.MustParse(\"th-TH\"),\n\tlanguage.MustParse(\"uk-UA\"),\n})\n\n// newCollator parses a locale into a collator\n// Go through the available matches and return a valid match, in practice the first is a fallback\n// Optionally pass collation options through for creation.\n// If passed a nil-locale string, return nil\nfunc newCollator(locale *string, opts ...collate.Option) *collate.Collator {\n\tif locale == nil {\n\t\treturn nil\n\t}\n\n\ttag, _ := language.MatchStrings(matcher, *locale)\n\treturn collate.New(tag, opts...)\n}\n"
  },
  {
    "path": "internal/api/models.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\ntype BaseFile interface {\n\tIsBaseFile()\n}\n\ntype VisualFile interface {\n\tIsVisualFile()\n}\n\nfunc convertVisualFile(f models.File) (VisualFile, error) {\n\tswitch f := f.(type) {\n\tcase VisualFile:\n\t\treturn f, nil\n\tcase *models.VideoFile:\n\t\treturn &VideoFile{VideoFile: f}, nil\n\tcase *models.ImageFile:\n\t\treturn &ImageFile{ImageFile: f}, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"file %s is not a visual file\", f.Base().Path)\n\t}\n}\n\nfunc convertBaseFile(f models.File) BaseFile {\n\tif f == nil {\n\t\treturn nil\n\t}\n\n\tswitch f := f.(type) {\n\tcase BaseFile:\n\t\treturn f\n\tcase *models.VideoFile:\n\t\treturn &VideoFile{VideoFile: f}\n\tcase *models.ImageFile:\n\t\treturn &ImageFile{ImageFile: f}\n\tcase *models.BaseFile:\n\t\treturn &BasicFile{BaseFile: f}\n\tdefault:\n\t\tpanic(\"unknown file type\")\n\t}\n}\n\nfunc convertBaseFiles(files []models.File) []BaseFile {\n\treturn sliceutil.Map(files, convertBaseFile)\n}\n\ntype GalleryFile struct {\n\t*models.BaseFile\n}\n\nfunc (GalleryFile) IsBaseFile() {}\n\nfunc (GalleryFile) IsVisualFile() {}\n\nfunc (f *GalleryFile) Fingerprints() []models.Fingerprint {\n\treturn f.BaseFile.Fingerprints\n}\n\ntype VideoFile struct {\n\t*models.VideoFile\n}\n\nfunc (VideoFile) IsBaseFile() {}\n\nfunc (VideoFile) IsVisualFile() {}\n\nfunc (f *VideoFile) Fingerprints() []models.Fingerprint {\n\treturn f.VideoFile.Fingerprints\n}\n\ntype ImageFile struct {\n\t*models.ImageFile\n}\n\nfunc (ImageFile) IsBaseFile() {}\n\nfunc (ImageFile) IsVisualFile() {}\n\nfunc (f *ImageFile) Fingerprints() []models.Fingerprint {\n\treturn f.ImageFile.Fingerprints\n}\n\ntype BasicFile struct {\n\t*models.BaseFile\n}\n\nfunc (BasicFile) IsBaseFile() {}\n\nfunc (BasicFile) IsVisualFile() {}\n\nfunc (f *BasicFile) Fingerprints() []models.Fingerprint {\n\treturn f.BaseFile.Fingerprints\n}\n"
  },
  {
    "path": "internal/api/plugin_map.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n)\n\nfunc MarshalPluginConfigMap(val map[string]map[string]interface{}) graphql.Marshaler {\n\treturn graphql.WriterFunc(func(w io.Writer) {\n\t\terr := json.NewEncoder(w).Encode(val)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t})\n}\n\nfunc UnmarshalPluginConfigMap(v interface{}) (map[string]map[string]interface{}, error) {\n\tm, ok := v.(map[string]interface{})\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"%T is not a plugin config map\", v)\n\t}\n\n\tresult := make(map[string]map[string]interface{})\n\tfor k, v := range m {\n\t\tval, ok := v.(map[string]interface{})\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"key %s (%T) is not a map\", k, v)\n\t\t}\n\n\t\tresult[k] = val\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/api/resolver.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/build\"\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n)\n\nvar (\n\t// ErrNotImplemented is an error which means the given functionality isn't implemented by the API.\n\tErrNotImplemented = errors.New(\"not implemented\")\n\n\t// ErrNotSupported is returned whenever there's a test, which can be used to guard against the error,\n\t// but the given parameters aren't supported by the system.\n\tErrNotSupported = errors.New(\"not supported\")\n\n\t// ErrInput signifies errors where the input isn't valid for some reason. And no more specific error exists.\n\tErrInput = errors.New(\"input error\")\n)\n\ntype hookExecutor interface {\n\tExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string)\n}\n\ntype Resolver struct {\n\trepository     models.Repository\n\tsceneService   manager.SceneService\n\timageService   manager.ImageService\n\tgalleryService manager.GalleryService\n\tgroupService   manager.GroupService\n\n\thookExecutor hookExecutor\n}\n\nfunc (r *Resolver) scraperCache() *scraper.Cache {\n\treturn manager.GetInstance().ScraperCache\n}\n\nfunc (r *Resolver) Gallery() GalleryResolver {\n\treturn &galleryResolver{r}\n}\nfunc (r *Resolver) GalleryChapter() GalleryChapterResolver {\n\treturn &galleryChapterResolver{r}\n}\nfunc (r *Resolver) Mutation() MutationResolver {\n\treturn &mutationResolver{r}\n}\nfunc (r *Resolver) Performer() PerformerResolver {\n\treturn &performerResolver{r}\n}\nfunc (r *Resolver) Query() QueryResolver {\n\treturn &queryResolver{r}\n}\nfunc (r *Resolver) Scene() SceneResolver {\n\treturn &sceneResolver{r}\n}\nfunc (r *Resolver) Image() ImageResolver {\n\treturn &imageResolver{r}\n}\nfunc (r *Resolver) SceneMarker() SceneMarkerResolver {\n\treturn &sceneMarkerResolver{r}\n}\nfunc (r *Resolver) Studio() StudioResolver {\n\treturn &studioResolver{r}\n}\n\nfunc (r *Resolver) Group() GroupResolver {\n\treturn &groupResolver{r}\n}\nfunc (r *Resolver) Movie() MovieResolver {\n\treturn &movieResolver{&groupResolver{r}}\n}\n\nfunc (r *Resolver) Subscription() SubscriptionResolver {\n\treturn &subscriptionResolver{r}\n}\nfunc (r *Resolver) Tag() TagResolver {\n\treturn &tagResolver{r}\n}\nfunc (r *Resolver) GalleryFile() GalleryFileResolver {\n\treturn &galleryFileResolver{r}\n}\nfunc (r *Resolver) VideoFile() VideoFileResolver {\n\treturn &videoFileResolver{r}\n}\nfunc (r *Resolver) ImageFile() ImageFileResolver {\n\treturn &imageFileResolver{r}\n}\nfunc (r *Resolver) BasicFile() BasicFileResolver {\n\treturn &basicFileResolver{r}\n}\nfunc (r *Resolver) Folder() FolderResolver {\n\treturn &folderResolver{r}\n}\nfunc (r *Resolver) SavedFilter() SavedFilterResolver {\n\treturn &savedFilterResolver{r}\n}\nfunc (r *Resolver) Plugin() PluginResolver {\n\treturn &pluginResolver{r}\n}\nfunc (r *Resolver) ConfigResult() ConfigResultResolver {\n\treturn &configResultResolver{r}\n}\n\ntype mutationResolver struct{ *Resolver }\ntype queryResolver struct{ *Resolver }\ntype subscriptionResolver struct{ *Resolver }\n\ntype galleryResolver struct{ *Resolver }\ntype galleryChapterResolver struct{ *Resolver }\ntype performerResolver struct{ *Resolver }\ntype sceneResolver struct{ *Resolver }\ntype sceneMarkerResolver struct{ *Resolver }\ntype imageResolver struct{ *Resolver }\ntype studioResolver struct{ *Resolver }\n\n// movie is group under the hood\ntype groupResolver struct{ *Resolver }\ntype movieResolver struct{ *groupResolver }\n\ntype tagResolver struct{ *Resolver }\ntype galleryFileResolver struct{ *Resolver }\ntype videoFileResolver struct{ *Resolver }\ntype imageFileResolver struct{ *Resolver }\ntype basicFileResolver struct{ *Resolver }\ntype folderResolver struct{ *Resolver }\ntype savedFilterResolver struct{ *Resolver }\ntype pluginResolver struct{ *Resolver }\ntype configResultResolver struct{ *Resolver }\n\nfunc (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {\n\treturn r.repository.WithTxn(ctx, fn)\n}\n\nfunc (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context) error) error {\n\treturn r.repository.WithReadTxn(ctx, fn)\n}\n\nfunc (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.SceneMarker.Wall(ctx, q)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) SceneWall(ctx context.Context, q *string) (ret []*models.Scene, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Scene.Wall(ctx, q)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) MarkerStrings(ctx context.Context, q *string, sort *string) (ret []*models.MarkerStringsResultType, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.SceneMarker.GetMarkerStrings(ctx, q, sort)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) {\n\tvar ret StatsResultType\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\trepo := r.repository\n\t\tsceneQB := repo.Scene\n\t\timageQB := repo.Image\n\t\tgalleryQB := repo.Gallery\n\t\tstudioQB := repo.Studio\n\t\tperformerQB := repo.Performer\n\t\tmovieQB := repo.Group\n\t\ttagQB := repo.Tag\n\n\t\t// embrace the error\n\n\t\tscenesCount, err := sceneQB.Count(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tscenesSize, err := sceneQB.Size(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tscenesDuration, err := sceneQB.Duration(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\timageCount, err := imageQB.Count(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\timageSize, err := imageQB.Size(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tgalleryCount, err := galleryQB.Count(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tperformersCount, err := performerQB.Count(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tstudiosCount, err := studioQB.Count(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tgroupsCount, err := movieQB.Count(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttagsCount, err := tagQB.Count(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tscenesTotalOCount, err := sceneQB.GetAllOCount(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\timagesTotalOCount, err := imageQB.OCount(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttotalOCount := scenesTotalOCount + imagesTotalOCount\n\n\t\ttotalPlayDuration, err := sceneQB.PlayDuration(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttotalPlayCount, err := sceneQB.CountAllViews(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tuniqueScenePlayCount, err := sceneQB.CountUniqueViews(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = StatsResultType{\n\t\t\tSceneCount:        scenesCount,\n\t\t\tScenesSize:        scenesSize,\n\t\t\tScenesDuration:    scenesDuration,\n\t\t\tImageCount:        imageCount,\n\t\t\tImagesSize:        imageSize,\n\t\t\tGalleryCount:      galleryCount,\n\t\t\tPerformerCount:    performersCount,\n\t\t\tStudioCount:       studiosCount,\n\t\t\tGroupCount:        groupsCount,\n\t\t\tMovieCount:        groupsCount,\n\t\t\tTagCount:          tagsCount,\n\t\t\tTotalOCount:       totalOCount,\n\t\t\tTotalPlayDuration: totalPlayDuration,\n\t\t\tTotalPlayCount:    totalPlayCount,\n\t\t\tScenesPlayed:      uniqueScenePlayCount,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &ret, nil\n}\n\nfunc (r *queryResolver) Version(ctx context.Context) (*Version, error) {\n\tversion, hash, buildtime := build.Version()\n\n\treturn &Version{\n\t\tVersion:   &version,\n\t\tHash:      hash,\n\t\tBuildTime: buildtime,\n\t}, nil\n}\n\nfunc (r *queryResolver) Latestversion(ctx context.Context) (*LatestVersion, error) {\n\tlatestRelease, err := GetLatestRelease(ctx)\n\tif err != nil {\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tlogger.Errorf(\"Error while retrieving latest version: %v\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\tlogger.Infof(\"Retrieved latest version: %s (%s)\", latestRelease.Version, latestRelease.ShortHash)\n\n\treturn &LatestVersion{\n\t\tVersion:     latestRelease.Version,\n\t\tShorthash:   latestRelease.ShortHash,\n\t\tReleaseDate: latestRelease.Date,\n\t\tURL:         latestRelease.Url,\n\t}, nil\n}\n\nfunc (r *mutationResolver) ExecSQL(ctx context.Context, sql string, args []interface{}) (*SQLExecResult, error) {\n\tvar rowsAffected *int64\n\tvar lastInsertID *int64\n\n\tdb := manager.GetInstance().Database\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\trowsAffected, lastInsertID, err = db.ExecSQL(ctx, sql, args)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &SQLExecResult{\n\t\tRowsAffected: rowsAffected,\n\t\tLastInsertID: lastInsertID,\n\t}, nil\n}\n\nfunc (r *mutationResolver) QuerySQL(ctx context.Context, sql string, args []interface{}) (*SQLQueryResult, error) {\n\tvar cols []string\n\tvar rows [][]interface{}\n\n\tdb := manager.GetInstance().Database\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\tcols, rows, err = db.QuerySQL(ctx, sql, args)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &SQLQueryResult{\n\t\tColumns: cols,\n\t\tRows:    rows,\n\t}, nil\n}\n\n// Get scene marker tags which show up under the video.\nfunc (r *queryResolver) SceneMarkerTags(ctx context.Context, scene_id string) ([]*SceneMarkerTag, error) {\n\tsceneID, err := strconv.Atoi(scene_id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar keys []int\n\ttags := make(map[int]*SceneMarkerTag)\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tsceneMarkers, err := r.repository.SceneMarker.FindBySceneID(ctx, sceneID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttqb := r.repository.Tag\n\t\tfor _, sceneMarker := range sceneMarkers {\n\t\t\tmarkerPrimaryTag, err := tqb.Find(ctx, sceneMarker.PrimaryTagID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif markerPrimaryTag == nil {\n\t\t\t\treturn fmt.Errorf(\"tag with id %d not found\", sceneMarker.PrimaryTagID)\n\t\t\t}\n\n\t\t\t_, hasKey := tags[markerPrimaryTag.ID]\n\t\t\tif !hasKey {\n\t\t\t\tsceneMarkerTag := &SceneMarkerTag{Tag: markerPrimaryTag}\n\t\t\t\ttags[markerPrimaryTag.ID] = sceneMarkerTag\n\t\t\t\tkeys = append(keys, markerPrimaryTag.ID)\n\t\t\t}\n\t\t\ttags[markerPrimaryTag.ID].SceneMarkers = append(tags[markerPrimaryTag.ID].SceneMarkers, sceneMarker)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Sort so that primary tags that show up earlier in the video are first.\n\tsort.Slice(keys, func(i, j int) bool {\n\t\ta := tags[keys[i]]\n\t\tb := tags[keys[j]]\n\t\treturn a.SceneMarkers[0].Seconds < b.SceneMarkers[0].Seconds\n\t})\n\n\tvar result []*SceneMarkerTag\n\tfor _, key := range keys {\n\t\tresult = append(result, tags[key])\n\t}\n\n\treturn result, nil\n}\n\nfunc firstError(errs []error) error {\n\tfor _, e := range errs {\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_config.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n)\n\nfunc (r *configResultResolver) Plugins(ctx context.Context, obj *ConfigResult, include []string) (map[string]map[string]interface{}, error) {\n\tif len(include) == 0 {\n\t\tret := config.GetInstance().GetAllPluginConfiguration()\n\t\treturn ret, nil\n\t}\n\n\tret := make(map[string]map[string]interface{})\n\n\tfor _, plugin := range include {\n\t\tc := config.GetInstance().GetPluginConfiguration(plugin)\n\t\tif len(c) > 0 {\n\t\t\tret[plugin] = c\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_file.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc fingerprintResolver(fp models.Fingerprints, type_ string) (*string, error) {\n\tfingerprint := fp.For(type_)\n\tif fingerprint != nil {\n\t\tvalue := fingerprint.Value()\n\t\treturn &value, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {\n\treturn fingerprintResolver(obj.BaseFile.Fingerprints, type_)\n}\n\nfunc (r *imageFileResolver) Fingerprint(ctx context.Context, obj *ImageFile, type_ string) (*string, error) {\n\treturn fingerprintResolver(obj.ImageFile.Fingerprints, type_)\n}\n\nfunc (r *videoFileResolver) Fingerprint(ctx context.Context, obj *VideoFile, type_ string) (*string, error) {\n\treturn fingerprintResolver(obj.VideoFile.Fingerprints, type_)\n}\n\nfunc (r *basicFileResolver) Fingerprint(ctx context.Context, obj *BasicFile, type_ string) (*string, error) {\n\treturn fingerprintResolver(obj.BaseFile.Fingerprints, type_)\n}\n\nfunc (r *galleryFileResolver) ParentFolder(ctx context.Context, obj *GalleryFile) (*models.Folder, error) {\n\treturn loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)\n}\n\nfunc (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*models.Folder, error) {\n\treturn loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)\n}\n\nfunc (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) {\n\treturn loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)\n}\n\nfunc (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) {\n\treturn loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)\n}\n\nfunc zipFileResolver(ctx context.Context, zipFileID *models.FileID) (*BasicFile, error) {\n\tif zipFileID == nil {\n\t\treturn nil, nil\n\t}\n\n\tf, err := loaders.From(ctx).FileByID.Load(*zipFileID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &BasicFile{\n\t\tBaseFile: f.Base(),\n\t}, nil\n}\n\nfunc (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*BasicFile, error) {\n\treturn zipFileResolver(ctx, obj.ZipFileID)\n}\n\nfunc (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) {\n\treturn zipFileResolver(ctx, obj.ZipFileID)\n}\n\nfunc (r *videoFileResolver) ZipFile(ctx context.Context, obj *VideoFile) (*BasicFile, error) {\n\treturn zipFileResolver(ctx, obj.ZipFileID)\n}\n\nfunc (r *basicFileResolver) ZipFile(ctx context.Context, obj *BasicFile) (*BasicFile, error) {\n\treturn zipFileResolver(ctx, obj.ZipFileID)\n}\n"
  },
  {
    "path": "internal/api/resolver_model_folder.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *folderResolver) Basename(ctx context.Context, obj *models.Folder) (string, error) {\n\treturn filepath.Base(obj.Path), nil\n}\n\nfunc (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) {\n\tif obj.ParentFolderID == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)\n}\n\nfunc (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) {\n\tids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar errs []error\n\tret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)\n\treturn ret, firstError(errs)\n}\n\nfunc (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {\n\treturn zipFileResolver(ctx, obj.ZipFileID)\n}\n"
  },
  {
    "path": "internal/api/resolver_model_gallery.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *galleryResolver) getFiles(ctx context.Context, obj *models.Gallery) ([]models.File, error) {\n\tfileIDs, err := loaders.From(ctx).GalleryFiles.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfiles, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs)\n\treturn files, firstError(errs)\n}\n\nfunc (r *galleryResolver) Files(ctx context.Context, obj *models.Gallery) ([]*GalleryFile, error) {\n\tfiles, err := r.getFiles(ctx, obj)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([]*GalleryFile, len(files))\n\n\tfor i, f := range files {\n\t\tret[i] = &GalleryFile{\n\t\t\tBaseFile: f.Base(),\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *galleryResolver) Folder(ctx context.Context, obj *models.Gallery) (*models.Folder, error) {\n\tif obj.FolderID == nil {\n\t\treturn nil, nil\n\t}\n\n\tvar ret *models.Folder\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\n\t\tret, err = r.repository.Folder.Find(ctx, *obj.FolderID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif ret == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *galleryResolver) Cover(ctx context.Context, obj *models.Gallery) (ret *models.Image, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t// Find cover image first\n\t\tret, err = image.FindGalleryCover(ctx, r.repository.Image, obj.ID, config.GetInstance().GetGalleryCoverRegex())\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *galleryResolver) Date(ctx context.Context, obj *models.Gallery) (*string, error) {\n\tif obj.Date != nil {\n\t\tresult := obj.Date.String()\n\t\treturn &result, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *galleryResolver) Rating100(ctx context.Context, obj *models.Gallery) (*int, error) {\n\treturn obj.Rating, nil\n}\n\nfunc (r *galleryResolver) Scenes(ctx context.Context, obj *models.Gallery) (ret []*models.Scene, err error) {\n\tif !obj.SceneIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadSceneIDs(ctx, r.repository.Gallery)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).SceneByID.LoadAll(obj.SceneIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *galleryResolver) Studio(ctx context.Context, obj *models.Gallery) (ret *models.Studio, err error) {\n\tif obj.StudioID == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn loaders.From(ctx).StudioByID.Load(*obj.StudioID)\n}\n\nfunc (r *galleryResolver) Tags(ctx context.Context, obj *models.Gallery) (ret []*models.Tag, err error) {\n\tif !obj.TagIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadTagIDs(ctx, r.repository.Gallery)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *galleryResolver) Performers(ctx context.Context, obj *models.Gallery) (ret []*models.Performer, err error) {\n\tif !obj.PerformerIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadPerformerIDs(ctx, r.repository.Gallery)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *galleryResolver) ImageCount(ctx context.Context, obj *models.Gallery) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\tret, err = r.repository.Image.CountByGalleryID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *galleryResolver) Chapters(ctx context.Context, obj *models.Gallery) (ret []*models.GalleryChapter, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.GalleryChapter.FindByGalleryID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *galleryResolver) URL(ctx context.Context, obj *models.Gallery) (*string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Gallery)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\turls := obj.URLs.List()\n\tif len(urls) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn &urls[0], nil\n}\n\nfunc (r *galleryResolver) Urls(ctx context.Context, obj *models.Gallery) ([]string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Gallery)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.URLs.List(), nil\n}\n\nfunc (r *galleryResolver) Paths(ctx context.Context, obj *models.Gallery) (*GalleryPathsType, error) {\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\tbuilder := urlbuilders.NewGalleryURLBuilder(baseURL, obj)\n\n\treturn &GalleryPathsType{\n\t\tCover:   builder.GetCoverURL(),\n\t\tPreview: builder.GetPreviewURL(),\n\t}, nil\n}\n\nfunc (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index int) (ret *models.Image, err error) {\n\tif index < 0 {\n\t\treturn nil, fmt.Errorf(\"index must >= 0\")\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Image.FindByGalleryIDIndex(ctx, obj.ID, uint(index))\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn\n}\n\nfunc (r *galleryResolver) CustomFields(ctx context.Context, obj *models.Gallery) (map[string]interface{}, error) {\n\tm, err := loaders.From(ctx).GalleryCustomFields.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif m == nil {\n\t\treturn make(map[string]interface{}), nil\n\t}\n\n\treturn m, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_gallery_chapter.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *galleryChapterResolver) Gallery(ctx context.Context, obj *models.GalleryChapter) (ret *models.Gallery, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Gallery.Find(ctx, obj.GalleryID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_image.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]models.File, error) {\n\tfileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfiles, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs)\n\treturn files, firstError(errs)\n}\n\nfunc (r *imageResolver) VisualFiles(ctx context.Context, obj *models.Image) ([]VisualFile, error) {\n\tfiles, err := r.getFiles(ctx, obj)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([]VisualFile, len(files))\n\tfor i, f := range files {\n\t\tret[i], err = convertVisualFile(f)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *imageResolver) Date(ctx context.Context, obj *models.Image) (*string, error) {\n\tif obj.Date != nil {\n\t\tresult := obj.Date.String()\n\t\treturn &result, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *imageResolver) Files(ctx context.Context, obj *models.Image) ([]*ImageFile, error) {\n\tfiles, err := r.getFiles(ctx, obj)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret []*ImageFile\n\n\tfor _, f := range files {\n\t\t// filter out non-image files\n\t\timageFile, ok := f.(*models.ImageFile)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tret = append(ret, &ImageFile{\n\t\t\tImageFile: imageFile,\n\t\t})\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*ImagePathsType, error) {\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\tbuilder := urlbuilders.NewImageURLBuilder(baseURL, obj)\n\tthumbnailPath := builder.GetThumbnailURL()\n\tpreviewPath := builder.GetPreviewURL()\n\timagePath := builder.GetImageURL()\n\treturn &ImagePathsType{\n\t\tImage:     &imagePath,\n\t\tThumbnail: &thumbnailPath,\n\t\tPreview:   &previewPath,\n\t}, nil\n}\n\nfunc (r *imageResolver) Galleries(ctx context.Context, obj *models.Image) (ret []*models.Gallery, err error) {\n\tif !obj.GalleryIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadGalleryIDs(ctx, r.repository.Image)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).GalleryByID.LoadAll(obj.GalleryIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *imageResolver) Rating100(ctx context.Context, obj *models.Image) (*int, error) {\n\treturn obj.Rating, nil\n}\n\nfunc (r *imageResolver) Studio(ctx context.Context, obj *models.Image) (ret *models.Studio, err error) {\n\tif obj.StudioID == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn loaders.From(ctx).StudioByID.Load(*obj.StudioID)\n}\n\nfunc (r *imageResolver) Tags(ctx context.Context, obj *models.Image) (ret []*models.Tag, err error) {\n\tif !obj.TagIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadTagIDs(ctx, r.repository.Image)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *imageResolver) Performers(ctx context.Context, obj *models.Image) (ret []*models.Performer, err error) {\n\tif !obj.PerformerIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadPerformerIDs(ctx, r.repository.Image)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *imageResolver) URL(ctx context.Context, obj *models.Image) (*string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Image)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\turls := obj.URLs.List()\n\tif len(urls) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn &urls[0], nil\n}\n\nfunc (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Image)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.URLs.List(), nil\n}\n\nfunc (r *imageResolver) CustomFields(ctx context.Context, obj *models.Image) (map[string]interface{}, error) {\n\tcustomFields, err := loaders.From(ctx).ImageCustomFields.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn customFields, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_movie.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/pkg/group\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\nfunc (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, error) {\n\tif obj.Date != nil {\n\t\tresult := obj.Date.String()\n\t\treturn &result, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *groupResolver) Rating100(ctx context.Context, obj *models.Group) (*int, error) {\n\treturn obj.Rating, nil\n}\n\nfunc (r *groupResolver) URL(ctx context.Context, obj *models.Group) (*string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Group)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\turls := obj.URLs.List()\n\tif len(urls) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn &urls[0], nil\n}\n\nfunc (r *groupResolver) Urls(ctx context.Context, obj *models.Group) ([]string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Group)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.URLs.List(), nil\n}\n\nfunc (r *groupResolver) Studio(ctx context.Context, obj *models.Group) (ret *models.Studio, err error) {\n\tif obj.StudioID == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn loaders.From(ctx).StudioByID.Load(*obj.StudioID)\n}\n\nfunc (r groupResolver) Tags(ctx context.Context, obj *models.Group) (ret []*models.Tag, err error) {\n\tif !obj.TagIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadTagIDs(ctx, r.repository.Group)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r groupResolver) relatedGroups(ctx context.Context, rgd models.RelatedGroupDescriptions) (ret []*GroupDescription, err error) {\n\t// rgd must be loaded\n\tgds := rgd.List()\n\tids := make([]int, len(gds))\n\tfor i, gd := range gds {\n\t\tids[i] = gd.GroupID\n\t}\n\n\tgroups, errs := loaders.From(ctx).GroupByID.LoadAll(ids)\n\n\terr = firstError(errs)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tret = make([]*GroupDescription, len(groups))\n\tfor i, group := range groups {\n\t\tret[i] = &GroupDescription{Group: group}\n\t\td := gds[i].Description\n\t\tif d != \"\" {\n\t\t\tret[i].Description = &d\n\t\t}\n\t}\n\n\treturn ret, firstError(errs)\n}\n\nfunc (r groupResolver) ContainingGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) {\n\tif !obj.ContainingGroups.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadContainingGroupIDs(ctx, r.repository.Group)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn r.relatedGroups(ctx, obj.ContainingGroups)\n}\n\nfunc (r groupResolver) SubGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) {\n\tif !obj.SubGroups.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadSubGroupIDs(ctx, r.repository.Group)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn r.relatedGroups(ctx, obj.SubGroups)\n}\n\nfunc (r *groupResolver) SubGroupCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = group.CountByContainingGroupID(ctx, r.repository.Group, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *groupResolver) FrontImagePath(ctx context.Context, obj *models.Group) (*string, error) {\n\tvar hasImage bool\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\thasImage, err = r.repository.Group.HasFrontImage(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\timagePath := urlbuilders.NewGroupURLBuilder(baseURL, obj).GetGroupFrontImageURL(hasImage)\n\treturn &imagePath, nil\n}\n\nfunc (r *groupResolver) BackImagePath(ctx context.Context, obj *models.Group) (*string, error) {\n\tvar hasImage bool\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\thasImage, err = r.repository.Group.HasBackImage(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// don't return anything if there is no back image\n\tif !hasImage {\n\t\treturn nil, nil\n\t}\n\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\timagePath := urlbuilders.NewGroupURLBuilder(baseURL, obj).GetGroupBackImageURL()\n\treturn &imagePath, nil\n}\n\nfunc (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = scene.CountByGroupID(ctx, r.repository.Scene, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *groupResolver) PerformerCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = performer.CountByGroupID(ctx, r.repository.Performer, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*models.Scene, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\tret, err = r.repository.Scene.FindByGroupID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *int, err error) {\n\tvar count int\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tcount, err = r.repository.Scene.OCountByGroupID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &count, nil\n}\n\nfunc (r *groupResolver) CustomFields(ctx context.Context, obj *models.Group) (map[string]interface{}, error) {\n\tm, err := loaders.From(ctx).GroupCustomFields.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif m == nil {\n\t\treturn make(map[string]interface{}), nil\n\t}\n\n\treturn m, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_performer.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n)\n\nfunc (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) {\n\tif !obj.Aliases.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadAliases(ctx, r.repository.Performer)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.Aliases.List(), nil\n}\n\nfunc (r *performerResolver) URL(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Performer)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\turls := obj.URLs.List()\n\tif len(urls) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn &urls[0], nil\n}\n\nfunc (r *performerResolver) Twitter(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Performer)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\turls := obj.URLs.List()\n\n\t// find the first twitter url\n\tfor _, url := range urls {\n\t\tif performer.IsTwitterURL(url) {\n\t\t\tu := url\n\t\t\treturn &u, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (r *performerResolver) Instagram(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Performer)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\turls := obj.URLs.List()\n\n\t// find the first instagram url\n\tfor _, url := range urls {\n\t\tif performer.IsInstagramURL(url) {\n\t\t\tu := url\n\t\t\treturn &u, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (r *performerResolver) Urls(ctx context.Context, obj *models.Performer) ([]string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Performer)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.URLs.List(), nil\n}\n\nfunc (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif obj.Height != nil {\n\t\tret := strconv.Itoa(*obj.Height)\n\t\treturn &ret, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer) (*int, error) {\n\treturn obj.Height, nil\n}\n\nfunc (r *performerResolver) CareerStart(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif obj.CareerStart != nil {\n\t\tret := obj.CareerStart.String()\n\t\treturn &ret, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *performerResolver) CareerEnd(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif obj.CareerEnd != nil {\n\t\tret := obj.CareerEnd.String()\n\t\treturn &ret, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif obj.CareerStart == nil && obj.CareerEnd == nil {\n\t\treturn nil, nil\n\t}\n\n\tret := models.FormatYearRange(obj.CareerStart, obj.CareerEnd)\n\treturn &ret, nil\n}\n\nfunc (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif obj.Birthdate != nil {\n\t\tret := obj.Birthdate.String()\n\t\treturn &ret, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer) (*string, error) {\n\tvar hasImage bool\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\thasImage, err = r.repository.Performer.HasImage(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\timagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL(hasImage)\n\treturn &imagePath, nil\n}\n\nfunc (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (ret []*models.Tag, err error) {\n\tif !obj.TagIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadTagIDs(ctx, r.repository.Performer)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Scene.CountByPerformerID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *performerResolver) ImageCount(ctx context.Context, obj *models.Performer) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = image.CountByPerformerID(ctx, r.repository.Image, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Performer) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = gallery.CountByPerformerID(ctx, r.repository.Gallery, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *performerResolver) GroupCount(ctx context.Context, obj *models.Performer) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Group.CountByPerformerID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\n// deprecated\nfunc (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performer) (ret int, err error) {\n\treturn r.GroupCount(ctx, obj)\n}\n\nfunc (r *performerResolver) PerformerCount(ctx context.Context, obj *models.Performer) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = performer.CountByAppearsWith(ctx, r.repository.Performer, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *performerResolver) OCounter(ctx context.Context, obj *models.Performer) (ret *int, err error) {\n\tvar res_scene int\n\tvar res_image int\n\tvar res int\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tres_scene, err = r.repository.Scene.OCountByPerformerID(ctx, obj.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tres_image, err = r.repository.Image.OCountByPerformerID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\tres = res_scene + res_image\n\treturn &res, nil\n}\n\nfunc (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Scene.FindByPerformerID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) ([]*models.StashID, error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\treturn obj.LoadStashIDs(ctx, r.repository.Performer)\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil\n}\n\nfunc (r *performerResolver) Rating100(ctx context.Context, obj *models.Performer) (*int, error) {\n\treturn obj.Rating, nil\n}\n\nfunc (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) {\n\tif obj.DeathDate != nil {\n\t\tret := obj.DeathDate.String()\n\t\treturn &ret, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Group.FindByPerformerID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *performerResolver) CustomFields(ctx context.Context, obj *models.Performer) (map[string]interface{}, error) {\n\tm, err := loaders.From(ctx).PerformerCustomFields.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif m == nil {\n\t\treturn make(map[string]interface{}), nil\n\t}\n\n\treturn m, nil\n}\n\n// deprecated\nfunc (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) {\n\treturn r.Groups(ctx, obj)\n}\n"
  },
  {
    "path": "internal/api/resolver_model_plugin.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/plugin\"\n)\n\ntype pluginURLBuilder struct {\n\tBaseURL string\n\tPlugin  *plugin.Plugin\n}\n\nfunc (b pluginURLBuilder) javascript() []string {\n\tui := b.Plugin.UI\n\tif len(ui.Javascript) == 0 && len(ui.ExternalScript) == 0 {\n\t\treturn nil\n\t}\n\n\tvar ret []string\n\n\tret = append(ret, ui.ExternalScript...)\n\tret = append(ret, b.BaseURL+\"/plugin/\"+b.Plugin.ID+\"/javascript\")\n\n\treturn ret\n}\n\nfunc (b pluginURLBuilder) css() []string {\n\tui := b.Plugin.UI\n\tif len(ui.CSS) == 0 && len(ui.ExternalCSS) == 0 {\n\t\treturn nil\n\t}\n\n\tvar ret []string\n\n\tret = append(ret, b.Plugin.UI.ExternalCSS...)\n\tret = append(ret, b.BaseURL+\"/plugin/\"+b.Plugin.ID+\"/css\")\n\treturn ret\n}\n\nfunc (b *pluginURLBuilder) paths() *PluginPaths {\n\treturn &PluginPaths{\n\t\tJavascript: b.javascript(),\n\t\tCSS:        b.css(),\n\t}\n}\n\nfunc (r *pluginResolver) Paths(ctx context.Context, obj *plugin.Plugin) (*PluginPaths, error) {\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\n\tb := pluginURLBuilder{\n\t\tBaseURL: baseURL,\n\t\tPlugin:  obj,\n\t}\n\n\treturn b.paths(), nil\n}\n\nfunc (r *pluginResolver) Requires(ctx context.Context, obj *plugin.Plugin) ([]string, error) {\n\treturn obj.UI.Requires, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_saved_filter.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *savedFilterResolver) Filter(ctx context.Context, obj *models.SavedFilter) (string, error) {\n\treturn \"\", nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_scene.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc convertVideoFile(f models.File) (*models.VideoFile, error) {\n\tvf, ok := f.(*models.VideoFile)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"file %T is not a video file\", f)\n\t}\n\treturn vf, nil\n}\n\nfunc (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (*models.VideoFile, error) {\n\tif obj.PrimaryFileID != nil {\n\t\tf, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret, err := convertVideoFile(f)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tobj.Files.SetPrimary(ret)\n\n\t\treturn ret, nil\n\t} else {\n\t\t_ = obj.LoadPrimaryFile(ctx, r.repository.File)\n\t}\n\n\treturn nil, nil\n}\n\nfunc (r *sceneResolver) getFiles(ctx context.Context, obj *models.Scene) ([]*models.VideoFile, error) {\n\tfileIDs, err := loaders.From(ctx).SceneFiles.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfiles, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs)\n\terr = firstError(errs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([]*models.VideoFile, len(files))\n\tfor i, f := range files {\n\t\tret[i], err = convertVideoFile(f)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tobj.Files.Set(ret)\n\n\treturn ret, nil\n}\n\nfunc (r *sceneResolver) Date(ctx context.Context, obj *models.Scene) (*string, error) {\n\tif obj.Date != nil {\n\t\tresult := obj.Date.String()\n\t\treturn &result, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoFile, error) {\n\tfiles, err := r.getFiles(ctx, obj)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([]*VideoFile, len(files))\n\n\tfor i, f := range files {\n\t\tret[i] = &VideoFile{\n\t\t\tVideoFile: f,\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *sceneResolver) Rating(ctx context.Context, obj *models.Scene) (*int, error) {\n\tif obj.Rating != nil {\n\t\trating := models.Rating100To5(*obj.Rating)\n\t\treturn &rating, nil\n\t}\n\treturn nil, nil\n}\n\nfunc (r *sceneResolver) Rating100(ctx context.Context, obj *models.Scene) (*int, error) {\n\treturn obj.Rating, nil\n}\n\nfunc (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePathsType, error) {\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\tconfig := manager.GetInstance().Config\n\tbuilder := urlbuilders.NewSceneURLBuilder(baseURL, obj)\n\tscreenshotPath := builder.GetScreenshotURL()\n\tpreviewPath := builder.GetStreamPreviewURL()\n\tstreamPath := builder.GetStreamURL(config.GetAPIKey()).String()\n\twebpPath := builder.GetStreamPreviewImageURL()\n\tobjHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())\n\tvttPath := builder.GetSpriteVTTURL(objHash)\n\tspritePath := builder.GetSpriteURL(objHash)\n\tfunscriptPath := builder.GetFunscriptURL()\n\tcaptionBasePath := builder.GetCaptionURL()\n\tinteractiveHeatmap := builder.GetInteractiveHeatmapURL()\n\n\treturn &ScenePathsType{\n\t\tScreenshot:         &screenshotPath,\n\t\tPreview:            &previewPath,\n\t\tStream:             &streamPath,\n\t\tWebp:               &webpPath,\n\t\tVtt:                &vttPath,\n\t\tSprite:             &spritePath,\n\t\tFunscript:          &funscriptPath,\n\t\tInteractiveHeatmap: &interactiveHeatmap,\n\t\tCaption:            &captionBasePath,\n\t}, nil\n}\n\nfunc (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) (ret []*models.SceneMarker, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.SceneMarker.FindBySceneID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *sceneResolver) Captions(ctx context.Context, obj *models.Scene) (ret []*models.VideoCaption, err error) {\n\tprimaryFile, err := r.getPrimaryFile(ctx, obj)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif primaryFile == nil {\n\t\treturn nil, nil\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.File.GetCaptions(ctx, primaryFile.Base().ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, err\n}\n\nfunc (r *sceneResolver) Galleries(ctx context.Context, obj *models.Scene) (ret []*models.Gallery, err error) {\n\tif !obj.GalleryIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadGalleryIDs(ctx, r.repository.Scene)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).GalleryByID.LoadAll(obj.GalleryIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *sceneResolver) Studio(ctx context.Context, obj *models.Scene) (ret *models.Studio, err error) {\n\tif obj.StudioID == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn loaders.From(ctx).StudioByID.Load(*obj.StudioID)\n}\n\nfunc (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) (ret []*SceneMovie, err error) {\n\tif !obj.Groups.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.repository.Scene\n\n\t\t\treturn obj.LoadGroups(ctx, qb)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tloader := loaders.From(ctx).GroupByID\n\n\tfor _, sm := range obj.Groups.List() {\n\t\tmovie, err := loader.Load(sm.GroupID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tsceneIdx := sm.SceneIndex\n\t\tsceneMovie := &SceneMovie{\n\t\t\tMovie:      movie,\n\t\t\tSceneIndex: sceneIdx,\n\t\t}\n\n\t\tret = append(ret, sceneMovie)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *sceneResolver) Groups(ctx context.Context, obj *models.Scene) (ret []*SceneGroup, err error) {\n\tif !obj.Groups.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.repository.Scene\n\n\t\t\treturn obj.LoadGroups(ctx, qb)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tloader := loaders.From(ctx).GroupByID\n\n\tfor _, sm := range obj.Groups.List() {\n\t\tgroup, err := loader.Load(sm.GroupID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tsceneIdx := sm.SceneIndex\n\t\tsceneGroup := &SceneGroup{\n\t\t\tGroup:      group,\n\t\t\tSceneIndex: sceneIdx,\n\t\t}\n\n\t\tret = append(ret, sceneGroup)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *sceneResolver) Tags(ctx context.Context, obj *models.Scene) (ret []*models.Tag, err error) {\n\tif !obj.TagIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadTagIDs(ctx, r.repository.Scene)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *sceneResolver) Performers(ctx context.Context, obj *models.Scene) (ret []*models.Performer, err error) {\n\tif !obj.PerformerIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadPerformerIDs(ctx, r.repository.Scene)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *sceneResolver) StashIds(ctx context.Context, obj *models.Scene) (ret []*models.StashID, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\treturn obj.LoadStashIDs(ctx, r.repository.Scene)\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil\n}\n\nfunc (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]*manager.SceneStreamEndpoint, error) {\n\t// load the primary file into the scene\n\t_, err := r.getPrimaryFile(ctx, obj)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tconfig := manager.GetInstance().Config\n\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\tbuilder := urlbuilders.NewSceneURLBuilder(baseURL, obj)\n\tapiKey := config.GetAPIKey()\n\n\treturn manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())\n}\n\nfunc (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) {\n\tprimaryFile, err := r.getPrimaryFile(ctx, obj)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif primaryFile == nil {\n\t\treturn false, nil\n\t}\n\n\treturn primaryFile.Interactive, nil\n}\n\nfunc (r *sceneResolver) InteractiveSpeed(ctx context.Context, obj *models.Scene) (*int, error) {\n\tprimaryFile, err := r.getPrimaryFile(ctx, obj)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif primaryFile == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn primaryFile.InteractiveSpeed, nil\n}\n\nfunc (r *sceneResolver) URL(ctx context.Context, obj *models.Scene) (*string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Scene)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\turls := obj.URLs.List()\n\tif len(urls) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn &urls[0], nil\n}\n\nfunc (r *sceneResolver) Urls(ctx context.Context, obj *models.Scene) ([]string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Scene)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.URLs.List(), nil\n}\n\nfunc (r *sceneResolver) OCounter(ctx context.Context, obj *models.Scene) (*int, error) {\n\tret, err := loaders.From(ctx).SceneOCount.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &ret, nil\n}\n\nfunc (r *sceneResolver) LastPlayedAt(ctx context.Context, obj *models.Scene) (*time.Time, error) {\n\tret, err := loaders.From(ctx).SceneLastPlayed.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *sceneResolver) PlayCount(ctx context.Context, obj *models.Scene) (*int, error) {\n\tret, err := loaders.From(ctx).ScenePlayCount.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &ret, nil\n}\n\nfunc (r *sceneResolver) PlayHistory(ctx context.Context, obj *models.Scene) ([]*time.Time, error) {\n\tret, err := loaders.From(ctx).ScenePlayHistory.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// convert to pointer slice\n\tptrRet := make([]*time.Time, len(ret))\n\tfor i, t := range ret {\n\t\ttt := t\n\t\tptrRet[i] = &tt\n\t}\n\n\treturn ptrRet, nil\n}\n\nfunc (r *sceneResolver) OHistory(ctx context.Context, obj *models.Scene) ([]*time.Time, error) {\n\tret, err := loaders.From(ctx).SceneOHistory.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// convert to pointer slice\n\tptrRet := make([]*time.Time, len(ret))\n\tfor i, t := range ret {\n\t\ttt := t\n\t\tptrRet[i] = &tt\n\t}\n\n\treturn ptrRet, nil\n}\n\nfunc (r *sceneResolver) CustomFields(ctx context.Context, obj *models.Scene) (map[string]interface{}, error) {\n\tm, err := loaders.From(ctx).SceneCustomFields.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif m == nil {\n\t\treturn make(map[string]interface{}), nil\n\t}\n\n\treturn m, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_scene_marker.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *sceneMarkerResolver) Scene(ctx context.Context, obj *models.SceneMarker) (ret *models.Scene, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Scene.Find(ctx, obj.SceneID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *sceneMarkerResolver) PrimaryTag(ctx context.Context, obj *models.SceneMarker) (ret *models.Tag, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Tag.Find(ctx, obj.PrimaryTagID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, err\n}\n\nfunc (r *sceneMarkerResolver) Tags(ctx context.Context, obj *models.SceneMarker) (ret []*models.Tag, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Tag.FindBySceneMarkerID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, err\n}\n\nfunc (r *sceneMarkerResolver) Stream(ctx context.Context, obj *models.SceneMarker) (string, error) {\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\treturn urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetStreamURL(), nil\n}\n\nfunc (r *sceneMarkerResolver) Preview(ctx context.Context, obj *models.SceneMarker) (string, error) {\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\treturn urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetPreviewURL(), nil\n}\n\nfunc (r *sceneMarkerResolver) Screenshot(ctx context.Context, obj *models.SceneMarker) (string, error) {\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\treturn urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetScreenshotURL(), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_model_studio.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/group\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\nfunc (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*string, error) {\n\tvar hasImage bool\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\thasImage, err = r.repository.Studio.HasImage(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\timagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL(hasImage)\n\treturn &imagePath, nil\n}\n\nfunc (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]string, error) {\n\tif !obj.Aliases.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadAliases(ctx, r.repository.Studio)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.Aliases.List(), nil\n}\n\nfunc (r *studioResolver) URL(ctx context.Context, obj *models.Studio) (*string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Studio)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\turls := obj.URLs.List()\n\tif len(urls) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn &urls[0], nil\n}\n\nfunc (r *studioResolver) Urls(ctx context.Context, obj *models.Studio) ([]string, error) {\n\tif !obj.URLs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadURLs(ctx, r.repository.Studio)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.URLs.List(), nil\n}\n\nfunc (r *studioResolver) Tags(ctx context.Context, obj *models.Studio) (ret []*models.Tag, err error) {\n\tif !obj.TagIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadTagIDs(ctx, r.repository.Studio)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = scene.CountByStudioID(ctx, r.repository.Scene, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *studioResolver) ImageCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = image.CountByStudioID(ctx, r.repository.Image, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *studioResolver) GalleryCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = gallery.CountByStudioID(ctx, r.repository.Gallery, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *studioResolver) PerformerCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = performer.CountByStudioID(ctx, r.repository.Performer, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *studioResolver) GroupCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = group.CountByStudioID(ctx, r.repository.Group, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\n// deprecated\nfunc (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {\n\treturn r.GroupCount(ctx, obj, depth)\n}\n\nfunc (r *studioResolver) OCounter(ctx context.Context, obj *models.Studio) (ret *int, err error) {\n\tvar res_scene int\n\tvar res_image int\n\tvar res int\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tres_scene, err = r.repository.Scene.OCountByStudioID(ctx, obj.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tres_image, err = r.repository.Image.OCountByStudioID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\tres = res_scene + res_image\n\treturn &res, nil\n}\n\nfunc (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) {\n\tif obj.ParentID == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn loaders.From(ctx).StudioByID.Load(*obj.ParentID)\n}\n\nfunc (r *studioResolver) ChildStudios(ctx context.Context, obj *models.Studio) (ret []*models.Studio, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Studio.FindChildren(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*models.StashID, error) {\n\tif !obj.StashIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadStashIDs(ctx, r.repository.Studio)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil\n}\n\nfunc (r *studioResolver) Rating100(ctx context.Context, obj *models.Studio) (*int, error) {\n\treturn obj.Rating, nil\n}\n\nfunc (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Group.FindByStudioID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *studioResolver) CustomFields(ctx context.Context, obj *models.Studio) (map[string]interface{}, error) {\n\tm, err := loaders.From(ctx).StudioCustomFields.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif m == nil {\n\t\treturn make(map[string]interface{}), nil\n\t}\n\n\treturn m, nil\n}\n\n// deprecated\nfunc (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) {\n\treturn r.Groups(ctx, obj)\n}\n"
  },
  {
    "path": "internal/api/resolver_model_tag.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/group\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/studio\"\n)\n\nfunc (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {\n\tif !obj.ParentIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadParentIDs(ctx, r.repository.Tag)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ParentIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *tagResolver) Children(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {\n\tif !obj.ChildIDs.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadChildIDs(ctx, r.repository.Tag)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar errs []error\n\tret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ChildIDs.List())\n\treturn ret, firstError(errs)\n}\n\nfunc (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) {\n\tif !obj.Aliases.Loaded() {\n\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn obj.LoadAliases(ctx, r.repository.Tag)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn obj.Aliases.List(), nil\n}\n\nfunc (r *tagResolver) StashIds(ctx context.Context, obj *models.Tag) ([]*models.StashID, error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\treturn obj.LoadStashIDs(ctx, r.repository.Tag)\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil\n}\n\nfunc (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = scene.CountByTagID(ctx, r.repository.Scene, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = scene.MarkerCountByTagID(ctx, r.repository.SceneMarker, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) ImageCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = image.CountByTagID(ctx, r.repository.Image, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) GalleryCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = gallery.CountByTagID(ctx, r.repository.Gallery, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = performer.CountByTagID(ctx, r.repository.Performer, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = studio.CountByTagID(ctx, r.repository.Studio, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) GroupCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = group.CountByTagID(ctx, r.repository.Group, obj.ID, depth)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {\n\treturn r.GroupCount(ctx, obj, depth)\n}\n\nfunc (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {\n\tvar hasImage bool\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\thasImage, err = r.repository.Tag.HasImage(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\timagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL(hasImage)\n\treturn &imagePath, nil\n}\n\nfunc (r *tagResolver) ParentCount(ctx context.Context, obj *models.Tag) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Tag.CountByParentTagID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn ret, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) ChildCount(ctx context.Context, obj *models.Tag) (ret int, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Tag.CountByChildTagID(ctx, obj.ID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn ret, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *tagResolver) CustomFields(ctx context.Context, obj *models.Tag) (map[string]interface{}, error) {\n\tm, err := loaders.From(ctx).TagCustomFields.Load(obj.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif m == nil {\n\t\treturn make(map[string]interface{}), nil\n\t}\n\n\treturn m, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_configure.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/internal/manager/task\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nvar ErrOverriddenConfig = errors.New(\"cannot set overridden value\")\n\nfunc (r *mutationResolver) Setup(ctx context.Context, input manager.SetupInput) (bool, error) {\n\terr := manager.GetInstance().Setup(ctx, input)\n\treturn err == nil, err\n}\n\nfunc (r *mutationResolver) DownloadFFMpeg(ctx context.Context) (string, error) {\n\tmgr := manager.GetInstance()\n\tconfigDir := mgr.Config.GetConfigPathAbs()\n\n\t// don't run if ffmpeg is already installed\n\tffmpegPath := ffmpeg.FindFFMpeg(configDir)\n\tffprobePath := ffmpeg.FindFFProbe(configDir)\n\tif ffmpegPath != \"\" && ffprobePath != \"\" {\n\t\treturn \"\", fmt.Errorf(\"ffmpeg and ffprobe already installed at %s and %s\", ffmpegPath, ffprobePath)\n\t}\n\n\tt := &task.DownloadFFmpegJob{\n\t\tConfigDirectory: configDir,\n\t\tOnComplete: func(ctx context.Context) {\n\t\t\t// clear the ffmpeg and ffprobe paths\n\t\t\tlogger.Infof(\"Clearing ffmpeg and ffprobe config paths so they are resolved from the config directory\")\n\t\t\tmgr.Config.SetString(config.FFMpegPath, \"\")\n\t\t\tmgr.Config.SetString(config.FFProbePath, \"\")\n\t\t\tmgr.RefreshFFMpeg(ctx)\n\t\t\tmgr.RefreshStreamManager()\n\t\t},\n\t}\n\n\tjobID := mgr.JobManager.Add(ctx, \"Downloading ffmpeg...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) setConfigString(key string, value *string) {\n\tc := config.GetInstance()\n\tif value != nil {\n\t\tc.SetString(key, *value)\n\t}\n}\n\nfunc (r *mutationResolver) setConfigBool(key string, value *bool) {\n\tc := config.GetInstance()\n\tif value != nil {\n\t\tc.SetBool(key, *value)\n\t}\n}\n\nfunc (r *mutationResolver) setConfigInt(key string, value *int) {\n\tc := config.GetInstance()\n\tif value != nil {\n\t\tc.SetInt(key, *value)\n\t}\n}\n\nfunc (r *mutationResolver) setConfigFloat(key string, value *float64) {\n\tc := config.GetInstance()\n\tif value != nil {\n\t\tc.SetFloat(key, *value)\n\t}\n}\n\nfunc (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) {\n\tc := config.GetInstance()\n\n\t// #4709 - allow stash paths even if they do not exist, so that users may configure stash\n\t// for disconnected drives or network storage.\n\texistingPaths := c.GetStashPaths()\n\tif input.Stashes != nil {\n\t\tfor _, s := range input.Stashes {\n\t\t\t// Only validate existence of new paths\n\t\t\tisNew := true\n\t\t\tfor _, path := range existingPaths {\n\t\t\t\tif path.Path == s.Path {\n\t\t\t\t\tisNew = false\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif isNew {\n\t\t\t\ts.Path = filepath.Clean(s.Path)\n\n\t\t\t\t// if it exists, it must be directory\n\t\t\t\texists, err := fsutil.DirExists(s.Path)\n\t\t\t\t// allow it to not exist but if it does exist it must be a directory\n\t\t\t\tif !exists && !errors.Is(err, fs.ErrNotExist) {\n\t\t\t\t\treturn makeConfigGeneralResult(), err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tc.SetInterface(config.Stash, input.Stashes)\n\t}\n\n\tcheckConfigOverride := func(key string) error {\n\t\tif c.HasOverride(key) {\n\t\t\treturn fmt.Errorf(\"%w: %s\", ErrOverriddenConfig, key)\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tvalidateDir := func(key string, value string, optional bool) error {\n\t\tif err := checkConfigOverride(key); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !optional || value != \"\" {\n\t\t\tif err := fsutil.EnsureDir(value); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\texistingDBPath := c.GetDatabasePath()\n\tif input.DatabasePath != nil && existingDBPath != *input.DatabasePath {\n\t\tif err := checkConfigOverride(config.Database); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\text := filepath.Ext(*input.DatabasePath)\n\t\tif ext != \".db\" && ext != \".sqlite\" && ext != \".sqlite3\" {\n\t\t\treturn makeConfigGeneralResult(), fmt.Errorf(\"invalid database path, use extension db, sqlite, or sqlite3\")\n\t\t}\n\t\tc.SetString(config.Database, *input.DatabasePath)\n\t}\n\n\texistingBackupDirectoryPath := c.GetBackupDirectoryPath()\n\tif input.BackupDirectoryPath != nil && existingBackupDirectoryPath != *input.BackupDirectoryPath {\n\t\tif err := validateDir(config.BackupDirectoryPath, *input.BackupDirectoryPath, true); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\tc.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath)\n\t}\n\n\texistingDeleteTrashPath := c.GetDeleteTrashPath()\n\tif input.DeleteTrashPath != nil && existingDeleteTrashPath != *input.DeleteTrashPath {\n\t\tif err := validateDir(config.DeleteTrashPath, *input.DeleteTrashPath, true); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\tc.SetString(config.DeleteTrashPath, *input.DeleteTrashPath)\n\t}\n\n\texistingGeneratedPath := c.GetGeneratedPath()\n\tif input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath {\n\t\tif err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\tc.SetString(config.Generated, *input.GeneratedPath)\n\t}\n\n\trefreshScraperCache := false\n\trefreshScraperSource := false\n\texistingScrapersPath := c.GetScrapersPath()\n\tif input.ScrapersPath != nil && existingScrapersPath != *input.ScrapersPath {\n\t\tif err := validateDir(config.ScrapersPath, *input.ScrapersPath, false); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\trefreshScraperCache = true\n\t\trefreshScraperSource = true\n\t\tc.SetString(config.ScrapersPath, *input.ScrapersPath)\n\t}\n\n\trefreshPluginCache := false\n\trefreshPluginSource := false\n\texistingPluginsPath := c.GetPluginsPath()\n\tif input.PluginsPath != nil && existingPluginsPath != *input.PluginsPath {\n\t\tif err := validateDir(config.PluginsPath, *input.PluginsPath, false); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\trefreshPluginCache = true\n\t\trefreshPluginSource = true\n\t\tc.SetString(config.PluginsPath, *input.PluginsPath)\n\t}\n\n\texistingMetadataPath := c.GetMetadataPath()\n\tif input.MetadataPath != nil && existingMetadataPath != *input.MetadataPath {\n\t\tif err := validateDir(config.Metadata, *input.MetadataPath, true); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\tc.SetString(config.Metadata, *input.MetadataPath)\n\t}\n\n\trefreshStreamManager := false\n\texistingCachePath := c.GetCachePath()\n\tif input.CachePath != nil && existingCachePath != *input.CachePath {\n\t\tif err := validateDir(config.Cache, *input.CachePath, true); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\tc.SetString(config.Cache, *input.CachePath)\n\t\trefreshStreamManager = true\n\t}\n\n\trefreshBlobStorage := false\n\texistingBlobsPath := c.GetBlobsPath()\n\tif input.BlobsPath != nil && existingBlobsPath != *input.BlobsPath {\n\t\tif err := validateDir(config.BlobsPath, *input.BlobsPath, true); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\tc.SetString(config.BlobsPath, *input.BlobsPath)\n\t\trefreshBlobStorage = true\n\t}\n\n\tif input.BlobsStorage != nil && *input.BlobsStorage != c.GetBlobsStorage() {\n\t\tif *input.BlobsStorage == config.BlobStorageTypeFilesystem && c.GetBlobsPath() == \"\" {\n\t\t\treturn makeConfigGeneralResult(), fmt.Errorf(\"blobs path must be set when using filesystem storage\")\n\t\t}\n\n\t\tc.SetInterface(config.BlobsStorage, *input.BlobsStorage)\n\n\t\trefreshBlobStorage = true\n\t}\n\n\trefreshFfmpeg := false\n\tif input.FfmpegPath != nil && *input.FfmpegPath != c.GetFFMpegPath() {\n\t\tif *input.FfmpegPath != \"\" {\n\t\t\tif err := ffmpeg.ValidateFFMpeg(*input.FfmpegPath); err != nil {\n\t\t\t\treturn makeConfigGeneralResult(), fmt.Errorf(\"invalid ffmpeg path: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tc.SetString(config.FFMpegPath, *input.FfmpegPath)\n\t\trefreshFfmpeg = true\n\t}\n\n\tif input.FfprobePath != nil && *input.FfprobePath != c.GetFFProbePath() {\n\t\tif *input.FfprobePath != \"\" {\n\t\t\tif err := ffmpeg.ValidateFFProbe(*input.FfprobePath); err != nil {\n\t\t\t\treturn makeConfigGeneralResult(), fmt.Errorf(\"invalid ffprobe path: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tc.SetString(config.FFProbePath, *input.FfprobePath)\n\t\trefreshFfmpeg = true\n\t}\n\n\tif input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {\n\t\tcalculateMD5 := c.IsCalculateMD5()\n\t\tif input.CalculateMd5 != nil {\n\t\t\tcalculateMD5 = *input.CalculateMd5\n\t\t}\n\t\tif !calculateMD5 && *input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {\n\t\t\treturn makeConfigGeneralResult(), errors.New(\"calculateMD5 must be true if using MD5\")\n\t\t}\n\n\t\t// validate changing VideoFileNamingAlgorithm\n\t\tif err := r.withTxn(context.TODO(), func(ctx context.Context) error {\n\t\t\treturn manager.ValidateVideoFileNamingAlgorithm(ctx, r.repository.Scene, *input.VideoFileNamingAlgorithm)\n\t\t}); err != nil {\n\t\t\treturn makeConfigGeneralResult(), err\n\t\t}\n\n\t\tc.SetInterface(config.VideoFileNamingAlgorithm, *input.VideoFileNamingAlgorithm)\n\t}\n\n\tr.setConfigBool(config.CalculateMD5, input.CalculateMd5)\n\tr.setConfigInt(config.ParallelTasks, input.ParallelTasks)\n\tr.setConfigBool(config.PreviewAudio, input.PreviewAudio)\n\tr.setConfigInt(config.PreviewSegments, input.PreviewSegments)\n\tr.setConfigFloat(config.PreviewSegmentDuration, input.PreviewSegmentDuration)\n\tr.setConfigString(config.PreviewExcludeStart, input.PreviewExcludeStart)\n\tr.setConfigString(config.PreviewExcludeEnd, input.PreviewExcludeEnd)\n\tif input.PreviewPreset != nil {\n\t\tc.SetString(config.PreviewPreset, input.PreviewPreset.String())\n\t}\n\tr.setConfigBool(config.UseCustomSpriteInterval, input.UseCustomSpriteInterval)\n\tr.setConfigFloat(config.SpriteInterval, input.SpriteInterval)\n\tr.setConfigInt(config.MinimumSprites, input.MinimumSprites)\n\tr.setConfigInt(config.MaximumSprites, input.MaximumSprites)\n\tr.setConfigInt(config.SpriteScreenshotSize, input.SpriteScreenshotSize)\n\n\tr.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration)\n\tif input.MaxTranscodeSize != nil {\n\t\tc.SetString(config.MaxTranscodeSize, input.MaxTranscodeSize.String())\n\t}\n\n\tif input.MaxStreamingTranscodeSize != nil {\n\t\tc.SetString(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())\n\t}\n\tr.setConfigBool(config.WriteImageThumbnails, input.WriteImageThumbnails)\n\tr.setConfigBool(config.CreateImageClipsFromVideos, input.CreateImageClipsFromVideos)\n\n\tif input.GalleryCoverRegex != nil {\n\t\t_, err := regexp.Compile(*input.GalleryCoverRegex)\n\t\tif err != nil {\n\t\t\treturn makeConfigGeneralResult(), fmt.Errorf(\"Gallery cover regex '%v' invalid, '%v'\", *input.GalleryCoverRegex, err.Error())\n\t\t}\n\n\t\tc.SetString(config.GalleryCoverRegex, *input.GalleryCoverRegex)\n\t}\n\n\tif input.Username != nil && *input.Username != c.GetUsername() {\n\t\tc.SetString(config.Username, *input.Username)\n\t\tif *input.Password == \"\" {\n\t\t\tlogger.Info(\"Username cleared\")\n\t\t} else {\n\t\t\tlogger.Info(\"Username changed\")\n\t\t}\n\t}\n\n\tif input.Password != nil {\n\t\t// bit of a hack - check if the passed in password is the same as the stored hash\n\t\t// and only set if they are different\n\t\tcurrentPWHash := c.GetPasswordHash()\n\n\t\tif *input.Password != currentPWHash {\n\t\t\tif *input.Password == \"\" {\n\t\t\t\tlogger.Info(\"Password cleared\")\n\t\t\t} else {\n\t\t\t\tlogger.Info(\"Password changed\")\n\t\t\t}\n\t\t\tc.SetPassword(*input.Password)\n\t\t}\n\t}\n\n\tr.setConfigInt(config.MaxSessionAge, input.MaxSessionAge)\n\tr.setConfigString(config.LogFile, input.LogFile)\n\tr.setConfigBool(config.LogOut, input.LogOut)\n\tr.setConfigBool(config.LogAccess, input.LogAccess)\n\n\tif input.LogLevel != nil && *input.LogLevel != c.GetLogLevel() {\n\t\tc.SetString(config.LogLevel, *input.LogLevel)\n\t\tlogger := manager.GetInstance().Logger\n\t\tlogger.SetLogLevel(*input.LogLevel)\n\t}\n\n\tif input.LogFileMaxSize != nil && *input.LogFileMaxSize != c.GetLogFileMaxSize() {\n\t\tc.SetInt(config.LogFileMaxSize, *input.LogFileMaxSize)\n\t}\n\n\tif input.Excludes != nil {\n\t\tfor _, r := range input.Excludes {\n\t\t\t_, err := regexp.Compile(r)\n\t\t\tif err != nil {\n\t\t\t\treturn makeConfigGeneralResult(), fmt.Errorf(\"video exclusion pattern '%v' invalid: %w\", r, err)\n\t\t\t}\n\t\t}\n\t\tc.SetInterface(config.Exclude, input.Excludes)\n\t}\n\n\tif input.ImageExcludes != nil {\n\t\tfor _, r := range input.ImageExcludes {\n\t\t\t_, err := regexp.Compile(r)\n\t\t\tif err != nil {\n\t\t\t\treturn makeConfigGeneralResult(), fmt.Errorf(\"image/gallery exclusion pattern '%v' invalid: %w\", r, err)\n\t\t\t}\n\t\t}\n\t\tc.SetInterface(config.ImageExclude, input.ImageExcludes)\n\t}\n\n\tif input.VideoExtensions != nil {\n\t\tc.SetInterface(config.VideoExtensions, input.VideoExtensions)\n\t}\n\n\tif input.ImageExtensions != nil {\n\t\tc.SetInterface(config.ImageExtensions, input.ImageExtensions)\n\t}\n\n\tif input.GalleryExtensions != nil {\n\t\tc.SetInterface(config.GalleryExtensions, input.GalleryExtensions)\n\t}\n\n\tr.setConfigBool(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)\n\n\tif input.CustomPerformerImageLocation != nil {\n\t\tc.SetString(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)\n\t\tinitCustomPerformerImages(*input.CustomPerformerImageLocation)\n\t}\n\n\tif input.StashBoxes != nil {\n\t\tif err := c.ValidateStashBoxes(input.StashBoxes); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tc.SetInterface(config.StashBoxes, input.StashBoxes)\n\t}\n\n\tif input.PythonPath != nil {\n\t\tr.setConfigString(config.PythonPath, input.PythonPath)\n\t}\n\n\tif input.TranscodeInputArgs != nil {\n\t\tc.SetInterface(config.TranscodeInputArgs, input.TranscodeInputArgs)\n\t}\n\tif input.TranscodeOutputArgs != nil {\n\t\tc.SetInterface(config.TranscodeOutputArgs, input.TranscodeOutputArgs)\n\t}\n\tif input.LiveTranscodeInputArgs != nil {\n\t\tc.SetInterface(config.LiveTranscodeInputArgs, input.LiveTranscodeInputArgs)\n\t}\n\tif input.LiveTranscodeOutputArgs != nil {\n\t\tc.SetInterface(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs)\n\t}\n\n\tr.setConfigBool(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)\n\n\tif input.ScraperPackageSources != nil {\n\t\tc.SetInterface(config.ScraperPackageSources, input.ScraperPackageSources)\n\t\trefreshScraperSource = true\n\t}\n\n\tif input.PluginPackageSources != nil {\n\t\tc.SetInterface(config.PluginPackageSources, input.PluginPackageSources)\n\t\trefreshPluginSource = true\n\t}\n\n\tif err := c.Write(); err != nil {\n\t\treturn makeConfigGeneralResult(), err\n\t}\n\n\tmanager.GetInstance().RefreshConfig()\n\tif refreshScraperCache {\n\t\tmanager.GetInstance().RefreshScraperCache()\n\t}\n\tif refreshPluginCache {\n\t\tmanager.GetInstance().RefreshPluginCache()\n\t}\n\tif refreshFfmpeg {\n\t\tmanager.GetInstance().RefreshFFMpeg(ctx)\n\n\t\t// refresh stream manager is required since ffmpeg changed\n\t\trefreshStreamManager = true\n\t}\n\tif refreshStreamManager {\n\t\tmanager.GetInstance().RefreshStreamManager()\n\t}\n\tif refreshBlobStorage {\n\t\tmanager.GetInstance().SetBlobStoreOptions()\n\t}\n\tif refreshScraperSource {\n\t\tmanager.GetInstance().RefreshScraperSourceManager()\n\t}\n\tif refreshPluginSource {\n\t\tmanager.GetInstance().RefreshPluginSourceManager()\n\t}\n\n\treturn makeConfigGeneralResult(), nil\n}\n\nfunc (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigInterfaceInput) (*ConfigInterfaceResult, error) {\n\tc := config.GetInstance()\n\n\tr.setConfigBool(config.SFWContentMode, input.SfwContentMode)\n\n\tif input.MenuItems != nil {\n\t\tc.SetInterface(config.MenuItems, input.MenuItems)\n\t}\n\n\tr.setConfigBool(config.SoundOnPreview, input.SoundOnPreview)\n\tr.setConfigBool(config.WallShowTitle, input.WallShowTitle)\n\n\tr.setConfigBool(config.NoBrowser, input.NoBrowser)\n\n\tr.setConfigBool(config.NotificationsEnabled, input.NotificationsEnabled)\n\n\tr.setConfigBool(config.ShowScrubber, input.ShowScrubber)\n\n\tr.setConfigString(config.WallPlayback, input.WallPlayback)\n\tr.setConfigInt(config.MaximumLoopDuration, input.MaximumLoopDuration)\n\tr.setConfigBool(config.AutostartVideo, input.AutostartVideo)\n\tr.setConfigBool(config.ShowStudioAsText, input.ShowStudioAsText)\n\tr.setConfigBool(config.AutostartVideoOnPlaySelected, input.AutostartVideoOnPlaySelected)\n\tr.setConfigBool(config.ContinuePlaylistDefault, input.ContinuePlaylistDefault)\n\n\tr.setConfigString(config.Language, input.Language)\n\n\tif input.ImageLightbox != nil {\n\t\toptions := input.ImageLightbox\n\n\t\tr.setConfigInt(config.ImageLightboxSlideshowDelay, options.SlideshowDelay)\n\n\t\tr.setConfigString(config.ImageLightboxDisplayModeKey, (*string)(options.DisplayMode))\n\t\tr.setConfigBool(config.ImageLightboxScaleUp, options.ScaleUp)\n\t\tr.setConfigBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav)\n\t\tr.setConfigString(config.ImageLightboxScrollModeKey, (*string)(options.ScrollMode))\n\n\t\tr.setConfigInt(config.ImageLightboxScrollAttemptsBeforeChange, options.ScrollAttemptsBeforeChange)\n\n\t\tr.setConfigBool(config.ImageLightboxDisableAnimation, options.DisableAnimation)\n\t}\n\n\tif input.CSS != nil {\n\t\tc.SetCSS(*input.CSS)\n\t}\n\n\tr.setConfigBool(config.CSSEnabled, input.CSSEnabled)\n\n\tif input.Javascript != nil {\n\t\tc.SetJavascript(*input.Javascript)\n\t}\n\n\tr.setConfigBool(config.JavascriptEnabled, input.JavascriptEnabled)\n\n\tif input.CustomLocales != nil {\n\t\tc.SetCustomLocales(*input.CustomLocales)\n\t}\n\n\tr.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)\n\n\tr.setConfigBool(config.DisableCustomizations, input.DisableCustomizations)\n\n\tif input.DisableDropdownCreate != nil {\n\t\tddc := input.DisableDropdownCreate\n\t\tr.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer)\n\t\tr.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio)\n\t\tr.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag)\n\t\tr.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie)\n\t\tr.setConfigBool(config.DisableDropdownCreateGallery, ddc.Gallery)\n\t}\n\n\tr.setConfigString(config.HandyKey, input.HandyKey)\n\tr.setConfigInt(config.FunscriptOffset, input.FunscriptOffset)\n\tr.setConfigBool(config.UseStashHostedFunscript, input.UseStashHostedFunscript)\n\n\tif err := c.Write(); err != nil {\n\t\treturn makeConfigInterfaceResult(), err\n\t}\n\n\treturn makeConfigInterfaceResult(), nil\n}\n\nfunc (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAInput) (*ConfigDLNAResult, error) {\n\tc := config.GetInstance()\n\n\tr.setConfigString(config.DLNAServerName, input.ServerName)\n\n\tif input.WhitelistedIPs != nil {\n\t\tc.SetInterface(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)\n\t}\n\n\tr.setConfigString(config.DLNAVideoSortOrder, input.VideoSortOrder)\n\tr.setConfigInt(config.DLNAPort, input.Port)\n\n\trefresh := false\n\tif input.Enabled != nil {\n\t\tc.SetBool(config.DLNADefaultEnabled, *input.Enabled)\n\t\trefresh = true\n\t}\n\n\tif input.Interfaces != nil {\n\t\tc.SetInterface(config.DLNAInterfaces, input.Interfaces)\n\t}\n\n\tif err := c.Write(); err != nil {\n\t\treturn makeConfigDLNAResult(), err\n\t}\n\n\tif refresh {\n\t\tmanager.GetInstance().RefreshDLNA()\n\t}\n\n\treturn makeConfigDLNAResult(), nil\n}\n\nfunc (r *mutationResolver) ConfigureScraping(ctx context.Context, input ConfigScrapingInput) (*ConfigScrapingResult, error) {\n\tc := config.GetInstance()\n\n\trefreshScraperCache := false\n\tif input.ScraperUserAgent != nil {\n\t\tc.SetString(config.ScraperUserAgent, *input.ScraperUserAgent)\n\t\trefreshScraperCache = true\n\t}\n\n\tif input.ScraperCDPPath != nil {\n\t\tc.SetString(config.ScraperCDPPath, *input.ScraperCDPPath)\n\t\trefreshScraperCache = true\n\t}\n\n\tif input.ExcludeTagPatterns != nil {\n\t\tfor _, r := range input.ExcludeTagPatterns {\n\t\t\t_, err := regexp.Compile(r)\n\t\t\tif err != nil {\n\t\t\t\treturn makeConfigScrapingResult(), fmt.Errorf(\"tag exclusion pattern '%v' invalid: %w\", r, err)\n\t\t\t}\n\t\t}\n\t\tc.SetInterface(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)\n\t}\n\n\tr.setConfigBool(config.ScraperCertCheck, input.ScraperCertCheck)\n\n\tif refreshScraperCache {\n\t\tmanager.GetInstance().RefreshScraperCache()\n\t}\n\tif err := c.Write(); err != nil {\n\t\treturn makeConfigScrapingResult(), err\n\t}\n\n\treturn makeConfigScrapingResult(), nil\n}\n\nfunc (r *mutationResolver) ConfigureDefaults(ctx context.Context, input ConfigDefaultSettingsInput) (*ConfigDefaultSettingsResult, error) {\n\tc := config.GetInstance()\n\n\tif input.Identify != nil {\n\t\tc.SetInterface(config.DefaultIdentifySettings, input.Identify)\n\t}\n\n\tif input.Scan != nil {\n\t\t// if input.Scan is used then ScanMetadataOptions is included in the config file\n\t\t// this causes the values to not be read correctly\n\t\tc.SetInterface(config.DefaultScanSettings, input.Scan.ScanMetadataOptions)\n\t}\n\n\tif input.AutoTag != nil {\n\t\tc.SetInterface(config.DefaultAutoTagSettings, input.AutoTag)\n\t}\n\n\tif input.Generate != nil {\n\t\tc.SetInterface(config.DefaultGenerateSettings, input.Generate)\n\t}\n\n\tr.setConfigBool(config.DeleteFileDefault, input.DeleteFile)\n\tr.setConfigBool(config.DeleteGeneratedDefault, input.DeleteGenerated)\n\n\tif err := c.Write(); err != nil {\n\t\treturn makeConfigDefaultsResult(), err\n\t}\n\n\treturn makeConfigDefaultsResult(), nil\n}\n\nfunc (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPIKeyInput) (string, error) {\n\tc := config.GetInstance()\n\n\tvar newAPIKey string\n\tif input.Clear == nil || !*input.Clear {\n\t\tusername := c.GetUsername()\n\t\tif username != \"\" {\n\t\t\tvar err error\n\t\t\tnewAPIKey, err = manager.GenerateAPIKey(username)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", err\n\t\t\t}\n\t\t}\n\t}\n\n\tc.SetString(config.ApiKey, newAPIKey)\n\tif err := c.Write(); err != nil {\n\t\treturn newAPIKey, err\n\t}\n\n\treturn newAPIKey, nil\n}\n\nfunc (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}, partial map[string]interface{}) (map[string]interface{}, error) {\n\tc := config.GetInstance()\n\n\tif input != nil {\n\t\t// #5483 - convert JSON numbers to float64 or int64\n\t\tinput = convertMapJSONNumbers(input)\n\t\tc.SetUIConfiguration(input)\n\t}\n\n\tif partial != nil {\n\t\t// #5483 - convert JSON numbers to float64 or int64\n\t\tpartial = convertMapJSONNumbers(partial)\n\t\t// merge partial into existing config\n\t\texisting := c.GetUIConfiguration()\n\t\tutils.MergeMaps(existing, partial)\n\t\tc.SetUIConfiguration(existing)\n\t}\n\n\tif err := c.Write(); err != nil {\n\t\treturn c.GetUIConfiguration(), err\n\t}\n\n\treturn c.GetUIConfiguration(), nil\n}\n\nfunc (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) {\n\tc := config.GetInstance()\n\n\tcfg := utils.NestedMap(c.GetUIConfiguration())\n\n\t// #5483 - convert JSON numbers to float64 or int64\n\tif m, ok := value.(map[string]interface{}); ok {\n\t\tvalue = convertMapJSONNumbers(m)\n\t} else if n, ok := value.(json.Number); ok {\n\t\tvalue = jsonNumberToNumber(n)\n\t}\n\n\tcfg.Set(key, value)\n\n\treturn r.ConfigureUI(ctx, cfg, nil)\n}\n\nfunc (r *mutationResolver) ConfigurePlugin(ctx context.Context, pluginID string, input map[string]interface{}) (map[string]interface{}, error) {\n\tc := config.GetInstance()\n\n\t// #5483 - convert JSON numbers to float64 or int64\n\tinput = convertMapJSONNumbers(input)\n\tc.SetPluginConfiguration(pluginID, input)\n\n\tif err := c.Write(); err != nil {\n\t\treturn c.GetPluginConfiguration(pluginID), err\n\t}\n\n\treturn c.GetPluginConfiguration(pluginID), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_dlna.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n)\n\nfunc (r *mutationResolver) EnableDlna(ctx context.Context, input EnableDLNAInput) (bool, error) {\n\terr := manager.GetInstance().DLNAService.Start(parseMinutes(input.Duration))\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) DisableDlna(ctx context.Context, input DisableDLNAInput) (bool, error) {\n\tmanager.GetInstance().DLNAService.Stop(parseMinutes(input.Duration))\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) AddTempDlnaip(ctx context.Context, input AddTempDLNAIPInput) (bool, error) {\n\tmanager.GetInstance().DLNAService.AddTempDLNAIP(input.Address, parseMinutes(input.Duration))\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) RemoveTempDlnaip(ctx context.Context, input RemoveTempDLNAIPInput) (bool, error) {\n\tret := manager.GetInstance().DLNAService.RemoveTempDLNAIP(input.Address)\n\treturn ret, nil\n}\n\nfunc parseMinutes(minutes *int) *time.Duration {\n\tvar ret *time.Duration\n\tif minutes != nil {\n\t\td := time.Duration(*minutes) * time.Minute\n\t\tret = &d\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_file.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/desktop\"\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/session\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n)\n\nfunc (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) (bool, error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tfileStore := r.repository.File\n\t\tfolderStore := r.repository.Folder\n\t\tmover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths())\n\t\tmover.RegisterHooks(ctx)\n\n\t\tvar (\n\t\t\tfolder   *models.Folder\n\t\t\tbasename string\n\t\t)\n\n\t\tfileIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"converting ids: %w\", err)\n\t\t}\n\n\t\tswitch {\n\t\tcase input.DestinationFolderID != nil:\n\t\t\tvar err error\n\n\t\t\tfolderID, err := strconv.Atoi(*input.DestinationFolderID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"converting destination folder id: %w\", err)\n\t\t\t}\n\n\t\t\tfolder, err = folderStore.Find(ctx, models.FolderID(folderID))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"finding destination folder: %w\", err)\n\t\t\t}\n\n\t\t\tif folder == nil {\n\t\t\t\treturn fmt.Errorf(\"folder with id %d not found\", input.DestinationFolderID)\n\t\t\t}\n\n\t\t\tif folder.ZipFileID != nil {\n\t\t\t\treturn fmt.Errorf(\"cannot move to %s, is in a zip file\", folder.Path)\n\t\t\t}\n\t\tcase input.DestinationFolder != nil:\n\t\t\tfolderPath := *input.DestinationFolder\n\n\t\t\t// ensure folder path is within the library\n\t\t\tstashPaths := manager.GetInstance().Config.GetStashPaths()\n\t\t\tif err := r.validateFolderPath(stashPaths, folderPath); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// get or create folder hierarchy\n\t\t\tvar err error\n\t\t\tfolder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths())\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting or creating folder hierarchy: %w\", err)\n\t\t\t}\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"must specify destination folder or path\")\n\t\t}\n\n\t\tif input.DestinationBasename != nil {\n\t\t\t// ensure only one file was supplied\n\t\t\tif len(input.Ids) != 1 {\n\t\t\t\treturn fmt.Errorf(\"must specify one file when providing destination path\")\n\t\t\t}\n\n\t\t\tbasename = *input.DestinationBasename\n\t\t}\n\n\t\t// create the folder hierarchy in the filesystem if needed\n\t\tif err := mover.CreateFolderHierarchy(folder.Path); err != nil {\n\t\t\treturn fmt.Errorf(\"creating folder hierarchy %s in filesystem: %w\", folder.Path, err)\n\t\t}\n\n\t\tfor _, fileIDInt := range fileIDs {\n\t\t\tfileID := models.FileID(fileIDInt)\n\t\t\tf, err := fileStore.Find(ctx, fileID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"finding file %d: %w\", fileID, err)\n\t\t\t}\n\n\t\t\t// ensure that the file extension matches the existing file type\n\t\t\tif basename != \"\" {\n\t\t\t\tif err := r.validateFileExtension(f[0].Base().Basename, basename); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := mover.Move(ctx, f[0], folder, basename); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error {\n\tif l := paths.GetStashFromDirPath(folderPath); l == nil {\n\t\treturn fmt.Errorf(\"folder path %s must be within a stash library path\", folderPath)\n\t}\n\n\treturn nil\n}\n\nfunc (r *mutationResolver) validateFileExtension(oldBasename, newBasename string) error {\n\tc := manager.GetInstance().Config\n\tif err := r.validateFileExtensionList(c.GetVideoExtensions(), oldBasename, newBasename); err != nil {\n\t\treturn err\n\t}\n\n\tif err := r.validateFileExtensionList(c.GetImageExtensions(), oldBasename, newBasename); err != nil {\n\t\treturn err\n\t}\n\n\tif err := r.validateFileExtensionList(c.GetGalleryExtensions(), oldBasename, newBasename); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (r *mutationResolver) validateFileExtensionList(exts []string, oldBasename, newBasename string) error {\n\tif fsutil.MatchExtension(oldBasename, exts) && !fsutil.MatchExtension(newBasename, exts) {\n\t\treturn fmt.Errorf(\"file extension for %s is inconsistent with old filename %s\", newBasename, oldBasename)\n\t}\n\n\treturn nil\n}\n\nfunc (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret bool, err error) {\n\tfileIDs, err := stringslice.StringSliceToIntSlice(ids)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttrashPath := manager.GetInstance().Config.GetDeleteTrashPath()\n\n\tfileDeleter := file.NewDeleterWithTrash(trashPath)\n\tdestroyer := &file.ZipDestroyer{\n\t\tFileDestroyer:   r.repository.File,\n\t\tFolderDestroyer: r.repository.Folder,\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.File\n\n\t\tfor _, fileIDInt := range fileIDs {\n\t\t\tfileID := models.FileID(fileIDInt)\n\t\t\tf, err := qb.Find(ctx, fileID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tpath := f[0].Base().Path\n\n\t\t\t// ensure not a primary file\n\t\t\tisPrimary, err := qb.IsPrimary(ctx, fileID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"checking if file %s is primary: %w\", path, err)\n\t\t\t}\n\n\t\t\tif isPrimary {\n\t\t\t\treturn fmt.Errorf(\"cannot delete primary file %s\", path)\n\t\t\t}\n\n\t\t\t// destroy files in zip file\n\t\t\tinZip, err := qb.FindByZipFileID(ctx, fileID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"finding zip file contents for %s: %w\", path, err)\n\t\t\t}\n\n\t\t\tfor _, ff := range inZip {\n\t\t\t\tconst deleteFileInZip = false\n\t\t\t\tif err := file.Destroy(ctx, qb, ff, fileDeleter, deleteFileInZip); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"destroying file %s: %w\", ff.Base().Path, err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst deleteFile = true\n\t\t\tif err := destroyer.DestroyZip(ctx, f[0], fileDeleter, deleteFile); err != nil {\n\t\t\t\treturn fmt.Errorf(\"deleting file %s: %w\", path, err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tfileDeleter.Rollback()\n\t\treturn false, err\n\t}\n\n\t// perform the post-commit actions\n\tfileDeleter.Commit()\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) DestroyFiles(ctx context.Context, ids []string) (ret bool, err error) {\n\tfileIDs, err := stringslice.StringSliceToIntSlice(ids)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\tdestroyer := &file.ZipDestroyer{\n\t\tFileDestroyer:   r.repository.File,\n\t\tFolderDestroyer: r.repository.Folder,\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.File\n\n\t\tfor _, fileIDInt := range fileIDs {\n\t\t\tfileID := models.FileID(fileIDInt)\n\t\t\tf, err := qb.Find(ctx, fileID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(f) == 0 {\n\t\t\t\treturn fmt.Errorf(\"file with id %d not found\", fileID)\n\t\t\t}\n\n\t\t\tpath := f[0].Base().Path\n\n\t\t\t// ensure not a primary file\n\t\t\tisPrimary, err := qb.IsPrimary(ctx, fileID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"checking if file %s is primary: %w\", path, err)\n\t\t\t}\n\n\t\t\tif isPrimary {\n\t\t\t\treturn fmt.Errorf(\"cannot destroy primary file entry %s\", path)\n\t\t\t}\n\n\t\t\t// destroy DB entries only (no filesystem deletion)\n\t\t\tconst deleteFile = false\n\t\t\tif err := destroyer.DestroyZip(ctx, f[0], nil, deleteFile); err != nil {\n\t\t\t\treturn fmt.Errorf(\"destroying file entry %s: %w\", path, err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) {\n\tfileIDInt, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tfileID := models.FileID(fileIDInt)\n\n\t// determine what we're doing\n\tvar (\n\t\tfingerprints []models.Fingerprint\n\t\ttoDelete     []string\n\t)\n\n\tfor _, i := range input.Fingerprints {\n\t\tif i.Type == models.FingerprintTypeMD5 || i.Type == models.FingerprintTypeOshash {\n\t\t\treturn false, fmt.Errorf(\"cannot modify %s fingerprint\", i.Type)\n\t\t}\n\n\t\tif i.Value == nil {\n\t\t\ttoDelete = append(toDelete, i.Type)\n\t\t} else {\n\t\t\t// phashes need to be converted from string into uint64\n\t\t\tvar v interface{}\n\t\t\tv = *i.Value\n\n\t\t\tif i.Type == models.FingerprintTypePhash {\n\t\t\t\tvInt, err := strconv.ParseUint(*i.Value, 16, 64)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn false, fmt.Errorf(\"converting phash %s: %w\", *i.Value, err)\n\t\t\t\t}\n\n\t\t\t\tv = vInt\n\t\t\t}\n\n\t\t\tfingerprints = append(fingerprints, models.Fingerprint{\n\t\t\t\tType:        i.Type,\n\t\t\t\tFingerprint: v,\n\t\t\t})\n\t\t}\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.File\n\n\t\tif len(fingerprints) > 0 {\n\t\t\tif err := qb.ModifyFingerprints(ctx, fileID, fingerprints); err != nil {\n\t\t\t\treturn fmt.Errorf(\"modifying fingerprints: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tif len(toDelete) > 0 {\n\t\t\tif err := qb.DestroyFingerprints(ctx, fileID, toDelete); err != nil {\n\t\t\t\treturn fmt.Errorf(\"destroying fingerprints: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) RevealFileInFileManager(ctx context.Context, id string) (bool, error) {\n\t// disallow if request did not come from localhost\n\tif !session.IsLocalRequest(ctx) {\n\t\tlogger.Warnf(\"Attempt to reveal file in file manager from non-local request\")\n\t\treturn false, fmt.Errorf(\"access denied\")\n\t}\n\n\tfileIDInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar filePath string\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tfiles, err := r.repository.File.Find(ctx, models.FileID(fileIDInt))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding file: %w\", err)\n\t\t}\n\t\tif len(files) == 0 {\n\t\t\treturn fmt.Errorf(\"file with id %d not found\", fileIDInt)\n\t\t}\n\t\tfilePath = files[0].Base().Path\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tif err := desktop.RevealInFileManager(filePath); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) RevealFolderInFileManager(ctx context.Context, id string) (bool, error) {\n\t// disallow if request did not come from localhost\n\tif !session.IsLocalRequest(ctx) {\n\t\tlogger.Warnf(\"Attempt to reveal folder in file manager from non-local request\")\n\t\treturn false, fmt.Errorf(\"access denied\")\n\t}\n\n\tfolderIDInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar folderPath string\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tfolder, err := r.repository.Folder.Find(ctx, models.FolderID(folderIDInt))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding folder: %w\", err)\n\t\t}\n\t\tif folder == nil {\n\t\t\treturn fmt.Errorf(\"folder with id %d not found\", folderIDInt)\n\t\t}\n\t\tfolderPath = folder.Path\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tif err := desktop.RevealInFileManager(folderPath); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_gallery.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\n// used to refetch gallery after hooks run\nfunc (r *mutationResolver) getGallery(ctx context.Context, id int) (ret *models.Gallery, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Gallery.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreateInput) (*models.Gallery, error) {\n\t// name must be provided\n\tif input.Title == \"\" {\n\t\treturn nil, errors.New(\"title must not be empty\")\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate a new gallery from the input\n\tnewGallery := models.CreateGalleryInput{\n\t\tGallery: &models.Gallery{},\n\t}\n\t*newGallery.Gallery = models.NewGallery()\n\n\tnewGallery.Title = strings.TrimSpace(input.Title)\n\tnewGallery.Code = translator.string(input.Code)\n\tnewGallery.Details = translator.string(input.Details)\n\tnewGallery.Photographer = translator.string(input.Photographer)\n\tnewGallery.Rating = input.Rating100\n\tnewGallery.Organized = translator.bool(input.Organized)\n\n\tvar err error\n\n\tnewGallery.Date, err = translator.datePtr(input.Date)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tnewGallery.StudioID, err = translator.intPtrFromString(input.StudioID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tnewGallery.PerformerIDs, err = translator.relatedIds(input.PerformerIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting performer ids: %w\", err)\n\t}\n\tnewGallery.TagIDs, err = translator.relatedIds(input.TagIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\tnewGallery.SceneIDs, err = translator.relatedIds(input.SceneIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting scene ids: %w\", err)\n\t}\n\n\tif input.Urls != nil {\n\t\tnewGallery.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))\n\t} else if input.URL != nil {\n\t\tnewGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})\n\t}\n\n\tnewGallery.CustomFields = convertMapJSONNumbers(input.CustomFields)\n\n\t// Start the transaction and save the gallery\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Gallery\n\t\tif err := qb.Create(ctx, &newGallery); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, input, nil)\n\treturn r.getGallery(ctx, newGallery.ID)\n}\n\nfunc (r *mutationResolver) GalleryUpdate(ctx context.Context, input models.GalleryUpdateInput) (ret *models.Gallery, err error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Start the transaction and save the gallery\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.galleryUpdate(ctx, input, translator)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside txn\n\tr.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.GalleryUpdatePost, input, translator.getFields())\n\treturn r.getGallery(ctx, ret.ID)\n}\n\nfunc (r *mutationResolver) GalleriesUpdate(ctx context.Context, input []*models.GalleryUpdateInput) (ret []*models.Gallery, err error) {\n\tinputMaps := getUpdateInputMaps(ctx)\n\n\t// Start the transaction and save the galleries\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tfor i, gallery := range input {\n\t\t\ttranslator := changesetTranslator{\n\t\t\t\tinputMap: inputMaps[i],\n\t\t\t}\n\n\t\t\tthisGallery, err := r.galleryUpdate(ctx, *gallery, translator)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, thisGallery)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside txn\n\tvar newRet []*models.Gallery\n\tfor i, gallery := range ret {\n\t\ttranslator := changesetTranslator{\n\t\t\tinputMap: inputMaps[i],\n\t\t}\n\n\t\tr.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryUpdatePost, input, translator.getFields())\n\n\t\tgallery, err = r.getGallery(ctx, gallery.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, gallery)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) galleryUpdate(ctx context.Context, input models.GalleryUpdateInput, translator changesetTranslator) (*models.Gallery, error) {\n\tgalleryID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tqb := r.repository.Gallery\n\n\toriginalGallery, err := qb.Find(ctx, galleryID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif originalGallery == nil {\n\t\treturn nil, fmt.Errorf(\"gallery with id %d not found\", galleryID)\n\t}\n\n\t// Populate gallery from the input\n\tupdatedGallery := models.NewGalleryPartial()\n\n\tif input.Title != nil {\n\t\t// ensure title is not empty\n\t\tif *input.Title == \"\" && originalGallery.IsUserCreated() {\n\t\t\treturn nil, errors.New(\"title must not be empty for user-created galleries\")\n\t\t}\n\n\t\tupdatedGallery.Title = models.NewOptionalString(*input.Title)\n\t}\n\n\tupdatedGallery.Code = translator.optionalString(input.Code, \"code\")\n\tupdatedGallery.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedGallery.Photographer = translator.optionalString(input.Photographer, \"photographer\")\n\tupdatedGallery.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedGallery.Organized = translator.optionalBool(input.Organized, \"organized\")\n\n\tupdatedGallery.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tupdatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tupdatedGallery.URLs = translator.optionalURLs(input.Urls, input.URL)\n\n\tupdatedGallery.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting primary file id: %w\", err)\n\t}\n\tif updatedGallery.PrimaryFileID != nil {\n\t\tprimaryFileID := *updatedGallery.PrimaryFileID\n\n\t\tif err := originalGallery.LoadFiles(ctx, r.repository.Gallery); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// ensure that new primary file is associated with gallery\n\t\tvar f models.File\n\t\tfor _, ff := range originalGallery.Files.List() {\n\t\t\tif ff.Base().ID == primaryFileID {\n\t\t\t\tf = ff\n\t\t\t}\n\t\t}\n\n\t\tif f == nil {\n\t\t\treturn nil, fmt.Errorf(\"file with id %d not associated with gallery\", primaryFileID)\n\t\t}\n\t}\n\n\tupdatedGallery.PerformerIDs, err = translator.updateIds(input.PerformerIds, \"performer_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting performer ids: %w\", err)\n\t}\n\tupdatedGallery.TagIDs, err = translator.updateIds(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\tupdatedGallery.SceneIDs, err = translator.updateIds(input.SceneIds, \"scene_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting scene ids: %w\", err)\n\t}\n\n\tif input.CustomFields != nil {\n\t\tupdatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields)\n\t}\n\n\t// gallery scene is set from the scene only\n\n\tgallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn gallery, nil\n}\n\nfunc (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGalleryUpdateInput) ([]*models.Gallery, error) {\n\tgalleryIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate gallery from the input\n\tupdatedGallery := models.NewGalleryPartial()\n\n\tupdatedGallery.Code = translator.optionalString(input.Code, \"code\")\n\tupdatedGallery.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedGallery.Photographer = translator.optionalString(input.Photographer, \"photographer\")\n\tupdatedGallery.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedGallery.Organized = translator.optionalBool(input.Organized, \"organized\")\n\tupdatedGallery.URLs = translator.optionalURLsBulk(input.Urls, input.URL)\n\n\tupdatedGallery.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tupdatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tupdatedGallery.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, \"performer_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting performer ids: %w\", err)\n\t}\n\tupdatedGallery.TagIDs, err = translator.updateIdsBulk(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\tupdatedGallery.SceneIDs, err = translator.updateIdsBulk(input.SceneIds, \"scene_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting scene ids: %w\", err)\n\t}\n\n\tif input.CustomFields != nil {\n\t\tupdatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields)\n\t}\n\n\tret := []*models.Gallery{}\n\n\t// Start the transaction and save the galleries\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Gallery\n\n\t\tfor _, galleryID := range galleryIDs {\n\t\t\tgallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, gallery)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside of txn\n\tvar newRet []*models.Gallery\n\tfor _, gallery := range ret {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryUpdatePost, input, translator.getFields())\n\n\t\tgallery, err := r.getGallery(ctx, gallery.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, gallery)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.GalleryDestroyInput) (bool, error) {\n\tgalleryIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttrashPath := manager.GetInstance().Config.GetDeleteTrashPath()\n\n\tvar galleries []*models.Gallery\n\tvar imgsDestroyed []*models.Image\n\tfileDeleter := &image.FileDeleter{\n\t\tDeleter: file.NewDeleterWithTrash(trashPath),\n\t\tPaths:   manager.GetInstance().Paths,\n\t}\n\n\tdeleteGenerated := utils.IsTrue(input.DeleteGenerated)\n\tdeleteFile := utils.IsTrue(input.DeleteFile)\n\tdestroyFileEntry := utils.IsTrue(input.DestroyFileEntry)\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Gallery\n\n\t\tfor _, id := range galleryIDs {\n\t\t\tgallery, err := qb.Find(ctx, id)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif gallery == nil {\n\t\t\t\treturn fmt.Errorf(\"gallery with id %d not found\", id)\n\t\t\t}\n\n\t\t\tif err := gallery.LoadFiles(ctx, qb); err != nil {\n\t\t\t\treturn fmt.Errorf(\"loading files for gallery %d\", id)\n\t\t\t}\n\n\t\t\tgalleries = append(galleries, gallery)\n\n\t\t\timgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tfileDeleter.Rollback()\n\t\treturn false, err\n\t}\n\n\t// perform the post-commit actions\n\tfileDeleter.Commit()\n\n\tfor _, gallery := range galleries {\n\t\t// don't delete stash library paths\n\t\tpath := gallery.Path\n\t\tif deleteFile && path != \"\" && !isStashPath(path) {\n\t\t\t// try to remove the folder - it is possible that it is not empty\n\t\t\t// so swallow the error if present\n\t\t\t_ = os.Remove(path)\n\t\t}\n\t}\n\n\t// call post hook after performing the other actionsa\n\tfor _, gallery := range galleries {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{\n\t\t\tGalleryDestroyInput: input,\n\t\t\tChecksum:            gallery.PrimaryChecksum(),\n\t\t\tPath:                gallery.Path,\n\t\t}, nil)\n\t}\n\n\t// call image destroy post hook as well\n\tfor _, img := range imgsDestroyed {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, img.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{\n\t\t\tChecksum: img.Checksum,\n\t\t\tPath:     img.Path,\n\t\t}, nil)\n\t}\n\n\treturn true, nil\n}\n\nfunc isStashPath(path string) bool {\n\tstashConfigs := manager.GetInstance().Config.GetStashPaths()\n\tfor _, config := range stashConfigs {\n\t\tif path == config.Path {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (r *mutationResolver) AddGalleryImages(ctx context.Context, input GalleryAddInput) (bool, error) {\n\tgalleryID, err := strconv.Atoi(input.GalleryID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting gallery id: %w\", err)\n\t}\n\n\timageIDs, err := stringslice.StringSliceToIntSlice(input.ImageIds)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting image ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Gallery\n\t\tgallery, err := qb.Find(ctx, galleryID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif gallery == nil {\n\t\t\treturn fmt.Errorf(\"gallery with id %d not found\", galleryID)\n\t\t}\n\n\t\treturn r.galleryService.AddImages(ctx, gallery, imageIDs...)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input GalleryRemoveInput) (bool, error) {\n\tgalleryID, err := strconv.Atoi(input.GalleryID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting gallery id: %w\", err)\n\t}\n\n\timageIDs, err := stringslice.StringSliceToIntSlice(input.ImageIds)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting image ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Gallery\n\t\tgallery, err := qb.Find(ctx, galleryID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif gallery == nil {\n\t\t\treturn fmt.Errorf(\"gallery with id %d not found\", galleryID)\n\t\t}\n\n\t\treturn r.galleryService.RemoveImages(ctx, gallery, imageIDs...)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) SetGalleryCover(ctx context.Context, input GallerySetCoverInput) (bool, error) {\n\tgalleryID, err := strconv.Atoi(input.GalleryID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting gallery id: %w\", err)\n\t}\n\n\tcoverImageID, err := strconv.Atoi(input.CoverImageID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting cover image id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Gallery\n\t\tgallery, err := qb.Find(ctx, galleryID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif gallery == nil {\n\t\t\treturn fmt.Errorf(\"gallery with id %d not found\", galleryID)\n\t\t}\n\n\t\treturn r.galleryService.SetCover(ctx, gallery, coverImageID)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) ResetGalleryCover(ctx context.Context, input GalleryResetCoverInput) (bool, error) {\n\tgalleryID, err := strconv.Atoi(input.GalleryID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting gallery id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Gallery\n\t\tgallery, err := qb.Find(ctx, galleryID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif gallery == nil {\n\t\t\treturn fmt.Errorf(\"gallery with id %d not found\", galleryID)\n\t\t}\n\n\t\treturn r.galleryService.ResetCover(ctx, gallery)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) getGalleryChapter(ctx context.Context, id int) (ret *models.GalleryChapter, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.GalleryChapter.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) GalleryChapterCreate(ctx context.Context, input GalleryChapterCreateInput) (*models.GalleryChapter, error) {\n\tgalleryID, err := strconv.Atoi(input.GalleryID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting gallery id: %w\", err)\n\t}\n\n\t// Populate a new gallery chapter from the input\n\tnewChapter := models.NewGalleryChapter()\n\n\tnewChapter.Title = input.Title\n\tnewChapter.ImageIndex = input.ImageIndex\n\tnewChapter.GalleryID = galleryID\n\n\t// Start the transaction and save the gallery chapter\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\timageCount, err := r.repository.Image.CountByGalleryID(ctx, galleryID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Sanity Check of Index\n\t\tif newChapter.ImageIndex > imageCount || newChapter.ImageIndex < 1 {\n\t\t\treturn errors.New(\"Image # must greater than zero and in range of the gallery images\")\n\t\t}\n\n\t\treturn r.repository.GalleryChapter.Create(ctx, &newChapter)\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, newChapter.ID, hook.GalleryChapterCreatePost, input, nil)\n\treturn r.getGalleryChapter(ctx, newChapter.ID)\n}\n\nfunc (r *mutationResolver) GalleryChapterUpdate(ctx context.Context, input GalleryChapterUpdateInput) (*models.GalleryChapter, error) {\n\tchapterID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate gallery chapter from the input\n\tupdatedChapter := models.NewGalleryChapterPartial()\n\n\tupdatedChapter.Title = translator.optionalString(input.Title, \"title\")\n\tupdatedChapter.ImageIndex = translator.optionalInt(input.ImageIndex, \"image_index\")\n\tupdatedChapter.GalleryID, err = translator.optionalIntFromString(input.GalleryID, \"gallery_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting gallery id: %w\", err)\n\t}\n\n\t// Start the transaction and save the gallery chapter\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.GalleryChapter\n\n\t\texistingChapter, err := qb.Find(ctx, chapterID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif existingChapter == nil {\n\t\t\treturn fmt.Errorf(\"gallery chapter with id %d not found\", chapterID)\n\t\t}\n\n\t\tgalleryID := existingChapter.GalleryID\n\t\timageIndex := existingChapter.ImageIndex\n\n\t\tif updatedChapter.GalleryID.Set {\n\t\t\tgalleryID = updatedChapter.GalleryID.Value\n\t\t}\n\t\tif updatedChapter.ImageIndex.Set {\n\t\t\timageIndex = updatedChapter.ImageIndex.Value\n\t\t}\n\n\t\timageCount, err := r.repository.Image.CountByGalleryID(ctx, galleryID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Sanity Check of Index\n\t\tif imageIndex > imageCount || imageIndex < 1 {\n\t\t\treturn errors.New(\"Image # must greater than zero and in range of the gallery images\")\n\t\t}\n\n\t\t_, err = qb.UpdatePartial(ctx, chapterID, updatedChapter)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, chapterID, hook.GalleryChapterUpdatePost, input, translator.getFields())\n\treturn r.getGalleryChapter(ctx, chapterID)\n}\n\nfunc (r *mutationResolver) GalleryChapterDestroy(ctx context.Context, id string) (bool, error) {\n\tchapterID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.GalleryChapter\n\n\t\tchapter, err := qb.Find(ctx, chapterID)\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif chapter == nil {\n\t\t\treturn fmt.Errorf(\"gallery chapter with id %d not found\", chapterID)\n\t\t}\n\n\t\treturn gallery.DestroyChapter(ctx, chapter, qb)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, chapterID, hook.GalleryChapterDestroyPost, id, nil)\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_group.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/group\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.CreateGroupInput, error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate a new group from the input\n\tnewGroupInput := &models.CreateGroupInput{\n\t\tGroup: &models.Group{},\n\t}\n\t*newGroupInput.Group = models.NewGroup()\n\tnewGroup := newGroupInput.Group\n\n\tnewGroup.Name = strings.TrimSpace(input.Name)\n\tnewGroup.Aliases = translator.string(input.Aliases)\n\tnewGroup.Duration = input.Duration\n\tnewGroup.Rating = input.Rating100\n\tnewGroup.Director = translator.string(input.Director)\n\tnewGroup.Synopsis = translator.string(input.Synopsis)\n\n\tvar err error\n\n\tnewGroup.Date, err = translator.datePtr(input.Date)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tnewGroup.StudioID, err = translator.intPtrFromString(input.StudioID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tnewGroup.TagIDs, err = translator.relatedIds(input.TagIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tnewGroup.ContainingGroups, err = translator.groupIDDescriptions(input.ContainingGroups)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting containing group ids: %w\", err)\n\t}\n\n\tnewGroup.SubGroups, err = translator.groupIDDescriptions(input.SubGroups)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting containing group ids: %w\", err)\n\t}\n\n\tif input.Urls != nil {\n\t\tnewGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))\n\t}\n\n\tnewGroupInput.CustomFields = convertMapJSONNumbers(input.CustomFields)\n\n\t// Process the base 64 encoded image string\n\tif input.FrontImage != nil {\n\t\tnewGroupInput.FrontImageData, err = utils.ProcessImageInput(ctx, *input.FrontImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing front image: %w\", err)\n\t\t}\n\t}\n\n\t// Process the base 64 encoded image string\n\tif input.BackImage != nil {\n\t\tnewGroupInput.BackImageData, err = utils.ProcessImageInput(ctx, *input.BackImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing back image: %w\", err)\n\t\t}\n\t}\n\n\t// HACK: if back image is being set, set the front image to the default.\n\t// This is because we can't have a null front image with a non-null back image.\n\tif len(newGroupInput.FrontImageData) == 0 && len(newGroupInput.BackImageData) != 0 {\n\t\tnewGroupInput.FrontImageData = static.ReadAll(static.DefaultGroupImage)\n\t}\n\n\treturn newGroupInput, nil\n}\n\nfunc (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) {\n\tcreateGroupInput, err := groupFromGroupCreateInput(ctx, input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Start the transaction and save the group\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tif err = r.groupService.Create(ctx, createGroupInput); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// for backwards compatibility - run both movie and group hooks\n\tr.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.GroupCreatePost, input, nil)\n\tr.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.MovieCreatePost, input, nil)\n\treturn r.getGroup(ctx, createGroupInput.Group.ID)\n}\n\nfunc groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) {\n\t// Populate group from the input\n\tupdatedGroup := models.NewGroupPartial()\n\n\tupdatedGroup.Name = translator.optionalString(input.Name, \"name\")\n\tupdatedGroup.Aliases = translator.optionalString(input.Aliases, \"aliases\")\n\tupdatedGroup.Duration = translator.optionalInt(input.Duration, \"duration\")\n\tupdatedGroup.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedGroup.Director = translator.optionalString(input.Director, \"director\")\n\tupdatedGroup.Synopsis = translator.optionalString(input.Synopsis, \"synopsis\")\n\n\tupdatedGroup.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting date: %w\", err)\n\t\treturn\n\t}\n\tupdatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting studio id: %w\", err)\n\t\treturn\n\t}\n\n\tupdatedGroup.TagIDs, err = translator.updateIds(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting tag ids: %w\", err)\n\t\treturn\n\t}\n\n\tupdatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptions(input.ContainingGroups, \"containing_groups\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting containing group ids: %w\", err)\n\t\treturn\n\t}\n\n\tupdatedGroup.SubGroups, err = translator.updateGroupIDDescriptions(input.SubGroups, \"sub_groups\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting containing group ids: %w\", err)\n\t\treturn\n\t}\n\n\tupdatedGroup.URLs = translator.updateStrings(input.Urls, \"urls\")\n\tif input.CustomFields != nil {\n\t\tupdatedGroup.CustomFields = *input.CustomFields\n\t\t// convert json.Numbers to int/float\n\t\tupdatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full)\n\t\tupdatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial)\n\t}\n\n\treturn updatedGroup, nil\n}\n\nfunc (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInput) (*models.Group, error) {\n\tgroupID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\tupdatedGroup, err := groupPartialFromGroupUpdateInput(translator, input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar frontimageData []byte\n\tfrontImageIncluded := translator.hasField(\"front_image\")\n\tif input.FrontImage != nil {\n\t\tfrontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing front image: %w\", err)\n\t\t}\n\t}\n\n\tvar backimageData []byte\n\tbackImageIncluded := translator.hasField(\"back_image\")\n\tif input.BackImage != nil {\n\t\tbackimageData, err = utils.ProcessImageInput(ctx, *input.BackImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing back image: %w\", err)\n\t\t}\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tfrontImage := group.ImageInput{\n\t\t\tImage: frontimageData,\n\t\t\tSet:   frontImageIncluded,\n\t\t}\n\n\t\tbackImage := group.ImageInput{\n\t\t\tImage: backimageData,\n\t\t\tSet:   backImageIncluded,\n\t\t}\n\n\t\t_, err = r.groupService.UpdatePartial(ctx, groupID, updatedGroup, frontImage, backImage)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// for backwards compatibility - run both movie and group hooks\n\tr.hookExecutor.ExecutePostHooks(ctx, groupID, hook.GroupUpdatePost, input, translator.getFields())\n\tr.hookExecutor.ExecutePostHooks(ctx, groupID, hook.MovieUpdatePost, input, translator.getFields())\n\treturn r.getGroup(ctx, groupID)\n}\n\nfunc groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) {\n\tupdatedGroup := models.NewGroupPartial()\n\n\tupdatedGroup.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting date: %w\", err)\n\t\treturn\n\t}\n\tupdatedGroup.Synopsis = translator.optionalString(input.Synopsis, \"synopsis\")\n\tupdatedGroup.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedGroup.Director = translator.optionalString(input.Director, \"director\")\n\n\tupdatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting studio id: %w\", err)\n\t\treturn\n\t}\n\n\tupdatedGroup.TagIDs, err = translator.updateIdsBulk(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting tag ids: %w\", err)\n\t\treturn\n\t}\n\n\tupdatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptionsBulk(input.ContainingGroups, \"containing_groups\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting containing group ids: %w\", err)\n\t\treturn\n\t}\n\n\tupdatedGroup.SubGroups, err = translator.updateGroupIDDescriptionsBulk(input.SubGroups, \"sub_groups\")\n\tif err != nil {\n\t\terr = fmt.Errorf(\"converting containing group ids: %w\", err)\n\t\treturn\n\t}\n\n\tupdatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil)\n\n\tif input.CustomFields != nil {\n\t\tupdatedGroup.CustomFields = *input.CustomFields\n\t\t// convert json.Numbers to int/float\n\t\tupdatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full)\n\t\tupdatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial)\n\t}\n\n\treturn updatedGroup, nil\n}\n\nfunc (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupUpdateInput) ([]*models.Group, error) {\n\tgroupIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate group from the input\n\tupdatedGroup, err := groupPartialFromBulkGroupUpdateInput(translator, input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := []*models.Group{}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tfor _, groupID := range groupIDs {\n\t\t\tgroup, err := r.groupService.UpdatePartial(ctx, groupID, updatedGroup, group.ImageInput{}, group.ImageInput{})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, group)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar newRet []*models.Group\n\tfor _, group := range ret {\n\t\t// for backwards compatibility - run both movie and group hooks\n\t\tr.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields())\n\t\tr.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields())\n\n\t\tgroup, err = r.getGroup(ctx, group.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, group)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) GroupDestroy(ctx context.Context, input GroupDestroyInput) (bool, error) {\n\tid, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.repository.Group.Destroy(ctx, id)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\t// for backwards compatibility - run both movie and group hooks\n\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, input, nil)\n\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil)\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) GroupsDestroy(ctx context.Context, groupIDs []string) (bool, error) {\n\tids, err := stringslice.StringSliceToIntSlice(groupIDs)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Group\n\t\tfor _, id := range ids {\n\t\t\tif err := qb.Destroy(ctx, id); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tfor _, id := range ids {\n\t\t// for backwards compatibility - run both movie and group hooks\n\t\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, groupIDs, nil)\n\t\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, groupIDs, nil)\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) AddGroupSubGroups(ctx context.Context, input GroupSubGroupAddInput) (bool, error) {\n\tgroupID, err := strconv.Atoi(input.ContainingGroupID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting group id: %w\", err)\n\t}\n\n\tsubGroups, err := groupsDescriptionsFromGroupInput(input.SubGroups)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting sub group ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.groupService.AddSubGroups(ctx, groupID, subGroups, input.InsertIndex)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) RemoveGroupSubGroups(ctx context.Context, input GroupSubGroupRemoveInput) (bool, error) {\n\tgroupID, err := strconv.Atoi(input.ContainingGroupID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting group id: %w\", err)\n\t}\n\n\tsubGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting sub group ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.groupService.RemoveSubGroups(ctx, groupID, subGroupIDs)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) ReorderSubGroups(ctx context.Context, input ReorderSubGroupsInput) (bool, error) {\n\tgroupID, err := strconv.Atoi(input.GroupID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting group id: %w\", err)\n\t}\n\n\tsubGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting sub group ids: %w\", err)\n\t}\n\n\tinsertPointID, err := strconv.Atoi(input.InsertAtID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting insert at id: %w\", err)\n\t}\n\n\tinsertAfter := utils.IsTrue(input.InsertAfter)\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.groupService.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_image.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\n// used to refetch image after hooks run\nfunc (r *mutationResolver) getImage(ctx context.Context, id int) (ret *models.Image, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Image.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) ImageUpdate(ctx context.Context, input models.ImageUpdateInput) (ret *models.Image, err error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Start the transaction and save the image\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.imageUpdate(ctx, input, translator)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside txn\n\tr.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.ImageUpdatePost, input, translator.getFields())\n\treturn r.getImage(ctx, ret.ID)\n}\n\nfunc (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*models.ImageUpdateInput) (ret []*models.Image, err error) {\n\tinputMaps := getUpdateInputMaps(ctx)\n\n\t// Start the transaction and save the image\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tfor i, image := range input {\n\t\t\ttranslator := changesetTranslator{\n\t\t\t\tinputMap: inputMaps[i],\n\t\t\t}\n\n\t\t\tthisImage, err := r.imageUpdate(ctx, *image, translator)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, thisImage)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside txn\n\tvar newRet []*models.Image\n\tfor i, image := range ret {\n\t\ttranslator := changesetTranslator{\n\t\t\tinputMap: inputMaps[i],\n\t\t}\n\n\t\tr.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageUpdatePost, input, translator.getFields())\n\n\t\timage, err = r.getImage(ctx, image.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, image)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUpdateInput, translator changesetTranslator) (*models.Image, error) {\n\timageID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ti, err := r.repository.Image.Find(ctx, imageID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif i == nil {\n\t\treturn nil, fmt.Errorf(\"image with id %d not found\", imageID)\n\t}\n\n\t// Populate image from the input\n\tupdatedImage := models.NewImagePartial()\n\n\tupdatedImage.Title = translator.optionalString(input.Title, \"title\")\n\tupdatedImage.Code = translator.optionalString(input.Code, \"code\")\n\tupdatedImage.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedImage.Photographer = translator.optionalString(input.Photographer, \"photographer\")\n\tupdatedImage.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedImage.Organized = translator.optionalBool(input.Organized, \"organized\")\n\n\tupdatedImage.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tupdatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tupdatedImage.URLs = translator.optionalURLs(input.Urls, input.URL)\n\n\tupdatedImage.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting primary file id: %w\", err)\n\t}\n\tif updatedImage.PrimaryFileID != nil {\n\t\tprimaryFileID := *updatedImage.PrimaryFileID\n\n\t\tif err := i.LoadFiles(ctx, r.repository.Image); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// ensure that new primary file is associated with image\n\t\tvar f models.File\n\t\tfor _, ff := range i.Files.List() {\n\t\t\tif ff.Base().ID == primaryFileID {\n\t\t\t\tf = ff\n\t\t\t}\n\t\t}\n\n\t\tif f == nil {\n\t\t\treturn nil, fmt.Errorf(\"file with id %d not associated with image\", primaryFileID)\n\t\t}\n\t}\n\n\tvar updatedGalleryIDs []int\n\n\tupdatedImage.GalleryIDs, err = translator.updateIds(input.GalleryIds, \"gallery_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting gallery ids: %w\", err)\n\t}\n\tif updatedImage.GalleryIDs != nil {\n\t\t// ensure gallery IDs are loaded\n\t\tif err := i.LoadGalleryIDs(ctx, r.repository.Image); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif err := r.galleryService.ValidateImageGalleryChange(ctx, i, *updatedImage.GalleryIDs); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tupdatedGalleryIDs = updatedImage.GalleryIDs.ImpactedIDs(i.GalleryIDs.List())\n\t}\n\n\tupdatedImage.PerformerIDs, err = translator.updateIds(input.PerformerIds, \"performer_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting performer ids: %w\", err)\n\t}\n\tupdatedImage.TagIDs, err = translator.updateIds(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tif input.CustomFields != nil {\n\t\tupdatedImage.CustomFields = *input.CustomFields\n\t\t// convert json.Numbers to int/float\n\t\tupdatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full)\n\t\tupdatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial)\n\t}\n\n\tqb := r.repository.Image\n\timage, err := qb.UpdatePartial(ctx, imageID, updatedImage)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// #3759 - update all impacted galleries\n\tfor _, galleryID := range updatedGalleryIDs {\n\t\tif err := r.galleryService.Updated(ctx, galleryID); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"updating gallery %d: %w\", galleryID, err)\n\t\t}\n\t}\n\n\treturn image, nil\n}\n\nfunc (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageUpdateInput) (ret []*models.Image, err error) {\n\timageIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate image from the input\n\tupdatedImage := models.NewImagePartial()\n\n\tupdatedImage.Title = translator.optionalString(input.Title, \"title\")\n\tupdatedImage.Code = translator.optionalString(input.Code, \"code\")\n\tupdatedImage.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedImage.Photographer = translator.optionalString(input.Photographer, \"photographer\")\n\tupdatedImage.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedImage.Organized = translator.optionalBool(input.Organized, \"organized\")\n\n\tupdatedImage.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tupdatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tupdatedImage.URLs = translator.optionalURLsBulk(input.Urls, input.URL)\n\n\tupdatedImage.GalleryIDs, err = translator.updateIdsBulk(input.GalleryIds, \"gallery_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting gallery ids: %w\", err)\n\t}\n\tupdatedImage.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, \"performer_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting performer ids: %w\", err)\n\t}\n\tupdatedImage.TagIDs, err = translator.updateIdsBulk(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tif input.CustomFields != nil {\n\t\tupdatedImage.CustomFields = *input.CustomFields\n\t\t// convert json.Numbers to int/float\n\t\tupdatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full)\n\t\tupdatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial)\n\t}\n\n\t// Start the transaction and save the images\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tvar updatedGalleryIDs []int\n\t\tqb := r.repository.Image\n\n\t\tfor _, imageID := range imageIDs {\n\t\t\ti, err := r.repository.Image.Find(ctx, imageID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif i == nil {\n\t\t\t\treturn fmt.Errorf(\"image with id %d not found\", imageID)\n\t\t\t}\n\n\t\t\tif updatedImage.GalleryIDs != nil {\n\t\t\t\t// ensure gallery IDs are loaded\n\t\t\t\tif err := i.LoadGalleryIDs(ctx, r.repository.Image); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := r.galleryService.ValidateImageGalleryChange(ctx, i, *updatedImage.GalleryIDs); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tthisUpdatedGalleryIDs := updatedImage.GalleryIDs.ImpactedIDs(i.GalleryIDs.List())\n\t\t\t\tupdatedGalleryIDs = sliceutil.AppendUniques(updatedGalleryIDs, thisUpdatedGalleryIDs)\n\t\t\t}\n\n\t\t\timage, err := qb.UpdatePartial(ctx, imageID, updatedImage)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, image)\n\t\t}\n\n\t\t// #3759 - update all impacted galleries\n\t\tfor _, galleryID := range updatedGalleryIDs {\n\t\t\tif err := r.galleryService.Updated(ctx, galleryID); err != nil {\n\t\t\t\treturn fmt.Errorf(\"updating gallery %d: %w\", galleryID, err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside of txn\n\tvar newRet []*models.Image\n\tfor _, image := range ret {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageUpdatePost, input, translator.getFields())\n\n\t\timage, err = r.getImage(ctx, image.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, image)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageDestroyInput) (ret bool, err error) {\n\timageID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ttrashPath := manager.GetInstance().Config.GetDeleteTrashPath()\n\n\tvar i *models.Image\n\tfileDeleter := &image.FileDeleter{\n\t\tDeleter: file.NewDeleterWithTrash(trashPath),\n\t\tPaths:   manager.GetInstance().Paths,\n\t}\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\ti, err = r.repository.Image.Find(ctx, imageID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif i == nil {\n\t\t\treturn fmt.Errorf(\"image with id %d not found\", imageID)\n\t\t}\n\n\t\treturn r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry))\n\t}); err != nil {\n\t\tfileDeleter.Rollback()\n\t\treturn false, err\n\t}\n\n\t// perform the post-commit actions\n\tfileDeleter.Commit()\n\n\t// call post hook after performing the other actions\n\tr.hookExecutor.ExecutePostHooks(ctx, i.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{\n\t\tImageDestroyInput: input,\n\t\tChecksum:          i.Checksum,\n\t\tPath:              i.Path,\n\t}, nil)\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.ImagesDestroyInput) (ret bool, err error) {\n\timageIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttrashPath := manager.GetInstance().Config.GetDeleteTrashPath()\n\n\tvar images []*models.Image\n\tfileDeleter := &image.FileDeleter{\n\t\tDeleter: file.NewDeleterWithTrash(trashPath),\n\t\tPaths:   manager.GetInstance().Paths,\n\t}\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Image\n\n\t\tfor _, imageID := range imageIDs {\n\t\t\ti, err := qb.Find(ctx, imageID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif i == nil {\n\t\t\t\treturn fmt.Errorf(\"image with id %d not found\", imageID)\n\t\t\t}\n\n\t\t\timages = append(images, i)\n\n\t\t\tif err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tfileDeleter.Rollback()\n\t\treturn false, err\n\t}\n\n\t// perform the post-commit actions\n\tfileDeleter.Commit()\n\n\tfor _, image := range images {\n\t\t// call post hook after performing the other actions\n\t\tr.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageDestroyPost, plugin.ImagesDestroyInput{\n\t\t\tImagesDestroyInput: input,\n\t\t\tChecksum:           image.Checksum,\n\t\t\tPath:               image.Path,\n\t\t}, nil)\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) ImageIncrementO(ctx context.Context, id string) (ret int, err error) {\n\timageID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Image\n\n\t\tret, err = qb.IncrementOCounter(ctx, imageID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) ImageDecrementO(ctx context.Context, id string) (ret int, err error) {\n\timageID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Image\n\n\t\tret, err = qb.DecrementOCounter(ctx, imageID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) ImageResetO(ctx context.Context, id string) (ret int, err error) {\n\timageID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Image\n\n\t\tret, err = qb.ResetOCounter(ctx, imageID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_job.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n)\n\nfunc (r *mutationResolver) StopJob(ctx context.Context, jobID string) (bool, error) {\n\tid, err := strconv.Atoi(jobID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\tmanager.GetInstance().JobManager.CancelJob(id)\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) StopAllJobs(ctx context.Context) (bool, error) {\n\tmanager.GetInstance().JobManager.CancelAll()\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_metadata.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/identify\"\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/internal/manager/task\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nfunc (r *mutationResolver) MetadataScan(ctx context.Context, input manager.ScanMetadataInput) (string, error) {\n\tjobID, err := manager.GetInstance().Scan(ctx, input)\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) MetadataImport(ctx context.Context) (string, error) {\n\tjobID, err := manager.GetInstance().Import(ctx)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) ImportObjects(ctx context.Context, input manager.ImportObjectsInput) (string, error) {\n\tt, err := manager.CreateImportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tjobID := manager.GetInstance().RunSingleTask(ctx, t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) {\n\tjobID, err := manager.GetInstance().Export(ctx)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) ExportObjects(ctx context.Context, input manager.ExportObjectsInput) (*string, error) {\n\tt := manager.CreateExportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input)\n\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\tt.Start(ctx, &wg)\n\n\tif t.DownloadHash != \"\" {\n\t\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\n\t\t// generate timestamp\n\t\tsuffix := time.Now().Format(\"20060102-150405\")\n\t\tret := baseURL + \"/downloads/\" + t.DownloadHash + \"/export\" + suffix + \".zip\"\n\t\treturn &ret, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (r *mutationResolver) MetadataGenerate(ctx context.Context, input manager.GenerateMetadataInput) (string, error) {\n\tjobID, err := manager.GetInstance().Generate(ctx, input)\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) MetadataAutoTag(ctx context.Context, input manager.AutoTagMetadataInput) (string, error) {\n\tjobID := manager.GetInstance().AutoTag(ctx, input)\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) MetadataIdentify(ctx context.Context, input identify.Options) (string, error) {\n\tt := manager.CreateIdentifyJob(input)\n\tjobID := manager.GetInstance().JobManager.Add(ctx, \"Identifying...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) MetadataClean(ctx context.Context, input manager.CleanMetadataInput) (string, error) {\n\tjobID := manager.GetInstance().Clean(ctx, input)\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) MetadataCleanGenerated(ctx context.Context, input task.CleanGeneratedOptions) (string, error) {\n\tmgr := manager.GetInstance()\n\tt := &task.CleanGeneratedJob{\n\t\tOptions:                  input,\n\t\tPaths:                    mgr.Paths,\n\t\tBlobsStorageType:         mgr.Config.GetBlobsStorage(),\n\t\tVideoFileNamingAlgorithm: mgr.Config.GetVideoFileNamingAlgorithm(),\n\t\tRepository:               mgr.Repository,\n\t\tBlobCleaner:              mgr.Repository.Blob,\n\t}\n\tjobID := mgr.JobManager.Add(ctx, \"Cleaning generated files...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) MigrateHashNaming(ctx context.Context) (string, error) {\n\tjobID := manager.GetInstance().MigrateHash(ctx)\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatabaseInput) (*string, error) {\n\t// if download is true, then backup to temporary file and return a link\n\tdownload := input.Download != nil && *input.Download\n\tincludeBlobs := input.IncludeBlobs != nil && *input.IncludeBlobs\n\tmgr := manager.GetInstance()\n\n\tbackupPath, backupName, err := mgr.BackupDatabase(download, includeBlobs)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error backing up database: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tif download {\n\t\tdownloadHash, err := mgr.DownloadStore.RegisterFile(backupPath, \"\", false)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error registering file for download: %w\", err)\n\t\t}\n\t\tlogger.Debugf(\"Generated backup file %s with hash %s\", backupPath, downloadHash)\n\n\t\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\n\t\tret := baseURL + \"/downloads/\" + downloadHash + \"/\" + backupName\n\t\treturn &ret, nil\n\t} else {\n\t\tlogger.Infof(\"Successfully backed up database to: %s\", backupPath)\n\t}\n\n\treturn nil, nil\n}\n\nfunc (r *mutationResolver) AnonymiseDatabase(ctx context.Context, input AnonymiseDatabaseInput) (*string, error) {\n\t// if download is true, then save to temporary file and return a link\n\tdownload := input.Download != nil && *input.Download\n\tmgr := manager.GetInstance()\n\n\toutPath, outName, err := mgr.AnonymiseDatabase(download)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error anonymising database: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tif download {\n\t\tdownloadHash, err := mgr.DownloadStore.RegisterFile(outPath, \"\", false)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error registering file for download: %w\", err)\n\t\t}\n\t\tlogger.Debugf(\"Generated anonymised file %s with hash %s\", outPath, downloadHash)\n\n\t\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\n\t\tret := baseURL + \"/downloads/\" + downloadHash + \"/\" + outName\n\t\treturn &ret, nil\n\t} else {\n\t\tlogger.Infof(\"Successfully anonymised database to: %s\", outPath)\n\t}\n\n\treturn nil, nil\n}\n\nfunc (r *mutationResolver) OptimiseDatabase(ctx context.Context) (string, error) {\n\tjobID := manager.GetInstance().OptimiseDatabase(ctx)\n\treturn strconv.Itoa(jobID), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_migrate.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/task\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc (r *mutationResolver) MigrateSceneScreenshots(ctx context.Context, input MigrateSceneScreenshotsInput) (string, error) {\n\tmgr := manager.GetInstance()\n\tt := &task.MigrateSceneScreenshotsJob{\n\t\tScreenshotsPath: manager.GetInstance().Paths.Generated.Screenshots,\n\t\tInput: scene.MigrateSceneScreenshotsInput{\n\t\t\tDeleteFiles:       utils.IsTrue(input.DeleteFiles),\n\t\t\tOverwriteExisting: utils.IsTrue(input.OverwriteExisting),\n\t\t},\n\t\tSceneRepo:  mgr.Repository.Scene,\n\t\tTxnManager: mgr.Repository.TxnManager,\n\t}\n\tjobID := mgr.JobManager.Add(ctx, \"Migrating scene screenshots to blobs...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) MigrateBlobs(ctx context.Context, input MigrateBlobsInput) (string, error) {\n\tmgr := manager.GetInstance()\n\tt := &task.MigrateBlobsJob{\n\t\tTxnManager: mgr.Database,\n\t\tBlobStore:  mgr.Database.Blobs,\n\t\tVacuumer:   mgr.Database,\n\t\tDeleteOld:  utils.IsTrue(input.DeleteOld),\n\t}\n\tjobID := mgr.JobManager.Add(ctx, \"Migrating blobs...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInput) (string, error) {\n\tmgr := manager.GetInstance()\n\tt := &task.MigrateJob{\n\t\tBackupPath: input.BackupPath,\n\t\tConfig:     mgr.Config,\n\t\tDatabase:   mgr.Database,\n\t}\n\n\tjobID := mgr.JobManager.Add(ctx, \"Migrating database...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_movie.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\n// used to refetch group after hooks run\nfunc (r *mutationResolver) getGroup(ctx context.Context, id int) (ret *models.Group, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Group.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInput) (*models.Group, error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate a new group from the input\n\tnewGroup := models.NewGroup()\n\n\tnewGroup.Name = strings.TrimSpace(input.Name)\n\tnewGroup.Aliases = translator.string(input.Aliases)\n\tnewGroup.Duration = input.Duration\n\tnewGroup.Rating = input.Rating100\n\tnewGroup.Director = translator.string(input.Director)\n\tnewGroup.Synopsis = translator.string(input.Synopsis)\n\n\tvar err error\n\n\tnewGroup.Date, err = translator.datePtr(input.Date)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tnewGroup.StudioID, err = translator.intPtrFromString(input.StudioID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tnewGroup.TagIDs, err = translator.relatedIds(input.TagIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tif input.Urls != nil {\n\t\tnewGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))\n\t} else if input.URL != nil {\n\t\tnewGroup.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})\n\t}\n\n\t// Process the base 64 encoded image string\n\tvar frontimageData []byte\n\tif input.FrontImage != nil {\n\t\tfrontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing front image: %w\", err)\n\t\t}\n\t}\n\n\t// Process the base 64 encoded image string\n\tvar backimageData []byte\n\tif input.BackImage != nil {\n\t\tbackimageData, err = utils.ProcessImageInput(ctx, *input.BackImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing back image: %w\", err)\n\t\t}\n\t}\n\n\t// HACK: if back image is being set, set the front image to the default.\n\t// This is because we can't have a null front image with a non-null back image.\n\tif len(frontimageData) == 0 && len(backimageData) != 0 {\n\t\tfrontimageData = static.ReadAll(static.DefaultGroupImage)\n\t}\n\n\t// Start the transaction and save the group\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Group\n\n\t\terr = qb.Create(ctx, &newGroup)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// update image table\n\t\tif len(frontimageData) > 0 {\n\t\t\tif err := qb.UpdateFrontImage(ctx, newGroup.ID, frontimageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif len(backimageData) > 0 {\n\t\t\tif err := qb.UpdateBackImage(ctx, newGroup.ID, backimageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// for backwards compatibility - run both movie and group hooks\n\tr.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil)\n\tr.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil)\n\treturn r.getGroup(ctx, newGroup.ID)\n}\n\nfunc (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInput) (*models.Group, error) {\n\tgroupID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate group from the input\n\tupdatedGroup := models.NewGroupPartial()\n\n\tupdatedGroup.Name = translator.optionalString(input.Name, \"name\")\n\tupdatedGroup.Aliases = translator.optionalString(input.Aliases, \"aliases\")\n\tupdatedGroup.Duration = translator.optionalInt(input.Duration, \"duration\")\n\tupdatedGroup.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedGroup.Director = translator.optionalString(input.Director, \"director\")\n\tupdatedGroup.Synopsis = translator.optionalString(input.Synopsis, \"synopsis\")\n\n\tupdatedGroup.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tupdatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tupdatedGroup.TagIDs, err = translator.updateIds(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tupdatedGroup.URLs = translator.optionalURLs(input.Urls, input.URL)\n\n\tvar frontimageData []byte\n\tfrontImageIncluded := translator.hasField(\"front_image\")\n\tif input.FrontImage != nil {\n\t\tfrontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing front image: %w\", err)\n\t\t}\n\t}\n\n\tvar backimageData []byte\n\tbackImageIncluded := translator.hasField(\"back_image\")\n\tif input.BackImage != nil {\n\t\tbackimageData, err = utils.ProcessImageInput(ctx, *input.BackImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing back image: %w\", err)\n\t\t}\n\t}\n\n\t// Start the transaction and save the group\n\tvar group *models.Group\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Group\n\t\tgroup, err = qb.UpdatePartial(ctx, groupID, updatedGroup)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// update image table\n\t\tif frontImageIncluded {\n\t\t\tif err := qb.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif backImageIncluded {\n\t\t\tif err := qb.UpdateBackImage(ctx, group.ID, backimageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// for backwards compatibility - run both movie and group hooks\n\tr.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields())\n\tr.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields())\n\treturn r.getGroup(ctx, group.ID)\n}\n\nfunc (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieUpdateInput) ([]*models.Group, error) {\n\tgroupIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate group from the input\n\tupdatedGroup := models.NewGroupPartial()\n\n\tupdatedGroup.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedGroup.Director = translator.optionalString(input.Director, \"director\")\n\n\tupdatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tupdatedGroup.TagIDs, err = translator.updateIdsBulk(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tupdatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil)\n\n\tret := []*models.Group{}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Group\n\n\t\tfor _, groupID := range groupIDs {\n\t\t\tgroup, err := qb.UpdatePartial(ctx, groupID, updatedGroup)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, group)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar newRet []*models.Group\n\tfor _, group := range ret {\n\t\t// for backwards compatibility - run both movie and group hooks\n\t\tr.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields())\n\t\tr.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields())\n\n\t\tgroup, err = r.getGroup(ctx, group.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, group)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) MovieDestroy(ctx context.Context, input MovieDestroyInput) (bool, error) {\n\tid, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.repository.Group.Destroy(ctx, id)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\t// for backwards compatibility - run both movie and group hooks\n\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, input, nil)\n\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil)\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) MoviesDestroy(ctx context.Context, groupIDs []string) (bool, error) {\n\tids, err := stringslice.StringSliceToIntSlice(groupIDs)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Group\n\t\tfor _, id := range ids {\n\t\t\tif err := qb.Destroy(ctx, id); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tfor _, id := range ids {\n\t\t// for backwards compatibility - run both movie and group hooks\n\t\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, groupIDs, nil)\n\t\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, groupIDs, nil)\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_package.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/task\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc refreshPackageType(typeArg PackageType) {\n\tmgr := manager.GetInstance()\n\n\tif typeArg == PackageTypePlugin {\n\t\tmgr.RefreshPluginCache()\n\t} else if typeArg == PackageTypeScraper {\n\t\tmgr.RefreshScraperCache()\n\t}\n}\n\nfunc (r *mutationResolver) InstallPackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) {\n\tpm, err := getPackageManager(typeArg)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tmgr := manager.GetInstance()\n\tt := &task.InstallPackagesJob{\n\t\tPackagesJob: task.PackagesJob{\n\t\t\tPackageManager: pm,\n\t\t\tOnComplete:     func() { refreshPackageType(typeArg) },\n\t\t},\n\t\tPackages: packages,\n\t}\n\tjobID := mgr.JobManager.Add(ctx, \"Installing packages...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) UpdatePackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) {\n\tpm, err := getPackageManager(typeArg)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tmgr := manager.GetInstance()\n\tt := &task.UpdatePackagesJob{\n\t\tPackagesJob: task.PackagesJob{\n\t\t\tPackageManager: pm,\n\t\t\tOnComplete:     func() { refreshPackageType(typeArg) },\n\t\t},\n\t\tPackages: packages,\n\t}\n\tjobID := mgr.JobManager.Add(ctx, \"Updating packages...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) UninstallPackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) {\n\tpm, err := getPackageManager(typeArg)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tmgr := manager.GetInstance()\n\tt := &task.UninstallPackagesJob{\n\t\tPackagesJob: task.PackagesJob{\n\t\t\tPackageManager: pm,\n\t\t\tOnComplete:     func() { refreshPackageType(typeArg) },\n\t\t},\n\t\tPackages: packages,\n\t}\n\tjobID := mgr.JobManager.Add(ctx, \"Updating packages...\", t)\n\n\treturn strconv.Itoa(jobID), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_performer.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nconst (\n\ttwitterURL   = \"https://twitter.com\"\n\tinstagramURL = \"https://instagram.com\"\n)\n\n// used to refetch performer after hooks run\nfunc (r *mutationResolver) getPerformer(ctx context.Context, id int) (ret *models.Performer, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Performer.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) PerformerCreate(ctx context.Context, input models.PerformerCreateInput) (*models.Performer, error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate a new performer from the input\n\tnewPerformer := models.NewPerformer()\n\n\tnewPerformer.Name = strings.TrimSpace(input.Name)\n\tnewPerformer.Disambiguation = translator.string(input.Disambiguation)\n\tnewPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name))\n\tnewPerformer.Gender = input.Gender\n\tnewPerformer.Ethnicity = translator.string(input.Ethnicity)\n\tnewPerformer.Country = translator.string(input.Country)\n\tnewPerformer.EyeColor = translator.string(input.EyeColor)\n\tnewPerformer.Measurements = translator.string(input.Measurements)\n\tnewPerformer.FakeTits = translator.string(input.FakeTits)\n\tnewPerformer.PenisLength = input.PenisLength\n\tnewPerformer.Circumcised = input.Circumcised\n\tnewPerformer.Tattoos = translator.string(input.Tattoos)\n\tnewPerformer.Piercings = translator.string(input.Piercings)\n\tnewPerformer.Favorite = translator.bool(input.Favorite)\n\tnewPerformer.Rating = input.Rating100\n\tnewPerformer.Details = translator.string(input.Details)\n\tnewPerformer.HairColor = translator.string(input.HairColor)\n\tnewPerformer.Height = input.HeightCm\n\tnewPerformer.Weight = input.Weight\n\tnewPerformer.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)\n\tnewPerformer.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())\n\n\tnewPerformer.URLs = models.NewRelatedStrings([]string{})\n\tif input.URL != nil {\n\t\tnewPerformer.URLs.Add(strings.TrimSpace(*input.URL))\n\t}\n\tif input.Twitter != nil {\n\t\tnewPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Twitter), twitterURL))\n\t}\n\tif input.Instagram != nil {\n\t\tnewPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Instagram), instagramURL))\n\t}\n\n\tif input.Urls != nil {\n\t\tnewPerformer.URLs.Add(stringslice.TrimSpace(input.Urls)...)\n\t}\n\n\tvar err error\n\n\tnewPerformer.Birthdate, err = translator.datePtr(input.Birthdate)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting birthdate: %w\", err)\n\t}\n\tnewPerformer.DeathDate, err = translator.datePtr(input.DeathDate)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting death date: %w\", err)\n\t}\n\n\tnewPerformer.CareerStart, err = translator.datePtr(input.CareerStart)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting career start: %w\", err)\n\t}\n\tnewPerformer.CareerEnd, err = translator.datePtr(input.CareerEnd)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting career end: %w\", err)\n\t}\n\n\t// if career_start/career_end not provided, parse deprecated career_length\n\tif newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {\n\t\tstart, end, err := models.ParseYearRangeString(*input.CareerLength)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"could not parse career_length %q: %w\", *input.CareerLength, err)\n\t\t}\n\t\tnewPerformer.CareerStart = start\n\t\tnewPerformer.CareerEnd = end\n\t}\n\n\tnewPerformer.TagIDs, err = translator.relatedIds(input.TagIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\t// Process the base 64 encoded image string\n\tvar imageData []byte\n\tif input.Image != nil {\n\t\timageData, err = utils.ProcessImageInput(ctx, *input.Image)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing image: %w\", err)\n\t\t}\n\t}\n\n\t// Start the transaction and save the performer\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Performer\n\n\t\tif err := performer.ValidateCreate(ctx, newPerformer, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ti := &models.CreatePerformerInput{\n\t\t\tPerformer: &newPerformer,\n\t\t\t// convert json.Numbers to int/float\n\t\t\tCustomFields: convertMapJSONNumbers(input.CustomFields),\n\t\t}\n\n\t\terr = qb.Create(ctx, i)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// update image table\n\t\tif len(imageData) > 0 {\n\t\t\tif err := qb.UpdateImage(ctx, newPerformer.ID, imageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, newPerformer.ID, hook.PerformerCreatePost, input, nil)\n\treturn r.getPerformer(ctx, newPerformer.ID)\n}\n\nfunc validateNoLegacyURLs(translator changesetTranslator) error {\n\t// ensure url/twitter/instagram are not included in the input\n\tif translator.hasField(\"url\") {\n\t\treturn fmt.Errorf(\"url field must not be included if urls is included\")\n\t}\n\tif translator.hasField(\"twitter\") {\n\t\treturn fmt.Errorf(\"twitter field must not be included if urls is included\")\n\t}\n\tif translator.hasField(\"instagram\") {\n\t\treturn fmt.Errorf(\"instagram field must not be included if urls is included\")\n\t}\n\n\treturn nil\n}\n\nfunc (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error {\n\tqb := r.repository.Performer\n\n\t// we need to be careful with URL/Twitter/Instagram\n\t// treat URL as replacing the first non-Twitter/Instagram URL in the list\n\t// twitter should replace any existing twitter URL\n\t// instagram should replace any existing instagram URL\n\tp, err := qb.Find(ctx, performerID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := p.LoadURLs(ctx, qb); err != nil {\n\t\treturn fmt.Errorf(\"loading performer URLs: %w\", err)\n\t}\n\n\texistingURLs := p.URLs.List()\n\n\t// performer partial URLs should be empty\n\tif legacyURLs.URL.Set {\n\t\treplaced := false\n\t\tfor i, url := range existingURLs {\n\t\t\tif !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {\n\t\t\t\texistingURLs[i] = legacyURLs.URL.Value\n\t\t\t\treplaced = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !replaced {\n\t\t\texistingURLs = append(existingURLs, legacyURLs.URL.Value)\n\t\t}\n\t}\n\n\tif legacyURLs.Twitter.Set {\n\t\tvalue := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)\n\t\tfound := false\n\t\t// find and replace the first twitter URL\n\t\tfor i, url := range existingURLs {\n\t\t\tif performer.IsTwitterURL(url) {\n\t\t\t\texistingURLs[i] = value\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\texistingURLs = append(existingURLs, value)\n\t\t}\n\t}\n\tif legacyURLs.Instagram.Set {\n\t\tfound := false\n\t\tvalue := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)\n\t\t// find and replace the first instagram URL\n\t\tfor i, url := range existingURLs {\n\t\t\tif performer.IsInstagramURL(url) {\n\t\t\t\texistingURLs[i] = value\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\texistingURLs = append(existingURLs, value)\n\t\t}\n\t}\n\n\tupdatedPerformer.URLs = &models.UpdateStrings{\n\t\tValues: existingURLs,\n\t\tMode:   models.RelationshipUpdateModeSet,\n\t}\n\n\treturn nil\n}\n\ntype legacyPerformerURLs struct {\n\tURL       models.OptionalString\n\tTwitter   models.OptionalString\n\tInstagram models.OptionalString\n}\n\nfunc (u *legacyPerformerURLs) AnySet() bool {\n\treturn u.URL.Set || u.Twitter.Set || u.Instagram.Set\n}\n\nfunc legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs {\n\treturn legacyPerformerURLs{\n\t\tURL:       translator.optionalString(input.URL, \"url\"),\n\t\tTwitter:   translator.optionalString(input.Twitter, \"twitter\"),\n\t\tInstagram: translator.optionalString(input.Instagram, \"instagram\"),\n\t}\n}\n\nfunc performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) {\n\t// Populate performer from the input\n\tupdatedPerformer := models.NewPerformerPartial()\n\n\tupdatedPerformer.Name = translator.optionalString(input.Name, \"name\")\n\tupdatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, \"disambiguation\")\n\tupdatedPerformer.Gender = translator.optionalString((*string)(input.Gender), \"gender\")\n\tupdatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, \"ethnicity\")\n\tupdatedPerformer.Country = translator.optionalString(input.Country, \"country\")\n\tupdatedPerformer.EyeColor = translator.optionalString(input.EyeColor, \"eye_color\")\n\tupdatedPerformer.Measurements = translator.optionalString(input.Measurements, \"measurements\")\n\tupdatedPerformer.FakeTits = translator.optionalString(input.FakeTits, \"fake_tits\")\n\tupdatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, \"penis_length\")\n\tupdatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), \"circumcised\")\n\t// prefer career_start/career_end over deprecated career_length\n\tif translator.hasField(\"career_start\") || translator.hasField(\"career_end\") {\n\t\tvar err error\n\t\tupdatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, \"career_start\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting career start: %w\", err)\n\t\t}\n\t\tupdatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, \"career_end\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting career end: %w\", err)\n\t\t}\n\t} else if translator.hasField(\"career_length\") && input.CareerLength != nil {\n\t\tstart, end, err := models.ParseYearRangeString(*input.CareerLength)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"could not parse career_length %q: %w\", *input.CareerLength, err)\n\t\t}\n\t\tif start != nil {\n\t\t\tupdatedPerformer.CareerStart = models.NewOptionalDate(*start)\n\t\t}\n\t\tif end != nil {\n\t\t\tupdatedPerformer.CareerEnd = models.NewOptionalDate(*end)\n\t\t}\n\t}\n\tupdatedPerformer.Tattoos = translator.optionalString(input.Tattoos, \"tattoos\")\n\tupdatedPerformer.Piercings = translator.optionalString(input.Piercings, \"piercings\")\n\tupdatedPerformer.Favorite = translator.optionalBool(input.Favorite, \"favorite\")\n\tupdatedPerformer.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedPerformer.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedPerformer.HairColor = translator.optionalString(input.HairColor, \"hair_color\")\n\tupdatedPerformer.Weight = translator.optionalInt(input.Weight, \"weight\")\n\tupdatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, \"ignore_auto_tag\")\n\tupdatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, \"stash_ids\")\n\n\tvar err error\n\n\tif translator.hasField(\"urls\") {\n\t\t// ensure url/twitter/instagram are not included in the input\n\t\tif err := validateNoLegacyURLs(translator); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tupdatedPerformer.URLs = translator.updateStrings(input.Urls, \"urls\")\n\t}\n\n\tupdatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, \"birthdate\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting birthdate: %w\", err)\n\t}\n\tupdatedPerformer.DeathDate, err = translator.optionalDate(input.DeathDate, \"death_date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting death date: %w\", err)\n\t}\n\n\t// prefer height_cm over height\n\tif translator.hasField(\"height_cm\") {\n\t\tupdatedPerformer.Height = translator.optionalInt(input.HeightCm, \"height_cm\")\n\t}\n\n\t// prefer alias_list over aliases\n\tif translator.hasField(\"alias_list\") {\n\t\tupdatedPerformer.Aliases = translator.updateStrings(input.AliasList, \"alias_list\")\n\t}\n\n\tupdatedPerformer.TagIDs, err = translator.updateIds(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tupdatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)\n\n\treturn &updatedPerformer, nil\n}\n\nfunc (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {\n\tperformerID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\tupdatedPerformer, err := performerPartialFromInput(input, translator)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlegacyURLs := legacyPerformerURLsFromInput(input, translator)\n\n\tvar imageData []byte\n\timageIncluded := translator.hasField(\"image\")\n\tif input.Image != nil {\n\t\timageData, err = utils.ProcessImageInput(ctx, *input.Image)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing image: %w\", err)\n\t\t}\n\t}\n\n\t// Start the transaction and save the performer\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Performer\n\n\t\tif legacyURLs.AnySet() {\n\t\t\tif err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif updatedPerformer.Aliases != nil {\n\t\t\tp, err := qb.Find(ctx, performerID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif p != nil {\n\t\t\t\tif err := p.LoadAliases(ctx, qb); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\teffectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List())\n\t\t\t\tname := p.Name\n\t\t\t\tif updatedPerformer.Name.Set {\n\t\t\t\t\tname = updatedPerformer.Name.Value\n\t\t\t\t}\n\n\t\t\t\tsanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)\n\t\t\t\tupdatedPerformer.Aliases.Values = sanitized\n\t\t\t\tupdatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet\n\t\t\t}\n\t\t}\n\t\tif err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// update image table\n\t\tif imageIncluded {\n\t\t\tif err := qb.UpdateImage(ctx, performerID, imageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, performerID, hook.PerformerUpdatePost, input, translator.getFields())\n\treturn r.getPerformer(ctx, performerID)\n}\n\nfunc (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPerformerUpdateInput) ([]*models.Performer, error) {\n\tperformerIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate performer from the input\n\tupdatedPerformer := models.NewPerformerPartial()\n\n\tupdatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, \"disambiguation\")\n\n\tupdatedPerformer.Gender = translator.optionalString((*string)(input.Gender), \"gender\")\n\tupdatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, \"ethnicity\")\n\tupdatedPerformer.Country = translator.optionalString(input.Country, \"country\")\n\tupdatedPerformer.EyeColor = translator.optionalString(input.EyeColor, \"eye_color\")\n\tupdatedPerformer.Measurements = translator.optionalString(input.Measurements, \"measurements\")\n\tupdatedPerformer.FakeTits = translator.optionalString(input.FakeTits, \"fake_tits\")\n\tupdatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, \"penis_length\")\n\tupdatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), \"circumcised\")\n\t// prefer career_start/career_end over deprecated career_length\n\tif translator.hasField(\"career_start\") || translator.hasField(\"career_end\") {\n\t\tupdatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, \"career_start\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting career start: %w\", err)\n\t\t}\n\t\tupdatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, \"career_end\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting career end: %w\", err)\n\t\t}\n\t} else if translator.hasField(\"career_length\") && input.CareerLength != nil {\n\t\tstart, end, err := models.ParseYearRangeString(*input.CareerLength)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"could not parse career_length %q: %w\", *input.CareerLength, err)\n\t\t}\n\t\tif start != nil {\n\t\t\tupdatedPerformer.CareerStart = models.NewOptionalDate(*start)\n\t\t}\n\t\tif end != nil {\n\t\t\tupdatedPerformer.CareerEnd = models.NewOptionalDate(*end)\n\t\t}\n\t}\n\tupdatedPerformer.Tattoos = translator.optionalString(input.Tattoos, \"tattoos\")\n\tupdatedPerformer.Piercings = translator.optionalString(input.Piercings, \"piercings\")\n\n\tupdatedPerformer.Favorite = translator.optionalBool(input.Favorite, \"favorite\")\n\tupdatedPerformer.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedPerformer.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedPerformer.HairColor = translator.optionalString(input.HairColor, \"hair_color\")\n\tupdatedPerformer.Weight = translator.optionalInt(input.Weight, \"weight\")\n\tupdatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, \"ignore_auto_tag\")\n\n\tif translator.hasField(\"urls\") {\n\t\t// ensure url/twitter/instagram are not included in the input\n\t\tif err := validateNoLegacyURLs(translator); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tupdatedPerformer.URLs = translator.updateStringsBulk(input.Urls, \"urls\")\n\t}\n\n\tlegacyURLs := legacyPerformerURLs{\n\t\tURL:       translator.optionalString(input.URL, \"url\"),\n\t\tTwitter:   translator.optionalString(input.Twitter, \"twitter\"),\n\t\tInstagram: translator.optionalString(input.Instagram, \"instagram\"),\n\t}\n\n\tupdatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, \"birthdate\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting birthdate: %w\", err)\n\t}\n\tupdatedPerformer.DeathDate, err = translator.optionalDate(input.DeathDate, \"death_date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting death date: %w\", err)\n\t}\n\n\t// prefer height_cm over height\n\tif translator.hasField(\"height_cm\") {\n\t\tupdatedPerformer.Height = translator.optionalInt(input.HeightCm, \"height_cm\")\n\t}\n\n\t// prefer alias_list over aliases\n\tif translator.hasField(\"alias_list\") {\n\t\tupdatedPerformer.Aliases = translator.updateStringsBulk(input.AliasList, \"alias_list\")\n\t}\n\n\tupdatedPerformer.TagIDs, err = translator.updateIdsBulk(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tif input.CustomFields != nil {\n\t\tupdatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields)\n\t}\n\n\tret := []*models.Performer{}\n\n\t// Start the transaction and save the performers\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Performer\n\n\t\tfor _, performerID := range performerIDs {\n\t\t\tif legacyURLs.AnySet() {\n\t\t\t\tif err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tperformer, err := qb.UpdatePartial(ctx, performerID, updatedPerformer)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, performer)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside of txn\n\tvar newRet []*models.Performer\n\tfor _, performer := range ret {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, performer.ID, hook.PerformerUpdatePost, input, translator.getFields())\n\n\t\tperformer, err = r.getPerformer(ctx, performer.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, performer)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) PerformerDestroy(ctx context.Context, input PerformerDestroyInput) (bool, error) {\n\tid, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.repository.Performer.Destroy(ctx, id)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.PerformerDestroyPost, input, nil)\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs []string) (bool, error) {\n\tids, err := stringslice.StringSliceToIntSlice(performerIDs)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Performer\n\t\tfor _, id := range ids {\n\t\t\tif err := qb.Destroy(ctx, id); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tfor _, id := range ids {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.PerformerDestroyPost, performerIDs, nil)\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {\n\tsrcIDs, err := stringslice.StringSliceToIntSlice(input.Source)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting source ids: %w\", err)\n\t}\n\n\t// ensure source ids are unique\n\tsrcIDs = sliceutil.AppendUniques(nil, srcIDs)\n\n\tdestID, err := strconv.Atoi(input.Destination)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting destination id: %w\", err)\n\t}\n\n\t// ensure destination is not in source list\n\tif slices.Contains(srcIDs, destID) {\n\t\treturn nil, errors.New(\"destination performer cannot be in source list\")\n\t}\n\n\tvar values *models.PerformerPartial\n\tvar imageData []byte\n\n\tif input.Values != nil {\n\t\ttranslator := changesetTranslator{\n\t\t\tinputMap: getNamedUpdateInputMap(ctx, \"input.values\"),\n\t\t}\n\n\t\tvalues, err = performerPartialFromInput(*input.Values, translator)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tlegacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)\n\t\tif legacyURLs.AnySet() {\n\t\t\treturn nil, errors.New(\"Merging legacy performer URLs is not supported\")\n\t\t}\n\n\t\tif input.Values.Image != nil {\n\t\t\tvar err error\n\t\t\timageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"processing cover image: %w\", err)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tv := models.NewPerformerPartial()\n\t\tvalues = &v\n\t}\n\n\tvar dest *models.Performer\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Performer\n\n\t\tdest, err = qb.Find(ctx, destID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding destination performer ID %d: %w\", destID, err)\n\t\t}\n\n\t\t// ensure source performers exist\n\t\tif _, err := qb.FindMany(ctx, srcIDs); err != nil {\n\t\t\treturn fmt.Errorf(\"finding source performers: %w\", err)\n\t\t}\n\n\t\tif _, err := qb.UpdatePartial(ctx, destID, *values); err != nil {\n\t\t\treturn fmt.Errorf(\"updating performer: %w\", err)\n\t\t}\n\n\t\tif err := qb.Merge(ctx, srcIDs, destID); err != nil {\n\t\t\treturn fmt.Errorf(\"merging performers: %w\", err)\n\t\t}\n\n\t\tif len(imageData) > 0 {\n\t\t\tif err := qb.UpdateImage(ctx, destID, imageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn dest, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_plugin.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\nfunc toPluginArgs(args []*plugin.PluginArgInput) plugin.OperationInput {\n\tret := make(plugin.OperationInput)\n\tfor _, a := range args {\n\t\tret[a.Key] = toPluginArgValue(a.Value)\n\t}\n\n\treturn ret\n}\n\nfunc toPluginArgValue(arg *plugin.PluginValueInput) interface{} {\n\tif arg == nil {\n\t\treturn nil\n\t}\n\n\tswitch {\n\tcase arg.Str != nil:\n\t\treturn *arg.Str\n\tcase arg.I != nil:\n\t\treturn *arg.I\n\tcase arg.B != nil:\n\t\treturn *arg.B\n\tcase arg.F != nil:\n\t\treturn *arg.F\n\tcase arg.O != nil:\n\t\treturn toPluginArgs(arg.O)\n\tcase arg.A != nil:\n\t\tvar ret []interface{}\n\t\tfor _, v := range arg.A {\n\t\t\tret = append(ret, toPluginArgValue(v))\n\t\t}\n\t\treturn ret\n\t}\n\n\treturn nil\n}\n\nfunc (r *mutationResolver) RunPluginTask(\n\tctx context.Context,\n\tpluginID string,\n\ttaskName *string,\n\tdescription *string,\n\targs []*plugin.PluginArgInput,\n\targsMap map[string]interface{},\n) (string, error) {\n\tif argsMap == nil {\n\t\t// convert args to map\n\t\t// otherwise ignore args in favour of args map\n\t\targsMap = toPluginArgs(args)\n\t}\n\n\tm := manager.GetInstance()\n\tjobID := m.RunPluginTask(ctx, pluginID, taskName, description, argsMap)\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) RunPluginOperation(\n\tctx context.Context,\n\tpluginID string,\n\targs map[string]interface{},\n) (interface{}, error) {\n\tif args == nil {\n\t\targs = make(map[string]interface{})\n\t}\n\n\tm := manager.GetInstance()\n\treturn m.PluginCache.RunPlugin(ctx, pluginID, args)\n}\n\nfunc (r *mutationResolver) ReloadPlugins(ctx context.Context) (bool, error) {\n\tmanager.GetInstance().RefreshPluginCache()\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) SetPluginsEnabled(ctx context.Context, enabledMap map[string]bool) (bool, error) {\n\tc := config.GetInstance()\n\n\texistingDisabled := c.GetDisabledPlugins()\n\tvar newDisabled []string\n\n\t// remove plugins that are no longer disabled\n\tfor _, disabledID := range existingDisabled {\n\t\tif enabled, found := enabledMap[disabledID]; !enabled || !found {\n\t\t\tnewDisabled = append(newDisabled, disabledID)\n\t\t}\n\t}\n\n\t// add plugins that are newly disabled\n\tfor pluginID, enabled := range enabledMap {\n\t\tif !enabled {\n\t\t\tnewDisabled = sliceutil.AppendUnique(newDisabled, pluginID)\n\t\t}\n\t}\n\n\tc.SetInterface(config.DisabledPlugins, newDisabled)\n\n\tif err := c.Write(); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_saved_filter.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/mitchellh/mapstructure\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc (r *mutationResolver) SaveFilter(ctx context.Context, input SaveFilterInput) (ret *models.SavedFilter, err error) {\n\tif strings.TrimSpace(input.Name) == \"\" {\n\t\treturn nil, errors.New(\"name must be non-empty\")\n\t}\n\n\tvar id *int\n\tif input.ID != nil {\n\t\tidv, err := strconv.Atoi(*input.ID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t\t}\n\t\tid = &idv\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.SavedFilter\n\n\t\tf := models.SavedFilter{\n\t\t\tMode:         input.Mode,\n\t\t\tName:         strings.TrimSpace(input.Name),\n\t\t\tFindFilter:   input.FindFilter,\n\t\t\tObjectFilter: input.ObjectFilter,\n\t\t\tUIOptions:    input.UIOptions,\n\t\t}\n\n\t\tif id == nil {\n\t\t\terr = qb.Create(ctx, &f)\n\t\t\tret = &f\n\t\t} else {\n\t\t\tf.ID = *id\n\t\t\terr = qb.Update(ctx, &f)\n\t\t\tret = &f\n\t\t}\n\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ret, err\n}\n\nfunc (r *mutationResolver) DestroySavedFilter(ctx context.Context, input DestroyFilterInput) (bool, error) {\n\tid, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.repository.SavedFilter.Destroy(ctx, id)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) SetDefaultFilter(ctx context.Context, input SetDefaultFilterInput) (bool, error) {\n\t// deprecated - write to the config in the meantime\n\tconfig := config.GetInstance()\n\n\tuiConfig := config.GetUIConfiguration()\n\tif uiConfig == nil {\n\t\tuiConfig = make(map[string]interface{})\n\t}\n\n\tm := utils.NestedMap(uiConfig)\n\n\tif input.FindFilter == nil && input.ObjectFilter == nil && input.UIOptions == nil {\n\t\t// clearing\n\t\tm.Delete(\"defaultFilters.\" + strings.ToLower(input.Mode.String()))\n\t\tconfig.SetUIConfiguration(m)\n\n\t\tif err := config.Write(); err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\treturn true, nil\n\t}\n\n\tsubMap := make(map[string]interface{})\n\td, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{\n\t\tTagName:          \"json\",\n\t\tWeaklyTypedInput: true,\n\t\tResult:           &subMap,\n\t})\n\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif err := d.Decode(input); err != nil {\n\t\treturn false, err\n\t}\n\n\tm.Set(\"defaultFilters.\"+strings.ToLower(input.Mode.String()), subMap)\n\n\tconfig.SetUIConfiguration(m)\n\n\tif err := config.Write(); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_scene.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\n// used to refetch scene after hooks run\nfunc (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Scene, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Scene.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCreateInput) (ret *models.Scene, err error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\tfileIDs, err := translator.fileIDSliceFromStringSlice(input.FileIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting file ids: %w\", err)\n\t}\n\n\t// Populate a new scene from the input\n\tnewScene := models.NewScene()\n\n\tnewScene.Title = translator.string(input.Title)\n\tnewScene.Code = translator.string(input.Code)\n\tnewScene.Details = translator.string(input.Details)\n\tnewScene.Director = translator.string(input.Director)\n\tnewScene.Rating = input.Rating100\n\tnewScene.Organized = translator.bool(input.Organized)\n\tnewScene.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())\n\n\tnewScene.Date, err = translator.datePtr(input.Date)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tnewScene.StudioID, err = translator.intPtrFromString(input.StudioID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tif input.Urls != nil {\n\t\tnewScene.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))\n\t} else if input.URL != nil {\n\t\tnewScene.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})\n\t}\n\n\tnewScene.PerformerIDs, err = translator.relatedIds(input.PerformerIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting performer ids: %w\", err)\n\t}\n\tnewScene.TagIDs, err = translator.relatedIds(input.TagIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\tnewScene.GalleryIDs, err = translator.relatedIds(input.GalleryIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting gallery ids: %w\", err)\n\t}\n\n\t// prefer groups over movies\n\tif len(input.Groups) > 0 {\n\t\tnewScene.Groups, err = translator.relatedGroups(input.Groups)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting groups: %w\", err)\n\t\t}\n\t} else if len(input.Movies) > 0 {\n\t\tnewScene.Groups, err = translator.relatedGroupsFromMovies(input.Movies)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting movies: %w\", err)\n\t\t}\n\t}\n\n\tvar coverImageData []byte\n\tif input.CoverImage != nil {\n\t\tvar err error\n\t\tcoverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing cover image: %w\", err)\n\t\t}\n\t}\n\n\tcustomFields := convertMapJSONNumbers(input.CustomFields)\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.Resolver.sceneService.Create(ctx, models.CreateSceneInput{\n\t\t\tScene:        &newScene,\n\t\t\tFileIDs:      fileIDs,\n\t\t\tCoverImage:   coverImageData,\n\t\t\tCustomFields: customFields,\n\t\t})\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (ret *models.Scene, err error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Start the transaction and save the scene\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.sceneUpdate(ctx, input, translator)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.SceneUpdatePost, input, translator.getFields())\n\treturn r.getScene(ctx, ret.ID)\n}\n\nfunc (r *mutationResolver) ScenesUpdate(ctx context.Context, input []*models.SceneUpdateInput) (ret []*models.Scene, err error) {\n\tinputMaps := getUpdateInputMaps(ctx)\n\n\t// Start the transaction and save the scenes\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tfor i, scene := range input {\n\t\t\ttranslator := changesetTranslator{\n\t\t\t\tinputMap: inputMaps[i],\n\t\t\t}\n\n\t\t\tthisScene, err := r.sceneUpdate(ctx, *scene, translator)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, thisScene)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside of txn\n\tvar newRet []*models.Scene\n\tfor i, scene := range ret {\n\t\ttranslator := changesetTranslator{\n\t\t\tinputMap: inputMaps[i],\n\t\t}\n\n\t\tr.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneUpdatePost, input, translator.getFields())\n\n\t\tscene, err = r.getScene(ctx, scene.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, scene)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc scenePartialFromInput(input models.SceneUpdateInput, translator changesetTranslator) (*models.ScenePartial, error) {\n\tupdatedScene := models.NewScenePartial()\n\n\tupdatedScene.Title = translator.optionalString(input.Title, \"title\")\n\tupdatedScene.Code = translator.optionalString(input.Code, \"code\")\n\tupdatedScene.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedScene.Director = translator.optionalString(input.Director, \"director\")\n\tupdatedScene.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\n\tif input.OCounter != nil {\n\t\tlogger.Warnf(\"o_counter is deprecated and no longer supported, use sceneIncrementO/sceneDecrementO instead\")\n\t}\n\n\tif input.PlayCount != nil {\n\t\tlogger.Warnf(\"play_count is deprecated and no longer supported, use sceneIncrementPlayCount/sceneDecrementPlayCount instead\")\n\t}\n\n\tupdatedScene.PlayDuration = translator.optionalFloat64(input.PlayDuration, \"play_duration\")\n\tupdatedScene.Organized = translator.optionalBool(input.Organized, \"organized\")\n\tupdatedScene.StashIDs = translator.updateStashIDs(input.StashIds, \"stash_ids\")\n\n\tvar err error\n\n\tupdatedScene.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tupdatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tupdatedScene.URLs = translator.optionalURLs(input.Urls, input.URL)\n\n\tupdatedScene.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting primary file id: %w\", err)\n\t}\n\n\tupdatedScene.PerformerIDs, err = translator.updateIds(input.PerformerIds, \"performer_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting performer ids: %w\", err)\n\t}\n\tupdatedScene.TagIDs, err = translator.updateIds(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\tupdatedScene.GalleryIDs, err = translator.updateIds(input.GalleryIds, \"gallery_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting gallery ids: %w\", err)\n\t}\n\n\tif translator.hasField(\"groups\") {\n\t\tupdatedScene.GroupIDs, err = translator.updateGroupIDs(input.Groups, \"groups\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting groups: %w\", err)\n\t\t}\n\t} else if translator.hasField(\"movies\") {\n\t\tupdatedScene.GroupIDs, err = translator.updateGroupIDsFromMovies(input.Movies, \"movies\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting movies: %w\", err)\n\t\t}\n\t}\n\n\treturn &updatedScene, nil\n}\n\nfunc (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) {\n\tsceneID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tqb := r.repository.Scene\n\n\toriginalScene, err := qb.Find(ctx, sceneID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif originalScene == nil {\n\t\treturn nil, fmt.Errorf(\"scene with id %d not found\", sceneID)\n\t}\n\n\t// Populate scene from the input\n\tupdatedScene, err := scenePartialFromInput(input, translator)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// ensure that title is set where scene has no file\n\tif updatedScene.Title.Set && updatedScene.Title.Value == \"\" {\n\t\tif err := originalScene.LoadFiles(ctx, r.repository.Scene); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(originalScene.Files.List()) == 0 {\n\t\t\treturn nil, errors.New(\"title must be set if scene has no files\")\n\t\t}\n\t}\n\n\tif updatedScene.PrimaryFileID != nil {\n\t\tnewPrimaryFileID := *updatedScene.PrimaryFileID\n\n\t\t// if file hash has changed, we should migrate generated files\n\t\t// after commit\n\t\tif err := originalScene.LoadFiles(ctx, r.repository.Scene); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// ensure that new primary file is associated with scene\n\t\tvar f *models.VideoFile\n\t\tfor _, ff := range originalScene.Files.List() {\n\t\t\tif ff.ID == newPrimaryFileID {\n\t\t\t\tf = ff\n\t\t\t}\n\t\t}\n\n\t\tif f == nil {\n\t\t\treturn nil, fmt.Errorf(\"file with id %d not associated with scene\", newPrimaryFileID)\n\t\t}\n\t}\n\n\tvar coverImageData []byte\n\tcoverImageIncluded := translator.hasField(\"cover_image\")\n\tif input.CoverImage != nil {\n\t\tvar err error\n\t\tcoverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing cover image: %w\", err)\n\t\t}\n\t}\n\n\tvar customFields *models.CustomFieldsInput\n\tif input.CustomFields != nil {\n\t\tcfCopy := *input.CustomFields\n\t\tcustomFields = &cfCopy\n\t\t// convert json.Numbers to int/float\n\t\tcustomFields.Full = convertMapJSONNumbers(customFields.Full)\n\t\tcustomFields.Partial = convertMapJSONNumbers(customFields.Partial)\n\t}\n\n\tscene, err := qb.UpdatePartial(ctx, sceneID, *updatedScene)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif coverImageIncluded {\n\t\tif err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif customFields != nil {\n\t\tif err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn scene, nil\n}\n\nfunc (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {\n\tqb := r.repository.Scene\n\n\t// update cover table - empty data will clear the cover\n\tif err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneUpdateInput) ([]*models.Scene, error) {\n\tsceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate scene from the input\n\tupdatedScene := models.NewScenePartial()\n\n\tupdatedScene.Title = translator.optionalString(input.Title, \"title\")\n\tupdatedScene.Code = translator.optionalString(input.Code, \"code\")\n\tupdatedScene.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedScene.Director = translator.optionalString(input.Director, \"director\")\n\tupdatedScene.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedScene.Organized = translator.optionalBool(input.Organized, \"organized\")\n\n\tupdatedScene.Date, err = translator.optionalDate(input.Date, \"date\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting date: %w\", err)\n\t}\n\tupdatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, \"studio_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting studio id: %w\", err)\n\t}\n\n\tupdatedScene.URLs = translator.optionalURLsBulk(input.Urls, input.URL)\n\n\tupdatedScene.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, \"performer_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting performer ids: %w\", err)\n\t}\n\tupdatedScene.TagIDs, err = translator.updateIdsBulk(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\tupdatedScene.GalleryIDs, err = translator.updateIdsBulk(input.GalleryIds, \"gallery_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting gallery ids: %w\", err)\n\t}\n\n\tif translator.hasField(\"group_ids\") {\n\t\tupdatedScene.GroupIDs, err = translator.updateGroupIDsBulk(input.GroupIds, \"group_ids\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting group ids: %w\", err)\n\t\t}\n\t} else if translator.hasField(\"movie_ids\") {\n\t\tupdatedScene.GroupIDs, err = translator.updateGroupIDsBulk(input.MovieIds, \"movie_ids\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting movie ids: %w\", err)\n\t\t}\n\t}\n\n\tvar customFields *models.CustomFieldsInput\n\tif input.CustomFields != nil {\n\t\tcf := handleUpdateCustomFields(*input.CustomFields)\n\t\tcustomFields = &cf\n\t}\n\n\tret := []*models.Scene{}\n\n\t// Start the transaction and save the scenes\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tfor _, sceneID := range sceneIDs {\n\t\t\tscene, err := qb.UpdatePartial(ctx, sceneID, updatedScene)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif customFields != nil {\n\t\t\t\tif err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tret = append(ret, scene)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside of txn\n\tvar newRet []*models.Scene\n\tfor _, scene := range ret {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneUpdatePost, input, translator.getFields())\n\n\t\tscene, err = r.getScene(ctx, scene.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, scene)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) {\n\tsceneID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tfileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()\n\ttrashPath := manager.GetInstance().Config.GetDeleteTrashPath()\n\n\tvar s *models.Scene\n\tfileDeleter := &scene.FileDeleter{\n\t\tDeleter:        file.NewDeleterWithTrash(trashPath),\n\t\tFileNamingAlgo: fileNamingAlgo,\n\t\tPaths:          manager.GetInstance().Paths,\n\t}\n\n\tdeleteGenerated := utils.IsTrue(input.DeleteGenerated)\n\tdeleteFile := utils.IsTrue(input.DeleteFile)\n\tdestroyFileEntry := utils.IsTrue(input.DestroyFileEntry)\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\t\tvar err error\n\t\ts, err = qb.Find(ctx, sceneID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif s == nil {\n\t\t\treturn fmt.Errorf(\"scene with id %d not found\", sceneID)\n\t\t}\n\n\t\t// kill any running encoders\n\t\tmanager.KillRunningStreams(s, fileNamingAlgo)\n\n\t\treturn r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)\n\t}); err != nil {\n\t\tfileDeleter.Rollback()\n\t\treturn false, err\n\t}\n\n\t// perform the post-commit actions\n\tfileDeleter.Commit()\n\n\t// call post hook after performing the other actions\n\tr.hookExecutor.ExecutePostHooks(ctx, s.ID, hook.SceneDestroyPost, plugin.SceneDestroyInput{\n\t\tSceneDestroyInput: input,\n\t\tChecksum:          s.Checksum,\n\t\tOSHash:            s.OSHash,\n\t\tPath:              s.Path,\n\t}, nil)\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.ScenesDestroyInput) (bool, error) {\n\tsceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\tvar scenes []*models.Scene\n\tfileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()\n\ttrashPath := manager.GetInstance().Config.GetDeleteTrashPath()\n\n\tfileDeleter := &scene.FileDeleter{\n\t\tDeleter:        file.NewDeleterWithTrash(trashPath),\n\t\tFileNamingAlgo: fileNamingAlgo,\n\t\tPaths:          manager.GetInstance().Paths,\n\t}\n\n\tdeleteGenerated := utils.IsTrue(input.DeleteGenerated)\n\tdeleteFile := utils.IsTrue(input.DeleteFile)\n\tdestroyFileEntry := utils.IsTrue(input.DestroyFileEntry)\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tfor _, id := range sceneIDs {\n\t\t\tscene, err := qb.Find(ctx, id)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif scene == nil {\n\t\t\t\treturn fmt.Errorf(\"scene with id %d not found\", id)\n\t\t\t}\n\n\t\t\tscenes = append(scenes, scene)\n\n\t\t\t// kill any running encoders\n\t\t\tmanager.KillRunningStreams(scene, fileNamingAlgo)\n\n\t\t\tif err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tfileDeleter.Rollback()\n\t\treturn false, err\n\t}\n\n\t// perform the post-commit actions\n\tfileDeleter.Commit()\n\n\tfor _, scene := range scenes {\n\t\t// call post hook after performing the other actions\n\t\tr.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneDestroyPost, plugin.ScenesDestroyInput{\n\t\t\tScenesDestroyInput: input,\n\t\t\tChecksum:           scene.Checksum,\n\t\t\tOSHash:             scene.OSHash,\n\t\t\tPath:               scene.Path,\n\t\t}, nil)\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) SceneAssignFile(ctx context.Context, input AssignSceneFileInput) (bool, error) {\n\tsceneID, err := strconv.Atoi(input.SceneID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting scene id: %w\", err)\n\t}\n\n\tfileID, err := strconv.Atoi(input.FileID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting file id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.Resolver.sceneService.AssignFile(ctx, sceneID, models.FileID(fileID))\n\t}); err != nil {\n\t\treturn false, fmt.Errorf(\"assigning file to scene: %w\", err)\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput) (*models.Scene, error) {\n\tsrcIDs, err := stringslice.StringSliceToIntSlice(input.Source)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting source ids: %w\", err)\n\t}\n\n\tdestID, err := strconv.Atoi(input.Destination)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting destination id: %w\", err)\n\t}\n\n\tvar values *models.ScenePartial\n\tvar coverImageData []byte\n\tvar customFields *models.CustomFieldsInput\n\n\tif input.Values != nil {\n\t\ttranslator := changesetTranslator{\n\t\t\tinputMap: getNamedUpdateInputMap(ctx, \"input.values\"),\n\t\t}\n\n\t\tvalues, err = scenePartialFromInput(*input.Values, translator)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif input.Values.CoverImage != nil {\n\t\t\tvar err error\n\t\t\tcoverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"processing cover image: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tif input.Values.CustomFields != nil {\n\t\t\tcf := handleUpdateCustomFields(*input.Values.CustomFields)\n\t\t\tcustomFields = &cf\n\t\t}\n\t} else {\n\t\tv := models.NewScenePartial()\n\t\tvalues = &v\n\t}\n\n\tmgr := manager.GetInstance()\n\ttrashPath := mgr.Config.GetDeleteTrashPath()\n\tfileDeleter := &scene.FileDeleter{\n\t\tDeleter:        file.NewDeleterWithTrash(trashPath),\n\t\tFileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),\n\t\tPaths:          mgr.Paths,\n\t}\n\n\tvar ret *models.Scene\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tif err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, fileDeleter, scene.MergeOptions{\n\t\t\tScenePartial:       *values,\n\t\t\tIncludePlayHistory: utils.IsTrue(input.PlayHistory),\n\t\t\tIncludeOHistory:    utils.IsTrue(input.OHistory),\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret, err = r.Resolver.repository.Scene.Find(ctx, destID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif ret == nil {\n\t\t\treturn fmt.Errorf(\"scene with id %d not found\", destID)\n\t\t}\n\n\t\t// only update cover image if one was provided\n\t\tif len(coverImageData) > 0 {\n\t\t\tif err := r.sceneUpdateCoverImage(ctx, ret, coverImageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif customFields != nil {\n\t\t\tif err := r.Resolver.repository.Scene.SetCustomFields(ctx, ret.ID, *customFields); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) getSceneMarker(ctx context.Context, id int) (ret *models.SceneMarker, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.SceneMarker.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMarkerCreateInput) (*models.SceneMarker, error) {\n\tsceneID, err := strconv.Atoi(input.SceneID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting scene id: %w\", err)\n\t}\n\n\tprimaryTagID, err := strconv.Atoi(input.PrimaryTagID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting primary tag id: %w\", err)\n\t}\n\n\t// Populate a new scene marker from the input\n\tnewMarker := models.NewSceneMarker()\n\n\tnewMarker.Title = strings.TrimSpace(input.Title)\n\tnewMarker.Seconds = input.Seconds\n\tnewMarker.PrimaryTagID = primaryTagID\n\tnewMarker.SceneID = sceneID\n\n\tif input.EndSeconds != nil {\n\t\tif err := validateSceneMarkerEndSeconds(newMarker.Seconds, *input.EndSeconds); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tnewMarker.EndSeconds = input.EndSeconds\n\t}\n\n\ttagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.SceneMarker\n\n\t\terr := qb.Create(ctx, &newMarker)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Save the marker tags\n\t\t// If this tag is the primary tag, then let's not add it.\n\t\ttagIDs = sliceutil.Exclude(tagIDs, []int{newMarker.PrimaryTagID})\n\t\treturn qb.UpdateTags(ctx, newMarker.ID, tagIDs)\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, newMarker.ID, hook.SceneMarkerCreatePost, input, nil)\n\treturn r.getSceneMarker(ctx, newMarker.ID)\n}\n\nfunc validateSceneMarkerEndSeconds(seconds, endSeconds float64) error {\n\tif endSeconds < seconds {\n\t\treturn fmt.Errorf(\"end_seconds (%f) must be greater than or equal to seconds (%f)\", endSeconds, seconds)\n\t}\n\treturn nil\n}\n\nfunc float64OrZero(f *float64) float64 {\n\tif f == nil {\n\t\treturn 0\n\t}\n\treturn *f\n}\n\nfunc (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) {\n\tmarkerID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate scene marker from the input\n\tupdatedMarker := models.NewSceneMarkerPartial()\n\n\tupdatedMarker.Title = translator.optionalString(input.Title, \"title\")\n\tupdatedMarker.Seconds = translator.optionalFloat64(input.Seconds, \"seconds\")\n\tupdatedMarker.EndSeconds = translator.optionalFloat64(input.EndSeconds, \"end_seconds\")\n\tupdatedMarker.SceneID, err = translator.optionalIntFromString(input.SceneID, \"scene_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting scene id: %w\", err)\n\t}\n\tupdatedMarker.PrimaryTagID, err = translator.optionalIntFromString(input.PrimaryTagID, \"primary_tag_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting primary tag id: %w\", err)\n\t}\n\n\tvar tagIDs []int\n\ttagIdsIncluded := translator.hasField(\"tag_ids\")\n\tif input.TagIds != nil {\n\t\ttagIDs, err = stringslice.StringSliceToIntSlice(input.TagIds)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t\t}\n\t}\n\n\tmgr := manager.GetInstance()\n\ttrashPath := mgr.Config.GetDeleteTrashPath()\n\n\tfileDeleter := &scene.FileDeleter{\n\t\tDeleter:        file.NewDeleterWithTrash(trashPath),\n\t\tFileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),\n\t\tPaths:          mgr.Paths,\n\t}\n\n\t// Start the transaction and save the scene marker\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.SceneMarker\n\t\tsqb := r.repository.Scene\n\n\t\t// check to see if timestamp was changed\n\t\texistingMarker, err := qb.Find(ctx, markerID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif existingMarker == nil {\n\t\t\treturn fmt.Errorf(\"scene marker with id %d not found\", markerID)\n\t\t}\n\n\t\t// Validate end_seconds\n\t\tshouldValidateEndSeconds := (updatedMarker.Seconds.Set || updatedMarker.EndSeconds.Set) && !updatedMarker.EndSeconds.Null\n\t\tif shouldValidateEndSeconds {\n\t\t\tseconds := existingMarker.Seconds\n\t\t\tif updatedMarker.Seconds.Set {\n\t\t\t\tseconds = updatedMarker.Seconds.Value\n\t\t\t}\n\n\t\t\tendSeconds := existingMarker.EndSeconds\n\t\t\tif updatedMarker.EndSeconds.Set {\n\t\t\t\tendSeconds = &updatedMarker.EndSeconds.Value\n\t\t\t}\n\n\t\t\tif endSeconds != nil {\n\t\t\t\tif err := validateSceneMarkerEndSeconds(seconds, *endSeconds); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tnewMarker, err := qb.UpdatePartial(ctx, markerID, updatedMarker)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\texistingScene, err := sqb.Find(ctx, existingMarker.SceneID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif existingScene == nil {\n\t\t\treturn fmt.Errorf(\"scene with id %d not found\", existingMarker.SceneID)\n\t\t}\n\n\t\t// remove the marker preview if the scene changed or if the timestamp was changed\n\t\tif existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || float64OrZero(existingMarker.EndSeconds) != float64OrZero(newMarker.EndSeconds) {\n\t\t\tseconds := int(existingMarker.Seconds)\n\t\t\tif err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif tagIdsIncluded {\n\t\t\t// Save the marker tags\n\t\t\t// If this tag is the primary tag, then let's not add it.\n\t\t\ttagIDs = sliceutil.Exclude(tagIDs, []int{newMarker.PrimaryTagID})\n\t\t\tif err := qb.UpdateTags(ctx, markerID, tagIDs); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tfileDeleter.Rollback()\n\t\treturn nil, err\n\t}\n\n\t// perform the post-commit actions\n\tfileDeleter.Commit()\n\n\tr.hookExecutor.ExecutePostHooks(ctx, markerID, hook.SceneMarkerUpdatePost, input, translator.getFields())\n\treturn r.getSceneMarker(ctx, markerID)\n}\n\nfunc (r *mutationResolver) BulkSceneMarkerUpdate(ctx context.Context, input BulkSceneMarkerUpdateInput) ([]*models.SceneMarker, error) {\n\tids, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate performer from the input\n\tpartial := models.NewSceneMarkerPartial()\n\n\tpartial.Title = translator.optionalString(input.Title, \"title\")\n\n\tpartial.PrimaryTagID, err = translator.optionalIntFromString(input.PrimaryTagID, \"primary_tag_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting primary tag id: %w\", err)\n\t}\n\n\tpartial.TagIDs, err = translator.updateIdsBulk(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tret := []*models.SceneMarker{}\n\n\t// Start the transaction and save the performers\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.SceneMarker\n\n\t\tfor _, id := range ids {\n\t\t\tl := partial\n\n\t\t\tif err := adjustMarkerPartialForTagExclusion(ctx, r.repository.SceneMarker, id, &l); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tupdated, err := qb.UpdatePartial(ctx, id, l)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, updated)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside of txn\n\tvar newRet []*models.SceneMarker\n\tfor _, m := range ret {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, m.ID, hook.SceneMarkerUpdatePost, input, translator.getFields())\n\n\t\tm, err = r.getSceneMarker(ctx, m.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, m)\n\t}\n\n\treturn newRet, nil\n}\n\n// adjustMarkerPartialForTagExclusion adjusts the SceneMarkerPartial to exclude the primary tag from tag updates.\nfunc adjustMarkerPartialForTagExclusion(ctx context.Context, r models.SceneMarkerReader, id int, partial *models.SceneMarkerPartial) error {\n\tif partial.TagIDs == nil && !partial.PrimaryTagID.Set {\n\t\treturn nil\n\t}\n\n\t// exclude primary tag from tag updates\n\tvar primaryTagID int\n\tif partial.PrimaryTagID.Set {\n\t\tprimaryTagID = partial.PrimaryTagID.Value\n\t} else {\n\t\texisting, err := r.Find(ctx, id)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding existing primary tag id: %w\", err)\n\t\t}\n\n\t\tprimaryTagID = existing.PrimaryTagID\n\t}\n\n\texistingTagIDs, err := r.GetTagIDs(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting existing tag ids: %w\", err)\n\t}\n\n\ttagIDAttr := partial.TagIDs\n\n\tif tagIDAttr == nil {\n\t\ttagIDAttr = &models.UpdateIDs{\n\t\t\tIDs:  existingTagIDs,\n\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t}\n\t}\n\n\tnewTagIDs := tagIDAttr.Apply(existingTagIDs)\n\t// Remove primary tag from newTagIDs if present\n\tnewTagIDs = sliceutil.Exclude(newTagIDs, []int{primaryTagID})\n\n\tif len(existingTagIDs) != len(newTagIDs) {\n\t\tpartial.TagIDs = &models.UpdateIDs{\n\t\t\tIDs:  newTagIDs,\n\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t}\n\t} else {\n\t\t// no change to tags required\n\t\tpartial.TagIDs = nil\n\t}\n\n\treturn nil\n}\n\nfunc (r *mutationResolver) SceneMarkerDestroy(ctx context.Context, id string) (bool, error) {\n\treturn r.SceneMarkersDestroy(ctx, []string{id})\n}\n\nfunc (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs []string) (bool, error) {\n\tids, err := stringslice.StringSliceToIntSlice(markerIDs)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\tvar markers []*models.SceneMarker\n\tfileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()\n\ttrashPath := manager.GetInstance().Config.GetDeleteTrashPath()\n\n\tfileDeleter := &scene.FileDeleter{\n\t\tDeleter:        file.NewDeleterWithTrash(trashPath),\n\t\tFileNamingAlgo: fileNamingAlgo,\n\t\tPaths:          manager.GetInstance().Paths,\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.SceneMarker\n\t\tsqb := r.repository.Scene\n\n\t\tfor _, markerID := range ids {\n\t\t\tmarker, err := qb.Find(ctx, markerID)\n\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif marker == nil {\n\t\t\t\treturn fmt.Errorf(\"scene marker with id %d not found\", markerID)\n\t\t\t}\n\n\t\t\ts, err := sqb.Find(ctx, marker.SceneID)\n\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif s == nil {\n\t\t\t\treturn fmt.Errorf(\"scene with id %d not found\", marker.SceneID)\n\t\t\t}\n\n\t\t\tmarkers = append(markers, marker)\n\n\t\t\tif err := scene.DestroyMarker(ctx, s, marker, qb, fileDeleter); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tfileDeleter.Rollback()\n\t\treturn false, err\n\t}\n\n\tfileDeleter.Commit()\n\n\tfor _, marker := range markers {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, marker.ID, hook.SceneMarkerDestroyPost, markerIDs, nil)\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) SceneSaveActivity(ctx context.Context, id string, resumeTime *float64, playDuration *float64) (ret bool, err error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tret, err = qb.SaveActivity(ctx, sceneID, resumeTime, playDuration)\n\t\treturn err\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) SceneResetActivity(ctx context.Context, id string, resetResume *bool, resetDuration *bool) (ret bool, err error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tret, err = qb.ResetActivity(ctx, sceneID, utils.IsTrue(resetResume), utils.IsTrue(resetDuration))\n\t\treturn err\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn ret, nil\n}\n\n// deprecated\nfunc (r *mutationResolver) SceneIncrementPlayCount(ctx context.Context, id string) (ret int, err error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar updatedTimes []time.Time\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tupdatedTimes, err = qb.AddViews(ctx, sceneID, nil)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn len(updatedTimes), nil\n}\n\nfunc (r *mutationResolver) SceneAddPlay(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar times []time.Time\n\n\t// convert time to local time, so that sorting is consistent\n\tfor _, tt := range t {\n\t\ttimes = append(times, tt.Local())\n\t}\n\n\tvar updatedTimes []time.Time\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tupdatedTimes, err = qb.AddViews(ctx, sceneID, times)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &HistoryMutationResult{\n\t\tCount:   len(updatedTimes),\n\t\tHistory: sliceutil.ValuesToPtrs(updatedTimes),\n\t}, nil\n}\n\nfunc (r *mutationResolver) SceneDeletePlay(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar times []time.Time\n\n\tfor _, tt := range t {\n\t\ttimes = append(times, *tt)\n\t}\n\n\tvar updatedTimes []time.Time\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tupdatedTimes, err = qb.DeleteViews(ctx, sceneID, times)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &HistoryMutationResult{\n\t\tCount:   len(updatedTimes),\n\t\tHistory: sliceutil.ValuesToPtrs(updatedTimes),\n\t}, nil\n}\n\nfunc (r *mutationResolver) SceneResetPlayCount(ctx context.Context, id string) (ret int, err error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tret, err = qb.DeleteAllViews(ctx, sceneID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\n// deprecated\nfunc (r *mutationResolver) SceneIncrementO(ctx context.Context, id string) (ret int, err error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar updatedTimes []time.Time\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tupdatedTimes, err = qb.AddO(ctx, sceneID, nil)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn len(updatedTimes), nil\n}\n\n// deprecated\nfunc (r *mutationResolver) SceneDecrementO(ctx context.Context, id string) (ret int, err error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar updatedTimes []time.Time\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tupdatedTimes, err = qb.DeleteO(ctx, sceneID, nil)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn len(updatedTimes), nil\n}\n\nfunc (r *mutationResolver) SceneResetO(ctx context.Context, id string) (ret int, err error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tret, err = qb.ResetO(ctx, sceneID)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) SceneAddO(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar times []time.Time\n\n\t// convert time to local time, so that sorting is consistent\n\tfor _, tt := range t {\n\t\ttimes = append(times, tt.Local())\n\t}\n\n\tvar updatedTimes []time.Time\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tupdatedTimes, err = qb.AddO(ctx, sceneID, times)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &HistoryMutationResult{\n\t\tCount:   len(updatedTimes),\n\t\tHistory: sliceutil.ValuesToPtrs(updatedTimes),\n\t}, nil\n}\n\nfunc (r *mutationResolver) SceneDeleteO(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) {\n\tsceneID, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar times []time.Time\n\n\tfor _, tt := range t {\n\t\ttimes = append(times, *tt)\n\t}\n\n\tvar updatedTimes []time.Time\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tupdatedTimes, err = qb.DeleteO(ctx, sceneID, times)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &HistoryMutationResult{\n\t\tCount:   len(updatedTimes),\n\t\tHistory: sliceutil.ValuesToPtrs(updatedTimes),\n\t}, nil\n}\n\nfunc (r *mutationResolver) SceneGenerateScreenshot(ctx context.Context, id string, at *float64) (string, error) {\n\tif at != nil {\n\t\tmanager.GetInstance().GenerateScreenshot(ctx, id, *at)\n\t} else {\n\t\tmanager.GetInstance().GenerateDefaultScreenshot(ctx, id)\n\t}\n\n\treturn \"todo\", nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_scraper.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n)\n\nfunc (r *mutationResolver) ReloadScrapers(ctx context.Context) (bool, error) {\n\tmanager.GetInstance().RefreshScraperCache()\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_stash_box.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/stashbox\"\n)\n\nfunc (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) {\n\tb, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tids, err := stringslice.StringSliceToIntSlice(input.SceneIds)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tclient := r.newStashBoxClient(*b)\n\n\tvar scenes []*models.Scene\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tscenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)\n\t\treturn err\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn client.SubmitFingerprints(ctx, scenes)\n}\n\nfunc (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {\n\tb, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tjobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, b, input)\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {\n\tb, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tjobID := manager.GetInstance().StashBoxBatchStudioTag(ctx, b, input)\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {\n\tb, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tjobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input)\n\treturn strconv.Itoa(jobID), nil\n}\n\nfunc (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) {\n\tb, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tclient := r.newStashBoxClient(*b)\n\n\tid, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar res *string\n\terr = r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\t\tscene, err := qb.Find(ctx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif scene == nil {\n\t\t\treturn fmt.Errorf(\"scene with id %d not found\", id)\n\t\t}\n\n\t\tcover, err := qb.GetCover(ctx, id)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error getting scene cover: %v\", err)\n\t\t}\n\n\t\tdraft, err := r.makeSceneDraft(ctx, scene, cover)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tres, err = client.SubmitSceneDraft(ctx, *draft)\n\t\treturn err\n\t})\n\n\treturn res, err\n}\n\nfunc (r *mutationResolver) makeSceneDraft(ctx context.Context, s *models.Scene, cover []byte) (*stashbox.SceneDraft, error) {\n\tif err := s.LoadURLs(ctx, r.repository.Scene); err != nil {\n\t\treturn nil, fmt.Errorf(\"loading scene URLs: %w\", err)\n\t}\n\n\tif err := s.LoadStashIDs(ctx, r.repository.Scene); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdraft := &stashbox.SceneDraft{\n\t\tScene: s,\n\t}\n\n\tpqb := r.repository.Performer\n\tsqb := r.repository.Studio\n\n\tif s.StudioID != nil {\n\t\tvar err error\n\t\tdraft.Studio, err = sqb.Find(ctx, *s.StudioID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif draft.Studio == nil {\n\t\t\treturn nil, fmt.Errorf(\"studio with id %d not found\", *s.StudioID)\n\t\t}\n\n\t\tif err := draft.Studio.LoadStashIDs(ctx, r.repository.Studio); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// submit all file fingerprints\n\tif err := s.LoadFiles(ctx, r.repository.Scene); err != nil {\n\t\treturn nil, err\n\t}\n\n\tscenePerformers, err := pqb.FindBySceneID(ctx, s.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, p := range scenePerformers {\n\t\tif err := p.LoadStashIDs(ctx, pqb); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tdraft.Performers = scenePerformers\n\n\tdraft.Tags, err = r.repository.Tag.FindBySceneID(ctx, s.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Load StashIDs for tags\n\ttqb := r.repository.Tag\n\tfor _, t := range draft.Tags {\n\t\tif err := t.LoadStashIDs(ctx, tqb); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tdraft.Cover = cover\n\n\treturn draft, nil\n}\n\nfunc (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) {\n\tb, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tclient := r.newStashBoxClient(*b)\n\n\tid, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tvar res *string\n\terr = r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Performer\n\t\tperformer, err := qb.Find(ctx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif performer == nil {\n\t\t\treturn fmt.Errorf(\"performer with id %d not found\", id)\n\t\t}\n\n\t\tpqb := r.repository.Performer\n\t\tif err := performer.LoadAliases(ctx, pqb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := performer.LoadURLs(ctx, pqb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := performer.LoadStashIDs(ctx, pqb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\timg, _ := pqb.GetImage(ctx, performer.ID)\n\n\t\tres, err = client.SubmitPerformerDraft(ctx, performer, img)\n\t\treturn err\n\t})\n\n\treturn res, err\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_studio.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/studio\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\n// used to refetch studio after hooks run\nfunc (r *mutationResolver) getStudio(ctx context.Context, id int) (ret *models.Studio, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Studio.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) StudioCreate(ctx context.Context, input models.StudioCreateInput) (*models.Studio, error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate a new studio from the input\n\tnewStudio := models.NewCreateStudioInput()\n\n\tnewStudio.Name = strings.TrimSpace(input.Name)\n\tnewStudio.Rating = input.Rating100\n\tnewStudio.Favorite = translator.bool(input.Favorite)\n\tnewStudio.Details = translator.string(input.Details)\n\tnewStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)\n\tnewStudio.Organized = translator.bool(input.Organized)\n\tnewStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name))\n\tnewStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())\n\n\tvar err error\n\n\tnewStudio.URLs = models.NewRelatedStrings([]string{})\n\tif input.URL != nil {\n\t\tnewStudio.URLs.Add(strings.TrimSpace(*input.URL))\n\t}\n\n\tif input.Urls != nil {\n\t\tnewStudio.URLs.Add(stringslice.TrimSpace(input.Urls)...)\n\t}\n\n\tnewStudio.ParentID, err = translator.intPtrFromString(input.ParentID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting parent id: %w\", err)\n\t}\n\n\tnewStudio.TagIDs, err = translator.relatedIds(input.TagIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\tnewStudio.CustomFields = convertMapJSONNumbers(input.CustomFields)\n\n\t// Process the base 64 encoded image string\n\tvar imageData []byte\n\tif input.Image != nil {\n\t\tvar err error\n\t\timageData, err = utils.ProcessImageInput(ctx, *input.Image)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing image: %w\", err)\n\t\t}\n\t}\n\n\t// Start the transaction and save the studio\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Studio\n\n\t\tif err := studio.ValidateCreate(ctx, newStudio, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\terr = qb.Create(ctx, &newStudio)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(imageData) > 0 {\n\t\t\tif err := qb.UpdateImage(ctx, newStudio.ID, imageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, newStudio.ID, hook.StudioCreatePost, input, nil)\n\treturn r.getStudio(ctx, newStudio.ID)\n}\n\nfunc (r *mutationResolver) StudioUpdate(ctx context.Context, input models.StudioUpdateInput) (*models.Studio, error) {\n\tstudioID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate studio from the input\n\tupdatedStudio := models.NewStudioPartial()\n\n\tupdatedStudio.ID = studioID\n\tupdatedStudio.Name = translator.optionalString(input.Name, \"name\")\n\tupdatedStudio.Details = translator.optionalString(input.Details, \"details\")\n\tupdatedStudio.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tupdatedStudio.Favorite = translator.optionalBool(input.Favorite, \"favorite\")\n\tupdatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, \"ignore_auto_tag\")\n\tupdatedStudio.Organized = translator.optionalBool(input.Organized, \"organized\")\n\tupdatedStudio.Aliases = translator.updateStrings(input.Aliases, \"aliases\")\n\tupdatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, \"stash_ids\")\n\n\tupdatedStudio.ParentID, err = translator.optionalIntFromString(input.ParentID, \"parent_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting parent id: %w\", err)\n\t}\n\n\tupdatedStudio.TagIDs, err = translator.updateIds(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tif translator.hasField(\"urls\") {\n\t\t// ensure url not included in the input\n\t\tif err := validateNoLegacyURLs(translator); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tupdatedStudio.URLs = translator.updateStrings(input.Urls, \"urls\")\n\t} else if translator.hasField(\"url\") {\n\t\t// handle legacy url field\n\t\tlegacyURLs := []string{}\n\t\tif input.URL != nil {\n\t\t\tlegacyURLs = append(legacyURLs, *input.URL)\n\t\t}\n\n\t\tupdatedStudio.URLs = &models.UpdateStrings{\n\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\tValues: legacyURLs,\n\t\t}\n\t}\n\n\tupdatedStudio.CustomFields = input.CustomFields\n\t// convert json.Numbers to int/float\n\tupdatedStudio.CustomFields.Full = convertMapJSONNumbers(updatedStudio.CustomFields.Full)\n\tupdatedStudio.CustomFields.Partial = convertMapJSONNumbers(updatedStudio.CustomFields.Partial)\n\n\t// Process the base 64 encoded image string\n\tvar imageData []byte\n\timageIncluded := translator.hasField(\"image\")\n\tif input.Image != nil {\n\t\tvar err error\n\t\timageData, err = utils.ProcessImageInput(ctx, *input.Image)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing image: %w\", err)\n\t\t}\n\t}\n\n\t// Start the transaction and update the studio\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Studio\n\n\t\tif updatedStudio.Aliases != nil {\n\t\t\ts, err := qb.Find(ctx, studioID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif s != nil {\n\t\t\t\tif err := s.LoadAliases(ctx, qb); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\teffectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List())\n\t\t\t\tname := s.Name\n\t\t\t\tif updatedStudio.Name.Set {\n\t\t\t\t\tname = updatedStudio.Name.Value\n\t\t\t\t}\n\n\t\t\t\tsanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)\n\t\t\t\tupdatedStudio.Aliases.Values = sanitized\n\t\t\t\tupdatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet\n\t\t\t}\n\t\t}\n\n\t\tif err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = qb.UpdatePartial(ctx, updatedStudio)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif imageIncluded {\n\t\t\tif err := qb.UpdateImage(ctx, studioID, imageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, studioID, hook.StudioUpdatePost, input, translator.getFields())\n\treturn r.getStudio(ctx, studioID)\n}\n\nfunc (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudioUpdateInput) ([]*models.Studio, error) {\n\tids, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate performer from the input\n\tpartial := models.NewStudioPartial()\n\n\tpartial.ParentID, err = translator.optionalIntFromString(input.ParentID, \"parent_id\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting parent id: %w\", err)\n\t}\n\n\tif translator.hasField(\"urls\") {\n\t\t// ensure url/twitter/instagram are not included in the input\n\t\tif err := validateNoLegacyURLs(translator); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tpartial.URLs = translator.updateStringsBulk(input.Urls, \"urls\")\n\t} else if translator.hasField(\"url\") {\n\t\t// handle legacy url field\n\t\tlegacyURLs := []string{}\n\t\tif input.URL != nil {\n\t\t\tlegacyURLs = append(legacyURLs, *input.URL)\n\t\t}\n\n\t\tpartial.URLs = &models.UpdateStrings{\n\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\tValues: legacyURLs,\n\t\t}\n\t}\n\n\tpartial.Favorite = translator.optionalBool(input.Favorite, \"favorite\")\n\tpartial.Rating = translator.optionalInt(input.Rating100, \"rating100\")\n\tpartial.Details = translator.optionalString(input.Details, \"details\")\n\tpartial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, \"ignore_auto_tag\")\n\tpartial.Organized = translator.optionalBool(input.Organized, \"organized\")\n\n\tpartial.TagIDs, err = translator.updateIdsBulk(input.TagIds, \"tag_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tag ids: %w\", err)\n\t}\n\n\tret := []*models.Studio{}\n\n\t// Start the transaction and save the performers\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Studio\n\n\t\tfor _, id := range ids {\n\t\t\tlocal := partial\n\t\t\tlocal.ID = id\n\t\t\tif err := studio.ValidateModify(ctx, local, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tupdated, err := qb.UpdatePartial(ctx, local)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, updated)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside of txn\n\tvar newRet []*models.Studio\n\tfor _, studio := range ret {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, studio.ID, hook.StudioUpdatePost, input, translator.getFields())\n\n\t\tstudio, err = r.getStudio(ctx, studio.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, studio)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) StudioDestroy(ctx context.Context, input StudioDestroyInput) (bool, error) {\n\tid, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.repository.Studio.Destroy(ctx, id)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.StudioDestroyPost, input, nil)\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) StudiosDestroy(ctx context.Context, studioIDs []string) (bool, error) {\n\tids, err := stringslice.StringSliceToIntSlice(studioIDs)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Studio\n\t\tfor _, id := range ids {\n\t\t\tif err := qb.Destroy(ctx, id); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tfor _, id := range ids {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.StudioDestroyPost, studioIDs, nil)\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_tag.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/tag\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc (r *mutationResolver) getTag(ctx context.Context, id int) (ret *models.Tag, err error) {\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Tag.Find(ctx, id)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) (*models.Tag, error) {\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate a new tag from the input\n\tnewTag := models.CreateTagInput{\n\t\tTag: &models.Tag{},\n\t}\n\t*newTag.Tag = models.NewTag()\n\n\tnewTag.Name = strings.TrimSpace(input.Name)\n\tnewTag.SortName = translator.string(input.SortName)\n\tnewTag.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newTag.Name))\n\tnewTag.Favorite = translator.bool(input.Favorite)\n\tnewTag.Description = translator.string(input.Description)\n\tnewTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)\n\n\tvar stashIDInputs models.StashIDInputs\n\tfor _, sid := range input.StashIds {\n\t\tif sid != nil {\n\t\t\tstashIDInputs = append(stashIDInputs, *sid)\n\t\t}\n\t}\n\tnewTag.StashIDs = models.NewRelatedStashIDs(stashIDInputs.ToStashIDs())\n\n\tvar err error\n\n\tnewTag.ParentIDs, err = translator.relatedIds(input.ParentIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting parent tag ids: %w\", err)\n\t}\n\n\tnewTag.ChildIDs, err = translator.relatedIds(input.ChildIds)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting child tag ids: %w\", err)\n\t}\n\n\tnewTag.CustomFields = convertMapJSONNumbers(input.CustomFields)\n\n\t// Process the base 64 encoded image string\n\tvar imageData []byte\n\tif input.Image != nil {\n\t\timageData, err = utils.ProcessImageInput(ctx, *input.Image)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing image: %w\", err)\n\t\t}\n\t}\n\n\t// Start the transaction and save the tag\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Tag\n\n\t\tif err := tag.ValidateCreate(ctx, *newTag.Tag, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\terr = qb.Create(ctx, &newTag)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// update image table\n\t\tif len(imageData) > 0 {\n\t\t\tif err := qb.UpdateImage(ctx, newTag.ID, imageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, newTag.ID, hook.TagCreatePost, input, nil)\n\treturn r.getTag(ctx, newTag.ID)\n}\n\nfunc tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (*models.TagPartial, error) {\n\tupdatedTag := models.NewTagPartial()\n\n\tupdatedTag.Name = translator.optionalString(input.Name, \"name\")\n\tupdatedTag.SortName = translator.optionalString(input.SortName, \"sort_name\")\n\tupdatedTag.Favorite = translator.optionalBool(input.Favorite, \"favorite\")\n\tupdatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, \"ignore_auto_tag\")\n\tupdatedTag.Description = translator.optionalString(input.Description, \"description\")\n\n\tupdatedTag.Aliases = translator.updateStrings(input.Aliases, \"aliases\")\n\n\tvar updateStashIDInputs models.StashIDInputs\n\tfor _, sid := range input.StashIds {\n\t\tif sid != nil {\n\t\t\tupdateStashIDInputs = append(updateStashIDInputs, *sid)\n\t\t}\n\t}\n\tupdatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, \"stash_ids\")\n\n\tvar err error\n\tupdatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, \"parent_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting parent tag ids: %w\", err)\n\t}\n\n\tupdatedTag.ChildIDs, err = translator.updateIds(input.ChildIds, \"child_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting child tag ids: %w\", err)\n\t}\n\n\tif input.CustomFields != nil {\n\t\tupdatedTag.CustomFields = *input.CustomFields\n\t\t// convert json.Numbers to int/float\n\t\tupdatedTag.CustomFields.Full = convertMapJSONNumbers(updatedTag.CustomFields.Full)\n\t\tupdatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial)\n\t}\n\n\treturn &updatedTag, nil\n}\n\nfunc (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {\n\ttagID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate tag from the input\n\tupdatedTag, err := tagPartialFromInput(input, translator)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar imageData []byte\n\timageIncluded := translator.hasField(\"image\")\n\tif input.Image != nil {\n\t\timageData, err = utils.ProcessImageInput(ctx, *input.Image)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"processing image: %w\", err)\n\t\t}\n\t}\n\n\t// Start the transaction and save the tag\n\tvar t *models.Tag\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Tag\n\n\t\tif updatedTag.Aliases != nil {\n\t\t\tt, err := qb.Find(ctx, tagID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif t != nil {\n\t\t\t\tif err := t.LoadAliases(ctx, qb); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tnewAliases := updatedTag.Aliases.Apply(t.Aliases.List())\n\t\t\t\tname := t.Name\n\t\t\t\tif updatedTag.Name.Set {\n\t\t\t\t\tname = updatedTag.Name.Value\n\t\t\t\t}\n\n\t\t\t\tsanitized := stringslice.UniqueExcludeFold(newAliases, name)\n\t\t\t\tupdatedTag.Aliases.Values = sanitized\n\t\t\t\tupdatedTag.Aliases.Mode = models.RelationshipUpdateModeSet\n\t\t\t}\n\t\t}\n\n\t\tif err := tag.ValidateUpdate(ctx, tagID, *updatedTag, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tt, err = qb.UpdatePartial(ctx, tagID, *updatedTag)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// update image table\n\t\tif imageIncluded {\n\t\t\tif err := qb.UpdateImage(ctx, tagID, imageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagUpdatePost, input, translator.getFields())\n\treturn r.getTag(ctx, t.ID)\n}\n\nfunc (r *mutationResolver) BulkTagUpdate(ctx context.Context, input BulkTagUpdateInput) ([]*models.Tag, error) {\n\ttagIDs, err := stringslice.StringSliceToIntSlice(input.Ids)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\ttranslator := changesetTranslator{\n\t\tinputMap: getUpdateInputMap(ctx),\n\t}\n\n\t// Populate scene from the input\n\tupdatedTag := models.NewTagPartial()\n\n\tupdatedTag.Description = translator.optionalString(input.Description, \"description\")\n\tupdatedTag.Favorite = translator.optionalBool(input.Favorite, \"favorite\")\n\tupdatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, \"ignore_auto_tag\")\n\n\tupdatedTag.Aliases = translator.updateStringsBulk(input.Aliases, \"aliases\")\n\n\tupdatedTag.ParentIDs, err = translator.updateIdsBulk(input.ParentIds, \"parent_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting parent tag ids: %w\", err)\n\t}\n\n\tupdatedTag.ChildIDs, err = translator.updateIdsBulk(input.ChildIds, \"child_ids\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting child tag ids: %w\", err)\n\t}\n\n\tret := []*models.Tag{}\n\n\t// Start the transaction and save the scenes\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Tag\n\n\t\tfor _, tagID := range tagIDs {\n\t\t\tif err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\ttag, err := qb.UpdatePartial(ctx, tagID, updatedTag)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tret = append(ret, tag)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// execute post hooks outside of txn\n\tvar newRet []*models.Tag\n\tfor _, tag := range ret {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, tag.ID, hook.TagUpdatePost, input, translator.getFields())\n\n\t\ttag, err = r.getTag(ctx, tag.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tnewRet = append(newRet, tag)\n\t}\n\n\treturn newRet, nil\n}\n\nfunc (r *mutationResolver) TagDestroy(ctx context.Context, input TagDestroyInput) (bool, error) {\n\ttagID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting id: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\treturn r.repository.Tag.Destroy(ctx, tagID)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, tagID, hook.TagDestroyPost, input, nil)\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) TagsDestroy(ctx context.Context, tagIDs []string) (bool, error) {\n\tids, err := stringslice.StringSliceToIntSlice(tagIDs)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"converting ids: %w\", err)\n\t}\n\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Tag\n\t\tfor _, id := range ids {\n\t\t\tif err := qb.Destroy(ctx, id); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\n\tfor _, id := range ids {\n\t\tr.hookExecutor.ExecutePostHooks(ctx, id, hook.TagDestroyPost, tagIDs, nil)\n\t}\n\n\treturn true, nil\n}\n\nfunc (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput) (*models.Tag, error) {\n\tsource, err := stringslice.StringSliceToIntSlice(input.Source)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting source ids: %w\", err)\n\t}\n\n\tdestination, err := strconv.Atoi(input.Destination)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting destination id: %w\", err)\n\t}\n\n\tif len(source) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tvar values *models.TagPartial\n\tvar imageData []byte\n\n\tif input.Values != nil {\n\t\ttranslator := changesetTranslator{\n\t\t\tinputMap: getNamedUpdateInputMap(ctx, \"input.values\"),\n\t\t}\n\n\t\tvalues, err = tagPartialFromInput(*input.Values, translator)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif input.Values.Image != nil {\n\t\t\tvar err error\n\t\t\timageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"processing cover image: %w\", err)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tv := models.NewTagPartial()\n\t\tvalues = &v\n\t}\n\n\tvar t *models.Tag\n\tif err := r.withTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Tag\n\n\t\tvar err error\n\t\tt, err = qb.Find(ctx, destination)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif t == nil {\n\t\t\treturn fmt.Errorf(\"tag with id %d not found\", destination)\n\t\t}\n\n\t\tif err = qb.Merge(ctx, source, destination); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := tag.ValidateUpdate(ctx, destination, *values, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := qb.UpdatePartial(ctx, destination, *values); err != nil {\n\t\t\treturn fmt.Errorf(\"updating tag: %w\", err)\n\t\t}\n\n\t\tif len(imageData) > 0 {\n\t\t\tif err := qb.UpdateImage(ctx, destination, imageData); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagMergePost, input, nil)\n\n\treturn t, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_mutation_tag_test.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\n// TODO - move this into a common area\nfunc newResolver(db *mocks.Database) *Resolver {\n\treturn &Resolver{\n\t\trepository:   db.Repository(),\n\t\thookExecutor: &mockHookExecutor{},\n\t}\n}\n\nconst (\n\ttagName    = \"tagName\"\n\terrTagName = \"errTagName\"\n\n\texistingTagID   = 1\n\texistingTagName = \"existingTagName\"\n\n\tnewTagID = 2\n)\n\nvar testCtx = context.Background()\n\ntype mockHookExecutor struct{}\n\nfunc (*mockHookExecutor) ExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) {\n}\n\nfunc TestTagCreate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\tr := newResolver(db)\n\n\tpp := 1\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\ttagFilterForName := func(n string) *models.TagFilterType {\n\t\treturn &models.TagFilterType{\n\t\t\tName: &models.StringCriterionInput{\n\t\t\t\tValue:    n,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t}\n\t}\n\n\ttagFilterForAlias := func(n string) *models.TagFilterType {\n\t\treturn &models.TagFilterType{\n\t\t\tAliases: &models.StringCriterionInput{\n\t\t\t\tValue:    n,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t}\n\t}\n\n\tdb.Tag.On(\"Query\", mock.Anything, tagFilterForName(existingTagName), findFilter).Return([]*models.Tag{\n\t\t{\n\t\t\tID:   existingTagID,\n\t\t\tName: existingTagName,\n\t\t},\n\t}, 1, nil).Once()\n\tdb.Tag.On(\"Query\", mock.Anything, tagFilterForName(errTagName), findFilter).Return(nil, 0, nil).Once()\n\tdb.Tag.On(\"Query\", mock.Anything, tagFilterForAlias(errTagName), findFilter).Return(nil, 0, nil).Once()\n\n\texpectedErr := errors.New(\"TagCreate error\")\n\tdb.Tag.On(\"Create\", mock.Anything, mock.AnythingOfType(\"*models.Tag\")).Return(expectedErr)\n\n\t// fails here because testCtx is empty\n\t// TODO: Fix this\n\tif 1 != 0 {\n\t\treturn\n\t}\n\n\t_, err := r.Mutation().TagCreate(testCtx, TagCreateInput{\n\t\tName: existingTagName,\n\t})\n\n\tassert.NotNil(t, err)\n\n\t_, err = r.Mutation().TagCreate(testCtx, TagCreateInput{\n\t\tName: errTagName,\n\t})\n\n\tassert.Equal(t, expectedErr, err)\n\tdb.AssertExpectations(t)\n\n\tdb = mocks.NewDatabase()\n\tr = newResolver(db)\n\n\tdb.Tag.On(\"Query\", mock.Anything, tagFilterForName(tagName), findFilter).Return(nil, 0, nil).Once()\n\tdb.Tag.On(\"Query\", mock.Anything, tagFilterForAlias(tagName), findFilter).Return(nil, 0, nil).Once()\n\tnewTag := &models.Tag{\n\t\tID:   newTagID,\n\t\tName: tagName,\n\t}\n\tdb.Tag.On(\"Create\", mock.Anything, mock.AnythingOfType(\"*models.Tag\")).Run(func(args mock.Arguments) {\n\t\targ := args.Get(1).(*models.Tag)\n\t\targ.ID = newTagID\n\t}).Return(nil)\n\tdb.Tag.On(\"Find\", mock.Anything, newTagID).Return(newTag, nil)\n\n\ttag, err := r.Mutation().TagCreate(testCtx, TagCreateInput{\n\t\tName: tagName,\n\t})\n\n\tassert.Nil(t, err)\n\tassert.NotNil(t, tag)\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "internal/api/resolver_query_configuration.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"golang.org/x/text/collate\"\n)\n\nfunc (r *queryResolver) Configuration(ctx context.Context) (*ConfigResult, error) {\n\treturn makeConfigResult(), nil\n}\n\nfunc (r *queryResolver) Directory(ctx context.Context, path, locale *string) (*Directory, error) {\n\n\tdirectory := &Directory{}\n\tvar err error\n\n\tcol := newCollator(locale, collate.IgnoreCase, collate.Numeric)\n\n\tvar dirPath = \"\"\n\tif path != nil {\n\t\tdirPath = *path\n\t}\n\tcurrentDir := getDir(dirPath)\n\tdirectories, err := listDir(col, currentDir)\n\tif err != nil {\n\t\treturn directory, err\n\t}\n\n\tdirectory.Path = currentDir\n\tdirectory.Parent = getParent(currentDir)\n\tdirectory.Directories = directories\n\n\treturn directory, err\n}\n\nfunc getDir(path string) string {\n\tif path == \"\" {\n\t\tpath = fsutil.GetHomeDirectory()\n\t}\n\n\treturn path\n}\n\nfunc getParent(path string) *string {\n\tisRoot := path == \"/\"\n\tif isRoot {\n\t\treturn nil\n\t} else {\n\t\tparentPath := filepath.Clean(path + \"/..\")\n\t\treturn &parentPath\n\t}\n}\n\nfunc makeConfigResult() *ConfigResult {\n\treturn &ConfigResult{\n\t\tGeneral:   makeConfigGeneralResult(),\n\t\tInterface: makeConfigInterfaceResult(),\n\t\tDlna:      makeConfigDLNAResult(),\n\t\tScraping:  makeConfigScrapingResult(),\n\t\tDefaults:  makeConfigDefaultsResult(),\n\t\tUI:        makeConfigUIResult(),\n\t}\n}\n\nfunc makeConfigGeneralResult() *ConfigGeneralResult {\n\tconfig := config.GetInstance()\n\tlogFile := config.GetLogFile()\n\n\tmaxTranscodeSize := config.GetMaxTranscodeSize()\n\tmaxStreamingTranscodeSize := config.GetMaxStreamingTranscodeSize()\n\n\tcustomPerformerImageLocation := config.GetCustomPerformerImageLocation()\n\n\treturn &ConfigGeneralResult{\n\t\tStashes:                       config.GetStashPaths(),\n\t\tDatabasePath:                  config.GetDatabasePath(),\n\t\tBackupDirectoryPath:           config.GetBackupDirectoryPath(),\n\t\tDeleteTrashPath:               config.GetDeleteTrashPath(),\n\t\tGeneratedPath:                 config.GetGeneratedPath(),\n\t\tMetadataPath:                  config.GetMetadataPath(),\n\t\tConfigFilePath:                config.GetConfigFile(),\n\t\tScrapersPath:                  config.GetScrapersPath(),\n\t\tPluginsPath:                   config.GetPluginsPath(),\n\t\tCachePath:                     config.GetCachePath(),\n\t\tBlobsPath:                     config.GetBlobsPath(),\n\t\tBlobsStorage:                  config.GetBlobsStorage(),\n\t\tFfmpegPath:                    config.GetFFMpegPath(),\n\t\tFfprobePath:                   config.GetFFProbePath(),\n\t\tCalculateMd5:                  config.IsCalculateMD5(),\n\t\tVideoFileNamingAlgorithm:      config.GetVideoFileNamingAlgorithm(),\n\t\tParallelTasks:                 config.GetParallelTasks(),\n\t\tUseCustomSpriteInterval:       config.GetUseCustomSpriteInterval(),\n\t\tSpriteInterval:                config.GetSpriteInterval(),\n\t\tSpriteScreenshotSize:          config.GetSpriteScreenshotSize(),\n\t\tMinimumSprites:                config.GetMinimumSprites(),\n\t\tMaximumSprites:                config.GetMaximumSprites(),\n\t\tPreviewAudio:                  config.GetPreviewAudio(),\n\t\tPreviewSegments:               config.GetPreviewSegments(),\n\t\tPreviewSegmentDuration:        config.GetPreviewSegmentDuration(),\n\t\tPreviewExcludeStart:           config.GetPreviewExcludeStart(),\n\t\tPreviewExcludeEnd:             config.GetPreviewExcludeEnd(),\n\t\tPreviewPreset:                 config.GetPreviewPreset(),\n\t\tTranscodeHardwareAcceleration: config.GetTranscodeHardwareAcceleration(),\n\t\tMaxTranscodeSize:              &maxTranscodeSize,\n\t\tMaxStreamingTranscodeSize:     &maxStreamingTranscodeSize,\n\t\tWriteImageThumbnails:          config.IsWriteImageThumbnails(),\n\t\tCreateImageClipsFromVideos:    config.IsCreateImageClipsFromVideos(),\n\t\tGalleryCoverRegex:             config.GetGalleryCoverRegex(),\n\t\tAPIKey:                        config.GetAPIKey(),\n\t\tUsername:                      config.GetUsername(),\n\t\tPassword:                      config.GetPasswordHash(),\n\t\tMaxSessionAge:                 config.GetMaxSessionAge(),\n\t\tLogFile:                       &logFile,\n\t\tLogOut:                        config.GetLogOut(),\n\t\tLogLevel:                      config.GetLogLevel(),\n\t\tLogAccess:                     config.GetLogAccess(),\n\t\tLogFileMaxSize:                config.GetLogFileMaxSize(),\n\t\tVideoExtensions:               config.GetVideoExtensions(),\n\t\tImageExtensions:               config.GetImageExtensions(),\n\t\tGalleryExtensions:             config.GetGalleryExtensions(),\n\t\tCreateGalleriesFromFolders:    config.GetCreateGalleriesFromFolders(),\n\t\tExcludes:                      config.GetExcludes(),\n\t\tImageExcludes:                 config.GetImageExcludes(),\n\t\tCustomPerformerImageLocation:  &customPerformerImageLocation,\n\t\tStashBoxes:                    config.GetStashBoxes(),\n\t\tPythonPath:                    config.GetPythonPath(),\n\t\tTranscodeInputArgs:            config.GetTranscodeInputArgs(),\n\t\tTranscodeOutputArgs:           config.GetTranscodeOutputArgs(),\n\t\tLiveTranscodeInputArgs:        config.GetLiveTranscodeInputArgs(),\n\t\tLiveTranscodeOutputArgs:       config.GetLiveTranscodeOutputArgs(),\n\t\tDrawFunscriptHeatmapRange:     config.GetDrawFunscriptHeatmapRange(),\n\t\tScraperPackageSources:         config.GetScraperPackageSources(),\n\t\tPluginPackageSources:          config.GetPluginPackageSources(),\n\t}\n}\n\nfunc makeConfigInterfaceResult() *ConfigInterfaceResult {\n\tconfig := config.GetInstance()\n\tmenuItems := config.GetMenuItems()\n\tsoundOnPreview := config.GetSoundOnPreview()\n\twallShowTitle := config.GetWallShowTitle()\n\tshowScrubber := config.GetShowScrubber()\n\twallPlayback := config.GetWallPlayback()\n\tnoBrowser := config.GetNoBrowser()\n\tnotificationsEnabled := config.GetNotificationsEnabled()\n\tmaximumLoopDuration := config.GetMaximumLoopDuration()\n\tautostartVideo := config.GetAutostartVideo()\n\tautostartVideoOnPlaySelected := config.GetAutostartVideoOnPlaySelected()\n\tcontinuePlaylistDefault := config.GetContinuePlaylistDefault()\n\tshowStudioAsText := config.GetShowStudioAsText()\n\tcss := config.GetCSS()\n\tcssEnabled := config.GetCSSEnabled()\n\tjavascript := config.GetJavascript()\n\tjavascriptEnabled := config.GetJavascriptEnabled()\n\tcustomLocales := config.GetCustomLocales()\n\tcustomLocalesEnabled := config.GetCustomLocalesEnabled()\n\tdisableCustomizations := config.GetDisableCustomizations()\n\tlanguage := config.GetLanguage()\n\thandyKey := config.GetHandyKey()\n\tscriptOffset := config.GetFunscriptOffset()\n\tuseStashHostedFunscript := config.GetUseStashHostedFunscript()\n\timageLightboxOptions := config.GetImageLightboxOptions()\n\tdisableDropdownCreate := config.GetDisableDropdownCreate()\n\n\treturn &ConfigInterfaceResult{\n\t\tSfwContentMode:               config.GetSFWContentMode(),\n\t\tMenuItems:                    menuItems,\n\t\tSoundOnPreview:               &soundOnPreview,\n\t\tWallShowTitle:                &wallShowTitle,\n\t\tWallPlayback:                 &wallPlayback,\n\t\tShowScrubber:                 &showScrubber,\n\t\tMaximumLoopDuration:          &maximumLoopDuration,\n\t\tNoBrowser:                    &noBrowser,\n\t\tNotificationsEnabled:         &notificationsEnabled,\n\t\tAutostartVideo:               &autostartVideo,\n\t\tShowStudioAsText:             &showStudioAsText,\n\t\tAutostartVideoOnPlaySelected: &autostartVideoOnPlaySelected,\n\t\tContinuePlaylistDefault:      &continuePlaylistDefault,\n\t\tCSS:                          &css,\n\t\tCSSEnabled:                   &cssEnabled,\n\t\tJavascript:                   &javascript,\n\t\tJavascriptEnabled:            &javascriptEnabled,\n\t\tCustomLocales:                &customLocales,\n\t\tCustomLocalesEnabled:         &customLocalesEnabled,\n\t\tDisableCustomizations:        &disableCustomizations,\n\t\tLanguage:                     &language,\n\n\t\tImageLightbox: &imageLightboxOptions,\n\n\t\tDisableDropdownCreate: disableDropdownCreate,\n\n\t\tHandyKey:                &handyKey,\n\t\tFunscriptOffset:         &scriptOffset,\n\t\tUseStashHostedFunscript: &useStashHostedFunscript,\n\t}\n}\n\nfunc makeConfigDLNAResult() *ConfigDLNAResult {\n\tconfig := config.GetInstance()\n\n\treturn &ConfigDLNAResult{\n\t\tServerName:     config.GetDLNAServerName(),\n\t\tEnabled:        config.GetDLNADefaultEnabled(),\n\t\tPort:           config.GetDLNAPort(),\n\t\tWhitelistedIPs: config.GetDLNADefaultIPWhitelist(),\n\t\tInterfaces:     config.GetDLNAInterfaces(),\n\t\tVideoSortOrder: config.GetVideoSortOrder(),\n\t}\n}\n\nfunc makeConfigScrapingResult() *ConfigScrapingResult {\n\tconfig := config.GetInstance()\n\n\tscraperUserAgent := config.GetScraperUserAgent()\n\tscraperCDPPath := config.GetScraperCDPPath()\n\n\treturn &ConfigScrapingResult{\n\t\tScraperUserAgent:   &scraperUserAgent,\n\t\tScraperCertCheck:   config.GetScraperCertCheck(),\n\t\tScraperCDPPath:     &scraperCDPPath,\n\t\tExcludeTagPatterns: config.GetScraperExcludeTagPatterns(),\n\t}\n}\n\nfunc makeConfigDefaultsResult() *ConfigDefaultSettingsResult {\n\tconfig := config.GetInstance()\n\tdeleteFileDefault := config.GetDeleteFileDefault()\n\tdeleteGeneratedDefault := config.GetDeleteGeneratedDefault()\n\n\treturn &ConfigDefaultSettingsResult{\n\t\tIdentify:        config.GetDefaultIdentifySettings(),\n\t\tScan:            config.GetDefaultScanSettings(),\n\t\tAutoTag:         config.GetDefaultAutoTagSettings(),\n\t\tGenerate:        config.GetDefaultGenerateSettings(),\n\t\tDeleteFile:      &deleteFileDefault,\n\t\tDeleteGenerated: &deleteGeneratedDefault,\n\t}\n}\n\nfunc makeConfigUIResult() map[string]interface{} {\n\treturn config.GetInstance().GetUIConfiguration()\n}\n\nfunc (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input config.StashBoxInput) (*StashBoxValidationResult, error) {\n\tbox := models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}\n\tclient := r.newStashBoxClient(box)\n\n\tuser, err := client.GetUser(ctx)\n\n\tvalid := user != nil && user.Me != nil\n\tvar status string\n\tif valid {\n\t\tstatus = fmt.Sprintf(\"Successfully authenticated as %s\", user.Me.Name)\n\t} else {\n\t\terrorStr := strings.ToLower(err.Error())\n\t\tswitch {\n\t\tcase strings.Contains(errorStr, \"doctype\"):\n\t\t\t// Index file returned rather than graphql\n\t\t\tstatus = \"Invalid endpoint\"\n\t\tcase strings.Contains(errorStr, \"request failed\"):\n\t\t\tstatus = \"No response from server\"\n\t\tcase strings.Contains(errorStr, \"invalid character\") ||\n\t\t\tstrings.Contains(errorStr, \"illegal base64 data\") ||\n\t\t\tstrings.Contains(errorStr, \"unexpected end of json input\") ||\n\t\t\tstrings.Contains(errorStr, \"token contains an invalid number of segments\"):\n\t\t\tstatus = \"Malformed API key.\"\n\t\tcase strings.Contains(errorStr, \"signature is invalid\"):\n\t\t\tstatus = \"Invalid or expired API key.\"\n\t\tdefault:\n\t\t\tstatus = fmt.Sprintf(\"Unknown error: %s\", err)\n\t\t}\n\t}\n\n\tresult := StashBoxValidationResult{\n\t\tValid:  valid,\n\t\tStatus: status,\n\t}\n\n\treturn &result, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_dlna.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/dlna\"\n\t\"github.com/stashapp/stash/internal/manager\"\n)\n\nfunc (r *queryResolver) DlnaStatus(ctx context.Context) (*dlna.Status, error) {\n\treturn manager.GetInstance().DLNAService.Status(), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_file.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n)\n\nfunc (r *queryResolver) FindFile(ctx context.Context, id *string, path *string) (BaseFile, error) {\n\tvar ret models.File\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.File\n\t\tvar err error\n\t\tswitch {\n\t\tcase id != nil:\n\t\t\tidInt, err := strconv.Atoi(*id)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tvar files []models.File\n\t\t\tfiles, err = qb.Find(ctx, models.FileID(idInt))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(files) > 0 {\n\t\t\t\tret = files[0]\n\t\t\t}\n\t\tcase path != nil:\n\t\t\tret, err = qb.FindByPath(ctx, *path, true)\n\t\t\tif err == nil && ret == nil {\n\t\t\t\treturn errors.New(\"file not found\")\n\t\t\t}\n\t\tdefault:\n\t\t\treturn errors.New(\"either id or path must be provided\")\n\t\t}\n\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn convertBaseFile(ret), nil\n}\n\nfunc (r *queryResolver) FindFiles(\n\tctx context.Context,\n\tfileFilter *models.FileFilterType,\n\tfilter *models.FindFilterType,\n\tids []string,\n) (ret *FindFilesResultType, err error) {\n\tvar fileIDs []models.FileID\n\tif len(ids) > 0 {\n\t\tfileIDsInt, err := stringslice.StringSliceToIntSlice(ids)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfileIDs = models.FileIDsFromInts(fileIDsInt)\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar files []models.File\n\t\tvar err error\n\n\t\tfields := collectQueryFields(ctx)\n\t\tresult := &models.FileQueryResult{}\n\n\t\tif len(fileIDs) > 0 {\n\t\t\tfiles, err = r.repository.File.Find(ctx, fileIDs...)\n\t\t\tif err == nil {\n\t\t\t\tresult.Count = len(files)\n\t\t\t\tfor _, f := range files {\n\t\t\t\t\tif asVideo, ok := f.(*models.VideoFile); ok {\n\t\t\t\t\t\tresult.TotalDuration += asVideo.Duration\n\t\t\t\t\t}\n\t\t\t\t\tif asImage, ok := f.(*models.ImageFile); ok {\n\t\t\t\t\t\tresult.Megapixels += asImage.Megapixels()\n\t\t\t\t\t}\n\n\t\t\t\t\tresult.TotalSize += f.Base().Size\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tresult, err = r.repository.File.Query(ctx, models.FileQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: filter,\n\t\t\t\t\tCount:      fields.Has(\"count\"),\n\t\t\t\t},\n\t\t\t\tFileFilter:    fileFilter,\n\t\t\t\tTotalDuration: fields.Has(\"duration\"),\n\t\t\t\tMegapixels:    fields.Has(\"megapixels\"),\n\t\t\t\tTotalSize:     fields.Has(\"size\"),\n\t\t\t})\n\t\t\tif err == nil {\n\t\t\t\tfiles, err = result.Resolve(ctx)\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindFilesResultType{\n\t\t\tCount:      result.Count,\n\t\t\tFiles:      convertBaseFiles(files),\n\t\t\tDuration:   result.TotalDuration,\n\t\t\tMegapixels: result.Megapixels,\n\t\t\tSize:       int(result.TotalSize),\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_folder.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {\n\tvar ret *models.Folder\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Folder\n\t\tvar err error\n\t\tswitch {\n\t\tcase id != nil:\n\t\t\tidInt, err := strconv.Atoi(*id)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tret, err = qb.Find(ctx, models.FolderID(idInt))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\tcase path != nil:\n\t\t\tret, err = qb.FindByPath(ctx, *path, true)\n\t\t\tif err == nil && ret == nil {\n\t\t\t\treturn errors.New(\"folder not found\")\n\t\t\t}\n\t\tdefault:\n\t\t\treturn errors.New(\"either id or path must be provided\")\n\t\t}\n\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindFolders(\n\tctx context.Context,\n\tfolderFilter *models.FolderFilterType,\n\tfilter *models.FindFilterType,\n\tids []string,\n) (ret *FindFoldersResultType, err error) {\n\tvar folderIDs []models.FolderID\n\tif len(ids) > 0 {\n\t\tfolderIDsInt, err := handleIDList(ids, \"ids\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfolderIDs = models.FolderIDsFromInts(folderIDsInt)\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar folders []*models.Folder\n\t\tvar err error\n\n\t\tfields := collectQueryFields(ctx)\n\t\tresult := &models.FolderQueryResult{}\n\n\t\tif len(folderIDs) > 0 {\n\t\t\tfolders, err = r.repository.Folder.FindMany(ctx, folderIDs)\n\t\t\tif err == nil {\n\t\t\t\tresult.Count = len(folders)\n\t\t\t}\n\t\t} else {\n\t\t\tresult, err = r.repository.Folder.Query(ctx, models.FolderQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: filter,\n\t\t\t\t\tCount:      fields.Has(\"count\"),\n\t\t\t\t},\n\t\t\t\tFolderFilter: folderFilter,\n\t\t\t})\n\t\t\tif err == nil {\n\t\t\t\tfolders, err = result.Resolve(ctx)\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindFoldersResultType{\n\t\t\tCount:   result.Count,\n\t\t\tFolders: folders,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_gallery.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) {\n\tidInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Gallery.Find(ctx, idInt)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) {\n\tidInts, err := handleIDList(ids, \"ids\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar galleries []*models.Gallery\n\t\tvar err error\n\t\tvar total int\n\n\t\tif len(idInts) > 0 {\n\t\t\tgalleries, err = r.repository.Gallery.FindMany(ctx, idInts)\n\t\t\ttotal = len(galleries)\n\t\t} else {\n\t\t\tgalleries, total, err = r.repository.Gallery.Query(ctx, galleryFilter, filter)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindGalleriesResultType{\n\t\t\tCount:     total,\n\t\t\tGalleries: galleries,\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AllGalleries(ctx context.Context) (ret []*models.Gallery, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Gallery.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_group.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) {\n\tidInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Group.Find(ctx, idInt)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) {\n\tidInts, err := handleIDList(ids, \"ids\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar groups []*models.Group\n\t\tvar err error\n\t\tvar total int\n\n\t\tif len(idInts) > 0 {\n\t\t\tgroups, err = r.repository.Group.FindMany(ctx, idInts)\n\t\t\ttotal = len(groups)\n\t\t} else {\n\t\t\tgroups, total, err = r.repository.Group.Query(ctx, groupFilter, filter)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindGroupsResultType{\n\t\t\tCount:  total,\n\t\t\tGroups: groups,\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_image.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"slices\"\n\t\"strconv\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) {\n\tvar image *models.Image\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Image\n\t\tvar err error\n\n\t\tif id != nil {\n\t\t\tidInt, err := strconv.Atoi(*id)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\timage, err = qb.Find(ctx, idInt)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else if checksum != nil {\n\t\t\tvar images []*models.Image\n\t\t\timages, err = qb.FindByChecksum(ctx, *checksum)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(images) > 0 {\n\t\t\t\timage = images[0]\n\t\t\t}\n\t\t}\n\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn image, nil\n}\n\nfunc (r *queryResolver) FindImages(\n\tctx context.Context,\n\timageFilter *models.ImageFilterType,\n\timageIds []int,\n\tids []string,\n\tfilter *models.FindFilterType,\n) (ret *FindImagesResultType, err error) {\n\tif len(ids) > 0 {\n\t\timageIds, err = handleIDList(ids, \"ids\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Image\n\n\t\tvar images []*models.Image\n\t\tfields := graphql.CollectAllFields(ctx)\n\t\tresult := &models.ImageQueryResult{}\n\n\t\tif len(imageIds) > 0 {\n\t\t\timages, err = r.repository.Image.FindMany(ctx, imageIds)\n\t\t\tif err == nil {\n\t\t\t\tresult.Count = len(images)\n\t\t\t\tfor _, s := range images {\n\t\t\t\t\tif err = s.LoadPrimaryFile(ctx, r.repository.File); err != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\n\t\t\t\t\tf := s.Files.Primary()\n\t\t\t\t\tif f == nil {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\timageFile, ok := f.(*models.ImageFile)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tresult.Megapixels += float64(imageFile.Width*imageFile.Height) / float64(1000000)\n\t\t\t\t\tresult.TotalSize += float64(f.Base().Size)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tresult, err = qb.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: filter,\n\t\t\t\t\tCount:      slices.Contains(fields, \"count\"),\n\t\t\t\t},\n\t\t\t\tImageFilter: imageFilter,\n\t\t\t\tMegapixels:  slices.Contains(fields, \"megapixels\"),\n\t\t\t\tTotalSize:   slices.Contains(fields, \"filesize\"),\n\t\t\t})\n\t\t\tif err == nil {\n\t\t\t\timages, err = result.Resolve(ctx)\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindImagesResultType{\n\t\t\tCount:      result.Count,\n\t\t\tImages:     images,\n\t\t\tMegapixels: result.Megapixels,\n\t\t\tFilesize:   result.TotalSize,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AllImages(ctx context.Context) (ret []*models.Image, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Image.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_movie.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) {\n\tidInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Group.Find(ctx, idInt)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) {\n\tidInts, err := handleIDList(ids, \"ids\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar groups []*models.Group\n\t\tvar err error\n\t\tvar total int\n\n\t\tif len(idInts) > 0 {\n\t\t\tgroups, err = r.repository.Group.FindMany(ctx, idInts)\n\t\t\ttotal = len(groups)\n\t\t} else {\n\t\t\tgroups, total, err = r.repository.Group.Query(ctx, movieFilter, filter)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindMoviesResultType{\n\t\t\tCount:  total,\n\t\t\tMovies: groups,\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AllMovies(ctx context.Context) (ret []*models.Group, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Group.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_performer.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) {\n\tidInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Performer.Find(ctx, idInt)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) {\n\tif len(ids) > 0 {\n\t\tperformerIDs, err = handleIDList(ids, \"ids\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// #5682 - convert JSON numbers to float64 or int64\n\tif performerFilter != nil {\n\t\tperformerFilter.CustomFields = convertCustomFieldCriterionInputJSONNumbers(performerFilter.CustomFields)\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar performers []*models.Performer\n\t\tvar err error\n\t\tvar total int\n\n\t\tif len(performerIDs) > 0 {\n\t\t\tperformers, err = r.repository.Performer.FindMany(ctx, performerIDs)\n\t\t\ttotal = len(performers)\n\t\t} else {\n\t\t\tperformers, total, err = r.repository.Performer.Query(ctx, performerFilter, filter)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindPerformersResultType{\n\t\t\tCount:      total,\n\t\t\tPerformers: performers,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AllPerformers(ctx context.Context) (ret []*models.Performer, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Performer.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_saved_filter.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/mitchellh/mapstructure\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *models.SavedFilter, err error) {\n\tidInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.SavedFilter.Find(ctx, idInt)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ret, err\n}\n\nfunc (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.FilterMode) (ret []*models.SavedFilter, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tif mode != nil {\n\t\t\tret, err = r.repository.SavedFilter.FindByMode(ctx, *mode)\n\t\t} else {\n\t\t\tret, err = r.repository.SavedFilter.All(ctx)\n\t\t}\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ret, err\n}\n\nfunc (r *queryResolver) FindDefaultFilter(ctx context.Context, mode models.FilterMode) (ret *models.SavedFilter, err error) {\n\t// deprecated - read from the config in the meantime\n\tconfig := config.GetInstance()\n\n\tuiConfig := config.GetUIConfiguration()\n\tif uiConfig == nil {\n\t\treturn nil, nil\n\t}\n\n\tm := utils.NestedMap(uiConfig)\n\tfilterRaw, _ := m.Get(\"defaultFilters.\" + strings.ToLower(mode.String()))\n\n\tif filterRaw == nil {\n\t\treturn nil, nil\n\t}\n\n\tret = &models.SavedFilter{}\n\td, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{\n\t\tTagName:          \"json\",\n\t\tWeaklyTypedInput: true,\n\t\tResult:           ret,\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := d.Decode(filterRaw); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_scene.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"slices\"\n\t\"strconv\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\nfunc (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {\n\tvar scene *models.Scene\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\t\tvar err error\n\t\tif id != nil {\n\t\t\tidInt, err := strconv.Atoi(*id)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tscene, err = qb.Find(ctx, idInt)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else if checksum != nil {\n\t\t\tvar scenes []*models.Scene\n\t\t\tscenes, err = qb.FindByChecksum(ctx, *checksum)\n\t\t\tif len(scenes) > 0 {\n\t\t\t\tscene = scenes[0]\n\t\t\t}\n\t\t}\n\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn scene, nil\n}\n\nfunc (r *queryResolver) FindSceneByHash(ctx context.Context, input SceneHashInput) (*models.Scene, error) {\n\tvar scene *models.Scene\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\t\tif input.Checksum != nil {\n\t\t\tscenes, err := qb.FindByChecksum(ctx, *input.Checksum)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(scenes) > 0 {\n\t\t\t\tscene = scenes[0]\n\t\t\t}\n\t\t}\n\n\t\tif scene == nil && input.Oshash != nil {\n\t\t\tscenes, err := qb.FindByOSHash(ctx, *input.Oshash)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(scenes) > 0 {\n\t\t\t\tscene = scenes[0]\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn scene, nil\n}\n\nfunc (r *queryResolver) FindScenes(\n\tctx context.Context,\n\tsceneFilter *models.SceneFilterType,\n\tsceneIDs []int,\n\tids []string,\n\tfilter *models.FindFilterType,\n) (ret *FindScenesResultType, err error) {\n\tif len(ids) > 0 {\n\t\tsceneIDs, err = handleIDList(ids, \"ids\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar scenes []*models.Scene\n\t\tvar err error\n\n\t\tfields := graphql.CollectAllFields(ctx)\n\t\tresult := &models.SceneQueryResult{}\n\n\t\tif len(sceneIDs) > 0 {\n\t\t\tscenes, err = r.repository.Scene.FindMany(ctx, sceneIDs)\n\t\t\tif err == nil {\n\t\t\t\tresult.Count = len(scenes)\n\t\t\t\tfor _, s := range scenes {\n\t\t\t\t\tif err = s.LoadPrimaryFile(ctx, r.repository.File); err != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\n\t\t\t\t\tf := s.Files.Primary()\n\t\t\t\t\tif f == nil {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tresult.TotalDuration += f.Duration\n\n\t\t\t\t\tresult.TotalSize += float64(f.Size)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tresult, err = r.repository.Scene.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: filter,\n\t\t\t\t\tCount:      slices.Contains(fields, \"count\"),\n\t\t\t\t},\n\t\t\t\tSceneFilter:   sceneFilter,\n\t\t\t\tTotalDuration: slices.Contains(fields, \"duration\"),\n\t\t\t\tTotalSize:     slices.Contains(fields, \"filesize\"),\n\t\t\t})\n\t\t\tif err == nil {\n\t\t\t\tscenes, err = result.Resolve(ctx)\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindScenesResultType{\n\t\t\tCount:    result.Count,\n\t\t\tScenes:   scenes,\n\t\t\tDuration: result.TotalDuration,\n\t\t\tFilesize: result.TotalSize,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *models.FindFilterType) (ret *FindScenesResultType, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\n\t\tsceneFilter := &models.SceneFilterType{}\n\n\t\tif filter != nil && filter.Q != nil {\n\t\t\tsceneFilter.Path = &models.StringCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\tValue:    \"(?i)\" + *filter.Q,\n\t\t\t}\n\t\t}\n\n\t\t// make a copy of the filter if provided, nilling out Q\n\t\tvar queryFilter *models.FindFilterType\n\t\tif filter != nil {\n\t\t\tf := *filter\n\t\t\tqueryFilter = &f\n\t\t\tqueryFilter.Q = nil\n\t\t}\n\n\t\tfields := graphql.CollectAllFields(ctx)\n\n\t\tresult, err := r.repository.Scene.Query(ctx, models.SceneQueryOptions{\n\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\tFindFilter: queryFilter,\n\t\t\t\tCount:      slices.Contains(fields, \"count\"),\n\t\t\t},\n\t\t\tSceneFilter:   sceneFilter,\n\t\t\tTotalDuration: slices.Contains(fields, \"duration\"),\n\t\t\tTotalSize:     slices.Contains(fields, \"filesize\"),\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tscenes, err := result.Resolve(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindScenesResultType{\n\t\t\tCount:    result.Count,\n\t\t\tScenes:   scenes,\n\t\t\tDuration: result.TotalDuration,\n\t\t\tFilesize: result.TotalSize,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.FindFilterType, config models.SceneParserInput) (ret *SceneParserResultType, err error) {\n\trepo := scene.NewFilenameParserRepository(r.repository)\n\tparser := scene.NewFilenameParser(filter, config, repo)\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tresult, count, err := parser.Parse(ctx)\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &SceneParserResultType{\n\t\t\tCount:   count,\n\t\t\tResults: result,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Scene, err error) {\n\tdist := 0\n\tdurDiff := -1.\n\tif distance != nil {\n\t\tdist = *distance\n\t}\n\tif durationDiff != nil {\n\t\tdurDiff = *durationDiff\n\t}\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AllScenes(ctx context.Context) (ret []*models.Scene, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Scene.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_scene_marker.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType, ids []string) (ret *FindSceneMarkersResultType, err error) {\n\tidInts, err := handleIDList(ids, \"ids\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar sceneMarkers []*models.SceneMarker\n\t\tvar err error\n\t\tvar total int\n\n\t\tif len(idInts) > 0 {\n\t\t\tsceneMarkers, err = r.repository.SceneMarker.FindMany(ctx, idInts)\n\t\t\ttotal = len(sceneMarkers)\n\t\t} else {\n\t\t\tsceneMarkers, total, err = r.repository.SceneMarker.Query(ctx, sceneMarkerFilter, filter)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindSceneMarkersResultType{\n\t\t\tCount:        total,\n\t\t\tSceneMarkers: sceneMarkers,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AllSceneMarkers(ctx context.Context) (ret []*models.SceneMarker, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.SceneMarker.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_studio.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) {\n\tidInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\tret, err = r.repository.Studio.Find(ctx, idInt)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) {\n\tidInts, err := handleIDList(ids, \"ids\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar studios []*models.Studio\n\t\tvar err error\n\t\tvar total int\n\n\t\tif len(idInts) > 0 {\n\t\t\tstudios, err = r.repository.Studio.FindMany(ctx, idInts)\n\t\t\ttotal = len(studios)\n\t\t} else {\n\t\t\tstudios, total, err = r.repository.Studio.Query(ctx, studioFilter, filter)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindStudiosResultType{\n\t\t\tCount:   total,\n\t\t\tStudios: studios,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AllStudios(ctx context.Context) (ret []*models.Studio, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Studio.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_find_tag.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) {\n\tidInt, err := strconv.Atoi(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Tag.Find(ctx, idInt)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) {\n\tidInts, err := handleIDList(ids, \"ids\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar tags []*models.Tag\n\t\tvar err error\n\t\tvar total int\n\n\t\tif len(idInts) > 0 {\n\t\t\ttags, err = r.repository.Tag.FindMany(ctx, idInts)\n\t\t\ttotal = len(tags)\n\t\t} else {\n\t\t\ttags, total, err = r.repository.Tag.Query(ctx, tagFilter, filter)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = &FindTagsResultType{\n\t\t\tCount: total,\n\t\t\tTags:  tags,\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AllTags(ctx context.Context) (ret []*models.Tag, err error) {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = r.repository.Tag.All(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_job.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/job\"\n)\n\nfunc (r *queryResolver) JobQueue(ctx context.Context) ([]*Job, error) {\n\tqueue := manager.GetInstance().JobManager.GetQueue()\n\n\tvar ret []*Job\n\tfor _, j := range queue {\n\t\tret = append(ret, jobToJobModel(j))\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*Job, error) {\n\tjobID, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tj := manager.GetInstance().JobManager.GetJob(jobID)\n\tif j == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn jobToJobModel(*j), nil\n}\n\nfunc jobToJobModel(j job.Job) *Job {\n\tret := &Job{\n\t\tID:          strconv.Itoa(j.ID),\n\t\tStatus:      JobStatus(j.Status),\n\t\tDescription: j.Description,\n\t\tSubTasks:    j.Details,\n\t\tStartTime:   j.StartTime,\n\t\tEndTime:     j.EndTime,\n\t\tAddTime:     j.AddTime,\n\t\tError:       j.Error,\n\t}\n\n\tif j.Progress != -1 {\n\t\tret.Progress = &j.Progress\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "internal/api/resolver_query_logs.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n)\n\nfunc (r *queryResolver) Logs(ctx context.Context) ([]*LogEntry, error) {\n\tlogger := manager.GetInstance().Logger\n\tlogCache := logger.GetLogCache()\n\tret := make([]*LogEntry, len(logCache))\n\n\tfor i, entry := range logCache {\n\t\tret[i] = &LogEntry{\n\t\t\tTime:    entry.Time,\n\t\t\tLevel:   getLogLevel(entry.Type),\n\t\t\tMessage: entry.Message,\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_metadata.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n)\n\nfunc (r *queryResolver) SystemStatus(ctx context.Context) (*manager.SystemStatus, error) {\n\treturn manager.GetInstance().GetSystemStatus(), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_package.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/pkg\"\n)\n\nvar ErrInvalidPackageType = errors.New(\"invalid package type\")\n\nfunc getPackageManager(typeArg PackageType) (*pkg.Manager, error) {\n\tvar pm *pkg.Manager\n\tswitch typeArg {\n\tcase PackageTypeScraper:\n\t\tpm = manager.GetInstance().ScraperPackageManager\n\tcase PackageTypePlugin:\n\t\tpm = manager.GetInstance().PluginPackageManager\n\tdefault:\n\t\treturn nil, ErrInvalidPackageType\n\t}\n\n\tif pm == nil {\n\t\treturn nil, fmt.Errorf(\"%s package manager not initialized\", typeArg)\n\t}\n\n\treturn pm, nil\n}\n\nfunc manifestToPackage(p pkg.Manifest) *Package {\n\tret := &Package{\n\t\tPackageID: p.ID,\n\t\tName:      p.Name,\n\t\tSourceURL: p.RepositoryURL,\n\t}\n\n\tif len(p.Version) > 0 {\n\t\tret.Version = &p.Version\n\t}\n\tif !p.Date.IsZero() {\n\t\tret.Date = &p.Date.Time\n\t}\n\n\tret.Metadata = p.Metadata\n\tif ret.Metadata == nil {\n\t\tret.Metadata = make(map[string]interface{})\n\t}\n\n\treturn ret\n}\n\nfunc remotePackageToPackage(p pkg.RemotePackage, index pkg.RemotePackageIndex) *Package {\n\tret := &Package{\n\t\tPackageID: p.ID,\n\t\tName:      p.Name,\n\t}\n\n\tif len(p.Version) > 0 {\n\t\tret.Version = &p.Version\n\t}\n\tif !p.Date.IsZero() {\n\t\tret.Date = &p.Date.Time\n\t}\n\n\tret.Metadata = p.Metadata\n\tif ret.Metadata == nil {\n\t\tret.Metadata = make(map[string]interface{})\n\t}\n\n\tret.SourceURL = p.Repository.Path()\n\n\tfor _, r := range p.Requires {\n\t\t// required packages must come from the same source\n\t\tspec := models.PackageSpecInput{\n\t\t\tID:        r,\n\t\t\tSourceURL: p.Repository.Path(),\n\t\t}\n\n\t\treq, found := index[spec]\n\t\tif !found {\n\t\t\t// shouldn't happen, but we'll ignore it\n\t\t\tcontinue\n\t\t}\n\n\t\tret.Requires = append(ret.Requires, remotePackageToPackage(req, index))\n\t}\n\n\treturn ret\n}\n\nfunc sortedPackageSpecKeys[V any](m map[models.PackageSpecInput]V) []models.PackageSpecInput {\n\t// sort keys\n\tvar keys []models.PackageSpecInput\n\tfor k := range m {\n\t\tkeys = append(keys, k)\n\t}\n\n\tsort.Slice(keys, func(i, j int) bool {\n\t\ta := keys[i]\n\t\tb := keys[j]\n\n\t\taID := a.ID\n\t\tbID := b.ID\n\n\t\tif aID == bID {\n\t\t\treturn a.SourceURL < b.SourceURL\n\t\t}\n\n\t\taIDL := strings.ToLower(aID)\n\t\tbIDL := strings.ToLower(bID)\n\n\t\tif aIDL == bIDL {\n\t\t\treturn aID < bID\n\t\t}\n\n\t\treturn aIDL < bIDL\n\t})\n\n\treturn keys\n}\n\nfunc (r *queryResolver) getInstalledPackagesWithUpgrades(ctx context.Context, pm *pkg.Manager) ([]*Package, error) {\n\t// get all installed packages\n\tinstalled, err := pm.ListInstalled(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// get remotes for all installed packages\n\tallRemoteList, err := pm.ListInstalledRemotes(ctx, installed)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpackageStatusIndex := pkg.MakePackageStatusIndex(installed, allRemoteList)\n\n\tret := make([]*Package, len(packageStatusIndex))\n\ti := 0\n\n\tfor _, k := range sortedPackageSpecKeys(packageStatusIndex) {\n\t\tv := packageStatusIndex[k]\n\t\tp := manifestToPackage(*v.Local)\n\t\tif v.Remote != nil {\n\t\t\tpp := remotePackageToPackage(*v.Remote, allRemoteList)\n\t\t\tp.SourcePackage = pp\n\t\t}\n\t\tret[i] = p\n\t\ti++\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) InstalledPackages(ctx context.Context, typeArg PackageType) ([]*Package, error) {\n\tpm, err := getPackageManager(typeArg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret []*Package\n\n\tif slices.Contains(graphql.CollectAllFields(ctx), \"source_package\") {\n\t\tret, err = r.getInstalledPackagesWithUpgrades(ctx, pm)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t} else {\n\t\tinstalled, err := pm.ListInstalled(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = make([]*Package, len(installed))\n\t\ti := 0\n\t\tfor _, k := range sortedPackageSpecKeys(installed) {\n\t\t\tret[i] = manifestToPackage(installed[k])\n\t\t\ti++\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) AvailablePackages(ctx context.Context, typeArg PackageType, source string) ([]*Package, error) {\n\tpm, err := getPackageManager(typeArg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tavailable, err := pm.ListRemote(ctx, source)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([]*Package, len(available))\n\ti := 0\n\tfor _, k := range sortedPackageSpecKeys(available) {\n\t\tp := available[k]\n\t\tret[i] = remotePackageToPackage(p, available)\n\n\t\ti++\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_plugin.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n)\n\nfunc (r *queryResolver) Plugins(ctx context.Context) ([]*plugin.Plugin, error) {\n\treturn manager.GetInstance().PluginCache.ListPlugins(), nil\n}\n\nfunc (r *queryResolver) PluginTasks(ctx context.Context) ([]*plugin.PluginTask, error) {\n\treturn manager.GetInstance().PluginCache.ListPluginTasks(), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_query_scene.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/api/urlbuilders\"\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manager.SceneStreamEndpoint, error) {\n\tsceneID, err := strconv.Atoi(*id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// find the scene\n\tvar scene *models.Scene\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\tscene, err = r.repository.Scene.Find(ctx, sceneID)\n\n\t\tif scene != nil {\n\t\t\terr = scene.LoadPrimaryFile(ctx, r.repository.File)\n\t\t}\n\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif scene == nil {\n\t\treturn nil, fmt.Errorf(\"scene with id %d not found\", sceneID)\n\t}\n\n\tconfig := manager.GetInstance().Config\n\n\tbaseURL, _ := ctx.Value(BaseURLCtxKey).(string)\n\tbuilder := urlbuilders.NewSceneURLBuilder(baseURL, scene)\n\tapiKey := config.GetAPIKey()\n\n\treturn manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())\n}\n"
  },
  {
    "path": "internal/api/resolver_query_scraper.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n)\n\nfunc (r *queryResolver) ScrapeURL(ctx context.Context, url string, ty scraper.ScrapeContentType) (scraper.ScrapedContent, error) {\n\treturn r.scraperCache().ScrapeURL(ctx, url, ty)\n}\n\nfunc (r *queryResolver) ListScrapers(ctx context.Context, types []scraper.ScrapeContentType) ([]*scraper.Scraper, error) {\n\treturn r.scraperCache().ListScrapers(types), nil\n}\n\nfunc (r *queryResolver) ScrapePerformerURL(ctx context.Context, url string) (*models.ScrapedPerformer, error) {\n\tcontent, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypePerformer)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn marshalScrapedPerformer(content)\n}\n\nfunc (r *queryResolver) ScrapeSceneQuery(ctx context.Context, scraperID string, query string) ([]*models.ScrapedScene, error) {\n\tif query == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tcontent, err := r.scraperCache().ScrapeName(ctx, scraperID, query, scraper.ScrapeContentTypeScene)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret, err := marshalScrapedScenes(content)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*models.ScrapedScene, error) {\n\tcontent, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret, err := marshalScrapedScene(content)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) ScrapeGalleryURL(ctx context.Context, url string) (*models.ScrapedGallery, error) {\n\tcontent, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeGallery)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret, err := marshalScrapedGallery(content)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) ScrapeImageURL(ctx context.Context, url string) (*models.ScrapedImage, error) {\n\tcontent, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeImage)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn marshalScrapedImage(content)\n}\n\nfunc (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models.ScrapedMovie, error) {\n\tcontent, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeMovie)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret, err := marshalScrapedMovie(content)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) ScrapeGroupURL(ctx context.Context, url string) (*models.ScrapedGroup, error) {\n\tcontent, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeGroup)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret, err := marshalScrapedGroup(content)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// convert to scraped group\n\tgroup := &models.ScrapedGroup{\n\t\tStoredID:   ret.StoredID,\n\t\tName:       ret.Name,\n\t\tAliases:    ret.Aliases,\n\t\tDuration:   ret.Duration,\n\t\tDate:       ret.Date,\n\t\tRating:     ret.Rating,\n\t\tDirector:   ret.Director,\n\t\tURLs:       ret.URLs,\n\t\tSynopsis:   ret.Synopsis,\n\t\tStudio:     ret.Studio,\n\t\tTags:       ret.Tags,\n\t\tFrontImage: ret.FrontImage,\n\t\tBackImage:  ret.BackImage,\n\t}\n\n\treturn group, nil\n}\n\nfunc (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*models.ScrapedScene, error) {\n\tvar ret []*models.ScrapedScene\n\n\tvar sceneID int\n\tif input.SceneID != nil {\n\t\tvar err error\n\t\tsceneID, err = strconv.Atoi(*input.SceneID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: sceneID is not an integer: '%s'\", ErrInput, *input.SceneID)\n\t\t}\n\t}\n\n\tswitch {\n\tcase source.ScraperID != nil:\n\t\tvar err error\n\t\tvar c scraper.ScrapedContent\n\t\tvar content []scraper.ScrapedContent\n\n\t\tswitch {\n\t\tcase input.SceneID != nil:\n\t\t\tc, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, sceneID, scraper.ScrapeContentTypeScene)\n\t\t\tif c != nil {\n\t\t\t\tcontent = []scraper.ScrapedContent{c}\n\t\t\t}\n\t\tcase input.SceneInput != nil:\n\t\t\tc, err = r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Scene: input.SceneInput})\n\t\t\tif c != nil {\n\t\t\t\tcontent = []scraper.ScrapedContent{c}\n\t\t\t}\n\t\tcase input.Query != nil:\n\t\t\tcontent, err = r.scraperCache().ScrapeName(ctx, *source.ScraperID, *input.Query, scraper.ScrapeContentTypeScene)\n\t\tdefault:\n\t\t\terr = fmt.Errorf(\"%w: scene_id, scene_input, or query must be set\", ErrInput)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret, err = marshalScrapedScenes(content)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tcase source.StashBoxIndex != nil || source.StashBoxEndpoint != nil:\n\t\tb, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tclient := r.newStashBoxClient(*b)\n\n\t\tswitch {\n\t\tcase input.SceneID != nil:\n\t\t\tvar fps []models.Fingerprints\n\t\t\tfps, err = r.getScenesFingerprints(ctx, []int{sceneID})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tret, err = client.FindSceneByFingerprints(ctx, fps[0])\n\t\tcase input.Query != nil:\n\t\t\tret, err = client.QueryScene(ctx, *input.Query)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"%w: scene_id or query must be set\", ErrInput)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// TODO - this should happen after any scene is scraped\n\t\tif err := r.matchScenesRelationships(ctx, ret, b.Endpoint); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"%w: scraper_id or stash_box_index must be set\", ErrInput)\n\t}\n\n\tfor i := range ret {\n\t\tslices.SortFunc(ret[i].Tags, models.ScrapedTagSortFunction)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.Source, input ScrapeMultiScenesInput) ([][]*models.ScrapedScene, error) {\n\tif source.ScraperID != nil {\n\t\treturn nil, ErrNotImplemented\n\t} else if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {\n\t\tb, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tclient := r.newStashBoxClient(*b)\n\n\t\tsceneIDs, err := stringslice.StringSliceToIntSlice(input.SceneIds)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfps, err := r.getScenesFingerprints(ctx, sceneIDs)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret, err := client.FindScenesByFingerprints(ctx, fps)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// match relationships - this mutates the existing scenes so we can\n\t\t// just flatten the slice and pass it in\n\t\tflat := sliceutil.Flatten(ret)\n\n\t\tif err := r.matchScenesRelationships(ctx, flat, b.Endpoint); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn ret, nil\n\t}\n\n\treturn nil, errors.New(\"scraper_id or stash_box_index must be set\")\n}\n\nfunc (r *queryResolver) getScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) {\n\tfingerprints := make([]models.Fingerprints, len(ids))\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.repository.Scene\n\n\t\tfor i, sceneID := range ids {\n\t\t\tscene, err := qb.Find(ctx, sceneID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif scene == nil {\n\t\t\t\treturn fmt.Errorf(\"scene with id %d not found\", sceneID)\n\t\t\t}\n\n\t\t\tif err := scene.LoadFiles(ctx, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tvar sceneFPs models.Fingerprints\n\n\t\t\tfor _, f := range scene.Files.List() {\n\t\t\t\tsceneFPs = append(sceneFPs, f.Fingerprints...)\n\t\t\t}\n\n\t\t\tfingerprints[i] = sceneFPs\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn fingerprints, nil\n}\n\n// matchSceneRelationships accepts scraped scenes and attempts to match its relationships to existing stash models.\nfunc (r *queryResolver) matchScenesRelationships(ctx context.Context, ss []*models.ScrapedScene, endpoint string) error {\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tmatcher := match.SceneRelationships{\n\t\t\tPerformerFinder: r.repository.Performer,\n\t\t\tTagFinder:       r.repository.Tag,\n\t\t\tStudioFinder:    r.repository.Studio,\n\t\t}\n\n\t\tfor _, s := range ss {\n\t\t\tif err := matcher.MatchRelationships(ctx, s, endpoint); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.Source, input ScrapeSingleStudioInput) ([]*models.ScrapedStudio, error) {\n\tif source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {\n\t\tb, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tclient := r.newStashBoxClient(*b)\n\n\t\tvar ret []*models.ScrapedStudio\n\t\tout, err := client.FindStudio(ctx, *input.Query)\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t} else if out != nil {\n\t\t\tret = append(ret, out)\n\t\t}\n\n\t\tif len(ret) > 0 {\n\t\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\t\tfor _, studio := range ret {\n\t\t\t\t\tif err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, b.Endpoint); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t}); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn ret, nil\n\t\t}\n\n\t\treturn nil, nil\n\t}\n\n\treturn nil, errors.New(\"stash_box_endpoint must be set\")\n}\n\nfunc (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) {\n\tif source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {\n\t\tb, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tclient := r.newStashBoxClient(*b)\n\n\t\tvar ret []*models.ScrapedTag\n\t\tout, err := client.QueryTag(ctx, *input.Query)\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t} else if out != nil {\n\t\t\tret = append(ret, out...)\n\t\t}\n\n\t\tif len(ret) > 0 {\n\t\t\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\t\t\tfor _, tag := range ret {\n\t\t\t\t\tif err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t}); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn ret, nil\n\t\t}\n\n\t\treturn nil, nil\n\t}\n\n\treturn nil, errors.New(\"stash_box_endpoint must be set\")\n}\n\nfunc (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) {\n\tvar ret []*models.ScrapedPerformer\n\tswitch {\n\tcase source.ScraperID != nil:\n\t\tswitch {\n\t\tcase input.PerformerInput != nil:\n\t\t\tperformer, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Performer: input.PerformerInput})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tret, err = marshalScrapedPerformers([]scraper.ScrapedContent{performer})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\tcase input.Query != nil:\n\t\t\tcontent, err := r.scraperCache().ScrapeName(ctx, *source.ScraperID, *input.Query, scraper.ScrapeContentTypePerformer)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tret, err = marshalScrapedPerformers(content)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\tdefault:\n\t\t\treturn nil, ErrNotImplemented\n\t\t}\n\tcase source.StashBoxIndex != nil || source.StashBoxEndpoint != nil:\n\t\tb, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tclient := r.newStashBoxClient(*b)\n\n\t\tvar query string\n\t\tswitch {\n\t\tcase input.PerformerID != nil:\n\t\t\tnames, err := r.findPerformerNames(ctx, []string{*input.PerformerID})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tquery = names[0]\n\t\tcase input.Query != nil:\n\t\t\tquery = *input.Query\n\t\tdefault:\n\t\t\treturn nil, ErrNotImplemented\n\t\t}\n\n\t\tif query == \"\" {\n\t\t\treturn nil, nil\n\t\t}\n\t\tret, err = client.QueryPerformer(ctx, query)\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tdefault:\n\t\treturn nil, errors.New(\"scraper_id or stash_box_index must be set\")\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) ScrapeMultiPerformers(ctx context.Context, source scraper.Source, input ScrapeMultiPerformersInput) ([][]*models.ScrapedPerformer, error) {\n\tif source.ScraperID != nil {\n\t\treturn nil, ErrNotImplemented\n\t} else if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {\n\t\tnames, err := r.findPerformerNames(ctx, input.PerformerIds)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tb, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tclient := r.newStashBoxClient(*b)\n\n\t\treturn client.QueryPerformers(ctx, names)\n\t}\n\n\treturn nil, errors.New(\"scraper_id or stash_box_index must be set\")\n}\n\nfunc (r *queryResolver) findPerformerNames(ctx context.Context, performerIDs []string) ([]string, error) {\n\tids, err := stringslice.StringSliceToIntSlice(performerIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnames := make([]string, len(ids))\n\n\tif err := r.withReadTxn(ctx, func(ctx context.Context) error {\n\t\tp, err := r.repository.Performer.FindMany(ctx, ids)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor i, pp := range p {\n\t\t\tnames[i] = pp.Name\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn names, nil\n}\n\nfunc (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper.Source, input ScrapeSingleGalleryInput) ([]*models.ScrapedGallery, error) {\n\tvar ret []*models.ScrapedGallery\n\n\tif source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {\n\t\treturn nil, ErrNotSupported\n\t}\n\n\tif source.ScraperID == nil {\n\t\treturn nil, fmt.Errorf(\"%w: scraper_id must be set\", ErrInput)\n\t}\n\n\tvar c scraper.ScrapedContent\n\n\tswitch {\n\tcase input.GalleryID != nil:\n\t\tgalleryID, err := strconv.Atoi(*input.GalleryID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: gallery id is not an integer: '%s'\", ErrInput, *input.GalleryID)\n\t\t}\n\t\tc, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, galleryID, scraper.ScrapeContentTypeGallery)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tret, err = marshalScrapedGalleries([]scraper.ScrapedContent{c})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tcase input.GalleryInput != nil:\n\t\tc, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Gallery: input.GalleryInput})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tret, err = marshalScrapedGalleries([]scraper.ScrapedContent{c})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tdefault:\n\t\treturn nil, ErrNotImplemented\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *queryResolver) ScrapeSingleImage(ctx context.Context, source scraper.Source, input ScrapeSingleImageInput) ([]*models.ScrapedImage, error) {\n\tif source.StashBoxIndex != nil {\n\t\treturn nil, ErrNotSupported\n\t}\n\n\tif source.ScraperID == nil {\n\t\treturn nil, fmt.Errorf(\"%w: scraper_id must be set\", ErrInput)\n\t}\n\n\tvar c scraper.ScrapedContent\n\n\tswitch {\n\tcase input.ImageID != nil:\n\t\timageID, err := strconv.Atoi(*input.ImageID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: image id is not an integer: '%s'\", ErrInput, *input.ImageID)\n\t\t}\n\t\tc, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, imageID, scraper.ScrapeContentTypeImage)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn marshalScrapedImages([]scraper.ScrapedContent{c})\n\tcase input.ImageInput != nil:\n\t\tc, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Image: input.ImageInput})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn marshalScrapedImages([]scraper.ScrapedContent{c})\n\tdefault:\n\t\treturn nil, ErrNotImplemented\n\t}\n}\n\nfunc (r *queryResolver) ScrapeSingleMovie(ctx context.Context, source scraper.Source, input ScrapeSingleMovieInput) ([]*models.ScrapedMovie, error) {\n\treturn nil, ErrNotSupported\n}\n\nfunc (r *queryResolver) ScrapeSingleGroup(ctx context.Context, source scraper.Source, input ScrapeSingleGroupInput) ([]*models.ScrapedGroup, error) {\n\treturn nil, ErrNotSupported\n}\n"
  },
  {
    "path": "internal/api/resolver_subscription_job.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/job\"\n)\n\nfunc makeJobStatusUpdate(t JobStatusUpdateType, j job.Job) *JobStatusUpdate {\n\treturn &JobStatusUpdate{\n\t\tType: t,\n\t\tJob:  jobToJobModel(j),\n\t}\n}\n\nfunc (r *subscriptionResolver) JobsSubscribe(ctx context.Context) (<-chan *JobStatusUpdate, error) {\n\tmsg := make(chan *JobStatusUpdate, 100)\n\n\tsubscription := manager.GetInstance().JobManager.Subscribe(ctx)\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase j := <-subscription.NewJob:\n\t\t\t\tmsg <- makeJobStatusUpdate(JobStatusUpdateTypeAdd, j)\n\t\t\tcase j := <-subscription.RemovedJob:\n\t\t\t\tmsg <- makeJobStatusUpdate(JobStatusUpdateTypeRemove, j)\n\t\t\tcase j := <-subscription.UpdatedJob:\n\t\t\t\tmsg <- makeJobStatusUpdate(JobStatusUpdateTypeUpdate, j)\n\t\t\tcase <-ctx.Done():\n\t\t\t\tclose(msg)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn msg, nil\n}\n\nfunc (r *subscriptionResolver) ScanCompleteSubscribe(ctx context.Context) (<-chan bool, error) {\n\treturn manager.GetInstance().ScanSubscribe(ctx), nil\n}\n"
  },
  {
    "path": "internal/api/resolver_subscription_logging.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/internal/log\"\n\t\"github.com/stashapp/stash/internal/manager\"\n)\n\nfunc getLogLevel(logType string) LogLevel {\n\tswitch logType {\n\tcase \"progress\":\n\t\treturn LogLevelProgress\n\tcase \"trace\":\n\t\treturn LogLevelTrace\n\tcase \"debug\":\n\t\treturn LogLevelDebug\n\tcase \"info\":\n\t\treturn LogLevelInfo\n\tcase \"warn\":\n\t\treturn LogLevelWarning\n\tcase \"error\":\n\t\treturn LogLevelError\n\tdefault:\n\t\treturn LogLevelDebug\n\t}\n}\n\nfunc logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {\n\tret := make([]*LogEntry, len(logItems))\n\n\tfor i, entry := range logItems {\n\t\tret[i] = &LogEntry{\n\t\t\tTime:    entry.Time,\n\t\t\tLevel:   getLogLevel(entry.Type),\n\t\t\tMessage: entry.Message,\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (r *subscriptionResolver) LoggingSubscribe(ctx context.Context) (<-chan []*LogEntry, error) {\n\tret := make(chan []*LogEntry, 100)\n\tstop := make(chan int, 1)\n\tlogger := manager.GetInstance().Logger\n\tlogSub := logger.SubscribeToLog(stop)\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase logEntries := <-logSub:\n\t\t\t\tret <- logEntriesFromLogItems(logEntries)\n\t\t\tcase <-ctx.Done():\n\t\t\t\tstop <- 0\n\t\t\t\tclose(ret)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/api/routes.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype routes struct {\n\ttxnManager txn.Manager\n}\n\nfunc (rs routes) withReadTxn(r *http.Request, fn txn.TxnFunc) error {\n\treturn txn.WithReadTxn(r.Context(), rs.txnManager, fn)\n}\n"
  },
  {
    "path": "internal/api/routes_custom.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/go-chi/chi/v5\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype customRoutes struct {\n\tservedFolders utils.URLMap\n}\n\nfunc getCustomRoutes(servedFolders utils.URLMap) chi.Router {\n\treturn customRoutes{servedFolders: servedFolders}.Routes()\n}\n\nfunc (rs customRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.HandleFunc(\"/*\", func(w http.ResponseWriter, r *http.Request) {\n\t\tr.URL.Path = strings.Replace(r.URL.Path, \"/custom\", \"\", 1)\n\n\t\t// http.FileServer redirects to / if the path ends with index.html\n\t\tr.URL.Path = strings.TrimSuffix(r.URL.Path, \"/index.html\")\n\n\t\t// map the path to the applicable filesystem location\n\t\tvar dir string\n\t\tr.URL.Path, dir = rs.servedFolders.GetFilesystemLocation(r.URL.Path)\n\t\tif dir != \"\" {\n\t\t\thttp.FileServer(http.Dir(dir)).ServeHTTP(w, r)\n\t\t} else {\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t})\n\n\treturn r\n}\n"
  },
  {
    "path": "internal/api/routes_downloads.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n)\n\ntype downloadsRoutes struct{}\n\nfunc (rs downloadsRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{downloadHash}\", func(r chi.Router) {\n\t\tr.Use(downloadCtx)\n\t\tr.Get(\"/{filename}\", rs.file)\n\t})\n\n\treturn r\n}\n\nfunc (rs downloadsRoutes) file(w http.ResponseWriter, r *http.Request) {\n\thash := r.Context().Value(downloadKey).(string)\n\tif hash == \"\" {\n\t\thttp.Error(w, http.StatusText(404), 404)\n\t\treturn\n\t}\n\n\tmanager.GetInstance().DownloadStore.Serve(hash, w, r)\n}\n\nfunc downloadCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tdownloadHash := chi.URLParam(r, \"downloadHash\")\n\n\t\tctx := context.WithValue(r.Context(), downloadKey, downloadHash)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/routes_gallery.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype GalleryFinder interface {\n\tmodels.GalleryGetter\n\tFindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error)\n}\n\ntype GalleryImageFinder interface {\n\tFindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error)\n\timage.Queryer\n\timage.CoverQueryer\n}\n\ntype galleryRoutes struct {\n\troutes\n\timageRoutes   imageRoutes\n\tgalleryFinder GalleryFinder\n\timageFinder   GalleryImageFinder\n\tfileGetter    models.FileGetter\n}\n\nfunc (rs galleryRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{galleryId}\", func(r chi.Router) {\n\t\tr.Use(rs.GalleryCtx)\n\n\t\tr.Get(\"/cover\", rs.Cover)\n\t\tr.Get(\"/preview/{imageIndex}\", rs.Preview)\n\t})\n\n\treturn r\n}\n\nfunc (rs galleryRoutes) Cover(w http.ResponseWriter, r *http.Request) {\n\tg := r.Context().Value(galleryKey).(*models.Gallery)\n\n\tvar i *models.Image\n\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t// Find cover image first\n\t\ti, _ = image.FindGalleryCover(ctx, rs.imageFinder, g.ID, config.GetInstance().GetGalleryCoverRegex())\n\t\tif i == nil {\n\t\t\treturn nil\n\t\t}\n\n\t\t// serveThumbnail needs files populated\n\t\tif err := i.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {\n\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\tlogger.Errorf(\"error loading primary file for image %d: %v\", i.ID, err)\n\t\t\t}\n\t\t\t// set image to nil so that it doesn't try to use the primary file\n\t\t\ti = nil\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif i == nil {\n\t\t// fallback to default image\n\t\timage := static.ReadAll(static.DefaultGalleryImage)\n\t\tutils.ServeImage(w, r, image)\n\t\treturn\n\t}\n\n\trs.imageRoutes.serveThumbnail(w, r, i, &g.UpdatedAt)\n}\n\nfunc (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) {\n\tg := r.Context().Value(galleryKey).(*models.Gallery)\n\tindexQueryParam := chi.URLParam(r, \"imageIndex\")\n\tvar i *models.Image\n\n\tindex, err := strconv.Atoi(indexQueryParam)\n\tif err != nil || index < 0 {\n\t\thttp.Error(w, \"bad index\", 400)\n\t\treturn\n\t}\n\n\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\tqb := rs.imageFinder\n\t\ti, _ = qb.FindByGalleryIDIndex(ctx, g.ID, uint(index))\n\t\tif i == nil {\n\t\t\treturn nil\n\t\t}\n\t\t// TODO - handle errors?\n\n\t\t// serveThumbnail needs files populated\n\t\tif err := i.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {\n\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\tlogger.Errorf(\"error loading primary file for image %d: %v\", i.ID, err)\n\t\t\t}\n\t\t\t// set image to nil so that it doesn't try to use the primary file\n\t\t\ti = nil\n\t\t}\n\n\t\treturn nil\n\t})\n\tif i == nil {\n\t\thttp.Error(w, http.StatusText(404), 404)\n\t\treturn\n\t}\n\n\trs.imageRoutes.serveThumbnail(w, r, i, nil)\n}\n\nfunc (rs galleryRoutes) GalleryCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgalleryIdentifierQueryParam := chi.URLParam(r, \"galleryId\")\n\t\tgalleryID, _ := strconv.Atoi(galleryIdentifierQueryParam)\n\n\t\tvar gallery *models.Gallery\n\t\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tqb := rs.galleryFinder\n\t\t\tif galleryID == 0 {\n\t\t\t\tgalleries, _ := qb.FindByChecksum(ctx, galleryIdentifierQueryParam)\n\t\t\t\tif len(galleries) > 0 {\n\t\t\t\t\tgallery = galleries[0]\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tgallery, _ = qb.Find(ctx, galleryID)\n\t\t\t}\n\n\t\t\tif gallery != nil {\n\t\t\t\tif err := gallery.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {\n\t\t\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\t\t\tlogger.Errorf(\"error loading primary file for gallery %d: %v\", galleryID, err)\n\t\t\t\t\t}\n\t\t\t\t\t// set image to nil so that it doesn't try to use the primary file\n\t\t\t\t\tgallery = nil\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif gallery == nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.WithValue(r.Context(), galleryKey, gallery)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/routes_group.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype GroupFinder interface {\n\tmodels.GroupGetter\n\tGetFrontImage(ctx context.Context, groupID int) ([]byte, error)\n\tGetBackImage(ctx context.Context, groupID int) ([]byte, error)\n}\n\ntype groupRoutes struct {\n\troutes\n\tgroupFinder GroupFinder\n}\n\nfunc (rs groupRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{groupId}\", func(r chi.Router) {\n\t\tr.Use(rs.GroupCtx)\n\t\tr.Get(\"/frontimage\", rs.FrontImage)\n\t\tr.Get(\"/backimage\", rs.BackImage)\n\t})\n\n\treturn r\n}\n\nfunc (rs groupRoutes) FrontImage(w http.ResponseWriter, r *http.Request) {\n\tgroup := r.Context().Value(groupKey).(*models.Group)\n\tdefaultParam := r.URL.Query().Get(\"default\")\n\tvar image []byte\n\tif defaultParam != \"true\" {\n\t\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\timage, err = rs.groupFinder.GetFrontImage(ctx, group.ID)\n\t\t\treturn err\n\t\t})\n\t\tif errors.Is(readTxnErr, context.Canceled) {\n\t\t\treturn\n\t\t}\n\t\tif readTxnErr != nil {\n\t\t\tlogger.Warnf(\"read transaction error on fetch group front image: %v\", readTxnErr)\n\t\t}\n\t}\n\n\t// fallback to default image\n\tif len(image) == 0 {\n\t\timage = static.ReadAll(static.DefaultGroupImage)\n\t}\n\n\tutils.ServeImage(w, r, image)\n}\n\nfunc (rs groupRoutes) BackImage(w http.ResponseWriter, r *http.Request) {\n\tgroup := r.Context().Value(groupKey).(*models.Group)\n\tdefaultParam := r.URL.Query().Get(\"default\")\n\tvar image []byte\n\tif defaultParam != \"true\" {\n\t\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\timage, err = rs.groupFinder.GetBackImage(ctx, group.ID)\n\t\t\treturn err\n\t\t})\n\t\tif errors.Is(readTxnErr, context.Canceled) {\n\t\t\treturn\n\t\t}\n\t\tif readTxnErr != nil {\n\t\t\tlogger.Warnf(\"read transaction error on fetch group back image: %v\", readTxnErr)\n\t\t}\n\t}\n\n\t// fallback to default image\n\tif len(image) == 0 {\n\t\timage = static.ReadAll(static.DefaultGroupImage)\n\t}\n\n\tutils.ServeImage(w, r, image)\n}\n\nfunc (rs groupRoutes) GroupCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgroupID, err := strconv.Atoi(chi.URLParam(r, \"groupId\"))\n\t\tif err != nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tvar group *models.Group\n\t\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tgroup, _ = rs.groupFinder.Find(ctx, groupID)\n\t\t\treturn nil\n\t\t})\n\t\tif group == nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.WithValue(r.Context(), groupKey, group)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/routes_image.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"os/exec\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ImageFinder interface {\n\tmodels.ImageGetter\n\tFindByChecksum(ctx context.Context, checksum string) ([]*models.Image, error)\n}\n\ntype imageRoutes struct {\n\troutes\n\timageFinder ImageFinder\n\tfileGetter  models.FileGetter\n}\n\nfunc (rs imageRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{imageId}\", func(r chi.Router) {\n\t\tr.Use(rs.ImageCtx)\n\n\t\tr.Get(\"/image\", rs.Image)\n\t\tr.Get(\"/thumbnail\", rs.Thumbnail)\n\t\tr.Get(\"/preview\", rs.Preview)\n\t})\n\n\treturn r\n}\n\nfunc (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {\n\timg := r.Context().Value(imageKey).(*models.Image)\n\trs.serveThumbnail(w, r, img, nil)\n}\n\nfunc (rs imageRoutes) serveThumbnail(w http.ResponseWriter, r *http.Request, img *models.Image, modTime *time.Time) {\n\tmgr := manager.GetInstance()\n\tfilepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)\n\n\t// if the thumbnail doesn't exist, encode on the fly\n\texists, _ := fsutil.FileExists(filepath)\n\tif exists {\n\t\tif modTime == nil {\n\t\t\tutils.ServeStaticFile(w, r, filepath)\n\t\t} else {\n\t\t\tutils.ServeStaticFileModTime(w, r, filepath, *modTime)\n\t\t}\n\t} else {\n\t\tconst useDefault = true\n\n\t\tf := img.Files.Primary()\n\t\tif f == nil {\n\t\t\trs.serveImage(w, r, img, useDefault)\n\t\t\treturn\n\t\t}\n\n\t\t// use the image thumbnail generate wait group to limit the number of concurrent thumbnail generation tasks\n\t\twg := &mgr.ImageThumbnailGenerateWaitGroup\n\t\twg.Add()\n\t\tdefer wg.Done()\n\n\t\tclipPreviewOptions := image.ClipPreviewOptions{\n\t\t\tInputArgs:  manager.GetInstance().Config.GetTranscodeInputArgs(),\n\t\t\tOutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(),\n\t\t\tPreset:     manager.GetInstance().Config.GetPreviewPreset().String(),\n\t\t}\n\n\t\tencoder := image.NewThumbnailEncoder(manager.GetInstance().FFMpeg, manager.GetInstance().FFProbe, clipPreviewOptions)\n\t\tdata, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)\n\t\tif err != nil {\n\t\t\t// don't log for unsupported image format\n\t\t\t// don't log for file not found - can optionally be logged in serveImage\n\t\t\tif !errors.Is(err, image.ErrNotSupportedForThumbnail) && !errors.Is(err, fs.ErrNotExist) {\n\t\t\t\tlogger.Errorf(\"error generating thumbnail for %s: %v\", f.Base().Path, err)\n\n\t\t\t\tvar exitErr *exec.ExitError\n\t\t\t\tif errors.As(err, &exitErr) {\n\t\t\t\t\tlogger.Errorf(\"stderr: %s\", string(exitErr.Stderr))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// backwards compatibility - fallback to original image instead\n\t\t\trs.serveImage(w, r, img, useDefault)\n\t\t\treturn\n\t\t}\n\n\t\t// write the generated thumbnail to disk if enabled\n\t\tif manager.GetInstance().Config.IsWriteImageThumbnails() {\n\t\t\tlogger.Debugf(\"writing thumbnail to disk: %s\", img.Path)\n\t\t\tif err := fsutil.WriteFile(filepath, data); err == nil {\n\t\t\t\tutils.ServeStaticFile(w, r, filepath)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlogger.Errorf(\"error writing thumbnail for image %s: %v\", img.Path, err)\n\t\t}\n\t\tutils.ServeStaticContent(w, r, data)\n\t}\n}\n\nfunc (rs imageRoutes) Preview(w http.ResponseWriter, r *http.Request) {\n\timg := r.Context().Value(imageKey).(*models.Image)\n\tfilepath := manager.GetInstance().Paths.Generated.GetClipPreviewPath(img.Checksum, models.DefaultGthumbWidth)\n\n\t// don't check if the preview exists - we'll just return a 404 if it doesn't\n\tutils.ServeStaticFile(w, r, filepath)\n}\n\nfunc (rs imageRoutes) Image(w http.ResponseWriter, r *http.Request) {\n\ti := r.Context().Value(imageKey).(*models.Image)\n\n\tconst useDefault = false\n\trs.serveImage(w, r, i, useDefault)\n}\n\nfunc (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *models.Image, useDefault bool) {\n\tif i.Files.Primary() != nil {\n\t\terr := i.Files.Primary().Base().Serve(&file.OsFS{}, w, r)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\n\t\tif !useDefault {\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\t// only log in debug since it can get noisy\n\t\tlogger.Debugf(\"Error serving %s: %v\", i.DisplayName(), err)\n\t}\n\n\tif !useDefault {\n\t\thttp.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)\n\t\treturn\n\t}\n\n\t// fallback to default image\n\timage := static.ReadAll(static.DefaultImageImage)\n\tutils.ServeImage(w, r, image)\n}\n\nfunc (rs imageRoutes) ImageCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\timageIdentifierQueryParam := chi.URLParam(r, \"imageId\")\n\t\timageID, _ := strconv.Atoi(imageIdentifierQueryParam)\n\n\t\tvar image *models.Image\n\t\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tqb := rs.imageFinder\n\t\t\tif imageID == 0 {\n\t\t\t\timages, _ := qb.FindByChecksum(ctx, imageIdentifierQueryParam)\n\t\t\t\tif len(images) > 0 {\n\t\t\t\t\timage = images[0]\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\timage, _ = qb.Find(ctx, imageID)\n\t\t\t}\n\n\t\t\tif image != nil {\n\t\t\t\tif err := image.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {\n\t\t\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\t\t\tlogger.Errorf(\"error loading primary file for image %d: %v\", imageID, err)\n\t\t\t\t\t}\n\t\t\t\t\t// set image to nil so that it doesn't try to use the primary file\n\t\t\t\t\timage = nil\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif image == nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.WithValue(r.Context(), imageKey, image)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/routes_performer.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype PerformerFinder interface {\n\tmodels.PerformerGetter\n\tGetImage(ctx context.Context, performerID int) ([]byte, error)\n}\n\ntype sfwConfig interface {\n\tGetSFWContentMode() bool\n}\n\ntype performerRoutes struct {\n\troutes\n\tperformerFinder PerformerFinder\n\tsfwConfig       sfwConfig\n}\n\nfunc (rs performerRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{performerId}\", func(r chi.Router) {\n\t\tr.Use(rs.PerformerCtx)\n\t\tr.Get(\"/image\", rs.Image)\n\t})\n\n\treturn r\n}\n\nfunc (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) {\n\tperformer := r.Context().Value(performerKey).(*models.Performer)\n\tdefaultParam := r.URL.Query().Get(\"default\")\n\n\tvar image []byte\n\tif defaultParam != \"true\" {\n\t\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\timage, err = rs.performerFinder.GetImage(ctx, performer.ID)\n\t\t\treturn err\n\t\t})\n\t\tif errors.Is(readTxnErr, context.Canceled) {\n\t\t\treturn\n\t\t}\n\t\tif readTxnErr != nil {\n\t\t\tlogger.Warnf(\"read transaction error on fetch performer image: %v\", readTxnErr)\n\t\t}\n\t}\n\n\tif len(image) == 0 {\n\t\timage = getDefaultPerformerImage(performer.Name, performer.Gender, rs.sfwConfig.GetSFWContentMode())\n\t}\n\n\tutils.ServeImage(w, r, image)\n}\n\nfunc (rs performerRoutes) PerformerCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tperformerID, err := strconv.Atoi(chi.URLParam(r, \"performerId\"))\n\t\tif err != nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tvar performer *models.Performer\n\t\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tperformer, err = rs.performerFinder.Find(ctx, performerID)\n\t\t\treturn err\n\t\t})\n\t\tif performer == nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.WithValue(r.Context(), performerKey, performer)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/routes_plugin.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/pkg/plugin\"\n)\n\ntype pluginRoutes struct {\n\tpluginCache *plugin.Cache\n}\n\nfunc (rs pluginRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{pluginId}\", func(r chi.Router) {\n\t\tr.Use(rs.PluginCtx)\n\t\tr.Get(\"/assets\", rs.Assets)\n\t\tr.Get(\"/assets/*\", rs.Assets)\n\t\tr.Get(\"/javascript\", rs.Javascript)\n\t\tr.Get(\"/css\", rs.CSS)\n\t})\n\n\treturn r\n}\n\nfunc (rs pluginRoutes) Assets(w http.ResponseWriter, r *http.Request) {\n\tp := r.Context().Value(pluginKey).(*plugin.Plugin)\n\n\tif !p.Enabled {\n\t\thttp.Error(w, \"plugin disabled\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tprefix := \"/plugin/\" + chi.URLParam(r, \"pluginId\") + \"/assets\"\n\n\tr.URL.Path = strings.Replace(r.URL.Path, prefix, \"\", 1)\n\n\t// http.FileServer redirects to / if the path ends with index.html\n\tr.URL.Path = strings.TrimSuffix(r.URL.Path, \"/index.html\")\n\n\tpluginDir := filepath.Dir(p.ConfigPath)\n\n\t// map the path to the applicable filesystem location\n\tvar dir string\n\tr.URL.Path, dir = p.UI.Assets.GetFilesystemLocation(r.URL.Path)\n\tif dir == \"\" {\n\t\thttp.NotFound(w, r)\n\t\treturn\n\t}\n\n\tdir = filepath.Join(pluginDir, filepath.FromSlash(dir))\n\n\t// ensure directory is still within the plugin directory\n\tif !strings.HasPrefix(dir, pluginDir) {\n\t\thttp.NotFound(w, r)\n\t\treturn\n\t}\n\n\thttp.FileServer(http.Dir(dir)).ServeHTTP(w, r)\n}\n\nfunc (rs pluginRoutes) Javascript(w http.ResponseWriter, r *http.Request) {\n\tp := r.Context().Value(pluginKey).(*plugin.Plugin)\n\n\tif !p.Enabled {\n\t\thttp.Error(w, \"plugin disabled\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/javascript\")\n\tserveFiles(w, r, p.UI.Javascript)\n}\n\nfunc (rs pluginRoutes) CSS(w http.ResponseWriter, r *http.Request) {\n\tp := r.Context().Value(pluginKey).(*plugin.Plugin)\n\n\tif !p.Enabled {\n\t\thttp.Error(w, \"plugin disabled\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/css\")\n\tserveFiles(w, r, p.UI.CSS)\n}\n\nfunc (rs pluginRoutes) PluginCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tp := rs.pluginCache.GetPlugin(chi.URLParam(r, \"pluginId\"))\n\t\tif p == nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.WithValue(r.Context(), pluginKey, p)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/routes_scene.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/file/video\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype SceneFinder interface {\n\tmodels.SceneGetter\n\n\tFindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error)\n\tFindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error)\n\tGetCover(ctx context.Context, sceneID int) ([]byte, error)\n}\n\ntype SceneMarkerFinder interface {\n\tmodels.SceneMarkerGetter\n\tFindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error)\n}\n\ntype SceneMarkerTagFinder interface {\n\tmodels.TagGetter\n\tFindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error)\n}\n\ntype CaptionFinder interface {\n\tGetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error)\n}\n\ntype sceneRoutes struct {\n\troutes\n\tsceneFinder       SceneFinder\n\tfileGetter        models.FileGetter\n\tcaptionFinder     CaptionFinder\n\tsceneMarkerFinder SceneMarkerFinder\n\ttagFinder         SceneMarkerTagFinder\n}\n\nfunc (rs sceneRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{sceneId}\", func(r chi.Router) {\n\t\tr.Use(rs.SceneCtx)\n\n\t\t// streaming endpoints\n\t\tr.Get(\"/stream\", rs.StreamDirect)\n\t\tr.Get(\"/stream.mp4\", rs.StreamMp4)\n\t\tr.Get(\"/stream.webm\", rs.StreamWebM)\n\t\tr.Get(\"/stream.mkv\", rs.StreamMKV)\n\t\tr.Get(\"/stream.m3u8\", rs.StreamHLS)\n\t\tr.Get(\"/stream.m3u8/{segment}.ts\", rs.StreamHLSSegment)\n\t\tr.Get(\"/stream.mpd\", rs.StreamDASH)\n\t\tr.Get(\"/stream.mpd/{segment}_v.webm\", rs.StreamDASHVideoSegment)\n\t\tr.Get(\"/stream.mpd/{segment}_a.webm\", rs.StreamDASHAudioSegment)\n\n\t\tr.Get(\"/screenshot\", rs.Screenshot)\n\t\tr.Get(\"/preview\", rs.Preview)\n\t\tr.Get(\"/webp\", rs.Webp)\n\t\tr.Get(\"/vtt/chapter\", rs.VttChapter)\n\t\tr.Get(\"/vtt/thumbs\", rs.VttThumbs)\n\t\tr.Get(\"/vtt/sprite\", rs.VttSprite)\n\t\tr.Get(\"/funscript\", rs.Funscript)\n\t\tr.Get(\"/interactive_csv\", rs.InteractiveCSV)\n\t\tr.Get(\"/interactive_heatmap\", rs.InteractiveHeatmap)\n\t\tr.Get(\"/caption\", rs.CaptionLang)\n\n\t\tr.Get(\"/scene_marker/{sceneMarkerId}/stream\", rs.SceneMarkerStream)\n\t\tr.Get(\"/scene_marker/{sceneMarkerId}/preview\", rs.SceneMarkerPreview)\n\t\tr.Get(\"/scene_marker/{sceneMarkerId}/screenshot\", rs.SceneMarkerScreenshot)\n\t})\n\tr.Get(\"/{sceneHash}_thumbs.vtt\", rs.VttThumbs)\n\tr.Get(\"/{sceneHash}_sprite.jpg\", rs.VttSprite)\n\n\treturn r\n}\n\nfunc (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\tss := manager.SceneServer{\n\t\tTxnManager:       rs.txnManager,\n\t\tSceneCoverGetter: rs.sceneFinder,\n\t}\n\tss.StreamSceneDirect(scene, w, r)\n}\n\nfunc (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {\n\trs.streamTranscode(w, r, ffmpeg.StreamTypeMP4)\n}\n\nfunc (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) {\n\trs.streamTranscode(w, r, ffmpeg.StreamTypeWEBM)\n}\n\nfunc (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {\n\t// only allow mkv streaming if the scene container is an mkv already\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\n\tpf := scene.Files.Primary()\n\tif pf == nil {\n\t\treturn\n\t}\n\n\tcontainer, err := manager.GetVideoFileContainer(pf)\n\tif err != nil {\n\t\tlogger.Errorf(\"[transcode] error getting container: %v\", err)\n\t}\n\n\tif container != ffmpeg.Matroska {\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tif _, err := w.Write([]byte(\"not an mkv file\")); err != nil {\n\t\t\tlogger.Warnf(\"[stream] error writing to stream: %v\", err)\n\t\t}\n\t\treturn\n\t}\n\n\trs.streamTranscode(w, r, ffmpeg.StreamTypeMKV)\n}\n\nfunc (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamType ffmpeg.StreamFormat) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\n\tstreamManager := manager.GetInstance().StreamManager\n\tif streamManager == nil {\n\t\thttp.Error(w, \"Live transcoding disabled\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\n\tf := scene.Files.Primary()\n\tif f == nil {\n\t\treturn\n\t}\n\n\tif err := r.ParseForm(); err != nil {\n\t\tlogger.Warnf(\"[transcode] error parsing query form: %v\", err)\n\t}\n\n\tstartTime := r.Form.Get(\"start\")\n\tss, _ := strconv.ParseFloat(startTime, 64)\n\tresolution := r.Form.Get(\"resolution\")\n\n\toptions := ffmpeg.TranscodeOptions{\n\t\tStreamType: streamType,\n\t\tVideoFile:  f,\n\t\tResolution: resolution,\n\t\tStartTime:  ss,\n\t}\n\n\tlogger.Debugf(\"[transcode] streaming scene %d as %s\", scene.ID, streamType.MimeType)\n\tstreamManager.ServeTranscode(w, r, options)\n}\n\nfunc (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {\n\trs.streamManifest(w, r, ffmpeg.StreamTypeHLS, \"HLS\")\n}\n\nfunc (rs sceneRoutes) StreamDASH(w http.ResponseWriter, r *http.Request) {\n\trs.streamManifest(w, r, ffmpeg.StreamTypeDASHVideo, \"DASH\")\n}\n\nfunc (rs sceneRoutes) streamManifest(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType, logName string) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\n\tstreamManager := manager.GetInstance().StreamManager\n\tif streamManager == nil {\n\t\thttp.Error(w, \"Live transcoding disabled\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\n\tf := scene.Files.Primary()\n\tif f == nil {\n\t\treturn\n\t}\n\n\tif err := r.ParseForm(); err != nil {\n\t\tlogger.Warnf(\"[transcode] error parsing query form: %v\", err)\n\t}\n\n\tresolution := r.Form.Get(\"resolution\")\n\n\tlogger.Debugf(\"[transcode] returning %s manifest for scene %d\", logName, scene.ID)\n\tstreamManager.ServeManifest(w, r, streamType, f, resolution)\n}\n\nfunc (rs sceneRoutes) StreamHLSSegment(w http.ResponseWriter, r *http.Request) {\n\trs.streamSegment(w, r, ffmpeg.StreamTypeHLS)\n}\n\nfunc (rs sceneRoutes) StreamDASHVideoSegment(w http.ResponseWriter, r *http.Request) {\n\trs.streamSegment(w, r, ffmpeg.StreamTypeDASHVideo)\n}\n\nfunc (rs sceneRoutes) StreamDASHAudioSegment(w http.ResponseWriter, r *http.Request) {\n\trs.streamSegment(w, r, ffmpeg.StreamTypeDASHAudio)\n}\n\nfunc (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\n\tstreamManager := manager.GetInstance().StreamManager\n\tif streamManager == nil {\n\t\thttp.Error(w, \"Live transcoding disabled\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\n\tf := scene.Files.Primary()\n\tif f == nil {\n\t\treturn\n\t}\n\n\tif err := r.ParseForm(); err != nil {\n\t\tlogger.Warnf(\"[transcode] error parsing query form: %v\", err)\n\t}\n\n\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\n\tsegment := chi.URLParam(r, \"segment\")\n\tresolution := r.Form.Get(\"resolution\")\n\n\toptions := ffmpeg.StreamOptions{\n\t\tStreamType: streamType,\n\t\tVideoFile:  f,\n\t\tResolution: resolution,\n\t\tHash:       sceneHash,\n\t\tSegment:    segment,\n\t}\n\n\tstreamManager.ServeSegment(w, r, options)\n}\n\nfunc (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {\n\t// if default flag is set, return the default image\n\tif r.URL.Query().Get(\"default\") == \"true\" {\n\t\tutils.ServeImage(w, r, static.ReadAll(static.DefaultSceneImage))\n\t\treturn\n\t}\n\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\n\tss := manager.SceneServer{\n\t\tTxnManager:       rs.txnManager,\n\t\tSceneCoverGetter: rs.sceneFinder,\n\t}\n\tss.ServeScreenshot(scene, w, r)\n}\n\nfunc (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\tfilepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(sceneHash)\n\n\tutils.ServeStaticFile(w, r, filepath)\n}\n\nfunc (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\tfilepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(sceneHash)\n\n\tutils.ServeStaticFile(w, r, filepath)\n}\n\nfunc (rs sceneRoutes) getChapterVttTitle(r *http.Request, marker *models.SceneMarker) (*string, error) {\n\tif marker.Title != \"\" {\n\t\treturn &marker.Title, nil\n\t}\n\n\tvar title string\n\tif err := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\tqb := rs.tagFinder\n\t\tprimaryTag, err := qb.Find(ctx, marker.PrimaryTagID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttitle = primaryTag.Name\n\n\t\ttags, err := qb.FindBySceneMarkerID(ctx, marker.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, t := range tags {\n\t\t\ttitle += \", \" + t.Name\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &title, nil\n}\n\nfunc (rs sceneRoutes) VttChapter(w http.ResponseWriter, r *http.Request) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\tvar sceneMarkers []*models.SceneMarker\n\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\tvar err error\n\t\tsceneMarkers, err = rs.sceneMarkerFinder.FindBySceneID(ctx, scene.ID)\n\t\treturn err\n\t})\n\tif errors.Is(readTxnErr, context.Canceled) {\n\t\treturn\n\t}\n\tif readTxnErr != nil {\n\t\tlogger.Warnf(\"read transaction error on fetch scene markers: %v\", readTxnErr)\n\t\thttp.Error(w, readTxnErr.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tvttLines := []string{\"WEBVTT\", \"\"}\n\tfor i, marker := range sceneMarkers {\n\t\tvttLines = append(vttLines, strconv.Itoa(i+1))\n\t\ttime := utils.GetVTTTime(marker.Seconds)\n\t\tvttLines = append(vttLines, time+\" --> \"+time)\n\n\t\tvttTitle, err := rs.getChapterVttTitle(r, marker)\n\t\tif errors.Is(err, context.Canceled) {\n\t\t\treturn\n\t\t}\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"read transaction error on fetch scene marker title: %v\", err)\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tvttLines = append(vttLines, *vttTitle)\n\t\tvttLines = append(vttLines, \"\")\n\t}\n\tvtt := strings.Join(vttLines, \"\\n\")\n\n\tw.Header().Set(\"Content-Type\", \"text/vtt\")\n\tutils.ServeStaticContent(w, r, []byte(vtt))\n}\n\nfunc (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {\n\tscene, ok := r.Context().Value(sceneKey).(*models.Scene)\n\tvar sceneHash string\n\tif ok && scene != nil {\n\t\tsceneHash = scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\t} else {\n\t\tsceneHash = chi.URLParam(r, \"sceneHash\")\n\t}\n\tfilepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(sceneHash)\n\n\tw.Header().Set(\"Content-Type\", \"text/vtt\")\n\tutils.ServeStaticFile(w, r, filepath)\n}\n\nfunc (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {\n\tscene, ok := r.Context().Value(sceneKey).(*models.Scene)\n\tvar sceneHash string\n\tif ok && scene != nil {\n\t\tsceneHash = scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\t} else {\n\t\tsceneHash = chi.URLParam(r, \"sceneHash\")\n\t}\n\tfilepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(sceneHash)\n\n\tutils.ServeStaticFile(w, r, filepath)\n}\n\nfunc (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {\n\ts := r.Context().Value(sceneKey).(*models.Scene)\n\tfilepath := video.GetFunscriptPath(s.Path)\n\n\tutils.ServeStaticFile(w, r, filepath)\n}\n\nfunc (rs sceneRoutes) InteractiveCSV(w http.ResponseWriter, r *http.Request) {\n\ts := r.Context().Value(sceneKey).(*models.Scene)\n\tfilepath := video.GetFunscriptPath(s.Path)\n\n\t// TheHandy directly only accepts interactive CSVs\n\tcsvBytes, err := manager.ConvertFunscriptToCSV(filepath)\n\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tutils.ServeStaticContent(w, r, csvBytes)\n}\n\nfunc (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\tfilepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(sceneHash)\n\n\tutils.ServeStaticFile(w, r, filepath)\n}\n\nfunc (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang string, ext string) {\n\ts := r.Context().Value(sceneKey).(*models.Scene)\n\n\tvar captions []*models.VideoCaption\n\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\tvar err error\n\t\tprimaryFile := s.Files.Primary()\n\t\tif primaryFile == nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tcaptions, err = rs.captionFinder.GetCaptions(ctx, primaryFile.Base().ID)\n\n\t\treturn err\n\t})\n\tif errors.Is(readTxnErr, context.Canceled) {\n\t\treturn\n\t}\n\tif readTxnErr != nil {\n\t\tlogger.Warnf(\"read transaction error on fetch scene captions: %v\", readTxnErr)\n\t\thttp.Error(w, readTxnErr.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tfor _, caption := range captions {\n\t\tif lang != caption.LanguageCode || ext != caption.CaptionType {\n\t\t\tcontinue\n\t\t}\n\n\t\tsub, err := video.ReadSubs(caption.Path(s.Path))\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"error while reading subs: %v\", err)\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tvar buf bytes.Buffer\n\n\t\terr = sub.WriteToWebVTT(&buf)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"text/vtt\")\n\t\tutils.ServeStaticContent(w, r, buf.Bytes())\n\t\treturn\n\t}\n}\n\nfunc (rs sceneRoutes) CaptionLang(w http.ResponseWriter, r *http.Request) {\n\t// serve caption based on lang query param, if provided\n\tif err := r.ParseForm(); err != nil {\n\t\tlogger.Warnf(\"[caption] error parsing query form: %v\", err)\n\t}\n\n\tl := r.Form.Get(\"lang\")\n\text := r.Form.Get(\"type\")\n\trs.Caption(w, r, l, ext)\n}\n\nfunc (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\tsceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, \"sceneMarkerId\"))\n\tvar sceneMarker *models.SceneMarker\n\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\tvar err error\n\t\tsceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID)\n\t\treturn err\n\t})\n\tif errors.Is(readTxnErr, context.Canceled) {\n\t\treturn\n\t}\n\tif readTxnErr != nil {\n\t\tlogger.Warnf(\"read transaction error on fetch scene marker: %v\", readTxnErr)\n\t\thttp.Error(w, readTxnErr.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif sceneMarker == nil {\n\t\thttp.Error(w, http.StatusText(404), 404)\n\t\treturn\n\t}\n\n\tfilepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(sceneHash, int(sceneMarker.Seconds))\n\tutils.ServeStaticFile(w, r, filepath)\n}\n\nfunc (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\tsceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, \"sceneMarkerId\"))\n\tvar sceneMarker *models.SceneMarker\n\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\tvar err error\n\t\tsceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID)\n\t\treturn err\n\t})\n\tif errors.Is(readTxnErr, context.Canceled) {\n\t\treturn\n\t}\n\tif readTxnErr != nil {\n\t\tlogger.Warnf(\"read transaction error on fetch scene marker preview: %v\", readTxnErr)\n\t\thttp.Error(w, readTxnErr.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif sceneMarker == nil {\n\t\thttp.Error(w, http.StatusText(404), 404)\n\t\treturn\n\t}\n\n\tfilepath := manager.GetInstance().Paths.SceneMarkers.GetWebpPreviewPath(sceneHash, int(sceneMarker.Seconds))\n\n\t// If the image doesn't exist, send the placeholder\n\texists, _ := fsutil.FileExists(filepath)\n\tif !exists {\n\t\tw.Header().Set(\"Content-Type\", \"image/png\")\n\t\tutils.ServeStaticContent(w, r, utils.PendingGenerateResource)\n\t} else {\n\t\tutils.ServeStaticFile(w, r, filepath)\n\t}\n}\n\nfunc (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) {\n\tscene := r.Context().Value(sceneKey).(*models.Scene)\n\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\tsceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, \"sceneMarkerId\"))\n\tvar sceneMarker *models.SceneMarker\n\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\tvar err error\n\t\tsceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID)\n\t\treturn err\n\t})\n\tif errors.Is(readTxnErr, context.Canceled) {\n\t\treturn\n\t}\n\tif readTxnErr != nil {\n\t\tlogger.Warnf(\"read transaction error on fetch scene marker screenshot: %v\", readTxnErr)\n\t\thttp.Error(w, readTxnErr.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif sceneMarker == nil {\n\t\thttp.Error(w, http.StatusText(404), 404)\n\t\treturn\n\t}\n\n\tfilepath := manager.GetInstance().Paths.SceneMarkers.GetScreenshotPath(sceneHash, int(sceneMarker.Seconds))\n\n\t// If the image doesn't exist, send the placeholder\n\texists, _ := fsutil.FileExists(filepath)\n\tif !exists {\n\t\tw.Header().Set(\"Content-Type\", \"image/png\")\n\t\tutils.ServeStaticContent(w, r, utils.PendingGenerateResource)\n\t} else {\n\t\tutils.ServeStaticFile(w, r, filepath)\n\t}\n}\n\nfunc (rs sceneRoutes) SceneCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tsceneID, err := strconv.Atoi(chi.URLParam(r, \"sceneId\"))\n\t\tif err != nil {\n\t\t\thttp.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tvar scene *models.Scene\n\t\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tqb := rs.sceneFinder\n\t\t\tscene, _ = qb.Find(ctx, sceneID)\n\n\t\t\tif scene != nil {\n\t\t\t\tif err := scene.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {\n\t\t\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\t\t\tlogger.Errorf(\"error loading primary file for scene %d: %v\", sceneID, err)\n\t\t\t\t\t}\n\t\t\t\t\t// set scene to nil so that it doesn't try to use the primary file\n\t\t\t\t\tscene = nil\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif scene == nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.WithValue(r.Context(), sceneKey, scene)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/routes_studio.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype StudioFinder interface {\n\tmodels.StudioGetter\n\tGetImage(ctx context.Context, studioID int) ([]byte, error)\n}\n\ntype studioRoutes struct {\n\troutes\n\tstudioFinder StudioFinder\n}\n\nfunc (rs studioRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{studioId}\", func(r chi.Router) {\n\t\tr.Use(rs.StudioCtx)\n\t\tr.Get(\"/image\", rs.Image)\n\t})\n\n\treturn r\n}\n\nfunc (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) {\n\tstudio := r.Context().Value(studioKey).(*models.Studio)\n\tdefaultParam := r.URL.Query().Get(\"default\")\n\n\tvar image []byte\n\tif defaultParam != \"true\" {\n\t\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\timage, err = rs.studioFinder.GetImage(ctx, studio.ID)\n\t\t\treturn err\n\t\t})\n\t\tif errors.Is(readTxnErr, context.Canceled) {\n\t\t\treturn\n\t\t}\n\t\tif readTxnErr != nil {\n\t\t\tlogger.Warnf(\"read transaction error on fetch studio image: %v\", readTxnErr)\n\t\t}\n\t}\n\n\t// fallback to default image\n\tif len(image) == 0 {\n\t\timage = static.ReadAll(static.DefaultStudioImage)\n\t}\n\n\tutils.ServeImage(w, r, image)\n}\n\nfunc (rs studioRoutes) StudioCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tstudioID, err := strconv.Atoi(chi.URLParam(r, \"studioId\"))\n\t\tif err != nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tvar studio *models.Studio\n\t\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tstudio, err = rs.studioFinder.Find(ctx, studioID)\n\t\t\treturn err\n\t\t})\n\t\tif studio == nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.WithValue(r.Context(), studioKey, studio)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/routes_tag.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/go-chi/chi/v5\"\n\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype TagFinder interface {\n\tmodels.TagGetter\n\tGetImage(ctx context.Context, tagID int) ([]byte, error)\n}\n\ntype tagRoutes struct {\n\troutes\n\ttagFinder TagFinder\n}\n\nfunc (rs tagRoutes) Routes() chi.Router {\n\tr := chi.NewRouter()\n\n\tr.Route(\"/{tagId}\", func(r chi.Router) {\n\t\tr.Use(rs.TagCtx)\n\t\tr.Get(\"/image\", rs.Image)\n\t})\n\n\treturn r\n}\n\nfunc (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) {\n\ttag := r.Context().Value(tagKey).(*models.Tag)\n\tdefaultParam := r.URL.Query().Get(\"default\")\n\n\tvar image []byte\n\tif defaultParam != \"true\" {\n\t\treadTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\timage, err = rs.tagFinder.GetImage(ctx, tag.ID)\n\t\t\treturn err\n\t\t})\n\t\tif errors.Is(readTxnErr, context.Canceled) {\n\t\t\treturn\n\t\t}\n\t\tif readTxnErr != nil {\n\t\t\tlogger.Warnf(\"read transaction error on fetch tag image: %v\", readTxnErr)\n\t\t}\n\t}\n\n\t// fallback to default image\n\tif len(image) == 0 {\n\t\timage = static.ReadAll(static.DefaultTagImage)\n\t}\n\n\tutils.ServeImage(w, r, image)\n}\n\nfunc (rs tagRoutes) TagCtx(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttagID, err := strconv.Atoi(chi.URLParam(r, \"tagId\"))\n\t\tif err != nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tvar tag *models.Tag\n\t\t_ = rs.withReadTxn(r, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\ttag, err = rs.tagFinder.Find(ctx, tagID)\n\t\t\treturn err\n\t\t})\n\t\tif tag == nil {\n\t\t\thttp.Error(w, http.StatusText(404), 404)\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.WithValue(r.Context(), tagKey, tag)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/scraped_content.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n)\n\n// marshalScrapedScenes converts ScrapedContent into ScrapedScene. If conversion fails, an\n// error is returned to the caller.\nfunc marshalScrapedScenes(content []scraper.ScrapedContent) ([]*models.ScrapedScene, error) {\n\tvar ret []*models.ScrapedScene\n\tfor _, c := range content {\n\t\tif c == nil {\n\t\t\t// graphql schema requires scenes to be non-nil\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch s := c.(type) {\n\t\tcase *models.ScrapedScene:\n\t\t\tret = append(ret, s)\n\t\tcase models.ScrapedScene:\n\t\t\tret = append(ret, &s)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot turn ScrapedContent into ScrapedScene\", models.ErrConversion)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// marshalScrapedPerformers converts ScrapedContent into ScrapedPerformer. If conversion\n// fails, an error is returned to the caller.\nfunc marshalScrapedPerformers(content []scraper.ScrapedContent) ([]*models.ScrapedPerformer, error) {\n\tvar ret []*models.ScrapedPerformer\n\tfor _, c := range content {\n\t\tif c == nil {\n\t\t\t// graphql schema requires performers to be non-nil\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch p := c.(type) {\n\t\tcase *models.ScrapedPerformer:\n\t\t\tret = append(ret, p)\n\t\tcase models.ScrapedPerformer:\n\t\t\tret = append(ret, &p)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot turn ScrapedContent into ScrapedPerformer\", models.ErrConversion)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// marshalScrapedGalleries converts ScrapedContent into ScrapedGallery. If\n// conversion fails, an error is returned.\nfunc marshalScrapedGalleries(content []scraper.ScrapedContent) ([]*models.ScrapedGallery, error) {\n\tvar ret []*models.ScrapedGallery\n\tfor _, c := range content {\n\t\tif c == nil {\n\t\t\t// graphql schema requires galleries to be non-nil\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch g := c.(type) {\n\t\tcase *models.ScrapedGallery:\n\t\t\tret = append(ret, g)\n\t\tcase models.ScrapedGallery:\n\t\t\tret = append(ret, &g)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot turn ScrapedContent into ScrapedGallery\", models.ErrConversion)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc marshalScrapedImages(content []scraper.ScrapedContent) ([]*models.ScrapedImage, error) {\n\tvar ret []*models.ScrapedImage\n\tfor _, c := range content {\n\t\tif c == nil {\n\t\t\t// graphql schema requires images to be non-nil\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch g := c.(type) {\n\t\tcase *models.ScrapedImage:\n\t\t\tret = append(ret, g)\n\t\tcase models.ScrapedImage:\n\t\t\tret = append(ret, &g)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot turn ScrapedContent into ScrapedImage\", models.ErrConversion)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// marshalScrapedMovies converts ScrapedContent into ScrapedMovie. If conversion\n// fails, an error is returned.\nfunc marshalScrapedMovies(content []scraper.ScrapedContent) ([]*models.ScrapedMovie, error) {\n\tvar ret []*models.ScrapedMovie\n\tfor _, c := range content {\n\t\tif c == nil {\n\t\t\t// graphql schema requires movies to be non-nil\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch m := c.(type) {\n\t\tcase *models.ScrapedMovie:\n\t\t\tret = append(ret, m)\n\t\tcase models.ScrapedMovie:\n\t\t\tret = append(ret, &m)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot turn ScrapedContent into ScrapedMovie\", models.ErrConversion)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// marshalScrapedMovies converts ScrapedContent into ScrapedMovie. If conversion\n// fails, an error is returned.\nfunc marshalScrapedGroups(content []scraper.ScrapedContent) ([]*models.ScrapedGroup, error) {\n\tvar ret []*models.ScrapedGroup\n\tfor _, c := range content {\n\t\tif c == nil {\n\t\t\t// graphql schema requires groups to be non-nil\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch m := c.(type) {\n\t\tcase *models.ScrapedGroup:\n\t\t\tret = append(ret, m)\n\t\tcase models.ScrapedGroup:\n\t\t\tret = append(ret, &m)\n\t\t// it's possible that a scraper returns models.ScrapedMovie\n\t\tcase *models.ScrapedMovie:\n\t\t\tg := m.ScrapedGroup()\n\t\t\tret = append(ret, &g)\n\t\tcase models.ScrapedMovie:\n\t\t\tg := m.ScrapedGroup()\n\t\t\tret = append(ret, &g)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot turn ScrapedContent into ScrapedGroup\", models.ErrConversion)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// marshalScrapedPerformer will marshal a single performer\nfunc marshalScrapedPerformer(content scraper.ScrapedContent) (*models.ScrapedPerformer, error) {\n\tp, err := marshalScrapedPerformers([]scraper.ScrapedContent{content})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn p[0], nil\n}\n\n// marshalScrapedScene will marshal a single scraped scene\nfunc marshalScrapedScene(content scraper.ScrapedContent) (*models.ScrapedScene, error) {\n\ts, err := marshalScrapedScenes([]scraper.ScrapedContent{content})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn s[0], nil\n}\n\n// marshalScrapedGallery will marshal a single scraped gallery\nfunc marshalScrapedGallery(content scraper.ScrapedContent) (*models.ScrapedGallery, error) {\n\tg, err := marshalScrapedGalleries([]scraper.ScrapedContent{content})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn g[0], nil\n}\n\n// marshalScrapedImage will marshal a single scraped image\nfunc marshalScrapedImage(content scraper.ScrapedContent) (*models.ScrapedImage, error) {\n\tg, err := marshalScrapedImages([]scraper.ScrapedContent{content})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn g[0], nil\n}\n\n// marshalScrapedMovie will marshal a single scraped movie\nfunc marshalScrapedMovie(content scraper.ScrapedContent) (*models.ScrapedMovie, error) {\n\tm, err := marshalScrapedMovies([]scraper.ScrapedContent{content})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn m[0], nil\n}\n\n// marshalScrapedMovie will marshal a single scraped movie\nfunc marshalScrapedGroup(content scraper.ScrapedContent) (*models.ScrapedGroup, error) {\n\tm, err := marshalScrapedGroups([]scraper.ScrapedContent{content})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn m[0], nil\n}\n"
  },
  {
    "path": "internal/api/server.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\tgqlHandler \"github.com/99designs/gqlgen/graphql/handler\"\n\tgqlExtension \"github.com/99designs/gqlgen/graphql/handler/extension\"\n\tgqlLru \"github.com/99designs/gqlgen/graphql/handler/lru\"\n\tgqlTransport \"github.com/99designs/gqlgen/graphql/handler/transport\"\n\tgqlPlayground \"github.com/99designs/gqlgen/graphql/playground\"\n\t\"github.com/go-chi/chi/v5\"\n\t\"github.com/go-chi/chi/v5/middleware\"\n\t\"github.com/go-chi/cors\"\n\t\"github.com/go-chi/httplog\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/vearutop/statigz\"\n\t\"github.com/vektah/gqlparser/v2/ast\"\n\n\t\"github.com/stashapp/stash/internal/api/loaders\"\n\t\"github.com/stashapp/stash/internal/build\"\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"github.com/stashapp/stash/ui\"\n)\n\nconst (\n\tloginEndpoint       = \"/login\"\n\tloginLocaleEndpoint = loginEndpoint + \"/locale\"\n\tlogoutEndpoint      = \"/logout\"\n\tgqlEndpoint         = \"/graphql\"\n\tplaygroundEndpoint  = \"/playground\"\n)\n\ntype Server struct {\n\thttp.Server\n\tdisplayAddress string\n\n\tmanager *manager.Manager\n}\n\n// TODO - os.DirFS doesn't implement ReadDir, so re-implement it here\n// This can be removed when we upgrade go\ntype osFS string\n\nfunc (dir osFS) ReadDir(name string) ([]os.DirEntry, error) {\n\tfullname := string(dir) + \"/\" + name\n\tentries, err := os.ReadDir(fullname)\n\tif err != nil {\n\t\tvar e *os.PathError\n\t\tif errors.As(err, &e) {\n\t\t\t// See comment in dirFS.Open.\n\t\t\te.Path = name\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn entries, nil\n}\n\nfunc (dir osFS) Open(name string) (fs.File, error) {\n\treturn os.DirFS(string(dir)).Open(name)\n}\n\n// Initialize creates a new [Server] instance.\n// It assumes that the [manager.Manager] instance has been initialised.\nfunc Initialize() (*Server, error) {\n\tmgr := manager.GetInstance()\n\tcfg := mgr.Config\n\n\tinitCustomPerformerImages(cfg.GetCustomPerformerImageLocation())\n\n\tdisplayHost := cfg.GetHost()\n\tif displayHost == \"0.0.0.0\" {\n\t\tdisplayHost = \"localhost\"\n\t}\n\tdisplayAddress := displayHost + \":\" + strconv.Itoa(cfg.GetPort())\n\n\taddress := cfg.GetHost() + \":\" + strconv.Itoa(cfg.GetPort())\n\ttlsConfig, err := makeTLSConfig(cfg)\n\tif err != nil {\n\t\t// assume we don't want to start with a broken TLS configuration\n\t\treturn nil, fmt.Errorf(\"error loading TLS config: %v\", err)\n\t}\n\n\tif tlsConfig != nil {\n\t\tdisplayAddress = \"https://\" + displayAddress + \"/\"\n\t} else {\n\t\tdisplayAddress = \"http://\" + displayAddress + \"/\"\n\t}\n\n\tr := chi.NewRouter()\n\n\tserver := &Server{\n\t\tServer: http.Server{\n\t\t\tAddr:      address,\n\t\t\tHandler:   r,\n\t\t\tTLSConfig: tlsConfig,\n\t\t\t// disable http/2 support by default\n\t\t\t// when http/2 is enabled, we are unable to hijack and close\n\t\t\t// the connection/request. This is necessary to stop running\n\t\t\t// streams when deleting a scene file.\n\t\t\tTLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),\n\t\t},\n\t\tdisplayAddress: displayAddress,\n\t\tmanager:        mgr,\n\t}\n\n\tr.Use(middleware.Heartbeat(\"/healthz\"))\n\tr.Use(cors.AllowAll().Handler)\n\tr.Use(authenticateHandler())\n\tvisitedPluginHandler := mgr.SessionStore.VisitedPluginHandler()\n\tr.Use(visitedPluginHandler)\n\n\tr.Use(middleware.Recoverer)\n\n\tif cfg.GetLogAccess() {\n\t\thttpLogger := httplog.NewLogger(\"Stash\", httplog.Options{\n\t\t\tConcise: true,\n\t\t})\n\t\tr.Use(httplog.RequestLogger(httpLogger))\n\t}\n\tr.Use(SecurityHeadersMiddleware)\n\tr.Use(middleware.Compress(4))\n\tr.Use(middleware.StripSlashes)\n\tr.Use(BaseURLMiddleware)\n\n\trecoverFunc := func(ctx context.Context, err interface{}) error {\n\t\tlogger.Error(err)\n\t\tdebug.PrintStack()\n\n\t\tmessage := fmt.Sprintf(\"Internal system error. Error <%v>\", err)\n\t\treturn errors.New(message)\n\t}\n\n\trepo := mgr.Repository\n\n\tdataloaders := loaders.Middleware{\n\t\tRepository: repo,\n\t}\n\n\tr.Use(dataloaders.Middleware)\n\n\tpluginCache := mgr.PluginCache\n\tsceneService := mgr.SceneService\n\timageService := mgr.ImageService\n\tgalleryService := mgr.GalleryService\n\tgroupService := mgr.GroupService\n\tresolver := &Resolver{\n\t\trepository:     repo,\n\t\tsceneService:   sceneService,\n\t\timageService:   imageService,\n\t\tgalleryService: galleryService,\n\t\tgroupService:   groupService,\n\t\thookExecutor:   pluginCache,\n\t}\n\n\tgqlSrv := gqlHandler.New(NewExecutableSchema(Config{Resolvers: resolver}))\n\tgqlSrv.SetRecoverFunc(recoverFunc)\n\tgqlSrv.AddTransport(gqlTransport.Websocket{\n\t\tUpgrader: websocket.Upgrader{\n\t\t\tCheckOrigin: func(r *http.Request) bool {\n\t\t\t\treturn true\n\t\t\t},\n\t\t},\n\t\tKeepAlivePingInterval: 10 * time.Second,\n\t})\n\tgqlSrv.AddTransport(gqlTransport.Options{})\n\tgqlSrv.AddTransport(gqlTransport.GET{})\n\tgqlSrv.AddTransport(gqlTransport.POST{})\n\tgqlSrv.AddTransport(gqlTransport.MultipartForm{\n\t\tMaxUploadSize: cfg.GetMaxUploadSize(),\n\t})\n\n\tgqlSrv.SetQueryCache(gqlLru.New[*ast.QueryDocument](1000))\n\tgqlSrv.Use(gqlExtension.Introspection{})\n\n\tgqlSrv.SetErrorPresenter(gqlErrorHandler)\n\n\tgqlHandlerFunc := func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\t\tgqlSrv.ServeHTTP(w, r)\n\t}\n\n\t// register GQL handler with plugin cache\n\t// chain the visited plugin handler\n\t// also requires the dataloader middleware\n\tgqlHandler := visitedPluginHandler(dataloaders.Middleware(http.HandlerFunc(gqlHandlerFunc)))\n\tpluginCache.RegisterGQLHandler(gqlHandler)\n\n\tr.HandleFunc(gqlEndpoint, gqlHandlerFunc)\n\tr.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {\n\t\tsetPageSecurityHeaders(w, r, pluginCache.ListPlugins())\n\t\tendpoint := getProxyPrefix(r) + gqlEndpoint\n\t\tgqlPlayground.Handler(\"GraphQL playground\", endpoint, gqlPlayground.WithGraphiqlEnablePluginExplorer(true))(w, r)\n\t})\n\n\tr.Mount(\"/performer\", server.getPerformerRoutes())\n\tr.Mount(\"/scene\", server.getSceneRoutes())\n\tr.Mount(\"/gallery\", server.getGalleryRoutes())\n\tr.Mount(\"/image\", server.getImageRoutes())\n\tr.Mount(\"/studio\", server.getStudioRoutes())\n\tr.Mount(\"/group\", server.getGroupRoutes())\n\tr.Mount(\"/tag\", server.getTagRoutes())\n\tr.Mount(\"/downloads\", server.getDownloadsRoutes())\n\tr.Mount(\"/plugin\", server.getPluginRoutes())\n\n\tr.HandleFunc(\"/css\", cssHandler(cfg))\n\tr.HandleFunc(\"/javascript\", javascriptHandler(cfg))\n\tr.HandleFunc(\"/customlocales\", customLocalesHandler(cfg))\n\n\tstaticLoginUI := statigz.FileServer(ui.LoginUIBox.(fs.ReadDirFS))\n\n\tr.Get(loginEndpoint, handleLogin())\n\tr.Post(loginEndpoint, handleLoginPost())\n\tr.Get(logoutEndpoint, handleLogout())\n\tr.Get(loginLocaleEndpoint, handleLoginLocale(cfg))\n\tr.HandleFunc(loginEndpoint+\"/*\", func(w http.ResponseWriter, r *http.Request) {\n\t\tr.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint)\n\t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\t\tstaticLoginUI.ServeHTTP(w, r)\n\t})\n\n\t// Serve static folders\n\tcustomServedFolders := cfg.GetCustomServedFolders()\n\tif customServedFolders != nil {\n\t\tr.Mount(\"/custom\", getCustomRoutes(customServedFolders))\n\t}\n\n\tvar uiFS fs.FS\n\tvar staticUI *statigz.Server\n\tcustomUILocation := cfg.GetUILocation()\n\tif customUILocation != \"\" {\n\t\tlogger.Debugf(\"Serving UI from %s\", customUILocation)\n\t\tuiFS = osFS(customUILocation)\n\t\tstaticUI = statigz.FileServer(uiFS.(fs.ReadDirFS))\n\t} else {\n\t\tlogger.Debug(\"Serving embedded UI\")\n\t\tuiFS = ui.UIBox\n\t\tstaticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS))\n\t}\n\n\t// handle favicon override\n\tr.HandleFunc(\"/favicon.ico\", handleFavicon(staticUI))\n\n\t// Serve the web app\n\tr.HandleFunc(\"/*\", func(w http.ResponseWriter, r *http.Request) {\n\t\text := path.Ext(r.URL.Path)\n\n\t\tif ext == \".html\" || ext == \"\" {\n\t\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\t\tsetPageSecurityHeaders(w, r, pluginCache.ListPlugins())\n\t\t}\n\n\t\tif ext == \"\" || r.URL.Path == \"/\" || r.URL.Path == \"/index.html\" {\n\t\t\tthemeColor := cfg.GetThemeColor()\n\t\t\tdata, err := fs.ReadFile(uiFS, \"index.html\")\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tindexHtml := string(data)\n\n\t\t\tprefix := getProxyPrefix(r)\n\t\t\tindexHtml = strings.ReplaceAll(indexHtml, \"%COLOR%\", themeColor)\n\t\t\tindexHtml = strings.Replace(indexHtml, `<base href=\"/\"`, fmt.Sprintf(`<base href=\"%s/\"`, prefix), 1)\n\n\t\t\tutils.ServeStaticContent(w, r, []byte(indexHtml))\n\t\t} else {\n\t\t\tisStatic, _ := path.Match(\"/assets/*\", r.URL.Path)\n\t\t\tif isStatic {\n\t\t\t\tw.Header().Set(\"Cache-Control\", \"public, max-age=31536000, immutable\")\n\t\t\t} else {\n\t\t\t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\t\t\t}\n\n\t\t\tstaticUI.ServeHTTP(w, r)\n\t\t}\n\t})\n\n\tlogger.Infof(\"stash version: %s\", build.VersionString())\n\tgo printLatestVersion(context.TODO())\n\n\treturn server, nil\n}\n\nfunc handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) {\n\tmgr := manager.GetInstance()\n\tcfg := mgr.Config\n\n\t// check if favicon.ico exists in the config directory\n\t// if so, use that\n\t// otherwise, use the embedded one\n\ticonPath := filepath.Join(cfg.GetConfigPath(), \"favicon.ico\")\n\texists, _ := fsutil.FileExists(iconPath)\n\n\tif exists {\n\t\tlogger.Debugf(\"Using custom favicon at %s\", iconPath)\n\t}\n\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\n\t\tif exists {\n\t\t\thttp.ServeFile(w, r, iconPath)\n\t\t} else {\n\t\t\tstaticUI.ServeHTTP(w, r)\n\t\t}\n\t}\n}\n\n// Start starts the server. It listens on the configured address and port.\n// It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe.\n// Calls to Start are blocked until the server is shutdown.\nfunc (s *Server) Start() error {\n\tlogger.Infof(\"stash is listening on \" + s.Addr)\n\tlogger.Infof(\"stash is running at \" + s.displayAddress)\n\n\tif s.TLSConfig != nil {\n\t\treturn s.ListenAndServeTLS(\"\", \"\")\n\t} else {\n\t\treturn s.ListenAndServe()\n\t}\n}\n\n// Shutdown gracefully shuts down the server without interrupting any active connections.\nfunc (s *Server) Shutdown() {\n\terr := s.Server.Shutdown(context.TODO())\n\tif err != nil {\n\t\tlogger.Errorf(\"Error shutting down http server: %v\", err)\n\t}\n}\n\nfunc (s *Server) getPerformerRoutes() chi.Router {\n\trepo := s.manager.Repository\n\treturn performerRoutes{\n\t\troutes:          routes{txnManager: repo.TxnManager},\n\t\tperformerFinder: repo.Performer,\n\t\tsfwConfig:       s.manager.Config,\n\t}.Routes()\n}\n\nfunc (s *Server) getSceneRoutes() chi.Router {\n\trepo := s.manager.Repository\n\treturn sceneRoutes{\n\t\troutes:            routes{txnManager: repo.TxnManager},\n\t\tsceneFinder:       repo.Scene,\n\t\tfileGetter:        repo.File,\n\t\tcaptionFinder:     repo.File,\n\t\tsceneMarkerFinder: repo.SceneMarker,\n\t\ttagFinder:         repo.Tag,\n\t}.Routes()\n}\n\nfunc (s *Server) getGalleryRoutes() chi.Router {\n\trepo := s.manager.Repository\n\treturn galleryRoutes{\n\t\troutes:        routes{txnManager: repo.TxnManager},\n\t\timageFinder:   repo.Image,\n\t\tgalleryFinder: repo.Gallery,\n\t\tfileGetter:    repo.File,\n\t}.Routes()\n}\n\nfunc (s *Server) getImageRoutes() chi.Router {\n\trepo := s.manager.Repository\n\treturn imageRoutes{\n\t\troutes:      routes{txnManager: repo.TxnManager},\n\t\timageFinder: repo.Image,\n\t\tfileGetter:  repo.File,\n\t}.Routes()\n}\n\nfunc (s *Server) getStudioRoutes() chi.Router {\n\trepo := s.manager.Repository\n\treturn studioRoutes{\n\t\troutes:       routes{txnManager: repo.TxnManager},\n\t\tstudioFinder: repo.Studio,\n\t}.Routes()\n}\n\nfunc (s *Server) getGroupRoutes() chi.Router {\n\trepo := s.manager.Repository\n\treturn groupRoutes{\n\t\troutes:      routes{txnManager: repo.TxnManager},\n\t\tgroupFinder: repo.Group,\n\t}.Routes()\n}\n\nfunc (s *Server) getTagRoutes() chi.Router {\n\trepo := s.manager.Repository\n\treturn tagRoutes{\n\t\troutes:    routes{txnManager: repo.TxnManager},\n\t\ttagFinder: repo.Tag,\n\t}.Routes()\n}\n\nfunc (s *Server) getDownloadsRoutes() chi.Router {\n\treturn downloadsRoutes{}.Routes()\n}\n\nfunc (s *Server) getPluginRoutes() chi.Router {\n\treturn pluginRoutes{\n\t\tpluginCache: s.manager.PluginCache,\n\t}.Routes()\n}\n\nfunc copyFile(w io.Writer, path string) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\t_, err = io.Copy(w, f)\n\n\treturn err\n}\n\nfunc serveFiles(w http.ResponseWriter, r *http.Request, paths []string) {\n\tbuffer := bytes.Buffer{}\n\n\tfor _, path := range paths {\n\t\terr := copyFile(&buffer, path)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error serving file %s: %v\", path, err)\n\t\t}\n\t\tbuffer.Write([]byte(\"\\n\"))\n\t}\n\n\tutils.ServeStaticContent(w, r, buffer.Bytes())\n}\n\nfunc cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tvar paths []string\n\n\t\tif c.GetCSSEnabled() && !c.GetDisableCustomizations() {\n\t\t\t// search for custom.css in current directory, then $HOME/.stash\n\t\t\tfn := c.GetCSSPath()\n\t\t\texists, _ := fsutil.FileExists(fn)\n\t\t\tif exists {\n\t\t\t\tpaths = append(paths, fn)\n\t\t\t}\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"text/css\")\n\t\tserveFiles(w, r, paths)\n\t}\n}\n\nfunc javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tvar paths []string\n\n\t\tif c.GetJavascriptEnabled() && !c.GetDisableCustomizations() {\n\t\t\t// search for custom.js in current directory, then $HOME/.stash\n\t\t\tfn := c.GetJavascriptPath()\n\t\t\texists, _ := fsutil.FileExists(fn)\n\t\t\tif exists {\n\t\t\t\tpaths = append(paths, fn)\n\t\t\t}\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"text/javascript\")\n\t\tserveFiles(w, r, paths)\n\t}\n}\n\nfunc customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tbuffer := bytes.Buffer{}\n\n\t\tif c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() {\n\t\t\t// search for custom-locales.json in current directory, then $HOME/.stash\n\t\t\tpath := c.GetCustomLocalesPath()\n\t\t\texists, _ := fsutil.FileExists(path)\n\t\t\tif exists {\n\t\t\t\terr := copyFile(&buffer, path)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Errorf(\"error serving file %s: %v\", path, err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif buffer.Len() == 0 {\n\t\t\tbuffer.Write([]byte(\"{}\"))\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tutils.ServeStaticContent(w, r, buffer.Bytes())\n\t}\n}\n\nfunc makeTLSConfig(c *config.Config) (*tls.Config, error) {\n\tc.InitTLS()\n\tcertFile, keyFile := c.GetTLSFiles()\n\n\tif certFile == \"\" && keyFile == \"\" {\n\t\t// assume http configuration\n\t\treturn nil, nil\n\t}\n\n\t// ensure both files are present\n\tif certFile == \"\" {\n\t\treturn nil, errors.New(\"SSL certificate file must be present if key file is present\")\n\t}\n\n\tif keyFile == \"\" {\n\t\treturn nil, errors.New(\"SSL key file must be present if certificate file is present\")\n\t}\n\n\tcert, err := os.ReadFile(certFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading SSL certificate file %s: %v\", certFile, err)\n\t}\n\n\tkey, err := os.ReadFile(keyFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading SSL key file %s: %v\", keyFile, err)\n\t}\n\n\tcerts := make([]tls.Certificate, 1)\n\tcerts[0], err = tls.X509KeyPair(cert, key)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing key pair: %v\", err)\n\t}\n\ttlsConfig := &tls.Config{\n\t\tCertificates: certs,\n\t}\n\n\treturn tlsConfig, nil\n}\n\nfunc isURL(s string) bool {\n\treturn strings.HasPrefix(s, \"http://\") || strings.HasPrefix(s, \"https://\")\n}\n\nfunc setPageSecurityHeaders(w http.ResponseWriter, r *http.Request, plugins []*plugin.Plugin) {\n\tc := config.GetInstance()\n\n\tdefaultSrc := \"data: 'self' 'unsafe-inline'\"\n\tconnectSrcSlice := []string{\n\t\t\"data:\",\n\t\t\"'self'\",\n\t}\n\timageSrc := \"data: *\"\n\tscriptSrcSlice := []string{\n\t\t\"'self'\",\n\t\t\"http://www.gstatic.com\",\n\t\t\"https://www.gstatic.com\",\n\t\t\"'unsafe-inline'\",\n\t\t\"'unsafe-eval'\",\n\t}\n\tstyleSrcSlice := []string{\n\t\t\"'self'\",\n\t\t\"'unsafe-inline'\",\n\t}\n\tmediaSrc := \"blob: 'self'\"\n\n\t// Workaround Safari bug https://bugs.webkit.org/show_bug.cgi?id=201591\n\t// Allows websocket requests to any origin\n\tconnectSrcSlice = append(connectSrcSlice, \"ws:\", \"wss:\")\n\n\t// The graphql playground pulls its frontend from a cdn\n\tif r.URL.Path == playgroundEndpoint {\n\t\tconnectSrcSlice = append(connectSrcSlice, \"https://cdn.jsdelivr.net\")\n\t\tscriptSrcSlice = append(scriptSrcSlice, \"https://cdn.jsdelivr.net\")\n\t\tstyleSrcSlice = append(styleSrcSlice, \"https://cdn.jsdelivr.net\")\n\t}\n\n\tif !c.IsNewSystem() && c.GetHandyKey() != \"\" {\n\t\tconnectSrcSlice = append(connectSrcSlice, \"https://www.handyfeeling.com\")\n\t}\n\n\tfor _, plugin := range plugins {\n\t\tif !plugin.Enabled {\n\t\t\tcontinue\n\t\t}\n\n\t\tui := plugin.UI\n\n\t\tfor _, url := range ui.ExternalScript {\n\t\t\tif isURL(url) {\n\t\t\t\tscriptSrcSlice = append(scriptSrcSlice, url)\n\t\t\t}\n\t\t}\n\n\t\tfor _, url := range ui.ExternalCSS {\n\t\t\tif isURL(url) {\n\t\t\t\tstyleSrcSlice = append(styleSrcSlice, url)\n\t\t\t}\n\t\t}\n\n\t\tconnectSrcSlice = append(connectSrcSlice, ui.CSP.ConnectSrc...)\n\t\tscriptSrcSlice = append(scriptSrcSlice, ui.CSP.ScriptSrc...)\n\t\tstyleSrcSlice = append(styleSrcSlice, ui.CSP.StyleSrc...)\n\t}\n\n\tconnectSrc := strings.Join(connectSrcSlice, \" \")\n\tscriptSrc := strings.Join(scriptSrcSlice, \" \")\n\tstyleSrc := strings.Join(styleSrcSlice, \" \")\n\n\tcspDirectives := fmt.Sprintf(\"default-src %s; connect-src %s; img-src %s; script-src %s; style-src %s; media-src %s;\", defaultSrc, connectSrc, imageSrc, scriptSrc, styleSrc, mediaSrc)\n\tcspDirectives += \" worker-src blob:; child-src 'none'; object-src 'none'; form-action 'self';\"\n\n\tw.Header().Set(\"Referrer-Policy\", \"same-origin\")\n\tw.Header().Set(\"Content-Security-Policy\", cspDirectives)\n}\n\nfunc SecurityHeadersMiddleware(next http.Handler) http.Handler {\n\tfn := func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\n\t\tnext.ServeHTTP(w, r)\n\t}\n\treturn http.HandlerFunc(fn)\n}\n\ntype contextKey struct {\n\tname string\n}\n\nvar (\n\tBaseURLCtxKey = &contextKey{\"BaseURL\"}\n)\n\nfunc BaseURLMiddleware(next http.Handler) http.Handler {\n\tfn := func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\n\t\tscheme := \"http\"\n\t\tif strings.Compare(\"https\", r.URL.Scheme) == 0 || r.TLS != nil || r.Header.Get(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tscheme = \"https\"\n\t\t}\n\t\tprefix := getProxyPrefix(r)\n\n\t\tbaseURL := scheme + \"://\" + r.Host + prefix\n\n\t\texternalHost := config.GetInstance().GetExternalHost()\n\t\tif externalHost != \"\" {\n\t\t\tbaseURL = externalHost + prefix\n\t\t}\n\n\t\tr = r.WithContext(context.WithValue(ctx, BaseURLCtxKey, baseURL))\n\n\t\tnext.ServeHTTP(w, r)\n\t}\n\treturn http.HandlerFunc(fn)\n}\n\nfunc getProxyPrefix(r *http.Request) string {\n\treturn strings.TrimRight(r.Header.Get(\"X-Forwarded-Prefix\"), \"/\")\n}\n"
  },
  {
    "path": "internal/api/session.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/session\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"github.com/stashapp/stash/ui\"\n)\n\nconst (\n\treturnURLParam = \"returnURL\"\n\n\tdefaultLocale = \"en-GB\"\n)\n\nfunc getLoginPage() []byte {\n\tdata, err := fs.ReadFile(ui.LoginUIBox, \"login.html\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn data\n}\n\ntype loginTemplateData struct {\n\tURL   string\n\tError string\n}\n\nfunc serveLoginPage(w http.ResponseWriter, r *http.Request, returnURL string, loginError string) {\n\tloginPage := string(getLoginPage())\n\tprefix := getProxyPrefix(r)\n\tloginPage = strings.ReplaceAll(loginPage, \"/%BASE_URL%\", prefix)\n\n\ttempl, err := template.New(\"Login\").Parse(loginPage)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"error: %s\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tbuffer := bytes.Buffer{}\n\terr = templ.Execute(&buffer, loginTemplateData{URL: returnURL, Error: loginError})\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"error: %s\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/html\")\n\n\t// we shouldn't need to set plugin exceptions here\n\tsetPageSecurityHeaders(w, r, nil)\n\n\tutils.ServeStaticContent(w, r, buffer.Bytes())\n}\n\nfunc handleLoginLocale(cfg *config.Config) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\t// get the locale from the config\n\t\tlang := cfg.GetLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = defaultLocale\n\t\t}\n\n\t\tdata, err := getLoginLocale(lang)\n\t\tif err != nil {\n\t\t\tlogger.Debugf(\"Failed to load login locale file for language %s: %v\", lang, err)\n\t\t\t// try again with the default language\n\t\t\tif lang != defaultLocale {\n\t\t\t\tdata, err = getLoginLocale(defaultLocale)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Errorf(\"Failed to load login locale file for default language %s: %v\", defaultLocale, err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// if there's still an error, response with an internal server error\n\t\t\tif err != nil {\n\t\t\t\thttp.Error(w, \"Failed to load login locale file\", http.StatusInternalServerError)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// write a script to set the locale string map as a global variable\n\t\tlocaleScript := fmt.Sprintf(\"var localeStrings = %s;\", data)\n\t\tw.Header().Set(\"Content-Type\", \"application/javascript\")\n\t\t_, _ = w.Write([]byte(localeScript))\n\t}\n}\n\nfunc getLoginLocale(lang string) ([]byte, error) {\n\tdata, err := fs.ReadFile(ui.LoginUIBox, \"locales/\"+lang+\".json\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn data, nil\n}\n\nfunc handleLogin() http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\treturnURL := r.URL.Query().Get(returnURLParam)\n\n\t\tif !config.GetInstance().HasCredentials() {\n\t\t\tif returnURL != \"\" {\n\t\t\t\thttp.Redirect(w, r, returnURL, http.StatusFound)\n\t\t\t} else {\n\t\t\t\tprefix := getProxyPrefix(r)\n\t\t\t\thttp.Redirect(w, r, prefix+\"/\", http.StatusFound)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tserveLoginPage(w, r, returnURL, \"\")\n\t}\n}\n\nfunc handleLoginPost() http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\terr := manager.GetInstance().SessionStore.Login(w, r)\n\t\tif err != nil {\n\t\t\t// always log the error\n\t\t\tlogger.Errorf(\"Error logging in: %v from IP: %s\", err, r.RemoteAddr)\n\t\t}\n\n\t\tvar invalidCredentialsError *session.InvalidCredentialsError\n\n\t\tif errors.As(err, &invalidCredentialsError) {\n\t\t\thttp.Error(w, \"Username or password is invalid\", http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\n\t\tif err != nil {\n\t\t\t// don't expose the error to the user\n\t\t\thttp.Error(w, \"An unexpected error occurred. See logs\", http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tw.WriteHeader(http.StatusOK)\n\t}\n}\n\nfunc handleLogout() http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := manager.GetInstance().SessionStore.Logout(w, r); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\t// redirect to the login page if credentials are required\n\t\tprefix := getProxyPrefix(r)\n\t\tif config.GetInstance().HasCredentials() {\n\t\t\thttp.Redirect(w, r, prefix+loginEndpoint, http.StatusFound)\n\t\t} else {\n\t\t\thttp.Redirect(w, r, prefix+\"/\", http.StatusFound)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/api/stash_box.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/stashbox\"\n)\n\nfunc (r *Resolver) newStashBoxClient(box models.StashBox) *stashbox.Client {\n\treturn stashbox.NewClient(box, stashbox.ExcludeTagPatterns(manager.GetInstance().Config.GetScraperExcludeTagPatterns()))\n}\n\nfunc resolveStashBoxFn(indexField, endpointField string) func(index *int, endpoint *string) (*models.StashBox, error) {\n\treturn func(index *int, endpoint *string) (*models.StashBox, error) {\n\t\tboxes := config.GetInstance().GetStashBoxes()\n\n\t\t// prefer endpoint over index\n\t\tif endpoint != nil {\n\t\t\tfor _, box := range boxes {\n\t\t\t\tif strings.EqualFold(*endpoint, box.Endpoint) {\n\t\t\t\t\treturn box, nil\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"stash box not found\")\n\t\t}\n\n\t\tif index != nil {\n\t\t\tif *index < 0 || *index >= len(boxes) {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid %s %d\", indexField, index)\n\t\t\t}\n\n\t\t\treturn boxes[*index], nil\n\t\t}\n\n\t\treturn nil, fmt.Errorf(\"%s not provided\", endpointField)\n\t}\n}\n\nvar (\n\tresolveStashBox              = resolveStashBoxFn(\"stash_box_index\", \"stash_box_endpoint\")\n\tresolveStashBoxBatchTagInput = resolveStashBoxFn(\"endpoint\", \"stash_box_endpoint\")\n)\n"
  },
  {
    "path": "internal/api/timestamp.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nvar ErrTimestamp = errors.New(\"cannot parse Timestamp\")\n\nfunc MarshalTimestamp(t time.Time) graphql.Marshaler {\n\tif t.IsZero() {\n\t\treturn graphql.Null\n\t}\n\n\treturn graphql.WriterFunc(func(w io.Writer) {\n\t\t_, err := io.WriteString(w, strconv.Quote(t.Format(time.RFC3339Nano)))\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"could not marshal timestamp: %v\", err)\n\t\t}\n\t})\n}\n\nfunc UnmarshalTimestamp(v interface{}) (time.Time, error) {\n\tif tmpStr, ok := v.(string); ok {\n\t\tif len(tmpStr) == 0 {\n\t\t\treturn time.Time{}, fmt.Errorf(\"%w: empty string\", ErrTimestamp)\n\t\t}\n\n\t\tswitch tmpStr[0] {\n\t\tcase '>', '<':\n\t\t\td, err := time.ParseDuration(tmpStr[1:])\n\t\t\tif err != nil {\n\t\t\t\treturn time.Time{}, fmt.Errorf(\"%w: cannot parse %v-duration: %v\", ErrTimestamp, tmpStr[0], err)\n\t\t\t}\n\t\t\tt := time.Now()\n\t\t\t// Compute point in time:\n\t\t\tif tmpStr[0] == '<' {\n\t\t\t\tt = t.Add(-d)\n\t\t\t} else {\n\t\t\t\tt = t.Add(d)\n\t\t\t}\n\n\t\t\treturn t, nil\n\t\t}\n\n\t\treturn utils.ParseDateStringAsTime(tmpStr)\n\t}\n\n\treturn time.Time{}, fmt.Errorf(\"%w: not a string\", ErrTimestamp)\n}\n"
  },
  {
    "path": "internal/api/timestamp_test.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestTimestampSymmetry(t *testing.T) {\n\tn := time.Now()\n\tbuf := bytes.NewBuffer([]byte{})\n\tMarshalTimestamp(n).MarshalGQL(buf)\n\n\tstr, err := strconv.Unquote(buf.String())\n\tif err != nil {\n\t\tt.Fatal(\"could not unquote string\")\n\t}\n\tgot, err := UnmarshalTimestamp(str)\n\tif err != nil {\n\t\tt.Fatalf(\"could not unmarshal time: %v\", err)\n\t}\n\n\tif !n.Equal(got) {\n\t\tt.Fatalf(\"have %v, want %v\", got, n)\n\t}\n}\n\nfunc TestTimestamp(t *testing.T) {\n\tn := time.Now().In(time.UTC)\n\ttestCases := []struct {\n\t\tname string\n\t\thave string\n\t\twant string\n\t}{\n\t\t{\"reflexivity\", n.Format(time.RFC3339Nano), n.Format(time.RFC3339Nano)},\n\t\t{\"rfc3339\", \"2021-11-04T01:02:03Z\", \"2021-11-04T01:02:03Z\"},\n\t\t{\"date\", \"2021-04-05\", \"2021-04-05T00:00:00Z\"},\n\t\t{\"datetime\", \"2021-04-05 14:45:36\", \"2021-04-05T14:45:36Z\"},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tp, err := UnmarshalTimestamp(tc.have)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"could not unmarshal time: %v\", err)\n\t\t\t}\n\n\t\t\tbuf := bytes.NewBuffer([]byte{})\n\t\t\tMarshalTimestamp(p).MarshalGQL(buf)\n\n\t\t\tgot, err := strconv.Unquote(buf.String())\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"count not unquote string\")\n\t\t\t}\n\t\t\tif got != tc.want {\n\t\t\t\tt.Errorf(\"got %s; want %s\", got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nconst epsilon = 10 * time.Second\n\nfunc TestTimestampRelative(t *testing.T) {\n\tn := time.Now()\n\ttestCases := []struct {\n\t\tname string\n\t\thave string\n\t\twant time.Time\n\t}{\n\t\t{\"past\", \"<4h\", n.Add(-4 * time.Hour)},\n\t\t{\"future\", \">5m\", n.Add(5 * time.Minute)},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot, err := UnmarshalTimestamp(tc.have)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"could not unmarshal time: %v\", err)\n\t\t\t}\n\n\t\t\tif got.Sub(tc.want) > epsilon {\n\t\t\t\tt.Errorf(\"not within bound of %v; got %s; want %s\", epsilon, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n}\n"
  },
  {
    "path": "internal/api/types.go",
    "content": "package api\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\nfunc stashIDsSliceToPtrSlice(v []models.StashID) []*models.StashID {\n\treturn sliceutil.ValuesToPtrs(v)\n}\n"
  },
  {
    "path": "internal/api/urlbuilders/doc.go",
    "content": "// Package urlbuilders provides the builders used to build URLs to pass to clients.\npackage urlbuilders\n"
  },
  {
    "path": "internal/api/urlbuilders/gallery.go",
    "content": "package urlbuilders\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GalleryURLBuilder struct {\n\tBaseURL   string\n\tGalleryID string\n\tUpdatedAt string\n}\n\nfunc NewGalleryURLBuilder(baseURL string, gallery *models.Gallery) GalleryURLBuilder {\n\treturn GalleryURLBuilder{\n\t\tBaseURL:   baseURL,\n\t\tGalleryID: strconv.Itoa(gallery.ID),\n\t\tUpdatedAt: strconv.FormatInt(gallery.UpdatedAt.Unix(), 10),\n\t}\n}\n\nfunc (b GalleryURLBuilder) GetPreviewURL() string {\n\treturn b.BaseURL + \"/gallery/\" + b.GalleryID + \"/preview\"\n}\n\nfunc (b GalleryURLBuilder) GetCoverURL() string {\n\treturn b.BaseURL + \"/gallery/\" + b.GalleryID + \"/cover?t=\" + b.UpdatedAt\n}\n"
  },
  {
    "path": "internal/api/urlbuilders/group.go",
    "content": "package urlbuilders\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GroupURLBuilder struct {\n\tBaseURL   string\n\tGroupID   string\n\tUpdatedAt string\n}\n\nfunc NewGroupURLBuilder(baseURL string, group *models.Group) GroupURLBuilder {\n\treturn GroupURLBuilder{\n\t\tBaseURL:   baseURL,\n\t\tGroupID:   strconv.Itoa(group.ID),\n\t\tUpdatedAt: strconv.FormatInt(group.UpdatedAt.Unix(), 10),\n\t}\n}\n\nfunc (b GroupURLBuilder) GetGroupFrontImageURL(hasImage bool) string {\n\turl := b.BaseURL + \"/group/\" + b.GroupID + \"/frontimage?t=\" + b.UpdatedAt\n\tif !hasImage {\n\t\turl += \"&default=true\"\n\t}\n\treturn url\n}\n\nfunc (b GroupURLBuilder) GetGroupBackImageURL() string {\n\treturn b.BaseURL + \"/group/\" + b.GroupID + \"/backimage?t=\" + b.UpdatedAt\n}\n"
  },
  {
    "path": "internal/api/urlbuilders/image.go",
    "content": "package urlbuilders\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype ImageURLBuilder struct {\n\tBaseURL   string\n\tImageID   string\n\tChecksum  string\n\tUpdatedAt string\n}\n\nfunc NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder {\n\treturn ImageURLBuilder{\n\t\tBaseURL:   baseURL,\n\t\tImageID:   strconv.Itoa(image.ID),\n\t\tChecksum:  image.Checksum,\n\t\tUpdatedAt: strconv.FormatInt(image.UpdatedAt.Unix(), 10),\n\t}\n}\n\nfunc (b ImageURLBuilder) GetImageURL() string {\n\treturn b.BaseURL + \"/image/\" + b.ImageID + \"/image?t=\" + b.UpdatedAt\n}\n\nfunc (b ImageURLBuilder) GetThumbnailURL() string {\n\treturn b.BaseURL + \"/image/\" + b.ImageID + \"/thumbnail?t=\" + b.UpdatedAt\n}\n\nfunc (b ImageURLBuilder) GetPreviewURL() string {\n\tif exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil {\n\t\treturn b.BaseURL + \"/image/\" + b.ImageID + \"/preview?\" + b.UpdatedAt\n\t} else {\n\t\treturn \"\"\n\t}\n}\n"
  },
  {
    "path": "internal/api/urlbuilders/performer.go",
    "content": "package urlbuilders\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype PerformerURLBuilder struct {\n\tBaseURL     string\n\tPerformerID string\n\tUpdatedAt   string\n}\n\nfunc NewPerformerURLBuilder(baseURL string, performer *models.Performer) PerformerURLBuilder {\n\treturn PerformerURLBuilder{\n\t\tBaseURL:     baseURL,\n\t\tPerformerID: strconv.Itoa(performer.ID),\n\t\tUpdatedAt:   strconv.FormatInt(performer.UpdatedAt.Unix(), 10),\n\t}\n}\n\nfunc (b PerformerURLBuilder) GetPerformerImageURL(hasImage bool) string {\n\turl := b.BaseURL + \"/performer/\" + b.PerformerID + \"/image?t=\" + b.UpdatedAt\n\tif !hasImage {\n\t\turl += \"&default=true\"\n\t}\n\treturn url\n}\n"
  },
  {
    "path": "internal/api/urlbuilders/scene.go",
    "content": "package urlbuilders\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype SceneURLBuilder struct {\n\tBaseURL   string\n\tSceneID   string\n\tUpdatedAt string\n}\n\nfunc NewSceneURLBuilder(baseURL string, scene *models.Scene) SceneURLBuilder {\n\treturn SceneURLBuilder{\n\t\tBaseURL:   baseURL,\n\t\tSceneID:   strconv.Itoa(scene.ID),\n\t\tUpdatedAt: strconv.FormatInt(scene.UpdatedAt.Unix(), 10),\n\t}\n}\n\nfunc (b SceneURLBuilder) GetStreamURL(apiKey string) *url.URL {\n\tu, err := url.Parse(fmt.Sprintf(\"%s/scene/%s/stream\", b.BaseURL, b.SceneID))\n\tif err != nil {\n\t\t// shouldn't happen\n\t\tpanic(err)\n\t}\n\n\tif apiKey != \"\" {\n\t\tv := u.Query()\n\t\tv.Set(\"apikey\", apiKey)\n\t\tu.RawQuery = v.Encode()\n\t}\n\treturn u\n}\n\nfunc (b SceneURLBuilder) GetStreamPreviewURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/preview\"\n}\n\nfunc (b SceneURLBuilder) GetStreamPreviewImageURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/webp\"\n}\n\nfunc (b SceneURLBuilder) GetSpriteVTTURL(checksum string) string {\n\treturn b.BaseURL + \"/scene/\" + checksum + \"_thumbs.vtt\"\n}\n\nfunc (b SceneURLBuilder) GetSpriteURL(checksum string) string {\n\treturn b.BaseURL + \"/scene/\" + checksum + \"_sprite.jpg\"\n}\n\nfunc (b SceneURLBuilder) GetScreenshotURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/screenshot?t=\" + b.UpdatedAt\n}\n\nfunc (b SceneURLBuilder) GetFunscriptURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/funscript\"\n}\n\nfunc (b SceneURLBuilder) GetCaptionURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/caption\"\n}\n\nfunc (b SceneURLBuilder) GetInteractiveHeatmapURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/interactive_heatmap\"\n}\n"
  },
  {
    "path": "internal/api/urlbuilders/scene_markers.go",
    "content": "package urlbuilders\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype SceneMarkerURLBuilder struct {\n\tBaseURL  string\n\tSceneID  string\n\tMarkerID string\n}\n\nfunc NewSceneMarkerURLBuilder(baseURL string, sceneMarker *models.SceneMarker) SceneMarkerURLBuilder {\n\treturn SceneMarkerURLBuilder{\n\t\tBaseURL:  baseURL,\n\t\tSceneID:  strconv.Itoa(sceneMarker.SceneID),\n\t\tMarkerID: strconv.Itoa(sceneMarker.ID),\n\t}\n}\n\nfunc (b SceneMarkerURLBuilder) GetStreamURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/scene_marker/\" + b.MarkerID + \"/stream\"\n}\n\nfunc (b SceneMarkerURLBuilder) GetPreviewURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/scene_marker/\" + b.MarkerID + \"/preview\"\n}\n\nfunc (b SceneMarkerURLBuilder) GetScreenshotURL() string {\n\treturn b.BaseURL + \"/scene/\" + b.SceneID + \"/scene_marker/\" + b.MarkerID + \"/screenshot\"\n}\n"
  },
  {
    "path": "internal/api/urlbuilders/studio.go",
    "content": "package urlbuilders\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype StudioURLBuilder struct {\n\tBaseURL   string\n\tStudioID  string\n\tUpdatedAt string\n}\n\nfunc NewStudioURLBuilder(baseURL string, studio *models.Studio) StudioURLBuilder {\n\treturn StudioURLBuilder{\n\t\tBaseURL:   baseURL,\n\t\tStudioID:  strconv.Itoa(studio.ID),\n\t\tUpdatedAt: strconv.FormatInt(studio.UpdatedAt.Unix(), 10),\n\t}\n}\n\nfunc (b StudioURLBuilder) GetStudioImageURL(hasImage bool) string {\n\turl := b.BaseURL + \"/studio/\" + b.StudioID + \"/image?t=\" + b.UpdatedAt\n\tif !hasImage {\n\t\turl += \"&default=true\"\n\t}\n\treturn url\n}\n"
  },
  {
    "path": "internal/api/urlbuilders/tag.go",
    "content": "package urlbuilders\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"strconv\"\n)\n\ntype TagURLBuilder struct {\n\tBaseURL   string\n\tTagID     string\n\tUpdatedAt string\n}\n\nfunc NewTagURLBuilder(baseURL string, tag *models.Tag) TagURLBuilder {\n\treturn TagURLBuilder{\n\t\tBaseURL:   baseURL,\n\t\tTagID:     strconv.Itoa(tag.ID),\n\t\tUpdatedAt: strconv.FormatInt(tag.UpdatedAt.Unix(), 10),\n\t}\n}\n\nfunc (b TagURLBuilder) GetTagImageURL(hasImage bool) string {\n\turl := b.BaseURL + \"/tag/\" + b.TagID + \"/image?t=\" + b.UpdatedAt\n\tif !hasImage {\n\t\turl += \"&default=true\"\n\t}\n\treturn url\n}\n"
  },
  {
    "path": "internal/autotag/doc.go",
    "content": "// Package autotag provides the autotagging functionality for the application.\n//\n// The autotag functionality sets media metadata based on the media's path.\n// The functions in this package are in the form of {ObjectType}{TagTypes},\n// where the ObjectType is the single object instance to run on, and TagTypes\n// are the related types.\n// For example, PerformerScenes finds and tags scenes with a provided performer,\n// whereas ScenePerformers tags a single scene with any Performers that match.\npackage autotag\n"
  },
  {
    "path": "internal/autotag/gallery.go",
    "content": "package autotag\n\nimport (\n\t\"context\"\n\t\"slices\"\n\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GalleryFinderUpdater interface {\n\tmodels.GalleryQueryer\n\tmodels.GalleryUpdater\n}\n\ntype GalleryPerformerUpdater interface {\n\tmodels.PerformerIDLoader\n\tmodels.GalleryUpdater\n}\n\ntype GalleryTagUpdater interface {\n\tmodels.TagIDLoader\n\tmodels.GalleryUpdater\n}\n\nfunc getGalleryFileTagger(s *models.Gallery, cache *match.Cache) tagger {\n\tvar path string\n\tif s.Path != \"\" {\n\t\tpath = s.Path\n\t}\n\n\t// only trim the extension if gallery is file-based\n\ttrimExt := s.PrimaryFileID != nil\n\n\treturn tagger{\n\t\tID:      s.ID,\n\t\tType:    \"gallery\",\n\t\tName:    s.DisplayName(),\n\t\tPath:    path,\n\t\ttrimExt: trimExt,\n\t\tcache:   cache,\n\t}\n}\n\n// GalleryPerformers tags the provided gallery with performers whose name matches the gallery's path.\nfunc GalleryPerformers(ctx context.Context, s *models.Gallery, rw GalleryPerformerUpdater, performerReader models.PerformerAutoTagQueryer, cache *match.Cache) error {\n\tt := getGalleryFileTagger(s, cache)\n\n\treturn t.tagPerformers(ctx, performerReader, func(subjectID, otherID int) (bool, error) {\n\t\tif err := s.LoadPerformerIDs(ctx, rw); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\texisting := s.PerformerIDs.List()\n\n\t\tif slices.Contains(existing, otherID) {\n\t\t\treturn false, nil\n\t\t}\n\n\t\tif err := gallery.AddPerformer(ctx, rw, s, otherID); err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\treturn true, nil\n\t})\n}\n\n// GalleryStudios tags the provided gallery with the first studio whose name matches the gallery's path.\n//\n// Gallerys will not be tagged if studio is already set.\nfunc GalleryStudios(ctx context.Context, s *models.Gallery, rw GalleryFinderUpdater, studioReader models.StudioAutoTagQueryer, cache *match.Cache) error {\n\tif s.StudioID != nil {\n\t\t// don't modify\n\t\treturn nil\n\t}\n\n\tt := getGalleryFileTagger(s, cache)\n\n\treturn t.tagStudios(ctx, studioReader, func(subjectID, otherID int) (bool, error) {\n\t\treturn addGalleryStudio(ctx, rw, s, otherID)\n\t})\n}\n\n// GalleryTags tags the provided gallery with tags whose name matches the gallery's path.\nfunc GalleryTags(ctx context.Context, s *models.Gallery, rw GalleryTagUpdater, tagReader models.TagAutoTagQueryer, cache *match.Cache) error {\n\tt := getGalleryFileTagger(s, cache)\n\n\treturn t.tagTags(ctx, tagReader, func(subjectID, otherID int) (bool, error) {\n\t\tif err := s.LoadTagIDs(ctx, rw); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\texisting := s.TagIDs.List()\n\n\t\tif slices.Contains(existing, otherID) {\n\t\t\treturn false, nil\n\t\t}\n\n\t\tif err := gallery.AddTag(ctx, rw, s, otherID); err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\treturn true, nil\n\t})\n}\n"
  },
  {
    "path": "internal/autotag/gallery_test.go",
    "content": "package autotag\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nconst galleryExt = \"zip\"\n\nvar testCtx = context.Background()\n\n// returns got == expected\n// ignores expected.UpdatedAt, but ensures that got.UpdatedAt is set and not null\nfunc galleryPartialsEqual(got, expected models.GalleryPartial) bool {\n\t// updated at should be set and not null\n\tif !got.UpdatedAt.Set || got.UpdatedAt.Null {\n\t\treturn false\n\t}\n\t// else ignore the exact value\n\tgot.UpdatedAt = models.OptionalTime{}\n\n\treturn assert.ObjectsAreEqual(got, expected)\n}\n\nfunc TestGalleryPerformers(t *testing.T) {\n\tt.Parallel()\n\n\tconst galleryID = 1\n\tconst performerName = \"performer name\"\n\tconst performerID = 2\n\tperformer := models.Performer{\n\t\tID:      performerID,\n\t\tName:    performerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\tconst reversedPerformerName = \"name performer\"\n\tconst reversedPerformerID = 3\n\treversedPerformer := models.Performer{\n\t\tID:      reversedPerformerID,\n\t\tName:    reversedPerformerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\ttestTables := generateTestTable(performerName, galleryExt)\n\n\tassert := assert.New(t)\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Performer.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Performer.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once()\n\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool {\n\t\t\t\texpected := models.GalleryPartial{\n\t\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  []int{performerID},\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\treturn galleryPartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Gallery.On(\"UpdatePartial\", testCtx, galleryID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\tgallery := models.Gallery{\n\t\t\tID:           galleryID,\n\t\t\tPath:         test.Path,\n\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t}\n\t\terr := GalleryPerformers(testCtx, &gallery, db.Gallery, db.Performer, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n}\n\nfunc TestGalleryStudios(t *testing.T) {\n\tt.Parallel()\n\n\tconst galleryID = 1\n\tconst studioName = \"studio name\"\n\tvar studioID = 2\n\tstudio := models.Studio{\n\t\tID:   studioID,\n\t\tName: studioName,\n\t}\n\n\tconst reversedStudioName = \"name studio\"\n\tconst reversedStudioID = 3\n\treversedStudio := models.Studio{\n\t\tID:   reversedStudioID,\n\t\tName: reversedStudioName,\n\t}\n\n\ttestTables := generateTestTable(studioName, galleryExt)\n\n\tassert := assert.New(t)\n\n\tdoTest := func(db *mocks.Database, test pathTestTable) {\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool {\n\t\t\t\texpected := models.GalleryPartial{\n\t\t\t\t\tStudioID: models.NewOptionalInt(studioID),\n\t\t\t\t}\n\n\t\t\t\treturn galleryPartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Gallery.On(\"UpdatePartial\", testCtx, galleryID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\tgallery := models.Gallery{\n\t\t\tID:   galleryID,\n\t\t\tPath: test.Path,\n\t\t}\n\t\terr := GalleryStudios(testCtx, &gallery, db.Gallery, db.Studio, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Studio.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Studio.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, mock.Anything).Return([]string{}, nil).Maybe()\n\n\t\tdoTest(db, test)\n\t}\n\n\t// test against aliases\n\tconst unmatchedName = \"unmatched\"\n\tstudio.Name = unmatchedName\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Studio.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Studio.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, studioID).Return([]string{\n\t\t\tstudioName,\n\t\t}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, reversedStudioID).Return([]string{}, nil).Once()\n\n\t\tdoTest(db, test)\n\t}\n}\n\nfunc TestGalleryTags(t *testing.T) {\n\tt.Parallel()\n\n\tconst galleryID = 1\n\tconst tagName = \"tag name\"\n\tconst tagID = 2\n\ttag := models.Tag{\n\t\tID:   tagID,\n\t\tName: tagName,\n\t}\n\n\tconst reversedTagName = \"name tag\"\n\tconst reversedTagID = 3\n\treversedTag := models.Tag{\n\t\tID:   reversedTagID,\n\t\tName: reversedTagName,\n\t}\n\n\ttestTables := generateTestTable(tagName, galleryExt)\n\n\tassert := assert.New(t)\n\n\tdoTest := func(db *mocks.Database, test pathTestTable) {\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool {\n\t\t\t\texpected := models.GalleryPartial{\n\t\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  []int{tagID},\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\treturn galleryPartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Gallery.On(\"UpdatePartial\", testCtx, galleryID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\tgallery := models.Gallery{\n\t\t\tID:     galleryID,\n\t\t\tPath:   test.Path,\n\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t}\n\t\terr := GalleryTags(testCtx, &gallery, db.Gallery, db.Tag, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Tag.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Tag.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, mock.Anything).Return([]string{}, nil).Maybe()\n\n\t\tdoTest(db, test)\n\t}\n\n\tconst unmatchedName = \"unmatched\"\n\ttag.Name = unmatchedName\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Tag.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Tag.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, tagID).Return([]string{\n\t\t\ttagName,\n\t\t}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, reversedTagID).Return([]string{}, nil).Once()\n\n\t\tdoTest(db, test)\n\t}\n}\n"
  },
  {
    "path": "internal/autotag/image.go",
    "content": "package autotag\n\nimport (\n\t\"context\"\n\t\"slices\"\n\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype ImageFinderUpdater interface {\n\tmodels.ImageQueryer\n\tmodels.ImageUpdater\n}\n\ntype ImagePerformerUpdater interface {\n\tmodels.PerformerIDLoader\n\tmodels.ImageUpdater\n}\n\ntype ImageTagUpdater interface {\n\tmodels.TagIDLoader\n\tmodels.ImageUpdater\n}\n\nfunc getImageFileTagger(s *models.Image, cache *match.Cache) tagger {\n\treturn tagger{\n\t\tID:    s.ID,\n\t\tType:  \"image\",\n\t\tName:  s.DisplayName(),\n\t\tPath:  s.Path,\n\t\tcache: cache,\n\t}\n}\n\n// ImagePerformers tags the provided image with performers whose name matches the image's path.\nfunc ImagePerformers(ctx context.Context, s *models.Image, rw ImagePerformerUpdater, performerReader models.PerformerAutoTagQueryer, cache *match.Cache) error {\n\tt := getImageFileTagger(s, cache)\n\n\treturn t.tagPerformers(ctx, performerReader, func(subjectID, otherID int) (bool, error) {\n\t\tif err := s.LoadPerformerIDs(ctx, rw); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\texisting := s.PerformerIDs.List()\n\n\t\tif slices.Contains(existing, otherID) {\n\t\t\treturn false, nil\n\t\t}\n\n\t\tif err := image.AddPerformer(ctx, rw, s, otherID); err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\treturn true, nil\n\t})\n}\n\n// ImageStudios tags the provided image with the first studio whose name matches the image's path.\n//\n// Images will not be tagged if studio is already set.\nfunc ImageStudios(ctx context.Context, s *models.Image, rw ImageFinderUpdater, studioReader models.StudioAutoTagQueryer, cache *match.Cache) error {\n\tif s.StudioID != nil {\n\t\t// don't modify\n\t\treturn nil\n\t}\n\n\tt := getImageFileTagger(s, cache)\n\n\treturn t.tagStudios(ctx, studioReader, func(subjectID, otherID int) (bool, error) {\n\t\treturn addImageStudio(ctx, rw, s, otherID)\n\t})\n}\n\n// ImageTags tags the provided image with tags whose name matches the image's path.\nfunc ImageTags(ctx context.Context, s *models.Image, rw ImageTagUpdater, tagReader models.TagAutoTagQueryer, cache *match.Cache) error {\n\tt := getImageFileTagger(s, cache)\n\n\treturn t.tagTags(ctx, tagReader, func(subjectID, otherID int) (bool, error) {\n\t\tif err := s.LoadTagIDs(ctx, rw); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\texisting := s.TagIDs.List()\n\n\t\tif slices.Contains(existing, otherID) {\n\t\t\treturn false, nil\n\t\t}\n\n\t\tif err := image.AddTag(ctx, rw, s, otherID); err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\treturn true, nil\n\t})\n}\n"
  },
  {
    "path": "internal/autotag/image_test.go",
    "content": "package autotag\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nconst imageExt = \"jpg\"\n\n// returns got == expected\n// ignores expected.UpdatedAt, but ensures that got.UpdatedAt is set and not null\nfunc imagePartialsEqual(got, expected models.ImagePartial) bool {\n\t// updated at should be set and not null\n\tif !got.UpdatedAt.Set || got.UpdatedAt.Null {\n\t\treturn false\n\t}\n\t// else ignore the exact value\n\tgot.UpdatedAt = models.OptionalTime{}\n\n\treturn assert.ObjectsAreEqual(got, expected)\n}\n\nfunc TestImagePerformers(t *testing.T) {\n\tt.Parallel()\n\n\tconst imageID = 1\n\tconst performerName = \"performer name\"\n\tconst performerID = 2\n\tperformer := models.Performer{\n\t\tID:      performerID,\n\t\tName:    performerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\tconst reversedPerformerName = \"name performer\"\n\tconst reversedPerformerID = 3\n\treversedPerformer := models.Performer{\n\t\tID:      reversedPerformerID,\n\t\tName:    reversedPerformerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\ttestTables := generateTestTable(performerName, imageExt)\n\n\tassert := assert.New(t)\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Performer.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Performer.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once()\n\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.ImagePartial) bool {\n\t\t\t\texpected := models.ImagePartial{\n\t\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  []int{performerID},\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\treturn imagePartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Image.On(\"UpdatePartial\", testCtx, imageID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\timage := models.Image{\n\t\t\tID:           imageID,\n\t\t\tPath:         test.Path,\n\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t}\n\t\terr := ImagePerformers(testCtx, &image, db.Image, db.Performer, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n}\n\nfunc TestImageStudios(t *testing.T) {\n\tt.Parallel()\n\n\tconst imageID = 1\n\tconst studioName = \"studio name\"\n\tvar studioID = 2\n\tstudio := models.Studio{\n\t\tID:   studioID,\n\t\tName: studioName,\n\t}\n\n\tconst reversedStudioName = \"name studio\"\n\tconst reversedStudioID = 3\n\treversedStudio := models.Studio{\n\t\tID:   reversedStudioID,\n\t\tName: reversedStudioName,\n\t}\n\n\ttestTables := generateTestTable(studioName, imageExt)\n\n\tassert := assert.New(t)\n\n\tdoTest := func(db *mocks.Database, test pathTestTable) {\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.ImagePartial) bool {\n\t\t\t\texpected := models.ImagePartial{\n\t\t\t\t\tStudioID: models.NewOptionalInt(studioID),\n\t\t\t\t}\n\n\t\t\t\treturn imagePartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Image.On(\"UpdatePartial\", testCtx, imageID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\timage := models.Image{\n\t\t\tID:   imageID,\n\t\t\tPath: test.Path,\n\t\t}\n\t\terr := ImageStudios(testCtx, &image, db.Image, db.Studio, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Studio.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Studio.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, mock.Anything).Return([]string{}, nil).Maybe()\n\n\t\tdoTest(db, test)\n\t}\n\n\t// test against aliases\n\tconst unmatchedName = \"unmatched\"\n\tstudio.Name = unmatchedName\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Studio.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Studio.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, studioID).Return([]string{\n\t\t\tstudioName,\n\t\t}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, reversedStudioID).Return([]string{}, nil).Once()\n\n\t\tdoTest(db, test)\n\t}\n}\n\nfunc TestImageTags(t *testing.T) {\n\tt.Parallel()\n\n\tconst imageID = 1\n\tconst tagName = \"tag name\"\n\tconst tagID = 2\n\ttag := models.Tag{\n\t\tID:   tagID,\n\t\tName: tagName,\n\t}\n\n\tconst reversedTagName = \"name tag\"\n\tconst reversedTagID = 3\n\treversedTag := models.Tag{\n\t\tID:   reversedTagID,\n\t\tName: reversedTagName,\n\t}\n\n\ttestTables := generateTestTable(tagName, imageExt)\n\n\tassert := assert.New(t)\n\n\tdoTest := func(db *mocks.Database, test pathTestTable) {\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.ImagePartial) bool {\n\t\t\t\texpected := models.ImagePartial{\n\t\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  []int{tagID},\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\treturn imagePartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Image.On(\"UpdatePartial\", testCtx, imageID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\timage := models.Image{\n\t\t\tID:     imageID,\n\t\t\tPath:   test.Path,\n\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t}\n\t\terr := ImageTags(testCtx, &image, db.Image, db.Tag, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Tag.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Tag.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, mock.Anything).Return([]string{}, nil).Maybe()\n\n\t\tdoTest(db, test)\n\t}\n\n\t// test against aliases\n\tconst unmatchedName = \"unmatched\"\n\ttag.Name = unmatchedName\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Tag.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Tag.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, tagID).Return([]string{\n\t\t\ttagName,\n\t\t}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, reversedTagID).Return([]string{}, nil).Once()\n\n\t\tdoTest(db, test)\n\t}\n}\n"
  },
  {
    "path": "internal/autotag/integration_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage autotag\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\n\t_ \"github.com/golang-migrate/migrate/v4/database/sqlite3\"\n\t_ \"github.com/golang-migrate/migrate/v4/source/file\"\n\n\t// necessary to register custom migrations\n\t_ \"github.com/stashapp/stash/pkg/sqlite/migrations\"\n)\n\nconst testName = \"Foo's Bar\"\nconst existingStudioName = \"ExistingStudio\"\n\nconst existingStudioSceneName = testName + \".dontChangeStudio.mp4\"\nconst existingStudioImageName = testName + \".dontChangeStudio.png\"\nconst existingStudioGalleryName = testName + \".dontChangeStudio.zip\"\n\nvar existingStudioID int\n\nconst expectedMatchTitle = \"expected match\"\n\nvar db *sqlite.Database\nvar r models.Repository\n\nfunc testTeardown(databaseFile string) {\n\terr := db.Close()\n\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\terr = os.Remove(databaseFile)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n}\n\nfunc runTests(m *testing.M) int {\n\t// create the database file\n\tf, err := os.CreateTemp(\"\", \"*.sqlite\")\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"Could not create temporary file: %s\", err.Error()))\n\t}\n\n\tf.Close()\n\tdatabaseFile := f.Name()\n\tdb = sqlite.NewDatabase()\n\tif err := db.Open(databaseFile); err != nil {\n\t\tpanic(fmt.Sprintf(\"Could not initialize database: %s\", err.Error()))\n\t}\n\n\tr = db.Repository()\n\n\t// defer close and delete the database\n\tdefer testTeardown(databaseFile)\n\n\terr = populateDB()\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"Could not populate database: %s\", err.Error()))\n\t} else {\n\t\t// run the tests\n\t\treturn m.Run()\n\t}\n}\n\nfunc TestMain(m *testing.M) {\n\t// initialise empty config - needed by some db migrations\n\t_ = config.InitializeEmpty()\n\n\tret := runTests(m)\n\tos.Exit(ret)\n}\n\nfunc createPerformer(ctx context.Context, pqb models.PerformerWriter) error {\n\t// create the performer\n\tperformer := models.Performer{\n\t\tName: testName,\n\t}\n\n\terr := pqb.Create(ctx, &models.CreatePerformerInput{Performer: &performer})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc createStudio(ctx context.Context, qb models.StudioWriter, name string) (*models.Studio, error) {\n\t// create the studio\n\tstudio := models.NewCreateStudioInput()\n\tstudio.Name = name\n\n\terr := qb.Create(ctx, &studio)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn studio.Studio, nil\n}\n\nfunc createTag(ctx context.Context, qb models.TagWriter) error {\n\t// create the studio\n\ttag := models.Tag{\n\t\tName: testName,\n\t}\n\n\terr := qb.Create(ctx, &models.CreateTagInput{Tag: &tag})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc createScenes(ctx context.Context, sqb models.SceneReaderWriter, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) error {\n\t// create the scenes\n\tscenePatterns, falseScenePatterns := generateTestPaths(testName, sceneExt)\n\n\tfor _, fn := range scenePatterns {\n\t\tf, err := createSceneFile(ctx, fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = true\n\t\tif err := createScene(ctx, sqb, makeScene(expectedResult), f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, fn := range falseScenePatterns {\n\t\tf, err := createSceneFile(ctx, fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = false\n\t\tif err := createScene(ctx, sqb, makeScene(expectedResult), f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// add organized scenes\n\tfor _, fn := range scenePatterns {\n\t\tf, err := createSceneFile(ctx, \"organized\"+fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = false\n\t\ts := makeScene(expectedResult)\n\t\ts.Organized = true\n\t\tif err := createScene(ctx, sqb, s, f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// create scene with existing studio io\n\tf, err := createSceneFile(ctx, existingStudioSceneName, folderStore, fileCreator)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts := &models.Scene{\n\t\tTitle:    expectedMatchTitle,\n\t\tCode:     existingStudioSceneName,\n\t\tStudioID: &existingStudioID,\n\t}\n\tif err := createScene(ctx, sqb, s, f); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc makeScene(expectedResult bool) *models.Scene {\n\ts := &models.Scene{}\n\n\t// if expectedResult is true then we expect it to match, set the title accordingly\n\tif expectedResult {\n\t\ts.Title = expectedMatchTitle\n\t}\n\n\treturn s\n}\n\nfunc createSceneFile(ctx context.Context, name string, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) (*models.VideoFile, error) {\n\tfolderPath := filepath.Dir(name)\n\tbasename := filepath.Base(name)\n\n\tfolder, err := getOrCreateFolder(ctx, folderStore, folderPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfolderID := folder.ID\n\n\tf := &models.VideoFile{\n\t\tBaseFile: &models.BaseFile{\n\t\t\tBasename:       basename,\n\t\t\tParentFolderID: folderID,\n\t\t},\n\t}\n\n\tif err := fileCreator.Create(ctx, f); err != nil {\n\t\treturn nil, fmt.Errorf(\"creating scene file %q: %w\", name, err)\n\t}\n\n\treturn f, nil\n}\n\nfunc getOrCreateFolder(ctx context.Context, folderStore models.FolderFinderCreator, folderPath string) (*models.Folder, error) {\n\tf, err := folderStore.FindByPath(ctx, folderPath, true)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting folder by path: %w\", err)\n\t}\n\n\tif f != nil {\n\t\treturn f, nil\n\t}\n\n\tvar parentID models.FolderID\n\tdir := filepath.Dir(folderPath)\n\tif dir != \".\" {\n\t\tparent, err := getOrCreateFolder(ctx, folderStore, dir)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tparentID = parent.ID\n\t}\n\n\tf = &models.Folder{\n\t\tPath: folderPath,\n\t}\n\n\tif parentID != 0 {\n\t\tf.ParentFolderID = &parentID\n\t}\n\n\tif err := folderStore.Create(ctx, f); err != nil {\n\t\treturn nil, fmt.Errorf(\"creating folder: %w\", err)\n\t}\n\n\treturn f, nil\n}\n\nfunc createScene(ctx context.Context, sqb models.SceneWriter, s *models.Scene, f *models.VideoFile) error {\n\terr := sqb.Create(ctx, s, []models.FileID{f.ID})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Failed to create scene with path '%s': %s\", f.Path, err.Error())\n\t}\n\n\treturn nil\n}\n\nfunc createImages(ctx context.Context, w models.ImageReaderWriter, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) error {\n\t// create the images\n\timagePatterns, falseImagePatterns := generateTestPaths(testName, imageExt)\n\n\tfor _, fn := range imagePatterns {\n\t\tf, err := createImageFile(ctx, fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = true\n\t\tif err := createImage(ctx, w, makeImage(expectedResult), f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tfor _, fn := range falseImagePatterns {\n\t\tf, err := createImageFile(ctx, fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = false\n\t\tif err := createImage(ctx, w, makeImage(expectedResult), f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// add organized images\n\tfor _, fn := range imagePatterns {\n\t\tf, err := createImageFile(ctx, \"organized\"+fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = false\n\t\ts := makeImage(expectedResult)\n\t\ts.Organized = true\n\t\tif err := createImage(ctx, w, s, f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// create image with existing studio io\n\tf, err := createImageFile(ctx, existingStudioImageName, folderStore, fileCreator)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts := &models.Image{\n\t\tTitle:    existingStudioImageName,\n\t\tStudioID: &existingStudioID,\n\t}\n\tif err := createImage(ctx, w, s, f); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc createImageFile(ctx context.Context, name string, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) (*models.ImageFile, error) {\n\tfolderPath := filepath.Dir(name)\n\tbasename := filepath.Base(name)\n\n\tfolder, err := getOrCreateFolder(ctx, folderStore, folderPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfolderID := folder.ID\n\n\tf := &models.ImageFile{\n\t\tBaseFile: &models.BaseFile{\n\t\t\tBasename:       basename,\n\t\t\tParentFolderID: folderID,\n\t\t},\n\t}\n\n\tif err := fileCreator.Create(ctx, f); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn f, nil\n}\n\nfunc makeImage(expectedResult bool) *models.Image {\n\to := &models.Image{}\n\n\t// if expectedResult is true then we expect it to match, set the title accordingly\n\tif expectedResult {\n\t\to.Title = expectedMatchTitle\n\t}\n\n\treturn o\n}\n\nfunc createImage(ctx context.Context, w models.ImageWriter, o *models.Image, f *models.ImageFile) error {\n\terr := w.Create(ctx, &models.CreateImageInput{\n\t\tImage:   o,\n\t\tFileIDs: []models.FileID{f.ID},\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Failed to create image with path '%s': %s\", f.Path, err.Error())\n\t}\n\n\treturn nil\n}\n\nfunc createGalleries(ctx context.Context, w models.GalleryReaderWriter, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) error {\n\t// create the galleries\n\tgalleryPatterns, falseGalleryPatterns := generateTestPaths(testName, galleryExt)\n\n\tfor _, fn := range galleryPatterns {\n\t\tf, err := createGalleryFile(ctx, fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = true\n\t\tif err := createGallery(ctx, w, makeGallery(expectedResult), f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tfor _, fn := range falseGalleryPatterns {\n\t\tf, err := createGalleryFile(ctx, fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = false\n\t\tif err := createGallery(ctx, w, makeGallery(expectedResult), f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// add organized galleries\n\tfor _, fn := range galleryPatterns {\n\t\tf, err := createGalleryFile(ctx, \"organized\"+fn, folderStore, fileCreator)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconst expectedResult = false\n\t\ts := makeGallery(expectedResult)\n\t\ts.Organized = true\n\t\tif err := createGallery(ctx, w, s, f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// create gallery with existing studio io\n\tf, err := createGalleryFile(ctx, existingStudioGalleryName, folderStore, fileCreator)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts := &models.Gallery{\n\t\tTitle:    existingStudioGalleryName,\n\t\tStudioID: &existingStudioID,\n\t}\n\tif err := createGallery(ctx, w, s, f); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc createGalleryFile(ctx context.Context, name string, folderStore models.FolderFinderCreator, fileCreator models.FileCreator) (*models.BaseFile, error) {\n\tfolderPath := filepath.Dir(name)\n\tbasename := filepath.Base(name)\n\n\tfolder, err := getOrCreateFolder(ctx, folderStore, folderPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfolderID := folder.ID\n\n\tf := &models.BaseFile{\n\t\tBasename:       basename,\n\t\tParentFolderID: folderID,\n\t}\n\n\tif err := fileCreator.Create(ctx, f); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn f, nil\n}\n\nfunc makeGallery(expectedResult bool) *models.Gallery {\n\to := &models.Gallery{}\n\n\t// if expectedResult is true then we expect it to match, set the title accordingly\n\tif expectedResult {\n\t\to.Title = expectedMatchTitle\n\t}\n\n\treturn o\n}\n\nfunc createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error {\n\terr := w.Create(ctx, &models.CreateGalleryInput{\n\t\tGallery: o,\n\t\tFileIDs: []models.FileID{f.ID},\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Failed to create gallery with path '%s': %s\", f.Path, err.Error())\n\t}\n\n\treturn nil\n}\n\nfunc withTxn(f func(ctx context.Context) error) error {\n\treturn txn.WithTxn(testCtx, db, f)\n}\n\nfunc withDB(f func(ctx context.Context) error) error {\n\treturn txn.WithDatabase(testCtx, db, f)\n}\n\nfunc populateDB() error {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\terr := createPerformer(ctx, r.Performer)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = createStudio(ctx, r.Studio, testName)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// create existing studio\n\t\texistingStudio, err := createStudio(ctx, r.Studio, existingStudioName)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\texistingStudioID = existingStudio.ID\n\n\t\terr = createTag(ctx, r.Tag)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\terr = createScenes(ctx, r.Scene, r.Folder, r.File)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\terr = createImages(ctx, r.Image, r.Folder, r.File)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\terr = createGalleries(ctx, r.Gallery, r.Folder, r.File)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc TestParsePerformerScenes(t *testing.T) {\n\tvar performers []*models.Performer\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\tperformers, err = r.Performer.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting performer: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, p := range performers {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\tif err := p.LoadAliases(ctx, r.Performer); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn tagger.PerformerScenes(ctx, p, nil, r.Scene)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that scenes were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\tpqb := r.Performer\n\n\t\tscenes, err := r.Scene.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\tfor _, scene := range scenes {\n\t\t\tperformers, err := pqb.FindBySceneID(ctx, scene.ID)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Error getting scene performers: %s\", err.Error())\n\t\t\t}\n\n\t\t\t// title is only set on scenes where we expect performer to be set\n\t\t\tif scene.Title == expectedMatchTitle && len(performers) == 0 {\n\t\t\t\tt.Errorf(\"Did not set performer '%s' for path '%s'\", testName, scene.Path)\n\t\t\t} else if scene.Title != expectedMatchTitle && len(performers) > 0 {\n\t\t\t\tt.Errorf(\"Incorrectly set performer '%s' for path '%s'\", testName, scene.Path)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestParseStudioScenes(t *testing.T) {\n\tvar studios []*models.Studio\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\tstudios, err = r.Studio.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting studio: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, s := range studios {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\taliases, err := r.Studio.GetAliases(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn tagger.StudioScenes(ctx, s, nil, aliases, r.Scene)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that scenes were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\tscenes, err := r.Scene.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\tfor _, scene := range scenes {\n\t\t\t// check for existing studio id scene first\n\t\t\tif scene.Code == existingStudioSceneName {\n\t\t\t\tif scene.StudioID == nil || *scene.StudioID != existingStudioID {\n\t\t\t\t\tt.Error(\"Incorrectly overwrote studio ID for scene with existing studio ID\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// title is only set on scenes where we expect studio to be set\n\t\t\t\tif scene.Title == expectedMatchTitle {\n\t\t\t\t\tif scene.StudioID == nil {\n\t\t\t\t\t\tt.Errorf(\"Did not set studio '%s' for path '%s'\", testName, scene.Path)\n\t\t\t\t\t} else if scene.StudioID != nil && *scene.StudioID != studios[1].ID {\n\t\t\t\t\t\tt.Errorf(\"Incorrect studio id %d set for path '%s'\", scene.StudioID, scene.Path)\n\t\t\t\t\t}\n\n\t\t\t\t} else if scene.Title != expectedMatchTitle && scene.StudioID != nil && *scene.StudioID == studios[1].ID {\n\t\t\t\t\tt.Errorf(\"Incorrectly set studio '%s' for path '%s'\", testName, scene.Path)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestParseTagScenes(t *testing.T) {\n\tvar tags []*models.Tag\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\ttags, err = r.Tag.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting performer: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, s := range tags {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\taliases, err := r.Tag.GetAliases(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn tagger.TagScenes(ctx, s, nil, aliases, r.Scene)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that scenes were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\tscenes, err := r.Scene.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\ttqb := r.Tag\n\n\t\tfor _, scene := range scenes {\n\t\t\ttags, err := tqb.FindBySceneID(ctx, scene.ID)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Error getting scene tags: %s\", err.Error())\n\t\t\t}\n\n\t\t\t// title is only set on scenes where we expect tag to be set\n\t\t\tif scene.Title == expectedMatchTitle && len(tags) == 0 {\n\t\t\t\tt.Errorf(\"Did not set tag '%s' for path '%s'\", testName, scene.Path)\n\t\t\t} else if (scene.Title != expectedMatchTitle) && len(tags) > 0 {\n\t\t\t\tt.Errorf(\"Incorrectly set tag '%s' for path '%s'\", testName, scene.Path)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestParsePerformerImages(t *testing.T) {\n\tvar performers []*models.Performer\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\tperformers, err = r.Performer.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting performer: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, p := range performers {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\tif err := p.LoadAliases(ctx, r.Performer); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn tagger.PerformerImages(ctx, p, nil, r.Image)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that images were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\tpqb := r.Performer\n\n\t\timages, err := r.Image.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\tfor _, image := range images {\n\t\t\tperformers, err := pqb.FindByImageID(ctx, image.ID)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Error getting image performers: %s\", err.Error())\n\t\t\t}\n\n\t\t\t// title is only set on images where we expect performer to be set\n\t\t\texpectedMatch := image.Title == expectedMatchTitle || image.Title == existingStudioImageName\n\t\t\tif expectedMatch && len(performers) == 0 {\n\t\t\t\tt.Errorf(\"Did not set performer '%s' for path '%s'\", testName, image.Path)\n\t\t\t} else if !expectedMatch && len(performers) > 0 {\n\t\t\t\tt.Errorf(\"Incorrectly set performer '%s' for path '%s'\", testName, image.Path)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestParseStudioImages(t *testing.T) {\n\tvar studios []*models.Studio\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\tstudios, err = r.Studio.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting studio: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, s := range studios {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\taliases, err := r.Studio.GetAliases(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn tagger.StudioImages(ctx, s, nil, aliases, r.Image)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that images were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\timages, err := r.Image.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\tfor _, image := range images {\n\t\t\t// check for existing studio id image first\n\t\t\tif image.Title == existingStudioImageName {\n\t\t\t\tif *image.StudioID != existingStudioID {\n\t\t\t\t\tt.Error(\"Incorrectly overwrote studio ID for image with existing studio ID\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// title is only set on images where we expect studio to be set\n\t\t\t\tif image.Title == expectedMatchTitle {\n\t\t\t\t\tif image.StudioID == nil {\n\t\t\t\t\t\tt.Errorf(\"Did not set studio '%s' for path '%s'\", testName, image.Path)\n\t\t\t\t\t} else if *image.StudioID != studios[1].ID {\n\t\t\t\t\t\tt.Errorf(\"Incorrect studio id %d set for path '%s'\", *image.StudioID, image.Path)\n\t\t\t\t\t}\n\n\t\t\t\t} else if image.Title != expectedMatchTitle && image.StudioID != nil && *image.StudioID == studios[1].ID {\n\t\t\t\t\tt.Errorf(\"Incorrectly set studio '%s' for path '%s'\", testName, image.Path)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestParseTagImages(t *testing.T) {\n\tvar tags []*models.Tag\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\ttags, err = r.Tag.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting performer: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, s := range tags {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\taliases, err := r.Tag.GetAliases(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn tagger.TagImages(ctx, s, nil, aliases, r.Image)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that images were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\timages, err := r.Image.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\ttqb := r.Tag\n\n\t\tfor _, image := range images {\n\t\t\ttags, err := tqb.FindByImageID(ctx, image.ID)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Error getting image tags: %s\", err.Error())\n\t\t\t}\n\n\t\t\t// title is only set on images where we expect performer to be set\n\t\t\texpectedMatch := image.Title == expectedMatchTitle || image.Title == existingStudioImageName\n\t\t\tif expectedMatch && len(tags) == 0 {\n\t\t\t\tt.Errorf(\"Did not set tag '%s' for path '%s'\", testName, image.Path)\n\t\t\t} else if !expectedMatch && len(tags) > 0 {\n\t\t\t\tt.Errorf(\"Incorrectly set tag '%s' for path '%s'\", testName, image.Path)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestParsePerformerGalleries(t *testing.T) {\n\tvar performers []*models.Performer\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\tperformers, err = r.Performer.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting performer: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, p := range performers {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\tif err := p.LoadAliases(ctx, r.Performer); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn tagger.PerformerGalleries(ctx, p, nil, r.Gallery)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that galleries were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\tpqb := r.Performer\n\n\t\tgalleries, err := r.Gallery.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\tfor _, gallery := range galleries {\n\t\t\tperformers, err := pqb.FindByGalleryID(ctx, gallery.ID)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Error getting gallery performers: %s\", err.Error())\n\t\t\t}\n\n\t\t\t// title is only set on galleries where we expect performer to be set\n\t\t\texpectedMatch := gallery.Title == expectedMatchTitle || gallery.Title == existingStudioGalleryName\n\t\t\tif expectedMatch && len(performers) == 0 {\n\t\t\t\tt.Errorf(\"Did not set performer '%s' for path '%s'\", testName, gallery.Path)\n\t\t\t} else if !expectedMatch && len(performers) > 0 {\n\t\t\t\tt.Errorf(\"Incorrectly set performer '%s' for path '%s'\", testName, gallery.Path)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestParseStudioGalleries(t *testing.T) {\n\tvar studios []*models.Studio\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\tstudios, err = r.Studio.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting studio: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, s := range studios {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\taliases, err := r.Studio.GetAliases(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn tagger.StudioGalleries(ctx, s, nil, aliases, r.Gallery)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that galleries were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\tgalleries, err := r.Gallery.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\tfor _, gallery := range galleries {\n\t\t\t// check for existing studio id gallery first\n\t\t\tif gallery.Title == existingStudioGalleryName {\n\t\t\t\tif *gallery.StudioID != existingStudioID {\n\t\t\t\t\tt.Error(\"Incorrectly overwrote studio ID for gallery with existing studio ID\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// title is only set on galleries where we expect studio to be set\n\t\t\t\tif gallery.Title == expectedMatchTitle {\n\t\t\t\t\tif gallery.StudioID == nil {\n\t\t\t\t\t\tt.Errorf(\"Did not set studio '%s' for path '%s'\", testName, gallery.Path)\n\t\t\t\t\t} else if *gallery.StudioID != studios[1].ID {\n\t\t\t\t\t\tt.Errorf(\"Incorrect studio id %d set for path '%s'\", *gallery.StudioID, gallery.Path)\n\t\t\t\t\t}\n\n\t\t\t\t} else if gallery.Title != expectedMatchTitle && (gallery.StudioID != nil && *gallery.StudioID == studios[1].ID) {\n\t\t\t\t\tt.Errorf(\"Incorrectly set studio '%s' for path '%s'\", testName, gallery.Path)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestParseTagGalleries(t *testing.T) {\n\tvar tags []*models.Tag\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tvar err error\n\t\ttags, err = r.Tag.All(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\tt.Errorf(\"Error getting performer: %s\", err)\n\t\treturn\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\tfor _, s := range tags {\n\t\tif err := withDB(func(ctx context.Context) error {\n\t\t\taliases, err := r.Tag.GetAliases(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn tagger.TagGalleries(ctx, s, nil, aliases, r.Gallery)\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"Error auto-tagging performers: %s\", err)\n\t\t}\n\t}\n\n\t// verify that galleries were tagged correctly\n\twithTxn(func(ctx context.Context) error {\n\t\tgalleries, err := r.Gallery.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Error(err.Error())\n\t\t}\n\n\t\ttqb := r.Tag\n\n\t\tfor _, gallery := range galleries {\n\t\t\ttags, err := tqb.FindByGalleryID(ctx, gallery.ID)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Error getting gallery tags: %s\", err.Error())\n\t\t\t}\n\n\t\t\t// title is only set on galleries where we expect performer to be set\n\t\t\texpectedMatch := gallery.Title == expectedMatchTitle || gallery.Title == existingStudioGalleryName\n\t\t\tif expectedMatch && len(tags) == 0 {\n\t\t\t\tt.Errorf(\"Did not set tag '%s' for path '%s'\", testName, gallery.Path)\n\t\t\t} else if !expectedMatch && len(tags) > 0 {\n\t\t\t\tt.Errorf(\"Incorrectly set tag '%s' for path '%s'\", testName, gallery.Path)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "internal/autotag/performer.go",
    "content": "package autotag\n\nimport (\n\t\"context\"\n\t\"slices\"\n\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype SceneQueryPerformerUpdater interface {\n\tmodels.SceneQueryer\n\tmodels.PerformerIDLoader\n\tmodels.SceneUpdater\n}\n\ntype ImageQueryPerformerUpdater interface {\n\tmodels.ImageQueryer\n\tmodels.PerformerIDLoader\n\tmodels.ImageUpdater\n}\n\ntype GalleryQueryPerformerUpdater interface {\n\tmodels.GalleryQueryer\n\tmodels.PerformerIDLoader\n\tmodels.GalleryUpdater\n}\n\nfunc getPerformerTaggers(p *models.Performer, cache *match.Cache) []tagger {\n\tret := []tagger{{\n\t\tID:    p.ID,\n\t\tType:  \"performer\",\n\t\tName:  p.Name,\n\t\tcache: cache,\n\t}}\n\n\t// TODO - disabled until we can have finer control over alias matching\n\t// for _, a := range p.Aliases.List() {\n\t// \tret = append(ret, tagger{\n\t// \t\tID:    p.ID,\n\t// \t\tType:  \"performer\",\n\t// \t\tName:  a,\n\t// \t\tcache: cache,\n\t// \t})\n\t// }\n\n\treturn ret\n}\n\n// PerformerScenes searches for scenes whose path matches the provided performer name and tags the scene with the performer.\n// Performer aliases must be loaded.\nfunc (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer, paths []string, rw SceneQueryPerformerUpdater) error {\n\tt := getPerformerTaggers(p, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) {\n\t\t\tif err := o.LoadPerformerIDs(ctx, rw); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\texisting := o.PerformerIDs.List()\n\n\t\t\tif slices.Contains(existing, p.ID) {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\treturn scene.AddPerformer(ctx, rw, o, p.ID)\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer.\nfunc (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer, paths []string, rw ImageQueryPerformerUpdater) error {\n\tt := getPerformerTaggers(p, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) {\n\t\t\tif err := o.LoadPerformerIDs(ctx, rw); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\texisting := o.PerformerIDs.List()\n\n\t\t\tif slices.Contains(existing, p.ID) {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\treturn image.AddPerformer(ctx, rw, o, p.ID)\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// PerformerGalleries searches for galleries whose path matches the provided performer name and tags the gallery with the performer.\nfunc (tagger *Tagger) PerformerGalleries(ctx context.Context, p *models.Performer, paths []string, rw GalleryQueryPerformerUpdater) error {\n\tt := getPerformerTaggers(p, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) {\n\t\t\tif err := o.LoadPerformerIDs(ctx, rw); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\texisting := o.PerformerIDs.List()\n\n\t\t\tif slices.Contains(existing, p.ID) {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\treturn gallery.AddPerformer(ctx, rw, o, p.ID)\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/autotag/performer_test.go",
    "content": "package autotag\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc TestPerformerScenes(t *testing.T) {\n\tt.Parallel()\n\n\ttype test struct {\n\t\tperformerName string\n\t\texpectedRegex string\n\t}\n\n\tperformerNames := []test{\n\t\t{\n\t\t\t\"performer name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])performer[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t\t{\n\t\t\t\"performer + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])performer[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t}\n\n\t// trailing backslash tests only work where filepath separator is not backslash\n\tif filepath.Separator != '\\\\' {\n\t\tperformerNames = append(performerNames, test{\n\t\t\t`performer + name\\`,\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])performer[.\\-_ ]*\\+[.\\-_ ]*name\\\\(?:$|_|[^\\p{L}\\d])`,\n\t\t})\n\t}\n\n\tfor _, p := range performerNames {\n\t\ttestPerformerScenes(t, p.performerName, p.expectedRegex)\n\t}\n}\n\nfunc testPerformerScenes(t *testing.T, performerName, expectedRegex string) {\n\tdb := mocks.NewDatabase()\n\n\tconst performerID = 2\n\n\tvar scenes []*models.Scene\n\tmatchingPaths, falsePaths := generateTestPaths(performerName, \"mp4\")\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\tscenes = append(scenes, &models.Scene{\n\t\t\tID:           i + 1,\n\t\t\tPath:         p,\n\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t})\n\t}\n\n\tperformer := models.Performer{\n\t\tID:      performerID,\n\t\tName:    performerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedSceneFilter := &models.SceneFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\tdb.Scene.On(\"Query\", mock.Anything, scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)).\n\t\tReturn(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once()\n\n\tfor i := range matchingPaths {\n\t\tsceneID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.ScenePartial) bool {\n\t\t\texpected := models.ScenePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\treturn scenePartialsEqual(got, expected)\n\t\t})\n\t\tdb.Scene.On(\"UpdatePartial\", mock.Anything, sceneID, matchPartial).Return(nil, nil).Once()\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.PerformerScenes(testCtx, &performer, nil, db.Scene)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n\nfunc TestPerformerImages(t *testing.T) {\n\tt.Parallel()\n\n\ttype test struct {\n\t\tperformerName string\n\t\texpectedRegex string\n\t}\n\n\tperformerNames := []test{\n\t\t{\n\t\t\t\"performer name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])performer[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t\t{\n\t\t\t\"performer + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])performer[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t}\n\n\tfor _, p := range performerNames {\n\t\ttestPerformerImages(t, p.performerName, p.expectedRegex)\n\t}\n}\n\nfunc testPerformerImages(t *testing.T, performerName, expectedRegex string) {\n\tdb := mocks.NewDatabase()\n\n\tconst performerID = 2\n\n\tvar images []*models.Image\n\tmatchingPaths, falsePaths := generateTestPaths(performerName, imageExt)\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\timages = append(images, &models.Image{\n\t\t\tID:           i + 1,\n\t\t\tPath:         p,\n\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t})\n\t}\n\n\tperformer := models.Performer{\n\t\tID:      performerID,\n\t\tName:    performerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedImageFilter := &models.ImageFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\tdb.Image.On(\"Query\", mock.Anything, image.QueryOptions(expectedImageFilter, expectedFindFilter, false)).\n\t\tReturn(mocks.ImageQueryResult(images, len(images)), nil).Once()\n\n\tfor i := range matchingPaths {\n\t\timageID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.ImagePartial) bool {\n\t\t\texpected := models.ImagePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\treturn imagePartialsEqual(got, expected)\n\t\t})\n\t\tdb.Image.On(\"UpdatePartial\", mock.Anything, imageID, matchPartial).Return(nil, nil).Once()\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.PerformerImages(testCtx, &performer, nil, db.Image)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n\nfunc TestPerformerGalleries(t *testing.T) {\n\tt.Parallel()\n\n\ttype test struct {\n\t\tperformerName string\n\t\texpectedRegex string\n\t}\n\n\tperformerNames := []test{\n\t\t{\n\t\t\t\"performer name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])performer[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t\t{\n\t\t\t\"performer + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])performer[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t}\n\n\tfor _, p := range performerNames {\n\t\ttestPerformerGalleries(t, p.performerName, p.expectedRegex)\n\t}\n}\n\nfunc testPerformerGalleries(t *testing.T, performerName, expectedRegex string) {\n\tdb := mocks.NewDatabase()\n\n\tconst performerID = 2\n\n\tvar galleries []*models.Gallery\n\tmatchingPaths, falsePaths := generateTestPaths(performerName, galleryExt)\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\tv := p\n\t\tgalleries = append(galleries, &models.Gallery{\n\t\t\tID:           i + 1,\n\t\t\tPath:         v,\n\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t})\n\t}\n\n\tperformer := models.Performer{\n\t\tID:      performerID,\n\t\tName:    performerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedGalleryFilter := &models.GalleryFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\tdb.Gallery.On(\"Query\", mock.Anything, expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once()\n\n\tfor i := range matchingPaths {\n\t\tgalleryID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool {\n\t\t\texpected := models.GalleryPartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\treturn galleryPartialsEqual(got, expected)\n\t\t})\n\t\tdb.Gallery.On(\"UpdatePartial\", mock.Anything, galleryID, matchPartial).Return(nil, nil).Once()\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.PerformerGalleries(testCtx, &performer, nil, db.Gallery)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "internal/autotag/scene.go",
    "content": "package autotag\n\nimport (\n\t\"context\"\n\t\"slices\"\n\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\ntype SceneFinderUpdater interface {\n\tmodels.SceneQueryer\n\tmodels.SceneUpdater\n}\n\ntype ScenePerformerUpdater interface {\n\tmodels.PerformerIDLoader\n\tmodels.SceneUpdater\n}\n\ntype SceneTagUpdater interface {\n\tmodels.TagIDLoader\n\tmodels.SceneUpdater\n}\n\nfunc getSceneFileTagger(s *models.Scene, cache *match.Cache) tagger {\n\treturn tagger{\n\t\tID:    s.ID,\n\t\tType:  \"scene\",\n\t\tName:  s.DisplayName(),\n\t\tPath:  s.Path,\n\t\tcache: cache,\n\t}\n}\n\n// ScenePerformers tags the provided scene with performers whose name matches the scene's path.\nfunc ScenePerformers(ctx context.Context, s *models.Scene, rw ScenePerformerUpdater, performerReader models.PerformerAutoTagQueryer, cache *match.Cache) error {\n\tt := getSceneFileTagger(s, cache)\n\n\treturn t.tagPerformers(ctx, performerReader, func(subjectID, otherID int) (bool, error) {\n\t\tif err := s.LoadPerformerIDs(ctx, rw); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\texisting := s.PerformerIDs.List()\n\n\t\tif slices.Contains(existing, otherID) {\n\t\t\treturn false, nil\n\t\t}\n\n\t\tif err := scene.AddPerformer(ctx, rw, s, otherID); err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\treturn true, nil\n\t})\n}\n\n// SceneStudios tags the provided scene with the first studio whose name matches the scene's path.\n//\n// Scenes will not be tagged if studio is already set.\nfunc SceneStudios(ctx context.Context, s *models.Scene, rw SceneFinderUpdater, studioReader models.StudioAutoTagQueryer, cache *match.Cache) error {\n\tif s.StudioID != nil {\n\t\t// don't modify\n\t\treturn nil\n\t}\n\n\tt := getSceneFileTagger(s, cache)\n\n\treturn t.tagStudios(ctx, studioReader, func(subjectID, otherID int) (bool, error) {\n\t\treturn addSceneStudio(ctx, rw, s, otherID)\n\t})\n}\n\n// SceneTags tags the provided scene with tags whose name matches the scene's path.\nfunc SceneTags(ctx context.Context, s *models.Scene, rw SceneTagUpdater, tagReader models.TagAutoTagQueryer, cache *match.Cache) error {\n\tt := getSceneFileTagger(s, cache)\n\n\treturn t.tagTags(ctx, tagReader, func(subjectID, otherID int) (bool, error) {\n\t\tif err := s.LoadTagIDs(ctx, rw); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\texisting := s.TagIDs.List()\n\n\t\tif slices.Contains(existing, otherID) {\n\t\t\treturn false, nil\n\t\t}\n\n\t\tif err := scene.AddTag(ctx, rw, s, otherID); err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\treturn true, nil\n\t})\n}\n"
  },
  {
    "path": "internal/autotag/scene_test.go",
    "content": "package autotag\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nconst sceneExt = \"mp4\"\n\nvar testSeparators = []string{\n\t\".\",\n\t\"-\",\n\t\"_\",\n\t\" \",\n}\n\nvar testEndSeparators = []string{\n\t\"{\",\n\t\"}\",\n\t\"(\",\n\t\")\",\n\t\",\",\n}\n\n// asserts that got == expected\n// ignores expected.UpdatedAt, but ensures that got.UpdatedAt is set and not null\nfunc scenePartialsEqual(got, expected models.ScenePartial) bool {\n\t// updated at should be set and not null\n\tif !got.UpdatedAt.Set || got.UpdatedAt.Null {\n\t\treturn false\n\t}\n\t// else ignore the exact value\n\tgot.UpdatedAt = models.OptionalTime{}\n\n\treturn assert.ObjectsAreEqual(got, expected)\n}\n\nfunc generateNamePatterns(name, separator, ext string) []string {\n\tvar ret []string\n\tret = append(ret, fmt.Sprintf(\"%s%saaa.%s\", name, separator, ext))\n\tret = append(ret, fmt.Sprintf(\"aaa%s%s.%s\", separator, name, ext))\n\tret = append(ret, fmt.Sprintf(\"aaa%s%s%sbbb.%s\", separator, name, separator, ext))\n\tret = append(ret, filepath.Join(\"dir\", fmt.Sprintf(\"%s%saaa.%s\", name, separator, ext)))\n\tret = append(ret, filepath.Join(fmt.Sprintf(\"dir%sdir\", separator), fmt.Sprintf(\"%s%saaa.%s\", name, separator, ext)))\n\tret = append(ret, filepath.Join(fmt.Sprintf(\"%s%saaa\", name, separator), \"dir\", fmt.Sprintf(\"bbb.%s\", ext)))\n\tret = append(ret, filepath.Join(\"dir\", fmt.Sprintf(\"%s%s\", name, separator), fmt.Sprintf(\"aaa.%s\", ext)))\n\n\treturn ret\n}\n\nfunc generateSplitNamePatterns(name, separator, ext string) []string {\n\tvar ret []string\n\tsplitted := strings.Split(name, \" \")\n\t// only do this for names that are split into two\n\tif len(splitted) == 2 {\n\t\tret = append(ret, fmt.Sprintf(\"%s%s%s.%s\", splitted[0], separator, splitted[1], ext))\n\t}\n\n\treturn ret\n}\n\nfunc generateFalseNamePatterns(name string, separator, ext string) []string {\n\tsplitted := strings.Split(name, \" \")\n\n\tvar ret []string\n\t// only do this for names that are split into two\n\tif len(splitted) == 2 {\n\t\tret = append(ret, fmt.Sprintf(\"%s%saaa%s%s.%s\", splitted[0], separator, separator, splitted[1], ext))\n\t}\n\n\treturn ret\n}\n\nfunc generateTestPaths(testName, ext string) (scenePatterns []string, falseScenePatterns []string) {\n\tseparators := testSeparators\n\tseparators = append(separators, testEndSeparators...)\n\n\tfor _, separator := range separators {\n\t\tscenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...)\n\t\tscenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...)\n\t\tscenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, \" \", \"\"), separator, ext)...)\n\t\tfalseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...)\n\t}\n\n\t// add test cases for intra-name separators\n\tfor _, separator := range testSeparators {\n\t\tif separator != \" \" {\n\t\t\tscenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, \" \", separator), separator, ext)...)\n\t\t}\n\t}\n\n\t// add basic false scenarios\n\tfalseScenePatterns = append(falseScenePatterns, fmt.Sprintf(\"aaa%s.%s\", testName, ext))\n\tfalseScenePatterns = append(falseScenePatterns, fmt.Sprintf(\"%saaa.%s\", testName, ext))\n\n\t// add path separator false scenarios\n\tfalseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, string(filepath.Separator), ext)...)\n\n\t// split patterns only valid for ._- and whitespace\n\tfor _, separator := range testSeparators {\n\t\tscenePatterns = append(scenePatterns, generateSplitNamePatterns(testName, separator, ext)...)\n\t}\n\n\t// false patterns for other separators\n\tfor _, separator := range testEndSeparators {\n\t\tfalseScenePatterns = append(falseScenePatterns, generateSplitNamePatterns(testName, separator, ext)...)\n\t}\n\n\treturn\n}\n\ntype pathTestTable struct {\n\tPath    string\n\tMatches bool\n}\n\nfunc generateTestTable(testName, ext string) []pathTestTable {\n\tvar ret []pathTestTable\n\n\tvar scenePatterns []string\n\tvar falseScenePatterns []string\n\n\tseparators := testSeparators\n\tseparators = append(separators, testEndSeparators...)\n\n\tfor _, separator := range separators {\n\t\tscenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...)\n\t\tscenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...)\n\t\tfalseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...)\n\t}\n\n\tfor _, p := range scenePatterns {\n\t\tt := pathTestTable{\n\t\t\tPath:    p,\n\t\t\tMatches: true,\n\t\t}\n\n\t\tret = append(ret, t)\n\t}\n\n\tfor _, p := range falseScenePatterns {\n\t\tt := pathTestTable{\n\t\t\tPath:    p,\n\t\t\tMatches: false,\n\t\t}\n\n\t\tret = append(ret, t)\n\t}\n\n\treturn ret\n}\n\nfunc TestScenePerformers(t *testing.T) {\n\tt.Parallel()\n\n\tconst sceneID = 1\n\tconst performerName = \"performer name\"\n\tconst performerID = 2\n\tperformer := models.Performer{\n\t\tID:      performerID,\n\t\tName:    performerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\tconst reversedPerformerName = \"name performer\"\n\tconst reversedPerformerID = 3\n\treversedPerformer := models.Performer{\n\t\tID:      reversedPerformerID,\n\t\tName:    reversedPerformerName,\n\t\tAliases: models.NewRelatedStrings([]string{}),\n\t}\n\n\ttestTables := generateTestTable(performerName, sceneExt)\n\n\tassert := assert.New(t)\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Performer.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Performer.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once()\n\n\t\tscene := models.Scene{\n\t\t\tID:           sceneID,\n\t\t\tPath:         test.Path,\n\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t}\n\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.ScenePartial) bool {\n\t\t\t\texpected := models.ScenePartial{\n\t\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  []int{performerID},\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\treturn scenePartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Scene.On(\"UpdatePartial\", testCtx, sceneID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\terr := ScenePerformers(testCtx, &scene, db.Scene, db.Performer, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n}\n\nfunc TestSceneStudios(t *testing.T) {\n\tt.Parallel()\n\n\tvar (\n\t\tsceneID    = 1\n\t\tstudioName = \"studio name\"\n\t\tstudioID   = 2\n\t)\n\tstudio := models.Studio{\n\t\tID:   studioID,\n\t\tName: studioName,\n\t}\n\n\tconst reversedStudioName = \"name studio\"\n\tconst reversedStudioID = 3\n\treversedStudio := models.Studio{\n\t\tID:   reversedStudioID,\n\t\tName: reversedStudioName,\n\t}\n\n\ttestTables := generateTestTable(studioName, sceneExt)\n\n\tassert := assert.New(t)\n\n\tdoTest := func(db *mocks.Database, test pathTestTable) {\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.ScenePartial) bool {\n\t\t\t\texpected := models.ScenePartial{\n\t\t\t\t\tStudioID: models.NewOptionalInt(studioID),\n\t\t\t\t}\n\n\t\t\t\treturn scenePartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Scene.On(\"UpdatePartial\", testCtx, sceneID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\tscene := models.Scene{\n\t\t\tID:   sceneID,\n\t\t\tPath: test.Path,\n\t\t}\n\t\terr := SceneStudios(testCtx, &scene, db.Scene, db.Studio, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Studio.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Studio.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, mock.Anything).Return([]string{}, nil).Maybe()\n\n\t\tdoTest(db, test)\n\t}\n\n\tconst unmatchedName = \"unmatched\"\n\tstudio.Name = unmatchedName\n\n\t// test against aliases\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Studio.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Studio.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, studioID).Return([]string{\n\t\t\tstudioName,\n\t\t}, nil).Once()\n\t\tdb.Studio.On(\"GetAliases\", testCtx, reversedStudioID).Return([]string{}, nil).Once()\n\n\t\tdoTest(db, test)\n\t}\n}\n\nfunc TestSceneTags(t *testing.T) {\n\tt.Parallel()\n\n\tconst sceneID = 1\n\tconst tagName = \"tag name\"\n\tconst tagID = 2\n\ttag := models.Tag{\n\t\tID:   tagID,\n\t\tName: tagName,\n\t}\n\n\tconst reversedTagName = \"name tag\"\n\tconst reversedTagID = 3\n\treversedTag := models.Tag{\n\t\tID:   reversedTagID,\n\t\tName: reversedTagName,\n\t}\n\n\ttestTables := generateTestTable(tagName, sceneExt)\n\n\tassert := assert.New(t)\n\n\tdoTest := func(db *mocks.Database, test pathTestTable) {\n\t\tif test.Matches {\n\t\t\tmatchPartial := mock.MatchedBy(func(got models.ScenePartial) bool {\n\t\t\t\texpected := models.ScenePartial{\n\t\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  []int{tagID},\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\treturn scenePartialsEqual(got, expected)\n\t\t\t})\n\t\t\tdb.Scene.On(\"UpdatePartial\", testCtx, sceneID, matchPartial).Return(nil, nil).Once()\n\t\t}\n\n\t\tscene := models.Scene{\n\t\t\tID:     sceneID,\n\t\t\tPath:   test.Path,\n\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t}\n\t\terr := SceneTags(testCtx, &scene, db.Scene, db.Tag, nil)\n\n\t\tassert.Nil(err)\n\t\tdb.AssertExpectations(t)\n\t}\n\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Tag.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Tag.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, mock.Anything).Return([]string{}, nil).Maybe()\n\n\t\tdoTest(db, test)\n\t}\n\n\tconst unmatchedName = \"unmatched\"\n\ttag.Name = unmatchedName\n\n\t// test against aliases\n\tfor _, test := range testTables {\n\t\tdb := mocks.NewDatabase()\n\n\t\tdb.Tag.On(\"Query\", testCtx, mock.Anything, mock.Anything).Return(nil, 0, nil)\n\t\tdb.Tag.On(\"QueryForAutoTag\", testCtx, mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, tagID).Return([]string{\n\t\t\ttagName,\n\t\t}, nil).Once()\n\t\tdb.Tag.On(\"GetAliases\", testCtx, reversedTagID).Return([]string{}, nil).Once()\n\n\t\tdoTest(db, test)\n\t}\n}\n"
  },
  {
    "path": "internal/autotag/studio.go",
    "content": "package autotag\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\n// the following functions aren't used in Tagger because they assume\n// use within a transaction\n\nfunc addSceneStudio(ctx context.Context, sceneWriter models.SceneUpdater, o *models.Scene, studioID int) (bool, error) {\n\t// don't set if already set\n\tif o.StudioID != nil {\n\t\treturn false, nil\n\t}\n\n\t// set the studio id\n\tscenePartial := models.NewScenePartial()\n\tscenePartial.StudioID = models.NewOptionalInt(studioID)\n\n\tif _, err := sceneWriter.UpdatePartial(ctx, o.ID, scenePartial); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc addImageStudio(ctx context.Context, imageWriter models.ImageUpdater, i *models.Image, studioID int) (bool, error) {\n\t// don't set if already set\n\tif i.StudioID != nil {\n\t\treturn false, nil\n\t}\n\n\t// set the studio id\n\timagePartial := models.NewImagePartial()\n\timagePartial.StudioID = models.NewOptionalInt(studioID)\n\n\tif _, err := imageWriter.UpdatePartial(ctx, i.ID, imagePartial); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc addGalleryStudio(ctx context.Context, galleryWriter GalleryFinderUpdater, o *models.Gallery, studioID int) (bool, error) {\n\t// don't set if already set\n\tif o.StudioID != nil {\n\t\treturn false, nil\n\t}\n\n\t// set the studio id\n\tgalleryPartial := models.NewGalleryPartial()\n\tgalleryPartial.StudioID = models.NewOptionalInt(studioID)\n\n\tif _, err := galleryWriter.UpdatePartial(ctx, o.ID, galleryPartial); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc getStudioTagger(p *models.Studio, aliases []string, cache *match.Cache) []tagger {\n\tret := []tagger{{\n\t\tID:    p.ID,\n\t\tType:  \"studio\",\n\t\tName:  p.Name,\n\t\tcache: cache,\n\t}}\n\n\tfor _, a := range aliases {\n\t\tret = append(ret, tagger{\n\t\t\tID:   p.ID,\n\t\t\tType: \"studio\",\n\t\t\tName: a,\n\t\t})\n\t}\n\n\treturn ret\n}\n\n// StudioScenes searches for scenes whose path matches the provided studio name and tags the scene with the studio, if studio is not already set on the scene.\nfunc (tagger *Tagger) StudioScenes(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw SceneFinderUpdater) error {\n\tt := getStudioTagger(p, aliases, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) {\n\t\t\t// don't set if already set\n\t\t\tif o.StudioID != nil {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\t// set the studio id\n\t\t\tscenePartial := models.NewScenePartial()\n\t\t\tscenePartial.StudioID = models.NewOptionalInt(p.ID)\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\t_, err := rw.UpdatePartial(ctx, o.ID, scenePartial)\n\t\t\t\treturn err\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// StudioImages searches for images whose path matches the provided studio name and tags the image with the studio, if studio is not already set on the image.\nfunc (tagger *Tagger) StudioImages(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw ImageFinderUpdater) error {\n\tt := getStudioTagger(p, aliases, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagImages(ctx, paths, rw, func(i *models.Image) (bool, error) {\n\t\t\t// don't set if already set\n\t\t\tif i.StudioID != nil {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\t// set the studio id\n\t\t\timagePartial := models.NewImagePartial()\n\t\t\timagePartial.StudioID = models.NewOptionalInt(p.ID)\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\t_, err := rw.UpdatePartial(ctx, i.ID, imagePartial)\n\t\t\t\treturn err\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// StudioGalleries searches for galleries whose path matches the provided studio name and tags the gallery with the studio, if studio is not already set on the gallery.\nfunc (tagger *Tagger) StudioGalleries(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw GalleryFinderUpdater) error {\n\tt := getStudioTagger(p, aliases, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) {\n\t\t\t// don't set if already set\n\t\t\tif o.StudioID != nil {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\t// set the studio id\n\t\t\tgalleryPartial := models.NewGalleryPartial()\n\t\t\tgalleryPartial.StudioID = models.NewOptionalInt(p.ID)\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\t_, err := rw.UpdatePartial(ctx, o.ID, galleryPartial)\n\t\t\t\treturn err\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/autotag/studio_test.go",
    "content": "package autotag\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\ntype testStudioCase struct {\n\tstudioName    string\n\texpectedRegex string\n\taliasName     string\n\taliasRegex    string\n}\n\nvar (\n\ttestStudioCases = []testStudioCase{\n\t\t{\n\t\t\t\"studio name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])studio[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"studio + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])studio[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"studio name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])studio[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"alias name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])alias[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t\t{\n\t\t\t\"studio + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])studio[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"alias + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])alias[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t}\n\n\ttrailingBackslashStudioCases = []testStudioCase{\n\t\t{\n\t\t\t`studio + name\\`,\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])studio[.\\-_ ]*\\+[.\\-_ ]*name\\\\(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t`studio + name\\`,\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])studio[.\\-_ ]*\\+[.\\-_ ]*name\\\\(?:$|_|[^\\p{L}\\d])`,\n\t\t\t`alias + name\\`,\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])alias[.\\-_ ]*\\+[.\\-_ ]*name\\\\(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t}\n)\n\nfunc TestStudioScenes(t *testing.T) {\n\tt.Parallel()\n\n\ttc := testStudioCases\n\t// trailing backslash tests only work where filepath separator is not backslash\n\tif filepath.Separator != '\\\\' {\n\t\ttc = append(tc, trailingBackslashStudioCases...)\n\t}\n\n\tfor _, p := range tc {\n\t\ttestStudioScenes(t, p)\n\t}\n}\n\nfunc testStudioScenes(t *testing.T, tc testStudioCase) {\n\tstudioName := tc.studioName\n\texpectedRegex := tc.expectedRegex\n\taliasName := tc.aliasName\n\taliasRegex := tc.aliasRegex\n\n\tdb := mocks.NewDatabase()\n\n\tvar studioID = 2\n\n\tvar aliases []string\n\n\ttestPathName := studioName\n\tif aliasName != \"\" {\n\t\taliases = []string{aliasName}\n\t\ttestPathName = aliasName\n\t}\n\n\tmatchingPaths, falsePaths := generateTestPaths(testPathName, \"mp4\")\n\n\tvar scenes []*models.Scene\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\tscenes = append(scenes, &models.Scene{\n\t\t\tID:   i + 1,\n\t\t\tPath: p,\n\t\t})\n\t}\n\n\tstudio := models.Studio{\n\t\tID:   studioID,\n\t\tName: studioName,\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedSceneFilter := &models.SceneFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\t// if alias provided, then don't find by name\n\tonNameQuery := db.Scene.On(\"Query\", testCtx, scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false))\n\n\tif aliasName == \"\" {\n\t\tonNameQuery.Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once()\n\t} else {\n\t\tonNameQuery.Return(mocks.SceneQueryResult(nil, 0), nil).Once()\n\n\t\texpectedAliasFilter := &models.SceneFilterType{\n\t\t\tOrganized: &organized,\n\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\tValue:    aliasRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t}\n\n\t\tdb.Scene.On(\"Query\", mock.Anything, scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)).\n\t\t\tReturn(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once()\n\t}\n\n\tfor i := range matchingPaths {\n\t\tsceneID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.ScenePartial) bool {\n\t\t\texpected := models.ScenePartial{\n\t\t\t\tStudioID: models.NewOptionalInt(studioID),\n\t\t\t}\n\n\t\t\treturn scenePartialsEqual(got, expected)\n\t\t})\n\t\tdb.Scene.On(\"UpdatePartial\", mock.Anything, sceneID, matchPartial).Return(nil, nil).Once()\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.StudioScenes(testCtx, &studio, nil, aliases, db.Scene)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n\nfunc TestStudioImages(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, p := range testStudioCases {\n\t\ttestStudioImages(t, p)\n\t}\n}\n\nfunc testStudioImages(t *testing.T, tc testStudioCase) {\n\tstudioName := tc.studioName\n\texpectedRegex := tc.expectedRegex\n\taliasName := tc.aliasName\n\taliasRegex := tc.aliasRegex\n\n\tdb := mocks.NewDatabase()\n\n\tvar studioID = 2\n\n\tvar aliases []string\n\n\ttestPathName := studioName\n\tif aliasName != \"\" {\n\t\taliases = []string{aliasName}\n\t\ttestPathName = aliasName\n\t}\n\n\tvar images []*models.Image\n\tmatchingPaths, falsePaths := generateTestPaths(testPathName, imageExt)\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\timages = append(images, &models.Image{\n\t\t\tID:   i + 1,\n\t\t\tPath: p,\n\t\t})\n\t}\n\n\tstudio := models.Studio{\n\t\tID:   studioID,\n\t\tName: studioName,\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedImageFilter := &models.ImageFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\t// if alias provided, then don't find by name\n\tonNameQuery := db.Image.On(\"Query\", mock.Anything, image.QueryOptions(expectedImageFilter, expectedFindFilter, false))\n\tif aliasName == \"\" {\n\t\tonNameQuery.Return(mocks.ImageQueryResult(images, len(images)), nil).Once()\n\t} else {\n\t\tonNameQuery.Return(mocks.ImageQueryResult(nil, 0), nil).Once()\n\n\t\texpectedAliasFilter := &models.ImageFilterType{\n\t\t\tOrganized: &organized,\n\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\tValue:    aliasRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t}\n\n\t\tdb.Image.On(\"Query\", mock.Anything, image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)).\n\t\t\tReturn(mocks.ImageQueryResult(images, len(images)), nil).Once()\n\t}\n\n\tfor i := range matchingPaths {\n\t\timageID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.ImagePartial) bool {\n\t\t\texpected := models.ImagePartial{\n\t\t\t\tStudioID: models.NewOptionalInt(studioID),\n\t\t\t}\n\n\t\t\treturn imagePartialsEqual(got, expected)\n\t\t})\n\t\tdb.Image.On(\"UpdatePartial\", mock.Anything, imageID, matchPartial).Return(nil, nil).Once()\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.StudioImages(testCtx, &studio, nil, aliases, db.Image)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n\nfunc TestStudioGalleries(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, p := range testStudioCases {\n\t\ttestStudioGalleries(t, p)\n\t}\n}\n\nfunc testStudioGalleries(t *testing.T, tc testStudioCase) {\n\tstudioName := tc.studioName\n\texpectedRegex := tc.expectedRegex\n\taliasName := tc.aliasName\n\taliasRegex := tc.aliasRegex\n\n\tdb := mocks.NewDatabase()\n\n\tvar studioID = 2\n\n\tvar aliases []string\n\n\ttestPathName := studioName\n\tif aliasName != \"\" {\n\t\taliases = []string{aliasName}\n\t\ttestPathName = aliasName\n\t}\n\n\tvar galleries []*models.Gallery\n\tmatchingPaths, falsePaths := generateTestPaths(testPathName, galleryExt)\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\tv := p\n\t\tgalleries = append(galleries, &models.Gallery{\n\t\t\tID:   i + 1,\n\t\t\tPath: v,\n\t\t})\n\t}\n\n\tstudio := models.Studio{\n\t\tID:   studioID,\n\t\tName: studioName,\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedGalleryFilter := &models.GalleryFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\t// if alias provided, then don't find by name\n\tonNameQuery := db.Gallery.On(\"Query\", mock.Anything, expectedGalleryFilter, expectedFindFilter)\n\tif aliasName == \"\" {\n\t\tonNameQuery.Return(galleries, len(galleries), nil).Once()\n\t} else {\n\t\tonNameQuery.Return(nil, 0, nil).Once()\n\n\t\texpectedAliasFilter := &models.GalleryFilterType{\n\t\t\tOrganized: &organized,\n\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\tValue:    aliasRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t}\n\n\t\tdb.Gallery.On(\"Query\", mock.Anything, expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once()\n\t}\n\n\tfor i := range matchingPaths {\n\t\tgalleryID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool {\n\t\t\texpected := models.GalleryPartial{\n\t\t\t\tStudioID: models.NewOptionalInt(studioID),\n\t\t\t}\n\n\t\t\treturn galleryPartialsEqual(got, expected)\n\t\t})\n\t\tdb.Gallery.On(\"UpdatePartial\", mock.Anything, galleryID, matchPartial).Return(nil, nil).Once()\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.StudioGalleries(testCtx, &studio, nil, aliases, db.Gallery)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "internal/autotag/tag.go",
    "content": "package autotag\n\nimport (\n\t\"context\"\n\t\"slices\"\n\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype SceneQueryTagUpdater interface {\n\tmodels.SceneQueryer\n\tmodels.TagIDLoader\n\tmodels.SceneUpdater\n}\n\ntype ImageQueryTagUpdater interface {\n\tmodels.ImageQueryer\n\tmodels.TagIDLoader\n\tmodels.ImageUpdater\n}\n\ntype GalleryQueryTagUpdater interface {\n\tmodels.GalleryQueryer\n\tmodels.TagIDLoader\n\tmodels.GalleryUpdater\n}\n\nfunc getTagTaggers(p *models.Tag, aliases []string, cache *match.Cache) []tagger {\n\tret := []tagger{{\n\t\tID:    p.ID,\n\t\tType:  \"tag\",\n\t\tName:  p.Name,\n\t\tcache: cache,\n\t}}\n\n\tfor _, a := range aliases {\n\t\tret = append(ret, tagger{\n\t\t\tID:    p.ID,\n\t\t\tType:  \"tag\",\n\t\t\tName:  a,\n\t\t\tcache: cache,\n\t\t})\n\t}\n\n\treturn ret\n}\n\n// TagScenes searches for scenes whose path matches the provided tag name and tags the scene with the tag.\nfunc (tagger *Tagger) TagScenes(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw SceneQueryTagUpdater) error {\n\tt := getTagTaggers(p, aliases, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) {\n\t\t\tif err := o.LoadTagIDs(ctx, rw); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\texisting := o.TagIDs.List()\n\n\t\t\tif slices.Contains(existing, p.ID) {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\treturn scene.AddTag(ctx, rw, o, p.ID)\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// TagImages searches for images whose path matches the provided tag name and tags the image with the tag.\nfunc (tagger *Tagger) TagImages(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw ImageQueryTagUpdater) error {\n\tt := getTagTaggers(p, aliases, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) {\n\t\t\tif err := o.LoadTagIDs(ctx, rw); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\texisting := o.TagIDs.List()\n\n\t\t\tif slices.Contains(existing, p.ID) {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\treturn image.AddTag(ctx, rw, o, p.ID)\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// TagGalleries searches for galleries whose path matches the provided tag name and tags the gallery with the tag.\nfunc (tagger *Tagger) TagGalleries(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw GalleryQueryTagUpdater) error {\n\tt := getTagTaggers(p, aliases, tagger.Cache)\n\n\tfor _, tt := range t {\n\t\tif err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) {\n\t\t\tif err := o.LoadTagIDs(ctx, rw); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\texisting := o.TagIDs.List()\n\n\t\t\tif slices.Contains(existing, p.ID) {\n\t\t\t\treturn false, nil\n\t\t\t}\n\n\t\t\tif err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {\n\t\t\t\treturn gallery.AddTag(ctx, rw, o, p.ID)\n\t\t\t}); err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\n\t\t\treturn true, nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/autotag/tag_test.go",
    "content": "package autotag\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\ntype testTagCase struct {\n\ttagName       string\n\texpectedRegex string\n\taliasName     string\n\taliasRegex    string\n}\n\nvar (\n\ttestTagCases = []testTagCase{\n\t\t{\n\t\t\t\"tag name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])tag[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"tag + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])tag[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t\"tag name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])tag[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"alias name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])alias[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t\t{\n\t\t\t\"tag + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])tag[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"alias + name\",\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])alias[.\\-_ ]*\\+[.\\-_ ]*name(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t}\n\n\ttrailingBackslashCases = []testTagCase{\n\t\t{\n\t\t\t`tag + name\\`,\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])tag[.\\-_ ]*\\+[.\\-_ ]*name\\\\(?:$|_|[^\\p{L}\\d])`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t},\n\t\t{\n\t\t\t`tag + name\\`,\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])tag[.\\-_ ]*\\+[.\\-_ ]*name\\\\(?:$|_|[^\\p{L}\\d])`,\n\t\t\t`alias + name\\`,\n\t\t\t`(?i)(?:^|_|[^\\p{L}\\d])alias[.\\-_ ]*\\+[.\\-_ ]*name\\\\(?:$|_|[^\\p{L}\\d])`,\n\t\t},\n\t}\n)\n\nfunc TestTagScenes(t *testing.T) {\n\tt.Parallel()\n\n\ttc := testTagCases\n\t// trailing backslash tests only work where filepath separator is not backslash\n\tif filepath.Separator != '\\\\' {\n\t\ttc = append(tc, trailingBackslashCases...)\n\t}\n\n\tfor _, p := range tc {\n\t\ttestTagScenes(t, p)\n\t}\n}\n\nfunc testTagScenes(t *testing.T, tc testTagCase) {\n\ttagName := tc.tagName\n\texpectedRegex := tc.expectedRegex\n\taliasName := tc.aliasName\n\taliasRegex := tc.aliasRegex\n\n\tdb := mocks.NewDatabase()\n\n\tconst tagID = 2\n\n\tvar aliases []string\n\n\ttestPathName := tagName\n\tif aliasName != \"\" {\n\t\taliases = []string{aliasName}\n\t\ttestPathName = aliasName\n\t}\n\n\tmatchingPaths, falsePaths := generateTestPaths(testPathName, \"mp4\")\n\n\tvar scenes []*models.Scene\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\tscenes = append(scenes, &models.Scene{\n\t\t\tID:     i + 1,\n\t\t\tPath:   p,\n\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t})\n\t}\n\n\ttag := models.Tag{\n\t\tID:   tagID,\n\t\tName: tagName,\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedSceneFilter := &models.SceneFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\t// if alias provided, then don't find by name\n\tonNameQuery := db.Scene.On(\"Query\", testCtx, scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false))\n\tif aliasName == \"\" {\n\t\tonNameQuery.Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once()\n\t} else {\n\t\tonNameQuery.Return(mocks.SceneQueryResult(nil, 0), nil).Once()\n\n\t\texpectedAliasFilter := &models.SceneFilterType{\n\t\t\tOrganized: &organized,\n\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\tValue:    aliasRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t}\n\n\t\tdb.Scene.On(\"Query\", mock.Anything, scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)).\n\t\t\tReturn(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once()\n\t}\n\n\tfor i := range matchingPaths {\n\t\tsceneID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.ScenePartial) bool {\n\t\t\texpected := models.ScenePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\treturn scenePartialsEqual(got, expected)\n\t\t})\n\t\tdb.Scene.On(\"UpdatePartial\", mock.Anything, sceneID, matchPartial).Return(nil, nil).Once()\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.TagScenes(testCtx, &tag, nil, aliases, db.Scene)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n\nfunc TestTagImages(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, p := range testTagCases {\n\t\ttestTagImages(t, p)\n\t}\n}\n\nfunc testTagImages(t *testing.T, tc testTagCase) {\n\ttagName := tc.tagName\n\texpectedRegex := tc.expectedRegex\n\taliasName := tc.aliasName\n\taliasRegex := tc.aliasRegex\n\n\tdb := mocks.NewDatabase()\n\n\tconst tagID = 2\n\n\tvar aliases []string\n\n\ttestPathName := tagName\n\tif aliasName != \"\" {\n\t\taliases = []string{aliasName}\n\t\ttestPathName = aliasName\n\t}\n\n\tvar images []*models.Image\n\tmatchingPaths, falsePaths := generateTestPaths(testPathName, \"mp4\")\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\timages = append(images, &models.Image{\n\t\t\tID:     i + 1,\n\t\t\tPath:   p,\n\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t})\n\t}\n\n\ttag := models.Tag{\n\t\tID:   tagID,\n\t\tName: tagName,\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedImageFilter := &models.ImageFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\t// if alias provided, then don't find by name\n\tonNameQuery := db.Image.On(\"Query\", testCtx, image.QueryOptions(expectedImageFilter, expectedFindFilter, false))\n\tif aliasName == \"\" {\n\t\tonNameQuery.Return(mocks.ImageQueryResult(images, len(images)), nil).Once()\n\t} else {\n\t\tonNameQuery.Return(mocks.ImageQueryResult(nil, 0), nil).Once()\n\n\t\texpectedAliasFilter := &models.ImageFilterType{\n\t\t\tOrganized: &organized,\n\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\tValue:    aliasRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t}\n\n\t\tdb.Image.On(\"Query\", mock.Anything, image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)).\n\t\t\tReturn(mocks.ImageQueryResult(images, len(images)), nil).Once()\n\t}\n\n\tfor i := range matchingPaths {\n\t\timageID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.ImagePartial) bool {\n\t\t\texpected := models.ImagePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\treturn imagePartialsEqual(got, expected)\n\t\t})\n\t\tdb.Image.On(\"UpdatePartial\", mock.Anything, imageID, matchPartial).Return(nil, nil).Once()\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.TagImages(testCtx, &tag, nil, aliases, db.Image)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n\nfunc TestTagGalleries(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, p := range testTagCases {\n\t\ttestTagGalleries(t, p)\n\t}\n}\n\nfunc testTagGalleries(t *testing.T, tc testTagCase) {\n\ttagName := tc.tagName\n\texpectedRegex := tc.expectedRegex\n\taliasName := tc.aliasName\n\taliasRegex := tc.aliasRegex\n\n\tdb := mocks.NewDatabase()\n\n\tconst tagID = 2\n\n\tvar aliases []string\n\n\ttestPathName := tagName\n\tif aliasName != \"\" {\n\t\taliases = []string{aliasName}\n\t\ttestPathName = aliasName\n\t}\n\n\tvar galleries []*models.Gallery\n\tmatchingPaths, falsePaths := generateTestPaths(testPathName, \"mp4\")\n\tfor i, p := range append(matchingPaths, falsePaths...) {\n\t\tv := p\n\t\tgalleries = append(galleries, &models.Gallery{\n\t\t\tID:     i + 1,\n\t\t\tPath:   v,\n\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t})\n\t}\n\n\ttag := models.Tag{\n\t\tID:   tagID,\n\t\tName: tagName,\n\t}\n\n\torganized := false\n\tperPage := 1000\n\tsort := \"id\"\n\tdirection := models.SortDirectionEnumAsc\n\n\texpectedGalleryFilter := &models.GalleryFilterType{\n\t\tOrganized: &organized,\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    expectedRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t}\n\n\texpectedFindFilter := &models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\t// if alias provided, then don't find by name\n\tonNameQuery := db.Gallery.On(\"Query\", testCtx, expectedGalleryFilter, expectedFindFilter)\n\tif aliasName == \"\" {\n\t\tonNameQuery.Return(galleries, len(galleries), nil).Once()\n\t} else {\n\t\tonNameQuery.Return(nil, 0, nil).Once()\n\n\t\texpectedAliasFilter := &models.GalleryFilterType{\n\t\t\tOrganized: &organized,\n\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\tValue:    aliasRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t}\n\n\t\tdb.Gallery.On(\"Query\", mock.Anything, expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once()\n\t}\n\n\tfor i := range matchingPaths {\n\t\tgalleryID := i + 1\n\n\t\tmatchPartial := mock.MatchedBy(func(got models.GalleryPartial) bool {\n\t\t\texpected := models.GalleryPartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\treturn galleryPartialsEqual(got, expected)\n\t\t})\n\t\tdb.Gallery.On(\"UpdatePartial\", mock.Anything, galleryID, matchPartial).Return(nil, nil).Once()\n\n\t}\n\n\ttagger := Tagger{\n\t\tTxnManager: db,\n\t}\n\n\terr := tagger.TagGalleries(testCtx, &tag, nil, aliases, db.Gallery)\n\n\tassert := assert.New(t)\n\n\tassert.Nil(err)\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "internal/autotag/tagger.go",
    "content": "// Package autotag provides methods to auto-tag scenes with performers,\n// studios and tags.\n//\n// The autotag engine tags scenes with performers/studios/tags if the scene's\n// path matches the performer/studio/tag name. A scene's path is considered\n// a match if it contains the performer/studio/tag's full name, ignoring any\n// '.', '-', '_' characters in the path.\n//\n// For example, for a performer \"foo bar\", the following paths would be\n// considered a match: \"foo bar.mp4\", \"foobar.mp4\", \"foo.bar.mp4\",\n// \"foo-bar.mp4\", \"aaa.foo bar.bbb.mp4\".\n// The following would not be considered a match:\n// \"aafoo bar.mp4\", \"foo barbb.mp4\", \"foo/bar.mp4\"\npackage autotag\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype Tagger struct {\n\tTxnManager txn.Manager\n\tCache      *match.Cache\n}\n\ntype tagger struct {\n\tID      int\n\tType    string\n\tName    string\n\tPath    string\n\ttrimExt bool\n\n\tcache *match.Cache\n}\n\ntype addLinkFunc func(subjectID, otherID int) (bool, error)\ntype addImageLinkFunc func(o *models.Image) (bool, error)\ntype addGalleryLinkFunc func(o *models.Gallery) (bool, error)\ntype addSceneLinkFunc func(o *models.Scene) (bool, error)\n\nfunc (t *tagger) addError(otherType, otherName string, err error) error {\n\treturn fmt.Errorf(\"error adding %s '%s' to %s '%s': %s\", otherType, otherName, t.Type, t.Name, err.Error())\n}\n\nfunc (t *tagger) addLog(otherType, otherName string) {\n\tlogger.Infof(\"Added %s '%s' to %s '%s'\", otherType, otherName, t.Type, t.Name)\n}\n\nfunc (t *tagger) tagPerformers(ctx context.Context, performerReader models.PerformerAutoTagQueryer, addFunc addLinkFunc) error {\n\tothers, err := match.PathToPerformers(ctx, t.Path, performerReader, t.cache, t.trimExt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, p := range others {\n\t\tadded, err := addFunc(t.ID, p.ID)\n\n\t\tif err != nil {\n\t\t\treturn t.addError(\"performer\", p.Name, err)\n\t\t}\n\n\t\tif added {\n\t\t\tt.addLog(\"performer\", p.Name)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *tagger) tagStudios(ctx context.Context, studioReader models.StudioAutoTagQueryer, addFunc addLinkFunc) error {\n\tstudio, err := match.PathToStudio(ctx, t.Path, studioReader, t.cache, t.trimExt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif studio != nil {\n\t\tadded, err := addFunc(t.ID, studio.ID)\n\n\t\tif err != nil {\n\t\t\treturn t.addError(\"studio\", studio.Name, err)\n\t\t}\n\n\t\tif added {\n\t\t\tt.addLog(\"studio\", studio.Name)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *tagger) tagTags(ctx context.Context, tagReader models.TagAutoTagQueryer, addFunc addLinkFunc) error {\n\tothers, err := match.PathToTags(ctx, t.Path, tagReader, t.cache, t.trimExt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, p := range others {\n\t\tadded, err := addFunc(t.ID, p.ID)\n\n\t\tif err != nil {\n\t\t\treturn t.addError(\"tag\", p.Name, err)\n\t\t}\n\n\t\tif added {\n\t\t\tt.addLog(\"tag\", p.Name)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *tagger) tagScenes(ctx context.Context, paths []string, sceneReader models.SceneQueryer, addFunc addSceneLinkFunc) error {\n\treturn match.PathToScenesFn(ctx, t.Name, paths, sceneReader, func(ctx context.Context, p *models.Scene) error {\n\t\tadded, err := addFunc(p)\n\n\t\tif err != nil {\n\t\t\treturn t.addError(\"scene\", p.DisplayName(), err)\n\t\t}\n\n\t\tif added {\n\t\t\tt.addLog(\"scene\", p.DisplayName())\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc (t *tagger) tagImages(ctx context.Context, paths []string, imageReader models.ImageQueryer, addFunc addImageLinkFunc) error {\n\treturn match.PathToImagesFn(ctx, t.Name, paths, imageReader, func(ctx context.Context, p *models.Image) error {\n\t\tadded, err := addFunc(p)\n\n\t\tif err != nil {\n\t\t\treturn t.addError(\"image\", p.DisplayName(), err)\n\t\t}\n\n\t\tif added {\n\t\t\tt.addLog(\"image\", p.DisplayName())\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc (t *tagger) tagGalleries(ctx context.Context, paths []string, galleryReader models.GalleryQueryer, addFunc addGalleryLinkFunc) error {\n\treturn match.PathToGalleriesFn(ctx, t.Name, paths, galleryReader, func(ctx context.Context, p *models.Gallery) error {\n\t\tadded, err := addFunc(p)\n\n\t\tif err != nil {\n\t\t\treturn t.addError(\"gallery\", p.DisplayName(), err)\n\t\t}\n\n\t\tif added {\n\t\t\tt.addLog(\"gallery\", p.DisplayName())\n\t\t}\n\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "internal/build/version.go",
    "content": "// Package build provides the version information for the application.\npackage build\n\nimport (\n\t\"regexp\"\n)\n\nvar version string\nvar buildstamp string\nvar githash string\nvar officialBuild string\n\nfunc Version() (string, string, string) {\n\treturn version, githash, buildstamp\n}\n\nfunc VersionString() string {\n\tvar versionString string\n\tswitch {\n\tcase version != \"\":\n\t\tif githash != \"\" && !IsDevelop() {\n\t\t\tversionString = version + \" (\" + githash + \")\"\n\t\t} else {\n\t\t\tversionString = version\n\t\t}\n\tcase githash != \"\":\n\t\tversionString = githash\n\tdefault:\n\t\tversionString = \"unknown\"\n\t}\n\tif IsOfficial() {\n\t\tversionString += \" - Official Build\"\n\t} else {\n\t\tversionString += \" - Unofficial Build\"\n\t}\n\tif buildstamp != \"\" {\n\t\tversionString += \" - \" + buildstamp\n\t}\n\treturn versionString\n}\n\nfunc IsOfficial() bool {\n\treturn officialBuild == \"true\"\n}\n\nfunc IsDevelop() bool {\n\tif githash == \"\" {\n\t\treturn false\n\t}\n\n\t// if the version is suffixed with -x-xxxx, then we are running a development build\n\tdevelop := false\n\tre := regexp.MustCompile(`-\\d+-g\\w+$`)\n\tif re.MatchString(version) {\n\t\tdevelop = true\n\t}\n\treturn develop\n}\n"
  },
  {
    "path": "internal/desktop/desktop.go",
    "content": "// Package desktop provides desktop integration functionality for the application.\npackage desktop\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/pkg/browser\"\n\t\"github.com/stashapp/stash/internal/build\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"golang.org/x/term\"\n)\n\nvar isDesktop bool\n\n// InitIsDesktop sets the value of isDesktop.\n// Changed IsDesktop to be evaluated once at startup because if it is\n// checked while there are open terminal sessions (such as the ffmpeg hardware\n// encoding checks), it may return false.\nfunc InitIsDesktop() {\n\tisDesktop = isDesktopCheck()\n}\n\ntype FaviconProvider interface {\n\tGetFavicon() []byte\n\tGetFaviconPng() []byte\n}\n\n// Start starts the desktop icon process. It blocks until the process exits.\n// MUST be run on the main goroutine or will have no effect on macOS\nfunc Start(exit chan int, faviconProvider FaviconProvider) {\n\tif IsDesktop() {\n\t\thideConsole()\n\n\t\tc := config.GetInstance()\n\t\tif !c.GetNoBrowser() {\n\t\t\topenURLInBrowser(\"\")\n\t\t}\n\t\twriteStashIcon(faviconProvider)\n\t\tstartSystray(exit, faviconProvider)\n\t}\n}\n\n// openURLInBrowser opens a browser to the Stash UI. Path can be an empty string for main page.\nfunc openURLInBrowser(path string) {\n\t// This can be done before actually starting the server, as modern browsers will\n\t// automatically reload the page if a local port is closed at page load and then opened.\n\tserverAddress := getServerURL(path)\n\n\terr := browser.OpenURL(serverAddress)\n\tif err != nil {\n\t\tlogger.Error(\"Could not open browser: \" + err.Error())\n\t}\n}\n\nfunc SendNotification(title string, text string) {\n\tif IsDesktop() {\n\t\tc := config.GetInstance()\n\t\tif c.GetNotificationsEnabled() {\n\t\t\tsendNotification(title, text)\n\t\t}\n\t}\n}\n\nfunc IsDesktop() bool {\n\treturn isDesktop\n}\n\n// isDesktop tries to determine if the application is running in a desktop environment\n// where desktop features like system tray and notifications should be enabled.\nfunc isDesktopCheck() bool {\n\tif isDoubleClickLaunched() {\n\t\tlogger.Debug(\"Detected double-click launch\")\n\t\treturn true\n\t}\n\n\t// Check if running under root\n\tif os.Getuid() == 0 {\n\t\tlogger.Debug(\"Running as root, disabling desktop features\")\n\t\treturn false\n\t}\n\t// Check if stdin is a terminal\n\tif term.IsTerminal(int(os.Stdin.Fd())) {\n\t\tlogger.Debug(\"Running in terminal, disabling desktop features\")\n\t\treturn false\n\t}\n\tif isService() {\n\t\tlogger.Debug(\"Running as a service, disabling desktop features\")\n\t\treturn false\n\t}\n\tif IsServerDockerized() {\n\t\tlogger.Debug(\"Running in docker, disabling desktop features\")\n\t\treturn false\n\t}\n\n\treturn true\n}\n\nfunc IsServerDockerized() bool {\n\treturn isServerDockerized()\n}\n\n// writeStashIcon writes the current stash logo to config/icon.png\nfunc writeStashIcon(faviconProvider FaviconProvider) {\n\tc := config.GetInstance()\n\tif !c.IsNewSystem() {\n\t\ticonPath := path.Join(c.GetConfigPath(), \"icon.png\")\n\t\terr := os.WriteFile(iconPath, faviconProvider.GetFaviconPng(), 0644)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Couldn't write icon file: %s\", err.Error())\n\t\t}\n\t}\n}\n\n// IsAllowedAutoUpdate tries to determine if the stash binary was installed from a\n// package manager or if touching the executable is otherwise a bad idea\nfunc IsAllowedAutoUpdate() bool {\n\n\t// Only try to update if downloaded from official sources\n\tif !build.IsOfficial() {\n\t\treturn false\n\t}\n\n\t// Avoid updating if installed from package manager\n\tif runtime.GOOS == \"linux\" {\n\t\texecutablePath, err := os.Executable()\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Cannot get executable path: %s\", err)\n\t\t\treturn false\n\t\t}\n\t\texecutablePath, err = filepath.EvalSymlinks(executablePath)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Cannot get executable path: %s\", err)\n\t\t\treturn false\n\t\t}\n\t\tif fsutil.IsPathInDir(\"/usr\", executablePath) || fsutil.IsPathInDir(\"/opt\", executablePath) {\n\t\t\treturn false\n\t\t}\n\n\t\tif isServerDockerized() {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc getIconPath() string {\n\treturn path.Join(config.GetInstance().GetConfigPath(), \"icon.png\")\n}\n\nfunc RevealInFileManager(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error checking path: %w\", err)\n\t}\n\n\tabsPath, err := filepath.Abs(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting absolute path: %w\", err)\n\t}\n\treturn revealInFileManager(absPath, info)\n}\n\nfunc getServerURL(path string) string {\n\tc := config.GetInstance()\n\tserverAddress := c.GetHost()\n\tif serverAddress == \"0.0.0.0\" {\n\t\tserverAddress = \"localhost\"\n\t}\n\tserverAddress = serverAddress + \":\" + strconv.Itoa(c.GetPort())\n\n\tproto := \"\"\n\tif c.HasTLSConfig() {\n\t\tproto = \"https://\"\n\t} else {\n\t\tproto = \"http://\"\n\t}\n\tserverAddress = proto + serverAddress + \"/\"\n\n\tif path != \"\" {\n\t\tserverAddress += strings.TrimPrefix(path, \"/\")\n\t}\n\n\treturn serverAddress\n}\n"
  },
  {
    "path": "internal/desktop/desktop_platform_darwin.go",
    "content": "//go:build darwin\n// +build darwin\n\npackage desktop\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\n\tgosxnotifier \"github.com/kermieisinthehouse/gosx-notifier\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nfunc isService() bool {\n\t// MacOS /does/ support services, using launchd, but there is no straightforward way to check if it was used.\n\treturn false\n}\n\nfunc isServerDockerized() bool {\n\treturn false\n}\n\nfunc sendNotification(notificationTitle string, notificationText string) {\n\tnotification := gosxnotifier.NewNotification(notificationText)\n\tnotification.Title = notificationTitle\n\tnotification.AppIcon = getIconPath()\n\tnotification.Open = getServerURL(\"\")\n\tnotification.Sender = \"cc.stashapp.stash\"\n\terr := notification.Push()\n\n\tif err != nil {\n\t\tlogger.Errorf(\"Could not send MacOS notification: %s\", err.Error())\n\t}\n}\n\nfunc revealInFileManager(path string, _ os.FileInfo) error {\n\tif err := exec.Command(`open`, `-R`, path).Run(); err != nil {\n\t\treturn fmt.Errorf(\"error revealing path in Finder: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc isDoubleClickLaunched() bool {\n\treturn false\n}\n\nfunc hideConsole() {\n\n}\n"
  },
  {
    "path": "internal/desktop/desktop_platform_nixes.go",
    "content": "//go:build unix && !darwin\n// +build unix,!darwin\n\npackage desktop\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// isService checks if started by init, e.g. stash is a *nix systemd service\nfunc isService() bool {\n\treturn os.Getppid() == 1\n}\n\nfunc isServerDockerized() bool {\n\t_, dockerEnvErr := os.Stat(\"/.dockerenv\")\n\tcgroups, _ := os.ReadFile(\"/proc/self/cgroup\")\n\tif !os.IsNotExist(dockerEnvErr) || strings.Contains(string(cgroups), \"docker\") {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc sendNotification(notificationTitle string, notificationText string) {\n\terr := exec.Command(\"notify-send\", \"-i\", getIconPath(), notificationTitle, notificationText, \"-a\", \"Stash\").Run()\n\tif err != nil {\n\t\tlogger.Errorf(\"Error sending notification on Linux: %s\", err.Error())\n\t}\n}\n\nfunc revealInFileManager(path string, info os.FileInfo) error {\n\tdir := path\n\tif !info.IsDir() {\n\t\tdir = filepath.Dir(path)\n\t}\n\tif err := exec.Command(\"xdg-open\", dir).Run(); err != nil {\n\t\treturn fmt.Errorf(\"error opening directory in file manager: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc isDoubleClickLaunched() bool {\n\treturn false\n}\n\nfunc hideConsole() {\n\n}\n"
  },
  {
    "path": "internal/desktop/desktop_platform_windows.go",
    "content": "//go:build windows\n// +build windows\n\npackage desktop\n\nimport (\n\t\"os\"\n\t\"os/exec\"\n\t\"syscall\"\n\t\"unsafe\"\n\n\t\"github.com/go-toast/toast\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"golang.org/x/sys/windows/svc\"\n)\n\nvar (\n\tkernel32 = syscall.NewLazyDLL(\"kernel32.dll\")\n\tuser32   = syscall.NewLazyDLL(\"user32.dll\")\n)\n\nfunc isService() bool {\n\tresult, err := svc.IsWindowsService()\n\tif err != nil {\n\t\tlogger.Errorf(\"Encountered error checking if running as Windows service: %s\", err.Error())\n\t\treturn false\n\t}\n\treturn result\n}\n\n// Detect if windows golang executable file is running via double click or from cmd/shell terminator\n// https://stackoverflow.com/questions/8610489/distinguish-if-program-runs-by-clicking-on-the-icon-typing-its-name-in-the-cons?rq=1\n// https://github.com/shirou/w32/blob/master/kernel32.go\n// https://github.com/kbinani/win/blob/master/kernel32.go#L3268\n// win.GetConsoleProcessList(new(uint32), win.DWORD(2))\n// from https://gist.github.com/yougg/213250cc04a52e2b853590b06f49d865\nfunc isDoubleClickLaunched() bool {\n\tlp := kernel32.NewProc(\"GetConsoleProcessList\")\n\tif lp != nil {\n\t\tvar pids [2]uint32\n\t\tvar maxCount uint32 = 2\n\t\tret, _, _ := lp.Call(uintptr(unsafe.Pointer(&pids)), uintptr(maxCount))\n\t\tif ret > 1 {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc hideConsole() {\n\tconst SW_HIDE = 0\n\th := getConsoleWindow()\n\tlp := user32.NewProc(\"ShowWindow\")\n\n\t// don't want to check for errors and can't prevent dogsled\n\t_, _, _ = lp.Call(h, SW_HIDE) //nolint:dogsled\n}\n\nfunc getConsoleWindow() uintptr {\n\tlp := kernel32.NewProc(\"GetConsoleWindow\")\n\tret, _, _ := lp.Call()\n\treturn ret\n}\n\nfunc isServerDockerized() bool {\n\treturn false\n}\n\nfunc sendNotification(notificationTitle string, notificationText string) {\n\tnotification := toast.Notification{\n\t\tAppID:   \"Stash\",\n\t\tTitle:   notificationTitle,\n\t\tMessage: notificationText,\n\t\tIcon:    getIconPath(),\n\t\tActions: []toast.Action{{\n\t\t\tType:      \"protocol\",\n\t\t\tLabel:     \"Open Stash\",\n\t\t\tArguments: getServerURL(\"\"),\n\t\t}},\n\t}\n\terr := notification.Push()\n\tif err != nil {\n\t\tlogger.Errorf(\"Error creating Windows notification: %s\", err.Error())\n\t}\n}\n\nfunc revealInFileManager(path string, _ os.FileInfo) error {\n\tc := exec.Command(`explorer`, `/select,`, path)\n\tlogger.Debugf(\"Running: %s\", c.String())\n\t// explorer seems to return an error code even when it works, so ignore the error\n\t_ = c.Run()\n\treturn nil\n}\n"
  },
  {
    "path": "internal/desktop/dialog_nonwindows.go",
    "content": "//go:build !windows\n// +build !windows\n\npackage desktop\n\nfunc FatalError(err error) int {\n\t// nothing to do\n\treturn 0\n}\n"
  },
  {
    "path": "internal/desktop/dialog_windows.go",
    "content": "//go:build windows\n// +build windows\n\npackage desktop\n\nimport (\n\t\"fmt\"\n\t\"syscall\"\n\t\"unsafe\"\n)\n\nfunc FatalError(err error) int {\n\tconst (\n\t\tNULL         = 0\n\t\tMB_OK        = 0\n\t\tMB_ICONERROR = 0x10\n\t)\n\n\treturn messageBox(NULL, fmt.Sprintf(\"Error: %v\", err), \"Stash - Fatal Error\", MB_OK|MB_ICONERROR)\n}\n\nfunc messageBox(hwnd uintptr, caption, title string, flags uint) int {\n\tlpText, _ := syscall.UTF16PtrFromString(caption)\n\tlpCaption, _ := syscall.UTF16PtrFromString(title)\n\n\tret, _, _ := syscall.NewLazyDLL(\"user32.dll\").NewProc(\"MessageBoxW\").Call(\n\t\tuintptr(hwnd),\n\t\tuintptr(unsafe.Pointer(lpText)),\n\t\tuintptr(unsafe.Pointer(lpCaption)),\n\t\tuintptr(flags))\n\n\treturn int(ret)\n}\n"
  },
  {
    "path": "internal/desktop/systray_nixes.go",
    "content": "//go:build (!windows && !darwin) || !cgo\n\npackage desktop\n\nfunc startSystray(exit chan int, favicon FaviconProvider) {\n\t// The systray is not available on Linux because the required libraries (libappindicator3 and gtk+3.0)\n\t// are not able to be statically compiled. Technically, the systray works perfectly fine when dynamically\n\t// linked, but we cannot distribute it for compatibility reasons.\n\t// Additionally, the systray package requires CGo so the dependency cannot be used if building with\n\t// CGo disabled.\n}\n"
  },
  {
    "path": "internal/desktop/systray_nonlinux.go",
    "content": "//go:build (windows || darwin) && cgo\n\npackage desktop\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/kermieisinthehouse/systray\"\n\t\"golang.org/x/text/cases\"\n\t\"golang.org/x/text/language\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// MUST be run on the main goroutine or will have no effect on macOS\nfunc startSystray(exit chan int, faviconProvider FaviconProvider) {\n\t// Shows a small notification to inform that Stash will no longer show a terminal window,\n\t// and instead will be available in the tray. Will only show the first time a pre-desktop integration\n\t// system is started from a non-terminal method, e.g. double-clicking an icon.\n\tc := config.GetInstance()\n\tif c.GetShowOneTimeMovedNotification() {\n\t\t// Use platform-appropriate terminology\n\t\tlocation := \"tray\"\n\t\tif runtime.GOOS == \"darwin\" {\n\t\t\tlocation = \"menu bar\"\n\t\t}\n\t\tSendNotification(\"Stash has moved!\", \"Stash now runs in your \"+location+\", instead of a terminal window.\")\n\t\tc.SetBool(config.ShowOneTimeMovedNotification, false)\n\t\tif err := c.Write(); err != nil {\n\t\t\tlogger.Errorf(\"Error while writing configuration file: %v\", err)\n\t\t}\n\t}\n\n\t// Listen for changes to rerender systray\n\t// TODO: This is disabled for now. The systray package does not clean up all of its resources when Quit() is called.\n\t// TODO: This results in this only working once, or changes being ignored. Our fork of systray fixes a crash(!) on macOS here.\n\t// go func() {\n\t// \tfor {\n\t// \t\t<-config.GetInstance().GetConfigUpdatesChannel()\n\t// \t\tsystray.Quit()\n\t// \t}\n\t// }()\n\n\t// \"intercept\" an exit code to quit the systray, allowing the call to systray.Run() below to return.\n\tgo func() {\n\t\texitCode := <-exit\n\t\tsystray.Quit()\n\t\texit <- exitCode\n\t}()\n\n\tsystray.Run(func() {\n\t\tsystrayInitialize(exit, faviconProvider)\n\t}, nil)\n}\n\nfunc systrayInitialize(exit chan<- int, faviconProvider FaviconProvider) {\n\tfavicon := faviconProvider.GetFavicon()\n\tsystray.SetTemplateIcon(favicon, favicon)\n\tc := config.GetInstance()\n\tsystray.SetTooltip(fmt.Sprintf(\"🟢 Stash is Running on port %d.\", c.GetPort()))\n\n\topenStashButton := systray.AddMenuItem(\"Open Stash\", \"Open a browser window to Stash\")\n\tvar menuItems []string\n\tsystray.AddSeparator()\n\tif !c.IsNewSystem() {\n\t\tmenuItems = c.GetMenuItems()\n\t\tfor _, item := range menuItems {\n\t\t\tc := cases.Title(language.Und)\n\t\t\ttitleCaseItem := c.String(strings.ToLower(item))\n\t\t\tcurr := systray.AddMenuItem(titleCaseItem, \"Open to \"+titleCaseItem)\n\t\t\tgo func(item string) {\n\t\t\t\tfor {\n\t\t\t\t\t<-curr.ClickedCh\n\t\t\t\t\tif item == \"markers\" {\n\t\t\t\t\t\titem = \"scenes/markers\"\n\t\t\t\t\t}\n\t\t\t\t\topenURLInBrowser(item)\n\t\t\t\t}\n\t\t\t}(item)\n\t\t}\n\t\tsystray.AddSeparator()\n\t\t// TODO - Some ideas for future expansions\n\t\t// systray.AddMenuItem(\"Start a Scan\", \"Scan all libraries with default settings\")\n\t\t// systray.AddMenuItem(\"Start Auto Tagging\", \"Auto Tag all libraries\")\n\t\t// systray.AddMenuItem(\"Check for updates\", \"Check for a new Stash release\")\n\t\t// systray.AddSeparator()\n\t}\n\n\tquitStashButton := systray.AddMenuItem(\"Quit Stash Server\", \"Quits the Stash server\")\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-openStashButton.ClickedCh:\n\t\t\t\topenURLInBrowser(\"\")\n\t\t\tcase <-quitStashButton.ClickedCh:\n\t\t\t\texit <- 0\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "internal/dlna/activity.go",
    "content": "package dlna\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\nconst (\n\t// DefaultSessionTimeout is the time after which a session is considered complete\n\t// if no new requests are received.\n\t// This is set high (5 minutes) because DLNA clients buffer aggressively and may not\n\t// send any HTTP requests for extended periods while the user is still watching.\n\tDefaultSessionTimeout = 5 * time.Minute\n\n\t// monitorInterval is how often we check for expired sessions.\n\tmonitorInterval = 10 * time.Second\n)\n\n// ActivityConfig provides configuration options for DLNA activity tracking.\ntype ActivityConfig interface {\n\t// GetDLNAActivityTrackingEnabled returns true if activity tracking should be enabled.\n\t// If not implemented, defaults to true.\n\tGetDLNAActivityTrackingEnabled() bool\n\n\t// GetMinimumPlayPercent returns the minimum percentage of a video that must be\n\t// watched before incrementing the play count. Uses UI setting if available.\n\tGetMinimumPlayPercent() int\n}\n\n// SceneActivityWriter provides methods for saving scene activity.\ntype SceneActivityWriter interface {\n\tSaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error)\n\tAddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error)\n}\n\n// streamSession represents an active DLNA streaming session.\ntype streamSession struct {\n\tSceneID        int\n\tClientIP       string\n\tStartTime      time.Time\n\tLastActivity   time.Time\n\tVideoDuration  float64\n\tPlayCountAdded bool\n}\n\n// sessionKey generates a unique key for a session based on client IP and scene ID.\nfunc sessionKey(clientIP string, sceneID int) string {\n\treturn fmt.Sprintf(\"%s:%d\", clientIP, sceneID)\n}\n\n// percentWatched calculates the estimated percentage of video watched.\n// Uses a time-based approach since DLNA clients buffer aggressively and byte\n// positions don't correlate with actual playback position.\n//\n// The key insight: you cannot have watched more of the video than time has elapsed.\n// If the video is 30 minutes and only 1 minute has passed, maximum watched is ~3.3%.\nfunc (s *streamSession) percentWatched() float64 {\n\tif s.VideoDuration <= 0 {\n\t\treturn 0\n\t}\n\n\t// Calculate elapsed time from session start to last activity\n\telapsed := s.LastActivity.Sub(s.StartTime).Seconds()\n\tif elapsed <= 0 {\n\t\treturn 0\n\t}\n\n\t// Maximum possible percent is based on elapsed time\n\t// You can't watch more of the video than time has passed\n\ttimeBasedPercent := (elapsed / s.VideoDuration) * 100\n\n\t// Cap at 100%\n\tif timeBasedPercent > 100 {\n\t\treturn 100\n\t}\n\n\treturn timeBasedPercent\n}\n\n// estimatedResumeTime calculates the estimated resume time based on elapsed time.\n// Since DLNA clients buffer aggressively, byte positions don't correlate with playback.\n// Instead, we estimate based on how long the session has been active.\n// Returns the time in seconds, or 0 if the video is nearly complete (>=98%).\nfunc (s *streamSession) estimatedResumeTime() float64 {\n\tif s.VideoDuration <= 0 {\n\t\treturn 0\n\t}\n\n\t// Calculate elapsed time from session start\n\telapsed := s.LastActivity.Sub(s.StartTime).Seconds()\n\tif elapsed <= 0 {\n\t\treturn 0\n\t}\n\n\t// If elapsed time exceeds 98% of video duration, reset resume time (matches frontend behavior)\n\tif elapsed >= s.VideoDuration*0.98 {\n\t\treturn 0\n\t}\n\n\t// Resume time is approximately where the user was watching\n\t// Capped by video duration\n\tif elapsed > s.VideoDuration {\n\t\telapsed = s.VideoDuration\n\t}\n\n\treturn elapsed\n}\n\n// ActivityTracker tracks DLNA streaming activity and saves it to the database.\ntype ActivityTracker struct {\n\ttxnManager     txn.Manager\n\tsceneWriter    SceneActivityWriter\n\tconfig         ActivityConfig\n\tsessionTimeout time.Duration\n\n\tsessions map[string]*streamSession\n\tmutex    sync.RWMutex\n\n\tctx        context.Context\n\tcancelFunc context.CancelFunc\n\twg         sync.WaitGroup\n}\n\n// NewActivityTracker creates a new ActivityTracker.\nfunc NewActivityTracker(\n\ttxnManager txn.Manager,\n\tsceneWriter SceneActivityWriter,\n\tconfig ActivityConfig,\n) *ActivityTracker {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\ttracker := &ActivityTracker{\n\t\ttxnManager:     txnManager,\n\t\tsceneWriter:    sceneWriter,\n\t\tconfig:         config,\n\t\tsessionTimeout: DefaultSessionTimeout,\n\t\tsessions:       make(map[string]*streamSession),\n\t\tctx:            ctx,\n\t\tcancelFunc:     cancel,\n\t}\n\n\t// Start the session monitor goroutine\n\ttracker.wg.Add(1)\n\tgo tracker.monitorSessions()\n\n\treturn tracker\n}\n\n// Stop stops the activity tracker and processes any remaining sessions.\nfunc (t *ActivityTracker) Stop() {\n\tt.cancelFunc()\n\tt.wg.Wait()\n\n\t// Process any remaining sessions\n\tt.mutex.Lock()\n\tsessions := make([]*streamSession, 0, len(t.sessions))\n\tfor _, session := range t.sessions {\n\t\tsessions = append(sessions, session)\n\t}\n\tt.sessions = make(map[string]*streamSession)\n\tt.mutex.Unlock()\n\n\tfor _, session := range sessions {\n\t\tt.processCompletedSession(session)\n\t}\n}\n\n// RecordRequest records a streaming request for activity tracking.\n// Each request updates the session's LastActivity time, which is used for\n// time-based tracking of watch progress.\nfunc (t *ActivityTracker) RecordRequest(sceneID int, clientIP string, videoDuration float64) {\n\tif !t.isEnabled() {\n\t\treturn\n\t}\n\n\tkey := sessionKey(clientIP, sceneID)\n\tnow := time.Now()\n\n\tt.mutex.Lock()\n\tdefer t.mutex.Unlock()\n\n\tsession, exists := t.sessions[key]\n\tif !exists {\n\t\tsession = &streamSession{\n\t\t\tSceneID:       sceneID,\n\t\t\tClientIP:      clientIP,\n\t\t\tStartTime:     now,\n\t\t\tVideoDuration: videoDuration,\n\t\t}\n\t\tt.sessions[key] = session\n\t\tlogger.Debugf(\"[DLNA Activity] New session started: scene=%d, client=%s\", sceneID, clientIP)\n\t}\n\n\tsession.LastActivity = now\n}\n\n// monitorSessions periodically checks for expired sessions and processes them.\nfunc (t *ActivityTracker) monitorSessions() {\n\tdefer t.wg.Done()\n\n\tticker := time.NewTicker(monitorInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-t.ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tt.processExpiredSessions()\n\t\t}\n\t}\n}\n\n// processExpiredSessions finds and processes sessions that have timed out.\nfunc (t *ActivityTracker) processExpiredSessions() {\n\tnow := time.Now()\n\tvar expiredSessions []*streamSession\n\n\tt.mutex.Lock()\n\tfor key, session := range t.sessions {\n\t\ttimeSinceStart := now.Sub(session.StartTime)\n\t\ttimeSinceActivity := now.Sub(session.LastActivity)\n\n\t\t// Must have no HTTP activity for the full timeout period\n\t\tif timeSinceActivity <= t.sessionTimeout {\n\t\t\tcontinue\n\t\t}\n\n\t\t// DLNA clients buffer aggressively - they fetch most/all of the video quickly,\n\t\t// then play from cache with NO further HTTP requests.\n\t\t//\n\t\t// Two scenarios:\n\t\t// 1. User watched the whole video: timeSinceStart >= videoDuration\n\t\t//    -> Set LastActivity to when timeout began (they finished watching)\n\t\t// 2. User stopped early: timeSinceStart < videoDuration\n\t\t//    -> Keep LastActivity as-is (best estimate of when they stopped)\n\n\t\tvideoDuration := time.Duration(session.VideoDuration) * time.Second\n\t\tif timeSinceStart >= videoDuration && videoDuration > 0 {\n\t\t\t// User likely watched the whole video, then it timed out\n\t\t\t// Estimate they watched until the timeout period started\n\t\t\tsession.LastActivity = now.Add(-t.sessionTimeout)\n\t\t}\n\t\t// else: User stopped early - LastActivity is already our best estimate\n\n\t\texpiredSessions = append(expiredSessions, session)\n\t\tdelete(t.sessions, key)\n\t}\n\tt.mutex.Unlock()\n\n\tfor _, session := range expiredSessions {\n\t\tt.processCompletedSession(session)\n\t}\n}\n\n// processCompletedSession saves activity data for a completed streaming session.\nfunc (t *ActivityTracker) processCompletedSession(session *streamSession) {\n\tpercentWatched := session.percentWatched()\n\tresumeTime := session.estimatedResumeTime()\n\n\tlogger.Debugf(\"[DLNA Activity] Session completed: scene=%d, client=%s, videoDuration=%.1fs, percent=%.1f%%, resume=%.1fs\",\n\t\tsession.SceneID, session.ClientIP, session.VideoDuration, percentWatched, resumeTime)\n\n\t// Only save if there was meaningful activity (at least 1% watched)\n\tif percentWatched < 1 {\n\t\tlogger.Debugf(\"[DLNA Activity] Session too short, skipping save\")\n\t\treturn\n\t}\n\n\t// Skip DB operations if txnManager is nil (for testing)\n\tif t.txnManager == nil {\n\t\tlogger.Debugf(\"[DLNA Activity] No transaction manager, skipping DB save\")\n\t\treturn\n\t}\n\n\t// Determine what needs to be saved\n\tshouldSaveResume := resumeTime > 0\n\tshouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent())\n\n\t// Nothing to save\n\tif !shouldSaveResume && !shouldAddView {\n\t\treturn\n\t}\n\n\t// Save everything in a single transaction\n\tctx := context.Background()\n\tif err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error {\n\t\t// Save resume time only. DLNA clients buffer aggressively and don't report\n\t\t// playback position, so we can't accurately track play duration - saving\n\t\t// guesses would corrupt analytics. Resume time is still useful as a\n\t\t// \"continue watching\" hint even if imprecise.\n\t\tif shouldSaveResume {\n\t\t\tif _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, &resumeTime, nil); err != nil {\n\t\t\t\treturn fmt.Errorf(\"save resume time: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\t// Increment play count (also updates last_played_at via view date)\n\t\tif shouldAddView {\n\t\t\tif _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add view: %w\", err)\n\t\t\t}\n\t\t\tsession.PlayCountAdded = true\n\t\t\tlogger.Debugf(\"[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)\",\n\t\t\t\tsession.SceneID, percentWatched)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Warnf(\"[DLNA Activity] Failed to save activity for scene %d: %v\", session.SceneID, err)\n\t}\n}\n\n// isEnabled returns true if activity tracking is enabled.\nfunc (t *ActivityTracker) isEnabled() bool {\n\tif t.config == nil {\n\t\treturn true // Default to enabled\n\t}\n\treturn t.config.GetDLNAActivityTrackingEnabled()\n}\n\n// getMinimumPlayPercent returns the minimum play percentage for incrementing play count.\nfunc (t *ActivityTracker) getMinimumPlayPercent() int {\n\tif t.config == nil {\n\t\treturn 0 // Default: any play increments count (matches frontend default)\n\t}\n\treturn t.config.GetMinimumPlayPercent()\n}\n"
  },
  {
    "path": "internal/dlna/activity_test.go",
    "content": "package dlna\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// mockSceneWriter is a mock implementation of SceneActivityWriter\ntype mockSceneWriter struct {\n\tmu                sync.Mutex\n\tsaveActivityCalls []saveActivityCall\n\taddViewsCalls     []addViewsCall\n}\n\ntype saveActivityCall struct {\n\tsceneID      int\n\tresumeTime   *float64\n\tplayDuration *float64\n}\n\ntype addViewsCall struct {\n\tsceneID int\n\tdates   []time.Time\n}\n\nfunc (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) {\n\tm.mu.Lock()\n\tm.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{\n\t\tsceneID:      sceneID,\n\t\tresumeTime:   resumeTime,\n\t\tplayDuration: playDuration,\n\t})\n\tm.mu.Unlock()\n\treturn true, nil\n}\n\nfunc (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) {\n\tm.mu.Lock()\n\tm.addViewsCalls = append(m.addViewsCalls, addViewsCall{\n\t\tsceneID: sceneID,\n\t\tdates:   dates,\n\t})\n\tm.mu.Unlock()\n\treturn dates, nil\n}\n\n// mockConfig is a mock implementation of ActivityConfig\ntype mockConfig struct {\n\tenabled        bool\n\tminPlayPercent int\n}\n\nfunc (c *mockConfig) GetDLNAActivityTrackingEnabled() bool {\n\treturn c.enabled\n}\n\nfunc (c *mockConfig) GetMinimumPlayPercent() int {\n\treturn c.minPlayPercent\n}\n\nfunc TestStreamSession_PercentWatched(t *testing.T) {\n\tnow := time.Now()\n\n\ttests := []struct {\n\t\tname          string\n\t\tstartTime     time.Time\n\t\tlastActivity  time.Time\n\t\tvideoDuration float64\n\t\texpected      float64\n\t}{\n\t\t{\n\t\t\tname:          \"no video duration\",\n\t\t\tstartTime:     now.Add(-60 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 0,\n\t\t\texpected:      0,\n\t\t},\n\t\t{\n\t\t\tname:          \"half watched\",\n\t\t\tstartTime:     now.Add(-60 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0, // 2 minutes, watched for 1 minute = 50%\n\t\t\texpected:      50.0,\n\t\t},\n\t\t{\n\t\t\tname:          \"fully watched\",\n\t\t\tstartTime:     now.Add(-120 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100%\n\t\t\texpected:      100.0,\n\t\t},\n\t\t{\n\t\t\tname:          \"quarter watched\",\n\t\t\tstartTime:     now.Add(-30 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25%\n\t\t\texpected:      25.0,\n\t\t},\n\t\t{\n\t\t\tname:          \"elapsed exceeds duration - capped at 100%\",\n\t\t\tstartTime:     now.Add(-180 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100%\n\t\t\texpected:      100.0,\n\t\t},\n\t\t{\n\t\t\tname:          \"no elapsed time\",\n\t\t\tstartTime:     now,\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0,\n\t\t\texpected:      0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsession := &streamSession{\n\t\t\t\tStartTime:     tt.startTime,\n\t\t\t\tLastActivity:  tt.lastActivity,\n\t\t\t\tVideoDuration: tt.videoDuration,\n\t\t\t}\n\t\t\tresult := session.percentWatched()\n\t\t\tassert.InDelta(t, tt.expected, result, 0.01)\n\t\t})\n\t}\n}\n\nfunc TestStreamSession_EstimatedResumeTime(t *testing.T) {\n\tnow := time.Now()\n\n\ttests := []struct {\n\t\tname          string\n\t\tstartTime     time.Time\n\t\tlastActivity  time.Time\n\t\tvideoDuration float64\n\t\texpected      float64\n\t}{\n\t\t{\n\t\t\tname:          \"no elapsed time\",\n\t\t\tstartTime:     now,\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0,\n\t\t\texpected:      0,\n\t\t},\n\t\t{\n\t\t\tname:          \"half way through\",\n\t\t\tstartTime:     now.Add(-60 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s\n\t\t\texpected:      60.0,\n\t\t},\n\t\t{\n\t\t\tname:          \"quarter way through\",\n\t\t\tstartTime:     now.Add(-30 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s\n\t\t\texpected:      30.0,\n\t\t},\n\t\t{\n\t\t\tname:          \"98% complete - should reset to 0\",\n\t\t\tstartTime:     now.Add(-118 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0, // 98.3% elapsed, should reset\n\t\t\texpected:      0,\n\t\t},\n\t\t{\n\t\t\tname:          \"100% complete - should reset to 0\",\n\t\t\tstartTime:     now.Add(-120 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0,\n\t\t\texpected:      0,\n\t\t},\n\t\t{\n\t\t\tname:          \"elapsed exceeds duration - capped and reset to 0\",\n\t\t\tstartTime:     now.Add(-180 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0\n\t\t\texpected:      0,\n\t\t},\n\t\t{\n\t\t\tname:          \"no video duration\",\n\t\t\tstartTime:     now.Add(-60 * time.Second),\n\t\t\tlastActivity:  now,\n\t\t\tvideoDuration: 0,\n\t\t\texpected:      0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsession := &streamSession{\n\t\t\t\tStartTime:     tt.startTime,\n\t\t\t\tLastActivity:  tt.lastActivity,\n\t\t\t\tVideoDuration: tt.videoDuration,\n\t\t\t}\n\t\t\tresult := session.estimatedResumeTime()\n\t\t\tassert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance\n\t\t})\n\t}\n}\n\nfunc TestSessionKey(t *testing.T) {\n\tkey := sessionKey(\"192.168.1.100\", 42)\n\tassert.Equal(t, \"192.168.1.100:42\", key)\n}\n\nfunc TestActivityTracker_RecordRequest(t *testing.T) {\n\tconfig := &mockConfig{enabled: true, minPlayPercent: 50}\n\n\t// Create tracker without starting the goroutine (for unit testing)\n\ttracker := &ActivityTracker{\n\t\ttxnManager:     nil, // Don't need DB for this test\n\t\tsceneWriter:    nil,\n\t\tconfig:         config,\n\t\tsessionTimeout: DefaultSessionTimeout,\n\t\tsessions:       make(map[string]*streamSession),\n\t}\n\n\t// Record first request - should create new session\n\ttracker.RecordRequest(42, \"192.168.1.100\", 120.0)\n\n\ttracker.mutex.RLock()\n\tsession := tracker.sessions[\"192.168.1.100:42\"]\n\ttracker.mutex.RUnlock()\n\n\tassert.NotNil(t, session)\n\tassert.Equal(t, 42, session.SceneID)\n\tassert.Equal(t, \"192.168.1.100\", session.ClientIP)\n\tassert.Equal(t, 120.0, session.VideoDuration)\n\tassert.False(t, session.StartTime.IsZero())\n\tassert.False(t, session.LastActivity.IsZero())\n\n\t// Record second request - should update LastActivity\n\tfirstActivity := session.LastActivity\n\ttime.Sleep(10 * time.Millisecond)\n\ttracker.RecordRequest(42, \"192.168.1.100\", 120.0)\n\n\ttracker.mutex.RLock()\n\tsession = tracker.sessions[\"192.168.1.100:42\"]\n\ttracker.mutex.RUnlock()\n\n\tassert.True(t, session.LastActivity.After(firstActivity))\n}\n\nfunc TestActivityTracker_DisabledTracking(t *testing.T) {\n\tconfig := &mockConfig{enabled: false, minPlayPercent: 50}\n\n\t// Create tracker without starting the goroutine (for unit testing)\n\ttracker := &ActivityTracker{\n\t\ttxnManager:     nil,\n\t\tsceneWriter:    nil,\n\t\tconfig:         config,\n\t\tsessionTimeout: DefaultSessionTimeout,\n\t\tsessions:       make(map[string]*streamSession),\n\t}\n\n\t// Record request - should be ignored when tracking is disabled\n\ttracker.RecordRequest(42, \"192.168.1.100\", 120.0)\n\n\ttracker.mutex.RLock()\n\tsessionCount := len(tracker.sessions)\n\ttracker.mutex.RUnlock()\n\n\tassert.Equal(t, 0, sessionCount)\n}\n\nfunc TestActivityTracker_SessionExpiration(t *testing.T) {\n\t// For this test, we'll test the session expiration logic directly\n\t// without the full transaction manager integration\n\n\tsceneWriter := &mockSceneWriter{}\n\tconfig := &mockConfig{enabled: true, minPlayPercent: 10}\n\n\t// Create a tracker with nil txnManager - we'll test processCompletedSession separately\n\t// Here we just verify the session management logic\n\ttracker := &ActivityTracker{\n\t\ttxnManager:     nil, // Skip DB calls for this test\n\t\tsceneWriter:    sceneWriter,\n\t\tconfig:         config,\n\t\tsessionTimeout: 100 * time.Millisecond,\n\t\tsessions:       make(map[string]*streamSession),\n\t}\n\n\t// Manually add a session\n\t// Use a short video duration (1 second) so the test can verify expiration quickly.\n\tnow := time.Now()\n\ttracker.sessions[\"192.168.1.100:42\"] = &streamSession{\n\t\tSceneID:       42,\n\t\tClientIP:      \"192.168.1.100\",\n\t\tStartTime:     now.Add(-5 * time.Second),        // Started 5 seconds ago\n\t\tLastActivity:  now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)\n\t\tVideoDuration: 1.0,                              // Short video so timeSinceStart > videoDuration\n\t}\n\n\t// Verify session exists\n\tassert.Len(t, tracker.sessions, 1)\n\n\t// Process expired sessions - this will try to save activity but txnManager is nil\n\t// so it will skip the DB calls but still remove the session\n\ttracker.processExpiredSessions()\n\n\t// Verify session was removed (even though DB calls were skipped)\n\tassert.Len(t, tracker.sessions, 0)\n}\n\nfunc TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) {\n\t// Test that sessions expire when user stops watching early (before video ends)\n\t// This was a bug where sessions wouldn't expire until video duration passed\n\n\tconfig := &mockConfig{enabled: true, minPlayPercent: 10}\n\ttracker := &ActivityTracker{\n\t\ttxnManager:     nil,\n\t\tsceneWriter:    nil,\n\t\tconfig:         config,\n\t\tsessionTimeout: 100 * time.Millisecond,\n\t\tsessions:       make(map[string]*streamSession),\n\t}\n\n\t// User started watching a 30-minute video but stopped after 5 seconds\n\tnow := time.Now()\n\ttracker.sessions[\"192.168.1.100:42\"] = &streamSession{\n\t\tSceneID:       42,\n\t\tClientIP:      \"192.168.1.100\",\n\t\tStartTime:     now.Add(-5 * time.Second),        // Started 5 seconds ago\n\t\tLastActivity:  now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)\n\t\tVideoDuration: 1800.0,                           // 30 minute video - much longer than elapsed time\n\t}\n\n\tassert.Len(t, tracker.sessions, 1)\n\n\t// Session should expire because timeSinceActivity > timeout\n\t// Even though the video is 30 minutes and only 5 seconds have passed\n\ttracker.processExpiredSessions()\n\n\t// Verify session was expired\n\tassert.Len(t, tracker.sessions, 0, \"Session should expire when user stops early, not wait for video duration\")\n}\n\nfunc TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) {\n\t// Test the threshold logic without full transaction integration\n\tconfig := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold\n\n\ttracker := &ActivityTracker{\n\t\ttxnManager:     nil,\n\t\tsceneWriter:    nil,\n\t\tconfig:         config,\n\t\tsessionTimeout: 50 * time.Millisecond,\n\t\tsessions:       make(map[string]*streamSession),\n\t}\n\n\t// Test that getMinimumPlayPercent returns the configured value\n\tassert.Equal(t, 75, tracker.getMinimumPlayPercent())\n\n\t// Create a session with 30% watched (36 seconds of a 120 second video)\n\tnow := time.Now()\n\tsession := &streamSession{\n\t\tSceneID:       42,\n\t\tStartTime:     now.Add(-36 * time.Second),\n\t\tLastActivity:  now,\n\t\tVideoDuration: 120.0,\n\t}\n\n\t// 30% is below 75% threshold\n\tpercentWatched := session.percentWatched()\n\tassert.InDelta(t, 30.0, percentWatched, 0.1)\n\tassert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent()))\n}\n\nfunc TestActivityTracker_MultipleSessions(t *testing.T) {\n\tconfig := &mockConfig{enabled: true, minPlayPercent: 50}\n\n\t// Create tracker without starting the goroutine (for unit testing)\n\ttracker := &ActivityTracker{\n\t\ttxnManager:     nil,\n\t\tsceneWriter:    nil,\n\t\tconfig:         config,\n\t\tsessionTimeout: DefaultSessionTimeout,\n\t\tsessions:       make(map[string]*streamSession),\n\t}\n\n\t// Different clients watching same scene\n\ttracker.RecordRequest(42, \"192.168.1.100\", 120.0)\n\ttracker.RecordRequest(42, \"192.168.1.101\", 120.0)\n\n\t// Same client watching different scenes\n\ttracker.RecordRequest(43, \"192.168.1.100\", 180.0)\n\n\ttracker.mutex.RLock()\n\tassert.Len(t, tracker.sessions, 3)\n\ttracker.mutex.RUnlock()\n}\n\nfunc TestActivityTracker_ShortSessionIgnored(t *testing.T) {\n\t// Test that short sessions are ignored\n\t// Create a session with only ~0.8% watched (1 second of a 120 second video)\n\tnow := time.Now()\n\tsession := &streamSession{\n\t\tSceneID:       42,\n\t\tClientIP:      \"192.168.1.100\",\n\t\tStartTime:     now.Add(-1 * time.Second), // Only 1 second\n\t\tLastActivity:  now,\n\t\tVideoDuration: 120.0, // 2 minutes\n\t}\n\n\t// Verify percent watched is below threshold (1s / 120s = 0.83%)\n\tassert.InDelta(t, 0.83, session.percentWatched(), 0.1)\n\n\t// Verify elapsed time is short\n\telapsed := session.LastActivity.Sub(session.StartTime).Seconds()\n\tassert.InDelta(t, 1.0, elapsed, 0.5)\n\n\t// Both are below the minimum thresholds (1% and 5 seconds)\n\tpercentWatched := session.percentWatched()\n\tshouldSkip := percentWatched < 1 && elapsed < 5\n\tassert.True(t, shouldSkip, \"Short session should be skipped\")\n}\n"
  },
  {
    "path": "internal/dlna/cd-service-desc.go",
    "content": "package dlna\n\n// From: https://github.com/anacrolix/dms\n// Copyright (c) 2012, Matt Joiner <anacrolix@gmail.com>.\n// All rights reserved.\n\n// Redistribution and use in source and binary forms, with or without\n// modification, are permitted provided that the following conditions are met:\n//     * Redistributions of source code must retain the above copyright\n//       notice, this list of conditions and the following disclaimer.\n//     * 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.\n//     * Neither the name of the <organization> nor the\n//       names of its contributors may be used to endorse or promote products\n//       derived from this software without specific prior written permission.\n\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\n// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\n// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n// DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY\n// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\n// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND\n// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\n// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nconst contentDirectoryServiceDescription = `<?xml version=\"1.0\"?>\n<scpd xmlns=\"urn:schemas-upnp-org:service-1-0\">\n  <specVersion>\n    <major>1</major>\n    <minor>0</minor>\n  </specVersion>\n  <actionList>\n    <action>\n      <name>GetSearchCapabilities</name>\n      <argumentList>\n        <argument>\n          <name>SearchCaps</name>\n          <direction>out</direction>\n          <relatedStateVariable>SearchCapabilities</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>GetSortCapabilities</name>\n      <argumentList>\n        <argument>\n          <name>SortCaps</name>\n          <direction>out</direction>\n          <relatedStateVariable>SortCapabilities</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>GetSortExtensionCapabilities</name>\n      <argumentList>\n        <argument>\n          <name>SortExtensionCaps</name>\n          <direction>out</direction>\n          <relatedStateVariable>SortExtensionCapabilities</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>GetFeatureList</name>\n      <argumentList>\n        <argument>\n          <name>FeatureList</name>\n          <direction>out</direction>\n          <relatedStateVariable>FeatureList</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>GetSystemUpdateID</name>\n      <argumentList>\n        <argument>\n          <name>Id</name>\n          <direction>out</direction>\n          <relatedStateVariable>SystemUpdateID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>Browse</name>\n      <argumentList>\n        <argument>\n          <name>ObjectID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>BrowseFlag</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Filter</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>StartingIndex</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>RequestedCount</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>SortCriteria</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Result</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>NumberReturned</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>TotalMatches</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>UpdateID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>Search</name>\n      <argumentList>\n        <argument>\n          <name>ContainerID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>SearchCriteria</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_SearchCriteria</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Filter</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>StartingIndex</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>RequestedCount</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>SortCriteria</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Result</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>NumberReturned</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>TotalMatches</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>UpdateID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>CreateObject</name>\n      <argumentList>\n        <argument>\n          <name>ContainerID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Elements</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>ObjectID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Result</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>DestroyObject</name>\n      <argumentList>\n        <argument>\n          <name>ObjectID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>UpdateObject</name>\n      <argumentList>\n        <argument>\n          <name>ObjectID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>CurrentTagValue</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_TagValueList</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>NewTagValue</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_TagValueList</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>MoveObject</name>\n      <argumentList>\n        <argument>\n          <name>ObjectID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>NewParentID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>NewObjectID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>ImportResource</name>\n      <argumentList>\n        <argument>\n          <name>SourceURI</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>DestinationURI</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>TransferID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_TransferID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>ExportResource</name>\n      <argumentList>\n        <argument>\n          <name>SourceURI</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>DestinationURI</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>TransferID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_TransferID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>StopTransferResource</name>\n      <argumentList>\n        <argument>\n          <name>TransferID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_TransferID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>DeleteResource</name>\n      <argumentList>\n        <argument>\n          <name>ResourceURI</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_URI</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>GetTransferProgress</name>\n      <argumentList>\n        <argument>\n          <name>TransferID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_TransferID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>TransferStatus</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_TransferStatus</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>TransferLength</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_TransferLength</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>TransferTotal</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_TransferTotal</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>CreateReference</name>\n      <argumentList>\n        <argument>\n          <name>ContainerID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>ObjectID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>NewID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n  </actionList>\n  <serviceStateTable>\n    <stateVariable sendEvents=\"no\">\n      <name>SearchCapabilities</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>SortCapabilities</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>SortExtensionCapabilities</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"yes\">\n      <name>SystemUpdateID</name>\n      <dataType>ui4</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"yes\">\n      <name>ContainerUpdateIDs</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"yes\">\n      <name>TransferIDs</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>FeatureList</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_ObjectID</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_Result</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_SearchCriteria</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_BrowseFlag</name>\n      <dataType>string</dataType>\n      <allowedValueList>\n        <allowedValue>BrowseMetadata</allowedValue>\n        <allowedValue>BrowseDirectChildren</allowedValue>\n      </allowedValueList>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_Filter</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_SortCriteria</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_Index</name>\n      <dataType>ui4</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_Count</name>\n      <dataType>ui4</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_UpdateID</name>\n      <dataType>ui4</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_TransferID</name>\n      <dataType>ui4</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_TransferStatus</name>\n      <dataType>string</dataType>\n      <allowedValueList>\n        <allowedValue>COMPLETED</allowedValue>\n        <allowedValue>ERROR</allowedValue>\n        <allowedValue>IN_PROGRESS</allowedValue>\n        <allowedValue>STOPPED</allowedValue>\n      </allowedValueList>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_TransferLength</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_TransferTotal</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_TagValueList</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_URI</name>\n      <dataType>uri</dataType>\n    </stateVariable>\n  </serviceStateTable>\n</scpd>`\n"
  },
  {
    "path": "internal/dlna/cds.go",
    "content": "package dlna\n\n// from https://github.com/rclone/rclone\n// Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/\n\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// 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 FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n// THE SOFTWARE.\n\nimport (\n\t\"context\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/anacrolix/dms/dlna\"\n\t\"github.com/anacrolix/dms/upnp\"\n\t\"github.com/anacrolix/dms/upnpav\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\nvar pageSize = 100\n\ntype browse struct {\n\tObjectID       string\n\tBrowseFlag     string\n\tFilter         string\n\tStartingIndex  int\n\tRequestedCount int\n}\n\ntype contentDirectoryService struct {\n\t*Server\n\tupnp.Eventing\n}\n\nfunc formatDurationSexagesimal(d time.Duration) string {\n\tns := d % time.Second\n\td /= time.Second\n\ts := d % 60\n\td /= 60\n\tm := d % 60\n\td /= 60\n\th := d\n\tret := fmt.Sprintf(\"%d:%02d:%02d.%09d\", h, m, s, ns)\n\tret = strings.TrimRight(ret, \"0\")\n\tret = strings.TrimRight(ret, \".\")\n\treturn ret\n}\n\nfunc (me *contentDirectoryService) updateIDString() string {\n\treturn fmt.Sprintf(\"%d\", uint32(os.Getpid()))\n}\n\nfunc sceneToContainer(scene *models.Scene, parent string, host string) interface{} {\n\t// make stash server URL\n\t// TODO - fix this\n\ticonURI := (&url.URL{\n\t\tScheme: \"http\",\n\t\tHost:   host,\n\t\tPath:   iconPath,\n\t\tRawQuery: url.Values{\n\t\t\t\"scene\": {strconv.Itoa(scene.ID)},\n\t\t}.Encode(),\n\t}).String()\n\n\t// Object goes first\n\tobj := upnpav.Object{\n\t\tID:          strconv.Itoa(scene.ID),\n\t\tRestricted:  1,\n\t\tParentID:    parent,\n\t\tTitle:       scene.GetTitle(),\n\t\tClass:       \"object.item.videoItem\",\n\t\tIcon:        iconURI,\n\t\tAlbumArtURI: iconURI,\n\t}\n\n\t// Wrap up\n\titem := upnpav.Item{\n\t\tObject: obj,\n\t\tRes:    make([]upnpav.Resource, 0, 1),\n\t}\n\n\tmimeType := \"video/mp4\"\n\tvar (\n\t\tsize     int\n\t\tbitrate  uint\n\t\tduration int64\n\t)\n\n\tf := scene.Files.Primary()\n\tif f != nil {\n\t\tsize = int(f.Size)\n\t\tbitrate = uint(f.BitRate)\n\t\tduration = int64(f.Duration)\n\t}\n\n\titem.Res = append(item.Res, upnpav.Resource{\n\t\tURL: (&url.URL{\n\t\t\tScheme: \"http\",\n\t\t\tHost:   host,\n\t\t\tPath:   resPath,\n\t\t\tRawQuery: url.Values{\n\t\t\t\t\"scene\": {strconv.Itoa(scene.ID)},\n\t\t\t}.Encode(),\n\t\t}).String(),\n\t\tProtocolInfo: fmt.Sprintf(\"http-get:*:%s:%s\", mimeType, dlna.ContentFeatures{\n\t\t\tSupportRange: true,\n\t\t}.String()),\n\t\tBitrate:  bitrate,\n\t\tDuration: formatDurationSexagesimal(time.Duration(duration) * time.Second),\n\t\tSize:     uint64(size),\n\t\t// Resolution: resolution,\n\t})\n\n\titem.Res = append(item.Res, upnpav.Resource{\n\t\tURL:          iconURI,\n\t\tProtocolInfo: \"http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_MED\",\n\t})\n\n\treturn item\n}\n\n// ContentDirectory object from ObjectID.\nfunc (me *contentDirectoryService) objectFromID(id string) (o object, err error) {\n\to.Path, err = url.QueryUnescape(id)\n\tif err != nil {\n\t\treturn\n\t}\n\tif o.Path == \"0\" {\n\t\to.Path = \"/\"\n\t}\n\t// o.Path = path.Clean(o.Path)\n\t// if !path.IsAbs(o.Path) {\n\t// \terr = fmt.Errorf(\"bad ObjectID %v\", o.Path)\n\t// \treturn\n\t// }\n\to.RootObjectPath = me.RootObjectPath\n\n\treturn\n}\n\nfunc childPath(paths []string) []string {\n\tif len(paths) > 1 {\n\t\treturn paths[1:]\n\t}\n\n\treturn nil\n}\n\nfunc (me *contentDirectoryService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {\n\thost := r.Host\n\t// userAgent := r.UserAgent()\n\tswitch action {\n\tcase \"GetSystemUpdateID\":\n\t\treturn map[string]string{\n\t\t\t\"Id\": me.updateIDString(),\n\t\t}, nil\n\tcase \"GetSortCapabilities\":\n\t\treturn map[string]string{\n\t\t\t\"SortCaps\": \"dc:title\",\n\t\t}, nil\n\tcase \"Browse\":\n\t\tvar browse browse\n\t\tif err := xml.Unmarshal([]byte(argsXML), &browse); err != nil {\n\t\t\treturn nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, \"cannot unmarshal browse argument: %s\", err.Error())\n\t\t}\n\n\t\tobj, err := me.objectFromID(browse.ObjectID)\n\t\tif err != nil {\n\t\t\treturn nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, \"cannot find object with id %q: %v\", browse.ObjectID, err.Error())\n\t\t}\n\n\t\tswitch browse.BrowseFlag {\n\t\tcase \"BrowseDirectChildren\":\n\t\t\treturn me.handleBrowseDirectChildren(obj, host)\n\t\tcase \"BrowseMetadata\":\n\t\t\treturn me.handleBrowseMetadata(obj, host)\n\t\tdefault:\n\t\t\treturn nil, upnp.Errorf(upnp.ArgumentValueInvalidErrorCode, \"unhandled browse flag: %v\", browse.BrowseFlag)\n\t\t}\n\tcase \"GetSearchCapabilities\":\n\t\treturn map[string]string{\n\t\t\t\"SearchCaps\": \"\",\n\t\t}, nil\n\t// from https://github.com/rclone/rclone/blob/master/cmd/serve/dlna/cds.go\n\t// Samsung Extensions\n\tcase \"X_GetFeatureList\":\n\t\treturn map[string]string{\n\t\t\t\"FeatureList\": `<Features xmlns=\"urn:schemas-upnp-org:av:avs\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"urn:schemas-upnp-org:av:avs http://www.upnp.org/schemas/av/avs.xsd\">\n\t<Feature name=\"samsung.com_BASICVIEW\" version=\"1\">\n\t\t<container id=\"0\" type=\"object.item.imageItem\"/>\n\t\t<container id=\"0\" type=\"object.item.audioItem\"/>\n\t\t<container id=\"0\" type=\"object.item.videoItem\"/>\n\t</Feature>\n\t</Features>`}, nil\n\tcase \"X_SetBookmark\":\n\t\t// just ignore\n\t\treturn map[string]string{}, nil\n\tdefault:\n\t\treturn nil, upnp.InvalidActionError\n\t}\n}\n\nfunc (me *contentDirectoryService) handleBrowseDirectChildren(obj object, host string) (map[string]string, error) {\n\t// Read folder and return children\n\t// TODO: check if obj == 0 and return root objects\n\t// TODO: check if special path and return files\n\n\tvar objs []interface{}\n\n\tif obj.IsRoot() {\n\t\tobjs = getRootObjects()\n\t}\n\n\tpaths := strings.Split(obj.Path, \"/\")\n\n\t// All videos\n\tif obj.Path == \"all\" {\n\t\tobjs = me.getAllScenes(host)\n\t}\n\n\tif strings.HasPrefix(obj.Path, \"all/\") {\n\t\tpage := getPageFromID(paths)\n\t\tif page != nil {\n\t\t\tobjs = me.getPageVideos(&models.SceneFilterType{}, \"all\", *page, host)\n\t\t}\n\t}\n\n\t// Saved searches\n\t// if obj.Path == \"saved-searches\" {\n\t// \tvar savedPlaylists []models.Playlist\n\t// \tdb, _ := models.GetDB()\n\t// \tdb.Where(\"is_deo_enabled = ?\", true).Order(\"ordering asc\").Find(&savedPlaylists)\n\t// \tdb.Close()\n\n\t// \tfor _, playlist := range savedPlaylists {\n\t// \t\tobjs = append(objs, upnpav.Container{Object: upnpav.Object{\n\t// \t\t\tID:         \"saved-searches/\" + strconv.Itoa(int(playlist.ID)),\n\t// \t\t\tRestricted: 1,\n\t// \t\t\tParentID:   \"saved-searches\",\n\t// \t\t\tClass:      \"object.container.storageFolder\",\n\t// \t\t\tTitle:      playlist.Name,\n\t// \t\t}})\n\t// \t}\n\t// }\n\n\t// if strings.HasPrefix(obj.Path, \"saved-searches/\") {\n\t// \tid := strings.Split(obj.Path, \"/\")\n\n\t// \tvar savedPlaylist models.Playlist\n\t// \tdb, _ := models.GetDB()\n\t// \tdb.Where(\"id = ?\", id[1]).First(&savedPlaylist)\n\t// \tdb.Close()\n\n\t// \tvar r models.RequestSceneList\n\t// \tif err := json.Unmarshal([]byte(savedPlaylist.SearchParams), &r); err == nil {\n\t// \t\tr.IsAccessible = optional.NewBool(true)\n\t// \t\tr.IsAvailable = optional.NewBool(true)\n\t// \t\tdata := models.QueryScenesFull(r)\n\n\t// \t\tfor i := range data.Scenes {\n\t// \t\t\tobjs = append(objs, me.sceneToContainer(data.Scenes[i], \"sites/\"+id[1], host))\n\t// \t\t}\n\t// \t}\n\t// }\n\n\t// Studios\n\tif obj.Path == \"studios\" {\n\t\tobjs = me.getStudios()\n\t}\n\n\tif strings.HasPrefix(obj.Path, \"studios/\") {\n\t\tobjs = me.getStudioScenes(childPath(paths), host)\n\t}\n\n\t// Tags\n\tif obj.Path == \"tags\" {\n\t\tobjs = me.getTags()\n\t}\n\n\tif strings.HasPrefix(obj.Path, \"tags/\") {\n\t\tobjs = me.getTagScenes(childPath(paths), host)\n\t}\n\n\t// Performers\n\tif obj.Path == \"performers\" {\n\t\tobjs = me.getPerformers()\n\t}\n\n\tif strings.HasPrefix(obj.Path, \"performers/\") {\n\t\tobjs = me.getPerformerScenes(childPath(paths), host)\n\t}\n\n\t// Groups - deprecated\n\tif obj.Path == \"groups\" {\n\t\tobjs = me.getGroups()\n\t}\n\n\tif strings.HasPrefix(obj.Path, \"groups/\") {\n\t\tobjs = me.getGroupScenes(childPath(paths), host)\n\t}\n\n\t// Rating\n\tif obj.Path == \"rating\" {\n\t\tobjs = me.getRating()\n\t}\n\n\tif strings.HasPrefix(obj.Path, \"rating/\") {\n\t\tobjs = me.getRatingScenes(childPath(paths), host)\n\t}\n\n\treturn makeBrowseResult(objs, me.updateIDString())\n}\n\nfunc (me *contentDirectoryService) handleBrowseMetadata(obj object, host string) (map[string]string, error) {\n\tvar objs []interface{}\n\tvar updateID string\n\n\t// if numeric, then must be scene, otherwise handle as if path\n\tsceneID, err := strconv.Atoi(obj.Path)\n\tif err != nil {\n\t\t// #1465 - handle root object\n\t\tif obj.IsRoot() {\n\t\t\tobjs = getRootObject()\n\t\t} else {\n\t\t\t// HACK: just create a fake storage folder to return. The name won't\n\t\t\t// be correct, but hopefully the names returned from handleBrowseDirectChildren\n\t\t\t// will be used instead.\n\t\t\tobjs = []interface{}{makeStorageFolder(obj.ID(), obj.ID(), obj.ParentID())}\n\t\t}\n\n\t\tupdateID = me.updateIDString()\n\t} else {\n\t\tvar scene *models.Scene\n\n\t\tr := me.repository\n\t\tif err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error {\n\t\t\tscene, err = r.SceneFinder.Find(ctx, sceneID)\n\t\t\tif scene != nil {\n\t\t\t\terr = scene.LoadPrimaryFile(ctx, r.FileGetter)\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tlogger.Error(err.Error())\n\t\t}\n\n\t\tif scene != nil {\n\t\t\tupnpObject := sceneToContainer(scene, \"-1\", host)\n\t\t\tobjs = []interface{}{upnpObject}\n\n\t\t\t// http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf\n\t\t\t// maximum update ID is 2**32, then rolls back to 0\n\t\t\tconst maxUpdateID int64 = 1 << 32\n\t\t\tupdateID = fmt.Sprint(scene.UpdatedAt.Unix() % maxUpdateID)\n\t\t} else {\n\t\t\treturn nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, \"scene not found\")\n\t\t}\n\t}\n\n\treturn makeBrowseResult(objs, updateID)\n}\n\nfunc makeBrowseResult(objs []interface{}, updateID string) (map[string]string, error) {\n\tresult, err := xml.Marshal(objs)\n\tif err != nil {\n\t\treturn nil, upnp.Errorf(upnp.ActionFailedErrorCode, \"could not marshal objects: %s\", err.Error())\n\t}\n\n\treturn map[string]string{\n\t\t\"TotalMatches\":   fmt.Sprint(len(objs)),\n\t\t\"NumberReturned\": fmt.Sprint(len(objs)),\n\t\t\"Result\":         didl_lite(string(result)),\n\t\t\"UpdateID\":       updateID,\n\t}, nil\n}\n\nfunc makeStorageFolder(id, title, parentID string) upnpav.Container {\n\tdefaultChildCount := 1\n\treturn upnpav.Container{\n\t\tObject: upnpav.Object{\n\t\t\tID:         id,\n\t\t\tRestricted: 1,\n\t\t\tParentID:   parentID,\n\t\t\tClass:      \"object.container.storageFolder\",\n\t\t\tTitle:      title,\n\t\t},\n\t\tChildCount: defaultChildCount,\n\t}\n}\n\nfunc getRootObject() []interface{} {\n\tconst rootID = \"0\"\n\n\treturn []interface{}{makeStorageFolder(rootID, \"stash\", \"-1\")}\n}\n\nfunc getRootObjects() []interface{} {\n\tconst rootID = \"0\"\n\n\tvar objs []interface{}\n\n\tobjs = append(objs, makeStorageFolder(\"all\", \"all\", rootID))\n\tobjs = append(objs, makeStorageFolder(\"performers\", \"performers\", rootID))\n\tobjs = append(objs, makeStorageFolder(\"tags\", \"tags\", rootID))\n\tobjs = append(objs, makeStorageFolder(\"studios\", \"studios\", rootID))\n\tobjs = append(objs, makeStorageFolder(\"groups\", \"groups\", rootID))\n\tobjs = append(objs, makeStorageFolder(\"rating\", \"rating\", rootID))\n\n\treturn objs\n}\n\nfunc getSortDirection(sceneFilter *models.SceneFilterType, sort string) models.SortDirectionEnum {\n\tdirection := models.SortDirectionEnumDesc\n\tif sort == \"title\" {\n\t\tdirection = models.SortDirectionEnumAsc\n\t}\n\n\treturn direction\n}\n\nfunc (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType, parentID string, host string) []interface{} {\n\tvar objs []interface{}\n\n\tr := me.repository\n\tif err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error {\n\t\tsort := me.VideoSortOrder\n\t\tdirection := getSortDirection(sceneFilter, sort)\n\t\tfindFilter := &models.FindFilterType{\n\t\t\tPerPage:   &pageSize,\n\t\t\tSort:      &sort,\n\t\t\tDirection: &direction,\n\t\t}\n\n\t\tscenes, total, err := scene.QueryWithCount(ctx, r.SceneFinder, sceneFilter, findFilter)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif total > pageSize {\n\t\t\tpager := scenePager{\n\t\t\t\tsceneFilter: sceneFilter,\n\t\t\t\tparentID:    parentID,\n\t\t\t}\n\n\t\t\tobjs, err = pager.getPages(ctx, r.SceneFinder, total)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\tfor _, s := range scenes {\n\t\t\t\tif err := s.LoadPrimaryFile(ctx, r.FileGetter); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tobjs = append(objs, sceneToContainer(s, parentID, host))\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Error(err.Error())\n\t}\n\n\treturn objs\n}\n\nfunc (me *contentDirectoryService) getPageVideos(sceneFilter *models.SceneFilterType, parentID string, page int, host string) []interface{} {\n\tvar objs []interface{}\n\n\tr := me.repository\n\tif err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error {\n\t\tpager := scenePager{\n\t\t\tsceneFilter: sceneFilter,\n\t\t\tparentID:    parentID,\n\t\t}\n\n\t\tsort := me.VideoSortOrder\n\t\tdirection := getSortDirection(sceneFilter, sort)\n\t\tvar err error\n\t\tobjs, err = pager.getPageVideos(ctx, r.SceneFinder, r.FileGetter, page, host, sort, direction)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Error(err.Error())\n\t}\n\n\treturn objs\n}\n\nfunc getPageFromID(paths []string) *int {\n\ti := slices.Index(paths, \"page\")\n\tif i == -1 || i+1 >= len(paths) {\n\t\treturn nil\n\t}\n\n\tret, err := strconv.Atoi(paths[i+1])\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\treturn &ret\n}\n\nfunc (me *contentDirectoryService) getAllScenes(host string) []interface{} {\n\treturn me.getVideos(&models.SceneFilterType{}, \"all\", host)\n}\n\nfunc (me *contentDirectoryService) getStudios() []interface{} {\n\tvar objs []interface{}\n\n\tr := me.repository\n\tif err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error {\n\t\tstudios, err := r.StudioFinder.All(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range studios {\n\t\t\tobjs = append(objs, makeStorageFolder(\"studios/\"+strconv.Itoa(s.ID), s.Name, \"studios\"))\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Errorf(err.Error())\n\t}\n\n\treturn objs\n}\n\nfunc (me *contentDirectoryService) getStudioScenes(paths []string, host string) []interface{} {\n\tsceneFilter := &models.SceneFilterType{\n\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tValue:    []string{paths[0]},\n\t\t},\n\t}\n\n\tparentID := \"studios/\" + strings.Join(paths, \"/\")\n\n\tpage := getPageFromID(paths)\n\tif page != nil {\n\t\treturn me.getPageVideos(sceneFilter, parentID, *page, host)\n\t}\n\n\treturn me.getVideos(sceneFilter, parentID, host)\n}\n\nfunc (me *contentDirectoryService) getTags() []interface{} {\n\tvar objs []interface{}\n\n\tr := me.repository\n\tif err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error {\n\t\ttags, err := r.TagFinder.All(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range tags {\n\t\t\tobjs = append(objs, makeStorageFolder(\"tags/\"+strconv.Itoa(s.ID), s.Name, \"tags\"))\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Errorf(err.Error())\n\t}\n\n\treturn objs\n}\n\nfunc (me *contentDirectoryService) getTagScenes(paths []string, host string) []interface{} {\n\tsceneFilter := &models.SceneFilterType{\n\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tValue:    []string{paths[0]},\n\t\t},\n\t}\n\n\tparentID := \"tags/\" + strings.Join(paths, \"/\")\n\n\tpage := getPageFromID(paths)\n\tif page != nil {\n\t\treturn me.getPageVideos(sceneFilter, parentID, *page, host)\n\t}\n\n\treturn me.getVideos(sceneFilter, parentID, host)\n}\n\nfunc (me *contentDirectoryService) getPerformers() []interface{} {\n\tvar objs []interface{}\n\n\tr := me.repository\n\tif err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error {\n\t\tperformers, err := r.PerformerFinder.All(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range performers {\n\t\t\tobjs = append(objs, makeStorageFolder(\"performers/\"+strconv.Itoa(s.ID), s.Name, \"performers\"))\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Errorf(err.Error())\n\t}\n\n\treturn objs\n}\n\nfunc (me *contentDirectoryService) getPerformerScenes(paths []string, host string) []interface{} {\n\tsceneFilter := &models.SceneFilterType{\n\t\tPerformers: &models.MultiCriterionInput{\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tValue:    []string{paths[0]},\n\t\t},\n\t}\n\n\tparentID := \"performers/\" + strings.Join(paths, \"/\")\n\n\tpage := getPageFromID(paths)\n\tif page != nil {\n\t\treturn me.getPageVideos(sceneFilter, parentID, *page, host)\n\t}\n\n\treturn me.getVideos(sceneFilter, parentID, host)\n}\n\nfunc (me *contentDirectoryService) getGroups() []interface{} {\n\tvar objs []interface{}\n\n\tr := me.repository\n\tif err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error {\n\t\tgroups, err := r.GroupFinder.All(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range groups {\n\t\t\tobjs = append(objs, makeStorageFolder(\"groups/\"+strconv.Itoa(s.ID), s.Name, \"groups\"))\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Errorf(err.Error())\n\t}\n\n\treturn objs\n}\n\nfunc (me *contentDirectoryService) getGroupScenes(paths []string, host string) []interface{} {\n\tsceneFilter := &models.SceneFilterType{\n\t\tGroups: &models.HierarchicalMultiCriterionInput{\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tValue:    []string{paths[0]},\n\t\t},\n\t}\n\n\tparentID := \"groups/\" + strings.Join(paths, \"/\")\n\n\tpage := getPageFromID(paths)\n\tif page != nil {\n\t\treturn me.getPageVideos(sceneFilter, parentID, *page, host)\n\t}\n\n\treturn me.getVideos(sceneFilter, parentID, host)\n}\n\nfunc (me *contentDirectoryService) getRating() []interface{} {\n\tvar objs []interface{}\n\n\tfor r := 1; r <= 5; r++ {\n\t\trStr := strconv.Itoa(r)\n\t\tobjs = append(objs, makeStorageFolder(\"rating/\"+rStr, rStr, \"rating\"))\n\t}\n\n\treturn objs\n}\n\nfunc (me *contentDirectoryService) getRatingScenes(paths []string, host string) []interface{} {\n\tr, err := strconv.Atoi(paths[0])\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tsceneFilter := &models.SceneFilterType{\n\t\tRating100: &models.IntCriterionInput{\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\tValue:    models.Rating5To100(r),\n\t\t},\n\t}\n\n\tparentID := \"rating/\" + strings.Join(paths, \"/\")\n\n\tpage := getPageFromID(paths)\n\tif page != nil {\n\t\treturn me.getPageVideos(sceneFilter, parentID, *page, host)\n\t}\n\n\treturn me.getVideos(sceneFilter, parentID, host)\n}\n\n// Represents a ContentDirectory object.\ntype object struct {\n\tPath           string // The cleaned, absolute path for the object relative to the server.\n\tRootObjectPath string\n}\n\n// Returns the actual local filesystem path for the object.\nfunc (o *object) FilePath() string {\n\treturn filepath.Join(o.RootObjectPath, filepath.FromSlash(o.Path))\n}\n\n// Returns the ObjectID for the object. This is used in various ContentDirectory actions.\nfunc (o object) ID() string {\n\tif len(o.Path) == 1 {\n\t\treturn \"0\"\n\t}\n\treturn url.QueryEscape(o.Path)\n}\n\nfunc (o *object) IsRoot() bool {\n\treturn o.Path == \"/\"\n}\n\n// Returns the object's parent ObjectID. Fortunately it can be deduced from the\n// ObjectID (for now).\nfunc (o object) ParentID() string {\n\tif o.IsRoot() {\n\t\treturn \"-1\"\n\t}\n\to.Path = path.Dir(o.Path)\n\treturn o.ID()\n}\n"
  },
  {
    "path": "internal/dlna/cds_test.go",
    "content": "package dlna\n\n// From: https://github.com/anacrolix/dms\n// Copyright (c) 2012, Matt Joiner <anacrolix@gmail.com>.\n// All rights reserved.\n\n// Redistribution and use in source and binary forms, with or without\n// modification, are permitted provided that the following conditions are met:\n//     * Redistributions of source code must retain the above copyright\n//       notice, this list of conditions and the following disclaimer.\n//     * 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.\n//     * Neither the name of the <organization> nor the\n//       names of its contributors may be used to endorse or promote products\n//       derived from this software without specific prior written permission.\n\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\n// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\n// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n// DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY\n// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\n// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND\n// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\n// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestEscapeObjectID(t *testing.T) {\n\to := object{\n\t\tPath: \"/some/file\",\n\t}\n\tid := o.ID()\n\tif strings.ContainsAny(id, \"/\") {\n\t\tt.Skip(\"may not work with some players: object IDs contain '/'\")\n\t}\n}\n\nfunc TestRootObjectID(t *testing.T) {\n\tif (object{Path: \"/\"}).ID() != \"0\" {\n\t\tt.FailNow()\n\t}\n}\n\nfunc TestRootParentObjectID(t *testing.T) {\n\tif (object{Path: \"/\"}).ParentID() != \"-1\" {\n\t\tt.FailNow()\n\t}\n}\n\nfunc testHandleBrowse(argsXML string) (map[string]string, error) {\n\tcds := contentDirectoryService{\n\t\tServer: &Server{},\n\t}\n\n\tr := &http.Request{}\n\treturn cds.Handle(\"Browse\", []byte(argsXML), r)\n}\n\nfunc TestBrowseMetadataRoot(t *testing.T) {\n\targsXML := `<u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\"><ObjectID>0</ObjectID><BrowseFlag>BrowseMetadata</BrowseFlag><Filter>*</Filter><StartingIndex>0</StartingIndex><RequestedCount>0</RequestedCount><SortCriteria></SortCriteria></u:Browse>`\n\t_, err := testHandleBrowse(argsXML)\n\n\tassert.Nil(t, err)\n}\n\nfunc TestBrowseMetadataTags(t *testing.T) {\n\targsXML := `<u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\"><ObjectID>tags</ObjectID><BrowseFlag>BrowseMetadata</BrowseFlag><Filter>*</Filter><StartingIndex>0</StartingIndex><RequestedCount>0</RequestedCount><SortCriteria></SortCriteria></u:Browse>`\n\t_, err := testHandleBrowse(argsXML)\n\n\tassert.Nil(t, err)\n}\n"
  },
  {
    "path": "internal/dlna/cm-service-desc.go",
    "content": "package dlna\n\n// from https://github.com/rclone/rclone\n// Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/\n\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// 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 FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n// THE SOFTWARE.\n\nconst connectionManagerServiceDescription = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<scpd xmlns=\"urn:schemas-upnp-org:service-1-0\">\n  <specVersion>\n    <major>1</major>\n    <minor>0</minor>\n  </specVersion>\n  <actionList>\n    <action>\n      <name>GetProtocolInfo</name>\n      <argumentList>\n        <argument>\n          <name>Source</name>\n          <direction>out</direction>\n          <relatedStateVariable>SourceProtocolInfo</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Sink</name>\n          <direction>out</direction>\n          <relatedStateVariable>SinkProtocolInfo</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>PrepareForConnection</name>\n      <argumentList>\n        <argument>\n          <name>RemoteProtocolInfo</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>PeerConnectionManager</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>PeerConnectionID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Direction</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>ConnectionID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>AVTransportID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>RcsID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>ConnectionComplete</name>\n      <argumentList>\n        <argument>\n          <name>ConnectionID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>GetCurrentConnectionIDs</name>\n      <argumentList>\n        <argument>\n          <name>ConnectionIDs</name>\n          <direction>out</direction>\n          <relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n    <action>\n      <name>GetCurrentConnectionInfo</name>\n      <argumentList>\n        <argument>\n          <name>ConnectionID</name>\n          <direction>in</direction>\n          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>RcsID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>AVTransportID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>ProtocolInfo</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>PeerConnectionManager</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>PeerConnectionID</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Direction</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>\n        </argument>\n        <argument>\n          <name>Status</name>\n          <direction>out</direction>\n          <relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>\n        </argument>\n      </argumentList>\n    </action>\n  </actionList>\n  <serviceStateTable>\n    <stateVariable sendEvents=\"yes\">\n      <name>SourceProtocolInfo</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"yes\">\n      <name>SinkProtocolInfo</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"yes\">\n      <name>CurrentConnectionIDs</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_ConnectionStatus</name>\n      <dataType>string</dataType>\n      <allowedValueList>\n        <allowedValue>OK</allowedValue>\n        <allowedValue>ContentFormatMismatch</allowedValue>\n        <allowedValue>InsufficientBandwidth</allowedValue>\n        <allowedValue>UnreliableChannel</allowedValue>\n        <allowedValue>Unknown</allowedValue>\n      </allowedValueList>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_ConnectionManager</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_Direction</name>\n      <dataType>string</dataType>\n      <allowedValueList>\n        <allowedValue>Input</allowedValue>\n        <allowedValue>Output</allowedValue>\n      </allowedValueList>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_ProtocolInfo</name>\n      <dataType>string</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_ConnectionID</name>\n      <dataType>i4</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_AVTransportID</name>\n      <dataType>i4</dataType>\n    </stateVariable>\n    <stateVariable sendEvents=\"no\">\n      <name>A_ARG_TYPE_RcsID</name>\n      <dataType>i4</dataType>\n    </stateVariable>\n  </serviceStateTable>\n</scpd>`\n"
  },
  {
    "path": "internal/dlna/cms.go",
    "content": "package dlna\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/anacrolix/dms/upnp\"\n)\n\n// from https://github.com/rclone/rclone\n// Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/\n\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// 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 FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n// THE SOFTWARE.\n\nconst defaultProtocolInfo = \"http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*,http-get:*:image/avif:*\"\n\ntype connectionManagerService struct {\n\t*Server\n\tupnp.Eventing\n}\n\nfunc (cms *connectionManagerService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {\n\tswitch action {\n\tcase \"GetProtocolInfo\":\n\t\treturn map[string]string{\n\t\t\t\"Source\": defaultProtocolInfo,\n\t\t\t\"Sink\":   \"\",\n\t\t}, nil\n\tdefault:\n\t\treturn nil, upnp.InvalidActionError\n\t}\n}\n"
  },
  {
    "path": "internal/dlna/dms.go",
    "content": "package dlna\n\n// Derived from: https://github.com/anacrolix/dms\n// Copyright (c) 2012, Matt Joiner <anacrolix@gmail.com>.\n// All rights reserved.\n\n// Redistribution and use in source and binary forms, with or without\n// modification, are permitted provided that the following conditions are met:\n//     * Redistributions of source code must retain the above copyright\n//       notice, this list of conditions and the following disclaimer.\n//     * 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.\n//     * Neither the name of the <organization> nor the\n//       names of its contributors may be used to endorse or promote products\n//       derived from this software without specific prior written permission.\n\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\n// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\n// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n// DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY\n// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\n// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND\n// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\n// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/md5\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/pprof\"\n\t\"net/url\"\n\t\"path\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/anacrolix/dms/soap\"\n\t\"github.com/anacrolix/dms/ssdp\"\n\t\"github.com/anacrolix/dms/upnp\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype SceneFinder interface {\n\tmodels.SceneGetter\n\tmodels.SceneQueryer\n}\n\ntype StudioFinder interface {\n\tAll(ctx context.Context) ([]*models.Studio, error)\n}\n\ntype TagFinder interface {\n\tAll(ctx context.Context) ([]*models.Tag, error)\n}\n\ntype PerformerFinder interface {\n\tAll(ctx context.Context) ([]*models.Performer, error)\n}\n\ntype GroupFinder interface {\n\tAll(ctx context.Context) ([]*models.Group, error)\n}\n\nconst (\n\tserverField                 = \"Linux/3.4 DLNADOC/1.50 UPnP/1.0 DMS/1.0\"\n\trootDeviceType              = \"urn:schemas-upnp-org:device:MediaServer:1\"\n\trootDeviceModelName         = \"dms 1.0xb\"\n\tresPath                     = \"/res\"\n\ticonPath                    = \"/icon\"\n\trootDescPath                = \"/rootDesc.xml\"\n\tcontentDirectoryEventSubURL = \"/evt/ContentDirectory\"\n\tserviceControlURL           = \"/ctl\"\n\tdeviceIconPath              = \"/deviceIcon\"\n)\n\nfunc makeDeviceUuid(unique string) string {\n\th := md5.New()\n\tif _, err := io.WriteString(h, unique); err != nil {\n\t\tpanic(\"makeDeviceUuid write failed: \" + err.Error())\n\t}\n\tbuf := h.Sum(nil)\n\treturn upnp.FormatUUID(buf)\n}\n\n// Groups the service definition with its XML description.\ntype service struct {\n\tupnp.Service\n\tSCPD string\n}\n\n// Exposed UPnP AV services.\nvar services = []*service{\n\t{\n\t\tService: upnp.Service{\n\t\t\tServiceType: \"urn:schemas-upnp-org:service:ContentDirectory:1\",\n\t\t\tServiceId:   \"urn:upnp-org:serviceId:ContentDirectory\",\n\t\t\tEventSubURL: contentDirectoryEventSubURL,\n\t\t\tControlURL:  serviceControlURL,\n\t\t},\n\t\tSCPD: contentDirectoryServiceDescription,\n\t},\n\t{\n\t\tService: upnp.Service{\n\t\t\tServiceType: \"urn:schemas-upnp-org:service:ConnectionManager:1\",\n\t\t\tServiceId:   \"urn:upnp-org:serviceId:ConnectionManager\",\n\t\t\tControlURL:  serviceControlURL,\n\t\t},\n\t\tSCPD: connectionManagerServiceDescription,\n\t},\n\t{\n\t\tService: upnp.Service{\n\t\t\tServiceType: \"urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1\",\n\t\t\tServiceId:   \"urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar\",\n\t\t\tControlURL:  serviceControlURL,\n\t\t},\n\t\tSCPD: xmsMediaReceiverServiceDescription,\n\t},\n}\n\nfunc init() {\n\tfor _, s := range services {\n\t\tp := path.Join(\"/scpd\", s.ServiceId)\n\t\ts.SCPDURL = p\n\t}\n}\n\nfunc devices() []string {\n\treturn []string{\n\t\t\"urn:schemas-upnp-org:device:MediaServer:1\",\n\t}\n}\n\nfunc serviceTypes() (ret []string) {\n\tfor _, s := range services {\n\t\tret = append(ret, s.ServiceType)\n\t}\n\treturn\n}\nfunc (me *Server) httpPort() int {\n\treturn me.HTTPConn.Addr().(*net.TCPAddr).Port\n}\n\nfunc (me *Server) serveHTTP() error {\n\tsrv := &http.Server{\n\t\tHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif me.LogHeaders {\n\t\t\t\tlogger.Debugf(\"%s %s\", r.Method, r.RequestURI)\n\t\t\t\tfor k, v := range r.Header {\n\t\t\t\t\tlogger.Debugf(\"%s: %s\", k, v)\n\t\t\t\t}\n\t\t\t}\n\t\t\tw.Header().Set(\"Ext\", \"\")\n\t\t\tw.Header().Set(\"Server\", serverField)\n\t\t\tme.httpServeMux.ServeHTTP(&mitmRespWriter{\n\t\t\t\tResponseWriter: w,\n\t\t\t\tlogHeader:      me.LogHeaders,\n\t\t\t}, r)\n\t\t}),\n\t}\n\terr := srv.Serve(me.HTTPConn)\n\tselect {\n\tcase <-me.closed:\n\t\treturn nil\n\tdefault:\n\t\treturn err\n\t}\n}\n\n// An interface with these flags should be valid for SSDP.\nconst ssdpInterfaceFlags = net.FlagUp | net.FlagMulticast\n\nfunc (me *Server) doSSDP() {\n\tactive := 0\n\tstopped := make(chan struct{})\n\tfor _, if_ := range me.Interfaces {\n\t\tactive++\n\t\tgo func(if_ net.Interface) {\n\t\t\tdefer func() {\n\t\t\t\tstopped <- struct{}{}\n\t\t\t}()\n\t\t\tme.ssdpInterface(if_)\n\t\t}(if_)\n\t}\n\tfor active > 0 {\n\t\t<-stopped\n\t\tactive--\n\t}\n}\n\n// Run SSDP server on an interface.\nfunc (me *Server) ssdpInterface(if_ net.Interface) {\n\ts := ssdp.Server{\n\t\tInterface: if_,\n\t\tDevices:   devices(),\n\t\tServices:  serviceTypes(),\n\t\tLocation: func(ip net.IP) string {\n\t\t\treturn me.location(ip)\n\t\t},\n\t\tServer:         serverField,\n\t\tUUID:           me.rootDeviceUUID,\n\t\tNotifyInterval: me.NotifyInterval,\n\t}\n\tif err := s.Init(); err != nil {\n\t\tif if_.Flags&ssdpInterfaceFlags != ssdpInterfaceFlags {\n\t\t\t// Didn't expect it to work anyway.\n\t\t\treturn\n\t\t}\n\t\tif strings.Contains(err.Error(), \"listen\") {\n\t\t\t// OSX has a lot of dud interfaces. Failure to create a socket on\n\t\t\t// the interface are what we're expecting if the interface is no\n\t\t\t// good.\n\t\t\treturn\n\t\t}\n\t\tlogger.Errorf(\"error creating ssdp server on %s: %s\", if_.Name, err)\n\t\treturn\n\t}\n\tdefer s.Close()\n\tlogger.Debugf(\"started SSDP on %s\", if_.Name)\n\tstopped := make(chan struct{})\n\tgo func() {\n\t\tdefer close(stopped)\n\t\t// FIXME - this currently blocks forever unless it encounters an error\n\t\t// See https://github.com/anacrolix/dms/pull/150\n\t\t// Needs to be fixed upstream\n\t\t//nolint:staticcheck\n\t\tif err := s.Serve(); err != nil {\n\t\t\tlogger.Errorf(\"%q: %q\\n\", if_.Name, err)\n\t\t}\n\t}()\n\tselect {\n\tcase <-me.closed:\n\t\t// Returning will close the server.\n\tcase <-stopped:\n\t}\n}\n\nvar (\n\tstartTime time.Time\n)\n\ntype Icon struct {\n\tWidth, Height, Depth int\n\tMimetype             string\n\tio.ReadSeeker\n}\n\ntype Server struct {\n\tHTTPConn       net.Listener\n\tFriendlyName   string\n\tInterfaces     []net.Interface\n\thttpServeMux   *http.ServeMux\n\tRootObjectPath string\n\trootDescXML    []byte\n\trootDeviceUUID string\n\tclosed         chan struct{}\n\tssdpStopped    chan struct{}\n\t// The service SOAP handler keyed by service URN.\n\tservices   map[string]UPnPService\n\tLogHeaders bool\n\tIcons      []Icon\n\t// Stall event subscription requests until they drop. A workaround for\n\t// some bad clients.\n\tStallEventSubscribe bool\n\t// Time interval between SSPD announces\n\tNotifyInterval time.Duration\n\n\trepository         Repository\n\tsceneServer        sceneServer\n\tipWhitelistManager *ipWhitelistManager\n\tactivityTracker    *ActivityTracker\n\tVideoSortOrder     string\n\n\tsubscribeLock sync.Mutex\n}\n\n// UPnP SOAP service.\ntype UPnPService interface {\n\tHandle(action string, argsXML []byte, r *http.Request) (respArgs map[string]string, err error)\n\tSubscribe(callback []*url.URL, timeoutSeconds int) (sid string, actualTimeout int, err error)\n\tUnsubscribe(sid string) error\n}\n\ntype Cache interface {\n\tSet(key interface{}, value interface{})\n\tGet(key interface{}) (value interface{}, ok bool)\n}\n\nfunc init() {\n\tstartTime = time.Now()\n}\n\nfunc xmlMarshalOrPanic(value interface{}) []byte {\n\tret, err := xml.MarshalIndent(value, \"\", \"  \")\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"xmlMarshalOrPanic failed to marshal %v: %s\", value, err))\n\t}\n\treturn ret\n}\n\n// TODO: Document the use of this for debugging.\ntype mitmRespWriter struct {\n\thttp.ResponseWriter\n\tloggedHeader bool\n\tlogHeader    bool\n}\n\nfunc (me *mitmRespWriter) WriteHeader(code int) {\n\tme.doLogHeader(code)\n\tme.ResponseWriter.WriteHeader(code)\n}\n\nfunc (me *mitmRespWriter) doLogHeader(code int) {\n\tif !me.logHeader {\n\t\treturn\n\t}\n\tlogger.Debugf(\"Response: %d\", code)\n\tfor k, v := range me.Header() {\n\t\tlogger.Debugf(\"%s: %s\", k, v)\n\t}\n\tme.loggedHeader = true\n}\n\nfunc (me *mitmRespWriter) Write(b []byte) (int, error) {\n\tif !me.loggedHeader {\n\t\tme.doLogHeader(200)\n\t}\n\treturn me.ResponseWriter.Write(b)\n}\n\n// Deprecated: the CloseNotifier interface predates Go's context package.\n// New code should use Request.Context instead.\nfunc (me *mitmRespWriter) CloseNotify() <-chan bool {\n\treturn me.ResponseWriter.(http.CloseNotifier).CloseNotify()\n}\n\n// Set the SCPD serve paths.\nfunc init() {\n\tfor _, s := range services {\n\t\tp := path.Join(\"/scpd\", s.ServiceId)\n\t\ts.SCPDURL = p\n\t}\n}\n\n// Install handlers to serve SCPD for each UPnP service.\nfunc handleSCPDs(mux *http.ServeMux) {\n\tfor _, s := range services {\n\t\tmux.HandleFunc(s.SCPDURL, func(serviceDesc string) http.HandlerFunc {\n\t\t\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"content-type\", `text/xml; charset=\"utf-8\"`)\n\t\t\t\thttp.ServeContent(w, r, \".xml\", startTime, bytes.NewReader([]byte(serviceDesc)))\n\t\t\t}\n\t\t}(s.SCPD))\n\t}\n}\n\n// Marshal SOAP response arguments into a response XML snippet.\nfunc marshalSOAPResponse(sa upnp.SoapAction, args map[string]string) []byte {\n\tsoapArgs := make([]soap.Arg, 0, len(args))\n\tfor argName, value := range args {\n\t\tsoapArgs = append(soapArgs, soap.Arg{\n\t\t\tXMLName: xml.Name{Local: argName},\n\t\t\tValue:   value,\n\t\t})\n\t}\n\treturn []byte(fmt.Sprintf(`<u:%[1]sResponse xmlns:u=\"%[2]s\">%[3]s</u:%[1]sResponse>`, sa.Action, sa.ServiceURN.String(), xmlMarshalOrPanic(soapArgs)))\n}\n\n// Handle a SOAP request and return the response arguments or UPnP error.\nfunc (me *Server) soapActionResponse(sa upnp.SoapAction, actionRequestXML []byte, r *http.Request) (map[string]string, error) {\n\tservice, ok := me.services[sa.Type]\n\tif !ok {\n\t\t// TODO: What's the invalid service error?!\n\t\treturn nil, upnp.Errorf(upnp.InvalidActionErrorCode, \"Invalid service: %s\", sa.Type)\n\t}\n\n\tlogger.Tracef(\"%s::Handle %s - %s\", sa.Type, sa.Action, actionRequestXML)\n\tret, err := service.Handle(sa.Action, actionRequestXML, r)\n\tif err == nil {\n\t\tlogger.Tracef(\"< %v\", ret)\n\t}\n\n\treturn ret, err\n}\n\n// Handle a service control HTTP request.\nfunc (me *Server) serviceControlHandler(w http.ResponseWriter, r *http.Request) {\n\tclientIp, _, _ := net.SplitHostPort(r.RemoteAddr)\n\n\tip := net.ParseIP(clientIp).String()\n\tif !me.ipWhitelistManager.ipAllowed(ip) {\n\t\t// only log if we haven't seen it\n\t\tif !me.ipWhitelistManager.addRecent(ip) {\n\t\t\tlogger.Infof(\"not allowed client %s\", clientIp)\n\t\t}\n\n\t\thttp.Error(w, \"forbidden\", http.StatusForbidden)\n\t\treturn\n\t}\n\n\tsoapActionString := r.Header.Get(\"SOAPACTION\")\n\tsoapAction, err := upnp.ParseActionHTTPHeader(soapActionString)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\tvar env soap.Envelope\n\tif err := xml.NewDecoder(r.Body).Decode(&env); err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\t// AwoX/1.1 UPnP/1.0 DLNADOC/1.50\n\tw.Header().Set(\"Content-Type\", `text/xml; charset=\"utf-8\"`)\n\tw.Header().Set(\"Ext\", \"\")\n\tw.Header().Set(\"Server\", serverField)\n\tsoapRespXML, code := func() ([]byte, int) {\n\t\trespArgs, err := me.soapActionResponse(soapAction, env.Body.Action, r)\n\t\tif err != nil {\n\t\t\tupnpErr := upnp.ConvertError(err)\n\t\t\treturn xmlMarshalOrPanic(soap.NewFault(\"UPnPError\", upnpErr)), 500\n\t\t}\n\t\treturn marshalSOAPResponse(soapAction, respArgs), 200\n\t}()\n\tbodyStr := fmt.Sprintf(`<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?><s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body>%s</s:Body></s:Envelope>`, soapRespXML)\n\tw.WriteHeader(code)\n\tif _, err := w.Write([]byte(bodyStr)); err != nil {\n\t\tlogger.Errorf(err.Error())\n\t}\n}\n\nfunc (me *Server) serveIcon(w http.ResponseWriter, r *http.Request) {\n\tsceneId := r.URL.Query().Get(\"scene\")\n\tif sceneId == \"\" {\n\t\treturn\n\t}\n\n\tvar scene *models.Scene\n\trepo := me.repository\n\terr := repo.WithReadTxn(r.Context(), func(ctx context.Context) error {\n\t\tidInt, err := strconv.Atoi(sceneId)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tscene, _ = repo.SceneFinder.Find(ctx, idInt)\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\tlogger.Warnf(\"failed to execute read transaction while trying to serve an icon: %v\", err)\n\t}\n\n\tif scene == nil {\n\t\treturn\n\t}\n\n\tme.sceneServer.ServeScreenshot(scene, w, r)\n}\n\nfunc (me *Server) contentDirectoryInitialEvent(ctx context.Context, urls []*url.URL, sid string) {\n\tbody := xmlMarshalOrPanic(upnp.PropertySet{\n\t\tProperties: []upnp.Property{\n\t\t\t{\n\t\t\t\tVariable: upnp.Variable{\n\t\t\t\t\tXMLName: xml.Name{\n\t\t\t\t\t\tLocal: \"SystemUpdateID\",\n\t\t\t\t\t},\n\t\t\t\t\tValue: \"0\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t// upnp.Property{\n\t\t\t// \tVariable: upnp.Variable{\n\t\t\t// \t\tXMLName: xml.Name{\n\t\t\t// \t\t\tLocal: \"ContainerUpdateIDs\",\n\t\t\t// \t\t},\n\t\t\t// \t},\n\t\t\t// },\n\t\t\t// upnp.Property{\n\t\t\t// \tVariable: upnp.Variable{\n\t\t\t// \t\tXMLName: xml.Name{\n\t\t\t// \t\t\tLocal: \"TransferIDs\",\n\t\t\t// \t\t},\n\t\t\t// \t},\n\t\t\t// },\n\t\t},\n\t\tSpace: \"urn:schemas-upnp-org:event-1-0\",\n\t})\n\tbody = append([]byte(`<?xml version=\"1.0\"?>`+\"\\n\"), body...)\n\tfor _, _url := range urls {\n\t\tbodyReader := bytes.NewReader(body)\n\t\treq, err := http.NewRequestWithContext(ctx, \"NOTIFY\", _url.String(), bodyReader)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Could not create a request to notify %s: %s\", _url.String(), err)\n\t\t\tcontinue\n\t\t}\n\t\treq.Header[\"CONTENT-TYPE\"] = []string{`text/xml; charset=\"utf-8\"`}\n\t\treq.Header[\"NT\"] = []string{\"upnp:event\"}\n\t\treq.Header[\"NTS\"] = []string{\"upnp:propchange\"}\n\t\treq.Header[\"SID\"] = []string{sid}\n\t\treq.Header[\"SEQ\"] = []string{\"0\"}\n\t\t// req.Header[\"TRANSFER-ENCODING\"] = []string{\"chunked\"}\n\t\t// req.ContentLength = int64(bodyReader.Len())\n\t\tresp, err := http.DefaultClient.Do(req)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Could not notify %s: %s\", _url.String(), err)\n\t\t\tcontinue\n\t\t}\n\t\tb, _ := io.ReadAll(resp.Body)\n\t\tif len(b) > 0 {\n\t\t\tlogger.Debug(string(b))\n\t\t}\n\t\tresp.Body.Close()\n\t}\n}\n\nfunc (me *Server) contentDirectoryEventSubHandler(w http.ResponseWriter, r *http.Request) {\n\tif me.StallEventSubscribe {\n\t\t// I have an LG TV that doesn't like my eventing implementation.\n\t\t// Returning unimplemented (501?) errors, results in repeat subscribe\n\t\t// attempts which hits some kind of error count limit on the TV\n\t\t// causing it to forcefully disconnect. It also won't work if the CDS\n\t\t// service doesn't include an EventSubURL. The best thing I can do is\n\t\t// cause every attempt to subscribe to timeout on the TV end, which\n\t\t// reduces the error rate enough that the TV continues to operate\n\t\t// without eventing.\n\t\t//\n\t\t// I've not found a reliable way to identify this TV, since it and\n\t\t// others don't seem to include any client-identifying headers on\n\t\t// SUBSCRIBE requests.\n\t\t//\n\t\t// TODO: Get eventing to work with the problematic TV.\n\t\tt := time.Now()\n\t\t<-r.Context().Done()\n\t\tlogger.Debugf(\"stalled subscribe connection went away after %s\", time.Since(t))\n\t\treturn\n\t}\n\t// The following code is a work in progress. It partially implements\n\t// the spec on eventing but hasn't been completed as I have nothing to\n\t// test it with.\n\tswitch {\n\tcase r.Method == \"SUBSCRIBE\" && r.Header.Get(\"SID\") == \"\":\n\t\turls := upnp.ParseCallbackURLs(r.Header.Get(\"CALLBACK\"))\n\t\tvar timeout int\n\t\t_, _ = fmt.Sscanf(r.Header.Get(\"TIMEOUT\"), \"Second-%d\", &timeout)\n\n\t\tsid, timeout, _ := me.subscribe(urls, timeout)\n\n\t\tw.Header()[\"SID\"] = []string{sid}\n\t\tw.Header()[\"TIMEOUT\"] = []string{fmt.Sprintf(\"Second-%d\", timeout)}\n\t\t// TODO: Shouldn't have to do this to get headers logged.\n\t\tw.WriteHeader(http.StatusOK)\n\t\tgo func() {\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\tme.contentDirectoryInitialEvent(r.Context(), urls, sid)\n\t\t}()\n\tcase r.Method == \"SUBSCRIBE\":\n\t\thttp.Error(w, \"meh\", http.StatusPreconditionFailed)\n\tdefault:\n\t\tlogger.Debugf(\"unhandled event method: %s\", r.Method)\n\t}\n}\n\n// wrapper around service.Subscribe which requires concurrency protection\n// TODO - this should be addressed upstream\nfunc (me *Server) subscribe(urls []*url.URL, timeout int) (sid string, actualTimeout int, err error) {\n\tme.subscribeLock.Lock()\n\tdefer me.subscribeLock.Unlock()\n\n\tservice := me.services[\"ContentDirectory\"]\n\treturn service.Subscribe(urls, timeout)\n}\n\nfunc (me *Server) initMux(mux *http.ServeMux) {\n\tmux.HandleFunc(\"/\", func(resp http.ResponseWriter, req *http.Request) {\n\t\tresp.Header().Set(\"content-type\", \"text/html\")\n\t\terr := rootTmpl.Execute(resp, struct {\n\t\t\tReadonly bool\n\t\t\tPath     string\n\t\t}{\n\t\t\ttrue,\n\t\t\tme.RootObjectPath,\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(err.Error())\n\t\t}\n\t})\n\tmux.HandleFunc(contentDirectoryEventSubURL, me.contentDirectoryEventSubHandler)\n\tmux.HandleFunc(iconPath, me.serveIcon)\n\tmux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) {\n\t\tsceneId := r.URL.Query().Get(\"scene\")\n\t\tvar scene *models.Scene\n\t\tvar videoDuration float64\n\t\trepo := me.repository\n\t\terr := repo.WithReadTxn(r.Context(), func(ctx context.Context) error {\n\t\t\tsceneIdInt, err := strconv.Atoi(sceneId)\n\t\t\tif err != nil {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tscene, _ = repo.SceneFinder.Find(ctx, sceneIdInt)\n\t\t\tif scene != nil {\n\t\t\t\t// Load primary file to get duration for activity tracking\n\t\t\t\tif err := scene.LoadPrimaryFile(ctx, repo.FileGetter); err != nil {\n\t\t\t\t\tlogger.Debugf(\"failed to load primary file for scene %d: %v\", sceneIdInt, err)\n\t\t\t\t}\n\t\t\t\tif f := scene.Files.Primary(); f != nil {\n\t\t\t\t\tvideoDuration = f.Duration\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"failed to execute read transaction for scene id (%v): %v\", sceneId, err)\n\t\t}\n\n\t\tif scene == nil {\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"transferMode.dlna.org\", \"Streaming\")\n\t\tw.Header().Set(\"contentFeatures.dlna.org\", \"DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000\")\n\n\t\t// Track activity - uses time-based tracking, updated on each request\n\t\tif me.activityTracker != nil {\n\t\t\tsceneIdInt, _ := strconv.Atoi(sceneId)\n\t\t\tclientIP, _, _ := net.SplitHostPort(r.RemoteAddr)\n\t\t\tme.activityTracker.RecordRequest(sceneIdInt, clientIP, videoDuration)\n\t\t}\n\n\t\tme.sceneServer.StreamSceneDirect(scene, w, r)\n\t})\n\tmux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"content-type\", `text/xml; charset=\"utf-8\"`)\n\t\tw.Header().Set(\"content-length\", fmt.Sprint(len(me.rootDescXML)))\n\t\tw.Header().Set(\"server\", serverField)\n\t\tif k, err := w.Write(me.rootDescXML); err != nil {\n\t\t\tlogger.Warnf(\"could not write rootDescXML (wrote %v bytes of %v): %v\", k, len(me.rootDescXML), err)\n\t\t}\n\t})\n\thandleSCPDs(mux)\n\tmux.HandleFunc(serviceControlURL, me.serviceControlHandler)\n\tmux.HandleFunc(\"/debug/pprof/\", pprof.Index)\n\tfor i, di := range me.Icons {\n\t\tmux.HandleFunc(fmt.Sprintf(\"%s/%d\", deviceIconPath, i), func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"Content-Type\", di.Mimetype)\n\t\t\thttp.ServeContent(w, r, \"\", time.Time{}, di.ReadSeeker)\n\t\t})\n\t}\n}\n\nfunc (me *Server) initServices() {\n\tme.services = map[string]UPnPService{\n\t\t\"ContentDirectory\": &contentDirectoryService{\n\t\t\tServer: me,\n\t\t},\n\t\t\"ConnectionManager\": &connectionManagerService{\n\t\t\tServer: me,\n\t\t},\n\t\t\"X_MS_MediaReceiverRegistrar\": &mediaReceiverRegistrarService{\n\t\t\tServer: me,\n\t\t},\n\t}\n}\n\nfunc (me *Server) Serve() (err error) {\n\tme.initServices()\n\tme.closed = make(chan struct{})\n\tif me.HTTPConn == nil {\n\t\tme.HTTPConn, err = net.Listen(\"tcp\", \"\")\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\tif me.Interfaces == nil {\n\t\tifs, err := net.Interfaces()\n\t\tif err != nil {\n\t\t\tlogger.Errorf(err.Error())\n\t\t}\n\t\tvar tmp []net.Interface\n\t\tfor _, if_ := range ifs {\n\t\t\tif if_.Flags&net.FlagUp == 0 || if_.MTU <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttmp = append(tmp, if_)\n\t\t}\n\t\tme.Interfaces = tmp\n\t}\n\tme.httpServeMux = http.NewServeMux()\n\tme.rootDeviceUUID = makeDeviceUuid(me.FriendlyName)\n\tme.rootDescXML, err = xml.MarshalIndent(\n\t\tupnp.DeviceDesc{\n\t\t\tSpecVersion: upnp.SpecVersion{Major: 1, Minor: 0},\n\t\t\tDevice: upnp.Device{\n\t\t\t\tDeviceType:   rootDeviceType,\n\t\t\t\tFriendlyName: me.FriendlyName,\n\t\t\t\tManufacturer: me.FriendlyName,\n\t\t\t\tModelName:    rootDeviceModelName,\n\t\t\t\tUDN:          me.rootDeviceUUID,\n\t\t\t\tServiceList: func() (ss []upnp.Service) {\n\t\t\t\t\tfor _, s := range services {\n\t\t\t\t\t\tss = append(ss, s.Service)\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}(),\n\t\t\t\tIconList: func() (ret []upnp.Icon) {\n\t\t\t\t\tfor i, di := range me.Icons {\n\t\t\t\t\t\tret = append(ret, upnp.Icon{\n\t\t\t\t\t\t\tHeight:   di.Height,\n\t\t\t\t\t\t\tWidth:    di.Width,\n\t\t\t\t\t\t\tDepth:    di.Depth,\n\t\t\t\t\t\t\tMimetype: di.Mimetype,\n\t\t\t\t\t\t\tURL:      fmt.Sprintf(\"%s/%d\", deviceIconPath, i),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}(),\n\t\t\t},\n\t\t},\n\t\t\" \", \"  \")\n\tif err != nil {\n\t\treturn\n\t}\n\tme.rootDescXML = append([]byte(`<?xml version=\"1.0\"?>`), me.rootDescXML...)\n\tlogger.Debug(\"HTTP srv on\", me.HTTPConn.Addr())\n\tme.initMux(me.httpServeMux)\n\tme.ssdpStopped = make(chan struct{})\n\tgo func() {\n\t\tme.doSSDP()\n\t\tclose(me.ssdpStopped)\n\t}()\n\treturn me.serveHTTP()\n}\n\nfunc (me *Server) Close() (err error) {\n\tclose(me.closed)\n\terr = me.HTTPConn.Close()\n\t<-me.ssdpStopped\n\treturn\n}\n\nfunc didl_lite(chardata string) string {\n\treturn `<DIDL-Lite` +\n\t\t` xmlns:dc=\"http://purl.org/dc/elements/1.1/\"` +\n\t\t` xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"` +\n\t\t` xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\"` +\n\t\t` xmlns:dlna=\"urn:schemas-dlna-org:metadata-1-0/\">` +\n\t\tchardata +\n\t\t`</DIDL-Lite>`\n}\n\nfunc (me *Server) location(ip net.IP) string {\n\turl := url.URL{\n\t\tScheme: \"http\",\n\t\tHost: (&net.TCPAddr{\n\t\t\tIP:   ip,\n\t\t\tPort: me.httpPort(),\n\t\t}).String(),\n\t\tPath: rootDescPath,\n\t}\n\treturn url.String()\n}\n"
  },
  {
    "path": "internal/dlna/doc.go",
    "content": "// Package dlna provides the DLNA functionality for the application.\n// Much of this code is adapted from https://github.com/anacrolix/dms\npackage dlna\n"
  },
  {
    "path": "internal/dlna/html.go",
    "content": "package dlna\n\n// From: https://github.com/anacrolix/dms\n// Copyright (c) 2012, Matt Joiner <anacrolix@gmail.com>.\n// All rights reserved.\n\n// Redistribution and use in source and binary forms, with or without\n// modification, are permitted provided that the following conditions are met:\n//     * Redistributions of source code must retain the above copyright\n//       notice, this list of conditions and the following disclaimer.\n//     * 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.\n//     * Neither the name of the <organization> nor the\n//       names of its contributors may be used to endorse or promote products\n//       derived from this software without specific prior written permission.\n\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\n// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\n// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n// DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY\n// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\n// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND\n// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\n// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nimport (\n\t\"html/template\"\n)\n\nvar (\n\trootTmpl *template.Template\n)\n\nfunc init() {\n\trootTmpl = template.Must(template.New(\"root\").Parse(\n\t\t`<form method=\"post\">\n\t\t\tPath: <input type=\"text\"\n\t\t\t\tname=\"path\"\n\t\t\t\t{{if .Readonly}} readonly=\"readonly\"{{end}}\n\t\t\t\tvalue=\"{{.Path}}\"\n\t\t\t/>\n\t\t\t<input type=\"submit\" value=\"Update\"{{if .Readonly}} disabled=\"disabled\"{{end}}/>\n\t\t</form>`))\n}\n"
  },
  {
    "path": "internal/dlna/mrrs.go",
    "content": "package dlna\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/anacrolix/dms/upnp\"\n)\n\ntype mediaReceiverRegistrarService struct {\n\t*Server\n\tupnp.Eventing\n}\n\nfunc (mrrs *mediaReceiverRegistrarService) Handle(action string, argsXML []byte, r *http.Request) (map[string]string, error) {\n\tswitch action {\n\tcase \"IsAuthorized\", \"IsValidated\":\n\t\treturn map[string]string{\n\t\t\t\"Result\": \"1\",\n\t\t}, nil\n\tcase \"RegisterDevice\":\n\t\treturn map[string]string{\n\t\t\t\"RegistrationRespMsg\": mrrs.rootDeviceUUID,\n\t\t}, nil\n\tdefault:\n\t\treturn nil, upnp.InvalidActionError\n\t}\n}\n"
  },
  {
    "path": "internal/dlna/paging.go",
    "content": "package dlna\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\ntype scenePager struct {\n\tsceneFilter *models.SceneFilterType\n\tparentID    string\n}\n\nfunc (p *scenePager) getPageID(page int) string {\n\treturn p.parentID + \"/page/\" + strconv.Itoa(page)\n}\n\nfunc (p *scenePager) getPages(ctx context.Context, r models.SceneQueryer, total int) ([]interface{}, error) {\n\tvar objs []interface{}\n\n\t// get the first scene of each page to set an appropriate title\n\tpages := int(math.Ceil(float64(total) / float64(pageSize)))\n\n\tsinglePageSize := 1\n\tsort := \"title\"\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &singlePageSize,\n\t\tSort:    &sort,\n\t}\n\n\tfor page := 1; page <= pages; page++ {\n\t\t// TODO - this is really slow. Not sure if there's a better way\n\t\ttitle := fmt.Sprintf(\"Page %d\", page)\n\t\tif pages <= 10 || (page-1)%(pages/10) == 0 {\n\t\t\tthisPage := ((page - 1) * pageSize) + 1\n\t\t\tfindFilter.Page = &thisPage\n\t\t\tscenes, err := scene.Query(ctx, r, p.sceneFilter, findFilter)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tsceneTitle := scenes[0].GetTitle()\n\n\t\t\t// use the first three letters as a prefix\n\t\t\tif len(sceneTitle) > 3 {\n\t\t\t\tsceneTitle = sceneTitle[0:3]\n\t\t\t}\n\n\t\t\ttitle += fmt.Sprintf(\" (%s...)\", sceneTitle)\n\t\t}\n\n\t\tobjs = append(objs, makeStorageFolder(p.getPageID(page), title, p.parentID))\n\t}\n\n\treturn objs, nil\n}\n\nfunc (p *scenePager) getPageVideos(ctx context.Context, r SceneFinder, f models.FileGetter, page int, host string, sort string, direction models.SortDirectionEnum) ([]interface{}, error) {\n\tvar objs []interface{}\n\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage:   &pageSize,\n\t\tPage:      &page,\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\tscenes, err := scene.Query(ctx, r, p.sceneFilter, findFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, s := range scenes {\n\t\tif err := s.LoadPrimaryFile(ctx, f); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tobjs = append(objs, sceneToContainer(s, p.parentID, host))\n\t}\n\n\treturn objs, nil\n}\n"
  },
  {
    "path": "internal/dlna/service.go",
    "content": "package dlna\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype Repository struct {\n\tTxnManager models.TxnManager\n\n\tSceneFinder     SceneFinder\n\tFileGetter      models.FileGetter\n\tStudioFinder    StudioFinder\n\tTagFinder       TagFinder\n\tPerformerFinder PerformerFinder\n\tGroupFinder     GroupFinder\n}\n\nfunc NewRepository(repo models.Repository) Repository {\n\treturn Repository{\n\t\tTxnManager:      repo.TxnManager,\n\t\tFileGetter:      repo.File,\n\t\tSceneFinder:     repo.Scene,\n\t\tStudioFinder:    repo.Studio,\n\t\tTagFinder:       repo.Tag,\n\t\tPerformerFinder: repo.Performer,\n\t\tGroupFinder:     repo.Group,\n\t}\n}\n\nfunc (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error {\n\treturn txn.WithReadTxn(ctx, r.TxnManager, fn)\n}\n\ntype Status struct {\n\tRunning bool `json:\"running\"`\n\t// If not currently running, time until it will be started. If running, time until it will be stopped\n\tUntil              *time.Time `json:\"until\"`\n\tRecentIPAddresses  []string   `json:\"recentIPAddresses\"`\n\tAllowedIPAddresses []*Dlnaip  `json:\"allowedIPAddresses\"`\n}\n\ntype Dlnaip struct {\n\tIPAddress string `json:\"ipAddress\"`\n\t// Time until IP will be no longer allowed/disallowed\n\tUntil *time.Time `json:\"until\"`\n}\n\ntype dmsConfig struct {\n\tPath                string\n\tIfNames             []string\n\tHttp                string\n\tFriendlyName        string\n\tLogHeaders          bool\n\tStallEventSubscribe bool\n\tNotifyInterval      time.Duration\n\tVideoSortOrder      string\n}\n\ntype sceneServer interface {\n\tStreamSceneDirect(scene *models.Scene, w http.ResponseWriter, r *http.Request)\n\tServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request)\n}\n\ntype Config interface {\n\tGetDLNAInterfaces() []string\n\tGetDLNAServerName() string\n\tGetDLNADefaultIPWhitelist() []string\n\tGetVideoSortOrder() string\n\tGetDLNAPortAsString() string\n\tGetDLNAActivityTrackingEnabled() bool\n}\n\n// activityConfig wraps Config to implement ActivityConfig.\ntype activityConfig struct {\n\tconfig         Config\n\tminPlayPercent int // cached from UI config\n}\n\nfunc (c *activityConfig) GetDLNAActivityTrackingEnabled() bool {\n\treturn c.config.GetDLNAActivityTrackingEnabled()\n}\n\nfunc (c *activityConfig) GetMinimumPlayPercent() int {\n\treturn c.minPlayPercent\n}\n\ntype Service struct {\n\trepository      Repository\n\tconfig          Config\n\tsceneServer     sceneServer\n\tipWhitelistMgr  *ipWhitelistManager\n\tactivityTracker *ActivityTracker\n\n\tserver  *Server\n\trunning bool\n\tmutex   sync.Mutex\n\n\tstartTimer *time.Timer\n\tstartTime  *time.Time\n\tstopTimer  *time.Timer\n\tstopTime   *time.Time\n}\n\nfunc (s *Service) getInterfaces() ([]net.Interface, error) {\n\tvar ifs []net.Interface\n\tvar err error\n\tifNames := s.config.GetDLNAInterfaces()\n\n\tif len(ifNames) == 0 {\n\t\tifs, err = net.Interfaces()\n\t} else {\n\t\tfor _, n := range ifNames {\n\t\t\tif_, err := net.InterfaceByName(n)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error getting interface for name %s: %s\", n, err.Error())\n\t\t\t}\n\n\t\t\tif if_ != nil {\n\t\t\t\tifs = append(ifs, *if_)\n\t\t\t}\n\t\t}\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar tmp []net.Interface\n\tfor _, if_ := range ifs {\n\t\tif if_.Flags&net.FlagUp == 0 || if_.MTU <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\ttmp = append(tmp, if_)\n\t}\n\tifs = tmp\n\treturn ifs, nil\n}\n\nfunc (s *Service) init() error {\n\tfriendlyName := s.config.GetDLNAServerName()\n\tif friendlyName == \"\" {\n\t\tfriendlyName = \"stash\"\n\t}\n\n\tvar dmsConfig = &dmsConfig{\n\t\tPath:           \"\",\n\t\tIfNames:        s.config.GetDLNADefaultIPWhitelist(),\n\t\tHttp:           s.config.GetDLNAPortAsString(),\n\t\tFriendlyName:   friendlyName,\n\t\tLogHeaders:     false,\n\t\tNotifyInterval: 30 * time.Second,\n\t\tVideoSortOrder: s.config.GetVideoSortOrder(),\n\t}\n\n\tinterfaces, err := s.getInterfaces()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.server = &Server{\n\t\trepository:         s.repository,\n\t\tsceneServer:        s.sceneServer,\n\t\tipWhitelistManager: s.ipWhitelistMgr,\n\t\tactivityTracker:    s.activityTracker,\n\t\tInterfaces:         interfaces,\n\t\tHTTPConn: func() net.Listener {\n\t\t\tconn, err := net.Listen(\"tcp\", dmsConfig.Http)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Error(err.Error())\n\t\t\t}\n\t\t\treturn conn\n\t\t}(),\n\t\tFriendlyName:   dmsConfig.FriendlyName,\n\t\tRootObjectPath: filepath.Clean(dmsConfig.Path),\n\t\tLogHeaders:     dmsConfig.LogHeaders,\n\t\t// Icons: []Icon{\n\t\t// \t{\n\t\t// \t\tWidth:    48,\n\t\t// \t\tHeight:   48,\n\t\t// \t\tDepth:    8,\n\t\t// \t\tMimetype: \"image/png\",\n\t\t// \t\t//ReadSeeker: readIcon(config.Config.Interfaces.DLNA.ServiceImage, 48),\n\t\t// \t},\n\t\t// \t{\n\t\t// \t\tWidth:    128,\n\t\t// \t\tHeight:   128,\n\t\t// \t\tDepth:    8,\n\t\t// \t\tMimetype: \"image/png\",\n\t\t// \t\t//ReadSeeker: readIcon(config.Config.Interfaces.DLNA.ServiceImage, 128),\n\t\t// \t},\n\t\t// },\n\t\tStallEventSubscribe: dmsConfig.StallEventSubscribe,\n\t\tNotifyInterval:      dmsConfig.NotifyInterval,\n\t\tVideoSortOrder:      dmsConfig.VideoSortOrder,\n\t}\n\n\treturn nil\n}\n\n// func getIconReader(fn string) (io.Reader, error) {\n// \tb, err := assets.ReadFile(\"dlna/\" + fn + \".png\")\n// \treturn bytes.NewReader(b), err\n// }\n\n// func readIcon(path string, size uint) *bytes.Reader {\n// \tr, err := getIconReader(path)\n// \tif err != nil {\n// \t\tpanic(err)\n// \t}\n// \timageData, _, err := image.Decode(r)\n// \tif err != nil {\n// \t\tpanic(err)\n// \t}\n// \treturn resizeImage(imageData, size)\n// }\n\n// func resizeImage(imageData image.Image, size uint) *bytes.Reader {\n// \timg := resize.Resize(size, size, imageData, resize.Lanczos3)\n// \tvar buff bytes.Buffer\n// \tpng.Encode(&buff, img)\n// \treturn bytes.NewReader(buff.Bytes())\n// }\n\n// NewService initialises and returns a new DLNA service.\n// The sceneWriter parameter should implement SceneActivityWriter (typically models.SceneReaderWriter).\n// The minPlayPercent parameter is the minimum percentage of video that must be played to increment play count.\nfunc NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWriter SceneActivityWriter, minPlayPercent int) *Service {\n\tactivityCfg := &activityConfig{\n\t\tconfig:         cfg,\n\t\tminPlayPercent: minPlayPercent,\n\t}\n\n\tret := &Service{\n\t\trepository:  repo,\n\t\tsceneServer: sceneServer,\n\t\tconfig:      cfg,\n\t\tipWhitelistMgr: &ipWhitelistManager{\n\t\t\tconfig: cfg,\n\t\t},\n\t\tactivityTracker: NewActivityTracker(repo.TxnManager, sceneWriter, activityCfg),\n\t\tmutex:           sync.Mutex{},\n\t}\n\n\treturn ret\n}\n\n// Start starts the DLNA service. If duration is provided, then the service\n// is stopped after the duration has elapsed.\nfunc (s *Service) Start(duration *time.Duration) error {\n\ts.mutex.Lock()\n\tdefer s.mutex.Unlock()\n\n\tif !s.running {\n\t\tif err := s.init(); err != nil {\n\t\t\tlogger.Error(err)\n\t\t\treturn err\n\t\t}\n\n\t\tgo func() {\n\t\t\tlogger.Info(\"Starting DLNA \" + s.server.HTTPConn.Addr().String())\n\t\t\tif err := s.server.Serve(); err != nil {\n\t\t\t\tlogger.Error(err)\n\t\t\t}\n\t\t}()\n\t\ts.running = true\n\n\t\tif s.startTimer != nil {\n\t\t\ts.startTimer.Stop()\n\t\t\ts.startTimer = nil\n\t\t\ts.startTime = nil\n\t\t}\n\t}\n\n\tif duration != nil {\n\t\t// clear the existing stop timer\n\t\tif s.stopTimer != nil {\n\t\t\ts.stopTimer.Stop()\n\t\t\ts.stopTime = nil\n\t\t}\n\n\t\tif s.stopTimer == nil {\n\t\t\ts.stopTimer = time.AfterFunc(*duration, func() {\n\t\t\t\ts.Stop(nil)\n\t\t\t})\n\t\t\tt := time.Now().Add(*duration)\n\t\t\ts.stopTime = &t\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Stop stops the DLNA service. If duration is provided, then the service\n// is started after the duration has elapsed.\nfunc (s *Service) Stop(duration *time.Duration) {\n\ts.mutex.Lock()\n\tdefer s.mutex.Unlock()\n\n\tif s.running {\n\t\tlogger.Info(\"Stopping DLNA\")\n\n\t\t// Stop activity tracker first to process any pending sessions\n\t\tif s.activityTracker != nil {\n\t\t\ts.activityTracker.Stop()\n\t\t}\n\n\t\terr := s.server.Close()\n\t\tif err != nil {\n\t\t\tlogger.Error(err)\n\t\t}\n\t\ts.running = false\n\n\t\tif s.stopTimer != nil {\n\t\t\ts.stopTimer.Stop()\n\t\t\ts.stopTimer = nil\n\t\t\ts.stopTime = nil\n\t\t}\n\t}\n\n\tif duration != nil {\n\t\t// clear the existing stop timer\n\t\tif s.startTimer != nil {\n\t\t\ts.startTimer.Stop()\n\t\t}\n\n\t\tif s.startTimer == nil {\n\t\t\ts.startTimer = time.AfterFunc(*duration, func() {\n\t\t\t\tif err := s.Start(nil); err != nil {\n\t\t\t\t\tlogger.Warnf(\"error restarting DLNA server: %v\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t\tt := time.Now().Add(*duration)\n\t\t\ts.startTime = &t\n\t\t}\n\t}\n}\n\n// IsRunning returns true if the DLNA service is running.\nfunc (s *Service) IsRunning() bool {\n\ts.mutex.Lock()\n\tdefer s.mutex.Unlock()\n\treturn s.running\n}\n\nfunc (s *Service) Status() *Status {\n\ts.mutex.Lock()\n\tdefer s.mutex.Unlock()\n\n\tret := &Status{\n\t\tRunning:            s.running,\n\t\tRecentIPAddresses:  s.ipWhitelistMgr.getRecent(),\n\t\tAllowedIPAddresses: s.ipWhitelistMgr.getTempAllowed(),\n\t}\n\n\tif s.startTime != nil {\n\t\tt := *s.startTime\n\t\tret.Until = &t\n\t}\n\n\tif s.stopTime != nil {\n\t\tt := *s.stopTime\n\t\tret.Until = &t\n\t}\n\n\treturn ret\n}\n\nfunc (s *Service) AddTempDLNAIP(pattern string, duration *time.Duration) {\n\ts.ipWhitelistMgr.allowPattern(pattern, duration)\n}\n\nfunc (s *Service) RemoveTempDLNAIP(pattern string) bool {\n\treturn s.ipWhitelistMgr.removePattern(pattern)\n}\n"
  },
  {
    "path": "internal/dlna/whitelist.go",
    "content": "package dlna\n\nimport (\n\t\"slices\"\n\t\"sync\"\n\t\"time\"\n)\n\n// only keep the 10 most recent IP addresses\nconst recentListLength = 10\n\nconst wildcard = \"*\"\n\ntype tempIPWhitelist struct {\n\tpattern string\n\tuntil   *time.Time\n}\n\ntype ipWhitelistManager struct {\n\trecentIPAddresses []string\n\tconfig            Config\n\ttempWhitelist     []tempIPWhitelist\n\tmutex             sync.Mutex\n}\n\n// addRecent adds the provided address to the recent IP addresses list if it\n// was not already present. Returns true if it was already present.\nfunc (m *ipWhitelistManager) addRecent(addr string) bool {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\ti := slices.Index(m.recentIPAddresses, addr)\n\tif i != -1 {\n\t\tif i == 0 {\n\t\t\t// don't do anything if it's already at the start\n\t\t\treturn true\n\t\t}\n\n\t\t// remove from the list\n\t\tm.recentIPAddresses = append(m.recentIPAddresses[:i], m.recentIPAddresses[i+1:]...)\n\t}\n\n\t// add to the top of the list\n\tm.recentIPAddresses = append([]string{addr}, m.recentIPAddresses...)\n\n\tif len(m.recentIPAddresses) > recentListLength {\n\t\tm.recentIPAddresses = m.recentIPAddresses[:recentListLength]\n\t}\n\n\treturn i != -1\n}\n\nfunc (m *ipWhitelistManager) getRecent() []string {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\treturn m.recentIPAddresses\n}\n\nfunc (m *ipWhitelistManager) getTempAllowed() []*Dlnaip {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tvar ret []*Dlnaip\n\n\tnow := time.Now()\n\tremoveExpired := false\n\tfor _, a := range m.tempWhitelist {\n\t\tif a.until != nil && now.After(*a.until) {\n\t\t\tremoveExpired = true\n\t\t\tcontinue\n\t\t}\n\n\t\tret = append(ret, &Dlnaip{\n\t\t\tIPAddress: a.pattern,\n\t\t\tUntil:     a.until,\n\t\t})\n\t}\n\n\tif removeExpired {\n\t\tm.removeExpiredWhitelists()\n\t}\n\n\treturn ret\n}\n\nfunc (m *ipWhitelistManager) ipAllowed(addr string) bool {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tfor _, a := range m.config.GetDLNADefaultIPWhitelist() {\n\t\tif a == wildcard {\n\t\t\treturn true\n\t\t}\n\n\t\tif addr == a {\n\t\t\treturn true\n\t\t}\n\t}\n\n\tnow := time.Now()\n\tremoveExpired := false\n\tfor _, a := range m.tempWhitelist {\n\t\tif a.until != nil && now.After(*a.until) {\n\t\t\tremoveExpired = true\n\t\t\tcontinue\n\t\t}\n\n\t\tif a.pattern == wildcard {\n\t\t\treturn true\n\t\t}\n\n\t\tif addr == a.pattern {\n\t\t\treturn true\n\t\t}\n\t}\n\n\tif removeExpired {\n\t\tm.removeExpiredWhitelists()\n\t}\n\n\treturn false\n}\n\nfunc (m *ipWhitelistManager) removeExpiredWhitelists() {\n\t// assumes mutex is already held\n\tvar newList []tempIPWhitelist\n\tnow := time.Now()\n\n\tfor _, a := range m.tempWhitelist {\n\t\tif a.until != nil && now.After(*a.until) {\n\t\t\tcontinue\n\t\t}\n\n\t\tnewList = append(newList, a)\n\t}\n\n\tm.tempWhitelist = newList\n}\n\nfunc (m *ipWhitelistManager) allowPattern(pattern string, duration *time.Duration) {\n\tif pattern == \"\" {\n\t\treturn\n\t}\n\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\t// overwrite existing\n\tvar newList []tempIPWhitelist\n\tfound := false\n\n\tvar until *time.Time\n\n\tif duration != nil {\n\t\tu := time.Now().Add(*duration)\n\t\tuntil = &u\n\t}\n\n\tfor _, a := range m.tempWhitelist {\n\t\tif a.pattern == pattern {\n\t\t\ta.until = until\n\t\t\tfound = true\n\t\t}\n\n\t\tnewList = append(newList, a)\n\t}\n\n\tif !found {\n\t\tnewList = append(newList, tempIPWhitelist{\n\t\t\tpattern: pattern,\n\t\t\tuntil:   until,\n\t\t})\n\t}\n\n\tm.tempWhitelist = newList\n}\n\nfunc (m *ipWhitelistManager) removePattern(pattern string) bool {\n\tif pattern == \"\" {\n\t\treturn false\n\t}\n\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tvar newList []tempIPWhitelist\n\tfound := false\n\n\tfor _, a := range m.tempWhitelist {\n\t\tif a.pattern == pattern {\n\t\t\tfound = true\n\t\t\tcontinue\n\t\t}\n\n\t\tnewList = append(newList, a)\n\t}\n\n\tm.tempWhitelist = newList\n\treturn found\n}\n"
  },
  {
    "path": "internal/dlna/xmsr-service-desc.go",
    "content": "package dlna\n\n// from https://github.com/rclone/rclone\n// Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/\n\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// 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 FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n// THE SOFTWARE.\n\nconst xmsMediaReceiverServiceDescription = `<?xml version=\"1.0\" ?>\n<scpd xmlns=\"urn:schemas-upnp-org:service-1-0\">\n\t<specVersion>\n\t\t<major>1</major>\n\t\t<minor>0</minor>\n\t</specVersion>\n\t<actionList>\n\t\t<action>\n\t\t\t<name>IsAuthorized</name>\n\t\t\t<argumentList>\n\t\t\t\t<argument>\n\t\t\t\t\t<name>DeviceID</name>\n\t\t\t\t\t<direction>in</direction>\n\t\t\t\t\t<relatedStateVariable>A_ARG_TYPE_DeviceID</relatedStateVariable>\n\t\t\t\t</argument>\n\t\t\t\t<argument>\n\t\t\t\t\t<name>Result</name>\n\t\t\t\t\t<direction>out</direction>\n\t\t\t\t\t<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>\n\t\t\t\t</argument>\n\t\t\t</argumentList>\n\t\t</action>\n\t\t<action>\n\t\t\t<name>RegisterDevice</name>\n\t\t\t<argumentList>\n\t\t\t\t<argument>\n\t\t\t\t\t<name>RegistrationReqMsg</name>\n\t\t\t\t\t<direction>in</direction>\n\t\t\t\t\t<relatedStateVariable>A_ARG_TYPE_RegistrationReqMsg</relatedStateVariable>\n\t\t\t\t</argument>\n\t\t\t\t<argument>\n\t\t\t\t\t<name>RegistrationRespMsg</name>\n\t\t\t\t\t<direction>out</direction>\n\t\t\t\t\t<relatedStateVariable>A_ARG_TYPE_RegistrationRespMsg</relatedStateVariable>\n\t\t\t\t</argument>\n\t\t\t</argumentList>\n\t\t</action>\n\t\t<action>\n\t\t\t<name>IsValidated</name>\n\t\t\t<argumentList>\n\t\t\t\t<argument>\n\t\t\t\t\t<name>DeviceID</name>\n\t\t\t\t\t<direction>in</direction>\n\t\t\t\t\t<relatedStateVariable>A_ARG_TYPE_DeviceID</relatedStateVariable>\n\t\t\t\t</argument>\n\t\t\t\t<argument>\n\t\t\t\t\t<name>Result</name>\n\t\t\t\t\t<direction>out</direction>\n\t\t\t\t\t<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>\n\t\t\t\t</argument>\n\t\t\t</argumentList>\n\t\t</action>\n\t</actionList>\n\t<serviceStateTable>\n\t\t<stateVariable sendEvents=\"no\">\n\t\t\t<name>A_ARG_TYPE_DeviceID</name>\n\t\t\t<dataType>string</dataType>\n\t\t</stateVariable>\n\t\t<stateVariable sendEvents=\"no\">\n\t\t\t<name>A_ARG_TYPE_Result</name>\n\t\t\t<dataType>int</dataType>\n\t\t</stateVariable>\n\t\t<stateVariable sendEvents=\"no\">\n\t\t\t<name>A_ARG_TYPE_RegistrationReqMsg</name>\n\t\t\t<dataType>bin.base64</dataType>\n\t\t</stateVariable>\n\t\t<stateVariable sendEvents=\"no\">\n\t\t\t<name>A_ARG_TYPE_RegistrationRespMsg</name>\n\t\t\t<dataType>bin.base64</dataType>\n\t\t</stateVariable>\n\t\t<stateVariable sendEvents=\"yes\">\n\t\t\t<name>AuthorizationGrantedUpdateID</name>\n\t\t\t<dataType>ui4</dataType>\n\t\t</stateVariable>\n\t\t<stateVariable sendEvents=\"yes\">\n\t\t\t<name>AuthorizationDeniedUpdateID</name>\n\t\t\t<dataType>ui4</dataType>\n\t\t</stateVariable>\n\t\t<stateVariable sendEvents=\"yes\">\n\t\t\t<name>ValidationSucceededUpdateID</name>\n\t\t\t<dataType>ui4</dataType>\n\t\t</stateVariable>\n\t\t<stateVariable sendEvents=\"yes\">\n\t\t\t<name>ValidationRevokedUpdateID</name>\n\t\t\t<dataType>ui4</dataType>\n\t\t</stateVariable>\n\t</serviceStateTable>\n</scpd>`\n"
  },
  {
    "path": "internal/identify/identify.go",
    "content": "// Package identify provides the scene identification functionality for the application.\n// The identify functionality uses scene scrapers to identify a given scene and\n// set its metadata based on the scraped data.\npackage identify\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nvar (\n\tErrSkipSingleNamePerformer = errors.New(\"a performer was skipped because they only had a single name and no disambiguation\")\n)\n\ntype MultipleMatchesFoundError struct {\n\tSource ScraperSource\n}\n\nfunc (e *MultipleMatchesFoundError) Error() string {\n\treturn fmt.Sprintf(\"multiple matches found for %s\", e.Source.Name)\n}\n\ntype SceneScraper interface {\n\tScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error)\n}\n\ntype SceneUpdatePostHookExecutor interface {\n\tExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string)\n}\n\ntype ScraperSource struct {\n\tName       string\n\tOptions    *MetadataOptions\n\tScraper    SceneScraper\n\tRemoteSite string\n}\n\ntype SceneIdentifier struct {\n\tTxnManager         txn.Manager\n\tSceneReaderUpdater SceneReaderUpdater\n\tStudioReaderWriter models.StudioReaderWriter\n\tPerformerCreator   PerformerCreator\n\tTagFinderCreator   models.TagFinderCreator\n\n\tDefaultOptions              *MetadataOptions\n\tSources                     []ScraperSource\n\tSceneUpdatePostHookExecutor SceneUpdatePostHookExecutor\n}\n\nfunc (t *SceneIdentifier) Identify(ctx context.Context, scene *models.Scene) error {\n\tresult, err := t.scrapeScene(ctx, scene)\n\tvar multipleMatchErr *MultipleMatchesFoundError\n\tif err != nil {\n\t\tif !errors.As(err, &multipleMatchErr) {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif result == nil {\n\t\tif multipleMatchErr != nil {\n\t\t\tlogger.Debugf(\"Identify skipped because multiple results returned for %s\", scene.Path)\n\n\t\t\t// find if the scene should be tagged for multiple results\n\t\t\toptions := t.getOptions(multipleMatchErr.Source)\n\t\t\tif options.SkipMultipleMatchTag != nil && len(*options.SkipMultipleMatchTag) > 0 {\n\t\t\t\t// Tag it with the multiple results tag\n\t\t\t\terr := t.addTagToScene(ctx, scene, *options.SkipMultipleMatchTag)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t} else {\n\t\t\tlogger.Debugf(\"Unable to identify %s\", scene.Path)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// results were found, modify the scene\n\tif err := t.modifyScene(ctx, scene, result); err != nil {\n\t\treturn fmt.Errorf(\"error modifying scene: %v\", err)\n\t}\n\n\treturn nil\n}\n\ntype scrapeResult struct {\n\tresult *models.ScrapedScene\n\tsource ScraperSource\n}\n\nfunc (t *SceneIdentifier) scrapeScene(ctx context.Context, scene *models.Scene) (*scrapeResult, error) {\n\t// iterate through the input sources\n\tfor _, source := range t.Sources {\n\t\t// scrape using the source\n\t\tresults, err := source.Scraper.ScrapeScenes(ctx, scene.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error scraping from %v: %v\", source.Scraper, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(results) > 0 {\n\t\t\toptions := t.getOptions(source)\n\t\t\tif len(results) > 1 && utils.IsTrue(options.SkipMultipleMatches) {\n\t\t\t\treturn nil, &MultipleMatchesFoundError{\n\t\t\t\t\tSource: source,\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// if results were found then return\n\t\t\t\treturn &scrapeResult{\n\t\t\t\t\tresult: results[0],\n\t\t\t\t\tsource: source,\n\t\t\t\t}, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\n// Returns a MetadataOptions object with any default options overwritten by source specific options\nfunc (t *SceneIdentifier) getOptions(source ScraperSource) MetadataOptions {\n\tvar options MetadataOptions\n\tif t.DefaultOptions != nil {\n\t\toptions = *t.DefaultOptions\n\t}\n\tif source.Options == nil {\n\t\treturn options\n\t}\n\n\tif source.Options.SetCoverImage != nil {\n\t\toptions.SetCoverImage = source.Options.SetCoverImage\n\t}\n\tif source.Options.SetOrganized != nil {\n\t\toptions.SetOrganized = source.Options.SetOrganized\n\t}\n\tif source.Options.IncludeMalePerformers != nil {\n\t\toptions.IncludeMalePerformers = source.Options.IncludeMalePerformers\n\t}\n\tif source.Options.PerformerGenders != nil {\n\t\toptions.PerformerGenders = source.Options.PerformerGenders\n\t}\n\tif source.Options.SkipMultipleMatches != nil {\n\t\toptions.SkipMultipleMatches = source.Options.SkipMultipleMatches\n\t}\n\tif source.Options.SkipMultipleMatchTag != nil && len(*source.Options.SkipMultipleMatchTag) > 0 {\n\t\toptions.SkipMultipleMatchTag = source.Options.SkipMultipleMatchTag\n\t}\n\tif source.Options.SkipSingleNamePerformers != nil {\n\t\toptions.SkipSingleNamePerformers = source.Options.SkipSingleNamePerformers\n\t}\n\tif source.Options.SkipSingleNamePerformerTag != nil && len(*source.Options.SkipSingleNamePerformerTag) > 0 {\n\t\toptions.SkipSingleNamePerformerTag = source.Options.SkipSingleNamePerformerTag\n\t}\n\n\treturn options\n}\n\nfunc (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, result *scrapeResult) (*scene.UpdateSet, error) {\n\tret := &scene.UpdateSet{\n\t\tID: s.ID,\n\t}\n\n\tallOptions := []MetadataOptions{}\n\tif result.source.Options != nil {\n\t\tallOptions = append(allOptions, *result.source.Options)\n\t}\n\tif t.DefaultOptions != nil {\n\t\tallOptions = append(allOptions, *t.DefaultOptions)\n\t}\n\n\tfieldOptions := getFieldOptions(allOptions)\n\toptions := t.getOptions(result.source)\n\n\tscraped := result.result\n\n\trel := sceneRelationships{\n\t\tsceneReader:              t.SceneReaderUpdater,\n\t\tstudioReaderWriter:       t.StudioReaderWriter,\n\t\tperformerCreator:         t.PerformerCreator,\n\t\ttagCreator:               t.TagFinderCreator,\n\t\tscene:                    s,\n\t\tresult:                   result,\n\t\tfieldOptions:             fieldOptions,\n\t\tskipSingleNamePerformers: utils.IsTrue(options.SkipSingleNamePerformers),\n\t}\n\n\tsetOrganized := utils.IsTrue(options.SetOrganized)\n\tret.Partial = getScenePartial(s, scraped, fieldOptions, setOrganized)\n\n\tstudioID, err := rel.studio(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting studio: %w\", err)\n\t}\n\n\tif studioID != nil {\n\t\tret.Partial.StudioID = models.NewOptionalInt(*studioID)\n\t}\n\n\t// Determine allowed genders for performer filtering\n\tvar allowedGenders []models.GenderEnum\n\tif options.PerformerGenders != nil {\n\t\t// New field takes precedence\n\t\tallowedGenders = options.PerformerGenders\n\t} else if options.IncludeMalePerformers != nil && !*options.IncludeMalePerformers {\n\t\t// Legacy: if includeMalePerformers is false, include all genders except male\n\t\tfor _, g := range models.AllGenderEnum {\n\t\t\tif g != models.GenderEnumMale {\n\t\t\t\tallowedGenders = append(allowedGenders, g)\n\t\t\t}\n\t\t}\n\t}\n\t// nil allowedGenders means include all performers\n\n\taddSkipSingleNamePerformerTag := false\n\tperformerIDs, err := rel.performers(ctx, allowedGenders)\n\tif err != nil {\n\t\tif errors.Is(err, ErrSkipSingleNamePerformer) {\n\t\t\taddSkipSingleNamePerformerTag = true\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif performerIDs != nil {\n\t\tret.Partial.PerformerIDs = &models.UpdateIDs{\n\t\t\tIDs:  performerIDs,\n\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t}\n\t}\n\n\ttagIDs, err := rel.tags(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif addSkipSingleNamePerformerTag && options.SkipSingleNamePerformerTag != nil {\n\t\ttagID, err := strconv.ParseInt(*options.SkipSingleNamePerformerTag, 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error converting tag ID %s: %w\", *options.SkipSingleNamePerformerTag, err)\n\t\t}\n\n\t\ttagIDs = sliceutil.AppendUnique(tagIDs, int(tagID))\n\t}\n\tif tagIDs != nil {\n\t\tret.Partial.TagIDs = &models.UpdateIDs{\n\t\t\tIDs:  tagIDs,\n\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t}\n\t}\n\n\t// SetCoverImage defaults to true if unset\n\tif options.SetCoverImage == nil || *options.SetCoverImage {\n\t\tret.CoverImage, err = rel.cover(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// if anything changed, also update the updated at time on the applicable stash id\n\tchanged := !ret.IsEmpty()\n\n\tstashIDs, err := rel.stashIDs(ctx, changed)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif stashIDs != nil {\n\t\tret.Partial.StashIDs = &models.UpdateStashIDs{\n\t\t\tStashIDs: stashIDs,\n\t\t\tMode:     models.RelationshipUpdateModeSet,\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *SceneIdentifier) modifyScene(ctx context.Context, s *models.Scene, result *scrapeResult) error {\n\tvar updater *scene.UpdateSet\n\tif err := txn.WithTxn(ctx, t.TxnManager, func(ctx context.Context) error {\n\t\t// load scene relationships\n\t\tif err := s.LoadURLs(ctx, t.SceneReaderUpdater); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := s.LoadPerformerIDs(ctx, t.SceneReaderUpdater); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := s.LoadTagIDs(ctx, t.SceneReaderUpdater); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := s.LoadStashIDs(ctx, t.SceneReaderUpdater); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar err error\n\t\tupdater, err = t.getSceneUpdater(ctx, s, result)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// don't update anything if nothing was set\n\t\tif updater.IsEmpty() {\n\t\t\tlogger.Debugf(\"Nothing to set for %s\", s.Path)\n\t\t\treturn nil\n\t\t}\n\n\t\tif _, err := updater.Update(ctx, t.SceneReaderUpdater); err != nil {\n\t\t\treturn fmt.Errorf(\"error updating scene: %w\", err)\n\t\t}\n\n\t\tas := \"\"\n\t\ttitle := updater.Partial.Title\n\t\tif title.Ptr() != nil {\n\t\t\tas = fmt.Sprintf(\" as %s\", title.Value)\n\t\t}\n\t\tlogger.Infof(\"Successfully identified %s%s using %s\", s.Path, as, result.source.Name)\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// fire post-update hooks\n\tif !updater.IsEmpty() {\n\t\tupdateInput := updater.UpdateInput()\n\t\tfields := utils.NotNilFields(updateInput, \"json\")\n\t\tt.SceneUpdatePostHookExecutor.ExecuteSceneUpdatePostHooks(ctx, updateInput, fields)\n\t}\n\n\treturn nil\n}\n\nfunc (t *SceneIdentifier) addTagToScene(ctx context.Context, s *models.Scene, tagToAdd string) error {\n\tif err := txn.WithTxn(ctx, t.TxnManager, func(ctx context.Context) error {\n\t\ttagID, err := strconv.Atoi(tagToAdd)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error converting tag ID %s: %w\", tagToAdd, err)\n\t\t}\n\n\t\tif err := s.LoadTagIDs(ctx, t.SceneReaderUpdater); err != nil {\n\t\t\treturn err\n\t\t}\n\t\texisting := s.TagIDs.List()\n\n\t\tif slices.Contains(existing, tagID) {\n\t\t\t// skip if the scene was already tagged\n\t\t\treturn nil\n\t\t}\n\n\t\tif err := scene.AddTag(ctx, t.SceneReaderUpdater, s, tagID); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret, err := t.TagFinderCreator.Find(ctx, tagID)\n\t\tif err != nil {\n\t\t\tlogger.Infof(\"Added tag id %s to skipped scene %s\", tagToAdd, s.Path)\n\t\t} else {\n\t\t\tlogger.Infof(\"Added tag %s to skipped scene %s\", ret.Name, s.Path)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc getFieldOptions(options []MetadataOptions) map[string]*FieldOptions {\n\t// prefer source-specific field strategies, then the defaults\n\tret := make(map[string]*FieldOptions)\n\tfor _, oo := range options {\n\t\tfor _, f := range oo.FieldOptions {\n\t\t\tif _, found := ret[f.Field]; !found {\n\t\t\t\tret[f.Field] = f\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc getScenePartial(scene *models.Scene, scraped *models.ScrapedScene, fieldOptions map[string]*FieldOptions, setOrganized bool) models.ScenePartial {\n\tpartial := models.ScenePartial{}\n\n\tif scraped.Title != nil && (scene.Title != *scraped.Title) {\n\t\tif shouldSetSingleValueField(fieldOptions[\"title\"], scene.Title != \"\") {\n\t\t\tpartial.Title = models.NewOptionalString(*scraped.Title)\n\t\t}\n\t}\n\tif scraped.Date != nil && (scene.Date == nil || scene.Date.String() != *scraped.Date) {\n\t\tif shouldSetSingleValueField(fieldOptions[\"date\"], scene.Date != nil) {\n\t\t\td, err := models.ParseDate(*scraped.Date)\n\t\t\tif err == nil {\n\t\t\t\tpartial.Date = models.NewOptionalDate(d)\n\t\t\t}\n\t\t}\n\t}\n\tif scraped.Details != nil && (scene.Details != *scraped.Details) {\n\t\tif shouldSetSingleValueField(fieldOptions[\"details\"], scene.Details != \"\") {\n\t\t\tpartial.Details = models.NewOptionalString(*scraped.Details)\n\t\t}\n\t}\n\tif len(scraped.URLs) > 0 && shouldSetSingleValueField(fieldOptions[\"url\"], false) {\n\t\t// if overwrite, then set over the top\n\t\tswitch getFieldStrategy(fieldOptions[\"url\"]) {\n\t\tcase FieldStrategyOverwrite:\n\t\t\t// only overwrite if not equal\n\t\t\tif len(sliceutil.Exclude(scraped.URLs, scene.URLs.List())) != 0 {\n\t\t\t\tpartial.URLs = &models.UpdateStrings{\n\t\t\t\t\tValues: scraped.URLs,\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t}\n\t\t\t}\n\t\tcase FieldStrategyMerge:\n\t\t\t// if merge, add if not already present\n\t\t\turls := sliceutil.AppendUniques(scene.URLs.List(), scraped.URLs)\n\n\t\t\tif len(urls) != len(scene.URLs.List()) {\n\t\t\t\tpartial.URLs = &models.UpdateStrings{\n\t\t\t\t\tValues: urls,\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif scraped.Director != nil && (scene.Director != *scraped.Director) {\n\t\tif shouldSetSingleValueField(fieldOptions[\"director\"], scene.Director != \"\") {\n\t\t\tpartial.Director = models.NewOptionalString(*scraped.Director)\n\t\t}\n\t}\n\tif scraped.Code != nil && (scene.Code != *scraped.Code) {\n\t\tif shouldSetSingleValueField(fieldOptions[\"code\"], scene.Code != \"\") {\n\t\t\tpartial.Code = models.NewOptionalString(*scraped.Code)\n\t\t}\n\t}\n\n\tif setOrganized && !scene.Organized {\n\t\tpartial.Organized = models.NewOptionalBool(true)\n\t}\n\n\treturn partial\n}\n\nfunc getFieldStrategy(strategy *FieldOptions) FieldStrategy {\n\t// if unset then default to MERGE\n\tfs := FieldStrategyMerge\n\n\tif strategy != nil && strategy.Strategy.IsValid() {\n\t\tfs = strategy.Strategy\n\t}\n\n\treturn fs\n}\n\nfunc shouldSetSingleValueField(strategy *FieldOptions, hasExistingValue bool) bool {\n\t// if unset then default to MERGE\n\tfs := getFieldStrategy(strategy)\n\n\tif fs == FieldStrategyIgnore {\n\t\treturn false\n\t}\n\n\treturn !hasExistingValue || fs == FieldStrategyOverwrite\n}\n"
  },
  {
    "path": "internal/identify/identify_test.go",
    "content": "package identify\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"reflect\"\n\t\"slices\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nvar testCtx = context.Background()\n\ntype mockSceneScraper struct {\n\terrIDs  []int\n\tresults map[int][]*models.ScrapedScene\n}\n\nfunc (s mockSceneScraper) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) {\n\tif slices.Contains(s.errIDs, sceneID) {\n\t\treturn nil, errors.New(\"scrape scene error\")\n\t}\n\treturn s.results[sceneID], nil\n}\n\ntype mockHookExecutor struct {\n}\n\nfunc (s mockHookExecutor) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) {\n}\n\nfunc TestSceneIdentifier_Identify(t *testing.T) {\n\tconst (\n\t\terrID1 = iota\n\t\terrID2\n\t\tmissingID\n\t\tfound1ID\n\t\tfound2ID\n\t\tmultiFoundID\n\t\tmultiFound2ID\n\t\terrUpdateID\n\t)\n\n\tvar (\n\t\tskipMultipleTagID    = 1\n\t\tskipMultipleTagIDStr = strconv.Itoa(skipMultipleTagID)\n\t)\n\n\tvar (\n\t\tscrapedTitle  = \"scrapedTitle\"\n\t\tscrapedTitle2 = \"scrapedTitle2\"\n\n\t\tboolFalse = false\n\t\tboolTrue  = true\n\t)\n\n\tdefaultOptions := &MetadataOptions{\n\t\tSetOrganized:  &boolFalse,\n\t\tSetCoverImage: &boolFalse,\n\t\tPerformerGenders: []models.GenderEnum{\n\t\t\tmodels.GenderEnumFemale,\n\t\t\tmodels.GenderEnumTransgenderFemale,\n\t\t\tmodels.GenderEnumTransgenderMale,\n\t\t\tmodels.GenderEnumIntersex,\n\t\t\tmodels.GenderEnumNonBinary,\n\t\t},\n\t\tSkipSingleNamePerformers: &boolFalse,\n\t}\n\tsources := []ScraperSource{\n\t\t{\n\t\t\tScraper: mockSceneScraper{\n\t\t\t\terrIDs: []int{errID1},\n\t\t\t\tresults: map[int][]*models.ScrapedScene{\n\t\t\t\t\tfound1ID: {{\n\t\t\t\t\t\tTitle: &scrapedTitle,\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tScraper: mockSceneScraper{\n\t\t\t\terrIDs: []int{errID2},\n\t\t\t\tresults: map[int][]*models.ScrapedScene{\n\t\t\t\t\tfound2ID: {{\n\t\t\t\t\t\tTitle: &scrapedTitle,\n\t\t\t\t\t}},\n\t\t\t\t\terrUpdateID: {{\n\t\t\t\t\t\tTitle: &scrapedTitle,\n\t\t\t\t\t}},\n\t\t\t\t\tmultiFoundID: {\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: &scrapedTitle,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: &scrapedTitle2,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tmultiFound2ID: {\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: &scrapedTitle,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: &scrapedTitle2,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdb := mocks.NewDatabase()\n\n\tdb.Scene.On(\"GetURLs\", mock.Anything, mock.Anything).Return(nil, nil)\n\tdb.Scene.On(\"UpdatePartial\", mock.Anything, mock.MatchedBy(func(id int) bool {\n\t\treturn id == errUpdateID\n\t}), mock.Anything).Return(nil, errors.New(\"update error\"))\n\tdb.Scene.On(\"UpdatePartial\", mock.Anything, mock.MatchedBy(func(id int) bool {\n\t\treturn id != errUpdateID\n\t}), mock.Anything).Return(nil, nil)\n\n\tdb.Tag.On(\"Find\", mock.Anything, skipMultipleTagID).Return(&models.Tag{\n\t\tID:   skipMultipleTagID,\n\t\tName: skipMultipleTagIDStr,\n\t}, nil)\n\n\ttests := []struct {\n\t\tname    string\n\t\tsceneID int\n\t\toptions *MetadataOptions\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"error scraping\",\n\t\t\terrID1,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error scraping from second\",\n\t\t\terrID2,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"found in first scraper\",\n\t\t\tfound1ID,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"found in second scraper\",\n\t\t\tfound2ID,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not found\",\n\t\t\tmissingID,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error modifying\",\n\t\t\terrUpdateID,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"multiple found\",\n\t\t\tmultiFoundID,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"multiple found - set tag\",\n\t\t\tmultiFound2ID,\n\t\t\t&MetadataOptions{\n\t\t\t\tSkipMultipleMatches:  &boolTrue,\n\t\t\t\tSkipMultipleMatchTag: &skipMultipleTagIDStr,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tidentifier := SceneIdentifier{\n\t\t\t\tTxnManager:                  db,\n\t\t\t\tSceneReaderUpdater:          db.Scene,\n\t\t\t\tStudioReaderWriter:          db.Studio,\n\t\t\t\tPerformerCreator:            db.Performer,\n\t\t\t\tTagFinderCreator:            db.Tag,\n\t\t\t\tDefaultOptions:              defaultOptions,\n\t\t\t\tSources:                     sources,\n\t\t\t\tSceneUpdatePostHookExecutor: mockHookExecutor{},\n\t\t\t}\n\n\t\t\tif tt.options != nil {\n\t\t\t\tidentifier.DefaultOptions = tt.options\n\t\t\t}\n\n\t\t\tscene := &models.Scene{\n\t\t\t\tID:           tt.sceneID,\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tStashIDs:     models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t}\n\t\t\tif err := identifier.Identify(testCtx, scene); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneIdentifier.Identify() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSceneIdentifier_modifyScene(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tboolFalse := false\n\tdefaultOptions := &MetadataOptions{\n\t\tSetOrganized:  &boolFalse,\n\t\tSetCoverImage: &boolFalse,\n\t\tPerformerGenders: []models.GenderEnum{\n\t\t\tmodels.GenderEnumFemale,\n\t\t\tmodels.GenderEnumTransgenderFemale,\n\t\t\tmodels.GenderEnumTransgenderMale,\n\t\t\tmodels.GenderEnumIntersex,\n\t\t\tmodels.GenderEnumNonBinary,\n\t\t},\n\t\tSkipSingleNamePerformers: &boolFalse,\n\t}\n\ttr := &SceneIdentifier{\n\t\tTxnManager:         db,\n\t\tSceneReaderUpdater: db.Scene,\n\t\tStudioReaderWriter: db.Studio,\n\t\tPerformerCreator:   db.Performer,\n\t\tTagFinderCreator:   db.Tag,\n\t\tDefaultOptions:     defaultOptions,\n\t}\n\n\ttype args struct {\n\t\tscene  *models.Scene\n\t\tresult *scrapeResult\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"empty update\",\n\t\t\targs{\n\t\t\t\t&models.Scene{\n\t\t\t\t\tURLs:         models.NewRelatedStrings([]string{}),\n\t\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\t\tStashIDs:     models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t\t},\n\t\t\t\t&scrapeResult{\n\t\t\t\t\tresult: &models.ScrapedScene{},\n\t\t\t\t\tsource: ScraperSource{\n\t\t\t\t\t\tOptions: defaultOptions,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif err := tr.modifyScene(testCtx, tt.args.scene, tt.args.result); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneIdentifier.modifyScene() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_getFieldOptions(t *testing.T) {\n\tconst (\n\t\tinFirst  = \"inFirst\"\n\t\tinSecond = \"inSecond\"\n\t\tinBoth   = \"inBoth\"\n\t)\n\n\ttype args struct {\n\t\toptions []MetadataOptions\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant map[string]*FieldOptions\n\t}{\n\t\t{\n\t\t\t\"simple\",\n\t\t\targs{\n\t\t\t\t[]MetadataOptions{\n\t\t\t\t\t{\n\t\t\t\t\t\tFieldOptions: []*FieldOptions{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tField:    inFirst,\n\t\t\t\t\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tField:    inBoth,\n\t\t\t\t\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tFieldOptions: []*FieldOptions{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tField:    inSecond,\n\t\t\t\t\t\t\t\tStrategy: FieldStrategyMerge,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tField:    inBoth,\n\t\t\t\t\t\t\t\tStrategy: FieldStrategyMerge,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]*FieldOptions{\n\t\t\t\tinFirst: {\n\t\t\t\t\tField:    inFirst,\n\t\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t\t},\n\t\t\t\tinSecond: {\n\t\t\t\t\tField:    inSecond,\n\t\t\t\t\tStrategy: FieldStrategyMerge,\n\t\t\t\t},\n\t\t\t\tinBoth: {\n\t\t\t\t\tField:    inBoth,\n\t\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := getFieldOptions(tt.args.options); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"getFieldOptions() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_getScenePartial(t *testing.T) {\n\tvar (\n\t\toriginalTitle   = \"originalTitle\"\n\t\toriginalDate    = \"2001-01-01\"\n\t\toriginalDetails = \"originalDetails\"\n\t\toriginalURL     = \"originalURL\"\n\t)\n\n\tvar (\n\t\tscrapedTitle   = \"scrapedTitle\"\n\t\tscrapedDate    = \"2002-02-02\"\n\t\tscrapedDetails = \"scrapedDetails\"\n\t\tscrapedURL     = \"scrapedURL\"\n\t)\n\n\toriginalDateObj, _ := models.ParseDate(originalDate)\n\tscrapedDateObj, _ := models.ParseDate(scrapedDate)\n\n\toriginalScene := &models.Scene{\n\t\tTitle:   originalTitle,\n\t\tDate:    &originalDateObj,\n\t\tDetails: originalDetails,\n\t\tURLs:    models.NewRelatedStrings([]string{originalURL}),\n\t}\n\n\torganisedScene := *originalScene\n\torganisedScene.Organized = true\n\n\temptyScene := &models.Scene{\n\t\tURLs: models.NewRelatedStrings([]string{}),\n\t}\n\n\tpostPartial := models.ScenePartial{\n\t\tTitle:   models.NewOptionalString(scrapedTitle),\n\t\tDate:    models.NewOptionalDate(scrapedDateObj),\n\t\tDetails: models.NewOptionalString(scrapedDetails),\n\t\tURLs: &models.UpdateStrings{\n\t\t\tValues: []string{scrapedURL},\n\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t},\n\t}\n\n\tpostPartialMerge := postPartial\n\tpostPartialMerge.URLs = &models.UpdateStrings{\n\t\tValues: []string{scrapedURL},\n\t\tMode:   models.RelationshipUpdateModeSet,\n\t}\n\n\tscrapedScene := &models.ScrapedScene{\n\t\tTitle:   &scrapedTitle,\n\t\tDate:    &scrapedDate,\n\t\tDetails: &scrapedDetails,\n\t\tURLs:    []string{scrapedURL},\n\t}\n\n\tscrapedUnchangedScene := &models.ScrapedScene{\n\t\tTitle:   &originalTitle,\n\t\tDate:    &originalDate,\n\t\tDetails: &originalDetails,\n\t\tURLs:    []string{originalURL},\n\t}\n\n\tmakeFieldOptions := func(input *FieldOptions) map[string]*FieldOptions {\n\t\treturn map[string]*FieldOptions{\n\t\t\t\"title\":   input,\n\t\t\t\"date\":    input,\n\t\t\t\"details\": input,\n\t\t\t\"url\":     input,\n\t\t}\n\t}\n\n\toverwriteAll := makeFieldOptions(&FieldOptions{\n\t\tStrategy: FieldStrategyOverwrite,\n\t})\n\tignoreAll := makeFieldOptions(&FieldOptions{\n\t\tStrategy: FieldStrategyIgnore,\n\t})\n\tmergeAll := makeFieldOptions(&FieldOptions{\n\t\tStrategy: FieldStrategyMerge,\n\t})\n\n\tsetOrganised := true\n\n\ttype args struct {\n\t\tscene        *models.Scene\n\t\tscraped      *models.ScrapedScene\n\t\tfieldOptions map[string]*FieldOptions\n\t\tsetOrganized bool\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant models.ScenePartial\n\t}{\n\t\t{\n\t\t\t\"overwrite all\",\n\t\t\targs{\n\t\t\t\toriginalScene,\n\t\t\t\tscrapedScene,\n\t\t\t\toverwriteAll,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tpostPartial,\n\t\t},\n\t\t{\n\t\t\t\"ignore all\",\n\t\t\targs{\n\t\t\t\toriginalScene,\n\t\t\t\tscrapedScene,\n\t\t\t\tignoreAll,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tmodels.ScenePartial{},\n\t\t},\n\t\t{\n\t\t\t\"merge (existing values)\",\n\t\t\targs{\n\t\t\t\toriginalScene,\n\t\t\t\tscrapedScene,\n\t\t\t\tmergeAll,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tmodels.ScenePartial{\n\t\t\t\tURLs: &models.UpdateStrings{\n\t\t\t\t\tValues: []string{originalURL, scrapedURL},\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"merge (empty values)\",\n\t\t\targs{\n\t\t\t\temptyScene,\n\t\t\t\tscrapedScene,\n\t\t\t\tmergeAll,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tpostPartialMerge,\n\t\t},\n\t\t{\n\t\t\t\"unchanged\",\n\t\t\targs{\n\t\t\t\toriginalScene,\n\t\t\t\tscrapedUnchangedScene,\n\t\t\t\toverwriteAll,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tmodels.ScenePartial{},\n\t\t},\n\t\t{\n\t\t\t\"set organized\",\n\t\t\targs{\n\t\t\t\toriginalScene,\n\t\t\t\tscrapedUnchangedScene,\n\t\t\t\toverwriteAll,\n\t\t\t\ttrue,\n\t\t\t},\n\t\t\tmodels.ScenePartial{\n\t\t\t\tOrganized: models.NewOptionalBool(setOrganised),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"set organized unchanged\",\n\t\t\targs{\n\t\t\t\t&organisedScene,\n\t\t\t\tscrapedUnchangedScene,\n\t\t\t\toverwriteAll,\n\t\t\t\ttrue,\n\t\t\t},\n\t\t\tmodels.ScenePartial{},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := getScenePartial(tt.args.scene, tt.args.scraped, tt.args.fieldOptions, tt.args.setOrganized)\n\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_shouldSetSingleValueField(t *testing.T) {\n\tconst invalid = \"invalid\"\n\n\ttype args struct {\n\t\tstrategy         *FieldOptions\n\t\thasExistingValue bool\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant bool\n\t}{\n\t\t{\n\t\t\t\"ignore\",\n\t\t\targs{\n\t\t\t\t&FieldOptions{\n\t\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t\t},\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge existing\",\n\t\t\targs{\n\t\t\t\t&FieldOptions{\n\t\t\t\t\tStrategy: FieldStrategyMerge,\n\t\t\t\t},\n\t\t\t\ttrue,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge absent\",\n\t\t\targs{\n\t\t\t\t&FieldOptions{\n\t\t\t\t\tStrategy: FieldStrategyMerge,\n\t\t\t\t},\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"overwrite\",\n\t\t\targs{\n\t\t\t\t&FieldOptions{\n\t\t\t\t\tStrategy: FieldStrategyOverwrite,\n\t\t\t\t},\n\t\t\t\ttrue,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"nil (merge) existing\",\n\t\t\targs{\n\t\t\t\t&FieldOptions{},\n\t\t\t\ttrue,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"nil (merge) absent\",\n\t\t\targs{\n\t\t\t\t&FieldOptions{},\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid (merge) existing\",\n\t\t\targs{\n\t\t\t\t&FieldOptions{\n\t\t\t\t\tStrategy: invalid,\n\t\t\t\t},\n\t\t\t\ttrue,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid (merge) absent\",\n\t\t\targs{\n\t\t\t\t&FieldOptions{\n\t\t\t\t\tStrategy: invalid,\n\t\t\t\t},\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := shouldSetSingleValueField(tt.args.strategy, tt.args.hasExistingValue); got != tt.want {\n\t\t\t\tt.Errorf(\"shouldSetSingleValueField() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/identify/options.go",
    "content": "package identify\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n)\n\ntype Source struct {\n\tSource *scraper.Source `json:\"source\"`\n\t// Options defined for a source override the defaults\n\tOptions *MetadataOptions `json:\"options\"`\n}\n\ntype Options struct {\n\t// An ordered list of sources to identify items with. Only the first source that finds a match is used.\n\tSources []*Source `json:\"sources\"`\n\t// Options defined here override the configured defaults\n\tOptions *MetadataOptions `json:\"options\"`\n\t// scene ids to identify\n\tSceneIDs []string `json:\"sceneIDs\"`\n\t// paths of scenes to identify - ignored if scene ids are set\n\tPaths []string `json:\"paths\"`\n}\n\ntype MetadataOptions struct {\n\t// any fields missing from here are defaulted to MERGE and createMissing false\n\tFieldOptions []*FieldOptions `json:\"fieldOptions\"`\n\t// defaults to true if not provided\n\tSetCoverImage *bool `json:\"setCoverImage\"`\n\tSetOrganized  *bool `json:\"setOrganized\"`\n\t// defaults to true if not provided\n\t// Deprecated: use PerformerGenders instead\n\tIncludeMalePerformers *bool `json:\"includeMalePerformers\"`\n\t// Filter to only include performers with these genders. If not provided, all genders are included.\n\tPerformerGenders []models.GenderEnum `json:\"performerGenders\"`\n\t// defaults to true if not provided\n\tSkipMultipleMatches *bool `json:\"skipMultipleMatches\"`\n\t// ID of tag to tag skipped multiple matches with\n\tSkipMultipleMatchTag *string `json:\"skipMultipleMatchTag\"`\n\t// defaults to true if not provided\n\tSkipSingleNamePerformers *bool `json:\"skipSingleNamePerformers\"`\n\t// ID of tag to tag skipped single name performers with\n\tSkipSingleNamePerformerTag *string `json:\"skipSingleNamePerformerTag\"`\n}\n\ntype FieldOptions struct {\n\tField    string        `json:\"field\"`\n\tStrategy FieldStrategy `json:\"strategy\"`\n\t// creates missing objects if needed - only applicable for performers, tags and studios\n\tCreateMissing *bool `json:\"createMissing\"`\n}\n\ntype FieldStrategy string\n\nconst (\n\t// Never sets the field value\n\tFieldStrategyIgnore FieldStrategy = \"IGNORE\"\n\t// For multi-value fields, merge with existing.\n\t// For single-value fields, ignore if already set\n\tFieldStrategyMerge FieldStrategy = \"MERGE\"\n\t// Always replaces the value if a value is found.\n\t//   For multi-value fields, any existing values are removed and replaced with the\n\t//   scraped values.\n\tFieldStrategyOverwrite FieldStrategy = \"OVERWRITE\"\n)\n\nvar AllFieldStrategy = []FieldStrategy{\n\tFieldStrategyIgnore,\n\tFieldStrategyMerge,\n\tFieldStrategyOverwrite,\n}\n\nfunc (e FieldStrategy) IsValid() bool {\n\tswitch e {\n\tcase FieldStrategyIgnore, FieldStrategyMerge, FieldStrategyOverwrite:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e FieldStrategy) String() string {\n\treturn string(e)\n}\n\nfunc (e *FieldStrategy) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = FieldStrategy(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid IdentifyFieldStrategy\", str)\n\t}\n\treturn nil\n}\n\nfunc (e FieldStrategy) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n"
  },
  {
    "path": "internal/identify/performer.go",
    "content": "package identify\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype PerformerCreator interface {\n\tmodels.PerformerCreator\n\tUpdateImage(ctx context.Context, performerID int, image []byte) error\n}\n\nfunc getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer, createMissing bool, skipSingleNamePerformers bool) (*int, error) {\n\tif p.StoredID != nil {\n\t\t// existing performer, just add it\n\t\tperformerID, err := strconv.Atoi(*p.StoredID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error converting performer ID %s: %w\", *p.StoredID, err)\n\t\t}\n\n\t\treturn &performerID, nil\n\t} else if createMissing && p.Name != nil { // name is mandatory\n\t\t// skip single name performers with no disambiguation\n\t\tif skipSingleNamePerformers && !strings.Contains(*p.Name, \" \") && (p.Disambiguation == nil || len(*p.Disambiguation) == 0) {\n\t\t\treturn nil, ErrSkipSingleNamePerformer\n\t\t}\n\t\treturn createMissingPerformer(ctx, endpoint, w, p)\n\t}\n\n\treturn nil, nil\n}\n\nfunc createMissingPerformer(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer) (*int, error) {\n\tnewPerformer := p.ToPerformer(endpoint, nil)\n\tperformerImage, err := p.GetImage(ctx, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = w.Create(ctx, &models.CreatePerformerInput{Performer: newPerformer})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating performer: %w\", err)\n\t}\n\n\t// update image table\n\tif len(performerImage) > 0 {\n\t\tif err := w.UpdateImage(ctx, newPerformer.ID, performerImage); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn &newPerformer.ID, nil\n}\n"
  },
  {
    "path": "internal/identify/performer_test.go",
    "content": "package identify\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc Test_getPerformerID(t *testing.T) {\n\tconst (\n\t\temptyEndpoint = \"\"\n\t\tendpoint      = \"endpoint\"\n\t)\n\tinvalidStoredID := \"invalidStoredID\"\n\tvalidStoredIDStr := \"1\"\n\tvalidStoredID := 1\n\tremoteSiteID := \"2\"\n\tname := \"name\"\n\n\tdb := mocks.NewDatabase()\n\n\tdb.Performer.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreatePerformerInput\")).Run(func(args mock.Arguments) {\n\t\tp := args.Get(1).(*models.CreatePerformerInput)\n\t\tp.ID = validStoredID\n\t}).Return(nil)\n\n\ttype args struct {\n\t\tendpoint       string\n\t\tp              *models.ScrapedPerformer\n\t\tcreateMissing  bool\n\t\tskipSingleName bool\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    *int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"no performer\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{},\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid stored id\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{\n\t\t\t\t\tStoredID: &invalidStoredID,\n\t\t\t\t},\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"valid stored id\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{\n\t\t\t\t\tStoredID: &validStoredIDStr,\n\t\t\t\t},\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\t&validStoredID,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"nil stored not creating\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{\n\t\t\t\t\tName: &name,\n\t\t\t\t},\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"nil name creating\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{},\n\t\t\t\ttrue,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"single name no disambig creating\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{\n\t\t\t\t\tName: &name,\n\t\t\t\t},\n\t\t\t\ttrue,\n\t\t\t\ttrue,\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"valid name creating\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{\n\t\t\t\t\tName:         &name,\n\t\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t\t},\n\t\t\t\ttrue,\n\t\t\t\tfalse,\n\t\t\t},\n\t\t\t&validStoredID,\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := getPerformerID(testCtx, tt.args.endpoint, db.Performer, tt.args.p, tt.args.createMissing, tt.args.skipSingleName)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"getPerformerID() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"getPerformerID() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_createMissingPerformer(t *testing.T) {\n\temptyEndpoint := \"\"\n\tvalidEndpoint := \"validEndpoint\"\n\tremoteSiteID := \"remoteSiteID\"\n\tvalidName := \"validName\"\n\tinvalidName := \"invalidName\"\n\tperformerID := 1\n\n\tdb := mocks.NewDatabase()\n\n\tdb.Performer.On(\"Create\", testCtx, mock.MatchedBy(func(p *models.CreatePerformerInput) bool {\n\t\treturn p.Name == validName\n\t})).Run(func(args mock.Arguments) {\n\t\tp := args.Get(1).(*models.CreatePerformerInput)\n\t\tp.ID = performerID\n\t}).Return(nil)\n\n\tdb.Performer.On(\"Create\", testCtx, mock.MatchedBy(func(p *models.CreatePerformerInput) bool {\n\t\treturn p.Name == invalidName\n\t})).Return(errors.New(\"error creating performer\"))\n\n\ttype args struct {\n\t\tendpoint string\n\t\tp        *models.ScrapedPerformer\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    *int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"simple\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{\n\t\t\t\t\tName:         &validName,\n\t\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&performerID,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error creating\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedPerformer{\n\t\t\t\t\tName:         &invalidName,\n\t\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"valid stash id\",\n\t\t\targs{\n\t\t\t\tvalidEndpoint,\n\t\t\t\t&models.ScrapedPerformer{\n\t\t\t\t\tName:         &validName,\n\t\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&performerID,\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := createMissingPerformer(testCtx, tt.args.endpoint, db.Performer, tt.args.p)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"createMissingPerformer() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"createMissingPerformer() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/identify/scene.go",
    "content": "package identify\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype SceneCoverGetter interface {\n\tGetCover(ctx context.Context, sceneID int) ([]byte, error)\n}\n\ntype SceneReaderUpdater interface {\n\tSceneCoverGetter\n\tmodels.SceneUpdater\n\tmodels.PerformerIDLoader\n\tmodels.TagIDLoader\n\tmodels.StashIDLoader\n\tmodels.URLLoader\n}\n\ntype sceneRelationships struct {\n\tsceneReader              SceneCoverGetter\n\tstudioReaderWriter       models.StudioReaderWriter\n\tperformerCreator         PerformerCreator\n\ttagCreator               models.TagCreator\n\tscene                    *models.Scene\n\tresult                   *scrapeResult\n\tfieldOptions             map[string]*FieldOptions\n\tskipSingleNamePerformers bool\n}\n\nfunc (g sceneRelationships) studio(ctx context.Context) (*int, error) {\n\texistingID := g.scene.StudioID\n\tfieldStrategy := g.fieldOptions[\"studio\"]\n\tcreateMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing)\n\n\tscraped := g.result.result.Studio\n\tendpoint := g.result.source.RemoteSite\n\n\tif scraped == nil || !shouldSetSingleValueField(fieldStrategy, existingID != nil) {\n\t\treturn nil, nil\n\t}\n\n\tif scraped.StoredID != nil {\n\t\t// existing studio, just set it\n\t\tstudioID, err := strconv.Atoi(*scraped.StoredID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error converting studio ID %s: %w\", *scraped.StoredID, err)\n\t\t}\n\n\t\t// only return value if different to current\n\t\tif existingID == nil || *existingID != studioID {\n\t\t\treturn &studioID, nil\n\t\t}\n\t} else if createMissing {\n\t\treturn createMissingStudio(ctx, endpoint, g.studioReaderWriter, scraped)\n\t}\n\n\treturn nil, nil\n}\n\nfunc (g sceneRelationships) performers(ctx context.Context, allowedGenders []models.GenderEnum) ([]int, error) {\n\tfieldStrategy := g.fieldOptions[\"performers\"]\n\tscraped := g.result.result.Performers\n\n\t// just check if ignored\n\tif len(scraped) == 0 || !shouldSetSingleValueField(fieldStrategy, false) {\n\t\treturn nil, nil\n\t}\n\n\tcreateMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing)\n\tstrategy := FieldStrategyMerge\n\tif fieldStrategy != nil {\n\t\tstrategy = fieldStrategy.Strategy\n\t}\n\n\tendpoint := g.result.source.RemoteSite\n\n\tvar performerIDs []int\n\toriginalPerformerIDs := g.scene.PerformerIDs.List()\n\n\tif strategy == FieldStrategyMerge {\n\t\t// add to existing\n\t\tperformerIDs = originalPerformerIDs\n\t}\n\n\tsingleNamePerformerSkipped := false\n\n\tfor _, p := range scraped {\n\t\tif allowedGenders != nil && p.Gender != nil {\n\t\t\tgender := models.GenderEnum(strings.ToUpper(*p.Gender))\n\t\t\tif !slices.Contains(allowedGenders, gender) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tperformerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing, g.skipSingleNamePerformers)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, ErrSkipSingleNamePerformer) {\n\t\t\t\tsingleNamePerformerSkipped = true\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif performerID != nil {\n\t\t\tperformerIDs = sliceutil.AppendUnique(performerIDs, *performerID)\n\t\t}\n\t}\n\n\t// don't return if nothing was added\n\tif sliceutil.SliceSame(originalPerformerIDs, performerIDs) {\n\t\tif singleNamePerformerSkipped {\n\t\t\treturn nil, ErrSkipSingleNamePerformer\n\t\t}\n\t\treturn nil, nil\n\t}\n\n\tif singleNamePerformerSkipped {\n\t\treturn performerIDs, ErrSkipSingleNamePerformer\n\t}\n\treturn performerIDs, nil\n}\n\nfunc (g sceneRelationships) tags(ctx context.Context) ([]int, error) {\n\tfieldStrategy := g.fieldOptions[\"tags\"]\n\tscraped := g.result.result.Tags\n\ttarget := g.scene\n\n\t// just check if ignored\n\tif len(scraped) == 0 || !shouldSetSingleValueField(fieldStrategy, false) {\n\t\treturn nil, nil\n\t}\n\n\tcreateMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing)\n\tstrategy := FieldStrategyMerge\n\tif fieldStrategy != nil {\n\t\tstrategy = fieldStrategy.Strategy\n\t}\n\n\tvar tagIDs []int\n\toriginalTagIDs := target.TagIDs.List()\n\n\tif strategy == FieldStrategyMerge {\n\t\t// add to existing\n\t\ttagIDs = originalTagIDs\n\t}\n\n\tendpoint := g.result.source.RemoteSite\n\n\tfor _, t := range scraped {\n\t\tif t.StoredID != nil {\n\t\t\t// existing tag, just add it\n\t\t\ttagID, err := strconv.ParseInt(*t.StoredID, 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error converting tag ID %s: %w\", *t.StoredID, err)\n\t\t\t}\n\n\t\t\ttagIDs = sliceutil.AppendUnique(tagIDs, int(tagID))\n\t\t} else if createMissing {\n\t\t\tnewTag := t.ToTag(endpoint, nil)\n\n\t\t\terr := g.tagCreator.Create(ctx, &models.CreateTagInput{\n\t\t\t\tTag: newTag,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error creating tag: %w\", err)\n\t\t\t}\n\n\t\t\ttagIDs = append(tagIDs, newTag.ID)\n\t\t}\n\t}\n\n\t// don't return if nothing was added\n\tif sliceutil.SliceSame(originalTagIDs, tagIDs) {\n\t\treturn nil, nil\n\t}\n\n\treturn tagIDs, nil\n}\n\n// stashIDs returns the updated stash IDs for the scene\n// returns nil if not applicable or no changes were made\n// if setUpdateTime is true, then the updated_at field will be set to the current time\n// for the applicable matching stash ID\nfunc (g sceneRelationships) stashIDs(ctx context.Context, setUpdateTime bool) ([]models.StashID, error) {\n\tupdateTime := time.Now()\n\n\tremoteSiteID := g.result.result.RemoteSiteID\n\tfieldStrategy := g.fieldOptions[\"stash_ids\"]\n\ttarget := g.scene\n\n\tendpoint := g.result.source.RemoteSite\n\n\t// just check if ignored\n\tif remoteSiteID == nil || endpoint == \"\" || !shouldSetSingleValueField(fieldStrategy, false) {\n\t\treturn nil, nil\n\t}\n\n\tstrategy := FieldStrategyMerge\n\tif fieldStrategy != nil {\n\t\tstrategy = fieldStrategy.Strategy\n\t}\n\n\tvar stashIDs models.StashIDs\n\toriginalStashIDs := target.StashIDs.List()\n\n\tif strategy == FieldStrategyMerge {\n\t\t// add to existing\n\t\t// make a copy so we don't modify the original\n\t\tstashIDs = append(stashIDs, originalStashIDs...)\n\t}\n\n\t// find and update the stash id if it exists\n\tfor i, stashID := range stashIDs {\n\t\tif endpoint == stashID.Endpoint {\n\t\t\t// if stashID is the same, then don't set\n\t\t\tif !setUpdateTime && stashID.StashID == *remoteSiteID {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\n\t\t\t// replace the stash id and return\n\t\t\tstashID.StashID = *remoteSiteID\n\t\t\tstashID.UpdatedAt = updateTime\n\t\t\tstashIDs[i] = stashID\n\t\t\treturn stashIDs, nil\n\t\t}\n\t}\n\n\t// not found, create new entry\n\tstashIDs = append(stashIDs, models.StashID{\n\t\tStashID:   *remoteSiteID,\n\t\tEndpoint:  endpoint,\n\t\tUpdatedAt: updateTime,\n\t})\n\n\t// don't return if nothing was changed\n\t// if we're setting update time, then we always return\n\tif !setUpdateTime && stashIDs.HasSameStashIDs(originalStashIDs) {\n\t\treturn nil, nil\n\t}\n\n\treturn stashIDs, nil\n}\n\nfunc (g sceneRelationships) cover(ctx context.Context) ([]byte, error) {\n\tscraped := g.result.result.Image\n\n\tif scraped == nil || *scraped == \"\" {\n\t\treturn nil, nil\n\t}\n\n\t// always overwrite if present\n\texistingCover, err := g.sceneReader.GetCover(ctx, g.scene.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error getting scene cover: %v\", err)\n\t}\n\n\tdata, err := utils.ProcessImageInput(ctx, *scraped)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error processing image input: %w\", err)\n\t}\n\n\t// only return if different\n\tif !bytes.Equal(existingCover, data) {\n\t\treturn data, nil\n\t}\n\n\treturn nil, nil\n}\n"
  },
  {
    "path": "internal/identify/scene_test.go",
    "content": "package identify\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc Test_sceneRelationships_studio(t *testing.T) {\n\tvalidStoredID := \"1\"\n\tremoteSiteID := \"2\"\n\tvar validStoredIDInt = 1\n\tinvalidStoredID := \"invalidStoredID\"\n\tcreateMissing := true\n\n\tdefaultOptions := &FieldOptions{\n\t\tStrategy: FieldStrategyMerge,\n\t}\n\n\tdb := mocks.NewDatabase()\n\n\tdb.Studio.On(\"Create\", testCtx, mock.Anything).Run(func(args mock.Arguments) {\n\t\ts := args.Get(1).(*models.CreateStudioInput)\n\t\ts.ID = validStoredIDInt\n\t}).Return(nil)\n\n\ttr := sceneRelationships{\n\t\tstudioReaderWriter: db.Studio,\n\t\tfieldOptions:       make(map[string]*FieldOptions),\n\t}\n\n\ttests := []struct {\n\t\tname         string\n\t\tscene        *models.Scene\n\t\tfieldOptions *FieldOptions\n\t\tresult       *models.ScrapedStudio\n\t\twant         *int\n\t\twantErr      bool\n\t}{\n\t\t{\n\t\t\t\"nil studio\",\n\t\t\t&models.Scene{},\n\t\t\tdefaultOptions,\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"ignore\",\n\t\t\t&models.Scene{},\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t},\n\t\t\t&models.ScrapedStudio{\n\t\t\t\tStoredID: &validStoredID,\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid stored id\",\n\t\t\t&models.Scene{},\n\t\t\tdefaultOptions,\n\t\t\t&models.ScrapedStudio{\n\t\t\t\tStoredID: &invalidStoredID,\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"same stored id\",\n\t\t\t&models.Scene{\n\t\t\t\tStudioID: &validStoredIDInt,\n\t\t\t},\n\t\t\tdefaultOptions,\n\t\t\t&models.ScrapedStudio{\n\t\t\t\tStoredID: &validStoredID,\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"different stored id\",\n\t\t\t&models.Scene{},\n\t\t\tdefaultOptions,\n\t\t\t&models.ScrapedStudio{\n\t\t\t\tStoredID: &validStoredID,\n\t\t\t},\n\t\t\t&validStoredIDInt,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"no create missing\",\n\t\t\t&models.Scene{},\n\t\t\tdefaultOptions,\n\t\t\t&models.ScrapedStudio{},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"create missing\",\n\t\t\t&models.Scene{},\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy:      FieldStrategyMerge,\n\t\t\t\tCreateMissing: &createMissing,\n\t\t\t},\n\t\t\t&models.ScrapedStudio{RemoteSiteID: &remoteSiteID},\n\t\t\t&validStoredIDInt,\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttr.scene = tt.scene\n\t\t\ttr.fieldOptions[\"studio\"] = tt.fieldOptions\n\t\t\ttr.result = &scrapeResult{\n\t\t\t\tsource: ScraperSource{\n\t\t\t\t\tRemoteSite: \"endpoint\",\n\t\t\t\t},\n\t\t\t\tresult: &models.ScrapedScene{\n\t\t\t\t\tStudio: tt.result,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tgot, err := tr.studio(testCtx)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneRelationships.studio() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"sceneRelationships.studio() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneRelationships_performers(t *testing.T) {\n\tconst (\n\t\tsceneID = iota\n\t\tsceneWithPerformerID\n\t\terrSceneID\n\t\texistingPerformerID\n\t\tvalidStoredIDInt\n\t)\n\tvalidStoredID := strconv.Itoa(validStoredIDInt)\n\tinvalidStoredID := \"invalidStoredID\"\n\tcreateMissing := true\n\texistingPerformerStr := strconv.Itoa(existingPerformerID)\n\tvalidName := \"validName\"\n\tfemale := models.GenderEnumFemale.String()\n\tmale := models.GenderEnumMale.String()\n\n\tdefaultOptions := &FieldOptions{\n\t\tStrategy: FieldStrategyMerge,\n\t}\n\n\temptyScene := &models.Scene{\n\t\tID:           sceneID,\n\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\tStashIDs:     models.NewRelatedStashIDs([]models.StashID{}),\n\t}\n\n\tsceneWithPerformer := &models.Scene{\n\t\tID: sceneWithPerformerID,\n\t\tPerformerIDs: models.NewRelatedIDs([]int{\n\t\t\texistingPerformerID,\n\t\t}),\n\t}\n\n\tdb := mocks.NewDatabase()\n\n\ttr := sceneRelationships{\n\t\tsceneReader:  db.Scene,\n\t\tfieldOptions: make(map[string]*FieldOptions),\n\t}\n\n\ttests := []struct {\n\t\tname           string\n\t\tscene          *models.Scene\n\t\tfieldOptions   *FieldOptions\n\t\tscraped        []*models.ScrapedPerformer\n\t\tallowedGenders []models.GenderEnum\n\t\twant           []int\n\t\twantErr        bool\n\t}{\n\t\t{\n\t\t\t\"ignore\",\n\t\t\temptyScene,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t},\n\t\t\t[]*models.ScrapedPerformer{\n\t\t\t\t{\n\t\t\t\t\tStoredID: &validStoredID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\temptyScene,\n\t\t\tdefaultOptions,\n\t\t\t[]*models.ScrapedPerformer{},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge existing\",\n\t\t\tsceneWithPerformer,\n\t\t\tdefaultOptions,\n\t\t\t[]*models.ScrapedPerformer{\n\t\t\t\t{\n\t\t\t\t\tName:     &validName,\n\t\t\t\t\tStoredID: &existingPerformerStr,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge add\",\n\t\t\tsceneWithPerformer,\n\t\t\tdefaultOptions,\n\t\t\t[]*models.ScrapedPerformer{\n\t\t\t\t{\n\t\t\t\t\tName:     &validName,\n\t\t\t\t\tStoredID: &validStoredID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{existingPerformerID, validStoredIDInt},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"ignore male\",\n\t\t\temptyScene,\n\t\t\tdefaultOptions,\n\t\t\t[]*models.ScrapedPerformer{\n\t\t\t\t{\n\t\t\t\t\tName:     &validName,\n\t\t\t\t\tStoredID: &validStoredID,\n\t\t\t\t\tGender:   &male,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"overwrite\",\n\t\t\tsceneWithPerformer,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyOverwrite,\n\t\t\t},\n\t\t\t[]*models.ScrapedPerformer{\n\t\t\t\t{\n\t\t\t\t\tName:     &validName,\n\t\t\t\t\tStoredID: &validStoredID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{validStoredIDInt},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"ignore male (not male)\",\n\t\t\tsceneWithPerformer,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyOverwrite,\n\t\t\t},\n\t\t\t[]*models.ScrapedPerformer{\n\t\t\t\t{\n\t\t\t\t\tName:     &validName,\n\t\t\t\t\tStoredID: &validStoredID,\n\t\t\t\t\tGender:   &female,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary},\n\t\t\t[]int{validStoredIDInt},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error getting tag ID\",\n\t\t\temptyScene,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy:      FieldStrategyOverwrite,\n\t\t\t\tCreateMissing: &createMissing,\n\t\t\t},\n\t\t\t[]*models.ScrapedPerformer{\n\t\t\t\t{\n\t\t\t\t\tName:     &validName,\n\t\t\t\t\tStoredID: &invalidStoredID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttr.scene = tt.scene\n\t\t\ttr.fieldOptions[\"performers\"] = tt.fieldOptions\n\t\t\ttr.result = &scrapeResult{\n\t\t\t\tresult: &models.ScrapedScene{\n\t\t\t\t\tPerformers: tt.scraped,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tgot, err := tr.performers(testCtx, tt.allowedGenders)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneRelationships.performers() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"sceneRelationships.performers() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneRelationships_tags(t *testing.T) {\n\tconst (\n\t\tsceneID = iota\n\t\tsceneWithTagID\n\t\terrSceneID\n\t\texistingID\n\t\tvalidStoredIDInt\n\t)\n\tvalidStoredID := strconv.Itoa(validStoredIDInt)\n\tinvalidStoredID := \"invalidStoredID\"\n\tcreateMissing := true\n\texistingIDStr := strconv.Itoa(existingID)\n\tvalidName := \"validName\"\n\tinvalidName := \"invalidName\"\n\n\tdefaultOptions := &FieldOptions{\n\t\tStrategy: FieldStrategyMerge,\n\t}\n\n\temptyScene := &models.Scene{\n\t\tID:           sceneID,\n\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\tStashIDs:     models.NewRelatedStashIDs([]models.StashID{}),\n\t}\n\n\tsceneWithTag := &models.Scene{\n\t\tID: sceneWithTagID,\n\t\tTagIDs: models.NewRelatedIDs([]int{\n\t\t\texistingID,\n\t\t}),\n\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\tStashIDs:     models.NewRelatedStashIDs([]models.StashID{}),\n\t}\n\n\tdb := mocks.NewDatabase()\n\n\tdb.Tag.On(\"Create\", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool {\n\t\treturn p.Tag.Name == validName\n\t})).Run(func(args mock.Arguments) {\n\t\tt := args.Get(1).(*models.CreateTagInput)\n\t\tt.Tag.ID = validStoredIDInt\n\t}).Return(nil)\n\tdb.Tag.On(\"Create\", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool {\n\t\treturn p.Tag.Name == invalidName\n\t})).Return(errors.New(\"error creating tag\"))\n\n\ttr := sceneRelationships{\n\t\tsceneReader:  db.Scene,\n\t\ttagCreator:   db.Tag,\n\t\tfieldOptions: make(map[string]*FieldOptions),\n\t}\n\n\ttests := []struct {\n\t\tname         string\n\t\tscene        *models.Scene\n\t\tfieldOptions *FieldOptions\n\t\tscraped      []*models.ScrapedTag\n\t\twant         []int\n\t\twantErr      bool\n\t}{\n\t\t{\n\t\t\t\"ignore\",\n\t\t\temptyScene,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t},\n\t\t\t[]*models.ScrapedTag{\n\t\t\t\t{\n\t\t\t\t\tStoredID: &validStoredID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\temptyScene,\n\t\t\tdefaultOptions,\n\t\t\t[]*models.ScrapedTag{},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge existing\",\n\t\t\tsceneWithTag,\n\t\t\tdefaultOptions,\n\t\t\t[]*models.ScrapedTag{\n\t\t\t\t{\n\t\t\t\t\tName:     validName,\n\t\t\t\t\tStoredID: &existingIDStr,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge add\",\n\t\t\tsceneWithTag,\n\t\t\tdefaultOptions,\n\t\t\t[]*models.ScrapedTag{\n\t\t\t\t{\n\t\t\t\t\tName:     validName,\n\t\t\t\t\tStoredID: &validStoredID,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{existingID, validStoredIDInt},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"overwrite\",\n\t\t\tsceneWithTag,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyOverwrite,\n\t\t\t},\n\t\t\t[]*models.ScrapedTag{\n\t\t\t\t{\n\t\t\t\t\tName:     validName,\n\t\t\t\t\tStoredID: &validStoredID,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{validStoredIDInt},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error getting tag ID\",\n\t\t\temptyScene,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyOverwrite,\n\t\t\t},\n\t\t\t[]*models.ScrapedTag{\n\t\t\t\t{\n\t\t\t\t\tName:     validName,\n\t\t\t\t\tStoredID: &invalidStoredID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"create missing\",\n\t\t\temptyScene,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy:      FieldStrategyOverwrite,\n\t\t\t\tCreateMissing: &createMissing,\n\t\t\t},\n\t\t\t[]*models.ScrapedTag{\n\t\t\t\t{\n\t\t\t\t\tName: validName,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{validStoredIDInt},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error creating\",\n\t\t\temptyScene,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy:      FieldStrategyOverwrite,\n\t\t\t\tCreateMissing: &createMissing,\n\t\t\t},\n\t\t\t[]*models.ScrapedTag{\n\t\t\t\t{\n\t\t\t\t\tName: invalidName,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttr.scene = tt.scene\n\t\t\ttr.fieldOptions[\"tags\"] = tt.fieldOptions\n\t\t\ttr.result = &scrapeResult{\n\t\t\t\tresult: &models.ScrapedScene{\n\t\t\t\t\tTags: tt.scraped,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tgot, err := tr.tags(testCtx)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneRelationships.tags() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"sceneRelationships.tags() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneRelationships_stashIDs(t *testing.T) {\n\tconst (\n\t\tsceneID = iota\n\t\tsceneWithStashID\n\t\terrSceneID\n\t\texistingID\n\t\tvalidStoredIDInt\n\t)\n\texistingEndpoint := \"existingEndpoint\"\n\tnewEndpoint := \"newEndpoint\"\n\tremoteSiteID := \"remoteSiteID\"\n\tnewRemoteSiteID := \"newRemoteSiteID\"\n\n\tdefaultOptions := &FieldOptions{\n\t\tStrategy: FieldStrategyMerge,\n\t}\n\n\temptyScene := &models.Scene{\n\t\tID: sceneID,\n\t}\n\n\tsceneWithStashIDs := &models.Scene{\n\t\tID: sceneWithStashID,\n\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t{\n\t\t\t\tStashID:   remoteSiteID,\n\t\t\t\tEndpoint:  existingEndpoint,\n\t\t\t\tUpdatedAt: time.Time{},\n\t\t\t},\n\t\t}),\n\t}\n\n\tdb := mocks.NewDatabase()\n\n\ttr := sceneRelationships{\n\t\tsceneReader:  db.Scene,\n\t\tfieldOptions: make(map[string]*FieldOptions),\n\t}\n\n\tsetTime := time.Now()\n\n\ttests := []struct {\n\t\tname          string\n\t\tscene         *models.Scene\n\t\tfieldOptions  *FieldOptions\n\t\tendpoint      string\n\t\tremoteSiteID  *string\n\t\tsetUpdateTime bool\n\t\twant          []models.StashID\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"ignore\",\n\t\t\temptyScene,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyIgnore,\n\t\t\t},\n\t\t\tnewEndpoint,\n\t\t\t&remoteSiteID,\n\t\t\tfalse,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"no endpoint\",\n\t\t\temptyScene,\n\t\t\tdefaultOptions,\n\t\t\t\"\",\n\t\t\t&remoteSiteID,\n\t\t\tfalse,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"no site id\",\n\t\t\temptyScene,\n\t\t\tdefaultOptions,\n\t\t\tnewEndpoint,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge existing\",\n\t\t\tsceneWithStashIDs,\n\t\t\tdefaultOptions,\n\t\t\texistingEndpoint,\n\t\t\t&remoteSiteID,\n\t\t\tfalse,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge existing set update time\",\n\t\t\tsceneWithStashIDs,\n\t\t\tdefaultOptions,\n\t\t\texistingEndpoint,\n\t\t\t&remoteSiteID,\n\t\t\ttrue,\n\t\t\t[]models.StashID{\n\t\t\t\t{\n\t\t\t\t\tStashID:   remoteSiteID,\n\t\t\t\t\tEndpoint:  existingEndpoint,\n\t\t\t\t\tUpdatedAt: setTime,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge existing new value\",\n\t\t\tsceneWithStashIDs,\n\t\t\tdefaultOptions,\n\t\t\texistingEndpoint,\n\t\t\t&newRemoteSiteID,\n\t\t\tfalse,\n\t\t\t[]models.StashID{\n\t\t\t\t{\n\t\t\t\t\tStashID:   newRemoteSiteID,\n\t\t\t\t\tEndpoint:  existingEndpoint,\n\t\t\t\t\tUpdatedAt: setTime,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"merge add\",\n\t\t\tsceneWithStashIDs,\n\t\t\tdefaultOptions,\n\t\t\tnewEndpoint,\n\t\t\t&newRemoteSiteID,\n\t\t\tfalse,\n\t\t\t[]models.StashID{\n\t\t\t\t{\n\t\t\t\t\tStashID:   remoteSiteID,\n\t\t\t\t\tEndpoint:  existingEndpoint,\n\t\t\t\t\tUpdatedAt: time.Time{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tStashID:   newRemoteSiteID,\n\t\t\t\t\tEndpoint:  newEndpoint,\n\t\t\t\t\tUpdatedAt: setTime,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"overwrite\",\n\t\t\tsceneWithStashIDs,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyOverwrite,\n\t\t\t},\n\t\t\tnewEndpoint,\n\t\t\t&newRemoteSiteID,\n\t\t\tfalse,\n\t\t\t[]models.StashID{\n\t\t\t\t{\n\t\t\t\t\tStashID:   newRemoteSiteID,\n\t\t\t\t\tEndpoint:  newEndpoint,\n\t\t\t\t\tUpdatedAt: setTime,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"overwrite same\",\n\t\t\tsceneWithStashIDs,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyOverwrite,\n\t\t\t},\n\t\t\texistingEndpoint,\n\t\t\t&remoteSiteID,\n\t\t\tfalse,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"overwrite same set update time\",\n\t\t\tsceneWithStashIDs,\n\t\t\t&FieldOptions{\n\t\t\t\tStrategy: FieldStrategyOverwrite,\n\t\t\t},\n\t\t\texistingEndpoint,\n\t\t\t&remoteSiteID,\n\t\t\ttrue,\n\t\t\t[]models.StashID{\n\t\t\t\t{\n\t\t\t\t\tStashID:   remoteSiteID,\n\t\t\t\t\tEndpoint:  existingEndpoint,\n\t\t\t\t\tUpdatedAt: setTime,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttr.scene = tt.scene\n\t\t\ttr.fieldOptions[\"stash_ids\"] = tt.fieldOptions\n\t\t\ttr.result = &scrapeResult{\n\t\t\t\tsource: ScraperSource{\n\t\t\t\t\tRemoteSite: tt.endpoint,\n\t\t\t\t},\n\t\t\t\tresult: &models.ScrapedScene{\n\t\t\t\t\tRemoteSiteID: tt.remoteSiteID,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tgot, err := tr.stashIDs(testCtx, tt.setUpdateTime)\n\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneRelationships.stashIDs() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// massage updatedAt times to be consistent for comparison\n\t\t\tfor i := range got {\n\t\t\t\tif !got[i].UpdatedAt.IsZero() {\n\t\t\t\t\tgot[i].UpdatedAt = setTime\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"sceneRelationships.stashIDs() = %+v, want %+v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneRelationships_cover(t *testing.T) {\n\tconst (\n\t\tsceneID = iota\n\t\tsceneWithStashID\n\t\terrSceneID\n\t\texistingID\n\t\tvalidStoredIDInt\n\t)\n\texistingData := []byte(\"existingData\")\n\tnewData := []byte(\"newData\")\n\tconst base64Prefix = \"data:image/png;base64,\"\n\texistingDataEncoded := base64Prefix + utils.GetBase64StringFromData(existingData)\n\tnewDataEncoded := base64Prefix + utils.GetBase64StringFromData(newData)\n\tinvalidData := newDataEncoded + \"!!!\"\n\n\tdb := mocks.NewDatabase()\n\n\tdb.Scene.On(\"GetCover\", testCtx, sceneID).Return(existingData, nil)\n\tdb.Scene.On(\"GetCover\", testCtx, errSceneID).Return(nil, errors.New(\"error getting cover\"))\n\n\ttr := sceneRelationships{\n\t\tsceneReader:  db.Scene,\n\t\tfieldOptions: make(map[string]*FieldOptions),\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tsceneID int\n\t\timage   *string\n\t\twant    []byte\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"nil image\",\n\t\t\tsceneID,\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"different image\",\n\t\t\tsceneID,\n\t\t\t&newDataEncoded,\n\t\t\tnewData,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"same image\",\n\t\t\tsceneID,\n\t\t\t&existingDataEncoded,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error getting scene cover\",\n\t\t\terrSceneID,\n\t\t\t&newDataEncoded,\n\t\t\tnewData,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid data\",\n\t\t\tsceneID,\n\t\t\t&invalidData,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttr.scene = &models.Scene{\n\t\t\t\tID: tt.sceneID,\n\t\t\t}\n\t\t\ttr.result = &scrapeResult{\n\t\t\t\tresult: &models.ScrapedScene{\n\t\t\t\t\tImage: tt.image,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tgot, err := tr.cover(testCtx)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneRelationships.cover() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"sceneRelationships.cover() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/identify/studio.go",
    "content": "package identify\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/studio\"\n)\n\nfunc createMissingStudio(ctx context.Context, endpoint string, w models.StudioReaderWriter, s *models.ScrapedStudio) (*int, error) {\n\tvar err error\n\n\tif s.Parent != nil {\n\t\tif s.Parent.StoredID == nil {\n\t\t\t// The parent needs to be created\n\t\t\tnewParentStudio := s.Parent.ToStudio(endpoint, nil)\n\t\t\tparentImage, err := s.Parent.GetImage(ctx, nil)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"Failed to make parent studio from scraped studio %s: %s\", s.Parent.Name, err.Error())\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\t// Create the studio\n\t\t\terr = w.Create(ctx, newParentStudio)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\t// Update image table\n\t\t\tif len(parentImage) > 0 {\n\t\t\t\tif err := w.UpdateImage(ctx, newParentStudio.ID, parentImage); err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tstoredId := strconv.Itoa(newParentStudio.ID)\n\t\t\ts.Parent.StoredID = &storedId\n\t\t} else {\n\t\t\t// The parent studio matched an existing one and the user has chosen in the UI to link and/or update it\n\t\t\tstoredID, _ := strconv.Atoi(*s.Parent.StoredID)\n\n\t\t\texistingStashIDs, err := w.GetStashIDs(ctx, storedID)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tstudioPartial := s.Parent.ToPartial(*s.Parent.StoredID, endpoint, nil, existingStashIDs)\n\t\t\tparentImage, err := s.Parent.GetImage(ctx, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tif err := studio.ValidateModify(ctx, studioPartial, w); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\t_, err = w.UpdatePartial(ctx, studioPartial)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tif len(parentImage) > 0 {\n\t\t\t\tif err := w.UpdateImage(ctx, studioPartial.ID, parentImage); err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tnewStudio := s.ToStudio(endpoint, nil)\n\tstudioImage, err := s.GetImage(ctx, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = w.Create(ctx, newStudio)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Update image table\n\tif len(studioImage) > 0 {\n\t\tif err := w.UpdateImage(ctx, newStudio.ID, studioImage); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn &newStudio.ID, nil\n}\n"
  },
  {
    "path": "internal/identify/studio_test.go",
    "content": "package identify\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc Test_createMissingStudio(t *testing.T) {\n\temptyEndpoint := \"\"\n\tvalidEndpoint := \"validEndpoint\"\n\tinvalidEndpoint := \"invalidEndpoint\"\n\tremoteSiteID := \"remoteSiteID\"\n\tvalidName := \"validName\"\n\tinvalidName := \"invalidName\"\n\tcreatedID := 1\n\n\tdb := mocks.NewDatabase()\n\n\tdb.Studio.On(\"Create\", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool {\n\t\treturn p.Name == validName\n\t})).Run(func(args mock.Arguments) {\n\t\ts := args.Get(1).(*models.CreateStudioInput)\n\t\ts.ID = createdID\n\t}).Return(nil)\n\tdb.Studio.On(\"Create\", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool {\n\t\treturn p.Name == invalidName\n\t})).Return(errors.New(\"error creating studio\"))\n\n\tdb.Studio.On(\"UpdatePartial\", testCtx, models.StudioPartial{\n\t\tID: createdID,\n\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\tStashIDs: []models.StashID{\n\t\t\t\t{\n\t\t\t\t\tEndpoint: invalidEndpoint,\n\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t},\n\t}).Return(nil, errors.New(\"error updating stash ids\"))\n\tdb.Studio.On(\"UpdatePartial\", testCtx, models.StudioPartial{\n\t\tID: createdID,\n\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\tStashIDs: []models.StashID{\n\t\t\t\t{\n\t\t\t\t\tEndpoint: validEndpoint,\n\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t},\n\t}).Return(models.Studio{\n\t\tID: createdID,\n\t}, nil)\n\n\ttype args struct {\n\t\tendpoint string\n\t\tstudio   *models.ScrapedStudio\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    *int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"simple\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedStudio{\n\t\t\t\t\tName:         validName,\n\t\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&createdID,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error creating\",\n\t\t\targs{\n\t\t\t\temptyEndpoint,\n\t\t\t\t&models.ScrapedStudio{\n\t\t\t\t\tName:         invalidName,\n\t\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"valid stash id\",\n\t\t\targs{\n\t\t\t\tvalidEndpoint,\n\t\t\t\t&models.ScrapedStudio{\n\t\t\t\t\tName:         validName,\n\t\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&createdID,\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := createMissingStudio(testCtx, tt.args.endpoint, db.Studio, tt.args.studio)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"createMissingStudio() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"createMissingStudio() = %d, want %d\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/log/hook.go",
    "content": "package log\n\nimport (\n\t\"io\"\n\n\t\"github.com/sirupsen/logrus\"\n)\n\ntype fileLogHook struct {\n\tWriter    io.Writer\n\tFormatter logrus.Formatter\n}\n\nfunc (hook *fileLogHook) Fire(entry *logrus.Entry) error {\n\tline, err := hook.Formatter.Format(entry)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = hook.Writer.Write(line)\n\treturn err\n}\n\nfunc (hook *fileLogHook) Levels() []logrus.Level {\n\treturn logrus.AllLevels\n}\n"
  },
  {
    "path": "internal/log/logger.go",
    "content": "// Package log provides an implementation of [logger.LoggerImpl], using logrus.\npackage log\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sirupsen/logrus\"\n\tlumberjack \"gopkg.in/natefinch/lumberjack.v2\"\n)\n\ntype LogItem struct {\n\tTime    time.Time `json:\"time\"`\n\tType    string    `json:\"type\"`\n\tMessage string    `json:\"message\"`\n}\n\ntype Logger struct {\n\tlogger         *logrus.Logger\n\tprogressLogger *logrus.Logger\n\tmutex          sync.Mutex\n\tlogCache       []LogItem\n\tlogSubs        []chan []LogItem\n\twaiting        bool\n\tlastBroadcast  time.Time\n\tlogBuffer      []LogItem\n}\n\nfunc NewLogger() *Logger {\n\tret := &Logger{\n\t\tlogger:         logrus.New(),\n\t\tprogressLogger: logrus.New(),\n\t\tlastBroadcast:  time.Now(),\n\t}\n\n\tret.progressLogger.SetFormatter(new(ProgressFormatter))\n\n\treturn ret\n}\n\n// Init initialises the logger based on a logging configuration\nfunc (log *Logger) Init(logFile string, logOut bool, logLevel string, logFileMaxSize int) {\n\tvar logger io.WriteCloser\n\tcustomFormatter := new(logrus.TextFormatter)\n\tcustomFormatter.TimestampFormat = \"2006-01-02 15:04:05\"\n\tcustomFormatter.ForceColors = true\n\tcustomFormatter.FullTimestamp = true\n\tlog.logger.SetOutput(os.Stderr)\n\tlog.logger.SetFormatter(customFormatter)\n\n\t// #1837 - trigger the console to use color-mode since it won't be\n\t// otherwise triggered until the first log entry\n\t// this is covers the situation where the logger is only logging to file\n\t// and therefore does not trigger the console color-mode - resulting in\n\t// the access log colouring not being applied\n\t_, _ = customFormatter.Format(logrus.NewEntry(log.logger))\n\n\t// if size is 0, disable rotation\n\tif logFile != \"\" {\n\t\tif logFileMaxSize == 0 {\n\t\t\tvar err error\n\t\t\tlogger, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"unable to open log file %s: %v\\n\", logFile, err)\n\t\t\t}\n\t\t} else {\n\t\t\tlogger = &lumberjack.Logger{\n\t\t\t\tFilename: logFile,\n\t\t\t\tMaxSize:  logFileMaxSize, // Megabytes\n\t\t\t\tCompress: true,\n\t\t\t}\n\t\t}\n\t}\n\n\tif logger != nil {\n\t\tif logOut {\n\t\t\t// log to file separately disabling colours\n\t\t\tfileFormatter := new(logrus.TextFormatter)\n\t\t\tfileFormatter.TimestampFormat = customFormatter.TimestampFormat\n\t\t\tfileFormatter.FullTimestamp = customFormatter.FullTimestamp\n\t\t\tlog.logger.AddHook(&fileLogHook{\n\t\t\t\tWriter:    logger,\n\t\t\t\tFormatter: fileFormatter,\n\t\t\t})\n\t\t} else {\n\t\t\t// logging to file only\n\t\t\t// turn off the colouring for the file\n\t\t\tcustomFormatter.ForceColors = false\n\t\t\tlog.logger.Out = logger\n\t\t}\n\t}\n\n\t// otherwise, output to StdErr\n\n\tlog.SetLogLevel(logLevel)\n}\n\nfunc (log *Logger) SetLogLevel(level string) {\n\tlog.logger.Level = logLevelFromString(level)\n}\n\nfunc logLevelFromString(level string) logrus.Level {\n\tret := logrus.InfoLevel\n\n\tswitch strings.ToLower(level) {\n\tcase \"debug\":\n\t\tret = logrus.DebugLevel\n\tcase \"warning\":\n\t\tret = logrus.WarnLevel\n\tcase \"error\":\n\t\tret = logrus.ErrorLevel\n\tcase \"trace\":\n\t\tret = logrus.TraceLevel\n\t}\n\n\treturn ret\n}\n\nfunc (log *Logger) addToCache(l *LogItem) {\n\t// assumes mutex held\n\t// only add to cache if meets minimum log level\n\tlevel := logLevelFromString(l.Type)\n\tif level <= log.logger.Level {\n\t\tlog.logCache = append([]LogItem{*l}, log.logCache...)\n\t\tif len(log.logCache) > 30 {\n\t\t\tlog.logCache = log.logCache[:len(log.logCache)-1]\n\t\t}\n\t}\n}\n\nfunc (log *Logger) addLogItem(l *LogItem) {\n\tlog.mutex.Lock()\n\tl.Time = time.Now()\n\tlog.addToCache(l)\n\tlog.mutex.Unlock()\n\tgo log.broadcastLogItem(l)\n}\n\nfunc (log *Logger) GetLogCache() []LogItem {\n\tlog.mutex.Lock()\n\n\tret := make([]LogItem, len(log.logCache))\n\tcopy(ret, log.logCache)\n\n\tlog.mutex.Unlock()\n\n\treturn ret\n}\n\nfunc (log *Logger) SubscribeToLog(stop chan int) <-chan []LogItem {\n\tret := make(chan []LogItem, 100)\n\n\tgo func() {\n\t\t<-stop\n\t\tlog.unsubscribeFromLog(ret)\n\t}()\n\n\tlog.mutex.Lock()\n\tlog.logSubs = append(log.logSubs, ret)\n\tlog.mutex.Unlock()\n\n\treturn ret\n}\n\nfunc (log *Logger) unsubscribeFromLog(toRemove chan []LogItem) {\n\tlog.mutex.Lock()\n\tfor i, c := range log.logSubs {\n\t\tif c == toRemove {\n\t\t\tlog.logSubs = append(log.logSubs[:i], log.logSubs[i+1:]...)\n\t\t}\n\t}\n\tclose(toRemove)\n\tlog.mutex.Unlock()\n}\n\nfunc (log *Logger) doBroadcastLogItems() {\n\t// assumes mutex held\n\n\tfor _, c := range log.logSubs {\n\t\t// don't block waiting to broadcast\n\t\tselect {\n\t\tcase c <- log.logBuffer:\n\t\tdefault:\n\t\t}\n\t}\n\n\tlog.logBuffer = nil\n\tlog.waiting = false\n\tlog.lastBroadcast = time.Now()\n}\n\nfunc (log *Logger) broadcastLogItem(l *LogItem) {\n\tlog.mutex.Lock()\n\n\tlog.logBuffer = append(log.logBuffer, *l)\n\n\t// don't send more than once per second\n\tif !log.waiting {\n\t\t// if last broadcast was under a second ago, wait until a second has\n\t\t// passed\n\t\ttimeSinceBroadcast := time.Since(log.lastBroadcast)\n\t\tif timeSinceBroadcast.Seconds() < 1 {\n\t\t\tlog.waiting = true\n\t\t\ttime.AfterFunc(time.Second-timeSinceBroadcast, func() {\n\t\t\t\tlog.mutex.Lock()\n\t\t\t\tlog.doBroadcastLogItems()\n\t\t\t\tlog.mutex.Unlock()\n\t\t\t})\n\t\t} else {\n\t\t\tlog.doBroadcastLogItems()\n\t\t}\n\t}\n\n\t// if waiting then adding it to the buffer is sufficient\n\tlog.mutex.Unlock()\n}\n\nfunc (log *Logger) Progressf(format string, args ...interface{}) {\n\tlog.progressLogger.Infof(format, args...)\n\tl := &LogItem{\n\t\tType:    \"progress\",\n\t\tMessage: fmt.Sprintf(format, args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) Trace(args ...interface{}) {\n\tlog.logger.Trace(args...)\n\tl := &LogItem{\n\t\tType:    \"trace\",\n\t\tMessage: fmt.Sprint(args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) Tracef(format string, args ...interface{}) {\n\tlog.logger.Tracef(format, args...)\n\tl := &LogItem{\n\t\tType:    \"trace\",\n\t\tMessage: fmt.Sprintf(format, args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) TraceFunc(fn func() (string, []interface{})) {\n\tif log.logger.Level >= logrus.TraceLevel {\n\t\tmsg, args := fn()\n\t\tlog.Tracef(msg, args...)\n\t}\n}\n\nfunc (log *Logger) Debug(args ...interface{}) {\n\tlog.logger.Debug(args...)\n\tl := &LogItem{\n\t\tType:    \"debug\",\n\t\tMessage: fmt.Sprint(args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) Debugf(format string, args ...interface{}) {\n\tlog.logger.Debugf(format, args...)\n\tl := &LogItem{\n\t\tType:    \"debug\",\n\t\tMessage: fmt.Sprintf(format, args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) logFunc(level logrus.Level, logFn func(format string, args ...interface{}), fn func() (string, []interface{})) {\n\tif log.logger.Level >= level {\n\t\tmsg, args := fn()\n\t\tlogFn(msg, args...)\n\t}\n}\n\nfunc (log *Logger) DebugFunc(fn func() (string, []interface{})) {\n\tlog.logFunc(logrus.DebugLevel, log.logger.Debugf, fn)\n}\n\nfunc (log *Logger) Info(args ...interface{}) {\n\tlog.logger.Info(args...)\n\tl := &LogItem{\n\t\tType:    \"info\",\n\t\tMessage: fmt.Sprint(args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) Infof(format string, args ...interface{}) {\n\tlog.logger.Infof(format, args...)\n\tl := &LogItem{\n\t\tType:    \"info\",\n\t\tMessage: fmt.Sprintf(format, args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) InfoFunc(fn func() (string, []interface{})) {\n\tlog.logFunc(logrus.InfoLevel, log.logger.Infof, fn)\n}\n\nfunc (log *Logger) Warn(args ...interface{}) {\n\tlog.logger.Warn(args...)\n\tl := &LogItem{\n\t\tType:    \"warn\",\n\t\tMessage: fmt.Sprint(args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) Warnf(format string, args ...interface{}) {\n\tlog.logger.Warnf(format, args...)\n\tl := &LogItem{\n\t\tType:    \"warn\",\n\t\tMessage: fmt.Sprintf(format, args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) WarnFunc(fn func() (string, []interface{})) {\n\tlog.logFunc(logrus.WarnLevel, log.logger.Warnf, fn)\n}\n\nfunc (log *Logger) Error(args ...interface{}) {\n\tlog.logger.Error(args...)\n\tl := &LogItem{\n\t\tType:    \"error\",\n\t\tMessage: fmt.Sprint(args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) Errorf(format string, args ...interface{}) {\n\tlog.logger.Errorf(format, args...)\n\tl := &LogItem{\n\t\tType:    \"error\",\n\t\tMessage: fmt.Sprintf(format, args...),\n\t}\n\tlog.addLogItem(l)\n}\n\nfunc (log *Logger) ErrorFunc(fn func() (string, []interface{})) {\n\tlog.logFunc(logrus.ErrorLevel, log.logger.Errorf, fn)\n}\n\nfunc (log *Logger) Fatal(args ...interface{}) {\n\tlog.logger.Fatal(args...)\n}\n\nfunc (log *Logger) Fatalf(format string, args ...interface{}) {\n\tlog.logger.Fatalf(format, args...)\n}\n"
  },
  {
    "path": "internal/log/progress_formatter.go",
    "content": "package log\n\nimport (\n\t\"github.com/sirupsen/logrus\"\n)\n\ntype ProgressFormatter struct{}\n\nfunc (f *ProgressFormatter) Format(entry *logrus.Entry) ([]byte, error) {\n\tmsg := []byte(\"Processing --> \" + entry.Message + \"\\r\")\n\treturn msg, nil\n}\n"
  },
  {
    "path": "internal/manager/apikey.go",
    "content": "package manager\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v4\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n)\n\nvar ErrInvalidToken = errors.New(\"invalid apikey\")\n\nconst APIKeySubject = \"APIKey\"\n\ntype APIKeyClaims struct {\n\tUserID string `json:\"uid\"`\n\tjwt.RegisteredClaims\n}\n\nfunc GenerateAPIKey(userID string) (string, error) {\n\tclaims := &APIKeyClaims{\n\t\tUserID: userID,\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tSubject:  APIKeySubject,\n\t\t\tIssuedAt: jwt.NewNumericDate(time.Now()),\n\t\t},\n\t}\n\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\n\tss, err := token.SignedString(config.GetInstance().GetJWTSignKey())\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn ss, nil\n}\n\n// GetUserIDFromAPIKey validates the provided api key and returns the user ID\nfunc GetUserIDFromAPIKey(apiKey string) (string, error) {\n\tclaims := &APIKeyClaims{}\n\ttoken, err := jwt.ParseWithClaims(apiKey, claims, func(t *jwt.Token) (interface{}, error) {\n\t\treturn config.GetInstance().GetJWTSignKey(), nil\n\t})\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif !token.Valid {\n\t\treturn \"\", ErrInvalidToken\n\t}\n\n\treturn claims.UserID, nil\n}\n"
  },
  {
    "path": "internal/manager/backup.go",
    "content": "package manager\n\nimport (\n\t\"archive/zip\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype databaseBackupZip struct {\n\t*zip.Writer\n}\n\nfunc (z *databaseBackupZip) zipFileRename(fn, outDir, outFn string) error {\n\tp := filepath.Join(outDir, outFn)\n\tp = filepath.ToSlash(p)\n\n\tf, err := z.Create(p)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating zip entry for %s: %v\", fn, err)\n\t}\n\n\ti, err := os.Open(fn)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error opening %s: %v\", fn, err)\n\t}\n\n\tdefer i.Close()\n\n\tif _, err := io.Copy(f, i); err != nil {\n\t\treturn fmt.Errorf(\"error writing %s to zip: %v\", fn, err)\n\t}\n\n\treturn nil\n}\n\nfunc (z *databaseBackupZip) zipFile(fn, outDir string) error {\n\treturn z.zipFileRename(fn, outDir, filepath.Base(fn))\n}\n\nfunc (s *Manager) BackupDatabase(download bool, includeBlobs bool) (string, string, error) {\n\tvar backupPath string\n\tvar backupName string\n\n\t// if we include blobs, then the output is a zip file\n\t// if not, using the same backup logic as before, which creates a sqlite file\n\tif !includeBlobs || s.Config.GetBlobsStorage() != config.BlobStorageTypeFilesystem {\n\t\treturn s.backupDatabaseOnly(download)\n\t}\n\n\t// use tmp directory for the backup\n\tbackupDir := s.Paths.Generated.Tmp\n\tif err := fsutil.EnsureDir(backupDir); err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"could not create backup directory %v: %w\", backupDir, err)\n\t}\n\tf, err := os.CreateTemp(backupDir, \"backup*.sqlite\")\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tbackupPath = f.Name()\n\tbackupName = s.Database.DatabaseBackupPath(\"\")\n\tf.Close()\n\n\t// delete the temp file so that the backup operation can create it\n\tif err := os.Remove(backupPath); err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"could not remove temporary backup file %v: %w\", backupPath, err)\n\t}\n\n\tif err := s.Database.Backup(backupPath); err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\t// create a zip file\n\tzipFileDir := s.Paths.Generated.Downloads\n\tif !download {\n\t\tzipFileDir = s.Config.GetBackupDirectoryPathOrDefault()\n\t\tif zipFileDir != \"\" {\n\t\t\tif err := fsutil.EnsureDir(zipFileDir); err != nil {\n\t\t\t\treturn \"\", \"\", fmt.Errorf(\"could not create backup directory %v: %w\", zipFileDir, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tzipFileName := backupName + \".zip\"\n\tzipFilePath := filepath.Join(zipFileDir, zipFileName)\n\n\tlogger.Debugf(\"Preparing zip file for database backup at %v\", zipFilePath)\n\n\tzf, err := os.Create(zipFilePath)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"could not create zip file %v: %w\", zipFilePath, err)\n\t}\n\tdefer zf.Close()\n\n\tz := databaseBackupZip{\n\t\tWriter: zip.NewWriter(zf),\n\t}\n\n\tdefer z.Close()\n\n\t// move the database file into the zip\n\tdbFn := filepath.Base(s.Config.GetDatabasePath())\n\tif err := z.zipFileRename(backupPath, \"\", dbFn); err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"could not add database backup to zip file: %w\", err)\n\t}\n\n\tif err := os.Remove(backupPath); err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"could not remove temporary backup file %v: %w\", backupPath, err)\n\t}\n\n\t// walk the blobs directory and add files to the zip\n\tblobsDir := s.Config.GetBlobsPath()\n\terr = filepath.WalkDir(blobsDir, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\t// calculate out dir by removing the blobsDir prefix from the path\n\t\toutDir := filepath.Join(\"blobs\", strings.TrimPrefix(filepath.Dir(path), blobsDir))\n\t\tif err := z.zipFile(path, outDir); err != nil {\n\t\t\treturn fmt.Errorf(\"could not add blob %v to zip file: %w\", path, err)\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"error walking blobs directory: %w\", err)\n\t}\n\n\treturn zipFilePath, zipFileName, nil\n}\n\nfunc (s *Manager) backupDatabaseOnly(download bool) (string, string, error) {\n\tvar backupPath string\n\tvar backupName string\n\n\tif download {\n\t\tbackupDir := s.Paths.Generated.Downloads\n\t\tif err := fsutil.EnsureDir(backupDir); err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"could not create backup directory %v: %w\", backupDir, err)\n\t\t}\n\t\tf, err := os.CreateTemp(backupDir, \"backup*.sqlite\")\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", err\n\t\t}\n\n\t\tbackupPath = f.Name()\n\t\tbackupName = s.Database.DatabaseBackupPath(\"\")\n\t\tf.Close()\n\n\t\t// delete the temp file so that the backup operation can create it\n\t\tif err := os.Remove(backupPath); err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"could not remove temporary backup file %v: %w\", backupPath, err)\n\t\t}\n\t} else {\n\t\tbackupDir := s.Config.GetBackupDirectoryPathOrDefault()\n\t\tif backupDir != \"\" {\n\t\t\tif err := fsutil.EnsureDir(backupDir); err != nil {\n\t\t\t\treturn \"\", \"\", fmt.Errorf(\"could not create backup directory %v: %w\", backupDir, err)\n\t\t\t}\n\t\t}\n\t\tbackupPath = s.Database.DatabaseBackupPath(backupDir)\n\t\tbackupName = filepath.Base(backupPath)\n\t}\n\n\terr := s.Database.Backup(backupPath)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\treturn backupPath, backupName, nil\n}\n"
  },
  {
    "path": "internal/manager/checksum.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype SceneMissingHashCounter interface {\n\tCountMissingChecksum(ctx context.Context) (int, error)\n\tCountMissingOSHash(ctx context.Context) (int, error)\n}\n\n// ValidateVideoFileNamingAlgorithm validates changing the\n// VideoFileNamingAlgorithm configuration flag.\n//\n// If setting VideoFileNamingAlgorithm to MD5, then this function will ensure\n// that all checksum values are set on all scenes.\n//\n// Likewise, if VideoFileNamingAlgorithm is set to oshash, then this function\n// will ensure that all oshash values are set on all scenes.\nfunc ValidateVideoFileNamingAlgorithm(ctx context.Context, qb SceneMissingHashCounter, newValue models.HashAlgorithm) error {\n\t// if algorithm is being set to MD5, then all checksums must be present\n\tif newValue == models.HashAlgorithmMd5 {\n\t\tmissingMD5, err := qb.CountMissingChecksum(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif missingMD5 > 0 {\n\t\t\treturn errors.New(\"some checksums are missing on scenes. Run Scan with calculateMD5 set to true\")\n\t\t}\n\t} else if newValue == models.HashAlgorithmOshash {\n\t\tmissingOSHash, err := qb.CountMissingOSHash(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif missingOSHash > 0 {\n\t\t\treturn errors.New(\"some oshash values are missing on scenes. Run Scan to populate\")\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/config/config.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"sync\"\n\t// \"github.com/sasha-s/go-deadlock\" // if you have deadlock issues\n\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/knadh/koanf/parsers/yaml\"\n\t\"github.com/knadh/koanf/providers/file\"\n\t\"github.com/knadh/koanf/v2\"\n\n\t\"github.com/stashapp/stash/internal/identify\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/hash\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nconst (\n\tStash               = \"stash\"\n\tCache               = \"cache\"\n\tBackupDirectoryPath = \"backup_directory_path\"\n\tGenerated           = \"generated\"\n\tMetadata            = \"metadata\"\n\tBlobsPath           = \"blobs_path\"\n\tDownloads           = \"downloads\"\n\tApiKey              = \"api_key\"\n\tUsername            = \"username\"\n\tPassword            = \"password\"\n\tMaxSessionAge       = \"max_session_age\"\n\n\t// SFWContentMode mode config key\n\tSFWContentMode = \"sfw_content_mode\"\n\n\tFFMpegPath  = \"ffmpeg_path\"\n\tFFProbePath = \"ffprobe_path\"\n\n\tBlobsStorage = \"blobs_storage\"\n\n\tDefaultMaxSessionAge = 60 * 60 * 1 // 1 hours\n\n\tDatabase = \"database\"\n\n\tExclude      = \"exclude\"\n\tImageExclude = \"image_exclude\"\n\n\tVideoExtensions            = \"video_extensions\"\n\tImageExtensions            = \"image_extensions\"\n\tGalleryExtensions          = \"gallery_extensions\"\n\tCreateGalleriesFromFolders = \"create_galleries_from_folders\"\n\n\t// CalculateMD5 is the config key used to determine if MD5 should be calculated\n\t// for video files.\n\tCalculateMD5 = \"calculate_md5\"\n\n\t// VideoFileNamingAlgorithm is the config key used to determine what hash\n\t// should be used when generating and using generated files for scenes.\n\tVideoFileNamingAlgorithm = \"video_file_naming_algorithm\"\n\n\tMaxTranscodeSize          = \"max_transcode_size\"\n\tMaxStreamingTranscodeSize = \"max_streaming_transcode_size\"\n\n\t// ffmpeg extra args options\n\tTranscodeInputArgs      = \"ffmpeg.transcode.input_args\"\n\tTranscodeOutputArgs     = \"ffmpeg.transcode.output_args\"\n\tLiveTranscodeInputArgs  = \"ffmpeg.live_transcode.input_args\"\n\tLiveTranscodeOutputArgs = \"ffmpeg.live_transcode.output_args\"\n\n\tParallelTasks        = \"parallel_tasks\"\n\tparallelTasksDefault = 1\n\n\tUseCustomSpriteInterval        = \"use_custom_sprite_interval\"\n\tUseCustomSpriteIntervalDefault = false\n\n\tSpriteInterval        = \"sprite_interval\"\n\tSpriteIntervalDefault = 30\n\n\tMinimumSprites        = \"minimum_sprites\"\n\tMinimumSpritesDefault = 10\n\n\tMaximumSprites        = \"maximum_sprites\"\n\tMaximumSpritesDefault = 500\n\n\tSpriteScreenshotSize        = \"sprite_screenshot_width\"\n\tspriteScreenshotSizeDefault = 160\n\n\tPreviewPreset                 = \"preview_preset\"\n\tTranscodeHardwareAcceleration = \"ffmpeg.hardware_acceleration\"\n\n\tSequentialScanning        = \"sequential_scanning\"\n\tSequentialScanningDefault = false\n\n\tPreviewAudio        = \"preview_audio\"\n\tpreviewAudioDefault = true\n\n\tPreviewSegmentDuration        = \"preview_segment_duration\"\n\tpreviewSegmentDurationDefault = 0.75\n\n\tPreviewSegments        = \"preview_segments\"\n\tpreviewSegmentsDefault = 12\n\n\tPreviewExcludeStart        = \"preview_exclude_start\"\n\tpreviewExcludeStartDefault = \"0\"\n\n\tPreviewExcludeEnd        = \"preview_exclude_end\"\n\tpreviewExcludeEndDefault = \"0\"\n\n\tWriteImageThumbnails        = \"write_image_thumbnails\"\n\twriteImageThumbnailsDefault = true\n\n\tCreateImageClipsFromVideos        = \"create_image_clip_from_videos\"\n\tcreateImageClipsFromVideosDefault = false\n\n\tHost        = \"host\"\n\thostDefault = \"0.0.0.0\"\n\n\tPort        = \"port\"\n\tportDefault = 9999\n\n\tExternalHost = \"external_host\"\n\n\t// http proxy url if required\n\tProxy = \"proxy\"\n\n\t// urls or IPs that should not use the proxy\n\tNoProxy        = \"no_proxy\"\n\tnoProxyDefault = \"localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12\"\n\n\t// key used to sign JWT tokens\n\tJWTSignKey = \"jwt_secret_key\"\n\n\t// key used for session store\n\tSessionStoreKey = \"session_store_key\"\n\n\t// scraping options\n\tScrapersPath              = \"scrapers_path\"\n\tScraperUserAgent          = \"scraper_user_agent\"\n\tScraperCertCheck          = \"scraper_cert_check\"\n\tScraperCDPPath            = \"scraper_cdp_path\"\n\tScraperExcludeTagPatterns = \"scraper_exclude_tag_patterns\"\n\n\t// stash-box options\n\tStashBoxes = \"stash_boxes\"\n\n\tPythonPath = \"python_path\"\n\n\t// plugin options\n\tPluginsPath          = \"plugins_path\"\n\tPluginsSetting       = \"plugins.settings\"\n\tPluginsSettingPrefix = PluginsSetting + \".\"\n\tDisabledPlugins      = \"plugins.disabled\"\n\n\tsourceDefaultPath = \"community\"\n\tsourceDefaultName = \"Community (stable)\"\n\n\tPluginPackageSources        = \"plugins.package_sources\"\n\tpluginPackageSourcesDefault = \"https://stashapp.github.io/CommunityScripts/stable/index.yml\"\n\n\tScraperPackageSources        = \"scrapers.package_sources\"\n\tscraperPackageSourcesDefault = \"https://stashapp.github.io/CommunityScrapers/stable/index.yml\"\n\n\t// i18n\n\tLanguage = \"language\"\n\n\t// served directories\n\t// this should be manually configured only\n\tCustomServedFolders = \"custom_served_folders\"\n\n\t// UI directory. Overrides to serve the UI from a specific location\n\t// rather than use the embedded UI.\n\tUILocation = \"ui_location\"\n\n\t// backwards compatible name\n\tLegacyCustomUILocation = \"custom_ui_location\"\n\n\t// Gallery Cover Regex\n\tGalleryCoverRegex        = \"gallery_cover_regex\"\n\tgalleryCoverRegexDefault = `(poster|cover|folder|board)\\.[^\\.]+$`\n\n\t// Interface options\n\tMenuItems = \"menu_items\"\n\n\tSoundOnPreview = \"sound_on_preview\"\n\n\tWallShowTitle        = \"wall_show_title\"\n\tdefaultWallShowTitle = true\n\n\tCustomPerformerImageLocation        = \"custom_performer_image_location\"\n\tMaximumLoopDuration                 = \"maximum_loop_duration\"\n\tAutostartVideo                      = \"autostart_video\"\n\tAutostartVideoOnPlaySelected        = \"autostart_video_on_play_selected\"\n\tautostartVideoOnPlaySelectedDefault = true\n\tContinuePlaylistDefault             = \"continue_playlist_default\"\n\tShowStudioAsText                    = \"show_studio_as_text\"\n\tCSSEnabled                          = \"cssenabled\"\n\tJavascriptEnabled                   = \"javascriptenabled\"\n\tCustomLocalesEnabled                = \"customlocalesenabled\"\n\tDisableCustomizations               = \"disable_customizations\"\n\n\tShowScrubber        = \"show_scrubber\"\n\tshowScrubberDefault = true\n\n\tWallPlayback        = \"wall_playback\"\n\tdefaultWallPlayback = \"video\"\n\n\t// Image lightbox options\n\tlegacyImageLightboxSlideshowDelay       = \"slideshow_delay\"\n\tImageLightboxSlideshowDelay             = \"image_lightbox.slideshow_delay\"\n\tImageLightboxDisplayModeKey             = \"image_lightbox.display_mode\"\n\tImageLightboxScaleUp                    = \"image_lightbox.scale_up\"\n\tImageLightboxResetZoomOnNav             = \"image_lightbox.reset_zoom_on_nav\"\n\tImageLightboxScrollModeKey              = \"image_lightbox.scroll_mode\"\n\tImageLightboxScrollAttemptsBeforeChange = \"image_lightbox.scroll_attempts_before_change\"\n\tImageLightboxDisableAnimation           = \"image_lightbox.disable_animation\"\n\n\tUI = \"ui\"\n\n\tdefaultImageLightboxSlideshowDelay = 5\n\n\tDisableDropdownCreatePerformer = \"disable_dropdown_create.performer\"\n\tDisableDropdownCreateStudio    = \"disable_dropdown_create.studio\"\n\tDisableDropdownCreateTag       = \"disable_dropdown_create.tag\"\n\tDisableDropdownCreateMovie     = \"disable_dropdown_create.movie\"\n\tDisableDropdownCreateGallery   = \"disable_dropdown_create.gallery\"\n\n\tHandyKey                       = \"handy_key\"\n\tFunscriptOffset                = \"funscript_offset\"\n\tUseStashHostedFunscript        = \"use_stash_hosted_funscript\"\n\tuseStashHostedFunscriptDefault = false\n\n\tDrawFunscriptHeatmapRange        = \"draw_funscript_heatmap_range\"\n\tdrawFunscriptHeatmapRangeDefault = true\n\n\tThemeColor        = \"theme_color\"\n\tDefaultThemeColor = \"#202b33\"\n\n\t// Security\n\tdangerousAllowPublicWithoutAuth                   = \"dangerous_allow_public_without_auth\"\n\tdangerousAllowPublicWithoutAuthDefault            = \"false\"\n\tSecurityTripwireAccessedFromPublicInternet        = \"security_tripwire_accessed_from_public_internet\"\n\tsecurityTripwireAccessedFromPublicInternetDefault = \"\"\n\n\tsslCertPath = \"ssl_cert_path\"\n\tsslKeyPath  = \"ssl_key_path\"\n\n\t// DLNA options\n\tDLNAServerName         = \"dlna.server_name\"\n\tDLNADefaultEnabled     = \"dlna.default_enabled\"\n\tDLNADefaultIPWhitelist = \"dlna.default_whitelist\"\n\tDLNAInterfaces         = \"dlna.interfaces\"\n\n\tDLNAVideoSortOrder        = \"dlna.video_sort_order\"\n\tdlnaVideoSortOrderDefault = \"title\"\n\n\tDLNAPort        = \"dlna.port\"\n\tDLNAPortDefault = 1338\n\n\t// Logging options\n\tLogFile               = \"logfile\"\n\tLogOut                = \"logout\"\n\tdefaultLogOut         = true\n\tLogLevel              = \"loglevel\"\n\tdefaultLogLevel       = \"Info\"\n\tLogAccess             = \"logaccess\"\n\tdefaultLogAccess      = true\n\tLogFileMaxSize        = \"logfile_max_size\"\n\tdefaultLogFileMaxSize = 0 // megabytes, default disabled\n\n\t// Default settings\n\tDefaultScanSettings     = \"defaults.scan_task\"\n\tDefaultIdentifySettings = \"defaults.identify_task\"\n\tDefaultAutoTagSettings  = \"defaults.auto_tag_task\"\n\tDefaultGenerateSettings = \"defaults.generate_task\"\n\n\tDeleteFileDefault             = \"defaults.delete_file\"\n\tDeleteGeneratedDefault        = \"defaults.delete_generated\"\n\tdeleteGeneratedDefaultDefault = true\n\n\t// Trash/Recycle Bin options\n\tDeleteTrashPath = \"delete_trash_path\"\n\n\t// Desktop Integration Options\n\tNoBrowser                           = \"nobrowser\"\n\tNoBrowserDefault                    = false\n\tNotificationsEnabled                = \"notifications_enabled\"\n\tNotificationsEnabledDefault         = true\n\tShowOneTimeMovedNotification        = \"show_one_time_moved_notification\"\n\tShowOneTimeMovedNotificationDefault = false\n\n\t// File upload options\n\tMaxUploadSize = \"max_upload_size\"\n\n\t// Developer options\n\tExtraBlobsPaths = \"developer_options.extra_blob_paths\"\n)\n\n// slice default values\nvar (\n\tdefaultVideoExtensions   = []string{\"m4v\", \"mp4\", \"mov\", \"wmv\", \"avi\", \"mpg\", \"mpeg\", \"rmvb\", \"rm\", \"flv\", \"asf\", \"mkv\", \"webm\", \"f4v\"}\n\tdefaultImageExtensions   = []string{\"png\", \"jpg\", \"jpeg\", \"gif\", \"webp\", \"avif\"}\n\tdefaultGalleryExtensions = []string{\"zip\", \"cbz\"}\n\tdefaultMenuItems         = []string{\"scenes\", \"images\", \"groups\", \"markers\", \"galleries\", \"performers\", \"studios\", \"tags\"}\n)\n\ntype MissingConfigError struct {\n\tmissingFields []string\n}\n\nfunc (e MissingConfigError) Error() string {\n\treturn fmt.Sprintf(\"missing the following mandatory settings: %s\", strings.Join(e.missingFields, \", \"))\n}\n\n// StashBoxError represents configuration errors of Stash-Box\ntype StashBoxError struct {\n\tmsg string\n}\n\nfunc (s *StashBoxError) Error() string {\n\t// \"Stash-box\" is a proper noun and is therefore capitcalized\n\treturn \"Stash-box: \" + s.msg\n}\n\ntype Config struct {\n\t// main instance - backed by config file\n\tmain *koanf.Koanf\n\n\t// override instance - populated from flags/environment\n\t// not written to config file\n\toverrides *koanf.Koanf\n\n\tfilePath    string\n\tisNewSystem bool\n\t// configUpdates  chan int\n\tcertFile string\n\tkeyFile  string\n\tsync.RWMutex\n\t// deadlock.RWMutex // for deadlock testing/issues\n}\n\nvar instance *Config\n\nfunc GetInstance() *Config {\n\tif instance == nil {\n\t\tpanic(\"config not initialized\")\n\t}\n\treturn instance\n}\n\nfunc (i *Config) load(f string) error {\n\tif err := i.main.Load(file.Provider(f), yaml.Parser()); err != nil {\n\t\treturn err\n\t}\n\n\ti.filePath = f\n\treturn nil\n}\n\nfunc (i *Config) IsNewSystem() bool {\n\treturn i.isNewSystem\n}\n\nfunc (i *Config) SetConfigFile(fn string) {\n\ti.Lock()\n\tdefer i.Unlock()\n\ti.filePath = fn\n}\n\nfunc (i *Config) InitTLS() {\n\tconfigDirectory := i.GetConfigPath()\n\ttlsPaths := []string{\n\t\tconfigDirectory,\n\t\tpaths.GetStashHomeDirectory(),\n\t}\n\n\ti.certFile = i.getString(sslCertPath)\n\tif i.certFile == \"\" {\n\t\t// Look for default file\n\t\ti.certFile = fsutil.FindInPaths(tlsPaths, \"stash.crt\")\n\t}\n\n\ti.keyFile = i.getString(sslKeyPath)\n\tif i.keyFile == \"\" {\n\t\t// Look for default file\n\t\ti.keyFile = fsutil.FindInPaths(tlsPaths, \"stash.key\")\n\t}\n}\n\nfunc (i *Config) GetTLSFiles() (certFile, keyFile string) {\n\treturn i.certFile, i.keyFile\n}\n\nfunc (i *Config) HasTLSConfig() bool {\n\tcertFile, keyFile := i.GetTLSFiles()\n\treturn certFile != \"\" && keyFile != \"\"\n}\n\nfunc (i *Config) GetNoBrowser() bool {\n\treturn i.getBool(NoBrowser)\n}\n\nfunc (i *Config) GetNotificationsEnabled() bool {\n\treturn i.getBool(NotificationsEnabled)\n}\n\n// GetShowOneTimeMovedNotification shows whether a small notification to inform the user that Stash\n// will no longer show a terminal window, and instead will be available in the tray, should be shown.\n// It is true when an existing system is started after upgrading, and set to false forever after it is shown.\nfunc (i *Config) GetShowOneTimeMovedNotification() bool {\n\treturn i.getBool(ShowOneTimeMovedNotification)\n}\n\n// these methods are intended to ensure type safety (ie no primitive pointers)\nfunc (i *Config) SetBool(key string, value bool) {\n\ti.SetInterface(key, value)\n}\n\nfunc (i *Config) SetString(key string, value string) {\n\ti.SetInterface(key, value)\n}\n\nfunc (i *Config) SetInt(key string, value int) {\n\ti.SetInterface(key, value)\n}\n\nfunc (i *Config) SetFloat(key string, value float64) {\n\ti.SetInterface(key, value)\n}\n\nfunc (i *Config) SetInterface(key string, value interface{}) {\n\ti.Lock()\n\tdefer i.Unlock()\n\n\ti.set(key, value)\n}\n\nfunc (i *Config) set(key string, value interface{}) {\n\t// assumes lock held\n\n\t// default behaviour for Set is to merge the value\n\t// we want to replace it\n\ti.main.Delete(key)\n\n\tif value == nil {\n\t\treturn\n\t}\n\n\t// test for nil interface as well\n\trefVal := reflect.ValueOf(value)\n\tif refVal.Kind() == reflect.Ptr && refVal.IsNil() {\n\t\treturn\n\t}\n\n\t_ = i.main.Set(key, value)\n}\n\nfunc (i *Config) SetDefault(key string, value interface{}) {\n\ti.Lock()\n\tdefer i.Unlock()\n\n\ti.setDefault(key, value)\n}\n\nfunc (i *Config) setDefault(key string, value interface{}) {\n\tif !i.main.Exists(key) {\n\t\ti.set(key, value)\n\t}\n}\n\nfunc (i *Config) SetPassword(value string) {\n\t// if blank, don't bother hashing; we want it to be blank\n\tif value == \"\" {\n\t\ti.SetString(Password, \"\")\n\t} else {\n\t\ti.SetString(Password, hashPassword(value))\n\t}\n}\n\nfunc (i *Config) Write() error {\n\ti.Lock()\n\tdefer i.Unlock()\n\n\tdata, err := i.marshal()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn os.WriteFile(i.filePath, data, 0640)\n}\n\nfunc (i *Config) Marshal() ([]byte, error) {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.marshal()\n}\n\nfunc (i *Config) marshal() ([]byte, error) {\n\treturn i.main.Marshal(yaml.Parser())\n}\n\n// FileEnvSet returns true if the configuration file environment parameter\n// is set.\nfunc FileEnvSet() bool {\n\treturn os.Getenv(\"STASH_CONFIG_FILE\") != \"\"\n}\n\n// GetConfigFile returns the full path to the used configuration file.\nfunc (i *Config) GetConfigFile() string {\n\ti.RLock()\n\tdefer i.RUnlock()\n\treturn i.filePath\n}\n\n// GetConfigPath returns the path of the directory containing the used\n// configuration file.\nfunc (i *Config) GetConfigPath() string {\n\treturn filepath.Dir(i.GetConfigFile())\n}\n\n// GetConfigPathAbs returns the path of the directory containing the used\n// configuration file, resolved to an absolute path. Returns the return value\n// of GetConfigPath if the path cannot be made into an absolute path.\nfunc (i *Config) GetConfigPathAbs() string {\n\tp := filepath.Dir(i.GetConfigFile())\n\n\tret, _ := filepath.Abs(p)\n\tif ret == \"\" {\n\t\treturn p\n\t}\n\n\treturn ret\n}\n\n// GetDefaultDatabaseFilePath returns the default database filename,\n// which is located in the same directory as the config file.\nfunc (i *Config) GetDefaultDatabaseFilePath() string {\n\treturn filepath.Join(i.GetConfigPath(), \"stash-go.sqlite\")\n}\n\n// forKey returns the Koanf instance that should be used to get the provided\n// key. Returns the overrides instance if the key exists there, otherwise it\n// returns the main instance. Assumes read lock held.\nfunc (i *Config) forKey(key string) *koanf.Koanf {\n\tv := i.main\n\tif i.overrides.Exists(key) {\n\t\tv = i.overrides\n\t}\n\n\treturn v\n}\n\n// viper returns the viper instance that has the key set. Returns nil\n// if no instance has the key. Assumes read lock held.\nfunc (i *Config) with(key string) *koanf.Koanf {\n\tv := i.forKey(key)\n\n\tif v.Exists(key) {\n\t\treturn v\n\t}\n\n\treturn nil\n}\n\nfunc (i *Config) HasOverride(key string) bool {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.overrides.Exists(key)\n}\n\n// These functions wrap the equivalent viper functions, checking the override\n// instance first, then the main instance.\n\nfunc (i *Config) unmarshalKey(key string, rawVal interface{}) error {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.forKey(key).Unmarshal(key, rawVal)\n}\n\nfunc (i *Config) getStringSlice(key string) []string {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.forKey(key).Strings(key)\n}\n\nfunc (i *Config) getString(key string) string {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.forKey(key).String(key)\n}\n\nfunc (i *Config) getBool(key string) bool {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.forKey(key).Bool(key)\n}\n\nfunc (i *Config) getBoolDefault(key string, def bool) bool {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tret := def\n\tv := i.forKey(key)\n\tif v.Exists(key) {\n\t\tret = v.Bool(key)\n\t}\n\treturn ret\n}\n\nfunc (i *Config) getInt(key string) int {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.forKey(key).Int(key)\n}\n\nfunc (i *Config) getFloat64(key string) float64 {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.forKey(key).Float64(key)\n}\n\nfunc (i *Config) getStringMapString(key string) map[string]string {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tret := i.forKey(key).StringMap(key)\n\n\t// GetStringMapString returns an empty map regardless of whether the\n\t// key exists or not.\n\tif len(ret) == 0 {\n\t\treturn nil\n\t}\n\n\treturn ret\n}\n\n// GetSFW returns true if SFW mode is enabled.\n// Default performer images are changed to more agnostic images when enabled.\nfunc (i *Config) GetSFWContentMode() bool {\n\ti.RLock()\n\tdefer i.RUnlock()\n\treturn i.getBool(SFWContentMode)\n}\n\n// GetStashPaths returns the configured stash library paths.\n// Works opposite to the usual case - it will return the override\n// value only if the main value is not set.\nfunc (i *Config) GetStashPaths() StashConfigs {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tvar ret StashConfigs\n\n\tv := i.main\n\tif !v.Exists(Stash) {\n\t\tv = i.overrides\n\t}\n\n\tif err := v.Unmarshal(Stash, &ret); err != nil || len(ret) == 0 {\n\t\t// fallback to legacy format\n\t\tss := v.Strings(Stash)\n\t\tret = nil\n\t\tfor _, path := range ss {\n\t\t\ttoAdd := &StashConfig{\n\t\t\t\tPath: path,\n\t\t\t}\n\t\t\tret = append(ret, toAdd)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (i *Config) GetCachePath() string {\n\treturn i.getString(Cache)\n}\n\nfunc (i *Config) GetGeneratedPath() string {\n\treturn i.getString(Generated)\n}\n\nfunc (i *Config) GetBlobsPath() string {\n\treturn i.getString(BlobsPath)\n}\n\n// GetExtraBlobsPaths returns extra blobs paths.\n// For developer/advanced use only.\nfunc (i *Config) GetExtraBlobsPaths() []string {\n\treturn i.getStringSlice(ExtraBlobsPaths)\n}\n\nfunc (i *Config) GetBlobsStorage() BlobsStorageType {\n\tret := BlobsStorageType(i.getString(BlobsStorage))\n\n\tif !ret.IsValid() {\n\t\t// default to database storage\n\t\t// for legacy systems this is probably the safer option\n\t\tret = BlobStorageTypeDatabase\n\t}\n\n\treturn ret\n}\n\nfunc (i *Config) GetMetadataPath() string {\n\treturn i.getString(Metadata)\n}\n\nfunc (i *Config) GetDatabasePath() string {\n\treturn i.getString(Database)\n}\n\nfunc (i *Config) GetBackupDirectoryPath() string {\n\treturn i.getString(BackupDirectoryPath)\n}\n\nfunc (i *Config) GetBackupDirectoryPathOrDefault() string {\n\tret := i.GetBackupDirectoryPath()\n\tif ret == \"\" {\n\t\t// #4915 - default to the same directory as the database\n\t\treturn filepath.Dir(i.GetDatabasePath())\n\t}\n\n\treturn ret\n}\n\n// GetFFMpegPath returns the path to the FFMpeg executable.\n// If empty, stash will attempt to resolve it from the path.\nfunc (i *Config) GetFFMpegPath() string {\n\treturn i.getString(FFMpegPath)\n}\n\n// GetFFProbePath returns the path to the FFProbe executable.\n// If empty, stash will attempt to resolve it from the path.\nfunc (i *Config) GetFFProbePath() string {\n\treturn i.getString(FFProbePath)\n}\n\nfunc (i *Config) GetJWTSignKey() []byte {\n\treturn []byte(i.getString(JWTSignKey))\n}\n\nfunc (i *Config) GetSessionStoreKey() []byte {\n\treturn []byte(i.getString(SessionStoreKey))\n}\n\nfunc (i *Config) GetDefaultScrapersPath() string {\n\t// default to the same directory as the config file\n\tfn := filepath.Join(i.GetConfigPath(), \"scrapers\")\n\n\treturn fn\n}\n\nfunc (i *Config) GetExcludes() []string {\n\treturn i.getStringSlice(Exclude)\n}\n\nfunc (i *Config) GetImageExcludes() []string {\n\treturn i.getStringSlice(ImageExclude)\n}\n\nfunc (i *Config) GetVideoExtensions() []string {\n\tret := i.getStringSlice(VideoExtensions)\n\tif len(ret) == 0 {\n\t\tret = defaultVideoExtensions\n\t}\n\treturn ret\n}\n\nfunc (i *Config) GetImageExtensions() []string {\n\tret := i.getStringSlice(ImageExtensions)\n\tif len(ret) == 0 {\n\t\tret = defaultImageExtensions\n\t}\n\treturn ret\n}\n\nfunc (i *Config) GetGalleryExtensions() []string {\n\tret := i.getStringSlice(GalleryExtensions)\n\tif len(ret) == 0 {\n\t\tret = defaultGalleryExtensions\n\t}\n\treturn ret\n}\n\nfunc (i *Config) GetCreateGalleriesFromFolders() bool {\n\treturn i.getBool(CreateGalleriesFromFolders)\n}\n\nfunc (i *Config) GetLanguage() string {\n\tret := i.getString(Language)\n\n\t// default to English\n\tif ret == \"\" {\n\t\treturn \"en-US\"\n\t}\n\n\treturn ret\n}\n\n// IsCalculateMD5 returns true if MD5 checksums should be generated for\n// scene video files.\nfunc (i *Config) IsCalculateMD5() bool {\n\treturn i.getBool(CalculateMD5)\n}\n\n// GetVideoFileNamingAlgorithm returns what hash algorithm should be used for\n// naming generated scene video files.\nfunc (i *Config) GetVideoFileNamingAlgorithm() models.HashAlgorithm {\n\tret := i.getString(VideoFileNamingAlgorithm)\n\n\t// default to oshash\n\tif ret == \"\" {\n\t\treturn models.HashAlgorithmOshash\n\t}\n\n\treturn models.HashAlgorithm(ret)\n}\n\nfunc (i *Config) GetSequentialScanning() bool {\n\treturn i.getBool(SequentialScanning)\n}\n\nfunc (i *Config) GetGalleryCoverRegex() string {\n\tvar regexString = i.getString(GalleryCoverRegex)\n\n\t_, err := regexp.Compile(regexString)\n\tif err != nil {\n\t\tlogger.Warnf(\"Gallery cover regex '%v' invalid, reverting to default.\", regexString)\n\t\treturn galleryCoverRegexDefault\n\t}\n\n\treturn regexString\n}\n\nfunc (i *Config) GetScrapersPath() string {\n\treturn i.getString(ScrapersPath)\n}\n\nfunc (i *Config) GetScraperUserAgent() string {\n\treturn i.getString(ScraperUserAgent)\n}\n\n// GetScraperCDPPath gets the path to the Chrome executable or remote address\n// to an instance of Chrome.\nfunc (i *Config) GetScraperCDPPath() string {\n\treturn i.getString(ScraperCDPPath)\n}\n\n// GetScraperCertCheck returns true if the scraper should check for insecure\n// certificates when fetching an image or a page.\nfunc (i *Config) GetScraperCertCheck() bool {\n\treturn i.getBoolDefault(ScraperCertCheck, true)\n}\n\nfunc (i *Config) GetScraperExcludeTagPatterns() []string {\n\treturn i.getStringSlice(ScraperExcludeTagPatterns)\n}\n\nfunc (i *Config) GetStashBoxes() []*models.StashBox {\n\tvar boxes []*models.StashBox\n\tif err := i.unmarshalKey(StashBoxes, &boxes); err != nil {\n\t\tlogger.Warnf(\"error in unmarshalkey: %v\", err)\n\t}\n\n\treturn boxes\n}\n\nfunc (i *Config) GetDefaultPluginsPath() string {\n\t// default to the same directory as the config file\n\tfn := filepath.Join(i.GetConfigPath(), \"plugins\")\n\n\treturn fn\n}\n\nfunc (i *Config) GetPluginsPath() string {\n\treturn i.getString(PluginsPath)\n}\n\nfunc (i *Config) GetAllPluginConfiguration() map[string]map[string]interface{} {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tret := make(map[string]map[string]interface{})\n\n\tv := i.forKey(PluginsSetting)\n\n\tsub := v.Cut(PluginsSetting)\n\tif sub == nil {\n\t\treturn ret\n\t}\n\n\tfor plugin := range sub.Raw() {\n\t\tret[plugin] = sub.Cut(plugin).Raw()\n\t}\n\n\treturn ret\n}\n\nfunc (i *Config) GetPluginConfiguration(pluginID string) map[string]interface{} {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tkey := PluginsSettingPrefix + pluginID\n\n\treturn i.forKey(key).Cut(key).Raw()\n}\n\n// SetPluginConfiguration sets the configuration for a plugin.\n// It will overwrite any existing configuration.\nfunc (i *Config) SetPluginConfiguration(pluginID string, v map[string]interface{}) {\n\ti.Lock()\n\tdefer i.Unlock()\n\n\tkey := PluginsSettingPrefix + pluginID\n\n\ti.set(key, v)\n}\n\nfunc (i *Config) GetDisabledPlugins() []string {\n\treturn i.getStringSlice(DisabledPlugins)\n}\n\nfunc (i *Config) GetPythonPath() string {\n\treturn i.getString(PythonPath)\n}\n\nfunc (i *Config) GetHost() string {\n\tret := i.getString(Host)\n\tif ret == \"\" {\n\t\tret = hostDefault\n\t}\n\n\treturn ret\n}\n\nfunc (i *Config) GetPort() int {\n\tret := i.getInt(Port)\n\tif ret == 0 {\n\t\tret = portDefault\n\t}\n\n\treturn ret\n}\n\nfunc (i *Config) GetThemeColor() string {\n\treturn i.getString(ThemeColor)\n}\n\nfunc (i *Config) GetExternalHost() string {\n\treturn i.getString(ExternalHost)\n}\n\n// GetPreviewSegmentDuration returns the duration of a single segment in a\n// scene preview file, in seconds.\nfunc (i *Config) GetPreviewSegmentDuration() float64 {\n\treturn i.getFloat64(PreviewSegmentDuration)\n}\n\n// GetParallelTasks returns the number of parallel tasks that should be started\n// by scan or generate task.\nfunc (i *Config) GetParallelTasks() int {\n\treturn i.getInt(ParallelTasks)\n}\n\nfunc (i *Config) GetParallelTasksWithAutoDetection() int {\n\tparallelTasks := i.getInt(ParallelTasks)\n\tif parallelTasks <= 0 {\n\t\tparallelTasks = (runtime.NumCPU() / 4) + 1\n\t}\n\treturn parallelTasks\n}\n\n// GetUseCustomSpriteInterval returns true if the sprite minimum, maximum, and interval settings\n// should be used instead of the default\nfunc (i *Config) GetUseCustomSpriteInterval() bool {\n\tvalue := i.getBool(UseCustomSpriteInterval)\n\treturn value\n}\n\n// GetSpriteInterval returns the time (in seconds) to be between each scrubber sprite\n// A value of 0 indicates that the sprite interval should be automatically determined\n// based on the minimum sprite setting.\nfunc (i *Config) GetSpriteInterval() float64 {\n\tvalue := i.getFloat64(SpriteInterval)\n\treturn value\n}\n\n// GetMinimumSprites returns the minimum number of sprites that have to be generated\n// A value of 0 will be overridden with the default of 10.\nfunc (i *Config) GetMinimumSprites() int {\n\tvalue := i.getInt(MinimumSprites)\n\tif value <= 0 {\n\t\treturn MinimumSpritesDefault\n\t}\n\treturn value\n}\n\n// GetMaximumSprites returns the maximum number of sprites that can be generated\n// A value of 0 indicates no maximum.\nfunc (i *Config) GetMaximumSprites() int {\n\tvalue := i.getInt(MaximumSprites)\n\treturn value\n}\n\n// GetSpriteScreenshotSize returns the required size of the screenshots to be taken\n// during sprite generation in pixels. This will be the width for landscape scenes\n// and the height for portrait scenes, with the other dimension being scaled to maintain\n// the aspect ratio. If the value is less than or equal to 0, the default will be used.\nfunc (i *Config) GetSpriteScreenshotSize() int {\n\tvalue := i.getInt(SpriteScreenshotSize)\n\tif value <= 0 {\n\t\treturn spriteScreenshotSizeDefault\n\t}\n\treturn value\n}\n\nfunc (i *Config) GetPreviewAudio() bool {\n\treturn i.getBool(PreviewAudio)\n}\n\n// GetPreviewSegments returns the amount of segments in a scene preview file.\nfunc (i *Config) GetPreviewSegments() int {\n\treturn i.getInt(PreviewSegments)\n}\n\n// GetPreviewExcludeStart returns the configuration setting string for\n// excluding the start of scene videos for preview generation. This can\n// be in two possible formats. A float value is interpreted as the amount\n// of seconds to exclude from the start of the video before it is included\n// in the preview. If the value is suffixed with a '%' character (for example\n// '2%'), then it is interpreted as a proportion of the total video duration.\nfunc (i *Config) GetPreviewExcludeStart() string {\n\treturn i.getString(PreviewExcludeStart)\n}\n\n// GetPreviewExcludeEnd returns the configuration setting string for\n// excluding the end of scene videos for preview generation. A float value\n// is interpreted as the amount of seconds to exclude from the end of the video\n// when generating previews. If the value is suffixed with a '%' character,\n// then it is interpreted as a proportion of the total video duration.\nfunc (i *Config) GetPreviewExcludeEnd() string {\n\treturn i.getString(PreviewExcludeEnd)\n}\n\n// GetPreviewPreset returns the preset when generating previews. Defaults to\n// Slow.\nfunc (i *Config) GetPreviewPreset() models.PreviewPreset {\n\tret := i.getString(PreviewPreset)\n\n\t// default to slow\n\tif ret == \"\" {\n\t\treturn models.PreviewPresetSlow\n\t}\n\n\treturn models.PreviewPreset(ret)\n}\n\nfunc (i *Config) GetTranscodeHardwareAcceleration() bool {\n\treturn i.getBool(TranscodeHardwareAcceleration)\n}\n\nfunc (i *Config) GetMaxTranscodeSize() models.StreamingResolutionEnum {\n\tret := i.getString(MaxTranscodeSize)\n\n\t// default to original\n\tif ret == \"\" {\n\t\treturn models.StreamingResolutionEnumOriginal\n\t}\n\n\treturn models.StreamingResolutionEnum(ret)\n}\n\nfunc (i *Config) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum {\n\tret := i.getString(MaxStreamingTranscodeSize)\n\n\t// default to original\n\tif ret == \"\" {\n\t\treturn models.StreamingResolutionEnumOriginal\n\t}\n\n\treturn models.StreamingResolutionEnum(ret)\n}\n\nfunc (i *Config) GetTranscodeInputArgs() []string {\n\treturn i.getStringSlice(TranscodeInputArgs)\n}\n\nfunc (i *Config) GetTranscodeOutputArgs() []string {\n\treturn i.getStringSlice(TranscodeOutputArgs)\n}\n\nfunc (i *Config) GetLiveTranscodeInputArgs() []string {\n\treturn i.getStringSlice(LiveTranscodeInputArgs)\n}\n\nfunc (i *Config) GetLiveTranscodeOutputArgs() []string {\n\treturn i.getStringSlice(LiveTranscodeOutputArgs)\n}\n\nfunc (i *Config) GetDrawFunscriptHeatmapRange() bool {\n\treturn i.getBoolDefault(DrawFunscriptHeatmapRange, drawFunscriptHeatmapRangeDefault)\n}\n\n// IsWriteImageThumbnails returns true if image thumbnails should be written\n// to disk after generating on the fly.\nfunc (i *Config) IsWriteImageThumbnails() bool {\n\treturn i.getBool(WriteImageThumbnails)\n}\n\nfunc (i *Config) IsCreateImageClipsFromVideos() bool {\n\treturn i.getBool(CreateImageClipsFromVideos)\n}\n\nfunc (i *Config) GetAPIKey() string {\n\treturn i.getString(ApiKey)\n}\n\nfunc (i *Config) GetUsername() string {\n\treturn i.getString(Username)\n}\n\nfunc (i *Config) GetPasswordHash() string {\n\treturn i.getString(Password)\n}\n\nfunc (i *Config) GetCredentials() (string, string) {\n\tif i.HasCredentials() {\n\t\treturn i.getString(Username), i.getString(Password)\n\t}\n\n\treturn \"\", \"\"\n}\n\nfunc (i *Config) HasCredentials() bool {\n\tusername := i.getString(Username)\n\tpwHash := i.getString(Password)\n\n\treturn username != \"\" && pwHash != \"\"\n}\n\nfunc hashPassword(password string) string {\n\thash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)\n\n\treturn string(hash)\n}\n\nfunc (i *Config) ValidateCredentials(username string, password string) bool {\n\tif !i.HasCredentials() {\n\t\t// don't need to authenticate if no credentials saved\n\t\treturn true\n\t}\n\n\tauthUser, authPWHash := i.GetCredentials()\n\n\terr := bcrypt.CompareHashAndPassword([]byte(authPWHash), []byte(password))\n\n\treturn username == authUser && err == nil\n}\n\nfunc stashBoxValidate(str string) bool {\n\tu, err := url.Parse(str)\n\treturn err == nil && u.Scheme != \"\" && u.Host != \"\" && strings.HasSuffix(u.Path, \"/graphql\")\n}\n\ntype StashBoxInput struct {\n\tEndpoint             string `json:\"endpoint\"`\n\tAPIKey               string `json:\"api_key\"`\n\tName                 string `json:\"name\"`\n\tMaxRequestsPerMinute int    `json:\"max_requests_per_minute\"`\n}\n\nfunc (i *Config) ValidateStashBoxes(boxes []*StashBoxInput) error {\n\tisMulti := len(boxes) > 1\n\n\tfor _, box := range boxes {\n\t\t// Validate each stash-box configuration field, return on error\n\t\tif box.APIKey == \"\" {\n\t\t\treturn &StashBoxError{msg: \"API Key cannot be blank\"}\n\t\t}\n\n\t\tif box.Endpoint == \"\" {\n\t\t\treturn &StashBoxError{msg: \"endpoint cannot be blank\"}\n\t\t}\n\n\t\tif !stashBoxValidate(box.Endpoint) {\n\t\t\treturn &StashBoxError{msg: \"endpoint is invalid\"}\n\t\t}\n\n\t\tif isMulti && box.Name == \"\" {\n\t\t\treturn &StashBoxError{msg: \"name cannot be blank\"}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// GetMaxSessionAge gets the maximum age for session cookies, in seconds.\n// Session cookie expiry times are refreshed every request.\nfunc (i *Config) GetMaxSessionAge() int {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tret := DefaultMaxSessionAge\n\tv := i.forKey(MaxSessionAge)\n\tif v.Exists(MaxSessionAge) {\n\t\tret = v.Int(MaxSessionAge)\n\t}\n\n\treturn ret\n}\n\n// GetCustomServedFolders gets the map of custom paths to their applicable\n// filesystem locations\nfunc (i *Config) GetCustomServedFolders() utils.URLMap {\n\treturn i.getStringMapString(CustomServedFolders)\n}\n\nfunc (i *Config) GetUILocation() string {\n\tif ret := i.getString(UILocation); ret != \"\" {\n\t\treturn ret\n\t}\n\n\treturn i.getString(LegacyCustomUILocation)\n}\n\n// Interface options\nfunc (i *Config) GetMenuItems() []string {\n\ti.RLock()\n\tdefer i.RUnlock()\n\tv := i.forKey(MenuItems)\n\tif v.Exists(MenuItems) {\n\t\treturn v.Strings(MenuItems)\n\t}\n\treturn defaultMenuItems\n}\n\nfunc (i *Config) GetSoundOnPreview() bool {\n\treturn i.getBool(SoundOnPreview)\n}\n\nfunc (i *Config) GetWallShowTitle() bool {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tret := defaultWallShowTitle\n\tv := i.forKey(WallShowTitle)\n\tif v.Exists(WallShowTitle) {\n\t\tret = v.Bool(WallShowTitle)\n\t}\n\treturn ret\n}\n\nfunc (i *Config) GetCustomPerformerImageLocation() string {\n\treturn i.getString(CustomPerformerImageLocation)\n}\n\nfunc (i *Config) GetWallPlayback() string {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tret := defaultWallPlayback\n\tv := i.forKey(WallPlayback)\n\tif v.Exists(WallPlayback) {\n\t\tret = v.String(WallPlayback)\n\t}\n\n\treturn ret\n}\n\nfunc (i *Config) GetShowScrubber() bool {\n\treturn i.getBoolDefault(ShowScrubber, showScrubberDefault)\n}\n\nfunc (i *Config) GetMaximumLoopDuration() int {\n\treturn i.getInt(MaximumLoopDuration)\n}\n\nfunc (i *Config) GetAutostartVideo() bool {\n\treturn i.getBool(AutostartVideo)\n}\n\nfunc (i *Config) GetAutostartVideoOnPlaySelected() bool {\n\treturn i.getBoolDefault(AutostartVideoOnPlaySelected, autostartVideoOnPlaySelectedDefault)\n}\n\nfunc (i *Config) GetContinuePlaylistDefault() bool {\n\treturn i.getBool(ContinuePlaylistDefault)\n}\n\nfunc (i *Config) GetShowStudioAsText() bool {\n\treturn i.getBool(ShowStudioAsText)\n}\n\nfunc (i *Config) getSlideshowDelay() int {\n\t// assume have lock\n\n\tret := defaultImageLightboxSlideshowDelay\n\tv := i.forKey(ImageLightboxSlideshowDelay)\n\tif v.Exists(ImageLightboxSlideshowDelay) {\n\t\tret = v.Int(ImageLightboxSlideshowDelay)\n\t} else {\n\t\t// fallback to old location\n\t\tv := i.forKey(legacyImageLightboxSlideshowDelay)\n\t\tif v.Exists(legacyImageLightboxSlideshowDelay) {\n\t\t\tret = v.Int(legacyImageLightboxSlideshowDelay)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (i *Config) GetImageLightboxOptions() ConfigImageLightboxResult {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\tdelay := i.getSlideshowDelay()\n\n\tret := ConfigImageLightboxResult{\n\t\tSlideshowDelay: &delay,\n\t}\n\n\tif v := i.with(ImageLightboxDisplayModeKey); v != nil {\n\t\tmode := ImageLightboxDisplayMode(v.String(ImageLightboxDisplayModeKey))\n\t\tret.DisplayMode = &mode\n\t}\n\tif v := i.with(ImageLightboxScaleUp); v != nil {\n\t\tvalue := v.Bool(ImageLightboxScaleUp)\n\t\tret.ScaleUp = &value\n\t}\n\tif v := i.with(ImageLightboxResetZoomOnNav); v != nil {\n\t\tvalue := v.Bool(ImageLightboxResetZoomOnNav)\n\t\tret.ResetZoomOnNav = &value\n\t}\n\tif v := i.with(ImageLightboxScrollModeKey); v != nil {\n\t\tmode := ImageLightboxScrollMode(v.String(ImageLightboxScrollModeKey))\n\t\tret.ScrollMode = &mode\n\t}\n\tif v := i.with(ImageLightboxScrollAttemptsBeforeChange); v != nil {\n\t\tret.ScrollAttemptsBeforeChange = v.Int(ImageLightboxScrollAttemptsBeforeChange)\n\t}\n\tif v := i.with(ImageLightboxDisableAnimation); v != nil {\n\t\tvalue := v.Bool(ImageLightboxDisableAnimation)\n\t\tret.DisableAnimation = &value\n\t}\n\n\treturn ret\n}\n\nfunc (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate {\n\treturn &ConfigDisableDropdownCreate{\n\t\tPerformer: i.getBool(DisableDropdownCreatePerformer),\n\t\tStudio:    i.getBool(DisableDropdownCreateStudio),\n\t\tTag:       i.getBool(DisableDropdownCreateTag),\n\t\tMovie:     i.getBool(DisableDropdownCreateMovie),\n\t\tGallery:   i.getBool(DisableDropdownCreateGallery),\n\t}\n}\n\nfunc (i *Config) GetUIConfiguration() map[string]interface{} {\n\ti.RLock()\n\tdefer i.RUnlock()\n\n\treturn i.forKey(UI).Cut(UI).Raw()\n}\n\n// GetMinimumPlayPercent returns the minimum percentage of a video that must be\n// watched before incrementing the play count. Returns 0 if not configured.\nfunc (i *Config) GetMinimumPlayPercent() int {\n\tuiConfig := i.GetUIConfiguration()\n\tif uiConfig == nil {\n\t\treturn 0\n\t}\n\tif val, ok := uiConfig[\"minimumPlayPercent\"]; ok {\n\t\tswitch v := val.(type) {\n\t\tcase int:\n\t\t\treturn v\n\t\tcase float64:\n\t\t\treturn int(v)\n\t\tcase int64:\n\t\t\treturn int(v)\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc (i *Config) SetUIConfiguration(v map[string]interface{}) {\n\ti.Lock()\n\tdefer i.Unlock()\n\n\ti.set(UI, v)\n}\n\nfunc (i *Config) GetCSSPath() string {\n\t// use custom.css in the same directory as the config file\n\tconfigFileUsed := i.GetConfigFile()\n\tconfigDir := filepath.Dir(configFileUsed)\n\n\tfn := filepath.Join(configDir, \"custom.css\")\n\n\treturn fn\n}\n\nfunc (i *Config) GetCSS() string {\n\tfn := i.GetCSSPath()\n\n\texists, _ := fsutil.FileExists(fn)\n\tif !exists {\n\t\treturn \"\"\n\t}\n\n\tbuf, err := os.ReadFile(fn)\n\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn string(buf)\n}\n\nfunc (i *Config) SetCSS(css string) {\n\tfn := i.GetCSSPath()\n\ti.Lock()\n\tdefer i.Unlock()\n\n\tbuf := []byte(css)\n\n\tif err := os.WriteFile(fn, buf, 0777); err != nil {\n\t\tlogger.Warnf(\"error while writing %v bytes to %v: %v\", len(buf), fn, err)\n\t}\n}\n\nfunc (i *Config) GetCSSEnabled() bool {\n\treturn i.getBool(CSSEnabled)\n}\n\nfunc (i *Config) GetJavascriptPath() string {\n\t// use custom.js in the same directory as the config file\n\tconfigFileUsed := i.GetConfigFile()\n\tconfigDir := filepath.Dir(configFileUsed)\n\n\tfn := filepath.Join(configDir, \"custom.js\")\n\n\treturn fn\n}\n\nfunc (i *Config) GetJavascript() string {\n\tfn := i.GetJavascriptPath()\n\n\texists, _ := fsutil.FileExists(fn)\n\tif !exists {\n\t\treturn \"\"\n\t}\n\n\tbuf, err := os.ReadFile(fn)\n\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn string(buf)\n}\n\nfunc (i *Config) SetJavascript(javascript string) {\n\tfn := i.GetJavascriptPath()\n\ti.Lock()\n\tdefer i.Unlock()\n\n\tbuf := []byte(javascript)\n\n\tif err := os.WriteFile(fn, buf, 0777); err != nil {\n\t\tlogger.Warnf(\"error while writing %v bytes to %v: %v\", len(buf), fn, err)\n\t}\n}\n\nfunc (i *Config) GetJavascriptEnabled() bool {\n\treturn i.getBool(JavascriptEnabled)\n}\n\nfunc (i *Config) GetCustomLocalesPath() string {\n\t// use custom-locales.json in the same directory as the config file\n\tconfigFileUsed := i.GetConfigFile()\n\tconfigDir := filepath.Dir(configFileUsed)\n\n\tfn := filepath.Join(configDir, \"custom-locales.json\")\n\n\treturn fn\n}\n\nfunc (i *Config) GetCustomLocales() string {\n\tfn := i.GetCustomLocalesPath()\n\n\texists, _ := fsutil.FileExists(fn)\n\tif !exists {\n\t\treturn \"\"\n\t}\n\n\tbuf, err := os.ReadFile(fn)\n\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn string(buf)\n}\n\nfunc (i *Config) SetCustomLocales(customLocales string) {\n\tfn := i.GetCustomLocalesPath()\n\ti.Lock()\n\tdefer i.Unlock()\n\n\tbuf := []byte(customLocales)\n\n\tif err := os.WriteFile(fn, buf, 0777); err != nil {\n\t\tlogger.Warnf(\"error while writing %v bytes to %v: %v\", len(buf), fn, err)\n\t}\n}\n\nfunc (i *Config) GetCustomLocalesEnabled() bool {\n\treturn i.getBool(CustomLocalesEnabled)\n}\n\n// GetDisableCustomizations returns true if all customizations (plugins, custom CSS,\n// custom JavaScript, and custom locales) should be disabled. This is useful for\n// troubleshooting issues without permanently disabling individual customizations.\nfunc (i *Config) GetDisableCustomizations() bool {\n\treturn i.getBool(DisableCustomizations)\n}\n\nfunc (i *Config) GetHandyKey() string {\n\treturn i.getString(HandyKey)\n}\n\nfunc (i *Config) GetFunscriptOffset() int {\n\treturn i.getInt(FunscriptOffset)\n}\n\nfunc (i *Config) GetUseStashHostedFunscript() bool {\n\treturn i.getBoolDefault(UseStashHostedFunscript, useStashHostedFunscriptDefault)\n}\n\nfunc (i *Config) GetDeleteFileDefault() bool {\n\treturn i.getBool(DeleteFileDefault)\n}\n\nfunc (i *Config) GetDeleteGeneratedDefault() bool {\n\treturn i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault)\n}\n\nfunc (i *Config) GetDeleteTrashPath() string {\n\treturn i.getString(DeleteTrashPath)\n}\n\nfunc (i *Config) SetDeleteTrashPath(value string) {\n\ti.SetString(DeleteTrashPath, value)\n}\n\n// GetDefaultIdentifySettings returns the default Identify task settings.\n// Returns nil if the settings could not be unmarshalled, or if it\n// has not been set.\nfunc (i *Config) GetDefaultIdentifySettings() *identify.Options {\n\ti.RLock()\n\tdefer i.RUnlock()\n\tv := i.forKey(DefaultIdentifySettings)\n\n\tif v.Exists(DefaultIdentifySettings) && v.Get(DefaultIdentifySettings) != nil {\n\t\tvar ret identify.Options\n\n\t\tif err := v.Unmarshal(DefaultIdentifySettings, &ret); err != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn &ret\n\t}\n\n\treturn nil\n}\n\n// GetDefaultScanSettings returns the default Scan task settings.\n// Returns nil if the settings could not be unmarshalled, or if it\n// has not been set.\nfunc (i *Config) GetDefaultScanSettings() *ScanMetadataOptions {\n\ti.RLock()\n\tdefer i.RUnlock()\n\tv := i.forKey(DefaultScanSettings)\n\n\tif v.Exists(DefaultScanSettings) && v.Get(DefaultScanSettings) != nil {\n\t\tvar ret ScanMetadataOptions\n\t\tif err := v.Unmarshal(DefaultScanSettings, &ret); err != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn &ret\n\t}\n\n\treturn nil\n}\n\n// GetDefaultAutoTagSettings returns the default Scan task settings.\n// Returns nil if the settings could not be unmarshalled, or if it\n// has not been set.\nfunc (i *Config) GetDefaultAutoTagSettings() *AutoTagMetadataOptions {\n\ti.RLock()\n\tdefer i.RUnlock()\n\tv := i.forKey(DefaultAutoTagSettings)\n\n\tif v.Exists(DefaultAutoTagSettings) {\n\t\tvar ret AutoTagMetadataOptions\n\t\tif err := v.Unmarshal(DefaultAutoTagSettings, &ret); err != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn &ret\n\t}\n\n\treturn nil\n}\n\n// GetDefaultGenerateSettings returns the default Scan task settings.\n// Returns nil if the settings could not be unmarshalled, or if it\n// has not been set.\nfunc (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {\n\ti.RLock()\n\tdefer i.RUnlock()\n\tv := i.forKey(DefaultGenerateSettings)\n\n\tif v.Exists(DefaultGenerateSettings) {\n\t\tvar ret models.GenerateMetadataOptions\n\t\tif err := v.Unmarshal(DefaultGenerateSettings, &ret); err != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn &ret\n\t}\n\n\treturn nil\n}\n\n// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.\n// See https://discourse.stashapp.cc/t/-/1658\nfunc (i *Config) GetDangerousAllowPublicWithoutAuth() bool {\n\treturn i.getBool(dangerousAllowPublicWithoutAuth)\n}\n\n// GetSecurityTripwireAccessedFromPublicInternet returns a public IP address if stash\n// has been accessed from the public internet, with no auth enabled, and\n// DangerousAllowPublicWithoutAuth disabled. Returns an empty string otherwise.\nfunc (i *Config) GetSecurityTripwireAccessedFromPublicInternet() string {\n\treturn i.getString(SecurityTripwireAccessedFromPublicInternet)\n}\n\n// GetDLNAServerName returns the visible name of the DLNA server. If empty,\n// \"stash\" will be used.\nfunc (i *Config) GetDLNAServerName() string {\n\treturn i.getString(DLNAServerName)\n}\n\n// GetDLNADefaultEnabled returns true if the DLNA is enabled by default.\nfunc (i *Config) GetDLNADefaultEnabled() bool {\n\treturn i.getBool(DLNADefaultEnabled)\n}\n\n// GetDLNADefaultIPWhitelist returns a list of IP addresses/wildcards that\n// are allowed to use the DLNA service.\nfunc (i *Config) GetDLNADefaultIPWhitelist() []string {\n\treturn i.getStringSlice(DLNADefaultIPWhitelist)\n}\n\n// GetDLNAInterfaces returns a list of interface names to expose DLNA on. If\n// empty, runs on all interfaces.\nfunc (i *Config) GetDLNAInterfaces() []string {\n\treturn i.getStringSlice(DLNAInterfaces)\n}\n\n// GetDLNAPort returns the port to run the DLNA server on. If empty, 1338\n// will be used.\nfunc (i *Config) GetDLNAPort() int {\n\tret := i.getInt(DLNAPort)\n\tif ret == 0 {\n\t\tret = DLNAPortDefault\n\t}\n\treturn ret\n}\n\n// GetDLNAPortAsString returns the port to run the DLNA server on as a string.\nfunc (i *Config) GetDLNAPortAsString() string {\n\treturn \":\" + strconv.Itoa(i.GetDLNAPort())\n}\n\n// GetDLNAActivityTrackingEnabled returns true if DLNA activity tracking is enabled.\n// This uses the same \"trackActivity\" UI setting that controls frontend play history tracking.\n// When enabled, scenes played via DLNA will have their play count and duration tracked.\nfunc (i *Config) GetDLNAActivityTrackingEnabled() bool {\n\tuiConfig := i.GetUIConfiguration()\n\tif uiConfig == nil {\n\t\treturn true // Default to enabled\n\t}\n\tif val, ok := uiConfig[\"trackActivity\"]; ok {\n\t\tif v, ok := val.(bool); ok {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn true // Default to enabled\n}\n\n// GetVideoSortOrder returns the sort order to display videos. If\n// empty, videos will be sorted by titles.\nfunc (i *Config) GetVideoSortOrder() string {\n\tret := i.getString(DLNAVideoSortOrder)\n\tif ret == \"\" {\n\t\tret = dlnaVideoSortOrderDefault\n\t}\n\n\treturn ret\n}\n\n// GetLogFile returns the filename of the file to output logs to.\n// An empty string means that file logging will be disabled.\nfunc (i *Config) GetLogFile() string {\n\treturn i.getString(LogFile)\n}\n\n// GetLogOut returns true if logging should be output to the terminal\n// in addition to writing to a log file. Logging will be output to the\n// terminal if file logging is disabled. Defaults to true.\nfunc (i *Config) GetLogOut() bool {\n\treturn i.getBoolDefault(LogOut, defaultLogOut)\n}\n\n// GetLogLevel returns the lowest log level to write to the log.\n// Should be one of \"Debug\", \"Info\", \"Warning\", \"Error\"\nfunc (i *Config) GetLogLevel() string {\n\tvalue := i.getString(LogLevel)\n\tif value != \"Debug\" && value != \"Info\" && value != \"Warning\" && value != \"Error\" && value != \"Trace\" {\n\t\tvalue = defaultLogLevel\n\t}\n\n\treturn value\n}\n\n// GetLogAccess returns true if http requests should be logged to the terminal.\n// HTTP requests are not logged to the log file. Defaults to true.\nfunc (i *Config) GetLogAccess() bool {\n\treturn i.getBoolDefault(LogAccess, defaultLogAccess)\n}\n\n// GetLogFileMaxSize returns the maximum size of the log file in megabytes for lumberjack to rotate\nfunc (i *Config) GetLogFileMaxSize() int {\n\tvalue := i.getInt(LogFileMaxSize)\n\tif value < 0 {\n\t\tvalue = defaultLogFileMaxSize\n\t}\n\n\treturn value\n}\n\n// Max allowed graphql upload size in megabytes\nfunc (i *Config) GetMaxUploadSize() int64 {\n\ti.RLock()\n\tdefer i.RUnlock()\n\tret := int64(1024)\n\n\tv := i.forKey(MaxUploadSize)\n\tif v.Exists(MaxUploadSize) {\n\t\tret = v.Int64(MaxUploadSize)\n\t}\n\treturn ret << 20\n}\n\n// GetProxy returns the url of a http proxy to be used for all outgoing http calls.\nfunc (i *Config) GetProxy() string {\n\t// Validate format\n\treg := regexp.MustCompile(`^((?:socks5h?|https?):\\/\\/)(([\\P{Cc}]+):([\\P{Cc}]+)@)?(([a-zA-Z0-9][a-zA-Z0-9.-]*)(:[0-9]{1,5})?)`)\n\tproxy := i.getString(Proxy)\n\tif proxy != \"\" && reg.MatchString(proxy) {\n\t\tlogger.Debug(\"Proxy is valid, using it\")\n\t\treturn proxy\n\t} else if proxy != \"\" {\n\t\tlogger.Error(\"Proxy is invalid, please review your configuration\")\n\t\treturn \"\"\n\t}\n\treturn \"\"\n}\n\n// GetProxy returns the url of a http proxy to be used for all outgoing http calls.\nfunc (i *Config) GetNoProxy() string {\n\t// NoProxy does not require validation, it is validated by the native Go library sufficiently\n\treturn i.getString(NoProxy)\n}\n\n// ActivatePublicAccessTripwire sets the security_tripwire_accessed_from_public_internet\n// config field to the provided IP address to indicate that stash has been accessed\n// from this public IP without authentication.\nfunc (i *Config) ActivatePublicAccessTripwire(requestIP string) error {\n\ti.SetString(SecurityTripwireAccessedFromPublicInternet, requestIP)\n\treturn i.Write()\n}\n\nfunc (i *Config) getPackageSources(key string) []*models.PackageSource {\n\tvar sources []*models.PackageSource\n\tif err := i.unmarshalKey(key, &sources); err != nil {\n\t\tlogger.Warnf(\"error in unmarshalkey: %v\", err)\n\t}\n\n\treturn sources\n}\n\nfunc (i *Config) GetPluginPackageSources() []*models.PackageSource {\n\treturn i.getPackageSources(PluginPackageSources)\n}\n\nfunc (i *Config) GetScraperPackageSources() []*models.PackageSource {\n\treturn i.getPackageSources(ScraperPackageSources)\n}\n\ntype packagePathGetter struct {\n\tgetterFn func() []*models.PackageSource\n}\n\nfunc (g packagePathGetter) GetAllSourcePaths() []string {\n\tp := g.getterFn()\n\tvar ret []string\n\tfor _, v := range p {\n\t\tret = sliceutil.AppendUnique(ret, v.LocalPath)\n\t}\n\n\treturn ret\n}\n\nfunc (g packagePathGetter) GetSourcePath(srcURL string) string {\n\tp := g.getterFn()\n\n\tfor _, v := range p {\n\t\tif v.URL == srcURL {\n\t\t\treturn v.LocalPath\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (i *Config) GetPluginPackagePathGetter() packagePathGetter {\n\treturn packagePathGetter{\n\t\tgetterFn: i.GetPluginPackageSources,\n\t}\n}\n\nfunc (i *Config) GetScraperPackagePathGetter() packagePathGetter {\n\treturn packagePathGetter{\n\t\tgetterFn: i.GetScraperPackageSources,\n\t}\n}\n\nfunc (i *Config) Validate() error {\n\ti.RLock()\n\tdefer i.RUnlock()\n\tmandatoryPaths := []string{\n\t\tDatabase,\n\t\tGenerated,\n\t}\n\n\tvar missingFields []string\n\n\tfor _, p := range mandatoryPaths {\n\t\tif !i.forKey(p).Exists(p) || i.forKey(p).String(p) == \"\" {\n\t\t\tmissingFields = append(missingFields, p)\n\t\t}\n\t}\n\n\tif len(missingFields) > 0 {\n\t\treturn MissingConfigError{\n\t\t\tmissingFields: missingFields,\n\t\t}\n\t}\n\n\tif i.GetBlobsStorage() == BlobStorageTypeFilesystem && i.forKey(BlobsPath).String(BlobsPath) == \"\" {\n\t\treturn MissingConfigError{\n\t\t\tmissingFields: []string{BlobsPath},\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Config) setDefaultValues() {\n\t// read data before write lock scope\n\tdefaultDatabaseFilePath := i.GetDefaultDatabaseFilePath()\n\tdefaultScrapersPath := i.GetDefaultScrapersPath()\n\tdefaultPluginsPath := i.GetDefaultPluginsPath()\n\n\ti.Lock()\n\tdefer i.Unlock()\n\n\t// set the default host and port so that these are written to the config\n\t// file\n\ti.setDefault(Host, hostDefault)\n\ti.setDefault(Port, portDefault)\n\n\ti.setDefault(ParallelTasks, parallelTasksDefault)\n\ti.setDefault(SequentialScanning, SequentialScanningDefault)\n\ti.setDefault(PreviewSegmentDuration, previewSegmentDurationDefault)\n\ti.setDefault(PreviewSegments, previewSegmentsDefault)\n\ti.setDefault(PreviewExcludeStart, previewExcludeStartDefault)\n\ti.setDefault(PreviewExcludeEnd, previewExcludeEndDefault)\n\ti.setDefault(PreviewAudio, previewAudioDefault)\n\ti.setDefault(SoundOnPreview, false)\n\n\ti.setDefault(UseCustomSpriteInterval, UseCustomSpriteIntervalDefault)\n\ti.setDefault(SpriteInterval, SpriteIntervalDefault)\n\ti.setDefault(MinimumSprites, MinimumSpritesDefault)\n\ti.setDefault(MaximumSprites, MaximumSpritesDefault)\n\ti.setDefault(SpriteScreenshotSize, spriteScreenshotSizeDefault)\n\n\ti.setDefault(ThemeColor, DefaultThemeColor)\n\n\ti.setDefault(WriteImageThumbnails, writeImageThumbnailsDefault)\n\ti.setDefault(CreateImageClipsFromVideos, createImageClipsFromVideosDefault)\n\n\ti.setDefault(Database, defaultDatabaseFilePath)\n\n\ti.setDefault(dangerousAllowPublicWithoutAuth, dangerousAllowPublicWithoutAuthDefault)\n\ti.setDefault(SecurityTripwireAccessedFromPublicInternet, securityTripwireAccessedFromPublicInternetDefault)\n\n\t// Set generated to the metadata path for backwards compat\n\ti.setDefault(Generated, i.main.String(Metadata))\n\n\ti.setDefault(NoBrowser, NoBrowserDefault)\n\ti.setDefault(NotificationsEnabled, NotificationsEnabledDefault)\n\ti.setDefault(ShowOneTimeMovedNotification, ShowOneTimeMovedNotificationDefault)\n\n\t// Set default scrapers and plugins paths\n\ti.setDefault(ScrapersPath, defaultScrapersPath)\n\ti.setDefault(PluginsPath, defaultPluginsPath)\n\n\t// Set default gallery cover regex\n\ti.setDefault(GalleryCoverRegex, galleryCoverRegexDefault)\n\n\t// Set NoProxy default\n\ti.setDefault(NoProxy, noProxyDefault)\n\n\t// set default package sources\n\ti.setDefault(PluginPackageSources, []map[string]string{{\n\t\t\"name\":      sourceDefaultName,\n\t\t\"url\":       pluginPackageSourcesDefault,\n\t\t\"localpath\": sourceDefaultPath,\n\t}})\n\ti.setDefault(ScraperPackageSources, []map[string]string{{\n\t\t\"name\":      sourceDefaultName,\n\t\t\"url\":       scraperPackageSourcesDefault,\n\t\t\"localpath\": sourceDefaultPath,\n\t}})\n}\n\n// setExistingSystemDefaults sets config options that are new and unset in an existing install,\n// but should have a separate default than for brand-new systems, to maintain behavior.\n// The config file will not be written.\nfunc (i *Config) setExistingSystemDefaults() {\n\ti.Lock()\n\tdefer i.Unlock()\n\tif !i.isNewSystem {\n\t\t// Existing systems as of the introduction of auto-browser open should retain existing\n\t\t// behavior and not start the browser automatically.\n\t\tif !i.main.Exists(NoBrowser) {\n\t\t\ti.set(NoBrowser, true)\n\t\t}\n\n\t\t// Existing systems as of the introduction of the taskbar should inform users.\n\t\tif !i.main.Exists(ShowOneTimeMovedNotification) {\n\t\t\ti.set(ShowOneTimeMovedNotification, true)\n\t\t}\n\t}\n}\n\n// SetInitialConfig fills in missing required config fields. The config file will not be written.\nfunc (i *Config) SetInitialConfig() error {\n\t// generate some api keys\n\tconst apiKeyLength = 32\n\n\tif string(i.GetJWTSignKey()) == \"\" {\n\t\tsignKey, err := hash.GenerateRandomKey(apiKeyLength)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error generating JWTSignKey: %w\", err)\n\t\t}\n\t\ti.SetString(JWTSignKey, signKey)\n\t}\n\n\tif string(i.GetSessionStoreKey()) == \"\" {\n\t\tsessionStoreKey, err := hash.GenerateRandomKey(apiKeyLength)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error generating session store key: %w\", err)\n\t\t}\n\t\ti.SetString(SessionStoreKey, sessionStoreKey)\n\t}\n\n\ti.setDefaultValues()\n\n\treturn nil\n}\n\nfunc (i *Config) FinalizeSetup() {\n\ti.isNewSystem = false\n\t// i.configUpdates <- 0\n}\n"
  },
  {
    "path": "internal/manager/config/config_concurrency_test.go",
    "content": "package config\n\nimport (\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\n// should be run with -race\nfunc TestConcurrentConfigAccess(t *testing.T) {\n\ti := InitializeEmpty()\n\n\tconst workers = 8\n\tconst loops = 200\n\tvar wg sync.WaitGroup\n\tfor k := 0; k < workers; k++ {\n\t\twg.Add(1)\n\t\tgo func(wk int) {\n\t\t\tfor l := 0; l < loops; l++ {\n\t\t\t\tstart := time.Now()\n\t\t\t\tif err := i.SetInitialConfig(); err != nil {\n\t\t\t\t\tt.Errorf(\"Failure setting initial configuration in worker %v iteration %v: %v\", wk, l, err)\n\t\t\t\t}\n\n\t\t\t\ti.HasCredentials()\n\t\t\t\ti.ValidateCredentials(\"\", \"\")\n\t\t\t\ti.GetConfigFile()\n\t\t\t\ti.GetConfigPath()\n\t\t\t\ti.GetDefaultDatabaseFilePath()\n\t\t\t\ti.SetInterface(BackupDirectoryPath, i.GetBackupDirectoryPath())\n\t\t\t\ti.GetStashPaths()\n\t\t\t\t_ = i.ValidateStashBoxes(nil)\n\t\t\t\t_ = i.Validate()\n\t\t\t\t_ = i.ActivatePublicAccessTripwire(\"\")\n\t\t\t\ti.SetInterface(Cache, i.GetCachePath())\n\t\t\t\ti.SetInterface(Generated, i.GetGeneratedPath())\n\t\t\t\ti.SetInterface(Metadata, i.GetMetadataPath())\n\t\t\t\ti.SetInterface(Database, i.GetDatabasePath())\n\n\t\t\t\t// these must be set as strings since the original values are also strings\n\t\t\t\t// setting them as []byte will cause the returned string to be corrupted\n\t\t\t\ti.SetInterface(JWTSignKey, string(i.GetJWTSignKey()))\n\t\t\t\ti.SetInterface(SessionStoreKey, string(i.GetSessionStoreKey()))\n\n\t\t\t\ti.GetDefaultScrapersPath()\n\t\t\t\ti.SetInterface(Exclude, i.GetExcludes())\n\t\t\t\ti.SetInterface(ImageExclude, i.GetImageExcludes())\n\t\t\t\ti.SetInterface(VideoExtensions, i.GetVideoExtensions())\n\t\t\t\ti.SetInterface(ImageExtensions, i.GetImageExtensions())\n\t\t\t\ti.SetInterface(GalleryExtensions, i.GetGalleryExtensions())\n\t\t\t\ti.SetInterface(CreateGalleriesFromFolders, i.GetCreateGalleriesFromFolders())\n\t\t\t\ti.SetInterface(Language, i.GetLanguage())\n\t\t\t\ti.SetInterface(VideoFileNamingAlgorithm, i.GetVideoFileNamingAlgorithm())\n\t\t\t\ti.SetInterface(ScrapersPath, i.GetScrapersPath())\n\t\t\t\ti.SetInterface(ScraperUserAgent, i.GetScraperUserAgent())\n\t\t\t\ti.SetInterface(ScraperCDPPath, i.GetScraperCDPPath())\n\t\t\t\ti.SetInterface(ScraperCertCheck, i.GetScraperCertCheck())\n\t\t\t\ti.SetInterface(ScraperExcludeTagPatterns, i.GetScraperExcludeTagPatterns())\n\t\t\t\ti.SetInterface(StashBoxes, i.GetStashBoxes())\n\t\t\t\ti.GetDefaultPluginsPath()\n\t\t\t\ti.SetInterface(PluginsPath, i.GetPluginsPath())\n\t\t\t\ti.SetInterface(Host, i.GetHost())\n\t\t\t\ti.SetInterface(Port, i.GetPort())\n\t\t\t\ti.SetInterface(ExternalHost, i.GetExternalHost())\n\t\t\t\ti.SetInterface(PreviewSegmentDuration, i.GetPreviewSegmentDuration())\n\t\t\t\ti.SetInterface(ParallelTasks, i.GetParallelTasks())\n\t\t\t\ti.SetInterface(ParallelTasks, i.GetParallelTasksWithAutoDetection())\n\t\t\t\ti.SetInterface(PreviewAudio, i.GetPreviewAudio())\n\t\t\t\ti.SetInterface(PreviewSegments, i.GetPreviewSegments())\n\t\t\t\ti.SetInterface(PreviewExcludeStart, i.GetPreviewExcludeStart())\n\t\t\t\ti.SetInterface(PreviewExcludeEnd, i.GetPreviewExcludeEnd())\n\t\t\t\ti.SetInterface(PreviewPreset, i.GetPreviewPreset())\n\t\t\t\ti.SetInterface(MaxTranscodeSize, i.GetMaxTranscodeSize())\n\t\t\t\ti.SetInterface(MaxStreamingTranscodeSize, i.GetMaxStreamingTranscodeSize())\n\t\t\t\ti.SetInterface(ApiKey, i.GetAPIKey())\n\t\t\t\ti.SetInterface(Username, i.GetUsername())\n\t\t\t\ti.SetInterface(Password, i.GetPasswordHash())\n\t\t\t\ti.GetCredentials()\n\t\t\t\ti.SetInterface(MaxSessionAge, i.GetMaxSessionAge())\n\t\t\t\ti.SetInterface(CustomServedFolders, i.GetCustomServedFolders())\n\t\t\t\ti.SetInterface(LegacyCustomUILocation, i.GetUILocation())\n\t\t\t\ti.SetInterface(MenuItems, i.GetMenuItems())\n\t\t\t\ti.SetInterface(SoundOnPreview, i.GetSoundOnPreview())\n\t\t\t\ti.SetInterface(WallShowTitle, i.GetWallShowTitle())\n\t\t\t\ti.SetInterface(CustomPerformerImageLocation, i.GetCustomPerformerImageLocation())\n\t\t\t\ti.SetInterface(WallPlayback, i.GetWallPlayback())\n\t\t\t\ti.SetInterface(MaximumLoopDuration, i.GetMaximumLoopDuration())\n\t\t\t\ti.SetInterface(AutostartVideo, i.GetAutostartVideo())\n\t\t\t\ti.SetInterface(ShowStudioAsText, i.GetShowStudioAsText())\n\t\t\t\ti.SetInterface(legacyImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay)\n\t\t\t\ti.SetInterface(ImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay)\n\t\t\t\ti.GetCSSPath()\n\t\t\t\ti.GetCSS()\n\t\t\t\ti.GetJavascriptPath()\n\t\t\t\ti.GetJavascript()\n\t\t\t\ti.GetCustomLocalesPath()\n\t\t\t\ti.GetCustomLocales()\n\t\t\t\ti.SetInterface(CSSEnabled, i.GetCSSEnabled())\n\t\t\t\ti.SetInterface(CSSEnabled, i.GetCustomLocalesEnabled())\n\t\t\t\ti.SetInterface(HandyKey, i.GetHandyKey())\n\t\t\t\ti.SetInterface(UseStashHostedFunscript, i.GetUseStashHostedFunscript())\n\t\t\t\ti.SetInterface(DLNAServerName, i.GetDLNAServerName())\n\t\t\t\ti.SetInterface(DLNADefaultEnabled, i.GetDLNADefaultEnabled())\n\t\t\t\ti.SetInterface(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist())\n\t\t\t\ti.SetInterface(DLNAInterfaces, i.GetDLNAInterfaces())\n\t\t\t\ti.SetInterface(DLNAPort, i.GetDLNAPort())\n\t\t\t\ti.SetInterface(LogFile, i.GetLogFile())\n\t\t\t\ti.SetInterface(LogOut, i.GetLogOut())\n\t\t\t\ti.SetInterface(LogLevel, i.GetLogLevel())\n\t\t\t\ti.SetInterface(LogAccess, i.GetLogAccess())\n\t\t\t\ti.SetInterface(MaxUploadSize, i.GetMaxUploadSize())\n\t\t\t\ti.SetInterface(FunscriptOffset, i.GetFunscriptOffset())\n\t\t\t\ti.SetInterface(DefaultIdentifySettings, i.GetDefaultIdentifySettings())\n\t\t\t\ti.SetInterface(DeleteGeneratedDefault, i.GetDeleteGeneratedDefault())\n\t\t\t\ti.SetInterface(DeleteFileDefault, i.GetDeleteFileDefault())\n\t\t\t\ti.SetInterface(dangerousAllowPublicWithoutAuth, i.GetDangerousAllowPublicWithoutAuth())\n\t\t\t\ti.SetInterface(SecurityTripwireAccessedFromPublicInternet, i.GetSecurityTripwireAccessedFromPublicInternet())\n\t\t\t\ti.SetInterface(DisableDropdownCreatePerformer, i.GetDisableDropdownCreate().Performer)\n\t\t\t\ti.SetInterface(DisableDropdownCreateStudio, i.GetDisableDropdownCreate().Studio)\n\t\t\t\ti.SetInterface(DisableDropdownCreateTag, i.GetDisableDropdownCreate().Tag)\n\t\t\t\ti.SetInterface(DisableDropdownCreateMovie, i.GetDisableDropdownCreate().Movie)\n\t\t\t\ti.SetInterface(AutostartVideoOnPlaySelected, i.GetAutostartVideoOnPlaySelected())\n\t\t\t\ti.SetInterface(ContinuePlaylistDefault, i.GetContinuePlaylistDefault())\n\t\t\t\ti.SetInterface(PythonPath, i.GetPythonPath())\n\t\t\t\tt.Logf(\"Worker %v iteration %v took %v\", wk, l, time.Since(start))\n\t\t\t}\n\t\t\twg.Done()\n\t\t}(k)\n\t}\n\n\twg.Wait()\n}\n"
  },
  {
    "path": "internal/manager/config/config_test.go",
    "content": "package config\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestConfig_GetAllPluginConfiguration(t *testing.T) {\n\ti := InitializeEmpty()\n\n\tassert.Equal(t, i.GetAllPluginConfiguration(), map[string]map[string]interface{}{})\n\n\ti.SetPluginConfiguration(\"plugin1\", map[string]interface{}{\"key1\": \"value1\"})\n\n\tassert.Equal(t, map[string]map[string]interface{}{\n\t\t\"plugin1\": {\"key1\": \"value1\"},\n\t}, i.GetAllPluginConfiguration())\n\n\ti.SetPluginConfiguration(\"plugin2\", map[string]interface{}{\"key2\": \"value2\"})\n\n\tassert.Equal(t, map[string]map[string]interface{}{\n\t\t\"plugin1\": {\"key1\": \"value1\"},\n\t\t\"plugin2\": {\"key2\": \"value2\"},\n\t}, i.GetAllPluginConfiguration())\n\n\t// ensure SetPluginConfiguration overwrites existing configuration\n\ti.SetPluginConfiguration(\"plugin2\", map[string]interface{}{\"key3\": \"value3\"})\n\n\tassert.Equal(t, map[string]map[string]interface{}{\n\t\t\"plugin1\": {\"key1\": \"value1\"},\n\t\t\"plugin2\": {\"key3\": \"value3\"},\n\t}, i.GetAllPluginConfiguration())\n}\n"
  },
  {
    "path": "internal/manager/config/enums.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype BlobsStorageType string\n\nconst (\n\t// Database\n\tBlobStorageTypeDatabase BlobsStorageType = \"DATABASE\"\n\t// Filesystem\n\tBlobStorageTypeFilesystem BlobsStorageType = \"FILESYSTEM\"\n)\n\nvar AllBlobStorageType = []BlobsStorageType{\n\tBlobStorageTypeDatabase,\n\tBlobStorageTypeFilesystem,\n}\n\nfunc (e BlobsStorageType) IsValid() bool {\n\tswitch e {\n\tcase BlobStorageTypeDatabase, BlobStorageTypeFilesystem:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e BlobsStorageType) String() string {\n\treturn string(e)\n}\n\nfunc (e *BlobsStorageType) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = BlobsStorageType(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid BlobStorageType\", str)\n\t}\n\treturn nil\n}\n\nfunc (e BlobsStorageType) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n"
  },
  {
    "path": "internal/manager/config/init.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/knadh/koanf/providers/env\"\n\t\"github.com/knadh/koanf/providers/posflag\"\n\t\"github.com/knadh/koanf/v2\"\n\t\"github.com/spf13/pflag\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype flagStruct struct {\n\tconfigFilePath string\n\tnobrowser      bool\n}\n\nvar (\n\tflags flagStruct\n\n\thomeDir, _ = os.UserHomeDir()\n\n\tdefaultConfigLocations = []string{\n\t\t\"config.yml\",\n\t\tfilepath.Join(homeDir, \".stash\", \"config.yml\"),\n\t}\n\n\t// map of env vars to config keys\n\tenvBinds = map[string]string{\n\t\t\"host\":          Host,\n\t\t\"port\":          Port,\n\t\t\"external_host\": ExternalHost,\n\t\t\"generated\":     Generated,\n\t\t\"metadata\":      Metadata,\n\t\t\"blobs\":         BlobsPath,\n\t\t\"cache\":         Cache,\n\t\t\"stash\":         Stash,\n\t\t\"ui\":            UILocation,\n\t}\n)\n\nvar errConfigNotFound = errors.New(\"config file not found\")\n\nfunc init() {\n\tpflag.IP(\"host\", net.IPv4(0, 0, 0, 0), \"ip address for the host\")\n\tpflag.Int(\"port\", 9999, \"port to serve from\")\n\tpflag.StringVarP(&flags.configFilePath, \"config\", \"c\", \"\", \"config file to use\")\n\tpflag.BoolVar(&flags.nobrowser, \"nobrowser\", false, \"Don't open a browser window after launch\")\n\tpflag.StringP(\"ui-location\", \"u\", \"\", \"path to the webui\")\n}\n\n// Called at startup\nfunc Initialize() (*Config, error) {\n\tcfg := &Config{\n\t\tmain:      koanf.New(\".\"),\n\t\toverrides: koanf.New(\".\"),\n\t}\n\n\tcfg.initOverrides()\n\n\terr := cfg.initConfig()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif cfg.isNewSystem {\n\t\tif cfg.Validate() == nil {\n\t\t\t// system has been initialised by the environment\n\t\t\tcfg.isNewSystem = false\n\t\t}\n\t}\n\n\tif !cfg.isNewSystem {\n\t\tcfg.setExistingSystemDefaults()\n\n\t\terr := cfg.SetInitialConfig()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\terr = cfg.Write()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\terr = cfg.Validate()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tinstance = cfg\n\treturn instance, nil\n}\n\n// Called by tests to initialize an empty config\nfunc InitializeEmpty() *Config {\n\tcfg := &Config{\n\t\tmain:      koanf.New(\".\"),\n\t\toverrides: koanf.New(\".\"),\n\t}\n\tinstance = cfg\n\treturn instance\n}\n\nfunc (i *Config) loadFromCommandLine() {\n\tv := i.overrides\n\n\tif err := v.Load(posflag.ProviderWithFlag(pflag.CommandLine, \".\", v, func(f *pflag.Flag) (string, interface{}) {\n\t\t// ignore flags that have not been changed\n\t\tif !f.Changed {\n\t\t\treturn \"\", nil\n\t\t}\n\n\t\treturn f.Name, posflag.FlagVal(pflag.CommandLine, f)\n\t}), nil); err != nil {\n\t\tlogger.Errorf(\"failed to load flags: %v\", err)\n\t}\n}\n\nfunc (i *Config) loadFromEnv() {\n\tv := i.overrides\n\n\tif err := v.Load(env.ProviderWithValue(\"STASH_\", \".\", func(key, value string) (string, interface{}) {\n\t\tkey = strings.ToLower(strings.TrimPrefix(key, \"STASH_\"))\n\t\tif newKey, ok := envBinds[key]; ok {\n\t\t\treturn newKey, value\n\t\t}\n\n\t\treturn \"\", nil\n\t}), nil); err != nil {\n\t\tlogger.Errorf(\"failed to load envs: %v\", err)\n\t}\n}\n\nfunc (i *Config) initOverrides() {\n\ti.loadFromCommandLine()\n\ti.loadFromEnv()\n}\n\nfunc (i *Config) initConfig() error {\n\tconfigFile := \"\"\n\tenvConfigFile := os.Getenv(\"STASH_CONFIG_FILE\")\n\n\tif flags.configFilePath != \"\" {\n\t\tconfigFile = flags.configFilePath\n\t} else if envConfigFile != \"\" {\n\t\tconfigFile = envConfigFile\n\t}\n\n\tif configFile != \"\" {\n\t\t// if file does not exist, assume it is a new system\n\t\tif exists, _ := fsutil.FileExists(configFile); !exists {\n\t\t\ti.isNewSystem = true\n\t\t\ti.SetConfigFile(configFile)\n\n\t\t\t// ensure we can write to the file\n\t\t\tif err := fsutil.Touch(configFile); err != nil {\n\t\t\t\treturn fmt.Errorf(`could not write to provided config path \"%s\": %v`, configFile, err)\n\t\t\t} else {\n\t\t\t\t// remove the file\n\t\t\t\tos.Remove(configFile)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t} else {\n\t\t\t// load from provided config file\n\t\t\tif err := i.loadFirstFromFiles([]string{configFile}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// load from default locations\n\t\tif err := i.loadFirstFromFiles(defaultConfigLocations); err != nil {\n\t\t\tif errors.Is(err, errConfigNotFound) {\n\t\t\t\ti.isNewSystem = true\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Config) loadFirstFromFiles(f []string) error {\n\tfor _, ff := range f {\n\t\tif exists, _ := fsutil.FileExists(ff); exists {\n\t\t\treturn i.load(ff)\n\t\t}\n\t}\n\n\treturn errConfigNotFound\n}\n"
  },
  {
    "path": "internal/manager/config/stash_config.go",
    "content": "package config\n\nimport (\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n)\n\n// Stash configuration details\ntype StashConfigInput struct {\n\tPath         string `json:\"path\"`\n\tExcludeVideo bool   `json:\"excludeVideo\"`\n\tExcludeImage bool   `json:\"excludeImage\"`\n}\n\ntype StashConfig struct {\n\tPath         string `json:\"path\"`\n\tExcludeVideo bool   `json:\"excludeVideo\"`\n\tExcludeImage bool   `json:\"excludeImage\"`\n}\n\ntype StashConfigs []*StashConfig\n\nfunc (s StashConfigs) GetStashFromPath(path string) *StashConfig {\n\tfor _, f := range s {\n\t\tif fsutil.IsPathInDir(f.Path, filepath.Dir(path)) {\n\t\t\treturn f\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig {\n\tfor _, f := range s {\n\t\tif fsutil.IsPathInDir(f.Path, dirPath) {\n\t\t\treturn f\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s StashConfigs) Paths() []string {\n\tpaths := make([]string, len(s))\n\tfor i, c := range s {\n\t\t// #6618 - clean the path to ensure comparison works correctly\n\t\tpaths[i] = filepath.Clean(c.Path)\n\t}\n\treturn paths\n}\n"
  },
  {
    "path": "internal/manager/config/tasks.go",
    "content": "package config\n\ntype ScanMetadataOptions struct {\n\t// Forces a rescan on files even if they have not changed\n\tRescan bool `json:\"rescan\"`\n\t// Generate scene covers during scan\n\tScanGenerateCovers bool `json:\"scanGenerateCovers\"`\n\t// Generate previews during scan\n\tScanGeneratePreviews bool `json:\"scanGeneratePreviews\"`\n\t// Generate image previews during scan\n\tScanGenerateImagePreviews bool `json:\"scanGenerateImagePreviews\"`\n\t// Generate sprites during scan\n\tScanGenerateSprites bool `json:\"scanGenerateSprites\"`\n\t// Generate video phashes during scan\n\tScanGeneratePhashes bool `json:\"scanGeneratePhashes\"`\n\t// Generate image phashes during scan\n\tScanGenerateImagePhashes bool `json:\"scanGenerateImagePhashes\"`\n\t// Generate image thumbnails during scan\n\tScanGenerateThumbnails bool `json:\"scanGenerateThumbnails\"`\n\t// Generate image thumbnails during scan\n\tScanGenerateClipPreviews bool `json:\"scanGenerateClipPreviews\"`\n}\n\ntype AutoTagMetadataOptions struct {\n\t// IDs of performers to tag files with, or \"*\" for all\n\tPerformers []string `json:\"performers\"`\n\t// IDs of studios to tag files with, or \"*\" for all\n\tStudios []string `json:\"studios\"`\n\t// IDs of tags to tag files with, or \"*\" for all\n\tTags []string `json:\"tags\"`\n}\n"
  },
  {
    "path": "internal/manager/config/ui.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype ConfigImageLightboxResult struct {\n\tSlideshowDelay             *int                      `json:\"slideshowDelay\"`\n\tDisplayMode                *ImageLightboxDisplayMode `json:\"displayMode\"`\n\tScaleUp                    *bool                     `json:\"scaleUp\"`\n\tResetZoomOnNav             *bool                     `json:\"resetZoomOnNav\"`\n\tScrollMode                 *ImageLightboxScrollMode  `json:\"scrollMode\"`\n\tScrollAttemptsBeforeChange int                       `json:\"scrollAttemptsBeforeChange\"`\n\tDisableAnimation           *bool                     `json:\"disableAnimation\"`\n}\n\ntype ImageLightboxDisplayMode string\n\nconst (\n\tImageLightboxDisplayModeOriginal ImageLightboxDisplayMode = \"ORIGINAL\"\n\tImageLightboxDisplayModeFitXy    ImageLightboxDisplayMode = \"FIT_XY\"\n\tImageLightboxDisplayModeFitX     ImageLightboxDisplayMode = \"FIT_X\"\n)\n\nvar AllImageLightboxDisplayMode = []ImageLightboxDisplayMode{\n\tImageLightboxDisplayModeOriginal,\n\tImageLightboxDisplayModeFitXy,\n\tImageLightboxDisplayModeFitX,\n}\n\nfunc (e ImageLightboxDisplayMode) IsValid() bool {\n\tswitch e {\n\tcase ImageLightboxDisplayModeOriginal, ImageLightboxDisplayModeFitXy, ImageLightboxDisplayModeFitX:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e ImageLightboxDisplayMode) String() string {\n\treturn string(e)\n}\n\nfunc (e *ImageLightboxDisplayMode) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = ImageLightboxDisplayMode(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid ImageLightboxDisplayMode\", str)\n\t}\n\treturn nil\n}\n\nfunc (e ImageLightboxDisplayMode) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype ImageLightboxScrollMode string\n\nconst (\n\tImageLightboxScrollModeZoom ImageLightboxScrollMode = \"ZOOM\"\n\tImageLightboxScrollModePanY ImageLightboxScrollMode = \"PAN_Y\"\n)\n\nvar AllImageLightboxScrollMode = []ImageLightboxScrollMode{\n\tImageLightboxScrollModeZoom,\n\tImageLightboxScrollModePanY,\n}\n\nfunc (e ImageLightboxScrollMode) IsValid() bool {\n\tswitch e {\n\tcase ImageLightboxScrollModeZoom, ImageLightboxScrollModePanY:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e ImageLightboxScrollMode) String() string {\n\treturn string(e)\n}\n\nfunc (e *ImageLightboxScrollMode) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = ImageLightboxScrollMode(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid ImageLightboxScrollMode\", str)\n\t}\n\treturn nil\n}\n\nfunc (e ImageLightboxScrollMode) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype ConfigDisableDropdownCreate struct {\n\tPerformer bool `json:\"performer\"`\n\tTag       bool `json:\"tag\"`\n\tStudio    bool `json:\"studio\"`\n\tMovie     bool `json:\"movie\"`\n\tGallery   bool `json:\"gallery\"`\n}\n"
  },
  {
    "path": "internal/manager/downloads.go",
    "content": "package manager\n\nimport (\n\t\"net/http\"\n\t\"os\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/hash\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// DownloadStore manages single-use generated files for the UI to download.\ntype DownloadStore struct {\n\tm     map[string]*storeFile\n\tmutex sync.Mutex\n}\n\ntype storeFile struct {\n\tpath        string\n\tcontentType string\n\tkeep        bool\n\twg          sync.WaitGroup\n\tonce        sync.Once\n}\n\nfunc NewDownloadStore() *DownloadStore {\n\treturn &DownloadStore{\n\t\tm: make(map[string]*storeFile),\n\t}\n}\n\nfunc (s *DownloadStore) RegisterFile(fp string, contentType string, keep bool) (string, error) {\n\tconst keyLength = 4\n\tconst attempts = 100\n\n\t// keep generating random keys until we get a free one\n\t// prevent infinite loop by only attempting a finite amount of times\n\tvar h string\n\tgenerate := true\n\ta := 0\n\n\ts.mutex.Lock()\n\tfor generate && a < attempts {\n\t\tvar err error\n\t\th, err = hash.GenerateRandomKey(keyLength)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\t_, generate = s.m[h]\n\t\ta++\n\t}\n\n\ts.m[h] = &storeFile{\n\t\tpath:        fp,\n\t\tcontentType: contentType,\n\t\tkeep:        keep,\n\t}\n\ts.mutex.Unlock()\n\n\treturn h, nil\n}\n\nfunc (s *DownloadStore) Serve(hash string, w http.ResponseWriter, r *http.Request) {\n\ts.mutex.Lock()\n\tf, ok := s.m[hash]\n\n\tif !ok {\n\t\ts.mutex.Unlock()\n\t\thttp.NotFound(w, r)\n\t\treturn\n\t}\n\n\tif !f.keep {\n\t\ts.waitAndRemoveFile(hash, &w, r)\n\t}\n\n\ts.mutex.Unlock()\n\n\tif f.contentType != \"\" {\n\t\tw.Header().Add(\"Content-Type\", f.contentType)\n\t}\n\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\thttp.ServeFile(w, r, f.path)\n}\n\nfunc (s *DownloadStore) waitAndRemoveFile(hash string, w *http.ResponseWriter, r *http.Request) {\n\tf := s.m[hash]\n\tnotify := r.Context().Done()\n\tf.wg.Add(1)\n\n\tgo func() {\n\t\t<-notify\n\t\ts.mutex.Lock()\n\t\tdefer s.mutex.Unlock()\n\n\t\tf.wg.Done()\n\t}()\n\n\tgo f.once.Do(func() {\n\t\t// leave it up for 30 seconds after the first request to allow for multiple requests\n\t\ttime.Sleep(30 * time.Second)\n\n\t\tf.wg.Wait()\n\t\ts.mutex.Lock()\n\t\tdefer s.mutex.Unlock()\n\n\t\tdelete(s.m, hash)\n\t\terr := os.Remove(f.path)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error removing %s after downloading: %s\", f.path, err.Error())\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "internal/manager/enums.go",
    "content": "package manager\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype SystemStatusEnum string\n\nconst (\n\tSystemStatusEnumSetup          SystemStatusEnum = \"SETUP\"\n\tSystemStatusEnumNeedsMigration SystemStatusEnum = \"NEEDS_MIGRATION\"\n\tSystemStatusEnumOk             SystemStatusEnum = \"OK\"\n)\n\nvar AllSystemStatusEnum = []SystemStatusEnum{\n\tSystemStatusEnumSetup,\n\tSystemStatusEnumNeedsMigration,\n\tSystemStatusEnumOk,\n}\n\nfunc (e SystemStatusEnum) IsValid() bool {\n\tswitch e {\n\tcase SystemStatusEnumSetup, SystemStatusEnumNeedsMigration, SystemStatusEnumOk:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e SystemStatusEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *SystemStatusEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = SystemStatusEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid SystemStatusEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e SystemStatusEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n"
  },
  {
    "path": "internal/manager/exclude_files.go",
    "content": "package manager\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nfunc excludeFiles(files []string, patterns []string) ([]string, int) {\n\tif patterns == nil {\n\t\tlogger.Infof(\"No exclude patterns in config\")\n\t\treturn files, 0\n\t} else {\n\t\tvar results []string\n\t\tvar exclCount int\n\n\t\tfileRegexps := generateRegexps(patterns)\n\n\t\tif len(fileRegexps) == 0 {\n\t\t\tlogger.Infof(\"Excluded 0 files from scan\")\n\t\t\treturn files, 0\n\t\t}\n\n\t\tfor _, f := range files {\n\t\t\tif matchFileSimple(f, fileRegexps) {\n\t\t\t\tlogger.Infof(\"File matched pattern. Excluding:\\\"%s\\\"\", f)\n\t\t\t\texclCount++\n\t\t\t} else {\n\t\t\t\t// if pattern doesn't match add file to list\n\t\t\t\tresults = append(results, f)\n\t\t\t}\n\t\t}\n\t\tlogger.Infof(\"Excluded %d file(s) from scan\", exclCount)\n\t\treturn results, exclCount\n\t}\n}\n\nfunc matchFileRegex(file string, fileRegexps []*regexp.Regexp) bool {\n\tfor _, regPattern := range fileRegexps {\n\t\tif regPattern.MatchString(file) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc matchFile(file string, patterns []string) bool {\n\tif patterns != nil {\n\t\tfileRegexps := generateRegexps(patterns)\n\n\t\treturn matchFileRegex(file, fileRegexps)\n\t}\n\n\treturn false\n}\n\nfunc generateRegexps(patterns []string) []*regexp.Regexp {\n\n\tvar fileRegexps []*regexp.Regexp\n\n\tfor _, pattern := range patterns {\n\t\tif pattern == \"\" || pattern == \" \" {\n\t\t\tlogger.Warnf(\"Skipping empty exclude pattern\")\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasPrefix(pattern, \"(?i)\") {\n\t\t\tpattern = \"(?i)\" + pattern\n\t\t}\n\t\treg, err := regexp.Compile(pattern)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Exclude :%v\", err)\n\t\t} else {\n\t\t\tfileRegexps = append(fileRegexps, reg)\n\t\t}\n\t}\n\n\tif len(fileRegexps) == 0 {\n\t\treturn nil\n\t} else {\n\t\treturn fileRegexps\n\t}\n\n}\n\nfunc matchFileSimple(file string, regExps []*regexp.Regexp) bool {\n\tfor _, regPattern := range regExps {\n\t\tif regPattern.MatchString(file) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/manager/exclude_files_test.go",
    "content": "package manager\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nvar excludeTestFilenames = []string{\n\t\"/stash/videos/filename.mp4\",\n\t\"/stash/videos/new filename.mp4\",\n\t\"filename sample.mp4\",\n\t\"/stash/videos/exclude/not wanted.webm\",\n\t\"/stash/videos/exclude/not wanted2.webm\",\n\t\"/somewhere/trash/not wanted.wmv\",\n\t\"/disk2/stash/videos/exclude/!!wanted!!.avi\",\n\t\"/disk2/stash/videos/xcl/not wanted.avi\",\n\t\"/stash/videos/partial.file.001.webm\",\n\t\"/stash/videos/partial.file.002.webm\",\n\t\"/stash/videos/partial.file.003.webm\",\n\t\"/stash/videos/sample file sample.mkv\",\n\t\"/stash/videos/.ckRVp1/.still_encoding.mp4\",\n\t\"c:\\\\stash\\\\videos\\\\exclude\\\\filename  windows.mp4\",\n\t\"c:\\\\stash\\\\videos\\\\filename  windows.mp4\",\n\t\"\\\\\\\\network\\\\videos\\\\filename  windows network.mp4\",\n\t\"\\\\\\\\network\\\\share\\\\windows network wanted.mp4\",\n\t\"\\\\\\\\network\\\\share\\\\windows network wanted sample.mp4\",\n\t\"\\\\\\\\network\\\\private\\\\windows.network.skip.mp4\",\n\t\"/stash/videos/a5.mp4\",\n\t\"/stash/videos/mIxEdCaSe.mp4\"}\n\nvar excludeTests = []struct {\n\ttestPattern []string\n\texpected    int\n}{\n\t{[]string{\"sample\\\\.mp4$\", \"trash\", \"\\\\.[\\\\d]{3}\\\\.webm$\"}, 6}, // generic\n\t{[]string{\"no_match\\\\.mp4\"}, 0},                                // no match\n\t{[]string{\"^/stash/videos/exclude/\", \"/videos/xcl/\"}, 3},       // linux\n\t{[]string{\"/\\\\.[[:word:]]+/\"}, 1},                              // linux hidden dirs (handbrake unraid issue?)\n\t{[]string{\"c:\\\\\\\\stash\\\\\\\\videos\\\\\\\\exclude\"}, 1},              // windows\n\t{[]string{\"\\\\/[/invalid\"}, 0},                                  // invalid pattern\n\t{[]string{\"\\\\/[/invalid\", \"sample\\\\.[[:alnum:]]+$\"}, 3},        // invalid pattern but continue\n\t{[]string{\"^\\\\\\\\\\\\\\\\network\"}, 4},                              // windows net share\n\t{[]string{\"\\\\\\\\private\\\\\\\\\"}, 1},                               // windows net share\n\t{[]string{\"\\\\\\\\private\\\\\\\\\", \"sample\\\\.mp4\"}, 3},               // windows net share\n\t{[]string{\"\\\\D\\\\d\\\\.mp4\"}, 1},                                  // validates that \\D doesn't get converted to lowercase \\d\n\t{[]string{\"mixedcase\\\\.mp4\"}, 1},                               // validates we can match the mixed case file\n\t{[]string{\"MIXEDCASE\\\\.mp4\"}, 1},                               // validates we can match the mixed case file\n\t{[]string{\"(?i)MIXEDCASE\\\\.mp4\"}, 1},                           // validates we can match the mixed case file without adding another (?i) to it\n}\n\nfunc TestExcludeFiles(t *testing.T) {\n\tfor _, test := range excludeTests {\n\t\terr := runExclude(excludeTestFilenames, test.testPattern, test.expected)\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc runExclude(filenames []string, patterns []string, expCount int) error {\n\n\tfiles, count := excludeFiles(filenames, patterns)\n\n\tif count != expCount {\n\t\treturn fmt.Errorf(\"Was expecting %d, found %d\", expCount, count)\n\t}\n\tif len(files) != len(filenames)-expCount {\n\t\treturn fmt.Errorf(\"Returned list should have %d files, not %d \", len(filenames)-expCount, len(files))\n\t}\n\n\treturn nil\n}\n\nfunc TestMatchFile(t *testing.T) {\n\tfor _, test := range excludeTests {\n\t\terr := runMatch(excludeTestFilenames, test.testPattern, test.expected)\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc runMatch(filenames []string, patterns []string, expCount int) error {\n\tcount := 0\n\tfor _, file := range filenames {\n\t\tif matchFile(file, patterns) {\n\t\t\tlogger.Infof(\"File \\\"%s\\\" matched pattern\\n\", file)\n\t\t\tcount++\n\t\t}\n\t}\n\tif count != expCount {\n\t\treturn fmt.Errorf(\"Was expecting %d, found %d\", expCount, count)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/fingerprint.go",
    "content": "package manager\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/hash/md5\"\n\t\"github.com/stashapp/stash/pkg/hash/oshash\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype fingerprintCalculator struct {\n\tConfig *config.Config\n}\n\nfunc (c *fingerprintCalculator) calculateOshash(f *models.BaseFile, o file.Opener) (*models.Fingerprint, error) {\n\tr, err := o.Open()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening file: %w\", err)\n\t}\n\n\tdefer r.Close()\n\n\trc, isRC := r.(io.ReadSeeker)\n\tif !isRC {\n\t\treturn nil, errors.New(\"cannot calculate oshash for non-readcloser\")\n\t}\n\n\thash, err := oshash.FromReader(rc, f.Size)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"calculating oshash: %w\", err)\n\t}\n\n\treturn &models.Fingerprint{\n\t\tType:        models.FingerprintTypeOshash,\n\t\tFingerprint: hash,\n\t}, nil\n}\n\nfunc (c *fingerprintCalculator) calculateMD5(o file.Opener) (*models.Fingerprint, error) {\n\tr, err := o.Open()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening file: %w\", err)\n\t}\n\n\tdefer r.Close()\n\n\thash, err := md5.FromReader(r)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"calculating md5: %w\", err)\n\t}\n\n\treturn &models.Fingerprint{\n\t\tType:        models.FingerprintTypeMD5,\n\t\tFingerprint: hash,\n\t}, nil\n}\n\nfunc (c *fingerprintCalculator) CalculateFingerprints(f *models.BaseFile, o file.Opener, useExisting bool) ([]models.Fingerprint, error) {\n\tvar ret []models.Fingerprint\n\tcalculateMD5 := true\n\n\tif useAsVideo(f.Path) {\n\t\tvar (\n\t\t\tfp  *models.Fingerprint\n\t\t\terr error\n\t\t)\n\n\t\tif useExisting {\n\t\t\tfp = f.Fingerprints.For(models.FingerprintTypeOshash)\n\t\t}\n\n\t\tif fp == nil {\n\t\t\t// calculate oshash first\n\t\t\tfp, err = c.calculateOshash(f, o)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\n\t\tret = append(ret, *fp)\n\n\t\t// only calculate MD5 if enabled in config\n\t\tcalculateMD5 = c.Config.IsCalculateMD5()\n\t}\n\n\tif calculateMD5 {\n\t\tvar (\n\t\t\tfp  *models.Fingerprint\n\t\t\terr error\n\t\t)\n\n\t\tif useExisting {\n\t\t\tfp = f.Fingerprints.For(models.FingerprintTypeMD5)\n\t\t}\n\n\t\tif fp == nil {\n\t\t\tif useExisting {\n\t\t\t\t// log to indicate missing fingerprint is being calculated\n\t\t\t\tlogger.Infof(\"Calculating checksum for %s ...\", f.Path)\n\t\t\t}\n\n\t\t\tfp, err = c.calculateMD5(o)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\n\t\tret = append(ret, *fp)\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "internal/manager/generator.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype generatorInfo struct {\n\tChunkCount     int\n\tFrameRate      float64\n\tNumberOfFrames int\n\n\t// NthFrame used for sprite generation\n\tNthFrame int\n\n\tVideoFile ffmpeg.VideoFile\n}\n\nfunc newGeneratorInfo(videoFile ffmpeg.VideoFile) (*generatorInfo, error) {\n\texists, err := fsutil.FileExists(videoFile.Path)\n\tif !exists {\n\t\tlogger.Errorf(\"video file not found\")\n\t\treturn nil, err\n\t}\n\n\tgenerator := &generatorInfo{VideoFile: videoFile}\n\treturn generator, nil\n}\n\nfunc (g *generatorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) error {\n\tvar framerate float64\n\tif g.VideoFile.FrameRate == 0 {\n\t\tframerate, _ = strconv.ParseFloat(videoStream.RFrameRate, 64)\n\t} else {\n\t\tframerate = g.VideoFile.FrameRate\n\t}\n\n\tnumberOfFrames, _ := strconv.Atoi(videoStream.NbFrames)\n\n\tif numberOfFrames == 0 && isValidFloat64(framerate) && g.VideoFile.VideoStreamDuration > 0 { // TODO: test\n\t\tnumberOfFrames = int(framerate * g.VideoFile.VideoStreamDuration)\n\t}\n\n\t// If we are missing the frame count or frame rate then seek through the file and extract the info with regex\n\tif numberOfFrames == 0 || !isValidFloat64(framerate) {\n\t\tinfo, err := instance.FFMpeg.CalculateFrameRate(context.TODO(), &g.VideoFile)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error calculating frame rate: %v\", err)\n\t\t} else {\n\t\t\tif numberOfFrames == 0 {\n\t\t\t\tnumberOfFrames = info.NumberOfFrames\n\t\t\t}\n\t\t\tif !isValidFloat64(framerate) {\n\t\t\t\tframerate = info.FrameRate\n\t\t\t}\n\t\t}\n\t}\n\n\t// Something seriously wrong with this file\n\tif numberOfFrames == 0 || !isValidFloat64(framerate) {\n\t\tlogger.Errorf(\n\t\t\t\"number of frames or framerate is 0.  nb_frames <%s> framerate <%f> duration <%f>\",\n\t\t\tvideoStream.NbFrames,\n\t\t\tframerate,\n\t\t\tg.VideoFile.VideoStreamDuration,\n\t\t)\n\t}\n\n\tg.FrameRate = framerate\n\tg.NumberOfFrames = numberOfFrames\n\n\treturn nil\n}\n\n// isValidFloat64 ensures the given value is a valid number (not NaN) which is not equal to 0\nfunc isValidFloat64(value float64) bool {\n\treturn !math.IsNaN(value) && value != 0\n}\n\nfunc (g *generatorInfo) configure() error {\n\tvideoStream := g.VideoFile.VideoStream\n\tif videoStream == nil {\n\t\treturn fmt.Errorf(\"missing video stream\")\n\t}\n\n\tif err := g.calculateFrameRate(videoStream); err != nil {\n\t\treturn err\n\t}\n\n\t// #2250 - ensure ChunkCount is valid\n\tif g.ChunkCount < 1 {\n\t\tlogger.Warnf(\"[generator] Segment count (%d) must be > 0. Using 1 instead.\", g.ChunkCount)\n\t\tg.ChunkCount = 1\n\t}\n\n\tg.NthFrame = g.NumberOfFrames / g.ChunkCount\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/generator_interactive_heatmap_speed.go",
    "content": "package manager\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image\"\n\t\"image/draw\"\n\t\"image/png\"\n\t\"math\"\n\t\"os\"\n\t\"sort\"\n\n\t\"github.com/lucasb-eyer/go-colorful\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype InteractiveHeatmapSpeedGenerator struct {\n\tInteractiveSpeed int\n\tFunscript        Script\n\tWidth            int\n\tHeight           int\n\tNumSegments      int\n\n\tDrawRange bool\n}\n\ntype Script struct {\n\t// Version of Launchscript\n\t// #5600 - ignore version, don't validate type\n\tVersion json.RawMessage `json:\"version\"`\n\t// Inverted causes up and down movement to be flipped.\n\tInverted bool `json:\"inverted,omitempty\"`\n\t// Range is the percentage of a full stroke to use.\n\tRange int `json:\"range,omitempty\"`\n\t// Actions are the timed moves.\n\tActions []Action `json:\"actions\"`\n}\n\n// Action is a move at a specific time.\ntype Action struct {\n\t// At time in milliseconds the action should fire.\n\tAt float64 `json:\"at\"`\n\t// Pos is the place in percent to move to.\n\tPos int `json:\"pos\"`\n\n\tSpeed float64\n}\n\ntype GradientTable []struct {\n\tCol    colorful.Color\n\tPos    float64\n\tYRange [2]float64\n}\n\nfunc NewInteractiveHeatmapSpeedGenerator(drawRange bool) *InteractiveHeatmapSpeedGenerator {\n\treturn &InteractiveHeatmapSpeedGenerator{\n\t\tWidth:       1280,\n\t\tHeight:      60,\n\t\tNumSegments: 600,\n\t\tDrawRange:   drawRange,\n\t}\n}\n\nfunc (g *InteractiveHeatmapSpeedGenerator) Generate(funscriptPath string, heatmapPath string, sceneDuration float64) error {\n\tfunscript, err := g.LoadFunscriptData(funscriptPath, sceneDuration)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(funscript.Actions) == 0 {\n\t\treturn fmt.Errorf(\"no valid actions in funscript\")\n\t}\n\n\tsceneDurationMilli := int64(sceneDuration * 1000)\n\tg.Funscript = funscript\n\tg.Funscript.UpdateIntensityAndSpeed()\n\n\terr = g.RenderHeatmap(heatmapPath, sceneDurationMilli)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tg.InteractiveSpeed = g.Funscript.CalculateMedian()\n\n\treturn nil\n}\n\nfunc (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneDuration float64) (Script, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn Script{}, err\n\t}\n\n\tvar funscript Script\n\terr = json.Unmarshal(data, &funscript)\n\tif err != nil {\n\t\treturn Script{}, err\n\t}\n\n\tif funscript.Actions == nil {\n\t\treturn Script{}, fmt.Errorf(\"actions list missing in %s\", path)\n\t}\n\n\tsort.SliceStable(funscript.Actions, func(i, j int) bool { return funscript.Actions[i].At < funscript.Actions[j].At })\n\n\t// trim actions with negative timestamps to avoid index range errors when generating heatmap\n\t// #3181 - also trim actions that occur after the scene duration\n\tloggedBadTimestamp := false\n\tsceneDurationMilli := sceneDuration * 1000\n\tisValid := func(x float64) bool {\n\t\treturn x >= 0 && x < sceneDurationMilli\n\t}\n\n\ti := 0\n\tfor _, x := range funscript.Actions {\n\t\tif isValid(x.At) {\n\t\t\tfunscript.Actions[i] = x\n\t\t\ti++\n\t\t} else if !loggedBadTimestamp {\n\t\t\tloggedBadTimestamp = true\n\t\t\tlogger.Warnf(\"Invalid timestamp %d in %s: subsequent invalid timestamps will not be logged\", x.At, path)\n\t\t}\n\t}\n\n\tfunscript.Actions = funscript.Actions[:i]\n\n\treturn funscript, nil\n}\n\nfunc (funscript *Script) UpdateIntensityAndSpeed() {\n\n\tvar t1, t2 float64\n\tvar p1, p2 int\n\tvar intensity float64\n\tfor i := range funscript.Actions {\n\t\tif i == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tt1 = funscript.Actions[i].At\n\t\tt2 = funscript.Actions[i-1].At\n\t\tp1 = funscript.Actions[i].Pos\n\t\tp2 = funscript.Actions[i-1].Pos\n\n\t\tspeed := math.Abs(float64(p1 - p2))\n\t\tintensity = float64(speed/float64(t1-t2)) * 1000\n\n\t\tfunscript.Actions[i].Speed = intensity\n\t}\n}\n\n// funscript needs to have intensity updated first\nfunc (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string, sceneDurationMilli int64) error {\n\tgradient := g.Funscript.getGradientTable(g.NumSegments, sceneDurationMilli)\n\n\timg := image.NewRGBA(image.Rect(0, 0, g.Width, g.Height))\n\tfor x := 0; x < g.Width; x++ {\n\t\txPos := float64(x) / float64(g.Width)\n\t\tc := gradient.GetInterpolatedColorFor(xPos)\n\n\t\ty0 := 0\n\t\ty1 := g.Height\n\n\t\tif g.DrawRange {\n\t\t\tyRange := gradient.GetYRange(xPos)\n\t\t\ttop := int(yRange[0] / 100.0 * float64(g.Height))\n\t\t\tbottom := int(yRange[1] / 100.0 * float64(g.Height))\n\n\t\t\ty0 = g.Height - top\n\t\t\ty1 = g.Height - bottom\n\t\t}\n\n\t\tdraw.Draw(img, image.Rect(x, y0, x+1, y1), &image.Uniform{c}, image.Point{}, draw.Src)\n\t}\n\n\t// add 10 minute marks\n\tmaxts := sceneDurationMilli\n\tconst tick = 600000\n\tvar ts int64 = tick\n\tc, _ := colorful.Hex(\"#000000\")\n\tfor ts < maxts {\n\t\tx := int(float64(ts) / float64(maxts) * float64(g.Width))\n\t\tdraw.Draw(img, image.Rect(x-1, g.Height/2, x+1, g.Height), &image.Uniform{c}, image.Point{}, draw.Src)\n\t\tts += tick\n\t}\n\n\toutpng, err := os.Create(heatmapPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer outpng.Close()\n\n\terr = png.Encode(outpng, img)\n\treturn err\n}\n\nfunc (funscript *Script) CalculateMedian() int {\n\tsort.Slice(funscript.Actions, func(i, j int) bool {\n\t\treturn funscript.Actions[i].Speed < funscript.Actions[j].Speed\n\t})\n\n\tmNumber := len(funscript.Actions) / 2\n\n\tif len(funscript.Actions)%2 != 0 {\n\t\treturn int(funscript.Actions[mNumber].Speed)\n\t}\n\n\treturn int((funscript.Actions[mNumber-1].Speed + funscript.Actions[mNumber].Speed) / 2)\n}\n\nfunc (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color {\n\tfor i := 0; i < len(gt)-1; i++ {\n\t\tc1 := gt[i]\n\t\tc2 := gt[i+1]\n\t\tif c1.Pos <= t && t <= c2.Pos {\n\t\t\t// We are in between c1 and c2. Go blend them!\n\t\t\tt := (t - c1.Pos) / (c2.Pos - c1.Pos)\n\t\t\treturn c1.Col.BlendHcl(c2.Col, t).Clamped()\n\t\t}\n\t}\n\n\t// Nothing found? Means we're at (or past) the last gradient keypoint.\n\treturn gt[len(gt)-1].Col\n}\n\nfunc (gt GradientTable) GetYRange(t float64) [2]float64 {\n\tfor i := 0; i < len(gt)-1; i++ {\n\t\tc1 := gt[i]\n\t\tc2 := gt[i+1]\n\t\tif c1.Pos <= t && t <= c2.Pos {\n\t\t\t// TODO: We are in between c1 and c2. Go blend them!\n\t\t\treturn c1.YRange\n\t\t}\n\t}\n\n\t// Nothing found? Means we're at (or past) the last gradient keypoint.\n\treturn gt[len(gt)-1].YRange\n}\n\nfunc (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int64) GradientTable {\n\tconst windowSize = 15\n\tconst backfillThreshold = float64(500)\n\n\tsegments := make([]struct {\n\t\tcount     int\n\t\tintensity int\n\t\tyRange    [2]float64\n\t\tat        float64\n\t}, numSegments)\n\tgradient := make(GradientTable, numSegments)\n\tposList := []int{}\n\n\tmaxts := sceneDurationMilli\n\n\tfor _, a := range funscript.Actions {\n\t\tposList = append(posList, a.Pos)\n\n\t\tif len(posList) > windowSize {\n\t\t\tposList = posList[1:]\n\t\t}\n\n\t\tsortedPos := make([]int, len(posList))\n\t\tcopy(sortedPos, posList)\n\t\tsort.Ints(sortedPos)\n\n\t\ttopHalf := sortedPos[len(sortedPos)/2:]\n\t\tbottomHalf := sortedPos[0 : len(sortedPos)/2]\n\n\t\tvar totalBottom int\n\t\tvar totalTop int\n\n\t\tfor _, value := range bottomHalf {\n\t\t\ttotalBottom += value\n\t\t}\n\t\tfor _, value := range topHalf {\n\t\t\ttotalTop += value\n\t\t}\n\n\t\taverageBottom := float64(totalBottom) / float64(len(bottomHalf))\n\t\taverageTop := float64(totalTop) / float64(len(topHalf))\n\n\t\tsegment := int(float64(a.At) / float64(maxts+1) * float64(numSegments))\n\t\t// #3181 - sanity check. Clamp segment to numSegments-1\n\t\tif segment >= numSegments {\n\t\t\tsegment = numSegments - 1\n\t\t}\n\t\tsegments[segment].at = a.At\n\t\tsegments[segment].count++\n\t\tsegments[segment].intensity += int(a.Speed)\n\t\tsegments[segment].yRange[0] = averageTop\n\t\tsegments[segment].yRange[1] = averageBottom\n\t}\n\n\tlastSegment := segments[0]\n\n\t// Fill in gaps in segments\n\tfor i := 0; i < numSegments; i++ {\n\t\tsegmentTS := float64((maxts / int64(numSegments)) * int64(i))\n\n\t\t// Empty segment - fill it with the previous up to backfillThreshold ms\n\t\tif segments[i].count == 0 {\n\t\t\tif segmentTS-lastSegment.at < backfillThreshold {\n\t\t\t\tsegments[i].count = lastSegment.count\n\t\t\t\tsegments[i].intensity = lastSegment.intensity\n\t\t\t\tsegments[i].yRange[0] = lastSegment.yRange[0]\n\t\t\t\tsegments[i].yRange[1] = lastSegment.yRange[1]\n\t\t\t}\n\t\t} else {\n\t\t\tlastSegment = segments[i]\n\t\t}\n\t}\n\n\tfor i := 0; i < numSegments; i++ {\n\t\tgradient[i].Pos = float64(i) / float64(numSegments-1)\n\t\tgradient[i].YRange = segments[i].yRange\n\t\tif segments[i].count > 0 {\n\t\t\tgradient[i].Col = getSegmentColor(float64(segments[i].intensity) / float64(segments[i].count))\n\t\t} else {\n\t\t\tgradient[i].Col = getSegmentColor(0.0)\n\t\t}\n\t}\n\n\treturn gradient\n}\n\nfunc getSegmentColor(intensity float64) colorful.Color {\n\tcolorBlue, _ := colorful.Hex(\"#1e90ff\")   // DodgerBlue\n\tcolorGreen, _ := colorful.Hex(\"#228b22\")  // ForestGreen\n\tcolorYellow, _ := colorful.Hex(\"#ffd700\") // Gold\n\tcolorRed, _ := colorful.Hex(\"#dc143c\")    // Crimson\n\tcolorPurple, _ := colorful.Hex(\"#800080\") // Purple\n\tcolorBlack, _ := colorful.Hex(\"#0f001e\")\n\tcolorBackground, _ := colorful.Hex(\"#30404d\") // Same as GridCard bg\n\n\tvar stepSize = 125.0\n\tvar f float64\n\tvar c colorful.Color\n\n\tswitch {\n\tcase intensity <= 25:\n\t\tc = colorBackground\n\tcase intensity <= 1*stepSize:\n\t\tf = (intensity - 0*stepSize) / stepSize\n\t\tc = colorBlue.BlendLab(colorGreen, f)\n\tcase intensity <= 2*stepSize:\n\t\tf = (intensity - 1*stepSize) / stepSize\n\t\tc = colorGreen.BlendLab(colorYellow, f)\n\tcase intensity <= 3*stepSize:\n\t\tf = (intensity - 2*stepSize) / stepSize\n\t\tc = colorYellow.BlendLab(colorRed, f)\n\tcase intensity <= 4*stepSize:\n\t\tf = (intensity - 3*stepSize) / stepSize\n\t\tc = colorRed.BlendRgb(colorPurple, f)\n\tdefault:\n\t\tf = (intensity - 4*stepSize) / (5 * stepSize)\n\t\tf = math.Min(f, 1.0)\n\t\tc = colorPurple.BlendLab(colorBlack, f)\n\t}\n\n\treturn c\n}\n\nfunc LoadFunscriptData(path string) (Script, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn Script{}, err\n\t}\n\n\tvar funscript Script\n\terr = json.Unmarshal(data, &funscript)\n\tif err != nil {\n\t\treturn Script{}, err\n\t}\n\n\tif funscript.Actions == nil {\n\t\treturn Script{}, fmt.Errorf(\"actions list missing in %s\", path)\n\t}\n\n\tsort.SliceStable(funscript.Actions, func(i, j int) bool { return funscript.Actions[i].At < funscript.Actions[j].At })\n\n\treturn funscript, nil\n}\n\nfunc convertRange(value int, fromLow int, fromHigh int, toLow int, toHigh int) int {\n\treturn ((value-fromLow)*(toHigh-toLow))/(fromHigh-fromLow) + toLow\n}\n\nfunc ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) {\n\tfunscript, err := LoadFunscriptData(funscriptPath)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar buffer bytes.Buffer\n\tfor _, action := range funscript.Actions {\n\t\tpos := action.Pos\n\n\t\tif funscript.Inverted {\n\t\t\tpos = convertRange(pos, 0, 100, 100, 0)\n\t\t}\n\n\t\tif funscript.Range > 0 {\n\t\t\tpos = convertRange(pos, 0, funscript.Range, 0, 100)\n\t\t}\n\n\t\t// I don't know whether the csv format requires int or float, so for now we'll use int\n\t\tbuffer.WriteString(fmt.Sprintf(\"%d,%d\\r\\n\", int(math.Round(action.At)), pos))\n\t}\n\treturn buffer.Bytes(), nil\n}\n\nfunc ConvertFunscriptToCSVFile(funscriptPath string, csvPath string) error {\n\tcsvBytes, err := ConvertFunscriptToCSV(funscriptPath)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn fsutil.WriteFile(csvPath, csvBytes)\n}\n"
  },
  {
    "path": "internal/manager/generator_sprite.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"image\"\n\t\"math\"\n\n\t\"github.com/disintegration/imaging\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/scene/generate\"\n)\n\ntype SpriteGenerator struct {\n\tInfo *generatorInfo\n\n\tVideoChecksum   string\n\tImageOutputPath string\n\tVTTOutputPath   string\n\tConfig          SpriteGeneratorConfig\n\tSlowSeek        bool // use alternate seek function, very slow!\n\n\tOverwrite bool\n\n\tg *generate.Generator\n}\n\n// SpriteGeneratorConfig holds configuration for the SpriteGenerator\ntype SpriteGeneratorConfig struct {\n\t// MinimumSprites is the minimum number of sprites to generate, even if the video duration is short\n\t// SpriteInterval will be adjusted accordingly to ensure at least this many sprites are generated.\n\t// A value of 0 means no minimum, and the generator will use the provided SpriteInterval or\n\t// calculate it based on the video duration and MaximumSprites\n\tMinimumSprites int\n\n\t// MaximumSprites is the maximum number of sprites to generate, even if the video duration is long\n\t// SpriteInterval will be adjusted accordingly to ensure no more than this many sprites are generated\n\t// A value of 0 means no maximum, and the generator will use the provided SpriteInterval or\n\t// calculate it based on the video duration and MinimumSprites\n\tMaximumSprites int\n\n\t// SpriteInterval is the default interval in seconds between each sprite.\n\t// If MinimumSprites or MaximumSprites are set, this value will be adjusted accordingly\n\t// to ensure the desired number of sprites are generated\n\t// A value of 0 means the generator will calculate the interval based on the video duration and\n\t// the provided MinimumSprites and MaximumSprites\n\tSpriteInterval float64\n\n\t// SpriteSize is the size in pixels of the longest dimension of each sprite image.\n\t// The other dimension will be automatically calculated to maintain the aspect ratio of the video\n\tSpriteSize int\n}\n\nconst (\n\t// DefaultSpriteAmount is the default number of sprites to generate if no configuration is provided\n\t// This corresponds to the legacy behavior of the generator, which generates 81 sprites at equal\n\t// intervals across the video duration\n\tDefaultSpriteAmount = 81\n\n\t// DefaultSpriteSize is the default size in pixels of the longest dimension of each sprite image\n\t// if no configuration is provided. This corresponds to the legacy behavior of the generator.\n\tDefaultSpriteSize = 160\n)\n\nvar DefaultSpriteGeneratorConfig = SpriteGeneratorConfig{\n\tMinimumSprites: DefaultSpriteAmount,\n\tMaximumSprites: DefaultSpriteAmount,\n\tSpriteInterval: 0,\n\tSpriteSize:     DefaultSpriteSize,\n}\n\n// NewSpriteGenerator creates a new SpriteGenerator for the given video file and configuration\n// It calculates the appropriate sprite interval and count based on the video duration and the provided configuration\nfunc NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, config SpriteGeneratorConfig) (*SpriteGenerator, error) {\n\texists, err := fsutil.FileExists(videoFile.Path)\n\tif !exists {\n\t\treturn nil, err\n\t}\n\n\tif videoFile.VideoStreamDuration <= 0 {\n\t\ts := fmt.Sprintf(\"video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation\", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount)\n\t\treturn nil, errors.New(s)\n\t}\n\n\tconfig.SpriteInterval = calculateSpriteInterval(videoFile, config)\n\tchunkCount := int(math.Ceil(videoFile.VideoStreamDuration / config.SpriteInterval))\n\n\t// adjust the chunk count to the next highest perfect square, to ensure the sprite image\n\t// is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns)\n\tgridSize := generate.GetSpriteGridSize(chunkCount)\n\tnewChunkCount := gridSize * gridSize\n\n\tif newChunkCount != chunkCount {\n\t\tlogger.Debugf(\"[generator] adjusting chunk count from %d to %d to fit a %dx%d grid\", chunkCount, newChunkCount, gridSize, gridSize)\n\t\tchunkCount = newChunkCount\n\t}\n\n\tif config.SpriteSize <= 0 {\n\t\tconfig.SpriteSize = DefaultSpriteSize\n\t}\n\n\tslowSeek := false\n\n\t// For files with small duration / low frame count  try to seek using frame number intead of seconds\n\tif videoFile.VideoStreamDuration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek  if duration < 5\n\t\tif videoFile.VideoStreamDuration <= 0 {\n\t\t\ts := fmt.Sprintf(\"video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation\", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount)\n\t\t\treturn nil, errors.New(s)\n\t\t}\n\t\tlogger.Warnf(\"[generator] video %s too short (%.3fs, %d frames), using frame seeking\", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount)\n\t\tslowSeek = true\n\t\t// do an actual frame count of the file ( number of frames = read frames)\n\t\tffprobe := GetInstance().FFProbe\n\t\tfc, err := ffprobe.GetReadFrameCount(videoFile.Path)\n\t\tif err == nil {\n\t\t\tif fc != videoFile.FrameCount {\n\t\t\t\tlogger.Warnf(\"[generator] updating framecount (%d) for %s with read frames count (%d)\", videoFile.FrameCount, videoFile.Path, fc)\n\t\t\t\tvideoFile.FrameCount = fc\n\t\t\t}\n\t\t}\n\t}\n\n\tgenerator, err := newGeneratorInfo(videoFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tgenerator.ChunkCount = chunkCount\n\tif err := generator.configure(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &SpriteGenerator{\n\t\tInfo:            generator,\n\t\tVideoChecksum:   videoChecksum,\n\t\tImageOutputPath: imageOutputPath,\n\t\tVTTOutputPath:   vttOutputPath,\n\t\tConfig:          config,\n\t\tSlowSeek:        slowSeek,\n\t\tg: &generate.Generator{\n\t\t\tEncoder:      instance.FFMpeg,\n\t\t\tFFMpegConfig: instance.Config,\n\t\t\tLockManager:  instance.ReadLockManager,\n\t\t\tScenePaths:   instance.Paths.Scene,\n\t\t},\n\t}, nil\n}\n\nfunc calculateSpriteInterval(videoFile ffmpeg.VideoFile, config SpriteGeneratorConfig) float64 {\n\t// If a custom sprite interval is provided, start with that\n\tspriteInterval := config.SpriteInterval\n\n\t// If no custom interval is provided, calculate the interval based on the\n\t// video duration and minimum sprite count\n\tif spriteInterval <= 0 {\n\t\tminSprites := config.MinimumSprites\n\t\tif minSprites <= 0 {\n\t\t\tpanic(\"invalid configuration: MinimumSprites must be greater than 0 if SpriteInterval is not set\")\n\t\t}\n\n\t\tlogger.Debugf(\"[generator] calculating sprite interval for video duration %.3fs with minimum sprites %d\", videoFile.VideoStreamDuration, minSprites)\n\t\treturn videoFile.VideoStreamDuration / float64(minSprites)\n\t}\n\n\t// Calculate the number of sprites that would be generated with the provided interval\n\tspriteCount := int(math.Ceil(videoFile.VideoStreamDuration / spriteInterval))\n\n\t// If the calculated sprite count is greater than the maximum, adjust the interval to meet the maximum\n\tif config.MaximumSprites > 0 && spriteCount > int(config.MaximumSprites) {\n\t\tspriteInterval = videoFile.VideoStreamDuration / float64(config.MaximumSprites)\n\t\tlogger.Debugf(\"[generator] provided sprite interval %.1fs results in %d sprites, which exceeds the maximum of %d, adjusting interval to %.1fs\", config.SpriteInterval, spriteCount, config.MaximumSprites, spriteInterval)\n\t}\n\n\t// If the calculated sprite count is less than the minimum, adjust the interval to meet the minimum\n\tif config.MinimumSprites > 0 && spriteCount < int(config.MinimumSprites) {\n\t\tspriteInterval = videoFile.VideoStreamDuration / float64(config.MinimumSprites)\n\t\tlogger.Debugf(\"[generator] provided sprite interval %.1fs results in %d sprites, which is less than the minimum of %d, adjusting interval to %.1fs\", config.SpriteInterval, spriteCount, config.MinimumSprites, spriteInterval)\n\t}\n\n\treturn spriteInterval\n}\n\nfunc (g *SpriteGenerator) Generate() error {\n\tif err := g.generateSpriteImage(); err != nil {\n\t\treturn err\n\t}\n\tif err := g.generateSpriteVTT(); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (g *SpriteGenerator) generateSpriteImage() error {\n\tif !g.Overwrite && g.imageExists() {\n\t\treturn nil\n\t}\n\n\tvar images []image.Image\n\n\tisPortrait := g.Info.VideoFile.Height > g.Info.VideoFile.Width\n\n\tif !g.SlowSeek {\n\t\tlogger.Infof(\"[generator] generating sprite image for %s\", g.Info.VideoFile.Path)\n\t\t// generate `ChunkCount` thumbnails\n\t\tstepSize := g.Info.VideoFile.VideoStreamDuration / float64(g.Info.ChunkCount)\n\n\t\tfor i := 0; i < g.Info.ChunkCount; i++ {\n\t\t\ttime := float64(i) * stepSize\n\t\t\timg, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time, g.Config.SpriteSize, isPortrait)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\timages = append(images, img)\n\t\t}\n\t} else {\n\t\tlogger.Infof(\"[generator] generating sprite image for %s (%d frames)\", g.Info.VideoFile.Path, g.Info.VideoFile.FrameCount)\n\n\t\tstepFrame := float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount)\n\n\t\tfor i := 0; i < g.Info.ChunkCount; i++ {\n\t\t\t// generate exactly `ChunkCount` thumbnails, using duplicate frames if needed\n\t\t\tframe := math.Round(float64(i) * stepFrame)\n\t\t\tif frame >= math.MaxInt || frame <= math.MinInt {\n\t\t\t\treturn errors.New(\"invalid frame number conversion\")\n\t\t\t}\n\n\t\t\timg, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame), g.Config.SpriteSize)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\timages = append(images, img)\n\t\t}\n\n\t}\n\n\tif len(images) == 0 {\n\t\treturn fmt.Errorf(\"images slice is empty, failed to generate sprite images for %s\", g.Info.VideoFile.Path)\n\t}\n\n\treturn imaging.Save(g.g.CombineSpriteImages(images), g.ImageOutputPath)\n}\n\nfunc (g *SpriteGenerator) generateSpriteVTT() error {\n\tif !g.Overwrite && g.vttExists() {\n\t\treturn nil\n\t}\n\tlogger.Infof(\"[generator] generating sprite vtt for %s\", g.Info.VideoFile.Path)\n\n\tvar stepSize float64\n\tif !g.SlowSeek {\n\t\tstepSize = float64(g.Info.NthFrame) / g.Info.FrameRate\n\t} else {\n\t\t// for files with a low framecount (<ChunkCount) g.Info.NthFrame can be zero\n\t\t// so recalculate from scratch\n\t\tstepSize = float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount)\n\t\tstepSize /= g.Info.FrameRate\n\t}\n\n\treturn g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize, g.Info.ChunkCount)\n}\n\nfunc (g *SpriteGenerator) imageExists() bool {\n\texists, _ := fsutil.FileExists(g.ImageOutputPath)\n\treturn exists\n}\n\nfunc (g *SpriteGenerator) vttExists() bool {\n\texists, _ := fsutil.FileExists(g.VTTOutputPath)\n\treturn exists\n}\n"
  },
  {
    "path": "internal/manager/import.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype ImportDuplicateEnum string\n\nconst (\n\tImportDuplicateEnumIgnore    ImportDuplicateEnum = \"IGNORE\"\n\tImportDuplicateEnumOverwrite ImportDuplicateEnum = \"OVERWRITE\"\n\tImportDuplicateEnumFail      ImportDuplicateEnum = \"FAIL\"\n)\n\nvar AllImportDuplicateEnum = []ImportDuplicateEnum{\n\tImportDuplicateEnumIgnore,\n\tImportDuplicateEnumOverwrite,\n\tImportDuplicateEnumFail,\n}\n\nfunc (e ImportDuplicateEnum) IsValid() bool {\n\tswitch e {\n\tcase ImportDuplicateEnumIgnore, ImportDuplicateEnumOverwrite, ImportDuplicateEnumFail:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e ImportDuplicateEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *ImportDuplicateEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = ImportDuplicateEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid ImportDuplicateEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e ImportDuplicateEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype importer interface {\n\tPreImport(ctx context.Context) error\n\tPostImport(ctx context.Context, id int) error\n\tName() string\n\tFindExistingID(ctx context.Context) (*int, error)\n\tCreate(ctx context.Context) (*int, error)\n\tUpdate(ctx context.Context, id int) error\n}\n\nfunc performImport(ctx context.Context, i importer, duplicateBehaviour ImportDuplicateEnum) error {\n\tif err := i.PreImport(ctx); err != nil {\n\t\treturn err\n\t}\n\n\t// try to find an existing object with the same name\n\tname := i.Name()\n\texisting, err := i.FindExistingID(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error finding existing objects: %v\", err)\n\t}\n\n\tvar id int\n\n\tif existing != nil {\n\t\tif duplicateBehaviour == ImportDuplicateEnumFail {\n\t\t\treturn fmt.Errorf(\"existing object with name '%s'\", name)\n\t\t} else if duplicateBehaviour == ImportDuplicateEnumIgnore {\n\t\t\tlogger.Infof(\"Skipping existing object %q\", name)\n\t\t\treturn nil\n\t\t}\n\n\t\t// must be overwriting\n\t\tid = *existing\n\t\tif err := i.Update(ctx, id); err != nil {\n\t\t\treturn fmt.Errorf(\"error updating existing object: %v\", err)\n\t\t}\n\t} else {\n\t\t// creating\n\t\tcreatedID, err := i.Create(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error creating object: %v\", err)\n\t\t}\n\n\t\tid = *createdID\n\t}\n\n\tif err := i.PostImport(ctx, id); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/init.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/remeh/sizedwaitgroup\"\n\t\"github.com/stashapp/stash/internal/desktop\"\n\t\"github.com/stashapp/stash/internal/dlna\"\n\t\"github.com/stashapp/stash/internal/log\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/group\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n\t\"github.com/stashapp/stash/pkg/session\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"github.com/stashapp/stash/ui\"\n)\n\n// Called at startup\nfunc Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {\n\tctx := context.TODO()\n\n\tdb := sqlite.NewDatabase()\n\trepo := db.Repository()\n\n\t// start with empty paths\n\tmgrPaths := &paths.Paths{}\n\n\tscraperRepository := scraper.NewRepository(repo)\n\tscraperCache := scraper.NewCache(cfg, scraperRepository)\n\n\tpluginCache := plugin.NewCache(cfg)\n\n\tsceneService := &scene.Service{\n\t\tFile:             db.File,\n\t\tRepository:       db.Scene,\n\t\tMarkerRepository: db.SceneMarker,\n\t\tPluginCache:      pluginCache,\n\t\tPaths:            mgrPaths,\n\t\tConfig:           cfg,\n\t}\n\n\timageService := &image.Service{\n\t\tFile:       db.File,\n\t\tRepository: db.Image,\n\t}\n\n\tgalleryService := &gallery.Service{\n\t\tRepository:   db.Gallery,\n\t\tImageFinder:  db.Image,\n\t\tImageService: imageService,\n\t\tFile:         db.File,\n\t\tFolder:       db.Folder,\n\t}\n\n\tgroupService := &group.Service{\n\t\tRepository: db.Group,\n\t}\n\n\tsceneServer := &SceneServer{\n\t\tTxnManager:       repo.TxnManager,\n\t\tSceneCoverGetter: repo.Scene,\n\t}\n\n\tdlnaRepository := dlna.NewRepository(repo)\n\tdlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer, repo.Scene, cfg.GetMinimumPlayPercent())\n\n\tmgr := &Manager{\n\t\tConfig: cfg,\n\t\tLogger: l,\n\n\t\tPaths: mgrPaths,\n\n\t\tImageThumbnailGenerateWaitGroup: sizedwaitgroup.New(1),\n\n\t\tJobManager:      initJobManager(cfg),\n\t\tReadLockManager: fsutil.NewReadLockManager(),\n\n\t\tDownloadStore: NewDownloadStore(),\n\n\t\tPluginCache:  pluginCache,\n\t\tScraperCache: scraperCache,\n\n\t\tDLNAService: dlnaService,\n\n\t\tDatabase:   db,\n\t\tRepository: repo,\n\n\t\tSceneService:   sceneService,\n\t\tImageService:   imageService,\n\t\tGalleryService: galleryService,\n\t\tGroupService:   groupService,\n\n\t\tscanSubs: &subscriptionManager{},\n\t}\n\n\tif !cfg.IsNewSystem() {\n\t\tlogger.Infof(\"using config file: %s\", cfg.GetConfigFile())\n\n\t\terr := cfg.Validate()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid configuration: %w\", err)\n\t\t}\n\n\t\tif err := mgr.postInit(ctx); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tmgr.checkSecurityTripwire()\n\t} else {\n\t\tcfgFile := cfg.GetConfigFile()\n\t\tif cfgFile != \"\" {\n\t\t\tcfgFile += \" \"\n\t\t}\n\n\t\t// create temporary session store - this will be re-initialised\n\t\t// after config is complete\n\t\tmgr.SessionStore = session.NewStore(cfg)\n\n\t\tlogger.Warnf(\"config file %snot found. Assuming new system...\", cfgFile)\n\t}\n\n\tinstance = mgr\n\treturn mgr, nil\n}\n\nfunc formatDuration(t time.Duration) string {\n\tswitch {\n\tcase t >= time.Minute: // 1m23s or 2h45m12s\n\t\tt = t.Round(time.Second)\n\tcase t >= time.Second: // 45.36s\n\t\tt = t.Round(10 * time.Millisecond)\n\tdefault: // 51ms\n\t\tt = t.Round(time.Millisecond)\n\t}\n\n\treturn t.String()\n}\n\nfunc initJobManager(cfg *config.Config) *job.Manager {\n\tret := job.NewManager()\n\n\t// desktop notifications\n\tctx := context.Background()\n\tc := ret.Subscribe(context.Background())\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase j := <-c.RemovedJob:\n\t\t\t\tif cfg.GetNotificationsEnabled() {\n\t\t\t\t\tcleanDesc := strings.TrimRight(j.Description, \".\")\n\n\t\t\t\t\tif j.StartTime == nil {\n\t\t\t\t\t\t// Task was never started\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\ttimeElapsed := j.EndTime.Sub(*j.StartTime)\n\t\t\t\t\tmsg := fmt.Sprintf(\"Task \\\"%s\\\" finished in %s.\", cleanDesc, formatDuration(timeElapsed))\n\t\t\t\t\tdesktop.SendNotification(\"Task Finished\", msg)\n\t\t\t\t}\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn ret\n}\n\n// postInit initialises the paths, caches and database after the initial\n// configuration has been set. Should only be called if the configuration\n// is valid.\nfunc (s *Manager) postInit(ctx context.Context) error {\n\ts.RefreshConfig()\n\n\ts.SessionStore = session.NewStore(s.Config)\n\ts.PluginCache.RegisterSessionStore(s.SessionStore)\n\n\ts.RefreshPluginCache()\n\ts.RefreshPluginSourceManager()\n\n\ts.RefreshScraperCache()\n\ts.RefreshScraperSourceManager()\n\n\ts.RefreshDLNA()\n\n\ts.SetBlobStoreOptions()\n\n\ts.writeStashIcon()\n\n\t// clear the downloads and tmp directories\n\t// #1021 - only clear these directories if the generated folder is non-empty\n\tif s.Config.GetGeneratedPath() != \"\" {\n\t\tconst deleteTimeout = 1 * time.Second\n\n\t\tutils.Timeout(func() {\n\t\t\tif err := fsutil.EmptyDir(s.Paths.Generated.Downloads); err != nil {\n\t\t\t\tlogger.Warnf(\"could not empty downloads directory: %v\", err)\n\t\t\t}\n\t\t\tif err := fsutil.EnsureDir(s.Paths.Generated.Tmp); err != nil {\n\t\t\t\tlogger.Warnf(\"could not create temporary directory: %v\", err)\n\t\t\t} else {\n\t\t\t\tif err := fsutil.EmptyDir(s.Paths.Generated.Tmp); err != nil {\n\t\t\t\t\tlogger.Warnf(\"could not empty temporary directory: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}, deleteTimeout, func(done chan struct{}) {\n\t\t\tlogger.Info(\"Please wait. Deleting temporary files...\") // print\n\t\t\t<-done                                                  // and wait for deletion\n\t\t\tlogger.Info(\"Temporary files deleted.\")\n\t\t})\n\t}\n\n\tif err := s.Database.Open(s.Config.GetDatabasePath()); err != nil {\n\t\tvar migrationNeededErr *sqlite.MigrationNeededError\n\t\tif errors.As(err, &migrationNeededErr) {\n\t\t\tlogger.Warn(err)\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Set the proxy if defined in config\n\tif s.Config.GetProxy() != \"\" {\n\t\tos.Setenv(\"HTTP_PROXY\", s.Config.GetProxy())\n\t\tos.Setenv(\"HTTPS_PROXY\", s.Config.GetProxy())\n\t\tos.Setenv(\"NO_PROXY\", s.Config.GetNoProxy())\n\t\tlogger.Info(\"Using HTTP proxy\")\n\t}\n\n\ts.RefreshFFMpeg(ctx)\n\ts.RefreshStreamManager()\n\n\treturn nil\n}\n\nfunc (s *Manager) checkSecurityTripwire() {\n\tif err := session.CheckExternalAccessTripwire(s.Config); err != nil {\n\t\tsession.LogExternalAccessError(*err)\n\t}\n}\n\nfunc (s *Manager) writeStashIcon() {\n\ticonPath := filepath.Join(s.Config.GetConfigPath(), \"icon.png\")\n\terr := os.WriteFile(iconPath, ui.FaviconProvider.GetFaviconPng(), 0644)\n\tif err != nil {\n\t\tlogger.Errorf(\"Couldn't write icon file: %v\", err)\n\t}\n}\n\nfunc (s *Manager) RefreshFFMpeg(ctx context.Context) {\n\t// use same directory as config path\n\t// executing binaries requires directory to be included\n\t// https://pkg.go.dev/os/exec#hdr-Executables_in_the_current_directory\n\tconfigDirectory := s.Config.GetConfigPathAbs()\n\tstashHomeDir := paths.GetStashHomeDirectory()\n\n\t// prefer the configured paths\n\tffmpegPath := s.Config.GetFFMpegPath()\n\tffprobePath := s.Config.GetFFProbePath()\n\n\t// ensure the paths are valid\n\tif ffmpegPath != \"\" {\n\t\t// path was set explicitly\n\t\tif err := ffmpeg.ValidateFFMpeg(ffmpegPath); err != nil {\n\t\t\tlogger.Errorf(\"invalid ffmpeg path: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tif err := ffmpeg.ValidateFFMpegCodecSupport(ffmpegPath); err != nil {\n\t\t\tlogger.Warn(err)\n\t\t}\n\t} else {\n\t\tffmpegPath = ffmpeg.ResolveFFMpeg(configDirectory, stashHomeDir)\n\t}\n\n\tif ffprobePath != \"\" {\n\t\tif err := ffmpeg.ValidateFFProbe(ffmpegPath); err != nil {\n\t\t\tlogger.Errorf(\"invalid ffprobe path: %v\", err)\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tffprobePath = ffmpeg.ResolveFFProbe(configDirectory, stashHomeDir)\n\t}\n\n\tif ffmpegPath == \"\" {\n\t\tlogger.Warn(\"Couldn't find FFmpeg\")\n\t}\n\tif ffprobePath == \"\" {\n\t\tlogger.Warn(\"Couldn't find FFProbe\")\n\t}\n\n\tif ffmpegPath != \"\" && ffprobePath != \"\" {\n\t\tlogger.Debugf(\"using ffmpeg: %s\", ffmpegPath)\n\t\tlogger.Debugf(\"using ffprobe: %s\", ffprobePath)\n\n\t\ts.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)\n\t\ts.FFProbe = ffmpeg.NewFFProbe(ffprobePath)\n\n\t\t// initialise hardware support with background context\n\t\ts.FFMpeg.InitHWSupport(context.Background())\n\t}\n}\n"
  },
  {
    "path": "internal/manager/json_utils.go",
    "content": "package manager\n\nimport (\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n)\n\ntype jsonUtils struct {\n\tjson paths.JSONPaths\n}\n\nfunc (jp *jsonUtils) savePerformer(fn string, performer *jsonschema.Performer) error {\n\treturn jsonschema.SavePerformerFile(filepath.Join(jp.json.Performers, fn), performer)\n}\n\nfunc (jp *jsonUtils) saveStudio(fn string, studio *jsonschema.Studio) error {\n\treturn jsonschema.SaveStudioFile(filepath.Join(jp.json.Studios, fn), studio)\n}\n\nfunc (jp *jsonUtils) saveTag(fn string, tag *jsonschema.Tag) error {\n\treturn jsonschema.SaveTagFile(filepath.Join(jp.json.Tags, fn), tag)\n}\n\nfunc (jp *jsonUtils) saveGroup(fn string, group *jsonschema.Group) error {\n\treturn jsonschema.SaveGroupFile(filepath.Join(jp.json.Groups, fn), group)\n}\n\nfunc (jp *jsonUtils) saveScene(fn string, scene *jsonschema.Scene) error {\n\treturn jsonschema.SaveSceneFile(filepath.Join(jp.json.Scenes, fn), scene)\n}\n\nfunc (jp *jsonUtils) saveImage(fn string, image *jsonschema.Image) error {\n\treturn jsonschema.SaveImageFile(filepath.Join(jp.json.Images, fn), image)\n}\n\nfunc (jp *jsonUtils) saveGallery(fn string, gallery *jsonschema.Gallery) error {\n\treturn jsonschema.SaveGalleryFile(filepath.Join(jp.json.Galleries, fn), gallery)\n}\n\nfunc (jp *jsonUtils) saveFile(fn string, file jsonschema.DirEntry) error {\n\treturn jsonschema.SaveFileFile(filepath.Join(jp.json.Files, fn), file)\n}\n\nfunc (jp *jsonUtils) saveSavedFilter(fn string, savedFilter *jsonschema.SavedFilter) error {\n\treturn jsonschema.SaveSavedFilterFile(filepath.Join(jp.json.SavedFilters, fn), savedFilter)\n}\n"
  },
  {
    "path": "internal/manager/log.go",
    "content": "package manager\n\nimport (\n\t\"errors\"\n\t\"os/exec\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nfunc logErrorOutput(err error) {\n\tvar exitErr *exec.ExitError\n\tif errors.As(err, &exitErr) {\n\t\tlogger.Errorf(\"command stderr: %v\", string(exitErr.Stderr))\n\t}\n}\n"
  },
  {
    "path": "internal/manager/manager.go",
    "content": "// Package manager provides the core manager of the application.\n// This consolidates all the services and managers into a single struct.\npackage manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/remeh/sizedwaitgroup\"\n\t\"github.com/stashapp/stash/internal/dlna\"\n\t\"github.com/stashapp/stash/internal/log\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/pkg\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n\t\"github.com/stashapp/stash/pkg/session\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\n\t// register custom migrations\n\t_ \"github.com/stashapp/stash/pkg/sqlite/migrations\"\n)\n\ntype Manager struct {\n\tConfig *config.Config\n\tLogger *log.Logger\n\n\t// ImageThumbnailGenerateWaitGroup is the global wait group image thumbnail generation\n\t// It uses the parallel tasks setting from the configuration.\n\tImageThumbnailGenerateWaitGroup sizedwaitgroup.SizedWaitGroup\n\n\tPaths *paths.Paths\n\n\tFFMpeg        *ffmpeg.FFMpeg\n\tFFProbe       *ffmpeg.FFProbe\n\tStreamManager *ffmpeg.StreamManager\n\n\tJobManager      *job.Manager\n\tReadLockManager *fsutil.ReadLockManager\n\n\tDownloadStore *DownloadStore\n\tSessionStore  *session.Store\n\n\tPluginCache  *plugin.Cache\n\tScraperCache *scraper.Cache\n\n\tPluginPackageManager  *pkg.Manager\n\tScraperPackageManager *pkg.Manager\n\n\tDLNAService *dlna.Service\n\n\tDatabase   *sqlite.Database\n\tRepository models.Repository\n\n\tSceneService   SceneService\n\tImageService   ImageService\n\tGalleryService GalleryService\n\tGroupService   GroupService\n\n\tscanSubs *subscriptionManager\n}\n\nvar instance *Manager\n\nfunc GetInstance() *Manager {\n\tif instance == nil {\n\t\tpanic(\"manager not initialized\")\n\t}\n\treturn instance\n}\n\nfunc (s *Manager) SetBlobStoreOptions() {\n\tstorageType := s.Config.GetBlobsStorage()\n\tblobsPath := s.Config.GetBlobsPath()\n\textraBlobsPaths := s.Config.GetExtraBlobsPaths()\n\n\ts.Database.SetBlobStoreOptions(sqlite.BlobStoreOptions{\n\t\tUseFilesystem:      storageType == config.BlobStorageTypeFilesystem,\n\t\tUseDatabase:        storageType == config.BlobStorageTypeDatabase,\n\t\tPath:               blobsPath,\n\t\tSupplementaryPaths: extraBlobsPaths,\n\t})\n}\n\nfunc (s *Manager) RefreshConfig() {\n\tcfg := s.Config\n\t*s.Paths = paths.NewPaths(cfg.GetGeneratedPath(), cfg.GetBlobsPath())\n\tif cfg.Validate() == nil {\n\t\tif err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil {\n\t\t\tlogger.Warnf(\"could not create screenshots directory: %v\", err)\n\t\t}\n\t\tif err := fsutil.EnsureDir(s.Paths.Generated.Vtt); err != nil {\n\t\t\tlogger.Warnf(\"could not create VTT directory: %v\", err)\n\t\t}\n\t\tif err := fsutil.EnsureDir(s.Paths.Generated.Markers); err != nil {\n\t\t\tlogger.Warnf(\"could not create markers directory: %v\", err)\n\t\t}\n\t\tif err := fsutil.EnsureDir(s.Paths.Generated.Transcodes); err != nil {\n\t\t\tlogger.Warnf(\"could not create transcodes directory: %v\", err)\n\t\t}\n\t\tif err := fsutil.EnsureDir(s.Paths.Generated.Downloads); err != nil {\n\t\t\tlogger.Warnf(\"could not create downloads directory: %v\", err)\n\t\t}\n\t\tif err := fsutil.EnsureDir(s.Paths.Generated.InteractiveHeatmap); err != nil {\n\t\t\tlogger.Warnf(\"could not create interactive heatmaps directory: %v\", err)\n\t\t}\n\n\t\ts.ImageThumbnailGenerateWaitGroup.Size = cfg.GetParallelTasksWithAutoDetection()\n\t}\n}\n\n// RefreshPluginCache refreshes the plugin cache.\n// Call this when the plugin configuration changes.\nfunc (s *Manager) RefreshPluginCache() {\n\ts.PluginCache.ReloadPlugins()\n}\n\n// RefreshScraperCache refreshes the scraper cache.\n// Call this when the scraper configuration changes.\nfunc (s *Manager) RefreshScraperCache() {\n\ts.ScraperCache.ReloadScrapers()\n}\n\n// RefreshStreamManager refreshes the stream manager.\n// Call this when the cache directory changes.\nfunc (s *Manager) RefreshStreamManager() {\n\t// shutdown existing manager if needed\n\tif s.StreamManager != nil {\n\t\ts.StreamManager.Shutdown()\n\t\ts.StreamManager = nil\n\t}\n\n\tcfg := s.Config\n\tcacheDir := cfg.GetCachePath()\n\ts.StreamManager = ffmpeg.NewStreamManager(cacheDir, s.FFMpeg, s.FFProbe, cfg, s.ReadLockManager)\n}\n\n// RefreshDLNA starts/stops the DLNA service as needed.\nfunc (s *Manager) RefreshDLNA() {\n\tdlnaService := s.DLNAService\n\tenabled := s.Config.GetDLNADefaultEnabled()\n\tif !enabled && dlnaService.IsRunning() {\n\t\tdlnaService.Stop(nil)\n\t} else if enabled && !dlnaService.IsRunning() {\n\t\tif err := dlnaService.Start(nil); err != nil {\n\t\t\tlogger.Warnf(\"error starting DLNA service: %v\", err)\n\t\t}\n\t}\n}\n\nfunc createPackageManager(localPath string, srcPathGetter pkg.SourcePathGetter) *pkg.Manager {\n\tconst timeout = 10 * time.Second\n\thttpClient := &http.Client{\n\t\tTransport: &http.Transport{\n\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t},\n\t\tTimeout: timeout,\n\t}\n\n\treturn &pkg.Manager{\n\t\tLocal: &pkg.Store{\n\t\t\tBaseDir:      localPath,\n\t\t\tManifestFile: pkg.ManifestFile,\n\t\t},\n\t\tPackagePathGetter: srcPathGetter,\n\t\tClient:            httpClient,\n\t}\n}\n\nfunc (s *Manager) RefreshScraperSourceManager() {\n\ts.ScraperPackageManager = createPackageManager(s.Config.GetScrapersPath(), s.Config.GetScraperPackagePathGetter())\n}\n\nfunc (s *Manager) RefreshPluginSourceManager() {\n\ts.PluginPackageManager = createPackageManager(s.Config.GetPluginsPath(), s.Config.GetPluginPackagePathGetter())\n}\n\nfunc setSetupDefaults(input *SetupInput) {\n\tif input.ConfigLocation == \"\" {\n\t\tinput.ConfigLocation = filepath.Join(fsutil.GetHomeDirectory(), \".stash\", \"config.yml\")\n\t}\n\n\tconfigDir := filepath.Dir(input.ConfigLocation)\n\tif input.GeneratedLocation == \"\" {\n\t\tinput.GeneratedLocation = filepath.Join(configDir, \"generated\")\n\t}\n\tif input.CacheLocation == \"\" {\n\t\tinput.CacheLocation = filepath.Join(configDir, \"cache\")\n\t}\n\n\tif input.DatabaseFile == \"\" {\n\t\tinput.DatabaseFile = filepath.Join(configDir, \"stash-go.sqlite\")\n\t}\n\n\tif input.BlobsLocation == \"\" {\n\t\tinput.BlobsLocation = filepath.Join(configDir, \"blobs\")\n\t}\n}\n\nfunc (s *Manager) Setup(ctx context.Context, input SetupInput) error {\n\tsetSetupDefaults(&input)\n\tcfg := s.Config\n\n\t// create the config directory if it does not exist\n\t// don't do anything if config is already set in the environment\n\tif !config.FileEnvSet() {\n\t\t// #3304 - if config path is relative, it breaks the ffmpeg/ffprobe\n\t\t// paths since they must not be relative. The config file property is\n\t\t// resolved to an absolute path when stash is run normally, so convert\n\t\t// relative paths to absolute paths during setup.\n\t\t// #6287 - this should no longer be necessary since the ffmpeg code\n\t\t// converts to absolute paths. Converting the config location to\n\t\t// absolute means that scraper and plugin paths default to absolute\n\t\t// which we don't want.\n\t\tconfigFile := input.ConfigLocation\n\t\tconfigDir := filepath.Dir(configFile)\n\n\t\tif exists, _ := fsutil.DirExists(configDir); !exists {\n\t\t\tif err := os.MkdirAll(configDir, 0755); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error creating config directory: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\tif err := fsutil.Touch(configFile); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating config file: %v\", err)\n\t\t}\n\n\t\ts.Config.SetConfigFile(configFile)\n\t}\n\n\tif err := cfg.SetInitialConfig(); err != nil {\n\t\treturn fmt.Errorf(\"error setting initial configuration: %v\", err)\n\t}\n\n\t// create the generated directory if it does not exist\n\tif !cfg.HasOverride(config.Generated) {\n\t\tif exists, _ := fsutil.DirExists(input.GeneratedLocation); !exists {\n\t\t\tif err := os.MkdirAll(input.GeneratedLocation, 0755); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error creating generated directory: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\ts.Config.SetString(config.Generated, input.GeneratedLocation)\n\t}\n\n\t// create the cache directory if it does not exist\n\tif !cfg.HasOverride(config.Cache) {\n\t\tif exists, _ := fsutil.DirExists(input.CacheLocation); !exists {\n\t\t\tif err := os.MkdirAll(input.CacheLocation, 0755); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error creating cache directory: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\tcfg.SetString(config.Cache, input.CacheLocation)\n\t}\n\n\tif input.SFWContentMode {\n\t\tcfg.SetBool(config.SFWContentMode, true)\n\t}\n\n\tif input.StoreBlobsInDatabase {\n\t\tcfg.SetInterface(config.BlobsStorage, config.BlobStorageTypeDatabase)\n\t} else {\n\t\tif !cfg.HasOverride(config.BlobsPath) {\n\t\t\tif exists, _ := fsutil.DirExists(input.BlobsLocation); !exists {\n\t\t\t\tif err := os.MkdirAll(input.BlobsLocation, 0755); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error creating blobs directory: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tcfg.SetString(config.BlobsPath, input.BlobsLocation)\n\t\t}\n\n\t\tcfg.SetInterface(config.BlobsStorage, config.BlobStorageTypeFilesystem)\n\t}\n\n\t// set the configuration\n\tif !cfg.HasOverride(config.Database) {\n\t\tcfg.SetString(config.Database, input.DatabaseFile)\n\t}\n\n\tcfg.SetInterface(config.Stash, input.Stashes)\n\n\tif err := cfg.Write(); err != nil {\n\t\treturn fmt.Errorf(\"error writing configuration file: %v\", err)\n\t}\n\n\t// finish initialization\n\tif err := s.postInit(ctx); err != nil {\n\t\treturn fmt.Errorf(\"error completing initialization: %v\", err)\n\t}\n\n\tcfg.FinalizeSetup()\n\n\treturn nil\n}\n\nfunc (s *Manager) validateFFmpeg() error {\n\tif s.FFMpeg == nil || s.FFProbe == nil {\n\t\treturn errors.New(\"missing ffmpeg and/or ffprobe\")\n\t}\n\treturn nil\n}\n\nfunc (s *Manager) AnonymiseDatabase(download bool) (string, string, error) {\n\tvar outPath string\n\tvar outName string\n\tif download {\n\t\toutDir := s.Paths.Generated.Downloads\n\t\tif err := fsutil.EnsureDir(outDir); err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"could not create output directory %v: %w\", outDir, err)\n\t\t}\n\t\tf, err := os.CreateTemp(outDir, \"anonymous*.sqlite\")\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", err\n\t\t}\n\n\t\toutPath = f.Name()\n\t\toutName = s.Database.AnonymousDatabasePath(\"\")\n\t\tf.Close()\n\t} else {\n\t\toutDir := s.Config.GetBackupDirectoryPathOrDefault()\n\t\tif outDir != \"\" {\n\t\t\tif err := fsutil.EnsureDir(outDir); err != nil {\n\t\t\t\treturn \"\", \"\", fmt.Errorf(\"could not create output directory %v: %w\", outDir, err)\n\t\t\t}\n\t\t}\n\t\toutPath = s.Database.AnonymousDatabasePath(outDir)\n\t\toutName = filepath.Base(outPath)\n\t}\n\n\terr := s.Database.Anonymise(outPath)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\treturn outPath, outName, nil\n}\n\nfunc (s *Manager) GetSystemStatus() *SystemStatus {\n\tworkingDir := fsutil.GetWorkingDirectory()\n\thomeDir := fsutil.GetHomeDirectory()\n\n\tdatabase := s.Database\n\tdbSchema := int(database.Version())\n\tdbPath := database.DatabasePath()\n\tappSchema := int(database.AppSchemaVersion())\n\n\tstatus := SystemStatusEnumOk\n\tif s.Config.IsNewSystem() {\n\t\tstatus = SystemStatusEnumSetup\n\t} else if dbSchema < appSchema {\n\t\tstatus = SystemStatusEnumNeedsMigration\n\t}\n\n\tconfigFile := s.Config.GetConfigFile()\n\n\tffmpegPath := \"\"\n\tif s.FFMpeg != nil {\n\t\tffmpegPath = s.FFMpeg.Path()\n\t}\n\n\tffprobePath := \"\"\n\tif s.FFProbe != nil {\n\t\tffprobePath = s.FFProbe.Path()\n\t}\n\n\treturn &SystemStatus{\n\t\tOs:             runtime.GOOS,\n\t\tWorkingDir:     workingDir,\n\t\tHomeDir:        homeDir,\n\t\tDatabaseSchema: &dbSchema,\n\t\tDatabasePath:   &dbPath,\n\t\tAppSchema:      appSchema,\n\t\tStatus:         status,\n\t\tConfigPath:     &configFile,\n\t\tFfmpegPath:     &ffmpegPath,\n\t\tFfprobePath:    &ffprobePath,\n\t}\n}\n\n// Shutdown gracefully stops the manager\nfunc (s *Manager) Shutdown() {\n\t// TODO: Each part of the manager needs to gracefully stop at some point\n\n\tif s.StreamManager != nil {\n\t\ts.StreamManager.Shutdown()\n\t\ts.StreamManager = nil\n\t}\n\n\terr := s.Database.Close()\n\tif err != nil {\n\t\tlogger.Errorf(\"Error closing database: %s\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/manager/manager_tasks.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\tfile_image \"github.com/stashapp/stash/pkg/file/image\"\n\t\"github.com/stashapp/stash/pkg/file/video\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc useAsVideo(pathname string) bool {\n\tstash := config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname)\n\n\tif instance.Config.IsCreateImageClipsFromVideos() && stash != nil && stash.ExcludeVideo {\n\t\treturn false\n\t}\n\treturn isVideo(pathname)\n}\n\nfunc useAsImage(pathname string) bool {\n\tstash := config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname)\n\tif instance.Config.IsCreateImageClipsFromVideos() && stash != nil && stash.ExcludeVideo {\n\t\treturn isImage(pathname) || isVideo(pathname)\n\t}\n\treturn isImage(pathname)\n}\n\nfunc isZip(pathname string) bool {\n\tgExt := config.GetInstance().GetGalleryExtensions()\n\treturn fsutil.MatchExtension(pathname, gExt)\n}\n\nfunc isVideo(pathname string) bool {\n\tvidExt := config.GetInstance().GetVideoExtensions()\n\treturn fsutil.MatchExtension(pathname, vidExt)\n}\n\nfunc isImage(pathname string) bool {\n\timgExt := config.GetInstance().GetImageExtensions()\n\treturn fsutil.MatchExtension(pathname, imgExt)\n}\n\nfunc getScanPaths(inputPaths []string) []*config.StashConfig {\n\tstashPaths := config.GetInstance().GetStashPaths()\n\n\tif len(inputPaths) == 0 {\n\t\treturn stashPaths\n\t}\n\n\tvar ret config.StashConfigs\n\tfor _, p := range inputPaths {\n\t\ts := stashPaths.GetStashFromDirPath(p)\n\t\tif s == nil {\n\t\t\tlogger.Warnf(\"%s is not in the configured stash paths\", p)\n\t\t\tcontinue\n\t\t}\n\n\t\t// make a copy, changing the path\n\t\tss := *s\n\t\tss.Path = p\n\t\tret = append(ret, &ss)\n\t}\n\n\treturn ret\n}\n\n// Filters the input array for paths that are within the paths managed by stash\nfunc filterStashPaths(inputPaths []string) []string {\n\tif len(inputPaths) == 0 {\n\t\treturn inputPaths\n\t}\n\n\tstashPaths := config.GetInstance().GetStashPaths()\n\n\tvar ret []string\n\tfor _, p := range inputPaths {\n\t\ts := stashPaths.GetStashFromDirPath(p)\n\t\tif s == nil {\n\t\t\tlogger.Warnf(\"%s is not in the configured stash paths\", p)\n\t\t\tcontinue\n\t\t}\n\n\t\tret = append(ret, p)\n\t}\n\n\treturn ret\n}\n\n// ScanSubscribe subscribes to a notification that is triggered when a\n// scan or clean is complete.\nfunc (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool {\n\treturn s.scanSubs.subscribe(ctx)\n}\n\ntype ScanMetadataInput struct {\n\tPaths []string `json:\"paths\"`\n\n\tconfig.ScanMetadataOptions `mapstructure:\",squash\"`\n\n\t// Filter options for the scan\n\tFilter *ScanMetaDataFilterInput `json:\"filter\"`\n}\n\n// Filter options for meta data scannning\ntype ScanMetaDataFilterInput struct {\n\t// If set, files with a modification time before this time point are ignored by the scan\n\tMinModTime *time.Time `json:\"minModTime\"`\n}\n\nfunc (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error) {\n\tif err := s.validateFFmpeg(); err != nil {\n\t\treturn 0, err\n\t}\n\n\tcfg := config.GetInstance()\n\n\tscanner := &file.Scanner{\n\t\tRepository: file.NewRepository(s.Repository),\n\t\tFileDecorators: []file.Decorator{\n\t\t\t&file.FilteredDecorator{\n\t\t\t\tDecorator: &video.Decorator{\n\t\t\t\t\tFFProbe: s.FFProbe,\n\t\t\t\t},\n\t\t\t\tFilter: file.FilterFunc(videoFileFilter),\n\t\t\t},\n\t\t\t&file.FilteredDecorator{\n\t\t\t\tDecorator: &file_image.Decorator{\n\t\t\t\t\tFFProbe: s.FFProbe,\n\t\t\t\t},\n\t\t\t\tFilter: file.FilterFunc(imageFileFilter),\n\t\t\t},\n\t\t},\n\t\tFingerprintCalculator: &fingerprintCalculator{s.Config},\n\t\tFS:                    &file.OsFS{},\n\t\tZipFileExtensions:     cfg.GetGalleryExtensions(),\n\t\t// ScanFilters is set in ScanJob.Execute\n\t\t// HandlerRequiredFilters is set in ScanJob.Execute\n\t\tRootPaths: cfg.GetStashPaths().Paths(),\n\t\tRescan:    input.Rescan,\n\t}\n\n\tscanJob := ScanJob{\n\t\tscanner:       scanner,\n\t\tinput:         input,\n\t\tsubscriptions: s.scanSubs,\n\t}\n\n\treturn s.JobManager.Add(ctx, \"Scanning...\", &scanJob), nil\n}\n\nfunc (s *Manager) Import(ctx context.Context) (int, error) {\n\tconfig := config.GetInstance()\n\tmetadataPath := config.GetMetadataPath()\n\tif metadataPath == \"\" {\n\t\treturn 0, errors.New(\"metadata path must be set in config\")\n\t}\n\n\tj := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {\n\t\ttask := ImportTask{\n\t\t\trepository:          s.Repository,\n\t\t\tresetter:            s.Database,\n\t\t\tBaseDir:             metadataPath,\n\t\t\tReset:               true,\n\t\t\tDuplicateBehaviour:  ImportDuplicateEnumFail,\n\t\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\t\tfileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),\n\t\t}\n\t\ttask.Start(ctx)\n\n\t\t// TODO - return error from task\n\t\treturn nil\n\t})\n\n\treturn s.JobManager.Add(ctx, \"Importing...\", j), nil\n}\n\nfunc (s *Manager) Export(ctx context.Context) (int, error) {\n\tconfig := config.GetInstance()\n\tmetadataPath := config.GetMetadataPath()\n\tif metadataPath == \"\" {\n\t\treturn 0, errors.New(\"metadata path must be set in config\")\n\t}\n\n\tj := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\t\ttask := ExportTask{\n\t\t\trepository:          s.Repository,\n\t\t\tfull:                true,\n\t\t\tfileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),\n\t\t}\n\t\ttask.Start(ctx, &wg)\n\t\t// TODO - return error from task\n\t\treturn nil\n\t})\n\n\treturn s.JobManager.Add(ctx, \"Exporting...\", j), nil\n}\n\nfunc (s *Manager) RunSingleTask(ctx context.Context, t Task) int {\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\n\tj := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {\n\t\tt.Start(ctx)\n\t\tdefer wg.Done()\n\t\t// TODO - return error from task\n\t\treturn nil\n\t})\n\n\treturn s.JobManager.Add(ctx, t.GetDescription(), j)\n}\n\nfunc (s *Manager) Generate(ctx context.Context, input GenerateMetadataInput) (int, error) {\n\tif err := s.validateFFmpeg(); err != nil {\n\t\treturn 0, err\n\t}\n\tif err := instance.Paths.Generated.EnsureTmpDir(); err != nil {\n\t\tlogger.Warnf(\"could not generate temporary directory: %v\", err)\n\t}\n\n\tj := &GenerateJob{\n\t\trepository: s.Repository,\n\t\tinput:      input,\n\t}\n\n\treturn s.JobManager.Add(ctx, \"Generating...\", j), nil\n}\n\nfunc (s *Manager) GenerateDefaultScreenshot(ctx context.Context, sceneId string) int {\n\treturn s.generateScreenshot(ctx, sceneId, nil)\n}\n\nfunc (s *Manager) GenerateScreenshot(ctx context.Context, sceneId string, at float64) int {\n\treturn s.generateScreenshot(ctx, sceneId, &at)\n}\n\n// generate default screenshot if at is nil\nfunc (s *Manager) generateScreenshot(ctx context.Context, sceneId string, at *float64) int {\n\tif err := instance.Paths.Generated.EnsureTmpDir(); err != nil {\n\t\tlogger.Warnf(\"failure generating screenshot: %v\", err)\n\t}\n\n\tj := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {\n\t\tsceneIdInt, err := strconv.Atoi(sceneId)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error parsing scene id %s: %w\", sceneId, err)\n\t\t}\n\n\t\tvar scene *models.Scene\n\t\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tscene, err = s.Repository.Scene.Find(ctx, sceneIdInt)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif scene == nil {\n\t\t\t\treturn fmt.Errorf(\"scene with id %s not found\", sceneId)\n\t\t\t}\n\n\t\t\treturn scene.LoadPrimaryFile(ctx, s.Repository.File)\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"error finding scene for screenshot generation: %w\", err)\n\t\t}\n\n\t\ttask := GenerateCoverTask{\n\t\t\trepository:   s.Repository,\n\t\t\tScene:        *scene,\n\t\t\tScreenshotAt: at,\n\t\t\tOverwrite:    true,\n\t\t}\n\n\t\ttask.Start(ctx)\n\n\t\tlogger.Infof(\"Generate screenshot finished\")\n\n\t\t// TODO - return error from task\n\t\treturn nil\n\t})\n\n\treturn s.JobManager.Add(ctx, fmt.Sprintf(\"Generating screenshot for scene id %s\", sceneId), j)\n}\n\ntype AutoTagMetadataInput struct {\n\t// Paths to tag, null for all files\n\tPaths []string `json:\"paths\"`\n\t// IDs of performers to tag files with, or \"*\" for all\n\tPerformers []string `json:\"performers\"`\n\t// IDs of studios to tag files with, or \"*\" for all\n\tStudios []string `json:\"studios\"`\n\t// IDs of tags to tag files with, or \"*\" for all\n\tTags []string `json:\"tags\"`\n}\n\nfunc (s *Manager) AutoTag(ctx context.Context, input AutoTagMetadataInput) int {\n\tj := autoTagJob{\n\t\trepository: s.Repository,\n\t\tinput:      input,\n\t}\n\n\treturn s.JobManager.Add(ctx, \"Auto-tagging...\", &j)\n}\n\ntype CleanMetadataInput struct {\n\tPaths []string `json:\"paths\"`\n\t// Do a dry run. Don't delete any files\n\tDryRun bool `json:\"dryRun\"`\n\n\tIgnoreZipFileContents bool `json:\"ignoreZipFileContents\"`\n}\n\nfunc (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {\n\tcleaner := &file.Cleaner{\n\t\tFS:         &file.OsFS{},\n\t\tRepository: file.NewRepository(s.Repository),\n\t\tHandlers: []file.CleanHandler{\n\t\t\t&cleanHandler{},\n\t\t},\n\t\tTrashPath: s.Config.GetDeleteTrashPath(),\n\t}\n\n\tj := cleanJob{\n\t\tcleaner:      cleaner,\n\t\trepository:   s.Repository,\n\t\tsceneService: s.SceneService,\n\t\timageService: s.ImageService,\n\t\tinput:        input,\n\t\tscanSubs:     s.scanSubs,\n\t}\n\n\treturn s.JobManager.Add(ctx, \"Cleaning...\", &j)\n}\n\nfunc (s *Manager) OptimiseDatabase(ctx context.Context) int {\n\tj := OptimiseDatabaseJob{\n\t\tOptimiser: s.Database,\n\t}\n\n\treturn s.JobManager.Add(ctx, \"Optimising database...\", &j)\n}\n\nfunc (s *Manager) MigrateHash(ctx context.Context) int {\n\tj := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {\n\t\tfileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()\n\t\tlogger.Infof(\"Migrating generated files for %s naming hash\", fileNamingAlgo.String())\n\n\t\tvar scenes []*models.Scene\n\t\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tscenes, err = s.Repository.Scene.All(ctx)\n\t\t\treturn err\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to fetch list of scenes for migration: %w\", err)\n\t\t}\n\n\t\tvar wg sync.WaitGroup\n\t\ttotal := len(scenes)\n\t\tprogress.SetTotal(total)\n\n\t\tfor _, scene := range scenes {\n\t\t\tprogress.Increment()\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\tlogger.Info(\"Stopping due to user request\")\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif scene == nil {\n\t\t\t\tlogger.Errorf(\"nil scene, skipping migrate\")\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\twg.Add(1)\n\n\t\t\ttask := MigrateHashTask{Scene: scene, fileNamingAlgorithm: fileNamingAlgo}\n\t\t\tgo func() {\n\t\t\t\ttask.Start()\n\t\t\t\twg.Done()\n\t\t\t}()\n\n\t\t\twg.Wait()\n\t\t}\n\n\t\tlogger.Info(\"Finished migrating\")\n\t\treturn nil\n\t})\n\n\treturn s.JobManager.Add(ctx, \"Migrating scene hashes...\", j)\n}\n\n// batchTagType indicates which batch tagging mode to use\ntype batchTagType int\n\nconst (\n\tbatchTagByIds batchTagType = iota\n\tbatchTagByNamesOrStashIds\n\tbatchTagAll\n)\n\n// getBatchTagType determines the batch tag mode based on the input\nfunc (input StashBoxBatchTagInput) getBatchTagType(hasPerformerFields bool) batchTagType {\n\tswitch {\n\tcase len(input.Ids) > 0:\n\t\treturn batchTagByIds\n\tcase hasPerformerFields && len(input.PerformerIds) > 0:\n\t\treturn batchTagByIds\n\tcase len(input.StashIDs) > 0 || len(input.Names) > 0:\n\t\treturn batchTagByNamesOrStashIds\n\tcase hasPerformerFields && len(input.PerformerNames) > 0:\n\t\treturn batchTagByNamesOrStashIds\n\tdefault:\n\t\treturn batchTagAll\n\t}\n}\n\n// Accepts either ids, or a combination of names and stash_ids.\n// If none are set, then all existing items will be tagged.\ntype StashBoxBatchTagInput struct {\n\t// Stash endpoint to use for the tagging\n\t//\n\t// Deprecated: use StashBoxEndpoint\n\tEndpoint         *int    `json:\"endpoint\"`\n\tStashBoxEndpoint *string `json:\"stash_box_endpoint\"`\n\t// Fields to exclude when executing the tagging\n\tExcludeFields []string `json:\"exclude_fields\"`\n\t// Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false\n\tRefresh bool `json:\"refresh\"`\n\t// If batch adding studios or tags, should their parent entities also be created?\n\tCreateParent bool `json:\"createParent\"`\n\t// IDs in stash of the items to update.\n\t// If set, names and stash_ids fields will be ignored.\n\tIds []string `json:\"ids\"`\n\t// Names of the items in the stash-box instance to search for and create\n\tNames []string `json:\"names\"`\n\t// Stash IDs of the items in the stash-box instance to search for and create\n\tStashIDs []string `json:\"stash_ids\"`\n\t// IDs in stash of the performers to update\n\t//\n\t// Deprecated: use Ids\n\tPerformerIds []string `json:\"performer_ids\"`\n\t// Names of the performers in the stash-box instance to search for and create\n\t//\n\t// Deprecated: use Names\n\tPerformerNames []string `json:\"performer_names\"`\n}\n\nfunc (s *Manager) batchTagPerformersByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {\n\tvar tasks []Task\n\n\terr := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tperformerQuery := s.Repository.Performer\n\n\t\tids := input.Ids\n\t\tif len(ids) == 0 {\n\t\t\tids = input.PerformerIds //nolint:staticcheck\n\t\t}\n\n\t\tfor _, performerID := range ids {\n\t\t\tif id, err := strconv.Atoi(performerID); err == nil {\n\t\t\t\tperformer, err := performerQuery.Find(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := performer.LoadStashIDs(ctx, performerQuery); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"loading performer stash ids: %w\", err)\n\t\t\t\t}\n\n\t\t\t\thasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil\n\t\t\t\tif (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {\n\t\t\t\t\ttasks = append(tasks, &stashBoxBatchPerformerTagTask{\n\t\t\t\t\t\tperformer:      performer,\n\t\t\t\t\t\tbox:            box,\n\t\t\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn tasks, err\n}\n\nfunc (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {\n\tvar tasks []Task\n\n\tfor i := range input.StashIDs {\n\t\tstashID := input.StashIDs[i]\n\t\tif len(stashID) > 0 {\n\t\t\ttasks = append(tasks, &stashBoxBatchPerformerTagTask{\n\t\t\t\tstashID:        &stashID,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t}\n\n\tnames := input.Names\n\tif len(names) == 0 {\n\t\tnames = input.PerformerNames //nolint:staticcheck\n\t}\n\n\tfor i := range names {\n\t\tname := names[i]\n\t\tif len(name) > 0 {\n\t\t\ttasks = append(tasks, &stashBoxBatchPerformerTagTask{\n\t\t\t\tname:           &name,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn tasks\n}\n\nfunc (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {\n\tvar tasks []Task\n\n\terr := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tperformerQuery := s.Repository.Performer\n\t\tvar performers []*models.Performer\n\t\tvar err error\n\n\t\tperformers, err = performerQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error querying performers: %v\", err)\n\t\t}\n\n\t\tfor _, performer := range performers {\n\t\t\tif err := performer.LoadStashIDs(ctx, performerQuery); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error loading stash ids for performer %s: %v\", performer.Name, err)\n\t\t\t}\n\n\t\t\ttasks = append(tasks, &stashBoxBatchPerformerTagTask{\n\t\t\t\tperformer:      performer,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn tasks, err\n}\n\nfunc (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {\n\tj := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {\n\t\tlogger.Infof(\"Initiating stash-box batch performer tag\")\n\n\t\tvar tasks []Task\n\t\tvar err error\n\n\t\tswitch input.getBatchTagType(true) {\n\t\tcase batchTagByIds:\n\t\t\ttasks, err = s.batchTagPerformersByIds(ctx, input, box)\n\t\tcase batchTagByNamesOrStashIds:\n\t\t\ttasks = s.batchTagPerformersByNamesOrStashIds(input, box)\n\t\tcase batchTagAll:\n\t\t\ttasks, err = s.batchTagAllPerformers(ctx, input, box)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(tasks) == 0 {\n\t\t\treturn nil\n\t\t}\n\n\t\tprogress.SetTotal(len(tasks))\n\n\t\tlogger.Infof(\"Starting stash-box batch operation for %d performers\", len(tasks))\n\n\t\tfor _, task := range tasks {\n\t\t\tprogress.ExecuteTask(task.GetDescription(), func() {\n\t\t\t\ttask.Start(ctx)\n\t\t\t})\n\n\t\t\tprogress.Increment()\n\t\t}\n\n\t\treturn nil\n\t})\n\n\treturn s.JobManager.Add(ctx, \"Batch stash-box performer tag...\", j)\n}\n\nfunc (s *Manager) batchTagStudiosByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {\n\tvar tasks []Task\n\n\terr := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tstudioQuery := s.Repository.Studio\n\n\t\tfor _, studioID := range input.Ids {\n\t\t\tif id, err := strconv.Atoi(studioID); err == nil {\n\t\t\t\tstudio, err := studioQuery.Find(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := studio.LoadStashIDs(ctx, studioQuery); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"loading studio stash ids: %w\", err)\n\t\t\t\t}\n\n\t\t\t\thasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil\n\t\t\t\tif (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {\n\t\t\t\t\ttasks = append(tasks, &stashBoxBatchStudioTagTask{\n\t\t\t\t\t\tstudio:         studio,\n\t\t\t\t\t\tcreateParent:   input.CreateParent,\n\t\t\t\t\t\tbox:            box,\n\t\t\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn tasks, err\n}\n\nfunc (s *Manager) batchTagStudiosByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {\n\tvar tasks []Task\n\n\tfor i := range input.StashIDs {\n\t\tstashID := input.StashIDs[i]\n\t\tif len(stashID) > 0 {\n\t\t\ttasks = append(tasks, &stashBoxBatchStudioTagTask{\n\t\t\t\tstashID:        &stashID,\n\t\t\t\tcreateParent:   input.CreateParent,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t}\n\n\tfor i := range input.Names {\n\t\tname := input.Names[i]\n\t\tif len(name) > 0 {\n\t\t\ttasks = append(tasks, &stashBoxBatchStudioTagTask{\n\t\t\t\tname:           &name,\n\t\t\t\tcreateParent:   input.CreateParent,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn tasks\n}\n\nfunc (s *Manager) batchTagAllStudios(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {\n\tvar tasks []Task\n\n\terr := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tstudioQuery := s.Repository.Studio\n\t\tvar studios []*models.Studio\n\t\tvar err error\n\n\t\tstudios, err = studioQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error querying studios: %v\", err)\n\t\t}\n\n\t\tfor _, studio := range studios {\n\t\t\ttasks = append(tasks, &stashBoxBatchStudioTagTask{\n\t\t\t\tstudio:         studio,\n\t\t\t\tcreateParent:   input.CreateParent,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn tasks, err\n}\n\nfunc (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {\n\tj := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {\n\t\tlogger.Infof(\"Initiating stash-box batch studio tag\")\n\n\t\tvar tasks []Task\n\t\tvar err error\n\n\t\tswitch input.getBatchTagType(false) {\n\t\tcase batchTagByIds:\n\t\t\ttasks, err = s.batchTagStudiosByIds(ctx, input, box)\n\t\tcase batchTagByNamesOrStashIds:\n\t\t\ttasks = s.batchTagStudiosByNamesOrStashIds(input, box)\n\t\tcase batchTagAll:\n\t\t\ttasks, err = s.batchTagAllStudios(ctx, input, box)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(tasks) == 0 {\n\t\t\treturn nil\n\t\t}\n\n\t\tprogress.SetTotal(len(tasks))\n\n\t\tlogger.Infof(\"Starting stash-box batch operation for %d studios\", len(tasks))\n\n\t\tfor _, task := range tasks {\n\t\t\tprogress.ExecuteTask(task.GetDescription(), func() {\n\t\t\t\ttask.Start(ctx)\n\t\t\t})\n\n\t\t\tprogress.Increment()\n\t\t}\n\n\t\treturn nil\n\t})\n\n\treturn s.JobManager.Add(ctx, \"Batch stash-box studio tag...\", j)\n}\n\nfunc (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {\n\tvar tasks []Task\n\n\terr := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\ttagQuery := s.Repository.Tag\n\n\t\tfor _, tagID := range input.Ids {\n\t\t\tif id, err := strconv.Atoi(tagID); err == nil {\n\t\t\t\tt, err := tagQuery.Find(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := t.LoadStashIDs(ctx, tagQuery); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"loading tag stash ids: %w\", err)\n\t\t\t\t}\n\n\t\t\t\thasStashID := t.StashIDs.ForEndpoint(box.Endpoint) != nil\n\t\t\t\tif (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {\n\t\t\t\t\ttasks = append(tasks, &stashBoxBatchTagTagTask{\n\t\t\t\t\t\ttag:            t,\n\t\t\t\t\t\tcreateParent:   input.CreateParent,\n\t\t\t\t\t\tbox:            box,\n\t\t\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn tasks, err\n}\n\nfunc (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {\n\tvar tasks []Task\n\n\tfor i := range input.StashIDs {\n\t\tstashID := input.StashIDs[i]\n\t\tif len(stashID) > 0 {\n\t\t\ttasks = append(tasks, &stashBoxBatchTagTagTask{\n\t\t\t\tstashID:        &stashID,\n\t\t\t\tcreateParent:   input.CreateParent,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t}\n\n\tfor i := range input.Names {\n\t\tname := input.Names[i]\n\t\tif len(name) > 0 {\n\t\t\ttasks = append(tasks, &stashBoxBatchTagTagTask{\n\t\t\t\tname:           &name,\n\t\t\t\tcreateParent:   input.CreateParent,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn tasks\n}\n\nfunc (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {\n\tvar tasks []Task\n\n\terr := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\ttagQuery := s.Repository.Tag\n\t\tvar tags []*models.Tag\n\t\tvar err error\n\n\t\ttags, err = tagQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error querying tags: %v\", err)\n\t\t}\n\n\t\tfor _, t := range tags {\n\t\t\ttasks = append(tasks, &stashBoxBatchTagTagTask{\n\t\t\t\ttag:            t,\n\t\t\t\tcreateParent:   input.CreateParent,\n\t\t\t\tbox:            box,\n\t\t\t\texcludedFields: input.ExcludeFields,\n\t\t\t})\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn tasks, err\n}\n\nfunc (s *Manager) StashBoxBatchTagTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {\n\tj := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {\n\t\tlogger.Infof(\"Initiating stash-box batch tag tag\")\n\n\t\tvar tasks []Task\n\t\tvar err error\n\n\t\tswitch input.getBatchTagType(false) {\n\t\tcase batchTagByIds:\n\t\t\ttasks, err = s.batchTagTagsByIds(ctx, input, box)\n\t\tcase batchTagByNamesOrStashIds:\n\t\t\ttasks = s.batchTagTagsByNamesOrStashIds(input, box)\n\t\tcase batchTagAll:\n\t\t\ttasks, err = s.batchTagAllTags(ctx, input, box)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(tasks) == 0 {\n\t\t\treturn nil\n\t\t}\n\n\t\tprogress.SetTotal(len(tasks))\n\n\t\tlogger.Infof(\"Starting stash-box batch operation for %d tags\", len(tasks))\n\n\t\tfor _, task := range tasks {\n\t\t\tprogress.ExecuteTask(task.GetDescription(), func() {\n\t\t\t\ttask.Start(ctx)\n\t\t\t})\n\n\t\t\tprogress.Increment()\n\t\t}\n\n\t\treturn nil\n\t})\n\n\treturn s.JobManager.Add(ctx, \"Batch stash-box tag tag...\", j)\n}\n"
  },
  {
    "path": "internal/manager/models.go",
    "content": "package manager\n\nimport (\n\t\"github.com/stashapp/stash/internal/manager/config\"\n)\n\ntype SystemStatus struct {\n\tDatabaseSchema *int             `json:\"databaseSchema\"`\n\tDatabasePath   *string          `json:\"databasePath\"`\n\tConfigPath     *string          `json:\"configPath\"`\n\tAppSchema      int              `json:\"appSchema\"`\n\tStatus         SystemStatusEnum `json:\"status\"`\n\tOs             string           `json:\"os\"`\n\tWorkingDir     string           `json:\"working_dir\"`\n\tHomeDir        string           `json:\"home_dir\"`\n\tFfmpegPath     *string          `json:\"ffmpegPath\"`\n\tFfprobePath    *string          `json:\"ffprobePath\"`\n}\n\ntype SetupInput struct {\n\t// Empty to indicate $HOME/.stash/config.yml default\n\tConfigLocation string                     `json:\"configLocation\"`\n\tStashes        []*config.StashConfigInput `json:\"stashes\"`\n\tSFWContentMode bool                       `json:\"sfwContentMode\"`\n\t// Empty to indicate default\n\tDatabaseFile string `json:\"databaseFile\"`\n\t// Empty to indicate default\n\tGeneratedLocation string `json:\"generatedLocation\"`\n\t// Empty to indicate default\n\tCacheLocation string `json:\"cacheLocation\"`\n\n\tStoreBlobsInDatabase bool `json:\"storeBlobsInDatabase\"`\n\t// Empty to indicate default\n\tBlobsLocation string `json:\"blobsLocation\"`\n}\n\ntype MigrateInput struct {\n\tBackupPath string `json:\"backupPath\"`\n}\n"
  },
  {
    "path": "internal/manager/repository.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/group\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\ntype SceneService interface {\n\tCreate(ctx context.Context, input models.CreateSceneInput) (*models.Scene, error)\n\tAssignFile(ctx context.Context, sceneID int, fileID models.FileID) error\n\tMerge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error\n\tDestroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error\n\n\tFindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)\n\tsceneFingerprintGetter\n}\n\ntype ImageService interface {\n\tDestroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error\n\tDestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)\n}\n\ntype GalleryService interface {\n\tAddImages(ctx context.Context, g *models.Gallery, toAdd ...int) error\n\tRemoveImages(ctx context.Context, g *models.Gallery, toRemove ...int) error\n\n\tSetCover(ctx context.Context, g *models.Gallery, coverImageId int) error\n\tResetCover(ctx context.Context, g *models.Gallery) error\n\n\tDestroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error)\n\n\tValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error\n\n\tUpdated(ctx context.Context, galleryID int) error\n}\n\ntype GroupService interface {\n\tCreate(ctx context.Context, input *models.CreateGroupInput) error\n\tUpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error)\n\n\tAddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error\n\tRemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error\n\tReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error\n}\n"
  },
  {
    "path": "internal/manager/running_streams.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"mime\"\n\t\"net/http\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/internal/static\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm) {\n\tinstance.ReadLockManager.Cancel(scene.Path)\n\n\tsceneHash := scene.GetHash(fileNamingAlgo)\n\n\tif sceneHash == \"\" {\n\t\treturn\n\t}\n\n\ttranscodePath := GetInstance().Paths.Scene.GetTranscodePath(sceneHash)\n\tinstance.ReadLockManager.Cancel(transcodePath)\n}\n\ntype SceneCoverGetter interface {\n\tGetCover(ctx context.Context, sceneID int) ([]byte, error)\n}\n\ntype SceneServer struct {\n\tTxnManager       txn.Manager\n\tSceneCoverGetter SceneCoverGetter\n}\n\nfunc (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWriter, r *http.Request) {\n\t// #3526 - return 404 if the scene does not have any files\n\tif scene.Path == \"\" {\n\t\thttp.Error(w, http.StatusText(404), 404)\n\t\treturn\n\t}\n\n\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\n\tfp := GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash)\n\tstreamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)\n\n\t// #2579 - hijacking and closing the connection here causes video playback to fail in Safari\n\t// We trust that the request context will be closed, so we don't need to call Cancel on the\n\t// returned context here.\n\t_ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, fp)\n\t_, filename := filepath.Split(fp)\n\tcontentDisposition := mime.FormatMediaType(\"inline\", map[string]string{\"filename\": filename})\n\tw.Header().Set(\"Content-Disposition\", contentDisposition)\n\thttp.ServeFile(w, r, fp)\n}\n\nfunc (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) {\n\tvar cover []byte\n\treadTxnErr := txn.WithReadTxn(r.Context(), s.TxnManager, func(ctx context.Context) error {\n\t\tvar err error\n\t\tcover, err = s.SceneCoverGetter.GetCover(ctx, scene.ID)\n\t\treturn err\n\t})\n\tif errors.Is(readTxnErr, context.Canceled) {\n\t\treturn\n\t}\n\tif readTxnErr != nil {\n\t\tlogger.Warnf(\"read transaction error on fetch screenshot: %v\", readTxnErr)\n\t}\n\n\tif cover == nil {\n\t\t// fallback to legacy image if present\n\t\tif scene.Path != \"\" {\n\t\t\tsceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())\n\t\t\tfilepath := GetInstance().Paths.Scene.GetLegacyScreenshotPath(sceneHash)\n\n\t\t\t// fall back to the scene image blob if the file isn't present\n\t\t\tscreenshotExists, _ := fsutil.FileExists(filepath)\n\t\t\tif screenshotExists {\n\t\t\t\tif r.URL.Query().Has(\"t\") {\n\t\t\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=31536000, immutable\")\n\t\t\t\t} else {\n\t\t\t\t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\t\t\t\t}\n\t\t\t\thttp.ServeFile(w, r, filepath)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// fallback to default cover if none found\n\t\tcover = static.ReadAll(static.DefaultSceneImage)\n\t}\n\n\tutils.ServeImage(w, r, cover)\n}\n"
  },
  {
    "path": "internal/manager/scan_stashignore_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage manager\n\nimport (\n\t\"context\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/file\"\n\n\t// Necessary to register custom migrations.\n\t_ \"github.com/stashapp/stash/pkg/sqlite/migrations\"\n)\n\n// stashIgnorePathFilter wraps StashIgnoreFilter to implement PathFilter for testing.\n// It provides a fixed library root for the filter.\ntype stashIgnorePathFilter struct {\n\tfilter      *file.StashIgnoreFilter\n\tlibraryRoot string\n}\n\nfunc (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {\n\treturn f.filter.Accept(ctx, path, info, f.libraryRoot, zipFilePath)\n}\n\n// createTestFileOnDisk creates a file with some content.\nfunc createTestFileOnDisk(t *testing.T, dir, name string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, name)\n\tif err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {\n\t\tt.Fatalf(\"failed to create directory for %s: %v\", path, err)\n\t}\n\t// Write some content so the file has a non-zero size.\n\tif err := os.WriteFile(path, []byte(\"test content for \"+name), 0644); err != nil {\n\t\tt.Fatalf(\"failed to create file %s: %v\", path, err)\n\t}\n\treturn path\n}\n\n// createStashIgnoreFile creates a .stashignore file with the given content.\nfunc createStashIgnoreFile(t *testing.T, dir, content string) {\n\tt.Helper()\n\tpath := filepath.Join(dir, \".stashignore\")\n\tif err := os.WriteFile(path, []byte(content), 0644); err != nil {\n\t\tt.Fatalf(\"failed to create .stashignore: %v\", err)\n\t}\n}\n\nfunc TestScannerWithStashIgnore(t *testing.T) {\n\t// Create temp directory structure.\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFileOnDisk(t, tmpDir, \"video1.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"video2.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"ignore_me.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"subdir/video3.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"subdir/skip_this.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"excluded_dir/video4.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"temp/processing.mp4\")\n\n\t// Create .stashignore file.\n\tstashignore := `# Ignore specific files\nignore_me.mp4\nsubdir/skip_this.mp4\n\n# Ignore directories\nexcluded_dir/\ntemp/\n`\n\tcreateStashIgnoreFile(t, tmpDir, stashignore)\n\n\t// Create stashignore filter with library root.\n\tstashIgnoreFilter := &stashIgnorePathFilter{\n\t\tfilter:      file.NewStashIgnoreFilter(),\n\t\tlibraryRoot: tmpDir,\n\t}\n\n\t// Create scanner.\n\tscanner := &file.Scanner{\n\t\tScanFilters: []file.PathFilter{stashIgnoreFilter},\n\t}\n\n\ttestScenarios := []struct {\n\t\tpath     string\n\t\taccepted bool\n\t}{\n\t\t{filepath.Join(tmpDir, \"video1.mp4\"), true},\n\t\t{filepath.Join(tmpDir, \"video2.mp4\"), true},\n\t\t{filepath.Join(tmpDir, \"ignore_me.mp4\"), false},\n\t\t{filepath.Join(tmpDir, \"subdir/video3.mp4\"), true},\n\t\t{filepath.Join(tmpDir, \"subdir/skip_this.mp4\"), false},\n\t\t{filepath.Join(tmpDir, \"excluded_dir/video4.mp4\"), false},\n\t\t{filepath.Join(tmpDir, \"temp/processing.mp4\"), false},\n\t}\n\n\tctx := context.Background()\n\n\tfor _, scenario := range testScenarios {\n\t\tinfo, err := os.Stat(scenario.path)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to stat file %s: %v\", scenario.path, err)\n\t\t}\n\t\taccepted := scanner.AcceptEntry(ctx, scenario.path, info, \"\")\n\n\t\tif accepted != scenario.accepted {\n\t\t\tt.Errorf(\"unexpected accept result for %s: expected %v, got %v\",\n\t\t\t\tscenario.path, scenario.accepted, accepted)\n\t\t}\n\t}\n}\n\nfunc TestScannerWithNestedStashIgnore(t *testing.T) {\n\t// Create temp directory structure.\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFileOnDisk(t, tmpDir, \"root.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"root.tmp\")\n\tcreateTestFileOnDisk(t, tmpDir, \"subdir/sub.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"subdir/sub.log\")\n\tcreateTestFileOnDisk(t, tmpDir, \"subdir/sub.tmp\")\n\n\t// Root .stashignore excludes *.tmp.\n\tcreateStashIgnoreFile(t, tmpDir, \"*.tmp\\n\")\n\n\t// Subdir .stashignore excludes *.log.\n\tcreateStashIgnoreFile(t, filepath.Join(tmpDir, \"subdir\"), \"*.log\\n\")\n\n\t// Create stashignore filter with library root.\n\tstashIgnoreFilter := &stashIgnorePathFilter{\n\t\tfilter:      file.NewStashIgnoreFilter(),\n\t\tlibraryRoot: tmpDir,\n\t}\n\n\t// Create scanner.\n\tscanner := &file.Scanner{\n\t\tScanFilters: []file.PathFilter{stashIgnoreFilter},\n\t}\n\n\ttestScenarios := []struct {\n\t\tpath     string\n\t\taccepted bool\n\t}{\n\t\t{filepath.Join(tmpDir, \"root.mp4\"), true},\n\t\t{filepath.Join(tmpDir, \"root.tmp\"), false},\n\t\t{filepath.Join(tmpDir, \"subdir/sub.mp4\"), true},\n\t\t{filepath.Join(tmpDir, \"subdir/sub.log\"), false},\n\t\t{filepath.Join(tmpDir, \"subdir/sub.tmp\"), false},\n\t}\n\n\tctx := context.Background()\n\n\tfor _, scenario := range testScenarios {\n\t\tinfo, err := os.Stat(scenario.path)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to stat file %s: %v\", scenario.path, err)\n\t\t}\n\t\taccepted := scanner.AcceptEntry(ctx, scenario.path, info, \"\")\n\n\t\tif accepted != scenario.accepted {\n\t\t\tt.Errorf(\"unexpected accept result for %s: expected %v, got %v\",\n\t\t\t\tscenario.path, scenario.accepted, accepted)\n\t\t}\n\t}\n}\n\nfunc TestScannerWithoutStashIgnore(t *testing.T) {\n\t// Create temp directory structure (no .stashignore).\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFileOnDisk(t, tmpDir, \"video1.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"video2.mp4\")\n\tcreateTestFileOnDisk(t, tmpDir, \"subdir/video3.mp4\")\n\n\t// Create stashignore filter with library root (but no .stashignore file exists).\n\tstashIgnoreFilter := &stashIgnorePathFilter{\n\t\tfilter:      file.NewStashIgnoreFilter(),\n\t\tlibraryRoot: tmpDir,\n\t}\n\n\t// Create scanner.\n\tscanner := &file.Scanner{\n\t\tScanFilters: []file.PathFilter{stashIgnoreFilter},\n\t}\n\n\ttestScenarios := []struct {\n\t\tpath     string\n\t\taccepted bool\n\t}{\n\t\t{filepath.Join(tmpDir, \"video1.mp4\"), true},\n\t\t{filepath.Join(tmpDir, \"video2.mp4\"), true},\n\t\t{filepath.Join(tmpDir, \"subdir/video3.mp4\"), true},\n\t}\n\n\tctx := context.Background()\n\n\tfor _, scenario := range testScenarios {\n\t\tinfo, err := os.Stat(scenario.path)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to stat file %s: %v\", scenario.path, err)\n\t\t}\n\t\taccepted := scanner.AcceptEntry(ctx, scenario.path, info, \"\")\n\n\t\tif accepted != scenario.accepted {\n\t\t\tt.Errorf(\"unexpected accept result for %s: expected %v, got %v\",\n\t\t\t\tscenario.path, scenario.accepted, accepted)\n\t\t}\n\t}\n}\n\nfunc TestScannerWithNegationPattern(t *testing.T) {\n\t// Create temp directory structure.\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFileOnDisk(t, tmpDir, \"file1.tmp\")\n\tcreateTestFileOnDisk(t, tmpDir, \"file2.tmp\")\n\tcreateTestFileOnDisk(t, tmpDir, \"keep_this.tmp\")\n\tcreateTestFileOnDisk(t, tmpDir, \"video.mp4\")\n\n\t// Create .stashignore with negation.\n\tstashignore := `*.tmp\n!keep_this.tmp\n`\n\tcreateStashIgnoreFile(t, tmpDir, stashignore)\n\n\t// Create stashignore filter with library root.\n\tstashIgnoreFilter := &stashIgnorePathFilter{\n\t\tfilter:      file.NewStashIgnoreFilter(),\n\t\tlibraryRoot: tmpDir,\n\t}\n\n\t// Create scanner.\n\tscanner := &file.Scanner{\n\t\tScanFilters: []file.PathFilter{stashIgnoreFilter},\n\t}\n\n\ttestScenarios := []struct {\n\t\tpath     string\n\t\taccepted bool\n\t}{\n\t\t{filepath.Join(tmpDir, \"file1.tmp\"), false},\n\t\t{filepath.Join(tmpDir, \"file2.tmp\"), false},\n\t\t{filepath.Join(tmpDir, \"keep_this.tmp\"), true},\n\t\t{filepath.Join(tmpDir, \"video.mp4\"), true},\n\t}\n\n\tctx := context.Background()\n\n\tfor _, scenario := range testScenarios {\n\t\tinfo, err := os.Stat(scenario.path)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to stat file %s: %v\", scenario.path, err)\n\t\t}\n\t\taccepted := scanner.AcceptEntry(ctx, scenario.path, info, \"\")\n\n\t\tif accepted != scenario.accepted {\n\t\t\tt.Errorf(\"unexpected accept result for %s: expected %v, got %v\",\n\t\t\t\tscenario.path, scenario.accepted, accepted)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/manager/scene.go",
    "content": "package manager\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype SceneStreamEndpoint struct {\n\tURL      string  `json:\"url\"`\n\tMimeType *string `json:\"mime_type\"`\n\tLabel    *string `json:\"label\"`\n}\n\ntype endpointType struct {\n\tlabel     string\n\tmimeType  string\n\textension string\n}\n\nvar (\n\tdirectEndpointType = endpointType{\n\t\tlabel:     \"Direct stream\",\n\t\tmimeType:  ffmpeg.MimeMp4Video,\n\t\textension: \"\",\n\t}\n\tmp4EndpointType = endpointType{\n\t\tlabel:     \"MP4\",\n\t\tmimeType:  ffmpeg.MimeMp4Video,\n\t\textension: \".mp4\",\n\t}\n\tmkvEndpointType = endpointType{\n\t\tlabel: \"MKV\",\n\t\t// use mp4 mimetype to trick the client, since many clients won't try mkv\n\t\tmimeType:  ffmpeg.MimeMp4Video,\n\t\textension: \".mkv\",\n\t}\n\twebmEndpointType = endpointType{\n\t\tlabel:     \"WEBM\",\n\t\tmimeType:  ffmpeg.MimeWebmVideo,\n\t\textension: \".webm\",\n\t}\n\thlsEndpointType = endpointType{\n\t\tlabel:     \"HLS\",\n\t\tmimeType:  ffmpeg.MimeHLS,\n\t\textension: \".m3u8\",\n\t}\n\tdashEndpointType = endpointType{\n\t\tlabel:     \"DASH\",\n\t\tmimeType:  ffmpeg.MimeDASH,\n\t\textension: \".mpd\",\n\t}\n)\n\nfunc GetVideoFileContainer(file *models.VideoFile) (ffmpeg.Container, error) {\n\tvar container ffmpeg.Container\n\tformat := file.Format\n\tif format != \"\" {\n\t\tcontainer = ffmpeg.Container(format)\n\t} else { // container isn't in the DB\n\t\t// shouldn't happen, fallback to ffprobe\n\t\tffprobe := GetInstance().FFProbe\n\t\ttmpVideoFile, err := ffprobe.NewVideoFile(file.Path)\n\t\tif err != nil {\n\t\t\treturn ffmpeg.Container(\"\"), fmt.Errorf(\"error reading video file: %v\", err)\n\t\t}\n\n\t\treturn ffmpeg.MatchContainer(tmpVideoFile.Container, file.Path)\n\t}\n\n\treturn container, nil\n}\n\nfunc GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStreamingTranscodeSize models.StreamingResolutionEnum) ([]*SceneStreamEndpoint, error) {\n\tif scene == nil {\n\t\treturn nil, fmt.Errorf(\"nil scene\")\n\t}\n\n\tpf := scene.Files.Primary()\n\tif pf == nil {\n\t\treturn nil, nil\n\t}\n\n\t// convert StreamingResolutionEnum to ResolutionEnum\n\tmaxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize)\n\tsceneResolution := models.GetMinResolution(pf)\n\tincludeSceneStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool {\n\t\tvar minResolution int\n\t\tif streamingResolution == models.StreamingResolutionEnumOriginal {\n\t\t\tminResolution = sceneResolution\n\t\t} else {\n\t\t\t// convert StreamingResolutionEnum to ResolutionEnum so we can get the min\n\t\t\t// resolution\n\t\t\tconvertedRes := models.ResolutionEnum(streamingResolution)\n\t\t\tminResolution = convertedRes.GetMinResolution()\n\n\t\t\t// don't include if scene resolution is smaller than the streamingResolution\n\t\t\tif sceneResolution != 0 && sceneResolution < minResolution {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\n\t\t// if we always allow everything, then return true\n\t\tif maxStreamingTranscodeSize == models.StreamingResolutionEnumOriginal {\n\t\t\treturn true\n\t\t}\n\n\t\treturn maxStreamingResolution.GetMinResolution() >= minResolution\n\t}\n\n\tmakeStreamEndpoint := func(t endpointType, resolution models.StreamingResolutionEnum) *SceneStreamEndpoint {\n\t\turl := *directStreamURL\n\t\turl.Path += t.extension\n\n\t\tlabel := t.label\n\n\t\tif resolution != \"\" {\n\t\t\tv := url.Query()\n\t\t\tv.Set(\"resolution\", resolution.String())\n\t\t\turl.RawQuery = v.Encode()\n\n\t\t\tswitch resolution {\n\t\t\tcase models.StreamingResolutionEnumFourK:\n\t\t\t\tlabel += \" 4K (2160p)\"\n\t\t\tcase models.StreamingResolutionEnumFullHd:\n\t\t\t\tlabel += \" Full HD (1080p)\"\n\t\t\tcase models.StreamingResolutionEnumStandardHd:\n\t\t\t\tlabel += \" HD (720p)\"\n\t\t\tcase models.StreamingResolutionEnumStandard:\n\t\t\t\tlabel += \" Standard (480p)\"\n\t\t\tcase models.StreamingResolutionEnumLow:\n\t\t\t\tlabel += \" Low (240p)\"\n\t\t\t}\n\t\t}\n\n\t\treturn &SceneStreamEndpoint{\n\t\t\tURL:      url.String(),\n\t\t\tMimeType: &t.mimeType,\n\t\t\tLabel:    &label,\n\t\t}\n\t}\n\n\tvar endpoints []*SceneStreamEndpoint\n\n\t// direct stream should only apply when the audio codec is supported\n\taudioCodec := ffmpeg.MissingUnsupported\n\tif pf.AudioCodec != \"\" {\n\t\taudioCodec = ffmpeg.ProbeAudioCodec(pf.AudioCodec)\n\t}\n\n\t// don't care if we can't get the container\n\tcontainer, _ := GetVideoFileContainer(pf)\n\n\tif HasTranscode(scene, config.GetInstance().GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) {\n\t\tendpoints = append(endpoints, makeStreamEndpoint(directEndpointType, \"\"))\n\t}\n\n\t// only add mkv stream endpoint if the scene container is an mkv already\n\tif container == ffmpeg.Matroska {\n\t\tendpoints = append(endpoints, makeStreamEndpoint(mkvEndpointType, \"\"))\n\t}\n\n\tmp4Streams := []*SceneStreamEndpoint{}\n\twebmStreams := []*SceneStreamEndpoint{}\n\thlsStreams := []*SceneStreamEndpoint{}\n\tdashStreams := []*SceneStreamEndpoint{}\n\n\tif includeSceneStreamPath(models.StreamingResolutionEnumOriginal) {\n\t\tmp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumOriginal))\n\t\twebmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumOriginal))\n\t\thlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumOriginal))\n\t\tdashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumOriginal))\n\t}\n\n\tif includeSceneStreamPath(models.StreamingResolutionEnumFourK) {\n\t\tmp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumFourK))\n\t\twebmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumFourK))\n\t\thlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumFourK))\n\t\tdashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumFourK))\n\t}\n\n\tif includeSceneStreamPath(models.StreamingResolutionEnumFullHd) {\n\t\tmp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumFullHd))\n\t\twebmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumFullHd))\n\t\thlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumFullHd))\n\t\tdashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumFullHd))\n\t}\n\n\tif includeSceneStreamPath(models.StreamingResolutionEnumStandardHd) {\n\t\tmp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumStandardHd))\n\t\twebmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumStandardHd))\n\t\thlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumStandardHd))\n\t\tdashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumStandardHd))\n\t}\n\n\tif includeSceneStreamPath(models.StreamingResolutionEnumStandard) {\n\t\tmp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumStandard))\n\t\twebmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumStandard))\n\t\thlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumStandard))\n\t\tdashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumStandard))\n\t}\n\n\tif includeSceneStreamPath(models.StreamingResolutionEnumLow) {\n\t\tmp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumLow))\n\t\twebmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumLow))\n\t\thlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumLow))\n\t\tdashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType, models.StreamingResolutionEnumLow))\n\t}\n\n\tendpoints = append(endpoints, mp4Streams...)\n\tendpoints = append(endpoints, webmStreams...)\n\tendpoints = append(endpoints, hlsStreams...)\n\tendpoints = append(endpoints, dashStreams...)\n\n\treturn endpoints, nil\n}\n\n// HasTranscode returns true if a transcoded video exists for the provided\n// scene. It will check using the OSHash of the scene first, then fall back\n// to the checksum.\nfunc HasTranscode(scene *models.Scene, fileNamingAlgo models.HashAlgorithm) bool {\n\tif scene == nil {\n\t\treturn false\n\t}\n\n\tsceneHash := scene.GetHash(fileNamingAlgo)\n\tif sceneHash == \"\" {\n\t\treturn false\n\t}\n\n\ttranscodePath := instance.Paths.Scene.GetTranscodePath(sceneHash)\n\tret, _ := fsutil.FileExists(transcodePath)\n\treturn ret\n}\n"
  },
  {
    "path": "internal/manager/subscribe.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"sync\"\n)\n\ntype subscriptionManager struct {\n\tsubscriptions []chan bool\n\tmutex         sync.Mutex\n}\n\nfunc (m *subscriptionManager) subscribe(ctx context.Context) <-chan bool {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tc := make(chan bool, 10)\n\tm.subscriptions = append(m.subscriptions, c)\n\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tm.mutex.Lock()\n\t\tdefer m.mutex.Unlock()\n\t\tclose(c)\n\n\t\tfor i, s := range m.subscriptions {\n\t\t\tif s == c {\n\t\t\t\tm.subscriptions = append(m.subscriptions[:i], m.subscriptions[i+1:]...)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn c\n}\n\nfunc (m *subscriptionManager) notify() {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tfor _, s := range m.subscriptions {\n\t\ts <- true\n\t}\n}\n"
  },
  {
    "path": "internal/manager/task/clean_generated.go",
    "content": "package task\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n)\n\ntype CleanGeneratedOptions struct {\n\tBlobFiles bool `json:\"blobs\"`\n\n\tSprites     bool `json:\"sprites\"`\n\tScreenshots bool `json:\"screenshots\"`\n\tTranscodes  bool `json:\"transcodes\"`\n\n\tMarkers bool `json:\"markers\"`\n\n\tImageThumbnails bool `json:\"imageThumbnails\"`\n\n\tDryRun bool `json:\"dryRun\"`\n}\n\ntype BlobCleaner interface {\n\tEntryExists(ctx context.Context, checksum string) (bool, error)\n}\n\ntype CleanGeneratedJob struct {\n\tOptions CleanGeneratedOptions\n\n\tPaths                    *paths.Paths\n\tBlobsStorageType         config.BlobsStorageType\n\tVideoFileNamingAlgorithm models.HashAlgorithm\n\n\tBlobCleaner BlobCleaner\n\tRepository  models.Repository\n\n\tdryRunPrefix  string\n\ttotalTasks    int\n\ttasksComplete int\n}\n\nfunc (j *CleanGeneratedJob) deleteFile(path string) {\n\tif j.Options.DryRun {\n\t\tlogger.Debugf(\"would delete file: %s\", path)\n\t\treturn\n\t}\n\n\tif err := os.Remove(path); err != nil {\n\t\tlogger.Errorf(\"error deleting file %s: %v\", path, err)\n\t}\n}\n\nfunc (j *CleanGeneratedJob) deleteDir(path string) {\n\tif j.Options.DryRun {\n\t\tlogger.Debugf(\"would delete file: %s\", path)\n\t\treturn\n\t}\n\n\tif err := os.RemoveAll(path); err != nil {\n\t\tlogger.Errorf(\"error deleting directory %s: %v\", path, err)\n\t}\n}\n\nfunc (j *CleanGeneratedJob) countTasks() int {\n\ttasks := 0\n\n\tif j.Options.BlobFiles {\n\t\ttasks++\n\t}\n\tif j.Options.Sprites {\n\t\ttasks++\n\t}\n\tif j.Options.Screenshots {\n\t\ttasks++\n\t}\n\tif j.Options.Transcodes {\n\t\ttasks++\n\t}\n\tif j.Options.Markers {\n\t\ttasks++\n\t}\n\tif j.Options.ImageThumbnails {\n\t\ttasks++\n\t}\n\treturn tasks\n}\n\nfunc (j *CleanGeneratedJob) taskComplete(progress *job.Progress) {\n\tj.tasksComplete++\n\tprogress.SetPercent(float64(j.tasksComplete) / float64(j.totalTasks))\n}\n\nfunc (j *CleanGeneratedJob) logError(err error) {\n\tif !errors.Is(err, context.Canceled) {\n\t\tlogger.Error(err)\n\t}\n}\n\nfunc (j *CleanGeneratedJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tj.tasksComplete = 0\n\n\tif !j.BlobsStorageType.IsValid() {\n\t\treturn fmt.Errorf(\"invalid blobs storage type: %s\", j.BlobsStorageType)\n\t}\n\n\tif !j.VideoFileNamingAlgorithm.IsValid() {\n\t\treturn fmt.Errorf(\"invalid video file naming algorithm: %s\", j.VideoFileNamingAlgorithm)\n\t}\n\n\tif j.Options.DryRun {\n\t\tj.dryRunPrefix = \"[dry run] \"\n\t}\n\n\tlogger.Infof(\"Cleaning generated files %s\", j.dryRunPrefix)\n\n\tj.totalTasks = j.countTasks()\n\n\tif j.Options.BlobFiles {\n\t\tprogress.ExecuteTask(\"Cleaning blob files\", func() {\n\t\t\tif err := j.cleanBlobFiles(ctx, progress); err != nil {\n\t\t\t\tj.logError(fmt.Errorf(\"error cleaning blob files: %w\", err))\n\t\t\t}\n\t\t})\n\t\tj.taskComplete(progress)\n\t}\n\n\tif j.Options.Sprites {\n\t\tprogress.ExecuteTask(\"Cleaning sprite files\", func() {\n\t\t\tif err := j.cleanSpriteFiles(ctx, progress); err != nil {\n\t\t\t\tj.logError(fmt.Errorf(\"error cleaning sprite files: %w\", err))\n\t\t\t}\n\t\t})\n\t\tj.taskComplete(progress)\n\t}\n\n\tif j.Options.Screenshots {\n\t\tprogress.ExecuteTask(\"Cleaning screenshot files\", func() {\n\t\t\tif err := j.cleanScreenshotFiles(ctx, progress); err != nil {\n\t\t\t\tj.logError(fmt.Errorf(\"error cleaning screenshot files: %w\", err))\n\t\t\t}\n\t\t})\n\t\tj.taskComplete(progress)\n\t}\n\n\tif j.Options.Transcodes {\n\t\tprogress.ExecuteTask(\"Cleaning transcode files\", func() {\n\t\t\tif err := j.cleanTranscodeFiles(ctx, progress); err != nil {\n\t\t\t\tj.logError(fmt.Errorf(\"error cleaning transcode files: %w\", err))\n\t\t\t}\n\t\t})\n\t\tj.taskComplete(progress)\n\t}\n\n\tif j.Options.Markers {\n\t\tprogress.ExecuteTask(\"Cleaning marker files\", func() {\n\t\t\tif err := j.cleanMarkerFiles(ctx, progress); err != nil {\n\t\t\t\tj.logError(fmt.Errorf(\"error cleaning marker files: %w\", err))\n\t\t\t}\n\t\t})\n\t\tj.taskComplete(progress)\n\t}\n\n\tif j.Options.ImageThumbnails {\n\t\tprogress.ExecuteTask(\"Cleaning thumbnail files\", func() {\n\t\t\tif err := j.cleanThumbnailFiles(ctx, progress); err != nil {\n\t\t\t\tj.logError(fmt.Errorf(\"error cleaning thumbnail files: %w\", err))\n\t\t\t}\n\t\t})\n\t\tj.taskComplete(progress)\n\t}\n\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Stopping due to user request\")\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Finished cleaning generated files\")\n\treturn nil\n}\n\nfunc (j *CleanGeneratedJob) setTaskProgress(taskProgress float64, progress *job.Progress) {\n\tprogress.SetPercent((float64(j.tasksComplete) + taskProgress) / float64(j.totalTasks))\n}\n\nfunc (j *CleanGeneratedJob) logDelete(format string, args ...interface{}) {\n\tlogger.Infof(j.dryRunPrefix+format, args...)\n}\n\n// estimates the progress by the hash prefix - first two characters\n// this is a rough estimate, but it's better than nothing\n// the prefix ranges from 00 to ff\nfunc (j *CleanGeneratedJob) estimateProgress(hashPrefix string) (float64, error) {\n\ttoInt, err := strconv.ParseInt(hashPrefix, 16, 64)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tconst total = 256 // ff\n\treturn float64(toInt) / total, nil\n}\n\nfunc (j *CleanGeneratedJob) setProgressFromFilename(prefix string, progress *job.Progress) {\n\tp, err := j.estimateProgress(prefix)\n\tif err != nil {\n\t\tlogger.Errorf(\"error estimating progress: %v\", err)\n\t\treturn\n\t}\n\tj.setTaskProgress(p, progress)\n}\n\nfunc (j *CleanGeneratedJob) getIntraFolderPrefix(basename string) (string, error) {\n\tvar hash string\n\t_, err := fmt.Sscanf(basename, \"%2x\", &hash)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"%x\", hash), nil\n}\n\nfunc (j *CleanGeneratedJob) getBlobFileHash(basename string) (string, error) {\n\tvar hash string\n\t_, err := fmt.Sscanf(basename, \"%32x\", &hash)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"%x\", hash), nil\n}\n\nfunc (j *CleanGeneratedJob) cleanBlobFiles(ctx context.Context, progress *job.Progress) error {\n\tif job.IsCancelled(ctx) {\n\t\treturn nil\n\t}\n\n\tif j.BlobsStorageType != config.BlobStorageTypeFilesystem {\n\t\tlogger.Debugf(\"skipping blob file cleanup, storage type is not filesystem\")\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Cleaning blob files\")\n\n\t// walk through the blob directory\n\tif err := filepath.Walk(j.Paths.Blobs, func(path string, info fs.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err = ctx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\tif path == j.Paths.Blobs {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// ignore any directory that isn't a two character hash prefix\n\t\t\t_, err := j.getIntraFolderPrefix(info.Name())\n\t\t\tif err != nil {\n\t\t\t\tlogger.Warnf(\"Ignoring unknown directory: %s\", path)\n\t\t\t\treturn fs.SkipDir\n\t\t\t}\n\n\t\t\t// estimate progress by the hash prefix\n\t\t\tif filepath.Dir(path) == j.Paths.Blobs {\n\t\t\t\thashPrefix := filepath.Base(path)\n\t\t\t\tj.setProgressFromFilename(hashPrefix, progress)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}\n\n\t\tblobname := info.Name()\n\n\t\t// ignore any files that aren't a 32 character hash\n\t\t_, err = j.getBlobFileHash(blobname)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"ignoring unknown blob file: %s\", blobname)\n\t\t\treturn nil\n\t\t}\n\n\t\t// if blob entry does not exist, delete the file\n\t\tif err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\texists, err := j.BlobCleaner.EntryExists(ctx, blobname)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif !exists {\n\t\t\t\tj.logDelete(\"deleting unused blob file: %s\", blobname)\n\t\t\t\tj.deleteFile(path)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"error checking blob entry: %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (j *CleanGeneratedJob) getScenesWithHash(ctx context.Context, hash string) ([]*models.Scene, error) {\n\tfp := models.Fingerprint{\n\t\tFingerprint: hash,\n\t}\n\n\tif j.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {\n\t\tfp.Type = models.FingerprintTypeMD5\n\t} else {\n\t\tfp.Type = models.FingerprintTypeOshash\n\t}\n\n\treturn j.Repository.Scene.FindByFingerprints(ctx, []models.Fingerprint{fp})\n}\n\nconst (\n\tmd5Length    = 32\n\toshashLength = 16\n)\n\nfunc (j *CleanGeneratedJob) hashPatternPrefix() string {\n\thashLen := oshashLength\n\tif j.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {\n\t\thashLen = md5Length\n\t}\n\n\treturn fmt.Sprintf(\"%%%dx\", hashLen)\n}\n\nfunc (j *CleanGeneratedJob) getSpriteFileHash(basename string) (string, error) {\n\tpatternPrefix := j.hashPatternPrefix()\n\tspritePattern := patternPrefix + \"_sprite.jpg\"\n\n\tvar hash string\n\t_, err := fmt.Sscanf(basename, spritePattern, &hash)\n\tif err != nil {\n\t\t// also try thumbs\n\t\tthumbPattern := patternPrefix + \"_thumbs.vtt\"\n\t\t_, err = fmt.Sscanf(basename, thumbPattern, &hash)\n\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\n\treturn fmt.Sprintf(\"%x\", hash), nil\n}\n\nfunc (j *CleanGeneratedJob) cleanSpriteFiles(ctx context.Context, progress *job.Progress) error {\n\tif job.IsCancelled(ctx) {\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Cleaning sprite files\")\n\n\t// walk through the sprite directory\n\tif err := filepath.Walk(j.Paths.Generated.Vtt, func(path string, info fs.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err = ctx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\tfilename := info.Name()\n\n\t\thash, err := j.getSpriteFileHash(filename)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Ignoring unknown sprite file: %s\", filename)\n\t\t\treturn nil\n\t\t}\n\n\t\tj.setProgressFromFilename(hash[0:2], progress)\n\n\t\tvar exists []*models.Scene\n\n\t\tif err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\texists, err = j.getScenesWithHash(ctx, hash)\n\t\t\treturn err\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"error checking scene entry for sprite: %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tif len(exists) == 0 {\n\t\t\tj.logDelete(\"deleting unused sprite file: %s\", filename)\n\t\t\tj.deleteFile(path)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (j *CleanGeneratedJob) cleanSceneFiles(ctx context.Context, path string, typ string, getSceneFileHash func(filename string) (string, error), progress *job.Progress) error {\n\tif job.IsCancelled(ctx) {\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Cleaning %s files\", typ)\n\n\t// walk through the sprite directory\n\tif err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\tif err = ctx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfilename := info.Name()\n\t\thash, err := getSceneFileHash(filename)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Ignoring unknown %s file: %s\", typ, filename)\n\t\t\treturn nil\n\t\t}\n\n\t\tj.setProgressFromFilename(hash[0:2], progress)\n\n\t\tvar exists []*models.Scene\n\n\t\tif err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\texists, err = j.getScenesWithHash(ctx, hash)\n\t\t\treturn err\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"error checking scene entry: %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tif len(exists) == 0 {\n\t\t\tj.logDelete(\"deleting unused %s file: %s\", typ, filename)\n\t\t\tj.deleteFile(path)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (j *CleanGeneratedJob) getScreenshotFileHash(basename string) (string, error) {\n\tvar hash string\n\tvar ext string\n\t// include the extension - which could be mp4/jpg/webp\n\t_, err := fmt.Sscanf(basename, j.hashPatternPrefix()+\".%s\", &hash, &ext)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"%x\", hash), nil\n}\n\nfunc (j *CleanGeneratedJob) cleanScreenshotFiles(ctx context.Context, progress *job.Progress) error {\n\treturn j.cleanSceneFiles(ctx, j.Paths.Generated.Screenshots, \"screenshot\", j.getScreenshotFileHash, progress)\n}\n\nfunc (j *CleanGeneratedJob) getTranscodeFileHash(basename string) (string, error) {\n\tvar hash string\n\t_, err := fmt.Sscanf(basename, j.hashPatternPrefix()+\".mp4\", &hash)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"%x\", hash), nil\n}\n\nfunc (j *CleanGeneratedJob) cleanTranscodeFiles(ctx context.Context, progress *job.Progress) error {\n\treturn j.cleanSceneFiles(ctx, j.Paths.Generated.Transcodes, \"transcode\", j.getTranscodeFileHash, progress)\n}\n\nfunc (j *CleanGeneratedJob) getMarkerSceneFileHash(basename string) (string, error) {\n\tvar hash string\n\t_, err := fmt.Sscanf(basename, j.hashPatternPrefix(), &hash)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"%x\", hash), nil\n}\n\nfunc (j *CleanGeneratedJob) getMarkerFileSeconds(basename string) (int, error) {\n\tvar ret int\n\tvar ext string\n\t// include the extension - which could be mp4/jpg/webp\n\t_, err := fmt.Sscanf(basename, \"%d.%s\", &ret, &ext)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.Progress) error {\n\tif job.IsCancelled(ctx) {\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Cleaning marker files\")\n\n\tvar scenes []*models.Scene\n\tvar sceneHash string\n\tvar markers []*models.SceneMarker\n\n\t// walk through the markers directory\n\tif err := filepath.Walk(j.Paths.Generated.Markers, func(path string, info fs.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err = ctx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\t// ignore markers directory\n\t\t\tif path == j.Paths.Generated.Markers {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tmarkers = nil\n\n\t\t\tif filepath.Dir(path) != j.Paths.Generated.Markers {\n\t\t\t\tlogger.Warnf(\"Ignoring unknown marker directory: %s\", path)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tsceneHash, err = j.getMarkerSceneFileHash(info.Name())\n\t\t\tif err != nil {\n\t\t\t\tlogger.Warnf(\"Ignoring unknown marker directory: %s\", path)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tj.setProgressFromFilename(sceneHash[0:2], progress)\n\n\t\t\t// check if the scene exists\n\t\t\tvar walkErr error\n\t\t\tif err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\t\tvar err error\n\t\t\t\tscenes, err = j.getScenesWithHash(ctx, sceneHash)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error checking scene entry: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tif len(scenes) == 0 {\n\t\t\t\t\tj.logDelete(\"deleting unused marker directory: %s\", sceneHash)\n\t\t\t\t\tj.deleteDir(path)\n\t\t\t\t\t// #5911 - we've just deleted the directory, so skip it in the walk to avoid errors\n\t\t\t\t\twalkErr = fs.SkipDir\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\t// get the markers now\n\t\t\t\tfor _, scene := range scenes {\n\t\t\t\t\tthisMarkers, err := j.Repository.SceneMarker.FindBySceneID(ctx, scene.ID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"error getting markers for scene: %v\", err)\n\t\t\t\t\t}\n\t\t\t\t\tmarkers = append(markers, thisMarkers...)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t}); err != nil {\n\t\t\t\tlogger.Error(err.Error())\n\t\t\t}\n\n\t\t\treturn walkErr\n\t\t}\n\n\t\tfilename := info.Name()\n\t\tseconds, err := j.getMarkerFileSeconds(filename)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Ignoring unknown marker file: %s\", filename)\n\t\t\treturn nil\n\t\t}\n\n\t\t// scenes should be set by the directory walk\n\t\thash := filepath.Base(filepath.Dir(path))\n\t\tif hash != sceneHash {\n\t\t\tlogger.Errorf(\"internal error: scene hash mismatch: %s != %s\", hash, sceneHash)\n\t\t\treturn nil\n\t\t}\n\n\t\tif len(scenes) == 0 {\n\t\t\tlogger.Errorf(\"no scenes found for marker file: %s\", filename)\n\t\t\treturn nil\n\t\t}\n\n\t\t// find the marker\n\t\tvar marker *models.SceneMarker\n\t\tfor _, m := range markers {\n\t\t\tif int(m.Seconds) == seconds {\n\t\t\t\tmarker = m\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif marker == nil {\n\t\t\t// not found, delete the file\n\t\t\tj.logDelete(\"deleting unused marker file: %s\", filename)\n\t\t\tj.deleteFile(path)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (j *CleanGeneratedJob) getImagesWithHash(ctx context.Context, checksum string) ([]*models.Image, error) {\n\tvar exists []*models.Image\n\tif err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t// if scene entry does not exist, delete the file\n\t\tvar err error\n\t\texists, err = j.Repository.Image.FindByChecksum(ctx, checksum)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn exists, nil\n}\n\nfunc (j *CleanGeneratedJob) getThumbnailFileHash(basename string) (string, error) {\n\tvar (\n\t\thash  string\n\t\twidth int\n\t\text   string\n\t)\n\t// include the extension - which could be jpg/webp\n\t_, err := fmt.Sscanf(basename, \"%32x_%d.%s\", &hash, &width, &ext)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"%x\", hash), nil\n}\n\nfunc (j *CleanGeneratedJob) cleanThumbnailFiles(ctx context.Context, progress *job.Progress) error {\n\tif job.IsCancelled(ctx) {\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Cleaning image thumbnail files\")\n\n\t// walk through the sprite directory\n\tif err := filepath.Walk(j.Paths.Generated.Thumbnails, func(path string, info fs.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err = ctx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\tif path == j.Paths.Generated.Thumbnails {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// ensure the directory is a hash prefix\n\t\t\t_, err := j.getIntraFolderPrefix(info.Name())\n\t\t\tif err != nil {\n\t\t\t\tlogger.Warnf(\"Ignoring unknown thumbnail directory: %s\", path)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// estimate progress by the hash prefix\n\t\t\tif filepath.Dir(path) == j.Paths.Generated.Thumbnails {\n\t\t\t\thashPrefix := filepath.Base(path)\n\t\t\t\tj.setProgressFromFilename(hashPrefix, progress)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}\n\n\t\tfilename := info.Name()\n\t\tchecksum, err := j.getThumbnailFileHash(filename)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Ignoring unknown thumbnail file: %s\", filename)\n\t\t\treturn nil\n\t\t}\n\n\t\texists, err := j.getImagesWithHash(ctx, checksum)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error checking image entry: %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tif len(exists) == 0 {\n\t\t\tj.logDelete(\"deleting unused thumbnail file: %s\", filename)\n\t\t\tj.deleteFile(path)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/task/download_ffmpeg.go",
    "content": "package task\n\nimport (\n\t\"archive/zip\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"runtime\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype DownloadFFmpegJob struct {\n\tConfigDirectory string\n\tOnComplete      func(ctx context.Context)\n\turls            []string\n\tdownloaded      int\n}\n\nfunc (s *DownloadFFmpegJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tif err := s.download(ctx, progress); err != nil {\n\t\tif job.IsCancelled(ctx) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\n\tif s.OnComplete != nil {\n\t\ts.OnComplete(ctx)\n\t}\n\n\treturn nil\n}\n\nfunc (s *DownloadFFmpegJob) setTaskProgress(taskProgress float64, progress *job.Progress) {\n\tprogress.SetPercent((float64(s.downloaded) + taskProgress) / float64(len(s.urls)))\n}\n\nfunc (s *DownloadFFmpegJob) download(ctx context.Context, progress *job.Progress) error {\n\ts.urls = ffmpeg.GetFFmpegURL()\n\n\t// set steps based on the number of URLs\n\n\tfor _, url := range s.urls {\n\t\terr := s.downloadSingle(ctx, url, progress)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ts.downloaded++\n\t}\n\n\t// validate that the urls contained what we needed\n\texecutables := []string{fsutil.GetExeName(\"ffmpeg\"), fsutil.GetExeName(\"ffprobe\")}\n\tfor _, executable := range executables {\n\t\t_, err := os.Stat(filepath.Join(s.ConfigDirectory, executable))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\ntype downloadProgressReader struct {\n\tio.Reader\n\tsetProgress func(taskProgress float64)\n\tbytesRead   int64\n\ttotal       int64\n}\n\nfunc (r *downloadProgressReader) Read(p []byte) (int, error) {\n\tread, err := r.Reader.Read(p)\n\tif err == nil {\n\t\tr.bytesRead += int64(read)\n\t\tif r.total > 0 {\n\t\t\tprogress := float64(r.bytesRead) / float64(r.total)\n\t\t\tr.setProgress(progress)\n\t\t}\n\t}\n\n\treturn read, err\n}\n\nfunc (s *DownloadFFmpegJob) downloadSingle(ctx context.Context, url string, progress *job.Progress) error {\n\tif url == \"\" {\n\t\treturn fmt.Errorf(\"no ffmpeg url for this platform\")\n\t}\n\n\tconfigDirectory := s.ConfigDirectory\n\n\t// Configure where we want to download the archive\n\turlBase := path.Base(url)\n\tarchivePath := filepath.Join(configDirectory, urlBase)\n\t_ = os.Remove(archivePath) // remove archive if it already exists\n\tout, err := os.Create(archivePath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer out.Close()\n\n\tlogger.Infof(\"Downloading %s...\", url)\n\n\tprogress.ExecuteTask(fmt.Sprintf(\"Downloading %s\", url), func() {\n\t\terr = s.downloadFile(ctx, url, out, progress)\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to download ffmpeg from %s: %w\", url, err)\n\t}\n\n\tlogger.Info(\"Downloading complete\")\n\n\tlogger.Infof(\"Unzipping %s...\", archivePath)\n\tprogress.ExecuteTask(fmt.Sprintf(\"Unzipping %s\", archivePath), func() {\n\t\terr = s.unzip(archivePath)\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to unzip ffmpeg archive: %w\", err)\n\t}\n\n\t// On OSX or Linux set downloaded files permissions\n\tif runtime.GOOS == \"darwin\" || runtime.GOOS == \"linux\" {\n\t\t_, err = os.Stat(filepath.Join(configDirectory, \"ffmpeg\"))\n\t\tif !os.IsNotExist(err) {\n\t\t\tif err = os.Chmod(filepath.Join(configDirectory, \"ffmpeg\"), 0755); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t_, err = os.Stat(filepath.Join(configDirectory, \"ffprobe\"))\n\t\tif !os.IsNotExist(err) {\n\t\t\tif err := os.Chmod(filepath.Join(configDirectory, \"ffprobe\"), 0755); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// TODO: In future possible clear xattr to allow running on osx without user intervention\n\t\t// TODO: this however may not be required.\n\t\t// xattr -c /path/to/binary -- xattr.Remove(path, \"com.apple.quarantine\")\n\t}\n\n\treturn nil\n}\n\nfunc (s *DownloadFFmpegJob) downloadFile(ctx context.Context, url string, out *os.File, progress *job.Progress) error {\n\t// Make the HTTP request\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttransport := &http.Transport{Proxy: http.ProxyFromEnvironment}\n\n\tclient := &http.Client{\n\t\tTransport: transport,\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check server response\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"bad status: %s\", resp.Status)\n\t}\n\n\treader := &downloadProgressReader{\n\t\tReader: resp.Body,\n\t\ttotal:  resp.ContentLength,\n\t\tsetProgress: func(taskProgress float64) {\n\t\t\ts.setTaskProgress(taskProgress, progress)\n\t\t},\n\t}\n\n\t// Write the response to the archive file location\n\tif _, err := io.Copy(out, reader); err != nil {\n\t\treturn err\n\t}\n\n\tmime := resp.Header.Get(\"Content-Type\")\n\tif mime != \"application/zip\" { // try detecting MIME type since some servers don't return the correct one\n\t\tdata := make([]byte, 500) // http.DetectContentType only reads up to 500 bytes\n\t\t_, _ = out.ReadAt(data, 0)\n\t\tmime = http.DetectContentType(data)\n\t}\n\n\tif mime != \"application/zip\" {\n\t\treturn fmt.Errorf(\"downloaded file is not a zip archive\")\n\t}\n\n\treturn nil\n}\n\nfunc (s *DownloadFFmpegJob) unzip(src string) error {\n\tzipReader, err := zip.OpenReader(src)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer zipReader.Close()\n\n\tfor _, f := range zipReader.File {\n\t\tif f.FileInfo().IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tfilename := f.FileInfo().Name()\n\t\tif filename != \"ffprobe\" && filename != \"ffmpeg\" && filename != \"ffprobe.exe\" && filename != \"ffmpeg.exe\" {\n\t\t\tcontinue\n\t\t}\n\n\t\trc, err := f.Open()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tunzippedPath := filepath.Join(s.ConfigDirectory, filename)\n\t\tunzippedOutput, err := os.Create(unzippedPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = io.Copy(unzippedOutput, rc)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := unzippedOutput.Close(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/task/migrate.go",
    "content": "package task\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\ntype migrateJobConfig interface {\n\tGetBackupDirectoryPath() string\n\tGetBackupDirectoryPathOrDefault() string\n}\n\ntype MigrateJob struct {\n\tBackupPath string\n\tConfig     migrateJobConfig\n\tDatabase   *sqlite.Database\n}\n\ntype databaseSchemaInfo struct {\n\tCurrentSchemaVersion  uint\n\tRequiredSchemaVersion uint\n\tStepsRequired         uint\n}\n\nfunc (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tschemaInfo, err := s.required()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif schemaInfo.StepsRequired == 0 {\n\t\tlogger.Infof(\"database is already at the latest schema version\")\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Migrating database from %d to %d\", schemaInfo.CurrentSchemaVersion, schemaInfo.RequiredSchemaVersion)\n\n\t// set the number of tasks = backup + required steps + optimise\n\tprogress.SetTotal(int(schemaInfo.StepsRequired + 2))\n\n\tdatabase := s.Database\n\n\t// always backup so that we can roll back to the previous version if\n\t// migration fails\n\tbackupPath := s.BackupPath\n\tif backupPath == \"\" {\n\t\tbackupPath = database.DatabaseBackupPath(s.Config.GetBackupDirectoryPath())\n\t} else {\n\t\t// check if backup path is a filename or path\n\t\t// filename goes into backup directory, path is kept as is\n\t\tfilename := filepath.Base(backupPath)\n\t\tif backupPath == filename {\n\t\t\tbackupPath = filepath.Join(s.Config.GetBackupDirectoryPathOrDefault(), filename)\n\t\t}\n\t}\n\n\tprogress.ExecuteTask(\"Backing up database\", func() {\n\t\tdefer progress.Increment()\n\n\t\t// perform database backup\n\t\terr = database.Backup(backupPath)\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error backing up database: %s\", err)\n\t}\n\n\terr = s.runMigrations(ctx, progress)\n\n\tif err != nil {\n\t\terrStr := fmt.Sprintf(\"error performing migration: %s\", err)\n\n\t\t// roll back to the backed up version\n\t\trestoreErr := database.RestoreFromBackup(backupPath)\n\t\tif restoreErr != nil {\n\t\t\terrStr = fmt.Sprintf(\"ERROR: unable to restore database from backup after migration failure: %s\\n%s\", restoreErr.Error(), errStr)\n\t\t} else {\n\t\t\terrStr = \"An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\\n\" + errStr\n\t\t}\n\n\t\treturn errors.New(errStr)\n\t}\n\n\t// if no backup path was provided, then delete the created backup\n\tif s.BackupPath == \"\" {\n\t\tif err := os.Remove(backupPath); err != nil {\n\t\t\tlogger.Warnf(\"error removing unwanted database backup (%s): %s\", backupPath, err.Error())\n\t\t}\n\t}\n\n\t// reinitialise the database\n\tif err := database.ReInitialise(); err != nil {\n\t\treturn fmt.Errorf(\"error reinitialising database: %s\", err)\n\t}\n\n\tlogger.Infof(\"Database migration complete\")\n\n\treturn nil\n}\n\nfunc (s *MigrateJob) required() (ret databaseSchemaInfo, err error) {\n\tdatabase := s.Database\n\n\tm, err := sqlite.NewMigrator(database)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tdefer m.Close()\n\n\tret.CurrentSchemaVersion = m.CurrentSchemaVersion()\n\tret.RequiredSchemaVersion = m.RequiredSchemaVersion()\n\n\tif ret.RequiredSchemaVersion < ret.CurrentSchemaVersion {\n\t\t// shouldn't happen\n\t\treturn\n\t}\n\n\tret.StepsRequired = ret.RequiredSchemaVersion - ret.CurrentSchemaVersion\n\treturn\n}\n\nfunc (s *MigrateJob) runMigrations(ctx context.Context, progress *job.Progress) error {\n\tdatabase := s.Database\n\n\tm, err := sqlite.NewMigrator(database)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer m.Close()\n\n\tlogger.Info(\"Running migrations\")\n\n\tfor {\n\t\tcurrentSchemaVersion := m.CurrentSchemaVersion()\n\t\ttargetSchemaVersion := m.RequiredSchemaVersion()\n\n\t\tif currentSchemaVersion >= targetSchemaVersion {\n\t\t\tbreak\n\t\t}\n\n\t\tvar err error\n\t\tprogress.ExecuteTask(fmt.Sprintf(\"Migrating database to schema version %d\", currentSchemaVersion+1), func() {\n\t\t\terr = m.RunMigration(ctx, currentSchemaVersion+1)\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error running migration for schema %d: %s\", currentSchemaVersion+1, err)\n\t\t}\n\n\t\tprogress.Increment()\n\t}\n\n\t// perform post-migrate analyze using the migrator connection\n\tprogress.ExecuteTask(\"Optimising database\", func() {\n\t\terr = m.PostMigrate(ctx)\n\t\tprogress.Increment()\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error optimising database: %s\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/task/migrate_blobs.go",
    "content": "package task\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype BlobStoreMigrator interface {\n\tCount(ctx context.Context) (int, error)\n\tFindBlobs(ctx context.Context, n uint, lastChecksum string) ([]string, error)\n\tMigrateBlob(ctx context.Context, checksum string, deleteOld bool) error\n}\n\ntype Vacuumer interface {\n\tVacuum(ctx context.Context) error\n}\n\ntype MigrateBlobsJob struct {\n\tTxnManager txn.Manager\n\tBlobStore  BlobStoreMigrator\n\tVacuumer   Vacuumer\n\tDeleteOld  bool\n}\n\nfunc (j *MigrateBlobsJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tvar (\n\t\tcount int\n\t\terr   error\n\t)\n\tprogress.ExecuteTask(\"Counting blobs\", func() {\n\t\tcount, err = j.countBlobs(ctx)\n\t\tprogress.SetTotal(count)\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error counting blobs: %w\", err)\n\t}\n\n\tif count == 0 {\n\t\tlogger.Infof(\"No blobs to migrate\")\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Migrating %d blobs\", count)\n\n\tprogress.ExecuteTask(fmt.Sprintf(\"Migrating %d blobs\", count), func() {\n\t\terr = j.migrateBlobs(ctx, progress)\n\t})\n\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Cancelled migrating blobs\")\n\t\treturn nil\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error migrating blobs: %w\", err)\n\t}\n\n\t// run a vacuum to reclaim space\n\tprogress.ExecuteTask(\"Vacuuming database\", func() {\n\t\terr = j.Vacuumer.Vacuum(ctx)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error vacuuming database: %v\", err)\n\t\t}\n\t})\n\n\tlogger.Infof(\"Finished migrating blobs\")\n\treturn nil\n}\n\nfunc (j *MigrateBlobsJob) countBlobs(ctx context.Context) (int, error) {\n\tvar count int\n\tif err := txn.WithReadTxn(ctx, j.TxnManager, func(ctx context.Context) error {\n\t\tvar err error\n\t\tcount, err = j.BlobStore.Count(ctx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn count, nil\n}\n\nfunc (j *MigrateBlobsJob) migrateBlobs(ctx context.Context, progress *job.Progress) error {\n\tlastChecksum := \"\"\n\tbatch, err := j.getBatch(ctx, lastChecksum)\n\n\tfor len(batch) > 0 && err == nil && ctx.Err() == nil {\n\t\tfor _, checksum := range batch {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlastChecksum = checksum\n\n\t\t\tprogress.ExecuteTask(\"Migrating blob \"+checksum, func() {\n\t\t\t\tdefer progress.Increment()\n\n\t\t\t\tif err := txn.WithTxn(ctx, j.TxnManager, func(ctx context.Context) error {\n\t\t\t\t\treturn j.BlobStore.MigrateBlob(ctx, checksum, j.DeleteOld)\n\t\t\t\t}); err != nil {\n\t\t\t\t\tlogger.Errorf(\"Error migrating blob %s: %v\", checksum, err)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\n\t\tbatch, err = j.getBatch(ctx, lastChecksum)\n\t}\n\n\treturn err\n}\n\nfunc (j *MigrateBlobsJob) getBatch(ctx context.Context, lastChecksum string) ([]string, error) {\n\tconst batchSize = 1000\n\n\tvar batch []string\n\terr := txn.WithReadTxn(ctx, j.TxnManager, func(ctx context.Context) error {\n\t\tvar err error\n\t\tbatch, err = j.BlobStore.FindBlobs(ctx, batchSize, lastChecksum)\n\t\treturn err\n\t})\n\n\treturn batch, err\n}\n"
  },
  {
    "path": "internal/manager/task/migrate_scene_screenshots.go",
    "content": "package task\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype MigrateSceneScreenshotsJob struct {\n\tScreenshotsPath string\n\tInput           scene.MigrateSceneScreenshotsInput\n\tSceneRepo       scene.HashFinderCoverUpdater\n\tTxnManager      txn.Manager\n}\n\nfunc (j *MigrateSceneScreenshotsJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tvar err error\n\tprogress.ExecuteTask(\"Counting files\", func() {\n\t\tvar count int\n\t\tcount, err = j.countFiles(ctx)\n\t\tprogress.SetTotal(count)\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error counting files: %w\", err)\n\t}\n\n\tprogress.ExecuteTask(\"Migrating files\", func() {\n\t\terr = j.migrateFiles(ctx, progress)\n\t})\n\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Cancelled migrating scene screenshots\")\n\t\treturn nil\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error migrating scene screenshots: %w\", err)\n\t}\n\n\tlogger.Infof(\"Finished migrating scene screenshots\")\n\treturn nil\n}\n\nfunc (j *MigrateSceneScreenshotsJob) countFiles(ctx context.Context) (int, error) {\n\tf, err := os.Open(j.ScreenshotsPath)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer f.Close()\n\n\tconst batchSize = 1000\n\tret := 0\n\tfiles, err := f.ReadDir(batchSize)\n\tfor err == nil && ctx.Err() == nil {\n\t\tret += len(files)\n\n\t\tfiles, err = f.ReadDir(batchSize)\n\t}\n\n\tif errors.Is(err, io.EOF) {\n\t\t// end of directory\n\t\treturn ret, nil\n\t}\n\n\treturn 0, err\n}\n\nfunc (j *MigrateSceneScreenshotsJob) migrateFiles(ctx context.Context, progress *job.Progress) error {\n\tf, err := os.Open(j.ScreenshotsPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tm := scene.ScreenshotMigrator{\n\t\tOptions:      j.Input,\n\t\tSceneUpdater: j.SceneRepo,\n\t\tTxnManager:   j.TxnManager,\n\t}\n\n\tconst batchSize = 1000\n\tfiles, err := f.ReadDir(batchSize)\n\tfor err == nil && ctx.Err() == nil {\n\t\tfor _, f := range files {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tprogress.ExecuteTask(\"Migrating file \"+f.Name(), func() {\n\t\t\t\tdefer progress.Increment()\n\n\t\t\t\tpath := filepath.Join(j.ScreenshotsPath, f.Name())\n\n\t\t\t\t// sanity check - only process files\n\t\t\t\tif f.IsDir() {\n\t\t\t\t\tlogger.Warnf(\"Skipping directory %s\", path)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// ignore non-jpg files\n\t\t\t\tif !strings.HasSuffix(f.Name(), \".jpg\") {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// ignore .thumb files\n\t\t\t\tif strings.HasSuffix(f.Name(), \".thumb.jpg\") {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif err := m.MigrateScreenshots(ctx, path); err != nil {\n\t\t\t\t\tlogger.Errorf(\"Error migrating screenshots for %s: %v\", path, err)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\n\t\tfiles, err = f.ReadDir(batchSize)\n\t}\n\n\tif errors.Is(err, io.EOF) {\n\t\t// end of directory\n\t\treturn nil\n\t}\n\n\treturn err\n}\n"
  },
  {
    "path": "internal/manager/task/packages.go",
    "content": "package task\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/pkg\"\n)\n\ntype PackagesJob struct {\n\tPackageManager *pkg.Manager\n\tOnComplete     func()\n}\n\nfunc (j *PackagesJob) installPackage(ctx context.Context, p models.PackageSpecInput, progress *job.Progress) error {\n\tdefer progress.Increment()\n\n\tif err := j.PackageManager.Install(ctx, p); err != nil {\n\t\treturn fmt.Errorf(\"installing package: %w\", err)\n\t}\n\n\treturn nil\n}\n\ntype InstallPackagesJob struct {\n\tPackagesJob\n\tPackages []*models.PackageSpecInput\n}\n\nfunc (j *InstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tprogress.SetTotal(len(j.Packages))\n\n\tfor _, p := range j.Packages {\n\t\tif job.IsCancelled(ctx) {\n\t\t\tlogger.Info(\"Cancelled installing packages\")\n\t\t\treturn nil\n\t\t}\n\n\t\tlogger.Infof(\"Installing package %s\", p.ID)\n\t\ttaskDesc := fmt.Sprintf(\"Installing %s\", p.ID)\n\t\tprogress.ExecuteTask(taskDesc, func() {\n\t\t\tif err := j.installPackage(ctx, *p, progress); err != nil {\n\t\t\t\tlogger.Errorf(\"Error installing package %s from %s: %v\", p.ID, p.SourceURL, err)\n\t\t\t}\n\t\t})\n\t}\n\n\tif j.OnComplete != nil {\n\t\tj.OnComplete()\n\t}\n\n\tlogger.Infof(\"Finished installing packages\")\n\treturn nil\n}\n\ntype UpdatePackagesJob struct {\n\tPackagesJob\n\tPackages []*models.PackageSpecInput\n}\n\nfunc (j *UpdatePackagesJob) Execute(ctx context.Context, progress *job.Progress) error {\n\t// if no packages are specified, update all\n\tif len(j.Packages) == 0 {\n\t\tinstalled, err := j.PackageManager.InstalledStatus(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting installed packages: %w\", err)\n\t\t}\n\n\t\tfor _, p := range installed {\n\t\t\tif p.Upgradable() {\n\t\t\t\tj.Packages = append(j.Packages, &models.PackageSpecInput{\n\t\t\t\t\tID:        p.Local.ID,\n\t\t\t\t\tSourceURL: p.Remote.Repository.Path(),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\tprogress.SetTotal(len(j.Packages))\n\n\tfor _, p := range j.Packages {\n\t\tif job.IsCancelled(ctx) {\n\t\t\tlogger.Info(\"Cancelled updating packages\")\n\t\t\treturn nil\n\t\t}\n\n\t\tlogger.Infof(\"Updating package %s\", p.ID)\n\t\ttaskDesc := fmt.Sprintf(\"Updating %s\", p.ID)\n\t\tprogress.ExecuteTask(taskDesc, func() {\n\t\t\tif err := j.installPackage(ctx, *p, progress); err != nil {\n\t\t\t\tlogger.Errorf(\"Error updating package %s from %s: %v\", p.ID, p.SourceURL, err)\n\t\t\t}\n\t\t})\n\t}\n\n\tif j.OnComplete != nil {\n\t\tj.OnComplete()\n\t}\n\n\tlogger.Infof(\"Finished updating packages\")\n\treturn nil\n}\n\ntype UninstallPackagesJob struct {\n\tPackagesJob\n\tPackages []*models.PackageSpecInput\n}\n\nfunc (j *UninstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tprogress.SetTotal(len(j.Packages))\n\n\tfor _, p := range j.Packages {\n\t\tif job.IsCancelled(ctx) {\n\t\t\tlogger.Info(\"Cancelled installing packages\")\n\t\t\treturn nil\n\t\t}\n\n\t\tlogger.Infof(\"Uninstalling package %s\", p.ID)\n\t\ttaskDesc := fmt.Sprintf(\"Uninstalling %s\", p.ID)\n\t\tprogress.ExecuteTask(taskDesc, func() {\n\t\t\tif err := j.PackageManager.Uninstall(ctx, *p); err != nil {\n\t\t\t\tlogger.Errorf(\"Error uninstalling package %s: %v\", p.ID, err)\n\t\t\t}\n\t\t})\n\t}\n\n\tif j.OnComplete != nil {\n\t\tj.OnComplete()\n\t}\n\n\tlogger.Infof(\"Finished uninstalling packages\")\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/task.go",
    "content": "package manager\n\nimport \"context\"\n\ntype Task interface {\n\tStart(context.Context)\n\tGetDescription() string\n}\n"
  },
  {
    "path": "internal/manager/task_autotag.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/autotag\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\ntype autoTagJob struct {\n\trepository models.Repository\n\tinput      AutoTagMetadataInput\n\n\tcache match.Cache\n}\n\nfunc (j *autoTagJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tbegin := time.Now()\n\n\tinput := j.input\n\tif j.isFileBasedAutoTag(input) {\n\t\t// doing file-based auto-tag\n\t\tj.autoTagFiles(ctx, progress, input.Paths, len(input.Performers) > 0, len(input.Studios) > 0, len(input.Tags) > 0)\n\t} else {\n\t\t// doing specific performer/studio/tag auto-tag\n\t\tj.autoTagSpecific(ctx, progress)\n\t}\n\n\tlogger.Infof(\"Finished auto-tag after %s\", time.Since(begin).String())\n\treturn nil\n}\n\nfunc (j *autoTagJob) isFileBasedAutoTag(input AutoTagMetadataInput) bool {\n\tconst wildcard = \"*\"\n\tperformerIds := input.Performers\n\tstudioIds := input.Studios\n\ttagIds := input.Tags\n\n\treturn (len(performerIds) == 0 || performerIds[0] == wildcard) && (len(studioIds) == 0 || studioIds[0] == wildcard) && (len(tagIds) == 0 || tagIds[0] == wildcard)\n}\n\nfunc (j *autoTagJob) autoTagFiles(ctx context.Context, progress *job.Progress, paths []string, performers, studios, tags bool) {\n\tt := autoTagFilesTask{\n\t\tpaths:      paths,\n\t\tperformers: performers,\n\t\tstudios:    studios,\n\t\ttags:       tags,\n\t\tprogress:   progress,\n\t\trepository: j.repository,\n\t\tcache:      &j.cache,\n\t}\n\n\tt.process(ctx)\n}\n\nfunc (j *autoTagJob) autoTagSpecific(ctx context.Context, progress *job.Progress) {\n\tinput := j.input\n\tperformerIds := input.Performers\n\tstudioIds := input.Studios\n\ttagIds := input.Tags\n\n\tperformerCount := len(performerIds)\n\tstudioCount := len(studioIds)\n\ttagCount := len(tagIds)\n\n\tr := j.repository\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tperformerQuery := r.Performer\n\t\tstudioQuery := r.Studio\n\t\ttagQuery := r.Tag\n\n\t\tconst wildcard = \"*\"\n\t\tvar err error\n\t\tif performerCount == 1 && performerIds[0] == wildcard {\n\t\t\tperformerCount, err = performerQuery.Count(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting performer count: %v\", err)\n\t\t\t}\n\t\t}\n\t\tif studioCount == 1 && studioIds[0] == wildcard {\n\t\t\tstudioCount, err = studioQuery.Count(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting studio count: %v\", err)\n\t\t\t}\n\t\t}\n\t\tif tagCount == 1 && tagIds[0] == wildcard {\n\t\t\ttagCount, err = tagQuery.Count(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting tag count: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tif !job.IsCancelled(ctx) {\n\t\t\tlogger.Errorf(\"auto-tag error: %v\", err)\n\t\t}\n\t\treturn\n\t}\n\n\ttotal := performerCount + studioCount + tagCount\n\tprogress.SetTotal(total)\n\n\tlogger.Infof(\"Starting auto-tag of %d performers, %d studios, %d tags\", performerCount, studioCount, tagCount)\n\n\tj.autoTagPerformers(ctx, progress, input.Paths, performerIds)\n\tj.autoTagStudios(ctx, progress, input.Paths, studioIds)\n\tj.autoTagTags(ctx, progress, input.Paths, tagIds)\n}\n\nfunc (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progress, paths []string, performerIds []string) {\n\tif job.IsCancelled(ctx) {\n\t\treturn\n\t}\n\n\tr := j.repository\n\ttagger := autotag.Tagger{\n\t\tTxnManager: r.TxnManager,\n\t\tCache:      &j.cache,\n\t}\n\n\tfor _, performerId := range performerIds {\n\t\tvar performers []*models.Performer\n\n\t\tif err := r.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tperformerQuery := r.Performer\n\t\t\tignoreAutoTag := false\n\t\t\tperPage := -1\n\n\t\t\tif performerId == \"*\" {\n\t\t\t\tvar err error\n\t\t\t\tperformers, _, err = performerQuery.Query(ctx, &models.PerformerFilterType{\n\t\t\t\t\tIgnoreAutoTag: &ignoreAutoTag,\n\t\t\t\t}, &models.FindFilterType{\n\t\t\t\t\tPerPage: &perPage,\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"querying performers: %w\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tperformerIdInt, err := strconv.Atoi(performerId)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"parsing performer id %s: %w\", performerId, err)\n\t\t\t\t}\n\n\t\t\t\tperformer, err := performerQuery.Find(ctx, performerIdInt)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"finding performer id %s: %w\", performerId, err)\n\t\t\t\t}\n\n\t\t\t\tif performer == nil {\n\t\t\t\t\treturn fmt.Errorf(\"performer with id %s not found\", performerId)\n\t\t\t\t}\n\n\t\t\t\tif performer.IgnoreAutoTag {\n\t\t\t\t\tlogger.Infof(\"Skipping performer %s because auto-tag is disabled\", performer.Name)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tif err := performer.LoadAliases(ctx, r.Performer); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"loading aliases for performer %d: %w\", performer.ID, err)\n\t\t\t\t}\n\t\t\t\tperformers = append(performers, performer)\n\t\t\t}\n\n\t\t\tfor _, performer := range performers {\n\t\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\terr := func() error {\n\t\t\t\t\tif err := tagger.PerformerScenes(ctx, performer, paths, r.Scene); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing scenes: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tif err := tagger.PerformerImages(ctx, performer, paths, r.Image); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing images: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tif err := tagger.PerformerGalleries(ctx, performer, paths, r.Gallery); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing galleries: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\treturn nil\n\t\t\t\t}()\n\n\t\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"tagging performer '%s': %s\", performer.Name, err.Error())\n\t\t\t\t}\n\n\t\t\t\tprogress.Increment()\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"auto-tag error: %v\", err)\n\t\t}\n\n\t\tif job.IsCancelled(ctx) {\n\t\t\tlogger.Info(\"Stopping performer auto-tag due to user request\")\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, paths []string, studioIds []string) {\n\tif job.IsCancelled(ctx) {\n\t\treturn\n\t}\n\n\tr := j.repository\n\ttagger := autotag.Tagger{\n\t\tTxnManager: r.TxnManager,\n\t\tCache:      &j.cache,\n\t}\n\n\tfor _, studioId := range studioIds {\n\t\tvar studios []*models.Studio\n\n\t\tif err := r.WithDB(ctx, func(ctx context.Context) error {\n\t\t\tstudioQuery := r.Studio\n\t\t\tignoreAutoTag := false\n\t\t\tperPage := -1\n\t\t\tif studioId == \"*\" {\n\t\t\t\tvar err error\n\t\t\t\tstudios, _, err = studioQuery.Query(ctx, &models.StudioFilterType{\n\t\t\t\t\tIgnoreAutoTag: &ignoreAutoTag,\n\t\t\t\t}, &models.FindFilterType{\n\t\t\t\t\tPerPage: &perPage,\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"querying studios: %v\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tstudioIdInt, err := strconv.Atoi(studioId)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"parsing studio id %s: %s\", studioId, err.Error())\n\t\t\t\t}\n\n\t\t\t\tstudio, err := studioQuery.Find(ctx, studioIdInt)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"finding studio id %s: %s\", studioId, err.Error())\n\t\t\t\t}\n\n\t\t\t\tif studio == nil {\n\t\t\t\t\treturn fmt.Errorf(\"studio with id %s not found\", studioId)\n\t\t\t\t}\n\n\t\t\t\tif studio.IgnoreAutoTag {\n\t\t\t\t\tlogger.Infof(\"Skipping studio %s because auto-tag is disabled\", studio.Name)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tstudios = append(studios, studio)\n\t\t\t}\n\n\t\t\tfor _, studio := range studios {\n\t\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\terr := func() error {\n\t\t\t\t\taliases, err := r.Studio.GetAliases(ctx, studio.ID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"getting studio aliases: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\tif err := tagger.StudioScenes(ctx, studio, paths, aliases, r.Scene); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing scenes: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tif err := tagger.StudioImages(ctx, studio, paths, aliases, r.Image); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing images: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tif err := tagger.StudioGalleries(ctx, studio, paths, aliases, r.Gallery); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing galleries: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\treturn nil\n\t\t\t\t}()\n\n\t\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"tagging studio '%s': %s\", studio.Name, err.Error())\n\t\t\t\t}\n\n\t\t\t\tprogress.Increment()\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"auto-tag error: %v\", err)\n\t\t}\n\n\t\tif job.IsCancelled(ctx) {\n\t\t\tlogger.Info(\"Stopping studio auto-tag due to user request\")\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, paths []string, tagIds []string) {\n\tif job.IsCancelled(ctx) {\n\t\treturn\n\t}\n\n\tr := j.repository\n\ttagger := autotag.Tagger{\n\t\tTxnManager: r.TxnManager,\n\t\tCache:      &j.cache,\n\t}\n\n\tfor _, tagId := range tagIds {\n\t\tvar tags []*models.Tag\n\t\tif err := r.WithDB(ctx, func(ctx context.Context) error {\n\t\t\ttagQuery := r.Tag\n\t\t\tignoreAutoTag := false\n\t\t\tperPage := -1\n\t\t\tif tagId == \"*\" {\n\t\t\t\tvar err error\n\t\t\t\ttags, _, err = tagQuery.Query(ctx, &models.TagFilterType{\n\t\t\t\t\tIgnoreAutoTag: &ignoreAutoTag,\n\t\t\t\t}, &models.FindFilterType{\n\t\t\t\t\tPerPage: &perPage,\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"querying tags: %v\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ttagIdInt, err := strconv.Atoi(tagId)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"parsing tag id %s: %s\", tagId, err.Error())\n\t\t\t\t}\n\n\t\t\t\ttag, err := tagQuery.Find(ctx, tagIdInt)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"finding tag id %s: %s\", tagId, err.Error())\n\t\t\t\t}\n\n\t\t\t\tif tag == nil {\n\t\t\t\t\treturn fmt.Errorf(\"tag with id %s not found\", tagId)\n\t\t\t\t}\n\n\t\t\t\tif tag.IgnoreAutoTag {\n\t\t\t\t\tlogger.Infof(\"Skipping tag %s because auto-tag is disabled\", tag.Name)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\ttags = append(tags, tag)\n\t\t\t}\n\n\t\t\tfor _, tag := range tags {\n\t\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\terr := func() error {\n\t\t\t\t\taliases, err := r.Tag.GetAliases(ctx, tag.ID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"getting tag aliases: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\tif err := tagger.TagScenes(ctx, tag, paths, aliases, r.Scene); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing scenes: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tif err := tagger.TagImages(ctx, tag, paths, aliases, r.Image); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing images: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tif err := tagger.TagGalleries(ctx, tag, paths, aliases, r.Gallery); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"processing galleries: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\treturn nil\n\t\t\t\t}()\n\n\t\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"tagging tag '%s': %s\", tag.Name, err.Error())\n\t\t\t\t}\n\n\t\t\t\tprogress.Increment()\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"auto-tag error: %v\", err)\n\t\t}\n\n\t\tif job.IsCancelled(ctx) {\n\t\t\tlogger.Info(\"Stopping tag auto-tag due to user request\")\n\t\t\treturn\n\t\t}\n\t}\n}\n\ntype autoTagFilesTask struct {\n\tpaths      []string\n\tperformers bool\n\tstudios    bool\n\ttags       bool\n\n\tprogress   *job.Progress\n\trepository models.Repository\n\tcache      *match.Cache\n}\n\nfunc (t *autoTagFilesTask) makeSceneFilter() *models.SceneFilterType {\n\tret := scene.FilterFromPaths(t.paths)\n\n\torganized := false\n\tret.Organized = &organized\n\n\treturn ret\n}\n\nfunc (t *autoTagFilesTask) makeImageFilter() *models.ImageFilterType {\n\tret := &models.ImageFilterType{}\n\tor := ret\n\tsep := string(filepath.Separator)\n\n\tfor _, p := range t.paths {\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tif ret.Path == nil {\n\t\t\tor = ret\n\t\t} else {\n\t\t\tnewOr := &models.ImageFilterType{}\n\t\t\tor.Or = newOr\n\t\t\tor = newOr\n\t\t}\n\n\t\tor.Path = &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\torganized := false\n\tret.Organized = &organized\n\n\treturn ret\n}\n\nfunc (t *autoTagFilesTask) makeGalleryFilter() *models.GalleryFilterType {\n\tret := &models.GalleryFilterType{}\n\n\tor := ret\n\tsep := string(filepath.Separator)\n\n\tif len(t.paths) == 0 {\n\t\tret.Path = &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t}\n\t}\n\n\tfor _, p := range t.paths {\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tif ret.Path == nil {\n\t\t\tor = ret\n\t\t} else {\n\t\t\tnewOr := &models.GalleryFilterType{}\n\t\t\tor.Or = newOr\n\t\t\tor = newOr\n\t\t}\n\n\t\tor.Path = &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\torganized := false\n\tret.Organized = &organized\n\n\treturn ret\n}\n\nfunc (t *autoTagFilesTask) getCount(ctx context.Context) (int, error) {\n\tr := t.repository\n\n\tpp := 0\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\tsceneResults, err := r.Scene.Query(ctx, models.SceneQueryOptions{\n\t\tQueryOptions: models.QueryOptions{\n\t\t\tFindFilter: findFilter,\n\t\t\tCount:      true,\n\t\t},\n\t\tSceneFilter: t.makeSceneFilter(),\n\t})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"getting scene count: %w\", err)\n\t}\n\n\tsceneCount := sceneResults.Count\n\n\timageResults, err := r.Image.Query(ctx, models.ImageQueryOptions{\n\t\tQueryOptions: models.QueryOptions{\n\t\t\tFindFilter: findFilter,\n\t\t\tCount:      true,\n\t\t},\n\t\tImageFilter: t.makeImageFilter(),\n\t})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"getting image count: %w\", err)\n\t}\n\n\timageCount := imageResults.Count\n\n\t_, galleryCount, err := r.Gallery.Query(ctx, t.makeGalleryFilter(), findFilter)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"getting gallery count: %w\", err)\n\t}\n\n\treturn sceneCount + imageCount + galleryCount, nil\n}\n\nfunc (t *autoTagFilesTask) processScenes(ctx context.Context) {\n\tif job.IsCancelled(ctx) {\n\t\treturn\n\t}\n\n\tlogger.Info(\"Auto-tagging scenes...\")\n\n\tbatchSize := 1000\n\n\tfindFilter := models.BatchFindFilter(batchSize)\n\tsceneFilter := t.makeSceneFilter()\n\n\tr := t.repository\n\n\tmore := true\n\tfor more {\n\t\tvar scenes []*models.Scene\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tscenes, err = scene.Query(ctx, r.Scene, sceneFilter, findFilter)\n\t\t\treturn err\n\t\t}); err != nil {\n\t\t\tif !job.IsCancelled(ctx) {\n\t\t\t\tlogger.Errorf(\"error querying scenes for auto-tag: %w\", err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tfor _, ss := range scenes {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\tlogger.Info(\"Stopping auto-tag due to user request\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttt := autoTagSceneTask{\n\t\t\t\trepository: r,\n\t\t\t\tscene:      ss,\n\t\t\t\tperformers: t.performers,\n\t\t\t\tstudios:    t.studios,\n\t\t\t\ttags:       t.tags,\n\t\t\t\tcache:      t.cache,\n\t\t\t}\n\n\t\t\tvar wg sync.WaitGroup\n\t\t\twg.Add(1)\n\t\t\tgo tt.Start(ctx, &wg)\n\t\t\twg.Wait()\n\n\t\t\tt.progress.Increment()\n\t\t}\n\n\t\tif len(scenes) != batchSize {\n\t\t\tmore = false\n\t\t} else {\n\t\t\t*findFilter.Page++\n\n\t\t\tif *findFilter.Page%10 == 1 {\n\t\t\t\tlogger.Infof(\"Processed %d scenes...\", (*findFilter.Page-1)*batchSize)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (t *autoTagFilesTask) processImages(ctx context.Context) {\n\tif job.IsCancelled(ctx) {\n\t\treturn\n\t}\n\n\tlogger.Info(\"Auto-tagging images...\")\n\n\tbatchSize := 1000\n\n\tfindFilter := models.BatchFindFilter(batchSize)\n\timageFilter := t.makeImageFilter()\n\n\tr := t.repository\n\n\tmore := true\n\tfor more {\n\t\tvar images []*models.Image\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\timages, err = image.Query(ctx, r.Image, imageFilter, findFilter)\n\t\t\treturn err\n\t\t}); err != nil {\n\t\t\tif !job.IsCancelled(ctx) {\n\t\t\t\tlogger.Errorf(\"error querying images for auto-tag: %w\", err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tfor _, ss := range images {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\tlogger.Info(\"Stopping auto-tag due to user request\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttt := autoTagImageTask{\n\t\t\t\trepository: t.repository,\n\t\t\t\timage:      ss,\n\t\t\t\tperformers: t.performers,\n\t\t\t\tstudios:    t.studios,\n\t\t\t\ttags:       t.tags,\n\t\t\t\tcache:      t.cache,\n\t\t\t}\n\n\t\t\tvar wg sync.WaitGroup\n\t\t\twg.Add(1)\n\t\t\tgo tt.Start(ctx, &wg)\n\t\t\twg.Wait()\n\n\t\t\tt.progress.Increment()\n\t\t}\n\n\t\tif len(images) != batchSize {\n\t\t\tmore = false\n\t\t} else {\n\t\t\t*findFilter.Page++\n\n\t\t\tif *findFilter.Page%10 == 1 {\n\t\t\t\tlogger.Infof(\"Processed %d images...\", (*findFilter.Page-1)*batchSize)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (t *autoTagFilesTask) processGalleries(ctx context.Context) {\n\tif job.IsCancelled(ctx) {\n\t\treturn\n\t}\n\n\tlogger.Info(\"Auto-tagging galleries...\")\n\n\tbatchSize := 1000\n\n\tfindFilter := models.BatchFindFilter(batchSize)\n\tgalleryFilter := t.makeGalleryFilter()\n\n\tr := t.repository\n\n\tmore := true\n\tfor more {\n\t\tvar galleries []*models.Gallery\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tgalleries, _, err = r.Gallery.Query(ctx, galleryFilter, findFilter)\n\t\t\treturn err\n\t\t}); err != nil {\n\t\t\tif !job.IsCancelled(ctx) {\n\t\t\t\tlogger.Errorf(\"error querying galleries for auto-tag: %w\", err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tfor _, ss := range galleries {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\tlogger.Info(\"Stopping auto-tag due to user request\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttt := autoTagGalleryTask{\n\t\t\t\trepository: t.repository,\n\t\t\t\tgallery:    ss,\n\t\t\t\tperformers: t.performers,\n\t\t\t\tstudios:    t.studios,\n\t\t\t\ttags:       t.tags,\n\t\t\t\tcache:      t.cache,\n\t\t\t}\n\n\t\t\tvar wg sync.WaitGroup\n\t\t\twg.Add(1)\n\t\t\tgo tt.Start(ctx, &wg)\n\t\t\twg.Wait()\n\n\t\t\tt.progress.Increment()\n\t\t}\n\n\t\tif len(galleries) != batchSize {\n\t\t\tmore = false\n\t\t} else {\n\t\t\t*findFilter.Page++\n\n\t\t\tif *findFilter.Page%10 == 1 {\n\t\t\t\tlogger.Infof(\"Processed %d galleries...\", (*findFilter.Page-1)*batchSize)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (t *autoTagFilesTask) process(ctx context.Context) {\n\tif err := t.repository.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\ttotal, err := t.getCount(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tt.progress.SetTotal(total)\n\t\tlogger.Infof(\"Starting auto-tag of %d files\", total)\n\n\t\treturn nil\n\t}); err != nil {\n\t\tif !job.IsCancelled(ctx) {\n\t\t\tlogger.Errorf(\"error getting file count for auto-tag task: %v\", err)\n\t\t}\n\t\treturn\n\t}\n\n\tt.processScenes(ctx)\n\tt.processImages(ctx)\n\tt.processGalleries(ctx)\n}\n\ntype autoTagSceneTask struct {\n\trepository models.Repository\n\tscene      *models.Scene\n\n\tperformers bool\n\tstudios    bool\n\ttags       bool\n\n\tcache *match.Cache\n}\n\nfunc (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) {\n\tdefer wg.Done()\n\tr := t.repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tif t.scene.Path == \"\" {\n\t\t\t// nothing to do\n\t\t\treturn nil\n\t\t}\n\n\t\tif t.performers {\n\t\t\tif err := autotag.ScenePerformers(ctx, t.scene, r.Scene, r.Performer, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging scene performers for %s: %v\", t.scene.DisplayName(), err)\n\t\t\t}\n\t\t}\n\t\tif t.studios {\n\t\t\tif err := autotag.SceneStudios(ctx, t.scene, r.Scene, r.Studio, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging scene studio for %s: %v\", t.scene.DisplayName(), err)\n\t\t\t}\n\t\t}\n\t\tif t.tags {\n\t\t\tif err := autotag.SceneTags(ctx, t.scene, r.Scene, r.Tag, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging scene tags for %s: %v\", t.scene.DisplayName(), err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tif !job.IsCancelled(ctx) {\n\t\t\tlogger.Errorf(\"auto-tag error: %v\", err)\n\t\t}\n\t}\n}\n\ntype autoTagImageTask struct {\n\trepository models.Repository\n\timage      *models.Image\n\n\tperformers bool\n\tstudios    bool\n\ttags       bool\n\n\tcache *match.Cache\n}\n\nfunc (t *autoTagImageTask) Start(ctx context.Context, wg *sync.WaitGroup) {\n\tdefer wg.Done()\n\tr := t.repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tif t.performers {\n\t\t\tif err := autotag.ImagePerformers(ctx, t.image, r.Image, r.Performer, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging image performers for %s: %v\", t.image.DisplayName(), err)\n\t\t\t}\n\t\t}\n\t\tif t.studios {\n\t\t\tif err := autotag.ImageStudios(ctx, t.image, r.Image, r.Studio, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging image studio for %s: %v\", t.image.DisplayName(), err)\n\t\t\t}\n\t\t}\n\t\tif t.tags {\n\t\t\tif err := autotag.ImageTags(ctx, t.image, r.Image, r.Tag, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging image tags for %s: %v\", t.image.DisplayName(), err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tif !job.IsCancelled(ctx) {\n\t\t\tlogger.Errorf(\"auto-tag error: %v\", err)\n\t\t}\n\t}\n}\n\ntype autoTagGalleryTask struct {\n\trepository models.Repository\n\tgallery    *models.Gallery\n\n\tperformers bool\n\tstudios    bool\n\ttags       bool\n\n\tcache *match.Cache\n}\n\nfunc (t *autoTagGalleryTask) Start(ctx context.Context, wg *sync.WaitGroup) {\n\tdefer wg.Done()\n\tr := t.repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tif t.performers {\n\t\t\tif err := autotag.GalleryPerformers(ctx, t.gallery, r.Gallery, r.Performer, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging gallery performers for %s: %v\", t.gallery.DisplayName(), err)\n\t\t\t}\n\t\t}\n\t\tif t.studios {\n\t\t\tif err := autotag.GalleryStudios(ctx, t.gallery, r.Gallery, r.Studio, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging gallery studio for %s: %v\", t.gallery.DisplayName(), err)\n\t\t\t}\n\t\t}\n\t\tif t.tags {\n\t\t\tif err := autotag.GalleryTags(ctx, t.gallery, r.Gallery, r.Tag, t.cache); err != nil {\n\t\t\t\treturn fmt.Errorf(\"tagging gallery tags for %s: %v\", t.gallery.DisplayName(), err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tif !job.IsCancelled(ctx) {\n\t\t\tlogger.Errorf(\"auto-tag error: %v\", err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/manager/task_clean.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\ntype cleaner interface {\n\tClean(ctx context.Context, options file.CleanOptions, progress *job.Progress)\n}\n\ntype cleanJob struct {\n\tcleaner      cleaner\n\trepository   models.Repository\n\tinput        CleanMetadataInput\n\tsceneService SceneService\n\timageService ImageService\n\tscanSubs     *subscriptionManager\n}\n\nfunc (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tlogger.Infof(\"Starting cleaning of tracked files\")\n\tstart := time.Now()\n\tif j.input.DryRun {\n\t\tlogger.Infof(\"Running in Dry Mode\")\n\t}\n\n\tj.cleaner.Clean(ctx, file.CleanOptions{\n\t\tPaths:                 j.input.Paths,\n\t\tDryRun:                j.input.DryRun,\n\t\tIgnoreZipFileContents: j.input.IgnoreZipFileContents,\n\t\tPathFilter:            newCleanFilter(instance.Config),\n\t}, progress)\n\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Stopping due to user request\")\n\t\treturn nil\n\t}\n\n\tj.cleanEmptyGalleries(ctx)\n\n\tj.scanSubs.notify()\n\telapsed := time.Since(start)\n\tlogger.Info(fmt.Sprintf(\"Finished Cleaning (%s)\", elapsed))\n\treturn nil\n}\n\nfunc (j *cleanJob) cleanEmptyGalleries(ctx context.Context) {\n\tconst batchSize = 1000\n\tvar toClean []int\n\tfindFilter := models.BatchFindFilter(batchSize)\n\tr := j.repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tfound := true\n\t\tfor found {\n\t\t\temptyGalleries, _, err := r.Gallery.Query(ctx, &models.GalleryFilterType{\n\t\t\t\tImageCount: &models.IntCriterionInput{\n\t\t\t\t\tValue:    0,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t}, findFilter)\n\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tfound = len(emptyGalleries) > 0\n\n\t\t\tfor _, g := range emptyGalleries {\n\t\t\t\tif g.Path == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif len(j.input.Paths) > 0 && !fsutil.IsPathInDirs(j.input.Paths, g.Path) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tlogger.Infof(\"Gallery has 0 images. Marking to clean: %s\", g.DisplayName())\n\t\t\t\ttoClean = append(toClean, g.ID)\n\t\t\t}\n\n\t\t\t*findFilter.Page++\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Errorf(\"Error finding empty galleries: %v\", err)\n\t\treturn\n\t}\n\n\tif !j.input.DryRun {\n\t\tfor _, id := range toClean {\n\t\t\tj.deleteGallery(ctx, id)\n\t\t}\n\t}\n}\n\nfunc (j *cleanJob) deleteGallery(ctx context.Context, id int) {\n\tpluginCache := GetInstance().PluginCache\n\n\tr := j.repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.Gallery\n\t\tg, err := qb.Find(ctx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif g == nil {\n\t\t\treturn fmt.Errorf(\"gallery with id %d not found\", id)\n\t\t}\n\n\t\tif err := g.LoadPrimaryFile(ctx, r.File); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := qb.Destroy(ctx, id); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tpluginCache.RegisterPostHooks(ctx, id, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{\n\t\t\tChecksum: g.PrimaryChecksum(),\n\t\t\tPath:     g.Path,\n\t\t}, nil)\n\n\t\treturn nil\n\t}); err != nil {\n\t\tlogger.Errorf(\"Error deleting gallery from database: %s\", err.Error())\n\t}\n}\n\ntype cleanFilter struct {\n\tscanFilter\n}\n\nfunc newCleanFilter(c *config.Config) *cleanFilter {\n\treturn &cleanFilter{\n\t\tscanFilter: scanFilter{\n\t\t\textensionConfig:   newExtensionConfig(c),\n\t\t\tstashPaths:        c.GetStashPaths(),\n\t\t\tgeneratedPath:     c.GetGeneratedPath(),\n\t\t\tvideoExcludeRegex: generateRegexps(c.GetExcludes()),\n\t\t\timageExcludeRegex: generateRegexps(c.GetImageExcludes()),\n\t\t\tstashIgnoreFilter: file.NewStashIgnoreFilter(),\n\t\t},\n\t}\n}\n\nfunc (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {\n\t//  #1102 - clean anything in generated path\n\tgeneratedPath := f.generatedPath\n\n\tvar stash *config.StashConfig\n\tfileOrFolder := \"File\"\n\n\tif info.IsDir() {\n\t\tfileOrFolder = \"Folder\"\n\t\tstash = f.stashPaths.GetStashFromDirPath(path)\n\t} else {\n\t\tstash = f.stashPaths.GetStashFromPath(path)\n\t}\n\n\tif stash == nil {\n\t\tlogger.Infof(\"%s not in any stash library directories. Marking to clean: %q\", fileOrFolder, path)\n\t\treturn false\n\t}\n\n\tif fsutil.IsPathInDir(generatedPath, path) {\n\t\tlogger.Infof(\"%s is in generated path. Marking to clean: %q\", fileOrFolder, path)\n\t\treturn false\n\t}\n\n\t// Check .stashignore files, bounded to the library root.\n\tif !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path, zipFilePath) {\n\t\tlogger.Infof(\"%s is excluded due to .stashignore. Marking to clean: %q\", fileOrFolder, path)\n\t\treturn false\n\t}\n\n\tif info.IsDir() {\n\t\treturn !f.shouldCleanFolder(path, stash)\n\t}\n\n\treturn !f.shouldCleanFile(path, info, stash)\n}\n\nfunc (f *cleanFilter) shouldCleanFolder(path string, s *config.StashConfig) bool {\n\t// only delete folders where it is excluded from everything\n\tpathExcludeTest := path + string(filepath.Separator)\n\tif (s.ExcludeVideo || matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) {\n\t\tlogger.Infof(\"Folder is excluded from both video and image. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc (f *cleanFilter) shouldCleanFile(path string, info fs.FileInfo, stash *config.StashConfig) bool {\n\tswitch {\n\tcase info.IsDir() || fsutil.MatchExtension(path, f.zipExt):\n\t\treturn f.shouldCleanGallery(path, stash)\n\tcase useAsVideo(path):\n\t\treturn f.shouldCleanVideoFile(path, stash)\n\tcase useAsImage(path):\n\t\treturn f.shouldCleanImage(path, stash)\n\tdefault:\n\t\tlogger.Infof(\"File extension does not match any media extensions. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n}\n\nfunc (f *cleanFilter) shouldCleanVideoFile(path string, stash *config.StashConfig) bool {\n\tif stash.ExcludeVideo {\n\t\tlogger.Infof(\"File in stash library that excludes video. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\tif matchFileRegex(path, f.videoExcludeRegex) {\n\t\tlogger.Infof(\"File matched regex. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc (f *cleanFilter) shouldCleanGallery(path string, stash *config.StashConfig) bool {\n\tif stash.ExcludeImage {\n\t\tlogger.Infof(\"File in stash library that excludes images. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\tif matchFileRegex(path, f.imageExcludeRegex) {\n\t\tlogger.Infof(\"File matched regex. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc (f *cleanFilter) shouldCleanImage(path string, stash *config.StashConfig) bool {\n\tif stash.ExcludeImage {\n\t\tlogger.Infof(\"File in stash library that excludes images. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\tif matchFileRegex(path, f.imageExcludeRegex) {\n\t\tlogger.Infof(\"File matched regex. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\treturn false\n}\n\ntype cleanHandler struct{}\n\nfunc (h *cleanHandler) HandleFile(ctx context.Context, fileDeleter *file.Deleter, fileID models.FileID) error {\n\tif err := h.handleRelatedScenes(ctx, fileDeleter, fileID); err != nil {\n\t\treturn err\n\t}\n\tif err := h.handleRelatedGalleries(ctx, fileID); err != nil {\n\t\treturn err\n\t}\n\tif err := h.handleRelatedImages(ctx, fileDeleter, fileID); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (h *cleanHandler) HandleFolder(ctx context.Context, fileDeleter *file.Deleter, folderID models.FolderID) error {\n\treturn h.deleteRelatedFolderGalleries(ctx, folderID)\n}\n\nfunc (h *cleanHandler) handleRelatedScenes(ctx context.Context, fileDeleter *file.Deleter, fileID models.FileID) error {\n\tmgr := GetInstance()\n\tsceneQB := mgr.Repository.Scene\n\tscenes, err := sceneQB.FindByFileID(ctx, fileID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfileNamingAlgo := mgr.Config.GetVideoFileNamingAlgorithm()\n\n\tsceneFileDeleter := &scene.FileDeleter{\n\t\tDeleter:        fileDeleter,\n\t\tFileNamingAlgo: fileNamingAlgo,\n\t\tPaths:          mgr.Paths,\n\t}\n\n\tfor _, scene := range scenes {\n\t\tif err := scene.LoadFiles(ctx, sceneQB); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// only delete if the scene has no other files\n\t\tif len(scene.Files.List()) <= 1 {\n\t\t\tlogger.Infof(\"Deleting scene %q since it has no other related files\", scene.DisplayName())\n\t\t\tconst deleteGenerated = true\n\t\t\tconst deleteFile = false\n\t\t\tconst destroyFileEntry = false\n\t\t\tif err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tmgr.PluginCache.RegisterPostHooks(ctx, scene.ID, hook.SceneDestroyPost, plugin.SceneDestroyInput{\n\t\t\t\tChecksum: scene.Checksum,\n\t\t\t\tOSHash:   scene.OSHash,\n\t\t\t\tPath:     scene.Path,\n\t\t\t}, nil)\n\t\t} else {\n\t\t\t// set the primary file to a remaining file\n\t\t\tvar newPrimaryID models.FileID\n\t\t\tfor _, f := range scene.Files.List() {\n\t\t\t\tif f.ID != fileID {\n\t\t\t\t\tnewPrimaryID = f.ID\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tscenePartial := models.NewScenePartial()\n\t\t\tscenePartial.PrimaryFileID = &newPrimaryID\n\n\t\t\tif _, err := mgr.Repository.Scene.UpdatePartial(ctx, scene.ID, scenePartial); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *cleanHandler) handleRelatedGalleries(ctx context.Context, fileID models.FileID) error {\n\tmgr := GetInstance()\n\tqb := mgr.Repository.Gallery\n\tgalleries, err := qb.FindByFileID(ctx, fileID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, g := range galleries {\n\t\tif err := g.LoadFiles(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// only delete if the gallery has no other files\n\t\tif len(g.Files.List()) <= 1 {\n\t\t\tlogger.Infof(\"Deleting gallery %q since it has no other related files\", g.DisplayName())\n\t\t\tif err := qb.Destroy(ctx, g.ID); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tmgr.PluginCache.RegisterPostHooks(ctx, g.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{\n\t\t\t\tChecksum: g.PrimaryChecksum(),\n\t\t\t\tPath:     g.Path,\n\t\t\t}, nil)\n\t\t} else {\n\t\t\t// set the primary file to a remaining file\n\t\t\tvar newPrimaryID models.FileID\n\t\t\tfor _, f := range g.Files.List() {\n\t\t\t\tif f.Base().ID != fileID {\n\t\t\t\t\tnewPrimaryID = f.Base().ID\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tgalleryPartial := models.NewGalleryPartial()\n\t\t\tgalleryPartial.PrimaryFileID = &newPrimaryID\n\n\t\t\tif _, err := mgr.Repository.Gallery.UpdatePartial(ctx, g.ID, galleryPartial); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *cleanHandler) deleteRelatedFolderGalleries(ctx context.Context, folderID models.FolderID) error {\n\tmgr := GetInstance()\n\tqb := mgr.Repository.Gallery\n\tgalleries, err := qb.FindByFolderID(ctx, folderID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, g := range galleries {\n\t\tlogger.Infof(\"Deleting folder-based gallery %q since the folder no longer exists\", g.DisplayName())\n\t\tif err := qb.Destroy(ctx, g.ID); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tmgr.PluginCache.RegisterPostHooks(ctx, g.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{\n\t\t\t// No checksum for folders\n\t\t\t// Checksum: g.Checksum(),\n\t\t\tPath: g.Path,\n\t\t}, nil)\n\t}\n\n\treturn nil\n}\n\nfunc (h *cleanHandler) handleRelatedImages(ctx context.Context, fileDeleter *file.Deleter, fileID models.FileID) error {\n\tmgr := GetInstance()\n\timageQB := mgr.Repository.Image\n\timages, err := imageQB.FindByFileID(ctx, fileID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\timageFileDeleter := &image.FileDeleter{\n\t\tDeleter: fileDeleter,\n\t\tPaths:   mgr.Paths,\n\t}\n\n\tfor _, i := range images {\n\t\tif err := i.LoadFiles(ctx, imageQB); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(i.Files.List()) <= 1 {\n\t\t\tlogger.Infof(\"Deleting image %q since it has no other related files\", i.DisplayName())\n\t\t\tconst deleteGenerated = true\n\t\t\tconst deleteFile = false\n\t\t\tconst destroyFileEntry = false\n\t\t\tif err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tmgr.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{\n\t\t\t\tChecksum: i.Checksum,\n\t\t\t\tPath:     i.Path,\n\t\t\t}, nil)\n\t\t} else {\n\t\t\t// set the primary file to a remaining file\n\t\t\tvar newPrimaryID models.FileID\n\t\t\tfor _, f := range i.Files.List() {\n\t\t\t\tif f.Base().ID != fileID {\n\t\t\t\t\tnewPrimaryID = f.Base().ID\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\timagePartial := models.NewImagePartial()\n\t\t\timagePartial.PrimaryFileID = &newPrimaryID\n\n\t\t\tif _, err := mgr.Repository.Image.UpdatePartial(ctx, i.ID, imagePartial); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/task_export.go",
    "content": "package manager\n\nimport (\n\t\"archive/zip\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/group\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n\t\"github.com/stashapp/stash/pkg/savedfilter\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/studio\"\n\t\"github.com/stashapp/stash/pkg/tag\"\n)\n\ntype ExportTask struct {\n\trepository models.Repository\n\tfull       bool\n\n\tbaseDir string\n\tjson    jsonUtils\n\n\tfileNamingAlgorithm models.HashAlgorithm\n\n\tscenes     *exportSpec\n\timages     *exportSpec\n\tperformers *exportSpec\n\tgroups     *exportSpec\n\ttags       *exportSpec\n\tstudios    *exportSpec\n\tgalleries  *exportSpec\n\n\tincludeDependencies bool\n\n\tDownloadHash string\n}\n\ntype ExportObjectTypeInput struct {\n\tIds []string `json:\"ids\"`\n\tAll *bool    `json:\"all\"`\n}\n\ntype ExportObjectsInput struct {\n\tScenes              *ExportObjectTypeInput `json:\"scenes\"`\n\tImages              *ExportObjectTypeInput `json:\"images\"`\n\tStudios             *ExportObjectTypeInput `json:\"studios\"`\n\tPerformers          *ExportObjectTypeInput `json:\"performers\"`\n\tTags                *ExportObjectTypeInput `json:\"tags\"`\n\tGroups              *ExportObjectTypeInput `json:\"groups\"`\n\tMovies              *ExportObjectTypeInput `json:\"movies\"` // deprecated\n\tGalleries           *ExportObjectTypeInput `json:\"galleries\"`\n\tIncludeDependencies *bool                  `json:\"includeDependencies\"`\n}\n\ntype exportSpec struct {\n\tIDs []int\n\tall bool\n}\n\nfunc newExportSpec(input *ExportObjectTypeInput) *exportSpec {\n\tif input == nil {\n\t\treturn &exportSpec{}\n\t}\n\n\tids, _ := stringslice.StringSliceToIntSlice(input.Ids)\n\n\tret := &exportSpec{\n\t\tIDs: ids,\n\t}\n\n\tif input.All != nil {\n\t\tret.all = *input.All\n\t}\n\n\treturn ret\n}\n\nfunc CreateExportTask(a models.HashAlgorithm, input ExportObjectsInput) *ExportTask {\n\tincludeDeps := false\n\tif input.IncludeDependencies != nil {\n\t\tincludeDeps = *input.IncludeDependencies\n\t}\n\n\t// handle deprecated Movies field\n\tgroupSpec := input.Groups\n\tif groupSpec == nil && input.Movies != nil {\n\t\tgroupSpec = input.Movies\n\t}\n\n\treturn &ExportTask{\n\t\trepository:          GetInstance().Repository,\n\t\tfileNamingAlgorithm: a,\n\t\tscenes:              newExportSpec(input.Scenes),\n\t\timages:              newExportSpec(input.Images),\n\t\tperformers:          newExportSpec(input.Performers),\n\t\tgroups:              newExportSpec(groupSpec),\n\t\ttags:                newExportSpec(input.Tags),\n\t\tstudios:             newExportSpec(input.Studios),\n\t\tgalleries:           newExportSpec(input.Galleries),\n\t\tincludeDependencies: includeDeps,\n\t}\n}\n\nfunc (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) {\n\tdefer wg.Done()\n\t// @manager.total = Scene.count + Gallery.count + Performer.count + Studio.count + Group.count\n\tworkerCount := runtime.GOMAXPROCS(0) // set worker count to number of cpus available\n\n\tstartTime := time.Now()\n\n\tif t.full {\n\t\tt.baseDir = config.GetInstance().GetMetadataPath()\n\t} else {\n\t\tvar err error\n\t\tt.baseDir, err = instance.Paths.Generated.TempDir(\"export\")\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error creating temporary directory for export: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tdefer func() {\n\t\t\terr := fsutil.RemoveDir(t.baseDir)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"error removing directory %s: %v\", t.baseDir, err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tif t.baseDir == \"\" {\n\t\tlogger.Errorf(\"baseDir must not be empty\")\n\t\treturn\n\t}\n\n\tt.json = jsonUtils{\n\t\tjson: *paths.GetJSONPaths(t.baseDir),\n\t}\n\n\tpaths.EmptyJSONDirs(t.baseDir)\n\tpaths.EnsureJSONDirs(t.baseDir)\n\n\ttxnErr := t.repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\t// include group scenes and gallery images\n\t\tif !t.full {\n\t\t\t// only include group scenes if includeDependencies is also set\n\t\t\tif !t.scenes.all && t.includeDependencies {\n\t\t\t\tt.populateGroupScenes(ctx)\n\t\t\t}\n\n\t\t\t// always export gallery images\n\t\t\tif !t.images.all {\n\t\t\t\tt.populateGalleryImages(ctx)\n\t\t\t}\n\t\t}\n\n\t\tt.ExportScenes(ctx, workerCount)\n\t\tt.ExportImages(ctx, workerCount)\n\t\tt.ExportGalleries(ctx, workerCount)\n\t\tt.ExportGroups(ctx, workerCount)\n\t\tt.ExportPerformers(ctx, workerCount)\n\t\tt.ExportStudios(ctx, workerCount)\n\t\tt.ExportTags(ctx, workerCount)\n\t\tt.ExportSavedFilters(ctx, workerCount)\n\n\t\treturn nil\n\t})\n\tif txnErr != nil {\n\t\tlogger.Warnf(\"error while running export transaction: %v\", txnErr)\n\t}\n\n\tif !t.full {\n\t\terr := t.generateDownload()\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error generating download link: %v\", err)\n\t\t\treturn\n\t\t}\n\t}\n\tlogger.Infof(\"Export complete in %s.\", time.Since(startTime))\n}\n\nfunc (t *ExportTask) generateDownload() error {\n\t// zip the files and register a download link\n\tif err := fsutil.EnsureDir(instance.Paths.Generated.Downloads); err != nil {\n\t\treturn err\n\t}\n\tz, err := os.CreateTemp(instance.Paths.Generated.Downloads, \"export*.zip\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer z.Close()\n\n\terr = t.zipFiles(z)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tt.DownloadHash, err = instance.DownloadStore.RegisterFile(z.Name(), \"\", false)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error registering file for download: %w\", err)\n\t}\n\tlogger.Debugf(\"Generated zip file %s with hash %s\", z.Name(), t.DownloadHash)\n\treturn nil\n}\n\nfunc (t *ExportTask) zipFiles(w io.Writer) error {\n\tz := zip.NewWriter(w)\n\tdefer z.Close()\n\n\tu := jsonUtils{\n\t\tjson: *paths.GetJSONPaths(\"\"),\n\t}\n\n\twalkWarn(t.json.json.Tags, t.zipWalkFunc(u.json.Tags, z))\n\twalkWarn(t.json.json.Galleries, t.zipWalkFunc(u.json.Galleries, z))\n\twalkWarn(t.json.json.Performers, t.zipWalkFunc(u.json.Performers, z))\n\twalkWarn(t.json.json.Studios, t.zipWalkFunc(u.json.Studios, z))\n\twalkWarn(t.json.json.Groups, t.zipWalkFunc(u.json.Groups, z))\n\twalkWarn(t.json.json.Scenes, t.zipWalkFunc(u.json.Scenes, z))\n\twalkWarn(t.json.json.Images, t.zipWalkFunc(u.json.Images, z))\n\n\treturn nil\n}\n\n// like filepath.Walk but issue a warning on error\nfunc walkWarn(root string, fn filepath.WalkFunc) {\n\tif err := filepath.Walk(root, fn); err != nil {\n\t\tlogger.Warnf(\"error walking structure %v: %v\", root, err)\n\t}\n}\n\nfunc (t *ExportTask) zipWalkFunc(outDir string, z *zip.Writer) filepath.WalkFunc {\n\treturn func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn t.zipFile(path, outDir, z)\n\t}\n}\n\nfunc (t *ExportTask) zipFile(fn, outDir string, z *zip.Writer) error {\n\tbn := filepath.Base(fn)\n\n\tp := filepath.Join(outDir, bn)\n\tp = filepath.ToSlash(p)\n\n\tf, err := z.Create(p)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating zip entry for %s: %v\", fn, err)\n\t}\n\n\ti, err := os.Open(fn)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error opening %s: %v\", fn, err)\n\t}\n\n\tdefer i.Close()\n\n\tif _, err := io.Copy(f, i); err != nil {\n\t\treturn fmt.Errorf(\"error writing %s to zip: %v\", fn, err)\n\t}\n\n\treturn nil\n}\n\nfunc (t *ExportTask) populateGroupScenes(ctx context.Context) {\n\tr := t.repository\n\treader := r.Group\n\tsceneReader := r.Scene\n\n\tvar groups []*models.Group\n\tvar err error\n\tall := t.full || (t.groups != nil && t.groups.all)\n\tif all {\n\t\tgroups, err = reader.All(ctx)\n\t} else if t.groups != nil && len(t.groups.IDs) > 0 {\n\t\tgroups, err = reader.FindMany(ctx, t.groups.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[groups] failed to fetch groups: %v\", err)\n\t}\n\n\tfor _, m := range groups {\n\t\tscenes, err := sceneReader.FindByGroupID(ctx, m.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[groups] <%s> failed to fetch scenes for group: %v\", m.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, s := range scenes {\n\t\t\tt.scenes.IDs = sliceutil.AppendUnique(t.scenes.IDs, s.ID)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) populateGalleryImages(ctx context.Context) {\n\tr := t.repository\n\treader := r.Gallery\n\timageReader := r.Image\n\n\tvar galleries []*models.Gallery\n\tvar err error\n\tall := t.full || (t.galleries != nil && t.galleries.all)\n\tif all {\n\t\tgalleries, err = reader.All(ctx)\n\t} else if t.galleries != nil && len(t.galleries.IDs) > 0 {\n\t\tgalleries, err = reader.FindMany(ctx, t.galleries.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[galleries] failed to fetch galleries: %v\", err)\n\t}\n\n\tfor _, g := range galleries {\n\t\tif err := g.LoadFiles(ctx, reader); err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> failed to fetch files for gallery: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\timages, err := imageReader.FindByGalleryID(ctx, g.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> failed to fetch images for gallery: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, i := range images {\n\t\t\tt.images.IDs = sliceutil.AppendUnique(t.images.IDs, i.ID)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) ExportScenes(ctx context.Context, workers int) {\n\tvar scenesWg sync.WaitGroup\n\n\tsceneReader := t.repository.Scene\n\n\tvar scenes []*models.Scene\n\tvar err error\n\tall := t.full || (t.scenes != nil && t.scenes.all)\n\tif all {\n\t\tscenes, err = sceneReader.All(ctx)\n\t} else if t.scenes != nil && len(t.scenes.IDs) > 0 {\n\t\tscenes, err = sceneReader.FindMany(ctx, t.scenes.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[scenes] failed to fetch scenes: %v\", err)\n\t}\n\n\tjobCh := make(chan *models.Scene, workers*2) // make a buffered channel to feed workers\n\n\tlogger.Info(\"[scenes] exporting\")\n\tstartTime := time.Now()\n\n\tfor w := 0; w < workers; w++ { // create export Scene workers\n\t\tscenesWg.Add(1)\n\t\tgo t.exportScene(ctx, &scenesWg, jobCh)\n\t}\n\n\tfor i, scene := range scenes {\n\t\tindex := i + 1\n\n\t\tif (i % 100) == 0 { // make progress easier to read\n\t\t\tlogger.Progressf(\"[scenes] %d of %d\", index, len(scenes))\n\t\t}\n\t\tjobCh <- scene // feed workers\n\t}\n\n\tclose(jobCh) // close channel so that workers will know no more jobs are available\n\tscenesWg.Wait()\n\n\tlogger.Infof(\"[scenes] export complete in %s. %d workers used.\", time.Since(startTime), workers)\n}\n\nfunc (t *ExportTask) exportFile(f models.File) {\n\tnewFileJSON := fileToJSON(f)\n\n\tfn := newFileJSON.Filename()\n\n\tif err := t.json.saveFile(fn, newFileJSON); err != nil {\n\t\tlogger.Errorf(\"[files] <%s> failed to save json: %v\", fn, err)\n\t}\n}\n\nfunc fileToJSON(f models.File) jsonschema.DirEntry {\n\tbf := f.Base()\n\n\tbase := jsonschema.BaseFile{\n\t\tBaseDirEntry: jsonschema.BaseDirEntry{\n\t\t\tType:      jsonschema.DirEntryTypeFile,\n\t\t\tModTime:   json.JSONTime{Time: bf.ModTime},\n\t\t\tPath:      bf.Path,\n\t\t\tCreatedAt: json.JSONTime{Time: bf.CreatedAt},\n\t\t\tUpdatedAt: json.JSONTime{Time: bf.UpdatedAt},\n\t\t},\n\t\tSize: bf.Size,\n\t}\n\n\tif bf.ZipFile != nil {\n\t\tbase.ZipFile = bf.ZipFile.Base().Path\n\t}\n\n\tfor _, fp := range bf.Fingerprints {\n\t\tbase.Fingerprints = append(base.Fingerprints, jsonschema.Fingerprint{\n\t\t\tType:        fp.Type,\n\t\t\tFingerprint: fp.Fingerprint,\n\t\t})\n\t}\n\n\tswitch ff := f.(type) {\n\tcase *models.VideoFile:\n\t\tbase.Type = jsonschema.DirEntryTypeVideo\n\t\treturn jsonschema.VideoFile{\n\t\t\tBaseFile:         &base,\n\t\t\tFormat:           ff.Format,\n\t\t\tWidth:            ff.Width,\n\t\t\tHeight:           ff.Height,\n\t\t\tDuration:         ff.Duration,\n\t\t\tVideoCodec:       ff.VideoCodec,\n\t\t\tAudioCodec:       ff.AudioCodec,\n\t\t\tFrameRate:        ff.FrameRate,\n\t\t\tBitRate:          ff.BitRate,\n\t\t\tInteractive:      ff.Interactive,\n\t\t\tInteractiveSpeed: ff.InteractiveSpeed,\n\t\t}\n\tcase *models.ImageFile:\n\t\tbase.Type = jsonschema.DirEntryTypeImage\n\t\treturn jsonschema.ImageFile{\n\t\t\tBaseFile: &base,\n\t\t\tFormat:   ff.Format,\n\t\t\tWidth:    ff.Width,\n\t\t\tHeight:   ff.Height,\n\t\t}\n\t}\n\n\treturn &base\n}\n\nfunc (t *ExportTask) exportFolder(f models.Folder) {\n\tnewFileJSON := folderToJSON(f)\n\n\tfn := newFileJSON.Filename()\n\n\tif err := t.json.saveFile(fn, newFileJSON); err != nil {\n\t\tlogger.Errorf(\"[files] <%s> failed to save json: %v\", fn, err)\n\t}\n}\n\nfunc folderToJSON(f models.Folder) jsonschema.DirEntry {\n\tbase := jsonschema.BaseDirEntry{\n\t\tType:      jsonschema.DirEntryTypeFolder,\n\t\tModTime:   json.JSONTime{Time: f.ModTime},\n\t\tPath:      f.Path,\n\t\tCreatedAt: json.JSONTime{Time: f.CreatedAt},\n\t\tUpdatedAt: json.JSONTime{Time: f.UpdatedAt},\n\t}\n\n\tif f.ZipFile != nil {\n\t\tbase.ZipFile = f.ZipFile.Base().Path\n\t}\n\n\treturn &base\n}\n\nfunc (t *ExportTask) exportScene(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Scene) {\n\tdefer wg.Done()\n\n\tr := t.repository\n\tsceneReader := r.Scene\n\tstudioReader := r.Studio\n\tgroupReader := r.Group\n\tgalleryReader := r.Gallery\n\tperformerReader := r.Performer\n\ttagReader := r.Tag\n\tsceneMarkerReader := r.SceneMarker\n\n\tfor s := range jobChan {\n\t\tsceneHash := s.GetHash(t.fileNamingAlgorithm)\n\n\t\tif err := s.LoadRelationships(ctx, sceneReader); err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> error loading scene relationships: %v\", sceneHash, err)\n\t\t}\n\n\t\tnewSceneJSON, err := scene.ToBasicJSON(ctx, sceneReader, s)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene JSON: %v\", sceneHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// export files\n\t\tfor _, f := range s.Files.List() {\n\t\t\tt.exportFile(f)\n\t\t}\n\n\t\tnewSceneJSON.Studio, err = scene.GetStudioName(ctx, studioReader, s)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene studio name: %v\", sceneHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tgalleries, err := galleryReader.FindBySceneID(ctx, s.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene gallery checksums: %v\", sceneHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, g := range galleries {\n\t\t\tif err := g.LoadFiles(ctx, galleryReader); err != nil {\n\t\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene gallery files: %v\", sceneHash, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tnewSceneJSON.Galleries = gallery.GetRefs(galleries)\n\n\t\tnewSceneJSON.ResumeTime = s.ResumeTime\n\t\tnewSceneJSON.PlayDuration = s.PlayDuration\n\n\t\tperformers, err := performerReader.FindBySceneID(ctx, s.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene performer names: %v\", sceneHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewSceneJSON.Performers = performer.GetNames(performers)\n\n\t\tnewSceneJSON.Tags, err = scene.GetTagNames(ctx, tagReader, s)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene tag names: %v\", sceneHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewSceneJSON.Markers, err = scene.GetSceneMarkersJSON(ctx, sceneMarkerReader, tagReader, s)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene markers JSON: %v\", sceneHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewSceneJSON.Groups, err = scene.GetSceneGroupsJSON(ctx, groupReader, s)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene groups JSON: %v\", sceneHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif t.includeDependencies {\n\t\t\tif s.StudioID != nil {\n\t\t\t\tt.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *s.StudioID)\n\t\t\t}\n\n\t\t\tt.galleries.IDs = sliceutil.AppendUniques(t.galleries.IDs, gallery.GetIDs(galleries))\n\n\t\t\ttagIDs, err := scene.GetDependentTagIDs(ctx, tagReader, sceneMarkerReader, s)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene tags: %v\", sceneHash, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tt.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs)\n\n\t\t\tgroupIDs, err := scene.GetDependentGroupIDs(ctx, s)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"[scenes] <%s> error getting scene groups: %v\", sceneHash, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tt.groups.IDs = sliceutil.AppendUniques(t.groups.IDs, groupIDs)\n\n\t\t\tt.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers))\n\t\t}\n\n\t\tbasename := filepath.Base(s.Path)\n\t\thash := s.OSHash\n\n\t\tfn := newSceneJSON.Filename(s.ID, basename, hash)\n\n\t\tif err := t.json.saveScene(fn, newSceneJSON); err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> failed to save json: %v\", sceneHash, err)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) ExportImages(ctx context.Context, workers int) {\n\tvar imagesWg sync.WaitGroup\n\n\tr := t.repository\n\timageReader := r.Image\n\n\tvar images []*models.Image\n\tvar err error\n\tall := t.full || (t.images != nil && t.images.all)\n\tif all {\n\t\timages, err = imageReader.All(ctx)\n\t} else if t.images != nil && len(t.images.IDs) > 0 {\n\t\timages, err = imageReader.FindMany(ctx, t.images.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[images] failed to fetch images: %v\", err)\n\t}\n\n\tjobCh := make(chan *models.Image, workers*2) // make a buffered channel to feed workers\n\n\tlogger.Info(\"[images] exporting\")\n\tstartTime := time.Now()\n\n\tfor w := 0; w < workers; w++ { // create export Image workers\n\t\timagesWg.Add(1)\n\t\tgo t.exportImage(ctx, &imagesWg, jobCh)\n\t}\n\n\tfor i, image := range images {\n\t\tindex := i + 1\n\n\t\tif (i % 100) == 0 { // make progress easier to read\n\t\t\tlogger.Progressf(\"[images] %d of %d\", index, len(images))\n\t\t}\n\t\tjobCh <- image // feed workers\n\t}\n\n\tclose(jobCh) // close channel so that workers will know no more jobs are available\n\timagesWg.Wait()\n\n\tlogger.Infof(\"[images] export complete in %s. %d workers used.\", time.Since(startTime), workers)\n}\n\nfunc (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Image) {\n\tdefer wg.Done()\n\n\tr := t.repository\n\tstudioReader := r.Studio\n\tgalleryReader := r.Gallery\n\tperformerReader := r.Performer\n\ttagReader := r.Tag\n\timageReader := r.Image\n\n\tfor s := range jobChan {\n\t\timageHash := s.Checksum\n\n\t\tif err := s.LoadFiles(ctx, r.Image); err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> error getting image files: %v\", imageHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.LoadURLs(ctx, r.Image); err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> error getting image urls: %v\", imageHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewImageJSON, err := image.ToBasicJSON(ctx, imageReader, s)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> error converting image to JSON: %v\", imageHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// export files\n\t\tfor _, f := range s.Files.List() {\n\t\t\tt.exportFile(f)\n\t\t}\n\n\t\tnewImageJSON.Studio, err = image.GetStudioName(ctx, studioReader, s)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> error getting image studio name: %v\", imageHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\timageGalleries, err := galleryReader.FindByImageID(ctx, s.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> error getting image galleries: %v\", imageHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, g := range imageGalleries {\n\t\t\tif err := g.LoadFiles(ctx, galleryReader); err != nil {\n\t\t\t\tlogger.Errorf(\"[images] <%s> error getting image gallery files: %v\", imageHash, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tnewImageJSON.Galleries = gallery.GetRefs(imageGalleries)\n\n\t\tperformers, err := performerReader.FindByImageID(ctx, s.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> error getting image performer names: %v\", imageHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewImageJSON.Performers = performer.GetNames(performers)\n\n\t\ttags, err := tagReader.FindByImageID(ctx, s.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> error getting image tag names: %v\", imageHash, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewImageJSON.Tags = tag.GetNames(tags)\n\n\t\tif t.includeDependencies {\n\t\t\tif s.StudioID != nil {\n\t\t\t\tt.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *s.StudioID)\n\t\t\t}\n\n\t\t\tt.galleries.IDs = sliceutil.AppendUniques(t.galleries.IDs, gallery.GetIDs(imageGalleries))\n\t\t\tt.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags))\n\t\t\tt.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers))\n\t\t}\n\n\t\tfn := newImageJSON.Filename(filepath.Base(s.Path), s.Checksum)\n\n\t\tif err := t.json.saveImage(fn, newImageJSON); err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> failed to save json: %v\", imageHash, err)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) ExportGalleries(ctx context.Context, workers int) {\n\tvar galleriesWg sync.WaitGroup\n\n\treader := t.repository.Gallery\n\n\tvar galleries []*models.Gallery\n\tvar err error\n\tall := t.full || (t.galleries != nil && t.galleries.all)\n\tif all {\n\t\tgalleries, err = reader.All(ctx)\n\t} else if t.galleries != nil && len(t.galleries.IDs) > 0 {\n\t\tgalleries, err = reader.FindMany(ctx, t.galleries.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[galleries] failed to fetch galleries: %v\", err)\n\t}\n\n\tjobCh := make(chan *models.Gallery, workers*2) // make a buffered channel to feed workers\n\n\tlogger.Info(\"[galleries] exporting\")\n\tstartTime := time.Now()\n\n\tfor w := 0; w < workers; w++ { // create export Scene workers\n\t\tgalleriesWg.Add(1)\n\t\tgo t.exportGallery(ctx, &galleriesWg, jobCh)\n\t}\n\n\tfor i, gallery := range galleries {\n\t\tindex := i + 1\n\n\t\tif (i % 100) == 0 { // make progress easier to read\n\t\t\tlogger.Progressf(\"[galleries] %d of %d\", index, len(galleries))\n\t\t}\n\n\t\tjobCh <- gallery\n\t}\n\n\tclose(jobCh) // close channel so that workers will know no more jobs are available\n\tgalleriesWg.Wait()\n\n\tlogger.Infof(\"[galleries] export complete in %s. %d workers used.\", time.Since(startTime), workers)\n}\n\nfunc (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Gallery) {\n\tdefer wg.Done()\n\n\tr := t.repository\n\tstudioReader := r.Studio\n\tperformerReader := r.Performer\n\ttagReader := r.Tag\n\tgalleryReader := r.Gallery\n\tgalleryChapterReader := r.GalleryChapter\n\n\tfor g := range jobChan {\n\t\tif err := g.LoadFiles(ctx, r.Gallery); err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery files: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := g.LoadURLs(ctx, r.Gallery); err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery urls: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewGalleryJSON, err := gallery.ToBasicJSON(g)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery JSON: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// export files\n\t\tfor _, f := range g.Files.List() {\n\t\t\tt.exportFile(f)\n\t\t}\n\n\t\t// export folder if necessary\n\t\tif g.FolderID != nil {\n\t\t\tfolder, err := r.Folder.Find(ctx, *g.FolderID)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery folder: %v\", g.DisplayName(), err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif folder == nil {\n\t\t\t\tlogger.Errorf(\"[galleries] <%s> unable to find gallery folder\", g.DisplayName())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tt.exportFolder(*folder)\n\t\t}\n\n\t\tnewGalleryJSON.Studio, err = gallery.GetStudioName(ctx, studioReader, g)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery studio name: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tperformers, err := performerReader.FindByGalleryID(ctx, g.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery performer names: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewGalleryJSON.Performers = performer.GetNames(performers)\n\n\t\ttags, err := tagReader.FindByGalleryID(ctx, g.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery tag names: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewGalleryJSON.Chapters, err = gallery.GetGalleryChaptersJSON(ctx, galleryChapterReader, g)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery chapters JSON: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewGalleryJSON.Tags = tag.GetNames(tags)\n\n\t\tnewGalleryJSON.CustomFields, err = galleryReader.GetCustomFields(ctx, g.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> error getting gallery custom fields: %v\", g.DisplayName(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif t.includeDependencies {\n\t\t\tif g.StudioID != nil {\n\t\t\t\tt.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *g.StudioID)\n\t\t\t}\n\n\t\t\tt.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags))\n\t\t\tt.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers))\n\t\t}\n\n\t\tbasename := \"\"\n\t\t// use id in case multiple galleries with the same basename\n\t\thash := strconv.Itoa(g.ID)\n\n\t\tswitch {\n\t\tcase g.Path != \"\":\n\t\t\tbasename = filepath.Base(g.Path)\n\t\tdefault:\n\t\t\tbasename = g.Title\n\t\t}\n\n\t\tfn := newGalleryJSON.Filename(basename, hash)\n\n\t\tif err := t.json.saveGallery(fn, newGalleryJSON); err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> failed to save json: %v\", g.DisplayName(), err)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) ExportPerformers(ctx context.Context, workers int) {\n\tvar performersWg sync.WaitGroup\n\n\treader := t.repository.Performer\n\tvar performers []*models.Performer\n\tvar err error\n\tall := t.full || (t.performers != nil && t.performers.all)\n\tif all {\n\t\tperformers, err = reader.All(ctx)\n\t} else if t.performers != nil && len(t.performers.IDs) > 0 {\n\t\tperformers, err = reader.FindMany(ctx, t.performers.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[performers] failed to fetch performers: %v\", err)\n\t}\n\tjobCh := make(chan *models.Performer, workers*2) // make a buffered channel to feed workers\n\n\tlogger.Info(\"[performers] exporting\")\n\tstartTime := time.Now()\n\n\tfor w := 0; w < workers; w++ { // create export Performer workers\n\t\tperformersWg.Add(1)\n\t\tgo t.exportPerformer(ctx, &performersWg, jobCh)\n\t}\n\n\tfor i, performer := range performers {\n\t\tindex := i + 1\n\t\tlogger.Progressf(\"[performers] %d of %d\", index, len(performers))\n\n\t\tjobCh <- performer // feed workers\n\t}\n\n\tclose(jobCh) // close channel so workers will know that no more jobs are available\n\tperformersWg.Wait()\n\n\tlogger.Infof(\"[performers] export complete in %s. %d workers used.\", time.Since(startTime), workers)\n}\n\nfunc (t *ExportTask) exportPerformer(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Performer) {\n\tdefer wg.Done()\n\n\tr := t.repository\n\tperformerReader := r.Performer\n\n\tfor p := range jobChan {\n\t\tnewPerformerJSON, err := performer.ToJSON(ctx, performerReader, p)\n\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[performers] <%s> error getting performer JSON: %v\", p.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\ttags, err := r.Tag.FindByPerformerID(ctx, p.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[performers] <%s> error getting performer tags: %v\", p.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewPerformerJSON.Tags = tag.GetNames(tags)\n\n\t\tif t.includeDependencies {\n\t\t\tt.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags))\n\t\t}\n\n\t\tfn := newPerformerJSON.Filename()\n\n\t\tif err := t.json.savePerformer(fn, newPerformerJSON); err != nil {\n\t\t\tlogger.Errorf(\"[performers] <%s> failed to save json: %v\", p.Name, err)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) ExportStudios(ctx context.Context, workers int) {\n\tvar studiosWg sync.WaitGroup\n\n\treader := t.repository.Studio\n\tvar studios []*models.Studio\n\tvar err error\n\tall := t.full || (t.studios != nil && t.studios.all)\n\tif all {\n\t\tstudios, err = reader.All(ctx)\n\t} else if t.studios != nil && len(t.studios.IDs) > 0 {\n\t\tstudios, err = reader.FindMany(ctx, t.studios.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[studios] failed to fetch studios: %v\", err)\n\t}\n\n\tlogger.Info(\"[studios] exporting\")\n\tstartTime := time.Now()\n\n\tjobCh := make(chan *models.Studio, workers*2) // make a buffered channel to feed workers\n\n\tfor w := 0; w < workers; w++ { // create export Studio workers\n\t\tstudiosWg.Add(1)\n\t\tgo t.exportStudio(ctx, &studiosWg, jobCh)\n\t}\n\n\tfor i, studio := range studios {\n\t\tindex := i + 1\n\t\tlogger.Progressf(\"[studios] %d of %d\", index, len(studios))\n\n\t\tjobCh <- studio // feed workers\n\t}\n\n\tclose(jobCh)\n\tstudiosWg.Wait()\n\n\tlogger.Infof(\"[studios] export complete in %s. %d workers used.\", time.Since(startTime), workers)\n}\n\nfunc (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Studio) {\n\tdefer wg.Done()\n\n\tr := t.repository\n\tstudioReader := t.repository.Studio\n\n\tfor s := range jobChan {\n\t\tnewStudioJSON, err := studio.ToJSON(ctx, studioReader, s)\n\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[studios] <%s> error getting studio JSON: %v\", s.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\ttags, err := r.Tag.FindByStudioID(ctx, s.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[studios] <%s> error getting studio tags: %s\", s.Name, err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tnewStudioJSON.Tags = tag.GetNames(tags)\n\n\t\tif t.includeDependencies {\n\t\t\tt.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags))\n\t\t}\n\n\t\tfn := newStudioJSON.Filename()\n\n\t\tif err := t.json.saveStudio(fn, newStudioJSON); err != nil {\n\t\t\tlogger.Errorf(\"[studios] <%s> failed to save json: %v\", s.Name, err)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) ExportTags(ctx context.Context, workers int) {\n\tvar tagsWg sync.WaitGroup\n\n\treader := t.repository.Tag\n\tvar tags []*models.Tag\n\tvar err error\n\tall := t.full || (t.tags != nil && t.tags.all)\n\tif all {\n\t\ttags, err = reader.All(ctx)\n\t} else if t.tags != nil && len(t.tags.IDs) > 0 {\n\t\ttags, err = reader.FindMany(ctx, t.tags.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[tags] failed to fetch tags: %v\", err)\n\t}\n\n\tlogger.Info(\"[tags] exporting\")\n\tstartTime := time.Now()\n\n\ttagIdx := 0\n\tif t.tags != nil {\n\t\ttagIdx = len(t.tags.IDs)\n\t}\n\n\tfor {\n\t\tjobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers\n\n\t\tfor w := 0; w < workers; w++ { // create export Tag workers\n\t\t\ttagsWg.Add(1)\n\t\t\tgo t.exportTag(ctx, &tagsWg, jobCh)\n\t\t}\n\n\t\tfor i, tag := range tags {\n\t\t\tindex := i + 1 + tagIdx\n\t\t\tlogger.Progressf(\"[tags] %d of %d\", index, len(tags)+tagIdx)\n\n\t\t\tjobCh <- tag // feed workers\n\t\t}\n\n\t\tclose(jobCh)\n\t\ttagsWg.Wait()\n\n\t\t// if more tags were added, we need to export those too\n\t\tif t.tags == nil || len(t.tags.IDs) == tagIdx {\n\t\t\tbreak\n\t\t}\n\n\t\tnewTags, err := reader.FindMany(ctx, t.tags.IDs[tagIdx:])\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[tags] failed to fetch tags: %v\", err)\n\t\t}\n\n\t\ttags = newTags\n\t\ttagIdx = len(t.tags.IDs)\n\t}\n\n\tlogger.Infof(\"[tags] export complete in %s. %d workers used.\", time.Since(startTime), workers)\n}\n\nfunc (t *ExportTask) exportTag(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Tag) {\n\tdefer wg.Done()\n\n\ttagReader := t.repository.Tag\n\n\tfor thisTag := range jobChan {\n\t\tnewTagJSON, err := tag.ToJSON(ctx, tagReader, thisTag)\n\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[tags] <%s> error getting tag JSON: %v\", thisTag.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif t.includeDependencies {\n\t\t\ttagIDs, err := tag.GetDependentTagIDs(ctx, tagReader, thisTag)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"[tags] <%s> error getting dependent tags: %v\", thisTag.Name, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tt.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs)\n\t\t}\n\n\t\tfn := newTagJSON.Filename()\n\n\t\tif err := t.json.saveTag(fn, newTagJSON); err != nil {\n\t\t\tlogger.Errorf(\"[tags] <%s> failed to save json: %v\", fn, err)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) ExportGroups(ctx context.Context, workers int) {\n\tvar groupsWg sync.WaitGroup\n\n\treader := t.repository.Group\n\tvar groups []*models.Group\n\tvar err error\n\tall := t.full || (t.groups != nil && t.groups.all)\n\tif all {\n\t\tgroups, err = reader.All(ctx)\n\t} else if t.groups != nil && len(t.groups.IDs) > 0 {\n\t\tgroups, err = reader.FindMany(ctx, t.groups.IDs)\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[groups] failed to fetch groups: %v\", err)\n\t}\n\n\tlogger.Info(\"[groups] exporting\")\n\tstartTime := time.Now()\n\n\tjobCh := make(chan *models.Group, workers*2) // make a buffered channel to feed workers\n\n\tfor w := 0; w < workers; w++ { // create export Studio workers\n\t\tgroupsWg.Add(1)\n\t\tgo t.exportGroup(ctx, &groupsWg, jobCh)\n\t}\n\n\tfor i, group := range groups {\n\t\tindex := i + 1\n\t\tlogger.Progressf(\"[groups] %d of %d\", index, len(groups))\n\n\t\tjobCh <- group // feed workers\n\t}\n\n\tclose(jobCh)\n\tgroupsWg.Wait()\n\n\tlogger.Infof(\"[groups] export complete in %s. %d workers used.\", time.Since(startTime), workers)\n\n}\nfunc (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Group) {\n\tdefer wg.Done()\n\n\tr := t.repository\n\tgroupReader := r.Group\n\tstudioReader := r.Studio\n\ttagReader := r.Tag\n\n\tfor m := range jobChan {\n\t\tif err := m.LoadURLs(ctx, r.Group); err != nil {\n\t\t\tlogger.Errorf(\"[groups] <%s> error getting group urls: %v\", m.Name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif err := m.LoadSubGroupIDs(ctx, r.Group); err != nil {\n\t\t\tlogger.Errorf(\"[groups] <%s> error getting group sub-groups: %v\", m.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewGroupJSON, err := group.ToJSON(ctx, groupReader, studioReader, m)\n\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[groups] <%s> error getting tag JSON: %v\", m.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\ttags, err := tagReader.FindByGroupID(ctx, m.ID)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[groups] <%s> error getting image tag names: %v\", m.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tnewGroupJSON.Tags = tag.GetNames(tags)\n\n\t\tsubGroups := m.SubGroups.List()\n\t\tif err := func() error {\n\t\t\tfor _, sg := range subGroups {\n\t\t\t\tsubGroup, err := groupReader.Find(ctx, sg.GroupID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error getting sub group: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tnewGroupJSON.SubGroups = append(newGroupJSON.SubGroups, jsonschema.SubGroupDescription{\n\t\t\t\t\t// TODO - this won't be unique\n\t\t\t\t\tGroup:       subGroup.Name,\n\t\t\t\t\tDescription: sg.Description,\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn nil\n\t\t}(); err != nil {\n\t\t\tlogger.Errorf(\"[groups] <%s> %v\", m.Name, err)\n\t\t}\n\n\t\tif t.includeDependencies {\n\t\t\tif m.StudioID != nil {\n\t\t\t\tt.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID)\n\t\t\t}\n\t\t}\n\n\t\tfn := newGroupJSON.Filename()\n\n\t\tif err := t.json.saveGroup(fn, newGroupJSON); err != nil {\n\t\t\tlogger.Errorf(\"[groups] <%s> failed to save json: %v\", m.Name, err)\n\t\t}\n\t}\n}\n\nfunc (t *ExportTask) ExportSavedFilters(ctx context.Context, workers int) {\n\t// don't export saved filters unless we're doing a full export\n\tif !t.full {\n\t\treturn\n\t}\n\n\tvar wg sync.WaitGroup\n\n\treader := t.repository.SavedFilter\n\tvar filters []*models.SavedFilter\n\tvar err error\n\tfilters, err = reader.All(ctx)\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[saved filters] failed to fetch saved filters: %v\", err)\n\t}\n\n\tlogger.Info(\"[saved filters] exporting\")\n\tstartTime := time.Now()\n\n\tjobCh := make(chan *models.SavedFilter, workers*2) // make a buffered channel to feed workers\n\n\tfor w := 0; w < workers; w++ { // create export Saved Filter workers\n\t\twg.Add(1)\n\t\tgo t.exportSavedFilter(ctx, &wg, jobCh)\n\t}\n\n\tfor i, savedFilter := range filters {\n\t\tindex := i + 1\n\t\tlogger.Progressf(\"[saved filters] %d of %d\", index, len(filters))\n\n\t\tjobCh <- savedFilter // feed workers\n\t}\n\n\tclose(jobCh)\n\twg.Wait()\n\n\tlogger.Infof(\"[saved filters] export complete in %s. %d workers used.\", time.Since(startTime), workers)\n}\n\nfunc (t *ExportTask) exportSavedFilter(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.SavedFilter) {\n\tdefer wg.Done()\n\n\tfor thisFilter := range jobChan {\n\t\tnewJSON, err := savedfilter.ToJSON(ctx, thisFilter)\n\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[saved filter] <%s> error getting saved filter JSON: %v\", thisFilter.Name, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfn := newJSON.Filename()\n\n\t\tif err := t.json.saveSavedFilter(fn, newJSON); err != nil {\n\t\t\tlogger.Errorf(\"[saved filter] <%s> failed to save json: %v\", fn, err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/manager/task_generate.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/remeh/sizedwaitgroup\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/scene/generate\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n)\n\ntype GenerateMetadataInput struct {\n\tCovers              bool                         `json:\"covers\"`\n\tSprites             bool                         `json:\"sprites\"`\n\tPreviews            bool                         `json:\"previews\"`\n\tImagePreviews       bool                         `json:\"imagePreviews\"`\n\tPreviewOptions      *GeneratePreviewOptionsInput `json:\"previewOptions\"`\n\tMarkers             bool                         `json:\"markers\"`\n\tMarkerImagePreviews bool                         `json:\"markerImagePreviews\"`\n\tMarkerScreenshots   bool                         `json:\"markerScreenshots\"`\n\tTranscodes          bool                         `json:\"transcodes\"`\n\t// Generate transcodes even if not required\n\tForceTranscodes           bool `json:\"forceTranscodes\"`\n\tPhashes                   bool `json:\"phashes\"`\n\tImagePhashes              bool `json:\"imagePhashes\"`\n\tInteractiveHeatmapsSpeeds bool `json:\"interactiveHeatmapsSpeeds\"`\n\tClipPreviews              bool `json:\"clipPreviews\"`\n\tImageThumbnails           bool `json:\"imageThumbnails\"`\n\t// scene ids to generate for\n\tSceneIDs []string `json:\"sceneIDs\"`\n\t// marker ids to generate for\n\tMarkerIDs []string `json:\"markerIDs\"`\n\t// image ids to generate for\n\tImageIDs []string `json:\"imageIDs\"`\n\t// gallery ids to generate for\n\tGalleryIDs []string `json:\"galleryIDs\"`\n\t// overwrite existing media\n\tOverwrite bool `json:\"overwrite\"`\n\t// paths to run generate on, in addition to the other ID lists\n\tPaths []string `json:\"paths\"`\n}\n\ntype GeneratePreviewOptionsInput struct {\n\t// Number of segments in a preview file\n\tPreviewSegments *int `json:\"previewSegments\"`\n\t// Preview segment duration, in seconds\n\tPreviewSegmentDuration *float64 `json:\"previewSegmentDuration\"`\n\t// Duration of start of video to exclude when generating previews\n\tPreviewExcludeStart *string `json:\"previewExcludeStart\"`\n\t// Duration of end of video to exclude when generating previews\n\tPreviewExcludeEnd *string `json:\"previewExcludeEnd\"`\n\t// Preset when generating preview\n\tPreviewPreset *models.PreviewPreset `json:\"previewPreset\"`\n}\n\nconst generateQueueSize = 200000\n\ntype GenerateJob struct {\n\trepository models.Repository\n\tinput      GenerateMetadataInput\n\n\toverwrite      bool\n\tfileNamingAlgo models.HashAlgorithm\n\n\ttotals totalsGenerate\n}\n\ntype totalsGenerate struct {\n\tcovers                   int64\n\tsprites                  int64\n\tpreviews                 int64\n\timagePreviews            int64\n\tmarkers                  int64\n\ttranscodes               int64\n\tphashes                  int64\n\timagePhashes             int64\n\tinteractiveHeatmapSpeeds int64\n\tclipPreviews             int64\n\timageThumbnails          int64\n\n\ttasks int\n}\n\nfunc (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tvar scenes []*models.Scene\n\tvar markers []*models.SceneMarker\n\tvar images []*models.Image\n\tvar err error\n\n\tj.overwrite = j.input.Overwrite\n\tj.fileNamingAlgo = config.GetInstance().GetVideoFileNamingAlgorithm()\n\n\tconfig := config.GetInstance()\n\tparallelTasks := config.GetParallelTasksWithAutoDetection()\n\n\tlogger.Infof(\"Generate started with %d parallel tasks\", parallelTasks)\n\n\tqueue := make(chan Task, generateQueueSize)\n\tgo func() {\n\t\tdefer close(queue)\n\n\t\tsceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs)\n\t\tif err != nil {\n\t\t\tlogger.Error(err.Error())\n\t\t}\n\t\tmarkerIDs, err := stringslice.StringSliceToIntSlice(j.input.MarkerIDs)\n\t\tif err != nil {\n\t\t\tlogger.Error(err.Error())\n\t\t}\n\t\timageIDs, err := stringslice.StringSliceToIntSlice(j.input.ImageIDs)\n\t\tif err != nil {\n\t\t\tlogger.Error(err.Error())\n\t\t}\n\t\tgalleryIDs, err := stringslice.StringSliceToIntSlice(j.input.GalleryIDs)\n\t\tif err != nil {\n\t\t\tlogger.Error(err.Error())\n\t\t}\n\n\t\tg := &generate.Generator{\n\t\t\tEncoder:      instance.FFMpeg,\n\t\t\tFFMpegConfig: instance.Config,\n\t\t\tLockManager:  instance.ReadLockManager,\n\t\t\tMarkerPaths:  instance.Paths.SceneMarkers,\n\t\t\tScenePaths:   instance.Paths.Scene,\n\t\t\tOverwrite:    j.overwrite,\n\t\t}\n\n\t\tr := j.repository\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Scene\n\t\t\tif len(j.input.SceneIDs) == 0 &&\n\t\t\t\tlen(j.input.MarkerIDs) == 0 &&\n\t\t\t\tlen(j.input.ImageIDs) == 0 &&\n\t\t\t\tlen(j.input.GalleryIDs) == 0 &&\n\t\t\t\tlen(j.input.Paths) == 0 {\n\n\t\t\t\tj.queueTasks(ctx, g, nil, queue)\n\t\t\t} else {\n\t\t\t\tif len(j.input.SceneIDs) > 0 {\n\t\t\t\t\tscenes, err = qb.FindMany(ctx, sceneIDs)\n\t\t\t\t\tfor _, s := range scenes {\n\t\t\t\t\t\tif err := s.LoadFiles(ctx, qb); err != nil {\n\t\t\t\t\t\t\treturn err\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tj.queueSceneJobs(ctx, g, s, queue)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif len(j.input.MarkerIDs) > 0 {\n\t\t\t\t\tmarkers, err = r.SceneMarker.FindMany(ctx, markerIDs)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\tfor _, m := range markers {\n\t\t\t\t\t\tj.queueMarkerJob(g, m, queue)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif len(j.input.ImageIDs) > 0 {\n\t\t\t\t\timages, err = r.Image.FindMany(ctx, imageIDs)\n\t\t\t\t\tfor _, i := range images {\n\t\t\t\t\t\tif err := i.LoadFiles(ctx, r.Image); err != nil {\n\t\t\t\t\t\t\treturn err\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tj.queueImageJob(g, i, queue)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif len(j.input.GalleryIDs) > 0 {\n\t\t\t\t\tfor _, galleryID := range galleryIDs {\n\t\t\t\t\t\timgs, err := r.Image.FindByGalleryID(ctx, galleryID)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn err\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfor _, img := range imgs {\n\t\t\t\t\t\t\tif err := img.LoadFiles(ctx, r.Image); err != nil {\n\t\t\t\t\t\t\t\treturn err\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tj.queueImageJob(g, img, queue)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif len(j.input.Paths) > 0 {\n\t\t\t\t\tpaths := filterStashPaths(j.input.Paths)\n\t\t\t\t\tj.queueTasks(ctx, g, paths, queue)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil && ctx.Err() == nil {\n\t\t\tlogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\n\t\ttotals := j.totals\n\t\tlogMsg := \"Generating\"\n\t\tif j.input.Covers {\n\t\t\tlogMsg += fmt.Sprintf(\" %d covers\", totals.covers)\n\t\t}\n\t\tif j.input.Sprites {\n\t\t\tlogMsg += fmt.Sprintf(\" %d sprites\", totals.sprites)\n\t\t}\n\t\tif j.input.Previews {\n\t\t\tlogMsg += fmt.Sprintf(\" %d previews\", totals.previews)\n\t\t}\n\t\tif j.input.ImagePreviews {\n\t\t\tlogMsg += fmt.Sprintf(\" %d image previews\", totals.imagePreviews)\n\t\t}\n\t\tif j.input.Markers {\n\t\t\tlogMsg += fmt.Sprintf(\" %d markers\", totals.markers)\n\t\t}\n\t\tif j.input.Transcodes {\n\t\t\tlogMsg += fmt.Sprintf(\" %d transcodes\", totals.transcodes)\n\t\t}\n\t\tif j.input.Phashes {\n\t\t\tlogMsg += fmt.Sprintf(\" %d phashes\", totals.phashes)\n\t\t}\n\t\tif j.input.ImagePhashes {\n\t\t\tlogMsg += fmt.Sprintf(\" %d image phashes\", totals.imagePhashes)\n\t\t}\n\t\tif j.input.InteractiveHeatmapsSpeeds {\n\t\t\tlogMsg += fmt.Sprintf(\" %d heatmaps & speeds\", totals.interactiveHeatmapSpeeds)\n\t\t}\n\t\tif j.input.ClipPreviews {\n\t\t\tlogMsg += fmt.Sprintf(\" %d image clip previews\", totals.clipPreviews)\n\t\t}\n\t\tif j.input.ImageThumbnails {\n\t\t\tlogMsg += fmt.Sprintf(\" %d image thumbnails\", totals.imageThumbnails)\n\t\t}\n\t\tif logMsg == \"Generating\" {\n\t\t\tlogMsg = \"Nothing selected to generate\"\n\t\t}\n\t\tlogger.Infof(logMsg)\n\n\t\tprogress.SetTotal(int(totals.tasks))\n\t}()\n\n\twg := sizedwaitgroup.New(parallelTasks)\n\n\t// Start measuring how long the generate has taken. (consider moving this up)\n\tstart := time.Now()\n\tif err = instance.Paths.Generated.EnsureTmpDir(); err != nil {\n\t\tlogger.Warnf(\"could not create temporary directory: %v\", err)\n\t}\n\n\tdefer func() {\n\t\tif err := instance.Paths.Generated.EmptyTmpDir(); err != nil {\n\t\t\tlogger.Warnf(\"failure emptying temporary directory: %v\", err)\n\t\t}\n\t}()\n\n\tfor f := range queue {\n\t\tif job.IsCancelled(ctx) {\n\t\t\tbreak\n\t\t}\n\n\t\twg.Add()\n\t\t// #1879 - need to make a copy of f - otherwise there is a race condition\n\t\t// where f is changed when the goroutine runs\n\t\tlocalTask := f\n\t\tgo progress.ExecuteTask(localTask.GetDescription(), func() {\n\t\t\tlocalTask.Start(ctx)\n\t\t\twg.Done()\n\t\t\tprogress.Increment()\n\t\t})\n\t}\n\n\twg.Wait()\n\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Stopping due to user request\")\n\t\treturn nil\n\t}\n\n\telapsed := time.Since(start)\n\tlogger.Info(fmt.Sprintf(\"Generate finished (%s)\", elapsed))\n\treturn nil\n}\n\nfunc (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {\n\tj.totals = totalsGenerate{}\n\n\tj.queueScenesTasks(ctx, g, paths, queue)\n\tj.queueImagesTasks(ctx, g, paths, queue)\n}\n\nfunc (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {\n\tconst batchSize = 1000\n\n\tfindFilter := models.BatchFindFilter(batchSize)\n\tsceneFilter := scene.FilterFromPaths(paths)\n\n\tr := j.repository\n\n\tfor more := true; more; {\n\t\tif job.IsCancelled(ctx) {\n\t\t\treturn\n\t\t}\n\n\t\tscenes, err := scene.Query(ctx, r.Scene, sceneFilter, findFilter)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error encountered queuing files to scan: %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tfor _, ss := range scenes {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := ss.LoadFiles(ctx, r.Scene); err != nil {\n\t\t\t\tlogger.Errorf(\"Error encountered queuing files to scan: %s\", err.Error())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tj.queueSceneJobs(ctx, g, ss, queue)\n\t\t}\n\n\t\tif len(scenes) != batchSize {\n\t\t\tmore = false\n\t\t} else {\n\t\t\t*findFilter.Page++\n\t\t}\n\t}\n}\n\nfunc (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {\n\tconst batchSize = 1000\n\n\tfindFilter := models.BatchFindFilter(batchSize)\n\timageFilter := image.FilterFromPaths(paths)\n\n\tr := j.repository\n\n\tfor more := j.input.ClipPreviews || j.input.ImageThumbnails || j.input.ImagePhashes; more; {\n\t\tif job.IsCancelled(ctx) {\n\t\t\treturn\n\t\t}\n\n\t\timages, err := image.Query(ctx, r.Image, imageFilter, findFilter)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error encountered queuing files to scan: %s\", err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tfor _, ss := range images {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := ss.LoadFiles(ctx, r.Image); err != nil {\n\t\t\t\tlogger.Errorf(\"Error encountered queuing files to scan: %s\", err.Error())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tj.queueImageJob(g, ss, queue)\n\t\t}\n\n\t\tif len(images) != batchSize {\n\t\t\tmore = false\n\t\t} else {\n\t\t\t*findFilter.Page++\n\t\t}\n\t}\n}\n\nfunc getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generate.PreviewOptions {\n\tconfig := config.GetInstance()\n\n\tret := generate.PreviewOptions{\n\t\tSegments:        config.GetPreviewSegments(),\n\t\tSegmentDuration: config.GetPreviewSegmentDuration(),\n\t\tExcludeStart:    config.GetPreviewExcludeStart(),\n\t\tExcludeEnd:      config.GetPreviewExcludeEnd(),\n\t\tPreset:          config.GetPreviewPreset().String(),\n\t\tAudio:           config.GetPreviewAudio(),\n\t}\n\n\tif optionsInput.PreviewSegments != nil {\n\t\tret.Segments = *optionsInput.PreviewSegments\n\t}\n\n\tif optionsInput.PreviewSegmentDuration != nil {\n\t\tret.SegmentDuration = *optionsInput.PreviewSegmentDuration\n\t}\n\n\tif optionsInput.PreviewExcludeStart != nil {\n\t\tret.ExcludeStart = *optionsInput.PreviewExcludeStart\n\t}\n\n\tif optionsInput.PreviewExcludeEnd != nil {\n\t\tret.ExcludeEnd = *optionsInput.PreviewExcludeEnd\n\t}\n\n\tif optionsInput.PreviewPreset != nil {\n\t\tret.Preset = optionsInput.PreviewPreset.String()\n\t}\n\n\treturn ret\n}\n\nfunc (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task) {\n\tr := j.repository\n\n\tif j.input.Covers {\n\t\ttask := &GenerateCoverTask{\n\t\t\trepository: r,\n\t\t\tScene:      *scene,\n\t\t\tOverwrite:  j.overwrite,\n\t\t}\n\n\t\tif task.required(ctx) {\n\t\t\tj.totals.covers++\n\t\t\tj.totals.tasks++\n\t\t\tqueue <- task\n\t\t}\n\t}\n\n\tif j.input.Sprites {\n\t\ttask := &GenerateSpriteTask{\n\t\t\tScene:               *scene,\n\t\t\tOverwrite:           j.overwrite,\n\t\t\tfileNamingAlgorithm: j.fileNamingAlgo,\n\t\t}\n\n\t\tif task.required() {\n\t\t\tj.totals.sprites++\n\t\t\tj.totals.tasks++\n\t\t\tqueue <- task\n\t\t}\n\t}\n\n\tgeneratePreviewOptions := j.input.PreviewOptions\n\tif generatePreviewOptions == nil {\n\t\tgeneratePreviewOptions = &GeneratePreviewOptionsInput{}\n\t}\n\toptions := getGeneratePreviewOptions(*generatePreviewOptions)\n\n\tif j.input.Previews {\n\t\ttask := &GeneratePreviewTask{\n\t\t\tScene:               *scene,\n\t\t\tImagePreview:        j.input.ImagePreviews,\n\t\t\tOptions:             options,\n\t\t\tOverwrite:           j.overwrite,\n\t\t\tfileNamingAlgorithm: j.fileNamingAlgo,\n\t\t\tgenerator:           g,\n\t\t}\n\n\t\tif task.required() {\n\t\t\tif task.videoPreviewRequired() {\n\t\t\t\tj.totals.previews++\n\t\t\t}\n\t\t\tif task.imagePreviewRequired() {\n\t\t\t\tj.totals.imagePreviews++\n\t\t\t}\n\n\t\t\tj.totals.tasks++\n\t\t\tqueue <- task\n\t\t}\n\t}\n\n\tif j.input.Markers || j.input.MarkerImagePreviews || j.input.MarkerScreenshots {\n\t\ttask := &GenerateMarkersTask{\n\t\t\trepository:          r,\n\t\t\tScene:               scene,\n\t\t\tOverwrite:           j.overwrite,\n\t\t\tfileNamingAlgorithm: j.fileNamingAlgo,\n\t\t\tVideoPreview:        j.input.Markers,\n\t\t\tImagePreview:        j.input.MarkerImagePreviews,\n\t\t\tScreenshot:          j.input.MarkerScreenshots,\n\n\t\t\tgenerator: g,\n\t\t}\n\n\t\tmarkers := task.markersNeeded(ctx)\n\t\tif markers > 0 {\n\t\t\tj.totals.markers += int64(markers)\n\t\t\tj.totals.tasks++\n\n\t\t\tqueue <- task\n\t\t}\n\t}\n\n\tif j.input.Transcodes {\n\t\tforceTranscode := j.input.ForceTranscodes\n\t\ttask := &GenerateTranscodeTask{\n\t\t\tScene:               *scene,\n\t\t\tOverwrite:           j.overwrite,\n\t\t\tForce:               forceTranscode,\n\t\t\tfileNamingAlgorithm: j.fileNamingAlgo,\n\t\t\tg:                   g,\n\t\t}\n\t\tif task.required() {\n\t\t\tj.totals.transcodes++\n\t\t\tj.totals.tasks++\n\t\t\tqueue <- task\n\t\t}\n\t}\n\n\tif j.input.Phashes {\n\t\t// generate for all files in scene\n\t\tfor _, f := range scene.Files.List() {\n\t\t\ttask := &GeneratePhashTask{\n\t\t\t\trepository:          r,\n\t\t\t\tFile:                f,\n\t\t\t\tfileNamingAlgorithm: j.fileNamingAlgo,\n\t\t\t\tOverwrite:           j.overwrite,\n\t\t\t}\n\n\t\t\tif task.required() {\n\t\t\t\tj.totals.phashes++\n\t\t\t\tj.totals.tasks++\n\t\t\t\tqueue <- task\n\t\t\t}\n\t\t}\n\t}\n\n\tif j.input.InteractiveHeatmapsSpeeds {\n\t\ttask := &GenerateInteractiveHeatmapSpeedTask{\n\t\t\trepository:          r,\n\t\t\tScene:               *scene,\n\t\t\tOverwrite:           j.overwrite,\n\t\t\tfileNamingAlgorithm: j.fileNamingAlgo,\n\t\t}\n\n\t\tif task.required() {\n\t\t\tj.totals.interactiveHeatmapSpeeds++\n\t\t\tj.totals.tasks++\n\t\t\tqueue <- task\n\t\t}\n\t}\n}\n\nfunc (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.SceneMarker, queue chan<- Task) {\n\ttask := &GenerateMarkersTask{\n\t\trepository:          j.repository,\n\t\tMarker:              marker,\n\t\tOverwrite:           j.overwrite,\n\t\tfileNamingAlgorithm: j.fileNamingAlgo,\n\t\tVideoPreview:        j.input.Markers,\n\t\tImagePreview:        j.input.MarkerImagePreviews,\n\t\tScreenshot:          j.input.MarkerScreenshots,\n\t\tgenerator:           g,\n\t}\n\tj.totals.markers++\n\tj.totals.tasks++\n\tqueue <- task\n}\n\nfunc (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task) {\n\tif j.input.ImageThumbnails {\n\t\ttask := &GenerateImageThumbnailTask{\n\t\t\tImage:     *image,\n\t\t\tOverwrite: j.overwrite,\n\t\t}\n\n\t\tif task.required() {\n\t\t\tj.totals.imageThumbnails++\n\t\t\tj.totals.tasks++\n\t\t\tqueue <- task\n\t\t}\n\t}\n\n\tif j.input.ClipPreviews {\n\t\ttask := &GenerateClipPreviewTask{\n\t\t\tImage:     *image,\n\t\t\tOverwrite: j.overwrite,\n\t\t}\n\n\t\tif task.required() {\n\t\t\tj.totals.clipPreviews++\n\t\t\tj.totals.tasks++\n\t\t\tqueue <- task\n\t\t}\n\t}\n\n\tif j.input.ImagePhashes {\n\t\t// generate for all files in image\n\t\tfor _, f := range image.Files.List() {\n\t\t\tif imageFile, ok := f.(*models.ImageFile); ok {\n\t\t\t\ttask := &GenerateImagePhashTask{\n\t\t\t\t\trepository: j.repository,\n\t\t\t\t\tFile:       imageFile,\n\t\t\t\t\tOverwrite:  j.overwrite,\n\t\t\t\t}\n\n\t\t\t\tif task.required() {\n\t\t\t\t\tj.totals.imagePhashes++\n\t\t\t\t\tj.totals.tasks++\n\t\t\t\t\tqueue <- task\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/manager/task_generate_clip_preview.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GenerateClipPreviewTask struct {\n\tImage     models.Image\n\tOverwrite bool\n}\n\nfunc (t *GenerateClipPreviewTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating Preview for image Clip %s\", t.Image.Path)\n}\n\nfunc (t *GenerateClipPreviewTask) Start(ctx context.Context) {\n\tif !t.required() {\n\t\treturn\n\t}\n\n\tprevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth)\n\tfilePath := t.Image.Files.Primary().Base().Path\n\n\tclipPreviewOptions := image.ClipPreviewOptions{\n\t\tInputArgs:  GetInstance().Config.GetTranscodeInputArgs(),\n\t\tOutputArgs: GetInstance().Config.GetTranscodeOutputArgs(),\n\t\tPreset:     GetInstance().Config.GetPreviewPreset().String(),\n\t}\n\n\tencoder := image.NewThumbnailEncoder(GetInstance().FFMpeg, GetInstance().FFProbe, clipPreviewOptions)\n\terr := encoder.GetPreview(filePath, prevPath, models.DefaultGthumbWidth)\n\tif err != nil {\n\t\tlogger.Errorf(\"getting preview for image %s: %w\", filePath, err)\n\t\treturn\n\t}\n\n}\n\nfunc (t *GenerateClipPreviewTask) required() bool {\n\t_, ok := t.Image.Files.Primary().(*models.VideoFile)\n\tif !ok {\n\t\treturn false\n\t}\n\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\tprevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth)\n\tif exists, _ := fsutil.FileExists(prevPath); exists {\n\t\treturn false\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "internal/manager/task_generate_image_phash.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/hash/imagephash\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GenerateImagePhashTask struct {\n\trepository models.Repository\n\tFile       *models.ImageFile\n\tOverwrite  bool\n}\n\nfunc (t *GenerateImagePhashTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating phash for %s\", t.File.Path)\n}\n\nfunc (t *GenerateImagePhashTask) Start(ctx context.Context) {\n\tif !t.required() {\n\t\treturn\n\t}\n\n\tvar hash int64\n\tset := false\n\n\t// #4393 - if there is a file with the same md5, we can use the same phash\n\t// only use this if we're not overwriting\n\tif !t.Overwrite {\n\t\texisting, err := t.findExistingPhash(ctx)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Error finding existing phash: %v\", err)\n\t\t} else if existing != nil {\n\t\t\tlogger.Infof(\"Using existing phash for %s\", t.File.Path)\n\t\t\thash = existing.(int64)\n\t\t\tset = true\n\t\t}\n\t}\n\n\tif !set {\n\t\tgenerated, err := imagephash.Generate(instance.FFMpeg, t.File)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error generating phash for %q: %v\", t.File.Path, err)\n\t\t\tlogErrorOutput(err)\n\t\t\treturn\n\t\t}\n\n\t\thash = int64(*generated)\n\t}\n\n\tr := t.repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tt.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{\n\t\t\tType:        models.FingerprintTypePhash,\n\t\t\tFingerprint: hash,\n\t\t})\n\n\t\treturn r.File.Update(ctx, t.File)\n\t}); err != nil && ctx.Err() == nil {\n\t\tlogger.Errorf(\"Error setting phash: %v\", err)\n\t}\n}\n\nfunc (t *GenerateImagePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) {\n\tr := t.repository\n\tvar ret interface{}\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tmd5 := t.File.Fingerprints.Get(models.FingerprintTypeMD5)\n\n\t\t// find other files with the same md5\n\t\tfiles, err := r.File.FindByFingerprint(ctx, models.Fingerprint{\n\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\tFingerprint: md5,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding files by md5: %w\", err)\n\t\t}\n\n\t\t// find the first file with a phash\n\t\tfor _, file := range files {\n\t\t\tif phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil {\n\t\t\t\tret = phash\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *GenerateImagePhashTask) required() bool {\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\treturn t.File.Fingerprints.Get(models.FingerprintTypePhash) == nil\n}\n"
  },
  {
    "path": "internal/manager/task_generate_image_thumbnail.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os/exec\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GenerateImageThumbnailTask struct {\n\tImage     models.Image\n\tOverwrite bool\n}\n\nfunc (t *GenerateImageThumbnailTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating Thumbnail for image %s\", t.Image.Path)\n}\n\nfunc (t *GenerateImageThumbnailTask) logStderr(err error) {\n\tvar exitErr *exec.ExitError\n\tif errors.As(err, &exitErr) {\n\t\tlogger.Debugf(\"[generator] error output: %s\", exitErr.Stderr)\n\t}\n}\n\nfunc (t *GenerateImageThumbnailTask) Start(ctx context.Context) {\n\tif !t.required() {\n\t\treturn\n\t}\n\n\tthumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth)\n\tf := t.Image.Files.Primary()\n\tpath := f.Base().Path\n\n\tlogger.Debugf(\"Generating thumbnail for %s\", path)\n\n\tmgr := GetInstance()\n\tc := mgr.Config\n\n\tclipPreviewOptions := image.ClipPreviewOptions{\n\t\tInputArgs:  c.GetTranscodeInputArgs(),\n\t\tOutputArgs: c.GetTranscodeOutputArgs(),\n\t\tPreset:     c.GetPreviewPreset().String(),\n\t}\n\n\tencoder := image.NewThumbnailEncoder(mgr.FFMpeg, mgr.FFProbe, clipPreviewOptions)\n\tdata, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)\n\n\tif err != nil {\n\t\t// don't log for animated images\n\t\tif !errors.Is(err, image.ErrNotSupportedForThumbnail) {\n\t\t\tlogger.Errorf(\"[generator] getting thumbnail for image %s: %s\", path, err.Error())\n\t\t\tt.logStderr(err)\n\t\t}\n\t\treturn\n\t}\n\n\terr = fsutil.WriteFile(thumbPath, data)\n\tif err != nil {\n\t\tlogger.Errorf(\"[generator] writing thumbnail for image %s: %s\", path, err.Error())\n\t\treturn\n\t}\n}\n\nfunc (t *GenerateImageThumbnailTask) required() bool {\n\tvf, ok := t.Image.Files.Primary().(models.VisualFile)\n\tif !ok {\n\t\treturn false\n\t}\n\n\tif vf.GetHeight() <= models.DefaultGthumbWidth && vf.GetWidth() <= models.DefaultGthumbWidth {\n\t\treturn false\n\t}\n\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\tthumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth)\n\texists, _ := fsutil.FileExists(thumbPath)\n\n\treturn !exists\n}\n"
  },
  {
    "path": "internal/manager/task_generate_interactive_heatmap_speed.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/file/video\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GenerateInteractiveHeatmapSpeedTask struct {\n\trepository          models.Repository\n\tScene               models.Scene\n\tOverwrite           bool\n\tfileNamingAlgorithm models.HashAlgorithm\n}\n\nfunc (t *GenerateInteractiveHeatmapSpeedTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating heatmap and speed for %s\", t.Scene.Path)\n}\n\nfunc (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {\n\tif !t.required() {\n\t\treturn\n\t}\n\n\tvideoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm)\n\tfunscriptPath := video.GetFunscriptPath(t.Scene.Path)\n\theatmapPath := instance.Paths.Scene.GetInteractiveHeatmapPath(videoChecksum)\n\tdrawRange := instance.Config.GetDrawFunscriptHeatmapRange()\n\n\tgenerator := NewInteractiveHeatmapSpeedGenerator(drawRange)\n\n\terr := generator.Generate(funscriptPath, heatmapPath, t.Scene.Files.Primary().Duration)\n\n\tif err != nil {\n\t\tlogger.Errorf(\"error generating heatmap for %s: %s\", t.Scene.Path, err.Error())\n\t\treturn\n\t}\n\n\tmedian := generator.InteractiveSpeed\n\n\tr := t.repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tprimaryFile := t.Scene.Files.Primary()\n\t\tprimaryFile.InteractiveSpeed = &median\n\t\tif err := r.File.Update(ctx, primaryFile); err != nil {\n\t\t\treturn fmt.Errorf(\"updating interactive speed for %s: %w\", primaryFile.Path, err)\n\t\t}\n\n\t\t// update the scene UpdatedAt field\n\t\t// NewScenePartial sets the UpdatedAt field to the current time\n\t\tif _, err := r.Scene.UpdatePartial(ctx, t.Scene.ID, models.NewScenePartial()); err != nil {\n\t\t\treturn fmt.Errorf(\"updating UpdatedAt field for scene %d: %w\", t.Scene.ID, err)\n\t\t}\n\t\treturn nil\n\t}); err != nil && ctx.Err() == nil {\n\t\tlogger.Error(err.Error())\n\t}\n}\n\nfunc (t *GenerateInteractiveHeatmapSpeedTask) required() bool {\n\tprimaryFile := t.Scene.Files.Primary()\n\tif primaryFile == nil || !primaryFile.Interactive {\n\t\treturn false\n\t}\n\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\tsceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)\n\treturn !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil\n}\n\nfunc (t *GenerateInteractiveHeatmapSpeedTask) doesHeatmapExist(sceneChecksum string) bool {\n\tif sceneChecksum == \"\" {\n\t\treturn false\n\t}\n\n\timageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetInteractiveHeatmapPath(sceneChecksum))\n\treturn imageExists\n}\n"
  },
  {
    "path": "internal/manager/task_generate_markers.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene/generate\"\n)\n\ntype GenerateMarkersTask struct {\n\trepository          models.Repository\n\tScene               *models.Scene\n\tMarker              *models.SceneMarker\n\tOverwrite           bool\n\tfileNamingAlgorithm models.HashAlgorithm\n\n\tVideoPreview bool\n\tImagePreview bool\n\tScreenshot   bool\n\n\tgenerator *generate.Generator\n}\n\nfunc (t *GenerateMarkersTask) GetDescription() string {\n\tif t.Scene != nil {\n\t\treturn fmt.Sprintf(\"Generating markers for %s\", t.Scene.Path)\n\t} else if t.Marker != nil {\n\t\treturn fmt.Sprintf(\"Generating marker preview for marker ID %d\", t.Marker.ID)\n\t}\n\n\treturn \"Generating markers\"\n}\n\nfunc (t *GenerateMarkersTask) Start(ctx context.Context) {\n\tif t.Scene != nil {\n\t\tt.generateSceneMarkers(ctx)\n\t}\n\n\tif t.Marker != nil {\n\t\tvar scene *models.Scene\n\t\tr := t.repository\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tvar err error\n\t\t\tscene, err = r.Scene.Find(ctx, t.Marker.SceneID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif scene == nil {\n\t\t\t\treturn fmt.Errorf(\"scene with id %d not found\", t.Marker.SceneID)\n\t\t\t}\n\n\t\t\treturn scene.LoadPrimaryFile(ctx, r.File)\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"error finding scene for marker generation: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tvideoFile := scene.Files.Primary()\n\n\t\tif videoFile == nil {\n\t\t\t// nothing to do\n\t\t\treturn\n\t\t}\n\n\t\tt.generateMarker(videoFile, scene, t.Marker)\n\t}\n}\n\nfunc (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) {\n\tvar sceneMarkers []*models.SceneMarker\n\tr := t.repository\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\tsceneMarkers, err = r.SceneMarker.FindBySceneID(ctx, t.Scene.ID)\n\t\treturn err\n\t}); err != nil {\n\t\tlogger.Errorf(\"error getting scene markers: %s\", err.Error())\n\t\treturn\n\t}\n\n\tvideoFile := t.Scene.Files.Primary()\n\n\tif len(sceneMarkers) == 0 || videoFile == nil {\n\t\treturn\n\t}\n\n\tsceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)\n\n\t// Make the folder for the scenes markers\n\tmarkersFolder := filepath.Join(instance.Paths.Generated.Markers, sceneHash)\n\tif err := fsutil.EnsureDir(markersFolder); err != nil {\n\t\tlogger.Warnf(\"could not create the markers folder (%v): %v\", markersFolder, err)\n\t}\n\n\tfor i, sceneMarker := range sceneMarkers {\n\t\tindex := i + 1\n\t\tlogger.Progressf(\"[generator] <%s> scene marker %d of %d\", sceneHash, index, len(sceneMarkers))\n\n\t\tt.generateMarker(videoFile, t.Scene, sceneMarker)\n\t}\n}\n\nfunc (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) {\n\tsceneHash := scene.GetHash(t.fileNamingAlgorithm)\n\tseconds := float64(sceneMarker.Seconds)\n\n\t// check if marker past duration\n\tif seconds > float64(videoFile.Duration) {\n\t\tlogger.Warnf(\"[generator] scene marker at %.2f seconds exceeds video duration of %.2f seconds, skipping\", seconds, float64(videoFile.Duration))\n\t\treturn\n\t}\n\n\tg := t.generator\n\n\tif t.VideoPreview {\n\t\tif err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {\n\t\t\tlogger.Errorf(\"[generator] failed to generate marker video: %v\", err)\n\t\t\tlogErrorOutput(err)\n\t\t}\n\t}\n\n\tif t.ImagePreview {\n\t\tif err := g.SceneMarkerWebp(context.TODO(), videoFile.Path, sceneHash, seconds); err != nil {\n\t\t\tlogger.Errorf(\"[generator] failed to generate marker image: %v\", err)\n\t\t\tlogErrorOutput(err)\n\t\t}\n\t}\n\n\tif t.Screenshot {\n\t\tif err := g.SceneMarkerScreenshot(context.TODO(), videoFile.Path, sceneHash, seconds, videoFile.Width); err != nil {\n\t\t\tlogger.Errorf(\"[generator] failed to generate marker screenshot: %v\", err)\n\t\t\tlogErrorOutput(err)\n\t\t}\n\t}\n}\n\nfunc (t *GenerateMarkersTask) markersNeeded(ctx context.Context) int {\n\tmarkers := 0\n\tsceneMarkers, err := t.repository.SceneMarker.FindBySceneID(ctx, t.Scene.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"error finding scene markers: %s\", err.Error())\n\t\treturn 0\n\t}\n\n\tif len(sceneMarkers) == 0 || t.Scene.Files.Primary() == nil {\n\t\treturn 0\n\t}\n\n\tsceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)\n\tfor _, sceneMarker := range sceneMarkers {\n\t\tseconds := int(sceneMarker.Seconds)\n\n\t\tif t.Overwrite || !t.markerExists(sceneHash, seconds) {\n\t\t\tmarkers++\n\t\t}\n\t}\n\n\treturn markers\n}\n\nfunc (t *GenerateMarkersTask) markerExists(sceneChecksum string, seconds int) bool {\n\tif sceneChecksum == \"\" {\n\t\treturn false\n\t}\n\n\tvideoExists := !t.VideoPreview || t.videoExists(sceneChecksum, seconds)\n\timageExists := !t.ImagePreview || t.imageExists(sceneChecksum, seconds)\n\tscreenshotExists := !t.Screenshot || t.screenshotExists(sceneChecksum, seconds)\n\n\treturn videoExists && imageExists && screenshotExists\n}\n\nfunc (t *GenerateMarkersTask) videoExists(sceneChecksum string, seconds int) bool {\n\tif sceneChecksum == \"\" {\n\t\treturn false\n\t}\n\n\tvideoPath := instance.Paths.SceneMarkers.GetVideoPreviewPath(sceneChecksum, seconds)\n\tvideoExists, _ := fsutil.FileExists(videoPath)\n\n\treturn videoExists\n}\n\nfunc (t *GenerateMarkersTask) imageExists(sceneChecksum string, seconds int) bool {\n\tif sceneChecksum == \"\" {\n\t\treturn false\n\t}\n\n\timagePath := instance.Paths.SceneMarkers.GetWebpPreviewPath(sceneChecksum, seconds)\n\timageExists, _ := fsutil.FileExists(imagePath)\n\n\treturn imageExists\n}\n\nfunc (t *GenerateMarkersTask) screenshotExists(sceneChecksum string, seconds int) bool {\n\tif sceneChecksum == \"\" {\n\t\treturn false\n\t}\n\n\tscreenshotPath := instance.Paths.SceneMarkers.GetScreenshotPath(sceneChecksum, seconds)\n\tscreenshotExists, _ := fsutil.FileExists(screenshotPath)\n\n\treturn screenshotExists\n}\n"
  },
  {
    "path": "internal/manager/task_generate_phash.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/hash/videophash\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GeneratePhashTask struct {\n\trepository          models.Repository\n\tFile                *models.VideoFile\n\tOverwrite           bool\n\tfileNamingAlgorithm models.HashAlgorithm\n}\n\nfunc (t *GeneratePhashTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating phash for %s\", t.File.Path)\n}\n\nfunc (t *GeneratePhashTask) Start(ctx context.Context) {\n\tif !t.required() {\n\t\treturn\n\t}\n\n\tvar hash int64\n\tset := false\n\n\t// #4393 - if there is a file with the same oshash, we can use the same phash\n\t// only use this if we're not overwriting\n\tif !t.Overwrite {\n\t\texisting, err := t.findExistingPhash(ctx)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Error finding existing phash: %v\", err)\n\t\t} else if existing != nil {\n\t\t\tlogger.Infof(\"Using existing phash for %s\", t.File.Path)\n\t\t\thash = existing.(int64)\n\t\t\tset = true\n\t\t}\n\t}\n\n\tif !set {\n\t\tgenerated, err := videophash.Generate(instance.FFMpeg, t.File)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error generating phash for %q: %v\", t.File.Path, err)\n\t\t\tlogErrorOutput(err)\n\t\t\treturn\n\t\t}\n\n\t\thash = int64(*generated)\n\t}\n\n\tr := t.repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tt.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{\n\t\t\tType:        models.FingerprintTypePhash,\n\t\t\tFingerprint: hash,\n\t\t})\n\n\t\treturn r.File.Update(ctx, t.File)\n\t}); err != nil && ctx.Err() == nil {\n\t\tlogger.Errorf(\"Error setting phash: %v\", err)\n\t}\n}\n\nfunc (t *GeneratePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) {\n\tr := t.repository\n\tvar ret interface{}\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\toshash := t.File.Fingerprints.Get(models.FingerprintTypeOshash)\n\n\t\t// find other files with the same oshash\n\t\tfiles, err := r.File.FindByFingerprint(ctx, models.Fingerprint{\n\t\t\tType:        models.FingerprintTypeOshash,\n\t\t\tFingerprint: oshash,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding files by oshash: %w\", err)\n\t\t}\n\n\t\t// find the first file with a phash\n\t\tfor _, file := range files {\n\t\t\tif phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil {\n\t\t\t\tret = phash\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *GeneratePhashTask) required() bool {\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\treturn t.File.Fingerprints.Get(models.FingerprintTypePhash) == nil\n}\n"
  },
  {
    "path": "internal/manager/task_generate_preview.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene/generate\"\n)\n\ntype GeneratePreviewTask struct {\n\tScene        models.Scene\n\tImagePreview bool\n\n\tOptions generate.PreviewOptions\n\n\tOverwrite           bool\n\tfileNamingAlgorithm models.HashAlgorithm\n\n\tgenerator *generate.Generator\n\n\tvideoPreviewExists *bool\n\timagePreviewExists *bool\n}\n\nfunc (t *GeneratePreviewTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating preview for %s\", t.Scene.Path)\n}\n\nfunc (t *GeneratePreviewTask) Start(ctx context.Context) {\n\tvideoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm)\n\n\tif t.videoPreviewRequired() {\n\t\tffprobe := instance.FFProbe\n\t\tvideoFile, err := ffprobe.NewVideoFile(t.Scene.Path)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error reading video file: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tif err := t.generateVideo(videoChecksum, videoFile.VideoStreamDuration, videoFile.FrameRate); err != nil {\n\t\t\tlogger.Errorf(\"error generating preview: %v\", err)\n\t\t\tlogErrorOutput(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif t.imagePreviewRequired() {\n\t\tif err := t.generateWebp(videoChecksum); err != nil {\n\t\t\tlogger.Errorf(\"error generating preview webp: %v\", err)\n\t\t\tlogErrorOutput(err)\n\t\t}\n\t}\n}\n\nfunc (t *GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64, videoFrameRate float64) error {\n\tvideoFilename := t.Scene.Path\n\tuseVsync2 := false\n\n\tif videoFrameRate <= 0.01 {\n\t\tlogger.Errorf(\"[generator] Video framerate very low/high (%f) most likely vfr so using -vsync 2\", videoFrameRate)\n\t\tuseVsync2 = true\n\t}\n\n\tif err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, false, useVsync2); err != nil {\n\t\tlogger.Warnf(\"[generator] failed generating scene preview, trying fallback\")\n\t\tif err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, true, useVsync2); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *GeneratePreviewTask) generateWebp(videoChecksum string) error {\n\tvideoFilename := t.Scene.Path\n\treturn t.generator.PreviewWebp(context.TODO(), videoFilename, videoChecksum)\n}\n\nfunc (t *GeneratePreviewTask) required() bool {\n\treturn t.videoPreviewRequired() || t.imagePreviewRequired()\n}\n\nfunc (t *GeneratePreviewTask) videoPreviewRequired() bool {\n\tif t.Scene.Path == \"\" {\n\t\treturn false\n\t}\n\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\tsceneChecksum := t.Scene.GetHash(t.fileNamingAlgorithm)\n\tif sceneChecksum == \"\" {\n\t\treturn false\n\t}\n\n\tif t.videoPreviewExists == nil {\n\t\tvideoExists, _ := fsutil.FileExists(instance.Paths.Scene.GetVideoPreviewPath(sceneChecksum))\n\t\tt.videoPreviewExists = &videoExists\n\t}\n\n\treturn !*t.videoPreviewExists\n}\n\nfunc (t *GeneratePreviewTask) imagePreviewRequired() bool {\n\tif !t.ImagePreview {\n\t\treturn false\n\t}\n\n\tif t.Scene.Path == \"\" {\n\t\treturn false\n\t}\n\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\tsceneChecksum := t.Scene.GetHash(t.fileNamingAlgorithm)\n\tif sceneChecksum == \"\" {\n\t\treturn false\n\t}\n\n\tif t.imagePreviewExists == nil {\n\t\timageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetWebpPreviewPath(sceneChecksum))\n\t\tt.imagePreviewExists = &imageExists\n\t}\n\n\treturn !*t.imagePreviewExists\n}\n"
  },
  {
    "path": "internal/manager/task_generate_screenshot.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene/generate\"\n)\n\ntype GenerateCoverTask struct {\n\trepository   models.Repository\n\tScene        models.Scene\n\tScreenshotAt *float64\n\tOverwrite    bool\n}\n\nfunc (t *GenerateCoverTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating cover for %s\", t.Scene.GetTitle())\n}\n\nfunc (t *GenerateCoverTask) Start(ctx context.Context) {\n\tscenePath := t.Scene.Path\n\n\tr := t.repository\n\n\tvar required bool\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\trequired = t.required(ctx)\n\n\t\treturn t.Scene.LoadPrimaryFile(ctx, r.File)\n\t}); err != nil {\n\t\tlogger.Error(err)\n\t\treturn\n\t}\n\n\tif !required {\n\t\treturn\n\t}\n\n\tvideoFile := t.Scene.Files.Primary()\n\tif videoFile == nil {\n\t\treturn\n\t}\n\n\tvar at float64\n\tif t.ScreenshotAt == nil {\n\t\tat = float64(videoFile.Duration) * 0.2\n\t} else {\n\t\tat = *t.ScreenshotAt\n\t}\n\n\t// we'll generate the screenshot, grab the generated data and set it\n\t// in the database.\n\n\tlogger.Debugf(\"Creating screenshot for %s\", scenePath)\n\n\tg := generate.Generator{\n\t\tEncoder:      instance.FFMpeg,\n\t\tFFMpegConfig: instance.Config,\n\t\tLockManager:  instance.ReadLockManager,\n\t\tScenePaths:   instance.Paths.Scene,\n\t\tOverwrite:    true,\n\t}\n\n\tcoverImageData, err := g.Screenshot(context.TODO(), videoFile.Path, videoFile.Width, videoFile.Duration, generate.ScreenshotOptions{\n\t\tAt: &at,\n\t})\n\tif err != nil {\n\t\tlogger.Errorf(\"Error generating screenshot: %v\", err)\n\t\tlogErrorOutput(err)\n\t\treturn\n\t}\n\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.Scene\n\t\tscenePartial := models.NewScenePartial()\n\n\t\t// update the scene cover table\n\t\tif err := qb.UpdateCover(ctx, t.Scene.ID, coverImageData); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting screenshot: %v\", err)\n\t\t}\n\n\t\t// update the scene with the update date\n\t\t_, err = qb.UpdatePartial(ctx, t.Scene.ID, scenePartial)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error updating scene: %v\", err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil && ctx.Err() == nil {\n\t\tlogger.Error(err.Error())\n\t}\n}\n\n// required returns true if the sprite needs to be generated\n// assumes in a transaction\nfunc (t *GenerateCoverTask) required(ctx context.Context) bool {\n\tif t.Scene.Path == \"\" {\n\t\treturn false\n\t}\n\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\t// if the scene has a cover, then we don't need to generate it\n\thasCover, err := t.repository.Scene.HasCover(ctx, t.Scene.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error getting cover: %v\", err)\n\t\treturn false\n\t}\n\n\treturn !hasCover\n}\n"
  },
  {
    "path": "internal/manager/task_generate_sprite.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype GenerateSpriteTask struct {\n\tScene               models.Scene\n\tOverwrite           bool\n\tfileNamingAlgorithm models.HashAlgorithm\n}\n\nfunc (t *GenerateSpriteTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating sprites for %s\", t.Scene.Path)\n}\n\nfunc (t *GenerateSpriteTask) Start(ctx context.Context) {\n\tif !t.required() {\n\t\treturn\n\t}\n\n\tffprobe := instance.FFProbe\n\tvideoFile, err := ffprobe.NewVideoFile(t.Scene.Path)\n\tif err != nil {\n\t\tlogger.Errorf(\"error reading video file: %s\", err.Error())\n\t\treturn\n\t}\n\n\tsceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)\n\timagePath := instance.Paths.Scene.GetSpriteImageFilePath(sceneHash)\n\tvttPath := instance.Paths.Scene.GetSpriteVttFilePath(sceneHash)\n\n\tcfg := DefaultSpriteGeneratorConfig\n\tcfg.SpriteSize = instance.Config.GetSpriteScreenshotSize()\n\n\tif instance.Config.GetUseCustomSpriteInterval() {\n\t\tcfg.MinimumSprites = instance.Config.GetMinimumSprites()\n\t\tcfg.MaximumSprites = instance.Config.GetMaximumSprites()\n\t\tcfg.SpriteInterval = instance.Config.GetSpriteInterval()\n\t}\n\n\tgenerator, err := NewSpriteGenerator(*videoFile, sceneHash, imagePath, vttPath, cfg)\n\n\tif err != nil {\n\t\tlogger.Errorf(\"error creating sprite generator: %s\", err.Error())\n\t\treturn\n\t}\n\tgenerator.Overwrite = t.Overwrite\n\n\tif err := generator.Generate(); err != nil {\n\t\tlogger.Errorf(\"error generating sprite: %s\", err.Error())\n\t\tlogErrorOutput(err)\n\t\treturn\n\t}\n}\n\n// required returns true if the sprite needs to be generated\nfunc (t GenerateSpriteTask) required() bool {\n\tif t.Scene.Path == \"\" {\n\t\treturn false\n\t}\n\n\tif t.Overwrite {\n\t\treturn true\n\t}\n\n\tsceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)\n\treturn !t.doesSpriteExist(sceneHash)\n}\n\nfunc (t *GenerateSpriteTask) doesSpriteExist(sceneChecksum string) bool {\n\tif sceneChecksum == \"\" {\n\t\treturn false\n\t}\n\n\timageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetSpriteImageFilePath(sceneChecksum))\n\tvttExists, _ := fsutil.FileExists(instance.Paths.Scene.GetSpriteVttFilePath(sceneChecksum))\n\treturn imageExists && vttExists\n}\n"
  },
  {
    "path": "internal/manager/task_identify.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/internal/identify\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/stashbox\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\nvar ErrInput = errors.New(\"invalid request input\")\n\ntype IdentifyJob struct {\n\tpostHookExecutor identify.SceneUpdatePostHookExecutor\n\tinput            identify.Options\n\n\tstashBoxes []*models.StashBox\n\tprogress   *job.Progress\n}\n\nfunc CreateIdentifyJob(input identify.Options) *IdentifyJob {\n\treturn &IdentifyJob{\n\t\tpostHookExecutor: instance.PluginCache,\n\t\tinput:            input,\n\t\tstashBoxes:       instance.Config.GetStashBoxes(),\n\t}\n}\n\nfunc (j *IdentifyJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tj.progress = progress\n\n\t// if no sources provided - just return\n\tif len(j.input.Sources) == 0 {\n\t\treturn nil\n\t}\n\n\tsources, err := j.getSources()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// if scene ids provided, use those\n\t// otherwise, batch query for all scenes - ordering by path\n\t// don't use a transaction to query scenes\n\tr := instance.Repository\n\tif err := r.WithDB(ctx, func(ctx context.Context) error {\n\t\tif len(j.input.SceneIDs) == 0 {\n\t\t\treturn j.identifyAllScenes(ctx, sources)\n\t\t}\n\n\t\tsceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid scene IDs: %w\", err)\n\t\t}\n\n\t\tprogress.SetTotal(len(sceneIDs))\n\t\tfor _, id := range sceneIDs {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// find the scene\n\t\t\tvar err error\n\t\t\tscene, err := r.Scene.Find(ctx, id)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"finding scene id %d: %w\", id, err)\n\t\t\t}\n\n\t\t\tif scene == nil {\n\t\t\t\treturn fmt.Errorf(\"scene with id %d not found\", id)\n\t\t\t}\n\n\t\t\tj.identifyScene(ctx, scene, sources)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"error encountered while identifying scenes: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (j *IdentifyJob) identifyAllScenes(ctx context.Context, sources []identify.ScraperSource) error {\n\tr := instance.Repository\n\n\t// exclude organised\n\torganised := false\n\tsceneFilter := scene.FilterFromPaths(j.input.Paths)\n\tsceneFilter.Organized = &organised\n\n\tsort := \"path\"\n\tfindFilter := &models.FindFilterType{\n\t\tSort: &sort,\n\t}\n\n\t// get the count\n\tpp := 0\n\tfindFilter.PerPage = &pp\n\tcountResult, err := r.Scene.Query(ctx, models.SceneQueryOptions{\n\t\tQueryOptions: models.QueryOptions{\n\t\t\tFindFilter: findFilter,\n\t\t\tCount:      true,\n\t\t},\n\t\tSceneFilter: sceneFilter,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting scene count: %w\", err)\n\t}\n\n\tj.progress.SetTotal(countResult.Count)\n\n\treturn scene.BatchProcess(ctx, r.Scene, sceneFilter, findFilter, func(scene *models.Scene) error {\n\t\tif job.IsCancelled(ctx) {\n\t\t\treturn nil\n\t\t}\n\n\t\tj.identifyScene(ctx, scene, sources)\n\t\treturn nil\n\t})\n}\n\nfunc (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, sources []identify.ScraperSource) {\n\tif job.IsCancelled(ctx) {\n\t\treturn\n\t}\n\n\tvar taskError error\n\tj.progress.ExecuteTask(\"Identifying \"+s.Path, func() {\n\t\tr := instance.Repository\n\t\ttask := identify.SceneIdentifier{\n\t\t\tTxnManager:         r.TxnManager,\n\t\t\tSceneReaderUpdater: r.Scene,\n\t\t\tStudioReaderWriter: r.Studio,\n\t\t\tPerformerCreator:   r.Performer,\n\t\t\tTagFinderCreator:   r.Tag,\n\n\t\t\tDefaultOptions:              j.input.Options,\n\t\t\tSources:                     sources,\n\t\t\tSceneUpdatePostHookExecutor: j.postHookExecutor,\n\t\t}\n\n\t\ttaskError = task.Identify(ctx, s)\n\t})\n\n\tif taskError != nil {\n\t\tlogger.Errorf(\"Error encountered identifying %s: %v\", s.Path, taskError)\n\t}\n\n\tj.progress.Increment()\n}\n\nfunc (j *IdentifyJob) getSources() ([]identify.ScraperSource, error) {\n\tvar ret []identify.ScraperSource\n\tfor _, source := range j.input.Sources {\n\t\t// get scraper source\n\t\tstashBox, err := j.getStashBox(source.Source)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar src identify.ScraperSource\n\t\tif stashBox != nil {\n\t\t\tmatcher := match.SceneRelationships{\n\t\t\t\tPerformerFinder: instance.Repository.Performer,\n\t\t\t\tTagFinder:       instance.Repository.Tag,\n\t\t\t\tStudioFinder:    instance.Repository.Studio,\n\t\t\t}\n\n\t\t\tsrc = identify.ScraperSource{\n\t\t\t\tName: \"stash-box: \" + stashBox.Endpoint,\n\t\t\t\tScraper: stashboxSource{\n\t\t\t\t\tClient:                 stashbox.NewClient(*stashBox, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())),\n\t\t\t\t\tendpoint:               stashBox.Endpoint,\n\t\t\t\t\ttxnManager:             instance.Repository.TxnManager,\n\t\t\t\t\tsceneFingerprintGetter: instance.SceneService,\n\t\t\t\t\tmatcher:                matcher,\n\t\t\t\t},\n\t\t\t\tRemoteSite: stashBox.Endpoint,\n\t\t\t}\n\t\t} else {\n\t\t\tscraperID := *source.Source.ScraperID\n\t\t\ts := instance.ScraperCache.GetScraper(scraperID)\n\t\t\tif s == nil {\n\t\t\t\treturn nil, fmt.Errorf(\"%w: scraper with id %q\", models.ErrNotFound, scraperID)\n\t\t\t}\n\t\t\tsrc = identify.ScraperSource{\n\t\t\t\tName: s.Name,\n\t\t\t\tScraper: scraperSource{\n\t\t\t\t\tcache:     instance.ScraperCache,\n\t\t\t\t\tscraperID: scraperID,\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\n\t\tsrc.Options = source.Options\n\t\tret = append(ret, src)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (j *IdentifyJob) getStashBox(src *scraper.Source) (*models.StashBox, error) {\n\tif src.ScraperID != nil {\n\t\treturn nil, nil\n\t}\n\n\t// must be stash-box\n\tif src.StashBoxIndex == nil && src.StashBoxEndpoint == nil {\n\t\treturn nil, fmt.Errorf(\"%w: stash_box_index or stash_box_endpoint or scraper_id must be set\", ErrInput)\n\t}\n\n\treturn resolveStashBox(j.stashBoxes, *src)\n}\n\nfunc resolveStashBox(sb []*models.StashBox, source scraper.Source) (*models.StashBox, error) {\n\tif source.StashBoxIndex != nil {\n\t\tindex := source.StashBoxIndex\n\t\tif *index < 0 || *index >= len(sb) {\n\t\t\treturn nil, fmt.Errorf(\"%w: invalid stash_box_index: %d\", models.ErrScraperSource, index)\n\t\t}\n\n\t\treturn sb[*index], nil\n\t}\n\n\tif source.StashBoxEndpoint != nil {\n\t\tvar ret *models.StashBox\n\t\tendpoint := *source.StashBoxEndpoint\n\t\tfor _, b := range sb {\n\t\t\tif strings.EqualFold(endpoint, b.Endpoint) {\n\t\t\t\tret = b\n\t\t\t}\n\t\t}\n\n\t\tif ret == nil {\n\t\t\treturn nil, fmt.Errorf(`%w: stash-box with endpoint \"%s\"`, models.ErrNotFound, endpoint)\n\t\t}\n\n\t\treturn ret, nil\n\t}\n\n\t// neither stash-box inputs were provided, so assume it is a scraper\n\n\treturn nil, nil\n}\n\ntype stashboxSource struct {\n\t*stashbox.Client\n\tendpoint string\n\n\ttxnManager             models.TxnManager\n\tsceneFingerprintGetter sceneFingerprintGetter\n\tmatcher                match.SceneRelationships\n}\n\ntype sceneFingerprintGetter interface {\n\tGetScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error)\n}\n\nfunc (s stashboxSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) {\n\tvar fps []models.Fingerprints\n\tif err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {\n\t\tvar err error\n\t\tfps, err = s.sceneFingerprintGetter.GetScenesFingerprints(ctx, []int{sceneID})\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting scene fingerprints: %w\", err)\n\t}\n\n\tresults, err := s.FindSceneByFingerprints(ctx, fps[0])\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error querying stash-box using scene ID %d: %w\", sceneID, err)\n\t}\n\n\tif err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {\n\t\tfor _, ret := range results {\n\t\t\tif err := s.matcher.MatchRelationships(ctx, ret, s.endpoint); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"error matching scene relationships: %w\", err)\n\t}\n\n\tif len(results) > 0 {\n\t\treturn results, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (s stashboxSource) String() string {\n\treturn fmt.Sprintf(\"stash-box %s\", s.endpoint)\n}\n\ntype scraperSource struct {\n\tcache     *scraper.Cache\n\tscraperID string\n}\n\nfunc (s scraperSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) {\n\tcontent, err := s.cache.ScrapeID(ctx, s.scraperID, sceneID, scraper.ScrapeContentTypeScene)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// don't try to convert nil return value\n\tif content == nil {\n\t\treturn nil, nil\n\t}\n\n\tif scene, ok := content.(models.ScrapedScene); ok {\n\t\treturn []*models.ScrapedScene{&scene}, nil\n\t}\n\n\treturn nil, errors.New(\"could not convert content to scene\")\n}\n\nfunc (s scraperSource) String() string {\n\treturn fmt.Sprintf(\"scraper %s\", s.scraperID)\n}\n"
  },
  {
    "path": "internal/manager/task_import.go",
    "content": "package manager\n\nimport (\n\t\"archive/zip\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/group\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n\t\"github.com/stashapp/stash/pkg/savedfilter\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/studio\"\n\t\"github.com/stashapp/stash/pkg/tag\"\n)\n\ntype Resetter interface {\n\tReset() error\n}\n\ntype ImportTask struct {\n\trepository models.Repository\n\tresetter   Resetter\n\tjson       jsonUtils\n\n\tBaseDir             string\n\tTmpZip              string\n\tReset               bool\n\tDuplicateBehaviour  ImportDuplicateEnum\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\tfileNamingAlgorithm models.HashAlgorithm\n}\n\ntype ImportObjectsInput struct {\n\tFile                graphql.Upload              `json:\"file\"`\n\tDuplicateBehaviour  ImportDuplicateEnum         `json:\"duplicateBehaviour\"`\n\tMissingRefBehaviour models.ImportMissingRefEnum `json:\"missingRefBehaviour\"`\n}\n\nfunc CreateImportTask(a models.HashAlgorithm, input ImportObjectsInput) (*ImportTask, error) {\n\tbaseDir, err := instance.Paths.Generated.TempDir(\"import\")\n\tif err != nil {\n\t\tlogger.Errorf(\"error creating temporary directory for import: %v\", err)\n\t\treturn nil, err\n\t}\n\n\ttmpZip := \"\"\n\tif input.File.File != nil {\n\t\ttmpZip = filepath.Join(baseDir, \"import.zip\")\n\t\tout, err := os.Create(tmpZip)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t_, err = io.Copy(out, input.File.File)\n\t\tout.Close()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tmgr := GetInstance()\n\treturn &ImportTask{\n\t\trepository:          mgr.Repository,\n\t\tresetter:            mgr.Database,\n\t\tBaseDir:             baseDir,\n\t\tTmpZip:              tmpZip,\n\t\tReset:               false,\n\t\tDuplicateBehaviour:  input.DuplicateBehaviour,\n\t\tMissingRefBehaviour: input.MissingRefBehaviour,\n\t\tfileNamingAlgorithm: a,\n\t}, nil\n}\n\nfunc (t *ImportTask) GetDescription() string {\n\treturn \"Importing...\"\n}\n\nfunc (t *ImportTask) Start(ctx context.Context) {\n\tif t.TmpZip != \"\" {\n\t\tdefer func() {\n\t\t\terr := fsutil.RemoveDir(t.BaseDir)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"error removing directory %s: %v\", t.BaseDir, err)\n\t\t\t}\n\t\t}()\n\n\t\tif err := t.unzipFile(); err != nil {\n\t\t\tlogger.Errorf(\"error unzipping provided file for import: %v\", err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tt.json = jsonUtils{\n\t\tjson: *paths.GetJSONPaths(t.BaseDir),\n\t}\n\n\t// set default behaviour if not provided\n\tif !t.DuplicateBehaviour.IsValid() {\n\t\tt.DuplicateBehaviour = ImportDuplicateEnumFail\n\t}\n\tif !t.MissingRefBehaviour.IsValid() {\n\t\tt.MissingRefBehaviour = models.ImportMissingRefEnumFail\n\t}\n\n\tif t.Reset {\n\t\terr := t.resetter.Reset()\n\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error resetting database: %v\", err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tt.ImportSavedFilters(ctx)\n\tt.ImportTags(ctx)\n\tt.ImportPerformers(ctx)\n\tt.ImportStudios(ctx)\n\tt.ImportGroups(ctx)\n\tt.ImportFiles(ctx)\n\tt.ImportGalleries(ctx)\n\n\tt.ImportScenes(ctx)\n\tt.ImportImages(ctx)\n}\n\nfunc (t *ImportTask) unzipFile() error {\n\tdefer func() {\n\t\terr := os.Remove(t.TmpZip)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"error removing temporary zip file %s: %v\", t.TmpZip, err)\n\t\t}\n\t}()\n\n\t// now we can read the zip file\n\tr, err := zip.OpenReader(t.TmpZip)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer r.Close()\n\n\tfor _, f := range r.File {\n\t\tfn := filepath.Join(t.BaseDir, f.Name)\n\n\t\tif f.FileInfo().IsDir() {\n\t\t\tif err := os.MkdirAll(fn, os.ModePerm); err != nil {\n\t\t\t\tlogger.Warnf(\"couldn't create directory %v while unzipping import file: %v\", fn, err)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\to, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ti, err := f.Open()\n\t\tif err != nil {\n\t\t\to.Close()\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := io.Copy(o, i); err != nil {\n\t\t\to.Close()\n\t\t\ti.Close()\n\t\t\treturn err\n\t\t}\n\n\t\to.Close()\n\t\ti.Close()\n\t}\n\n\treturn nil\n}\n\nfunc (t *ImportTask) ImportPerformers(ctx context.Context) {\n\tlogger.Info(\"[performers] importing\")\n\n\tpath := t.json.json.Performers\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[performers] failed to read performers directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\t\tperformerJSON, err := jsonschema.LoadPerformerFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[performers] failed to read json: %v\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tlogger.Progressf(\"[performers] %d of %d\", index, len(files))\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\timporter := &performer.Importer{\n\t\t\t\tReaderWriter: r.Performer,\n\t\t\t\tTagWriter:    r.Tag,\n\t\t\t\tInput:        *performerJSON,\n\t\t\t}\n\n\t\t\treturn performImport(ctx, importer, t.DuplicateBehaviour)\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"[performers] <%s> import failed: %v\", fi.Name(), err)\n\t\t}\n\t}\n\n\tlogger.Info(\"[performers] import complete\")\n}\n\nfunc (t *ImportTask) ImportStudios(ctx context.Context) {\n\tpendingParent := make(map[string][]*jsonschema.Studio)\n\n\tlogger.Info(\"[studios] importing\")\n\n\tpath := t.json.json.Studios\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[studios] failed to read studios directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\t\tstudioJSON, err := jsonschema.LoadStudioFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[studios] failed to read json: %v\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tlogger.Progressf(\"[studios] %d of %d\", index, len(files))\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn t.importStudio(ctx, studioJSON, pendingParent)\n\t\t}); err != nil {\n\t\t\tif errors.Is(err, studio.ErrParentStudioNotExist) {\n\t\t\t\t// add to the pending parent list so that it is created after the parent\n\t\t\t\ts := pendingParent[studioJSON.ParentStudio]\n\t\t\t\ts = append(s, studioJSON)\n\t\t\t\tpendingParent[studioJSON.ParentStudio] = s\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlogger.Errorf(\"[studios] <%s> failed to create: %v\", fi.Name(), err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\t// create the leftover studios, warning for missing parents\n\tif len(pendingParent) > 0 {\n\t\tlogger.Warnf(\"[studios] importing studios with missing parents\")\n\n\t\tfor _, s := range pendingParent {\n\t\t\tfor _, orphanStudioJSON := range s {\n\t\t\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\t\t\treturn t.importStudio(ctx, orphanStudioJSON, nil)\n\t\t\t\t}); err != nil {\n\t\t\t\t\tlogger.Errorf(\"[studios] <%s> failed to create: %v\", orphanStudioJSON.Name, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tlogger.Info(\"[studios] import complete\")\n}\n\nfunc (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.Studio, pendingParent map[string][]*jsonschema.Studio) error {\n\tr := t.repository\n\n\timporter := &studio.Importer{\n\t\tReaderWriter:        t.repository.Studio,\n\t\tTagWriter:           r.Tag,\n\t\tInput:               *studioJSON,\n\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\t}\n\n\t// first phase: return error if parent does not exist\n\tif pendingParent != nil {\n\t\timporter.MissingRefBehaviour = models.ImportMissingRefEnumFail\n\t}\n\n\tif err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil {\n\t\treturn err\n\t}\n\n\t// now create the studios pending this studios creation\n\ts := pendingParent[studioJSON.Name]\n\tfor _, childStudioJSON := range s {\n\t\t// map is nil since we're not checking parent studios at this point\n\t\tif err := t.importStudio(ctx, childStudioJSON, nil); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create child studio <%s>: %v\", childStudioJSON.Name, err)\n\t\t}\n\t}\n\n\t// delete the entry from the map so that we know its not left over\n\tdelete(pendingParent, studioJSON.Name)\n\n\treturn nil\n}\n\nfunc (t *ImportTask) ImportGroups(ctx context.Context) {\n\tlogger.Info(\"[groups] importing\")\n\tpendingSubs := make(map[string][]*jsonschema.Group)\n\n\tpath := t.json.json.Groups\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[groups] failed to read movies directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\t\tgroupJSON, err := jsonschema.LoadGroupFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[groups] failed to read json: %v\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tlogger.Progressf(\"[groups] %d of %d\", index, len(files))\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn t.importGroup(ctx, groupJSON, pendingSubs, false)\n\t\t}); err != nil {\n\t\t\tvar subError group.SubGroupNotExistError\n\t\t\tif errors.As(err, &subError) {\n\t\t\t\tmissingSub := subError.MissingSubGroup()\n\t\t\t\tpendingSubs[missingSub] = append(pendingSubs[missingSub], groupJSON)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlogger.Errorf(\"[groups] <%s> failed to import: %v\", fi.Name(), err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tfor _, s := range pendingSubs {\n\t\tfor _, orphanGroupJSON := range s {\n\t\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\t\treturn t.importGroup(ctx, orphanGroupJSON, nil, true)\n\t\t\t}); err != nil {\n\t\t\t\tlogger.Errorf(\"[groups] <%s> failed to create: %v\", orphanGroupJSON.Name, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n\n\tlogger.Info(\"[groups] import complete\")\n}\n\nfunc (t *ImportTask) importGroup(ctx context.Context, groupJSON *jsonschema.Group, pendingSub map[string][]*jsonschema.Group, fail bool) error {\n\tr := t.repository\n\n\timporter := &group.Importer{\n\t\tReaderWriter:        r.Group,\n\t\tStudioWriter:        r.Studio,\n\t\tTagWriter:           r.Tag,\n\t\tInput:               *groupJSON,\n\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\t}\n\n\t// first phase: return error if parent does not exist\n\tif !fail {\n\t\timporter.MissingRefBehaviour = models.ImportMissingRefEnumFail\n\t}\n\n\tif err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, containingGroupJSON := range pendingSub[groupJSON.Name] {\n\t\tif err := t.importGroup(ctx, containingGroupJSON, pendingSub, fail); err != nil {\n\t\t\tvar subError group.SubGroupNotExistError\n\t\t\tif errors.As(err, &subError) {\n\t\t\t\tmissingSub := subError.MissingSubGroup()\n\t\t\t\tpendingSub[missingSub] = append(pendingSub[missingSub], containingGroupJSON)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treturn fmt.Errorf(\"failed to create containing group <%s>: %v\", containingGroupJSON.Name, err)\n\t\t}\n\t}\n\n\tdelete(pendingSub, groupJSON.Name)\n\n\treturn nil\n}\n\nfunc (t *ImportTask) ImportFiles(ctx context.Context) {\n\tlogger.Info(\"[files] importing\")\n\n\tpath := t.json.json.Files\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[files] failed to read files directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tpendingParent := make(map[string][]jsonschema.DirEntry)\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\t\tfileJSON, err := jsonschema.LoadFileFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[files] failed to read json: %v\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tlogger.Progressf(\"[files] %d of %d\", index, len(files))\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn t.importFile(ctx, fileJSON, pendingParent)\n\t\t}); err != nil {\n\t\t\tif errors.Is(err, file.ErrZipFileNotExist) {\n\t\t\t\t// add to the pending parent list so that it is created after the parent\n\t\t\t\ts := pendingParent[fileJSON.DirEntry().ZipFile]\n\t\t\t\ts = append(s, fileJSON)\n\t\t\t\tpendingParent[fileJSON.DirEntry().ZipFile] = s\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlogger.Errorf(\"[files] <%s> failed to create: %v\", fi.Name(), err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\t// create the leftover studios, warning for missing parents\n\tif len(pendingParent) > 0 {\n\t\tlogger.Warnf(\"[files] importing files with missing zip files\")\n\n\t\tfor _, s := range pendingParent {\n\t\t\tfor _, orphanFileJSON := range s {\n\t\t\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\t\t\treturn t.importFile(ctx, orphanFileJSON, nil)\n\t\t\t\t}); err != nil {\n\t\t\t\t\tlogger.Errorf(\"[files] <%s> failed to create: %v\", orphanFileJSON.DirEntry().Path, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tlogger.Info(\"[files] import complete\")\n}\n\nfunc (t *ImportTask) importFile(ctx context.Context, fileJSON jsonschema.DirEntry, pendingParent map[string][]jsonschema.DirEntry) error {\n\tr := t.repository\n\n\tfileImporter := &file.Importer{\n\t\tReaderWriter: r.File,\n\t\tFolderStore:  r.Folder,\n\t\tInput:        fileJSON,\n\t}\n\n\t// ignore duplicate files - don't overwrite\n\tif err := performImport(ctx, fileImporter, ImportDuplicateEnumIgnore); err != nil {\n\t\treturn err\n\t}\n\n\t// now create the files pending this file's creation\n\ts := pendingParent[fileJSON.DirEntry().Path]\n\tfor _, childFileJSON := range s {\n\t\t// map is nil since we're not checking parent studios at this point\n\t\tif err := t.importFile(ctx, childFileJSON, nil); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create child file <%s>: %v\", childFileJSON.DirEntry().Path, err)\n\t\t}\n\t}\n\n\t// delete the entry from the map so that we know its not left over\n\tdelete(pendingParent, fileJSON.DirEntry().Path)\n\n\treturn nil\n}\n\nfunc (t *ImportTask) ImportGalleries(ctx context.Context) {\n\tlogger.Info(\"[galleries] importing\")\n\n\tpath := t.json.json.Galleries\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[galleries] failed to read galleries directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\t\tgalleryJSON, err := jsonschema.LoadGalleryFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[galleries] failed to read json: %v\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tlogger.Progressf(\"[galleries] %d of %d\", index, len(files))\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tgalleryImporter := &gallery.Importer{\n\t\t\t\tReaderWriter:        r.Gallery,\n\t\t\t\tFolderFinder:        r.Folder,\n\t\t\t\tFileFinder:          r.File,\n\t\t\t\tPerformerWriter:     r.Performer,\n\t\t\t\tStudioWriter:        r.Studio,\n\t\t\t\tTagWriter:           r.Tag,\n\t\t\t\tInput:               *galleryJSON,\n\t\t\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\t\t\t}\n\n\t\t\tif err := performImport(ctx, galleryImporter, t.DuplicateBehaviour); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// import the gallery chapters\n\t\t\tfor _, m := range galleryJSON.Chapters {\n\t\t\t\tchapterImporter := &gallery.ChapterImporter{\n\t\t\t\t\tGalleryID:           galleryImporter.ID,\n\t\t\t\t\tInput:               m,\n\t\t\t\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\t\t\t\t\tReaderWriter:        r.GalleryChapter,\n\t\t\t\t}\n\n\t\t\t\tif err := performImport(ctx, chapterImporter, t.DuplicateBehaviour); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"[galleries] <%s> import failed to commit: %v\", fi.Name(), err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tlogger.Info(\"[galleries] import complete\")\n}\n\nfunc (t *ImportTask) ImportTags(ctx context.Context) {\n\tpendingParent := make(map[string][]*jsonschema.Tag)\n\tlogger.Info(\"[tags] importing\")\n\n\tpath := t.json.json.Tags\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[tags] failed to read tags directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\t\ttagJSON, err := jsonschema.LoadTagFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[tags] failed to read json: %v\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tlogger.Progressf(\"[tags] %d of %d\", index, len(files))\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn t.importTag(ctx, tagJSON, pendingParent, false)\n\t\t}); err != nil {\n\t\t\tvar parentError tag.ParentTagNotExistError\n\t\t\tif errors.As(err, &parentError) {\n\t\t\t\tpendingParent[parentError.MissingParent()] = append(pendingParent[parentError.MissingParent()], tagJSON)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlogger.Errorf(\"[tags] <%s> failed to import: %v\", fi.Name(), err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tfor _, s := range pendingParent {\n\t\tfor _, orphanTagJSON := range s {\n\t\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\t\treturn t.importTag(ctx, orphanTagJSON, nil, true)\n\t\t\t}); err != nil {\n\t\t\t\tlogger.Errorf(\"[tags] <%s> failed to create: %v\", orphanTagJSON.Name, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n\n\tlogger.Info(\"[tags] import complete\")\n}\n\nfunc (t *ImportTask) importTag(ctx context.Context, tagJSON *jsonschema.Tag, pendingParent map[string][]*jsonschema.Tag, fail bool) error {\n\timporter := &tag.Importer{\n\t\tReaderWriter:        t.repository.Tag,\n\t\tInput:               *tagJSON,\n\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\t}\n\n\t// first phase: return error if parent does not exist\n\tif !fail {\n\t\timporter.MissingRefBehaviour = models.ImportMissingRefEnumFail\n\t}\n\n\tif err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, childTagJSON := range pendingParent[tagJSON.Name] {\n\t\tif err := t.importTag(ctx, childTagJSON, pendingParent, fail); err != nil {\n\t\t\tvar parentError tag.ParentTagNotExistError\n\t\t\tif errors.As(err, &parentError) {\n\t\t\t\tpendingParent[parentError.MissingParent()] = append(pendingParent[parentError.MissingParent()], childTagJSON)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treturn fmt.Errorf(\"failed to create child tag <%s>: %v\", childTagJSON.Name, err)\n\t\t}\n\t}\n\n\tdelete(pendingParent, tagJSON.Name)\n\n\treturn nil\n}\n\nfunc (t *ImportTask) ImportScenes(ctx context.Context) {\n\tlogger.Info(\"[scenes] importing\")\n\n\tpath := t.json.json.Scenes\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[scenes] failed to read scenes directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\n\t\tlogger.Progressf(\"[scenes] %d of %d\", index, len(files))\n\n\t\tsceneJSON, err := jsonschema.LoadSceneFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Infof(\"[scenes] <%s> json parse failure: %v\", fi.Name(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tsceneImporter := &scene.Importer{\n\t\t\t\tReaderWriter: r.Scene,\n\t\t\t\tInput:        *sceneJSON,\n\t\t\t\tFileFinder:   r.File,\n\n\t\t\t\tFileNamingAlgorithm: t.fileNamingAlgorithm,\n\t\t\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\n\t\t\t\tGalleryFinder:   r.Gallery,\n\t\t\t\tGroupWriter:     r.Group,\n\t\t\t\tPerformerWriter: r.Performer,\n\t\t\t\tStudioWriter:    r.Studio,\n\t\t\t\tTagWriter:       r.Tag,\n\t\t\t}\n\n\t\t\tif err := performImport(ctx, sceneImporter, t.DuplicateBehaviour); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// skip importing markers if the scene was not created\n\t\t\tif sceneImporter.ID == 0 {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// import the scene markers\n\t\t\tfor _, m := range sceneJSON.Markers {\n\t\t\t\tmarkerImporter := &scene.MarkerImporter{\n\t\t\t\t\tSceneID:             sceneImporter.ID,\n\t\t\t\t\tInput:               m,\n\t\t\t\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\t\t\t\t\tReaderWriter:        r.SceneMarker,\n\t\t\t\t\tTagWriter:           r.Tag,\n\t\t\t\t}\n\n\t\t\t\tif err := performImport(ctx, markerImporter, t.DuplicateBehaviour); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"[scenes] <%s> import failed: %v\", fi.Name(), err)\n\t\t}\n\t}\n\n\tlogger.Info(\"[scenes] import complete\")\n}\n\nfunc (t *ImportTask) ImportImages(ctx context.Context) {\n\tlogger.Info(\"[images] importing\")\n\n\tpath := t.json.json.Images\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[images] failed to read images directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\n\t\tlogger.Progressf(\"[images] %d of %d\", index, len(files))\n\n\t\timageJSON, err := jsonschema.LoadImageFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Infof(\"[images] <%s> json parse failure: %v\", fi.Name(), err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\timageImporter := &image.Importer{\n\t\t\t\tReaderWriter: r.Image,\n\t\t\t\tFileFinder:   r.File,\n\t\t\t\tInput:        *imageJSON,\n\n\t\t\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\n\t\t\t\tGalleryFinder:   r.Gallery,\n\t\t\t\tPerformerWriter: r.Performer,\n\t\t\t\tStudioWriter:    r.Studio,\n\t\t\t\tTagWriter:       r.Tag,\n\t\t\t}\n\n\t\t\treturn performImport(ctx, imageImporter, t.DuplicateBehaviour)\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"[images] <%s> import failed: %v\", fi.Name(), err)\n\t\t}\n\t}\n\n\tlogger.Info(\"[images] import complete\")\n}\n\nfunc (t *ImportTask) ImportSavedFilters(ctx context.Context) {\n\tlogger.Info(\"[saved filters] importing\")\n\n\tpath := t.json.json.SavedFilters\n\tfiles, err := os.ReadDir(path)\n\tif err != nil {\n\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Errorf(\"[saved filters] failed to read saved filters directory: %v\", err)\n\t\t}\n\n\t\treturn\n\t}\n\n\tr := t.repository\n\n\tfor i, fi := range files {\n\t\tindex := i + 1\n\t\tsavedFilterJSON, err := jsonschema.LoadSavedFilterFile(filepath.Join(path, fi.Name()))\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"[saved filters] failed to read json: %v\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tlogger.Progressf(\"[saved filters] %d of %d\", index, len(files))\n\n\t\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn t.importSavedFilter(ctx, savedFilterJSON)\n\t\t}); err != nil {\n\t\t\tlogger.Errorf(\"[saved filters] <%s> failed to import: %v\", fi.Name(), err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tlogger.Info(\"[saved filters] import complete\")\n}\n\nfunc (t *ImportTask) importSavedFilter(ctx context.Context, savedFilterJSON *jsonschema.SavedFilter) error {\n\timporter := &savedfilter.Importer{\n\t\tReaderWriter:        t.repository.SavedFilter,\n\t\tInput:               *savedFilterJSON,\n\t\tMissingRefBehaviour: t.MissingRefBehaviour,\n\t}\n\n\tif err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/task_migrate_hash.go",
    "content": "package manager\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n)\n\n// MigrateHashTask renames generated files between oshash and MD5 based on the\n// value of the fileNamingAlgorithm flag.\ntype MigrateHashTask struct {\n\tScene               *models.Scene\n\tfileNamingAlgorithm models.HashAlgorithm\n}\n\n// Start starts the task.\nfunc (t *MigrateHashTask) Start() {\n\tif t.Scene.OSHash == \"\" || t.Scene.Checksum == \"\" {\n\t\t// nothing to do\n\t\treturn\n\t}\n\n\toshash := t.Scene.OSHash\n\tchecksum := t.Scene.Checksum\n\n\toldHash := oshash\n\tnewHash := checksum\n\tif t.fileNamingAlgorithm == models.HashAlgorithmOshash {\n\t\toldHash = checksum\n\t\tnewHash = oshash\n\t}\n\n\tscene.MigrateHash(instance.Paths, oldHash, newHash)\n}\n"
  },
  {
    "path": "internal/manager/task_optimise.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype Optimiser interface {\n\tAnalyze(ctx context.Context) error\n\tVacuum(ctx context.Context) error\n}\n\ntype OptimiseDatabaseJob struct {\n\tOptimiser Optimiser\n}\n\nfunc (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tlogger.Info(\"Optimising database\")\n\tprogress.SetTotal(2)\n\n\tstart := time.Now()\n\n\tvar err error\n\n\tprogress.ExecuteTask(\"Analyzing database\", func() {\n\t\terr = j.Optimiser.Analyze(ctx)\n\t\tprogress.Increment()\n\t})\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Stopping due to user request\")\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Error analyzing database: %w\", err)\n\t}\n\n\tprogress.ExecuteTask(\"Vacuuming database\", func() {\n\t\terr = j.Optimiser.Vacuum(ctx)\n\t\tprogress.Increment()\n\t})\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Stopping due to user request\")\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error vacuuming database: %w\", err)\n\t}\n\n\telapsed := time.Since(start)\n\tlogger.Infof(\"Finished optimising database after %s\", elapsed)\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/task_plugin.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n)\n\nfunc (s *Manager) RunPluginTask(\n\tctx context.Context,\n\tpluginID string,\n\ttaskName *string,\n\tdescription *string,\n\targs plugin.OperationInput,\n) int {\n\tj := job.MakeJobExec(func(jobCtx context.Context, progress *job.Progress) error {\n\t\tpluginProgress := make(chan float64)\n\t\ttask, err := s.PluginCache.CreateTask(ctx, pluginID, taskName, args, pluginProgress)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating plugin task: %w\", err)\n\t\t}\n\n\t\terr = task.Start()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error running plugin task: %w\", err)\n\t\t}\n\n\t\tdone := make(chan bool)\n\t\tgo func() {\n\t\t\tdefer close(done)\n\t\t\ttask.Wait()\n\n\t\t\toutput := task.GetResult()\n\t\t\tif output == nil {\n\t\t\t\tlogger.Debug(\"Plugin returned no result\")\n\t\t\t} else {\n\t\t\t\tif output.Error != nil {\n\t\t\t\t\tlogger.Errorf(\"Plugin returned error: %s\", *output.Error)\n\t\t\t\t} else if output.Output != nil {\n\t\t\t\t\tlogger.Debugf(\"Plugin returned: %v\", output.Output)\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-done:\n\t\t\t\treturn nil\n\t\t\tcase p := <-pluginProgress:\n\t\t\t\tprogress.SetPercent(p)\n\t\t\tcase <-jobCtx.Done():\n\t\t\t\tif err := task.Stop(); err != nil {\n\t\t\t\t\tlogger.Errorf(\"Error stopping plugin operation: %s\", err.Error())\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t})\n\n\tdisplayName := pluginID\n\tif taskName != nil {\n\t\tdisplayName = *taskName\n\t}\n\tif description != nil {\n\t\tdisplayName = *description\n\t}\n\treturn s.JobManager.Add(ctx, fmt.Sprintf(\"Running plugin task: %s\", displayName), j)\n}\n"
  },
  {
    "path": "internal/manager/task_scan.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime/debug\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/99designs/gqlgen/graphql/handler/lru\"\n\t\"github.com/remeh/sizedwaitgroup\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/file/video\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/scene/generate\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ScanJob struct {\n\tscanner       *file.Scanner\n\tinput         ScanMetadataInput\n\tsubscriptions *subscriptionManager\n\n\tfileQueue chan file.ScannedFile\n\tcount     int\n\n\tunmatchedCaptionFiles utils.MutexField[[]string]\n}\n\nfunc (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {\n\tcfg := config.GetInstance()\n\tinput := j.input\n\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Stopping due to user request\")\n\t\treturn nil\n\t}\n\n\tsp := getScanPaths(input.Paths)\n\tpaths := make([]string, len(sp))\n\tfor i, p := range sp {\n\t\tpaths[i] = p.Path\n\t}\n\n\tmgr := GetInstance()\n\tc := mgr.Config\n\trepo := mgr.Repository\n\n\tstart := time.Now()\n\n\tnTasks := cfg.GetParallelTasksWithAutoDetection()\n\n\tconst taskQueueSize = 200000\n\ttaskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, nTasks)\n\n\tvar minModTime time.Time\n\tif j.input.Filter != nil && j.input.Filter.MinModTime != nil {\n\t\tminModTime = *j.input.Filter.MinModTime\n\t}\n\n\t// HACK - these should really be set in the scanner initialization\n\tj.scanner.FileHandlers = getScanHandlers(j.input, taskQueue, progress)\n\tj.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)}\n\tj.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)}\n\n\tlogger.Infof(\"Starting scan of %d paths with %d parallel tasks\", len(paths), nTasks)\n\n\tj.runJob(ctx, paths, nTasks, progress)\n\n\ttaskQueue.Close()\n\n\tif job.IsCancelled(ctx) {\n\t\tlogger.Info(\"Stopping due to user request\")\n\t\treturn nil\n\t}\n\n\telapsed := time.Since(start)\n\tlogger.Infof(\"Scan finished (%s)\", elapsed)\n\n\tj.subscriptions.notify()\n\treturn nil\n}\n\nfunc (j *ScanJob) runJob(ctx context.Context, paths []string, nTasks int, progress *job.Progress) {\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\n\tj.fileQueue = make(chan file.ScannedFile, scanQueueSize)\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\twg.Done()\n\n\t\t\t// handle panics in goroutine\n\t\t\tif p := recover(); p != nil {\n\t\t\t\tlogger.Errorf(\"panic while queuing files for scan: %v\", p)\n\t\t\t\tlogger.Errorf(string(debug.Stack()))\n\t\t\t}\n\t\t}()\n\n\t\tif err := j.queueFiles(ctx, paths, progress); err != nil {\n\t\t\tif errors.Is(err, context.Canceled) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tlogger.Errorf(\"error queuing files for scan: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tlogger.Infof(\"Finished adding files to queue. %d files queued\", j.count)\n\t}()\n\n\tdefer wg.Wait()\n\n\tj.processQueue(ctx, nTasks, progress)\n}\n\nconst scanQueueSize = 200000\n\nfunc (j *ScanJob) queueFiles(ctx context.Context, paths []string, progress *job.Progress) error {\n\tfs := &file.OsFS{}\n\n\tdefer func() {\n\t\tclose(j.fileQueue)\n\n\t\tprogress.AddTotal(j.count)\n\t\tprogress.Definite()\n\t}()\n\n\tvar err error\n\tprogress.ExecuteTask(\"Walking directory tree\", func() {\n\t\tfor _, p := range paths {\n\t\t\terr = file.SymWalk(fs, p, j.queueFileFunc(ctx, fs, nil, progress))\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n\n\treturn err\n}\n\nfunc (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.ScannedFile, progress *job.Progress) fs.WalkDirFunc {\n\treturn func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\t// don't let errors prevent scanning\n\t\t\tlogger.Errorf(\"error scanning %s: %v\", path, err)\n\t\t\treturn nil\n\t\t}\n\n\t\tif err = ctx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tinfo, err := d.Info()\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"reading info for %q: %v\", path, err)\n\t\t\treturn nil\n\t\t}\n\n\t\tzipFilePath := \"\"\n\t\tif zipFile != nil {\n\t\t\tzipFilePath = zipFile.Path\n\t\t}\n\n\t\tif !j.scanner.AcceptEntry(ctx, path, info, zipFilePath) {\n\t\t\tif info.IsDir() {\n\t\t\t\tlogger.Debugf(\"Skipping directory %s\", path)\n\t\t\t\treturn fs.SkipDir\n\t\t\t}\n\n\t\t\t// we don't include caption files in the file scan, but we do need\n\t\t\t// to handle them\n\t\t\tif fsutil.MatchExtension(path, video.CaptionExts) {\n\t\t\t\tfileRepo := j.scanner.Repository.File\n\t\t\t\tmatched := video.AssociateCaptions(ctx, path, j.scanner.Repository.TxnManager, fileRepo, fileRepo)\n\n\t\t\t\tif !matched {\n\t\t\t\t\tlogger.Debugf(\"No matching video file found for caption file %s\", path)\n\t\t\t\t\tj.unmatchedCaptionFiles.SetFunc(func(files []string) []string {\n\t\t\t\t\t\treturn append(files, path)\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tlogger.Debugf(\"Skipping file %s\", path)\n\t\t\treturn nil\n\t\t}\n\n\t\tsize, err := file.GetFileSize(f, path, info)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tff := file.ScannedFile{\n\t\t\tBaseFile: &models.BaseFile{\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tModTime: file.ModTime(info),\n\t\t\t\t},\n\t\t\t\tPath:     path,\n\t\t\t\tBasename: filepath.Base(path),\n\t\t\t\tSize:     size,\n\t\t\t},\n\t\t\tFS:   f,\n\t\t\tInfo: info,\n\t\t}\n\n\t\tif zipFile != nil {\n\t\t\tff.ZipFileID = &zipFile.ID\n\t\t\tff.ZipFile = zipFile\n\t\t}\n\n\t\tif info.IsDir() {\n\t\t\t// handle folders immediately\n\t\t\tif err := j.handleFolder(ctx, ff, progress); err != nil {\n\t\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\t\tlogger.Errorf(\"error processing %q: %v\", path, err)\n\t\t\t\t}\n\n\t\t\t\t// skip the directory since we won't be able to process the files anyway\n\t\t\t\treturn fs.SkipDir\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}\n\n\t\t// if zip file is present, we handle immediately\n\t\tif zipFile != nil {\n\t\t\tprogress.ExecuteTask(\"Scanning \"+path, func() {\n\t\t\t\t// don't increment progress in zip files\n\t\t\t\tif err := j.handleFile(ctx, ff, nil); err != nil {\n\t\t\t\t\tif !errors.Is(err, context.Canceled) {\n\t\t\t\t\t\tlogger.Errorf(\"error processing %q: %v\", path, err)\n\t\t\t\t\t}\n\t\t\t\t\t// don't return an error, just skip the file\n\t\t\t\t}\n\t\t\t})\n\n\t\t\treturn nil\n\t\t}\n\n\t\tlogger.Tracef(\"Queueing file %s for scanning\", path)\n\t\tj.fileQueue <- ff\n\n\t\tj.count++\n\n\t\treturn nil\n\t}\n}\n\nfunc (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress *job.Progress) {\n\tif parallelTasks < 1 {\n\t\tparallelTasks = 1\n\t}\n\n\twg := sizedwaitgroup.New(parallelTasks)\n\n\tfunc() {\n\t\tdefer func() {\n\t\t\twg.Wait()\n\n\t\t\t// handle panics in goroutine\n\t\t\tif p := recover(); p != nil {\n\t\t\t\tlogger.Errorf(\"panic while scanning files: %v\", p)\n\t\t\t\tlogger.Errorf(string(debug.Stack()))\n\t\t\t}\n\t\t}()\n\n\t\tfor f := range j.fileQueue {\n\t\t\tlogger.Tracef(\"Processing queued file %s\", f.Path)\n\t\t\tif err := ctx.Err(); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\twg.Add()\n\t\t\tff := f\n\t\t\tgo func() {\n\t\t\t\tdefer wg.Done()\n\t\t\t\tj.processQueueItem(ctx, ff, progress)\n\t\t\t}()\n\t\t}\n\t}()\n}\n\nfunc (j *ScanJob) processQueueItem(ctx context.Context, f file.ScannedFile, progress *job.Progress) {\n\tprogress.ExecuteTask(\"Scanning \"+f.Path, func() {\n\t\tvar err error\n\t\tif f.Info.IsDir() {\n\t\t\terr = j.handleFolder(ctx, f, progress)\n\t\t} else {\n\t\t\terr = j.handleFile(ctx, f, progress)\n\t\t}\n\n\t\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\t\tlogger.Errorf(\"error processing %q: %v\", f.Path, err)\n\t\t}\n\t})\n}\n\nfunc (j *ScanJob) handleFolder(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {\n\tif progress != nil {\n\t\tdefer progress.Increment()\n\t}\n\n\t_, err := j.scanner.ScanFolder(ctx, f)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {\n\tif progress != nil {\n\t\tdefer progress.Increment()\n\t}\n\n\tr, err := j.scanner.ScanFile(ctx, f)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// if this is a new video file, match it with any unmatched caption files\n\tif r.New && len(j.unmatchedCaptionFiles.Get()) > 0 {\n\t\tvideoFile, _ := r.File.(*models.VideoFile)\n\n\t\tif videoFile != nil {\n\t\t\t// try to match any unmatched caption files to this video file\n\t\t\tfor _, captionPath := range j.unmatchedCaptionFiles.Get() {\n\t\t\t\tif video.MatchesCaption(videoFile.Path, captionPath) {\n\t\t\t\t\tvideo.AssociateCaptions(ctx, captionPath, j.scanner.Repository.TxnManager, j.scanner.Repository.File, j.scanner.Repository.File)\n\n\t\t\t\t\t// remove from the unmatched list\n\t\t\t\t\tj.unmatchedCaptionFiles.SetFunc(func(files []string) []string {\n\t\t\t\t\t\tnewFiles := make([]string, 0, len(files)-1)\n\t\t\t\t\t\tfor _, f := range files {\n\t\t\t\t\t\t\tif f != captionPath {\n\t\t\t\t\t\t\t\tnewFiles = append(newFiles, f)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn newFiles\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// clean captions - scene handler handles this as well, but\n\t// unchanged files aren't processed by the scene handler\n\tif r.IsUnchanged() {\n\t\tvideoFile, _ := r.File.(*models.VideoFile)\n\n\t\tif videoFile != nil {\n\t\t\ttxnMgr := j.scanner.Repository.TxnManager\n\t\t\tfileRepo := j.scanner.Repository.File\n\t\t\tif err := txn.WithDatabase(ctx, txnMgr, func(ctx context.Context) error {\n\t\t\t\treturn video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo)\n\t\t\t}); err != nil {\n\t\t\t\tlogger.Errorf(\"Error cleaning captions: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// handle rename should have already handled the contents of the zip file\n\t// so shouldn't need to scan it again.\n\t// Only scan zip contents if the file is new, the fingerprint changed,\n\t// or if a force rescan was requested.\n\n\tif j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) {\n\t\tff := r.File\n\t\tf.BaseFile = ff.Base()\n\n\t\t// scan zip files with a different context that is not cancellable\n\t\t// cancelling while scanning zip file contents results in the scan\n\t\t// contents being partially completed\n\t\tzipCtx := context.WithoutCancel(ctx)\n\n\t\tif err := j.scanZipFile(zipCtx, f, progress); err != nil {\n\t\t\tlogger.Errorf(\"Error scanning zip file %q: %v\", f.Path, err)\n\t\t}\n\t} else if r.Updated && j.scanner.IsZipFile(f.Info.Name()) {\n\t\tlogger.Debugf(\"Skipping zip file scan for %q: fingerprint unchanged\", f.Path)\n\t}\n\n\treturn nil\n}\n\nfunc (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {\n\tzipFS, err := f.FS.OpenZip(f.Path, f.Size)\n\tif err != nil {\n\t\tif errors.Is(err, file.ErrNotReaderAt) {\n\t\t\t// can't walk the zip file\n\t\t\t// just return\n\t\t\tlogger.Debugf(\"Skipping zip file %q as it cannot be opened for walking\", f.Path)\n\t\t\treturn nil\n\t\t}\n\n\t\treturn err\n\t}\n\n\tdefer zipFS.Close()\n\n\treturn file.SymWalk(zipFS, f.Path, j.queueFileFunc(ctx, zipFS, &f, progress))\n}\n\ntype extensionConfig struct {\n\tvidExt []string\n\timgExt []string\n\tzipExt []string\n}\n\nfunc newExtensionConfig(c *config.Config) extensionConfig {\n\treturn extensionConfig{\n\t\tvidExt: c.GetVideoExtensions(),\n\t\timgExt: c.GetImageExtensions(),\n\t\tzipExt: c.GetGalleryExtensions(),\n\t}\n}\n\ntype fileCounter interface {\n\tCountByFileID(ctx context.Context, fileID models.FileID) (int, error)\n}\n\ntype galleryFinder interface {\n\tfileCounter\n\tFindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error)\n}\n\ntype sceneFinder interface {\n\tfileCounter\n\tFindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error)\n}\n\n// handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated.\ntype handlerRequiredFilter struct {\n\textensionConfig\n\ttxnManager    txn.Manager\n\tSceneFinder   sceneFinder\n\tImageFinder   fileCounter\n\tGalleryFinder galleryFinder\n\n\tFolderCache *lru.LRU[bool]\n\n\tvideoFileNamingAlgorithm models.HashAlgorithm\n}\n\nfunc newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handlerRequiredFilter {\n\tprocesses := c.GetParallelTasksWithAutoDetection()\n\n\treturn &handlerRequiredFilter{\n\t\textensionConfig:          newExtensionConfig(c),\n\t\ttxnManager:               repo.TxnManager,\n\t\tSceneFinder:              repo.Scene,\n\t\tImageFinder:              repo.Image,\n\t\tGalleryFinder:            repo.Gallery,\n\t\tFolderCache:              lru.New[bool](processes * 2),\n\t\tvideoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(),\n\t}\n}\n\nfunc (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool {\n\tpath := ff.Base().Path\n\tisVideoFile := useAsVideo(path)\n\tisImageFile := useAsImage(path)\n\tisZipFile := fsutil.MatchExtension(path, f.zipExt)\n\n\tvar counter fileCounter\n\n\tswitch {\n\tcase isVideoFile:\n\t\t// return true if there are no scenes associated\n\t\tcounter = f.SceneFinder\n\tcase isImageFile:\n\t\tcounter = f.ImageFinder\n\tcase isZipFile:\n\t\tcounter = f.GalleryFinder\n\t}\n\n\tif counter == nil {\n\t\treturn false\n\t}\n\n\tn, err := counter.CountByFileID(ctx, ff.Base().ID)\n\tif err != nil {\n\t\t// just ignore\n\t\treturn false\n\t}\n\n\t// execute handler if there are no related objects\n\tif n == 0 {\n\t\treturn true\n\t}\n\n\t// if create galleries from folder is enabled and the file is not in a zip\n\t// file, then check if there is a folder-based gallery for the file's\n\t// directory\n\t// #4611 - also check for .forcegallery\n\tif isImageFile && ff.Base().ZipFileID == nil {\n\t\t// only do this for the first time it encounters the folder\n\t\t// the first instance should create the gallery\n\t\t_, found := f.FolderCache.Get(ctx, ff.Base().ParentFolderID.String())\n\t\tif found {\n\t\t\t// should already be handled\n\t\t\treturn false\n\t\t}\n\n\t\tf.FolderCache.Add(ctx, ff.Base().ParentFolderID.String(), true)\n\n\t\tcreateGallery := instance.Config.GetCreateGalleriesFromFolders()\n\t\tif !createGallery {\n\t\t\t// check for presence of .forcegallery\n\t\t\tforceGalleryPath := filepath.Join(filepath.Dir(path), \".forcegallery\")\n\t\t\tif exists, _ := fsutil.FileExists(forceGalleryPath); exists {\n\t\t\t\tcreateGallery = true\n\t\t\t}\n\t\t}\n\n\t\tif !createGallery {\n\t\t\treturn false\n\t\t}\n\n\t\tg, _ := f.GalleryFinder.FindByFolderID(ctx, ff.Base().ParentFolderID)\n\n\t\tif len(g) == 0 {\n\t\t\t// no folder gallery. Return true so that it creates one.\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\ntype scanFilter struct {\n\textensionConfig\n\ttxnManager txn.Manager\n\n\tstashPaths        config.StashConfigs\n\tgeneratedPath     string\n\tvideoExcludeRegex []*regexp.Regexp\n\timageExcludeRegex []*regexp.Regexp\n\tminModTime        time.Time\n\tstashIgnoreFilter *file.StashIgnoreFilter\n}\n\nfunc newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter {\n\treturn &scanFilter{\n\t\textensionConfig:   newExtensionConfig(c),\n\t\ttxnManager:        repo.TxnManager,\n\t\tstashPaths:        c.GetStashPaths(),\n\t\tgeneratedPath:     c.GetGeneratedPath(),\n\t\tvideoExcludeRegex: generateRegexps(c.GetExcludes()),\n\t\timageExcludeRegex: generateRegexps(c.GetImageExcludes()),\n\t\tminModTime:        minModTime,\n\t\tstashIgnoreFilter: file.NewStashIgnoreFilter(),\n\t}\n}\n\nfunc (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {\n\tif fsutil.IsPathInDir(f.generatedPath, path) {\n\t\tlogger.Warnf(\"Skipping %q as it overlaps with the generated folder\", path)\n\t\treturn false\n\t}\n\n\t// exit early on cutoff\n\tif info.Mode().IsRegular() && info.ModTime().Before(f.minModTime) {\n\t\treturn false\n\t}\n\n\ts := f.stashPaths.GetStashFromDirPath(path)\n\tif s == nil {\n\t\tlogger.Debugf(\"Skipping %s as it is not in the stash library\", path)\n\t\treturn false\n\t}\n\n\t// Check .stashignore files, bounded to the library root.\n\tif !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path, zipFilePath) {\n\t\tlogger.Debugf(\"Skipping %s due to .stashignore\", path)\n\t\treturn false\n\t}\n\n\tisVideoFile := useAsVideo(path)\n\tisImageFile := useAsImage(path)\n\tisZipFile := fsutil.MatchExtension(path, f.zipExt)\n\n\tif !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile {\n\t\tlogger.Debugf(\"Skipping %s as it does not match any known file extensions\", path)\n\t\treturn false\n\t}\n\n\t// #1756 - skip zero length files\n\tif !info.IsDir() && info.Size() == 0 {\n\t\tlogger.Infof(\"Skipping zero-length file: %s\", path)\n\t\treturn false\n\t}\n\n\t// shortcut: skip the directory entirely if it matches both exclusion patterns\n\t// add a trailing separator so that it correctly matches against patterns like path/.*\n\tpathExcludeTest := path + string(filepath.Separator)\n\tif (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) {\n\t\tlogger.Debugf(\"Skipping directory %s as it matches video and image exclusion patterns\", path)\n\t\treturn false\n\t}\n\n\tif isVideoFile && (s.ExcludeVideo || matchFileRegex(path, f.videoExcludeRegex)) {\n\t\tlogger.Debugf(\"Skipping %s as it matches video exclusion patterns\", path)\n\t\treturn false\n\t} else if (isImageFile || isZipFile) && (s.ExcludeImage || matchFileRegex(path, f.imageExcludeRegex)) {\n\t\tlogger.Debugf(\"Skipping %s as it matches image exclusion patterns\", path)\n\t\treturn false\n\t}\n\n\treturn true\n}\n\ntype scanConfig struct {\n\tisGenerateThumbnails   bool\n\tisGenerateClipPreviews bool\n\n\tcreateGalleriesFromFolders bool\n}\n\nfunc (c *scanConfig) GetCreateGalleriesFromFolders() bool {\n\treturn c.createGalleriesFromFolders\n}\n\nfunc videoFileFilter(ctx context.Context, f models.File) bool {\n\treturn useAsVideo(f.Base().Path)\n}\n\nfunc imageFileFilter(ctx context.Context, f models.File) bool {\n\treturn useAsImage(f.Base().Path)\n}\n\nfunc galleryFileFilter(ctx context.Context, f models.File) bool {\n\treturn isZip(f.Base().Basename)\n}\n\nfunc getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler {\n\tmgr := GetInstance()\n\tc := mgr.Config\n\tr := mgr.Repository\n\tpluginCache := mgr.PluginCache\n\n\treturn []file.Handler{\n\t\t&file.FilteredHandler{\n\t\t\tFilter: file.FilterFunc(imageFileFilter),\n\t\t\tHandler: &image.ScanHandler{\n\t\t\t\tCreatorUpdater:     r.Image,\n\t\t\t\tGalleryFinder:      r.Gallery,\n\t\t\t\tSceneFinderUpdater: r.Scene,\n\t\t\t\tScanGenerator: &imageGenerators{\n\t\t\t\t\tinput:              options,\n\t\t\t\t\ttaskQueue:          taskQueue,\n\t\t\t\t\tprogress:           progress,\n\t\t\t\t\tpaths:              mgr.Paths,\n\t\t\t\t\tsequentialScanning: c.GetSequentialScanning(),\n\t\t\t\t},\n\t\t\t\tScanConfig: &scanConfig{\n\t\t\t\t\tisGenerateThumbnails:       options.ScanGenerateThumbnails,\n\t\t\t\t\tisGenerateClipPreviews:     options.ScanGenerateClipPreviews,\n\t\t\t\t\tcreateGalleriesFromFolders: c.GetCreateGalleriesFromFolders(),\n\t\t\t\t},\n\t\t\t\tPluginCache: pluginCache,\n\t\t\t\tPaths:       instance.Paths,\n\t\t\t},\n\t\t},\n\t\t&file.FilteredHandler{\n\t\t\tFilter: file.FilterFunc(galleryFileFilter),\n\t\t\tHandler: &gallery.ScanHandler{\n\t\t\t\tCreatorUpdater:     r.Gallery,\n\t\t\t\tSceneFinderUpdater: r.Scene,\n\t\t\t\tImageFinderUpdater: r.Image,\n\t\t\t\tPluginCache:        pluginCache,\n\t\t\t},\n\t\t},\n\t\t&file.FilteredHandler{\n\t\t\tFilter: file.FilterFunc(videoFileFilter),\n\t\t\tHandler: &scene.ScanHandler{\n\t\t\t\tCreatorUpdater:       r.Scene,\n\t\t\t\tGalleryFinderUpdater: r.Gallery,\n\t\t\t\tCaptionUpdater:       r.File,\n\t\t\t\tPluginCache:          pluginCache,\n\t\t\t\tScanGenerator: &sceneGenerators{\n\t\t\t\t\tinput:               options,\n\t\t\t\t\ttaskQueue:           taskQueue,\n\t\t\t\t\tprogress:            progress,\n\t\t\t\t\tpaths:               mgr.Paths,\n\t\t\t\t\tfileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(),\n\t\t\t\t\tsequentialScanning:  c.GetSequentialScanning(),\n\t\t\t\t},\n\t\t\t\tFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(),\n\t\t\t\tPaths:               mgr.Paths,\n\t\t\t},\n\t\t},\n\t}\n}\n\ntype imageGenerators struct {\n\tinput     ScanMetadataInput\n\ttaskQueue *job.TaskQueue\n\tprogress  *job.Progress\n\n\tpaths              *paths.Paths\n\tsequentialScanning bool\n}\n\nfunc (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f models.File) error {\n\tconst overwrite = false\n\n\tprogress := g.progress\n\tt := g.input\n\tpath := f.Base().Path\n\n\t// this is a bit of a hack: the task requires files to be loaded, but\n\t// we don't really need to since we already have the file\n\tii := *i\n\tii.Files = models.NewRelatedFiles([]models.File{f})\n\n\tif t.ScanGenerateThumbnails {\n\t\t// this should be quick, so always generate sequentially\n\t\ttaskThumbnail := GenerateImageThumbnailTask{\n\t\t\tImage:     ii,\n\t\t\tOverwrite: overwrite,\n\t\t}\n\n\t\ttaskThumbnail.Start(ctx)\n\t}\n\n\t// avoid adding a task if the file isn't a video file\n\t_, isVideo := f.(*models.VideoFile)\n\tif isVideo && t.ScanGenerateClipPreviews {\n\t\tprogress.AddTotal(1)\n\t\tpreviewsFn := func(ctx context.Context) {\n\t\t\ttaskPreview := GenerateClipPreviewTask{\n\t\t\t\tImage:     ii,\n\t\t\t\tOverwrite: overwrite,\n\t\t\t}\n\n\t\t\ttaskPreview.Start(ctx)\n\t\t\tprogress.Increment()\n\t\t}\n\n\t\tif g.sequentialScanning {\n\t\t\tpreviewsFn(ctx)\n\t\t} else {\n\t\t\tg.taskQueue.Add(fmt.Sprintf(\"Generating preview for %s\", path), previewsFn)\n\t\t}\n\t}\n\n\tif t.ScanGenerateImagePhashes {\n\t\tprogress.AddTotal(1)\n\t\tphashFn := func(ctx context.Context) {\n\t\t\tmgr := GetInstance()\n\t\t\t// Only generate phash for image files, not video files\n\t\t\tif imageFile, ok := f.(*models.ImageFile); ok {\n\t\t\t\ttaskPhash := GenerateImagePhashTask{\n\t\t\t\t\trepository: mgr.Repository,\n\t\t\t\t\tFile:       imageFile,\n\t\t\t\t\tOverwrite:  overwrite,\n\t\t\t\t}\n\t\t\t\ttaskPhash.Start(ctx)\n\t\t\t}\n\t\t\tprogress.Increment()\n\t\t}\n\n\t\tif g.sequentialScanning {\n\t\t\tphashFn(ctx)\n\t\t} else {\n\t\t\tg.taskQueue.Add(fmt.Sprintf(\"Generating phash for %s\", path), phashFn)\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype sceneGenerators struct {\n\tinput     ScanMetadataInput\n\ttaskQueue *job.TaskQueue\n\tprogress  *job.Progress\n\n\tpaths               *paths.Paths\n\tfileNamingAlgorithm models.HashAlgorithm\n\tsequentialScanning  bool\n}\n\nfunc (g *sceneGenerators) Generate(ctx context.Context, s *models.Scene, f *models.VideoFile) error {\n\tconst overwrite = false\n\n\tprogress := g.progress\n\tt := g.input\n\tpath := f.Path\n\n\tmgr := GetInstance()\n\n\tif t.ScanGenerateSprites {\n\t\tprogress.AddTotal(1)\n\t\tspriteFn := func(ctx context.Context) {\n\t\t\ttaskSprite := GenerateSpriteTask{\n\t\t\t\tScene:               *s,\n\t\t\t\tOverwrite:           overwrite,\n\t\t\t\tfileNamingAlgorithm: g.fileNamingAlgorithm,\n\t\t\t}\n\t\t\ttaskSprite.Start(ctx)\n\t\t\tprogress.Increment()\n\t\t}\n\n\t\tif g.sequentialScanning {\n\t\t\tspriteFn(ctx)\n\t\t} else {\n\t\t\tg.taskQueue.Add(fmt.Sprintf(\"Generating sprites for %s\", path), spriteFn)\n\t\t}\n\t}\n\n\tif t.ScanGeneratePhashes {\n\t\tprogress.AddTotal(1)\n\t\tphashFn := func(ctx context.Context) {\n\t\t\ttaskPhash := GeneratePhashTask{\n\t\t\t\trepository:          mgr.Repository,\n\t\t\t\tFile:                f,\n\t\t\t\tOverwrite:           overwrite,\n\t\t\t\tfileNamingAlgorithm: g.fileNamingAlgorithm,\n\t\t\t}\n\t\t\ttaskPhash.Start(ctx)\n\t\t\tprogress.Increment()\n\t\t}\n\n\t\tif g.sequentialScanning {\n\t\t\tphashFn(ctx)\n\t\t} else {\n\t\t\tg.taskQueue.Add(fmt.Sprintf(\"Generating phash for %s\", path), phashFn)\n\t\t}\n\t}\n\n\tif t.ScanGeneratePreviews {\n\t\tprogress.AddTotal(1)\n\t\tpreviewsFn := func(ctx context.Context) {\n\t\t\toptions := getGeneratePreviewOptions(GeneratePreviewOptionsInput{})\n\n\t\t\tgenerator := &generate.Generator{\n\t\t\t\tEncoder:      mgr.FFMpeg,\n\t\t\t\tFFMpegConfig: mgr.Config,\n\t\t\t\tLockManager:  mgr.ReadLockManager,\n\t\t\t\tMarkerPaths:  g.paths.SceneMarkers,\n\t\t\t\tScenePaths:   g.paths.Scene,\n\t\t\t\tOverwrite:    overwrite,\n\t\t\t}\n\n\t\t\ttaskPreview := GeneratePreviewTask{\n\t\t\t\tScene:               *s,\n\t\t\t\tImagePreview:        t.ScanGenerateImagePreviews,\n\t\t\t\tOptions:             options,\n\t\t\t\tOverwrite:           overwrite,\n\t\t\t\tfileNamingAlgorithm: g.fileNamingAlgorithm,\n\t\t\t\tgenerator:           generator,\n\t\t\t}\n\t\t\ttaskPreview.Start(ctx)\n\t\t\tprogress.Increment()\n\t\t}\n\n\t\tif g.sequentialScanning {\n\t\t\tpreviewsFn(ctx)\n\t\t} else {\n\t\t\tg.taskQueue.Add(fmt.Sprintf(\"Generating preview for %s\", path), previewsFn)\n\t\t}\n\t}\n\n\tif t.ScanGenerateCovers {\n\t\tprogress.AddTotal(1)\n\t\tg.taskQueue.Add(fmt.Sprintf(\"Generating cover for %s\", path), func(ctx context.Context) {\n\t\t\ttaskCover := GenerateCoverTask{\n\t\t\t\trepository: mgr.Repository,\n\t\t\t\tScene:      *s,\n\t\t\t\tOverwrite:  overwrite,\n\t\t\t}\n\t\t\ttaskCover.Start(ctx)\n\t\t\tprogress.Increment()\n\t\t})\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/manager/task_stash_box_tag.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/stashbox\"\n\t\"github.com/stashapp/stash/pkg/studio\"\n\t\"github.com/stashapp/stash/pkg/tag\"\n)\n\n// stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box.\n//\n// Two modes of operation:\n//   - Update existing performer: set performer to update from stash-box data\n//   - Create new performer: set name or stashID to search stash-box and create locally\ntype stashBoxBatchPerformerTagTask struct {\n\tbox            *models.StashBox\n\tname           *string\n\tstashID        *string\n\tperformer      *models.Performer\n\texcludedFields []string\n}\n\nfunc (t *stashBoxBatchPerformerTagTask) getName() string {\n\tswitch {\n\tcase t.name != nil:\n\t\treturn *t.name\n\tcase t.stashID != nil:\n\t\treturn *t.stashID\n\tcase t.performer != nil:\n\t\treturn t.performer.Name\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) {\n\tperformer, err := t.findStashBoxPerformer(ctx)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error fetching performer data from stash-box: %v\", err)\n\t\treturn\n\t}\n\n\texcluded := map[string]bool{}\n\tfor _, field := range t.excludedFields {\n\t\texcluded[field] = true\n\t}\n\n\tif performer != nil {\n\t\tt.processMatchedPerformer(ctx, performer, excluded)\n\t} else {\n\t\tlogger.Infof(\"No match found for %s\", t.getName())\n\t}\n}\n\nfunc (t *stashBoxBatchPerformerTagTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Tagging performer %s from stash-box\", t.getName())\n}\n\nfunc (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) {\n\tvar performer *models.ScrapedPerformer\n\tvar err error\n\n\tr := instance.Repository\n\n\tclient := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))\n\n\tswitch {\n\tcase t.name != nil:\n\t\tperformer, err = client.FindPerformerByName(ctx, *t.name)\n\tcase t.stashID != nil:\n\t\tperformer, err = client.FindPerformerByID(ctx, *t.stashID)\n\n\t\tif performer != nil && performer.RemoteMergedIntoId != nil {\n\t\t\tmergedPerformer, err := t.handleMergedPerformer(ctx, performer, client)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tif mergedPerformer != nil {\n\t\t\t\tlogger.Infof(\"Performer id %s merged into %s, updating local performer\", *t.stashID, *performer.RemoteMergedIntoId)\n\t\t\t\tperformer = mergedPerformer\n\t\t\t}\n\t\t}\n\tcase t.performer != nil: // tagging or updating existing performer\n\t\tvar remoteID string\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Performer\n\n\t\t\tif !t.performer.StashIDs.Loaded() {\n\t\t\t\terr = t.performer.LoadStashIDs(ctx, qb)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor _, id := range t.performer.StashIDs.List() {\n\t\t\t\tif id.Endpoint == t.box.Endpoint {\n\t\t\t\t\tremoteID = id.StashID\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif remoteID != \"\" {\n\t\t\tperformer, err = client.FindPerformerByID(ctx, remoteID)\n\n\t\t\tif performer != nil && performer.RemoteMergedIntoId != nil {\n\t\t\t\tmergedPerformer, err := t.handleMergedPerformer(ctx, performer, client)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\n\t\t\t\tif mergedPerformer != nil {\n\t\t\t\t\tlogger.Infof(\"Performer id %s merged into %s, updating local performer\", remoteID, *performer.RemoteMergedIntoId)\n\t\t\t\t\tperformer = mergedPerformer\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// find by performer name instead\n\t\t\tperformer, err = client.FindPerformerByName(ctx, t.performer.Name)\n\t\t}\n\t}\n\n\tif performer != nil {\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\treturn match.ScrapedPerformer(ctx, r.Performer, performer, t.box.Endpoint)\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn performer, err\n}\n\nfunc (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) {\n\tmergedPerformer, err = client.FindPerformerByID(ctx, *performer.RemoteMergedIntoId)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"loading merged performer %s from stashbox\", *performer.RemoteMergedIntoId)\n\t}\n\n\tif mergedPerformer.StoredID != nil && *mergedPerformer.StoredID != *performer.StoredID {\n\t\tlogger.Warnf(\"Performer %s merged into %s, but both exist locally, not merging\", *performer.StoredID, *mergedPerformer.StoredID)\n\t\treturn nil, nil\n\t}\n\n\tmergedPerformer.StoredID = performer.StoredID\n\treturn mergedPerformer, nil\n}\n\nfunc (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {\n\tif t.performer != nil {\n\t\tstoredID, _ := strconv.Atoi(*p.StoredID)\n\n\t\timage, err := p.GetImage(ctx, excluded)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error processing scraped performer image for %s: %v\", *p.Name, err)\n\t\t\treturn\n\t\t}\n\n\t\tr := instance.Repository\n\t\terr = r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Performer\n\n\t\t\texistingStashIDs, err := qb.GetStashIDs(ctx, storedID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tpartial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs)\n\n\t\t\t// if we're setting the performer's aliases, and not the name, then filter out the name\n\t\t\t// from the aliases to avoid duplicates\n\t\t\t// add the name to the aliases if it's not already there\n\t\t\tif partial.Aliases != nil && !partial.Name.Set {\n\t\t\t\tpartial.Aliases.Values = sliceutil.Filter(partial.Aliases.Values, func(s string) bool {\n\t\t\t\t\treturn s != t.performer.Name\n\t\t\t\t})\n\n\t\t\t\tif p.Name != nil && t.performer.Name != *p.Name {\n\t\t\t\t\tpartial.Aliases.Values = sliceutil.AppendUnique(partial.Aliases.Values, *p.Name)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := performer.ValidateUpdate(ctx, t.performer.ID, partial, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif _, err := qb.UpdatePartial(ctx, t.performer.ID, partial); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(image) > 0 {\n\t\t\t\tif err := qb.UpdateImage(ctx, t.performer.ID, image); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to update performer %s: %v\", *p.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Updated performer %s\", *p.Name)\n\t\t}\n\t} else {\n\t\t// no existing performer, create a new one\n\t\tnewPerformer := p.ToPerformer(t.box.Endpoint, excluded)\n\t\timage, err := p.GetImage(ctx, excluded)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error processing scraped performer image for %s: %v\", *p.Name, err)\n\t\t\treturn\n\t\t}\n\n\t\tr := instance.Repository\n\t\terr = r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Performer\n\n\t\t\tif err := performer.ValidateCreate(ctx, *newPerformer, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := qb.Create(ctx, &models.CreatePerformerInput{Performer: newPerformer}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(image) > 0 {\n\t\t\t\tif err := qb.UpdateImage(ctx, newPerformer.ID, image); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to create performer %s: %v\", *p.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Created performer %s\", *p.Name)\n\t\t}\n\t}\n}\n\n// stashBoxBatchStudioTagTask is used to tag or create studios from stash-box.\n//\n// Two modes of operation:\n//   - Update existing studio: set studio to update from stash-box data\n//   - Create new studio: set name or stashID to search stash-box and create locally\ntype stashBoxBatchStudioTagTask struct {\n\tbox            *models.StashBox\n\tname           *string\n\tstashID        *string\n\tstudio         *models.Studio\n\tcreateParent   bool\n\texcludedFields []string\n}\n\nfunc (t *stashBoxBatchStudioTagTask) getName() string {\n\tswitch {\n\tcase t.name != nil:\n\t\treturn *t.name\n\tcase t.stashID != nil:\n\t\treturn *t.stashID\n\tcase t.studio != nil:\n\t\treturn t.studio.Name\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) {\n\t// Skip organized studios\n\tif t.studio != nil && t.studio.Organized {\n\t\tlogger.Infof(\"Skipping organized studio %s\", t.studio.Name)\n\t\treturn\n\t}\n\n\tstudio, err := t.findStashBoxStudio(ctx)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error fetching studio data from stash-box: %v\", err)\n\t\treturn\n\t}\n\n\texcluded := map[string]bool{}\n\tfor _, field := range t.excludedFields {\n\t\texcluded[field] = true\n\t}\n\n\tif studio != nil {\n\t\tt.processMatchedStudio(ctx, studio, excluded)\n\t} else {\n\t\tlogger.Infof(\"No match found for %s\", t.getName())\n\t}\n}\n\nfunc (t *stashBoxBatchStudioTagTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Tagging studio %s from stash-box\", t.getName())\n}\n\nfunc (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) {\n\tvar studio *models.ScrapedStudio\n\tvar err error\n\n\tr := instance.Repository\n\n\tclient := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))\n\n\tswitch {\n\tcase t.name != nil:\n\t\tstudio, err = client.FindStudio(ctx, *t.name)\n\tcase t.stashID != nil:\n\t\tstudio, err = client.FindStudio(ctx, *t.stashID)\n\tcase t.studio != nil:\n\t\tvar remoteID string\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tif !t.studio.StashIDs.Loaded() {\n\t\t\t\terr = t.studio.LoadStashIDs(ctx, r.Studio)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor _, id := range t.studio.StashIDs.List() {\n\t\t\t\tif id.Endpoint == t.box.Endpoint {\n\t\t\t\t\tremoteID = id.StashID\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif remoteID != \"\" {\n\t\t\tstudio, err = client.FindStudio(ctx, remoteID)\n\t\t} else {\n\t\t\t// find by studio name instead\n\t\t\tstudio, err = client.FindStudio(ctx, t.studio.Name)\n\t\t}\n\t}\n\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tif studio != nil {\n\t\t\tif err := match.ScrapedStudioHierarchy(ctx, r.Studio, studio, t.box.Endpoint); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn studio, err\n}\n\nfunc (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {\n\tif t.studio != nil {\n\t\tstoredID, _ := strconv.Atoi(*s.StoredID)\n\n\t\tif s.Parent != nil && t.createParent {\n\t\t\terr := t.processParentStudio(ctx, s.Parent, excluded)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\timage, err := s.GetImage(ctx, excluded)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error processing scraped studio image for %s: %v\", s.Name, err)\n\t\t\treturn\n\t\t}\n\n\t\tr := instance.Repository\n\t\terr = r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Studio\n\n\t\t\texistingStashIDs, err := qb.GetStashIDs(ctx, storedID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tpartial := s.ToPartial(*s.StoredID, t.box.Endpoint, excluded, existingStashIDs)\n\n\t\t\tif err := studio.ValidateModify(ctx, partial, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif _, err := qb.UpdatePartial(ctx, partial); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(image) > 0 {\n\t\t\t\tif err := qb.UpdateImage(ctx, partial.ID, image); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to update studio %s: %v\", s.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Updated studio %s\", s.Name)\n\t\t}\n\t} else if s.Name != \"\" {\n\t\t// no existing studio, create a new one\n\t\tif s.Parent != nil && t.createParent {\n\t\t\terr := t.processParentStudio(ctx, s.Parent, excluded)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tnewStudio := s.ToStudio(t.box.Endpoint, excluded)\n\t\tstudioImage, err := s.GetImage(ctx, excluded)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error processing scraped studio image for %s: %v\", s.Name, err)\n\t\t\treturn\n\t\t}\n\n\t\tr := instance.Repository\n\t\terr = r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Studio\n\n\t\t\tif err := studio.ValidateCreate(ctx, *newStudio, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := qb.Create(ctx, newStudio); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(studioImage) > 0 {\n\t\t\t\tif err := qb.UpdateImage(ctx, newStudio.ID, studioImage); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to create studio %s: %v\", s.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Created studio %s\", s.Name)\n\t\t}\n\t}\n}\n\nfunc (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error {\n\tif parent.StoredID == nil {\n\t\tnewParentStudio := parent.ToStudio(t.box.Endpoint, excluded)\n\n\t\timage, err := parent.GetImage(ctx, excluded)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error processing scraped studio image for %s: %v\", parent.Name, err)\n\t\t\treturn err\n\t\t}\n\n\t\tr := instance.Repository\n\t\terr = r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Studio\n\n\t\t\tif err := qb.Create(ctx, newParentStudio); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(image) > 0 {\n\t\t\t\tif err := qb.UpdateImage(ctx, newParentStudio.ID, image); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tstoredId := strconv.Itoa(newParentStudio.ID)\n\t\t\tparent.StoredID = &storedId\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to create studio %s: %v\", parent.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Created studio %s\", parent.Name)\n\t\t}\n\t\treturn err\n\t} else {\n\t\tstoredID, _ := strconv.Atoi(*parent.StoredID)\n\n\t\timage, err := parent.GetImage(ctx, excluded)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error processing scraped studio image for %s: %v\", parent.Name, err)\n\t\t\treturn err\n\t\t}\n\n\t\tr := instance.Repository\n\t\terr = r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Studio\n\n\t\t\texistingStashIDs, err := qb.GetStashIDs(ctx, storedID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tpartial := parent.ToPartial(*parent.StoredID, t.box.Endpoint, excluded, existingStashIDs)\n\n\t\t\tif err := studio.ValidateModify(ctx, partial, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif _, err := qb.UpdatePartial(ctx, partial); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(image) > 0 {\n\t\t\t\tif err := qb.UpdateImage(ctx, partial.ID, image); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to update studio %s: %v\", parent.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Updated studio %s\", parent.Name)\n\t\t}\n\t\treturn err\n\t}\n}\n\n// stashBoxBatchTagTagTask is used to tag or create tags from stash-box.\n//\n// Two modes of operation:\n//   - Update existing tag: set tag to update from stash-box data\n//   - Create new tag: set name or stashID to search stash-box and create locally\ntype stashBoxBatchTagTagTask struct {\n\tbox            *models.StashBox\n\tname           *string\n\tstashID        *string\n\ttag            *models.Tag\n\tcreateParent   bool\n\texcludedFields []string\n}\n\nfunc (t *stashBoxBatchTagTagTask) getName() string {\n\tswitch {\n\tcase t.name != nil:\n\t\treturn *t.name\n\tcase t.stashID != nil:\n\t\treturn *t.stashID\n\tcase t.tag != nil:\n\t\treturn t.tag.Name\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc (t *stashBoxBatchTagTagTask) Start(ctx context.Context) {\n\tscrapedTag, err := t.findStashBoxTag(ctx)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error fetching tag data from stash-box: %v\", err)\n\t\treturn\n\t}\n\n\texcluded := map[string]bool{}\n\tfor _, field := range t.excludedFields {\n\t\texcluded[field] = true\n\t}\n\n\tif scrapedTag != nil {\n\t\tt.processMatchedTag(ctx, scrapedTag, excluded)\n\t} else {\n\t\tlogger.Infof(\"No match found for %s\", t.getName())\n\t}\n}\n\nfunc (t *stashBoxBatchTagTagTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Tagging tag %s from stash-box\", t.getName())\n}\n\nfunc (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.ScrapedTag, error) {\n\tvar results []*models.ScrapedTag\n\tvar err error\n\n\tr := instance.Repository\n\n\tclient := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))\n\n\tswitch {\n\tcase t.name != nil:\n\t\tresults, err = client.QueryTag(ctx, *t.name)\n\tcase t.stashID != nil:\n\t\tresults, err = client.QueryTag(ctx, *t.stashID)\n\tcase t.tag != nil:\n\t\tvar remoteID string\n\t\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\t\tif !t.tag.StashIDs.Loaded() {\n\t\t\t\terr = t.tag.LoadStashIDs(ctx, r.Tag)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor _, id := range t.tag.StashIDs.List() {\n\t\t\t\tif id.Endpoint == t.box.Endpoint {\n\t\t\t\t\tremoteID = id.StashID\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif remoteID != \"\" {\n\t\t\tresults, err = client.QueryTag(ctx, remoteID)\n\t\t} else {\n\t\t\tresults, err = client.QueryTag(ctx, t.tag.Name)\n\t\t}\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(results) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tresult := results[0]\n\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\treturn match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint)\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn result, nil\n}\n\nfunc (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error {\n\tif parent.StoredID == nil {\n\t\t// Create new parent tag\n\t\tnewParentTag := parent.ToTag(t.box.Endpoint, excluded)\n\n\t\tr := instance.Repository\n\t\terr := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Tag\n\n\t\t\tif err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tstoredID := strconv.Itoa(newParentTag.ID)\n\t\t\tparent.StoredID = &storedID\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to create parent tag %s: %v\", parent.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Created parent tag %s\", parent.Name)\n\t\t}\n\t\treturn err\n\t}\n\n\t// Parent already exists — nothing to update for categories\n\treturn nil\n}\n\nfunc (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) {\n\t// Determine the tag ID to update — either from the task's tag or from the\n\t// StoredID set by match.ScrapedTag (when batch adding by name and the tag\n\t// already exists locally).\n\ttagID := 0\n\tif t.tag != nil {\n\t\ttagID = t.tag.ID\n\t} else if s.StoredID != nil {\n\t\ttagID, _ = strconv.Atoi(*s.StoredID)\n\t}\n\n\tif s.Parent != nil && t.createParent {\n\t\tif err := t.processParentTag(ctx, s.Parent, excluded); err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\n\tif tagID > 0 {\n\t\tr := instance.Repository\n\t\terr := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Tag\n\n\t\t\texistingStashIDs, err := qb.GetStashIDs(ctx, tagID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tstoredID := strconv.Itoa(tagID)\n\t\t\tpartial := s.ToPartial(storedID, t.box.Endpoint, excluded, existingStashIDs)\n\n\t\t\tif err := tag.ValidateUpdate(ctx, tagID, partial, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif _, err := qb.UpdatePartial(ctx, tagID, partial); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to update tag %s: %v\", s.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Updated tag %s\", s.Name)\n\t\t}\n\t} else if s.Name != \"\" {\n\t\t// no existing tag, create a new one\n\t\tnewTag := s.ToTag(t.box.Endpoint, excluded)\n\n\t\tr := instance.Repository\n\t\terr := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tqb := r.Tag\n\n\t\t\tif err := tag.ValidateCreate(ctx, *newTag, qb); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := qb.Create(ctx, &models.CreateTagInput{Tag: newTag}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to create tag %s: %v\", s.Name, err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Created tag %s\", s.Name)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/manager/task_transcode.go",
    "content": "package manager\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene/generate\"\n)\n\ntype GenerateTranscodeTask struct {\n\tScene               models.Scene\n\tOverwrite           bool\n\tfileNamingAlgorithm models.HashAlgorithm\n\n\t// is true, generate even if video is browser-supported\n\tForce bool\n\n\tg *generate.Generator\n}\n\nfunc (t *GenerateTranscodeTask) GetDescription() string {\n\treturn fmt.Sprintf(\"Generating transcode for %s\", t.Scene.Path)\n}\n\nfunc (t *GenerateTranscodeTask) Start(ctx context.Context) {\n\thasTranscode := HasTranscode(&t.Scene, t.fileNamingAlgorithm)\n\tif !t.Overwrite && hasTranscode {\n\t\treturn\n\t}\n\n\tf := t.Scene.Files.Primary()\n\n\tffprobe := instance.FFProbe\n\tvar container ffmpeg.Container\n\n\tvar err error\n\tcontainer, err = GetVideoFileContainer(f)\n\tif err != nil {\n\t\tlogger.Errorf(\"[transcode] error getting scene container: %s\", err.Error())\n\t\treturn\n\t}\n\n\tvar videoCodec string\n\n\tif f.VideoCodec != \"\" {\n\t\tvideoCodec = f.VideoCodec\n\t}\n\n\taudioCodec := ffmpeg.MissingUnsupported\n\tif f.AudioCodec != \"\" {\n\t\taudioCodec = ffmpeg.ProbeAudioCodec(f.AudioCodec)\n\t}\n\n\tif !t.Force && ffmpeg.IsStreamable(videoCodec, audioCodec, container) == nil {\n\t\treturn\n\t}\n\n\t// TODO - move transcode generation logic elsewhere\n\n\tvideoFile, err := ffprobe.NewVideoFile(f.Path)\n\tif err != nil {\n\t\tlogger.Errorf(\"[transcode] error reading video file: %s\", err.Error())\n\t\treturn\n\t}\n\n\tsceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)\n\ttranscodeSize := config.GetInstance().GetMaxTranscodeSize()\n\n\tw, h := videoFile.TranscodeScale(transcodeSize.GetMaxResolution())\n\n\t// if scale is being set, then we can't use stream copy\n\tscaleSet := w == 0 && h == 0\n\n\tif scaleSet && videoCodec == ffmpeg.H264 { // for non supported h264 files stream copy the video part\n\t\tif audioCodec == ffmpeg.MissingUnsupported {\n\t\t\terr = t.g.TranscodeCopyVideo(ctx, videoFile.Path, sceneHash)\n\t\t} else {\n\t\t\terr = t.g.TranscodeAudio(ctx, videoFile.Path, sceneHash)\n\t\t}\n\t} else {\n\t\toptions := generate.TranscodeOptions{\n\t\t\tWidth:  w,\n\t\t\tHeight: h,\n\t\t}\n\n\t\tif audioCodec == ffmpeg.MissingUnsupported {\n\t\t\t// ffmpeg fails if it tries to transcode an unsupported audio codec\n\t\t\terr = t.g.TranscodeVideo(ctx, videoFile.Path, sceneHash, options)\n\t\t} else {\n\t\t\terr = t.g.Transcode(ctx, videoFile.Path, sceneHash, options)\n\t\t}\n\t}\n\n\tif err != nil {\n\t\tlogger.Errorf(\"[transcode] error generating transcode: %v\", err)\n\t\treturn\n\t}\n}\n\n// return true if transcode is needed\n// used only when counting files to generate, doesn't affect the actual transcode generation\n// if container is missing from DB it is treated as non supported in order not to delay the user\nfunc (t *GenerateTranscodeTask) required() bool {\n\tf := t.Scene.Files.Primary()\n\tif f == nil {\n\t\treturn false\n\t}\n\n\thasTranscode := HasTranscode(&t.Scene, t.fileNamingAlgorithm)\n\tif !t.Overwrite && hasTranscode {\n\t\treturn false\n\t}\n\n\tif t.Force {\n\t\treturn true\n\t}\n\n\tvar videoCodec string\n\tif f.VideoCodec != \"\" {\n\t\tvideoCodec = f.VideoCodec\n\t}\n\tcontainer := \"\"\n\taudioCodec := ffmpeg.MissingUnsupported\n\tif f.AudioCodec != \"\" {\n\t\taudioCodec = ffmpeg.ProbeAudioCodec(f.AudioCodec)\n\t}\n\n\tif f.Format != \"\" {\n\t\tcontainer = f.Format\n\t}\n\n\tif ffmpeg.IsStreamable(videoCodec, audioCodec, ffmpeg.Container(container)) == nil {\n\t\treturn false\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "internal/static/embed.go",
    "content": "// Package static provides the static files embedded in the application.\npackage static\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n)\n\n//go:embed performer performer_male performer_sfw scene image gallery tag studio group\nvar data embed.FS\n\nconst (\n\tPerformer                = \"performer\"\n\tPerformerMale            = \"performer_male\"\n\tDefaultSFWPerformerImage = \"performer_sfw/performer.svg\"\n\n\tScene             = \"scene\"\n\tDefaultSceneImage = \"scene/scene.svg\"\n\n\tImage             = \"image\"\n\tDefaultImageImage = \"image/image.svg\"\n\n\tGallery             = \"gallery\"\n\tDefaultGalleryImage = \"gallery/gallery.svg\"\n\n\tTag             = \"tag\"\n\tDefaultTagImage = \"tag/tag.svg\"\n\n\tStudio             = \"studio\"\n\tDefaultStudioImage = \"studio/studio.svg\"\n\n\tGroup             = \"group\"\n\tDefaultGroupImage = \"group/group.svg\"\n)\n\n// Sub returns an FS rooted at path, using fs.Sub.\n// It will panic if an error occurs.\nfunc Sub(path string) fs.FS {\n\tret, err := fs.Sub(data, path)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"creating static SubFS: %v\", err))\n\t}\n\treturn ret\n}\n\n// Open opens the file at path for reading.\n// It will panic if an error occurs.\nfunc Open(path string) fs.File {\n\tf, err := data.Open(path)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"opening static file: %v\", err))\n\t}\n\treturn f\n}\n\n// ReadAll returns the contents of the file at path.\n// It will panic if an error occurs.\nfunc ReadAll(path string) []byte {\n\tf := Open(path)\n\tret, err := io.ReadAll(f)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"reading static file: %v\", err))\n\t}\n\treturn ret\n}\n"
  },
  {
    "path": "internal/static/performer/attribution.md",
    "content": "NoName02.svg - \"[Exotic dancer silhouette](https://freesvg.org/exotic-dancer-silhouette)\" by OpenClipart-Vectors under CC0 License  \nNoName05.svg - \"[Fashion girl silhouette](https://creazilla.com/media/silhouette/76433/fashion-girl)\" by Creazilla under CC0 License  \nNoName06.png - \"[Woman, Female, Girl](https://pixabay.com/illustrations/woman-female-girl-lady-silhouette-163525/)\" by No-longer-here under Pixabay License  \nNoName07.svg - \"[Woman Silhouette 11](https://openclipart.org/detail/14083/woman-silhouette-11)\" by nicubunu under CC0 License  \nNoName09.svg - \"[Girl, Pose, Posing](https://pixabay.com/vectors/girl-pose-posing-female-woman-311535/)\" by Clker-Free-Vector-Images under CC0 License  \nNoName11.png - \"[Alpha Mask, Silhouette, Woman](https://pixabay.com/illustrations/alpha-mask-silhouette-woman-girl-3072470/)\" by Wolfgang Eckert under Pixabay License  \nNoName12.svg - \"[Dance, Dancer, Dancing](https://pixabay.com/vectors/dance-dancer-dancing-female-girl-2023863/)\" by OpenClipart-Vectors under CC0 License  \nNoName13.svg - \"[Dress, Silhouette, Woman](https://pixabay.com/vectors/dress-silhouette-woman-female-148745/)\" by OpenClipart-Vectors under CC0 License  \nNoName14.svg - \"[Woman in long dress silhouette](https://freesvg.org/woman-in-long-dress-silhouette)\" by OpenClipart-Vectors under CC0 License  \nNoName17.svg - \"[Female Model silhouette](https://creazilla.com/media/silhouette/2495/female-model)\" by Natasha Sinegina under CC-BY-4.0  \nNoName19.svg - \"[Female, Girl, Heel](https://pixabay.com/vectors/female-girl-heel-silhouette-woman-2023898/)\" by OpenClipart-Vectors under CC0 License  \nNoName21.svg - \"[Lady, Silhouette, Woman](https://pixabay.com/vectors/lady-silhouette-woman-pink-296698/)\" by Clker-Free-Vector-Images under CC0 License  \nNoName22.svg - \"[Female, Girl, Heel](https://pixabay.com/vectors/female-girl-heel-silhouette-woman-2023856/)\" by OpenClipart-Vectors under CC0 License  \nNoName23.svg - \"[Woman, Female, Figure](https://pixabay.com/vectors/woman-female-figure-slender-slim-149723/)\" by OpenClipart-Vectors under CC0 License  \nNoName24.svg - \"[Silhouette, Woman, Bunny](https://pixabay.com/illustrations/silhouette-woman-bunny-girl-female-3196716/)\" by Wolfgang Eckert under Pixabay License  \nNoName25.svg - \"[Female, Girl, Silhouette](https://pixabay.com/vectors/female-girl-silhouette-woman-2023857/)\" by OpenClipart-Vectors under CC0 License  \nNoName26.svg - \"[Female, Girl, Silhouette](https://pixabay.com/vectors/female-girl-silhouette-woman-2024047/)\" by OpenClipart-Vectors under CC0 License  \nNoName27.svg - \"[Woman, School Clothes, Uniform](https://pixabay.com/illustrations/woman-school-clothes-uniform-644569/)\" by Silvia under Pixabay License  \nNoName28.svg - \"[Girl, Woman, Feminine](https://pixabay.com/illustrations/girl-woman-feminine-sensual-1369733/)\" by Calzas under Pixabay License  \nNoName29.png - \"[Alpha Mask, Silhouette, Woman](https://pixabay.com/illustrations/alpha-mask-silhouette-woman-girl-3066005/)\" by Wolfgang Eckert under Pixabay License  \nNoName30.svg - \"[Architetto](https://openclipart.org/detail/68047)\" by Emilie Rollandin under CC0 License  \nNoName31.svg - \"[Model silhouette](https://creazilla.com/media/silhouette/1785/model)\" by Bob Comix under CC-BY-4.0 License  \nNoName32.svg - \"[Fashion, Female, Girl](https://pixabay.com/vectors/fashion-female-girl-heel-model-2023859/)\" by OpenClipart-Vectors under CC0 License  \nNoName33.png - \"[Silhouette Donna 6](https://www.publicdomainpictures.net/view-image.php?image=82268)\" by Tammy Sue under CC0 License  \nNoName34.svg - \"[Donna in piedi 01](https://openclipart.org/detail/33139)\" by Emilie Rollandin under CC0 License  \nNoName35.png - \"[Silhouette, Woman, Young](https://pixabay.com/illustrations/silhouette-woman-young-move-female-3104942/)\" by Wolfgang Eckert under Pixabay License  \nNoName36.svg - \"[Fashion Model silhouette](https://creazilla.com/media/silhouette/2506/fashion-model)\" by Natasha Sinegina under CC-BY-4.0 License  \nNoName37.svg - \"[Female, Woman, Standing](https://pixabay.com/vectors/female-woman-standing-confident-2816234/)\" by Mohamed Hassan under Pixabay License  \nNoName38.svg - \"[Dress, Silhouette, Women](https://pixabay.com/vectors/dress-silhouette-women-dance-lady-3360422/)\" by Mohamed Hassan under Pixabay License  \nNoName39.svg - \"[Woman, Female, Lady](https://pixabay.com/illustrations/woman-female-lady-business-woman-220260/)\" by No-longer-here under Pixabay License  \n\nCC0 License: https://creativecommons.org/publicdomain/zero/1.0/\nCC-BY-4.0 License: https://creativecommons.org/licenses/by/4.0/\nPixabay License: https://pixabay.com/service/license-summary/"
  },
  {
    "path": "internal/static/performer_male/attribution.md",
    "content": "Male01.svg - \"[Man Silhouette](https://freesvg.org/1528398040)\" by \"OpenClipart\" under CC0 License  \nMale02.svg - \"[Male pose silhouette](https://freesvg.org/male-pose-silhouette)\" by OpenClipart under CC0 License  \nMale03.svg - \"[Bald man walking in a suit silhouette vector image](https://freesvg.org/bald-man-walking-in-a-suit-silhouette-vector-image)\" by OpenClipart under CC0 License  \nMale04.svg - \"[Man silhouette vector clip art](https://freesvg.org/man-silhouette-vector-clip-art) by OpenClipart under CC0 License  \nMale05.svg - \"[Man, Walking, Confident](https://pixabay.com/vectors/man-walking-confident-silhouette-2759950/)\" by Mohamed Hassan under Pixabay License  \n\nCC0 Licence: https://creativecommons.org/public-domain/cc0/  \nPixabay License: https://pixabay.com/service/license-summary/  "
  },
  {
    "path": "pkg/exec/command.go",
    "content": "// Package exec provides functions that wrap os/exec functions. These functions prevent external commands from opening windows on the Windows platform.\npackage exec\n\nimport (\n\t\"context\"\n\t\"os/exec\"\n)\n\n// Command wraps the exec.Command function, preventing Windows from opening a window when starting.\nfunc Command(name string, arg ...string) *exec.Cmd {\n\tret := exec.Command(name, arg...)\n\thideExecShell(ret)\n\treturn ret\n}\n\n// CommandContext wraps the exec.CommandContext function, preventing Windows from opening a window when starting.\nfunc CommandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {\n\tret := exec.CommandContext(ctx, name, arg...)\n\thideExecShell(ret)\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/exec/shell_nonwindows.go",
    "content": "//go:build linux || darwin || !windows\n// +build linux darwin !windows\n\npackage exec\n\nimport \"os/exec\"\n\n// hideExecShell does nothing on non-Windows platforms.\nfunc hideExecShell(cmd *exec.Cmd) {\n}\n"
  },
  {
    "path": "pkg/exec/shell_windows.go",
    "content": "//go:build windows\n// +build windows\n\npackage exec\n\nimport (\n\t\"os/exec\"\n\t\"syscall\"\n\n\t\"golang.org/x/sys/windows\"\n)\n\n// hideExecShell hides the windows when executing on Windows.\nfunc hideExecShell(cmd *exec.Cmd) {\n\tcmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS & windows.CREATE_NO_WINDOW}\n}\n"
  },
  {
    "path": "pkg/ffmpeg/browser.go",
    "content": "package ffmpeg\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\n// only support H264 by default, since Safari does not support VP8/VP9\nvar defaultSupportedCodecs = []string{H264, H265}\n\nvar validForH264Mkv = []Container{Mp4, Matroska}\nvar validForH264 = []Container{Mp4}\nvar validForH265Mkv = []Container{Mp4, Matroska}\nvar validForH265 = []Container{Mp4}\nvar validForVp8 = []Container{Webm}\nvar validForVp9Mkv = []Container{Webm, Matroska}\nvar validForVp9 = []Container{Webm}\nvar validForHevcMkv = []Container{Mp4, Matroska}\nvar validForHevc = []Container{Mp4}\n\nvar validAudioForMkv = []ProbeAudioCodec{Aac, Mp3, Vorbis, Opus}\nvar validAudioForWebm = []ProbeAudioCodec{Vorbis, Opus}\nvar validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3, Opus}\n\nvar (\n\t// ErrUnsupportedVideoCodecForBrowser is returned when the video codec is not supported for browser streaming.\n\tErrUnsupportedVideoCodecForBrowser = errors.New(\"unsupported video codec for browser\")\n\n\t// ErrUnsupportedVideoCodecContainer is returned when the video codec/container combination is not supported for browser streaming.\n\tErrUnsupportedVideoCodecContainer = errors.New(\"video codec/container combination is unsupported for browser streaming\")\n\n\t// ErrUnsupportedAudioCodecContainer is returned when the audio codec/container combination is not supported for browser streaming.\n\tErrUnsupportedAudioCodecContainer = errors.New(\"audio codec/container combination is unsupported for browser streaming\")\n)\n\n// IsStreamable returns nil if the file is streamable, or an error if it is not.\nfunc IsStreamable(videoCodec string, audioCodec ProbeAudioCodec, container Container) error {\n\tsupportedVideoCodecs := defaultSupportedCodecs\n\n\t// check if the video codec matches the supported codecs\n\tif !isValidCodec(videoCodec, supportedVideoCodecs) {\n\t\treturn fmt.Errorf(\"%w: %s\", ErrUnsupportedVideoCodecForBrowser, videoCodec)\n\t}\n\n\tif !isValidCombo(videoCodec, container, supportedVideoCodecs) {\n\t\treturn fmt.Errorf(\"%w: %s/%s\", ErrUnsupportedVideoCodecContainer, videoCodec, container)\n\t}\n\n\tif !IsValidAudioForContainer(audioCodec, container) {\n\t\treturn fmt.Errorf(\"%w: %s/%s\", ErrUnsupportedAudioCodecContainer, audioCodec, container)\n\t}\n\n\treturn nil\n}\n\nfunc isValidCodec(codecName string, supportedCodecs []string) bool {\n\tfor _, c := range supportedCodecs {\n\t\tif c == codecName {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isValidAudio(audio ProbeAudioCodec, validCodecs []ProbeAudioCodec) bool {\n\t// if audio codec is missing or unsupported by ffmpeg we can't do anything about it\n\t// report it as valid so that the file can at least be streamed directly if the video codec is supported\n\tif audio == MissingUnsupported {\n\t\treturn true\n\t}\n\n\tfor _, c := range validCodecs {\n\t\tif c == audio {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// IsValidAudioForContainer returns true if the audio codec is valid for the container.\nfunc IsValidAudioForContainer(audio ProbeAudioCodec, format Container) bool {\n\tswitch format {\n\tcase Matroska:\n\t\treturn isValidAudio(audio, validAudioForMkv)\n\tcase Webm:\n\t\treturn isValidAudio(audio, validAudioForWebm)\n\tcase Mp4:\n\t\treturn isValidAudio(audio, validAudioForMp4)\n\t}\n\treturn false\n}\n\n// isValidCombo checks if a codec/container combination is valid.\n// Returns true on validity, false otherwise\nfunc isValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {\n\tsupportMKV := isValidCodec(Mkv, supportedVideoCodecs)\n\tsupportHEVC := isValidCodec(Hevc, supportedVideoCodecs)\n\n\tswitch codecName {\n\tcase H264:\n\t\tif supportMKV {\n\t\t\treturn isValidForContainer(format, validForH264Mkv)\n\t\t}\n\t\treturn isValidForContainer(format, validForH264)\n\tcase H265:\n\t\tif supportMKV {\n\t\t\treturn isValidForContainer(format, validForH265Mkv)\n\t\t}\n\t\treturn isValidForContainer(format, validForH265)\n\tcase Vp8:\n\t\treturn isValidForContainer(format, validForVp8)\n\tcase Vp9:\n\t\tif supportMKV {\n\t\t\treturn isValidForContainer(format, validForVp9Mkv)\n\t\t}\n\t\treturn isValidForContainer(format, validForVp9)\n\tcase Hevc:\n\t\tif supportHEVC {\n\t\t\tif supportMKV {\n\t\t\t\treturn isValidForContainer(format, validForHevcMkv)\n\t\t\t}\n\t\t\treturn isValidForContainer(format, validForHevc)\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isValidForContainer(format Container, validContainers []Container) bool {\n\tfor _, fmt := range validContainers {\n\t\tif fmt == format {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "pkg/ffmpeg/codec.go",
    "content": "package ffmpeg\n\ntype VideoCodec struct {\n\tName     string // The full name of the codec including profile/quality\n\tCodeName string // The core codec name without profile/quality suffix\n}\n\nfunc makeVideoCodec(name string, codename string) VideoCodec {\n\treturn VideoCodec{name, codename}\n}\n\nfunc (c VideoCodec) Args() []string {\n\tif c.CodeName == \"\" {\n\t\treturn nil\n\t}\n\n\treturn []string{\"-c:v\", string(c.CodeName)}\n}\n\nvar (\n\t// Software codec's\n\tVideoCodecLibX264 = makeVideoCodec(\"x264\", \"libx264\")\n\tVideoCodecLibWebP = makeVideoCodec(\"WebP\", \"libwebp\")\n\tVideoCodecBMP     = makeVideoCodec(\"BMP\", \"bmp\")\n\tVideoCodecMJpeg   = makeVideoCodec(\"Jpeg\", \"mjpeg\")\n\tVideoCodecVP9     = makeVideoCodec(\"VPX-VP9\", \"libvpx-vp9\")\n\tVideoCodecVPX     = makeVideoCodec(\"VPX-VP8\", \"libvpx\")\n\tVideoCodecLibX265 = makeVideoCodec(\"x265\", \"libx265\")\n\tVideoCodecCopy    = makeVideoCodec(\"Copy\", \"copy\")\n)\n\ntype AudioCodec string\n\nfunc (c AudioCodec) Args() []string {\n\tif c == \"\" {\n\t\treturn nil\n\t}\n\n\treturn []string{\"-c:a\", string(c)}\n}\n\nvar (\n\tAudioCodecAAC     AudioCodec = \"aac\"\n\tAudioCodecLibOpus AudioCodec = \"libopus\"\n\tAudioCodecCopy    AudioCodec = \"copy\"\n)\n"
  },
  {
    "path": "pkg/ffmpeg/codec_hardware.go",
    "content": "package ffmpeg\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"os\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nvar (\n\t// Hardware codec's\n\tVideoCodecN264  = makeVideoCodec(\"H264 NVENC\", \"h264_nvenc\")\n\tVideoCodecN264H = makeVideoCodec(\"H264 NVENC HQ profile\", \"h264_nvenc\")\n\tVideoCodecI264  = makeVideoCodec(\"H264 Intel Quick Sync Video (QSV)\", \"h264_qsv\")\n\tVideoCodecI264C = makeVideoCodec(\"H264 Intel Quick Sync Video (QSV) Compatibility profile\", \"h264_qsv\")\n\tVideoCodecA264  = makeVideoCodec(\"H264 Advanced Media Framework (AMF)\", \"h264_amf\")\n\tVideoCodecM264  = makeVideoCodec(\"H264 VideoToolbox\", \"h264_videotoolbox\")\n\tVideoCodecV264  = makeVideoCodec(\"H264 VAAPI\", \"h264_vaapi\")\n\tVideoCodecR264  = makeVideoCodec(\"H264 V4L2M2M\", \"h264_v4l2m2m\")\n\tVideoCodecO264  = makeVideoCodec(\"H264 OMX\", \"h264_omx\")\n\tVideoCodecIVP9  = makeVideoCodec(\"VP9 Intel Quick Sync Video (QSV)\", \"vp9_qsv\")\n\tVideoCodecVVP9  = makeVideoCodec(\"VP9 VAAPI\", \"vp9_vaapi\")\n\tVideoCodecVVPX  = makeVideoCodec(\"VP8 VAAPI\", \"vp8_vaapi\")\n\tVideoCodecRK264 = makeVideoCodec(\"H264 Rockchip MPP (rkmpp)\", \"h264_rkmpp\")\n)\n\nconst minHeight int = 480\n\n// Tests all (given) hardware codec's\nfunc (f *FFMpeg) InitHWSupport(ctx context.Context) {\n\t// do the hardware codec tests in a separate goroutine to avoid blocking\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tf.initHWSupport(ctx)\n\t\tclose(done)\n\t}()\n\n\t// log if the initialization takes too long\n\tconst hwInitLogTimeoutSecondsDefault = 5\n\thwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second\n\ttimer := time.NewTimer(hwInitLogTimeoutSeconds)\n\n\tgo func() {\n\t\tselect {\n\t\tcase <-timer.C:\n\t\t\tlogger.Warnf(\"[InitHWSupport] Hardware codec initialization is taking longer than %s...\", hwInitLogTimeoutSeconds)\n\t\t\tlogger.Info(\"[InitHWSupport] Hardware encoding will not be available until initialization is complete.\")\n\t\tcase <-done:\n\t\t\tif !timer.Stop() {\n\t\t\t\t<-timer.C\n\t\t\t}\n\t\t}\n\t}()\n}\n\nfunc (f *FFMpeg) initHWSupport(ctx context.Context) {\n\tvar hwCodecSupport []VideoCodec\n\n\t// Note that the first compatible codec is returned, so order is important\n\tfor _, codec := range []VideoCodec{\n\t\tVideoCodecN264H,\n\t\tVideoCodecN264,\n\t\tVideoCodecI264,\n\t\tVideoCodecI264C,\n\t\tVideoCodecV264,\n\t\tVideoCodecR264,\n\t\tVideoCodecRK264,\n\t\tVideoCodecIVP9,\n\t\tVideoCodecVVP9,\n\t\tVideoCodecM264,\n\t} {\n\t\tvar args Args\n\t\targs = append(args, \"-hide_banner\")\n\t\targs = args.LogLevel(LogLevelWarning)\n\t\targs = f.hwDeviceInit(args, codec, false)\n\t\targs = args.Format(\"lavfi\")\n\t\tvFile := &models.VideoFile{Width: 1280, Height: 720}\n\t\targs = args.Input(fmt.Sprintf(\"color=c=red:s=%dx%d\", vFile.Width, vFile.Height))\n\t\targs = args.Duration(0.1)\n\n\t\t// Test scaling\n\t\tvideoFilter := f.hwMaxResFilter(codec, vFile, minHeight, false)\n\t\targs = append(args, CodecInit(codec)...)\n\t\targs = args.VideoFilter(videoFilter)\n\n\t\targs = args.Format(\"null\")\n\t\targs = args.Output(\"-\")\n\n\t\t// #6064 - add timeout to context to prevent hangs\n\t\tconst hwTestTimeoutSecondsDefault = 10\n\t\thwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second\n\n\t\t// allow timeout to be overridden with environment variable\n\t\tif timeout := os.Getenv(\"STASH_HW_TEST_TIMEOUT\"); timeout != \"\" {\n\t\t\tif seconds, err := strconv.Atoi(timeout); err == nil {\n\t\t\t\thwTestTimeoutSeconds = time.Duration(seconds) * time.Second\n\t\t\t}\n\t\t}\n\n\t\ttestCtx, cancel := context.WithTimeout(ctx, hwTestTimeoutSeconds)\n\t\tdefer cancel()\n\n\t\tcmd := f.Command(testCtx, args)\n\t\tcmd.WaitDelay = time.Second\n\t\tlogger.Tracef(\"[InitHWSupport] Testing codec %s: %v\", codec, cmd.Args)\n\n\t\tvar stderr bytes.Buffer\n\t\tcmd.Stderr = &stderr\n\n\t\tif err := cmd.Run(); err != nil {\n\t\t\tif testCtx.Err() != nil {\n\t\t\t\tlogger.Debugf(\"[InitHWSupport] Codec %s test timed out after %s\", codec, hwTestTimeoutSeconds)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\terrOutput := stderr.String()\n\n\t\t\tif len(errOutput) == 0 {\n\t\t\t\terrOutput = err.Error()\n\t\t\t}\n\n\t\t\tlogger.Debugf(\"[InitHWSupport] Codec %s not supported. Error output:\\n%s\", codec, errOutput)\n\t\t} else {\n\t\t\thwCodecSupport = append(hwCodecSupport, codec)\n\t\t}\n\t}\n\n\toutstr := fmt.Sprintf(\"[InitHWSupport] Supported HW codecs [%d]:\\n\", len(hwCodecSupport))\n\tfor _, codec := range hwCodecSupport {\n\t\toutstr += fmt.Sprintf(\"\\t%s - %s\\n\", codec.Name, codec.CodeName)\n\t}\n\tlogger.Info(outstr)\n\n\tf.hwCodecSupportMutex.Lock()\n\tdefer f.hwCodecSupportMutex.Unlock()\n\tf.hwCodecSupport = hwCodecSupport\n}\n\nfunc (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf *models.VideoFile, reqHeight int) bool {\n\tif codec == VideoCodecCopy {\n\t\treturn false\n\t}\n\n\tvar args Args\n\targs = append(args, \"-hide_banner\")\n\targs = args.LogLevel(LogLevelWarning)\n\targs = args.XError()\n\targs = f.hwDeviceInit(args, codec, true)\n\targs = args.Input(vf.Path)\n\targs = args.Duration(1)\n\n\tvideoFilter := f.hwMaxResFilter(codec, vf, reqHeight, true)\n\targs = append(args, CodecInit(codec)...)\n\targs = args.VideoFilter(videoFilter)\n\n\targs = args.Format(\"null\")\n\targs = args.Output(\"-\")\n\n\tcmd := f.Command(ctx, args)\n\n\tvar stderr bytes.Buffer\n\tcmd.Stderr = &stderr\n\n\tif err := cmd.Run(); err != nil {\n\t\terrOutput := stderr.String()\n\n\t\tif len(errOutput) == 0 {\n\t\t\terrOutput = err.Error()\n\t\t}\n\n\t\tlogger.Debugf(\"[InitHWSupport] Full hardware transcode for file %s not supported. Error output:\\n%s\", vf.Basename, errOutput)\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n// Prepend input for hardware encoding only\nfunc (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args {\n\tswitch toCodec {\n\tcase VideoCodecN264,\n\t\tVideoCodecN264H:\n\t\targs = append(args, \"-hwaccel_device\")\n\t\targs = append(args, \"0\")\n\t\tif fullhw {\n\t\t\targs = append(args, \"-threads\")\n\t\t\targs = append(args, \"1\")\n\t\t\targs = append(args, \"-hwaccel\")\n\t\t\targs = append(args, \"cuda\")\n\t\t\targs = append(args, \"-hwaccel_output_format\")\n\t\t\targs = append(args, \"cuda\")\n\t\t}\n\tcase VideoCodecV264,\n\t\tVideoCodecVVP9:\n\t\targs = append(args, \"-vaapi_device\")\n\t\targs = append(args, \"/dev/dri/renderD128\")\n\t\tif fullhw {\n\t\t\targs = append(args, \"-hwaccel\")\n\t\t\targs = append(args, \"vaapi\")\n\t\t\targs = append(args, \"-hwaccel_output_format\")\n\t\t\targs = append(args, \"vaapi\")\n\t\t}\n\tcase VideoCodecI264,\n\t\tVideoCodecI264C,\n\t\tVideoCodecIVP9:\n\t\tif fullhw {\n\t\t\targs = append(args, \"-hwaccel\")\n\t\t\targs = append(args, \"qsv\")\n\t\t\targs = append(args, \"-hwaccel_output_format\")\n\t\t\targs = append(args, \"qsv\")\n\t\t} else {\n\t\t\targs = append(args, \"-init_hw_device\")\n\t\t\targs = append(args, \"qsv=hw\")\n\t\t\targs = append(args, \"-filter_hw_device\")\n\t\t\targs = append(args, \"hw\")\n\t\t}\n\tcase VideoCodecM264:\n\t\tif fullhw {\n\t\t\targs = append(args, \"-hwaccel\")\n\t\t\targs = append(args, \"videotoolbox\")\n\t\t\targs = append(args, \"-hwaccel_output_format\")\n\t\t\targs = append(args, \"videotoolbox_vld\")\n\t\t} else {\n\t\t\targs = append(args, \"-init_hw_device\")\n\t\t\targs = append(args, \"videotoolbox=vt\")\n\t\t}\n\tcase VideoCodecRK264:\n\t\t// Rockchip: always create rkmpp device and make it the filter device, so\n\t\t// scale_rkrga and subsequent hwupload/hwmap operate in the right context.\n\t\targs = append(args, \"-init_hw_device\")\n\t\targs = append(args, \"rkmpp=rk\")\n\t\targs = append(args, \"-filter_hw_device\")\n\t\targs = append(args, \"rk\")\n\t\tif fullhw {\n\t\t\targs = append(args, \"-hwaccel\")\n\t\t\targs = append(args, \"rkmpp\")\n\t\t\targs = append(args, \"-hwaccel_output_format\")\n\t\t\targs = append(args, \"drm_prime\")\n\t\t}\n\t}\n\n\treturn args\n}\n\n// Initialise a video filter for HW encoding\nfunc (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter {\n\tvar videoFilter VideoFilter\n\tswitch toCodec {\n\tcase VideoCodecV264,\n\t\tVideoCodecVVP9:\n\t\tif !fullhw {\n\t\t\tvideoFilter = videoFilter.Append(\"format=nv12\")\n\t\t\tvideoFilter = videoFilter.Append(\"hwupload\")\n\t\t}\n\tcase VideoCodecN264, VideoCodecN264H:\n\t\tif !fullhw {\n\t\t\tvideoFilter = videoFilter.Append(\"format=nv12\")\n\t\t\tvideoFilter = videoFilter.Append(\"hwupload_cuda\")\n\t\t}\n\tcase VideoCodecI264,\n\t\tVideoCodecI264C,\n\t\tVideoCodecIVP9:\n\t\tif !fullhw {\n\t\t\tvideoFilter = videoFilter.Append(\"hwupload=extra_hw_frames=64\")\n\t\t\tvideoFilter = videoFilter.Append(\"format=qsv\")\n\t\t}\n\tcase VideoCodecM264:\n\t\tif !fullhw {\n\t\t\tvideoFilter = videoFilter.Append(\"format=nv12\")\n\t\t\tvideoFilter = videoFilter.Append(\"hwupload\")\n\t\t}\n\tcase VideoCodecRK264:\n\t\t// For Rockchip full-hw, do NOT pre-map to rkrga here. scale_rkrga can\n\t\t// consume DRM_PRIME frames directly when filter_hw_device is set.\n\t\t// For non-fullhw, keep a sane software format.\n\t\tif !fullhw {\n\t\t\tvideoFilter = videoFilter.Append(\"format=nv12\")\n\t\t\tvideoFilter = videoFilter.Append(\"hwupload\")\n\t\t}\n\t}\n\n\treturn videoFilter\n}\n\nvar scaler_re = regexp.MustCompile(`scale=(?P<value>([-\\d]+):([-\\d]+))`)\n\nfunc templateReplaceScale(input string, template string, match []int, vf *models.VideoFile, minusonehack bool) string {\n\tresult := []byte{}\n\n\tif minusonehack {\n\t\t// Parse width and height\n\t\tw, err := strconv.Atoi(input[match[4]:match[5]])\n\t\tif err != nil {\n\t\t\tlogger.Error(\"failed to parse width\")\n\t\t\treturn input\n\t\t}\n\t\th, err := strconv.Atoi(input[match[6]:match[7]])\n\t\tif err != nil {\n\t\t\tlogger.Error(\"failed to parse height\")\n\t\t\treturn input\n\t\t}\n\n\t\t// Calculate ratio\n\t\tratio := float64(vf.Width) / float64(vf.Height)\n\t\tif w < 0 {\n\t\t\tw = int(math.Round(float64(h) * ratio))\n\t\t} else if h < 0 {\n\t\t\th = int(math.Round(float64(w) / ratio))\n\t\t}\n\n\t\t// Fix not divisible by 2 errors\n\t\tif w%2 != 0 {\n\t\t\tw++\n\t\t}\n\t\tif h%2 != 0 {\n\t\t\th++\n\t\t}\n\n\t\ttemplate = strings.ReplaceAll(template, \"$value\", fmt.Sprintf(\"%d:%d\", w, h))\n\t}\n\n\tres := string(scaler_re.ExpandString(result, template, input, match))\n\n\tmatchStart := match[0]\n\tmatchEnd := match[1]\n\n\treturn input[0:matchStart] + res + input[matchEnd:]\n}\n\n// Replace video filter scaling with hardware scaling for full hardware transcoding (also fixes the format)\nfunc (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, vf *models.VideoFile, fullhw bool) VideoFilter {\n\tsargs := string(args)\n\n\tmatch := scaler_re.FindStringSubmatchIndex(sargs)\n\tif match == nil {\n\t\treturn f.hwApplyFullHWFilter(args, codec, fullhw)\n\t}\n\n\treturn f.hwApplyScaleTemplate(sargs, codec, match, vf, fullhw)\n}\n\n// Apply format switching if applicable\nfunc (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter {\n\tswitch codec {\n\tcase VideoCodecN264, VideoCodecN264H:\n\t\tif fullhw && f.version.Gteq(Version{major: 5}) { // Added in FFMpeg 5\n\t\t\targs = args.Append(\"scale_cuda=format=yuv420p\")\n\t\t}\n\tcase VideoCodecV264, VideoCodecVVP9:\n\t\tif fullhw && f.version.Gteq(Version{major: 3, minor: 1}) { // Added in FFMpeg 3.1\n\t\t\targs = args.Append(\"scale_vaapi=format=nv12\")\n\t\t}\n\tcase VideoCodecI264, VideoCodecI264C, VideoCodecIVP9:\n\t\tif fullhw && f.version.Gteq(Version{major: 3, minor: 3}) { // Added in FFMpeg 3.3\n\t\t\targs = args.Append(\"scale_qsv=format=nv12\")\n\t\t}\n\tcase VideoCodecRK264:\n\t\t// Full-hw decode on 10-bit sources often produces DRM_PRIME with sw_pix_fmt=nv15.\n\t\t// h264_rkmpp does NOT accept nv15, so we must force a conversion to nv12\n\t\tif fullhw {\n\t\t\targs = args.Append(\"scale_rkrga=w=iw:h=ih:format=nv12\")\n\t\t}\n\t}\n\n\treturn args\n}\n\n// Switch scaler\nfunc (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []int, vf *models.VideoFile, fullhw bool) VideoFilter {\n\tvar template string\n\n\tswitch codec {\n\tcase VideoCodecN264, VideoCodecN264H:\n\t\ttemplate = \"scale_cuda=$value\"\n\t\tif fullhw && f.version.Gteq(Version{major: 5}) { // Added in FFMpeg 5\n\t\t\ttemplate += \":format=yuv420p\"\n\t\t}\n\tcase VideoCodecV264, VideoCodecVVP9:\n\t\ttemplate = \"scale_vaapi=$value\"\n\t\tif fullhw && f.version.Gteq(Version{major: 3, minor: 1}) { // Added in FFMpeg 3.1\n\t\t\ttemplate += \":format=nv12\"\n\t\t}\n\tcase VideoCodecI264, VideoCodecI264C, VideoCodecIVP9:\n\t\ttemplate = \"scale_qsv=$value\"\n\t\tif fullhw && f.version.Gteq(Version{major: 3, minor: 3}) { // Added in FFMpeg 3.3\n\t\t\ttemplate += \":format=nv12\"\n\t\t}\n\tcase VideoCodecM264:\n\t\ttemplate = \"scale_vt=$value\"\n\tcase VideoCodecRK264:\n\t\t// The original filter chain is a fallback for maximum compatibility:\n\t\t// \"scale_rkrga=$value:format=nv12,hwdownload,format=nv12,hwupload\"\n\t\t// It avoids hwmap(rkrga→rkmpp) failures (-38/-12) seen on some builds\n\t\t// by downloading the scaled frame to system RAM and re-uploading it.\n\t\t// The filter chain below uses a zero-copy approach, passing the hardware-scaled\n\t\t// frame directly to the encoder. This is more efficient but may be less stable.\n\t\ttemplate = \"scale_rkrga=$value:format=nv12\"\n\tdefault:\n\t\treturn VideoFilter(sargs)\n\t}\n\n\t// BUG: [scale_qsv]: Size values less than -1 are not acceptable.\n\tisIntel := codec == VideoCodecI264 || codec == VideoCodecI264C || codec == VideoCodecIVP9\n\t// BUG: scale_vt doesn't call ff_scale_adjust_dimensions, thus cant accept negative size values\n\tisApple := codec == VideoCodecM264\n\t// Rockchip's scale_rkrga supports -1/-2; don't apply minus-one hack here.\n\treturn VideoFilter(templateReplaceScale(sargs, template, match, vf, isIntel || isApple))\n}\n\n// Returns the max resolution for a given codec, or a default\nfunc (f *FFMpeg) hwCodecMaxRes(codec VideoCodec) (int, int) {\n\tswitch codec {\n\tcase VideoCodecRK264:\n\t\treturn 8192, 8192\n\tcase VideoCodecN264,\n\t\tVideoCodecN264H,\n\t\tVideoCodecI264,\n\t\tVideoCodecI264C:\n\t\treturn 4096, 4096\n\t}\n\n\treturn 0, 0\n}\n\n// Return a maxres filter\nfunc (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHeight int, fullhw bool) VideoFilter {\n\tif vf.Width == 0 || vf.Height == 0 {\n\t\treturn \"\"\n\t}\n\tvideoFilter := f.hwFilterInit(toCodec, fullhw)\n\tmaxWidth, maxHeight := f.hwCodecMaxRes(toCodec)\n\tvideoFilter = videoFilter.ScaleMaxLM(vf.Width, vf.Height, reqHeight, maxWidth, maxHeight)\n\treturn f.hwCodecFilter(videoFilter, toCodec, vf, fullhw)\n}\n\n// Return if a hardware accelerated for HLS is available\nfunc (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {\n\tfor _, element := range f.getHWCodecSupport() {\n\t\tswitch element {\n\t\tcase VideoCodecN264,\n\t\t\tVideoCodecN264H,\n\t\t\tVideoCodecI264,\n\t\t\tVideoCodecI264C,\n\t\t\tVideoCodecV264,\n\t\t\tVideoCodecR264,\n\t\t\tVideoCodecM264, // Note that the Apple encoder sucks at startup, thus HLS quality is crap\n\t\t\tVideoCodecRK264:\n\t\t\treturn &element\n\t\t}\n\t}\n\treturn nil\n}\n\n// Return if a hardware accelerated codec for MP4 is available\nfunc (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {\n\tfor _, element := range f.getHWCodecSupport() {\n\t\tswitch element {\n\t\tcase VideoCodecN264,\n\t\t\tVideoCodecN264H,\n\t\t\tVideoCodecI264,\n\t\t\tVideoCodecI264C,\n\t\t\tVideoCodecM264,\n\t\t\tVideoCodecRK264:\n\t\t\treturn &element\n\t\t}\n\t}\n\treturn nil\n}\n\n// Return if a hardware accelerated codec for WebM is available\nfunc (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec {\n\tfor _, element := range f.getHWCodecSupport() {\n\t\tswitch element {\n\t\tcase VideoCodecIVP9,\n\t\t\tVideoCodecVVP9:\n\t\t\treturn &element\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/ffmpeg/container.go",
    "content": "package ffmpeg\n\ntype Container string\ntype ProbeAudioCodec string\n\nconst (\n\tMp4      Container = \"mp4\"\n\tM4v      Container = \"m4v\"\n\tMov      Container = \"mov\"\n\tWmv      Container = \"wmv\"\n\tWebm     Container = \"webm\"\n\tMatroska Container = \"matroska\"\n\tAvi      Container = \"avi\"\n\tFlv      Container = \"flv\"\n\tMpegts   Container = \"mpegts\"\n\n\tAac                ProbeAudioCodec = \"aac\"\n\tMp3                ProbeAudioCodec = \"mp3\"\n\tOpus               ProbeAudioCodec = \"opus\"\n\tVorbis             ProbeAudioCodec = \"vorbis\"\n\tMissingUnsupported ProbeAudioCodec = \"\"\n\n\tMp4Ffmpeg      string = \"mov,mp4,m4a,3gp,3g2,mj2\" // browsers support all of them\n\tM4vFfmpeg      string = \"mov,mp4,m4a,3gp,3g2,mj2\" // so we don't care that ffmpeg\n\tMovFfmpeg      string = \"mov,mp4,m4a,3gp,3g2,mj2\" // can't differentiate between them\n\tWmvFfmpeg      string = \"asf\"\n\tWebmFfmpeg     string = \"matroska,webm\"\n\tMatroskaFfmpeg string = \"matroska,webm\"\n\tAviFfmpeg      string = \"avi\"\n\tFlvFfmpeg      string = \"flv\"\n\tMpegtsFfmpeg   string = \"mpegts\"\n\tH264           string = \"h264\"\n\tH265           string = \"h265\" // found in rare cases from a faulty encoder\n\tHevc           string = \"hevc\"\n\tVp8            string = \"vp8\"\n\tVp9            string = \"vp9\"\n\tMkv            string = \"mkv\" // only used from the browser to indicate mkv support\n\tHls            string = \"hls\" // only used from the browser to indicate hls support\n)\n\nvar ffprobeToContainer = map[string]Container{\n\tMp4Ffmpeg:      Mp4,\n\tWmvFfmpeg:      Wmv,\n\tAviFfmpeg:      Avi,\n\tFlvFfmpeg:      Flv,\n\tMpegtsFfmpeg:   Mpegts,\n\tMatroskaFfmpeg: Matroska,\n}\n\nfunc MatchContainer(format string, filePath string) (Container, error) { // match ffprobe string to our Container\n\tcontainer := ffprobeToContainer[format]\n\tif container == Matroska {\n\t\treturn magicContainer(filePath) // use magic number instead of ffprobe for matroska,webm\n\t}\n\tif container == \"\" { // if format is not in our Container list leave it as ffprobes reported format_name\n\t\tcontainer = Container(format)\n\t}\n\treturn container, nil\n}\n"
  },
  {
    "path": "pkg/ffmpeg/downloader.go",
    "content": "package ffmpeg\n\nimport (\n\t\"runtime\"\n)\n\nfunc GetFFmpegURL() []string {\n\tvar urls []string\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\turls = []string{\"https://evermeet.cx/ffmpeg/getrelease/zip\", \"https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip\"}\n\tcase \"linux\":\n\t\tswitch runtime.GOARCH {\n\t\tcase \"amd64\":\n\t\t\turls = []string{\"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-64.zip\", \"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-64.zip\"}\n\t\tcase \"arm\":\n\t\t\turls = []string{\"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-armhf-32.zip\", \"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-armhf-32.zip\"}\n\t\tcase \"arm64\":\n\t\t\turls = []string{\"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-arm-64.zip\", \"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-arm-64.zip\"}\n\t\t}\n\tcase \"windows\":\n\t\turls = []string{\"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip\"}\n\tdefault:\n\t\turls = []string{\"\"}\n\t}\n\treturn urls\n}\n\nfunc getFFMpegFilename() string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn \"ffmpeg.exe\"\n\t}\n\treturn \"ffmpeg\"\n}\n\nfunc getFFProbeFilename() string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn \"ffprobe.exe\"\n\t}\n\treturn \"ffprobe\"\n}\n"
  },
  {
    "path": "pkg/ffmpeg/ffmpeg.go",
    "content": "// Package ffmpeg provides a wrapper around the ffmpeg and ffprobe executables.\npackage ffmpeg\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\n\tstashExec \"github.com/stashapp/stash/pkg/exec\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nfunc ffmpegHelp(ffmpegPath string) (string, error) {\n\tcmd := stashExec.Command(ffmpegPath, \"-h\")\n\tbytes, err := cmd.CombinedOutput()\n\toutput := string(bytes)\n\tif err != nil {\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) {\n\t\t\treturn \"\", fmt.Errorf(\"error running ffmpeg: %v\", output)\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"error running ffmpeg: %v\", err)\n\t}\n\n\treturn output, nil\n}\n\nfunc ValidateFFMpeg(ffmpegPath string) error {\n\t_, err := ffmpegHelp(ffmpegPath)\n\treturn err\n}\n\nfunc ValidateFFMpegCodecSupport(ffmpegPath string) error {\n\toutput, err := ffmpegHelp(ffmpegPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar missingSupport []string\n\n\tif !strings.Contains(output, \"--enable-libopus\") {\n\t\tmissingSupport = append(missingSupport, \"libopus\")\n\t}\n\tif !strings.Contains(output, \"--enable-libvpx\") {\n\t\tmissingSupport = append(missingSupport, \"libvpx\")\n\t}\n\tif !strings.Contains(output, \"--enable-libx264\") {\n\t\tmissingSupport = append(missingSupport, \"libx264\")\n\t}\n\tif !strings.Contains(output, \"--enable-libx265\") {\n\t\tmissingSupport = append(missingSupport, \"libx265\")\n\t}\n\tif !strings.Contains(output, \"--enable-libwebp\") {\n\t\tmissingSupport = append(missingSupport, \"libwebp\")\n\t}\n\n\tif len(missingSupport) > 0 {\n\t\treturn fmt.Errorf(\"ffmpeg missing codec support: %v\", missingSupport)\n\t}\n\n\treturn nil\n}\n\nfunc LookPathFFMpeg() string {\n\tret, _ := exec.LookPath(getFFMpegFilename())\n\n\tif ret != \"\" {\n\t\t// ensure ffmpeg has the correct flags\n\t\tif err := ValidateFFMpeg(ret); err != nil {\n\t\t\tlogger.Warnf(\"ffmpeg found (%s), could not be executed: %v\", ret, err)\n\t\t\tret = \"\"\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc FindFFMpeg(path string) string {\n\tret := fsutil.FindInPaths([]string{path}, getFFMpegFilename())\n\n\tif ret != \"\" {\n\t\t// ensure ffmpeg has the correct flags\n\t\tif err := ValidateFFMpeg(ret); err != nil {\n\t\t\tlogger.Warnf(\"ffmpeg found (%s), could not be executed: %v\", ret, err)\n\t\t\tret = \"\"\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// ResolveFFMpeg attempts to resolve the path to the ffmpeg executable.\n// It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path.\n// It will prefer an ffmpeg binary that has the required codec support.\n// Returns an empty string if a valid ffmpeg cannot be found.\nfunc ResolveFFMpeg(path string, fallbackPath string) string {\n\tvar ret string\n\t// look in the provided path first\n\tpathFound := FindFFMpeg(path)\n\tif pathFound != \"\" {\n\t\terr := ValidateFFMpegCodecSupport(pathFound)\n\t\tif err == nil {\n\t\t\treturn pathFound\n\t\t}\n\n\t\tlogger.Warnf(\"ffmpeg found (%s), but it is missing required flags: %v\", pathFound, err)\n\t\tret = pathFound\n\t}\n\n\t// then resolve from the environment\n\tenvFound := LookPathFFMpeg()\n\tif envFound != \"\" {\n\t\terr := ValidateFFMpegCodecSupport(envFound)\n\t\tif err == nil {\n\t\t\treturn envFound\n\t\t}\n\n\t\tlogger.Warnf(\"ffmpeg found (%s), but it is missing required flags: %v\", envFound, err)\n\t\tif ret == \"\" {\n\t\t\tret = envFound\n\t\t}\n\t}\n\n\t// finally, look in the fallback path\n\tfallbackFound := FindFFMpeg(fallbackPath)\n\tif fallbackFound != \"\" {\n\t\terr := ValidateFFMpegCodecSupport(fallbackFound)\n\t\tif err == nil {\n\t\t\treturn fallbackFound\n\t\t}\n\n\t\tlogger.Warnf(\"ffmpeg found (%s), but it is missing required flags: %v\", fallbackFound, err)\n\t\tif ret == \"\" {\n\t\t\tret = fallbackFound\n\t\t}\n\t}\n\n\treturn ret\n}\n\nvar version_re = regexp.MustCompile(`ffmpeg version n?((\\d+)\\.(\\d+)(?:\\.(\\d+))?)`)\n\nfunc (f *FFMpeg) getVersion() error {\n\tvar args Args\n\targs = append(args, \"-version\")\n\tcmd := f.Command(context.Background(), args)\n\n\tvar stdout bytes.Buffer\n\tcmd.Stdout = &stdout\n\n\tvar err error\n\tif err = cmd.Run(); err != nil {\n\t\treturn err\n\t}\n\n\tstdoutStr := stdout.String()\n\tmatch := version_re.FindStringSubmatchIndex(stdoutStr)\n\tif match == nil {\n\t\treturn errors.New(\"version string malformed\")\n\t}\n\n\tmajorS := stdoutStr[match[4]:match[5]]\n\tminorS := stdoutStr[match[6]:match[7]]\n\n\t// patch is optional\n\tvar patchS string\n\tif match[8] != -1 && match[9] != -1 {\n\t\tpatchS = stdoutStr[match[8]:match[9]]\n\t}\n\n\tif i, err := strconv.Atoi(majorS); err == nil {\n\t\tf.version.major = i\n\t}\n\tif i, err := strconv.Atoi(minorS); err == nil {\n\t\tf.version.minor = i\n\t}\n\tif i, err := strconv.Atoi(patchS); err == nil {\n\t\tf.version.patch = i\n\t}\n\tlogger.Debugf(\"FFMpeg version %s detected\", f.version.String())\n\n\treturn nil\n}\n\n// FFMpeg version params\ntype Version struct {\n\tmajor int\n\tminor int\n\tpatch int\n}\n\n// Gteq returns true if the version is greater than or equal to the other version.\nfunc (v Version) Gteq(other Version) bool {\n\tif v.major > other.major {\n\t\treturn true\n\t}\n\tif v.major == other.major && v.minor > other.minor {\n\t\treturn true\n\t}\n\tif v.major == other.major && v.minor == other.minor && v.patch >= other.patch {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (v Version) String() string {\n\treturn fmt.Sprintf(\"%d.%d.%d\", v.major, v.minor, v.patch)\n}\n\n// FFMpeg provides an interface to ffmpeg.\ntype FFMpeg struct {\n\tffmpeg              string\n\tversion             Version\n\thwCodecSupport      []VideoCodec\n\thwCodecSupportMutex sync.RWMutex\n}\n\n// Creates a new FFMpeg encoder\nfunc NewEncoder(ffmpegPath string) *FFMpeg {\n\tret := &FFMpeg{\n\t\tffmpeg: ffmpegPath,\n\t}\n\tif err := ret.getVersion(); err != nil {\n\t\tlogger.Warnf(\"FFMpeg version not detected %v\", err)\n\t}\n\n\treturn ret\n}\n\n// Returns an exec.Cmd that can be used to run ffmpeg using args.\nfunc (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {\n\treturn stashExec.CommandContext(ctx, string(f.ffmpeg), args...)\n}\n\nfunc (f *FFMpeg) Path() string {\n\treturn f.ffmpeg\n}\n\nfunc (f *FFMpeg) getHWCodecSupport() []VideoCodec {\n\tf.hwCodecSupportMutex.RLock()\n\tdefer f.hwCodecSupportMutex.RUnlock()\n\treturn f.hwCodecSupport\n}\n"
  },
  {
    "path": "pkg/ffmpeg/ffmpeg_test.go",
    "content": "// Package ffmpeg provides a wrapper around the ffmpeg and ffprobe executables.\npackage ffmpeg\n\nimport \"testing\"\n\nfunc TestFFMpegVersion_GreaterThan(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tthis  Version\n\t\tother Version\n\t\twant  bool\n\t}{\n\t\t{\n\t\t\t\"major greater, minor equal, patch equal\",\n\t\t\tVersion{2, 0, 0},\n\t\t\tVersion{1, 0, 0},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"major greater, minor less, patch less\",\n\t\t\tVersion{2, 1, 1},\n\t\t\tVersion{1, 0, 0},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"major equal, minor greater, patch equal\",\n\t\t\tVersion{1, 1, 0},\n\t\t\tVersion{1, 0, 0},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"major equal, minor equal, patch greater\",\n\t\t\tVersion{1, 0, 1},\n\t\t\tVersion{1, 0, 0},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"major equal, minor equal, patch equal\",\n\t\t\tVersion{1, 0, 0},\n\t\t\tVersion{1, 0, 0},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"major less, minor equal, patch equal\",\n\t\t\tVersion{1, 0, 0},\n\t\t\tVersion{2, 0, 0},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"major equal, minor less, patch equal\",\n\t\t\tVersion{1, 0, 0},\n\t\t\tVersion{1, 1, 0},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"major equal, minor equal, patch less\",\n\t\t\tVersion{1, 0, 0},\n\t\t\tVersion{1, 0, 1},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"major less, minor less, patch less\",\n\t\t\tVersion{1, 0, 0},\n\t\t\tVersion{2, 1, 1},\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := tt.this.Gteq(tt.other); got != tt.want {\n\t\t\t\tt.Errorf(\"FFMpegVersion.GreaterThan() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/ffmpeg/ffprobe.go",
    "content": "package ffmpeg\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"os\"\n\t\"os/exec\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\tstashExec \"github.com/stashapp/stash/pkg/exec\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst minimumFFProbeVersion = 5\n\nfunc ValidateFFProbe(ffprobePath string) error {\n\tcmd := stashExec.Command(ffprobePath, \"-h\")\n\tbytes, err := cmd.CombinedOutput()\n\toutput := string(bytes)\n\tif err != nil {\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) {\n\t\t\treturn fmt.Errorf(\"error running ffprobe: %v\", output)\n\t\t}\n\n\t\treturn fmt.Errorf(\"error running ffprobe: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc LookPathFFProbe() string {\n\tret, _ := exec.LookPath(getFFProbeFilename())\n\n\tif ret != \"\" {\n\t\tif err := ValidateFFProbe(ret); err != nil {\n\t\t\tlogger.Warnf(\"ffprobe found in PATH (%s), but it is missing required flags: %v\", ret, err)\n\t\t\tret = \"\"\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc FindFFProbe(path string) string {\n\tret := fsutil.FindInPaths([]string{path}, getFFProbeFilename())\n\n\tif ret != \"\" {\n\t\tif err := ValidateFFProbe(ret); err != nil {\n\t\t\tlogger.Warnf(\"ffprobe found (%s), but it is missing required flags: %v\", ret, err)\n\t\t\tret = \"\"\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// ResolveFFMpeg attempts to resolve the path to the ffmpeg executable.\n// It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path.\n// Returns an empty string if a valid ffmpeg cannot be found.\nfunc ResolveFFProbe(path string, fallbackPath string) string {\n\t// look in the provided path first\n\tret := FindFFProbe(path)\n\tif ret != \"\" {\n\t\treturn ret\n\t}\n\n\t// then resolve from the environment\n\tret = LookPathFFProbe()\n\tif ret != \"\" {\n\t\treturn ret\n\t}\n\n\t// finally, look in the fallback path\n\tret = FindFFProbe(fallbackPath)\n\treturn ret\n}\n\n// VideoFile represents the ffprobe output for a video file.\ntype VideoFile struct {\n\tJSON        FFProbeJSON\n\tAudioStream *FFProbeStream\n\tVideoStream *FFProbeStream\n\n\tPath      string\n\tTitle     string\n\tComment   string\n\tContainer string\n\t// FileDuration is the declared (meta-data) duration of the *file*.\n\t// In most cases (sprites, previews, etc.) we actually care about the duration of the video stream specifically,\n\t// because those two can differ slightly (e.g. audio stream longer than the video stream, making the whole file\n\t// longer).\n\tFileDuration        float64\n\tVideoStreamDuration float64\n\tStartTime           float64\n\tBitrate             int64\n\tSize                int64\n\tCreationTime        time.Time\n\n\tVideoCodec   string\n\tVideoBitrate int64\n\tWidth        int\n\tHeight       int\n\tFrameRate    float64\n\tRotation     int64\n\tFrameCount   int64\n\n\tAudioCodec string\n}\n\n// TranscodeScale calculates the dimension scaling for a transcode, where maxSize is the maximum size of the longest dimension of the input video.\n// If no scaling is required, then returns 0, 0.\n// Returns -2 for the dimension that will scale to maintain aspect ratio.\nfunc (v *VideoFile) TranscodeScale(maxSize int) (int, int) {\n\t// get the smaller dimension of the video file\n\tvideoSize := v.Height\n\tif v.Width < videoSize {\n\t\tvideoSize = v.Width\n\t}\n\n\t// if our streaming resolution is larger than the video dimension\n\t// or we are streaming the original resolution, then just set the\n\t// input width\n\tif maxSize >= videoSize || maxSize == 0 {\n\t\treturn 0, 0\n\t}\n\n\t// we're setting either the width or height\n\t// we'll set the smaller dimesion\n\tif v.Width > v.Height {\n\t\t// set the height\n\t\treturn -2, maxSize\n\t}\n\n\treturn maxSize, -2\n}\n\n// FFProbe provides an interface to the ffprobe executable.\ntype FFProbe struct {\n\tpath    string\n\tversion Version\n}\n\nfunc (f *FFProbe) Path() string {\n\treturn f.path\n}\n\nvar ffprobeVersionRE = regexp.MustCompile(`ffprobe version n?((\\d+)\\.(\\d+)(?:\\.(\\d+))?)`)\n\nfunc (f *FFProbe) getVersion() error {\n\tvar args []string\n\targs = append(args, \"-version\")\n\tcmd := stashExec.Command(f.path, args...)\n\n\tvar stdout bytes.Buffer\n\tcmd.Stdout = &stdout\n\n\tvar err error\n\tif err = cmd.Run(); err != nil {\n\t\treturn err\n\t}\n\n\tstdoutStr := stdout.String()\n\tmatch := ffprobeVersionRE.FindStringSubmatchIndex(stdoutStr)\n\tif match == nil {\n\t\treturn errors.New(\"version string malformed\")\n\t}\n\n\tmajorS := stdoutStr[match[4]:match[5]]\n\tminorS := stdoutStr[match[6]:match[7]]\n\n\t// patch is optional\n\tvar patchS string\n\tif match[8] != -1 && match[9] != -1 {\n\t\tpatchS = stdoutStr[match[8]:match[9]]\n\t}\n\n\tif i, err := strconv.Atoi(majorS); err == nil {\n\t\tf.version.major = i\n\t}\n\tif i, err := strconv.Atoi(minorS); err == nil {\n\t\tf.version.minor = i\n\t}\n\tif i, err := strconv.Atoi(patchS); err == nil {\n\t\tf.version.patch = i\n\t}\n\tlogger.Debugf(\"FFProbe version %s detected\", f.version.String())\n\n\treturn nil\n}\n\n// Creates a new FFProbe instance.\nfunc NewFFProbe(path string) *FFProbe {\n\tret := &FFProbe{\n\t\tpath: path,\n\t}\n\tif err := ret.getVersion(); err != nil {\n\t\tlogger.Warnf(\"FFProbe version not detected %v\", err)\n\t}\n\n\tif ret.version.major != 0 && ret.version.major < minimumFFProbeVersion {\n\t\tlogger.Warnf(\"FFProbe version %d.%d.%d detected, but %d.x or later is required\", ret.version.major, ret.version.minor, ret.version.patch, minimumFFProbeVersion)\n\t}\n\n\treturn ret\n}\n\n// NewVideoFile runs ffprobe on the given path and returns a VideoFile.\nfunc (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {\n\targs := []string{\n\t\t\"-v\",\n\t\t\"quiet\",\n\t\t\"-print_format\", \"json\",\n\t\t\"-show_format\",\n\t\t\"-show_streams\",\n\t\t\"-show_error\",\n\t}\n\n\t// show_entries stream_side_data=rotation requires 5.x or later ffprobe\n\tif f.version.major >= 5 {\n\t\targs = append(args, \"-show_entries\", \"stream_side_data=rotation\")\n\t}\n\n\targs = append(args, videoPath)\n\n\tcmd := stashExec.Command(f.path, args...)\n\tout, err := cmd.Output()\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"FFProbe encountered an error with <%s>.\\nError JSON:\\n%s\\nError: %s\", videoPath, string(out), err.Error())\n\t}\n\n\tprobeJSON := &FFProbeJSON{}\n\tif err := json.Unmarshal(out, probeJSON); err != nil {\n\t\treturn nil, fmt.Errorf(\"error unmarshalling video data for <%s>: %s\", videoPath, err.Error())\n\t}\n\n\treturn parse(videoPath, probeJSON)\n}\n\n// GetReadFrameCount counts the actual frames of the video file.\n// Used when the frame count is missing or incorrect.\nfunc (f *FFProbe) GetReadFrameCount(path string) (int64, error) {\n\targs := []string{\"-v\", \"quiet\", \"-print_format\", \"json\", \"-count_frames\", \"-show_format\", \"-show_streams\", \"-show_error\", path}\n\tout, err := stashExec.Command(f.path, args...).Output()\n\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"FFProbe encountered an error with <%s>.\\nError JSON:\\n%s\\nError: %s\", path, string(out), err.Error())\n\t}\n\n\tprobeJSON := &FFProbeJSON{}\n\tif err := json.Unmarshal(out, probeJSON); err != nil {\n\t\treturn 0, fmt.Errorf(\"error unmarshalling video data for <%s>: %s\", path, err.Error())\n\t}\n\n\tfc, err := parse(path, probeJSON)\n\treturn fc.FrameCount, err\n}\n\nfunc parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {\n\tif probeJSON == nil {\n\t\treturn nil, fmt.Errorf(\"failed to get ffprobe json for <%s>\", filePath)\n\t}\n\n\tresult := &VideoFile{}\n\tresult.JSON = *probeJSON\n\n\tif result.JSON.Error.Code != 0 {\n\t\treturn nil, fmt.Errorf(\"ffprobe error code %d: %s\", result.JSON.Error.Code, result.JSON.Error.String)\n\t}\n\n\tresult.Path = filePath\n\tresult.Title = probeJSON.Format.Tags.Title\n\n\tresult.Comment = probeJSON.Format.Tags.Comment\n\tresult.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)\n\n\tresult.Container = probeJSON.Format.FormatName\n\tduration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)\n\tresult.FileDuration = math.Round(duration*100) / 100\n\tfileStat, err := os.Stat(filePath)\n\tif err != nil {\n\t\tstatErr := fmt.Errorf(\"error statting file <%s>: %w\", filePath, err)\n\t\tlogger.Errorf(\"%v\", statErr)\n\t\treturn nil, statErr\n\t}\n\tresult.Size = fileStat.Size()\n\tresult.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64)\n\tresult.CreationTime = probeJSON.Format.Tags.CreationTime.Time\n\n\taudioStream := result.getAudioStream()\n\tif audioStream != nil {\n\t\tresult.AudioCodec = audioStream.CodecName\n\t\tresult.AudioStream = audioStream\n\t}\n\n\tvideoStream := result.getVideoStream()\n\tif videoStream != nil {\n\t\tresult.VideoStream = videoStream\n\t\tresult.VideoCodec = videoStream.CodecName\n\t\tresult.FrameCount, _ = strconv.ParseInt(videoStream.NbFrames, 10, 64)\n\t\tif videoStream.NbReadFrames != \"\" { // if ffprobe counted the frames use that instead\n\t\t\tfc, _ := strconv.ParseInt(videoStream.NbReadFrames, 10, 64)\n\t\t\tif fc > 0 {\n\t\t\t\tresult.FrameCount, _ = strconv.ParseInt(videoStream.NbReadFrames, 10, 64)\n\t\t\t} else {\n\t\t\t\tlogger.Debugf(\"[ffprobe] <%s> invalid Read Frames count\", videoStream.NbReadFrames)\n\t\t\t}\n\t\t}\n\t\tresult.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64)\n\t\tvar framerate float64\n\t\tif strings.Contains(videoStream.AvgFrameRate, \"/\") {\n\t\t\tframeRateSplit := strings.Split(videoStream.AvgFrameRate, \"/\")\n\t\t\tnumerator, _ := strconv.ParseFloat(frameRateSplit[0], 64)\n\t\t\tdenominator, _ := strconv.ParseFloat(frameRateSplit[1], 64)\n\t\t\tframerate = numerator / denominator\n\t\t} else {\n\t\t\tframerate, _ = strconv.ParseFloat(videoStream.AvgFrameRate, 64)\n\t\t}\n\t\tif math.IsNaN(framerate) {\n\t\t\tframerate = 0\n\t\t}\n\t\tresult.FrameRate = math.Round(framerate*100) / 100\n\t\tresult.Width = videoStream.Width\n\t\tresult.Height = videoStream.Height\n\n\t\tif isRotated(videoStream) {\n\t\t\tresult.Width = videoStream.Height\n\t\t\tresult.Height = videoStream.Width\n\t\t}\n\n\t\tresult.VideoStreamDuration, err = strconv.ParseFloat(videoStream.Duration, 64)\n\t\tif err != nil {\n\t\t\t// Revert to the historical behaviour, which is still correct in the vast majority of cases.\n\t\t\tresult.VideoStreamDuration = result.FileDuration\n\t\t}\n\t}\n\n\treturn result, nil\n}\n\nfunc isRotated(s *FFProbeStream) bool {\n\trotate, _ := strconv.ParseInt(s.Tags.Rotate, 10, 64)\n\tif rotate != 180 && rotate != 0 {\n\t\treturn true\n\t}\n\n\tfor _, sd := range s.SideDataList {\n\t\tr := sd.Rotation\n\t\tif r < 0 {\n\t\t\tr = -r\n\t\t}\n\t\tif r != 0 && r != 180 {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (v *VideoFile) getAudioStream() *FFProbeStream {\n\tindex := v.getStreamIndex(\"audio\", v.JSON)\n\tif index != -1 {\n\t\treturn &v.JSON.Streams[index]\n\t}\n\treturn nil\n}\n\nfunc (v *VideoFile) getVideoStream() *FFProbeStream {\n\tindex := v.getStreamIndex(\"video\", v.JSON)\n\tif index != -1 {\n\t\treturn &v.JSON.Streams[index]\n\t}\n\treturn nil\n}\n\nfunc (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {\n\tret := -1\n\tfor i, stream := range probeJSON.Streams {\n\t\t// skip cover art/thumbnails\n\t\tif stream.CodecType == fileType && stream.Disposition.AttachedPic == 0 {\n\t\t\t// prefer default stream\n\t\t\tif stream.Disposition.Default == 1 {\n\t\t\t\treturn i\n\t\t\t}\n\n\t\t\t// backwards compatible behaviour - fallback to first matching stream\n\t\t\tif ret == -1 {\n\t\t\t\tret = i\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/ffmpeg/filter.go",
    "content": "package ffmpeg\n\nimport (\n\t\"fmt\"\n)\n\n// VideoFilter represents video filter parameters to be passed to ffmpeg.\ntype VideoFilter string\n\n// Args converts the video filter parameters to a slice of arguments to be passed to ffmpeg.\n// Returns an empty slice if the filter is empty.\nfunc (f VideoFilter) Args() []string {\n\tif f == \"\" {\n\t\treturn nil\n\t}\n\n\treturn []string{\"-vf\", string(f)}\n}\n\n// ScaleWidth returns a VideoFilter scaling the width to the given width, maintaining aspect ratio and a height as a multiple of 2.\nfunc (f VideoFilter) ScaleWidth(w int) VideoFilter {\n\treturn f.ScaleDimensions(w, -2)\n}\n\nfunc (f VideoFilter) ScaleHeight(h int) VideoFilter {\n\treturn f.ScaleDimensions(-2, h)\n}\n\n// ScaleDimesions returns a VideoFilter scaling using w and h. Use -n to maintain aspect ratio and maintain as multiple of n.\nfunc (f VideoFilter) ScaleDimensions(w, h int) VideoFilter {\n\treturn f.Append(fmt.Sprintf(\"scale=%v:%v\", w, h))\n}\n\n// ScaleMaxSize returns a VideoFilter scaling to maxDimensions, maintaining aspect ratio using force_original_aspect_ratio=decrease.\nfunc (f VideoFilter) ScaleMaxSize(maxDimensions int) VideoFilter {\n\treturn f.Append(fmt.Sprintf(\"scale=%v:%v:force_original_aspect_ratio=decrease\", maxDimensions, maxDimensions))\n}\n\n// ScaleMax returns a VideoFilter scaling to maxSize. It will scale width if it is larger than height, otherwise it will scale height.\nfunc (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter {\n\t// get the smaller dimension of the input\n\tvideoSize := inputHeight\n\tif inputWidth < videoSize {\n\t\tvideoSize = inputWidth\n\t}\n\n\t// if maxSize is larger than the video dimension, then no-op\n\tif maxSize >= videoSize || maxSize == 0 {\n\t\treturn f\n\t}\n\n\t// we're setting either the width or height\n\t// we'll set the smaller dimesion\n\tif inputWidth > inputHeight {\n\t\t// set the height\n\t\treturn f.ScaleDimensions(-2, maxSize)\n\t}\n\n\treturn f.ScaleDimensions(maxSize, -2)\n}\n\n// ScaleMaxLM scales an image to fit within specified maximum dimensions while maintaining its aspect ratio.\nfunc (f VideoFilter) ScaleMaxLM(width int, height int, reqHeight int, maxWidth int, maxHeight int) VideoFilter {\n\tif maxWidth == 0 || maxHeight == 0 {\n\t\treturn f.ScaleMax(width, height, reqHeight)\n\t}\n\n\taspectRatio := float64(width) / float64(height)\n\tdesiredHeight := reqHeight\n\tif desiredHeight == 0 {\n\t\tdesiredHeight = height\n\t}\n\tdesiredWidth := int(float64(desiredHeight) * aspectRatio)\n\n\tif desiredHeight <= maxHeight && desiredWidth <= maxWidth {\n\t\treturn f.ScaleMax(width, height, reqHeight)\n\t}\n\n\tif float64(desiredHeight-maxHeight) > float64(desiredWidth-maxWidth) {\n\t\treturn f.ScaleDimensions(-2, maxHeight)\n\t} else {\n\t\treturn f.ScaleDimensions(maxWidth, -2)\n\t}\n}\n\n// Fps returns a VideoFilter setting the frames per second.\nfunc (f VideoFilter) Fps(fps int) VideoFilter {\n\treturn f.Append(fmt.Sprintf(\"fps=%v\", fps))\n}\n\n// Select returns a VideoFilter to select the given frame.\nfunc (f VideoFilter) Select(frame int) VideoFilter {\n\treturn f.Append(fmt.Sprintf(\"select=eq(n\\\\,%d)\", frame))\n}\n\n// Append returns a VideoFilter appending the given string.\nfunc (f VideoFilter) Append(s string) VideoFilter {\n\t// if filter is empty, then just set\n\tif f == \"\" {\n\t\treturn VideoFilter(s)\n\t}\n\n\treturn VideoFilter(fmt.Sprintf(\"%s,%s\", f, s))\n}\n"
  },
  {
    "path": "pkg/ffmpeg/format.go",
    "content": "package ffmpeg\n\n// Format represents the input/output format for ffmpeg.\ntype Format string\n\n// Args converts the Format to a slice of arguments to be passed to ffmpeg.\nfunc (f Format) Args() []string {\n\tif f == \"\" {\n\t\treturn nil\n\t}\n\n\treturn []string{\"-f\", string(f)}\n}\n\nvar (\n\tFormatConcat   Format = \"concat\"\n\tFormatImage2   Format = \"image2\"\n\tFormatRawVideo Format = \"rawvideo\"\n\tFormatMpegTS   Format = \"mpegts\"\n\tFormatMP4      Format = \"mp4\"\n\tFormatWebm     Format = \"webm\"\n\tFormatMatroska Format = \"matroska\"\n)\n\n// ImageFormat represents the input format for an image for ffmpeg.\ntype ImageFormat string\n\n// Args converts the ImageFormat to a slice of arguments to be passed to ffmpeg.\nfunc (f ImageFormat) Args() []string {\n\tif f == \"\" {\n\t\treturn nil\n\t}\n\n\treturn []string{\"-f\", string(f)}\n}\n\nvar (\n\tImageFormatJpeg ImageFormat = \"mjpeg\"\n\tImageFormatPng  ImageFormat = \"png_pipe\"\n\tImageFormatWebp ImageFormat = \"webp_pipe\"\n\n\tImageFormatImage2Pipe ImageFormat = \"image2pipe\"\n)\n"
  },
  {
    "path": "pkg/ffmpeg/frame_rate.go",
    "content": "package ffmpeg\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"math\"\n\t\"regexp\"\n\t\"strconv\"\n)\n\n// FrameInfo contains the number of frames and the frame rate for a video file.\ntype FrameInfo struct {\n\tFrameRate      float64\n\tNumberOfFrames int\n}\n\n// CalculateFrameRate calculates the frame rate and number of frames of the video file.\n// Used where the frame rate or NbFrames is missing or invalid in the ffprobe output.\nfunc (f *FFMpeg) CalculateFrameRate(ctx context.Context, v *VideoFile) (*FrameInfo, error) {\n\tvar args Args\n\targs = append(args, \"-nostats\")\n\targs = args.Input(v.Path).\n\t\tVideoCodec(VideoCodecCopy).\n\t\tFormat(FormatRawVideo).\n\t\tOverwrite().\n\t\tNullOutput()\n\n\tcommand := f.Command(ctx, args)\n\tvar stdErrBuffer bytes.Buffer\n\tcommand.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout\n\terr := command.Run()\n\tif err == nil {\n\t\tvar ret FrameInfo\n\t\tstdErrString := stdErrBuffer.String()\n\t\tret.NumberOfFrames = getFrameFromRegex(stdErrString)\n\n\t\ttime := getTimeFromRegex(stdErrString)\n\t\tret.FrameRate = math.Round((float64(ret.NumberOfFrames)/time)*100) / 100\n\n\t\treturn &ret, nil\n\t}\n\n\treturn nil, err\n}\n\nvar timeRegex = regexp.MustCompile(`time=\\s*(\\d+):(\\d+):(\\d+.\\d+)`)\nvar frameRegex = regexp.MustCompile(`frame=\\s*([0-9]+)`)\n\nfunc getTimeFromRegex(str string) float64 {\n\tregexResult := timeRegex.FindStringSubmatch(str)\n\n\t// Bail early if we don't have the results we expect\n\tif len(regexResult) != 4 {\n\t\treturn 0\n\t}\n\n\th, _ := strconv.ParseFloat(regexResult[1], 64)\n\tm, _ := strconv.ParseFloat(regexResult[2], 64)\n\ts, _ := strconv.ParseFloat(regexResult[3], 64)\n\thours := h * 3600\n\tminutes := m * 60\n\tseconds := s\n\treturn hours + minutes + seconds\n}\n\nfunc getFrameFromRegex(str string) int {\n\tregexResult := frameRegex.FindStringSubmatch(str)\n\n\t// Bail early if we don't have the results we expect\n\tif len(regexResult) < 2 {\n\t\treturn 0\n\t}\n\n\tresult, _ := strconv.Atoi(regexResult[1])\n\treturn result\n}\n"
  },
  {
    "path": "pkg/ffmpeg/generate.go",
    "content": "package ffmpeg\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os/exec\"\n\t\"strings\"\n)\n\n// Generate runs ffmpeg with the given args and waits for it to finish.\n// Returns an error if the command fails. If the command fails, the return\n// value will be of type *exec.ExitError.\nfunc (f *FFMpeg) Generate(ctx context.Context, args Args) error {\n\tcmd := f.Command(ctx, args)\n\n\tvar stderr bytes.Buffer\n\tcmd.Stderr = &stderr\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn fmt.Errorf(\"error starting command: %w\", err)\n\t}\n\n\tif err := cmd.Wait(); err != nil {\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) {\n\t\t\texitErr.Stderr = stderr.Bytes()\n\t\t\terr = exitErr\n\t\t}\n\t\treturn fmt.Errorf(\"error running ffmpeg command <%s>: %w\", strings.Join(args, \" \"), err)\n\t}\n\n\treturn nil\n}\n\n// GenerateOutput runs ffmpeg with the given args and returns it standard output.\nfunc (f *FFMpeg) GenerateOutput(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) {\n\tcmd := f.Command(ctx, args)\n\tcmd.Stdin = stdin\n\n\tret, err := cmd.Output()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error running ffmpeg command <%s>: %w\", strings.Join(args, \" \"), err)\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/ffmpeg/media_detection.go",
    "content": "package ffmpeg\n\nimport (\n\t\"bytes\"\n\t\"os\"\n)\n\n// detect file format from magic file number\n// https://github.com/lex-r/filetype/blob/73c10ad714e3b8ecf5cd1564c882ed6d440d5c2d/matchers/video.go\n\nfunc mkv(buf []byte) bool {\n\treturn len(buf) > 3 &&\n\t\tbuf[0] == 0x1A && buf[1] == 0x45 &&\n\t\tbuf[2] == 0xDF && buf[3] == 0xA3 &&\n\t\tcontainsMatroskaSignature(buf, []byte{'m', 'a', 't', 'r', 'o', 's', 'k', 'a'})\n}\n\nfunc webm(buf []byte) bool {\n\treturn len(buf) > 3 &&\n\t\tbuf[0] == 0x1A && buf[1] == 0x45 &&\n\t\tbuf[2] == 0xDF && buf[3] == 0xA3 &&\n\t\tcontainsMatroskaSignature(buf, []byte{'w', 'e', 'b', 'm'})\n}\n\nfunc containsMatroskaSignature(buf, subType []byte) bool {\n\tlimit := 4096\n\tif len(buf) < limit {\n\t\tlimit = len(buf)\n\t}\n\n\tindex := bytes.Index(buf[:limit], subType)\n\tif index < 3 {\n\t\treturn false\n\t}\n\n\treturn buf[index-3] == 0x42 && buf[index-2] == 0x82\n}\n\n// magicContainer returns the container type of a file path.\n// Returns the zero-value on errors or no-match. Implements mkv or\n// webm only, as ffprobe can't distinguish between them and not all\n// browsers support mkv\nfunc magicContainer(filePath string) (Container, error) {\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tdefer file.Close()\n\n\tbuf := make([]byte, 4096)\n\t_, err = file.Read(buf)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif webm(buf) {\n\t\treturn Webm, nil\n\t}\n\tif mkv(buf) {\n\t\treturn Matroska, nil\n\t}\n\treturn \"\", nil\n}\n"
  },
  {
    "path": "pkg/ffmpeg/options.go",
    "content": "package ffmpeg\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n)\n\n// Arger is an interface that can be used to append arguments to an Args slice.\ntype Arger interface {\n\tArgs() []string\n}\n\n// Args represents a slice of arguments to be passed to ffmpeg.\ntype Args []string\n\n// LogLevel sets the LogLevel to l and returns the result.\nfunc (a Args) LogLevel(l LogLevel) Args {\n\tif l == \"\" {\n\t\treturn a\n\t}\n\n\treturn append(a, l.Args()...)\n}\n\n// XError adds the -xerror flag and returns the result.\nfunc (a Args) XError() Args {\n\treturn append(a, \"-xerror\")\n}\n\n// Overwrite adds the overwrite flag (-y) and returns the result.\nfunc (a Args) Overwrite() Args {\n\treturn append(a, \"-y\")\n}\n\n// Seek adds a seek (-ss) to the given seconds and returns the result.\nfunc (a Args) Seek(seconds float64) Args {\n\treturn append(a, \"-ss\", fmt.Sprint(seconds))\n}\n\n// Duration sets the duration (-t) to the given seconds and returns the result.\nfunc (a Args) Duration(seconds float64) Args {\n\treturn append(a, \"-t\", fmt.Sprint(seconds))\n}\n\n// Input adds the input (-i) and returns the result.\nfunc (a Args) Input(i string) Args {\n\treturn append(a, \"-i\", i)\n}\n\n// Output adds the output o and returns the result.\nfunc (a Args) Output(o string) Args {\n\treturn append(a, o)\n}\n\n// NullOutput adds a null output and returns the result.\n// On Windows, this outputs to NUL, on everything else, /dev/null.\nfunc (a Args) NullOutput() Args {\n\tvar output string\n\tif runtime.GOOS == \"windows\" {\n\t\toutput = \"nul\" // https://stackoverflow.com/questions/313111/is-there-a-dev-null-on-windows\n\t} else {\n\t\toutput = \"/dev/null\"\n\t}\n\n\treturn a.Output(output)\n}\n\n// VideoFrames adds the -frames:v with f and returns the result.\nfunc (a Args) VideoFrames(f int) Args {\n\treturn append(a, \"-frames:v\", fmt.Sprint(f))\n}\n\n// FixedQualityScaleVideo adds the -q:v argument with q and returns the result.\nfunc (a Args) FixedQualityScaleVideo(q int) Args {\n\treturn append(a, \"-q:v\", fmt.Sprint(q))\n}\n\n// VideoFilter adds the vf video filter and returns the result.\nfunc (a Args) VideoFilter(vf VideoFilter) Args {\n\treturn append(a, vf.Args()...)\n}\n\n// VSync adds the VsyncMethod and returns the result.\nfunc (a Args) VSync(m VSyncMethod) Args {\n\treturn append(a, m.Args()...)\n}\n\n// AudioBitrate adds the -b:a argument with b and returns the result.\nfunc (a Args) AudioBitrate(b string) Args {\n\treturn append(a, \"-b:a\", b)\n}\n\n// MaxMuxingQueueSize adds the -max_muxing_queue_size argument with s and returns the result.\nfunc (a Args) MaxMuxingQueueSize(s int) Args {\n\t// https://trac.ffmpeg.org/ticket/6375\n\treturn append(a, \"-max_muxing_queue_size\", fmt.Sprint(s))\n}\n\n// SkipAudio adds the skip audio flag (-an) and returns the result.\nfunc (a Args) SkipAudio() Args {\n\treturn append(a, \"-an\")\n}\n\n// VideoCodec adds the given video codec and returns the result.\nfunc (a Args) VideoCodec(c VideoCodec) Args {\n\treturn append(a, c.Args()...)\n}\n\n// AudioCodec adds the given audio codec and returns the result.\nfunc (a Args) AudioCodec(c AudioCodec) Args {\n\treturn append(a, c.Args()...)\n}\n\n// Format adds the format flag with f and returns the result.\nfunc (a Args) Format(f Format) Args {\n\treturn append(a, f.Args()...)\n}\n\n// ImageFormat adds the image format (using -f) and returns the result.\nfunc (a Args) ImageFormat(f ImageFormat) Args {\n\treturn append(a, f.Args()...)\n}\n\n// AppendArgs appends the given Arger to the Args and returns the result.\nfunc (a Args) AppendArgs(o Arger) Args {\n\treturn append(a, o.Args()...)\n}\n\n// Args returns a string slice of the arguments.\nfunc (a Args) Args() []string {\n\treturn []string(a)\n}\n\n// LogLevel represents the log level of ffmpeg.\ntype LogLevel string\n\n// Args returns the arguments to set the log level in ffmpeg.\nfunc (l LogLevel) Args() []string {\n\tif l == \"\" {\n\t\treturn nil\n\t}\n\n\treturn []string{\"-v\", string(l)}\n}\n\n// LogLevels for ffmpeg. See -v entry under https://ffmpeg.org/ffmpeg.html#Generic-options\nvar (\n\tLogLevelQuiet   LogLevel = \"quiet\"\n\tLogLevelPanic   LogLevel = \"panic\"\n\tLogLevelFatal   LogLevel = \"fatal\"\n\tLogLevelError   LogLevel = \"error\"\n\tLogLevelWarning LogLevel = \"warning\"\n\tLogLevelInfo    LogLevel = \"info\"\n\tLogLevelVerbose LogLevel = \"verbose\"\n\tLogLevelDebug   LogLevel = \"debug\"\n\tLogLevelTrace   LogLevel = \"trace\"\n)\n\n// VSyncMethod represents the vsync method of ffmpeg.\ntype VSyncMethod string\n\n// Args returns the arguments to set the vsync method in ffmpeg.\nfunc (m VSyncMethod) Args() []string {\n\tif m == \"\" {\n\t\treturn nil\n\t}\n\n\treturn []string{\"-vsync\", string(m)}\n}\n\n// Video sync methods for ffmpeg. See -vsync entry under https://ffmpeg.org/ffmpeg.html#Advanced-options\nvar (\n\tVSyncMethodPassthrough VSyncMethod = \"0\"\n\tVSyncMethodCFR         VSyncMethod = \"1\"\n\tVSyncMethodVFR         VSyncMethod = \"2\"\n\tVSyncMethodDrop        VSyncMethod = \"drop\"\n\tVSyncMethodAuto        VSyncMethod = \"-1\"\n)\n"
  },
  {
    "path": "pkg/ffmpeg/stream.go",
    "content": "package ffmpeg\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst (\n\tMimeWebmVideo string = \"video/webm\"\n\tMimeWebmAudio string = \"audio/webm\"\n\tMimeMkvVideo  string = \"video/x-matroska\"\n\tMimeMkvAudio  string = \"audio/x-matroska\"\n\tMimeMp4Video  string = \"video/mp4\"\n\tMimeMp4Audio  string = \"audio/mp4\"\n)\n\ntype StreamManager struct {\n\tcacheDir string\n\tencoder  *FFMpeg\n\tffprobe  *FFProbe\n\n\tconfig      StreamManagerConfig\n\tlockManager *fsutil.ReadLockManager\n\n\tcontext    context.Context\n\tcancelFunc context.CancelFunc\n\n\trunningStreams map[string]*runningStream\n\tstreamsMutex   sync.Mutex\n}\n\ntype StreamManagerConfig interface {\n\tGetMaxStreamingTranscodeSize() models.StreamingResolutionEnum\n\tGetLiveTranscodeInputArgs() []string\n\tGetLiveTranscodeOutputArgs() []string\n\tGetTranscodeHardwareAcceleration() bool\n}\n\nfunc NewStreamManager(cacheDir string, encoder *FFMpeg, ffprobe *FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager {\n\tif cacheDir == \"\" {\n\t\tlogger.Warn(\"cache directory is not set. Live HLS/DASH transcoding will be disabled\")\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\n\tret := &StreamManager{\n\t\tcacheDir:       cacheDir,\n\t\tencoder:        encoder,\n\t\tffprobe:        ffprobe,\n\t\tconfig:         config,\n\t\tlockManager:    lockManager,\n\t\tcontext:        ctx,\n\t\tcancelFunc:     cancel,\n\t\trunningStreams: make(map[string]*runningStream),\n\t}\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-time.After(monitorInterval):\n\t\t\t\tret.monitorStreams()\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn ret\n}\n\n// Shutdown shuts down the stream manager, killing any running transcoding processes and removing all cached files.\nfunc (sm *StreamManager) Shutdown() {\n\tsm.cancelFunc()\n\tsm.stopAndRemoveAll()\n}\n\ntype StreamRequestContext struct {\n\tcontext.Context\n\tResponseWriter http.ResponseWriter\n}\n\nfunc NewStreamRequestContext(w http.ResponseWriter, r *http.Request) *StreamRequestContext {\n\treturn &StreamRequestContext{\n\t\tContext:        r.Context(),\n\t\tResponseWriter: w,\n\t}\n}\n\nfunc (c *StreamRequestContext) Cancel() {\n\thj, ok := (c.ResponseWriter).(http.Hijacker)\n\tif !ok {\n\t\treturn\n\t}\n\n\t// hijack and close the connection\n\tconn, bw, _ := hj.Hijack()\n\tif conn != nil {\n\t\tif bw != nil {\n\t\t\t// notify end of stream\n\t\t\t_, err := bw.WriteString(\"0\\r\\n\")\n\t\t\tif err != nil {\n\t\t\t\tlogger.Warnf(\"unable to write end of stream: %v\", err)\n\t\t\t}\n\t\t\t_, err = bw.WriteString(\"\\r\\n\")\n\t\t\tif err != nil {\n\t\t\t\tlogger.Warnf(\"unable to write end of stream: %v\", err)\n\t\t\t}\n\n\t\t\t// flush the buffer, but don't wait indefinitely\n\t\t\ttimeout := make(chan struct{}, 1)\n\t\t\tgo func() {\n\t\t\t\t_ = bw.Flush()\n\t\t\t\tclose(timeout)\n\t\t\t}()\n\n\t\t\tconst waitTime = time.Second\n\n\t\t\tselect {\n\t\t\tcase <-timeout:\n\t\t\tcase <-time.After(waitTime):\n\t\t\t\tlogger.Warnf(\"unable to flush buffer - closing connection\")\n\t\t\t}\n\t\t}\n\n\t\tconn.Close()\n\t}\n}\n"
  },
  {
    "path": "pkg/ffmpeg/stream_segmented.go",
    "content": "package ffmpeg\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\n\t\"github.com/zencoder/go-dash/v3/mpd\"\n)\n\nconst (\n\tMimeHLS    string = \"application/vnd.apple.mpegurl\"\n\tMimeMpegTS string = \"video/MP2T\"\n\tMimeDASH   string = \"application/dash+xml\"\n\n\tsegmentLength = 2\n\n\tmaxSegmentWait  = 15 * time.Second\n\tmonitorInterval = 200 * time.Millisecond\n\n\t// segment gap before counting a request as a seek and\n\t// restarting the transcode process at the requested segment\n\tmaxSegmentGap = 5\n\n\t// maximum number of segments to generate\n\t// ahead of the currently streaming segment\n\tmaxSegmentBuffer = 15\n\n\t// maximum idle time between segment requests before\n\t// stopping transcode and deleting cache folder\n\tmaxIdleTime = 30 * time.Second\n\n\tresolutionParamKey = \"resolution\"\n\t// TODO - setting the apikey in here isn't ideal\n\tapiKeyParamKey = \"apikey\"\n)\n\ntype StreamType struct {\n\tName          string\n\tSegmentType   *SegmentType\n\tServeManifest func(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *models.VideoFile, resolution string)\n\tArgs          func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) Args\n}\n\nvar (\n\tStreamTypeHLS = &StreamType{\n\t\tName:          \"hls\",\n\t\tSegmentType:   SegmentTypeTS,\n\t\tServeManifest: serveHLSManifest,\n\t\tArgs: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {\n\t\t\targs = CodecInit(codec)\n\t\t\targs = append(args,\n\t\t\t\t\"-flags\", \"+cgop\",\n\t\t\t\t\"-force_key_frames\", fmt.Sprintf(\"expr:gte(t,n_forced*%d)\", segmentLength),\n\t\t\t)\n\t\t\targs = args.VideoFilter(videoFilter)\n\t\t\tif videoOnly {\n\t\t\t\targs = append(args, \"-an\")\n\t\t\t} else {\n\t\t\t\targs = append(args,\n\t\t\t\t\t\"-c:a\", \"aac\",\n\t\t\t\t\t\"-ac\", \"2\",\n\t\t\t\t)\n\t\t\t}\n\t\t\targs = append(args,\n\t\t\t\t\"-sn\",\n\t\t\t\t\"-copyts\",\n\t\t\t\t\"-avoid_negative_ts\", \"disabled\",\n\t\t\t\t\"-f\", \"hls\",\n\t\t\t\t\"-start_number\", fmt.Sprint(segment),\n\t\t\t\t\"-hls_time\", fmt.Sprint(segmentLength),\n\t\t\t\t\"-hls_flags\", \"split_by_time\",\n\t\t\t\t\"-hls_segment_type\", \"mpegts\",\n\t\t\t\t\"-hls_playlist_type\", \"vod\",\n\t\t\t\t\"-hls_segment_filename\", filepath.Join(outputDir, \".%d.ts\"),\n\t\t\t\tfilepath.Join(outputDir, \"manifest.m3u8\"),\n\t\t\t)\n\t\t\treturn\n\t\t},\n\t}\n\tStreamTypeHLSCopy = &StreamType{\n\t\tName:          \"hls-copy\",\n\t\tSegmentType:   SegmentTypeTS,\n\t\tServeManifest: serveHLSManifest,\n\t\tArgs: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {\n\t\t\targs = CodecInit(codec)\n\t\t\tif videoOnly {\n\t\t\t\targs = append(args, \"-an\")\n\t\t\t} else {\n\t\t\t\targs = append(args,\n\t\t\t\t\t\"-c:a\", \"aac\",\n\t\t\t\t\t\"-ac\", \"2\",\n\t\t\t\t)\n\t\t\t}\n\t\t\targs = append(args,\n\t\t\t\t\"-sn\",\n\t\t\t\t\"-copyts\",\n\t\t\t\t\"-avoid_negative_ts\", \"disabled\",\n\t\t\t\t\"-f\", \"hls\",\n\t\t\t\t\"-start_number\", fmt.Sprint(segment),\n\t\t\t\t\"-hls_time\", fmt.Sprint(segmentLength),\n\t\t\t\t\"-hls_flags\", \"split_by_time\",\n\t\t\t\t\"-hls_segment_type\", \"mpegts\",\n\t\t\t\t\"-hls_playlist_type\", \"vod\",\n\t\t\t\t\"-hls_segment_filename\", filepath.Join(outputDir, \".%d.ts\"),\n\t\t\t\tfilepath.Join(outputDir, \"manifest.m3u8\"),\n\t\t\t)\n\t\t\treturn\n\t\t},\n\t}\n\tStreamTypeDASHVideo = &StreamType{\n\t\tName:          \"dash-v\",\n\t\tSegmentType:   SegmentTypeWEBMVideo,\n\t\tServeManifest: serveDASHManifest,\n\t\tArgs: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {\n\t\t\t// only generate the actual init segment (init_v.webm)\n\t\t\t// when generating the first segment\n\t\t\tinit := \".init\"\n\t\t\tif segment == 0 {\n\t\t\t\tinit = \"init\"\n\t\t\t}\n\n\t\t\targs = CodecInit(codec)\n\t\t\targs = append(args,\n\t\t\t\t\"-force_key_frames\", fmt.Sprintf(\"expr:gte(t,n_forced*%d)\", segmentLength),\n\t\t\t)\n\n\t\t\targs = args.VideoFilter(videoFilter)\n\t\t\targs = append(args,\n\t\t\t\t\"-copyts\",\n\t\t\t\t\"-avoid_negative_ts\", \"disabled\",\n\t\t\t\t\"-map\", \"0:v:0\",\n\t\t\t\t\"-f\", \"webm_chunk\",\n\t\t\t\t\"-chunk_start_index\", fmt.Sprint(segment),\n\t\t\t\t\"-header\", filepath.Join(outputDir, init+\"_v.webm\"),\n\t\t\t\tfilepath.Join(outputDir, \".%d_v.webm\"),\n\t\t\t)\n\t\t\treturn\n\t\t},\n\t}\n\tStreamTypeDASHAudio = &StreamType{\n\t\tName:          \"dash-a\",\n\t\tSegmentType:   SegmentTypeWEBMAudio,\n\t\tServeManifest: serveDASHManifest,\n\t\tArgs: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {\n\t\t\t// only generate the actual init segment (init_a.webm)\n\t\t\t// when generating the first segment\n\t\t\tinit := \".init\"\n\t\t\tif segment == 0 {\n\t\t\t\tinit = \"init\"\n\t\t\t}\n\t\t\targs = append(args,\n\t\t\t\t\"-c:a\", \"libopus\",\n\t\t\t\t\"-b:a\", \"96000\",\n\t\t\t\t\"-ar\", \"48000\",\n\t\t\t\t\"-copyts\",\n\t\t\t\t\"-avoid_negative_ts\", \"disabled\",\n\t\t\t\t\"-map\", \"0:a:0\",\n\t\t\t\t\"-f\", \"webm_chunk\",\n\t\t\t\t\"-chunk_start_index\", fmt.Sprint(segment),\n\t\t\t\t\"-audio_chunk_duration\", fmt.Sprint(segmentLength*1000),\n\t\t\t\t\"-header\", filepath.Join(outputDir, init+\"_a.webm\"),\n\t\t\t\tfilepath.Join(outputDir, \".%d_a.webm\"),\n\t\t\t)\n\t\t\treturn\n\t\t},\n\t}\n)\n\ntype SegmentType struct {\n\tFormat       string\n\tMimeType     string\n\tMakeFilename func(segment int) string\n\tParseSegment func(str string) (int, error)\n}\n\nvar (\n\tSegmentTypeTS = &SegmentType{\n\t\tFormat:   \"%d.ts\",\n\t\tMimeType: MimeMpegTS,\n\t\tMakeFilename: func(segment int) string {\n\t\t\treturn fmt.Sprintf(\"%d.ts\", segment)\n\t\t},\n\t\tParseSegment: func(str string) (int, error) {\n\t\t\tsegment, err := strconv.Atoi(str)\n\t\t\tif err != nil || segment < 0 {\n\t\t\t\terr = ErrInvalidSegment\n\t\t\t}\n\t\t\treturn segment, err\n\t\t},\n\t}\n\tSegmentTypeWEBMVideo = &SegmentType{\n\t\tFormat:   \"%d_v.webm\",\n\t\tMimeType: MimeWebmVideo,\n\t\tMakeFilename: func(segment int) string {\n\t\t\tif segment == -1 {\n\t\t\t\treturn \"init_v.webm\"\n\t\t\t} else {\n\t\t\t\treturn fmt.Sprintf(\"%d_v.webm\", segment)\n\t\t\t}\n\t\t},\n\t\tParseSegment: func(str string) (int, error) {\n\t\t\tif str == \"init\" {\n\t\t\t\treturn -1, nil\n\t\t\t} else {\n\t\t\t\tsegment, err := strconv.Atoi(str)\n\t\t\t\tif err != nil || segment < 0 {\n\t\t\t\t\terr = ErrInvalidSegment\n\t\t\t\t}\n\t\t\t\treturn segment, err\n\t\t\t}\n\t\t},\n\t}\n\tSegmentTypeWEBMAudio = &SegmentType{\n\t\tFormat:   \"%d_a.webm\",\n\t\tMimeType: MimeWebmAudio,\n\t\tMakeFilename: func(segment int) string {\n\t\t\tif segment == -1 {\n\t\t\t\treturn \"init_a.webm\"\n\t\t\t} else {\n\t\t\t\treturn fmt.Sprintf(\"%d_a.webm\", segment)\n\t\t\t}\n\t\t},\n\t\tParseSegment: func(str string) (int, error) {\n\t\t\tif str == \"init\" {\n\t\t\t\treturn -1, nil\n\t\t\t} else {\n\t\t\t\tsegment, err := strconv.Atoi(str)\n\t\t\t\tif err != nil || segment < 0 {\n\t\t\t\t\terr = ErrInvalidSegment\n\t\t\t\t}\n\t\t\t\treturn segment, err\n\t\t\t}\n\t\t},\n\t}\n)\n\nvar ErrInvalidSegment = errors.New(\"invalid segment\")\n\ntype StreamOptions struct {\n\tStreamType *StreamType\n\tVideoFile  *models.VideoFile\n\tResolution string\n\tHash       string\n\tSegment    string\n}\n\ntype transcodeProcess struct {\n\tcmd         *exec.Cmd\n\tcontext     context.Context\n\tcancel      context.CancelFunc\n\tcancelled   bool\n\toutputDir   string\n\tsegmentType *SegmentType\n\tsegment     int\n}\n\ntype waitingSegment struct {\n\tsegmentType *SegmentType\n\tidx         int\n\tfile        string\n\tpath        string\n\taccessed    time.Time\n\tavailable   chan error\n\tdone        atomic.Bool\n}\n\ntype runningStream struct {\n\tdir              string\n\tstreamType       *StreamType\n\tvf               *models.VideoFile\n\tmaxTranscodeSize int\n\toutputDir        string\n\n\twaitingSegments []*waitingSegment\n\ttp              *transcodeProcess\n\tlastAccessed    time.Time\n\tlastSegment     int\n}\n\nfunc (t StreamType) String() string {\n\treturn t.Name\n}\n\nfunc (t StreamType) FileDir(hash string, maxTranscodeSize int) string {\n\tif maxTranscodeSize == 0 {\n\t\treturn fmt.Sprintf(\"%s_%s\", hash, t)\n\t} else {\n\t\treturn fmt.Sprintf(\"%s_%s_%d\", hash, t, maxTranscodeSize)\n\t}\n}\n\nfunc HLSGetCodec(sm *StreamManager, name string) (codec VideoCodec) {\n\tswitch name {\n\tcase \"hls\":\n\t\tcodec = VideoCodecLibX264\n\t\tif hwcodec := sm.encoder.hwCodecHLSCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {\n\t\t\tcodec = *hwcodec\n\t\t}\n\tcase \"dash-v\":\n\t\tcodec = VideoCodecVP9\n\t\tif hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {\n\t\t\tcodec = *hwcodec\n\t\t}\n\tcase \"hls-copy\":\n\t\tcodec = VideoCodecCopy\n\t}\n\n\treturn codec\n}\n\nfunc (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args {\n\textraInputArgs := sm.config.GetLiveTranscodeInputArgs()\n\textraOutputArgs := sm.config.GetLiveTranscodeOutputArgs()\n\n\targs := Args{\"-hide_banner\"}\n\targs = args.LogLevel(LogLevelError)\n\n\tcodec := HLSGetCodec(sm, s.streamType.Name)\n\n\tfullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.hwCanFullHWTranscode(sm.context, codec, s.vf, s.maxTranscodeSize)\n\targs = sm.encoder.hwDeviceInit(args, codec, fullhw)\n\targs = append(args, extraInputArgs...)\n\n\tif segment > 0 {\n\t\targs = args.Seek(float64(segment * segmentLength))\n\t}\n\n\targs = args.Input(s.vf.Path)\n\n\tvideoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported\n\n\tvideoFilter := sm.encoder.hwMaxResFilter(codec, s.vf, s.maxTranscodeSize, fullhw)\n\n\targs = append(args, s.streamType.Args(codec, segment, videoFilter, videoOnly, s.outputDir)...)\n\n\targs = append(args, extraOutputArgs...)\n\n\treturn args\n}\n\n// checkSegments renames temp segments that have been completely generated.\n// existing segments are not replaced - if a segment is generated\n// multiple times, then only the first one is kept.\nfunc (tp *transcodeProcess) checkSegments() {\n\tdoSegment := func(filename string) {\n\t\tif filename != \"\" {\n\t\t\toldPath := filepath.Join(tp.outputDir, filename)\n\t\t\tnewPath := filepath.Join(tp.outputDir, filename[1:])\n\t\t\tif !segmentExists(newPath) {\n\t\t\t\t_ = os.Rename(oldPath, newPath)\n\t\t\t} else {\n\t\t\t\tos.Remove(oldPath)\n\t\t\t}\n\t\t}\n\t}\n\n\tprocessState := tp.cmd.ProcessState\n\tvar lastFilename string\n\tfor i := tp.segment; ; i++ {\n\t\tfilename := fmt.Sprintf(\".\"+tp.segmentType.Format, i)\n\t\tif segmentExists(filepath.Join(tp.outputDir, filename)) {\n\t\t\t// this segment exists so the previous segment is valid\n\t\t\tdoSegment(lastFilename)\n\t\t} else {\n\t\t\t// if the transcode process has exited then\n\t\t\t// we need to do something with the last segment\n\t\t\tif processState != nil {\n\t\t\t\tif processState.Success() {\n\t\t\t\t\t// if the process exited successfully then\n\t\t\t\t\t// count the last segment as valid\n\t\t\t\t\tdoSegment(lastFilename)\n\t\t\t\t} else if lastFilename != \"\" {\n\t\t\t\t\t// if the process exited unsuccessfully then just delete\n\t\t\t\t\t// the last segment, it's probably incomplete\n\t\t\t\t\tos.Remove(filepath.Join(tp.outputDir, lastFilename))\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tlastFilename = filename\n\t\ttp.segment = i\n\t}\n}\n\nfunc lastSegment(vf *models.VideoFile) int {\n\treturn int(math.Ceil(vf.Duration/segmentLength)) - 1\n}\n\nfunc segmentExists(path string) bool {\n\texists, _ := fsutil.FileExists(path)\n\treturn exists\n}\n\n// serveHLSManifest serves a generated HLS playlist. The URLs for the segments\n// are of the form {r.URL}/%d.ts{?urlQuery} where %d is the segment index.\nfunc serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *models.VideoFile, resolution string) {\n\tif sm.cacheDir == \"\" {\n\t\tlogger.Error(\"[transcode] cannot live transcode with HLS because cache dir is unset\")\n\t\thttp.Error(w, \"cannot live transcode with HLS because cache dir is unset\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\n\tprobeResult, err := sm.ffprobe.NewVideoFile(vf.Path)\n\tif err != nil {\n\t\tlogger.Warnf(\"[transcode] error generating HLS manifest: %v\", err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tprefix := r.Header.Get(\"X-Forwarded-Prefix\")\n\n\tbaseUrl := *r.URL\n\tbaseUrl.RawQuery = \"\"\n\tbaseURL := prefix + baseUrl.String()\n\n\turlQuery := url.Values{}\n\tapikey := r.URL.Query().Get(apiKeyParamKey)\n\n\tif resolution != \"\" {\n\t\turlQuery.Set(resolutionParamKey, resolution)\n\t}\n\n\t// TODO - this needs to be handled outside of this package\n\tif apikey != \"\" {\n\t\turlQuery.Set(apiKeyParamKey, apikey)\n\t}\n\n\turlQueryString := \"\"\n\tif len(urlQuery) > 0 {\n\t\turlQueryString = \"?\" + urlQuery.Encode()\n\t}\n\n\tvar buf bytes.Buffer\n\n\tfmt.Fprint(&buf, \"#EXTM3U\\n\")\n\n\tfmt.Fprint(&buf, \"#EXT-X-VERSION:3\\n\")\n\tfmt.Fprint(&buf, \"#EXT-X-MEDIA-SEQUENCE:0\\n\")\n\tfmt.Fprintf(&buf, \"#EXT-X-TARGETDURATION:%d\\n\", segmentLength)\n\tfmt.Fprint(&buf, \"#EXT-X-PLAYLIST-TYPE:VOD\\n\")\n\n\tleftover := probeResult.FileDuration\n\tsegment := 0\n\n\tfor leftover > 0 {\n\t\tthisLength := float64(segmentLength)\n\t\tif leftover < thisLength {\n\t\t\tthisLength = leftover\n\t\t}\n\n\t\tfmt.Fprintf(&buf, \"#EXTINF:%f,\\n\", thisLength)\n\t\tfmt.Fprintf(&buf, \"%s/%d.ts%s\\n\", baseURL, segment, urlQueryString)\n\n\t\tleftover -= thisLength\n\t\tsegment++\n\t}\n\n\tfmt.Fprint(&buf, \"#EXT-X-ENDLIST\\n\")\n\n\tw.Header().Set(\"Content-Type\", MimeHLS)\n\tutils.ServeStaticContent(w, r, buf.Bytes())\n}\n\n// serveDASHManifest serves a generated DASH manifest.\nfunc serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *models.VideoFile, resolution string) {\n\tif sm.cacheDir == \"\" {\n\t\tlogger.Error(\"[transcode] cannot live transcode with DASH because cache dir is unset\")\n\t\thttp.Error(w, \"cannot live transcode files with DASH because cache dir is unset\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\n\tprobeResult, err := sm.ffprobe.NewVideoFile(vf.Path)\n\tif err != nil {\n\t\tlogger.Warnf(\"[transcode] error generating DASH manifest: %v\", err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tvar framerate string\n\tvar videoWidth int\n\tvar videoHeight int\n\tvideoStream := probeResult.VideoStream\n\tif videoStream != nil {\n\t\tframerate = videoStream.AvgFrameRate\n\t\tvideoWidth = videoStream.Width\n\t\tvideoHeight = videoStream.Height\n\t} else {\n\t\t// extract the framerate fraction from the file framerate\n\t\t// framerates 0.1% below round numbers are common,\n\t\t// attempt to infer when this is the case\n\t\tfileFramerate := vf.FrameRate\n\t\trate1001, off1001 := math.Modf(fileFramerate * 1.001)\n\t\tvar numerator int\n\t\tvar denominator int\n\t\tswitch {\n\t\tcase off1001 < 0.005:\n\t\t\tnumerator = int(rate1001) * 1000\n\t\t\tdenominator = 1001\n\t\tcase off1001 > 0.995:\n\t\t\tnumerator = (int(rate1001) + 1) * 1000\n\t\t\tdenominator = 1001\n\t\tdefault:\n\t\t\tnumerator = int(fileFramerate * 1000)\n\t\t\tdenominator = 1000\n\t\t}\n\t\tframerate = fmt.Sprintf(\"%d/%d\", numerator, denominator)\n\t\tvideoHeight = vf.Height\n\t\tvideoWidth = vf.Width\n\t}\n\n\turlQuery := url.Values{}\n\n\t// TODO - this needs to be handled outside of this package\n\tapikey := r.URL.Query().Get(apiKeyParamKey)\n\tif apikey != \"\" {\n\t\turlQuery.Set(apiKeyParamKey, apikey)\n\t}\n\n\tmaxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()\n\tif resolution != \"\" {\n\t\tmaxTranscodeSize = models.StreamingResolutionEnum(resolution).GetMaxResolution()\n\t\turlQuery.Set(resolutionParamKey, resolution)\n\t}\n\tif maxTranscodeSize != 0 {\n\t\tvideoSize := videoHeight\n\t\tif videoWidth < videoSize {\n\t\t\tvideoSize = videoWidth\n\t\t}\n\n\t\tif maxTranscodeSize < videoSize {\n\t\t\tscaleFactor := float64(maxTranscodeSize) / float64(videoSize)\n\t\t\tvideoWidth = int(float64(videoWidth) * scaleFactor)\n\t\t\tvideoHeight = int(float64(videoHeight) * scaleFactor)\n\t\t}\n\t}\n\n\turlQueryString := \"\"\n\tif len(urlQuery) > 0 {\n\t\turlQueryString = \"?\" + urlQuery.Encode()\n\t}\n\n\tmediaDuration := mpd.Duration(time.Duration(probeResult.FileDuration * float64(time.Second)))\n\tm := mpd.NewMPD(mpd.DASH_PROFILE_LIVE, mediaDuration.String(), \"PT4.0S\")\n\n\tprefix := r.Header.Get(\"X-Forwarded-Prefix\")\n\n\tbaseUrl := r.URL.JoinPath(\"/\")\n\tbaseUrl.RawQuery = \"\"\n\tm.BaseURL = prefix + baseUrl.String()\n\n\tvideo, _ := m.AddNewAdaptationSetVideo(MimeWebmVideo, \"progressive\", true, 1)\n\n\t_, _ = video.SetNewSegmentTemplate(2, \"init_v.webm\"+urlQueryString, \"$Number$_v.webm\"+urlQueryString, 0, 1)\n\t_, _ = video.AddNewRepresentationVideo(200000, \"vp09.00.40.08\", \"0\", framerate, int64(videoWidth), int64(videoHeight))\n\n\tif ProbeAudioCodec(vf.AudioCodec) != MissingUnsupported {\n\t\taudio, _ := m.AddNewAdaptationSetAudio(MimeWebmAudio, true, 1, \"und\")\n\t\t_, _ = audio.SetNewSegmentTemplate(2, \"init_a.webm\"+urlQueryString, \"$Number$_a.webm\"+urlQueryString, 0, 1)\n\t\t_, _ = audio.AddNewRepresentationAudio(48000, 96000, \"opus\", \"1\")\n\t}\n\n\tvar buf bytes.Buffer\n\t_ = m.Write(&buf)\n\n\tw.Header().Set(\"Content-Type\", MimeDASH)\n\tutils.ServeStaticContent(w, r, buf.Bytes())\n}\n\nfunc (sm *StreamManager) ServeManifest(w http.ResponseWriter, r *http.Request, streamType *StreamType, vf *models.VideoFile, resolution string) {\n\tstreamType.ServeManifest(sm, w, r, vf, resolution)\n}\n\nfunc (sm *StreamManager) serveWaitingSegment(w http.ResponseWriter, r *http.Request, segment *waitingSegment) {\n\tselect {\n\tcase <-r.Context().Done():\n\t\tbreak\n\tcase err := <-segment.available:\n\t\tif err == nil {\n\t\t\tlogger.Tracef(\"[transcode] streaming segment file %s\", segment.file)\n\t\t\tw.Header().Set(\"Content-Type\", segment.segmentType.MimeType)\n\t\t\tutils.ServeStaticFile(w, r, segment.path)\n\t\t} else if !errors.Is(err, context.Canceled) {\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t}\n\t}\n\tsegment.done.Store(true)\n}\n\nfunc (sm *StreamManager) ServeSegment(w http.ResponseWriter, r *http.Request, options StreamOptions) {\n\tif sm.cacheDir == \"\" {\n\t\tlogger.Error(\"[transcode] cannot live transcode files because cache dir is unset\")\n\t\thttp.Error(w, \"cannot live transcode files because cache dir is unset\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\n\tif options.Hash == \"\" {\n\t\thttp.Error(w, \"invalid hash\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tstreamType := options.StreamType\n\n\tsegment, err := streamType.SegmentType.ParseSegment(options.Segment)\n\t// error if segment is past the end of the video\n\tif err != nil || segment > lastSegment(options.VideoFile) {\n\t\thttp.Error(w, \"invalid segment\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tmaxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()\n\tif options.Resolution != \"\" {\n\t\tmaxTranscodeSize = models.StreamingResolutionEnum(options.Resolution).GetMaxResolution()\n\t}\n\n\tdir := options.StreamType.FileDir(options.Hash, maxTranscodeSize)\n\toutputDir := filepath.Join(sm.cacheDir, dir)\n\n\tname := streamType.SegmentType.MakeFilename(segment)\n\tfile := filepath.Join(dir, name)\n\n\tsm.streamsMutex.Lock()\n\n\tstream := sm.runningStreams[dir]\n\tif stream == nil {\n\t\tstream = &runningStream{\n\t\t\tdir:              dir,\n\t\t\tstreamType:       options.StreamType,\n\t\t\tvf:               options.VideoFile,\n\t\t\tmaxTranscodeSize: maxTranscodeSize,\n\t\t\toutputDir:        outputDir,\n\n\t\t\t// initialize to cap 10 to avoid reallocations\n\t\t\twaitingSegments: make([]*waitingSegment, 0, 10),\n\t\t}\n\t\tsm.runningStreams[dir] = stream\n\t}\n\n\tnow := time.Now()\n\tstream.lastAccessed = now\n\tif segment != -1 {\n\t\tstream.lastSegment = segment\n\t}\n\n\twaitingSegment := &waitingSegment{\n\t\tsegmentType: streamType.SegmentType,\n\t\tidx:         segment,\n\t\tfile:        file,\n\t\tpath:        filepath.Join(sm.cacheDir, file),\n\t\taccessed:    now,\n\t\tavailable:   make(chan error, 1),\n\t}\n\tstream.waitingSegments = append(stream.waitingSegments, waitingSegment)\n\n\tsm.streamsMutex.Unlock()\n\n\tsm.serveWaitingSegment(w, r, waitingSegment)\n}\n\n// assume lock is held\nfunc (sm *StreamManager) startTranscode(stream *runningStream, segment int, done chan<- error) {\n\t// generate segment 0 if init segment requested\n\tif segment == -1 {\n\t\tsegment = 0\n\t}\n\n\tlogger.Debugf(\"[transcode] starting transcode for %s at segment #%d\", stream.dir, segment)\n\n\tif err := os.MkdirAll(stream.outputDir, os.ModePerm); err != nil {\n\t\tlogger.Errorf(\"[transcode] %v\", err)\n\t\tdone <- err\n\t\treturn\n\t}\n\n\tlockCtx := sm.lockManager.ReadLock(sm.context, stream.vf.Path)\n\n\targs := stream.makeStreamArgs(sm, segment)\n\tcmd := sm.encoder.Command(lockCtx, args)\n\n\tstderr, err := cmd.StderrPipe()\n\tif err != nil {\n\t\tlogger.Errorf(\"[transcode] ffmpeg stderr not available: %v\", err)\n\t}\n\n\tstdout, err := cmd.StdoutPipe()\n\tif nil != err {\n\t\tlogger.Errorf(\"[transcode] ffmpeg stdout not available: %v\", err)\n\t}\n\n\tlogger.Tracef(\"[transcode] running %s\", cmd)\n\tif err := cmd.Start(); err != nil {\n\t\tlockCtx.Cancel()\n\t\terr = fmt.Errorf(\"error starting transcode process: %w\", err)\n\t\tlogger.Errorf(\"[transcode] %v\", err)\n\t\tdone <- err\n\t\treturn\n\t}\n\n\ttp := &transcodeProcess{\n\t\tcmd:         cmd,\n\t\tcontext:     lockCtx,\n\t\tcancel:      lockCtx.Cancel,\n\t\toutputDir:   stream.outputDir,\n\t\tsegmentType: stream.streamType.SegmentType,\n\t\tsegment:     segment,\n\t}\n\tstream.tp = tp\n\n\tgo func() {\n\t\terrStr, _ := io.ReadAll(stderr)\n\t\toutStr, _ := io.ReadAll(stdout)\n\n\t\terrCmd := cmd.Wait()\n\n\t\tvar err error\n\n\t\t// don't log error if cancelled\n\t\tif !tp.cancelled {\n\t\t\te := string(errStr)\n\t\t\tif e == \"\" {\n\t\t\t\te = string(outStr)\n\t\t\t}\n\t\t\tif e != \"\" {\n\t\t\t\terr = errors.New(e)\n\t\t\t} else {\n\t\t\t\terr = errCmd\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\terr = fmt.Errorf(\"ffmpeg error when running command <%s>: %w\", strings.Join(cmd.Args, \" \"), err)\n\n\t\t\t\tvar exitError *exec.ExitError\n\t\t\t\tif !errors.As(err, &exitError) {\n\t\t\t\t\tlogger.Errorf(\"[transcode] %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tsm.streamsMutex.Lock()\n\n\t\t// make sure that cancel is called to prevent memory leaks\n\t\ttp.cancel()\n\n\t\t// clear remaining segments after ffmpeg exit\n\t\ttp.checkSegments()\n\n\t\tif stream.tp == tp {\n\t\t\tstream.tp = nil\n\t\t}\n\n\t\tsm.streamsMutex.Unlock()\n\n\t\tif err != nil {\n\t\t\tdone <- err\n\t\t}\n\t}()\n}\n\n// assume lock is held\nfunc (sm *StreamManager) stopTranscode(stream *runningStream) {\n\ttp := stream.tp\n\tif tp != nil {\n\t\ttp.cancel()\n\t\ttp.cancelled = true\n\t}\n}\n\nfunc (sm *StreamManager) checkTranscode(stream *runningStream, now time.Time) {\n\tif len(stream.waitingSegments) == 0 && stream.lastAccessed.Add(maxIdleTime).Before(now) {\n\t\t// Stream expired. Cancel the transcode process and delete the files\n\t\tlogger.Debugf(\"[transcode] stream for %s not accessed recently. Cancelling transcode and removing files\", stream.dir)\n\n\t\tsm.stopTranscode(stream)\n\t\tsm.removeTranscodeFiles(stream)\n\n\t\tdelete(sm.runningStreams, stream.dir)\n\t\treturn\n\t}\n\n\tif stream.tp != nil {\n\t\tsegmentType := stream.streamType.SegmentType\n\t\tsegment := stream.lastSegment\n\t\t// if all segments up to maxSegmentBuffer exist, stop transcode\n\t\tfor i := segment; i < segment+maxSegmentBuffer; i++ {\n\t\t\tif !segmentExists(filepath.Join(stream.outputDir, segmentType.MakeFilename(i))) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tlogger.Debugf(\"[transcode] stopping transcode for %s, buffer is full\", stream.dir)\n\t\tsm.stopTranscode(stream)\n\t}\n}\n\nfunc (s *waitingSegment) checkAvailable(now time.Time) bool {\n\tif segmentExists(s.path) {\n\t\ts.available <- nil\n\t\treturn true\n\t} else if s.accessed.Add(maxSegmentWait).Before(now) {\n\t\terr := fmt.Errorf(\"timed out waiting for segment file %s to be generated\", s.file)\n\t\tlogger.Errorf(\"[transcode] %v\", err)\n\t\ts.available <- err\n\t\treturn true\n\t}\n\treturn false\n}\n\n// ensureTranscode will start a new transcode process if the transcode\n// is more than maxSegmentGap behind the requested segment\nfunc (sm *StreamManager) ensureTranscode(stream *runningStream, segment *waitingSegment) bool {\n\tsegmentIdx := segment.idx\n\ttp := stream.tp\n\tif tp == nil {\n\t\tsm.startTranscode(stream, segmentIdx, segment.available)\n\t\treturn true\n\t} else if segmentIdx < tp.segment || tp.segment+maxSegmentGap < segmentIdx {\n\t\t// only stop the transcode process here - it will be restarted only\n\t\t// after the old process exits as stream.tp will then be nil.\n\t\tsm.stopTranscode(stream)\n\t\treturn true\n\t}\n\treturn false\n}\n\n// runs every monitorInterval\nfunc (sm *StreamManager) monitorStreams() {\n\tsm.streamsMutex.Lock()\n\tdefer sm.streamsMutex.Unlock()\n\n\tnow := time.Now()\n\n\tfor _, stream := range sm.runningStreams {\n\t\tif stream.tp != nil {\n\t\t\tstream.tp.checkSegments()\n\t\t}\n\n\t\ttranscodeStarted := false\n\t\ttemp := stream.waitingSegments[:0]\n\t\tfor _, segment := range stream.waitingSegments {\n\t\t\tremove := false\n\t\t\tif segment.done.Load() || segment.checkAvailable(now) {\n\t\t\t\tremove = true\n\t\t\t} else if !transcodeStarted {\n\t\t\t\ttranscodeStarted = sm.ensureTranscode(stream, segment)\n\t\t\t}\n\t\t\tif !remove {\n\t\t\t\ttemp = append(temp, segment)\n\t\t\t}\n\t\t}\n\t\tstream.waitingSegments = temp\n\n\t\tif !transcodeStarted {\n\t\t\tsm.checkTranscode(stream, now)\n\t\t}\n\t}\n}\n\n// assume lock is held\nfunc (sm *StreamManager) removeTranscodeFiles(stream *runningStream) {\n\tpath := stream.outputDir\n\tif err := os.RemoveAll(path); err != nil {\n\t\tlogger.Warnf(\"[transcode] error removing segment directory %s: %v\", path, err)\n\t}\n}\n\n// stopAndRemoveAll stops all current streams and removes all cache files\nfunc (sm *StreamManager) stopAndRemoveAll() {\n\tsm.streamsMutex.Lock()\n\tdefer sm.streamsMutex.Unlock()\n\n\tfor _, stream := range sm.runningStreams {\n\t\tfor _, segment := range stream.waitingSegments {\n\t\t\tif len(segment.available) == 0 {\n\t\t\t\tsegment.available <- context.Canceled\n\t\t\t}\n\t\t}\n\t\tsm.stopTranscode(stream)\n\t\tsm.removeTranscodeFiles(stream)\n\t}\n\n\t// ensure nothing else can use the map\n\tsm.runningStreams = nil\n}\n"
  },
  {
    "path": "pkg/ffmpeg/stream_transcode.go",
    "content": "package ffmpeg\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype StreamFormat struct {\n\tMimeType string\n\tArgs     func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) Args\n}\n\nfunc CodecInit(codec VideoCodec) (args Args) {\n\targs = args.VideoCodec(codec)\n\n\tswitch codec {\n\t// CPU Codecs\n\tcase VideoCodecLibX264:\n\t\targs = append(args,\n\t\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\t\"-preset\", \"veryfast\",\n\t\t\t\"-crf\", \"25\",\n\t\t\t\"-sc_threshold\", \"0\",\n\t\t)\n\tcase VideoCodecVP9:\n\t\targs = append(args,\n\t\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\t\"-deadline\", \"realtime\",\n\t\t\t\"-cpu-used\", \"5\",\n\t\t\t\"-row-mt\", \"1\",\n\t\t\t\"-crf\", \"30\",\n\t\t\t\"-b:v\", \"0\",\n\t\t)\n\t// HW Codecs\n\tcase VideoCodecN264:\n\t\targs = append(args,\n\t\t\t\"-rc\", \"vbr\",\n\t\t\t\"-cq\", \"15\",\n\t\t)\n\tcase VideoCodecN264H:\n\t\targs = append(args,\n\t\t\t\"-profile\", \"p7\",\n\t\t\t\"-tune\", \"hq\",\n\t\t\t\"-profile\", \"high\",\n\t\t\t\"-rc\", \"vbr\",\n\t\t\t\"-rc-lookahead\", \"60\",\n\t\t\t\"-surfaces\", \"64\",\n\t\t\t\"-spatial-aq\", \"1\",\n\t\t\t\"-aq-strength\", \"15\",\n\t\t\t\"-cq\", \"15\",\n\t\t\t\"-coder\", \"cabac\",\n\t\t\t\"-b_ref_mode\", \"middle\",\n\t\t)\n\tcase VideoCodecI264, VideoCodecIVP9:\n\t\targs = append(args,\n\t\t\t\"-global_quality\", \"20\",\n\t\t\t\"-preset\", \"faster\",\n\t\t)\n\tcase VideoCodecI264C:\n\t\targs = append(args,\n\t\t\t\"-q\", \"20\",\n\t\t\t\"-preset\", \"faster\",\n\t\t)\n\tcase VideoCodecV264, VideoCodecVVP9:\n\t\targs = append(args,\n\t\t\t\"-qp\", \"20\",\n\t\t)\n\tcase VideoCodecA264:\n\t\targs = append(args,\n\t\t\t\"-quality\", \"speed\",\n\t\t)\n\tcase VideoCodecM264:\n\t\targs = append(args,\n\t\t\t\"-realtime\", \"1\",\n\t\t)\n\tcase VideoCodecO264:\n\t\targs = append(args,\n\t\t\t\"-preset\", \"superfast\",\n\t\t\t\"-crf\", \"25\",\n\t\t)\n\t}\n\n\treturn args\n}\n\nvar (\n\tStreamTypeMP4 = StreamFormat{\n\t\tMimeType: MimeMp4Video,\n\t\tArgs: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) {\n\t\t\targs = CodecInit(codec)\n\t\t\targs = append(args, \"-movflags\", \"frag_keyframe+empty_moov\")\n\t\t\targs = args.VideoFilter(videoFilter)\n\t\t\tif videoOnly {\n\t\t\t\targs = args.SkipAudio()\n\t\t\t} else {\n\t\t\t\targs = append(args, \"-ac\", \"2\")\n\t\t\t}\n\t\t\targs = args.Format(FormatMP4)\n\t\t\treturn\n\t\t},\n\t}\n\tStreamTypeWEBM = StreamFormat{\n\t\tMimeType: MimeWebmVideo,\n\t\tArgs: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) {\n\t\t\targs = CodecInit(codec)\n\t\t\targs = args.VideoFilter(videoFilter)\n\t\t\tif videoOnly {\n\t\t\t\targs = args.SkipAudio()\n\t\t\t} else {\n\t\t\t\targs = append(args, \"-ac\", \"2\")\n\t\t\t}\n\t\t\targs = args.Format(FormatWebm)\n\t\t\treturn\n\t\t},\n\t}\n\tStreamTypeMKV = StreamFormat{\n\t\tMimeType: MimeMkvVideo,\n\t\tArgs: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) {\n\t\t\targs = CodecInit(codec)\n\t\t\tif videoOnly {\n\t\t\t\targs = args.SkipAudio()\n\t\t\t} else {\n\t\t\t\targs = args.AudioCodec(AudioCodecLibOpus)\n\t\t\t\targs = append(args,\n\t\t\t\t\t\"-b:a\", \"96k\",\n\t\t\t\t\t\"-vbr\", \"on\",\n\t\t\t\t\t\"-ac\", \"2\",\n\t\t\t\t)\n\t\t\t}\n\t\t\targs = args.Format(FormatMatroska)\n\t\t\treturn\n\t\t},\n\t}\n)\n\ntype TranscodeOptions struct {\n\tStreamType StreamFormat\n\tVideoFile  *models.VideoFile\n\tResolution string\n\tStartTime  float64\n}\n\nfunc (o TranscodeOptions) FileGetCodec(sm *StreamManager, maxTranscodeSize int) (codec VideoCodec) {\n\tneedsResize := false\n\n\tif maxTranscodeSize != 0 {\n\t\tif o.VideoFile.Width > o.VideoFile.Height {\n\t\t\tneedsResize = o.VideoFile.Width > maxTranscodeSize\n\t\t} else {\n\t\t\tneedsResize = o.VideoFile.Height > maxTranscodeSize\n\t\t}\n\t}\n\n\tswitch o.StreamType.MimeType {\n\tcase MimeMp4Video:\n\t\tif !needsResize && o.VideoFile.VideoCodec == H264 {\n\t\t\treturn VideoCodecCopy\n\t\t}\n\t\tcodec = VideoCodecLibX264\n\t\tif hwcodec := sm.encoder.hwCodecMP4Compatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {\n\t\t\tcodec = *hwcodec\n\t\t}\n\tcase MimeWebmVideo:\n\t\tif !needsResize && (o.VideoFile.VideoCodec == Vp8 || o.VideoFile.VideoCodec == Vp9) {\n\t\t\treturn VideoCodecCopy\n\t\t}\n\t\tcodec = VideoCodecVP9\n\t\tif hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {\n\t\t\tcodec = *hwcodec\n\t\t}\n\tcase MimeMkvVideo:\n\t\tcodec = VideoCodecCopy\n\t}\n\n\treturn codec\n}\n\nfunc (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args {\n\tmaxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()\n\tif o.Resolution != \"\" {\n\t\tmaxTranscodeSize = models.StreamingResolutionEnum(o.Resolution).GetMaxResolution()\n\t}\n\textraInputArgs := sm.config.GetLiveTranscodeInputArgs()\n\textraOutputArgs := sm.config.GetLiveTranscodeOutputArgs()\n\n\targs := Args{\"-hide_banner\"}\n\targs = args.LogLevel(LogLevelError)\n\n\tcodec := o.FileGetCodec(sm, maxTranscodeSize)\n\n\tfullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.hwCanFullHWTranscode(sm.context, codec, o.VideoFile, maxTranscodeSize)\n\targs = sm.encoder.hwDeviceInit(args, codec, fullhw)\n\targs = append(args, extraInputArgs...)\n\n\tif o.StartTime != 0 {\n\t\targs = args.Seek(o.StartTime)\n\t}\n\n\targs = args.Input(o.VideoFile.Path)\n\n\tvideoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported\n\n\tvideoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile, maxTranscodeSize, fullhw)\n\n\targs = append(args, o.StreamType.Args(codec, videoFilter, videoOnly)...)\n\n\targs = append(args, extraOutputArgs...)\n\n\targs = args.Output(\"pipe:\")\n\n\treturn args\n}\n\nfunc (sm *StreamManager) ServeTranscode(w http.ResponseWriter, r *http.Request, options TranscodeOptions) {\n\tstreamRequestCtx := NewStreamRequestContext(w, r)\n\tlockCtx := sm.lockManager.ReadLock(streamRequestCtx, options.VideoFile.Path)\n\n\t// hijacking and closing the connection here causes video playback to hang in Chrome\n\t// due to ERR_INCOMPLETE_CHUNKED_ENCODING\n\t// We trust that the request context will be closed, so we don't need to call Cancel on the returned context here.\n\n\thandler, err := sm.getTranscodeStream(lockCtx, options)\n\n\tif err != nil {\n\t\t// don't log context canceled errors\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tlogger.Errorf(\"[transcode] error transcoding video file: %v\", err)\n\t\t}\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tif _, err := w.Write([]byte(err.Error())); err != nil {\n\t\t\tlogger.Warnf(\"[transcode] error writing response: %v\", err)\n\t\t}\n\t\treturn\n\t}\n\n\thandler(w, r)\n}\n\nfunc (sm *StreamManager) getTranscodeStream(ctx *fsutil.LockContext, options TranscodeOptions) (http.HandlerFunc, error) {\n\targs := options.makeStreamArgs(sm)\n\tcmd := sm.encoder.Command(ctx, args)\n\n\tstdout, err := cmd.StdoutPipe()\n\tif nil != err {\n\t\tlogger.Errorf(\"[transcode] ffmpeg stdout not available: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tstderr, err := cmd.StderrPipe()\n\tif nil != err {\n\t\tlogger.Errorf(\"[transcode] ffmpeg stderr not available: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tif err = cmd.Start(); err != nil {\n\t\treturn nil, err\n\t}\n\tctx.AttachCommand(cmd)\n\n\t// stderr must be consumed or the process deadlocks\n\tgo func() {\n\t\terrStr, _ := io.ReadAll(stderr)\n\n\t\terrCmd := cmd.Wait()\n\n\t\tvar err error\n\n\t\te := string(errStr)\n\t\tif e != \"\" {\n\t\t\terr = errors.New(e)\n\t\t} else {\n\t\t\terr = errCmd\n\t\t}\n\n\t\t// ignore ExitErrors, the process is always forcibly killed\n\t\tvar exitError *exec.ExitError\n\t\tif err != nil && !errors.As(err, &exitError) {\n\t\t\tlogger.Errorf(\"[transcode] ffmpeg error when running command <%s>: %v\", strings.Join(cmd.Args, \" \"), err)\n\t\t}\n\t}()\n\n\tmimeType := options.StreamType.MimeType\n\thandler := func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\t\tw.Header().Set(\"Content-Type\", mimeType)\n\t\tw.WriteHeader(http.StatusOK)\n\n\t\t// process killing should be handled by command context\n\n\t\t_, err := io.Copy(w, stdout)\n\t\tif err != nil && !errors.Is(err, syscall.EPIPE) && !errors.Is(err, syscall.ECONNRESET) {\n\t\t\tlogger.Errorf(\"[transcode] error serving transcoded video file: %v\", err)\n\t\t}\n\n\t\tw.(http.Flusher).Flush()\n\t}\n\treturn handler, nil\n}\n"
  },
  {
    "path": "pkg/ffmpeg/transcoder/image.go",
    "content": "package transcoder\n\nimport (\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n)\n\nvar ErrUnsupportedFormat = errors.New(\"unsupported image format\")\n\ntype ImageThumbnailOptions struct {\n\tInputFormat   ffmpeg.ImageFormat\n\tOutputFormat  ffmpeg.ImageFormat\n\tOutputPath    string\n\tMaxDimensions int\n\tQuality       int\n}\n\nfunc ImageThumbnail(input string, options ImageThumbnailOptions) ffmpeg.Args {\n\tvar videoFilter ffmpeg.VideoFilter\n\tvideoFilter = videoFilter.ScaleMaxSize(options.MaxDimensions)\n\n\tvar args ffmpeg.Args\n\targs = append(args, \"-hide_banner\")\n\targs = args.LogLevel(ffmpeg.LogLevelError)\n\n\targs = args.Overwrite().\n\t\tImageFormat(options.InputFormat).\n\t\tInput(input).\n\t\tVideoFilter(videoFilter).\n\t\tVideoCodec(ffmpeg.VideoCodecMJpeg)\n\n\targs = append(args, \"-frames:v\", \"1\")\n\n\tif options.Quality > 0 {\n\t\targs = args.FixedQualityScaleVideo(options.Quality)\n\t}\n\n\targs = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe).\n\t\tOutput(options.OutputPath).\n\t\tImageFormat(options.OutputFormat)\n\n\treturn args\n}\n"
  },
  {
    "path": "pkg/ffmpeg/transcoder/screenshot.go",
    "content": "package transcoder\n\nimport \"github.com/stashapp/stash/pkg/ffmpeg\"\n\ntype ScreenshotOptions struct {\n\tOutputPath string\n\tOutputType ScreenshotOutputType\n\n\t// Quality is the quality scale. See https://ffmpeg.org/ffmpeg.html#Main-options\n\tQuality int\n\n\t// Width is the width to scale the screenshot to. If 0, no scaling will be applied.\n\tWidth int\n\t// Height is the height to scale the screenshot to. If 0, no scaling will be applied.\n\t// Not used if Width is set.\n\tHeight int\n\n\t// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.\n\tVerbosity ffmpeg.LogLevel\n\n\tUseSelectFilter bool\n}\n\nfunc (o *ScreenshotOptions) setDefaults() {\n\tif o.Verbosity == \"\" {\n\t\to.Verbosity = ffmpeg.LogLevelError\n\t}\n}\n\ntype ScreenshotOutputType struct {\n\tcodec  *ffmpeg.VideoCodec\n\tformat ffmpeg.Format\n}\n\nfunc (t ScreenshotOutputType) Args() []string {\n\tvar ret []string\n\tif t.codec != nil {\n\t\tret = append(ret, t.codec.Args()...)\n\t}\n\tif t.format != \"\" {\n\t\tret = append(ret, t.format.Args()...)\n\t}\n\n\treturn ret\n}\n\nvar (\n\tScreenshotOutputTypeImage2 = ScreenshotOutputType{\n\t\tformat: \"image2\",\n\t}\n\tScreenshotOutputTypeBMP = ScreenshotOutputType{\n\t\tcodec:  &ffmpeg.VideoCodecBMP,\n\t\tformat: \"rawvideo\",\n\t}\n)\n\nfunc ScreenshotTime(input string, t float64, options ScreenshotOptions) ffmpeg.Args {\n\toptions.setDefaults()\n\n\tvar args ffmpeg.Args\n\targs = args.LogLevel(options.Verbosity)\n\targs = args.Overwrite()\n\targs = args.Seek(t)\n\n\targs = args.Input(input)\n\targs = args.VideoFrames(1)\n\n\tif options.Quality > 0 {\n\t\targs = args.FixedQualityScaleVideo(options.Quality)\n\t}\n\n\tvar vf ffmpeg.VideoFilter\n\n\tif options.Width > 0 {\n\t\tvf = vf.ScaleWidth(options.Width)\n\t\targs = args.VideoFilter(vf)\n\t} else if options.Height > 0 {\n\t\tvf = vf.ScaleHeight(options.Height)\n\t\targs = args.VideoFilter(vf)\n\t}\n\n\targs = args.AppendArgs(options.OutputType)\n\targs = args.Output(options.OutputPath)\n\n\treturn args\n}\n\n// ScreenshotFrame uses the select filter to get a single frame from the video.\n// It is very slow and should only be used for files with very small duration in secs / frame count.\nfunc ScreenshotFrame(input string, frame int, options ScreenshotOptions) ffmpeg.Args {\n\toptions.setDefaults()\n\n\tvar args ffmpeg.Args\n\targs = args.LogLevel(options.Verbosity)\n\targs = args.Overwrite()\n\n\targs = args.Input(input)\n\targs = args.VideoFrames(1)\n\n\targs = args.VSync(ffmpeg.VSyncMethodPassthrough)\n\n\tvar vf ffmpeg.VideoFilter\n\t// keep only frame number options.Frame)\n\tvf = vf.Select(frame)\n\n\tif options.Width > 0 {\n\t\tvf = vf.ScaleWidth(options.Width)\n\t}\n\n\targs = args.VideoFilter(vf)\n\n\targs = args.AppendArgs(options.OutputType)\n\targs = args.Output(options.OutputPath)\n\n\treturn args\n}\n"
  },
  {
    "path": "pkg/ffmpeg/transcoder/splice.go",
    "content": "package transcoder\n\nimport (\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n)\n\ntype SpliceOptions struct {\n\tOutputPath string\n\tFormat     ffmpeg.Format\n\n\tVideoCodec *ffmpeg.VideoCodec\n\tVideoArgs  ffmpeg.Args\n\n\tAudioCodec ffmpeg.AudioCodec\n\tAudioArgs  ffmpeg.Args\n\n\t// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.\n\tVerbosity ffmpeg.LogLevel\n}\n\nfunc (o *SpliceOptions) setDefaults() {\n\tif o.Verbosity == \"\" {\n\t\to.Verbosity = ffmpeg.LogLevelError\n\t}\n}\n\n// fixWindowsPath replaces \\ with / in the given path because the \\ isn't recognized as valid on windows ffmpeg\nfunc fixWindowsPath(str string) string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ReplaceAll(str, `\\`, \"/\")\n\t}\n\treturn str\n}\n\nfunc Splice(concatFile string, options SpliceOptions) ffmpeg.Args {\n\toptions.setDefaults()\n\n\tvar args ffmpeg.Args\n\targs = args.LogLevel(options.Verbosity)\n\targs = args.Format(ffmpeg.FormatConcat)\n\targs = args.Input(fixWindowsPath(concatFile))\n\targs = args.Overwrite()\n\n\t// if video codec is not provided, then use copy\n\tif options.VideoCodec == nil {\n\t\toptions.VideoCodec = &ffmpeg.VideoCodecCopy\n\t}\n\n\targs = args.VideoCodec(*options.VideoCodec)\n\targs = args.AppendArgs(options.VideoArgs)\n\n\t// if audio codec is not provided, then use copy\n\tif options.AudioCodec == \"\" {\n\t\toptions.AudioCodec = ffmpeg.AudioCodecCopy\n\t}\n\n\targs = args.AudioCodec(options.AudioCodec)\n\targs = args.AppendArgs(options.AudioArgs)\n\n\targs = args.Format(options.Format)\n\targs = args.Output(options.OutputPath)\n\n\treturn args\n}\n"
  },
  {
    "path": "pkg/ffmpeg/transcoder/transcode.go",
    "content": "package transcoder\n\nimport \"github.com/stashapp/stash/pkg/ffmpeg\"\n\ntype TranscodeOptions struct {\n\tOutputPath string\n\tFormat     ffmpeg.Format\n\n\tVideoCodec ffmpeg.VideoCodec\n\tVideoArgs  ffmpeg.Args\n\n\tAudioCodec ffmpeg.AudioCodec\n\tAudioArgs  ffmpeg.Args\n\n\t// if XError is true, then ffmpeg will fail on warnings\n\tXError bool\n\n\tStartTime float64\n\tSlowSeek  bool\n\tDuration  float64\n\n\t// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.\n\tVerbosity ffmpeg.LogLevel\n\n\t// arguments added before the input argument\n\tExtraInputArgs []string\n\t// arguments added before the output argument\n\tExtraOutputArgs []string\n}\n\nfunc (o *TranscodeOptions) setDefaults() {\n\tif o.Verbosity == \"\" {\n\t\to.Verbosity = ffmpeg.LogLevelError\n\t}\n}\n\nfunc Transcode(input string, options TranscodeOptions) ffmpeg.Args {\n\toptions.setDefaults()\n\n\t// TODO - this should probably be generalised and applied to all operations. Need to verify impact on phash algorithm.\n\tconst fallbackMinSlowSeek = 20.0\n\n\tvar fastSeek float64\n\tvar slowSeek float64\n\n\tif !options.SlowSeek {\n\t\tfastSeek = options.StartTime\n\t\tslowSeek = 0\n\t} else {\n\t\t// In slowseek mode, try a combination of fast/slow seek instead of just fastseek\n\t\t// Commonly with avi/wmv ffmpeg doesn't seem to always predict the right start point to begin decoding when\n\t\t// using fast seek. If you force ffmpeg to decode more, it avoids the \"blocky green artifact\" issue.\n\t\tif options.StartTime > fallbackMinSlowSeek {\n\t\t\t// Handle seeks longer than fallbackMinSlowSeek with fast/slow seeks\n\t\t\t// Allow for at least fallbackMinSlowSeek seconds of slow seek\n\t\t\tfastSeek = options.StartTime - fallbackMinSlowSeek\n\t\t\tslowSeek = fallbackMinSlowSeek\n\t\t} else {\n\t\t\t// Handle seeks shorter than fallbackMinSlowSeek with only slow seeks.\n\t\t\tslowSeek = options.StartTime\n\t\t\tfastSeek = 0\n\t\t}\n\t}\n\n\tvar args ffmpeg.Args\n\targs = args.LogLevel(options.Verbosity).Overwrite()\n\targs = append(args, options.ExtraInputArgs...)\n\n\tif options.XError {\n\t\targs = args.XError()\n\t}\n\n\tif fastSeek > 0 {\n\t\targs = args.Seek(fastSeek)\n\t}\n\n\targs = args.Input(input)\n\n\tif slowSeek > 0 {\n\t\targs = args.Seek(slowSeek)\n\t}\n\n\tif options.Duration > 0 {\n\t\targs = args.Duration(options.Duration)\n\t}\n\n\t// https://trac.ffmpeg.org/ticket/6375\n\targs = args.MaxMuxingQueueSize(1024)\n\n\targs = args.VideoCodec(options.VideoCodec)\n\targs = args.AppendArgs(options.VideoArgs)\n\n\t// if audio codec is not provided, then skip it\n\tif options.AudioCodec == \"\" {\n\t\targs = args.SkipAudio()\n\t} else {\n\t\targs = args.AudioCodec(options.AudioCodec)\n\t}\n\targs = args.AppendArgs(options.AudioArgs)\n\n\targs = append(args, options.ExtraOutputArgs...)\n\n\targs = args.Format(options.Format)\n\targs = args.Output(options.OutputPath)\n\n\treturn args\n}\n"
  },
  {
    "path": "pkg/ffmpeg/types.go",
    "content": "package ffmpeg\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\n// FFProbeJSON is the JSON output of ffprobe.\ntype FFProbeJSON struct {\n\tFormat struct {\n\t\tBitRate        string `json:\"bit_rate\"`\n\t\tDuration       string `json:\"duration\"`\n\t\tFilename       string `json:\"filename\"`\n\t\tFormatLongName string `json:\"format_long_name\"`\n\t\tFormatName     string `json:\"format_name\"`\n\t\tNbPrograms     int    `json:\"nb_programs\"`\n\t\tNbStreams      int    `json:\"nb_streams\"`\n\t\tProbeScore     int    `json:\"probe_score\"`\n\t\tSize           string `json:\"size\"`\n\t\tStartTime      string `json:\"start_time\"`\n\t\tTags           struct {\n\t\t\tCompatibleBrands string        `json:\"compatible_brands\"`\n\t\t\tCreationTime     json.JSONTime `json:\"creation_time\"`\n\t\t\tEncoder          string        `json:\"encoder\"`\n\t\t\tMajorBrand       string        `json:\"major_brand\"`\n\t\t\tMinorVersion     string        `json:\"minor_version\"`\n\t\t\tTitle            string        `json:\"title\"`\n\t\t\tComment          string        `json:\"comment\"`\n\t\t} `json:\"tags\"`\n\t} `json:\"format\"`\n\tStreams []FFProbeStream `json:\"streams\"`\n\tError   struct {\n\t\tCode   int    `json:\"code\"`\n\t\tString string `json:\"string\"`\n\t} `json:\"error\"`\n}\n\n// FFProbeStream is a JSON representation of an ffmpeg stream.\ntype FFProbeStream struct {\n\tAvgFrameRate       string `json:\"avg_frame_rate\"`\n\tBitRate            string `json:\"bit_rate\"`\n\tBitsPerRawSample   string `json:\"bits_per_raw_sample,omitempty\"`\n\tChromaLocation     string `json:\"chroma_location,omitempty\"`\n\tCodecLongName      string `json:\"codec_long_name\"`\n\tCodecName          string `json:\"codec_name\"`\n\tCodecTag           string `json:\"codec_tag\"`\n\tCodecTagString     string `json:\"codec_tag_string\"`\n\tCodecTimeBase      string `json:\"codec_time_base\"`\n\tCodecType          string `json:\"codec_type\"`\n\tCodedHeight        int    `json:\"coded_height,omitempty\"`\n\tCodedWidth         int    `json:\"coded_width,omitempty\"`\n\tDisplayAspectRatio string `json:\"display_aspect_ratio,omitempty\"`\n\tDisposition        struct {\n\t\tAttachedPic     int `json:\"attached_pic\"`\n\t\tCleanEffects    int `json:\"clean_effects\"`\n\t\tComment         int `json:\"comment\"`\n\t\tDefault         int `json:\"default\"`\n\t\tDub             int `json:\"dub\"`\n\t\tForced          int `json:\"forced\"`\n\t\tHearingImpaired int `json:\"hearing_impaired\"`\n\t\tKaraoke         int `json:\"karaoke\"`\n\t\tLyrics          int `json:\"lyrics\"`\n\t\tOriginal        int `json:\"original\"`\n\t\tTimedThumbnails int `json:\"timed_thumbnails\"`\n\t\tVisualImpaired  int `json:\"visual_impaired\"`\n\t} `json:\"disposition\"`\n\tDuration          string `json:\"duration\"`\n\tDurationTs        int64  `json:\"duration_ts\"`\n\tHasBFrames        int    `json:\"has_b_frames,omitempty\"`\n\tHeight            int    `json:\"height,omitempty\"`\n\tIndex             int    `json:\"index\"`\n\tIsAvc             string `json:\"is_avc,omitempty\"`\n\tLevel             int    `json:\"level,omitempty\"`\n\tNalLengthSize     string `json:\"nal_length_size,omitempty\"`\n\tNbFrames          string `json:\"nb_frames\"`\n\tNbReadFrames      string `json:\"nb_read_frames\"`\n\tPixFmt            string `json:\"pix_fmt,omitempty\"`\n\tProfile           string `json:\"profile\"`\n\tRFrameRate        string `json:\"r_frame_rate\"`\n\tRefs              int    `json:\"refs,omitempty\"`\n\tSampleAspectRatio string `json:\"sample_aspect_ratio,omitempty\"`\n\tStartPts          int64  `json:\"start_pts\"`\n\tStartTime         string `json:\"start_time\"`\n\tTags              struct {\n\t\tCreationTime json.JSONTime `json:\"creation_time\"`\n\t\tHandlerName  string        `json:\"handler_name\"`\n\t\tLanguage     string        `json:\"language\"`\n\t\tRotate       string        `json:\"rotate\"`\n\t} `json:\"tags\"`\n\tTimeBase      string `json:\"time_base\"`\n\tWidth         int    `json:\"width,omitempty\"`\n\tBitsPerSample int    `json:\"bits_per_sample,omitempty\"`\n\tChannelLayout string `json:\"channel_layout,omitempty\"`\n\tChannels      int    `json:\"channels,omitempty\"`\n\tMaxBitRate    string `json:\"max_bit_rate,omitempty\"`\n\tSampleFmt     string `json:\"sample_fmt,omitempty\"`\n\tSampleRate    string `json:\"sample_rate,omitempty\"`\n\tSideDataList  []struct {\n\t\tRotation int `json:\"rotation\"`\n\t} `json:\"side_data_list\"`\n}\n"
  },
  {
    "path": "pkg/file/clean.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// Cleaner scans through stored file and folder instances and removes those that are no longer present on disk.\ntype Cleaner struct {\n\tFS         models.FS\n\tRepository Repository\n\n\tHandlers  []CleanHandler\n\tTrashPath string\n}\n\ntype cleanJob struct {\n\t*Cleaner\n\n\tprogress *job.Progress\n\toptions  CleanOptions\n}\n\n// CleanOptions provides options for scanning files.\ntype CleanOptions struct {\n\tPaths []string\n\n\t// IgnoreZipFileContents will skip checking the contents of zip files when determining whether to clean a file.\n\t// This can significantly speed up the clean process, but will potentially miss removed files within zip files.\n\t// Where users do not modify zip files contents directly, this should be safe to use.\n\tIgnoreZipFileContents bool\n\n\t// Do a dry run. Don't delete any files\n\tDryRun bool\n\n\t// PathFilter are used to determine if a file should be included.\n\t// Excluded files are marked for cleaning.\n\tPathFilter PathFilter\n}\n\n// Clean starts the clean process.\nfunc (s *Cleaner) Clean(ctx context.Context, options CleanOptions, progress *job.Progress) {\n\tj := &cleanJob{\n\t\tCleaner:  s,\n\t\tprogress: progress,\n\t\toptions:  options,\n\t}\n\n\tif err := j.execute(ctx); err != nil {\n\t\tlogger.Errorf(\"error cleaning files: %v\", err)\n\t\treturn\n\t}\n}\n\ntype fileOrFolder struct {\n\tfileID   models.FileID\n\tfolderID models.FolderID\n}\n\ntype deleteSet struct {\n\torderedList []fileOrFolder\n\tfileIDSet   map[models.FileID]string\n\n\tfolderIDSet map[models.FolderID]string\n}\n\nfunc newDeleteSet() deleteSet {\n\treturn deleteSet{\n\t\tfileIDSet:   make(map[models.FileID]string),\n\t\tfolderIDSet: make(map[models.FolderID]string),\n\t}\n}\n\nfunc (s *deleteSet) add(id models.FileID, path string) {\n\tif _, ok := s.fileIDSet[id]; !ok {\n\t\ts.orderedList = append(s.orderedList, fileOrFolder{fileID: id})\n\t\ts.fileIDSet[id] = path\n\t}\n}\n\nfunc (s *deleteSet) has(id models.FileID) bool {\n\t_, ok := s.fileIDSet[id]\n\treturn ok\n}\n\nfunc (s *deleteSet) addFolder(id models.FolderID, path string) {\n\tif _, ok := s.folderIDSet[id]; !ok {\n\t\ts.orderedList = append(s.orderedList, fileOrFolder{folderID: id})\n\t\ts.folderIDSet[id] = path\n\t}\n}\n\nfunc (s *deleteSet) hasFolder(id models.FolderID) bool {\n\t_, ok := s.folderIDSet[id]\n\treturn ok\n}\n\nfunc (s *deleteSet) len() int {\n\treturn len(s.orderedList)\n}\n\nfunc (j *cleanJob) execute(ctx context.Context) error {\n\tprogress := j.progress\n\n\ttoDelete := newDeleteSet()\n\n\tvar (\n\t\tfileCount   int\n\t\tfolderCount int\n\t)\n\n\tr := j.Repository\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tvar err error\n\t\tfileCount, err = r.File.CountAllInPaths(ctx, j.options.Paths)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfolderCount, err = r.Folder.CountAllInPaths(ctx, j.options.Paths)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tprogress.AddTotal(fileCount + folderCount)\n\tprogress.Definite()\n\n\tif err := j.assessFiles(ctx, &toDelete); err != nil {\n\t\treturn err\n\t}\n\n\tif err := j.assessFolders(ctx, &toDelete); err != nil {\n\t\treturn err\n\t}\n\n\tif j.options.DryRun && toDelete.len() > 0 {\n\t\t// add progress for files that would've been deleted\n\t\tprogress.AddProcessed(toDelete.len())\n\t\treturn nil\n\t}\n\n\tprogress.ExecuteTask(fmt.Sprintf(\"Cleaning %d files and folders\", toDelete.len()), func() {\n\t\tfor _, ff := range toDelete.orderedList {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif ff.fileID != 0 {\n\t\t\t\tj.deleteFile(ctx, ff.fileID, toDelete.fileIDSet[ff.fileID])\n\t\t\t}\n\t\t\tif ff.folderID != 0 {\n\t\t\t\tj.deleteFolder(ctx, ff.folderID, toDelete.folderIDSet[ff.folderID])\n\t\t\t}\n\n\t\t\tprogress.Increment()\n\t\t}\n\t})\n\n\treturn nil\n}\n\nfunc (j *cleanJob) assessFiles(ctx context.Context, toDelete *deleteSet) error {\n\tconst batchSize = 1000\n\toffset := 0\n\tprogress := j.progress\n\n\tmore := true\n\tr := j.Repository\n\n\tincludeZipContents := !j.options.IgnoreZipFileContents\n\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tfor more {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tfiles, err := r.File.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error querying for files: %w\", err)\n\t\t\t}\n\n\t\t\tfor _, f := range files {\n\t\t\t\tpath := f.Base().Path\n\t\t\t\terr = nil\n\t\t\t\tfileID := f.Base().ID\n\n\t\t\t\t// short-cut, don't assess if already added\n\t\t\t\tif toDelete.has(fileID) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tprogress.ExecuteTask(fmt.Sprintf(\"Assessing file %s for clean\", path), func() {\n\t\t\t\t\tif j.shouldClean(ctx, f) {\n\t\t\t\t\t\terr = j.flagFileForDelete(ctx, toDelete, f)\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// increment progress, no further processing\n\t\t\t\t\t\tprogress.Increment()\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(files) != batchSize {\n\t\t\t\tmore = false\n\t\t\t} else {\n\t\t\t\toffset += batchSize\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// flagFolderForDelete adds folders to the toDelete set, with the leaf folders added first\nfunc (j *cleanJob) flagFileForDelete(ctx context.Context, toDelete *deleteSet, f models.File) error {\n\tr := j.Repository\n\t// add contained files first\n\tcontainedFiles, err := r.File.FindByZipFileID(ctx, f.Base().ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error finding contained files for %q: %w\", f.Base().Path, err)\n\t}\n\n\tfor _, cf := range containedFiles {\n\t\tlogger.Infof(\"Marking contained file %q to clean\", cf.Base().Path)\n\t\ttoDelete.add(cf.Base().ID, cf.Base().Path)\n\t}\n\n\t// add contained folders as well\n\tcontainedFolders, err := r.Folder.FindByZipFileID(ctx, f.Base().ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error finding contained folders for %q: %w\", f.Base().Path, err)\n\t}\n\n\tfor _, cf := range containedFolders {\n\t\tlogger.Infof(\"Marking contained folder %q to clean\", cf.Path)\n\t\ttoDelete.addFolder(cf.ID, cf.Path)\n\t}\n\n\ttoDelete.add(f.Base().ID, f.Base().Path)\n\n\treturn nil\n}\n\nfunc (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error {\n\tconst batchSize = 1000\n\toffset := 0\n\tprogress := j.progress\n\n\tincludeZipContents := !j.options.IgnoreZipFileContents\n\n\tmore := true\n\tr := j.Repository\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tfor more {\n\t\t\tif job.IsCancelled(ctx) {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tfolders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error querying for folders: %w\", err)\n\t\t\t}\n\n\t\t\tfor _, f := range folders {\n\t\t\t\tpath := f.Path\n\t\t\t\tfolderID := f.ID\n\n\t\t\t\t// short-cut, don't assess if already added\n\t\t\t\tif toDelete.hasFolder(folderID) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\terr = nil\n\t\t\t\tprogress.ExecuteTask(fmt.Sprintf(\"Assessing folder %s for clean\", path), func() {\n\t\t\t\t\tif j.shouldCleanFolder(ctx, f) {\n\t\t\t\t\t\tif err = j.flagFolderForDelete(ctx, toDelete, f); err != nil {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// increment progress, no further processing\n\t\t\t\t\t\tprogress.Increment()\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(folders) != batchSize {\n\t\t\t\tmore = false\n\t\t\t} else {\n\t\t\t\toffset += batchSize\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (j *cleanJob) flagFolderForDelete(ctx context.Context, toDelete *deleteSet, folder *models.Folder) error {\n\t// it is possible that child folders may be included while parent folders are not\n\t// so we need to check child folders separately\n\ttoDelete.addFolder(folder.ID, folder.Path)\n\n\treturn nil\n}\n\nfunc isNotFound(err error) bool {\n\t// ErrInvalid can occur in zip files where the zip file path changed\n\t// and the underlying folder did not\n\t// #3877 - fs.PathError can occur if the network share no longer exists\n\tvar pathErr *fs.PathError\n\treturn err != nil &&\n\t\t(errors.Is(err, fs.ErrNotExist) ||\n\t\t\terrors.Is(err, fs.ErrInvalid) ||\n\t\t\terrors.As(err, &pathErr))\n}\n\nfunc (j *cleanJob) shouldClean(ctx context.Context, f models.File) bool {\n\tpath := f.Base().Path\n\n\tinfo, err := f.Base().Info(j.FS)\n\tif err != nil && !isNotFound(err) {\n\t\tlogger.Errorf(\"error getting file info for %q, not cleaning: %v\", path, err)\n\t\treturn false\n\t}\n\n\tif info == nil {\n\t\t// info is nil - file not exist\n\t\tlogger.Infof(\"File not found. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\t// run through path filter, if returns false then the file should be cleaned\n\tfilter := j.options.PathFilter\n\n\t// need to get the zip file path if present\n\tzipFilePath := \"\"\n\tif f.Base().ZipFile != nil {\n\t\tzipFilePath = f.Base().ZipFile.Base().Path\n\t}\n\n\t// don't log anything - assume filter will have logged the reason\n\treturn !filter.Accept(ctx, path, info, zipFilePath)\n}\n\nfunc (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool {\n\tpath := f.Path\n\n\tinfo, err := f.Info(j.FS)\n\n\tif err != nil && !isNotFound(err) {\n\t\tlogger.Errorf(\"error getting folder info for %q, not cleaning: %v\", path, err)\n\t\treturn false\n\t}\n\n\tif info == nil {\n\t\t// info is nil - file not exist\n\t\tlogger.Infof(\"Folder not found. Marking to clean: \\\"%s\\\"\", path)\n\t\treturn true\n\t}\n\n\t// #3261 - handle symlinks\n\tif info.Mode()&os.ModeSymlink == os.ModeSymlink {\n\t\tfinalPath, err := filepath.EvalSymlinks(path)\n\t\tif err != nil {\n\t\t\t// don't bail out if symlink is invalid\n\t\t\tlogger.Infof(\"Invalid symlink. Marking to clean: \\\"%s\\\"\", path)\n\t\t\treturn true\n\t\t}\n\n\t\tinfo, err = j.FS.Lstat(finalPath)\n\t\tif err != nil && !isNotFound(err) {\n\t\t\tlogger.Errorf(\"error getting file info for %q (-> %s), not cleaning: %v\", path, finalPath, err)\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// run through path filter, if returns false then the file should be cleaned\n\tfilter := j.options.PathFilter\n\n\t// need to get the zip file path if present\n\tzipFilePath := \"\"\n\tif f.ZipFile != nil {\n\t\tzipFilePath = f.ZipFile.Base().Path\n\t}\n\n\t// don't log anything - assume filter will have logged the reason\n\treturn !filter.Accept(ctx, path, info, zipFilePath)\n}\n\nfunc (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) {\n\t// delete associated objects\n\tfileDeleter := NewDeleterWithTrash(j.TrashPath)\n\tr := j.Repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tfileDeleter.RegisterHooks(ctx)\n\n\t\tif err := j.fireHandlers(ctx, fileDeleter, fileID); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn r.File.Destroy(ctx, fileID)\n\t}); err != nil {\n\t\tlogger.Errorf(\"Error deleting file %q from database: %s\", fn, err.Error())\n\t\treturn\n\t}\n}\n\nfunc (j *cleanJob) deleteFolder(ctx context.Context, folderID models.FolderID, fn string) {\n\t// delete associated objects\n\tfileDeleter := NewDeleterWithTrash(j.TrashPath)\n\tr := j.Repository\n\tif err := r.WithTxn(ctx, func(ctx context.Context) error {\n\t\tfileDeleter.RegisterHooks(ctx)\n\n\t\tif err := j.fireFolderHandlers(ctx, fileDeleter, folderID); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn r.Folder.Destroy(ctx, folderID)\n\t}); err != nil {\n\t\tlogger.Errorf(\"Error deleting folder %q from database: %s\", fn, err.Error())\n\t\treturn\n\t}\n}\n\nfunc (j *cleanJob) fireHandlers(ctx context.Context, fileDeleter *Deleter, fileID models.FileID) error {\n\tfor _, h := range j.Handlers {\n\t\tif err := h.HandleFile(ctx, fileDeleter, fileID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (j *cleanJob) fireFolderHandlers(ctx context.Context, fileDeleter *Deleter, folderID models.FolderID) error {\n\tfor _, h := range j.Handlers {\n\t\tif err := h.HandleFolder(ctx, fileDeleter, folderID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/file/delete.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\nconst deleteFileSuffix = \".delete\"\n\n// RenamerRemover provides access to the Rename and Remove functions.\ntype RenamerRemover interface {\n\tRenamer\n\tRemove(name string) error\n\tRemoveAll(path string) error\n\tStatter\n}\n\ntype renamerRemoverImpl struct {\n\tRenameFn    func(oldpath, newpath string) error\n\tRemoveFn    func(name string) error\n\tRemoveAllFn func(path string) error\n\tStatFn      func(path string) (fs.FileInfo, error)\n}\n\nfunc (r renamerRemoverImpl) Rename(oldpath, newpath string) error {\n\treturn r.RenameFn(oldpath, newpath)\n}\n\nfunc (r renamerRemoverImpl) Remove(name string) error {\n\treturn r.RemoveFn(name)\n}\n\nfunc (r renamerRemoverImpl) RemoveAll(path string) error {\n\treturn r.RemoveAllFn(path)\n}\n\nfunc (r renamerRemoverImpl) Stat(path string) (fs.FileInfo, error) {\n\treturn r.StatFn(path)\n}\n\nfunc newRenamerRemoverImpl() renamerRemoverImpl {\n\treturn renamerRemoverImpl{\n\t\t// use fsutil.SafeMove to support cross-device moves\n\t\tRenameFn:    fsutil.SafeMove,\n\t\tRemoveFn:    os.Remove,\n\t\tRemoveAllFn: os.RemoveAll,\n\t\tStatFn:      os.Stat,\n\t}\n}\n\n// Deleter is used to safely delete files and directories from the filesystem.\n// During a transaction, files and directories are marked for deletion using\n// the Files and Dirs methods. If TrashPath is set, files are moved to trash\n// immediately. Otherwise, they are renamed with a .delete suffix. If the\n// transaction is rolled back, then the files/directories can be restored to\n// their original state with the Rollback method. If the transaction is\n// committed, the marked files are then deleted from the filesystem using the\n// Commit method.\ntype Deleter struct {\n\tRenamerRemover RenamerRemover\n\tfiles          []string\n\tdirs           []string\n\tTrashPath      string            // if set, files will be moved to this directory instead of being permanently deleted\n\ttrashedPaths   map[string]string // map of original path -> trash path (only used when TrashPath is set)\n}\n\nfunc NewDeleter() *Deleter {\n\treturn &Deleter{\n\t\tRenamerRemover: newRenamerRemoverImpl(),\n\t\tTrashPath:      \"\",\n\t\ttrashedPaths:   make(map[string]string),\n\t}\n}\n\nfunc NewDeleterWithTrash(trashPath string) *Deleter {\n\treturn &Deleter{\n\t\tRenamerRemover: newRenamerRemoverImpl(),\n\t\tTrashPath:      trashPath,\n\t\ttrashedPaths:   make(map[string]string),\n\t}\n}\n\n// RegisterHooks registers post-commit and post-rollback hooks.\nfunc (d *Deleter) RegisterHooks(ctx context.Context) {\n\ttxn.AddPostCommitHook(ctx, func(ctx context.Context) {\n\t\td.Commit()\n\t})\n\n\ttxn.AddPostRollbackHook(ctx, func(ctx context.Context) {\n\t\td.Rollback()\n\t})\n}\n\n// Files designates files to be deleted. Each file marked will be renamed to add\n// a `.delete` suffix. An error is returned if a file could not be renamed.\n// Note that if an error is returned, then some files may be left renamed.\n// Abort should be called to restore marked files if this function returns an\n// error.\nfunc (d *Deleter) Files(paths []string) error {\n\treturn d.filesInternal(paths, false)\n}\n\n// FilesWithoutTrash designates files to be deleted, bypassing the trash directory.\n// Files will be permanently deleted even if TrashPath is configured.\n// This is useful for deleting generated files that can be easily recreated.\nfunc (d *Deleter) FilesWithoutTrash(paths []string) error {\n\treturn d.filesInternal(paths, true)\n}\n\nfunc (d *Deleter) filesInternal(paths []string, bypassTrash bool) error {\n\tfor _, p := range paths {\n\t\t// fail silently if the file does not exist\n\t\tif _, err := d.RenamerRemover.Stat(p); err != nil {\n\t\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\t\tlogger.Warnf(\"File %q does not exist and therefore cannot be deleted. Ignoring.\", p)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treturn fmt.Errorf(\"check file %q exists: %w\", p, err)\n\t\t}\n\n\t\tif err := d.renameForDelete(p, bypassTrash); err != nil {\n\t\t\treturn fmt.Errorf(\"marking file %q for deletion: %w\", p, err)\n\t\t}\n\t\td.files = append(d.files, p)\n\t}\n\n\treturn nil\n}\n\n// Dirs designates directories to be deleted. Each directory marked will be renamed to add\n// a `.delete` suffix. An error is returned if a directory could not be renamed.\n// Note that if an error is returned, then some directories may be left renamed.\n// Abort should be called to restore marked files/directories if this function returns an\n// error.\nfunc (d *Deleter) Dirs(paths []string) error {\n\treturn d.dirsInternal(paths, false)\n}\n\n// DirsWithoutTrash designates directories to be deleted, bypassing the trash directory.\n// Directories will be permanently deleted even if TrashPath is configured.\n// This is useful for deleting generated directories that can be easily recreated.\nfunc (d *Deleter) DirsWithoutTrash(paths []string) error {\n\treturn d.dirsInternal(paths, true)\n}\n\nfunc (d *Deleter) dirsInternal(paths []string, bypassTrash bool) error {\n\tfor _, p := range paths {\n\t\t// fail silently if the file does not exist\n\t\tif _, err := d.RenamerRemover.Stat(p); err != nil {\n\t\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\t\tlogger.Warnf(\"Directory %q does not exist and therefore cannot be deleted. Ignoring.\", p)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treturn fmt.Errorf(\"check directory %q exists: %w\", p, err)\n\t\t}\n\n\t\tif err := d.renameForDelete(p, bypassTrash); err != nil {\n\t\t\treturn fmt.Errorf(\"marking directory %q for deletion: %w\", p, err)\n\t\t}\n\t\td.dirs = append(d.dirs, p)\n\t}\n\n\treturn nil\n}\n\n// Rollback tries to rename all marked files and directories back to their\n// original names and clears the marked list. Any errors encountered are\n// logged. All files will be attempted regardless of any errors occurred.\nfunc (d *Deleter) Rollback() {\n\tfor _, f := range append(d.files, d.dirs...) {\n\t\tif err := d.renameForRestore(f); err != nil {\n\t\t\tlogger.Warnf(\"Error restoring %q: %v\", f, err)\n\t\t}\n\t}\n\n\td.files = nil\n\td.dirs = nil\n\td.trashedPaths = make(map[string]string)\n}\n\n// Commit deletes all files marked for deletion and clears the marked list.\n// When using trash, files have already been moved during renameForDelete, so\n// this just clears the tracking. Otherwise, permanently delete the .delete files.\n// Any errors encountered are logged. All files will be attempted, regardless\n// of the errors encountered.\nfunc (d *Deleter) Commit() {\n\tif d.TrashPath != \"\" {\n\t\t// Files were already moved to trash during renameForDelete, just clear tracking\n\t\tlogger.Debugf(\"Commit: %d files and %d directories already in trash, clearing tracking\", len(d.files), len(d.dirs))\n\t} else {\n\t\t// Permanently delete files and directories marked with .delete suffix\n\t\tfor _, f := range d.files {\n\t\t\tif err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {\n\t\t\t\tlogger.Warnf(\"Error deleting file %q: %v\", f+deleteFileSuffix, err)\n\t\t\t}\n\t\t}\n\n\t\tfor _, f := range d.dirs {\n\t\t\tif err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil {\n\t\t\t\tlogger.Warnf(\"Error deleting directory %q: %v\", f+deleteFileSuffix, err)\n\t\t\t}\n\t\t}\n\t}\n\n\td.files = nil\n\td.dirs = nil\n\td.trashedPaths = make(map[string]string)\n}\n\nfunc (d *Deleter) renameForDelete(path string, bypassTrash bool) error {\n\tif d.TrashPath != \"\" && !bypassTrash {\n\t\t// Move file to trash immediately\n\t\ttrashDest, err := fsutil.MoveToTrash(path, d.TrashPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\td.trashedPaths[path] = trashDest\n\t\tlogger.Infof(\"Moved %q to trash at %s\", path, trashDest)\n\t\treturn nil\n\t}\n\n\t// Standard behavior: rename with .delete suffix (or when bypassing trash)\n\treturn d.RenamerRemover.Rename(path, path+deleteFileSuffix)\n}\n\nfunc (d *Deleter) renameForRestore(path string) error {\n\tif d.TrashPath != \"\" {\n\t\t// Restore file from trash\n\t\ttrashPath, ok := d.trashedPaths[path]\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"no trash path found for %q\", path)\n\t\t}\n\t\treturn d.RenamerRemover.Rename(trashPath, path)\n\t}\n\n\t// Standard behavior: restore from .delete suffix\n\treturn d.RenamerRemover.Rename(path+deleteFileSuffix, path)\n}\n\nfunc Destroy(ctx context.Context, destroyer models.FileDestroyer, f models.File, fileDeleter *Deleter, deleteFile bool) error {\n\tif err := destroyer.Destroy(ctx, f.Base().ID); err != nil {\n\t\treturn err\n\t}\n\n\t// don't delete files in zip files\n\tif deleteFile && f.Base().ZipFileID == nil {\n\t\tif err := fileDeleter.Files([]string{f.Base().Path}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype ZipDestroyer struct {\n\tFileDestroyer   models.FileFinderDestroyer\n\tFolderDestroyer models.FolderFinderDestroyer\n}\n\nfunc (d *ZipDestroyer) DestroyZip(ctx context.Context, f models.File, fileDeleter *Deleter, deleteFile bool) error {\n\t// destroy contained files\n\tfiles, err := d.FileDestroyer.FindByZipFileID(ctx, f.Base().ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, ff := range files {\n\t\tif err := d.FileDestroyer.Destroy(ctx, ff.Base().ID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// destroy contained folders\n\tfolders, err := d.FolderDestroyer.FindByZipFileID(ctx, f.Base().ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, ff := range folders {\n\t\tif err := d.FolderDestroyer.Destroy(ctx, ff.ID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := d.FileDestroyer.Destroy(ctx, f.Base().ID); err != nil {\n\t\treturn err\n\t}\n\n\tif deleteFile {\n\t\tif err := fileDeleter.Files([]string{f.Base().Path}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/file/file.go",
    "content": "// Package file provides functionality for managing, scanning and cleaning files and folders.\npackage file\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\n// Repository provides access to storage methods for files and folders.\ntype Repository struct {\n\tTxnManager models.TxnManager\n\n\tFile   models.FileReaderWriter\n\tFolder models.FolderReaderWriter\n}\n\nfunc NewRepository(repo models.Repository) Repository {\n\treturn Repository{\n\t\tTxnManager: repo.TxnManager,\n\t\tFile:       repo.File,\n\t\tFolder:     repo.Folder,\n\t}\n}\n\nfunc (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error {\n\treturn txn.WithTxn(ctx, r.TxnManager, fn)\n}\n\nfunc (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error {\n\treturn txn.WithReadTxn(ctx, r.TxnManager, fn)\n}\n\nfunc (r *Repository) WithDB(ctx context.Context, fn txn.TxnFunc) error {\n\treturn txn.WithDatabase(ctx, r.TxnManager, fn)\n}\n\n// ModTime returns the modification time truncated to seconds.\nfunc ModTime(info fs.FileInfo) time.Time {\n\t// truncate to seconds, since we don't store beyond that in the database\n\treturn info.ModTime().Truncate(time.Second)\n}\n\n// GetFileSize gets the size of the file, taking into account symlinks.\nfunc GetFileSize(f models.FS, path string, info fs.FileInfo) (int64, error) {\n\t// #2196/#3042 - replace size with target size if file is a symlink\n\tif info.Mode()&os.ModeSymlink == os.ModeSymlink {\n\t\ttargetInfo, err := f.Stat(path)\n\t\tif err != nil {\n\t\t\treturn 0, fmt.Errorf(\"reading info for symlink %q: %w\", path, err)\n\t\t}\n\t\treturn targetInfo.Size(), nil\n\t}\n\n\treturn info.Size(), nil\n}\n"
  },
  {
    "path": "pkg/file/folder.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// GetOrCreateFolderHierarchy gets the folder for the given path, or creates a folder hierarchy for the given path if one if no existing folder is found.\n// Creates folder entries for each level of the hierarchy that doesn't already exist, up to the provided root paths.\n// Does not create any folders in the file system.\nfunc GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string, rootPaths []string) (*models.Folder, error) {\n\t// get or create folder hierarchy\n\t// assume case sensitive when searching for the folder\n\tconst caseSensitive = true\n\tfolder, err := fc.FindByPath(ctx, path, caseSensitive)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif folder == nil {\n\t\tvar parentID *models.FolderID\n\n\t\tif !slices.Contains(rootPaths, path) {\n\t\t\tparentPath := filepath.Dir(path)\n\n\t\t\t// safety check - don't allow parent path to be the same as the current path,\n\t\t\t// otherwise we could end up in an infinite loop\n\t\t\tif parentPath == path {\n\t\t\t\t// #6618 - log a warning and return nil for the parent ID,\n\t\t\t\t// which will cause the folder to be created with no parent\n\t\t\t\tlogger.Warnf(\"parent path is the same as the current path: %s\", path)\n\t\t\t\treturn nil, nil\n\t\t\t}\n\n\t\t\tparent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tparentID = &parent.ID\n\t\t}\n\n\t\tnow := time.Now()\n\n\t\tfolder = &models.Folder{\n\t\t\tPath:           path,\n\t\t\tParentFolderID: parentID,\n\t\t\tDirEntry:       models.DirEntry{\n\t\t\t\t// leave mod time empty for now - it will be updated when the folder is scanned\n\t\t\t},\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now,\n\t\t}\n\n\t\tlogger.Infof(\"%s doesn't exist. Creating new folder entry...\", path)\n\n\t\tif err = fc.Create(ctx, folder); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"creating folder %s: %w\", path, err)\n\t\t}\n\t}\n\n\treturn folder, nil\n}\n\ntype zipHierarchyMover struct {\n\tfolderStore models.FolderReaderWriter\n\tfiles       models.FileFinderUpdater\n\trootPaths   []string\n}\n\nfunc (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {\n\tif err := m.transferZipFolderHierarchy(ctx, zipFileID, oldPath, newPath); err != nil {\n\t\treturn fmt.Errorf(\"moving folder hierarchy for file %s: %w\", oldPath, err)\n\t}\n\n\tif err := m.transferZipFileEntries(ctx, zipFileID, oldPath, newPath); err != nil {\n\t\treturn fmt.Errorf(\"moving zip file contents for file %s: %w\", oldPath, err)\n\t}\n\n\treturn nil\n}\n\n// transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes\n// ZipFileID from folders under oldPath.\nfunc (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {\n\tzipFolders, err := m.folderStore.FindByZipFileID(ctx, zipFileID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, oldFolder := range zipFolders {\n\t\toldZfPath := oldFolder.Path\n\n\t\t// sanity check - ignore folders which aren't under oldPath\n\t\tif !strings.HasPrefix(oldZfPath, oldPath) {\n\t\t\tcontinue\n\t\t}\n\n\t\trelZfPath, err := filepath.Rel(oldPath, oldZfPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tnewZfPath := filepath.Join(newPath, relZfPath)\n\n\t\tnewFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfPath, m.rootPaths)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// add ZipFileID to new folder\n\t\tlogger.Debugf(\"adding zip file %s to folder %s\", zipFileID, newFolder.Path)\n\t\tnewFolder.ZipFileID = &zipFileID\n\t\tif err = m.folderStore.Update(ctx, newFolder); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// remove ZipFileID from old folder\n\t\tlogger.Debugf(\"removing zip file %s from folder %s\", zipFileID, oldFolder.Path)\n\t\toldFolder.ZipFileID = nil\n\t\tif err = m.folderStore.Update(ctx, oldFolder); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID models.FileID, oldPath, newPath string) error {\n\t// move contained files if file is a zip file\n\tzipFiles, err := m.files.FindByZipFileID(ctx, zipFileID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding contained files in file %s: %w\", oldPath, err)\n\t}\n\tfor _, zf := range zipFiles {\n\t\tzfBase := zf.Base()\n\t\toldZfPath := zfBase.Path\n\t\toldZfDir := filepath.Dir(oldZfPath)\n\n\t\t// sanity check - ignore files which aren't under oldPath\n\t\tif !strings.HasPrefix(oldZfPath, oldPath) {\n\t\t\tcontinue\n\t\t}\n\n\t\trelZfDir, err := filepath.Rel(oldPath, oldZfDir)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"moving contained file %s: %w\", zfBase.ID, err)\n\t\t}\n\t\tnewZfDir := filepath.Join(newPath, relZfDir)\n\n\t\t// folder should have been created by transferZipFolderHierarchy\n\t\tnewZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfDir, m.rootPaths)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting or creating folder hierarchy: %w\", err)\n\t\t}\n\n\t\t// update file parent folder\n\t\tzfBase.ParentFolderID = newZfFolder.ID\n\t\tlogger.Debugf(\"moving %s to folder %s\", zfBase.Path, newZfFolder.Path)\n\t\tif err := m.files.Update(ctx, zf); err != nil {\n\t\t\treturn fmt.Errorf(\"updating file %s: %w\", oldZfPath, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/file/folder_rename_detect.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype folderRenameCandidate struct {\n\tfolder *models.Folder\n\tfound  int\n\tfiles  int\n}\n\ntype folderRenameDetector struct {\n\t// candidates is a map of folder id to the number of files that match\n\tcandidates map[models.FolderID]folderRenameCandidate\n\t// rejects is a set of folder ids which were found to still exist\n\trejects map[models.FolderID]struct{}\n}\n\nfunc (d *folderRenameDetector) isReject(id models.FolderID) bool {\n\t_, ok := d.rejects[id]\n\treturn ok\n}\n\nfunc (d *folderRenameDetector) getCandidate(id models.FolderID) *folderRenameCandidate {\n\tc, ok := d.candidates[id]\n\tif !ok {\n\t\treturn nil\n\t}\n\n\treturn &c\n}\n\nfunc (d *folderRenameDetector) setCandidate(c folderRenameCandidate) {\n\td.candidates[c.folder.ID] = c\n}\n\nfunc (d *folderRenameDetector) reject(id models.FolderID) {\n\td.rejects[id] = struct{}{}\n}\n\n// bestCandidate returns the folder that is the best candidate for a rename.\n// This is the folder that has the largest number of its original files that\n// are still present in the new location.\nfunc (d *folderRenameDetector) bestCandidate() *models.Folder {\n\tif len(d.candidates) == 0 {\n\t\treturn nil\n\t}\n\n\tvar best *folderRenameCandidate\n\n\tfor _, c := range d.candidates {\n\t\t// ignore folders that have less than 50% of their original files\n\t\tif c.found < c.files/2 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// prefer the folder with the most files if the ratio is the same\n\t\tif best == nil || c.found > best.found {\n\t\t\tcc := c\n\t\t\tbest = &cc\n\t\t}\n\t}\n\n\tif best == nil {\n\t\treturn nil\n\t}\n\n\treturn best.folder\n}\n\nfunc (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*models.Folder, error) {\n\t// in order for a folder to be considered moved, the existing folder must be\n\t// missing, and the majority of the old folder's files must be present, unchanged,\n\t// in the new folder.\n\n\tdetector := folderRenameDetector{\n\t\tcandidates: make(map[models.FolderID]folderRenameCandidate),\n\t\trejects:    make(map[models.FolderID]struct{}),\n\t}\n\t// rejects is a set of folder ids which were found to still exist\n\n\tr := s.Repository\n\n\tzipFilePath := \"\"\n\tif file.ZipFile != nil {\n\t\tzipFilePath = file.ZipFile.Base().Path\n\t}\n\n\tif err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\t// don't let errors prevent scanning\n\t\t\tlogger.Errorf(\"error scanning %s: %v\", path, err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// ignore root\n\t\tif path == file.Path {\n\t\t\treturn nil\n\t\t}\n\n\t\t// ignore directories\n\t\tif d.IsDir() {\n\t\t\treturn fs.SkipDir\n\t\t}\n\n\t\tinfo, err := d.Info()\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"reading info for %q: %v\", path, err)\n\t\t\treturn nil\n\t\t}\n\n\t\tif !s.AcceptEntry(ctx, path, info, zipFilePath) {\n\t\t\treturn nil\n\t\t}\n\n\t\tsize, err := GetFileSize(file.FS, path, info)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting file size for %q: %w\", path, err)\n\t\t}\n\n\t\t// check if the file exists in the database based on basename, size and mod time\n\t\texisting, err := r.File.FindByFileInfo(ctx, info, size)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"checking for existing file %q: %w\", path, err)\n\t\t}\n\n\t\tfor _, e := range existing {\n\t\t\t// ignore files in zip files\n\t\t\tif e.Base().ZipFileID != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tparentFolderID := e.Base().ParentFolderID\n\n\t\t\tif detector.isReject(parentFolderID) {\n\t\t\t\t// folder was found to still exist, not a candidate\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tc := detector.getCandidate(parentFolderID)\n\n\t\t\tif c == nil {\n\t\t\t\t// need to check if the folder exists in the filesystem\n\t\t\t\tpf, err := r.Folder.Find(ctx, e.Base().ParentFolderID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"getting parent folder %d: %w\", e.Base().ParentFolderID, err)\n\t\t\t\t}\n\n\t\t\t\tif pf == nil {\n\t\t\t\t\t// shouldn't happen, but just in case\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// parent folder must be missing\n\t\t\t\t_, err = file.FS.Lstat(pf.Path)\n\t\t\t\tif err == nil {\n\t\t\t\t\t// parent folder exists, not a candidate\n\t\t\t\t\tdetector.reject(parentFolderID)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// treat any error as missing folder\n\n\t\t\t\t// parent folder is missing, possible candidate\n\t\t\t\t// count the total number of files in the existing folder\n\t\t\t\tcount, err := r.File.CountByFolderID(ctx, parentFolderID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"counting files in folder %d: %w\", parentFolderID, err)\n\t\t\t\t}\n\n\t\t\t\tif count == 0 {\n\t\t\t\t\t// no files in the folder, not a candidate\n\t\t\t\t\tdetector.reject(parentFolderID)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tc = &folderRenameCandidate{\n\t\t\t\t\tfolder: pf,\n\t\t\t\t\tfound:  0,\n\t\t\t\t\tfiles:  count,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// increment the count and set it in the map\n\t\t\tc.found++\n\t\t\tdetector.setCandidate(*c)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"walking filesystem for folder rename detection: %w\", err)\n\t}\n\n\treturn detector.bestCandidate(), nil\n}\n"
  },
  {
    "path": "pkg/file/fs.go",
    "content": "package file\n\nimport (\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// Opener provides an interface to open a file.\ntype Opener interface {\n\tOpen() (io.ReadCloser, error)\n}\n\ntype fsOpener struct {\n\tfs   models.FS\n\tname string\n}\n\nfunc (o *fsOpener) Open() (io.ReadCloser, error) {\n\treturn o.fs.Open(o.name)\n}\n\n// OsFS is a file system backed by the OS.\ntype OsFS struct{}\n\nfunc (f *OsFS) Create(name string) (*os.File, error) {\n\treturn os.Create(name)\n}\n\nfunc (f *OsFS) MkdirAll(path string, perm fs.FileMode) error {\n\treturn os.MkdirAll(path, perm)\n}\n\nfunc (f *OsFS) Remove(name string) error {\n\treturn os.Remove(name)\n}\n\nfunc (f *OsFS) Rename(oldpath, newpath string) error {\n\treturn os.Rename(oldpath, newpath)\n}\n\nfunc (f *OsFS) RemoveAll(path string) error {\n\treturn os.RemoveAll(path)\n}\n\nfunc (f *OsFS) Stat(name string) (fs.FileInfo, error) {\n\treturn os.Stat(name)\n}\n\nfunc (f *OsFS) Lstat(name string) (fs.FileInfo, error) {\n\treturn os.Lstat(name)\n}\n\nfunc (f *OsFS) Open(name string) (fs.ReadDirFile, error) {\n\treturn os.Open(name)\n}\n\nfunc (f *OsFS) OpenZip(name string, size int64) (models.ZipFS, error) {\n\treturn newZipFS(f, name, size)\n}\n\nfunc (f *OsFS) IsPathCaseSensitive(path string) (bool, error) {\n\treturn fsutil.IsFsPathCaseSensitive(path)\n}\n"
  },
  {
    "path": "pkg/file/handler.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"io/fs\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// PathFilter provides a filter function for paths.\ntype PathFilter interface {\n\tAccept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool\n}\n\ntype PathFilterFunc func(path string) bool\n\nfunc (pff PathFilterFunc) Accept(path string) bool {\n\treturn pff(path)\n}\n\n// Filter provides a filter function for Files.\ntype Filter interface {\n\tAccept(ctx context.Context, f models.File) bool\n}\n\ntype FilterFunc func(ctx context.Context, f models.File) bool\n\nfunc (ff FilterFunc) Accept(ctx context.Context, f models.File) bool {\n\treturn ff(ctx, f)\n}\n\n// Handler provides a handler for Files.\ntype Handler interface {\n\tHandle(ctx context.Context, f models.File, oldFile models.File) error\n}\n\n// FilteredHandler is a Handler runs only if the filter accepts the file.\ntype FilteredHandler struct {\n\tHandler\n\tFilter\n}\n\n// Handle runs the handler if the filter accepts the file.\nfunc (h *FilteredHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error {\n\tif h.Accept(ctx, f) {\n\t\treturn h.Handler.Handle(ctx, f, oldFile)\n\t}\n\treturn nil\n}\n\n// CleanHandler provides a handler for cleaning Files and Folders.\ntype CleanHandler interface {\n\tHandleFile(ctx context.Context, fileDeleter *Deleter, fileID models.FileID) error\n\tHandleFolder(ctx context.Context, fileDeleter *Deleter, folderID models.FolderID) error\n}\n"
  },
  {
    "path": "pkg/file/image/orientation.go",
    "content": "package image\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/rwcarlsen/goexif/exif\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc adjustForOrientation(fs models.FS, path string, f *models.ImageFile) {\n\tisFlipped, err := areDimensionsFlipped(fs, path)\n\tif err != nil {\n\t\tlogger.Warnf(\"Error determining image orientation for %s: %v\", path, err)\n\t\t// isFlipped is false by default\n\t}\n\n\tif isFlipped {\n\t\tf.Width, f.Height = f.Height, f.Width\n\t}\n}\n\n// areDimensionsFlipped returns true if the image dimensions are flipped.\n// This is determined by the EXIF orientation tag.\nfunc areDimensionsFlipped(fs models.FS, path string) (bool, error) {\n\tr, err := fs.Open(path)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"reading image file %q: %w\", path, err)\n\t}\n\tdefer r.Close()\n\n\tx, err := exif.Decode(r)\n\tif err != nil {\n\t\tif errors.Is(err, io.EOF) || strings.Contains(err.Error(), \"failed to find exif\") {\n\t\t\t// no exif data\n\t\t\treturn false, nil\n\t\t}\n\n\t\treturn false, fmt.Errorf(\"decoding exif data: %w\", err)\n\t}\n\n\to, err := x.Get(exif.Orientation)\n\tif err != nil {\n\t\t// assume not present\n\t\treturn false, nil\n\t}\n\n\too, err := o.Int(0)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"decoding orientation: %w\", err)\n\t}\n\n\treturn isOrientationDimensionsFlipped(oo), nil\n}\n\n// isOrientationDimensionsFlipped returns true if the image orientation is flipped based on the input orientation EXIF value.\n// From https://sirv.com/help/articles/rotate-photos-to-be-upright/\n// 1 = 0 degrees: the correct orientation, no adjustment is required.\n// 2 = 0 degrees, mirrored: image has been flipped back-to-front.\n// 3 = 180 degrees: image is upside down.\n// 4 = 180 degrees, mirrored: image has been flipped back-to-front and is upside down.\n// 5 = 90 degrees: image has been flipped back-to-front and is on its side.\n// 6 = 90 degrees, mirrored: image is on its side.\n// 7 = 270 degrees: image has been flipped back-to-front and is on its far side.\n// 8 = 270 degrees, mirrored: image is on its far side.\nfunc isOrientationDimensionsFlipped(o int) bool {\n\tswitch o {\n\tcase 5, 6, 7, 8:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "pkg/file/image/scan.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"image\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t_ \"image/gif\"\n\t_ \"image/jpeg\"\n\t_ \"image/png\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/file/video\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t_ \"golang.org/x/image/webp\"\n)\n\nvar ErrUnsupportedAVIFInZip = errors.New(\"AVIF images in zip files is unsupported\")\n\n// Decorator adds image specific fields to a File.\ntype Decorator struct {\n\tFFProbe *ffmpeg.FFProbe\n}\n\nfunc (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) {\n\tbase := f.Base()\n\n\t// ignore clips in non-OsFS filesystems as ffprobe cannot read them\n\t// TODO - copy to temp file if not an OsFS\n\tif _, isOs := fs.(*file.OsFS); !isOs {\n\t\t// AVIF images inside zip files are not supported\n\t\tif strings.ToLower(filepath.Ext(base.Path)) == \".avif\" {\n\t\t\treturn nil, fmt.Errorf(\"%w: %s\", ErrUnsupportedAVIFInZip, base.Path)\n\t\t}\n\t\tlogger.Debugf(\"assuming ImageFile for non-OsFS file %q\", base.Path)\n\t\treturn decorateFallback(fs, f)\n\t}\n\n\tprobe, err := d.FFProbe.NewVideoFile(base.Path)\n\tif err != nil {\n\t\tlogger.Warnf(\"File %q could not be read with ffprobe: %s, assuming ImageFile\", base.Path, err)\n\t\treturn decorateFallback(fs, f)\n\t}\n\n\t// Fallback to catch non-animated avif images that FFProbe detects as video files\n\tif probe.Bitrate == 0 && probe.VideoCodec == \"av1\" {\n\t\treturn &models.ImageFile{\n\t\t\tBaseFile: base,\n\t\t\tFormat:   \"avif\",\n\t\t\tWidth:    probe.Width,\n\t\t\tHeight:   probe.Height,\n\t\t}, nil\n\t}\n\n\tisClip := true\n\t// This list is derived from ffmpegImageThumbnail in pkg/image/thumbnail. If one gets updated, the other should be as well\n\tfor _, item := range []string{\"png\", \"mjpeg\", \"webp\", \"bmp\", \"jpegxl\"} {\n\t\tif item == probe.VideoCodec {\n\t\t\tisClip = false\n\t\t}\n\t}\n\tif isClip {\n\t\tvideoFileDecorator := video.Decorator{FFProbe: d.FFProbe}\n\t\treturn videoFileDecorator.Decorate(ctx, fs, f)\n\t}\n\n\tret := &models.ImageFile{\n\t\tBaseFile: base,\n\t\tFormat:   probe.VideoCodec,\n\t\tWidth:    probe.Width,\n\t\tHeight:   probe.Height,\n\t}\n\n\t// FFprobe has a known bug where it returns 0x0 dimensions for some animated WebP files\n\t// Fall back to image.DecodeConfig in this case.\n\t// See: https://trac.ffmpeg.org/ticket/4907\n\tif ret.Width == 0 || ret.Height == 0 {\n\t\tlogger.Warnf(\"FFprobe returned invalid dimensions (%dx%d) for %q, trying fallback decoder\", ret.Width, ret.Height, base.Path)\n\t\tc, format, err := decodeConfig(fs, base.Path)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Fallback decoder failed for %q: %s. Proceeding with original FFprobe result\", base.Path, err)\n\t\t} else {\n\t\t\tret.Width = c.Width\n\t\t\tret.Height = c.Height\n\t\t\t// Update format if it differs (fallback decoder may be more accurate)\n\t\t\tif format != \"\" && format != ret.Format {\n\t\t\t\tlogger.Debugf(\"Updating format from %q to %q for %q\", ret.Format, format, base.Path)\n\t\t\t\tret.Format = format\n\t\t\t}\n\t\t}\n\t}\n\n\tadjustForOrientation(fs, base.Path, ret)\n\n\treturn ret, nil\n}\n\nfunc decodeConfig(fs models.FS, path string) (config image.Config, format string, err error) {\n\tr, err := fs.Open(path)\n\tif err != nil {\n\t\terr = fmt.Errorf(\"reading image file %q: %w\", path, err)\n\t\treturn\n\t}\n\tdefer r.Close()\n\n\tconfig, format, err = image.DecodeConfig(r)\n\tif err != nil {\n\t\terr = fmt.Errorf(\"decoding image file %q: %w\", path, err)\n\t\treturn\n\t}\n\treturn\n}\n\nfunc decorateFallback(fs models.FS, f models.File) (models.File, error) {\n\tbase := f.Base()\n\tpath := base.Path\n\n\tc, format, err := decodeConfig(fs, path)\n\tif err != nil {\n\t\treturn f, err\n\t}\n\n\tret := &models.ImageFile{\n\t\tBaseFile: base,\n\t\tFormat:   format,\n\t\tWidth:    c.Width,\n\t\tHeight:   c.Height,\n\t}\n\n\tadjustForOrientation(fs, path, ret)\n\n\treturn ret, nil\n}\n\nfunc (d *Decorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool {\n\tconst (\n\t\tunsetString = \"unset\"\n\t\tunsetNumber = -1\n\t)\n\n\timf, isImage := f.(*models.ImageFile)\n\tvf, isVideo := f.(*models.VideoFile)\n\n\tswitch {\n\tcase isImage:\n\t\treturn imf.Format == unsetString || imf.Width == unsetNumber || imf.Height == unsetNumber\n\tcase isVideo:\n\t\tvideoFileDecorator := video.Decorator{FFProbe: d.FFProbe}\n\t\treturn videoFileDecorator.IsMissingMetadata(ctx, fs, vf)\n\tdefault:\n\t\treturn true\n\t}\n}\n"
  },
  {
    "path": "pkg/file/import.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n)\n\nvar ErrZipFileNotExist = errors.New(\"zip file does not exist\")\n\ntype Importer struct {\n\tReaderWriter models.FileFinderCreator\n\tFolderStore  models.FolderFinderCreator\n\tInput        jsonschema.DirEntry\n\n\tfile   models.File\n\tfolder *models.Folder\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\tvar err error\n\n\tswitch ff := i.Input.(type) {\n\tcase *jsonschema.BaseDirEntry:\n\t\ti.folder, err = i.folderJSONToFolder(ctx, ff)\n\tdefault:\n\t\ti.file, err = i.fileJSONToFile(ctx, i.Input)\n\t}\n\n\treturn err\n}\n\nfunc (i *Importer) folderJSONToFolder(ctx context.Context, baseJSON *jsonschema.BaseDirEntry) (*models.Folder, error) {\n\tret := models.Folder{\n\t\tDirEntry: models.DirEntry{\n\t\t\tModTime: baseJSON.ModTime.GetTime(),\n\t\t},\n\t\tPath:      baseJSON.Path,\n\t\tCreatedAt: baseJSON.CreatedAt.GetTime(),\n\t\tUpdatedAt: baseJSON.CreatedAt.GetTime(),\n\t}\n\n\tif err := i.populateZipFileID(ctx, &ret.DirEntry); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// set parent folder id during the creation process\n\n\treturn &ret, nil\n}\n\nfunc (i *Importer) fileJSONToFile(ctx context.Context, fileJSON jsonschema.DirEntry) (models.File, error) {\n\tswitch ff := fileJSON.(type) {\n\tcase *jsonschema.VideoFile:\n\t\tbaseFile, err := i.baseFileJSONToBaseFile(ctx, ff.BaseFile)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn &models.VideoFile{\n\t\t\tBaseFile:         baseFile,\n\t\t\tFormat:           ff.Format,\n\t\t\tWidth:            ff.Width,\n\t\t\tHeight:           ff.Height,\n\t\t\tDuration:         ff.Duration,\n\t\t\tVideoCodec:       ff.VideoCodec,\n\t\t\tAudioCodec:       ff.AudioCodec,\n\t\t\tFrameRate:        ff.FrameRate,\n\t\t\tBitRate:          ff.BitRate,\n\t\t\tInteractive:      ff.Interactive,\n\t\t\tInteractiveSpeed: ff.InteractiveSpeed,\n\t\t}, nil\n\tcase *jsonschema.ImageFile:\n\t\tbaseFile, err := i.baseFileJSONToBaseFile(ctx, ff.BaseFile)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn &models.ImageFile{\n\t\t\tBaseFile: baseFile,\n\t\t\tFormat:   ff.Format,\n\t\t\tWidth:    ff.Width,\n\t\t\tHeight:   ff.Height,\n\t\t}, nil\n\tcase *jsonschema.BaseFile:\n\t\treturn i.baseFileJSONToBaseFile(ctx, ff)\n\t}\n\n\treturn nil, errors.New(\"unknown file type\")\n}\n\nfunc (i *Importer) baseFileJSONToBaseFile(ctx context.Context, baseJSON *jsonschema.BaseFile) (*models.BaseFile, error) {\n\tbaseFile := models.BaseFile{\n\t\tDirEntry: models.DirEntry{\n\t\t\tModTime: baseJSON.ModTime.GetTime(),\n\t\t},\n\t\tBasename:  filepath.Base(baseJSON.Path),\n\t\tSize:      baseJSON.Size,\n\t\tCreatedAt: baseJSON.CreatedAt.GetTime(),\n\t\tUpdatedAt: baseJSON.CreatedAt.GetTime(),\n\t}\n\n\tfor _, fp := range baseJSON.Fingerprints {\n\t\tbaseFile.Fingerprints = append(baseFile.Fingerprints, models.Fingerprint{\n\t\t\tType:        fp.Type,\n\t\t\tFingerprint: fp.Fingerprint,\n\t\t})\n\t}\n\n\tif err := i.populateZipFileID(ctx, &baseFile.DirEntry); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &baseFile, nil\n}\n\nfunc (i *Importer) populateZipFileID(ctx context.Context, f *models.DirEntry) error {\n\tzipFilePath := i.Input.DirEntry().ZipFile\n\tif zipFilePath != \"\" {\n\t\tzf, err := i.ReaderWriter.FindByPath(ctx, zipFilePath, true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding file by path %q: %v\", zipFilePath, err)\n\t\t}\n\n\t\tif zf == nil {\n\t\t\treturn ErrZipFileNotExist\n\t\t}\n\n\t\tid := zf.Base().ID\n\t\tf.ZipFileID = &id\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\treturn i.Input.DirEntry().Path\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\tpath := i.Input.DirEntry().Path\n\texisting, err := i.ReaderWriter.FindByPath(ctx, path, true)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif existing != nil {\n\t\tid := int(existing.Base().ID)\n\t\treturn &id, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *Importer) createFolderHierarchy(ctx context.Context, p string) (*models.Folder, error) {\n\tparentPath := filepath.Dir(p)\n\n\tif parentPath == p {\n\t\t// get or create this folder\n\t\treturn i.getOrCreateFolder(ctx, p, nil)\n\t}\n\n\tparent, err := i.createFolderHierarchy(ctx, parentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn i.getOrCreateFolder(ctx, p, parent)\n}\n\nfunc (i *Importer) getOrCreateFolder(ctx context.Context, path string, parent *models.Folder) (*models.Folder, error) {\n\tfolder, err := i.FolderStore.FindByPath(ctx, path, true)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif folder != nil {\n\t\treturn folder, nil\n\t}\n\n\tnow := time.Now()\n\n\tfolder = &models.Folder{\n\t\tPath:      path,\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}\n\n\tif parent != nil {\n\t\tfolder.ZipFileID = parent.ZipFileID\n\t\tfolder.ParentFolderID = &parent.ID\n\t}\n\n\tif err := i.FolderStore.Create(ctx, folder); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn folder, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\t// create folder hierarchy and set parent folder id\n\tpath := i.Input.DirEntry().Path\n\tpath = filepath.Dir(path)\n\tfolder, err := i.createFolderHierarchy(ctx, path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"creating folder hierarchy for %q: %w\", path, err)\n\t}\n\n\tif i.folder != nil {\n\t\treturn i.createFolder(ctx, folder)\n\t}\n\n\treturn i.createFile(ctx, folder)\n}\n\nfunc (i *Importer) createFile(ctx context.Context, parentFolder *models.Folder) (*int, error) {\n\tif parentFolder != nil {\n\t\ti.file.Base().ParentFolderID = parentFolder.ID\n\t}\n\n\tif err := i.ReaderWriter.Create(ctx, i.file); err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating file: %w\", err)\n\t}\n\n\tid := int(i.file.Base().ID)\n\treturn &id, nil\n}\n\nfunc (i *Importer) createFolder(ctx context.Context, parentFolder *models.Folder) (*int, error) {\n\tif parentFolder != nil {\n\t\ti.folder.ParentFolderID = &parentFolder.ID\n\t}\n\n\tif err := i.FolderStore.Create(ctx, i.folder); err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating folder: %w\", err)\n\t}\n\n\tid := int(i.folder.ID)\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\t// update not supported\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/file/move.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype Renamer interface {\n\tRename(oldpath, newpath string) error\n}\n\ntype Statter interface {\n\tStat(name string) (fs.FileInfo, error)\n}\n\ntype DirMakerStatRenamer interface {\n\tStatter\n\tRenamer\n\tMkdir(name string, perm os.FileMode) error\n\tRemove(name string) error\n}\n\ntype folderCreatorStatRenamerImpl struct {\n\trenamerRemoverImpl\n\tmkDirFn func(name string, perm os.FileMode) error\n}\n\nfunc (r folderCreatorStatRenamerImpl) Mkdir(name string, perm os.FileMode) error {\n\treturn r.mkDirFn(name, perm)\n}\n\ntype Mover struct {\n\tRenamer DirMakerStatRenamer\n\tFiles   models.FileFinderUpdater\n\tFolders models.FolderReaderWriter\n\n\tmoved          map[string]string\n\tfoldersCreated []string\n\n\t// needed for creating folder hierarchy when moving zip file entries\n\trootPaths []string\n}\n\nfunc NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter, rootPaths []string) *Mover {\n\treturn &Mover{\n\t\tFiles:   fileStore,\n\t\tFolders: folderStore,\n\t\tRenamer: &folderCreatorStatRenamerImpl{\n\t\t\trenamerRemoverImpl: newRenamerRemoverImpl(),\n\t\t\tmkDirFn:            os.Mkdir,\n\t\t},\n\t\trootPaths: rootPaths,\n\t}\n}\n\n// Move moves the file to the given folder and basename. If basename is empty, then the existing basename is used.\n// Assumes that the parent folder exists in the filesystem.\nfunc (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder, basename string) error {\n\tfBase := f.Base()\n\n\t// don't allow moving files in zip files\n\tif fBase.ZipFileID != nil {\n\t\treturn fmt.Errorf(\"cannot move file %s, is in a zip file\", fBase.Path)\n\t}\n\n\tif basename == \"\" {\n\t\tbasename = fBase.Basename\n\t}\n\n\t// modify the database first\n\n\toldPath := fBase.Path\n\n\tif folder.ID == fBase.ParentFolderID && (basename == \"\" || basename == fBase.Basename) {\n\t\t// nothing to do\n\t\treturn nil\n\t}\n\n\t// ensure that the new path doesn't already exist\n\tnewPath := filepath.Join(folder.Path, basename)\n\tif _, err := m.Renamer.Stat(newPath); !errors.Is(err, fs.ErrNotExist) {\n\t\treturn fmt.Errorf(\"file %s already exists\", newPath)\n\t}\n\n\tzipMover := zipHierarchyMover{\n\t\tfolderStore: m.Folders,\n\t\tfiles:       m.Files,\n\t\trootPaths:   m.rootPaths,\n\t}\n\n\tif err := zipMover.transferZipHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil {\n\t\treturn fmt.Errorf(\"moving folder hierarchy for file %s: %w\", fBase.Path, err)\n\t}\n\n\tfBase.ParentFolderID = folder.ID\n\tfBase.Basename = basename\n\tfBase.UpdatedAt = time.Now()\n\t// leave ModTime as is. It may or may not be changed by this operation\n\n\tif err := m.Files.Update(ctx, f); err != nil {\n\t\treturn fmt.Errorf(\"updating file %s: %w\", oldPath, err)\n\t}\n\n\t// then move the file\n\treturn m.moveFile(oldPath, newPath)\n}\n\nfunc (m *Mover) CreateFolderHierarchy(path string) error {\n\tinfo, err := m.Renamer.Stat(path)\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\t// create the parent folder\n\t\t\tparentPath := filepath.Dir(path)\n\t\t\tif err := m.CreateFolderHierarchy(parentPath); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// create the folder\n\t\t\tif err := m.Renamer.Mkdir(path, 0755); err != nil {\n\t\t\t\treturn fmt.Errorf(\"creating folder %s: %w\", path, err)\n\t\t\t}\n\n\t\t\tm.foldersCreated = append(m.foldersCreated, path)\n\t\t} else {\n\t\t\treturn fmt.Errorf(\"getting info for %s: %w\", path, err)\n\t\t}\n\t} else {\n\t\tif !info.IsDir() {\n\t\t\treturn fmt.Errorf(\"%s is not a directory\", path)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *Mover) moveFile(oldPath, newPath string) error {\n\tif err := m.Renamer.Rename(oldPath, newPath); err != nil {\n\t\treturn fmt.Errorf(\"renaming file %s to %s: %w\", oldPath, newPath, err)\n\t}\n\n\tif m.moved == nil {\n\t\tm.moved = make(map[string]string)\n\t}\n\n\tm.moved[newPath] = oldPath\n\n\treturn nil\n}\n\nfunc (m *Mover) RegisterHooks(ctx context.Context) {\n\ttxn.AddPostCommitHook(ctx, func(ctx context.Context) {\n\t\tm.commit()\n\t})\n\n\ttxn.AddPostRollbackHook(ctx, func(ctx context.Context) {\n\t\tm.rollback()\n\t})\n}\n\nfunc (m *Mover) commit() {\n\tm.moved = nil\n\tm.foldersCreated = nil\n}\n\nfunc (m *Mover) rollback() {\n\t// move files back to their original location\n\tfor newPath, oldPath := range m.moved {\n\t\tif err := m.Renamer.Rename(newPath, oldPath); err != nil {\n\t\t\tlogger.Errorf(\"error moving file %s back to %s: %s\", newPath, oldPath, err.Error())\n\t\t}\n\t}\n\n\t// remove folders created in reverse order\n\tfor i := len(m.foldersCreated) - 1; i >= 0; i-- {\n\t\tfolder := m.foldersCreated[i]\n\t\tif err := m.Renamer.Remove(folder); err != nil {\n\t\t\tlogger.Errorf(\"error removing folder %s: %s\", folder, err.Error())\n\t\t}\n\t}\n}\n\n// correctSubFolderHierarchy sets the path of all contained folders to be relative to the given folder.\n// It does not move the folder hierarchy in the filesystem.\nfunc correctSubFolderHierarchy(ctx context.Context, rw models.FolderReaderWriter, folder *models.Folder) error {\n\tfolders, err := rw.FindByParentFolderID(ctx, folder.ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding contained folders in folder %s: %w\", folder.Path, err)\n\t}\n\n\tfolderPath := folder.Path\n\n\tfor _, f := range folders {\n\t\toldPath := f.Path\n\t\tfolderBasename := filepath.Base(f.Path)\n\t\tcorrectPath := filepath.Join(folderPath, folderBasename)\n\n\t\tlogger.Debugf(\"updating folder %s to %s\", oldPath, correctPath)\n\n\t\t// #6427 - ensure folder entry with new path doesn't already exist\n\t\tconst caseSensitive = true\n\t\texisting, err := rw.FindByPath(ctx, correctPath, caseSensitive)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding folder by path %s: %w\", correctPath, err)\n\t\t}\n\n\t\tif existing != nil {\n\t\t\t// this should no longer be possible, but if it does happen, log a warning\n\t\t\t// and skip updating this folder and its subfolders\n\t\t\tlogger.Warnf(\"folder with path %s already exists, setting parent_folder_id of %s to NULL and skipping\", correctPath, oldPath)\n\t\t\tf.ParentFolderID = nil\n\t\t\tif err := rw.Update(ctx, f); err != nil {\n\t\t\t\treturn fmt.Errorf(\"updating folder parent id to NULL for folder %s: %w\", oldPath, err)\n\t\t\t}\n\n\t\t\tcontinue\n\t\t}\n\n\t\tf.Path = correctPath\n\t\tif err := rw.Update(ctx, f); err != nil {\n\t\t\treturn fmt.Errorf(\"updating folder path %s -> %s: %w\", oldPath, f.Path, err)\n\t\t}\n\n\t\t// recurse\n\t\tif err := correctSubFolderHierarchy(ctx, rw, f); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/file/scan.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\n// Scanner scans files into the database.\n//\n// The scan process works using two goroutines. The first walks through the provided paths\n// in the filesystem. It runs each directory entry through the provided ScanFilters. If none\n// of the filter Accept methods return true, then the file/directory is ignored.\n// Any folders found are handled immediately. Files inside zip files are also handled immediately.\n// All other files encountered are sent to the second goroutine queue.\n//\n// Folders are handled by checking if the folder exists in the database, by its full path.\n// If a folder entry already exists, then its mod time is updated (if applicable).\n// If the folder does not exist in the database, then a new folder entry its created.\n//\n// Files are handled by first querying for the file by its path. If the file entry exists in the\n// database, then the mod time is compared to the value in the database. If the mod time is different\n// then file is marked as updated - it recalculates any fingerprints and fires decorators, then\n// the file entry is updated and any applicable handlers are fired.\n//\n// If the file entry does not exist in the database, then fingerprints are calculated for the file.\n// It then determines if the file is a rename of an existing file by querying for file entries with\n// the same fingerprint. If any are found, it checks each to see if any are missing in the file\n// system. If one is, then the file is treated as renamed and its path is updated. If none are missing,\n// or many are, then the file is treated as a new file.\n//\n// If the file is not a renamed file, then the decorators are fired and the file is created, then\n// the applicable handlers are fired.\ntype Scanner struct {\n\tFS                    models.FS\n\tRepository            Repository\n\tFingerprintCalculator FingerprintCalculator\n\n\t// ZipFileExtensions is a list of file extensions that are considered zip files.\n\t// Extension does not include the . character.\n\tZipFileExtensions []string\n\n\t// ScanFilters are used to determine if a file should be scanned.\n\tScanFilters []PathFilter\n\n\t// HandlerRequiredFilters are used to determine if an unchanged file needs to be handled\n\tHandlerRequiredFilters []Filter\n\n\t// FileDecorators are applied to files as they are scanned.\n\tFileDecorators []Decorator\n\n\t// handlers are called after a file has been scanned.\n\tFileHandlers []Handler\n\n\t// RootPaths form the top-level paths for the library.\n\t// Used to determine the root of the folder hierarchy when creating folders.\n\tRootPaths []string\n\n\t// Rescan indicates whether files should be rescanned even if they haven't changed.\n\tRescan bool\n\n\tfolderPathToID sync.Map\n}\n\n// FingerprintCalculator calculates a fingerprint for the provided file.\ntype FingerprintCalculator interface {\n\tCalculateFingerprints(f *models.BaseFile, o Opener, useExisting bool) ([]models.Fingerprint, error)\n}\n\n// Decorator wraps the Decorate method to add additional functionality while scanning files.\ntype Decorator interface {\n\tDecorate(ctx context.Context, fs models.FS, f models.File) (models.File, error)\n\tIsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool\n}\n\ntype FilteredDecorator struct {\n\tDecorator\n\tFilter\n}\n\n// Decorate runs the decorator if the filter accepts the file.\nfunc (d *FilteredDecorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) {\n\tif d.Accept(ctx, f) {\n\t\treturn d.Decorator.Decorate(ctx, fs, f)\n\t}\n\treturn f, nil\n}\n\nfunc (d *FilteredDecorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool {\n\tif d.Accept(ctx, f) {\n\t\treturn d.Decorator.IsMissingMetadata(ctx, fs, f)\n\t}\n\n\treturn false\n}\n\n// ScannedFile represents a file being scanned.\ntype ScannedFile struct {\n\t*models.BaseFile\n\tFS   models.FS\n\tInfo fs.FileInfo\n}\n\n// AcceptEntry determines if the file entry should be accepted for scanning\nfunc (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {\n\t// always accept if there's no filters\n\taccept := len(s.ScanFilters) == 0\n\tfor _, filter := range s.ScanFilters {\n\t\t// accept if any filter accepts the file\n\t\tif filter.Accept(ctx, path, info, zipFilePath) {\n\t\t\taccept = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn accept\n}\n\nfunc (s *Scanner) getFolderID(ctx context.Context, path string) (*models.FolderID, error) {\n\t// check the folder cache first\n\tif f, ok := s.folderPathToID.Load(path); ok {\n\t\tv := f.(models.FolderID)\n\t\treturn &v, nil\n\t}\n\n\t// assume case sensitive when searching for the folder\n\tconst caseSensitive = true\n\n\tret, err := s.Repository.Folder.FindByPath(ctx, path, caseSensitive)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif ret == nil {\n\t\treturn nil, nil\n\t}\n\n\ts.folderPathToID.Store(path, ret.ID)\n\treturn &ret.ID, nil\n}\n\n// ScanFolder scans the provided folder into the database, returning the folder entry.\n// If the folder already exists, it is updated if necessary.\nfunc (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {\n\tvar f *models.Folder\n\tvar err error\n\tpath := file.Path\n\n\terr = s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\t// determine if folder already exists in data store (by path)\n\t\t// assume case sensitive by default\n\t\tf, err = s.Repository.Folder.FindByPath(ctx, path, true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"checking for existing folder %q: %w\", path, err)\n\t\t}\n\n\t\t// #1426 / #6326 - if folder is in a case-insensitive filesystem, then try\n\t\t// case insensitive searching\n\t\t// assume case sensitive if in zip\n\t\tif f == nil && file.ZipFileID == nil {\n\t\t\tcaseSensitive, _ := file.FS.IsPathCaseSensitive(file.Path)\n\n\t\t\tif !caseSensitive {\n\t\t\t\tf, err = s.Repository.Folder.FindByPath(ctx, path, false)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"checking for existing folder %q: %w\", path, err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// if folder not exists, create it\n\t\tif f == nil {\n\t\t\tf, err = s.onNewFolder(ctx, file)\n\t\t} else {\n\t\t\tf, err = s.onExistingFolder(ctx, file, f)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif f != nil {\n\t\t\ts.folderPathToID.Store(f.Path, f.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n\n\treturn f, err\n}\n\nfunc (s *Scanner) isRootPath(path string) bool {\n\treturn path == \".\" || slices.Contains(s.RootPaths, path)\n}\n\nfunc (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {\n\trenamed, err := s.handleFolderRename(ctx, file)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif renamed != nil {\n\t\treturn renamed, nil\n\t}\n\n\tnow := time.Now()\n\n\ttoCreate := &models.Folder{\n\t\tDirEntry:  file.DirEntry,\n\t\tPath:      file.Path,\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}\n\n\tif !s.isRootPath(file.Path) {\n\t\tdir := filepath.Dir(file.Path)\n\n\t\t// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID\n\t\tparentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, dir, s.RootPaths)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"getting parent folder %q: %w\", dir, err)\n\t\t}\n\n\t\ttoCreate.ParentFolderID = &parentFolder.ID\n\t}\n\n\ttxn.AddPostCommitHook(ctx, func(ctx context.Context) {\n\t\t// log at the end so that if anything fails above due to a locked database\n\t\t// error and the transaction must be retried, then we shouldn't get multiple\n\t\t// logs of the same thing.\n\t\tlogger.Infof(\"%s doesn't exist. Creating new folder entry...\", file.Path)\n\t})\n\n\tif err := s.Repository.Folder.Create(ctx, toCreate); err != nil {\n\t\treturn nil, fmt.Errorf(\"creating folder %q: %w\", file.Path, err)\n\t}\n\n\treturn toCreate, nil\n}\n\nfunc (s *Scanner) handleFolderRename(ctx context.Context, file ScannedFile) (*models.Folder, error) {\n\t// ignore folders in zip files\n\tif file.ZipFileID != nil {\n\t\treturn nil, nil\n\t}\n\n\t// check if the folder was moved from elsewhere\n\trenamedFrom, err := s.detectFolderMove(ctx, file)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"detecting folder move: %w\", err)\n\t}\n\n\tif renamedFrom == nil {\n\t\treturn nil, nil\n\t}\n\n\t// if the folder was moved, update the existing folder\n\tlogger.Infof(\"%s moved to %s. Updating path...\", renamedFrom.Path, file.Path)\n\trenamedFrom.Path = file.Path\n\n\t// update the parent folder ID\n\t// find the parent folder\n\tparentFolderID, err := s.getFolderID(ctx, filepath.Dir(file.Path))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting parent folder for %q: %w\", file.Path, err)\n\t}\n\n\trenamedFrom.ParentFolderID = parentFolderID\n\n\tif err := s.Repository.Folder.Update(ctx, renamedFrom); err != nil {\n\t\treturn nil, fmt.Errorf(\"updating folder for rename %q: %w\", renamedFrom.Path, err)\n\t}\n\n\t// #4146 - correct sub-folders to have the correct path\n\tif err := correctSubFolderHierarchy(ctx, s.Repository.Folder, renamedFrom); err != nil {\n\t\treturn nil, fmt.Errorf(\"correcting sub folder hierarchy for %q: %w\", renamedFrom.Path, err)\n\t}\n\n\treturn renamedFrom, nil\n}\n\nfunc (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing *models.Folder) (*models.Folder, error) {\n\tupdate := false\n\n\t// update if mod time is changed\n\tentryModTime := f.ModTime\n\tif !entryModTime.Equal(existing.ModTime) {\n\t\texisting.Path = f.Path\n\t\texisting.ModTime = entryModTime\n\t\tupdate = true\n\t}\n\n\t// #6326 - update if path has changed - should only happen if case is\n\t// changed and filesystem is case insensitive\n\tif existing.Path != f.Path {\n\t\texisting.Path = f.Path\n\t\tupdate = true\n\t}\n\n\t// update if zip file ID has changed\n\tfZfID := f.ZipFileID\n\texistingZfID := existing.ZipFileID\n\tif fZfID != existingZfID {\n\t\tif fZfID == nil {\n\t\t\texisting.ZipFileID = nil\n\t\t\tupdate = true\n\t\t} else if existingZfID == nil || *fZfID != *existingZfID {\n\t\t\texisting.ZipFileID = fZfID\n\t\t\tupdate = true\n\t\t}\n\t}\n\n\t// handle case where parent folder was not previously set\n\tif existing.ParentFolderID == nil && !s.isRootPath(existing.Path) {\n\t\tlogger.Infof(\"Existing folder entry %q has no parent folder. Creating folder hierarchy and setting parent ID...\", existing.Path)\n\n\t\t// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID\n\t\tparentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, filepath.Dir(f.Path), s.RootPaths)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"getting parent folder for %q: %w\", f.Path, err)\n\t\t}\n\t\texisting.ParentFolderID = &parentFolder.ID\n\t\tupdate = true\n\t}\n\n\tif update {\n\t\tvar err error\n\t\tif err = s.Repository.Folder.Update(ctx, existing); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"updating folder %q: %w\", f.Path, err)\n\t\t}\n\t}\n\n\treturn existing, nil\n}\n\ntype ScanFileResult struct {\n\tFile               models.File\n\tNew                bool\n\tRenamed            bool\n\tUpdated            bool\n\tFingerprintChanged bool\n}\n\nfunc (r ScanFileResult) IsUnchanged() bool {\n\treturn !r.New && !r.Renamed && !r.Updated\n}\n\n// ScanFile scans the provided file into the database, returning the scan result.\nfunc (s *Scanner) ScanFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) {\n\tvar r *ScanFileResult\n\n\t// don't use a transaction to check if new or existing\n\tif err := s.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t// determine if file already exists in data store\n\t\t// assume case sensitive when searching for the file to begin with\n\t\tff, err := s.Repository.File.FindByPath(ctx, f.Path, true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"checking for existing file %q: %w\", f.Path, err)\n\t\t}\n\n\t\t// #1426 / #6326 - if file is in a case-insensitive filesystem, then try\n\t\t// case insensitive search\n\t\t// assume case sensitive if in zip\n\t\tif ff == nil && f.ZipFileID != nil {\n\t\t\tcaseSensitive, _ := f.FS.IsPathCaseSensitive(f.Path)\n\n\t\t\tif !caseSensitive {\n\t\t\t\tff, err = s.Repository.File.FindByPath(ctx, f.Path, false)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"checking for existing file %q: %w\", f.Path, err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif ff == nil {\n\t\t\t// returns a file only if it is actually new\n\t\t\tr, err = s.onNewFile(ctx, f)\n\t\t\treturn err\n\t\t}\n\n\t\tr, err = s.onExistingFile(ctx, f, ff)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn r, nil\n}\n\n// IsZipFile determines if the provided path is a zip file based on its extension.\nfunc (s *Scanner) IsZipFile(path string) bool {\n\tfExt := filepath.Ext(path)\n\tfor _, ext := range s.ZipFileExtensions {\n\t\tif strings.EqualFold(fExt, \".\"+ext) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) {\n\tnow := time.Now()\n\n\tbaseFile := f.BaseFile\n\tpath := baseFile.Path\n\n\tbaseFile.CreatedAt = now\n\tbaseFile.UpdatedAt = now\n\n\t// find the parent folder\n\tfolderPath := filepath.Dir(path)\n\tparentFolderID, err := s.getFolderID(ctx, folderPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting parent folder for %q: %w\", path, err)\n\t}\n\n\tif parentFolderID == nil {\n\t\t// parent folders should have been created before scanning this file in a recursive scan\n\t\t// assume that we are scanning specifically and only this file,\n\t\t// so we should create the parent folder hierarchy if it doesn't exist\n\t\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tparentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, folderPath, s.RootPaths)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting parent folder for %q: %w\", f.Path, err)\n\t\t\t}\n\n\t\t\tparentFolderID = &parentFolder.ID\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif parentFolderID == nil {\n\t\t// shouldn't happen\n\t\treturn nil, fmt.Errorf(\"parent folder ID is nil for %q\", path)\n\t}\n\n\tbaseFile.ParentFolderID = *parentFolderID\n\n\tconst useExisting = false\n\tfp, err := s.calculateFingerprints(f.FS, baseFile, path, useExisting)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbaseFile.SetFingerprints(fp)\n\n\tfile, err := s.fireDecorators(ctx, f.FS, baseFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// determine if the file is renamed from an existing file in the store\n\t// do this after decoration so that missing fields can be populated\n\tzipFilePath := \"\"\n\tif f.ZipFile != nil {\n\t\tzipFilePath = f.ZipFile.Base().Path\n\t}\n\trenamed, err := s.handleRename(ctx, file, fp, zipFilePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif renamed != nil {\n\t\treturn &ScanFileResult{\n\t\t\tFile:    renamed,\n\t\t\tRenamed: true,\n\t\t}, nil\n\t\t// handle rename should have already handled the contents of the zip file\n\t\t// so shouldn't need to scan it again\n\t\t// return nil so it doesn't\n\t}\n\n\t// if not renamed, queue file for creation\n\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tif err := s.Repository.File.Create(ctx, file); err != nil {\n\t\t\treturn fmt.Errorf(\"creating file %q: %w\", path, err)\n\t\t}\n\n\t\tif err := s.fireHandlers(ctx, file, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &ScanFileResult{\n\t\tFile: file,\n\t\tNew:  true,\n\t}, nil\n}\n\nfunc (s *Scanner) fireDecorators(ctx context.Context, fs models.FS, f models.File) (models.File, error) {\n\tfor _, h := range s.FileDecorators {\n\t\tvar err error\n\t\tf, err = h.Decorate(ctx, fs, f)\n\t\tif err != nil {\n\t\t\treturn f, err\n\t\t}\n\t}\n\n\treturn f, nil\n}\n\nfunc (s *Scanner) fireHandlers(ctx context.Context, f models.File, oldFile models.File) error {\n\tfor _, h := range s.FileHandlers {\n\t\tif err := h.Handle(ctx, f, oldFile); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (s *Scanner) calculateFingerprints(fs models.FS, f *models.BaseFile, path string, useExisting bool) (models.Fingerprints, error) {\n\t// only log if we're (re)calculating fingerprints\n\tif !useExisting {\n\t\tlogger.Infof(\"Calculating fingerprints for %s ...\", path)\n\t}\n\n\t// calculate primary fingerprint for the file\n\tfp, err := s.FingerprintCalculator.CalculateFingerprints(f, &fsOpener{\n\t\tfs:   fs,\n\t\tname: path,\n\t}, useExisting)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"calculating fingerprint for file %q: %w\", path, err)\n\t}\n\n\treturn fp, nil\n}\n\nfunc appendFileUnique(v []models.File, toAdd []models.File) []models.File {\n\tfor _, f := range toAdd {\n\t\tfound := false\n\t\tid := f.Base().ID\n\t\tfor _, vv := range v {\n\t\t\tif vv.Base().ID == id {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\tv = append(v, f)\n\t\t}\n\t}\n\n\treturn v\n}\n\nfunc (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) {\n\tif f.ZipFile == nil {\n\t\treturn s.FS, nil\n\t}\n\n\tfs, err := s.getFileFS(f.ZipFile.Base())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tzipPath := f.ZipFile.Base().Path\n\tzipSize := f.ZipFile.Base().Size\n\treturn fs.OpenZip(zipPath, zipSize)\n}\n\nfunc (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint, zipFilePath string) (models.File, error) {\n\tvar others []models.File\n\n\tfor _, tfp := range fp {\n\t\tthisOthers, err := s.Repository.File.FindByFingerprint(ctx, tfp)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"getting files by fingerprint %v: %w\", tfp, err)\n\t\t}\n\n\t\tothers = appendFileUnique(others, thisOthers)\n\t}\n\n\tvar missing []models.File\n\n\tfZipID := f.Base().ZipFileID\n\tfor _, other := range others {\n\t\t// if file is from a zip file, then only rename if both files are from the same zip file\n\t\totherZipID := other.Base().ZipFileID\n\t\tif otherZipID != nil && (fZipID == nil || *otherZipID != *fZipID) {\n\t\t\tcontinue\n\t\t}\n\n\t\t// if file does not exist, then update it to the new path\n\t\tfs, err := s.getFileFS(other.Base())\n\t\tif err != nil {\n\t\t\tmissing = append(missing, other)\n\t\t\tcontinue\n\t\t}\n\n\t\tinfo, err := fs.Lstat(other.Base().Path)\n\t\tswitch {\n\t\tcase err != nil:\n\t\t\tmissing = append(missing, other)\n\t\tcase strings.EqualFold(f.Base().Path, other.Base().Path):\n\t\t\t// #1426 - if file exists but is a case-insensitive match for the\n\t\t\t// original filename, and the filesystem is case-insensitive\n\t\t\t// then treat it as a move\n\t\t\t// #6326 - this should now be handled earlier, and this shouldn't be necessary\n\t\t\tif caseSensitive, _ := fs.IsPathCaseSensitive(other.Base().Path); !caseSensitive {\n\t\t\t\t// treat as a move\n\t\t\t\tmissing = append(missing, other)\n\t\t\t}\n\t\tcase !s.AcceptEntry(ctx, other.Base().Path, info, zipFilePath):\n\t\t\t// #4393 - if the file is no longer in the configured library paths, treat it as a move\n\t\t\tlogger.Debugf(\"File %q no longer in library paths. Treating as a move.\", other.Base().Path)\n\t\t\tmissing = append(missing, other)\n\t\t}\n\t}\n\n\tn := len(missing)\n\tif n == 0 {\n\t\t// no missing files, not a rename\n\t\treturn nil, nil\n\t}\n\n\t// assume does not exist, update existing file\n\t// it's possible that there may be multiple missing files.\n\t// just use the first one to rename.\n\t// #4775 - using the new file instance means that any changes made to the existing\n\t// file will be lost. Update the existing file instead.\n\tother := missing[0]\n\tupdated := other.Clone()\n\tupdatedBase := updated.Base()\n\n\tfBaseCopy := *(f.Base())\n\n\toldPath := updatedBase.Path\n\tnewPath := fBaseCopy.Path\n\n\tlogger.Infof(\"%s moved to %s. Updating path...\", oldPath, newPath)\n\tfBaseCopy.ID = updatedBase.ID\n\tfBaseCopy.CreatedAt = updatedBase.CreatedAt\n\tfBaseCopy.Fingerprints = updatedBase.Fingerprints\n\t*updatedBase = fBaseCopy\n\n\tzipMover := zipHierarchyMover{\n\t\tfolderStore: s.Repository.Folder,\n\t\tfiles:       s.Repository.File,\n\t\trootPaths:   s.RootPaths,\n\t}\n\n\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tif err := s.Repository.File.Update(ctx, updated); err != nil {\n\t\t\treturn fmt.Errorf(\"updating file for rename %q: %w\", newPath, err)\n\t\t}\n\n\t\tif s.IsZipFile(updatedBase.Basename) {\n\t\t\tif err := zipMover.transferZipHierarchy(ctx, updatedBase.ID, oldPath, newPath); err != nil {\n\t\t\t\treturn fmt.Errorf(\"moving zip hierarchy for renamed zip file %q: %w\", newPath, err)\n\t\t\t}\n\t\t}\n\n\t\tif err := s.fireHandlers(ctx, updated, other); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn updated, nil\n}\n\nfunc (s *Scanner) isHandlerRequired(ctx context.Context, f models.File) bool {\n\taccept := len(s.HandlerRequiredFilters) == 0\n\tfor _, filter := range s.HandlerRequiredFilters {\n\t\t// accept if any filter accepts the file\n\t\tif filter.Accept(ctx, f) {\n\t\t\taccept = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn accept\n}\n\n// isMissingMetadata returns true if the provided file is missing metadata.\n// Missing metadata should only occur after the 32 schema migration.\n// Looks for special values. For numbers, this will be -1. For strings, this\n// will be 'unset'.\n// Missing metadata includes the following:\n// - file size\n// - image format, width or height\n// - video codec, audio codec, format, width, height, framerate or bitrate\nfunc (s *Scanner) isMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) bool {\n\tfor _, h := range s.FileDecorators {\n\t\tif h.IsMissingMetadata(ctx, f.FS, existing) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (s *Scanner) setMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) {\n\tpath := existing.Base().Path\n\tlogger.Infof(\"Updating metadata for %s\", path)\n\n\texisting.Base().Size = f.Size\n\n\tvar err error\n\texisting, err = s.fireDecorators(ctx, f.FS, existing)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// queue file for update\n\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tif err := s.Repository.File.Update(ctx, existing); err != nil {\n\t\t\treturn fmt.Errorf(\"updating file %q: %w\", path, err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn existing, nil\n}\n\nfunc (s *Scanner) setMissingFingerprints(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) {\n\tconst useExisting = true\n\tfp, err := s.calculateFingerprints(f.FS, existing.Base(), f.Path, useExisting)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif fp.ContentsChanged(existing.Base().Fingerprints) {\n\t\texisting.SetFingerprints(fp)\n\n\t\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\t\tif err := s.Repository.File.Update(ctx, existing); err != nil {\n\t\t\t\treturn fmt.Errorf(\"updating file %q: %w\", f.Path, err)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn existing, nil\n}\n\n// returns a file only if it was updated\nfunc (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) {\n\tbase := existing.Base()\n\tpath := base.Path\n\n\tfileModTime := f.ModTime\n\t// #6326 - also force a rescan if the basename changed\n\tupdated := !fileModTime.Equal(base.ModTime) || base.Basename != f.Basename\n\tforceRescan := s.Rescan\n\n\tif !updated && !forceRescan {\n\t\treturn s.onUnchangedFile(ctx, f, existing)\n\t}\n\n\toldBase := *base\n\n\tif !updated && forceRescan {\n\t\tlogger.Infof(\"rescanning %s\", path)\n\t} else {\n\t\tlogger.Infof(\"%s has been updated: rescanning\", path)\n\t}\n\n\t// #6326 - update basename in case it changed\n\tbase.Basename = f.Basename\n\tbase.ModTime = fileModTime\n\tbase.Size = f.Size\n\tbase.UpdatedAt = time.Now()\n\n\t// calculate and update fingerprints for the file\n\tconst useExisting = false\n\tfp, err := s.calculateFingerprints(f.FS, base, path, useExisting)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\toldFingerprints := existing.Base().Fingerprints\n\tfingerprintChanged := fp.ContentsChanged(oldFingerprints)\n\n\ts.removeOutdatedFingerprints(existing, fp)\n\texisting.SetFingerprints(fp)\n\n\texisting, err = s.fireDecorators(ctx, f.FS, existing)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// queue file for update\n\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tif err := s.Repository.File.Update(ctx, existing); err != nil {\n\t\t\treturn fmt.Errorf(\"updating file %q: %w\", path, err)\n\t\t}\n\n\t\tif err := s.fireHandlers(ctx, existing, &oldBase); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ScanFileResult{\n\t\tFile:               existing,\n\t\tUpdated:            true,\n\t\tFingerprintChanged: fingerprintChanged,\n\t}, nil\n}\n\nfunc (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fingerprints) {\n\t// HACK - if no MD5 fingerprint was returned, and the oshash is changed\n\t// then remove the MD5 fingerprint\n\toshash := fp.For(models.FingerprintTypeOshash)\n\tif oshash == nil {\n\t\treturn\n\t}\n\n\texistingOshash := existing.Base().Fingerprints.For(models.FingerprintTypeOshash)\n\tif existingOshash == nil || *existingOshash == *oshash {\n\t\t// missing oshash or same oshash - nothing to do\n\t\treturn\n\t}\n\n\tmd5 := fp.For(models.FingerprintTypeMD5)\n\n\tif md5 != nil {\n\t\t// nothing to do\n\t\treturn\n\t}\n\n\t// oshash has changed, MD5 is missing - remove MD5 from the existing fingerprints\n\tlogger.Infof(\"Removing outdated checksum from %s\", existing.Base().Path)\n\tb := existing.Base()\n\tb.Fingerprints = b.Fingerprints.Remove(models.FingerprintTypeMD5)\n}\n\n// returns a file only if it was updated\nfunc (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) {\n\tvar err error\n\n\tisMissingMetdata := s.isMissingMetadata(ctx, f, existing)\n\t// set missing information\n\tif isMissingMetdata {\n\t\texisting, err = s.setMissingMetadata(ctx, f, existing)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// calculate missing fingerprints\n\texisting, err = s.setMissingFingerprints(ctx, f, existing)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\thandlerRequired := false\n\tif err := s.Repository.WithDB(ctx, func(ctx context.Context) error {\n\t\t// check if the handler needs to be run\n\t\thandlerRequired = s.isHandlerRequired(ctx, existing)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !handlerRequired {\n\t\t// if this file is a zip file, then we need to rescan the contents\n\t\t// as well. We do this by indicating that the file is updated.\n\t\tif isMissingMetdata {\n\t\t\treturn &ScanFileResult{\n\t\t\t\tFile:    existing,\n\t\t\t\tUpdated: true,\n\t\t\t}, nil\n\t\t}\n\n\t\treturn &ScanFileResult{\n\t\t\tFile: existing,\n\t\t}, nil\n\t}\n\n\tif err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {\n\t\tif err := s.fireHandlers(ctx, existing, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// if this file is a zip file, then we need to rescan the contents\n\t// as well. We do this by indicating that the file is updated.\n\treturn &ScanFileResult{\n\t\tFile:    existing,\n\t\tUpdated: true,\n\t}, nil\n}\n"
  },
  {
    "path": "pkg/file/stashignore.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\n\tlru \"github.com/hashicorp/golang-lru/v2\"\n\tignore \"github.com/sabhiram/go-gitignore\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst stashIgnoreFilename = \".stashignore\"\n\n// entriesCacheSize is the size of the LRU cache for collected ignore entries.\n// This cache stores the computed list of ignore entries per directory, avoiding\n// repeated directory tree walks for files in the same directory.\nconst entriesCacheSize = 500\n\n// StashIgnoreFilter implements PathFilter to exclude files/directories\n// based on .stashignore files with gitignore-style patterns.\ntype StashIgnoreFilter struct {\n\t// cache stores compiled ignore patterns per directory.\n\tcache sync.Map // map[string]*ignoreEntry\n\t// entriesCache stores collected ignore entries per (dir, libraryRoot) pair.\n\t// This avoids recomputing the entry list for every file in the same directory.\n\tentriesCache *lru.Cache[string, []*ignoreEntry]\n}\n\n// ignoreEntry holds the compiled ignore patterns for a directory.\ntype ignoreEntry struct {\n\t// patterns is the compiled gitignore matcher for this directory.\n\tpatterns *ignore.GitIgnore\n\t// dir is the directory this entry applies to.\n\tdir string\n}\n\n// NewStashIgnoreFilter creates a new StashIgnoreFilter.\nfunc NewStashIgnoreFilter() *StashIgnoreFilter {\n\t// Create the LRU cache for collected entries.\n\t// Ignore error as it only fails if size <= 0.\n\tentriesCache, _ := lru.New[string, []*ignoreEntry](entriesCacheSize)\n\treturn &StashIgnoreFilter{\n\t\tentriesCache: entriesCache,\n\t}\n}\n\n// Accept returns true if the path should be included in the scan.\n// It checks for .stashignore files in the directory hierarchy and\n// applies gitignore-style pattern matching.\n// The libraryRoot parameter bounds the search for .stashignore files -\n// only directories within the library root are checked.\n// zipFilepath is the path of the zip file if the file is inside a zip.\n// .stashignore files will not be read within zip files.\nfunc (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string, zipFilePath string) bool {\n\t// If no library root provided, accept the file (safety fallback).\n\tif libraryRoot == \"\" {\n\t\treturn true\n\t}\n\n\t// Get the directory containing this path.\n\tdir := filepath.Dir(path)\n\n\t// If the file is inside a zip, use the zip file's directory as the base for .stashignore lookup.\n\tif zipFilePath != \"\" {\n\t\tdir = filepath.Dir(zipFilePath)\n\t}\n\n\t// Collect all applicable ignore entries from library root to this directory.\n\tentries := f.collectIgnoreEntries(dir, libraryRoot)\n\n\t// If no .stashignore files found, accept the file.\n\tif len(entries) == 0 {\n\t\treturn true\n\t}\n\n\t// Check each ignore entry in order (from root to most specific).\n\t// Later entries can override earlier ones with negation patterns.\n\tignored := false\n\tfor _, entry := range entries {\n\t\t// Get path relative to the ignore file's directory.\n\t\tentryRelPath, err := filepath.Rel(entry.dir, path)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tentryRelPath = filepath.ToSlash(entryRelPath)\n\t\tif info.IsDir() {\n\t\t\tentryRelPath += \"/\"\n\t\t}\n\n\t\tif entry.patterns.MatchesPath(entryRelPath) {\n\t\t\tignored = true\n\t\t}\n\t}\n\n\treturn !ignored\n}\n\n// collectIgnoreEntries gathers all ignore entries from library root to the given directory.\n// It walks up the directory tree from dir to libraryRoot and returns entries in order\n// from root to most specific. Results are cached to avoid repeated computation for\n// files in the same directory.\nfunc (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry {\n\t// Clean paths for consistent comparison and cache key generation.\n\tdir = filepath.Clean(dir)\n\tlibraryRoot = filepath.Clean(libraryRoot)\n\n\t// Build cache key from dir and libraryRoot.\n\tcacheKey := dir + \"\\x00\" + libraryRoot\n\n\t// Check the entries cache first.\n\tif cached, ok := f.entriesCache.Get(cacheKey); ok {\n\t\treturn cached\n\t}\n\n\t// Try subdirectory shortcut: if parent's entries are cached, extend them.\n\tif dir != libraryRoot {\n\t\tparent := filepath.Dir(dir)\n\t\tif isPathInOrEqual(libraryRoot, parent) {\n\t\t\tparentKey := parent + \"\\x00\" + libraryRoot\n\t\t\tif parentEntries, ok := f.entriesCache.Get(parentKey); ok {\n\t\t\t\t// Parent is cached - just check if current dir has a .stashignore.\n\t\t\t\tentries := parentEntries\n\t\t\t\tif entry := f.getOrLoadIgnoreEntry(dir); entry != nil {\n\t\t\t\t\t// Copy parent slice and append to avoid mutating cached slice.\n\t\t\t\t\tentries = make([]*ignoreEntry, len(parentEntries), len(parentEntries)+1)\n\t\t\t\t\tcopy(entries, parentEntries)\n\t\t\t\t\tentries = append(entries, entry)\n\t\t\t\t}\n\t\t\t\tf.entriesCache.Add(cacheKey, entries)\n\t\t\t\treturn entries\n\t\t\t}\n\t\t}\n\t}\n\n\t// No cache hit - compute from scratch.\n\t// Walk up from dir to library root, collecting directories.\n\tvar dirs []string\n\tcurrent := dir\n\tfor {\n\t\t// Check if we're still within the library root.\n\t\tif !isPathInOrEqual(libraryRoot, current) {\n\t\t\tbreak\n\t\t}\n\n\t\tdirs = append(dirs, current)\n\n\t\t// Stop if we've reached the library root.\n\t\tif current == libraryRoot {\n\t\t\tbreak\n\t\t}\n\n\t\tparent := filepath.Dir(current)\n\t\tif parent == current {\n\t\t\t// Reached filesystem root without finding library root.\n\t\t\tbreak\n\t\t}\n\t\tcurrent = parent\n\t}\n\n\t// Reverse to get root-to-leaf order.\n\tfor i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 {\n\t\tdirs[i], dirs[j] = dirs[j], dirs[i]\n\t}\n\n\t// Check each directory for .stashignore files.\n\tvar entries []*ignoreEntry\n\tfor _, d := range dirs {\n\t\tif entry := f.getOrLoadIgnoreEntry(d); entry != nil {\n\t\t\tentries = append(entries, entry)\n\t\t}\n\t}\n\n\t// Cache the result.\n\tf.entriesCache.Add(cacheKey, entries)\n\n\treturn entries\n}\n\n// isPathInOrEqual checks if path is equal to or inside root.\nfunc isPathInOrEqual(root, path string) bool {\n\tif path == root {\n\t\treturn true\n\t}\n\t// Check if path starts with root + separator.\n\treturn strings.HasPrefix(path, root+string(filepath.Separator))\n}\n\n// getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it.\nfunc (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry {\n\t// Check cache first.\n\tif cached, ok := f.cache.Load(dir); ok {\n\t\tentry := cached.(*ignoreEntry)\n\t\tif entry.patterns == nil {\n\t\t\treturn nil // Cached negative result.\n\t\t}\n\t\treturn entry\n\t}\n\n\t// Try to load .stashignore from this directory.\n\tstashIgnorePath := filepath.Join(dir, stashIgnoreFilename)\n\tpatterns, err := f.loadIgnoreFile(stashIgnorePath)\n\tif err != nil {\n\t\tif !os.IsNotExist(err) {\n\t\t\tlogger.Warnf(\"Failed to load .stashignore from %s: %v\", dir, err)\n\t\t}\n\t\tf.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})\n\t\treturn nil\n\t}\n\tif patterns == nil {\n\t\t// File exists but has no patterns (empty or only comments).\n\t\tf.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})\n\t\treturn nil\n\t}\n\n\tlogger.Debugf(\"Loaded .stashignore from %s\", dir)\n\n\tentry := &ignoreEntry{\n\t\tpatterns: patterns,\n\t\tdir:      dir,\n\t}\n\tf.cache.Store(dir, entry)\n\treturn entry\n}\n\n// loadIgnoreFile loads and compiles a .stashignore file.\nfunc (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlines := strings.Split(string(data), \"\\n\")\n\tvar patterns []string\n\n\tfor _, line := range lines {\n\t\t// Trim trailing whitespace (but preserve leading for patterns).\n\t\tline = strings.TrimRight(line, \" \\t\\r\")\n\n\t\t// Skip empty lines.\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Skip comments (but not escaped #).\n\t\tif strings.HasPrefix(line, \"#\") && !strings.HasPrefix(line, \"\\\\#\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tpatterns = append(patterns, line)\n\t}\n\n\tif len(patterns) == 0 {\n\t\t// File exists but has no patterns (e.g., only comments).\n\t\treturn nil, nil\n\t}\n\n\treturn ignore.CompileIgnoreLines(patterns...), nil\n}\n"
  },
  {
    "path": "pkg/file/stashignore_test.go",
    "content": "package file\n\nimport (\n\t\"context\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"testing\"\n)\n\n// Helper to create an empty file.\nfunc createTestFile(t *testing.T, dir, name string) {\n\tt.Helper()\n\tpath := filepath.Join(dir, name)\n\tif err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {\n\t\tt.Fatalf(\"failed to create directory for %s: %v\", path, err)\n\t}\n\tif err := os.WriteFile(path, []byte{}, 0644); err != nil {\n\t\tt.Fatalf(\"failed to create file %s: %v\", path, err)\n\t}\n}\n\n// Helper to create a file with content.\nfunc createTestFileWithContent(t *testing.T, dir, name, content string) {\n\tt.Helper()\n\tpath := filepath.Join(dir, name)\n\tif err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {\n\t\tt.Fatalf(\"failed to create directory for %s: %v\", path, err)\n\t}\n\tif err := os.WriteFile(path, []byte(content), 0644); err != nil {\n\t\tt.Fatalf(\"failed to create file %s: %v\", path, err)\n\t}\n}\n\n// Helper to create a directory.\nfunc createTestDir(t *testing.T, dir, name string) {\n\tt.Helper()\n\tpath := filepath.Join(dir, name)\n\tif err := os.MkdirAll(path, 0755); err != nil {\n\t\tt.Fatalf(\"failed to create directory %s: %v\", path, err)\n\t}\n}\n\n// walkAndFilter walks the directory tree and returns paths accepted by the filter.\n// Returns paths relative to root for easier assertion.\nfunc walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []string {\n\tt.Helper()\n\tvar accepted []string\n\tctx := context.Background()\n\n\terr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Skip the root directory itself.\n\t\tif path == root {\n\t\t\treturn nil\n\t\t}\n\n\t\tinfo, err := d.Info()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif filter.Accept(ctx, path, info, root, \"\") {\n\t\t\trelPath, _ := filepath.Rel(root, path)\n\t\t\taccepted = append(accepted, relPath)\n\t\t} else if info.IsDir() {\n\t\t\t// If directory is rejected, skip it.\n\t\t\treturn filepath.SkipDir\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\tt.Fatalf(\"walk failed: %v\", err)\n\t}\n\n\tsort.Strings(accepted)\n\treturn accepted\n}\n\n// assertPathsEqual checks that the accepted paths match expected.\nfunc assertPathsEqual(t *testing.T, expected, actual []string) {\n\tt.Helper()\n\tsort.Strings(expected)\n\n\tif len(expected) != len(actual) {\n\t\tt.Errorf(\"path count mismatch:\\nexpected %d: %v\\nactual %d: %v\", len(expected), expected, len(actual), actual)\n\t\treturn\n\t}\n\n\tfor i := range expected {\n\t\tif expected[i] != actual[i] {\n\t\t\tt.Errorf(\"path mismatch at index %d:\\nexpected: %s\\nactual: %s\", i, expected[i], actual[i])\n\t\t}\n\t}\n}\n\nfunc TestStashIgnore_ExactFilename(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestFile(t, tmpDir, \"video2.mp4\")\n\tcreateTestFile(t, tmpDir, \"ignore_me.mp4\")\n\n\t// Create .stashignore that excludes exact filename.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"ignore_me.mp4\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"video1.mp4\",\n\t\t\"video2.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_WildcardPattern(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestFile(t, tmpDir, \"video2.mp4\")\n\tcreateTestFile(t, tmpDir, \"temp1.tmp\")\n\tcreateTestFile(t, tmpDir, \"temp2.tmp\")\n\tcreateTestFile(t, tmpDir, \"notes.log\")\n\n\t// Create .stashignore that excludes by extension.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"*.tmp\\n*.log\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"video1.mp4\",\n\t\t\"video2.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_DirectoryExclusion(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestDir(t, tmpDir, \"excluded_dir\")\n\tcreateTestFile(t, tmpDir, \"excluded_dir/video2.mp4\")\n\tcreateTestFile(t, tmpDir, \"excluded_dir/video3.mp4\")\n\tcreateTestDir(t, tmpDir, \"included_dir\")\n\tcreateTestFile(t, tmpDir, \"included_dir/video4.mp4\")\n\n\t// Create .stashignore that excludes a directory.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"excluded_dir/\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"included_dir\",\n\t\t\"included_dir/video4.mp4\",\n\t\t\"video1.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_NegationPattern(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"file1.tmp\")\n\tcreateTestFile(t, tmpDir, \"file2.tmp\")\n\tcreateTestFile(t, tmpDir, \"keep_this.tmp\")\n\n\t// Create .stashignore that excludes *.tmp but keeps one.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"*.tmp\\n!keep_this.tmp\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"keep_this.tmp\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_CommentsAndEmptyLines(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestFile(t, tmpDir, \"ignore_me.mp4\")\n\n\t// Create .stashignore with comments and empty lines.\n\tstashignore := `# This is a comment\nignore_me.mp4\n\n# Another comment\n\n`\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", stashignore)\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"video1.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_NestedStashIgnoreFiles(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"root_video.mp4\")\n\tcreateTestFile(t, tmpDir, \"root_ignore.tmp\")\n\tcreateTestDir(t, tmpDir, \"subdir\")\n\tcreateTestFile(t, tmpDir, \"subdir/sub_video.mp4\")\n\tcreateTestFile(t, tmpDir, \"subdir/sub_ignore.log\")\n\tcreateTestFile(t, tmpDir, \"subdir/also_tmp.tmp\")\n\n\t// Root .stashignore excludes *.tmp.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"*.tmp\\n\")\n\n\t// Subdir .stashignore excludes *.log.\n\tcreateTestFileWithContent(t, tmpDir, \"subdir/.stashignore\", \"*.log\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\t// *.tmp from root should apply everywhere.\n\t// *.log from subdir should only apply in subdir.\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"root_video.mp4\",\n\t\t\"subdir\",\n\t\t\"subdir/.stashignore\",\n\t\t\"subdir/sub_video.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_PathPattern(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestDir(t, tmpDir, \"subdir\")\n\tcreateTestFile(t, tmpDir, \"subdir/video2.mp4\")\n\tcreateTestFile(t, tmpDir, \"subdir/skip_this.mp4\")\n\n\t// Create .stashignore that excludes a specific path.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"subdir/skip_this.mp4\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"subdir\",\n\t\t\"subdir/video2.mp4\",\n\t\t\"video1.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_DoubleStarPattern(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestDir(t, tmpDir, \"a\")\n\tcreateTestFile(t, tmpDir, \"a/video2.mp4\")\n\tcreateTestDir(t, tmpDir, \"a/temp\")\n\tcreateTestFile(t, tmpDir, \"a/temp/video3.mp4\")\n\tcreateTestDir(t, tmpDir, \"a/b\")\n\tcreateTestDir(t, tmpDir, \"a/b/temp\")\n\tcreateTestFile(t, tmpDir, \"a/b/temp/video4.mp4\")\n\n\t// Create .stashignore that excludes temp directories at any level.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"**/temp/\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"a\",\n\t\t\"a/b\",\n\t\t\"a/video2.mp4\",\n\t\t\"video1.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_LeadingSlashPattern(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"ignore.mp4\")\n\tcreateTestDir(t, tmpDir, \"subdir\")\n\tcreateTestFile(t, tmpDir, \"subdir/ignore.mp4\")\n\n\t// Create .stashignore that excludes only at root level.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"/ignore.mp4\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\t// Only root ignore.mp4 should be excluded.\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"subdir\",\n\t\t\"subdir/ignore.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_NoStashIgnoreFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files without any .stashignore.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestFile(t, tmpDir, \"video2.mp4\")\n\tcreateTestDir(t, tmpDir, \"subdir\")\n\tcreateTestFile(t, tmpDir, \"subdir/video3.mp4\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\t// All files should be accepted.\n\texpected := []string{\n\t\t\"subdir\",\n\t\t\"subdir/video3.mp4\",\n\t\t\"video1.mp4\",\n\t\t\"video2.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_HiddenDirectories(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files including hidden directory.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestDir(t, tmpDir, \".hidden\")\n\tcreateTestFile(t, tmpDir, \".hidden/video2.mp4\")\n\n\t// Create .stashignore that excludes hidden directories.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \".*\\n!.stashignore\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"video1.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_MultiplePatternsSameLine(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestFile(t, tmpDir, \"file.tmp\")\n\tcreateTestFile(t, tmpDir, \"file.log\")\n\tcreateTestFile(t, tmpDir, \"file.bak\")\n\n\t// Each pattern should be on its own line.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"*.tmp\\n*.log\\n*.bak\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"video1.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_TrailingSpaces(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestFile(t, tmpDir, \"ignore_me.mp4\")\n\n\t// Pattern with trailing spaces (should be trimmed).\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"ignore_me.mp4   \\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"video1.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_EscapedHash(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestFile(t, tmpDir, \"#filename.mp4\")\n\n\t// Escaped hash should match literal # character.\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"\\\\#filename.mp4\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"video1.mp4\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_CaseSensitiveMatching(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create test files - use distinct names that work on all filesystems.\n\tcreateTestFile(t, tmpDir, \"video_lower.mp4\")\n\tcreateTestFile(t, tmpDir, \"VIDEO_UPPER.mp4\")\n\tcreateTestFile(t, tmpDir, \"other.avi\")\n\n\t// Pattern should match exactly (case-sensitive).\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", \"video_lower.mp4\\n\")\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\t// Only exact match is excluded.\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"VIDEO_UPPER.mp4\",\n\t\t\"other.avi\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n\nfunc TestStashIgnore_ComplexScenario(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create a complex directory structure.\n\tcreateTestFile(t, tmpDir, \"video1.mp4\")\n\tcreateTestFile(t, tmpDir, \"video2.avi\")\n\tcreateTestFile(t, tmpDir, \"thumbnail.jpg\")\n\tcreateTestFile(t, tmpDir, \"metadata.nfo\")\n\tcreateTestDir(t, tmpDir, \"movies\")\n\tcreateTestFile(t, tmpDir, \"movies/movie1.mp4\")\n\tcreateTestFile(t, tmpDir, \"movies/movie1.nfo\")\n\tcreateTestDir(t, tmpDir, \"movies/.thumbnails\")\n\tcreateTestFile(t, tmpDir, \"movies/.thumbnails/thumb1.jpg\")\n\tcreateTestDir(t, tmpDir, \"temp\")\n\tcreateTestFile(t, tmpDir, \"temp/processing.mp4\")\n\tcreateTestDir(t, tmpDir, \"backup\")\n\tcreateTestFile(t, tmpDir, \"backup/video1.mp4.bak\")\n\n\t// Complex .stashignore.\n\tstashignore := `# Ignore metadata files\n*.nfo\n\n# Ignore hidden directories\n.*\n!.stashignore\n\n# Ignore temp and backup directories\ntemp/\nbackup/\n\n# But keep thumbnails in specific location\n!movies/.thumbnails/\n`\n\tcreateTestFileWithContent(t, tmpDir, \".stashignore\", stashignore)\n\n\tfilter := NewStashIgnoreFilter()\n\taccepted := walkAndFilter(t, tmpDir, filter)\n\n\texpected := []string{\n\t\t\".stashignore\",\n\t\t\"movies\",\n\t\t\"movies/.thumbnails\",\n\t\t\"movies/.thumbnails/thumb1.jpg\",\n\t\t\"movies/movie1.mp4\",\n\t\t\"thumbnail.jpg\",\n\t\t\"video1.mp4\",\n\t\t\"video2.avi\",\n\t}\n\n\tassertPathsEqual(t, expected, accepted)\n}\n"
  },
  {
    "path": "pkg/file/video/caption.go",
    "content": "package video\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/asticode/go-astisub\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\t\"golang.org/x/text/language\"\n)\n\nvar CaptionExts = []string{\"vtt\", \"srt\"} // in a case where vtt and srt files are both provided prioritize vtt file due to native support\n\n// to be used for captions without a language code in the filename\n// ISO 639-1 uses 2 or 3 a-z chars for codes so 00 is a safe non valid choise\n// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes\nconst LangUnknown = \"00\"\n\n// GetCaptionPath generates the path of a caption\n// from a given file path, wanted language and caption sufffix\nfunc GetCaptionPath(path, lang, suffix string) string {\n\text := filepath.Ext(path)\n\tfn := strings.TrimSuffix(path, ext)\n\tcaptionExt := \"\"\n\tif len(lang) == 0 || lang == LangUnknown {\n\t\tcaptionExt = suffix\n\t} else {\n\t\tcaptionExt = lang + \".\" + suffix\n\t}\n\treturn fn + \".\" + captionExt\n}\n\n// ReadSubs reads a captions file\nfunc ReadSubs(path string) (*astisub.Subtitles, error) {\n\treturn astisub.OpenFile(path)\n}\n\n// IsValidLanguage checks whether the given string is a valid\n// ISO 639 language code\nfunc IsValidLanguage(lang string) bool {\n\t_, err := language.ParseBase(lang)\n\treturn err == nil\n}\n\n// IsLangInCaptions returns true if lang is present\n// in the captions\nfunc IsLangInCaptions(lang string, ext string, captions []*models.VideoCaption) bool {\n\tfor _, caption := range captions {\n\t\tif lang == caption.LanguageCode && ext == caption.CaptionType {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// getCaptionPrefix returns the prefix used to search for video files for the provided caption path\nfunc getCaptionPrefix(captionPath string) string {\n\tbasename := strings.TrimSuffix(captionPath, filepath.Ext(captionPath)) // caption filename without the extension\n\n\t// a caption file can be something like scene_filename.srt or scene_filename.en.srt\n\t// if a language code is present and valid remove it from the basename\n\tlanguageExt := filepath.Ext(basename)\n\tif len(languageExt) > 2 && IsValidLanguage(languageExt[1:]) {\n\t\tbasename = strings.TrimSuffix(basename, languageExt)\n\t}\n\n\treturn basename + \".\"\n}\n\n// GetCaptionsLangFromPath returns the language code from a given captions path\n// If no valid language is present LangUknown is returned\nfunc getCaptionsLangFromPath(captionPath string) string {\n\tlangCode := LangUnknown\n\tbasename := strings.TrimSuffix(captionPath, filepath.Ext(captionPath)) // caption filename without the extension\n\tlanguageExt := filepath.Ext(basename)\n\tif len(languageExt) > 2 && IsValidLanguage(languageExt[1:]) {\n\t\tlangCode = languageExt[1:]\n\t}\n\treturn langCode\n}\n\ntype CaptionUpdater interface {\n\tGetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error)\n\tUpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error\n}\n\n// MatchesCaption returns true if the caption file matches the video file based on the filename\nfunc MatchesCaption(videoPath, captionPath string) bool {\n\tcaptionPrefix := getCaptionPrefix(captionPath)\n\tvideoPrefix := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + \".\"\n\treturn captionPrefix == videoPrefix\n}\n\n// associates captions to scene/s with the same basename\n// returns true if the caption file was matched to a video file and processed, false otherwise\nfunc AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) bool {\n\tcaptionLang := getCaptionsLangFromPath(captionPath)\n\n\tcaptionPrefix := getCaptionPrefix(captionPath)\n\tmatched := false\n\tif err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error {\n\t\tvar err error\n\t\tfiles, er := fqb.FindAllByPath(ctx, captionPrefix+\"*\", true)\n\n\t\tif er != nil {\n\t\t\treturn fmt.Errorf(\"searching for scene %s: %w\", captionPrefix, er)\n\t\t}\n\n\t\tfor _, f := range files {\n\t\t\t// found some files\n\t\t\t// filter out non video files\n\t\t\tswitch f.(type) {\n\t\t\tcase *models.VideoFile:\n\t\t\t\tbreak\n\t\t\tdefault:\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfileID := f.Base().ID\n\t\t\tpath := f.Base().Path\n\n\t\t\tlogger.Debugf(\"Matched captions to file %s\", path)\n\t\t\tmatched = true\n\n\t\t\tcaptions, er := w.GetCaptions(ctx, fileID)\n\t\t\tif er != nil {\n\t\t\t\treturn fmt.Errorf(\"getting captions for file %s: %w\", path, er)\n\t\t\t}\n\n\t\t\tfileExt := filepath.Ext(captionPath)\n\t\t\text := fileExt[1:]\n\t\t\tif !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present\n\t\t\t\tnewCaption := &models.VideoCaption{\n\t\t\t\t\tLanguageCode: captionLang,\n\t\t\t\t\tFilename:     filepath.Base(captionPath),\n\t\t\t\t\tCaptionType:  ext,\n\t\t\t\t}\n\t\t\t\tcaptions = append(captions, newCaption)\n\t\t\t\ter = w.UpdateCaptions(ctx, fileID, captions)\n\t\t\t\tif er != nil {\n\t\t\t\t\treturn fmt.Errorf(\"updating captions for file %s: %w\", path, er)\n\t\t\t\t}\n\n\t\t\t\tlogger.Debugf(\"Updated captions for file %s. Added %s\", path, captionLang)\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}); err != nil {\n\t\tlogger.Error(err.Error())\n\t}\n\n\treturn matched\n}\n\n// CleanCaptions removes non existent/accessible language codes from captions\nfunc CleanCaptions(ctx context.Context, f *models.VideoFile, txnMgr txn.Manager, w CaptionUpdater) error {\n\tcaptions, err := w.GetCaptions(ctx, f.ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting captions for file %s: %w\", f.Path, err)\n\t}\n\n\tif len(captions) == 0 {\n\t\treturn nil\n\t}\n\n\tfilePath := f.Path\n\n\tchanged := false\n\tvar newCaptions []*models.VideoCaption\n\n\tfor _, caption := range captions {\n\t\tcaptionPath := caption.Path(filePath)\n\t\t_, err := os.Stat(captionPath)\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\tlogger.Infof(\"Removing non existent caption %s for %s\", caption.Filename, f.Path)\n\t\t\tchanged = true\n\t\t} else {\n\t\t\t// other errors are ignored for the purposes of cleaning\n\t\t\tnewCaptions = append(newCaptions, caption)\n\t\t}\n\t}\n\n\tif changed {\n\t\tfn := func(ctx context.Context) error {\n\t\t\treturn w.UpdateCaptions(ctx, f.ID, newCaptions)\n\t\t}\n\n\t\t// possible that we are already in a transaction and txnMgr is nil\n\t\t// in that case just call the function directly\n\t\tif txnMgr == nil {\n\t\t\terr = fn(ctx)\n\t\t} else {\n\t\t\terr = txn.WithTxn(ctx, txnMgr, fn)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"updating captions for file %s: %w\", f.Path, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/file/video/caption_test.go",
    "content": "package video\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype testCase struct {\n\tcaptionPath    string\n\texpectedLang   string\n\texpectedResult string\n}\n\nvar testCases = []testCase{\n\t{\n\t\tcaptionPath:    \"/stash/video.vtt\",\n\t\texpectedLang:   LangUnknown,\n\t\texpectedResult: \"/stash/video.\",\n\t},\n\t{\n\t\tcaptionPath:    \"/stash/video.en.vtt\",\n\t\texpectedLang:   \"en\",\n\t\texpectedResult: \"/stash/video.\", // lang code valid, remove en part\n\t},\n\t{\n\t\tcaptionPath:    \"/stash/video.test.srt\",\n\t\texpectedLang:   LangUnknown,\n\t\texpectedResult: \"/stash/video.test.\", // no lang code/lang code invalid test should remain\n\t},\n\t{\n\t\tcaptionPath:    \"C:\\\\videos\\\\video.fr.srt\",\n\t\texpectedLang:   \"fr\",\n\t\texpectedResult: \"C:\\\\videos\\\\video.\",\n\t},\n\t{\n\t\tcaptionPath:    \"C:\\\\videos\\\\video.xx.srt\",\n\t\texpectedLang:   LangUnknown,\n\t\texpectedResult: \"C:\\\\videos\\\\video.xx.\", // no lang code/lang code invalid xx should remain\n\t},\n}\n\nfunc TestGenerateCaptionCandidates(t *testing.T) {\n\tfor _, c := range testCases {\n\t\tassert.Equal(t, c.expectedResult, getCaptionPrefix(c.captionPath))\n\t}\n}\n\nfunc TestGetCaptionsLangFromPath(t *testing.T) {\n\tfor _, l := range testCases {\n\t\tassert.Equal(t, l.expectedLang, getCaptionsLangFromPath(l.captionPath))\n\t}\n}\n"
  },
  {
    "path": "pkg/file/video/funscript.go",
    "content": "package video\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// GetFunscriptPath returns the path of a file\n// with the extension changed to .funscript\nfunc GetFunscriptPath(path string) string {\n\text := filepath.Ext(path)\n\tfn := strings.TrimSuffix(path, ext)\n\treturn fn + \".funscript\"\n}\n"
  },
  {
    "path": "pkg/file/video/scan.go",
    "content": "package video\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// Decorator adds video specific fields to a File.\ntype Decorator struct {\n\tFFProbe *ffmpeg.FFProbe\n}\n\nfunc (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) {\n\tif d.FFProbe == nil {\n\t\treturn f, errors.New(\"ffprobe not configured\")\n\t}\n\n\tbase := f.Base()\n\t// TODO - copy to temp file if not an OsFS\n\tif _, isOs := fs.(*file.OsFS); !isOs {\n\t\treturn f, fmt.Errorf(\"video.constructFile: only OsFS is supported\")\n\t}\n\n\tprobe := d.FFProbe\n\tvideoFile, err := probe.NewVideoFile(base.Path)\n\tif err != nil {\n\t\treturn f, fmt.Errorf(\"running ffprobe on %q: %w\", base.Path, err)\n\t}\n\n\tcontainer, err := ffmpeg.MatchContainer(videoFile.Container, base.Path)\n\tif err != nil {\n\t\treturn f, fmt.Errorf(\"matching container for %q: %w\", base.Path, err)\n\t}\n\n\t// check if there is a funscript file\n\tinteractive := false\n\tif _, err := fs.Lstat(GetFunscriptPath(base.Path)); err == nil {\n\t\tinteractive = true\n\t}\n\n\treturn &models.VideoFile{\n\t\tBaseFile:    base,\n\t\tFormat:      string(container),\n\t\tVideoCodec:  videoFile.VideoCodec,\n\t\tAudioCodec:  videoFile.AudioCodec,\n\t\tWidth:       videoFile.Width,\n\t\tHeight:      videoFile.Height,\n\t\tDuration:    videoFile.FileDuration,\n\t\tFrameRate:   videoFile.FrameRate,\n\t\tBitRate:     videoFile.Bitrate,\n\t\tInteractive: interactive,\n\t}, nil\n}\n\nfunc (d *Decorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool {\n\tconst (\n\t\tunsetString = \"unset\"\n\t\tunsetNumber = -1\n\t)\n\n\tvf, ok := f.(*models.VideoFile)\n\tif !ok {\n\t\treturn true\n\t}\n\n\tinteractive := false\n\tif _, err := fs.Lstat(GetFunscriptPath(vf.Base().Path)); err == nil {\n\t\tinteractive = true\n\t}\n\n\treturn vf.VideoCodec == unsetString || vf.AudioCodec == unsetString ||\n\t\tvf.Format == unsetString || vf.Width == unsetNumber ||\n\t\tvf.Height == unsetNumber || vf.FrameRate == unsetNumber ||\n\t\tvf.Duration == unsetNumber ||\n\t\tvf.BitRate == unsetNumber || interactive != vf.Interactive\n}\n"
  },
  {
    "path": "pkg/file/walk.go",
    "content": "package file\n\nimport (\n\t\"errors\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// Modified from github.com/facebookgo/symwalk\n\n// BSD License\n\n// For symwalk software\n\n// Copyright (c) 2015, Facebook, Inc. All rights reserved.\n\n// Redistribution and use in source and binary forms, with or without modification,\n// are permitted provided that the following conditions are met:\n\n//  * Redistributions of source code must retain the above copyright notice, this\n//    list of conditions and the following disclaimer.\n\n//  * Redistributions in binary form must reproduce the above copyright notice,\n//    this list of conditions and the following disclaimer in the documentation\n//    and/or other materials provided with the distribution.\n\n//  * Neither the name Facebook nor the names of its contributors may be used to\n//    endorse or promote products derived from this software without specific\n//    prior written permission.\n\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\n// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\n// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR\n// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\n// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON\n// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\n// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n// symwalkFunc calls the provided WalkFn for regular files.\n// However, when it encounters a symbolic link, it resolves the link fully using the\n// filepath.EvalSymlinks function and recursively calls symwalk.Walk on the resolved path.\n// This ensures that unlink filepath.Walk, traversal does not stop at symbolic links.\n//\n// Note that symwalk.Walk does not terminate if there are any non-terminating loops in\n// the file structure.\nfunc walkSym(f models.FS, filename string, linkDirname string, walkFn fs.WalkDirFunc) error {\n\tsymWalkFunc := func(path string, info fs.DirEntry, err error) error {\n\n\t\tif fname, err := filepath.Rel(filename, path); err == nil {\n\t\t\tpath = filepath.Join(linkDirname, fname)\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\n\t\tif err == nil && info.Type()&os.ModeSymlink == os.ModeSymlink {\n\t\t\tfinalPath, err := filepath.EvalSymlinks(path)\n\t\t\tif err != nil {\n\t\t\t\t// don't bail out if symlink is invalid\n\t\t\t\treturn walkFn(path, info, err)\n\t\t\t}\n\t\t\tinfo, err := f.Lstat(finalPath)\n\t\t\tif err != nil {\n\t\t\t\treturn walkFn(path, &statDirEntry{\n\t\t\t\t\tinfo: info,\n\t\t\t\t}, err)\n\t\t\t}\n\t\t\tif info.IsDir() {\n\t\t\t\treturn walkSym(f, finalPath, path, walkFn)\n\t\t\t}\n\t\t}\n\n\t\treturn walkFn(path, info, err)\n\t}\n\treturn fsWalk(f, filename, symWalkFunc)\n}\n\n// SymWalk extends filepath.Walk to also follow symlinks\nfunc SymWalk(fs models.FS, path string, walkFn fs.WalkDirFunc) error {\n\treturn walkSym(fs, path, path, walkFn)\n}\n\ntype statDirEntry struct {\n\tinfo fs.FileInfo\n}\n\nfunc (d *statDirEntry) Name() string               { return d.info.Name() }\nfunc (d *statDirEntry) IsDir() bool                { return d.info.IsDir() }\nfunc (d *statDirEntry) Type() fs.FileMode          { return d.info.Mode().Type() }\nfunc (d *statDirEntry) Info() (fs.FileInfo, error) { return d.info, nil }\n\nfunc fsWalk(f models.FS, root string, fn fs.WalkDirFunc) error {\n\tinfo, err := f.Lstat(root)\n\tif err != nil {\n\t\terr = fn(root, nil, err)\n\t} else {\n\t\terr = walkDir(f, root, &statDirEntry{info}, fn)\n\t}\n\tif errors.Is(err, fs.SkipDir) {\n\t\treturn nil\n\t}\n\treturn err\n}\n\nfunc walkDir(f models.FS, path string, d fs.DirEntry, walkDirFn fs.WalkDirFunc) error {\n\tif err := walkDirFn(path, d, nil); err != nil || !d.IsDir() {\n\t\tif errors.Is(err, fs.SkipDir) && d.IsDir() {\n\t\t\t// Successfully skipped directory.\n\t\t\terr = nil\n\t\t}\n\t\treturn err\n\t}\n\n\tdirs, err := readDir(f, path)\n\tif err != nil {\n\t\t// Second call, to report ReadDir error.\n\t\terr = walkDirFn(path, d, err)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, d1 := range dirs {\n\t\tname := d1.Name()\n\t\t// Prevent infinite loops; this can happen with certain FS implementations (e.g. ZipFS).\n\t\tif name == \"\" || name == \".\" {\n\t\t\tcontinue\n\t\t}\n\t\tpath1 := filepath.Join(path, name)\n\t\tif err := walkDir(f, path1, d1, walkDirFn); err != nil {\n\t\t\tif errors.Is(err, fs.SkipDir) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// readDir reads the directory named by dirname and returns\n// a sorted list of directory entries.\nfunc readDir(fs models.FS, dirname string) ([]fs.DirEntry, error) {\n\tf, err := fs.Open(dirname)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdirs, err := f.ReadDir(-1)\n\tf.Close()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() })\n\treturn dirs, nil\n}\n"
  },
  {
    "path": "pkg/file/zip.go",
    "content": "package file\n\nimport (\n\t\"archive/zip\"\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/xWTF/chardet\"\n\n\t\"golang.org/x/net/html/charset\"\n\t\"golang.org/x/text/transform\"\n)\n\nvar (\n\tErrNotReaderAt  = errors.New(\"invalid reader: does not implement io.ReaderAt\")\n\terrZipFSOpenZip = errors.New(\"cannot open zip file inside zip file\")\n)\n\n// ZipFS is a file system backed by a zip file.\ntype zipFS struct {\n\t*zip.Reader\n\tzipFileCloser io.Closer\n\tzipPath       string\n}\n\nfunc newZipFS(fs models.FS, path string, size int64) (*zipFS, error) {\n\treader, err := fs.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tasReaderAt, _ := reader.(io.ReaderAt)\n\tif asReaderAt == nil {\n\t\treader.Close()\n\t\treturn nil, ErrNotReaderAt\n\t}\n\n\tzipReader, err := zip.NewReader(asReaderAt, size)\n\tif err != nil {\n\t\treader.Close()\n\t\treturn nil, err\n\t}\n\n\t// Concat all Name and Comment for better detection result\n\tvar buffer bytes.Buffer\n\tfor _, f := range zipReader.File {\n\t\tbuffer.WriteString(f.Name)\n\t\tbuffer.WriteString(f.Comment)\n\t}\n\tbuffer.WriteString(zipReader.Comment)\n\n\t// Detect encoding\n\td, err := chardet.NewTextDetector().DetectBest(buffer.Bytes())\n\tif err != nil {\n\t\t// If we can't detect the encoding, just assume it's UTF8\n\t\tlogger.Warnf(\"Unable to detect decoding for %s: %w\", path, err)\n\t}\n\n\t// If the charset is not UTF8, decode'em\n\tif d != nil && d.Charset != \"UTF-8\" {\n\t\tlogger.Debugf(\"Detected non-utf8 zip charset %s (%s): %s\", d.Charset, d.Language, path)\n\n\t\te, _ := charset.Lookup(d.Charset)\n\t\tif e == nil {\n\t\t\t// if we can't find the encoding, just assume it's UTF8\n\t\t\tlogger.Warnf(\"Failed to lookup charset %s, language %s\", d.Charset, d.Language)\n\t\t} else {\n\t\t\tdecoder := e.NewDecoder()\n\t\t\tfor _, f := range zipReader.File {\n\t\t\t\tnewName, _, err := transform.String(decoder, f.Name)\n\t\t\t\tif err != nil {\n\t\t\t\t\treader.Close()\n\t\t\t\t\tlogger.Warnf(\"Failed to decode %v: %v\", []byte(f.Name), err)\n\t\t\t\t} else {\n\t\t\t\t\tf.Name = newName\n\t\t\t\t}\n\t\t\t\t// Comments are not decoded cuz stash doesn't use that\n\t\t\t}\n\t\t}\n\t}\n\n\treturn &zipFS{\n\t\tReader:        zipReader,\n\t\tzipFileCloser: reader,\n\t\tzipPath:       path,\n\t}, nil\n}\n\nfunc (f *zipFS) rel(name string) (string, error) {\n\tif f.zipPath == name {\n\t\treturn \".\", nil\n\t}\n\n\trelName, err := filepath.Rel(f.zipPath, name)\n\tif err != nil {\n\t\t// if the path is not relative to the zip path, then it's not found in the zip file,\n\t\t// so treat this as a file not found\n\t\treturn \"\", fs.ErrNotExist\n\t}\n\n\t// convert relName to use slash, since zip files do so regardless\n\t// of os\n\trelName = filepath.ToSlash(relName)\n\n\treturn relName, nil\n}\n\nfunc (f *zipFS) Stat(name string) (fs.FileInfo, error) {\n\treader, err := f.Open(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer reader.Close()\n\n\treturn reader.Stat()\n}\n\nfunc (f *zipFS) Lstat(name string) (fs.FileInfo, error) {\n\treturn f.Stat(name)\n}\n\nfunc (f *zipFS) OpenZip(name string, size int64) (models.ZipFS, error) {\n\treturn nil, errZipFSOpenZip\n}\n\nfunc (f *zipFS) IsPathCaseSensitive(path string) (bool, error) {\n\treturn true, nil\n}\n\ntype zipReadDirFile struct {\n\tfs.File\n}\n\nfunc (f *zipReadDirFile) ReadDir(n int) ([]fs.DirEntry, error) {\n\tasReadDirFile, _ := f.File.(fs.ReadDirFile)\n\tif asReadDirFile == nil {\n\t\treturn nil, fmt.Errorf(\"internal error: not a ReadDirFile\")\n\t}\n\n\treturn asReadDirFile.ReadDir(n)\n}\n\nfunc (f *zipFS) Open(name string) (fs.ReadDirFile, error) {\n\trelName, err := f.rel(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tr, err := f.Reader.Open(relName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &zipReadDirFile{\n\t\tFile: r,\n\t}, nil\n}\n\nfunc (f *zipFS) Close() error {\n\treturn f.zipFileCloser.Close()\n}\n\n// openOnly returns a ReadCloser where calling Close will close the zip fs as well.\nfunc (f *zipFS) OpenOnly(name string) (io.ReadCloser, error) {\n\tr, err := f.Open(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &wrappedReadCloser{\n\t\tReadCloser: r,\n\t\touter:      f,\n\t}, nil\n}\n\ntype wrappedReadCloser struct {\n\tio.ReadCloser\n\touter io.Closer\n}\n\nfunc (f *wrappedReadCloser) Close() error {\n\t_ = f.ReadCloser.Close()\n\treturn f.outer.Close()\n}\n"
  },
  {
    "path": "pkg/fsutil/dir.go",
    "content": "package fsutil\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"os/user\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// DirExists returns true if the given path exists and is a directory\nfunc DirExists(path string) (bool, error) {\n\tfileInfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn false, fs.ErrNotExist\n\t}\n\tif !fileInfo.IsDir() {\n\t\treturn false, fmt.Errorf(\"path is not a directory <%s>\", path)\n\t}\n\treturn true, nil\n}\n\n// IsPathInDir returns true if pathToCheck is within dir.\nfunc IsPathInDir(dir, pathToCheck string) bool {\n\trel, err := filepath.Rel(dir, pathToCheck)\n\n\tif err == nil {\n\t\tif !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// IsPathInDirs returns true if pathToCheck is within anys of the paths in dirs.\nfunc IsPathInDirs(dirs []string, pathToCheck string) bool {\n\tfor _, dir := range dirs {\n\t\tif IsPathInDir(dir, pathToCheck) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// GetWorkingDirectory returns the current working directory.\nfunc GetWorkingDirectory() string {\n\tret, err := os.Getwd()\n\tif err != nil {\n\t\t// if we can't get cwd for whatever reason, just return \".\"\n\t\tret = \".\"\n\t}\n\treturn ret\n}\n\n// GetHomeDirectory returns the path of the user's home directory.  ~ on Unix and C:\\Users\\UserName on Windows\nfunc GetHomeDirectory() string {\n\tcurrentUser, err := user.Current()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn currentUser.HomeDir\n}\n\n// EnsureDir will create a directory at the given path if it doesn't already exist\nfunc EnsureDir(path string) error {\n\texists, err := DirExists(path)\n\tif !exists {\n\t\terr = os.Mkdir(path, 0755)\n\t\treturn err\n\t}\n\treturn err\n}\n\n// EnsureDirAll will create a directory at the given path along with any necessary parents if they don't already exist\nfunc EnsureDirAll(path string) error {\n\treturn os.MkdirAll(path, 0755)\n}\n\n// RemoveDir removes the given dir (if it exists) along with all of its contents\nfunc RemoveDir(path string) error {\n\treturn os.RemoveAll(path)\n}\n\n// EmptyDir will recursively remove the contents of a directory at the given path\nfunc EmptyDir(path string) error {\n\td, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer d.Close()\n\n\tnames, err := d.Readdirnames(-1)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, name := range names {\n\t\terr = os.RemoveAll(filepath.Join(path, name))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// GetIntraDir returns a string that can be added to filepath.Join to implement directory depth, \"\" on error\n// eg given a pattern of 0af63ce3c99162e9df23a997f62621c5 and a depth of 2 length of 3\n// returns 0af/63c or 0af\\63c ( dependin on os)  that can be later used like this  filepath.Join(directory, intradir, basename)\nfunc GetIntraDir(pattern string, depth, length int) string {\n\tif depth < 1 || length < 1 || (depth*length > len(pattern)) {\n\t\treturn \"\"\n\t}\n\tintraDir := pattern[0:length] // depth 1 , get length number of characters from pattern\n\tfor i := 1; i < depth; i++ {  // for every extra depth: move to the right of the pattern length positions, get length number of chars\n\t\tintraDir = filepath.Join(intraDir, pattern[length*i:length*(i+1)]) //  adding each time to intradir the extra characters with a filepath join\n\t}\n\treturn intraDir\n}\n"
  },
  {
    "path": "pkg/fsutil/dir_test.go",
    "content": "package fsutil\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestIsPathInDir(t *testing.T) {\n\ttype test struct {\n\t\tdir         string\n\t\tpathToCheck string\n\t\texpected    bool\n\t}\n\n\tconst parentDirName = \"parentDir\"\n\tconst subDirName = \"subDir\"\n\tconst filename = \"filename\"\n\tsubDir := filepath.Join(parentDirName, subDirName)\n\tfileInSubDir := filepath.Join(subDir, filename)\n\tfileInParentDir := filepath.Join(parentDirName, filename)\n\tsubSubSubDir := filepath.Join(parentDirName, subDirName, subDirName, subDirName)\n\n\ttests := []test{\n\t\t{dir: parentDirName, pathToCheck: subDir, expected: true},\n\t\t{dir: subDir, pathToCheck: subDir, expected: true},\n\t\t{dir: subDir, pathToCheck: parentDirName, expected: false},\n\t\t{dir: subDir, pathToCheck: fileInSubDir, expected: true},\n\t\t{dir: parentDirName, pathToCheck: fileInSubDir, expected: true},\n\t\t{dir: subDir, pathToCheck: fileInParentDir, expected: false},\n\t\t{dir: parentDirName, pathToCheck: fileInParentDir, expected: true},\n\t\t{dir: parentDirName, pathToCheck: filename, expected: false},\n\t\t{dir: parentDirName, pathToCheck: subSubSubDir, expected: true},\n\t\t{dir: subSubSubDir, pathToCheck: parentDirName, expected: false},\n\t}\n\n\tassert := assert.New(t)\n\tfor i, tc := range tests {\n\t\tresult := IsPathInDir(tc.dir, tc.pathToCheck)\n\t\tassert.Equal(tc.expected, result, \"[%d] expected: %t for dir: %s; pathToCheck: %s\", i, tc.expected, tc.dir, tc.pathToCheck)\n\t}\n}\n\nfunc TestDirExists(t *testing.T) {\n\ttype test struct {\n\t\tdir      string\n\t\texpected bool\n\t}\n\n\tconst st = \"stash_tmp\"\n\n\ttmp := os.TempDir()\n\ttmpDir, err := os.MkdirTemp(tmp, st) // create a tmp dir in the system's tmp folder\n\tif err == nil {\n\t\tdefer os.RemoveAll(tmpDir)\n\n\t\ttmpFile, err := os.CreateTemp(tmpDir, st)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\ttmpFile.Close()\n\n\t\ttests := []test{\n\t\t\t{dir: tmpDir, expected: true},                     // exists\n\t\t\t{dir: tmpFile.Name(), expected: false},            // not a directory\n\t\t\t{dir: filepath.Join(tmpDir, st), expected: false}, // doesn't exist\n\t\t\t{dir: \"\\000x\", expected: false},                   // stat error  \\000 (ASCII: NUL) is an invalid character in unix,ntfs file names.\n\t\t}\n\n\t\tassert := assert.New(t)\n\n\t\tfor i, tc := range tests {\n\t\t\tresult, _ := DirExists(tc.dir)\n\t\t\tassert.Equal(tc.expected, result, \"[%d] expected: %t for dir: %s;\", i, tc.expected, tc.dir)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/fsutil/file.go",
    "content": "package fsutil\n\nimport (\n\t\"crypto/sha1\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n)\n\n// CopyFile copies the contents of the file at srcpath to a regular file at dstpath.\n// It will copy the last modified timestamp\n// If dstpath already exists the function will fail.\nfunc CopyFile(srcpath, dstpath string) (err error) {\n\tr, err := os.Open(srcpath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tw, err := os.OpenFile(dstpath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0666)\n\tif err != nil {\n\t\tr.Close() // We need to close the input file as the defer below would not be called.\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tr.Close() // ok to ignore error: file was opened read-only.\n\t\te := w.Close()\n\t\t// Report the error from w.Close, if any.\n\t\t// But do so only if there isn't already an outgoing error.\n\t\tif e != nil && err == nil {\n\t\t\terr = e\n\t\t}\n\t\t// Copy modified time\n\t\tif err == nil {\n\t\t\t// io.Copy succeeded, we should fix the dstpath timestamp\n\t\t\tsrcFileInfo, e := os.Stat(srcpath)\n\t\t\tif e != nil {\n\t\t\t\terr = e\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\te = os.Chtimes(dstpath, srcFileInfo.ModTime(), srcFileInfo.ModTime())\n\t\t\tif e != nil {\n\t\t\t\terr = e\n\t\t\t}\n\t\t}\n\t}()\n\n\t_, err = io.Copy(w, r)\n\treturn err\n}\n\n// SafeMove attempts to move the file with path src to dest using os.Rename. If this fails, then it copies src to dest, then deletes src.\n// If the copy fails, or the delete fails, the function will return an error.\nfunc SafeMove(src, dst string) error {\n\terr := os.Rename(src, dst)\n\n\tif err != nil {\n\t\tcopyErr := CopyFile(src, dst)\n\t\tif copyErr != nil {\n\t\t\treturn fmt.Errorf(\"copying file during SaveMove failed with: '%w'; renaming file failed previously with: '%v'\", copyErr, err)\n\t\t}\n\n\t\tremoveErr := os.Remove(src)\n\t\tif removeErr != nil {\n\t\t\t// if we can't remove the old file, remove the new one and fail\n\t\t\t_ = os.Remove(dst)\n\t\t\treturn fmt.Errorf(\"removing old file during SafeMove failed with: '%w'; renaming file failed previously with: '%v'\", removeErr, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// MatchExtension returns true if the extension of the provided path\n// matches any of the provided extensions.\nfunc MatchExtension(path string, extensions []string) bool {\n\text := filepath.Ext(path)\n\tfor _, e := range extensions {\n\t\tif strings.EqualFold(ext, \".\"+e) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// FindInPaths returns the path to baseName in the first path where it exists from paths.\nfunc FindInPaths(paths []string, baseName string) string {\n\tfor _, p := range paths {\n\t\tfilePath := filepath.Join(p, baseName)\n\t\tif exists, _ := FileExists(filePath); exists {\n\t\t\treturn filePath\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// FileExists returns true if the given path exists and is a file.\n// This function returns false and the error encountered if the call to os.Stat fails.\nfunc FileExists(path string) (bool, error) {\n\tinfo, err := os.Stat(path)\n\tif err == nil {\n\t\treturn !info.IsDir(), nil\n\t}\n\treturn false, err\n}\n\n// WriteFile writes file to path creating parent directories if needed\nfunc WriteFile(path string, file []byte) error {\n\tpathErr := EnsureDirAll(filepath.Dir(path))\n\tif pathErr != nil {\n\t\treturn fmt.Errorf(\"cannot ensure path exists: %w\", pathErr)\n\t}\n\n\treturn os.WriteFile(path, file, 0755)\n}\n\n// GetNameFromPath returns the name of a file from its path\n// if stripExtension is true the extension is omitted from the name\nfunc GetNameFromPath(path string, stripExtension bool) string {\n\tfn := filepath.Base(path)\n\tif stripExtension {\n\t\text := filepath.Ext(fn)\n\t\tfn = strings.TrimSuffix(fn, ext)\n\t}\n\treturn fn\n}\n\n// Touch creates an empty file at the given path if it doesn't already exist\nfunc Touch(path string) error {\n\tvar _, err = os.Stat(path)\n\tif os.IsNotExist(err) {\n\t\tvar file, err = os.Create(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer file.Close()\n\t}\n\treturn nil\n}\n\nvar (\n\treplaceCharsRE = regexp.MustCompile(`[&=\\\\/:*\"?_ ]`)\n\tremoveCharsRE  = regexp.MustCompile(`[^[:alnum:]-.]`)\n\tmultiHyphenRE  = regexp.MustCompile(`\\-+`)\n)\n\n// SanitiseBasename returns a file basename removing any characters that are illegal or problematic to use in the filesystem.\n// It appends a short hash of the original string to ensure uniqueness.\nfunc SanitiseBasename(v string) string {\n\t// Generate a short hash for uniqueness\n\thash := sha1.Sum([]byte(v))\n\tshortHash := hex.EncodeToString(hash[:4]) // Use the first 4 bytes of the hash\n\n\tv = strings.TrimSpace(v)\n\n\t// replace illegal filename characters with -\n\tv = replaceCharsRE.ReplaceAllString(v, \"-\")\n\n\t// remove other characters\n\tv = removeCharsRE.ReplaceAllString(v, \"\")\n\n\t// remove multiple hyphens\n\tv = multiHyphenRE.ReplaceAllString(v, \"-\")\n\n\treturn strings.TrimSpace(v) + \"-\" + shortHash\n}\n\n// GetExeName returns the name of the given executable for the current platform.\n// One windows it returns the name with the .exe extension.\nfunc GetExeName(base string) string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn base + \".exe\"\n\t}\n\treturn base\n}\n"
  },
  {
    "path": "pkg/fsutil/file_test.go",
    "content": "package fsutil\n\nimport \"testing\"\n\nfunc TestSanitiseBasename(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tv    string\n\t\twant string\n\t}{\n\t\t{\"basic\", \"basic\", \"basic-61a7508e\"},\n\t\t{\"spaces\", `spaced name`, \"spaced-name-b297cf60\"},\n\t\t{\"leading/trailing spaces\", `  spaced name  `, \"spaced-name-175433e9\"},\n\t\t{\"hyphen name\", `hyphened-name`, \"hyphened-name-789c55f2\"},\n\t\t{\"multi-hyphen\", `hyphened--name`, \"hyphened-name-2da2a58f\"},\n\t\t{\"replaced characters\", `a&b=c\\d/:e*\"f?_ g`, \"a-b-c-d-e-f-g-ffca6fb0\"},\n\t\t{\"removed characters\", `foo!!bar@@and, more`, \"foobarand-more-7cee02ab\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := SanitiseBasename(tt.v); got != tt.want {\n\t\t\t\tt.Errorf(\"SanitiseBasename() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/fsutil/fs.go",
    "content": "// Package fsutil provides filesystem utility functions for the application.\npackage fsutil\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"unicode\"\n)\n\n// IsFsPathCaseSensitive checks the fs of the given path to see if it is case sensitive\n// if the case sensitivity can not be determined false and an error != nil are returned\nfunc IsFsPathCaseSensitive(path string) (bool, error) {\n\t// The case sensitivity of the fs of \"path\" is determined by case flipping\n\t// the first letter rune from the base string of the path\n\t// If the resulting flipped path exists then the fs should not be case sensitive\n\t// ( we check the file mod time to avoid matching an existing path )\n\n\tfi, err := os.Stat(path)\n\tif err != nil { // path cannot be stat'd\n\t\treturn false, err\n\t}\n\n\tbase := filepath.Base(path)\n\tfBase, err := flipCaseSingle(base)\n\tif err != nil { // cannot be case flipped\n\t\treturn false, err\n\t}\n\n\tflippedPath := filepath.Join(filepath.Dir(path), fBase)\n\n\tfiCase, err := os.Stat(flippedPath)\n\tif err != nil { // cannot stat the case flipped path\n\t\treturn true, nil // fs of path should be case sensitive\n\t}\n\n\tif fiCase.ModTime().Equal(fi.ModTime()) { // file path exists and is the same\n\t\treturn false, nil // fs of path is not case sensitive\n\t}\n\treturn false, fmt.Errorf(\"can not determine case sensitivity of path %s\", path)\n}\n\n// flipCaseSingle flips the case ( lower<->upper ) of a single char from the string s\n// If the string cannot be flipped, the original string value and an error are returned\nfunc flipCaseSingle(s string) (string, error) {\n\trr := []rune(s)\n\tfor i, r := range rr {\n\t\tif unicode.IsLetter(r) { // look for a letter  to flip\n\t\t\tif unicode.IsUpper(r) {\n\t\t\t\trr[i] = unicode.ToLower(r)\n\t\t\t\treturn string(rr), nil\n\t\t\t}\n\t\t\trr[i] = unicode.ToUpper(r)\n\t\t\treturn string(rr), nil\n\t\t}\n\n\t}\n\treturn s, fmt.Errorf(\"could not case flip string %s\", s)\n}\n"
  },
  {
    "path": "pkg/fsutil/fs_test.go",
    "content": "package fsutil\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestIsFsPathCaseSensitive_UnicodeByteLength(t *testing.T) {\n\t// Ⱥ (U+023A) is 2 bytes in UTF-8\n\t// Its lowercase ⱥ (U+2C65) is 3 bytes in UTF-8\n\n\tdir := t.TempDir()\n\tmakeDir := func(path string) {\n\t\t// Create the directory so os.Stat succeeds\n\t\tif err := os.Mkdir(path, 0755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tpath := filepath.Join(dir, \"Ⱥtest\")\n\tmakeDir(path)\n\n\t// ensure the test does not panic due to byte length differences in the case flipped path\n\t_, err := IsFsPathCaseSensitive(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// no guarantee about case sensitivity of the fs running the tests,\n\t// so we just want to ensure the function works and does not panic\n\t// assert.True(t, r, \"expected fs to be case sensitive\")\n\n\t// test regular ASCII paths still work\n\tpath2 := filepath.Join(dir, \"Test\")\n\tmakeDir(path2)\n\n\t_, err = IsFsPathCaseSensitive(path2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// assert.True(t, r, \"expected fs to be case sensitive\")\n\n\t// Ensure that subfolders of a folder with multi-byte chars is not causing a panic\n\tpath3 := filepath.Join(dir, \"NoPanic ❤️\")\n\tmakeDir(path3)\n\tpath4 := filepath.Join(path3, \"Test\")\n\tmakeDir(path4)\n\n\t_, err = IsFsPathCaseSensitive(path4)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "pkg/fsutil/lock_manager.go",
    "content": "package fsutil\n\nimport (\n\t\"context\"\n\t\"os/exec\"\n\t\"sync\"\n\t\"time\"\n)\n\ntype Cancellable interface {\n\tCancel()\n}\n\ntype LockContext struct {\n\tcontext.Context\n\tcancel context.CancelFunc\n\n\tcmd *exec.Cmd\n}\n\nfunc (c *LockContext) AttachCommand(cmd *exec.Cmd) {\n\tc.cmd = cmd\n}\n\nfunc (c *LockContext) Cancel() {\n\tc.cancel()\n\n\tif c.cmd != nil {\n\t\t// wait for the process to die before returning\n\t\t// don't wait more than a few seconds\n\t\tdone := make(chan error)\n\t\tgo func() {\n\t\t\terr := c.cmd.Wait()\n\t\t\tdone <- err\n\t\t}()\n\n\t\tselect {\n\t\tcase <-done:\n\t\t\treturn\n\t\tcase <-time.After(5 * time.Second):\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// ReadLockManager manages read locks on file paths.\ntype ReadLockManager struct {\n\treadLocks map[string][]*LockContext\n\tmutex     sync.RWMutex\n}\n\n// NewReadLockManager creates a new ReadLockManager.\nfunc NewReadLockManager() *ReadLockManager {\n\treturn &ReadLockManager{\n\t\treadLocks: make(map[string][]*LockContext),\n\t}\n}\n\n// ReadLock adds a pending file read lock for fn to its storage, returning a context and cancel function.\n// Per standard WithCancel usage, cancel must be called when the lock is freed.\nfunc (m *ReadLockManager) ReadLock(ctx context.Context, fn string) *LockContext {\n\tretCtx, cancel := context.WithCancel(ctx)\n\n\t// if Cancellable, call Cancel() when cancelled\n\tcancellable, ok := ctx.(Cancellable)\n\tif ok {\n\t\torigCancel := cancel\n\t\tcancel = func() {\n\t\t\torigCancel()\n\t\t\tcancellable.Cancel()\n\t\t}\n\t}\n\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tlocks := m.readLocks[fn]\n\n\tcc := &LockContext{\n\t\tContext: retCtx,\n\t\tcancel:  cancel,\n\t}\n\tm.readLocks[fn] = append(locks, cc)\n\n\tgo m.waitAndUnlock(fn, cc)\n\n\treturn cc\n}\n\nfunc (m *ReadLockManager) waitAndUnlock(fn string, cc *LockContext) {\n\t<-cc.Done()\n\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tlocks := m.readLocks[fn]\n\tfor i, v := range locks {\n\t\tif v == cc {\n\t\t\tm.readLocks[fn] = append(locks[:i], locks[i+1:]...)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// Cancel cancels all read lock contexts associated with fn.\nfunc (m *ReadLockManager) Cancel(fn string) {\n\tm.mutex.RLock()\n\tlocks := m.readLocks[fn]\n\tm.mutex.RUnlock()\n\n\tfor _, l := range locks {\n\t\tl.Cancel()\n\t\t<-l.Done()\n\t}\n}\n"
  },
  {
    "path": "pkg/fsutil/symwalk.go",
    "content": "// Modified from github.com/facebookgo/symwalk\n\n// BSD License\n\n// For symwalk software\n\n// Copyright (c) 2015, Facebook, Inc. All rights reserved.\n\n// Redistribution and use in source and binary forms, with or without modification,\n// are permitted provided that the following conditions are met:\n\n//  * Redistributions of source code must retain the above copyright notice, this\n//    list of conditions and the following disclaimer.\n\n//  * Redistributions in binary form must reproduce the above copyright notice,\n//    this list of conditions and the following disclaimer in the documentation\n//    and/or other materials provided with the distribution.\n\n//  * Neither the name Facebook nor the names of its contributors may be used to\n//    endorse or promote products derived from this software without specific\n//    prior written permission.\n\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\n// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\n// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR\n// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\n// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON\n// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\n// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\npackage fsutil\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\n// symwalkFunc calls the provided WalkFn for regular files.\n// However, when it encounters a symbolic link, it resolves the link fully using the\n// filepath.EvalSymlinks function and recursively calls symwalk.Walk on the resolved path.\n// This ensures that unlink filepath.Walk, traversal does not stop at symbolic links.\n//\n// Note that symwalk.Walk does not terminate if there are any non-terminating loops in\n// the file structure.\nfunc walk(filename string, linkDirname string, walkFn filepath.WalkFunc) error {\n\tsymWalkFunc := func(path string, info os.FileInfo, err error) error {\n\n\t\tif fname, err := filepath.Rel(filename, path); err == nil {\n\t\t\tpath = filepath.Join(linkDirname, fname)\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\n\t\tif err == nil && info.Mode()&os.ModeSymlink == os.ModeSymlink {\n\t\t\tfinalPath, err := filepath.EvalSymlinks(path)\n\t\t\tif err != nil {\n\t\t\t\t// don't bail out if symlink is invalid\n\t\t\t\treturn walkFn(path, info, err)\n\t\t\t}\n\t\t\tinfo, err := os.Lstat(finalPath)\n\t\t\tif err != nil {\n\t\t\t\treturn walkFn(path, info, err)\n\t\t\t}\n\t\t\tif info.IsDir() {\n\t\t\t\treturn walk(finalPath, path, walkFn)\n\t\t\t}\n\t\t}\n\n\t\treturn walkFn(path, info, err)\n\t}\n\treturn filepath.Walk(filename, symWalkFunc)\n}\n\n// SymWalk extends filepath.Walk to also follow symlinks\nfunc SymWalk(path string, walkFn filepath.WalkFunc) error {\n\treturn walk(path, path, walkFn)\n}\n"
  },
  {
    "path": "pkg/fsutil/trash.go",
    "content": "package fsutil\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n)\n\n// MoveToTrash moves a file or directory to a custom trash directory.\n// If a file with the same name already exists in the trash, a timestamp is appended.\n// Returns the destination path where the file was moved to.\nfunc MoveToTrash(sourcePath string, trashPath string) (string, error) {\n\t// Get absolute path for the source\n\tabsSourcePath, err := filepath.Abs(sourcePath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to get absolute path: %w\", err)\n\t}\n\n\t// Ensure trash directory exists\n\tif err := os.MkdirAll(trashPath, 0755); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create trash directory: %w\", err)\n\t}\n\n\t// Get the base name of the file/directory\n\tbaseName := filepath.Base(absSourcePath)\n\tdestPath := filepath.Join(trashPath, baseName)\n\n\t// If a file with the same name already exists in trash, append timestamp\n\tif _, err := os.Stat(destPath); err == nil {\n\t\text := filepath.Ext(baseName)\n\t\tnameWithoutExt := baseName[:len(baseName)-len(ext)]\n\t\ttimestamp := time.Now().Format(\"20060102-150405\")\n\t\tdestPath = filepath.Join(trashPath, fmt.Sprintf(\"%s_%s%s\", nameWithoutExt, timestamp, ext))\n\t}\n\n\t// Move the file to trash using SafeMove to support cross-filesystem moves\n\tif err := SafeMove(absSourcePath, destPath); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to move to trash: %w\", err)\n\t}\n\n\treturn destPath, nil\n}\n"
  },
  {
    "path": "pkg/gallery/chapter_import.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n)\n\ntype ChapterImporterReaderWriter interface {\n\tmodels.GalleryChapterCreatorUpdater\n\tFindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error)\n}\n\ntype ChapterImporter struct {\n\tGalleryID           int\n\tReaderWriter        ChapterImporterReaderWriter\n\tInput               jsonschema.GalleryChapter\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\tchapter models.GalleryChapter\n}\n\nfunc (i *ChapterImporter) PreImport(ctx context.Context) error {\n\ti.chapter = models.GalleryChapter{\n\t\tTitle:      i.Input.Title,\n\t\tImageIndex: i.Input.ImageIndex,\n\t\tGalleryID:  i.GalleryID,\n\t\tCreatedAt:  i.Input.CreatedAt.GetTime(),\n\t\tUpdatedAt:  i.Input.UpdatedAt.GetTime(),\n\t}\n\n\treturn nil\n}\n\nfunc (i *ChapterImporter) Name() string {\n\treturn fmt.Sprintf(\"%s (%d)\", i.Input.Title, i.Input.ImageIndex)\n}\n\nfunc (i *ChapterImporter) PostImport(ctx context.Context, id int) error {\n\treturn nil\n}\n\nfunc (i *ChapterImporter) FindExistingID(ctx context.Context) (*int, error) {\n\texistingChapters, err := i.ReaderWriter.FindByGalleryID(ctx, i.GalleryID)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, m := range existingChapters {\n\t\tif m.ImageIndex == i.chapter.ImageIndex {\n\t\t\tid := m.ID\n\t\t\treturn &id, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *ChapterImporter) Create(ctx context.Context) (*int, error) {\n\terr := i.ReaderWriter.Create(ctx, &i.chapter)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating chapter: %v\", err)\n\t}\n\n\tid := i.chapter.ID\n\treturn &id, nil\n}\n\nfunc (i *ChapterImporter) Update(ctx context.Context, id int) error {\n\tchapter := i.chapter\n\tchapter.ID = id\n\terr := i.ReaderWriter.Update(ctx, &chapter)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating existing chapter: %v\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/gallery/delete.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) {\n\tvar imgsDestroyed []*models.Image\n\n\t// chapter deletion is done via delete cascade, so we don't need to do anything here\n\n\t// if this is a zip-based gallery, delete the images as well first\n\tzipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\timgsDestroyed = zipImgsDestroyed\n\n\t// only delete folder based gallery images if we're deleting the folder\n\tif deleteFile && i.FolderID != nil {\n\t\tfolderImgsDestroyed, err := s.ImageService.DestroyFolderImages(ctx, *i.FolderID, fileDeleter, deleteGenerated, deleteFile)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\timgsDestroyed = append(imgsDestroyed, folderImgsDestroyed...)\n\t}\n\n\t// we only want to delete a folder-based gallery if it is empty.\n\t// this has to be done post-transaction\n\n\tif err := s.Repository.Destroy(ctx, i.ID); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn imgsDestroyed, nil\n}\n\nfunc DestroyChapter(ctx context.Context, galleryChapter *models.GalleryChapter, qb models.GalleryChapterDestroyer) error {\n\treturn qb.Destroy(ctx, galleryChapter.ID)\n}\n\nfunc (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) {\n\tif err := i.LoadFiles(ctx, s.Repository); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar imgsDestroyed []*models.Image\n\n\tdestroyer := &file.ZipDestroyer{\n\t\tFileDestroyer:   s.File,\n\t\tFolderDestroyer: s.Folder,\n\t}\n\n\t// for zip-based galleries, delete the images as well first\n\tfor _, f := range i.Files.List() {\n\t\t// only do this where there are no other galleries related to the file\n\t\totherGalleries, err := s.Repository.FindByFileID(ctx, f.Base().ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(otherGalleries) > 1 {\n\t\t\t// other gallery associated, don't remove\n\t\t\tcontinue\n\t\t}\n\n\t\tthisDestroyed, err := s.ImageService.DestroyZipImages(ctx, f, fileDeleter, deleteGenerated)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\timgsDestroyed = append(imgsDestroyed, thisDestroyed...)\n\n\t\tif deleteFile {\n\t\t\tif err := destroyer.DestroyZip(ctx, f, fileDeleter.Deleter, deleteFile); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t} else if destroyFileEntry {\n\t\t\t// destroy file DB entry without deleting filesystem file\n\t\t\tconst deleteFileFromFS = false\n\t\t\tif err := destroyer.DestroyZip(ctx, f, nil, deleteFileFromFS); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn imgsDestroyed, nil\n}\n"
  },
  {
    "path": "pkg/gallery/export.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n)\n\n// ToBasicJSON converts a gallery object into its JSON object equivalent. It\n// does not convert the relationships to other objects.\nfunc ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) {\n\tnewGalleryJSON := jsonschema.Gallery{\n\t\tTitle:        gallery.Title,\n\t\tCode:         gallery.Code,\n\t\tURLs:         gallery.URLs.List(),\n\t\tDetails:      gallery.Details,\n\t\tPhotographer: gallery.Photographer,\n\t\tCreatedAt:    json.JSONTime{Time: gallery.CreatedAt},\n\t\tUpdatedAt:    json.JSONTime{Time: gallery.UpdatedAt},\n\t}\n\n\tif gallery.FolderID != nil {\n\t\tnewGalleryJSON.FolderPath = gallery.Path\n\t}\n\n\tfor _, f := range gallery.Files.List() {\n\t\tnewGalleryJSON.ZipFiles = append(newGalleryJSON.ZipFiles, f.Base().Path)\n\t}\n\n\tif gallery.Date != nil {\n\t\tnewGalleryJSON.Date = gallery.Date.String()\n\t}\n\n\tif gallery.Rating != nil {\n\t\tnewGalleryJSON.Rating = *gallery.Rating\n\t}\n\n\tnewGalleryJSON.Organized = gallery.Organized\n\n\treturn &newGalleryJSON, nil\n}\n\n// GetStudioName returns the name of the provided gallery's studio. It returns an\n// empty string if there is no studio assigned to the gallery.\nfunc GetStudioName(ctx context.Context, reader models.StudioGetter, gallery *models.Gallery) (string, error) {\n\tif gallery.StudioID != nil {\n\t\tstudio, err := reader.Find(ctx, *gallery.StudioID)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tif studio != nil {\n\t\t\treturn studio.Name, nil\n\t\t}\n\t}\n\n\treturn \"\", nil\n}\n\n// GetGalleryChaptersJSON returns a slice of GalleryChapter JSON representation\n// objects corresponding to the provided gallery's chapters.\nfunc GetGalleryChaptersJSON(ctx context.Context, chapterReader models.GalleryChapterFinder, gallery *models.Gallery) ([]jsonschema.GalleryChapter, error) {\n\tgalleryChapters, err := chapterReader.FindByGalleryID(ctx, gallery.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting gallery chapters: %v\", err)\n\t}\n\n\tvar results []jsonschema.GalleryChapter\n\n\tfor _, galleryChapter := range galleryChapters {\n\t\tgalleryChapterJSON := jsonschema.GalleryChapter{\n\t\t\tTitle:      galleryChapter.Title,\n\t\t\tImageIndex: galleryChapter.ImageIndex,\n\t\t\tCreatedAt:  json.JSONTime{Time: galleryChapter.CreatedAt},\n\t\t\tUpdatedAt:  json.JSONTime{Time: galleryChapter.UpdatedAt},\n\t\t}\n\n\t\tresults = append(results, galleryChapterJSON)\n\t}\n\n\treturn results, nil\n}\n\nfunc GetIDs(galleries []*models.Gallery) []int {\n\tvar results []int\n\tfor _, gallery := range galleries {\n\t\tresults = append(results, gallery.ID)\n\t}\n\n\treturn results\n}\n\nfunc GetRefs(galleries []*models.Gallery) []jsonschema.GalleryRef {\n\tvar results []jsonschema.GalleryRef\n\tfor _, gallery := range galleries {\n\t\ttoAdd := jsonschema.GalleryRef{}\n\t\tswitch {\n\t\tcase gallery.FolderID != nil:\n\t\t\ttoAdd.FolderPath = gallery.Path\n\t\tcase len(gallery.Files.List()) > 0:\n\t\t\tfor _, f := range gallery.Files.List() {\n\t\t\t\ttoAdd.ZipFiles = append(toAdd.ZipFiles, f.Base().Path)\n\t\t\t}\n\t\tdefault:\n\t\t\ttoAdd.Title = gallery.Title\n\t\t}\n\n\t\tresults = append(results, toAdd)\n\t}\n\n\treturn results\n}\n"
  },
  {
    "path": "pkg/gallery/export_test.go",
    "content": "package gallery\n\nimport (\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\tgalleryID = 1\n\n\tstudioID        = 4\n\tmissingStudioID = 5\n\terrStudioID     = 6\n\n\t// noTagsID  = 11\n\tnoChaptersID       = 7\n\terrChaptersID      = 8\n\terrFindByChapterID = 9\n)\n\nvar (\n\turl        = \"url\"\n\ttitle      = \"title\"\n\tdate       = \"2001-01-01\"\n\tdateObj, _ = models.ParseDate(date)\n\trating     = 5\n\torganized  = true\n\tdetails    = \"details\"\n)\n\nconst (\n\tstudioName = \"studioName\"\n\tpath       = \"path\"\n)\n\nvar (\n\tcreateTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC)\n\tupdateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC)\n)\n\nfunc createFullGallery(id int) models.Gallery {\n\treturn models.Gallery{\n\t\tID: id,\n\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t&models.BaseFile{\n\t\t\t\tPath: path,\n\t\t\t},\n\t\t}),\n\t\tTitle:     title,\n\t\tDate:      &dateObj,\n\t\tDetails:   details,\n\t\tRating:    &rating,\n\t\tOrganized: organized,\n\t\tURLs:      models.NewRelatedStrings([]string{url}),\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t}\n}\n\nfunc createEmptyGallery(id int) models.Gallery {\n\treturn models.Gallery{\n\t\tID: id,\n\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t&models.BaseFile{\n\t\t\t\tPath: path,\n\t\t\t},\n\t\t}),\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t}\n}\n\nfunc createFullJSONGallery() *jsonschema.Gallery {\n\treturn &jsonschema.Gallery{\n\t\tTitle:     title,\n\t\tDate:      date,\n\t\tDetails:   details,\n\t\tRating:    rating,\n\t\tOrganized: organized,\n\t\tURLs:      []string{url},\n\t\tZipFiles:  []string{path},\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t}\n}\n\ntype basicTestScenario struct {\n\tinput    models.Gallery\n\texpected *jsonschema.Gallery\n\terr      bool\n}\n\nvar scenarios = []basicTestScenario{\n\t{\n\t\tcreateFullGallery(galleryID),\n\t\tcreateFullJSONGallery(),\n\t\tfalse,\n\t},\n}\n\nfunc TestToJSON(t *testing.T) {\n\tfor i, s := range scenarios {\n\t\tgallery := s.input\n\t\tjson, err := ToBasicJSON(&gallery)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n}\n\nfunc createStudioGallery(studioID int) models.Gallery {\n\treturn models.Gallery{\n\t\tStudioID: &studioID,\n\t}\n}\n\ntype stringTestScenario struct {\n\tinput    models.Gallery\n\texpected string\n\terr      bool\n}\n\nvar getStudioScenarios = []stringTestScenario{\n\t{\n\t\tcreateStudioGallery(studioID),\n\t\tstudioName,\n\t\tfalse,\n\t},\n\t{\n\t\tcreateStudioGallery(missingStudioID),\n\t\t\"\",\n\t\tfalse,\n\t},\n\t{\n\t\tcreateStudioGallery(errStudioID),\n\t\t\"\",\n\t\ttrue,\n\t},\n}\n\nfunc TestGetStudioName(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tstudioErr := errors.New(\"error getting image\")\n\n\tdb.Studio.On(\"Find\", testCtx, studioID).Return(&models.Studio{\n\t\tName: studioName,\n\t}, nil).Once()\n\tdb.Studio.On(\"Find\", testCtx, missingStudioID).Return(nil, nil).Once()\n\tdb.Studio.On(\"Find\", testCtx, errStudioID).Return(nil, studioErr).Once()\n\n\tfor i, s := range getStudioScenarios {\n\t\tgallery := s.input\n\t\tjson, err := GetStudioName(testCtx, db.Studio, &gallery)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n\nconst (\n\tvalidChapterID1 = 1\n\tvalidChapterID2 = 2\n\n\tchapterTitle1 = \"chapterTitle1\"\n\tchapterTitle2 = \"chapterTitle2\"\n\n\tchapterImageIndex1 = 10\n\tchapterImageIndex2 = 50\n)\n\ntype galleryChaptersTestScenario struct {\n\tinput    models.Gallery\n\texpected []jsonschema.GalleryChapter\n\terr      bool\n}\n\nvar getGalleryChaptersJSONScenarios = []galleryChaptersTestScenario{\n\t{\n\t\tcreateEmptyGallery(galleryID),\n\t\t[]jsonschema.GalleryChapter{\n\t\t\t{\n\t\t\t\tTitle:      chapterTitle1,\n\t\t\t\tImageIndex: chapterImageIndex1,\n\t\t\t\tCreatedAt: json.JSONTime{\n\t\t\t\t\tTime: createTime,\n\t\t\t\t},\n\t\t\t\tUpdatedAt: json.JSONTime{\n\t\t\t\t\tTime: updateTime,\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tTitle:      chapterTitle2,\n\t\t\t\tImageIndex: chapterImageIndex2,\n\t\t\t\tCreatedAt: json.JSONTime{\n\t\t\t\t\tTime: createTime,\n\t\t\t\t},\n\t\t\t\tUpdatedAt: json.JSONTime{\n\t\t\t\t\tTime: updateTime,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tfalse,\n\t},\n\t{\n\t\tcreateEmptyGallery(noChaptersID),\n\t\tnil,\n\t\tfalse,\n\t},\n\t{\n\t\tcreateEmptyGallery(errChaptersID),\n\t\tnil,\n\t\ttrue,\n\t},\n}\n\nvar validChapters = []*models.GalleryChapter{\n\t{\n\t\tID:         validChapterID1,\n\t\tTitle:      chapterTitle1,\n\t\tImageIndex: chapterImageIndex1,\n\t\tCreatedAt:  createTime,\n\t\tUpdatedAt:  updateTime,\n\t},\n\t{\n\t\tID:         validChapterID2,\n\t\tTitle:      chapterTitle2,\n\t\tImageIndex: chapterImageIndex2,\n\t\tCreatedAt:  createTime,\n\t\tUpdatedAt:  updateTime,\n\t},\n}\n\nfunc TestGetGalleryChaptersJSON(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tchaptersErr := errors.New(\"error getting gallery chapters\")\n\n\tdb.GalleryChapter.On(\"FindByGalleryID\", testCtx, galleryID).Return(validChapters, nil).Once()\n\tdb.GalleryChapter.On(\"FindByGalleryID\", testCtx, noChaptersID).Return(nil, nil).Once()\n\tdb.GalleryChapter.On(\"FindByGalleryID\", testCtx, errChaptersID).Return(nil, chaptersErr).Once()\n\n\tfor i, s := range getGalleryChaptersJSONScenarios {\n\t\tgallery := s.input\n\t\tjson, err := GetGalleryChaptersJSON(testCtx, db.GalleryChapter, &gallery)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/gallery/filter.go",
    "content": "package gallery\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc PathsFilter(paths []string) *models.GalleryFilterType {\n\tif paths == nil {\n\t\treturn nil\n\t}\n\n\tsep := string(filepath.Separator)\n\n\tvar ret *models.GalleryFilterType\n\tvar or *models.GalleryFilterType\n\tfor _, p := range paths {\n\t\tnewOr := &models.GalleryFilterType{}\n\t\tif or != nil {\n\t\t\tor.Or = newOr\n\t\t} else {\n\t\t\tret = newOr\n\t\t}\n\n\t\tor = newOr\n\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tor.Path = &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/gallery/import.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\ntype ImporterReaderWriter interface {\n\tmodels.GalleryCreatorUpdater\n\tFindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error)\n\tFindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error)\n\tFindUserGalleryByTitle(ctx context.Context, title string) ([]*models.Gallery, error)\n}\n\ntype Importer struct {\n\tReaderWriter        ImporterReaderWriter\n\tStudioWriter        models.StudioFinderCreator\n\tPerformerWriter     models.PerformerFinderCreator\n\tTagWriter           models.TagFinderCreator\n\tFileFinder          models.FileFinder\n\tFolderFinder        models.FolderFinder\n\tInput               jsonschema.Gallery\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\tID           int\n\tgallery      models.Gallery\n\tcustomFields map[string]interface{}\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\ti.gallery = i.galleryJSONToGallery(i.Input)\n\n\tif err := i.populateFilesFolder(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateStudio(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populatePerformers(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateTags(ctx); err != nil {\n\t\treturn err\n\t}\n\n\ti.customFields = i.Input.CustomFields\n\n\treturn nil\n}\n\nfunc (i *Importer) galleryJSONToGallery(galleryJSON jsonschema.Gallery) models.Gallery {\n\tnewGallery := models.Gallery{\n\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t}\n\n\tif galleryJSON.Title != \"\" {\n\t\tnewGallery.Title = galleryJSON.Title\n\t}\n\tif galleryJSON.Code != \"\" {\n\t\tnewGallery.Code = galleryJSON.Code\n\t}\n\tif galleryJSON.Details != \"\" {\n\t\tnewGallery.Details = galleryJSON.Details\n\t}\n\tif galleryJSON.Photographer != \"\" {\n\t\tnewGallery.Photographer = galleryJSON.Photographer\n\t}\n\tif len(galleryJSON.URLs) > 0 {\n\t\tnewGallery.URLs = models.NewRelatedStrings(galleryJSON.URLs)\n\t} else if galleryJSON.URL != \"\" {\n\t\tnewGallery.URLs = models.NewRelatedStrings([]string{galleryJSON.URL})\n\t}\n\tif galleryJSON.Date != \"\" {\n\t\td, err := models.ParseDate(galleryJSON.Date)\n\t\tif err == nil {\n\t\t\tnewGallery.Date = &d\n\t\t}\n\t}\n\tif galleryJSON.Rating != 0 {\n\t\tnewGallery.Rating = &galleryJSON.Rating\n\t}\n\n\tnewGallery.Organized = galleryJSON.Organized\n\tnewGallery.CreatedAt = galleryJSON.CreatedAt.GetTime()\n\tnewGallery.UpdatedAt = galleryJSON.UpdatedAt.GetTime()\n\n\treturn newGallery\n}\n\nfunc (i *Importer) populateStudio(ctx context.Context) error {\n\tif i.Input.Studio != \"\" {\n\t\tstudio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding studio by name: %v\", err)\n\t\t}\n\n\t\tif studio == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"gallery studio '%s' not found\", i.Input.Studio)\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tstudioID, err := i.createStudio(ctx, i.Input.Studio)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\ti.gallery.StudioID = &studioID\n\t\t\t}\n\t\t} else {\n\t\t\ti.gallery.StudioID = &studio.ID\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createStudio(ctx context.Context, name string) (int, error) {\n\tnewStudio := models.NewCreateStudioInput()\n\tnewStudio.Name = name\n\n\terr := i.StudioWriter.Create(ctx, &newStudio)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn newStudio.ID, nil\n}\n\nfunc (i *Importer) populatePerformers(ctx context.Context) error {\n\tif len(i.Input.Performers) > 0 {\n\t\tnames := i.Input.Performers\n\t\tperformers, err := i.PerformerWriter.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar pluckedNames []string\n\t\tfor _, performer := range performers {\n\t\t\tif performer.Name == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpluckedNames = append(pluckedNames, performer.Name)\n\t\t}\n\n\t\tmissingPerformers := sliceutil.Filter(names, func(name string) bool {\n\t\t\treturn !slices.Contains(pluckedNames, name)\n\t\t})\n\n\t\tif len(missingPerformers) > 0 {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"gallery performers [%s] not found\", strings.Join(missingPerformers, \", \"))\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tcreatedPerformers, err := i.createPerformers(ctx, missingPerformers)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error creating gallery performers: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tperformers = append(performers, createdPerformers...)\n\t\t\t}\n\n\t\t\t// ignore if MissingRefBehaviour set to Ignore\n\t\t}\n\n\t\tfor _, p := range performers {\n\t\t\ti.gallery.PerformerIDs.Add(p.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createPerformers(ctx context.Context, names []string) ([]*models.Performer, error) {\n\tvar ret []*models.Performer\n\tfor _, name := range names {\n\t\tnewPerformer := models.NewPerformer()\n\t\tnewPerformer.Name = name\n\n\t\terr := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{\n\t\t\tPerformer: &newPerformer,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newPerformer)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (i *Importer) populateTags(ctx context.Context) error {\n\tif len(i.Input.Tags) > 0 {\n\t\tnames := i.Input.Tags\n\t\ttags, err := i.TagWriter.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar pluckedNames []string\n\t\tfor _, tag := range tags {\n\t\t\tpluckedNames = append(pluckedNames, tag.Name)\n\t\t}\n\n\t\tmissingTags := sliceutil.Filter(names, func(name string) bool {\n\t\t\treturn !slices.Contains(pluckedNames, name)\n\t\t})\n\n\t\tif len(missingTags) > 0 {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"gallery tags [%s] not found\", strings.Join(missingTags, \", \"))\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tcreatedTags, err := i.createTags(ctx, missingTags)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error creating gallery tags: %v\", err)\n\t\t\t\t}\n\n\t\t\t\ttags = append(tags, createdTags...)\n\t\t\t}\n\n\t\t\t// ignore if MissingRefBehaviour set to Ignore\n\t\t}\n\n\t\tfor _, t := range tags {\n\t\t\ti.gallery.TagIDs.Add(t.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createTags(ctx context.Context, names []string) ([]*models.Tag, error) {\n\tvar ret []*models.Tag\n\tfor _, name := range names {\n\t\tnewTag := models.NewTag()\n\t\tnewTag.Name = name\n\n\t\terr := i.TagWriter.Create(ctx, &models.CreateTagInput{\n\t\t\tTag: &newTag,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newTag)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (i *Importer) populateFilesFolder(ctx context.Context) error {\n\tfiles := make([]models.File, 0)\n\n\tfor _, ref := range i.Input.ZipFiles {\n\t\tpath := ref\n\t\tf, err := i.FileFinder.FindByPath(ctx, path, true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding file: %w\", err)\n\t\t}\n\n\t\tif f == nil {\n\t\t\treturn fmt.Errorf(\"gallery zip file '%s' not found\", path)\n\t\t} else {\n\t\t\tfiles = append(files, f)\n\t\t}\n\t}\n\n\ti.gallery.Files = models.NewRelatedFiles(files)\n\n\tif i.Input.FolderPath != \"\" {\n\t\tpath := i.Input.FolderPath\n\t\tf, err := i.FolderFinder.FindByPath(ctx, path, true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding folder: %w\", err)\n\t\t}\n\n\t\tif f == nil {\n\t\t\treturn fmt.Errorf(\"gallery folder '%s' not found\", path)\n\t\t} else {\n\t\t\ti.gallery.FolderID = &f.ID\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\tif i.Input.Title != \"\" {\n\t\treturn i.Input.Title\n\t}\n\n\tif i.Input.FolderPath != \"\" {\n\t\treturn i.Input.FolderPath\n\t}\n\n\tif len(i.Input.ZipFiles) > 0 {\n\t\treturn i.Input.ZipFiles[0]\n\t}\n\n\treturn \"\"\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\tvar existing []*models.Gallery\n\tvar err error\n\tswitch {\n\tcase len(i.gallery.Files.List()) > 0:\n\t\tfor _, f := range i.gallery.Files.List() {\n\t\t\texisting, err := i.ReaderWriter.FindByFileID(ctx, f.Base().ID)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tif existing != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\tcase i.gallery.FolderID != nil:\n\t\texisting, err = i.ReaderWriter.FindByFolderID(ctx, *i.gallery.FolderID)\n\tdefault:\n\t\texisting, err = i.ReaderWriter.FindUserGalleryByTitle(ctx, i.gallery.Title)\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(existing) > 0 {\n\t\tid := existing[0].ID\n\t\treturn &id, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\tvar fileIDs []models.FileID\n\tfor _, f := range i.gallery.Files.List() {\n\t\tfileIDs = append(fileIDs, f.Base().ID)\n\t}\n\terr := i.ReaderWriter.Create(ctx, &models.CreateGalleryInput{\n\t\tGallery:      &i.gallery,\n\t\tFileIDs:      fileIDs,\n\t\tCustomFields: i.customFields,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating gallery: %v\", err)\n\t}\n\n\tid := i.gallery.ID\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\tgallery := i.gallery\n\tgallery.ID = id\n\terr := i.ReaderWriter.Update(ctx, &models.UpdateGalleryInput{\n\t\tGallery: &gallery,\n\t\tCustomFields: models.CustomFieldsInput{\n\t\t\tFull: i.customFields,\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating existing gallery: %v\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/gallery/import_test.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nvar (\n\texistingStudioID    = 101\n\texistingPerformerID = 103\n\texistingTagID       = 105\n\n\texistingStudioName = \"existingStudioName\"\n\texistingStudioErr  = \"existingStudioErr\"\n\tmissingStudioName  = \"missingStudioName\"\n\n\texistingPerformerName = \"existingPerformerName\"\n\texistingPerformerErr  = \"existingPerformerErr\"\n\tmissingPerformerName  = \"missingPerformerName\"\n\n\texistingTagName = \"existingTagName\"\n\texistingTagErr  = \"existingTagErr\"\n\tmissingTagName  = \"missingTagName\"\n)\n\nvar testCtx = context.Background()\n\nvar (\n\tcreatedAt = time.Date(2001, time.January, 2, 1, 2, 3, 4, time.Local)\n\tupdatedAt = time.Date(2002, time.January, 2, 1, 2, 3, 4, time.Local)\n)\n\nfunc TestImporterPreImport(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Gallery{\n\t\t\tTitle:     title,\n\t\t\tDate:      date,\n\t\t\tDetails:   details,\n\t\t\tRating:    rating,\n\t\t\tOrganized: organized,\n\t\t\tURL:       url,\n\t\t\tCreatedAt: json.JSONTime{\n\t\t\t\tTime: createdAt,\n\t\t\t},\n\t\t\tUpdatedAt: json.JSONTime{\n\t\t\t\tTime: updatedAt,\n\t\t\t},\n\t\t},\n\t}\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\texpectedGallery := models.Gallery{\n\t\tTitle:        title,\n\t\tDate:         &dateObj,\n\t\tDetails:      details,\n\t\tRating:       &rating,\n\t\tOrganized:    organized,\n\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\tFiles:        models.NewRelatedFiles([]models.File{}),\n\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\tCreatedAt:    createdAt,\n\t\tUpdatedAt:    updatedAt,\n\t}\n\n\tassert.Equal(t, expectedGallery, i.gallery)\n}\n\nfunc TestImporterPreImportWithStudio(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Gallery{\n\t\t\tStudio: existingStudioName,\n\t\t},\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioName, false).Return(&models.Studio{\n\t\tID: existingStudioID,\n\t}, nil).Once()\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioErr, false).Return(nil, errors.New(\"FindByName error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.gallery.StudioID)\n\n\ti.Input.Studio = existingStudioErr\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingStudio(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Gallery{\n\t\t\tStudio: missingStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingStudioName, false).Return(nil, nil).Times(3)\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Run(func(args mock.Arguments) {\n\t\ts := args.Get(1).(*models.CreateStudioInput)\n\t\ts.Studio.ID = existingStudioID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.gallery.StudioID)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Gallery{\n\t\t\tStudio: missingStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingStudioName, false).Return(nil, nil).Once()\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithPerformer(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter:     db.Performer,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Gallery{\n\t\t\tPerformers: []string{\n\t\t\t\texistingPerformerName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{\n\t\t{\n\t\t\tID:   existingPerformerID,\n\t\t\tName: existingPerformerName,\n\t\t},\n\t}, nil).Once()\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingPerformerID}, i.gallery.PerformerIDs.List())\n\n\ti.Input.Performers = []string{existingPerformerErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingPerformer(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter: db.Performer,\n\t\tInput: jsonschema.Gallery{\n\t\t\tPerformers: []string{\n\t\t\t\tmissingPerformerName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3)\n\tdb.Performer.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreatePerformerInput\")).Run(func(args mock.Arguments) {\n\t\tperformer := args.Get(1).(*models.CreatePerformerInput)\n\t\tperformer.ID = existingPerformerID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingPerformerID}, i.gallery.PerformerIDs.List())\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter: db.Performer,\n\t\tInput: jsonschema.Gallery{\n\t\t\tPerformers: []string{\n\t\t\t\tmissingPerformerName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once()\n\tdb.Performer.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreatePerformerInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter:           db.Tag,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Gallery{\n\t\t\tTags: []string{\n\t\t\t\texistingTagName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagName}, false).Return([]*models.Tag{\n\t\t{\n\t\t\tID:   existingTagID,\n\t\t\tName: existingTagName,\n\t\t},\n\t}, nil).Once()\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingTagID}, i.gallery.TagIDs.List())\n\n\ti.Input.Tags = []string{existingTagErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter: db.Tag,\n\t\tInput: jsonschema.Gallery{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Run(func(args mock.Arguments) {\n\t\tt := args.Get(1).(*models.CreateTagInput)\n\t\tt.Tag.ID = existingTagID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingTagID}, i.gallery.TagIDs.List())\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter: db.Tag,\n\t\tInput: jsonschema.Gallery{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/gallery/query.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc CountByPerformerID(ctx context.Context, r models.GalleryQueryer, id int) (int, error) {\n\tfilter := &models.GalleryFilterType{\n\t\tPerformers: &models.MultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByStudioID(ctx context.Context, r models.GalleryQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.GalleryFilterType{\n\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByTagID(ctx context.Context, r models.GalleryQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.GalleryFilterType{\n\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n"
  },
  {
    "path": "pkg/gallery/scan.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n)\n\ntype ScanCreatorUpdater interface {\n\tFindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error)\n\tFindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error)\n\tGetFiles(ctx context.Context, relatedID int) ([]models.File, error)\n\n\tmodels.GalleryCreator\n\tUpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error)\n\tAddFileID(ctx context.Context, id int, fileID models.FileID) error\n}\n\ntype ScanSceneFinderUpdater interface {\n\tFindByPath(ctx context.Context, p string) ([]*models.Scene, error)\n\tAddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error\n}\n\ntype ScanImageFinderUpdater interface {\n\tFindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error)\n\tUpdatePartial(ctx context.Context, id int, partial models.ImagePartial) (*models.Image, error)\n}\n\ntype ScanHandler struct {\n\tCreatorUpdater     ScanCreatorUpdater\n\tSceneFinderUpdater ScanSceneFinderUpdater\n\tImageFinderUpdater ScanImageFinderUpdater\n\tPluginCache        *plugin.Cache\n}\n\nfunc (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error {\n\tbaseFile := f.Base()\n\n\t// try to match the file to a gallery\n\texisting, err := h.CreatorUpdater.FindByFileID(ctx, f.Base().ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding existing gallery: %w\", err)\n\t}\n\n\tif len(existing) == 0 {\n\t\t// try also to match file by fingerprints\n\t\texisting, err = h.CreatorUpdater.FindByFingerprints(ctx, baseFile.Fingerprints)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding existing gallery by fingerprints: %w\", err)\n\t\t}\n\t}\n\n\tif len(existing) > 0 {\n\t\tupdateExisting := oldFile != nil\n\t\tif err := h.associateExisting(ctx, existing, f, updateExisting); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t// only create galleries if there is something to put in them\n\t\t// otherwise, they will be created on the fly when an image is created\n\t\timages, err := h.ImageFinderUpdater.FindByZipFileID(ctx, f.Base().ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(images) == 0 {\n\t\t\t// don't create an empty gallery\n\t\t\treturn nil\n\t\t}\n\n\t\t// create a new gallery\n\t\tnewGallery := models.NewGallery()\n\n\t\tlogger.Infof(\"%s doesn't exist. Creating new gallery...\", f.Base().Path)\n\n\t\tif err := h.CreatorUpdater.Create(ctx, &models.CreateGalleryInput{\n\t\t\tGallery: &newGallery,\n\t\t\tFileIDs: []models.FileID{baseFile.ID},\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"creating new gallery: %w\", err)\n\t\t}\n\n\t\th.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil)\n\n\t\t// associate all the images in the zip file with the gallery\n\t\tfor _, i := range images {\n\t\t\timagePartial := models.ImagePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{newGallery.ID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t\t// set UpdatedAt directly instead of using NewImagePartial, to ensure\n\t\t\t\t// that the images have the same UpdatedAt time as the gallery\n\t\t\t\tUpdatedAt: models.NewOptionalTime(newGallery.UpdatedAt),\n\t\t\t}\n\t\t\tif _, err := h.ImageFinderUpdater.UpdatePartial(ctx, i.ID, imagePartial); err != nil {\n\t\t\t\treturn fmt.Errorf(\"adding image %s to gallery: %w\", i.Path, err)\n\t\t\t}\n\t\t}\n\n\t\texisting = []*models.Gallery{&newGallery}\n\t}\n\n\tif err := h.associateScene(ctx, existing, f); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Gallery, f models.File, updateExisting bool) error {\n\tfor _, i := range existing {\n\t\tif err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfound := false\n\t\tfor _, sf := range i.Files.List() {\n\t\t\tif sf.Base().ID == f.Base().ID {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\tlogger.Infof(\"Adding %s to gallery %s\", f.Base().Path, i.DisplayName())\n\n\t\t\tif err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.Base().ID); err != nil {\n\t\t\t\treturn fmt.Errorf(\"adding file to gallery: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tif !found || updateExisting {\n\t\t\t// update updated_at time when file association or content changes\n\t\t\tif _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil {\n\t\t\t\treturn fmt.Errorf(\"updating gallery: %w\", err)\n\t\t\t}\n\n\t\t\th.PluginCache.RegisterPostHooks(ctx, i.ID, hook.GalleryUpdatePost, nil, nil)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) associateScene(ctx context.Context, existing []*models.Gallery, f models.File) error {\n\tgalleryIDs := make([]int, len(existing))\n\tfor i, g := range existing {\n\t\tgalleryIDs[i] = g.ID\n\t}\n\n\tpath := f.Base().Path\n\twithoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + \".*\"\n\n\t// find scenes with a file that matches\n\tscenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, scene := range scenes {\n\t\t// found related Scene\n\t\tlogger.Infof(\"associate: Gallery %s is related to scene: %d\", path, scene.ID)\n\t\tif err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/gallery/scan_test.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {\n\tconst (\n\t\ttestGalleryID = 1\n\t\ttestFileID    = 100\n\t)\n\n\texistingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: \"test.zip\"}\n\n\tmakeGallery := func() *models.Gallery {\n\t\treturn &models.Gallery{\n\t\t\tID:    testGalleryID,\n\t\t\tFiles: models.NewRelatedFiles([]models.File{existingFile}),\n\t\t}\n\t}\n\n\ttests := []struct {\n\t\tname           string\n\t\tupdateExisting bool\n\t\texpectUpdate   bool\n\t}{\n\t\t{\n\t\t\tname:           \"calls UpdatePartial when file content changed\",\n\t\t\tupdateExisting: true,\n\t\t\texpectUpdate:   true,\n\t\t},\n\t\t{\n\t\t\tname:           \"skips UpdatePartial when file unchanged and already associated\",\n\t\t\tupdateExisting: false,\n\t\t\texpectUpdate:   false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdb := mocks.NewDatabase()\n\t\t\tdb.Gallery.On(\"GetFiles\", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil)\n\n\t\t\tif tt.expectUpdate {\n\t\t\t\tdb.Gallery.On(\"UpdatePartial\", mock.Anything, testGalleryID, mock.Anything).\n\t\t\t\t\tReturn(&models.Gallery{ID: testGalleryID}, nil)\n\t\t\t}\n\n\t\t\th := &ScanHandler{\n\t\t\t\tCreatorUpdater: db.Gallery,\n\t\t\t\tPluginCache:    &plugin.Cache{},\n\t\t\t}\n\n\t\t\tdb.WithTxnCtx(func(ctx context.Context) {\n\t\t\t\terr := h.associateExisting(ctx, []*models.Gallery{makeGallery()}, existingFile, tt.updateExisting)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t})\n\n\t\t\tif tt.expectUpdate {\n\t\t\t\tdb.Gallery.AssertCalled(t, \"UpdatePartial\", mock.Anything, testGalleryID, mock.Anything)\n\t\t\t} else {\n\t\t\t\tdb.Gallery.AssertNotCalled(t, \"UpdatePartial\", mock.Anything, mock.Anything, mock.Anything)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {\n\tconst (\n\t\ttestGalleryID = 1\n\t\texistFileID   = 100\n\t\tnewFileID     = 200\n\t)\n\n\texistingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: \"existing.zip\"}\n\tnewFile := &models.BaseFile{ID: models.FileID(newFileID), Path: \"new.zip\"}\n\n\tgallery := &models.Gallery{\n\t\tID:    testGalleryID,\n\t\tFiles: models.NewRelatedFiles([]models.File{existingFile}),\n\t}\n\n\tdb := mocks.NewDatabase()\n\tdb.Gallery.On(\"GetFiles\", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil)\n\tdb.Gallery.On(\"AddFileID\", mock.Anything, testGalleryID, models.FileID(newFileID)).Return(nil)\n\tdb.Gallery.On(\"UpdatePartial\", mock.Anything, testGalleryID, mock.Anything).\n\t\tReturn(&models.Gallery{ID: testGalleryID}, nil)\n\n\th := &ScanHandler{\n\t\tCreatorUpdater: db.Gallery,\n\t\tPluginCache:    &plugin.Cache{},\n\t}\n\n\tdb.WithTxnCtx(func(ctx context.Context) {\n\t\terr := h.associateExisting(ctx, []*models.Gallery{gallery}, newFile, false)\n\t\tassert.NoError(t, err)\n\t})\n\n\tdb.Gallery.AssertCalled(t, \"AddFileID\", mock.Anything, testGalleryID, models.FileID(newFileID))\n\tdb.Gallery.AssertCalled(t, \"UpdatePartial\", mock.Anything, testGalleryID, mock.Anything)\n}\n"
  },
  {
    "path": "pkg/gallery/service.go",
    "content": "// Package gallery provides application logic for managing galleries.\n// This functionality is exposed via the [Service] type.\npackage gallery\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype ImageFinder interface {\n\tFindByFolderID(ctx context.Context, folder models.FolderID) ([]*models.Image, error)\n\tFindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error)\n\tmodels.GalleryIDLoader\n}\n\ntype ImageService interface {\n\tDestroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error\n\tDestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)\n\tDestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error)\n}\n\ntype Service struct {\n\tRepository   models.GalleryReaderWriter\n\tImageFinder  ImageFinder\n\tImageService ImageService\n\tFile         models.FileReaderWriter\n\tFolder       models.FolderReaderWriter\n}\n"
  },
  {
    "path": "pkg/gallery/update.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype ImageUpdater interface {\n\tGetImageIDs(ctx context.Context, galleryID int) ([]int, error)\n\tAddImages(ctx context.Context, galleryID int, imageIDs ...int) error\n\tRemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error\n}\n\nfunc (s *Service) Updated(ctx context.Context, galleryID int) error {\n\tgalleryPartial := models.NewGalleryPartial()\n\t_, err := s.Repository.UpdatePartial(ctx, galleryID, galleryPartial)\n\treturn err\n}\n\n// AddImages adds images to the provided gallery.\n// It returns an error if the gallery does not support adding images, or if\n// the operation fails.\nfunc (s *Service) AddImages(ctx context.Context, g *models.Gallery, toAdd ...int) error {\n\tif err := validateContentChange(g); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.Repository.AddImages(ctx, g.ID, toAdd...); err != nil {\n\t\treturn fmt.Errorf(\"failed to add images to gallery: %w\", err)\n\t}\n\n\t// #3759 - update the gallery's UpdatedAt timestamp\n\treturn s.Updated(ctx, g.ID)\n}\n\n// RemoveImages removes images from the provided gallery.\n// It does not validate if the images are part of the gallery.\n// It returns an error if the gallery does not support removing images, or if\n// the operation fails.\nfunc (s *Service) RemoveImages(ctx context.Context, g *models.Gallery, toRemove ...int) error {\n\tif err := validateContentChange(g); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.Repository.RemoveImages(ctx, g.ID, toRemove...); err != nil {\n\t\treturn fmt.Errorf(\"failed to remove images from gallery: %w\", err)\n\t}\n\n\t// #3759 - update the gallery's UpdatedAt timestamp\n\treturn s.Updated(ctx, g.ID)\n}\n\nfunc (s *Service) SetCover(ctx context.Context, g *models.Gallery, coverImageID int) error {\n\tif err := s.Repository.SetCover(ctx, g.ID, coverImageID); err != nil {\n\t\treturn fmt.Errorf(\"failed to set cover: %w\", err)\n\t}\n\n\treturn s.Updated(ctx, g.ID)\n}\n\nfunc (s *Service) ResetCover(ctx context.Context, g *models.Gallery) error {\n\tif err := s.Repository.ResetCover(ctx, g.ID); err != nil {\n\t\treturn fmt.Errorf(\"failed to reset cover: %w\", err)\n\t}\n\n\treturn s.Updated(ctx, g.ID)\n}\n\nfunc AddPerformer(ctx context.Context, qb models.GalleryUpdater, o *models.Gallery, performerID int) error {\n\tgalleryPartial := models.NewGalleryPartial()\n\tgalleryPartial.PerformerIDs = &models.UpdateIDs{\n\t\tIDs:  []int{performerID},\n\t\tMode: models.RelationshipUpdateModeAdd,\n\t}\n\t_, err := qb.UpdatePartial(ctx, o.ID, galleryPartial)\n\treturn err\n}\n\nfunc AddTag(ctx context.Context, qb models.GalleryUpdater, o *models.Gallery, tagID int) error {\n\tgalleryPartial := models.NewGalleryPartial()\n\tgalleryPartial.TagIDs = &models.UpdateIDs{\n\t\tIDs:  []int{tagID},\n\t\tMode: models.RelationshipUpdateModeAdd,\n\t}\n\t_, err := qb.UpdatePartial(ctx, o.ID, galleryPartial)\n\treturn err\n}\n"
  },
  {
    "path": "pkg/gallery/validation.go",
    "content": "package gallery\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\ntype ContentsChangedError struct {\n\tGallery *models.Gallery\n}\n\nfunc (e *ContentsChangedError) Error() string {\n\ttyp := \"zip-based\"\n\tif e.Gallery.FolderID != nil {\n\t\ttyp = \"folder-based\"\n\t}\n\n\treturn fmt.Sprintf(\"cannot change contents of %s gallery %q\", typ, e.Gallery.GetTitle())\n}\n\n// validateContentChange returns an error if a gallery cannot have its contents changed.\n// Only manually created galleries can have images changed.\nfunc validateContentChange(g *models.Gallery) error {\n\tif g.FolderID != nil || g.PrimaryFileID != nil {\n\t\treturn &ContentsChangedError{\n\t\t\tGallery: g,\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (s *Service) ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error {\n\t// determine what is changing\n\tvar changedIDs []int\n\n\tswitch updateIDs.Mode {\n\tcase models.RelationshipUpdateModeAdd, models.RelationshipUpdateModeRemove:\n\t\tchangedIDs = updateIDs.IDs\n\tcase models.RelationshipUpdateModeSet:\n\t\t// get the difference between the two lists\n\t\tchangedIDs = sliceutil.NotIntersect(i.GalleryIDs.List(), updateIDs.IDs)\n\t}\n\n\tgalleries, err := s.Repository.FindMany(ctx, changedIDs)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, g := range galleries {\n\t\tif err := validateContentChange(g); err != nil {\n\t\t\treturn fmt.Errorf(\"changing galleries of image %q: %w\", i.GetTitle(), err)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/group/create.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nvar (\n\tErrEmptyName     = errors.New(\"name cannot be empty\")\n\tErrHierarchyLoop = errors.New(\"a group cannot be contained by one of its subgroups\")\n)\n\nfunc (s *Service) Create(ctx context.Context, input *models.CreateGroupInput) error {\n\tr := s.Repository\n\tgroup := input.Group\n\n\tif err := s.validateCreate(ctx, group); err != nil {\n\t\treturn err\n\t}\n\n\terr := r.Create(ctx, input.Group)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// set custom fields\n\tif len(input.CustomFields) > 0 {\n\t\tif err := r.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{\n\t\t\tFull: input.CustomFields,\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// update image table\n\tif len(input.FrontImageData) > 0 {\n\t\tif err := r.UpdateFrontImage(ctx, group.ID, input.FrontImageData); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(input.BackImageData) > 0 {\n\t\tif err := r.UpdateBackImage(ctx, group.ID, input.BackImageData); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/group/doc.go",
    "content": "// Package group provides the application logic for groups.\npackage group\n"
  },
  {
    "path": "pkg/group/export.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype GroupExportReader interface {\n\tGetFrontImage(ctx context.Context, groupID int) ([]byte, error)\n\tGetBackImage(ctx context.Context, groupID int) ([]byte, error)\n\tGetCustomFields(ctx context.Context, groupID int) (map[string]interface{}, error)\n}\n\n// ToJSON converts a Group into its JSON equivalent.\nfunc ToJSON(ctx context.Context, reader GroupExportReader, studioReader models.StudioGetter, group *models.Group) (*jsonschema.Group, error) {\n\tnewGroupJSON := jsonschema.Group{\n\t\tName:      group.Name,\n\t\tAliases:   group.Aliases,\n\t\tDirector:  group.Director,\n\t\tSynopsis:  group.Synopsis,\n\t\tURLs:      group.URLs.List(),\n\t\tCreatedAt: json.JSONTime{Time: group.CreatedAt},\n\t\tUpdatedAt: json.JSONTime{Time: group.UpdatedAt},\n\t}\n\n\tif group.Date != nil {\n\t\tnewGroupJSON.Date = group.Date.String()\n\t}\n\tif group.Rating != nil {\n\t\tnewGroupJSON.Rating = *group.Rating\n\t}\n\tif group.Duration != nil {\n\t\tnewGroupJSON.Duration = *group.Duration\n\t}\n\n\tif group.StudioID != nil {\n\t\tstudio, err := studioReader.Find(ctx, *group.StudioID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting movie studio: %v\", err)\n\t\t}\n\n\t\tif studio != nil {\n\t\t\tnewGroupJSON.Studio = studio.Name\n\t\t}\n\t}\n\n\tfrontImage, err := reader.GetFrontImage(ctx, group.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error getting movie front image: %v\", err)\n\t}\n\n\tif len(frontImage) > 0 {\n\t\tnewGroupJSON.FrontImage = utils.GetBase64StringFromData(frontImage)\n\t}\n\n\tbackImage, err := reader.GetBackImage(ctx, group.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error getting movie back image: %v\", err)\n\t}\n\n\tif len(backImage) > 0 {\n\t\tnewGroupJSON.BackImage = utils.GetBase64StringFromData(backImage)\n\t}\n\n\tnewGroupJSON.CustomFields, err = reader.GetCustomFields(ctx, group.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting group custom fields: %v\", err)\n\t}\n\n\treturn &newGroupJSON, nil\n}\n"
  },
  {
    "path": "pkg/group/export_test.go",
    "content": "package group\n\nimport (\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\tmovieID = iota + 1\n\temptyID\n\terrFrontImageID\n\terrBackImageID\n\terrStudioMovieID\n\tmissingStudioMovieID\n\terrCustomFieldsID\n)\n\nconst (\n\tstudioID = iota + 1\n\tmissingStudioID\n\terrStudioID\n)\n\nconst movieName = \"testMovie\"\nconst movieAliases = \"aliases\"\n\nvar (\n\tdate       = \"2001-01-01\"\n\tdateObj, _ = models.ParseDate(date)\n\trating     = 5\n\tduration   = 100\n\tdirector   = \"director\"\n\tsynopsis   = \"synopsis\"\n\turl        = \"url\"\n)\n\nconst studioName = \"studio\"\n\nconst (\n\tfrontImage = \"ZnJvbnRJbWFnZUJ5dGVz\"\n\tbackImage  = \"YmFja0ltYWdlQnl0ZXM=\"\n)\n\nvar (\n\tfrontImageBytes = []byte(\"frontImageBytes\")\n\tbackImageBytes  = []byte(\"backImageBytes\")\n\n\temptyCustomFields = make(map[string]interface{})\n\tcustomFields      = map[string]interface{}{\n\t\t\"customField1\": \"customValue1\",\n\t}\n)\n\nvar movieStudio models.Studio = models.Studio{\n\tName: studioName,\n}\n\nvar (\n\tcreateTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC)\n\tupdateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC)\n)\n\nfunc createFullMovie(id int, studioID int) models.Group {\n\treturn models.Group{\n\t\tID:        id,\n\t\tName:      movieName,\n\t\tAliases:   movieAliases,\n\t\tDate:      &dateObj,\n\t\tRating:    &rating,\n\t\tDuration:  &duration,\n\t\tDirector:  director,\n\t\tSynopsis:  synopsis,\n\t\tURLs:      models.NewRelatedStrings([]string{url}),\n\t\tStudioID:  &studioID,\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t}\n}\n\nfunc createEmptyMovie(id int) models.Group {\n\treturn models.Group{\n\t\tID:        id,\n\t\tURLs:      models.NewRelatedStrings([]string{}),\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t}\n}\n\nfunc createFullJSONMovie(studio, frontImage, backImage string, customFields map[string]interface{}) *jsonschema.Group {\n\treturn &jsonschema.Group{\n\t\tName:       movieName,\n\t\tAliases:    movieAliases,\n\t\tDate:       date,\n\t\tRating:     rating,\n\t\tDuration:   duration,\n\t\tDirector:   director,\n\t\tSynopsis:   synopsis,\n\t\tURLs:       []string{url},\n\t\tStudio:     studio,\n\t\tFrontImage: frontImage,\n\t\tBackImage:  backImage,\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tCustomFields: customFields,\n\t}\n}\n\nfunc createEmptyJSONMovie() *jsonschema.Group {\n\treturn &jsonschema.Group{\n\t\tURLs: []string{},\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tCustomFields: emptyCustomFields,\n\t}\n}\n\ntype testScenario struct {\n\tmovie        models.Group\n\tcustomFields map[string]interface{}\n\texpected     *jsonschema.Group\n\terr          bool\n}\n\nvar scenarios []testScenario\n\nfunc initTestTable() {\n\tscenarios = []testScenario{\n\t\t{\n\t\t\tcreateFullMovie(movieID, studioID),\n\t\t\tcustomFields,\n\t\t\tcreateFullJSONMovie(studioName, frontImage, backImage, customFields),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateEmptyMovie(emptyID),\n\t\t\temptyCustomFields,\n\t\t\tcreateEmptyJSONMovie(),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateFullMovie(errFrontImageID, studioID),\n\t\t\temptyCustomFields,\n\t\t\tcreateFullJSONMovie(studioName, \"\", backImage, emptyCustomFields),\n\t\t\t// failure to get front image should not cause error\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateFullMovie(errBackImageID, studioID),\n\t\t\temptyCustomFields,\n\t\t\tcreateFullJSONMovie(studioName, frontImage, \"\", emptyCustomFields),\n\t\t\t// failure to get back image should not cause error\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateFullMovie(errStudioMovieID, errStudioID),\n\t\t\temptyCustomFields,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\tcreateFullMovie(missingStudioMovieID, missingStudioID),\n\t\t\temptyCustomFields,\n\t\t\tcreateFullJSONMovie(\"\", frontImage, backImage, emptyCustomFields),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateFullMovie(errCustomFieldsID, studioID),\n\t\t\tcustomFields,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n}\n\nfunc TestToJSON(t *testing.T) {\n\tinitTestTable()\n\n\tdb := mocks.NewDatabase()\n\n\timageErr := errors.New(\"error getting image\")\n\n\tdb.Group.On(\"GetFrontImage\", testCtx, movieID).Return(frontImageBytes, nil).Once()\n\tdb.Group.On(\"GetFrontImage\", testCtx, missingStudioMovieID).Return(frontImageBytes, nil).Once()\n\tdb.Group.On(\"GetFrontImage\", testCtx, emptyID).Return(nil, nil).Once().Maybe()\n\tdb.Group.On(\"GetFrontImage\", testCtx, errFrontImageID).Return(nil, imageErr).Once()\n\tdb.Group.On(\"GetFrontImage\", testCtx, errBackImageID).Return(frontImageBytes, nil).Once()\n\tdb.Group.On(\"GetFrontImage\", testCtx, errCustomFieldsID).Return(nil, nil).Once()\n\n\tdb.Group.On(\"GetBackImage\", testCtx, movieID).Return(backImageBytes, nil).Once()\n\tdb.Group.On(\"GetBackImage\", testCtx, missingStudioMovieID).Return(backImageBytes, nil).Once()\n\tdb.Group.On(\"GetBackImage\", testCtx, emptyID).Return(nil, nil).Once()\n\tdb.Group.On(\"GetBackImage\", testCtx, errBackImageID).Return(nil, imageErr).Once()\n\tdb.Group.On(\"GetBackImage\", testCtx, errFrontImageID).Return(backImageBytes, nil).Maybe()\n\tdb.Group.On(\"GetBackImage\", testCtx, errStudioMovieID).Return(backImageBytes, nil).Maybe()\n\tdb.Group.On(\"GetBackImage\", testCtx, errCustomFieldsID).Return(nil, nil).Once()\n\n\tdb.Group.On(\"GetCustomFields\", testCtx, movieID).Return(customFields, nil).Once()\n\tdb.Group.On(\"GetCustomFields\", testCtx, errCustomFieldsID).Return(nil, errors.New(\"error getting custom fields\")).Once()\n\tdb.Group.On(\"GetCustomFields\", testCtx, mock.Anything).Return(emptyCustomFields, nil).Times(4)\n\n\tstudioErr := errors.New(\"error getting studio\")\n\n\tdb.Studio.On(\"Find\", testCtx, studioID).Return(&movieStudio, nil)\n\tdb.Studio.On(\"Find\", testCtx, missingStudioID).Return(nil, nil)\n\tdb.Studio.On(\"Find\", testCtx, errStudioID).Return(nil, studioErr)\n\n\tfor i, s := range scenarios {\n\t\tmovie := s.movie\n\t\tjson, err := ToJSON(testCtx, db.Group, db.Studio, &movie)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/group/import.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ImporterReaderWriter interface {\n\tmodels.GroupCreatorUpdater\n\tmodels.CustomFieldsWriter\n\tFindByName(ctx context.Context, name string, nocase bool) (*models.Group, error)\n}\n\ntype SubGroupNotExistError struct {\n\tmissingSubGroup string\n}\n\nfunc (e SubGroupNotExistError) Error() string {\n\treturn fmt.Sprintf(\"sub group <%s> does not exist\", e.missingSubGroup)\n}\n\nfunc (e SubGroupNotExistError) MissingSubGroup() string {\n\treturn e.missingSubGroup\n}\n\ntype Importer struct {\n\tReaderWriter        ImporterReaderWriter\n\tStudioWriter        models.StudioFinderCreator\n\tTagWriter           models.TagFinderCreator\n\tInput               jsonschema.Group\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\tgroup          models.Group\n\tfrontImageData []byte\n\tbackImageData  []byte\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\ti.group = i.groupJSONToGroup(i.Input)\n\n\tif err := i.populateStudio(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateTags(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tvar err error\n\tif len(i.Input.FrontImage) > 0 {\n\t\ti.frontImageData, err = utils.ProcessBase64Image(i.Input.FrontImage)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid front_image: %v\", err)\n\t\t}\n\t}\n\tif len(i.Input.BackImage) > 0 {\n\t\ti.backImageData, err = utils.ProcessBase64Image(i.Input.BackImage)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid back_image: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) populateTags(ctx context.Context) error {\n\tif len(i.Input.Tags) > 0 {\n\n\t\ttags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, p := range tags {\n\t\t\ti.group.TagIDs.Add(p.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {\n\ttags, err := tagWriter.FindByNames(ctx, names, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar pluckedNames []string\n\tfor _, tag := range tags {\n\t\tpluckedNames = append(pluckedNames, tag.Name)\n\t}\n\n\tmissingTags := sliceutil.Filter(names, func(name string) bool {\n\t\treturn !slices.Contains(pluckedNames, name)\n\t})\n\n\tif len(missingTags) > 0 {\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\treturn nil, fmt.Errorf(\"tags [%s] not found\", strings.Join(missingTags, \", \"))\n\t\t}\n\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\tcreatedTags, err := createTags(ctx, tagWriter, missingTags)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error creating tags: %v\", err)\n\t\t\t}\n\n\t\t\ttags = append(tags, createdTags...)\n\t\t}\n\n\t\t// ignore if MissingRefBehaviour set to Ignore\n\t}\n\n\treturn tags, nil\n}\n\nfunc createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) {\n\tvar ret []*models.Tag\n\tfor _, name := range names {\n\t\tnewTag := models.NewTag()\n\t\tnewTag.Name = name\n\n\t\terr := tagWriter.Create(ctx, &models.CreateTagInput{\n\t\t\tTag: &newTag,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newTag)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (i *Importer) groupJSONToGroup(groupJSON jsonschema.Group) models.Group {\n\tnewGroup := models.Group{\n\t\tName:      groupJSON.Name,\n\t\tAliases:   groupJSON.Aliases,\n\t\tDirector:  groupJSON.Director,\n\t\tSynopsis:  groupJSON.Synopsis,\n\t\tCreatedAt: groupJSON.CreatedAt.GetTime(),\n\t\tUpdatedAt: groupJSON.UpdatedAt.GetTime(),\n\n\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t}\n\n\tif len(groupJSON.URLs) > 0 {\n\t\tnewGroup.URLs = models.NewRelatedStrings(groupJSON.URLs)\n\t} else if groupJSON.URL != \"\" {\n\t\tnewGroup.URLs = models.NewRelatedStrings([]string{groupJSON.URL})\n\t}\n\tif groupJSON.Date != \"\" {\n\t\td, err := models.ParseDate(groupJSON.Date)\n\t\tif err == nil {\n\t\t\tnewGroup.Date = &d\n\t\t}\n\t}\n\tif groupJSON.Rating != 0 {\n\t\tnewGroup.Rating = &groupJSON.Rating\n\t}\n\n\tif groupJSON.Duration != 0 {\n\t\tnewGroup.Duration = &groupJSON.Duration\n\t}\n\n\treturn newGroup\n}\n\nfunc (i *Importer) populateStudio(ctx context.Context) error {\n\tif i.Input.Studio != \"\" {\n\t\tstudio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding studio by name: %v\", err)\n\t\t}\n\n\t\tif studio == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"group studio '%s' not found\", i.Input.Studio)\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tstudioID, err := i.createStudio(ctx, i.Input.Studio)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\ti.group.StudioID = &studioID\n\t\t\t}\n\t\t} else {\n\t\t\ti.group.StudioID = &studio.ID\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createStudio(ctx context.Context, name string) (int, error) {\n\tnewStudio := models.NewCreateStudioInput()\n\tnewStudio.Name = name\n\n\terr := i.StudioWriter.Create(ctx, &newStudio)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn newStudio.ID, nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\tsubGroups, err := i.getSubGroups(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(subGroups) > 0 {\n\t\tif _, err := i.ReaderWriter.UpdatePartial(ctx, id, models.GroupPartial{\n\t\t\tSubGroups: &models.UpdateGroupDescriptions{\n\t\t\t\tGroups: subGroups,\n\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t},\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting parents: %v\", err)\n\t\t}\n\t}\n\n\tif len(i.Input.CustomFields) > 0 {\n\t\tif err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{\n\t\t\tFull: i.Input.CustomFields,\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting custom fields: %v\", err)\n\t\t}\n\t}\n\n\tif len(i.frontImageData) > 0 {\n\t\tif err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting group front image: %v\", err)\n\t\t}\n\t}\n\n\tif len(i.backImageData) > 0 {\n\t\tif err := i.ReaderWriter.UpdateBackImage(ctx, id, i.backImageData); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting group back image: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\treturn i.Input.Name\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\tconst nocase = false\n\texisting, err := i.ReaderWriter.FindByName(ctx, i.Name(), nocase)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif existing != nil {\n\t\tid := existing.ID\n\t\treturn &id, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\terr := i.ReaderWriter.Create(ctx, &i.group)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating group: %v\", err)\n\t}\n\n\tid := i.group.ID\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\tgroup := i.group\n\tgroup.ID = id\n\terr := i.ReaderWriter.Update(ctx, &group)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating existing group: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) getSubGroups(ctx context.Context) ([]models.GroupIDDescription, error) {\n\tvar subGroups []models.GroupIDDescription\n\tfor _, subGroup := range i.Input.SubGroups {\n\t\tgroup, err := i.ReaderWriter.FindByName(ctx, subGroup.Group, false)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error finding parent by name: %v\", err)\n\t\t}\n\n\t\tif group == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn nil, SubGroupNotExistError{missingSubGroup: subGroup.Group}\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tparentID, err := i.createSubGroup(ctx, subGroup.Group)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tsubGroups = append(subGroups, models.GroupIDDescription{\n\t\t\t\t\tGroupID:     parentID,\n\t\t\t\t\tDescription: subGroup.Description,\n\t\t\t\t})\n\t\t\t}\n\t\t} else {\n\t\t\tsubGroups = append(subGroups, models.GroupIDDescription{\n\t\t\t\tGroupID:     group.ID,\n\t\t\t\tDescription: subGroup.Description,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn subGroups, nil\n}\n\nfunc (i *Importer) createSubGroup(ctx context.Context, name string) (int, error) {\n\tnewGroup := models.NewGroup()\n\tnewGroup.Name = name\n\n\terr := i.ReaderWriter.Create(ctx, &newGroup)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn newGroup.ID, nil\n}\n"
  },
  {
    "path": "pkg/group/import_test.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nconst invalidImage = \"aW1hZ2VCeXRlcw&&\"\n\nconst (\n\tmovieNameErr      = \"movieNameErr\"\n\texistingMovieName = \"existingMovieName\"\n\n\texistingMovieID  = 100\n\texistingStudioID = 101\n\n\texistingStudioName = \"existingStudioName\"\n\texistingStudioErr  = \"existingStudioErr\"\n\tmissingStudioName  = \"existingStudioName\"\n\n\terrImageID = 3\n\n\texistingTagID = 105\n\terrTagsID     = 106\n\n\texistingTagName = \"existingTagName\"\n\texistingTagErr  = \"existingTagErr\"\n\tmissingTagName  = \"missingTagName\"\n)\n\nvar testCtx = context.Background()\n\nfunc TestImporterName(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Group{\n\t\t\tName: movieName,\n\t\t},\n\t}\n\n\tassert.Equal(t, movieName, i.Name())\n}\n\nfunc TestImporterPreImport(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Group{\n\t\t\tName:       movieName,\n\t\t\tFrontImage: invalidImage,\n\t\t},\n\t}\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.Input.FrontImage = frontImage\n\ti.Input.BackImage = invalidImage\n\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.Input.BackImage = \"\"\n\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.Input.BackImage = backImage\n\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n}\n\nfunc TestImporterPreImportWithStudio(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Group{\n\t\t\tName:       movieName,\n\t\t\tFrontImage: frontImage,\n\t\t\tStudio:     existingStudioName,\n\t\t\tRating:     5,\n\t\t\tDuration:   10,\n\t\t},\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioName, false).Return(&models.Studio{\n\t\tID: existingStudioID,\n\t}, nil).Once()\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioErr, false).Return(nil, errors.New(\"FindByName error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.group.StudioID)\n\n\ti.Input.Studio = existingStudioErr\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingStudio(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Group{\n\t\t\tName:       movieName,\n\t\t\tFrontImage: frontImage,\n\t\t\tStudio:     missingStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingStudioName, false).Return(nil, nil).Times(3)\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Run(func(args mock.Arguments) {\n\t\ts := args.Get(1).(*models.CreateStudioInput)\n\t\ts.Studio.ID = existingStudioID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.group.StudioID)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Group{\n\t\t\tName:       movieName,\n\t\t\tFrontImage: frontImage,\n\t\t\tStudio:     missingStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingStudioName, false).Return(nil, nil).Once()\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter:        db.Group,\n\t\tTagWriter:           db.Tag,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Group{\n\t\t\tTags: []string{\n\t\t\t\texistingTagName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagName}, false).Return([]*models.Tag{\n\t\t{\n\t\t\tID:   existingTagID,\n\t\t\tName: existingTagName,\n\t\t},\n\t}, nil).Once()\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingTagID, i.group.TagIDs.List()[0])\n\n\ti.Input.Tags = []string{existingTagErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Group{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Run(func(args mock.Arguments) {\n\t\tt := args.Get(1).(*models.CreateTagInput)\n\t\tt.Tag.ID = existingTagID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingTagID, i.group.TagIDs.List()[0])\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Group{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPostImport(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Group{\n\t\t\tCustomFields: customFields,\n\t\t},\n\t\tfrontImageData: frontImageBytes,\n\t\tbackImageData:  backImageBytes,\n\t}\n\n\tupdateMovieImageErr := errors.New(\"UpdateImages error\")\n\tcustomFieldsErr := errors.New(\"SetCustomFields error\")\n\n\tcustomFieldsInput := models.CustomFieldsInput{\n\t\tFull: customFields,\n\t}\n\n\tdb.Group.On(\"UpdateFrontImage\", testCtx, movieID, frontImageBytes).Return(nil).Once()\n\tdb.Group.On(\"UpdateFrontImage\", testCtx, errImageID, frontImageBytes).Return(updateMovieImageErr).Once()\n\tdb.Group.On(\"UpdateBackImage\", testCtx, movieID, backImageBytes).Return(nil).Once()\n\n\tdb.Group.On(\"SetCustomFields\", testCtx, movieID, customFieldsInput).Return(nil).Once()\n\tdb.Group.On(\"SetCustomFields\", testCtx, errImageID, customFieldsInput).Return(nil).Once()\n\tdb.Group.On(\"SetCustomFields\", testCtx, errCustomFieldsID, customFieldsInput).Return(customFieldsErr).Once()\n\n\terr := i.PostImport(testCtx, movieID)\n\tassert.Nil(t, err)\n\n\terr = i.PostImport(testCtx, errImageID)\n\tassert.NotNil(t, err)\n\n\terr = i.PostImport(testCtx, errCustomFieldsID)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterFindExistingID(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Group{\n\t\t\tName: movieName,\n\t\t},\n\t}\n\n\terrFindByName := errors.New(\"FindByName error\")\n\tdb.Group.On(\"FindByName\", testCtx, movieName, false).Return(nil, nil).Once()\n\tdb.Group.On(\"FindByName\", testCtx, existingMovieName, false).Return(&models.Group{\n\t\tID: existingMovieID,\n\t}, nil).Once()\n\tdb.Group.On(\"FindByName\", testCtx, movieNameErr, false).Return(nil, errFindByName).Once()\n\n\tid, err := i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.Nil(t, err)\n\n\ti.Input.Name = existingMovieName\n\tid, err = i.FindExistingID(testCtx)\n\tassert.Equal(t, existingMovieID, *id)\n\tassert.Nil(t, err)\n\n\ti.Input.Name = movieNameErr\n\tid, err = i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestCreate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tmovie := models.Group{\n\t\tName: movieName,\n\t}\n\n\tmovieErr := models.Group{\n\t\tName: movieNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tStudioWriter: db.Studio,\n\t\tgroup:        movie,\n\t}\n\n\terrCreate := errors.New(\"Create error\")\n\tdb.Group.On(\"Create\", testCtx, &movie).Run(func(args mock.Arguments) {\n\t\tm := args.Get(1).(*models.Group)\n\t\tm.ID = movieID\n\t}).Return(nil).Once()\n\tdb.Group.On(\"Create\", testCtx, &movieErr).Return(errCreate).Once()\n\n\tid, err := i.Create(testCtx)\n\tassert.Equal(t, movieID, *id)\n\tassert.Nil(t, err)\n\n\ti.group = movieErr\n\tid, err = i.Create(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestUpdate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tmovie := models.Group{\n\t\tName: movieName,\n\t}\n\n\tmovieErr := models.Group{\n\t\tName: movieNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.Group,\n\t\tStudioWriter: db.Studio,\n\t\tgroup:        movie,\n\t}\n\n\terrUpdate := errors.New(\"Update error\")\n\n\t// id needs to be set for the mock input\n\tmovie.ID = movieID\n\tdb.Group.On(\"Update\", testCtx, &movie).Return(nil).Once()\n\n\terr := i.Update(testCtx, movieID)\n\tassert.Nil(t, err)\n\n\ti.group = movieErr\n\n\t// need to set id separately\n\tmovieErr.ID = errImageID\n\tdb.Group.On(\"Update\", testCtx, &movieErr).Return(errUpdate).Once()\n\n\terr = i.Update(testCtx, errImageID)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/group/query.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc CountByStudioID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.GroupFilterType{\n\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByTagID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.GroupFilterType{\n\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByContainingGroupID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.GroupFilterType{\n\t\tContainingGroups: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n"
  },
  {
    "path": "pkg/group/reorder.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nvar ErrInvalidInsertIndex = errors.New(\"invalid insert index\")\n\nfunc (s *Service) ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error {\n\t// get the group\n\texisting, err := s.Repository.Find(ctx, groupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// ensure it exists\n\tif existing == nil {\n\t\treturn models.ErrNotFound\n\t}\n\n\t// TODO - ensure the subgroups exist in the group\n\n\t// ensure the insert index is valid\n\tif insertPointID < 0 {\n\t\treturn ErrInvalidInsertIndex\n\t}\n\n\t// reorder the subgroups\n\treturn s.Repository.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter)\n}\n"
  },
  {
    "path": "pkg/group/service.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype CreatorUpdater interface {\n\tmodels.GroupGetter\n\tmodels.GroupCreator\n\tmodels.GroupUpdater\n\tmodels.CustomFieldsWriter\n\n\tmodels.ContainingGroupLoader\n\tmodels.SubGroupLoader\n\n\tAnscestorFinder\n\tSubGroupIDFinder\n\tSubGroupAdder\n\tSubGroupRemover\n\tSubGroupReorderer\n}\n\ntype AnscestorFinder interface {\n\tFindInAncestors(ctx context.Context, ascestorIDs []int, ids []int) ([]int, error)\n}\n\ntype SubGroupIDFinder interface {\n\tFindSubGroupIDs(ctx context.Context, containingID int, ids []int) ([]int, error)\n}\n\ntype SubGroupAdder interface {\n\tAddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error\n}\n\ntype SubGroupRemover interface {\n\tRemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error\n}\n\ntype SubGroupReorderer interface {\n\tReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertID int, insertAfter bool) error\n}\n\ntype Service struct {\n\tRepository CreatorUpdater\n}\n"
  },
  {
    "path": "pkg/group/update.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\ntype SubGroupAlreadyInGroupError struct {\n\tGroupIDs []int\n}\n\nfunc (e *SubGroupAlreadyInGroupError) Error() string {\n\treturn fmt.Sprintf(\"subgroups with IDs %v already in group\", e.GroupIDs)\n}\n\ntype ImageInput struct {\n\tImage []byte\n\tSet   bool\n}\n\nfunc (s *Service) UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage ImageInput, backImage ImageInput) (*models.Group, error) {\n\tif err := s.validateUpdate(ctx, id, updatedGroup); err != nil {\n\t\treturn nil, err\n\t}\n\n\tr := s.Repository\n\n\tgroup, err := r.UpdatePartial(ctx, id, updatedGroup)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// update image table\n\tif frontImage.Set {\n\t\tif err := r.UpdateFrontImage(ctx, id, frontImage.Image); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif backImage.Set {\n\t\tif err := r.UpdateBackImage(ctx, id, backImage.Image); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn group, nil\n}\n\nfunc (s *Service) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error {\n\t// get the group\n\texisting, err := s.Repository.Find(ctx, groupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// ensure it exists\n\tif existing == nil {\n\t\treturn models.ErrNotFound\n\t}\n\n\t// ensure the subgroups aren't already sub-groups of the group\n\tsubGroupIDs := sliceutil.Map(subGroups, func(sg models.GroupIDDescription) int {\n\t\treturn sg.GroupID\n\t})\n\n\texistingSubGroupIDs, err := s.Repository.FindSubGroupIDs(ctx, groupID, subGroupIDs)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(existingSubGroupIDs) > 0 {\n\t\treturn &SubGroupAlreadyInGroupError{\n\t\t\tGroupIDs: existingSubGroupIDs,\n\t\t}\n\t}\n\n\t// validate the hierarchy\n\td := &models.UpdateGroupDescriptions{\n\t\tGroups: subGroups,\n\t\tMode:   models.RelationshipUpdateModeAdd,\n\t}\n\tif err := s.validateUpdateGroupHierarchy(ctx, existing, nil, d); err != nil {\n\t\treturn err\n\t}\n\n\t// validate insert index\n\tif insertIndex != nil && *insertIndex < 0 {\n\t\treturn ErrInvalidInsertIndex\n\t}\n\n\t// add the subgroups\n\treturn s.Repository.AddSubGroups(ctx, groupID, subGroups, insertIndex)\n}\n\nfunc (s *Service) RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error {\n\t// get the group\n\texisting, err := s.Repository.Find(ctx, groupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// ensure it exists\n\tif existing == nil {\n\t\treturn models.ErrNotFound\n\t}\n\n\t// add the subgroups\n\treturn s.Repository.RemoveSubGroups(ctx, groupID, subGroupIDs)\n}\n"
  },
  {
    "path": "pkg/group/validate.go",
    "content": "package group\n\nimport (\n\t\"context\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\nfunc (s *Service) validateCreate(ctx context.Context, group *models.Group) error {\n\tif err := validateName(group.Name); err != nil {\n\t\treturn err\n\t}\n\n\tcontainingIDs := group.ContainingGroups.IDs()\n\tsubIDs := group.SubGroups.IDs()\n\n\tif err := s.validateGroupHierarchy(ctx, containingIDs, subIDs); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (s *Service) validateUpdate(ctx context.Context, id int, partial models.GroupPartial) error {\n\t// get the existing group - ensure it exists\n\texisting, err := s.Repository.Find(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif existing == nil {\n\t\treturn models.ErrNotFound\n\t}\n\n\tif partial.Name.Set {\n\t\tif err := validateName(partial.Name.Value); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := s.validateUpdateGroupHierarchy(ctx, existing, partial.ContainingGroups, partial.SubGroups); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc validateName(n string) error {\n\t// ensure name is not empty\n\tif strings.TrimSpace(n) == \"\" {\n\t\treturn ErrEmptyName\n\t}\n\n\treturn nil\n}\n\nfunc (s *Service) validateGroupHierarchy(ctx context.Context, containingIDs []int, subIDs []int) error {\n\t// only need to validate if both are non-empty\n\tif len(containingIDs) == 0 || len(subIDs) == 0 {\n\t\treturn nil\n\t}\n\n\t// ensure none of the containing groups are in the sub groups\n\tfound, err := s.Repository.FindInAncestors(ctx, containingIDs, subIDs)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(found) > 0 {\n\t\treturn ErrHierarchyLoop\n\t}\n\n\treturn nil\n}\n\nfunc (s *Service) validateUpdateGroupHierarchy(ctx context.Context, existing *models.Group, containingGroups *models.UpdateGroupDescriptions, subGroups *models.UpdateGroupDescriptions) error {\n\t// no need to validate if there are no changes\n\tif containingGroups == nil && subGroups == nil {\n\t\treturn nil\n\t}\n\n\tif err := existing.LoadContainingGroupIDs(ctx, s.Repository); err != nil {\n\t\treturn err\n\t}\n\texistingContainingGroups := existing.ContainingGroups.List()\n\n\tif err := existing.LoadSubGroupIDs(ctx, s.Repository); err != nil {\n\t\treturn err\n\t}\n\texistingSubGroups := existing.SubGroups.List()\n\n\teffectiveContainingGroups := existingContainingGroups\n\tif containingGroups != nil {\n\t\teffectiveContainingGroups = containingGroups.Apply(existingContainingGroups)\n\t}\n\n\teffectiveSubGroups := existingSubGroups\n\tif subGroups != nil {\n\t\teffectiveSubGroups = subGroups.Apply(existingSubGroups)\n\t}\n\n\tcontainingIDs := idsFromGroupDescriptions(effectiveContainingGroups)\n\tsubIDs := idsFromGroupDescriptions(effectiveSubGroups)\n\n\t// ensure we haven't set the group as a subgroup of itself\n\tif slices.Contains(containingIDs, existing.ID) || slices.Contains(subIDs, existing.ID) {\n\t\treturn ErrHierarchyLoop\n\t}\n\n\treturn s.validateGroupHierarchy(ctx, containingIDs, subIDs)\n}\n\nfunc idsFromGroupDescriptions(v []models.GroupIDDescription) []int {\n\treturn sliceutil.Map(v, func(g models.GroupIDDescription) int { return g.GroupID })\n}\n"
  },
  {
    "path": "pkg/hash/imagephash/phash.go",
    "content": "package imagephash\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"image\"\n\n\t\"github.com/corona10/goimagehash\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg/transcoder\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// Generate computes a perceptual hash for an image file.\nfunc Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, error) {\n\timg, err := loadImage(encoder, imageFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"loading image: %w\", err)\n\t}\n\n\thash, err := goimagehash.PerceptionHash(img)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"computing phash from image: %w\", err)\n\t}\n\n\thashValue := hash.GetHash()\n\treturn &hashValue, nil\n}\n\n// loadImage loads an image from disk and decodes it.\n// Where Go has no built-in decoder for a specific format, ffmpeg is used to convert to BMP first.\nfunc loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) {\n\t// try to load with Go's built-in decoders first for better performance\n\treader, err := imageFile.Open(&file.OsFS{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer reader.Close()\n\n\tbuf := new(bytes.Buffer)\n\tif _, err := buf.ReadFrom(reader); err != nil {\n\t\treturn nil, err\n\t}\n\n\timg, _, err := image.Decode(buf)\n\tif errors.Is(err, image.ErrFormat) {\n\t\t// try ffmpeg as a fallback for unsupported formats\n\t\t// ffmpeg cannot read files inside zips\n\t\tif imageFile.Base().ZipFileID != nil {\n\t\t\treturn nil, fmt.Errorf(\"ffmpeg fallback unsupported for images in zip files\")\n\t\t}\n\t\treturn loadImageFFmpeg(encoder, imageFile.Path)\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"decoding image: %w\", err)\n\t}\n\n\treturn img, nil\n}\n\n// loadImageFFmpeg uses ffmpeg to convert an image to BMP and then decodes it.\nfunc loadImageFFmpeg(encoder *ffmpeg.FFMpeg, path string) (image.Image, error) {\n\toptions := transcoder.ScreenshotOptions{\n\t\tOutputPath: \"-\",\n\t\tOutputType: transcoder.ScreenshotOutputTypeBMP,\n\t}\n\n\targs := transcoder.ScreenshotTime(path, 0, options)\n\tdata, err := encoder.GenerateOutput(context.Background(), args, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting image with ffmpeg: %w\", err)\n\t}\n\n\timg, _, err := image.Decode(bytes.NewReader(data))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"decoding ffmpeg output: %w\", err)\n\t}\n\n\treturn img, nil\n}\n"
  },
  {
    "path": "pkg/hash/key.go",
    "content": "// Package hash provides utility functions for generating hashes from strings and random keys.\npackage hash\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"hash/fnv\"\n)\n\n// GenerateRandomKey generates a random string of length l.\n// It returns an empty string and an error if an error occurs while generating a random number.\nfunc GenerateRandomKey(l int) (string, error) {\n\tb := make([]byte, l)\n\tif _, err := rand.Read(b); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn fmt.Sprintf(\"%x\", b), nil\n}\n\n// IntFromString generates a uint64 from a string.\n// Values returned by this function are guaranteed to be the same for equal strings.\n// They are not guaranteed to be unique for different strings.\nfunc IntFromString(str string) uint64 {\n\th := fnv.New64a()\n\th.Write([]byte(str))\n\treturn h.Sum64()\n}\n"
  },
  {
    "path": "pkg/hash/md5/md5.go",
    "content": "// Package md5 provides utility functions for generating MD5 hashes.\npackage md5\n\nimport (\n\t\"crypto/md5\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n)\n\n// FromBytes returns an MD5 checksum string from data.\nfunc FromBytes(data []byte) string {\n\tresult := md5.Sum(data)\n\treturn fmt.Sprintf(\"%x\", result)\n}\n\n// FromString returns an MD5 checksum string from str.\nfunc FromString(str string) string {\n\tdata := []byte(str)\n\treturn FromBytes(data)\n}\n\n// FromFilePath returns an MD5 checksum string for the file at filePath.\n// It returns an empty string and an error if an error occurs opening the file.\nfunc FromFilePath(filePath string) (string, error) {\n\tf, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer f.Close()\n\n\treturn FromReader(f)\n}\n\n// FromReader returns an MD5 checksum string from data read from src.\n// It returns an empty string and an error if an error occurs reading from src.\nfunc FromReader(src io.Reader) (string, error) {\n\th := md5.New()\n\tif _, err := io.Copy(h, src); err != nil {\n\t\treturn \"\", err\n\t}\n\tchecksum := h.Sum(nil)\n\treturn fmt.Sprintf(\"%x\", checksum), nil\n}\n"
  },
  {
    "path": "pkg/hash/oshash/oshash.go",
    "content": "// Package oshash implements the algorithm that OpenSubtitles.org uses to generate unique hashes.\n//\n// Calculation is as follows:\n// size + 64 bit checksum of the first and last 64k bytes of the file.\npackage oshash\n\nimport (\n\t\"encoding/binary\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n)\n\nconst chunkSize int64 = 64 * 1024\n\nvar ErrOsHashLen = errors.New(\"buffer is not a multiple of 8\")\n\nfunc sumBytes(buf []byte) (uint64, error) {\n\tif len(buf)%8 != 0 {\n\t\treturn 0, ErrOsHashLen\n\t}\n\n\tsz := len(buf) / 8\n\tvar sum uint64\n\tfor j := 0; j < sz; j++ {\n\t\tsum += binary.LittleEndian.Uint64(buf[8*j : 8*(j+1)])\n\t}\n\n\treturn sum, nil\n}\n\nfunc oshash(size int64, head []byte, tail []byte) (string, error) {\n\theadSum, err := sumBytes(head)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"oshash head: %w\", err)\n\t}\n\ttailSum, err := sumBytes(tail)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"oshash tail: %w\", err)\n\t}\n\n\t// Compute the sum of the head, tail and file size\n\tresult := headSum + tailSum + uint64(size)\n\t// output as hex\n\treturn fmt.Sprintf(\"%016x\", result), nil\n}\n\n// FromReader calculates the hash reading from src.\nfunc FromReader(src io.ReadSeeker, fileSize int64) (string, error) {\n\tif fileSize <= 8 {\n\t\treturn \"\", fmt.Errorf(\"cannot calculate oshash where size < 8 (%d)\", fileSize)\n\t}\n\n\tfileChunkSize := chunkSize\n\tif fileSize < fileChunkSize {\n\t\t// Must be a multiple of 8.\n\t\tfileChunkSize = (fileSize / 8) * 8\n\t}\n\n\thead := make([]byte, fileChunkSize)\n\ttail := make([]byte, fileChunkSize)\n\n\t// read the head of the file into the start of the buffer\n\t_, err := src.Read(head)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// seek to the end of the file - the chunk size\n\t_, err = src.Seek(-fileChunkSize, io.SeekEnd)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// read the tail of the file\n\t_, err = src.Read(tail)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn oshash(fileSize, head, tail)\n}\n\n// Is the equivalent of opening filePath, and calling FromReader with the data and file size.\nfunc FromFilePath(filePath string) (string, error) {\n\tf, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer f.Close()\n\n\tfi, err := f.Stat()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tfileSize := fi.Size()\n\n\treturn FromReader(f, fileSize)\n}\n"
  },
  {
    "path": "pkg/hash/oshash/oshash_test.go",
    "content": "package oshash\n\nimport (\n\t\"bytes\"\n\t\"math/rand\"\n\t\"testing\"\n)\n\nfunc BenchmarkOsHash(b *testing.B) {\n\tsrc := rand.NewSource(9999)\n\tr := rand.New(src)\n\n\tsize := int64(1234567890)\n\n\thead := make([]byte, 1024*64)\n\t_, err := r.Read(head)\n\tif err != nil {\n\t\tb.Errorf(\"unable to generate head array: %v\", err)\n\t}\n\n\ttail := make([]byte, 1024*64)\n\t_, err = r.Read(tail)\n\tif err != nil {\n\t\tb.Errorf(\"unable to generate tail array: %v\", err)\n\t}\n\n\tb.ResetTimer()\n\n\tfor n := 0; n < b.N; n++ {\n\t\t_, err := oshash(size, head, tail)\n\t\tif err != nil {\n\t\t\tb.Errorf(\"unexpected error: %v\", err)\n\t\t}\n\t}\n}\n\nfunc TestFromReader(t *testing.T) {\n\tmakeByteArray := func(base []byte, mag int) []byte {\n\t\tret := base\n\t\tfor i := 0; i < mag; i++ {\n\t\t\tret = append(ret, ret...)\n\t\t}\n\t\treturn ret\n\t}\n\n\tmakeTailArray := func(base []byte, tail []byte) []byte {\n\t\tret := base\n\t\tt := make([]byte, chunkSize)\n\t\tcopy(t[len(t)-len(tail):], tail)\n\t\tret = append(ret, t...)\n\t\treturn ret\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tdata    []byte\n\t\twant    string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"empty\",\n\t\t\t[]byte{},\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"regular\",\n\t\t\tmakeByteArray([]byte(\"this is a test\"), 15),\n\t\t\t\"6a0eba04654d0b9b\",\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"< chunk size\",\n\t\t\t[]byte(\"hello world\"),\n\t\t\t\"d3e392dee38cd4df\",\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"< 8\",\n\t\t\t[]byte(\"hello\"),\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"identical #1\",\n\t\t\tmakeTailArray(make([]byte, chunkSize), []byte(\"this is dumb\")),\n\t\t\t\"d5d6ddd820756920\",\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"identical #2\",\n\t\t\tmakeTailArray(make([]byte, chunkSize), []byte(\"dumb is this\")),\n\t\t\t\"d5d6ddd820756920\",\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tr := bytes.NewReader(tt.data)\n\n\t\t\tgot, err := FromReader(r, int64(len(tt.data)))\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FromReader() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"FromReader() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/hash/videophash/phash.go",
    "content": "package videophash\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"image\"\n\t\"image/color\"\n\t\"math\"\n\n\t\"github.com/corona10/goimagehash\"\n\t\"github.com/disintegration/imaging\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg/transcoder\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst (\n\tscreenshotSize = 160\n\tcolumns        = 5\n\trows           = 5\n)\n\nfunc Generate(encoder *ffmpeg.FFMpeg, videoFile *models.VideoFile) (*uint64, error) {\n\tsprite, err := generateSprite(encoder, videoFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\thash, err := goimagehash.PerceptionHash(sprite)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"computing phash from sprite: %w\", err)\n\t}\n\thashValue := hash.GetHash()\n\treturn &hashValue, nil\n}\n\nfunc generateSpriteScreenshot(encoder *ffmpeg.FFMpeg, input string, t float64) (image.Image, error) {\n\toptions := transcoder.ScreenshotOptions{\n\t\tWidth:      screenshotSize,\n\t\tOutputPath: \"-\",\n\t\tOutputType: transcoder.ScreenshotOutputTypeBMP,\n\t}\n\n\targs := transcoder.ScreenshotTime(input, t, options)\n\tdata, err := encoder.GenerateOutput(context.Background(), args, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treader := bytes.NewReader(data)\n\n\timg, _, err := image.Decode(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"decoding image: %w\", err)\n\t}\n\n\treturn img, nil\n}\n\nfunc combineImages(images []image.Image) image.Image {\n\twidth := images[0].Bounds().Size().X\n\theight := images[0].Bounds().Size().Y\n\tcanvasWidth := width * columns\n\tcanvasHeight := height * rows\n\tmontage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{})\n\tfor index := 0; index < len(images); index++ {\n\t\tx := width * (index % columns)\n\t\ty := height * int(math.Floor(float64(index)/float64(rows)))\n\t\timg := images[index]\n\t\tmontage = imaging.Paste(montage, img, image.Pt(x, y))\n\t}\n\n\treturn montage\n}\n\nfunc generateSprite(encoder *ffmpeg.FFMpeg, videoFile *models.VideoFile) (image.Image, error) {\n\tlogger.Infof(\"[generator] generating phash sprite for %s\", videoFile.Path)\n\n\t// Generate sprite image offset by 5% on each end to avoid intro/outros\n\tchunkCount := columns * rows\n\toffset := 0.05 * videoFile.Duration\n\tstepSize := (0.9 * videoFile.Duration) / float64(chunkCount)\n\tvar images []image.Image\n\tfor i := 0; i < chunkCount; i++ {\n\t\ttime := offset + (float64(i) * stepSize)\n\n\t\timg, err := generateSpriteScreenshot(encoder, videoFile.Path, time)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"generating sprite screenshot: %w\", err)\n\t\t}\n\n\t\timages = append(images, img)\n\t}\n\n\t// Combine all of the thumbnails into a sprite image\n\tif len(images) == 0 {\n\t\treturn nil, fmt.Errorf(\"images slice is empty, failed to generate phash sprite for %s\", videoFile.Path)\n\t}\n\n\treturn combineImages(images), nil\n}\n"
  },
  {
    "path": "pkg/image/delete.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n)\n\n// FileDeleter is an extension of file.Deleter that handles deletion of image files.\ntype FileDeleter struct {\n\t*file.Deleter\n\n\tPaths *paths.Paths\n}\n\n// MarkGeneratedFiles marks for deletion the generated files for the provided image.\n// Generated files bypass trash and are permanently deleted since they can be regenerated.\nfunc (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {\n\tvar files []string\n\tthumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth)\n\texists, _ := fsutil.FileExists(thumbPath)\n\tif exists {\n\t\tfiles = append(files, thumbPath)\n\t}\n\tprevPath := d.Paths.Generated.GetClipPreviewPath(image.Checksum, models.DefaultGthumbWidth)\n\texists, _ = fsutil.FileExists(prevPath)\n\tif exists {\n\t\tfiles = append(files, prevPath)\n\t}\n\n\treturn d.FilesWithoutTrash(files)\n}\n\n// Destroy destroys an image, optionally marking the file and generated files for deletion.\nfunc (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {\n\treturn s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)\n}\n\n// DestroyZipImages destroys all images in zip, optionally marking the files and generated files for deletion.\n// Returns a slice of images that were destroyed.\nfunc (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *FileDeleter, deleteGenerated bool) ([]*models.Image, error) {\n\tvar imgsDestroyed []*models.Image\n\tzipFileID := zipFile.Base().ID\n\n\timgs, err := s.Repository.FindByZipFileID(ctx, zipFileID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, img := range imgs {\n\t\tif err := img.LoadFiles(ctx, s.Repository); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// #5048 - if the image has multiple files, we just want to remove the file in the zip file,\n\t\t// not delete the image entirely\n\t\tif len(img.Files.List()) > 1 {\n\t\t\tfor _, f := range img.Files.List() {\n\t\t\t\tif f.Base().ZipFileID == nil || *f.Base().ZipFileID != zipFileID {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif err := s.Repository.RemoveFileID(ctx, img.ID, f.Base().ID); err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"failed to remove file from image: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// don't delete the image\n\t\t\tcontinue\n\t\t}\n\n\t\tconst deleteFileInZip = false\n\t\tconst destroyFileEntry = false\n\t\tif err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip, destroyFileEntry); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\timgsDestroyed = append(imgsDestroyed, img)\n\t}\n\n\treturn imgsDestroyed, nil\n}\n\n// DestroyFolderImages destroys all images in a folder, optionally marking the files and generated files for deletion.\n// It will not delete images that are attached to more than one gallery.\n// Returns a slice of images that were destroyed.\nfunc (s *Service) DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {\n\tvar imgsDestroyed []*models.Image\n\n\t// find images in this folder\n\timgs, err := s.Repository.FindByFolderID(ctx, folderID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, img := range imgs {\n\t\tif err := img.LoadFiles(ctx, s.Repository); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// #5048 - if the image has multiple files, we just want to remove the file\n\t\t// in the folder\n\t\tif len(img.Files.List()) > 1 {\n\t\t\tfor _, f := range img.Files.List() {\n\t\t\t\tif f.Base().ParentFolderID != folderID {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif err := s.Repository.RemoveFileID(ctx, img.ID, f.Base().ID); err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"failed to remove file from image: %w\", err)\n\t\t\t\t}\n\n\t\t\t\t// we still want to delete the file from the folder, if applicable\n\t\t\t\tif deleteFile {\n\t\t\t\t\tif err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"failed to delete image file: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// don't delete the image\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := img.LoadGalleryIDs(ctx, s.Repository); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// only destroy images that are not attached to other galleries\n\t\tif len(img.GalleryIDs.List()) > 1 {\n\t\t\tcontinue\n\t\t}\n\n\t\tconst destroyFileEntry = false\n\t\tif err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\timgsDestroyed = append(imgsDestroyed, img)\n\t}\n\n\treturn imgsDestroyed, nil\n}\n\n// Destroy destroys an image, optionally marking the file and generated files for deletion.\nfunc (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {\n\tif deleteFile {\n\t\tif err := s.deleteFiles(ctx, i, fileDeleter); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else if destroyFileEntry {\n\t\tif err := s.destroyFileEntries(ctx, i); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif deleteGenerated {\n\t\tif err := fileDeleter.MarkGeneratedFiles(i); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn s.Repository.Destroy(ctx, i.ID)\n}\n\n// deleteFiles deletes files for the image from the database and file system, if they are not in use by other images\nfunc (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter *FileDeleter) error {\n\tif err := i.LoadFiles(ctx, s.Repository); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, f := range i.Files.List() {\n\t\t// only delete files where there is no other associated image\n\t\totherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(otherImages) > 1 {\n\t\t\t// other image associated, don't remove\n\t\t\tcontinue\n\t\t}\n\n\t\t// don't delete files in zip archives\n\t\tconst deleteFile = true\n\t\tif f.Base().ZipFileID == nil {\n\t\t\tlogger.Info(\"Deleting image file: \", f.Base().Path)\n\t\t\tif err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// destroyFileEntries destroys file entries from the database without deleting\n// the files from the filesystem\nfunc (s *Service) destroyFileEntries(ctx context.Context, i *models.Image) error {\n\tif err := i.LoadFiles(ctx, s.Repository); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, f := range i.Files.List() {\n\t\t// only destroy file entries where there is no other associated image\n\t\totherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(otherImages) > 1 {\n\t\t\t// other image associated, don't remove\n\t\t\tcontinue\n\t\t}\n\n\t\t// don't destroy files in zip archives\n\t\tif f.Base().ZipFileID == nil {\n\t\t\tconst deleteFile = false\n\t\t\tlogger.Info(\"Destroying image file entry: \", f.Base().Path)\n\t\t\tif err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/image/export.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n)\n\ntype ExportReader interface {\n\tmodels.CustomFieldsReader\n}\n\n// ToBasicJSON converts a image object into its JSON object equivalent. It\n// does not convert the relationships to other objects, with the exception\n// of cover image.\nfunc ToBasicJSON(ctx context.Context, reader ExportReader, image *models.Image) (*jsonschema.Image, error) {\n\tnewImageJSON := jsonschema.Image{\n\t\tTitle:        image.Title,\n\t\tCode:         image.Code,\n\t\tURLs:         image.URLs.List(),\n\t\tDetails:      image.Details,\n\t\tPhotographer: image.Photographer,\n\t\tCreatedAt:    json.JSONTime{Time: image.CreatedAt},\n\t\tUpdatedAt:    json.JSONTime{Time: image.UpdatedAt},\n\t}\n\n\tif image.Rating != nil {\n\t\tnewImageJSON.Rating = *image.Rating\n\t}\n\n\tif image.Date != nil {\n\t\tnewImageJSON.Date = image.Date.String()\n\t}\n\n\tnewImageJSON.Organized = image.Organized\n\tnewImageJSON.OCounter = image.OCounter\n\n\tvar err error\n\tnewImageJSON.CustomFields, err = reader.GetCustomFields(ctx, image.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting image custom fields: %v\", err)\n\t}\n\n\tfor _, f := range image.Files.List() {\n\t\tnewImageJSON.Files = append(newImageJSON.Files, f.Base().Path)\n\t}\n\n\treturn &newImageJSON, nil\n}\n\n// GetStudioName returns the name of the provided image's studio. It returns an\n// empty string if there is no studio assigned to the image.\nfunc GetStudioName(ctx context.Context, reader models.StudioGetter, image *models.Image) (string, error) {\n\tif image.StudioID != nil {\n\t\tstudio, err := reader.Find(ctx, *image.StudioID)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tif studio != nil {\n\t\t\treturn studio.Name, nil\n\t\t}\n\t}\n\n\treturn \"\", nil\n}\n\n// GetGalleryChecksum returns the checksum of the provided image. It returns an\n// empty string if there is no gallery assigned to the image.\n// func GetGalleryChecksum(reader models.GalleryReader, image *models.Image) (string, error) {\n// \tgallery, err := reader.FindByImageID(image.ID)\n// \tif err != nil {\n// \t\treturn \"\", fmt.Errorf(\"error getting image gallery: %v\", err)\n// \t}\n\n// \tif gallery != nil {\n// \t\treturn gallery.Checksum, nil\n// \t}\n\n// \treturn \"\", nil\n// }\n"
  },
  {
    "path": "pkg/image/export_test.go",
    "content": "package image\n\nimport (\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\timageID = 1\n\n\tstudioID        = 4\n\tmissingStudioID = 5\n\terrStudioID     = 6\n)\n\nvar (\n\ttitle      = \"title\"\n\trating     = 5\n\turl        = \"http://a.com\"\n\tdate       = \"2001-01-01\"\n\tdateObj, _ = models.ParseDate(date)\n\torganized  = true\n\tocounter   = 2\n\n\tcustomFields = map[string]interface{}{\n\t\t\"customField1\": \"customValue1\",\n\t}\n)\n\nconst (\n\tstudioName = \"studioName\"\n\tpath       = \"path\"\n)\n\nvar (\n\tcreateTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC)\n\tupdateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC)\n)\n\nfunc createFullImage(id int) models.Image {\n\treturn models.Image{\n\t\tID: id,\n\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t&models.BaseFile{\n\t\t\t\tPath: path,\n\t\t\t},\n\t\t}),\n\t\tTitle:     title,\n\t\tOCounter:  ocounter,\n\t\tRating:    &rating,\n\t\tDate:      &dateObj,\n\t\tURLs:      models.NewRelatedStrings([]string{url}),\n\t\tOrganized: organized,\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t}\n}\n\nfunc createFullJSONImage(customFields map[string]interface{}) *jsonschema.Image {\n\treturn &jsonschema.Image{\n\t\tTitle:     title,\n\t\tOCounter:  ocounter,\n\t\tRating:    rating,\n\t\tDate:      date,\n\t\tURLs:      []string{url},\n\t\tOrganized: organized,\n\t\tFiles:     []string{path},\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tCustomFields: customFields,\n\t}\n}\n\ntype basicTestScenario struct {\n\tinput        models.Image\n\tcustomFields map[string]interface{}\n\texpected     *jsonschema.Image\n}\n\nvar scenarios = []basicTestScenario{\n\t{\n\t\tcreateFullImage(imageID),\n\t\tcustomFields,\n\t\tcreateFullJSONImage(customFields),\n\t},\n}\n\nfunc TestToJSON(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\tdb.Image.On(\"GetCustomFields\", testCtx, imageID).Return(customFields, nil).Once()\n\n\tfor i, s := range scenarios {\n\t\timage := s.input\n\t\tjson, err := ToBasicJSON(testCtx, db.Image, &image)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t}\n\n\tdb.AssertExpectations(t)\n}\n\nfunc createStudioImage(studioID int) models.Image {\n\treturn models.Image{\n\t\tStudioID: &studioID,\n\t}\n}\n\ntype stringTestScenario struct {\n\tinput    models.Image\n\texpected string\n\terr      bool\n}\n\nvar getStudioScenarios = []stringTestScenario{\n\t{\n\t\tcreateStudioImage(studioID),\n\t\tstudioName,\n\t\tfalse,\n\t},\n\t{\n\t\tcreateStudioImage(missingStudioID),\n\t\t\"\",\n\t\tfalse,\n\t},\n\t{\n\t\tcreateStudioImage(errStudioID),\n\t\t\"\",\n\t\ttrue,\n\t},\n}\n\nfunc TestGetStudioName(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tstudioErr := errors.New(\"error getting image\")\n\n\tdb.Studio.On(\"Find\", testCtx, studioID).Return(&models.Studio{\n\t\tName: studioName,\n\t}, nil).Once()\n\tdb.Studio.On(\"Find\", testCtx, missingStudioID).Return(nil, nil).Once()\n\tdb.Studio.On(\"Find\", testCtx, errStudioID).Return(nil, studioErr).Once()\n\n\tfor i, s := range getStudioScenarios {\n\t\timage := s.input\n\t\tjson, err := GetStudioName(testCtx, db.Studio, &image)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/image/filter.go",
    "content": "package image\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc PathsFilter(paths []string) *models.ImageFilterType {\n\tif paths == nil {\n\t\treturn nil\n\t}\n\n\tsep := string(filepath.Separator)\n\n\tvar ret *models.ImageFilterType\n\tvar or *models.ImageFilterType\n\tfor _, p := range paths {\n\t\tnewOr := &models.ImageFilterType{}\n\t\tif or != nil {\n\t\t\tor.Or = newOr\n\t\t} else {\n\t\t\tret = newOr\n\t\t}\n\n\t\tor = newOr\n\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tor.Path = &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/image/import.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\ntype GalleryFinder interface {\n\tFindByPath(ctx context.Context, p string) ([]*models.Gallery, error)\n\tFindUserGalleryByTitle(ctx context.Context, title string) ([]*models.Gallery, error)\n}\n\ntype ImporterReaderWriter interface {\n\tmodels.ImageCreatorUpdater\n\tFindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error)\n}\n\ntype Importer struct {\n\tReaderWriter        ImporterReaderWriter\n\tFileFinder          models.FileFinder\n\tStudioWriter        models.StudioFinderCreator\n\tGalleryFinder       GalleryFinder\n\tPerformerWriter     models.PerformerFinderCreator\n\tTagWriter           models.TagFinderCreator\n\tInput               jsonschema.Image\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\tID           int\n\timage        models.Image\n\tcustomFields map[string]interface{}\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\ti.image = i.imageJSONToImage(i.Input)\n\n\tif err := i.populateFiles(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateStudio(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateGalleries(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populatePerformers(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateTags(ctx); err != nil {\n\t\treturn err\n\t}\n\n\ti.customFields = i.Input.CustomFields\n\n\treturn nil\n}\n\nfunc (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image {\n\tnewImage := models.Image{\n\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\n\t\tTitle:     imageJSON.Title,\n\t\tOrganized: imageJSON.Organized,\n\t\tOCounter:  imageJSON.OCounter,\n\t\tCreatedAt: imageJSON.CreatedAt.GetTime(),\n\t\tUpdatedAt: imageJSON.UpdatedAt.GetTime(),\n\t}\n\n\tif imageJSON.Title != \"\" {\n\t\tnewImage.Title = imageJSON.Title\n\t}\n\tif imageJSON.Code != \"\" {\n\t\tnewImage.Code = imageJSON.Code\n\t}\n\tif imageJSON.Details != \"\" {\n\t\tnewImage.Details = imageJSON.Details\n\t}\n\tif imageJSON.Photographer != \"\" {\n\t\tnewImage.Photographer = imageJSON.Photographer\n\t}\n\tif imageJSON.Rating != 0 {\n\t\tnewImage.Rating = &imageJSON.Rating\n\t}\n\tif len(imageJSON.URLs) > 0 {\n\t\tnewImage.URLs = models.NewRelatedStrings(imageJSON.URLs)\n\t} else if imageJSON.URL != \"\" {\n\t\tnewImage.URLs = models.NewRelatedStrings([]string{imageJSON.URL})\n\t}\n\n\tif imageJSON.Date != \"\" {\n\t\td, err := models.ParseDate(imageJSON.Date)\n\t\tif err == nil {\n\t\t\tnewImage.Date = &d\n\t\t}\n\t}\n\n\treturn newImage\n}\n\nfunc (i *Importer) populateFiles(ctx context.Context) error {\n\tfiles := make([]models.File, 0)\n\n\tfor _, ref := range i.Input.Files {\n\t\tpath := ref\n\t\tf, err := i.FileFinder.FindByPath(ctx, path, true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding file: %w\", err)\n\t\t}\n\n\t\tif f == nil {\n\t\t\treturn fmt.Errorf(\"image file '%s' not found\", path)\n\t\t} else {\n\t\t\tfiles = append(files, f)\n\t\t}\n\t}\n\n\ti.image.Files = models.NewRelatedFiles(files)\n\n\treturn nil\n}\n\nfunc (i *Importer) populateStudio(ctx context.Context) error {\n\tif i.Input.Studio != \"\" {\n\t\tstudio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding studio by name: %v\", err)\n\t\t}\n\n\t\tif studio == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"image studio '%s' not found\", i.Input.Studio)\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tstudioID, err := i.createStudio(ctx, i.Input.Studio)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\ti.image.StudioID = &studioID\n\t\t\t}\n\t\t} else {\n\t\t\ti.image.StudioID = &studio.ID\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createStudio(ctx context.Context, name string) (int, error) {\n\tnewStudio := models.NewCreateStudioInput()\n\tnewStudio.Name = name\n\n\terr := i.StudioWriter.Create(ctx, &newStudio)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn newStudio.ID, nil\n}\n\nfunc (i *Importer) locateGallery(ctx context.Context, ref jsonschema.GalleryRef) (*models.Gallery, error) {\n\tvar galleries []*models.Gallery\n\tvar err error\n\tswitch {\n\tcase ref.FolderPath != \"\":\n\t\tgalleries, err = i.GalleryFinder.FindByPath(ctx, ref.FolderPath)\n\tcase len(ref.ZipFiles) > 0:\n\t\tfor _, p := range ref.ZipFiles {\n\t\t\tgalleries, err = i.GalleryFinder.FindByPath(ctx, p)\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif len(galleries) > 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\tcase ref.Title != \"\":\n\t\tgalleries, err = i.GalleryFinder.FindUserGalleryByTitle(ctx, ref.Title)\n\t}\n\n\tvar ret *models.Gallery\n\tif len(galleries) > 0 {\n\t\tret = galleries[0]\n\t}\n\n\treturn ret, err\n}\n\nfunc (i *Importer) populateGalleries(ctx context.Context) error {\n\tfor _, ref := range i.Input.Galleries {\n\t\tgallery, err := i.locateGallery(ctx, ref)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding gallery: %v\", err)\n\t\t}\n\n\t\tif gallery == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"image gallery '%s' not found\", ref.String())\n\t\t\t}\n\n\t\t\t// we don't create galleries - just ignore\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore || i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t} else {\n\t\t\ti.image.GalleryIDs.Add(gallery.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) populatePerformers(ctx context.Context) error {\n\tif len(i.Input.Performers) > 0 {\n\t\tnames := i.Input.Performers\n\t\tperformers, err := i.PerformerWriter.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar pluckedNames []string\n\t\tfor _, performer := range performers {\n\t\t\tif performer.Name == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpluckedNames = append(pluckedNames, performer.Name)\n\t\t}\n\n\t\tmissingPerformers := sliceutil.Filter(names, func(name string) bool {\n\t\t\treturn !slices.Contains(pluckedNames, name)\n\t\t})\n\n\t\tif len(missingPerformers) > 0 {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"image performers [%s] not found\", strings.Join(missingPerformers, \", \"))\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tcreatedPerformers, err := i.createPerformers(ctx, missingPerformers)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error creating image performers: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tperformers = append(performers, createdPerformers...)\n\t\t\t}\n\n\t\t\t// ignore if MissingRefBehaviour set to Ignore\n\t\t}\n\n\t\tfor _, p := range performers {\n\t\t\ti.image.PerformerIDs.Add(p.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createPerformers(ctx context.Context, names []string) ([]*models.Performer, error) {\n\tvar ret []*models.Performer\n\tfor _, name := range names {\n\t\tnewPerformer := models.NewPerformer()\n\t\tnewPerformer.Name = name\n\n\t\terr := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{\n\t\t\tPerformer: &newPerformer,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newPerformer)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (i *Importer) populateTags(ctx context.Context) error {\n\tif len(i.Input.Tags) > 0 {\n\n\t\ttags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, t := range tags {\n\t\t\ti.image.TagIDs.Add(t.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\tif i.Input.Title != \"\" {\n\t\treturn i.Input.Title\n\t}\n\n\tif len(i.Input.Files) > 0 {\n\t\treturn i.Input.Files[0]\n\t}\n\n\treturn \"\"\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\tvar existing []*models.Image\n\tvar err error\n\n\tfor _, f := range i.image.Files.List() {\n\t\texisting, err = i.ReaderWriter.FindByFileID(ctx, f.Base().ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(existing) > 0 {\n\t\t\tid := existing[0].ID\n\t\t\treturn &id, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\tvar fileIDs []models.FileID\n\tfor _, f := range i.image.Files.List() {\n\t\tfileIDs = append(fileIDs, f.Base().ID)\n\t}\n\n\terr := i.ReaderWriter.Create(ctx, &models.CreateImageInput{\n\t\tImage:        &i.image,\n\t\tFileIDs:      fileIDs,\n\t\tCustomFields: i.customFields,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating image: %v\", err)\n\t}\n\n\tid := i.image.ID\n\ti.ID = id\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\timage := i.image\n\timage.ID = id\n\ti.ID = id\n\terr := i.ReaderWriter.Update(ctx, &image)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating existing image: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {\n\ttags, err := tagWriter.FindByNames(ctx, names, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar pluckedNames []string\n\tfor _, tag := range tags {\n\t\tpluckedNames = append(pluckedNames, tag.Name)\n\t}\n\n\tmissingTags := sliceutil.Filter(names, func(name string) bool {\n\t\treturn !slices.Contains(pluckedNames, name)\n\t})\n\n\tif len(missingTags) > 0 {\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\treturn nil, fmt.Errorf(\"tags [%s] not found\", strings.Join(missingTags, \", \"))\n\t\t}\n\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\tcreatedTags, err := createTags(ctx, tagWriter, missingTags)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error creating tags: %v\", err)\n\t\t\t}\n\n\t\t\ttags = append(tags, createdTags...)\n\t\t}\n\n\t\t// ignore if MissingRefBehaviour set to Ignore\n\t}\n\n\treturn tags, nil\n}\n\nfunc createTags(ctx context.Context, tagWriter models.TagCreator, names []string) ([]*models.Tag, error) {\n\tvar ret []*models.Tag\n\tfor _, name := range names {\n\t\tnewTag := models.NewTag()\n\t\tnewTag.Name = name\n\n\t\terr := tagWriter.Create(ctx, &models.CreateTagInput{\n\t\t\tTag: &newTag,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newTag)\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/image/import_test.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nvar (\n\texistingStudioID    = 101\n\texistingPerformerID = 103\n\texistingTagID       = 105\n\n\texistingStudioName = \"existingStudioName\"\n\texistingStudioErr  = \"existingStudioErr\"\n\tmissingStudioName  = \"missingStudioName\"\n\n\texistingPerformerName = \"existingPerformerName\"\n\texistingPerformerErr  = \"existingPerformerErr\"\n\tmissingPerformerName  = \"missingPerformerName\"\n\n\texistingTagName = \"existingTagName\"\n\texistingTagErr  = \"existingTagErr\"\n\tmissingTagName  = \"missingTagName\"\n)\n\nvar testCtx = context.Background()\n\nfunc TestImporterPreImport(t *testing.T) {\n\ti := Importer{}\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n}\n\nfunc TestImporterPreImportWithStudio(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Image{\n\t\t\tStudio:       existingStudioName,\n\t\t\tCustomFields: customFields,\n\t\t},\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioName, false).Return(&models.Studio{\n\t\tID: existingStudioID,\n\t}, nil).Once()\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioErr, false).Return(nil, errors.New(\"FindByName error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.image.StudioID)\n\tassert.Equal(t, customFields, i.customFields)\n\n\ti.Input.Studio = existingStudioErr\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingStudio(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Image{\n\t\t\tStudio: missingStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingStudioName, false).Return(nil, nil).Times(3)\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Run(func(args mock.Arguments) {\n\t\ts := args.Get(1).(*models.CreateStudioInput)\n\t\ts.Studio.ID = existingStudioID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.image.StudioID)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Image{\n\t\t\tStudio: missingStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingStudioName, false).Return(nil, nil).Once()\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithPerformer(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter:     db.Performer,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Image{\n\t\t\tPerformers: []string{\n\t\t\t\texistingPerformerName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{\n\t\t{\n\t\t\tID:   existingPerformerID,\n\t\t\tName: existingPerformerName,\n\t\t},\n\t}, nil).Once()\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingPerformerID}, i.image.PerformerIDs.List())\n\n\ti.Input.Performers = []string{existingPerformerErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingPerformer(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter: db.Performer,\n\t\tInput: jsonschema.Image{\n\t\t\tPerformers: []string{\n\t\t\t\tmissingPerformerName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3)\n\tdb.Performer.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreatePerformerInput\")).Run(func(args mock.Arguments) {\n\t\tperformer := args.Get(1).(*models.CreatePerformerInput)\n\t\tperformer.ID = existingPerformerID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingPerformerID}, i.image.PerformerIDs.List())\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter: db.Performer,\n\t\tInput: jsonschema.Image{\n\t\t\tPerformers: []string{\n\t\t\t\tmissingPerformerName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once()\n\tdb.Performer.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreatePerformerInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter:           db.Tag,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Image{\n\t\t\tTags: []string{\n\t\t\t\texistingTagName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagName}, false).Return([]*models.Tag{\n\t\t{\n\t\t\tID:   existingTagID,\n\t\t\tName: existingTagName,\n\t\t},\n\t}, nil).Once()\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingTagID}, i.image.TagIDs.List())\n\n\ti.Input.Tags = []string{existingTagErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter: db.Tag,\n\t\tInput: jsonschema.Image{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Run(func(args mock.Arguments) {\n\t\tt := args.Get(1).(*models.CreateTagInput)\n\t\tt.Tag.ID = existingTagID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingTagID}, i.image.TagIDs.List())\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter: db.Tag,\n\t\tInput: jsonschema.Image{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/image/query.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype Queryer interface {\n\tQuery(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error)\n}\n\ntype CoverQueryer interface {\n\tQueryer\n\tCoverByGalleryID(ctx context.Context, galleryId int) (*models.Image, error)\n}\n\ntype QueryCounter interface {\n\tQueryCount(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error)\n}\n\n// QueryOptions returns a ImageQueryResult populated with the provided filters.\nfunc QueryOptions(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType, count bool) models.ImageQueryOptions {\n\treturn models.ImageQueryOptions{\n\t\tQueryOptions: models.QueryOptions{\n\t\t\tFindFilter: findFilter,\n\t\t\tCount:      count,\n\t\t},\n\t\tImageFilter: imageFilter,\n\t}\n}\n\n// Query queries for images using the provided filters.\nfunc Query(ctx context.Context, qb Queryer, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, error) {\n\tresult, err := qb.Query(ctx, QueryOptions(imageFilter, findFilter, false))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\timages, err := result.Resolve(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn images, nil\n}\n\n// FilterFromPaths creates a ImageFilterType that filters using the provided\n// paths.\nfunc FilterFromPaths(paths []string) *models.ImageFilterType {\n\tret := &models.ImageFilterType{}\n\tor := ret\n\tsep := string(filepath.Separator)\n\n\tfor _, p := range paths {\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tif ret.Path == nil {\n\t\t\tor = ret\n\t\t} else {\n\t\t\tnewOr := &models.ImageFilterType{}\n\t\t\tor.Or = newOr\n\t\t\tor = newOr\n\t\t}\n\n\t\tor.Path = &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc CountByPerformerID(ctx context.Context, r QueryCounter, id int) (int, error) {\n\tfilter := &models.ImageFilterType{\n\t\tPerformers: &models.MultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByStudioID(ctx context.Context, r QueryCounter, id int, depth *int) (int, error) {\n\tfilter := &models.ImageFilterType{\n\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByTagID(ctx context.Context, r QueryCounter, id int, depth *int) (int, error) {\n\tfilter := &models.ImageFilterType{\n\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc FindByGalleryID(ctx context.Context, r Queryer, galleryID int, sortBy string, sortDir models.SortDirectionEnum) ([]*models.Image, error) {\n\tperPage := -1\n\n\tfindFilter := models.FindFilterType{\n\t\tPerPage: &perPage,\n\t}\n\n\tif sortBy != \"\" {\n\t\tfindFilter.Sort = &sortBy\n\t}\n\n\tif sortDir.IsValid() {\n\t\tfindFilter.Direction = &sortDir\n\t}\n\n\treturn Query(ctx, r, &models.ImageFilterType{\n\t\tGalleries: &models.MultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(galleryID)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t},\n\t}, &findFilter)\n}\n\nfunc FindGalleryCover(ctx context.Context, r CoverQueryer, galleryID int, galleryCoverRegex string) (*models.Image, error) {\n\tconst useCoverJpg = true\n\timg, err := findGalleryCover(ctx, r, galleryID, useCoverJpg, galleryCoverRegex)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif img != nil {\n\t\treturn img, nil\n\t}\n\n\t// return the first image in the gallery\n\treturn findGalleryCover(ctx, r, galleryID, !useCoverJpg, galleryCoverRegex)\n}\n\nfunc findGalleryCover(ctx context.Context, r CoverQueryer, galleryID int, useCoverJpg bool, galleryCoverRegex string) (*models.Image, error) {\n\timg, err := r.CoverByGalleryID(ctx, galleryID)\n\tif err != nil {\n\t\treturn nil, err\n\t} else if img != nil {\n\t\treturn img, nil\n\t}\n\n\t// try to find cover.jpg in the gallery\n\tperPage := 1\n\tsortBy := \"path\"\n\tsortDir := models.SortDirectionEnumAsc\n\n\tfindFilter := models.FindFilterType{\n\t\tPerPage:   &perPage,\n\t\tSort:      &sortBy,\n\t\tDirection: &sortDir,\n\t}\n\n\timageFilter := &models.ImageFilterType{\n\t\tGalleries: &models.MultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(galleryID)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t},\n\t}\n\n\tif useCoverJpg {\n\t\timageFilter.Path = &models.StringCriterionInput{\n\t\t\tValue:    \"(?i)\" + galleryCoverRegex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t}\n\t}\n\n\timgs, err := Query(ctx, r, imageFilter, &findFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(imgs) > 0 {\n\t\treturn imgs[0], nil\n\t}\n\n\treturn nil, nil\n}\n"
  },
  {
    "path": "pkg/image/scan.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\nvar (\n\tErrNotImageFile = errors.New(\"not an image file\")\n)\n\ntype ScanCreatorUpdater interface {\n\tFindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error)\n\tFindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Image, error)\n\tFindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Image, error)\n\tGetFiles(ctx context.Context, relatedID int) ([]models.File, error)\n\tGetGalleryIDs(ctx context.Context, relatedID int) ([]int, error)\n\n\tCreate(ctx context.Context, newImage *models.CreateImageInput) error\n\tUpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error)\n\tAddFileID(ctx context.Context, id int, fileID models.FileID) error\n}\n\ntype GalleryFinderCreator interface {\n\tFindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error)\n\tFindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error)\n\tmodels.GalleryCreator\n\tUpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error)\n}\n\ntype ScanSceneFinderUpdater interface {\n\tFindByPath(ctx context.Context, p string) ([]*models.Scene, error)\n\tAddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error\n}\n\ntype ScanConfig interface {\n\tGetCreateGalleriesFromFolders() bool\n}\n\ntype ScanGenerator interface {\n\tGenerate(ctx context.Context, i *models.Image, f models.File) error\n}\n\ntype ScanHandler struct {\n\tCreatorUpdater     ScanCreatorUpdater\n\tGalleryFinder      GalleryFinderCreator\n\tSceneFinderUpdater ScanSceneFinderUpdater\n\n\tScanGenerator ScanGenerator\n\n\tScanConfig ScanConfig\n\n\tPluginCache *plugin.Cache\n\n\tPaths *paths.Paths\n}\n\nfunc (h *ScanHandler) validate() error {\n\tif h.CreatorUpdater == nil {\n\t\treturn errors.New(\"CreatorUpdater is required\")\n\t}\n\tif h.ScanGenerator == nil {\n\t\treturn errors.New(\"ScanGenerator is required\")\n\t}\n\tif h.GalleryFinder == nil {\n\t\treturn errors.New(\"GalleryFinder is required\")\n\t}\n\tif h.ScanConfig == nil {\n\t\treturn errors.New(\"ScanConfig is required\")\n\t}\n\tif h.Paths == nil {\n\t\treturn errors.New(\"Paths is required\")\n\t}\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error {\n\tif err := h.validate(); err != nil {\n\t\treturn err\n\t}\n\n\timageFile := f.Base()\n\n\t// try to match the file to an image\n\texisting, err := h.CreatorUpdater.FindByFileID(ctx, imageFile.ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding existing image: %w\", err)\n\t}\n\n\tif len(existing) == 0 {\n\t\t// try also to match file by fingerprints\n\t\texisting, err = h.CreatorUpdater.FindByFingerprints(ctx, imageFile.Fingerprints)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding existing image by fingerprints: %w\", err)\n\t\t}\n\t}\n\n\tif len(existing) > 0 {\n\t\tupdateExisting := oldFile != nil\n\n\t\tif err := h.associateExisting(ctx, existing, imageFile, updateExisting); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t// create a new image\n\t\tnewImage := models.NewImage()\n\t\tnewImage.GalleryIDs = models.NewRelatedIDs([]int{})\n\n\t\tlogger.Infof(\"%s doesn't exist. Creating new image...\", f.Base().Path)\n\n\t\tg, err := h.getGalleryToAssociate(ctx, &newImage, f)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif g != nil {\n\t\t\tnewImage.GalleryIDs.Add(g.ID)\n\t\t\tlogger.Infof(\"Adding %s to gallery %s\", f.Base().Path, g.Path)\n\t\t}\n\n\t\tif err := h.CreatorUpdater.Create(ctx, &models.CreateImageInput{\n\t\t\tImage:   &newImage,\n\t\t\tFileIDs: []models.FileID{imageFile.ID},\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"creating new image: %w\", err)\n\t\t}\n\n\t\t// update the gallery updated at timestamp if applicable\n\t\tif g != nil {\n\t\t\tgalleryPartial := models.GalleryPartial{\n\t\t\t\tUpdatedAt: models.NewOptionalTime(newImage.UpdatedAt),\n\t\t\t}\n\t\t\tif _, err := h.GalleryFinder.UpdatePartial(ctx, g.ID, galleryPartial); err != nil {\n\t\t\t\treturn fmt.Errorf(\"updating gallery updated at timestamp: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\th.PluginCache.RegisterPostHooks(ctx, newImage.ID, hook.ImageCreatePost, nil, nil)\n\n\t\texisting = []*models.Image{&newImage}\n\t}\n\n\t// remove the old thumbnail if the checksum changed - we'll regenerate it\n\tif oldFile != nil {\n\t\toldHash := oldFile.Base().Fingerprints.GetString(models.FingerprintTypeMD5)\n\t\tnewHash := f.Base().Fingerprints.GetString(models.FingerprintTypeMD5)\n\n\t\tif oldHash != \"\" && newHash != \"\" && oldHash != newHash {\n\t\t\t// remove cache dir of gallery\n\t\t\t_ = os.Remove(h.Paths.Generated.GetThumbnailPath(oldHash, models.DefaultGthumbWidth))\n\t\t}\n\t}\n\n\t// do this after the commit so that generation doesn't hold up the transaction\n\ttxn.AddPostCommitHook(ctx, func(ctx context.Context) {\n\t\tfor _, s := range existing {\n\t\t\tif err := h.ScanGenerator.Generate(ctx, s, f); err != nil {\n\t\t\t\t// just log if cover generation fails. We can try again on rescan\n\t\t\t\tlogger.Errorf(\"Error generating content for %s: %v\", imageFile.Path, err)\n\t\t\t}\n\t\t}\n\t})\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *models.BaseFile, updateExisting bool) error {\n\tfor _, i := range existing {\n\t\tif err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfound := false\n\t\tfor _, sf := range i.Files.List() {\n\t\t\tif sf.Base().ID == f.Base().ID {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// associate with gallery if applicable\n\t\tg, err := h.getGalleryToAssociate(ctx, i, f)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar galleryIDs *models.UpdateIDs\n\t\tchanged := false\n\t\tif g != nil {\n\t\t\tchanged = true\n\t\t\tgalleryIDs = &models.UpdateIDs{\n\t\t\t\tIDs:  []int{g.ID},\n\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\tlogger.Infof(\"Adding %s to image %s\", f.Path, i.DisplayName())\n\n\t\t\tif err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.ID); err != nil {\n\t\t\t\treturn fmt.Errorf(\"adding file to image: %w\", err)\n\t\t\t}\n\n\t\t\tchanged = true\n\t\t}\n\n\t\tif changed || updateExisting {\n\t\t\t// update updated_at time when file association or content changes\n\t\t\timagePartial := models.NewImagePartial()\n\t\t\timagePartial.GalleryIDs = galleryIDs\n\n\t\t\tif _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, imagePartial); err != nil {\n\t\t\t\treturn fmt.Errorf(\"updating image: %w\", err)\n\t\t\t}\n\n\t\t\tif g != nil {\n\t\t\t\tgalleryPartial := models.GalleryPartial{\n\t\t\t\t\t// set UpdatedAt directly instead of using NewGalleryPartial, to ensure\n\t\t\t\t\t// that the linked gallery has the same UpdatedAt time as this image\n\t\t\t\t\tUpdatedAt: imagePartial.UpdatedAt,\n\t\t\t\t}\n\t\t\t\tif _, err := h.GalleryFinder.UpdatePartial(ctx, g.ID, galleryPartial); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"updating gallery updated at timestamp: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\th.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageUpdatePost, nil, nil)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f models.File) (*models.Gallery, error) {\n\tfolderID := f.Base().ParentFolderID\n\tg, err := h.GalleryFinder.FindByFolderID(ctx, folderID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"finding folder based gallery: %w\", err)\n\t}\n\n\tif len(g) > 0 {\n\t\tgg := g[0]\n\t\treturn gg, nil\n\t}\n\n\t// create a new folder-based gallery\n\tnewGallery := models.NewGallery()\n\tnewGallery.FolderID = &folderID\n\n\tinput := models.CreateGalleryInput{\n\t\tGallery: &newGallery,\n\t}\n\n\tlogger.Infof(\"Creating folder-based gallery for %s\", filepath.Dir(f.Base().Path))\n\n\tif err := h.GalleryFinder.Create(ctx, &input); err != nil {\n\t\treturn nil, fmt.Errorf(\"creating folder based gallery: %w\", err)\n\t}\n\n\th.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil)\n\n\t// it's possible that there are other images in the folder that\n\t// need to be added to the new gallery. Find and add them now.\n\tif err := h.associateFolderImages(ctx, &newGallery); err != nil {\n\t\treturn nil, fmt.Errorf(\"associating existing folder images: %w\", err)\n\t}\n\n\treturn &newGallery, nil\n}\n\nfunc (h *ScanHandler) associateFolderImages(ctx context.Context, g *models.Gallery) error {\n\ti, err := h.CreatorUpdater.FindByFolderID(ctx, *g.FolderID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding images in folder: %w\", err)\n\t}\n\n\tfor _, ii := range i {\n\t\tlogger.Infof(\"Adding %s to gallery %s\", ii.Path, g.Path)\n\n\t\timagePartial := models.NewImagePartial()\n\t\timagePartial.GalleryIDs = &models.UpdateIDs{\n\t\t\tIDs:  []int{g.ID},\n\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t}\n\n\t\tif _, err := h.CreatorUpdater.UpdatePartial(ctx, ii.ID, imagePartial); err != nil {\n\t\t\treturn fmt.Errorf(\"updating image: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile models.File) (*models.Gallery, error) {\n\tg, err := h.GalleryFinder.FindByFileID(ctx, zipFile.Base().ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"finding zip based gallery: %w\", err)\n\t}\n\n\tif len(g) > 0 {\n\t\tgg := g[0]\n\t\treturn gg, nil\n\t}\n\n\t// create a new zip-based gallery\n\tnewGallery := models.NewGallery()\n\n\tlogger.Infof(\"%s doesn't exist. Creating new gallery...\", zipFile.Base().Path)\n\n\tinput := models.CreateGalleryInput{\n\t\tGallery: &newGallery,\n\t\tFileIDs: []models.FileID{zipFile.Base().ID},\n\t}\n\n\tif err := h.GalleryFinder.Create(ctx, &input); err != nil {\n\t\treturn nil, fmt.Errorf(\"creating zip-based gallery: %w\", err)\n\t}\n\n\t// try to associate with scene\n\tif err := h.associateScene(ctx, &newGallery, zipFile); err != nil {\n\t\treturn nil, fmt.Errorf(\"associating scene: %w\", err)\n\t}\n\n\th.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil)\n\n\treturn &newGallery, nil\n}\n\nfunc (h *ScanHandler) associateScene(ctx context.Context, existing *models.Gallery, zipFile models.File) error {\n\tgalleryIDs := []int{existing.ID}\n\n\tpath := zipFile.Base().Path\n\twithoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + \".*\"\n\n\t// find scenes with a file that matches\n\tscenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, scene := range scenes {\n\t\t// found related Scene\n\t\tlogger.Infof(\"associate: Gallery %s is related to scene: %d\", path, scene.ID)\n\t\tif err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*models.Gallery, error) {\n\t// don't create folder-based galleries for files in zip file\n\tif f.Base().ZipFile != nil {\n\t\treturn h.getOrCreateZipBasedGallery(ctx, f.Base().ZipFile)\n\t}\n\n\t// Look for specific filename in Folder to find out if the Folder is marked to be handled differently as the setting\n\tfolderPath := filepath.Dir(f.Base().Path)\n\n\tforceGallery := false\n\tif _, err := os.Stat(filepath.Join(folderPath, \".forcegallery\")); err == nil {\n\t\tforceGallery = true\n\t} else if !errors.Is(err, os.ErrNotExist) {\n\t\treturn nil, fmt.Errorf(\"Could not test Path %s: %w\", folderPath, err)\n\t}\n\texemptGallery := false\n\tif _, err := os.Stat(filepath.Join(folderPath, \".nogallery\")); err == nil {\n\t\texemptGallery = true\n\t} else if !errors.Is(err, os.ErrNotExist) {\n\t\treturn nil, fmt.Errorf(\"Could not test Path %s: %w\", folderPath, err)\n\t}\n\n\tif forceGallery || (h.ScanConfig.GetCreateGalleriesFromFolders() && !exemptGallery) {\n\t\treturn h.getOrCreateFolderBasedGallery(ctx, f)\n\t}\n\n\treturn nil, nil\n}\n\nfunc (h *ScanHandler) getGalleryToAssociate(ctx context.Context, newImage *models.Image, f models.File) (*models.Gallery, error) {\n\tg, err := h.getOrCreateGallery(ctx, f)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := newImage.LoadGalleryIDs(ctx, h.CreatorUpdater); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif g != nil && !slices.Contains(newImage.GalleryIDs.List(), g.ID) {\n\t\treturn g, nil\n\t}\n\n\treturn nil, nil\n}\n"
  },
  {
    "path": "pkg/image/scan_test.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\ntype mockScanConfig struct{}\n\nfunc (m *mockScanConfig) GetCreateGalleriesFromFolders() bool { return false }\n\nfunc TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {\n\tconst (\n\t\ttestImageID = 1\n\t\ttestFileID  = 100\n\t)\n\n\texistingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: \"/images/test.jpg\"}\n\n\tmakeImage := func() *models.Image {\n\t\treturn &models.Image{\n\t\t\tID:         testImageID,\n\t\t\tFiles:      models.NewRelatedFiles([]models.File{existingFile}),\n\t\t\tGalleryIDs: models.NewRelatedIDs([]int{}),\n\t\t}\n\t}\n\n\ttests := []struct {\n\t\tname           string\n\t\tupdateExisting bool\n\t\texpectUpdate   bool\n\t}{\n\t\t{\n\t\t\tname:           \"calls UpdatePartial when file content changed\",\n\t\t\tupdateExisting: true,\n\t\t\texpectUpdate:   true,\n\t\t},\n\t\t{\n\t\t\tname:           \"skips UpdatePartial when file unchanged and already associated\",\n\t\t\tupdateExisting: false,\n\t\t\texpectUpdate:   false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdb := mocks.NewDatabase()\n\t\t\tdb.Image.On(\"GetFiles\", mock.Anything, testImageID).Return([]models.File{existingFile}, nil)\n\t\t\tdb.Image.On(\"GetGalleryIDs\", mock.Anything, testImageID).Return([]int{}, nil)\n\n\t\t\tif tt.expectUpdate {\n\t\t\t\tdb.Image.On(\"UpdatePartial\", mock.Anything, testImageID, mock.Anything).\n\t\t\t\t\tReturn(&models.Image{ID: testImageID}, nil)\n\t\t\t}\n\n\t\t\th := &ScanHandler{\n\t\t\t\tCreatorUpdater: db.Image,\n\t\t\t\tGalleryFinder:  db.Gallery,\n\t\t\t\tScanConfig:     &mockScanConfig{},\n\t\t\t\tPluginCache:    &plugin.Cache{},\n\t\t\t}\n\n\t\t\tdb.WithTxnCtx(func(ctx context.Context) {\n\t\t\t\terr := h.associateExisting(ctx, []*models.Image{makeImage()}, existingFile, tt.updateExisting)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t})\n\n\t\t\tif tt.expectUpdate {\n\t\t\t\tdb.Image.AssertCalled(t, \"UpdatePartial\", mock.Anything, testImageID, mock.Anything)\n\t\t\t} else {\n\t\t\t\tdb.Image.AssertNotCalled(t, \"UpdatePartial\", mock.Anything, mock.Anything, mock.Anything)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {\n\tconst (\n\t\ttestImageID = 1\n\t\texistFileID = 100\n\t\tnewFileID   = 200\n\t)\n\n\texistingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: \"/images/existing.jpg\"}\n\tnewFile := &models.BaseFile{ID: models.FileID(newFileID), Path: \"/images/new.jpg\"}\n\n\timage := &models.Image{\n\t\tID:         testImageID,\n\t\tFiles:      models.NewRelatedFiles([]models.File{existingFile}),\n\t\tGalleryIDs: models.NewRelatedIDs([]int{}),\n\t}\n\n\tdb := mocks.NewDatabase()\n\tdb.Image.On(\"GetFiles\", mock.Anything, testImageID).Return([]models.File{existingFile}, nil)\n\tdb.Image.On(\"GetGalleryIDs\", mock.Anything, testImageID).Return([]int{}, nil)\n\tdb.Image.On(\"AddFileID\", mock.Anything, testImageID, models.FileID(newFileID)).Return(nil)\n\tdb.Image.On(\"UpdatePartial\", mock.Anything, testImageID, mock.Anything).\n\t\tReturn(&models.Image{ID: testImageID}, nil)\n\n\th := &ScanHandler{\n\t\tCreatorUpdater: db.Image,\n\t\tGalleryFinder:  db.Gallery,\n\t\tScanConfig:     &mockScanConfig{},\n\t\tPluginCache:    &plugin.Cache{},\n\t}\n\n\tdb.WithTxnCtx(func(ctx context.Context) {\n\t\terr := h.associateExisting(ctx, []*models.Image{image}, newFile, false)\n\t\tassert.NoError(t, err)\n\t})\n\n\tdb.Image.AssertCalled(t, \"AddFileID\", mock.Anything, testImageID, models.FileID(newFileID))\n\tdb.Image.AssertCalled(t, \"UpdatePartial\", mock.Anything, testImageID, mock.Anything)\n}\n"
  },
  {
    "path": "pkg/image/service.go",
    "content": "// Package image provides the application logic for images.\n// The functionality is exposed via the [Service] type.\npackage image\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype Service struct {\n\tFile       models.FileReaderWriter\n\tRepository models.ImageReaderWriter\n}\n"
  },
  {
    "path": "pkg/image/thumbnail.go",
    "content": "package image\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"sync\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg/transcoder\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst ffmpegImageQuality = 5\n\nvar vipsPath string\nvar once sync.Once\n\n// ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation\nvar ErrNotSupportedForThumbnail = errors.New(\"unsupported image format for thumbnail\")\n\ntype ThumbnailEncoder struct {\n\tFFMpeg             *ffmpeg.FFMpeg\n\tFFProbe            *ffmpeg.FFProbe\n\tClipPreviewOptions ClipPreviewOptions\n\tvips               *vipsEncoder\n}\n\ntype ClipPreviewOptions struct {\n\tInputArgs  []string\n\tOutputArgs []string\n\tPreset     string\n}\n\nfunc GetVipsPath() string {\n\tonce.Do(func() {\n\t\tvipsPath, _ = exec.LookPath(\"vips\")\n\t})\n\treturn vipsPath\n}\n\nfunc NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe *ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder {\n\tret := ThumbnailEncoder{\n\t\tFFMpeg:             ffmpegEncoder,\n\t\tFFProbe:            ffProbe,\n\t\tClipPreviewOptions: clipPreviewOptions,\n\t}\n\n\tvipsPath := GetVipsPath()\n\tif vipsPath != \"\" {\n\t\tvipsEncoder := vipsEncoder(vipsPath)\n\t\tret.vips = &vipsEncoder\n\t}\n\n\treturn ret\n}\n\n// GetThumbnail returns the thumbnail image of the provided image resized to\n// the provided max size. It resizes based on the largest X/Y direction.\n// It returns nil and an error if an error occurs reading, decoding or encoding\n// the image, or if the image is not suitable for thumbnails.\nfunc (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, error) {\n\treader, err := f.Open(&file.OsFS{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer reader.Close()\n\n\tbuf := new(bytes.Buffer)\n\tif _, err := buf.ReadFrom(reader); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdata := buf.Bytes()\n\n\tformat := \"\"\n\tif imageFile, ok := f.(*models.ImageFile); ok {\n\t\tformat = imageFile.Format\n\t\tanimated := imageFile.Format == formatGif\n\n\t\t// #2266 - if image is webp, then determine if it is animated\n\t\tif format == formatWebP {\n\t\t\tanimated = isWebPAnimated(data)\n\t\t}\n\n\t\t// #2266 - don't generate a thumbnail for animated images\n\t\tif animated {\n\t\t\treturn nil, fmt.Errorf(\"%w: %s\", ErrNotSupportedForThumbnail, format)\n\t\t}\n\n\t\t// AVIF cannot be read from stdin, must use file path\n\t\t// AVIF in zip files is not supported\n\t\t// Note: No Windows check needed here since we use file path, not stdin\n\t\tif format == \"avif\" {\n\t\t\tif f.Base().ZipFileID != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"%w: AVIF in zip file\", ErrNotSupportedForThumbnail)\n\t\t\t}\n\t\t\tif e.vips != nil {\n\t\t\t\treturn e.vips.ImageThumbnailPath(f.Base().Path, maxSize)\n\t\t\t}\n\t\t\treturn e.ffmpegImageThumbnailPath(f.Base().Path, maxSize)\n\t\t}\n\t}\n\n\t// Videofiles can only be thumbnailed with ffmpeg\n\tif _, ok := f.(*models.VideoFile); ok {\n\t\treturn e.ffmpegImageThumbnail(buf, maxSize)\n\t}\n\n\t// vips has issues loading files from stdin on Windows\n\tif e.vips != nil {\n\t\tif runtime.GOOS == \"windows\" && f.Base().ZipFileID == nil {\n\t\t\treturn e.vips.ImageThumbnailPath(f.Base().Path, maxSize)\n\t\t}\n\t\tif runtime.GOOS != \"windows\" {\n\t\t\treturn e.vips.ImageThumbnail(buf, maxSize)\n\t\t}\n\t}\n\treturn e.ffmpegImageThumbnail(buf, maxSize)\n}\n\n// GetPreview returns the preview clip of the provided image clip resized to\n// the provided max size. It resizes based on the largest X/Y direction.\n// It is hardcoded to 30 seconds maximum right now\nfunc (e *ThumbnailEncoder) GetPreview(inPath string, outPath string, maxSize int) error {\n\tfileData, err := e.FFProbe.NewVideoFile(inPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fileData.Width <= maxSize {\n\t\tmaxSize = fileData.Width\n\t}\n\tclipDuration := fileData.VideoStreamDuration\n\tif clipDuration > 30.0 {\n\t\tclipDuration = 30.0\n\t}\n\treturn e.getClipPreview(inPath, outPath, maxSize, clipDuration, fileData.FrameRate)\n}\n\nfunc (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) {\n\toptions := transcoder.ImageThumbnailOptions{\n\t\tOutputFormat:  ffmpeg.ImageFormatJpeg,\n\t\tOutputPath:    \"-\",\n\t\tMaxDimensions: maxSize,\n\t\tQuality:       ffmpegImageQuality,\n\t}\n\n\targs := transcoder.ImageThumbnail(\"-\", options)\n\n\treturn e.FFMpeg.GenerateOutput(context.TODO(), args, image)\n}\n\n// ffmpegImageThumbnailPath generates a thumbnail from a file path (used for AVIF which can't be piped)\nfunc (e *ThumbnailEncoder) ffmpegImageThumbnailPath(inputPath string, maxSize int) ([]byte, error) {\n\toptions := transcoder.ImageThumbnailOptions{\n\t\tOutputFormat:  ffmpeg.ImageFormatJpeg,\n\t\tOutputPath:    \"-\",\n\t\tMaxDimensions: maxSize,\n\t\tQuality:       ffmpegImageQuality,\n\t}\n\n\targs := transcoder.ImageThumbnail(inputPath, options)\n\n\treturn e.FFMpeg.GenerateOutput(context.TODO(), args, nil)\n}\n\nfunc (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize int, clipDuration float64, frameRate float64) error {\n\tvar thumbFilter ffmpeg.VideoFilter\n\tthumbFilter = thumbFilter.ScaleMaxSize(maxSize)\n\n\tvar thumbArgs ffmpeg.Args\n\tthumbArgs = thumbArgs.VideoFilter(thumbFilter)\n\n\to := e.ClipPreviewOptions\n\n\tthumbArgs = append(thumbArgs,\n\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\"-preset\", o.Preset,\n\t\t\"-crf\", \"25\",\n\t\t\"-threads\", \"4\",\n\t\t\"-strict\", \"-2\",\n\t\t\"-f\", \"webm\",\n\t)\n\n\tif frameRate <= 0.01 {\n\t\tthumbArgs = append(thumbArgs, \"-vsync\", \"2\")\n\t}\n\n\tthumbOptions := transcoder.TranscodeOptions{\n\t\tOutputPath: outPath,\n\t\tStartTime:  0,\n\t\tDuration:   clipDuration,\n\n\t\tXError:   true,\n\t\tSlowSeek: false,\n\n\t\tVideoCodec: ffmpeg.VideoCodecVP9,\n\t\tVideoArgs:  thumbArgs,\n\n\t\tExtraInputArgs:  o.InputArgs,\n\t\tExtraOutputArgs: o.OutputArgs,\n\t}\n\n\tif err := fsutil.EnsureDirAll(filepath.Dir(outPath)); err != nil {\n\t\treturn err\n\t}\n\targs := transcoder.Transcode(inPath, thumbOptions)\n\treturn e.FFMpeg.Generate(context.TODO(), args)\n}\n"
  },
  {
    "path": "pkg/image/update.go",
    "content": "package image\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc AddPerformer(ctx context.Context, qb models.ImageUpdater, i *models.Image, performerID int) error {\n\timagePartial := models.NewImagePartial()\n\timagePartial.PerformerIDs = &models.UpdateIDs{\n\t\tIDs:  []int{performerID},\n\t\tMode: models.RelationshipUpdateModeAdd,\n\t}\n\t_, err := qb.UpdatePartial(ctx, i.ID, imagePartial)\n\treturn err\n}\n\nfunc AddTag(ctx context.Context, qb models.ImageUpdater, i *models.Image, tagID int) error {\n\timagePartial := models.NewImagePartial()\n\timagePartial.TagIDs = &models.UpdateIDs{\n\t\tIDs:  []int{tagID},\n\t\tMode: models.RelationshipUpdateModeAdd,\n\t}\n\t_, err := qb.UpdatePartial(ctx, i.ID, imagePartial)\n\treturn err\n}\n"
  },
  {
    "path": "pkg/image/vips.go",
    "content": "package image\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/exec\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype vipsEncoder string\n\nfunc (e *vipsEncoder) ImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) {\n\targs := []string{\n\t\t\"thumbnail_source\",\n\t\t\"[descriptor=0]\",\n\t\t\".jpg[Q=70,strip]\",\n\t\tfmt.Sprint(maxSize),\n\t\t\"--size\", \"down\",\n\t}\n\tdata, err := e.run(args, image)\n\n\treturn []byte(data), err\n}\n\n// ImageThumbnailPath generates a thumbnail from a file path instead of stdin.\n// This is required for formats like AVIF that need random file access (seeking)\n// which stdin cannot provide.\nfunc (e *vipsEncoder) ImageThumbnailPath(path string, maxSize int) ([]byte, error) {\n\t// vips thumbnail syntax: thumbnail input output width [options]\n\t// Using .jpg[Q=70,strip] as output writes to stdout\n\targs := []string{\n\t\t\"thumbnail\",\n\t\tpath,\n\t\t\".jpg[Q=70,strip]\",\n\t\tfmt.Sprint(maxSize),\n\t\t\"--size\", \"down\",\n\t}\n\n\tcmd := exec.Command(string(*e), args...)\n\n\tvar stdout, stderr bytes.Buffer\n\tcmd.Stdout = &stdout\n\tcmd.Stderr = &stderr\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := cmd.Wait(); err != nil {\n\t\tlogger.Errorf(\"image encoder error when running command <%s>: %s\", strings.Join(cmd.Args, \" \"), stderr.String())\n\t\treturn nil, err\n\t}\n\n\treturn stdout.Bytes(), nil\n}\n\nfunc (e *vipsEncoder) run(args []string, stdin *bytes.Buffer) (string, error) {\n\tcmd := exec.Command(string(*e), args...)\n\n\tvar stdout, stderr bytes.Buffer\n\tcmd.Stdout = &stdout\n\tcmd.Stderr = &stderr\n\tcmd.Stdin = stdin\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\terr := cmd.Wait()\n\n\tif err != nil {\n\t\t// error message should be in the stderr stream\n\t\tlogger.Errorf(\"image encoder error when running command <%s>: %s\", strings.Join(cmd.Args, \" \"), stderr.String())\n\t\treturn stdout.String(), err\n\t}\n\n\treturn stdout.String(), nil\n}\n"
  },
  {
    "path": "pkg/image/webp.go",
    "content": "package image\n\nimport (\n\t\"bytes\"\n)\n\nconst (\n\tformatWebP = \"webp\"\n\tformatGif  = \"gif\"\n)\n\n// https://developers.google.com/speed/webp/docs/riff_container\nfunc isWebPAnimated(buf []byte) bool {\n\tconst (\n\t\twebPHeaderStart = 8\n\t\twebPHeaderEnd   = 12\n\t\twebPHeader      = \"WEBP\"\n\n\t\tanimationHeaderLoc    = 16\n\t\tminAnimSignatureIndex = 20\n\n\t\tmaxSize = 48\n\t)\n\n\t// truncate the buffer to the max size\n\tif len(buf) > maxSize {\n\t\tbuf = buf[:maxSize]\n\t}\n\n\tisWebp := len(buf) >= webPHeaderEnd && string(buf[webPHeaderStart:webPHeaderEnd]) == \"WEBP\" // is WEBP\n\n\tif isWebp {\n\t\tconst animBit byte = 1 << 1\n\t\tif len(buf) > minAnimSignatureIndex {\n\t\t\t// Animation Bit is set and ANIM header is present\n\t\t\treturn (buf[animationHeaderLoc]&animBit == animBit) && containsAnimSignature(buf[minAnimSignatureIndex:])\n\t\t}\n\t}\n\treturn false\n}\n\n// https://developers.google.com/speed/webp/docs/riff_container#animation\nfunc containsAnimSignature(buf []byte) bool {\n\tindex := bytes.Index(buf, []byte(\"ANIM\"))\n\treturn index != -1\n}\n"
  },
  {
    "path": "pkg/image/webp_internal_test.go",
    "content": "package image\n\nimport \"testing\"\n\nfunc Test_isWebPAnimated(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbuf  []byte\n\t\twant bool\n\t}{\n\t\t{\n\t\t\t\"basic animated\",\n\t\t\t[]byte{\n\t\t\t\t0x52, 0x49, 0x46, 0x46, 0xb2, 0x3a, 0x17, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58,\n\t\t\t\t0x0a, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x7f, 0x02, 0x00, 0x55, 0x01, 0x00, 0x41, 0x4e,\n\t\t\t\t0x49, 0x4d, 0x06, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x41, 0x4e, 0x4d, 0x46,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"static webp\",\n\t\t\t[]byte{\n\t\t\t\t0x52, 0x49, 0x46, 0x46, 0x68, 0x76, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20,\n\t\t\t\t0x5c, 0x76, 0x00, 0x00, 0xd2, 0xbe, 0x01, 0x9d, 0x01, 0x2a, 0x26, 0x02, 0x70, 0x01, 0x3e, 0xd5,\n\t\t\t\t0x4e, 0x97, 0x43, 0xa2, 0x06, 0x16, 0xd1, 0xb4, 0x88, 0x03, 0x51, 0x39, 0xb7, 0x13, 0x33, 0x75,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"false animated bit\",\n\t\t\t[]byte{\n\t\t\t\t0x52, 0x49, 0x46, 0x46, 0xb2, 0x3a, 0x17, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58,\n\t\t\t\t0x09, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x7f, 0x02, 0x00, 0x55, 0x01, 0x00, 0x41, 0x4e,\n\t\t\t\t0x49, 0x4d, 0x06, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x41, 0x4e, 0x4d, 0x46,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"ANIM out of range\",\n\t\t\t[]byte{\n\t\t\t\t0x52, 0x49, 0x46, 0x46, 0xb2, 0x3a, 0x17, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58,\n\t\t\t\t0x0a, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x7f, 0x02, 0x00, 0x55, 0x01, 0x00, 0x3e, 0xd5,\n\t\t\t\t0x4e, 0x97, 0x43, 0xa2, 0x06, 0x16, 0xd1, 0xb4, 0x88, 0x03, 0x51, 0x39, 0xb7, 0x13, 0x33, 0x75,\n\t\t\t\t0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not webp\",\n\t\t\t[]byte{\n\t\t\t\t0x52, 0x49, 0x46, 0x46, 0xb2, 0x3a, 0x17, 0x00, 0x58, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58,\n\t\t\t\t0x0a, 0x00, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00, 0x7f, 0x02, 0x00, 0x55, 0x01, 0x00, 0x3e, 0xd5,\n\t\t\t\t0x4e, 0x97, 0x43, 0xa2, 0x06, 0x16, 0xd1, 0xb4, 0x88, 0x03, 0x51, 0x39, 0xb7, 0x13, 0x33, 0x75,\n\t\t\t\t0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d, 0x41, 0x4e, 0x49, 0x4d,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := isWebPAnimated(tt.buf); got != tt.want {\n\t\t\t\tt.Errorf(\"isWebPAnimated() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/javascript/console.go",
    "content": "package javascript\n\nimport \"fmt\"\n\ntype console struct {\n\tLog\n}\n\nfunc (c *console) AddToVM(globalName string, vm *VM) error {\n\tconsole := vm.NewObject()\n\tif err := SetAll(console,\n\t\tObjectValueDef{\"log\", c.logInfo},\n\t\tObjectValueDef{\"error\", c.logError},\n\t\tObjectValueDef{\"warn\", c.logWarn},\n\t\tObjectValueDef{\"info\", c.logInfo},\n\t\tObjectValueDef{\"debug\", c.logDebug},\n\t); err != nil {\n\t\treturn err\n\t}\n\n\tif err := vm.Set(globalName, console); err != nil {\n\t\treturn fmt.Errorf(\"unable to set console: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/javascript/gql.go",
    "content": "package javascript\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/dop251/goja\"\n)\n\ntype responseWriter struct {\n\tr          strings.Builder\n\theader     http.Header\n\tstatusCode int\n}\n\nfunc (w *responseWriter) Header() http.Header {\n\treturn w.header\n}\n\nfunc (w *responseWriter) WriteHeader(statusCode int) {\n\tw.statusCode = statusCode\n}\n\nfunc (w *responseWriter) Write(b []byte) (int, error) {\n\treturn w.r.Write(b)\n}\n\ntype GQL struct {\n\tContext    context.Context\n\tCookie     *http.Cookie\n\tGQLHandler http.Handler\n}\n\nfunc (g *GQL) gqlRequestFunc(vm *VM) func(query string, variables map[string]interface{}) (goja.Value, error) {\n\treturn func(query string, variables map[string]interface{}) (goja.Value, error) {\n\t\tin := struct {\n\t\t\tQuery     string                 `json:\"query\"`\n\t\t\tVariables map[string]interface{} `json:\"variables,omitempty\"`\n\t\t}{\n\t\t\tQuery:     query,\n\t\t\tVariables: variables,\n\t\t}\n\n\t\tvar body bytes.Buffer\n\t\terr := json.NewEncoder(&body).Encode(in)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tr, err := http.NewRequestWithContext(g.Context, \"POST\", \"/graphql\", &body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"could not make request\")\n\t\t}\n\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tif g.Cookie != nil {\n\t\t\tr.AddCookie(g.Cookie)\n\t\t}\n\n\t\tw := &responseWriter{\n\t\t\theader: make(http.Header),\n\t\t}\n\n\t\tg.GQLHandler.ServeHTTP(w, r)\n\n\t\tif w.statusCode != http.StatusOK && w.statusCode != 0 {\n\t\t\tvm.Throw(fmt.Errorf(\"graphQL query failed: %d - %s. Query: %s. Variables: %v\", w.statusCode, w.r.String(), in.Query, in.Variables))\n\t\t}\n\n\t\toutput := w.r.String()\n\t\t// convert to JSON\n\t\tvar obj map[string]interface{}\n\t\tif err = json.Unmarshal([]byte(output), &obj); err != nil {\n\t\t\tvm.Throw(fmt.Errorf(\"could not unmarshal object %s: %s\", output, err.Error()))\n\t\t}\n\n\t\tretErr, hasErr := obj[\"errors\"]\n\n\t\tif hasErr {\n\t\t\terrOut, _ := json.Marshal(retErr)\n\t\t\tvm.Throw(fmt.Errorf(\"graphql error: %s\", string(errOut)))\n\t\t}\n\n\t\tv := vm.ToValue(obj[\"data\"])\n\n\t\treturn v, nil\n\t}\n}\n\nfunc (g *GQL) AddToVM(globalName string, vm *VM) error {\n\tgql := vm.NewObject()\n\n\tif err := gql.Set(\"Do\", g.gqlRequestFunc(vm)); err != nil {\n\t\treturn fmt.Errorf(\"unable to set GraphQL Do function: %w\", err)\n\t}\n\n\tif err := vm.Set(globalName, gql); err != nil {\n\t\treturn fmt.Errorf(\"unable to set gql: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/javascript/log.go",
    "content": "package javascript\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"math\"\n\t\"reflect\"\n\n\t\"github.com/dop251/goja\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// Log provides log wrappers for usable from the JS VM.\ntype Log struct {\n\t// Logger is the LoggerImpl to forward log messages to.\n\tLogger logger.LoggerImpl\n\t// Prefix is the prefix to prepend to log messages.\n\tPrefix string\n\t// ProgressChan is a channel that receives float64s indicating the current progress of an operation.\n\tProgressChan chan float64\n}\n\nfunc (l *Log) argToString(call goja.FunctionCall) string {\n\targ := call.Argument(0)\n\tvar o map[string]interface{}\n\tif arg.ExportType() == reflect.TypeOf(o) {\n\t\tii := arg.Export()\n\t\to = ii.(map[string]interface{})\n\t\tdata, err := json.Marshal(o)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Couldn't json encode object\")\n\t\t}\n\t\treturn string(data)\n\t}\n\n\treturn arg.String()\n}\n\nfunc (l *Log) logTrace(call goja.FunctionCall) goja.Value {\n\tl.Logger.Trace(l.Prefix, l.argToString(call))\n\treturn nil\n}\n\nfunc (l *Log) logDebug(call goja.FunctionCall) goja.Value {\n\tl.Logger.Debug(l.Prefix, l.argToString(call))\n\treturn nil\n}\n\nfunc (l *Log) logInfo(call goja.FunctionCall) goja.Value {\n\tl.Logger.Info(l.Prefix, l.argToString(call))\n\treturn nil\n}\n\nfunc (l *Log) logWarn(call goja.FunctionCall) goja.Value {\n\tl.Logger.Warn(l.Prefix, l.argToString(call))\n\treturn nil\n}\n\nfunc (l *Log) logError(call goja.FunctionCall) goja.Value {\n\tl.Logger.Error(l.Prefix, l.argToString(call))\n\treturn nil\n}\n\n// Progress logs the current progress value. The progress value should be\n// between 0 and 1.0 inclusively, with 1 representing that the task is\n// complete. Values outside of this range will be clamp to be within it.\nfunc (l *Log) logProgress(value float64) {\n\tvalue = math.Min(math.Max(0, value), 1)\n\tl.ProgressChan <- value\n}\n\nfunc (l *Log) AddToVM(globalName string, vm *VM) error {\n\tlog := vm.NewObject()\n\tif err := SetAll(log,\n\t\tObjectValueDef{\"Trace\", l.logTrace},\n\t\tObjectValueDef{\"Debug\", l.logDebug},\n\t\tObjectValueDef{\"Info\", l.logInfo},\n\t\tObjectValueDef{\"Warn\", l.logWarn},\n\t\tObjectValueDef{\"Error\", l.logError},\n\t\tObjectValueDef{\"Progress\", l.logProgress},\n\t); err != nil {\n\t\treturn err\n\t}\n\n\tif err := vm.Set(globalName, log); err != nil {\n\t\treturn fmt.Errorf(\"unable to set log: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/javascript/util.go",
    "content": "package javascript\n\nimport (\n\t\"fmt\"\n\t\"time\"\n)\n\ntype Util struct{}\n\nfunc (u *Util) sleepFunc(ms int64) {\n\ttime.Sleep(time.Millisecond * time.Duration(ms))\n}\n\nfunc (u *Util) AddToVM(globalName string, vm *VM) error {\n\tutil := vm.NewObject()\n\tif err := util.Set(\"Sleep\", u.sleepFunc); err != nil {\n\t\treturn fmt.Errorf(\"unable to set sleep func: %w\", err)\n\t}\n\n\tif err := vm.Set(globalName, util); err != nil {\n\t\treturn fmt.Errorf(\"unable to set util: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/javascript/vm.go",
    "content": "// Package javascript provides the javascript runtime for the application.\npackage javascript\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"reflect\"\n\n\t\"github.com/dop251/goja\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// VM is a wrapper around goja.Runtime.\ntype VM struct {\n\t*goja.Runtime\n}\n\n// optionalFieldNameMapper wraps a goja.FieldNameMapper and returns the field name if the wrapped mapper returns an empty string.\ntype optionalFieldNameMapper struct {\n\tmapper goja.FieldNameMapper\n}\n\nfunc (tfm optionalFieldNameMapper) FieldName(t reflect.Type, f reflect.StructField) string {\n\tif ret := tfm.mapper.FieldName(t, f); ret != \"\" {\n\t\treturn ret\n\t}\n\n\treturn f.Name\n}\n\nfunc (tfm optionalFieldNameMapper) MethodName(t reflect.Type, m reflect.Method) string {\n\treturn tfm.mapper.MethodName(t, m)\n}\n\nfunc NewVM() *VM {\n\tr := goja.New()\n\n\t// enable console for backwards compatibility\n\tc := console{\n\t\tLog{\n\t\t\tLogger: logger.Logger,\n\t\t},\n\t}\n\n\t// there should not be any reason for this to fail\n\t_ = c.AddToVM(\"console\", &VM{Runtime: r})\n\n\tr.SetFieldNameMapper(optionalFieldNameMapper{goja.TagFieldNameMapper(\"json\", true)})\n\treturn &VM{Runtime: r}\n}\n\ntype APIAdder interface {\n\tAddToVM(globalName string, vm *VM) error\n}\n\ntype ObjectValueDef struct {\n\tName  string\n\tValue interface{}\n}\n\ntype setter interface {\n\tSet(name string, value interface{}) error\n}\n\nfunc Compile(path string) (*goja.Program, error) {\n\tjs, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\treturn goja.Compile(path, string(js), true)\n}\n\nfunc CompileScript(name, script string) (*goja.Program, error) {\n\treturn goja.Compile(name, string(script), true)\n}\n\nfunc SetAll(s setter, defs ...ObjectValueDef) error {\n\tfor _, def := range defs {\n\t\tif err := s.Set(def.Name, def.Value); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to set %s: %w\", def.Name, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (v *VM) Throw(err error) {\n\te, newErr := v.New(v.Get(\"Error\"), v.ToValue(err))\n\tif newErr != nil {\n\t\tpanic(newErr)\n\t}\n\n\tpanic(e)\n}\n"
  },
  {
    "path": "pkg/job/job.go",
    "content": "// Package job provides the job execution and management functionality for the application.\npackage job\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\ntype JobExecFn func(ctx context.Context, progress *Progress) error\n\n// JobExec represents the implementation of a Job to be executed.\ntype JobExec interface {\n\tExecute(ctx context.Context, progress *Progress) error\n}\n\ntype jobExecImpl struct {\n\tfn JobExecFn\n}\n\nfunc (j *jobExecImpl) Execute(ctx context.Context, progress *Progress) error {\n\treturn j.fn(ctx, progress)\n}\n\n// MakeJobExec returns a simple JobExec implementation using the provided\n// function.\nfunc MakeJobExec(fn JobExecFn) JobExec {\n\treturn &jobExecImpl{\n\t\tfn: fn,\n\t}\n}\n\n// Status is the status of a Job\ntype Status string\n\nconst (\n\t// StatusReady means that the Job is not yet started.\n\tStatusReady Status = \"READY\"\n\t// StatusRunning means that the job is currently running.\n\tStatusRunning Status = \"RUNNING\"\n\t// StatusStopping means that the job is cancelled but is still running.\n\tStatusStopping Status = \"STOPPING\"\n\t// StatusFinished means that the job was completed.\n\tStatusFinished Status = \"FINISHED\"\n\t// StatusCancelled means that the job was cancelled and is now stopped.\n\tStatusCancelled Status = \"CANCELLED\"\n\t// StatusFailed means that the job failed.\n\tStatusFailed Status = \"FAILED\"\n)\n\n// Job represents the status of a queued or running job.\ntype Job struct {\n\tID     int\n\tStatus Status\n\t// details of the current operations of the job\n\tDetails     []string\n\tDescription string\n\t// Progress in terms of 0 - 1.\n\tProgress  float64\n\tStartTime *time.Time\n\tEndTime   *time.Time\n\tAddTime   time.Time\n\tError     *string\n\n\touterCtx   context.Context\n\texec       JobExec\n\tcancelFunc context.CancelFunc\n}\n\n// TimeElapsed returns the total time elapsed for the job.\n// If the EndTime is set, then it uses this to calculate the elapsed time, otherwise it uses time.Now.\nfunc (j *Job) TimeElapsed() time.Duration {\n\tvar end time.Time\n\tif j.EndTime != nil {\n\t\tend = time.Now()\n\t} else {\n\t\tend = *j.EndTime\n\t}\n\n\treturn end.Sub(*j.StartTime)\n}\n\nfunc (j *Job) cancel() {\n\tif j.Status == StatusReady {\n\t\tj.Status = StatusCancelled\n\t} else if j.Status == StatusRunning {\n\t\tj.Status = StatusStopping\n\t}\n\n\tif j.cancelFunc != nil {\n\t\tj.cancelFunc()\n\t}\n}\n\nfunc (j *Job) error(err error) {\n\terrStr := err.Error()\n\tj.Error = &errStr\n\tj.Status = StatusFailed\n}\n\n// IsCancelled returns true if cancel has been called on the context.\nfunc IsCancelled(ctx context.Context) bool {\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "pkg/job/manager.go",
    "content": "package job\n\nimport (\n\t\"context\"\n\t\"runtime/debug\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst maxGraveyardSize = 10\nconst defaultThrottleLimit = 100 * time.Millisecond\n\n// Manager maintains a queue of jobs. Jobs are executed one at a time.\ntype Manager struct {\n\tqueue     []*Job\n\tgraveyard []*Job\n\n\tmutex    sync.Mutex\n\tnotEmpty *sync.Cond\n\tstop     chan struct{}\n\n\tlastID int\n\n\tsubscriptions       []*ManagerSubscription\n\tupdateThrottleLimit time.Duration\n}\n\n// NewManager initialises and returns a new Manager.\nfunc NewManager() *Manager {\n\tret := &Manager{\n\t\tstop:                make(chan struct{}),\n\t\tupdateThrottleLimit: defaultThrottleLimit,\n\t}\n\n\tret.notEmpty = sync.NewCond(&ret.mutex)\n\n\tgo ret.dispatcher()\n\n\treturn ret\n}\n\n// Stop is used to stop the dispatcher thread. Once Stop is called, no\n// more Jobs will be processed.\nfunc (m *Manager) Stop() {\n\tm.CancelAll()\n\tclose(m.stop)\n}\n\n// Add queues a job.\nfunc (m *Manager) Add(ctx context.Context, description string, e JobExec) int {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tt := time.Now()\n\n\tj := Job{\n\t\tID:          m.nextID(),\n\t\tStatus:      StatusReady,\n\t\tDescription: description,\n\t\tAddTime:     t,\n\t\texec:        e,\n\t\touterCtx:    ctx,\n\t}\n\n\tm.queue = append(m.queue, &j)\n\n\tif len(m.queue) == 1 {\n\t\t// notify that there is now a job in the queue\n\t\tm.notEmpty.Broadcast()\n\t}\n\n\tm.notifyNewJob(&j)\n\n\treturn j.ID\n}\n\n// Start adds a job and starts it immediately, concurrently with any other\n// jobs.\nfunc (m *Manager) Start(ctx context.Context, description string, e JobExec) int {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tt := time.Now()\n\n\tj := Job{\n\t\tID:          m.nextID(),\n\t\tStatus:      StatusReady,\n\t\tDescription: description,\n\t\tAddTime:     t,\n\t\texec:        e,\n\t\touterCtx:    ctx,\n\t}\n\n\tm.queue = append(m.queue, &j)\n\n\tm.dispatch(ctx, &j)\n\n\treturn j.ID\n}\n\nfunc (m *Manager) notifyNewJob(j *Job) {\n\t// assumes lock held\n\tfor _, s := range m.subscriptions {\n\t\t// don't block if channel is full\n\t\tselect {\n\t\tcase s.newJob <- *j:\n\t\tdefault:\n\t\t}\n\t}\n}\n\nfunc (m *Manager) nextID() int {\n\tm.lastID++\n\treturn m.lastID\n}\n\nfunc (m *Manager) getReadyJob() *Job {\n\t// assumes lock held\n\tfor _, j := range m.queue {\n\t\tif j.Status == StatusReady {\n\t\t\treturn j\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *Manager) dispatcher() {\n\tm.mutex.Lock()\n\n\tfor {\n\t\t// wait until we have something to process\n\t\tj := m.getReadyJob()\n\n\t\tfor j == nil {\n\t\t\tm.notEmpty.Wait()\n\n\t\t\t// it's possible that we have been stopped - check here\n\t\t\tselect {\n\t\t\tcase <-m.stop:\n\t\t\t\tm.mutex.Unlock()\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t\t// keep going\n\t\t\t\tj = m.getReadyJob()\n\t\t\t}\n\t\t}\n\n\t\tdone := m.dispatch(j.outerCtx, j)\n\n\t\t// unlock the mutex and wait for the job to finish\n\t\tm.mutex.Unlock()\n\t\t<-done\n\t\tm.mutex.Lock()\n\n\t\t// remove the job from the queue\n\t\tm.removeJob(j)\n\n\t\t// process next job\n\t}\n}\n\nfunc (m *Manager) newProgress(j *Job) *Progress {\n\treturn &Progress{\n\t\tupdater: &updater{\n\t\t\tm:   m,\n\t\t\tjob: j,\n\t\t},\n\t\tpercent: ProgressIndefinite,\n\t}\n}\n\nfunc (m *Manager) dispatch(ctx context.Context, j *Job) (done chan struct{}) {\n\t// assumes lock held\n\tt := time.Now()\n\tj.StartTime = &t\n\tj.Status = StatusRunning\n\n\t// create a cancellable context for the job that is not canceled by the outer context\n\tctx, cancelFunc := context.WithCancel(context.WithoutCancel(ctx))\n\tj.cancelFunc = cancelFunc\n\n\tdone = make(chan struct{})\n\tgo m.executeJob(ctx, j, done)\n\n\tm.notifyJobUpdate(j)\n\n\treturn\n}\n\nfunc (m *Manager) executeJob(ctx context.Context, j *Job, done chan struct{}) {\n\tdefer close(done)\n\tdefer m.onJobFinish(j)\n\tdefer func() {\n\t\tif p := recover(); p != nil {\n\t\t\t// a panic occurred, log and mark the job as failed\n\t\t\tlogger.Errorf(\"panic in job %d - %s: %v\", j.ID, j.Description, p)\n\t\t\tlogger.Error(string(debug.Stack()))\n\n\t\t\tm.mutex.Lock()\n\t\t\tdefer m.mutex.Unlock()\n\t\t\tj.Status = StatusFailed\n\t\t}\n\t}()\n\n\tprogress := m.newProgress(j)\n\tif err := j.exec.Execute(ctx, progress); err != nil {\n\t\tlogger.Errorf(\"task failed due to error: %v\", err)\n\t\tj.error(err)\n\t}\n}\n\nfunc (m *Manager) onJobFinish(job *Job) {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tif job.Status == StatusStopping {\n\t\tjob.Status = StatusCancelled\n\t} else if job.Status != StatusFailed {\n\t\tjob.Status = StatusFinished\n\t}\n\tt := time.Now()\n\tjob.EndTime = &t\n}\n\nfunc (m *Manager) removeJob(job *Job) {\n\t// assumes lock held\n\tindex, _ := m.getJob(m.queue, job.ID)\n\tif index == -1 {\n\t\treturn\n\t}\n\n\t// clear any subtasks\n\tjob.Details = nil\n\n\tm.queue = append(m.queue[:index], m.queue[index+1:]...)\n\n\tm.graveyard = append(m.graveyard, job)\n\tif len(m.graveyard) > maxGraveyardSize {\n\t\tm.graveyard = m.graveyard[1:]\n\t}\n\n\t// notify job removed\n\tfor _, s := range m.subscriptions {\n\t\t// don't block if channel is full\n\t\tselect {\n\t\tcase s.removedJob <- *job:\n\t\tdefault:\n\t\t}\n\t}\n}\n\nfunc (m *Manager) getJob(list []*Job, id int) (index int, job *Job) {\n\t// assumes lock held\n\tfor i, j := range list {\n\t\tif j.ID == id {\n\t\t\tindex = i\n\t\t\tjob = j\n\t\t\treturn\n\t\t}\n\t}\n\n\treturn -1, nil\n}\n\n// CancelJob cancels the job with the provided id. Jobs that have been started\n// are notified that they are stopping. Jobs that have not yet started are\n// removed from the queue. If no job exists with the provided id, then there is\n// no effect. Likewise, if the job is already cancelled, there is no effect.\nfunc (m *Manager) CancelJob(id int) {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\t_, j := m.getJob(m.queue, id)\n\tif j != nil {\n\t\tj.cancel()\n\n\t\tif j.Status == StatusCancelled {\n\t\t\t// remove from the queue\n\t\t\tm.removeJob(j)\n\t\t}\n\t}\n}\n\n// CancelAll cancels all of the jobs in the queue. This is the same as\n// calling CancelJob on all jobs in the queue.\nfunc (m *Manager) CancelAll() {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\t// call cancel on all\n\tfor _, j := range m.queue {\n\t\tj.cancel()\n\n\t\tif j.Status == StatusCancelled {\n\t\t\t// add to graveyard\n\t\t\tm.removeJob(j)\n\t\t}\n\t}\n}\n\n// GetJob returns a copy of the Job for the provided id. Returns nil if the job\n// does not exist.\nfunc (m *Manager) GetJob(id int) *Job {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\t// get from the queue or graveyard\n\t_, j := m.getJob(append(m.queue, m.graveyard...), id)\n\tif j != nil {\n\t\t// make a copy of the job and return the pointer\n\t\tjCopy := *j\n\t\treturn &jCopy\n\t}\n\n\treturn nil\n}\n\n// GetQueue returns a copy of the current job queue.\nfunc (m *Manager) GetQueue() []Job {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tvar ret []Job\n\n\tfor _, j := range m.queue {\n\t\tjCopy := *j\n\t\tret = append(ret, jCopy)\n\t}\n\n\treturn ret\n}\n\n// Subscribe subscribes to changes to jobs in the manager queue.\nfunc (m *Manager) Subscribe(ctx context.Context) *ManagerSubscription {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tret := newSubscription()\n\n\tm.subscriptions = append(m.subscriptions, ret)\n\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tm.mutex.Lock()\n\t\tdefer m.mutex.Unlock()\n\n\t\tret.close()\n\n\t\t// remove from the list\n\t\tfor i, s := range m.subscriptions {\n\t\t\tif s == ret {\n\t\t\t\tm.subscriptions = append(m.subscriptions[:i], m.subscriptions[i+1:]...)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn ret\n}\n\nfunc (m *Manager) notifyJobUpdate(j *Job) {\n\t// don't update if job is finished or cancelled - these are handled\n\t// by removeJob\n\tif j.Status == StatusCancelled || j.Status == StatusFinished {\n\t\treturn\n\t}\n\n\t// assumes lock held\n\tfor _, s := range m.subscriptions {\n\t\t// don't block if channel is full\n\t\tselect {\n\t\tcase s.updatedJob <- *j:\n\t\tdefault:\n\t\t}\n\t}\n}\n\ntype updater struct {\n\tm           *Manager\n\tjob         *Job\n\tlastUpdate  time.Time\n\tupdateTimer *time.Timer\n}\n\nfunc (u *updater) notifyUpdate() {\n\t// assumes lock held\n\tu.m.notifyJobUpdate(u.job)\n\tu.lastUpdate = time.Now()\n\tu.updateTimer = nil\n}\n\nfunc (u *updater) updateProgress(progress float64, details []string) {\n\tu.m.mutex.Lock()\n\tdefer u.m.mutex.Unlock()\n\n\tu.job.Progress = progress\n\tu.job.Details = details\n\n\tif time.Since(u.lastUpdate) < u.m.updateThrottleLimit {\n\t\tif u.updateTimer == nil {\n\t\t\tu.updateTimer = time.AfterFunc(u.m.updateThrottleLimit-time.Since(u.lastUpdate), func() {\n\t\t\t\tu.m.mutex.Lock()\n\t\t\t\tdefer u.m.mutex.Unlock()\n\n\t\t\t\tu.notifyUpdate()\n\t\t\t})\n\t\t}\n\t} else {\n\t\tu.notifyUpdate()\n\t}\n}\n"
  },
  {
    "path": "pkg/job/manager_test.go",
    "content": "package job\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nconst sleepTime time.Duration = 10 * time.Millisecond\n\ntype testExec struct {\n\tstarted   chan struct{}\n\tfinish    chan struct{}\n\tcancelled bool\n\tprogress  *Progress\n}\n\nfunc newTestExec(finish chan struct{}) *testExec {\n\treturn &testExec{\n\t\tstarted: make(chan struct{}),\n\t\tfinish:  finish,\n\t}\n}\n\nfunc (e *testExec) Execute(ctx context.Context, p *Progress) error {\n\te.progress = p\n\tclose(e.started)\n\n\tif e.finish != nil {\n\t\t<-e.finish\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\te.cancelled = true\n\t\tdefault:\n\t\t\t// fall through\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc TestAdd(t *testing.T) {\n\tm := NewManager()\n\n\tconst jobName = \"test job\"\n\texec1 := newTestExec(make(chan struct{}))\n\tjobID := m.Add(context.Background(), jobName, exec1)\n\n\t// expect jobID to be the first ID\n\tassert := assert.New(t)\n\tassert.Equal(1, jobID)\n\n\t// wait a tiny bit\n\ttime.Sleep(sleepTime)\n\n\t// expect job to have started\n\tselect {\n\tcase <-exec1.started:\n\t\t// ok\n\tdefault:\n\t\tt.Error(\"exec was not started\")\n\t}\n\n\t// expect status to be running\n\tj := m.GetJob(jobID)\n\n\tassert.Equal(StatusRunning, j.Status)\n\n\t// expect description to be set\n\tassert.Equal(jobName, j.Description)\n\n\t// expect startTime and addTime to be set\n\tassert.NotNil(j.StartTime)\n\tassert.NotNil(j.AddTime)\n\n\t// expect endTime to not be set\n\tassert.Nil(j.EndTime)\n\n\t// add another job to the queue\n\tconst otherJobName = \"other job name\"\n\texec2 := newTestExec(make(chan struct{}))\n\tjob2ID := m.Add(context.Background(), otherJobName, exec2)\n\n\t// expect status to be ready\n\tj2 := m.GetJob(job2ID)\n\n\tassert.Equal(StatusReady, j2.Status)\n\n\t// expect addTime to be set\n\tassert.NotNil(j2.AddTime)\n\n\t// expect startTime and endTime to not be set\n\tassert.Nil(j2.StartTime)\n\tassert.Nil(j2.EndTime)\n\n\t// allow first job to finish\n\tclose(exec1.finish)\n\n\t// wait a tiny bit\n\ttime.Sleep(sleepTime)\n\n\t// expect first job to be finished\n\tj = m.GetJob(jobID)\n\tassert.Equal(StatusFinished, j.Status)\n\n\t// expect end time to be set\n\tassert.NotNil(j.EndTime)\n\n\t// expect second job to have started\n\tselect {\n\tcase <-exec2.started:\n\t\t// ok\n\tdefault:\n\t\tt.Error(\"exec was not started\")\n\t}\n\n\t// expect status to be running\n\tj2 = m.GetJob(job2ID)\n\n\tassert.Equal(StatusRunning, j2.Status)\n\n\t// expect startTime to be set\n\tassert.NotNil(j2.StartTime)\n}\n\nfunc TestCancel(t *testing.T) {\n\tm := NewManager()\n\n\t// add two jobs\n\tconst jobName = \"test job\"\n\texec1 := newTestExec(make(chan struct{}))\n\tjobID := m.Add(context.Background(), jobName, exec1)\n\n\tconst otherJobName = \"other job\"\n\texec2 := newTestExec(make(chan struct{}))\n\tjob2ID := m.Add(context.Background(), otherJobName, exec2)\n\n\t// wait a tiny bit\n\ttime.Sleep(sleepTime)\n\n\tm.CancelJob(job2ID)\n\n\t// expect job to be cancelled\n\tassert := assert.New(t)\n\tj := m.GetJob(job2ID)\n\tassert.Equal(StatusCancelled, j.Status)\n\n\t// expect end time not to be set\n\tassert.Nil(j.EndTime)\n\n\t// expect job to be removed from the queue\n\tassert.Len(m.GetQueue(), 1)\n\n\t// expect job to have not have been started\n\tselect {\n\tcase <-exec2.started:\n\t\tt.Error(\"cancelled exec was started\")\n\tdefault:\n\t}\n\n\t// cancel running job\n\tm.CancelJob(jobID)\n\n\t// wait a tiny bit\n\ttime.Sleep(sleepTime)\n\n\t// expect status to be stopping\n\tj = m.GetJob(jobID)\n\tassert.Equal(StatusStopping, j.Status)\n\n\t// expect job to still be in the queue\n\tassert.Len(m.GetQueue(), 1)\n\n\t// allow first job to finish\n\tclose(exec1.finish)\n\n\t// wait a tiny bit\n\ttime.Sleep(sleepTime)\n\n\t// expect job to be removed from the queue\n\tassert.Len(m.GetQueue(), 0)\n\n\t// expect job to be cancelled\n\tj = m.GetJob(jobID)\n\tassert.Equal(StatusCancelled, j.Status)\n\n\t// expect endtime to be set\n\tassert.NotNil(j.EndTime)\n\n\t// expect job to have been cancelled via context\n\tassert.True(exec1.cancelled)\n}\n\nfunc TestCancelAll(t *testing.T) {\n\tm := NewManager()\n\n\t// add two jobs\n\tconst jobName = \"test job\"\n\texec1 := newTestExec(make(chan struct{}))\n\tjobID := m.Add(context.Background(), jobName, exec1)\n\n\tconst otherJobName = \"other job\"\n\texec2 := newTestExec(make(chan struct{}))\n\tjob2ID := m.Add(context.Background(), otherJobName, exec2)\n\n\t// wait a tiny bit\n\ttime.Sleep(sleepTime)\n\n\tm.CancelAll()\n\n\t// allow first job to finish\n\tclose(exec1.finish)\n\n\t// wait a tiny bit\n\ttime.Sleep(sleepTime)\n\n\t// expect all jobs to be cancelled\n\tassert := assert.New(t)\n\tj := m.GetJob(job2ID)\n\tassert.Equal(StatusCancelled, j.Status)\n\n\tj = m.GetJob(jobID)\n\tassert.Equal(StatusCancelled, j.Status)\n\n\t// expect all jobs to be removed from the queue\n\tassert.Len(m.GetQueue(), 0)\n\n\t// expect job to have not have been started\n\tselect {\n\tcase <-exec2.started:\n\t\tt.Error(\"cancelled exec was started\")\n\tdefault:\n\t}\n}\n\nfunc TestSubscribe(t *testing.T) {\n\tm := NewManager()\n\n\tm.updateThrottleLimit = time.Millisecond * 100\n\n\tctx, cancel := context.WithCancel(context.Background())\n\n\ts := m.Subscribe(ctx)\n\n\t// add a job\n\tconst jobName = \"test job\"\n\texec1 := newTestExec(make(chan struct{}))\n\tjobID := m.Add(context.Background(), jobName, exec1)\n\n\tassert := assert.New(t)\n\n\tselect {\n\tcase newJob := <-s.NewJob:\n\t\tassert.Equal(jobID, newJob.ID)\n\t\tassert.Equal(jobName, newJob.Description)\n\t\tassert.Equal(StatusReady, newJob.Status)\n\tcase <-time.After(time.Second):\n\t\tt.Error(\"new job was not received\")\n\t}\n\n\t// should receive an update when the job begins to run\n\tselect {\n\tcase updatedJob := <-s.UpdatedJob:\n\t\tassert.Equal(jobID, updatedJob.ID)\n\t\tassert.Equal(jobName, updatedJob.Description)\n\t\tassert.Equal(StatusRunning, updatedJob.Status)\n\tcase <-time.After(time.Second):\n\t\tt.Error(\"updated job was not received\")\n\t}\n\n\t// wait for it to start\n\tselect {\n\tcase <-exec1.started:\n\t\t// ok\n\tcase <-time.After(time.Second):\n\t\tt.Error(\"exec was not started\")\n\t}\n\n\t// test update throttling\n\texec1.progress.SetPercent(0.1)\n\n\t// first update should be immediate\n\tselect {\n\tcase updatedJob := <-s.UpdatedJob:\n\t\tassert.Equal(0.1, updatedJob.Progress)\n\tcase <-time.After(m.updateThrottleLimit):\n\t\tt.Error(\"updated job was not received\")\n\t}\n\n\texec1.progress.SetPercent(0.2)\n\texec1.progress.SetPercent(0.3)\n\n\t// should only receive a single update with the second status\n\tselect {\n\tcase updatedJob := <-s.UpdatedJob:\n\t\tassert.Equal(0.3, updatedJob.Progress)\n\tcase <-time.After(time.Second):\n\t\tt.Error(\"updated job was not received\")\n\t}\n\n\tselect {\n\tcase <-s.UpdatedJob:\n\t\tt.Error(\"received an additional updatedJob\")\n\tdefault:\n\t}\n\n\t// allow job to finish\n\tclose(exec1.finish)\n\n\tselect {\n\tcase removedJob := <-s.RemovedJob:\n\t\tassert.Equal(jobID, removedJob.ID)\n\t\tassert.Equal(jobName, removedJob.Description)\n\t\tassert.Equal(StatusFinished, removedJob.Status)\n\tcase <-time.After(time.Second):\n\t\tt.Error(\"removed job was not received\")\n\t}\n\n\t// should not receive another update\n\tselect {\n\tcase <-s.UpdatedJob:\n\t\tt.Error(\"updated job was received after update\")\n\tcase <-time.After(m.updateThrottleLimit):\n\t}\n\n\t// add another job and cancel it\n\texec2 := newTestExec(make(chan struct{}))\n\tjobID = m.Add(context.Background(), jobName, exec2)\n\n\tm.CancelJob(jobID)\n\n\tselect {\n\tcase removedJob := <-s.RemovedJob:\n\t\tassert.Equal(jobID, removedJob.ID)\n\t\tassert.Equal(jobName, removedJob.Description)\n\t\tassert.Equal(StatusCancelled, removedJob.Status)\n\tcase <-time.After(time.Second):\n\t\tt.Error(\"cancelled job was not received\")\n\t}\n\n\tcancel()\n}\n"
  },
  {
    "path": "pkg/job/progress.go",
    "content": "package job\n\nimport \"sync\"\n\n// ProgressIndefinite is the special percent value to indicate that the\n// percent progress is not known.\nconst ProgressIndefinite float64 = -1\n\n// Progress is used by JobExec to communicate updates to the job's progress to\n// the JobManager.\ntype Progress struct {\n\tdefined      bool\n\tprocessed    int\n\ttotal        int\n\tpercent      float64\n\tcurrentTasks []*task\n\n\tmutex   sync.Mutex\n\tupdater *updater\n}\n\ntype task struct {\n\tdescription string\n}\n\nfunc (p *Progress) updated() {\n\tvar details []string\n\tfor _, t := range p.currentTasks {\n\t\tdetails = append(details, t.description)\n\t}\n\n\tp.updater.updateProgress(p.percent, details)\n}\n\n// Indefinite sets the progress to an indefinite amount.\nfunc (p *Progress) Indefinite() {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tp.defined = false\n\tp.total = 0\n\tp.calculatePercent()\n}\n\n// Definite notifies that the total is known.\nfunc (p *Progress) Definite() {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tp.defined = true\n\tp.calculatePercent()\n}\n\n// SetTotal sets the total number of work units and sets definite to true.\n// This is used to calculate the progress percentage.\nfunc (p *Progress) SetTotal(total int) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tp.total = total\n\tp.defined = true\n\tp.calculatePercent()\n}\n\n// AddTotal adds to the total number of work units. This is used to calculate the\n// progress percentage.\nfunc (p *Progress) AddTotal(total int) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tp.total += total\n\tp.calculatePercent()\n}\n\n// SetProcessed sets the number of work units completed. This is used to\n// calculate the progress percentage.\nfunc (p *Progress) SetProcessed(processed int) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tp.processed = processed\n\tp.calculatePercent()\n}\n\nfunc (p *Progress) calculatePercent() {\n\tswitch {\n\tcase !p.defined || p.total <= 0:\n\t\tp.percent = ProgressIndefinite\n\tcase p.processed < 0:\n\t\tp.percent = 0\n\tdefault:\n\t\tp.percent = float64(p.processed) / float64(p.total)\n\t\tif p.percent > 1 {\n\t\t\tp.percent = 1\n\t\t}\n\t}\n\n\tp.updated()\n}\n\n// SetPercent sets the progress percent directly. This value will be\n// overwritten if Indefinite, SetTotal, Increment or SetProcessed is called.\n// Constrains the percent value between 0 and 1, inclusive.\nfunc (p *Progress) SetPercent(percent float64) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tif percent < 0 {\n\t\tpercent = 0\n\t} else if percent > 1 {\n\t\tpercent = 1\n\t}\n\n\tp.percent = percent\n\tp.updated()\n}\n\n// Increment increments the number of processed work units. This is used to calculate the percentage.\n// If total is set already, then the number of processed work units will not exceed the total.\nfunc (p *Progress) Increment() {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tif !p.defined || p.total <= 0 || p.processed < p.total {\n\t\tp.processed++\n\t\tp.calculatePercent()\n\t}\n}\n\n// AddProcessed increments the number of processed work units by the provided\n// amount. This is used to calculate the percentage.\nfunc (p *Progress) AddProcessed(v int) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tnewVal := v\n\tif p.defined && p.total > 0 && newVal > p.total {\n\t\tnewVal = p.total\n\t}\n\n\tp.processed = newVal\n\tp.calculatePercent()\n}\n\nfunc (p *Progress) addTask(t *task) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tp.currentTasks = append([]*task{t}, p.currentTasks...)\n\tp.updated()\n}\n\nfunc (p *Progress) removeTask(t *task) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tfor i, tt := range p.currentTasks {\n\t\tif tt == t {\n\t\t\tp.currentTasks = append(p.currentTasks[:i], p.currentTasks[i+1:]...)\n\t\t\tp.updated()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// ExecuteTask executes a task as part of a job. The description is used to\n// populate the Details slice in the parent Job.\nfunc (p *Progress) ExecuteTask(description string, fn func()) {\n\tt := &task{\n\t\tdescription: description,\n\t}\n\n\tp.addTask(t)\n\tdefer p.removeTask(t)\n\tfn()\n}\n"
  },
  {
    "path": "pkg/job/progress_test.go",
    "content": "package job\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc createProgress(m *Manager, j *Job) Progress {\n\treturn Progress{\n\t\tupdater: &updater{\n\t\t\tm:   m,\n\t\t\tjob: j,\n\t\t},\n\t\ttotal:     100,\n\t\tdefined:   true,\n\t\tprocessed: 10,\n\t\tpercent:   10,\n\t}\n}\n\nfunc TestProgressIndefinite(t *testing.T) {\n\tm := NewManager()\n\tj := &Job{}\n\n\tp := createProgress(m, j)\n\n\tp.Indefinite()\n\n\tassert := assert.New(t)\n\n\t// ensure job progress was updated\n\tassert.Equal(ProgressIndefinite, j.Progress)\n}\n\nfunc TestProgressSetTotal(t *testing.T) {\n\tm := NewManager()\n\tj := &Job{}\n\n\tp := createProgress(m, j)\n\n\tp.SetTotal(50)\n\n\tassert := assert.New(t)\n\n\t// ensure job progress was updated\n\tassert.Equal(0.2, j.Progress)\n\n\tp.SetTotal(0)\n\tassert.Equal(ProgressIndefinite, j.Progress)\n\n\tp.SetTotal(-10)\n\tassert.Equal(ProgressIndefinite, j.Progress)\n\n\tp.SetTotal(9)\n\tassert.Equal(float64(1), j.Progress)\n}\n\nfunc TestProgressSetProcessed(t *testing.T) {\n\tm := NewManager()\n\tj := &Job{}\n\n\tp := createProgress(m, j)\n\n\tp.SetProcessed(30)\n\n\tassert := assert.New(t)\n\n\t// ensure job progress was updated\n\tassert.Equal(0.3, j.Progress)\n\n\tp.SetProcessed(-10)\n\tassert.Equal(float64(0), j.Progress)\n\n\tp.SetProcessed(200)\n\tassert.Equal(float64(1), j.Progress)\n}\n\nfunc TestProgressSetPercent(t *testing.T) {\n\tm := NewManager()\n\tj := &Job{}\n\n\tp := createProgress(m, j)\n\n\tp.SetPercent(0.3)\n\n\tassert := assert.New(t)\n\n\t// ensure job progress was updated\n\tassert.Equal(0.3, j.Progress)\n\n\tp.SetPercent(-10)\n\tassert.Equal(float64(0), j.Progress)\n\n\tp.SetPercent(200)\n\tassert.Equal(float64(1), j.Progress)\n}\n\nfunc TestProgressIncrement(t *testing.T) {\n\tm := NewManager()\n\tj := &Job{}\n\n\tp := createProgress(m, j)\n\n\tp.SetProcessed(49)\n\tp.Increment()\n\n\tassert := assert.New(t)\n\n\t// ensure job progress was updated\n\tassert.Equal(0.5, j.Progress)\n\n\tp.SetProcessed(100)\n\tp.Increment()\n\tassert.Equal(float64(1), j.Progress)\n}\n\nfunc TestExecuteTask(t *testing.T) {\n\tm := NewManager()\n\tj := &Job{}\n\n\tp := createProgress(m, j)\n\n\tc := make(chan struct{}, 1)\n\tconst taskDesciption = \"taskDescription\"\n\n\tgo p.ExecuteTask(taskDesciption, func() {\n\t\t<-c\n\t})\n\n\ttime.Sleep(sleepTime)\n\n\tassert := assert.New(t)\n\n\tm.mutex.Lock()\n\t// ensure task is added to the job details\n\tassert.Equal(taskDesciption, j.Details[0])\n\tm.mutex.Unlock()\n\n\t// allow task to finish\n\tclose(c)\n\n\ttime.Sleep(sleepTime)\n\n\tm.mutex.Lock()\n\t// ensure task is removed from the job details\n\tassert.Len(j.Details, 0)\n\tm.mutex.Unlock()\n}\n"
  },
  {
    "path": "pkg/job/subscribe.go",
    "content": "package job\n\n// ManagerSubscription is a collection of channels that will receive updates\n// from the job manager.\ntype ManagerSubscription struct {\n\t// new jobs are sent to this channel\n\tNewJob <-chan Job\n\t// removed jobs are sent to this channel\n\tRemovedJob <-chan Job\n\t// updated jobs are sent to this channel\n\tUpdatedJob <-chan Job\n\n\tnewJob     chan Job\n\tremovedJob chan Job\n\tupdatedJob chan Job\n}\n\nfunc newSubscription() *ManagerSubscription {\n\tret := &ManagerSubscription{\n\t\tnewJob:     make(chan Job, 100),\n\t\tremovedJob: make(chan Job, 100),\n\t\tupdatedJob: make(chan Job, 100),\n\t}\n\n\tret.NewJob = ret.newJob\n\tret.RemovedJob = ret.removedJob\n\tret.UpdatedJob = ret.updatedJob\n\n\treturn ret\n}\n\nfunc (s *ManagerSubscription) close() {\n\tclose(s.newJob)\n\tclose(s.removedJob)\n\tclose(s.updatedJob)\n}\n"
  },
  {
    "path": "pkg/job/task.go",
    "content": "package job\n\nimport (\n\t\"context\"\n\n\t\"github.com/remeh/sizedwaitgroup\"\n)\n\ntype taskExec struct {\n\ttask\n\tfn func(ctx context.Context)\n}\n\ntype TaskQueue struct {\n\tp     *Progress\n\twg    sizedwaitgroup.SizedWaitGroup\n\ttasks chan taskExec\n\tdone  chan struct{}\n}\n\nfunc NewTaskQueue(ctx context.Context, p *Progress, queueSize int, processes int) *TaskQueue {\n\tret := &TaskQueue{\n\t\tp:     p,\n\t\twg:    sizedwaitgroup.New(processes),\n\t\ttasks: make(chan taskExec, queueSize),\n\t\tdone:  make(chan struct{}),\n\t}\n\n\tgo ret.executer(ctx)\n\n\treturn ret\n}\n\nfunc (tq *TaskQueue) Add(description string, fn func(ctx context.Context)) {\n\ttq.tasks <- taskExec{\n\t\ttask: task{\n\t\t\tdescription: description,\n\t\t},\n\t\tfn: fn,\n\t}\n}\n\nfunc (tq *TaskQueue) Close() {\n\tclose(tq.tasks)\n\t// wait for all tasks to finish\n\t<-tq.done\n}\n\nfunc (tq *TaskQueue) executer(ctx context.Context) {\n\tdefer close(tq.done)\n\tdefer tq.wg.Wait()\n\tfor task := range tq.tasks {\n\t\tif IsCancelled(ctx) {\n\t\t\treturn\n\t\t}\n\n\t\ttt := task\n\n\t\ttq.wg.Add()\n\t\tgo func() {\n\t\t\tdefer tq.wg.Done()\n\t\t\ttq.p.ExecuteTask(tt.description, func() {\n\t\t\t\ttt.fn(ctx)\n\t\t\t})\n\t\t}()\n\t}\n}\n"
  },
  {
    "path": "pkg/logger/basic.go",
    "content": "package logger\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\n// BasicLogger logs all messages to stdout\ntype BasicLogger struct{}\n\nvar _ LoggerImpl = &BasicLogger{}\n\nfunc (log *BasicLogger) print(level string, args ...interface{}) {\n\tfmt.Print(level + \": \")\n\tfmt.Println(args...)\n}\n\nfunc (log *BasicLogger) printf(level string, format string, args ...interface{}) {\n\tfmt.Printf(level+\": \"+format+\"\\n\", args...)\n}\n\nfunc (log *BasicLogger) Progressf(format string, args ...interface{}) {\n\tlog.printf(\"Progress\", format, args...)\n}\n\nfunc (log *BasicLogger) Trace(args ...interface{}) {\n\tlog.print(\"Trace\", args...)\n}\n\nfunc (log *BasicLogger) Tracef(format string, args ...interface{}) {\n\tlog.printf(\"Trace\", format, args...)\n}\n\nfunc (log *BasicLogger) TraceFunc(fn func() (string, []interface{})) {\n\tformat, args := fn()\n\tlog.printf(\"Trace\", format, args...)\n}\n\nfunc (log *BasicLogger) Debug(args ...interface{}) {\n\tlog.print(\"Debug\", args...)\n}\n\nfunc (log *BasicLogger) Debugf(format string, args ...interface{}) {\n\tlog.printf(\"Debug\", format, args...)\n}\n\nfunc (log *BasicLogger) DebugFunc(fn func() (string, []interface{})) {\n\tformat, args := fn()\n\tlog.printf(\"Debug\", format, args...)\n}\n\nfunc (log *BasicLogger) Info(args ...interface{}) {\n\tlog.print(\"Info\", args...)\n}\n\nfunc (log *BasicLogger) Infof(format string, args ...interface{}) {\n\tlog.printf(\"Info\", format, args...)\n}\n\nfunc (log *BasicLogger) InfoFunc(fn func() (string, []interface{})) {\n\tformat, args := fn()\n\tlog.printf(\"Info\", format, args...)\n}\n\nfunc (log *BasicLogger) Warn(args ...interface{}) {\n\tlog.print(\"Warn\", args...)\n}\n\nfunc (log *BasicLogger) Warnf(format string, args ...interface{}) {\n\tlog.printf(\"Warn\", format, args...)\n}\n\nfunc (log *BasicLogger) WarnFunc(fn func() (string, []interface{})) {\n\tformat, args := fn()\n\tlog.printf(\"Warn\", format, args...)\n}\n\nfunc (log *BasicLogger) Error(args ...interface{}) {\n\tlog.print(\"Error\", args...)\n}\n\nfunc (log *BasicLogger) Errorf(format string, args ...interface{}) {\n\tlog.printf(\"Error\", format, args...)\n}\n\nfunc (log *BasicLogger) ErrorFunc(fn func() (string, []interface{})) {\n\tformat, args := fn()\n\tlog.printf(\"Error\", format, args...)\n}\n\nfunc (log *BasicLogger) Fatal(args ...interface{}) {\n\tlog.print(\"Fatal\", args...)\n\tos.Exit(1)\n}\n\nfunc (log *BasicLogger) Fatalf(format string, args ...interface{}) {\n\tlog.printf(\"Fatal\", format, args...)\n\tos.Exit(1)\n}\n"
  },
  {
    "path": "pkg/logger/logger.go",
    "content": "// Package logger provides methods and interfaces used by other stash packages for logging purposes.\npackage logger\n\nimport (\n\t\"os\"\n)\n\n// LoggerImpl is the interface that groups logging methods.\n//\n// Progressf logs using a specific progress format.\n// Trace, Debug, Info, Warn and Error log to the applicable log level. Arguments are handled in the manner of fmt.Print.\n// Tracef, Debugf, Infof, Warnf, Errorf log to the applicable log level. Arguments are handled in the manner of fmt.Printf.\n// Fatal and Fatalf log to the applicable log level, then call os.Exit(1).\ntype LoggerImpl interface {\n\tProgressf(format string, args ...interface{})\n\n\tTrace(args ...interface{})\n\tTracef(format string, args ...interface{})\n\tTraceFunc(fn func() (string, []interface{}))\n\n\tDebug(args ...interface{})\n\tDebugf(format string, args ...interface{})\n\tDebugFunc(fn func() (string, []interface{}))\n\n\tInfo(args ...interface{})\n\tInfof(format string, args ...interface{})\n\tInfoFunc(fn func() (string, []interface{}))\n\n\tWarn(args ...interface{})\n\tWarnf(format string, args ...interface{})\n\tWarnFunc(fn func() (string, []interface{}))\n\n\tError(args ...interface{})\n\tErrorf(format string, args ...interface{})\n\tErrorFunc(fn func() (string, []interface{}))\n\n\tFatal(args ...interface{})\n\tFatalf(format string, args ...interface{})\n}\n\n// Logger is the LoggerImpl used when calling the global Logger functions.\n// It is suggested to use the LoggerImpl interface directly, rather than calling global log functions.\nvar Logger LoggerImpl\n\n// Progressf calls Progressf with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Progressf(format string, args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Progressf(format, args...)\n\t}\n}\n\n// Trace calls Trace with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Trace(args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Trace(args...)\n\t}\n}\n\n// Tracef calls Tracef with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Tracef(format string, args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Tracef(format, args...)\n\t}\n}\n\n// TraceFunc calls TraceFunc with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc TraceFunc(fn func() (string, []interface{})) {\n\tif Logger != nil {\n\t\tLogger.TraceFunc(fn)\n\t}\n}\n\n// Debug calls Debug with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Debug(args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Debug(args...)\n\t}\n}\n\n// Debugf calls Debugf with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Debugf(format string, args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Debugf(format, args...)\n\t}\n}\n\n// DebugFunc calls DebugFunc with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc DebugFunc(fn func() (string, []interface{})) {\n\tif Logger != nil {\n\t\tLogger.DebugFunc(fn)\n\t}\n}\n\n// Info calls Info with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Info(args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Info(args...)\n\t}\n}\n\n// Infof calls Infof with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Infof(format string, args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Infof(format, args...)\n\t}\n}\n\n// InfoFunc calls InfoFunc with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc InfoFunc(fn func() (string, []interface{})) {\n\tif Logger != nil {\n\t\tLogger.InfoFunc(fn)\n\t}\n}\n\n// Warn calls Warn with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Warn(args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Warn(args...)\n\t}\n}\n\n// Warnf calls Warnf with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Warnf(format string, args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Warnf(format, args...)\n\t}\n}\n\n// WarnFunc calls WarnFunc with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc WarnFunc(fn func() (string, []interface{})) {\n\tif Logger != nil {\n\t\tLogger.WarnFunc(fn)\n\t}\n}\n\n// Error calls Error with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Error(args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Error(args...)\n\t}\n}\n\n// Errorf calls Errorf with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Errorf(format string, args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Errorf(format, args...)\n\t}\n}\n\n// ErrorFunc calls ErrorFunc with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc ErrorFunc(fn func() (string, []interface{})) {\n\tif Logger != nil {\n\t\tLogger.ErrorFunc(fn)\n\t}\n}\n\n// Fatal calls Fatal with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Fatal(args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Fatal(args...)\n\t} else {\n\t\tos.Exit(1)\n\t}\n}\n\n// Fatalf calls Fatalf with the Logger registered using RegisterLogger.\n// If no logger has been registered, then this function is a no-op.\nfunc Fatalf(format string, args ...interface{}) {\n\tif Logger != nil {\n\t\tLogger.Fatalf(format, args...)\n\t} else {\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "pkg/logger/plugin.go",
    "content": "package logger\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// PluginLogLevel represents a logging level for plugins to send log messages to stash.\ntype PluginLogLevel struct {\n\tchar byte\n\tname string\n}\n\n// Valid Level values.\nvar (\n\tTraceLevel = PluginLogLevel{\n\t\tchar: 't',\n\t\tname: \"trace\",\n\t}\n\tDebugLevel = PluginLogLevel{\n\t\tchar: 'd',\n\t\tname: \"debug\",\n\t}\n\tInfoLevel = PluginLogLevel{\n\t\tchar: 'i',\n\t\tname: \"info\",\n\t}\n\tWarningLevel = PluginLogLevel{\n\t\tchar: 'w',\n\t\tname: \"warning\",\n\t}\n\tErrorLevel = PluginLogLevel{\n\t\tchar: 'e',\n\t\tname: \"error\",\n\t}\n\tProgressLevel = PluginLogLevel{\n\t\tchar: 'p',\n\t\tname: \"progress\",\n\t}\n\tNoneLevel = PluginLogLevel{\n\t\tname: \"none\",\n\t}\n)\n\nvar validLevels = []PluginLogLevel{\n\tTraceLevel,\n\tDebugLevel,\n\tInfoLevel,\n\tWarningLevel,\n\tErrorLevel,\n\tProgressLevel,\n\tNoneLevel,\n}\n\nconst startLevelChar byte = 1\nconst endLevelChar byte = 2\n\nfunc (l PluginLogLevel) prefix() string {\n\treturn string([]byte{\n\t\tstartLevelChar,\n\t\tbyte(l.char),\n\t\tendLevelChar,\n\t})\n}\n\n// Log prints the provided message to os.Stderr in a format that provides the correct LogLevel for stash.\n// The message is formatted in the same way as fmt.Println.\nfunc (l PluginLogLevel) Log(args ...interface{}) {\n\tif l.char == 0 {\n\t\treturn\n\t}\n\n\targsToUse := []interface{}{\n\t\tl.prefix(),\n\t}\n\targsToUse = append(argsToUse, args...)\n\tfmt.Fprintln(os.Stderr, argsToUse...)\n}\n\n// Logf prints the provided message to os.Stderr in a format that provides the correct LogLevel for stash.\n// The message is formatted in the same way as fmt.Printf.\nfunc (l PluginLogLevel) Logf(format string, args ...interface{}) {\n\tif l.char == 0 {\n\t\treturn\n\t}\n\n\tformatToUse := string(l.prefix()) + format + \"\\n\"\n\tfmt.Fprintf(os.Stderr, formatToUse, args...)\n}\n\n// PluginLogLevelFromName returns the PluginLogLevel that matches the provided name or nil if\n// the name does not match a valid value.\nfunc PluginLogLevelFromName(name string) *PluginLogLevel {\n\tfor _, l := range validLevels {\n\t\tif l.name == name {\n\t\t\treturn &l\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// detectLogLevel returns the Level and the logging string for a provided line\n// of plugin output. It parses the string for logging control characters and\n// determines the log level, if present. If not present, the plugin output\n// is returned unchanged with a nil Level.\nfunc detectLogLevel(line string) (*PluginLogLevel, string) {\n\tif len(line) < 4 || line[0] != startLevelChar || line[2] != endLevelChar {\n\t\treturn nil, line\n\t}\n\n\tchar := line[1]\n\tvar level *PluginLogLevel\n\tfor _, l := range validLevels {\n\t\tif l.char == char {\n\t\t\tl := l // Make a copy of the loop variable\n\t\t\tlevel = &l\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif level == nil {\n\t\treturn nil, line\n\t}\n\n\tline = strings.TrimSpace(line[3:])\n\n\treturn level, line\n}\n\n// PluginLogger interprets incoming log messages from plugins and logs to the appropriate log level.\ntype PluginLogger struct {\n\t// Logger is the LoggerImpl to forward log messages to.\n\tLogger LoggerImpl\n\t// Prefix is the prefix to prepend to log messages.\n\tPrefix string\n\t// DefaultLogLevel is the log level used if a log level prefix is not present in the received log message.\n\tDefaultLogLevel *PluginLogLevel\n\t// ProgressChan is a channel that receives float64s indicating the current progress of an operation.\n\tProgressChan chan float64\n}\n\nfunc (log *PluginLogger) handleStderrLine(line string) {\n\tif log.Logger == nil {\n\t\treturn\n\t}\n\n\tlogger := log.Logger\n\n\tlevel, ll := detectLogLevel(line)\n\n\t// if no log level, just output to info\n\tif level == nil {\n\t\tif log.DefaultLogLevel != nil {\n\t\t\tlevel = log.DefaultLogLevel\n\t\t} else {\n\t\t\tlevel = &InfoLevel\n\t\t}\n\t}\n\n\tswitch *level {\n\tcase TraceLevel:\n\t\tlogger.Trace(log.Prefix, ll)\n\tcase DebugLevel:\n\t\tlogger.Debug(log.Prefix, ll)\n\tcase InfoLevel:\n\t\tlogger.Info(log.Prefix, ll)\n\tcase WarningLevel:\n\t\tlogger.Warn(log.Prefix, ll)\n\tcase ErrorLevel:\n\t\tlogger.Error(log.Prefix, ll)\n\tcase ProgressLevel:\n\t\tp, err := strconv.ParseFloat(ll, 64)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Error parsing progress value '%s': %s\", ll, err.Error())\n\t\t} else if log.ProgressChan != nil { // only pass progress through if channel present\n\t\t\t// don't block on this\n\t\t\tselect {\n\t\t\tcase log.ProgressChan <- p:\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t}\n}\n\n// ReadLogMessages reads plugin log messages from src, forwarding them to the PluginLoggers Logger.\n// ProgressLevel messages are parsed as float64 and forwarded to ProgressChan. If ProgressChan is full,\n// then the progress message is not forwarded.\n// This method only returns when it reaches the end of src or encounters an error while reading src.\n// This method closes src before returning.\nfunc (log *PluginLogger) ReadLogMessages(src io.ReadCloser) {\n\t// pipe plugin stderr to our logging\n\tscanner := bufio.NewScanner(src)\n\tfor scanner.Scan() {\n\t\tstr := scanner.Text()\n\t\tif str != \"\" {\n\t\t\tlog.handleStderrLine(str)\n\t\t}\n\t}\n\n\tstr := scanner.Text()\n\tif str != \"\" {\n\t\tlog.handleStderrLine(str)\n\t}\n\n\tsrc.Close()\n}\n"
  },
  {
    "path": "pkg/logger/progress_formatter.go",
    "content": "package logger\n\nimport (\n\t\"github.com/sirupsen/logrus\"\n)\n\ntype ProgressFormatter struct{}\n\nfunc (f *ProgressFormatter) Format(entry *logrus.Entry) ([]byte, error) {\n\tmsg := []byte(\"Processing --> \" + entry.Message + \"\\r\")\n\treturn msg, nil\n}\n"
  },
  {
    "path": "pkg/match/cache.go",
    "content": "package match\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst singleFirstCharacterRegex = `^[\\p{L}][.\\-_ ]`\n\n// Cache is used to cache queries that should not change across an autotag process.\ntype Cache struct {\n\tsingleCharPerformers []*models.Performer\n\tsingleCharStudios    []*models.Studio\n\tsingleCharTags       []*models.Tag\n}\n\n// getSingleLetterPerformers returns all performers with names that start with single character words.\n// The autotag query splits the words into two-character words to query\n// against. This means that performers with single-letter words in their names could potentially\n// be missed.\n// This query is expensive, so it's queried once and cached, if the cache if provided.\nfunc getSingleLetterPerformers(ctx context.Context, c *Cache, reader models.PerformerAutoTagQueryer) ([]*models.Performer, error) {\n\tif c == nil {\n\t\tc = &Cache{}\n\t}\n\n\tif c.singleCharPerformers == nil {\n\t\tpp := -1\n\t\tperformers, _, err := reader.Query(ctx, &models.PerformerFilterType{\n\t\t\tName: &models.StringCriterionInput{\n\t\t\t\tValue:    singleFirstCharacterRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t}, &models.FindFilterType{\n\t\t\tPerPage: &pp,\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(performers) == 0 {\n\t\t\t// make singleWordPerformers not nil\n\t\t\tc.singleCharPerformers = make([]*models.Performer, 0)\n\t\t} else {\n\t\t\tc.singleCharPerformers = performers\n\t\t}\n\t}\n\n\treturn c.singleCharPerformers, nil\n}\n\n// getSingleLetterStudios returns all studios with names that start with single character words.\n// See getSingleLetterPerformers for details.\nfunc getSingleLetterStudios(ctx context.Context, c *Cache, reader models.StudioAutoTagQueryer) ([]*models.Studio, error) {\n\tif c == nil {\n\t\tc = &Cache{}\n\t}\n\n\tif c.singleCharStudios == nil {\n\t\tpp := -1\n\t\tstudios, _, err := reader.Query(ctx, &models.StudioFilterType{\n\t\t\tName: &models.StringCriterionInput{\n\t\t\t\tValue:    singleFirstCharacterRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t}, &models.FindFilterType{\n\t\t\tPerPage: &pp,\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(studios) == 0 {\n\t\t\t// make singleWordStudios not nil\n\t\t\tc.singleCharStudios = make([]*models.Studio, 0)\n\t\t} else {\n\t\t\tc.singleCharStudios = studios\n\t\t}\n\t}\n\n\treturn c.singleCharStudios, nil\n}\n\n// getSingleLetterTags returns all tags with names that start with single character words.\n// See getSingleLetterPerformers for details.\nfunc getSingleLetterTags(ctx context.Context, c *Cache, reader models.TagAutoTagQueryer) ([]*models.Tag, error) {\n\tif c == nil {\n\t\tc = &Cache{}\n\t}\n\n\tif c.singleCharTags == nil {\n\t\tpp := -1\n\t\ttags, _, err := reader.Query(ctx, &models.TagFilterType{\n\t\t\tName: &models.StringCriterionInput{\n\t\t\t\tValue:    singleFirstCharacterRegex,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t\tOperatorFilter: models.OperatorFilter[models.TagFilterType]{\n\t\t\t\tOr: &models.TagFilterType{\n\t\t\t\t\tAliases: &models.StringCriterionInput{\n\t\t\t\t\t\tValue:    singleFirstCharacterRegex,\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}, &models.FindFilterType{\n\t\t\tPerPage: &pp,\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(tags) == 0 {\n\t\t\t// make singleWordTags not nil\n\t\t\tc.singleCharTags = make([]*models.Tag, 0)\n\t\t} else {\n\t\t\tc.singleCharTags = tags\n\t\t}\n\t}\n\n\treturn c.singleCharTags, nil\n}\n"
  },
  {
    "path": "pkg/match/path.go",
    "content": "// Package match provides functions for matching paths to models.\npackage match\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"unicode\"\n\t\"unicode/utf8\"\n\n\t\"github.com/stashapp/stash/pkg/gallery\"\n\t\"github.com/stashapp/stash/pkg/image\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scene\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\nconst (\n\tseparatorChars   = `.\\-_ `\n\tseparatorPattern = `(?:_|[^\\p{L}\\w\\d])+`\n\n\treNotLetterWordUnicode = `[^\\p{L}\\w\\d]`\n\treNotLetterWord        = `[^\\w\\d]`\n)\n\nvar separatorRE = regexp.MustCompile(separatorPattern)\n\nfunc getPathQueryRegex(name string) string {\n\t// escape specific regex characters\n\tname = regexp.QuoteMeta(name)\n\n\t// handle path separators\n\tconst separator = `[` + separatorChars + `]`\n\n\tret := strings.ReplaceAll(name, \" \", separator+\"*\")\n\n\tret = `(?:^|_|[^\\p{L}\\d])` + ret + `(?:$|_|[^\\p{L}\\d])`\n\treturn ret\n}\n\nfunc getPathWords(path string, trimExt bool) []string {\n\tretStr := path\n\n\tif trimExt {\n\t\t// remove the extension\n\t\text := filepath.Ext(retStr)\n\t\tif ext != \"\" {\n\t\t\tretStr = strings.TrimSuffix(retStr, ext)\n\t\t}\n\t}\n\n\t// handle path separators\n\tretStr = separatorRE.ReplaceAllString(retStr, \" \")\n\n\twords := strings.Split(retStr, \" \")\n\n\t// remove any single letter words\n\tvar ret []string\n\tfor _, w := range words {\n\t\tif utf8.RuneCountInString(w) > 1 {\n\t\t\t// #1450 - we need to open up the criteria for matching so that we\n\t\t\t// can match where path has no space between subject names -\n\t\t\t// ie name = \"foo bar\" - path = \"foobar\"\n\t\t\t// we post-match afterwards, so we can afford to be a little loose\n\t\t\t// with the query\n\t\t\t// just use the first two characters\n\t\t\t// #2293 - need to convert to unicode runes for the substring, otherwise\n\t\t\t// the resulting string is corrupted.\n\t\t\tret = sliceutil.AppendUnique(ret, string([]rune(w)[0:2]))\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// https://stackoverflow.com/a/53069799\nfunc allASCII(s string) bool {\n\tfor i := 0; i < len(s); i++ {\n\t\tif s[i] > unicode.MaxASCII {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// nameMatchesPath returns the index in the path for the right-most match.\n// Returns -1 if not found.\nfunc nameMatchesPath(name, path string) int {\n\t// #2363 - optimisation: only use unicode character regexp if path contains\n\t// unicode characters\n\tre := nameToRegexp(name, !allASCII(path))\n\treturn regexpMatchesPath(re, path)\n}\n\n// nameToRegexp compiles a regexp pattern to match paths from the given name.\n// Set useUnicode to true if this regexp is to be used on any strings with unicode characters.\nfunc nameToRegexp(name string, useUnicode bool) *regexp.Regexp {\n\t// escape specific regex characters\n\tname = regexp.QuoteMeta(name)\n\n\tname = strings.ToLower(name)\n\n\t// handle path separators\n\tconst separator = `[` + separatorChars + `]`\n\n\t// performance optimisation: only use \\p{L} is useUnicode is true\n\tnotWord := reNotLetterWord\n\tif useUnicode {\n\t\tnotWord = reNotLetterWordUnicode\n\t}\n\n\treStr := strings.ReplaceAll(name, \" \", separator+\"*\")\n\treStr = `(?:^|_|` + notWord + `)` + reStr + `(?:$|_|` + notWord + `)`\n\n\tre := regexp.MustCompile(reStr)\n\treturn re\n}\n\nfunc regexpMatchesPath(r *regexp.Regexp, path string) int {\n\tpath = strings.ToLower(path)\n\tfound := r.FindAllStringIndex(path, -1)\n\tif found == nil {\n\t\treturn -1\n\t}\n\treturn found[len(found)-1][0]\n}\n\nfunc getPerformers(ctx context.Context, words []string, performerReader models.PerformerAutoTagQueryer, cache *Cache) ([]*models.Performer, error) {\n\tperformers, err := performerReader.QueryForAutoTag(ctx, words)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tswPerformers, err := getSingleLetterPerformers(ctx, cache, performerReader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn append(performers, swPerformers...), nil\n}\n\nfunc PathToPerformers(ctx context.Context, path string, reader models.PerformerAutoTagQueryer, cache *Cache, trimExt bool) ([]*models.Performer, error) {\n\twords := getPathWords(path, trimExt)\n\n\tperformers, err := getPerformers(ctx, words, reader, cache)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret []*models.Performer\n\tfor _, p := range performers {\n\t\tmatches := false\n\t\tif nameMatchesPath(p.Name, path) != -1 {\n\t\t\tmatches = true\n\t\t}\n\n\t\t// TODO - disabled alias matching until we can get finer\n\t\t// control over the matching\n\t\t// if !matches {\n\t\t// \tif err := p.LoadAliases(ctx, reader); err != nil {\n\t\t// \t\treturn nil, err\n\t\t// \t}\n\n\t\t// \tfor _, alias := range p.Aliases.List() {\n\t\t// \t\tif nameMatchesPath(alias, path) != -1 {\n\t\t// \t\t\tmatches = true\n\t\t// \t\t\tbreak\n\t\t// \t\t}\n\t\t// \t}\n\t\t// }\n\n\t\tif matches {\n\t\t\tret = append(ret, p)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc getStudios(ctx context.Context, words []string, reader models.StudioAutoTagQueryer, cache *Cache) ([]*models.Studio, error) {\n\tstudios, err := reader.QueryForAutoTag(ctx, words)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tswStudios, err := getSingleLetterStudios(ctx, cache, reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn append(studios, swStudios...), nil\n}\n\n// PathToStudio returns the Studio that matches the given path.\n// Where multiple matching studios are found, the one that matches the latest\n// position in the path is returned.\nfunc PathToStudio(ctx context.Context, path string, reader models.StudioAutoTagQueryer, cache *Cache, trimExt bool) (*models.Studio, error) {\n\twords := getPathWords(path, trimExt)\n\tcandidates, err := getStudios(ctx, words, reader, cache)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret *models.Studio\n\tindex := -1\n\tfor _, c := range candidates {\n\t\tmatchIndex := nameMatchesPath(c.Name, path)\n\t\tif matchIndex != -1 && matchIndex > index {\n\t\t\tret = c\n\t\t\tindex = matchIndex\n\t\t}\n\n\t\taliases, err := reader.GetAliases(ctx, c.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, alias := range aliases {\n\t\t\tmatchIndex = nameMatchesPath(alias, path)\n\t\t\tif matchIndex != -1 && matchIndex > index {\n\t\t\t\tret = c\n\t\t\t\tindex = matchIndex\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc getTags(ctx context.Context, words []string, reader models.TagAutoTagQueryer, cache *Cache) ([]*models.Tag, error) {\n\ttags, err := reader.QueryForAutoTag(ctx, words)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tswTags, err := getSingleLetterTags(ctx, cache, reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn append(tags, swTags...), nil\n}\n\nfunc PathToTags(ctx context.Context, path string, reader models.TagAutoTagQueryer, cache *Cache, trimExt bool) ([]*models.Tag, error) {\n\twords := getPathWords(path, trimExt)\n\ttags, err := getTags(ctx, words, reader, cache)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret []*models.Tag\n\tfor _, t := range tags {\n\t\tmatches := false\n\t\tif nameMatchesPath(t.Name, path) != -1 {\n\t\t\tmatches = true\n\t\t}\n\n\t\tif !matches {\n\t\t\taliases, err := reader.GetAliases(ctx, t.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tfor _, alias := range aliases {\n\t\t\t\tif nameMatchesPath(alias, path) != -1 {\n\t\t\t\t\tmatches = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif matches {\n\t\t\tret = append(ret, t)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc PathToScenesFn(ctx context.Context, name string, paths []string, sceneReader models.SceneQueryer, fn func(ctx context.Context, scene *models.Scene) error) error {\n\tregex := getPathQueryRegex(name)\n\torganized := false\n\tfilter := models.SceneFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    \"(?i)\" + regex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t\tOrganized: &organized,\n\t}\n\n\tfilter.And = scene.PathsFilter(paths)\n\n\t// do in batches\n\tpp := 1000\n\tsort := \"id\"\n\tsortDir := models.SortDirectionEnumAsc\n\tlastID := 0\n\n\tfor {\n\t\tif lastID != 0 {\n\t\t\tfilter.ID = &models.IntCriterionInput{\n\t\t\t\tValue:    lastID,\n\t\t\t\tModifier: models.CriterionModifierGreaterThan,\n\t\t\t}\n\t\t}\n\n\t\tscenes, err := scene.Query(ctx, sceneReader, &filter, &models.FindFilterType{\n\t\t\tPerPage:   &pp,\n\t\t\tSort:      &sort,\n\t\t\tDirection: &sortDir,\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error querying scenes with regex '%s': %s\", regex, err.Error())\n\t\t}\n\n\t\t// paths may have unicode characters\n\t\tconst useUnicode = true\n\n\t\tr := nameToRegexp(name, useUnicode)\n\t\tfor _, p := range scenes {\n\t\t\tif regexpMatchesPath(r, p.Path) != -1 {\n\t\t\t\tif err := fn(ctx, p); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"processing scene %s: %w\", p.GetTitle(), err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif len(scenes) < pp {\n\t\t\tbreak\n\t\t}\n\n\t\tlastID = scenes[len(scenes)-1].ID\n\t}\n\n\treturn nil\n}\n\nfunc PathToImagesFn(ctx context.Context, name string, paths []string, imageReader models.ImageQueryer, fn func(ctx context.Context, scene *models.Image) error) error {\n\tregex := getPathQueryRegex(name)\n\torganized := false\n\tfilter := models.ImageFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    \"(?i)\" + regex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t\tOrganized: &organized,\n\t}\n\n\tfilter.And = image.PathsFilter(paths)\n\n\t// do in batches\n\tpp := 1000\n\tsort := \"id\"\n\tsortDir := models.SortDirectionEnumAsc\n\tlastID := 0\n\n\tfor {\n\t\tif lastID != 0 {\n\t\t\tfilter.ID = &models.IntCriterionInput{\n\t\t\t\tValue:    lastID,\n\t\t\t\tModifier: models.CriterionModifierGreaterThan,\n\t\t\t}\n\t\t}\n\n\t\timages, err := image.Query(ctx, imageReader, &filter, &models.FindFilterType{\n\t\t\tPerPage:   &pp,\n\t\t\tSort:      &sort,\n\t\t\tDirection: &sortDir,\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error querying images with regex '%s': %s\", regex, err.Error())\n\t\t}\n\n\t\t// paths may have unicode characters\n\t\tconst useUnicode = true\n\n\t\tr := nameToRegexp(name, useUnicode)\n\t\tfor _, p := range images {\n\t\t\tif regexpMatchesPath(r, p.Path) != -1 {\n\t\t\t\tif err := fn(ctx, p); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"processing image %s: %w\", p.GetTitle(), err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif len(images) < pp {\n\t\t\tbreak\n\t\t}\n\n\t\tlastID = images[len(images)-1].ID\n\t}\n\n\treturn nil\n}\n\nfunc PathToGalleriesFn(ctx context.Context, name string, paths []string, galleryReader models.GalleryQueryer, fn func(ctx context.Context, scene *models.Gallery) error) error {\n\tregex := getPathQueryRegex(name)\n\torganized := false\n\tfilter := models.GalleryFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    \"(?i)\" + regex,\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t},\n\t\tOrganized: &organized,\n\t}\n\n\tfilter.And = gallery.PathsFilter(paths)\n\n\t// do in batches\n\tpp := 1000\n\tsort := \"id\"\n\tsortDir := models.SortDirectionEnumAsc\n\tlastID := 0\n\n\tfor {\n\t\tif lastID != 0 {\n\t\t\tfilter.ID = &models.IntCriterionInput{\n\t\t\t\tValue:    lastID,\n\t\t\t\tModifier: models.CriterionModifierGreaterThan,\n\t\t\t}\n\t\t}\n\n\t\tgalleries, _, err := galleryReader.Query(ctx, &filter, &models.FindFilterType{\n\t\t\tPerPage:   &pp,\n\t\t\tSort:      &sort,\n\t\t\tDirection: &sortDir,\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error querying galleries with regex '%s': %s\", regex, err.Error())\n\t\t}\n\n\t\t// paths may have unicode characters\n\t\tconst useUnicode = true\n\n\t\tr := nameToRegexp(name, useUnicode)\n\t\tfor _, p := range galleries {\n\t\t\tpath := p.Path\n\t\t\tif path != \"\" && regexpMatchesPath(r, path) != -1 {\n\t\t\t\tif err := fn(ctx, p); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"processing gallery %s: %w\", p.GetTitle(), err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif len(galleries) < pp {\n\t\t\tbreak\n\t\t}\n\n\t\tlastID = galleries[len(galleries)-1].ID\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/match/path_test.go",
    "content": "package match\n\nimport \"testing\"\n\nfunc Test_nameMatchesPath(t *testing.T) {\n\tconst name = \"first last\"\n\tconst unicodeName = \"伏字\"\n\n\ttests := []struct {\n\t\ttestName string\n\t\tname     string\n\t\tpath     string\n\t\twant     int\n\t}{\n\t\t{\n\t\t\t\"exact\",\n\t\t\tname,\n\t\t\tname,\n\t\t\t0,\n\t\t},\n\t\t{\n\t\t\t\"partial\",\n\t\t\tname,\n\t\t\t\"first\",\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"separator\",\n\t\t\tname,\n\t\t\t\"first.last\",\n\t\t\t0,\n\t\t},\n\t\t{\n\t\t\t\"separator\",\n\t\t\tname,\n\t\t\t\"first-last\",\n\t\t\t0,\n\t\t},\n\t\t{\n\t\t\t\"separator\",\n\t\t\tname,\n\t\t\t\"first_last\",\n\t\t\t0,\n\t\t},\n\t\t{\n\t\t\t\"separators\",\n\t\t\tname,\n\t\t\t\"first.-_ last\",\n\t\t\t0,\n\t\t},\n\t\t{\n\t\t\t\"within string\",\n\t\t\tname,\n\t\t\t\"before_first last/after\",\n\t\t\t6,\n\t\t},\n\t\t{\n\t\t\t\"within string case insensitive\",\n\t\t\tname,\n\t\t\t\"before FIRST last/after\",\n\t\t\t6,\n\t\t},\n\t\t{\n\t\t\t\"not within string\",\n\t\t\tname,\n\t\t\t\"beforefirst last/after\",\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"not within string\",\n\t\t\tname,\n\t\t\t\"before/first lastafter\",\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"not within string\",\n\t\t\tname,\n\t\t\t\"first last1\",\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"not within string\",\n\t\t\tname,\n\t\t\t\"1first last\",\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"unicode\",\n\t\t\tunicodeName,\n\t\t\tunicodeName,\n\t\t\t0,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.testName, func(t *testing.T) {\n\t\t\tif got := nameMatchesPath(tt.name, tt.path); got != tt.want {\n\t\t\t\tt.Errorf(\"nameMatchesPath() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/match/scraped.go",
    "content": "package match\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/performer\"\n\t\"github.com/stashapp/stash/pkg/studio\"\n\t\"github.com/stashapp/stash/pkg/tag\"\n)\n\ntype PerformerFinder interface {\n\tmodels.PerformerQueryer\n\tFindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error)\n\tFindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error)\n}\n\ntype GroupNamesFinder interface {\n\tFindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error)\n}\n\ntype SceneRelationships struct {\n\tPerformerFinder PerformerFinder\n\tTagFinder       models.TagQueryer\n\tStudioFinder    StudioFinder\n}\n\n// MatchRelationships accepts a scraped scene and attempts to match its relationships to existing stash models.\nfunc (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.ScrapedScene, endpoint string) error {\n\tthisStudio := s.Studio\n\tfor thisStudio != nil {\n\t\tif err := ScrapedStudio(ctx, r.StudioFinder, thisStudio, endpoint); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tthisStudio = thisStudio.Parent\n\t}\n\n\tfor _, p := range s.Performers {\n\t\terr := ScrapedPerformer(ctx, r.PerformerFinder, p, endpoint)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, t := range s.Tags {\n\t\terr := ScrapedTag(ctx, r.TagFinder, t, endpoint)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// ScrapedPerformer matches the provided performer with the\n// performers in the database and sets the ID field if one is found.\nfunc ScrapedPerformer(ctx context.Context, qb PerformerFinder, p *models.ScrapedPerformer, stashBoxEndpoint string) error {\n\tif p.StoredID != nil || p.Name == nil {\n\t\treturn nil\n\t}\n\n\t// Check if a performer with the StashID already exists\n\tif stashBoxEndpoint != \"\" && p.RemoteSiteID != nil {\n\t\tperformers, err := qb.FindByStashID(ctx, models.StashID{\n\t\t\tStashID:  *p.RemoteSiteID,\n\t\t\tEndpoint: stashBoxEndpoint,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(performers) > 0 {\n\t\t\tid := strconv.Itoa(performers[0].ID)\n\t\t\tp.StoredID = &id\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tperformers, err := qb.FindByNames(ctx, []string{*p.Name}, true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(performers) == 0 {\n\t\t// if no names matched, try match an exact alias\n\t\tperformers, err = performer.ByAlias(ctx, qb, *p.Name)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(performers) != 1 {\n\t\t// ignore - cannot match\n\t\treturn nil\n\t}\n\n\tid := strconv.Itoa(performers[0].ID)\n\tp.StoredID = &id\n\treturn nil\n}\n\ntype StudioFinder interface {\n\tmodels.StudioQueryer\n\tFindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error)\n}\n\n// ScrapedStudio matches the provided studio with the studios\n// in the database and sets the ID field if one is found.\nfunc ScrapedStudio(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio, stashBoxEndpoint string) error {\n\tif s.StoredID != nil {\n\t\treturn nil\n\t}\n\n\t// Check if a studio with the StashID already exists\n\tif stashBoxEndpoint != \"\" && s.RemoteSiteID != nil {\n\t\tstudios, err := qb.FindByStashID(ctx, models.StashID{\n\t\t\tStashID:  *s.RemoteSiteID,\n\t\t\tEndpoint: stashBoxEndpoint,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(studios) > 0 {\n\t\t\tid := strconv.Itoa(studios[0].ID)\n\t\t\ts.StoredID = &id\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tst, err := studio.ByName(ctx, qb, s.Name)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif st == nil {\n\t\t// try matching by alias\n\t\tst, err = studio.ByAlias(ctx, qb, s.Name)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif st == nil {\n\t\t// ignore - cannot match\n\t\treturn nil\n\t}\n\n\tid := strconv.Itoa(st.ID)\n\ts.StoredID = &id\n\treturn nil\n}\n\n// ScrapedStudioHierarchy executes ScrapedStudio for the provided studio and its parents recursively.\nfunc ScrapedStudioHierarchy(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio, stashBoxEndpoint string) error {\n\tif err := ScrapedStudio(ctx, qb, s, stashBoxEndpoint); err != nil {\n\t\treturn err\n\t}\n\n\tif s.Parent == nil {\n\t\treturn nil\n\t}\n\n\treturn ScrapedStudioHierarchy(ctx, qb, s.Parent, stashBoxEndpoint)\n}\n\n// ScrapedGroup matches the provided movie with the movies\n// in the database and returns the ID field if one is found.\nfunc ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, name *string) (matchedID *string, err error) {\n\tif storedID != nil || name == nil {\n\t\treturn\n\t}\n\n\tmovies, err := qb.FindByNames(ctx, []string{*name}, true)\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif len(movies) != 1 {\n\t\t// ignore - cannot match\n\t\treturn\n\t}\n\n\tid := strconv.Itoa(movies[0].ID)\n\tmatchedID = &id\n\treturn\n}\n\n// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.\nfunc ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {\n\tif err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {\n\t\treturn err\n\t}\n\n\tif s.Parent == nil {\n\t\treturn nil\n\t}\n\n\t// Match parent by name only (categories don't have StashDB tag IDs)\n\treturn ScrapedTag(ctx, qb, s.Parent, \"\")\n}\n\n// ScrapedTag matches the provided tag with the tags\n// in the database and sets the ID field if one is found.\nfunc ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {\n\tif s.StoredID != nil {\n\t\treturn nil\n\t}\n\n\t// Check if a tag with the StashID already exists\n\tif stashBoxEndpoint != \"\" && s.RemoteSiteID != nil {\n\t\tif finder, ok := qb.(models.TagFinder); ok {\n\t\t\ttags, err := finder.FindByStashID(ctx, models.StashID{\n\t\t\t\tStashID:  *s.RemoteSiteID,\n\t\t\t\tEndpoint: stashBoxEndpoint,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(tags) > 0 {\n\t\t\t\tid := strconv.Itoa(tags[0].ID)\n\t\t\t\ts.StoredID = &id\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n\n\tt, err := tag.ByName(ctx, qb, s.Name)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif t == nil {\n\t\t// try matching by alias\n\t\tt, err = tag.ByAlias(ctx, qb, s.Name)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif t == nil {\n\t\t// ignore - cannot match\n\t\treturn nil\n\t}\n\n\tid := strconv.Itoa(t.ID)\n\ts.StoredID = &id\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/models/custom_fields.go",
    "content": "package models\n\nimport \"context\"\n\ntype CustomFieldMap map[string]interface{}\n\ntype CustomFieldsInput struct {\n\t// If populated, the entire custom fields map will be replaced with this value\n\tFull map[string]interface{} `json:\"full\"`\n\t// If populated, only the keys in this map will be updated\n\tPartial map[string]interface{} `json:\"partial\"`\n\t// Remove any keys in this list\n\tRemove []string `json:\"remove\"`\n}\n\ntype CustomFieldsReader interface {\n\tGetCustomFields(ctx context.Context, id int) (map[string]interface{}, error)\n\tGetCustomFieldsBulk(ctx context.Context, ids []int) ([]CustomFieldMap, error)\n}\n\ntype CustomFieldsWriter interface {\n\tSetCustomFields(ctx context.Context, id int, fields CustomFieldsInput) error\n}\n"
  },
  {
    "path": "pkg/models/date.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype DatePrecision int\n\nconst (\n\t// default precision is day\n\tDatePrecisionDay DatePrecision = iota\n\tDatePrecisionMonth\n\tDatePrecisionYear\n)\n\n// Date wraps a time.Time with a format of \"YYYY-MM-DD\"\ntype Date struct {\n\ttime.Time\n\tPrecision DatePrecision\n}\n\nvar dateFormatPrecision = []string{\n\t\"2006-01-02\",\n\t\"2006-01\",\n\t\"2006\",\n}\n\nfunc (d Date) String() string {\n\treturn d.Format(dateFormatPrecision[d.Precision])\n}\n\nfunc (d Date) After(o Date) bool {\n\treturn d.Time.After(o.Time)\n}\n\n// ParseDate tries to parse the input string into a date using utils.ParseDateStringAsTime.\n// If that fails, it attempts to parse the string with decreasing precision (month, then year).\n// It returns a Date struct with the appropriate precision set, or an error if all parsing attempts fail.\nfunc ParseDate(s string) (Date, error) {\n\tvar errs []error\n\n\t// default parse to day precision\n\tret, err := utils.ParseDateStringAsTime(s)\n\tif err == nil {\n\t\treturn Date{Time: ret, Precision: DatePrecisionDay}, nil\n\t}\n\n\terrs = append(errs, err)\n\n\t// try month and year precision\n\tfor i, format := range dateFormatPrecision[1:] {\n\t\tret, err := time.Parse(format, s)\n\t\tif err == nil {\n\t\t\treturn Date{Time: ret, Precision: DatePrecision(i + 1)}, nil\n\t\t}\n\t\terrs = append(errs, err)\n\t}\n\n\treturn Date{}, fmt.Errorf(\"failed to parse date %q: %v\", s, errs)\n}\n\nfunc DateFromYear(year int) Date {\n\treturn Date{\n\t\tTime:      time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC),\n\t\tPrecision: DatePrecisionYear,\n\t}\n}\n\nfunc FormatYearRange(start *Date, end *Date) string {\n\tvar (\n\t\tstartStr, endStr string\n\t)\n\n\tif start != nil {\n\t\tstartStr = start.Format(dateFormatPrecision[DatePrecisionYear])\n\t}\n\n\tif end != nil {\n\t\tendStr = end.Format(dateFormatPrecision[DatePrecisionYear])\n\t}\n\n\tswitch {\n\tcase startStr == \"\" && endStr == \"\":\n\t\treturn \"\"\n\tcase endStr == \"\":\n\t\treturn fmt.Sprintf(\"%s -\", startStr)\n\tcase startStr == \"\":\n\t\treturn fmt.Sprintf(\"- %s\", endStr)\n\tdefault:\n\t\treturn fmt.Sprintf(\"%s - %s\", startStr, endStr)\n\t}\n}\n\nfunc FormatYearRangeString(start *string, end *string) string {\n\tswitch {\n\tcase start == nil && end == nil:\n\t\treturn \"\"\n\tcase end == nil:\n\t\treturn fmt.Sprintf(\"%s -\", *start)\n\tcase start == nil:\n\t\treturn fmt.Sprintf(\"- %s\", *end)\n\tdefault:\n\t\treturn fmt.Sprintf(\"%s - %s\", *start, *end)\n\t}\n}\n\n// ParseYearRangeString parses a year range string into start and end year integers.\n// Supported formats: \"YYYY\", \"YYYY - YYYY\", \"YYYY-YYYY\", \"YYYY -\", \"- YYYY\", \"YYYY-present\".\n// Returns nil for start/end if not present in the string.\nfunc ParseYearRangeString(s string) (start *Date, end *Date, err error) {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn nil, nil, fmt.Errorf(\"empty year range string\")\n\t}\n\n\t// normalize \"present\" to empty end\n\tlower := strings.ToLower(s)\n\tlower = strings.ReplaceAll(lower, \"present\", \"\")\n\n\t// split on \"-\" if it contains one\n\tvar parts []string\n\tif strings.Contains(lower, \"-\") {\n\t\tparts = strings.SplitN(lower, \"-\", 2)\n\t} else {\n\t\t// single value, treat as start year\n\t\tyear, err := parseYear(lower)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"invalid year range %q: %w\", s, err)\n\t\t}\n\t\treturn year, nil, nil\n\t}\n\n\tstartStr := strings.TrimSpace(parts[0])\n\tendStr := strings.TrimSpace(parts[1])\n\n\tif startStr != \"\" {\n\t\ty, err := parseYear(startStr)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"invalid start year in %q: %w\", s, err)\n\t\t}\n\t\tstart = y\n\t}\n\n\tif endStr != \"\" {\n\t\ty, err := parseYear(endStr)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"invalid end year in %q: %w\", s, err)\n\t\t}\n\t\tend = y\n\t}\n\n\tif start == nil && end == nil {\n\t\treturn nil, nil, fmt.Errorf(\"could not parse year range %q\", s)\n\t}\n\n\treturn start, end, nil\n}\n\nfunc parseYear(s string) (*Date, error) {\n\tret, err := ParseDate(s)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing year %q: %w\", s, err)\n\t}\n\n\tyear := ret.Time.Year()\n\tif year < 1900 || year > 2200 {\n\t\treturn nil, fmt.Errorf(\"year %d out of reasonable range\", year)\n\t}\n\n\treturn &ret, nil\n}\n"
  },
  {
    "path": "pkg/models/date_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestParseDateStringAsTime(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tinput       string\n\t\toutput      Date\n\t\texpectError bool\n\t}{\n\t\t// Full date formats (existing support)\n\t\t{\"RFC3339\", \"2014-01-02T15:04:05Z\", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},\n\t\t{\"Date only\", \"2014-01-02\", Date{Time: time.Date(2014, 1, 2, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionDay}, false},\n\t\t{\"Date with time\", \"2014-01-02 15:04:05\", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},\n\n\t\t// Partial date formats (new support)\n\t\t{\"Year-Month\", \"2006-08\", Date{Time: time.Date(2006, 8, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionMonth}, false},\n\t\t{\"Year only\", \"2014\", Date{Time: time.Date(2014, 1, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionYear}, false},\n\n\t\t// Invalid formats\n\t\t{\"Invalid format\", \"not-a-date\", Date{}, true},\n\t\t{\"Empty string\", \"\", Date{}, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := ParseDate(tt.input)\n\n\t\t\tif tt.expectError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error for input %q, but got none\", tt.input)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Unexpected error for input %q: %v\", tt.input, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !result.Time.Equal(tt.output.Time) || result.Precision != tt.output.Precision {\n\t\t\t\tt.Errorf(\"For input %q, expected output %+v, got %+v\", tt.input, tt.output, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFormatYearRange(t *testing.T) {\n\tdatePtr := func(v int) *Date {\n\t\tdate := DateFromYear(v)\n\t\treturn &date\n\t}\n\n\ttests := []struct {\n\t\tname  string\n\t\tstart *Date\n\t\tend   *Date\n\t\twant  string\n\t}{\n\t\t{\"both nil\", nil, nil, \"\"},\n\t\t{\"only start\", datePtr(2005), nil, \"2005 -\"},\n\t\t{\"only end\", nil, datePtr(2010), \"- 2010\"},\n\t\t{\"start and end\", datePtr(2005), datePtr(2010), \"2005 - 2010\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := FormatYearRange(tt.start, tt.end)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestFormatYearRangeString(t *testing.T) {\n\tstringPtr := func(v string) *string { return &v }\n\n\ttests := []struct {\n\t\tname  string\n\t\tstart *string\n\t\tend   *string\n\t\twant  string\n\t}{\n\t\t{\"both nil\", nil, nil, \"\"},\n\t\t{\"only start\", stringPtr(\"2005\"), nil, \"2005 -\"},\n\t\t{\"only end\", nil, stringPtr(\"2010\"), \"- 2010\"},\n\t\t{\"start and end\", stringPtr(\"2005\"), stringPtr(\"2010\"), \"2005 - 2010\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := FormatYearRangeString(tt.start, tt.end)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestParseYearRangeString(t *testing.T) {\n\tintPtr := func(v int) *int { return &v }\n\n\ttests := []struct {\n\t\tname      string\n\t\tinput     string\n\t\twantStart *int\n\t\twantEnd   *int\n\t\twantErr   bool\n\t}{\n\t\t{\"single year\", \"2005\", intPtr(2005), nil, false},\n\t\t{\"year range with spaces\", \"2005 - 2010\", intPtr(2005), intPtr(2010), false},\n\t\t{\"year range no spaces\", \"2005-2010\", intPtr(2005), intPtr(2010), false},\n\t\t{\"year dash open\", \"2005 -\", intPtr(2005), nil, false},\n\t\t{\"year dash open no space\", \"2005-\", intPtr(2005), nil, false},\n\t\t{\"dash year\", \"- 2010\", nil, intPtr(2010), false},\n\t\t{\"year present\", \"2005-present\", intPtr(2005), nil, false},\n\t\t{\"year Present caps\", \"2005 - Present\", intPtr(2005), nil, false},\n\t\t{\"whitespace padding\", \"  2005 - 2010  \", intPtr(2005), intPtr(2010), false},\n\t\t{\"empty string\", \"\", nil, nil, true},\n\t\t{\"garbage\", \"not a year\", nil, nil, true},\n\t\t{\"partial garbage start\", \"abc - 2010\", nil, nil, true},\n\t\t{\"partial garbage end\", \"2005 - abc\", nil, nil, true},\n\t\t{\"year out of range\", \"1800\", nil, nil, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstart, end, err := ParseYearRangeString(tt.input)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.NoError(t, err)\n\t\t\tif tt.wantStart != nil {\n\t\t\t\tassert.NotNil(t, start)\n\t\t\t\tassert.Equal(t, *tt.wantStart, start.Time.Year())\n\t\t\t} else {\n\t\t\t\tassert.Nil(t, start)\n\t\t\t}\n\t\t\tif tt.wantEnd != nil {\n\t\t\t\tassert.NotNil(t, end)\n\t\t\t\tassert.Equal(t, *tt.wantEnd, end.Time.Year())\n\t\t\t} else {\n\t\t\t\tassert.Nil(t, end)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/models/doc.go",
    "content": "// Package models provides application models that are used throughout the application.\npackage models\n"
  },
  {
    "path": "pkg/models/errors.go",
    "content": "package models\n\nimport \"errors\"\n\nvar (\n\t// ErrNotFound signifies entities which are not found\n\tErrNotFound = errors.New(\"not found\")\n\n\t// ErrConversion signifies conversion errors\n\tErrConversion = errors.New(\"conversion error\")\n\n\tErrScraperSource = errors.New(\"invalid ScraperSource\")\n)\n"
  },
  {
    "path": "pkg/models/file.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\ntype FileQueryOptions struct {\n\tQueryOptions\n\tFileFilter *FileFilterType\n\n\tTotalDuration bool\n\tMegapixels    bool\n\tTotalSize     bool\n}\n\ntype FileFilterType struct {\n\tOperatorFilter[FileFilterType]\n\n\t// Filter by path\n\tPath *StringCriterionInput `json:\"path\"`\n\n\tBasename        *StringCriterionInput            `json:\"basename\"`\n\tDir             *StringCriterionInput            `json:\"dir\"`\n\tParentFolder    *HierarchicalMultiCriterionInput `json:\"parent_folder\"`\n\tZipFile         *MultiCriterionInput             `json:\"zip_file\"`\n\tModTime         *TimestampCriterionInput         `json:\"mod_time\"`\n\tDuplicated      *FileDuplicationCriterionInput   `json:\"duplicated\"`\n\tHashes          []*FingerprintFilterInput        `json:\"hashes\"`\n\tVideoFileFilter *VideoFileFilterInput            `json:\"video_file_filter\"`\n\tImageFileFilter *ImageFileFilterInput            `json:\"image_file_filter\"`\n\tSceneCount      *IntCriterionInput               `json:\"scene_count\"`\n\tImageCount      *IntCriterionInput               `json:\"image_count\"`\n\tGalleryCount    *IntCriterionInput               `json:\"gallery_count\"`\n\tScenesFilter    *SceneFilterType                 `json:\"scenes_filter\"`\n\tImagesFilter    *ImageFilterType                 `json:\"images_filter\"`\n\tGalleriesFilter *GalleryFilterType               `json:\"galleries_filter\"`\n\tCreatedAt       *TimestampCriterionInput         `json:\"created_at\"`\n\tUpdatedAt       *TimestampCriterionInput         `json:\"updated_at\"`\n}\n\nfunc PathsFileFilter(paths []string) *FileFilterType {\n\tif paths == nil {\n\t\treturn nil\n\t}\n\n\tsep := string(filepath.Separator)\n\n\tvar ret *FileFilterType\n\tvar or *FileFilterType\n\tfor _, p := range paths {\n\t\tnewOr := &FileFilterType{}\n\t\tif or != nil {\n\t\t\tor.Or = newOr\n\t\t} else {\n\t\t\tret = newOr\n\t\t}\n\n\t\tor = newOr\n\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tor.Path = &StringCriterionInput{\n\t\t\tModifier: CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\treturn ret\n}\n\ntype FileQueryResult struct {\n\tQueryResult[FileID]\n\tTotalDuration float64\n\tMegapixels    float64\n\tTotalSize     int64\n\n\tgetter     FileGetter\n\tfiles      []File\n\tresolveErr error\n}\n\nfunc NewFileQueryResult(fileGetter FileGetter) *FileQueryResult {\n\treturn &FileQueryResult{\n\t\tgetter: fileGetter,\n\t}\n}\n\nfunc (r *FileQueryResult) Resolve(ctx context.Context) ([]File, error) {\n\t// cache results\n\tif r.files == nil && r.resolveErr == nil {\n\t\tr.files, r.resolveErr = r.getter.Find(ctx, r.IDs...)\n\t}\n\treturn r.files, r.resolveErr\n}\n"
  },
  {
    "path": "pkg/models/filename_parser.go",
    "content": "package models\n\ntype SceneParserInput struct {\n\tIgnoreWords          []string `json:\"ignoreWords\"`\n\tWhitespaceCharacters *string  `json:\"whitespaceCharacters\"`\n\tCapitalizeTitle      *bool    `json:\"capitalizeTitle\"`\n\tIgnoreOrganized      *bool    `json:\"ignoreOrganized\"`\n}\n\ntype SceneParserResult struct {\n\tScene        *Scene          `json:\"scene\"`\n\tTitle        *string         `json:\"title\"`\n\tCode         *string         `json:\"code\"`\n\tDetails      *string         `json:\"details\"`\n\tDirector     *string         `json:\"director\"`\n\tURL          *string         `json:\"url\"`\n\tDate         *string         `json:\"date\"`\n\tRating       *int            `json:\"rating\"`\n\tRating100    *int            `json:\"rating100\"`\n\tStudioID     *string         `json:\"studio_id\"`\n\tGalleryIds   []string        `json:\"gallery_ids\"`\n\tPerformerIds []string        `json:\"performer_ids\"`\n\tMovies       []*SceneMovieID `json:\"movies\"`\n\tTagIds       []string        `json:\"tag_ids\"`\n}\n\ntype SceneMovieID struct {\n\tMovieID    string  `json:\"movie_id\"`\n\tSceneIndex *string `json:\"scene_index\"`\n}\n"
  },
  {
    "path": "pkg/models/filter.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype OperatorFilter[T any] struct {\n\tAnd *T `json:\"AND\"`\n\tOr  *T `json:\"OR\"`\n\tNot *T `json:\"NOT\"`\n}\n\n// SubFilter returns the subfilter of the operator filter.\n// Only one of And, Or, or Not should be set, so it returns the first of these that are not nil.\nfunc (f *OperatorFilter[T]) SubFilter() *T {\n\tif f.And != nil {\n\t\treturn f.And\n\t}\n\tif f.Or != nil {\n\t\treturn f.Or\n\t}\n\tif f.Not != nil {\n\t\treturn f.Not\n\t}\n\treturn nil\n}\n\ntype CriterionModifier string\n\nconst (\n\t// =\n\tCriterionModifierEquals CriterionModifier = \"EQUALS\"\n\t// !=\n\tCriterionModifierNotEquals CriterionModifier = \"NOT_EQUALS\"\n\t// >\n\tCriterionModifierGreaterThan CriterionModifier = \"GREATER_THAN\"\n\t// <\n\tCriterionModifierLessThan CriterionModifier = \"LESS_THAN\"\n\t// IS NULL\n\tCriterionModifierIsNull CriterionModifier = \"IS_NULL\"\n\t// IS NOT NULL\n\tCriterionModifierNotNull CriterionModifier = \"NOT_NULL\"\n\t// INCLUDES ALL\n\tCriterionModifierIncludesAll CriterionModifier = \"INCLUDES_ALL\"\n\tCriterionModifierIncludes    CriterionModifier = \"INCLUDES\"\n\tCriterionModifierExcludes    CriterionModifier = \"EXCLUDES\"\n\t// MATCHES REGEX\n\tCriterionModifierMatchesRegex CriterionModifier = \"MATCHES_REGEX\"\n\t// NOT MATCHES REGEX\n\tCriterionModifierNotMatchesRegex CriterionModifier = \"NOT_MATCHES_REGEX\"\n\t// >= AND <=\n\tCriterionModifierBetween CriterionModifier = \"BETWEEN\"\n\t// < OR >\n\tCriterionModifierNotBetween CriterionModifier = \"NOT_BETWEEN\"\n)\n\nvar AllCriterionModifier = []CriterionModifier{\n\tCriterionModifierEquals,\n\tCriterionModifierNotEquals,\n\tCriterionModifierGreaterThan,\n\tCriterionModifierLessThan,\n\tCriterionModifierIsNull,\n\tCriterionModifierNotNull,\n\tCriterionModifierIncludesAll,\n\tCriterionModifierIncludes,\n\tCriterionModifierExcludes,\n\tCriterionModifierMatchesRegex,\n\tCriterionModifierNotMatchesRegex,\n\tCriterionModifierBetween,\n\tCriterionModifierNotBetween,\n}\n\nfunc (e CriterionModifier) IsValid() bool {\n\tswitch e {\n\tcase CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierIncludesAll, CriterionModifierIncludes, CriterionModifierExcludes, CriterionModifierMatchesRegex, CriterionModifierNotMatchesRegex, CriterionModifierBetween, CriterionModifierNotBetween:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e CriterionModifier) String() string {\n\treturn string(e)\n}\n\nfunc (e *CriterionModifier) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = CriterionModifier(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid CriterionModifier\", str)\n\t}\n\treturn nil\n}\n\nfunc (e CriterionModifier) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype StringCriterionInput struct {\n\tValue    string            `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\nfunc (i StringCriterionInput) ValidModifier() bool {\n\tswitch i.Modifier {\n\tcase CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierIncludes, CriterionModifierExcludes, CriterionModifierMatchesRegex, CriterionModifierNotMatchesRegex,\n\t\tCriterionModifierIsNull, CriterionModifierNotNull:\n\t\treturn true\n\t}\n\n\treturn false\n}\n\ntype IntCriterionInput struct {\n\tValue    int               `json:\"value\"`\n\tValue2   *int              `json:\"value2\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\nfunc (i IntCriterionInput) ValidModifier() bool {\n\tswitch i.Modifier {\n\tcase CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween:\n\t\treturn true\n\t}\n\treturn false\n}\n\ntype FloatCriterionInput struct {\n\tValue    float64           `json:\"value\"`\n\tValue2   *float64          `json:\"value2\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\nfunc (i FloatCriterionInput) ValidModifier() bool {\n\tswitch i.Modifier {\n\tcase CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween:\n\t\treturn true\n\t}\n\treturn false\n}\n\ntype ResolutionCriterionInput struct {\n\tValue    ResolutionEnum    `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype HierarchicalMultiCriterionInput struct {\n\tValue    []string          `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n\tDepth    *int              `json:\"depth\"`\n\tExcludes []string          `json:\"excludes\"`\n}\n\nfunc (i HierarchicalMultiCriterionInput) CombineExcludes() HierarchicalMultiCriterionInput {\n\tii := i\n\tif ii.Modifier == CriterionModifierExcludes {\n\t\tii.Modifier = CriterionModifierIncludesAll\n\t\tii.Excludes = append(ii.Excludes, ii.Value...)\n\t\tii.Value = nil\n\t}\n\n\treturn ii\n}\n\ntype MultiCriterionInput struct {\n\tValue    []string          `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n\tExcludes []string          `json:\"excludes\"`\n}\n\ntype DateCriterionInput struct {\n\tValue    string            `json:\"value\"`\n\tValue2   *string           `json:\"value2\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype TimestampCriterionInput struct {\n\tValue    string            `json:\"value\"`\n\tValue2   *string           `json:\"value2\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype PhashDistanceCriterionInput struct {\n\tValue    string            `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n\tDistance *int              `json:\"distance\"`\n}\n\ntype OrientationCriterionInput struct {\n\tValue []OrientationEnum `json:\"value\"`\n}\n\ntype CustomFieldCriterionInput struct {\n\tField    string            `json:\"field\"`\n\tValue    []any             `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype FingerprintFilterInput struct {\n\tType  string `json:\"type\"`\n\tValue string `json:\"value\"`\n\t// Hamming distance - defaults to 0\n\tDistance *int `json:\"distance,omitempty\"`\n}\n\ntype VideoFileFilterInput struct {\n\tFormat      *StringCriterionInput      `json:\"format,omitempty\"`\n\tResolution  *ResolutionCriterionInput  `json:\"resolution,omitempty\"`\n\tOrientation *OrientationCriterionInput `json:\"orientation,omitempty\"`\n\tFramerate   *IntCriterionInput         `json:\"framerate,omitempty\"`\n\tBitrate     *IntCriterionInput         `json:\"bitrate,omitempty\"`\n\tVideoCodec  *StringCriterionInput      `json:\"video_codec,omitempty\"`\n\tAudioCodec  *StringCriterionInput      `json:\"audio_codec,omitempty\"`\n\t// in seconds\n\tDuration         *IntCriterionInput    `json:\"duration,omitempty\"`\n\tCaptions         *StringCriterionInput `json:\"captions,omitempty\"`\n\tInteractive      *bool                 `json:\"interactive,omitempty\"`\n\tInteractiveSpeed *IntCriterionInput    `json:\"interactive_speed,omitempty\"`\n}\n\ntype ImageFileFilterInput struct {\n\tFormat      *StringCriterionInput      `json:\"format,omitempty\"`\n\tResolution  *ResolutionCriterionInput  `json:\"resolution,omitempty\"`\n\tOrientation *OrientationCriterionInput `json:\"orientation,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/models/find_filter.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\n// PerPageAll is the value used for perPage to indicate all results should be\n// returned.\nconst PerPageAll = -1\n\ntype SortDirectionEnum string\n\nconst (\n\tSortDirectionEnumAsc  SortDirectionEnum = \"ASC\"\n\tSortDirectionEnumDesc SortDirectionEnum = \"DESC\"\n)\n\nvar AllSortDirectionEnum = []SortDirectionEnum{\n\tSortDirectionEnumAsc,\n\tSortDirectionEnumDesc,\n}\n\nfunc (e SortDirectionEnum) IsValid() bool {\n\tswitch e {\n\tcase SortDirectionEnumAsc, SortDirectionEnumDesc:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e SortDirectionEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *SortDirectionEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = SortDirectionEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid SortDirectionEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e SortDirectionEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype FindFilterType struct {\n\tQ    *string `json:\"q\"`\n\tPage *int    `json:\"page\"`\n\t// use per_page = -1 to indicate all results. Defaults to 25.\n\tPerPage   *int               `json:\"per_page\"`\n\tSort      *string            `json:\"sort\"`\n\tDirection *SortDirectionEnum `json:\"direction\"`\n}\n\nfunc (ff FindFilterType) GetSort(defaultSort string) string {\n\tvar sort string\n\tif ff.Sort == nil {\n\t\tsort = defaultSort\n\t} else {\n\t\tsort = *ff.Sort\n\t}\n\treturn sort\n}\n\nfunc (ff FindFilterType) GetDirection() string {\n\tvar direction string\n\tif directionFilter := ff.Direction; directionFilter != nil {\n\t\tif dir := directionFilter.String(); directionFilter.IsValid() {\n\t\t\tdirection = dir\n\t\t} else {\n\t\t\tdirection = \"ASC\"\n\t\t}\n\t} else {\n\t\tdirection = \"ASC\"\n\t}\n\treturn direction\n}\n\nfunc (ff FindFilterType) GetPage() int {\n\tconst defaultPage = 1\n\tif ff.Page == nil || *ff.Page < 1 {\n\t\treturn defaultPage\n\t}\n\n\treturn *ff.Page\n}\n\nfunc (ff FindFilterType) GetPageSize() int {\n\tconst defaultPerPage = 25\n\tconst minPerPage = 0\n\n\tif ff.PerPage == nil {\n\t\treturn defaultPerPage\n\t}\n\n\t// removed the maxPerPage check. We already all -1 to indicate all results\n\t// so there is no conceivable reason we should limit the page size\n\n\tif *ff.PerPage < minPerPage {\n\t\t// negative page sizes should return all results\n\t\t// this is a sanity check in case GetPageSize is\n\t\t// called with a negative page size.\n\t\treturn minPerPage\n\t}\n\n\treturn *ff.PerPage\n}\n\nfunc (ff FindFilterType) IsGetAll() bool {\n\treturn ff.PerPage != nil && *ff.PerPage < 0\n}\n\n// BatchFindFilter returns a FindFilterType suitable for batch finding\n// using the provided batch size.\nfunc BatchFindFilter(batchSize int) *FindFilterType {\n\tpage := 1\n\treturn &FindFilterType{\n\t\tPerPage: &batchSize,\n\t\tPage:    &page,\n\t}\n}\n"
  },
  {
    "path": "pkg/models/fingerprint.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n)\n\nvar (\n\tFingerprintTypeOshash = \"oshash\"\n\tFingerprintTypeMD5    = \"md5\"\n\tFingerprintTypePhash  = \"phash\"\n)\n\n// Fingerprint represents a fingerprint of a file.\ntype Fingerprint struct {\n\tType        string\n\tFingerprint interface{}\n}\n\nfunc (f *Fingerprint) Value() string {\n\tswitch v := f.Fingerprint.(type) {\n\tcase int64:\n\t\treturn strconv.FormatUint(uint64(v), 16)\n\tdefault:\n\t\treturn fmt.Sprintf(\"%v\", f.Fingerprint)\n\t}\n}\n\n// String returns the string representation of the Fingerprint.\n// It will return an empty string if the Fingerprint is not a string.\nfunc (f Fingerprint) String() string {\n\ts, _ := f.Fingerprint.(string)\n\treturn s\n}\n\n// Int64 returns the int64 representation of the Fingerprint.\n// It will return 0 if the Fingerprint is not an int64.\nfunc (f Fingerprint) Int64() int64 {\n\tv, _ := f.Fingerprint.(int64)\n\treturn v\n}\n\ntype Fingerprints []Fingerprint\n\nfunc (f Fingerprints) Remove(type_ string) Fingerprints {\n\tvar ret Fingerprints\n\n\tfor _, ff := range f {\n\t\tif ff.Type != type_ {\n\t\t\tret = append(ret, ff)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (f Fingerprints) Filter(types ...string) Fingerprints {\n\tvar ret Fingerprints\n\n\tfor _, ff := range f {\n\t\tfor _, t := range types {\n\t\t\tif ff.Type == t {\n\t\t\t\tret = append(ret, ff)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// Equals returns true if the contents of this slice are equal to those in the other slice.\nfunc (f Fingerprints) Equals(other Fingerprints) bool {\n\tif len(f) != len(other) {\n\t\treturn false\n\t}\n\n\tfor _, ff := range f {\n\t\tfound := false\n\t\tfor _, oo := range other {\n\t\t\tif ff == oo {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\n// ContentsChanged returns true if this Fingerprints slice contains any Fingerprints that different Fingerprint values for the matching type in other, or if this slice contains any Fingerprint types that are not in other.\nfunc (f Fingerprints) ContentsChanged(other Fingerprints) bool {\n\tfor _, ff := range f {\n\t\too := other.For(ff.Type)\n\t\tif oo == nil || oo.Fingerprint != ff.Fingerprint {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// For returns a pointer to the first Fingerprint element matching the provided type.\nfunc (f Fingerprints) For(type_ string) *Fingerprint {\n\tfor _, fp := range f {\n\t\tif fp.Type == type_ {\n\t\t\treturn &fp\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (f Fingerprints) Get(type_ string) interface{} {\n\tfp := f.For(type_)\n\tif fp == nil {\n\t\treturn nil\n\t}\n\treturn fp.Fingerprint\n}\n\nfunc (f Fingerprints) GetString(type_ string) string {\n\tfp := f.For(type_)\n\tif fp == nil {\n\t\treturn \"\"\n\t}\n\treturn fp.String()\n}\n\nfunc (f Fingerprints) GetInt64(type_ string) int64 {\n\tfp := f.For(type_)\n\tif fp != nil {\n\t\treturn 0\n\t}\n\treturn fp.Int64()\n}\n\n// AppendUnique appends a fingerprint to the list if a Fingerprint of the same type does not already exist in the list. If one does, then it is updated with o's Fingerprint value.\nfunc (f Fingerprints) AppendUnique(o Fingerprint) Fingerprints {\n\tret := f\n\tfor i, fp := range ret {\n\t\tif fp.Type == o.Type {\n\t\t\tret[i] = o\n\t\t\treturn ret\n\t\t}\n\t}\n\n\treturn append(f, o)\n}\n"
  },
  {
    "path": "pkg/models/fingerprint_test.go",
    "content": "package models\n\nimport \"testing\"\n\nfunc TestFingerprints_Equals(t *testing.T) {\n\tvar (\n\t\tvalue1 = 1\n\t\tvalue2 = \"2\"\n\t\tvalue3 = 1.23\n\n\t\tfingerprint1 = Fingerprint{\n\t\t\tType:        FingerprintTypeMD5,\n\t\t\tFingerprint: value1,\n\t\t}\n\t\tfingerprint2 = Fingerprint{\n\t\t\tType:        FingerprintTypeOshash,\n\t\t\tFingerprint: value2,\n\t\t}\n\t\tfingerprint3 = Fingerprint{\n\t\t\tType:        FingerprintTypePhash,\n\t\t\tFingerprint: value3,\n\t\t}\n\t)\n\n\ttests := []struct {\n\t\tname  string\n\t\tf     Fingerprints\n\t\tother Fingerprints\n\t\twant  bool\n\t}{\n\t\t{\n\t\t\t\"identical\",\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"different order\",\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\tFingerprints{\n\t\t\t\tfingerprint2,\n\t\t\t\tfingerprint1,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"different length\",\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"different\",\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint3,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := tt.f.Equals(tt.other); got != tt.want {\n\t\t\t\tt.Errorf(\"Fingerprints.Equals() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFingerprints_ContentsChanged(t *testing.T) {\n\tvar (\n\t\tvalue1 = 1\n\t\tvalue2 = \"2\"\n\t\tvalue3 = 1.23\n\n\t\tfingerprint1 = Fingerprint{\n\t\t\tType:        FingerprintTypeMD5,\n\t\t\tFingerprint: value1,\n\t\t}\n\t\tfingerprint2 = Fingerprint{\n\t\t\tType:        FingerprintTypeOshash,\n\t\t\tFingerprint: value2,\n\t\t}\n\t\tfingerprint3 = Fingerprint{\n\t\t\tType:        FingerprintTypeMD5,\n\t\t\tFingerprint: value3,\n\t\t}\n\t)\n\n\ttests := []struct {\n\t\tname  string\n\t\tf     Fingerprints\n\t\tother Fingerprints\n\t\twant  bool\n\t}{\n\t\t{\n\t\t\t\"identical\",\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"has new\",\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"has different value\",\n\t\t\tFingerprints{\n\t\t\t\tfingerprint3,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\tFingerprints{\n\t\t\t\tfingerprint1,\n\t\t\t\tfingerprint2,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := tt.f.ContentsChanged(tt.other); got != tt.want {\n\t\t\t\tt.Errorf(\"Fingerprints.ContentsChanged() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/models/folder.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\ntype FolderQueryOptions struct {\n\tQueryOptions\n\tFolderFilter *FolderFilterType\n\n\tTotalDuration bool\n\tMegapixels    bool\n\tTotalSize     bool\n}\n\ntype FolderFilterType struct {\n\tOperatorFilter[FolderFilterType]\n\n\tPath         *StringCriterionInput            `json:\"path,omitempty\"`\n\tBasename     *StringCriterionInput            `json:\"basename,omitempty\"`\n\tParentFolder *HierarchicalMultiCriterionInput `json:\"parent_folder,omitempty\"`\n\tZipFile      *MultiCriterionInput             `json:\"zip_file,omitempty\"`\n\t// Filter by modification time\n\tModTime      *TimestampCriterionInput `json:\"mod_time,omitempty\"`\n\tGalleryCount *IntCriterionInput       `json:\"gallery_count,omitempty\"`\n\t// Filter by files that meet this criteria\n\tFilesFilter *FileFilterType `json:\"files_filter,omitempty\"`\n\t// Filter by related galleries that meet this criteria\n\tGalleriesFilter *GalleryFilterType `json:\"galleries_filter,omitempty\"`\n\t// Filter by creation time\n\tCreatedAt *TimestampCriterionInput `json:\"created_at,omitempty\"`\n\t// Filter by last update time\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at,omitempty\"`\n}\n\nfunc PathsFolderFilter(paths []string) *FileFilterType {\n\tif paths == nil {\n\t\treturn nil\n\t}\n\n\tsep := string(filepath.Separator)\n\n\tvar ret *FileFilterType\n\tvar or *FileFilterType\n\tfor _, p := range paths {\n\t\tnewOr := &FileFilterType{}\n\t\tif or != nil {\n\t\t\tor.Or = newOr\n\t\t} else {\n\t\t\tret = newOr\n\t\t}\n\n\t\tor = newOr\n\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tor.Path = &StringCriterionInput{\n\t\t\tModifier: CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\treturn ret\n}\n\ntype FolderQueryResult struct {\n\tQueryResult[FolderID]\n\n\tgetter     FolderGetter\n\tfolders    []*Folder\n\tresolveErr error\n}\n\nfunc NewFolderQueryResult(folderGetter FolderGetter) *FolderQueryResult {\n\treturn &FolderQueryResult{\n\t\tgetter: folderGetter,\n\t}\n}\n\nfunc (r *FolderQueryResult) Resolve(ctx context.Context) ([]*Folder, error) {\n\t// cache results\n\tif r.folders == nil && r.resolveErr == nil {\n\t\tr.folders, r.resolveErr = r.getter.FindMany(ctx, r.IDs)\n\t}\n\treturn r.folders, r.resolveErr\n}\n"
  },
  {
    "path": "pkg/models/fs.go",
    "content": "package models\n\nimport (\n\t\"io\"\n\t\"io/fs\"\n)\n\n// FileOpener provides an interface to open a file.\ntype FileOpener interface {\n\tOpen() (io.ReadCloser, error)\n}\n\n// FS represents a file system.\ntype FS interface {\n\tStat(name string) (fs.FileInfo, error)\n\tLstat(name string) (fs.FileInfo, error)\n\tOpen(name string) (fs.ReadDirFile, error)\n\tOpenZip(name string, size int64) (ZipFS, error)\n\tIsPathCaseSensitive(path string) (bool, error)\n}\n\n// ZipFS represents a zip file system.\ntype ZipFS interface {\n\tFS\n\tio.Closer\n\tOpenOnly(name string) (io.ReadCloser, error)\n}\n"
  },
  {
    "path": "pkg/models/gallery.go",
    "content": "package models\n\ntype GalleryFilterType struct {\n\tOperatorFilter[GalleryFilterType]\n\tID           *IntCriterionInput    `json:\"id\"`\n\tTitle        *StringCriterionInput `json:\"title\"`\n\tCode         *StringCriterionInput `json:\"code\"`\n\tDetails      *StringCriterionInput `json:\"details\"`\n\tPhotographer *StringCriterionInput `json:\"photographer\"`\n\t// Filter by file checksum\n\tChecksum *StringCriterionInput `json:\"checksum\"`\n\t// Filter by path\n\tPath *StringCriterionInput `json:\"path\"`\n\t// Filter by parent folder\n\tParentFolder *HierarchicalMultiCriterionInput `json:\"parent_folder,omitempty\"`\n\t// Filter by zip file count\n\tFileCount *IntCriterionInput `json:\"file_count\"`\n\t// Filter to only include galleries missing this property\n\tIsMissing *string `json:\"is_missing\"`\n\t// Filter to include/exclude galleries that were created from zip\n\tIsZip *bool `json:\"is_zip\"`\n\t// Filter by rating expressed as 1-100\n\tRating100 *IntCriterionInput `json:\"rating100\"`\n\t// Filter by organized\n\tOrganized *bool `json:\"organized\"`\n\t// Filter by average image resolution\n\tAverageResolution *ResolutionCriterionInput `json:\"average_resolution\"`\n\t// Filter to only include scenes which have chapters. `true` or `false`\n\tHasChapters *string `json:\"has_chapters\"`\n\t// Filter to only include galleries with these scenes\n\tScenes *MultiCriterionInput `json:\"scenes\"`\n\t// Filter to only include galleries with this studio\n\tStudios *HierarchicalMultiCriterionInput `json:\"studios\"`\n\t// Filter to only include galleries with these tags\n\tTags *HierarchicalMultiCriterionInput `json:\"tags\"`\n\t// Filter by tag count\n\tTagCount *IntCriterionInput `json:\"tag_count\"`\n\t// Filter to only include galleries with performers with these tags\n\tPerformerTags *HierarchicalMultiCriterionInput `json:\"performer_tags\"`\n\t// Filter to only include galleries with these performers\n\tPerformers *MultiCriterionInput `json:\"performers\"`\n\t// Filter by performer count\n\tPerformerCount *IntCriterionInput `json:\"performer_count\"`\n\t// Filter galleries that have performers that have been favorited\n\tPerformerFavorite *bool `json:\"performer_favorite\"`\n\t// Filter galleries by performer age at time of gallery\n\tPerformerAge *IntCriterionInput `json:\"performer_age\"`\n\t// Filter by number of images in this gallery\n\tImageCount *IntCriterionInput `json:\"image_count\"`\n\t// Filter by url\n\tURL *StringCriterionInput `json:\"url\"`\n\t// Filter by date\n\tDate *DateCriterionInput `json:\"date\"`\n\t// Filter by related scenes that meet this criteria\n\tScenesFilter *SceneFilterType `json:\"scenes_filter\"`\n\t// Filter by related images that meet this criteria\n\tImagesFilter *ImageFilterType `json:\"images_filter\"`\n\t// Filter by related performers that meet this criteria\n\tPerformersFilter *PerformerFilterType `json:\"performers_filter\"`\n\t// Filter by related studios that meet this criteria\n\tStudiosFilter *StudioFilterType `json:\"studios_filter\"`\n\t// Filter by related tags that meet this criteria\n\tTagsFilter *TagFilterType `json:\"tags_filter\"`\n\t// Filter by related files that meet this criteria\n\tFilesFilter *FileFilterType `json:\"files_filter\"`\n\t// Filter by related folders that meet this criteria\n\tFoldersFilter *FolderFilterType `json:\"folders_filter\"`\n\t// Filter by created at\n\tCreatedAt *TimestampCriterionInput `json:\"created_at\"`\n\t// Filter by updated at\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at\"`\n\n\t// Filter by custom fields\n\tCustomFields []CustomFieldCriterionInput `json:\"custom_fields\"`\n}\n\ntype GalleryUpdateInput struct {\n\tClientMutationID *string  `json:\"clientMutationId\"`\n\tID               string   `json:\"id\"`\n\tTitle            *string  `json:\"title\"`\n\tCode             *string  `json:\"code\"`\n\tUrls             []string `json:\"urls\"`\n\tDate             *string  `json:\"date\"`\n\tDetails          *string  `json:\"details\"`\n\tPhotographer     *string  `json:\"photographer\"`\n\tRating100        *int     `json:\"rating100\"`\n\tOrganized        *bool    `json:\"organized\"`\n\tSceneIds         []string `json:\"scene_ids\"`\n\tStudioID         *string  `json:\"studio_id\"`\n\tTagIds           []string `json:\"tag_ids\"`\n\tPerformerIds     []string `json:\"performer_ids\"`\n\tPrimaryFileID    *string  `json:\"primary_file_id\"`\n\n\tCustomFields *CustomFieldsInput `json:\"custom_fields\"`\n\n\t// deprecated\n\tURL *string `json:\"url\"`\n}\n\ntype GalleryDestroyInput struct {\n\tIds []string `json:\"ids\"`\n\t// If true, then the zip file will be deleted if the gallery is zip-file-based.\n\t// If gallery is folder-based, then any files not associated with other\n\t// galleries will be deleted, along with the folder, if it is not empty.\n\tDeleteFile       *bool `json:\"delete_file\"`\n\tDeleteGenerated  *bool `json:\"delete_generated\"`\n\tDestroyFileEntry *bool `json:\"destroy_file_entry\"`\n}\n"
  },
  {
    "path": "pkg/models/generate.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype GenerateMetadataOptions struct {\n\tCovers                    bool                    `json:\"covers\"`\n\tSprites                   bool                    `json:\"sprites\"`\n\tPreviews                  bool                    `json:\"previews\"`\n\tImagePreviews             bool                    `json:\"imagePreviews\"`\n\tPreviewOptions            *GeneratePreviewOptions `json:\"previewOptions\"`\n\tMarkers                   bool                    `json:\"markers\"`\n\tMarkerImagePreviews       bool                    `json:\"markerImagePreviews\"`\n\tMarkerScreenshots         bool                    `json:\"markerScreenshots\"`\n\tTranscodes                bool                    `json:\"transcodes\"`\n\tPhashes                   bool                    `json:\"phashes\"`\n\tInteractiveHeatmapsSpeeds bool                    `json:\"interactiveHeatmapsSpeeds\"`\n\tImageThumbnails           bool                    `json:\"imageThumbnails\"`\n\tClipPreviews              bool                    `json:\"clipPreviews\"`\n}\n\ntype GeneratePreviewOptions struct {\n\t// Number of segments in a preview file\n\tPreviewSegments *int `json:\"previewSegments\"`\n\t// Preview segment duration, in seconds\n\tPreviewSegmentDuration *float64 `json:\"previewSegmentDuration\"`\n\t// Duration of start of video to exclude when generating previews\n\tPreviewExcludeStart *string `json:\"previewExcludeStart\"`\n\t// Duration of end of video to exclude when generating previews\n\tPreviewExcludeEnd *string `json:\"previewExcludeEnd\"`\n\t// Preset when generating preview\n\tPreviewPreset *PreviewPreset `json:\"previewPreset\"`\n}\n\ntype PreviewPreset string\n\nconst (\n\t// X264_ULTRAFAST\n\tPreviewPresetUltrafast PreviewPreset = \"ultrafast\"\n\t// X264_VERYFAST\n\tPreviewPresetVeryfast PreviewPreset = \"veryfast\"\n\t// X264_FAST\n\tPreviewPresetFast PreviewPreset = \"fast\"\n\t// X264_MEDIUM\n\tPreviewPresetMedium PreviewPreset = \"medium\"\n\t// X264_SLOW\n\tPreviewPresetSlow PreviewPreset = \"slow\"\n\t// X264_SLOWER\n\tPreviewPresetSlower PreviewPreset = \"slower\"\n\t// X264_VERYSLOW\n\tPreviewPresetVeryslow PreviewPreset = \"veryslow\"\n)\n\nvar AllPreviewPreset = []PreviewPreset{\n\tPreviewPresetUltrafast,\n\tPreviewPresetVeryfast,\n\tPreviewPresetFast,\n\tPreviewPresetMedium,\n\tPreviewPresetSlow,\n\tPreviewPresetSlower,\n\tPreviewPresetVeryslow,\n}\n\nfunc (e PreviewPreset) IsValid() bool {\n\tswitch e {\n\tcase PreviewPresetUltrafast, PreviewPresetVeryfast, PreviewPresetFast, PreviewPresetMedium, PreviewPresetSlow, PreviewPresetSlower, PreviewPresetVeryslow:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e PreviewPreset) String() string {\n\treturn string(e)\n}\n\nfunc (e *PreviewPreset) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = PreviewPreset(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid PreviewPreset\", str)\n\t}\n\treturn nil\n}\n\nfunc (e PreviewPreset) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n"
  },
  {
    "path": "pkg/models/group.go",
    "content": "package models\n\ntype GroupFilterType struct {\n\tOperatorFilter[GroupFilterType]\n\tName     *StringCriterionInput `json:\"name\"`\n\tDirector *StringCriterionInput `json:\"director\"`\n\tSynopsis *StringCriterionInput `json:\"synopsis\"`\n\t// Filter by duration (in seconds)\n\tDuration *IntCriterionInput `json:\"duration\"`\n\t// Filter by rating expressed as 1-100\n\tRating100 *IntCriterionInput `json:\"rating100\"`\n\t// Filter to only include movies with this studio\n\tStudios *HierarchicalMultiCriterionInput `json:\"studios\"`\n\t// Filter to only include movies missing this property\n\tIsMissing *string `json:\"is_missing\"`\n\t// Filter by url\n\tURL *StringCriterionInput `json:\"url\"`\n\t// Filter to only include movies where performer appears in a scene\n\tPerformers *MultiCriterionInput `json:\"performers\"`\n\t// Filter to only include performers with these tags\n\tTags *HierarchicalMultiCriterionInput `json:\"tags\"`\n\t// Filter by tag count\n\tTagCount *IntCriterionInput `json:\"tag_count\"`\n\t// Filter by date\n\tDate *DateCriterionInput `json:\"date\"`\n\t// Filter by O counter\n\tOCounter *IntCriterionInput `json:\"o_counter\"`\n\t// Filter by containing groups\n\tContainingGroups *HierarchicalMultiCriterionInput `json:\"containing_groups\"`\n\t// Filter by sub groups\n\tSubGroups *HierarchicalMultiCriterionInput `json:\"sub_groups\"`\n\t// Filter by number of containing groups the group has\n\tContainingGroupCount *IntCriterionInput `json:\"containing_group_count\"`\n\t// Filter by number of sub-groups the group has\n\tSubGroupCount *IntCriterionInput `json:\"sub_group_count\"`\n\t// Filter by number of scenes the group has\n\tSceneCount *IntCriterionInput `json:\"scene_count\"`\n\t// Filter by related scenes that meet this criteria\n\tScenesFilter *SceneFilterType `json:\"scenes_filter\"`\n\t// Filter by related studios that meet this criteria\n\tStudiosFilter *StudioFilterType `json:\"studios_filter\"`\n\t// Filter by created at\n\tCreatedAt *TimestampCriterionInput `json:\"created_at\"`\n\t// Filter by updated at\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at\"`\n\t// Filter by custom fields\n\tCustomFields []CustomFieldCriterionInput `json:\"custom_fields\"`\n}\n"
  },
  {
    "path": "pkg/models/image.go",
    "content": "package models\n\nimport (\n\t\"context\"\n)\n\ntype ImageFilterType struct {\n\tOperatorFilter[ImageFilterType]\n\tID           *IntCriterionInput    `json:\"id\"`\n\tTitle        *StringCriterionInput `json:\"title\"`\n\tCode         *StringCriterionInput `json:\"code\"`\n\tDetails      *StringCriterionInput `json:\"details\"`\n\tPhotographer *StringCriterionInput `json:\"photographer\"`\n\t// Filter by file checksum\n\tChecksum *StringCriterionInput `json:\"checksum\"`\n\t// Filter by phash distance\n\tPhashDistance *PhashDistanceCriterionInput `json:\"phash_distance\"`\n\t// Filter by path\n\tPath *StringCriterionInput `json:\"path\"`\n\t// Filter by file count\n\tFileCount *IntCriterionInput `json:\"file_count\"`\n\t// Filter by rating expressed as 1-100\n\tRating100 *IntCriterionInput `json:\"rating100\"`\n\t// Filter by date\n\tDate *DateCriterionInput `json:\"date\"`\n\t// Filter by url\n\tURL *StringCriterionInput `json:\"url\"`\n\t// Filter by organized\n\tOrganized *bool `json:\"organized\"`\n\t// Filter by o-counter\n\tOCounter *IntCriterionInput `json:\"o_counter\"`\n\t// Filter by resolution\n\tResolution *ResolutionCriterionInput `json:\"resolution\"`\n\t// Filter by landscape/portrait\n\tOrientation *OrientationCriterionInput `json:\"orientation\"`\n\t// Filter to only include images missing this property\n\tIsMissing *string `json:\"is_missing\"`\n\t// Filter to only include images with this studio\n\tStudios *HierarchicalMultiCriterionInput `json:\"studios\"`\n\t// Filter to only include images with these tags\n\tTags *HierarchicalMultiCriterionInput `json:\"tags\"`\n\t// Filter by tag count\n\tTagCount *IntCriterionInput `json:\"tag_count\"`\n\t// Filter to only include images with performers with these tags\n\tPerformerTags *HierarchicalMultiCriterionInput `json:\"performer_tags\"`\n\t// Filter to only include images with these performers\n\tPerformers *MultiCriterionInput `json:\"performers\"`\n\t// Filter by performer count\n\tPerformerCount *IntCriterionInput `json:\"performer_count\"`\n\t// Filter images that have performers that have been favorited\n\tPerformerFavorite *bool `json:\"performer_favorite\"`\n\t// Filter images by performer age at time of image\n\tPerformerAge *IntCriterionInput `json:\"performer_age\"`\n\t// Filter to only include images with these galleries\n\tGalleries *MultiCriterionInput `json:\"galleries\"`\n\t// Filter by related galleries that meet this criteria\n\tGalleriesFilter *GalleryFilterType `json:\"galleries_filter\"`\n\t// Filter by related performers that meet this criteria\n\tPerformersFilter *PerformerFilterType `json:\"performers_filter\"`\n\t// Filter by related studios that meet this criteria\n\tStudiosFilter *StudioFilterType `json:\"studios_filter\"`\n\t// Filter by related tags that meet this criteria\n\tTagsFilter *TagFilterType `json:\"tags_filter\"`\n\t// Filter by related files that meet this criteria\n\tFilesFilter *FileFilterType `json:\"files_filter\"`\n\t// Filter by created at\n\tCreatedAt *TimestampCriterionInput `json:\"created_at\"`\n\t// Filter by updated at\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at\"`\n\t// Filter by custom fields\n\tCustomFields []CustomFieldCriterionInput `json:\"custom_fields\"`\n}\n\ntype ImageUpdateInput struct {\n\tClientMutationID *string            `json:\"clientMutationId\"`\n\tID               string             `json:\"id\"`\n\tTitle            *string            `json:\"title\"`\n\tCode             *string            `json:\"code\"`\n\tUrls             []string           `json:\"urls\"`\n\tDate             *string            `json:\"date\"`\n\tDetails          *string            `json:\"details\"`\n\tPhotographer     *string            `json:\"photographer\"`\n\tRating100        *int               `json:\"rating100\"`\n\tOrganized        *bool              `json:\"organized\"`\n\tSceneIds         []string           `json:\"scene_ids\"`\n\tStudioID         *string            `json:\"studio_id\"`\n\tTagIds           []string           `json:\"tag_ids\"`\n\tPerformerIds     []string           `json:\"performer_ids\"`\n\tGalleryIds       []string           `json:\"gallery_ids\"`\n\tPrimaryFileID    *string            `json:\"primary_file_id\"`\n\tCustomFields     *CustomFieldsInput `json:\"custom_fields\"`\n\n\t// deprecated\n\tURL *string `json:\"url\"`\n}\n\ntype ImageDestroyInput struct {\n\tID               string `json:\"id\"`\n\tDeleteFile       *bool  `json:\"delete_file\"`\n\tDeleteGenerated  *bool  `json:\"delete_generated\"`\n\tDestroyFileEntry *bool  `json:\"destroy_file_entry\"`\n}\n\ntype ImagesDestroyInput struct {\n\tIds              []string `json:\"ids\"`\n\tDeleteFile       *bool    `json:\"delete_file\"`\n\tDeleteGenerated  *bool    `json:\"delete_generated\"`\n\tDestroyFileEntry *bool    `json:\"destroy_file_entry\"`\n}\n\ntype ImageQueryOptions struct {\n\tQueryOptions\n\tImageFilter *ImageFilterType\n\n\tMegapixels bool\n\tTotalSize  bool\n}\n\ntype ImageQueryResult struct {\n\tQueryResult[int]\n\tMegapixels float64\n\tTotalSize  float64\n\n\tgetter     ImageGetter\n\timages     []*Image\n\tresolveErr error\n}\n\nfunc NewImageQueryResult(getter ImageGetter) *ImageQueryResult {\n\treturn &ImageQueryResult{\n\t\tgetter: getter,\n\t}\n}\n\nfunc (r *ImageQueryResult) Resolve(ctx context.Context) ([]*Image, error) {\n\t// cache results\n\tif r.images == nil && r.resolveErr == nil {\n\t\tr.images, r.resolveErr = r.getter.FindMany(ctx, r.IDs)\n\t}\n\treturn r.images, r.resolveErr\n}\n"
  },
  {
    "path": "pkg/models/import.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype ImportMissingRefEnum string\n\nconst (\n\tImportMissingRefEnumIgnore ImportMissingRefEnum = \"IGNORE\"\n\tImportMissingRefEnumFail   ImportMissingRefEnum = \"FAIL\"\n\tImportMissingRefEnumCreate ImportMissingRefEnum = \"CREATE\"\n)\n\nvar AllImportMissingRefEnum = []ImportMissingRefEnum{\n\tImportMissingRefEnumIgnore,\n\tImportMissingRefEnumFail,\n\tImportMissingRefEnumCreate,\n}\n\nfunc (e ImportMissingRefEnum) IsValid() bool {\n\tswitch e {\n\tcase ImportMissingRefEnumIgnore, ImportMissingRefEnumFail, ImportMissingRefEnumCreate:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e ImportMissingRefEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *ImportMissingRefEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = ImportMissingRefEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid ImportMissingRefEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e ImportMissingRefEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n"
  },
  {
    "path": "pkg/models/json/json_time.go",
    "content": "// Package json provides generic JSON types.\npackage json\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nvar currentLocation = time.Now().Location()\n\ntype JSONTime struct {\n\ttime.Time\n}\n\nfunc (jt *JSONTime) UnmarshalJSON(b []byte) error {\n\ts := strings.Trim(string(b), \"\\\"\")\n\tif s == \"null\" {\n\t\tjt.Time = time.Time{}\n\t\treturn nil\n\t}\n\n\t// #731 - returning an error here causes the entire JSON parse to fail for ffprobe.\n\tjt.Time, _ = utils.ParseDateStringAsTime(s)\n\treturn nil\n}\n\nfunc (jt *JSONTime) MarshalJSON() ([]byte, error) {\n\tif jt.Time.IsZero() {\n\t\treturn []byte(\"null\"), nil\n\t}\n\treturn []byte(fmt.Sprintf(\"\\\"%s\\\"\", jt.Time.Format(time.RFC3339))), nil\n}\n\nfunc (jt JSONTime) GetTime() time.Time {\n\tif currentLocation != nil {\n\t\tif jt.IsZero() {\n\t\t\treturn time.Now().In(currentLocation)\n\t\t} else {\n\t\t\treturn jt.Time.In(currentLocation)\n\t\t}\n\t} else {\n\t\tif jt.IsZero() {\n\t\t\treturn time.Now()\n\t\t} else {\n\t\t\treturn jt.Time\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/doc.go",
    "content": "// Package jsonschema provides the JSON schema models used for importing and exporting data.\npackage jsonschema\n"
  },
  {
    "path": "pkg/models/jsonschema/file_folder.go",
    "content": "package jsonschema\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/stashapp/stash/pkg/hash/md5\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\nconst (\n\tDirEntryTypeFolder = \"folder\"\n\tDirEntryTypeVideo  = \"video\"\n\tDirEntryTypeImage  = \"image\"\n\tDirEntryTypeFile   = \"file\"\n)\n\ntype DirEntry interface {\n\tIsFile() bool\n\tFilename() string\n\tDirEntry() *BaseDirEntry\n}\n\ntype BaseDirEntry struct {\n\tZipFile string        `json:\"zip_file,omitempty\"`\n\tModTime json.JSONTime `json:\"mod_time\"`\n\n\tType string `json:\"type,omitempty\"`\n\n\tPath string `json:\"path,omitempty\"`\n\n\tCreatedAt json.JSONTime `json:\"created_at,omitempty\"`\n\tUpdatedAt json.JSONTime `json:\"updated_at,omitempty\"`\n}\n\nfunc (f *BaseDirEntry) DirEntry() *BaseDirEntry {\n\treturn f\n}\n\nfunc (f *BaseDirEntry) IsFile() bool {\n\treturn false\n}\n\nfunc (f *BaseDirEntry) Filename() string {\n\t// prefix with the path depth so that we can import lower-level files/folders first\n\tdepth := strings.Count(f.Path, string(filepath.Separator))\n\n\t// hash the full path for a unique filename\n\thash := md5.FromString(f.Path)\n\n\tbasename := filepath.Base(f.Path)\n\n\treturn fmt.Sprintf(\"%02x.%s.%s.json\", depth, basename, hash)\n}\n\ntype BaseFile struct {\n\tBaseDirEntry\n\n\tFingerprints []Fingerprint `json:\"fingerprints,omitempty\"`\n\tSize         int64         `json:\"size\"`\n}\n\nfunc (f *BaseFile) IsFile() bool {\n\treturn true\n}\n\ntype Fingerprint struct {\n\tType        string      `json:\"type,omitempty\"`\n\tFingerprint interface{} `json:\"fingerprint,omitempty\"`\n}\n\ntype VideoFile struct {\n\t*BaseFile\n\tFormat     string  `json:\"format,omitempty\"`\n\tWidth      int     `json:\"width,omitempty\"`\n\tHeight     int     `json:\"height,omitempty\"`\n\tDuration   float64 `json:\"duration,omitempty\"`\n\tVideoCodec string  `json:\"video_codec,omitempty\"`\n\tAudioCodec string  `json:\"audio_codec,omitempty\"`\n\tFrameRate  float64 `json:\"frame_rate,omitempty\"`\n\tBitRate    int64   `json:\"bitrate,omitempty\"`\n\n\tInteractive      bool `json:\"interactive,omitempty\"`\n\tInteractiveSpeed *int `json:\"interactive_speed,omitempty\"`\n}\n\ntype ImageFile struct {\n\t*BaseFile\n\tFormat string `json:\"format,omitempty\"`\n\tWidth  int    `json:\"width,omitempty\"`\n\tHeight int    `json:\"height,omitempty\"`\n}\n\nfunc LoadFileFile(filePath string) (DirEntry, error) {\n\tr, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer r.Close()\n\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(bytes.NewReader(data))\n\n\tvar bf BaseDirEntry\n\tif err := jsonParser.Decode(&bf); err != nil {\n\t\treturn nil, err\n\t}\n\n\tjsonParser = json.NewDecoder(bytes.NewReader(data))\n\n\tswitch bf.Type {\n\tcase DirEntryTypeFolder:\n\t\treturn &bf, nil\n\tcase DirEntryTypeVideo:\n\t\tvar vf VideoFile\n\t\tif err := jsonParser.Decode(&vf); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn &vf, nil\n\tcase DirEntryTypeImage:\n\t\tvar imf ImageFile\n\t\tif err := jsonParser.Decode(&imf); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn &imf, nil\n\tcase DirEntryTypeFile:\n\t\tvar bff BaseFile\n\t\tif err := jsonParser.Decode(&bff); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn &bff, nil\n\tdefault:\n\t\treturn nil, errors.New(\"unknown file type\")\n\t}\n}\n\nfunc SaveFileFile(filePath string, file DirEntry) error {\n\tif file == nil {\n\t\treturn fmt.Errorf(\"file must not be nil\")\n\t}\n\treturn marshalToFile(filePath, file)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/folder.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/stashapp/stash/pkg/hash/md5\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\ntype Folder struct {\n\tBaseDirEntry\n\n\tPath string `json:\"path,omitempty\"`\n\n\tCreatedAt json.JSONTime `json:\"created_at,omitempty\"`\n\tUpdatedAt json.JSONTime `json:\"updated_at,omitempty\"`\n}\n\nfunc (f *Folder) Filename() string {\n\t// prefix with the path depth so that we can import lower-level folders first\n\tdepth := strings.Count(f.Path, string(filepath.Separator))\n\n\t// hash the full path for a unique filename\n\thash := md5.FromString(f.Path)\n\n\tbasename := filepath.Base(f.Path)\n\n\treturn fmt.Sprintf(\"%2x.%s.%s.json\", depth, basename, hash)\n}\n\nfunc LoadFolderFile(filePath string) (*Folder, error) {\n\tvar folder Folder\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(file)\n\terr = jsonParser.Decode(&folder)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &folder, nil\n}\n\nfunc SaveFolderFile(filePath string, folder *Folder) error {\n\tif folder == nil {\n\t\treturn fmt.Errorf(\"folder must not be nil\")\n\t}\n\treturn marshalToFile(filePath, folder)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/gallery.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\ntype GalleryChapter struct {\n\tTitle      string        `json:\"title,omitempty\"`\n\tImageIndex int           `json:\"image_index,omitempty\"`\n\tCreatedAt  json.JSONTime `json:\"created_at,omitempty\"`\n\tUpdatedAt  json.JSONTime `json:\"updated_at,omitempty\"`\n}\n\ntype Gallery struct {\n\tZipFiles     []string               `json:\"zip_files,omitempty\"`\n\tFolderPath   string                 `json:\"folder_path,omitempty\"`\n\tTitle        string                 `json:\"title,omitempty\"`\n\tCode         string                 `json:\"code,omitempty\"`\n\tURLs         []string               `json:\"urls,omitempty\"`\n\tDate         string                 `json:\"date,omitempty\"`\n\tDetails      string                 `json:\"details,omitempty\"`\n\tPhotographer string                 `json:\"photographer,omitempty\"`\n\tRating       int                    `json:\"rating,omitempty\"`\n\tOrganized    bool                   `json:\"organized,omitempty\"`\n\tChapters     []GalleryChapter       `json:\"chapters,omitempty\"`\n\tStudio       string                 `json:\"studio,omitempty\"`\n\tPerformers   []string               `json:\"performers,omitempty\"`\n\tTags         []string               `json:\"tags,omitempty\"`\n\tCreatedAt    json.JSONTime          `json:\"created_at,omitempty\"`\n\tUpdatedAt    json.JSONTime          `json:\"updated_at,omitempty\"`\n\tCustomFields map[string]interface{} `json:\"custom_fields,omitempty\"`\n\n\t// deprecated - for import only\n\tURL string `json:\"url,omitempty\"`\n}\n\nfunc (s Gallery) Filename(basename string, hash string) string {\n\tret := fsutil.SanitiseBasename(basename)\n\n\tif ret != \"\" {\n\t\tret += \".\"\n\t}\n\tret += hash\n\n\treturn ret + \".json\"\n}\n\nfunc LoadGalleryFile(filePath string) (*Gallery, error) {\n\tvar gallery Gallery\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(file)\n\terr = jsonParser.Decode(&gallery)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &gallery, nil\n}\n\nfunc SaveGalleryFile(filePath string, gallery *Gallery) error {\n\tif gallery == nil {\n\t\treturn fmt.Errorf(\"gallery must not be nil\")\n\t}\n\treturn marshalToFile(filePath, gallery)\n}\n\n// GalleryRef is used to identify a Gallery.\n// Only one field should be populated.\ntype GalleryRef struct {\n\tZipFiles   []string `json:\"zip_files,omitempty\"`\n\tFolderPath string   `json:\"folder_path,omitempty\"`\n\t// Title is used only if FolderPath and ZipPaths is empty\n\tTitle string `json:\"title,omitempty\"`\n}\n\nfunc (r GalleryRef) String() string {\n\tswitch {\n\tcase r.FolderPath != \"\":\n\t\treturn \"{ folder: \" + r.FolderPath + \" }\"\n\tcase len(r.ZipFiles) > 0:\n\t\treturn \"{ zipFiles: [\" + strings.Join(r.ZipFiles, \", \") + \"] }\"\n\tdefault:\n\t\treturn \"{ title: \" + r.Title + \" }\"\n\t}\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/group.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\ntype SubGroupDescription struct {\n\tGroup       string `json:\"name,omitempty\"`\n\tDescription string `json:\"description,omitempty\"`\n}\n\ntype Group struct {\n\tName       string                `json:\"name,omitempty\"`\n\tAliases    string                `json:\"aliases,omitempty\"`\n\tDuration   int                   `json:\"duration,omitempty\"`\n\tDate       string                `json:\"date,omitempty\"`\n\tRating     int                   `json:\"rating,omitempty\"`\n\tDirector   string                `json:\"director,omitempty\"`\n\tSynopsis   string                `json:\"synopsis,omitempty\"`\n\tFrontImage string                `json:\"front_image,omitempty\"`\n\tBackImage  string                `json:\"back_image,omitempty\"`\n\tURLs       []string              `json:\"urls,omitempty\"`\n\tStudio     string                `json:\"studio,omitempty\"`\n\tTags       []string              `json:\"tags,omitempty\"`\n\tSubGroups  []SubGroupDescription `json:\"sub_groups,omitempty\"`\n\tCreatedAt  json.JSONTime         `json:\"created_at,omitempty\"`\n\tUpdatedAt  json.JSONTime         `json:\"updated_at,omitempty\"`\n\n\tCustomFields map[string]interface{} `json:\"custom_fields,omitempty\"`\n\n\t// deprecated - for import only\n\tURL string `json:\"url,omitempty\"`\n}\n\nfunc (s Group) Filename() string {\n\treturn fsutil.SanitiseBasename(s.Name) + \".json\"\n}\n\n// Backwards Compatible synopsis for the movie\ntype MovieSynopsisBC struct {\n\tSynopsis string `json:\"sypnopsis,omitempty\"`\n}\n\nfunc LoadGroupFile(filePath string) (*Group, error) {\n\tvar movie Group\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(file)\n\terr = jsonParser.Decode(&movie)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif movie.Synopsis == \"\" {\n\t\t// keep backwards compatibility with pre #2664 builds\n\t\t// attempt to get the synopsis from the alternate (sypnopsis) key\n\n\t\t_, err = file.Seek(0, 0) // seek to start of file\n\t\tif err == nil {\n\t\t\tvar synopsis MovieSynopsisBC\n\t\t\terr = jsonParser.Decode(&synopsis)\n\t\t\tif err == nil {\n\t\t\t\tmovie.Synopsis = synopsis.Synopsis\n\t\t\t\tif movie.Synopsis != \"\" {\n\t\t\t\t\tlogger.Debug(\"Movie synopsis retrieved from alternate key\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn &movie, nil\n}\n\nfunc SaveGroupFile(filePath string, movie *Group) error {\n\tif movie == nil {\n\t\treturn fmt.Errorf(\"movie must not be nil\")\n\t}\n\treturn marshalToFile(filePath, movie)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/image.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\ntype Image struct {\n\tTitle  string `json:\"title,omitempty\"`\n\tCode   string `json:\"code,omitempty\"`\n\tStudio string `json:\"studio,omitempty\"`\n\tRating int    `json:\"rating,omitempty\"`\n\n\t// deprecated - for import only\n\tURL string `json:\"url,omitempty\"`\n\n\tURLs         []string               `json:\"urls,omitempty\"`\n\tDate         string                 `json:\"date,omitempty\"`\n\tDetails      string                 `json:\"details,omitempty\"`\n\tPhotographer string                 `json:\"photographer,omitempty\"`\n\tOrganized    bool                   `json:\"organized,omitempty\"`\n\tOCounter     int                    `json:\"o_counter,omitempty\"`\n\tGalleries    []GalleryRef           `json:\"galleries,omitempty\"`\n\tPerformers   []string               `json:\"performers,omitempty\"`\n\tTags         []string               `json:\"tags,omitempty\"`\n\tFiles        []string               `json:\"files,omitempty\"`\n\tCreatedAt    json.JSONTime          `json:\"created_at,omitempty\"`\n\tUpdatedAt    json.JSONTime          `json:\"updated_at,omitempty\"`\n\tCustomFields map[string]interface{} `json:\"custom_fields,omitempty\"`\n}\n\nfunc (s Image) Filename(basename string, hash string) string {\n\tret := fsutil.SanitiseBasename(s.Title)\n\tif ret == \"\" {\n\t\tret = basename\n\t}\n\n\tif hash != \"\" {\n\t\tret += \".\" + hash\n\t}\n\n\treturn ret + \".json\"\n}\n\nfunc LoadImageFile(filePath string) (*Image, error) {\n\tvar image Image\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(file)\n\terr = jsonParser.Decode(&image)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &image, nil\n}\n\nfunc SaveImageFile(filePath string, image *Image) error {\n\tif image == nil {\n\t\treturn fmt.Errorf(\"image must not be nil\")\n\t}\n\treturn marshalToFile(filePath, image)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/load.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\nfunc loadFile[T any](filePath string) (*T, error) {\n\tvar ret T\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(file)\n\terr = jsonParser.Decode(&ret)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ret, nil\n}\n\nfunc saveFile[T any](filePath string, obj *T) error {\n\tif obj == nil {\n\t\treturn fmt.Errorf(\"object must not be nil\")\n\t}\n\treturn marshalToFile(filePath, obj)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/performer.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n)\n\ntype StringOrStringList []string\n\nfunc (s *StringOrStringList) UnmarshalJSON(data []byte) error {\n\tvar stringList []string\n\tvar stringVal string\n\n\terr := jsoniter.Unmarshal(data, &stringList)\n\tif err == nil {\n\t\t*s = stringList\n\t\treturn nil\n\t}\n\n\terr = jsoniter.Unmarshal(data, &stringVal)\n\tif err == nil {\n\t\t*s = stringslice.FromString(stringVal, \",\")\n\t\treturn nil\n\t}\n\n\treturn err\n}\n\ntype Performer struct {\n\tName           string   `json:\"name,omitempty\"`\n\tDisambiguation string   `json:\"disambiguation,omitempty\"`\n\tGender         string   `json:\"gender,omitempty\"`\n\tURLs           []string `json:\"urls,omitempty\"`\n\tBirthdate      string   `json:\"birthdate,omitempty\"`\n\tEthnicity      string   `json:\"ethnicity,omitempty\"`\n\tCountry        string   `json:\"country,omitempty\"`\n\tEyeColor       string   `json:\"eye_color,omitempty\"`\n\t// this should be int, but keeping string for backwards compatibility\n\tHeight        string             `json:\"height,omitempty\"`\n\tMeasurements  string             `json:\"measurements,omitempty\"`\n\tFakeTits      string             `json:\"fake_tits,omitempty\"`\n\tPenisLength   float64            `json:\"penis_length,omitempty\"`\n\tCircumcised   string             `json:\"circumcised,omitempty\"`\n\tCareerLength  string             `json:\"career_length,omitempty\"` // deprecated - for import only\n\tCareerStart   string             `json:\"career_start,omitempty\"`\n\tCareerEnd     string             `json:\"career_end,omitempty\"`\n\tTattoos       string             `json:\"tattoos,omitempty\"`\n\tPiercings     string             `json:\"piercings,omitempty\"`\n\tAliases       StringOrStringList `json:\"aliases,omitempty\"`\n\tFavorite      bool               `json:\"favorite,omitempty\"`\n\tTags          []string           `json:\"tags,omitempty\"`\n\tImage         string             `json:\"image,omitempty\"`\n\tCreatedAt     json.JSONTime      `json:\"created_at,omitempty\"`\n\tUpdatedAt     json.JSONTime      `json:\"updated_at,omitempty\"`\n\tRating        int                `json:\"rating,omitempty\"`\n\tDetails       string             `json:\"details,omitempty\"`\n\tDeathDate     string             `json:\"death_date,omitempty\"`\n\tHairColor     string             `json:\"hair_color,omitempty\"`\n\tWeight        int                `json:\"weight,omitempty\"`\n\tStashIDs      []models.StashID   `json:\"stash_ids,omitempty\"`\n\tIgnoreAutoTag bool               `json:\"ignore_auto_tag,omitempty\"`\n\n\tCustomFields map[string]interface{} `json:\"custom_fields,omitempty\"`\n\n\t// deprecated - for import only\n\tURL       string `json:\"url,omitempty\"`\n\tTwitter   string `json:\"twitter,omitempty\"`\n\tInstagram string `json:\"instagram,omitempty\"`\n}\n\nfunc (s Performer) Filename() string {\n\tname := s.Name\n\tif s.Disambiguation != \"\" {\n\t\tname += \"_\" + s.Disambiguation\n\t}\n\treturn fsutil.SanitiseBasename(name) + \".json\"\n}\n\nfunc LoadPerformerFile(filePath string) (*Performer, error) {\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\treturn loadPerformer(file)\n}\n\nfunc loadPerformer(r io.ReadSeeker) (*Performer, error) {\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(r)\n\n\tvar performer Performer\n\tif err := jsonParser.Decode(&performer); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &performer, nil\n}\n\nfunc SavePerformerFile(filePath string, performer *Performer) error {\n\tif performer == nil {\n\t\treturn fmt.Errorf(\"performer must not be nil\")\n\t}\n\treturn marshalToFile(filePath, performer)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/performer_test.go",
    "content": "package jsonschema\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_loadPerformer(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tinput   string\n\t\twant    Performer\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"alias list\",\n\t\t\tinput: `\n{\n\t\"aliases\": [\"alias1\", \"alias2\"]\n}`,\n\t\t\twant: Performer{\n\t\t\t\tAliases: []string{\"alias1\", \"alias2\"},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"alias string list\",\n\t\t\tinput: `\n{\n\t\"aliases\": \"alias1, alias2\"\n}`,\n\t\t\twant: Performer{\n\t\t\t\tAliases: []string{\"alias1\", \"alias2\"},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tr := strings.NewReader(tt.input)\n\t\t\tgot, err := loadPerformer(r)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"loadPerformer() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(t, &tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/saved_filter.go",
    "content": "package jsonschema\n\nimport (\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype SavedFilter struct {\n\tMode         models.FilterMode      `db:\"mode\" json:\"mode\"`\n\tName         string                 `db:\"name\" json:\"name\"`\n\tFindFilter   *models.FindFilterType `json:\"find_filter\"`\n\tObjectFilter map[string]interface{} `json:\"object_filter\"`\n\tUIOptions    map[string]interface{} `json:\"ui_options\"`\n}\n\nfunc (s SavedFilter) Filename() string {\n\tret := fsutil.SanitiseBasename(s.Name + \"_\" + s.Mode.String())\n\treturn ret + \".json\"\n}\n\nfunc LoadSavedFilterFile(filePath string) (*SavedFilter, error) {\n\treturn loadFile[SavedFilter](filePath)\n}\n\nfunc SaveSavedFilterFile(filePath string, image *SavedFilter) error {\n\treturn saveFile[SavedFilter](filePath, image)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/scene.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\ntype SceneMarker struct {\n\tTitle      string        `json:\"title,omitempty\"`\n\tSeconds    string        `json:\"seconds,omitempty\"`\n\tEndSeconds string        `json:\"end_seconds,omitempty\"`\n\tPrimaryTag string        `json:\"primary_tag,omitempty\"`\n\tTags       []string      `json:\"tags,omitempty\"`\n\tCreatedAt  json.JSONTime `json:\"created_at,omitempty\"`\n\tUpdatedAt  json.JSONTime `json:\"updated_at,omitempty\"`\n}\n\ntype SceneFile struct {\n\tModTime    json.JSONTime `json:\"mod_time,omitempty\"`\n\tSize       string        `json:\"size\"`\n\tDuration   string        `json:\"duration\"`\n\tVideoCodec string        `json:\"video_codec\"`\n\tAudioCodec string        `json:\"audio_codec\"`\n\tFormat     string        `json:\"format\"`\n\tWidth      int           `json:\"width\"`\n\tHeight     int           `json:\"height\"`\n\tFramerate  string        `json:\"framerate\"`\n\tBitrate    int           `json:\"bitrate\"`\n}\n\ntype SceneGroup struct {\n\tGroupName  string `json:\"movieName,omitempty\"`\n\tSceneIndex int    `json:\"scene_index,omitempty\"`\n}\n\ntype Scene struct {\n\tTitle  string `json:\"title,omitempty\"`\n\tCode   string `json:\"code,omitempty\"`\n\tStudio string `json:\"studio,omitempty\"`\n\n\t// deprecated - for import only\n\tURL string `json:\"url,omitempty\"`\n\n\tURLs      []string `json:\"urls,omitempty\"`\n\tDate      string   `json:\"date,omitempty\"`\n\tRating    int      `json:\"rating,omitempty\"`\n\tOrganized bool     `json:\"organized,omitempty\"`\n\n\t// deprecated - for import only\n\tOCounter int `json:\"o_counter,omitempty\"`\n\n\tDetails    string        `json:\"details,omitempty\"`\n\tDirector   string        `json:\"director,omitempty\"`\n\tGalleries  []GalleryRef  `json:\"galleries,omitempty\"`\n\tPerformers []string      `json:\"performers,omitempty\"`\n\tGroups     []SceneGroup  `json:\"movies,omitempty\"`\n\tTags       []string      `json:\"tags,omitempty\"`\n\tMarkers    []SceneMarker `json:\"markers,omitempty\"`\n\tFiles      []string      `json:\"files,omitempty\"`\n\tCover      string        `json:\"cover,omitempty\"`\n\tCreatedAt  json.JSONTime `json:\"created_at,omitempty\"`\n\tUpdatedAt  json.JSONTime `json:\"updated_at,omitempty\"`\n\n\t// deprecated - for import only\n\tLastPlayedAt json.JSONTime `json:\"last_played_at,omitempty\"`\n\n\tResumeTime float64 `json:\"resume_time,omitempty\"`\n\n\t// deprecated - for import only\n\tPlayCount int `json:\"play_count,omitempty\"`\n\n\tPlayHistory []json.JSONTime `json:\"play_history,omitempty\"`\n\tOHistory    []json.JSONTime `json:\"o_history,omitempty\"`\n\n\tPlayDuration float64          `json:\"play_duration,omitempty\"`\n\tStashIDs     []models.StashID `json:\"stash_ids,omitempty\"`\n\n\tCustomFields map[string]interface{} `json:\"custom_fields,omitempty\"`\n}\n\nfunc (s Scene) Filename(id int, basename string, hash string) string {\n\tret := fsutil.SanitiseBasename(s.Title)\n\tif ret == \"\" {\n\t\tret = basename\n\t}\n\n\tif hash != \"\" {\n\t\tret += \".\" + hash\n\t} else {\n\t\t// scenes may have no file and therefore no hash\n\t\tret += \".\" + strconv.Itoa(id)\n\t}\n\n\treturn ret + \".json\"\n}\n\nfunc LoadSceneFile(filePath string) (*Scene, error) {\n\tvar scene Scene\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(file)\n\terr = jsonParser.Decode(&scene)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &scene, nil\n}\n\nfunc SaveSceneFile(filePath string, scene *Scene) error {\n\tif scene == nil {\n\t\treturn fmt.Errorf(\"scene must not be nil\")\n\t}\n\treturn marshalToFile(filePath, scene)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/studio.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\ntype Studio struct {\n\tName          string           `json:\"name,omitempty\"`\n\tURLs          []string         `json:\"urls,omitempty\"`\n\tParentStudio  string           `json:\"parent_studio,omitempty\"`\n\tImage         string           `json:\"image,omitempty\"`\n\tCreatedAt     json.JSONTime    `json:\"created_at,omitempty\"`\n\tUpdatedAt     json.JSONTime    `json:\"updated_at,omitempty\"`\n\tRating        int              `json:\"rating,omitempty\"`\n\tFavorite      bool             `json:\"favorite,omitempty\"`\n\tDetails       string           `json:\"details,omitempty\"`\n\tAliases       []string         `json:\"aliases,omitempty\"`\n\tStashIDs      []models.StashID `json:\"stash_ids,omitempty\"`\n\tTags          []string         `json:\"tags,omitempty\"`\n\tIgnoreAutoTag bool             `json:\"ignore_auto_tag,omitempty\"`\n\tOrganized     bool             `json:\"organized,omitempty\"`\n\n\tCustomFields map[string]interface{} `json:\"custom_fields,omitempty\"`\n\n\t// deprecated - for import only\n\tURL string `json:\"url,omitempty\"`\n}\n\nfunc (s Studio) Filename() string {\n\treturn fsutil.SanitiseBasename(s.Name) + \".json\"\n}\n\nfunc LoadStudioFile(filePath string) (*Studio, error) {\n\tvar studio Studio\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(file)\n\terr = jsonParser.Decode(&studio)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &studio, nil\n}\n\nfunc SaveStudioFile(filePath string, studio *Studio) error {\n\tif studio == nil {\n\t\treturn fmt.Errorf(\"studio must not be nil\")\n\t}\n\treturn marshalToFile(filePath, studio)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/tag.go",
    "content": "package jsonschema\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n)\n\ntype Tag struct {\n\tName          string                 `json:\"name,omitempty\"`\n\tSortName      string                 `json:\"sort_name,omitempty\"`\n\tDescription   string                 `json:\"description,omitempty\"`\n\tFavorite      bool                   `json:\"favorite,omitempty\"`\n\tAliases       []string               `json:\"aliases,omitempty\"`\n\tImage         string                 `json:\"image,omitempty\"`\n\tParents       []string               `json:\"parents,omitempty\"`\n\tIgnoreAutoTag bool                   `json:\"ignore_auto_tag,omitempty\"`\n\tStashIDs      []models.StashID       `json:\"stash_ids,omitempty\"`\n\tCreatedAt     json.JSONTime          `json:\"created_at,omitempty\"`\n\tUpdatedAt     json.JSONTime          `json:\"updated_at,omitempty\"`\n\tCustomFields  map[string]interface{} `json:\"custom_fields,omitempty\"`\n}\n\nfunc (s Tag) Filename() string {\n\treturn fsutil.SanitiseBasename(s.Name) + \".json\"\n}\n\nfunc LoadTagFile(filePath string) (*Tag, error) {\n\tvar tag Tag\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tjsonParser := json.NewDecoder(file)\n\terr = jsonParser.Decode(&tag)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &tag, nil\n}\n\nfunc SaveTagFile(filePath string, tag *Tag) error {\n\tif tag == nil {\n\t\treturn fmt.Errorf(\"tag must not be nil\")\n\t}\n\treturn marshalToFile(filePath, tag)\n}\n"
  },
  {
    "path": "pkg/models/jsonschema/utils.go",
    "content": "package jsonschema\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\nfunc CompareJSON(a interface{}, b interface{}) bool {\n\taBuf, _ := encode(a)\n\tbBuf, _ := encode(b)\n\treturn bytes.Equal(aBuf, bBuf)\n}\n\nfunc marshalToFile(filePath string, j interface{}) error {\n\tdata, err := encode(j)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn os.WriteFile(filePath, data, 0644)\n}\n\nfunc encode(j interface{}) ([]byte, error) {\n\tbuffer := &bytes.Buffer{}\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tencoder := json.NewEncoder(buffer)\n\tencoder.SetEscapeHTML(false)\n\tencoder.SetIndent(\"\", \"  \")\n\tif err := encoder.Encode(j); err != nil {\n\t\treturn nil, err\n\t}\n\t// Strip the newline at the end of the file\n\treturn bytes.TrimRight(buffer.Bytes(), \"\\n\"), nil\n}\n"
  },
  {
    "path": "pkg/models/mocks/FileReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\tfs \"io/fs\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n)\n\n// FileReaderWriter is an autogenerated mock type for the FileReaderWriter type\ntype FileReaderWriter struct {\n\tmock.Mock\n}\n\n// CountAllInPaths provides a mock function with given fields: ctx, p\nfunc (_m *FileReaderWriter) CountAllInPaths(ctx context.Context, p []string) (int, error) {\n\tret := _m.Called(ctx, p)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, []string) int); ok {\n\t\tr0 = rf(ctx, p)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {\n\t\tr1 = rf(ctx, p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByFolderID provides a mock function with given fields: ctx, folderID\nfunc (_m *FileReaderWriter) CountByFolderID(ctx context.Context, folderID models.FolderID) (int, error) {\n\tret := _m.Called(ctx, folderID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FolderID) int); ok {\n\t\tr0 = rf(ctx, folderID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok {\n\t\tr1 = rf(ctx, folderID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, f\nfunc (_m *FileReaderWriter) Create(ctx context.Context, f models.File) error {\n\tret := _m.Called(ctx, f)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, models.File) error); ok {\n\t\tr0 = rf(ctx, f)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *FileReaderWriter) Destroy(ctx context.Context, id models.FileID) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// DestroyFingerprints provides a mock function with given fields: ctx, fileID, types\nfunc (_m *FileReaderWriter) DestroyFingerprints(ctx context.Context, fileID models.FileID, types []string) error {\n\tret := _m.Called(ctx, fileID, types)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID, []string) error); ok {\n\t\tr0 = rf(ctx, fileID, types)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *FileReaderWriter) Find(ctx context.Context, id ...models.FileID) ([]models.File, error) {\n\t_va := make([]interface{}, len(id))\n\tfor _i := range id {\n\t\t_va[_i] = id[_i]\n\t}\n\tvar _ca []interface{}\n\t_ca = append(_ca, ctx)\n\t_ca = append(_ca, _va...)\n\tret := _m.Called(_ca...)\n\n\tvar r0 []models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, ...models.FileID) []models.File); ok {\n\t\tr0 = rf(ctx, id...)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, ...models.FileID) error); ok {\n\t\tr1 = rf(ctx, id...)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindAllByPath provides a mock function with given fields: ctx, path, caseSensitive\nfunc (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]models.File, error) {\n\tret := _m.Called(ctx, path, caseSensitive)\n\n\tvar r0 []models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, string, bool) []models.File); ok {\n\t\tr0 = rf(ctx, path, caseSensitive)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {\n\t\tr1 = rf(ctx, path, caseSensitive)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset\nfunc (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]models.File, error) {\n\tret := _m.Called(ctx, p, includeZipContents, limit, offset)\n\n\tvar r0 []models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []models.File); ok {\n\t\tr0 = rf(ctx, p, includeZipContents, limit, offset)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok {\n\t\tr1 = rf(ctx, p, includeZipContents, limit, offset)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFileInfo provides a mock function with given fields: ctx, info, size\nfunc (_m *FileReaderWriter) FindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]models.File, error) {\n\tret := _m.Called(ctx, info, size)\n\n\tvar r0 []models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, fs.FileInfo, int64) []models.File); ok {\n\t\tr0 = rf(ctx, info, size)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, fs.FileInfo, int64) error); ok {\n\t\tr1 = rf(ctx, info, size)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFingerprint provides a mock function with given fields: ctx, fp\nfunc (_m *FileReaderWriter) FindByFingerprint(ctx context.Context, fp models.Fingerprint) ([]models.File, error) {\n\tret := _m.Called(ctx, fp)\n\n\tvar r0 []models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, models.Fingerprint) []models.File); ok {\n\t\tr0 = rf(ctx, fp)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.Fingerprint) error); ok {\n\t\tr1 = rf(ctx, fp)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByPath provides a mock function with given fields: ctx, path, caseSensitive\nfunc (_m *FileReaderWriter) FindByPath(ctx context.Context, path string, caseSensitive bool) (models.File, error) {\n\tret := _m.Called(ctx, path, caseSensitive)\n\n\tvar r0 models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, string, bool) models.File); ok {\n\t\tr0 = rf(ctx, path, caseSensitive)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {\n\t\tr1 = rf(ctx, path, caseSensitive)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByZipFileID provides a mock function with given fields: ctx, zipFileID\nfunc (_m *FileReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]models.File, error) {\n\tret := _m.Called(ctx, zipFileID)\n\n\tvar r0 []models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) []models.File); ok {\n\t\tr0 = rf(ctx, zipFileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, zipFileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCaptions provides a mock function with given fields: ctx, fileID\nfunc (_m *FileReaderWriter) GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 []*models.VideoCaption\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.VideoCaption); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.VideoCaption)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// IsPrimary provides a mock function with given fields: ctx, fileID\nfunc (_m *FileReaderWriter) IsPrimary(ctx context.Context, fileID models.FileID) (bool, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) bool); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// ModifyFingerprints provides a mock function with given fields: ctx, fileID, fingerprints\nfunc (_m *FileReaderWriter) ModifyFingerprints(ctx context.Context, fileID models.FileID, fingerprints []models.Fingerprint) error {\n\tret := _m.Called(ctx, fileID, fingerprints)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID, []models.Fingerprint) error); ok {\n\t\tr0 = rf(ctx, fileID, fingerprints)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Query provides a mock function with given fields: ctx, options\nfunc (_m *FileReaderWriter) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) {\n\tret := _m.Called(ctx, options)\n\n\tvar r0 *models.FileQueryResult\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileQueryOptions) *models.FileQueryResult); ok {\n\t\tr0 = rf(ctx, options)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.FileQueryResult)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileQueryOptions) error); ok {\n\t\tr1 = rf(ctx, options)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, f\nfunc (_m *FileReaderWriter) Update(ctx context.Context, f models.File) error {\n\tret := _m.Called(ctx, f)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, models.File) error); ok {\n\t\tr0 = rf(ctx, f)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateCaptions provides a mock function with given fields: ctx, fileID, captions\nfunc (_m *FileReaderWriter) UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error {\n\tret := _m.Called(ctx, fileID, captions)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID, []*models.VideoCaption) error); ok {\n\t\tr0 = rf(ctx, fileID, captions)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n"
  },
  {
    "path": "pkg/models/mocks/FolderReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// FolderReaderWriter is an autogenerated mock type for the FolderReaderWriter type\ntype FolderReaderWriter struct {\n\tmock.Mock\n}\n\n// CountAllInPaths provides a mock function with given fields: ctx, p\nfunc (_m *FolderReaderWriter) CountAllInPaths(ctx context.Context, p []string) (int, error) {\n\tret := _m.Called(ctx, p)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, []string) int); ok {\n\t\tr0 = rf(ctx, p)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {\n\t\tr1 = rf(ctx, p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, f\nfunc (_m *FolderReaderWriter) Create(ctx context.Context, f *models.Folder) error {\n\tret := _m.Called(ctx, f)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.Folder) error); ok {\n\t\tr0 = rf(ctx, f)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *FolderReaderWriter) Destroy(ctx context.Context, id models.FolderID) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FolderID) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *FolderReaderWriter) Find(ctx context.Context, id models.FolderID) (*models.Folder, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.Folder\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FolderID) *models.Folder); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Folder)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset\nfunc (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]*models.Folder, error) {\n\tret := _m.Called(ctx, p, includeZipContents, limit, offset)\n\n\tvar r0 []*models.Folder\n\tif rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []*models.Folder); ok {\n\t\tr0 = rf(ctx, p, includeZipContents, limit, offset)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Folder)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok {\n\t\tr1 = rf(ctx, p, includeZipContents, limit, offset)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByParentFolderID provides a mock function with given fields: ctx, parentFolderID\nfunc (_m *FolderReaderWriter) FindByParentFolderID(ctx context.Context, parentFolderID models.FolderID) ([]*models.Folder, error) {\n\tret := _m.Called(ctx, parentFolderID)\n\n\tvar r0 []*models.Folder\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FolderID) []*models.Folder); ok {\n\t\tr0 = rf(ctx, parentFolderID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Folder)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok {\n\t\tr1 = rf(ctx, parentFolderID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByPath provides a mock function with given fields: ctx, path, caseSensitive\nfunc (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string, caseSensitive bool) (*models.Folder, error) {\n\tret := _m.Called(ctx, path, caseSensitive)\n\n\tvar r0 *models.Folder\n\tif rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Folder); ok {\n\t\tr0 = rf(ctx, path, caseSensitive)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Folder)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {\n\t\tr1 = rf(ctx, path, caseSensitive)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByZipFileID provides a mock function with given fields: ctx, zipFileID\nfunc (_m *FolderReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Folder, error) {\n\tret := _m.Called(ctx, zipFileID)\n\n\tvar r0 []*models.Folder\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Folder); ok {\n\t\tr0 = rf(ctx, zipFileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Folder)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, zipFileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, id\nfunc (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID) ([]*models.Folder, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 []*models.Folder\n\tif rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) []*models.Folder); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Folder)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyParentFolderIDs provides a mock function with given fields: ctx, folderIDs\nfunc (_m *FolderReaderWriter) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {\n\tret := _m.Called(ctx, folderIDs)\n\n\tvar r0 [][]models.FolderID\n\tif rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok {\n\t\tr0 = rf(ctx, folderIDs)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([][]models.FolderID)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {\n\t\tr1 = rf(ctx, folderIDs)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Query provides a mock function with given fields: ctx, options\nfunc (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {\n\tret := _m.Called(ctx, options)\n\n\tvar r0 *models.FolderQueryResult\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FolderQueryOptions) *models.FolderQueryResult); ok {\n\t\tr0 = rf(ctx, options)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.FolderQueryResult)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FolderQueryOptions) error); ok {\n\t\tr1 = rf(ctx, options)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, f\nfunc (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error {\n\tret := _m.Called(ctx, f)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.Folder) error); ok {\n\t\tr0 = rf(ctx, f)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n"
  },
  {
    "path": "pkg/models/mocks/GalleryChapterReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// GalleryChapterReaderWriter is an autogenerated mock type for the GalleryChapterReaderWriter type\ntype GalleryChapterReaderWriter struct {\n\tmock.Mock\n}\n\n// Create provides a mock function with given fields: ctx, newGalleryChapter\nfunc (_m *GalleryChapterReaderWriter) Create(ctx context.Context, newGalleryChapter *models.GalleryChapter) error {\n\tret := _m.Called(ctx, newGalleryChapter)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.GalleryChapter) error); ok {\n\t\tr0 = rf(ctx, newGalleryChapter)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *GalleryChapterReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *GalleryChapterReaderWriter) Find(ctx context.Context, id int) (*models.GalleryChapter, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.GalleryChapter\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.GalleryChapter); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.GalleryChapter)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByGalleryID provides a mock function with given fields: ctx, galleryID\nfunc (_m *GalleryChapterReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) {\n\tret := _m.Called(ctx, galleryID)\n\n\tvar r0 []*models.GalleryChapter\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.GalleryChapter); ok {\n\t\tr0 = rf(ctx, galleryID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.GalleryChapter)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, galleryID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *GalleryChapterReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.GalleryChapter, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.GalleryChapter\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.GalleryChapter); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.GalleryChapter)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, updatedGalleryChapter\nfunc (_m *GalleryChapterReaderWriter) Update(ctx context.Context, updatedGalleryChapter *models.GalleryChapter) error {\n\tret := _m.Called(ctx, updatedGalleryChapter)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.GalleryChapter) error); ok {\n\t\tr0 = rf(ctx, updatedGalleryChapter)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, id, updatedGalleryChapter\nfunc (_m *GalleryChapterReaderWriter) UpdatePartial(ctx context.Context, id int, updatedGalleryChapter models.GalleryChapterPartial) (*models.GalleryChapter, error) {\n\tret := _m.Called(ctx, id, updatedGalleryChapter)\n\n\tvar r0 *models.GalleryChapter\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.GalleryChapterPartial) *models.GalleryChapter); ok {\n\t\tr0 = rf(ctx, id, updatedGalleryChapter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.GalleryChapter)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, models.GalleryChapterPartial) error); ok {\n\t\tr1 = rf(ctx, id, updatedGalleryChapter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n"
  },
  {
    "path": "pkg/models/mocks/GalleryReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// GalleryReaderWriter is an autogenerated mock type for the GalleryReaderWriter type\ntype GalleryReaderWriter struct {\n\tmock.Mock\n}\n\n// AddFileID provides a mock function with given fields: ctx, id, fileID\nfunc (_m *GalleryReaderWriter) AddFileID(ctx context.Context, id int, fileID models.FileID) error {\n\tret := _m.Called(ctx, id, fileID)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok {\n\t\tr0 = rf(ctx, id, fileID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// AddImages provides a mock function with given fields: ctx, galleryID, imageIDs\nfunc (_m *GalleryReaderWriter) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error {\n\t_va := make([]interface{}, len(imageIDs))\n\tfor _i := range imageIDs {\n\t\t_va[_i] = imageIDs[_i]\n\t}\n\tvar _ca []interface{}\n\t_ca = append(_ca, ctx, galleryID)\n\t_ca = append(_ca, _va...)\n\tret := _m.Called(_ca...)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, ...int) error); ok {\n\t\tr0 = rf(ctx, galleryID, imageIDs...)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// AddSceneIDs provides a mock function with given fields: ctx, galleryID, sceneIDs\nfunc (_m *GalleryReaderWriter) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error {\n\tret := _m.Called(ctx, galleryID, sceneIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {\n\t\tr0 = rf(ctx, galleryID, sceneIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *GalleryReaderWriter) All(ctx context.Context) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.Gallery); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Count provides a mock function with given fields: ctx\nfunc (_m *GalleryReaderWriter) Count(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByFileID provides a mock function with given fields: ctx, fileID\nfunc (_m *GalleryReaderWriter) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) int); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, newGallery\nfunc (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.CreateGalleryInput) error {\n\tret := _m.Called(ctx, newGallery)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.CreateGalleryInput) error); ok {\n\t\tr0 = rf(ctx, newGallery)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *GalleryReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *GalleryReaderWriter) Find(ctx context.Context, id int) (*models.Gallery, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Gallery); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByChecksum provides a mock function with given fields: ctx, checksum\nfunc (_m *GalleryReaderWriter) FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, checksum)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, string) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, checksum)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string) error); ok {\n\t\tr1 = rf(ctx, checksum)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByChecksums provides a mock function with given fields: ctx, checksums\nfunc (_m *GalleryReaderWriter) FindByChecksums(ctx context.Context, checksums []string) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, checksums)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, []string) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, checksums)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {\n\t\tr1 = rf(ctx, checksums)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFileID provides a mock function with given fields: ctx, fileID\nfunc (_m *GalleryReaderWriter) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFingerprints provides a mock function with given fields: ctx, fp\nfunc (_m *GalleryReaderWriter) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, fp)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, []models.Fingerprint) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, fp)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []models.Fingerprint) error); ok {\n\t\tr1 = rf(ctx, fp)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFolderID provides a mock function with given fields: ctx, folderID\nfunc (_m *GalleryReaderWriter) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, folderID)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FolderID) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, folderID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok {\n\t\tr1 = rf(ctx, folderID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByImageID provides a mock function with given fields: ctx, imageID\nfunc (_m *GalleryReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, imageID)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, imageID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, imageID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByPath provides a mock function with given fields: ctx, path\nfunc (_m *GalleryReaderWriter) FindByPath(ctx context.Context, path string) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, path)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, string) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, path)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string) error); ok {\n\t\tr1 = rf(ctx, path)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindBySceneID provides a mock function with given fields: ctx, sceneID\nfunc (_m *GalleryReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, sceneID)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, sceneID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, sceneID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *GalleryReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindUserGalleryByTitle provides a mock function with given fields: ctx, title\nfunc (_m *GalleryReaderWriter) FindUserGalleryByTitle(ctx context.Context, title string) ([]*models.Gallery, error) {\n\tret := _m.Called(ctx, title)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, string) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, title)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string) error); ok {\n\t\tr1 = rf(ctx, title)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFields provides a mock function with given fields: ctx, id\nfunc (_m *GalleryReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 map[string]interface{}\n\tif rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(map[string]interface{})\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids\nfunc (_m *GalleryReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []models.CustomFieldMap\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.CustomFieldMap)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetFiles provides a mock function with given fields: ctx, relatedID\nfunc (_m *GalleryReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.File); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetImageIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *GalleryReaderWriter) GetImageIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyFileIDs provides a mock function with given fields: ctx, ids\nfunc (_m *GalleryReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 [][]models.FileID\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) [][]models.FileID); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([][]models.FileID)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetPerformerIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *GalleryReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetSceneIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *GalleryReaderWriter) GetSceneIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetTagIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *GalleryReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetURLs provides a mock function with given fields: ctx, relatedID\nfunc (_m *GalleryReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Query provides a mock function with given fields: ctx, galleryFilter, findFilter\nfunc (_m *GalleryReaderWriter) Query(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) {\n\tret := _m.Called(ctx, galleryFilter, findFilter)\n\n\tvar r0 []*models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) []*models.Gallery); ok {\n\t\tr0 = rf(ctx, galleryFilter, findFilter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 int\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) int); ok {\n\t\tr1 = rf(ctx, galleryFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Get(1).(int)\n\t}\n\n\tvar r2 error\n\tif rf, ok := ret.Get(2).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) error); ok {\n\t\tr2 = rf(ctx, galleryFilter, findFilter)\n\t} else {\n\t\tr2 = ret.Error(2)\n\t}\n\n\treturn r0, r1, r2\n}\n\n// QueryCount provides a mock function with given fields: ctx, galleryFilter, findFilter\nfunc (_m *GalleryReaderWriter) QueryCount(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) {\n\tret := _m.Called(ctx, galleryFilter, findFilter)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) int); ok {\n\t\tr0 = rf(ctx, galleryFilter, findFilter)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.GalleryFilterType, *models.FindFilterType) error); ok {\n\t\tr1 = rf(ctx, galleryFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// RemoveImages provides a mock function with given fields: ctx, galleryID, imageIDs\nfunc (_m *GalleryReaderWriter) RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error {\n\t_va := make([]interface{}, len(imageIDs))\n\tfor _i := range imageIDs {\n\t\t_va[_i] = imageIDs[_i]\n\t}\n\tvar _ca []interface{}\n\t_ca = append(_ca, ctx, galleryID)\n\t_ca = append(_ca, _va...)\n\tret := _m.Called(_ca...)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, ...int) error); ok {\n\t\tr0 = rf(ctx, galleryID, imageIDs...)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// ResetCover provides a mock function with given fields: ctx, galleryID\nfunc (_m *GalleryReaderWriter) ResetCover(ctx context.Context, galleryID int) error {\n\tret := _m.Called(ctx, galleryID)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, galleryID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// SetCover provides a mock function with given fields: ctx, galleryID, coverImageID\nfunc (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, coverImageID int) error {\n\tret := _m.Called(ctx, galleryID, coverImageID)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, int) error); ok {\n\t\tr0 = rf(ctx, galleryID, coverImageID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// SetCustomFields provides a mock function with given fields: ctx, id, fields\nfunc (_m *GalleryReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error {\n\tret := _m.Called(ctx, id, fields)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok {\n\t\tr0 = rf(ctx, id, fields)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Update provides a mock function with given fields: ctx, updatedGallery\nfunc (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.UpdateGalleryInput) error {\n\tret := _m.Called(ctx, updatedGallery)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.UpdateGalleryInput) error); ok {\n\t\tr0 = rf(ctx, updatedGallery)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateImages provides a mock function with given fields: ctx, galleryID, imageIDs\nfunc (_m *GalleryReaderWriter) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error {\n\tret := _m.Called(ctx, galleryID, imageIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {\n\t\tr0 = rf(ctx, galleryID, imageIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, id, updatedGallery\nfunc (_m *GalleryReaderWriter) UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) {\n\tret := _m.Called(ctx, id, updatedGallery)\n\n\tvar r0 *models.Gallery\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.GalleryPartial) *models.Gallery); ok {\n\t\tr0 = rf(ctx, id, updatedGallery)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Gallery)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, models.GalleryPartial) error); ok {\n\t\tr1 = rf(ctx, id, updatedGallery)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n"
  },
  {
    "path": "pkg/models/mocks/GroupReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// GroupReaderWriter is an autogenerated mock type for the GroupReaderWriter type\ntype GroupReaderWriter struct {\n\tmock.Mock\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *GroupReaderWriter) All(ctx context.Context) ([]*models.Group, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.Group); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Group)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Count provides a mock function with given fields: ctx\nfunc (_m *GroupReaderWriter) Count(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByPerformerID provides a mock function with given fields: ctx, performerID\nfunc (_m *GroupReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByStudioID provides a mock function with given fields: ctx, studioID\nfunc (_m *GroupReaderWriter) CountByStudioID(ctx context.Context, studioID int) (int, error) {\n\tret := _m.Called(ctx, studioID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, studioID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, studioID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, newGroup\nfunc (_m *GroupReaderWriter) Create(ctx context.Context, newGroup *models.Group) error {\n\tret := _m.Called(ctx, newGroup)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.Group) error); ok {\n\t\tr0 = rf(ctx, newGroup)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *GroupReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *GroupReaderWriter) Find(ctx context.Context, id int) (*models.Group, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Group); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Group)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByName provides a mock function with given fields: ctx, name, nocase\nfunc (_m *GroupReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) {\n\tret := _m.Called(ctx, name, nocase)\n\n\tvar r0 *models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Group); ok {\n\t\tr0 = rf(ctx, name, nocase)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Group)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {\n\t\tr1 = rf(ctx, name, nocase)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByNames provides a mock function with given fields: ctx, names, nocase\nfunc (_m *GroupReaderWriter) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) {\n\tret := _m.Called(ctx, names, nocase)\n\n\tvar r0 []*models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context, []string, bool) []*models.Group); ok {\n\t\tr0 = rf(ctx, names, nocase)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Group)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string, bool) error); ok {\n\t\tr1 = rf(ctx, names, nocase)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByPerformerID provides a mock function with given fields: ctx, performerID\nfunc (_m *GroupReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Group, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 []*models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Group); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Group)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByStudioID provides a mock function with given fields: ctx, studioID\nfunc (_m *GroupReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Group, error) {\n\tret := _m.Called(ctx, studioID)\n\n\tvar r0 []*models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Group); ok {\n\t\tr0 = rf(ctx, studioID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Group)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, studioID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *GroupReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Group, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Group); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Group)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetBackImage provides a mock function with given fields: ctx, groupID\nfunc (_m *GroupReaderWriter) GetBackImage(ctx context.Context, groupID int) ([]byte, error) {\n\tret := _m.Called(ctx, groupID)\n\n\tvar r0 []byte\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok {\n\t\tr0 = rf(ctx, groupID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]byte)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, groupID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetContainingGroupDescriptions provides a mock function with given fields: ctx, id\nfunc (_m *GroupReaderWriter) GetContainingGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 []models.GroupIDDescription\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupIDDescription); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.GroupIDDescription)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFields provides a mock function with given fields: ctx, id\nfunc (_m *GroupReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 map[string]interface{}\n\tif rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(map[string]interface{})\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids\nfunc (_m *GroupReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []models.CustomFieldMap\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.CustomFieldMap)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetFrontImage provides a mock function with given fields: ctx, groupID\nfunc (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) {\n\tret := _m.Called(ctx, groupID)\n\n\tvar r0 []byte\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok {\n\t\tr0 = rf(ctx, groupID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]byte)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, groupID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetSubGroupDescriptions provides a mock function with given fields: ctx, id\nfunc (_m *GroupReaderWriter) GetSubGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 []models.GroupIDDescription\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupIDDescription); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.GroupIDDescription)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetTagIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *GroupReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetURLs provides a mock function with given fields: ctx, relatedID\nfunc (_m *GroupReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// HasBackImage provides a mock function with given fields: ctx, groupID\nfunc (_m *GroupReaderWriter) HasBackImage(ctx context.Context, groupID int) (bool, error) {\n\tret := _m.Called(ctx, groupID)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {\n\t\tr0 = rf(ctx, groupID)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, groupID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// HasFrontImage provides a mock function with given fields: ctx, groupID\nfunc (_m *GroupReaderWriter) HasFrontImage(ctx context.Context, groupID int) (bool, error) {\n\tret := _m.Called(ctx, groupID)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {\n\t\tr0 = rf(ctx, groupID)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, groupID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Query provides a mock function with given fields: ctx, groupFilter, findFilter\nfunc (_m *GroupReaderWriter) Query(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) ([]*models.Group, int, error) {\n\tret := _m.Called(ctx, groupFilter, findFilter)\n\n\tvar r0 []*models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) []*models.Group); ok {\n\t\tr0 = rf(ctx, groupFilter, findFilter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Group)\n\t\t}\n\t}\n\n\tvar r1 int\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) int); ok {\n\t\tr1 = rf(ctx, groupFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Get(1).(int)\n\t}\n\n\tvar r2 error\n\tif rf, ok := ret.Get(2).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) error); ok {\n\t\tr2 = rf(ctx, groupFilter, findFilter)\n\t} else {\n\t\tr2 = ret.Error(2)\n\t}\n\n\treturn r0, r1, r2\n}\n\n// QueryCount provides a mock function with given fields: ctx, groupFilter, findFilter\nfunc (_m *GroupReaderWriter) QueryCount(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (int, error) {\n\tret := _m.Called(ctx, groupFilter, findFilter)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) int); ok {\n\t\tr0 = rf(ctx, groupFilter, findFilter)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) error); ok {\n\t\tr1 = rf(ctx, groupFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// SetCustomFields provides a mock function with given fields: ctx, id, fields\nfunc (_m *GroupReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error {\n\tret := _m.Called(ctx, id, fields)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok {\n\t\tr0 = rf(ctx, id, fields)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Update provides a mock function with given fields: ctx, updatedGroup\nfunc (_m *GroupReaderWriter) Update(ctx context.Context, updatedGroup *models.Group) error {\n\tret := _m.Called(ctx, updatedGroup)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.Group) error); ok {\n\t\tr0 = rf(ctx, updatedGroup)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateBackImage provides a mock function with given fields: ctx, groupID, backImage\nfunc (_m *GroupReaderWriter) UpdateBackImage(ctx context.Context, groupID int, backImage []byte) error {\n\tret := _m.Called(ctx, groupID, backImage)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok {\n\t\tr0 = rf(ctx, groupID, backImage)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateFrontImage provides a mock function with given fields: ctx, groupID, frontImage\nfunc (_m *GroupReaderWriter) UpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error {\n\tret := _m.Called(ctx, groupID, frontImage)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok {\n\t\tr0 = rf(ctx, groupID, frontImage)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, id, updatedGroup\nfunc (_m *GroupReaderWriter) UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial) (*models.Group, error) {\n\tret := _m.Called(ctx, id, updatedGroup)\n\n\tvar r0 *models.Group\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.GroupPartial) *models.Group); ok {\n\t\tr0 = rf(ctx, id, updatedGroup)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Group)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, models.GroupPartial) error); ok {\n\t\tr1 = rf(ctx, id, updatedGroup)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n"
  },
  {
    "path": "pkg/models/mocks/ImageReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// ImageReaderWriter is an autogenerated mock type for the ImageReaderWriter type\ntype ImageReaderWriter struct {\n\tmock.Mock\n}\n\n// AddFileID provides a mock function with given fields: ctx, id, fileID\nfunc (_m *ImageReaderWriter) AddFileID(ctx context.Context, id int, fileID models.FileID) error {\n\tret := _m.Called(ctx, id, fileID)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok {\n\t\tr0 = rf(ctx, id, fileID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *ImageReaderWriter) All(ctx context.Context) ([]*models.Image, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.Image); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Count provides a mock function with given fields: ctx\nfunc (_m *ImageReaderWriter) Count(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByFileID provides a mock function with given fields: ctx, fileID\nfunc (_m *ImageReaderWriter) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) int); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByGalleryID provides a mock function with given fields: ctx, galleryID\nfunc (_m *ImageReaderWriter) CountByGalleryID(ctx context.Context, galleryID int) (int, error) {\n\tret := _m.Called(ctx, galleryID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, galleryID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, galleryID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CoverByGalleryID provides a mock function with given fields: ctx, galleryId\nfunc (_m *ImageReaderWriter) CoverByGalleryID(ctx context.Context, galleryId int) (*models.Image, error) {\n\tret := _m.Called(ctx, galleryId)\n\n\tvar r0 *models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Image); ok {\n\t\tr0 = rf(ctx, galleryId)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, galleryId)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, newImage\nfunc (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.CreateImageInput) error {\n\tret := _m.Called(ctx, newImage)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.CreateImageInput) error); ok {\n\t\tr0 = rf(ctx, newImage)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// DecrementOCounter provides a mock function with given fields: ctx, id\nfunc (_m *ImageReaderWriter) DecrementOCounter(ctx context.Context, id int) (int, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *ImageReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *ImageReaderWriter) Find(ctx context.Context, id int) (*models.Image, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Image); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByChecksum provides a mock function with given fields: ctx, checksum\nfunc (_m *ImageReaderWriter) FindByChecksum(ctx context.Context, checksum string) ([]*models.Image, error) {\n\tret := _m.Called(ctx, checksum)\n\n\tvar r0 []*models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, string) []*models.Image); ok {\n\t\tr0 = rf(ctx, checksum)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string) error); ok {\n\t\tr1 = rf(ctx, checksum)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFileID provides a mock function with given fields: ctx, fileID\nfunc (_m *ImageReaderWriter) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 []*models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Image); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFingerprints provides a mock function with given fields: ctx, fp\nfunc (_m *ImageReaderWriter) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Image, error) {\n\tret := _m.Called(ctx, fp)\n\n\tvar r0 []*models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, []models.Fingerprint) []*models.Image); ok {\n\t\tr0 = rf(ctx, fp)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []models.Fingerprint) error); ok {\n\t\tr1 = rf(ctx, fp)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFolderID provides a mock function with given fields: ctx, fileID\nfunc (_m *ImageReaderWriter) FindByFolderID(ctx context.Context, fileID models.FolderID) ([]*models.Image, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 []*models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FolderID) []*models.Image); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FolderID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByGalleryID provides a mock function with given fields: ctx, galleryID\nfunc (_m *ImageReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) {\n\tret := _m.Called(ctx, galleryID)\n\n\tvar r0 []*models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Image); ok {\n\t\tr0 = rf(ctx, galleryID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, galleryID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByGalleryIDIndex provides a mock function with given fields: ctx, galleryID, index\nfunc (_m *ImageReaderWriter) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) {\n\tret := _m.Called(ctx, galleryID, index)\n\n\tvar r0 *models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, int, uint) *models.Image); ok {\n\t\tr0 = rf(ctx, galleryID, index)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, uint) error); ok {\n\t\tr1 = rf(ctx, galleryID, index)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByZipFileID provides a mock function with given fields: ctx, zipFileID\nfunc (_m *ImageReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) {\n\tret := _m.Called(ctx, zipFileID)\n\n\tvar r0 []*models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Image); ok {\n\t\tr0 = rf(ctx, zipFileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, zipFileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *ImageReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Image, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Image); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFields provides a mock function with given fields: ctx, id\nfunc (_m *ImageReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 map[string]interface{}\n\tif rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(map[string]interface{})\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids\nfunc (_m *ImageReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []models.CustomFieldMap\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.CustomFieldMap)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetFiles provides a mock function with given fields: ctx, relatedID\nfunc (_m *ImageReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []models.File\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.File); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.File)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetGalleryIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *ImageReaderWriter) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyFileIDs provides a mock function with given fields: ctx, ids\nfunc (_m *ImageReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 [][]models.FileID\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) [][]models.FileID); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([][]models.FileID)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetPerformerIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *ImageReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetTagIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *ImageReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetURLs provides a mock function with given fields: ctx, relatedID\nfunc (_m *ImageReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// IncrementOCounter provides a mock function with given fields: ctx, id\nfunc (_m *ImageReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// OCount provides a mock function with given fields: ctx\nfunc (_m *ImageReaderWriter) OCount(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// OCountByPerformerID provides a mock function with given fields: ctx, performerID\nfunc (_m *ImageReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// OCountByStudioID provides a mock function with given fields: ctx, studioID\nfunc (_m *ImageReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) {\n\tret := _m.Called(ctx, studioID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, studioID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, studioID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Query provides a mock function with given fields: ctx, options\nfunc (_m *ImageReaderWriter) Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) {\n\tret := _m.Called(ctx, options)\n\n\tvar r0 *models.ImageQueryResult\n\tif rf, ok := ret.Get(0).(func(context.Context, models.ImageQueryOptions) *models.ImageQueryResult); ok {\n\t\tr0 = rf(ctx, options)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.ImageQueryResult)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.ImageQueryOptions) error); ok {\n\t\tr1 = rf(ctx, options)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// QueryCount provides a mock function with given fields: ctx, imageFilter, findFilter\nfunc (_m *ImageReaderWriter) QueryCount(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) {\n\tret := _m.Called(ctx, imageFilter, findFilter)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.ImageFilterType, *models.FindFilterType) int); ok {\n\t\tr0 = rf(ctx, imageFilter, findFilter)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.ImageFilterType, *models.FindFilterType) error); ok {\n\t\tr1 = rf(ctx, imageFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// RemoveFileID provides a mock function with given fields: ctx, id, fileID\nfunc (_m *ImageReaderWriter) RemoveFileID(ctx context.Context, id int, fileID models.FileID) error {\n\tret := _m.Called(ctx, id, fileID)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok {\n\t\tr0 = rf(ctx, id, fileID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// ResetOCounter provides a mock function with given fields: ctx, id\nfunc (_m *ImageReaderWriter) ResetOCounter(ctx context.Context, id int) (int, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// SetCustomFields provides a mock function with given fields: ctx, id, fields\nfunc (_m *ImageReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error {\n\tret := _m.Called(ctx, id, fields)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok {\n\t\tr0 = rf(ctx, id, fields)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Size provides a mock function with given fields: ctx\nfunc (_m *ImageReaderWriter) Size(ctx context.Context) (float64, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 float64\n\tif rf, ok := ret.Get(0).(func(context.Context) float64); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(float64)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, updatedImage\nfunc (_m *ImageReaderWriter) Update(ctx context.Context, updatedImage *models.Image) error {\n\tret := _m.Called(ctx, updatedImage)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.Image) error); ok {\n\t\tr0 = rf(ctx, updatedImage)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, id, partial\nfunc (_m *ImageReaderWriter) UpdatePartial(ctx context.Context, id int, partial models.ImagePartial) (*models.Image, error) {\n\tret := _m.Called(ctx, id, partial)\n\n\tvar r0 *models.Image\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.ImagePartial) *models.Image); ok {\n\t\tr0 = rf(ctx, id, partial)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Image)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, models.ImagePartial) error); ok {\n\t\tr1 = rf(ctx, id, partial)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// UpdatePerformers provides a mock function with given fields: ctx, imageID, performerIDs\nfunc (_m *ImageReaderWriter) UpdatePerformers(ctx context.Context, imageID int, performerIDs []int) error {\n\tret := _m.Called(ctx, imageID, performerIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {\n\t\tr0 = rf(ctx, imageID, performerIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateTags provides a mock function with given fields: ctx, imageID, tagIDs\nfunc (_m *ImageReaderWriter) UpdateTags(ctx context.Context, imageID int, tagIDs []int) error {\n\tret := _m.Called(ctx, imageID, tagIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {\n\t\tr0 = rf(ctx, imageID, tagIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n"
  },
  {
    "path": "pkg/models/mocks/PerformerReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// PerformerReaderWriter is an autogenerated mock type for the PerformerReaderWriter type\ntype PerformerReaderWriter struct {\n\tmock.Mock\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *PerformerReaderWriter) All(ctx context.Context) ([]*models.Performer, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.Performer); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Count provides a mock function with given fields: ctx\nfunc (_m *PerformerReaderWriter) Count(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByTagID provides a mock function with given fields: ctx, tagID\nfunc (_m *PerformerReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) {\n\tret := _m.Called(ctx, tagID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, tagID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, tagID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, newPerformer\nfunc (_m *PerformerReaderWriter) Create(ctx context.Context, newPerformer *models.CreatePerformerInput) error {\n\tret := _m.Called(ctx, newPerformer)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.CreatePerformerInput) error); ok {\n\t\tr0 = rf(ctx, newPerformer)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *PerformerReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *PerformerReaderWriter) Find(ctx context.Context, id int) (*models.Performer, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Performer); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByGalleryID provides a mock function with given fields: ctx, galleryID\nfunc (_m *PerformerReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Performer, error) {\n\tret := _m.Called(ctx, galleryID)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok {\n\t\tr0 = rf(ctx, galleryID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, galleryID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByImageID provides a mock function with given fields: ctx, imageID\nfunc (_m *PerformerReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) {\n\tret := _m.Called(ctx, imageID)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok {\n\t\tr0 = rf(ctx, imageID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, imageID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByNames provides a mock function with given fields: ctx, names, nocase\nfunc (_m *PerformerReaderWriter) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) {\n\tret := _m.Called(ctx, names, nocase)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, []string, bool) []*models.Performer); ok {\n\t\tr0 = rf(ctx, names, nocase)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string, bool) error); ok {\n\t\tr1 = rf(ctx, names, nocase)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindBySceneID provides a mock function with given fields: ctx, sceneID\nfunc (_m *PerformerReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) {\n\tret := _m.Called(ctx, sceneID)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok {\n\t\tr0 = rf(ctx, sceneID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, sceneID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByStashID provides a mock function with given fields: ctx, stashID\nfunc (_m *PerformerReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) {\n\tret := _m.Called(ctx, stashID)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Performer); ok {\n\t\tr0 = rf(ctx, stashID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok {\n\t\tr1 = rf(ctx, stashID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint\nfunc (_m *PerformerReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) {\n\tret := _m.Called(ctx, hasStashID, stashboxEndpoint)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Performer); ok {\n\t\tr0 = rf(ctx, hasStashID, stashboxEndpoint)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok {\n\t\tr1 = rf(ctx, hasStashID, stashboxEndpoint)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *PerformerReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Performer, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Performer); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetAliases provides a mock function with given fields: ctx, relatedID\nfunc (_m *PerformerReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFields provides a mock function with given fields: ctx, id\nfunc (_m *PerformerReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 map[string]interface{}\n\tif rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(map[string]interface{})\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids\nfunc (_m *PerformerReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []models.CustomFieldMap\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.CustomFieldMap)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetImage provides a mock function with given fields: ctx, performerID\nfunc (_m *PerformerReaderWriter) GetImage(ctx context.Context, performerID int) ([]byte, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 []byte\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]byte)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetStashIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *PerformerReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []models.StashID\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.StashID)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetTagIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetURLs provides a mock function with given fields: ctx, relatedID\nfunc (_m *PerformerReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// HasImage provides a mock function with given fields: ctx, performerID\nfunc (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int) (bool, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Merge provides a mock function with given fields: ctx, source, destination\nfunc (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error {\n\tret := _m.Called(ctx, source, destination)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok {\n\t\tr0 = rf(ctx, source, destination)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Query provides a mock function with given fields: ctx, performerFilter, findFilter\nfunc (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {\n\tret := _m.Called(ctx, performerFilter, findFilter)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) []*models.Performer); ok {\n\t\tr0 = rf(ctx, performerFilter, findFilter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 int\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) int); ok {\n\t\tr1 = rf(ctx, performerFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Get(1).(int)\n\t}\n\n\tvar r2 error\n\tif rf, ok := ret.Get(2).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) error); ok {\n\t\tr2 = rf(ctx, performerFilter, findFilter)\n\t} else {\n\t\tr2 = ret.Error(2)\n\t}\n\n\treturn r0, r1, r2\n}\n\n// QueryCount provides a mock function with given fields: ctx, performerFilter, findFilter\nfunc (_m *PerformerReaderWriter) QueryCount(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (int, error) {\n\tret := _m.Called(ctx, performerFilter, findFilter)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) int); ok {\n\t\tr0 = rf(ctx, performerFilter, findFilter)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.PerformerFilterType, *models.FindFilterType) error); ok {\n\t\tr1 = rf(ctx, performerFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// QueryForAutoTag provides a mock function with given fields: ctx, words\nfunc (_m *PerformerReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Performer, error) {\n\tret := _m.Called(ctx, words)\n\n\tvar r0 []*models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, []string) []*models.Performer); ok {\n\t\tr0 = rf(ctx, words)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {\n\t\tr1 = rf(ctx, words)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, updatedPerformer\nfunc (_m *PerformerReaderWriter) Update(ctx context.Context, updatedPerformer *models.UpdatePerformerInput) error {\n\tret := _m.Called(ctx, updatedPerformer)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.UpdatePerformerInput) error); ok {\n\t\tr0 = rf(ctx, updatedPerformer)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateImage provides a mock function with given fields: ctx, performerID, image\nfunc (_m *PerformerReaderWriter) UpdateImage(ctx context.Context, performerID int, image []byte) error {\n\tret := _m.Called(ctx, performerID, image)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok {\n\t\tr0 = rf(ctx, performerID, image)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, id, updatedPerformer\nfunc (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedPerformer models.PerformerPartial) (*models.Performer, error) {\n\tret := _m.Called(ctx, id, updatedPerformer)\n\n\tvar r0 *models.Performer\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.PerformerPartial) *models.Performer); ok {\n\t\tr0 = rf(ctx, id, updatedPerformer)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Performer)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, models.PerformerPartial) error); ok {\n\t\tr1 = rf(ctx, id, updatedPerformer)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n"
  },
  {
    "path": "pkg/models/mocks/SavedFilterReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// SavedFilterReaderWriter is an autogenerated mock type for the SavedFilterReaderWriter type\ntype SavedFilterReaderWriter struct {\n\tmock.Mock\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *SavedFilterReaderWriter) All(ctx context.Context) ([]*models.SavedFilter, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.SavedFilter\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.SavedFilter); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.SavedFilter)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, obj\nfunc (_m *SavedFilterReaderWriter) Create(ctx context.Context, obj *models.SavedFilter) error {\n\tret := _m.Called(ctx, obj)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.SavedFilter) error); ok {\n\t\tr0 = rf(ctx, obj)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *SavedFilterReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *SavedFilterReaderWriter) Find(ctx context.Context, id int) (*models.SavedFilter, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.SavedFilter\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.SavedFilter); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.SavedFilter)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByMode provides a mock function with given fields: ctx, mode\nfunc (_m *SavedFilterReaderWriter) FindByMode(ctx context.Context, mode models.FilterMode) ([]*models.SavedFilter, error) {\n\tret := _m.Called(ctx, mode)\n\n\tvar r0 []*models.SavedFilter\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FilterMode) []*models.SavedFilter); ok {\n\t\tr0 = rf(ctx, mode)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.SavedFilter)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FilterMode) error); ok {\n\t\tr1 = rf(ctx, mode)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids, ignoreNotFound\nfunc (_m *SavedFilterReaderWriter) FindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) {\n\tret := _m.Called(ctx, ids, ignoreNotFound)\n\n\tvar r0 []*models.SavedFilter\n\tif rf, ok := ret.Get(0).(func(context.Context, []int, bool) []*models.SavedFilter); ok {\n\t\tr0 = rf(ctx, ids, ignoreNotFound)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.SavedFilter)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int, bool) error); ok {\n\t\tr1 = rf(ctx, ids, ignoreNotFound)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, obj\nfunc (_m *SavedFilterReaderWriter) Update(ctx context.Context, obj *models.SavedFilter) error {\n\tret := _m.Called(ctx, obj)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.SavedFilter) error); ok {\n\t\tr0 = rf(ctx, obj)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n"
  },
  {
    "path": "pkg/models/mocks/SceneMarkerReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// SceneMarkerReaderWriter is an autogenerated mock type for the SceneMarkerReaderWriter type\ntype SceneMarkerReaderWriter struct {\n\tmock.Mock\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *SceneMarkerReaderWriter) All(ctx context.Context) ([]*models.SceneMarker, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.SceneMarker\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.SceneMarker); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.SceneMarker)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Count provides a mock function with given fields: ctx\nfunc (_m *SceneMarkerReaderWriter) Count(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByTagID provides a mock function with given fields: ctx, tagID\nfunc (_m *SceneMarkerReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) {\n\tret := _m.Called(ctx, tagID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, tagID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, tagID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, newSceneMarker\nfunc (_m *SceneMarkerReaderWriter) Create(ctx context.Context, newSceneMarker *models.SceneMarker) error {\n\tret := _m.Called(ctx, newSceneMarker)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.SceneMarker) error); ok {\n\t\tr0 = rf(ctx, newSceneMarker)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *SceneMarkerReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *SceneMarkerReaderWriter) Find(ctx context.Context, id int) (*models.SceneMarker, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.SceneMarker\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.SceneMarker); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.SceneMarker)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindBySceneID provides a mock function with given fields: ctx, sceneID\nfunc (_m *SceneMarkerReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error) {\n\tret := _m.Called(ctx, sceneID)\n\n\tvar r0 []*models.SceneMarker\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.SceneMarker); ok {\n\t\tr0 = rf(ctx, sceneID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.SceneMarker)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, sceneID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *SceneMarkerReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.SceneMarker, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.SceneMarker\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.SceneMarker); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.SceneMarker)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetMarkerStrings provides a mock function with given fields: ctx, q, sort\nfunc (_m *SceneMarkerReaderWriter) GetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*models.MarkerStringsResultType, error) {\n\tret := _m.Called(ctx, q, sort)\n\n\tvar r0 []*models.MarkerStringsResultType\n\tif rf, ok := ret.Get(0).(func(context.Context, *string, *string) []*models.MarkerStringsResultType); ok {\n\t\tr0 = rf(ctx, q, sort)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.MarkerStringsResultType)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *string, *string) error); ok {\n\t\tr1 = rf(ctx, q, sort)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetTagIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneMarkerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Query provides a mock function with given fields: ctx, sceneMarkerFilter, findFilter\nfunc (_m *SceneMarkerReaderWriter) Query(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) ([]*models.SceneMarker, int, error) {\n\tret := _m.Called(ctx, sceneMarkerFilter, findFilter)\n\n\tvar r0 []*models.SceneMarker\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) []*models.SceneMarker); ok {\n\t\tr0 = rf(ctx, sceneMarkerFilter, findFilter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.SceneMarker)\n\t\t}\n\t}\n\n\tvar r1 int\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) int); ok {\n\t\tr1 = rf(ctx, sceneMarkerFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Get(1).(int)\n\t}\n\n\tvar r2 error\n\tif rf, ok := ret.Get(2).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) error); ok {\n\t\tr2 = rf(ctx, sceneMarkerFilter, findFilter)\n\t} else {\n\t\tr2 = ret.Error(2)\n\t}\n\n\treturn r0, r1, r2\n}\n\n// QueryCount provides a mock function with given fields: ctx, sceneMarkerFilter, findFilter\nfunc (_m *SceneMarkerReaderWriter) QueryCount(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (int, error) {\n\tret := _m.Called(ctx, sceneMarkerFilter, findFilter)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) int); ok {\n\t\tr0 = rf(ctx, sceneMarkerFilter, findFilter)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.SceneMarkerFilterType, *models.FindFilterType) error); ok {\n\t\tr1 = rf(ctx, sceneMarkerFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, updatedSceneMarker\nfunc (_m *SceneMarkerReaderWriter) Update(ctx context.Context, updatedSceneMarker *models.SceneMarker) error {\n\tret := _m.Called(ctx, updatedSceneMarker)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.SceneMarker) error); ok {\n\t\tr0 = rf(ctx, updatedSceneMarker)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, id, updatedSceneMarker\nfunc (_m *SceneMarkerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedSceneMarker models.SceneMarkerPartial) (*models.SceneMarker, error) {\n\tret := _m.Called(ctx, id, updatedSceneMarker)\n\n\tvar r0 *models.SceneMarker\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.SceneMarkerPartial) *models.SceneMarker); ok {\n\t\tr0 = rf(ctx, id, updatedSceneMarker)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.SceneMarker)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, models.SceneMarkerPartial) error); ok {\n\t\tr1 = rf(ctx, id, updatedSceneMarker)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// UpdateTags provides a mock function with given fields: ctx, markerID, tagIDs\nfunc (_m *SceneMarkerReaderWriter) UpdateTags(ctx context.Context, markerID int, tagIDs []int) error {\n\tret := _m.Called(ctx, markerID, tagIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {\n\t\tr0 = rf(ctx, markerID, tagIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Wall provides a mock function with given fields: ctx, q\nfunc (_m *SceneMarkerReaderWriter) Wall(ctx context.Context, q *string) ([]*models.SceneMarker, error) {\n\tret := _m.Called(ctx, q)\n\n\tvar r0 []*models.SceneMarker\n\tif rf, ok := ret.Get(0).(func(context.Context, *string) []*models.SceneMarker); ok {\n\t\tr0 = rf(ctx, q)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.SceneMarker)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *string) error); ok {\n\t\tr1 = rf(ctx, q)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n"
  },
  {
    "path": "pkg/models/mocks/SceneReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n\n\ttime \"time\"\n)\n\n// SceneReaderWriter is an autogenerated mock type for the SceneReaderWriter type\ntype SceneReaderWriter struct {\n\tmock.Mock\n}\n\n// AddFileID provides a mock function with given fields: ctx, id, fileID\nfunc (_m *SceneReaderWriter) AddFileID(ctx context.Context, id int, fileID models.FileID) error {\n\tret := _m.Called(ctx, id, fileID)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok {\n\t\tr0 = rf(ctx, id, fileID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// AddGalleryIDs provides a mock function with given fields: ctx, sceneID, galleryIDs\nfunc (_m *SceneReaderWriter) AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error {\n\tret := _m.Called(ctx, sceneID, galleryIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {\n\t\tr0 = rf(ctx, sceneID, galleryIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// AddO provides a mock function with given fields: ctx, id, dates\nfunc (_m *SceneReaderWriter) AddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\tret := _m.Called(ctx, id, dates)\n\n\tvar r0 []time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok {\n\t\tr0 = rf(ctx, id, dates)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok {\n\t\tr1 = rf(ctx, id, dates)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// AddViews provides a mock function with given fields: ctx, sceneID, dates\nfunc (_m *SceneReaderWriter) AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error) {\n\tret := _m.Called(ctx, sceneID, dates)\n\n\tvar r0 []time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok {\n\t\tr0 = rf(ctx, sceneID, dates)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok {\n\t\tr1 = rf(ctx, sceneID, dates)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) All(ctx context.Context) ([]*models.Scene, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.Scene); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// AssignFiles provides a mock function with given fields: ctx, sceneID, fileID\nfunc (_m *SceneReaderWriter) AssignFiles(ctx context.Context, sceneID int, fileID []models.FileID) error {\n\tret := _m.Called(ctx, sceneID, fileID)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []models.FileID) error); ok {\n\t\tr0 = rf(ctx, sceneID, fileID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Count provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) Count(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountAllViews provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) CountAllViews(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByFileID provides a mock function with given fields: ctx, fileID\nfunc (_m *SceneReaderWriter) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) int); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByPerformerID provides a mock function with given fields: ctx, performerID\nfunc (_m *SceneReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountMissingChecksum provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) CountMissingChecksum(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountMissingOSHash provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) CountMissingOSHash(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountUniqueViews provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) CountUniqueViews(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountViews provides a mock function with given fields: ctx, id\nfunc (_m *SceneReaderWriter) CountViews(ctx context.Context, id int) (int, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, newScene, fileIDs\nfunc (_m *SceneReaderWriter) Create(ctx context.Context, newScene *models.Scene, fileIDs []models.FileID) error {\n\tret := _m.Called(ctx, newScene, fileIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.Scene, []models.FileID) error); ok {\n\t\tr0 = rf(ctx, newScene, fileIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// DeleteAllViews provides a mock function with given fields: ctx, id\nfunc (_m *SceneReaderWriter) DeleteAllViews(ctx context.Context, id int) (int, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// DeleteO provides a mock function with given fields: ctx, id, dates\nfunc (_m *SceneReaderWriter) DeleteO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\tret := _m.Called(ctx, id, dates)\n\n\tvar r0 []time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok {\n\t\tr0 = rf(ctx, id, dates)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok {\n\t\tr1 = rf(ctx, id, dates)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// DeleteViews provides a mock function with given fields: ctx, id, dates\nfunc (_m *SceneReaderWriter) DeleteViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\tret := _m.Called(ctx, id, dates)\n\n\tvar r0 []time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok {\n\t\tr0 = rf(ctx, id, dates)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok {\n\t\tr1 = rf(ctx, id, dates)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *SceneReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Duration provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) Duration(ctx context.Context) (float64, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 float64\n\tif rf, ok := ret.Get(0).(func(context.Context) float64); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(float64)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *SceneReaderWriter) Find(ctx context.Context, id int) (*models.Scene, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Scene); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByChecksum provides a mock function with given fields: ctx, checksum\nfunc (_m *SceneReaderWriter) FindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, checksum)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, string) []*models.Scene); ok {\n\t\tr0 = rf(ctx, checksum)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string) error); ok {\n\t\tr1 = rf(ctx, checksum)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFileID provides a mock function with given fields: ctx, fileID\nfunc (_m *SceneReaderWriter) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Scene); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByFingerprints provides a mock function with given fields: ctx, fp\nfunc (_m *SceneReaderWriter) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, fp)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, []models.Fingerprint) []*models.Scene); ok {\n\t\tr0 = rf(ctx, fp)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []models.Fingerprint) error); ok {\n\t\tr1 = rf(ctx, fp)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByGalleryID provides a mock function with given fields: ctx, performerID\nfunc (_m *SceneReaderWriter) FindByGalleryID(ctx context.Context, performerID int) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Scene); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByGroupID provides a mock function with given fields: ctx, groupID\nfunc (_m *SceneReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, groupID)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Scene); ok {\n\t\tr0 = rf(ctx, groupID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, groupID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByIDs provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Scene); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByOSHash provides a mock function with given fields: ctx, oshash\nfunc (_m *SceneReaderWriter) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, oshash)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, string) []*models.Scene); ok {\n\t\tr0 = rf(ctx, oshash)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string) error); ok {\n\t\tr1 = rf(ctx, oshash)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByPath provides a mock function with given fields: ctx, path\nfunc (_m *SceneReaderWriter) FindByPath(ctx context.Context, path string) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, path)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, string) []*models.Scene); ok {\n\t\tr0 = rf(ctx, path)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string) error); ok {\n\t\tr1 = rf(ctx, path)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByPerformerID provides a mock function with given fields: ctx, performerID\nfunc (_m *SceneReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Scene); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByPrimaryFileID provides a mock function with given fields: ctx, fileID\nfunc (_m *SceneReaderWriter) FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, fileID)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Scene); ok {\n\t\tr0 = rf(ctx, fileID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok {\n\t\tr1 = rf(ctx, fileID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindDuplicates provides a mock function with given fields: ctx, distance, durationDiff\nfunc (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) {\n\tret := _m.Called(ctx, distance, durationDiff)\n\n\tvar r0 [][]*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, int, float64) [][]*models.Scene); ok {\n\t\tr0 = rf(ctx, distance, durationDiff)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([][]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, float64) error); ok {\n\t\tr1 = rf(ctx, distance, durationDiff)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Scene); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetAllOCount provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) GetAllOCount(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCover provides a mock function with given fields: ctx, sceneID\nfunc (_m *SceneReaderWriter) GetCover(ctx context.Context, sceneID int) ([]byte, error) {\n\tret := _m.Called(ctx, sceneID)\n\n\tvar r0 []byte\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok {\n\t\tr0 = rf(ctx, sceneID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]byte)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, sceneID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFields provides a mock function with given fields: ctx, id\nfunc (_m *SceneReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 map[string]interface{}\n\tif rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(map[string]interface{})\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []models.CustomFieldMap\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.CustomFieldMap)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetFiles provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]*models.VideoFile, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []*models.VideoFile\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.VideoFile); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.VideoFile)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetGalleryIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneReaderWriter) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetGroups provides a mock function with given fields: ctx, id\nfunc (_m *SceneReaderWriter) GetGroups(ctx context.Context, id int) ([]models.GroupsScenes, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 []models.GroupsScenes\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupsScenes); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.GroupsScenes)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyFileIDs provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 [][]models.FileID\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) [][]models.FileID); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([][]models.FileID)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyLastViewed provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) GetManyLastViewed(ctx context.Context, ids []int) ([]*time.Time, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*time.Time); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyOCount provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) GetManyOCount(ctx context.Context, ids []int) ([]int, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []int); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyODates provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) GetManyODates(ctx context.Context, ids []int) ([][]time.Time, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 [][]time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) [][]time.Time); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([][]time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyViewCount provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) GetManyViewCount(ctx context.Context, ids []int) ([]int, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []int); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetManyViewDates provides a mock function with given fields: ctx, ids\nfunc (_m *SceneReaderWriter) GetManyViewDates(ctx context.Context, ids []int) ([][]time.Time, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 [][]time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) [][]time.Time); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([][]time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetOCount provides a mock function with given fields: ctx, id\nfunc (_m *SceneReaderWriter) GetOCount(ctx context.Context, id int) (int, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetODates provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneReaderWriter) GetODates(ctx context.Context, relatedID int) ([]time.Time, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []time.Time); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetPerformerIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetStashIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []models.StashID\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.StashID)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetTagIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetURLs provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetViewDates provides a mock function with given fields: ctx, relatedID\nfunc (_m *SceneReaderWriter) GetViewDates(ctx context.Context, relatedID int) ([]time.Time, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []time.Time\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []time.Time); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]time.Time)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// HasCover provides a mock function with given fields: ctx, sceneID\nfunc (_m *SceneReaderWriter) HasCover(ctx context.Context, sceneID int) (bool, error) {\n\tret := _m.Called(ctx, sceneID)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {\n\t\tr0 = rf(ctx, sceneID)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, sceneID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// OCountByGroupID provides a mock function with given fields: ctx, groupID\nfunc (_m *SceneReaderWriter) OCountByGroupID(ctx context.Context, groupID int) (int, error) {\n\tret := _m.Called(ctx, groupID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, groupID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, groupID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// OCountByPerformerID provides a mock function with given fields: ctx, performerID\nfunc (_m *SceneReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// OCountByStudioID provides a mock function with given fields: ctx, studioID\nfunc (_m *SceneReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) {\n\tret := _m.Called(ctx, studioID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, studioID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, studioID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// PlayDuration provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) PlayDuration(ctx context.Context) (float64, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 float64\n\tif rf, ok := ret.Get(0).(func(context.Context) float64); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(float64)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Query provides a mock function with given fields: ctx, options\nfunc (_m *SceneReaderWriter) Query(ctx context.Context, options models.SceneQueryOptions) (*models.SceneQueryResult, error) {\n\tret := _m.Called(ctx, options)\n\n\tvar r0 *models.SceneQueryResult\n\tif rf, ok := ret.Get(0).(func(context.Context, models.SceneQueryOptions) *models.SceneQueryResult); ok {\n\t\tr0 = rf(ctx, options)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.SceneQueryResult)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.SceneQueryOptions) error); ok {\n\t\tr1 = rf(ctx, options)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// QueryCount provides a mock function with given fields: ctx, sceneFilter, findFilter\nfunc (_m *SceneReaderWriter) QueryCount(ctx context.Context, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) (int, error) {\n\tret := _m.Called(ctx, sceneFilter, findFilter)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.SceneFilterType, *models.FindFilterType) int); ok {\n\t\tr0 = rf(ctx, sceneFilter, findFilter)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.SceneFilterType, *models.FindFilterType) error); ok {\n\t\tr1 = rf(ctx, sceneFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// ResetActivity provides a mock function with given fields: ctx, sceneID, resetResume, resetDuration\nfunc (_m *SceneReaderWriter) ResetActivity(ctx context.Context, sceneID int, resetResume bool, resetDuration bool) (bool, error) {\n\tret := _m.Called(ctx, sceneID, resetResume, resetDuration)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, int, bool, bool) bool); ok {\n\t\tr0 = rf(ctx, sceneID, resetResume, resetDuration)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, bool, bool) error); ok {\n\t\tr1 = rf(ctx, sceneID, resetResume, resetDuration)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// ResetO provides a mock function with given fields: ctx, id\nfunc (_m *SceneReaderWriter) ResetO(ctx context.Context, id int) (int, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// SaveActivity provides a mock function with given fields: ctx, sceneID, resumeTime, playDuration\nfunc (_m *SceneReaderWriter) SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) {\n\tret := _m.Called(ctx, sceneID, resumeTime, playDuration)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, int, *float64, *float64) bool); ok {\n\t\tr0 = rf(ctx, sceneID, resumeTime, playDuration)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, *float64, *float64) error); ok {\n\t\tr1 = rf(ctx, sceneID, resumeTime, playDuration)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// SetCustomFields provides a mock function with given fields: ctx, id, fields\nfunc (_m *SceneReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error {\n\tret := _m.Called(ctx, id, fields)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok {\n\t\tr0 = rf(ctx, id, fields)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Size provides a mock function with given fields: ctx\nfunc (_m *SceneReaderWriter) Size(ctx context.Context) (float64, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 float64\n\tif rf, ok := ret.Get(0).(func(context.Context) float64); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(float64)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, updatedScene\nfunc (_m *SceneReaderWriter) Update(ctx context.Context, updatedScene *models.Scene) error {\n\tret := _m.Called(ctx, updatedScene)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.Scene) error); ok {\n\t\tr0 = rf(ctx, updatedScene)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateCover provides a mock function with given fields: ctx, sceneID, cover\nfunc (_m *SceneReaderWriter) UpdateCover(ctx context.Context, sceneID int, cover []byte) error {\n\tret := _m.Called(ctx, sceneID, cover)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok {\n\t\tr0 = rf(ctx, sceneID, cover)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, id, updatedScene\nfunc (_m *SceneReaderWriter) UpdatePartial(ctx context.Context, id int, updatedScene models.ScenePartial) (*models.Scene, error) {\n\tret := _m.Called(ctx, id, updatedScene)\n\n\tvar r0 *models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.ScenePartial) *models.Scene); ok {\n\t\tr0 = rf(ctx, id, updatedScene)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, models.ScenePartial) error); ok {\n\t\tr1 = rf(ctx, id, updatedScene)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Wall provides a mock function with given fields: ctx, q\nfunc (_m *SceneReaderWriter) Wall(ctx context.Context, q *string) ([]*models.Scene, error) {\n\tret := _m.Called(ctx, q)\n\n\tvar r0 []*models.Scene\n\tif rf, ok := ret.Get(0).(func(context.Context, *string) []*models.Scene); ok {\n\t\tr0 = rf(ctx, q)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Scene)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *string) error); ok {\n\t\tr1 = rf(ctx, q)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n"
  },
  {
    "path": "pkg/models/mocks/StudioReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// StudioReaderWriter is an autogenerated mock type for the StudioReaderWriter type\ntype StudioReaderWriter struct {\n\tmock.Mock\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *StudioReaderWriter) All(ctx context.Context) ([]*models.Studio, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.Studio); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Count provides a mock function with given fields: ctx\nfunc (_m *StudioReaderWriter) Count(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByTagID provides a mock function with given fields: ctx, tagID\nfunc (_m *StudioReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) {\n\tret := _m.Called(ctx, tagID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, tagID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, tagID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, newStudio\nfunc (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.CreateStudioInput) error {\n\tret := _m.Called(ctx, newStudio)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.CreateStudioInput) error); ok {\n\t\tr0 = rf(ctx, newStudio)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *StudioReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *StudioReaderWriter) Find(ctx context.Context, id int) (*models.Studio, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Studio); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByName provides a mock function with given fields: ctx, name, nocase\nfunc (_m *StudioReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Studio, error) {\n\tret := _m.Called(ctx, name, nocase)\n\n\tvar r0 *models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Studio); ok {\n\t\tr0 = rf(ctx, name, nocase)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {\n\t\tr1 = rf(ctx, name, nocase)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindBySceneID provides a mock function with given fields: ctx, sceneID\nfunc (_m *StudioReaderWriter) FindBySceneID(ctx context.Context, sceneID int) (*models.Studio, error) {\n\tret := _m.Called(ctx, sceneID)\n\n\tvar r0 *models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Studio); ok {\n\t\tr0 = rf(ctx, sceneID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, sceneID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByStashID provides a mock function with given fields: ctx, stashID\nfunc (_m *StudioReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error) {\n\tret := _m.Called(ctx, stashID)\n\n\tvar r0 []*models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Studio); ok {\n\t\tr0 = rf(ctx, stashID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok {\n\t\tr1 = rf(ctx, stashID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint\nfunc (_m *StudioReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Studio, error) {\n\tret := _m.Called(ctx, hasStashID, stashboxEndpoint)\n\n\tvar r0 []*models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Studio); ok {\n\t\tr0 = rf(ctx, hasStashID, stashboxEndpoint)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok {\n\t\tr1 = rf(ctx, hasStashID, stashboxEndpoint)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindChildren provides a mock function with given fields: ctx, id\nfunc (_m *StudioReaderWriter) FindChildren(ctx context.Context, id int) ([]*models.Studio, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 []*models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Studio); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *StudioReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Studio, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Studio); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetAliases provides a mock function with given fields: ctx, relatedID\nfunc (_m *StudioReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFields provides a mock function with given fields: ctx, id\nfunc (_m *StudioReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 map[string]interface{}\n\tif rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(map[string]interface{})\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids\nfunc (_m *StudioReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []models.CustomFieldMap\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.CustomFieldMap)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetImage provides a mock function with given fields: ctx, studioID\nfunc (_m *StudioReaderWriter) GetImage(ctx context.Context, studioID int) ([]byte, error) {\n\tret := _m.Called(ctx, studioID)\n\n\tvar r0 []byte\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok {\n\t\tr0 = rf(ctx, studioID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]byte)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, studioID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetStashIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *StudioReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []models.StashID\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.StashID)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetTagIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *StudioReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetURLs provides a mock function with given fields: ctx, relatedID\nfunc (_m *StudioReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// HasImage provides a mock function with given fields: ctx, studioID\nfunc (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) {\n\tret := _m.Called(ctx, studioID)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {\n\t\tr0 = rf(ctx, studioID)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, studioID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Query provides a mock function with given fields: ctx, studioFilter, findFilter\nfunc (_m *StudioReaderWriter) Query(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) {\n\tret := _m.Called(ctx, studioFilter, findFilter)\n\n\tvar r0 []*models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) []*models.Studio); ok {\n\t\tr0 = rf(ctx, studioFilter, findFilter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 int\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) int); ok {\n\t\tr1 = rf(ctx, studioFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Get(1).(int)\n\t}\n\n\tvar r2 error\n\tif rf, ok := ret.Get(2).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) error); ok {\n\t\tr2 = rf(ctx, studioFilter, findFilter)\n\t} else {\n\t\tr2 = ret.Error(2)\n\t}\n\n\treturn r0, r1, r2\n}\n\n// QueryCount provides a mock function with given fields: ctx, studioFilter, findFilter\nfunc (_m *StudioReaderWriter) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) {\n\tret := _m.Called(ctx, studioFilter, findFilter)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) int); ok {\n\t\tr0 = rf(ctx, studioFilter, findFilter)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) error); ok {\n\t\tr1 = rf(ctx, studioFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// QueryForAutoTag provides a mock function with given fields: ctx, words\nfunc (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) {\n\tret := _m.Called(ctx, words)\n\n\tvar r0 []*models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, []string) []*models.Studio); ok {\n\t\tr0 = rf(ctx, words)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {\n\t\tr1 = rf(ctx, words)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Update provides a mock function with given fields: ctx, updatedStudio\nfunc (_m *StudioReaderWriter) Update(ctx context.Context, updatedStudio *models.UpdateStudioInput) error {\n\tret := _m.Called(ctx, updatedStudio)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.UpdateStudioInput) error); ok {\n\t\tr0 = rf(ctx, updatedStudio)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateImage provides a mock function with given fields: ctx, studioID, image\nfunc (_m *StudioReaderWriter) UpdateImage(ctx context.Context, studioID int, image []byte) error {\n\tret := _m.Called(ctx, studioID, image)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok {\n\t\tr0 = rf(ctx, studioID, image)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, updatedStudio\nfunc (_m *StudioReaderWriter) UpdatePartial(ctx context.Context, updatedStudio models.StudioPartial) (*models.Studio, error) {\n\tret := _m.Called(ctx, updatedStudio)\n\n\tvar r0 *models.Studio\n\tif rf, ok := ret.Get(0).(func(context.Context, models.StudioPartial) *models.Studio); ok {\n\t\tr0 = rf(ctx, updatedStudio)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Studio)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.StudioPartial) error); ok {\n\t\tr1 = rf(ctx, updatedStudio)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n"
  },
  {
    "path": "pkg/models/mocks/TagReaderWriter.go",
    "content": "// Code generated by mockery v2.10.0. DO NOT EDIT.\n\npackage mocks\n\nimport (\n\tcontext \"context\"\n\n\tmodels \"github.com/stashapp/stash/pkg/models\"\n\tmock \"github.com/stretchr/testify/mock\"\n)\n\n// TagReaderWriter is an autogenerated mock type for the TagReaderWriter type\ntype TagReaderWriter struct {\n\tmock.Mock\n}\n\n// All provides a mock function with given fields: ctx\nfunc (_m *TagReaderWriter) All(ctx context.Context) ([]*models.Tag, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context) []*models.Tag); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Count provides a mock function with given fields: ctx\nfunc (_m *TagReaderWriter) Count(ctx context.Context) (int, error) {\n\tret := _m.Called(ctx)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context) int); ok {\n\t\tr0 = rf(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = rf(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByChildTagID provides a mock function with given fields: ctx, childID\nfunc (_m *TagReaderWriter) CountByChildTagID(ctx context.Context, childID int) (int, error) {\n\tret := _m.Called(ctx, childID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, childID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, childID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// CountByParentTagID provides a mock function with given fields: ctx, parentID\nfunc (_m *TagReaderWriter) CountByParentTagID(ctx context.Context, parentID int) (int, error) {\n\tret := _m.Called(ctx, parentID)\n\n\tvar r0 int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) int); ok {\n\t\tr0 = rf(ctx, parentID)\n\t} else {\n\t\tr0 = ret.Get(0).(int)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, parentID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Create provides a mock function with given fields: ctx, newTag\nfunc (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.CreateTagInput) error {\n\tret := _m.Called(ctx, newTag)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.CreateTagInput) error); ok {\n\t\tr0 = rf(ctx, newTag)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Destroy provides a mock function with given fields: ctx, id\nfunc (_m *TagReaderWriter) Destroy(ctx context.Context, id int) error {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int) error); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Find provides a mock function with given fields: ctx, id\nfunc (_m *TagReaderWriter) Find(ctx context.Context, id int) (*models.Tag, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 *models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) *models.Tag); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindAllAncestors provides a mock function with given fields: ctx, tagID, excludeIDs\nfunc (_m *TagReaderWriter) FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) {\n\tret := _m.Called(ctx, tagID, excludeIDs)\n\n\tvar r0 []*models.TagPath\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) []*models.TagPath); ok {\n\t\tr0 = rf(ctx, tagID, excludeIDs)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.TagPath)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, []int) error); ok {\n\t\tr1 = rf(ctx, tagID, excludeIDs)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindAllDescendants provides a mock function with given fields: ctx, tagID, excludeIDs\nfunc (_m *TagReaderWriter) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) {\n\tret := _m.Called(ctx, tagID, excludeIDs)\n\n\tvar r0 []*models.TagPath\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) []*models.TagPath); ok {\n\t\tr0 = rf(ctx, tagID, excludeIDs)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.TagPath)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, []int) error); ok {\n\t\tr1 = rf(ctx, tagID, excludeIDs)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByChildTagID provides a mock function with given fields: ctx, childID\nfunc (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, childID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, childID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, childID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByGalleryID provides a mock function with given fields: ctx, galleryID\nfunc (_m *TagReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, galleryID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, galleryID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, galleryID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByGroupID provides a mock function with given fields: ctx, groupID\nfunc (_m *TagReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, groupID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, groupID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, groupID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByImageID provides a mock function with given fields: ctx, imageID\nfunc (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, imageID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, imageID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, imageID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByName provides a mock function with given fields: ctx, name, nocase\nfunc (_m *TagReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {\n\tret := _m.Called(ctx, name, nocase)\n\n\tvar r0 *models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Tag); ok {\n\t\tr0 = rf(ctx, name, nocase)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {\n\t\tr1 = rf(ctx, name, nocase)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByNames provides a mock function with given fields: ctx, names, nocase\nfunc (_m *TagReaderWriter) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, names, nocase)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, []string, bool) []*models.Tag); ok {\n\t\tr0 = rf(ctx, names, nocase)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string, bool) error); ok {\n\t\tr1 = rf(ctx, names, nocase)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByParentTagID provides a mock function with given fields: ctx, parentID\nfunc (_m *TagReaderWriter) FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, parentID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, parentID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, parentID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByPerformerID provides a mock function with given fields: ctx, performerID\nfunc (_m *TagReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, performerID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, performerID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, performerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindBySceneID provides a mock function with given fields: ctx, sceneID\nfunc (_m *TagReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, sceneID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, sceneID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, sceneID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindBySceneMarkerID provides a mock function with given fields: ctx, sceneMarkerID\nfunc (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, sceneMarkerID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, sceneMarkerID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, sceneMarkerID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByStashID provides a mock function with given fields: ctx, stashID\nfunc (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, stashID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Tag); ok {\n\t\tr0 = rf(ctx, stashID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok {\n\t\tr1 = rf(ctx, stashID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint\nfunc (_m *TagReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, hasStashID, stashboxEndpoint)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Tag); ok {\n\t\tr0 = rf(ctx, hasStashID, stashboxEndpoint)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok {\n\t\tr1 = rf(ctx, hasStashID, stashboxEndpoint)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindByStudioID provides a mock function with given fields: ctx, studioID\nfunc (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, studioID)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, studioID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, studioID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// FindMany provides a mock function with given fields: ctx, ids\nfunc (_m *TagReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Tag); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetAliases provides a mock function with given fields: ctx, relatedID\nfunc (_m *TagReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]string, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []string\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []string); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetChildIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *TagReaderWriter) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFields provides a mock function with given fields: ctx, id\nfunc (_m *TagReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {\n\tret := _m.Called(ctx, id)\n\n\tvar r0 map[string]interface{}\n\tif rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {\n\t\tr0 = rf(ctx, id)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(map[string]interface{})\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, id)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids\nfunc (_m *TagReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {\n\tret := _m.Called(ctx, ids)\n\n\tvar r0 []models.CustomFieldMap\n\tif rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {\n\t\tr0 = rf(ctx, ids)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.CustomFieldMap)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {\n\t\tr1 = rf(ctx, ids)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetImage provides a mock function with given fields: ctx, tagID\nfunc (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, error) {\n\tret := _m.Called(ctx, tagID)\n\n\tvar r0 []byte\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok {\n\t\tr0 = rf(ctx, tagID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]byte)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, tagID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetParentIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *TagReaderWriter) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []int\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]int)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// GetStashIDs provides a mock function with given fields: ctx, relatedID\nfunc (_m *TagReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) {\n\tret := _m.Called(ctx, relatedID)\n\n\tvar r0 []models.StashID\n\tif rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok {\n\t\tr0 = rf(ctx, relatedID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]models.StashID)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, relatedID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// HasImage provides a mock function with given fields: ctx, tagID\nfunc (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) {\n\tret := _m.Called(ctx, tagID)\n\n\tvar r0 bool\n\tif rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {\n\t\tr0 = rf(ctx, tagID)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int) error); ok {\n\t\tr1 = rf(ctx, tagID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// Merge provides a mock function with given fields: ctx, source, destination\nfunc (_m *TagReaderWriter) Merge(ctx context.Context, source []int, destination int) error {\n\tret := _m.Called(ctx, source, destination)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok {\n\t\tr0 = rf(ctx, source, destination)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Query provides a mock function with given fields: ctx, tagFilter, findFilter\nfunc (_m *TagReaderWriter) Query(ctx context.Context, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) ([]*models.Tag, int, error) {\n\tret := _m.Called(ctx, tagFilter, findFilter)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.TagFilterType, *models.FindFilterType) []*models.Tag); ok {\n\t\tr0 = rf(ctx, tagFilter, findFilter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 int\n\tif rf, ok := ret.Get(1).(func(context.Context, *models.TagFilterType, *models.FindFilterType) int); ok {\n\t\tr1 = rf(ctx, tagFilter, findFilter)\n\t} else {\n\t\tr1 = ret.Get(1).(int)\n\t}\n\n\tvar r2 error\n\tif rf, ok := ret.Get(2).(func(context.Context, *models.TagFilterType, *models.FindFilterType) error); ok {\n\t\tr2 = rf(ctx, tagFilter, findFilter)\n\t} else {\n\t\tr2 = ret.Error(2)\n\t}\n\n\treturn r0, r1, r2\n}\n\n// QueryForAutoTag provides a mock function with given fields: ctx, words\nfunc (_m *TagReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Tag, error) {\n\tret := _m.Called(ctx, words)\n\n\tvar r0 []*models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, []string) []*models.Tag); ok {\n\t\tr0 = rf(ctx, words)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {\n\t\tr1 = rf(ctx, words)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n\n// SetCustomFields provides a mock function with given fields: ctx, id, fields\nfunc (_m *TagReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error {\n\tret := _m.Called(ctx, id, fields)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok {\n\t\tr0 = rf(ctx, id, fields)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// Update provides a mock function with given fields: ctx, updatedTag\nfunc (_m *TagReaderWriter) Update(ctx context.Context, updatedTag *models.UpdateTagInput) error {\n\tret := _m.Called(ctx, updatedTag)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, *models.UpdateTagInput) error); ok {\n\t\tr0 = rf(ctx, updatedTag)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateAliases provides a mock function with given fields: ctx, tagID, aliases\nfunc (_m *TagReaderWriter) UpdateAliases(ctx context.Context, tagID int, aliases []string) error {\n\tret := _m.Called(ctx, tagID, aliases)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []string) error); ok {\n\t\tr0 = rf(ctx, tagID, aliases)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateChildTags provides a mock function with given fields: ctx, tagID, parentIDs\nfunc (_m *TagReaderWriter) UpdateChildTags(ctx context.Context, tagID int, parentIDs []int) error {\n\tret := _m.Called(ctx, tagID, parentIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {\n\t\tr0 = rf(ctx, tagID, parentIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateImage provides a mock function with given fields: ctx, tagID, image\nfunc (_m *TagReaderWriter) UpdateImage(ctx context.Context, tagID int, image []byte) error {\n\tret := _m.Called(ctx, tagID, image)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok {\n\t\tr0 = rf(ctx, tagID, image)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdateParentTags provides a mock function with given fields: ctx, tagID, parentIDs\nfunc (_m *TagReaderWriter) UpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error {\n\tret := _m.Called(ctx, tagID, parentIDs)\n\n\tvar r0 error\n\tif rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {\n\t\tr0 = rf(ctx, tagID, parentIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\n\treturn r0\n}\n\n// UpdatePartial provides a mock function with given fields: ctx, id, updateTag\nfunc (_m *TagReaderWriter) UpdatePartial(ctx context.Context, id int, updateTag models.TagPartial) (*models.Tag, error) {\n\tret := _m.Called(ctx, id, updateTag)\n\n\tvar r0 *models.Tag\n\tif rf, ok := ret.Get(0).(func(context.Context, int, models.TagPartial) *models.Tag); ok {\n\t\tr0 = rf(ctx, id, updateTag)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*models.Tag)\n\t\t}\n\t}\n\n\tvar r1 error\n\tif rf, ok := ret.Get(1).(func(context.Context, int, models.TagPartial) error); ok {\n\t\tr1 = rf(ctx, id, updateTag)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\n\treturn r0, r1\n}\n"
  },
  {
    "path": "pkg/models/mocks/database.go",
    "content": "// Package mocks provides mocks for various interfaces in [models].\npackage mocks\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\ntype Database struct {\n\tFile           *FileReaderWriter\n\tFolder         *FolderReaderWriter\n\tGallery        *GalleryReaderWriter\n\tGalleryChapter *GalleryChapterReaderWriter\n\tImage          *ImageReaderWriter\n\tGroup          *GroupReaderWriter\n\tPerformer      *PerformerReaderWriter\n\tScene          *SceneReaderWriter\n\tSceneMarker    *SceneMarkerReaderWriter\n\tStudio         *StudioReaderWriter\n\tTag            *TagReaderWriter\n\tSavedFilter    *SavedFilterReaderWriter\n}\n\nfunc (*Database) Begin(ctx context.Context, exclusive bool) (context.Context, error) {\n\treturn ctx, nil\n}\n\nfunc (*Database) WithDatabase(ctx context.Context) (context.Context, error) {\n\treturn ctx, nil\n}\n\nfunc (*Database) Commit(ctx context.Context) error {\n\treturn nil\n}\n\nfunc (*Database) Rollback(ctx context.Context) error {\n\treturn nil\n}\n\nfunc (*Database) Complete(ctx context.Context) {\n}\n\nfunc (*Database) AddPostCommitHook(ctx context.Context, hook txn.TxnFunc) {\n}\n\nfunc (*Database) AddPostRollbackHook(ctx context.Context, hook txn.TxnFunc) {\n}\n\nfunc (*Database) IsLocked(err error) bool {\n\treturn false\n}\n\nfunc (*Database) Reset() error {\n\treturn nil\n}\n\nfunc NewDatabase() *Database {\n\treturn &Database{\n\t\tFile:           &FileReaderWriter{},\n\t\tFolder:         &FolderReaderWriter{},\n\t\tGallery:        &GalleryReaderWriter{},\n\t\tGalleryChapter: &GalleryChapterReaderWriter{},\n\t\tImage:          &ImageReaderWriter{},\n\t\tGroup:          &GroupReaderWriter{},\n\t\tPerformer:      &PerformerReaderWriter{},\n\t\tScene:          &SceneReaderWriter{},\n\t\tSceneMarker:    &SceneMarkerReaderWriter{},\n\t\tStudio:         &StudioReaderWriter{},\n\t\tTag:            &TagReaderWriter{},\n\t\tSavedFilter:    &SavedFilterReaderWriter{},\n\t}\n}\n\nfunc (db *Database) AssertExpectations(t mock.TestingT) {\n\tdb.File.AssertExpectations(t)\n\tdb.Folder.AssertExpectations(t)\n\tdb.Gallery.AssertExpectations(t)\n\tdb.GalleryChapter.AssertExpectations(t)\n\tdb.Image.AssertExpectations(t)\n\tdb.Group.AssertExpectations(t)\n\tdb.Performer.AssertExpectations(t)\n\tdb.Scene.AssertExpectations(t)\n\tdb.SceneMarker.AssertExpectations(t)\n\tdb.Studio.AssertExpectations(t)\n\tdb.Tag.AssertExpectations(t)\n\tdb.SavedFilter.AssertExpectations(t)\n}\n\n// WithTxnCtx runs fn with a context that has a transaction hook manager registered,\n// so code that calls txn.AddPostCommitHook (e.g. plugin cache) won't nil-panic.\n// Always rolls back to avoid executing the registered hooks.\nfunc (db *Database) WithTxnCtx(fn func(ctx context.Context)) {\n\t_ = txn.WithTxn(context.Background(), db, func(ctx context.Context) error {\n\t\tfn(ctx)\n\t\treturn errors.New(\"rollback\")\n\t})\n}\n\nfunc (db *Database) Repository() models.Repository {\n\treturn models.Repository{\n\t\tTxnManager:     db,\n\t\tFile:           db.File,\n\t\tFolder:         db.Folder,\n\t\tGallery:        db.Gallery,\n\t\tGalleryChapter: db.GalleryChapter,\n\t\tImage:          db.Image,\n\t\tGroup:          db.Group,\n\t\tPerformer:      db.Performer,\n\t\tScene:          db.Scene,\n\t\tSceneMarker:    db.SceneMarker,\n\t\tStudio:         db.Studio,\n\t\tTag:            db.Tag,\n\t\tSavedFilter:    db.SavedFilter,\n\t}\n}\n"
  },
  {
    "path": "pkg/models/mocks/query.go",
    "content": "package mocks\n\nimport (\n\tcontext \"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype sceneResolver struct {\n\tscenes []*models.Scene\n}\n\nfunc (s *sceneResolver) Find(ctx context.Context, id int) (*models.Scene, error) {\n\tpanic(\"not implemented\")\n}\n\nfunc (s *sceneResolver) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {\n\treturn s.scenes, nil\n}\n\nfunc (s *sceneResolver) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {\n\treturn s.scenes, nil\n}\n\nfunc SceneQueryResult(scenes []*models.Scene, count int) *models.SceneQueryResult {\n\tret := models.NewSceneQueryResult(&sceneResolver{\n\t\tscenes: scenes,\n\t})\n\n\tret.Count = count\n\treturn ret\n}\n\ntype imageResolver struct {\n\timages []*models.Image\n}\n\nfunc (s *imageResolver) Find(ctx context.Context, id int) (*models.Image, error) {\n\tpanic(\"not implemented\")\n}\n\nfunc (s *imageResolver) FindMany(ctx context.Context, ids []int) ([]*models.Image, error) {\n\treturn s.images, nil\n}\n\nfunc ImageQueryResult(images []*models.Image, count int) *models.ImageQueryResult {\n\tret := models.NewImageQueryResult(&imageResolver{\n\t\timages: images,\n\t})\n\n\tret.Count = count\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/models/model_file.go",
    "content": "package models\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"math\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n)\n\ntype HashAlgorithm string\n\nconst (\n\tHashAlgorithmMd5 HashAlgorithm = \"MD5\"\n\t// oshash\n\tHashAlgorithmOshash HashAlgorithm = \"OSHASH\"\n)\n\nvar AllHashAlgorithm = []HashAlgorithm{\n\tHashAlgorithmMd5,\n\tHashAlgorithmOshash,\n}\n\nfunc (e HashAlgorithm) IsValid() bool {\n\tswitch e {\n\tcase HashAlgorithmMd5, HashAlgorithmOshash:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e HashAlgorithm) String() string {\n\treturn string(e)\n}\n\nfunc (e *HashAlgorithm) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = HashAlgorithm(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid HashAlgorithm\", str)\n\t}\n\treturn nil\n}\n\nfunc (e HashAlgorithm) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\n// ID represents an ID of a file.\ntype FileID int32\n\nfunc (i FileID) String() string {\n\treturn strconv.Itoa(int(i))\n}\n\nfunc (i *FileID) UnmarshalGQL(v interface{}) (err error) {\n\tswitch v := v.(type) {\n\tcase string:\n\t\tvar id int\n\t\tid, err = strconv.Atoi(v)\n\t\t*i = FileID(id)\n\t\treturn err\n\tcase int:\n\t\t*i = FileID(v)\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"%T is not an int\", v)\n\t}\n}\n\nfunc (i FileID) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(i.String()))\n}\n\nfunc FileIDsFromInts(ids []int) []FileID {\n\tret := make([]FileID, len(ids))\n\tfor i, id := range ids {\n\t\tret[i] = FileID(id)\n\t}\n\treturn ret\n}\n\n// DirEntry represents a file or directory in the file system.\ntype DirEntry struct {\n\tZipFileID *FileID `json:\"zip_file_id\"`\n\n\t// transient - not persisted\n\t// only guaranteed to have id, path and basename set\n\tZipFile File\n\n\tModTime time.Time `json:\"mod_time\"`\n}\n\nfunc (e *DirEntry) info(fs FS, path string) (fs.FileInfo, error) {\n\tif e.ZipFile != nil {\n\t\tzipPath := e.ZipFile.Base().Path\n\t\tzfs, err := fs.OpenZip(zipPath, e.ZipFile.Base().Size)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tdefer zfs.Close()\n\t\tfs = zfs\n\t}\n\t// else assume os file\n\n\tret, err := fs.Lstat(path)\n\treturn ret, err\n}\n\n// File represents a file in the file system.\ntype File interface {\n\tBase() *BaseFile\n\tSetFingerprints(fp Fingerprints)\n\tOpen(fs FS) (io.ReadCloser, error)\n\tClone() File\n}\n\n// BaseFile represents a file in the file system.\ntype BaseFile struct {\n\tID FileID `json:\"id\"`\n\n\tDirEntry\n\n\t// resolved from parent folder and basename only - not stored in DB\n\tPath string `json:\"path\"`\n\n\tBasename       string   `json:\"basename\"`\n\tParentFolderID FolderID `json:\"parent_folder_id\"`\n\n\tFingerprints Fingerprints `json:\"fingerprints\"`\n\n\tSize int64 `json:\"size\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// SetFingerprints sets the fingerprints of the file.\n// If a fingerprint of the same type already exists, it is overwritten.\nfunc (f *BaseFile) SetFingerprints(fp Fingerprints) {\n\tfor _, v := range fp {\n\t\tf.SetFingerprint(v)\n\t}\n}\n\n// SetFingerprint sets the fingerprint of the file.\n// If a fingerprint of the same type already exists, it is overwritten.\nfunc (f *BaseFile) SetFingerprint(fp Fingerprint) {\n\tfor i, existing := range f.Fingerprints {\n\t\tif existing.Type == fp.Type {\n\t\t\tf.Fingerprints[i] = fp\n\t\t\treturn\n\t\t}\n\t}\n\n\tf.Fingerprints = append(f.Fingerprints, fp)\n}\n\n// Base is used to fulfil the File interface.\nfunc (f *BaseFile) Base() *BaseFile {\n\treturn f\n}\n\nfunc (f *BaseFile) Open(fs FS) (io.ReadCloser, error) {\n\tif f.ZipFile != nil {\n\t\tzipPath := f.ZipFile.Base().Path\n\t\tzfs, err := fs.OpenZip(zipPath, f.ZipFile.Base().Size)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn zfs.OpenOnly(f.Path)\n\t}\n\n\treturn fs.Open(f.Path)\n}\n\nfunc (f *BaseFile) Clone() (ret File) {\n\tclone := *f\n\tret = &clone\n\treturn\n}\n\nfunc (f *BaseFile) Info(fs FS) (fs.FileInfo, error) {\n\treturn f.info(fs, f.Path)\n}\n\nfunc (f *BaseFile) Serve(fs FS, w http.ResponseWriter, r *http.Request) error {\n\treader, err := f.Open(fs)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer reader.Close()\n\n\tcontent, ok := reader.(io.ReadSeeker)\n\tif !ok {\n\t\tdata, err := io.ReadAll(reader)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcontent = bytes.NewReader(data)\n\t}\n\n\tif r.URL.Query().Has(\"t\") {\n\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=31536000, immutable\")\n\t} else {\n\t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\t}\n\n\t// Set filename if not previously set\n\tif w.Header().Get(\"Content-Disposition\") == \"\" {\n\t\tw.Header().Set(\"Content-Disposition\", fmt.Sprintf(`filename=\"%s\"`, f.Basename))\n\t}\n\n\thttp.ServeContent(w, r, f.Basename, f.ModTime, content)\n\n\treturn nil\n}\n\n// VisualFile is an interface for files that have a width and height.\ntype VisualFile interface {\n\tFile\n\tGetWidth() int\n\tGetHeight() int\n\tGetFormat() string\n}\n\nfunc GetMinResolution(f VisualFile) int {\n\tw := f.GetWidth()\n\th := f.GetHeight()\n\n\tif w < h {\n\t\treturn w\n\t}\n\n\treturn h\n}\n\n// ImageFile is an extension of BaseFile to represent image files.\ntype ImageFile struct {\n\t*BaseFile\n\tFormat string `json:\"format\"`\n\tWidth  int    `json:\"width\"`\n\tHeight int    `json:\"height\"`\n}\n\nfunc (f ImageFile) GetWidth() int {\n\treturn f.Width\n}\n\nfunc (f ImageFile) GetHeight() int {\n\treturn f.Height\n}\n\nfunc (f ImageFile) Megapixels() float64 {\n\treturn float64(f.Width*f.Height) / 1e6\n}\n\nfunc (f ImageFile) GetFormat() string {\n\treturn f.Format\n}\n\nfunc (f ImageFile) Clone() (ret File) {\n\tclone := f\n\tclone.BaseFile = f.BaseFile.Clone().(*BaseFile)\n\tret = &clone\n\treturn\n}\n\n// VideoFile is an extension of BaseFile to represent video files.\ntype VideoFile struct {\n\t*BaseFile\n\tFormat     string  `json:\"format\"`\n\tWidth      int     `json:\"width\"`\n\tHeight     int     `json:\"height\"`\n\tDuration   float64 `json:\"duration\"`\n\tVideoCodec string  `json:\"video_codec\"`\n\tAudioCodec string  `json:\"audio_codec\"`\n\tFrameRate  float64 `json:\"frame_rate\"`\n\tBitRate    int64   `json:\"bitrate\"`\n\n\tInteractive      bool `json:\"interactive\"`\n\tInteractiveSpeed *int `json:\"interactive_speed\"`\n}\n\nfunc (f VideoFile) GetWidth() int {\n\treturn f.Width\n}\n\nfunc (f VideoFile) GetHeight() int {\n\treturn f.Height\n}\n\nfunc (f VideoFile) GetFormat() string {\n\treturn f.Format\n}\n\nfunc (f VideoFile) Clone() (ret File) {\n\tclone := f\n\tclone.BaseFile = f.BaseFile.Clone().(*BaseFile)\n\tret = &clone\n\treturn\n}\n\n// #1572 - Inf and NaN values cause the JSON marshaller to fail\n// Replace these values with 0 rather than erroring\n\nfunc (f VideoFile) DurationFinite() float64 {\n\tret := f.Duration\n\tif math.IsInf(ret, 0) || math.IsNaN(ret) {\n\t\treturn 0\n\t}\n\treturn ret\n}\n\nfunc (f VideoFile) FrameRateFinite() float64 {\n\tret := f.FrameRate\n\tif math.IsInf(ret, 0) || math.IsNaN(ret) {\n\t\treturn 0\n\t}\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/models/model_folder.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"strconv\"\n\t\"time\"\n)\n\n// FolderID represents an ID of a folder.\ntype FolderID int32\n\n// String converts the ID to a string.\nfunc (i FolderID) String() string {\n\treturn strconv.Itoa(int(i))\n}\n\nfunc (i *FolderID) UnmarshalGQL(v interface{}) (err error) {\n\tswitch v := v.(type) {\n\tcase string:\n\t\tvar id int\n\t\tid, err = strconv.Atoi(v)\n\t\t*i = FolderID(id)\n\t\treturn err\n\tcase int:\n\t\t*i = FolderID(v)\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"%T is not an int\", v)\n\t}\n}\n\nfunc (i FolderID) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(i.String()))\n}\n\nfunc FolderIDsFromInts(ids []int) []FolderID {\n\tret := make([]FolderID, len(ids))\n\tfor i, id := range ids {\n\t\tret[i] = FolderID(id)\n\t}\n\treturn ret\n}\n\n// Folder represents a folder in the file system.\ntype Folder struct {\n\tID FolderID `json:\"id\"`\n\tDirEntry\n\tPath           string    `json:\"path\"`\n\tParentFolderID *FolderID `json:\"parent_folder_id\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\nfunc (f *Folder) Info(fs FS) (fs.FileInfo, error) {\n\treturn f.info(fs, f.Path)\n}\n"
  },
  {
    "path": "pkg/models/model_gallery.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"time\"\n)\n\ntype Gallery struct {\n\tID int `json:\"id\"`\n\n\tTitle        string `json:\"title\"`\n\tCode         string `json:\"code\"`\n\tDate         *Date  `json:\"date\"`\n\tDetails      string `json:\"details\"`\n\tPhotographer string `json:\"photographer\"`\n\t// Rating expressed in 1-100 scale\n\tRating    *int `json:\"rating\"`\n\tOrganized bool `json:\"organized\"`\n\tStudioID  *int `json:\"studio_id\"`\n\n\t// transient - not persisted\n\tFiles RelatedFiles\n\t// transient - not persisted\n\tPrimaryFileID *FileID\n\t// transient - path of primary file or folder\n\tPath string\n\n\tFolderID *FolderID `json:\"folder_id\"`\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n\n\tURLs         RelatedStrings `json:\"urls\"`\n\tSceneIDs     RelatedIDs     `json:\"scene_ids\"`\n\tTagIDs       RelatedIDs     `json:\"tag_ids\"`\n\tPerformerIDs RelatedIDs     `json:\"performer_ids\"`\n}\n\nfunc NewGallery() Gallery {\n\tcurrentTime := time.Now()\n\treturn Gallery{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\ntype CreateGalleryInput struct {\n\t*Gallery\n\n\tFileIDs      []FileID\n\tCustomFields map[string]interface{} `json:\"custom_fields\"`\n}\n\ntype UpdateGalleryInput struct {\n\t*Gallery\n\n\tFileIDs      []FileID\n\tCustomFields CustomFieldsInput `json:\"custom_fields\"`\n}\n\n// GalleryPartial represents part of a Gallery object. It is used to update\n// the database entry. Only non-nil fields will be updated.\ntype GalleryPartial struct {\n\t// Path        OptionalString\n\t// Checksum    OptionalString\n\t// Zip         OptionalBool\n\tTitle        OptionalString\n\tCode         OptionalString\n\tURLs         *UpdateStrings\n\tDate         OptionalDate\n\tDetails      OptionalString\n\tPhotographer OptionalString\n\t// Rating expressed in 1-100 scale\n\tRating    OptionalInt\n\tOrganized OptionalBool\n\tStudioID  OptionalInt\n\t// FileModTime OptionalTime\n\tCreatedAt OptionalTime\n\tUpdatedAt OptionalTime\n\n\tSceneIDs      *UpdateIDs\n\tTagIDs        *UpdateIDs\n\tPerformerIDs  *UpdateIDs\n\tPrimaryFileID *FileID\n\n\tCustomFields CustomFieldsInput\n}\n\nfunc NewGalleryPartial() GalleryPartial {\n\tcurrentTime := time.Now()\n\treturn GalleryPartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n\n// IsUserCreated returns true if the gallery was created by the user.\n// This is determined by whether the gallery has a primary file or folder.\nfunc (g *Gallery) IsUserCreated() bool {\n\treturn g.PrimaryFileID == nil && g.FolderID == nil\n}\n\nfunc (g *Gallery) LoadURLs(ctx context.Context, l URLLoader) error {\n\treturn g.URLs.load(func() ([]string, error) {\n\t\treturn l.GetURLs(ctx, g.ID)\n\t})\n}\n\nfunc (g *Gallery) LoadFiles(ctx context.Context, l FileLoader) error {\n\treturn g.Files.load(func() ([]File, error) {\n\t\treturn l.GetFiles(ctx, g.ID)\n\t})\n}\n\nfunc (g *Gallery) LoadPrimaryFile(ctx context.Context, l FileGetter) error {\n\treturn g.Files.loadPrimary(func() (File, error) {\n\t\tif g.PrimaryFileID == nil {\n\t\t\treturn nil, nil\n\t\t}\n\n\t\tf, err := l.Find(ctx, *g.PrimaryFileID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(f) > 0 {\n\t\t\treturn f[0], nil\n\t\t}\n\t\treturn nil, nil\n\t})\n}\n\nfunc (g *Gallery) LoadSceneIDs(ctx context.Context, l SceneIDLoader) error {\n\treturn g.SceneIDs.load(func() ([]int, error) {\n\t\treturn l.GetSceneIDs(ctx, g.ID)\n\t})\n}\n\nfunc (g *Gallery) LoadPerformerIDs(ctx context.Context, l PerformerIDLoader) error {\n\treturn g.PerformerIDs.load(func() ([]int, error) {\n\t\treturn l.GetPerformerIDs(ctx, g.ID)\n\t})\n}\n\nfunc (g *Gallery) LoadTagIDs(ctx context.Context, l TagIDLoader) error {\n\treturn g.TagIDs.load(func() ([]int, error) {\n\t\treturn l.GetTagIDs(ctx, g.ID)\n\t})\n}\n\nfunc (g Gallery) PrimaryChecksum() string {\n\t// renamed from Checksum to prevent gqlgen from using it in the resolver\n\tif p := g.Files.Primary(); p != nil {\n\t\tv := p.Base().Fingerprints.Get(FingerprintTypeMD5)\n\t\tif v == nil {\n\t\t\treturn \"\"\n\t\t}\n\n\t\treturn v.(string)\n\t}\n\treturn \"\"\n}\n\n// GetTitle returns the title of the scene. If the Title field is empty,\n// then the base filename is returned.\nfunc (g Gallery) GetTitle() string {\n\tif g.Title != \"\" {\n\t\treturn g.Title\n\t}\n\n\treturn filepath.Base(g.Path)\n}\n\n// DisplayName returns a display name for the scene for logging purposes.\n// It returns the path or title, or otherwise it returns the ID if both of these are empty.\nfunc (g Gallery) DisplayName() string {\n\tif g.Path != \"\" {\n\t\treturn g.Path\n\t}\n\n\tif g.Title != \"\" {\n\t\treturn g.Title\n\t}\n\n\treturn strconv.Itoa(g.ID)\n}\n\nconst DefaultGthumbWidth int = 640\n"
  },
  {
    "path": "pkg/models/model_gallery_chapter.go",
    "content": "package models\n\nimport (\n\t\"time\"\n)\n\ntype GalleryChapter struct {\n\tID         int       `json:\"id\"`\n\tTitle      string    `json:\"title\"`\n\tImageIndex int       `json:\"image_index\"`\n\tGalleryID  int       `json:\"gallery_id\"`\n\tCreatedAt  time.Time `json:\"created_at\"`\n\tUpdatedAt  time.Time `json:\"updated_at\"`\n}\n\nfunc NewGalleryChapter() GalleryChapter {\n\tcurrentTime := time.Now()\n\treturn GalleryChapter{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\n// GalleryChapterPartial represents part of a GalleryChapter object.\n// It is used to update the database entry.\ntype GalleryChapterPartial struct {\n\tTitle      OptionalString\n\tImageIndex OptionalInt\n\tGalleryID  OptionalInt\n\tCreatedAt  OptionalTime\n\tUpdatedAt  OptionalTime\n}\n\nfunc NewGalleryChapterPartial() GalleryChapterPartial {\n\tcurrentTime := time.Now()\n\treturn GalleryChapterPartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n"
  },
  {
    "path": "pkg/models/model_group.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\ntype Group struct {\n\tID       int    `json:\"id\"`\n\tName     string `json:\"name\"`\n\tAliases  string `json:\"aliases\"`\n\tDuration *int   `json:\"duration\"`\n\tDate     *Date  `json:\"date\"`\n\t// Rating expressed in 1-100 scale\n\tRating    *int      `json:\"rating\"`\n\tStudioID  *int      `json:\"studio_id\"`\n\tDirector  string    `json:\"director\"`\n\tSynopsis  string    `json:\"synopsis\"`\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n\n\tURLs   RelatedStrings `json:\"urls\"`\n\tTagIDs RelatedIDs     `json:\"tag_ids\"`\n\n\tContainingGroups RelatedGroupDescriptions `json:\"containing_groups\"`\n\tSubGroups        RelatedGroupDescriptions `json:\"sub_groups\"`\n}\n\nfunc NewGroup() Group {\n\tcurrentTime := time.Now()\n\treturn Group{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\ntype CreateGroupInput struct {\n\t*Group\n\n\tCustomFields   map[string]interface{} `json:\"custom_fields\"`\n\tFrontImageData []byte\n\tBackImageData  []byte\n}\n\nfunc (m *Group) LoadURLs(ctx context.Context, l URLLoader) error {\n\treturn m.URLs.load(func() ([]string, error) {\n\t\treturn l.GetURLs(ctx, m.ID)\n\t})\n}\n\nfunc (m *Group) LoadTagIDs(ctx context.Context, l TagIDLoader) error {\n\treturn m.TagIDs.load(func() ([]int, error) {\n\t\treturn l.GetTagIDs(ctx, m.ID)\n\t})\n}\n\nfunc (m *Group) LoadContainingGroupIDs(ctx context.Context, l ContainingGroupLoader) error {\n\treturn m.ContainingGroups.load(func() ([]GroupIDDescription, error) {\n\t\treturn l.GetContainingGroupDescriptions(ctx, m.ID)\n\t})\n}\n\nfunc (m *Group) LoadSubGroupIDs(ctx context.Context, l SubGroupLoader) error {\n\treturn m.SubGroups.load(func() ([]GroupIDDescription, error) {\n\t\treturn l.GetSubGroupDescriptions(ctx, m.ID)\n\t})\n}\n\ntype GroupPartial struct {\n\tName     OptionalString\n\tAliases  OptionalString\n\tDuration OptionalInt\n\tDate     OptionalDate\n\t// Rating expressed in 1-100 scale\n\tRating           OptionalInt\n\tStudioID         OptionalInt\n\tDirector         OptionalString\n\tSynopsis         OptionalString\n\tURLs             *UpdateStrings\n\tTagIDs           *UpdateIDs\n\tContainingGroups *UpdateGroupDescriptions\n\tSubGroups        *UpdateGroupDescriptions\n\tCreatedAt        OptionalTime\n\tUpdatedAt        OptionalTime\n\n\tCustomFields CustomFieldsInput\n}\n\nfunc NewGroupPartial() GroupPartial {\n\tcurrentTime := time.Now()\n\treturn GroupPartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n"
  },
  {
    "path": "pkg/models/model_image.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"time\"\n)\n\n// Image stores the metadata for a single image.\ntype Image struct {\n\tID int `json:\"id\"`\n\n\tTitle        string `json:\"title\"`\n\tCode         string `json:\"code\"`\n\tDetails      string `json:\"details\"`\n\tPhotographer string `json:\"photographer\"`\n\t// Rating expressed in 1-100 scale\n\tRating    *int           `json:\"rating\"`\n\tOrganized bool           `json:\"organized\"`\n\tOCounter  int            `json:\"o_counter\"`\n\tStudioID  *int           `json:\"studio_id\"`\n\tURLs      RelatedStrings `json:\"urls\"`\n\tDate      *Date          `json:\"date\"`\n\n\t// transient - not persisted\n\tFiles         RelatedFiles\n\tPrimaryFileID *FileID\n\t// transient - path of primary file - empty if no files\n\tPath string\n\t// transient - checksum of primary file - empty if no files\n\tChecksum string\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n\n\tGalleryIDs   RelatedIDs `json:\"gallery_ids\"`\n\tTagIDs       RelatedIDs `json:\"tag_ids\"`\n\tPerformerIDs RelatedIDs `json:\"performer_ids\"`\n}\n\nfunc NewImage() Image {\n\tcurrentTime := time.Now()\n\treturn Image{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\ntype CreateImageInput struct {\n\t*Image\n\n\tFileIDs      []FileID\n\tCustomFields map[string]interface{} `json:\"custom_fields\"`\n}\n\ntype ImagePartial struct {\n\tTitle OptionalString\n\tCode  OptionalString\n\t// Rating expressed in 1-100 scale\n\tRating       OptionalInt\n\tURLs         *UpdateStrings\n\tDate         OptionalDate\n\tDetails      OptionalString\n\tPhotographer OptionalString\n\tOrganized    OptionalBool\n\tOCounter     OptionalInt\n\tStudioID     OptionalInt\n\tCreatedAt    OptionalTime\n\tUpdatedAt    OptionalTime\n\n\tGalleryIDs    *UpdateIDs\n\tTagIDs        *UpdateIDs\n\tPerformerIDs  *UpdateIDs\n\tPrimaryFileID *FileID\n\tCustomFields  CustomFieldsInput\n}\n\nfunc NewImagePartial() ImagePartial {\n\tcurrentTime := time.Now()\n\treturn ImagePartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n\nfunc (i *Image) LoadURLs(ctx context.Context, l URLLoader) error {\n\treturn i.URLs.load(func() ([]string, error) {\n\t\treturn l.GetURLs(ctx, i.ID)\n\t})\n}\n\nfunc (i *Image) LoadFiles(ctx context.Context, l FileLoader) error {\n\treturn i.Files.load(func() ([]File, error) {\n\t\treturn l.GetFiles(ctx, i.ID)\n\t})\n}\n\nfunc (i *Image) LoadPrimaryFile(ctx context.Context, l FileGetter) error {\n\treturn i.Files.loadPrimary(func() (File, error) {\n\t\tif i.PrimaryFileID == nil {\n\t\t\treturn nil, nil\n\t\t}\n\n\t\tf, err := l.Find(ctx, *i.PrimaryFileID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(f) > 0 {\n\t\t\treturn f[0], nil\n\t\t}\n\n\t\treturn nil, nil\n\t})\n}\n\nfunc (i *Image) LoadGalleryIDs(ctx context.Context, l GalleryIDLoader) error {\n\treturn i.GalleryIDs.load(func() ([]int, error) {\n\t\treturn l.GetGalleryIDs(ctx, i.ID)\n\t})\n}\n\nfunc (i *Image) LoadPerformerIDs(ctx context.Context, l PerformerIDLoader) error {\n\treturn i.PerformerIDs.load(func() ([]int, error) {\n\t\treturn l.GetPerformerIDs(ctx, i.ID)\n\t})\n}\n\nfunc (i *Image) LoadTagIDs(ctx context.Context, l TagIDLoader) error {\n\treturn i.TagIDs.load(func() ([]int, error) {\n\t\treturn l.GetTagIDs(ctx, i.ID)\n\t})\n}\n\n// GetTitle returns the title of the image. If the Title field is empty,\n// then the base filename is returned.\nfunc (i Image) GetTitle() string {\n\tif i.Title != \"\" {\n\t\treturn i.Title\n\t}\n\n\tif i.Path != \"\" {\n\t\treturn filepath.Base(i.Path)\n\t}\n\n\treturn \"\"\n}\n\n// DisplayName returns a display name for the scene for logging purposes.\n// It returns Path if not empty, otherwise it returns the ID.\nfunc (i Image) DisplayName() string {\n\tif i.Path != \"\" {\n\t\treturn i.Path\n\t}\n\n\treturn strconv.Itoa(i.ID)\n}\n"
  },
  {
    "path": "pkg/models/model_joins.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n)\n\ntype GroupsScenes struct {\n\tGroupID int `json:\"movie_id\"`\n\t// SceneID    int  `json:\"scene_id\"`\n\tSceneIndex *int `json:\"scene_index\"`\n}\n\nfunc (s GroupsScenes) SceneMovieInput() SceneMovieInput {\n\treturn SceneMovieInput{\n\t\tMovieID:    strconv.Itoa(s.GroupID),\n\t\tSceneIndex: s.SceneIndex,\n\t}\n}\n\nfunc (s GroupsScenes) Equal(o GroupsScenes) bool {\n\treturn o.GroupID == s.GroupID && ((o.SceneIndex == nil && s.SceneIndex == nil) ||\n\t\t(o.SceneIndex != nil && s.SceneIndex != nil && *o.SceneIndex == *s.SceneIndex))\n}\n\ntype UpdateGroupIDs struct {\n\tGroups []GroupsScenes         `json:\"movies\"`\n\tMode   RelationshipUpdateMode `json:\"mode\"`\n}\n\nfunc (u *UpdateGroupIDs) SceneMovieInputs() []SceneMovieInput {\n\tif u == nil {\n\t\treturn nil\n\t}\n\n\tret := make([]SceneMovieInput, 0, len(u.Groups))\n\tfor _, id := range u.Groups {\n\t\tret = append(ret, id.SceneMovieInput())\n\t}\n\n\treturn ret\n}\n\nfunc (u *UpdateGroupIDs) AddUnique(v GroupsScenes) {\n\tfor _, vv := range u.Groups {\n\t\tif vv.GroupID == v.GroupID {\n\t\t\treturn\n\t\t}\n\t}\n\n\tu.Groups = append(u.Groups, v)\n}\n\nfunc GroupsScenesFromInput(input []SceneMovieInput) ([]GroupsScenes, error) {\n\tret := make([]GroupsScenes, len(input))\n\n\tfor i, v := range input {\n\t\tmID, err := strconv.Atoi(v.MovieID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid movie ID: %s\", v.MovieID)\n\t\t}\n\n\t\tret[i] = GroupsScenes{\n\t\t\tGroupID:    mID,\n\t\t\tSceneIndex: v.SceneIndex,\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\ntype GroupIDDescription struct {\n\tGroupID     int    `json:\"group_id\"`\n\tDescription string `json:\"description\"`\n}\n"
  },
  {
    "path": "pkg/models/model_performer.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\ntype Performer struct {\n\tID             int              `json:\"id\"`\n\tName           string           `json:\"name\"`\n\tDisambiguation string           `json:\"disambiguation\"`\n\tGender         *GenderEnum      `json:\"gender\"`\n\tBirthdate      *Date            `json:\"birthdate\"`\n\tEthnicity      string           `json:\"ethnicity\"`\n\tCountry        string           `json:\"country\"`\n\tEyeColor       string           `json:\"eye_color\"`\n\tHeight         *int             `json:\"height\"`\n\tMeasurements   string           `json:\"measurements\"`\n\tFakeTits       string           `json:\"fake_tits\"`\n\tPenisLength    *float64         `json:\"penis_length\"`\n\tCircumcised    *CircumcisedEnum `json:\"circumcised\"`\n\tCareerStart    *Date            `json:\"career_start\"`\n\tCareerEnd      *Date            `json:\"career_end\"`\n\tTattoos        string           `json:\"tattoos\"`\n\tPiercings      string           `json:\"piercings\"`\n\tFavorite       bool             `json:\"favorite\"`\n\tCreatedAt      time.Time        `json:\"created_at\"`\n\tUpdatedAt      time.Time        `json:\"updated_at\"`\n\t// Rating expressed in 1-100 scale\n\tRating        *int   `json:\"rating\"`\n\tDetails       string `json:\"details\"`\n\tDeathDate     *Date  `json:\"death_date\"`\n\tHairColor     string `json:\"hair_color\"`\n\tWeight        *int   `json:\"weight\"`\n\tIgnoreAutoTag bool   `json:\"ignore_auto_tag\"`\n\n\tAliases  RelatedStrings  `json:\"aliases\"`\n\tURLs     RelatedStrings  `json:\"urls\"`\n\tTagIDs   RelatedIDs      `json:\"tag_ids\"`\n\tStashIDs RelatedStashIDs `json:\"stash_ids\"`\n}\n\ntype CreatePerformerInput struct {\n\t*Performer\n\n\tCustomFields map[string]interface{} `json:\"custom_fields\"`\n}\n\ntype UpdatePerformerInput struct {\n\t*Performer\n\n\tCustomFields CustomFieldsInput `json:\"custom_fields\"`\n}\n\nfunc NewPerformer() Performer {\n\tcurrentTime := time.Now()\n\treturn Performer{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\n// PerformerPartial represents part of a Performer object. It is used to update\n// the database entry.\ntype PerformerPartial struct {\n\tName           OptionalString\n\tDisambiguation OptionalString\n\tGender         OptionalString\n\tURLs           *UpdateStrings\n\tBirthdate      OptionalDate\n\tEthnicity      OptionalString\n\tCountry        OptionalString\n\tEyeColor       OptionalString\n\tHeight         OptionalInt\n\tMeasurements   OptionalString\n\tFakeTits       OptionalString\n\tPenisLength    OptionalFloat64\n\tCircumcised    OptionalString\n\tCareerStart    OptionalDate\n\tCareerEnd      OptionalDate\n\tTattoos        OptionalString\n\tPiercings      OptionalString\n\tFavorite       OptionalBool\n\tCreatedAt      OptionalTime\n\tUpdatedAt      OptionalTime\n\t// Rating expressed in 1-100 scale\n\tRating        OptionalInt\n\tDetails       OptionalString\n\tDeathDate     OptionalDate\n\tHairColor     OptionalString\n\tWeight        OptionalInt\n\tIgnoreAutoTag OptionalBool\n\n\tAliases  *UpdateStrings\n\tTagIDs   *UpdateIDs\n\tStashIDs *UpdateStashIDs\n\n\tCustomFields CustomFieldsInput\n}\n\nfunc NewPerformerPartial() PerformerPartial {\n\tcurrentTime := time.Now()\n\treturn PerformerPartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n\nfunc (s *Performer) LoadAliases(ctx context.Context, l AliasLoader) error {\n\treturn s.Aliases.load(func() ([]string, error) {\n\t\treturn l.GetAliases(ctx, s.ID)\n\t})\n}\n\nfunc (s *Performer) LoadURLs(ctx context.Context, l URLLoader) error {\n\treturn s.URLs.load(func() ([]string, error) {\n\t\treturn l.GetURLs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Performer) LoadTagIDs(ctx context.Context, l TagIDLoader) error {\n\treturn s.TagIDs.load(func() ([]int, error) {\n\t\treturn l.GetTagIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Performer) LoadStashIDs(ctx context.Context, l StashIDLoader) error {\n\treturn s.StashIDs.load(func() ([]StashID, error) {\n\t\treturn l.GetStashIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Performer) LoadRelationships(ctx context.Context, l PerformerReader) error {\n\tif err := s.LoadAliases(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadTagIDs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadStashIDs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/models/model_saved_filter.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype FilterMode string\n\nconst (\n\tFilterModeScenes       FilterMode = \"SCENES\"\n\tFilterModePerformers   FilterMode = \"PERFORMERS\"\n\tFilterModeStudios      FilterMode = \"STUDIOS\"\n\tFilterModeGalleries    FilterMode = \"GALLERIES\"\n\tFilterModeSceneMarkers FilterMode = \"SCENE_MARKERS\"\n\tFilterModeMovies       FilterMode = \"MOVIES\"\n\tFilterModeGroups       FilterMode = \"GROUPS\"\n\tFilterModeTags         FilterMode = \"TAGS\"\n\tFilterModeImages       FilterMode = \"IMAGES\"\n)\n\nvar AllFilterMode = []FilterMode{\n\tFilterModeScenes,\n\tFilterModePerformers,\n\tFilterModeStudios,\n\tFilterModeGalleries,\n\tFilterModeSceneMarkers,\n\tFilterModeGroups,\n\tFilterModeMovies,\n\tFilterModeTags,\n\tFilterModeImages,\n}\n\nfunc (e FilterMode) IsValid() bool {\n\tswitch e {\n\tcase FilterModeScenes, FilterModePerformers, FilterModeStudios, FilterModeGalleries, FilterModeSceneMarkers, FilterModeMovies, FilterModeGroups, FilterModeTags, FilterModeImages:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e FilterMode) String() string {\n\treturn string(e)\n}\n\nfunc (e *FilterMode) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = FilterMode(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid FilterMode\", str)\n\t}\n\treturn nil\n}\n\nfunc (e FilterMode) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype SavedFilter struct {\n\tID           int                    `db:\"id\" json:\"id\"`\n\tMode         FilterMode             `db:\"mode\" json:\"mode\"`\n\tName         string                 `db:\"name\" json:\"name\"`\n\tFindFilter   *FindFilterType        `json:\"find_filter\"`\n\tObjectFilter map[string]interface{} `json:\"object_filter\"`\n\tUIOptions    map[string]interface{} `json:\"ui_options\"`\n}\n"
  },
  {
    "path": "pkg/models/model_scene.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"time\"\n)\n\n// Scene stores the metadata for a single video scene.\ntype Scene struct {\n\tID       int    `json:\"id\"`\n\tTitle    string `json:\"title\"`\n\tCode     string `json:\"code\"`\n\tDetails  string `json:\"details\"`\n\tDirector string `json:\"director\"`\n\tDate     *Date  `json:\"date\"`\n\t// Rating expressed in 1-100 scale\n\tRating    *int `json:\"rating\"`\n\tOrganized bool `json:\"organized\"`\n\tStudioID  *int `json:\"studio_id\"`\n\n\t// transient - not persisted\n\tFiles         RelatedVideoFiles\n\tPrimaryFileID *FileID\n\t// transient - path of primary file - empty if no files\n\tPath string\n\t// transient - oshash of primary file - empty if no files\n\tOSHash string\n\t// transient - checksum of primary file - empty if no files\n\tChecksum string\n\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n\n\tResumeTime   float64 `json:\"resume_time\"`\n\tPlayDuration float64 `json:\"play_duration\"`\n\n\tURLs         RelatedStrings  `json:\"urls\"`\n\tGalleryIDs   RelatedIDs      `json:\"gallery_ids\"`\n\tTagIDs       RelatedIDs      `json:\"tag_ids\"`\n\tPerformerIDs RelatedIDs      `json:\"performer_ids\"`\n\tGroups       RelatedGroups   `json:\"groups\"`\n\tStashIDs     RelatedStashIDs `json:\"stash_ids\"`\n}\n\nfunc NewScene() Scene {\n\tcurrentTime := time.Now()\n\treturn Scene{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\ntype CreateSceneInput struct {\n\t*Scene\n\n\tFileIDs      []FileID\n\tCoverImage   []byte\n\tCustomFields CustomFieldMap `json:\"custom_fields\"`\n}\n\ntype UpdateSceneInput struct {\n\t*Scene\n\n\tCustomFields CustomFieldsInput `json:\"custom_fields\"`\n}\n\n// ScenePartial represents part of a Scene object. It is used to update\n// the database entry.\ntype ScenePartial struct {\n\tTitle    OptionalString\n\tCode     OptionalString\n\tDetails  OptionalString\n\tDirector OptionalString\n\tDate     OptionalDate\n\t// Rating expressed in 1-100 scale\n\tRating       OptionalInt\n\tOrganized    OptionalBool\n\tStudioID     OptionalInt\n\tCreatedAt    OptionalTime\n\tUpdatedAt    OptionalTime\n\tResumeTime   OptionalFloat64\n\tPlayDuration OptionalFloat64\n\n\tURLs          *UpdateStrings\n\tGalleryIDs    *UpdateIDs\n\tTagIDs        *UpdateIDs\n\tPerformerIDs  *UpdateIDs\n\tGroupIDs      *UpdateGroupIDs\n\tStashIDs      *UpdateStashIDs\n\tPrimaryFileID *FileID\n}\n\nfunc NewScenePartial() ScenePartial {\n\tcurrentTime := time.Now()\n\treturn ScenePartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n\nfunc (s *Scene) LoadURLs(ctx context.Context, l URLLoader) error {\n\treturn s.URLs.load(func() ([]string, error) {\n\t\treturn l.GetURLs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Scene) LoadFiles(ctx context.Context, l VideoFileLoader) error {\n\treturn s.Files.load(func() ([]*VideoFile, error) {\n\t\treturn l.GetFiles(ctx, s.ID)\n\t})\n}\n\nfunc (s *Scene) LoadPrimaryFile(ctx context.Context, l FileGetter) error {\n\treturn s.Files.loadPrimary(func() (*VideoFile, error) {\n\t\tif s.PrimaryFileID == nil {\n\t\t\treturn nil, nil\n\t\t}\n\n\t\tf, err := l.Find(ctx, *s.PrimaryFileID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar vf *VideoFile\n\t\tif len(f) > 0 {\n\t\t\tvar ok bool\n\t\t\tvf, ok = f[0].(*VideoFile)\n\t\t\tif !ok {\n\t\t\t\treturn nil, errors.New(\"not a video file\")\n\t\t\t}\n\t\t}\n\t\treturn vf, nil\n\t})\n}\n\nfunc (s *Scene) LoadGalleryIDs(ctx context.Context, l GalleryIDLoader) error {\n\treturn s.GalleryIDs.load(func() ([]int, error) {\n\t\treturn l.GetGalleryIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Scene) LoadPerformerIDs(ctx context.Context, l PerformerIDLoader) error {\n\treturn s.PerformerIDs.load(func() ([]int, error) {\n\t\treturn l.GetPerformerIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Scene) LoadTagIDs(ctx context.Context, l TagIDLoader) error {\n\treturn s.TagIDs.load(func() ([]int, error) {\n\t\treturn l.GetTagIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Scene) LoadGroups(ctx context.Context, l SceneGroupLoader) error {\n\treturn s.Groups.load(func() ([]GroupsScenes, error) {\n\t\treturn l.GetGroups(ctx, s.ID)\n\t})\n}\n\nfunc (s *Scene) LoadStashIDs(ctx context.Context, l StashIDLoader) error {\n\treturn s.StashIDs.load(func() ([]StashID, error) {\n\t\treturn l.GetStashIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Scene) LoadRelationships(ctx context.Context, l SceneReader) error {\n\tif err := s.LoadURLs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadGalleryIDs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadPerformerIDs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadTagIDs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadGroups(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadStashIDs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadFiles(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// UpdateInput constructs a SceneUpdateInput using the populated fields in the ScenePartial object.\nfunc (s ScenePartial) UpdateInput(id int) SceneUpdateInput {\n\tvar dateStr *string\n\tif s.Date.Set {\n\t\td := s.Date.Value\n\t\tv := d.String()\n\t\tdateStr = &v\n\t}\n\n\tvar stashIDs StashIDs\n\tif s.StashIDs != nil {\n\t\tstashIDs = StashIDs(s.StashIDs.StashIDs)\n\t}\n\n\tret := SceneUpdateInput{\n\t\tID:           strconv.Itoa(id),\n\t\tTitle:        s.Title.Ptr(),\n\t\tCode:         s.Code.Ptr(),\n\t\tDetails:      s.Details.Ptr(),\n\t\tDirector:     s.Director.Ptr(),\n\t\tUrls:         s.URLs.Strings(),\n\t\tDate:         dateStr,\n\t\tRating100:    s.Rating.Ptr(),\n\t\tOrganized:    s.Organized.Ptr(),\n\t\tStudioID:     s.StudioID.StringPtr(),\n\t\tGalleryIds:   s.GalleryIDs.IDStrings(),\n\t\tPerformerIds: s.PerformerIDs.IDStrings(),\n\t\tMovies:       s.GroupIDs.SceneMovieInputs(),\n\t\tTagIds:       s.TagIDs.IDStrings(),\n\t\tStashIds:     stashIDs.ToStashIDInputs(),\n\t}\n\n\treturn ret\n}\n\n// GetTitle returns the title of the scene. If the Title field is empty,\n// then the base filename is returned.\nfunc (s Scene) GetTitle() string {\n\tif s.Title != \"\" {\n\t\treturn s.Title\n\t}\n\n\treturn filepath.Base(s.Path)\n}\n\n// DisplayName returns a display name for the scene for logging purposes.\n// It returns Path if not empty, otherwise it returns the ID.\nfunc (s Scene) DisplayName() string {\n\tif s.Path != \"\" {\n\t\treturn s.Path\n\t}\n\n\treturn strconv.Itoa(s.ID)\n}\n\n// GetHash returns the hash of the scene, based on the hash algorithm provided. If\n// hash algorithm is MD5, then Checksum is returned. Otherwise, OSHash is returned.\nfunc (s Scene) GetHash(hashAlgorithm HashAlgorithm) string {\n\tswitch hashAlgorithm {\n\tcase HashAlgorithmMd5:\n\t\treturn s.Checksum\n\tcase HashAlgorithmOshash:\n\t\treturn s.OSHash\n\t}\n\n\treturn \"\"\n}\n\n// SceneFileType represents the file metadata for a scene.\ntype SceneFileType struct {\n\tSize       *string  `graphql:\"size\" json:\"size\"`\n\tDuration   *float64 `graphql:\"duration\" json:\"duration\"`\n\tVideoCodec *string  `graphql:\"video_codec\" json:\"video_codec\"`\n\tAudioCodec *string  `graphql:\"audio_codec\" json:\"audio_codec\"`\n\tWidth      *int     `graphql:\"width\" json:\"width\"`\n\tHeight     *int     `graphql:\"height\" json:\"height\"`\n\tFramerate  *float64 `graphql:\"framerate\" json:\"framerate\"`\n\tBitrate    *int     `graphql:\"bitrate\" json:\"bitrate\"`\n}\n\ntype VideoCaption struct {\n\tLanguageCode string `json:\"language_code\"`\n\tFilename     string `json:\"filename\"`\n\tCaptionType  string `json:\"caption_type\"`\n}\n\nfunc (c VideoCaption) Path(filePath string) string {\n\treturn filepath.Join(filepath.Dir(filePath), c.Filename)\n}\n"
  },
  {
    "path": "pkg/models/model_scene_marker.go",
    "content": "package models\n\nimport (\n\t\"time\"\n)\n\ntype SceneMarker struct {\n\tID           int       `json:\"id\"`\n\tTitle        string    `json:\"title\"`\n\tSeconds      float64   `json:\"seconds\"`\n\tEndSeconds   *float64  `json:\"end_seconds\"`\n\tPrimaryTagID int       `json:\"primary_tag_id\"`\n\tSceneID      int       `json:\"scene_id\"`\n\tCreatedAt    time.Time `json:\"created_at\"`\n\tUpdatedAt    time.Time `json:\"updated_at\"`\n}\n\nfunc NewSceneMarker() SceneMarker {\n\tcurrentTime := time.Now()\n\treturn SceneMarker{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\n// SceneMarkerPartial represents part of a SceneMarker object.\n// It is used to update the database entry.\ntype SceneMarkerPartial struct {\n\tTitle        OptionalString\n\tSeconds      OptionalFloat64\n\tEndSeconds   OptionalFloat64\n\tPrimaryTagID OptionalInt\n\tTagIDs       *UpdateIDs\n\tSceneID      OptionalInt\n\tCreatedAt    OptionalTime\n\tUpdatedAt    OptionalTime\n}\n\nfunc NewSceneMarkerPartial() SceneMarkerPartial {\n\tcurrentTime := time.Now()\n\treturn SceneMarkerPartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n"
  },
  {
    "path": "pkg/models/model_scene_test.go",
    "content": "package models\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestScenePartial_UpdateInput(t *testing.T) {\n\tconst (\n\t\tid    = 1\n\t\tidStr = \"1\"\n\t)\n\n\tvar (\n\t\ttitle       = \"title\"\n\t\tcode        = \"1337\"\n\t\tdetails     = \"details\"\n\t\tdirector    = \"director\"\n\t\turl         = \"url\"\n\t\tdate        = \"2001-02-03\"\n\t\trating100   = 80\n\t\torganized   = true\n\t\tstudioID    = 2\n\t\tstudioIDStr = \"2\"\n\t)\n\n\tdateObj, _ := ParseDate(date)\n\n\ttests := []struct {\n\t\tname string\n\t\tid   int\n\t\ts    ScenePartial\n\t\twant SceneUpdateInput\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tid,\n\t\t\tScenePartial{\n\t\t\t\tTitle:    NewOptionalString(title),\n\t\t\t\tCode:     NewOptionalString(code),\n\t\t\t\tDetails:  NewOptionalString(details),\n\t\t\t\tDirector: NewOptionalString(director),\n\t\t\t\tURLs: &UpdateStrings{\n\t\t\t\t\tValues: []string{url},\n\t\t\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tDate:      NewOptionalDate(dateObj),\n\t\t\t\tRating:    NewOptionalInt(rating100),\n\t\t\t\tOrganized: NewOptionalBool(organized),\n\t\t\t\tStudioID:  NewOptionalInt(studioID),\n\t\t\t},\n\t\t\tSceneUpdateInput{\n\t\t\t\tID:        idStr,\n\t\t\t\tTitle:     &title,\n\t\t\t\tCode:      &code,\n\t\t\t\tDetails:   &details,\n\t\t\t\tDirector:  &director,\n\t\t\t\tUrls:      []string{url},\n\t\t\t\tDate:      &date,\n\t\t\t\tRating100: &rating100,\n\t\t\t\tOrganized: &organized,\n\t\t\t\tStudioID:  &studioIDStr,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"empty\",\n\t\t\tid,\n\t\t\tScenePartial{},\n\t\t\tSceneUpdateInput{\n\t\t\t\tID: idStr,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := tt.s.UpdateInput(tt.id); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"ScenePartial.UpdateInput() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/models/model_scraped_item.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ScrapedStudio struct {\n\t// Set if studio matched\n\tStoredID     *string        `json:\"stored_id\"`\n\tName         string         `json:\"name\"`\n\tURL          *string        `json:\"url\"` // deprecated\n\tURLs         []string       `json:\"urls\"`\n\tParent       *ScrapedStudio `json:\"parent\"`\n\tImage        *string        `json:\"image\"`\n\tImages       []string       `json:\"images\"`\n\tDetails      *string        `json:\"details\"`\n\tAliases      *string        `json:\"aliases\"`\n\tTags         []*ScrapedTag  `json:\"tags\"`\n\tRemoteSiteID *string        `json:\"remote_site_id\"`\n}\n\nfunc (ScrapedStudio) IsScrapedContent() {}\n\nfunc (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *CreateStudioInput {\n\t// Populate a new studio from the input\n\tret := NewCreateStudioInput()\n\tret.Name = strings.TrimSpace(s.Name)\n\n\tif s.RemoteSiteID != nil && endpoint != \"\" && *s.RemoteSiteID != \"\" {\n\t\tret.StashIDs = NewRelatedStashIDs([]StashID{\n\t\t\t{\n\t\t\t\tEndpoint:  endpoint,\n\t\t\t\tStashID:   *s.RemoteSiteID,\n\t\t\t\tUpdatedAt: time.Now(),\n\t\t\t},\n\t\t})\n\t}\n\n\t// if URLs are provided, only use those\n\tif len(s.URLs) > 0 {\n\t\tif !excluded[\"urls\"] {\n\t\t\tret.URLs = NewRelatedStrings(s.URLs)\n\t\t}\n\t} else {\n\t\turls := []string{}\n\t\tif s.URL != nil && !excluded[\"url\"] {\n\t\t\turls = append(urls, *s.URL)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tret.URLs = NewRelatedStrings(urls)\n\t\t}\n\t}\n\n\tif s.Details != nil && !excluded[\"details\"] {\n\t\tret.Details = *s.Details\n\t}\n\n\tif s.Aliases != nil && *s.Aliases != \"\" && !excluded[\"aliases\"] {\n\t\tret.Aliases = NewRelatedStrings(stringslice.FromString(*s.Aliases, \",\"))\n\t}\n\n\tif s.Parent != nil && s.Parent.StoredID != nil && !excluded[\"parent\"] && !excluded[\"parent_studio\"] {\n\t\tparentId, _ := strconv.Atoi(*s.Parent.StoredID)\n\t\tret.ParentID = &parentId\n\t}\n\n\treturn &ret\n}\n\nfunc (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool) ([]byte, error) {\n\t// Process the base 64 encoded image string\n\tif len(s.Images) > 0 && !excluded[\"image\"] {\n\t\tvar err error\n\t\timg, err := utils.ProcessImageInput(ctx, *s.Image)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn img, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) StudioPartial {\n\tret := NewStudioPartial()\n\tret.ID, _ = strconv.Atoi(id)\n\tcurrentTime := time.Now()\n\n\tif s.Name != \"\" && !excluded[\"name\"] {\n\t\tret.Name = NewOptionalString(strings.TrimSpace(s.Name))\n\t}\n\n\tif len(s.URLs) > 0 {\n\t\tif !excluded[\"urls\"] {\n\n\t\t\tret.URLs = &UpdateStrings{\n\t\t\t\tValues: stringslice.TrimSpace(s.URLs),\n\t\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t\t}\n\t\t}\n\t} else {\n\t\turls := []string{}\n\t\tif s.URL != nil && !excluded[\"url\"] {\n\t\t\turls = append(urls, strings.TrimSpace(*s.URL))\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tret.URLs = &UpdateStrings{\n\t\t\t\tValues: stringslice.TrimSpace(urls),\n\t\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t\t}\n\t\t}\n\t}\n\n\tif s.Details != nil && !excluded[\"details\"] {\n\t\tret.Details = NewOptionalString(strings.TrimSpace(*s.Details))\n\t}\n\n\tif s.Aliases != nil && *s.Aliases != \"\" && !excluded[\"aliases\"] {\n\t\tret.Aliases = &UpdateStrings{\n\t\t\tValues: stringslice.TrimSpace(stringslice.FromString(*s.Aliases, \",\")),\n\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t}\n\t}\n\n\tif s.Parent != nil && !excluded[\"parent\"] {\n\t\tif s.Parent.StoredID != nil {\n\t\t\tparentID, _ := strconv.Atoi(*s.Parent.StoredID)\n\t\t\tif parentID > 0 {\n\t\t\t\t// This is to be set directly as we know it has a value and the translator won't have the field\n\t\t\t\tret.ParentID = NewOptionalInt(parentID)\n\t\t\t}\n\t\t}\n\t}\n\n\tif s.RemoteSiteID != nil && endpoint != \"\" && *s.RemoteSiteID != \"\" {\n\t\tret.StashIDs = &UpdateStashIDs{\n\t\t\tStashIDs: existingStashIDs,\n\t\t\tMode:     RelationshipUpdateModeSet,\n\t\t}\n\t\tret.StashIDs.Set(StashID{\n\t\t\tEndpoint:  endpoint,\n\t\t\tStashID:   *s.RemoteSiteID,\n\t\t\tUpdatedAt: currentTime,\n\t\t})\n\t}\n\n\treturn ret\n}\n\n// A performer from a scraping operation...\ntype ScrapedPerformer struct {\n\t// Set if performer matched\n\tStoredID       *string       `json:\"stored_id\"`\n\tName           *string       `json:\"name\"`\n\tDisambiguation *string       `json:\"disambiguation\"`\n\tGender         *string       `json:\"gender\"`\n\tURLs           []string      `json:\"urls\"`\n\tURL            *string       `json:\"url\"`       // deprecated\n\tTwitter        *string       `json:\"twitter\"`   // deprecated\n\tInstagram      *string       `json:\"instagram\"` // deprecated\n\tBirthdate      *string       `json:\"birthdate\"`\n\tEthnicity      *string       `json:\"ethnicity\"`\n\tCountry        *string       `json:\"country\"`\n\tEyeColor       *string       `json:\"eye_color\"`\n\tHeight         *string       `json:\"height\"`\n\tMeasurements   *string       `json:\"measurements\"`\n\tFakeTits       *string       `json:\"fake_tits\"`\n\tPenisLength    *string       `json:\"penis_length\"`\n\tCircumcised    *string       `json:\"circumcised\"`\n\tCareerLength   *string       `json:\"career_length\"` // deprecated: use CareerStart/CareerEnd\n\tCareerStart    *string       `json:\"career_start\"`\n\tCareerEnd      *string       `json:\"career_end\"`\n\tTattoos        *string       `json:\"tattoos\"`\n\tPiercings      *string       `json:\"piercings\"`\n\tAliases        *string       `json:\"aliases\"`\n\tTags           []*ScrapedTag `json:\"tags\"`\n\t// This should be a base64 encoded data URL\n\tImage              *string  `json:\"image\"` // deprecated: use Images\n\tImages             []string `json:\"images\"`\n\tDetails            *string  `json:\"details\"`\n\tDeathDate          *string  `json:\"death_date\"`\n\tHairColor          *string  `json:\"hair_color\"`\n\tWeight             *string  `json:\"weight\"`\n\tRemoteSiteID       *string  `json:\"remote_site_id\"`\n\tRemoteDeleted      bool     `json:\"remote_deleted\"`\n\tRemoteMergedIntoId *string  `json:\"remote_merged_into_id\"`\n}\n\nfunc (ScrapedPerformer) IsScrapedContent() {}\n\nfunc (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool) *Performer {\n\tret := NewPerformer()\n\tcurrentTime := time.Now()\n\tret.Name = strings.TrimSpace(*p.Name)\n\n\tif p.Aliases != nil && !excluded[\"aliases\"] {\n\t\taliases := stringslice.FromString(*p.Aliases, \",\")\n\t\tfor i, alias := range aliases {\n\t\t\taliases[i] = strings.TrimSpace(alias)\n\t\t}\n\t\tret.Aliases = NewRelatedStrings(aliases)\n\t}\n\tif p.Birthdate != nil && !excluded[\"birthdate\"] {\n\t\tdate, err := ParseDate(*p.Birthdate)\n\t\tif err == nil {\n\t\t\tret.Birthdate = &date\n\t\t}\n\t}\n\tif p.DeathDate != nil && !excluded[\"death_date\"] {\n\t\tdate, err := ParseDate(*p.DeathDate)\n\t\tif err == nil {\n\t\t\tret.DeathDate = &date\n\t\t}\n\t}\n\n\t// assume that career length is _not_ populated in favour of start/end\n\n\tif p.CareerStart != nil && !excluded[\"career_start\"] {\n\t\tdate, err := ParseDate(*p.CareerStart)\n\t\tif err == nil {\n\t\t\tret.CareerStart = &date\n\t\t}\n\t}\n\tif p.CareerEnd != nil && !excluded[\"career_end\"] {\n\t\tdate, err := ParseDate(*p.CareerEnd)\n\t\tif err == nil {\n\t\t\tret.CareerEnd = &date\n\t\t}\n\t}\n\tif p.Country != nil && !excluded[\"country\"] {\n\t\tret.Country = *p.Country\n\t}\n\tif p.Ethnicity != nil && !excluded[\"ethnicity\"] {\n\t\tret.Ethnicity = *p.Ethnicity\n\t}\n\tif p.EyeColor != nil && !excluded[\"eye_color\"] {\n\t\tret.EyeColor = *p.EyeColor\n\t}\n\tif p.HairColor != nil && !excluded[\"hair_color\"] {\n\t\tret.HairColor = *p.HairColor\n\t}\n\tif p.FakeTits != nil && !excluded[\"fake_tits\"] {\n\t\tret.FakeTits = *p.FakeTits\n\t}\n\tif p.Gender != nil && !excluded[\"gender\"] {\n\t\tv := GenderEnum(*p.Gender)\n\t\tif v.IsValid() {\n\t\t\tret.Gender = &v\n\t\t}\n\t}\n\tif p.Height != nil && !excluded[\"height\"] {\n\t\th, err := strconv.Atoi(*p.Height)\n\t\tif err == nil {\n\t\t\tret.Height = &h\n\t\t}\n\t}\n\tif p.Weight != nil && !excluded[\"weight\"] {\n\t\tw, err := strconv.Atoi(*p.Weight)\n\t\tif err == nil {\n\t\t\tret.Weight = &w\n\t\t}\n\t}\n\n\tif p.Measurements != nil && !excluded[\"measurements\"] {\n\t\tret.Measurements = *p.Measurements\n\t}\n\tif p.Disambiguation != nil && !excluded[\"disambiguation\"] {\n\t\tret.Disambiguation = *p.Disambiguation\n\t}\n\tif p.Details != nil && !excluded[\"details\"] {\n\t\tret.Details = *p.Details\n\t}\n\tif p.Piercings != nil && !excluded[\"piercings\"] {\n\t\tret.Piercings = *p.Piercings\n\t}\n\tif p.Tattoos != nil && !excluded[\"tattoos\"] {\n\t\tret.Tattoos = *p.Tattoos\n\t}\n\tif p.PenisLength != nil && !excluded[\"penis_length\"] {\n\t\tl, err := strconv.ParseFloat(*p.PenisLength, 64)\n\t\tif err == nil {\n\t\t\tret.PenisLength = &l\n\t\t}\n\t}\n\tif p.Circumcised != nil && !excluded[\"circumcised\"] {\n\t\tv := CircumcisedEnum(*p.Circumcised)\n\t\tif v.IsValid() {\n\t\t\tret.Circumcised = &v\n\t\t}\n\t}\n\n\t// if URLs are provided, only use those\n\tif len(p.URLs) > 0 {\n\t\tif !excluded[\"urls\"] {\n\t\t\tret.URLs = NewRelatedStrings(p.URLs)\n\t\t}\n\t} else {\n\t\turls := []string{}\n\t\tif p.URL != nil && !excluded[\"url\"] {\n\t\t\turls = append(urls, *p.URL)\n\t\t}\n\t\tif p.Twitter != nil && !excluded[\"twitter\"] {\n\t\t\turls = append(urls, *p.Twitter)\n\t\t}\n\t\tif p.Instagram != nil && !excluded[\"instagram\"] {\n\t\t\turls = append(urls, *p.Instagram)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tret.URLs = NewRelatedStrings(urls)\n\t\t}\n\t}\n\n\tif p.RemoteSiteID != nil && endpoint != \"\" && *p.RemoteSiteID != \"\" {\n\t\tret.StashIDs = NewRelatedStashIDs([]StashID{\n\t\t\t{\n\t\t\t\tEndpoint:  endpoint,\n\t\t\t\tStashID:   *p.RemoteSiteID,\n\t\t\t\tUpdatedAt: currentTime,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn &ret\n}\n\nfunc (p *ScrapedPerformer) GetImage(ctx context.Context, excluded map[string]bool) ([]byte, error) {\n\t// Process the base 64 encoded image string\n\tif len(p.Images) > 0 && !excluded[\"image\"] {\n\t\tvar err error\n\t\timg, err := utils.ProcessImageInput(ctx, p.Images[0])\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn img, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, existingStashIDs []StashID) PerformerPartial {\n\tret := NewPerformerPartial()\n\n\tif p.Aliases != nil && !excluded[\"aliases\"] {\n\t\tret.Aliases = &UpdateStrings{\n\t\t\tValues: stringslice.FromString(*p.Aliases, \",\"),\n\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t}\n\t}\n\tif p.Birthdate != nil && !excluded[\"birthdate\"] {\n\t\tdate, err := ParseDate(*p.Birthdate)\n\t\tif err == nil {\n\t\t\tret.Birthdate = NewOptionalDate(date)\n\t\t}\n\t}\n\tif p.DeathDate != nil && !excluded[\"death_date\"] {\n\t\tdate, err := ParseDate(*p.DeathDate)\n\t\tif err == nil {\n\t\t\tret.DeathDate = NewOptionalDate(date)\n\t\t}\n\t}\n\tif p.CareerLength != nil && !excluded[\"career_length\"] {\n\t\t// parse career_length into career_start/career_end\n\t\tstart, end, err := ParseYearRangeString(*p.CareerLength)\n\t\tif err == nil {\n\t\t\tif start != nil {\n\t\t\t\tret.CareerStart = NewOptionalDate(*start)\n\t\t\t}\n\t\t\tif end != nil {\n\t\t\t\tret.CareerEnd = NewOptionalDate(*end)\n\t\t\t}\n\t\t}\n\t}\n\tif p.Country != nil && !excluded[\"country\"] {\n\t\tret.Country = NewOptionalString(*p.Country)\n\t}\n\tif p.Ethnicity != nil && !excluded[\"ethnicity\"] {\n\t\tret.Ethnicity = NewOptionalString(*p.Ethnicity)\n\t}\n\tif p.EyeColor != nil && !excluded[\"eye_color\"] {\n\t\tret.EyeColor = NewOptionalString(*p.EyeColor)\n\t}\n\tif p.HairColor != nil && !excluded[\"hair_color\"] {\n\t\tret.HairColor = NewOptionalString(*p.HairColor)\n\t}\n\tif p.FakeTits != nil && !excluded[\"fake_tits\"] {\n\t\tret.FakeTits = NewOptionalString(*p.FakeTits)\n\t}\n\tif p.Gender != nil && !excluded[\"gender\"] {\n\t\tret.Gender = NewOptionalString(*p.Gender)\n\t}\n\tif p.Height != nil && !excluded[\"height\"] {\n\t\th, err := strconv.Atoi(*p.Height)\n\t\tif err == nil {\n\t\t\tret.Height = NewOptionalInt(h)\n\t\t}\n\t}\n\tif p.Weight != nil && !excluded[\"weight\"] {\n\t\tw, err := strconv.Atoi(*p.Weight)\n\t\tif err == nil {\n\t\t\tret.Weight = NewOptionalInt(w)\n\t\t}\n\t}\n\tif p.Measurements != nil && !excluded[\"measurements\"] {\n\t\tret.Measurements = NewOptionalString(*p.Measurements)\n\t}\n\tif p.Name != nil && !excluded[\"name\"] {\n\t\tret.Name = NewOptionalString(*p.Name)\n\t}\n\tif p.Disambiguation != nil && !excluded[\"disambiguation\"] {\n\t\tret.Disambiguation = NewOptionalString(*p.Disambiguation)\n\t}\n\tif p.Details != nil && !excluded[\"details\"] {\n\t\tret.Details = NewOptionalString(*p.Details)\n\t}\n\tif p.Piercings != nil && !excluded[\"piercings\"] {\n\t\tret.Piercings = NewOptionalString(*p.Piercings)\n\t}\n\tif p.Tattoos != nil && !excluded[\"tattoos\"] {\n\t\tret.Tattoos = NewOptionalString(*p.Tattoos)\n\t}\n\n\t// if URLs are provided, only use those\n\tif len(p.URLs) > 0 {\n\t\tif !excluded[\"urls\"] {\n\t\t\tret.URLs = &UpdateStrings{\n\t\t\t\tValues: p.URLs,\n\t\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t\t}\n\t\t}\n\t} else {\n\t\turls := []string{}\n\t\tif p.URL != nil && !excluded[\"url\"] {\n\t\t\turls = append(urls, *p.URL)\n\t\t}\n\t\tif p.Twitter != nil && !excluded[\"twitter\"] {\n\t\t\turls = append(urls, *p.Twitter)\n\t\t}\n\t\tif p.Instagram != nil && !excluded[\"instagram\"] {\n\t\t\turls = append(urls, *p.Instagram)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tret.URLs = &UpdateStrings{\n\t\t\t\tValues: urls,\n\t\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t\t}\n\t\t}\n\t}\n\n\tif p.RemoteSiteID != nil && endpoint != \"\" && *p.RemoteSiteID != \"\" {\n\t\tret.StashIDs = &UpdateStashIDs{\n\t\t\tStashIDs: existingStashIDs,\n\t\t\tMode:     RelationshipUpdateModeSet,\n\t\t}\n\t\tret.StashIDs.Set(StashID{\n\t\t\tEndpoint:  endpoint,\n\t\t\tStashID:   *p.RemoteSiteID,\n\t\t\tUpdatedAt: time.Now(),\n\t\t})\n\t}\n\n\treturn ret\n}\n\ntype ScrapedTag struct {\n\t// Set if tag matched\n\tStoredID     *string     `json:\"stored_id\"`\n\tName         string      `json:\"name\"`\n\tDescription  *string     `json:\"description\"`\n\tAliasList    []string    `json:\"alias_list\"`\n\tRemoteSiteID *string     `json:\"remote_site_id\"`\n\tParent       *ScrapedTag `json:\"parent\"`\n}\n\nfunc (ScrapedTag) IsScrapedContent() {}\n\nfunc (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {\n\tcurrentTime := time.Now()\n\tret := NewTag()\n\tret.Name = t.Name\n\tret.ParentIDs = NewRelatedIDs([]int{})\n\tret.ChildIDs = NewRelatedIDs([]int{})\n\tret.Aliases = NewRelatedStrings([]string{})\n\n\tif t.Description != nil && !excluded[\"description\"] {\n\t\tret.Description = *t.Description\n\t}\n\n\tif len(t.AliasList) > 0 && !excluded[\"aliases\"] {\n\t\tret.Aliases = NewRelatedStrings(t.AliasList)\n\t}\n\n\tif t.Parent != nil && t.Parent.StoredID != nil {\n\t\tparentID, err := strconv.Atoi(*t.Parent.StoredID)\n\t\tif err == nil && parentID > 0 {\n\t\t\tret.ParentIDs = NewRelatedIDs([]int{parentID})\n\t\t}\n\t}\n\n\tif t.RemoteSiteID != nil && endpoint != \"\" && *t.RemoteSiteID != \"\" {\n\t\tret.StashIDs = NewRelatedStashIDs([]StashID{\n\t\t\t{\n\t\t\t\tEndpoint:  endpoint,\n\t\t\t\tStashID:   *t.RemoteSiteID,\n\t\t\t\tUpdatedAt: currentTime,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn &ret\n}\n\nfunc (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) TagPartial {\n\tret := NewTagPartial()\n\n\tif t.Name != \"\" && !excluded[\"name\"] {\n\t\tret.Name = NewOptionalString(t.Name)\n\t}\n\n\tif t.Description != nil && !excluded[\"description\"] {\n\t\tret.Description = NewOptionalString(*t.Description)\n\t}\n\n\tif len(t.AliasList) > 0 && !excluded[\"aliases\"] {\n\t\tret.Aliases = &UpdateStrings{\n\t\t\tValues: t.AliasList,\n\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t}\n\t}\n\n\tif t.Parent != nil && t.Parent.StoredID != nil {\n\t\tparentID, err := strconv.Atoi(*t.Parent.StoredID)\n\t\tif err == nil && parentID > 0 {\n\t\t\tret.ParentIDs = &UpdateIDs{\n\t\t\t\tIDs:  []int{parentID},\n\t\t\t\tMode: RelationshipUpdateModeAdd,\n\t\t\t}\n\t\t}\n\t}\n\n\tif t.RemoteSiteID != nil && endpoint != \"\" && *t.RemoteSiteID != \"\" {\n\t\tret.StashIDs = &UpdateStashIDs{\n\t\t\tStashIDs: existingStashIDs,\n\t\t\tMode:     RelationshipUpdateModeSet,\n\t\t}\n\t\tret.StashIDs.Set(StashID{\n\t\t\tEndpoint:  endpoint,\n\t\t\tStashID:   *t.RemoteSiteID,\n\t\t\tUpdatedAt: time.Now(),\n\t\t})\n\t}\n\n\treturn ret\n}\n\nfunc ScrapedTagSortFunction(a, b *ScrapedTag) int {\n\treturn strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))\n}\n\n// A movie from a scraping operation...\ntype ScrapedMovie struct {\n\tStoredID *string        `json:\"stored_id\"`\n\tName     *string        `json:\"name\"`\n\tAliases  *string        `json:\"aliases\"`\n\tDuration *string        `json:\"duration\"`\n\tDate     *string        `json:\"date\"`\n\tRating   *string        `json:\"rating\"`\n\tDirector *string        `json:\"director\"`\n\tURLs     []string       `json:\"urls\"`\n\tSynopsis *string        `json:\"synopsis\"`\n\tStudio   *ScrapedStudio `json:\"studio\"`\n\tTags     []*ScrapedTag  `json:\"tags\"`\n\t// This should be a base64 encoded data URL\n\tFrontImage *string `json:\"front_image\"`\n\t// This should be a base64 encoded data URL\n\tBackImage *string `json:\"back_image\"`\n\n\t// deprecated\n\tURL *string `json:\"url\"`\n}\n\nfunc (ScrapedMovie) IsScrapedContent() {}\n\nfunc (m ScrapedMovie) ScrapedGroup() ScrapedGroup {\n\tret := ScrapedGroup{\n\t\tStoredID:   m.StoredID,\n\t\tName:       m.Name,\n\t\tAliases:    m.Aliases,\n\t\tDuration:   m.Duration,\n\t\tDate:       m.Date,\n\t\tRating:     m.Rating,\n\t\tDirector:   m.Director,\n\t\tURLs:       m.URLs,\n\t\tSynopsis:   m.Synopsis,\n\t\tStudio:     m.Studio,\n\t\tTags:       m.Tags,\n\t\tFrontImage: m.FrontImage,\n\t\tBackImage:  m.BackImage,\n\t}\n\n\tif len(m.URLs) == 0 && m.URL != nil {\n\t\tret.URLs = []string{*m.URL}\n\t}\n\n\treturn ret\n}\n\n// ScrapedGroup is a group from a scraping operation\ntype ScrapedGroup struct {\n\tStoredID *string        `json:\"stored_id\"`\n\tName     *string        `json:\"name\"`\n\tAliases  *string        `json:\"aliases\"`\n\tDuration *string        `json:\"duration\"`\n\tDate     *string        `json:\"date\"`\n\tRating   *string        `json:\"rating\"`\n\tDirector *string        `json:\"director\"`\n\tURL      *string        `json:\"url\"` // included for backward compatibility\n\tURLs     []string       `json:\"urls\"`\n\tSynopsis *string        `json:\"synopsis\"`\n\tStudio   *ScrapedStudio `json:\"studio\"`\n\tTags     []*ScrapedTag  `json:\"tags\"`\n\t// This should be a base64 encoded data URL\n\tFrontImage *string `json:\"front_image\"`\n\t// This should be a base64 encoded data URL\n\tBackImage *string `json:\"back_image\"`\n}\n\nfunc (ScrapedGroup) IsScrapedContent() {}\n\nfunc (g ScrapedGroup) ScrapedMovie() ScrapedMovie {\n\tret := ScrapedMovie{\n\t\tStoredID:   g.StoredID,\n\t\tName:       g.Name,\n\t\tAliases:    g.Aliases,\n\t\tDuration:   g.Duration,\n\t\tDate:       g.Date,\n\t\tRating:     g.Rating,\n\t\tDirector:   g.Director,\n\t\tURLs:       g.URLs,\n\t\tSynopsis:   g.Synopsis,\n\t\tStudio:     g.Studio,\n\t\tTags:       g.Tags,\n\t\tFrontImage: g.FrontImage,\n\t\tBackImage:  g.BackImage,\n\t}\n\n\tif len(g.URLs) > 0 {\n\t\tret.URL = &g.URLs[0]\n\t}\n\n\treturn ret\n}\n\ntype ScrapedScene struct {\n\tTitle    *string  `json:\"title\"`\n\tCode     *string  `json:\"code\"`\n\tDetails  *string  `json:\"details\"`\n\tDirector *string  `json:\"director\"`\n\tURL      *string  `json:\"url\"`\n\tURLs     []string `json:\"urls\"`\n\tDate     *string  `json:\"date\"`\n\t// This should be a base64 encoded data URL\n\tImage        *string                `json:\"image\"`\n\tFile         *SceneFileType         `json:\"file\"`\n\tStudio       *ScrapedStudio         `json:\"studio\"`\n\tTags         []*ScrapedTag          `json:\"tags\"`\n\tPerformers   []*ScrapedPerformer    `json:\"performers\"`\n\tGroups       []*ScrapedGroup        `json:\"groups\"`\n\tMovies       []*ScrapedMovie        `json:\"movies\"`\n\tRemoteSiteID *string                `json:\"remote_site_id\"`\n\tDuration     *int                   `json:\"duration\"`\n\tFingerprints []*StashBoxFingerprint `json:\"fingerprints\"`\n}\n\nfunc (ScrapedScene) IsScrapedContent() {}\n\ntype ScrapedSceneInput struct {\n\tTitle        *string  `json:\"title\"`\n\tCode         *string  `json:\"code\"`\n\tDetails      *string  `json:\"details\"`\n\tDirector     *string  `json:\"director\"`\n\tURL          *string  `json:\"url\"`\n\tURLs         []string `json:\"urls\"`\n\tDate         *string  `json:\"date\"`\n\tRemoteSiteID *string  `json:\"remote_site_id\"`\n}\n\ntype ScrapedImage struct {\n\tTitle        *string             `json:\"title\"`\n\tCode         *string             `json:\"code\"`\n\tDetails      *string             `json:\"details\"`\n\tPhotographer *string             `json:\"photographer\"`\n\tURLs         []string            `json:\"urls\"`\n\tDate         *string             `json:\"date\"`\n\tStudio       *ScrapedStudio      `json:\"studio\"`\n\tTags         []*ScrapedTag       `json:\"tags\"`\n\tPerformers   []*ScrapedPerformer `json:\"performers\"`\n}\n\nfunc (ScrapedImage) IsScrapedContent() {}\n\ntype ScrapedImageInput struct {\n\tTitle   *string  `json:\"title\"`\n\tCode    *string  `json:\"code\"`\n\tDetails *string  `json:\"details\"`\n\tURLs    []string `json:\"urls\"`\n\tDate    *string  `json:\"date\"`\n}\n\ntype ScrapedGallery struct {\n\tTitle        *string             `json:\"title\"`\n\tCode         *string             `json:\"code\"`\n\tDetails      *string             `json:\"details\"`\n\tPhotographer *string             `json:\"photographer\"`\n\tURLs         []string            `json:\"urls\"`\n\tDate         *string             `json:\"date\"`\n\tStudio       *ScrapedStudio      `json:\"studio\"`\n\tTags         []*ScrapedTag       `json:\"tags\"`\n\tPerformers   []*ScrapedPerformer `json:\"performers\"`\n\n\t// deprecated\n\tURL *string `json:\"url\"`\n}\n\nfunc (ScrapedGallery) IsScrapedContent() {}\n\ntype ScrapedGalleryInput struct {\n\tTitle        *string  `json:\"title\"`\n\tCode         *string  `json:\"code\"`\n\tDetails      *string  `json:\"details\"`\n\tPhotographer *string  `json:\"photographer\"`\n\tURLs         []string `json:\"urls\"`\n\tDate         *string  `json:\"date\"`\n\n\t// deprecated\n\tURL *string `json:\"url\"`\n}\n"
  },
  {
    "path": "pkg/models/model_scraped_item_test.go",
    "content": "package models\n\nimport (\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_scrapedToStudioInput(t *testing.T) {\n\tconst name = \"name\"\n\turl := \"url\"\n\turl2 := \"url2\"\n\temptyEndpoint := \"\"\n\tendpoint := \"endpoint\"\n\tremoteSiteID := \"remoteSiteID\"\n\n\ttests := []struct {\n\t\tname     string\n\t\tstudio   *ScrapedStudio\n\t\tendpoint string\n\t\twant     *Studio\n\t}{\n\t\t{\n\t\t\t\"set all\",\n\t\t\t&ScrapedStudio{\n\t\t\t\tName:         name,\n\t\t\t\tURLs:         []string{url, url2},\n\t\t\t\tURL:          &url,\n\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t},\n\t\t\tendpoint,\n\t\t\t&Studio{\n\t\t\t\tName: name,\n\t\t\t\tURLs: NewRelatedStrings([]string{url, url2}),\n\t\t\t\tStashIDs: NewRelatedStashIDs([]StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tEndpoint: endpoint,\n\t\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"set url instead of urls\",\n\t\t\t&ScrapedStudio{\n\t\t\t\tName:         name,\n\t\t\t\tURL:          &url,\n\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t},\n\t\t\tendpoint,\n\t\t\t&Studio{\n\t\t\t\tName: name,\n\t\t\t\tURLs: NewRelatedStrings([]string{url}),\n\t\t\t\tStashIDs: NewRelatedStashIDs([]StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tEndpoint: endpoint,\n\t\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"set none\",\n\t\t\t&ScrapedStudio{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t\temptyEndpoint,\n\t\t\t&Studio{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"missing remoteSiteID\",\n\t\t\t&ScrapedStudio{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t\tendpoint,\n\t\t\t&Studio{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"set stashid\",\n\t\t\t&ScrapedStudio{\n\t\t\t\tName:         name,\n\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t},\n\t\t\tendpoint,\n\t\t\t&Studio{\n\t\t\t\tName: name,\n\t\t\t\tStashIDs: NewRelatedStashIDs([]StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tEndpoint: endpoint,\n\t\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.studio.ToStudio(tt.endpoint, nil)\n\n\t\t\tassert.NotEqual(t, time.Time{}, got.CreatedAt)\n\t\t\tassert.NotEqual(t, time.Time{}, got.UpdatedAt)\n\n\t\t\tgot.CreatedAt = time.Time{}\n\t\t\tgot.UpdatedAt = time.Time{}\n\t\t\tif got.StashIDs.Loaded() && len(got.StashIDs.List()) > 0 {\n\t\t\t\tfor stid := range got.StashIDs.List() {\n\t\t\t\t\tgot.StashIDs.List()[stid].UpdatedAt = time.Time{}\n\t\t\t\t}\n\t\t\t}\n\t\t\tassert.Equal(t, tt.want, got.Studio)\n\t\t})\n\t}\n}\n\nfunc Test_scrapedToPerformerInput(t *testing.T) {\n\tname := \"name\"\n\temptyEndpoint := \"\"\n\tendpoint := \"endpoint\"\n\tremoteSiteID := \"remoteSiteID\"\n\n\tconst nValues = 19\n\tstringValues := make([]string, nValues)\n\tfor i := 0; i < nValues; i++ {\n\t\tstringValues[i] = strconv.Itoa(i)\n\t}\n\n\tupTo := 0\n\tnextVal := func() *string {\n\t\tret := stringValues[upTo]\n\t\tupTo = (upTo + 1) % len(stringValues)\n\t\treturn &ret\n\t}\n\n\tnextIntVal := func() *int {\n\t\tret := upTo\n\t\tupTo = (upTo + 1) % len(stringValues)\n\t\treturn &ret\n\t}\n\n\tdateFromInt := func(i int) *Date {\n\t\tt := time.Date(2001, 1, i, 0, 0, 0, 0, time.UTC)\n\t\td := Date{Time: t}\n\t\treturn &d\n\t}\n\tdateStrFromInt := func(i int) *string {\n\t\ts := dateFromInt(i).String()\n\t\treturn &s\n\t}\n\n\tgenderFromInt := func(i int) *GenderEnum {\n\t\tg := AllGenderEnum[i%len(AllGenderEnum)]\n\t\treturn &g\n\t}\n\tgenderStrFromInt := func(i int) *string {\n\t\ts := genderFromInt(i).String()\n\t\treturn &s\n\t}\n\n\ttests := []struct {\n\t\tname      string\n\t\tperformer *ScrapedPerformer\n\t\tendpoint  string\n\t\twant      *Performer\n\t}{\n\t\t{\n\t\t\t\"set all\",\n\t\t\t&ScrapedPerformer{\n\t\t\t\tName:           &name,\n\t\t\t\tDisambiguation: nextVal(),\n\t\t\t\tBirthdate:      dateStrFromInt(*nextIntVal()),\n\t\t\t\tDeathDate:      dateStrFromInt(*nextIntVal()),\n\t\t\t\tGender:         genderStrFromInt(*nextIntVal()),\n\t\t\t\tEthnicity:      nextVal(),\n\t\t\t\tCountry:        nextVal(),\n\t\t\t\tEyeColor:       nextVal(),\n\t\t\t\tHairColor:      nextVal(),\n\t\t\t\tHeight:         nextVal(),\n\t\t\t\tWeight:         nextVal(),\n\t\t\t\tMeasurements:   nextVal(),\n\t\t\t\tFakeTits:       nextVal(),\n\t\t\t\tCareerStart:    dateStrFromInt(2005),\n\t\t\t\tCareerEnd:      dateStrFromInt(2015),\n\t\t\t\tTattoos:        nextVal(),\n\t\t\t\tPiercings:      nextVal(),\n\t\t\t\tAliases:        nextVal(),\n\t\t\t\tURL:            nextVal(),\n\t\t\t\tTwitter:        nextVal(),\n\t\t\t\tInstagram:      nextVal(),\n\t\t\t\tDetails:        nextVal(),\n\t\t\t\tRemoteSiteID:   &remoteSiteID,\n\t\t\t},\n\t\t\tendpoint,\n\t\t\t&Performer{\n\t\t\t\tName:           name,\n\t\t\t\tDisambiguation: *nextVal(),\n\t\t\t\tBirthdate:      dateFromInt(*nextIntVal()),\n\t\t\t\tDeathDate:      dateFromInt(*nextIntVal()),\n\t\t\t\tGender:         genderFromInt(*nextIntVal()),\n\t\t\t\tEthnicity:      *nextVal(),\n\t\t\t\tCountry:        *nextVal(),\n\t\t\t\tEyeColor:       *nextVal(),\n\t\t\t\tHairColor:      *nextVal(),\n\t\t\t\tHeight:         nextIntVal(),\n\t\t\t\tWeight:         nextIntVal(),\n\t\t\t\tMeasurements:   *nextVal(),\n\t\t\t\tFakeTits:       *nextVal(),\n\t\t\t\tCareerStart:    dateFromInt(2005),\n\t\t\t\tCareerEnd:      dateFromInt(2015),\n\t\t\t\tTattoos:        *nextVal(), // skip CareerLength counter slot\n\t\t\t\tPiercings:      *nextVal(),\n\t\t\t\tAliases:        NewRelatedStrings([]string{*nextVal()}),\n\t\t\t\tURLs:           NewRelatedStrings([]string{*nextVal(), *nextVal(), *nextVal()}),\n\t\t\t\tDetails:        *nextVal(),\n\t\t\t\tStashIDs: NewRelatedStashIDs([]StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tEndpoint: endpoint,\n\t\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"set none\",\n\t\t\t&ScrapedPerformer{\n\t\t\t\tName: &name,\n\t\t\t},\n\t\t\temptyEndpoint,\n\t\t\t&Performer{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"missing remoteSiteID\",\n\t\t\t&ScrapedPerformer{\n\t\t\t\tName: &name,\n\t\t\t},\n\t\t\tendpoint,\n\t\t\t&Performer{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"set stashid\",\n\t\t\t&ScrapedPerformer{\n\t\t\t\tName:         &name,\n\t\t\t\tRemoteSiteID: &remoteSiteID,\n\t\t\t},\n\t\t\tendpoint,\n\t\t\t&Performer{\n\t\t\t\tName: name,\n\t\t\t\tStashIDs: NewRelatedStashIDs([]StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tEndpoint: endpoint,\n\t\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.performer.ToPerformer(tt.endpoint, nil)\n\n\t\t\tassert.NotEqual(t, time.Time{}, got.CreatedAt)\n\t\t\tassert.NotEqual(t, time.Time{}, got.UpdatedAt)\n\n\t\t\tgot.CreatedAt = time.Time{}\n\t\t\tgot.UpdatedAt = time.Time{}\n\n\t\t\tif got.StashIDs.Loaded() && len(got.StashIDs.List()) > 0 {\n\t\t\t\tfor stid := range got.StashIDs.List() {\n\t\t\t\t\tgot.StashIDs.List()[stid].UpdatedAt = time.Time{}\n\t\t\t\t}\n\t\t\t}\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestScrapedStudio_ToPartial(t *testing.T) {\n\tvar (\n\t\tid                = 1000\n\t\tidStr             = strconv.Itoa(id)\n\t\tstoredID          = \"storedID\"\n\t\tparentStoredID    = 2000\n\t\tparentStoredIDStr = strconv.Itoa(parentStoredID)\n\t\tname              = \"name\"\n\t\turl               = \"url\"\n\t\tremoteSiteID      = \"remoteSiteID\"\n\t\tendpoint          = \"endpoint\"\n\t\timage             = \"image\"\n\t\timages            = []string{image}\n\n\t\texistingEndpoint = \"existingEndpoint\"\n\t\texistingStashID  = StashID{\"existingStashID\", existingEndpoint, time.Time{}}\n\t\texistingStashIDs = []StashID{existingStashID}\n\t)\n\n\tfullStudio := ScrapedStudio{\n\t\tStoredID: &storedID,\n\t\tName:     name,\n\t\tURL:      &url,\n\t\tParent: &ScrapedStudio{\n\t\t\tStoredID: &parentStoredIDStr,\n\t\t},\n\t\tImage:        &image,\n\t\tImages:       images,\n\t\tRemoteSiteID: &remoteSiteID,\n\t}\n\n\ttype args struct {\n\t\tid               string\n\t\tendpoint         string\n\t\texcluded         map[string]bool\n\t\texistingStashIDs []StashID\n\t}\n\n\tstdArgs := args{\n\t\tid:               idStr,\n\t\tendpoint:         endpoint,\n\t\texcluded:         map[string]bool{},\n\t\texistingStashIDs: existingStashIDs,\n\t}\n\n\texcludeAll := map[string]bool{\n\t\t\"name\":   true,\n\t\t\"url\":    true,\n\t\t\"parent\": true,\n\t}\n\n\ttests := []struct {\n\t\tname string\n\t\to    ScrapedStudio\n\t\targs args\n\t\twant StudioPartial\n\t}{\n\t\t{\n\t\t\t\"full no exclusions\",\n\t\t\tfullStudio,\n\t\t\tstdArgs,\n\t\t\tStudioPartial{\n\t\t\t\tID:   id,\n\t\t\t\tName: NewOptionalString(name),\n\t\t\t\tURLs: &UpdateStrings{\n\t\t\t\t\tValues: []string{url},\n\t\t\t\t\tMode:   RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tParentID: NewOptionalInt(parentStoredID),\n\t\t\t\tStashIDs: &UpdateStashIDs{\n\t\t\t\t\tStashIDs: append(existingStashIDs, StashID{\n\t\t\t\t\t\tEndpoint: endpoint,\n\t\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t\t}),\n\t\t\t\t\tMode: RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"exclude all\",\n\t\t\tfullStudio,\n\t\t\targs{\n\t\t\t\tid:       idStr,\n\t\t\t\texcluded: excludeAll,\n\t\t\t},\n\t\t\tStudioPartial{\n\t\t\t\tID: id,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"overwrite stash id\",\n\t\t\tfullStudio,\n\t\t\targs{\n\t\t\t\tid:               idStr,\n\t\t\t\texcluded:         excludeAll,\n\t\t\t\tendpoint:         existingEndpoint,\n\t\t\t\texistingStashIDs: existingStashIDs,\n\t\t\t},\n\t\t\tStudioPartial{\n\t\t\t\tID: id,\n\t\t\t\tStashIDs: &UpdateStashIDs{\n\t\t\t\t\tStashIDs: []StashID{{\n\t\t\t\t\t\tEndpoint: existingEndpoint,\n\t\t\t\t\t\tStashID:  remoteSiteID,\n\t\t\t\t\t}},\n\t\t\t\t\tMode: RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ts := tt.o\n\t\t\tgot := s.ToPartial(tt.args.id, tt.args.endpoint, tt.args.excluded, tt.args.existingStashIDs)\n\n\t\t\t// unset updatedAt - we don't need to compare it\n\t\t\tgot.UpdatedAt = OptionalTime{}\n\t\t\tif got.StashIDs != nil && len(got.StashIDs.StashIDs) > 0 {\n\t\t\t\tfor stid := range got.StashIDs.StashIDs {\n\t\t\t\t\tgot.StashIDs.StashIDs[stid].UpdatedAt = time.Time{}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/models/model_studio.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\ntype Studio struct {\n\tID        int       `json:\"id\"`\n\tName      string    `json:\"name\"`\n\tParentID  *int      `json:\"parent_id\"`\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n\t// Rating expressed in 1-100 scale\n\tRating        *int   `json:\"rating\"`\n\tFavorite      bool   `json:\"favorite\"`\n\tDetails       string `json:\"details\"`\n\tIgnoreAutoTag bool   `json:\"ignore_auto_tag\"`\n\tOrganized     bool   `json:\"organized\"`\n\n\tAliases  RelatedStrings  `json:\"aliases\"`\n\tURLs     RelatedStrings  `json:\"urls\"`\n\tTagIDs   RelatedIDs      `json:\"tag_ids\"`\n\tStashIDs RelatedStashIDs `json:\"stash_ids\"`\n}\n\ntype CreateStudioInput struct {\n\t*Studio\n\n\tCustomFields map[string]interface{} `json:\"custom_fields\"`\n}\n\ntype UpdateStudioInput struct {\n\t*Studio\n\n\tCustomFields CustomFieldsInput `json:\"custom_fields\"`\n}\n\nfunc NewStudio() Studio {\n\tcurrentTime := time.Now()\n\treturn Studio{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\nfunc NewCreateStudioInput() CreateStudioInput {\n\ts := NewStudio()\n\treturn CreateStudioInput{\n\t\tStudio: &s,\n\t}\n}\n\n// StudioPartial represents part of a Studio object. It is used to update the database entry.\ntype StudioPartial struct {\n\tID       int\n\tName     OptionalString\n\tParentID OptionalInt\n\t// Rating expressed in 1-100 scale\n\tRating        OptionalInt\n\tFavorite      OptionalBool\n\tDetails       OptionalString\n\tCreatedAt     OptionalTime\n\tUpdatedAt     OptionalTime\n\tIgnoreAutoTag OptionalBool\n\tOrganized     OptionalBool\n\n\tAliases  *UpdateStrings\n\tURLs     *UpdateStrings\n\tTagIDs   *UpdateIDs\n\tStashIDs *UpdateStashIDs\n\n\tCustomFields CustomFieldsInput\n}\n\nfunc NewStudioPartial() StudioPartial {\n\tcurrentTime := time.Now()\n\treturn StudioPartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n\nfunc (s *Studio) LoadAliases(ctx context.Context, l AliasLoader) error {\n\treturn s.Aliases.load(func() ([]string, error) {\n\t\treturn l.GetAliases(ctx, s.ID)\n\t})\n}\n\nfunc (s *Studio) LoadURLs(ctx context.Context, l URLLoader) error {\n\treturn s.URLs.load(func() ([]string, error) {\n\t\treturn l.GetURLs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Studio) LoadTagIDs(ctx context.Context, l TagIDLoader) error {\n\treturn s.TagIDs.load(func() ([]int, error) {\n\t\treturn l.GetTagIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Studio) LoadStashIDs(ctx context.Context, l StashIDLoader) error {\n\treturn s.StashIDs.load(func() ([]StashID, error) {\n\t\treturn l.GetStashIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Studio) LoadRelationships(ctx context.Context, l PerformerReader) error {\n\tif err := s.LoadAliases(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadTagIDs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.LoadStashIDs(ctx, l); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/models/model_tag.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\ntype Tag struct {\n\tID            int       `json:\"id\"`\n\tName          string    `json:\"name\"`\n\tSortName      string    `json:\"sort_name\"`\n\tFavorite      bool      `json:\"favorite\"`\n\tDescription   string    `json:\"description\"`\n\tIgnoreAutoTag bool      `json:\"ignore_auto_tag\"`\n\tCreatedAt     time.Time `json:\"created_at\"`\n\tUpdatedAt     time.Time `json:\"updated_at\"`\n\n\tAliases   RelatedStrings  `json:\"aliases\"`\n\tParentIDs RelatedIDs      `json:\"parent_ids\"`\n\tChildIDs  RelatedIDs      `json:\"tag_ids\"`\n\tStashIDs  RelatedStashIDs `json:\"stash_ids\"`\n}\n\nfunc NewTag() Tag {\n\tcurrentTime := time.Now()\n\treturn Tag{\n\t\tCreatedAt: currentTime,\n\t\tUpdatedAt: currentTime,\n\t}\n}\n\ntype CreateTagInput struct {\n\t*Tag\n\n\tCustomFields map[string]interface{} `json:\"custom_fields\"`\n}\n\ntype UpdateTagInput struct {\n\t*Tag\n\n\tCustomFields CustomFieldsInput `json:\"custom_fields\"`\n}\n\nfunc (s *Tag) LoadAliases(ctx context.Context, l AliasLoader) error {\n\treturn s.Aliases.load(func() ([]string, error) {\n\t\treturn l.GetAliases(ctx, s.ID)\n\t})\n}\n\nfunc (s *Tag) LoadParentIDs(ctx context.Context, l TagRelationLoader) error {\n\treturn s.ParentIDs.load(func() ([]int, error) {\n\t\treturn l.GetParentIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error {\n\treturn s.ChildIDs.load(func() ([]int, error) {\n\t\treturn l.GetChildIDs(ctx, s.ID)\n\t})\n}\n\nfunc (s *Tag) LoadStashIDs(ctx context.Context, l StashIDLoader) error {\n\treturn s.StashIDs.load(func() ([]StashID, error) {\n\t\treturn l.GetStashIDs(ctx, s.ID)\n\t})\n}\n\ntype TagPartial struct {\n\tName          OptionalString\n\tSortName      OptionalString\n\tDescription   OptionalString\n\tFavorite      OptionalBool\n\tIgnoreAutoTag OptionalBool\n\tCreatedAt     OptionalTime\n\tUpdatedAt     OptionalTime\n\n\tAliases   *UpdateStrings\n\tParentIDs *UpdateIDs\n\tChildIDs  *UpdateIDs\n\tStashIDs  *UpdateStashIDs\n\n\tCustomFields CustomFieldsInput\n}\n\nfunc NewTagPartial() TagPartial {\n\tcurrentTime := time.Now()\n\treturn TagPartial{\n\t\tUpdatedAt: NewOptionalTime(currentTime),\n\t}\n}\n\ntype TagPath struct {\n\tTag\n\tPath string `json:\"path\"`\n}\n"
  },
  {
    "path": "pkg/models/orientation.go",
    "content": "package models\n\ntype OrientationEnum string\n\nconst (\n\tOrientationLandscape OrientationEnum = \"LANDSCAPE\"\n\tOrientationPortrait  OrientationEnum = \"PORTRAIT\"\n\tOrientationSquare    OrientationEnum = \"SQUARE\"\n)\n\nfunc (e OrientationEnum) IsValid() bool {\n\tswitch e {\n\tcase OrientationLandscape, OrientationPortrait, OrientationSquare:\n\t\treturn true\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "pkg/models/package.go",
    "content": "package models\n\ntype PackageSpecInput struct {\n\tID        string `json:\"id\"`\n\tSourceURL string `json:\"sourceURL\"`\n}\n\ntype PackageSource struct {\n\tName      *string `json:\"name\"`\n\tLocalPath string  `json:\"localPath\"`\n\tURL       string  `json:\"url\"`\n}\n"
  },
  {
    "path": "pkg/models/paths/paths.go",
    "content": "// Package paths provides functions to return paths to various resources.\npackage paths\n\nimport (\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n)\n\ntype Paths struct {\n\tGenerated *generatedPaths\n\n\tScene        *scenePaths\n\tSceneMarkers *sceneMarkerPaths\n\tBlobs        string\n}\n\nfunc NewPaths(generatedPath string, blobsPath string) Paths {\n\tp := Paths{}\n\tp.Generated = newGeneratedPaths(generatedPath)\n\n\tp.Scene = newScenePaths(p)\n\tp.SceneMarkers = newSceneMarkerPaths(p)\n\tp.Blobs = blobsPath\n\n\treturn p\n}\n\nfunc GetStashHomeDirectory() string {\n\treturn filepath.Join(fsutil.GetHomeDirectory(), \".stash\")\n}\n\nfunc GetDefaultDatabaseFilePath() string {\n\treturn filepath.Join(GetStashHomeDirectory(), \"stash-go.sqlite\")\n}\n"
  },
  {
    "path": "pkg/models/paths/paths_generated.go",
    "content": "package paths\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst thumbDirDepth int = 2\nconst thumbDirLength int = 2 // thumbDirDepth * thumbDirLength must be smaller than the length of checksum\n\ntype generatedPaths struct {\n\tScreenshots        string\n\tThumbnails         string\n\tVtt                string\n\tMarkers            string\n\tTranscodes         string\n\tDownloads          string\n\tTmp                string\n\tInteractiveHeatmap string\n}\n\nfunc newGeneratedPaths(path string) *generatedPaths {\n\tgp := generatedPaths{}\n\tgp.Screenshots = filepath.Join(path, \"screenshots\")\n\tgp.Thumbnails = filepath.Join(path, \"thumbnails\")\n\tgp.Vtt = filepath.Join(path, \"vtt\")\n\tgp.Markers = filepath.Join(path, \"markers\")\n\tgp.Transcodes = filepath.Join(path, \"transcodes\")\n\tgp.Downloads = filepath.Join(path, \"download_stage\")\n\tgp.Tmp = filepath.Join(path, \"tmp\")\n\tgp.InteractiveHeatmap = filepath.Join(path, \"interactive_heatmaps\")\n\treturn &gp\n}\n\nfunc (gp *generatedPaths) GetTmpPath(fileName string) string {\n\treturn filepath.Join(gp.Tmp, fileName)\n}\n\n// TempFile creates a temporary file using os.CreateTemp.\n// It is the equivalent of calling os.CreateTemp using Tmp and pattern.\nfunc (gp *generatedPaths) TempFile(pattern string) (*os.File, error) {\n\tif err := gp.EnsureTmpDir(); err != nil {\n\t\tlogger.Warnf(\"Could not ensure existence of a temporary directory: %v\", err)\n\t}\n\treturn os.CreateTemp(gp.Tmp, pattern)\n}\n\nfunc (gp *generatedPaths) EnsureTmpDir() error {\n\treturn fsutil.EnsureDir(gp.Tmp)\n}\n\nfunc (gp *generatedPaths) EmptyTmpDir() error {\n\treturn fsutil.EmptyDir(gp.Tmp)\n}\n\nfunc (gp *generatedPaths) RemoveTmpDir() error {\n\treturn fsutil.RemoveDir(gp.Tmp)\n}\n\nfunc (gp *generatedPaths) TempDir(pattern string) (string, error) {\n\tif err := gp.EnsureTmpDir(); err != nil {\n\t\tlogger.Warnf(\"Could not ensure existence of a temporary directory: %v\", err)\n\t}\n\tret, err := os.MkdirTemp(gp.Tmp, pattern)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif err = fsutil.EmptyDir(ret); err != nil {\n\t\tlogger.Warnf(\"could not recursively empty dir: %v\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (gp *generatedPaths) GetThumbnailPath(checksum string, width int) string {\n\tfname := fmt.Sprintf(\"%s_%d.jpg\", checksum, width)\n\treturn filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname)\n}\n\nfunc (gp *generatedPaths) GetClipPreviewPath(checksum string, width int) string {\n\tfname := fmt.Sprintf(\"%s_%d.webm\", checksum, width)\n\treturn filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname)\n}\n"
  },
  {
    "path": "pkg/models/paths/paths_json.go",
    "content": "package paths\n\nimport (\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype JSONPaths struct {\n\tMetadata string\n\n\tScrapedFile string\n\n\tPerformers   string\n\tScenes       string\n\tImages       string\n\tGalleries    string\n\tStudios      string\n\tTags         string\n\tGroups       string\n\tFiles        string\n\tSavedFilters string\n}\n\nfunc newJSONPaths(baseDir string) *JSONPaths {\n\tjp := JSONPaths{}\n\tjp.Metadata = baseDir\n\tjp.ScrapedFile = filepath.Join(baseDir, \"scraped.json\")\n\tjp.Performers = filepath.Join(baseDir, \"performers\")\n\tjp.Scenes = filepath.Join(baseDir, \"scenes\")\n\tjp.Images = filepath.Join(baseDir, \"images\")\n\tjp.Galleries = filepath.Join(baseDir, \"galleries\")\n\tjp.Studios = filepath.Join(baseDir, \"studios\")\n\tjp.Groups = filepath.Join(baseDir, \"movies\")\n\tjp.Tags = filepath.Join(baseDir, \"tags\")\n\tjp.Files = filepath.Join(baseDir, \"files\")\n\tjp.SavedFilters = filepath.Join(baseDir, \"saved_filters\")\n\treturn &jp\n}\n\nfunc GetJSONPaths(baseDir string) *JSONPaths {\n\tjp := newJSONPaths(baseDir)\n\treturn jp\n}\n\nfunc EmptyJSONDirs(baseDir string) {\n\tjsonPaths := GetJSONPaths(baseDir)\n\t_ = fsutil.EmptyDir(jsonPaths.Scenes)\n\t_ = fsutil.EmptyDir(jsonPaths.Images)\n\t_ = fsutil.EmptyDir(jsonPaths.Galleries)\n\t_ = fsutil.EmptyDir(jsonPaths.Performers)\n\t_ = fsutil.EmptyDir(jsonPaths.Studios)\n\t_ = fsutil.EmptyDir(jsonPaths.Groups)\n\t_ = fsutil.EmptyDir(jsonPaths.Tags)\n\t_ = fsutil.EmptyDir(jsonPaths.Files)\n\t_ = fsutil.EmptyDir(jsonPaths.SavedFilters)\n}\n\nfunc EnsureJSONDirs(baseDir string) {\n\tjsonPaths := GetJSONPaths(baseDir)\n\tif err := fsutil.EnsureDir(jsonPaths.Metadata); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Metadata: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.Scenes); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Scenes: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.Images); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Images: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.Galleries); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Galleries: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.Performers); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Performers: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.Studios); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Studios: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.Groups); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Groups: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.Tags); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Tags: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.Files); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Files: %v\", err)\n\t}\n\tif err := fsutil.EnsureDir(jsonPaths.SavedFilters); err != nil {\n\t\tlogger.Warnf(\"couldn't create directories for Saved Filters: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/models/paths/paths_scene_markers.go",
    "content": "package paths\n\nimport (\n\t\"path/filepath\"\n\t\"strconv\"\n)\n\ntype sceneMarkerPaths struct {\n\tgeneratedPaths\n}\n\nfunc newSceneMarkerPaths(p Paths) *sceneMarkerPaths {\n\tsp := sceneMarkerPaths{\n\t\tgeneratedPaths: *p.Generated,\n\t}\n\treturn &sp\n}\n\nfunc (sp *sceneMarkerPaths) GetFolderPath(checksum string) string {\n\treturn filepath.Join(sp.Markers, checksum)\n}\n\nfunc (sp *sceneMarkerPaths) GetVideoPreviewPath(checksum string, seconds int) string {\n\treturn filepath.Join(sp.GetFolderPath(checksum), strconv.Itoa(seconds)+\".mp4\")\n}\n\nfunc (sp *sceneMarkerPaths) GetWebpPreviewPath(checksum string, seconds int) string {\n\treturn filepath.Join(sp.GetFolderPath(checksum), strconv.Itoa(seconds)+\".webp\")\n}\n\nfunc (sp *sceneMarkerPaths) GetScreenshotPath(checksum string, seconds int) string {\n\treturn filepath.Join(sp.GetFolderPath(checksum), strconv.Itoa(seconds)+\".jpg\")\n}\n"
  },
  {
    "path": "pkg/models/paths/paths_scenes.go",
    "content": "package paths\n\nimport (\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n)\n\ntype scenePaths struct {\n\tgeneratedPaths\n}\n\nfunc newScenePaths(p Paths) *scenePaths {\n\tsp := scenePaths{\n\t\tgeneratedPaths: *p.Generated,\n\t}\n\treturn &sp\n}\n\nfunc (sp *scenePaths) GetLegacyScreenshotPath(checksum string) string {\n\treturn filepath.Join(sp.Screenshots, checksum+\".jpg\")\n}\n\nfunc (sp *scenePaths) GetTranscodePath(checksum string) string {\n\treturn filepath.Join(sp.Transcodes, checksum+\".mp4\")\n}\n\nfunc (sp *scenePaths) GetStreamPath(scenePath string, checksum string) string {\n\ttranscodePath := sp.GetTranscodePath(checksum)\n\ttranscodeExists, _ := fsutil.FileExists(transcodePath)\n\tif transcodeExists {\n\t\treturn transcodePath\n\t}\n\treturn scenePath\n}\n\nfunc (sp *scenePaths) GetVideoPreviewPath(checksum string) string {\n\treturn filepath.Join(sp.Screenshots, checksum+\".mp4\")\n}\n\nfunc (sp *scenePaths) GetWebpPreviewPath(checksum string) string {\n\treturn filepath.Join(sp.Screenshots, checksum+\".webp\")\n}\n\nfunc (sp *scenePaths) GetSpriteImageFilePath(checksum string) string {\n\treturn filepath.Join(sp.Vtt, checksum+\"_sprite.jpg\")\n}\n\nfunc (sp *scenePaths) GetSpriteVttFilePath(checksum string) string {\n\treturn filepath.Join(sp.Vtt, checksum+\"_thumbs.vtt\")\n}\n\nfunc (sp *scenePaths) GetInteractiveHeatmapPath(checksum string) string {\n\treturn filepath.Join(sp.InteractiveHeatmap, checksum+\".png\")\n}\n"
  },
  {
    "path": "pkg/models/performer.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype GenderEnum string\n\nconst (\n\tGenderEnumMale              GenderEnum = \"MALE\"\n\tGenderEnumFemale            GenderEnum = \"FEMALE\"\n\tGenderEnumTransgenderMale   GenderEnum = \"TRANSGENDER_MALE\"\n\tGenderEnumTransgenderFemale GenderEnum = \"TRANSGENDER_FEMALE\"\n\tGenderEnumIntersex          GenderEnum = \"INTERSEX\"\n\tGenderEnumNonBinary         GenderEnum = \"NON_BINARY\"\n)\n\nvar AllGenderEnum = []GenderEnum{\n\tGenderEnumMale,\n\tGenderEnumFemale,\n\tGenderEnumTransgenderMale,\n\tGenderEnumTransgenderFemale,\n\tGenderEnumIntersex,\n\tGenderEnumNonBinary,\n}\n\nfunc (e GenderEnum) IsValid() bool {\n\tswitch e {\n\tcase GenderEnumMale, GenderEnumFemale, GenderEnumTransgenderMale, GenderEnumTransgenderFemale, GenderEnumIntersex, GenderEnumNonBinary:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e GenderEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *GenderEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = GenderEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid GenderEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e GenderEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype GenderCriterionInput struct {\n\tValue     GenderEnum        `json:\"value\"`\n\tValueList []GenderEnum      `json:\"value_list\"`\n\tModifier  CriterionModifier `json:\"modifier\"`\n}\n\ntype CircumcisedEnum string\n\nconst (\n\tCircumcisedEnumCut   CircumcisedEnum = \"CUT\"\n\tCircumcisedEnumUncut CircumcisedEnum = \"UNCUT\"\n)\n\nvar AllCircumcisionEnum = []CircumcisedEnum{\n\tCircumcisedEnumCut,\n\tCircumcisedEnumUncut,\n}\n\nfunc (e CircumcisedEnum) IsValid() bool {\n\tswitch e {\n\tcase CircumcisedEnumCut, CircumcisedEnumUncut:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e CircumcisedEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *CircumcisedEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = CircumcisedEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid CircumcisedEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e CircumcisedEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype CircumcisionCriterionInput struct {\n\tValue    []CircumcisedEnum `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype PerformerFilterType struct {\n\tOperatorFilter[PerformerFilterType]\n\tName           *StringCriterionInput `json:\"name\"`\n\tDisambiguation *StringCriterionInput `json:\"disambiguation\"`\n\tDetails        *StringCriterionInput `json:\"details\"`\n\t// Filter by favorite\n\tFilterFavorites *bool `json:\"filter_favorites\"`\n\t// Filter by birth year\n\tBirthYear *IntCriterionInput `json:\"birth_year\"`\n\t// Filter by age\n\tAge *IntCriterionInput `json:\"age\"`\n\t// Filter by ethnicity\n\tEthnicity *StringCriterionInput `json:\"ethnicity\"`\n\t// Filter by country\n\tCountry *StringCriterionInput `json:\"country\"`\n\t// Filter by eye color\n\tEyeColor *StringCriterionInput `json:\"eye_color\"`\n\t// Filter by height - deprecated: use height_cm instead\n\tHeight *StringCriterionInput `json:\"height\"`\n\t// Filter by height in centimeters\n\tHeightCm *IntCriterionInput `json:\"height_cm\"`\n\t// Filter by measurements\n\tMeasurements *StringCriterionInput `json:\"measurements\"`\n\t// Filter by fake tits value\n\tFakeTits *StringCriterionInput `json:\"fake_tits\"`\n\t// Filter by penis length value\n\tPenisLength *FloatCriterionInput `json:\"penis_length\"`\n\t// Filter by circumcision\n\tCircumcised *CircumcisionCriterionInput `json:\"circumcised\"`\n\t// Filter by career length\n\tCareerLength *StringCriterionInput `json:\"career_length\"` // deprecated\n\t// Filter by career start year\n\tCareerStart *DateCriterionInput `json:\"career_start\"`\n\t// Filter by career end year\n\tCareerEnd *DateCriterionInput `json:\"career_end\"`\n\t// Filter by tattoos\n\tTattoos *StringCriterionInput `json:\"tattoos\"`\n\t// Filter by piercings\n\tPiercings *StringCriterionInput `json:\"piercings\"`\n\t// Filter by aliases\n\tAliases *StringCriterionInput `json:\"aliases\"`\n\t// Filter by gender\n\tGender *GenderCriterionInput `json:\"gender\"`\n\t// Filter to only include performers missing this property\n\tIsMissing *string `json:\"is_missing\"`\n\t// Filter to only include performers with these tags\n\tTags *HierarchicalMultiCriterionInput `json:\"tags\"`\n\t// Filter by tag count\n\tTagCount *IntCriterionInput `json:\"tag_count\"`\n\t// Filter by scene count\n\tSceneCount *IntCriterionInput `json:\"scene_count\"`\n\t// Filter by scene marker count (via scene)\n\tMarkerCount *IntCriterionInput `json:\"marker_count\"`\n\t// Filter by image count\n\tImageCount *IntCriterionInput `json:\"image_count\"`\n\t// Filter by gallery count\n\tGalleryCount *IntCriterionInput `json:\"gallery_count\"`\n\t// Filter by play count\n\tPlayCount *IntCriterionInput `json:\"play_count\"`\n\t// Filter by O count\n\tOCounter *IntCriterionInput `json:\"o_counter\"`\n\t// Filter by StashID\n\tStashID *StringCriterionInput `json:\"stash_id\"`\n\t// Filter by StashID Endpoint\n\tStashIDEndpoint *StashIDCriterionInput `json:\"stash_id_endpoint\"`\n\t// Filter by StashIDs Endpoint\n\tStashIDsEndpoint *StashIDsCriterionInput `json:\"stash_ids_endpoint\"`\n\t// Filter by rating expressed as 1-100\n\tRating100 *IntCriterionInput `json:\"rating100\"`\n\t// Filter by url\n\tURL *StringCriterionInput `json:\"url\"`\n\t// Filter by hair color\n\tHairColor *StringCriterionInput `json:\"hair_color\"`\n\t// Filter by weight\n\tWeight *IntCriterionInput `json:\"weight\"`\n\t// Filter by death year\n\tDeathYear *IntCriterionInput `json:\"death_year\"`\n\t// Filter by studios where performer appears in scene/image/gallery\n\tStudios *HierarchicalMultiCriterionInput `json:\"studios\"`\n\t// Filter by groups where performer appears in scene\n\tGroups *HierarchicalMultiCriterionInput `json:\"groups\"`\n\t// Filter by performers where performer appears with another performer in scene/image/gallery\n\tPerformers *MultiCriterionInput `json:\"performers\"`\n\t// Filter by autotag ignore value\n\tIgnoreAutoTag *bool `json:\"ignore_auto_tag\"`\n\t// Filter by birthdate\n\tBirthdate *DateCriterionInput `json:\"birth_date\"`\n\t// Filter by death date\n\tDeathDate *DateCriterionInput `json:\"death_date\"`\n\t// Filter by related scenes that meet this criteria\n\tScenesFilter *SceneFilterType `json:\"scenes_filter\"`\n\t// Filter by related images that meet this criteria\n\tImagesFilter *ImageFilterType `json:\"images_filter\"`\n\t// Filter by related galleries that meet this criteria\n\tGalleriesFilter *GalleryFilterType `json:\"galleries_filter\"`\n\t// Filter by related tags that meet this criteria\n\tTagsFilter *TagFilterType `json:\"tags_filter\"`\n\t// Filter by related scene markers (via scene) that meet this criteria\n\tMarkersFilter *SceneMarkerFilterType `json:\"markers_filter\"`\n\t// Filter by created at\n\tCreatedAt *TimestampCriterionInput `json:\"created_at\"`\n\t// Filter by updated at\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at\"`\n\n\t// Filter by custom fields\n\tCustomFields []CustomFieldCriterionInput `json:\"custom_fields\"`\n}\n\ntype PerformerCreateInput struct {\n\tName           string           `json:\"name\"`\n\tDisambiguation *string          `json:\"disambiguation\"`\n\tURL            *string          `json:\"url\"` // deprecated\n\tUrls           []string         `json:\"urls\"`\n\tGender         *GenderEnum      `json:\"gender\"`\n\tBirthdate      *string          `json:\"birthdate\"`\n\tEthnicity      *string          `json:\"ethnicity\"`\n\tCountry        *string          `json:\"country\"`\n\tEyeColor       *string          `json:\"eye_color\"`\n\tHeight         *string          `json:\"height\"`\n\tHeightCm       *int             `json:\"height_cm\"`\n\tMeasurements   *string          `json:\"measurements\"`\n\tFakeTits       *string          `json:\"fake_tits\"`\n\tPenisLength    *float64         `json:\"penis_length\"`\n\tCircumcised    *CircumcisedEnum `json:\"circumcised\"`\n\tCareerLength   *string          `json:\"career_length\"`\n\tCareerStart    *string          `json:\"career_start\"`\n\tCareerEnd      *string          `json:\"career_end\"`\n\tTattoos        *string          `json:\"tattoos\"`\n\tPiercings      *string          `json:\"piercings\"`\n\tAliases        *string          `json:\"aliases\"`\n\tAliasList      []string         `json:\"alias_list\"`\n\tTwitter        *string          `json:\"twitter\"`   // deprecated\n\tInstagram      *string          `json:\"instagram\"` // deprecated\n\tFavorite       *bool            `json:\"favorite\"`\n\tTagIds         []string         `json:\"tag_ids\"`\n\t// This should be a URL or a base64 encoded data URL\n\tImage         *string        `json:\"image\"`\n\tStashIds      []StashIDInput `json:\"stash_ids\"`\n\tRating100     *int           `json:\"rating100\"`\n\tDetails       *string        `json:\"details\"`\n\tDeathDate     *string        `json:\"death_date\"`\n\tHairColor     *string        `json:\"hair_color\"`\n\tWeight        *int           `json:\"weight\"`\n\tIgnoreAutoTag *bool          `json:\"ignore_auto_tag\"`\n\n\tCustomFields map[string]interface{} `json:\"custom_fields\"`\n}\n\ntype PerformerUpdateInput struct {\n\tID             string           `json:\"id\"`\n\tName           *string          `json:\"name\"`\n\tDisambiguation *string          `json:\"disambiguation\"`\n\tURL            *string          `json:\"url\"` // deprecated\n\tUrls           []string         `json:\"urls\"`\n\tGender         *GenderEnum      `json:\"gender\"`\n\tBirthdate      *string          `json:\"birthdate\"`\n\tEthnicity      *string          `json:\"ethnicity\"`\n\tCountry        *string          `json:\"country\"`\n\tEyeColor       *string          `json:\"eye_color\"`\n\tHeight         *string          `json:\"height\"`\n\tHeightCm       *int             `json:\"height_cm\"`\n\tMeasurements   *string          `json:\"measurements\"`\n\tFakeTits       *string          `json:\"fake_tits\"`\n\tPenisLength    *float64         `json:\"penis_length\"`\n\tCircumcised    *CircumcisedEnum `json:\"circumcised\"`\n\tCareerLength   *string          `json:\"career_length\"`\n\tCareerStart    *string          `json:\"career_start\"`\n\tCareerEnd      *string          `json:\"career_end\"`\n\tTattoos        *string          `json:\"tattoos\"`\n\tPiercings      *string          `json:\"piercings\"`\n\tAliases        *string          `json:\"aliases\"`\n\tAliasList      []string         `json:\"alias_list\"`\n\tTwitter        *string          `json:\"twitter\"`   // deprecated\n\tInstagram      *string          `json:\"instagram\"` // deprecated\n\tFavorite       *bool            `json:\"favorite\"`\n\tTagIds         []string         `json:\"tag_ids\"`\n\t// This should be a URL or a base64 encoded data URL\n\tImage         *string        `json:\"image\"`\n\tStashIds      []StashIDInput `json:\"stash_ids\"`\n\tRating100     *int           `json:\"rating100\"`\n\tDetails       *string        `json:\"details\"`\n\tDeathDate     *string        `json:\"death_date\"`\n\tHairColor     *string        `json:\"hair_color\"`\n\tWeight        *int           `json:\"weight\"`\n\tIgnoreAutoTag *bool          `json:\"ignore_auto_tag\"`\n\n\tCustomFields CustomFieldsInput `json:\"custom_fields\"`\n}\n"
  },
  {
    "path": "pkg/models/query.go",
    "content": "package models\n\ntype QueryOptions struct {\n\tFindFilter *FindFilterType\n\tCount      bool\n}\n\ntype QueryResult[T comparable] struct {\n\tIDs   []T\n\tCount int\n}\n"
  },
  {
    "path": "pkg/models/rating.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"math\"\n\t\"strconv\"\n)\n\ntype RatingSystem string\n\nconst (\n\tFiveStar             = \"FiveStar\"\n\tFivePointFiveStar    = \"FivePointFiveStar\"\n\tFivePointTwoFiveStar = \"FivePointTwoFiveStar\"\n\t// TenStar              = \"TenStar\"\n\t// TenPointFiveStar     = \"TenPointFiveStar\"\n\t// TenPointTwoFiveStar  = \"TenPointTwoFiveStar\"\n\tTenPointDecimal = \"TenPointDecimal\"\n)\n\nfunc (e RatingSystem) IsValid() bool {\n\tswitch e {\n\t// case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenStar, TenPointFiveStar, TenPointTwoFiveStar, TenPointDecimal:\n\tcase FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenPointDecimal:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e RatingSystem) String() string {\n\treturn string(e)\n}\n\nfunc (e *RatingSystem) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = RatingSystem(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid RatingSystem\", str)\n\t}\n\treturn nil\n}\n\nfunc (e RatingSystem) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nconst (\n\tmaxRating100 = 100\n\tmaxRating5   = 5\n\tminRating5   = 1\n\tminRating100 = 20\n)\n\n// Rating100To5 converts a 1-100 rating to a 1-5 rating.\n// Values <= 30 are converted to 1. Otherwise, rating is divided by 20 and rounded to the nearest integer.\nfunc Rating100To5(rating100 int) int {\n\tval := math.Round((float64(rating100) / 20))\n\treturn int(math.Max(minRating5, math.Min(maxRating5, val)))\n}\n\n// Rating5To100 converts a 1-5 rating to a 1-100 rating\nfunc Rating5To100(rating5 int) int {\n\treturn int(math.Max(minRating100, math.Min(maxRating100, float64(rating5*20))))\n}\n"
  },
  {
    "path": "pkg/models/rating_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n)\n\nfunc TestRating100To5(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\trating100 int\n\t\twant      int\n\t}{\n\t\t{\"20\", 20, 1},\n\t\t{\"100\", 100, 5},\n\t\t{\"1\", 1, 1},\n\t\t{\"10\", 10, 1},\n\t\t{\"11\", 11, 1},\n\t\t{\"21\", 21, 1},\n\t\t{\"31\", 31, 2},\n\t\t{\"0\", 0, 1},\n\t\t{\"-100\", -100, 1},\n\t\t{\"120\", 120, 5},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := Rating100To5(tt.rating100); got != tt.want {\n\t\t\t\tt.Errorf(\"Rating100To5() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRating5To100(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\trating5 int\n\t\twant    int\n\t}{\n\t\t{\"1\", 1, 20},\n\t\t{\"5\", 5, 100},\n\t\t{\"2\", 2, 40},\n\t\t{\"3\", 3, 60},\n\t\t{\"4\", 4, 80},\n\t\t{\"6\", 6, 100},\n\t\t{\"0\", 0, 20},\n\t\t{\"-1\", -1, 20},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := Rating5To100(tt.rating5); got != tt.want {\n\t\t\t\tt.Errorf(\"Rating5To100() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/models/relationships.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\ntype SceneIDLoader interface {\n\tGetSceneIDs(ctx context.Context, relatedID int) ([]int, error)\n}\n\ntype ImageIDLoader interface {\n\tGetImageIDs(ctx context.Context, relatedID int) ([]int, error)\n}\n\ntype GalleryIDLoader interface {\n\tGetGalleryIDs(ctx context.Context, relatedID int) ([]int, error)\n}\n\ntype PerformerIDLoader interface {\n\tGetPerformerIDs(ctx context.Context, relatedID int) ([]int, error)\n}\n\ntype TagIDLoader interface {\n\tGetTagIDs(ctx context.Context, relatedID int) ([]int, error)\n}\n\ntype TagRelationLoader interface {\n\tGetParentIDs(ctx context.Context, relatedID int) ([]int, error)\n\tGetChildIDs(ctx context.Context, relatedID int) ([]int, error)\n}\n\ntype FileIDLoader interface {\n\tGetManyFileIDs(ctx context.Context, ids []int) ([][]FileID, error)\n}\n\ntype SceneGroupLoader interface {\n\tGetGroups(ctx context.Context, id int) ([]GroupsScenes, error)\n}\n\ntype ContainingGroupLoader interface {\n\tGetContainingGroupDescriptions(ctx context.Context, id int) ([]GroupIDDescription, error)\n}\n\ntype SubGroupLoader interface {\n\tGetSubGroupDescriptions(ctx context.Context, id int) ([]GroupIDDescription, error)\n}\n\ntype StashIDLoader interface {\n\tGetStashIDs(ctx context.Context, relatedID int) ([]StashID, error)\n}\n\ntype VideoFileLoader interface {\n\tGetFiles(ctx context.Context, relatedID int) ([]*VideoFile, error)\n}\n\ntype FileLoader interface {\n\tGetFiles(ctx context.Context, relatedID int) ([]File, error)\n}\n\ntype AliasLoader interface {\n\tGetAliases(ctx context.Context, relatedID int) ([]string, error)\n}\n\ntype URLLoader interface {\n\tGetURLs(ctx context.Context, relatedID int) ([]string, error)\n}\n\n// RelatedIDs represents a list of related IDs.\n// TODO - this can be made generic\ntype RelatedIDs struct {\n\tlist []int\n}\n\n// NewRelatedIDs returns a loaded RelatedIDs object with the provided IDs.\n// Loaded will return true when called on the returned object if the provided slice is not nil.\nfunc NewRelatedIDs(ids []int) RelatedIDs {\n\treturn RelatedIDs{\n\t\tlist: ids,\n\t}\n}\n\n// Loaded returns true if the related IDs have been loaded.\nfunc (r RelatedIDs) Loaded() bool {\n\treturn r.list != nil\n}\n\nfunc (r RelatedIDs) mustLoaded() {\n\tif !r.Loaded() {\n\t\tpanic(\"list has not been loaded\")\n\t}\n}\n\n// List returns the related IDs. Panics if the relationship has not been loaded.\nfunc (r RelatedIDs) List() []int {\n\tr.mustLoaded()\n\n\treturn r.list\n}\n\n// Add adds the provided ids to the list. Panics if the relationship has not been loaded.\nfunc (r *RelatedIDs) Add(ids ...int) {\n\tr.mustLoaded()\n\n\tr.list = append(r.list, ids...)\n}\n\nfunc (r *RelatedIDs) load(fn func() ([]int, error)) error {\n\tif r.Loaded() {\n\t\treturn nil\n\t}\n\n\tids, err := fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif ids == nil {\n\t\tids = []int{}\n\t}\n\n\tr.list = ids\n\n\treturn nil\n}\n\n// RelatedGroups represents a list of related Groups.\ntype RelatedGroups struct {\n\tlist []GroupsScenes\n}\n\n// NewRelatedGroups returns a loaded RelateGroups object with the provided groups.\n// Loaded will return true when called on the returned object if the provided slice is not nil.\nfunc NewRelatedGroups(list []GroupsScenes) RelatedGroups {\n\treturn RelatedGroups{\n\t\tlist: list,\n\t}\n}\n\n// Loaded returns true if the relationship has been loaded.\nfunc (r RelatedGroups) Loaded() bool {\n\treturn r.list != nil\n}\n\nfunc (r RelatedGroups) mustLoaded() {\n\tif !r.Loaded() {\n\t\tpanic(\"list has not been loaded\")\n\t}\n}\n\n// List returns the related Groups. Panics if the relationship has not been loaded.\nfunc (r RelatedGroups) List() []GroupsScenes {\n\tr.mustLoaded()\n\n\treturn r.list\n}\n\n// Add adds the provided ids to the list. Panics if the relationship has not been loaded.\nfunc (r *RelatedGroups) Add(groups ...GroupsScenes) {\n\tr.mustLoaded()\n\n\tr.list = append(r.list, groups...)\n}\n\n// ForID returns the GroupsScenes object for the given group ID. Returns nil if not found.\nfunc (r *RelatedGroups) ForID(id int) *GroupsScenes {\n\tr.mustLoaded()\n\n\tfor _, v := range r.list {\n\t\tif v.GroupID == id {\n\t\t\treturn &v\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *RelatedGroups) load(fn func() ([]GroupsScenes, error)) error {\n\tif r.Loaded() {\n\t\treturn nil\n\t}\n\n\tids, err := fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif ids == nil {\n\t\tids = []GroupsScenes{}\n\t}\n\n\tr.list = ids\n\n\treturn nil\n}\n\ntype RelatedGroupDescriptions struct {\n\tlist []GroupIDDescription\n}\n\n// NewRelatedGroups returns a loaded RelateGroups object with the provided groups.\n// Loaded will return true when called on the returned object if the provided slice is not nil.\nfunc NewRelatedGroupDescriptions(list []GroupIDDescription) RelatedGroupDescriptions {\n\treturn RelatedGroupDescriptions{\n\t\tlist: list,\n\t}\n}\n\n// Loaded returns true if the relationship has been loaded.\nfunc (r RelatedGroupDescriptions) Loaded() bool {\n\treturn r.list != nil\n}\n\nfunc (r RelatedGroupDescriptions) mustLoaded() {\n\tif !r.Loaded() {\n\t\tpanic(\"list has not been loaded\")\n\t}\n}\n\n// List returns the related Groups. Panics if the relationship has not been loaded.\nfunc (r RelatedGroupDescriptions) List() []GroupIDDescription {\n\tr.mustLoaded()\n\n\treturn r.list\n}\n\n// List returns the related Groups. Panics if the relationship has not been loaded.\nfunc (r RelatedGroupDescriptions) IDs() []int {\n\tr.mustLoaded()\n\n\treturn sliceutil.Map(r.list, func(d GroupIDDescription) int { return d.GroupID })\n}\n\n// Add adds the provided ids to the list. Panics if the relationship has not been loaded.\nfunc (r *RelatedGroupDescriptions) Add(groups ...GroupIDDescription) {\n\tr.mustLoaded()\n\n\tr.list = append(r.list, groups...)\n}\n\n// ForID returns the GroupsScenes object for the given group ID. Returns nil if not found.\nfunc (r *RelatedGroupDescriptions) ForID(id int) *GroupIDDescription {\n\tr.mustLoaded()\n\n\tfor _, v := range r.list {\n\t\tif v.GroupID == id {\n\t\t\treturn &v\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *RelatedGroupDescriptions) load(fn func() ([]GroupIDDescription, error)) error {\n\tif r.Loaded() {\n\t\treturn nil\n\t}\n\n\tids, err := fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif ids == nil {\n\t\tids = []GroupIDDescription{}\n\t}\n\n\tr.list = ids\n\n\treturn nil\n}\n\ntype RelatedStashIDs struct {\n\tlist []StashID\n}\n\n// NewRelatedStashIDs returns a RelatedStashIDs object with the provided ids.\n// Loaded will return true when called on the returned object if the provided slice is not nil.\nfunc NewRelatedStashIDs(list []StashID) RelatedStashIDs {\n\treturn RelatedStashIDs{\n\t\tlist: list,\n\t}\n}\n\nfunc (r RelatedStashIDs) mustLoaded() {\n\tif !r.Loaded() {\n\t\tpanic(\"list has not been loaded\")\n\t}\n}\n\n// Loaded returns true if the relationship has been loaded.\nfunc (r RelatedStashIDs) Loaded() bool {\n\treturn r.list != nil\n}\n\n// List returns the related Stash IDs. Panics if the relationship has not been loaded.\nfunc (r RelatedStashIDs) List() []StashID {\n\tr.mustLoaded()\n\n\treturn r.list\n}\n\n// ForID returns the StashID object for the given endpoint. Returns nil if not found.\nfunc (r *RelatedStashIDs) ForEndpoint(endpoint string) *StashID {\n\tr.mustLoaded()\n\n\tfor _, v := range r.list {\n\t\tif v.Endpoint == endpoint {\n\t\t\treturn &v\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *RelatedStashIDs) load(fn func() ([]StashID, error)) error {\n\tif r.Loaded() {\n\t\treturn nil\n\t}\n\n\tids, err := fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif ids == nil {\n\t\tids = []StashID{}\n\t}\n\n\tr.list = ids\n\n\treturn nil\n}\n\ntype RelatedVideoFiles struct {\n\tprimaryFile   *VideoFile\n\tfiles         []*VideoFile\n\tprimaryLoaded bool\n}\n\nfunc NewRelatedVideoFiles(files []*VideoFile) RelatedVideoFiles {\n\tret := RelatedVideoFiles{\n\t\tfiles:         files,\n\t\tprimaryLoaded: true,\n\t}\n\n\tif len(files) > 0 {\n\t\tret.primaryFile = files[0]\n\t}\n\n\treturn ret\n}\n\nfunc (r *RelatedVideoFiles) SetPrimary(f *VideoFile) {\n\tr.primaryFile = f\n\tr.primaryLoaded = true\n}\n\nfunc (r *RelatedVideoFiles) Set(f []*VideoFile) {\n\tr.files = f\n\tif len(r.files) > 0 {\n\t\tr.primaryFile = r.files[0]\n\t}\n\n\tr.primaryLoaded = true\n}\n\n// Loaded returns true if the relationship has been loaded.\nfunc (r RelatedVideoFiles) Loaded() bool {\n\treturn r.files != nil\n}\n\n// Loaded returns true if the primary file relationship has been loaded.\nfunc (r RelatedVideoFiles) PrimaryLoaded() bool {\n\treturn r.primaryLoaded\n}\n\n// List returns the related files. Panics if the relationship has not been loaded.\nfunc (r RelatedVideoFiles) List() []*VideoFile {\n\tif !r.Loaded() {\n\t\tpanic(\"relationship has not been loaded\")\n\t}\n\n\treturn r.files\n}\n\n// Primary returns the primary file. Panics if the relationship has not been loaded.\nfunc (r RelatedVideoFiles) Primary() *VideoFile {\n\tif !r.PrimaryLoaded() {\n\t\tpanic(\"relationship has not been loaded\")\n\t}\n\n\treturn r.primaryFile\n}\n\nfunc (r *RelatedVideoFiles) load(fn func() ([]*VideoFile, error)) error {\n\tif r.Loaded() {\n\t\treturn nil\n\t}\n\n\tvar err error\n\tr.files, err = fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(r.files) > 0 {\n\t\tr.primaryFile = r.files[0]\n\t}\n\n\tr.primaryLoaded = true\n\n\treturn nil\n}\n\nfunc (r *RelatedVideoFiles) loadPrimary(fn func() (*VideoFile, error)) error {\n\tif r.PrimaryLoaded() {\n\t\treturn nil\n\t}\n\n\tvar err error\n\tr.primaryFile, err = fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tr.primaryLoaded = true\n\n\treturn nil\n}\n\ntype RelatedFiles struct {\n\tprimaryFile   File\n\tfiles         []File\n\tprimaryLoaded bool\n}\n\nfunc NewRelatedFiles(files []File) RelatedFiles {\n\tret := RelatedFiles{\n\t\tfiles:         files,\n\t\tprimaryLoaded: true,\n\t}\n\n\tif len(files) > 0 {\n\t\tret.primaryFile = files[0]\n\t}\n\n\treturn ret\n}\n\n// Loaded returns true if the relationship has been loaded.\nfunc (r RelatedFiles) Loaded() bool {\n\treturn r.files != nil\n}\n\n// Loaded returns true if the primary file relationship has been loaded.\nfunc (r RelatedFiles) PrimaryLoaded() bool {\n\treturn r.primaryLoaded\n}\n\n// List returns the related files. Panics if the relationship has not been loaded.\nfunc (r RelatedFiles) List() []File {\n\tif !r.Loaded() {\n\t\tpanic(\"relationship has not been loaded\")\n\t}\n\n\treturn r.files\n}\n\n// Primary returns the primary file. Panics if the relationship has not been loaded.\nfunc (r RelatedFiles) Primary() File {\n\tif !r.PrimaryLoaded() {\n\t\tpanic(\"relationship has not been loaded\")\n\t}\n\n\treturn r.primaryFile\n}\n\nfunc (r *RelatedFiles) load(fn func() ([]File, error)) error {\n\tif r.Loaded() {\n\t\treturn nil\n\t}\n\n\tvar err error\n\tr.files, err = fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(r.files) > 0 {\n\t\tr.primaryFile = r.files[0]\n\t}\n\n\tr.primaryLoaded = true\n\n\treturn nil\n}\n\nfunc (r *RelatedFiles) loadPrimary(fn func() (File, error)) error {\n\tif r.PrimaryLoaded() {\n\t\treturn nil\n\t}\n\n\tvar err error\n\tr.primaryFile, err = fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tr.primaryLoaded = true\n\n\treturn nil\n}\n\n// RelatedStrings represents a list of related strings.\n// TODO - this can be made generic\ntype RelatedStrings struct {\n\tlist []string\n}\n\n// NewRelatedStrings returns a loaded RelatedStrings object with the provided values.\n// Loaded will return true when called on the returned object if the provided slice is not nil.\nfunc NewRelatedStrings(values []string) RelatedStrings {\n\treturn RelatedStrings{\n\t\tlist: values,\n\t}\n}\n\n// Loaded returns true if the related IDs have been loaded.\nfunc (r RelatedStrings) Loaded() bool {\n\treturn r.list != nil\n}\n\nfunc (r RelatedStrings) mustLoaded() {\n\tif !r.Loaded() {\n\t\tpanic(\"list has not been loaded\")\n\t}\n}\n\n// List returns the related values. Panics if the relationship has not been loaded.\nfunc (r RelatedStrings) List() []string {\n\tr.mustLoaded()\n\n\treturn r.list\n}\n\n// Add adds the provided values to the list. Panics if the relationship has not been loaded.\nfunc (r *RelatedStrings) Add(values ...string) {\n\tr.mustLoaded()\n\n\tr.list = append(r.list, values...)\n}\n\nfunc (r *RelatedStrings) load(fn func() ([]string, error)) error {\n\tif r.Loaded() {\n\t\treturn nil\n\t}\n\n\tvalues, err := fn()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif values == nil {\n\t\tvalues = []string{}\n\t}\n\n\tr.list = values\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/models/repository.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype TxnManager interface {\n\ttxn.Manager\n\ttxn.DatabaseProvider\n}\n\ntype Repository struct {\n\tTxnManager TxnManager\n\n\tBlob           BlobReader\n\tFile           FileReaderWriter\n\tFolder         FolderReaderWriter\n\tGallery        GalleryReaderWriter\n\tGalleryChapter GalleryChapterReaderWriter\n\tImage          ImageReaderWriter\n\tGroup          GroupReaderWriter\n\tPerformer      PerformerReaderWriter\n\tScene          SceneReaderWriter\n\tSceneMarker    SceneMarkerReaderWriter\n\tStudio         StudioReaderWriter\n\tTag            TagReaderWriter\n\tSavedFilter    SavedFilterReaderWriter\n}\n\nfunc (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error {\n\treturn txn.WithTxn(ctx, r.TxnManager, fn)\n}\n\nfunc (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error {\n\treturn txn.WithReadTxn(ctx, r.TxnManager, fn)\n}\n\nfunc (r *Repository) WithDB(ctx context.Context, fn txn.TxnFunc) error {\n\treturn txn.WithDatabase(ctx, r.TxnManager, fn)\n}\n"
  },
  {
    "path": "pkg/models/repository_blob.go",
    "content": "package models\n\nimport \"context\"\n\n// BlobReader provides methods to get files by ID.\ntype BlobReader interface {\n\tEntryExists(ctx context.Context, checksum string) (bool, error)\n}\n"
  },
  {
    "path": "pkg/models/repository_file.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"io/fs\"\n)\n\n// FileGetter provides methods to get files by ID.\ntype FileGetter interface {\n\tFind(ctx context.Context, id ...FileID) ([]File, error)\n}\n\n// FileFinder provides methods to find files.\ntype FileFinder interface {\n\tFileGetter\n\tFindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error)\n\tFindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]File, error)\n\tFindByPath(ctx context.Context, path string, caseSensitive bool) (File, error)\n\tFindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error)\n\tFindByZipFileID(ctx context.Context, zipFileID FileID) ([]File, error)\n\tFindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]File, error)\n}\n\n// FileQueryer provides methods to query files.\ntype FileQueryer interface {\n\tQuery(ctx context.Context, options FileQueryOptions) (*FileQueryResult, error)\n}\n\n// FileCounter provides methods to count files.\ntype FileCounter interface {\n\tCountAllInPaths(ctx context.Context, p []string) (int, error)\n\tCountByFolderID(ctx context.Context, folderID FolderID) (int, error)\n}\n\n// FileCreator provides methods to create files.\ntype FileCreator interface {\n\tCreate(ctx context.Context, f File) error\n}\n\n// FileUpdater provides methods to update files.\ntype FileUpdater interface {\n\tUpdate(ctx context.Context, f File) error\n}\n\n// FileDestroyer provides methods to destroy files.\ntype FileDestroyer interface {\n\tDestroy(ctx context.Context, id FileID) error\n}\n\ntype FileFinderCreator interface {\n\tFileFinder\n\tFileCreator\n}\n\ntype FileFinderUpdater interface {\n\tFileFinder\n\tFileUpdater\n}\n\ntype FileFinderDestroyer interface {\n\tFileFinder\n\tFileDestroyer\n}\n\n// FileReader provides all methods to read files.\ntype FileReader interface {\n\tFileFinder\n\tFileQueryer\n\tFileCounter\n\n\tGetCaptions(ctx context.Context, fileID FileID) ([]*VideoCaption, error)\n\tIsPrimary(ctx context.Context, fileID FileID) (bool, error)\n}\n\ntype FileFingerprintWriter interface {\n\tModifyFingerprints(ctx context.Context, fileID FileID, fingerprints []Fingerprint) error\n\tDestroyFingerprints(ctx context.Context, fileID FileID, types []string) error\n}\n\n// FileWriter provides all methods to modify files.\ntype FileWriter interface {\n\tFileCreator\n\tFileUpdater\n\tFileDestroyer\n\tFileFingerprintWriter\n\n\tUpdateCaptions(ctx context.Context, fileID FileID, captions []*VideoCaption) error\n}\n\n// FileReaderWriter provides all file methods.\ntype FileReaderWriter interface {\n\tFileReader\n\tFileWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_folder.go",
    "content": "package models\n\nimport \"context\"\n\n// FolderGetter provides methods to get folders by ID.\ntype FolderGetter interface {\n\tFind(ctx context.Context, id FolderID) (*Folder, error)\n\tFindMany(ctx context.Context, id []FolderID) ([]*Folder, error)\n}\n\n// FolderFinder provides methods to find folders.\ntype FolderFinder interface {\n\tFolderGetter\n\tFindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*Folder, error)\n\tFindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error)\n\tFindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error)\n\tFindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)\n\tGetManyParentFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error)\n}\n\ntype FolderQueryer interface {\n\tQuery(ctx context.Context, options FolderQueryOptions) (*FolderQueryResult, error)\n}\n\ntype FolderCounter interface {\n\tCountAllInPaths(ctx context.Context, p []string) (int, error)\n}\n\n// FolderCreator provides methods to create folders.\ntype FolderCreator interface {\n\tCreate(ctx context.Context, f *Folder) error\n}\n\n// FolderUpdater provides methods to update folders.\ntype FolderUpdater interface {\n\tUpdate(ctx context.Context, f *Folder) error\n}\n\ntype FolderDestroyer interface {\n\tDestroy(ctx context.Context, id FolderID) error\n}\n\ntype FolderFinderCreator interface {\n\tFolderFinder\n\tFolderCreator\n}\n\ntype FolderFinderDestroyer interface {\n\tFolderFinder\n\tFolderDestroyer\n}\n\n// FolderReader provides all methods to read folders.\ntype FolderReader interface {\n\tFolderFinder\n\tFolderQueryer\n\tFolderCounter\n}\n\n// FolderWriter provides all methods to modify folders.\ntype FolderWriter interface {\n\tFolderCreator\n\tFolderUpdater\n\tFolderDestroyer\n}\n\n// FolderReaderWriter provides all folder methods.\ntype FolderReaderWriter interface {\n\tFolderReader\n\tFolderWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_gallery.go",
    "content": "package models\n\nimport \"context\"\n\n// GalleryGetter provides methods to get galleries by ID.\ntype GalleryGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*Gallery, error)\n\tFind(ctx context.Context, id int) (*Gallery, error)\n}\n\n// GalleryFinder provides methods to find galleries.\ntype GalleryFinder interface {\n\tGalleryGetter\n\tFindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Gallery, error)\n\tFindByChecksum(ctx context.Context, checksum string) ([]*Gallery, error)\n\tFindByChecksums(ctx context.Context, checksums []string) ([]*Gallery, error)\n\tFindByPath(ctx context.Context, path string) ([]*Gallery, error)\n\tFindByFileID(ctx context.Context, fileID FileID) ([]*Gallery, error)\n\tFindByFolderID(ctx context.Context, folderID FolderID) ([]*Gallery, error)\n\tFindBySceneID(ctx context.Context, sceneID int) ([]*Gallery, error)\n\tFindByImageID(ctx context.Context, imageID int) ([]*Gallery, error)\n\tFindUserGalleryByTitle(ctx context.Context, title string) ([]*Gallery, error)\n}\n\n// GalleryQueryer provides methods to query galleries.\ntype GalleryQueryer interface {\n\tQuery(ctx context.Context, galleryFilter *GalleryFilterType, findFilter *FindFilterType) ([]*Gallery, int, error)\n\tQueryCount(ctx context.Context, galleryFilter *GalleryFilterType, findFilter *FindFilterType) (int, error)\n}\n\n// GalleryCounter provides methods to count galleries.\ntype GalleryCounter interface {\n\tCount(ctx context.Context) (int, error)\n\tCountByFileID(ctx context.Context, fileID FileID) (int, error)\n}\n\n// GalleryCreator provides methods to create galleries.\ntype GalleryCreator interface {\n\tCreate(ctx context.Context, newGallery *CreateGalleryInput) error\n}\n\n// GalleryUpdater provides methods to update galleries.\ntype GalleryUpdater interface {\n\tUpdate(ctx context.Context, updatedGallery *UpdateGalleryInput) error\n\tUpdatePartial(ctx context.Context, id int, updatedGallery GalleryPartial) (*Gallery, error)\n\tUpdateImages(ctx context.Context, galleryID int, imageIDs []int) error\n}\n\n// GalleryDestroyer provides methods to destroy galleries.\ntype GalleryDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype GalleryCreatorUpdater interface {\n\tGalleryCreator\n\tGalleryUpdater\n}\n\n// GalleryReader provides all methods to read galleries.\ntype GalleryReader interface {\n\tGalleryFinder\n\tGalleryQueryer\n\tGalleryCounter\n\n\tURLLoader\n\tFileIDLoader\n\tImageIDLoader\n\tSceneIDLoader\n\tPerformerIDLoader\n\tTagIDLoader\n\tFileLoader\n\tCustomFieldsReader\n\n\tAll(ctx context.Context) ([]*Gallery, error)\n}\n\n// GalleryWriter provides all methods to modify galleries.\ntype GalleryWriter interface {\n\tGalleryCreator\n\tGalleryUpdater\n\tGalleryDestroyer\n\n\tCustomFieldsWriter\n\n\tAddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error\n\tAddFileID(ctx context.Context, id int, fileID FileID) error\n\tAddImages(ctx context.Context, galleryID int, imageIDs ...int) error\n\tRemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error\n\tSetCover(ctx context.Context, galleryID int, coverImageID int) error\n\tResetCover(ctx context.Context, galleryID int) error\n}\n\n// GalleryReaderWriter provides all gallery methods.\ntype GalleryReaderWriter interface {\n\tGalleryReader\n\tGalleryWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_gallery_chapter.go",
    "content": "package models\n\nimport \"context\"\n\n// GalleryChapterGetter provides methods to get gallery chapters by ID.\ntype GalleryChapterGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*GalleryChapter, error)\n\tFind(ctx context.Context, id int) (*GalleryChapter, error)\n}\n\n// GalleryChapterFinder provides methods to find gallery chapters.\ntype GalleryChapterFinder interface {\n\tGalleryChapterGetter\n\tFindByGalleryID(ctx context.Context, galleryID int) ([]*GalleryChapter, error)\n}\n\n// GalleryChapterCreator provides methods to create gallery chapters.\ntype GalleryChapterCreator interface {\n\tCreate(ctx context.Context, newGalleryChapter *GalleryChapter) error\n}\n\n// GalleryChapterUpdater provides methods to update gallery chapters.\ntype GalleryChapterUpdater interface {\n\tUpdate(ctx context.Context, updatedGalleryChapter *GalleryChapter) error\n\tUpdatePartial(ctx context.Context, id int, updatedGalleryChapter GalleryChapterPartial) (*GalleryChapter, error)\n}\n\n// GalleryChapterDestroyer provides methods to destroy gallery chapters.\ntype GalleryChapterDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype GalleryChapterCreatorUpdater interface {\n\tGalleryChapterCreator\n\tGalleryChapterUpdater\n}\n\n// GalleryChapterReader provides all methods to read gallery chapters.\ntype GalleryChapterReader interface {\n\tGalleryChapterFinder\n}\n\n// GalleryChapterWriter provides all methods to modify gallery chapters.\ntype GalleryChapterWriter interface {\n\tGalleryChapterCreator\n\tGalleryChapterUpdater\n\tGalleryChapterDestroyer\n}\n\n// GalleryChapterReaderWriter provides all gallery chapter methods.\ntype GalleryChapterReaderWriter interface {\n\tGalleryChapterReader\n\tGalleryChapterWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_group.go",
    "content": "package models\n\nimport \"context\"\n\n// GroupGetter provides methods to get groups by ID.\ntype GroupGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*Group, error)\n\tFind(ctx context.Context, id int) (*Group, error)\n}\n\n// GroupFinder provides methods to find groups.\ntype GroupFinder interface {\n\tGroupGetter\n\tFindByPerformerID(ctx context.Context, performerID int) ([]*Group, error)\n\tFindByStudioID(ctx context.Context, studioID int) ([]*Group, error)\n\tFindByName(ctx context.Context, name string, nocase bool) (*Group, error)\n\tFindByNames(ctx context.Context, names []string, nocase bool) ([]*Group, error)\n}\n\n// GroupQueryer provides methods to query groups.\ntype GroupQueryer interface {\n\tQuery(ctx context.Context, groupFilter *GroupFilterType, findFilter *FindFilterType) ([]*Group, int, error)\n\tQueryCount(ctx context.Context, groupFilter *GroupFilterType, findFilter *FindFilterType) (int, error)\n}\n\n// GroupCounter provides methods to count groups.\ntype GroupCounter interface {\n\tCount(ctx context.Context) (int, error)\n\tCountByPerformerID(ctx context.Context, performerID int) (int, error)\n\tCountByStudioID(ctx context.Context, studioID int) (int, error)\n}\n\n// GroupCreator provides methods to create groups.\ntype GroupCreator interface {\n\tCreate(ctx context.Context, newGroup *Group) error\n}\n\n// GroupUpdater provides methods to update groups.\ntype GroupUpdater interface {\n\tUpdate(ctx context.Context, updatedGroup *Group) error\n\tUpdatePartial(ctx context.Context, id int, updatedGroup GroupPartial) (*Group, error)\n\tUpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error\n\tUpdateBackImage(ctx context.Context, groupID int, backImage []byte) error\n}\n\n// GroupDestroyer provides methods to destroy groups.\ntype GroupDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype GroupCreatorUpdater interface {\n\tGroupCreator\n\tGroupUpdater\n}\n\ntype GroupFinderCreator interface {\n\tGroupFinder\n\tGroupCreator\n}\n\n// GroupReader provides all methods to read groups.\ntype GroupReader interface {\n\tGroupFinder\n\tGroupQueryer\n\tGroupCounter\n\tURLLoader\n\tTagIDLoader\n\tContainingGroupLoader\n\tSubGroupLoader\n\tCustomFieldsReader\n\n\tAll(ctx context.Context) ([]*Group, error)\n\tGetFrontImage(ctx context.Context, groupID int) ([]byte, error)\n\tHasFrontImage(ctx context.Context, groupID int) (bool, error)\n\tGetBackImage(ctx context.Context, groupID int) ([]byte, error)\n\tHasBackImage(ctx context.Context, groupID int) (bool, error)\n}\n\n// GroupWriter provides all methods to modify groups.\ntype GroupWriter interface {\n\tGroupCreator\n\tGroupUpdater\n\tGroupDestroyer\n\tCustomFieldsWriter\n}\n\n// GroupReaderWriter provides all group methods.\ntype GroupReaderWriter interface {\n\tGroupReader\n\tGroupWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_image.go",
    "content": "package models\n\nimport \"context\"\n\n// ImageGetter provides methods to get images by ID.\ntype ImageGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*Image, error)\n\tFind(ctx context.Context, id int) (*Image, error)\n}\n\n// ImageFinder provides methods to find images.\ntype ImageFinder interface {\n\tImageGetter\n\tFindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Image, error)\n\tFindByChecksum(ctx context.Context, checksum string) ([]*Image, error)\n\tFindByFileID(ctx context.Context, fileID FileID) ([]*Image, error)\n\tFindByFolderID(ctx context.Context, fileID FolderID) ([]*Image, error)\n\tFindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Image, error)\n\tFindByGalleryID(ctx context.Context, galleryID int) ([]*Image, error)\n\tFindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*Image, error)\n}\n\n// ImageQueryer provides methods to query images.\ntype ImageQueryer interface {\n\tQuery(ctx context.Context, options ImageQueryOptions) (*ImageQueryResult, error)\n\tQueryCount(ctx context.Context, imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error)\n}\n\ntype GalleryCoverFinder interface {\n\tCoverByGalleryID(ctx context.Context, galleryId int) (*Image, error)\n}\n\n// ImageCounter provides methods to count images.\ntype ImageCounter interface {\n\tCount(ctx context.Context) (int, error)\n\tCountByFileID(ctx context.Context, fileID FileID) (int, error)\n\tCountByGalleryID(ctx context.Context, galleryID int) (int, error)\n\tOCount(ctx context.Context) (int, error)\n\tOCountByPerformerID(ctx context.Context, performerID int) (int, error)\n\tOCountByStudioID(ctx context.Context, studioID int) (int, error)\n}\n\n// ImageCreator provides methods to create images.\ntype ImageCreator interface {\n\tCreate(ctx context.Context, newImage *CreateImageInput) error\n}\n\n// ImageUpdater provides methods to update images.\ntype ImageUpdater interface {\n\tUpdate(ctx context.Context, updatedImage *Image) error\n\tUpdatePartial(ctx context.Context, id int, partial ImagePartial) (*Image, error)\n\tUpdatePerformers(ctx context.Context, imageID int, performerIDs []int) error\n\tUpdateTags(ctx context.Context, imageID int, tagIDs []int) error\n}\n\n// ImageDestroyer provides methods to destroy images.\ntype ImageDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype ImageCreatorUpdater interface {\n\tImageCreator\n\tImageUpdater\n}\n\n// ImageReader provides all methods to read images.\ntype ImageReader interface {\n\tImageFinder\n\tImageQueryer\n\tImageCounter\n\n\tURLLoader\n\tFileIDLoader\n\tGalleryIDLoader\n\tPerformerIDLoader\n\tTagIDLoader\n\tFileLoader\n\n\tGalleryCoverFinder\n\tCustomFieldsReader\n\n\tAll(ctx context.Context) ([]*Image, error)\n\tSize(ctx context.Context) (float64, error)\n}\n\n// ImageWriter provides all methods to modify images.\ntype ImageWriter interface {\n\tImageCreator\n\tImageUpdater\n\tImageDestroyer\n\tCustomFieldsWriter\n\n\tAddFileID(ctx context.Context, id int, fileID FileID) error\n\tRemoveFileID(ctx context.Context, id int, fileID FileID) error\n\tIncrementOCounter(ctx context.Context, id int) (int, error)\n\tDecrementOCounter(ctx context.Context, id int) (int, error)\n\tResetOCounter(ctx context.Context, id int) (int, error)\n}\n\n// ImageReaderWriter provides all image methods.\ntype ImageReaderWriter interface {\n\tImageReader\n\tImageWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_performer.go",
    "content": "package models\n\nimport \"context\"\n\n// PerformerGetter provides methods to get performers by ID.\ntype PerformerGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*Performer, error)\n\tFind(ctx context.Context, id int) (*Performer, error)\n}\n\n// PerformerFinder provides methods to find performers.\ntype PerformerFinder interface {\n\tPerformerGetter\n\tFindBySceneID(ctx context.Context, sceneID int) ([]*Performer, error)\n\tFindByImageID(ctx context.Context, imageID int) ([]*Performer, error)\n\tFindByGalleryID(ctx context.Context, galleryID int) ([]*Performer, error)\n\tFindByStashID(ctx context.Context, stashID StashID) ([]*Performer, error)\n\tFindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Performer, error)\n\tFindByNames(ctx context.Context, names []string, nocase bool) ([]*Performer, error)\n}\n\n// PerformerQueryer provides methods to query performers.\ntype PerformerQueryer interface {\n\tQuery(ctx context.Context, performerFilter *PerformerFilterType, findFilter *FindFilterType) ([]*Performer, int, error)\n\tQueryCount(ctx context.Context, performerFilter *PerformerFilterType, findFilter *FindFilterType) (int, error)\n}\n\ntype PerformerAutoTagQueryer interface {\n\tPerformerQueryer\n\tAliasLoader\n\n\t// TODO - this interface is temporary until the filter schema can fully\n\t// support the query needed\n\tQueryForAutoTag(ctx context.Context, words []string) ([]*Performer, error)\n}\n\n// PerformerCounter provides methods to count performers.\ntype PerformerCounter interface {\n\tCount(ctx context.Context) (int, error)\n\tCountByTagID(ctx context.Context, tagID int) (int, error)\n}\n\n// PerformerCreator provides methods to create performers.\ntype PerformerCreator interface {\n\tCreate(ctx context.Context, newPerformer *CreatePerformerInput) error\n}\n\n// PerformerUpdater provides methods to update performers.\ntype PerformerUpdater interface {\n\tUpdate(ctx context.Context, updatedPerformer *UpdatePerformerInput) error\n\tUpdatePartial(ctx context.Context, id int, updatedPerformer PerformerPartial) (*Performer, error)\n\tUpdateImage(ctx context.Context, performerID int, image []byte) error\n}\n\n// PerformerDestroyer provides methods to destroy performers.\ntype PerformerDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype PerformerFinderCreator interface {\n\tPerformerFinder\n\tPerformerCreator\n}\n\ntype PerformerCreatorUpdater interface {\n\tPerformerCreator\n\tPerformerUpdater\n}\n\n// PerformerReader provides all methods to read performers.\ntype PerformerReader interface {\n\tPerformerFinder\n\tPerformerQueryer\n\tPerformerAutoTagQueryer\n\tPerformerCounter\n\n\tAliasLoader\n\tStashIDLoader\n\tTagIDLoader\n\tURLLoader\n\n\tCustomFieldsReader\n\n\tAll(ctx context.Context) ([]*Performer, error)\n\tGetImage(ctx context.Context, performerID int) ([]byte, error)\n\tHasImage(ctx context.Context, performerID int) (bool, error)\n}\n\n// PerformerWriter provides all methods to modify performers.\ntype PerformerWriter interface {\n\tPerformerCreator\n\tPerformerUpdater\n\tPerformerDestroyer\n\n\tMerge(ctx context.Context, source []int, destination int) error\n}\n\n// PerformerReaderWriter provides all performer methods.\ntype PerformerReaderWriter interface {\n\tPerformerReader\n\tPerformerWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_scene.go",
    "content": "package models\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\n// SceneGetter provides methods to get scenes by ID.\ntype SceneGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*Scene, error)\n\tFind(ctx context.Context, id int) (*Scene, error)\n\t// FindByIDs works the same way as FindMany, but it ignores any scenes not found\n\t// Scenes are not guaranteed to be in the same order as the input\n\tFindByIDs(ctx context.Context, ids []int) ([]*Scene, error)\n}\n\n// SceneFinder provides methods to find scenes.\ntype SceneFinder interface {\n\tSceneGetter\n\tFindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Scene, error)\n\tFindByChecksum(ctx context.Context, checksum string) ([]*Scene, error)\n\tFindByOSHash(ctx context.Context, oshash string) ([]*Scene, error)\n\tFindByPath(ctx context.Context, path string) ([]*Scene, error)\n\tFindByFileID(ctx context.Context, fileID FileID) ([]*Scene, error)\n\tFindByPrimaryFileID(ctx context.Context, fileID FileID) ([]*Scene, error)\n\tFindByPerformerID(ctx context.Context, performerID int) ([]*Scene, error)\n\tFindByGalleryID(ctx context.Context, performerID int) ([]*Scene, error)\n\tFindByGroupID(ctx context.Context, groupID int) ([]*Scene, error)\n\tFindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Scene, error)\n}\n\n// SceneQueryer provides methods to query scenes.\ntype SceneQueryer interface {\n\tQuery(ctx context.Context, options SceneQueryOptions) (*SceneQueryResult, error)\n\tQueryCount(ctx context.Context, sceneFilter *SceneFilterType, findFilter *FindFilterType) (int, error)\n}\n\n// SceneCounter provides methods to count scenes.\ntype SceneCounter interface {\n\tCount(ctx context.Context) (int, error)\n\tCountByPerformerID(ctx context.Context, performerID int) (int, error)\n\tCountByFileID(ctx context.Context, fileID FileID) (int, error)\n\tCountMissingChecksum(ctx context.Context) (int, error)\n\tCountMissingOSHash(ctx context.Context) (int, error)\n\tOCountByPerformerID(ctx context.Context, performerID int) (int, error)\n\tOCountByGroupID(ctx context.Context, groupID int) (int, error)\n\tOCountByStudioID(ctx context.Context, studioID int) (int, error)\n}\n\n// SceneCreator provides methods to create scenes.\ntype SceneCreator interface {\n\tCreate(ctx context.Context, newScene *Scene, fileIDs []FileID) error\n}\n\n// SceneUpdater provides methods to update scenes.\ntype SceneUpdater interface {\n\tUpdate(ctx context.Context, updatedScene *Scene) error\n\tUpdatePartial(ctx context.Context, id int, updatedScene ScenePartial) (*Scene, error)\n\tUpdateCover(ctx context.Context, sceneID int, cover []byte) error\n}\n\n// SceneDestroyer provides methods to destroy scenes.\ntype SceneDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype SceneCreatorUpdater interface {\n\tSceneCreator\n\tSceneUpdater\n}\n\ntype ViewDateReader interface {\n\tCountViews(ctx context.Context, id int) (int, error)\n\tCountAllViews(ctx context.Context) (int, error)\n\tCountUniqueViews(ctx context.Context) (int, error)\n\tGetManyViewCount(ctx context.Context, ids []int) ([]int, error)\n\tGetViewDates(ctx context.Context, relatedID int) ([]time.Time, error)\n\tGetManyViewDates(ctx context.Context, ids []int) ([][]time.Time, error)\n\tGetManyLastViewed(ctx context.Context, ids []int) ([]*time.Time, error)\n}\n\ntype ODateReader interface {\n\tGetOCount(ctx context.Context, id int) (int, error)\n\tGetManyOCount(ctx context.Context, ids []int) ([]int, error)\n\tGetAllOCount(ctx context.Context) (int, error)\n\tGetODates(ctx context.Context, relatedID int) ([]time.Time, error)\n\tGetManyODates(ctx context.Context, ids []int) ([][]time.Time, error)\n}\n\n// SceneReader provides all methods to read scenes.\ntype SceneReader interface {\n\tSceneFinder\n\tSceneQueryer\n\tSceneCounter\n\n\tURLLoader\n\tViewDateReader\n\tODateReader\n\tFileIDLoader\n\tGalleryIDLoader\n\tPerformerIDLoader\n\tTagIDLoader\n\tSceneGroupLoader\n\tStashIDLoader\n\tVideoFileLoader\n\tCustomFieldsReader\n\n\tAll(ctx context.Context) ([]*Scene, error)\n\tWall(ctx context.Context, q *string) ([]*Scene, error)\n\tSize(ctx context.Context) (float64, error)\n\tDuration(ctx context.Context) (float64, error)\n\tPlayDuration(ctx context.Context) (float64, error)\n\tGetCover(ctx context.Context, sceneID int) ([]byte, error)\n\tHasCover(ctx context.Context, sceneID int) (bool, error)\n}\n\ntype OHistoryWriter interface {\n\tAddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error)\n\tDeleteO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error)\n\tResetO(ctx context.Context, id int) (int, error)\n}\n\ntype ViewHistoryWriter interface {\n\tAddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error)\n\tDeleteViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error)\n\tDeleteAllViews(ctx context.Context, id int) (int, error)\n}\n\n// SceneWriter provides all methods to modify scenes.\ntype SceneWriter interface {\n\tSceneCreator\n\tSceneUpdater\n\tSceneDestroyer\n\n\tAddFileID(ctx context.Context, id int, fileID FileID) error\n\tAddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error\n\tAssignFiles(ctx context.Context, sceneID int, fileID []FileID) error\n\n\tOHistoryWriter\n\tViewHistoryWriter\n\tSaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error)\n\tResetActivity(ctx context.Context, sceneID int, resetResume bool, resetDuration bool) (bool, error)\n\tCustomFieldsWriter\n}\n\n// SceneReaderWriter provides all scene methods.\ntype SceneReaderWriter interface {\n\tSceneReader\n\tSceneWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_scene_marker.go",
    "content": "package models\n\nimport \"context\"\n\n// SceneMarkerGetter provides methods to get scene markers by ID.\ntype SceneMarkerGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*SceneMarker, error)\n\tFind(ctx context.Context, id int) (*SceneMarker, error)\n}\n\n// SceneMarkerFinder provides methods to find scene markers.\ntype SceneMarkerFinder interface {\n\tSceneMarkerGetter\n\tFindBySceneID(ctx context.Context, sceneID int) ([]*SceneMarker, error)\n}\n\n// SceneMarkerQueryer provides methods to query scene markers.\ntype SceneMarkerQueryer interface {\n\tQuery(ctx context.Context, sceneMarkerFilter *SceneMarkerFilterType, findFilter *FindFilterType) ([]*SceneMarker, int, error)\n\tQueryCount(ctx context.Context, sceneMarkerFilter *SceneMarkerFilterType, findFilter *FindFilterType) (int, error)\n}\n\n// SceneMarkerCounter provides methods to count scene markers.\ntype SceneMarkerCounter interface {\n\tCount(ctx context.Context) (int, error)\n\tCountByTagID(ctx context.Context, tagID int) (int, error)\n}\n\n// SceneMarkerCreator provides methods to create scene markers.\ntype SceneMarkerCreator interface {\n\tCreate(ctx context.Context, newSceneMarker *SceneMarker) error\n}\n\n// SceneMarkerUpdater provides methods to update scene markers.\ntype SceneMarkerUpdater interface {\n\tUpdate(ctx context.Context, updatedSceneMarker *SceneMarker) error\n\tUpdatePartial(ctx context.Context, id int, updatedSceneMarker SceneMarkerPartial) (*SceneMarker, error)\n\tUpdateTags(ctx context.Context, markerID int, tagIDs []int) error\n}\n\n// SceneMarkerDestroyer provides methods to destroy scene markers.\ntype SceneMarkerDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype SceneMarkerCreatorUpdater interface {\n\tSceneMarkerCreator\n\tSceneMarkerUpdater\n}\n\n// SceneMarkerReader provides all methods to read scene markers.\ntype SceneMarkerReader interface {\n\tSceneMarkerFinder\n\tSceneMarkerQueryer\n\tSceneMarkerCounter\n\n\tTagIDLoader\n\n\tAll(ctx context.Context) ([]*SceneMarker, error)\n\tWall(ctx context.Context, q *string) ([]*SceneMarker, error)\n\tGetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*MarkerStringsResultType, error)\n}\n\n// SceneMarkerWriter provides all methods to modify scene markers.\ntype SceneMarkerWriter interface {\n\tSceneMarkerCreator\n\tSceneMarkerUpdater\n\tSceneMarkerDestroyer\n}\n\n// SceneMarkerReaderWriter provides all scene marker methods.\ntype SceneMarkerReaderWriter interface {\n\tSceneMarkerReader\n\tSceneMarkerWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_studio.go",
    "content": "package models\n\nimport \"context\"\n\n// StudioGetter provides methods to get studios by ID.\ntype StudioGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*Studio, error)\n\tFind(ctx context.Context, id int) (*Studio, error)\n}\n\n// StudioFinder provides methods to find studios.\ntype StudioFinder interface {\n\tStudioGetter\n\tFindChildren(ctx context.Context, id int) ([]*Studio, error)\n\tFindBySceneID(ctx context.Context, sceneID int) (*Studio, error)\n\tFindByStashID(ctx context.Context, stashID StashID) ([]*Studio, error)\n\tFindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Studio, error)\n\tFindByName(ctx context.Context, name string, nocase bool) (*Studio, error)\n}\n\n// StudioQueryer provides methods to query studios.\ntype StudioQueryer interface {\n\tQuery(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error)\n\tQueryCount(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) (int, error)\n}\n\ntype StudioAutoTagQueryer interface {\n\tStudioQueryer\n\tAliasLoader\n\n\t// TODO - this interface is temporary until the filter schema can fully\n\t// support the query needed\n\tQueryForAutoTag(ctx context.Context, words []string) ([]*Studio, error)\n}\n\n// StudioCounter provides methods to count studios.\ntype StudioCounter interface {\n\tCount(ctx context.Context) (int, error)\n\tCountByTagID(ctx context.Context, tagID int) (int, error)\n}\n\n// StudioCreator provides methods to create studios.\ntype StudioCreator interface {\n\tCreate(ctx context.Context, newStudio *CreateStudioInput) error\n}\n\n// StudioUpdater provides methods to update studios.\ntype StudioUpdater interface {\n\tUpdate(ctx context.Context, updatedStudio *UpdateStudioInput) error\n\tUpdatePartial(ctx context.Context, updatedStudio StudioPartial) (*Studio, error)\n\tUpdateImage(ctx context.Context, studioID int, image []byte) error\n}\n\n// StudioDestroyer provides methods to destroy studios.\ntype StudioDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype StudioFinderCreator interface {\n\tStudioFinder\n\tStudioCreator\n}\n\ntype StudioCreatorUpdater interface {\n\tStudioCreator\n\tStudioUpdater\n}\n\n// StudioReader provides all methods to read studios.\ntype StudioReader interface {\n\tStudioFinder\n\tStudioQueryer\n\tStudioAutoTagQueryer\n\tStudioCounter\n\n\tAliasLoader\n\tStashIDLoader\n\tTagIDLoader\n\tURLLoader\n\n\tCustomFieldsReader\n\n\tAll(ctx context.Context) ([]*Studio, error)\n\tGetImage(ctx context.Context, studioID int) ([]byte, error)\n\tHasImage(ctx context.Context, studioID int) (bool, error)\n}\n\n// StudioWriter provides all methods to modify studios.\ntype StudioWriter interface {\n\tStudioCreator\n\tStudioUpdater\n\tStudioDestroyer\n}\n\n// StudioReaderWriter provides all studio methods.\ntype StudioReaderWriter interface {\n\tStudioReader\n\tStudioWriter\n}\n"
  },
  {
    "path": "pkg/models/repository_tag.go",
    "content": "package models\n\nimport \"context\"\n\n// TagGetter provides methods to get tags by ID.\ntype TagGetter interface {\n\t// TODO - rename this to Find and remove existing method\n\tFindMany(ctx context.Context, ids []int) ([]*Tag, error)\n\tFind(ctx context.Context, id int) (*Tag, error)\n}\n\n// TagFinder provides methods to find tags.\ntype TagFinder interface {\n\tTagGetter\n\tFindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)\n\tFindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)\n\tFindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error)\n\tFindByChildTagID(ctx context.Context, childID int) ([]*Tag, error)\n\tFindBySceneID(ctx context.Context, sceneID int) ([]*Tag, error)\n\tFindByImageID(ctx context.Context, imageID int) ([]*Tag, error)\n\tFindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error)\n\tFindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error)\n\tFindByGroupID(ctx context.Context, groupID int) ([]*Tag, error)\n\tFindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error)\n\tFindByStudioID(ctx context.Context, studioID int) ([]*Tag, error)\n\tFindByName(ctx context.Context, name string, nocase bool) (*Tag, error)\n\tFindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)\n\tFindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error)\n\tFindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error)\n}\n\n// TagQueryer provides methods to query tags.\ntype TagQueryer interface {\n\tQuery(ctx context.Context, tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error)\n}\n\ntype TagAutoTagQueryer interface {\n\tTagQueryer\n\tAliasLoader\n\n\t// TODO - this interface is temporary until the filter schema can fully\n\t// support the query needed\n\tQueryForAutoTag(ctx context.Context, words []string) ([]*Tag, error)\n}\n\n// TagCounter provides methods to count tags.\ntype TagCounter interface {\n\tCount(ctx context.Context) (int, error)\n\tCountByParentTagID(ctx context.Context, parentID int) (int, error)\n\tCountByChildTagID(ctx context.Context, childID int) (int, error)\n}\n\n// TagCreator provides methods to create tags.\ntype TagCreator interface {\n\tCreate(ctx context.Context, newTag *CreateTagInput) error\n}\n\n// TagUpdater provides methods to update tags.\ntype TagUpdater interface {\n\tUpdate(ctx context.Context, updatedTag *UpdateTagInput) error\n\tUpdatePartial(ctx context.Context, id int, updateTag TagPartial) (*Tag, error)\n\tUpdateAliases(ctx context.Context, tagID int, aliases []string) error\n\tUpdateImage(ctx context.Context, tagID int, image []byte) error\n\tUpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error\n\tUpdateChildTags(ctx context.Context, tagID int, parentIDs []int) error\n}\n\n// TagDestroyer provides methods to destroy tags.\ntype TagDestroyer interface {\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype TagFinderCreator interface {\n\tTagFinder\n\tTagCreator\n}\n\ntype TagCreatorUpdater interface {\n\tTagCreator\n\tTagUpdater\n\tCustomFieldsWriter\n}\n\n// TagReader provides all methods to read tags.\ntype TagReader interface {\n\tTagFinder\n\tTagQueryer\n\tTagAutoTagQueryer\n\tTagCounter\n\n\tAliasLoader\n\tTagRelationLoader\n\tStashIDLoader\n\tCustomFieldsReader\n\n\tAll(ctx context.Context) ([]*Tag, error)\n\tGetImage(ctx context.Context, tagID int) ([]byte, error)\n\tHasImage(ctx context.Context, tagID int) (bool, error)\n}\n\n// TagWriter provides all methods to modify tags.\ntype TagWriter interface {\n\tTagCreator\n\tTagUpdater\n\tTagDestroyer\n\tCustomFieldsWriter\n\n\tMerge(ctx context.Context, source []int, destination int) error\n}\n\n// TagReaderWriter provides all tags methods.\ntype TagReaderWriter interface {\n\tTagReader\n\tTagWriter\n}\n"
  },
  {
    "path": "pkg/models/resolution.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype ResolutionRange struct {\n\tmin, max int\n}\n\nvar resolutionRanges = map[ResolutionEnum]ResolutionRange{\n\tResolutionEnum(\"VERY_LOW\"):    {144, 239},\n\tResolutionEnum(\"LOW\"):         {240, 359},\n\tResolutionEnum(\"R360P\"):       {360, 479},\n\tResolutionEnum(\"STANDARD\"):    {480, 539},\n\tResolutionEnum(\"WEB_HD\"):      {540, 719},\n\tResolutionEnum(\"STANDARD_HD\"): {720, 1079},\n\tResolutionEnum(\"FULL_HD\"):     {1080, 1439},\n\tResolutionEnum(\"QUAD_HD\"):     {1440, 1919},\n\tResolutionEnum(\"VR_HD\"):       {1920, 2159},\n\tResolutionEnum(\"FOUR_K\"):      {1920, 2559},\n\tResolutionEnum(\"FIVE_K\"):      {2560, 2999},\n\tResolutionEnum(\"SIX_K\"):       {3000, 3583},\n\tResolutionEnum(\"SEVEN_K\"):     {3584, 3839},\n\tResolutionEnum(\"EIGHT_K\"):     {3840, 6143},\n\tResolutionEnum(\"HUGE\"):        {6144, 9999},\n}\n\ntype ResolutionEnum string\n\nconst (\n\t// 144p\n\tResolutionEnumVeryLow ResolutionEnum = \"VERY_LOW\"\n\t// 240p\n\tResolutionEnumLow ResolutionEnum = \"LOW\"\n\t// 360p\n\tResolutionEnumR360p ResolutionEnum = \"R360P\"\n\t// 480p\n\tResolutionEnumStandard ResolutionEnum = \"STANDARD\"\n\t// 540p\n\tResolutionEnumWebHd ResolutionEnum = \"WEB_HD\"\n\t// 720p\n\tResolutionEnumStandardHd ResolutionEnum = \"STANDARD_HD\"\n\t// 1080p\n\tResolutionEnumFullHd ResolutionEnum = \"FULL_HD\"\n\t// 1440p\n\tResolutionEnumQuadHd ResolutionEnum = \"QUAD_HD\"\n\t// 1920p - deprecated\n\tResolutionEnumVrHd ResolutionEnum = \"VR_HD\"\n\t// 4k\n\tResolutionEnumFourK ResolutionEnum = \"FOUR_K\"\n\t// 5k\n\tResolutionEnumFiveK ResolutionEnum = \"FIVE_K\"\n\t// 6k\n\tResolutionEnumSixK ResolutionEnum = \"SIX_K\"\n\t// 7k\n\tResolutionEnumSevenK ResolutionEnum = \"SEVEN_K\"\n\t// 8k\n\tResolutionEnumEightK ResolutionEnum = \"EIGHT_K\"\n\t// 8K+\n\tResolutionEnumHuge ResolutionEnum = \"HUGE\"\n)\n\nvar AllResolutionEnum = []ResolutionEnum{\n\tResolutionEnumVeryLow,\n\tResolutionEnumLow,\n\tResolutionEnumR360p,\n\tResolutionEnumStandard,\n\tResolutionEnumWebHd,\n\tResolutionEnumStandardHd,\n\tResolutionEnumFullHd,\n\tResolutionEnumQuadHd,\n\tResolutionEnumVrHd,\n\tResolutionEnumFourK,\n\tResolutionEnumFiveK,\n\tResolutionEnumSixK,\n\tResolutionEnumSevenK,\n\tResolutionEnumEightK,\n\tResolutionEnumHuge,\n}\n\nfunc (e ResolutionEnum) IsValid() bool {\n\tswitch e {\n\tcase ResolutionEnumVeryLow, ResolutionEnumLow, ResolutionEnumR360p, ResolutionEnumStandard, ResolutionEnumWebHd, ResolutionEnumStandardHd, ResolutionEnumFullHd, ResolutionEnumQuadHd, ResolutionEnumVrHd, ResolutionEnumFourK, ResolutionEnumFiveK, ResolutionEnumSixK, ResolutionEnumSevenK, ResolutionEnumEightK, ResolutionEnumHuge:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e ResolutionEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *ResolutionEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = ResolutionEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid ResolutionEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e ResolutionEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\n// GetMaxResolution returns the maximum width or height that media must be\n// to qualify as this resolution.\nfunc (e *ResolutionEnum) GetMaxResolution() int {\n\treturn resolutionRanges[*e].max\n}\n\n// GetMinResolution returns the minimum width or height that media must be\n// to qualify as this resolution.\nfunc (e *ResolutionEnum) GetMinResolution() int {\n\treturn resolutionRanges[*e].min\n}\n\ntype StreamingResolutionEnum string\n\nconst (\n\t// 240p\n\tStreamingResolutionEnumLow StreamingResolutionEnum = \"LOW\"\n\t// 480p\n\tStreamingResolutionEnumStandard StreamingResolutionEnum = \"STANDARD\"\n\t// 720p\n\tStreamingResolutionEnumStandardHd StreamingResolutionEnum = \"STANDARD_HD\"\n\t// 1080p\n\tStreamingResolutionEnumFullHd StreamingResolutionEnum = \"FULL_HD\"\n\t// 4k\n\tStreamingResolutionEnumFourK StreamingResolutionEnum = \"FOUR_K\"\n\t// Original\n\tStreamingResolutionEnumOriginal StreamingResolutionEnum = \"ORIGINAL\"\n)\n\nvar AllStreamingResolutionEnum = []StreamingResolutionEnum{\n\tStreamingResolutionEnumLow,\n\tStreamingResolutionEnumStandard,\n\tStreamingResolutionEnumStandardHd,\n\tStreamingResolutionEnumFullHd,\n\tStreamingResolutionEnumFourK,\n\tStreamingResolutionEnumOriginal,\n}\n\nfunc (e StreamingResolutionEnum) IsValid() bool {\n\tswitch e {\n\tcase StreamingResolutionEnumLow, StreamingResolutionEnumStandard, StreamingResolutionEnumStandardHd, StreamingResolutionEnumFullHd, StreamingResolutionEnumFourK, StreamingResolutionEnumOriginal:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e StreamingResolutionEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *StreamingResolutionEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = StreamingResolutionEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid StreamingResolutionEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e StreamingResolutionEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nvar streamingResolutionMax = map[StreamingResolutionEnum]int{\n\tStreamingResolutionEnumLow:        resolutionRanges[ResolutionEnumLow].min,\n\tStreamingResolutionEnumStandard:   resolutionRanges[ResolutionEnumStandard].min,\n\tStreamingResolutionEnumStandardHd: resolutionRanges[ResolutionEnumStandardHd].min,\n\tStreamingResolutionEnumFullHd:     resolutionRanges[ResolutionEnumFullHd].min,\n\tStreamingResolutionEnumFourK:      resolutionRanges[ResolutionEnumFourK].min,\n\tStreamingResolutionEnumOriginal:   0,\n}\n\nfunc (e StreamingResolutionEnum) GetMaxResolution() int {\n\treturn streamingResolutionMax[e]\n}\n"
  },
  {
    "path": "pkg/models/saved_filter.go",
    "content": "package models\n\nimport \"context\"\n\ntype SavedFilterReader interface {\n\tAll(ctx context.Context) ([]*SavedFilter, error)\n\tFind(ctx context.Context, id int) (*SavedFilter, error)\n\tFindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*SavedFilter, error)\n\tFindByMode(ctx context.Context, mode FilterMode) ([]*SavedFilter, error)\n}\n\ntype SavedFilterWriter interface {\n\tCreate(ctx context.Context, obj *SavedFilter) error\n\tUpdate(ctx context.Context, obj *SavedFilter) error\n\tDestroy(ctx context.Context, id int) error\n}\n\ntype SavedFilterReaderWriter interface {\n\tSavedFilterReader\n\tSavedFilterWriter\n}\n"
  },
  {
    "path": "pkg/models/scene.go",
    "content": "package models\n\nimport \"context\"\n\ntype DuplicationCriterionInput struct {\n\t// Deprecated: Use Phash field instead. Kept for backwards compatibility.\n\tDuplicated *bool `json:\"duplicated\"`\n\t// Currently unimplemented. Intended for phash distance matching.\n\tDistance *int `json:\"distance\"`\n\t// Filter by phash duplication\n\tPhash *bool `json:\"phash\"`\n\t// Filter by URL duplication\n\tURL *bool `json:\"url\"`\n\t// Filter by Stash ID duplication\n\tStashID *bool `json:\"stash_id\"`\n\t// Filter by title duplication\n\tTitle *bool `json:\"title\"`\n}\n\ntype FileDuplicationCriterionInput struct {\n\t// Deprecated: Use Phash field instead. Kept for backwards compatibility.\n\tDuplicated *bool `json:\"duplicated\"`\n\t// Currently unimplemented. Intended for phash distance matching.\n\tDistance *int `json:\"distance\"`\n\t// Filter by phash duplication\n\tPhash *bool `json:\"phash\"`\n}\n\ntype SceneFilterType struct {\n\tOperatorFilter[SceneFilterType]\n\tID       *IntCriterionInput    `json:\"id\"`\n\tTitle    *StringCriterionInput `json:\"title\"`\n\tCode     *StringCriterionInput `json:\"code\"`\n\tDetails  *StringCriterionInput `json:\"details\"`\n\tDirector *StringCriterionInput `json:\"director\"`\n\t// Filter by file oshash\n\tOshash *StringCriterionInput `json:\"oshash\"`\n\t// Filter by file checksum\n\tChecksum *StringCriterionInput `json:\"checksum\"`\n\t// Filter by file phash\n\tPhash *StringCriterionInput `json:\"phash\"`\n\t// Filter by phash distance\n\tPhashDistance *PhashDistanceCriterionInput `json:\"phash_distance\"`\n\t// Filter by path\n\tPath *StringCriterionInput `json:\"path\"`\n\t// Filter by file count\n\tFileCount *IntCriterionInput `json:\"file_count\"`\n\t// Filter by rating expressed as 1-100\n\tRating100 *IntCriterionInput `json:\"rating100\"`\n\t// Filter by organized\n\tOrganized *bool `json:\"organized\"`\n\t// Filter by o-counter\n\tOCounter *IntCriterionInput `json:\"o_counter\"`\n\t// Filter Scenes by duplication criteria\n\tDuplicated *DuplicationCriterionInput `json:\"duplicated\"`\n\t// Filter by resolution\n\tResolution *ResolutionCriterionInput `json:\"resolution\"`\n\t// Filter by orientation\n\tOrientation *OrientationCriterionInput `json:\"orientation\"`\n\t// Filter by framerate\n\tFramerate *IntCriterionInput `json:\"framerate\"`\n\t// Filter by bitrate\n\tBitrate *IntCriterionInput `json:\"bitrate\"`\n\t// Filter by video codec\n\tVideoCodec *StringCriterionInput `json:\"video_codec\"`\n\t// Filter by audio codec\n\tAudioCodec *StringCriterionInput `json:\"audio_codec\"`\n\t// Filter by duration (in seconds)\n\tDuration *IntCriterionInput `json:\"duration\"`\n\t// Filter to only include scenes which have markers. `true` or `false`\n\tHasMarkers *string `json:\"has_markers\"`\n\t// Filter to only include scenes missing this property\n\tIsMissing *string `json:\"is_missing\"`\n\t// Filter to only include scenes with this studio\n\tStudios *HierarchicalMultiCriterionInput `json:\"studios\"`\n\t// Filter to only include scenes with this group\n\tGroups *HierarchicalMultiCriterionInput `json:\"groups\"`\n\t// Filter to only include scenes with this movie\n\tMovies *MultiCriterionInput `json:\"movies\"`\n\t// Filter to only include scenes with this gallery\n\tGalleries *MultiCriterionInput `json:\"galleries\"`\n\t// Filter to only include scenes with these tags\n\tTags *HierarchicalMultiCriterionInput `json:\"tags\"`\n\t// Filter by tag count\n\tTagCount *IntCriterionInput `json:\"tag_count\"`\n\t// Filter to only include scenes with performers with these tags\n\tPerformerTags *HierarchicalMultiCriterionInput `json:\"performer_tags\"`\n\t// Filter scenes that have performers that have been favorited\n\tPerformerFavorite *bool `json:\"performer_favorite\"`\n\t// Filter scenes by performer age at time of scene\n\tPerformerAge *IntCriterionInput `json:\"performer_age\"`\n\t// Filter to only include scenes with these performers\n\tPerformers *MultiCriterionInput `json:\"performers\"`\n\t// Filter by performer count\n\tPerformerCount *IntCriterionInput `json:\"performer_count\"`\n\t// Filter by StashID\n\tStashID *StringCriterionInput `json:\"stash_id\"`\n\t// Filter by StashID Endpoint\n\tStashIDEndpoint *StashIDCriterionInput `json:\"stash_id_endpoint\"`\n\t// Filter by StashIDs Endpoint\n\tStashIDsEndpoint *StashIDsCriterionInput `json:\"stash_ids_endpoint\"`\n\t// Filter by StashID count\n\tStashIDCount *IntCriterionInput `json:\"stash_id_count\"`\n\t// Filter by url\n\tURL *StringCriterionInput `json:\"url\"`\n\t// Filter by interactive\n\tInteractive *bool `json:\"interactive\"`\n\t// Filter by InteractiveSpeed\n\tInteractiveSpeed *IntCriterionInput `json:\"interactive_speed\"`\n\t// Filter by captions\n\tCaptions *StringCriterionInput `json:\"captions\"`\n\t// Filter by resume time\n\tResumeTime *IntCriterionInput `json:\"resume_time\"`\n\t// Filter by play count\n\tPlayCount *IntCriterionInput `json:\"play_count\"`\n\t// Filter by play duration (in seconds)\n\tPlayDuration *IntCriterionInput `json:\"play_duration\"`\n\t// Filter by last played at\n\tLastPlayedAt *TimestampCriterionInput `json:\"last_played_at\"`\n\t// Filter by date\n\tDate *DateCriterionInput `json:\"date\"`\n\t// Filter by related galleries that meet this criteria\n\tGalleriesFilter *GalleryFilterType `json:\"galleries_filter\"`\n\t// Filter by related performers that meet this criteria\n\tPerformersFilter *PerformerFilterType `json:\"performers_filter\"`\n\t// Filter by related studios that meet this criteria\n\tStudiosFilter *StudioFilterType `json:\"studios_filter\"`\n\t// Filter by related tags that meet this criteria\n\tTagsFilter *TagFilterType `json:\"tags_filter\"`\n\t// Filter by related groups that meet this criteria\n\tGroupsFilter *GroupFilterType `json:\"groups_filter\"`\n\t// Filter by related movies that meet this criteria\n\tMoviesFilter *GroupFilterType `json:\"movies_filter\"`\n\t// Filter by related markers that meet this criteria\n\tMarkersFilter *SceneMarkerFilterType `json:\"markers_filter\"`\n\t// Filter by related files that meet this criteria\n\tFilesFilter *FileFilterType `json:\"files_filter\"`\n\t// Filter by created at\n\tCreatedAt *TimestampCriterionInput `json:\"created_at\"`\n\t// Filter by updated at\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at\"`\n\n\t// Filter by custom fields\n\tCustomFields []CustomFieldCriterionInput `json:\"custom_fields\"`\n}\n\ntype SceneQueryOptions struct {\n\tQueryOptions\n\tSceneFilter *SceneFilterType\n\n\tTotalDuration bool\n\tTotalSize     bool\n}\n\ntype SceneQueryResult struct {\n\tQueryResult[int]\n\tTotalDuration float64\n\tTotalSize     float64\n\n\tgetter     SceneGetter\n\tscenes     []*Scene\n\tresolveErr error\n}\n\n// SceneMovieInput is used for groups and movies\ntype SceneMovieInput struct {\n\tMovieID    string `json:\"movie_id\"`\n\tSceneIndex *int   `json:\"scene_index\"`\n}\n\ntype SceneGroupInput struct {\n\tGroupID    string `json:\"group_id\"`\n\tSceneIndex *int   `json:\"scene_index\"`\n}\n\ntype SceneCreateInput struct {\n\tTitle        *string           `json:\"title\"`\n\tCode         *string           `json:\"code\"`\n\tDetails      *string           `json:\"details\"`\n\tDirector     *string           `json:\"director\"`\n\tURL          *string           `json:\"url\"`\n\tUrls         []string          `json:\"urls\"`\n\tDate         *string           `json:\"date\"`\n\tRating100    *int              `json:\"rating100\"`\n\tOrganized    *bool             `json:\"organized\"`\n\tStudioID     *string           `json:\"studio_id\"`\n\tGalleryIds   []string          `json:\"gallery_ids\"`\n\tPerformerIds []string          `json:\"performer_ids\"`\n\tMovies       []SceneMovieInput `json:\"movies\"`\n\tGroups       []SceneGroupInput `json:\"groups\"`\n\tTagIds       []string          `json:\"tag_ids\"`\n\t// This should be a URL or a base64 encoded data URL\n\tCoverImage *string        `json:\"cover_image\"`\n\tStashIds   []StashIDInput `json:\"stash_ids\"`\n\t// The first id will be assigned as primary.\n\t// Files will be reassigned from existing scenes if applicable.\n\t// Files must not already be primary for another scene.\n\tFileIds      []string       `json:\"file_ids\"`\n\tCustomFields map[string]any `json:\"custom_fields,omitempty\"`\n}\n\ntype SceneUpdateInput struct {\n\tClientMutationID *string           `json:\"clientMutationId\"`\n\tID               string            `json:\"id\"`\n\tTitle            *string           `json:\"title\"`\n\tCode             *string           `json:\"code\"`\n\tDetails          *string           `json:\"details\"`\n\tDirector         *string           `json:\"director\"`\n\tURL              *string           `json:\"url\"`\n\tUrls             []string          `json:\"urls\"`\n\tDate             *string           `json:\"date\"`\n\tRating100        *int              `json:\"rating100\"`\n\tOCounter         *int              `json:\"o_counter\"`\n\tOrganized        *bool             `json:\"organized\"`\n\tStudioID         *string           `json:\"studio_id\"`\n\tGalleryIds       []string          `json:\"gallery_ids\"`\n\tPerformerIds     []string          `json:\"performer_ids\"`\n\tMovies           []SceneMovieInput `json:\"movies\"`\n\tGroups           []SceneGroupInput `json:\"groups\"`\n\tTagIds           []string          `json:\"tag_ids\"`\n\t// This should be a URL or a base64 encoded data URL\n\tCoverImage    *string        `json:\"cover_image\"`\n\tStashIds      []StashIDInput `json:\"stash_ids\"`\n\tResumeTime    *float64       `json:\"resume_time\"`\n\tPlayDuration  *float64       `json:\"play_duration\"`\n\tPlayCount     *int           `json:\"play_count\"`\n\tPrimaryFileID *string        `json:\"primary_file_id\"`\n\tCustomFields  *CustomFieldsInput\n}\n\ntype SceneDestroyInput struct {\n\tID               string `json:\"id\"`\n\tDeleteFile       *bool  `json:\"delete_file\"`\n\tDeleteGenerated  *bool  `json:\"delete_generated\"`\n\tDestroyFileEntry *bool  `json:\"destroy_file_entry\"`\n}\n\ntype ScenesDestroyInput struct {\n\tIds              []string `json:\"ids\"`\n\tDeleteFile       *bool    `json:\"delete_file\"`\n\tDeleteGenerated  *bool    `json:\"delete_generated\"`\n\tDestroyFileEntry *bool    `json:\"destroy_file_entry\"`\n}\n\nfunc NewSceneQueryResult(getter SceneGetter) *SceneQueryResult {\n\treturn &SceneQueryResult{\n\t\tgetter: getter,\n\t}\n}\n\nfunc (r *SceneQueryResult) Resolve(ctx context.Context) ([]*Scene, error) {\n\t// cache results\n\tif r.scenes == nil && r.resolveErr == nil {\n\t\tr.scenes, r.resolveErr = r.getter.FindMany(ctx, r.IDs)\n\t}\n\treturn r.scenes, r.resolveErr\n}\n"
  },
  {
    "path": "pkg/models/scene_marker.go",
    "content": "package models\n\ntype SceneMarkerFilterType struct {\n\t// Filter to only include scene markers with this tag\n\tTagID *string `json:\"tag_id\"`\n\t// Filter to only include scene markers with these tags\n\tTags *HierarchicalMultiCriterionInput `json:\"tags\"`\n\t// Filter to only include scene markers attached to a scene with these tags\n\tSceneTags *HierarchicalMultiCriterionInput `json:\"scene_tags\"`\n\t// Filter to only include scene markers with these performers\n\tPerformers *MultiCriterionInput `json:\"performers\"`\n\t// Filter to only include scene markers from these scenes\n\tScenes *MultiCriterionInput `json:\"scenes\"`\n\t// Filter by duration (in seconds)\n\tDuration *FloatCriterionInput `json:\"duration\"`\n\t// Filter by created at\n\tCreatedAt *TimestampCriterionInput `json:\"created_at\"`\n\t// Filter by updated at\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at\"`\n\t// Filter by scenes date\n\tSceneDate *DateCriterionInput `json:\"scene_date\"`\n\t// Filter by scenes created at\n\tSceneCreatedAt *TimestampCriterionInput `json:\"scene_created_at\"`\n\t// Filter by scenes updated at\n\tSceneUpdatedAt *TimestampCriterionInput `json:\"scene_updated_at\"`\n\t// Filter by related scenes that meet this criteria\n\tSceneFilter *SceneFilterType `json:\"scene_filter\"`\n}\n\ntype MarkerStringsResultType struct {\n\tCount int    `json:\"count\"`\n\tID    string `json:\"id\"`\n\tTitle string `json:\"title\"`\n}\n"
  },
  {
    "path": "pkg/models/search.go",
    "content": "package models\n\nimport \"strings\"\n\nconst (\n\tor         = \"OR\"\n\torSymbol   = \"|\"\n\tnotPrefix  = '-'\n\tphraseChar = '\"'\n)\n\n// SearchSpecs provides the specifications for text-based searches.\ntype SearchSpecs struct {\n\t// MustHave specifies all of the terms that must appear in the results.\n\tMustHave []string\n\n\t// AnySets specifies sets of terms where one of each set must appear in the results.\n\tAnySets [][]string\n\n\t// MustNot specifies all terms that must not appear in the results.\n\tMustNot []string\n}\n\n// combinePhrases detects quote characters at the start and end of\n// words and combines the contents into a single word.\nfunc combinePhrases(words []string) []string {\n\tvar ret []string\n\tstartIndex := -1\n\tfor i, w := range words {\n\t\tif startIndex == -1 {\n\t\t\t// looking for start of phrase\n\t\t\t// this could either be \" or -\"\n\t\t\tww := w\n\t\t\tif len(w) > 0 && w[0] == notPrefix {\n\t\t\t\tww = w[1:]\n\t\t\t}\n\t\t\tif len(ww) > 0 && ww[0] == phraseChar && (len(ww) < 2 || ww[len(ww)-1] != phraseChar) {\n\t\t\t\tstartIndex = i\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tret = append(ret, w)\n\t\t} else if len(w) > 0 && w[len(w)-1] == phraseChar { // looking for end of phrase\n\t\t\t// combine words\n\t\t\tphrase := strings.Join(words[startIndex:i+1], \" \")\n\n\t\t\t// add to return value\n\t\t\tret = append(ret, phrase)\n\t\t\tstartIndex = -1\n\t\t}\n\t}\n\n\tif startIndex != -1 {\n\t\tret = append(ret, words[startIndex:]...)\n\t}\n\n\treturn ret\n}\n\nfunc extractOrConditions(words []string, searchSpec *SearchSpecs) []string {\n\tfor foundOr := true; foundOr; {\n\t\tfoundOr = false\n\t\tfor i, w := range words {\n\t\t\tif i > 0 && i < len(words)-1 && (strings.EqualFold(w, or) || w == orSymbol) {\n\t\t\t\t// found an OR keyword\n\t\t\t\t// first operand will be the last word\n\t\t\t\tstartIndex := i - 1\n\n\t\t\t\t// find the last operand\n\t\t\t\t// this will be the last word not preceded by OR\n\t\t\t\tlastIndex := len(words) - 1\n\t\t\t\tfor ii := i + 2; ii < len(words); ii += 2 {\n\t\t\t\t\tif !strings.EqualFold(words[ii], or) {\n\t\t\t\t\t\tlastIndex = ii - 1\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfoundOr = true\n\n\t\t\t\t// combine the words into an any set\n\t\t\t\tvar set []string\n\t\t\t\tfor ii := startIndex; ii <= lastIndex; ii += 2 {\n\t\t\t\t\tword := extractPhrase(words[ii])\n\t\t\t\t\tif word == \"\" {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tset = append(set, word)\n\t\t\t\t}\n\n\t\t\t\tsearchSpec.AnySets = append(searchSpec.AnySets, set)\n\n\t\t\t\t// take out the OR'd words\n\t\t\t\twords = append(words[0:startIndex], words[lastIndex+1:]...)\n\n\t\t\t\t// break and reparse\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\treturn words\n}\n\nfunc extractNotConditions(words []string, searchSpec *SearchSpecs) []string {\n\tvar ret []string\n\n\tfor _, w := range words {\n\t\tif len(w) > 1 && w[0] == notPrefix {\n\t\t\tword := extractPhrase(w[1:])\n\t\t\tif word == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsearchSpec.MustNot = append(searchSpec.MustNot, word)\n\t\t} else {\n\t\t\tret = append(ret, w)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc extractPhrase(w string) string {\n\tif len(w) > 1 && w[0] == phraseChar && w[len(w)-1] == phraseChar {\n\t\treturn w[1 : len(w)-1]\n\t}\n\n\treturn w\n}\n\n// ParseSearchString parses the Q value and returns a SearchSpecs object.\n//\n// By default, any words in the search value must appear in the results.\n// Words encompassed by quotes (\") as treated as a single term.\n// Where keyword \"OR\" (case-insensitive) appears (and is not part of a quoted phrase), one of the\n// OR'd terms must appear in the results.\n// Where a keyword is prefixed with \"-\", that keyword must not appear in the results.\n// Where OR appears as the first or last term, or where one of the OR operands has a\n// not prefix, then the OR is treated literally.\nfunc ParseSearchString(s string) SearchSpecs {\n\ts = strings.TrimSpace(s)\n\n\tif s == \"\" {\n\t\treturn SearchSpecs{}\n\t}\n\n\t// break into words\n\twords := strings.Split(s, \" \")\n\n\t// combine phrases first, then extract OR conditions, then extract NOT conditions\n\t// and the leftovers will be AND'd\n\tret := SearchSpecs{}\n\twords = combinePhrases(words)\n\twords = extractOrConditions(words, &ret)\n\twords = extractNotConditions(words, &ret)\n\n\tfor _, w := range words {\n\t\t// ignore empty quotes\n\t\tword := extractPhrase(w)\n\t\tif word == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tret.MustHave = append(ret.MustHave, word)\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/models/search_test.go",
    "content": "package models\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestParseSearchString(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tq    string\n\t\twant SearchSpecs\n\t}{\n\t\t{\n\t\t\t\"basic\",\n\t\t\t\"a b c\",\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\", \"b\", \"c\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"empty\",\n\t\t\t\"\",\n\t\t\tSearchSpecs{},\n\t\t},\n\t\t{\n\t\t\t\"whitespace\",\n\t\t\t\" \",\n\t\t\tSearchSpecs{},\n\t\t},\n\t\t{\n\t\t\t\"single\",\n\t\t\t\"a\",\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"quoted\",\n\t\t\t`\"a b\" c`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a b\", \"c\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"quoted double space\",\n\t\t\t`\"a  b\" c`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a  b\", \"c\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"quoted end space\",\n\t\t\t`\"a  b \" c`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a  b \", \"c\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"no matching end quote\",\n\t\t\t`\"a b c`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{`\"a`, \"b\", \"c\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"no matching start quote\",\n\t\t\t`a b c\"`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\", \"b\", `c\"`},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"or\",\n\t\t\t\"a OR b\",\n\t\t\tSearchSpecs{\n\t\t\t\tAnySets: [][]string{\n\t\t\t\t\t{\"a\", \"b\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"multi or\",\n\t\t\t\"a OR b c OR d\",\n\t\t\tSearchSpecs{\n\t\t\t\tAnySets: [][]string{\n\t\t\t\t\t{\"a\", \"b\"},\n\t\t\t\t\t{\"c\", \"d\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"lowercase or\",\n\t\t\t\"a or b\",\n\t\t\tSearchSpecs{\n\t\t\t\tAnySets: [][]string{\n\t\t\t\t\t{\"a\", \"b\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"or symbol\",\n\t\t\t\"a | b\",\n\t\t\tSearchSpecs{\n\t\t\t\tAnySets: [][]string{\n\t\t\t\t\t{\"a\", \"b\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"quoted or\",\n\t\t\t`a \"OR\" b`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\", \"OR\", \"b\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"quoted or symbol\",\n\t\t\t`a \"|\" b`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\", \"|\", \"b\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"or phrases\",\n\t\t\t`\"a b\" OR \"c d\"`,\n\t\t\tSearchSpecs{\n\t\t\t\tAnySets: [][]string{\n\t\t\t\t\t{\"a b\", \"c d\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"or at start\",\n\t\t\t\"OR a\",\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"OR\", \"a\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"or at end\",\n\t\t\t\"a OR\",\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\", \"OR\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"or symbol at start\",\n\t\t\t\"| a\",\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"|\", \"a\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"or symbol at end\",\n\t\t\t\"a |\",\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\", \"|\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"nots\",\n\t\t\t\"-a -b\",\n\t\t\tSearchSpecs{\n\t\t\t\tMustNot: []string{\"a\", \"b\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"not or\",\n\t\t\t\"-a OR b\",\n\t\t\tSearchSpecs{\n\t\t\t\tAnySets: [][]string{\n\t\t\t\t\t{\"-a\", \"b\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"not phrase\",\n\t\t\t`-\"a b\"`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustNot: []string{\"a b\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"not in phrase\",\n\t\t\t`\"-a b\"`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"-a b\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"double not\",\n\t\t\t\"--a\",\n\t\t\tSearchSpecs{\n\t\t\t\tMustNot: []string{\"-a\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"empty quote\",\n\t\t\t`\"\" a`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"not empty quote\",\n\t\t\t`-\"\" a`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{\"a\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"quote in word\",\n\t\t\t`ab\"cd\"`,\n\t\t\tSearchSpecs{\n\t\t\t\tMustHave: []string{`ab\"cd\"`},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := ParseSearchString(tt.q); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"FindFilterType.ParseSearchString() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/models/stash_box.go",
    "content": "package models\n\ntype StashBoxFingerprint struct {\n\tAlgorithm string `json:\"algorithm\"`\n\tHash      string `json:\"hash\"`\n\tDuration  int    `json:\"duration\"`\n}\n\ntype StashBox struct {\n\tEndpoint             string `json:\"endpoint\"`\n\tAPIKey               string `json:\"api_key\"`\n\tName                 string `json:\"name\"`\n\tMaxRequestsPerMinute int    `json:\"max_requests_per_minute\" koanf:\"max_requests_per_minute\"`\n}\n"
  },
  {
    "path": "pkg/models/stash_ids.go",
    "content": "package models\n\nimport (\n\t\"slices\"\n\t\"time\"\n)\n\ntype StashID struct {\n\tStashID   string    `db:\"stash_id\" json:\"stash_id\"`\n\tEndpoint  string    `db:\"endpoint\" json:\"endpoint\"`\n\tUpdatedAt time.Time `db:\"updated_at\" json:\"updated_at\"`\n}\n\nfunc (s StashID) ToStashIDInput() StashIDInput {\n\tt := s.UpdatedAt\n\treturn StashIDInput{\n\t\tStashID:   s.StashID,\n\t\tEndpoint:  s.Endpoint,\n\t\tUpdatedAt: &t,\n\t}\n}\n\ntype StashIDs []StashID\n\nfunc (s StashIDs) ToStashIDInputs() StashIDInputs {\n\tif s == nil {\n\t\treturn nil\n\t}\n\n\tret := make(StashIDInputs, len(s))\n\tfor i, v := range s {\n\t\tret[i] = v.ToStashIDInput()\n\t}\n\treturn ret\n}\n\n// HasSameStashIDs returns true if the two lists of StashIDs are the same, ignoring order and updated at time.\nfunc (s StashIDs) HasSameStashIDs(other StashIDs) bool {\n\tif len(s) != len(other) {\n\t\treturn false\n\t}\n\n\tfor _, v := range s {\n\t\tif !slices.ContainsFunc(other, func(o StashID) bool {\n\t\t\treturn o.StashID == v.StashID && o.Endpoint == v.Endpoint\n\t\t}) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\ntype StashIDInput struct {\n\tStashID   string     `db:\"stash_id\" json:\"stash_id\"`\n\tEndpoint  string     `db:\"endpoint\" json:\"endpoint\"`\n\tUpdatedAt *time.Time `db:\"updated_at\" json:\"updated_at\"`\n}\n\nfunc (s StashIDInput) ToStashID() StashID {\n\tret := StashID{\n\t\tStashID:  s.StashID,\n\t\tEndpoint: s.Endpoint,\n\t}\n\tif s.UpdatedAt != nil {\n\t\tret.UpdatedAt = *s.UpdatedAt\n\t} else {\n\t\t// default to now if not provided\n\t\tret.UpdatedAt = time.Now()\n\t}\n\n\treturn ret\n}\n\ntype StashIDInputs []StashIDInput\n\nfunc (s StashIDInputs) ToStashIDs() StashIDs {\n\tif s == nil {\n\t\treturn nil\n\t}\n\n\t// #2800 - deduplicate StashIDs based on endpoint and stash_id\n\tret := make(StashIDs, 0, len(s))\n\tseen := make(map[string]map[string]bool)\n\n\tfor _, v := range s {\n\t\tstashID := v.ToStashID()\n\n\t\tif seen[stashID.Endpoint] == nil {\n\t\t\tseen[stashID.Endpoint] = make(map[string]bool)\n\t\t}\n\n\t\tif !seen[stashID.Endpoint][stashID.StashID] {\n\t\t\tseen[stashID.Endpoint][stashID.StashID] = true\n\t\t\tret = append(ret, stashID)\n\t\t}\n\t}\n\n\treturn ret\n}\n\ntype UpdateStashIDs struct {\n\tStashIDs []StashID              `json:\"stash_ids\"`\n\tMode     RelationshipUpdateMode `json:\"mode\"`\n}\n\n// AddUnique adds the stash id to the list, only if the endpoint/stashid pair does not already exist in the list.\nfunc (u *UpdateStashIDs) AddUnique(v StashID) {\n\tfor _, vv := range u.StashIDs {\n\t\tif vv.StashID == v.StashID && vv.Endpoint == v.Endpoint {\n\t\t\treturn\n\t\t}\n\t}\n\n\tu.StashIDs = append(u.StashIDs, v)\n}\n\n// Set sets or replaces the stash id for the endpoint in the provided value.\nfunc (u *UpdateStashIDs) Set(v StashID) {\n\tfor i, vv := range u.StashIDs {\n\t\tif vv.Endpoint == v.Endpoint {\n\t\t\tu.StashIDs[i] = v\n\t\t\treturn\n\t\t}\n\t}\n\n\tu.StashIDs = append(u.StashIDs, v)\n}\n\ntype StashIDCriterionInput struct {\n\t// If present, this value is treated as a predicate.\n\t// That is, it will filter based on stash_id with the matching endpoint\n\tEndpoint *string           `json:\"endpoint\"`\n\tStashID  *string           `json:\"stash_id\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype StashIDsCriterionInput struct {\n\t// If present, this value is treated as a predicate.\n\t// That is, it will filter based on stash_ids with the matching endpoint\n\tEndpoint *string           `json:\"endpoint\"`\n\tStashIDs []*string         `json:\"stash_ids\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n"
  },
  {
    "path": "pkg/models/studio.go",
    "content": "package models\n\ntype StudioFilterType struct {\n\tOperatorFilter[StudioFilterType]\n\tName    *StringCriterionInput `json:\"name\"`\n\tDetails *StringCriterionInput `json:\"details\"`\n\t// Filter to only include studios with this parent studio\n\tParents *MultiCriterionInput `json:\"parents\"`\n\t// Filter by StashID\n\tStashID *StringCriterionInput `json:\"stash_id\"`\n\t// Filter by StashID Endpoint\n\tStashIDEndpoint *StashIDCriterionInput `json:\"stash_id_endpoint\"`\n\t// Filter by StashIDs Endpoint\n\tStashIDsEndpoint *StashIDsCriterionInput `json:\"stash_ids_endpoint\"`\n\t// Filter to only include studios missing this property\n\tIsMissing *string `json:\"is_missing\"`\n\t// Filter by rating expressed as 1-100\n\tRating100 *IntCriterionInput `json:\"rating100\"`\n\t// Filter to only include studios with these tags\n\tTags *HierarchicalMultiCriterionInput `json:\"tags\"`\n\t// Filter by tag count\n\tTagCount *IntCriterionInput `json:\"tag_count\"`\n\t// Filter by favorite\n\tFavorite *bool `json:\"favorite\"`\n\t// Filter by scene count\n\tSceneCount *IntCriterionInput `json:\"scene_count\"`\n\t// Filter by image count\n\tImageCount *IntCriterionInput `json:\"image_count\"`\n\t// Filter by gallery count\n\tGalleryCount *IntCriterionInput `json:\"gallery_count\"`\n\t// Filter by group count\n\tGroupCount *IntCriterionInput `json:\"group_count\"`\n\t// Filter by url\n\tURL *StringCriterionInput `json:\"url\"`\n\t// Filter by studio aliases\n\tAliases *StringCriterionInput `json:\"aliases\"`\n\t// Filter by subsidiary studio count\n\tChildCount *IntCriterionInput `json:\"child_count\"`\n\t// Filter by autotag ignore value\n\tIgnoreAutoTag *bool `json:\"ignore_auto_tag\"`\n\t// Filter by organized\n\tOrganized *bool `json:\"organized\"`\n\t// Filter by related scenes that meet this criteria\n\tScenesFilter *SceneFilterType `json:\"scenes_filter\"`\n\t// Filter by related images that meet this criteria\n\tImagesFilter *ImageFilterType `json:\"images_filter\"`\n\t// Filter by related galleries that meet this criteria\n\tGalleriesFilter *GalleryFilterType `json:\"galleries_filter\"`\n\t// Filter by related groups that meet this criteria\n\tGroupsFilter *GroupFilterType `json:\"groups_filter\"`\n\t// Filter by created at\n\tCreatedAt *TimestampCriterionInput `json:\"created_at\"`\n\t// Filter by updated at\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at\"`\n\n\t// Filter by custom fields\n\tCustomFields []CustomFieldCriterionInput `json:\"custom_fields\"`\n}\n\ntype StudioCreateInput struct {\n\tName     string   `json:\"name\"`\n\tURL      *string  `json:\"url\"` // deprecated\n\tUrls     []string `json:\"urls\"`\n\tParentID *string  `json:\"parent_id\"`\n\t// This should be a URL or a base64 encoded data URL\n\tImage         *string        `json:\"image\"`\n\tStashIds      []StashIDInput `json:\"stash_ids\"`\n\tRating100     *int           `json:\"rating100\"`\n\tFavorite      *bool          `json:\"favorite\"`\n\tDetails       *string        `json:\"details\"`\n\tAliases       []string       `json:\"aliases\"`\n\tTagIds        []string       `json:\"tag_ids\"`\n\tIgnoreAutoTag *bool          `json:\"ignore_auto_tag\"`\n\tOrganized     *bool          `json:\"organized\"`\n\n\tCustomFields map[string]interface{} `json:\"custom_fields\"`\n}\n\ntype StudioUpdateInput struct {\n\tID       string   `json:\"id\"`\n\tName     *string  `json:\"name\"`\n\tURL      *string  `json:\"url\"` // deprecated\n\tUrls     []string `json:\"urls\"`\n\tParentID *string  `json:\"parent_id\"`\n\t// This should be a URL or a base64 encoded data URL\n\tImage         *string        `json:\"image\"`\n\tStashIds      []StashIDInput `json:\"stash_ids\"`\n\tRating100     *int           `json:\"rating100\"`\n\tFavorite      *bool          `json:\"favorite\"`\n\tDetails       *string        `json:\"details\"`\n\tAliases       []string       `json:\"aliases\"`\n\tTagIds        []string       `json:\"tag_ids\"`\n\tIgnoreAutoTag *bool          `json:\"ignore_auto_tag\"`\n\tOrganized     *bool          `json:\"organized\"`\n\n\tCustomFields CustomFieldsInput `json:\"custom_fields\"`\n}\n"
  },
  {
    "path": "pkg/models/tag.go",
    "content": "package models\n\ntype TagFilterType struct {\n\tOperatorFilter[TagFilterType]\n\t// Filter by tag name\n\tName *StringCriterionInput `json:\"name\"`\n\t// Filter by tag sort_name\n\tSortName *StringCriterionInput `json:\"sort_name\"`\n\t// Filter by tag aliases\n\tAliases *StringCriterionInput `json:\"aliases\"`\n\t// Filter by tag favorites\n\tFavorite *bool `json:\"favorite\"`\n\t// Filter by tag description\n\tDescription *StringCriterionInput `json:\"description\"`\n\t// Filter to only include tags missing this property\n\tIsMissing *string `json:\"is_missing\"`\n\t// Filter by number of scenes with this tag\n\tSceneCount *IntCriterionInput `json:\"scene_count\"`\n\t// Filter by number of images with this tag\n\tImageCount *IntCriterionInput `json:\"image_count\"`\n\t// Filter by number of galleries with this tag\n\tGalleryCount *IntCriterionInput `json:\"gallery_count\"`\n\t// Filter by number of performers with this tag\n\tPerformerCount *IntCriterionInput `json:\"performer_count\"`\n\t// Filter by number of studios with this tag\n\tStudioCount *IntCriterionInput `json:\"studio_count\"`\n\t// Filter by number of groups with this tag\n\tGroupCount *IntCriterionInput `json:\"group_count\"`\n\t// Filter by number of movies with this tag\n\tMovieCount *IntCriterionInput `json:\"movie_count\"`\n\t// Filter by number of markers with this tag\n\tMarkerCount *IntCriterionInput `json:\"marker_count\"`\n\t// Filter by parent tags\n\tParents *HierarchicalMultiCriterionInput `json:\"parents\"`\n\t// Filter by child tags\n\tChildren *HierarchicalMultiCriterionInput `json:\"children\"`\n\t// Filter by number of parent tags the tag has\n\tParentCount *IntCriterionInput `json:\"parent_count\"`\n\t// Filter by number f child tags the tag has\n\tChildCount *IntCriterionInput `json:\"child_count\"`\n\t// Filter by autotag ignore value\n\tIgnoreAutoTag *bool `json:\"ignore_auto_tag\"`\n\t// Filter by StashID Endpoint\n\tStashIDEndpoint *StashIDCriterionInput `json:\"stash_id_endpoint\"`\n\t// Filter by StashIDs Endpoint\n\tStashIDsEndpoint *StashIDsCriterionInput `json:\"stash_ids_endpoint\"`\n\t// Filter by related scenes that meet this criteria\n\tScenesFilter *SceneFilterType `json:\"scenes_filter\"`\n\t// Filter by related images that meet this criteria\n\tImagesFilter *ImageFilterType `json:\"images_filter\"`\n\t// Filter by related galleries that meet this criteria\n\tGalleriesFilter *GalleryFilterType `json:\"galleries_filter\"`\n\t// Filter by related groups\tthat meet this criteria\n\tGroupsFilter *GroupFilterType `json:\"groups_filter\"`\n\t// Filter by related performers that meet this criteria\n\tPerformersFilter *PerformerFilterType `json:\"performers_filter\"`\n\t// Filter by related studios that meet this criteria\n\tStudiosFilter *StudioFilterType `json:\"studios_filter\"`\n\t// Filter by related scene markers that meet this criteria\n\tMarkersFilter *SceneMarkerFilterType `json:\"markers_filter\"`\n\t// Filter by created at\n\tCreatedAt *TimestampCriterionInput `json:\"created_at\"`\n\t// Filter by updated at\n\tUpdatedAt *TimestampCriterionInput `json:\"updated_at\"`\n\n\t// Filter by custom fields\n\tCustomFields []CustomFieldCriterionInput `json:\"custom_fields\"`\n}\n"
  },
  {
    "path": "pkg/models/update.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/intslice\"\n)\n\ntype RelationshipUpdateMode string\n\nconst (\n\tRelationshipUpdateModeSet    RelationshipUpdateMode = \"SET\"\n\tRelationshipUpdateModeAdd    RelationshipUpdateMode = \"ADD\"\n\tRelationshipUpdateModeRemove RelationshipUpdateMode = \"REMOVE\"\n)\n\nvar AllRelationshipUpdateMode = []RelationshipUpdateMode{\n\tRelationshipUpdateModeSet,\n\tRelationshipUpdateModeAdd,\n\tRelationshipUpdateModeRemove,\n}\n\nfunc (e RelationshipUpdateMode) IsValid() bool {\n\tswitch e {\n\tcase RelationshipUpdateModeSet, RelationshipUpdateModeAdd, RelationshipUpdateModeRemove:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e RelationshipUpdateMode) String() string {\n\treturn string(e)\n}\n\nfunc (e *RelationshipUpdateMode) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = RelationshipUpdateMode(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid RelationshipUpdateMode\", str)\n\t}\n\treturn nil\n}\n\nfunc (e RelationshipUpdateMode) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype UpdateIDs struct {\n\tIDs  []int                  `json:\"ids\"`\n\tMode RelationshipUpdateMode `json:\"mode\"`\n}\n\nfunc (u *UpdateIDs) IDStrings() []string {\n\tif u == nil {\n\t\treturn nil\n\t}\n\n\treturn intslice.IntSliceToStringSlice(u.IDs)\n}\n\n// GetImpactedIDs returns the IDs that will be impacted by the update.\n// If the update is to add IDs, then the impacted IDs are the IDs being added.\n// If the update is to remove IDs, then the impacted IDs are the IDs being removed.\n// If the update is to set IDs, then the impacted IDs are the IDs being removed and the IDs being added.\n// Any IDs that are already present and are being added are not returned.\n// Likewise, any IDs that are not present that are being removed are not returned.\nfunc (u *UpdateIDs) ImpactedIDs(existing []int) []int {\n\tif u == nil {\n\t\treturn nil\n\t}\n\n\tswitch u.Mode {\n\tcase RelationshipUpdateModeAdd:\n\t\treturn sliceutil.Exclude(u.IDs, existing)\n\tcase RelationshipUpdateModeRemove:\n\t\treturn sliceutil.Intersect(existing, u.IDs)\n\tcase RelationshipUpdateModeSet:\n\t\t// get the difference between the two lists\n\t\treturn sliceutil.NotIntersect(existing, u.IDs)\n\t}\n\n\treturn nil\n}\n\n// Apply applies the update to a list of existing ids, returning the result.\nfunc (u *UpdateIDs) Apply(existing []int) []int {\n\tif u == nil {\n\t\treturn existing\n\t}\n\n\treturn applyUpdate(u.IDs, u.Mode, existing)\n}\n\ntype UpdateStrings struct {\n\tValues []string               `json:\"values\"`\n\tMode   RelationshipUpdateMode `json:\"mode\"`\n}\n\nfunc (u *UpdateStrings) Strings() []string {\n\tif u == nil {\n\t\treturn nil\n\t}\n\n\treturn u.Values\n}\n\n// Apply applies the update to a list of existing strings, returning the result.\nfunc (u *UpdateStrings) Apply(existing []string) []string {\n\tif u == nil {\n\t\treturn existing\n\t}\n\n\treturn applyUpdate(u.Values, u.Mode, existing)\n}\n\n// applyUpdate applies values to existing, using the update mode specified.\nfunc applyUpdate[T comparable](values []T, mode RelationshipUpdateMode, existing []T) []T {\n\tswitch mode {\n\tcase RelationshipUpdateModeAdd:\n\t\treturn sliceutil.AppendUniques(existing, values)\n\tcase RelationshipUpdateModeRemove:\n\t\treturn sliceutil.Exclude(existing, values)\n\tcase RelationshipUpdateModeSet:\n\t\treturn values\n\t}\n\n\treturn nil\n}\n\ntype UpdateGroupDescriptions struct {\n\tGroups []GroupIDDescription   `json:\"groups\"`\n\tMode   RelationshipUpdateMode `json:\"mode\"`\n}\n\n// Apply applies the update to a list of existing ids, returning the result.\nfunc (u *UpdateGroupDescriptions) Apply(existing []GroupIDDescription) []GroupIDDescription {\n\tif u == nil {\n\t\treturn existing\n\t}\n\n\tswitch u.Mode {\n\tcase RelationshipUpdateModeAdd:\n\t\treturn u.applyAdd(existing)\n\tcase RelationshipUpdateModeRemove:\n\t\treturn u.applyRemove(existing)\n\tcase RelationshipUpdateModeSet:\n\t\treturn u.Groups\n\t}\n\n\treturn nil\n}\n\nfunc (u *UpdateGroupDescriptions) applyAdd(existing []GroupIDDescription) []GroupIDDescription {\n\t// overwrite any existing values with the same id\n\tret := append([]GroupIDDescription{}, existing...)\n\tfor _, v := range u.Groups {\n\t\tfound := false\n\t\tfor i, vv := range ret {\n\t\t\tif vv.GroupID == v.GroupID {\n\t\t\t\tret[i] = v\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (u *UpdateGroupDescriptions) applyRemove(existing []GroupIDDescription) []GroupIDDescription {\n\t// remove any existing values with the same id\n\tvar ret []GroupIDDescription\n\tfor _, v := range existing {\n\t\tfound := false\n\t\tfor _, vv := range u.Groups {\n\t\t\tif vv.GroupID == v.GroupID {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// if not found in the remove list, keep it\n\t\tif !found {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/models/update_test.go",
    "content": "package models\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestUpdateIDs_ImpactedIDs(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tIDs      []int\n\t\tMode     RelationshipUpdateMode\n\t\texisting []int\n\t\twant     []int\n\t}{\n\t\t{\n\t\t\tname:     \"add\",\n\t\t\tIDs:      []int{1, 2, 3},\n\t\t\tMode:     RelationshipUpdateModeAdd,\n\t\t\texisting: []int{1, 2},\n\t\t\twant:     []int{3},\n\t\t},\n\t\t{\n\t\t\tname:     \"remove\",\n\t\t\tIDs:      []int{1, 2, 3},\n\t\t\tMode:     RelationshipUpdateModeRemove,\n\t\t\texisting: []int{1, 2},\n\t\t\twant:     []int{1, 2},\n\t\t},\n\t\t{\n\t\t\tname:     \"set\",\n\t\t\tIDs:      []int{1, 2, 3},\n\t\t\tMode:     RelationshipUpdateModeSet,\n\t\t\texisting: []int{1, 2},\n\t\t\twant:     []int{3},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tu := &UpdateIDs{\n\t\t\t\tIDs:  tt.IDs,\n\t\t\t\tMode: tt.Mode,\n\t\t\t}\n\t\t\tif got := u.ImpactedIDs(tt.existing); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"UpdateIDs.ImpactedIDs() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyUpdate(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tvalues   []int\n\t\tmode     RelationshipUpdateMode\n\t\texisting []int\n\t\twant     []int\n\t}{\n\t\t{\n\t\t\tname:     \"add\",\n\t\t\tvalues:   []int{2, 3},\n\t\t\tmode:     RelationshipUpdateModeAdd,\n\t\t\texisting: []int{1, 2},\n\t\t\twant:     []int{1, 2, 3},\n\t\t},\n\t\t{\n\t\t\tname:     \"remove\",\n\t\t\tvalues:   []int{2, 3},\n\t\t\tmode:     RelationshipUpdateModeRemove,\n\t\t\texisting: []int{1, 2},\n\t\t\twant:     []int{1},\n\t\t},\n\t\t{\n\t\t\tname:     \"set\",\n\t\t\tvalues:   []int{1, 2, 3},\n\t\t\tmode:     RelationshipUpdateModeSet,\n\t\t\texisting: []int{1, 2},\n\t\t\twant:     []int{1, 2, 3},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := applyUpdate(tt.values, tt.mode, tt.existing)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/models/value.go",
    "content": "package models\n\nimport (\n\t\"strconv\"\n\t\"time\"\n)\n\n// OptionalString represents an optional string argument that may be null.\n// A value is only considered null if both Set and Null is true.\ntype OptionalString struct {\n\tValue string\n\tNull  bool\n\tSet   bool\n}\n\n// Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true.\nfunc (o *OptionalString) Ptr() *string {\n\tif !o.Set || o.Null {\n\t\treturn nil\n\t}\n\n\tv := o.Value\n\treturn &v\n}\n\n// Merge sets the OptionalString if it is not already set, the destination value is empty and the source value is not empty.\nfunc (o *OptionalString) Merge(destVal string, srcVal string) {\n\tif destVal == \"\" && srcVal != \"\" && !o.Set {\n\t\t*o = NewOptionalString(srcVal)\n\t}\n}\n\n// NewOptionalString returns a new OptionalString with the given value.\nfunc NewOptionalString(v string) OptionalString {\n\treturn OptionalString{v, false, true}\n}\n\n// NewOptionalStringPtr returns a new OptionalString with the given value.\n// If the value is nil, the returned OptionalString will be set and null.\nfunc NewOptionalStringPtr(v *string) OptionalString {\n\tif v == nil {\n\t\treturn OptionalString{\n\t\t\tNull: true,\n\t\t\tSet:  true,\n\t\t}\n\t}\n\n\treturn OptionalString{*v, false, true}\n}\n\n// OptionalInt represents an optional int argument that may be null. See OptionalString.\ntype OptionalInt struct {\n\tValue int\n\tNull  bool\n\tSet   bool\n}\n\n// Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true.\nfunc (o *OptionalInt) Ptr() *int {\n\tif !o.Set || o.Null {\n\t\treturn nil\n\t}\n\n\tv := o.Value\n\treturn &v\n}\n\n// MergePtr sets the OptionalInt if it is not already set, the destination value is nil and the source value is not nil.\nfunc (o *OptionalInt) MergePtr(destVal *int, srcVal *int) {\n\tif destVal == nil && srcVal != nil && !o.Set {\n\t\t*o = NewOptionalInt(*srcVal)\n\t}\n}\n\n// NewOptionalInt returns a new OptionalInt with the given value.\nfunc NewOptionalInt(v int) OptionalInt {\n\treturn OptionalInt{v, false, true}\n}\n\n// NewOptionalIntPtr returns a new OptionalInt with the given value.\n// If the value is nil, the returned OptionalInt will be set and null.\nfunc NewOptionalIntPtr(v *int) OptionalInt {\n\tif v == nil {\n\t\treturn OptionalInt{\n\t\t\tNull: true,\n\t\t\tSet:  true,\n\t\t}\n\t}\n\n\treturn OptionalInt{*v, false, true}\n}\n\n// StringPtr returns a pointer to a string representation of the value.\n// Returns nil if Set is false or null is true.\nfunc (o *OptionalInt) StringPtr() *string {\n\tif !o.Set || o.Null {\n\t\treturn nil\n\t}\n\n\tv := strconv.Itoa(o.Value)\n\treturn &v\n}\n\n// OptionalInt64 represents an optional int64 argument that may be null. See OptionalString.\ntype OptionalInt64 struct {\n\tValue int64\n\tNull  bool\n\tSet   bool\n}\n\n// Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true.\nfunc (o *OptionalInt64) Ptr() *int64 {\n\tif !o.Set || o.Null {\n\t\treturn nil\n\t}\n\n\tv := o.Value\n\treturn &v\n}\n\n// NewOptionalInt64 returns a new OptionalInt64 with the given value.\nfunc NewOptionalInt64(v int64) OptionalInt64 {\n\treturn OptionalInt64{v, false, true}\n}\n\n// NewOptionalInt64Ptr returns a new OptionalInt64 with the given value.\n// If the value is nil, the returned OptionalInt64 will be set and null.\nfunc NewOptionalInt64Ptr(v *int64) OptionalInt64 {\n\tif v == nil {\n\t\treturn OptionalInt64{\n\t\t\tNull: true,\n\t\t\tSet:  true,\n\t\t}\n\t}\n\n\treturn OptionalInt64{*v, false, true}\n}\n\n// OptionalBool represents an optional int64 argument that may be null. See OptionalString.\ntype OptionalBool struct {\n\tValue bool\n\tNull  bool\n\tSet   bool\n}\n\nfunc (o *OptionalBool) Ptr() *bool {\n\tif !o.Set || o.Null {\n\t\treturn nil\n\t}\n\n\tv := o.Value\n\treturn &v\n}\n\n// Merge sets the OptionalBool to true if it is not already set, the destination value is false and the source value is true.\nfunc (o *OptionalBool) Merge(destVal bool, srcVal bool) {\n\tif !destVal && srcVal && !o.Set {\n\t\t*o = NewOptionalBool(true)\n\t}\n}\n\n// NewOptionalBool returns a new OptionalBool with the given value.\nfunc NewOptionalBool(v bool) OptionalBool {\n\treturn OptionalBool{v, false, true}\n}\n\n// NewOptionalBoolPtr returns a new OptionalBool with the given value.\n// If the value is nil, the returned OptionalBool will be set and null.\nfunc NewOptionalBoolPtr(v *bool) OptionalBool {\n\tif v == nil {\n\t\treturn OptionalBool{\n\t\t\tNull: true,\n\t\t\tSet:  true,\n\t\t}\n\t}\n\n\treturn OptionalBool{*v, false, true}\n}\n\n// OptionalFloat64 represents an optional float64 argument that may be null. See OptionalString.\ntype OptionalFloat64 struct {\n\tValue float64\n\tNull  bool\n\tSet   bool\n}\n\n// Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true.\nfunc (o *OptionalFloat64) Ptr() *float64 {\n\tif !o.Set || o.Null {\n\t\treturn nil\n\t}\n\n\tv := o.Value\n\treturn &v\n}\n\n// NewOptionalFloat64 returns a new OptionalFloat64 with the given value.\nfunc NewOptionalFloat64(v float64) OptionalFloat64 {\n\treturn OptionalFloat64{v, false, true}\n}\n\n// NewOptionalFloat64 returns a new OptionalFloat64 with the given value.\nfunc NewOptionalFloat64Ptr(v *float64) OptionalFloat64 {\n\tif v == nil {\n\t\treturn OptionalFloat64{\n\t\t\tNull: true,\n\t\t\tSet:  true,\n\t\t}\n\t}\n\n\treturn OptionalFloat64{*v, false, true}\n}\n\n// OptionalDate represents an optional date argument that may be null. See OptionalString.\ntype OptionalDate struct {\n\tValue Date\n\tNull  bool\n\tSet   bool\n}\n\n// Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true.\nfunc (o *OptionalDate) Ptr() *Date {\n\tif !o.Set || o.Null {\n\t\treturn nil\n\t}\n\n\tv := o.Value\n\treturn &v\n}\n\n// NewOptionalDate returns a new OptionalDate with the given value.\nfunc NewOptionalDate(v Date) OptionalDate {\n\treturn OptionalDate{v, false, true}\n}\n\n// Merge sets the OptionalDate if it is not already set, the destination value is nil and the source value is nil.\nfunc (o *OptionalDate) MergePtr(destVal *Date, srcVal *Date) {\n\tif destVal == nil && srcVal != nil && !o.Set {\n\t\t*o = NewOptionalDate(*srcVal)\n\t}\n}\n\n// NewOptionalBoolPtr returns a new OptionalDate with the given value.\n// If the value is nil, the returned OptionalDate will be set and null.\nfunc NewOptionalDatePtr(v *Date) OptionalDate {\n\tif v == nil {\n\t\treturn OptionalDate{\n\t\t\tNull: true,\n\t\t\tSet:  true,\n\t\t}\n\t}\n\n\treturn OptionalDate{*v, false, true}\n}\n\n// OptionalTime represents an optional time argument that may be null. See OptionalString.\ntype OptionalTime struct {\n\tValue time.Time\n\tNull  bool\n\tSet   bool\n}\n\n// NewOptionalTime returns a new OptionalTime with the given value.\nfunc NewOptionalTime(v time.Time) OptionalTime {\n\treturn OptionalTime{v, false, true}\n}\n\n// NewOptionalTimePtr returns a new OptionalTime with the given value.\n// If the value is nil, the returned OptionalTime will be set and null.\nfunc NewOptionalTimePtr(v *time.Time) OptionalTime {\n\tif v == nil {\n\t\treturn OptionalTime{\n\t\t\tNull: true,\n\t\t\tSet:  true,\n\t\t}\n\t}\n\n\treturn OptionalTime{*v, false, true}\n}\n\n// Ptr returns a pointer to the underlying value. Returns nil if Set is false or Null is true.\nfunc (o *OptionalTime) Ptr() *time.Time {\n\tif !o.Set || o.Null {\n\t\treturn nil\n\t}\n\n\tv := o.Value\n\treturn &v\n}\n"
  },
  {
    "path": "pkg/performer/doc.go",
    "content": "// Package performer provides the application logic for performer functionality.\npackage performer\n"
  },
  {
    "path": "pkg/performer/export.go",
    "content": "package performer\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ImageAliasStashIDGetter interface {\n\tGetImage(ctx context.Context, performerID int) ([]byte, error)\n\tmodels.AliasLoader\n\tmodels.StashIDLoader\n\tmodels.URLLoader\n\tmodels.CustomFieldsReader\n}\n\n// ToJSON converts a Performer object into its JSON equivalent.\nfunc ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *models.Performer) (*jsonschema.Performer, error) {\n\tnewPerformerJSON := jsonschema.Performer{\n\t\tName:           performer.Name,\n\t\tDisambiguation: performer.Disambiguation,\n\t\tEthnicity:      performer.Ethnicity,\n\t\tCountry:        performer.Country,\n\t\tEyeColor:       performer.EyeColor,\n\t\tMeasurements:   performer.Measurements,\n\t\tFakeTits:       performer.FakeTits,\n\t\tTattoos:        performer.Tattoos,\n\t\tPiercings:      performer.Piercings,\n\t\tFavorite:       performer.Favorite,\n\t\tDetails:        performer.Details,\n\t\tHairColor:      performer.HairColor,\n\t\tIgnoreAutoTag:  performer.IgnoreAutoTag,\n\t\tCreatedAt:      json.JSONTime{Time: performer.CreatedAt},\n\t\tUpdatedAt:      json.JSONTime{Time: performer.UpdatedAt},\n\t}\n\n\tif performer.Gender != nil {\n\t\tnewPerformerJSON.Gender = performer.Gender.String()\n\t}\n\n\tif performer.Circumcised != nil {\n\t\tnewPerformerJSON.Circumcised = performer.Circumcised.String()\n\t}\n\n\tif performer.Birthdate != nil {\n\t\tnewPerformerJSON.Birthdate = performer.Birthdate.String()\n\t}\n\tif performer.Rating != nil {\n\t\tnewPerformerJSON.Rating = *performer.Rating\n\t}\n\tif performer.DeathDate != nil {\n\t\tnewPerformerJSON.DeathDate = performer.DeathDate.String()\n\t}\n\n\tif performer.Height != nil {\n\t\tnewPerformerJSON.Height = strconv.Itoa(*performer.Height)\n\t}\n\n\tif performer.Weight != nil {\n\t\tnewPerformerJSON.Weight = *performer.Weight\n\t}\n\n\tif performer.PenisLength != nil {\n\t\tnewPerformerJSON.PenisLength = *performer.PenisLength\n\t}\n\n\tif performer.CareerStart != nil {\n\t\tnewPerformerJSON.CareerStart = performer.CareerStart.String()\n\t}\n\tif performer.CareerEnd != nil {\n\t\tnewPerformerJSON.CareerEnd = performer.CareerEnd.String()\n\t}\n\n\tif err := performer.LoadAliases(ctx, reader); err != nil {\n\t\treturn nil, fmt.Errorf(\"loading performer aliases: %w\", err)\n\t}\n\n\tnewPerformerJSON.Aliases = performer.Aliases.List()\n\n\tif err := performer.LoadURLs(ctx, reader); err != nil {\n\t\treturn nil, fmt.Errorf(\"loading performer urls: %w\", err)\n\t}\n\tnewPerformerJSON.URLs = performer.URLs.List()\n\n\tif err := performer.LoadStashIDs(ctx, reader); err != nil {\n\t\treturn nil, fmt.Errorf(\"loading performer stash ids: %w\", err)\n\t}\n\n\tnewPerformerJSON.StashIDs = performer.StashIDs.List()\n\n\tvar err error\n\tnewPerformerJSON.CustomFields, err = reader.GetCustomFields(ctx, performer.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting performer custom fields: %v\", err)\n\t}\n\n\timage, err := reader.GetImage(ctx, performer.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error getting performer image: %v\", err)\n\t}\n\n\tif len(image) > 0 {\n\t\tnewPerformerJSON.Image = utils.GetBase64StringFromData(image)\n\t}\n\n\treturn &newPerformerJSON, nil\n}\n\nfunc GetIDs(performers []*models.Performer) []int {\n\tvar results []int\n\tfor _, performer := range performers {\n\t\tresults = append(results, performer.ID)\n\t}\n\n\treturn results\n}\n\nfunc GetNames(performers []*models.Performer) []string {\n\tvar results []string\n\tfor _, performer := range performers {\n\t\tif performer.Name != \"\" {\n\t\t\tresults = append(results, performer.Name)\n\t\t}\n\t}\n\n\treturn results\n}\n"
  },
  {
    "path": "pkg/performer/export_test.go",
    "content": "package performer\n\nimport (\n\t\"errors\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\tperformerID       = 1\n\tnoImageID         = 2\n\terrImageID        = 3\n\tcustomFieldsID    = 4\n\terrCustomFieldsID = 5\n)\n\nconst (\n\tperformerName  = \"testPerformer\"\n\tdisambiguation = \"disambiguation\"\n\turl            = \"url\"\n\tcountry        = \"country\"\n\tethnicity      = \"ethnicity\"\n\teyeColor       = \"eyeColor\"\n\tfakeTits       = \"fakeTits\"\n\tinstagram      = \"instagram\"\n\tmeasurements   = \"measurements\"\n\tpiercings      = \"piercings\"\n\ttattoos        = \"tattoos\"\n\ttwitter        = \"twitter\"\n\tdetails        = \"details\"\n\thairColor      = \"hairColor\"\n\n\tautoTagIgnored = true\n)\n\nvar (\n\tgenderEnum      = models.GenderEnumFemale\n\tgender          = genderEnum.String()\n\taliases         = []string{\"alias1\", \"alias2\"}\n\trating          = 5\n\theight          = 123\n\tweight          = 60\n\tcareerStart, _  = models.ParseDate(\"2005\")\n\tcareerEnd, _    = models.ParseDate(\"2015\")\n\tpenisLength     = 1.23\n\tcircumcisedEnum = models.CircumcisedEnumCut\n\tcircumcised     = circumcisedEnum.String()\n\n\temptyCustomFields = make(map[string]interface{})\n\tcustomFields      = map[string]interface{}{\n\t\t\"customField1\": \"customValue1\",\n\t}\n)\n\nvar imageBytes = []byte(\"imageBytes\")\n\nvar stashID = models.StashID{\n\tStashID:  \"StashID\",\n\tEndpoint: \"Endpoint\",\n}\nvar stashIDs = []models.StashID{\n\tstashID,\n}\n\nconst image = \"aW1hZ2VCeXRlcw==\"\n\nvar birthDate, _ = models.ParseDate(\"2001-01-01\")\nvar deathDate, _ = models.ParseDate(\"2021-02-02\")\n\nvar (\n\tcreateTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local)\n\tupdateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local)\n)\n\nfunc createFullPerformer(id int, name string) *models.Performer {\n\treturn &models.Performer{\n\t\tID:             id,\n\t\tName:           name,\n\t\tDisambiguation: disambiguation,\n\t\tURLs:           models.NewRelatedStrings([]string{url, twitter, instagram}),\n\t\tAliases:        models.NewRelatedStrings(aliases),\n\t\tBirthdate:      &birthDate,\n\t\tCareerStart:    &careerStart,\n\t\tCareerEnd:      &careerEnd,\n\t\tCountry:        country,\n\t\tEthnicity:      ethnicity,\n\t\tEyeColor:       eyeColor,\n\t\tFakeTits:       fakeTits,\n\t\tPenisLength:    &penisLength,\n\t\tCircumcised:    &circumcisedEnum,\n\t\tFavorite:       true,\n\t\tGender:         &genderEnum,\n\t\tHeight:         &height,\n\t\tMeasurements:   measurements,\n\t\tPiercings:      piercings,\n\t\tTattoos:        tattoos,\n\t\tCreatedAt:      createTime,\n\t\tUpdatedAt:      updateTime,\n\t\tRating:         &rating,\n\t\tDetails:        details,\n\t\tDeathDate:      &deathDate,\n\t\tHairColor:      hairColor,\n\t\tWeight:         &weight,\n\t\tIgnoreAutoTag:  autoTagIgnored,\n\t\tTagIDs:         models.NewRelatedIDs([]int{}),\n\t\tStashIDs:       models.NewRelatedStashIDs(stashIDs),\n\t}\n}\n\nfunc createEmptyPerformer(id int) models.Performer {\n\treturn models.Performer{\n\t\tID:        id,\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t\tAliases:   models.NewRelatedStrings([]string{}),\n\t\tURLs:      models.NewRelatedStrings([]string{}),\n\t\tTagIDs:    models.NewRelatedIDs([]int{}),\n\t\tStashIDs:  models.NewRelatedStashIDs([]models.StashID{}),\n\t}\n}\n\nfunc createFullJSONPerformer(name string, image string, withCustomFields bool) *jsonschema.Performer {\n\tret := &jsonschema.Performer{\n\t\tName:           name,\n\t\tDisambiguation: disambiguation,\n\t\tURLs:           []string{url, twitter, instagram},\n\t\tAliases:        aliases,\n\t\tBirthdate:      birthDate.String(),\n\t\tCareerStart:    careerStart.String(),\n\t\tCareerEnd:      careerEnd.String(),\n\t\tCountry:        country,\n\t\tEthnicity:      ethnicity,\n\t\tEyeColor:       eyeColor,\n\t\tFakeTits:       fakeTits,\n\t\tPenisLength:    penisLength,\n\t\tCircumcised:    circumcised,\n\t\tFavorite:       true,\n\t\tGender:         gender,\n\t\tHeight:         strconv.Itoa(height),\n\t\tMeasurements:   measurements,\n\t\tPiercings:      piercings,\n\t\tTattoos:        tattoos,\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tRating:        rating,\n\t\tImage:         image,\n\t\tDetails:       details,\n\t\tDeathDate:     deathDate.String(),\n\t\tHairColor:     hairColor,\n\t\tWeight:        weight,\n\t\tStashIDs:      stashIDs,\n\t\tIgnoreAutoTag: autoTagIgnored,\n\t\tCustomFields:  emptyCustomFields,\n\t}\n\n\tif withCustomFields {\n\t\tret.CustomFields = customFields\n\t}\n\treturn ret\n}\n\nfunc createEmptyJSONPerformer() *jsonschema.Performer {\n\treturn &jsonschema.Performer{\n\t\tAliases:  []string{},\n\t\tURLs:     []string{},\n\t\tStashIDs: []models.StashID{},\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tCustomFields: emptyCustomFields,\n\t}\n}\n\ntype testScenario struct {\n\tinput        models.Performer\n\tcustomFields map[string]interface{}\n\texpected     *jsonschema.Performer\n\terr          bool\n}\n\nvar scenarios []testScenario\n\nfunc initTestTable() {\n\tscenarios = []testScenario{\n\t\t{\n\t\t\t*createFullPerformer(performerID, performerName),\n\t\t\temptyCustomFields,\n\t\t\tcreateFullJSONPerformer(performerName, image, false),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t*createFullPerformer(customFieldsID, performerName),\n\t\t\tcustomFields,\n\t\t\tcreateFullJSONPerformer(performerName, image, true),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateEmptyPerformer(noImageID),\n\t\t\temptyCustomFields,\n\t\t\tcreateEmptyJSONPerformer(),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t*createFullPerformer(errImageID, performerName),\n\t\t\temptyCustomFields,\n\t\t\tcreateFullJSONPerformer(performerName, \"\", false),\n\t\t\t// failure to get image should not cause an error\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t*createFullPerformer(errCustomFieldsID, performerName),\n\t\t\tcustomFields,\n\t\t\tnil,\n\t\t\t// failure to get custom fields should cause an error\n\t\t\ttrue,\n\t\t},\n\t}\n}\n\nfunc TestToJSON(t *testing.T) {\n\tinitTestTable()\n\n\tdb := mocks.NewDatabase()\n\n\timageErr := errors.New(\"error getting image\")\n\tcustomFieldsErr := errors.New(\"error getting custom fields\")\n\n\tdb.Performer.On(\"GetImage\", testCtx, performerID).Return(imageBytes, nil).Once()\n\tdb.Performer.On(\"GetImage\", testCtx, customFieldsID).Return(imageBytes, nil).Once()\n\tdb.Performer.On(\"GetImage\", testCtx, noImageID).Return(nil, nil).Once()\n\tdb.Performer.On(\"GetImage\", testCtx, errImageID).Return(nil, imageErr).Once()\n\n\tdb.Performer.On(\"GetCustomFields\", testCtx, performerID).Return(emptyCustomFields, nil).Once()\n\tdb.Performer.On(\"GetCustomFields\", testCtx, customFieldsID).Return(customFields, nil).Once()\n\tdb.Performer.On(\"GetCustomFields\", testCtx, noImageID).Return(emptyCustomFields, nil).Once()\n\tdb.Performer.On(\"GetCustomFields\", testCtx, errImageID).Return(emptyCustomFields, nil).Once()\n\tdb.Performer.On(\"GetCustomFields\", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once()\n\n\tfor i, s := range scenarios {\n\t\ttag := s.input\n\t\tjson, err := ToJSON(testCtx, db.Performer, &tag)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/performer/import.go",
    "content": "package performer\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ImporterReaderWriter interface {\n\tmodels.PerformerCreatorUpdater\n\tmodels.PerformerQueryer\n}\n\ntype Importer struct {\n\tReaderWriter        ImporterReaderWriter\n\tTagWriter           models.TagFinderCreator\n\tInput               jsonschema.Performer\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\tID           int\n\tperformer    models.Performer\n\tcustomFields models.CustomFieldMap\n\timageData    []byte\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\tvar err error\n\ti.performer, err = performerJSONToPerformer(i.Input)\n\tif err != nil {\n\t\treturn err\n\t}\n\ti.customFields = i.Input.CustomFields\n\n\tif err := i.populateTags(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif len(i.Input.Image) > 0 {\n\t\ti.imageData, err = utils.ProcessBase64Image(i.Input.Image)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid image: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) populateTags(ctx context.Context) error {\n\tif len(i.Input.Tags) > 0 {\n\n\t\ttags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, p := range tags {\n\t\t\ti.performer.TagIDs.Add(p.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {\n\ttags, err := tagWriter.FindByNames(ctx, names, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar pluckedNames []string\n\tfor _, tag := range tags {\n\t\tpluckedNames = append(pluckedNames, tag.Name)\n\t}\n\n\tmissingTags := sliceutil.Filter(names, func(name string) bool {\n\t\treturn !slices.Contains(pluckedNames, name)\n\t})\n\n\tif len(missingTags) > 0 {\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\treturn nil, fmt.Errorf(\"tags [%s] not found\", strings.Join(missingTags, \", \"))\n\t\t}\n\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\tcreatedTags, err := createTags(ctx, tagWriter, missingTags)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error creating tags: %v\", err)\n\t\t\t}\n\n\t\t\ttags = append(tags, createdTags...)\n\t\t}\n\n\t\t// ignore if MissingRefBehaviour set to Ignore\n\t}\n\n\treturn tags, nil\n}\n\nfunc createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) {\n\tvar ret []*models.Tag\n\tfor _, name := range names {\n\t\tnewTag := models.NewTag()\n\t\tnewTag.Name = name\n\n\t\terr := tagWriter.Create(ctx, &models.CreateTagInput{\n\t\t\tTag: &newTag,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newTag)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\tif len(i.imageData) > 0 {\n\t\tif err := i.ReaderWriter.UpdateImage(ctx, id, i.imageData); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting performer image: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\treturn i.Input.Name\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\t// use disambiguation as well\n\tperformerFilter := models.PerformerFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    i.Input.Name,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tif i.Input.Disambiguation != \"\" {\n\t\tperformerFilter.Disambiguation = &models.StringCriterionInput{\n\t\t\tValue:    i.Input.Disambiguation,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t}\n\t}\n\n\tpp := 1\n\tfindFilter := models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\texisting, _, err := i.ReaderWriter.Query(ctx, &performerFilter, &findFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(existing) > 0 {\n\t\tid := existing[0].ID\n\t\treturn &id, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\terr := i.ReaderWriter.Create(ctx, &models.CreatePerformerInput{\n\t\tPerformer:    &i.performer,\n\t\tCustomFields: i.customFields,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating performer: %v\", err)\n\t}\n\n\tid := i.performer.ID\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\ti.performer.ID = id\n\terr := i.ReaderWriter.Update(ctx, &models.UpdatePerformerInput{\n\t\tPerformer: &i.performer,\n\t\tCustomFields: models.CustomFieldsInput{\n\t\t\tFull: i.customFields,\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating existing performer: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Performer, error) {\n\tnewPerformer := models.Performer{\n\t\tName:           performerJSON.Name,\n\t\tDisambiguation: performerJSON.Disambiguation,\n\t\tEthnicity:      performerJSON.Ethnicity,\n\t\tCountry:        performerJSON.Country,\n\t\tEyeColor:       performerJSON.EyeColor,\n\t\tMeasurements:   performerJSON.Measurements,\n\t\tFakeTits:       performerJSON.FakeTits,\n\t\tTattoos:        performerJSON.Tattoos,\n\t\tPiercings:      performerJSON.Piercings,\n\t\tAliases:        models.NewRelatedStrings(performerJSON.Aliases),\n\t\tDetails:        performerJSON.Details,\n\t\tHairColor:      performerJSON.HairColor,\n\t\tFavorite:       performerJSON.Favorite,\n\t\tIgnoreAutoTag:  performerJSON.IgnoreAutoTag,\n\t\tCreatedAt:      performerJSON.CreatedAt.GetTime(),\n\t\tUpdatedAt:      performerJSON.UpdatedAt.GetTime(),\n\n\t\tTagIDs:   models.NewRelatedIDs([]int{}),\n\t\tStashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs),\n\t}\n\n\tif len(performerJSON.URLs) > 0 {\n\t\tnewPerformer.URLs = models.NewRelatedStrings(performerJSON.URLs)\n\t} else {\n\t\turls := []string{}\n\t\tif performerJSON.URL != \"\" {\n\t\t\turls = append(urls, performerJSON.URL)\n\t\t}\n\t\tif performerJSON.Twitter != \"\" {\n\t\t\turls = append(urls, performerJSON.Twitter)\n\t\t}\n\t\tif performerJSON.Instagram != \"\" {\n\t\t\turls = append(urls, performerJSON.Instagram)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tnewPerformer.URLs = models.NewRelatedStrings(urls)\n\t\t}\n\t}\n\n\tif performerJSON.Gender != \"\" {\n\t\tv := models.GenderEnum(performerJSON.Gender)\n\t\tnewPerformer.Gender = &v\n\t}\n\n\tif performerJSON.Circumcised != \"\" {\n\t\tv := models.CircumcisedEnum(performerJSON.Circumcised)\n\t\tnewPerformer.Circumcised = &v\n\t}\n\n\tif performerJSON.Birthdate != \"\" {\n\t\tdate, err := models.ParseDate(performerJSON.Birthdate)\n\t\tif err == nil {\n\t\t\tnewPerformer.Birthdate = &date\n\t\t}\n\t}\n\tif performerJSON.Rating != 0 {\n\t\tnewPerformer.Rating = &performerJSON.Rating\n\t}\n\tif performerJSON.DeathDate != \"\" {\n\t\tdate, err := models.ParseDate(performerJSON.DeathDate)\n\t\tif err == nil {\n\t\t\tnewPerformer.DeathDate = &date\n\t\t}\n\t}\n\n\tif performerJSON.Weight != 0 {\n\t\tnewPerformer.Weight = &performerJSON.Weight\n\t}\n\n\tif performerJSON.PenisLength != 0 {\n\t\tnewPerformer.PenisLength = &performerJSON.PenisLength\n\t}\n\n\tif performerJSON.Height != \"\" {\n\t\th, err := strconv.Atoi(performerJSON.Height)\n\t\tif err == nil {\n\t\t\tnewPerformer.Height = &h\n\t\t} else {\n\t\t\tlogger.Warnf(\"error parsing height %q: %v\", performerJSON.Height, err)\n\t\t}\n\t}\n\n\t// prefer explicit career_start/career_end, fall back to parsing legacy career_length\n\tif performerJSON.CareerStart != \"\" || performerJSON.CareerEnd != \"\" {\n\t\tcareerStart, err := models.ParseDate(performerJSON.CareerStart)\n\t\tif err == nil {\n\t\t\tnewPerformer.CareerStart = &careerStart\n\t\t}\n\t\tcareerEnd, err := models.ParseDate(performerJSON.CareerEnd)\n\t\tif err == nil {\n\t\t\tnewPerformer.CareerEnd = &careerEnd\n\t\t}\n\t} else if performerJSON.CareerLength != \"\" {\n\t\tstart, end, err := models.ParseYearRangeString(performerJSON.CareerLength)\n\t\tif err != nil {\n\t\t\treturn models.Performer{}, fmt.Errorf(\"invalid career_length %q: %w\", performerJSON.CareerLength, err)\n\t\t}\n\t\tnewPerformer.CareerStart = start\n\t\tnewPerformer.CareerEnd = end\n\t}\n\n\treturn newPerformer, nil\n}\n"
  },
  {
    "path": "pkg/performer/import_test.go",
    "content": "package performer\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"testing\"\n)\n\nconst invalidImage = \"aW1hZ2VCeXRlcw&&\"\n\nconst (\n\texistingPerformerID = 100\n\texistingTagID       = 105\n\terrTagsID           = 106\n\n\texistingPerformerName = \"existingPerformerName\"\n\tperformerNameErr      = \"performerNameErr\"\n\n\texistingTagName = \"existingTagName\"\n\texistingTagErr  = \"existingTagErr\"\n\tmissingTagName  = \"missingTagName\"\n)\n\nvar testCtx = context.Background()\n\nfunc TestImporterName(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Performer{\n\t\t\tName: performerName,\n\t\t},\n\t}\n\n\tassert.Equal(t, performerName, i.Name())\n}\n\nfunc TestImporterPreImport(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Performer{\n\t\t\tName:  performerName,\n\t\t\tImage: invalidImage,\n\t\t},\n\t}\n\n\terr := i.PreImport(testCtx)\n\n\tassert.NotNil(t, err)\n\n\ti.Input = *createFullJSONPerformer(performerName, image, true)\n\n\terr = i.PreImport(testCtx)\n\n\tassert.Nil(t, err)\n\texpectedPerformer := *createFullPerformer(0, performerName)\n\tassert.Equal(t, expectedPerformer, i.performer)\n\tassert.Equal(t, models.CustomFieldMap(customFields), i.customFields)\n}\n\nfunc TestImporterPreImportWithTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter:        db.Performer,\n\t\tTagWriter:           db.Tag,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Performer{\n\t\t\tTags: []string{\n\t\t\t\texistingTagName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagName}, false).Return([]*models.Tag{\n\t\t{\n\t\t\tID:   existingTagID,\n\t\t\tName: existingTagName,\n\t\t},\n\t}, nil).Once()\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingTagID, i.performer.TagIDs.List()[0])\n\n\ti.Input.Tags = []string{existingTagErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Performer,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Performer{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Run(func(args mock.Arguments) {\n\t\tt := args.Get(1).(*models.CreateTagInput)\n\t\tt.Tag.ID = existingTagID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingTagID, i.performer.TagIDs.List()[0])\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Performer,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Performer{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPostImport(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Performer,\n\t\tTagWriter:    db.Tag,\n\t\timageData:    imageBytes,\n\t}\n\n\tupdatePerformerImageErr := errors.New(\"UpdateImage error\")\n\n\tdb.Performer.On(\"UpdateImage\", testCtx, performerID, imageBytes).Return(nil).Once()\n\tdb.Performer.On(\"UpdateImage\", testCtx, errImageID, imageBytes).Return(updatePerformerImageErr).Once()\n\n\terr := i.PostImport(testCtx, performerID)\n\tassert.Nil(t, err)\n\n\terr = i.PostImport(testCtx, errImageID)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterFindExistingID(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Performer,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Performer{\n\t\t\tName: performerName,\n\t\t},\n\t}\n\n\tpp := 1\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\tperformerFilter := func(name string) *models.PerformerFilterType {\n\t\treturn &models.PerformerFilterType{\n\t\t\tName: &models.StringCriterionInput{\n\t\t\t\tValue:    name,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t}\n\t}\n\n\terrFindByNames := errors.New(\"FindByNames error\")\n\tdb.Performer.On(\"Query\", testCtx, performerFilter(performerName), findFilter).Return(nil, 0, nil).Once()\n\tdb.Performer.On(\"Query\", testCtx, performerFilter(existingPerformerName), findFilter).Return([]*models.Performer{\n\t\t{\n\t\t\tID: existingPerformerID,\n\t\t},\n\t}, 1, nil).Once()\n\tdb.Performer.On(\"Query\", testCtx, performerFilter(performerNameErr), findFilter).Return(nil, 0, errFindByNames).Once()\n\n\tid, err := i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.Nil(t, err)\n\n\ti.Input.Name = existingPerformerName\n\tid, err = i.FindExistingID(testCtx)\n\tassert.Equal(t, existingPerformerID, *id)\n\tassert.Nil(t, err)\n\n\ti.Input.Name = performerNameErr\n\tid, err = i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestCreate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tperformer := models.Performer{\n\t\tName: performerName,\n\t}\n\n\tperformerInput := models.CreatePerformerInput{\n\t\tPerformer: &performer,\n\t}\n\n\tperformerErr := models.Performer{\n\t\tName: performerNameErr,\n\t}\n\n\tperformerErrInput := models.CreatePerformerInput{\n\t\tPerformer: &performerErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.Performer,\n\t\tTagWriter:    db.Tag,\n\t\tperformer:    performer,\n\t}\n\n\terrCreate := errors.New(\"Create error\")\n\tdb.Performer.On(\"Create\", testCtx, &performerInput).Run(func(args mock.Arguments) {\n\t\targ := args.Get(1).(*models.CreatePerformerInput)\n\t\targ.ID = performerID\n\t}).Return(nil).Once()\n\tdb.Performer.On(\"Create\", testCtx, &performerErrInput).Return(errCreate).Once()\n\n\tid, err := i.Create(testCtx)\n\tassert.Equal(t, performerID, *id)\n\tassert.Nil(t, err)\n\n\ti.performer = performerErr\n\tid, err = i.Create(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestUpdate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tperformer := models.Performer{\n\t\tName: performerName,\n\t}\n\n\tperformerErr := models.Performer{\n\t\tName: performerNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.Performer,\n\t\tTagWriter:    db.Tag,\n\t\tperformer:    performer,\n\t}\n\n\terrUpdate := errors.New(\"Update error\")\n\n\t// id needs to be set for the mock input\n\tperformer.ID = performerID\n\tperformerInput := models.UpdatePerformerInput{\n\t\tPerformer: &performer,\n\t}\n\tdb.Performer.On(\"Update\", testCtx, &performerInput).Return(nil).Once()\n\n\terr := i.Update(testCtx, performerID)\n\tassert.Nil(t, err)\n\n\ti.performer = performerErr\n\n\t// need to set id separately\n\tperformerErr.ID = errImageID\n\tperformerErrInput := models.UpdatePerformerInput{\n\t\tPerformer: &performerErr,\n\t}\n\tdb.Performer.On(\"Update\", testCtx, &performerErrInput).Return(errUpdate).Once()\n\n\terr = i.Update(testCtx, errImageID)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImportCareerFields(t *testing.T) {\n\tstartYear, _ := models.ParseDate(\"2005\")\n\tendYear, _ := models.ParseDate(\"2015\")\n\n\t// explicit career_start/career_end should be used directly\n\tt.Run(\"explicit fields\", func(t *testing.T) {\n\t\tinput := jsonschema.Performer{\n\t\t\tName:        \"test\",\n\t\t\tCareerStart: startYear.String(),\n\t\t\tCareerEnd:   endYear.String(),\n\t\t}\n\n\t\tp, err := performerJSONToPerformer(input)\n\t\tassert.Nil(t, err)\n\t\tassert.Equal(t, &startYear, p.CareerStart)\n\t\tassert.Equal(t, &endYear, p.CareerEnd)\n\t})\n\n\t// explicit fields take priority over legacy career_length\n\tt.Run(\"explicit fields override legacy\", func(t *testing.T) {\n\t\tinput := jsonschema.Performer{\n\t\t\tName:         \"test\",\n\t\t\tCareerStart:  startYear.String(),\n\t\t\tCareerEnd:    endYear.String(),\n\t\t\tCareerLength: \"1990 - 1995\",\n\t\t}\n\n\t\tp, err := performerJSONToPerformer(input)\n\t\tassert.Nil(t, err)\n\t\tassert.Equal(t, &startYear, p.CareerStart)\n\t\tassert.Equal(t, &endYear, p.CareerEnd)\n\t})\n\n\t// legacy career_length should be parsed when explicit fields are absent\n\tt.Run(\"legacy career_length fallback\", func(t *testing.T) {\n\t\tinput := jsonschema.Performer{\n\t\t\tName:         \"test\",\n\t\t\tCareerLength: \"2005 - 2015\",\n\t\t}\n\n\t\tp, err := performerJSONToPerformer(input)\n\t\tassert.Nil(t, err)\n\t\tassert.Equal(t, &startYear, p.CareerStart)\n\t\tassert.Equal(t, &endYear, p.CareerEnd)\n\t})\n\n\t// legacy career_length with only start year\n\tt.Run(\"legacy career_length start only\", func(t *testing.T) {\n\t\tinput := jsonschema.Performer{\n\t\t\tName:         \"test\",\n\t\t\tCareerLength: \"2005 -\",\n\t\t}\n\n\t\tp, err := performerJSONToPerformer(input)\n\t\tassert.Nil(t, err)\n\t\tassert.Equal(t, &startYear, p.CareerStart)\n\t\tassert.Nil(t, p.CareerEnd)\n\t})\n\n\t// unparseable career_length should return an error\n\tt.Run(\"legacy career_length unparseable\", func(t *testing.T) {\n\t\tinput := jsonschema.Performer{\n\t\t\tName:         \"test\",\n\t\t\tCareerLength: \"not a year range\",\n\t\t}\n\n\t\t_, err := performerJSONToPerformer(input)\n\t\tassert.NotNil(t, err)\n\t})\n\n\t// no career fields at all\n\tt.Run(\"no career fields\", func(t *testing.T) {\n\t\tinput := jsonschema.Performer{\n\t\t\tName: \"test\",\n\t\t}\n\n\t\tp, err := performerJSONToPerformer(input)\n\t\tassert.Nil(t, err)\n\t\tassert.Nil(t, p.CareerStart)\n\t\tassert.Nil(t, p.CareerEnd)\n\t})\n}\n"
  },
  {
    "path": "pkg/performer/query.go",
    "content": "package performer\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc CountByStudioID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.PerformerFilterType{\n\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByGroupID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.PerformerFilterType{\n\t\tGroups: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByTagID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.PerformerFilterType{\n\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByAppearsWith(ctx context.Context, r models.PerformerQueryer, id int) (int, error) {\n\tfilter := &models.PerformerFilterType{\n\t\tPerformers: &models.MultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc ByAlias(ctx context.Context, r models.PerformerQueryer, alias string) ([]*models.Performer, error) {\n\tf := &models.PerformerFilterType{\n\t\tAliases: &models.StringCriterionInput{\n\t\t\tValue:    alias,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tret, count, err := r.Query(ctx, f, nil)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif count > 0 {\n\t\treturn ret, nil\n\t}\n\n\treturn nil, nil\n}\n"
  },
  {
    "path": "pkg/performer/url.go",
    "content": "package performer\n\nimport (\n\t\"regexp\"\n)\n\nvar (\n\ttwitterURLRE   = regexp.MustCompile(`^https?:\\/\\/(?:www\\.)?twitter\\.com\\/`)\n\tinstagramURLRE = regexp.MustCompile(`^https?:\\/\\/(?:www\\.)?instagram\\.com\\/`)\n)\n\nfunc IsTwitterURL(url string) bool {\n\treturn twitterURLRE.MatchString(url)\n}\n\nfunc IsInstagramURL(url string) bool {\n\treturn instagramURLRE.MatchString(url)\n}\n"
  },
  {
    "path": "pkg/performer/validate.go",
    "content": "package performer\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nvar (\n\tErrNameMissing = errors.New(\"performer name must not be blank\")\n)\n\ntype NotFoundError struct {\n\tid int\n}\n\nfunc (e *NotFoundError) Error() string {\n\treturn fmt.Sprintf(\"performer with id %d not found\", e.id)\n}\n\ntype NameExistsError struct {\n\tName           string\n\tDisambiguation string\n}\n\nfunc (e *NameExistsError) Error() string {\n\tif e.Disambiguation != \"\" {\n\t\treturn fmt.Sprintf(\"performer with name '%s' and disambiguation '%s' already exists\", e.Name, e.Disambiguation)\n\t}\n\treturn fmt.Sprintf(\"performer with name '%s' already exists\", e.Name)\n}\n\ntype DuplicateAliasError struct {\n\tAlias string\n}\n\nfunc (e *DuplicateAliasError) Error() string {\n\treturn fmt.Sprintf(\"performer contains duplicate alias '%s'\", e.Alias)\n}\n\ntype DeathDateError struct {\n\tBirthdate models.Date\n\tDeathDate models.Date\n}\n\nfunc (e *DeathDateError) Error() string {\n\treturn fmt.Sprintf(\"death date %s should be after birthdate %s\", e.DeathDate, e.Birthdate)\n}\n\nfunc ValidateCreate(ctx context.Context, performer models.Performer, qb models.PerformerReader) error {\n\tif err := ValidateName(ctx, performer.Name, performer.Disambiguation, qb); err != nil {\n\t\treturn err\n\t}\n\n\tif err := ValidateAliases(performer.Name, performer.Aliases); err != nil {\n\t\treturn err\n\t}\n\n\tif err := ValidateDeathDate(performer.Birthdate, performer.DeathDate); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc ValidateUpdate(ctx context.Context, id int, partial models.PerformerPartial, qb models.PerformerReader) error {\n\texisting, err := qb.Find(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif existing == nil {\n\t\treturn &NotFoundError{id}\n\t}\n\n\tif err := ValidateUpdateName(ctx, *existing, partial.Name, partial.Disambiguation, qb); err != nil {\n\t\treturn err\n\t}\n\n\tif err := existing.LoadAliases(ctx, qb); err != nil {\n\t\treturn err\n\t}\n\tif err := ValidateUpdateAliases(*existing, partial.Name, partial.Aliases); err != nil {\n\t\treturn err\n\t}\n\n\tif err := ValidateUpdateDeathDate(*existing, partial.Birthdate, partial.DeathDate); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc validateName(ctx context.Context, name string, disambig string, existingID *int, qb models.PerformerQueryer) error {\n\tperformerFilter := models.PerformerFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    name,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tmodifier := models.CriterionModifierIsNull\n\n\tif disambig != \"\" {\n\t\tmodifier = models.CriterionModifierEquals\n\t}\n\n\tperformerFilter.Disambiguation = &models.StringCriterionInput{\n\t\tValue:    disambig,\n\t\tModifier: modifier,\n\t}\n\n\tif existingID == nil {\n\t\t// creating: error if any existing performer matches\n\n\t\tpp := 1\n\t\tfindFilter := models.FindFilterType{\n\t\t\tPerPage: &pp,\n\t\t}\n\n\t\tcount, err := qb.QueryCount(ctx, &performerFilter, &findFilter)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif count > 0 {\n\t\t\treturn &NameExistsError{\n\t\t\t\tName:           name,\n\t\t\t\tDisambiguation: disambig,\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t} else {\n\t\t// updating: check for matches, but ignore self\n\n\t\tpp := 2\n\t\tfindFilter := models.FindFilterType{\n\t\t\tPerPage: &pp,\n\t\t}\n\n\t\tconflicts, _, err := qb.Query(ctx, &performerFilter, &findFilter)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(conflicts) > 0 {\n\t\t\t// valid if the only conflict is the existing performer\n\t\t\tif len(conflicts) > 1 || conflicts[0].ID != *existingID {\n\t\t\t\treturn &NameExistsError{\n\t\t\t\t\tName:           name,\n\t\t\t\t\tDisambiguation: disambig,\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n}\n\n// ValidateName returns an error if the performer name and disambiguation provided is used by another performer.\nfunc ValidateName(ctx context.Context, name string, disambig string, qb models.PerformerQueryer) error {\n\tif name == \"\" {\n\t\treturn ErrNameMissing\n\t}\n\n\treturn validateName(ctx, name, disambig, nil, qb)\n}\n\n// ValidateUpdateName performs the same check as ValidateName, but is used when modifying an existing performer.\nfunc ValidateUpdateName(ctx context.Context, existing models.Performer, name models.OptionalString, disambig models.OptionalString, qb models.PerformerQueryer) error {\n\t// if neither name nor disambig is set, don't check anything\n\tif !name.Set && !disambig.Set {\n\t\treturn nil\n\t}\n\n\tnewName := existing.Name\n\tif name.Set {\n\t\tnewName = name.Value\n\t}\n\n\tif newName == \"\" {\n\t\treturn ErrNameMissing\n\t}\n\n\tnewDisambig := existing.Disambiguation\n\tif disambig.Set {\n\t\tnewDisambig = disambig.Value\n\t}\n\n\treturn validateName(ctx, newName, newDisambig, &existing.ID, qb)\n}\n\nfunc ValidateAliases(name string, aliases models.RelatedStrings) error {\n\tif !aliases.Loaded() {\n\t\treturn nil\n\t}\n\n\tm := make(map[string]bool)\n\tnameL := strings.ToLower(name)\n\tm[nameL] = true\n\n\tfor _, alias := range aliases.List() {\n\t\taliasL := strings.ToLower(alias)\n\t\tif m[aliasL] {\n\t\t\treturn &DuplicateAliasError{alias}\n\t\t}\n\t\tm[aliasL] = true\n\t}\n\n\treturn nil\n}\n\nfunc ValidateUpdateAliases(existing models.Performer, name models.OptionalString, aliases *models.UpdateStrings) error {\n\t// if neither name nor aliases is set, don't check anything\n\tif !name.Set && aliases == nil {\n\t\treturn nil\n\t}\n\n\tnewName := existing.Name\n\tif name.Set {\n\t\tnewName = name.Value\n\t}\n\n\t// If aliases is nil, we're only changing the name - check existing aliases against new name\n\tif aliases == nil {\n\t\treturn ValidateAliases(newName, existing.Aliases)\n\t}\n\n\tnewAliases := aliases.Apply(existing.Aliases.List())\n\n\treturn ValidateAliases(newName, models.NewRelatedStrings(newAliases))\n}\n\n// ValidateDeathDate returns an error if the birthdate is after the death date.\nfunc ValidateDeathDate(birthdate *models.Date, deathDate *models.Date) error {\n\tif birthdate == nil || deathDate == nil {\n\t\treturn nil\n\t}\n\n\tif birthdate.After(*deathDate) {\n\t\treturn &DeathDateError{Birthdate: *birthdate, DeathDate: *deathDate}\n\t}\n\n\treturn nil\n}\n\n// ValidateUpdateDeathDate performs the same check as ValidateDeathDate, but is used when modifying an existing performer.\nfunc ValidateUpdateDeathDate(existing models.Performer, birthdate models.OptionalDate, deathDate models.OptionalDate) error {\n\t// if neither birthdate nor deathDate is set, don't check anything\n\tif !birthdate.Set && !deathDate.Set {\n\t\treturn nil\n\t}\n\n\tnewBirthdate := existing.Birthdate\n\tif birthdate.Set {\n\t\tnewBirthdate = birthdate.Ptr()\n\t}\n\n\tnewDeathDate := existing.DeathDate\n\tif deathDate.Set {\n\t\tnewDeathDate = deathDate.Ptr()\n\t}\n\n\treturn ValidateDeathDate(newBirthdate, newDeathDate)\n}\n"
  },
  {
    "path": "pkg/performer/validate_test.go",
    "content": "package performer\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc nameFilter(n string) *models.PerformerFilterType {\n\treturn &models.PerformerFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    n,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tDisambiguation: &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t},\n\t}\n}\n\nfunc disambigFilter(n string, d string) *models.PerformerFilterType {\n\treturn &models.PerformerFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    n,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tDisambiguation: &models.StringCriterionInput{\n\t\t\tValue:    d,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n}\n\nfunc TestValidateName(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tconst (\n\t\tname1       = \"name 1\"\n\t\tname2       = \"name 2\"\n\t\tdisambig    = \"disambiguation\"\n\t\tnewName     = \"new name\"\n\t\tnewDisambig = \"new disambiguation\"\n\t)\n\n\tpp := 1\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\tdb.Performer.On(\"QueryCount\", testCtx, nameFilter(name1), findFilter).Return(1, nil)\n\tdb.Performer.On(\"QueryCount\", testCtx, nameFilter(name2), findFilter).Return(1, nil)\n\tdb.Performer.On(\"QueryCount\", testCtx, disambigFilter(name2, disambig), findFilter).Return(1, nil)\n\tdb.Performer.On(\"QueryCount\", testCtx, mock.Anything, findFilter).Return(0, nil)\n\n\ttests := []struct {\n\t\ttName    string\n\t\tname     string\n\t\tdisambig string\n\t\twant     error\n\t}{\n\t\t{\"missing name\", \"\", newDisambig, ErrNameMissing},\n\t\t{\"new name\", newName, \"\", nil},\n\t\t{\"new name new disambig\", newName, newDisambig, nil},\n\t\t{\"new name existing disambig\", newName, disambig, nil},\n\t\t{\"existing name\", name1, \"\", &NameExistsError{name1, \"\"}},\n\t\t{\"existing name new disambig\", name1, newDisambig, nil},\n\t\t{\"existing name existing disambig\", name1, disambig, nil},\n\t\t{\"existing name and disambig\", name2, disambig, &NameExistsError{name2, disambig}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.tName, func(t *testing.T) {\n\t\t\tgot := ValidateName(testCtx, tt.name, tt.disambig, db.Performer)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestValidateUpdateName(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tconst (\n\t\tname1       = \"name 1\"\n\t\tname2       = \"name 2\"\n\t\tdisambig1   = \"disambiguation 1\"\n\t\tdisambig2   = \"disambiguation 2\"\n\t\tnewName     = \"new name\"\n\t\tnewDisambig = \"new disambiguation\"\n\t)\n\n\tosUnset := models.OptionalString{}\n\tosNull := models.OptionalString{Set: true, Null: true}\n\tosName1 := models.NewOptionalString(name1)\n\tosName2 := models.NewOptionalString(name2)\n\tosDisambig1 := models.NewOptionalString(disambig1)\n\tosDisambig2 := models.NewOptionalString(disambig2)\n\tosNewName := models.NewOptionalString(newName)\n\tosNewDisambig := models.NewOptionalString(newDisambig)\n\n\texisting1 := models.Performer{\n\t\tID:   1,\n\t\tName: name1,\n\t}\n\texisting2 := models.Performer{\n\t\tID:             2,\n\t\tName:           name2,\n\t\tDisambiguation: disambig1,\n\t}\n\texisting3 := models.Performer{\n\t\tID:             3,\n\t\tName:           name2,\n\t\tDisambiguation: disambig2,\n\t}\n\n\tpp := 2\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\tdb.Performer.On(\"Query\", testCtx, nameFilter(name1), findFilter).Return([]*models.Performer{&existing1}, 1, nil)\n\tdb.Performer.On(\"Query\", testCtx, nameFilter(name2), findFilter).Return([]*models.Performer{&existing2, &existing3}, 2, nil)\n\tdb.Performer.On(\"Query\", testCtx, disambigFilter(name2, disambig1), findFilter).Return([]*models.Performer{&existing2}, 1, nil)\n\tdb.Performer.On(\"Query\", testCtx, disambigFilter(name2, disambig2), findFilter).Return([]*models.Performer{&existing3}, 1, nil)\n\tdb.Performer.On(\"Query\", testCtx, mock.Anything, findFilter).Return(nil, 0, nil)\n\n\ttests := []struct {\n\t\ttName     string\n\t\tperformer models.Performer\n\t\tname      models.OptionalString\n\t\tdisambig  models.OptionalString\n\t\twant      error\n\t}{\n\t\t{\"missing name\", existing1, osNull, osUnset, ErrNameMissing},\n\t\t{\"same name\", existing3, osName2, osUnset, nil},\n\t\t{\"same disambig\", existing2, osUnset, osDisambig1, nil},\n\t\t{\"same name same disambig\", existing2, osName2, osDisambig1, nil},\n\t\t{\"new name\", existing1, osNewName, osUnset, nil},\n\t\t{\"new disambig\", existing1, osUnset, osNewDisambig, nil},\n\t\t{\"new name new disambig\", existing1, osNewName, osNewDisambig, nil},\n\t\t{\"remove disambig\", existing3, osUnset, osNull, &NameExistsError{name2, \"\"}},\n\t\t{\"existing name keep disambig\", existing3, osName1, osUnset, nil},\n\t\t{\"existing name remove disambig\", existing3, osName1, osNull, &NameExistsError{name1, \"\"}},\n\t\t{\"existing disambig\", existing2, osUnset, osDisambig2, &NameExistsError{name2, disambig2}},\n\t\t{\"existing name and disambig\", existing1, osName2, osDisambig1, &NameExistsError{name2, disambig1}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.tName, func(t *testing.T) {\n\t\t\tgot := ValidateUpdateName(testCtx, tt.performer, tt.name, tt.disambig, db.Performer)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestValidateAliases(t *testing.T) {\n\tconst (\n\t\tname1  = \"name 1\"\n\t\tname1U = \"NAME 1\"\n\t\tname2  = \"name 2\"\n\t\tname3  = \"name 3\"\n\t\tname4  = \"name 4\"\n\t)\n\n\ttests := []struct {\n\t\ttName   string\n\t\tname    string\n\t\taliases []string\n\t\twant    error\n\t}{\n\t\t{\"no aliases\", name1, nil, nil},\n\t\t{\"valid aliases\", name2, []string{name3, name4}, nil},\n\t\t{\"duplicate alias\", name1, []string{name2, name3, name2}, &DuplicateAliasError{name2}},\n\t\t{\"duplicate name\", name4, []string{name4, name3}, &DuplicateAliasError{name4}},\n\t\t{\"duplicate alias caps\", name2, []string{name1, name1U}, &DuplicateAliasError{name1U}},\n\t\t{\"duplicate name caps\", name1U, []string{name1}, &DuplicateAliasError{name1}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.tName, func(t *testing.T) {\n\t\t\tgot := ValidateAliases(tt.name, models.NewRelatedStrings(tt.aliases))\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestValidateUpdateAliases(t *testing.T) {\n\tconst (\n\t\tname1  = \"name 1\"\n\t\tname1U = \"NAME 1\"\n\t\tname2  = \"name 2\"\n\t\tname3  = \"name 3\"\n\t\tname4  = \"name 4\"\n\t)\n\n\texisting := models.Performer{\n\t\tName:    name1,\n\t\tAliases: models.NewRelatedStrings([]string{name2}),\n\t}\n\n\tosUnset := models.OptionalString{}\n\tos1 := models.NewOptionalString(name1)\n\tos2 := models.NewOptionalString(name2)\n\tos3 := models.NewOptionalString(name3)\n\tos4 := models.NewOptionalString(name4)\n\n\ttests := []struct {\n\t\ttName   string\n\t\tname    models.OptionalString\n\t\taliases []string\n\t\twant    error\n\t}{\n\t\t{\"both unset\", osUnset, nil, nil},\n\t\t{\"name conflicts with alias\", os2, nil, &DuplicateAliasError{name2}},\n\t\t{\"valid name set\", os3, nil, nil},\n\t\t{\"valid aliases empty\", os1, []string{}, nil},\n\t\t{\"alias matches name\", osUnset, []string{name1U}, &DuplicateAliasError{name1U}},\n\t\t{\"valid aliases set\", osUnset, []string{name3, name2}, nil},\n\t\t{\"alias matches new name\", os4, []string{name4}, &DuplicateAliasError{name4}},\n\t\t{\"valid both set\", os2, []string{name1}, nil},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.tName, func(t *testing.T) {\n\t\t\tvar aliases *models.UpdateStrings\n\t\t\tif tt.aliases != nil {\n\t\t\t\taliases = &models.UpdateStrings{\n\t\t\t\t\tValues: tt.aliases,\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t}\n\t\t\t}\n\t\t\tgot := ValidateUpdateAliases(existing, tt.name, aliases)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestValidateDeathDate(t *testing.T) {\n\tdate1, _ := models.ParseDate(\"2001-01-01\")\n\tdate2, _ := models.ParseDate(\"2002-01-01\")\n\tdate3, _ := models.ParseDate(\"2003-01-01\")\n\tdate4, _ := models.ParseDate(\"2004-01-01\")\n\n\ttests := []struct {\n\t\tname      string\n\t\tbirthdate *models.Date\n\t\tdeathdate *models.Date\n\t\twant      error\n\t}{\n\t\t{\"both nil\", nil, nil, nil},\n\t\t{\"birthdate nil\", nil, &date1, nil},\n\t\t{\"deathdate nil\", nil, &date2, nil},\n\t\t{\"valid\", &date3, &date4, nil},\n\t\t{\"invalid\", &date3, &date2, &DeathDateError{date3, date2}},\n\t\t{\"same date\", &date1, &date1, nil},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := ValidateDeathDate(tt.birthdate, tt.deathdate)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestValidateUpdateDeathDate(t *testing.T) {\n\tdate1, _ := models.ParseDate(\"2001-01-01\")\n\tdate2, _ := models.ParseDate(\"2002-01-01\")\n\tdate3, _ := models.ParseDate(\"2003-01-01\")\n\tdate4, _ := models.ParseDate(\"2004-01-01\")\n\n\texisting := models.Performer{\n\t\tBirthdate: &date2,\n\t\tDeathDate: &date3,\n\t}\n\n\todUnset := models.OptionalDate{}\n\todNull := models.OptionalDate{Set: true, Null: true}\n\tod1 := models.NewOptionalDate(date1)\n\tod2 := models.NewOptionalDate(date2)\n\tod3 := models.NewOptionalDate(date3)\n\tod4 := models.NewOptionalDate(date4)\n\n\ttests := []struct {\n\t\tname      string\n\t\tbirthdate models.OptionalDate\n\t\tdeathdate models.OptionalDate\n\t\twant      error\n\t}{\n\t\t{\"both unset\", odUnset, odUnset, nil},\n\t\t{\"invalid birthdate set\", od4, odUnset, &DeathDateError{date4, date3}},\n\t\t{\"valid birthdate set\", od1, odUnset, nil},\n\t\t{\"valid birthdate set null\", odNull, odUnset, nil},\n\t\t{\"invalid deathdate set\", odUnset, od1, &DeathDateError{date2, date1}},\n\t\t{\"valid deathdate set\", odUnset, od4, nil},\n\t\t{\"valid deathdate set null\", odUnset, odNull, nil},\n\t\t{\"invalid both set\", od3, od2, &DeathDateError{date3, date2}},\n\t\t{\"valid both set\", od2, od3, nil},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := ValidateUpdateDeathDate(existing, tt.birthdate, tt.deathdate)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/pkg/cache.go",
    "content": "package pkg\n\nimport (\n\t\"time\"\n)\n\ntype cacheEntry struct {\n\tlastModified time.Time\n\tdata         []RemotePackage\n}\n\ntype repositoryCache struct {\n\t// cache maps the URL to the last modified time and the data\n\tcache map[string]cacheEntry\n}\n\nfunc (c *repositoryCache) ensureCache() {\n\tif c.cache == nil {\n\t\tc.cache = make(map[string]cacheEntry)\n\t}\n}\n\nfunc (c *repositoryCache) lastModified(url string) *time.Time {\n\tif c == nil {\n\t\treturn nil\n\t}\n\n\tc.ensureCache()\n\te, found := c.cache[url]\n\n\tif !found {\n\t\treturn nil\n\t}\n\n\treturn &e.lastModified\n}\n\nfunc (c *repositoryCache) getPackageList(url string) []RemotePackage {\n\tc.ensureCache()\n\te, found := c.cache[url]\n\n\tif !found {\n\t\treturn nil\n\t}\n\n\treturn e.data\n}\n\nfunc (c *repositoryCache) cacheList(url string, lastModified time.Time, data []RemotePackage) {\n\tif c == nil {\n\t\treturn\n\t}\n\n\tc.ensureCache()\n\tc.cache[url] = cacheEntry{\n\t\tlastModified: lastModified,\n\t\tdata:         data,\n\t}\n}\n"
  },
  {
    "path": "pkg/pkg/manager.go",
    "content": "package pkg\n\nimport (\n\t\"archive/zip\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// SourcePathGetter gets the source path for a given package URL.\ntype SourcePathGetter interface {\n\t// GetAllSourcePaths gets all source paths.\n\tGetAllSourcePaths() []string\n\n\t// GetSourcePath gets the source path for the given package URL.\n\tGetSourcePath(srcURL string) string\n}\n\n// Manager manages the installation of paks.\ntype Manager struct {\n\tLocal             *Store\n\tPackagePathGetter SourcePathGetter\n\n\tClient *http.Client\n\n\tcache *repositoryCache\n}\n\nfunc (m *Manager) getCache() *repositoryCache {\n\tif m.cache == nil {\n\t\tm.cache = &repositoryCache{}\n\t}\n\n\treturn m.cache\n}\n\nfunc (m *Manager) remoteFromURL(path string) (*httpRepository, error) {\n\tu, err := url.Parse(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing path: %w\", err)\n\t}\n\n\treturn newHttpRepository(*u, m.Client, m.getCache()), nil\n}\n\nfunc (m *Manager) ListInstalled(ctx context.Context) (LocalPackageIndex, error) {\n\tpaths := m.PackagePathGetter.GetAllSourcePaths()\n\n\tvar installedList []Manifest\n\n\tfor _, p := range paths {\n\t\tstore := m.Local.sub(p)\n\n\t\tsrcList, err := store.List(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"listing local packages: %w\", err)\n\t\t}\n\n\t\tinstalledList = append(installedList, srcList...)\n\t}\n\n\treturn localPackageIndexFromList(installedList), nil\n}\n\nfunc (m *Manager) ListRemote(ctx context.Context, remoteURL string) (RemotePackageIndex, error) {\n\tr, err := m.remoteFromURL(remoteURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"creating remote repository: %w\", err)\n\t}\n\n\tlist, err := r.List(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"listing remote packages: %w\", err)\n\t}\n\n\t// add link to RemotePackage\n\tfor i := range list {\n\t\tlist[i].Repository = r\n\t}\n\n\tret := remotePackageIndexFromList(list)\n\n\treturn ret, nil\n}\n\nfunc (m *Manager) ListInstalledRemotes(ctx context.Context, installed LocalPackageIndex) (RemotePackageIndex, error) {\n\t// get remotes for all installed packages\n\tallRemoteList := make(RemotePackageIndex)\n\n\tremoteURLs := installed.remoteURLs()\n\tfor _, remoteURL := range remoteURLs {\n\t\tremoteList, err := m.ListRemote(ctx, remoteURL)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"error listing remote package %s: %v\", remoteURL, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tallRemoteList.merge(remoteList)\n\t}\n\n\treturn allRemoteList, nil\n}\n\nfunc (m *Manager) InstalledStatus(ctx context.Context) (PackageStatusIndex, error) {\n\t// get all installed packages\n\tinstalled, err := m.ListInstalled(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// get remotes for all installed packages\n\tallRemoteList, err := m.ListInstalledRemotes(ctx, installed)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := MakePackageStatusIndex(installed, allRemoteList)\n\n\treturn ret, nil\n}\n\nfunc (m *Manager) packageByID(ctx context.Context, spec models.PackageSpecInput) (*RemotePackage, error) {\n\tl, err := m.ListRemote(ctx, spec.SourceURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpkg, found := l[spec]\n\tif !found {\n\t\treturn nil, nil\n\t}\n\n\treturn &pkg, nil\n}\n\nfunc (m *Manager) getStore(remoteURL string) *Store {\n\tsrcPath := m.PackagePathGetter.GetSourcePath(remoteURL)\n\tstore := m.Local.sub(srcPath)\n\n\treturn store\n}\n\nfunc (m *Manager) Install(ctx context.Context, spec models.PackageSpecInput) error {\n\tremote, err := m.remoteFromURL(spec.SourceURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating remote repository: %w\", err)\n\t}\n\n\tpkg, err := m.packageByID(ctx, spec)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting remote package: %w\", err)\n\t}\n\n\tfromRemote, err := remote.GetPackageZip(ctx, *pkg)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting remote package: %w\", err)\n\t}\n\n\tdefer fromRemote.Close()\n\n\td, err := io.ReadAll(fromRemote)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading package data: %w\", err)\n\t}\n\n\tsha := fmt.Sprintf(\"%x\", sha256.Sum256(d))\n\tif sha != pkg.Sha256 {\n\t\treturn fmt.Errorf(\"package data (%s) does not match expected SHA256 (%s)\", sha, pkg.Sha256)\n\t}\n\n\tzr, err := zip.NewReader(bytes.NewReader(d), int64(len(d)))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading zip data: %w\", err)\n\t}\n\n\tstore := m.getStore(spec.SourceURL)\n\n\t// uninstall existing package if present\n\tif _, err := store.getManifest(ctx, pkg.ID); err == nil {\n\t\tif err := m.deletePackageFiles(ctx, store, pkg.ID); err != nil {\n\t\t\treturn fmt.Errorf(\"uninstalling existing package: %w\", err)\n\t\t}\n\t}\n\n\tif err := m.installPackage(*pkg, store, zr); err != nil {\n\t\treturn fmt.Errorf(\"installing package: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (m *Manager) installPackage(pkg RemotePackage, store *Store, zr *zip.Reader) error {\n\tmanifest := Manifest{\n\t\tID:             pkg.ID,\n\t\tName:           pkg.Name,\n\t\tMetadata:       pkg.Metadata,\n\t\tPackageVersion: pkg.PackageVersion,\n\t\tRepositoryURL:  pkg.Repository.Path(),\n\t}\n\n\tfor _, f := range zr.File {\n\t\tif f.FileInfo().IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\ti, err := f.Open()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfn := filepath.Clean(f.Name)\n\t\tif err := store.writeFile(pkg.ID, fn, f.Mode(), i); err != nil {\n\t\t\ti.Close()\n\t\t\treturn fmt.Errorf(\"writing file %q: %w\", fn, err)\n\t\t}\n\n\t\ti.Close()\n\t\tmanifest.Files = append(manifest.Files, fn)\n\t}\n\n\tif err := store.writeManifest(pkg.ID, manifest); err != nil {\n\t\treturn fmt.Errorf(\"writing manifest: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// Uninstall uninstalls the given package.\nfunc (m *Manager) Uninstall(ctx context.Context, spec models.PackageSpecInput) error {\n\tstore := m.getStore(spec.SourceURL)\n\n\tif err := m.deletePackageFiles(ctx, store, spec.ID); err != nil {\n\t\treturn fmt.Errorf(\"deleting local package: %w\", err)\n\t}\n\n\t// also delete the directory\n\t// ignore errors\n\t_ = store.deletePackageDir(spec.ID)\n\n\treturn nil\n}\n\nfunc (m *Manager) deletePackageFiles(ctx context.Context, store *Store, id string) error {\n\tmanifest, err := store.getManifest(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting manifest: %w\", err)\n\t}\n\n\tfor _, f := range manifest.Files {\n\t\tif err := store.deleteFile(id, f); err != nil {\n\t\t\t// ignore\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tif err := store.deleteManifest(id); err != nil {\n\t\treturn fmt.Errorf(\"deleting manifest: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/pkg/pkg.go",
    "content": "// Package pkg provides interfaces to interact with the package system used for plugins and scrapers.\npackage pkg\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\nconst (\n\t// TimeFormat is the format used for marshalling/unmarshalling time.Time.\n\t// Times are stored in UTC.\n\tTimeFormat = \"2006-01-02 15:04:05\"\n\n\t// timeFormatLegacy is the old format that may exist in some manifests.\n\ttimeFormatLegacy = \"2006-01-02 15:04:05 -0700\"\n)\n\n// Time is a wrapper around time.Time that allows for custom YAML marshalling/unmarshalling using TimeFormat.\ntype Time struct {\n\ttime.Time\n}\n\nfunc (t *Time) UnmarshalYAML(unmarshal func(interface{}) error) error {\n\tvar s string\n\tif err := unmarshal(&s); err != nil {\n\t\treturn err\n\t}\n\n\t// times are stored in UTC\n\tparsed, err := time.Parse(TimeFormat, s)\n\tif err != nil {\n\t\t// try to parse using the legacy format\n\t\tvar legacyErr error\n\t\tparsed, legacyErr = time.Parse(timeFormatLegacy, s)\n\n\t\tif legacyErr != nil {\n\t\t\t// if we can't parse using the legacy format, return the original error\n\t\t\treturn err\n\t\t}\n\n\t\t// convert timezoned time to UTC\n\t\tparsed = parsed.UTC()\n\t}\n\tt.Time = parsed\n\treturn nil\n}\n\nfunc (t Time) MarshalYAML() (interface{}, error) {\n\treturn t.Format(TimeFormat), nil\n}\n\ntype PackageMetadata map[string]interface{}\n\ntype PackageVersion struct {\n\tVersion string `yaml:\"version\"`\n\tDate    Time   `yaml:\"date\"`\n}\n\nfunc (v PackageVersion) Upgradable(o PackageVersion) bool {\n\treturn o.Date.After(v.Date.Time)\n}\n\nfunc (v PackageVersion) String() string {\n\tret := v.Version\n\tif !v.Date.IsZero() {\n\t\tdate := v.Date.Format(\"2006-01-02\")\n\t\tif ret != \"\" {\n\t\t\tret += fmt.Sprintf(\" (%s)\", date)\n\t\t} else {\n\t\t\tret = date\n\t\t}\n\t}\n\n\treturn ret\n}\n\ntype PackageLocation struct {\n\t// Path is the path to the package zip file.\n\t// This may be relative or absolute.\n\tPath   string `yaml:\"path\"`\n\tSha256 string `yaml:\"sha256\"`\n}\n\ntype RemotePackage struct {\n\tID              string           `yaml:\"id\"`\n\tName            string           `yaml:\"name\"`\n\tRepository      remoteRepository `yaml:\"-\"`\n\tRequires        []string         `yaml:\"requires\"`\n\tMetadata        PackageMetadata  `yaml:\"metadata\"`\n\tPackageVersion  `yaml:\",inline\"`\n\tPackageLocation `yaml:\",inline\"`\n}\n\nfunc (p RemotePackage) PackageSpecInput() models.PackageSpecInput {\n\treturn models.PackageSpecInput{\n\t\tID:        p.ID,\n\t\tSourceURL: p.Repository.Path(),\n\t}\n}\n\ntype Manifest struct {\n\tID             string          `yaml:\"id\"`\n\tName           string          `yaml:\"name\"`\n\tMetadata       PackageMetadata `yaml:\"metadata\"`\n\tPackageVersion `yaml:\",inline\"`\n\tRequires       []string `yaml:\"requires\"`\n\n\tRepositoryURL string   `yaml:\"source_repository\"`\n\tFiles         []string `yaml:\"files\"`\n}\n\nfunc (m Manifest) PackageSpecInput() models.PackageSpecInput {\n\treturn models.PackageSpecInput{\n\t\tID:        m.ID,\n\t\tSourceURL: m.RepositoryURL,\n\t}\n}\n\n// RemotePackageIndex is a map of package name to RemotePackage\ntype RemotePackageIndex map[models.PackageSpecInput]RemotePackage\n\nfunc (i RemotePackageIndex) merge(o RemotePackageIndex) {\n\tfor id, pkg := range o {\n\t\tif existing, found := i[id]; found {\n\t\t\tif existing.Date.After(pkg.Date.Time) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\ti[id] = pkg\n\t}\n}\n\nfunc remotePackageIndexFromList(packages []RemotePackage) RemotePackageIndex {\n\tindex := make(RemotePackageIndex)\n\tfor _, pkg := range packages {\n\t\tspecInput := pkg.PackageSpecInput()\n\n\t\t// if package already exists in map, choose the newest\n\t\tif existing, found := index[specInput]; found {\n\t\t\tif existing.Date.After(pkg.Date.Time) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tindex[specInput] = pkg\n\t}\n\treturn index\n}\n\n// LocalPackageIndex is a map of package name to RemotePackage\ntype LocalPackageIndex map[models.PackageSpecInput]Manifest\n\nfunc (i LocalPackageIndex) remoteURLs() []string {\n\tvar ret []string\n\n\tfor _, pkg := range i {\n\t\tret = sliceutil.AppendUnique(ret, pkg.RepositoryURL)\n\t}\n\n\treturn ret\n}\n\nfunc localPackageIndexFromList(packages []Manifest) LocalPackageIndex {\n\tindex := make(LocalPackageIndex)\n\tfor _, pkg := range packages {\n\t\tindex[pkg.PackageSpecInput()] = pkg\n\t}\n\treturn index\n}\n\ntype PackageStatus struct {\n\tLocal  *Manifest\n\tRemote *RemotePackage\n}\n\nfunc (s PackageStatus) Upgradable() bool {\n\tif s.Local == nil || s.Remote == nil {\n\t\treturn false\n\t}\n\n\treturn s.Local.Upgradable(s.Remote.PackageVersion)\n}\n\ntype PackageStatusIndex map[models.PackageSpecInput]PackageStatus\n\nfunc MakePackageStatusIndex(installed LocalPackageIndex, remote RemotePackageIndex) PackageStatusIndex {\n\ti := make(PackageStatusIndex)\n\n\tfor spec, pkg := range installed {\n\t\tpkgCopy := pkg\n\t\ts := PackageStatus{\n\t\t\tLocal: &pkgCopy,\n\t\t}\n\n\t\tif remotePkg, found := remote[spec]; found {\n\t\t\ts.Remote = &remotePkg\n\t\t}\n\n\t\ti[spec] = s\n\t}\n\n\treturn i\n}\n\nfunc (i PackageStatusIndex) Upgradable() []PackageStatus {\n\tvar ret []PackageStatus\n\n\tfor _, s := range i {\n\t\tif s.Upgradable() {\n\t\t\tret = append(ret, s)\n\t\t}\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/pkg/repository.go",
    "content": "package pkg\n\nimport (\n\t\"context\"\n\t\"io\"\n)\n\n// remoteRepository is a repository that can be used to get paks from.\ntype remoteRepository interface {\n\tRemotePackageLister\n\tRemotePackageGetter\n\tPath() string\n}\n\ntype RemotePackageLister interface {\n\t// List returns all specs in the repository.\n\tList(ctx context.Context) ([]RemotePackage, error)\n}\n\ntype RemotePackageGetter interface {\n\tGetPackageZip(ctx context.Context, pkg RemotePackage) (io.ReadCloser, error)\n}\n"
  },
  {
    "path": "pkg/pkg/repository_http.go",
    "content": "// Package http provides a repository implementation for HTTP.\npackage pkg\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"gopkg.in/yaml.v2\"\n)\n\n// httpRepository is a HTTP based repository.\n// It is configured with a package list URL. Packages are located from the Path field of the package.\n//\n// The index is cached for the duration of CacheTTL. The first request after the cache expires will cause the index to be reloaded.\ntype httpRepository struct {\n\tpackageListURL url.URL\n\tclient         *http.Client\n\n\tcache *repositoryCache\n}\n\n// newHttpRepository creates a new Repository. If client is nil then http.DefaultClient is used.\nfunc newHttpRepository(packageListURL url.URL, client *http.Client, cache *repositoryCache) *httpRepository {\n\tif client == nil {\n\t\tclient = http.DefaultClient\n\t}\n\treturn &httpRepository{\n\t\tpackageListURL: packageListURL,\n\t\tclient:         client,\n\t\tcache:          cache,\n\t}\n}\n\nfunc (r *httpRepository) Path() string {\n\treturn r.packageListURL.String()\n}\n\nfunc (r *httpRepository) List(ctx context.Context) ([]RemotePackage, error) {\n\tu := r.packageListURL\n\n\t// the package list URL may be file://, in which case we need to use the local file system\n\tvar (\n\t\tf       io.ReadCloser\n\t\tmodTime *time.Time\n\t\terr     error\n\t)\n\n\tisLocal := u.Scheme == \"file\"\n\n\tif isLocal {\n\t\tf, err = r.getLocalFile(ctx, u.Path)\n\t} else {\n\t\t// try to get the cached list first\n\t\tvar cachedList []RemotePackage\n\t\tcachedList, err = r.getCachedList(ctx, u)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get cached package list: %w\", err)\n\t\t}\n\n\t\tif cachedList != nil {\n\t\t\treturn cachedList, nil\n\t\t}\n\n\t\tf, modTime, err = r.getFile(ctx, u)\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get package list: %w\", err)\n\t}\n\n\tdefer f.Close()\n\n\tdata, err := io.ReadAll(f)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read package list: %w\", err)\n\t}\n\n\tvar index []RemotePackage\n\tif err := yaml.Unmarshal(data, &index); err != nil {\n\t\treturn nil, fmt.Errorf(\"reading package list: %w\", err)\n\t}\n\n\t// cache if not local file\n\tif !isLocal {\n\t\tr.cache.cacheList(u.String(), *modTime, index)\n\t}\n\n\treturn index, nil\n}\n\nfunc isURL(s string) bool {\n\tu, err := url.Parse(s)\n\treturn err == nil && u.Scheme != \"\" && (u.Scheme == \"file\" || u.Host != \"\")\n}\n\nfunc (r *httpRepository) resolvePath(p string) url.URL {\n\t// if the path can be resolved to a URL, then use that\n\tif isURL(p) {\n\t\t// isURL ensures URL is valid\n\t\tu, _ := url.Parse(p)\n\t\treturn *u\n\t}\n\n\t// otherwise, determine if the path is relative or absolute\n\t// if it's relative, then join it with the package list URL\n\tu := r.packageListURL\n\n\tif path.IsAbs(p) {\n\t\tu.Path = p\n\t} else {\n\t\tu.Path = path.Join(path.Dir(u.Path), p)\n\t}\n\n\treturn u\n}\n\nfunc (r *httpRepository) GetPackageZip(ctx context.Context, pkg RemotePackage) (io.ReadCloser, error) {\n\tp := pkg.Path\n\n\tu := r.resolvePath(p)\n\n\tvar (\n\t\tf   io.ReadCloser\n\t\terr error\n\t)\n\n\t// the package list URL may be file://, in which case we need to use the local file system\n\t// the package zip path may be a URL. A remotely hosted list may _not_ use local files.\n\tif u.Scheme == \"file\" {\n\t\tif r.packageListURL.Scheme != \"file\" {\n\t\t\treturn nil, fmt.Errorf(\"%s is invalid for a remotely hosted package list\", u.String())\n\t\t}\n\n\t\tf, err = r.getLocalFile(ctx, u.Path)\n\t} else {\n\t\tf, _, err = r.getFile(ctx, u)\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get package file: %w\", err)\n\t}\n\n\treturn f, nil\n}\n\n// getFileCached tries to get the list from the local cache.\n// If it is not found or is stale, then nil is returned.\nfunc (r *httpRepository) getCachedList(ctx context.Context, u url.URL) ([]RemotePackage, error) {\n\t// check if the file is in the cache first\n\tlocalModTime := r.cache.lastModified(u.String())\n\n\tif localModTime != nil {\n\t\t// get the update time of the file\n\t\treq, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil)\n\t\tif err != nil {\n\t\t\t// shouldn't happen\n\t\t\treturn nil, err\n\t\t}\n\n\t\tresp, err := r.client.Do(req)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get remote file: %w\", err)\n\t\t}\n\n\t\tif resp.StatusCode >= 400 {\n\t\t\treturn nil, fmt.Errorf(\"failed to get remote file: %s\", resp.Status)\n\t\t}\n\n\t\tlastModified := resp.Header.Get(\"Last-Modified\")\n\t\tif lastModified != \"\" {\n\t\t\tremoteModTime, _ := time.Parse(http.TimeFormat, lastModified)\n\n\t\t\tif !remoteModTime.After(*localModTime) {\n\t\t\t\tlogger.Debugf(\"cached version of %s is equal or newer than remote\", u.String())\n\t\t\t\treturn r.cache.getPackageList(u.String()), nil\n\t\t\t}\n\t\t}\n\n\t\tlogger.Debugf(\"cached version of %s is older than remote\", u.String())\n\t}\n\n\treturn nil, nil\n}\n\n// getFile gets the file from the remote server. Returns the file and the last modified time.\nfunc (r *httpRepository) getFile(ctx context.Context, u url.URL) (io.ReadCloser, *time.Time, error) {\n\tlogger.Debugf(\"fetching %s\", u.String())\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)\n\tif err != nil {\n\t\t// shouldn't happen\n\t\treturn nil, nil, err\n\t}\n\n\tresp, err := r.client.Do(req)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to get remote file: %w\", err)\n\t}\n\n\tif resp.StatusCode >= 400 {\n\t\treturn nil, nil, fmt.Errorf(\"failed to get remote file: %s\", resp.Status)\n\t}\n\n\tlastModified := resp.Header.Get(\"Last-Modified\")\n\tvar remoteModTime time.Time\n\tif lastModified != \"\" {\n\t\tremoteModTime, _ = time.Parse(http.TimeFormat, lastModified)\n\t}\n\n\treturn resp.Body, &remoteModTime, nil\n}\n\nfunc (r *httpRepository) getLocalFile(ctx context.Context, path string) (fs.File, error) {\n\tret, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get file %q: %w\", path, err)\n\t}\n\n\treturn ret, nil\n}\n\nvar _ = remoteRepository(&httpRepository{})\n"
  },
  {
    "path": "pkg/pkg/repository_http_test.go",
    "content": "// Package http provides a repository implementation for HTTP.\npackage pkg\n\nimport (\n\t\"net/url\"\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestHttpRepository_resolvePath(t *testing.T) {\n\tmustParse := func(s string) url.URL {\n\t\tu, err := url.Parse(s)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\treturn *u\n\t}\n\n\ttests := []struct {\n\t\tname           string\n\t\tpackageListURL url.URL\n\t\tp              string\n\t\twant           url.URL\n\t}{\n\t\t{\n\t\t\tname:           \"relative\",\n\t\t\tpackageListURL: mustParse(\"https://example.com/foo/packages.yaml\"),\n\t\t\tp:              \"bar\",\n\t\t\twant:           mustParse(\"https://example.com/foo/bar\"),\n\t\t},\n\t\t{\n\t\t\tname:           \"absolute\",\n\t\t\tpackageListURL: mustParse(\"https://example.com/foo/packages.yaml\"),\n\t\t\tp:              \"/bar\",\n\t\t\twant:           mustParse(\"https://example.com/bar\"),\n\t\t},\n\t\t{\n\t\t\tname:           \"different server\",\n\t\t\tpackageListURL: mustParse(\"https://example.com/foo/packages.yaml\"),\n\t\t\tp:              \"http://example.org/bar\",\n\t\t\twant:           mustParse(\"http://example.org/bar\"),\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tr := &httpRepository{\n\t\t\t\tpackageListURL: tt.packageListURL,\n\t\t\t}\n\t\t\tgot := r.resolvePath(tt.p)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"HttpRepository.resolvePath() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/pkg/store.go",
    "content": "package pkg\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"gopkg.in/yaml.v2\"\n)\n\n// ManifestFile is the default filename for the package manifest.\nconst ManifestFile = \"manifest\"\n\n// Store is a folder-based local repository.\n// Packages are installed in their own directory under BaseDir.\n// The package details are stored in a file named based on PackageFile.\ntype Store struct {\n\tBaseDir string\n\t// ManifestFile is the filename of the package file.\n\tManifestFile string\n}\n\n// sub returns a new Store with the given path appended to the BaseDir.\nfunc (r *Store) sub(path string) *Store {\n\tif path == \"\" || path == \".\" {\n\t\treturn r\n\t}\n\n\treturn &Store{\n\t\tBaseDir:      filepath.Join(r.BaseDir, path),\n\t\tManifestFile: r.ManifestFile,\n\t}\n}\n\nfunc (r *Store) List(ctx context.Context) ([]Manifest, error) {\n\te, err := os.ReadDir(r.BaseDir)\n\t// ignore if directory cannot be read\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\tvar ret []Manifest\n\n\tfor _, ee := range e {\n\t\tif !ee.IsDir() {\n\t\t\t// ignore non-directories\n\t\t\tcontinue\n\t\t}\n\n\t\tpkg, err := r.getManifest(ctx, ee.Name())\n\t\tif err != nil {\n\t\t\t// ignore if manifest does not exist\n\t\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, *pkg)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *Store) packageDir(id string) string {\n\treturn filepath.Join(r.BaseDir, id)\n}\n\nfunc (r *Store) manifestPath(id string) string {\n\treturn filepath.Join(r.packageDir(id), r.ManifestFile)\n}\n\nfunc (r *Store) getManifest(ctx context.Context, packageID string) (*Manifest, error) {\n\tpfp := r.manifestPath(packageID)\n\n\tdata, err := os.ReadFile(pfp)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading manifest file %q: %w\", pfp, err)\n\t}\n\n\tvar manifest Manifest\n\tif err := yaml.Unmarshal(data, &manifest); err != nil {\n\t\treturn nil, fmt.Errorf(\"reading manifest file %q: %w\", pfp, err)\n\t}\n\n\treturn &manifest, nil\n}\n\nfunc (r *Store) ensurePackageExists(packageID string) error {\n\t// ensure the manifest file exists\n\tif _, err := os.Stat(r.manifestPath(packageID)); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn fmt.Errorf(\"package %q does not exist\", packageID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *Store) writeFile(packageID string, name string, mode fs.FileMode, i io.Reader) error {\n\tfn := filepath.Join(r.packageDir(packageID), name)\n\n\tif err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil {\n\t\treturn fmt.Errorf(\"creating directory %v: %w\", fn, err)\n\t}\n\n\to, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer o.Close()\n\n\tif _, err := io.Copy(o, i); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (r *Store) writeManifest(packageID string, m Manifest) error {\n\tpfp := r.manifestPath(packageID)\n\tdata, err := yaml.Marshal(m)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"marshaling manifest: %w\", err)\n\t}\n\n\tif err := os.WriteFile(pfp, data, os.ModePerm); err != nil {\n\t\treturn fmt.Errorf(\"writing manifest file %q: %w\", pfp, err)\n\t}\n\n\treturn nil\n}\n\nfunc (r *Store) deleteFile(packageID string, name string) error {\n\t// ensure the package exists\n\tif err := r.ensurePackageExists(packageID); err != nil {\n\t\treturn err\n\t}\n\n\tpkgDir := r.packageDir(packageID)\n\tfp := filepath.Join(pkgDir, name)\n\n\treturn os.Remove(fp)\n}\n\nfunc (r *Store) deleteManifest(packageID string) error {\n\treturn r.deleteFile(packageID, r.ManifestFile)\n}\n\nfunc (r *Store) deletePackageDir(packageID string) error {\n\treturn os.Remove(r.packageDir(packageID))\n}\n"
  },
  {
    "path": "pkg/plugin/args.go",
    "content": "package plugin\n\ntype OperationInput map[string]interface{}\n\ntype PluginArgInput struct {\n\tKey   string            `json:\"key\"`\n\tValue *PluginValueInput `json:\"value\"`\n}\n\ntype PluginValueInput struct {\n\tStr *string             `json:\"str\"`\n\tI   *int                `json:\"i\"`\n\tB   *bool               `json:\"b\"`\n\tF   *float64            `json:\"f\"`\n\tO   []*PluginArgInput   `json:\"o\"`\n\tA   []*PluginValueInput `json:\"a\"`\n}\n\nfunc applyDefaultArgs(args OperationInput, defaultArgs map[string]string) {\n\tfor k, v := range defaultArgs {\n\t\t_, found := args[k]\n\t\tif !found {\n\t\t\targs[k] = v\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/plugin/common/doc.go",
    "content": "// Package common encapulates data structures and functions that will be used\n// by plugin executables and the plugin subsystem in the stash server.\npackage common\n"
  },
  {
    "path": "pkg/plugin/common/log/log.go",
    "content": "// Package log provides a number of logging utility functions for encoding and\n// decoding log messages between a stash server and a plugin instance.\n//\n// Log messages sent from a plugin instance are transmitted via stderr and are\n// encoded with a prefix consisting of special character SOH, then the log\n// level (one of t, d, i, w, e, or p - corresponding to trace, debug, info,\n// warning, error and progress levels respectively), then special character\n// STX.\n//\n// The Trace, Debug, Info, Warning, and Error methods, and their equivalent\n// formatted methods are intended for use by plugin instances to transmit log\n// messages. The Progress method is also intended for sending progress data.\n//\n// Conversely, LevelFromName and DetectLogLevel are intended for use by the\n// stash server.\npackage log\n\nimport (\n\t\"math\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// Level represents a logging level for plugin outputs.\ntype Level struct {\n\t*logger.PluginLogLevel\n}\n\n// Valid Level values.\nvar (\n\tTraceLevel = Level{\n\t\t&logger.TraceLevel,\n\t}\n\tDebugLevel = Level{\n\t\t&logger.DebugLevel,\n\t}\n\tInfoLevel = Level{\n\t\t&logger.InfoLevel,\n\t}\n\tWarningLevel = Level{\n\t\t&logger.WarningLevel,\n\t}\n\tErrorLevel = Level{\n\t\t&logger.ErrorLevel,\n\t}\n\tProgressLevel = Level{\n\t\t&logger.ProgressLevel,\n\t}\n\tNoneLevel = Level{\n\t\t&logger.NoneLevel,\n\t}\n)\n\n// Trace outputs a trace logging message to os.Stderr. Message is encoded with a\n// prefix that signifies to the server that it is a trace message.\nfunc Trace(args ...interface{}) {\n\tTraceLevel.Log(args...)\n}\n\n// Tracef is the equivalent of Printf outputting as a trace logging message.\nfunc Tracef(format string, args ...interface{}) {\n\tTraceLevel.Logf(format, args...)\n}\n\n// Debug outputs a debug logging message to os.Stderr. Message is encoded with a\n// prefix that signifies to the server that it is a debug message.\nfunc Debug(args ...interface{}) {\n\tDebugLevel.Log(args...)\n}\n\n// Debugf is the equivalent of Printf outputting as a debug logging message.\nfunc Debugf(format string, args ...interface{}) {\n\tDebugLevel.Logf(format, args...)\n}\n\n// Info outputs an info logging message to os.Stderr. Message is encoded with a\n// prefix that signifies to the server that it is an info message.\nfunc Info(args ...interface{}) {\n\tInfoLevel.Log(args...)\n}\n\n// Infof is the equivalent of Printf outputting as an info logging message.\nfunc Infof(format string, args ...interface{}) {\n\tInfoLevel.Logf(format, args...)\n}\n\n// Warn outputs a warning logging message to os.Stderr. Message is encoded with a\n// prefix that signifies to the server that it is a warning message.\nfunc Warn(args ...interface{}) {\n\tWarningLevel.Log(args...)\n}\n\n// Warnf is the equivalent of Printf outputting as a warning logging message.\nfunc Warnf(format string, args ...interface{}) {\n\tWarningLevel.Logf(format, args...)\n}\n\n// Error outputs an error logging message to os.Stderr. Message is encoded with a\n// prefix that signifies to the server that it is an error message.\nfunc Error(args ...interface{}) {\n\tErrorLevel.Log(args...)\n}\n\n// Errorf is the equivalent of Printf outputting as an error logging message.\nfunc Errorf(format string, args ...interface{}) {\n\tErrorLevel.Logf(format, args...)\n}\n\n// Progress logs the current progress value. The progress value should be\n// between 0 and 1.0 inclusively, with 1 representing that the task is\n// complete. Values outside of this range will be clamp to be within it.\nfunc Progress(progress float64) {\n\tprogress = math.Min(math.Max(0, progress), 1)\n\tProgressLevel.Log(progress)\n}\n\n// LevelFromName returns the Level that matches the provided name or nil if\n// the name does not match a valid value.\nfunc LevelFromName(name string) *Level {\n\tl := logger.PluginLogLevelFromName(name)\n\tif l != nil {\n\t\treturn &Level{\n\t\t\tl,\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/common/msg.go",
    "content": "package common\n\nimport \"net/http\"\n\nconst (\n\tHookContextKey = \"hookContext\"\n)\n\n// StashServerConnection represents the connection details needed for a\n// plugin instance to connect to its parent stash server.\ntype StashServerConnection struct {\n\t// http or https\n\tScheme string\n\tHost   string\n\tPort   int\n\n\t// Cookie for authentication purposes\n\tSessionCookie *http.Cookie\n\n\t// Dir specifies the directory containing the stash server's configuration\n\t// file.\n\tDir string\n\n\t// PluginDir specifies the directory containing the plugin configuration\n\t// file.\n\tPluginDir string\n}\n\n// PluginArgValue represents a single value parameter for plugin operations.\ntype PluginArgValue interface{}\n\n// ArgsMap is a map of argument key to value.\ntype ArgsMap map[string]PluginArgValue\n\n// String returns the string field or an empty string if the string field is\n// nil\nfunc (m ArgsMap) String(key string) string {\n\tv, found := m[key]\n\tvar ret string\n\tif !found {\n\t\treturn ret\n\t}\n\tret, _ = v.(string)\n\treturn ret\n}\n\n// Int returns the int field or 0 if the int field is nil\nfunc (m ArgsMap) Int(key string) int {\n\tv, found := m[key]\n\tvar ret int\n\tif !found {\n\t\treturn ret\n\t}\n\tret, _ = v.(int)\n\treturn ret\n}\n\n// Bool returns the boolean field or false if the boolean field is nil\nfunc (m ArgsMap) Bool(key string) bool {\n\tv, found := m[key]\n\tvar ret bool\n\tif !found {\n\t\treturn ret\n\t}\n\tret, _ = v.(bool)\n\treturn ret\n}\n\n// Float returns the float field or 0 if the float field is nil\nfunc (m ArgsMap) Float(key string) float64 {\n\tv, found := m[key]\n\tvar ret float64\n\tif !found {\n\t\treturn ret\n\t}\n\tret, _ = v.(float64)\n\treturn ret\n}\n\nfunc (m ArgsMap) ToMap() map[string]interface{} {\n\tret := make(map[string]interface{})\n\tfor k, v := range m {\n\t\tret[k] = v\n\t}\n\treturn ret\n}\n\n// PluginInput is the data structure that is sent to plugin instances when they\n// are spawned.\ntype PluginInput struct {\n\t// Server details to connect to the stash server.\n\tServerConnection StashServerConnection `json:\"server_connection\"`\n\n\t// Arguments to the plugin operation.\n\tArgs ArgsMap `json:\"args\"`\n}\n\n// PluginOutput is the data structure that is expected to be output by plugin\n// processes when execution has concluded. It is expected that this data will\n// be encoded as JSON.\ntype PluginOutput struct {\n\tError  *string     `json:\"error\"`\n\tOutput interface{} `json:\"output\"`\n}\n\n// SetError is a convenience method that sets the Error field based on the\n// provided error.\nfunc (o *PluginOutput) SetError(err error) {\n\terrStr := err.Error()\n\to.Error = &errStr\n}\n\n// HookContext is passed as a PluginArgValue and indicates what hook triggered\n// this plugin task.\ntype HookContext struct {\n\tID          int         `json:\"id,omitempty\"`\n\tType        string      `json:\"type\"`\n\tInput       interface{} `json:\"input\"`\n\tInputFields []string    `json:\"inputFields,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/plugin/common/rpc.go",
    "content": "package common\n\nimport (\n\t\"net/rpc/jsonrpc\"\n\n\t\"github.com/natefinch/pie\"\n)\n\n// RPCRunner is the interface that RPC plugins are expected to fulfil.\ntype RPCRunner interface {\n\t// Perform the operation, using the provided input and populating the\n\t// output object.\n\tRun(input PluginInput, output *PluginOutput) error\n\n\t// Stop any running operations, if possible. No input is sent and any\n\t// output is ignored.\n\tStop(input struct{}, output *bool) error\n}\n\n// ServePlugin is used by plugin instances to serve the plugin via RPC, using\n// the provided RPCRunner interface.\nfunc ServePlugin(iface RPCRunner) error {\n\tp := pie.NewProvider()\n\tif err := p.RegisterName(\"RPCRunner\", iface); err != nil {\n\t\treturn err\n\t}\n\n\tp.ServeCodec(jsonrpc.NewServerCodec)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/config.go",
    "content": "package plugin\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/python\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"gopkg.in/yaml.v2\"\n)\n\n// Config describes the configuration for a single plugin.\ntype Config struct {\n\tid string\n\n\t// path to the configuration file\n\tpath string\n\n\t// The name of the plugin. This will be displayed in the UI.\n\tName string `yaml:\"name\"`\n\n\t// An optional description of what the plugin does.\n\tDescription *string `yaml:\"description\"`\n\n\t// An optional URL for the plugin.\n\tURL *string `yaml:\"url\"`\n\n\t// An optional version string.\n\tVersion *string `yaml:\"version\"`\n\n\t// The communication interface used when communicating with the spawned\n\t// plugin process. Defaults to 'raw' if not provided.\n\tInterface interfaceEnum `yaml:\"interface\"`\n\n\t// The command to execute for the operations in this plugin. The first\n\t// element should be the program name, and subsequent elements are passed\n\t// as arguments.\n\t//\n\t// Note: the execution process will search the path for the program,\n\t// then will attempt to find the program in the plugins\n\t// directory. The exe extension is not necessary on Windows platforms.\n\t// The current working directory is set to that of the stash process.\n\tExec []string `yaml:\"exec,flow\"`\n\n\t// The default log level to output the plugin process's stderr stream.\n\t// Only used if the plugin does not encode its output using log level\n\t// control characters.\n\t// See package common/log for valid values.\n\t// If left unset, defaults to log.ErrorLevel.\n\tPluginErrLogLevel string `yaml:\"errLog\"`\n\n\t// The task configurations for tasks provided by this plugin.\n\tTasks []*OperationConfig `yaml:\"tasks\"`\n\n\t// The hooks configurations for hooks registered by this plugin.\n\tHooks []*HookConfig `yaml:\"hooks\"`\n\n\t// Javascript files that will be injected into the stash UI.\n\tUI UIConfig `yaml:\"ui\"`\n\n\t// Settings that will be used to configure the plugin.\n\tSettings map[string]SettingConfig `yaml:\"settings\"`\n}\n\ntype PluginCSP struct {\n\tScriptSrc  []string `json:\"script-src\" yaml:\"script-src\"`\n\tStyleSrc   []string `json:\"style-src\" yaml:\"style-src\"`\n\tConnectSrc []string `json:\"connect-src\" yaml:\"connect-src\"`\n}\n\ntype UIConfig struct {\n\t// Requires is a list of plugin IDs that this plugin depends on.\n\t// These plugins will be loaded before this plugin.\n\tRequires []string `yaml:\"requires\"`\n\n\t// Content Security Policy configuration for the plugin.\n\tCSP PluginCSP `yaml:\"csp\"`\n\n\t// Javascript files that will be injected into the stash UI.\n\t// These may be URLs or paths to files relative to the plugin configuration file.\n\tJavascript []string `yaml:\"javascript\"`\n\n\t// CSS files that will be injected into the stash UI.\n\t// These may be URLs or paths to files relative to the plugin configuration file.\n\tCSS []string `yaml:\"css\"`\n\n\t// Assets is a map of URL prefixes to hosted directories.\n\t// This allows plugins to serve static assets from a URL path.\n\t// Plugin assets are exposed via the /plugin/{pluginId}/assets path.\n\t// For example, if the plugin configuration file contains:\n\t// /foo: bar\n\t// /bar: baz\n\t// /: root\n\t// Then the following requests will be mapped to the following files:\n\t// /plugin/{pluginId}/assets/foo/file.txt -> {pluginDir}/foo/file.txt\n\t// /plugin/{pluginId}/assets/bar/file.txt -> {pluginDir}/baz/file.txt\n\t// /plugin/{pluginId}/assets/file.txt -> {pluginDir}/root/file.txt\n\tAssets utils.URLMap `yaml:\"assets\"`\n}\n\nfunc isURL(s string) bool {\n\treturn strings.HasPrefix(s, \"http://\") || strings.HasPrefix(s, \"https://\")\n}\n\nfunc (c UIConfig) getCSSFiles(parent Config) []string {\n\tvar ret []string\n\tfor _, v := range c.CSS {\n\t\tif !isURL(v) {\n\t\t\tret = append(ret, filepath.Join(parent.getConfigPath(), v))\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (c UIConfig) getExternalCSS() []string {\n\tvar ret []string\n\tfor _, v := range c.CSS {\n\t\tif isURL(v) {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (c UIConfig) getJavascriptFiles(parent Config) []string {\n\tvar ret []string\n\tfor _, v := range c.Javascript {\n\t\tif !isURL(v) {\n\t\t\tret = append(ret, filepath.Join(parent.getConfigPath(), v))\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (c UIConfig) getExternalScripts() []string {\n\tvar ret []string\n\tfor _, v := range c.Javascript {\n\t\tif isURL(v) {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\n\treturn ret\n}\n\ntype SettingConfig struct {\n\t// defaults to string\n\tType PluginSettingTypeEnum `yaml:\"type\"`\n\t// defaults to key name\n\tDisplayName string `yaml:\"displayName\"`\n\tDescription string `yaml:\"description\"`\n}\n\nfunc (c Config) getPluginTasks(includePlugin bool) []*PluginTask {\n\tvar ret []*PluginTask\n\n\tfor _, o := range c.Tasks {\n\t\ttask := &PluginTask{\n\t\t\tName:        o.Name,\n\t\t\tDescription: &o.Description,\n\t\t}\n\n\t\tif includePlugin {\n\t\t\ttask.Plugin = c.toPlugin()\n\t\t}\n\t\tret = append(ret, task)\n\t}\n\n\treturn ret\n}\n\nfunc (c Config) getPluginHooks(includePlugin bool) []*PluginHook {\n\tvar ret []*PluginHook\n\n\tfor _, o := range c.Hooks {\n\t\thook := &PluginHook{\n\t\t\tName:        o.Name,\n\t\t\tDescription: &o.Description,\n\t\t\tHooks:       convertHooks(o.TriggeredBy),\n\t\t}\n\n\t\tif includePlugin {\n\t\t\thook.Plugin = c.toPlugin()\n\t\t}\n\t\tret = append(ret, hook)\n\t}\n\n\treturn ret\n}\n\nfunc convertHooks(hooks []hook.TriggerEnum) []string {\n\tvar ret []string\n\tfor _, h := range hooks {\n\t\tret = append(ret, h.String())\n\t}\n\n\treturn ret\n}\n\nfunc (c Config) getPluginSettings() []PluginSetting {\n\tret := []PluginSetting{}\n\n\tvar keys []string\n\tfor k := range c.Settings {\n\t\tkeys = append(keys, k)\n\t}\n\n\tsort.Strings(keys)\n\n\tfor _, k := range keys {\n\t\to := c.Settings[k]\n\t\tt := o.Type\n\t\tif t == \"\" {\n\t\t\tt = PluginSettingTypeEnumString\n\t\t}\n\n\t\ts := PluginSetting{\n\t\t\tName:        k,\n\t\t\tDisplayName: o.DisplayName,\n\t\t\tDescription: o.Description,\n\t\t\tType:        t,\n\t\t}\n\n\t\tret = append(ret, s)\n\t}\n\n\treturn ret\n}\n\nfunc (c Config) getName() string {\n\tif c.Name != \"\" {\n\t\treturn c.Name\n\t}\n\n\treturn c.id\n}\n\nfunc (c Config) toPlugin() *Plugin {\n\treturn &Plugin{\n\t\tID:          c.id,\n\t\tName:        c.getName(),\n\t\tDescription: c.Description,\n\t\tURL:         c.URL,\n\t\tVersion:     c.Version,\n\t\tTasks:       c.getPluginTasks(false),\n\t\tHooks:       c.getPluginHooks(false),\n\t\tUI: PluginUI{\n\t\t\tRequires:       c.UI.Requires,\n\t\t\tExternalScript: c.UI.getExternalScripts(),\n\t\t\tExternalCSS:    c.UI.getExternalCSS(),\n\t\t\tJavascript:     c.UI.getJavascriptFiles(c),\n\t\t\tCSS:            c.UI.getCSSFiles(c),\n\t\t\tCSP:            c.UI.CSP,\n\t\t\tAssets:         c.UI.Assets,\n\t\t},\n\t\tSettings:   c.getPluginSettings(),\n\t\tConfigPath: c.path,\n\t}\n}\n\nfunc (c Config) getTask(name string) *OperationConfig {\n\tfor _, o := range c.Tasks {\n\t\tif o.Name == name {\n\t\t\treturn o\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c Config) getHooks(hookType hook.TriggerEnum) []*HookConfig {\n\tvar ret []*HookConfig\n\tfor _, h := range c.Hooks {\n\t\tfor _, t := range h.TriggeredBy {\n\t\t\tif hookType == t {\n\t\t\t\tret = append(ret, h)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (c Config) getConfigPath() string {\n\treturn filepath.Dir(c.path)\n}\n\nfunc (c Config) getExecCommand(task *OperationConfig) []string {\n\t// #4859 - don't modify the original exec command\n\tret := append([]string{}, c.Exec...)\n\n\tif task != nil {\n\t\tret = append(ret, task.ExecArgs...)\n\t}\n\n\t// #4859 - don't use the plugin path in the exec command if it is a python command\n\tif len(ret) > 0 && !python.IsPythonCommand(ret[0]) {\n\t\t_, err := exec.LookPath(ret[0])\n\t\tif err != nil {\n\t\t\t// change command to run from the plugin path\n\t\t\tpluginPath := filepath.Dir(c.path)\n\t\t\tret[0] = filepath.Join(pluginPath, ret[0])\n\t\t}\n\t}\n\n\t// replace {pluginDir} in arguments with that of the plugin directory\n\tdir := c.getConfigPath()\n\tfor i, arg := range ret {\n\t\tif i == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tret[i] = strings.ReplaceAll(arg, \"{pluginDir}\", dir)\n\t}\n\n\treturn ret\n}\n\nfunc (c Config) valid() error {\n\tif c.Interface != \"\" && !c.Interface.Valid() {\n\t\treturn fmt.Errorf(\"invalid interface type %s\", c.Interface)\n\t}\n\n\tfor k, o := range c.Settings {\n\t\tif o.Type != \"\" && !o.Type.IsValid() {\n\t\t\treturn fmt.Errorf(\"invalid type %s for setting %s\", k, o.Type)\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype interfaceEnum string\n\n// Valid interfaceEnum values\nconst (\n\t// InterfaceEnumRPC indicates that the plugin uses the RPCRunner interface\n\t// declared in common/rpc.go.\n\tInterfaceEnumRPC interfaceEnum = \"rpc\"\n\n\t// InterfaceEnumRaw interfaces will have the common.PluginInput encoded as\n\t// json (but may be ignored), and output will be decoded as\n\t// common.PluginOutput. If this decoding fails, then the raw output will be\n\t// treated as the output.\n\tInterfaceEnumRaw interfaceEnum = \"raw\"\n\n\tInterfaceEnumJS interfaceEnum = \"js\"\n)\n\nfunc (i interfaceEnum) Valid() bool {\n\treturn i == InterfaceEnumRPC || i == InterfaceEnumRaw || i == InterfaceEnumJS\n}\n\nfunc (i *interfaceEnum) getTaskBuilder() taskBuilder {\n\tif *i == InterfaceEnumRaw {\n\t\treturn &rawTaskBuilder{}\n\t}\n\n\tif *i == InterfaceEnumRPC {\n\t\treturn &rpcTaskBuilder{}\n\t}\n\n\tif *i == InterfaceEnumJS {\n\t\treturn &jsTaskBuilder{}\n\t}\n\n\t// shouldn't happen\n\treturn nil\n}\n\n// OperationConfig describes the configuration for a single plugin operation\n// provided by a plugin.\ntype OperationConfig struct {\n\t// Used to identify the operation. Must be unique within a plugin\n\t// configuration. This name is shown in the button for the operation\n\t// in the UI.\n\tName string `yaml:\"name\"`\n\n\t// A short description of the operation. This description is shown below\n\t// the button in the UI.\n\tDescription string `yaml:\"description\"`\n\n\t// A list of arguments that will be appended to the plugin's Exec arguments\n\t// when executing this operation.\n\tExecArgs []string `yaml:\"execArgs\"`\n\n\t// A map of argument keys to their default values. The default value is\n\t// used if the applicable argument is not provided during the operation\n\t// call.\n\tDefaultArgs map[string]string `yaml:\"defaultArgs\"`\n}\n\ntype HookConfig struct {\n\tOperationConfig `yaml:\",inline\"`\n\n\t// A list of stash operations that will be used to trigger this hook operation.\n\tTriggeredBy []hook.TriggerEnum `yaml:\"triggeredBy\"`\n}\n\nfunc loadPluginFromYAML(reader io.Reader) (*Config, error) {\n\tret := &Config{}\n\n\tparser := yaml.NewDecoder(reader)\n\tparser.SetStrict(true)\n\terr := parser.Decode(&ret)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif ret.Interface == \"\" {\n\t\tret.Interface = InterfaceEnumRaw\n\t}\n\n\tif err := ret.valid(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc loadPluginFromYAMLFile(path string) (*Config, error) {\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\tret, err := loadPluginFromYAML(file)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// set id to the filename\n\tid := filepath.Base(path)\n\tret.id = id[:strings.LastIndex(id, \".\")]\n\tret.path = path\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/plugin/convert.go",
    "content": "package plugin\n\nimport (\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n)\n\nfunc toPluginArgs(args OperationInput) common.ArgsMap {\n\tret := make(common.ArgsMap)\n\tfor k, a := range args {\n\t\tret[k] = common.PluginArgValue(a)\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/plugin/examples/README.md",
    "content": "# Building\n\nFrom the base stash source directory:\n```\ngo build -tags=plugin_example -o plugin_goraw.exe ./pkg/plugin/examples/goraw/...\ngo build -tags=plugin_example -o plugin_gorpc.exe ./pkg/plugin/examples/gorpc/...\n```\n\nPlace the resulting binaries together with the yml files in the `plugins` subdirectory of your stash directory."
  },
  {
    "path": "pkg/plugin/examples/common/graphql.go",
    "content": "//go:build plugin_example\n// +build plugin_example\n\npackage common\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\tgraphql \"github.com/hasura/go-graphql-client\"\n\t\"github.com/stashapp/stash/pkg/plugin/common/log\"\n)\n\nconst tagName = \"Hawwwwt\"\n\n// graphql inputs and returns\ntype TagCreate struct {\n\tID graphql.ID `graphql:\"id\"`\n}\n\ntype TagCreateInput struct {\n\tName graphql.String `graphql:\"name\" json:\"name\"`\n}\n\ntype TagDestroyInput struct {\n\tID graphql.ID `graphql:\"id\" json:\"id\"`\n}\n\ntype FindScenesResultType struct {\n\tCount           graphql.Int\n\tDurationSeconds graphql.Float `graphql:\"duration\" json:\"duration\"`\n\tFilesizeBytes   graphql.Float `graphql:\"filesize\" json:\"filesize\"`\n\tScenes          []Scene\n}\n\ntype Tag struct {\n\tID   graphql.ID     `graphql:\"id\"`\n\tName graphql.String `graphql:\"name\"`\n}\n\ntype Scene struct {\n\tID   graphql.ID\n\tTags []Tag\n}\n\nfunc (s Scene) getTagIds() []graphql.ID {\n\tret := []graphql.ID{}\n\n\tfor _, t := range s.Tags {\n\t\tret = append(ret, t.ID)\n\t}\n\n\treturn ret\n}\n\ntype FindFilterType struct {\n\tPerPage *graphql.Int    `graphql:\"per_page\" json:\"per_page\"`\n\tSort    *graphql.String `graphql:\"sort\" json:\"sort\"`\n}\n\ntype SceneUpdate struct {\n\tID graphql.ID `graphql:\"id\"`\n}\n\ntype SceneUpdateInput struct {\n\tID     graphql.ID   `graphql:\"id\" json:\"id\"`\n\tTagIds []graphql.ID `graphql:\"tag_ids\" json:\"tag_ids\"`\n}\n\nfunc getTagID(ctx context.Context, client *graphql.Client, create bool) (*graphql.ID, error) {\n\tlog.Info(\"Checking if tag exists already\")\n\n\t// see if tag exists already\n\tvar q struct {\n\t\tAllTags []Tag `graphql:\"allTags\"`\n\t}\n\n\terr := client.Query(ctx, &q, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"Error getting tags: %s\\n\", err.Error())\n\t}\n\n\tfor _, t := range q.AllTags {\n\t\tif t.Name == tagName {\n\t\t\tid := t.ID\n\t\t\treturn &id, nil\n\t\t}\n\t}\n\n\tif !create {\n\t\tlog.Info(\"Not found and not creating\")\n\t\treturn nil, nil\n\t}\n\n\t// create the tag\n\tvar m struct {\n\t\tTagCreate TagCreate `graphql:\"tagCreate(input: $s)\"`\n\t}\n\n\tinput := TagCreateInput{\n\t\tName: tagName,\n\t}\n\n\tvars := map[string]interface{}{\n\t\t\"s\": input,\n\t}\n\n\tlog.Info(\"Creating new tag\")\n\n\terr = client.Mutate(ctx, &m, vars)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"Error mutating scene: %s\\n\", err.Error())\n\t}\n\n\treturn &m.TagCreate.ID, nil\n}\n\nfunc findRandomScene(ctx context.Context, client *graphql.Client) (*Scene, error) {\n\t// get a random scene\n\tvar q struct {\n\t\tFindScenes FindScenesResultType `graphql:\"findScenes(filter: $c)\"`\n\t}\n\n\tpp := graphql.Int(1)\n\tsort := graphql.String(\"random\")\n\tfilterInput := &FindFilterType{\n\t\tPerPage: &pp,\n\t\tSort:    &sort,\n\t}\n\n\tvars := map[string]interface{}{\n\t\t\"c\": filterInput,\n\t}\n\n\tlog.Info(\"Finding a random scene\")\n\terr := client.Query(ctx, &q, vars)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"Error getting random scene: %s\\n\", err.Error())\n\t}\n\n\tif q.FindScenes.Count == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn &q.FindScenes.Scenes[0], nil\n}\n\nfunc addTagId(tagIds []graphql.ID, tagId graphql.ID) []graphql.ID {\n\tfor _, t := range tagIds {\n\t\tif t == tagId {\n\t\t\treturn tagIds\n\t\t}\n\t}\n\n\ttagIds = append(tagIds, tagId)\n\treturn tagIds\n}\n\nfunc AddTag(ctx context.Context, client *graphql.Client) error {\n\ttagID, err := getTagID(ctx, client, true)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tscene, err := findRandomScene(ctx, client)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif scene == nil {\n\t\treturn errors.New(\"no scenes to add tag to\")\n\t}\n\n\tvar m struct {\n\t\tSceneUpdate SceneUpdate `graphql:\"sceneUpdate(input: $s)\"`\n\t}\n\n\tinput := SceneUpdateInput{\n\t\tID:     scene.ID,\n\t\tTagIds: scene.getTagIds(),\n\t}\n\n\tinput.TagIds = addTagId(input.TagIds, *tagID)\n\n\tvars := map[string]interface{}{\n\t\t\"s\": input,\n\t}\n\n\tlog.Infof(\"Adding tag to scene %v\", scene.ID)\n\terr = client.Mutate(ctx, &m, vars)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Error mutating scene: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc RemoveTag(ctx context.Context, client *graphql.Client) error {\n\ttagID, err := getTagID(ctx, client, false)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif tagID == nil {\n\t\tlog.Info(\"Tag does not exist. Nothing to remove\")\n\t\treturn nil\n\t}\n\n\t// destroy the tag\n\tvar m struct {\n\t\tTagDestroy bool `graphql:\"tagDestroy(input: $s)\"`\n\t}\n\n\tinput := TagDestroyInput{\n\t\tID: *tagID,\n\t}\n\n\tvars := map[string]interface{}{\n\t\t\"s\": input,\n\t}\n\n\tlog.Info(\"Destroying tag\")\n\n\terr = client.Mutate(ctx, &m, vars)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Error destroying tag: %v\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/examples/goraw/goraw.yml",
    "content": "# example plugin config\nname: Hawwwwt Tagger (Raw edition)\ndescription: Ultimate Hawwwwt tagging utility (using raw interface).\nversion: 1.0\nurl: http://www.github.com/stashapp/stash\nexec:\n  - plugin_goraw\ninterface: raw\ntasks:\n  - name: Add hawwwwt tag to random scene\n    description: Creates a \"Hawwwwt\" tag if not present and adds to a random scene.\n    defaultArgs:\n      mode: add\n  - name: Remove hawwwwt tag from system\n    description: Removes the \"Hawwwwt\" tag from all scenes and deletes the tag.\n    defaultArgs:\n      mode: remove\n  - name: Indefinite task\n    description: Sleeps indefinitely - interruptable\n    # we'll try command-line argument for this one\n    execArgs:\n      - indef\n      - \"{pluginDir}\"\n  - name: Long task\n    description: Sleeps for 100 seconds - interruptable\n    defaultArgs:\n      mode: long\n      "
  },
  {
    "path": "pkg/plugin/examples/goraw/main.go",
    "content": "//go:build plugin_example\n// +build plugin_example\n\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"os\"\n\t\"time\"\n\n\texampleCommon \"github.com/stashapp/stash/pkg/plugin/examples/common\"\n\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n\t\"github.com/stashapp/stash/pkg/plugin/common/log\"\n\t\"github.com/stashapp/stash/pkg/plugin/util\"\n)\n\n// raw plugins may accept the plugin input from stdin, or they can elect\n// to ignore it entirely. In this case it optionally reads from the\n// command-line parameters.\nfunc main() {\n\tinput := common.PluginInput{}\n\n\tif len(os.Args) < 2 {\n\t\tinData, _ := io.ReadAll(os.Stdin)\n\t\tlog.Debugf(\"Raw input: %s\", string(inData))\n\t\tdecodeErr := json.Unmarshal(inData, &input)\n\n\t\tif decodeErr != nil {\n\t\t\tpanic(\"missing mode argument\")\n\t\t}\n\t} else {\n\t\tlog.Debug(\"Using command line inputs\")\n\t\tmode := os.Args[1]\n\t\tlog.Debugf(\"Command line inputs: %v\", os.Args[1:])\n\t\tinput.Args = common.ArgsMap{\n\t\t\t\"mode\": mode,\n\t\t}\n\n\t\t// just some hard-coded values\n\t\tinput.ServerConnection = common.StashServerConnection{\n\t\t\tScheme: \"http\",\n\t\t\tPort:   9999,\n\t\t}\n\t}\n\n\toutput := common.PluginOutput{}\n\tRun(input, &output)\n\n\tout, _ := json.Marshal(output)\n\tos.Stdout.WriteString(string(out))\n}\n\nfunc Run(input common.PluginInput, output *common.PluginOutput) error {\n\tmodeArg := input.Args.String(\"mode\")\n\tctx := context.TODO()\n\tvar err error\n\tif modeArg == \"\" || modeArg == \"add\" {\n\t\tclient := util.NewClient(input.ServerConnection)\n\t\terr = exampleCommon.AddTag(ctx, client)\n\t} else if modeArg == \"remove\" {\n\t\tclient := util.NewClient(input.ServerConnection)\n\t\terr = exampleCommon.RemoveTag(ctx, client)\n\t} else if modeArg == \"long\" {\n\t\terr = doLongTask()\n\t} else if modeArg == \"indef\" {\n\t\terr = doIndefiniteTask()\n\t}\n\n\tif err != nil {\n\t\terrStr := err.Error()\n\t\t*output = common.PluginOutput{\n\t\t\tError: &errStr,\n\t\t}\n\t\treturn nil\n\t}\n\n\toutputStr := \"ok\"\n\t*output = common.PluginOutput{\n\t\tOutput: &outputStr,\n\t}\n\n\treturn nil\n}\n\nfunc doLongTask() error {\n\tconst total = 100\n\tupTo := 0\n\n\tlog.Info(\"Doing long task\")\n\tfor upTo < total {\n\t\ttime.Sleep(time.Second)\n\n\t\tlog.Progress(float64(upTo) / float64(total))\n\t\tupTo++\n\t}\n\n\treturn nil\n}\n\nfunc doIndefiniteTask() error {\n\tlog.Warn(\"Sleeping indefinitely\")\n\tfor {\n\t\ttime.Sleep(time.Second)\n\t}\n}\n"
  },
  {
    "path": "pkg/plugin/examples/gorpc/gorpc.yml",
    "content": "# example plugin config\nname: Hawwwwt Tagger\ndescription: Ultimate Hawwwwt tagging utility.\nversion: 1.0\nurl: http://www.github.com/stashapp/stash\nexec:\n  - plugin_gorpc\ninterface: rpc\ntasks:\n  - name: Add hawwwwt tag to random scene\n    description: Creates a \"Hawwwwt\" tag if not present and adds to a random scene.\n    defaultArgs:\n      mode: add\n  - name: Remove hawwwwt tag from system\n    description: Removes the \"Hawwwwt\" tag from all scenes and deletes the tag.\n    defaultArgs:\n      mode: remove\n  - name: Indefinite task\n    description: Sleeps indefinitely - interruptable\n    defaultArgs:\n      mode: indef\n  - name: Long task\n    description: Sleeps for 100 seconds - interruptable\n    defaultArgs:\n      mode: long\n      "
  },
  {
    "path": "pkg/plugin/examples/gorpc/main.go",
    "content": "//go:build plugin_example\n// +build plugin_example\n\npackage main\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\texampleCommon \"github.com/stashapp/stash/pkg/plugin/examples/common\"\n\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n\t\"github.com/stashapp/stash/pkg/plugin/common/log\"\n\t\"github.com/stashapp/stash/pkg/plugin/util\"\n)\n\nfunc main() {\n\t// serves the plugin, providing an object that satisfies the\n\t// common.RPCRunner interface\n\terr := common.ServePlugin(&api{})\n\tif err != nil {\n\t\tpanic(err)\n\t}\n}\n\ntype api struct {\n\tstopping bool\n}\n\nfunc (a *api) Stop(input struct{}, output *bool) error {\n\tlog.Info(\"Stopping...\")\n\ta.stopping = true\n\t*output = true\n\treturn nil\n}\n\n// Run is the main work function of the plugin. It interprets the input and\n// acts accordingly.\nfunc (a *api) Run(input common.PluginInput, output *common.PluginOutput) error {\n\tmodeArg := input.Args.String(\"mode\")\n\tctx := context.TODO()\n\n\tvar err error\n\tif modeArg == \"\" || modeArg == \"add\" {\n\t\tclient := util.NewClient(input.ServerConnection)\n\t\terr = exampleCommon.AddTag(ctx, client)\n\t} else if modeArg == \"remove\" {\n\t\tclient := util.NewClient(input.ServerConnection)\n\t\terr = exampleCommon.RemoveTag(ctx, client)\n\t} else if modeArg == \"long\" {\n\t\terr = a.doLongTask()\n\t} else if modeArg == \"indef\" {\n\t\terr = a.doIndefiniteTask()\n\t}\n\n\tif err != nil {\n\t\terrStr := err.Error()\n\t\t*output = common.PluginOutput{\n\t\t\tError: &errStr,\n\t\t}\n\t\treturn nil\n\t}\n\n\toutputStr := \"ok\"\n\t*output = common.PluginOutput{\n\t\tOutput: &outputStr,\n\t}\n\n\treturn nil\n}\n\nfunc (a *api) doLongTask() error {\n\tconst total = 100\n\tupTo := 0\n\n\tlog.Info(\"Doing long task\")\n\tfor upTo < total {\n\t\ttime.Sleep(time.Second)\n\t\tif a.stopping {\n\t\t\treturn nil\n\t\t}\n\n\t\tlog.Progress(float64(upTo) / float64(total))\n\t\tupTo++\n\t}\n\n\treturn nil\n}\n\nfunc (a *api) doIndefiniteTask() error {\n\tlog.Warn(\"Sleeping indefinitely\")\n\tfor {\n\t\ttime.Sleep(time.Second)\n\t\tif a.stopping {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/examples/js/js.js",
    "content": "var tagName = \"Hawwwwt\"\n\nfunction main() {\n    var modeArg = input.Args.mode;\n    if (modeArg !== undefined) {\n        try {\n            if (modeArg == \"\" || modeArg == \"add\") {\n                addTag();\n            } else if (modeArg == \"remove\") {\n                removeTag();\n            } else if (modeArg == \"long\") {\n                doLongTask();\n            } else if (modeArg == \"indef\") {\n                doIndefiniteTask();\n            } else if (modeArg == \"hook\") {\n                doHookTask();\n            }\n        } catch (err) {\n            return {\n                Error: err\n            };\n        }\n\n        return {\n            Output: \"ok\"\n        };\n    }\n\n    if (input.Args.error) {\n        return {\n            Error: input.Args.error\n        };\n    }\n\n    // immediate mode\n    // just return the args\n    return {\n        Output: input.Args\n    };\n}\n\nfunction getResult(result) {\n    if (result[1]) {\n        throw result[1];\n    }\n\n    return result[0];\n}\n\nfunction getTagID(create) {\n\tlog.Info(\"Checking if tag exists already (via GQL)\")\n\n\t// see if tag exists already\n    var query = \"\\\nquery {\\\n  allTags {\\\n    id\\\n    name\\\n  }\\\n}\"\n\t\n    var result = gql.Do(query);\n    var allTags = result[\"allTags\"];\n    \n    var tag;\n\tfor (var i = 0; i < allTags.length; ++i) {\n\t\tif (allTags[i].name === tagName) {\n\t\t\ttag = allTags[i];\n\t\t\tbreak;\n\t\t}\n\t}\n\n    if (tag) {\n        log.Info(\"found existing tag\");\n        return tag.id;\n    }\n\n\tif (!create) {\n\t\tlog.Info(\"Not found and not creating\");\n\t\treturn null;\n\t}\n\n    log.Info(\"Creating new tag\");\n\n    var mutation = \"\\\nmutation tagCreate($input: TagCreateInput!) {\\\n  tagCreate(input: $input) {\\\n    id\\\n  }\\\n}\";\n\n    var variables = {\n        input: {\n            'name': tagName\n        }\n    };\n\n    result = gql.Do(mutation, variables);\n    log.Info(\"tag id = \" + result.tagCreate.id);\n\treturn result.tagCreate.id;\n}\n\nfunction addTag() {\n    var tagID = getTagID(true)\n    \n\tvar scene = findRandomScene();\n\n\tif (scene === null) {\n\t\tthrow \"no scenes to add tag to\";\n\t}\n\n    var tagIds = []\n    var found = false;\n\tfor (var i = 0; i < scene.tags.length; ++i) {\n        var sceneTagID = scene.tags[i].id;\n        if (tagID === sceneTagID) {\n            found = true;\n        }\n        tagIds.push(sceneTagID);\n    }\n\t\n    if (found) {\n        log.Info(\"already has tag\");\n        return;\n    }\n\t    \n    tagIds.push(tagID)\n\n    var mutation = \"\\\nmutation sceneUpdate($input: SceneUpdateInput!) {\\\n    sceneUpdate(input: $input) {\\\n        id\\\n    }\\\n}\";\n\n    var variables = {\n        input: {\n            id: scene.id,\n            tag_ids: tagIds,\n        }\n    };\n\n    log.Info(\"Adding tag to scene \" + scene.id);\n\n    gql.Do(mutation, variables);\n}\n\nfunction removeTag() {\n\tvar tagID = getTagID(false);\n\n\tif (tagID == null) {\n\t\tlog.Info(\"Tag does not exist. Nothing to remove\");\n\t\treturn\n    }\n\n\tlog.Info(\"Destroying tag\");\n\t\n    var mutation = \"\\\nmutation tagDestroy($input: TagDestroyInput!) {\\\n    tagDestroy(input: $input)\\\n}\";\n\n    var variables = {\n        input: {\n            id: tagID\n        }\n    };\n\n    gql.Do(mutation, variables);\n}\n\nfunction findRandomScene() {\n\t// get a random scene\n    log.Info(\"Finding a random scene\")\n\n    var query = \"\\\nquery findScenes($filter: FindFilterType!) {\\\n    findScenes(filter: $filter) {\\\n        count\\\n        scenes {\\\n            id\\\n            tags {\\\n                id\\\n            }\\\n        }\\\n    }\\\n}\"\n\n    var variables = {\n        filter: {\n            per_page: 1,\n            sort: 'random'\n        }\n    };\n\n    var result = gql.Do(query, variables);\n    var findScenes = result[\"findScenes\"];\n    \n    if (findScenes.Count === 0) {\n        return null;\n    }\n\n    return findScenes.scenes[0];\n}\n\nfunction doLongTask() {\n\tvar total = 100;\n\tvar upTo = 0;\n\n\tlog.Info(\"Doing long task\");\n\twhile (upTo < total) {\n\t\tutil.Sleep(1000);\n\n\t\tlog.Progress(upTo / total);\n\t\tupTo = upTo + 1;\n    }\n}\n\nfunction doIndefiniteTask() {\n\tlog.Info(\"Sleeping indefinitely\");\n\twhile (true) {\n\t\tutil.Sleep(1000);\n    }\n}\n\nfunction doHookTask() {\n    log.Info(\"JS Hook called!\");\n    log.Info(input.Args);\n}\n\nmain();"
  },
  {
    "path": "pkg/plugin/examples/js/js.yml",
    "content": "# example plugin config\nname: Hawwwwt Tagger (Javascript edition)\ndescription: Javascript Hawwwwt tagging utility (using raw interface).\nversion: 1.0\nurl: http://www.github.com/stashapp/stash\nexec:\n  - js.js\ninterface: js\ntasks:\n  - name: Add hawwwwt tag to random scene\n    description: Creates a \"Hawwwwt\" tag if not present and adds to a random scene.\n    defaultArgs:\n      mode: add\n  - name: Remove hawwwwt tag from system\n    description: Removes the \"Hawwwwt\" tag from all scenes and deletes the tag.\n    defaultArgs:\n      mode: remove\n  - name: Indefinite task\n    description: Sleeps indefinitely - interruptable\n    # we'll try command-line argument for this one\n    defaultArgs:\n      mode: indef\n  - name: Long task\n    description: Sleeps for 100 seconds - interruptable\n    defaultArgs:\n      mode: long\nhooks:\n  - name: Log scene marker create/update\n    description: Logs some stuff when creating/updating scene marker.\n    triggeredBy: \n      - SceneMarker.Create.Post\n      - SceneMarker.Update.Post\n      - SceneMarker.Delete.Post\n      - Scene.Create.Post\n      - Scene.Update.Post\n      - Scene.Destroy.Post\n      - Image.Create.Post\n      - Image.Update.Post\n      - Image.Destroy.Post\n      - Gallery.Create.Post\n      - Gallery.Update.Post\n      - Gallery.Destroy.Post\n      - Movie.Create.Post\n      - Movie.Update.Post\n      - Movie.Destroy.Post\n      - Performer.Create.Post\n      - Performer.Update.Post\n      - Performer.Destroy.Post\n      - Studio.Create.Post\n      - Studio.Update.Post\n      - Studio.Destroy.Post\n      - Tag.Create.Post\n      - Tag.Update.Post\n      - Tag.Destroy.Post\n    defaultArgs:\n      mode: hook\n    \n    \n"
  },
  {
    "path": "pkg/plugin/examples/python/log.py",
    "content": "import sys\n\n# Log messages sent from a plugin instance are transmitted via stderr and are\n# encoded with a prefix consisting of special character SOH, then the log\n# level (one of t, d, i, w, e, or p - corresponding to trace, debug, info,\n# warning, error and progress levels respectively), then special character\n# STX.\n#\n# The LogTrace, LogDebug, LogInfo, LogWarning, and LogError methods, and their equivalent\n# formatted methods are intended for use by plugin instances to transmit log\n# messages. The LogProgress method is also intended for sending progress data.\n#\n\ndef __prefix(levelChar):\n    startLevelChar = b'\\x01'\n    endLevelChar = b'\\x02'\n\n    ret = startLevelChar + levelChar + endLevelChar\n    return ret.decode()\n\ndef __log(levelChar, s):\n    if levelChar == \"\":\n        return\n\n    print(__prefix(levelChar) + s + \"\\n\", file=sys.stderr, flush=True)\n\ndef LogTrace(s):\n    __log(b't', s)\n\ndef LogDebug(s):\n    __log(b'd', s)\n\ndef LogInfo(s):\n    __log(b'i', s)\n\ndef LogWarning(s):\n    __log(b'w', s)\n\ndef LogError(s):\n    __log(b'e', s)\n\ndef LogProgress(p):\n    progress = min(max(0, p), 1)\n    __log(b'p', str(progress))\n"
  },
  {
    "path": "pkg/plugin/examples/python/pyplugin.py",
    "content": "import json\nimport sys\nimport time\n\nimport log\nfrom stash_interface import StashInterface\n\n# raw plugins may accept the plugin input from stdin, or they can elect\n# to ignore it entirely. In this case it optionally reads from the\n# command-line parameters.\ndef main():\n\tinput = None\n\n\tif len(sys.argv) < 2:\n\t\tinput = readJSONInput()\n\t\tlog.LogDebug(\"Raw input: %s\" % json.dumps(input))\n\telse:\n\t\tlog.LogDebug(\"Using command line inputs\")\n\t\tmode = sys.argv[1]\n\t\tlog.LogDebug(\"Command line inputs: {}\".format(sys.argv[1:]))\n\t\t\n\t\tinput = {}\n\t\tinput['args'] = {\n\t\t\t\"mode\": mode\n\t\t}\n\n\t\t# just some hard-coded values\n\t\tinput['server_connection'] = {\n\t\t\t\"Scheme\": \"http\",\n\t\t\t\"Port\":   9999,\n\t\t}\n\n\toutput = {}\n\trun(input, output)\n\n\tout = json.dumps(output)\n\tprint(out + \"\\n\")\n\ndef readJSONInput():\n\tinput = sys.stdin.read()\n\treturn json.loads(input)\n\ndef run(input, output):\n\tmodeArg = input['args'][\"mode\"]\n\n\ttry:\n\t\tif modeArg == \"\" or modeArg == \"add\":\n\t\t\tclient = StashInterface(input[\"server_connection\"])\n\t\t\taddTag(client)\n\t\telif modeArg == \"remove\":\n\t\t\tclient = StashInterface(input[\"server_connection\"])\n\t\t\tremoveTag(client)\n\t\telif modeArg == \"long\":\n\t\t\tdoLongTask()\n\t\telif modeArg == \"indef\":\n\t\t\tdoIndefiniteTask()\n\texcept Exception as e:\n\t\traise\n\t\t#output[\"error\"] = str(e)\n\t\t#return\n\n\toutput[\"output\"] = \"ok\"\n\ndef doLongTask():\n\ttotal = 100\n\tupTo = 0\n\n\tlog.LogInfo(\"Doing long task\")\n\twhile upTo < total:\n\t\ttime.sleep(1)\n\n\t\tlog.LogProgress(float(upTo) / float(total))\n\t\tupTo = upTo + 1\n\ndef doIndefiniteTask():\n\tlog.LogWarning(\"Sleeping indefinitely\")\n\twhile True:\n\t\ttime.sleep(1)\n\ndef addTag(client):\n\ttagName = \"Hawwwwt\"\n\ttagID = client.findTagIdWithName(tagName)\n\n\tif tagID == None:\n\t\ttagID = client.createTagWithName(tagName)\n\n\tscene = client.findRandomSceneId()\n\n\tif scene == None:\n\t\traise Exception(\"no scenes to add tag to\")\n\n\ttagIds = []\n\tfor t in scene[\"tags\"]:\n\t\ttagIds.append(t[\"id\"])\n\t\n\t# remove first to ensure we don't re-add the same id\n\ttry:\n\t\ttagIds.remove(tagID)\n\texcept ValueError:\n\t\tpass\n\n\ttagIds.append(tagID)\n\n\tinput = {\n\t\t\"id\": scene[\"id\"],\n\t\t\"tag_ids\": tagIds\n\t}\n\n\tlog.LogInfo(\"Adding tag to scene {}\".format(scene[\"id\"]))\n\tclient.updateScene(input)\n\ndef removeTag(client):\n\ttagName = \"Hawwwwt\"\n\ttagID = client.findTagIdWithName(tagName)\n\n\tif tagID == None:\n\t\tlog.LogInfo(\"Tag does not exist. Nothing to remove\")\n\t\treturn\n\n\tlog.LogInfo(\"Destroying tag\")\n\tclient.destroyTag(tagID)\n\nmain()"
  },
  {
    "path": "pkg/plugin/examples/python/pyraw.yml",
    "content": "# example plugin config\nname: Hawwwwt Tagger (Raw Python edition)\ndescription: Python Hawwwwt tagging utility (using raw interface).\nversion: 1.0\nurl: http://www.github.com/stashapp/stash\nexec:\n  - python\n  - \"{pluginDir}/pyplugin.py\"\ninterface: raw\ntasks:\n  - name: Add hawwwwt tag to random scene\n    description: Creates a \"Hawwwwt\" tag if not present and adds to a random scene.\n    defaultArgs:\n      mode: add\n  - name: Remove hawwwwt tag from system\n    description: Removes the \"Hawwwwt\" tag from all scenes and deletes the tag.\n    defaultArgs:\n      mode: remove\n  - name: Indefinite task\n    description: Sleeps indefinitely - interruptable\n    # we'll try command-line argument for this one\n    execArgs:\n      - indef\n      - \"{pluginDir}\"\n  - name: Long task\n    description: Sleeps for 100 seconds - interruptable\n    defaultArgs:\n      mode: long\n      \n"
  },
  {
    "path": "pkg/plugin/examples/python/stash_interface.py",
    "content": "import requests\n\nclass StashInterface:\n\tport = \"\"\n\turl = \"\"\n\theaders = {\n\t\t\"Accept-Encoding\": \"gzip, deflate, br\",\n\t\t\"Content-Type\": \"application/json\",\n\t\t\"Accept\": \"application/json\",\n\t\t\"Connection\": \"keep-alive\",\n\t\t\"DNT\": \"1\"\n\t\t}\n\n\tdef __init__(self, conn):\n\t\tself.port = conn['Port']\n\t\tscheme = conn['Scheme']\n\n\t\tself.url = scheme + \"://localhost:\" + str(self.port) + \"/graphql\"\n\n\t\t# Session cookie for authentication\n\t\tself.cookies = {\n\t\t\t'session': conn.get('SessionCookie').get('Value')\n\t\t}\n\n\tdef __callGraphQL(self, query, variables = None):\n\t\tjson = {}\n\t\tjson['query'] = query\n\t\tif variables != None:\n\t\t\tjson['variables'] = variables\n\t\t\n\t\t# handle cookies\n\t\tresponse = requests.post(self.url, json=json, headers=self.headers, cookies=self.cookies)\n\t\t\n\t\tif response.status_code == 200:\n\t\t\tresult = response.json()\n\t\t\tif result.get(\"error\", None):\n\t\t\t\tfor error in result[\"error\"][\"errors\"]:\n\t\t\t\t\traise Exception(\"GraphQL error: {}\".format(error))\n\t\t\tif result.get(\"data\", None):\n\t\t\t\treturn result.get(\"data\")\n\t\telse:\n\t\t\traise Exception(\"GraphQL query failed:{} - {}. Query: {}. Variables: {}\".format(response.status_code, response.content, query, variables))\n\n\tdef findTagIdWithName(self, name):\n\t\tquery = \"\"\"\nquery {\n  allTags {\n    id\n    name\n  }\n}\n\t\t\"\"\"\n\n\t\tresult = self.__callGraphQL(query)\n\t\t\n\t\tfor tag in result[\"allTags\"]:\n\t\t\tif tag[\"name\"] == name:\n\t\t\t\treturn tag[\"id\"]\n\t\treturn None\n\n\tdef createTagWithName(self, name):\n\t\tquery = \"\"\"\nmutation tagCreate($input:TagCreateInput!) {\n  tagCreate(input: $input){\n    id       \n  }\n}\n\"\"\"\n\t\tvariables = {'input': {\n\t\t\t'name': name\n\t\t}}\n\n\t\tresult = self.__callGraphQL(query, variables)\n\t\treturn result[\"tagCreate\"][\"id\"]\n\n\tdef destroyTag(self, id):\n\t\tquery = \"\"\"\nmutation tagDestroy($input: TagDestroyInput!) {\n  tagDestroy(input: $input)\n}\n\"\"\"\n\t\tvariables = {'input': {\n\t\t\t'id': id\n\t\t}}\n\n\t\tself.__callGraphQL(query, variables)\n\n\tdef findRandomSceneId(self):\n\t\tquery = \"\"\"\nquery findScenes($filter: FindFilterType!) {\n  findScenes(filter: $filter) {\n    count\n    scenes {\n      id\n      tags {\n        id\n      }\n    }\n  }\n}\n\"\"\"\n\t\t\n\t\tvariables = {'filter': {\n\t\t\t'per_page': 1,\n\t\t\t'sort': 'random'\n\t\t}}\n\n\t\tresult = self.__callGraphQL(query, variables)\n\n\t\tif result[\"findScenes\"][\"count\"] == 0:\n\t\t\treturn None\n\n\t\treturn result[\"findScenes\"][\"scenes\"][0]\n\n\tdef updateScene(self, sceneData):\n\t\tquery = \"\"\"\nmutation sceneUpdate($input:SceneUpdateInput!) {\n  sceneUpdate(input: $input) {\n    id\n  }\n}\n\"\"\"\n\t\tvariables = {'input': sceneData}\n\n\t\tself.__callGraphQL(query, variables)"
  },
  {
    "path": "pkg/plugin/examples/react-component/README.md",
    "content": "This is a reference React component plugin. It replaces the `details` part of scene cards with a list of performers and tags.\n\nTo build:\n- run `pnpm install --frozen-lockfile`\n- run `npm run build`\n\nThis will copy the plugin files into the `dist` directory. These files can be copied to a `plugins` directory. \n"
  },
  {
    "path": "pkg/plugin/examples/react-component/package.json",
    "content": "{\n  \"name\": \"react-component\",\n  \"version\": \"1.0.0\",\n  \"main\": \"index.js\",\n  \"author\": \"WithoutPants\",\n  \"license\": \"AGPL-3.0\",\n  \"scripts\": {\n    \"compile:ts\": \"npm run tsc\",\n    \"compile:sass\": \"npm run sass src/testReact.scss dist/testReact.css\",\n    \"copy:yml\": \"cpx \\\"src/testReact.yml\\\" \\\"dist\\\"\",\n    \"compile\": \"npm run compile:ts && npm run compile:sass\",\n    \"build\": \"npm run compile && npm run copy:yml\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^18.2.31\",\n    \"@types/react-dom\": \"^18.2.14\",\n    \"cpx\": \"^1.5.0\",\n    \"sass\": \"^1.69.4\",\n    \"typescript\": \"^5.2.2\"\n  }\n}\n"
  },
  {
    "path": "pkg/plugin/examples/react-component/src/testReact.scss",
    "content": ".scene-card__date {\n  color: #bfccd6;;\n  font-size: 0.85em;\n}\n\n.scene-card__performer {\n  display: inline-block;\n  font-weight: 500;\n  margin-right: 0.5em;\n\n  a {\n    color: #137cbd;\n  }\n}\n\n.scene-card__performers,\n.scene-card__tags {\n  -webkit-box-orient: vertical;\n  display: -webkit-box;\n  -webkit-line-clamp: 1;\n  overflow: hidden;\n\n  &:hover {\n    -webkit-line-clamp: unset;\n    overflow: visible;\n  }\n}\n\n.scene-card__tags .tag-item {\n  margin-left: 0;\n}\n\n.scene-performer-popover .image-thumbnail {\n  margin: 1em;\n}\n\n.example-react-component-custom-overlay {\n  display: block;\n  font-weight: 900;\n  height: 100%;\n  opacity: 0.25;\n  position: absolute;\n  text-align: center;\n  top: 0;\n  width: 100%;\n  z-index: 8;\n}"
  },
  {
    "path": "pkg/plugin/examples/react-component/src/testReact.tsx",
    "content": "interface IPluginApi {\n  React: typeof React;\n  GQL: any;\n  Event: {\n    addEventListener: (event: string, callback: (e: CustomEvent) => void) => void;\n  };\n  libraries: {\n    ReactRouterDOM: {\n      Link: React.FC<any>;\n      Route: React.FC<any>;\n      NavLink: React.FC<any>;\n    },\n    Bootstrap: {\n      Button: React.FC<any>;\n      Nav: React.FC<any> & {\n        Link: React.FC<any>;\n        Item: React.FC<any>;\n      };\n      Tab: React.FC<any> & {\n        Pane: React.FC<any>;\n      }\n    },\n    FontAwesomeSolid: {\n      faEthernet: any;\n    },\n    Intl: {\n      FormattedMessage: React.FC<any>;\n    }\n  },\n  loadableComponents: any;\n  components: Record<string, React.FC<any>>;\n  utils: {\n    NavUtils: any;\n    loadComponents: any;\n  },\n  hooks: any;\n  patch: {\n    before: (target: string, fn: Function) => void;\n    instead: (target: string, fn: Function) => void;\n    after: (target: string, fn: Function) => void;\n  },\n  register: {\n    route: (path: string, component: React.FC<any>) => void;\n  }\n}\n\n(function () {\n  const PluginApi = (window as any).PluginApi as IPluginApi;\n  const React = PluginApi.React;\n  const GQL = PluginApi.GQL;\n\n  const { Button, Nav, Tab } = PluginApi.libraries.Bootstrap;\n  const { faEthernet } = PluginApi.libraries.FontAwesomeSolid;\n  const {\n    Link,\n    NavLink,\n  } = PluginApi.libraries.ReactRouterDOM;\n\n  const {\n    NavUtils\n  } = PluginApi.utils;\n\n  PluginApi.Event.addEventListener(\"stash:location\", (e) => console.log(\"Page Changed\", e.detail.data.location.pathname, e.detail.data.location.search))\n\n  const ScenePerformer: React.FC<{\n    performer: any;\n  }> = ({ performer }) => {\n    // PluginApi.components may not be registered when the outside function is run\n    // need to initialise these inside the function component\n    const {\n      HoverPopover,\n    } = PluginApi.components;\n\n    const popoverContent = React.useMemo(\n      () => (\n        <div className=\"scene-performer-popover\">\n          <Link to={`/performers/${performer.id}`}>\n            <img\n              className=\"image-thumbnail\"\n              alt={performer.name ?? \"\"}\n              src={performer.image_path ?? \"\"}\n            />\n          </Link>\n        </div>\n      ),\n      [performer]\n    );\n  \n    return (\n      <HoverPopover\n        className=\"scene-card__performer\"\n        placement=\"top\"\n        content={popoverContent}\n        leaveDelay={100}\n      >\n        <a href={NavUtils.makePerformerScenesUrl(performer)}>{performer.name}</a>\n      </HoverPopover>\n    );\n  };\n\n  function SceneDetails(props: any) {\n    const {\n      TagLink,\n    } = PluginApi.components;\n\n    function maybeRenderPerformers() {\n      if (props.scene.performers.length <= 0) return;\n  \n      return (\n        <div className=\"scene-card__performers\">\n          {props.scene.performers.map((performer: any) => (\n            <ScenePerformer performer={performer} key={performer.id} />\n          ))}\n        </div>\n      );\n    }\n  \n    function maybeRenderTags() {\n      if (props.scene.tags.length <= 0) return;\n  \n      return (\n        <div className=\"scene-card__tags\">\n          {props.scene.tags.map((tag: any) => (\n            <TagLink key={tag.id} tag={tag} />\n          ))}\n        </div>\n      );\n    }\n\n    return (\n      <div className=\"scene-card__details\">\n        <span className=\"scene-card__date\">{props.scene.date}</span>\n        {maybeRenderPerformers()}\n        {maybeRenderTags()}\n      </div>\n    );\n  }\n\n  function Overlays() {\n    return <span className=\"example-react-component-custom-overlay\">Custom overlay</span>;\n  }\n\n  PluginApi.patch.instead(\"SceneCard.Details\", function (props: any, _: any, original: any) {\n    return <SceneDetails {...props} />;\n  });\n\n  PluginApi.patch.instead(\"SceneCard.Overlays\", function (props: any, _: any, original: (props: any) => any) {  \n    return <><Overlays />{original({...props})}</>;\n  });\n\n  PluginApi.patch.instead(\"FrontPage\", function (props: any, _: any, original: (props: any) => any) {  \n    return <><p>Hello from Test React!</p>{original({...props})}</>;\n  });\n\n  const TestPage: React.FC = () => {\n    const componentsToLoad = [\n      PluginApi.loadableComponents.SceneCard,\n      PluginApi.loadableComponents.PerformerSelect,\n    ];\n    const componentsLoading = PluginApi.hooks.useLoadComponents(componentsToLoad);\n    \n    const {\n      SceneCard,\n      LoadingIndicator,\n      PerformerSelect,\n    } = PluginApi.components;\n\n    // read a random scene and show a scene card for it\n    const { data } = GQL.useFindScenesQuery({\n      variables: {\n        filter: {\n          per_page: 1,\n          sort: \"random\",\n        },\n      },\n    });\n\n    const scene = data?.findScenes.scenes[0];\n\n    if (componentsLoading) return (\n      <LoadingIndicator />\n    );\n    \n    return (\n      <div>\n        <div>This is a test page.</div>\n        {!!scene && <SceneCard scene={data.findScenes.scenes[0]} />}\n        <div>\n          <PerformerSelect isMulti onSelect={() => {}} values={[]} />\n        </div>\n      </div>\n    );\n  };\n\n  PluginApi.register.route(\"/plugins/test-react\", TestPage);\n\n  PluginApi.patch.before(\"SettingsToolsSection\", function (props: any) {\n    const {\n      Setting,\n    } = PluginApi.components;\n\n    return [\n      {\n        children: (\n          <>\n            {props.children}\n            <Setting\n              heading={\n                <Link to=\"/plugins/test-react\">\n                  <Button>\n                    Test page\n                  </Button>\n                </Link>\n              }\n            />\n          </>\n        ),\n      },\n    ];\n  });\n\n  PluginApi.patch.before(\"MainNavBar.UtilityItems\", function (props: any) {\n    const {\n      Icon,\n    } = PluginApi.components;\n\n    return [\n      {\n        children: (\n          <>\n            {props.children}\n            <NavLink\n              className=\"nav-utility\"\n              exact\n              to=\"/plugins/test-react\"\n            >\n              <Button\n                className=\"minimal d-flex align-items-center h-100\"\n                title=\"Test page\"\n              >\n                <Icon icon={faEthernet} />\n              </Button>\n            </NavLink>\n          </>\n        )\n      }\n    ]\n  });\n\n  PluginApi.patch.before(\"ScenePage.Tabs\", function (props: any) {\n    return [\n      {\n        children: (\n          <>\n            {props.children}\n            <Nav.Item>\n              <Nav.Link eventKey=\"test-react-tab\">\n                Test React tab\n              </Nav.Link>\n            </Nav.Item>\n          </>\n        ),\n      },\n    ];\n  });\n\n  PluginApi.patch.before(\"ScenePage.TabContent\", function (props: any) {\n    return [\n      {\n        children: (\n          <>\n            {props.children}\n            <Tab.Pane eventKey=\"test-react-tab\">\n              Test React tab content {props.scene.id}\n            </Tab.Pane>\n          </>\n        ),\n      },\n    ];\n  });\n})();"
  },
  {
    "path": "pkg/plugin/examples/react-component/src/testReact.yml",
    "content": "name: Test React\ndescription: Adds a React component\nurl: https://github.com/stashapp/CommunityScripts\nversion: 1.0\nui:\n  javascript:\n  - testReact.js\n  css:\n  - testReact.css\n\n\n"
  },
  {
    "path": "pkg/plugin/examples/react-component/tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n      \"target\": \"es2019\",\n      \"outDir\": \"dist\",\n    //   \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n      \"skipLibCheck\": true,\n      \"esModuleInterop\": true,\n      \"allowSyntheticDefaultImports\": true,\n      \"strict\": true,\n      \"forceConsistentCasingInFileNames\": true,\n      // \"module\": \"es2020\",\n      \"module\": \"None\",\n      \"moduleResolution\": \"node\",\n    //   \"resolveJsonModule\": true,\n    //   \"noEmit\": true,\n      \"jsx\": \"react\",\n      \"experimentalDecorators\": true,\n      \"baseUrl\": \".\",\n      \"sourceMap\": true,\n      \"allowJs\": true,\n      \"isolatedModules\": true,\n      \"noFallthroughCasesInSwitch\": true,\n      \"useDefineForClassFields\": true,\n      // \"types\": [\"React\"]\n    },\n    \"include\": [\"src\"]\n  }\n  "
  },
  {
    "path": "pkg/plugin/hook/hooks.go",
    "content": "package hook\n\ntype TriggerEnum string\n\n// Scan-related hooks are current disabled until post-hook execution is\n// integrated.\n\nconst (\n\tSceneMarkerCreatePost  TriggerEnum = \"SceneMarker.Create.Post\"\n\tSceneMarkerUpdatePost  TriggerEnum = \"SceneMarker.Update.Post\"\n\tSceneMarkerDestroyPost TriggerEnum = \"SceneMarker.Destroy.Post\"\n\n\tSceneCreatePost  TriggerEnum = \"Scene.Create.Post\"\n\tSceneUpdatePost  TriggerEnum = \"Scene.Update.Post\"\n\tSceneDestroyPost TriggerEnum = \"Scene.Destroy.Post\"\n\n\tImageCreatePost  TriggerEnum = \"Image.Create.Post\"\n\tImageUpdatePost  TriggerEnum = \"Image.Update.Post\"\n\tImageDestroyPost TriggerEnum = \"Image.Destroy.Post\"\n\n\tGalleryCreatePost  TriggerEnum = \"Gallery.Create.Post\"\n\tGalleryUpdatePost  TriggerEnum = \"Gallery.Update.Post\"\n\tGalleryDestroyPost TriggerEnum = \"Gallery.Destroy.Post\"\n\n\tGalleryChapterCreatePost  TriggerEnum = \"GalleryChapter.Create.Post\"\n\tGalleryChapterUpdatePost  TriggerEnum = \"GalleryChapter.Update.Post\"\n\tGalleryChapterDestroyPost TriggerEnum = \"GalleryChapter.Destroy.Post\"\n\n\t// deprecated - use Group hooks instead\n\t// for now, both movie and group hooks will be executed\n\tMovieCreatePost  TriggerEnum = \"Movie.Create.Post\"\n\tMovieUpdatePost  TriggerEnum = \"Movie.Update.Post\"\n\tMovieDestroyPost TriggerEnum = \"Movie.Destroy.Post\"\n\n\tGroupCreatePost  TriggerEnum = \"Group.Create.Post\"\n\tGroupUpdatePost  TriggerEnum = \"Group.Update.Post\"\n\tGroupDestroyPost TriggerEnum = \"Group.Destroy.Post\"\n\n\tPerformerCreatePost  TriggerEnum = \"Performer.Create.Post\"\n\tPerformerUpdatePost  TriggerEnum = \"Performer.Update.Post\"\n\tPerformerDestroyPost TriggerEnum = \"Performer.Destroy.Post\"\n\n\tStudioCreatePost  TriggerEnum = \"Studio.Create.Post\"\n\tStudioUpdatePost  TriggerEnum = \"Studio.Update.Post\"\n\tStudioDestroyPost TriggerEnum = \"Studio.Destroy.Post\"\n\n\tTagCreatePost  TriggerEnum = \"Tag.Create.Post\"\n\tTagUpdatePost  TriggerEnum = \"Tag.Update.Post\"\n\tTagMergePost   TriggerEnum = \"Tag.Merge.Post\"\n\tTagDestroyPost TriggerEnum = \"Tag.Destroy.Post\"\n)\n\nvar AllHookTriggerEnum = []TriggerEnum{\n\tSceneMarkerCreatePost,\n\tSceneMarkerUpdatePost,\n\tSceneMarkerDestroyPost,\n\n\tSceneCreatePost,\n\tSceneUpdatePost,\n\tSceneDestroyPost,\n\n\tImageCreatePost,\n\tImageUpdatePost,\n\tImageDestroyPost,\n\n\tGalleryCreatePost,\n\tGalleryUpdatePost,\n\tGalleryDestroyPost,\n\n\tGalleryChapterCreatePost,\n\tGalleryChapterUpdatePost,\n\tGalleryChapterDestroyPost,\n\n\tMovieCreatePost,\n\tMovieUpdatePost,\n\tMovieDestroyPost,\n\n\tPerformerCreatePost,\n\tPerformerUpdatePost,\n\tPerformerDestroyPost,\n\n\tStudioCreatePost,\n\tStudioUpdatePost,\n\tStudioDestroyPost,\n\n\tTagCreatePost,\n\tTagUpdatePost,\n\tTagMergePost,\n\tTagDestroyPost,\n}\n\nfunc (e TriggerEnum) IsValid() bool {\n\n\tswitch e {\n\tcase SceneMarkerCreatePost,\n\t\tSceneMarkerUpdatePost,\n\t\tSceneMarkerDestroyPost,\n\n\t\tSceneCreatePost,\n\t\tSceneUpdatePost,\n\t\tSceneDestroyPost,\n\n\t\tImageCreatePost,\n\t\tImageUpdatePost,\n\t\tImageDestroyPost,\n\n\t\tGalleryCreatePost,\n\t\tGalleryUpdatePost,\n\t\tGalleryDestroyPost,\n\n\t\tGalleryChapterCreatePost,\n\t\tGalleryChapterUpdatePost,\n\t\tGalleryChapterDestroyPost,\n\n\t\tMovieCreatePost,\n\t\tMovieUpdatePost,\n\t\tMovieDestroyPost,\n\n\t\tPerformerCreatePost,\n\t\tPerformerUpdatePost,\n\t\tPerformerDestroyPost,\n\n\t\tStudioCreatePost,\n\t\tStudioUpdatePost,\n\t\tStudioDestroyPost,\n\n\t\tTagCreatePost,\n\t\tTagUpdatePost,\n\t\tTagDestroyPost:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e TriggerEnum) String() string {\n\treturn string(e)\n}\n"
  },
  {
    "path": "pkg/plugin/hooks.go",
    "content": "package plugin\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n)\n\ntype PluginHook struct {\n\tName        string   `json:\"name\"`\n\tDescription *string  `json:\"description\"`\n\tHooks       []string `json:\"hooks\"`\n\tPlugin      *Plugin  `json:\"plugin\"`\n}\n\nfunc addHookContext(argsMap common.ArgsMap, hookContext common.HookContext) {\n\targsMap[common.HookContextKey] = hookContext\n}\n\n// types for destroy hooks, to provide a little more information\ntype SceneDestroyInput struct {\n\tmodels.SceneDestroyInput\n\tChecksum string `json:\"checksum\"`\n\tOSHash   string `json:\"oshash\"`\n\tPath     string `json:\"path\"`\n}\n\ntype ScenesDestroyInput struct {\n\tmodels.ScenesDestroyInput\n\tChecksum string `json:\"checksum\"`\n\tOSHash   string `json:\"oshash\"`\n\tPath     string `json:\"path\"`\n}\n\ntype GalleryDestroyInput struct {\n\tmodels.GalleryDestroyInput\n\tChecksum string `json:\"checksum\"`\n\tPath     string `json:\"path\"`\n}\n\ntype ImageDestroyInput struct {\n\tmodels.ImageDestroyInput\n\tChecksum string `json:\"checksum\"`\n\tPath     string `json:\"path\"`\n}\n\ntype ImagesDestroyInput struct {\n\tmodels.ImagesDestroyInput\n\tChecksum string `json:\"checksum\"`\n\tPath     string `json:\"path\"`\n}\n"
  },
  {
    "path": "pkg/plugin/js.go",
    "content": "package plugin\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"sync\"\n\n\t\"github.com/dop251/goja\"\n\t\"github.com/stashapp/stash/pkg/javascript\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n)\n\nvar errStop = errors.New(\"stop\")\n\ntype jsTaskBuilder struct{}\n\nfunc (*jsTaskBuilder) build(task pluginTask) Task {\n\treturn &jsPluginTask{\n\t\tpluginTask: task,\n\t}\n}\n\ntype jsPluginTask struct {\n\tpluginTask\n\n\tstarted   bool\n\twaitGroup sync.WaitGroup\n\tvm        *javascript.VM\n}\n\nfunc (t *jsPluginTask) onError(err error) {\n\terrString := err.Error()\n\tt.result = &common.PluginOutput{\n\t\tError: &errString,\n\t}\n}\n\nfunc (t *jsPluginTask) makeOutput(o goja.Value) {\n\tt.result = &common.PluginOutput{}\n\n\tasObj := o.ToObject(t.vm.Runtime)\n\tif asObj == nil {\n\t\treturn\n\t}\n\n\tt.result.Output = asObj.Get(\"Output\")\n\terr := asObj.Get(\"Error\")\n\tif !goja.IsNull(err) && !goja.IsUndefined(err) {\n\t\terrStr := err.String()\n\t\tt.result.Error = &errStr\n\t}\n}\n\nfunc (t *jsPluginTask) initVM() error {\n\t// converting the Args field to map[string]interface{} is required, otherwise\n\t// it gets converted to an empty object\n\t// ideally this should have included json tags with the correct casing but changing\n\t// it now will result in a breaking change\n\ttype pluginInput struct {\n\t\t// Server details to connect to the stash server.\n\t\tServerConnection common.StashServerConnection\n\n\t\t// Arguments to the plugin operation.\n\t\tArgs map[string]interface{}\n\t}\n\n\tinput := pluginInput{\n\t\tServerConnection: t.input.ServerConnection,\n\t\tArgs:             t.input.Args.ToMap(),\n\t}\n\n\tif err := t.vm.Set(\"input\", input); err != nil {\n\t\treturn fmt.Errorf(\"error setting input: %w\", err)\n\t}\n\n\tconst pluginPrefix = \"[Plugin / %s] \"\n\n\tlog := &javascript.Log{\n\t\tLogger:       logger.Logger,\n\t\tPrefix:       fmt.Sprintf(pluginPrefix, t.plugin.Name),\n\t\tProgressChan: t.progress,\n\t}\n\n\tif err := log.AddToVM(\"log\", t.vm); err != nil {\n\t\treturn fmt.Errorf(\"error adding log API: %w\", err)\n\t}\n\n\tutil := &javascript.Util{}\n\tif err := util.AddToVM(\"util\", t.vm); err != nil {\n\t\treturn fmt.Errorf(\"error adding util API: %w\", err)\n\t}\n\n\tgql := &javascript.GQL{\n\t\tContext:    context.TODO(),\n\t\tCookie:     t.input.ServerConnection.SessionCookie,\n\t\tGQLHandler: t.gqlHandler,\n\t}\n\tif err := gql.AddToVM(\"gql\", t.vm); err != nil {\n\t\treturn fmt.Errorf(\"error adding GraphQL API: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (t *jsPluginTask) Start() error {\n\tif t.started {\n\t\treturn errors.New(\"task already started\")\n\t}\n\n\tt.started = true\n\n\tif len(t.plugin.Exec) == 0 {\n\t\treturn errors.New(\"no script specified in exec\")\n\t}\n\n\tscriptFile := t.plugin.Exec[0]\n\n\tt.vm = javascript.NewVM()\n\tpluginPath := t.plugin.getConfigPath()\n\tscript, err := javascript.Compile(filepath.Join(pluginPath, scriptFile))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := t.initVM(); err != nil {\n\t\treturn err\n\t}\n\n\tt.waitGroup.Add(1)\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tt.waitGroup.Done()\n\n\t\t\tif caught := recover(); caught != nil {\n\t\t\t\tif err, ok := caught.(error); ok && errors.Is(err, errStop) {\n\t\t\t\t\t// TODO - log this\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\toutput, err := t.vm.RunProgram(script)\n\n\t\tif err != nil {\n\t\t\tt.onError(err)\n\t\t} else {\n\t\t\tt.makeOutput(output)\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (t *jsPluginTask) Wait() {\n\tt.waitGroup.Wait()\n}\n\nfunc (t *jsPluginTask) Stop() error {\n\tt.vm.Interrupt(errStop)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/log.go",
    "content": "package plugin\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nfunc (t *pluginTask) handlePluginStderr(name string, pluginOutputReader io.ReadCloser) {\n\tlogLevel := logger.PluginLogLevelFromName(t.plugin.PluginErrLogLevel)\n\tif logLevel == nil {\n\t\t// default log level to error\n\t\tlogLevel = &logger.ErrorLevel\n\t}\n\n\tconst pluginPrefix = \"[Plugin / %s] \"\n\n\tlgr := logger.PluginLogger{\n\t\tLogger:          logger.Logger,\n\t\tPrefix:          fmt.Sprintf(pluginPrefix, name),\n\t\tDefaultLogLevel: logLevel,\n\t\tProgressChan:    t.progress,\n\t}\n\n\tlgr.ReadLogMessages(pluginOutputReader)\n}\n"
  },
  {
    "path": "pkg/plugin/plugins.go",
    "content": "// Package plugin implements functions and types for maintaining and running\n// stash plugins.\n//\n// Stash plugins are configured using yml files in the configured plugins\n// directory. These yml files must follow the Config structure format.\n//\n// The main entry into the plugin sub-system is via the Cache type.\npackage plugin\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/session\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype Plugin struct {\n\tID          string          `json:\"id\"`\n\tName        string          `json:\"name\"`\n\tDescription *string         `json:\"description\"`\n\tURL         *string         `json:\"url\"`\n\tVersion     *string         `json:\"version\"`\n\tTasks       []*PluginTask   `json:\"tasks\"`\n\tHooks       []*PluginHook   `json:\"hooks\"`\n\tUI          PluginUI        `json:\"ui\"`\n\tSettings    []PluginSetting `json:\"settings\"`\n\n\tEnabled bool `json:\"enabled\"`\n\n\t// ConfigPath is the path to the plugin's configuration file.\n\tConfigPath string `json:\"-\"`\n}\n\ntype PluginUI struct {\n\t// Requires is a list of plugin IDs that this plugin depends on.\n\t// These plugins will be loaded before this plugin.\n\tRequires []string `json:\"requires\"`\n\n\t// Content Security Policy configuration for the plugin.\n\tCSP PluginCSP `json:\"csp\"`\n\n\t// External Javascript files that will be injected into the stash UI.\n\tExternalScript []string `json:\"external_script\"`\n\n\t// External CSS files that will be injected into the stash UI.\n\tExternalCSS []string `json:\"external_css\"`\n\n\t// Javascript files that will be injected into the stash UI.\n\tJavascript []string `json:\"javascript\"`\n\n\t// CSS files that will be injected into the stash UI.\n\tCSS []string `json:\"css\"`\n\n\t// Assets is a map of URL prefixes to hosted directories.\n\t// This allows plugins to serve static assets from a URL path.\n\t// Plugin assets are exposed via the /plugin/{pluginId}/assets path.\n\t// For example, if the plugin configuration file contains:\n\t// /foo: bar\n\t// /bar: baz\n\t// /: root\n\t// Then the following requests will be mapped to the following files:\n\t// /plugin/{pluginId}/assets/foo/file.txt -> {pluginDir}/foo/file.txt\n\t// /plugin/{pluginId}/assets/bar/file.txt -> {pluginDir}/baz/file.txt\n\t// /plugin/{pluginId}/assets/file.txt -> {pluginDir}/root/file.txt\n\tAssets utils.URLMap `json:\"assets\"`\n}\n\ntype PluginSetting struct {\n\tName string `json:\"name\"`\n\t// defaults to string\n\tType PluginSettingTypeEnum `json:\"type\"`\n\t// defaults to key name\n\tDisplayName string `json:\"displayName\"`\n\tDescription string `json:\"description\"`\n}\n\ntype ServerConfig interface {\n\tGetHost() string\n\tGetPort() int\n\tGetConfigPathAbs() string\n\tHasTLSConfig() bool\n\tGetPluginsPath() string\n\tGetDisabledPlugins() []string\n\tGetPythonPath() string\n}\n\n// Cache stores plugin details.\ntype Cache struct {\n\tconfig       ServerConfig\n\tplugins      []Config\n\tsessionStore *session.Store\n\tgqlHandler   http.Handler\n}\n\n// NewCache returns a new Cache.\n//\n// Plugins configurations are loaded from yml files in the plugin\n// directory in the config and any subdirectories.\n//\n// Does not load plugins. Plugins will need to be\n// loaded explicitly using ReloadPlugins.\nfunc NewCache(config ServerConfig) *Cache {\n\treturn &Cache{\n\t\tconfig: config,\n\t}\n}\n\nfunc (c *Cache) RegisterGQLHandler(handler http.Handler) {\n\tc.gqlHandler = handler\n}\n\nfunc (c *Cache) RegisterSessionStore(sessionStore *session.Store) {\n\tc.sessionStore = sessionStore\n}\n\n// ReloadPlugins clears the plugin cache and loads from the plugin path.\n// If a plugin cannot be loaded, an error is logged and the plugin is skipped.\nfunc (c *Cache) ReloadPlugins() {\n\tpath := c.config.GetPluginsPath()\n\t// # 4484 - ensure plugin ids are unique\n\tplugins := make([]Config, 0)\n\tpluginIDs := make(map[string]bool)\n\n\tlogger.Debugf(\"Reading plugin configs from %s\", path)\n\n\terr := fsutil.SymWalk(path, func(fp string, f os.FileInfo, err error) error {\n\t\tif filepath.Ext(fp) == \".yml\" {\n\t\t\tplugin, err := loadPluginFromYAMLFile(fp)\n\t\t\t// use case insensitive plugin IDs\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"Error loading plugin %s: %v\", fp, err)\n\t\t\t} else {\n\t\t\t\tpluginID := strings.ToLower(plugin.id)\n\t\t\t\tif _, exists := pluginIDs[pluginID]; exists {\n\t\t\t\t\tlogger.Errorf(\"Error loading plugin %s: plugin ID %s already exists\", fp, plugin.id)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tpluginIDs[pluginID] = true\n\t\t\t\tplugins = append(plugins, *plugin)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\tlogger.Errorf(\"Error reading plugin configs: %v\", err)\n\t}\n\n\tc.plugins = plugins\n}\n\nfunc (c Cache) enabledPlugins() []Config {\n\tdisabledPlugins := c.config.GetDisabledPlugins()\n\n\tvar ret []Config\n\tfor _, p := range c.plugins {\n\t\tdisabled := slices.Contains(disabledPlugins, p.id)\n\n\t\tif !disabled {\n\t\t\tret = append(ret, p)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (c Cache) pluginDisabled(id string) bool {\n\tdisabledPlugins := c.config.GetDisabledPlugins()\n\n\treturn slices.Contains(disabledPlugins, id)\n}\n\n// ListPlugins returns plugin details for all of the loaded plugins.\nfunc (c Cache) ListPlugins() []*Plugin {\n\tdisabledPlugins := c.config.GetDisabledPlugins()\n\n\tvar ret []*Plugin\n\tfor _, s := range c.plugins {\n\t\tp := s.toPlugin()\n\n\t\tdisabled := slices.Contains(disabledPlugins, p.ID)\n\t\tp.Enabled = !disabled\n\n\t\tret = append(ret, p)\n\t}\n\n\treturn ret\n}\n\n// GetPlugin returns the plugin with the given ID.\n// Returns nil if the plugin is not found.\nfunc (c Cache) GetPlugin(id string) *Plugin {\n\tdisabledPlugins := c.config.GetDisabledPlugins()\n\tplugin := c.getPlugin(id)\n\tif plugin != nil {\n\t\tp := plugin.toPlugin()\n\n\t\tdisabled := slices.Contains(disabledPlugins, p.ID)\n\t\tp.Enabled = !disabled\n\t\treturn p\n\t}\n\n\treturn nil\n}\n\n// ListPluginTasks returns all runnable plugin tasks in all loaded plugins.\nfunc (c Cache) ListPluginTasks() []*PluginTask {\n\tvar ret []*PluginTask\n\tfor _, s := range c.enabledPlugins() {\n\t\tret = append(ret, s.getPluginTasks(true)...)\n\t}\n\n\treturn ret\n}\n\nfunc buildPluginInput(plugin *Config, operation *OperationConfig, serverConnection common.StashServerConnection, args OperationInput) common.PluginInput {\n\tif args == nil {\n\t\targs = make(OperationInput)\n\t}\n\tif operation != nil {\n\t\tapplyDefaultArgs(args, operation.DefaultArgs)\n\t}\n\tserverConnection.PluginDir = plugin.getConfigPath()\n\treturn common.PluginInput{\n\t\tServerConnection: serverConnection,\n\t\tArgs:             toPluginArgs(args),\n\t}\n}\n\nfunc (c Cache) makeServerConnection(ctx context.Context) common.StashServerConnection {\n\tcookie := c.sessionStore.MakePluginCookie(ctx)\n\n\tserverConnection := common.StashServerConnection{\n\t\tScheme:        \"http\",\n\t\tHost:          c.config.GetHost(),\n\t\tPort:          c.config.GetPort(),\n\t\tSessionCookie: cookie,\n\t\tDir:           c.config.GetConfigPathAbs(),\n\t}\n\n\tif c.config.HasTLSConfig() {\n\t\tserverConnection.Scheme = \"https\"\n\t}\n\n\treturn serverConnection\n}\n\n// CreateTask runs the plugin operation for the pluginID and operation\n// name provided. Returns an error if the plugin or the operation could not be\n// resolved.\nfunc (c Cache) CreateTask(ctx context.Context, pluginID string, operationName *string, args OperationInput, progress chan float64) (Task, error) {\n\tserverConnection := c.makeServerConnection(ctx)\n\n\tif c.pluginDisabled(pluginID) {\n\t\treturn nil, fmt.Errorf(\"plugin %s is disabled\", pluginID)\n\t}\n\n\t// find the plugin and operation\n\tplugin := c.getPlugin(pluginID)\n\n\tif plugin == nil {\n\t\treturn nil, fmt.Errorf(\"no plugin with ID %s\", pluginID)\n\t}\n\n\tvar operation *OperationConfig\n\tif operationName != nil {\n\t\toperation = plugin.getTask(*operationName)\n\t\tif operation == nil {\n\t\t\treturn nil, fmt.Errorf(\"no task with name %s in plugin %s\", *operationName, plugin.getName())\n\t\t}\n\t}\n\n\ttask := pluginTask{\n\t\tplugin:       plugin,\n\t\toperation:    operation,\n\t\tinput:        buildPluginInput(plugin, operation, serverConnection, args),\n\t\tprogress:     progress,\n\t\tgqlHandler:   c.gqlHandler,\n\t\tserverConfig: c.config,\n\t}\n\treturn task.createTask(), nil\n}\n\nfunc (c Cache) RunPlugin(ctx context.Context, pluginID string, args OperationInput) (interface{}, error) {\n\tserverConnection := c.makeServerConnection(ctx)\n\n\tif c.pluginDisabled(pluginID) {\n\t\treturn nil, fmt.Errorf(\"plugin %s is disabled\", pluginID)\n\t}\n\n\t// find the plugin\n\tplugin := c.getPlugin(pluginID)\n\n\tpluginInput := buildPluginInput(plugin, nil, serverConnection, args)\n\n\tpt := pluginTask{\n\t\tplugin:       plugin,\n\t\tinput:        pluginInput,\n\t\tgqlHandler:   c.gqlHandler,\n\t\tserverConfig: c.config,\n\t}\n\n\ttask := pt.createTask()\n\tif err := task.Start(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := waitForTask(ctx, task); err != nil {\n\t\treturn nil, err\n\t}\n\n\toutput := task.GetResult()\n\tif output == nil {\n\t\tlogger.Debugf(\"%s: returned no result\", pluginID)\n\t\treturn nil, nil\n\t} else {\n\t\tif output.Error != nil {\n\t\t\treturn nil, errors.New(*output.Error)\n\t\t}\n\n\t\treturn output.Output, nil\n\t}\n}\n\nfunc waitForTask(ctx context.Context, task Task) error {\n\t// handle cancel from context\n\tc := make(chan struct{})\n\tgo func() {\n\t\ttask.Wait()\n\t\tclose(c)\n\t}()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tif err := task.Stop(); err != nil {\n\t\t\tlogger.Warnf(\"could not stop task: %v\", err)\n\t\t}\n\t\treturn fmt.Errorf(\"operation cancelled\")\n\tcase <-c:\n\t\t// task finished normally\n\t}\n\n\treturn nil\n}\n\nfunc (c Cache) ExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) {\n\tif err := c.executePostHooks(ctx, hookType, common.HookContext{\n\t\tID:          id,\n\t\tType:        hookType.String(),\n\t\tInput:       input,\n\t\tInputFields: inputFields,\n\t}); err != nil {\n\t\tlogger.Errorf(\"error executing post hooks: %s\", err.Error())\n\t}\n}\n\nfunc (c Cache) RegisterPostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) {\n\ttxn.AddPostCommitHook(ctx, func(ctx context.Context) {\n\t\tc.ExecutePostHooks(ctx, id, hookType, input, inputFields)\n\t})\n}\n\nfunc (c Cache) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) {\n\tid, err := strconv.Atoi(input.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"error converting id in SceneUpdatePostHooks: %v\", err)\n\t\treturn\n\t}\n\tc.ExecutePostHooks(ctx, id, hook.SceneUpdatePost, input, inputFields)\n}\n\n// maxCyclicLoopDepth is the maximum number of identical plugin hook calls that\n// can be made before a cyclic loop is detected. It is set to an arbitrary value\n// that should not be hit under normal circumstances.\nconst maxCyclicLoopDepth = 10\n\nfunc (c Cache) executePostHooks(ctx context.Context, hookType hook.TriggerEnum, hookContext common.HookContext) error {\n\tvisitedPluginHookCounts := getVisitedPluginHookCounts(ctx)\n\n\tfor _, p := range c.enabledPlugins() {\n\t\thooks := p.getHooks(hookType)\n\t\t// don't revisit a plugin we've already visited\n\t\t// only log if there's hooks that we're skipping\n\t\tif len(hooks) > 0 && visitedPluginHookCounts.For(p.id, hookType) >= maxCyclicLoopDepth {\n\t\t\tlogger.Debugf(\"cyclic loop detected: plugin ID '%s' hook %s, not re-triggering\", p.id, hookType)\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, h := range hooks {\n\t\t\tnewCtx := session.AddVisitedPluginHook(ctx, p.id, hookType)\n\t\t\tserverConnection := c.makeServerConnection(newCtx)\n\n\t\t\tpluginInput := buildPluginInput(&p, &h.OperationConfig, serverConnection, nil)\n\t\t\taddHookContext(pluginInput.Args, hookContext)\n\n\t\t\tpt := pluginTask{\n\t\t\t\tplugin:       &p,\n\t\t\t\toperation:    &h.OperationConfig,\n\t\t\t\tinput:        pluginInput,\n\t\t\t\tgqlHandler:   c.gqlHandler,\n\t\t\t\tserverConfig: c.config,\n\t\t\t}\n\n\t\t\ttask := pt.createTask()\n\t\t\tif err := task.Start(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := waitForTask(ctx, task); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\toutput := task.GetResult()\n\t\t\tif output == nil {\n\t\t\t\tlogger.Debugf(\"%s [%s]: returned no result\", hookType.String(), p.Name)\n\t\t\t} else {\n\t\t\t\tif output.Error != nil {\n\t\t\t\t\tlogger.Errorf(\"%s [%s]: returned error: %s\", hookType.String(), p.Name, *output.Error)\n\t\t\t\t} else if output.Output != nil {\n\t\t\t\t\tlogger.Debugf(\"%s [%s]: returned: %v\", hookType.String(), p.Name, output.Output)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype visitedPluginHookCount struct {\n\tsession.VisitedPluginHook\n\tCount int\n}\n\ntype visitedPluginHookCounts []visitedPluginHookCount\n\nfunc (v visitedPluginHookCounts) For(pluginID string, hookType hook.TriggerEnum) int {\n\tfor _, c := range v {\n\t\tif c.VisitedPluginHook.PluginID == pluginID && c.VisitedPluginHook.HookType == hookType {\n\t\t\treturn c.Count\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc getVisitedPluginHookCounts(ctx context.Context) visitedPluginHookCounts {\n\tvisitedPluginHooks := session.GetVisitedPluginHooks(ctx)\n\n\tvisitedPluginHookCounts := make([]visitedPluginHookCount, 0)\n\tfor _, p := range visitedPluginHooks {\n\t\tfound := false\n\t\tfor i, v := range visitedPluginHookCounts {\n\t\t\tif v.VisitedPluginHook == p {\n\t\t\t\tvisitedPluginHookCounts[i].Count++\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tvisitedPluginHookCounts = append(visitedPluginHookCounts, visitedPluginHookCount{\n\t\t\t\tVisitedPluginHook: p,\n\t\t\t\tCount:             1,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn visitedPluginHookCounts\n}\n\nfunc (c Cache) getPlugin(pluginID string) *Config {\n\tfor _, s := range c.plugins {\n\t\tif s.id == pluginID {\n\t\t\treturn &s\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/raw.go",
    "content": "package plugin\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\n\tstashExec \"github.com/stashapp/stash/pkg/exec\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n\t\"github.com/stashapp/stash/pkg/python\"\n)\n\ntype rawTaskBuilder struct{}\n\nfunc (*rawTaskBuilder) build(task pluginTask) Task {\n\treturn &rawPluginTask{\n\t\tpluginTask: task,\n\t}\n}\n\ntype rawPluginTask struct {\n\tpluginTask\n\n\tstarted   bool\n\twaitGroup sync.WaitGroup\n\tcmd       *exec.Cmd\n\tdone      chan bool\n}\n\nfunc (t *rawPluginTask) Start() error {\n\tif t.started {\n\t\treturn errors.New(\"task already started\")\n\t}\n\n\tcommand := t.plugin.getExecCommand(t.operation)\n\tif len(command) == 0 {\n\t\treturn fmt.Errorf(\"empty exec value\")\n\t}\n\n\tvar cmd *exec.Cmd\n\tif python.IsPythonCommand(command[0]) {\n\t\tpythonPath := t.serverConfig.GetPythonPath()\n\t\tp, err := python.Resolve(pythonPath)\n\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"%s\", err)\n\t\t} else {\n\t\t\tcmd = p.Command(context.TODO(), command[1:])\n\n\t\t\tenvVariable, _ := filepath.Abs(filepath.Dir(filepath.Dir(t.plugin.path)))\n\t\t\tpython.AppendPythonPath(cmd, envVariable)\n\t\t}\n\t}\n\n\tif cmd == nil {\n\t\t// if could not find python, just use the command args as-is\n\t\tcmd = stashExec.Command(command[0], command[1:]...)\n\t}\n\n\tstdin, err := cmd.StdinPipe()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting plugin process stdin: %v\", err)\n\t}\n\n\tgo func() {\n\t\tdefer stdin.Close()\n\n\t\tinBytes, err := json.Marshal(t.input)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"error marshalling raw command input\")\n\t\t}\n\t\tif k, err := stdin.Write(inBytes); err != nil {\n\t\t\tlogger.Warnf(\"error writing input to plugins stdin (wrote %v bytes out of %v): %v\", k, len(string(inBytes)), err)\n\t\t}\n\t}()\n\n\tstderr, err := cmd.StderrPipe()\n\tif err != nil {\n\t\tlogger.Error(\"plugin stderr not available: \" + err.Error())\n\t}\n\n\tstdout, err := cmd.StdoutPipe()\n\tif nil != err {\n\t\tlogger.Error(\"plugin stdout not available: \" + err.Error())\n\t}\n\n\tt.waitGroup.Add(1)\n\tt.done = make(chan bool, 1)\n\tif err = cmd.Start(); err != nil {\n\t\treturn fmt.Errorf(\"error running plugin: %v\", err)\n\t}\n\n\tgo t.handlePluginStderr(t.plugin.Name, stderr)\n\tt.cmd = cmd\n\n\tlogger.Debugf(\"Plugin %s started: %s\", t.plugin.Name, strings.Join(cmd.Args, \" \"))\n\n\t// send the stdout to the plugin output\n\tgo func() {\n\t\tdefer t.waitGroup.Done()\n\t\tdefer close(t.done)\n\t\tstdoutData, _ := io.ReadAll(stdout)\n\t\tstdoutString := string(stdoutData)\n\n\t\toutput := t.getOutput(stdoutString)\n\n\t\terr := cmd.Wait()\n\t\tif err != nil && output.Error == nil {\n\t\t\terrStr := err.Error()\n\t\t\toutput.Error = &errStr\n\t\t}\n\t\tlogger.Debugf(\"Plugin %s finished\", t.plugin.Name)\n\n\t\tt.result = &output\n\t}()\n\n\tt.started = true\n\treturn nil\n}\n\nfunc (t *rawPluginTask) getOutput(output string) common.PluginOutput {\n\t// try to parse the output as a PluginOutput json. If it fails just\n\t// get the raw output\n\tret := common.PluginOutput{}\n\tdecodeErr := json.Unmarshal([]byte(output), &ret)\n\n\tif decodeErr != nil {\n\t\tret.Output = &output\n\t}\n\n\treturn ret\n}\n\nfunc (t *rawPluginTask) Wait() {\n\tt.waitGroup.Wait()\n}\n\nfunc (t *rawPluginTask) Stop() error {\n\tif t.cmd == nil {\n\t\treturn nil\n\t}\n\n\treturn t.cmd.Process.Kill()\n}\n"
  },
  {
    "path": "pkg/plugin/rpc.go",
    "content": "package plugin\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/rpc\"\n\t\"net/rpc/jsonrpc\"\n\t\"sync\"\n\n\t\"github.com/natefinch/pie\"\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n)\n\ntype rpcTaskBuilder struct{}\n\nfunc (*rpcTaskBuilder) build(task pluginTask) Task {\n\treturn &rpcPluginTask{\n\t\tpluginTask: task,\n\t}\n}\n\ntype rpcPluginClient struct {\n\tClient *rpc.Client\n}\n\nfunc (p rpcPluginClient) Run(input common.PluginInput, output *common.PluginOutput) error {\n\treturn p.Client.Call(\"RPCRunner.Run\", input, output)\n}\n\nfunc (p rpcPluginClient) RunAsync(input common.PluginInput, output *common.PluginOutput, done chan *rpc.Call) *rpc.Call {\n\treturn p.Client.Go(\"RPCRunner.Run\", input, output, done)\n}\n\nfunc (p rpcPluginClient) Stop() error {\n\tvar resp interface{}\n\treturn p.Client.Call(\"RPCRunner.Stop\", nil, &resp)\n}\n\ntype rpcPluginTask struct {\n\tpluginTask\n\n\tstarted   bool\n\tclient    *rpc.Client\n\twaitGroup sync.WaitGroup\n\tdone      chan *rpc.Call\n}\n\nfunc (t *rpcPluginTask) Start() error {\n\tif t.started {\n\t\treturn errors.New(\"task already started\")\n\t}\n\n\tcommand := t.plugin.getExecCommand(t.operation)\n\tif len(command) == 0 {\n\t\treturn fmt.Errorf(\"empty exec value\")\n\t}\n\n\tpluginErrReader, pluginErrWriter := io.Pipe()\n\n\tvar err error\n\tt.client, err = pie.StartProviderCodec(jsonrpc.NewClientCodec, pluginErrWriter, command[0], command[1:]...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgo t.handlePluginStderr(t.plugin.Name, pluginErrReader)\n\n\tiface := rpcPluginClient{\n\t\tClient: t.client,\n\t}\n\n\tt.done = make(chan *rpc.Call, 1)\n\tresult := common.PluginOutput{}\n\tt.waitGroup.Add(1)\n\tiface.RunAsync(t.input, &result, t.done)\n\tgo t.waitToFinish(&result)\n\n\tt.started = true\n\treturn nil\n}\n\nfunc (t *rpcPluginTask) waitToFinish(result *common.PluginOutput) {\n\tdefer t.client.Close()\n\tdefer t.waitGroup.Done()\n\t<-t.done\n\n\tt.result = result\n}\n\nfunc (t *rpcPluginTask) Wait() {\n\tt.waitGroup.Wait()\n}\n\nfunc (t *rpcPluginTask) Stop() error {\n\tiface := rpcPluginClient{\n\t\tClient: t.client,\n\t}\n\n\treturn iface.Stop()\n}\n"
  },
  {
    "path": "pkg/plugin/setting.go",
    "content": "package plugin\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n)\n\ntype PluginSettingTypeEnum string\n\nconst (\n\tPluginSettingTypeEnumString  PluginSettingTypeEnum = \"STRING\"\n\tPluginSettingTypeEnumNumber  PluginSettingTypeEnum = \"NUMBER\"\n\tPluginSettingTypeEnumBoolean PluginSettingTypeEnum = \"BOOLEAN\"\n)\n\nvar AllPluginSettingTypeEnum = []PluginSettingTypeEnum{\n\tPluginSettingTypeEnumString,\n\tPluginSettingTypeEnumNumber,\n\tPluginSettingTypeEnumBoolean,\n}\n\nfunc (e PluginSettingTypeEnum) IsValid() bool {\n\tswitch e {\n\tcase PluginSettingTypeEnumString, PluginSettingTypeEnumNumber, PluginSettingTypeEnumBoolean:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e PluginSettingTypeEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *PluginSettingTypeEnum) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = PluginSettingTypeEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid PluginSettingTypeEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e PluginSettingTypeEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n"
  },
  {
    "path": "pkg/plugin/task.go",
    "content": "package plugin\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n)\n\ntype PluginTask struct {\n\tName        string  `json:\"name\"`\n\tDescription *string `json:\"description\"`\n\tPlugin      *Plugin `json:\"plugin\"`\n}\n\n// Task is the interface that handles management of a single plugin task.\ntype Task interface {\n\t// Start starts the plugin task. Returns an error if task could not be\n\t// started or the task has already been started.\n\tStart() error\n\n\t// Stop instructs a running plugin task to stop and returns immediately.\n\t// Use Wait to subsequently wait for the task to stop.\n\tStop() error\n\n\t// Wait blocks until the plugin task is complete. Returns immediately if\n\t// task has not been started.\n\tWait()\n\n\t// GetResult returns the output of the plugin task. Returns nil if the task\n\t// has not completed.\n\tGetResult() *common.PluginOutput\n}\n\ntype taskBuilder interface {\n\tbuild(task pluginTask) Task\n}\n\ntype pluginTask struct {\n\tplugin       *Config\n\toperation    *OperationConfig\n\tinput        common.PluginInput\n\tgqlHandler   http.Handler\n\tserverConfig ServerConfig\n\n\tprogress chan float64\n\tresult   *common.PluginOutput\n}\n\nfunc (t *pluginTask) GetResult() *common.PluginOutput {\n\treturn t.result\n}\n\nfunc (t *pluginTask) createTask() Task {\n\treturn t.plugin.Interface.getTaskBuilder().build(*t)\n}\n"
  },
  {
    "path": "pkg/plugin/util/client.go",
    "content": "// Package util implements utility and convenience methods for plugins. It is\n// not intended for the main stash code to access.\npackage util\n\nimport (\n\t\"net/http\"\n\t\"net/http/cookiejar\"\n\t\"net/url\"\n\t\"strconv\"\n\n\tgraphql \"github.com/hasura/go-graphql-client\"\n\n\t\"github.com/stashapp/stash/pkg/plugin/common\"\n)\n\n// NewClient creates a graphql Client connecting to the stash server using\n// the provided server connection details.\n// Always connects to the graphql endpoint of the localhost.\nfunc NewClient(provider common.StashServerConnection) *graphql.Client {\n\tportStr := strconv.Itoa(provider.Port)\n\n\tu, _ := url.Parse(\"http://\" + provider.Host + \":\" + portStr + \"/graphql\")\n\tu.Scheme = provider.Scheme\n\n\tcookieJar, _ := cookiejar.New(nil)\n\n\tcookie := provider.SessionCookie\n\tif cookie != nil {\n\t\tcookieJar.SetCookies(u, []*http.Cookie{\n\t\t\tcookie,\n\t\t})\n\t}\n\n\thttpClient := &http.Client{\n\t\tJar: cookieJar,\n\t}\n\n\treturn graphql.NewClient(u.String(), httpClient)\n}\n"
  },
  {
    "path": "pkg/python/env.go",
    "content": "package python\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n)\n\nfunc AppendPythonPath(cmd *exec.Cmd, path string) {\n\t// Respect the users PYTHONPATH if set\n\tif currentValue, set := os.LookupEnv(\"PYTHONPATH\"); set {\n\t\tpath = fmt.Sprintf(\"%s%c%s\", currentValue, os.PathListSeparator, path)\n\t}\n\tcmd.Env = append(os.Environ(), fmt.Sprintf(\"PYTHONPATH=%s\", path))\n}\n"
  },
  {
    "path": "pkg/python/exec.go",
    "content": "// Package python provides utilities for working with the python executable.\npackage python\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os/exec\"\n\n\tstashExec \"github.com/stashapp/stash/pkg/exec\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype Python string\n\nfunc (p *Python) Command(ctx context.Context, args []string) *exec.Cmd {\n\treturn stashExec.CommandContext(ctx, string(*p), args...)\n}\n\n// New returns a new Python instance at the given path.\nfunc New(path string) *Python {\n\tret := Python(path)\n\treturn &ret\n}\n\n// Resolve tries to find the python executable in the system.\n// It first checks for python3, then python.\n// Returns nil and an exec.ErrNotFound error if not found.\nfunc Resolve(configuredPythonPath string) (*Python, error) {\n\tif configuredPythonPath != \"\" {\n\t\tisFile, err := fsutil.FileExists(configuredPythonPath)\n\t\tswitch {\n\t\tcase err == nil && isFile:\n\t\t\tlogger.Tracef(\"using configured python path: %s\", configuredPythonPath)\n\t\t\treturn New(configuredPythonPath), nil\n\t\tcase err == nil && !isFile:\n\t\t\tlogger.Warnf(\"configured python path is not a file: %s\", configuredPythonPath)\n\t\tcase err != nil:\n\t\t\tlogger.Warnf(\"unable to use configured python path: %v\", err)\n\t\t}\n\t}\n\n\tpython3, err := exec.LookPath(\"python3\")\n\n\tif err != nil {\n\t\tpython, err := exec.LookPath(\"python\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"python executable not in PATH: %w\", err)\n\t\t}\n\t\tret := Python(python)\n\t\treturn &ret, nil\n\t}\n\n\tret := Python(python3)\n\treturn &ret, nil\n}\n\n// IsPythonCommand returns true if arg is \"python\" or \"python3\"\nfunc IsPythonCommand(arg string) bool {\n\treturn arg == \"python\" || arg == \"python3\"\n}\n"
  },
  {
    "path": "pkg/savedfilter/export.go",
    "content": "package savedfilter\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n)\n\n// ToJSON converts a SavedFilter object into its JSON equivalent.\nfunc ToJSON(ctx context.Context, filter *models.SavedFilter) (*jsonschema.SavedFilter, error) {\n\treturn &jsonschema.SavedFilter{\n\t\tName:         filter.Name,\n\t\tMode:         filter.Mode,\n\t\tFindFilter:   filter.FindFilter,\n\t\tObjectFilter: filter.ObjectFilter,\n\t\tUIOptions:    filter.UIOptions,\n\t}, nil\n}\n"
  },
  {
    "path": "pkg/savedfilter/export_test.go",
    "content": "package savedfilter\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"testing\"\n)\n\nconst (\n\tsavedFilterID = 1\n\tnoImageID     = 2\n\terrImageID    = 3\n\terrAliasID    = 4\n\twithParentsID = 5\n\terrParentsID  = 6\n)\n\nconst (\n\tfilterName = \"testFilter\"\n\tmode       = models.FilterModeGalleries\n)\n\nvar (\n\tfindFilter   = models.FindFilterType{}\n\tobjectFilter = make(map[string]interface{})\n\tuiOptions    = make(map[string]interface{})\n)\n\nfunc createSavedFilter(id int) models.SavedFilter {\n\treturn models.SavedFilter{\n\t\tID:           id,\n\t\tName:         filterName,\n\t\tMode:         mode,\n\t\tFindFilter:   &findFilter,\n\t\tObjectFilter: objectFilter,\n\t\tUIOptions:    uiOptions,\n\t}\n}\n\nfunc createJSONSavedFilter() *jsonschema.SavedFilter {\n\treturn &jsonschema.SavedFilter{\n\t\tName:         filterName,\n\t\tMode:         mode,\n\t\tFindFilter:   &findFilter,\n\t\tObjectFilter: objectFilter,\n\t\tUIOptions:    uiOptions,\n\t}\n}\n\ntype testScenario struct {\n\tsavedFilter models.SavedFilter\n\texpected    *jsonschema.SavedFilter\n\terr         bool\n}\n\nvar scenarios []testScenario\n\nfunc initTestTable() {\n\tscenarios = []testScenario{\n\t\t{\n\t\t\tcreateSavedFilter(savedFilterID),\n\t\t\tcreateJSONSavedFilter(),\n\t\t\tfalse,\n\t\t},\n\t}\n}\n\nfunc TestToJSON(t *testing.T) {\n\tinitTestTable()\n\n\tdb := mocks.NewDatabase()\n\n\tfor i, s := range scenarios {\n\t\tsavedFilter := s.savedFilter\n\t\tjson, err := ToJSON(testCtx, &savedFilter)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/savedfilter/import.go",
    "content": "package savedfilter\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n)\n\ntype ImporterReaderWriter interface {\n\tmodels.SavedFilterWriter\n}\n\ntype Importer struct {\n\tReaderWriter        ImporterReaderWriter\n\tInput               jsonschema.SavedFilter\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\tsavedFilter models.SavedFilter\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\ti.savedFilter = models.SavedFilter{\n\t\tName:         i.Input.Name,\n\t\tMode:         i.Input.Mode,\n\t\tFindFilter:   i.Input.FindFilter,\n\t\tObjectFilter: i.Input.ObjectFilter,\n\t\tUIOptions:    i.Input.UIOptions,\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\treturn i.Input.Name\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\t// for now, assume this is only imported in full, so we don't support updating existing filters\n\treturn nil, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\terr := i.ReaderWriter.Create(ctx, &i.savedFilter)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating saved filter: %v\", err)\n\t}\n\n\tid := i.savedFilter.ID\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\treturn fmt.Errorf(\"updating existing saved filters is not supported\")\n}\n"
  },
  {
    "path": "pkg/savedfilter/import_test.go",
    "content": "package savedfilter\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nconst (\n\tsavedFilterNameErr      = \"savedFilterNameErr\"\n\texistingSavedFilterName = \"existingSavedFilterName\"\n\n\texistingFilterID = 100\n)\n\nvar testCtx = context.Background()\n\nfunc TestImporterName(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.SavedFilter{\n\t\t\tName: filterName,\n\t\t},\n\t}\n\n\tassert.Equal(t, filterName, i.Name())\n}\n\nfunc TestImporterPreImport(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.SavedFilter{\n\t\t\tName: filterName,\n\t\t},\n\t}\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n}\n\nfunc TestImporterPostImport(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.SavedFilter,\n\t\tInput:        jsonschema.SavedFilter{},\n\t}\n\n\terr := i.PostImport(testCtx, savedFilterID)\n\tassert.Nil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterFindExistingID(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.SavedFilter,\n\t\tInput: jsonschema.SavedFilter{\n\t\t\tName: filterName,\n\t\t},\n\t}\n\n\tid, err := i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.Nil(t, err)\n}\n\nfunc TestCreate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tsavedFilter := models.SavedFilter{\n\t\tName: filterName,\n\t}\n\n\tsavedFilterErr := models.SavedFilter{\n\t\tName: savedFilterNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.SavedFilter,\n\t\tsavedFilter:  savedFilter,\n\t}\n\n\terrCreate := errors.New(\"Create error\")\n\tdb.SavedFilter.On(\"Create\", testCtx, &savedFilter).Run(func(args mock.Arguments) {\n\t\tt := args.Get(1).(*models.SavedFilter)\n\t\tt.ID = savedFilterID\n\t}).Return(nil).Once()\n\tdb.SavedFilter.On(\"Create\", testCtx, &savedFilterErr).Return(errCreate).Once()\n\n\tid, err := i.Create(testCtx)\n\tassert.Equal(t, savedFilterID, *id)\n\tassert.Nil(t, err)\n\n\ti.savedFilter = savedFilterErr\n\tid, err = i.Create(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestUpdate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tsavedFilterErr := models.SavedFilter{\n\t\tName: savedFilterNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.SavedFilter,\n\t\tsavedFilter:  savedFilterErr,\n\t}\n\n\t// Update is not currently supported\n\terr := i.Update(testCtx, existingFilterID)\n\tassert.NotNil(t, err)\n}\n"
  },
  {
    "path": "pkg/scene/create.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n)\n\nfunc (s *Service) Create(ctx context.Context, input models.CreateSceneInput) (*models.Scene, error) {\n\t// title must be set if no files are provided\n\tif input.Scene.Title == \"\" && len(input.FileIDs) == 0 {\n\t\treturn nil, errors.New(\"title must be set if scene has no files\")\n\t}\n\n\tnow := time.Now()\n\tnewScene := *input.Scene\n\tnewScene.CreatedAt = now\n\tnewScene.UpdatedAt = now\n\n\t// don't pass the file ids since they may be already assigned\n\t// assign them afterwards\n\tif err := s.Repository.Create(ctx, &newScene, nil); err != nil {\n\t\treturn nil, fmt.Errorf(\"creating new scene: %w\", err)\n\t}\n\n\tif len(input.CustomFields) > 0 {\n\t\tif err := s.Repository.SetCustomFields(ctx, newScene.ID, models.CustomFieldsInput{\n\t\t\tFull: input.CustomFields,\n\t\t}); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"setting custom fields on new scene: %w\", err)\n\t\t}\n\t}\n\n\tfor _, f := range input.FileIDs {\n\t\tif err := s.AssignFile(ctx, newScene.ID, f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"assigning file %d to new scene: %w\", f, err)\n\t\t}\n\t}\n\n\tif len(input.FileIDs) > 0 {\n\t\t// assign the primary to the first\n\t\tif _, err := s.Repository.UpdatePartial(ctx, newScene.ID, models.ScenePartial{\n\t\t\tPrimaryFileID: &input.FileIDs[0],\n\t\t}); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"setting primary file on new scene: %w\", err)\n\t\t}\n\t}\n\n\t// re-find the scene so that it correctly returns file-related fields\n\tret, err := s.Repository.Find(ctx, newScene.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(input.CoverImage) > 0 {\n\t\tif err := s.Repository.UpdateCover(ctx, ret.ID, input.CoverImage); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"setting cover on new scene: %w\", err)\n\t\t}\n\t}\n\n\ts.PluginCache.RegisterPostHooks(ctx, ret.ID, hook.SceneCreatePost, nil, nil)\n\n\t// re-find the scene so that it correctly returns file-related fields\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/scene/delete.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/file/video\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n)\n\n// FileDeleter is an extension of file.Deleter that handles deletion of scene files.\ntype FileDeleter struct {\n\t*file.Deleter\n\n\tFileNamingAlgo models.HashAlgorithm\n\tPaths          *paths.Paths\n}\n\n// MarkGeneratedFiles marks for deletion the generated files for the provided scene.\n// Generated files bypass trash and are permanently deleted since they can be regenerated.\nfunc (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {\n\tsceneHash := scene.GetHash(d.FileNamingAlgo)\n\n\tif sceneHash == \"\" {\n\t\treturn nil\n\t}\n\n\tmarkersFolder := filepath.Join(d.Paths.Generated.Markers, sceneHash)\n\n\texists, _ := fsutil.FileExists(markersFolder)\n\tif exists {\n\t\tif err := d.DirsWithoutTrash([]string{markersFolder}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tvar files []string\n\n\tstreamPreviewPath := d.Paths.Scene.GetVideoPreviewPath(sceneHash)\n\texists, _ = fsutil.FileExists(streamPreviewPath)\n\tif exists {\n\t\tfiles = append(files, streamPreviewPath)\n\t}\n\n\tstreamPreviewImagePath := d.Paths.Scene.GetWebpPreviewPath(sceneHash)\n\texists, _ = fsutil.FileExists(streamPreviewImagePath)\n\tif exists {\n\t\tfiles = append(files, streamPreviewImagePath)\n\t}\n\n\ttranscodePath := d.Paths.Scene.GetTranscodePath(sceneHash)\n\texists, _ = fsutil.FileExists(transcodePath)\n\tif exists {\n\t\tfiles = append(files, transcodePath)\n\t}\n\n\tspritePath := d.Paths.Scene.GetSpriteImageFilePath(sceneHash)\n\texists, _ = fsutil.FileExists(spritePath)\n\tif exists {\n\t\tfiles = append(files, spritePath)\n\t}\n\n\tvttPath := d.Paths.Scene.GetSpriteVttFilePath(sceneHash)\n\texists, _ = fsutil.FileExists(vttPath)\n\tif exists {\n\t\tfiles = append(files, vttPath)\n\t}\n\n\theatmapPath := d.Paths.Scene.GetInteractiveHeatmapPath(sceneHash)\n\texists, _ = fsutil.FileExists(heatmapPath)\n\tif exists {\n\t\tfiles = append(files, heatmapPath)\n\t}\n\n\treturn d.FilesWithoutTrash(files)\n}\n\n// MarkMarkerFiles deletes generated files for a scene marker with the\n// provided scene and timestamp.\n// Generated files bypass trash and are permanently deleted since they can be regenerated.\nfunc (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {\n\tvideoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)\n\timagePath := d.Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)\n\tscreenshotPath := d.Paths.SceneMarkers.GetScreenshotPath(scene.GetHash(d.FileNamingAlgo), seconds)\n\n\tvar files []string\n\n\texists, _ := fsutil.FileExists(videoPath)\n\tif exists {\n\t\tfiles = append(files, videoPath)\n\t}\n\n\texists, _ = fsutil.FileExists(imagePath)\n\tif exists {\n\t\tfiles = append(files, imagePath)\n\t}\n\n\texists, _ = fsutil.FileExists(screenshotPath)\n\tif exists {\n\t\tfiles = append(files, screenshotPath)\n\t}\n\n\treturn d.FilesWithoutTrash(files)\n}\n\n// Destroy deletes a scene and its associated relationships from the\n// database.\nfunc (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {\n\tmqb := s.MarkerRepository\n\tmarkers, err := mqb.FindBySceneID(ctx, scene.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, m := range markers {\n\t\tif err := DestroyMarker(ctx, scene, m, mqb, fileDeleter); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif deleteFile {\n\t\tif err := s.deleteFiles(ctx, scene, fileDeleter); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else if destroyFileEntry {\n\t\tif err := s.destroyFileEntries(ctx, scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif deleteGenerated {\n\t\tif err := fileDeleter.MarkGeneratedFiles(scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := s.Repository.Destroy(ctx, scene.ID); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// deleteFiles deletes files from the database and file system\nfunc (s *Service) deleteFiles(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter) error {\n\tif err := scene.LoadFiles(ctx, s.Repository); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, f := range scene.Files.List() {\n\t\t// only delete files where there is no other associated scene\n\t\totherScenes, err := s.Repository.FindByFileID(ctx, f.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(otherScenes) > 1 {\n\t\t\t// other scenes associated, don't remove\n\t\t\tcontinue\n\t\t}\n\n\t\tconst deleteFile = true\n\t\tlogger.Info(\"Deleting scene file: \", f.Path)\n\t\tif err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// don't delete files in zip archives\n\t\tif f.ZipFileID == nil {\n\t\t\tfunscriptPath := video.GetFunscriptPath(f.Path)\n\t\t\tfunscriptExists, _ := fsutil.FileExists(funscriptPath)\n\t\t\tif funscriptExists {\n\t\t\t\tif err := fileDeleter.Files([]string{funscriptPath}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// destroyFileEntries destroys file entries from the database without deleting\n// the files from the filesystem\nfunc (s *Service) destroyFileEntries(ctx context.Context, scene *models.Scene) error {\n\tif err := scene.LoadFiles(ctx, s.Repository); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, f := range scene.Files.List() {\n\t\t// only destroy file entries where there is no other associated scene\n\t\totherScenes, err := s.Repository.FindByFileID(ctx, f.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(otherScenes) > 1 {\n\t\t\t// other scenes associated, don't remove\n\t\t\tcontinue\n\t\t}\n\n\t\tconst deleteFile = false\n\t\tlogger.Info(\"Destroying scene file entry: \", f.Path)\n\t\tif err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// DestroyMarker deletes the scene marker from the database and returns a\n// function that removes the generated files, to be executed after the\n// transaction is successfully committed.\nfunc DestroyMarker(ctx context.Context, scene *models.Scene, sceneMarker *models.SceneMarker, qb models.SceneMarkerDestroyer, fileDeleter *FileDeleter) error {\n\tif err := qb.Destroy(ctx, sceneMarker.ID); err != nil {\n\t\treturn err\n\t}\n\n\t// delete the preview for the marker\n\tseconds := int(sceneMarker.Seconds)\n\treturn fileDeleter.MarkMarkerFiles(scene, seconds)\n}\n"
  },
  {
    "path": "pkg/scene/export.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ExportGetter interface {\n\tmodels.ViewDateReader\n\tmodels.ODateReader\n\tmodels.CustomFieldsReader\n\tGetCover(ctx context.Context, sceneID int) ([]byte, error)\n}\n\ntype TagFinder interface {\n\tmodels.TagGetter\n\tFindBySceneID(ctx context.Context, sceneID int) ([]*models.Tag, error)\n\tFindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error)\n}\n\n// ToBasicJSON converts a scene object into its JSON object equivalent. It\n// does not convert the relationships to other objects, with the exception\n// of cover image.\nfunc ToBasicJSON(ctx context.Context, reader ExportGetter, scene *models.Scene) (*jsonschema.Scene, error) {\n\tnewSceneJSON := jsonschema.Scene{\n\t\tTitle:     scene.Title,\n\t\tCode:      scene.Code,\n\t\tURLs:      scene.URLs.List(),\n\t\tDetails:   scene.Details,\n\t\tDirector:  scene.Director,\n\t\tCreatedAt: json.JSONTime{Time: scene.CreatedAt},\n\t\tUpdatedAt: json.JSONTime{Time: scene.UpdatedAt},\n\t}\n\n\tif scene.Date != nil {\n\t\tnewSceneJSON.Date = scene.Date.String()\n\t}\n\n\tif scene.Rating != nil {\n\t\tnewSceneJSON.Rating = *scene.Rating\n\t}\n\n\tnewSceneJSON.Organized = scene.Organized\n\n\tfor _, f := range scene.Files.List() {\n\t\tnewSceneJSON.Files = append(newSceneJSON.Files, f.Base().Path)\n\t}\n\n\tcover, err := reader.GetCover(ctx, scene.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error getting scene cover: %v\", err)\n\t}\n\n\tif len(cover) > 0 {\n\t\tnewSceneJSON.Cover = utils.GetBase64StringFromData(cover)\n\t}\n\n\tvar ret []models.StashID\n\tfor _, stashID := range scene.StashIDs.List() {\n\t\tnewJoin := models.StashID{\n\t\t\tStashID:  stashID.StashID,\n\t\t\tEndpoint: stashID.Endpoint,\n\t\t}\n\t\tret = append(ret, newJoin)\n\t}\n\n\tnewSceneJSON.StashIDs = ret\n\n\tdates, err := reader.GetViewDates(ctx, scene.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting view dates: %v\", err)\n\t}\n\n\tfor _, date := range dates {\n\t\tnewSceneJSON.PlayHistory = append(newSceneJSON.PlayHistory, json.JSONTime{Time: date})\n\t}\n\n\todates, err := reader.GetODates(ctx, scene.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting o dates: %v\", err)\n\t}\n\n\tfor _, date := range odates {\n\t\tnewSceneJSON.OHistory = append(newSceneJSON.OHistory, json.JSONTime{Time: date})\n\t}\n\n\tnewSceneJSON.CustomFields, err = reader.GetCustomFields(ctx, scene.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting scene custom fields: %v\", err)\n\t}\n\n\treturn &newSceneJSON, nil\n}\n\n// GetStudioName returns the name of the provided scene's studio. It returns an\n// empty string if there is no studio assigned to the scene.\nfunc GetStudioName(ctx context.Context, reader models.StudioGetter, scene *models.Scene) (string, error) {\n\tif scene.StudioID != nil {\n\t\tstudio, err := reader.Find(ctx, *scene.StudioID)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tif studio != nil {\n\t\t\treturn studio.Name, nil\n\t\t}\n\t}\n\n\treturn \"\", nil\n}\n\n// GetTagNames returns a slice of tag names corresponding to the provided\n// scene's tags.\nfunc GetTagNames(ctx context.Context, reader TagFinder, scene *models.Scene) ([]string, error) {\n\ttags, err := reader.FindBySceneID(ctx, scene.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting scene tags: %v\", err)\n\t}\n\n\treturn getTagNames(tags), nil\n}\n\nfunc getTagNames(tags []*models.Tag) []string {\n\tvar results []string\n\tfor _, tag := range tags {\n\t\tif tag.Name != \"\" {\n\t\t\tresults = append(results, tag.Name)\n\t\t}\n\t}\n\n\treturn results\n}\n\n// GetDependentTagIDs returns a slice of unique tag IDs that this scene references.\nfunc GetDependentTagIDs(ctx context.Context, tags TagFinder, markerReader models.SceneMarkerFinder, scene *models.Scene) ([]int, error) {\n\tvar ret []int\n\n\tt, err := tags.FindBySceneID(ctx, scene.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, tt := range t {\n\t\tret = sliceutil.AppendUnique(ret, tt.ID)\n\t}\n\n\tsm, err := markerReader.FindBySceneID(ctx, scene.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, smm := range sm {\n\t\tret = sliceutil.AppendUnique(ret, smm.PrimaryTagID)\n\t\tsmmt, err := tags.FindBySceneMarkerID(ctx, smm.ID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid tags for scene marker: %v\", err)\n\t\t}\n\n\t\tfor _, smmtt := range smmt {\n\t\t\tret = sliceutil.AppendUnique(ret, smmtt.ID)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// GetSceneGroupsJSON returns a slice of SceneGroup JSON representation objects\n// corresponding to the provided scene's scene group relationships.\nfunc GetSceneGroupsJSON(ctx context.Context, groupReader models.GroupGetter, scene *models.Scene) ([]jsonschema.SceneGroup, error) {\n\tsceneGroups := scene.Groups.List()\n\n\tvar results []jsonschema.SceneGroup\n\tfor _, sceneGroup := range sceneGroups {\n\t\tgroup, err := groupReader.Find(ctx, sceneGroup.GroupID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting group: %v\", err)\n\t\t}\n\n\t\tif group != nil {\n\t\t\tsceneGroupJSON := jsonschema.SceneGroup{\n\t\t\t\tGroupName: group.Name,\n\t\t\t}\n\t\t\tif sceneGroup.SceneIndex != nil {\n\t\t\t\tsceneGroupJSON.SceneIndex = *sceneGroup.SceneIndex\n\t\t\t}\n\t\t\tresults = append(results, sceneGroupJSON)\n\t\t}\n\t}\n\n\treturn results, nil\n}\n\n// GetDependentGroupIDs returns a slice of group IDs that this scene references.\nfunc GetDependentGroupIDs(ctx context.Context, scene *models.Scene) ([]int, error) {\n\tvar ret []int\n\n\tm := scene.Groups.List()\n\tfor _, mm := range m {\n\t\tret = append(ret, mm.GroupID)\n\t}\n\n\treturn ret, nil\n}\n\n// GetSceneMarkersJSON returns a slice of SceneMarker JSON representation\n// objects corresponding to the provided scene's markers.\nfunc GetSceneMarkersJSON(ctx context.Context, markerReader models.SceneMarkerFinder, tagReader TagFinder, scene *models.Scene) ([]jsonschema.SceneMarker, error) {\n\tsceneMarkers, err := markerReader.FindBySceneID(ctx, scene.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting scene markers: %v\", err)\n\t}\n\n\tvar results []jsonschema.SceneMarker\n\n\tfor _, sceneMarker := range sceneMarkers {\n\t\tprimaryTag, err := tagReader.Find(ctx, sceneMarker.PrimaryTagID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid primary tag for scene marker: %v\", err)\n\t\t}\n\n\t\tsceneMarkerTags, err := tagReader.FindBySceneMarkerID(ctx, sceneMarker.ID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid tags for scene marker: %v\", err)\n\t\t}\n\n\t\tsceneMarkerJSON := jsonschema.SceneMarker{\n\t\t\tTitle:      sceneMarker.Title,\n\t\t\tSeconds:    getDecimalString(sceneMarker.Seconds),\n\t\t\tPrimaryTag: primaryTag.Name,\n\t\t\tTags:       getTagNames(sceneMarkerTags),\n\t\t\tCreatedAt:  json.JSONTime{Time: sceneMarker.CreatedAt},\n\t\t\tUpdatedAt:  json.JSONTime{Time: sceneMarker.UpdatedAt},\n\t\t}\n\n\t\tif sceneMarker.EndSeconds != nil {\n\t\t\tsceneMarkerJSON.EndSeconds = getDecimalString(*sceneMarker.EndSeconds)\n\t\t}\n\n\t\tresults = append(results, sceneMarkerJSON)\n\t}\n\n\treturn results, nil\n}\n\nfunc getDecimalString(num float64) string {\n\tif num == 0 {\n\t\treturn \"\"\n\t}\n\n\tprecision := getPrecision(num)\n\tif precision == 0 {\n\t\tprecision = 1\n\t}\n\treturn fmt.Sprintf(\"%.\"+strconv.Itoa(precision)+\"f\", num)\n}\n\nfunc getPrecision(num float64) int {\n\tif num == 0 {\n\t\treturn 0\n\t}\n\n\te := 1.0\n\tp := 0\n\tfor (math.Round(num*e) / e) != num {\n\t\te *= 10\n\t\tp++\n\t}\n\treturn p\n}\n"
  },
  {
    "path": "pkg/scene/export_test.go",
    "content": "package scene\n\nimport (\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\tsceneID    = 1\n\tnoImageID  = 2\n\terrImageID = 3\n\n\tstudioID        = 4\n\tmissingStudioID = 5\n\terrStudioID     = 6\n\tcustomFieldsID  = 7\n\n\tnoTagsID  = 11\n\terrTagsID = 12\n\n\tnoGroupsID     = 13\n\terrFindGroupID = 15\n\n\tnoMarkersID         = 16\n\terrMarkersID        = 17\n\terrFindPrimaryTagID = 18\n\terrFindByMarkerID   = 19\n\terrCustomFieldsID   = 20\n)\n\nvar (\n\turl        = \"url\"\n\ttitle      = \"title\"\n\tdate       = \"2001-01-01\"\n\tdateObj, _ = models.ParseDate(date)\n\trating     = 5\n\torganized  = true\n\tdetails    = \"details\"\n)\n\nvar (\n\tstudioName = \"studioName\"\n\t// galleryChecksum = \"galleryChecksum\"\n\n\tvalidGroup1  = 1\n\tvalidGroup2  = 2\n\tinvalidGroup = 3\n\n\tgroup1Name = \"group1Name\"\n\tgroup2Name = \"group2Name\"\n\n\tgroup1Scene = 1\n\tgroup2Scene = 2\n)\n\nvar names = []string{\n\t\"name1\",\n\t\"name2\",\n}\n\nvar imageBytes = []byte(\"imageBytes\")\n\nvar stashID = models.StashID{\n\tStashID:  \"StashID\",\n\tEndpoint: \"Endpoint\",\n}\n\nconst (\n\tpath        = \"path\"\n\timageBase64 = \"aW1hZ2VCeXRlcw==\"\n)\n\nvar (\n\tcreateTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC)\n\tupdateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC)\n)\n\nvar (\n\temptyCustomFields = make(map[string]interface{})\n\tcustomFields      = map[string]interface{}{\n\t\t\"customField1\": \"customValue1\",\n\t}\n)\n\nfunc createFullScene(id int) models.Scene {\n\treturn models.Scene{\n\t\tID:        id,\n\t\tTitle:     title,\n\t\tDate:      &dateObj,\n\t\tDetails:   details,\n\t\tRating:    &rating,\n\t\tOrganized: organized,\n\t\tURLs:      models.NewRelatedStrings([]string{url}),\n\t\tFiles: models.NewRelatedVideoFiles([]*models.VideoFile{\n\t\t\t{\n\t\t\t\tBaseFile: &models.BaseFile{\n\t\t\t\t\tPath: path,\n\t\t\t\t},\n\t\t\t},\n\t\t}),\n\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\tstashID,\n\t\t}),\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t}\n}\n\nfunc createEmptyScene(id int) models.Scene {\n\treturn models.Scene{\n\t\tID: id,\n\t\tFiles: models.NewRelatedVideoFiles([]*models.VideoFile{\n\t\t\t{\n\t\t\t\tBaseFile: &models.BaseFile{\n\t\t\t\t\tPath: path,\n\t\t\t\t},\n\t\t\t},\n\t\t}),\n\t\tURLs:      models.NewRelatedStrings([]string{}),\n\t\tStashIDs:  models.NewRelatedStashIDs([]models.StashID{}),\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t}\n}\n\nfunc createFullJSONScene(image string, customFields map[string]interface{}) *jsonschema.Scene {\n\treturn &jsonschema.Scene{\n\t\tTitle:     title,\n\t\tFiles:     []string{path},\n\t\tDate:      date,\n\t\tDetails:   details,\n\t\tRating:    rating,\n\t\tOrganized: organized,\n\t\tURLs:      []string{url},\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tCover: image,\n\t\tStashIDs: []models.StashID{\n\t\t\tstashID,\n\t\t},\n\t\tCustomFields: customFields,\n\t}\n}\n\nfunc createEmptyJSONScene() *jsonschema.Scene {\n\treturn &jsonschema.Scene{\n\t\tURLs:  []string{},\n\t\tFiles: []string{path},\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tCustomFields: emptyCustomFields,\n\t}\n}\n\ntype basicTestScenario struct {\n\tinput        models.Scene\n\tcustomFields map[string]interface{}\n\texpected     *jsonschema.Scene\n\terr          bool\n}\n\nvar scenarios = []basicTestScenario{\n\t{\n\t\tcreateFullScene(sceneID),\n\t\temptyCustomFields,\n\t\tcreateFullJSONScene(imageBase64, emptyCustomFields),\n\t\tfalse,\n\t},\n\t{\n\t\tcreateFullScene(customFieldsID),\n\t\tcustomFields,\n\t\tcreateFullJSONScene(\"\", customFields),\n\t\tfalse,\n\t},\n\t{\n\t\tcreateEmptyScene(noImageID),\n\t\temptyCustomFields,\n\t\tcreateEmptyJSONScene(),\n\t\tfalse,\n\t},\n\t{\n\t\tcreateFullScene(errImageID),\n\t\temptyCustomFields,\n\t\tcreateFullJSONScene(\"\", emptyCustomFields),\n\t\t// failure to get image should not cause an error\n\t\tfalse,\n\t},\n\t{\n\t\tcreateFullScene(errCustomFieldsID),\n\t\tcustomFields,\n\t\tcreateFullJSONScene(\"\", customFields),\n\t\ttrue,\n\t},\n}\n\nfunc TestToJSON(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\timageErr := errors.New(\"error getting image\")\n\n\tdb.Scene.On(\"GetCover\", testCtx, sceneID).Return(imageBytes, nil).Once()\n\tdb.Scene.On(\"GetCover\", testCtx, noImageID).Return(nil, nil).Once()\n\tdb.Scene.On(\"GetCover\", testCtx, errImageID).Return(nil, imageErr).Once()\n\tdb.Scene.On(\"GetCover\", testCtx, mock.Anything).Return(nil, nil)\n\tdb.Scene.On(\"GetViewDates\", testCtx, mock.Anything).Return(nil, nil)\n\tdb.Scene.On(\"GetODates\", testCtx, mock.Anything).Return(nil, nil)\n\tdb.Scene.On(\"GetCustomFields\", testCtx, customFieldsID).Return(customFields, nil).Once()\n\tdb.Scene.On(\"GetCustomFields\", testCtx, errCustomFieldsID).Return(nil, errors.New(\"error getting custom fields\")).Once()\n\tdb.Scene.On(\"GetCustomFields\", testCtx, mock.Anything).Return(emptyCustomFields, nil)\n\n\tfor i, s := range scenarios {\n\t\tscene := s.input\n\t\tjson, err := ToBasicJSON(testCtx, db.Scene, &scene)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tcase err != nil:\n\t\t\t// error case already handled, no need for assertion\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n\nfunc createStudioScene(studioID int) models.Scene {\n\treturn models.Scene{\n\t\tStudioID: &studioID,\n\t}\n}\n\ntype stringTestScenario struct {\n\tinput    models.Scene\n\texpected string\n\terr      bool\n}\n\nvar getStudioScenarios = []stringTestScenario{\n\t{\n\t\tcreateStudioScene(studioID),\n\t\tstudioName,\n\t\tfalse,\n\t},\n\t{\n\t\tcreateStudioScene(missingStudioID),\n\t\t\"\",\n\t\tfalse,\n\t},\n\t{\n\t\tcreateStudioScene(errStudioID),\n\t\t\"\",\n\t\ttrue,\n\t},\n}\n\nfunc TestGetStudioName(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tstudioErr := errors.New(\"error getting image\")\n\n\tdb.Studio.On(\"Find\", testCtx, studioID).Return(&models.Studio{\n\t\tName: studioName,\n\t}, nil).Once()\n\tdb.Studio.On(\"Find\", testCtx, missingStudioID).Return(nil, nil).Once()\n\tdb.Studio.On(\"Find\", testCtx, errStudioID).Return(nil, studioErr).Once()\n\n\tfor i, s := range getStudioScenarios {\n\t\tscene := s.input\n\t\tjson, err := GetStudioName(testCtx, db.Studio, &scene)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n\ntype stringSliceTestScenario struct {\n\tinput    models.Scene\n\texpected []string\n\terr      bool\n}\n\nvar getTagNamesScenarios = []stringSliceTestScenario{\n\t{\n\t\tcreateEmptyScene(sceneID),\n\t\tnames,\n\t\tfalse,\n\t},\n\t{\n\t\tcreateEmptyScene(noTagsID),\n\t\tnil,\n\t\tfalse,\n\t},\n\t{\n\t\tcreateEmptyScene(errTagsID),\n\t\tnil,\n\t\ttrue,\n\t},\n}\n\nfunc getTags(names []string) []*models.Tag {\n\tvar ret []*models.Tag\n\tfor _, n := range names {\n\t\tret = append(ret, &models.Tag{\n\t\t\tName: n,\n\t\t})\n\t}\n\n\treturn ret\n}\n\nfunc TestGetTagNames(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ttagErr := errors.New(\"error getting tag\")\n\n\tdb.Tag.On(\"FindBySceneID\", testCtx, sceneID).Return(getTags(names), nil).Once()\n\tdb.Tag.On(\"FindBySceneID\", testCtx, noTagsID).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindBySceneID\", testCtx, errTagsID).Return(nil, tagErr).Once()\n\n\tfor i, s := range getTagNamesScenarios {\n\t\tscene := s.input\n\t\tjson, err := GetTagNames(testCtx, db.Tag, &scene)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n\ntype sceneGroupsTestScenario struct {\n\tinput    models.Scene\n\texpected []jsonschema.SceneGroup\n\terr      bool\n}\n\nvar validGroups = models.NewRelatedGroups([]models.GroupsScenes{\n\t{\n\t\tGroupID:    validGroup1,\n\t\tSceneIndex: &group1Scene,\n\t},\n\t{\n\t\tGroupID:    validGroup2,\n\t\tSceneIndex: &group2Scene,\n\t},\n})\n\nvar invalidGroups = models.NewRelatedGroups([]models.GroupsScenes{\n\t{\n\t\tGroupID:    invalidGroup,\n\t\tSceneIndex: &group1Scene,\n\t},\n})\n\nvar getSceneGroupsJSONScenarios = []sceneGroupsTestScenario{\n\t{\n\t\tmodels.Scene{\n\t\t\tID:     sceneID,\n\t\t\tGroups: validGroups,\n\t\t},\n\t\t[]jsonschema.SceneGroup{\n\t\t\t{\n\t\t\t\tGroupName:  group1Name,\n\t\t\t\tSceneIndex: group1Scene,\n\t\t\t},\n\t\t\t{\n\t\t\t\tGroupName:  group2Name,\n\t\t\t\tSceneIndex: group2Scene,\n\t\t\t},\n\t\t},\n\t\tfalse,\n\t},\n\t{\n\t\tmodels.Scene{\n\t\t\tID:     noGroupsID,\n\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{}),\n\t\t},\n\t\tnil,\n\t\tfalse,\n\t},\n\t{\n\t\tmodels.Scene{\n\t\t\tID:     errFindGroupID,\n\t\t\tGroups: invalidGroups,\n\t\t},\n\t\tnil,\n\t\ttrue,\n\t},\n}\n\nfunc TestGetSceneGroupsJSON(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tgroupErr := errors.New(\"error getting group\")\n\n\tdb.Group.On(\"Find\", testCtx, validGroup1).Return(&models.Group{\n\t\tName: group1Name,\n\t}, nil).Once()\n\tdb.Group.On(\"Find\", testCtx, validGroup2).Return(&models.Group{\n\t\tName: group2Name,\n\t}, nil).Once()\n\tdb.Group.On(\"Find\", testCtx, invalidGroup).Return(nil, groupErr).Once()\n\n\tfor i, s := range getSceneGroupsJSONScenarios {\n\t\tscene := s.input\n\t\tjson, err := GetSceneGroupsJSON(testCtx, db.Group, &scene)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n\nconst (\n\tvalidMarkerID1 = 1\n\tvalidMarkerID2 = 2\n\n\tinvalidMarkerID1 = 3\n\tinvalidMarkerID2 = 4\n\n\tvalidTagID1 = 1\n\tvalidTagID2 = 2\n\n\tvalidTagName1 = \"validTagName1\"\n\tvalidTagName2 = \"validTagName2\"\n\n\tinvalidTagID = 3\n\n\tmarkerTitle1 = \"markerTitle1\"\n\tmarkerTitle2 = \"markerTitle2\"\n\n\tmarkerSeconds1 = 1.0\n\tmarkerSeconds2 = 2.3\n\n\tmarkerSeconds1Str = \"1.0\"\n\tmarkerSeconds2Str = \"2.3\"\n)\n\ntype sceneMarkersTestScenario struct {\n\tinput    models.Scene\n\texpected []jsonschema.SceneMarker\n\terr      bool\n}\n\nvar getSceneMarkersJSONScenarios = []sceneMarkersTestScenario{\n\t{\n\t\tcreateEmptyScene(sceneID),\n\t\t[]jsonschema.SceneMarker{\n\t\t\t{\n\t\t\t\tTitle:      markerTitle1,\n\t\t\t\tPrimaryTag: validTagName1,\n\t\t\t\tSeconds:    markerSeconds1Str,\n\t\t\t\tTags: []string{\n\t\t\t\t\tvalidTagName1,\n\t\t\t\t\tvalidTagName2,\n\t\t\t\t},\n\t\t\t\tCreatedAt: json.JSONTime{\n\t\t\t\t\tTime: createTime,\n\t\t\t\t},\n\t\t\t\tUpdatedAt: json.JSONTime{\n\t\t\t\t\tTime: updateTime,\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tTitle:      markerTitle2,\n\t\t\t\tPrimaryTag: validTagName2,\n\t\t\t\tSeconds:    markerSeconds2Str,\n\t\t\t\tTags: []string{\n\t\t\t\t\tvalidTagName2,\n\t\t\t\t},\n\t\t\t\tCreatedAt: json.JSONTime{\n\t\t\t\t\tTime: createTime,\n\t\t\t\t},\n\t\t\t\tUpdatedAt: json.JSONTime{\n\t\t\t\t\tTime: updateTime,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tfalse,\n\t},\n\t{\n\t\tcreateEmptyScene(noMarkersID),\n\t\tnil,\n\t\tfalse,\n\t},\n\t{\n\t\tcreateEmptyScene(errMarkersID),\n\t\tnil,\n\t\ttrue,\n\t},\n\t{\n\t\tcreateEmptyScene(errFindPrimaryTagID),\n\t\tnil,\n\t\ttrue,\n\t},\n\t{\n\t\tcreateEmptyScene(errFindByMarkerID),\n\t\tnil,\n\t\ttrue,\n\t},\n}\n\nvar validMarkers = []*models.SceneMarker{\n\t{\n\t\tID:           validMarkerID1,\n\t\tTitle:        markerTitle1,\n\t\tPrimaryTagID: validTagID1,\n\t\tSeconds:      markerSeconds1,\n\t\tCreatedAt:    createTime,\n\t\tUpdatedAt:    updateTime,\n\t},\n\t{\n\t\tID:           validMarkerID2,\n\t\tTitle:        markerTitle2,\n\t\tPrimaryTagID: validTagID2,\n\t\tSeconds:      markerSeconds2,\n\t\tCreatedAt:    createTime,\n\t\tUpdatedAt:    updateTime,\n\t},\n}\n\nvar invalidMarkers1 = []*models.SceneMarker{\n\t{\n\t\tID:           invalidMarkerID1,\n\t\tPrimaryTagID: invalidTagID,\n\t},\n}\n\nvar invalidMarkers2 = []*models.SceneMarker{\n\t{\n\t\tID:           invalidMarkerID2,\n\t\tPrimaryTagID: validTagID1,\n\t},\n}\n\nfunc TestGetSceneMarkersJSON(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tmarkersErr := errors.New(\"error getting scene markers\")\n\ttagErr := errors.New(\"error getting tags\")\n\n\tdb.SceneMarker.On(\"FindBySceneID\", testCtx, sceneID).Return(validMarkers, nil).Once()\n\tdb.SceneMarker.On(\"FindBySceneID\", testCtx, noMarkersID).Return(nil, nil).Once()\n\tdb.SceneMarker.On(\"FindBySceneID\", testCtx, errMarkersID).Return(nil, markersErr).Once()\n\tdb.SceneMarker.On(\"FindBySceneID\", testCtx, errFindPrimaryTagID).Return(invalidMarkers1, nil).Once()\n\tdb.SceneMarker.On(\"FindBySceneID\", testCtx, errFindByMarkerID).Return(invalidMarkers2, nil).Once()\n\n\tdb.Tag.On(\"Find\", testCtx, validTagID1).Return(&models.Tag{\n\t\tName: validTagName1,\n\t}, nil)\n\tdb.Tag.On(\"Find\", testCtx, validTagID2).Return(&models.Tag{\n\t\tName: validTagName2,\n\t}, nil)\n\tdb.Tag.On(\"Find\", testCtx, invalidTagID).Return(nil, tagErr)\n\n\tdb.Tag.On(\"FindBySceneMarkerID\", testCtx, validMarkerID1).Return([]*models.Tag{\n\t\t{\n\t\t\tName: validTagName1,\n\t\t},\n\t\t{\n\t\t\tName: validTagName2,\n\t\t},\n\t}, nil)\n\tdb.Tag.On(\"FindBySceneMarkerID\", testCtx, validMarkerID2).Return([]*models.Tag{\n\t\t{\n\t\t\tName: validTagName2,\n\t\t},\n\t}, nil)\n\tdb.Tag.On(\"FindBySceneMarkerID\", testCtx, invalidMarkerID2).Return(nil, tagErr).Once()\n\n\tfor i, s := range getSceneMarkersJSONScenarios {\n\t\tscene := s.input\n\t\tjson, err := GetSceneMarkersJSON(testCtx, db.SceneMarker, db.Tag, &scene)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/scene/filename_parser.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/studio\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/tag\"\n)\n\ntype parserField struct {\n\tfield           string\n\tfieldRegex      *regexp.Regexp\n\tregex           string\n\tisFullDateField bool\n\tisCaptured      bool\n}\n\nfunc newParserField(field string, regex string, captured bool) parserField {\n\tret := parserField{\n\t\tfield:           field,\n\t\tisFullDateField: false,\n\t\tisCaptured:      captured,\n\t}\n\n\tret.fieldRegex, _ = regexp.Compile(`\\{` + ret.field + `\\}`)\n\n\tregexStr := regex\n\n\tif captured {\n\t\tregexStr = \"(\" + regexStr + \")\"\n\t}\n\tret.regex = regexStr\n\n\treturn ret\n}\n\nfunc newFullDateParserField(field string, regex string) parserField {\n\tret := newParserField(field, regex, true)\n\tret.isFullDateField = true\n\treturn ret\n}\n\nfunc (f parserField) replaceInPattern(pattern string) string {\n\treturn string(f.fieldRegex.ReplaceAllString(pattern, f.regex))\n}\n\nvar validFields map[string]parserField\nvar escapeCharRE *regexp.Regexp\nvar capitalizeTitleRE *regexp.Regexp\nvar multiWSRE *regexp.Regexp\nvar delimiterRE *regexp.Regexp\n\nfunc compileREs() {\n\tconst escapeCharPattern = `([\\-\\.\\(\\)\\[\\]])`\n\tescapeCharRE = regexp.MustCompile(escapeCharPattern)\n\n\tconst capitaliseTitlePattern = `(?:^| )\\w`\n\tcapitalizeTitleRE = regexp.MustCompile(capitaliseTitlePattern)\n\n\tconst multiWSPattern = ` {2,}`\n\tmultiWSRE = regexp.MustCompile(multiWSPattern)\n\n\tconst delimiterPattern = `(?:\\.|-|_)`\n\tdelimiterRE = regexp.MustCompile(delimiterPattern)\n}\n\nfunc initParserFields() {\n\tif validFields != nil {\n\t\treturn\n\t}\n\n\tret := make(map[string]parserField)\n\n\tret[\"title\"] = newParserField(\"title\", \".*\", true)\n\tret[\"ext\"] = newParserField(\"ext\", \".*$\", false)\n\n\tret[\"d\"] = newParserField(\"d\", `(?:\\.|-|_)`, false)\n\tret[\"rating\"] = newParserField(\"rating\", `\\d`, true)\n\tret[\"rating100\"] = newParserField(\"rating100\", `\\d`, true)\n\tret[\"performer\"] = newParserField(\"performer\", \".*\", true)\n\tret[\"studio\"] = newParserField(\"studio\", \".*\", true)\n\tret[\"movie\"] = newParserField(\"movie\", \".*\", true)\n\tret[\"tag\"] = newParserField(\"tag\", \".*\", true)\n\n\t// date fields\n\tret[\"date\"] = newParserField(\"date\", `\\d{4}-\\d{2}-\\d{2}`, true)\n\tret[\"yyyy\"] = newParserField(\"yyyy\", `\\d{4}`, true)\n\tret[\"yy\"] = newParserField(\"yy\", `\\d{2}`, true)\n\tret[\"mm\"] = newParserField(\"mm\", `\\d{2}`, true)\n\tret[\"mmm\"] = newParserField(\"mmm\", `\\w{3}`, true)\n\tret[\"dd\"] = newParserField(\"dd\", `\\d{2}`, true)\n\tret[\"yyyymmdd\"] = newFullDateParserField(\"yyyymmdd\", `\\d{8}`)\n\tret[\"yymmdd\"] = newFullDateParserField(\"yymmdd\", `\\d{6}`)\n\tret[\"ddmmyyyy\"] = newFullDateParserField(\"ddmmyyyy\", `\\d{8}`)\n\tret[\"ddmmyy\"] = newFullDateParserField(\"ddmmyy\", `\\d{6}`)\n\tret[\"mmddyyyy\"] = newFullDateParserField(\"mmddyyyy\", `\\d{8}`)\n\tret[\"mmddyy\"] = newFullDateParserField(\"mmddyy\", `\\d{6}`)\n\n\tvalidFields = ret\n}\n\nfunc replacePatternWithRegex(pattern string, ignoreWords []string) string {\n\tinitParserFields()\n\n\tfor _, field := range validFields {\n\t\tpattern = field.replaceInPattern(pattern)\n\t}\n\n\tignoreClause := getIgnoreClause(ignoreWords)\n\tignoreField := newParserField(\"i\", ignoreClause, false)\n\tpattern = ignoreField.replaceInPattern(pattern)\n\n\treturn pattern\n}\n\ntype parseMapper struct {\n\tfields      []string\n\tregexString string\n\tregex       *regexp.Regexp\n}\n\nfunc getIgnoreClause(ignoreFields []string) string {\n\tif len(ignoreFields) == 0 {\n\t\treturn \"\"\n\t}\n\n\tvar ignoreClauses []string\n\n\tfor _, v := range ignoreFields {\n\t\tnewVal := string(escapeCharRE.ReplaceAllString(v, `\\$1`))\n\t\tnewVal = strings.TrimSpace(newVal)\n\t\tnewVal = \"(?:\" + newVal + \")\"\n\t\tignoreClauses = append(ignoreClauses, newVal)\n\t}\n\n\treturn \"(?:\" + strings.Join(ignoreClauses, \"|\") + \")\"\n}\n\nfunc newParseMapper(pattern string, ignoreFields []string) (*parseMapper, error) {\n\tret := &parseMapper{}\n\n\t// escape control characters\n\tregex := escapeCharRE.ReplaceAllString(pattern, `\\$1`)\n\n\t// replace {} with wildcard\n\tbraceRE := regexp.MustCompile(`\\{\\}`)\n\tregex = braceRE.ReplaceAllString(regex, \".*\")\n\n\t// replace all known fields with applicable regexes\n\tregex = replacePatternWithRegex(regex, ignoreFields)\n\n\tret.regexString = regex\n\n\t// make case insensitive\n\tregex = \"(?i)\" + regex\n\n\tvar err error\n\n\tret.regex, err = regexp.Compile(regex)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// find invalid fields\n\tinvalidRE := regexp.MustCompile(`\\{[A-Za-z]+\\}`)\n\tfoundInvalid := invalidRE.FindAllString(regex, -1)\n\tif len(foundInvalid) > 0 {\n\t\treturn nil, errors.New(\"Invalid fields: \" + strings.Join(foundInvalid, \", \"))\n\t}\n\n\tfieldExtractor := regexp.MustCompile(`\\{([A-Za-z]+)\\}`)\n\n\tresult := fieldExtractor.FindAllStringSubmatch(pattern, -1)\n\n\tvar fields []string\n\tfor _, v := range result {\n\t\tfield := v[1]\n\n\t\t// only add to fields if it is captured\n\t\tparserField, found := validFields[field]\n\t\tif found && parserField.isCaptured {\n\t\t\tfields = append(fields, field)\n\t\t}\n\t}\n\n\tret.fields = fields\n\n\treturn ret, nil\n}\n\ntype sceneHolder struct {\n\tscene      *models.Scene\n\tresult     *models.Scene\n\tyyyy       string\n\tmm         string\n\tdd         string\n\tperformers []string\n\tgroups     []string\n\tstudio     string\n\ttags       []string\n}\n\nfunc newSceneHolder(scene *models.Scene) *sceneHolder {\n\tsceneCopy := models.Scene{\n\t\tID:    scene.ID,\n\t\tFiles: scene.Files,\n\t\t// Checksum: scene.Checksum,\n\t\t// Path:     scene.Path,\n\t}\n\tret := sceneHolder{\n\t\tscene:  scene,\n\t\tresult: &sceneCopy,\n\t}\n\n\treturn &ret\n}\n\nfunc validateRating(rating int) bool {\n\treturn rating >= 1 && rating <= 5\n}\n\nfunc validateRating100(rating100 int) bool {\n\treturn rating100 >= 1 && rating100 <= 100\n}\n\n// returns nil if invalid\nfunc parseDate(dateStr string) *models.Date {\n\tsplits := strings.Split(dateStr, \"-\")\n\tif len(splits) != 3 {\n\t\treturn nil\n\t}\n\n\tyear, _ := strconv.Atoi(splits[0])\n\tmonth, _ := strconv.Atoi(splits[1])\n\td, _ := strconv.Atoi(splits[2])\n\n\t// assume year must be between 1900 and 2100\n\tif year < 1900 || year > 2100 {\n\t\treturn nil\n\t}\n\n\tif month < 1 || month > 12 {\n\t\treturn nil\n\t}\n\n\t// not checking individual months to ensure date is in the correct range\n\tif d < 1 || d > 31 {\n\t\treturn nil\n\t}\n\n\tret, err := models.ParseDate(dateStr)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &ret\n}\n\nfunc (h *sceneHolder) setDate(field *parserField, value string) {\n\tyearIndex := 0\n\tyearLength := len(strings.Split(field.field, \"y\")) - 1\n\tdateIndex := 0\n\tmonthIndex := 0\n\n\tswitch field.field {\n\tcase \"yyyymmdd\", \"yymmdd\":\n\t\tmonthIndex = yearLength\n\t\tdateIndex = monthIndex + 2\n\tcase \"ddmmyyyy\", \"ddmmyy\":\n\t\tmonthIndex = 2\n\t\tyearIndex = monthIndex + 2\n\tcase \"mmddyyyy\", \"mmddyy\":\n\t\tdateIndex = monthIndex + 2\n\t\tyearIndex = dateIndex + 2\n\t}\n\n\tyearValue := value[yearIndex : yearIndex+yearLength]\n\tmonthValue := value[monthIndex : monthIndex+2]\n\tdateValue := value[dateIndex : dateIndex+2]\n\n\tfullDate := yearValue + \"-\" + monthValue + \"-\" + dateValue\n\n\t// ensure the date is valid\n\t// only set if new value is different from the old\n\tnewDate := parseDate(fullDate)\n\tif newDate != nil && h.scene.Date != nil && *h.scene.Date != *newDate {\n\t\th.result.Date = newDate\n\t}\n}\n\nfunc mmmToMonth(mmm string) string {\n\tformat := \"02-Jan-2006\"\n\tdateStr := \"01-\" + mmm + \"-2000\"\n\tt, err := time.Parse(format, dateStr)\n\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\t// expect month in two-digit format\n\tformat = \"01-02-2006\"\n\treturn t.Format(format)[0:2]\n}\n\nfunc (h *sceneHolder) setField(field parserField, value interface{}) {\n\tif field.isFullDateField {\n\t\th.setDate(&field, value.(string))\n\t\treturn\n\t}\n\n\tswitch field.field {\n\tcase \"title\":\n\t\tv := value.(string)\n\t\th.result.Title = v\n\tcase \"date\":\n\t\th.result.Date = parseDate(value.(string))\n\tcase \"rating\":\n\t\trating, _ := strconv.Atoi(value.(string))\n\t\tif validateRating(rating) {\n\t\t\t// convert to 1-100 scale\n\t\t\trating = models.Rating5To100(rating)\n\t\t\th.result.Rating = &rating\n\t\t}\n\tcase \"rating100\":\n\t\trating, _ := strconv.Atoi(value.(string))\n\t\tif validateRating100(rating) {\n\t\t\th.result.Rating = &rating\n\t\t}\n\tcase \"performer\":\n\t\t// add performer to list\n\t\th.performers = append(h.performers, value.(string))\n\tcase \"studio\":\n\t\th.studio = value.(string)\n\tcase \"movie\":\n\t\th.groups = append(h.groups, value.(string))\n\tcase \"tag\":\n\t\th.tags = append(h.tags, value.(string))\n\tcase \"yyyy\":\n\t\th.yyyy = value.(string)\n\tcase \"yy\":\n\t\tv := value.(string)\n\t\tv = \"20\" + v\n\t\th.yyyy = v\n\tcase \"mmm\":\n\t\th.mm = mmmToMonth(value.(string))\n\tcase \"mm\":\n\t\th.mm = value.(string)\n\tcase \"dd\":\n\t\th.dd = value.(string)\n\t}\n}\n\nfunc (h *sceneHolder) postParse() {\n\t// set the date if the components are set\n\tif h.yyyy != \"\" && h.mm != \"\" && h.dd != \"\" {\n\t\tfullDate := h.yyyy + \"-\" + h.mm + \"-\" + h.dd\n\t\th.setField(validFields[\"date\"], fullDate)\n\t}\n}\n\nfunc (m parseMapper) parse(scene *models.Scene) *sceneHolder {\n\n\t// #302 - if the pattern includes a path separator, then include the entire\n\t// scene path in the match. Otherwise, use the default behaviour of just\n\t// the file's basename\n\t// must be double \\ because of the regex escaping\n\tfilename := filepath.Base(scene.Path)\n\tif strings.Contains(m.regexString, `\\\\`) || strings.Contains(m.regexString, \"/\") {\n\t\tfilename = scene.Path\n\t}\n\n\tresult := m.regex.FindStringSubmatch(filename)\n\n\tif len(result) == 0 {\n\t\treturn nil\n\t}\n\n\tinitParserFields()\n\n\tsceneHolder := newSceneHolder(scene)\n\n\tfor index, match := range result {\n\t\tif index == 0 {\n\t\t\t// skip entire match\n\t\t\tcontinue\n\t\t}\n\n\t\tfield := m.fields[index-1]\n\t\tparserField, found := validFields[field]\n\t\tif found {\n\t\t\tsceneHolder.setField(parserField, match)\n\t\t}\n\t}\n\n\tsceneHolder.postParse()\n\n\treturn sceneHolder\n}\n\ntype FilenameParser struct {\n\tPattern        string\n\tParserInput    models.SceneParserInput\n\tFilter         *models.FindFilterType\n\twhitespaceRE   *regexp.Regexp\n\trepository     FilenameParserRepository\n\tperformerCache map[string]*models.Performer\n\tstudioCache    map[string]*models.Studio\n\tgroupCache     map[string]*models.Group\n\ttagCache       map[string]*models.Tag\n}\n\nfunc NewFilenameParser(filter *models.FindFilterType, config models.SceneParserInput, repo FilenameParserRepository) *FilenameParser {\n\tp := &FilenameParser{\n\t\tPattern:     *filter.Q,\n\t\tParserInput: config,\n\t\tFilter:      filter,\n\t\trepository:  repo,\n\t}\n\n\tp.performerCache = make(map[string]*models.Performer)\n\tp.studioCache = make(map[string]*models.Studio)\n\tp.groupCache = make(map[string]*models.Group)\n\tp.tagCache = make(map[string]*models.Tag)\n\n\tp.initWhiteSpaceRegex()\n\n\treturn p\n}\n\nfunc (p *FilenameParser) initWhiteSpaceRegex() {\n\tcompileREs()\n\n\twsChars := \"\"\n\tif p.ParserInput.WhitespaceCharacters != nil {\n\t\twsChars = *p.ParserInput.WhitespaceCharacters\n\t\twsChars = strings.TrimSpace(wsChars)\n\t}\n\n\tif len(wsChars) > 0 {\n\t\twsRegExp := escapeCharRE.ReplaceAllString(wsChars, `\\$1`)\n\t\twsRegExp = \"[\" + wsRegExp + \"]\"\n\t\tp.whitespaceRE = regexp.MustCompile(wsRegExp)\n\t}\n}\n\ntype FilenameParserRepository struct {\n\tScene     models.SceneQueryer\n\tPerformer PerformerNamesFinder\n\tStudio    models.StudioQueryer\n\tGroup     GroupNameFinder\n\tTag       models.TagQueryer\n}\n\nfunc NewFilenameParserRepository(repo models.Repository) FilenameParserRepository {\n\treturn FilenameParserRepository{\n\t\tScene:     repo.Scene,\n\t\tPerformer: repo.Performer,\n\t\tStudio:    repo.Studio,\n\t\tGroup:     repo.Group,\n\t\tTag:       repo.Tag,\n\t}\n}\n\nfunc (p *FilenameParser) Parse(ctx context.Context) ([]*models.SceneParserResult, int, error) {\n\t// perform the query to find the scenes\n\tmapper, err := newParseMapper(p.Pattern, p.ParserInput.IgnoreWords)\n\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tsceneFilter := &models.SceneFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\tValue:    \"(?i)\" + mapper.regexString,\n\t\t},\n\t}\n\n\tif p.ParserInput.IgnoreOrganized != nil && *p.ParserInput.IgnoreOrganized {\n\t\torganized := false\n\t\tsceneFilter.Organized = &organized\n\t}\n\n\tp.Filter.Q = nil\n\n\tscenes, total, err := QueryWithCount(ctx, p.repository.Scene, sceneFilter, p.Filter)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tret := p.parseScenes(ctx, scenes, mapper)\n\n\treturn ret, total, nil\n}\n\nfunc (p *FilenameParser) parseScenes(ctx context.Context, scenes []*models.Scene, mapper *parseMapper) []*models.SceneParserResult {\n\tvar ret []*models.SceneParserResult\n\tfor _, scene := range scenes {\n\t\tsceneHolder := mapper.parse(scene)\n\n\t\tif sceneHolder != nil {\n\t\t\tr := &models.SceneParserResult{\n\t\t\t\tScene: scene,\n\t\t\t}\n\t\t\tp.setParserResult(ctx, *sceneHolder, r)\n\n\t\t\tret = append(ret, r)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (p FilenameParser) replaceWhitespaceCharacters(value string) string {\n\tif p.whitespaceRE != nil {\n\t\tvalue = p.whitespaceRE.ReplaceAllString(value, \" \")\n\t\t// remove consecutive spaces\n\t\tvalue = multiWSRE.ReplaceAllString(value, \" \")\n\t}\n\n\treturn value\n}\n\ntype PerformerNamesFinder interface {\n\tFindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error)\n}\n\nfunc (p *FilenameParser) queryPerformer(ctx context.Context, qb PerformerNamesFinder, performerName string) *models.Performer {\n\t// massage the performer name\n\tperformerName = delimiterRE.ReplaceAllString(performerName, \" \")\n\n\t// check cache first\n\tif ret, found := p.performerCache[performerName]; found {\n\t\treturn ret\n\t}\n\n\t// perform an exact match and grab the first\n\tperformers, _ := qb.FindByNames(ctx, []string{performerName}, true)\n\n\tvar ret *models.Performer\n\tif len(performers) > 0 {\n\t\tret = performers[0]\n\t}\n\n\t// add result to cache\n\tp.performerCache[performerName] = ret\n\n\treturn ret\n}\n\nfunc (p *FilenameParser) queryStudio(ctx context.Context, qb models.StudioQueryer, studioName string) *models.Studio {\n\t// massage the performer name\n\tstudioName = delimiterRE.ReplaceAllString(studioName, \" \")\n\n\t// check cache first\n\tif ret, found := p.studioCache[studioName]; found {\n\t\treturn ret\n\t}\n\n\tret, _ := studio.ByName(ctx, qb, studioName)\n\n\t// try to match on alias\n\tif ret == nil {\n\t\tret, _ = studio.ByAlias(ctx, qb, studioName)\n\t}\n\n\t// add result to cache\n\tp.studioCache[studioName] = ret\n\n\treturn ret\n}\n\ntype GroupNameFinder interface {\n\tFindByName(ctx context.Context, name string, nocase bool) (*models.Group, error)\n}\n\nfunc (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, groupName string) *models.Group {\n\t// massage the group name\n\tgroupName = delimiterRE.ReplaceAllString(groupName, \" \")\n\n\t// check cache first\n\tif ret, found := p.groupCache[groupName]; found {\n\t\treturn ret\n\t}\n\n\tret, _ := qb.FindByName(ctx, groupName, true)\n\n\t// add result to cache\n\tp.groupCache[groupName] = ret\n\n\treturn ret\n}\n\nfunc (p *FilenameParser) queryTag(ctx context.Context, qb models.TagQueryer, tagName string) *models.Tag {\n\t// massage the tag name\n\ttagName = delimiterRE.ReplaceAllString(tagName, \" \")\n\n\t// check cache first\n\tif ret, found := p.tagCache[tagName]; found {\n\t\treturn ret\n\t}\n\n\t// match tag name exactly\n\tret, _ := tag.ByName(ctx, qb, tagName)\n\n\t// try to match on alias\n\tif ret == nil {\n\t\tret, _ = tag.ByAlias(ctx, qb, tagName)\n\t}\n\n\t// add result to cache\n\tp.tagCache[tagName] = ret\n\n\treturn ret\n}\n\nfunc (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFinder, h sceneHolder, result *models.SceneParserResult) {\n\t// query for each performer\n\tperformersSet := make(map[int]bool)\n\tfor _, performerName := range h.performers {\n\t\tif performerName != \"\" {\n\t\t\tperformer := p.queryPerformer(ctx, qb, performerName)\n\t\t\tif performer != nil {\n\t\t\t\tif _, found := performersSet[performer.ID]; !found {\n\t\t\t\t\tresult.PerformerIds = append(result.PerformerIds, strconv.Itoa(performer.ID))\n\t\t\t\t\tperformersSet[performer.ID] = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (p *FilenameParser) setTags(ctx context.Context, qb models.TagQueryer, h sceneHolder, result *models.SceneParserResult) {\n\t// query for each performer\n\ttagsSet := make(map[int]bool)\n\tfor _, tagName := range h.tags {\n\t\tif tagName != \"\" {\n\t\t\ttag := p.queryTag(ctx, qb, tagName)\n\t\t\tif tag != nil {\n\t\t\t\tif _, found := tagsSet[tag.ID]; !found {\n\t\t\t\t\tresult.TagIds = append(result.TagIds, strconv.Itoa(tag.ID))\n\t\t\t\t\ttagsSet[tag.ID] = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (p *FilenameParser) setStudio(ctx context.Context, qb models.StudioQueryer, h sceneHolder, result *models.SceneParserResult) {\n\t// query for each performer\n\tif h.studio != \"\" {\n\t\tstudio := p.queryStudio(ctx, qb, h.studio)\n\t\tif studio != nil {\n\t\t\tstudioID := strconv.Itoa(studio.ID)\n\t\t\tresult.StudioID = &studioID\n\t\t}\n\t}\n}\n\nfunc (p *FilenameParser) setGroups(ctx context.Context, qb GroupNameFinder, h sceneHolder, result *models.SceneParserResult) {\n\t// query for each group\n\tgroupsSet := make(map[int]bool)\n\tfor _, groupName := range h.groups {\n\t\tif groupName != \"\" {\n\t\t\tgroup := p.queryGroup(ctx, qb, groupName)\n\t\t\tif group != nil {\n\t\t\t\tif _, found := groupsSet[group.ID]; !found {\n\t\t\t\t\tresult.Movies = append(result.Movies, &models.SceneMovieID{\n\t\t\t\t\t\tMovieID: strconv.Itoa(group.ID),\n\t\t\t\t\t})\n\t\t\t\t\tgroupsSet[group.ID] = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (p *FilenameParser) setParserResult(ctx context.Context, h sceneHolder, result *models.SceneParserResult) {\n\tif h.result.Title != \"\" {\n\t\ttitle := h.result.Title\n\t\ttitle = p.replaceWhitespaceCharacters(title)\n\n\t\tif p.ParserInput.CapitalizeTitle != nil && *p.ParserInput.CapitalizeTitle {\n\t\t\ttitle = capitalizeTitleRE.ReplaceAllStringFunc(title, strings.ToUpper)\n\t\t}\n\n\t\tresult.Title = &title\n\t}\n\n\tif h.result.Date != nil {\n\t\tdateStr := h.result.Date.String()\n\t\tresult.Date = &dateStr\n\t}\n\n\tif h.result.Rating != nil {\n\t\tresult.Rating = h.result.Rating\n\t}\n\n\tr := p.repository\n\n\tif len(h.performers) > 0 {\n\t\tp.setPerformers(ctx, r.Performer, h, result)\n\t}\n\tif len(h.tags) > 0 {\n\t\tp.setTags(ctx, r.Tag, h, result)\n\t}\n\tp.setStudio(ctx, r.Studio, h, result)\n\n\tif len(h.groups) > 0 {\n\t\tp.setGroups(ctx, r.Group, h, result)\n\t}\n}\n"
  },
  {
    "path": "pkg/scene/filter.go",
    "content": "package scene\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc PathsFilter(paths []string) *models.SceneFilterType {\n\tif paths == nil {\n\t\treturn nil\n\t}\n\n\tsep := string(filepath.Separator)\n\n\tvar ret *models.SceneFilterType\n\tvar or *models.SceneFilterType\n\tfor _, p := range paths {\n\t\tnewOr := &models.SceneFilterType{}\n\t\tif or != nil {\n\t\t\tor.Or = newOr\n\t\t} else {\n\t\t\tret = newOr\n\t\t}\n\n\t\tor = newOr\n\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tor.Path = &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/scene/find.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype LoadRelationshipOption func(context.Context, *models.Scene, models.SceneReader) error\n\nfunc LoadURLs(ctx context.Context, scene *models.Scene, r models.SceneReader) error {\n\tif err := scene.LoadURLs(ctx, r); err != nil {\n\t\treturn fmt.Errorf(\"loading scene URLs: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc LoadStashIDs(ctx context.Context, scene *models.Scene, r models.SceneReader) error {\n\tif err := scene.LoadStashIDs(ctx, r); err != nil {\n\t\treturn fmt.Errorf(\"failed to load stash IDs for scene %d: %w\", scene.ID, err)\n\t}\n\n\treturn nil\n}\n\nfunc LoadFiles(ctx context.Context, scene *models.Scene, r models.SceneReader) error {\n\tif err := scene.LoadFiles(ctx, r); err != nil {\n\t\treturn fmt.Errorf(\"failed to load files for scene %d: %w\", scene.ID, err)\n\t}\n\n\treturn nil\n}\n\n// FindByIDs retrieves multiple scenes by their IDs.\n// Missing scenes will be ignored, and the returned scenes are unsorted.\n// This method will load the specified relationships for each scene.\nfunc (s *Service) FindByIDs(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) {\n\tvar scenes []*models.Scene\n\tqb := s.Repository\n\n\tvar err error\n\tscenes, err = qb.FindByIDs(ctx, ids)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// TODO - we should bulk load these relationships\n\tfor _, scene := range scenes {\n\t\tif err := s.LoadRelationships(ctx, scene, load...); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn scenes, nil\n}\n\n// FindMany retrieves multiple scenes by their IDs. Return value is guaranteed to be in the same order as the input.\n// Missing scenes will return an error.\n// This method will load the specified relationships for each scene.\nfunc (s *Service) FindMany(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) {\n\tvar scenes []*models.Scene\n\tqb := s.Repository\n\n\tvar err error\n\tscenes, err = qb.FindMany(ctx, ids)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// TODO - we should bulk load these relationships\n\tfor _, scene := range scenes {\n\t\tif err := s.LoadRelationships(ctx, scene, load...); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn scenes, nil\n}\n\nfunc (s *Service) LoadRelationships(ctx context.Context, scene *models.Scene, load ...LoadRelationshipOption) error {\n\tfor _, l := range load {\n\t\tif err := l(ctx, scene, s.Repository); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/scene/fingerprints.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// GetFingerprints returns the fingerprints for the given scene ids.\nfunc (s *Service) GetScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) {\n\tfingerprints := make([]models.Fingerprints, len(ids))\n\n\tqb := s.Repository\n\n\tfor i, sceneID := range ids {\n\t\tscene, err := qb.Find(ctx, sceneID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif scene == nil {\n\t\t\treturn nil, fmt.Errorf(\"scene with id %d not found\", sceneID)\n\t\t}\n\n\t\tif err := scene.LoadFiles(ctx, qb); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar sceneFPs models.Fingerprints\n\n\t\tfor _, f := range scene.Files.List() {\n\t\t\tsceneFPs = append(sceneFPs, f.Fingerprints...)\n\t\t}\n\n\t\tfingerprints[i] = sceneFPs\n\t}\n\n\treturn fingerprints, nil\n}\n"
  },
  {
    "path": "pkg/scene/generate/generator.go",
    "content": "// Package generate provides functions to generate media assets from scenes.\npackage generate\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n)\n\nconst (\n\tmp4Pattern  = \"*.mp4\"\n\twebpPattern = \"*.webp\"\n\tjpgPattern  = \"*.jpg\"\n\ttxtPattern  = \"*.txt\"\n\tvttPattern  = \"*.vtt\"\n)\n\ntype Paths interface {\n\tTempFile(pattern string) (*os.File, error)\n}\n\ntype MarkerPaths interface {\n\tPaths\n\n\tGetVideoPreviewPath(checksum string, seconds int) string\n\tGetWebpPreviewPath(checksum string, seconds int) string\n\tGetScreenshotPath(checksum string, seconds int) string\n}\n\ntype ScenePaths interface {\n\tPaths\n\n\tGetVideoPreviewPath(checksum string) string\n\tGetWebpPreviewPath(checksum string) string\n\n\tGetSpriteImageFilePath(checksum string) string\n\tGetSpriteVttFilePath(checksum string) string\n\n\tGetTranscodePath(checksum string) string\n}\n\ntype FFMpegConfig interface {\n\tGetTranscodeInputArgs() []string\n\tGetTranscodeOutputArgs() []string\n}\n\ntype Generator struct {\n\tEncoder      *ffmpeg.FFMpeg\n\tFFMpegConfig FFMpegConfig\n\tLockManager  *fsutil.ReadLockManager\n\tMarkerPaths  MarkerPaths\n\tScenePaths   ScenePaths\n\tOverwrite    bool\n}\n\ntype generateFn func(lockCtx *fsutil.LockContext, tmpFn string) error\n\nfunc (g Generator) tempFile(p Paths, pattern string) (*os.File, error) {\n\ttmpFile, err := p.TempFile(pattern) // tmp output in case the process ends abruptly\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"creating temporary file: %w\", err)\n\t}\n\t_ = tmpFile.Close()\n\treturn tmpFile, err\n}\n\n// generateFile performs a generate operation by generating a temporary file using p and pattern, then\n// moving it to output on success.\nfunc (g Generator) generateFile(lockCtx *fsutil.LockContext, p Paths, pattern string, output string, generateFn generateFn) error {\n\ttmpFile, err := g.tempFile(p, pattern) // tmp output in case the process ends abruptly\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpFn := tmpFile.Name()\n\tdefer func() {\n\t\t_ = os.Remove(tmpFn)\n\t}()\n\n\tif err := generateFn(lockCtx, tmpFn); err != nil {\n\t\treturn err\n\t}\n\n\t// check if generated empty file\n\tstat, err := os.Stat(tmpFn)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error getting file stat: %w\", err)\n\t}\n\n\tif stat.Size() == 0 {\n\t\treturn fmt.Errorf(\"ffmpeg command produced no output\")\n\t}\n\n\tif err := fsutil.SafeMove(tmpFn, output); err != nil {\n\t\treturn fmt.Errorf(\"moving %s to %s failed: %w\", tmpFn, output, err)\n\t}\n\n\treturn nil\n}\n\n// generateBytes performs a generate operation by generating a temporary file using p and pattern, returns the contents, then deletes it.\nfunc (g Generator) generateBytes(lockCtx *fsutil.LockContext, p Paths, pattern string, generateFn generateFn) ([]byte, error) {\n\ttmpFile, err := g.tempFile(p, pattern) // tmp output in case the process ends abruptly\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttmpFn := tmpFile.Name()\n\tdefer func() {\n\t\t_ = os.Remove(tmpFn)\n\t}()\n\n\tif err := generateFn(lockCtx, tmpFn); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer os.Remove(tmpFn)\n\treturn os.ReadFile(tmpFn)\n}\n\n// generate runs ffmpeg with the given args and waits for it to finish.\n// Returns an error if the command fails. If the command fails, the return\n// value will be of type *exec.ExitError.\nfunc (g Generator) generate(ctx *fsutil.LockContext, args []string) error {\n\tcmd := g.Encoder.Command(ctx, args)\n\n\tvar stderr bytes.Buffer\n\tcmd.Stderr = &stderr\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn fmt.Errorf(\"error starting command: %w\", err)\n\t}\n\n\tctx.AttachCommand(cmd)\n\n\tif err := cmd.Wait(); err != nil {\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) {\n\t\t\texitErr.Stderr = stderr.Bytes()\n\t\t\terr = exitErr\n\t\t}\n\t\treturn fmt.Errorf(\"error running ffmpeg command <%s>: %w\", strings.Join(args, \" \"), err)\n\t}\n\n\treturn nil\n}\n\n// GenerateOutput runs ffmpeg with the given args and returns it standard output.\nfunc (g Generator) generateOutput(lockCtx *fsutil.LockContext, args []string) ([]byte, error) {\n\tcmd := g.Encoder.Command(lockCtx, args)\n\n\tvar stdout bytes.Buffer\n\tcmd.Stdout = &stdout\n\n\tvar stderr bytes.Buffer\n\tcmd.Stderr = &stderr\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn nil, fmt.Errorf(\"error starting command: %w\", err)\n\t}\n\n\tlockCtx.AttachCommand(cmd)\n\n\tif err := cmd.Wait(); err != nil {\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) {\n\t\t\texitErr.Stderr = stderr.Bytes()\n\t\t\terr = exitErr\n\t\t}\n\t\treturn nil, fmt.Errorf(\"error running ffmpeg command <%s>: %w\", strings.Join(args, \" \"), err)\n\t}\n\n\tif stdout.Len() == 0 {\n\t\treturn nil, fmt.Errorf(\"ffmpeg command produced no output: <%s>\", strings.Join(args, \" \"))\n\t}\n\n\treturn stdout.Bytes(), nil\n}\n"
  },
  {
    "path": "pkg/scene/generate/marker_preview.go",
    "content": "package generate\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg/transcoder\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst (\n\tmarkerPreviewWidth        = 640\n\tmaxMarkerPreviewDuration  = 20\n\tmarkerPreviewAudioBitrate = \"64k\"\n\n\tmarkerImageDuration = 5\n\tmarkerWebpFPS       = 12\n\n\tmarkerScreenshotQuality = 2\n)\n\nfunc (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash string, seconds float64, endSeconds *float64, includeAudio bool) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\toutput := g.MarkerPaths.GetVideoPreviewPath(hash, int(seconds))\n\tif !g.Overwrite {\n\t\tif exists, _ := fsutil.FileExists(output); exists {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tduration := float64(maxMarkerPreviewDuration)\n\n\t// don't allow preview to exceed max duration\n\tif endSeconds != nil && *endSeconds-seconds < maxMarkerPreviewDuration {\n\t\tduration = float64(*endSeconds) - seconds\n\t}\n\n\tif err := g.generateFile(lockCtx, g.MarkerPaths, mp4Pattern, output, g.markerPreviewVideo(input, sceneMarkerOptions{\n\t\tSeconds:  seconds,\n\t\tDuration: duration,\n\t\tAudio:    includeAudio,\n\t})); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Debug(\"created marker video: \", output)\n\n\treturn nil\n}\n\ntype sceneMarkerOptions struct {\n\tSeconds  float64\n\tDuration float64\n\tAudio    bool\n}\n\nfunc (g Generator) markerPreviewVideo(input string, options sceneMarkerOptions) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tvar videoFilter ffmpeg.VideoFilter\n\t\tvideoFilter = videoFilter.ScaleWidth(markerPreviewWidth)\n\n\t\tvar videoArgs ffmpeg.Args\n\t\tvideoArgs = videoArgs.VideoFilter(videoFilter)\n\n\t\tvideoArgs = append(videoArgs,\n\t\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\t\"-profile:v\", \"high\",\n\t\t\t\"-level\", \"4.2\",\n\t\t\t\"-preset\", \"veryslow\",\n\t\t\t\"-crf\", \"24\",\n\t\t\t\"-movflags\", \"+faststart\",\n\t\t\t\"-threads\", \"4\",\n\t\t\t\"-sws_flags\", \"lanczos\",\n\t\t\t\"-strict\", \"-2\",\n\t\t)\n\n\t\ttrimOptions := transcoder.TranscodeOptions{\n\t\t\tDuration:   options.Duration,\n\t\t\tStartTime:  options.Seconds,\n\t\t\tOutputPath: tmpFn,\n\t\t\tVideoCodec: ffmpeg.VideoCodecLibX264,\n\t\t\tVideoArgs:  videoArgs,\n\t\t}\n\n\t\tif options.Audio {\n\t\t\tvar audioArgs ffmpeg.Args\n\t\t\taudioArgs = audioArgs.AudioBitrate(markerPreviewAudioBitrate)\n\n\t\t\ttrimOptions.AudioCodec = ffmpeg.AudioCodecAAC\n\t\t\ttrimOptions.AudioArgs = audioArgs\n\t\t}\n\n\t\targs := transcoder.Transcode(input, trimOptions)\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n\nfunc (g Generator) SceneMarkerWebp(ctx context.Context, input string, hash string, seconds float64) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\toutput := g.MarkerPaths.GetWebpPreviewPath(hash, int(seconds))\n\tif !g.Overwrite {\n\t\tif exists, _ := fsutil.FileExists(output); exists {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tif err := g.generateFile(lockCtx, g.MarkerPaths, webpPattern, output, g.sceneMarkerWebp(input, sceneMarkerOptions{\n\t\tSeconds: seconds,\n\t})); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Debug(\"created marker image: \", output)\n\n\treturn nil\n}\n\nfunc (g Generator) sceneMarkerWebp(input string, options sceneMarkerOptions) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tvar videoFilter ffmpeg.VideoFilter\n\t\tvideoFilter = videoFilter.ScaleWidth(markerPreviewWidth)\n\t\tvideoFilter = videoFilter.Fps(markerWebpFPS)\n\n\t\tvar videoArgs ffmpeg.Args\n\t\tvideoArgs = videoArgs.VideoFilter(videoFilter)\n\t\tvideoArgs = append(videoArgs,\n\t\t\t\"-lossless\", \"1\",\n\t\t\t\"-q:v\", \"70\",\n\t\t\t\"-compression_level\", \"6\",\n\t\t\t\"-preset\", \"default\",\n\t\t\t\"-loop\", \"0\",\n\t\t\t\"-threads\", \"4\",\n\t\t)\n\n\t\ttrimOptions := transcoder.TranscodeOptions{\n\t\t\tDuration:   markerImageDuration,\n\t\t\tStartTime:  float64(options.Seconds),\n\t\t\tOutputPath: tmpFn,\n\t\t\tVideoCodec: ffmpeg.VideoCodecLibWebP,\n\t\t\tVideoArgs:  videoArgs,\n\t\t}\n\n\t\targs := transcoder.Transcode(input, trimOptions)\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n\nfunc (g Generator) SceneMarkerScreenshot(ctx context.Context, input string, hash string, seconds float64, width int) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\toutput := g.MarkerPaths.GetScreenshotPath(hash, int(seconds))\n\tif !g.Overwrite {\n\t\tif exists, _ := fsutil.FileExists(output); exists {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tif err := g.generateFile(lockCtx, g.MarkerPaths, jpgPattern, output, g.sceneMarkerScreenshot(input, SceneMarkerScreenshotOptions{\n\t\tSeconds: seconds,\n\t\tWidth:   width,\n\t})); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Debug(\"created marker screenshot: \", output)\n\n\treturn nil\n}\n\ntype SceneMarkerScreenshotOptions struct {\n\tSeconds float64\n\tWidth   int\n}\n\nfunc (g Generator) sceneMarkerScreenshot(input string, options SceneMarkerScreenshotOptions) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tssOptions := transcoder.ScreenshotOptions{\n\t\t\tOutputPath: tmpFn,\n\t\t\tOutputType: transcoder.ScreenshotOutputTypeImage2,\n\t\t\tQuality:    markerScreenshotQuality,\n\t\t\tWidth:      options.Width,\n\t\t}\n\n\t\targs := transcoder.ScreenshotTime(input, options.Seconds, ssOptions)\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n"
  },
  {
    "path": "pkg/scene/generate/preview.go",
    "content": "package generate\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg/transcoder\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst (\n\tscenePreviewWidth        = 640\n\tscenePreviewAudioBitrate = \"128k\"\n\n\tscenePreviewImageFPS = 12\n\n\tminSegmentDuration = 0.75\n)\n\ntype PreviewOptions struct {\n\tSegments        int\n\tSegmentDuration float64\n\tExcludeStart    string\n\tExcludeEnd      string\n\n\tPreset string\n\n\tAudio bool\n}\n\nfunc getExcludeValue(videoDuration float64, v string) float64 {\n\tif strings.HasSuffix(v, \"%\") && len(v) > 1 {\n\t\t// proportion of video duration\n\t\tv = v[0 : len(v)-1]\n\t\tprop, _ := strconv.ParseFloat(v, 64)\n\t\treturn prop / 100.0 * videoDuration\n\t}\n\n\tprop, _ := strconv.ParseFloat(v, 64)\n\treturn prop\n}\n\n// getStepSizeAndOffset calculates the step size for preview generation and\n// the starting offset.\n//\n// Step size is calculated based on the duration of the video file, minus the\n// excluded duration. The offset is based on the ExcludeStart. If the total\n// excluded duration exceeds the duration of the video, then offset is 0, and\n// the video duration is used to calculate the step size.\nfunc (g PreviewOptions) getStepSizeAndOffset(videoDuration float64) (stepSize float64, offset float64) {\n\texcludeStart := getExcludeValue(videoDuration, g.ExcludeStart)\n\texcludeEnd := getExcludeValue(videoDuration, g.ExcludeEnd)\n\n\tduration := videoDuration\n\tif videoDuration > excludeStart+excludeEnd {\n\t\tduration = duration - excludeStart - excludeEnd\n\t\toffset = excludeStart\n\t}\n\n\tstepSize = duration / float64(g.Segments)\n\treturn\n}\n\nfunc (g Generator) PreviewVideo(ctx context.Context, input string, videoDuration float64, hash string, options PreviewOptions, fallback bool, useVsync2 bool) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\toutput := g.ScenePaths.GetVideoPreviewPath(hash)\n\tif !g.Overwrite {\n\t\tif exists, _ := fsutil.FileExists(output); exists {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tlogger.Infof(\"[generator] generating video preview for %s\", input)\n\n\tif err := g.generateFile(lockCtx, g.ScenePaths, mp4Pattern, output, g.previewVideo(input, videoDuration, options, fallback, useVsync2)); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Debug(\"created video preview: \", output)\n\n\treturn nil\n}\n\nfunc (g *Generator) previewVideo(input string, videoDuration float64, options PreviewOptions, fallback bool, useVsync2 bool) generateFn {\n\t// #2496 - generate a single preview video for videos shorter than segments * segment duration\n\tif videoDuration < options.SegmentDuration*float64(options.Segments) {\n\t\treturn g.previewVideoSingle(input, videoDuration, options, fallback, useVsync2)\n\t}\n\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\t// a list of tmp files used during the preview generation\n\t\tvar tmpFiles []string\n\n\t\t// remove tmpFiles when done\n\t\tdefer func() { removeFiles(tmpFiles) }()\n\n\t\tstepSize, offset := options.getStepSizeAndOffset(videoDuration)\n\n\t\tsegmentDuration := options.SegmentDuration\n\t\t// TODO - move this out into calling function\n\t\t// a very short duration can create files without a video stream\n\t\tif segmentDuration < minSegmentDuration {\n\t\t\tsegmentDuration = minSegmentDuration\n\t\t\tlogger.Warnf(\"[generator] Segment duration (%f) too short. Using %f instead.\", options.SegmentDuration, minSegmentDuration)\n\t\t}\n\n\t\tfor i := 0; i < options.Segments; i++ {\n\t\t\tchunkFile, err := g.tempFile(g.ScenePaths, mp4Pattern)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"generating video preview chunk file: %w\", err)\n\t\t\t}\n\n\t\t\ttmpFiles = append(tmpFiles, chunkFile.Name())\n\n\t\t\ttime := offset + (float64(i) * stepSize)\n\n\t\t\tchunkOptions := previewChunkOptions{\n\t\t\t\tStartTime:  time,\n\t\t\t\tDuration:   segmentDuration,\n\t\t\t\tOutputPath: chunkFile.Name(),\n\t\t\t\tAudio:      options.Audio,\n\t\t\t\tPreset:     options.Preset,\n\t\t\t}\n\n\t\t\tif err := g.previewVideoChunk(lockCtx, input, chunkOptions, fallback, useVsync2); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// generate concat file based on generated video chunks\n\t\tconcatFilePath, err := g.generateConcatFile(tmpFiles)\n\t\tif concatFilePath != \"\" {\n\t\t\ttmpFiles = append(tmpFiles, concatFilePath)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn g.previewVideoChunkCombine(lockCtx, concatFilePath, tmpFn)\n\t}\n}\n\nfunc (g *Generator) previewVideoSingle(input string, videoDuration float64, options PreviewOptions, fallback bool, useVsync2 bool) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tchunkOptions := previewChunkOptions{\n\t\t\tStartTime:  0,\n\t\t\tDuration:   videoDuration,\n\t\t\tOutputPath: tmpFn,\n\t\t\tAudio:      options.Audio,\n\t\t\tPreset:     options.Preset,\n\t\t}\n\n\t\treturn g.previewVideoChunk(lockCtx, input, chunkOptions, fallback, useVsync2)\n\t}\n}\n\ntype previewChunkOptions struct {\n\tStartTime  float64\n\tDuration   float64\n\tOutputPath string\n\tAudio      bool\n\tPreset     string\n}\n\nfunc (g Generator) previewVideoChunk(lockCtx *fsutil.LockContext, fn string, options previewChunkOptions, fallback bool, useVsync2 bool) error {\n\tvar videoFilter ffmpeg.VideoFilter\n\tvideoFilter = videoFilter.ScaleWidth(scenePreviewWidth)\n\n\tvar videoArgs ffmpeg.Args\n\tvideoArgs = videoArgs.VideoFilter(videoFilter)\n\n\tvideoArgs = append(videoArgs,\n\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\"-profile:v\", \"high\",\n\t\t\"-level\", \"4.2\",\n\t\t\"-preset\", options.Preset,\n\t\t\"-crf\", \"21\",\n\t\t\"-threads\", \"4\",\n\t\t\"-strict\", \"-2\",\n\t)\n\n\tif useVsync2 {\n\t\tvideoArgs = append(videoArgs, \"-vsync\", \"2\")\n\t}\n\n\ttrimOptions := transcoder.TranscodeOptions{\n\t\tOutputPath: options.OutputPath,\n\t\tStartTime:  options.StartTime,\n\t\tDuration:   options.Duration,\n\n\t\tXError:   !fallback,\n\t\tSlowSeek: fallback,\n\n\t\tVideoCodec: ffmpeg.VideoCodecLibX264,\n\t\tVideoArgs:  videoArgs,\n\n\t\tExtraInputArgs:  g.FFMpegConfig.GetTranscodeInputArgs(),\n\t\tExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(),\n\t}\n\n\tif options.Audio {\n\t\tvar audioArgs ffmpeg.Args\n\t\taudioArgs = audioArgs.AudioBitrate(scenePreviewAudioBitrate)\n\n\t\ttrimOptions.AudioCodec = ffmpeg.AudioCodecAAC\n\t\ttrimOptions.AudioArgs = audioArgs\n\t}\n\n\targs := transcoder.Transcode(fn, trimOptions)\n\n\treturn g.generate(lockCtx, args)\n}\n\nfunc (g Generator) generateConcatFile(chunkFiles []string) (fn string, err error) {\n\tconcatFile, err := g.ScenePaths.TempFile(txtPattern)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"creating concat file: %w\", err)\n\t}\n\tdefer concatFile.Close()\n\n\tw := bufio.NewWriter(concatFile)\n\tfor _, f := range chunkFiles {\n\t\t// files in concat file should be relative to concat\n\t\trelFile := filepath.Base(f)\n\t\tif _, err := w.WriteString(fmt.Sprintf(\"file '%s'\\n\", relFile)); err != nil {\n\t\t\treturn concatFile.Name(), fmt.Errorf(\"writing concat file: %w\", err)\n\t\t}\n\t}\n\treturn concatFile.Name(), w.Flush()\n}\n\nfunc (g Generator) previewVideoChunkCombine(lockCtx *fsutil.LockContext, concatFilePath string, outputPath string) error {\n\tspliceOptions := transcoder.SpliceOptions{\n\t\tOutputPath: outputPath,\n\t}\n\n\targs := transcoder.Splice(concatFilePath, spliceOptions)\n\n\treturn g.generate(lockCtx, args)\n}\n\nfunc removeFiles(list []string) {\n\tfor _, f := range list {\n\t\tif err := os.Remove(f); err != nil {\n\t\t\tlogger.Warnf(\"[generator] Delete error: %s\", err)\n\t\t}\n\t}\n}\n\n// PreviewWebp generates a webp file based on the preview video input.\n// TODO - this should really generate a new webp using chunks.\nfunc (g Generator) PreviewWebp(ctx context.Context, input string, hash string) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\toutput := g.ScenePaths.GetWebpPreviewPath(hash)\n\tif !g.Overwrite {\n\t\tif exists, _ := fsutil.FileExists(output); exists {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tlogger.Infof(\"[generator] generating webp preview for %s\", input)\n\n\tsrc := g.ScenePaths.GetVideoPreviewPath(hash)\n\n\tif err := g.generateFile(lockCtx, g.ScenePaths, webpPattern, output, g.previewVideoToImage(src)); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Debug(\"created video preview: \", output)\n\n\treturn nil\n}\n\nfunc (g Generator) previewVideoToImage(input string) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tvar videoFilter ffmpeg.VideoFilter\n\t\tvideoFilter = videoFilter.ScaleWidth(scenePreviewWidth)\n\t\tvideoFilter = videoFilter.Fps(scenePreviewImageFPS)\n\n\t\tvar videoArgs ffmpeg.Args\n\t\tvideoArgs = videoArgs.VideoFilter(videoFilter)\n\n\t\tvideoArgs = append(videoArgs,\n\t\t\t\"-lossless\", \"1\",\n\t\t\t\"-q:v\", \"70\",\n\t\t\t\"-compression_level\", \"6\",\n\t\t\t\"-preset\", \"default\",\n\t\t\t\"-loop\", \"0\",\n\t\t\t\"-threads\", \"4\",\n\t\t)\n\n\t\tencodeOptions := transcoder.TranscodeOptions{\n\t\t\tOutputPath: tmpFn,\n\n\t\t\tVideoCodec: ffmpeg.VideoCodecLibWebP,\n\t\t\tVideoArgs:  videoArgs,\n\n\t\t\tExtraInputArgs:  g.FFMpegConfig.GetTranscodeInputArgs(),\n\t\t\tExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(),\n\t\t}\n\n\t\targs := transcoder.Transcode(input, encodeOptions)\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n"
  },
  {
    "path": "pkg/scene/generate/screenshot.go",
    "content": "package generate\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg/transcoder\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst (\n\t// thumbnailWidth   = 320\n\t// thumbnailQuality = 5\n\n\tscreenshotQuality = 2\n\n\tscreenshotDurationProportion = 0.2\n)\n\ntype ScreenshotOptions struct {\n\tAt *float64\n}\n\nfunc (g Generator) Screenshot(ctx context.Context, input string, videoWidth int, videoDuration float64, options ScreenshotOptions) ([]byte, error) {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\tlogger.Infof(\"Creating screenshot for %s\", input)\n\n\tat := screenshotDurationProportion * videoDuration\n\tif options.At != nil {\n\t\tat = *options.At\n\t}\n\n\tret, err := g.generateBytes(lockCtx, g.ScenePaths, jpgPattern, g.screenshot(input, screenshotOptions{\n\t\tTime:    at,\n\t\tQuality: screenshotQuality,\n\t\t// default Width is video width\n\t}))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\ntype screenshotOptions struct {\n\tTime    float64\n\tWidth   int\n\tQuality int\n}\n\nfunc (g Generator) screenshot(input string, options screenshotOptions) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tssOptions := transcoder.ScreenshotOptions{\n\t\t\tOutputPath: tmpFn,\n\t\t\tOutputType: transcoder.ScreenshotOutputTypeImage2,\n\t\t\tQuality:    options.Quality,\n\t\t\tWidth:      options.Width,\n\t\t}\n\n\t\targs := transcoder.ScreenshotTime(input, options.Time, ssOptions)\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n"
  },
  {
    "path": "pkg/scene/generate/sprite.go",
    "content": "package generate\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"image\"\n\t\"image/color\"\n\t\"math\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/disintegration/imaging\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg/transcoder\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds float64, size int, isPortrait bool) (image.Image, error) {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\tssOptions := transcoder.ScreenshotOptions{\n\t\tOutputPath: \"-\",\n\t\tOutputType: transcoder.ScreenshotOutputTypeBMP,\n\t}\n\n\tif !isPortrait {\n\t\tssOptions.Width = size\n\t} else {\n\t\tssOptions.Height = size\n\t}\n\n\targs := transcoder.ScreenshotTime(input, seconds, ssOptions)\n\n\treturn g.generateImage(lockCtx, args)\n}\n\nfunc (g Generator) SpriteScreenshotSlow(ctx context.Context, input string, frame int, width int) (image.Image, error) {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\tssOptions := transcoder.ScreenshotOptions{\n\t\tOutputPath: \"-\",\n\t\tOutputType: transcoder.ScreenshotOutputTypeBMP,\n\t\tWidth:      width,\n\t}\n\n\targs := transcoder.ScreenshotFrame(input, frame, ssOptions)\n\n\treturn g.generateImage(lockCtx, args)\n}\n\nfunc (g Generator) generateImage(lockCtx *fsutil.LockContext, args ffmpeg.Args) (image.Image, error) {\n\tout, err := g.generateOutput(lockCtx, args)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\timg, _, err := image.Decode(bytes.NewReader(out))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"decoding image from ffmpeg: %w\", err)\n\t}\n\n\treturn img, nil\n}\n\nfunc (g Generator) CombineSpriteImages(images []image.Image) image.Image {\n\t// Combine all of the thumbnails into a sprite image\n\twidth := images[0].Bounds().Size().X\n\theight := images[0].Bounds().Size().Y\n\tgridSize := GetSpriteGridSize(len(images))\n\tcanvasWidth := width * gridSize\n\tcanvasHeight := height * gridSize\n\tmontage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{})\n\tfor index := 0; index < len(images); index++ {\n\t\tx := width * (index % gridSize)\n\t\ty := height * int(math.Floor(float64(index)/float64(gridSize)))\n\t\timg := images[index]\n\t\tmontage = imaging.Paste(montage, img, image.Pt(x, y))\n\t}\n\n\treturn montage\n}\n\n// GetSpriteGridSize return the required size of a grid, where the number of images in width\n// equals the number of images in height, to hold 'imageCount' images\nfunc GetSpriteGridSize(imageCount int) int {\n\treturn int(math.Ceil(math.Sqrt(float64(imageCount))))\n}\n\nfunc (g Generator) SpriteVTT(ctx context.Context, output string, spritePath string, stepSize float64, spriteChunks int) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, spritePath)\n\tdefer lockCtx.Cancel()\n\treturn g.generateFile(lockCtx, g.ScenePaths, vttPattern, output, g.spriteVTT(spritePath, stepSize, spriteChunks))\n}\n\nfunc (g Generator) spriteVTT(spritePath string, stepSize float64, spriteChunks int) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tspriteImage, err := os.Open(spritePath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer spriteImage.Close()\n\t\tspriteImageName := filepath.Base(spritePath)\n\t\timage, _, err := image.DecodeConfig(spriteImage)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tgridSize := GetSpriteGridSize(spriteChunks)\n\t\twidth := image.Width / gridSize\n\t\theight := image.Height / gridSize\n\n\t\tvttLines := []string{\"WEBVTT\", \"\"}\n\t\tfor index := 0; index < spriteChunks; index++ {\n\t\t\tx := width * (index % gridSize)\n\t\t\ty := height * int(math.Floor(float64(index)/float64(gridSize)))\n\t\t\tstartTime := utils.GetVTTTime(float64(index) * stepSize)\n\t\t\tendTime := utils.GetVTTTime(float64(index+1) * stepSize)\n\t\t\tvttLines = append(vttLines, startTime+\" --> \"+endTime)\n\t\t\tvttLines = append(vttLines, fmt.Sprintf(\"%s#xywh=%d,%d,%d,%d\", spriteImageName, x, y, width, height))\n\t\t\tvttLines = append(vttLines, \"\")\n\t\t}\n\t\tvtt := strings.Join(vttLines, \"\\n\")\n\n\t\treturn os.WriteFile(tmpFn, []byte(vtt), 0644)\n\t}\n}\n\n// TODO - move all sprite generation code here\n// WIP\n// func (g Generator) Sprite(ctx context.Context, videoFile *ffmpeg.VideoFile, hash string) error {\n// \tinput := videoFile.Path\n// \tif err := g.generateSpriteImage(ctx, videoFile, hash); err != nil {\n// \t\treturn fmt.Errorf(\"generating sprite image for %s: %w\", input, err)\n// \t}\n\n// \toutput := g.ScenePaths.GetSpriteVttFilePath(hash)\n// \tif !g.Overwrite {\n// \t\tif exists, _ := fsutil.FileExists(output); exists {\n// \t\t\treturn nil\n// \t\t}\n// \t}\n\n// \tif err := g.generateFile(ctx, g.ScenePaths, vttPattern, output, g.spriteVtt(input, screenshotOptions{\n// \t\tTime:    at,\n// \t\tQuality: screenshotQuality,\n// \t\t// default Width is video width\n// \t})); err != nil {\n// \t\treturn err\n// \t}\n\n// \tlogger.Debug(\"created screenshot: \", output)\n\n// \treturn nil\n// }\n\n// func (g Generator) generateSpriteImage(ctx context.Context, videoFile *ffmpeg.VideoFile, hash string) error {\n// \toutput := g.ScenePaths.GetSpriteImageFilePath(hash)\n// \tif !g.Overwrite {\n// \t\tif exists, _ := fsutil.FileExists(output); exists {\n// \t\t\treturn nil\n// \t\t}\n// \t}\n\n// \tvar images []image.Image\n// \tvar err error\n// \tif options.VideoDuration > 0 {\n// \t\timages, err = g.generateSprites(ctx, input, options.VideoDuration)\n// \t} else {\n// \t\timages, err = g.generateSpritesSlow(ctx, input, options.FrameCount)\n// \t}\n\n// \tif len(images) == 0 {\n// \t\treturn errors.New(\"images slice is empty\")\n// \t}\n\n// \tmontage, err := g.combineSpriteImages(images)\n// \tif err != nil {\n// \t\treturn err\n// \t}\n\n// \tif err := imaging.Save(montage, output); err != nil {\n// \t\treturn err\n// \t}\n\n// \tlogger.Debug(\"created sprite image: \", output)\n\n// \treturn nil\n// }\n\n// func useSlowSeek(videoFile *ffmpeg.VideoFile) (bool, error) {\n// \t// For files with small duration / low frame count  try to seek using frame number intead of seconds\n// \t// some files can have FrameCount == 0, only use SlowSeek if duration < 5\n// \tif videoFile.Duration < 5 || (videoFile.FrameCount > 0 && videoFile.FrameCount <= int64(spriteChunks)) {\n// \t\tif videoFile.Duration <= 0 {\n// \t\t\treturn false, fmt.Errorf(\"duration(%.3f)/frame count(%d) invalid\", videoFile.Duration, videoFile.FrameCount)\n// \t\t}\n\n// \t\tlogger.Warnf(\"[generator] video %s too short (%.3fs, %d frames), using frame seeking\", videoFile.Path, videoFile.Duration, videoFile.FrameCount)\n// \t\treturn true, nil\n// \t}\n// }\n\n// func (g Generator) combineSpriteImages(images []image.Image) (image.Image, error) {\n// \t// Combine all of the thumbnails into a sprite image\n// \twidth := images[0].Bounds().Size().X\n// \theight := images[0].Bounds().Size().Y\n// \tcanvasWidth := width * spriteCols\n// \tcanvasHeight := height * spriteRows\n// \tmontage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{})\n// \tfor index := 0; index < len(images); index++ {\n// \t\tx := width * (index % spriteCols)\n// \t\ty := height * int(math.Floor(float64(index)/float64(spriteRows)))\n// \t\timg := images[index]\n// \t\tmontage = imaging.Paste(montage, img, image.Pt(x, y))\n// \t}\n\n// \treturn montage, nil\n// }\n\n// func (g Generator) generateSprites(ctx context.Context, input string, videoDuration float64) ([]image.Image, error) {\n// \tlogger.Infof(\"[generator] generating sprite image for %s\", input)\n// \t// generate `ChunkCount` thumbnails\n// \tstepSize := videoDuration / float64(spriteChunks)\n\n// \tvar images []image.Image\n// \tfor i := 0; i < spriteChunks; i++ {\n// \t\ttime := float64(i) * stepSize\n\n// \t\timg, err := g.spriteScreenshot(ctx, input, time)\n// \t\tif err != nil {\n// \t\t\treturn nil, err\n// \t\t}\n// \t\timages = append(images, img)\n// \t}\n\n// \treturn images, nil\n// }\n\n// func (g Generator) generateSpritesSlow(ctx context.Context, input string, frameCount int) ([]image.Image, error) {\n// \tlogger.Infof(\"[generator] generating sprite image for %s (%d frames)\", input, frameCount)\n\n// \tstepFrame := float64(frameCount-1) / float64(spriteChunks)\n\n// \tvar images []image.Image\n// \tfor i := 0; i < spriteChunks; i++ {\n// \t\t// generate exactly `ChunkCount` thumbnails, using duplicate frames if needed\n// \t\tframe := math.Round(float64(i) * stepFrame)\n// \t\tif frame >= math.MaxInt || frame <= math.MinInt {\n// \t\t\treturn nil, errors.New(\"invalid frame number conversion\")\n// \t\t}\n\n// \t\timg, err := g.spriteScreenshotSlow(ctx, input, int(frame))\n// \t\tif err != nil {\n// \t\t\treturn nil, err\n// \t\t}\n// \t\timages = append(images, img)\n// \t}\n\n// \treturn images, nil\n// }\n\n// func (g Generator) spriteScreenshot(ctx context.Context, input string, seconds float64) (image.Image, error) {\n// \tssOptions := transcoder.ScreenshotOptions{\n// \t\tOutputPath: \"-\",\n// \t\tOutputType: transcoder.ScreenshotOutputTypeBMP,\n// \t\tWidth:      spriteScreenshotWidth,\n// \t}\n\n// \targs := transcoder.ScreenshotTime(input, seconds, ssOptions)\n\n// \treturn g.generateImage(ctx, args)\n// }\n\n// func (g Generator) spriteScreenshotSlow(ctx context.Context, input string, frame int) (image.Image, error) {\n// \tssOptions := transcoder.ScreenshotOptions{\n// \t\tOutputPath: \"-\",\n// \t\tOutputType: transcoder.ScreenshotOutputTypeBMP,\n// \t\tWidth:      spriteScreenshotWidth,\n// \t}\n\n// \targs := transcoder.ScreenshotFrame(input, frame, ssOptions)\n\n// \treturn g.generateImage(ctx, args)\n// }\n\n// func (g Generator) spriteVTT(videoFile ffmpeg.VideoFile, spriteImagePath string, slowSeek bool) generateFn {\n// \treturn func(ctx context.Context, tmpFn string) error {\n// \t\tlogger.Infof(\"[generator] generating sprite vtt for %s\", input)\n\n// \t\tspriteImage, err := os.Open(spriteImagePath)\n// \t\tif err != nil {\n// \t\t\treturn err\n// \t\t}\n// \t\tdefer spriteImage.Close()\n// \t\tspriteImageName := filepath.Base(spriteImagePath)\n// \t\timage, _, err := image.DecodeConfig(spriteImage)\n// \t\tif err != nil {\n// \t\t\treturn err\n// \t\t}\n// \t\twidth := image.Width / spriteCols\n// \t\theight := image.Height / spriteRows\n\n// \t\tvar stepSize float64\n// \t\tif !slowSeek {\n// \t\t\tnthFrame = g.NumberOfFrames / g.ChunkCount\n// \t\t\tstepSize = float64(g.Info.NthFrame) / g.Info.FrameRate\n// \t\t} else {\n// \t\t\t// for files with a low framecount (<ChunkCount) g.Info.NthFrame can be zero\n// \t\t\t// so recalculate from scratch\n// \t\t\tstepSize = float64(videoFile.FrameCount-1) / float64(spriteChunks)\n// \t\t\tstepSize /= g.Info.FrameRate\n// \t\t}\n\n// \t\tvttLines := []string{\"WEBVTT\", \"\"}\n// \t\tfor index := 0; index < spriteChunks; index++ {\n// \t\t\tx := width * (index % spriteCols)\n// \t\t\ty := height * int(math.Floor(float64(index)/float64(spriteRows)))\n// \t\t\tstartTime := utils.GetVTTTime(float64(index) * stepSize)\n// \t\t\tendTime := utils.GetVTTTime(float64(index+1) * stepSize)\n\n// \t\t\tvttLines = append(vttLines, startTime+\" --> \"+endTime)\n// \t\t\tvttLines = append(vttLines, fmt.Sprintf(\"%s#xywh=%d,%d,%d,%d\", spriteImageName, x, y, width, height))\n// \t\t\tvttLines = append(vttLines, \"\")\n// \t\t}\n// \t\tvtt := strings.Join(vttLines, \"\\n\")\n\n// \t\treturn os.WriteFile(tmpFn, []byte(vtt), 0644)\n// \t}\n// }\n"
  },
  {
    "path": "pkg/scene/generate/transcode.go",
    "content": "package generate\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/ffmpeg\"\n\t\"github.com/stashapp/stash/pkg/ffmpeg/transcoder\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype TranscodeOptions struct {\n\tWidth  int\n\tHeight int\n}\n\nfunc (g Generator) Transcode(ctx context.Context, input string, hash string, options TranscodeOptions) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\treturn g.makeTranscode(lockCtx, hash, g.transcode(input, options))\n}\n\n// TranscodeVideo transcodes the video, and removes the audio.\n// In some videos where the audio codec is not supported by ffmpeg,\n// ffmpeg fails if you try to transcode the audio\nfunc (g Generator) TranscodeVideo(ctx context.Context, input string, hash string, options TranscodeOptions) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\treturn g.makeTranscode(lockCtx, hash, g.transcodeVideo(input, options))\n}\n\n// TranscodeAudio will copy the video stream as is, and transcode audio.\nfunc (g Generator) TranscodeAudio(ctx context.Context, input string, hash string) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\treturn g.makeTranscode(lockCtx, hash, g.transcodeAudio(input))\n}\n\n// TranscodeCopyVideo will copy the video stream as is, and drop the audio stream.\nfunc (g Generator) TranscodeCopyVideo(ctx context.Context, input string, hash string) error {\n\tlockCtx := g.LockManager.ReadLock(ctx, input)\n\tdefer lockCtx.Cancel()\n\n\treturn g.makeTranscode(lockCtx, hash, g.transcodeCopyVideo(input))\n}\n\nfunc (g Generator) makeTranscode(lockCtx *fsutil.LockContext, hash string, generateFn generateFn) error {\n\toutput := g.ScenePaths.GetTranscodePath(hash)\n\tif !g.Overwrite {\n\t\tif exists, _ := fsutil.FileExists(output); exists {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tif err := g.generateFile(lockCtx, g.ScenePaths, mp4Pattern, output, generateFn); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Debug(\"created transcode: \", output)\n\n\treturn nil\n}\n\nfunc (g Generator) transcode(input string, options TranscodeOptions) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tvar videoArgs ffmpeg.Args\n\t\tif options.Width != 0 && options.Height != 0 {\n\t\t\tvar videoFilter ffmpeg.VideoFilter\n\t\t\tvideoFilter = videoFilter.ScaleDimensions(options.Width, options.Height)\n\t\t\tvideoArgs = videoArgs.VideoFilter(videoFilter)\n\t\t}\n\n\t\tvideoArgs = append(videoArgs,\n\t\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\t\"-profile:v\", \"high\",\n\t\t\t\"-level\", \"4.2\",\n\t\t\t\"-preset\", \"superfast\",\n\t\t\t\"-crf\", \"23\",\n\t\t)\n\n\t\targs := transcoder.Transcode(input, transcoder.TranscodeOptions{\n\t\t\tOutputPath: tmpFn,\n\t\t\tVideoCodec: ffmpeg.VideoCodecLibX264,\n\t\t\tVideoArgs:  videoArgs,\n\t\t\tAudioCodec: ffmpeg.AudioCodecAAC,\n\n\t\t\tExtraInputArgs:  g.FFMpegConfig.GetTranscodeInputArgs(),\n\t\t\tExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(),\n\t\t})\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n\nfunc (g Generator) transcodeVideo(input string, options TranscodeOptions) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\tvar videoArgs ffmpeg.Args\n\t\tif options.Width != 0 && options.Height != 0 {\n\t\t\tvar videoFilter ffmpeg.VideoFilter\n\t\t\tvideoFilter = videoFilter.ScaleDimensions(options.Width, options.Height)\n\t\t\tvideoArgs = videoArgs.VideoFilter(videoFilter)\n\t\t}\n\n\t\tvideoArgs = append(videoArgs,\n\t\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\t\"-profile:v\", \"high\",\n\t\t\t\"-level\", \"4.2\",\n\t\t\t\"-preset\", \"superfast\",\n\t\t\t\"-crf\", \"23\",\n\t\t)\n\n\t\tvar audioArgs ffmpeg.Args\n\t\taudioArgs = audioArgs.SkipAudio()\n\n\t\targs := transcoder.Transcode(input, transcoder.TranscodeOptions{\n\t\t\tOutputPath: tmpFn,\n\t\t\tVideoCodec: ffmpeg.VideoCodecLibX264,\n\t\t\tVideoArgs:  videoArgs,\n\t\t\tAudioArgs:  audioArgs,\n\n\t\t\tExtraInputArgs:  g.FFMpegConfig.GetTranscodeInputArgs(),\n\t\t\tExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(),\n\t\t})\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n\nfunc (g Generator) transcodeAudio(input string) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\t\targs := transcoder.Transcode(input, transcoder.TranscodeOptions{\n\t\t\tOutputPath: tmpFn,\n\t\t\tVideoCodec: ffmpeg.VideoCodecCopy,\n\t\t\tAudioCodec: ffmpeg.AudioCodecAAC,\n\t\t})\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n\nfunc (g Generator) transcodeCopyVideo(input string) generateFn {\n\treturn func(lockCtx *fsutil.LockContext, tmpFn string) error {\n\n\t\tvar audioArgs ffmpeg.Args\n\t\taudioArgs = audioArgs.SkipAudio()\n\n\t\targs := transcoder.Transcode(input, transcoder.TranscodeOptions{\n\t\t\tOutputPath: tmpFn,\n\t\t\tVideoCodec: ffmpeg.VideoCodecCopy,\n\t\t\tAudioArgs:  audioArgs,\n\t\t})\n\n\t\treturn g.generate(lockCtx, args)\n\t}\n}\n"
  },
  {
    "path": "pkg/scene/hash.go",
    "content": "package scene\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// GetHash returns the hash of the file, based on the hash algorithm provided. If\n// hash algorithm is MD5, then Checksum is returned. Otherwise, OSHash is returned.\nfunc GetHash(f models.File, hashAlgorithm models.HashAlgorithm) string {\n\tswitch hashAlgorithm {\n\tcase models.HashAlgorithmMd5:\n\t\treturn f.Base().Fingerprints.GetString(models.FingerprintTypeMD5)\n\tcase models.HashAlgorithmOshash:\n\t\treturn f.Base().Fingerprints.GetString(models.FingerprintTypeOshash)\n\tdefault:\n\t\tpanic(\"unknown hash algorithm\")\n\t}\n}\n"
  },
  {
    "path": "pkg/scene/import.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ImporterReaderWriter interface {\n\tmodels.SceneCreatorUpdater\n\tmodels.ViewHistoryWriter\n\tmodels.OHistoryWriter\n\tmodels.CustomFieldsWriter\n\tFindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error)\n}\n\ntype Importer struct {\n\tReaderWriter        ImporterReaderWriter\n\tFileFinder          models.FileFinder\n\tStudioWriter        models.StudioFinderCreator\n\tGalleryFinder       models.GalleryFinder\n\tPerformerWriter     models.PerformerFinderCreator\n\tGroupWriter         models.GroupFinderCreator\n\tTagWriter           models.TagFinderCreator\n\tInput               jsonschema.Scene\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\tFileNamingAlgorithm models.HashAlgorithm\n\n\tID             int\n\tscene          models.Scene\n\tcustomFields   map[string]interface{}\n\tcoverImageData []byte\n\tviewHistory    []time.Time\n\toHistory       []time.Time\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\ti.scene = i.sceneJSONToScene(i.Input)\n\n\tif err := i.populateFiles(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateStudio(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateGalleries(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populatePerformers(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateTags(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateGroups(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tvar err error\n\tif len(i.Input.Cover) > 0 {\n\t\ti.coverImageData, err = utils.ProcessBase64Image(i.Input.Cover)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid cover image: %v\", err)\n\t\t}\n\t}\n\n\ti.customFields = i.Input.CustomFields\n\n\ti.populateViewHistory()\n\ti.populateOHistory()\n\n\treturn nil\n}\n\nfunc (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene {\n\tnewScene := models.Scene{\n\t\tTitle:        sceneJSON.Title,\n\t\tCode:         sceneJSON.Code,\n\t\tDetails:      sceneJSON.Details,\n\t\tDirector:     sceneJSON.Director,\n\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\tGroups:       models.NewRelatedGroups([]models.GroupsScenes{}),\n\t\tStashIDs:     models.NewRelatedStashIDs(sceneJSON.StashIDs),\n\t}\n\n\tif len(sceneJSON.URLs) > 0 {\n\t\tnewScene.URLs = models.NewRelatedStrings(sceneJSON.URLs)\n\t} else if sceneJSON.URL != \"\" {\n\t\tnewScene.URLs = models.NewRelatedStrings([]string{sceneJSON.URL})\n\t}\n\n\tif sceneJSON.Date != \"\" {\n\t\td, err := models.ParseDate(sceneJSON.Date)\n\t\tif err == nil {\n\t\t\tnewScene.Date = &d\n\t\t}\n\t}\n\tif sceneJSON.Rating != 0 {\n\t\tnewScene.Rating = &sceneJSON.Rating\n\t}\n\n\tnewScene.Organized = sceneJSON.Organized\n\tnewScene.CreatedAt = sceneJSON.CreatedAt.GetTime()\n\tnewScene.UpdatedAt = sceneJSON.UpdatedAt.GetTime()\n\tnewScene.ResumeTime = sceneJSON.ResumeTime\n\tnewScene.PlayDuration = sceneJSON.PlayDuration\n\n\treturn newScene\n}\n\nfunc getHistory(historyJSON []json.JSONTime, count int, last json.JSONTime, createdAt json.JSONTime) []time.Time {\n\tvar ret []time.Time\n\n\tif len(historyJSON) > 0 {\n\t\tfor _, d := range historyJSON {\n\t\t\tret = append(ret, d.GetTime())\n\t\t}\n\t} else if count > 0 {\n\t\tcreatedAt := createdAt.GetTime()\n\t\tfor j := 0; j < count; j++ {\n\t\t\tt := createdAt\n\t\t\tif j+1 == count && !last.IsZero() {\n\t\t\t\t// last one, use last play date\n\t\t\t\tt = last.GetTime()\n\t\t\t}\n\t\t\tret = append(ret, t)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (i *Importer) populateViewHistory() {\n\ti.viewHistory = getHistory(\n\t\ti.Input.PlayHistory,\n\t\ti.Input.PlayCount,\n\t\ti.Input.LastPlayedAt,\n\t\ti.Input.CreatedAt,\n\t)\n}\n\nfunc (i *Importer) populateOHistory() {\n\ti.oHistory = getHistory(\n\t\ti.Input.OHistory,\n\t\ti.Input.OCounter,\n\t\ti.Input.CreatedAt, // no last o count date\n\t\ti.Input.CreatedAt,\n\t)\n}\n\nfunc (i *Importer) populateFiles(ctx context.Context) error {\n\tfiles := make([]*models.VideoFile, 0)\n\n\tfor _, ref := range i.Input.Files {\n\t\tpath := ref\n\t\tf, err := i.FileFinder.FindByPath(ctx, path, true)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding file: %w\", err)\n\t\t}\n\n\t\tif f == nil {\n\t\t\treturn fmt.Errorf(\"scene file '%s' not found\", path)\n\t\t} else {\n\t\t\tfiles = append(files, f.(*models.VideoFile))\n\t\t}\n\t}\n\n\ti.scene.Files = models.NewRelatedVideoFiles(files)\n\n\treturn nil\n}\n\nfunc (i *Importer) populateStudio(ctx context.Context) error {\n\tif i.Input.Studio != \"\" {\n\t\tstudio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding studio by name: %v\", err)\n\t\t}\n\n\t\tif studio == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"scene studio '%s' not found\", i.Input.Studio)\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tstudioID, err := i.createStudio(ctx, i.Input.Studio)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\ti.scene.StudioID = &studioID\n\t\t\t}\n\t\t} else {\n\t\t\ti.scene.StudioID = &studio.ID\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createStudio(ctx context.Context, name string) (int, error) {\n\tnewStudio := models.NewCreateStudioInput()\n\tnewStudio.Name = name\n\n\terr := i.StudioWriter.Create(ctx, &newStudio)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn newStudio.ID, nil\n}\n\nfunc (i *Importer) locateGallery(ctx context.Context, ref jsonschema.GalleryRef) (*models.Gallery, error) {\n\tvar galleries []*models.Gallery\n\tvar err error\n\tswitch {\n\tcase ref.FolderPath != \"\":\n\t\tgalleries, err = i.GalleryFinder.FindByPath(ctx, ref.FolderPath)\n\tcase len(ref.ZipFiles) > 0:\n\t\tfor _, p := range ref.ZipFiles {\n\t\t\tgalleries, err = i.GalleryFinder.FindByPath(ctx, p)\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif len(galleries) > 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\tcase ref.Title != \"\":\n\t\tgalleries, err = i.GalleryFinder.FindUserGalleryByTitle(ctx, ref.Title)\n\t}\n\n\tvar ret *models.Gallery\n\tif len(galleries) > 0 {\n\t\tret = galleries[0]\n\t}\n\n\treturn ret, err\n}\n\nfunc (i *Importer) populateGalleries(ctx context.Context) error {\n\tfor _, ref := range i.Input.Galleries {\n\t\tgallery, err := i.locateGallery(ctx, ref)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif gallery == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"scene gallery '%s' not found\", ref.String())\n\t\t\t}\n\n\t\t\t// we don't create galleries - just ignore\n\t\t} else {\n\t\t\ti.scene.GalleryIDs.Add(gallery.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) populatePerformers(ctx context.Context) error {\n\tif len(i.Input.Performers) > 0 {\n\t\tnames := i.Input.Performers\n\t\tperformers, err := i.PerformerWriter.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar pluckedNames []string\n\t\tfor _, performer := range performers {\n\t\t\tif performer.Name == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpluckedNames = append(pluckedNames, performer.Name)\n\t\t}\n\n\t\tmissingPerformers := sliceutil.Filter(names, func(name string) bool {\n\t\t\treturn !slices.Contains(pluckedNames, name)\n\t\t})\n\n\t\tif len(missingPerformers) > 0 {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn fmt.Errorf(\"scene performers [%s] not found\", strings.Join(missingPerformers, \", \"))\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tcreatedPerformers, err := i.createPerformers(ctx, missingPerformers)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error creating scene performers: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tperformers = append(performers, createdPerformers...)\n\t\t\t}\n\n\t\t\t// ignore if MissingRefBehaviour set to Ignore\n\t\t}\n\n\t\tfor _, p := range performers {\n\t\t\ti.scene.PerformerIDs.Add(p.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createPerformers(ctx context.Context, names []string) ([]*models.Performer, error) {\n\tvar ret []*models.Performer\n\tfor _, name := range names {\n\t\tnewPerformer := models.NewPerformer()\n\t\tnewPerformer.Name = name\n\n\t\terr := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{\n\t\t\tPerformer: &newPerformer,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newPerformer)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (i *Importer) populateGroups(ctx context.Context) error {\n\tif len(i.Input.Groups) > 0 {\n\t\tfor _, inputGroup := range i.Input.Groups {\n\t\t\tgroup, err := i.GroupWriter.FindByName(ctx, inputGroup.GroupName, false)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error finding scene group: %v\", err)\n\t\t\t}\n\n\t\t\tvar groupID int\n\t\t\tif group == nil {\n\t\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\t\treturn fmt.Errorf(\"scene group [%s] not found\", inputGroup.GroupName)\n\t\t\t\t}\n\n\t\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\t\tgroupID, err = i.createGroup(ctx, inputGroup.GroupName)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"error creating scene group: %v\", err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// ignore if MissingRefBehaviour set to Ignore\n\t\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tgroupID = group.ID\n\t\t\t}\n\n\t\t\ttoAdd := models.GroupsScenes{\n\t\t\t\tGroupID: groupID,\n\t\t\t}\n\n\t\t\tif inputGroup.SceneIndex != 0 {\n\t\t\t\tindex := inputGroup.SceneIndex\n\t\t\t\ttoAdd.SceneIndex = &index\n\t\t\t}\n\n\t\t\ti.scene.Groups.Add(toAdd)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createGroup(ctx context.Context, name string) (int, error) {\n\tnewGroup := models.NewGroup()\n\tnewGroup.Name = name\n\n\terr := i.GroupWriter.Create(ctx, &newGroup)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn newGroup.ID, nil\n}\n\nfunc (i *Importer) populateTags(ctx context.Context) error {\n\tif len(i.Input.Tags) > 0 {\n\n\t\ttags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, p := range tags {\n\t\t\ti.scene.TagIDs.Add(p.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) addViewHistory(ctx context.Context) error {\n\tif len(i.viewHistory) > 0 {\n\t\t_, err := i.ReaderWriter.AddViews(ctx, i.ID, i.viewHistory)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error adding view date: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) addOHistory(ctx context.Context) error {\n\tif len(i.oHistory) > 0 {\n\t\t_, err := i.ReaderWriter.AddO(ctx, i.ID, i.oHistory)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error adding o date: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\tif len(i.coverImageData) > 0 {\n\t\tif err := i.ReaderWriter.UpdateCover(ctx, id, i.coverImageData); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting scene images: %v\", err)\n\t\t}\n\t}\n\n\t// add histories\n\tif err := i.addViewHistory(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.addOHistory(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif len(i.customFields) > 0 {\n\t\tif err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{\n\t\t\tFull: i.customFields,\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting scene custom fields: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\tif i.Input.Title != \"\" {\n\t\treturn i.Input.Title\n\t}\n\n\tif len(i.Input.Files) > 0 {\n\t\treturn i.Input.Files[0]\n\t}\n\n\treturn \"\"\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\tvar existing []*models.Scene\n\tvar err error\n\n\tfor _, f := range i.scene.Files.List() {\n\t\texisting, err = i.ReaderWriter.FindByFileID(ctx, f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(existing) > 0 {\n\t\t\tid := existing[0].ID\n\t\t\treturn &id, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\tvar fileIDs []models.FileID\n\tfor _, f := range i.scene.Files.List() {\n\t\tfileIDs = append(fileIDs, f.Base().ID)\n\t}\n\tif err := i.ReaderWriter.Create(ctx, &i.scene, fileIDs); err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating scene: %v\", err)\n\t}\n\n\tid := i.scene.ID\n\ti.ID = id\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\tscene := i.scene\n\tscene.ID = id\n\ti.ID = id\n\tif err := i.ReaderWriter.Update(ctx, &scene); err != nil {\n\t\treturn fmt.Errorf(\"error updating existing scene: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {\n\ttags, err := tagWriter.FindByNames(ctx, names, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar pluckedNames []string\n\tfor _, tag := range tags {\n\t\tpluckedNames = append(pluckedNames, tag.Name)\n\t}\n\n\tmissingTags := sliceutil.Filter(names, func(name string) bool {\n\t\treturn !slices.Contains(pluckedNames, name)\n\t})\n\n\tif len(missingTags) > 0 {\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\treturn nil, fmt.Errorf(\"tags [%s] not found\", strings.Join(missingTags, \", \"))\n\t\t}\n\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\tcreatedTags, err := createTags(ctx, tagWriter, missingTags)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error creating tags: %v\", err)\n\t\t\t}\n\n\t\t\ttags = append(tags, createdTags...)\n\t\t}\n\n\t\t// ignore if MissingRefBehaviour set to Ignore\n\t}\n\n\treturn tags, nil\n}\n\nfunc createTags(ctx context.Context, tagWriter models.TagCreator, names []string) ([]*models.Tag, error) {\n\tvar ret []*models.Tag\n\tfor _, name := range names {\n\t\tnewTag := models.NewTag()\n\t\tnewTag.Name = name\n\n\t\terr := tagWriter.Create(ctx, &models.CreateTagInput{\n\t\t\tTag: &newTag,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newTag)\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/scene/import_test.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nconst invalidImage = \"aW1hZ2VCeXRlcw&&\"\n\nvar (\n\texistingStudioID    = 101\n\texistingPerformerID = 103\n\texistingGroupID     = 104\n\texistingTagID       = 105\n\n\texistingStudioName = \"existingStudioName\"\n\texistingStudioErr  = \"existingStudioErr\"\n\tmissingStudioName  = \"missingStudioName\"\n\n\texistingPerformerName = \"existingPerformerName\"\n\texistingPerformerErr  = \"existingPerformerErr\"\n\tmissingPerformerName  = \"missingPerformerName\"\n\n\texistingGroupName = \"existingGroupName\"\n\texistingGroupErr  = \"existingGroupErr\"\n\tmissingGroupName  = \"missingGroupName\"\n\n\texistingTagName = \"existingTagName\"\n\texistingTagErr  = \"existingTagErr\"\n\tmissingTagName  = \"missingTagName\"\n)\n\nvar testCtx = context.Background()\n\nfunc TestImporterPreImport(t *testing.T) {\n\tvar (\n\t\ttitle     = \"title\"\n\t\tcode      = \"code\"\n\t\tdetails   = \"details\"\n\t\tdirector  = \"director\"\n\t\tendpoint1 = \"endpoint1\"\n\t\tstashID1  = \"stashID1\"\n\t\tendpoint2 = \"endpoint2\"\n\t\tstashID2  = \"stashID2\"\n\t\turl1      = \"url1\"\n\t\turl2      = \"url2\"\n\t\trating    = 3\n\t\torganized = true\n\n\t\tcreatedAt = time.Now().Add(-time.Hour)\n\t\tupdatedAt = time.Now().Add(-time.Minute)\n\n\t\tresumeTime   = 1.234\n\t\tplayDuration = 2.345\n\t)\n\ttests := []struct {\n\t\tname   string\n\t\tinput  jsonschema.Scene\n\t\toutput models.Scene\n\t}{\n\t\t{\n\t\t\t\"basic\",\n\t\t\tjsonschema.Scene{\n\t\t\t\tTitle:    title,\n\t\t\t\tCode:     code,\n\t\t\t\tDetails:  details,\n\t\t\t\tDirector: director,\n\t\t\t\tStashIDs: []models.StashID{\n\t\t\t\t\t{Endpoint: endpoint1, StashID: stashID1},\n\t\t\t\t\t{Endpoint: endpoint2, StashID: stashID2},\n\t\t\t\t},\n\t\t\t\tURLs:         []string{url1, url2},\n\t\t\t\tRating:       rating,\n\t\t\t\tOrganized:    organized,\n\t\t\t\tCreatedAt:    json.JSONTime{Time: createdAt},\n\t\t\t\tUpdatedAt:    json.JSONTime{Time: updatedAt},\n\t\t\t\tResumeTime:   resumeTime,\n\t\t\t\tPlayDuration: playDuration,\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tTitle:    title,\n\t\t\t\tCode:     code,\n\t\t\t\tDetails:  details,\n\t\t\t\tDirector: director,\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t{Endpoint: endpoint1, StashID: stashID1},\n\t\t\t\t\t{Endpoint: endpoint2, StashID: stashID2},\n\t\t\t\t}),\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url1, url2}),\n\t\t\t\tRating:       &rating,\n\t\t\t\tOrganized:    organized,\n\t\t\t\tCreatedAt:    createdAt.Truncate(0),\n\t\t\t\tUpdatedAt:    updatedAt.Truncate(0),\n\t\t\t\tResumeTime:   resumeTime,\n\t\t\t\tPlayDuration: playDuration,\n\n\t\t\t\tFiles:        models.NewRelatedVideoFiles([]*models.VideoFile{}),\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tGroups:       models.NewRelatedGroups([]models.GroupsScenes{}),\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ti := Importer{\n\t\t\t\tInput: tt.input,\n\t\t\t}\n\n\t\t\tif err := i.PreImport(testCtx); err != nil {\n\t\t\t\tt.Errorf(\"PreImport() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(t, tt.output, i.scene)\n\t\t})\n\t}\n}\n\nfunc truncateTimes(t []time.Time) []time.Time {\n\treturn sliceutil.Map(t, func(t time.Time) time.Time { return t.Truncate(0) })\n}\n\nfunc TestImporterPreImportHistory(t *testing.T) {\n\tvar (\n\t\tplayTime1 = time.Now().Add(-time.Hour * 2)\n\t\tplayTime2 = time.Now().Add(-time.Minute * 2)\n\t\toTime1    = time.Now().Add(-time.Hour * 3)\n\t\toTime2    = time.Now().Add(-time.Minute * 3)\n\t)\n\ttests := []struct {\n\t\tname                string\n\t\tinput               jsonschema.Scene\n\t\texpectedPlayHistory []time.Time\n\t\texpectedOHistory    []time.Time\n\t}{\n\t\t{\n\t\t\t\"basic\",\n\t\t\tjsonschema.Scene{\n\t\t\t\tPlayHistory: []json.JSONTime{\n\t\t\t\t\t{Time: playTime1},\n\t\t\t\t\t{Time: playTime2},\n\t\t\t\t},\n\t\t\t\tOHistory: []json.JSONTime{\n\t\t\t\t\t{Time: oTime1},\n\t\t\t\t\t{Time: oTime2},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]time.Time{playTime1, playTime2},\n\t\t\t[]time.Time{oTime1, oTime2},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ti := Importer{\n\t\t\t\tInput: tt.input,\n\t\t\t}\n\n\t\t\tif err := i.PreImport(testCtx); err != nil {\n\t\t\t\tt.Errorf(\"PreImport() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// convert histories to unix timestamps for comparison\n\t\t\teph := truncateTimes(tt.expectedPlayHistory)\n\t\t\tvh := truncateTimes(i.viewHistory)\n\n\t\t\teoh := truncateTimes(tt.expectedOHistory)\n\t\t\toh := truncateTimes(i.oHistory)\n\n\t\t\tassert.Equal(t, eph, vh, \"view history mismatch\")\n\t\t\tassert.Equal(t, eoh, oh, \"o history mismatch\")\n\t\t})\n\t}\n}\n\nfunc TestImporterPreImportCoverImage(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Scene{\n\t\t\tCover: invalidImage,\n\t\t},\n\t}\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.Input.Cover = imageBase64\n\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n}\n\nfunc TestImporterPreImportWithStudio(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Scene{\n\t\t\tStudio: existingStudioName,\n\t\t},\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioName, false).Return(&models.Studio{\n\t\tID: existingStudioID,\n\t}, nil).Once()\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioErr, false).Return(nil, errors.New(\"FindByName error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.scene.StudioID)\n\n\ti.Input.Studio = existingStudioErr\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingStudio(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Scene{\n\t\t\tStudio: missingStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingStudioName, false).Return(nil, nil).Times(3)\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Run(func(args mock.Arguments) {\n\t\ts := args.Get(1).(*models.CreateStudioInput)\n\t\ts.Studio.ID = existingStudioID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.scene.StudioID)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tStudioWriter: db.Studio,\n\t\tInput: jsonschema.Scene{\n\t\t\tStudio: missingStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingStudioName, false).Return(nil, nil).Once()\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithPerformer(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter:     db.Performer,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Scene{\n\t\t\tPerformers: []string{\n\t\t\t\texistingPerformerName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{\n\t\t{\n\t\t\tID:   existingPerformerID,\n\t\t\tName: existingPerformerName,\n\t\t},\n\t}, nil).Once()\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingPerformerID}, i.scene.PerformerIDs.List())\n\n\ti.Input.Performers = []string{existingPerformerErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingPerformer(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter: db.Performer,\n\t\tInput: jsonschema.Scene{\n\t\t\tPerformers: []string{\n\t\t\t\tmissingPerformerName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3)\n\tdb.Performer.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreatePerformerInput\")).Run(func(args mock.Arguments) {\n\t\tp := args.Get(1).(*models.CreatePerformerInput)\n\t\tp.ID = existingPerformerID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingPerformerID}, i.scene.PerformerIDs.List())\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tPerformerWriter: db.Performer,\n\t\tInput: jsonschema.Scene{\n\t\t\tPerformers: []string{\n\t\t\t\tmissingPerformerName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Performer.On(\"FindByNames\", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once()\n\tdb.Performer.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreatePerformerInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithGroup(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tGroupWriter:         db.Group,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Scene{\n\t\t\tGroups: []jsonschema.SceneGroup{\n\t\t\t\t{\n\t\t\t\t\tGroupName:  existingGroupName,\n\t\t\t\t\tSceneIndex: 1,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Group.On(\"FindByName\", testCtx, existingGroupName, false).Return(&models.Group{\n\t\tID:   existingGroupID,\n\t\tName: existingGroupName,\n\t}, nil).Once()\n\tdb.Group.On(\"FindByName\", testCtx, existingGroupErr, false).Return(nil, errors.New(\"FindByName error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingGroupID, i.scene.Groups.List()[0].GroupID)\n\n\ti.Input.Groups[0].GroupName = existingGroupErr\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingGroup(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tGroupWriter: db.Group,\n\t\tInput: jsonschema.Scene{\n\t\t\tGroups: []jsonschema.SceneGroup{\n\t\t\t\t{\n\t\t\t\t\tGroupName: missingGroupName,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Group.On(\"FindByName\", testCtx, missingGroupName, false).Return(nil, nil).Times(3)\n\tdb.Group.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.Group\")).Run(func(args mock.Arguments) {\n\t\tm := args.Get(1).(*models.Group)\n\t\tm.ID = existingGroupID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingGroupID, i.scene.Groups.List()[0].GroupID)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingGroupCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tGroupWriter: db.Group,\n\t\tInput: jsonschema.Scene{\n\t\t\tGroups: []jsonschema.SceneGroup{\n\t\t\t\t{\n\t\t\t\t\tGroupName: missingGroupName,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Group.On(\"FindByName\", testCtx, missingGroupName, false).Return(nil, nil).Once()\n\tdb.Group.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.Group\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter:           db.Tag,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Scene{\n\t\t\tTags: []string{\n\t\t\t\texistingTagName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagName}, false).Return([]*models.Tag{\n\t\t{\n\t\t\tID:   existingTagID,\n\t\t\tName: existingTagName,\n\t\t},\n\t}, nil).Once()\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingTagID}, i.scene.TagIDs.List())\n\n\ti.Input.Tags = []string{existingTagErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter: db.Tag,\n\t\tInput: jsonschema.Scene{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Run(func(args mock.Arguments) {\n\t\tt := args.Get(1).(*models.CreateTagInput)\n\t\tt.Tag.ID = existingTagID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, []int{existingTagID}, i.scene.TagIDs.List())\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tTagWriter: db.Tag,\n\t\tInput: jsonschema.Scene{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPostImport(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tvt := time.Now()\n\tot := vt.Add(time.Minute)\n\n\tvar (\n\t\tokID              = 1\n\t\terrViewHistoryID  = 2\n\t\terrOHistoryID     = 3\n\t\terrImageID        = 4\n\t\terrCustomFieldsID = 5\n\t)\n\n\tvar (\n\t\terrImage        = errors.New(\"error updating cover image\")\n\t\terrViewHistory  = errors.New(\"error updating view history\")\n\t\terrOHistory     = errors.New(\"error updating o history\")\n\t\terrCustomFields = errors.New(\"error updating custom fields\")\n\t)\n\n\ttable := []struct {\n\t\tname     string\n\t\timporter Importer\n\t\terr      bool\n\t}{\n\t\t{\n\t\t\tname: \"all set successfully\",\n\t\t\timporter: Importer{\n\t\t\t\tID:             okID,\n\t\t\t\tcoverImageData: []byte(imageBase64),\n\t\t\t\tviewHistory:    []time.Time{vt},\n\t\t\t\toHistory:       []time.Time{ot},\n\t\t\t\tcustomFields:   customFields,\n\t\t\t},\n\t\t\terr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"cover image set with error\",\n\t\t\timporter: Importer{\n\t\t\t\tID:             errImageID,\n\t\t\t\tcoverImageData: []byte(invalidImage),\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"view history set with error\",\n\t\t\timporter: Importer{\n\t\t\t\tID:          errViewHistoryID,\n\t\t\t\tviewHistory: []time.Time{vt},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"o history set with error\",\n\t\t\timporter: Importer{\n\t\t\t\tID:       errOHistoryID,\n\t\t\t\toHistory: []time.Time{ot},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"custom fields set with error\",\n\t\t\timporter: Importer{\n\t\t\t\tID:           errCustomFieldsID,\n\t\t\t\tcustomFields: customFields,\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t}\n\n\tdb.Scene.On(\"UpdateCover\", testCtx, okID, []byte(imageBase64)).Return(nil).Once()\n\tdb.Scene.On(\"UpdateCover\", testCtx, errImageID, []byte(invalidImage)).Return(errImage).Once()\n\tdb.Scene.On(\"AddViews\", testCtx, okID, []time.Time{vt}).Return([]time.Time{vt}, nil).Once()\n\tdb.Scene.On(\"AddViews\", testCtx, errViewHistoryID, []time.Time{vt}).Return(nil, errViewHistory).Once()\n\tdb.Scene.On(\"AddO\", testCtx, okID, []time.Time{ot}).Return([]time.Time{ot}, nil).Once()\n\tdb.Scene.On(\"AddO\", testCtx, errOHistoryID, []time.Time{ot}).Return(nil, errOHistory).Once()\n\tdb.Scene.On(\"SetCustomFields\", testCtx, okID, models.CustomFieldsInput{\n\t\tFull: customFields,\n\t}).Return(nil).Once()\n\tdb.Scene.On(\"SetCustomFields\", testCtx, errCustomFieldsID, models.CustomFieldsInput{\n\t\tFull: customFields,\n\t}).Return(errCustomFields).Once()\n\n\tfor _, tt := range table {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ti := tt.importer\n\t\t\ti.ReaderWriter = db.Scene\n\n\t\t\terr := i.PostImport(testCtx, i.ID)\n\n\t\t\tif tt.err {\n\t\t\t\tassert.NotNil(t, err, \"expected error but got nil\")\n\t\t\t} else {\n\t\t\t\tassert.Nil(t, err, \"unexpected error: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/scene/marker_import.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n)\n\ntype MarkerCreatorUpdater interface {\n\tmodels.SceneMarkerCreatorUpdater\n\tFindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error)\n}\n\ntype MarkerImporter struct {\n\tSceneID             int\n\tReaderWriter        MarkerCreatorUpdater\n\tTagWriter           models.TagFinderCreator\n\tInput               jsonschema.SceneMarker\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\ttags   []*models.Tag\n\tmarker models.SceneMarker\n}\n\nfunc (i *MarkerImporter) PreImport(ctx context.Context) error {\n\tseconds, _ := strconv.ParseFloat(i.Input.Seconds, 64)\n\n\tvar endSeconds *float64\n\tif i.Input.EndSeconds != \"\" {\n\t\tparsedEndSeconds, _ := strconv.ParseFloat(i.Input.EndSeconds, 64)\n\t\tendSeconds = &parsedEndSeconds\n\t}\n\n\ti.marker = models.SceneMarker{\n\t\tTitle:      i.Input.Title,\n\t\tSeconds:    seconds,\n\t\tEndSeconds: endSeconds,\n\t\tSceneID:    i.SceneID,\n\t\tCreatedAt:  i.Input.CreatedAt.GetTime(),\n\t\tUpdatedAt:  i.Input.UpdatedAt.GetTime(),\n\t}\n\n\tif err := i.populateTags(ctx); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (i *MarkerImporter) populateTags(ctx context.Context) error {\n\t// primary tag cannot be ignored\n\tmrb := i.MissingRefBehaviour\n\tif mrb == models.ImportMissingRefEnumIgnore {\n\t\tmrb = models.ImportMissingRefEnumFail\n\t}\n\n\tprimaryTag, err := importTags(ctx, i.TagWriter, []string{i.Input.PrimaryTag}, mrb)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ti.marker.PrimaryTagID = primaryTag[0].ID\n\n\tif len(i.Input.Tags) > 0 {\n\t\ttags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ti.tags = tags\n\t}\n\n\treturn nil\n}\n\nfunc (i *MarkerImporter) PostImport(ctx context.Context, id int) error {\n\tif len(i.tags) > 0 {\n\t\tvar tagIDs []int\n\t\tfor _, t := range i.tags {\n\t\t\ttagIDs = append(tagIDs, t.ID)\n\t\t}\n\t\tif err := i.ReaderWriter.UpdateTags(ctx, id, tagIDs); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to associate tags: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *MarkerImporter) Name() string {\n\treturn fmt.Sprintf(\"%s (%s)\", i.Input.Title, i.Input.Seconds)\n}\n\nfunc (i *MarkerImporter) FindExistingID(ctx context.Context) (*int, error) {\n\texistingMarkers, err := i.ReaderWriter.FindBySceneID(ctx, i.SceneID)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, m := range existingMarkers {\n\t\tif m.Seconds == i.marker.Seconds {\n\t\t\tid := m.ID\n\t\t\treturn &id, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *MarkerImporter) Create(ctx context.Context) (*int, error) {\n\terr := i.ReaderWriter.Create(ctx, &i.marker)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating marker: %v\", err)\n\t}\n\n\tid := i.marker.ID\n\treturn &id, nil\n}\n\nfunc (i *MarkerImporter) Update(ctx context.Context, id int) error {\n\tmarker := i.marker\n\tmarker.ID = id\n\terr := i.ReaderWriter.Update(ctx, &marker)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating existing marker: %v\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/scene/marker_import_test.go",
    "content": "package scene\n\n// import (\n// \t\"context\"\n// \t\"errors\"\n// \t\"testing\"\n\n// \t\"github.com/stashapp/stash/pkg/models\"\n// \t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n// \t\"github.com/stashapp/stash/pkg/models/mocks\"\n// \t\"github.com/stretchr/testify/assert\"\n// \t\"github.com/stretchr/testify/mock\"\n// )\n\n// const (\n// \tseconds      = \"5\"\n// \tsecondsFloat = 5.0\n// \terrSceneID   = 999\n// )\n\n// func TestMarkerImporterName(t *testing.T) {\n// \ti := MarkerImporter{\n// \t\tInput: jsonschema.SceneMarker{\n// \t\t\tTitle:   title,\n// \t\t\tSeconds: seconds,\n// \t\t},\n// \t}\n\n// \tassert.Equal(t, title+\" (5)\", i.Name())\n// }\n\n// func TestMarkerImporterPreImportWithTag(t *testing.T) {\n// \ttagReaderWriter := &mocks.TagReaderWriter{}\n// \tctx := context.Background()\n\n// \ti := MarkerImporter{\n// \t\tTagWriter:           tagReaderWriter,\n// \t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n// \t\tInput: jsonschema.SceneMarker{\n// \t\t\tPrimaryTag: existingTagName,\n// \t\t},\n// \t}\n\n// \ttagReaderWriter.On(\"FindByNames\", ctx, []string{existingTagName}, false).Return([]*models.Tag{\n// \t\t{\n// \t\t\tID:   existingTagID,\n// \t\t\tName: existingTagName,\n// \t\t},\n// \t}, nil).Times(4)\n// \ttagReaderWriter.On(\"FindByNames\", ctx, []string{existingTagErr}, false).Return(nil, errors.New(\"FindByNames error\")).Times(2)\n\n// \terr := i.PreImport(ctx)\n// \tassert.Nil(t, err)\n// \tassert.Equal(t, existingTagID, i.marker.PrimaryTagID)\n\n// \ti.Input.PrimaryTag = existingTagErr\n// \terr = i.PreImport(ctx)\n// \tassert.NotNil(t, err)\n\n// \ti.Input.PrimaryTag = existingTagName\n// \ti.Input.Tags = []string{\n// \t\texistingTagName,\n// \t}\n// \terr = i.PreImport(ctx)\n// \tassert.Nil(t, err)\n// \tassert.Equal(t, existingTagID, i.tags[0].ID)\n\n// \ti.Input.Tags[0] = existingTagErr\n// \terr = i.PreImport(ctx)\n// \tassert.NotNil(t, err)\n\n// \ttagReaderWriter.AssertExpectations(t)\n// }\n\n// func TestMarkerImporterPostImportUpdateTags(t *testing.T) {\n// \tsceneMarkerReaderWriter := &mocks.SceneMarkerReaderWriter{}\n// \tctx := context.Background()\n\n// \ti := MarkerImporter{\n// \t\tReaderWriter: sceneMarkerReaderWriter,\n// \t\ttags: []*models.Tag{\n// \t\t\t{\n// \t\t\t\tID: existingTagID,\n// \t\t\t},\n// \t\t},\n// \t}\n\n// \tupdateErr := errors.New(\"UpdateTags error\")\n\n// \tsceneMarkerReaderWriter.On(\"UpdateTags\", ctx, sceneID, []int{existingTagID}).Return(nil).Once()\n// \tsceneMarkerReaderWriter.On(\"UpdateTags\", ctx, errTagsID, mock.AnythingOfType(\"[]int\")).Return(updateErr).Once()\n\n// \terr := i.PostImport(ctx, sceneID)\n// \tassert.Nil(t, err)\n\n// \terr = i.PostImport(ctx, errTagsID)\n// \tassert.NotNil(t, err)\n\n// \tsceneMarkerReaderWriter.AssertExpectations(t)\n// }\n\n// func TestMarkerImporterFindExistingID(t *testing.T) {\n// \treaderWriter := &mocks.SceneMarkerReaderWriter{}\n// \tctx := context.Background()\n\n// \ti := MarkerImporter{\n// \t\tReaderWriter: readerWriter,\n// \t\tSceneID:      sceneID,\n// \t\tmarker: models.SceneMarker{\n// \t\t\tSeconds: secondsFloat,\n// \t\t},\n// \t}\n\n// \texpectedErr := errors.New(\"FindBy* error\")\n// \treaderWriter.On(\"FindBySceneID\", ctx, sceneID).Return([]*models.SceneMarker{\n// \t\t{\n// \t\t\tID:      existingSceneID,\n// \t\t\tSeconds: secondsFloat,\n// \t\t},\n// \t}, nil).Times(2)\n// \treaderWriter.On(\"FindBySceneID\", ctx, errSceneID).Return(nil, expectedErr).Once()\n\n// \tid, err := i.FindExistingID(ctx)\n// \tassert.Equal(t, existingSceneID, *id)\n// \tassert.Nil(t, err)\n\n// \ti.marker.Seconds++\n// \tid, err = i.FindExistingID(ctx)\n// \tassert.Nil(t, id)\n// \tassert.Nil(t, err)\n\n// \ti.SceneID = errSceneID\n// \tid, err = i.FindExistingID(ctx)\n// \tassert.Nil(t, id)\n// \tassert.NotNil(t, err)\n\n// \treaderWriter.AssertExpectations(t)\n// }\n\n// func TestMarkerImporterCreate(t *testing.T) {\n// \treaderWriter := &mocks.SceneMarkerReaderWriter{}\n// \tctx := context.Background()\n\n// \tscene := models.SceneMarker{\n// \t\tTitle: title,\n// \t}\n\n// \tsceneErr := models.SceneMarker{\n// \t\tTitle: sceneNameErr,\n// \t}\n\n// \ti := MarkerImporter{\n// \t\tReaderWriter: readerWriter,\n// \t\tmarker:       scene,\n// \t}\n\n// \terrCreate := errors.New(\"Create error\")\n// \treaderWriter.On(\"Create\", ctx, scene).Return(&models.SceneMarker{\n// \t\tID: sceneID,\n// \t}, nil).Once()\n// \treaderWriter.On(\"Create\", ctx, sceneErr).Return(nil, errCreate).Once()\n\n// \tid, err := i.Create(ctx)\n// \tassert.Equal(t, sceneID, *id)\n// \tassert.Nil(t, err)\n\n// \ti.marker = sceneErr\n// \tid, err = i.Create(ctx)\n// \tassert.Nil(t, id)\n// \tassert.NotNil(t, err)\n\n// \treaderWriter.AssertExpectations(t)\n// }\n\n// func TestMarkerImporterUpdate(t *testing.T) {\n// \treaderWriter := &mocks.SceneMarkerReaderWriter{}\n// \tctx := context.Background()\n\n// \tscene := models.SceneMarker{\n// \t\tTitle: title,\n// \t}\n\n// \tsceneErr := models.SceneMarker{\n// \t\tTitle: sceneNameErr,\n// \t}\n\n// \ti := MarkerImporter{\n// \t\tReaderWriter: readerWriter,\n// \t\tmarker:       scene,\n// \t}\n\n// \terrUpdate := errors.New(\"Update error\")\n\n// \t// id needs to be set for the mock input\n// \tscene.ID = sceneID\n// \treaderWriter.On(\"Update\", ctx, scene).Return(nil, nil).Once()\n\n// \terr := i.Update(ctx, sceneID)\n// \tassert.Nil(t, err)\n\n// \ti.marker = sceneErr\n\n// \t// need to set id separately\n// \tsceneErr.ID = errImageID\n// \treaderWriter.On(\"Update\", ctx, sceneErr).Return(nil, errUpdate).Once()\n\n// \terr = i.Update(ctx, errImageID)\n// \tassert.NotNil(t, err)\n\n// \treaderWriter.AssertExpectations(t)\n// }\n"
  },
  {
    "path": "pkg/scene/marker_query.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc MarkerCountByTagID(ctx context.Context, r models.SceneMarkerQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.SceneMarkerFilterType{\n\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n"
  },
  {
    "path": "pkg/scene/merge.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype MergeOptions struct {\n\tScenePartial       models.ScenePartial\n\tIncludePlayHistory bool\n\tIncludeOHistory    bool\n}\n\nfunc (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *FileDeleter, options MergeOptions) error {\n\tscenePartial := options.ScenePartial\n\n\t// ensure source ids are unique\n\tsourceIDs = sliceutil.AppendUniques(nil, sourceIDs)\n\n\t// ensure destination is not in source list\n\tif slices.Contains(sourceIDs, destinationID) {\n\t\treturn errors.New(\"destination scene cannot be in source list\")\n\t}\n\n\tdest, err := s.Repository.Find(ctx, destinationID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding destination scene ID %d: %w\", destinationID, err)\n\t}\n\n\tsources, err := s.Repository.FindMany(ctx, sourceIDs)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding source scenes: %w\", err)\n\t}\n\n\tvar fileIDs []models.FileID\n\n\tfor _, src := range sources {\n\t\tif err := src.LoadRelationships(ctx, s.Repository); err != nil {\n\t\t\treturn fmt.Errorf(\"loading scene relationships from %d: %w\", src.ID, err)\n\t\t}\n\n\t\tfor _, f := range src.Files.List() {\n\t\t\tfileIDs = append(fileIDs, f.Base().ID)\n\t\t}\n\n\t\tif err := s.mergeSceneMarkers(ctx, dest, src); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// move files to destination scene\n\tif len(fileIDs) > 0 {\n\t\tif err := s.Repository.AssignFiles(ctx, destinationID, fileIDs); err != nil {\n\t\t\treturn fmt.Errorf(\"moving files to destination scene: %w\", err)\n\t\t}\n\n\t\t// if scene didn't already have a primary file, then set it now\n\t\tif dest.PrimaryFileID == nil {\n\t\t\tscenePartial.PrimaryFileID = &fileIDs[0]\n\t\t} else {\n\t\t\t// don't allow changing primary file ID from the input values\n\t\t\tscenePartial.PrimaryFileID = nil\n\t\t}\n\t}\n\n\tif _, err := s.Repository.UpdatePartial(ctx, destinationID, scenePartial); err != nil {\n\t\treturn fmt.Errorf(\"updating scene: %w\", err)\n\t}\n\n\t// merge play history\n\tif options.IncludePlayHistory {\n\t\tvar allDates []time.Time\n\t\tfor _, src := range sources {\n\t\t\tthisDates, err := s.Repository.GetViewDates(ctx, src.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting view dates for scene %d: %w\", src.ID, err)\n\t\t\t}\n\n\t\t\tallDates = append(allDates, thisDates...)\n\t\t}\n\n\t\tif len(allDates) > 0 {\n\t\t\tif _, err := s.Repository.AddViews(ctx, destinationID, allDates); err != nil {\n\t\t\t\treturn fmt.Errorf(\"adding view dates to scene %d: %w\", destinationID, err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// merge o history\n\tif options.IncludeOHistory {\n\t\tvar allDates []time.Time\n\t\tfor _, src := range sources {\n\t\t\tthisDates, err := s.Repository.GetODates(ctx, src.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting o dates for scene %d: %w\", src.ID, err)\n\t\t\t}\n\n\t\t\tallDates = append(allDates, thisDates...)\n\t\t}\n\n\t\tif len(allDates) > 0 {\n\t\t\tif _, err := s.Repository.AddO(ctx, destinationID, allDates); err != nil {\n\t\t\t\treturn fmt.Errorf(\"adding o dates to scene %d: %w\", destinationID, err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// delete old scenes\n\tfor _, src := range sources {\n\t\tconst deleteGenerated = true\n\t\tconst deleteFile = false\n\t\tconst destroyFileEntry = false\n\t\tif err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {\n\t\t\treturn fmt.Errorf(\"deleting scene %d: %w\", src.ID, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src *models.Scene) error {\n\tmarkers, err := s.MarkerRepository.FindBySceneID(ctx, src.ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding scene markers: %w\", err)\n\t}\n\n\ttype rename struct {\n\t\tsrc  string\n\t\tdest string\n\t}\n\n\tvar toRename []rename\n\n\tdestHash := dest.GetHash(s.Config.GetVideoFileNamingAlgorithm())\n\n\tfor _, m := range markers {\n\t\tsrcHash := src.GetHash(s.Config.GetVideoFileNamingAlgorithm())\n\n\t\t// updated the scene id\n\t\tm.SceneID = dest.ID\n\n\t\tif err := s.MarkerRepository.Update(ctx, m); err != nil {\n\t\t\treturn fmt.Errorf(\"updating scene marker %d: %w\", m.ID, err)\n\t\t}\n\n\t\t// move generated files to new location\n\t\ttoRename = append(toRename, []rename{\n\t\t\t{\n\t\t\t\tsrc:  s.Paths.SceneMarkers.GetScreenshotPath(srcHash, int(m.Seconds)),\n\t\t\t\tdest: s.Paths.SceneMarkers.GetScreenshotPath(destHash, int(m.Seconds)),\n\t\t\t},\n\t\t\t{\n\t\t\t\tsrc:  s.Paths.SceneMarkers.GetThumbnailPath(srcHash, int(m.Seconds)),\n\t\t\t\tdest: s.Paths.SceneMarkers.GetThumbnailPath(destHash, int(m.Seconds)),\n\t\t\t},\n\t\t\t{\n\t\t\t\tsrc:  s.Paths.SceneMarkers.GetWebpPreviewPath(srcHash, int(m.Seconds)),\n\t\t\t\tdest: s.Paths.SceneMarkers.GetWebpPreviewPath(destHash, int(m.Seconds)),\n\t\t\t},\n\t\t}...)\n\t}\n\n\tif len(toRename) > 0 {\n\t\ttxn.AddPostCommitHook(ctx, func(ctx context.Context) {\n\t\t\t// rename the files if they exist\n\t\t\tfor _, e := range toRename {\n\t\t\t\tsrcExists, _ := fsutil.FileExists(e.src)\n\t\t\t\tdestExists, _ := fsutil.FileExists(e.dest)\n\n\t\t\t\tif srcExists && !destExists {\n\t\t\t\t\tdestDir := filepath.Dir(e.dest)\n\t\t\t\t\tif err := fsutil.EnsureDir(destDir); err != nil {\n\t\t\t\t\t\tlogger.Errorf(\"Error creating generated marker folder %s: %v\", destDir, err)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif err := os.Rename(e.src, e.dest); err != nil {\n\t\t\t\t\t\tlogger.Errorf(\"Error renaming generated marker file from %s to %s: %v\", e.src, e.dest, err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/scene/migrate_hash.go",
    "content": "package scene\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n)\n\nfunc MigrateHash(p *paths.Paths, oldHash string, newHash string) {\n\toldPath := filepath.Join(p.Generated.Markers, oldHash)\n\tnewPath := filepath.Join(p.Generated.Markers, newHash)\n\tmigrateSceneFiles(oldPath, newPath)\n\n\tscenePaths := p.Scene\n\toldPath = scenePaths.GetVideoPreviewPath(oldHash)\n\tnewPath = scenePaths.GetVideoPreviewPath(newHash)\n\tmigrateSceneFiles(oldPath, newPath)\n\n\toldPath = scenePaths.GetWebpPreviewPath(oldHash)\n\tnewPath = scenePaths.GetWebpPreviewPath(newHash)\n\tmigrateSceneFiles(oldPath, newPath)\n\n\toldPath = scenePaths.GetTranscodePath(oldHash)\n\tnewPath = scenePaths.GetTranscodePath(newHash)\n\tmigrateSceneFiles(oldPath, newPath)\n\n\toldVttPath := scenePaths.GetSpriteVttFilePath(oldHash)\n\tnewVttPath := scenePaths.GetSpriteVttFilePath(newHash)\n\tmigrateSceneFiles(oldVttPath, newVttPath)\n\n\toldPath = scenePaths.GetSpriteImageFilePath(oldHash)\n\tnewPath = scenePaths.GetSpriteImageFilePath(newHash)\n\tmigrateSceneFiles(oldPath, newPath)\n\tmigrateVttFile(newVttPath, oldPath, newPath)\n\n\toldPath = scenePaths.GetInteractiveHeatmapPath(oldHash)\n\tnewPath = scenePaths.GetInteractiveHeatmapPath(newHash)\n\tmigrateSceneFiles(oldPath, newPath)\n\n\t// #3986 - migrate scene marker files\n\tmarkerPaths := p.SceneMarkers\n\toldPath = markerPaths.GetFolderPath(oldHash)\n\tnewPath = markerPaths.GetFolderPath(newHash)\n\tmigrateSceneFolder(oldPath, newPath)\n}\n\nfunc migrateSceneFiles(oldName, newName string) {\n\toldExists, err := fsutil.FileExists(oldName)\n\tif err != nil && !os.IsNotExist(err) {\n\t\tlogger.Errorf(\"Error checking existence of %s: %s\", oldName, err.Error())\n\t\treturn\n\t}\n\n\tif oldExists {\n\t\tlogger.Infof(\"renaming %s to %s\", oldName, newName)\n\t\tif err := os.Rename(oldName, newName); err != nil {\n\t\t\tlogger.Errorf(\"error renaming %s to %s: %s\", oldName, newName, err.Error())\n\t\t}\n\t}\n}\n\n// #2481: migrate vtt file contents in addition to renaming\nfunc migrateVttFile(vttPath, oldSpritePath, newSpritePath string) {\n\t// #3356 - don't try to migrate if the file doesn't exist\n\texists, err := fsutil.FileExists(vttPath)\n\tif err != nil && !os.IsNotExist(err) {\n\t\tlogger.Errorf(\"Error checking existence of %s: %s\", vttPath, err.Error())\n\t\treturn\n\t}\n\n\tif !exists {\n\t\treturn\n\t}\n\n\tcontents, err := os.ReadFile(vttPath)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error reading %s for vtt migration: %v\", vttPath, err)\n\t\treturn\n\t}\n\n\toldSpriteBasename := filepath.Base(oldSpritePath)\n\tnewSpriteBasename := filepath.Base(newSpritePath)\n\n\tcontents = bytes.ReplaceAll(contents, []byte(oldSpriteBasename), []byte(newSpriteBasename))\n\n\tif err := os.WriteFile(vttPath, contents, 0644); err != nil {\n\t\tlogger.Errorf(\"Error writing %s for vtt migration: %v\", vttPath, err)\n\t\treturn\n\t}\n}\n\nfunc migrateSceneFolder(oldName, newName string) {\n\toldExists, err := fsutil.DirExists(oldName)\n\tif err != nil && !os.IsNotExist(err) {\n\t\tlogger.Errorf(\"Error checking existence of %s: %s\", oldName, err.Error())\n\t\treturn\n\t}\n\n\tif oldExists {\n\t\tlogger.Infof(\"renaming %s to %s\", oldName, newName)\n\t\tif err := os.Rename(oldName, newName); err != nil {\n\t\t\tlogger.Errorf(\"error renaming %s to %s: %s\", oldName, newName, err.Error())\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/scene/migrate_screenshots.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\ntype MigrateSceneScreenshotsInput struct {\n\tDeleteFiles       bool `json:\"deleteFiles\"`\n\tOverwriteExisting bool `json:\"overwriteExisting\"`\n}\n\ntype HashFinderCoverUpdater interface {\n\tFindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error)\n\tFindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error)\n\tHasCover(ctx context.Context, sceneID int) (bool, error)\n\tUpdateCover(ctx context.Context, sceneID int, cover []byte) error\n}\n\ntype ScreenshotMigrator struct {\n\tOptions      MigrateSceneScreenshotsInput\n\tSceneUpdater HashFinderCoverUpdater\n\tTxnManager   txn.Manager\n}\n\nfunc (m *ScreenshotMigrator) MigrateScreenshots(ctx context.Context, screenshotPath string) error {\n\t// find the scene based on the screenshot path\n\ts, err := m.findScenes(ctx, screenshotPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding scenes for screenshot: %w\", err)\n\t}\n\n\tfor _, scene := range s {\n\t\t// migrate each scene in its own transaction\n\t\tif err := txn.WithTxn(ctx, m.TxnManager, func(ctx context.Context) error {\n\t\t\treturn m.migrateSceneScreenshot(ctx, scene, screenshotPath)\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"migrating screenshot for scene %s: %w\", scene.DisplayName(), err)\n\t\t}\n\t}\n\n\t// if deleteFiles is true, delete the file\n\tif m.Options.DeleteFiles {\n\t\tif err := os.Remove(screenshotPath); err != nil {\n\t\t\t// log and continue\n\t\t\tlogger.Errorf(\"Error deleting screenshot file %s: %v\", screenshotPath, err)\n\t\t} else {\n\t\t\tlogger.Debugf(\"Deleted screenshot file %s\", screenshotPath)\n\t\t}\n\n\t\t// also delete the thumb file\n\t\tthumbPath := strings.TrimSuffix(screenshotPath, \".jpg\") + \".thumb.jpg\"\n\t\t// ignore errors for thumb files\n\t\tif err := os.Remove(thumbPath); err == nil {\n\t\t\tlogger.Debugf(\"Deleted thumb file %s\", thumbPath)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *ScreenshotMigrator) findScenes(ctx context.Context, screenshotPath string) ([]*models.Scene, error) {\n\tbasename := filepath.Base(screenshotPath)\n\text := filepath.Ext(basename)\n\tbasename = basename[:len(basename)-len(ext)]\n\n\t// use the basename to determine the hash type\n\talgo := m.getHashType(basename)\n\n\tif algo == \"\" {\n\t\t// log and return\n\t\treturn nil, fmt.Errorf(\"could not determine hash type\")\n\t}\n\n\t// use the hash type to get the scene\n\tvar ret []*models.Scene\n\terr := txn.WithReadTxn(ctx, m.TxnManager, func(ctx context.Context) error {\n\t\tvar err error\n\n\t\tif algo == models.HashAlgorithmOshash {\n\t\t\t// use oshash\n\t\t\tret, err = m.SceneUpdater.FindByOSHash(ctx, basename)\n\t\t} else {\n\t\t\t// use md5\n\t\t\tret, err = m.SceneUpdater.FindByChecksum(ctx, basename)\n\t\t}\n\n\t\treturn err\n\t})\n\n\treturn ret, err\n}\n\nfunc (m *ScreenshotMigrator) getHashType(basename string) models.HashAlgorithm {\n\t// if the basename is 16 characters long, must be oshash\n\tif len(basename) == 16 {\n\t\treturn models.HashAlgorithmOshash\n\t}\n\n\t// if its 32 characters long, must be md5\n\tif len(basename) == 32 {\n\t\treturn models.HashAlgorithmMd5\n\t}\n\n\t// otherwise, it's undefined\n\treturn \"\"\n}\n\nfunc (m *ScreenshotMigrator) migrateSceneScreenshot(ctx context.Context, scene *models.Scene, screenshotPath string) error {\n\tif !m.Options.OverwriteExisting {\n\t\t// check if the scene has a cover already\n\t\thasCover, err := m.SceneUpdater.HasCover(ctx, scene.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"checking for existing cover: %w\", err)\n\t\t}\n\n\t\tif hasCover {\n\t\t\t// already has cover, just silently return\n\t\t\tlogger.Debugf(\"Scene %s already has a screenshot, skipping\", scene.DisplayName())\n\t\t\treturn nil\n\t\t}\n\t}\n\n\t// get the data from the file\n\tdata, err := os.ReadFile(screenshotPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading screenshot file: %w\", err)\n\t}\n\n\tif err := m.SceneUpdater.UpdateCover(ctx, scene.ID, data); err != nil {\n\t\treturn fmt.Errorf(\"updating scene screenshot: %w\", err)\n\t}\n\n\tlogger.Infof(\"Updated screenshot for scene %s from %s\", scene.DisplayName(), filepath.Base(screenshotPath))\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/scene/query.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/job\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// QueryOptions returns a SceneQueryOptions populated with the provided filters.\nfunc QueryOptions(sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType, count bool) models.SceneQueryOptions {\n\treturn models.SceneQueryOptions{\n\t\tQueryOptions: models.QueryOptions{\n\t\t\tFindFilter: findFilter,\n\t\t\tCount:      count,\n\t\t},\n\t\tSceneFilter: sceneFilter,\n\t}\n}\n\n// QueryWithCount queries for scenes, returning the scene objects and the total count.\nfunc QueryWithCount(ctx context.Context, qb models.SceneQueryer, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, int, error) {\n\t// this was moved from the queryBuilder code\n\t// left here so that calling functions can reference this instead\n\tresult, err := qb.Query(ctx, QueryOptions(sceneFilter, findFilter, true))\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tscenes, err := result.Resolve(ctx)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn scenes, result.Count, nil\n}\n\n// Query queries for scenes using the provided filters.\nfunc Query(ctx context.Context, qb models.SceneQueryer, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, error) {\n\tresult, err := qb.Query(ctx, QueryOptions(sceneFilter, findFilter, false))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tscenes, err := result.Resolve(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn scenes, nil\n}\n\nfunc BatchProcess(ctx context.Context, reader models.SceneQueryer, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType, fn func(scene *models.Scene) error) error {\n\tconst batchSize = 1000\n\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tpage := 1\n\tperPage := batchSize\n\tfindFilter.Page = &page\n\tfindFilter.PerPage = &perPage\n\n\tfor more := true; more; {\n\t\tif job.IsCancelled(ctx) {\n\t\t\treturn nil\n\t\t}\n\n\t\tscenes, err := Query(ctx, reader, sceneFilter, findFilter)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error querying for scenes: %w\", err)\n\t\t}\n\n\t\tfor _, scene := range scenes {\n\t\t\tif err := fn(scene); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif len(scenes) != batchSize {\n\t\t\tmore = false\n\t\t} else {\n\t\t\t*findFilter.Page++\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// FilterFromPaths creates a SceneFilterType that filters using the provided\n// paths.\nfunc FilterFromPaths(paths []string) *models.SceneFilterType {\n\tret := &models.SceneFilterType{}\n\tor := ret\n\tsep := string(filepath.Separator)\n\n\tfor _, p := range paths {\n\t\tif !strings.HasSuffix(p, sep) {\n\t\t\tp += sep\n\t\t}\n\n\t\tif ret.Path == nil {\n\t\t\tor = ret\n\t\t} else {\n\t\t\tnewOr := &models.SceneFilterType{}\n\t\t\tor.Or = newOr\n\t\t\tor = newOr\n\t\t}\n\n\t\tor.Path = &models.StringCriterionInput{\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\tValue:    p + \"%\",\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc CountByStudioID(ctx context.Context, r models.SceneQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.SceneFilterType{\n\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByTagID(ctx context.Context, r models.SceneQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.SceneFilterType{\n\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n\nfunc CountByGroupID(ctx context.Context, r models.SceneQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.SceneFilterType{\n\t\tGroups: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn r.QueryCount(ctx, filter, nil)\n}\n"
  },
  {
    "path": "pkg/scene/scan.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/file/video\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\nvar (\n\tErrNotVideoFile = errors.New(\"not a video file\")\n\n\t// fingerprint types to match with\n\t// only try to match by data fingerprints, _not_ perceptual fingerprints\n\tmatchableFingerprintTypes = []string{models.FingerprintTypeOshash, models.FingerprintTypeMD5}\n)\n\ntype ScanCreatorUpdater interface {\n\tFindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error)\n\tFindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Scene, error)\n\tGetFiles(ctx context.Context, relatedID int) ([]*models.VideoFile, error)\n\n\tCreate(ctx context.Context, newScene *models.Scene, fileIDs []models.FileID) error\n\tUpdatePartial(ctx context.Context, id int, updatedScene models.ScenePartial) (*models.Scene, error)\n\tAddFileID(ctx context.Context, id int, fileID models.FileID) error\n}\n\ntype ScanGalleryFinderUpdater interface {\n\tFindByPath(ctx context.Context, p string) ([]*models.Gallery, error)\n\tAddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error\n}\n\ntype ScanGenerator interface {\n\tGenerate(ctx context.Context, s *models.Scene, f *models.VideoFile) error\n}\n\ntype ScanHandler struct {\n\tCreatorUpdater       ScanCreatorUpdater\n\tGalleryFinderUpdater ScanGalleryFinderUpdater\n\n\tScanGenerator  ScanGenerator\n\tCaptionUpdater video.CaptionUpdater\n\tPluginCache    *plugin.Cache\n\n\tFileNamingAlgorithm models.HashAlgorithm\n\tPaths               *paths.Paths\n}\n\nfunc (h *ScanHandler) validate() error {\n\tif h.CreatorUpdater == nil {\n\t\treturn errors.New(\"CreatorUpdater is required\")\n\t}\n\tif h.ScanGenerator == nil {\n\t\treturn errors.New(\"ScanGenerator is required\")\n\t}\n\tif h.CaptionUpdater == nil {\n\t\treturn errors.New(\"CaptionUpdater is required\")\n\t}\n\tif !h.FileNamingAlgorithm.IsValid() {\n\t\treturn errors.New(\"FileNamingAlgorithm is required\")\n\t}\n\tif h.Paths == nil {\n\t\treturn errors.New(\"Paths is required\")\n\t}\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error {\n\tif err := h.validate(); err != nil {\n\t\treturn err\n\t}\n\n\tvideoFile, ok := f.(*models.VideoFile)\n\tif !ok {\n\t\treturn ErrNotVideoFile\n\t}\n\n\tif oldFile != nil {\n\t\tif err := video.CleanCaptions(ctx, videoFile, nil, h.CaptionUpdater); err != nil {\n\t\t\treturn fmt.Errorf(\"cleaning captions: %w\", err)\n\t\t}\n\t}\n\n\t// try to match the file to a scene\n\texisting, err := h.CreatorUpdater.FindByFileID(ctx, f.Base().ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding existing scene: %w\", err)\n\t}\n\n\tif len(existing) == 0 {\n\t\t// try also to match file by fingerprints\n\t\texisting, err = h.CreatorUpdater.FindByFingerprints(ctx, videoFile.Fingerprints.Filter(matchableFingerprintTypes...))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"finding existing scene by fingerprints: %w\", err)\n\t\t}\n\t}\n\n\tif len(existing) > 0 {\n\t\tupdateExisting := oldFile != nil\n\t\tif err := h.associateExisting(ctx, existing, videoFile, updateExisting); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t// create a new scene\n\t\tnewScene := models.NewScene()\n\n\t\tlogger.Infof(\"%s doesn't exist. Creating new scene...\", f.Base().Path)\n\n\t\tif err := h.CreatorUpdater.Create(ctx, &newScene, []models.FileID{videoFile.ID}); err != nil {\n\t\t\treturn fmt.Errorf(\"creating new scene: %w\", err)\n\t\t}\n\n\t\th.PluginCache.RegisterPostHooks(ctx, newScene.ID, hook.SceneCreatePost, nil, nil)\n\n\t\texisting = []*models.Scene{&newScene}\n\t}\n\n\tif oldFile != nil {\n\t\t// migrate hashes from the old file to the new\n\t\toldHash := GetHash(oldFile, h.FileNamingAlgorithm)\n\t\tnewHash := GetHash(f, h.FileNamingAlgorithm)\n\n\t\tif oldHash != \"\" && newHash != \"\" && oldHash != newHash {\n\t\t\tMigrateHash(h.Paths, oldHash, newHash)\n\t\t}\n\t}\n\n\tif err := h.associateGallery(ctx, existing, f); err != nil {\n\t\treturn err\n\t}\n\n\t// do this after the commit so that cover generation doesn't hold up the transaction\n\ttxn.AddPostCommitHook(ctx, func(ctx context.Context) {\n\t\tfor _, s := range existing {\n\t\t\tif err := h.ScanGenerator.Generate(ctx, s, videoFile); err != nil {\n\t\t\t\t// just log if cover generation fails. We can try again on rescan\n\t\t\t\tlogger.Errorf(\"Error generating content for %s: %v\", videoFile.Path, err)\n\t\t\t}\n\t\t}\n\t})\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Scene, f *models.VideoFile, updateExisting bool) error {\n\tfor _, s := range existing {\n\t\tif err := s.LoadFiles(ctx, h.CreatorUpdater); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfound := false\n\t\tfor _, sf := range s.Files.List() {\n\t\t\tif sf.ID == f.ID {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\tlogger.Infof(\"Adding %s to scene %s\", f.Path, s.DisplayName())\n\n\t\t\tif err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil {\n\t\t\t\treturn fmt.Errorf(\"adding file to scene: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tif !found || updateExisting {\n\t\t\t// update updated_at time when file association or content changes\n\t\t\tscenePartial := models.NewScenePartial()\n\t\t\tif _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, scenePartial); err != nil {\n\t\t\t\treturn fmt.Errorf(\"updating scene: %w\", err)\n\t\t\t}\n\n\t\t\th.PluginCache.RegisterPostHooks(ctx, s.ID, hook.SceneUpdatePost, nil, nil)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Scene, f models.File) error {\n\tsceneIDs := make([]int, len(existing))\n\tfor i, s := range existing {\n\t\tsceneIDs[i] = s.ID\n\t}\n\n\tpath := f.Base().Path\n\tzipPath := strings.TrimSuffix(path, filepath.Ext(path)) + \".zip\"\n\n\t// find galleries with a file that matches\n\tgalleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, gallery := range galleries {\n\t\t// found related Scene\n\t\tlogger.Infof(\"associate: Scene %s is related to gallery: %d\", path, gallery.ID)\n\t\tif err := h.GalleryFinderUpdater.AddSceneIDs(ctx, gallery.ID, sceneIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/scene/scan_test.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {\n\tconst (\n\t\ttestSceneID = 1\n\t\ttestFileID  = 100\n\t)\n\n\texistingFile := &models.VideoFile{\n\t\tBaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: \"test.mp4\"},\n\t}\n\n\tmakeScene := func() *models.Scene {\n\t\treturn &models.Scene{\n\t\t\tID:    testSceneID,\n\t\t\tFiles: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),\n\t\t}\n\t}\n\n\ttests := []struct {\n\t\tname           string\n\t\tupdateExisting bool\n\t\texpectUpdate   bool\n\t}{\n\t\t{\n\t\t\tname:           \"calls UpdatePartial when file content changed\",\n\t\t\tupdateExisting: true,\n\t\t\texpectUpdate:   true,\n\t\t},\n\t\t{\n\t\t\tname:           \"skips UpdatePartial when file unchanged and already associated\",\n\t\t\tupdateExisting: false,\n\t\t\texpectUpdate:   false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdb := mocks.NewDatabase()\n\t\t\tdb.Scene.On(\"GetFiles\", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil)\n\n\t\t\tif tt.expectUpdate {\n\t\t\t\tdb.Scene.On(\"UpdatePartial\", mock.Anything, testSceneID, mock.Anything).\n\t\t\t\t\tReturn(&models.Scene{ID: testSceneID}, nil)\n\t\t\t}\n\n\t\t\th := &ScanHandler{\n\t\t\t\tCreatorUpdater: db.Scene,\n\t\t\t\tPluginCache:    &plugin.Cache{},\n\t\t\t}\n\n\t\t\tdb.WithTxnCtx(func(ctx context.Context) {\n\t\t\t\terr := h.associateExisting(ctx, []*models.Scene{makeScene()}, existingFile, tt.updateExisting)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t})\n\n\t\t\tif tt.expectUpdate {\n\t\t\t\tdb.Scene.AssertCalled(t, \"UpdatePartial\", mock.Anything, testSceneID, mock.Anything)\n\t\t\t} else {\n\t\t\t\tdb.Scene.AssertNotCalled(t, \"UpdatePartial\", mock.Anything, mock.Anything, mock.Anything)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {\n\tconst (\n\t\ttestSceneID = 1\n\t\texistFileID = 100\n\t\tnewFileID   = 200\n\t)\n\n\texistingFile := &models.VideoFile{\n\t\tBaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: \"existing.mp4\"},\n\t}\n\tnewFile := &models.VideoFile{\n\t\tBaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: \"new.mp4\"},\n\t}\n\n\tscene := &models.Scene{\n\t\tID:    testSceneID,\n\t\tFiles: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),\n\t}\n\n\tdb := mocks.NewDatabase()\n\tdb.Scene.On(\"GetFiles\", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil)\n\tdb.Scene.On(\"AddFileID\", mock.Anything, testSceneID, models.FileID(newFileID)).Return(nil)\n\tdb.Scene.On(\"UpdatePartial\", mock.Anything, testSceneID, mock.Anything).\n\t\tReturn(&models.Scene{ID: testSceneID}, nil)\n\n\th := &ScanHandler{\n\t\tCreatorUpdater: db.Scene,\n\t\tPluginCache:    &plugin.Cache{},\n\t}\n\n\tdb.WithTxnCtx(func(ctx context.Context) {\n\t\terr := h.associateExisting(ctx, []*models.Scene{scene}, newFile, false)\n\t\tassert.NoError(t, err)\n\t})\n\n\tdb.Scene.AssertCalled(t, \"AddFileID\", mock.Anything, testSceneID, models.FileID(newFileID))\n\tdb.Scene.AssertCalled(t, \"UpdatePartial\", mock.Anything, testSceneID, mock.Anything)\n}\n"
  },
  {
    "path": "pkg/scene/service.go",
    "content": "// Package scene provides the application logic for scene functionality.\n// Most functionality is provided by [Service].\npackage scene\n\nimport (\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/paths\"\n\t\"github.com/stashapp/stash/pkg/plugin\"\n)\n\ntype Config interface {\n\tGetVideoFileNamingAlgorithm() models.HashAlgorithm\n}\n\ntype Service struct {\n\tFile             models.FileReaderWriter\n\tRepository       models.SceneReaderWriter\n\tMarkerRepository models.SceneMarkerReaderWriter\n\tPluginCache      *plugin.Cache\n\n\tPaths  *paths.Paths\n\tConfig Config\n}\n"
  },
  {
    "path": "pkg/scene/update.go",
    "content": "package scene\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nvar ErrEmptyUpdater = errors.New(\"no fields have been set\")\n\n// UpdateSet is used to update a scene and its relationships.\ntype UpdateSet struct {\n\tID int\n\n\tPartial models.ScenePartial\n\n\t// in future these could be moved into a separate struct and reused\n\t// for a Creator struct\n\n\t// Not set if nil. Set to []byte{} to clear existing\n\tCoverImage []byte\n}\n\n// IsEmpty returns true if there is nothing to update.\nfunc (u *UpdateSet) IsEmpty() bool {\n\twithoutID := u.Partial\n\n\treturn withoutID == models.ScenePartial{} &&\n\t\tu.CoverImage == nil\n}\n\n// Update updates a scene by updating the fields in the Partial field, then\n// updates non-nil relationships. Returns an error if there is no work to\n// be done.\nfunc (u *UpdateSet) Update(ctx context.Context, qb models.SceneUpdater) (*models.Scene, error) {\n\tif u.IsEmpty() {\n\t\treturn nil, ErrEmptyUpdater\n\t}\n\n\tpartial := u.Partial\n\tupdatedAt := time.Now()\n\tpartial.UpdatedAt = models.NewOptionalTime(updatedAt)\n\n\tret, err := qb.UpdatePartial(ctx, u.ID, partial)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating scene: %w\", err)\n\t}\n\n\tif u.CoverImage != nil {\n\t\tif err := qb.UpdateCover(ctx, u.ID, u.CoverImage); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error updating scene cover: %w\", err)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// UpdateInput converts the UpdateSet into SceneUpdateInput for hook firing purposes.\nfunc (u UpdateSet) UpdateInput() models.SceneUpdateInput {\n\t// ensure the partial ID is set\n\tret := u.Partial.UpdateInput(u.ID)\n\n\tif u.CoverImage != nil {\n\t\t// convert back to base64\n\t\tdata := utils.GetBase64StringFromData(u.CoverImage)\n\t\tret.CoverImage = &data\n\t}\n\n\treturn ret\n}\n\nfunc AddPerformer(ctx context.Context, qb models.SceneUpdater, o *models.Scene, performerID int) error {\n\tscenePartial := models.NewScenePartial()\n\tscenePartial.PerformerIDs = &models.UpdateIDs{\n\t\tIDs:  []int{performerID},\n\t\tMode: models.RelationshipUpdateModeAdd,\n\t}\n\t_, err := qb.UpdatePartial(ctx, o.ID, scenePartial)\n\treturn err\n}\n\nfunc AddTag(ctx context.Context, qb models.SceneUpdater, o *models.Scene, tagID int) error {\n\tscenePartial := models.NewScenePartial()\n\tscenePartial.TagIDs = &models.UpdateIDs{\n\t\tIDs:  []int{tagID},\n\t\tMode: models.RelationshipUpdateModeAdd,\n\t}\n\t_, err := qb.UpdatePartial(ctx, o.ID, scenePartial)\n\treturn err\n}\n\nfunc AddGallery(ctx context.Context, qb models.SceneUpdater, o *models.Scene, galleryID int) error {\n\tscenePartial := models.NewScenePartial()\n\tscenePartial.TagIDs = &models.UpdateIDs{\n\t\tIDs:  []int{galleryID},\n\t\tMode: models.RelationshipUpdateModeAdd,\n\t}\n\t_, err := qb.UpdatePartial(ctx, o.ID, scenePartial)\n\treturn err\n}\n\nfunc (s *Service) AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error {\n\t// ensure file isn't a primary file and that it is a video file\n\tf, err := s.File.Find(ctx, fileID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tff := f[0]\n\tif _, ok := ff.(*models.VideoFile); !ok {\n\t\treturn fmt.Errorf(\"%s is not a video file\", ff.Base().Path)\n\t}\n\n\tisPrimary, err := s.File.IsPrimary(ctx, fileID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif isPrimary {\n\t\treturn errors.New(\"cannot reassign primary file\")\n\t}\n\n\treturn s.Repository.AssignFiles(ctx, sceneID, []models.FileID{fileID})\n}\n"
  },
  {
    "path": "pkg/scene/update_test.go",
    "content": "package scene\n\nimport (\n\t\"errors\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/intslice\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc TestUpdater_IsEmpty(t *testing.T) {\n\torganized := true\n\tids := []int{1}\n\tstashIDs := []models.StashID{\n\t\t{},\n\t}\n\tcover := []byte{1}\n\n\ttests := []struct {\n\t\tname string\n\t\tu    *UpdateSet\n\t\twant bool\n\t}{\n\t\t{\n\t\t\t\"empty\",\n\t\t\t&UpdateSet{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"partial set\",\n\t\t\t&UpdateSet{\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tOrganized: models.NewOptionalBool(organized),\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"performer set\",\n\t\t\t&UpdateSet{\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  ids,\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"tags set\",\n\t\t\t&UpdateSet{\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  ids,\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"performer set\",\n\t\t\t&UpdateSet{\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\t\tMode:     models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"cover set\",\n\t\t\t&UpdateSet{\n\t\t\t\tCoverImage: cover,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := tt.u.IsEmpty(); got != tt.want {\n\t\t\t\tt.Errorf(\"Updater.IsEmpty() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestUpdater_Update(t *testing.T) {\n\tconst (\n\t\tsceneID = iota + 1\n\t\tbadUpdateID\n\t\tbadPerformersID\n\t\tbadTagsID\n\t\tbadStashIDsID\n\t\tbadCoverID\n\t\tperformerID\n\t\ttagID\n\t)\n\n\tperformerIDs := []int{performerID}\n\ttagIDs := []int{tagID}\n\tstashID := \"stashID\"\n\tendpoint := \"endpoint\"\n\n\ttitle := \"title\"\n\tcover := []byte(\"cover\")\n\n\tvalidScene := &models.Scene{}\n\n\tupdateErr := errors.New(\"error updating\")\n\n\tdb := mocks.NewDatabase()\n\n\tdb.Scene.On(\"UpdatePartial\", testCtx, mock.MatchedBy(func(id int) bool {\n\t\treturn id != badUpdateID\n\t}), mock.Anything).Return(validScene, nil)\n\tdb.Scene.On(\"UpdatePartial\", testCtx, badUpdateID, mock.Anything).Return(nil, updateErr)\n\n\tdb.Scene.On(\"UpdateCover\", testCtx, sceneID, cover).Return(nil).Once()\n\tdb.Scene.On(\"UpdateCover\", testCtx, badCoverID, cover).Return(updateErr).Once()\n\n\ttests := []struct {\n\t\tname    string\n\t\tu       *UpdateSet\n\t\twantNil bool\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"empty\",\n\t\t\t&UpdateSet{\n\t\t\t\tID: sceneID,\n\t\t\t},\n\t\t\ttrue,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"update all\",\n\t\t\t&UpdateSet{\n\t\t\t\tID: sceneID,\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  performerIDs,\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  tagIDs,\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\t\tStashIDs: []models.StashID{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tStashID:  stashID,\n\t\t\t\t\t\t\t\tEndpoint: endpoint,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCoverImage: cover,\n\t\t\t},\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"update fields only\",\n\t\t\t&UpdateSet{\n\t\t\t\tID: sceneID,\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tTitle: models.NewOptionalString(title),\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"error updating scene\",\n\t\t\t&UpdateSet{\n\t\t\t\tID: badUpdateID,\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tTitle: models.NewOptionalString(title),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"error updating cover\",\n\t\t\t&UpdateSet{\n\t\t\t\tID:         badCoverID,\n\t\t\t\tCoverImage: cover,\n\t\t\t},\n\t\t\ttrue,\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := tt.u.Update(testCtx, db.Scene)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"Updater.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (got == nil) != tt.wantNil {\n\t\t\t\tt.Errorf(\"Updater.Update() = %v, want %v\", got, tt.wantNil)\n\t\t\t}\n\t\t})\n\t}\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestUpdateSet_UpdateInput(t *testing.T) {\n\tconst (\n\t\tsceneID = iota + 1\n\t\tbadUpdateID\n\t\tbadPerformersID\n\t\tbadTagsID\n\t\tbadStashIDsID\n\t\tbadCoverID\n\t\tperformerID\n\t\ttagID\n\t)\n\n\tsceneIDStr := strconv.Itoa(sceneID)\n\n\tperformerIDs := []int{performerID}\n\tperformerIDStrs := intslice.IntSliceToStringSlice(performerIDs)\n\ttagIDs := []int{tagID}\n\ttagIDStrs := intslice.IntSliceToStringSlice(tagIDs)\n\tstashID := \"stashID\"\n\tendpoint := \"endpoint\"\n\tupdatedAt := time.Now()\n\tstashIDs := []models.StashID{\n\t\t{\n\t\t\tStashID:   stashID,\n\t\t\tEndpoint:  endpoint,\n\t\t\tUpdatedAt: updatedAt,\n\t\t},\n\t}\n\tstashIDInputs := []models.StashIDInput{\n\t\t{\n\t\t\tStashID:   stashID,\n\t\t\tEndpoint:  endpoint,\n\t\t\tUpdatedAt: &updatedAt,\n\t\t},\n\t}\n\n\ttitle := \"title\"\n\tcover := []byte(\"cover\")\n\tcoverB64 := \"Y292ZXI=\"\n\n\ttests := []struct {\n\t\tname string\n\t\tu    UpdateSet\n\t\twant models.SceneUpdateInput\n\t}{\n\t\t{\n\t\t\t\"empty\",\n\t\t\tUpdateSet{\n\t\t\t\tID: sceneID,\n\t\t\t},\n\t\t\tmodels.SceneUpdateInput{\n\t\t\t\tID: sceneIDStr,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"update all\",\n\t\t\tUpdateSet{\n\t\t\t\tID: sceneID,\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  performerIDs,\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\t\tIDs:  tagIDs,\n\t\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\t\tMode:     models.RelationshipUpdateModeSet,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCoverImage: cover,\n\t\t\t},\n\t\t\tmodels.SceneUpdateInput{\n\t\t\t\tID:           sceneIDStr,\n\t\t\t\tPerformerIds: performerIDStrs,\n\t\t\t\tTagIds:       tagIDStrs,\n\t\t\t\tStashIds:     stashIDInputs,\n\t\t\t\tCoverImage:   &coverB64,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"update fields only\",\n\t\t\tUpdateSet{\n\t\t\t\tID: sceneID,\n\t\t\t\tPartial: models.ScenePartial{\n\t\t\t\t\tTitle: models.NewOptionalString(title),\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.SceneUpdateInput{\n\t\t\t\tID:    sceneIDStr,\n\t\t\t\tTitle: &title,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.u.UpdateInput()\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/scraper/action.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype scraperAction string\n\nconst (\n\tscraperActionScript scraperAction = \"script\"\n\tscraperActionStash  scraperAction = \"stash\"\n\tscraperActionXPath  scraperAction = \"scrapeXPath\"\n\tscraperActionJson   scraperAction = \"scrapeJson\"\n)\n\nfunc (e scraperAction) IsValid() bool {\n\tswitch e {\n\tcase scraperActionScript, scraperActionStash, scraperActionXPath, scraperActionJson:\n\t\treturn true\n\t}\n\treturn false\n}\n\ntype urlScraperActionImpl interface {\n\tscrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error)\n}\n\nfunc (c Definition) getURLScraper(def ByURLDefinition, client *http.Client, globalConfig GlobalConfig) urlScraperActionImpl {\n\tswitch def.Action {\n\tcase scraperActionScript:\n\t\treturn &scriptURLScraper{\n\t\t\tscriptScraper: scriptScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t},\n\t\t\tdefinition: def,\n\t\t}\n\tcase scraperActionStash:\n\t\treturn newStashScraper(client, c, globalConfig)\n\tcase scraperActionXPath:\n\t\treturn &xpathURLScraper{\n\t\t\txpathScraper: xpathScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t\tclient:       client,\n\t\t\t},\n\t\t\tdefinition: def,\n\t\t}\n\tcase scraperActionJson:\n\t\treturn &jsonURLScraper{\n\t\t\tjsonScraper: jsonScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t\tclient:       client,\n\t\t\t},\n\t\t\tdefinition: def,\n\t\t}\n\t}\n\n\tpanic(\"unknown scraper action: \" + def.Action)\n}\n\ntype nameScraperActionImpl interface {\n\tscrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error)\n}\n\nfunc (c Definition) getNameScraper(def ByNameDefinition, client *http.Client, globalConfig GlobalConfig) nameScraperActionImpl {\n\tswitch def.Action {\n\tcase scraperActionScript:\n\t\treturn &scriptNameScraper{\n\t\t\tscriptScraper: scriptScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t},\n\t\t\tdefinition: def,\n\t\t}\n\tcase scraperActionStash:\n\t\treturn newStashScraper(client, c, globalConfig)\n\tcase scraperActionXPath:\n\t\treturn &xpathNameScraper{\n\t\t\txpathScraper: xpathScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t\tclient:       client,\n\t\t\t},\n\t\t\tdefinition: def,\n\t\t}\n\tcase scraperActionJson:\n\t\treturn &jsonNameScraper{\n\t\t\tjsonScraper: jsonScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t\tclient:       client,\n\t\t\t},\n\t\t\tdefinition: def,\n\t\t}\n\t}\n\n\tpanic(\"unknown scraper action: \" + def.Action)\n}\n\ntype fragmentScraperActionImpl interface {\n\tscrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error)\n\n\tscrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error)\n\tscrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error)\n\tscrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error)\n}\n\nfunc (c Definition) getFragmentScraper(actionDef ByFragmentDefinition, client *http.Client, globalConfig GlobalConfig) fragmentScraperActionImpl {\n\tswitch actionDef.Action {\n\tcase scraperActionScript:\n\t\treturn &scriptFragmentScraper{\n\t\t\tscriptScraper: scriptScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t},\n\t\t\tdefinition: actionDef,\n\t\t}\n\tcase scraperActionStash:\n\t\treturn newStashScraper(client, c, globalConfig)\n\tcase scraperActionXPath:\n\t\treturn &xpathFragmentScraper{\n\t\t\txpathScraper: xpathScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t\tclient:       client,\n\t\t\t},\n\t\t\tdefinition: actionDef,\n\t\t}\n\tcase scraperActionJson:\n\t\treturn &jsonFragmentScraper{\n\t\t\tjsonScraper: jsonScraper{\n\t\t\t\tdefinition:   c,\n\t\t\t\tglobalConfig: globalConfig,\n\t\t\t\tclient:       client,\n\t\t\t},\n\t\t\tdefinition: actionDef,\n\t\t}\n\t}\n\n\tpanic(\"unknown scraper action: \" + actionDef.Action)\n}\n"
  },
  {
    "path": "pkg/scraper/autotag.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\n// autoTagScraperID is the scraper ID for the built-in AutoTag scraper\nconst (\n\tautoTagScraperID   = \"builtin_autotag\"\n\tautoTagScraperName = \"Auto Tag\"\n)\n\ntype autotagScraper struct {\n\ttxnManager      txn.Manager\n\tperformerReader models.PerformerAutoTagQueryer\n\tstudioReader    models.StudioAutoTagQueryer\n\ttagReader       models.TagAutoTagQueryer\n\n\tglobalConfig GlobalConfig\n}\n\nfunc autotagMatchPerformers(ctx context.Context, path string, performerReader models.PerformerAutoTagQueryer, trimExt bool) ([]*models.ScrapedPerformer, error) {\n\tp, err := match.PathToPerformers(ctx, path, performerReader, nil, trimExt)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error matching performers: %w\", err)\n\t}\n\n\tvar ret []*models.ScrapedPerformer\n\tfor _, pp := range p {\n\t\tid := strconv.Itoa(pp.ID)\n\n\t\tsp := &models.ScrapedPerformer{\n\t\t\tName:     &pp.Name,\n\t\t\tStoredID: &id,\n\t\t}\n\t\tif pp.Gender != nil && pp.Gender.IsValid() {\n\t\t\tv := pp.Gender.String()\n\t\t\tsp.Gender = &v\n\t\t}\n\n\t\tret = append(ret, sp)\n\t}\n\n\treturn ret, nil\n}\n\nfunc autotagMatchStudio(ctx context.Context, path string, studioReader models.StudioAutoTagQueryer, trimExt bool) (*models.ScrapedStudio, error) {\n\tstudio, err := match.PathToStudio(ctx, path, studioReader, nil, trimExt)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error matching studios: %w\", err)\n\t}\n\n\tif studio != nil {\n\t\tid := strconv.Itoa(studio.ID)\n\t\treturn &models.ScrapedStudio{\n\t\t\tName:     studio.Name,\n\t\t\tStoredID: &id,\n\t\t}, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc autotagMatchTags(ctx context.Context, path string, tagReader models.TagAutoTagQueryer, trimExt bool) ([]*models.ScrapedTag, error) {\n\tt, err := match.PathToTags(ctx, path, tagReader, nil, trimExt)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error matching tags: %w\", err)\n\t}\n\n\tvar ret []*models.ScrapedTag\n\tfor _, tt := range t {\n\t\tid := strconv.Itoa(tt.ID)\n\n\t\tst := &models.ScrapedTag{\n\t\t\tName:     tt.Name,\n\t\t\tStoredID: &id,\n\t\t}\n\n\t\tret = append(ret, st)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) {\n\tvar ret *models.ScrapedScene\n\tconst trimExt = false\n\n\t// populate performers, studio and tags based on scene path\n\tif err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {\n\t\tpath := scene.Path\n\t\tif path == \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\tperformers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"autotag scraper viaScene: %w\", err)\n\t\t}\n\t\tstudio, err := autotagMatchStudio(ctx, path, s.studioReader, trimExt)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"autotag scraper viaScene: %w\", err)\n\t\t}\n\n\t\ttags, err := autotagMatchTags(ctx, path, s.tagReader, trimExt)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"autotag scraper viaScene: %w\", err)\n\t\t}\n\n\t\tif len(performers) > 0 || studio != nil || len(tags) > 0 {\n\t\t\tret = &models.ScrapedScene{\n\t\t\t\tPerformers: performers,\n\t\t\t\tStudio:     studio,\n\t\t\t\tTags:       tags,\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) {\n\tpath := gallery.Path\n\tif path == \"\" {\n\t\t// not valid for non-path-based galleries\n\t\treturn nil, nil\n\t}\n\n\t// only trim extension if gallery is file-based\n\ttrimExt := gallery.PrimaryFileID != nil\n\n\tvar ret *models.ScrapedGallery\n\n\t// populate performers, studio and tags based on scene path\n\tif err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {\n\t\tpath := gallery.Path\n\t\tperformers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"autotag scraper viaGallery: %w\", err)\n\t\t}\n\t\tstudio, err := autotagMatchStudio(ctx, path, s.studioReader, trimExt)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"autotag scraper viaGallery: %w\", err)\n\t\t}\n\n\t\ttags, err := autotagMatchTags(ctx, path, s.tagReader, trimExt)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"autotag scraper viaGallery: %w\", err)\n\t\t}\n\n\t\tif len(performers) > 0 || studio != nil || len(tags) > 0 {\n\t\t\tret = &models.ScrapedGallery{\n\t\t\t\tPerformers: performers,\n\t\t\t\tStudio:     studio,\n\t\t\t\tTags:       tags,\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (s autotagScraper) supports(ty ScrapeContentType) bool {\n\tswitch ty {\n\tcase ScrapeContentTypeScene:\n\t\treturn true\n\tcase ScrapeContentTypeGallery:\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc (s autotagScraper) supportsURL(url string, ty ScrapeContentType) bool {\n\treturn false\n}\n\nfunc (s autotagScraper) spec() Scraper {\n\tsupportedScrapes := []ScrapeType{\n\t\tScrapeTypeFragment,\n\t}\n\n\treturn Scraper{\n\t\tID:   autoTagScraperID,\n\t\tName: autoTagScraperName,\n\t\tScene: &ScraperSpec{\n\t\t\tSupportedScrapes: supportedScrapes,\n\t\t},\n\t\tGallery: &ScraperSpec{\n\t\t\tSupportedScrapes: supportedScrapes,\n\t\t},\n\t}\n}\n\nfunc getAutoTagScraper(repo Repository, globalConfig GlobalConfig) scraper {\n\tbase := autotagScraper{\n\t\ttxnManager:      repo.TxnManager,\n\t\tperformerReader: repo.PerformerFinder,\n\t\tstudioReader:    repo.StudioFinder,\n\t\ttagReader:       repo.TagFinder,\n\t\tglobalConfig:    globalConfig,\n\t}\n\n\treturn base\n}\n"
  },
  {
    "path": "pkg/scraper/cache.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\nconst (\n\t// scrapeGetTimeout is the timeout for scraper HTTP requests. Includes transfer time.\n\t// We may want to bump this at some point and use local context-timeouts if more granularity\n\t// is needed.\n\tscrapeGetTimeout = time.Second * 60\n\n\t// maxIdleConnsPerHost is the maximum number of idle connections the HTTP client will\n\t// keep on a per-host basis.\n\tmaxIdleConnsPerHost = 8\n\n\t// maxRedirects defines the maximum number of redirects the HTTP client will follow\n\tmaxRedirects = 20\n)\n\n// GlobalConfig contains the global scraper options.\ntype GlobalConfig interface {\n\tGetScraperUserAgent() string\n\tGetScrapersPath() string\n\tGetScraperCDPPath() string\n\tGetScraperCertCheck() bool\n\tGetPythonPath() string\n\tGetProxy() string\n\tGetScraperExcludeTagPatterns() []string\n}\n\nfunc isCDPPathHTTP(c GlobalConfig) bool {\n\treturn strings.HasPrefix(c.GetScraperCDPPath(), \"http://\") || strings.HasPrefix(c.GetScraperCDPPath(), \"https://\")\n}\n\nfunc isCDPPathWS(c GlobalConfig) bool {\n\treturn strings.HasPrefix(c.GetScraperCDPPath(), \"ws://\")\n}\n\ntype SceneFinder interface {\n\tmodels.SceneGetter\n\tmodels.URLLoader\n\tmodels.VideoFileLoader\n}\n\ntype PerformerFinder interface {\n\tmodels.PerformerAutoTagQueryer\n\tmatch.PerformerFinder\n}\n\ntype StudioFinder interface {\n\tmodels.StudioAutoTagQueryer\n\tFindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error)\n}\n\ntype TagFinder interface {\n\tmodels.TagGetter\n\tmodels.TagAutoTagQueryer\n}\n\ntype GalleryFinder interface {\n\tmodels.GalleryGetter\n\tmodels.FileLoader\n\tmodels.URLLoader\n}\n\ntype ImageFinder interface {\n\tmodels.ImageGetter\n\tmodels.FileLoader\n\tmodels.URLLoader\n}\n\ntype Repository struct {\n\tTxnManager models.TxnManager\n\n\tSceneFinder     SceneFinder\n\tGalleryFinder   GalleryFinder\n\tImageFinder     ImageFinder\n\tTagFinder       TagFinder\n\tPerformerFinder PerformerFinder\n\tGroupFinder     match.GroupNamesFinder\n\tStudioFinder    StudioFinder\n}\n\nfunc NewRepository(repo models.Repository) Repository {\n\treturn Repository{\n\t\tTxnManager:      repo.TxnManager,\n\t\tSceneFinder:     repo.Scene,\n\t\tGalleryFinder:   repo.Gallery,\n\t\tImageFinder:     repo.Image,\n\t\tTagFinder:       repo.Tag,\n\t\tPerformerFinder: repo.Performer,\n\t\tGroupFinder:     repo.Group,\n\t\tStudioFinder:    repo.Studio,\n\t}\n}\n\nfunc (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error {\n\treturn txn.WithReadTxn(ctx, r.TxnManager, fn)\n}\n\n// Cache stores the database of scrapers\ntype Cache struct {\n\tclient       *http.Client\n\tscrapers     map[string]scraper // Scraper ID -> Scraper\n\tglobalConfig GlobalConfig\n\n\trepository Repository\n}\n\n// newClient creates a scraper-local http client we use throughout the scraper subsystem.\nfunc newClient(gc GlobalConfig) *http.Client {\n\tclient := &http.Client{\n\t\tTransport: &http.Transport{ // ignore insecure certificates\n\t\t\tTLSClientConfig:     &tls.Config{InsecureSkipVerify: !gc.GetScraperCertCheck()},\n\t\t\tMaxIdleConnsPerHost: maxIdleConnsPerHost,\n\t\t\tProxy:               http.ProxyFromEnvironment,\n\t\t},\n\t\tTimeout: scrapeGetTimeout,\n\t\t// defaultCheckRedirect code with max changed from 10 to maxRedirects\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\tif len(via) >= maxRedirects {\n\t\t\t\treturn fmt.Errorf(\"%w: gave up after %d redirects\", ErrMaxRedirects, maxRedirects)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn client\n}\n\n// NewCache returns a new Cache.\n//\n// Scraper configurations are loaded from yml files in the scrapers\n// directory in the config and any subdirectories.\n//\n// Does not load scrapers. Scrapers will need to be\n// loaded explicitly using ReloadScrapers.\nfunc NewCache(globalConfig GlobalConfig, repo Repository) *Cache {\n\t// HTTP Client setup\n\tclient := newClient(globalConfig)\n\n\treturn &Cache{\n\t\tclient:       client,\n\t\tglobalConfig: globalConfig,\n\t\trepository:   repo,\n\t}\n}\n\n// ReloadScrapers clears the scraper cache and reloads from the scraper path.\n// If a scraper cannot be loaded, an error is logged and the scraper is skipped.\nfunc (c *Cache) ReloadScrapers() {\n\tpath := c.globalConfig.GetScrapersPath()\n\tscrapers := make(map[string]scraper)\n\n\t// Add built-in scrapers\n\tfreeOnes := getFreeonesScraper(c.globalConfig)\n\tautoTag := getAutoTagScraper(c.repository, c.globalConfig)\n\tscrapers[freeOnes.spec().ID] = freeOnes\n\tscrapers[autoTag.spec().ID] = autoTag\n\n\tlogger.Debugf(\"Reading scraper configs from %s\", path)\n\n\terr := fsutil.SymWalk(path, func(fp string, f os.FileInfo, err error) error {\n\t\tif filepath.Ext(fp) == \".yml\" {\n\t\t\tconf, err := loadConfigFromYAMLFile(fp)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"Error loading scraper %s: %v\", fp, err)\n\t\t\t} else {\n\t\t\t\tscraper := scraperFromDefinition(*conf, c.globalConfig)\n\t\t\t\tscrapers[scraper.spec().ID] = scraper\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\tlogger.Errorf(\"Error reading scraper configs: %v\", err)\n\t}\n\n\tc.scrapers = scrapers\n}\n\n// ListScrapers lists scrapers matching one of the given types.\n// Returns a list of scrapers, sorted by their name.\nfunc (c Cache) ListScrapers(tys []ScrapeContentType) []*Scraper {\n\tvar ret []*Scraper\n\tfor _, s := range c.scrapers {\n\t\tfor _, t := range tys {\n\t\t\tif s.supports(t) {\n\t\t\t\tspec := s.spec()\n\t\t\t\tret = append(ret, &spec)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tsort.Slice(ret, func(i, j int) bool {\n\t\treturn strings.ToLower(ret[i].Name) < strings.ToLower(ret[j].Name)\n\t})\n\n\treturn ret\n}\n\n// GetScraper returns the scraper matching the provided id.\nfunc (c Cache) GetScraper(scraperID string) *Scraper {\n\ts := c.findScraper(scraperID)\n\tif s != nil {\n\t\tspec := s.spec()\n\t\treturn &spec\n\t}\n\n\treturn nil\n}\n\nfunc (c Cache) findScraper(scraperID string) scraper {\n\ts, ok := c.scrapers[scraperID]\n\tif ok {\n\t\treturn s\n\t}\n\n\treturn nil\n}\n\nfunc (c Cache) compileExcludeTagPatterns() []*regexp.Regexp {\n\treturn CompileExclusionRegexps(c.globalConfig.GetScraperExcludeTagPatterns())\n}\n\nfunc (c Cache) ScrapeName(ctx context.Context, id, query string, ty ScrapeContentType) ([]ScrapedContent, error) {\n\t// find scraper with the provided id\n\ts := c.findScraper(id)\n\tif s == nil {\n\t\treturn nil, fmt.Errorf(\"%w: id %s\", ErrNotFound, id)\n\t}\n\tif !s.supports(ty) {\n\t\treturn nil, fmt.Errorf(\"%w: cannot use scraper %s as a %v scraper\", ErrNotSupported, id, ty)\n\t}\n\n\tns, ok := s.(nameScraper)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"%w: cannot use scraper %s to scrape by name\", ErrNotSupported, id)\n\t}\n\n\tcontent, err := ns.viaName(ctx, c.client, query, ty)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error while name scraping with scraper %s: %w\", id, err)\n\t}\n\n\tpp := postScraper{\n\t\tCache:        c,\n\t\texcludeTagRE: c.compileExcludeTagPatterns(),\n\t}\n\tif err := c.repository.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tfor i, cc := range content {\n\t\t\tcontent[i], err = pp.postScrape(ctx, cc)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error while post-scraping with scraper %s: %w\", id, err)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tLogIgnoredTags(pp.ignoredTags)\n\n\treturn content, nil\n}\n\n// ScrapeFragment uses the given fragment input to scrape\nfunc (c Cache) ScrapeFragment(ctx context.Context, id string, input Input) (ScrapedContent, error) {\n\t// set the deprecated URL field if it's not set\n\tinput.populateURL()\n\n\ts := c.findScraper(id)\n\tif s == nil {\n\t\treturn nil, fmt.Errorf(\"%w: id %s\", ErrNotFound, id)\n\t}\n\n\tfs, ok := s.(fragmentScraper)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"%w: cannot use scraper %s as a fragment scraper\", ErrNotSupported, id)\n\t}\n\n\tcontent, err := fs.viaFragment(ctx, c.client, input)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error while fragment scraping with scraper %s: %w\", id, err)\n\t}\n\n\treturn c.postScrapeSingle(ctx, content)\n}\n\n// ScrapeURL scrapes a given url for the given content. Searches the scraper cache\n// and picks the first scraper capable of scraping the given url into the desired\n// content. Returns the scraped content or an error if the scrape fails.\nfunc (c Cache) ScrapeURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) {\n\tfor _, s := range c.scrapers {\n\t\tif s.supportsURL(url, ty) {\n\t\t\tul, ok := s.(urlScraper)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"%w: cannot use scraper %s as an url scraper\", ErrNotSupported, s.spec().ID)\n\t\t\t}\n\t\t\tret, err := ul.viaURL(ctx, c.client, url, ty)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tif ret == nil {\n\t\t\t\treturn ret, nil\n\t\t\t}\n\n\t\t\treturn c.postScrapeSingle(ctx, ret)\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (c Cache) ScrapeID(ctx context.Context, scraperID string, id int, ty ScrapeContentType) (ScrapedContent, error) {\n\ts := c.findScraper(scraperID)\n\tif s == nil {\n\t\treturn nil, fmt.Errorf(\"%w: id %s\", ErrNotFound, scraperID)\n\t}\n\n\tif !s.supports(ty) {\n\t\treturn nil, fmt.Errorf(\"%w: cannot use scraper %s to scrape %v content\", ErrNotSupported, scraperID, ty)\n\t}\n\n\tvar ret ScrapedContent\n\tswitch ty {\n\tcase ScrapeContentTypeScene:\n\t\tss, ok := s.(sceneScraper)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot use scraper %s as a scene scraper\", ErrNotSupported, scraperID)\n\t\t}\n\n\t\tscene, err := c.getScene(ctx, id)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"scraper %s: unable to load scene id %v: %w\", scraperID, id, err)\n\t\t}\n\n\t\t// don't assign nil concrete pointer to ret interface, otherwise nil\n\t\t// detection is harder\n\t\tscraped, err := ss.viaScene(ctx, c.client, scene)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"scraper %s: %w\", scraperID, err)\n\t\t}\n\n\t\tif scraped != nil {\n\t\t\tret = scraped\n\t\t}\n\tcase ScrapeContentTypeGallery:\n\t\tgs, ok := s.(galleryScraper)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot use scraper %s as a gallery scraper\", ErrNotSupported, scraperID)\n\t\t}\n\n\t\tgallery, err := c.getGallery(ctx, id)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"scraper %s: unable to load gallery id %v: %w\", scraperID, id, err)\n\t\t}\n\n\t\t// don't assign nil concrete pointer to ret interface, otherwise nil\n\t\t// detection is harder\n\t\tscraped, err := gs.viaGallery(ctx, c.client, gallery)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"scraper %s: %w\", scraperID, err)\n\t\t}\n\n\t\tif scraped != nil {\n\t\t\tret = scraped\n\t\t}\n\n\tcase ScrapeContentTypeImage:\n\t\tis, ok := s.(imageScraper)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"%w: cannot use scraper %s as a image scraper\", ErrNotSupported, scraperID)\n\t\t}\n\n\t\tscene, err := c.getImage(ctx, id)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"scraper %s: unable to load image id %v: %w\", scraperID, id, err)\n\t\t}\n\n\t\t// don't assign nil concrete pointer to ret interface, otherwise nil\n\t\t// detection is harder\n\t\tscraped, err := is.viaImage(ctx, c.client, scene)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"scraper %s: %w\", scraperID, err)\n\t\t}\n\n\t\tif scraped != nil {\n\t\t\tret = scraped\n\t\t}\n\t}\n\n\treturn c.postScrapeSingle(ctx, ret)\n}\n\nfunc (c Cache) getScene(ctx context.Context, sceneID int) (*models.Scene, error) {\n\tvar ret *models.Scene\n\tr := c.repository\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.SceneFinder\n\n\t\tvar err error\n\t\tret, err = qb.Find(ctx, sceneID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif ret == nil {\n\t\t\treturn fmt.Errorf(\"scene with id %d not found\", sceneID)\n\t\t}\n\n\t\tif err := ret.LoadURLs(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := ret.LoadFiles(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ret, nil\n}\n\nfunc (c Cache) getGallery(ctx context.Context, galleryID int) (*models.Gallery, error) {\n\tvar ret *models.Gallery\n\tr := c.repository\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.GalleryFinder\n\n\t\tvar err error\n\t\tret, err = qb.Find(ctx, galleryID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif ret == nil {\n\t\t\treturn fmt.Errorf(\"gallery with id %d not found\", galleryID)\n\t\t}\n\n\t\tif err := ret.LoadURLs(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := ret.LoadFiles(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ret, nil\n}\n\nfunc (c Cache) getImage(ctx context.Context, imageID int) (*models.Image, error) {\n\tvar ret *models.Image\n\tr := c.repository\n\tif err := r.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tqb := r.ImageFinder\n\n\t\tvar err error\n\t\tret, err = qb.Find(ctx, imageID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif ret == nil {\n\t\t\treturn fmt.Errorf(\"image with id %d not found\", imageID)\n\t\t}\n\n\t\terr = ret.LoadFiles(ctx, qb)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn ret.LoadURLs(ctx, qb)\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/scraper/cookies.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/http/cookiejar\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"github.com/chromedp/cdproto/cdp\"\n\t\"github.com/chromedp/cdproto/network\"\n\t\"github.com/chromedp/chromedp\"\n\t\"golang.org/x/net/publicsuffix\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// jar constructs a cookie jar from a configuration\nfunc (c Definition) jar() (*cookiejar.Jar, error) {\n\topts := c.DriverOptions\n\tjar, err := cookiejar.New(&cookiejar.Options{\n\t\tPublicSuffixList: publicsuffix.List,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif opts == nil || opts.UseCDP {\n\t\treturn jar, nil\n\t}\n\n\tfor i, ckURL := range opts.Cookies {\n\t\turl, err := url.Parse(ckURL.CookieURL) // CookieURL must be valid, include schema\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"skipping cookie [%d] for cookieURL %s: %v\", i, ckURL.CookieURL, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tvar httpCookies []*http.Cookie\n\t\tfor _, cookie := range ckURL.Cookies {\n\t\t\tc := &http.Cookie{\n\t\t\t\tName:   cookie.Name,\n\t\t\t\tValue:  getCookieValue(cookie),\n\t\t\t\tPath:   cookie.Path,\n\t\t\t\tDomain: cookie.Domain,\n\t\t\t}\n\t\t\thttpCookies = append(httpCookies, c)\n\t\t}\n\n\t\tjar.SetCookies(url, httpCookies)\n\t\tif jar.Cookies(url) == nil {\n\t\t\tlogger.Warnf(\"setting jar cookies for %s failed\", url.String())\n\t\t}\n\t}\n\n\treturn jar, nil\n}\n\nfunc getCookieValue(cookie *scraperCookies) string {\n\tif cookie.ValueRandom > 0 {\n\t\treturn randomSequence(cookie.ValueRandom)\n\t}\n\treturn cookie.Value\n}\n\nvar characters = []rune(\"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\")\n\nfunc randomSequence(n int) string {\n\tb := make([]rune, n)\n\trand := rand.New(rand.NewSource(time.Now().UnixNano()))\n\tfor i := range b {\n\t\tb[i] = characters[rand.Intn(len(characters))]\n\t}\n\treturn string(b)\n}\n\n// printCookies prints all cookies from the given cookie jar\nfunc printCookies(jar *cookiejar.Jar, scraperConfig Definition, msg string) {\n\tdriverOptions := scraperConfig.DriverOptions\n\tif driverOptions != nil && !driverOptions.UseCDP {\n\t\tvar foundURLs []*url.URL\n\n\t\tfor _, ckURL := range driverOptions.Cookies { // go through all cookies\n\t\t\turl, err := url.Parse(ckURL.CookieURL) // CookieURL must be valid, include schema\n\t\t\tif err == nil {\n\t\t\t\tfoundURLs = append(foundURLs, url)\n\t\t\t}\n\t\t}\n\t\tif len(foundURLs) > 0 {\n\t\t\tlogger.Debugf(\"%s\\n\", msg)\n\t\t\tprintJarCookies(jar, foundURLs)\n\n\t\t}\n\t}\n}\n\n// print all cookies from the jar of the native http client for given urls\nfunc printJarCookies(jar *cookiejar.Jar, urls []*url.URL) {\n\tfor _, url := range urls {\n\t\tlogger.Debugf(\"Jar cookies for %s\", url.String())\n\t\tfor i, cookie := range jar.Cookies(url) {\n\t\t\tlogger.Debugf(\"[%d]: Name: \\\"%s\\\" Value: \\\"%s\\\"\", i, cookie.Name, cookie.Value)\n\t\t}\n\t}\n}\n\n// set all cookies listed in the scraper config\nfunc setCDPCookies(driverOptions scraperDriverOptions) chromedp.Tasks {\n\treturn chromedp.Tasks{\n\t\tchromedp.ActionFunc(func(ctx context.Context) error {\n\t\t\t// create cookie expiration\n\t\t\texpr := cdp.TimeSinceEpoch(time.Now().Add(180 * 24 * time.Hour))\n\n\t\t\tfor _, ckURL := range driverOptions.Cookies {\n\t\t\t\tfor _, cookie := range ckURL.Cookies {\n\t\t\t\t\terr := network.SetCookie(cookie.Name, getCookieValue(cookie)).\n\t\t\t\t\t\tWithExpires(&expr).\n\t\t\t\t\t\tWithDomain(cookie.Domain).\n\t\t\t\t\t\tWithPath(cookie.Path).\n\t\t\t\t\t\tWithHTTPOnly(false).\n\t\t\t\t\t\tWithSecure(false).\n\t\t\t\t\t\tDo(ctx)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"could not set chrome cookie %s: %s\", cookie.Name, err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t}),\n\t}\n}\n\n// print cookies whose domain is included in the scraper  config\nfunc printCDPCookies(driverOptions scraperDriverOptions, msg string) chromedp.Action {\n\treturn chromedp.ActionFunc(func(ctx context.Context) error {\n\t\tchromeCookies, err := network.GetCookies().Do(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tscraperDomains := make(map[string]struct{})\n\t\tfor _, ckURL := range driverOptions.Cookies {\n\t\t\tfor _, cookie := range ckURL.Cookies {\n\t\t\t\tscraperDomains[cookie.Domain] = struct{}{}\n\t\t\t}\n\t\t}\n\n\t\tif len(scraperDomains) > 0 { // only print the cookies if they are listed in the scraper\n\t\t\tlogger.Debugf(\"%s\\n\", msg)\n\t\t\tfor i, cookie := range chromeCookies {\n\t\t\t\t_, ok := scraperDomains[cookie.Domain]\n\t\t\t\tif ok {\n\t\t\t\t\tlogger.Debugf(\"[%d]: Name: \\\"%s\\\" Value: \\\"%s\\\"  Domain: \\\"%s\\\"\", i, cookie.Name, cookie.Value, cookie.Domain)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "pkg/scraper/country.go",
    "content": "package scraper\n\nimport (\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nvar countryNameMapping = map[string]string{\n\t\"afghanistan\":                          \"AF\",\n\t\"albania\":                              \"AL\",\n\t\"algeria\":                              \"DZ\",\n\t\"america\":                              \"US\",\n\t\"american\":                             \"US\",\n\t\"american samoa\":                       \"AS\",\n\t\"andorra\":                              \"AD\",\n\t\"angola\":                               \"AO\",\n\t\"anguilla\":                             \"AI\",\n\t\"antarctica\":                           \"AQ\",\n\t\"antigua and barbuda\":                  \"AG\",\n\t\"argentina\":                            \"AR\",\n\t\"armenia\":                              \"AM\",\n\t\"aruba\":                                \"AW\",\n\t\"australia\":                            \"AU\",\n\t\"austria\":                              \"AT\",\n\t\"azerbaijan\":                           \"AZ\",\n\t\"bahamas\":                              \"BS\",\n\t\"bahrain\":                              \"BH\",\n\t\"bangladesh\":                           \"BD\",\n\t\"barbados\":                             \"BB\",\n\t\"belarus\":                              \"BY\",\n\t\"belgium\":                              \"BE\",\n\t\"belize\":                               \"BZ\",\n\t\"benin\":                                \"BJ\",\n\t\"bermuda\":                              \"BM\",\n\t\"bhutan\":                               \"BT\",\n\t\"bolivia\":                              \"BO\",\n\t\"bosnia and herzegovina\":               \"BA\",\n\t\"botswana\":                             \"BW\",\n\t\"bouvet island\":                        \"BV\",\n\t\"brazil\":                               \"BR\",\n\t\"british indian ocean territory\":       \"IO\",\n\t\"brunei darussalam\":                    \"BN\",\n\t\"bulgaria\":                             \"BG\",\n\t\"burkina faso\":                         \"BF\",\n\t\"burundi\":                              \"BI\",\n\t\"cambodia\":                             \"KH\",\n\t\"cameroon\":                             \"CM\",\n\t\"canada\":                               \"CA\",\n\t\"cape verde\":                           \"CV\",\n\t\"cayman islands\":                       \"KY\",\n\t\"central african republic\":             \"CF\",\n\t\"chad\":                                 \"TD\",\n\t\"chile\":                                \"CL\",\n\t\"china\":                                \"CN\",\n\t\"christmas island\":                     \"CX\",\n\t\"cocos (keeling) islands\":              \"CC\",\n\t\"colombia\":                             \"CO\",\n\t\"comoros\":                              \"KM\",\n\t\"congo\":                                \"CG\",\n\t\"congo the democratic republic of the\": \"CD\",\n\t\"cook islands\":                         \"CK\",\n\t\"costa rica\":                           \"CR\",\n\t\"cote d'ivoire\":                        \"CI\",\n\t\"croatia\":                              \"HR\",\n\t\"cuba\":                                 \"CU\",\n\t\"cyprus\":                               \"CY\",\n\t\"czech republic\":                       \"CZ\",\n\t\"czechia\":                              \"CZ\",\n\t\"denmark\":                              \"DK\",\n\t\"djibouti\":                             \"DJ\",\n\t\"dominica\":                             \"DM\",\n\t\"dominican republic\":                   \"DO\",\n\t\"ecuador\":                              \"EC\",\n\t\"egypt\":                                \"EG\",\n\t\"el salvador\":                          \"SV\",\n\t\"equatorial guinea\":                    \"GQ\",\n\t\"eritrea\":                              \"ER\",\n\t\"estonia\":                              \"EE\",\n\t\"ethiopia\":                             \"ET\",\n\t\"falkland islands (malvinas)\":          \"FK\",\n\t\"faroe islands\":                        \"FO\",\n\t\"fiji\":                                 \"FJ\",\n\t\"finland\":                              \"FI\",\n\t\"france\":                               \"FR\",\n\t\"french guiana\":                        \"GF\",\n\t\"french polynesia\":                     \"PF\",\n\t\"french southern territories\":          \"TF\",\n\t\"gabon\":                                \"GA\",\n\t\"gambia\":                               \"GM\",\n\t\"georgia\":                              \"GE\",\n\t\"germany\":                              \"DE\",\n\t\"ghana\":                                \"GH\",\n\t\"gibraltar\":                            \"GI\",\n\t\"greece\":                               \"GR\",\n\t\"greenland\":                            \"GL\",\n\t\"grenada\":                              \"GD\",\n\t\"guadeloupe\":                           \"GP\",\n\t\"guam\":                                 \"GU\",\n\t\"guatemala\":                            \"GT\",\n\t\"guinea\":                               \"GN\",\n\t\"guinea-bissau\":                        \"GW\",\n\t\"guyana\":                               \"GY\",\n\t\"haiti\":                                \"HT\",\n\t\"heard island and mcdonald islands\":    \"HM\",\n\t\"holy see (vatican city state)\":        \"VA\",\n\t\"honduras\":                             \"HN\",\n\t\"hong kong\":                            \"HK\",\n\t\"hungary\":                              \"HU\",\n\t\"iceland\":                              \"IS\",\n\t\"india\":                                \"IN\",\n\t\"indonesia\":                            \"ID\",\n\t\"iran\":                                 \"IR\",\n\t\"iran islamic republic of\":             \"IR\",\n\t\"iraq\":                                 \"IQ\",\n\t\"ireland\":                              \"IE\",\n\t\"israel\":                               \"IL\",\n\t\"italy\":                                \"IT\",\n\t\"jamaica\":                              \"JM\",\n\t\"japan\":                                \"JP\",\n\t\"jordan\":                               \"JO\",\n\t\"kazakhstan\":                           \"KZ\",\n\t\"kenya\":                                \"KE\",\n\t\"kiribati\":                             \"KI\",\n\t\"north korea\":                          \"KP\",\n\t\"south korea\":                          \"KR\",\n\t\"kuwait\":                               \"KW\",\n\t\"kyrgyzstan\":                           \"KG\",\n\t\"lao people's democratic republic\":     \"LA\",\n\t\"latvia\":                               \"LV\",\n\t\"lebanon\":                              \"LB\",\n\t\"lesotho\":                              \"LS\",\n\t\"liberia\":                              \"LR\",\n\t\"libya\":                                \"LY\",\n\t\"liechtenstein\":                        \"LI\",\n\t\"lithuania\":                            \"LT\",\n\t\"luxembourg\":                           \"LU\",\n\t\"macao\":                                \"MO\",\n\t\"madagascar\":                           \"MG\",\n\t\"malawi\":                               \"MW\",\n\t\"malaysia\":                             \"MY\",\n\t\"maldives\":                             \"MV\",\n\t\"mali\":                                 \"ML\",\n\t\"malta\":                                \"MT\",\n\t\"marshall islands\":                     \"MH\",\n\t\"martinique\":                           \"MQ\",\n\t\"mauritania\":                           \"MR\",\n\t\"mauritius\":                            \"MU\",\n\t\"mayotte\":                              \"YT\",\n\t\"mexico\":                               \"MX\",\n\t\"micronesia federated states of\":       \"FM\",\n\t\"moldova\":                              \"MD\",\n\t\"moldova republic of\":                  \"MD\",\n\t\"moldova, republic of\":                 \"MD\",\n\t\"monaco\":                               \"MC\",\n\t\"mongolia\":                             \"MN\",\n\t\"montserrat\":                           \"MS\",\n\t\"morocco\":                              \"MA\",\n\t\"mozambique\":                           \"MZ\",\n\t\"myanmar\":                              \"MM\",\n\t\"namibia\":                              \"NA\",\n\t\"nauru\":                                \"NR\",\n\t\"nepal\":                                \"NP\",\n\t\"netherlands\":                          \"NL\",\n\t\"new caledonia\":                        \"NC\",\n\t\"new zealand\":                          \"NZ\",\n\t\"nicaragua\":                            \"NI\",\n\t\"niger\":                                \"NE\",\n\t\"nigeria\":                              \"NG\",\n\t\"niue\":                                 \"NU\",\n\t\"norfolk island\":                       \"NF\",\n\t\"north macedonia republic of\":          \"MK\",\n\t\"northern mariana islands\":             \"MP\",\n\t\"norway\":                               \"NO\",\n\t\"oman\":                                 \"OM\",\n\t\"pakistan\":                             \"PK\",\n\t\"palau\":                                \"PW\",\n\t\"palestinian territory occupied\":       \"PS\",\n\t\"panama\":                               \"PA\",\n\t\"papua new guinea\":                     \"PG\",\n\t\"paraguay\":                             \"PY\",\n\t\"peru\":                                 \"PE\",\n\t\"philippines\":                          \"PH\",\n\t\"pitcairn\":                             \"PN\",\n\t\"poland\":                               \"PL\",\n\t\"portugal\":                             \"PT\",\n\t\"puerto rico\":                          \"PR\",\n\t\"qatar\":                                \"QA\",\n\t\"reunion\":                              \"RE\",\n\t\"romania\":                              \"RO\",\n\t\"russia\":                               \"RU\",\n\t\"russian federation\":                   \"RU\",\n\t\"rwanda\":                               \"RW\",\n\t\"saint helena\":                         \"SH\",\n\t\"saint kitts and nevis\":                \"KN\",\n\t\"saint lucia\":                          \"LC\",\n\t\"saint pierre and miquelon\":            \"PM\",\n\t\"saint vincent and the grenadines\":     \"VC\",\n\t\"samoa\":                                \"WS\",\n\t\"san marino\":                           \"SM\",\n\t\"sao tome and principe\":                \"ST\",\n\t\"saudi arabia\":                         \"SA\",\n\t\"senegal\":                              \"SN\",\n\t\"seychelles\":                           \"SC\",\n\t\"sierra leone\":                         \"SL\",\n\t\"singapore\":                            \"SG\",\n\t\"slovakia\":                             \"SK\",\n\t\"slovak republic\":                      \"SK\",\n\t\"slovenia\":                             \"SI\",\n\t\"solomon islands\":                      \"SB\",\n\t\"somalia\":                              \"SO\",\n\t\"south africa\":                         \"ZA\",\n\t\"south georgia and the south sandwich islands\": \"GS\",\n\t\"spain\":                                \"ES\",\n\t\"sri lanka\":                            \"LK\",\n\t\"sudan\":                                \"SD\",\n\t\"suriname\":                             \"SR\",\n\t\"svalbard and jan mayen\":               \"SJ\",\n\t\"eswatini\":                             \"SZ\",\n\t\"sweden\":                               \"SE\",\n\t\"switzerland\":                          \"CH\",\n\t\"syrian arab republic\":                 \"SY\",\n\t\"taiwan\":                               \"TW\",\n\t\"tajikistan\":                           \"TJ\",\n\t\"tanzania united republic of\":          \"TZ\",\n\t\"thailand\":                             \"TH\",\n\t\"timor-leste\":                          \"TL\",\n\t\"togo\":                                 \"TG\",\n\t\"tokelau\":                              \"TK\",\n\t\"tonga\":                                \"TO\",\n\t\"trinidad and tobago\":                  \"TT\",\n\t\"tunisia\":                              \"TN\",\n\t\"turkey\":                               \"TR\",\n\t\"turkmenistan\":                         \"TM\",\n\t\"turks and caicos islands\":             \"TC\",\n\t\"tuvalu\":                               \"TV\",\n\t\"uganda\":                               \"UG\",\n\t\"ukraine\":                              \"UA\",\n\t\"united arab emirates\":                 \"AE\",\n\t\"england\":                              \"GB\",\n\t\"great britain\":                        \"GB\",\n\t\"united kingdom\":                       \"GB\",\n\t\"usa\":                                  \"US\",\n\t\"united states\":                        \"US\",\n\t\"united states of america\":             \"US\",\n\t\"united states minor outlying islands\": \"UM\",\n\t\"uruguay\":                              \"UY\",\n\t\"uzbekistan\":                           \"UZ\",\n\t\"vanuatu\":                              \"VU\",\n\t\"venezuela\":                            \"VE\",\n\t\"vietnam\":                              \"VN\",\n\t\"virgin islands british\":               \"VG\",\n\t\"virgin islands u.s.\":                  \"VI\",\n\t\"wallis and futuna\":                    \"WF\",\n\t\"western sahara\":                       \"EH\",\n\t\"yemen\":                                \"YE\",\n\t\"zambia\":                               \"ZM\",\n\t\"zimbabwe\":                             \"ZW\",\n\t\"åland islands\":                        \"AX\",\n\t\"bonaire sint eustatius and saba\":      \"BQ\",\n\t\"curaçao\":                              \"CW\",\n\t\"guernsey\":                             \"GG\",\n\t\"isle of man\":                          \"IM\",\n\t\"jersey\":                               \"JE\",\n\t\"montenegro\":                           \"ME\",\n\t\"saint barthélemy\":                     \"BL\",\n\t\"saint martin (french part)\":           \"MF\",\n\t\"serbia\":                               \"RS\",\n\t\"sint maarten (dutch part)\":            \"SX\",\n\t\"south sudan\":                          \"SS\",\n\t\"kosovo\":                               \"XK\",\n}\n\nfunc resolveCountryName(name *string) *string {\n\tif name == nil {\n\t\treturn nil\n\t}\n\n\ttrimmedName := strings.TrimSpace(*name)\n\tif len(trimmedName) == 2 {\n\t\t// If name is two characters it's likely already an ISO value\n\t\treturn &trimmedName\n\t} else if len(trimmedName) == 0 {\n\t\treturn nil\n\t}\n\n\tv, exists := countryNameMapping[strings.ToLower(trimmedName)]\n\tif exists {\n\t\treturn &v\n\t}\n\n\tlogger.Debugf(\"Scraped country was not recognized: %s\", trimmedName)\n\n\t// return original name\n\treturn &trimmedName\n}\n"
  },
  {
    "path": "pkg/scraper/defined_scraper.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// definedScraper implements the scraper interface using a Definition object.\ntype definedScraper struct {\n\tconfig Definition\n\n\tglobalConf GlobalConfig\n}\n\nfunc scraperFromDefinition(c Definition, globalConfig GlobalConfig) definedScraper {\n\treturn definedScraper{\n\t\tconfig:     c,\n\t\tglobalConf: globalConfig,\n\t}\n}\n\nfunc (g definedScraper) spec() Scraper {\n\treturn g.config.spec()\n}\n\n// fragmentScraper finds an appropriate fragment scraper based on input.\nfunc (g definedScraper) fragmentScraper(input Input) *ByFragmentDefinition {\n\tswitch {\n\tcase input.Performer != nil:\n\t\treturn g.config.PerformerByFragment\n\tcase input.Gallery != nil:\n\t\t// TODO - this should be galleryByQueryFragment\n\t\treturn g.config.GalleryByFragment\n\tcase input.Image != nil:\n\t\t// TODO - this should be imageByImageFragment\n\t\treturn g.config.ImageByFragment\n\tcase input.Scene != nil:\n\t\treturn g.config.SceneByQueryFragment\n\t}\n\n\treturn nil\n}\n\nfunc (g definedScraper) viaFragment(ctx context.Context, client *http.Client, input Input) (ScrapedContent, error) {\n\tstc := g.fragmentScraper(input)\n\tif stc == nil {\n\t\t// If there's no performer fragment scraper in the group, we try to use\n\t\t// the URL scraper. Check if there's an URL in the input, and then shift\n\t\t// to an URL scrape if it's present.\n\t\tif input.Performer != nil && input.Performer.URL != nil && *input.Performer.URL != \"\" {\n\t\t\treturn g.viaURL(ctx, client, *input.Performer.URL, ScrapeContentTypePerformer)\n\t\t}\n\n\t\treturn nil, ErrNotSupported\n\t}\n\n\ts := g.config.getFragmentScraper(*stc, client, g.globalConf)\n\treturn s.scrapeByFragment(ctx, input)\n}\n\nfunc (g definedScraper) viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) {\n\tif g.config.SceneByFragment == nil {\n\t\treturn nil, ErrNotSupported\n\t}\n\n\ts := g.config.getFragmentScraper(*g.config.SceneByFragment, client, g.globalConf)\n\treturn s.scrapeSceneByScene(ctx, scene)\n}\n\nfunc (g definedScraper) viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) {\n\tif g.config.GalleryByFragment == nil {\n\t\treturn nil, ErrNotSupported\n\t}\n\n\ts := g.config.getFragmentScraper(*g.config.GalleryByFragment, client, g.globalConf)\n\treturn s.scrapeGalleryByGallery(ctx, gallery)\n}\n\nfunc (g definedScraper) viaImage(ctx context.Context, client *http.Client, gallery *models.Image) (*models.ScrapedImage, error) {\n\tif g.config.ImageByFragment == nil {\n\t\treturn nil, ErrNotSupported\n\t}\n\n\ts := g.config.getFragmentScraper(*g.config.ImageByFragment, client, g.globalConf)\n\treturn s.scrapeImageByImage(ctx, gallery)\n}\n\nfunc loadUrlCandidates(c Definition, ty ScrapeContentType) []*ByURLDefinition {\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\treturn c.PerformerByURL\n\tcase ScrapeContentTypeScene:\n\t\treturn c.SceneByURL\n\tcase ScrapeContentTypeMovie, ScrapeContentTypeGroup:\n\t\treturn append(c.MovieByURL, c.GroupByURL...)\n\tcase ScrapeContentTypeGallery:\n\t\treturn c.GalleryByURL\n\tcase ScrapeContentTypeImage:\n\t\treturn c.ImageByURL\n\t}\n\n\tpanic(\"loadUrlCandidates: unreachable\")\n}\n\nfunc (g definedScraper) viaURL(ctx context.Context, client *http.Client, url string, ty ScrapeContentType) (ScrapedContent, error) {\n\tcandidates := loadUrlCandidates(g.config, ty)\n\tfor _, scraper := range candidates {\n\t\tif scraper.matchesURL(url) {\n\t\t\tu := replaceURL(url, *scraper) // allow a URL Replace for url-queries\n\t\t\ts := g.config.getURLScraper(*scraper, client, g.globalConf)\n\t\t\tret, err := s.scrapeByURL(ctx, u, ty)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tif ret != nil {\n\t\t\t\treturn ret, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (g definedScraper) viaName(ctx context.Context, client *http.Client, name string, ty ScrapeContentType) ([]ScrapedContent, error) {\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\tif g.config.PerformerByName == nil {\n\t\t\tbreak\n\t\t}\n\n\t\ts := g.config.getNameScraper(*g.config.PerformerByName, client, g.globalConf)\n\t\treturn s.scrapeByName(ctx, name, ty)\n\tcase ScrapeContentTypeScene:\n\t\tif g.config.SceneByName == nil {\n\t\t\tbreak\n\t\t}\n\n\t\ts := g.config.getNameScraper(*g.config.SceneByName, client, g.globalConf)\n\t\treturn s.scrapeByName(ctx, name, ty)\n\t}\n\n\treturn nil, fmt.Errorf(\"%w: cannot load %v by name\", ErrNotSupported, ty)\n}\n\nfunc (g definedScraper) supports(ty ScrapeContentType) bool {\n\treturn g.config.supports(ty)\n}\n\nfunc (g definedScraper) supportsURL(url string, ty ScrapeContentType) bool {\n\treturn g.config.matchesURL(url, ty)\n}\n"
  },
  {
    "path": "pkg/scraper/definition.go",
    "content": "package scraper\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"gopkg.in/yaml.v2\"\n)\n\n// Definition represents a scraper definition (typically) loaded from a YAML configuration file.\ntype Definition struct {\n\tID   string\n\tpath string\n\n\t// The name of the scraper. This is displayed in the UI.\n\tName string `yaml:\"name\"`\n\n\t// Configuration for querying performers by name\n\tPerformerByName *ByNameDefinition `yaml:\"performerByName\"`\n\n\t// Configuration for querying performers by a Performer fragment\n\tPerformerByFragment *ByFragmentDefinition `yaml:\"performerByFragment\"`\n\n\t// Configuration for querying a performer by a URL\n\tPerformerByURL []*ByURLDefinition `yaml:\"performerByURL\"`\n\n\t// Configuration for querying scenes by a Scene fragment\n\tSceneByFragment *ByFragmentDefinition `yaml:\"sceneByFragment\"`\n\n\t// Configuration for querying gallery by a Gallery fragment\n\tGalleryByFragment *ByFragmentDefinition `yaml:\"galleryByFragment\"`\n\n\t// Configuration for querying scenes by name\n\tSceneByName *ByNameDefinition `yaml:\"sceneByName\"`\n\n\t// Configuration for querying scenes by query fragment\n\tSceneByQueryFragment *ByFragmentDefinition `yaml:\"sceneByQueryFragment\"`\n\n\t// Configuration for querying a scene by a URL\n\tSceneByURL []*ByURLDefinition `yaml:\"sceneByURL\"`\n\n\t// Configuration for querying a gallery by a URL\n\tGalleryByURL []*ByURLDefinition `yaml:\"galleryByURL\"`\n\n\t// Configuration for querying an image by a URL\n\tImageByURL []*ByURLDefinition `yaml:\"imageByURL\"`\n\n\t// Configuration for querying image by an Image fragment\n\tImageByFragment *ByFragmentDefinition `yaml:\"imageByFragment\"`\n\n\t// Configuration for querying a movie by a URL - deprecated, use GroupByURL\n\tMovieByURL []*ByURLDefinition `yaml:\"movieByURL\"`\n\n\t// Configuration for querying a group by a URL\n\tGroupByURL []*ByURLDefinition `yaml:\"groupByURL\"`\n\n\t// Scraper debugging options\n\tDebugOptions *scraperDebugOptions `yaml:\"debug\"`\n\n\t// Stash server configuration\n\tStashServer *stashServer `yaml:\"stashServer\"`\n\n\t// Xpath scraping configurations\n\tXPathScrapers mappedScrapers `yaml:\"xPathScrapers\"`\n\n\t// Json scraping configurations\n\tJsonScrapers mappedScrapers `yaml:\"jsonScrapers\"`\n\n\t// Scraping driver options\n\tDriverOptions *scraperDriverOptions `yaml:\"driver\"`\n}\n\nfunc (c Definition) validate() error {\n\tif strings.TrimSpace(c.Name) == \"\" {\n\t\treturn errors.New(\"name must not be empty\")\n\t}\n\n\tif c.PerformerByName != nil {\n\t\tif err := c.PerformerByName.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif c.PerformerByFragment != nil {\n\t\tif err := c.PerformerByFragment.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif c.SceneByFragment != nil {\n\t\tif err := c.SceneByFragment.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, s := range c.PerformerByURL {\n\t\tif err := s.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, s := range c.SceneByURL {\n\t\tif err := s.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(c.MovieByURL) > 0 && len(c.GroupByURL) > 0 {\n\t\treturn errors.New(\"movieByURL disallowed if groupByURL is present\")\n\t}\n\n\tfor _, s := range append(c.MovieByURL, c.GroupByURL...) {\n\t\tif err := s.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype stashServer struct {\n\tURL    string `yaml:\"url\"`\n\tApiKey string `yaml:\"apiKey\"`\n}\n\ntype ActionDefinition struct {\n\tAction  scraperAction `yaml:\"action\"`\n\tScript  []string      `yaml:\"script,flow\"`\n\tScraper string        `yaml:\"scraper\"`\n}\n\nfunc (c ActionDefinition) validate() error {\n\tif !c.Action.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid scraper action\", c.Action)\n\t}\n\n\tif c.Action == scraperActionScript && len(c.Script) == 0 {\n\t\treturn errors.New(\"script is mandatory for script scraper action\")\n\t}\n\n\treturn nil\n}\n\ntype ByURLDefinition struct {\n\tActionDefinition     `yaml:\",inline\"`\n\tURL                  []string             `yaml:\"url,flow\"`\n\tQueryURL             string               `yaml:\"queryURL\"`\n\tQueryURLReplacements queryURLReplacements `yaml:\"queryURLReplace\"`\n}\n\nfunc (c ByURLDefinition) validate() error {\n\tif len(c.URL) == 0 {\n\t\treturn errors.New(\"url is mandatory for scrape by url scrapers\")\n\t}\n\n\treturn c.ActionDefinition.validate()\n}\n\nfunc (c ByURLDefinition) matchesURL(url string) bool {\n\tfor _, thisURL := range c.URL {\n\t\tif strings.Contains(url, thisURL) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\ntype ByFragmentDefinition struct {\n\tActionDefinition `yaml:\",inline\"`\n\n\tQueryURL             string               `yaml:\"queryURL\"`\n\tQueryURLReplacements queryURLReplacements `yaml:\"queryURLReplace\"`\n}\n\ntype ByNameDefinition struct {\n\tActionDefinition `yaml:\",inline\"`\n\tQueryURL         string `yaml:\"queryURL\"`\n}\n\ntype scraperDebugOptions struct {\n\tPrintHTML bool `yaml:\"printHTML\"`\n}\n\ntype scraperCookies struct {\n\tName        string `yaml:\"Name\"`\n\tValue       string `yaml:\"Value\"`\n\tValueRandom int    `yaml:\"ValueRandom\"`\n\tDomain      string `yaml:\"Domain\"`\n\tPath        string `yaml:\"Path\"`\n}\n\ntype cookieOptions struct {\n\tCookieURL string            `yaml:\"CookieURL\"`\n\tCookies   []*scraperCookies `yaml:\"Cookies\"`\n}\n\ntype clickOptions struct {\n\tXPath string `yaml:\"xpath\"`\n\tSleep int    `yaml:\"sleep\"`\n}\n\ntype header struct {\n\tKey   string `yaml:\"Key\"`\n\tValue string `yaml:\"Value\"`\n}\n\ntype scraperDriverOptions struct {\n\tUseCDP  bool             `yaml:\"useCDP\"`\n\tSleep   int              `yaml:\"sleep\"`\n\tClicks  []*clickOptions  `yaml:\"clicks\"`\n\tCookies []*cookieOptions `yaml:\"cookies\"`\n\tHeaders []*header        `yaml:\"headers\"`\n}\n\nfunc loadConfigFromYAML(id string, reader io.Reader) (*Definition, error) {\n\tret := &Definition{}\n\n\tparser := yaml.NewDecoder(reader)\n\tparser.SetStrict(true)\n\terr := parser.Decode(&ret)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret.ID = id\n\n\tif err := ret.validate(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc loadConfigFromYAMLFile(path string) (*Definition, error) {\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\t// set id to the filename\n\tid := filepath.Base(path)\n\tid = id[:strings.LastIndex(id, \".\")]\n\n\tret, err := loadConfigFromYAML(id, file)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret.path = path\n\n\treturn ret, nil\n}\n\nfunc (c Definition) spec() Scraper {\n\tret := Scraper{\n\t\tID:   c.ID,\n\t\tName: c.Name,\n\t}\n\n\tperformer := ScraperSpec{}\n\tif c.PerformerByName != nil {\n\t\tperformer.SupportedScrapes = append(performer.SupportedScrapes, ScrapeTypeName)\n\t}\n\tif c.PerformerByFragment != nil {\n\t\tperformer.SupportedScrapes = append(performer.SupportedScrapes, ScrapeTypeFragment)\n\t}\n\tif len(c.PerformerByURL) > 0 {\n\t\tperformer.SupportedScrapes = append(performer.SupportedScrapes, ScrapeTypeURL)\n\t\tfor _, v := range c.PerformerByURL {\n\t\t\tperformer.Urls = append(performer.Urls, v.URL...)\n\t\t}\n\t}\n\n\tif len(performer.SupportedScrapes) > 0 {\n\t\tret.Performer = &performer\n\t}\n\n\tscene := ScraperSpec{}\n\tif c.SceneByFragment != nil {\n\t\tscene.SupportedScrapes = append(scene.SupportedScrapes, ScrapeTypeFragment)\n\t}\n\tif c.SceneByName != nil && c.SceneByQueryFragment != nil {\n\t\tscene.SupportedScrapes = append(scene.SupportedScrapes, ScrapeTypeName)\n\t}\n\tif len(c.SceneByURL) > 0 {\n\t\tscene.SupportedScrapes = append(scene.SupportedScrapes, ScrapeTypeURL)\n\t\tfor _, v := range c.SceneByURL {\n\t\t\tscene.Urls = append(scene.Urls, v.URL...)\n\t\t}\n\t}\n\n\tif len(scene.SupportedScrapes) > 0 {\n\t\tret.Scene = &scene\n\t}\n\n\tgallery := ScraperSpec{}\n\tif c.GalleryByFragment != nil {\n\t\tgallery.SupportedScrapes = append(gallery.SupportedScrapes, ScrapeTypeFragment)\n\t}\n\tif len(c.GalleryByURL) > 0 {\n\t\tgallery.SupportedScrapes = append(gallery.SupportedScrapes, ScrapeTypeURL)\n\t\tfor _, v := range c.GalleryByURL {\n\t\t\tgallery.Urls = append(gallery.Urls, v.URL...)\n\t\t}\n\t}\n\n\tif len(gallery.SupportedScrapes) > 0 {\n\t\tret.Gallery = &gallery\n\t}\n\n\timage := ScraperSpec{}\n\tif c.ImageByFragment != nil {\n\t\timage.SupportedScrapes = append(image.SupportedScrapes, ScrapeTypeFragment)\n\t}\n\tif len(c.ImageByURL) > 0 {\n\t\timage.SupportedScrapes = append(image.SupportedScrapes, ScrapeTypeURL)\n\t\tfor _, v := range c.ImageByURL {\n\t\t\timage.Urls = append(image.Urls, v.URL...)\n\t\t}\n\t}\n\n\tif len(image.SupportedScrapes) > 0 {\n\t\tret.Image = &image\n\t}\n\n\tgroup := ScraperSpec{}\n\tif len(c.MovieByURL) > 0 || len(c.GroupByURL) > 0 {\n\t\tgroup.SupportedScrapes = append(group.SupportedScrapes, ScrapeTypeURL)\n\t\tfor _, v := range append(c.MovieByURL, c.GroupByURL...) {\n\t\t\tgroup.Urls = append(group.Urls, v.URL...)\n\t\t}\n\t}\n\n\tif len(group.SupportedScrapes) > 0 {\n\t\tret.Movie = &group\n\t\tret.Group = &group\n\t}\n\n\treturn ret\n}\n\nfunc (c Definition) supports(ty ScrapeContentType) bool {\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\treturn c.PerformerByName != nil || c.PerformerByFragment != nil || len(c.PerformerByURL) > 0\n\tcase ScrapeContentTypeScene:\n\t\treturn (c.SceneByName != nil && c.SceneByQueryFragment != nil) || c.SceneByFragment != nil || len(c.SceneByURL) > 0\n\tcase ScrapeContentTypeGallery:\n\t\treturn c.GalleryByFragment != nil || len(c.GalleryByURL) > 0\n\tcase ScrapeContentTypeImage:\n\t\treturn c.ImageByFragment != nil || len(c.ImageByURL) > 0\n\tcase ScrapeContentTypeMovie, ScrapeContentTypeGroup:\n\t\treturn len(c.MovieByURL) > 0 || len(c.GroupByURL) > 0\n\t}\n\n\tpanic(\"Unhandled ScrapeContentType\")\n}\n\nfunc (c Definition) matchesURL(url string, ty ScrapeContentType) bool {\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\tfor _, scraper := range c.PerformerByURL {\n\t\t\tif scraper.matchesURL(url) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\tcase ScrapeContentTypeScene:\n\t\tfor _, scraper := range c.SceneByURL {\n\t\t\tif scraper.matchesURL(url) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\tcase ScrapeContentTypeGallery:\n\t\tfor _, scraper := range c.GalleryByURL {\n\t\t\tif scraper.matchesURL(url) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\tcase ScrapeContentTypeImage:\n\t\tfor _, scraper := range c.ImageByURL {\n\t\t\tif scraper.matchesURL(url) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\tcase ScrapeContentTypeMovie, ScrapeContentTypeGroup:\n\t\tfor _, scraper := range c.GroupByURL {\n\t\t\tif scraper.matchesURL(url) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\tfor _, scraper := range c.MovieByURL {\n\t\t\tif scraper.matchesURL(url) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "pkg/scraper/freeones.go",
    "content": "package scraper\n\nimport (\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// FreeonesScraperID is the scraper ID for the built-in Freeones scraper\nconst FreeonesScraperID = \"builtin_freeones\"\n\n// 537: stolen from: https://github.com/stashapp/CommunityScrapers/blob/master/scrapers/FreeonesCommunity.yml\nconst freeonesScraperConfig = `\nname: Freeones\nperformerByName:\n  action: scrapeXPath\n  queryURL: https://www.freeones.com/babes?q={}&v=teasers&s=relevance&l=96&m%5BcanPreviewFeatures%5D=0\n  scraper: performerSearch\nperformerByURL:\n  - action: scrapeXPath\n    url:\n      - freeones.xxx\n      - freeones.com\n    scraper: performerScraper\n\nxPathScrapers:\n  performerSearch:\n    performer:\n      Name: //div[@id=\"search-result\"]//p[@data-test=\"subject-name\"]/text()\n      URL:\n        selector: //div[@id=\"search-result\"]//div[@data-test=\"teaser-subject\"]/a/@href\n        postProcess:\n          - replace:\n              - regex: ^\n                with: https://www.freeones.com\n              - regex: /feed$\n                with: /bio\n\n  performerScraper:\n    performer:\n      Name:\n        selector: //h1\n        postProcess:\n          - replace:\n              - regex: (.+)\\sidentifies.+\n                with: $1\n      URL: //link[@rel=\"alternate\" and @hreflang=\"x-default\"]/@href\n      Twitter: //form//a[contains(@href,'twitter.com/')]/@href\n      Instagram: //form//a[contains(@href,'instagram.com/')]/@href\n      Birthdate:\n        selector: //span[@data-test=\"link_span_dateOfBirth\"]/text()\n        postProcess:\n          - parseDate: January 2, 2006\n      Ethnicity:\n        selector: //span[@data-test=\"link_span_ethnicity\"]\n        postProcess:\n          - map:\n              Asian: Asian\n              Caucasian: White\n              Black: Black\n              Latin: Hispanic\n      Country:\n        selector: //a[@data-test=\"link_placeOfBirth\"][contains(@href, 'country')]/span/text()\n        postProcess:\n          - map:\n              United States: \"USA\"\n      EyeColor: //span[text()='Eye Color:']/following-sibling::span/a/span/text()\n      Height:\n        selector: //span[text()='Height:']/following-sibling::span/a\n        postProcess:\n          - replace:\n            - regex: \\scm\n              with: \"\"\n          - map:\n              Unknown: \"\"\n      Measurements:\n        selector: //span[(@data-test='link_span_bra') or (@data-test='link_span_waist') or (@data-test='link_span_hip')]\n        concat: \" - \"\n        postProcess:\n          - replace:\n              - regex: \\sIn\n                with: \"\"\n          - map:\n              Unknown: \"\"\n      FakeTits:\n        selector: //span[text()='Boobs:']/following-sibling::span/a\n        postProcess:\n          - map:\n              Unknown: \"\"\n              Fake: \"Yes\"\n              Natural: \"No\"\n      CareerLength:\n        selector: //div[contains(@class,'timeline-horizontal')]//p[@class='m-0']\n        concat: \"-\"\n      Aliases:\n        selector: //span[@data-test='link_span_aliases']/text()\n        concat: \", \"\n      Tattoos:\n        selector: //span[text()='Tattoo locations:']/following-sibling::span\n        postProcess:\n          - map:\n              Unknown: \"\"\n      Piercings:\n        selector: //span[text()='Piercing locations:']/following-sibling::span\n        postProcess:\n          - map:\n              Unknown: \"\"\n      Image:\n        selector: //div[contains(@class,'image-container')]//a/img/@src\n      Gender:\n        selector: //h1/*[1]/*[1]/text()Add commentMore actions\n        postProcess:\n          - replace:\n            - regex: .+ identifies as (.+)\n              with: $1\n      DeathDate:\n        selector: //div[contains(text(),'Passed away on')]\n        postProcess:\n          - replace:\n              - regex: Passed away on (.+) at the age of \\d+\n                with: $1\n          - parseDate: January 2, 2006\n      HairColor: //span[@data-test=\"link_span_hair_color\"]\n      Weight:\n        selector: //span[@data-test=\"link_span_weight\"]\n        postProcess:\n          - replace:\n            - regex: \\skg\n              with: \"\"\n\n# Last Updated June 22, 2025\n`\n\nfunc getFreeonesScraper(globalConfig GlobalConfig) scraper {\n\tyml := freeonesScraperConfig\n\n\tc, err := loadConfigFromYAML(FreeonesScraperID, strings.NewReader(yml))\n\tif err != nil {\n\t\tlogger.Fatalf(\"Error loading builtin freeones scraper: %s\", err.Error())\n\t}\n\n\treturn scraperFromDefinition(*c, globalConfig)\n}\n"
  },
  {
    "path": "pkg/scraper/graphql.go",
    "content": "package scraper\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/hasura/go-graphql-client\"\n)\n\ntype graphqlErrors []error\n\nfunc (e graphqlErrors) Error() string {\n\tb := strings.Builder{}\n\tfor _, err := range e {\n\t\t_, _ = b.WriteString(err.Error())\n\t}\n\treturn b.String()\n}\n\ntype graphqlError struct {\n\terr graphql.Error\n}\n\nfunc (e graphqlError) Error() string {\n\tunwrapped := e.err.Unwrap()\n\tif unwrapped != nil {\n\t\tvar networkErr graphql.NetworkError\n\t\tif errors.As(unwrapped, &networkErr) {\n\t\t\tif networkErr.StatusCode() == 422 {\n\t\t\t\treturn networkErr.Body()\n\t\t\t}\n\t\t}\n\t}\n\treturn e.err.Error()\n}\n\n// convertGraphqlError converts a graphql.Error or graphql.Errors into an error with a useful message.\n// graphql.Error swallows important information, so we need to convert it to a more useful error type.\nfunc convertGraphqlError(err error) error {\n\tvar gqlErrs graphql.Errors\n\tif errors.As(err, &gqlErrs) {\n\t\tret := make(graphqlErrors, len(gqlErrs))\n\t\tfor i, e := range gqlErrs {\n\t\t\tret[i] = convertGraphqlError(e)\n\t\t}\n\t\treturn ret\n\t}\n\n\tvar gqlErr graphql.Error\n\tif errors.As(err, &gqlErr) {\n\t\treturn graphqlError{gqlErr}\n\t}\n\n\treturn err\n}\n"
  },
  {
    "path": "pkg/scraper/image.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc setPerformerImage(ctx context.Context, client *http.Client, p *models.ScrapedPerformer, globalConfig GlobalConfig) error {\n\t// backwards compatibility: we fetch the image if it's a URL and set it to the first image\n\t// Image is deprecated, so only do this if Images is unset\n\tif p.Image == nil || len(p.Images) > 0 {\n\t\t// nothing to do\n\t\treturn nil\n\t}\n\n\t// don't try to get the image if it doesn't appear to be a URL\n\tif !strings.HasPrefix(*p.Image, \"http\") {\n\t\tp.Images = []string{*p.Image}\n\t\treturn nil\n\t}\n\n\timg, err := getImage(ctx, *p.Image, client, globalConfig)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tp.Image = img\n\t// Image is deprecated. Use images instead\n\tp.Images = []string{*img}\n\n\treturn nil\n}\n\nfunc setStudioImage(ctx context.Context, client *http.Client, p *models.ScrapedStudio, globalConfig GlobalConfig) error {\n\t// backwards compatibility: we fetch the image if it's a URL and set it to the first image\n\t// Image is deprecated, so only do this if Images is unset\n\tif p.Image == nil || len(p.Images) > 0 {\n\t\t// nothing to do\n\t\treturn nil\n\t}\n\n\t// don't try to get the image if it doesn't appear to be a URL\n\tif !strings.HasPrefix(*p.Image, \"http\") {\n\t\tp.Images = []string{*p.Image}\n\t\treturn nil\n\t}\n\n\timg, err := getImage(ctx, *p.Image, client, globalConfig)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tp.Image = img\n\t// Image is deprecated. Use images instead\n\tp.Images = []string{*img}\n\n\treturn nil\n}\n\nfunc processImageField(ctx context.Context, imageField *string, client *http.Client, globalConfig GlobalConfig) error {\n\tif imageField == nil {\n\t\treturn nil\n\t}\n\n\t// don't try to get the image if it doesn't appear to be a URL\n\t// this allows scrapers to return base64 data URIs directly\n\tif !strings.HasPrefix(*imageField, \"http\") {\n\t\treturn nil\n\t}\n\n\timg, err := getImage(ctx, *imageField, client, globalConfig)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t*imageField = *img\n\treturn nil\n}\n\ntype imageGetter struct {\n\tclient          *http.Client\n\tglobalConfig    GlobalConfig\n\trequestModifier func(req *http.Request)\n}\n\nfunc (i *imageGetter) getImage(ctx context.Context, url string) (*string, error) {\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuserAgent := i.globalConfig.GetScraperUserAgent()\n\tif userAgent != \"\" {\n\t\treq.Header.Set(\"User-Agent\", userAgent)\n\t}\n\n\t// assume is a URL for now\n\n\t// set the host of the URL as the referer\n\tif req.URL.Scheme != \"\" {\n\t\treq.Header.Set(\"Referer\", req.URL.Scheme+\"://\"+req.Host+\"/\")\n\t}\n\n\tif i.requestModifier != nil {\n\t\ti.requestModifier(req)\n\t}\n\n\tresp, err := i.client.Do(req)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif resp.StatusCode >= 400 {\n\t\treturn nil, fmt.Errorf(\"http error %d\", resp.StatusCode)\n\t}\n\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// determine the image type and set the base64 type\n\tcontentType := resp.Header.Get(\"Content-Type\")\n\tif contentType == \"\" {\n\t\tcontentType = http.DetectContentType(body)\n\t}\n\n\timg := \"data:\" + contentType + \";base64,\" + utils.GetBase64StringFromData(body)\n\treturn &img, nil\n}\n\nfunc getImage(ctx context.Context, url string, client *http.Client, globalConfig GlobalConfig) (*string, error) {\n\tg := imageGetter{\n\t\tclient:       client,\n\t\tglobalConfig: globalConfig,\n\t}\n\n\treturn g.getImage(ctx, url)\n}\n\nfunc getStashPerformerImage(ctx context.Context, stashURL string, performerID string, imageGetter imageGetter) (*string, error) {\n\treturn imageGetter.getImage(ctx, stashURL+\"/performer/\"+performerID+\"/image\")\n}\n\nfunc getStashSceneImage(ctx context.Context, stashURL string, sceneID string, imageGetter imageGetter) (*string, error) {\n\treturn imageGetter.getImage(ctx, stashURL+\"/scene/\"+sceneID+\"/screenshot\")\n}\n"
  },
  {
    "path": "pkg/scraper/json.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/tidwall/gjson\"\n)\n\ntype jsonScraper struct {\n\tdefinition   Definition\n\tglobalConfig GlobalConfig\n\tclient       *http.Client\n}\n\nfunc (s *jsonScraper) getJsonScraper(name string) (*mappedScraper, error) {\n\tret, ok := s.definition.JsonScrapers[name]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"json scraper with name %s not found in config\", name)\n\t}\n\n\treturn &ret, nil\n}\n\nfunc (s *jsonScraper) loadURL(ctx context.Context, url string) (string, error) {\n\tr, err := loadURL(ctx, url, s.client, s.definition, s.globalConfig)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tlogger.Infof(\"loadURL (%s)\\n\", url)\n\tdoc, err := io.ReadAll(r)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tdocStr := string(doc)\n\tif !gjson.Valid(docStr) {\n\t\treturn \"\", errors.New(\"not valid json\")\n\t}\n\n\tif s.definition.DebugOptions != nil && s.definition.DebugOptions.PrintHTML {\n\t\tlogger.Infof(\"loadURL (%s) response: \\n%s\", url, docStr)\n\t}\n\n\treturn docStr, err\n}\n\ntype jsonURLScraper struct {\n\tjsonScraper\n\tdefinition ByURLDefinition\n}\n\nfunc (s *jsonURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) {\n\tscraper, err := s.getJsonScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getJsonQuery(doc, url)\n\t// if these just return the return values from scraper.scrape* functions then\n\t// it ends up returning ScrapedContent(nil) rather than nil\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\tret, err := scraper.scrapePerformer(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\tcase ScrapeContentTypeScene:\n\t\tret, err := scraper.scrapeScene(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\tcase ScrapeContentTypeGallery:\n\t\tret, err := scraper.scrapeGallery(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\tcase ScrapeContentTypeImage:\n\t\tret, err := scraper.scrapeImage(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\tcase ScrapeContentTypeMovie, ScrapeContentTypeGroup:\n\t\tret, err := scraper.scrapeGroup(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\t}\n\n\treturn nil, ErrNotSupported\n}\n\ntype jsonNameScraper struct {\n\tjsonScraper\n\tdefinition ByNameDefinition\n}\n\nfunc (s *jsonNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) {\n\tscraper, err := s.getJsonScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tconst placeholder = \"{}\"\n\n\t// replace the placeholder string with the URL-escaped name\n\tescapedName := url.QueryEscape(name)\n\n\turl := s.definition.QueryURL\n\turl = strings.ReplaceAll(url, placeholder, escapedName)\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getJsonQuery(doc, url)\n\tq.setType(SearchQuery)\n\n\tvar content []ScrapedContent\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\tperformers, err := scraper.scrapePerformers(ctx, q)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, p := range performers {\n\t\t\tcontent = append(content, p)\n\t\t}\n\n\t\treturn content, nil\n\tcase ScrapeContentTypeScene:\n\t\tscenes, err := scraper.scrapeScenes(ctx, q)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, s := range scenes {\n\t\t\tcontent = append(content, s)\n\t\t}\n\n\t\treturn content, nil\n\t}\n\n\treturn nil, ErrNotSupported\n}\n\ntype jsonFragmentScraper struct {\n\tjsonScraper\n\tdefinition ByFragmentDefinition\n}\n\nfunc (s *jsonFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) {\n\t// construct the URL\n\tqueryURL := queryURLParametersFromScene(scene)\n\tif s.definition.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(s.definition.QueryURLReplacements)\n\t}\n\turl := queryURL.constructURL(s.definition.QueryURL)\n\n\tscraper, err := s.getJsonScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getJsonQuery(doc, url)\n\treturn scraper.scrapeScene(ctx, q)\n}\n\nfunc (s *jsonFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) {\n\tswitch {\n\tcase input.Gallery != nil:\n\t\treturn nil, fmt.Errorf(\"%w: cannot use a json scraper as a gallery fragment scraper\", ErrNotSupported)\n\tcase input.Performer != nil:\n\t\treturn nil, fmt.Errorf(\"%w: cannot use a json scraper as a performer fragment scraper\", ErrNotSupported)\n\tcase input.Scene == nil:\n\t\treturn nil, fmt.Errorf(\"%w: scene input is nil\", ErrNotSupported)\n\t}\n\n\tscene := *input.Scene\n\n\t// construct the URL\n\tqueryURL := queryURLParametersFromScrapedScene(scene)\n\tif s.definition.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(s.definition.QueryURLReplacements)\n\t}\n\turl := queryURL.constructURL(s.definition.QueryURL)\n\n\tscraper, err := s.getJsonScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getJsonQuery(doc, url)\n\treturn scraper.scrapeScene(ctx, q)\n}\n\nfunc (s *jsonFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {\n\t// construct the URL\n\tqueryURL := queryURLParametersFromImage(image)\n\tif s.definition.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(s.definition.QueryURLReplacements)\n\t}\n\turl := queryURL.constructURL(s.definition.QueryURL)\n\n\tscraper, err := s.getJsonScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getJsonQuery(doc, url)\n\treturn scraper.scrapeImage(ctx, q)\n}\n\nfunc (s *jsonFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) {\n\t// construct the URL\n\tqueryURL := queryURLParametersFromGallery(gallery)\n\tif s.definition.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(s.definition.QueryURLReplacements)\n\t}\n\turl := queryURL.constructURL(s.definition.QueryURL)\n\n\tscraper, err := s.getJsonScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getJsonQuery(doc, url)\n\treturn scraper.scrapeGallery(ctx, q)\n}\n\nfunc (s *jsonScraper) getJsonQuery(doc string, url string) *jsonQuery {\n\treturn &jsonQuery{\n\t\tdoc:     doc,\n\t\tscraper: s,\n\t\turl:     url,\n\t}\n}\n\ntype jsonQuery struct {\n\tdoc       string\n\tscraper   *jsonScraper\n\tqueryType QueryType\n\turl       string\n}\n\nfunc (q *jsonQuery) getType() QueryType {\n\treturn q.queryType\n}\n\nfunc (q *jsonQuery) setType(t QueryType) {\n\tq.queryType = t\n}\n\nfunc (q *jsonQuery) getURL() string {\n\treturn q.url\n}\n\nfunc (q *jsonQuery) runQuery(selector string) ([]string, error) {\n\tvalue := gjson.Get(q.doc, selector)\n\n\tif !value.Exists() {\n\t\t// many possible reasons why the selector may not be in the json object\n\t\t// and not all are errors.\n\t\t// Just return nil\n\t\treturn nil, nil\n\t}\n\n\tvar ret []string\n\tif value.IsArray() {\n\t\tvalue.ForEach(func(k, v gjson.Result) bool {\n\t\t\tret = append(ret, v.String())\n\t\t\treturn true\n\t\t})\n\t} else {\n\t\tret = append(ret, value.String())\n\t}\n\n\treturn ret, nil\n}\n\nfunc (q *jsonQuery) subScrape(ctx context.Context, value string) mappedQuery {\n\tdoc, err := q.scraper.loadURL(ctx, value)\n\n\tif err != nil {\n\t\tlogger.Warnf(\"Error getting URL '%s' for sub-scraper: %s\", value, err.Error())\n\t\treturn nil\n\t}\n\n\treturn q.scraper.getJsonQuery(doc, value)\n}\n"
  },
  {
    "path": "pkg/scraper/json_test.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"gopkg.in/yaml.v2\"\n)\n\nfunc TestJsonPerformerScraper(t *testing.T) {\n\tconst yamlStr = `name: Test\njsonScrapers:\n  performerScraper:\n    common:\n      $extras: data.extras\n    performer:\n      Name: data.name\n      Gender: $extras.gender\n      Birthdate: $extras.birthday\n      Ethnicity: $extras.ethnicity\n      Height: $extras.height\n      Measurements: $extras.measurements\n      Tattoos: $extras.tattoos\n      Piercings: $extras.piercings\n      Aliases: data.aliases\n      Image: data.image\n      Details: data.bio\n      HairColor: $extras.hair_colour\n      Weight: $extras.weight\n`\n\n\tconst json = `\n{\n\t\"data\": {\n        \"id\": \"2cd4146b-637d-49b1-8ff9-19d4a06947bb\",\n        \"name\": \"Mia Malkova\",\n        \"bio\": \"Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova. arrow_drop_up\",\n        \"extras\": {\n            \"gender\": \"Female\",\n            \"birthday\": \"1992-07-01\",\n            \"birthday_timestamp\": 709948800,\n            \"birthplace\": \"Palm Springs, California, United States\",\n            \"active\": 1,\n            \"astrology\": \"Cancer (Jun 21 - Jul 22)\",\n            \"ethnicity\": \"Caucasian\",\n            \"nationality\": \"United States\",\n            \"hair_colour\": \"Blonde\",\n            \"weight\": 57,\n            \"height\": \"5'6\\\" (or 167 cm)\",\n            \"measurements\": \"34-26-36\",\n            \"cupsize\": \"34C (75C)\",\n            \"tattoos\": \"None\",\n            \"piercings\": \"Navel\",\n            \"first_seen\": null\n        },\n        \"aliases\": [\n            \"Mia Bliss\",\n            \"Madison Clover\",\n            \"Madison Swan\",\n            \"Mia Mountain\",\n            \"Mia M.\",\n            \"Mia Malvoka\",\n            \"Mia Molkova\",\n            \"Mia Thomas\"\n        ],\n\t\t\"image\": \"https:\\/\\/thumb.metadataapi.net\\/unsafe\\/1000x1500\\/smart\\/filters:sharpen():upscale()\\/https%3A%2F%2Fcdn.metadataapi.net%2Fperformer%2F49%2F05%2F30%2Fade2255dc065032a89ebb23f0e038fa%2Fposter%2Fmia-malkova.jpg%3Fid1582610531\"\n\t}\n}\n`\n\n\tc := &Definition{}\n\terr := yaml.Unmarshal([]byte(yamlStr), &c)\n\n\tif err != nil {\n\t\tt.Fatalf(\"Error loading yaml: %s\", err.Error())\n\t}\n\n\t// perform scrape using json string\n\tperformerScraper := c.JsonScrapers[\"performerScraper\"]\n\n\tq := &jsonQuery{\n\t\tdoc: json,\n\t}\n\n\tscrapedPerformer, err := performerScraper.scrapePerformer(context.Background(), q)\n\tif err != nil {\n\t\tt.Fatalf(\"Error scraping performer: %s\", err.Error())\n\t}\n\n\tverifyField(t, \"Mia Malkova\", scrapedPerformer.Name, \"Name\")\n\tverifyField(t, \"Female\", scrapedPerformer.Gender, \"Gender\")\n\tverifyField(t, \"1992-07-01\", scrapedPerformer.Birthdate, \"Birthdate\")\n\tverifyField(t, \"Caucasian\", scrapedPerformer.Ethnicity, \"Ethnicity\")\n\tverifyField(t, \"5'6\\\" (or 167 cm)\", scrapedPerformer.Height, \"Height\")\n\tverifyField(t, \"None\", scrapedPerformer.Tattoos, \"Tattoos\")\n\tverifyField(t, \"Navel\", scrapedPerformer.Piercings, \"Piercings\")\n\tverifyField(t, \"Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova. arrow_drop_up\", scrapedPerformer.Details, \"Details\")\n\tverifyField(t, \"Blonde\", scrapedPerformer.HairColor, \"HairColor\")\n\tverifyField(t, \"57\", scrapedPerformer.Weight, \"Weight\")\n\n\tnotFoundJson := `\n{\n    \"data\": null\n}`\n\n\tq = &jsonQuery{\n\t\tdoc: notFoundJson,\n\t}\n\n\tscrapedPerformer, err = performerScraper.scrapePerformer(context.Background(), q)\n\tif err != nil {\n\t\tt.Fatalf(\"Error scraping performer: %s\", err.Error())\n\t}\n\n\tif scrapedPerformer != nil {\n\t\tt.Errorf(\"expected nil scraped performer when not found, got %v\", scrapedPerformer)\n\t}\n}\n"
  },
  {
    "path": "pkg/scraper/mapped.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype mappedQuery interface {\n\trunQuery(selector string) ([]string, error)\n\tgetType() QueryType\n\tsetType(QueryType)\n\tsubScrape(ctx context.Context, value string) mappedQuery\n\tgetURL() string\n}\n\ntype mappedScrapers map[string]mappedScraper\n\ntype mappedScraper struct {\n\tCommon    commonMappedConfig            `yaml:\"common\"`\n\tScene     *mappedSceneScraperConfig     `yaml:\"scene\"`\n\tGallery   *mappedGalleryScraperConfig   `yaml:\"gallery\"`\n\tImage     *mappedImageScraperConfig     `yaml:\"image\"`\n\tPerformer *mappedPerformerScraperConfig `yaml:\"performer\"`\n\tGroup     *mappedMovieScraperConfig     `yaml:\"group\"`\n\n\t// deprecated\n\tMovie *mappedMovieScraperConfig `yaml:\"movie\"`\n}\n\nfunc urlsIsMulti(key string) bool {\n\treturn key == \"URLs\"\n}\n\nfunc (s mappedScraper) scrapePerformer(ctx context.Context, q mappedQuery) (*models.ScrapedPerformer, error) {\n\tvar ret *models.ScrapedPerformer\n\n\tperformerMap := s.Performer\n\tif performerMap == nil {\n\t\treturn nil, nil\n\t}\n\n\tperformerTagsMap := performerMap.Tags\n\n\tresults := performerMap.process(ctx, q, s.Common, urlsIsMulti)\n\n\t// now apply the tags\n\tvar tagResults mappedResults\n\n\tif performerTagsMap != nil {\n\t\tlogger.Debug(`Processing performer tags:`)\n\t\ttagResults = performerTagsMap.process(ctx, q, s.Common, nil)\n\t}\n\n\tif len(results) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tif len(results) > 0 {\n\t\tret = results[0].scrapedPerformer()\n\t\tret.Tags = tagResults.scrapedTags()\n\t}\n\n\treturn ret, nil\n}\n\nfunc (s mappedScraper) scrapePerformers(ctx context.Context, q mappedQuery) ([]*models.ScrapedPerformer, error) {\n\tperformerMap := s.Performer\n\tif performerMap == nil {\n\t\treturn nil, nil\n\t}\n\n\t// isMulti is nil because it will behave incorrect when scraping multiple performers\n\tresults := performerMap.process(ctx, q, s.Common, nil)\n\treturn results.scrapedPerformers(), nil\n}\n\n// processSceneRelationships sets the relationships on the models.ScrapedScene. It returns true if any relationships were set.\nfunc (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQuery, resultIndex int, ret *models.ScrapedScene) bool {\n\tsceneScraperConfig := s.Scene\n\n\tscenePerformersMap := sceneScraperConfig.Performers\n\tsceneTagsMap := sceneScraperConfig.Tags\n\tsceneStudioMap := sceneScraperConfig.Studio\n\tsceneMoviesMap := sceneScraperConfig.Movies\n\tsceneGroupsMap := sceneScraperConfig.Groups\n\n\tret.Performers = s.processPerformers(ctx, scenePerformersMap, q)\n\n\tif sceneTagsMap != nil {\n\t\tlogger.Debug(`Processing scene tags:`)\n\n\t\tret.Tags = sceneTagsMap.process(ctx, q, s.Common, nil).scrapedTags()\n\t}\n\n\tif sceneStudioMap != nil {\n\t\tlogger.Debug(`Processing scene studio:`)\n\t\tstudioResults := sceneStudioMap.process(ctx, q, s.Common, nil)\n\n\t\tif len(studioResults) > 0 && resultIndex < len(studioResults) {\n\t\t\t// when doing a `search` scrape get the related studio\n\t\t\tstudio := studioResults[resultIndex].scrapedStudio()\n\t\t\tret.Studio = studio\n\t\t}\n\t}\n\n\tif sceneMoviesMap != nil {\n\t\tlogger.Debug(`Processing scene movies:`)\n\t\tret.Movies = sceneMoviesMap.process(ctx, q, s.Common, nil).scrapedMovies()\n\t}\n\n\tif sceneGroupsMap != nil {\n\t\tlogger.Debug(`Processing scene groups:`)\n\t\tret.Groups = sceneGroupsMap.process(ctx, q, s.Common, nil).scrapedGroups()\n\t}\n\n\treturn len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 || len(ret.Groups) > 0\n}\n\nfunc (s mappedScraper) processPerformers(ctx context.Context, performersMap mappedPerformerScraperConfig, q mappedQuery) []*models.ScrapedPerformer {\n\tvar ret []*models.ScrapedPerformer\n\n\t// now apply the performers and tags\n\tif performersMap.mappedConfig != nil {\n\t\tlogger.Debug(`Processing performers:`)\n\t\t// isMulti is nil because it will behave incorrect when scraping multiple performers\n\t\tperformerResults := performersMap.process(ctx, q, s.Common, nil)\n\n\t\tscenePerformerTagsMap := performersMap.Tags\n\n\t\t// process performer tags once\n\t\tvar performerTagResults mappedResults\n\t\tif scenePerformerTagsMap != nil {\n\t\t\tperformerTagResults = scenePerformerTagsMap.process(ctx, q, s.Common, nil)\n\t\t}\n\n\t\tfor _, p := range performerResults {\n\t\t\tperformer := p.scrapedPerformer()\n\n\t\t\tfor _, p := range performerTagResults {\n\t\t\t\ttag := p.scrapedTag()\n\t\t\t\tperformer.Tags = append(performer.Tags, tag)\n\t\t\t}\n\n\t\t\tret = append(ret, performer)\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (s mappedScraper) scrapeScenes(ctx context.Context, q mappedQuery) ([]*models.ScrapedScene, error) {\n\tvar ret []*models.ScrapedScene\n\n\tsceneScraperConfig := s.Scene\n\tsceneMap := sceneScraperConfig.mappedConfig\n\tif sceneMap == nil {\n\t\treturn nil, nil\n\t}\n\n\tlogger.Debug(`Processing scenes:`)\n\t// urlsIsMulti is nil because it will behave incorrect when scraping multiple scenes\n\tresults := sceneMap.process(ctx, q, s.Common, nil)\n\tfor i, r := range results {\n\t\tlogger.Debug(`Processing scene:`)\n\n\t\tthisScene := r.scrapedScene()\n\t\ts.processSceneRelationships(ctx, q, i, thisScene)\n\t\tret = append(ret, thisScene)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (s mappedScraper) scrapeScene(ctx context.Context, q mappedQuery) (*models.ScrapedScene, error) {\n\tsceneScraperConfig := s.Scene\n\tif sceneScraperConfig == nil {\n\t\treturn nil, nil\n\t}\n\n\tsceneMap := sceneScraperConfig.mappedConfig\n\n\tlogger.Debug(`Processing scene:`)\n\tresults := sceneMap.process(ctx, q, s.Common, urlsIsMulti)\n\n\tvar ret *models.ScrapedScene\n\tif len(results) > 0 {\n\t\tret = results[0].scrapedScene()\n\t}\n\thasRelationships := s.processSceneRelationships(ctx, q, 0, ret)\n\n\t// #3953 - process only returns results if the non-relationship fields are\n\t// populated\n\t// only return if we have results or relationships\n\tif len(results) > 0 || hasRelationships {\n\t\treturn ret, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (s mappedScraper) scrapeImage(ctx context.Context, q mappedQuery) (*models.ScrapedImage, error) {\n\tvar ret models.ScrapedImage\n\n\timageScraperConfig := s.Image\n\tif imageScraperConfig == nil {\n\t\treturn nil, nil\n\t}\n\n\timageMap := imageScraperConfig.mappedConfig\n\n\timagePerformersMap := imageScraperConfig.Performers\n\timageTagsMap := imageScraperConfig.Tags\n\timageStudioMap := imageScraperConfig.Studio\n\n\tlogger.Debug(`Processing image:`)\n\tresults := imageMap.process(ctx, q, s.Common, urlsIsMulti)\n\n\tif len(results) > 0 {\n\t\tret = *results[0].scrapedImage()\n\t}\n\n\t// now apply the performers and tags\n\tif imagePerformersMap != nil {\n\t\tlogger.Debug(`Processing image performers:`)\n\t\tret.Performers = imagePerformersMap.process(ctx, q, s.Common, nil).scrapedPerformers()\n\t}\n\n\tif imageTagsMap != nil {\n\t\tlogger.Debug(`Processing image tags:`)\n\t\tret.Tags = imageTagsMap.process(ctx, q, s.Common, nil).scrapedTags()\n\t}\n\n\tif imageStudioMap != nil {\n\t\tlogger.Debug(`Processing image studio:`)\n\t\tstudioResults := imageStudioMap.process(ctx, q, s.Common, nil)\n\n\t\tif len(studioResults) > 0 {\n\t\t\tret.Studio = studioResults[0].scrapedStudio()\n\t\t}\n\t}\n\n\t// if no basic fields are populated, and no relationships, then return nil\n\tif len(results) == 0 && len(ret.Performers) == 0 && len(ret.Tags) == 0 && ret.Studio == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn &ret, nil\n}\n\nfunc (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*models.ScrapedGallery, error) {\n\tvar ret models.ScrapedGallery\n\n\tgalleryScraperConfig := s.Gallery\n\tif galleryScraperConfig == nil {\n\t\treturn nil, nil\n\t}\n\n\tgalleryMap := galleryScraperConfig.mappedConfig\n\n\tgalleryPerformersMap := galleryScraperConfig.Performers\n\tgalleryTagsMap := galleryScraperConfig.Tags\n\tgalleryStudioMap := galleryScraperConfig.Studio\n\n\tlogger.Debug(`Processing gallery:`)\n\tresults := galleryMap.process(ctx, q, s.Common, urlsIsMulti)\n\n\tif len(results) > 0 {\n\t\tret = *results[0].scrapedGallery()\n\t}\n\n\t// now apply the performers and tags\n\tif galleryPerformersMap != nil {\n\t\tlogger.Debug(`Processing gallery performers:`)\n\t\tperformerResults := galleryPerformersMap.process(ctx, q, s.Common, urlsIsMulti)\n\n\t\tret.Performers = performerResults.scrapedPerformers()\n\t}\n\n\tif galleryTagsMap != nil {\n\t\tlogger.Debug(`Processing gallery tags:`)\n\t\ttagResults := galleryTagsMap.process(ctx, q, s.Common, nil)\n\t\tret.Tags = tagResults.scrapedTags()\n\t}\n\n\tif galleryStudioMap != nil {\n\t\tlogger.Debug(`Processing gallery studio:`)\n\t\tstudioResults := galleryStudioMap.process(ctx, q, s.Common, nil)\n\n\t\tif len(studioResults) > 0 {\n\t\t\tret.Studio = studioResults[0].scrapedStudio()\n\t\t}\n\t}\n\n\t// if no basic fields are populated, and no relationships, then return nil\n\tif len(results) == 0 && len(ret.Performers) == 0 && len(ret.Tags) == 0 && ret.Studio == nil {\n\t\treturn nil, nil\n\t}\n\n\treturn &ret, nil\n}\n\nfunc (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedGroup, error) {\n\tvar ret models.ScrapedGroup\n\n\t// try group scraper first, falling back to movie\n\tgroupScraperConfig := s.Group\n\n\tif groupScraperConfig == nil {\n\t\tgroupScraperConfig = s.Movie\n\t}\n\tif groupScraperConfig == nil {\n\t\treturn nil, nil\n\t}\n\n\tgroupMap := groupScraperConfig.mappedConfig\n\n\tgroupStudioMap := groupScraperConfig.Studio\n\tgroupTagsMap := groupScraperConfig.Tags\n\n\tresults := groupMap.process(ctx, q, s.Common, urlsIsMulti)\n\n\tif len(results) > 0 {\n\t\tret = *results[0].scrapedGroup()\n\t}\n\n\tif groupStudioMap != nil {\n\t\tlogger.Debug(`Processing group studio:`)\n\t\tstudioResults := groupStudioMap.process(ctx, q, s.Common, nil)\n\n\t\tif len(studioResults) > 0 {\n\t\t\tret.Studio = studioResults[0].scrapedStudio()\n\t\t}\n\t}\n\n\t// now apply the tags\n\tif groupTagsMap != nil {\n\t\tlogger.Debug(`Processing group tags:`)\n\t\ttagResults := groupTagsMap.process(ctx, q, s.Common, nil)\n\n\t\tret.Tags = tagResults.scrapedTags()\n\t}\n\n\tif len(results) == 0 && ret.Studio == nil && len(ret.Tags) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn &ret, nil\n}\n"
  },
  {
    "path": "pkg/scraper/mapped_config.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"gopkg.in/yaml.v2\"\n)\n\ntype commonMappedConfig map[string]string\n\ntype mappedConfig map[string]mappedScraperAttrConfig\n\nfunc (s mappedConfig) applyCommon(c commonMappedConfig, src string) string {\n\tif c == nil {\n\t\treturn src\n\t}\n\n\tret := src\n\tfor commonKey, commonVal := range c {\n\t\tret = strings.ReplaceAll(ret, commonKey, commonVal)\n\t}\n\n\treturn ret\n}\n\n// extractHostname parses a URL string and returns the hostname.\n// Returns empty string if the URL cannot be parsed.\nfunc extractHostname(urlStr string) string {\n\tif urlStr == \"\" {\n\t\treturn \"\"\n\t}\n\n\tu, err := url.Parse(urlStr)\n\tif err != nil {\n\t\tlogger.Warnf(\"Error parsing URL '%s': %s\", urlStr, err.Error())\n\t\treturn \"\"\n\t}\n\n\treturn u.Hostname()\n}\n\ntype isMultiFunc func(key string) bool\n\nfunc (s mappedConfig) process(ctx context.Context, q mappedQuery, common commonMappedConfig, isMulti isMultiFunc) mappedResults {\n\tvar ret mappedResults\n\n\tfor k, attrConfig := range s {\n\n\t\tif attrConfig.Fixed != \"\" {\n\t\t\t// TODO - not sure if this needs to set _all_ indexes for the key\n\t\t\tconst i = 0\n\t\t\t// Support {inputURL} and {inputHostname} placeholders in fixed values\n\t\t\tvalue := strings.ReplaceAll(attrConfig.Fixed, \"{inputURL}\", q.getURL())\n\t\t\tvalue = strings.ReplaceAll(value, \"{inputHostname}\", extractHostname(q.getURL()))\n\t\t\tret = ret.setSingleValue(i, k, value)\n\t\t} else {\n\t\t\tselector := attrConfig.Selector\n\t\t\tselector = s.applyCommon(common, selector)\n\t\t\t// Support {inputURL} and {inputHostname} placeholders in selectors\n\t\t\tselector = strings.ReplaceAll(selector, \"{inputURL}\", q.getURL())\n\t\t\tselector = strings.ReplaceAll(selector, \"{inputHostname}\", extractHostname(q.getURL()))\n\n\t\t\tfound, err := q.runQuery(selector)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Warnf(\"key '%v': %v\", k, err)\n\t\t\t}\n\n\t\t\tif len(found) > 0 {\n\t\t\t\tresult := s.postProcess(ctx, q, attrConfig, found)\n\n\t\t\t\t// HACK - if the key is URLs, then we need to set the value as a multi-value\n\t\t\t\tisMulti := isMulti != nil && isMulti(k)\n\t\t\t\tif isMulti {\n\t\t\t\t\tret = ret.setMultiValue(0, k, result)\n\t\t\t\t} else {\n\t\t\t\t\tfor i, text := range result {\n\t\t\t\t\t\tret = ret.setSingleValue(i, k, text)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc (s mappedConfig) postProcess(ctx context.Context, q mappedQuery, attrConfig mappedScraperAttrConfig, found []string) []string {\n\t// check if we're concatenating the results into a single result\n\tvar ret []string\n\tif attrConfig.hasConcat() {\n\t\tresult := attrConfig.concatenateResults(found)\n\t\tresult = attrConfig.postProcess(ctx, result, q)\n\t\tif attrConfig.hasSplit() {\n\t\t\tresults := attrConfig.splitString(result)\n\t\t\t// skip cleaning when the query is used for searching\n\t\t\tif q.getType() == SearchQuery {\n\t\t\t\treturn results\n\t\t\t}\n\t\t\tresults = attrConfig.cleanResults(results)\n\t\t\treturn results\n\t\t}\n\n\t\tret = []string{result}\n\t} else {\n\t\tfor _, text := range found {\n\t\t\ttext = attrConfig.postProcess(ctx, text, q)\n\t\t\tif attrConfig.hasSplit() {\n\t\t\t\treturn attrConfig.splitString(text)\n\t\t\t}\n\n\t\t\tret = append(ret, text)\n\t\t}\n\t\t// skip cleaning when the query is used for searching\n\t\tif q.getType() == SearchQuery {\n\t\t\treturn ret\n\t\t}\n\t\tret = attrConfig.cleanResults(ret)\n\n\t}\n\n\treturn ret\n}\n\ntype mappedSceneScraperConfig struct {\n\tmappedConfig\n\n\tTags       mappedConfig                 `yaml:\"Tags\"`\n\tPerformers mappedPerformerScraperConfig `yaml:\"Performers\"`\n\tStudio     mappedConfig                 `yaml:\"Studio\"`\n\tMovies     mappedConfig                 `yaml:\"Movies\"`\n\tGroups     mappedConfig                 `yaml:\"Groups\"`\n}\ntype _mappedSceneScraperConfig mappedSceneScraperConfig\n\nconst (\n\tmappedScraperConfigSceneTags       = \"Tags\"\n\tmappedScraperConfigScenePerformers = \"Performers\"\n\tmappedScraperConfigSceneStudio     = \"Studio\"\n\tmappedScraperConfigSceneMovies     = \"Movies\"\n\tmappedScraperConfigSceneGroups     = \"Groups\"\n)\n\nfunc (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {\n\t// HACK - unmarshal to map first, then remove known scene sub-fields, then\n\t// remarshal to yaml and pass that down to the base map\n\tparentMap := make(map[string]interface{})\n\tif err := unmarshal(parentMap); err != nil {\n\t\treturn err\n\t}\n\n\t// move the known sub-fields to a separate map\n\tthisMap := make(map[string]interface{})\n\n\tthisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags]\n\tthisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]\n\tthisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]\n\tthisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies]\n\tthisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups]\n\n\tdelete(parentMap, mappedScraperConfigSceneTags)\n\tdelete(parentMap, mappedScraperConfigScenePerformers)\n\tdelete(parentMap, mappedScraperConfigSceneStudio)\n\tdelete(parentMap, mappedScraperConfigSceneMovies)\n\tdelete(parentMap, mappedScraperConfigSceneGroups)\n\n\t// re-unmarshal the sub-fields\n\tyml, err := yaml.Marshal(thisMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// needs to be a different type to prevent infinite recursion\n\tc := _mappedSceneScraperConfig{}\n\tif err := yaml.Unmarshal(yml, &c); err != nil {\n\t\treturn err\n\t}\n\n\t*s = mappedSceneScraperConfig(c)\n\n\tyml, err = yaml.Marshal(parentMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype mappedGalleryScraperConfig struct {\n\tmappedConfig\n\n\tTags       mappedConfig `yaml:\"Tags\"`\n\tPerformers mappedConfig `yaml:\"Performers\"`\n\tStudio     mappedConfig `yaml:\"Studio\"`\n}\n\ntype _mappedGalleryScraperConfig mappedGalleryScraperConfig\n\nfunc (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {\n\t// HACK - unmarshal to map first, then remove known scene sub-fields, then\n\t// remarshal to yaml and pass that down to the base map\n\tparentMap := make(map[string]interface{})\n\tif err := unmarshal(parentMap); err != nil {\n\t\treturn err\n\t}\n\n\t// move the known sub-fields to a separate map\n\tthisMap := make(map[string]interface{})\n\n\tthisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags]\n\tthisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]\n\tthisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]\n\n\tdelete(parentMap, mappedScraperConfigSceneTags)\n\tdelete(parentMap, mappedScraperConfigScenePerformers)\n\tdelete(parentMap, mappedScraperConfigSceneStudio)\n\n\t// re-unmarshal the sub-fields\n\tyml, err := yaml.Marshal(thisMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// needs to be a different type to prevent infinite recursion\n\tc := _mappedGalleryScraperConfig{}\n\tif err := yaml.Unmarshal(yml, &c); err != nil {\n\t\treturn err\n\t}\n\n\t*s = mappedGalleryScraperConfig(c)\n\n\tyml, err = yaml.Marshal(parentMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype mappedImageScraperConfig struct {\n\tmappedConfig\n\n\tTags       mappedConfig `yaml:\"Tags\"`\n\tPerformers mappedConfig `yaml:\"Performers\"`\n\tStudio     mappedConfig `yaml:\"Studio\"`\n}\ntype _mappedImageScraperConfig mappedImageScraperConfig\n\nfunc (s *mappedImageScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {\n\t// HACK - unmarshal to map first, then remove known scene sub-fields, then\n\t// remarshal to yaml and pass that down to the base map\n\tparentMap := make(map[string]interface{})\n\tif err := unmarshal(parentMap); err != nil {\n\t\treturn err\n\t}\n\n\t// move the known sub-fields to a separate map\n\tthisMap := make(map[string]interface{})\n\n\tthisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags]\n\tthisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]\n\tthisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]\n\n\tdelete(parentMap, mappedScraperConfigSceneTags)\n\tdelete(parentMap, mappedScraperConfigScenePerformers)\n\tdelete(parentMap, mappedScraperConfigSceneStudio)\n\n\t// re-unmarshal the sub-fields\n\tyml, err := yaml.Marshal(thisMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// needs to be a different type to prevent infinite recursion\n\tc := _mappedImageScraperConfig{}\n\tif err := yaml.Unmarshal(yml, &c); err != nil {\n\t\treturn err\n\t}\n\n\t*s = mappedImageScraperConfig(c)\n\n\tyml, err = yaml.Marshal(parentMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype mappedPerformerScraperConfig struct {\n\tmappedConfig\n\n\tTags mappedConfig `yaml:\"Tags\"`\n}\ntype _mappedPerformerScraperConfig mappedPerformerScraperConfig\n\nconst (\n\tmappedScraperConfigPerformerTags = \"Tags\"\n)\n\nfunc (s *mappedPerformerScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {\n\t// HACK - unmarshal to map first, then remove known scene sub-fields, then\n\t// remarshal to yaml and pass that down to the base map\n\tparentMap := make(map[string]interface{})\n\tif err := unmarshal(parentMap); err != nil {\n\t\treturn err\n\t}\n\n\t// move the known sub-fields to a separate map\n\tthisMap := make(map[string]interface{})\n\n\tthisMap[mappedScraperConfigPerformerTags] = parentMap[mappedScraperConfigPerformerTags]\n\n\tdelete(parentMap, mappedScraperConfigPerformerTags)\n\n\t// re-unmarshal the sub-fields\n\tyml, err := yaml.Marshal(thisMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// needs to be a different type to prevent infinite recursion\n\tc := _mappedPerformerScraperConfig{}\n\tif err := yaml.Unmarshal(yml, &c); err != nil {\n\t\treturn err\n\t}\n\n\t*s = mappedPerformerScraperConfig(c)\n\n\tyml, err = yaml.Marshal(parentMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype mappedMovieScraperConfig struct {\n\tmappedConfig\n\n\tStudio mappedConfig `yaml:\"Studio\"`\n\tTags   mappedConfig `yaml:\"Tags\"`\n}\ntype _mappedMovieScraperConfig mappedMovieScraperConfig\n\nconst (\n\tmappedScraperConfigMovieStudio = \"Studio\"\n\tmappedScraperConfigMovieTags   = \"Tags\"\n)\n\nfunc (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {\n\t// HACK - unmarshal to map first, then remove known movie sub-fields, then\n\t// remarshal to yaml and pass that down to the base map\n\tparentMap := make(map[string]interface{})\n\tif err := unmarshal(parentMap); err != nil {\n\t\treturn err\n\t}\n\n\t// move the known sub-fields to a separate map\n\tthisMap := make(map[string]interface{})\n\n\tthisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio]\n\tdelete(parentMap, mappedScraperConfigMovieStudio)\n\n\tthisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags]\n\tdelete(parentMap, mappedScraperConfigMovieTags)\n\n\t// re-unmarshal the sub-fields\n\tyml, err := yaml.Marshal(thisMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// needs to be a different type to prevent infinite recursion\n\tc := _mappedMovieScraperConfig{}\n\tif err := yaml.Unmarshal(yml, &c); err != nil {\n\t\treturn err\n\t}\n\n\t*s = mappedMovieScraperConfig(c)\n\n\tyml, err = yaml.Marshal(parentMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype mappedScraperAttrConfig struct {\n\tSelector    string                    `yaml:\"selector\"`\n\tFixed       string                    `yaml:\"fixed\"`\n\tPostProcess []mappedPostProcessAction `yaml:\"postProcess\"`\n\tConcat      string                    `yaml:\"concat\"`\n\tSplit       string                    `yaml:\"split\"`\n\n\tpostProcessActions []postProcessAction\n\n\t// Deprecated: use PostProcess instead\n\tParseDate  string                   `yaml:\"parseDate\"`\n\tReplace    mappedRegexConfigs       `yaml:\"replace\"`\n\tSubScraper *mappedScraperAttrConfig `yaml:\"subScraper\"`\n}\n\ntype _mappedScraperAttrConfig mappedScraperAttrConfig\n\nfunc (c *mappedScraperAttrConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {\n\t// try unmarshalling into a string first\n\tif err := unmarshal(&c.Selector); err != nil {\n\t\t// if it's a type error then we try to unmarshall to the full object\n\t\tvar typeErr *yaml.TypeError\n\t\tif !errors.As(err, &typeErr) {\n\t\t\treturn err\n\t\t}\n\n\t\t// unmarshall to full object\n\t\t// need it as a separate object\n\t\tt := _mappedScraperAttrConfig{}\n\t\tif err = unmarshal(&t); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t*c = mappedScraperAttrConfig(t)\n\t}\n\n\treturn c.convertPostProcessActions()\n}\n\nfunc (c *mappedScraperAttrConfig) convertPostProcessActions() error {\n\t// ensure we don't have the old deprecated fields and the new post process field\n\tif len(c.PostProcess) > 0 {\n\t\tif c.ParseDate != \"\" || len(c.Replace) > 0 || c.SubScraper != nil {\n\t\t\treturn errors.New(\"cannot include postProcess and (parseDate, replace, subScraper) deprecated fields\")\n\t\t}\n\n\t\t// convert xpathPostProcessAction actions to postProcessActions\n\t\tfor _, a := range c.PostProcess {\n\t\t\taction, err := a.ToPostProcessAction()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tc.postProcessActions = append(c.postProcessActions, action)\n\t\t}\n\n\t\tc.PostProcess = nil\n\t} else {\n\t\t// convert old deprecated fields if present\n\t\t// in same order as they used to be executed\n\t\tif len(c.Replace) > 0 {\n\t\t\taction := postProcessReplace(c.Replace)\n\t\t\tc.postProcessActions = append(c.postProcessActions, &action)\n\t\t\tc.Replace = nil\n\t\t}\n\n\t\tif c.SubScraper != nil {\n\t\t\taction := postProcessSubScraper(*c.SubScraper)\n\t\t\tc.postProcessActions = append(c.postProcessActions, &action)\n\t\t\tc.SubScraper = nil\n\t\t}\n\n\t\tif c.ParseDate != \"\" {\n\t\t\taction := postProcessParseDate(c.ParseDate)\n\t\t\tc.postProcessActions = append(c.postProcessActions, &action)\n\t\t\tc.ParseDate = \"\"\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c mappedScraperAttrConfig) hasConcat() bool {\n\treturn c.Concat != \"\"\n}\n\nfunc (c mappedScraperAttrConfig) hasSplit() bool {\n\treturn c.Split != \"\"\n}\n\nfunc (c mappedScraperAttrConfig) concatenateResults(nodes []string) string {\n\tseparator := c.Concat\n\treturn strings.Join(nodes, separator)\n}\n\nfunc (c mappedScraperAttrConfig) cleanResults(nodes []string) []string {\n\tcleaned := sliceutil.Unique(nodes)      // remove duplicate values\n\tcleaned = sliceutil.Delete(cleaned, \"\") // remove empty values\n\treturn cleaned\n}\n\nfunc (c mappedScraperAttrConfig) splitString(value string) []string {\n\tseparator := c.Split\n\tvar res []string\n\n\tif separator == \"\" {\n\t\treturn []string{value}\n\t}\n\n\tfor _, str := range strings.Split(value, separator) {\n\t\tif str != \"\" {\n\t\t\tres = append(res, str)\n\t\t}\n\t}\n\n\treturn res\n}\n\nfunc (c mappedScraperAttrConfig) postProcess(ctx context.Context, value string, q mappedQuery) string {\n\tfor _, action := range c.postProcessActions {\n\t\tvalue = action.Apply(ctx, value, q)\n\t}\n\n\treturn value\n}\n"
  },
  {
    "path": "pkg/scraper/mapped_postprocessing.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/javascript\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype mappedRegexConfig struct {\n\tRegex string `yaml:\"regex\"`\n\tWith  string `yaml:\"with\"`\n}\n\ntype mappedRegexConfigs []mappedRegexConfig\n\nfunc (c mappedRegexConfig) apply(value string) string {\n\tif c.Regex != \"\" {\n\t\tre, err := regexp.Compile(c.Regex)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Error compiling regex '%s': %s\", c.Regex, err.Error())\n\t\t\treturn value\n\t\t}\n\n\t\tret := re.ReplaceAllString(value, c.With)\n\n\t\t// trim leading and trailing whitespace\n\t\t// this is done to maintain backwards compatibility with existing\n\t\t// scrapers\n\t\tret = strings.TrimSpace(ret)\n\n\t\tlogger.Debugf(`Replace: '%s' with '%s'`, c.Regex, c.With)\n\t\tlogger.Debugf(\"Before: %s\", value)\n\t\tlogger.Debugf(\"After: %s\", ret)\n\t\treturn ret\n\t}\n\n\treturn value\n}\n\nfunc (c mappedRegexConfigs) apply(value string) string {\n\t// apply regex in order\n\tfor _, config := range c {\n\t\tvalue = config.apply(value)\n\t}\n\n\treturn value\n}\n\ntype postProcessAction interface {\n\tApply(ctx context.Context, value string, q mappedQuery) string\n}\n\ntype postProcessParseDate string\n\nfunc (p *postProcessParseDate) Apply(ctx context.Context, value string, q mappedQuery) string {\n\tparseDate := string(*p)\n\n\tconst internalDateFormat = \"2006-01-02\"\n\n\tvalueLower := strings.ToLower(value)\n\tif valueLower == \"today\" || valueLower == \"yesterday\" { // handle today, yesterday\n\t\tdt := time.Now()\n\t\tif valueLower == \"yesterday\" { // subtract 1 day from now\n\t\t\tdt = dt.AddDate(0, 0, -1)\n\t\t}\n\t\treturn dt.Format(internalDateFormat)\n\t}\n\n\tif parseDate == \"\" {\n\t\treturn value\n\t}\n\n\tif parseDate == \"unix\" {\n\t\t// try to parse the date using unix timestamp format\n\t\t// if it fails, then just fall back to the original value\n\t\ttimeAsInt, err := strconv.ParseInt(value, 10, 64)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Error parsing date string '%s' using unix timestamp format : %s\", value, err.Error())\n\t\t\treturn value\n\t\t}\n\t\tparsedValue := time.Unix(timeAsInt, 0)\n\n\t\treturn parsedValue.Format(internalDateFormat)\n\t}\n\n\t// try to parse the date using the pattern\n\t// if it fails, then just fall back to the original value\n\tparsedValue, err := time.Parse(parseDate, value)\n\tif err != nil {\n\t\tlogger.Warnf(\"Error parsing date string '%s' using format '%s': %s\", value, parseDate, err.Error())\n\t\treturn value\n\t}\n\n\t// convert it into our date format\n\treturn parsedValue.Format(internalDateFormat)\n}\n\ntype postProcessSubtractDays bool\n\nfunc (p *postProcessSubtractDays) Apply(ctx context.Context, value string, q mappedQuery) string {\n\tconst internalDateFormat = \"2006-01-02\"\n\n\ti, err := strconv.Atoi(value)\n\tif err != nil {\n\t\tlogger.Warnf(\"Error parsing day string %s: %s\", value, err)\n\t\treturn value\n\t}\n\n\tdt := time.Now()\n\tdt = dt.AddDate(0, 0, -i)\n\treturn dt.Format(internalDateFormat)\n}\n\ntype postProcessReplace mappedRegexConfigs\n\nfunc (c *postProcessReplace) Apply(ctx context.Context, value string, q mappedQuery) string {\n\treplace := mappedRegexConfigs(*c)\n\treturn replace.apply(value)\n}\n\ntype postProcessSubScraper mappedScraperAttrConfig\n\nfunc (p *postProcessSubScraper) Apply(ctx context.Context, value string, q mappedQuery) string {\n\tsubScrapeConfig := mappedScraperAttrConfig(*p)\n\n\tlogger.Debugf(\"Sub-scraping for: %s\", value)\n\tss := q.subScrape(ctx, value)\n\n\tif ss != nil {\n\t\tfound, err := ss.runQuery(subScrapeConfig.Selector)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"subscrape for '%v': %v\", value, err)\n\t\t}\n\n\t\tif len(found) > 0 {\n\t\t\t// check if we're concatenating the results into a single result\n\t\t\tvar result string\n\t\t\tif subScrapeConfig.hasConcat() {\n\t\t\t\tresult = subScrapeConfig.concatenateResults(found)\n\t\t\t} else {\n\t\t\t\tresult = found[0]\n\t\t\t}\n\n\t\t\tresult = subScrapeConfig.postProcess(ctx, result, ss)\n\t\t\treturn result\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\ntype postProcessMap map[string]string\n\nfunc (p *postProcessMap) Apply(ctx context.Context, value string, q mappedQuery) string {\n\t// return the mapped value if present\n\tm := *p\n\tmapped, ok := m[value]\n\n\tif ok {\n\t\treturn mapped\n\t}\n\n\treturn value\n}\n\ntype postProcessFeetToCm bool\n\nfunc (p *postProcessFeetToCm) Apply(ctx context.Context, value string, q mappedQuery) string {\n\tconst foot_in_cm = 30.48\n\tconst inch_in_cm = 2.54\n\n\treg := regexp.MustCompile(\"[0-9]+\")\n\tfiltered := reg.FindAllString(value, -1)\n\n\tvar feet float64\n\tvar inches float64\n\tif len(filtered) > 0 {\n\t\tfeet, _ = strconv.ParseFloat(filtered[0], 64)\n\t}\n\tif len(filtered) > 1 {\n\t\tinches, _ = strconv.ParseFloat(filtered[1], 64)\n\t}\n\n\tvar centimeters = feet*foot_in_cm + inches*inch_in_cm\n\n\t// Return rounded integer string\n\treturn strconv.Itoa(int(math.Round(centimeters)))\n}\n\ntype postProcessLbToKg bool\n\nfunc (p *postProcessLbToKg) Apply(ctx context.Context, value string, q mappedQuery) string {\n\tconst lb_in_kg = 0.45359237\n\tw, err := strconv.ParseFloat(value, 64)\n\tif err == nil {\n\t\tw *= lb_in_kg\n\t\tvalue = strconv.Itoa(int(math.Round(w)))\n\t}\n\treturn value\n}\n\ntype postProcessJavascript string\n\nfunc (p *postProcessJavascript) Apply(ctx context.Context, value string, q mappedQuery) string {\n\tvm := javascript.NewVM()\n\tif err := vm.Set(\"value\", value); err != nil {\n\t\tlogger.Warnf(\"javascript failed to set value: %v\", err)\n\t\treturn value\n\t}\n\n\tlog := &javascript.Log{\n\t\tLogger:       logger.Logger,\n\t\tPrefix:       \"\",\n\t\tProgressChan: make(chan float64),\n\t}\n\n\tif err := log.AddToVM(\"log\", vm); err != nil {\n\t\tlogger.Logger.Errorf(\"error adding log API: %w\", err)\n\t}\n\n\tutil := &javascript.Util{}\n\tif err := util.AddToVM(\"util\", vm); err != nil {\n\t\tlogger.Logger.Errorf(\"error adding util API: %w\", err)\n\t}\n\n\tscript, err := javascript.CompileScript(\"\", \"(function() { \"+string(*p)+\"})()\")\n\tif err != nil {\n\t\tlogger.Warnf(\"javascript failed to compile: %v\", err)\n\t\treturn value\n\t}\n\n\toutput, err := vm.RunProgram(script)\n\tif err != nil {\n\t\tlogger.Warnf(\"javascript failed to run: %v\", err)\n\t\treturn value\n\t}\n\n\t// assume output is string\n\treturn output.String()\n}\n\ntype mappedPostProcessAction struct {\n\tParseDate    string                   `yaml:\"parseDate\"`\n\tSubtractDays bool                     `yaml:\"subtractDays\"`\n\tReplace      mappedRegexConfigs       `yaml:\"replace\"`\n\tSubScraper   *mappedScraperAttrConfig `yaml:\"subScraper\"`\n\tMap          map[string]string        `yaml:\"map\"`\n\tFeetToCm     bool                     `yaml:\"feetToCm\"`\n\tLbToKg       bool                     `yaml:\"lbToKg\"`\n\tJavascript   string                   `yaml:\"javascript\"`\n}\n\nfunc (a mappedPostProcessAction) ToPostProcessAction() (postProcessAction, error) {\n\tvar found string\n\tvar ret postProcessAction\n\n\tensureOnly := func(field string) error {\n\t\tif found != \"\" {\n\t\t\treturn fmt.Errorf(\"post-process actions must have a single field, found %s and %s\", found, field)\n\t\t}\n\t\tfound = field\n\t\treturn nil\n\t}\n\n\tif a.ParseDate != \"\" {\n\t\tfound = \"parseDate\"\n\t\taction := postProcessParseDate(a.ParseDate)\n\t\tret = &action\n\t}\n\tif len(a.Replace) > 0 {\n\t\tif err := ensureOnly(\"replace\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taction := postProcessReplace(a.Replace)\n\t\tret = &action\n\t}\n\tif a.SubScraper != nil {\n\t\tif err := ensureOnly(\"subScraper\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taction := postProcessSubScraper(*a.SubScraper)\n\t\tret = &action\n\t}\n\tif a.Map != nil {\n\t\tif err := ensureOnly(\"map\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taction := postProcessMap(a.Map)\n\t\tret = &action\n\t}\n\tif a.FeetToCm {\n\t\tif err := ensureOnly(\"feetToCm\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taction := postProcessFeetToCm(a.FeetToCm)\n\t\tret = &action\n\t}\n\tif a.LbToKg {\n\t\tif err := ensureOnly(\"lbToKg\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taction := postProcessLbToKg(a.LbToKg)\n\t\tret = &action\n\t}\n\tif a.SubtractDays {\n\t\tif err := ensureOnly(\"subtractDays\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taction := postProcessSubtractDays(a.SubtractDays)\n\t\tret = &action\n\t}\n\tif a.Javascript != \"\" {\n\t\tif err := ensureOnly(\"javascript\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taction := postProcessJavascript(a.Javascript)\n\t\tret = &action\n\t}\n\n\tif ret == nil {\n\t\treturn nil, errors.New(\"invalid post-process action\")\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/scraper/mapped_result.go",
    "content": "package scraper\n\nimport (\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype mappedResult map[string]interface{}\ntype mappedResults []mappedResult\n\nfunc (r mappedResult) string(key string) (string, bool) {\n\tv, ok := r[key]\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\n\tval, ok := v.(string)\n\tif !ok {\n\t\tlogger.Errorf(\"String field %s is %T in mappedResult\", key, r[key])\n\t}\n\n\treturn val, true\n}\n\nfunc (r mappedResult) mustString(key string) string {\n\tv, ok := r[key]\n\tif !ok {\n\t\tlogger.Errorf(\"Missing required string field %s in mappedResult\", key)\n\t\treturn \"\"\n\t}\n\n\tval, ok := v.(string)\n\tif !ok {\n\t\tlogger.Errorf(\"String field %s is %T in mappedResult\", key, r[key])\n\t}\n\n\treturn val\n}\n\nfunc (r mappedResult) stringPtr(key string) *string {\n\tval, ok := r.string(key)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &val\n}\n\nfunc (r mappedResult) stringSlice(key string) []string {\n\tv, ok := r[key]\n\tif !ok {\n\t\treturn nil\n\t}\n\n\t// need to try both []string and string\n\tval, ok := v.([]string)\n\n\tif ok {\n\t\treturn val\n\t}\n\n\t// try single string\n\tsingleVal, ok := v.(string)\n\tif !ok {\n\t\tlogger.Errorf(\"String slice field %s is %T in mappedResult\", key, r[key])\n\t\treturn nil\n\t}\n\n\treturn []string{singleVal}\n}\n\nfunc (r mappedResult) IntPtr(key string) *int {\n\tv, ok := r[key]\n\tif !ok {\n\t\treturn nil\n\t}\n\n\tval, ok := v.(int)\n\tif !ok {\n\t\tlogger.Errorf(\"Int field %s is %T in mappedResult\", key, r[key])\n\t\treturn nil\n\t}\n\n\treturn &val\n}\n\nfunc (r mappedResults) setSingleValue(index int, key string, value string) mappedResults {\n\tif index >= len(r) {\n\t\tr = append(r, make(mappedResult))\n\t}\n\n\tlogger.Debugf(`[%d][%s] = %s`, index, key, value)\n\tr[index][key] = value\n\treturn r\n}\n\nfunc (r mappedResults) setMultiValue(index int, key string, value []string) mappedResults {\n\tif index >= len(r) {\n\t\tr = append(r, make(mappedResult))\n\t}\n\n\tlogger.Debugf(`[%d][%s] = %s`, index, key, value)\n\tr[index][key] = value\n\treturn r\n}\n\nfunc (r mappedResults) scrapedTags() []*models.ScrapedTag {\n\tif len(r) == 0 {\n\t\treturn nil\n\t}\n\n\tret := make([]*models.ScrapedTag, len(r))\n\tfor i, result := range r {\n\t\tret[i] = result.scrapedTag()\n\t}\n\n\treturn ret\n}\n\nfunc (r mappedResult) scrapedTag() *models.ScrapedTag {\n\treturn &models.ScrapedTag{\n\t\tName: r.mustString(\"Name\"),\n\t}\n}\n\nfunc (r mappedResult) scrapedPerformer() *models.ScrapedPerformer {\n\tret := &models.ScrapedPerformer{\n\t\tName:           r.stringPtr(\"Name\"),\n\t\tDisambiguation: r.stringPtr(\"Disambiguation\"),\n\t\tGender:         r.stringPtr(\"Gender\"),\n\t\tURL:            r.stringPtr(\"URL\"),\n\t\tURLs:           r.stringSlice(\"URLs\"),\n\t\tTwitter:        r.stringPtr(\"Twitter\"),\n\t\tBirthdate:      r.stringPtr(\"Birthdate\"),\n\t\tEthnicity:      r.stringPtr(\"Ethnicity\"),\n\t\tCountry:        r.stringPtr(\"Country\"),\n\t\tEyeColor:       r.stringPtr(\"EyeColor\"),\n\t\tHeight:         r.stringPtr(\"Height\"),\n\t\tMeasurements:   r.stringPtr(\"Measurements\"),\n\t\tFakeTits:       r.stringPtr(\"FakeTits\"),\n\t\tPenisLength:    r.stringPtr(\"PenisLength\"),\n\t\tCircumcised:    r.stringPtr(\"Circumcised\"),\n\t\tCareerLength:   r.stringPtr(\"CareerLength\"),\n\t\tCareerStart:    r.stringPtr(\"CareerStart\"),\n\t\tCareerEnd:      r.stringPtr(\"CareerEnd\"),\n\t\tTattoos:        r.stringPtr(\"Tattoos\"),\n\t\tPiercings:      r.stringPtr(\"Piercings\"),\n\t\tAliases:        r.stringPtr(\"Aliases\"),\n\t\tImage:          r.stringPtr(\"Image\"),\n\t\tImages:         r.stringSlice(\"Images\"),\n\t\tDetails:        r.stringPtr(\"Details\"),\n\t\tDeathDate:      r.stringPtr(\"DeathDate\"),\n\t\tHairColor:      r.stringPtr(\"HairColor\"),\n\t\tWeight:         r.stringPtr(\"Weight\"),\n\t}\n\treturn ret\n}\n\nfunc (r mappedResults) scrapedPerformers() []*models.ScrapedPerformer {\n\tif len(r) == 0 {\n\t\treturn nil\n\t}\n\n\tret := make([]*models.ScrapedPerformer, len(r))\n\tfor i, result := range r {\n\t\tret[i] = result.scrapedPerformer()\n\t}\n\n\treturn ret\n}\n\nfunc (r mappedResult) scrapedScene() *models.ScrapedScene {\n\tret := &models.ScrapedScene{\n\t\tTitle:    r.stringPtr(\"Title\"),\n\t\tCode:     r.stringPtr(\"Code\"),\n\t\tDetails:  r.stringPtr(\"Details\"),\n\t\tDirector: r.stringPtr(\"Director\"),\n\t\tURL:      r.stringPtr(\"URL\"),\n\t\tURLs:     r.stringSlice(\"URLs\"),\n\t\tDate:     r.stringPtr(\"Date\"),\n\t\tImage:    r.stringPtr(\"Image\"),\n\t\tDuration: r.IntPtr(\"Duration\"),\n\t}\n\treturn ret\n}\n\nfunc (r mappedResult) scrapedImage() *models.ScrapedImage {\n\tret := &models.ScrapedImage{\n\t\tTitle:        r.stringPtr(\"Title\"),\n\t\tCode:         r.stringPtr(\"Code\"),\n\t\tDetails:      r.stringPtr(\"Details\"),\n\t\tPhotographer: r.stringPtr(\"Photographer\"),\n\t\tURLs:         r.stringSlice(\"URLs\"),\n\t\tDate:         r.stringPtr(\"Date\"),\n\t}\n\treturn ret\n}\n\nfunc (r mappedResult) scrapedGallery() *models.ScrapedGallery {\n\tret := &models.ScrapedGallery{\n\t\tTitle:        r.stringPtr(\"Title\"),\n\t\tCode:         r.stringPtr(\"Code\"),\n\t\tDetails:      r.stringPtr(\"Details\"),\n\t\tPhotographer: r.stringPtr(\"Photographer\"),\n\t\tURL:          r.stringPtr(\"URL\"),\n\t\tURLs:         r.stringSlice(\"URLs\"),\n\t\tDate:         r.stringPtr(\"Date\"),\n\t}\n\treturn ret\n}\n\nfunc (r mappedResult) scrapedStudio() *models.ScrapedStudio {\n\tret := &models.ScrapedStudio{\n\t\tName:    r.mustString(\"Name\"),\n\t\tURL:     r.stringPtr(\"URL\"),\n\t\tURLs:    r.stringSlice(\"URLs\"),\n\t\tImage:   r.stringPtr(\"Image\"),\n\t\tDetails: r.stringPtr(\"Details\"),\n\t\tAliases: r.stringPtr(\"Aliases\"),\n\t}\n\treturn ret\n}\n\nfunc (r mappedResult) scrapedMovie() *models.ScrapedMovie {\n\tret := &models.ScrapedMovie{\n\t\tName:       r.stringPtr(\"Name\"),\n\t\tAliases:    r.stringPtr(\"Aliases\"),\n\t\tURLs:       r.stringSlice(\"URLs\"),\n\t\tDuration:   r.stringPtr(\"Duration\"),\n\t\tDate:       r.stringPtr(\"Date\"),\n\t\tDirector:   r.stringPtr(\"Director\"),\n\t\tSynopsis:   r.stringPtr(\"Synopsis\"),\n\t\tFrontImage: r.stringPtr(\"FrontImage\"),\n\t\tBackImage:  r.stringPtr(\"BackImage\"),\n\t}\n\n\treturn ret\n}\n\nfunc (r mappedResult) scrapedGroup() *models.ScrapedGroup {\n\tret := &models.ScrapedGroup{\n\t\tName:       r.stringPtr(\"Name\"),\n\t\tAliases:    r.stringPtr(\"Aliases\"),\n\t\tURL:        r.stringPtr(\"URL\"),\n\t\tURLs:       r.stringSlice(\"URLs\"),\n\t\tDuration:   r.stringPtr(\"Duration\"),\n\t\tDate:       r.stringPtr(\"Date\"),\n\t\tDirector:   r.stringPtr(\"Director\"),\n\t\tSynopsis:   r.stringPtr(\"Synopsis\"),\n\t\tFrontImage: r.stringPtr(\"FrontImage\"),\n\t\tBackImage:  r.stringPtr(\"BackImage\"),\n\t}\n\n\treturn ret\n}\n\nfunc (r mappedResults) scrapedMovies() []*models.ScrapedMovie {\n\tif len(r) == 0 {\n\t\treturn nil\n\t}\n\tret := make([]*models.ScrapedMovie, len(r))\n\tfor i, result := range r {\n\t\tret[i] = result.scrapedMovie()\n\t}\n\n\treturn ret\n}\n\nfunc (r mappedResults) scrapedGroups() []*models.ScrapedGroup {\n\tif len(r) == 0 {\n\t\treturn nil\n\t}\n\tret := make([]*models.ScrapedGroup, len(r))\n\tfor i, result := range r {\n\t\tret[i] = result.scrapedGroup()\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/scraper/mapped_result_test.go",
    "content": "package scraper\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// Test string method\nfunc TestMappedResultString(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResult\n\t\tkey           string\n\t\texpectedValue string\n\t\texpectedOk    bool\n\t}{\n\t\t{\n\t\t\tname:          \"valid string\",\n\t\t\tdata:          mappedResult{\"name\": \"test\"},\n\t\t\tkey:           \"name\",\n\t\t\texpectedValue: \"test\",\n\t\t\texpectedOk:    true,\n\t\t},\n\t\t{\n\t\t\tname:          \"missing key\",\n\t\t\tdata:          mappedResult{},\n\t\t\tkey:           \"missing\",\n\t\t\texpectedValue: \"\",\n\t\t\texpectedOk:    false,\n\t\t},\n\t\t{\n\t\t\tname:          \"wrong type still returns ok true but empty value\",\n\t\t\tdata:          mappedResult{\"num\": 123},\n\t\t\tkey:           \"num\",\n\t\t\texpectedValue: \"\",\n\t\t\texpectedOk:    true, // logs error but returns ok=true\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tval, ok := test.data.string(test.key)\n\t\t\tassert.Equal(t, test.expectedValue, val)\n\t\t\tassert.Equal(t, test.expectedOk, ok)\n\t\t})\n\t}\n}\n\n// Test mustString method\nfunc TestMappedResultMustString(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResult\n\t\tkey           string\n\t\texpectedValue string\n\t}{\n\t\t{\n\t\t\tname:          \"valid string\",\n\t\t\tdata:          mappedResult{\"name\": \"test\"},\n\t\t\tkey:           \"name\",\n\t\t\texpectedValue: \"test\",\n\t\t},\n\t\t{\n\t\t\tname:          \"missing key returns empty string\",\n\t\t\tdata:          mappedResult{},\n\t\t\tkey:           \"missing\",\n\t\t\texpectedValue: \"\",\n\t\t},\n\t\t{\n\t\t\tname:          \"wrong type returns empty string\",\n\t\t\tdata:          mappedResult{\"num\": 123},\n\t\t\tkey:           \"num\",\n\t\t\texpectedValue: \"\",\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tval := test.data.mustString(test.key)\n\t\t\tassert.Equal(t, test.expectedValue, val)\n\t\t})\n\t}\n}\n\n// Test stringPtr method\nfunc TestMappedResultStringPtr(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResult\n\t\tkey           string\n\t\texpectedValue *string\n\t}{\n\t\t{\n\t\t\tname:          \"valid string\",\n\t\t\tdata:          mappedResult{\"name\": \"test\"},\n\t\t\tkey:           \"name\",\n\t\t\texpectedValue: strPtr(\"test\"),\n\t\t},\n\t\t{\n\t\t\tname:          \"missing key returns nil\",\n\t\t\tdata:          mappedResult{},\n\t\t\tkey:           \"missing\",\n\t\t\texpectedValue: nil,\n\t\t},\n\t\t{\n\t\t\tname:          \"wrong type returns non-nil pointer to empty string\",\n\t\t\tdata:          mappedResult{\"num\": 123},\n\t\t\tkey:           \"num\",\n\t\t\texpectedValue: strPtr(\"\"), // string() returns empty string but ok=true\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tval := test.data.stringPtr(test.key)\n\t\t\tif test.expectedValue == nil {\n\t\t\t\tassert.Nil(t, val)\n\t\t\t} else {\n\t\t\t\tassert.NotNil(t, val)\n\t\t\t\tassert.Equal(t, *test.expectedValue, *val)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test stringSlice method\nfunc TestMappedResultStringSlice(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResult\n\t\tkey           string\n\t\texpectedValue []string\n\t}{\n\t\t{\n\t\t\tname:          \"valid slice\",\n\t\t\tdata:          mappedResult{\"tags\": []string{\"a\", \"b\", \"c\"}},\n\t\t\tkey:           \"tags\",\n\t\t\texpectedValue: []string{\"a\", \"b\", \"c\"},\n\t\t},\n\t\t{\n\t\t\tname:          \"missing key returns nil\",\n\t\t\tdata:          mappedResult{},\n\t\t\tkey:           \"missing\",\n\t\t\texpectedValue: nil,\n\t\t},\n\t\t{\n\t\t\tname:          \"single value converted to slice\",\n\t\t\tdata:          mappedResult{\"tags\": \"not a slice\"},\n\t\t\tkey:           \"tags\",\n\t\t\texpectedValue: []string{\"not a slice\"},\n\t\t},\n\t\t{\n\t\t\tname:          \"wrong type returns nil\",\n\t\t\tdata:          mappedResult{\"tags\": 123},\n\t\t\tkey:           \"tags\",\n\t\t\texpectedValue: nil,\n\t\t},\n\t\t{\n\t\t\tname:          \"empty slice\",\n\t\t\tdata:          mappedResult{\"tags\": []string{}},\n\t\t\tkey:           \"tags\",\n\t\t\texpectedValue: []string{},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tval := test.data.stringSlice(test.key)\n\t\t\tassert.Equal(t, test.expectedValue, val)\n\t\t})\n\t}\n}\n\n// Test IntPtr method\nfunc TestMappedResultIntPtr(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResult\n\t\tkey           string\n\t\texpectedValue *int\n\t}{\n\t\t{\n\t\t\tname:          \"valid int\",\n\t\t\tdata:          mappedResult{\"duration\": 120},\n\t\t\tkey:           \"duration\",\n\t\t\texpectedValue: intPtr(120),\n\t\t},\n\t\t{\n\t\t\tname:          \"missing key returns nil\",\n\t\t\tdata:          mappedResult{},\n\t\t\tkey:           \"missing\",\n\t\t\texpectedValue: nil,\n\t\t},\n\t\t{\n\t\t\tname:          \"wrong type returns nil\",\n\t\t\tdata:          mappedResult{\"duration\": \"120\"},\n\t\t\tkey:           \"duration\",\n\t\t\texpectedValue: nil,\n\t\t},\n\t\t{\n\t\t\tname:          \"zero value\",\n\t\t\tdata:          mappedResult{\"duration\": 0},\n\t\t\tkey:           \"duration\",\n\t\t\texpectedValue: intPtr(0),\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tval := test.data.IntPtr(test.key)\n\t\t\tassert.Equal(t, test.expectedValue, val)\n\t\t})\n\t}\n}\n\n// Test setSingleValue method\nfunc TestMappedResultsSetSingleValue(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tinitialResults mappedResults\n\t\tindex          int\n\t\tkey            string\n\t\tvalue          string\n\t\texpectedLen    int\n\t\tshouldPanic    bool\n\t}{\n\t\t{\n\t\t\tname:           \"append to empty\",\n\t\t\tinitialResults: mappedResults{},\n\t\t\tindex:          0,\n\t\t\tkey:            \"name\",\n\t\t\tvalue:          \"test\",\n\t\t\texpectedLen:    1,\n\t\t\tshouldPanic:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"set in existing\",\n\t\t\tinitialResults: mappedResults{mappedResult{}},\n\t\t\tindex:          0,\n\t\t\tkey:            \"name\",\n\t\t\tvalue:          \"test\",\n\t\t\texpectedLen:    1,\n\t\t\tshouldPanic:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"append to existing\",\n\t\t\tinitialResults: mappedResults{mappedResult{}},\n\t\t\tindex:          1,\n\t\t\tkey:            \"name\",\n\t\t\tvalue:          \"test\",\n\t\t\texpectedLen:    2,\n\t\t\tshouldPanic:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"sparse index causes panic\",\n\t\t\tinitialResults: mappedResults{mappedResult{}},\n\t\t\tindex:          5,\n\t\t\tkey:            \"name\",\n\t\t\tvalue:          \"test\",\n\t\t\texpectedLen:    6,\n\t\t\tshouldPanic:    true,\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tif test.shouldPanic {\n\t\t\t\tassert.Panics(t, func() {\n\t\t\t\t\ttest.initialResults.setSingleValue(test.index, test.key, test.value)\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tresults := test.initialResults.setSingleValue(test.index, test.key, test.value)\n\t\t\t\tassert.Equal(t, test.expectedLen, len(results))\n\t\t\t\tassert.Equal(t, test.value, results[test.index][test.key])\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test setMultiValue method\nfunc TestMappedResultsSetMultiValue(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tinitialResults mappedResults\n\t\tindex          int\n\t\tkey            string\n\t\tvalue          []string\n\t\texpectedLen    int\n\t}{\n\t\t{\n\t\t\tname:           \"append to empty\",\n\t\t\tinitialResults: mappedResults{},\n\t\t\tindex:          0,\n\t\t\tkey:            \"tags\",\n\t\t\tvalue:          []string{\"a\", \"b\"},\n\t\t\texpectedLen:    1,\n\t\t},\n\t\t{\n\t\t\tname:           \"set in existing\",\n\t\t\tinitialResults: mappedResults{mappedResult{}},\n\t\t\tindex:          0,\n\t\t\tkey:            \"tags\",\n\t\t\tvalue:          []string{\"a\", \"b\"},\n\t\t\texpectedLen:    1,\n\t\t},\n\t\t{\n\t\t\tname:           \"append to existing\",\n\t\t\tinitialResults: mappedResults{mappedResult{}},\n\t\t\tindex:          1,\n\t\t\tkey:            \"tags\",\n\t\t\tvalue:          []string{\"x\", \"y\"},\n\t\t\texpectedLen:    2,\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tresults := test.initialResults.setMultiValue(test.index, test.key, test.value)\n\t\t\tassert.Equal(t, test.expectedLen, len(results))\n\t\t\tassert.Equal(t, test.value, results[test.index][test.key])\n\t\t})\n\t}\n}\n\n// Test scrapedTag method\nfunc TestMappedResultScrapedTag(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tdata         mappedResult\n\t\texpectedName string\n\t}{\n\t\t{\n\t\t\tname:         \"valid tag\",\n\t\t\tdata:         mappedResult{\"Name\": \"Action\"},\n\t\t\texpectedName: \"Action\",\n\t\t},\n\t\t{\n\t\t\tname:         \"missing name\",\n\t\t\tdata:         mappedResult{},\n\t\t\texpectedName: \"\",\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\ttag := test.data.scrapedTag()\n\t\t\tassert.NotNil(t, tag)\n\t\t\tassert.Equal(t, test.expectedName, tag.Name)\n\t\t})\n\t}\n}\n\n// Test scrapedTags method\nfunc TestMappedResultsScrapedTags(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResults\n\t\texpectedCount int\n\t\texpectedNames []string\n\t}{\n\t\t{\n\t\t\tname:          \"empty results\",\n\t\t\tdata:          mappedResults{},\n\t\t\texpectedCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"single tag\",\n\t\t\tdata: mappedResults{\n\t\t\t\tmappedResult{\"Name\": \"Action\"},\n\t\t\t},\n\t\t\texpectedCount: 1,\n\t\t\texpectedNames: []string{\"Action\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple tags\",\n\t\t\tdata: mappedResults{\n\t\t\t\tmappedResult{\"Name\": \"Action\"},\n\t\t\t\tmappedResult{\"Name\": \"Drama\"},\n\t\t\t\tmappedResult{\"Name\": \"Comedy\"},\n\t\t\t},\n\t\t\texpectedCount: 3,\n\t\t\texpectedNames: []string{\"Action\", \"Drama\", \"Comedy\"},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\ttags := test.data.scrapedTags()\n\t\t\tif test.expectedCount == 0 {\n\t\t\t\tassert.Nil(t, tags)\n\t\t\t} else {\n\t\t\t\tassert.NotNil(t, tags)\n\t\t\t\tassert.Equal(t, test.expectedCount, len(tags))\n\t\t\t\tfor i, expectedName := range test.expectedNames {\n\t\t\t\t\tassert.Equal(t, expectedName, tags[i].Name)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test scrapedPerformer method\nfunc TestMappedResultScrapedPerformer(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdata     mappedResult\n\t\tvalidate func(t *testing.T, p *models.ScrapedPerformer)\n\t}{\n\t\t{\n\t\t\tname: \"full performer\",\n\t\t\tdata: mappedResult{\n\t\t\t\t\"Name\":           \"Jane Doe\",\n\t\t\t\t\"Disambiguation\": \"Actress\",\n\t\t\t\t\"Gender\":         \"Female\",\n\t\t\t\t\"URL\":            \"https://example.com/jane\",\n\t\t\t\t\"URLs\":           []string{\"url1\", \"url2\"},\n\t\t\t\t\"Twitter\":        \"@jane\",\n\t\t\t\t\"Birthdate\":      \"1990-01-01\",\n\t\t\t\t\"Ethnicity\":      \"Caucasian\",\n\t\t\t\t\"Country\":        \"USA\",\n\t\t\t\t\"EyeColor\":       \"Blue\",\n\t\t\t\t\"Height\":         \"5'6\\\"\",\n\t\t\t\t\"Measurements\":   \"36-24-36\",\n\t\t\t\t\"FakeTits\":       \"No\",\n\t\t\t\t\"PenisLength\":    \"N/A\",\n\t\t\t\t\"Circumcised\":    \"N/A\",\n\t\t\t\t\"CareerLength\":   \"10 years\",\n\t\t\t\t\"Tattoos\":        \"Yes\",\n\t\t\t\t\"Piercings\":      \"Yes\",\n\t\t\t\t\"Aliases\":        \"Jane Smith\",\n\t\t\t\t\"Image\":          \"image.jpg\",\n\t\t\t\t\"Images\":         []string{\"img1\", \"img2\"},\n\t\t\t\t\"Details\":        \"Some details\",\n\t\t\t\t\"DeathDate\":      \"N/A\",\n\t\t\t\t\"HairColor\":      \"Blonde\",\n\t\t\t\t\"Weight\":         \"130 lbs\",\n\t\t\t},\n\t\t\tvalidate: func(t *testing.T, p *models.ScrapedPerformer) {\n\t\t\t\tassert.NotNil(t, p)\n\t\t\t\tassert.Equal(t, \"Jane Doe\", *p.Name)\n\t\t\t\tassert.Equal(t, \"Actress\", *p.Disambiguation)\n\t\t\t\tassert.Equal(t, \"Female\", *p.Gender)\n\t\t\t\tassert.Equal(t, \"https://example.com/jane\", *p.URL)\n\t\t\t\tassert.Equal(t, []string{\"url1\", \"url2\"}, p.URLs)\n\t\t\t\tassert.Equal(t, \"@jane\", *p.Twitter)\n\t\t\t\tassert.Equal(t, \"Blonde\", *p.HairColor)\n\t\t\t\tassert.Equal(t, \"130 lbs\", *p.Weight)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"minimal performer\",\n\t\t\tdata: mappedResult{},\n\t\t\tvalidate: func(t *testing.T, p *models.ScrapedPerformer) {\n\t\t\t\tassert.NotNil(t, p)\n\t\t\t\tassert.Nil(t, p.Name)\n\t\t\t\tassert.Nil(t, p.Gender)\n\t\t\t\tassert.Empty(t, p.URLs)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tperformer := test.data.scrapedPerformer()\n\t\t\ttest.validate(t, performer)\n\t\t})\n\t}\n}\n\n// Test scrapedPerformers method\nfunc TestMappedResultsScrapedPerformers(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResults\n\t\texpectedCount int\n\t}{\n\t\t{\n\t\t\tname:          \"empty results\",\n\t\t\tdata:          mappedResults{},\n\t\t\texpectedCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"single performer\",\n\t\t\tdata: mappedResults{\n\t\t\t\tmappedResult{\"Name\": \"Jane Doe\"},\n\t\t\t},\n\t\t\texpectedCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple performers\",\n\t\t\tdata: mappedResults{\n\t\t\t\tmappedResult{\"Name\": \"Jane Doe\"},\n\t\t\t\tmappedResult{\"Name\": \"John Doe\"},\n\t\t\t\tmappedResult{\"Name\": \"Alice\"},\n\t\t\t},\n\t\t\texpectedCount: 3,\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tperformers := test.data.scrapedPerformers()\n\t\t\tif test.expectedCount == 0 {\n\t\t\t\tassert.Nil(t, performers)\n\t\t\t} else {\n\t\t\t\tassert.NotNil(t, performers)\n\t\t\t\tassert.Equal(t, test.expectedCount, len(performers))\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test scrapedScene method\nfunc TestMappedResultScrapedScene(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdata     mappedResult\n\t\tvalidate func(t *testing.T, s *models.ScrapedScene)\n\t}{\n\t\t{\n\t\t\tname: \"full scene\",\n\t\t\tdata: mappedResult{\n\t\t\t\t\"Title\":    \"Scene Title\",\n\t\t\t\t\"Code\":     \"CODE123\",\n\t\t\t\t\"Details\":  \"Scene details\",\n\t\t\t\t\"Director\": \"John Smith\",\n\t\t\t\t\"URL\":      \"https://example.com/scene\",\n\t\t\t\t\"URLs\":     []string{\"url1\", \"url2\"},\n\t\t\t\t\"Date\":     \"2020-01-01\",\n\t\t\t\t\"Image\":    \"scene.jpg\",\n\t\t\t\t\"Duration\": 3600,\n\t\t\t},\n\t\t\tvalidate: func(t *testing.T, s *models.ScrapedScene) {\n\t\t\t\tassert.NotNil(t, s)\n\t\t\t\tassert.Equal(t, \"Scene Title\", *s.Title)\n\t\t\t\tassert.Equal(t, \"CODE123\", *s.Code)\n\t\t\t\tassert.Equal(t, \"Scene details\", *s.Details)\n\t\t\t\tassert.Equal(t, \"John Smith\", *s.Director)\n\t\t\t\tassert.Equal(t, \"https://example.com/scene\", *s.URL)\n\t\t\t\tassert.Equal(t, []string{\"url1\", \"url2\"}, s.URLs)\n\t\t\t\tassert.Equal(t, \"2020-01-01\", *s.Date)\n\t\t\t\tassert.Equal(t, \"scene.jpg\", *s.Image)\n\t\t\t\tassert.Equal(t, 3600, *s.Duration)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"minimal scene\",\n\t\t\tdata: mappedResult{},\n\t\t\tvalidate: func(t *testing.T, s *models.ScrapedScene) {\n\t\t\t\tassert.NotNil(t, s)\n\t\t\t\tassert.Nil(t, s.Title)\n\t\t\t\tassert.Nil(t, s.Duration)\n\t\t\t\tassert.Empty(t, s.URLs)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tscene := test.data.scrapedScene()\n\t\t\ttest.validate(t, scene)\n\t\t})\n\t}\n}\n\n// Test scrapedImage method\nfunc TestMappedResultScrapedImage(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdata     mappedResult\n\t\tvalidate func(t *testing.T, i *models.ScrapedImage)\n\t}{\n\t\t{\n\t\t\tname: \"full image\",\n\t\t\tdata: mappedResult{\n\t\t\t\t\"Title\":        \"Image Title\",\n\t\t\t\t\"Code\":         \"IMG123\",\n\t\t\t\t\"Details\":      \"Image details\",\n\t\t\t\t\"Photographer\": \"Jane Photographer\",\n\t\t\t\t\"URLs\":         []string{\"url1\", \"url2\"},\n\t\t\t\t\"Date\":         \"2020-06-15\",\n\t\t\t},\n\t\t\tvalidate: func(t *testing.T, i *models.ScrapedImage) {\n\t\t\t\tassert.NotNil(t, i)\n\t\t\t\tassert.Equal(t, \"Image Title\", *i.Title)\n\t\t\t\tassert.Equal(t, \"IMG123\", *i.Code)\n\t\t\t\tassert.Equal(t, \"Image details\", *i.Details)\n\t\t\t\tassert.Equal(t, \"Jane Photographer\", *i.Photographer)\n\t\t\t\tassert.Equal(t, []string{\"url1\", \"url2\"}, i.URLs)\n\t\t\t\tassert.Equal(t, \"2020-06-15\", *i.Date)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"minimal image\",\n\t\t\tdata: mappedResult{},\n\t\t\tvalidate: func(t *testing.T, i *models.ScrapedImage) {\n\t\t\t\tassert.NotNil(t, i)\n\t\t\t\tassert.Nil(t, i.Title)\n\t\t\t\tassert.Empty(t, i.URLs)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\timage := test.data.scrapedImage()\n\t\t\ttest.validate(t, image)\n\t\t})\n\t}\n}\n\n// Test scrapedGallery method\nfunc TestMappedResultScrapedGallery(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdata     mappedResult\n\t\tvalidate func(t *testing.T, g *models.ScrapedGallery)\n\t}{\n\t\t{\n\t\t\tname: \"full gallery\",\n\t\t\tdata: mappedResult{\n\t\t\t\t\"Title\":        \"Gallery Title\",\n\t\t\t\t\"Code\":         \"GAL123\",\n\t\t\t\t\"Details\":      \"Gallery details\",\n\t\t\t\t\"Photographer\": \"Jane Photographer\",\n\t\t\t\t\"URL\":          \"https://example.com/gallery\",\n\t\t\t\t\"URLs\":         []string{\"url1\", \"url2\"},\n\t\t\t\t\"Date\":         \"2020-07-20\",\n\t\t\t},\n\t\t\tvalidate: func(t *testing.T, g *models.ScrapedGallery) {\n\t\t\t\tassert.NotNil(t, g)\n\t\t\t\tassert.Equal(t, \"Gallery Title\", *g.Title)\n\t\t\t\tassert.Equal(t, \"GAL123\", *g.Code)\n\t\t\t\tassert.Equal(t, \"Gallery details\", *g.Details)\n\t\t\t\tassert.Equal(t, \"Jane Photographer\", *g.Photographer)\n\t\t\t\tassert.Equal(t, \"https://example.com/gallery\", *g.URL)\n\t\t\t\tassert.Equal(t, []string{\"url1\", \"url2\"}, g.URLs)\n\t\t\t\tassert.Equal(t, \"2020-07-20\", *g.Date)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"minimal gallery\",\n\t\t\tdata: mappedResult{},\n\t\t\tvalidate: func(t *testing.T, g *models.ScrapedGallery) {\n\t\t\t\tassert.NotNil(t, g)\n\t\t\t\tassert.Nil(t, g.Title)\n\t\t\t\tassert.Empty(t, g.URLs)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tgallery := test.data.scrapedGallery()\n\t\t\ttest.validate(t, gallery)\n\t\t})\n\t}\n}\n\n// Test scrapedStudio method\nfunc TestMappedResultScrapedStudio(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdata     mappedResult\n\t\tvalidate func(t *testing.T, st *models.ScrapedStudio)\n\t}{\n\t\t{\n\t\t\tname: \"full studio\",\n\t\t\tdata: mappedResult{\n\t\t\t\t\"Name\":    \"Studio Name\",\n\t\t\t\t\"URL\":     \"https://example.com/studio\",\n\t\t\t\t\"URLs\":    []string{\"url1\", \"url2\"},\n\t\t\t\t\"Image\":   \"studio.jpg\",\n\t\t\t\t\"Details\": \"Studio details\",\n\t\t\t\t\"Aliases\": \"Studio Alias\",\n\t\t\t},\n\t\t\tvalidate: func(t *testing.T, st *models.ScrapedStudio) {\n\t\t\t\tassert.NotNil(t, st)\n\t\t\t\tassert.Equal(t, \"Studio Name\", st.Name)\n\t\t\t\tassert.Equal(t, \"https://example.com/studio\", *st.URL)\n\t\t\t\tassert.Equal(t, []string{\"url1\", \"url2\"}, st.URLs)\n\t\t\t\tassert.Equal(t, \"studio.jpg\", *st.Image)\n\t\t\t\tassert.Equal(t, \"Studio details\", *st.Details)\n\t\t\t\tassert.Equal(t, \"Studio Alias\", *st.Aliases)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"minimal studio\",\n\t\t\tdata: mappedResult{},\n\t\t\tvalidate: func(t *testing.T, st *models.ScrapedStudio) {\n\t\t\t\tassert.NotNil(t, st)\n\t\t\t\tassert.Equal(t, \"\", st.Name) // mustString returns empty string\n\t\t\t\tassert.Nil(t, st.URL)\n\t\t\t\tassert.Empty(t, st.URLs)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tstudio := test.data.scrapedStudio()\n\t\t\ttest.validate(t, studio)\n\t\t})\n\t}\n}\n\n// Test scrapedMovie method\nfunc TestMappedResultScrapedMovie(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdata     mappedResult\n\t\tvalidate func(t *testing.T, m *models.ScrapedMovie)\n\t}{\n\t\t{\n\t\t\tname: \"full movie\",\n\t\t\tdata: mappedResult{\n\t\t\t\t\"Name\":       \"Movie Title\",\n\t\t\t\t\"Aliases\":    \"Movie Alias\",\n\t\t\t\t\"URLs\":       []string{\"url1\", \"url2\"},\n\t\t\t\t\"Duration\":   \"120 minutes\",\n\t\t\t\t\"Date\":       \"2020-05-10\",\n\t\t\t\t\"Director\":   \"John Director\",\n\t\t\t\t\"Synopsis\":   \"Movie synopsis\",\n\t\t\t\t\"FrontImage\": \"front.jpg\",\n\t\t\t\t\"BackImage\":  \"back.jpg\",\n\t\t\t},\n\t\t\tvalidate: func(t *testing.T, m *models.ScrapedMovie) {\n\t\t\t\tassert.NotNil(t, m)\n\t\t\t\tassert.Equal(t, \"Movie Title\", *m.Name)\n\t\t\t\tassert.Equal(t, \"Movie Alias\", *m.Aliases)\n\t\t\t\tassert.Equal(t, []string{\"url1\", \"url2\"}, m.URLs)\n\t\t\t\tassert.Equal(t, \"120 minutes\", *m.Duration)\n\t\t\t\tassert.Equal(t, \"2020-05-10\", *m.Date)\n\t\t\t\tassert.Equal(t, \"John Director\", *m.Director)\n\t\t\t\tassert.Equal(t, \"Movie synopsis\", *m.Synopsis)\n\t\t\t\tassert.Equal(t, \"front.jpg\", *m.FrontImage)\n\t\t\t\tassert.Equal(t, \"back.jpg\", *m.BackImage)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"minimal movie\",\n\t\t\tdata: mappedResult{},\n\t\t\tvalidate: func(t *testing.T, m *models.ScrapedMovie) {\n\t\t\t\tassert.NotNil(t, m)\n\t\t\t\tassert.Nil(t, m.Name)\n\t\t\t\tassert.Empty(t, m.URLs)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tmovie := test.data.scrapedMovie()\n\t\t\ttest.validate(t, movie)\n\t\t})\n\t}\n}\n\n// Test scrapedMovies method\nfunc TestMappedResultsScrapedMovies(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResults\n\t\texpectedCount int\n\t}{\n\t\t{\n\t\t\tname:          \"empty results\",\n\t\t\tdata:          mappedResults{},\n\t\t\texpectedCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"single movie\",\n\t\t\tdata: mappedResults{\n\t\t\t\tmappedResult{\"Name\": \"Movie 1\"},\n\t\t\t},\n\t\t\texpectedCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple movies\",\n\t\t\tdata: mappedResults{\n\t\t\t\tmappedResult{\"Name\": \"Movie 1\"},\n\t\t\t\tmappedResult{\"Name\": \"Movie 2\"},\n\t\t\t\tmappedResult{\"Name\": \"Movie 3\"},\n\t\t\t},\n\t\t\texpectedCount: 3,\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tmovies := test.data.scrapedMovies()\n\t\t\tif test.expectedCount == 0 {\n\t\t\t\tassert.Nil(t, movies)\n\t\t\t} else {\n\t\t\t\tassert.NotNil(t, movies)\n\t\t\t\tassert.Equal(t, test.expectedCount, len(movies))\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test scrapedGroup method\nfunc TestMappedResultScrapedGroup(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdata     mappedResult\n\t\tvalidate func(t *testing.T, g *models.ScrapedGroup)\n\t}{\n\t\t{\n\t\t\tname: \"full group\",\n\t\t\tdata: mappedResult{\n\t\t\t\t\"Name\":       \"Group Title\",\n\t\t\t\t\"Aliases\":    \"Group Alias\",\n\t\t\t\t\"URL\":        \"https://example.com/group\",\n\t\t\t\t\"URLs\":       []string{\"url1\", \"url2\"},\n\t\t\t\t\"Duration\":   \"240 minutes\",\n\t\t\t\t\"Date\":       \"2020-08-15\",\n\t\t\t\t\"Director\":   \"Jane Director\",\n\t\t\t\t\"Synopsis\":   \"Group synopsis\",\n\t\t\t\t\"FrontImage\": \"front.jpg\",\n\t\t\t\t\"BackImage\":  \"back.jpg\",\n\t\t\t},\n\t\t\tvalidate: func(t *testing.T, g *models.ScrapedGroup) {\n\t\t\t\tassert.NotNil(t, g)\n\t\t\t\tassert.Equal(t, \"Group Title\", *g.Name)\n\t\t\t\tassert.Equal(t, \"Group Alias\", *g.Aliases)\n\t\t\t\tassert.Equal(t, \"https://example.com/group\", *g.URL)\n\t\t\t\tassert.Equal(t, []string{\"url1\", \"url2\"}, g.URLs)\n\t\t\t\tassert.Equal(t, \"240 minutes\", *g.Duration)\n\t\t\t\tassert.Equal(t, \"2020-08-15\", *g.Date)\n\t\t\t\tassert.Equal(t, \"Jane Director\", *g.Director)\n\t\t\t\tassert.Equal(t, \"Group synopsis\", *g.Synopsis)\n\t\t\t\tassert.Equal(t, \"front.jpg\", *g.FrontImage)\n\t\t\t\tassert.Equal(t, \"back.jpg\", *g.BackImage)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"minimal group\",\n\t\t\tdata: mappedResult{},\n\t\t\tvalidate: func(t *testing.T, g *models.ScrapedGroup) {\n\t\t\t\tassert.NotNil(t, g)\n\t\t\t\tassert.Nil(t, g.Name)\n\t\t\t\tassert.Empty(t, g.URLs)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tgroup := test.data.scrapedGroup()\n\t\t\ttest.validate(t, group)\n\t\t})\n\t}\n}\n\n// Test scrapedGroups method\nfunc TestMappedResultsScrapedGroups(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tdata          mappedResults\n\t\texpectedCount int\n\t}{\n\t\t{\n\t\t\tname:          \"empty results\",\n\t\t\tdata:          mappedResults{},\n\t\t\texpectedCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"single group\",\n\t\t\tdata: mappedResults{\n\t\t\t\tmappedResult{\"Name\": \"Group 1\"},\n\t\t\t},\n\t\t\texpectedCount: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple groups\",\n\t\t\tdata: mappedResults{\n\t\t\t\tmappedResult{\"Name\": \"Group 1\"},\n\t\t\t\tmappedResult{\"Name\": \"Group 2\"},\n\t\t\t\tmappedResult{\"Name\": \"Group 3\"},\n\t\t\t},\n\t\t\texpectedCount: 3,\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tgroups := test.data.scrapedGroups()\n\t\t\tif test.expectedCount == 0 {\n\t\t\t\tassert.Nil(t, groups)\n\t\t\t} else {\n\t\t\t\tassert.NotNil(t, groups)\n\t\t\t\tassert.Equal(t, test.expectedCount, len(groups))\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Helper functions\nfunc strPtr(s string) *string {\n\treturn &s\n}\n\nfunc intPtr(i int) *int {\n\treturn &i\n}\n"
  },
  {
    "path": "pkg/scraper/mapped_test.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v2\"\n)\n\nfunc TestInvalidPostProcessAction(t *testing.T) {\n\tyamlStr := `name: Test\nperformerByURL:\n  - action: scrapeXPath\n    scraper: performerScraper\nxPathScrapers:\n  performerScraper:\n    performer:\n      Name:\n        selector: //div/a/@href\n        postProcess:\n          - parseDate: Jan 2, 2006\n          - anything\n`\n\n\tc := &Definition{}\n\terr := yaml.Unmarshal([]byte(yamlStr), &c)\n\n\tif err == nil {\n\t\tt.Error(\"expected error unmarshalling with invalid post-process action\")\n\t\treturn\n\t}\n}\n\ntype feetToCMTest struct {\n\tin  string\n\tout string\n}\n\nvar feetToCMTests = []feetToCMTest{\n\t{\"\", \"0\"},\n\t{\"a\", \"0\"},\n\t{\"6\", \"183\"},\n\t{\"6 feet\", \"183\"},\n\t{\"6ft0\", \"183\"},\n\t{\"6ft2\", \"188\"},\n\t{\"6'2\\\"\", \"188\"},\n\t{\"6.2\", \"188\"},\n\t{\"6ft2.99\", \"188\"},\n\t{\"text6other2\", \"188\"},\n}\n\nfunc TestFeetToCM(t *testing.T) {\n\tpp := postProcessFeetToCm(true)\n\n\tq := &xpathQuery{}\n\n\tfor _, test := range feetToCMTests {\n\t\tassert.Equal(t, test.out, pp.Apply(context.Background(), test.in, q))\n\t}\n}\n\nfunc Test_postProcessParseDate_Apply(t *testing.T) {\n\tconst internalDateFormat = \"2006-01-02\"\n\n\tunixDate := time.Date(2021, 9, 4, 1, 2, 3, 4, time.Local)\n\n\ttests := []struct {\n\t\tname  string\n\t\targ   postProcessParseDate\n\t\tvalue string\n\t\twant  string\n\t}{\n\t\t{\n\t\t\t\"simple\",\n\t\t\t\"2006=01=02\",\n\t\t\t\"2001=03=23\",\n\t\t\t\"2001-03-23\",\n\t\t},\n\t\t{\n\t\t\t\"today\",\n\t\t\t\"\",\n\t\t\t\"today\",\n\t\t\ttime.Now().Format(internalDateFormat),\n\t\t},\n\t\t{\n\t\t\t\"yesterday\",\n\t\t\t\"\",\n\t\t\t\"yesterday\",\n\t\t\ttime.Now().Add(-24 * time.Hour).Format(internalDateFormat),\n\t\t},\n\t\t{\n\t\t\t\"unix\",\n\t\t\t\"unix\",\n\t\t\tstrconv.FormatInt(unixDate.Unix(), 10),\n\t\t\tunixDate.Format(internalDateFormat),\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid\",\n\t\t\t\"2001=03=23\",\n\t\t\t\"2001=03=23\",\n\t\t},\n\t}\n\n\tctx := context.Background()\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := tt.arg.Apply(ctx, tt.value, nil); got != tt.want {\n\t\t\t\tt.Errorf(\"postProcessParseDate.Apply() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/scraper/movie.go",
    "content": "package scraper\n\ntype ScrapedMovieInput struct {\n\tName     *string  `json:\"name\"`\n\tAliases  *string  `json:\"aliases\"`\n\tDuration *string  `json:\"duration\"`\n\tDate     *string  `json:\"date\"`\n\tRating   *string  `json:\"rating\"`\n\tDirector *string  `json:\"director\"`\n\tURLs     []string `json:\"urls\"`\n\tSynopsis *string  `json:\"synopsis\"`\n\n\t// deprecated\n\tURL *string `json:\"url\"`\n}\n"
  },
  {
    "path": "pkg/scraper/performer.go",
    "content": "package scraper\n\ntype ScrapedPerformerInput struct {\n\t// Set if performer matched\n\tStoredID       *string  `json:\"stored_id\"`\n\tName           *string  `json:\"name\"`\n\tDisambiguation *string  `json:\"disambiguation\"`\n\tGender         *string  `json:\"gender\"`\n\tURLs           []string `json:\"urls\"`\n\tURL            *string  `json:\"url\"`       // deprecated\n\tTwitter        *string  `json:\"twitter\"`   // deprecated\n\tInstagram      *string  `json:\"instagram\"` // deprecated\n\tBirthdate      *string  `json:\"birthdate\"`\n\tEthnicity      *string  `json:\"ethnicity\"`\n\tCountry        *string  `json:\"country\"`\n\tEyeColor       *string  `json:\"eye_color\"`\n\tHeight         *string  `json:\"height\"`\n\tMeasurements   *string  `json:\"measurements\"`\n\tFakeTits       *string  `json:\"fake_tits\"`\n\tPenisLength    *string  `json:\"penis_length\"`\n\tCircumcised    *string  `json:\"circumcised\"`\n\tCareerLength   *string  `json:\"career_length\"`\n\tCareerStart    *string  `json:\"career_start\"`\n\tCareerEnd      *string  `json:\"career_end\"`\n\tTattoos        *string  `json:\"tattoos\"`\n\tPiercings      *string  `json:\"piercings\"`\n\tAliases        *string  `json:\"aliases\"`\n\tDetails        *string  `json:\"details\"`\n\tDeathDate      *string  `json:\"death_date\"`\n\tHairColor      *string  `json:\"hair_color\"`\n\tWeight         *string  `json:\"weight\"`\n\tRemoteSiteID   *string  `json:\"remote_site_id\"`\n}\n"
  },
  {
    "path": "pkg/scraper/post_processing_test.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc TestPostScrapePerformerCareerLength(t *testing.T) {\n\tctx := context.Background()\n\tconst related = false\n\n\tstrPtr := func(s string) *string {\n\t\treturn &s\n\t}\n\n\ttests := []struct {\n\t\tname  string\n\t\tinput models.ScrapedPerformer\n\t\twant  models.ScrapedPerformer\n\t}{\n\t\t{\n\t\t\t\"start = 2000\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerStart: strPtr(\"2000\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerStart:  strPtr(\"2000\"),\n\t\t\t\tCareerLength: strPtr(\"2000 -\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"end = 2000\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerEnd: strPtr(\"2000\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerEnd:    strPtr(\"2000\"),\n\t\t\t\tCareerLength: strPtr(\"- 2000\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"start = 2000, end = 2020\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerStart: strPtr(\"2000\"),\n\t\t\t\tCareerEnd:   strPtr(\"2020\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerStart:  strPtr(\"2000\"),\n\t\t\t\tCareerEnd:    strPtr(\"2020\"),\n\t\t\t\tCareerLength: strPtr(\"2000 - 2020\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"length = 2000 -\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerLength: strPtr(\"2000 -\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerStart:  strPtr(\"2000\"),\n\t\t\t\tCareerLength: strPtr(\"2000 -\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"length = - 2010\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerLength: strPtr(\"- 2010\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerEnd:    strPtr(\"2010\"),\n\t\t\t\tCareerLength: strPtr(\"- 2010\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"length = 2000 - 2010\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerLength: strPtr(\"2000 - 2010\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerStart:  strPtr(\"2000\"),\n\t\t\t\tCareerEnd:    strPtr(\"2010\"),\n\t\t\t\tCareerLength: strPtr(\"2000 - 2010\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"invalid start\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerStart: strPtr(\"two thousand\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerStart: strPtr(\"two thousand\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"invalid end\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerEnd: strPtr(\"two thousand\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerEnd: strPtr(\"two thousand\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"invalid career length\",\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerLength: strPtr(\"1234 - 4567 - 9224\"),\n\t\t\t},\n\t\t\tmodels.ScrapedPerformer{\n\t\t\t\tCareerLength: strPtr(\"1234 - 4567 - 9224\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tcompareStrPtr := func(a, b *string) bool {\n\t\tif a == b {\n\t\t\treturn true\n\t\t}\n\t\tif a == nil || b == nil {\n\t\t\treturn false\n\t\t}\n\t\treturn *a == *b\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tc := &postScraper{}\n\t\t\tgot, err := c.postScrapePerformer(ctx, tt.input, related)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"postScrapePerformer returned error: %v\", err)\n\t\t\t}\n\t\t\tpostScraped := got.(models.ScrapedPerformer)\n\t\t\tif !compareStrPtr(postScraped.CareerStart, tt.want.CareerStart) {\n\t\t\t\tt.Errorf(\"CareerStart = %v, want %v\", postScraped.CareerStart, tt.want.CareerStart)\n\t\t\t}\n\t\t\tif !compareStrPtr(postScraped.CareerEnd, tt.want.CareerEnd) {\n\t\t\t\tt.Errorf(\"CareerEnd = %v, want %v\", postScraped.CareerEnd, tt.want.CareerEnd)\n\t\t\t}\n\t\t\tif !compareStrPtr(postScraped.CareerLength, tt.want.CareerLength) {\n\t\t\t\tt.Errorf(\"CareerLength = %v, want %v\", postScraped.CareerLength, tt.want.CareerLength)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/scraper/postprocessing.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"regexp\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype postScraper struct {\n\tCache\n\texcludeTagRE []*regexp.Regexp\n\n\t// ignoredTags is a list of tags that were ignored during post-processing\n\tignoredTags []string\n}\n\n// postScrape handles post-processing of scraped content. If the content\n// requires post-processing, this function fans out to the given content\n// type and post-processes it.\n// Assumes called within a read transaction.\nfunc (c *postScraper) postScrape(ctx context.Context, content ScrapedContent) (_ ScrapedContent, err error) {\n\tconst related = false\n\n\t// Analyze the concrete type, call the right post-processing function\n\tswitch v := content.(type) {\n\tcase *models.ScrapedPerformer:\n\t\tif v != nil {\n\t\t\treturn c.postScrapePerformer(ctx, *v, related)\n\t\t}\n\tcase models.ScrapedPerformer:\n\t\treturn c.postScrapePerformer(ctx, v, related)\n\tcase *models.ScrapedScene:\n\t\tif v != nil {\n\t\t\treturn c.postScrapeScene(ctx, *v)\n\t\t}\n\tcase models.ScrapedScene:\n\t\treturn c.postScrapeScene(ctx, v)\n\tcase *models.ScrapedGallery:\n\t\tif v != nil {\n\t\t\treturn c.postScrapeGallery(ctx, *v)\n\t\t}\n\tcase models.ScrapedGallery:\n\t\treturn c.postScrapeGallery(ctx, v)\n\tcase *models.ScrapedImage:\n\t\tif v != nil {\n\t\t\treturn c.postScrapeImage(ctx, *v)\n\t\t}\n\tcase models.ScrapedImage:\n\t\treturn c.postScrapeImage(ctx, v)\n\tcase *models.ScrapedMovie:\n\t\tif v != nil {\n\t\t\treturn c.postScrapeMovie(ctx, *v, related)\n\t\t}\n\tcase models.ScrapedMovie:\n\t\treturn c.postScrapeMovie(ctx, v, related)\n\tcase *models.ScrapedGroup:\n\t\tif v != nil {\n\t\t\treturn c.postScrapeGroup(ctx, *v, related)\n\t\t}\n\tcase models.ScrapedGroup:\n\t\treturn c.postScrapeGroup(ctx, v, related)\n\t}\n\n\t// If nothing matches, pass the content through\n\treturn content, nil\n}\n\nfunc (c *postScraper) filterTags(tags []*models.ScrapedTag) []*models.ScrapedTag {\n\tvar ret []*models.ScrapedTag\n\tvar thisIgnoredTags []string\n\tret, thisIgnoredTags = FilterTags(c.excludeTagRE, tags)\n\tc.ignoredTags = sliceutil.AppendUniques(c.ignoredTags, thisIgnoredTags)\n\n\treturn ret\n}\n\nfunc (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedPerformer, related bool) (_ ScrapedContent, err error) {\n\tr := c.repository\n\ttqb := r.TagFinder\n\n\ttags, err := postProcessTags(ctx, tqb, p.Tags)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tp.Tags = c.filterTags(tags)\n\n\t// post-process - set the image if applicable\n\t// don't set image for related performers to avoid excessive network calls\n\tif !related {\n\t\tif err := setPerformerImage(ctx, c.client, &p, c.globalConfig); err != nil {\n\t\t\tlogger.Warnf(\"Could not set image using URL %s: %s\", *p.Image, err.Error())\n\t\t}\n\t}\n\n\tp.Country = resolveCountryName(p.Country)\n\n\t// populate URL/URLs\n\t// if URLs are provided, only use those\n\tif len(p.URLs) > 0 {\n\t\tp.URL = &p.URLs[0]\n\t} else {\n\t\turls := []string{}\n\t\tif p.URL != nil {\n\t\t\turls = append(urls, *p.URL)\n\t\t}\n\t\tif p.Twitter != nil && *p.Twitter != \"\" {\n\t\t\t// handle twitter profile names\n\t\t\tu := utils.URLFromHandle(*p.Twitter, \"https://twitter.com\")\n\t\t\turls = append(urls, u)\n\t\t}\n\t\tif p.Instagram != nil && *p.Instagram != \"\" {\n\t\t\t// handle instagram profile names\n\t\t\tu := utils.URLFromHandle(*p.Instagram, \"https://instagram.com\")\n\t\t\turls = append(urls, u)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tp.URLs = urls\n\t\t}\n\t}\n\n\tc.postProcessCareerLength(&p)\n\n\treturn p, nil\n}\n\nfunc (c *postScraper) postProcessCareerLength(p *models.ScrapedPerformer) {\n\tisEmptyStr := func(s *string) bool { return s == nil || *s == \"\" }\n\n\t// populate career start/end from career length and vice versa\n\tif !isEmptyStr(p.CareerLength) && isEmptyStr(p.CareerStart) && isEmptyStr(p.CareerEnd) {\n\t\tstart, end, err := models.ParseYearRangeString(*p.CareerLength)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"Could not parse career length %s: %v\", *p.CareerLength, err)\n\t\t\treturn\n\t\t}\n\n\t\tif start != nil {\n\t\t\tstartStr := start.String()\n\t\t\tp.CareerStart = &startStr\n\t\t}\n\t\tif end != nil {\n\t\t\tendStr := end.String()\n\t\t\tp.CareerEnd = &endStr\n\t\t}\n\n\t\treturn\n\t}\n\n\t// populate career length from career start/end if career length is missing\n\tif isEmptyStr(p.CareerLength) {\n\t\tvar (\n\t\t\tstart *models.Date\n\t\t\tend   *models.Date\n\t\t)\n\n\t\tif !isEmptyStr(p.CareerStart) {\n\t\t\tdate, err := models.ParseDate(*p.CareerStart)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Warnf(\"Could not parse career start %s: %v\", *p.CareerStart, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstart = &date\n\t\t}\n\n\t\tif !isEmptyStr(p.CareerEnd) {\n\t\t\tdate, err := models.ParseDate(*p.CareerEnd)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Warnf(\"Could not parse career end %s: %v\", *p.CareerEnd, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tend = &date\n\t\t}\n\n\t\tv := models.FormatYearRange(start, end)\n\t\tp.CareerLength = &v\n\t}\n}\n\nfunc (c *postScraper) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, related bool) (_ ScrapedContent, err error) {\n\tr := c.repository\n\ttqb := r.TagFinder\n\ttags, err := postProcessTags(ctx, tqb, m.Tags)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tm.Tags = c.filterTags(tags)\n\n\tif m.Studio != nil {\n\t\tif err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, \"\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// populate URL/URLs\n\t// if URLs are provided, only use those\n\tif len(m.URLs) > 0 {\n\t\tm.URL = &m.URLs[0]\n\t} else {\n\t\turls := []string{}\n\t\tif m.URL != nil {\n\t\t\turls = append(urls, *m.URL)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tm.URLs = urls\n\t\t}\n\t}\n\n\t// post-process - set the image if applicable\n\t// don't set images for related movies to avoid excessive network calls\n\tif !related {\n\t\tif err := processImageField(ctx, m.FrontImage, c.client, c.globalConfig); err != nil {\n\t\t\tlogger.Warnf(\"could not set front image using URL %s: %v\", *m.FrontImage, err)\n\t\t}\n\t\tif err := processImageField(ctx, m.BackImage, c.client, c.globalConfig); err != nil {\n\t\t\tlogger.Warnf(\"could not set back image using URL %s: %v\", *m.BackImage, err)\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\nfunc (c *postScraper) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, related bool) (_ ScrapedContent, err error) {\n\tr := c.repository\n\ttqb := r.TagFinder\n\ttags, err := postProcessTags(ctx, tqb, m.Tags)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tm.Tags = c.filterTags(tags)\n\n\tif m.Studio != nil {\n\t\tif err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, \"\"); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// populate URL/URLs\n\t// if URLs are provided, only use those\n\tif len(m.URLs) > 0 {\n\t\tm.URL = &m.URLs[0]\n\t} else {\n\t\turls := []string{}\n\t\tif m.URL != nil {\n\t\t\turls = append(urls, *m.URL)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tm.URLs = urls\n\t\t}\n\t}\n\n\t// post-process - set the image if applicable\n\t// don't set images for related groups to avoid excessive network calls\n\tif !related {\n\t\tif err := processImageField(ctx, m.FrontImage, c.client, c.globalConfig); err != nil {\n\t\t\tlogger.Warnf(\"could not set front image using URL %s: %v\", *m.FrontImage, err)\n\t\t}\n\t\tif err := processImageField(ctx, m.BackImage, c.client, c.globalConfig); err != nil {\n\t\t\tlogger.Warnf(\"could not set back image using URL %s: %v\", *m.BackImage, err)\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\n// postScrapeRelatedPerformers post-processes a list of performers.\n// It modifies the performers in place.\nfunc (c *postScraper) postScrapeRelatedPerformers(ctx context.Context, items []*models.ScrapedPerformer) error {\n\tfor _, p := range items {\n\t\tif p == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tconst related = true\n\t\tsc, err := c.postScrapePerformer(ctx, *p, related)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tnewP := sc.(models.ScrapedPerformer)\n\t\t*p = newP\n\n\t\tif err := match.ScrapedPerformer(ctx, c.repository.PerformerFinder, p, \"\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (c *postScraper) postScrapeRelatedMovies(ctx context.Context, items []*models.ScrapedMovie) error {\n\tfor _, p := range items {\n\t\tconst related = true\n\t\tsc, err := c.postScrapeMovie(ctx, *p, related)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tnewP := sc.(models.ScrapedMovie)\n\t\t*p = newP\n\n\t\tmatchedID, err := match.ScrapedGroup(ctx, c.repository.GroupFinder, p.StoredID, p.Name)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif matchedID != nil {\n\t\t\tp.StoredID = matchedID\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *postScraper) postScrapeRelatedGroups(ctx context.Context, items []*models.ScrapedGroup) error {\n\tfor _, p := range items {\n\t\tconst related = true\n\t\tsc, err := c.postScrapeGroup(ctx, *p, related)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tnewP := sc.(models.ScrapedGroup)\n\t\t*p = newP\n\n\t\tmatchedID, err := match.ScrapedGroup(ctx, c.repository.GroupFinder, p.StoredID, p.Name)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif matchedID != nil {\n\t\t\tp.StoredID = matchedID\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *postScraper) postScrapeStudio(ctx context.Context, s models.ScrapedStudio, related bool) (_ ScrapedContent, err error) {\n\tr := c.repository\n\ttqb := r.TagFinder\n\n\ttags, err := postProcessTags(ctx, tqb, s.Tags)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ts.Tags = c.filterTags(tags)\n\n\t// post-process - set the image if applicable\n\t// don't set image for related studios to avoid excessive network calls\n\tif !related {\n\t\tif err := setStudioImage(ctx, c.client, &s, c.globalConfig); err != nil {\n\t\t\tlogger.Warnf(\"Could not set image using URL %s: %s\", *s.Image, err.Error())\n\t\t}\n\t}\n\n\t// populate URL/URLs\n\t// if URLs are provided, only use those\n\tif len(s.URLs) > 0 {\n\t\ts.URL = &s.URLs[0]\n\t} else {\n\t\turls := []string{}\n\t\tif s.URL != nil {\n\t\t\turls = append(urls, *s.URL)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\ts.URLs = urls\n\t\t}\n\t}\n\n\treturn s, nil\n}\n\nfunc (c *postScraper) postScrapeRelatedStudio(ctx context.Context, s *models.ScrapedStudio) error {\n\tif s == nil {\n\t\treturn nil\n\t}\n\n\tconst related = true\n\tsc, err := c.postScrapeStudio(ctx, *s, related)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnewS := sc.(models.ScrapedStudio)\n\t*s = newS\n\n\tif err = match.ScrapedStudio(ctx, c.repository.StudioFinder, s, \"\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (c *postScraper) postScrapeScene(ctx context.Context, scene models.ScrapedScene) (_ ScrapedContent, err error) {\n\t// set the URL/URLs field\n\tif scene.URL == nil && len(scene.URLs) > 0 {\n\t\tscene.URL = &scene.URLs[0]\n\t}\n\tif scene.URL != nil && len(scene.URLs) == 0 {\n\t\tscene.URLs = []string{*scene.URL}\n\t}\n\n\tr := c.repository\n\ttqb := r.TagFinder\n\n\tif err = c.postScrapeRelatedPerformers(ctx, scene.Performers); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err = c.postScrapeRelatedMovies(ctx, scene.Movies); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err = c.postScrapeRelatedGroups(ctx, scene.Groups); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// HACK - if movies was returned but not groups, add the groups from the movies\n\t// if groups was returned but not movies, add the movies from the groups for backward compatibility\n\tif len(scene.Movies) > 0 && len(scene.Groups) == 0 {\n\t\tfor _, m := range scene.Movies {\n\t\t\tg := m.ScrapedGroup()\n\t\t\tscene.Groups = append(scene.Groups, &g)\n\t\t}\n\t} else if len(scene.Groups) > 0 && len(scene.Movies) == 0 {\n\t\tfor _, g := range scene.Groups {\n\t\t\tm := g.ScrapedMovie()\n\t\t\tscene.Movies = append(scene.Movies, &m)\n\t\t}\n\t}\n\n\ttags, err := postProcessTags(ctx, tqb, scene.Tags)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tscene.Tags = c.filterTags(tags)\n\n\tif err := c.postScrapeRelatedStudio(ctx, scene.Studio); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// post-process - set the image if applicable\n\tif err := processImageField(ctx, scene.Image, c.client, c.globalConfig); err != nil {\n\t\tlogger.Warnf(\"Could not set image using URL %s: %v\", *scene.Image, err)\n\t}\n\n\treturn scene, nil\n}\n\nfunc (c *postScraper) postScrapeGallery(ctx context.Context, g models.ScrapedGallery) (_ ScrapedContent, err error) {\n\t// set the URL/URLs field\n\tif g.URL == nil && len(g.URLs) > 0 {\n\t\tg.URL = &g.URLs[0]\n\t}\n\tif g.URL != nil && len(g.URLs) == 0 {\n\t\tg.URLs = []string{*g.URL}\n\t}\n\n\tr := c.repository\n\ttqb := r.TagFinder\n\n\tif err = c.postScrapeRelatedPerformers(ctx, g.Performers); err != nil {\n\t\treturn nil, err\n\t}\n\n\ttags, err := postProcessTags(ctx, tqb, g.Tags)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tg.Tags = c.filterTags(tags)\n\n\tif err := c.postScrapeRelatedStudio(ctx, g.Studio); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn g, nil\n}\n\nfunc (c *postScraper) postScrapeImage(ctx context.Context, image models.ScrapedImage) (_ ScrapedContent, err error) {\n\tr := c.repository\n\ttqb := r.TagFinder\n\n\tif err = c.postScrapeRelatedPerformers(ctx, image.Performers); err != nil {\n\t\treturn nil, err\n\t}\n\n\ttags, err := postProcessTags(ctx, tqb, image.Tags)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\timage.Tags = c.filterTags(tags)\n\n\tif err := c.postScrapeRelatedStudio(ctx, image.Studio); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn image, nil\n}\n\n// postScrapeSingle handles post-processing of a single scraped content item.\n// This is a convenience function that includes logging the ignored tags, as opposed to logging them in the caller.\nfunc (c Cache) postScrapeSingle(ctx context.Context, content ScrapedContent) (ret ScrapedContent, err error) {\n\tpp := postScraper{\n\t\tCache:        c,\n\t\texcludeTagRE: c.compileExcludeTagPatterns(),\n\t}\n\n\tif err := c.repository.WithReadTxn(ctx, func(ctx context.Context) error {\n\t\tret, err = pp.postScrape(ctx, content)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tLogIgnoredTags(pp.ignoredTags)\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/scraper/query_url.go",
    "content": "package scraper\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype queryURLReplacements map[string]mappedRegexConfigs\n\ntype queryURLParameters map[string]string\n\nfunc queryURLParametersFromScene(scene *models.Scene) queryURLParameters {\n\tret := make(queryURLParameters)\n\tret[\"checksum\"] = scene.Checksum\n\tret[\"oshash\"] = scene.OSHash\n\tret[\"filename\"] = filepath.Base(scene.Path)\n\n\t// pull phash from primary file\n\tphashFingerprints := scene.Files.Primary().Base().Fingerprints.Filter(models.FingerprintTypePhash)\n\tif len(phashFingerprints) > 0 {\n\t\tret[\"phash\"] = phashFingerprints[0].Value()\n\t}\n\n\tif scene.Title != \"\" {\n\t\tret[\"title\"] = scene.Title\n\t}\n\tif len(scene.URLs.List()) > 0 {\n\t\tret[\"url\"] = scene.URLs.List()[0]\n\t}\n\treturn ret\n}\n\nfunc queryURLParametersFromScrapedScene(scene models.ScrapedSceneInput) queryURLParameters {\n\tret := make(queryURLParameters)\n\n\tsetField := func(field string, value *string) {\n\t\tif value != nil {\n\t\t\tret[field] = *value\n\t\t}\n\t}\n\n\tsetField(\"title\", scene.Title)\n\tsetField(\"code\", scene.Code)\n\tif len(scene.URLs) > 0 {\n\t\tsetField(\"url\", &scene.URLs[0])\n\t} else {\n\t\tsetField(\"url\", scene.URL)\n\t}\n\tsetField(\"date\", scene.Date)\n\tsetField(\"details\", scene.Details)\n\tsetField(\"director\", scene.Director)\n\tsetField(\"remote_site_id\", scene.RemoteSiteID)\n\treturn ret\n}\n\nfunc queryURLParameterFromURL(url string) queryURLParameters {\n\tret := make(queryURLParameters)\n\tret[\"url\"] = url\n\treturn ret\n}\n\nfunc queryURLParametersFromGallery(gallery *models.Gallery) queryURLParameters {\n\tret := make(queryURLParameters)\n\tret[\"checksum\"] = gallery.PrimaryChecksum()\n\n\tif gallery.Path != \"\" {\n\t\tret[\"filename\"] = filepath.Base(gallery.Path)\n\t}\n\tif gallery.Title != \"\" {\n\t\tret[\"title\"] = gallery.Title\n\t}\n\n\tif len(gallery.URLs.List()) > 0 {\n\t\tret[\"url\"] = gallery.URLs.List()[0]\n\t}\n\n\treturn ret\n}\n\nfunc queryURLParametersFromImage(image *models.Image) queryURLParameters {\n\tret := make(queryURLParameters)\n\tret[\"checksum\"] = image.Checksum\n\n\tif image.Path != \"\" {\n\t\tret[\"filename\"] = filepath.Base(image.Path)\n\t}\n\tif image.Title != \"\" {\n\t\tret[\"title\"] = image.Title\n\t}\n\n\tif len(image.URLs.List()) > 0 {\n\t\tret[\"url\"] = image.URLs.List()[0]\n\t}\n\n\treturn ret\n}\n\nfunc (p queryURLParameters) applyReplacements(r queryURLReplacements) {\n\tfor k, v := range p {\n\t\trpl, found := r[k]\n\t\tif found {\n\t\t\tp[k] = rpl.apply(v)\n\t\t}\n\t}\n}\n\nfunc (p queryURLParameters) constructURL(url string) string {\n\tret := url\n\tfor k, v := range p {\n\t\tret = strings.ReplaceAll(ret, \"{\"+k+\"}\", v)\n\t}\n\n\treturn ret\n}\n\n// replaceURL does a partial URL Replace ( only url parameter is used)\nfunc replaceURL(url string, scraperConfig ByURLDefinition) string {\n\tu := url\n\tqueryURL := queryURLParameterFromURL(u)\n\tif scraperConfig.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(scraperConfig.QueryURLReplacements)\n\t\tu = queryURL.constructURL(scraperConfig.QueryURL)\n\t}\n\treturn u\n}\n"
  },
  {
    "path": "pkg/scraper/scraper.go",
    "content": "// Package scraper provides interfaces to interact with the scraper subsystem.\n// The [Cache] type is the main entry point to the scraper subsystem.\npackage scraper\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype Source struct {\n\t// Index of the configured stash-box instance to use. Should be unset if scraper_id is set\n\tStashBoxIndex *int `json:\"stash_box_index\"`\n\t// Stash-box endpoint\n\tStashBoxEndpoint *string `json:\"stash_box_endpoint\"`\n\t// Scraper ID to scrape with. Should be unset if stash_box_index is set\n\tScraperID *string `json:\"scraper_id\"`\n}\n\n// Scraped Content is the forming union over the different scrapers\ntype ScrapedContent interface {\n\tIsScrapedContent()\n}\n\n// Type of the content a scraper generates\ntype ScrapeContentType string\n\nconst (\n\tScrapeContentTypeGallery   ScrapeContentType = \"GALLERY\"\n\tScrapeContentTypeMovie     ScrapeContentType = \"MOVIE\"\n\tScrapeContentTypeGroup     ScrapeContentType = \"GROUP\"\n\tScrapeContentTypePerformer ScrapeContentType = \"PERFORMER\"\n\tScrapeContentTypeScene     ScrapeContentType = \"SCENE\"\n\tScrapeContentTypeImage     ScrapeContentType = \"IMAGE\"\n)\n\nvar AllScrapeContentType = []ScrapeContentType{\n\tScrapeContentTypeGallery,\n\tScrapeContentTypeMovie,\n\tScrapeContentTypeGroup,\n\tScrapeContentTypePerformer,\n\tScrapeContentTypeScene,\n\tScrapeContentTypeImage,\n}\n\nfunc (e ScrapeContentType) IsValid() bool {\n\tswitch e {\n\tcase ScrapeContentTypeGallery, ScrapeContentTypeMovie, ScrapeContentTypeGroup, ScrapeContentTypePerformer, ScrapeContentTypeScene, ScrapeContentTypeImage:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e ScrapeContentType) String() string {\n\treturn string(e)\n}\n\nfunc (e *ScrapeContentType) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = ScrapeContentType(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid ScrapeContentType\", str)\n\t}\n\treturn nil\n}\n\nfunc (e ScrapeContentType) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\ntype Scraper struct {\n\tID   string `json:\"id\"`\n\tName string `json:\"name\"`\n\t// Details for performer scraper\n\tPerformer *ScraperSpec `json:\"performer\"`\n\t// Details for scene scraper\n\tScene *ScraperSpec `json:\"scene\"`\n\t// Details for gallery scraper\n\tGallery *ScraperSpec `json:\"gallery\"`\n\t// Details for image scraper\n\tImage *ScraperSpec `json:\"image\"`\n\t// Details for movie scraper\n\tGroup *ScraperSpec `json:\"group\"`\n\t// Details for movie scraper\n\tMovie *ScraperSpec `json:\"movie\"`\n}\n\ntype ScraperSpec struct {\n\t// URLs matching these can be scraped with\n\tUrls             []string     `json:\"urls\"`\n\tSupportedScrapes []ScrapeType `json:\"supported_scrapes\"`\n}\n\ntype ScrapeType string\n\nconst (\n\t// From text query\n\tScrapeTypeName ScrapeType = \"NAME\"\n\t// From existing object\n\tScrapeTypeFragment ScrapeType = \"FRAGMENT\"\n\t// From URL\n\tScrapeTypeURL ScrapeType = \"URL\"\n)\n\nvar AllScrapeType = []ScrapeType{\n\tScrapeTypeName,\n\tScrapeTypeFragment,\n\tScrapeTypeURL,\n}\n\nfunc (e ScrapeType) IsValid() bool {\n\tswitch e {\n\tcase ScrapeTypeName, ScrapeTypeFragment, ScrapeTypeURL:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e ScrapeType) String() string {\n\treturn string(e)\n}\n\nfunc (e *ScrapeType) UnmarshalGQL(v interface{}) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = ScrapeType(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid ScrapeType\", str)\n\t}\n\treturn nil\n}\n\nfunc (e ScrapeType) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nvar (\n\t// ErrMaxRedirects is returned if the max number of HTTP redirects are reached.\n\tErrMaxRedirects = errors.New(\"maximum number of HTTP redirects reached\")\n\n\t// ErrNotFound is returned when an entity isn't found\n\tErrNotFound = errors.New(\"scraper not found\")\n\n\t// ErrNotSupported is returned when a given invocation isn't supported, and there\n\t// is a guard function which should be able to guard against it.\n\tErrNotSupported = errors.New(\"scraper operation not supported\")\n)\n\n// Input coalesces inputs of different types into a single structure.\n// The system expects one of these to be set, and the remaining to be\n// set to nil.\ntype Input struct {\n\tPerformer *ScrapedPerformerInput\n\tScene     *models.ScrapedSceneInput\n\tGallery   *models.ScrapedGalleryInput\n\tImage     *models.ScrapedImageInput\n}\n\n// populateURL populates the URL field of the input based on the\n// URLs field of the input. Does nothing if the URL field is already set.\nfunc (i *Input) populateURL() {\n\tif i.Scene != nil && i.Scene.URL == nil && len(i.Scene.URLs) > 0 {\n\t\ti.Scene.URL = &i.Scene.URLs[0]\n\t}\n\tif i.Gallery != nil && i.Gallery.URL == nil && len(i.Gallery.URLs) > 0 {\n\t\ti.Gallery.URL = &i.Gallery.URLs[0]\n\t}\n\tif i.Performer != nil && i.Performer.URL == nil && len(i.Performer.URLs) > 0 {\n\t\ti.Performer.URL = &i.Performer.URLs[0]\n\t}\n}\n\n// simple type definitions that can help customize\n// actions per query\ntype QueryType int\n\nconst (\n\t// for now only SearchQuery is needed\n\tSearchQuery QueryType = iota + 1\n)\n\n// scraper is the generic interface to the scraper subsystems\ntype scraper interface {\n\t// spec returns the scraper specification, suitable for graphql\n\tspec() Scraper\n\t// supports tests if the scraper supports a given content type\n\tsupports(ScrapeContentType) bool\n\t// supportsURL tests if the scraper supports scrapes of a given url, producing a given content type\n\tsupportsURL(url string, ty ScrapeContentType) bool\n}\n\n// urlScraper is the interface of scrapers supporting url loads\ntype urlScraper interface {\n\tscraper\n\n\tviaURL(ctx context.Context, client *http.Client, url string, ty ScrapeContentType) (ScrapedContent, error)\n}\n\n// nameScraper is the interface of scrapers supporting name loads\ntype nameScraper interface {\n\tscraper\n\n\tviaName(ctx context.Context, client *http.Client, name string, ty ScrapeContentType) ([]ScrapedContent, error)\n}\n\n// fragmentScraper is the interface of scrapers supporting fragment loads\ntype fragmentScraper interface {\n\tscraper\n\n\tviaFragment(ctx context.Context, client *http.Client, input Input) (ScrapedContent, error)\n}\n\n// sceneScraper is a scraper which supports scene scrapes with\n// scene data as the input.\ntype sceneScraper interface {\n\tscraper\n\n\tviaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*models.ScrapedScene, error)\n}\n\n// imageScraper is a scraper which supports image scrapes with\n// image data as the input.\ntype imageScraper interface {\n\tscraper\n\n\tviaImage(ctx context.Context, client *http.Client, image *models.Image) (*models.ScrapedImage, error)\n}\n\n// galleryScraper is a scraper which supports gallery scrapes with\n// gallery data as the input.\ntype galleryScraper interface {\n\tscraper\n\n\tviaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error)\n}\n"
  },
  {
    "path": "pkg/scraper/script.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\tstashExec \"github.com/stashapp/stash/pkg/exec\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\tstashJson \"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/python\"\n)\n\n// inputs for scrapers\n\ntype fingerprintInput struct {\n\tType        string `json:\"type,omitempty\"`\n\tFingerprint string `json:\"fingerprint,omitempty\"`\n}\n\ntype fileInput struct {\n\tID      string             `json:\"id\"`\n\tZipFile *fileInput         `json:\"zip_file,omitempty\"`\n\tModTime stashJson.JSONTime `json:\"mod_time\"`\n\n\tPath string `json:\"path,omitempty\"`\n\n\tFingerprints []fingerprintInput `json:\"fingerprints,omitempty\"`\n\tSize         int64              `json:\"size,omitempty\"`\n}\n\ntype videoFileInput struct {\n\tfileInput\n\tFormat     string  `json:\"format,omitempty\"`\n\tWidth      int     `json:\"width,omitempty\"`\n\tHeight     int     `json:\"height,omitempty\"`\n\tDuration   float64 `json:\"duration,omitempty\"`\n\tVideoCodec string  `json:\"video_codec,omitempty\"`\n\tAudioCodec string  `json:\"audio_codec,omitempty\"`\n\tFrameRate  float64 `json:\"frame_rate,omitempty\"`\n\tBitRate    int64   `json:\"bitrate,omitempty\"`\n\n\tInteractive      bool `json:\"interactive,omitempty\"`\n\tInteractiveSpeed *int `json:\"interactive_speed,omitempty\"`\n}\n\n// sceneInput is the input passed to the scraper for an existing scene\ntype sceneInput struct {\n\tID    string `json:\"id\"`\n\tTitle string `json:\"title\"`\n\tCode  string `json:\"code,omitempty\"`\n\n\t// deprecated - use urls instead\n\tURL  *string  `json:\"url\"`\n\tURLs []string `json:\"urls\"`\n\n\t// don't use omitempty for these to maintain backwards compatibility\n\tDate    *string `json:\"date\"`\n\tDetails string  `json:\"details\"`\n\n\tDirector string `json:\"director,omitempty\"`\n\n\tFiles []videoFileInput `json:\"files,omitempty\"`\n}\n\nfunc fileInputFromFile(f models.BaseFile) fileInput {\n\tb := f.Base()\n\tvar z *fileInput\n\tif b.ZipFile != nil {\n\t\tzz := fileInputFromFile(*b.ZipFile.Base())\n\t\tz = &zz\n\t}\n\n\tret := fileInput{\n\t\tID:      f.ID.String(),\n\t\tZipFile: z,\n\t\tModTime: stashJson.JSONTime{Time: f.ModTime},\n\t\tPath:    f.Path,\n\t\tSize:    f.Size,\n\t}\n\n\tfor _, fp := range f.Fingerprints {\n\t\tret.Fingerprints = append(ret.Fingerprints, fingerprintInput{\n\t\t\tType:        fp.Type,\n\t\t\tFingerprint: fp.Value(),\n\t\t})\n\t}\n\n\treturn ret\n}\n\nfunc videoFileInputFromVideoFile(vf *models.VideoFile) videoFileInput {\n\treturn videoFileInput{\n\t\tfileInput:        fileInputFromFile(*vf.Base()),\n\t\tFormat:           vf.Format,\n\t\tWidth:            vf.Width,\n\t\tHeight:           vf.Height,\n\t\tDuration:         vf.Duration,\n\t\tVideoCodec:       vf.VideoCodec,\n\t\tAudioCodec:       vf.AudioCodec,\n\t\tFrameRate:        vf.FrameRate,\n\t\tBitRate:          vf.BitRate,\n\t\tInteractive:      vf.Interactive,\n\t\tInteractiveSpeed: vf.InteractiveSpeed,\n\t}\n}\n\nfunc sceneInputFromScene(scene *models.Scene) sceneInput {\n\tdateToStringPtr := func(s *models.Date) *string {\n\t\tif s != nil {\n\t\t\tv := s.String()\n\t\t\treturn &v\n\t\t}\n\n\t\treturn nil\n\t}\n\n\t// fallback to file basename if title is empty\n\ttitle := scene.GetTitle()\n\n\tvar url *string\n\turls := scene.URLs.List()\n\tif len(urls) > 0 {\n\t\turl = &urls[0]\n\t}\n\n\tret := sceneInput{\n\t\tID:      strconv.Itoa(scene.ID),\n\t\tTitle:   title,\n\t\tDetails: scene.Details,\n\t\t// include deprecated URL for now\n\t\tURL:      url,\n\t\tURLs:     urls,\n\t\tDate:     dateToStringPtr(scene.Date),\n\t\tCode:     scene.Code,\n\t\tDirector: scene.Director,\n\t}\n\n\tfor _, f := range scene.Files.List() {\n\t\tvf := videoFileInputFromVideoFile(f)\n\t\tret.Files = append(ret.Files, vf)\n\t}\n\n\treturn ret\n}\n\ntype galleryInput struct {\n\tID      string   `json:\"id\"`\n\tTitle   string   `json:\"title\"`\n\tUrls    []string `json:\"urls\"`\n\tDate    *string  `json:\"date\"`\n\tDetails string   `json:\"details\"`\n\n\tCode         string `json:\"code,omitempty\"`\n\tPhotographer string `json:\"photographer,omitempty\"`\n\n\tFiles []fileInput `json:\"files,omitempty\"`\n\n\t// deprecated\n\tURL *string `json:\"url\"`\n}\n\nfunc galleryInputFromGallery(gallery *models.Gallery) galleryInput {\n\tdateToStringPtr := func(s *models.Date) *string {\n\t\tif s != nil {\n\t\t\tv := s.String()\n\t\t\treturn &v\n\t\t}\n\n\t\treturn nil\n\t}\n\n\t// fallback to file basename if title is empty\n\ttitle := gallery.GetTitle()\n\n\tvar url *string\n\turls := gallery.URLs.List()\n\tif len(urls) > 0 {\n\t\turl = &urls[0]\n\t}\n\n\tret := galleryInput{\n\t\tID:           strconv.Itoa(gallery.ID),\n\t\tTitle:        title,\n\t\tDetails:      gallery.Details,\n\t\tURL:          url,\n\t\tUrls:         urls,\n\t\tDate:         dateToStringPtr(gallery.Date),\n\t\tCode:         gallery.Code,\n\t\tPhotographer: gallery.Photographer,\n\t}\n\n\tfor _, f := range gallery.Files.List() {\n\t\tfi := fileInputFromFile(*f.Base())\n\t\tret.Files = append(ret.Files, fi)\n\t}\n\n\treturn ret\n}\n\nvar ErrScraperScript = errors.New(\"scraper script error\")\n\ntype scriptScraper struct {\n\tdefinition   Definition\n\tglobalConfig GlobalConfig\n}\n\nfunc (s *scriptScraper) runScraperScript(ctx context.Context, command []string, inString string, out interface{}) error {\n\tvar cmd *exec.Cmd\n\tif python.IsPythonCommand(command[0]) {\n\t\tpythonPath := s.globalConfig.GetPythonPath()\n\t\tp, err := python.Resolve(pythonPath)\n\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"%s\", err)\n\t\t} else {\n\t\t\tcmd = p.Command(ctx, command[1:])\n\t\t\tenvVariable, _ := filepath.Abs(filepath.Dir(filepath.Dir(s.definition.path)))\n\t\t\tpython.AppendPythonPath(cmd, envVariable)\n\t\t}\n\t}\n\n\tif cmd == nil {\n\t\t// if could not find python, just use the command args as-is\n\t\tcmd = stashExec.CommandContext(ctx, command[0], command[1:]...)\n\t}\n\n\tcmd.Dir = filepath.Dir(s.definition.path)\n\n\tstdin, err := cmd.StdinPipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgo func() {\n\t\tdefer stdin.Close()\n\n\t\tif n, err := io.WriteString(stdin, inString); err != nil {\n\t\t\tlogger.Warnf(\"failure to write full input to script (wrote %v bytes out of %v): %v\", n, len(inString), err)\n\t\t}\n\t}()\n\n\tstderr, err := cmd.StderrPipe()\n\tif err != nil {\n\t\tlogger.Error(\"Scraper stderr not available: \" + err.Error())\n\t}\n\n\tstdout, err := cmd.StdoutPipe()\n\tif nil != err {\n\t\tlogger.Error(\"Scraper stdout not available: \" + err.Error())\n\t}\n\n\tif err = cmd.Start(); err != nil {\n\t\tlogger.Error(\"Error running scraper script: \" + err.Error())\n\t\treturn errors.New(\"error running scraper script\")\n\t}\n\n\tgo handleScraperStderr(s.definition.Name, stderr)\n\n\tlogger.Debugf(\"Scraper script <%s> started\", strings.Join(cmd.Args, \" \"))\n\n\t// TODO - add a timeout here\n\t// Make a copy of stdout here. This allows us to decode it twice.\n\tvar sb strings.Builder\n\ttr := io.TeeReader(stdout, &sb)\n\n\t// First, perform a decode where unknown fields are disallowed.\n\td := json.NewDecoder(tr)\n\td.DisallowUnknownFields()\n\tstrictErr := d.Decode(out)\n\n\tif strictErr != nil {\n\t\t// The decode failed for some reason, use the built string\n\t\t// and allow unknown fields in the decode.\n\t\ts := sb.String()\n\t\tlenientErr := json.NewDecoder(strings.NewReader(s)).Decode(out)\n\t\tif lenientErr != nil {\n\t\t\t// The error is genuine, so return it\n\t\t\tlogger.Errorf(\"could not unmarshal json from script output: %v\", lenientErr)\n\t\t\treturn fmt.Errorf(\"could not unmarshal json from script output: %w\", lenientErr)\n\t\t}\n\n\t\t// Lenient decode succeeded, print a warning, but use the decode\n\t\tlogger.Warnf(\"reading script result: %v\", strictErr)\n\t}\n\n\terr = cmd.Wait()\n\tlogger.Debugf(\"Scraper script finished\")\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%w: %v\", ErrScraperScript, err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *scriptScraper) scrape(ctx context.Context, command []string, input string, ty ScrapeContentType) (ScrapedContent, error) {\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\tvar performer *models.ScrapedPerformer\n\t\terr := s.runScraperScript(ctx, command, input, &performer)\n\t\treturn performer, err\n\tcase ScrapeContentTypeGallery:\n\t\tvar gallery *models.ScrapedGallery\n\t\terr := s.runScraperScript(ctx, command, input, &gallery)\n\t\treturn gallery, err\n\tcase ScrapeContentTypeScene:\n\t\tvar scene *models.ScrapedScene\n\t\terr := s.runScraperScript(ctx, command, input, &scene)\n\t\treturn scene, err\n\tcase ScrapeContentTypeMovie, ScrapeContentTypeGroup:\n\t\tvar movie *models.ScrapedMovie\n\t\terr := s.runScraperScript(ctx, command, input, &movie)\n\t\treturn movie, err\n\tcase ScrapeContentTypeImage:\n\t\tvar image *models.ScrapedImage\n\t\terr := s.runScraperScript(ctx, command, input, &image)\n\t\treturn image, err\n\t}\n\n\treturn nil, ErrNotSupported\n}\n\ntype scriptNameScraper struct {\n\tscriptScraper\n\tdefinition ByNameDefinition\n}\n\nfunc (s *scriptNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) {\n\tinput := `{\"name\": \"` + name + `\"}`\n\n\tvar ret []ScrapedContent\n\tvar err error\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\tvar performers []models.ScrapedPerformer\n\t\terr = s.runScraperScript(ctx, s.definition.Script, input, &performers)\n\t\tif err == nil {\n\t\t\tfor _, p := range performers {\n\t\t\t\tv := p\n\t\t\t\tret = append(ret, &v)\n\t\t\t}\n\t\t}\n\tcase ScrapeContentTypeScene:\n\t\tvar scenes []models.ScrapedScene\n\t\terr = s.runScraperScript(ctx, s.definition.Script, input, &scenes)\n\t\tif err == nil {\n\t\t\tfor _, s := range scenes {\n\t\t\t\tv := s\n\t\t\t\tret = append(ret, &v)\n\t\t\t}\n\t\t}\n\tdefault:\n\t\treturn nil, ErrNotSupported\n\t}\n\n\treturn ret, err\n}\n\ntype scriptURLScraper struct {\n\tscriptScraper\n\tdefinition ByURLDefinition\n}\n\nfunc (s *scriptURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) {\n\treturn s.scrape(ctx, s.definition.Script, `{\"url\": \"`+url+`\"}`, ty)\n}\n\ntype scriptFragmentScraper struct {\n\tscriptScraper\n\tdefinition ByFragmentDefinition\n}\n\nfunc (s *scriptFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) {\n\tvar inString []byte\n\tvar err error\n\tvar ty ScrapeContentType\n\tswitch {\n\tcase input.Performer != nil:\n\t\tinString, err = json.Marshal(*input.Performer)\n\t\tty = ScrapeContentTypePerformer\n\tcase input.Gallery != nil:\n\t\tinString, err = json.Marshal(*input.Gallery)\n\t\tty = ScrapeContentTypeGallery\n\tcase input.Scene != nil:\n\t\tinString, err = json.Marshal(*input.Scene)\n\t\tty = ScrapeContentTypeScene\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn s.scrape(ctx, s.definition.Script, string(inString), ty)\n}\n\nfunc (s *scriptFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) {\n\tinString, err := json.Marshal(sceneInputFromScene(scene))\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret *models.ScrapedScene\n\n\terr = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret)\n\n\treturn ret, err\n}\n\nfunc (s *scriptFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) {\n\tinString, err := json.Marshal(galleryInputFromGallery(gallery))\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret *models.ScrapedGallery\n\n\terr = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret)\n\n\treturn ret, err\n}\n\nfunc (s *scriptFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {\n\tinString, err := json.Marshal(imageToUpdateInput(image))\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret *models.ScrapedImage\n\n\terr = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret)\n\n\treturn ret, err\n}\n\nfunc handleScraperStderr(name string, scraperOutputReader io.ReadCloser) {\n\tconst scraperPrefix = \"[Scrape / %s] \"\n\n\tlgr := logger.PluginLogger{\n\t\tLogger:          logger.Logger,\n\t\tPrefix:          fmt.Sprintf(scraperPrefix, name),\n\t\tDefaultLogLevel: &logger.ErrorLevel,\n\t}\n\tlgr.ReadLogMessages(scraperOutputReader)\n}\n"
  },
  {
    "path": "pkg/scraper/stash.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tgraphql \"github.com/hasura/go-graphql-client\"\n\t\"github.com/jinzhu/copier\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype stashScraper struct {\n\tconfig       Definition\n\tglobalConfig GlobalConfig\n\tclient       *http.Client\n}\n\nfunc newStashScraper(client *http.Client, config Definition, globalConfig GlobalConfig) *stashScraper {\n\treturn &stashScraper{\n\t\tconfig:       config,\n\t\tclient:       client,\n\t\tglobalConfig: globalConfig,\n\t}\n}\n\nfunc setApiKeyHeader(apiKey string) func(req *http.Request) {\n\treturn func(req *http.Request) {\n\t\treq.Header.Set(\"ApiKey\", apiKey)\n\t}\n}\n\nfunc (s *stashScraper) getStashClient() *graphql.Client {\n\turl := s.config.StashServer.URL + \"/graphql\"\n\tret := graphql.NewClient(url, s.client)\n\n\tif s.config.StashServer.ApiKey != \"\" {\n\t\tret = ret.WithRequestModifier(setApiKeyHeader(s.config.StashServer.ApiKey))\n\t}\n\n\treturn ret\n}\n\ntype stashFindPerformerNamePerformer struct {\n\tID   string `json:\"id\" graphql:\"id\"`\n\tName string `json:\"name\" graphql:\"name\"`\n}\n\nfunc (p stashFindPerformerNamePerformer) toPerformer() *models.ScrapedPerformer {\n\treturn &models.ScrapedPerformer{\n\t\tName: &p.Name,\n\t\t// HACK - put id into the URL field\n\t\tURL: &p.ID,\n\t}\n}\n\ntype stashFindPerformerNamesResultType struct {\n\tCount      int                                `graphql:\"count\"`\n\tPerformers []*stashFindPerformerNamePerformer `graphql:\"performers\"`\n}\n\n// need a separate for scraped stash performers - does not include remote_site_id or image\ntype scrapedTagStash struct {\n\tName string `graphql:\"name\" json:\"name\"`\n}\n\ntype scrapedPerformerStash struct {\n\tName         *string            `graphql:\"name\" json:\"name\"`\n\tGender       *string            `graphql:\"gender\" json:\"gender\"`\n\tURLs         []string           `graphql:\"urls\" json:\"urls\"`\n\tBirthdate    *string            `graphql:\"birthdate\" json:\"birthdate\"`\n\tEthnicity    *string            `graphql:\"ethnicity\" json:\"ethnicity\"`\n\tCountry      *string            `graphql:\"country\" json:\"country\"`\n\tEyeColor     *string            `graphql:\"eye_color\" json:\"eye_color\"`\n\tHeight       *int               `graphql:\"height_cm\" json:\"height_cm\"`\n\tMeasurements *string            `graphql:\"measurements\" json:\"measurements\"`\n\tFakeTits     *string            `graphql:\"fake_tits\" json:\"fake_tits\"`\n\tPenisLength  *string            `graphql:\"penis_length\" json:\"penis_length\"`\n\tCircumcised  *string            `graphql:\"circumcised\" json:\"circumcised\"`\n\tCareerLength *string            `graphql:\"career_length\" json:\"career_length\"`\n\tTattoos      *string            `graphql:\"tattoos\" json:\"tattoos\"`\n\tPiercings    *string            `graphql:\"piercings\" json:\"piercings\"`\n\tAliases      []string           `graphql:\"alias_list\" json:\"alias_list\"`\n\tTags         []*scrapedTagStash `graphql:\"tags\" json:\"tags\"`\n\tDetails      *string            `graphql:\"details\" json:\"details\"`\n\tDeathDate    *string            `graphql:\"death_date\" json:\"death_date\"`\n\tHairColor    *string            `graphql:\"hair_color\" json:\"hair_color\"`\n\tWeight       *int               `graphql:\"weight\" json:\"weight\"`\n}\n\nfunc (s *stashScraper) imageGetter() imageGetter {\n\tret := imageGetter{\n\t\tclient:       s.client,\n\t\tglobalConfig: s.globalConfig,\n\t}\n\n\tif s.config.StashServer.ApiKey != \"\" {\n\t\tret.requestModifier = setApiKeyHeader(s.config.StashServer.ApiKey)\n\t}\n\n\treturn ret\n}\n\nfunc (s *stashScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) {\n\tif input.Performer != nil {\n\t\treturn s.scrapeByPerformerFragment(ctx, *input.Performer)\n\t}\n\n\tif input.Scene != nil {\n\t\treturn s.scrapeBySceneFragment(ctx, *input.Scene)\n\t}\n\n\treturn nil, fmt.Errorf(\"%w: using stash scraper as a fragment scraper\", ErrNotSupported)\n}\n\nfunc (s *stashScraper) scrapeByPerformerFragment(ctx context.Context, scrapedPerformer ScrapedPerformerInput) (ScrapedContent, error) {\n\tclient := s.getStashClient()\n\n\tvar q struct {\n\t\tFindPerformer *scrapedPerformerStash `graphql:\"findPerformer(id: $f)\"`\n\t}\n\n\tperformerID := *scrapedPerformer.URL\n\n\t// get the id from the URL field\n\tvars := map[string]interface{}{\n\t\t\"f\": graphql.ID(performerID),\n\t}\n\n\terr := client.Query(ctx, &q, vars)\n\tif err != nil {\n\t\treturn nil, convertGraphqlError(err)\n\t}\n\n\t// need to copy back to a scraped performer\n\tret := models.ScrapedPerformer{}\n\terr = copier.Copy(&ret, q.FindPerformer)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// convert alias list to aliases\n\taliasStr := strings.Join(q.FindPerformer.Aliases, \", \")\n\tret.Aliases = &aliasStr\n\n\t// convert numeric to string\n\tif q.FindPerformer.Height != nil {\n\t\theightStr := strconv.Itoa(*q.FindPerformer.Height)\n\t\tret.Height = &heightStr\n\t}\n\tif q.FindPerformer.Weight != nil {\n\t\tweightStr := strconv.Itoa(*q.FindPerformer.Weight)\n\t\tret.Weight = &weightStr\n\t}\n\n\t// get the performer image directly\n\tig := s.imageGetter()\n\timg, err := getStashPerformerImage(ctx, s.config.StashServer.URL, performerID, ig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tret.Images = []string{*img}\n\tret.Image = img\n\n\treturn &ret, nil\n}\n\nfunc (s *stashScraper) scrapeBySceneFragment(ctx context.Context, scrapedScene models.ScrapedSceneInput) (ScrapedContent, error) {\n\tclient := s.getStashClient()\n\n\tvar q struct {\n\t\tFindScene *scrapedSceneStash `graphql:\"findScene(id: $f)\"`\n\t}\n\n\tsceneID := scrapedScene.URLs[0]\n\n\t// get the id from the URL field\n\tvars := map[string]interface{}{\n\t\t\"f\": graphql.ID(sceneID),\n\t}\n\n\terr := client.Query(ctx, &q, vars)\n\tif err != nil {\n\t\treturn nil, convertGraphqlError(err)\n\t}\n\n\tif q.FindScene == nil {\n\t\treturn nil, nil\n\t}\n\n\t// need to copy back to a scraped scene\n\tret, err := s.scrapedStashSceneToScrapedScene(ctx, q.FindScene)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// get the scene image directly\n\tig := s.imageGetter()\n\tret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, q.FindScene.ID, ig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\ntype scrapedStudioStash struct {\n\tName string  `graphql:\"name\" json:\"name\"`\n\tURL  *string `graphql:\"url\" json:\"url\"`\n}\n\ntype stashFindSceneNamesResultType struct {\n\tCount  int                  `graphql:\"count\"`\n\tScenes []*scrapedSceneStash `graphql:\"scenes\"`\n}\n\nfunc (s *stashScraper) scrapedStashSceneToScrapedScene(ctx context.Context, scene *scrapedSceneStash) (*models.ScrapedScene, error) {\n\tret := models.ScrapedScene{}\n\terr := copier.Copy(&ret, scene)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// convert first in files to file\n\tif len(scene.Files) > 0 {\n\t\tf := scene.Files[0].SceneFileType()\n\t\tret.File = &f\n\t}\n\n\t// get the scene image directly\n\tig := s.imageGetter()\n\tret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, scene.ID, ig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &ret, nil\n}\n\nfunc (s *stashScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) {\n\tclient := s.getStashClient()\n\n\tpage := 1\n\tperPage := 10\n\n\tvars := map[string]interface{}{\n\t\t\"f\": models.FindFilterType{\n\t\t\tQ:       &name,\n\t\t\tPage:    &page,\n\t\t\tPerPage: &perPage,\n\t\t},\n\t}\n\n\tvar ret []ScrapedContent\n\tswitch ty {\n\tcase ScrapeContentTypeScene:\n\t\tvar q struct {\n\t\t\tFindScenes stashFindSceneNamesResultType `graphql:\"findScenes(filter: $f)\"`\n\t\t}\n\n\t\terr := client.Query(ctx, &q, vars)\n\t\tif err != nil {\n\t\t\treturn nil, convertGraphqlError(err)\n\t\t}\n\n\t\tfor _, scene := range q.FindScenes.Scenes {\n\t\t\tconverted, err := s.scrapedStashSceneToScrapedScene(ctx, scene)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\t// HACK - put id into the URL field\n\t\t\t// put id into the URL field\n\t\t\tconverted.URLs = []string{scene.ID}\n\t\t\tret = append(ret, converted)\n\t\t}\n\n\t\treturn ret, nil\n\tcase ScrapeContentTypePerformer:\n\t\tvar q struct {\n\t\t\tFindPerformers stashFindPerformerNamesResultType `graphql:\"findPerformers(filter: $f)\"`\n\t\t}\n\n\t\terr := client.Query(ctx, &q, vars)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, p := range q.FindPerformers.Performers {\n\t\t\tret = append(ret, p.toPerformer())\n\t\t}\n\n\t\treturn ret, nil\n\t}\n\n\treturn nil, ErrNotSupported\n}\n\ntype stashVideoFile struct {\n\tSize       int64   `graphql:\"size\" json:\"size\"`\n\tDuration   float64 `graphql:\"duration\" json:\"duration\"`\n\tVideoCodec string  `graphql:\"video_codec\" json:\"video_codec\"`\n\tAudioCodec string  `graphql:\"audio_codec\" json:\"audio_codec\"`\n\tWidth      int     `graphql:\"width\" json:\"width\"`\n\tHeight     int     `graphql:\"height\" json:\"height\"`\n\tFramerate  float64 `graphql:\"frame_rate\" json:\"frame_rate\"`\n\tBitrate    int     `graphql:\"bit_rate\" json:\"bit_rate\"`\n}\n\nfunc (f stashVideoFile) SceneFileType() models.SceneFileType {\n\tret := models.SceneFileType{\n\t\tDuration:   &f.Duration,\n\t\tVideoCodec: &f.VideoCodec,\n\t\tAudioCodec: &f.AudioCodec,\n\t\tWidth:      &f.Width,\n\t\tHeight:     &f.Height,\n\t\tFramerate:  &f.Framerate,\n\t\tBitrate:    &f.Bitrate,\n\t}\n\n\tsize := strconv.FormatInt(f.Size, 10)\n\tret.Size = &size\n\n\treturn ret\n}\n\ntype scrapedSceneStash struct {\n\tID         string                   `graphql:\"id\" json:\"id\"`\n\tTitle      *string                  `graphql:\"title\" json:\"title\"`\n\tDetails    *string                  `graphql:\"details\" json:\"details\"`\n\tURLs       []string                 `graphql:\"urls\" json:\"urls\"`\n\tDate       *string                  `graphql:\"date\" json:\"date\"`\n\tFiles      []stashVideoFile         `graphql:\"files\" json:\"files\"`\n\tStudio     *scrapedStudioStash      `graphql:\"studio\" json:\"studio\"`\n\tTags       []*scrapedTagStash       `graphql:\"tags\" json:\"tags\"`\n\tPerformers []*scrapedPerformerStash `graphql:\"performers\" json:\"performers\"`\n}\n\nfunc (s *stashScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) {\n\t// query by MD5\n\tvar q struct {\n\t\tFindScene *scrapedSceneStash `graphql:\"findSceneByHash(input: $c)\"`\n\t}\n\n\ttype SceneHashInput struct {\n\t\tChecksum *string `graphql:\"checksum\" json:\"checksum\"`\n\t\tOshash   *string `graphql:\"oshash\" json:\"oshash\"`\n\t}\n\n\tchecksum := scene.Checksum\n\toshash := scene.OSHash\n\n\tinput := SceneHashInput{\n\t\tChecksum: &checksum,\n\t\tOshash:   &oshash,\n\t}\n\n\tvars := map[string]interface{}{\n\t\t\"c\": input,\n\t}\n\n\tclient := s.getStashClient()\n\tif err := client.Query(ctx, &q, vars); err != nil {\n\t\treturn nil, convertGraphqlError(err)\n\t}\n\n\tif q.FindScene == nil {\n\t\treturn nil, nil\n\t}\n\n\t// need to copy back to a scraped scene\n\tret, err := s.scrapedStashSceneToScrapedScene(ctx, q.FindScene)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// get the scene image directly\n\tig := s.imageGetter()\n\tret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, q.FindScene.ID, ig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\ntype scrapedGalleryStash struct {\n\tID         string                   `graphql:\"id\" json:\"id\"`\n\tTitle      *string                  `graphql:\"title\" json:\"title\"`\n\tDetails    *string                  `graphql:\"details\" json:\"details\"`\n\tURL        *string                  `graphql:\"url\" json:\"url\"`\n\tDate       *string                  `graphql:\"date\" json:\"date\"`\n\tFile       *models.SceneFileType    `graphql:\"file\" json:\"file\"`\n\tStudio     *scrapedStudioStash      `graphql:\"studio\" json:\"studio\"`\n\tTags       []*scrapedTagStash       `graphql:\"tags\" json:\"tags\"`\n\tPerformers []*scrapedPerformerStash `graphql:\"performers\" json:\"performers\"`\n}\n\nfunc (s *stashScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) {\n\tvar q struct {\n\t\tFindGallery *scrapedGalleryStash `graphql:\"findGalleryByHash(input: $c)\"`\n\t}\n\n\ttype GalleryHashInput struct {\n\t\tChecksum *string `graphql:\"checksum\" json:\"checksum\"`\n\t}\n\n\tchecksum := gallery.PrimaryChecksum()\n\tinput := GalleryHashInput{\n\t\tChecksum: &checksum,\n\t}\n\n\tvars := map[string]interface{}{\n\t\t\"c\": &input,\n\t}\n\n\tclient := s.getStashClient()\n\tif err := client.Query(ctx, &q, vars); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// need to copy back to a scraped scene\n\tret := models.ScrapedGallery{}\n\tif err := copier.Copy(&ret, q.FindGallery); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &ret, nil\n}\n\nfunc (s *stashScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {\n\treturn nil, ErrNotSupported\n}\n\nfunc (s *stashScraper) scrapeByURL(_ context.Context, _ string, _ ScrapeContentType) (ScrapedContent, error) {\n\treturn nil, ErrNotSupported\n}\n\nfunc imageToUpdateInput(gallery *models.Image) models.ImageUpdateInput {\n\tdateToStringPtr := func(s *models.Date) *string {\n\t\tif s != nil {\n\t\t\tv := s.String()\n\t\t\treturn &v\n\t\t}\n\n\t\treturn nil\n\t}\n\n\t// fallback to file basename if title is empty\n\ttitle := gallery.GetTitle()\n\turls := gallery.URLs.List()\n\n\treturn models.ImageUpdateInput{\n\t\tID:      strconv.Itoa(gallery.ID),\n\t\tTitle:   &title,\n\t\tDetails: &gallery.Details,\n\t\tUrls:    urls,\n\t\tDate:    dateToStringPtr(gallery.Date),\n\t}\n}\n"
  },
  {
    "path": "pkg/scraper/tag.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/match\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\nfunc postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) {\n\tret = make([]*models.ScrapedTag, 0, len(scrapedTags))\n\n\tfor _, t := range scrapedTags {\n\t\t// Pass empty string for endpoint since this is used by general scrapers, not just stash-box\n\t\terr := match.ScrapedTag(ctx, tqb, t, \"\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tret = append(ret, t)\n\t}\n\n\treturn ret, err\n}\n\n// FilterTags removes tags matching excluded tag patterns from the list of scraped tags\n// It returns the filtered list of tags and a list of the excluded tags\nfunc FilterTags(excludeRegexps []*regexp.Regexp, tags []*models.ScrapedTag) (newTags []*models.ScrapedTag, ignoredTags []string) {\n\tif len(excludeRegexps) == 0 {\n\t\treturn tags, nil\n\t}\n\n\tnewTags = make([]*models.ScrapedTag, 0, len(tags))\n\n\tfor _, t := range tags {\n\t\tignore := false\n\t\tfor _, reg := range excludeRegexps {\n\t\t\tif reg.MatchString(strings.ToLower(t.Name)) {\n\t\t\t\tignore = true\n\t\t\t\tignoredTags = sliceutil.AppendUnique(ignoredTags, t.Name)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !ignore {\n\t\t\tnewTags = append(newTags, t)\n\t\t}\n\t}\n\n\treturn newTags, ignoredTags\n}\n\n// CompileExclusionRegexps compiles a list of tag exclusion patterns into a list of regular expressions\nfunc CompileExclusionRegexps(patterns []string) []*regexp.Regexp {\n\texcludePatterns := patterns\n\tvar excludeRegexps []*regexp.Regexp\n\n\tfor _, excludePattern := range excludePatterns {\n\t\treg, err := regexp.Compile(strings.ToLower(excludePattern))\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Invalid tag exclusion pattern: %v\", err)\n\t\t} else {\n\t\t\texcludeRegexps = append(excludeRegexps, reg)\n\t\t}\n\t}\n\n\treturn excludeRegexps\n}\n\n// LogIgnoredTags logs the list of ignored tags\nfunc LogIgnoredTags(ignoredTags []string) {\n\tif len(ignoredTags) > 0 {\n\t\tlogger.Debugf(\"Tags ignored for matching exclusion patterns: %s\", strings.Join(ignoredTags, \", \"))\n\t}\n}\n"
  },
  {
    "path": "pkg/scraper/url.go",
    "content": "package scraper\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/chromedp/cdproto/cdp\"\n\t\"github.com/chromedp/cdproto/fetch\"\n\t\"github.com/chromedp/cdproto/network\"\n\t\"github.com/chromedp/chromedp\"\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"golang.org/x/net/html/charset\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst scrapeDefaultSleep = time.Second * 2\n\nfunc loadURL(ctx context.Context, loadURL string, client *http.Client, def Definition, globalConfig GlobalConfig) (io.Reader, error) {\n\tdriverOptions := def.DriverOptions\n\tif driverOptions != nil && driverOptions.UseCDP {\n\t\t// get the page using chrome dp\n\t\treturn urlFromCDP(ctx, loadURL, *driverOptions, globalConfig)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, loadURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tjar, err := def.jar()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating cookie jar: %w\", err)\n\t}\n\n\tu, err := url.Parse(loadURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing url %s: %w\", loadURL, err)\n\t}\n\n\t// Fetch relevant cookies from the jar for url u and add them to the request\n\tcookies := jar.Cookies(u)\n\tfor _, cookie := range cookies {\n\t\treq.AddCookie(cookie)\n\t}\n\n\tuserAgent := globalConfig.GetScraperUserAgent()\n\tif userAgent != \"\" {\n\t\treq.Header.Set(\"User-Agent\", userAgent)\n\t}\n\n\tif driverOptions != nil { // setting the Headers after the UA allows us to override it from inside the scraper\n\t\tfor _, h := range driverOptions.Headers {\n\t\t\tif h.Key != \"\" {\n\t\t\t\treq.Header.Set(h.Key, h.Value)\n\t\t\t\tlogger.Debugf(\"[scraper] adding header <%s:%s>\", h.Key, h.Value)\n\t\t\t}\n\t\t}\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif resp.StatusCode >= 400 {\n\t\treturn nil, fmt.Errorf(\"http error %d:%s\", resp.StatusCode, http.StatusText(resp.StatusCode))\n\t}\n\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbodyReader := bytes.NewReader(body)\n\tprintCookies(jar, def, \"Jar cookies found for scraper urls\")\n\treturn charset.NewReader(bodyReader, resp.Header.Get(\"Content-Type\"))\n}\n\n// func urlFromCDP uses chrome cdp and DOM to load and process the url\n// if remote is set as true in the scraperConfig  it will try to use localhost:9222\n// else it will look for google-chrome in path\nfunc urlFromCDP(ctx context.Context, urlCDP string, driverOptions scraperDriverOptions, globalConfig GlobalConfig) (io.Reader, error) {\n\n\tif !driverOptions.UseCDP {\n\t\treturn nil, fmt.Errorf(\"url shouldn't be fetched through CDP\")\n\t}\n\n\tsleepDuration := scrapeDefaultSleep\n\n\tif driverOptions.Sleep > 0 {\n\t\tsleepDuration = time.Duration(driverOptions.Sleep) * time.Second\n\t}\n\n\t// if scraperCDPPath is a remote address, then allocate accordingly\n\tcdpPath := globalConfig.GetScraperCDPPath()\n\tif cdpPath != \"\" {\n\t\tvar cancelAct context.CancelFunc\n\n\t\tif isCDPPathHTTP(globalConfig) || isCDPPathWS(globalConfig) {\n\t\t\tremote := cdpPath\n\n\t\t\t// -------------------------------------------------------------------\n\t\t\t// #1023\n\t\t\t// when chromium is listening over RDP it only accepts requests\n\t\t\t// with host headers that are either IPs or `localhost`\n\t\t\tcdpURL, err := url.Parse(remote)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to parse CDP Path: %v\", err)\n\t\t\t}\n\t\t\thostname := cdpURL.Hostname()\n\t\t\tif hostname != \"localhost\" {\n\t\t\t\tif net.ParseIP(hostname) == nil { // not an IP\n\t\t\t\t\taddr, err := net.LookupIP(hostname)\n\t\t\t\t\tif err != nil || len(addr) == 0 { // can not resolve to IP\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"CDP: hostname <%s> can not be resolved\", hostname)\n\t\t\t\t\t}\n\t\t\t\t\tif len(addr[0]) == 0 { // nil IP\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"CDP: hostname <%s> resolved to nil\", hostname)\n\t\t\t\t\t}\n\t\t\t\t\t// addr is a valid IP\n\t\t\t\t\t// replace the host part of the cdpURL with the IP\n\t\t\t\t\tcdpURL.Host = strings.Replace(cdpURL.Host, hostname, addr[0].String(), 1)\n\t\t\t\t\t// use that for remote\n\t\t\t\t\tremote = cdpURL.String()\n\t\t\t\t}\n\t\t\t}\n\t\t\t// --------------------------------------------------------------------\n\n\t\t\t// if CDPPath is http(s) then we need to get the websocket URL\n\t\t\tif isCDPPathHTTP(globalConfig) {\n\t\t\t\tvar err error\n\t\t\t\tremote, err = getRemoteCDPWSAddress(ctx, remote)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tctx, cancelAct = chromedp.NewRemoteAllocator(ctx, remote)\n\t\t} else {\n\t\t\t// use a temporary user directory for chrome\n\t\t\tdir, err := os.MkdirTemp(\"\", \"stash-chromedp\")\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tdefer os.RemoveAll(dir)\n\n\t\t\topts := append(chromedp.DefaultExecAllocatorOptions[:],\n\t\t\t\tchromedp.UserDataDir(dir),\n\t\t\t\tchromedp.ExecPath(cdpPath),\n\t\t\t)\n\t\t\tif globalConfig.GetProxy() != \"\" {\n\t\t\t\turl, _, _ := splitProxyAuth(globalConfig.GetProxy())\n\t\t\t\topts = append(opts, chromedp.ProxyServer(url))\n\t\t\t}\n\n\t\t\tctx, cancelAct = chromedp.NewExecAllocator(ctx, opts...)\n\t\t}\n\n\t\tdefer cancelAct()\n\t}\n\n\tctx, cancel := chromedp.NewContext(ctx)\n\tdefer cancel()\n\n\t// add a fixed timeout for the http request\n\tctx, cancel = context.WithTimeout(ctx, scrapeGetTimeout)\n\tdefer cancel()\n\n\tvar res string\n\theaders := cdpHeaders(driverOptions)\n\n\tif proxyUsesAuth(globalConfig.GetProxy()) {\n\t\t_, user, pass := splitProxyAuth(globalConfig.GetProxy())\n\n\t\t// Based on https://github.com/chromedp/examples/blob/master/proxy/main.go\n\t\tlctx, lcancel := context.WithCancel(ctx)\n\t\tchromedp.ListenTarget(lctx, func(ev interface{}) {\n\t\t\tswitch ev := ev.(type) {\n\t\t\tcase *fetch.EventRequestPaused:\n\t\t\t\tgo func() {\n\t\t\t\t\t_ = chromedp.Run(ctx, fetch.ContinueRequest(ev.RequestID))\n\t\t\t\t}()\n\t\t\tcase *fetch.EventAuthRequired:\n\t\t\t\tif ev.AuthChallenge.Source == fetch.AuthChallengeSourceProxy {\n\t\t\t\t\tgo func() {\n\t\t\t\t\t\t_ = chromedp.Run(ctx,\n\t\t\t\t\t\t\tfetch.ContinueWithAuth(ev.RequestID, &fetch.AuthChallengeResponse{\n\t\t\t\t\t\t\t\tResponse: fetch.AuthChallengeResponseResponseProvideCredentials,\n\t\t\t\t\t\t\t\tUsername: user,\n\t\t\t\t\t\t\t\tPassword: pass,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t// Chrome will remember the credential for the current instance,\n\t\t\t\t\t\t\t// so we can disable the fetch domain once credential is provided.\n\t\t\t\t\t\t\t// Please file an issue if Chrome does not work in this way.\n\t\t\t\t\t\t\tfetch.Disable(),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t// and cancel the event handler too.\n\t\t\t\t\t\tlcancel()\n\t\t\t\t\t}()\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n\n\terr := chromedp.Run(ctx,\n\t\tnetwork.Enable(),\n\t\tsetCDPCookies(driverOptions),\n\t\tprintCDPCookies(driverOptions, \"Cookies found\"),\n\t\tnetwork.SetExtraHTTPHeaders(network.Headers(headers)),\n\t\tchromedp.Navigate(urlCDP),\n\t\tchromedp.Sleep(sleepDuration),\n\t\tsetCDPClicks(driverOptions),\n\t\tchromedp.OuterHTML(\"html\", &res, chromedp.ByQuery),\n\t\tprintCDPCookies(driverOptions, \"Cookies set\"),\n\t)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn strings.NewReader(res), nil\n}\n\n// click all xpaths listed in the scraper config\nfunc setCDPClicks(driverOptions scraperDriverOptions) chromedp.Tasks {\n\tvar tasks chromedp.Tasks\n\tfor _, click := range driverOptions.Clicks { // for each click element find the node from the xpath and add a click action\n\t\tif click.XPath != \"\" {\n\t\t\txpath := click.XPath\n\t\t\twaitDuration := scrapeDefaultSleep\n\t\t\tif click.Sleep > 0 {\n\t\t\t\twaitDuration = time.Duration(click.Sleep) * time.Second\n\t\t\t}\n\n\t\t\taction := chromedp.ActionFunc(func(ctx context.Context) error {\n\t\t\t\tvar nodes []*cdp.Node\n\t\t\t\tif err := chromedp.Nodes(xpath, &nodes, chromedp.AtLeast(0)).Do(ctx); err != nil {\n\t\t\t\t\tlogger.Debugf(\"Error %s looking for click xpath %s.\\n\", err, xpath)\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif len(nodes) == 0 {\n\t\t\t\t\tlogger.Debugf(\"Click xpath %s not found in page.\\n\", xpath)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tlogger.Debugf(\"Clicking %s\\n\", xpath)\n\t\t\t\treturn chromedp.MouseClickNode(nodes[0]).Do(ctx)\n\t\t\t})\n\n\t\t\ttasks = append(tasks, action)\n\t\t\ttasks = append(tasks, chromedp.Sleep(waitDuration))\n\t\t}\n\n\t}\n\treturn tasks\n}\n\n// getRemoteCDPWSAddress returns the complete remote address that is required to access the cdp instance\nfunc getRemoteCDPWSAddress(ctx context.Context, url string) (string, error) {\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result map[string]interface{}\n\tvar json = jsoniter.ConfigCompatibleWithStandardLibrary\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn \"\", err\n\t}\n\tremote := result[\"webSocketDebuggerUrl\"].(string)\n\tlogger.Debugf(\"Remote cdp instance found %s\", remote)\n\treturn remote, err\n}\n\nfunc cdpHeaders(driverOptions scraperDriverOptions) map[string]interface{} {\n\theaders := map[string]interface{}{}\n\tif driverOptions.Headers != nil {\n\t\tfor _, h := range driverOptions.Headers {\n\t\t\tif h.Key != \"\" {\n\t\t\t\theaders[h.Key] = h.Value\n\t\t\t\tlogger.Debugf(\"[scraper] adding header <%s:%s>\", h.Key, h.Value)\n\t\t\t}\n\t\t}\n\t}\n\treturn headers\n}\n\nfunc proxyUsesAuth(proxyUrl string) bool {\n\tif proxyUrl == \"\" {\n\t\treturn false\n\t}\n\treg := regexp.MustCompile(`^(https?:\\/\\/)(([\\P{Cc}]+):([\\P{Cc}]+)@)?(([a-zA-Z0-9][a-zA-Z0-9.-]*)(:[0-9]{1,5})?)`)\n\tmatches := reg.FindAllStringSubmatch(proxyUrl, -1)\n\tif matches != nil {\n\t\tsplit := matches[0]\n\t\treturn len(split) == 0 || (len(split) > 5 && split[3] != \"\")\n\t}\n\n\treturn false\n}\n\nfunc splitProxyAuth(proxyUrl string) (string, string, string) {\n\tif proxyUrl == \"\" {\n\t\treturn \"\", \"\", \"\"\n\t}\n\treg := regexp.MustCompile(`^(https?:\\/\\/)(([\\P{Cc}]+):([\\P{Cc}]+)@)?(([a-zA-Z0-9][a-zA-Z0-9.-]*)(:[0-9]{1,5})?)`)\n\tmatches := reg.FindAllStringSubmatch(proxyUrl, -1)\n\n\tif matches != nil && len(matches[0]) > 5 {\n\t\tsplit := matches[0]\n\t\treturn split[1] + split[5], split[3], split[4]\n\t}\n\n\treturn proxyUrl, \"\", \"\"\n}\n"
  },
  {
    "path": "pkg/scraper/xpath.go",
    "content": "package scraper\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/antchfx/htmlquery\"\n\n\t\"golang.org/x/net/html\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype xpathScraper struct {\n\tdefinition   Definition\n\tglobalConfig GlobalConfig\n\tclient       *http.Client\n}\n\nfunc (s *xpathScraper) getXpathScraper(name string) (*mappedScraper, error) {\n\tret, ok := s.definition.XPathScrapers[name]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"xpath scraper with name %s not found in config\", name)\n\t}\n\treturn &ret, nil\n}\n\ntype xpathURLScraper struct {\n\txpathScraper\n\tdefinition ByURLDefinition\n}\n\nfunc (s *xpathURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) {\n\tscraper, err := s.getXpathScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getXPathQuery(doc, url)\n\t// if these just return the return values from scraper.scrape* functions then\n\t// it ends up returning ScrapedContent(nil) rather than nil\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\tret, err := scraper.scrapePerformer(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\tcase ScrapeContentTypeScene:\n\t\tret, err := scraper.scrapeScene(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\tcase ScrapeContentTypeGallery:\n\t\tret, err := scraper.scrapeGallery(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\tcase ScrapeContentTypeImage:\n\t\tret, err := scraper.scrapeImage(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\tcase ScrapeContentTypeMovie, ScrapeContentTypeGroup:\n\t\tret, err := scraper.scrapeGroup(ctx, q)\n\t\tif err != nil || ret == nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ret, nil\n\t}\n\n\treturn nil, ErrNotSupported\n}\n\ntype xpathNameScraper struct {\n\txpathScraper\n\tdefinition ByNameDefinition\n}\n\nfunc (s *xpathNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) {\n\tscraper, err := s.getXpathScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tconst placeholder = \"{}\"\n\n\t// replace the placeholder string with the URL-escaped name\n\tescapedName := url.QueryEscape(name)\n\n\turl := s.definition.QueryURL\n\turl = strings.ReplaceAll(url, placeholder, escapedName)\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getXPathQuery(doc, url)\n\tq.setType(SearchQuery)\n\n\tvar content []ScrapedContent\n\tswitch ty {\n\tcase ScrapeContentTypePerformer:\n\t\tperformers, err := scraper.scrapePerformers(ctx, q)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, p := range performers {\n\t\t\tcontent = append(content, p)\n\t\t}\n\n\t\treturn content, nil\n\tcase ScrapeContentTypeScene:\n\t\tscenes, err := scraper.scrapeScenes(ctx, q)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, s := range scenes {\n\t\t\tcontent = append(content, s)\n\t\t}\n\n\t\treturn content, nil\n\t}\n\n\treturn nil, ErrNotSupported\n}\n\ntype xpathFragmentScraper struct {\n\txpathScraper\n\tdefinition ByFragmentDefinition\n}\n\nfunc (s *xpathFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) {\n\t// construct the URL\n\tqueryURL := queryURLParametersFromScene(scene)\n\tif s.definition.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(s.definition.QueryURLReplacements)\n\t}\n\turl := queryURL.constructURL(s.definition.QueryURL)\n\n\tscraper, err := s.getXpathScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getXPathQuery(doc, url)\n\treturn scraper.scrapeScene(ctx, q)\n}\n\nfunc (s *xpathFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) {\n\tswitch {\n\tcase input.Gallery != nil:\n\t\treturn nil, fmt.Errorf(\"%w: cannot use an xpath scraper as a gallery fragment scraper\", ErrNotSupported)\n\tcase input.Performer != nil:\n\t\treturn nil, fmt.Errorf(\"%w: cannot use an xpath scraper as a performer fragment scraper\", ErrNotSupported)\n\tcase input.Scene == nil:\n\t\treturn nil, fmt.Errorf(\"%w: scene input is nil\", ErrNotSupported)\n\t}\n\n\tscene := *input.Scene\n\n\t// construct the URL\n\tqueryURL := queryURLParametersFromScrapedScene(scene)\n\tif s.definition.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(s.definition.QueryURLReplacements)\n\t}\n\turl := queryURL.constructURL(s.definition.QueryURL)\n\n\tscraper, err := s.getXpathScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getXPathQuery(doc, url)\n\treturn scraper.scrapeScene(ctx, q)\n}\n\nfunc (s *xpathFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) {\n\t// construct the URL\n\tqueryURL := queryURLParametersFromGallery(gallery)\n\tif s.definition.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(s.definition.QueryURLReplacements)\n\t}\n\turl := queryURL.constructURL(s.definition.QueryURL)\n\n\tscraper, err := s.getXpathScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getXPathQuery(doc, url)\n\treturn scraper.scrapeGallery(ctx, q)\n}\n\nfunc (s *xpathFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {\n\t// construct the URL\n\tqueryURL := queryURLParametersFromImage(image)\n\tif s.definition.QueryURLReplacements != nil {\n\t\tqueryURL.applyReplacements(s.definition.QueryURLReplacements)\n\t}\n\turl := queryURL.constructURL(s.definition.QueryURL)\n\n\tscraper, err := s.getXpathScraper(s.definition.Scraper)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := s.loadURL(ctx, url)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tq := s.getXPathQuery(doc, url)\n\treturn scraper.scrapeImage(ctx, q)\n}\n\nfunc (s *xpathScraper) loadURL(ctx context.Context, url string) (*html.Node, error) {\n\tr, err := loadURL(ctx, url, s.client, s.definition, s.globalConfig)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to load URL %q: %w\", url, err)\n\t}\n\n\tret, err := html.Parse(r)\n\n\tif err == nil && s.definition.DebugOptions != nil && s.definition.DebugOptions.PrintHTML {\n\t\tvar b bytes.Buffer\n\t\tif err := html.Render(&b, ret); err != nil {\n\t\t\tlogger.Warnf(\"could not render HTML: %v\", err)\n\t\t}\n\t\tlogger.Infof(\"loadURL (%s) response: \\n%s\", url, b.String())\n\t}\n\n\treturn ret, err\n}\n\nfunc (s *xpathScraper) getXPathQuery(doc *html.Node, url string) *xpathQuery {\n\treturn &xpathQuery{\n\t\tdoc:     doc,\n\t\tscraper: s,\n\t\turl:     url,\n\t}\n}\n\ntype xpathQuery struct {\n\tdoc       *html.Node\n\tscraper   *xpathScraper\n\tqueryType QueryType\n\turl       string\n}\n\nfunc (q *xpathQuery) getType() QueryType {\n\treturn q.queryType\n}\n\nfunc (q *xpathQuery) setType(t QueryType) {\n\tq.queryType = t\n}\n\nfunc (q *xpathQuery) getURL() string {\n\treturn q.url\n}\n\nfunc (q *xpathQuery) runQuery(selector string) ([]string, error) {\n\tfound, err := htmlquery.QueryAll(q.doc, selector)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"selector '%s': parse error: %v\", selector, err)\n\t}\n\n\tvar ret []string\n\tfor _, n := range found {\n\t\t// don't add empty strings\n\t\tnodeText := q.nodeText(n)\n\t\tif nodeText != \"\" {\n\t\t\tret = append(ret, q.nodeText(n))\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (q *xpathQuery) nodeText(n *html.Node) string {\n\tvar ret string\n\tif n != nil && n.Type == html.CommentNode {\n\t\tret = htmlquery.OutputHTML(n, true)\n\t} else {\n\t\tret = htmlquery.InnerText(n)\n\t}\n\n\t// trim all leading and trailing whitespace\n\tret = strings.TrimSpace(ret)\n\n\t// remove multiple whitespace\n\tre := regexp.MustCompile(\"  +\")\n\tret = re.ReplaceAllString(ret, \" \")\n\n\t// TODO - make this optional\n\tre = regexp.MustCompile(\"\\n\")\n\tret = re.ReplaceAllString(ret, \"\")\n\n\treturn ret\n}\n\nfunc (q *xpathQuery) subScrape(ctx context.Context, value string) mappedQuery {\n\tdoc, err := q.scraper.loadURL(ctx, value)\n\n\tif err != nil {\n\t\tlogger.Warnf(\"Error getting URL '%s' for sub-scraper: %s\", value, err.Error())\n\t\treturn nil\n\t}\n\n\treturn q.scraper.getXPathQuery(doc, value)\n}\n"
  },
  {
    "path": "pkg/scraper/xpath_test.go",
    "content": "package scraper\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/antchfx/htmlquery\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v2\"\n)\n\n// adapted from https://www.freeones.com/html/m_links/bio_Mia_Malkova.php\nconst htmlDoc1 = `\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"en\" lang=\"en\" dir=\"ltr\">\n\t<head>\n\t\t<title>Freeones:  Mia Malkova Biography</title>\n\t</head>\n\t<body data-babe=\"Mia Malkova\">\n\t\t<div class=\"ContentBlock Block1\">\n\t\t\t<div class=\"ContentBlockBody\" style=\"padding: 0px;\">\n\t\t\t\t<table id=\"biographyTable\" border=\"0\" cellspacing=\"0\" cellpadding=\"0\" width=\"100%\">\n\t\t\t\t\t<tbody>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<div><b>Babe Name:</b></div>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\t<a href=\"/html/m_links/Mia_Malkova/\">Mia Malkova</a>&nbsp;\n\t\t\t\t\t\t\t\t<a href=\"/html/m_links/Mia_Malkova/second_url\">Mia Malkova</a>&nbsp;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<div><b>Profession:</b></div>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">Porn Star\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Ethnicity:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\tCaucasian&nbsp;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Country of Origin:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\n\t\t\t\t\t\t\t\t<span class=\"country-us\">\n\n\t\t\t\t\t\t\t\t\tUnited States\n\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t</span></span></td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Date of Birth:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\tJuly 1, 1992 (27 years old)&nbsp;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Aliases:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\tMia Bliss, Madison Clover, Madison Swan, Mia Mountain, Jessica&nbsp;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Eye Color:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\tHazel&nbsp;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Hair Color:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\tBlonde&nbsp;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Height:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\t5ft7\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Weight:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\t126\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Measurements:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\t34C-26-36\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Fake boobs:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\tNo&nbsp;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Career Start And End</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\t2012 - 2019\n\t\t\t\t\t\t\t\t(7 Years In The Business)\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Tattoos:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\tNone&nbsp;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Piercings:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\t<!-- None -->;\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<b>Details:</b>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\tSome girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova.\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"paramname\">\n\t\t\t\t\t\t\t\t<div><b>Social Network Links:</b></div>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t<td class=\"paramvalue\">\n\t\t\t\t\t\t\t\t<ul id=\"socialmedia\">\n\t\t\t\t\t\t\t\t\t<!-- Adding twitter twice to verify distict post-processing -->\n\t\t\t\t\t\t\t\t\t<li class=\"twitter\"><a href=\"https://twitter.com/MiaMalkova\" target=\"_blank\" alt=\"Mia Malkova Twitter\" title=\"Mia Malkova Twitter\">Twitter</a></li>\n\t\t\t\t\t\t\t\t\t<li class=\"twitter\"><a href=\"https://twitter.com/MiaMalkova\" target=\"_blank\" alt=\"Mia Malkova Twitter\" title=\"Mia Malkova Twitter\">Twitter</a></li>\n\t\t\t\t\t\t\t\t\t<li class=\"facebook\"><a href=\"https://www.facebook.com/MiaMalcove\" target=\"_blank\" alt=\"Mia Malkova Facebook\" title=\"Mia Malkova Facebook\">Facebook</a></li>\n\t\t\t\t\t\t\t\t\t<li class=\"youtube\"><a href=\"https://www.youtube.com/channel/UCEPR0sZKa_ScMoyhemfB7nA\" target=\"_blank\" alt=\"Mia Malkova YouTube\" title=\"Mia Malkova YouTube\">YouTube</a></li>\n\t\t\t\t\t\t\t\t\t<li class=\"instagram\"><a href=\"https://www.instagram.com/mia_malkova/\" target=\"_blank\" alt=\"Mia Malkova Instagram\" title=\"Mia Malkova Instagram\">Instagram</a></li>\n\t\t\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n`\n\nfunc makeCommonXPath(attr string) string {\n\treturn `//table[@id=\"biographyTable\"]//tr/td[@class=\"paramname\"]//b[text() = '` + attr + `']/ancestor::tr/td[@class=\"paramvalue\"]`\n}\n\nfunc makeSimpleAttrConfig(str string) mappedScraperAttrConfig {\n\treturn mappedScraperAttrConfig{\n\t\tSelector: str,\n\t}\n}\n\nfunc makeReplaceRegex(regex string, with string) mappedRegexConfig {\n\tret := mappedRegexConfig{\n\t\tRegex: regex,\n\t\tWith:  with,\n\t}\n\n\treturn ret\n}\n\nfunc makeXPathConfig() mappedPerformerScraperConfig {\n\tconfig := mappedPerformerScraperConfig{\n\t\tmappedConfig: make(mappedConfig),\n\t}\n\n\tconfig.mappedConfig[\"Name\"] = makeSimpleAttrConfig(makeCommonXPath(\"Babe Name:\") + `/a`)\n\tconfig.mappedConfig[\"URL\"] = makeSimpleAttrConfig(makeCommonXPath(\"Babe Name:\") + `/a/@href`)\n\tconfig.mappedConfig[\"URLs\"] = makeSimpleAttrConfig(makeCommonXPath(\"Babe Name:\") + `/a/@href`)\n\tconfig.mappedConfig[\"Ethnicity\"] = makeSimpleAttrConfig(makeCommonXPath(\"Ethnicity:\"))\n\tconfig.mappedConfig[\"Aliases\"] = makeSimpleAttrConfig(makeCommonXPath(\"Aliases:\"))\n\tconfig.mappedConfig[\"EyeColor\"] = makeSimpleAttrConfig(makeCommonXPath(\"Eye Color:\"))\n\tconfig.mappedConfig[\"Measurements\"] = makeSimpleAttrConfig(makeCommonXPath(\"Measurements:\"))\n\tconfig.mappedConfig[\"FakeTits\"] = makeSimpleAttrConfig(makeCommonXPath(\"Fake boobs:\"))\n\tconfig.mappedConfig[\"Tattoos\"] = makeSimpleAttrConfig(makeCommonXPath(\"Tattoos:\"))\n\tconfig.mappedConfig[\"Piercings\"] = makeSimpleAttrConfig(makeCommonXPath(\"Piercings:\") + \"/comment()\")\n\tconfig.mappedConfig[\"Details\"] = makeSimpleAttrConfig(makeCommonXPath(\"Details:\"))\n\tconfig.mappedConfig[\"HairColor\"] = makeSimpleAttrConfig(makeCommonXPath(\"Hair Color:\"))\n\n\t// special handling for birthdate\n\tbirthdateAttrConfig := makeSimpleAttrConfig(makeCommonXPath(\"Date of Birth:\"))\n\n\tvar birthdateReplace mappedRegexConfigs\n\t// make this leave the trailing space to test existing scrapers that do so\n\tbirthdateReplace = append(birthdateReplace, makeReplaceRegex(`\\(.* years old\\)`, \"\"))\n\n\tbirthdateReplaceAction := postProcessReplace(birthdateReplace)\n\tbirthdateParseDate := postProcessParseDate(\"January 2, 2006\") // \"July 1, 1992 (27 years old)&nbsp;\"\n\tbirthdateAttrConfig.postProcessActions = []postProcessAction{\n\t\t&birthdateReplaceAction,\n\t\t&birthdateParseDate,\n\t}\n\tconfig.mappedConfig[\"Birthdate\"] = birthdateAttrConfig\n\n\t// special handling for career length\n\t// no colon in attribute header\n\tcareerLengthAttrConfig := makeSimpleAttrConfig(makeCommonXPath(\"Career Start And End\"))\n\n\tvar careerLengthReplace mappedRegexConfigs\n\tcareerLengthReplace = append(careerLengthReplace, makeReplaceRegex(`\\s+\\(.*\\)`, \"\"))\n\tcareerLengthReplaceAction := postProcessReplace(careerLengthReplace)\n\tcareerLengthAttrConfig.postProcessActions = []postProcessAction{\n\t\t&careerLengthReplaceAction,\n\t}\n\n\tconfig.mappedConfig[\"CareerLength\"] = careerLengthAttrConfig\n\n\t// use map post-process action for gender\n\tgenderConfig := makeSimpleAttrConfig(makeCommonXPath(\"Profession:\"))\n\tgenderMapAction := make(postProcessMap)\n\tgenderMapAction[\"Porn Star\"] = \"Female\"\n\tgenderConfig.postProcessActions = []postProcessAction{\n\t\t&genderMapAction,\n\t}\n\n\tconfig.mappedConfig[\"Gender\"] = genderConfig\n\n\t// use fixed for Country\n\tconfig.mappedConfig[\"Country\"] = mappedScraperAttrConfig{\n\t\tFixed: \"United States\",\n\t}\n\n\theightConfig := makeSimpleAttrConfig(makeCommonXPath(\"Height:\"))\n\theightConvAction := postProcessFeetToCm(true)\n\theightConfig.postProcessActions = []postProcessAction{\n\t\t&heightConvAction,\n\t}\n\tconfig.mappedConfig[\"Height\"] = heightConfig\n\n\tweightConfig := makeSimpleAttrConfig(makeCommonXPath(\"Weight:\"))\n\tweightConvAction := postProcessLbToKg(true)\n\tweightConfig.postProcessActions = []postProcessAction{\n\t\t&weightConvAction,\n\t}\n\tconfig.mappedConfig[\"Weight\"] = weightConfig\n\n\ttagConfig := mappedScraperAttrConfig{\n\t\tSelector: `//ul[@id=\"socialmedia\"]//a`,\n\t}\n\tconfig.Tags = make(mappedConfig)\n\tconfig.Tags[\"Name\"] = tagConfig\n\n\treturn config\n}\n\nfunc verifyField(t *testing.T, expected string, actual *string, field string) {\n\tt.Helper()\n\n\tif actual == nil || *actual != expected {\n\t\tif actual == nil {\n\t\t\tt.Errorf(\"Expected %s to be set to %s, instead got nil\", field, expected)\n\t\t} else {\n\t\t\tt.Errorf(\"Expected %s to be set to %s, instead got %s\", field, expected, *actual)\n\t\t}\n\t}\n}\n\nfunc TestScrapePerformerXPath(t *testing.T) {\n\treader := strings.NewReader(htmlDoc1)\n\tdoc, err := htmlquery.Parse(reader)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error loading document: %s\", err.Error())\n\t\treturn\n\t}\n\n\txpathConfig := makeXPathConfig()\n\n\tscraper := mappedScraper{\n\t\tPerformer: &xpathConfig,\n\t}\n\n\tq := &xpathQuery{\n\t\tdoc: doc,\n\t}\n\n\tperformer, err := scraper.scrapePerformer(context.Background(), q)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error scraping performer: %s\", err.Error())\n\t\treturn\n\t}\n\n\tconst performerName = \"Mia Malkova\"\n\tconst url = \"/html/m_links/Mia_Malkova/\"\n\tconst secondURL = \"/html/m_links/Mia_Malkova/second_url\"\n\tconst ethnicity = \"Caucasian\"\n\tconst country = \"United States\"\n\tconst birthdate = \"1992-07-01\"\n\tconst aliases = \"Mia Bliss, Madison Clover, Madison Swan, Mia Mountain, Jessica\"\n\tconst eyeColor = \"Hazel\"\n\tconst measurements = \"34C-26-36\"\n\tconst fakeTits = \"No\"\n\tconst careerLength = \"2012 - 2019\"\n\tconst tattoos = \"None\"\n\tconst piercings = \"<!-- None -->\"\n\tconst gender = \"Female\"\n\tconst height = \"170\" //\t5ft7\n\tconst details = \"Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova.\"\n\tconst hairColor = \"Blonde\"\n\tconst weight = \"57\" // 126 lb\n\n\tverifyField(t, performerName, performer.Name, \"Name\")\n\tverifyField(t, url, performer.URL, \"URL\")\n\n\t// #5294 - test multiple URLs\n\tif len(performer.URLs) != 2 {\n\t\tt.Errorf(\"Expected 2 URLs, got %d\", len(performer.URLs))\n\t} else {\n\t\tverifyField(t, url, &performer.URLs[0], \"URLs[0]\")\n\t\tverifyField(t, secondURL, &performer.URLs[1], \"URLs[1]\")\n\t}\n\n\tverifyField(t, gender, performer.Gender, \"Gender\")\n\tverifyField(t, ethnicity, performer.Ethnicity, \"Ethnicity\")\n\tverifyField(t, country, performer.Country, \"Country\")\n\n\tverifyField(t, birthdate, performer.Birthdate, \"Birthdate\")\n\n\tverifyField(t, aliases, performer.Aliases, \"Aliases\")\n\tverifyField(t, eyeColor, performer.EyeColor, \"EyeColor\")\n\tverifyField(t, measurements, performer.Measurements, \"Measurements\")\n\tverifyField(t, fakeTits, performer.FakeTits, \"FakeTits\")\n\n\tverifyField(t, careerLength, performer.CareerLength, \"CareerLength\")\n\n\tverifyField(t, tattoos, performer.Tattoos, \"Tattoos\")\n\tverifyField(t, piercings, performer.Piercings, \"Piercings\")\n\tverifyField(t, height, performer.Height, \"Height\")\n\tverifyField(t, details, performer.Details, \"Details\")\n\tverifyField(t, hairColor, performer.HairColor, \"HairColor\")\n\tverifyField(t, weight, performer.Weight, \"Weight\")\n\n\texpectedTagNames := []string{\n\t\t\"Twitter\",\n\t\t\"Facebook\",\n\t\t\"YouTube\",\n\t\t\"Instagram\",\n\t}\n\tfor i, expected := range expectedTagNames {\n\t\tverifyField(t, expected, &performer.Tags[i].Name, \"TagName\")\n\t}\n}\n\nfunc TestConcatXPath(t *testing.T) {\n\tconst firstName = \"FirstName\"\n\tconst lastName = \"LastName\"\n\tconst eyeColor = \"EyeColor\"\n\tconst separator = \" \"\n\tconst testDoc = `\n\t<html>\n\t<div>` + firstName + `</div>\n\t<div>` + lastName + `</div>\n\t<span>` + eyeColor + `</span>\n\t</html>\n\t`\n\n\treader := strings.NewReader(testDoc)\n\tdoc, err := htmlquery.Parse(reader)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error loading document: %s\", err.Error())\n\t\treturn\n\t}\n\n\txpathConfig := make(mappedConfig)\n\tnameAttrConfig := mappedScraperAttrConfig{\n\t\tSelector: \"//div\",\n\t\tConcat:   separator,\n\t}\n\txpathConfig[\"Name\"] = nameAttrConfig\n\txpathConfig[\"EyeColor\"] = makeSimpleAttrConfig(\"//span\")\n\n\tscraper := mappedScraper{\n\t\tPerformer: &mappedPerformerScraperConfig{\n\t\t\tmappedConfig: xpathConfig,\n\t\t},\n\t}\n\n\tq := &xpathQuery{\n\t\tdoc: doc,\n\t}\n\n\tperformer, err := scraper.scrapePerformer(context.Background(), q)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error scraping performer: %s\", err.Error())\n\t\treturn\n\t}\n\n\tconst performerName = firstName + separator + lastName\n\n\tverifyField(t, performerName, performer.Name, \"Name\")\n\tverifyField(t, eyeColor, performer.EyeColor, \"EyeColor\")\n}\n\nconst sceneHTML = `\n<!DOCTYPE html>\n\n<head>\n    <title>Test Video - Pornhub.com</title>\n    <meta property=\"og:title\" content=\"Test Video\" />\n    <script type=\"application/ld+json\">\n\t\t{\n\t\t\t\"name\": \"Test Video\",\n\t\t\t\"uploadDate\": \"2019-10-13T00:33:51+00:00\",\n\t\t\t\"author\" : \"Mia Malkova\"\n\t\t}\n\t</script>\n</head>\n\n<body class=\"logged-out\">\n    <div class=\"container  \">\n        <div id=\"main-container\" class=\"clearfix\">\n            <div id=\"vpContentContainer\">\n                <div id=\"hd-leftColVideoPage\">\n                    <div class=\"video-wrapper\">\n\t\t\t\t\t\t<div class=\"title-container\">\n\t\t\t\t\t\t\t<i class=\"isMe tooltipTrig\" data-title=\"Video of verified member\"></i>\n                            <h1 class=\"title\">\n                                <span class=\"inlineFree\">Test Video</span>\n                            </h1>\n                        </div>\n\n                        <div class=\"video-actions-container\">\n                            <div class=\"video-actions-tabs\">\n                                <div class=\"video-action-tab about-tab active\">\n                                    <div class=\"video-detailed-info\">\n                                        <div class=\"video-info-row\">\n                                            From:&nbsp;\n                                            <div class=\"usernameWrap clearfix\" data-type=\"channel\">\n                                                <a rel=\"\" href=\"/channels/sis-loves-me\" class=\"bolded\">Sis Loves Me</a>\n                                            </div>\n                                        </div>\n\n                                        <div class=\"video-info-row\">\n                                            <div class=\"pornstarsWrapper\">\n                                                Pornstars:&nbsp;\n                                                <a class=\"pstar-list-btn js-mxp\" data-mxptype=\"Pornstar\"\n                                                    data-mxptext=\"Alex D\" href=\"/pornstar/alex-d\">Alex D\n                                                </a>\n                                                , <a class=\"pstar-list-btn js-mxp\" data-mxptype=\"Pornstar\"\n                                                    data-mxptext=\"Mia Malkova\" href=\"/pornstar/mia-malkova\">\n                                                </a>\n                                                , <a class=\"pstar-list-btn js-mxp\" data-mxptype=\"Pornstar\"\n                                                    data-mxptext=\"Riley Reid\" href=\"/pornstar/riley-reid\">Riley Reid\n                                                </a>\n                                                <div class=\"tooltipTrig suggestBtn\" data-title=\"Add a pornstar\">\n                                                    <a class=\"add-btn-small add-pornstar-btn-2\">+\n                                                        <span>Suggest</span></a>\n                                                </div>\n                                            </div>\n                                        </div>\n\n                                        <div class=\"video-info-row showLess\">\n                                            <div class=\"categoriesWrapper\">\n                                                Categories:&nbsp;\n                                                <a href=\"/video?c=3\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Amateur</a>,\n                                                <a href=\"/categories/babe\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Babe</a>,\n                                                <a href=\"/video?c=13\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Blowjob</a>,\n                                                <a href=\"/video?c=115\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Exclusive</a>,\n                                                <a href=\"/hd\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">HD\n                                                    Porn</a>, <a href=\"/categories/pornstar\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Pornstar</a>,\n                                                <a href=\"/video?c=24\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Public</a>,\n                                                <a href=\"/video?c=131\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Pussy\n                                                    Licking</a>, <a href=\"/video?c=65\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Threesome</a>,\n                                                <a href=\"/video?c=139\"\n                                                    onclick=\"ga('send', 'event', 'Watch Page', 'click', 'Category');\">Verified\n                                                    Models</a>\n                                                <div class=\"tooltipTrig suggestBtn\" data-title=\"Suggest Categories\">\n                                                    <a id=\"categoryLink\" class=\"add-btn-small \">+\n                                                        <span>Suggest</span></a>\n                                                </div>\n                                            </div>\n                                        </div>\n\n                                        <div class=\"video-info-row showLess\">\n                                            <div class=\"tagsWrapper\">\n                                                Tags:&nbsp;\n                                                <a href=\"/video/search?search=3some\">3some</a>, <a\n                                                    href=\"/video?c=9\">blonde</a>, <a href=\"/video?c=59\">small tits</a>,\n                                                <a href=\"/video/search?search=butt\">butt</a>, <a\n                                                    href=\"/video/search?search=natural+tits\">natural tits</a>, <a\n                                                    href=\"/video/search?search=petite\">petite</a>, <a\n                                                    href=\"/video?c=24\">public</a>, <a\n                                                    href=\"/video/search?search=outside\">outside</a>, <a\n                                                    href=\"/video/search?search=car\">car</a>, <a\n                                                    href=\"/video/search?search=garage\">garage</a>, <a\n                                                    href=\"/video?c=65\">threesome</a>, <a\n                                                    href=\"/video/search?search=bgg\">bgg</a>, <a\n                                                    href=\"/video/search?search=girlfrien+d\">girlfrien d</a>, <a\n                                                    href=\"/video/search?search=parking\">parking</a>, <a\n                                                    href=\"/video/search?search=sex\">sex</a>, <a\n                                                    href=\"/video/search?search=gagging\">gagging</a>, <a\n                                                    href=\"/video?c=13\">blowjob</a>, <a\n                                                    href=\"/video/search?search=bj\">bj</a>, <a\n                                                    href=\"/video/search?search=double\">double</a>, <a\n                                                    href=\"/video/search?search=ass\">ass</a>\n                                                <div class=\"tooltipTrig suggestBtn\" data-title=\"Suggest Tags\">\n                                                    <a id=\"tagLink\" class=\"add-btn-small\">+ <span>Suggest</span></a>\n                                                </div>\n                                            </div>\n                                        </div>\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</body>\n</html>`\n\nfunc makeSceneXPathConfig() mappedScraper {\n\tcommon := make(commonMappedConfig)\n\n\tcommon[\"$performerElem\"] = `//div[@class=\"pornstarsWrapper\"]/a[@data-mxptype=\"Pornstar\"]`\n\tcommon[\"$studioElem\"] = `//div[@data-type=\"channel\"]/a`\n\n\tconfig := mappedSceneScraperConfig{\n\t\tmappedConfig: make(mappedConfig),\n\t}\n\n\tconfig.mappedConfig[\"Title\"] = makeSimpleAttrConfig(`//meta[@property=\"og:title\"]/@content`)\n\t// this needs post-processing\n\tconfig.mappedConfig[\"Date\"] = makeSimpleAttrConfig(`//script[@type=\"application/ld+json\"]`)\n\n\ttagConfig := make(mappedConfig)\n\ttagConfig[\"Name\"] = makeSimpleAttrConfig(`//div[@class=\"categoriesWrapper\"]//a[not(@class=\"add-btn-small \")]`)\n\tconfig.Tags = tagConfig\n\n\tperformerConfig := make(mappedConfig)\n\tperformerConfig[\"Name\"] = makeSimpleAttrConfig(`$performerElem/@data-mxptext`)\n\tperformerConfig[\"URLs\"] = makeSimpleAttrConfig(`$performerElem/@href`)\n\tconfig.Performers.mappedConfig = performerConfig\n\n\tstudioConfig := make(mappedConfig)\n\tstudioConfig[\"Name\"] = makeSimpleAttrConfig(`$studioElem`)\n\tstudioConfig[\"URL\"] = makeSimpleAttrConfig(`$studioElem/@href`)\n\tconfig.Studio = studioConfig\n\n\tconst sep = \" \"\n\tmoviesNameConfig := mappedScraperAttrConfig{\n\t\tSelector: `//i[@class=\"isMe tooltipTrig\"]/@data-title`,\n\t\tSplit:    sep,\n\t}\n\tmoviesConfig := make(mappedConfig)\n\tmoviesConfig[\"Name\"] = moviesNameConfig\n\tconfig.Movies = moviesConfig\n\n\tscraper := mappedScraper{\n\t\tScene:  &config,\n\t\tCommon: common,\n\t}\n\n\treturn scraper\n}\n\nfunc verifyTags(t *testing.T, expectedTagNames []string, actualTags []*models.ScrapedTag) {\n\tt.Helper()\n\n\ti := 0\n\tfor i < len(expectedTagNames) || i < len(actualTags) {\n\t\texpectedTag := \"\"\n\t\tactualTag := \"\"\n\t\tif i < len(expectedTagNames) {\n\t\t\texpectedTag = expectedTagNames[i]\n\t\t}\n\t\tif i < len(actualTags) {\n\t\t\tactualTag = actualTags[i].Name\n\t\t}\n\n\t\tif expectedTag != actualTag {\n\t\t\tt.Errorf(\"Expected tag %s, got %s\", expectedTag, actualTag)\n\t\t}\n\t\ti++\n\t}\n}\n\nfunc verifyMovies(t *testing.T, expectedMovieNames []string, actualMovies []*models.ScrapedMovie) {\n\tt.Helper()\n\n\ti := 0\n\tfor i < len(expectedMovieNames) || i < len(actualMovies) {\n\t\texpectedMovie := \"\"\n\t\tactualMovie := \"\"\n\t\tif i < len(expectedMovieNames) {\n\t\t\texpectedMovie = expectedMovieNames[i]\n\t\t}\n\t\tif i < len(actualMovies) {\n\t\t\tactualMovie = *actualMovies[i].Name\n\t\t}\n\n\t\tif expectedMovie != actualMovie {\n\t\t\tt.Errorf(\"Expected movie %s, got %s\", expectedMovie, actualMovie)\n\t\t}\n\t\ti++\n\t}\n}\n\nfunc verifyPerformers(t *testing.T, expectedNames []string, expectedURLs []string, actualPerformers []*models.ScrapedPerformer) {\n\tt.Helper()\n\n\ti := 0\n\tfor i < len(expectedNames) || i < len(actualPerformers) {\n\t\texpectedName := \"\"\n\t\tactualName := \"\"\n\t\texpectedURL := \"\"\n\t\tactualURL := \"\"\n\t\tif i < len(expectedNames) {\n\t\t\texpectedName = expectedNames[i]\n\t\t}\n\t\tif i < len(expectedURLs) {\n\t\t\texpectedURL = expectedURLs[i]\n\t\t}\n\t\tif i < len(actualPerformers) {\n\t\t\tactualName = *actualPerformers[i].Name\n\t\t\tif len(actualPerformers[i].URLs) == 1 {\n\t\t\t\tactualURL = actualPerformers[i].URLs[0]\n\t\t\t}\n\t\t}\n\n\t\tif expectedName != actualName {\n\t\t\tt.Errorf(\"Expected performer name %q, got %q\", expectedName, actualName)\n\t\t}\n\t\tif expectedURL != actualURL {\n\t\t\tt.Errorf(\"Expected performer URL %q, got %q\", expectedURL, actualURL)\n\t\t}\n\t\ti++\n\t}\n}\n\nfunc TestApplySceneXPathConfig(t *testing.T) {\n\treader := strings.NewReader(sceneHTML)\n\tdoc, err := htmlquery.Parse(reader)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error loading document: %s\", err.Error())\n\t\treturn\n\t}\n\n\tscraper := makeSceneXPathConfig()\n\n\tq := &xpathQuery{\n\t\tdoc: doc,\n\t}\n\tscene, err := scraper.scrapeScene(context.Background(), q)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error scraping scene: %s\", err.Error())\n\t\treturn\n\t}\n\n\tconst title = \"Test Video\"\n\n\tverifyField(t, title, scene.Title, \"Title\")\n\n\t// verify tags\n\texpectedTags := []string{\n\t\t\"Amateur\",\n\t\t\"Babe\",\n\t\t\"Blowjob\",\n\t\t\"Exclusive\",\n\t\t\"HD Porn\",\n\t\t\"Pornstar\",\n\t\t\"Public\",\n\t\t\"Pussy Licking\",\n\t\t\"Threesome\",\n\t\t\"Verified Models\",\n\t}\n\tverifyTags(t, expectedTags, scene.Tags)\n\n\t// verify movies\n\texpectedMovies := []string{\n\t\t\"Video\",\n\t\t\"of\",\n\t\t\"verified\",\n\t\t\"member\",\n\t}\n\tverifyMovies(t, expectedMovies, scene.Movies)\n\n\texpectedPerformerNames := []string{\n\t\t\"Alex D\",\n\t\t\"Mia Malkova\",\n\t\t\"Riley Reid\",\n\t}\n\n\texpectedPerformerURLs := []string{\n\t\t\"/pornstar/alex-d\",\n\t\t\"/pornstar/mia-malkova\",\n\t\t\"/pornstar/riley-reid\",\n\t}\n\n\tverifyPerformers(t, expectedPerformerNames, expectedPerformerURLs, scene.Performers)\n\n\tconst expectedStudioName = \"Sis Loves Me\"\n\tconst expectedStudioURL = \"/channels/sis-loves-me\"\n\n\tverifyField(t, expectedStudioName, &scene.Studio.Name, \"Studio.Name\")\n\tverifyField(t, expectedStudioURL, scene.Studio.URL, \"Studio.URL\")\n}\n\nfunc TestLoadXPathScraperFromYAML(t *testing.T) {\n\tconst yamlStr = `name: Test\nperformerByURL:\n  - action: scrapeXPath\n    url:\n      - test.com\n    scraper: performerScraper\nxPathScrapers:\n  performerScraper:\n    performer:\n      name: //h1[@itemprop=\"name\"]\n  sceneScraper:\n    scene:\n      Title:\n        selector: //title\n        postProcess:\n          - parseDate: January 2, 2006\n      Tags:\n        Name: //tags\n      Movies:\n        Name: //movies\n      Performers:\n        Name: //performers\n      Studio:\n        Name: //studio\n`\n\n\tc := &Definition{}\n\terr := yaml.Unmarshal([]byte(yamlStr), &c)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error loading yaml: %s\", err.Error())\n\t\treturn\n\t}\n\n\t// ensure fields are filled in correctly\n\tsceneScraper := c.XPathScrapers[\"sceneScraper\"]\n\tsceneConfig := sceneScraper.Scene\n\n\tassert.Equal(t, \"//title\", sceneConfig.mappedConfig[\"Title\"].Selector)\n\tassert.Equal(t, \"//tags\", sceneConfig.Tags[\"Name\"].Selector)\n\tassert.Equal(t, \"//movies\", sceneConfig.Movies[\"Name\"].Selector)\n\tassert.Equal(t, \"//performers\", sceneConfig.Performers.mappedConfig[\"Name\"].Selector)\n\tassert.Equal(t, \"//studio\", sceneConfig.Studio[\"Name\"].Selector)\n\n\tpostProcess := sceneConfig.mappedConfig[\"Title\"].postProcessActions\n\tparseDate := postProcess[0].(*postProcessParseDate)\n\tassert.Equal(t, \"January 2, 2006\", string(*parseDate))\n}\n\nfunc TestLoadInvalidXPath(t *testing.T) {\n\tconfig := make(mappedConfig)\n\n\tconfig[\"Name\"] = makeSimpleAttrConfig(`//a[id=']/span`)\n\n\treader := strings.NewReader(htmlDoc1)\n\tdoc, err := htmlquery.Parse(reader)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error loading document: %s\", err.Error())\n\t\treturn\n\t}\n\n\tq := &xpathQuery{\n\t\tdoc: doc,\n\t}\n\n\tconfig.process(context.Background(), q, nil, nil)\n}\n\ntype mockGlobalConfig struct{}\n\nfunc (mockGlobalConfig) GetScraperUserAgent() string {\n\treturn \"\"\n}\n\nfunc (mockGlobalConfig) GetScrapersPath() string {\n\treturn \"\"\n}\n\nfunc (mockGlobalConfig) GetScraperCDPPath() string {\n\treturn \"\"\n}\n\nfunc (mockGlobalConfig) GetScraperCertCheck() bool {\n\treturn false\n}\n\nfunc (mockGlobalConfig) GetScraperExcludeTagPatterns() []string {\n\treturn nil\n}\n\nfunc (mockGlobalConfig) GetPythonPath() string {\n\treturn \"\"\n}\n\nfunc (mockGlobalConfig) GetProxy() string {\n\treturn \"\"\n}\n\nfunc TestSubScrape(t *testing.T) {\n\tretHTML := `\n\t<div>\n\t\t<a href=\"/getName\">A link</a>\n\t</div>\n\t`\n\n\tssHTML := `\n\t<span>The name</span>\n\t`\n\n\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/getName\" {\n\t\t\tfmt.Fprint(w, ssHTML)\n\t\t} else {\n\t\t\tfmt.Fprint(w, retHTML)\n\t\t}\n\t}))\n\tdefer ts.Close()\n\n\tyamlStr := `name: Test\nperformerByURL:\n  - action: scrapeXPath\n    url:\n      - ` + ts.URL + `\n    scraper: performerScraper\nxPathScrapers:\n  performerScraper:\n    performer:\n      Name:\n        selector: //div/a/@href\n        postProcess:\n          - replace:\n              - regex: ^\n                with: ` + ts.URL + `\n          - subScraper:\n              selector: //span\n`\n\n\tc := &Definition{}\n\terr := yaml.Unmarshal([]byte(yamlStr), &c)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error loading yaml: %s\", err.Error())\n\t\treturn\n\t}\n\n\tglobalConfig := mockGlobalConfig{}\n\n\tclient := &http.Client{}\n\tctx := context.Background()\n\ts := scraperFromDefinition(*c, globalConfig)\n\tcontent, err := s.viaURL(ctx, client, ts.URL, ScrapeContentTypePerformer)\n\n\tif err != nil {\n\t\tt.Errorf(\"Error scraping performer: %s\", err.Error())\n\t\treturn\n\t}\n\n\tperformer, ok := content.(*models.ScrapedPerformer)\n\tif !ok {\n\t\tt.Error(\"couldn't convert scraped content into a performer\")\n\t}\n\n\tverifyField(t, \"The name\", performer.Name, \"Name\")\n}\n"
  },
  {
    "path": "pkg/session/authentication.go",
    "content": "package session\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype ExternalAccessError net.IP\n\nfunc (e ExternalAccessError) Error() string {\n\treturn fmt.Sprintf(\"stash accessed from external IP %s\", net.IP(e).String())\n}\n\nfunc CheckAllowPublicWithoutAuth(c ExternalAccessConfig, r *http.Request) error {\n\tif !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() && !c.IsNewSystem() {\n\t\trequestIPString, _, err := net.SplitHostPort(r.RemoteAddr)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error parsing remote host (%s): %w\", r.RemoteAddr, err)\n\t\t}\n\n\t\t// presence of scope ID in IPv6 addresses prevents parsing. Remove if present\n\t\tscopeIDIndex := strings.Index(requestIPString, \"%\")\n\t\tif scopeIDIndex != -1 {\n\t\t\trequestIPString = requestIPString[0:scopeIDIndex]\n\t\t}\n\n\t\trequestIP := net.ParseIP(requestIPString)\n\t\tif requestIP == nil {\n\t\t\treturn fmt.Errorf(\"unable to parse remote host (%s)\", requestIPString)\n\t\t}\n\n\t\tif r.Header.Get(\"X-FORWARDED-FOR\") != \"\" {\n\t\t\t// Request was proxied\n\t\t\tproxyChain := strings.Split(r.Header.Get(\"X-FORWARDED-FOR\"), \", \")\n\n\t\t\t// validate proxies against local network only\n\t\t\tif !isLocalIP(requestIP) {\n\t\t\t\treturn ExternalAccessError(requestIP)\n\t\t\t} else {\n\t\t\t\t// Safe to validate X-Forwarded-For\n\t\t\t\tfor i := range proxyChain {\n\t\t\t\t\tip := net.ParseIP(proxyChain[i])\n\t\t\t\t\tif !isLocalIP(ip) {\n\t\t\t\t\t\treturn ExternalAccessError(ip)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t} else if !isLocalIP(requestIP) { // request was not proxied\n\t\t\treturn ExternalAccessError(requestIP)\n\t\t}\n\n\t}\n\n\treturn nil\n}\n\nfunc CheckExternalAccessTripwire(c ExternalAccessConfig) *ExternalAccessError {\n\tif !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() {\n\t\tif remoteIP := c.GetSecurityTripwireAccessedFromPublicInternet(); remoteIP != \"\" {\n\t\t\terr := ExternalAccessError(net.ParseIP(remoteIP))\n\t\t\treturn &err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc isLocalIP(requestIP net.IP) bool {\n\t_, cgNatAddrSpace, _ := net.ParseCIDR(\"100.64.0.0/10\")\n\treturn requestIP.IsPrivate() || requestIP.IsLoopback() || requestIP.IsLinkLocalUnicast() || cgNatAddrSpace.Contains(requestIP)\n}\n\nfunc LogExternalAccessError(err ExternalAccessError) {\n\tlogger.Errorf(\"Stash has been accessed from the internet (public IP %s), without authentication. \\n\"+\n\t\t\"This is extremely dangerous! The whole world can see your stash page and browse your files! \\n\"+\n\t\t\"You probably forwarded a port from your router. At the very least, add a password to stash in the settings. \\n\"+\n\t\t\"Stash will not serve requests until you edit config.yml, remove the security_tripwire_accessed_from_public_internet key and restart stash. \\n\"+\n\t\t\"This behaviour can be overridden (but not recommended) by setting dangerous_allow_public_without_auth to true in config.yml. \\n\"+\n\t\t\"More information is available at https://discourse.stashapp.cc/t/-/1658 \\n\"+\n\t\t\"Stash is not answering any other requests to protect your privacy.\", net.IP(err).String())\n}\n"
  },
  {
    "path": "pkg/session/authentication_test.go",
    "content": "package session\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"testing\"\n)\n\ntype config struct {\n\tusername                                   string\n\tpassword                                   string\n\tdangerousAllowPublicWithoutAuth            bool\n\tsecurityTripwireAccessedFromPublicInternet string\n}\n\nfunc (c *config) HasCredentials() bool {\n\treturn c.username != \"\" && c.password != \"\"\n}\n\nfunc (c *config) GetDangerousAllowPublicWithoutAuth() bool {\n\treturn c.dangerousAllowPublicWithoutAuth\n}\n\nfunc (c *config) GetSecurityTripwireAccessedFromPublicInternet() string {\n\treturn c.securityTripwireAccessedFromPublicInternet\n}\n\nfunc (c *config) IsNewSystem() bool {\n\treturn false\n}\n\nfunc TestCheckAllowPublicWithoutAuth(t *testing.T) {\n\tc := &config{}\n\n\tdoTest := func(caseIndex int, r *http.Request, expectedErr interface{}) {\n\t\tt.Helper()\n\t\terr := CheckAllowPublicWithoutAuth(c, r)\n\n\t\tif expectedErr == nil && err == nil {\n\t\t\treturn\n\t\t}\n\n\t\tif expectedErr == nil {\n\t\t\tt.Errorf(\"[%d]: unexpected error: %v\", caseIndex, err)\n\t\t\treturn\n\t\t}\n\n\t\tif !errors.As(err, expectedErr) {\n\t\t\tt.Errorf(\"[%d]: expected %T, got %v (%T)\", caseIndex, expectedErr, err, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\t{\n\t\t// direct connection tests\n\t\ttestCases := []struct {\n\t\t\taddress string\n\t\t\terr     error\n\t\t}{\n\t\t\t{\"192.168.1.1:8080\", nil},\n\t\t\t{\"192.168.1.1:8080\", nil},\n\t\t\t{\"100.64.0.1:8080\", nil},\n\t\t\t{\"127.0.0.1:8080\", nil},\n\t\t\t{\"[::1]:8080\", nil},\n\t\t\t{\"[fe80::c081:1c1a:ae39:d3cd%Ethernet 5]:9999\", nil},\n\t\t\t{\"193.168.1.1:8080\", &ExternalAccessError{}},\n\t\t\t{\"[2002:9fc4:ed97:e472:5170:5766:520c:c901]:9999\", &ExternalAccessError{}},\n\t\t}\n\n\t\t// try with no X-FORWARDED-FOR and valid one\n\t\txFwdVals := []string{\"\", \"192.168.1.1\"}\n\n\t\tfor i, xFwdVal := range xFwdVals {\n\t\t\theader := make(http.Header)\n\t\t\theader.Set(\"X-FORWARDED-FOR\", xFwdVal)\n\n\t\t\tfor ii, tc := range testCases {\n\t\t\t\tr := &http.Request{\n\t\t\t\t\tRemoteAddr: tc.address,\n\t\t\t\t\tHeader:     header,\n\t\t\t\t}\n\n\t\t\t\tdoTest((i*len(testCases) + ii), r, tc.err)\n\t\t\t}\n\t\t}\n\t}\n\n\t{\n\t\t// X-FORWARDED-FOR\n\t\ttestCases := []struct {\n\t\t\tproxyChain string\n\t\t\terr        error\n\t\t}{\n\t\t\t{\"192.168.1.1, 192.168.1.2, 100.64.0.1, 127.0.0.1\", nil},\n\t\t\t{\"192.168.1.1, 193.168.1.1\", &ExternalAccessError{}},\n\t\t\t{\"193.168.1.1, 192.168.1.1\", &ExternalAccessError{}},\n\t\t}\n\n\t\tconst remoteAddr = \"192.168.1.1:8080\"\n\n\t\theader := make(http.Header)\n\n\t\tfor i, tc := range testCases {\n\t\t\theader.Set(\"X-FORWARDED-FOR\", tc.proxyChain)\n\t\t\tr := &http.Request{\n\t\t\t\tRemoteAddr: remoteAddr,\n\t\t\t\tHeader:     header,\n\t\t\t}\n\n\t\t\tdoTest(i, r, tc.err)\n\t\t}\n\t}\n\n\t{\n\t\t// test invalid request IPs\n\t\tinvalidIPs := []string{\"192.168.1.a:9999\", \"192.168.1.1\"}\n\n\t\tfor _, remoteAddr := range invalidIPs {\n\t\t\tr := &http.Request{\n\t\t\t\tRemoteAddr: remoteAddr,\n\t\t\t}\n\n\t\t\terr := CheckAllowPublicWithoutAuth(c, r)\n\t\t\tif err == nil {\n\t\t\t\tt.Errorf(\"[%s]: expected error\", remoteAddr)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n\n\t{\n\t\t// test overrides\n\t\tr := &http.Request{\n\t\t\tRemoteAddr: \"193.168.1.1:8080\",\n\t\t}\n\n\t\tc.username = \"admin\"\n\t\tc.password = \"admin\"\n\n\t\tif err := CheckAllowPublicWithoutAuth(c, r); err != nil {\n\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t}\n\n\t\tc.username = \"\"\n\t\tc.password = \"\"\n\n\t\tc.dangerousAllowPublicWithoutAuth = true\n\n\t\tif err := CheckAllowPublicWithoutAuth(c, r); err != nil {\n\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t}\n\t}\n}\n\nfunc TestCheckExternalAccessTripwire(t *testing.T) {\n\tc := &config{}\n\tc.securityTripwireAccessedFromPublicInternet = \"4.4.4.4\"\n\n\t// always return nil if authentication configured or dangerous key set\n\tc.username = \"admin\"\n\tc.password = \"admin\"\n\n\tif err := CheckExternalAccessTripwire(c); err != nil {\n\t\tt.Errorf(\"unexpected error %v\", err)\n\t}\n\n\tc.username = \"\"\n\tc.password = \"\"\n\n\t// HACK - this key isn't publically exposed\n\tc.dangerousAllowPublicWithoutAuth = true\n\n\tif err := CheckExternalAccessTripwire(c); err != nil {\n\t\tt.Errorf(\"unexpected error %v\", err)\n\t}\n\n\tc.dangerousAllowPublicWithoutAuth = false\n\n\tif err := CheckExternalAccessTripwire(c); err == nil {\n\t\tt.Errorf(\"expected error %v\", ExternalAccessError(\"4.4.4.4\"))\n\t}\n\n\tc.securityTripwireAccessedFromPublicInternet = \"\"\n\n\tif err := CheckExternalAccessTripwire(c); err != nil {\n\t\tt.Errorf(\"unexpected error %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/session/config.go",
    "content": "package session\n\ntype ExternalAccessConfig interface {\n\tHasCredentials() bool\n\tGetDangerousAllowPublicWithoutAuth() bool\n\tGetSecurityTripwireAccessedFromPublicInternet() string\n\tIsNewSystem() bool\n}\n\ntype SessionConfig interface {\n\tGetUsername() string\n\tGetAPIKey() string\n\n\tGetSessionStoreKey() []byte\n\tGetMaxSessionAge() int\n\tValidateCredentials(username string, password string) bool\n}\n"
  },
  {
    "path": "pkg/session/local.go",
    "content": "package session\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"net/http\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\n// SetLocalRequest checks if the request is from localhost and sets the context value accordingly.\n// It returns the modified request with the updated context, or the original request if it did\n// not come from localhost or if there was an error parsing the remote address.\nfunc SetLocalRequest(r *http.Request) *http.Request {\n\t// determine if request is from localhost\n\thost, _, err := net.SplitHostPort(r.RemoteAddr)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error parsing remote address: %v\", err)\n\t\treturn r\n\t}\n\n\tip := net.ParseIP(host)\n\tif ip == nil {\n\t\tlogger.Errorf(\"Error parsing IP address: %s\", host)\n\t\treturn r\n\t}\n\n\tif ip.IsLoopback() {\n\t\tctx := context.WithValue(r.Context(), contextLocalRequest, true)\n\t\tr = r.WithContext(ctx)\n\t}\n\n\treturn r\n}\n\n// IsLocalRequest returns true if the request is from localhost, as determined by the context value set by SetLocalRequest.\n// If the context value is not set, it returns false.\nfunc IsLocalRequest(ctx context.Context) bool {\n\tval := ctx.Value(contextLocalRequest)\n\tif val == nil {\n\t\treturn false\n\t}\n\treturn val.(bool)\n}\n"
  },
  {
    "path": "pkg/session/plugin.go",
    "content": "package session\n\nimport (\n\t\"context\"\n\t\"encoding/gob\"\n\t\"net/http\"\n\n\t\"github.com/gorilla/securecookie\"\n\t\"github.com/gorilla/sessions\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/plugin/hook\"\n)\n\ntype VisitedPluginHook struct {\n\tPluginID string\n\tHookType hook.TriggerEnum\n}\n\nfunc init() {\n\tgob.Register([]VisitedPluginHook{})\n}\n\nfunc (s *Store) VisitedPluginHandler() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t// get the visited plugins from the cookie and set in the context\n\t\t\tsession, err := s.sessionStore.Get(r, cookieName)\n\n\t\t\t// ignore errors\n\t\t\tif err == nil {\n\t\t\t\tval := session.Values[visitedPluginHooksKey]\n\n\t\t\t\tvisitedPlugins, _ := val.([]VisitedPluginHook)\n\n\t\t\t\tctx := setVisitedPluginHooks(r.Context(), visitedPlugins)\n\t\t\t\tr = r.WithContext(ctx)\n\t\t\t}\n\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n\nfunc GetVisitedPluginHooks(ctx context.Context) []VisitedPluginHook {\n\tctxVal := ctx.Value(contextVisitedPlugins)\n\tif ctxVal != nil {\n\t\treturn ctxVal.([]VisitedPluginHook)\n\t}\n\n\treturn nil\n}\n\nfunc AddVisitedPluginHook(ctx context.Context, pluginID string, hookType hook.TriggerEnum) context.Context {\n\tcurVal := GetVisitedPluginHooks(ctx)\n\tcurVal = append(curVal, VisitedPluginHook{PluginID: pluginID, HookType: hookType})\n\treturn setVisitedPluginHooks(ctx, curVal)\n}\n\nfunc setVisitedPluginHooks(ctx context.Context, visitedPlugins []VisitedPluginHook) context.Context {\n\treturn context.WithValue(ctx, contextVisitedPlugins, visitedPlugins)\n}\n\nfunc (s *Store) MakePluginCookie(ctx context.Context) *http.Cookie {\n\tcurrentUser := GetCurrentUserID(ctx)\n\tvisitedPlugins := GetVisitedPluginHooks(ctx)\n\n\tsession := sessions.NewSession(s.sessionStore, cookieName)\n\tif currentUser != nil {\n\t\tsession.Values[userIDKey] = *currentUser\n\t}\n\n\tsession.Values[visitedPluginHooksKey] = visitedPlugins\n\n\tencoded, err := securecookie.EncodeMulti(session.Name(), session.Values,\n\t\ts.sessionStore.Codecs...)\n\tif err != nil {\n\t\tlogger.Errorf(\"error creating session cookie: %s\", err.Error())\n\t\treturn nil\n\t}\n\n\treturn sessions.NewCookie(session.Name(), encoded, session.Options)\n}\n"
  },
  {
    "path": "pkg/session/session.go",
    "content": "// Package session provides session authentication and management for the application.\npackage session\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\n\t\"github.com/gorilla/sessions\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\ntype key int\n\nconst (\n\tcontextUser key = iota\n\tcontextVisitedPlugins\n\tcontextLocalRequest\n)\n\nconst (\n\tuserIDKey             = \"userID\"\n\tvisitedPluginHooksKey = \"visitedPluginsHooks\"\n)\n\nconst (\n\tApiKeyHeader    = \"ApiKey\"\n\tApiKeyParameter = \"apikey\"\n)\n\nconst (\n\tcookieName      = \"session\"\n\tusernameFormKey = \"username\"\n\tpasswordFormKey = \"password\"\n)\n\ntype InvalidCredentialsError struct {\n\tUsername string\n}\n\nfunc (e InvalidCredentialsError) Error() string {\n\t// don't leak the username\n\treturn \"invalid credentials\"\n}\n\nvar ErrUnauthorized = errors.New(\"unauthorized\")\n\ntype Store struct {\n\tsessionStore *sessions.CookieStore\n\tconfig       SessionConfig\n}\n\nfunc NewStore(c SessionConfig) *Store {\n\tret := &Store{\n\t\tsessionStore: sessions.NewCookieStore(c.GetSessionStoreKey()),\n\t\tconfig:       c,\n\t}\n\n\tret.sessionStore.MaxAge(c.GetMaxSessionAge())\n\tret.sessionStore.Options.SameSite = http.SameSiteLaxMode\n\n\treturn ret\n}\n\nfunc (s *Store) Login(w http.ResponseWriter, r *http.Request) error {\n\t// ignore error - we want a new session regardless\n\tnewSession, _ := s.sessionStore.Get(r, cookieName)\n\n\tusername := r.FormValue(usernameFormKey)\n\tpassword := r.FormValue(passwordFormKey)\n\n\t// authenticate the user\n\tif !s.config.ValidateCredentials(username, password) {\n\t\treturn &InvalidCredentialsError{Username: username}\n\t}\n\n\t// since we only have one user, don't leak the name\n\tlogger.Info(\"User logged in\")\n\n\tnewSession.Values[userIDKey] = username\n\n\terr := newSession.Save(r, w)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (s *Store) Logout(w http.ResponseWriter, r *http.Request) error {\n\tsession, err := s.sessionStore.Get(r, cookieName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdelete(session.Values, userIDKey)\n\tsession.Options.MaxAge = -1\n\n\terr = session.Save(r, w)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// since we only have one user, don't leak the name\n\tlogger.Infof(\"User logged out\")\n\n\treturn nil\n}\n\nfunc (s *Store) GetSessionUserID(w http.ResponseWriter, r *http.Request) (string, error) {\n\tsession, err := s.sessionStore.Get(r, cookieName)\n\t// ignore errors and treat as an empty user id, so that we handle expired\n\t// cookie\n\tif err != nil {\n\t\treturn \"\", nil\n\t}\n\n\tif !session.IsNew {\n\t\tval := session.Values[userIDKey]\n\n\t\t// refresh the cookie\n\t\terr = session.Save(r, w)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tret, _ := val.(string)\n\n\t\treturn ret, nil\n\t}\n\n\treturn \"\", nil\n}\n\nfunc SetCurrentUserID(ctx context.Context, userID string) context.Context {\n\treturn context.WithValue(ctx, contextUser, userID)\n}\n\n// GetCurrentUserID gets the current user id from the provided context\nfunc GetCurrentUserID(ctx context.Context) *string {\n\tuserCtxVal := ctx.Value(contextUser)\n\tif userCtxVal != nil {\n\t\tcurrentUser := userCtxVal.(string)\n\t\treturn &currentUser\n\t}\n\n\treturn nil\n}\n\nfunc (s *Store) Authenticate(w http.ResponseWriter, r *http.Request) (userID string, err error) {\n\tc := s.config\n\n\t// translate api key into current user, if present\n\tapiKey := r.Header.Get(ApiKeyHeader)\n\n\t// try getting the api key as a query parameter\n\tif apiKey == \"\" {\n\t\tapiKey = r.URL.Query().Get(ApiKeyParameter)\n\t}\n\n\tif apiKey != \"\" {\n\t\t// match against configured API and set userID to the\n\t\t// configured username. In future, we'll want to\n\t\t// get the username from the key.\n\t\tif c.GetAPIKey() != apiKey {\n\t\t\treturn \"\", ErrUnauthorized\n\t\t}\n\n\t\tuserID = c.GetUsername()\n\t} else {\n\t\t// handle session\n\t\tuserID, err = s.GetSessionUserID(w, r)\n\t}\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn\n}\n"
  },
  {
    "path": "pkg/sliceutil/collections.go",
    "content": "// Package sliceutil provides utilities for working with slices.\npackage sliceutil\n\nimport (\n\t\"slices\"\n)\n\n// AppendUnique appends toAdd to the vs slice if toAdd does not already\n// exist in the slice. It returns the new or unchanged slice.\nfunc AppendUnique[T comparable](vs []T, toAdd T) []T {\n\tif slices.Contains(vs, toAdd) {\n\t\treturn vs\n\t}\n\n\treturn append(vs, toAdd)\n}\n\n// AppendUniques appends a slice of values to the vs slice. It only\n// appends values that do not already exist in the slice.\n// It returns the new or unchanged slice.\nfunc AppendUniques[T comparable](vs []T, toAdd []T) []T {\n\tif len(toAdd) == 0 {\n\t\treturn vs\n\t}\n\n\t// Extend the slice's capacity to avoid multiple re-allocations even in the worst case\n\tvs = slices.Grow(vs, len(toAdd))\n\n\tfor _, v := range toAdd {\n\t\tvs = AppendUnique(vs, v)\n\t}\n\n\treturn vs\n}\n\n// Exclude returns a copy of the vs slice, excluding all values\n// that are also present in the toExclude slice.\nfunc Exclude[T comparable](vs []T, toExclude []T) []T {\n\tret := make([]T, 0, len(vs))\n\tfor _, v := range vs {\n\t\tif !slices.Contains(toExclude, v) {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// Unique returns a copy of the vs slice, with non-unique values removed.\nfunc Unique[T comparable](vs []T) []T {\n\tdistinctValues := make(map[T]struct{}, len(vs))\n\tret := make([]T, 0, len(vs))\n\tfor _, v := range vs {\n\t\tif _, exists := distinctValues[v]; !exists {\n\t\t\tdistinctValues[v] = struct{}{}\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\treturn ret\n}\n\n// Delete returns a copy of the vs slice with toDel values removed.\nfunc Delete[T comparable](vs []T, toDel T) []T {\n\tret := make([]T, 0, len(vs))\n\tfor _, v := range vs {\n\t\tif v != toDel {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\treturn ret\n}\n\n// Intersect returns a slice containing values that exist in both provided slices.\nfunc Intersect[T comparable](a []T, b []T) []T {\n\tvar ret []T\n\tfor _, v := range a {\n\t\tif slices.Contains(b, v) {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// NotIntersect returns a slice containing values that do not exist in both provided slices.\nfunc NotIntersect[T comparable](a []T, b []T) []T {\n\tvar ret []T\n\tfor _, v := range a {\n\t\tif !slices.Contains(b, v) {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\n\tfor _, v := range b {\n\t\tif !slices.Contains(a, v) {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// SliceSame returns true if the two provided slices have equal elements,\n// regardless of order.\nfunc SliceSame[T comparable](a []T, b []T) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\n\tvisited := make(map[int]struct{})\n\tfor i := range a {\n\t\tfound := false\n\t\tfor j := range b {\n\t\t\tif _, exists := visited[j]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif a[i] == b[j] {\n\t\t\t\tfound = true\n\t\t\t\tvisited[j] = struct{}{}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\n// Filter returns a slice containing the elements of the vs slice\n// that meet the condition specified by f.\nfunc Filter[T any](vs []T, f func(T) bool) []T {\n\tvar ret []T\n\tfor _, v := range vs {\n\t\tif f(v) {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\treturn ret\n}\n\n// Map returns the result of applying f to each element of the vs slice.\nfunc Map[T any, V any](vs []T, f func(T) V) []V {\n\tret := make([]V, len(vs))\n\tfor i, v := range vs {\n\t\tret[i] = f(v)\n\t}\n\treturn ret\n}\n\nfunc PtrsToValues[T any](vs []*T) []T {\n\tret := make([]T, len(vs))\n\tfor i, v := range vs {\n\t\tret[i] = *v\n\t}\n\treturn ret\n}\n\nfunc ValuesToPtrs[T any](vs []T) []*T {\n\tret := make([]*T, len(vs))\n\tfor i, v := range vs {\n\t\t// We can do this safely because go.mod indicates Go 1.22\n\t\t// See: https://go.dev/blog/loopvar-preview\n\t\tret[i] = &v\n\t}\n\treturn ret\n}\n\n// Flatten returns a single slice containing all elements of the provided\n// slice of slices.\nfunc Flatten[T any](vs [][]T) []T {\n\tvar ret []T\n\tfor _, v := range vs {\n\t\tret = append(ret, v...)\n\t}\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/sliceutil/collections_test.go",
    "content": "package sliceutil\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestSliceSame(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\ta    []int\n\t\tb    []int\n\t\twant bool\n\t}{\n\t\t{\"nil values\", nil, nil, true},\n\t\t{\"empty\", []int{}, []int{}, true},\n\t\t{\"nil and empty\", nil, []int{}, true},\n\t\t{\n\t\t\t\"different length\",\n\t\t\t[]int{1, 2, 3},\n\t\t\t[]int{1, 2},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equal\",\n\t\t\t[]int{1, 2, 3, 4, 5},\n\t\t\t[]int{1, 2, 3, 4, 5},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"different order\",\n\t\t\t[]int{5, 4, 3, 2, 1},\n\t\t\t[]int{1, 2, 3, 4, 5},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"different\",\n\t\t\t[]int{5, 4, 3, 2, 6},\n\t\t\t[]int{1, 2, 3, 4, 5},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"same with duplicates\",\n\t\t\t[]int{1, 1, 2, 3, 4},\n\t\t\t[]int{1, 2, 3, 4, 1},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"subset\",\n\t\t\t[]int{1, 1, 2, 2, 3},\n\t\t\t[]int{1, 2, 3, 4, 5},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"superset\",\n\t\t\t[]int{1, 2, 3, 4, 5},\n\t\t\t[]int{1, 1, 2, 2, 3},\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := SliceSame(tt.a, tt.b)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestAppendUniques(t *testing.T) {\n\ttype args struct {\n\t\tvs    []int\n\t\ttoAdd []int\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant []int\n\t}{\n\t\t{\n\t\t\tname: \"append to empty slice\",\n\t\t\targs: args{\n\t\t\t\tvs:    []int{},\n\t\t\t\ttoAdd: []int{1, 2, 3},\n\t\t\t},\n\t\t\twant: []int{1, 2, 3},\n\t\t},\n\t\t{\n\t\t\tname: \"append all unique values\",\n\t\t\targs: args{\n\t\t\t\tvs:    []int{1, 2, 3},\n\t\t\t\ttoAdd: []int{4, 5, 6},\n\t\t\t},\n\t\t\twant: []int{1, 2, 3, 4, 5, 6},\n\t\t},\n\t\t{\n\t\t\tname: \"append with some duplicates\",\n\t\t\targs: args{\n\t\t\t\tvs:    []int{1, 2, 3},\n\t\t\t\ttoAdd: []int{3, 4, 5},\n\t\t\t},\n\t\t\twant: []int{1, 2, 3, 4, 5},\n\t\t},\n\t\t{\n\t\t\tname: \"append all duplicates\",\n\t\t\targs: args{\n\t\t\t\tvs:    []int{1, 2, 3},\n\t\t\t\ttoAdd: []int{1, 2, 3},\n\t\t\t},\n\t\t\twant: []int{1, 2, 3},\n\t\t},\n\t\t{\n\t\t\tname: \"append to nil slice\",\n\t\t\targs: args{\n\t\t\t\tvs:    nil,\n\t\t\t\ttoAdd: []int{1, 2, 3},\n\t\t\t},\n\t\t\twant: []int{1, 2, 3},\n\t\t},\n\t\t{\n\t\t\tname: \"append empty slice\",\n\t\t\targs: args{\n\t\t\t\tvs:    []int{1, 2, 3},\n\t\t\t\ttoAdd: []int{},\n\t\t\t},\n\t\t\twant: []int{1, 2, 3},\n\t\t},\n\t\t{\n\t\t\tname: \"append nil to slice\",\n\t\t\targs: args{\n\t\t\t\tvs:    []int{1, 2, 3},\n\t\t\t\ttoAdd: nil,\n\t\t\t},\n\t\t\twant: []int{1, 2, 3},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := AppendUniques(tt.args.vs, tt.args.toAdd); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"AppendUniques() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc BenchmarkAppendUniques(b *testing.B) {\n\tfor i := 0; i < b.N; i++ {\n\t\tAppendUniques([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, []int{3, 4, 4, 11, 12, 13, 14, 15, 16, 17, 18})\n\t}\n}\n"
  },
  {
    "path": "pkg/sliceutil/intslice/int_collections.go",
    "content": "package intslice\n\nimport \"strconv\"\n\n// IntSliceToStringSlice converts a slice of ints to a slice of strings.\nfunc IntSliceToStringSlice(ss []int) []string {\n\tret := make([]string, len(ss))\n\tfor i, v := range ss {\n\t\tret[i] = strconv.Itoa(v)\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/sliceutil/stringslice/string_collections.go",
    "content": "package stringslice\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n)\n\n// StringSliceToIntSlice converts a slice of strings to a slice of ints.\n// Returns an error if any values cannot be parsed.\nfunc StringSliceToIntSlice(ss []string) ([]int, error) {\n\tret := make([]int, len(ss))\n\tfor i, v := range ss {\n\t\tvar err error\n\t\tret[i], err = strconv.Atoi(v)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// FromString converts a string to a slice of strings, splitting on the sep character.\n// Unlike strings.Split, this function will also trim whitespace from the resulting strings.\nfunc FromString(s string, sep string) []string {\n\tv := strings.Split(s, \",\")\n\tfor i, vv := range v {\n\t\tv[i] = strings.TrimSpace(vv)\n\t}\n\treturn v\n}\n\n// Unique returns a slice containing only unique values from the provided slice.\n// The comparison is case-insensitive.\nfunc UniqueFold(s []string) []string {\n\tseen := make(map[string]struct{}, len(s))\n\tret := make([]string, 0, len(s))\n\tfor _, v := range s {\n\t\tif _, exists := seen[strings.ToLower(v)]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[strings.ToLower(v)] = struct{}{}\n\t\tret = append(ret, v)\n\t}\n\treturn ret\n}\n\n// UniqueExcludeFold returns a deduplicated slice of strings with the excluded string removed.\n// The comparison is case-insensitive.\nfunc UniqueExcludeFold(values []string, exclude string) []string {\n\tseen := make(map[string]struct{}, len(values))\n\tseen[strings.ToLower(exclude)] = struct{}{}\n\tret := make([]string, 0, len(values))\n\tfor _, v := range values {\n\t\tvLower := strings.ToLower(v)\n\t\tif _, exists := seen[vLower]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[vLower] = struct{}{}\n\t\tret = append(ret, v)\n\t}\n\treturn ret\n}\n\n// TrimSpace trims whitespace from each string in a slice.\nfunc TrimSpace(s []string) []string {\n\tfor i, v := range s {\n\t\ts[i] = strings.TrimSpace(v)\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "pkg/sqlite/anonymise.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"unicode\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nconst (\n\tletters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\thex     = \"0123456789abcdef\"\n)\n\ntype Anonymiser struct {\n\t*Database\n}\n\nfunc NewAnonymiser(db *Database, outPath string) (*Anonymiser, error) {\n\tif _, err := db.writeDB.Exec(fmt.Sprintf(`VACUUM INTO \"%s\"`, outPath)); err != nil {\n\t\treturn nil, fmt.Errorf(\"vacuuming into %s: %w\", outPath, err)\n\t}\n\n\tnewDB := NewDatabase()\n\tif err := newDB.Open(outPath); err != nil {\n\t\treturn nil, fmt.Errorf(\"opening %s: %w\", outPath, err)\n\t}\n\n\treturn &Anonymiser{Database: newDB}, nil\n}\n\nfunc (db *Anonymiser) Anonymise(ctx context.Context) error {\n\tif err := func() error {\n\t\tdefer db.Close()\n\n\t\treturn utils.Do([]func() error{\n\t\t\tfunc() error { return db.deleteBlobs() },\n\t\t\tfunc() error { return db.deleteStashIDs() },\n\t\t\tfunc() error { return db.clearOHistory() },\n\t\t\tfunc() error { return db.clearWatchHistory() },\n\t\t\tfunc() error { return db.anonymiseFolders(ctx) },\n\t\t\tfunc() error { return db.anonymiseFiles(ctx) },\n\t\t\tfunc() error { return db.anonymiseCaptions(ctx) },\n\t\t\tfunc() error { return db.anonymiseFingerprints(ctx) },\n\t\t\tfunc() error { return db.anonymiseScenes(ctx) },\n\t\t\tfunc() error { return db.anonymiseMarkers(ctx) },\n\t\t\tfunc() error { return db.anonymiseImages(ctx) },\n\t\t\tfunc() error { return db.anonymiseGalleries(ctx) },\n\t\t\tfunc() error { return db.anonymisePerformers(ctx) },\n\t\t\tfunc() error { return db.anonymiseStudios(ctx) },\n\t\t\tfunc() error { return db.anonymiseTags(ctx) },\n\t\t\tfunc() error { return db.anonymiseGroups(ctx) },\n\t\t\tfunc() error { return db.anonymiseSavedFilters(ctx) },\n\t\t\tfunc() error { return db.Optimise(ctx) },\n\t\t})\n\t}(); err != nil {\n\t\t// delete the database\n\t\t_ = db.Remove()\n\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) truncateColumn(tableName string, column string) error {\n\t_, err := db.writeDB.Exec(\"UPDATE \" + tableName + \" SET \" + column + \" = NULL\")\n\treturn err\n}\n\nfunc (db *Anonymiser) truncateTable(tableName string) error {\n\t_, err := db.writeDB.Exec(\"DELETE FROM \" + tableName)\n\treturn err\n}\n\nfunc (db *Anonymiser) deleteBlobs() error {\n\treturn utils.Do([]func() error{\n\t\tfunc() error { return db.truncateColumn(tagTable, tagImageBlobColumn) },\n\t\tfunc() error { return db.truncateColumn(studioTable, studioImageBlobColumn) },\n\t\tfunc() error { return db.truncateColumn(performerTable, performerImageBlobColumn) },\n\t\tfunc() error { return db.truncateColumn(sceneTable, sceneCoverBlobColumn) },\n\t\tfunc() error { return db.truncateColumn(groupTable, groupFrontImageBlobColumn) },\n\t\tfunc() error { return db.truncateColumn(groupTable, groupBackImageBlobColumn) },\n\n\t\tfunc() error { return db.truncateTable(blobTable) },\n\t})\n}\n\nfunc (db *Anonymiser) deleteStashIDs() error {\n\treturn utils.Do([]func() error{\n\t\tfunc() error { return db.truncateTable(\"scene_stash_ids\") },\n\t\tfunc() error { return db.truncateTable(\"studio_stash_ids\") },\n\t\tfunc() error { return db.truncateTable(\"performer_stash_ids\") },\n\t\tfunc() error { return db.truncateTable(\"tag_stash_ids\") },\n\t})\n}\n\nfunc (db *Anonymiser) clearOHistory() error {\n\treturn utils.Do([]func() error{\n\t\tfunc() error { return db.truncateTable(scenesODatesTable) },\n\t})\n}\n\nfunc (db *Anonymiser) clearWatchHistory() error {\n\treturn utils.Do([]func() error{\n\t\tfunc() error { return db.truncateTable(scenesViewDatesTable) },\n\t})\n}\n\nfunc (db *Anonymiser) anonymiseFolders(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising folders\")\n\treturn txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\treturn db.anonymiseFoldersRecurse(ctx, 0, \"\")\n\t})\n}\n\nfunc (db *Anonymiser) anonymiseFoldersRecurse(ctx context.Context, parentFolderID int, parentPath string) error {\n\ttable := folderTableMgr.table\n\n\tstmt := dialect.Update(table)\n\n\tif parentFolderID == 0 {\n\t\tstmt = stmt.Set(goqu.Record{\"path\": goqu.Cast(table.Col(idColumn), \"VARCHAR\")}).Where(table.Col(\"parent_folder_id\").IsNull())\n\t} else {\n\t\tstmt = stmt.Prepared(true).Set(goqu.Record{\n\t\t\t\"path\": goqu.L(\"? || ? || id\", parentPath, string(filepath.Separator)),\n\t\t}).Where(table.Col(\"parent_folder_id\").Eq(parentFolderID))\n\t}\n\n\tif _, err := exec(ctx, stmt); err != nil {\n\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t}\n\n\t// now recurse to sub-folders\n\tquery := dialect.From(table).Select(table.Col(idColumn), table.Col(\"path\"))\n\tif parentFolderID == 0 {\n\t\tquery = query.Where(table.Col(\"parent_folder_id\").IsNull())\n\t} else {\n\t\tquery = query.Where(table.Col(\"parent_folder_id\").Eq(parentFolderID))\n\t}\n\n\tconst single = false\n\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\tvar id int\n\t\tvar path string\n\t\tif err := rows.Scan(&id, &path); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn db.anonymiseFoldersRecurse(ctx, id, path)\n\t})\n}\n\nfunc (db *Anonymiser) anonymiseFiles(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising files\")\n\treturn txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\ttable := fileTableMgr.table\n\t\tstmt := dialect.Update(table).Set(goqu.Record{\"basename\": goqu.Cast(table.Col(idColumn), \"VARCHAR\")})\n\n\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc (db *Anonymiser) anonymiseCaptions(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising captions\")\n\treturn txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\ttable := goqu.T(videoCaptionsTable)\n\t\tstmt := dialect.Update(table).Set(goqu.Record{\"filename\": goqu.Cast(table.Col(\"file_id\"), \"VARCHAR\")})\n\n\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc (db *Anonymiser) anonymiseFingerprints(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising fingerprints\")\n\ttable := fingerprintTableMgr.table\n\tlastID := 0\n\tlastType := \"\"\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(fileIDColumn),\n\t\t\t\ttable.Col(\"type\"),\n\t\t\t\ttable.Col(\"fingerprint\"),\n\t\t\t).Where(goqu.L(\"(file_id, type)\").Gt(goqu.L(\"(?, ?)\", lastID, lastType))).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid          int\n\t\t\t\t\ttyp         string\n\t\t\t\t\tfingerprint string\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&typ,\n\t\t\t\t\t&fingerprint,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := db.anonymiseFingerprint(ctx, table, \"fingerprint\", fingerprint); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tlastType = typ\n\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d fingerprints\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseScenes(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising scenes\")\n\ttable := sceneTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"title\"),\n\t\t\t\ttable.Col(\"details\"),\n\t\t\t\ttable.Col(\"code\"),\n\t\t\t\ttable.Col(\"director\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid       int\n\t\t\t\t\ttitle    sql.NullString\n\t\t\t\t\tdetails  sql.NullString\n\t\t\t\t\tcode     sql.NullString\n\t\t\t\t\tdirector sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&title,\n\t\t\t\t\t&details,\n\t\t\t\t\t&code,\n\t\t\t\t\t&director,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\n\t\t\t\t// if title set set new title\n\t\t\t\tdb.obfuscateNullString(set, \"title\", title)\n\t\t\t\tdb.obfuscateNullString(set, \"details\", details)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif code.Valid {\n\t\t\t\t\tif err := db.anonymiseText(ctx, table, \"code\", code.String); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif director.Valid {\n\t\t\t\t\tif err := db.anonymiseText(ctx, table, \"director\", director.String); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d scenes\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := db.anonymiseURLs(ctx, goqu.T(scenesURLsTable), \"scene_id\"); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.anonymiseCustomFields(ctx, goqu.T(scenesCustomFieldsTable.GetTable()), \"scene_id\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseMarkers(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising scene markers\")\n\ttable := sceneMarkerTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"title\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid    int\n\t\t\t\t\ttitle string\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&title,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := db.anonymiseText(ctx, table, \"title\", title); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d scene markers\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseImages(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising images\")\n\ttable := imageTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"title\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid    int\n\t\t\t\t\ttitle sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&title,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"title\", title)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d images\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := db.anonymiseURLs(ctx, goqu.T(imagesURLsTable), \"image_id\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseGalleries(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising galleries\")\n\ttable := galleryTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"title\"),\n\t\t\t\ttable.Col(\"details\"),\n\t\t\t\ttable.Col(\"photographer\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid           int\n\t\t\t\t\ttitle        sql.NullString\n\t\t\t\t\tdetails      sql.NullString\n\t\t\t\t\tphotographer sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&title,\n\t\t\t\t\t&details,\n\t\t\t\t\t&photographer,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"title\", title)\n\t\t\t\tdb.obfuscateNullString(set, \"details\", details)\n\t\t\t\tdb.obfuscateNullString(set, \"photographer\", photographer)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d galleries\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := db.anonymiseURLs(ctx, goqu.T(galleriesURLsTable), \"gallery_id\"); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.anonymiseCustomFields(ctx, goqu.T(galleriesCustomFieldsTable.GetTable()), \"gallery_id\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymisePerformers(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising performers\")\n\ttable := performerTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"name\"),\n\t\t\t\ttable.Col(\"disambiguation\"),\n\t\t\t\ttable.Col(\"details\"),\n\t\t\t\ttable.Col(\"tattoos\"),\n\t\t\t\ttable.Col(\"piercings\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid             int\n\t\t\t\t\tname           sql.NullString\n\t\t\t\t\tdisambiguation sql.NullString\n\t\t\t\t\tdetails        sql.NullString\n\t\t\t\t\ttattoos        sql.NullString\n\t\t\t\t\tpiercings      sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&name,\n\t\t\t\t\t&disambiguation,\n\t\t\t\t\t&details,\n\t\t\t\t\t&tattoos,\n\t\t\t\t\t&piercings,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"name\", name)\n\t\t\t\tdb.obfuscateNullString(set, \"disambiguation\", disambiguation)\n\t\t\t\tdb.obfuscateNullString(set, \"details\", details)\n\t\t\t\tdb.obfuscateNullString(set, \"tattoos\", tattoos)\n\t\t\t\tdb.obfuscateNullString(set, \"piercings\", piercings)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d performers\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := db.anonymiseAliases(ctx, goqu.T(performersAliasesTable), \"performer_id\"); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.anonymiseURLs(ctx, goqu.T(performerURLsTable), \"performer_id\"); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.anonymiseCustomFields(ctx, goqu.T(performersCustomFieldsTable.GetTable()), \"performer_id\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseStudios(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising studios\")\n\ttable := studioTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"name\"),\n\t\t\t\ttable.Col(\"details\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid      int\n\t\t\t\t\tname    sql.NullString\n\t\t\t\t\tdetails sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&name,\n\t\t\t\t\t&details,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"name\", name)\n\t\t\t\tdb.obfuscateNullString(set, \"details\", details)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\t// TODO - anonymise studio aliases\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d studios\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := db.anonymiseAliases(ctx, goqu.T(studioAliasesTable), \"studio_id\"); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.anonymiseURLs(ctx, goqu.T(studioURLsTable), \"studio_id\"); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.anonymiseCustomFields(ctx, goqu.T(studiosCustomFieldsTable.GetTable()), \"studio_id\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseAliases(ctx context.Context, table exp.IdentifierExpression, idColumn string) error {\n\tlastID := 0\n\tlastAlias := \"\"\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"alias\"),\n\t\t\t).Where(goqu.L(\"(\" + idColumn + \", alias)\").Gt(goqu.L(\"(?, ?)\", lastID, lastAlias))).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid    int\n\t\t\t\t\talias sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&alias,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"alias\", alias)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(\n\t\t\t\t\t\ttable.Col(idColumn).Eq(id),\n\t\t\t\t\t\ttable.Col(\"alias\").Eq(alias),\n\t\t\t\t\t)\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tlastAlias = alias.String\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d %s aliases\", total, table.GetTable())\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseURLs(ctx context.Context, table exp.IdentifierExpression, idColumn string) error {\n\tlastID := 0\n\tlastURL := \"\"\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"url\"),\n\t\t\t).Where(goqu.L(\"(\" + idColumn + \", url)\").Gt(goqu.L(\"(?, ?)\", lastID, lastURL))).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid  int\n\t\t\t\t\turl sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&url,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"url\", url)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(\n\t\t\t\t\t\ttable.Col(idColumn).Eq(id),\n\t\t\t\t\t\ttable.Col(\"url\").Eq(url),\n\t\t\t\t\t)\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tlastURL = url.String\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d %s URLs\", total, table.GetTable())\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseTags(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising tags\")\n\ttable := tagTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"name\"),\n\t\t\t\ttable.Col(\"sort_name\"),\n\t\t\t\ttable.Col(\"description\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid          int\n\t\t\t\t\tname        sql.NullString\n\t\t\t\t\tsortName    sql.NullString\n\t\t\t\t\tdescription sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&name,\n\t\t\t\t\t&sortName,\n\t\t\t\t\t&description,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"name\", name)\n\t\t\t\tdb.obfuscateNullString(set, \"sort_name\", sortName)\n\t\t\t\tdb.obfuscateNullString(set, \"description\", description)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d tags\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := db.anonymiseAliases(ctx, goqu.T(tagAliasesTable), \"tag_id\"); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.anonymiseCustomFields(ctx, goqu.T(tagsCustomFieldsTable.GetTable()), \"tag_id\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseGroups(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising groups\")\n\ttable := groupTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"name\"),\n\t\t\t\ttable.Col(\"aliases\"),\n\t\t\t\ttable.Col(\"description\"),\n\t\t\t\ttable.Col(\"director\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid          int\n\t\t\t\t\tname        sql.NullString\n\t\t\t\t\taliases     sql.NullString\n\t\t\t\t\tdescription sql.NullString\n\t\t\t\t\tdirector    sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&name,\n\t\t\t\t\t&aliases,\n\t\t\t\t\t&description,\n\t\t\t\t\t&director,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"name\", name)\n\t\t\t\tdb.obfuscateNullString(set, \"aliases\", aliases)\n\t\t\t\tdb.obfuscateNullString(set, \"description\", description)\n\t\t\t\tdb.obfuscateNullString(set, \"director\", director)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d groups\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := db.anonymiseURLs(ctx, goqu.T(groupURLsTable), \"group_id\"); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.anonymiseCustomFields(ctx, goqu.T(groupsCustomFieldsTable.GetTable()), \"group_id\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseSavedFilters(ctx context.Context) error {\n\tlogger.Infof(\"Anonymising saved filters\")\n\ttable := savedFilterTableMgr.table\n\tlastID := 0\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"name\"),\n\t\t\t).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid   int\n\t\t\t\t\tname sql.NullString\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&name,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tdb.obfuscateNullString(set, \"name\", name)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id))\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d saved filters\", total)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseText(ctx context.Context, table exp.IdentifierExpression, column string, value string) error {\n\tset := goqu.Record{}\n\tset[column] = db.obfuscateString(value, letters)\n\n\tstmt := dialect.Update(table).Set(set).Where(table.Col(column).Eq(value))\n\n\tif _, err := exec(ctx, stmt); err != nil {\n\t\treturn fmt.Errorf(\"anonymising %s: %w\", column, err)\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) anonymiseFingerprint(ctx context.Context, table exp.IdentifierExpression, column string, value string) error {\n\tset := goqu.Record{}\n\tset[column] = db.obfuscateString(value, hex)\n\n\tstmt := dialect.Update(table).Set(set).Where(table.Col(column).Eq(value))\n\n\tif _, err := exec(ctx, stmt); err != nil {\n\t\treturn fmt.Errorf(\"anonymising %s: %w\", column, err)\n\t}\n\n\treturn nil\n}\n\nfunc (db *Anonymiser) obfuscateNullString(out goqu.Record, column string, in sql.NullString) {\n\tif in.Valid {\n\t\tout[column] = db.obfuscateString(in.String, letters)\n\t}\n}\n\nfunc (db *Anonymiser) obfuscateString(in string, dict string) string {\n\tout := strings.Builder{}\n\tfor _, c := range in {\n\t\tif unicode.IsSpace(c) {\n\t\t\tout.WriteRune(c)\n\t\t} else {\n\t\t\tnum, err := rand.Int(rand.Reader, big.NewInt(int64(len(dict))))\n\t\t\tif err != nil {\n\t\t\t\tpanic(\"error generating random number\")\n\t\t\t}\n\n\t\t\tout.WriteByte(dict[num.Int64()])\n\t\t}\n\t}\n\n\treturn out.String()\n}\n\nfunc (db *Anonymiser) anonymiseCustomFields(ctx context.Context, table exp.IdentifierExpression, idColumn string) error {\n\tlastID := 0\n\tlastField := \"\"\n\ttotal := 0\n\tconst logEvery = 10000\n\n\tfor gotSome := true; gotSome; {\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tquery := dialect.From(table).Select(\n\t\t\t\ttable.Col(idColumn),\n\t\t\t\ttable.Col(\"field\"),\n\t\t\t\ttable.Col(\"value\"),\n\t\t\t).Where(\n\t\t\t\tgoqu.L(\"(\"+idColumn+\", field)\").Gt(goqu.L(\"(?, ?)\", lastID, lastField)),\n\t\t\t).Order(\n\t\t\t\ttable.Col(idColumn).Asc(), table.Col(\"field\").Asc(),\n\t\t\t).Limit(1000)\n\n\t\t\tgotSome = false\n\n\t\t\tconst single = false\n\t\t\treturn queryFunc(ctx, query, single, func(rows *sqlx.Rows) error {\n\t\t\t\tvar (\n\t\t\t\t\tid    int\n\t\t\t\t\tfield string\n\t\t\t\t\tvalue string\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(\n\t\t\t\t\t&id,\n\t\t\t\t\t&field,\n\t\t\t\t\t&value,\n\t\t\t\t); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tset := goqu.Record{}\n\t\t\t\tset[\"field\"] = db.obfuscateString(field, letters)\n\t\t\t\tset[\"value\"] = db.obfuscateString(value, letters)\n\n\t\t\t\tif len(set) > 0 {\n\t\t\t\t\tstmt := dialect.Update(table).Set(set).Where(\n\t\t\t\t\t\ttable.Col(idColumn).Eq(id),\n\t\t\t\t\t\ttable.Col(\"field\").Eq(field),\n\t\t\t\t\t)\n\n\t\t\t\t\tif _, err := exec(ctx, stmt); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"anonymising %s: %w\", table.GetTable(), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tlastField = field\n\t\t\t\tgotSome = true\n\t\t\t\ttotal++\n\n\t\t\t\tif total%logEvery == 0 {\n\t\t\t\t\tlogger.Infof(\"Anonymised %d %s custom fields\", total, table.GetTable())\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/anonymise_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\nfunc TestAnonymiser_Anonymise(t *testing.T) {\n\tf, err := os.CreateTemp(\"\", \"*.sqlite\")\n\tif err != nil {\n\t\tt.Errorf(\"Could not create temporary file: %v\", err)\n\t\treturn\n\t}\n\n\tf.Close()\n\tdefer os.Remove(f.Name())\n\n\t// use existing database\n\tanonymiser, err := sqlite.NewAnonymiser(db, f.Name())\n\tif err != nil {\n\t\tt.Errorf(\"Could not create anonymiser: %v\", err)\n\t\treturn\n\t}\n\n\tif err := anonymiser.Anonymise(context.Background()); err != nil {\n\t\tt.Errorf(\"Could not anonymise: %v\", err)\n\t\treturn\n\t}\n\n\tt.Logf(\"Anonymised database written to %s\", f.Name())\n\n\t// TODO - ensure anonymous\n}\n"
  },
  {
    "path": "pkg/sqlite/batch.go",
    "content": "package sqlite\n\nconst defaultBatchSize = 1000\n\n// batchExec executes the provided function in batches of the provided size.\nfunc batchExec[T any](ids []T, batchSize int, fn func(batch []T) error) error {\n\tfor i := 0; i < len(ids); i += batchSize {\n\t\tend := i + batchSize\n\t\tif end > len(ids) {\n\t\t\tend = len(ids)\n\t\t}\n\n\t\tbatch := ids[i:end]\n\t\tif err := fn(batch); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/blob/fs.go",
    "content": "package blob\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst (\n\tblobsDirDepth  int = 2\n\tblobsDirLength int = 2 // thumbDirDepth * thumbDirLength must be smaller than the length of checksum\n)\n\ntype FSReader interface {\n\tOpen(name string) (fs.ReadDirFile, error)\n}\n\ntype FSWriter interface {\n\tCreate(name string) (*os.File, error)\n\tMkdirAll(path string, perm fs.FileMode) error\n\n\tRemove(name string) error\n\n\tfile.RenamerRemover\n}\n\ntype FS interface {\n\tFSReader\n\tFSWriter\n}\n\ntype FilesystemReader struct {\n\tpath string\n\tfs   FSReader\n}\n\nfunc (s *FilesystemReader) checksumToPath(checksum string) string {\n\treturn filepath.Join(s.path, fsutil.GetIntraDir(checksum, blobsDirDepth, blobsDirLength), checksum)\n}\n\nfunc (s *FilesystemReader) Read(ctx context.Context, checksum string) ([]byte, error) {\n\tif s.path == \"\" {\n\t\treturn nil, fmt.Errorf(\"no path set\")\n\t}\n\n\tfn := s.checksumToPath(checksum)\n\tf, err := s.fs.Open(fn)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening file %q: %w\", fn, err)\n\t}\n\n\tdefer f.Close()\n\n\treturn io.ReadAll(f)\n}\n\ntype FilesystemStore struct {\n\tFilesystemReader\n\tdeleter *file.Deleter\n}\n\nfunc NewFilesystemStore(path string, fs FS) *FilesystemStore {\n\tdeleter := &file.Deleter{\n\t\tRenamerRemover: fs,\n\t}\n\n\treturn &FilesystemStore{\n\t\tFilesystemReader: *NewReadonlyFilesystemStore(path, fs),\n\t\tdeleter:          deleter,\n\t}\n}\n\nfunc NewReadonlyFilesystemStore(path string, fs FSReader) *FilesystemReader {\n\treturn &FilesystemReader{\n\t\tpath: path,\n\t\tfs:   fs,\n\t}\n}\n\nfunc (s *FilesystemStore) Write(ctx context.Context, checksum string, data []byte) error {\n\tfs, ok := s.fs.(FS)\n\tif !ok {\n\t\treturn fmt.Errorf(\"internal error: fs is not an FS\")\n\t}\n\n\tif s.path == \"\" {\n\t\treturn fmt.Errorf(\"no path set\")\n\t}\n\n\tfn := s.checksumToPath(checksum)\n\n\t// create the directory if it doesn't exist\n\tif err := fs.MkdirAll(filepath.Dir(fn), 0755); err != nil {\n\t\treturn fmt.Errorf(\"creating directory %q: %w\", filepath.Dir(fn), err)\n\t}\n\n\tlogger.Debugf(\"Writing blob file %s\", fn)\n\tout, err := fs.Create(fn)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating file %q: %w\", fn, err)\n\t}\n\n\tr := bytes.NewReader(data)\n\n\tif _, err = io.Copy(out, r); err != nil {\n\t\treturn fmt.Errorf(\"writing file %q: %w\", fn, err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *FilesystemStore) Delete(ctx context.Context, checksum string) error {\n\tif s.path == \"\" {\n\t\treturn fmt.Errorf(\"no path set\")\n\t}\n\n\ts.deleter.RegisterHooks(ctx)\n\n\tfn := s.checksumToPath(checksum)\n\n\tif err := s.deleter.Files([]string{fn}); err != nil {\n\t\treturn fmt.Errorf(\"deleting file %q: %w\", fn, err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/blob.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/mattn/go-sqlite3\"\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/hash/md5\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite/blob\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"gopkg.in/guregu/null.v4\"\n)\n\nconst (\n\tblobTable          = \"blobs\"\n\tblobChecksumColumn = \"checksum\"\n)\n\ntype BlobStoreOptions struct {\n\t// UseFilesystem should be true if blob data should be stored in the filesystem\n\tUseFilesystem bool\n\t// UseDatabase should be true if blob data should be stored in the database\n\tUseDatabase bool\n\t// Path is the filesystem path to use for storing blobs\n\tPath string\n\t// SupplementaryPaths are alternative filesystem paths that will be used to find blobs\n\t// No changes will be made to these filesystems\n\tSupplementaryPaths []string\n}\n\ntype BlobStore struct {\n\trepository\n\n\ttableMgr *table\n\n\tfsStore *blob.FilesystemStore\n\t// supplementary stores\n\totherStores []blob.FilesystemReader\n\toptions     BlobStoreOptions\n}\n\nfunc NewBlobStore(options BlobStoreOptions) *BlobStore {\n\tfs := &file.OsFS{}\n\n\tret := &BlobStore{\n\t\trepository: repository{\n\t\t\ttableName: blobTable,\n\t\t\tidColumn:  blobChecksumColumn,\n\t\t},\n\n\t\ttableMgr: blobTableMgr,\n\n\t\tfsStore: blob.NewFilesystemStore(options.Path, fs),\n\t\toptions: options,\n\t}\n\n\tfor _, otherPath := range options.SupplementaryPaths {\n\t\tret.otherStores = append(ret.otherStores, *blob.NewReadonlyFilesystemStore(otherPath, fs))\n\t}\n\n\treturn ret\n}\n\ntype blobRow struct {\n\tChecksum string `db:\"checksum\"`\n\tBlob     []byte `db:\"blob\"`\n}\n\nfunc (qb *BlobStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *BlobStore) Count(ctx context.Context) (int, error) {\n\ttable := qb.table()\n\tq := dialect.From(table).Select(goqu.COUNT(table.Col(blobChecksumColumn)))\n\n\tvar ret int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\n// Write stores the data and its checksum in enabled stores.\n// Always writes at least the checksum to the database.\nfunc (qb *BlobStore) Write(ctx context.Context, data []byte) (string, error) {\n\tif !qb.options.UseDatabase && !qb.options.UseFilesystem {\n\t\tpanic(\"no blob store configured\")\n\t}\n\n\tif len(data) == 0 {\n\t\treturn \"\", fmt.Errorf(\"cannot write empty data\")\n\t}\n\n\tchecksum := md5.FromBytes(data)\n\n\t// only write blob to the database if UseDatabase is true\n\t// always at least write the checksum\n\tvar storedData []byte\n\tif qb.options.UseDatabase {\n\t\tstoredData = data\n\t}\n\n\tif err := qb.write(ctx, checksum, storedData); err != nil {\n\t\treturn \"\", fmt.Errorf(\"writing to database: %w\", err)\n\t}\n\n\tif qb.options.UseFilesystem {\n\t\tif err := qb.fsStore.Write(ctx, checksum, data); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"writing to filesystem: %w\", err)\n\t\t}\n\t}\n\n\treturn checksum, nil\n}\n\nfunc (qb *BlobStore) write(ctx context.Context, checksum string, data []byte) error {\n\ttable := qb.table()\n\tq := dialect.Insert(table).Prepared(true).Rows(blobRow{\n\t\tChecksum: checksum,\n\t\tBlob:     data,\n\t}).OnConflict(goqu.DoNothing())\n\n\t_, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"inserting into %s: %w\", table, err)\n\t}\n\n\treturn nil\n}\n\nfunc (qb *BlobStore) update(ctx context.Context, checksum string, data []byte) error {\n\ttable := qb.table()\n\tq := dialect.Update(table).Prepared(true).Set(goqu.Record{\n\t\t\"blob\": data,\n\t}).Where(goqu.C(blobChecksumColumn).Eq(checksum))\n\n\t_, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"updating %s: %w\", table, err)\n\t}\n\n\treturn nil\n}\n\ntype ChecksumNotFoundError struct {\n\tChecksum string\n}\n\nfunc (e *ChecksumNotFoundError) Error() string {\n\treturn fmt.Sprintf(\"checksum %s does not exist\", e.Checksum)\n}\n\ntype ChecksumBlobNotExistError struct {\n\tChecksum string\n}\n\nfunc (e *ChecksumBlobNotExistError) Error() string {\n\treturn fmt.Sprintf(\"blob for checksum %s does not exist\", e.Checksum)\n}\n\nfunc (qb *BlobStore) readSQL(ctx context.Context, querySQL string, args ...interface{}) ([]byte, string, error) {\n\tif !qb.options.UseDatabase && !qb.options.UseFilesystem {\n\t\tpanic(\"no blob store configured\")\n\t}\n\n\t// always try to get from the database first, even if set to use filesystem\n\tvar row blobRow\n\tfound := false\n\tconst single = true\n\tif err := qb.queryFunc(ctx, querySQL, args, single, func(r *sqlx.Rows) error {\n\t\tfound = true\n\t\tif err := r.StructScan(&row); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"reading from database: %w\", err)\n\t}\n\n\tif !found {\n\t\t// not found in the database - does not exist\n\t\treturn nil, \"\", nil\n\t}\n\n\tchecksum := row.Checksum\n\n\tif row.Blob != nil {\n\t\treturn row.Blob, checksum, nil\n\t}\n\n\t// don't use the filesystem if not configured to do so\n\tif qb.options.UseFilesystem {\n\t\tret, err := qb.readFromFilesystem(ctx, checksum)\n\t\tif err != nil {\n\t\t\treturn nil, checksum, err\n\t\t}\n\n\t\treturn ret, checksum, nil\n\t}\n\n\treturn nil, checksum, &ChecksumBlobNotExistError{\n\t\tChecksum: checksum,\n\t}\n}\n\nfunc (qb *BlobStore) readFromFilesystem(ctx context.Context, checksum string) ([]byte, error) {\n\t// try to read from primary store first, then supplementaries\n\tfsStores := append([]blob.FilesystemReader{qb.fsStore.FilesystemReader}, qb.otherStores...)\n\n\tfor _, fsStore := range fsStores {\n\t\tret, err := fsStore.Read(ctx, checksum)\n\t\tif err == nil {\n\t\t\treturn ret, nil\n\t\t}\n\n\t\tif !errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn nil, fmt.Errorf(\"reading from filesystem: %w\", err)\n\t\t}\n\t}\n\n\t// blob not found - should not happen\n\treturn nil, &ChecksumBlobNotExistError{\n\t\tChecksum: checksum,\n\t}\n}\n\nfunc (qb *BlobStore) EntryExists(ctx context.Context, checksum string) (bool, error) {\n\tq := dialect.From(qb.table()).Select(goqu.COUNT(\"*\")).Where(qb.tableMgr.byID(checksum))\n\n\tvar found int\n\tif err := querySimple(ctx, q, &found); err != nil {\n\t\treturn false, fmt.Errorf(\"querying %s: %w\", qb.table(), err)\n\t}\n\n\treturn found != 0, nil\n}\n\n// Read reads the data from the database or filesystem, depending on which is enabled.\nfunc (qb *BlobStore) Read(ctx context.Context, checksum string) ([]byte, error) {\n\tif !qb.options.UseDatabase && !qb.options.UseFilesystem {\n\t\tpanic(\"no blob store configured\")\n\t}\n\n\t// always try to get from the database first, even if set to use filesystem\n\tret, err := qb.readFromDatabase(ctx, checksum)\n\tif err != nil {\n\t\tif !errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, fmt.Errorf(\"reading from database: %w\", err)\n\t\t}\n\n\t\t// not found in the database - does not exist\n\t\treturn nil, &ChecksumNotFoundError{\n\t\t\tChecksum: checksum,\n\t\t}\n\t}\n\n\tif ret != nil {\n\t\treturn ret, nil\n\t}\n\n\t// don't use the filesystem if not configured to do so\n\tif qb.options.UseFilesystem {\n\t\treturn qb.readFromFilesystem(ctx, checksum)\n\t}\n\n\t// blob not found - should not happen\n\treturn nil, &ChecksumBlobNotExistError{\n\t\tChecksum: checksum,\n\t}\n}\n\nfunc (qb *BlobStore) readFromDatabase(ctx context.Context, checksum string) ([]byte, error) {\n\tq := dialect.From(qb.table()).Select(qb.table().All()).Where(qb.tableMgr.byID(checksum))\n\n\tvar row blobRow\n\tconst single = true\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tif err := r.StructScan(&row); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"querying %s: %w\", qb.table(), err)\n\t}\n\n\treturn row.Blob, nil\n}\n\n// Delete marks a checksum as no longer in use by a single reference.\n// If no references remain, the blob is deleted from the database and filesystem.\nfunc (qb *BlobStore) Delete(ctx context.Context, checksum string) error {\n\t// try to delete the blob from the database\n\tif err := qb.delete(ctx, checksum); err != nil {\n\t\tif qb.isConstraintError(err) {\n\t\t\t// blob is still referenced - do not delete\n\t\t\tlogger.Debugf(\"Blob %s is still referenced - not deleting\", checksum)\n\t\t\treturn nil\n\t\t}\n\n\t\t// unexpected error\n\t\treturn fmt.Errorf(\"deleting from database: %w\", err)\n\t}\n\n\t// blob was deleted from the database - delete from filesystem if enabled\n\tif qb.options.UseFilesystem {\n\t\tlogger.Debugf(\"Deleting blob %s from filesystem\", checksum)\n\t\tif err := qb.fsStore.Delete(ctx, checksum); err != nil {\n\t\t\treturn fmt.Errorf(\"deleting from filesystem: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *BlobStore) isConstraintError(err error) bool {\n\tvar sqliteError sqlite3.Error\n\tif errors.As(err, &sqliteError) {\n\t\treturn sqliteError.Code == sqlite3.ErrConstraint\n\t}\n\treturn false\n}\n\nfunc (qb *BlobStore) delete(ctx context.Context, checksum string) error {\n\ttable := qb.table()\n\n\tq := dialect.Delete(table).Where(goqu.C(blobChecksumColumn).Eq(checksum))\n\n\t_, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"deleting from %s: %w\", table, err)\n\t}\n\n\treturn nil\n}\n\ntype blobJoinQueryBuilder struct {\n\trepository repository\n\tblobStore  *BlobStore\n\n\tjoinTable string\n}\n\nfunc (qb *blobJoinQueryBuilder) GetImage(ctx context.Context, id int, blobCol string) ([]byte, error) {\n\tsqlQuery := utils.StrFormat(`\nSELECT blobs.checksum, blobs.blob FROM {joinTable} INNER JOIN blobs ON {joinTable}.{joinCol} = blobs.checksum\nWHERE {joinTable}.id = ?\n`, utils.StrFormatMap{\n\t\t\"joinTable\": qb.joinTable,\n\t\t\"joinCol\":   blobCol,\n\t})\n\n\tret, _, err := qb.blobStore.readSQL(ctx, sqlQuery, id)\n\treturn ret, err\n}\n\nfunc (qb *blobJoinQueryBuilder) UpdateImage(ctx context.Context, id int, blobCol string, image []byte) error {\n\tif len(image) == 0 {\n\t\treturn qb.DestroyImage(ctx, id, blobCol)\n\t}\n\n\toldChecksum, err := qb.getChecksum(ctx, id, blobCol)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tchecksum, err := qb.blobStore.Write(ctx, image)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsqlQuery := fmt.Sprintf(\"UPDATE %s SET %s = ? WHERE id = ?\", qb.joinTable, blobCol)\n\tif _, err := dbWrapper.Exec(ctx, sqlQuery, checksum, id); err != nil {\n\t\treturn err\n\t}\n\n\t// #3595 - delete the old blob if the checksum is different\n\tif oldChecksum != nil && *oldChecksum != checksum {\n\t\tif err := qb.blobStore.Delete(ctx, *oldChecksum); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *blobJoinQueryBuilder) getChecksum(ctx context.Context, id int, blobCol string) (*string, error) {\n\tsqlQuery := utils.StrFormat(`\nSELECT {joinTable}.{joinCol} FROM {joinTable} WHERE {joinTable}.id = ?\n`, utils.StrFormatMap{\n\t\t\"joinTable\": qb.joinTable,\n\t\t\"joinCol\":   blobCol,\n\t})\n\n\tvar checksum null.String\n\terr := qb.repository.querySimple(ctx, sqlQuery, []interface{}{id}, &checksum)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !checksum.Valid {\n\t\treturn nil, nil\n\t}\n\n\treturn &checksum.String, nil\n}\n\nfunc (qb *blobJoinQueryBuilder) DestroyImage(ctx context.Context, id int, blobCol string) error {\n\tchecksum, err := qb.getChecksum(ctx, id, blobCol)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif checksum == nil {\n\t\t// no image to delete\n\t\treturn nil\n\t}\n\n\tupdateQuery := fmt.Sprintf(\"UPDATE %s SET %s = NULL WHERE id = ?\", qb.joinTable, blobCol)\n\tif _, err = dbWrapper.Exec(ctx, updateQuery, id); err != nil {\n\t\treturn err\n\t}\n\n\treturn qb.blobStore.Delete(ctx, *checksum)\n}\n\nfunc (qb *blobJoinQueryBuilder) HasImage(ctx context.Context, id int, blobCol string) (bool, error) {\n\tstmt := utils.StrFormat(\"SELECT COUNT(*) as count FROM (SELECT {joinCol} FROM {joinTable} WHERE id = ? AND {joinCol} IS NOT NULL LIMIT 1)\", utils.StrFormatMap{\n\t\t\"joinTable\": qb.joinTable,\n\t\t\"joinCol\":   blobCol,\n\t})\n\n\tc, err := qb.repository.runCountQuery(ctx, stmt, []interface{}{id})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\treturn c == 1, nil\n}\n"
  },
  {
    "path": "pkg/sqlite/blob_migrate.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/jmoiron/sqlx\"\n)\n\nfunc (qb *BlobStore) FindBlobs(ctx context.Context, n uint, lastChecksum string) ([]string, error) {\n\ttable := qb.table()\n\tq := dialect.From(table).Select(table.Col(blobChecksumColumn)).Order(table.Col(blobChecksumColumn).Asc()).Limit(n)\n\n\tif lastChecksum != \"\" {\n\t\tq = q.Where(table.Col(blobChecksumColumn).Gt(lastChecksum))\n\t}\n\n\tconst single = false\n\tvar checksums []string\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar checksum string\n\t\tif err := rows.Scan(&checksum); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tchecksums = append(checksums, checksum)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn checksums, nil\n}\n\n// MigrateBlob migrates a blob from the filesystem to the database, or vice versa.\n// The target is determined by the UseDatabase and UseFilesystem options.\n// If deleteOld is true, the blob is deleted from the source after migration.\nfunc (qb *BlobStore) MigrateBlob(ctx context.Context, checksum string, deleteOld bool) error {\n\tif !qb.options.UseDatabase && !qb.options.UseFilesystem {\n\t\tpanic(\"no blob store configured\")\n\t}\n\n\tif qb.options.UseDatabase && qb.options.UseFilesystem {\n\t\tpanic(\"both filesystem and database configured\")\n\t}\n\n\tif qb.options.Path == \"\" {\n\t\tpanic(\"no blob path configured\")\n\t}\n\n\tif qb.options.UseDatabase {\n\t\treturn qb.migrateBlobDatabase(ctx, checksum, deleteOld)\n\t}\n\n\treturn qb.migrateBlobFilesystem(ctx, checksum, deleteOld)\n}\n\n// migrateBlobDatabase migrates a blob from the filesystem to the database\nfunc (qb *BlobStore) migrateBlobDatabase(ctx context.Context, checksum string, deleteOld bool) error {\n\t// ignore if the blob is already present in the database\n\t// (still delete the old data if requested)\n\texisting, err := qb.readFromDatabase(ctx, checksum)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading from database: %w\", err)\n\t}\n\n\tif len(existing) == 0 {\n\t\t// find the blob in the filesystem\n\t\tblob, err := qb.fsStore.Read(ctx, checksum)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"reading from filesystem: %w\", err)\n\t\t}\n\n\t\t// write the blob to the database\n\t\tif err := qb.update(ctx, checksum, blob); err != nil {\n\t\t\treturn fmt.Errorf(\"writing to database: %w\", err)\n\t\t}\n\t}\n\n\tif deleteOld {\n\t\t// delete the blob from the filesystem after commit\n\t\tif err := qb.fsStore.Delete(ctx, checksum); err != nil {\n\t\t\treturn fmt.Errorf(\"deleting from filesystem: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// migrateBlobFilesystem migrates a blob from the database to the filesystem\nfunc (qb *BlobStore) migrateBlobFilesystem(ctx context.Context, checksum string, deleteOld bool) error {\n\t// find the blob in the database\n\tblob, err := qb.readFromDatabase(ctx, checksum)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading from database: %w\", err)\n\t}\n\n\tif len(blob) == 0 {\n\t\t// it's possible that the blob is already present in the filesystem\n\t\t// just ignore\n\t\treturn nil\n\t}\n\n\t// write the blob to the filesystem\n\tif err := qb.fsStore.Write(ctx, checksum, blob); err != nil {\n\t\treturn fmt.Errorf(\"writing to filesystem: %w\", err)\n\t}\n\n\tif deleteOld {\n\t\t// delete the blob from the database row\n\t\tif err := qb.update(ctx, checksum, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/blob_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype updateImageFunc func(ctx context.Context, id int, image []byte) error\ntype getImageFunc func(ctx context.Context, id int) ([]byte, error)\n\nfunc testUpdateImage(t *testing.T, ctx context.Context, id int, updateFn updateImageFunc, getFn getImageFunc) error {\n\timage := []byte(\"image\")\n\terr := updateFn(ctx, id, image)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Error updating performer image: %s\", err.Error())\n\t}\n\n\t// ensure image set\n\tstoredImage, err := getFn(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Error getting image: %s\", err.Error())\n\t}\n\tassert.Equal(t, storedImage, image)\n\n\t// set nil image\n\terr = updateFn(ctx, id, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error setting nil image: %w\", err)\n\t}\n\n\t// ensure image null\n\tstoredImage, err = getFn(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Error getting image: %s\", err.Error())\n\t}\n\tassert.Nil(t, storedImage)\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/common.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/jmoiron/sqlx\"\n)\n\ntype oCounterManager struct {\n\ttableMgr *table\n}\n\nfunc (qb *oCounterManager) getOCounter(ctx context.Context, id int) (int, error) {\n\tq := dialect.From(qb.tableMgr.table).Select(\"o_counter\").Where(goqu.Ex{\"id\": id})\n\n\tconst single = true\n\tvar ret int\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tif err := rows.Scan(&ret); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *oCounterManager) IncrementOCounter(ctx context.Context, id int) (int, error) {\n\tif err := qb.tableMgr.checkIDExists(ctx, id); err != nil {\n\t\treturn 0, err\n\t}\n\n\tif err := qb.tableMgr.updateByID(ctx, id, goqu.Record{\n\t\t\"o_counter\": goqu.L(\"o_counter + 1\"),\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn qb.getOCounter(ctx, id)\n}\n\nfunc (qb *oCounterManager) DecrementOCounter(ctx context.Context, id int) (int, error) {\n\tif err := qb.tableMgr.checkIDExists(ctx, id); err != nil {\n\t\treturn 0, err\n\t}\n\n\ttable := qb.tableMgr.table\n\tq := dialect.Update(table).Set(goqu.Record{\n\t\t\"o_counter\": goqu.L(\"o_counter - 1\"),\n\t}).Where(qb.tableMgr.byID(id), goqu.L(\"o_counter > 0\"))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn 0, fmt.Errorf(\"updating %s: %w\", table.GetTable(), err)\n\t}\n\n\treturn qb.getOCounter(ctx, id)\n}\n\nfunc (qb *oCounterManager) ResetOCounter(ctx context.Context, id int) (int, error) {\n\tif err := qb.tableMgr.checkIDExists(ctx, id); err != nil {\n\t\treturn 0, err\n\t}\n\n\tif err := qb.tableMgr.updateByID(ctx, id, goqu.Record{\n\t\t\"o_counter\": 0,\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn qb.getOCounter(ctx, id)\n}\n"
  },
  {
    "path": "pkg/sqlite/criterion_handlers.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype criterionHandler interface {\n\thandle(ctx context.Context, f *filterBuilder)\n}\n\ntype criterionHandlerFunc func(ctx context.Context, f *filterBuilder)\n\nfunc (h criterionHandlerFunc) handle(ctx context.Context, f *filterBuilder) {\n\th(ctx, f)\n}\n\ntype compoundHandler []criterionHandler\n\nfunc (h compoundHandler) handle(ctx context.Context, f *filterBuilder) {\n\tfor _, h := range h {\n\t\th.handle(ctx, f)\n\t}\n}\n\n// shared criterion handlers go here\n\nfunc stringCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\tif modifier := c.Modifier; c.Modifier.IsValid() {\n\t\t\t\tswitch modifier {\n\t\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, false))\n\t\t\t\tcase models.CriterionModifierExcludes:\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, true))\n\t\t\t\tcase models.CriterionModifierEquals:\n\t\t\t\t\tf.addWhere(column+\" LIKE ?\", c.Value)\n\t\t\t\tcase models.CriterionModifierNotEquals:\n\t\t\t\t\tf.addWhere(column+\" NOT LIKE ?\", c.Value)\n\t\t\t\tcase models.CriterionModifierMatchesRegex:\n\t\t\t\t\tif _, err := regexp.Compile(c.Value); err != nil {\n\t\t\t\t\t\tf.setError(err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"(%s IS NOT NULL AND %[1]s regexp ?)\", column), c.Value)\n\t\t\t\tcase models.CriterionModifierNotMatchesRegex:\n\t\t\t\t\tif _, err := regexp.Compile(c.Value); err != nil {\n\t\t\t\t\t\tf.setError(err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"(%s IS NULL OR %[1]s NOT regexp ?)\", column), c.Value)\n\t\t\t\tcase models.CriterionModifierIsNull:\n\t\t\t\t\tf.addWhere(\"(\" + column + \" IS NULL OR TRIM(\" + column + \") = '')\")\n\t\t\t\tcase models.CriterionModifierNotNull:\n\t\t\t\t\tf.addWhere(\"(\" + column + \" IS NOT NULL AND TRIM(\" + column + \") != '')\")\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"unsupported string filter modifier\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\t\t\tstringCriterionHandler(c, column)(ctx, f)\n\t\t}\n\t}\n}\n\nfunc enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif modifier.IsValid() {\n\t\t\tswitch modifier {\n\t\t\tcase models.CriterionModifierIncludes, models.CriterionModifierEquals:\n\t\t\t\tif len(values) > 0 {\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false))\n\t\t\t\t}\n\t\t\tcase models.CriterionModifierExcludes, models.CriterionModifierNotEquals:\n\t\t\t\tif len(values) > 0 {\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true))\n\t\t\t\t}\n\t\t\tcase models.CriterionModifierIsNull:\n\t\t\t\tf.addWhere(\"(\" + column + \" IS NULL OR TRIM(\" + column + \") = '')\")\n\t\t\tcase models.CriterionModifierNotNull:\n\t\t\t\tf.addWhere(\"(\" + column + \" IS NOT NULL AND TRIM(\" + column + \") != '')\")\n\t\t\tdefault:\n\t\t\t\tpanic(\"unsupported string filter modifier\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\t\t\taddWildcards := true\n\t\t\tnot := false\n\n\t\t\tif modifier := c.Modifier; c.Modifier.IsValid() {\n\t\t\t\tswitch modifier {\n\t\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not))\n\t\t\t\tcase models.CriterionModifierExcludes:\n\t\t\t\t\tnot = true\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not))\n\t\t\t\tcase models.CriterionModifierEquals:\n\t\t\t\t\taddWildcards = false\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not))\n\t\t\t\tcase models.CriterionModifierNotEquals:\n\t\t\t\t\taddWildcards = false\n\t\t\t\t\tnot = true\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not))\n\t\t\t\tcase models.CriterionModifierMatchesRegex:\n\t\t\t\t\tif _, err := regexp.Compile(c.Value); err != nil {\n\t\t\t\t\t\tf.setError(err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tfilepathColumn := fmt.Sprintf(\"%s || '%s' || %s\", pathColumn, string(filepath.Separator), basenameColumn)\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?\", pathColumn, basenameColumn, filepathColumn), c.Value)\n\t\t\t\tcase models.CriterionModifierNotMatchesRegex:\n\t\t\t\t\tif _, err := regexp.Compile(c.Value); err != nil {\n\t\t\t\t\t\tf.setError(err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tfilepathColumn := fmt.Sprintf(\"%s || '%s' || %s\", pathColumn, string(filepath.Separator), basenameColumn)\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s IS NULL OR %s IS NULL OR %s NOT regexp ?\", pathColumn, basenameColumn, filepathColumn), c.Value)\n\t\t\t\tcase models.CriterionModifierIsNull:\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''\", pathColumn, basenameColumn))\n\t\t\t\tcase models.CriterionModifierNotNull:\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''\", pathColumn, basenameColumn))\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"unsupported string filter modifier\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc getPathSearchClause(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause {\n\tif addWildcards {\n\t\tp = \"%\" + p + \"%\"\n\t}\n\n\tfilepathColumn := fmt.Sprintf(\"%s || '%s' || %s\", pathColumn, string(filepath.Separator), basenameColumn)\n\tret := makeClause(fmt.Sprintf(\"%s LIKE ?\", filepathColumn), p)\n\n\tif not {\n\t\tret = ret.not()\n\t}\n\n\treturn ret\n}\n\n// getPathSearchClauseMany splits the query string p on whitespace\n// Used for backwards compatibility for the includes/excludes modifiers\nfunc getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause {\n\tq := strings.TrimSpace(p)\n\ttrimmedQuery := strings.Trim(q, \"\\\"\")\n\n\tif trimmedQuery == q {\n\t\tq = regexp.MustCompile(`\\s+`).ReplaceAllString(q, \" \")\n\t\tqueryWords := strings.Split(q, \" \")\n\n\t\tvar ret []sqlClause\n\t\t// Search for any word\n\t\tfor _, word := range queryWords {\n\t\t\tret = append(ret, getPathSearchClause(pathColumn, basenameColumn, word, addWildcards, not))\n\t\t}\n\n\t\tif !not {\n\t\t\treturn orClauses(ret...)\n\t\t}\n\n\t\treturn andClauses(ret...)\n\t}\n\n\treturn getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not)\n}\n\nfunc intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\t\t\tclause, args := getIntCriterionWhereClause(column, *c)\n\t\t\tf.addWhere(clause, args...)\n\t\t}\n\t}\n}\n\nfunc floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\t\t\tclause, args := getFloatCriterionWhereClause(column, *c)\n\t\t\tf.addWhere(clause, args...)\n\t\t}\n\t}\n}\n\nfunc floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif durationFilter != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\t\t\tclause, args := getIntCriterionWhereClause(\"cast(\"+column+\" as int)\", *durationFilter)\n\t\t\tf.addWhere(clause, args...)\n\t\t}\n\t}\n}\n\nfunc boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\t\t\tvar v string\n\t\t\tif *c {\n\t\t\t\tv = \"1\"\n\t\t\t} else {\n\t\t\t\tv = \"0\"\n\t\t\t}\n\n\t\t\tf.addWhere(column + \" = \" + v)\n\t\t}\n\t}\n}\n\ntype dateCriterionHandler struct {\n\tc      *models.DateCriterionInput\n\tcolumn string\n\tjoinFn func(f *filterBuilder)\n}\n\nfunc (h *dateCriterionHandler) handle(ctx context.Context, f *filterBuilder) {\n\tif h.c != nil {\n\t\tif h.joinFn != nil {\n\t\t\th.joinFn(f)\n\t\t}\n\t\tclause, args := getDateCriterionWhereClause(h.column, *h.c)\n\t\tf.addWhere(clause, args...)\n\t}\n}\n\ntype timestampCriterionHandler struct {\n\tc      *models.TimestampCriterionInput\n\tcolumn string\n\tjoinFn func(f *filterBuilder)\n}\n\nfunc (h *timestampCriterionHandler) handle(ctx context.Context, f *filterBuilder) {\n\tif h.c != nil {\n\t\tif h.joinFn != nil {\n\t\t\th.joinFn(f)\n\t\t}\n\t\tclause, args := getTimestampCriterionWhereClause(h.column, *h.c)\n\t\tf.addWhere(clause, args...)\n\t}\n}\n\nfunc yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif year != nil && year.Modifier.IsValid() {\n\t\t\tclause, args := getIntCriterionWhereClause(\"cast(strftime('%Y', \"+col+\") as int)\", *year)\n\t\t\tf.addWhere(clause, args...)\n\t\t}\n\t}\n}\n\nfunc resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif resolution != nil && resolution.Value.IsValid() {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\n\t\t\tmn := resolution.Value.GetMinResolution()\n\t\t\tmx := resolution.Value.GetMaxResolution()\n\n\t\t\twidthHeight := fmt.Sprintf(\"MIN(%s, %s)\", widthColumn, heightColumn)\n\n\t\t\tswitch resolution.Modifier {\n\t\t\tcase models.CriterionModifierEquals:\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s BETWEEN %d AND %d\", widthHeight, mn, mx))\n\t\t\tcase models.CriterionModifierNotEquals:\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s NOT BETWEEN %d AND %d\", widthHeight, mn, mx))\n\t\t\tcase models.CriterionModifierLessThan:\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s < %d\", widthHeight, mn))\n\t\t\tcase models.CriterionModifierGreaterThan:\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s > %d\", widthHeight, mx))\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif orientation != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\n\t\t\tvar clauses []sqlClause\n\n\t\t\tfor _, v := range orientation.Value {\n\t\t\t\t// width mod height\n\t\t\t\tmod := \"\"\n\t\t\t\tswitch v {\n\t\t\t\tcase models.OrientationPortrait:\n\t\t\t\t\tmod = \"<\"\n\t\t\t\tcase models.OrientationLandscape:\n\t\t\t\t\tmod = \">\"\n\t\t\t\tcase models.OrientationSquare:\n\t\t\t\t\tmod = \"=\"\n\t\t\t\t}\n\n\t\t\t\tif mod != \"\" {\n\t\t\t\t\tclauses = append(clauses, makeClause(fmt.Sprintf(\"%s %s %s\", widthColumn, mod, heightColumn)))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(clauses) > 0 {\n\t\t\t\tf.whereClauses = append(f.whereClauses, orClauses(clauses...))\n\t\t\t}\n\t\t}\n\t}\n}\n\n// handle for MultiCriterion where there is a join table between the new\n// objects\ntype joinedMultiCriterionHandlerBuilder struct {\n\t// table containing the primary objects\n\tprimaryTable string\n\t// table joining primary and foreign objects\n\tjoinTable string\n\t// alias for join table, if required\n\tjoinAs string\n\t// foreign key of the primary object on the join table\n\tprimaryFK string\n\t// foreign key of the foreign object on the join table\n\tforeignFK string\n\n\taddJoinTable func(f *filterBuilder)\n}\n\nfunc (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\t// make local copy so we can modify it\n\t\t\tcriterion := *c\n\n\t\t\tjoinAlias := m.joinAs\n\t\t\tif joinAlias == \"\" {\n\t\t\t\tjoinAlias = m.joinTable\n\t\t\t}\n\n\t\t\tif criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tm.addJoinTable(f)\n\n\t\t\t\tf.addWhere(utils.StrFormat(\"{table}.{column} IS {not} NULL\", utils.StrFormatMap{\n\t\t\t\t\t\"table\":  joinAlias,\n\t\t\t\t\t\"column\": m.foreignFK,\n\t\t\t\t\t\"not\":    notClause,\n\t\t\t\t}))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// combine excludes if excludes modifier is selected\n\t\t\tif criterion.Modifier == models.CriterionModifierExcludes {\n\t\t\t\tcriterion.Modifier = models.CriterionModifierIncludesAll\n\t\t\t\tcriterion.Excludes = append(criterion.Excludes, criterion.Value...)\n\t\t\t\tcriterion.Value = nil\n\t\t\t}\n\n\t\t\tif len(criterion.Value) > 0 {\n\t\t\t\twhereClause := \"\"\n\t\t\t\thavingClause := \"\"\n\n\t\t\t\tvar args []interface{}\n\t\t\t\tfor _, tagID := range criterion.Value {\n\t\t\t\t\targs = append(args, tagID)\n\t\t\t\t}\n\n\t\t\t\tswitch criterion.Modifier {\n\t\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\t\t// includes any of the provided ids\n\t\t\t\t\tm.addJoinTable(f)\n\t\t\t\t\twhereClause = fmt.Sprintf(\"%s.%s IN %s\", joinAlias, m.foreignFK, getInBinding(len(criterion.Value)))\n\t\t\t\tcase models.CriterionModifierEquals:\n\t\t\t\t\t// includes only the provided ids\n\t\t\t\t\tm.addJoinTable(f)\n\t\t\t\t\twhereClause = utils.StrFormat(\"{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?\", utils.StrFormatMap{\n\t\t\t\t\t\t\"joinAlias\":    joinAlias,\n\t\t\t\t\t\t\"foreignFK\":    m.foreignFK,\n\t\t\t\t\t\t\"inBinding\":    getInBinding(len(criterion.Value)),\n\t\t\t\t\t\t\"joinTable\":    m.joinTable,\n\t\t\t\t\t\t\"primaryFK\":    m.primaryFK,\n\t\t\t\t\t\t\"primaryTable\": m.primaryTable,\n\t\t\t\t\t})\n\t\t\t\t\thavingClause = fmt.Sprintf(\"count(distinct %s.%s) IS %d\", joinAlias, m.foreignFK, len(criterion.Value))\n\t\t\t\t\targs = append(args, len(criterion.Value))\n\t\t\t\tcase models.CriterionModifierNotEquals:\n\t\t\t\t\tf.setError(fmt.Errorf(\"not equals modifier is not supported for multi criterion input\"))\n\t\t\t\tcase models.CriterionModifierIncludesAll:\n\t\t\t\t\t// includes all of the provided ids\n\t\t\t\t\tm.addJoinTable(f)\n\t\t\t\t\twhereClause = fmt.Sprintf(\"%s.%s IN %s\", joinAlias, m.foreignFK, getInBinding(len(criterion.Value)))\n\t\t\t\t\thavingClause = fmt.Sprintf(\"count(distinct %s.%s) IS %d\", joinAlias, m.foreignFK, len(criterion.Value))\n\t\t\t\t}\n\n\t\t\t\tf.addWhere(whereClause, args...)\n\t\t\t\tf.addHaving(havingClause)\n\t\t\t}\n\n\t\t\tif len(criterion.Excludes) > 0 {\n\t\t\t\tvar args []interface{}\n\t\t\t\tfor _, tagID := range criterion.Excludes {\n\t\t\t\t\targs = append(args, tagID)\n\t\t\t\t}\n\n\t\t\t\t// excludes all of the provided ids\n\t\t\t\t// need to use actual join table name for this\n\t\t\t\t// <primaryTable>.id NOT IN (select <joinTable>.<primaryFK> from <joinTable> where <joinTable>.<foreignFK> in <values>)\n\t\t\t\twhereClause := fmt.Sprintf(\"%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)\", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes)))\n\n\t\t\t\tf.addWhere(whereClause, args...)\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype multiCriterionHandlerBuilder struct {\n\tprimaryTable string\n\tforeignTable string\n\tjoinTable    string\n\tprimaryFK    string\n\tforeignFK    string\n\n\t// function that will be called to perform any necessary joins\n\taddJoinsFunc func(f *filterBuilder)\n}\n\nfunc (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif criterion != nil {\n\t\t\tif criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\ttable := m.primaryTable\n\t\t\t\tif m.joinTable != \"\" {\n\t\t\t\t\ttable = m.joinTable\n\t\t\t\t\tf.addLeftJoin(table, \"\", fmt.Sprintf(\"%s.%s = %s.id\", table, m.primaryFK, m.primaryTable))\n\t\t\t\t}\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s.%s IS %s NULL\", table, m.foreignFK, notClause))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(criterion.Value) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar args []interface{}\n\t\t\tfor _, tagID := range criterion.Value {\n\t\t\t\targs = append(args, tagID)\n\t\t\t}\n\n\t\t\tif m.addJoinsFunc != nil {\n\t\t\t\tm.addJoinsFunc(f)\n\t\t\t}\n\n\t\t\twhereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion)\n\t\t\tf.addWhere(whereClause, args...)\n\t\t\tf.addHaving(havingClause)\n\t\t}\n\t}\n}\n\ntype countCriterionHandlerBuilder struct {\n\tprimaryTable string\n\tjoinTable    string\n\tprimaryFK    string\n}\n\nfunc (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif criterion != nil {\n\t\t\tclause, args := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion)\n\n\t\t\tf.addWhere(clause, args...)\n\t\t}\n\t}\n}\n\n// handler for StringCriterion for string list fields\ntype stringListCriterionHandlerBuilder struct {\n\tprimaryTable string\n\t// foreign key of the primary object on the join table\n\tprimaryFK string\n\t// table joining primary and foreign objects\n\tjoinTable string\n\t// string field on the join table\n\tstringColumn string\n\n\taddJoinTable   func(f *filterBuilder)\n\texcludeHandler func(f *filterBuilder, criterion *models.StringCriterionInput)\n}\n\nfunc (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif criterion != nil {\n\t\t\tif criterion.Modifier == models.CriterionModifierExcludes {\n\t\t\t\t// special handling for excludes\n\t\t\t\tif m.excludeHandler != nil {\n\t\t\t\t\tm.excludeHandler(f, criterion)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// excludes all of the provided values\n\t\t\t\t// need to use actual join table name for this\n\t\t\t\t// <primaryTable>.id NOT IN (select <joinTable>.<primaryFK> from <joinTable> where <joinTable>.<foreignFK> in <values>)\n\t\t\t\twhereClause := utils.StrFormat(\"{primaryTable}.id NOT IN (SELECT {joinTable}.{primaryFK} from {joinTable} where {joinTable}.{stringColumn} LIKE ?)\",\n\t\t\t\t\tutils.StrFormatMap{\n\t\t\t\t\t\t\"primaryTable\": m.primaryTable,\n\t\t\t\t\t\t\"joinTable\":    m.joinTable,\n\t\t\t\t\t\t\"primaryFK\":    m.primaryFK,\n\t\t\t\t\t\t\"stringColumn\": m.stringColumn,\n\t\t\t\t\t},\n\t\t\t\t)\n\n\t\t\t\tf.addWhere(whereClause, \"%\"+criterion.Value+\"%\")\n\n\t\t\t\t// TODO - should we also exclude null values?\n\t\t\t\t// m.addJoinTable(f)\n\t\t\t\t// stringCriterionHandler(&models.StringCriterionInput{\n\t\t\t\t// \tModifier: models.CriterionModifierNotNull,\n\t\t\t\t// }, m.joinTable+\".\"+m.stringColumn)(ctx, f)\n\t\t\t} else {\n\t\t\t\tm.addJoinTable(f)\n\t\t\t\tstringCriterionHandler(criterion, m.joinTable+\".\"+m.stringColumn)(ctx, f)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif studios == nil {\n\t\t\treturn\n\t\t}\n\n\t\tstudiosCopy := *studios\n\t\tswitch studiosCopy.Modifier {\n\t\tcase models.CriterionModifierEquals:\n\t\t\tstudiosCopy.Modifier = models.CriterionModifierIncludesAll\n\t\tcase models.CriterionModifierNotEquals:\n\t\t\tstudiosCopy.Modifier = models.CriterionModifierExcludes\n\t\t}\n\n\t\thh := hierarchicalMultiCriterionHandlerBuilder{\n\t\t\tprimaryTable: primaryTable,\n\t\t\tforeignTable: studioTable,\n\t\t\tforeignFK:    studioIDColumn,\n\t\t\tparentFK:     \"parent_id\",\n\t\t}\n\n\t\thh.handler(&studiosCopy)(ctx, f)\n\t}\n}\n\ntype hierarchicalMultiCriterionHandlerBuilder struct {\n\tprimaryTable string\n\tforeignTable string\n\tforeignFK    string\n\n\tparentFK       string\n\tchildFK        string\n\trelationsTable string\n}\n\nfunc getHierarchicalValues(ctx context.Context, values []string, table, relationsTable, parentFK string, childFK string, depth *int) (string, error) {\n\tvar args []interface{}\n\n\tif parentFK == \"\" {\n\t\tparentFK = \"parent_id\"\n\t}\n\tif childFK == \"\" {\n\t\tchildFK = \"child_id\"\n\t}\n\n\tdepthVal := 0\n\tif depth != nil {\n\t\tdepthVal = *depth\n\t}\n\n\tif depthVal == 0 {\n\t\tvalid := true\n\t\tvar valuesClauses []string\n\t\tfor _, value := range values {\n\t\t\tid, err := strconv.Atoi(value)\n\t\t\t// In case of invalid value just run the query.\n\t\t\t// Building VALUES() based on provided values just saves a query when depth is 0.\n\t\t\tif err != nil {\n\t\t\t\tvalid = false\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tvaluesClauses = append(valuesClauses, fmt.Sprintf(\"(%d,%d)\", id, id))\n\t\t}\n\n\t\tif valid {\n\t\t\treturn \"VALUES\" + strings.Join(valuesClauses, \",\"), nil\n\t\t}\n\t}\n\n\tfor _, value := range values {\n\t\targs = append(args, value)\n\t}\n\tinCount := len(args)\n\n\tvar depthCondition string\n\tif depthVal != -1 {\n\t\tdepthCondition = fmt.Sprintf(\"WHERE depth < %d\", depthVal)\n\t}\n\n\twithClauseMap := utils.StrFormatMap{\n\t\t\"table\":           table,\n\t\t\"relationsTable\":  relationsTable,\n\t\t\"inBinding\":       getInBinding(inCount),\n\t\t\"recursiveSelect\": \"\",\n\t\t\"parentFK\":        parentFK,\n\t\t\"childFK\":         childFK,\n\t\t\"depthCondition\":  depthCondition,\n\t\t\"unionClause\":     \"\",\n\t}\n\n\tif relationsTable != \"\" {\n\t\twithClauseMap[\"recursiveSelect\"] = utils.StrFormat(`SELECT p.root_id, c.{childFK}, depth + 1 FROM {relationsTable} AS c\nINNER JOIN items as p ON c.{parentFK} = p.item_id\n`, withClauseMap)\n\t} else {\n\t\twithClauseMap[\"recursiveSelect\"] = utils.StrFormat(`SELECT p.root_id, c.id, depth + 1 FROM {table} as c\nINNER JOIN items as p ON c.{parentFK} = p.item_id\n`, withClauseMap)\n\t}\n\n\tif depthVal != 0 {\n\t\twithClauseMap[\"unionClause\"] = utils.StrFormat(`\nUNION {recursiveSelect} {depthCondition}\n`, withClauseMap)\n\t}\n\n\twithClause := utils.StrFormat(`items AS (\nSELECT id as root_id, id as item_id, 0 as depth FROM {table}\nWHERE id in {inBinding}\n{unionClause})\n`, withClauseMap)\n\n\tquery := fmt.Sprintf(\"WITH RECURSIVE %s SELECT 'VALUES' || GROUP_CONCAT('(' || root_id || ', ' || item_id || ')') AS val FROM items\", withClause)\n\n\tvar valuesClause sql.NullString\n\terr := dbWrapper.Get(ctx, &valuesClause, query, args...)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to get hierarchical values: %w\", err)\n\t}\n\n\t// if no values are found, just return a values string with the values only\n\tif !valuesClause.Valid {\n\t\tfor i, value := range values {\n\t\t\tvalues[i] = fmt.Sprintf(\"(%s, %s)\", value, value)\n\t\t}\n\t\tvaluesClause.String = \"VALUES\" + strings.Join(values, \",\")\n\t}\n\n\treturn valuesClause.String, nil\n}\n\nfunc addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) {\n\tswitch criterion.Modifier {\n\tcase models.CriterionModifierIncludes:\n\t\tf.addWhere(fmt.Sprintf(\"%s.%s IS NOT NULL\", table, idColumn))\n\tcase models.CriterionModifierIncludesAll:\n\t\tf.addWhere(fmt.Sprintf(\"%s.%s IS NOT NULL\", table, idColumn))\n\t\tf.addHaving(fmt.Sprintf(\"count(distinct %s.%s) IS %d\", table, idColumn, len(criterion.Value)))\n\tcase models.CriterionModifierExcludes:\n\t\tf.addWhere(fmt.Sprintf(\"%s.%s IS NULL\", table, idColumn))\n\t}\n}\n\nfunc (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\t// make a copy so we don't modify the original\n\t\t\tcriterion := *c\n\n\t\t\t// don't support equals/not equals\n\t\t\tif criterion.Modifier == models.CriterionModifierEquals || criterion.Modifier == models.CriterionModifierNotEquals {\n\t\t\t\tf.setError(fmt.Errorf(\"modifier %s is not supported for hierarchical multi criterion\", criterion.Modifier))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tf.addWhere(utils.StrFormat(\"{table}.{column} IS {not} NULL\", utils.StrFormatMap{\n\t\t\t\t\t\"table\":  m.primaryTable,\n\t\t\t\t\t\"column\": m.foreignFK,\n\t\t\t\t\t\"not\":    notClause,\n\t\t\t\t}))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// combine excludes if excludes modifier is selected\n\t\t\tif criterion.Modifier == models.CriterionModifierExcludes {\n\t\t\t\tcriterion.Modifier = models.CriterionModifierIncludesAll\n\t\t\t\tcriterion.Excludes = append(criterion.Excludes, criterion.Value...)\n\t\t\t\tcriterion.Value = nil\n\t\t\t}\n\n\t\t\tif len(criterion.Value) > 0 {\n\t\t\t\tvaluesClause, err := getHierarchicalValues(ctx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tswitch criterion.Modifier {\n\t\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s.%s IN (SELECT column2 FROM (%s))\", m.primaryTable, m.foreignFK, valuesClause))\n\t\t\t\tcase models.CriterionModifierIncludesAll:\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s.%s IN (SELECT column2 FROM (%s))\", m.primaryTable, m.foreignFK, valuesClause))\n\t\t\t\t\tf.addHaving(fmt.Sprintf(\"count(distinct %s.%s) IS %d\", m.primaryTable, m.foreignFK, len(criterion.Value)))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(criterion.Excludes) > 0 {\n\t\t\t\tvaluesClause, err := getHierarchicalValues(ctx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL\", m.primaryTable, m.foreignFK, valuesClause))\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype joinedHierarchicalMultiCriterionHandlerBuilder struct {\n\tprimaryTable string\n\tprimaryKey   string\n\tforeignTable string\n\tforeignFK    string\n\n\tparentFK       string\n\tchildFK        string\n\trelationsTable string\n\n\tjoinAs    string\n\tjoinTable string\n\tprimaryFK string\n}\n\nfunc (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) {\n\tprimaryKey := m.primaryKey\n\tif primaryKey == \"\" {\n\t\tprimaryKey = \"id\"\n\t}\n\n\tswitch criterion.Modifier {\n\tcase models.CriterionModifierEquals:\n\t\t// includes only the provided ids\n\t\tf.addWhere(fmt.Sprintf(\"%s.%s IS NOT NULL\", table, idColumn))\n\t\tf.addHaving(fmt.Sprintf(\"count(distinct %s.%s) IS %d\", table, idColumn, len(criterion.Value)))\n\t\tf.addWhere(utils.StrFormat(\"(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.{primaryKey}) = ?\", utils.StrFormatMap{\n\t\t\t\"joinTable\":    m.joinTable,\n\t\t\t\"primaryFK\":    m.primaryFK,\n\t\t\t\"primaryTable\": m.primaryTable,\n\t\t\t\"primaryKey\":   primaryKey,\n\t\t}), len(criterion.Value))\n\tcase models.CriterionModifierNotEquals:\n\t\tf.setError(fmt.Errorf(\"not equals modifier is not supported for hierarchical multi criterion input\"))\n\tdefault:\n\t\taddHierarchicalConditionClauses(f, criterion, table, idColumn)\n\t}\n}\n\nfunc (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\t// make a copy so we don't modify the original\n\t\t\tcriterion := *c\n\t\t\tjoinAlias := m.joinAs\n\t\t\tprimaryKey := m.primaryKey\n\t\t\tif primaryKey == \"\" {\n\t\t\t\tprimaryKey = \"id\"\n\t\t\t}\n\n\t\t\tif criterion.Modifier == models.CriterionModifierEquals && criterion.Depth != nil && *criterion.Depth != 0 {\n\t\t\t\tf.setError(fmt.Errorf(\"depth is not supported for equals modifier in hierarchical multi criterion input\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tf.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf(\"%s.%s = %s.%s\", joinAlias, m.primaryFK, m.primaryTable, primaryKey))\n\n\t\t\t\tf.addWhere(utils.StrFormat(\"{table}.{column} IS {not} NULL\", utils.StrFormatMap{\n\t\t\t\t\t\"table\":  joinAlias,\n\t\t\t\t\t\"column\": m.foreignFK,\n\t\t\t\t\t\"not\":    notClause,\n\t\t\t\t}))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// combine excludes if excludes modifier is selected\n\t\t\tif criterion.Modifier == models.CriterionModifierExcludes {\n\t\t\t\tcriterion.Modifier = models.CriterionModifierIncludesAll\n\t\t\t\tcriterion.Excludes = append(criterion.Excludes, criterion.Value...)\n\t\t\t\tcriterion.Value = nil\n\t\t\t}\n\n\t\t\tif len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(criterion.Value) > 0 {\n\t\t\t\tvaluesClause, err := getHierarchicalValues(ctx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tjoinTable := utils.StrFormat(`(\n\t\tSELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j\n\t\tINNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2\n\t)\n\t`, utils.StrFormatMap{\n\t\t\t\t\t\"joinTable\":    m.joinTable,\n\t\t\t\t\t\"foreignFK\":    m.foreignFK,\n\t\t\t\t\t\"valuesClause\": valuesClause,\n\t\t\t\t})\n\n\t\t\t\tf.addLeftJoin(joinTable, joinAlias, fmt.Sprintf(\"%s.%s = %s.%s\", joinAlias, m.primaryFK, m.primaryTable, primaryKey))\n\n\t\t\t\tm.addHierarchicalConditionClauses(f, criterion, joinAlias, \"root_id\")\n\t\t\t}\n\n\t\t\tif len(criterion.Excludes) > 0 {\n\t\t\t\tvaluesClause, err := getHierarchicalValues(ctx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tjoinTable := utils.StrFormat(`(\n\t\tSELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2\n\t\tINNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2\n\t)\n\t`, utils.StrFormatMap{\n\t\t\t\t\t\"joinTable\":    m.joinTable,\n\t\t\t\t\t\"foreignFK\":    m.foreignFK,\n\t\t\t\t\t\"valuesClause\": valuesClause,\n\t\t\t\t})\n\n\t\t\t\tjoinAlias2 := joinAlias + \"2\"\n\n\t\t\t\tf.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf(\"%s.%s = %s.%s\", joinAlias2, m.primaryFK, m.primaryTable, primaryKey))\n\n\t\t\t\t// modify for exclusion\n\t\t\t\tcriterionCopy := criterion\n\t\t\t\tcriterionCopy.Modifier = models.CriterionModifierExcludes\n\t\t\t\tcriterionCopy.Value = c.Excludes\n\n\t\t\t\tm.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, \"root_id\")\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype joinedPerformerTagsHandler struct {\n\tcriterion *models.HierarchicalMultiCriterionInput\n\n\tprimaryTable   string // eg scenes\n\tjoinTable      string // eg performers_scenes\n\tjoinPrimaryKey string // eg scene_id\n}\n\nfunc (h *joinedPerformerTagsHandler) handle(ctx context.Context, f *filterBuilder) {\n\ttags := h.criterion\n\n\tif tags != nil {\n\t\tcriterion := tags.CombineExcludes()\n\n\t\t// validate the modifier\n\t\tswitch criterion.Modifier {\n\t\tcase models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull:\n\t\t\t// valid\n\t\tdefault:\n\t\t\tf.setError(fmt.Errorf(\"invalid modifier %s for performer tags\", criterion.Modifier))\n\t\t}\n\n\t\tstrFormatMap := utils.StrFormatMap{\n\t\t\t\"primaryTable\":   h.primaryTable,\n\t\t\t\"joinTable\":      h.joinTable,\n\t\t\t\"joinPrimaryKey\": h.joinPrimaryKey,\n\t\t\t\"inBinding\":      getInBinding(len(criterion.Value)),\n\t\t}\n\n\t\tif criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\tvar notClause string\n\t\t\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tnotClause = \"NOT\"\n\t\t\t}\n\n\t\t\tf.addLeftJoin(h.joinTable, \"\", utils.StrFormat(\"{primaryTable}.id = {joinTable}.{joinPrimaryKey}\", strFormatMap))\n\t\t\tf.addLeftJoin(\"performers_tags\", \"\", utils.StrFormat(\"{joinTable}.performer_id = performers_tags.performer_id\", strFormatMap))\n\n\t\t\tf.addWhere(fmt.Sprintf(\"performers_tags.tag_id IS %s NULL\", notClause))\n\t\t\treturn\n\t\t}\n\n\t\tif len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {\n\t\t\treturn\n\t\t}\n\n\t\tif len(criterion.Value) > 0 {\n\t\t\tvaluesClause, err := getHierarchicalValues(ctx, criterion.Value, tagTable, \"tags_relations\", \"\", \"\", criterion.Depth)\n\t\t\tif err != nil {\n\t\t\t\tf.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf.addWith(utils.StrFormat(`performer_tags AS (\nSELECT ps.{joinPrimaryKey} as primaryID, t.column1 AS root_tag_id FROM {joinTable} ps\nINNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id\nINNER JOIN (`+valuesClause+`) t ON t.column2 = pt.tag_id\n)`, strFormatMap))\n\n\t\t\tf.addLeftJoin(\"performer_tags\", \"\", utils.StrFormat(\"performer_tags.primaryID = {primaryTable}.id\", strFormatMap))\n\n\t\t\taddHierarchicalConditionClauses(f, criterion, \"performer_tags\", \"root_tag_id\")\n\t\t}\n\n\t\tif len(criterion.Excludes) > 0 {\n\t\t\tvaluesClause, err := getHierarchicalValues(ctx, criterion.Excludes, tagTable, \"tags_relations\", \"\", \"\", criterion.Depth)\n\t\t\tif err != nil {\n\t\t\t\tf.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tclause := utils.StrFormat(\"{primaryTable}.id NOT IN (SELECT {joinTable}.{joinPrimaryKey} FROM {joinTable} INNER JOIN performers_tags ON {joinTable}.performer_id = performers_tags.performer_id WHERE performers_tags.tag_id IN (SELECT column2 FROM (%s)))\", strFormatMap)\n\t\t\tf.addWhere(fmt.Sprintf(clause, valuesClause))\n\t\t}\n\t}\n}\n\ntype stashIDCriterionHandler struct {\n\tc                 *models.StashIDCriterionInput\n\tstashIDRepository *stashIDRepository\n\tstashIDTableAs    string\n\tparentIDCol       string\n}\n\nfunc (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) {\n\tif h.c == nil {\n\t\treturn\n\t}\n\n\t// ideally, this handler should just convert to stashIDsCriterionHandler\n\t// but there are some differences in how the existing handler works compared\n\t// to the new code, specifically because this code uses the stringCriterionHandler.\n\t// To minimise potential regressions, we'll keep the existing logic for now.\n\n\tstashIDRepo := h.stashIDRepository\n\tt := stashIDRepo.tableName\n\tif h.stashIDTableAs != \"\" {\n\t\tt = h.stashIDTableAs\n\t}\n\n\tjoinClause := fmt.Sprintf(\"%s.%s = %s\", t, stashIDRepo.idColumn, h.parentIDCol)\n\tif h.c.Endpoint != nil && *h.c.Endpoint != \"\" {\n\t\tjoinClause += fmt.Sprintf(\" AND %s.endpoint = '%s'\", t, *h.c.Endpoint)\n\t}\n\n\tf.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause)\n\n\tv := \"\"\n\tif h.c.StashID != nil {\n\t\tv = *h.c.StashID\n\t}\n\n\tstringCriterionHandler(&models.StringCriterionInput{\n\t\tValue:    v,\n\t\tModifier: h.c.Modifier,\n\t}, t+\".stash_id\")(ctx, f)\n}\n\ntype stashIDsCriterionHandler struct {\n\tc                 *models.StashIDsCriterionInput\n\tstashIDRepository *stashIDRepository\n\tstashIDTableAs    string\n\tparentIDCol       string\n}\n\nfunc (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) {\n\tif h.c == nil {\n\t\treturn\n\t}\n\n\tstashIDRepo := h.stashIDRepository\n\tt := stashIDRepo.tableName\n\tif h.stashIDTableAs != \"\" {\n\t\tt = h.stashIDTableAs\n\t}\n\n\tjoinClause := fmt.Sprintf(\"%s.%s = %s\", t, stashIDRepo.idColumn, h.parentIDCol)\n\tif h.c.Endpoint != nil && *h.c.Endpoint != \"\" {\n\t\tjoinClause += fmt.Sprintf(\" AND %s.endpoint = '%s'\", t, *h.c.Endpoint)\n\t}\n\n\tf.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause)\n\n\tswitch h.c.Modifier {\n\tcase models.CriterionModifierIsNull:\n\t\tf.addWhere(fmt.Sprintf(\"%s.stash_id IS NULL\", t))\n\tcase models.CriterionModifierNotNull:\n\t\tf.addWhere(fmt.Sprintf(\"%s.stash_id IS NOT NULL\", t))\n\tcase models.CriterionModifierEquals:\n\t\tvar clauses []sqlClause\n\t\tfor _, id := range h.c.StashIDs {\n\t\t\tclauses = append(clauses, makeClause(fmt.Sprintf(\"%s.stash_id = ?\", t), id))\n\t\t}\n\t\tf.whereClauses = append(f.whereClauses, orClauses(clauses...))\n\tcase models.CriterionModifierNotEquals:\n\t\tvar clauses []sqlClause\n\t\tfor _, id := range h.c.StashIDs {\n\t\t\tclauses = append(clauses, makeClause(fmt.Sprintf(\"%s.stash_id != ?\", t), id))\n\t\t}\n\t\tf.whereClauses = append(f.whereClauses, andClauses(clauses...))\n\tdefault:\n\t\tf.setError(fmt.Errorf(\"invalid modifier %s for stash IDs criterion\", h.c.Modifier))\n\t}\n}\n\ntype relatedFilterHandler struct {\n\t// column on the primary table that relates to the related table (eg scene_id)\n\trelatedIDCol string\n\t// repository for the related table (eg sceneRepository)\n\trelatedRepo repository\n\t// handler for the filter on the related table\n\trelatedHandler criterionHandler\n\t// optional function to perform the necessary join(s) to the related table\n\tjoinFn func(f *filterBuilder)\n\t// if true, related filter handler will be run using the existing filterBuilder instead of a subquery.\n\tdirectJoin bool\n}\n\nfunc (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tff := filterBuilderFromHandler(ctx, h.relatedHandler)\n\tif ff.err != nil {\n\t\tf.setError(ff.err)\n\t\treturn\n\t}\n\n\tif ff.empty() {\n\t\treturn\n\t}\n\n\tif h.joinFn != nil {\n\t\th.joinFn(f)\n\t}\n\n\tif h.directJoin {\n\t\t// rerun handler using existing filter builder\n\t\th.relatedHandler.handle(ctx, f)\n\t\treturn\n\t}\n\n\tsubQuery := h.relatedRepo.newQuery()\n\tselectIDs(&subQuery, subQuery.repository.tableName)\n\tif err := subQuery.addFilter(ff); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tf.addWhere(fmt.Sprintf(\"%s IN (\"+subQuery.toSQL(false)+\")\", h.relatedIDCol), subQuery.allArgs()...)\n}\n\ntype phashDistanceCriterionHandler struct {\n\t// assumes that applicable fingerprints table is joined as fingerprints_phash\n\tjoinFn    func(f *filterBuilder)\n\tcriterion *models.PhashDistanceCriterionInput\n}\n\nfunc (h *phashDistanceCriterionHandler) handle(ctx context.Context, f *filterBuilder) {\n\tphashDistance := h.criterion\n\tif phashDistance == nil {\n\t\treturn\n\t}\n\n\th.joinFn(f)\n\n\tvalue, _ := utils.StringToPhash(phashDistance.Value)\n\tdistance := 0\n\tif phashDistance.Distance != nil {\n\t\tdistance = *phashDistance.Distance\n\t}\n\n\tswitch {\n\tcase phashDistance.Modifier == models.CriterionModifierEquals && distance > 0:\n\t\t// needed to avoid a type mismatch\n\t\tf.addWhere(\"typeof(fingerprints_phash.fingerprint) = 'integer'\")\n\t\tf.addWhere(\"phash_distance(fingerprints_phash.fingerprint, ?) < ?\", value, distance)\n\tcase phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0:\n\t\t// needed to avoid a type mismatch\n\t\tf.addWhere(\"typeof(fingerprints_phash.fingerprint) = 'integer'\")\n\t\tf.addWhere(\"phash_distance(fingerprints_phash.fingerprint, ?) > ?\", value, distance)\n\tdefault:\n\t\tintCriterionHandler(&models.IntCriterionInput{\n\t\t\tValue:    int(value),\n\t\t\tModifier: phashDistance.Modifier,\n\t\t}, \"fingerprints_phash.fingerprint\", nil)(ctx, f)\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/custom_fields.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst maxCustomFieldNameLength = 64\n\ntype customFieldsStore struct {\n\ttable exp.IdentifierExpression\n\tfk    exp.IdentifierExpression\n}\n\nfunc (s *customFieldsStore) deleteForID(ctx context.Context, id int) error {\n\ttable := s.table\n\tq := dialect.Delete(table).Where(s.fk.Eq(id))\n\t_, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"deleting from %s: %w\", s.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values models.CustomFieldsInput) error {\n\tvar partial bool\n\tvar valMap map[string]interface{}\n\n\tswitch {\n\tcase values.Full != nil:\n\t\tpartial = false\n\t\tvalMap = values.Full\n\tcase values.Partial != nil:\n\t\tpartial = true\n\t\tvalMap = values.Partial\n\t}\n\n\tif valMap != nil {\n\t\tif err := s.validateCustomFields(valMap, values.Remove); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := s.setCustomFields(ctx, id, valMap, partial); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := s.deleteCustomFields(ctx, id, values.Remove); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (s *customFieldsStore) validateCustomFields(values map[string]interface{}, deleteKeys []string) error {\n\t// if values is nil, nothing to validate\n\tif values == nil {\n\t\treturn nil\n\t}\n\n\t// ensure that custom field names are valid\n\t// no leading or trailing whitespace, no empty strings\n\tfor k := range values {\n\t\tif err := s.validateCustomFieldName(k); err != nil {\n\t\t\treturn fmt.Errorf(\"custom field name %q: %w\", k, err)\n\t\t}\n\t}\n\n\t// ensure delete keys are not also in values\n\tfor _, k := range deleteKeys {\n\t\tif _, ok := values[k]; ok {\n\t\t\treturn fmt.Errorf(\"custom field name %q cannot be in both values and delete keys\", k)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (s *customFieldsStore) validateCustomFieldName(fieldName string) error {\n\t// ensure that custom field names are valid\n\t// no leading or trailing whitespace, no empty strings\n\tif strings.TrimSpace(fieldName) == \"\" {\n\t\treturn fmt.Errorf(\"custom field name cannot be empty\")\n\t}\n\tif fieldName != strings.TrimSpace(fieldName) {\n\t\treturn fmt.Errorf(\"custom field name cannot have leading or trailing whitespace\")\n\t}\n\tif len(fieldName) > maxCustomFieldNameLength {\n\t\treturn fmt.Errorf(\"custom field name must be less than %d characters\", maxCustomFieldNameLength+1)\n\t}\n\treturn nil\n}\n\nfunc getSQLValueFromCustomFieldInput(input interface{}) (interface{}, error) {\n\tswitch v := input.(type) {\n\tcase []interface{}, map[string]interface{}:\n\t\t// TODO - in future it would be nice to convert to a JSON string\n\t\t// however, we would need some way to differentiate between a JSON string and a regular string\n\t\t// for now, we will not support objects and arrays\n\t\treturn nil, fmt.Errorf(\"unsupported custom field value type: %T\", input)\n\tdefault:\n\t\treturn v, nil\n\t}\n}\n\nfunc (s *customFieldsStore) sqlValueToValue(value interface{}) interface{} {\n\t// TODO - if we ever support objects and arrays we will need to add support here\n\treturn value\n}\n\nfunc (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values map[string]interface{}, partial bool) error {\n\tif !partial {\n\t\t// delete existing custom fields\n\t\tif err := s.deleteForID(ctx, id); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(values) == 0 {\n\t\treturn nil\n\t}\n\n\tconflictKey := s.fk.GetCol().(string) + \", field\"\n\t// upsert new custom fields\n\tq := dialect.Insert(s.table).Prepared(true).Cols(s.fk, \"field\", \"value\").\n\t\tOnConflict(goqu.DoUpdate(conflictKey, goqu.Record{\"value\": goqu.I(\"excluded.value\")}))\n\tr := make([]interface{}, len(values))\n\tvar i int\n\tfor key, value := range values {\n\t\tv, err := getSQLValueFromCustomFieldInput(value)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting SQL value for field %q: %w\", key, err)\n\t\t}\n\t\tr[i] = goqu.Record{\"field\": key, \"value\": v, s.fk.GetCol().(string): id}\n\t\ti++\n\t}\n\n\tif _, err := exec(ctx, q.Rows(r...)); err != nil {\n\t\treturn fmt.Errorf(\"inserting custom fields: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *customFieldsStore) deleteCustomFields(ctx context.Context, id int, keys []string) error {\n\tif len(keys) == 0 {\n\t\treturn nil\n\t}\n\n\tq := dialect.Delete(s.table).\n\t\tWhere(s.fk.Eq(id)).\n\t\tWhere(goqu.I(\"field\").In(keys))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"deleting custom fields: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *customFieldsStore) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {\n\tq := dialect.Select(\"field\", \"value\").From(s.table).Where(s.fk.Eq(id))\n\n\tconst single = false\n\tret := make(map[string]interface{})\n\terr := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar field string\n\t\tvar value interface{}\n\t\tif err := rows.Scan(&field, &value); err != nil {\n\t\t\treturn fmt.Errorf(\"scanning custom fields: %w\", err)\n\t\t}\n\t\tret[field] = s.sqlValueToValue(value)\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting custom fields: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (s *customFieldsStore) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {\n\tq := dialect.Select(s.fk.As(\"id\"), \"field\", \"value\").From(s.table).Where(s.fk.In(ids))\n\n\tconst single = false\n\tret := make([]models.CustomFieldMap, len(ids))\n\t// initialise ret with empty maps for each id\n\tfor i := range ret {\n\t\tret[i] = make(map[string]interface{})\n\t}\n\n\tidi := make(map[int]int, len(ids))\n\tfor i, id := range ids {\n\t\tidi[id] = i\n\t}\n\n\terr := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar id int\n\t\tvar field string\n\t\tvar value interface{}\n\t\tif err := rows.Scan(&id, &field, &value); err != nil {\n\t\t\treturn fmt.Errorf(\"scanning custom fields: %w\", err)\n\t\t}\n\n\t\ti := idi[id]\n\t\tm := ret[i]\n\t\tif m == nil {\n\t\t\tm = make(map[string]interface{})\n\t\t\tret[i] = m\n\t\t}\n\n\t\tm[field] = s.sqlValueToValue(value)\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting custom fields: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\ntype customFieldsFilterHandler struct {\n\ttable string\n\tfkCol string\n\tc     []models.CustomFieldCriterionInput\n\tidCol string\n}\n\nfunc (h *customFieldsFilterHandler) innerJoin(f *filterBuilder, as string, field string) {\n\tjoinOn := fmt.Sprintf(\"%s = %s.%s AND %s.field = ?\", h.idCol, as, h.fkCol, as)\n\tf.addInnerJoin(h.table, as, joinOn, field)\n}\n\nfunc (h *customFieldsFilterHandler) leftJoin(f *filterBuilder, as string, field string) {\n\tjoinOn := fmt.Sprintf(\"%s = %s.%s AND %s.field = ?\", h.idCol, as, h.fkCol, as)\n\tf.addLeftJoin(h.table, as, joinOn, field)\n}\n\nfunc (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs string, cc models.CustomFieldCriterionInput) {\n\t// convert values\n\tcv := make([]interface{}, len(cc.Value))\n\tfor i, v := range cc.Value {\n\t\tvar err error\n\t\tcv[i], err = getSQLValueFromCustomFieldInput(v)\n\t\tif err != nil {\n\t\t\tf.setError(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tswitch cc.Modifier {\n\tcase models.CriterionModifierEquals:\n\t\th.innerJoin(f, joinAs, cc.Field)\n\t\tf.addWhere(fmt.Sprintf(\"%[1]s.value IN %s\", joinAs, getInBinding(len(cv))), cv...)\n\tcase models.CriterionModifierNotEquals:\n\t\th.innerJoin(f, joinAs, cc.Field)\n\t\tf.addWhere(fmt.Sprintf(\"%[1]s.value NOT IN %s\", joinAs, getInBinding(len(cv))), cv...)\n\tcase models.CriterionModifierIncludes:\n\t\tclauses := make([]sqlClause, len(cv))\n\t\tfor i, v := range cv {\n\t\t\tclauses[i] = makeClause(fmt.Sprintf(\"%s.value LIKE ?\", joinAs), fmt.Sprintf(\"%%%v%%\", v))\n\t\t}\n\t\th.innerJoin(f, joinAs, cc.Field)\n\t\tf.whereClauses = append(f.whereClauses, clauses...)\n\tcase models.CriterionModifierExcludes:\n\t\tfor _, v := range cv {\n\t\t\tf.addWhere(fmt.Sprintf(\"%[1]s.value NOT LIKE ?\", joinAs), fmt.Sprintf(\"%%%v%%\", v))\n\t\t}\n\t\th.leftJoin(f, joinAs, cc.Field)\n\tcase models.CriterionModifierMatchesRegex:\n\t\tfor _, v := range cv {\n\t\t\tvs, ok := v.(string)\n\t\t\tif !ok {\n\t\t\t\tf.setError(fmt.Errorf(\"unsupported custom field criterion value type: %T\", v))\n\t\t\t}\n\t\t\tif _, err := regexp.Compile(vs); err != nil {\n\t\t\t\tf.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tf.addWhere(fmt.Sprintf(\"(%s.value regexp ?)\", joinAs), v)\n\t\t}\n\t\th.innerJoin(f, joinAs, cc.Field)\n\tcase models.CriterionModifierNotMatchesRegex:\n\t\tfor _, v := range cv {\n\t\t\tvs, ok := v.(string)\n\t\t\tif !ok {\n\t\t\t\tf.setError(fmt.Errorf(\"unsupported custom field criterion value type: %T\", v))\n\t\t\t}\n\t\t\tif _, err := regexp.Compile(vs); err != nil {\n\t\t\t\tf.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tf.addWhere(fmt.Sprintf(\"(%s.value IS NULL OR %[1]s.value NOT regexp ?)\", joinAs), v)\n\t\t}\n\t\th.leftJoin(f, joinAs, cc.Field)\n\tcase models.CriterionModifierIsNull:\n\t\th.leftJoin(f, joinAs, cc.Field)\n\t\tf.addWhere(fmt.Sprintf(\"%s.value IS NULL OR TRIM(%[1]s.value) = ''\", joinAs))\n\tcase models.CriterionModifierNotNull:\n\t\th.innerJoin(f, joinAs, cc.Field)\n\t\tf.addWhere(fmt.Sprintf(\"TRIM(%[1]s.value) != ''\", joinAs))\n\tcase models.CriterionModifierBetween:\n\t\tif len(cv) != 2 {\n\t\t\tf.setError(fmt.Errorf(\"expected 2 values for custom field criterion modifier BETWEEN, got %d\", len(cv)))\n\t\t\treturn\n\t\t}\n\t\th.innerJoin(f, joinAs, cc.Field)\n\t\tf.addWhere(fmt.Sprintf(\"%s.value BETWEEN ? AND ?\", joinAs), cv[0], cv[1])\n\tcase models.CriterionModifierNotBetween:\n\t\th.innerJoin(f, joinAs, cc.Field)\n\t\tf.addWhere(fmt.Sprintf(\"%s.value NOT BETWEEN ? AND ?\", joinAs), cv[0], cv[1])\n\tcase models.CriterionModifierLessThan:\n\t\tif len(cv) != 1 {\n\t\t\tf.setError(fmt.Errorf(\"expected 1 value for custom field criterion modifier LESS_THAN, got %d\", len(cv)))\n\t\t\treturn\n\t\t}\n\t\th.innerJoin(f, joinAs, cc.Field)\n\t\tf.addWhere(fmt.Sprintf(\"%s.value < ?\", joinAs), cv[0])\n\tcase models.CriterionModifierGreaterThan:\n\t\tif len(cv) != 1 {\n\t\t\tf.setError(fmt.Errorf(\"expected 1 value for custom field criterion modifier LESS_THAN, got %d\", len(cv)))\n\t\t\treturn\n\t\t}\n\t\th.innerJoin(f, joinAs, cc.Field)\n\t\tf.addWhere(fmt.Sprintf(\"%s.value > ?\", joinAs), cv[0])\n\tdefault:\n\t\tf.setError(fmt.Errorf(\"unsupported custom field criterion modifier: %s\", cc.Modifier))\n\t}\n}\n\nfunc (h *customFieldsFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tif len(h.c) == 0 {\n\t\treturn\n\t}\n\n\tfor i, cc := range h.c {\n\t\tjoin := fmt.Sprintf(\"custom_fields_%d\", i)\n\t\th.handleCriterion(f, join, cc)\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/custom_fields_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype customFieldsReaderWriter interface {\n\tmodels.CustomFieldsReader\n\tmodels.CustomFieldsWriter\n}\n\nfunc testSetCustomFields(t *testing.T, namePrefix string, store customFieldsReaderWriter, id int, origCustomFields map[string]interface{}) {\n\tgetCustomFields := func() map[string]interface{} {\n\t\tm := make(map[string]interface{})\n\t\tfor k, v := range origCustomFields {\n\t\t\tm[k] = v\n\t\t}\n\t\treturn m\n\t}\n\n\tmergeCustomFields := func(i map[string]interface{}) map[string]interface{} {\n\t\tm := getCustomFields()\n\n\t\tfor k, v := range i {\n\t\t\tm[k] = v\n\t\t}\n\t\treturn m\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tinput    models.CustomFieldsInput\n\t\texpected map[string]interface{}\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\t\"valid full\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tFull: map[string]interface{}{\n\t\t\t\t\t\"key\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"key\": \"value\",\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"valid partial\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\"key\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tmergeCustomFields(map[string]interface{}{\n\t\t\t\t\"key\": \"value\",\n\t\t\t}),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"valid partial overwrite\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\"real\": float64(4.56),\n\t\t\t\t},\n\t\t\t},\n\t\t\tmergeCustomFields(map[string]interface{}{\n\t\t\t\t\"real\": float64(4.56),\n\t\t\t}),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"valid remove\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tRemove: []string{\"real\"},\n\t\t\t},\n\t\t\tfunc() map[string]interface{} {\n\t\t\t\tm := getCustomFields()\n\t\t\t\tdelete(m, \"real\")\n\t\t\t\treturn m\n\t\t\t}(),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"leading space full\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tFull: map[string]interface{}{\n\t\t\t\t\t\" key\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"trailing space full\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tFull: map[string]interface{}{\n\t\t\t\t\t\"key \": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"leading space partial\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\" key\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"trailing space partial\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\"key \": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"big key full\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tFull: map[string]interface{}{\n\t\t\t\t\t\"12345678901234567890123456789012345678901234567890123456789012345\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"big key partial\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\"12345678901234567890123456789012345678901234567890123456789012345\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"empty key full\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tFull: map[string]interface{}{\n\t\t\t\t\t\"\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"empty key partial\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\"\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid remove full\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tFull: map[string]interface{}{\n\t\t\t\t\t\"key\": \"value\",\n\t\t\t\t},\n\t\t\t\tRemove: []string{\"key\"},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid remove partial\",\n\t\t\tmodels.CustomFieldsInput{\n\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\"real\": float64(4.56),\n\t\t\t\t},\n\t\t\t\tRemove: []string{\"real\"},\n\t\t\t},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, namePrefix+\" \"+tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\terr := store.SetCustomFields(ctx, id, tt.input)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SetCustomFields() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tactual, err := store.GetCustomFields(ctx, id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.expected, actual)\n\t\t})\n\t}\n}\n\nfunc TestPerformerSetCustomFields(t *testing.T) {\n\tperformerIdx := performerIdx1WithScene\n\n\ttestSetCustomFields(t, \"Performer\", db.Performer, performerIDs[performerIdx], getPerformerCustomFields(performerIdx))\n}\n\nfunc TestTagSetCustomFields(t *testing.T) {\n\ttagIdx := tagIdx1WithScene\n\n\ttestSetCustomFields(t, \"Tag\", db.Tag, tagIDs[tagIdx], getTagCustomFields(tagIdx))\n}\n\nfunc TestStudioSetCustomFields(t *testing.T) {\n\tstudioIdx := studioIdxWithScene\n\n\ttestSetCustomFields(t, \"Studio\", db.Studio, studioIDs[studioIdx], getStudioCustomFields(studioIdx))\n}\n\nfunc TestSceneSetCustomFields(t *testing.T) {\n\tsceneIdx := sceneIdxWithPerformer\n\n\ttestSetCustomFields(t, \"Scene\", db.Scene, sceneIDs[sceneIdx], getSceneCustomFields(sceneIdx))\n}\n\nfunc TestGallerySetCustomFields(t *testing.T) {\n\tgalleryIdx := galleryIdxWithChapters\n\n\ttestSetCustomFields(t, \"Gallery\", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx))\n}\n\nfunc TestImageSetCustomFields(t *testing.T) {\n\timageIdx := imageIdx2WithGallery\n\n\ttestSetCustomFields(t, \"Image\", db.Image, imageIDs[imageIdx], getImageCustomFields(imageIdx))\n}\n\nfunc TestGroupSetCustomFields(t *testing.T) {\n\tgroupIdx := groupIdxWithScene\n\n\ttestSetCustomFields(t, \"Group\", db.Group, groupIDs[groupIdx], getGroupCustomFields(groupIdx))\n}\n"
  },
  {
    "path": "pkg/sqlite/custom_migrations.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\n\t\"github.com/jmoiron/sqlx\"\n)\n\ntype customMigrationFunc func(ctx context.Context, db *sqlx.DB) error\n\nfunc RegisterPostMigration(schemaVersion uint, fn customMigrationFunc) {\n\tv := postMigrations[schemaVersion]\n\tv = append(v, fn)\n\tpostMigrations[schemaVersion] = v\n}\n\nfunc RegisterPreMigration(schemaVersion uint, fn customMigrationFunc) {\n\tv := preMigrations[schemaVersion]\n\tv = append(v, fn)\n\tpreMigrations[schemaVersion] = v\n}\n\nvar postMigrations = make(map[uint][]customMigrationFunc)\nvar preMigrations = make(map[uint][]customMigrationFunc)\n"
  },
  {
    "path": "pkg/sqlite/database.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"embed\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst (\n\tmaxWriteConnections = 1\n\t// Number of database read connections to use\n\t// The same value is used for both the maximum and idle limit,\n\t// to prevent opening connections on the fly which has a notieable performance penalty.\n\t// Fewer connections use less memory, more connections increase performance,\n\t// but have diminishing returns.\n\t// 10 was found to be a good tradeoff.\n\tmaxReadConnections = 10\n\t// Idle connection timeout, in seconds\n\t// Closes a connection after a period of inactivity, which saves on memory and\n\t// causes the sqlite -wal and -shm files to be automatically deleted.\n\tdbConnTimeout = 30 * time.Second\n\n\t// environment variable to set the cache size\n\tcacheSizeEnv = \"STASH_SQLITE_CACHE_SIZE\"\n)\n\nvar appSchemaVersion uint = 85\n\n//go:embed migrations/*.sql\nvar migrationsBox embed.FS\n\nvar (\n\t// ErrDatabaseNotInitialized indicates that the database is not\n\t// initialized, usually due to an incomplete configuration.\n\tErrDatabaseNotInitialized = errors.New(\"database not initialized\")\n)\n\n// ErrMigrationNeeded indicates that a database migration is needed\n// before the database can be initialized\ntype MigrationNeededError struct {\n\tCurrentSchemaVersion  uint\n\tRequiredSchemaVersion uint\n}\n\nfunc (e *MigrationNeededError) Error() string {\n\treturn fmt.Sprintf(\"database schema version %d does not match required schema version %d\", e.CurrentSchemaVersion, e.RequiredSchemaVersion)\n}\n\ntype MismatchedSchemaVersionError struct {\n\tCurrentSchemaVersion  uint\n\tRequiredSchemaVersion uint\n}\n\nfunc (e *MismatchedSchemaVersionError) Error() string {\n\treturn fmt.Sprintf(\"schema version %d is incompatible with required schema version %d\", e.CurrentSchemaVersion, e.RequiredSchemaVersion)\n}\n\ntype storeRepository struct {\n\tBlobs          *BlobStore\n\tFile           *FileStore\n\tFolder         *FolderStore\n\tImage          *ImageStore\n\tGallery        *GalleryStore\n\tGalleryChapter *GalleryChapterStore\n\tScene          *SceneStore\n\tSceneMarker    *SceneMarkerStore\n\tPerformer      *PerformerStore\n\tSavedFilter    *SavedFilterStore\n\tStudio         *StudioStore\n\tTag            *TagStore\n\tGroup          *GroupStore\n}\n\ntype Database struct {\n\t*storeRepository\n\n\treadDB  *sqlx.DB\n\twriteDB *sqlx.DB\n\tdbPath  string\n\n\tschemaVersion uint\n\n\tlockChan chan struct{}\n}\n\nfunc NewDatabase() *Database {\n\tfileStore := NewFileStore()\n\tfolderStore := NewFolderStore()\n\tgalleryStore := NewGalleryStore(fileStore, folderStore)\n\tblobStore := NewBlobStore(BlobStoreOptions{})\n\tperformerStore := NewPerformerStore(blobStore)\n\tstudioStore := NewStudioStore(blobStore)\n\ttagStore := NewTagStore(blobStore)\n\n\tr := &storeRepository{}\n\t*r = storeRepository{\n\t\tBlobs:          blobStore,\n\t\tFile:           fileStore,\n\t\tFolder:         folderStore,\n\t\tScene:          NewSceneStore(r, blobStore),\n\t\tSceneMarker:    NewSceneMarkerStore(),\n\t\tImage:          NewImageStore(r),\n\t\tGallery:        galleryStore,\n\t\tGalleryChapter: NewGalleryChapterStore(),\n\t\tPerformer:      performerStore,\n\t\tStudio:         studioStore,\n\t\tTag:            tagStore,\n\t\tGroup:          NewGroupStore(blobStore),\n\t\tSavedFilter:    NewSavedFilterStore(),\n\t}\n\n\tret := &Database{\n\t\tstoreRepository: r,\n\t\tlockChan:        make(chan struct{}, 1),\n\t}\n\n\treturn ret\n}\n\nfunc (db *Database) SetBlobStoreOptions(options BlobStoreOptions) {\n\t*db.Blobs = *NewBlobStore(options)\n}\n\n// Ready returns an error if the database is not ready to begin transactions.\nfunc (db *Database) Ready() error {\n\tif db.readDB == nil || db.writeDB == nil {\n\t\treturn ErrDatabaseNotInitialized\n\t}\n\n\treturn nil\n}\n\n// Open initializes the database. If the database is new, then it\n// performs a full migration to the latest schema version. Otherwise, any\n// necessary migrations must be run separately using RunMigrations.\n// Returns true if the database is new.\nfunc (db *Database) Open(dbPath string) error {\n\tdb.lock()\n\tdefer db.unlock()\n\n\tdb.dbPath = dbPath\n\n\tdatabaseSchemaVersion, err := db.getDatabaseSchemaVersion()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting database schema version: %w\", err)\n\t}\n\n\tdb.schemaVersion = databaseSchemaVersion\n\n\tisNew := databaseSchemaVersion == 0\n\n\tif isNew {\n\t\t// new database, just run the migrations\n\t\tif err := db.RunAllMigrations(); err != nil {\n\t\t\treturn fmt.Errorf(\"error running initial schema migrations: %w\", err)\n\t\t}\n\t} else {\n\t\tif databaseSchemaVersion > appSchemaVersion {\n\t\t\treturn &MismatchedSchemaVersionError{\n\t\t\t\tCurrentSchemaVersion:  databaseSchemaVersion,\n\t\t\t\tRequiredSchemaVersion: appSchemaVersion,\n\t\t\t}\n\t\t}\n\n\t\t// if migration is needed, then don't open the connection\n\t\tif db.needsMigration() {\n\t\t\treturn &MigrationNeededError{\n\t\t\t\tCurrentSchemaVersion:  databaseSchemaVersion,\n\t\t\t\tRequiredSchemaVersion: appSchemaVersion,\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := db.initialise(); err != nil {\n\t\treturn err\n\t}\n\n\tif isNew {\n\t\t// optimize database after migration\n\t\terr = db.Optimise(context.Background())\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"error while performing post-migration optimisation: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// lock locks the database for writing. This method will block until the lock is acquired.\nfunc (db *Database) lock() {\n\tdb.lockChan <- struct{}{}\n}\n\n// unlock unlocks the database\nfunc (db *Database) unlock() {\n\t// will block the caller if the lock is not held, so check first\n\tselect {\n\tcase <-db.lockChan:\n\t\treturn\n\tdefault:\n\t\tpanic(\"database is not locked\")\n\t}\n}\n\nfunc (db *Database) Close() error {\n\tdb.lock()\n\tdefer db.unlock()\n\n\tif db.readDB != nil {\n\t\tif err := db.readDB.Close(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdb.readDB = nil\n\t}\n\tif db.writeDB != nil {\n\t\tif err := db.writeDB.Close(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdb.writeDB = nil\n\t}\n\n\treturn nil\n}\n\nfunc (db *Database) open(disableForeignKeys bool, writable bool) (*sqlx.DB, error) {\n\t// https://github.com/mattn/go-sqlite3\n\turl := \"file:\" + db.dbPath + \"?_journal=WAL&_sync=NORMAL&_busy_timeout=50\"\n\tif !disableForeignKeys {\n\t\turl += \"&_fk=true\"\n\t}\n\n\tif writable {\n\t\turl += \"&_txlock=immediate\"\n\t} else {\n\t\turl += \"&mode=ro\"\n\t}\n\n\t// #5155 - set the cache size if the environment variable is set\n\t// default is -2000 which is 2MB\n\tif cacheSize := os.Getenv(cacheSizeEnv); cacheSize != \"\" {\n\t\turl += \"&_cache_size=\" + cacheSize\n\t}\n\n\tconn, err := sqlx.Open(sqlite3Driver, url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"db.Open(): %w\", err)\n\t}\n\n\treturn conn, nil\n}\n\nfunc (db *Database) initialise() error {\n\tif err := db.openReadDB(); err != nil {\n\t\treturn fmt.Errorf(\"opening read database: %w\", err)\n\t}\n\tif err := db.openWriteDB(); err != nil {\n\t\treturn fmt.Errorf(\"opening write database: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (db *Database) openReadDB() error {\n\tconst (\n\t\tdisableForeignKeys = false\n\t\twritable           = false\n\t)\n\tvar err error\n\tdb.readDB, err = db.open(disableForeignKeys, writable)\n\tdb.readDB.SetMaxOpenConns(maxReadConnections)\n\tdb.readDB.SetMaxIdleConns(maxReadConnections)\n\tdb.readDB.SetConnMaxIdleTime(dbConnTimeout)\n\treturn err\n}\n\nfunc (db *Database) openWriteDB() error {\n\tconst (\n\t\tdisableForeignKeys = false\n\t\twritable           = true\n\t)\n\tvar err error\n\tdb.writeDB, err = db.open(disableForeignKeys, writable)\n\tdb.writeDB.SetMaxOpenConns(maxWriteConnections)\n\tdb.writeDB.SetMaxIdleConns(maxWriteConnections)\n\tdb.writeDB.SetConnMaxIdleTime(dbConnTimeout)\n\treturn err\n}\n\nfunc (db *Database) Remove() error {\n\tdatabasePath := db.dbPath\n\terr := db.Close()\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error closing database: %w\", err)\n\t}\n\n\terr = os.Remove(databasePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error removing database: %w\", err)\n\t}\n\n\t// remove the -shm, -wal files ( if they exist )\n\twalFiles := []string{databasePath + \"-shm\", databasePath + \"-wal\"}\n\tfor _, wf := range walFiles {\n\t\tif exists, _ := fsutil.FileExists(wf); exists {\n\t\t\terr = os.Remove(wf)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error removing database: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (db *Database) Reset() error {\n\tdatabasePath := db.dbPath\n\tif err := db.Remove(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.Open(databasePath); err != nil {\n\t\treturn fmt.Errorf(\"[reset DB] unable to initialize: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// Backup the database. If db is nil, then uses the existing database\n// connection.\nfunc (db *Database) Backup(backupPath string) (err error) {\n\tthisDB := db.writeDB\n\tif thisDB == nil {\n\t\tthisDB, err = sqlx.Connect(sqlite3Driver, \"file:\"+db.dbPath+\"?_fk=true\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"open database %s failed: %w\", db.dbPath, err)\n\t\t}\n\t\tdefer thisDB.Close()\n\t}\n\n\t// if backup path is not in the same directory as the database,\n\t// then backup to the same directory first, then move to the final location.\n\t// This is to prevent errors if the backup directory is over a network share.\n\tdbDir := filepath.Dir(db.dbPath)\n\tmoveAfter := filepath.Dir(backupPath) != dbDir\n\tvacuumOut := backupPath\n\tif moveAfter {\n\t\tvacuumOut = filepath.Join(dbDir, filepath.Base(backupPath))\n\t}\n\n\tlogger.Infof(\"Backing up database into: %s\", vacuumOut)\n\t_, err = thisDB.Exec(`VACUUM INTO \"` + vacuumOut + `\"`)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"vacuum failed: %w\", err)\n\t}\n\n\tif moveAfter {\n\t\tlogger.Infof(\"Moving database backup to: %s\", backupPath)\n\t\terr = fsutil.SafeMove(vacuumOut, backupPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"moving database backup failed: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (db *Database) Anonymise(outPath string) error {\n\tanon, err := NewAnonymiser(db, outPath)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn anon.Anonymise(context.Background())\n}\n\nfunc (db *Database) RestoreFromBackup(backupPath string) error {\n\tlogger.Infof(\"Restoring backup database %s into %s\", backupPath, db.dbPath)\n\treturn os.Rename(backupPath, db.dbPath)\n}\n\nfunc (db *Database) AppSchemaVersion() uint {\n\treturn appSchemaVersion\n}\n\nfunc (db *Database) DatabasePath() string {\n\treturn db.dbPath\n}\n\nfunc (db *Database) DatabaseBackupPath(backupDirectoryPath string) string {\n\tfn := fmt.Sprintf(\"%s.%d.%s\", filepath.Base(db.dbPath), db.schemaVersion, time.Now().Format(\"20060102_150405\"))\n\n\tif backupDirectoryPath != \"\" {\n\t\treturn filepath.Join(backupDirectoryPath, fn)\n\t}\n\n\treturn fn\n}\n\nfunc (db *Database) AnonymousDatabasePath(backupDirectoryPath string) string {\n\tfn := fmt.Sprintf(\"%s.anonymous.%d.%s\", filepath.Base(db.dbPath), db.schemaVersion, time.Now().Format(\"20060102_150405\"))\n\n\tif backupDirectoryPath != \"\" {\n\t\treturn filepath.Join(backupDirectoryPath, fn)\n\t}\n\n\treturn fn\n}\n\nfunc (db *Database) Version() uint {\n\treturn db.schemaVersion\n}\n\nfunc (db *Database) Optimise(ctx context.Context) error {\n\tlogger.Info(\"Optimising database\")\n\n\terr := db.Analyze(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"performing optimization: %w\", err)\n\t}\n\n\terr = db.Vacuum(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"performing vacuum: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// Vacuum runs a VACUUM on the database, rebuilding the database file into a minimal amount of disk space.\nfunc (db *Database) Vacuum(ctx context.Context) error {\n\t_, err := db.writeDB.ExecContext(ctx, \"VACUUM\")\n\treturn err\n}\n\n// Analyze runs an ANALYZE on the database to improve query performance.\nfunc (db *Database) Analyze(ctx context.Context) error {\n\treturn analyze(ctx, db.writeDB)\n}\n\n// analyze runs an ANALYZE on the database to improve query performance.\nfunc analyze(ctx context.Context, db *sqlx.DB) error {\n\t_, err := db.ExecContext(ctx, \"ANALYZE\")\n\treturn err\n}\n\n// flushWAL flushes the Write-Ahead Log (WAL) to the main database file.\n// It also truncates the WAL file to 0 bytes.\nfunc flushWAL(ctx context.Context, db *sqlx.DB) error {\n\t_, err := db.ExecContext(ctx, \"PRAGMA wal_checkpoint(TRUNCATE)\")\n\treturn err\n}\n\nfunc (db *Database) ExecSQL(ctx context.Context, query string, args []interface{}) (*int64, *int64, error) {\n\twrapper := dbWrapperType{}\n\n\tresult, err := wrapper.Exec(ctx, query, args...)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tvar rowsAffected *int64\n\tra, err := result.RowsAffected()\n\tif err == nil {\n\t\trowsAffected = &ra\n\t}\n\n\tvar lastInsertId *int64\n\tli, err := result.LastInsertId()\n\tif err == nil {\n\t\tlastInsertId = &li\n\t}\n\n\treturn rowsAffected, lastInsertId, nil\n}\n\nfunc (db *Database) QuerySQL(ctx context.Context, query string, args []interface{}) ([]string, [][]interface{}, error) {\n\twrapper := dbWrapperType{}\n\n\trows, err := wrapper.QueryxContext(ctx, query, args...)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil, err\n\t}\n\tdefer rows.Close()\n\n\tcols, err := rows.Columns()\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tvar ret [][]interface{}\n\n\tfor rows.Next() {\n\t\trow, err := rows.SliceScan()\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tret = append(ret, row)\n\t}\n\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\treturn cols, ret, nil\n}\n"
  },
  {
    "path": "pkg/sqlite/date.go",
    "content": "package sqlite\n\nimport (\n\t\"database/sql/driver\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"gopkg.in/guregu/null.v4\"\n)\n\nconst sqliteDateLayout = \"2006-01-02\"\n\n// Date represents a date stored as \"YYYY-MM-DD\"\ntype Date struct {\n\tDate time.Time\n}\n\n// Scan implements the Scanner interface.\nfunc (d *Date) Scan(value interface{}) error {\n\td.Date = value.(time.Time)\n\treturn nil\n}\n\n// Value implements the driver Valuer interface.\nfunc (d Date) Value() (driver.Value, error) {\n\treturn d.Date.Format(sqliteDateLayout), nil\n}\n\n// NullDate represents a nullable date stored as \"YYYY-MM-DD\"\ntype NullDate struct {\n\tDate  time.Time\n\tValid bool\n}\n\n// Scan implements the Scanner interface.\nfunc (d *NullDate) Scan(value interface{}) error {\n\tvar ok bool\n\td.Date, ok = value.(time.Time)\n\tif !ok {\n\t\td.Date = time.Time{}\n\t\td.Valid = false\n\t\treturn nil\n\t}\n\n\td.Valid = true\n\treturn nil\n}\n\n// Value implements the driver Valuer interface.\nfunc (d NullDate) Value() (driver.Value, error) {\n\tif !d.Valid {\n\t\treturn nil, nil\n\t}\n\n\treturn d.Date.Format(sqliteDateLayout), nil\n}\n\nfunc (d *NullDate) DatePtr(precision null.Int) *models.Date {\n\tif d == nil || !d.Valid {\n\t\treturn nil\n\t}\n\n\treturn &models.Date{Time: d.Date, Precision: models.DatePrecision(precision.Int64)}\n}\n\nfunc NullDateFromDatePtr(d *models.Date) NullDate {\n\tif d == nil {\n\t\treturn NullDate{Valid: false}\n\t}\n\treturn NullDate{Date: d.Time, Valid: true}\n}\n\nfunc datePrecisionFromDatePtr(d *models.Date) null.Int {\n\tif d == nil {\n\t\t// default to day precision\n\t\treturn null.Int{}\n\t}\n\treturn null.IntFrom(int64(d.Precision))\n}\n"
  },
  {
    "path": "pkg/sqlite/doc.go",
    "content": "// Package sqlite provides interfaces to interact with the sqlite database.\npackage sqlite\n"
  },
  {
    "path": "pkg/sqlite/driver.go",
    "content": "package sqlite\n\nimport (\n\t\"database/sql\"\n\t\"database/sql/driver\"\n\t\"fmt\"\n\n\t\"github.com/WithoutPants/sortorder/casefolded\"\n\tsqlite3 \"github.com/mattn/go-sqlite3\"\n)\n\nconst sqlite3Driver = \"sqlite3ex\"\n\nfunc init() {\n\t// register custom driver\n\tsql.Register(sqlite3Driver, &CustomSQLiteDriver{})\n}\n\ntype CustomSQLiteDriver struct{}\n\ntype CustomSQLiteConn struct {\n\t*sqlite3.SQLiteConn\n}\n\nfunc (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) {\n\tsqlite3Driver := &sqlite3.SQLiteDriver{\n\t\tConnectHook: func(conn *sqlite3.SQLiteConn) error {\n\t\t\tfuncs := map[string]interface{}{\n\t\t\t\t\"regexp\":            regexFn,\n\t\t\t\t\"durationToTinyInt\": durationToTinyIntFn,\n\t\t\t\t\"basename\":          basenameFn,\n\t\t\t\t\"phash_distance\":    phashDistanceFn,\n\t\t\t}\n\n\t\t\tfor name, fn := range funcs {\n\t\t\t\tif err := conn.RegisterFunc(name, fn, true); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error registering function %s: %v\", name, err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// COLLATE NATURAL_CI - Case insensitive natural sort\n\t\t\terr := conn.RegisterCollation(\"NATURAL_CI\", func(s string, s2 string) int {\n\t\t\t\tif casefolded.NaturalLess(s, s2) {\n\t\t\t\t\treturn -1\n\t\t\t\t} else {\n\t\t\t\t\treturn 1\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error registering natural sort collation: %v\", err)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tconn, err := sqlite3Driver.Open(dsn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &CustomSQLiteConn{conn.(*sqlite3.SQLiteConn)}, nil\n}\n\nfunc (c *CustomSQLiteConn) Close() error {\n\tconn := c.SQLiteConn\n\n\t_, _ = conn.Exec(\"PRAGMA analysis_limit=1000; PRAGMA optimize;\", []driver.Value{})\n\n\treturn conn.Close()\n}\n"
  },
  {
    "path": "pkg/sqlite/file.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"gopkg.in/guregu/null.v4\"\n)\n\nconst (\n\tfileTable      = \"files\"\n\tvideoFileTable = \"video_files\"\n\timageFileTable = \"image_files\"\n\tfileIDColumn   = \"file_id\"\n\n\tvideoCaptionsTable    = \"video_captions\"\n\tcaptionCodeColumn     = \"language_code\"\n\tcaptionFilenameColumn = \"filename\"\n\tcaptionTypeColumn     = \"caption_type\"\n)\n\ntype basicFileRow struct {\n\tID             models.FileID   `db:\"id\" goqu:\"skipinsert\"`\n\tBasename       string          `db:\"basename\"`\n\tZipFileID      null.Int        `db:\"zip_file_id\"`\n\tParentFolderID models.FolderID `db:\"parent_folder_id\"`\n\tSize           int64           `db:\"size\"`\n\tModTime        Timestamp       `db:\"mod_time\"`\n\tCreatedAt      Timestamp       `db:\"created_at\"`\n\tUpdatedAt      Timestamp       `db:\"updated_at\"`\n}\n\nfunc (r *basicFileRow) fromBasicFile(o models.BaseFile) {\n\tr.ID = o.ID\n\tr.Basename = o.Basename\n\tr.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID)\n\tr.ParentFolderID = o.ParentFolderID\n\tr.Size = o.Size\n\tr.ModTime = Timestamp{Timestamp: o.ModTime}\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n}\n\ntype videoFileRow struct {\n\tFileID           models.FileID `db:\"file_id\"`\n\tFormat           string        `db:\"format\"`\n\tWidth            int           `db:\"width\"`\n\tHeight           int           `db:\"height\"`\n\tDuration         float64       `db:\"duration\"`\n\tVideoCodec       string        `db:\"video_codec\"`\n\tAudioCodec       string        `db:\"audio_codec\"`\n\tFrameRate        float64       `db:\"frame_rate\"`\n\tBitRate          int64         `db:\"bit_rate\"`\n\tInteractive      bool          `db:\"interactive\"`\n\tInteractiveSpeed null.Int      `db:\"interactive_speed\"`\n}\n\nfunc (f *videoFileRow) fromVideoFile(ff models.VideoFile) {\n\tf.FileID = ff.ID\n\tf.Format = ff.Format\n\tf.Width = ff.Width\n\tf.Height = ff.Height\n\tf.Duration = ff.Duration\n\tf.VideoCodec = ff.VideoCodec\n\tf.AudioCodec = ff.AudioCodec\n\tf.FrameRate = ff.FrameRate\n\tf.BitRate = ff.BitRate\n\tf.Interactive = ff.Interactive\n\tf.InteractiveSpeed = intFromPtr(ff.InteractiveSpeed)\n}\n\ntype imageFileRow struct {\n\tFileID models.FileID `db:\"file_id\"`\n\tFormat string        `db:\"format\"`\n\tWidth  int           `db:\"width\"`\n\tHeight int           `db:\"height\"`\n}\n\nfunc (f *imageFileRow) fromImageFile(ff models.ImageFile) {\n\tf.FileID = ff.ID\n\tf.Format = ff.Format\n\tf.Width = ff.Width\n\tf.Height = ff.Height\n}\n\n// we redefine this to change the columns around\n// otherwise, we collide with the image file columns\ntype videoFileQueryRow struct {\n\tFileID           null.Int    `db:\"file_id_video\"`\n\tFormat           null.String `db:\"video_format\"`\n\tWidth            null.Int    `db:\"video_width\"`\n\tHeight           null.Int    `db:\"video_height\"`\n\tDuration         null.Float  `db:\"duration\"`\n\tVideoCodec       null.String `db:\"video_codec\"`\n\tAudioCodec       null.String `db:\"audio_codec\"`\n\tFrameRate        null.Float  `db:\"frame_rate\"`\n\tBitRate          null.Int    `db:\"bit_rate\"`\n\tInteractive      null.Bool   `db:\"interactive\"`\n\tInteractiveSpeed null.Int    `db:\"interactive_speed\"`\n}\n\nfunc (f *videoFileQueryRow) resolve() *models.VideoFile {\n\treturn &models.VideoFile{\n\t\tFormat:           f.Format.String,\n\t\tWidth:            int(f.Width.Int64),\n\t\tHeight:           int(f.Height.Int64),\n\t\tDuration:         f.Duration.Float64,\n\t\tVideoCodec:       f.VideoCodec.String,\n\t\tAudioCodec:       f.AudioCodec.String,\n\t\tFrameRate:        f.FrameRate.Float64,\n\t\tBitRate:          f.BitRate.Int64,\n\t\tInteractive:      f.Interactive.Bool,\n\t\tInteractiveSpeed: nullIntPtr(f.InteractiveSpeed),\n\t}\n}\n\nfunc videoFileQueryColumns() []interface{} {\n\ttable := videoFileTableMgr.table\n\treturn []interface{}{\n\t\ttable.Col(\"file_id\").As(\"file_id_video\"),\n\t\ttable.Col(\"format\").As(\"video_format\"),\n\t\ttable.Col(\"width\").As(\"video_width\"),\n\t\ttable.Col(\"height\").As(\"video_height\"),\n\t\ttable.Col(\"duration\"),\n\t\ttable.Col(\"video_codec\"),\n\t\ttable.Col(\"audio_codec\"),\n\t\ttable.Col(\"frame_rate\"),\n\t\ttable.Col(\"bit_rate\"),\n\t\ttable.Col(\"interactive\"),\n\t\ttable.Col(\"interactive_speed\"),\n\t}\n}\n\n// we redefine this to change the columns around\n// otherwise, we collide with the video file columns\ntype imageFileQueryRow struct {\n\tFormat null.String `db:\"image_format\"`\n\tWidth  null.Int    `db:\"image_width\"`\n\tHeight null.Int    `db:\"image_height\"`\n}\n\nfunc (imageFileQueryRow) columns(table *table) []interface{} {\n\tex := table.table\n\treturn []interface{}{\n\t\tex.Col(\"format\").As(\"image_format\"),\n\t\tex.Col(\"width\").As(\"image_width\"),\n\t\tex.Col(\"height\").As(\"image_height\"),\n\t}\n}\n\nfunc (f *imageFileQueryRow) resolve() *models.ImageFile {\n\treturn &models.ImageFile{\n\t\tFormat: f.Format.String,\n\t\tWidth:  int(f.Width.Int64),\n\t\tHeight: int(f.Height.Int64),\n\t}\n}\n\ntype fileQueryRow struct {\n\tFileID         null.Int      `db:\"file_id\"`\n\tBasename       null.String   `db:\"basename\"`\n\tZipFileID      null.Int      `db:\"zip_file_id\"`\n\tParentFolderID null.Int      `db:\"parent_folder_id\"`\n\tSize           null.Int      `db:\"size\"`\n\tModTime        NullTimestamp `db:\"mod_time\"`\n\tCreatedAt      NullTimestamp `db:\"file_created_at\"`\n\tUpdatedAt      NullTimestamp `db:\"file_updated_at\"`\n\n\tZipBasename   null.String `db:\"zip_basename\"`\n\tZipFolderPath null.String `db:\"zip_folder_path\"`\n\tZipSize       null.Int    `db:\"zip_size\"`\n\n\tFolderPath null.String `db:\"parent_folder_path\"`\n\tfingerprintQueryRow\n\tvideoFileQueryRow\n\timageFileQueryRow\n}\n\nfunc (r *fileQueryRow) resolve() models.File {\n\tbasic := &models.BaseFile{\n\t\tID: models.FileID(r.FileID.Int64),\n\t\tDirEntry: models.DirEntry{\n\t\t\tZipFileID: nullIntFileIDPtr(r.ZipFileID),\n\t\t\tModTime:   r.ModTime.Timestamp,\n\t\t},\n\t\tPath:           filepath.Join(r.FolderPath.String, r.Basename.String),\n\t\tParentFolderID: models.FolderID(r.ParentFolderID.Int64),\n\t\tBasename:       r.Basename.String,\n\t\tSize:           r.Size.Int64,\n\t\tCreatedAt:      r.CreatedAt.Timestamp,\n\t\tUpdatedAt:      r.UpdatedAt.Timestamp,\n\t}\n\n\tif basic.ZipFileID != nil && r.ZipFolderPath.Valid && r.ZipBasename.Valid {\n\t\tbasic.ZipFile = &models.BaseFile{\n\t\t\tID:       *basic.ZipFileID,\n\t\t\tPath:     filepath.Join(r.ZipFolderPath.String, r.ZipBasename.String),\n\t\t\tBasename: r.ZipBasename.String,\n\t\t\tSize:     r.ZipSize.Int64,\n\t\t}\n\t}\n\n\tvar ret models.File = basic\n\n\tif r.videoFileQueryRow.Format.Valid {\n\t\tvf := r.videoFileQueryRow.resolve()\n\t\tvf.BaseFile = basic\n\t\tret = vf\n\t}\n\n\tif r.imageFileQueryRow.Format.Valid {\n\t\timf := r.imageFileQueryRow.resolve()\n\t\timf.BaseFile = basic\n\t\tret = imf\n\t}\n\n\tr.appendRelationships(basic)\n\n\treturn ret\n}\n\nfunc appendFingerprintsUnique(vs []models.Fingerprint, v ...models.Fingerprint) []models.Fingerprint {\n\tfor _, vv := range v {\n\t\tfound := false\n\t\tfor _, vsv := range vs {\n\t\t\tif vsv.Type == vv.Type {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\tvs = append(vs, vv)\n\t\t}\n\t}\n\treturn vs\n}\n\nfunc (r *fileQueryRow) appendRelationships(i *models.BaseFile) {\n\tif r.fingerprintQueryRow.valid() {\n\t\ti.Fingerprints = appendFingerprintsUnique(i.Fingerprints, r.fingerprintQueryRow.resolve())\n\t}\n}\n\ntype fileQueryRows []fileQueryRow\n\nfunc (r fileQueryRows) resolve() []models.File {\n\tvar ret []models.File\n\tvar last models.File\n\tvar lastID models.FileID\n\n\tfor _, row := range r {\n\t\tif last == nil || lastID != models.FileID(row.FileID.Int64) {\n\t\t\tf := row.resolve()\n\t\t\tlast = f\n\t\t\tlastID = models.FileID(row.FileID.Int64)\n\t\t\tret = append(ret, last)\n\t\t\tcontinue\n\t\t}\n\n\t\t// must be merging with previous row\n\t\trow.appendRelationships(last.Base())\n\t}\n\n\treturn ret\n}\n\ntype fileRepositoryType struct {\n\trepository\n\tscenes    joinRepository\n\timages    joinRepository\n\tgalleries joinRepository\n}\n\nvar (\n\tfileRepository = fileRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: fileTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\tscenes: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: scenesFilesTable,\n\t\t\t\tidColumn:  fileIDColumn,\n\t\t\t},\n\t\t\tfkColumn: sceneIDColumn,\n\t\t},\n\t\timages: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: imagesFilesTable,\n\t\t\t\tidColumn:  fileIDColumn,\n\t\t\t},\n\t\t\tfkColumn: imageIDColumn,\n\t\t},\n\t\tgalleries: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: galleriesFilesTable,\n\t\t\t\tidColumn:  fileIDColumn,\n\t\t\t},\n\t\t\tfkColumn: galleryIDColumn,\n\t\t},\n\t}\n)\n\ntype FileStore struct {\n\trepository\n\n\ttableMgr *table\n}\n\nfunc NewFileStore() *FileStore {\n\treturn &FileStore{\n\t\trepository: repository{\n\t\t\ttableName: fileTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\n\t\ttableMgr: fileTableMgr,\n\t}\n}\n\nfunc (qb *FileStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *FileStore) Create(ctx context.Context, f models.File) error {\n\tvar r basicFileRow\n\tr.fromBasicFile(*f.Base())\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfileID := models.FileID(id)\n\n\t// create extended stuff here\n\tswitch ef := f.(type) {\n\tcase *models.VideoFile:\n\t\tif err := qb.createVideoFile(ctx, fileID, *ef); err != nil {\n\t\t\treturn err\n\t\t}\n\tcase *models.ImageFile:\n\t\tif err := qb.createImageFile(ctx, fileID, *ef); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := FingerprintReaderWriter.insertJoins(ctx, fileID, f.Base().Fingerprints); err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.Find(ctx, fileID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\tbase := f.Base()\n\t*base = *updated[0].Base()\n\n\treturn nil\n}\n\nfunc (qb *FileStore) Update(ctx context.Context, f models.File) error {\n\tvar r basicFileRow\n\tr.fromBasicFile(*f.Base())\n\n\tid := f.Base().ID\n\n\tif err := qb.tableMgr.updateByID(ctx, id, r); err != nil {\n\t\treturn err\n\t}\n\n\t// create extended stuff here\n\tswitch ef := f.(type) {\n\tcase *models.VideoFile:\n\t\tif err := qb.updateOrCreateVideoFile(ctx, id, *ef); err != nil {\n\t\t\treturn err\n\t\t}\n\tcase *models.ImageFile:\n\t\tif err := qb.updateOrCreateImageFile(ctx, id, *ef); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := FingerprintReaderWriter.replaceJoins(ctx, id, f.Base().Fingerprints); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// ModifyFingerprints updates existing fingerprints and adds new ones.\nfunc (qb *FileStore) ModifyFingerprints(ctx context.Context, fileID models.FileID, fingerprints []models.Fingerprint) error {\n\treturn FingerprintReaderWriter.upsertJoins(ctx, fileID, fingerprints)\n}\n\nfunc (qb *FileStore) DestroyFingerprints(ctx context.Context, fileID models.FileID, types []string) error {\n\treturn FingerprintReaderWriter.destroyJoins(ctx, fileID, types)\n}\n\nfunc (qb *FileStore) Destroy(ctx context.Context, id models.FileID) error {\n\treturn qb.tableMgr.destroyExisting(ctx, []int{int(id)})\n}\n\nfunc (qb *FileStore) createVideoFile(ctx context.Context, id models.FileID, f models.VideoFile) error {\n\tvar r videoFileRow\n\tr.fromVideoFile(f)\n\tr.FileID = id\n\tif _, err := videoFileTableMgr.insert(ctx, r); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *FileStore) updateOrCreateVideoFile(ctx context.Context, id models.FileID, f models.VideoFile) error {\n\texists, err := videoFileTableMgr.idExists(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !exists {\n\t\treturn qb.createVideoFile(ctx, id, f)\n\t}\n\n\tvar r videoFileRow\n\tr.fromVideoFile(f)\n\tr.FileID = id\n\tif err := videoFileTableMgr.updateByID(ctx, id, r); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *FileStore) createImageFile(ctx context.Context, id models.FileID, f models.ImageFile) error {\n\tvar r imageFileRow\n\tr.fromImageFile(f)\n\tr.FileID = id\n\tif _, err := imageFileTableMgr.insert(ctx, r); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *FileStore) updateOrCreateImageFile(ctx context.Context, id models.FileID, f models.ImageFile) error {\n\texists, err := imageFileTableMgr.idExists(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !exists {\n\t\treturn qb.createImageFile(ctx, id, f)\n\t}\n\n\tvar r imageFileRow\n\tr.fromImageFile(f)\n\tr.FileID = id\n\tif err := imageFileTableMgr.updateByID(ctx, id, r); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *FileStore) selectDataset() *goqu.SelectDataset {\n\ttable := qb.table()\n\n\tfolderTable := folderTableMgr.table\n\tfingerprintTable := fingerprintTableMgr.table\n\tvideoFileTable := videoFileTableMgr.table\n\timageFileTable := imageFileTableMgr.table\n\n\tzipFileTable := table.As(\"zip_files\")\n\tzipFolderTable := folderTable.As(\"zip_files_folders\")\n\n\tcols := []interface{}{\n\t\ttable.Col(\"id\").As(\"file_id\"),\n\t\ttable.Col(\"basename\"),\n\t\ttable.Col(\"zip_file_id\"),\n\t\ttable.Col(\"parent_folder_id\"),\n\t\ttable.Col(\"size\"),\n\t\ttable.Col(\"mod_time\"),\n\t\ttable.Col(\"created_at\").As(\"file_created_at\"),\n\t\ttable.Col(\"updated_at\").As(\"file_updated_at\"),\n\t\tfolderTable.Col(\"path\").As(\"parent_folder_path\"),\n\t\tfingerprintTable.Col(\"type\").As(\"fingerprint_type\"),\n\t\tfingerprintTable.Col(\"fingerprint\"),\n\t\tzipFileTable.Col(\"basename\").As(\"zip_basename\"),\n\t\tzipFolderTable.Col(\"path\").As(\"zip_folder_path\"),\n\t\t// size is needed to open containing zip files\n\t\tzipFileTable.Col(\"size\").As(\"zip_size\"),\n\t}\n\n\tcols = append(cols, videoFileQueryColumns()...)\n\tcols = append(cols, imageFileQueryRow{}.columns(imageFileTableMgr)...)\n\n\tret := dialect.From(table).Select(cols...)\n\n\treturn ret.InnerJoin(\n\t\tfolderTable,\n\t\tgoqu.On(table.Col(\"parent_folder_id\").Eq(folderTable.Col(idColumn))),\n\t).LeftJoin(\n\t\tfingerprintTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(fingerprintTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\tvideoFileTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\timageFileTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\tzipFileTable,\n\t\tgoqu.On(table.Col(\"zip_file_id\").Eq(zipFileTable.Col(\"id\"))),\n\t).LeftJoin(\n\t\tzipFolderTable,\n\t\tgoqu.On(zipFileTable.Col(\"parent_folder_id\").Eq(zipFolderTable.Col(idColumn))),\n\t)\n}\n\nfunc (qb *FileStore) countDataset() *goqu.SelectDataset {\n\ttable := qb.table()\n\n\tfolderTable := folderTableMgr.table\n\tfingerprintTable := fingerprintTableMgr.table\n\tvideoFileTable := videoFileTableMgr.table\n\timageFileTable := imageFileTableMgr.table\n\n\tzipFileTable := table.As(\"zip_files\")\n\tzipFolderTable := folderTable.As(\"zip_files_folders\")\n\n\tret := dialect.From(table).Select(goqu.COUNT(goqu.DISTINCT(table.Col(\"id\"))))\n\n\treturn ret.InnerJoin(\n\t\tfolderTable,\n\t\tgoqu.On(table.Col(\"parent_folder_id\").Eq(folderTable.Col(idColumn))),\n\t).LeftJoin(\n\t\tfingerprintTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(fingerprintTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\tvideoFileTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\timageFileTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\tzipFileTable,\n\t\tgoqu.On(table.Col(\"zip_file_id\").Eq(zipFileTable.Col(\"id\"))),\n\t).LeftJoin(\n\t\tzipFolderTable,\n\t\tgoqu.On(zipFileTable.Col(\"parent_folder_id\").Eq(zipFolderTable.Col(idColumn))),\n\t)\n}\n\nfunc (qb *FileStore) get(ctx context.Context, q *goqu.SelectDataset) (models.File, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *FileStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]models.File, error) {\n\tconst single = false\n\tvar rows fileQueryRows\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f fileQueryRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trows = append(rows, f)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn rows.resolve(), nil\n}\n\nfunc (qb *FileStore) Find(ctx context.Context, ids ...models.FileID) ([]models.File, error) {\n\tvar files []models.File\n\tfor _, id := range ids {\n\t\tfile, err := qb.find(ctx, id)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif file == nil {\n\t\t\treturn nil, fmt.Errorf(\"file with id %d not found\", id)\n\t\t}\n\n\t\tfiles = append(files, file)\n\t}\n\n\treturn files, nil\n}\n\nfunc (qb *FileStore) find(ctx context.Context, id models.FileID) (models.File, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting file by id %d: %w\", id, err)\n\t}\n\n\treturn ret, nil\n}\n\n// FindByPath returns the first file that matches the given path. Wildcard characters are supported.\nfunc (qb *FileStore) FindByPath(ctx context.Context, p string, caseSensitive bool) (models.File, error) {\n\n\tret, err := qb.FindAllByPath(ctx, p, caseSensitive)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn ret[0], nil\n}\n\n// FindAllByPath returns all the files that match the given path.\n// Wildcard characters are supported.\nfunc (qb *FileStore) FindAllByPath(ctx context.Context, p string, caseSensitive bool) ([]models.File, error) {\n\t// separate basename from path\n\tbasename := filepath.Base(p)\n\tdirName := filepath.Dir(p)\n\n\t// replace wildcards\n\tbasename = strings.ReplaceAll(basename, \"*\", \"%\")\n\tdirName = strings.ReplaceAll(dirName, \"*\", \"%\")\n\n\ttable := qb.table()\n\tfolderTable := folderTableMgr.table\n\n\t// like uses case-insensitive matching. Only use like if wildcards are used\n\tq := qb.selectDataset().Prepared(true)\n\n\tif strings.Contains(basename, \"%\") || strings.Contains(dirName, \"%\") || !caseSensitive {\n\t\tq = q.Where(\n\t\t\tfolderTable.Col(\"path\").Like(dirName),\n\t\t\ttable.Col(\"basename\").Like(basename),\n\t\t)\n\t} else {\n\t\tq = q.Where(\n\t\t\tfolderTable.Col(\"path\").Eq(dirName),\n\t\t\ttable.Col(\"basename\").Eq(basename),\n\t\t)\n\t}\n\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, fmt.Errorf(\"getting file by path %s: %w\", p, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *FileStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset {\n\tfolderTable := folderTableMgr.table\n\n\tvar conds []exp.Expression\n\tfor _, pp := range p {\n\t\tppWildcard := pp + string(filepath.Separator) + \"%\"\n\n\t\tconds = append(conds, folderTable.Col(\"path\").Eq(pp), folderTable.Col(\"path\").Like(ppWildcard))\n\t}\n\n\treturn q.Where(\n\t\tgoqu.Or(conds...),\n\t)\n}\n\n// FindAllByPaths returns the all files that are within any of the given paths.\n// Returns all if limit is < 0.\n// Returns all files if p is empty.\nfunc (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]models.File, error) {\n\ttable := qb.table()\n\tfolderTable := folderTableMgr.table\n\n\tq := dialect.From(table).Prepared(true).InnerJoin(\n\t\tfolderTable,\n\t\tgoqu.On(table.Col(\"parent_folder_id\").Eq(folderTable.Col(idColumn))),\n\t).Select(table.Col(idColumn))\n\n\tq = qb.allInPaths(q, p)\n\n\tif !includeZipContents {\n\t\tq = q.Where(table.Col(\"zip_file_id\").IsNull())\n\t}\n\n\tif limit > -1 {\n\t\tq = q.Limit(uint(limit))\n\t}\n\n\tq = q.Offset(uint(offset))\n\n\tret, err := qb.findBySubquery(ctx, q)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, fmt.Errorf(\"getting files by path %s: %w\", p, err)\n\t}\n\n\treturn ret, nil\n}\n\n// CountAllInPaths returns a count of all files that are within any of the given paths.\n// Returns count of all files if p is empty.\nfunc (qb *FileStore) CountAllInPaths(ctx context.Context, p []string) (int, error) {\n\tq := qb.countDataset().Prepared(true)\n\tq = qb.allInPaths(q, p)\n\n\treturn count(ctx, q)\n}\n\nfunc (qb *FileStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]models.File, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().Prepared(true).Where(\n\t\ttable.Col(idColumn).Eq(\n\t\t\tsq,\n\t\t),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\nfunc (qb *FileStore) FindByFingerprint(ctx context.Context, fp models.Fingerprint) ([]models.File, error) {\n\tfingerprintTable := fingerprintTableMgr.table\n\n\tfingerprints := fingerprintTable.As(\"fp\")\n\n\tsq := dialect.From(fingerprints).Select(fingerprints.Col(fileIDColumn)).Where(\n\t\tfingerprints.Col(\"type\").Eq(fp.Type),\n\t\tfingerprints.Col(\"fingerprint\").Eq(fp.Fingerprint),\n\t)\n\n\treturn qb.findBySubquery(ctx, sq)\n}\n\nfunc (qb *FileStore) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]models.File, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().Prepared(true).Where(\n\t\ttable.Col(\"zip_file_id\").Eq(zipFileID),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\n// FindByFileInfo finds files that match the base name, size, and mod time of the given file.\nfunc (qb *FileStore) FindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]models.File, error) {\n\ttable := qb.table()\n\n\tmodTime := info.ModTime().Format(time.RFC3339)\n\n\tq := qb.selectDataset().Prepared(true).Where(\n\t\ttable.Col(\"basename\").Eq(info.Name()),\n\t\ttable.Col(\"size\").Eq(size),\n\t\ttable.Col(\"mod_time\").Eq(modTime),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\nfunc (qb *FileStore) CountByFolderID(ctx context.Context, folderID models.FolderID) (int, error) {\n\ttable := qb.table()\n\n\tq := qb.countDataset().Prepared(true).Where(\n\t\ttable.Col(\"parent_folder_id\").Eq(folderID),\n\t)\n\n\treturn count(ctx, q)\n}\n\nfunc (qb *FileStore) IsPrimary(ctx context.Context, fileID models.FileID) (bool, error) {\n\tjoinTables := []exp.IdentifierExpression{\n\t\tscenesFilesJoinTable,\n\t\tgalleriesFilesJoinTable,\n\t\timagesFilesJoinTable,\n\t}\n\n\tvar sq *goqu.SelectDataset\n\n\tfor _, t := range joinTables {\n\t\tqq := dialect.From(t).Select(t.Col(fileIDColumn)).Where(\n\t\t\tt.Col(fileIDColumn).Eq(fileID),\n\t\t\tt.Col(\"primary\").Eq(1),\n\t\t)\n\n\t\tif sq == nil {\n\t\t\tsq = qq\n\t\t} else {\n\t\t\tsq = sq.Union(qq)\n\t\t}\n\t}\n\n\tq := dialect.Select(goqu.COUNT(\"*\").As(\"count\")).Prepared(true).From(\n\t\tsq,\n\t)\n\n\tvar ret int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn ret > 0, nil\n}\n\nfunc (qb *FileStore) validateFilter(fileFilter *models.FileFilterType) error {\n\tconst and = \"AND\"\n\tconst or = \"OR\"\n\tconst not = \"NOT\"\n\n\tif fileFilter.And != nil {\n\t\tif fileFilter.Or != nil {\n\t\t\treturn illegalFilterCombination(and, or)\n\t\t}\n\t\tif fileFilter.Not != nil {\n\t\t\treturn illegalFilterCombination(and, not)\n\t\t}\n\n\t\treturn qb.validateFilter(fileFilter.And)\n\t}\n\n\tif fileFilter.Or != nil {\n\t\tif fileFilter.Not != nil {\n\t\t\treturn illegalFilterCombination(or, not)\n\t\t}\n\n\t\treturn qb.validateFilter(fileFilter.Or)\n\t}\n\n\tif fileFilter.Not != nil {\n\t\treturn qb.validateFilter(fileFilter.Not)\n\t}\n\n\treturn nil\n}\n\nfunc (qb *FileStore) makeFilter(ctx context.Context, fileFilter *models.FileFilterType) *filterBuilder {\n\tquery := &filterBuilder{}\n\n\tif fileFilter.And != nil {\n\t\tquery.and(qb.makeFilter(ctx, fileFilter.And))\n\t}\n\tif fileFilter.Or != nil {\n\t\tquery.or(qb.makeFilter(ctx, fileFilter.Or))\n\t}\n\tif fileFilter.Not != nil {\n\t\tquery.not(qb.makeFilter(ctx, fileFilter.Not))\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &fileFilterHandler{\n\t\tfileFilter: fileFilter,\n\t})\n\n\treturn filter\n}\n\nfunc (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) {\n\tfileFilter := options.FileFilter\n\tfindFilter := options.FindFilter\n\n\tif fileFilter == nil {\n\t\tfileFilter = &models.FileFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := qb.newQuery()\n\tquery.join(folderTable, \"\", \"files.parent_folder_id = folders.id\")\n\n\tdistinctIDs(&query, fileTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tfilepathColumn := \"folders.path || '\" + string(filepath.Separator) + \"' || files.basename\"\n\t\tsearchColumns := []string{filepathColumn}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tif err := qb.validateFilter(fileFilter); err != nil {\n\t\treturn nil, err\n\t}\n\tfilter := qb.makeFilter(ctx, fileFilter)\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.setQuerySort(&query, findFilter); err != nil {\n\t\treturn nil, err\n\t}\n\tquery.sortAndPagination += getPagination(findFilter)\n\n\tresult, err := qb.queryGroupedFields(ctx, options, query)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error querying aggregate fields: %w\", err)\n\t}\n\n\tidsResult, err := query.findIDs(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding IDs: %w\", err)\n\t}\n\n\tresult.IDs = make([]models.FileID, len(idsResult))\n\tfor i, id := range idsResult {\n\t\tresult.IDs[i] = models.FileID(id)\n\t}\n\n\treturn result, nil\n}\n\nfunc (qb *FileStore) queryGroupedFields(ctx context.Context, options models.FileQueryOptions, query queryBuilder) (*models.FileQueryResult, error) {\n\tif !options.Count && !options.TotalDuration && !options.Megapixels && !options.TotalSize {\n\t\t// nothing to do - return empty result\n\t\treturn models.NewFileQueryResult(qb), nil\n\t}\n\n\taggregateQuery := qb.newQuery()\n\n\tif options.Count {\n\t\taggregateQuery.addColumn(\"COUNT(DISTINCT temp.id) as total\")\n\t}\n\n\tif options.TotalDuration {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    videoFileTable,\n\t\t\t\tonClause: \"files.id = video_files.file_id\",\n\t\t\t},\n\t\t)\n\t\tquery.addColumn(\"COALESCE(video_files.duration, 0) as duration\")\n\t\taggregateQuery.addColumn(\"COALESCE(SUM(temp.duration), 0) as duration\")\n\t}\n\tif options.Megapixels {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    imageFileTable,\n\t\t\t\tonClause: \"files.id = image_files.file_id\",\n\t\t\t},\n\t\t)\n\t\tquery.addColumn(\"COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels\")\n\t\taggregateQuery.addColumn(\"COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels\")\n\t}\n\n\tif options.TotalSize {\n\t\tquery.addColumn(\"COALESCE(files.size, 0) as size\")\n\t\taggregateQuery.addColumn(\"COALESCE(SUM(temp.size), 0) as size\")\n\t}\n\n\tconst includeSortPagination = false\n\taggregateQuery.from = fmt.Sprintf(\"(%s) as temp\", query.toSQL(includeSortPagination))\n\n\tout := struct {\n\t\tTotal      int\n\t\tDuration   float64\n\t\tMegapixels float64\n\t\tSize       int64\n\t}{}\n\tif err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := models.NewFileQueryResult(qb)\n\tret.Count = out.Total\n\tret.Megapixels = out.Megapixels\n\tret.TotalDuration = out.Duration\n\tret.TotalSize = out.Size\n\n\treturn ret, nil\n}\n\nvar fileSortOptions = sortOptions{\n\t\"created_at\",\n\t\"id\",\n\t\"path\",\n\t\"random\",\n\t\"updated_at\",\n}\n\nfunc (qb *FileStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error {\n\tif findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == \"\" {\n\t\treturn nil\n\t}\n\tsort := findFilter.GetSort(\"path\")\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := fileSortOptions.validateSort(sort); err != nil {\n\t\treturn err\n\t}\n\n\tdirection := findFilter.GetDirection()\n\tswitch sort {\n\tcase \"path\":\n\t\t// special handling for path\n\t\tquery.sortAndPagination += fmt.Sprintf(\" ORDER BY folders.path %s, files.basename %[1]s\", direction)\n\tdefault:\n\t\tquery.sortAndPagination += getSort(sort, direction, \"files\")\n\t}\n\n\treturn nil\n}\n\nfunc (qb *FileStore) captionRepository() *captionRepository {\n\treturn &captionRepository{\n\t\trepository: repository{\n\t\t\ttableName: videoCaptionsTable,\n\t\t\tidColumn:  fileIDColumn,\n\t\t},\n\t}\n}\n\nfunc (qb *FileStore) GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error) {\n\treturn qb.captionRepository().get(ctx, fileID)\n}\n\nfunc (qb *FileStore) UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error {\n\treturn qb.captionRepository().replace(ctx, fileID, captions)\n}\n"
  },
  {
    "path": "pkg/sqlite/file_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype fileFilterHandler struct {\n\tfileFilter *models.FileFilterType\n\t// if true, don't allow use of related filters\n\tisRelated bool\n}\n\nfunc (qb *fileFilterHandler) validate() error {\n\tfileFilter := qb.fileFilter\n\tif fileFilter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(fileFilter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif qb.isRelated && (fileFilter.ScenesFilter != nil || fileFilter.ImagesFilter != nil || fileFilter.GalleriesFilter != nil) {\n\t\treturn fmt.Errorf(\"cannot use related filters inside a related filter\")\n\t}\n\n\tif subFilter := fileFilter.SubFilter(); subFilter != nil {\n\t\tsqb := &fileFilterHandler{fileFilter: subFilter, isRelated: qb.isRelated}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *fileFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tfileFilter := qb.fileFilter\n\tif fileFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := fileFilter.SubFilter()\n\tif sf != nil {\n\t\tsub := &fileFilterHandler{sf, qb.isRelated}\n\t\thandleSubFilter(ctx, sub, f, fileFilter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *fileFilterHandler) criterionHandler() criterionHandler {\n\tfileFilter := qb.fileFilter\n\treturn compoundHandler{\n\t\t&videoFileFilterHandler{\n\t\t\tfilter: fileFilter.VideoFileFilter,\n\t\t},\n\t\t&imageFileFilterHandler{\n\t\t\tfilter: fileFilter.ImageFileFilter,\n\t\t},\n\n\t\tpathCriterionHandler(fileFilter.Path, \"folders.path\", \"files.basename\", nil),\n\t\tstringCriterionHandler(fileFilter.Basename, \"files.basename\"),\n\t\tstringCriterionHandler(fileFilter.Dir, \"folders.path\"),\n\t\t&timestampCriterionHandler{fileFilter.ModTime, \"files.mod_time\", nil},\n\n\t\tqb.parentFolderCriterionHandler(fileFilter.ParentFolder),\n\t\tqb.zipFileCriterionHandler(fileFilter.ZipFile),\n\n\t\tqb.sceneCountCriterionHandler(fileFilter.SceneCount),\n\t\tqb.imageCountCriterionHandler(fileFilter.ImageCount),\n\t\tqb.galleryCountCriterionHandler(fileFilter.GalleryCount),\n\n\t\tqb.hashesCriterionHandler(fileFilter.Hashes),\n\n\t\tqb.duplicatedCriterionHandler(fileFilter.Duplicated),\n\t\t&timestampCriterionHandler{fileFilter.CreatedAt, \"files.created_at\", nil},\n\t\t&timestampCriterionHandler{fileFilter.UpdatedAt, \"files.updated_at\", nil},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scenes_files.scene_id\",\n\t\t\trelatedRepo:    sceneRepository.repository,\n\t\t\trelatedHandler: &sceneFilterHandler{fileFilter.ScenesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tfileRepository.scenes.innerJoin(f, \"\", \"files.id\")\n\t\t\t},\n\t\t},\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"images_files.image_id\",\n\t\t\trelatedRepo:    imageRepository.repository,\n\t\t\trelatedHandler: &imageFilterHandler{fileFilter.ImagesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tfileRepository.images.innerJoin(f, \"\", \"files.id\")\n\t\t\t},\n\t\t},\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"galleries_files.gallery_id\",\n\t\t\trelatedRepo:    galleryRepository.repository,\n\t\t\trelatedHandler: &galleryFilterHandler{fileFilter.GalleriesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tfileRepository.galleries.innerJoin(f, \"\", \"files.id\")\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (qb *fileFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif criterion != nil {\n\t\t\tif criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"files.zip_file_id IS %s NULL\", notClause))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(criterion.Value) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar args []interface{}\n\t\t\tfor _, tagID := range criterion.Value {\n\t\t\t\targs = append(args, tagID)\n\t\t\t}\n\n\t\t\twhereClause := \"\"\n\t\t\thavingClause := \"\"\n\t\t\tswitch criterion.Modifier {\n\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\twhereClause = \"files.zip_file_id IN \" + getInBinding(len(criterion.Value))\n\t\t\tcase models.CriterionModifierExcludes:\n\t\t\t\twhereClause = \"files.zip_file_id NOT IN \" + getInBinding(len(criterion.Value))\n\t\t\t}\n\n\t\t\tf.addWhere(whereClause, args...)\n\t\t\tf.addHaving(havingClause)\n\t\t}\n\t}\n}\n\nfunc (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif folder == nil {\n\t\t\treturn\n\t\t}\n\n\t\tfolderCopy := *folder\n\t\tswitch folderCopy.Modifier {\n\t\tcase models.CriterionModifierEquals:\n\t\t\tfolderCopy.Modifier = models.CriterionModifierIncludesAll\n\t\tcase models.CriterionModifierNotEquals:\n\t\t\tfolderCopy.Modifier = models.CriterionModifierExcludes\n\t\t}\n\n\t\thh := hierarchicalMultiCriterionHandlerBuilder{\n\t\t\tprimaryTable: fileTable,\n\t\t\tforeignTable: folderTable,\n\t\t\tforeignFK:    \"parent_folder_id\",\n\t\t\tparentFK:     \"parent_folder_id\",\n\t\t}\n\n\t\thh.handler(&folderCopy)(ctx, f)\n\t}\n}\n\nfunc (qb *fileFilterHandler) sceneCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: fileTable,\n\t\tjoinTable:    scenesFilesTable,\n\t\tprimaryFK:    fileIDColumn,\n\t}\n\n\treturn h.handler(c)\n}\n\nfunc (qb *fileFilterHandler) imageCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: fileTable,\n\t\tjoinTable:    imagesFilesTable,\n\t\tprimaryFK:    fileIDColumn,\n\t}\n\n\treturn h.handler(c)\n}\n\nfunc (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: fileTable,\n\t\tjoinTable:    galleriesFilesTable,\n\t\tprimaryFK:    fileIDColumn,\n\t}\n\n\treturn h.handler(c)\n}\n\nfunc (qb *fileFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.FileDuplicationCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\t// TODO: Wishlist item: Implement Distance matching\n\t\t// For files, only phash duplication applies\n\t\tif duplicatedFilter == nil {\n\t\t\treturn\n\t\t}\n\n\t\tvar phashValue *bool\n\n\t\t// Handle legacy 'duplicated' field for backwards compatibility\n\t\t//nolint:staticcheck\n\t\tif duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil {\n\t\t\t//nolint:staticcheck\n\t\t\tphashValue = duplicatedFilter.Duplicated\n\t\t} else if duplicatedFilter.Phash != nil {\n\t\t\tphashValue = duplicatedFilter.Phash\n\t\t}\n\n\t\tif phashValue != nil {\n\t\t\tv := getCountOperator(*phashValue)\n\t\t\tf.addInnerJoin(\"(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) \"+v+\" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)\", \"scph\", \"files.id = scph.file_id\")\n\t\t}\n\t}\n}\n\nfunc (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.FingerprintFilterInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\t// TODO - this won't work for AND/OR combinations\n\t\tfor i, hash := range hashes {\n\t\t\tt := fmt.Sprintf(\"file_fingerprints_%d\", i)\n\t\t\tf.addLeftJoin(fingerprintTable, t, fmt.Sprintf(\"files.id = %s.file_id AND %s.type = ?\", t, t), hash.Type)\n\n\t\t\tdistance := 0\n\t\t\tif hash.Distance != nil {\n\t\t\t\tdistance = *hash.Distance\n\t\t\t}\n\n\t\t\t// Only phash supports distance matching and is stored as integer\n\t\t\tif hash.Type == models.FingerprintTypePhash {\n\t\t\t\tvalue, err := utils.StringToPhash(hash.Value)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(fmt.Errorf(\"invalid phash value: %w\", err))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif distance > 0 {\n\t\t\t\t\t// needed to avoid a type mismatch\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"typeof(%s.fingerprint) = 'integer'\", t))\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"phash_distance(%s.fingerprint, ?) < ?\", t), value, distance)\n\t\t\t\t} else {\n\t\t\t\t\tintCriterionHandler(&models.IntCriterionInput{\n\t\t\t\t\t\tValue:    int(value),\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t}, t+\".fingerprint\", nil)(ctx, f)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// All other fingerprint types (md5, oshash, sha1, etc.) are stored as strings\n\t\t\t\t// Use exact match for string-based fingerprints\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s.fingerprint = ?\", t), hash.Value)\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype videoFileFilterHandler struct {\n\tfilter *models.VideoFileFilterInput\n}\n\nfunc (qb *videoFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tvideoFileFilter := qb.filter\n\tif videoFileFilter == nil {\n\t\treturn\n\t}\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *videoFileFilterHandler) criterionHandler() criterionHandler {\n\tvideoFileFilter := qb.filter\n\treturn compoundHandler{\n\t\tjoinedStringCriterionHandler(videoFileFilter.Format, \"video_files.format\", qb.addVideoFilesTable),\n\t\tfloatIntCriterionHandler(videoFileFilter.Duration, \"video_files.duration\", qb.addVideoFilesTable),\n\t\tresolutionCriterionHandler(videoFileFilter.Resolution, \"video_files.height\", \"video_files.width\", qb.addVideoFilesTable),\n\t\torientationCriterionHandler(videoFileFilter.Orientation, \"video_files.height\", \"video_files.width\", qb.addVideoFilesTable),\n\t\tfloatIntCriterionHandler(videoFileFilter.Framerate, \"ROUND(video_files.frame_rate)\", qb.addVideoFilesTable),\n\t\tintCriterionHandler(videoFileFilter.Bitrate, \"video_files.bit_rate\", qb.addVideoFilesTable),\n\t\tqb.codecCriterionHandler(videoFileFilter.VideoCodec, \"video_files.video_codec\", qb.addVideoFilesTable),\n\t\tqb.codecCriterionHandler(videoFileFilter.AudioCodec, \"video_files.audio_codec\", qb.addVideoFilesTable),\n\n\t\tboolCriterionHandler(videoFileFilter.Interactive, \"video_files.interactive\", qb.addVideoFilesTable),\n\t\tintCriterionHandler(videoFileFilter.InteractiveSpeed, \"video_files.interactive_speed\", qb.addVideoFilesTable),\n\n\t\tqb.captionCriterionHandler(videoFileFilter.Captions),\n\t}\n}\n\nfunc (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) {\n\tf.addLeftJoin(videoFileTable, \"\", \"video_files.file_id = files.id\")\n}\n\nfunc (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif codec != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\n\t\t\tstringCriterionHandler(codec, codecColumn)(ctx, f)\n\t\t}\n\t}\n}\n\nfunc (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t\tjoinTable:    videoCaptionsTable,\n\t\tstringColumn: captionCodeColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tf.addLeftJoin(videoCaptionsTable, \"\", \"video_captions.file_id = files.id\")\n\t\t},\n\t\texcludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {\n\t\t\texcludeClause := `files.id NOT IN (\n\t\t\t\tSELECT files.id from files \n\t\t\t\tINNER JOIN video_captions on video_captions.file_id = files.id \n\t\t\t\tWHERE video_captions.language_code LIKE ?\n\t\t\t)`\n\t\t\tf.addWhere(excludeClause, criterion.Value)\n\n\t\t\t// TODO - should we also exclude null values?\n\t\t},\n\t}\n\n\treturn h.handler(captions)\n}\n\ntype imageFileFilterHandler struct {\n\tfilter *models.ImageFileFilterInput\n}\n\nfunc (qb *imageFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tff := qb.filter\n\tif ff == nil {\n\t\treturn\n\t}\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *imageFileFilterHandler) criterionHandler() criterionHandler {\n\tff := qb.filter\n\treturn compoundHandler{\n\t\tjoinedStringCriterionHandler(ff.Format, \"image_files.format\", qb.addImageFilesTable),\n\t\tresolutionCriterionHandler(ff.Resolution, \"image_files.height\", \"image_files.width\", qb.addImageFilesTable),\n\t\torientationCriterionHandler(ff.Orientation, \"image_files.height\", \"image_files.width\", qb.addImageFilesTable),\n\t}\n}\n\nfunc (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) {\n\tf.addLeftJoin(imageFileTable, \"\", \"image_files.file_id = files.id\")\n}\n"
  },
  {
    "path": "pkg/sqlite/file_filter_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFileQuery(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.FileFilterType\n\t\tincludeIdxs []int\n\t\tincludeIDs  []models.FileID\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\tname: \"path\",\n\t\t\tfilter: &models.FileFilterType{\n\t\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getPrefixedStringValue(\"file\", fileIdxStartVideoFiles, \"basename\"),\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{fileIdxStartVideoFiles},\n\t\t\texcludeIdxs: []int{fileIdxStartImageFiles},\n\t\t},\n\t\t{\n\t\t\tname: \"basename\",\n\t\t\tfilter: &models.FileFilterType{\n\t\t\t\tBasename: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getPrefixedStringValue(\"file\", fileIdxStartVideoFiles, \"basename\"),\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{fileIdxStartVideoFiles},\n\t\t\texcludeIdxs: []int{fileIdxStartImageFiles},\n\t\t},\n\t\t{\n\t\t\tname: \"dir\",\n\t\t\tfilter: &models.FileFilterType{\n\t\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\t\tValue:    folderPaths[folderIdxWithSceneFiles],\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIDs:  []models.FileID{sceneFileIDs[sceneIdxWithGroup]},\n\t\t\texcludeIdxs: []int{fileIdxStartImageFiles},\n\t\t},\n\t\t{\n\t\t\tname: \"parent folder\",\n\t\t\tfilter: &models.FileFilterType{\n\t\t\t\tParentFolder: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(int(folderIDs[folderIdxWithSceneFiles])),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIDs:  []models.FileID{sceneFileIDs[sceneIdxWithGroup]},\n\t\t\texcludeIdxs: []int{fileIdxStartImageFiles},\n\t\t},\n\t\t{\n\t\t\tname: \"zip file\",\n\t\t\tfilter: &models.FileFilterType{\n\t\t\t\tZipFile: &models.MultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(int(fileIDs[fileIdxZip])),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIDs:  []models.FileID{fileIDs[fileIdxInZip]},\n\t\t\texcludeIdxs: []int{fileIdxStartImageFiles},\n\t\t},\n\t\t{\n\t\t\tname: \"hashes md5\",\n\t\t\tfilter: &models.FileFilterType{\n\t\t\t\tHashes: []*models.FingerprintFilterInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:  models.FingerprintTypeMD5,\n\t\t\t\t\t\tValue: getPrefixedStringValue(\"file\", fileIdxStartVideoFiles, \"md5\"),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{fileIdxStartVideoFiles},\n\t\t\texcludeIdxs: []int{fileIdxStartImageFiles},\n\t\t},\n\t\t{\n\t\t\tname: \"hashes oshash\",\n\t\t\tfilter: &models.FileFilterType{\n\t\t\t\tHashes: []*models.FingerprintFilterInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:  models.FingerprintTypeOshash,\n\t\t\t\t\t\tValue: getPrefixedStringValue(\"file\", fileIdxStartVideoFiles, \"oshash\"),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{fileIdxStartVideoFiles},\n\t\t\texcludeIdxs: []int{fileIdxStartImageFiles},\n\t\t},\n\t\t{\n\t\t\tname: \"hashes phash\",\n\t\t\tfilter: &models.FileFilterType{\n\t\t\t\tHashes: []*models.FingerprintFilterInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:  models.FingerprintTypePhash,\n\t\t\t\t\t\tValue: utils.PhashToString(getFilePhash(fileIdxStartImageFiles)),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{fileIdxStartImageFiles},\n\t\t\texcludeIdxs: []int{fileIdxStartVideoFiles},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.File.Query(ctx, models.FileQueryOptions{\n\t\t\t\tFileFilter: tt.filter,\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: tt.findFilter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDPtrs(fileIDs, tt.includeIdxs)\n\t\t\tfor _, id := range tt.includeIDs {\n\t\t\t\tv := id\n\t\t\t\tinclude = append(include, &v)\n\t\t\t}\n\t\t\texclude := indexesToIDPtrs(fileIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, models.FileID(*i))\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, models.FileID(*e))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/file_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc getFilePath(folderIdx int, basename string) string {\n\treturn filepath.Join(folderPaths[folderIdx], basename)\n}\n\nfunc makeZipFileWithID(index int) models.File {\n\tf := makeFile(index)\n\n\treturn &models.BaseFile{\n\t\tID:       fileIDs[index],\n\t\tBasename: f.Base().Basename,\n\t\tPath:     getFilePath(fileFolders[index], getFileBaseName(index)),\n\t}\n}\n\nfunc Test_fileFileStore_Create(t *testing.T) {\n\tvar (\n\t\tbasename               = \"basename\"\n\t\tfingerprintType        = \"MD5\"\n\t\tfingerprintValue       = \"checksum\"\n\t\tfileModTime            = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tcreatedAt              = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt              = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tsize             int64 = 1234\n\n\t\tduration         = 1.234\n\t\twidth            = 640\n\t\theight           = 480\n\t\tframerate        = 2.345\n\t\tbitrate    int64 = 234\n\t\tvideoCodec       = \"videoCodec\"\n\t\taudioCodec       = \"audioCodec\"\n\t\tformat           = \"format\"\n\t)\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.File\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\t&models.BaseFile{\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tZipFileID: &fileIDs[fileIdxZip],\n\t\t\t\t\tZipFile:   makeZipFileWithID(fileIdxZip),\n\t\t\t\t\tModTime:   fileModTime,\n\t\t\t\t},\n\t\t\t\tPath:           getFilePath(folderIdxWithFiles, basename),\n\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t\tBasename:       basename,\n\t\t\t\tSize:           size,\n\t\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:        fingerprintType,\n\t\t\t\t\t\tFingerprint: fingerprintValue,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"video file\",\n\t\t\t&models.VideoFile{\n\t\t\t\tBaseFile: &models.BaseFile{\n\t\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\t\tZipFileID: &fileIDs[fileIdxZip],\n\t\t\t\t\t\tZipFile:   makeZipFileWithID(fileIdxZip),\n\t\t\t\t\t\tModTime:   fileModTime,\n\t\t\t\t\t},\n\t\t\t\t\tPath:           getFilePath(folderIdxWithFiles, basename),\n\t\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t\t\tBasename:       basename,\n\t\t\t\t\tSize:           size,\n\t\t\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tType:        fingerprintType,\n\t\t\t\t\t\t\tFingerprint: fingerprintValue,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t\tDuration:   duration,\n\t\t\t\tVideoCodec: videoCodec,\n\t\t\t\tAudioCodec: audioCodec,\n\t\t\t\tFormat:     format,\n\t\t\t\tWidth:      width,\n\t\t\t\tHeight:     height,\n\t\t\t\tFrameRate:  framerate,\n\t\t\t\tBitRate:    bitrate,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"image file\",\n\t\t\t&models.ImageFile{\n\t\t\t\tBaseFile: &models.BaseFile{\n\t\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\t\tZipFileID: &fileIDs[fileIdxZip],\n\t\t\t\t\t\tZipFile:   makeZipFileWithID(fileIdxZip),\n\t\t\t\t\t\tModTime:   fileModTime,\n\t\t\t\t\t},\n\t\t\t\t\tPath:           getFilePath(folderIdxWithFiles, basename),\n\t\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t\t\tBasename:       basename,\n\t\t\t\t\tSize:           size,\n\t\t\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tType:        fingerprintType,\n\t\t\t\t\t\t\tFingerprint: fingerprintValue,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t\tFormat: format,\n\t\t\t\tWidth:  width,\n\t\t\t\tHeight: height,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"duplicate path\",\n\t\t\t&models.BaseFile{\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tModTime: fileModTime,\n\t\t\t\t},\n\t\t\t\tPath:           getFilePath(folderIdxWithFiles, getFileBaseName(fileIdxZip)),\n\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t\tBasename:       getFileBaseName(fileIdxZip),\n\t\t\t\tSize:           size,\n\t\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:        fingerprintType,\n\t\t\t\t\t\tFingerprint: fingerprintValue,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"empty basename\",\n\t\t\t&models.BaseFile{\n\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"missing folder id\",\n\t\t\t&models.BaseFile{\n\t\t\t\tBasename: basename,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid folder id\",\n\t\t\t&models.BaseFile{\n\t\t\t\tDirEntry:       models.DirEntry{},\n\t\t\t\tParentFolderID: invalidFolderID,\n\t\t\t\tBasename:       basename,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid zip file id\",\n\t\t\t&models.BaseFile{\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tZipFileID: &invalidFileID,\n\t\t\t\t},\n\t\t\t\tBasename: basename,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.File\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\ts := tt.newObject\n\t\t\tif err := qb.Create(ctx, s); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"fileStore.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(s.Base().ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(s.Base().ID)\n\n\t\t\tvar copy models.File\n\t\t\tswitch t := s.(type) {\n\t\t\tcase *models.BaseFile:\n\t\t\t\tv := *t\n\t\t\t\tcopy = &v\n\t\t\tcase *models.VideoFile:\n\t\t\t\tv := *t\n\t\t\t\tcopy = &v\n\t\t\tcase *models.ImageFile:\n\t\t\t\tv := *t\n\t\t\t\tcopy = &v\n\t\t\t}\n\n\t\t\tcopy.Base().ID = s.Base().ID\n\n\t\t\tassert.Equal(copy, s)\n\n\t\t\t// ensure can find the scene\n\t\t\tfound, err := qb.Find(ctx, s.Base().ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"fileStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tif !assert.Len(found, 1) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, found[0])\n\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc Test_fileStore_Update(t *testing.T) {\n\tvar (\n\t\tbasename               = \"basename\"\n\t\tfingerprintType        = \"MD5\"\n\t\tfingerprintValue       = \"checksum\"\n\t\tfileModTime            = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tcreatedAt              = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt              = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tsize             int64 = 1234\n\n\t\tduration         = 1.234\n\t\twidth            = 640\n\t\theight           = 480\n\t\tframerate        = 2.345\n\t\tbitrate    int64 = 234\n\t\tvideoCodec       = \"videoCodec\"\n\t\taudioCodec       = \"audioCodec\"\n\t\tformat           = \"format\"\n\t)\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject models.File\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\t&models.BaseFile{\n\t\t\t\tID: fileIDs[fileIdxInZip],\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tZipFileID: &fileIDs[fileIdxZip],\n\t\t\t\t\tZipFile:   makeZipFileWithID(fileIdxZip),\n\t\t\t\t\tModTime:   fileModTime,\n\t\t\t\t},\n\t\t\t\tPath:           getFilePath(folderIdxWithFiles, basename),\n\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t\tBasename:       basename,\n\t\t\t\tSize:           size,\n\t\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:        fingerprintType,\n\t\t\t\t\t\tFingerprint: fingerprintValue,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"video file\",\n\t\t\t&models.VideoFile{\n\t\t\t\tBaseFile: &models.BaseFile{\n\t\t\t\t\tID: fileIDs[fileIdxStartVideoFiles],\n\t\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\t\tZipFileID: &fileIDs[fileIdxZip],\n\t\t\t\t\t\tZipFile:   makeZipFileWithID(fileIdxZip),\n\t\t\t\t\t\tModTime:   fileModTime,\n\t\t\t\t\t},\n\t\t\t\t\tPath:           getFilePath(folderIdxWithFiles, basename),\n\t\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t\t\tBasename:       basename,\n\t\t\t\t\tSize:           size,\n\t\t\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tType:        fingerprintType,\n\t\t\t\t\t\t\tFingerprint: fingerprintValue,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t\tDuration:   duration,\n\t\t\t\tVideoCodec: videoCodec,\n\t\t\t\tAudioCodec: audioCodec,\n\t\t\t\tFormat:     format,\n\t\t\t\tWidth:      width,\n\t\t\t\tHeight:     height,\n\t\t\t\tFrameRate:  framerate,\n\t\t\t\tBitRate:    bitrate,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"image file\",\n\t\t\t&models.ImageFile{\n\t\t\t\tBaseFile: &models.BaseFile{\n\t\t\t\t\tID: fileIDs[fileIdxStartImageFiles],\n\t\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\t\tZipFileID: &fileIDs[fileIdxZip],\n\t\t\t\t\t\tZipFile:   makeZipFileWithID(fileIdxZip),\n\t\t\t\t\t\tModTime:   fileModTime,\n\t\t\t\t\t},\n\t\t\t\t\tPath:           getFilePath(folderIdxWithFiles, basename),\n\t\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t\t\tBasename:       basename,\n\t\t\t\t\tSize:           size,\n\t\t\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tType:        fingerprintType,\n\t\t\t\t\t\t\tFingerprint: fingerprintValue,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t\tFormat: format,\n\t\t\t\tWidth:  width,\n\t\t\t\tHeight: height,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"duplicate path\",\n\t\t\t&models.BaseFile{\n\t\t\t\tID: fileIDs[fileIdxInZip],\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tModTime: fileModTime,\n\t\t\t\t},\n\t\t\t\tPath:           getFilePath(folderIdxWithFiles, getFileBaseName(fileIdxZip)),\n\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t\tBasename:       getFileBaseName(fileIdxZip),\n\t\t\t\tSize:           size,\n\t\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:        fingerprintType,\n\t\t\t\t\t\tFingerprint: fingerprintValue,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"clear zip\",\n\t\t\t&models.BaseFile{\n\t\t\t\tID:             fileIDs[fileIdxInZip],\n\t\t\t\tPath:           getFilePath(folderIdxWithFiles, getFileBaseName(fileIdxZip)+\".renamed\"),\n\t\t\t\tBasename:       getFileBaseName(fileIdxZip) + \".renamed\",\n\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear folder\",\n\t\t\t&models.BaseFile{\n\t\t\t\tID:   fileIDs[fileIdxZip],\n\t\t\t\tPath: basename,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid parent folder id\",\n\t\t\t&models.BaseFile{\n\t\t\t\tID:             fileIDs[fileIdxZip],\n\t\t\t\tPath:           basename,\n\t\t\t\tParentFolderID: invalidFolderID,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid zip file id\",\n\t\t\t&models.BaseFile{\n\t\t\t\tID:   fileIDs[fileIdxZip],\n\t\t\t\tPath: basename,\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tZipFileID: &invalidFileID,\n\t\t\t\t},\n\t\t\t\tParentFolderID: folderIDs[folderIdxWithFiles],\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.File\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tcopy := tt.updatedObject\n\n\t\t\tif err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FileStore.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.updatedObject.Base().ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"FileStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tif !assert.Len(s, 1) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, s[0])\n\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc makeFileWithID(index int) models.File {\n\tret := makeFile(index)\n\tret.Base().Path = getFilePath(fileFolders[index], getFileBaseName(index))\n\tret.Base().ID = fileIDs[index]\n\n\treturn ret\n}\n\nfunc Test_fileStore_Find(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      models.FileID\n\t\twant    models.File\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tfileIDs[fileIdxZip],\n\t\t\tmakeFileWithID(fileIdxZip),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tmodels.FileID(invalidID),\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"video file\",\n\t\t\tfileIDs[fileIdxStartVideoFiles],\n\t\t\tmakeFileWithID(fileIdxStartVideoFiles),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"image file\",\n\t\t\tfileIDs[fileIdxStartImageFiles],\n\t\t\tmakeFileWithID(fileIdxStartImageFiles),\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.File\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.Find(ctx, tt.id)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"fileStore.Find() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.want == nil {\n\t\t\t\tassert.Len(got, 0)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !assert.Len(got, 1) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got[0])\n\t\t})\n\t}\n}\n\nfunc Test_FileStore_FindByPath(t *testing.T) {\n\tgetPath := func(index int) string {\n\t\tfolderIdx, found := fileFolders[index]\n\t\tif !found {\n\t\t\tfolderIdx = folderIdxWithFiles\n\t\t}\n\n\t\treturn getFilePath(folderIdx, getFileBaseName(index))\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tpath    string\n\t\twant    models.File\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgetPath(fileIdxZip),\n\t\t\tmakeFileWithID(fileIdxZip),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid path\",\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.File\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByPath(ctx, tt.path, true)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FileStore.FindByPath() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestFileStore_FindByFingerprint(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tfp      models.Fingerprint\n\t\twant    []models.File\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"by MD5\",\n\t\t\tmodels.Fingerprint{\n\t\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\t\tFingerprint: getPrefixedStringValue(\"file\", fileIdxZip, \"md5\"),\n\t\t\t},\n\t\t\t[]models.File{makeFileWithID(fileIdxZip)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"by OSHASH\",\n\t\t\tmodels.Fingerprint{\n\t\t\t\tType:        models.FingerprintTypeOshash,\n\t\t\t\tFingerprint: getPrefixedStringValue(\"file\", fileIdxZip, \"oshash\"),\n\t\t\t},\n\t\t\t[]models.File{makeFileWithID(fileIdxZip)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"non-existing\",\n\t\t\tmodels.Fingerprint{\n\t\t\t\tType:        models.FingerprintTypeOshash,\n\t\t\t\tFingerprint: \"foo\",\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.File\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByFingerprint(ctx, tt.fp)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FileStore.FindByFingerprint() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestFileStore_IsPrimary(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tfileID models.FileID\n\t\twant   bool\n\t}{\n\t\t{\n\t\t\t\"scene file\",\n\t\t\tsceneFileIDs[sceneIdx1WithPerformer],\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"image file\",\n\t\t\timageFileIDs[imageIdx1WithGallery],\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"gallery file\",\n\t\t\tgalleryFileIDs[galleryIdx1WithImage],\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"orphan file\",\n\t\t\tfileIDs[fileIdxZip],\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid file\",\n\t\t\tinvalidFileID,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.File\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.IsPrimary(ctx, tt.fileID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"FileStore.IsPrimary() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc illegalFilterCombination(type1, type2 string) error {\n\treturn fmt.Errorf(\"cannot have %s and %s in the same filter\", type1, type2)\n}\n\nfunc validateFilterCombination[T any](sf models.OperatorFilter[T]) error {\n\tconst and = \"AND\"\n\tconst or = \"OR\"\n\tconst not = \"NOT\"\n\n\tif sf.And != nil {\n\t\tif sf.Or != nil {\n\t\t\treturn illegalFilterCombination(and, or)\n\t\t}\n\t\tif sf.Not != nil {\n\t\t\treturn illegalFilterCombination(and, not)\n\t\t}\n\t}\n\n\tif sf.Or != nil {\n\t\tif sf.Not != nil {\n\t\t\treturn illegalFilterCombination(or, not)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc handleSubFilter[T any](ctx context.Context, handler criterionHandler, f *filterBuilder, subFilter models.OperatorFilter[T]) {\n\tsubQuery := &filterBuilder{}\n\thandler.handle(ctx, subQuery)\n\n\tif subFilter.And != nil {\n\t\tf.and(subQuery)\n\t}\n\tif subFilter.Or != nil {\n\t\tf.or(subQuery)\n\t}\n\tif subFilter.Not != nil {\n\t\tf.not(subQuery)\n\t}\n}\n\ntype sqlClause struct {\n\tsql  string\n\targs []interface{}\n}\n\nfunc (c sqlClause) not() sqlClause {\n\treturn sqlClause{\n\t\tsql:  \"NOT (\" + c.sql + \")\",\n\t\targs: c.args,\n\t}\n}\n\nfunc makeClause(sql string, args ...interface{}) sqlClause {\n\treturn sqlClause{\n\t\tsql:  sql,\n\t\targs: args,\n\t}\n}\n\nfunc joinClauses(joinType string, clauses ...sqlClause) sqlClause {\n\tvar ret []string\n\tvar args []interface{}\n\n\tfor _, clause := range clauses {\n\t\tret = append(ret, \"(\"+clause.sql+\")\")\n\t\targs = append(args, clause.args...)\n\t}\n\n\treturn sqlClause{sql: strings.Join(ret, \" \"+joinType+\" \"), args: args}\n}\n\nfunc orClauses(clauses ...sqlClause) sqlClause {\n\treturn joinClauses(\"OR\", clauses...)\n}\n\nfunc andClauses(clauses ...sqlClause) sqlClause {\n\treturn joinClauses(\"AND\", clauses...)\n}\n\ntype join struct {\n\ttable    string\n\tas       string\n\tonClause string\n\tjoinType string\n\targs     []interface{}\n\n\t// if true, indicates this is required for sorting only\n\tsort bool\n}\n\n// equals returns true if the other join alias/table is equal to this one\nfunc (j join) equals(o join) bool {\n\treturn j.alias() == o.alias()\n}\n\n// alias returns the as string, or the table if as is empty\nfunc (j join) alias() string {\n\tif j.as == \"\" {\n\t\treturn j.table\n\t}\n\n\treturn j.as\n}\n\nfunc (j join) toSQL() string {\n\tasStr := \"\"\n\tjoinStr := j.joinType\n\tif j.as != \"\" && j.as != j.table {\n\t\tasStr = \" AS \" + j.as\n\t}\n\tif j.joinType == \"\" {\n\t\tjoinStr = \"LEFT\"\n\t}\n\n\treturn fmt.Sprintf(\"%s JOIN %s%s ON %s\", joinStr, j.table, asStr, j.onClause)\n}\n\ntype joins []join\n\n// addUnique only adds if not already present\n// returns true if added\nfunc (j *joins) addUnique(newJoin join) bool {\n\tfound := false\n\tfor i, jj := range *j {\n\t\tif jj.equals(newJoin) {\n\t\t\tfound = true\n\t\t\t// if sort is false on the new join, but true on the existing, set the false\n\t\t\tif !newJoin.sort && jj.sort {\n\t\t\t\t(*j)[i].sort = false\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !found {\n\t\t*j = append(*j, newJoin)\n\t}\n\treturn !found\n}\n\nfunc (j *joins) add(newJoins ...join) {\n\t// only add if not already joined\n\tfor _, newJoin := range newJoins {\n\t\tj.addUnique(newJoin)\n\t}\n}\n\nfunc (j *joins) toSQL(includeSortPagination bool) string {\n\tif len(*j) == 0 {\n\t\treturn \"\"\n\t}\n\n\tvar ret []string\n\tfor _, jj := range *j {\n\t\t// skip sort-only joins if not including sort/pagination\n\t\tif !includeSortPagination && jj.sort {\n\t\t\tcontinue\n\t\t}\n\t\tret = append(ret, jj.toSQL())\n\t}\n\n\treturn \" \" + strings.Join(ret, \" \")\n}\n\ntype filterBuilder struct {\n\tsubFilter   *filterBuilder\n\tsubFilterOp string\n\n\tjoins         joins\n\twhereClauses  []sqlClause\n\thavingClauses []sqlClause\n\twithClauses   []sqlClause\n\trecursiveWith bool\n\n\terr error\n}\n\nfunc (f *filterBuilder) empty() bool {\n\treturn f == nil || (len(f.whereClauses) == 0 && len(f.joins) == 0 && len(f.havingClauses) == 0 && f.subFilter == nil)\n}\n\nfunc filterBuilderFromHandler(ctx context.Context, handler criterionHandler) *filterBuilder {\n\tf := &filterBuilder{}\n\thandler.handle(ctx, f)\n\treturn f\n}\n\nvar errSubFilterAlreadySet = errors.New(`sub-filter already set`)\n\n// sub-filter operator values\nvar (\n\tandOp = \"AND\"\n\torOp  = \"OR\"\n\tnotOp = \"AND NOT\"\n)\n\n// and sets the sub-filter that will be ANDed with this one.\n// Sets the error state if sub-filter is already set.\nfunc (f *filterBuilder) and(a *filterBuilder) {\n\tif f.subFilter != nil {\n\t\tf.setError(errSubFilterAlreadySet)\n\t\treturn\n\t}\n\n\tf.subFilter = a\n\tf.subFilterOp = andOp\n}\n\n// or sets the sub-filter that will be ORed with this one.\n// Sets the error state if a sub-filter is already set.\nfunc (f *filterBuilder) or(o *filterBuilder) {\n\tif f.subFilter != nil {\n\t\tf.setError(errSubFilterAlreadySet)\n\t\treturn\n\t}\n\n\tf.subFilter = o\n\tf.subFilterOp = orOp\n}\n\n// not sets the sub-filter that will be AND NOTed with this one.\n// Sets the error state if a sub-filter is already set.\nfunc (f *filterBuilder) not(n *filterBuilder) {\n\tif f.subFilter != nil {\n\t\tf.setError(errSubFilterAlreadySet)\n\t\treturn\n\t}\n\n\tf.subFilter = n\n\tf.subFilterOp = notOp\n}\n\n// addLeftJoin adds a left join to the filter. The join is expressed in SQL as:\n// LEFT JOIN <table> [AS <as>] ON <onClause>\n// The AS is omitted if as is empty.\n// This method does not add a join if it its alias/table name is already\n// present in another existing join.\nfunc (f *filterBuilder) addLeftJoin(table, as, onClause string, args ...interface{}) {\n\tnewJoin := join{\n\t\ttable:    table,\n\t\tas:       as,\n\t\tonClause: onClause,\n\t\tjoinType: \"LEFT\",\n\t\targs:     args,\n\t}\n\n\tf.joins.add(newJoin)\n}\n\n// addInnerJoin adds an inner join to the filter. The join is expressed in SQL as:\n// INNER JOIN <table> [AS <as>] ON <onClause>\n// The AS is omitted if as is empty.\n// This method does not add a join if it its alias/table name is already\n// present in another existing join.\nfunc (f *filterBuilder) addInnerJoin(table, as, onClause string, args ...interface{}) {\n\tnewJoin := join{\n\t\ttable:    table,\n\t\tas:       as,\n\t\tonClause: onClause,\n\t\tjoinType: \"INNER\",\n\t\targs:     args,\n\t}\n\n\tf.joins.add(newJoin)\n}\n\n// addWhere adds a where clause and arguments to the filter. Where clauses\n// are ANDed together. Does not add anything if the provided string is empty.\nfunc (f *filterBuilder) addWhere(sql string, args ...interface{}) {\n\tif sql == \"\" {\n\t\treturn\n\t}\n\tf.whereClauses = append(f.whereClauses, makeClause(sql, args...))\n}\n\n// addHaving adds a where clause and arguments to the filter. Having clauses\n// are ANDed together. Does not add anything if the provided string is empty.\nfunc (f *filterBuilder) addHaving(sql string, args ...interface{}) {\n\tif sql == \"\" {\n\t\treturn\n\t}\n\tf.havingClauses = append(f.havingClauses, makeClause(sql, args...))\n}\n\n// addWith adds a with clause and arguments to the filter\nfunc (f *filterBuilder) addWith(sql string, args ...interface{}) {\n\tif sql == \"\" {\n\t\treturn\n\t}\n\n\tf.withClauses = append(f.withClauses, makeClause(sql, args...))\n}\n\n// addRecursiveWith adds a with clause and arguments to the filter, and sets it to recursive\n//\n//nolint:unused\nfunc (f *filterBuilder) addRecursiveWith(sql string, args ...interface{}) {\n\tif sql == \"\" {\n\t\treturn\n\t}\n\n\tf.addWith(sql, args...)\n\tf.recursiveWith = true\n}\n\nfunc (f *filterBuilder) getSubFilterClause(clause, subFilterClause string) string {\n\tret := clause\n\n\tif subFilterClause != \"\" {\n\t\tvar op string\n\t\tif len(ret) > 0 {\n\t\t\top = \" \" + f.subFilterOp + \" \"\n\t\t} else if f.subFilterOp == notOp {\n\t\t\top = \"NOT \"\n\t\t}\n\n\t\tret += op + \"(\" + subFilterClause + \")\"\n\t}\n\n\treturn ret\n}\n\n// generateWhereClauses generates the SQL where clause for this filter.\n// All where clauses within the filter are ANDed together. This is combined\n// with the sub-filter, which will use the applicable operator (AND/OR/AND NOT).\nfunc (f *filterBuilder) generateWhereClauses() (clause string, args []interface{}) {\n\tclause, args = f.andClauses(f.whereClauses)\n\n\tif f.subFilter != nil {\n\t\tc, a := f.subFilter.generateWhereClauses()\n\t\tif c != \"\" {\n\t\t\tclause = f.getSubFilterClause(clause, c)\n\t\t\tif len(a) > 0 {\n\t\t\t\targs = append(args, a...)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn\n}\n\n// generateHavingClauses generates the SQL having clause for this filter.\n// All having clauses within the filter are ANDed together. This is combined\n// with the sub-filter, which will use the applicable operator (AND/OR/AND NOT).\nfunc (f *filterBuilder) generateHavingClauses() (string, []interface{}) {\n\tclause, args := f.andClauses(f.havingClauses)\n\n\tif f.subFilter != nil {\n\t\tc, a := f.subFilter.generateHavingClauses()\n\t\tif c != \"\" {\n\t\t\tclause = f.getSubFilterClause(clause, c)\n\t\t\tif len(a) > 0 {\n\t\t\t\targs = append(args, a...)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn clause, args\n}\n\nfunc (f *filterBuilder) generateWithClauses() (string, []interface{}) {\n\tvar clauses []string\n\tvar args []interface{}\n\tfor _, w := range f.withClauses {\n\t\tclauses = append(clauses, w.sql)\n\t\targs = append(args, w.args...)\n\t}\n\n\tif len(clauses) > 0 {\n\t\treturn strings.Join(clauses, \", \"), args\n\t}\n\n\treturn \"\", nil\n}\n\n// getAllJoins returns all of the joins in this filter and any sub-filter(s).\n// Redundant joins will not be duplicated in the return value.\nfunc (f *filterBuilder) getAllJoins() joins {\n\tvar ret joins\n\tret.add(f.joins...)\n\tif f.subFilter != nil {\n\t\tsubJoins := f.subFilter.getAllJoins()\n\t\tif len(subJoins) > 0 {\n\t\t\tret.add(subJoins...)\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// getError returns the error state on this filter, or on any sub-filter(s) if\n// the error state is nil.\nfunc (f *filterBuilder) getError() error {\n\tif f.err != nil {\n\t\treturn f.err\n\t}\n\n\tif f.subFilter != nil {\n\t\treturn f.subFilter.getError()\n\t}\n\n\treturn nil\n}\n\n// handleCriterion calls the handle function on the provided criterionHandler,\n// providing itself.\nfunc (f *filterBuilder) handleCriterion(ctx context.Context, handler criterionHandler) {\n\thandler.handle(ctx, f)\n}\n\nfunc (f *filterBuilder) setError(e error) {\n\tif f.err == nil {\n\t\tf.err = e\n\t}\n}\n\nfunc (f *filterBuilder) andClauses(input []sqlClause) (string, []interface{}) {\n\tvar clauses []string\n\tvar args []interface{}\n\tfor _, w := range input {\n\t\tclauses = append(clauses, w.sql)\n\t\targs = append(args, w.args...)\n\t}\n\n\tif len(clauses) > 0 {\n\t\tc := \"(\" + strings.Join(clauses, \") AND (\") + \")\"\n\t\tif len(clauses) > 1 {\n\t\t\tc = \"(\" + c + \")\"\n\t\t}\n\t\treturn c, args\n\t}\n\n\treturn \"\", nil\n}\n"
  },
  {
    "path": "pkg/sqlite/filter_hierarchical.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\n// hierarchicalRelationshipHandler provides handlers for parent, children, parent count, and child count criteria.\ntype hierarchicalRelationshipHandler struct {\n\tprimaryTable  string\n\trelationTable string\n\taliasPrefix   string\n\tparentIDCol   string\n\tchildIDCol    string\n}\n\nfunc (h hierarchicalRelationshipHandler) validateModifier(m models.CriterionModifier) error {\n\tswitch m {\n\tcase models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull:\n\t\t// valid\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"invalid modifier %s\", m)\n\t}\n}\n\nfunc (h hierarchicalRelationshipHandler) handleNullNotNull(f *filterBuilder, m models.CriterionModifier, isParents bool) {\n\tvar notClause string\n\tif m == models.CriterionModifierNotNull {\n\t\tnotClause = \"NOT\"\n\t}\n\n\tas := h.aliasPrefix + \"_parents\"\n\tcol := h.childIDCol\n\tif !isParents {\n\t\tas = h.aliasPrefix + \"_children\"\n\t\tcol = h.parentIDCol\n\t}\n\n\t// Based on:\n\t// f.addLeftJoin(\"tags_relations\", \"parent_relations\", \"tags.id = parent_relations.child_id\")\n\t// f.addWhere(fmt.Sprintf(\"parent_relations.parent_id IS %s NULL\", notClause))\n\n\tf.addLeftJoin(h.relationTable, as, fmt.Sprintf(\"%s.id = %s.%s\", h.primaryTable, as, col))\n\tf.addWhere(fmt.Sprintf(\"%s.%s IS %s NULL\", as, col, notClause))\n}\n\nfunc (h hierarchicalRelationshipHandler) parentsAlias() string {\n\treturn h.aliasPrefix + \"_parents\"\n}\n\nfunc (h hierarchicalRelationshipHandler) childrenAlias() string {\n\treturn h.aliasPrefix + \"_children\"\n}\n\nfunc (h hierarchicalRelationshipHandler) valueQuery(value []string, depth int, alias string, isParents bool) string {\n\tvar depthCondition string\n\tif depth != -1 {\n\t\tdepthCondition = fmt.Sprintf(\"WHERE depth < %d\", depth)\n\t}\n\n\tqueryTempl := `{alias} AS (\nSELECT {root_id_col} AS root_id, {item_id_col} AS item_id, 0 AS depth FROM {relation_table} WHERE {root_id_col} IN` + getInBinding(len(value)) + `\nUNION\nSELECT root_id, {item_id_col}, depth + 1 FROM {relation_table} INNER JOIN {alias} ON item_id = {root_id_col} ` + depthCondition + `\n)`\n\n\tvar queryMap utils.StrFormatMap\n\tif isParents {\n\t\tqueryMap = utils.StrFormatMap{\n\t\t\t\"root_id_col\": h.parentIDCol,\n\t\t\t\"item_id_col\": h.childIDCol,\n\t\t}\n\t} else {\n\t\tqueryMap = utils.StrFormatMap{\n\t\t\t\"root_id_col\": h.childIDCol,\n\t\t\t\"item_id_col\": h.parentIDCol,\n\t\t}\n\t}\n\n\tqueryMap[\"alias\"] = alias\n\tqueryMap[\"relation_table\"] = h.relationTable\n\n\treturn utils.StrFormat(queryTempl, queryMap)\n}\n\nfunc (h hierarchicalRelationshipHandler) handleValues(f *filterBuilder, c models.HierarchicalMultiCriterionInput, isParents bool, aliasSuffix string) {\n\tif len(c.Value) == 0 {\n\t\treturn\n\t}\n\n\tvar args []interface{}\n\tfor _, val := range c.Value {\n\t\targs = append(args, val)\n\t}\n\n\tdepthVal := 0\n\tif c.Depth != nil {\n\t\tdepthVal = *c.Depth\n\t}\n\n\ttableAlias := h.parentsAlias()\n\tif !isParents {\n\t\ttableAlias = h.childrenAlias()\n\t}\n\ttableAlias += aliasSuffix\n\n\tquery := h.valueQuery(c.Value, depthVal, tableAlias, isParents)\n\tf.addRecursiveWith(query, args...)\n\n\tf.addLeftJoin(tableAlias, \"\", fmt.Sprintf(\"%s.item_id = %s.id\", tableAlias, h.primaryTable))\n\taddHierarchicalConditionClauses(f, c, tableAlias, \"root_id\")\n}\n\nfunc (h hierarchicalRelationshipHandler) handleValuesSimple(f *filterBuilder, value string, isParents bool) {\n\tjoinCol := h.childIDCol\n\tvalueCol := h.parentIDCol\n\tif !isParents {\n\t\tjoinCol = h.parentIDCol\n\t\tvalueCol = h.childIDCol\n\t}\n\n\ttableAlias := h.parentsAlias()\n\tif !isParents {\n\t\ttableAlias = h.childrenAlias()\n\t}\n\n\tf.addInnerJoin(h.relationTable, tableAlias, fmt.Sprintf(\"%s.%s = %s.id\", tableAlias, joinCol, h.primaryTable))\n\tf.addWhere(fmt.Sprintf(\"%s.%s = ?\", tableAlias, valueCol), value)\n}\n\nfunc (h hierarchicalRelationshipHandler) hierarchicalCriterionHandler(criterion *models.HierarchicalMultiCriterionInput, isParents bool) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif criterion != nil {\n\t\t\tc := criterion.CombineExcludes()\n\n\t\t\t// validate the modifier\n\t\t\tif err := h.validateModifier(c.Modifier); err != nil {\n\t\t\t\tf.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif c.Modifier == models.CriterionModifierIsNull || c.Modifier == models.CriterionModifierNotNull {\n\t\t\t\th.handleNullNotNull(f, c.Modifier, isParents)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(c.Value) == 0 && len(c.Excludes) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdepth := 0\n\t\t\tif c.Depth != nil {\n\t\t\t\tdepth = *c.Depth\n\t\t\t}\n\n\t\t\t// if we have a single include, no excludes, and no depth, we can use a simple join and where clause\n\t\t\tif (c.Modifier == models.CriterionModifierIncludes || c.Modifier == models.CriterionModifierIncludesAll) && len(c.Value) == 1 && len(c.Excludes) == 0 && depth == 0 {\n\t\t\t\th.handleValuesSimple(f, c.Value[0], isParents)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\taliasSuffix := \"\"\n\t\t\th.handleValues(f, c, isParents, aliasSuffix)\n\n\t\t\tif len(c.Excludes) > 0 {\n\t\t\t\texCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    c.Excludes,\n\t\t\t\t\tDepth:    c.Depth,\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t}\n\n\t\t\t\taliasSuffix := \"2\"\n\t\t\t\th.handleValues(f, exCriterion, isParents, aliasSuffix)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (h hierarchicalRelationshipHandler) ParentsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\tconst isParents = true\n\treturn h.hierarchicalCriterionHandler(criterion, isParents)\n}\n\nfunc (h hierarchicalRelationshipHandler) ChildrenCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\tconst isParents = false\n\treturn h.hierarchicalCriterionHandler(criterion, isParents)\n}\n\nfunc (h hierarchicalRelationshipHandler) countCriterionHandler(c *models.IntCriterionInput, isParents bool) criterionHandlerFunc {\n\ttableAlias := h.parentsAlias()\n\tcol := h.childIDCol\n\totherCol := h.parentIDCol\n\tif !isParents {\n\t\ttableAlias = h.childrenAlias()\n\t\tcol = h.parentIDCol\n\t\totherCol = h.childIDCol\n\t}\n\ttableAlias += \"_count\"\n\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\tf.addLeftJoin(h.relationTable, tableAlias, fmt.Sprintf(\"%s.%s = %s.id\", tableAlias, col, h.primaryTable))\n\t\t\tclause, args := getIntCriterionWhereClause(fmt.Sprintf(\"count(distinct %s.%s)\", tableAlias, otherCol), *c)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (h hierarchicalRelationshipHandler) ParentCountCriterionHandler(parentCount *models.IntCriterionInput) criterionHandlerFunc {\n\tconst isParents = true\n\treturn h.countCriterionHandler(parentCount, isParents)\n}\n\nfunc (h hierarchicalRelationshipHandler) ChildCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc {\n\tconst isParents = false\n\treturn h.countCriterionHandler(childCount, isParents)\n}\n"
  },
  {
    "path": "pkg/sqlite/filter_internal_test.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nvar testCtx = context.Background()\n\nfunc TestJoinsAddJoin(t *testing.T) {\n\tvar joins joins\n\n\t// add a single join\n\tjoins.add(join{table: \"test\"})\n\n\tassert := assert.New(t)\n\n\t// ensure join was added\n\tassert.Len(joins, 1)\n\n\t// add the same join and another\n\tjoins.add([]join{\n\t\t{\n\t\t\ttable: \"test\",\n\t\t},\n\t\t{\n\t\t\ttable: \"foo\",\n\t\t},\n\t}...)\n\n\t// should have added a single join\n\tassert.Len(joins, 2)\n}\n\nfunc TestFilterBuilderAnd(t *testing.T) {\n\tassert := assert.New(t)\n\n\tf := &filterBuilder{}\n\tother := &filterBuilder{}\n\tnewBuilder := &filterBuilder{}\n\n\t// and should set the subFilter\n\tf.and(other)\n\tassert.Equal(other, f.subFilter)\n\tassert.Nil(f.getError())\n\n\t// and should set error if and is set\n\tf.and(newBuilder)\n\tassert.Equal(other, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n\n\t// and should set error if or is set\n\t// and should not set subFilter if or is set\n\tf = &filterBuilder{}\n\tf.or(other)\n\tf.and(newBuilder)\n\tassert.Equal(other, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n\n\t// and should set error if not is set\n\t// and should not set subFilter if not is set\n\tf = &filterBuilder{}\n\tf.not(other)\n\tf.and(newBuilder)\n\tassert.Equal(other, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n}\n\nfunc TestFilterBuilderOr(t *testing.T) {\n\tassert := assert.New(t)\n\n\tf := &filterBuilder{}\n\tother := &filterBuilder{}\n\tnewBuilder := &filterBuilder{}\n\n\t// or should set the orFilter\n\tf.or(other)\n\tassert.Equal(other, f.subFilter)\n\tassert.Nil(f.getError())\n\n\t// or should set error if or is set\n\tf.or(newBuilder)\n\tassert.Equal(newBuilder, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n\n\t// or should set error if and is set\n\t// or should not set subFilter if and is set\n\tf = &filterBuilder{}\n\tf.and(other)\n\tf.or(newBuilder)\n\tassert.Equal(other, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n\n\t// or should set error if not is set\n\t// or should not set subFilter if not is set\n\tf = &filterBuilder{}\n\tf.not(other)\n\tf.or(newBuilder)\n\tassert.Equal(other, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n}\n\nfunc TestFilterBuilderNot(t *testing.T) {\n\tassert := assert.New(t)\n\n\tf := &filterBuilder{}\n\tother := &filterBuilder{}\n\tnewBuilder := &filterBuilder{}\n\n\t// not should set the subFilter\n\tf.not(other)\n\t// ensure and filter is set\n\tassert.Equal(other, f.subFilter)\n\tassert.Nil(f.getError())\n\n\t// not should set error if not is set\n\tf.not(newBuilder)\n\tassert.Equal(newBuilder, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n\n\t// not should set error if and is set\n\t// not should not set subFilter if and is set\n\tf = &filterBuilder{}\n\tf.and(other)\n\tf.not(newBuilder)\n\tassert.Equal(other, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n\n\t// not should set error if or is set\n\t// not should not set subFilter if or is set\n\tf = &filterBuilder{}\n\tf.or(other)\n\tf.not(newBuilder)\n\tassert.Equal(other, f.subFilter)\n\tassert.Equal(errSubFilterAlreadySet, f.getError())\n}\n\nfunc TestAddJoin(t *testing.T) {\n\tassert := assert.New(t)\n\n\tf := &filterBuilder{}\n\n\tconst (\n\t\ttable1Name = \"table1Name\"\n\t\ttable2Name = \"table2Name\"\n\n\t\tas1Name = \"as1\"\n\t\tas2Name = \"as2\"\n\n\t\tonClause = \"onClause1\"\n\t)\n\n\tf.addLeftJoin(table1Name, as1Name, onClause)\n\n\t// ensure join is added\n\tassert.Len(f.joins, 1)\n\tassert.Equal(fmt.Sprintf(\"LEFT JOIN %s AS %s ON %s\", table1Name, as1Name, onClause), f.joins[0].toSQL())\n\n\t// ensure join with same as is not added\n\tf.addLeftJoin(table2Name, as1Name, onClause)\n\tassert.Len(f.joins, 1)\n\n\t// ensure same table with different alias can be added\n\tf.addLeftJoin(table1Name, as2Name, onClause)\n\tassert.Len(f.joins, 2)\n\tassert.Equal(fmt.Sprintf(\"LEFT JOIN %s AS %s ON %s\", table1Name, as2Name, onClause), f.joins[1].toSQL())\n\n\t// ensure table without alias can be added if tableName != existing alias/tableName\n\tf.addLeftJoin(table1Name, \"\", onClause)\n\tassert.Len(f.joins, 3)\n\tassert.Equal(fmt.Sprintf(\"LEFT JOIN %s ON %s\", table1Name, onClause), f.joins[2].toSQL())\n\n\t// ensure table with alias == table name of a join without alias is not added\n\tf.addLeftJoin(table2Name, table1Name, onClause)\n\tassert.Len(f.joins, 3)\n\n\t// ensure table without alias cannot be added if tableName == existing alias\n\tf.addLeftJoin(as2Name, \"\", onClause)\n\tassert.Len(f.joins, 3)\n\n\t// ensure AS is not used if same as table name\n\tf.addLeftJoin(table2Name, table2Name, onClause)\n\tassert.Len(f.joins, 4)\n\tassert.Equal(fmt.Sprintf(\"LEFT JOIN %s ON %s\", table2Name, onClause), f.joins[3].toSQL())\n}\n\nfunc TestAddWhere(t *testing.T) {\n\tassert := assert.New(t)\n\n\tf := &filterBuilder{}\n\n\t// ensure empty sql adds nothing\n\tf.addWhere(\"\")\n\tassert.Len(f.whereClauses, 0)\n\n\tconst whereClause = \"a = b\"\n\tvar args = []interface{}{\"1\", \"2\"}\n\n\t// ensure addWhere sets where clause and args\n\tf.addWhere(whereClause, args...)\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(whereClause, f.whereClauses[0].sql)\n\tassert.Equal(args, f.whereClauses[0].args)\n\n\t// ensure addWhere without args sets where clause\n\tf.addWhere(whereClause)\n\tassert.Len(f.whereClauses, 2)\n\tassert.Equal(whereClause, f.whereClauses[1].sql)\n\tassert.Len(f.whereClauses[1].args, 0)\n}\n\nfunc TestAddHaving(t *testing.T) {\n\tassert := assert.New(t)\n\n\tf := &filterBuilder{}\n\n\t// ensure empty sql adds nothing\n\tf.addHaving(\"\")\n\tassert.Len(f.havingClauses, 0)\n\n\tconst havingClause = \"a = b\"\n\tvar args = []interface{}{\"1\", \"2\"}\n\n\t// ensure addWhere sets where clause and args\n\tf.addHaving(havingClause, args...)\n\tassert.Len(f.havingClauses, 1)\n\tassert.Equal(havingClause, f.havingClauses[0].sql)\n\tassert.Equal(args, f.havingClauses[0].args)\n\n\t// ensure addWhere without args sets where clause\n\tf.addHaving(havingClause)\n\tassert.Len(f.havingClauses, 2)\n\tassert.Equal(havingClause, f.havingClauses[1].sql)\n\tassert.Len(f.havingClauses[1].args, 0)\n}\n\nfunc TestGenerateWhereClauses(t *testing.T) {\n\tassert := assert.New(t)\n\n\tf := &filterBuilder{}\n\n\tconst clause1 = \"a = 1\"\n\tconst clause2 = \"b = 2\"\n\tconst clause3 = \"c = 3\"\n\n\tconst arg1 = \"1\"\n\tconst arg2 = \"2\"\n\tconst arg3 = \"3\"\n\n\t// ensure single where clause is generated correctly\n\tf.addWhere(clause1)\n\tr, rArgs := f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"(%s)\", clause1), r)\n\tassert.Len(rArgs, 0)\n\n\t// ensure multiple where clauses are surrounded with parenthesis and\n\t// ANDed together\n\tf.addWhere(clause2, arg1, arg2)\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"((%s) AND (%s))\", clause1, clause2), r)\n\tassert.Len(rArgs, 2)\n\n\t// ensure empty subfilter is not added to generated where clause\n\tsf := &filterBuilder{}\n\tf.and(sf)\n\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"((%s) AND (%s))\", clause1, clause2), r)\n\tassert.Len(rArgs, 2)\n\n\t// ensure sub-filter is generated correctly\n\tsf.addWhere(clause3, arg3)\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"((%s) AND (%s)) AND ((%s))\", clause1, clause2, clause3), r)\n\tassert.Len(rArgs, 3)\n\n\t// ensure OR sub-filter is generated correctly\n\tf = &filterBuilder{}\n\tf.addWhere(clause1)\n\tf.addWhere(clause2, arg1, arg2)\n\tf.or(sf)\n\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"((%s) AND (%s)) OR ((%s))\", clause1, clause2, clause3), r)\n\tassert.Len(rArgs, 3)\n\n\t// ensure NOT sub-filter is generated correctly\n\tf = &filterBuilder{}\n\tf.addWhere(clause1)\n\tf.addWhere(clause2, arg1, arg2)\n\tf.not(sf)\n\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"((%s) AND (%s)) AND NOT ((%s))\", clause1, clause2, clause3), r)\n\tassert.Len(rArgs, 3)\n\n\t// ensure empty filter with ANDed sub-filter does not include AND\n\tf = &filterBuilder{}\n\tf.and(sf)\n\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"((%s))\", clause3), r)\n\tassert.Len(rArgs, 1)\n\n\t// ensure empty filter with ORed sub-filter does not include OR\n\tf = &filterBuilder{}\n\tf.or(sf)\n\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"((%s))\", clause3), r)\n\tassert.Len(rArgs, 1)\n\n\t// ensure empty filter with NOTed sub-filter does not include AND\n\tf = &filterBuilder{}\n\tf.not(sf)\n\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"NOT ((%s))\", clause3), r)\n\tassert.Len(rArgs, 1)\n\n\t// (clause1) AND ((clause2) OR (clause3))\n\tf = &filterBuilder{}\n\tf.addWhere(clause1)\n\tsf2 := &filterBuilder{}\n\tsf2.addWhere(clause2, arg1, arg2)\n\tf.and(sf2)\n\tsf2.or(sf)\n\tr, rArgs = f.generateWhereClauses()\n\tassert.Equal(fmt.Sprintf(\"(%s) AND ((%s) OR ((%s)))\", clause1, clause2, clause3), r)\n\tassert.Len(rArgs, 3)\n}\n\nfunc TestGenerateHavingClauses(t *testing.T) {\n\tassert := assert.New(t)\n\n\tf := &filterBuilder{}\n\n\tconst clause1 = \"a = 1\"\n\tconst clause2 = \"b = 2\"\n\tconst clause3 = \"c = 3\"\n\n\tconst arg1 = \"1\"\n\tconst arg2 = \"2\"\n\tconst arg3 = \"3\"\n\n\t// ensure single Having clause is generated correctly\n\tf.addHaving(clause1)\n\tr, rArgs := f.generateHavingClauses()\n\tassert.Equal(fmt.Sprintf(\"(%s)\", clause1), r)\n\tassert.Len(rArgs, 0)\n\n\t// ensure multiple Having clauses are surrounded with parenthesis and\n\t// ANDed together\n\tf.addHaving(clause2, arg1, arg2)\n\tr, rArgs = f.generateHavingClauses()\n\tassert.Equal(\"((\"+clause1+\") AND (\"+clause2+\"))\", r)\n\tassert.Len(rArgs, 2)\n\n\t// ensure empty subfilter is not added to generated Having clause\n\tsf := &filterBuilder{}\n\tf.and(sf)\n\n\tr, rArgs = f.generateHavingClauses()\n\tassert.Equal(\"((\"+clause1+\") AND (\"+clause2+\"))\", r)\n\tassert.Len(rArgs, 2)\n\n\t// ensure sub-filter is generated correctly\n\tsf.addHaving(clause3, arg3)\n\tr, rArgs = f.generateHavingClauses()\n\tassert.Equal(\"((\"+clause1+\") AND (\"+clause2+\")) AND ((\"+clause3+\"))\", r)\n\tassert.Len(rArgs, 3)\n\n\t// ensure OR sub-filter is generated correctly\n\tf = &filterBuilder{}\n\tf.addHaving(clause1)\n\tf.addHaving(clause2, arg1, arg2)\n\tf.or(sf)\n\n\tr, rArgs = f.generateHavingClauses()\n\tassert.Equal(\"((\"+clause1+\") AND (\"+clause2+\")) OR ((\"+clause3+\"))\", r)\n\tassert.Len(rArgs, 3)\n\n\t// ensure NOT sub-filter is generated correctly\n\tf = &filterBuilder{}\n\tf.addHaving(clause1)\n\tf.addHaving(clause2, arg1, arg2)\n\tf.not(sf)\n\n\tr, rArgs = f.generateHavingClauses()\n\tassert.Equal(\"((\"+clause1+\") AND (\"+clause2+\")) AND NOT ((\"+clause3+\"))\", r)\n\tassert.Len(rArgs, 3)\n}\n\nfunc TestGetAllJoins(t *testing.T) {\n\tassert := assert.New(t)\n\tf := &filterBuilder{}\n\n\tconst (\n\t\ttable1Name = \"table1Name\"\n\t\ttable2Name = \"table2Name\"\n\n\t\tas1Name = \"as1\"\n\t\tas2Name = \"as2\"\n\n\t\tonClause = \"onClause1\"\n\t)\n\n\tf.addLeftJoin(table1Name, as1Name, onClause)\n\n\t// ensure join is returned\n\tjoins := f.getAllJoins()\n\tassert.Len(joins, 1)\n\tassert.Equal(fmt.Sprintf(\"LEFT JOIN %s AS %s ON %s\", table1Name, as1Name, onClause), joins[0].toSQL())\n\n\t// ensure joins in sub-filter are returned\n\tsubFilter := &filterBuilder{}\n\tf.and(subFilter)\n\tsubFilter.addLeftJoin(table2Name, as2Name, onClause)\n\n\tjoins = f.getAllJoins()\n\tassert.Len(joins, 2)\n\tassert.Equal(fmt.Sprintf(\"LEFT JOIN %s AS %s ON %s\", table2Name, as2Name, onClause), joins[1].toSQL())\n\n\t// ensure redundant joins are not returned\n\tsubFilter.addLeftJoin(as1Name, \"\", onClause)\n\tjoins = f.getAllJoins()\n\tassert.Len(joins, 2)\n}\n\nfunc TestGetError(t *testing.T) {\n\tassert := assert.New(t)\n\tf := &filterBuilder{}\n\tsubFilter := &filterBuilder{}\n\n\tf.and(subFilter)\n\n\texpectedErr := errors.New(\"test error\")\n\texpectedErr2 := errors.New(\"test error2\")\n\tf.err = expectedErr\n\tsubFilter.err = expectedErr2\n\n\t// ensure getError returns the top-level error state\n\tassert.Equal(expectedErr, f.getError())\n\n\t// ensure getError returns sub-filter error state if top-level error\n\t// is nil\n\tf.err = nil\n\tassert.Equal(expectedErr2, f.getError())\n\n\t// ensure getError returns nil if all error states are nil\n\tsubFilter.err = nil\n\tassert.Nil(f.getError())\n}\n\nfunc TestStringCriterionHandlerIncludes(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst column = \"column\"\n\tconst value1 = \"two words\"\n\tconst quotedValue = `\"two words\"`\n\n\tf := &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierIncludes,\n\t\tValue:    value1,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"(%[1]s LIKE ? OR %[1]s LIKE ?)\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 2)\n\tassert.Equal(\"%two%\", f.whereClauses[0].args[0])\n\tassert.Equal(\"%words%\", f.whereClauses[0].args[1])\n\n\tf = &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierIncludes,\n\t\tValue:    quotedValue,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"(%[1]s LIKE ?)\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 1)\n\tassert.Equal(\"%two words%\", f.whereClauses[0].args[0])\n}\n\nfunc TestStringCriterionHandlerExcludes(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst column = \"column\"\n\tconst value1 = \"two words\"\n\tconst quotedValue = `\"two words\"`\n\n\tf := &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierExcludes,\n\t\tValue:    value1,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"(%[1]s NOT LIKE ? AND %[1]s NOT LIKE ?)\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 2)\n\tassert.Equal(\"%two%\", f.whereClauses[0].args[0])\n\tassert.Equal(\"%words%\", f.whereClauses[0].args[1])\n\n\tf = &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierExcludes,\n\t\tValue:    quotedValue,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"(%[1]s NOT LIKE ?)\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 1)\n\tassert.Equal(\"%two words%\", f.whereClauses[0].args[0])\n}\n\nfunc TestStringCriterionHandlerEquals(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst column = \"column\"\n\tconst value1 = \"two words\"\n\n\tf := &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierEquals,\n\t\tValue:    value1,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"%[1]s LIKE ?\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 1)\n\tassert.Equal(value1, f.whereClauses[0].args[0])\n}\n\nfunc TestStringCriterionHandlerNotEquals(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst column = \"column\"\n\tconst value1 = \"two words\"\n\n\tf := &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierNotEquals,\n\t\tValue:    value1,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"%[1]s NOT LIKE ?\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 1)\n\tassert.Equal(value1, f.whereClauses[0].args[0])\n}\n\nfunc TestStringCriterionHandlerMatchesRegex(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst column = \"column\"\n\tconst validValue = \"two words\"\n\tconst invalidValue = \"*two words\"\n\n\tf := &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\tValue:    validValue,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"(%s IS NOT NULL AND %[1]s regexp ?)\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 1)\n\tassert.Equal(validValue, f.whereClauses[0].args[0])\n\n\t// ensure invalid regex sets error state\n\tf = &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\tValue:    invalidValue,\n\t}, column))\n\n\tassert.NotNil(f.getError())\n}\n\nfunc TestStringCriterionHandlerNotMatchesRegex(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst column = \"column\"\n\tconst validValue = \"two words\"\n\tconst invalidValue = \"*two words\"\n\n\tf := &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\tValue:    validValue,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"(%s IS NULL OR %[1]s NOT regexp ?)\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 1)\n\tassert.Equal(validValue, f.whereClauses[0].args[0])\n\n\t// ensure invalid regex sets error state\n\tf = &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\tValue:    invalidValue,\n\t}, column))\n\n\tassert.NotNil(f.getError())\n}\n\nfunc TestStringCriterionHandlerIsNull(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst column = \"column\"\n\n\tf := &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierIsNull,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"(%[1]s IS NULL OR TRIM(%[1]s) = '')\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 0)\n}\n\nfunc TestStringCriterionHandlerNotNull(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst column = \"column\"\n\n\tf := &filterBuilder{}\n\tf.handleCriterion(testCtx, stringCriterionHandler(&models.StringCriterionInput{\n\t\tModifier: models.CriterionModifierNotNull,\n\t}, column))\n\n\tassert.Len(f.whereClauses, 1)\n\tassert.Equal(fmt.Sprintf(\"(%[1]s IS NOT NULL AND TRIM(%[1]s) != '')\", column), f.whereClauses[0].sql)\n\tassert.Len(f.whereClauses[0].args, 0)\n}\n"
  },
  {
    "path": "pkg/sqlite/fingerprint.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"gopkg.in/guregu/null.v4\"\n)\n\nconst (\n\tfingerprintTable = \"files_fingerprints\"\n)\n\ntype fingerprintQueryRow struct {\n\tType        null.String `db:\"fingerprint_type\"`\n\tFingerprint interface{} `db:\"fingerprint\"`\n}\n\nfunc (r fingerprintQueryRow) valid() bool {\n\treturn r.Type.Valid\n}\n\nfunc (r *fingerprintQueryRow) resolve() models.Fingerprint {\n\treturn models.Fingerprint{\n\t\tType:        r.Type.String,\n\t\tFingerprint: r.Fingerprint,\n\t}\n}\n\ntype fingerprintQueryBuilder struct {\n\trepository\n\n\ttableMgr *table\n}\n\nvar FingerprintReaderWriter = &fingerprintQueryBuilder{\n\trepository: repository{\n\t\ttableName: fingerprintTable,\n\t\tidColumn:  fileIDColumn,\n\t},\n\n\ttableMgr: fingerprintTableMgr,\n}\n\nfunc (qb *fingerprintQueryBuilder) insert(ctx context.Context, fileID models.FileID, f models.Fingerprint) error {\n\ttable := qb.table()\n\tq := dialect.Insert(table).Cols(fileIDColumn, \"type\", \"fingerprint\").Vals(\n\t\tgoqu.Vals{fileID, f.Type, f.Fingerprint},\n\t)\n\t_, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"inserting into %s: %w\", table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (qb *fingerprintQueryBuilder) insertJoins(ctx context.Context, fileID models.FileID, f []models.Fingerprint) error {\n\tfor _, ff := range f {\n\t\tif err := qb.insert(ctx, fileID, ff); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *fingerprintQueryBuilder) upsertJoins(ctx context.Context, fileID models.FileID, f []models.Fingerprint) error {\n\ttypes := make([]string, len(f))\n\tfor i, ff := range f {\n\t\ttypes[i] = ff.Type\n\t}\n\n\tif err := qb.destroyJoins(ctx, fileID, types); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, ff := range f {\n\t\tif err := qb.insert(ctx, fileID, ff); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *fingerprintQueryBuilder) replaceJoins(ctx context.Context, fileID models.FileID, f []models.Fingerprint) error {\n\tif err := qb.destroy(ctx, []int{int(fileID)}); err != nil {\n\t\treturn err\n\t}\n\n\treturn qb.insertJoins(ctx, fileID, f)\n}\n\nfunc (qb *fingerprintQueryBuilder) destroyJoins(ctx context.Context, fileID models.FileID, types []string) error {\n\ttable := qb.table()\n\tq := dialect.Delete(table).Where(\n\t\ttable.Col(fileIDColumn).Eq(fileID),\n\t\ttable.Col(\"type\").In(types),\n\t)\n\n\t_, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"deleting from %s: %w\", table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (qb *fingerprintQueryBuilder) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n"
  },
  {
    "path": "pkg/sqlite/folder.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"slices\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"gopkg.in/guregu/null.v4\"\n)\n\nconst folderTable = \"folders\"\nconst folderIDColumn = \"folder_id\"\n\ntype folderRow struct {\n\tID             models.FolderID `db:\"id\" goqu:\"skipinsert\"`\n\tBasename       string          `db:\"basename\"`\n\tPath           string          `db:\"path\"`\n\tZipFileID      null.Int        `db:\"zip_file_id\"`\n\tParentFolderID null.Int        `db:\"parent_folder_id\"`\n\tModTime        Timestamp       `db:\"mod_time\"`\n\tCreatedAt      Timestamp       `db:\"created_at\"`\n\tUpdatedAt      Timestamp       `db:\"updated_at\"`\n}\n\nfunc (r *folderRow) fromFolder(o models.Folder) {\n\tr.ID = o.ID\n\t// derive basename from path\n\tr.Basename = filepath.Base(o.Path)\n\tr.Path = o.Path\n\tr.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID)\n\tr.ParentFolderID = nullIntFromFolderIDPtr(o.ParentFolderID)\n\tr.ModTime = Timestamp{Timestamp: o.ModTime}\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n}\n\ntype folderQueryRow struct {\n\tfolderRow\n\n\tZipBasename   null.String `db:\"zip_basename\"`\n\tZipFolderPath null.String `db:\"zip_folder_path\"`\n\tZipSize       null.Int    `db:\"zip_size\"`\n}\n\nfunc (r *folderQueryRow) resolve() *models.Folder {\n\tret := &models.Folder{\n\t\tID: r.ID,\n\t\tDirEntry: models.DirEntry{\n\t\t\tZipFileID: nullIntFileIDPtr(r.ZipFileID),\n\t\t\tModTime:   r.ModTime.Timestamp,\n\t\t},\n\t\tPath:           string(r.Path),\n\t\tParentFolderID: nullIntFolderIDPtr(r.ParentFolderID),\n\t\tCreatedAt:      r.CreatedAt.Timestamp,\n\t\tUpdatedAt:      r.UpdatedAt.Timestamp,\n\t}\n\n\tif ret.ZipFileID != nil && r.ZipFolderPath.Valid && r.ZipBasename.Valid {\n\t\tret.ZipFile = &models.BaseFile{\n\t\t\tID:       *ret.ZipFileID,\n\t\t\tPath:     filepath.Join(r.ZipFolderPath.String, r.ZipBasename.String),\n\t\t\tBasename: r.ZipBasename.String,\n\t\t\tSize:     r.ZipSize.Int64,\n\t\t}\n\t}\n\n\treturn ret\n}\n\ntype folderQueryRows []folderQueryRow\n\nfunc (r folderQueryRows) resolve() []*models.Folder {\n\tvar ret []*models.Folder\n\n\tfor _, row := range r {\n\t\tf := row.resolve()\n\t\tret = append(ret, f)\n\t}\n\n\treturn ret\n}\n\ntype folderRepositoryType struct {\n\trepository\n\n\tgalleries repository\n}\n\nvar (\n\tfolderRepository = folderRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: folderTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\tgalleries: repository{\n\t\t\ttableName: galleryTable,\n\t\t\tidColumn:  folderIDColumn,\n\t\t},\n\t}\n)\n\ntype FolderStore struct {\n\trepository\n\n\ttableMgr *table\n}\n\nfunc NewFolderStore() *FolderStore {\n\treturn &FolderStore{\n\t\trepository: repository{\n\t\t\ttableName: folderTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\n\t\ttableMgr: folderTableMgr,\n\t}\n}\n\nfunc (qb *FolderStore) Create(ctx context.Context, f *models.Folder) error {\n\tvar r folderRow\n\tr.fromFolder(*f)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// only assign id once we are successful\n\tf.ID = models.FolderID(id)\n\n\treturn nil\n}\n\nfunc (qb *FolderStore) Update(ctx context.Context, updatedObject *models.Folder) error {\n\tvar r folderRow\n\tr.fromFolder(*updatedObject)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *FolderStore) Destroy(ctx context.Context, id models.FolderID) error {\n\treturn qb.tableMgr.destroyExisting(ctx, []int{int(id)})\n}\n\nfunc (qb *FolderStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *FolderStore) selectDataset() *goqu.SelectDataset {\n\ttable := qb.table()\n\tfileTable := fileTableMgr.table\n\n\tzipFileTable := fileTable.As(\"zip_files\")\n\tzipFolderTable := table.As(\"zip_files_folders\")\n\n\tcols := []interface{}{\n\t\ttable.Col(\"id\"),\n\t\ttable.Col(\"path\"),\n\t\ttable.Col(\"zip_file_id\"),\n\t\ttable.Col(\"parent_folder_id\"),\n\t\ttable.Col(\"mod_time\"),\n\t\ttable.Col(\"created_at\"),\n\t\ttable.Col(\"updated_at\"),\n\t\tzipFileTable.Col(\"basename\").As(\"zip_basename\"),\n\t\tzipFolderTable.Col(\"path\").As(\"zip_folder_path\"),\n\t\t// size is needed to open containing zip files\n\t\tzipFileTable.Col(\"size\").As(\"zip_size\"),\n\t}\n\n\tret := dialect.From(table).Select(cols...)\n\n\treturn ret.LeftJoin(\n\t\tzipFileTable,\n\t\tgoqu.On(table.Col(\"zip_file_id\").Eq(zipFileTable.Col(\"id\"))),\n\t).LeftJoin(\n\t\tzipFolderTable,\n\t\tgoqu.On(zipFileTable.Col(\"parent_folder_id\").Eq(zipFolderTable.Col(idColumn))),\n\t)\n}\n\nfunc (qb *FolderStore) countDataset() *goqu.SelectDataset {\n\ttable := qb.table()\n\tfileTable := fileTableMgr.table\n\n\tzipFileTable := fileTable.As(\"zip_files\")\n\tzipFolderTable := table.As(\"zip_files_folders\")\n\n\tret := dialect.From(table).Select(goqu.COUNT(goqu.DISTINCT(table.Col(\"id\"))))\n\n\treturn ret.LeftJoin(\n\t\tzipFileTable,\n\t\tgoqu.On(table.Col(\"zip_file_id\").Eq(zipFileTable.Col(\"id\"))),\n\t).LeftJoin(\n\t\tzipFolderTable,\n\t\tgoqu.On(zipFileTable.Col(\"parent_folder_id\").Eq(zipFolderTable.Col(idColumn))),\n\t)\n}\n\nfunc (qb *FolderStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Folder, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *FolderStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Folder, error) {\n\tconst single = false\n\tvar rows folderQueryRows\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f folderQueryRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trows = append(rows, f)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn rows.resolve(), nil\n}\n\nfunc (qb *FolderStore) Find(ctx context.Context, id models.FolderID) (*models.Folder, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting folder by id %d: %w\", id, err)\n\t}\n\n\treturn ret, nil\n}\n\n// FindByIDs finds multiple folders by their IDs.\n// No check is made to see if the folders exist, and the order of the returned folders\n// is not guaranteed to be the same as the order of the input IDs.\nfunc (qb *FolderStore) FindByIDs(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {\n\tfolders := make([]*models.Folder, 0, len(ids))\n\n\ttable := qb.table()\n\tif err := batchExec(ids, defaultBatchSize, func(batch []models.FolderID) error {\n\t\tq := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))\n\t\tunsorted, err := qb.getMany(ctx, q)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfolders = append(folders, unsorted...)\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn folders, nil\n}\n\nfunc (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {\n\tfolders := make([]*models.Folder, len(ids))\n\n\tunsorted, err := qb.FindByIDs(ctx, ids)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, s := range unsorted {\n\t\ti := slices.Index(ids, s.ID)\n\t\tfolders[i] = s\n\t}\n\n\tfor i := range folders {\n\t\tif folders[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"folder with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn folders, nil\n}\n\nfunc (qb *FolderStore) FindByPath(ctx context.Context, p string, caseSensitive bool) (*models.Folder, error) {\n\t// use like for case insensitive search\n\tvar criterion exp.BooleanExpression\n\tif caseSensitive {\n\t\tcriterion = qb.table().Col(\"path\").Eq(p)\n\t} else {\n\t\tcriterion = qb.table().Col(\"path\").ILike(p)\n\t}\n\n\tq := qb.selectDataset().Prepared(true).Where(criterion)\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, fmt.Errorf(\"getting folder by path %s: %w\", p, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *FolderStore) FindByParentFolderID(ctx context.Context, parentFolderID models.FolderID) ([]*models.Folder, error) {\n\tq := qb.selectDataset().Where(qb.table().Col(\"parent_folder_id\").Eq(int(parentFolderID)))\n\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting folders by parent folder id %d: %w\", parentFolderID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *FolderStore) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {\n\ttable := qb.table()\n\n\t// SQL recursive query to get all parent folder IDs for each folder ID\n\t/*\n\t\tWITH RECURSIVE parent_folders AS (\n\t\t    SELECT id, parent_folder_id\n\t\t    FROM folders\n\t\t    WHERE id IN (folderIDs)\n\n\t\t    UNION ALL\n\n\t\t    SELECT f.id, f.parent_folder_id\n\t\t    FROM folders f\n\t\t    INNER JOIN parent_folders pf ON f.id = pf.parent_folder_id\n\t\t)\n\t\tSELECT id, parent_folder_id FROM parent_folders;\n\t*/\n\tconst parentFolders = \"parent_folders\"\n\tconst parentFolderID = \"parent_folder_id\"\n\tconst parentID = \"parent_id\"\n\tconst foldersAlias = \"f\"\n\n\tconst parentFoldersAlias = \"pf\"\n\tfoldersAliasedI := table.As(foldersAlias)\n\tparentFoldersI := goqu.T(parentFolders).As(parentFoldersAlias)\n\n\tq := dialect.From(parentFolders).Prepared(true).\n\t\tWithRecursive(parentFolders,\n\t\t\tdialect.From(table).Select(table.Col(idColumn), table.Col(parentFolderID).As(parentID)).\n\t\t\t\tWhere(table.Col(idColumn).In(folderIDs)).\n\t\t\t\tUnion(\n\t\t\t\t\tdialect.From(foldersAliasedI).InnerJoin(\n\t\t\t\t\t\tparentFoldersI,\n\t\t\t\t\t\tgoqu.On(foldersAliasedI.Col(idColumn).Eq(parentFoldersI.Col(parentID))),\n\t\t\t\t\t).Select(foldersAliasedI.Col(idColumn), foldersAliasedI.Col(parentFolderID).As(parentID)),\n\t\t\t\t),\n\t\t).Select(idColumn, parentID)\n\n\ttype resultRow struct {\n\t\tFolderID       models.FolderID `db:\"id\"`\n\t\tParentFolderID null.Int        `db:\"parent_id\"`\n\t}\n\n\tfolderMap := make(map[models.FolderID]models.FolderID)\n\n\tif err := queryFunc(ctx, q, false, func(r *sqlx.Rows) error {\n\t\tvar row resultRow\n\t\tif err := r.StructScan(&row); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif row.ParentFolderID.Valid {\n\t\t\tfolderMap[row.FolderID] = models.FolderID(row.ParentFolderID.Int64)\n\t\t} else {\n\t\t\tfolderMap[row.FolderID] = 0\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([][]models.FolderID, len(folderIDs))\n\n\tfor i, folderID := range folderIDs {\n\t\tvar parents []models.FolderID\n\t\tcurrentID := folderID\n\n\t\tfor {\n\t\t\tparentID, exists := folderMap[currentID]\n\t\t\tif !exists || parentID == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tparents = append(parents, parentID)\n\t\t\tcurrentID = parentID\n\t\t}\n\n\t\tret[i] = parents\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset {\n\ttable := qb.table()\n\n\tvar conds []exp.Expression\n\tfor _, pp := range p {\n\t\tppWildcard := pp + string(filepath.Separator) + \"%\"\n\n\t\tconds = append(conds, table.Col(\"path\").Eq(pp), table.Col(\"path\").Like(ppWildcard))\n\t}\n\n\treturn q.Where(\n\t\tgoqu.Or(conds...),\n\t)\n}\n\n// FindAllInPaths returns the all folders that are or are within any of the given paths.\n// Returns all if limit is < 0.\n// Returns all folders if p is empty.\nfunc (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*models.Folder, error) {\n\tq := qb.selectDataset().Prepared(true)\n\tq = qb.allInPaths(q, p)\n\n\tif !includeZipContents {\n\t\tq = q.Where(qb.table().Col(\"zip_file_id\").IsNull())\n\t}\n\n\tif limit > -1 {\n\t\tq = q.Limit(uint(limit))\n\t}\n\n\tq = q.Offset(uint(offset))\n\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, fmt.Errorf(\"getting folders in path %s: %w\", p, err)\n\t}\n\n\treturn ret, nil\n}\n\n// CountAllInPaths returns a count of all folders that are within any of the given paths.\n// Returns count of all folders if p is empty.\nfunc (qb *FolderStore) CountAllInPaths(ctx context.Context, p []string) (int, error) {\n\tq := qb.countDataset().Prepared(true)\n\tq = qb.allInPaths(q, p)\n\n\treturn count(ctx, q)\n}\n\n// func (qb *FolderStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*file.Folder, error) {\n// \ttable := qb.table()\n\n// \tq := qb.selectDataset().Prepared(true).Where(\n// \t\ttable.Col(idColumn).Eq(\n// \t\t\tsq,\n// \t\t),\n// \t)\n\n// \treturn qb.getMany(ctx, q)\n// }\n\nfunc (qb *FolderStore) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Folder, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().Prepared(true).Where(\n\t\ttable.Col(\"zip_file_id\").Eq(zipFileID),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\nfunc (qb *FolderStore) validateFilter(fileFilter *models.FolderFilterType) error {\n\tconst and = \"AND\"\n\tconst or = \"OR\"\n\tconst not = \"NOT\"\n\n\tif fileFilter.And != nil {\n\t\tif fileFilter.Or != nil {\n\t\t\treturn illegalFilterCombination(and, or)\n\t\t}\n\t\tif fileFilter.Not != nil {\n\t\t\treturn illegalFilterCombination(and, not)\n\t\t}\n\n\t\treturn qb.validateFilter(fileFilter.And)\n\t}\n\n\tif fileFilter.Or != nil {\n\t\tif fileFilter.Not != nil {\n\t\t\treturn illegalFilterCombination(or, not)\n\t\t}\n\n\t\treturn qb.validateFilter(fileFilter.Or)\n\t}\n\n\tif fileFilter.Not != nil {\n\t\treturn qb.validateFilter(fileFilter.Not)\n\t}\n\n\treturn nil\n}\n\nfunc (qb *FolderStore) makeFilter(ctx context.Context, folderFilter *models.FolderFilterType) *filterBuilder {\n\tquery := &filterBuilder{}\n\n\tif folderFilter.And != nil {\n\t\tquery.and(qb.makeFilter(ctx, folderFilter.And))\n\t}\n\tif folderFilter.Or != nil {\n\t\tquery.or(qb.makeFilter(ctx, folderFilter.Or))\n\t}\n\tif folderFilter.Not != nil {\n\t\tquery.not(qb.makeFilter(ctx, folderFilter.Not))\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &folderFilterHandler{\n\t\tfolderFilter: folderFilter,\n\t})\n\n\treturn filter\n}\n\nfunc (qb *FolderStore) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {\n\tfolderFilter := options.FolderFilter\n\tfindFilter := options.FindFilter\n\n\tif folderFilter == nil {\n\t\tfolderFilter = &models.FolderFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := qb.newQuery()\n\n\tdistinctIDs(&query, folderTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tsearchColumns := []string{\"folders.path\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tif err := qb.validateFilter(folderFilter); err != nil {\n\t\treturn nil, err\n\t}\n\tfilter := qb.makeFilter(ctx, folderFilter)\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.setQuerySort(&query, findFilter); err != nil {\n\t\treturn nil, err\n\t}\n\tquery.sortAndPagination += getPagination(findFilter)\n\n\tresult, err := qb.queryGroupedFields(ctx, options, query)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error querying aggregate fields: %w\", err)\n\t}\n\n\tidsResult, err := query.findIDs(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding IDs: %w\", err)\n\t}\n\n\tresult.IDs = make([]models.FolderID, len(idsResult))\n\tfor i, id := range idsResult {\n\t\tresult.IDs[i] = models.FolderID(id)\n\t}\n\n\treturn result, nil\n}\n\nfunc (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.FolderQueryOptions, query queryBuilder) (*models.FolderQueryResult, error) {\n\tif !options.Count {\n\t\t// nothing to do - return empty result\n\t\treturn models.NewFolderQueryResult(qb), nil\n\t}\n\n\taggregateQuery := qb.newQuery()\n\n\tif options.Count {\n\t\taggregateQuery.addColumn(\"COUNT(DISTINCT temp.id) as total\")\n\t}\n\n\tconst includeSortPagination = false\n\taggregateQuery.from = fmt.Sprintf(\"(%s) as temp\", query.toSQL(includeSortPagination))\n\n\tout := struct {\n\t\tTotal      int\n\t\tDuration   float64\n\t\tMegapixels float64\n\t\tSize       int64\n\t}{}\n\tif err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := models.NewFolderQueryResult(qb)\n\tret.Count = out.Total\n\n\treturn ret, nil\n}\n\nvar folderSortOptions = sortOptions{\n\t\"created_at\",\n\t\"id\",\n\t\"path\",\n\t\"basename\",\n\t\"random\",\n\t\"updated_at\",\n}\n\nfunc (qb *FolderStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error {\n\tif findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == \"\" {\n\t\treturn nil\n\t}\n\tsort := findFilter.GetSort(\"path\")\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := folderSortOptions.validateSort(sort); err != nil {\n\t\treturn err\n\t}\n\n\tdirection := findFilter.GetDirection()\n\tquery.sortAndPagination += getSort(sort, direction, \"folders\")\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/folder_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype folderFilterHandler struct {\n\tfolderFilter *models.FolderFilterType\n\ttable        sqlTable\n\tisRelated    bool\n}\n\nfunc (qb *folderFilterHandler) validate() error {\n\tfolderFilter := qb.folderFilter\n\tif folderFilter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(folderFilter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif qb.isRelated && (folderFilter.GalleriesFilter != nil) {\n\t\treturn fmt.Errorf(\"cannot use related filters inside a related filter\")\n\t}\n\n\tif subFilter := folderFilter.SubFilter(); subFilter != nil {\n\t\tsqb := &folderFilterHandler{folderFilter: subFilter, isRelated: qb.isRelated}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *folderFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tfolderFilter := qb.folderFilter\n\tif folderFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := folderFilter.SubFilter()\n\tif sf != nil {\n\t\tsub := &folderFilterHandler{folderFilter: sf, table: qb.table}\n\t\thandleSubFilter(ctx, sub, f, folderFilter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *folderFilterHandler) criterionHandler() criterionHandler {\n\tif qb.table == \"\" {\n\t\tqb.table = folderTable\n\t}\n\n\tfolderFilter := qb.folderFilter\n\treturn compoundHandler{\n\t\tstringCriterionHandler(folderFilter.Path, qb.table.Col(\"path\")),\n\t\tstringCriterionHandler(folderFilter.Basename, qb.table.Col(\"basename\")),\n\t\t&timestampCriterionHandler{folderFilter.ModTime, qb.table.Col(\"mod_time\"), nil},\n\n\t\tqb.parentFolderCriterionHandler(folderFilter.ParentFolder),\n\t\tqb.zipFileCriterionHandler(folderFilter.ZipFile),\n\n\t\tqb.galleryCountCriterionHandler(folderFilter.GalleryCount),\n\n\t\t&timestampCriterionHandler{folderFilter.CreatedAt, qb.table.Col(\"created_at\"), nil},\n\t\t&timestampCriterionHandler{folderFilter.UpdatedAt, qb.table.Col(\"updated_at\"), nil},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   qb.table.Col(\"id\"),\n\t\t\trelatedRepo:    galleryRepository.repository,\n\t\t\trelatedHandler: &galleryFilterHandler{folderFilter.GalleriesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tfolderRepository.galleries.innerJoin(f, \"\", qb.table.Col(\"id\"))\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (qb *folderFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif criterion != nil {\n\t\t\tif criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s.zip_file_id IS %s NULL\", qb.table.Name(), notClause))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(criterion.Value) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar args []interface{}\n\t\t\tfor _, tagID := range criterion.Value {\n\t\t\t\targs = append(args, tagID)\n\t\t\t}\n\n\t\t\twhereClause := \"\"\n\t\t\thavingClause := \"\"\n\t\t\tswitch criterion.Modifier {\n\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\twhereClause = fmt.Sprintf(\"%s.zip_file_id IN %s\", qb.table.Name(), getInBinding(len(criterion.Value)))\n\t\t\tcase models.CriterionModifierExcludes:\n\t\t\t\twhereClause = fmt.Sprintf(\"%s.zip_file_id NOT IN %s\", qb.table.Name(), getInBinding(len(criterion.Value)))\n\t\t\t}\n\n\t\t\tf.addWhere(whereClause, args...)\n\t\t\tf.addHaving(havingClause)\n\t\t}\n\t}\n}\n\nfunc (qb *folderFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif folder == nil {\n\t\t\treturn\n\t\t}\n\n\t\tfolderCopy := *folder\n\t\tswitch folderCopy.Modifier {\n\t\tcase models.CriterionModifierEquals:\n\t\t\tfolderCopy.Modifier = models.CriterionModifierIncludesAll\n\t\tcase models.CriterionModifierNotEquals:\n\t\t\tfolderCopy.Modifier = models.CriterionModifierExcludes\n\t\t}\n\n\t\thh := hierarchicalMultiCriterionHandlerBuilder{\n\t\t\tprimaryTable: qb.table.Name(),\n\t\t\tforeignTable: qb.table.Name(),\n\t\t\tforeignFK:    \"parent_folder_id\",\n\t\t\tparentFK:     \"parent_folder_id\",\n\t\t}\n\n\t\thh.handler(&folderCopy)(ctx, f)\n\t}\n}\n\nfunc (qb *folderFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif galleryCount != nil {\n\t\t\tf.addLeftJoin(\"galleries\", \"\", \"galleries.folder_id = folders.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct galleries.id)\", *galleryCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/folder_filter_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFolderQuery(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.FolderFilterType\n\t\tincludeIdxs []int\n\t\tincludeIDs  []models.FolderID\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\tname: \"path\",\n\t\t\tfilter: &models.FolderFilterType{\n\t\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getFolderPath(folderIdxWithSubFolder, nil),\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder},\n\t\t\texcludeIdxs: []int{folderIdxInZip},\n\t\t},\n\t\t{\n\t\t\tname: \"basename\",\n\t\t\tfilter: &models.FolderFilterType{\n\t\t\t\tBasename: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getFolderBasename(folderIdxWithParentFolder, nil),\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{folderIdxWithParentFolder},\n\t\t\texcludeIdxs: []int{folderIdxInZip},\n\t\t},\n\t\t{\n\t\t\tname: \"parent folder\",\n\t\t\tfilter: &models.FolderFilterType{\n\t\t\t\tParentFolder: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(int(folderIDs[folderIdxWithSubFolder])),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{folderIdxWithParentFolder},\n\t\t\texcludeIdxs: []int{folderIdxWithSubFolder, folderIdxInZip},\n\t\t},\n\t\t{\n\t\t\tname: \"zip file\",\n\t\t\tfilter: &models.FolderFilterType{\n\t\t\t\tZipFile: &models.MultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(int(fileIDs[fileIdxZip])),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tincludeIdxs: []int{folderIdxInZip},\n\t\t\texcludeIdxs: []int{folderIdxForObjectFiles},\n\t\t},\n\t\t// TODO - add more tests for other folder filters\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.Folder.Query(ctx, models.FolderQueryOptions{\n\t\t\t\tFolderFilter: tt.filter,\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: tt.findFilter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDPtrs(folderIDs, tt.includeIdxs)\n\t\t\tfor _, id := range tt.includeIDs {\n\t\t\t\tv := id\n\t\t\t\tinclude = append(include, &v)\n\t\t\t}\n\t\t\texclude := indexesToIDPtrs(folderIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, models.FolderID(*i))\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, models.FolderID(*e))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/folder_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nvar (\n\tinvalidFolderID = models.FolderID(invalidID)\n\tinvalidFileID   = models.FileID(invalidID)\n)\n\nfunc Test_FolderStore_Create(t *testing.T) {\n\tvar (\n\t\tpath        = \"path\"\n\t\tfileModTime = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tcreatedAt   = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt   = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.Folder\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.Folder{\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tZipFileID: &fileIDs[fileIdxZip],\n\t\t\t\t\tZipFile:   makeZipFileWithID(fileIdxZip),\n\t\t\t\t\tModTime:   fileModTime,\n\t\t\t\t},\n\t\t\t\tPath:      path,\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid parent folder id\",\n\t\t\tmodels.Folder{\n\t\t\t\tPath:           path,\n\t\t\t\tParentFolderID: &invalidFolderID,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid zip file id\",\n\t\t\tmodels.Folder{\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tZipFileID: &invalidFileID,\n\t\t\t\t},\n\t\t\t\tPath: path,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Folder\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\ts := tt.newObject\n\t\t\tif err := qb.Create(ctx, &s); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FolderStore.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(s.ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(s.ID)\n\n\t\t\tcopy := tt.newObject\n\t\t\tcopy.ID = s.ID\n\n\t\t\tassert.Equal(copy, s)\n\n\t\t\t// ensure can find the folder\n\t\t\tfound, err := qb.FindByPath(ctx, path, true)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"FolderStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *found)\n\t\t})\n\t}\n}\n\nfunc Test_FolderStore_Update(t *testing.T) {\n\tvar (\n\t\tpath        = \"path\"\n\t\tfileModTime = time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC)\n\t\tcreatedAt   = time.Date(2001, 1, 2, 3, 4, 5, 0, time.UTC)\n\t\tupdatedAt   = time.Date(2002, 1, 2, 3, 4, 5, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject *models.Folder\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\t&models.Folder{\n\t\t\t\tID: folderIDs[folderIdxWithParentFolder],\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tZipFileID: &fileIDs[fileIdxZip],\n\t\t\t\t\tZipFile:   makeZipFileWithID(fileIdxZip),\n\t\t\t\t\tModTime:   fileModTime,\n\t\t\t\t},\n\t\t\t\tPath:      path,\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear zip\",\n\t\t\t&models.Folder{\n\t\t\t\tID:   folderIDs[folderIdxInZip],\n\t\t\t\tPath: path,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear folder\",\n\t\t\t&models.Folder{\n\t\t\t\tID:   folderIDs[folderIdxWithParentFolder],\n\t\t\t\tPath: path,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid parent folder id\",\n\t\t\t&models.Folder{\n\t\t\t\tID:             folderIDs[folderIdxWithParentFolder],\n\t\t\t\tPath:           path,\n\t\t\t\tParentFolderID: &invalidFolderID,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid zip file id\",\n\t\t\t&models.Folder{\n\t\t\t\tID: folderIDs[folderIdxWithParentFolder],\n\t\t\t\tDirEntry: models.DirEntry{\n\t\t\t\t\tZipFileID: &invalidFileID,\n\t\t\t\t},\n\t\t\t\tPath: path,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Folder\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tcopy := *tt.updatedObject\n\n\t\t\tif err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FolderStore.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.FindByPath(ctx, path, true)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"FolderStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *s)\n\t\t})\n\t}\n}\n\nfunc makeFolderWithID(index int) *models.Folder {\n\tret := makeFolder(index)\n\tret.ID = folderIDs[index]\n\n\treturn &ret\n}\n\nfunc Test_FolderStore_FindByPath(t *testing.T) {\n\tgetPath := func(index int) string {\n\t\treturn folderPaths[index]\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tpath    string\n\t\twant    *models.Folder\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgetPath(folderIdxWithFiles),\n\t\t\tmakeFolderWithID(folderIdxWithFiles),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid path\",\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Folder\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.FindByPath(ctx, tt.path, true)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FolderStore.FindByPath() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"FolderStore.FindByPath() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_FolderStore_GetManyParentFolderIDs(t *testing.T) {\n\tvar empty []models.FolderID\n\temptyResult := [][]models.FolderID{empty}\n\ttests := []struct {\n\t\tname            string\n\t\tparentFolderIDs []models.FolderID\n\t\twant            [][]models.FolderID\n\t\twantErr         bool\n\t}{\n\t\t{\n\t\t\t\"valid with parent folders\",\n\t\t\t[]models.FolderID{folderIDs[folderIdxWithParentFolder]},\n\t\t\t[][]models.FolderID{\n\t\t\t\t{\n\t\t\t\t\tfolderIDs[folderIdxWithSubFolder],\n\t\t\t\t\tfolderIDs[folderIdxRoot],\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"valid multiple folders\",\n\t\t\t[]models.FolderID{\n\t\t\t\tfolderIDs[folderIdxWithParentFolder],\n\t\t\t\tfolderIDs[folderIdxWithSceneFiles],\n\t\t\t},\n\t\t\t[][]models.FolderID{\n\t\t\t\t{\n\t\t\t\t\tfolderIDs[folderIdxWithSubFolder],\n\t\t\t\t\tfolderIDs[folderIdxRoot],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tfolderIDs[folderIdxForObjectFiles],\n\t\t\t\t\tfolderIDs[folderIdxRoot],\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"valid without parent folders\",\n\t\t\t[]models.FolderID{folderIDs[folderIdxRoot]},\n\t\t\temptyResult,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid folder id\",\n\t\t\t[]models.FolderID{invalidFolderID},\n\t\t\temptyResult,\n\t\t\t// does not error, just returns empty result\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Folder\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.GetManyParentFolderIDs(ctx, tt.parentFolderIDs)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tassert.Errorf(err, \"FolderStore.GetManyParentFolderIDs() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(got, tt.want)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/functions.go",
    "content": "package sqlite\n\nimport (\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nfunc durationToTinyIntFn(str string) (int64, error) {\n\tsplits := strings.Split(str, \":\")\n\n\tif len(splits) > 3 {\n\t\treturn 0, nil\n\t}\n\n\tseconds := 0\n\tfactor := 1\n\tfor len(splits) > 0 {\n\t\t// pop the last split\n\t\tvar thisSplit string\n\t\tthisSplit, splits = splits[len(splits)-1], splits[:len(splits)-1]\n\n\t\tthisInt, err := strconv.Atoi(thisSplit)\n\t\tif err != nil {\n\t\t\treturn 0, nil\n\t\t}\n\n\t\tseconds += factor * thisInt\n\t\tfactor *= 60\n\t}\n\n\treturn int64(seconds), nil\n}\n\nfunc basenameFn(str string) (string, error) {\n\treturn filepath.Base(str), nil\n}\n"
  },
  {
    "path": "pkg/sqlite/gallery.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"slices\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n)\n\nconst (\n\tgalleryTable = \"galleries\"\n\n\tgalleriesFilesTable      = \"galleries_files\"\n\tperformersGalleriesTable = \"performers_galleries\"\n\tgalleriesTagsTable       = \"galleries_tags\"\n\tgalleriesImagesTable     = \"galleries_images\"\n\tgalleriesScenesTable     = \"scenes_galleries\"\n\tgalleryIDColumn          = \"gallery_id\"\n\tgalleriesURLsTable       = \"gallery_urls\"\n\tgalleriesURLColumn       = \"url\"\n)\n\ntype galleryRow struct {\n\tID            int         `db:\"id\" goqu:\"skipinsert\"`\n\tTitle         zero.String `db:\"title\"`\n\tCode          zero.String `db:\"code\"`\n\tDate          NullDate    `db:\"date\"`\n\tDatePrecision null.Int    `db:\"date_precision\"`\n\tDetails       zero.String `db:\"details\"`\n\tPhotographer  zero.String `db:\"photographer\"`\n\t// expressed as 1-100\n\tRating    null.Int  `db:\"rating\"`\n\tOrganized bool      `db:\"organized\"`\n\tStudioID  null.Int  `db:\"studio_id,omitempty\"`\n\tFolderID  null.Int  `db:\"folder_id,omitempty\"`\n\tCreatedAt Timestamp `db:\"created_at\"`\n\tUpdatedAt Timestamp `db:\"updated_at\"`\n}\n\nfunc (r *galleryRow) fromGallery(o models.Gallery) {\n\tr.ID = o.ID\n\tr.Title = zero.StringFrom(o.Title)\n\tr.Code = zero.StringFrom(o.Code)\n\tr.Date = NullDateFromDatePtr(o.Date)\n\tr.DatePrecision = datePrecisionFromDatePtr(o.Date)\n\tr.Details = zero.StringFrom(o.Details)\n\tr.Photographer = zero.StringFrom(o.Photographer)\n\tr.Rating = intFromPtr(o.Rating)\n\tr.Organized = o.Organized\n\tr.StudioID = intFromPtr(o.StudioID)\n\tr.FolderID = nullIntFromFolderIDPtr(o.FolderID)\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n}\n\ntype galleryQueryRow struct {\n\tgalleryRow\n\tFolderPath            zero.String `db:\"folder_path\"`\n\tPrimaryFileID         null.Int    `db:\"primary_file_id\"`\n\tPrimaryFileFolderPath zero.String `db:\"primary_file_folder_path\"`\n\tPrimaryFileBasename   zero.String `db:\"primary_file_basename\"`\n\tPrimaryFileChecksum   zero.String `db:\"primary_file_checksum\"`\n}\n\nfunc (r *galleryQueryRow) resolve() *models.Gallery {\n\tret := &models.Gallery{\n\t\tID:            r.ID,\n\t\tTitle:         r.Title.String,\n\t\tCode:          r.Code.String,\n\t\tDate:          r.Date.DatePtr(r.DatePrecision),\n\t\tDetails:       r.Details.String,\n\t\tPhotographer:  r.Photographer.String,\n\t\tRating:        nullIntPtr(r.Rating),\n\t\tOrganized:     r.Organized,\n\t\tStudioID:      nullIntPtr(r.StudioID),\n\t\tFolderID:      nullIntFolderIDPtr(r.FolderID),\n\t\tPrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID),\n\t\tCreatedAt:     r.CreatedAt.Timestamp,\n\t\tUpdatedAt:     r.UpdatedAt.Timestamp,\n\t}\n\n\tif r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid {\n\t\tret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String)\n\t} else if r.FolderPath.Valid {\n\t\tret.Path = r.FolderPath.String\n\t}\n\n\treturn ret\n}\n\ntype galleryRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *galleryRowRecord) fromPartial(o models.GalleryPartial) {\n\tr.setNullString(\"title\", o.Title)\n\tr.setNullString(\"code\", o.Code)\n\tr.setNullDate(\"date\", \"date_precision\", o.Date)\n\tr.setNullString(\"details\", o.Details)\n\tr.setNullString(\"photographer\", o.Photographer)\n\tr.setNullInt(\"rating\", o.Rating)\n\tr.setBool(\"organized\", o.Organized)\n\tr.setNullInt(\"studio_id\", o.StudioID)\n\tr.setTimestamp(\"created_at\", o.CreatedAt)\n\tr.setTimestamp(\"updated_at\", o.UpdatedAt)\n}\n\ntype galleryRepositoryType struct {\n\trepository\n\tperformers joinRepository\n\timages     joinRepository\n\ttags       joinRepository\n\tscenes     joinRepository\n\tfiles      filesRepository\n}\n\nfunc (r *galleryRepositoryType) addGalleriesFilesTable(f *filterBuilder) {\n\tf.addLeftJoin(galleriesFilesTable, \"\", \"galleries_files.gallery_id = galleries.id\")\n}\n\nfunc (r *galleryRepositoryType) addFilesTable(f *filterBuilder) {\n\tr.addGalleriesFilesTable(f)\n\tf.addLeftJoin(fileTable, \"\", \"galleries_files.file_id = files.id\")\n}\n\nfunc (r *galleryRepositoryType) addFoldersTable(f *filterBuilder) {\n\tr.addFilesTable(f)\n\tf.addLeftJoin(folderTable, \"\", \"files.parent_folder_id = folders.id\")\n}\n\nvar (\n\tgalleryRepository = galleryRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: galleryTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\tperformers: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: performersGalleriesTable,\n\t\t\t\tidColumn:  galleryIDColumn,\n\t\t\t},\n\t\t\tfkColumn: \"performer_id\",\n\t\t},\n\t\ttags: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: galleriesTagsTable,\n\t\t\t\tidColumn:  galleryIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     \"tag_id\",\n\t\t\tforeignTable: tagTable,\n\t\t\torderBy:      tagTableSortSQL,\n\t\t},\n\t\timages: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: galleriesImagesTable,\n\t\t\t\tidColumn:  galleryIDColumn,\n\t\t\t},\n\t\t\tfkColumn: \"image_id\",\n\t\t},\n\t\tscenes: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: galleriesScenesTable,\n\t\t\t\tidColumn:  galleryIDColumn,\n\t\t\t},\n\t\t\tfkColumn: sceneIDColumn,\n\t\t},\n\t\tfiles: filesRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: galleriesFilesTable,\n\t\t\t\tidColumn:  galleryIDColumn,\n\t\t\t},\n\t\t},\n\t}\n)\n\ntype GalleryStore struct {\n\tcustomFieldsStore\n\n\ttableMgr *table\n\n\tfileStore   *FileStore\n\tfolderStore *FolderStore\n}\n\nfunc NewGalleryStore(fileStore *FileStore, folderStore *FolderStore) *GalleryStore {\n\treturn &GalleryStore{\n\t\tcustomFieldsStore: customFieldsStore{\n\t\t\ttable: galleriesCustomFieldsTable,\n\t\t\tfk:    galleriesCustomFieldsTable.Col(galleryIDColumn),\n\t\t},\n\t\ttableMgr:    galleryTableMgr,\n\t\tfileStore:   fileStore,\n\t\tfolderStore: folderStore,\n\t}\n}\n\nfunc (qb *GalleryStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *GalleryStore) selectDataset() *goqu.SelectDataset {\n\ttable := qb.table()\n\tfiles := fileTableMgr.table\n\tfolders := folderTableMgr.table\n\tgalleryFolder := folderTableMgr.table.As(\"gallery_folder\")\n\n\treturn dialect.From(table).LeftJoin(\n\t\tgalleriesFilesJoinTable,\n\t\tgoqu.On(\n\t\t\tgalleriesFilesJoinTable.Col(galleryIDColumn).Eq(table.Col(idColumn)),\n\t\t\tgalleriesFilesJoinTable.Col(\"primary\").Eq(1),\n\t\t),\n\t).LeftJoin(\n\t\tfiles,\n\t\tgoqu.On(files.Col(idColumn).Eq(galleriesFilesJoinTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\tfolders,\n\t\tgoqu.On(folders.Col(idColumn).Eq(files.Col(\"parent_folder_id\"))),\n\t).LeftJoin(\n\t\tgalleryFolder,\n\t\tgoqu.On(galleryFolder.Col(idColumn).Eq(table.Col(\"folder_id\"))),\n\t).Select(\n\t\tqb.table().All(),\n\t\tgalleriesFilesJoinTable.Col(fileIDColumn).As(\"primary_file_id\"),\n\t\tfolders.Col(\"path\").As(\"primary_file_folder_path\"),\n\t\tfiles.Col(\"basename\").As(\"primary_file_basename\"),\n\t\tgalleryFolder.Col(\"path\").As(\"folder_path\"),\n\t)\n}\n\nfunc (qb *GalleryStore) Create(ctx context.Context, newObject *models.CreateGalleryInput) error {\n\tvar r galleryRow\n\tr.fromGallery(*newObject.Gallery)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(newObject.FileIDs) > 0 {\n\t\tconst firstPrimary = true\n\t\tif err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.URLs.Loaded() {\n\t\tconst startPos = 0\n\t\tif err := galleriesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif newObject.PerformerIDs.Loaded() {\n\t\tif err := galleriesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif newObject.TagIDs.Loaded() {\n\t\tif err := galleriesTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif newObject.SceneIDs.Loaded() {\n\t\tif err := galleriesScenesTableMgr.insertJoins(ctx, id, newObject.SceneIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tconst partial = false\n\tif err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject.Gallery = *updated\n\n\treturn nil\n}\n\nfunc (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.UpdateGalleryInput) error {\n\tvar r galleryRow\n\tr.fromGallery(*updatedObject.Gallery)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\tif updatedObject.URLs.Loaded() {\n\t\tif err := galleriesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif updatedObject.PerformerIDs.Loaded() {\n\t\tif err := galleriesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif updatedObject.TagIDs.Loaded() {\n\t\tif err := galleriesTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif updatedObject.SceneIDs.Loaded() {\n\t\tif err := galleriesScenesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.SceneIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.Files.Loaded() {\n\t\tfileIDs := make([]models.FileID, len(updatedObject.Files.List()))\n\t\tfor i, f := range updatedObject.Files.List() {\n\t\t\tfileIDs[i] = f.Base().ID\n\t\t}\n\n\t\tif err := galleriesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *GalleryStore) UpdatePartial(ctx context.Context, id int, partial models.GalleryPartial) (*models.Gallery, error) {\n\tr := galleryRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(partial)\n\n\tif len(r.Record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.URLs != nil {\n\t\tif err := galleriesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.PerformerIDs != nil {\n\t\tif err := galleriesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.TagIDs != nil {\n\t\tif err := galleriesTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.SceneIDs != nil {\n\t\tif err := galleriesScenesTableMgr.modifyJoins(ctx, id, partial.SceneIDs.IDs, partial.SceneIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.PrimaryFileID != nil {\n\t\tif err := galleriesFilesTableMgr.setPrimary(ctx, id, *partial.PrimaryFileID); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn qb.find(ctx, id)\n}\n\nfunc (qb *GalleryStore) Destroy(ctx context.Context, id int) error {\n\treturn qb.tableMgr.destroyExisting(ctx, []int{id})\n}\n\nfunc (qb *GalleryStore) GetFiles(ctx context.Context, id int) ([]models.File, error) {\n\tfileIDs, err := galleryRepository.files.get(ctx, id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// use fileStore to load files\n\tfiles, err := qb.fileStore.Find(ctx, fileIDs...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([]models.File, len(files))\n\tcopy(ret, files)\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) {\n\tconst primaryOnly = false\n\treturn galleryRepository.files.getMany(ctx, ids, primaryOnly)\n}\n\n// returns nil, nil if not found\nfunc (qb *GalleryStore) Find(ctx context.Context, id int) (*models.Gallery, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *GalleryStore) FindMany(ctx context.Context, ids []int) ([]*models.Gallery, error) {\n\tgalleries := make([]*models.Gallery, len(ids))\n\n\tif err := batchExec(ids, defaultBatchSize, func(batch []int) error {\n\t\tq := qb.selectDataset().Prepared(true).Where(qb.table().Col(idColumn).In(batch))\n\t\tunsorted, err := qb.getMany(ctx, q)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range unsorted {\n\t\t\ti := slices.Index(ids, s.ID)\n\t\t\tgalleries[i] = s\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor i := range galleries {\n\t\tif galleries[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"gallery with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn galleries, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *GalleryStore) find(ctx context.Context, id int) (*models.Gallery, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Gallery, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().Prepared(true).Where(\n\t\ttable.Col(idColumn).Eq(\n\t\t\tsq,\n\t\t),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *GalleryStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Gallery, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *GalleryStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Gallery, error) {\n\tconst single = false\n\tvar ret []*models.Gallery\n\tvar lastID int\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f galleryQueryRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tif s.ID == lastID {\n\t\t\treturn fmt.Errorf(\"internal error: multiple rows returned for single gallery id %d\", s.ID)\n\t\t}\n\t\tlastID = s.ID\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) {\n\tsq := dialect.From(galleriesFilesJoinTable).Select(galleriesFilesJoinTable.Col(galleryIDColumn)).Where(\n\t\tgalleriesFilesJoinTable.Col(fileIDColumn).Eq(fileID),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting gallery by file id %d: %w\", fileID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) {\n\tjoinTable := galleriesFilesJoinTable\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(joinTable).Where(joinTable.Col(fileIDColumn).Eq(fileID))\n\treturn count(ctx, q)\n}\n\nfunc (qb *GalleryStore) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) {\n\tfingerprintTable := fingerprintTableMgr.table\n\n\tvar ex []exp.Expression\n\n\tfor _, v := range fp {\n\t\tex = append(ex, goqu.And(\n\t\t\tfingerprintTable.Col(\"type\").Eq(v.Type),\n\t\t\tfingerprintTable.Col(\"fingerprint\").Eq(v.Fingerprint),\n\t\t))\n\t}\n\n\tsq := dialect.From(galleriesFilesJoinTable).\n\t\tInnerJoin(\n\t\t\tfingerprintTable,\n\t\t\tgoqu.On(fingerprintTable.Col(fileIDColumn).Eq(galleriesFilesJoinTable.Col(fileIDColumn))),\n\t\t).\n\t\tSelect(galleriesFilesJoinTable.Col(galleryIDColumn)).Where(goqu.Or(ex...))\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting gallery by fingerprints: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error) {\n\treturn qb.FindByFingerprints(ctx, []models.Fingerprint{\n\t\t{\n\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\tFingerprint: checksum,\n\t\t},\n\t})\n}\n\nfunc (qb *GalleryStore) FindByChecksums(ctx context.Context, checksums []string) ([]*models.Gallery, error) {\n\tfingerprints := make([]models.Fingerprint, len(checksums))\n\n\tfor i, c := range checksums {\n\t\tfingerprints[i] = models.Fingerprint{\n\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\tFingerprint: c,\n\t\t}\n\t}\n\treturn qb.FindByFingerprints(ctx, fingerprints)\n}\n\nfunc (qb *GalleryStore) FindByPath(ctx context.Context, p string) ([]*models.Gallery, error) {\n\ttable := qb.table()\n\tfilesTable := fileTableMgr.table\n\tfileFoldersTable := folderTableMgr.table.As(\"file_folders\")\n\tfoldersTable := folderTableMgr.table\n\n\tbasename := filepath.Base(p)\n\tdir := filepath.Dir(p)\n\n\tsq := dialect.From(table).LeftJoin(\n\t\tgalleriesFilesJoinTable,\n\t\tgoqu.On(galleriesFilesJoinTable.Col(galleryIDColumn).Eq(table.Col(idColumn))),\n\t).LeftJoin(\n\t\tfilesTable,\n\t\tgoqu.On(filesTable.Col(idColumn).Eq(galleriesFilesJoinTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\tfileFoldersTable,\n\t\tgoqu.On(fileFoldersTable.Col(idColumn).Eq(filesTable.Col(\"parent_folder_id\"))),\n\t).LeftJoin(\n\t\tfoldersTable,\n\t\tgoqu.On(foldersTable.Col(idColumn).Eq(table.Col(\"folder_id\"))),\n\t).Select(table.Col(idColumn)).Where(\n\t\tgoqu.Or(\n\t\t\tgoqu.And(\n\t\t\t\tfileFoldersTable.Col(\"path\").Eq(dir),\n\t\t\t\tfilesTable.Col(\"basename\").Eq(basename),\n\t\t\t),\n\t\t\tfoldersTable.Col(\"path\").Eq(p),\n\t\t),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, fmt.Errorf(\"getting gallery by path %s: %w\", p, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) {\n\ttable := qb.table()\n\n\tsq := dialect.From(table).Select(table.Col(idColumn)).Where(\n\t\ttable.Col(\"folder_id\").Eq(folderID),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting galleries for folder %d: %w\", folderID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Gallery, error) {\n\tsq := dialect.From(galleriesScenesJoinTable).Select(galleriesScenesJoinTable.Col(galleryIDColumn)).Where(\n\t\tgalleriesScenesJoinTable.Col(sceneIDColumn).Eq(sceneID),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting galleries for scene %d: %w\", sceneID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Gallery, error) {\n\tsq := dialect.From(galleriesImagesJoinTable).Select(galleriesImagesJoinTable.Col(galleryIDColumn)).Where(\n\t\tgalleriesImagesJoinTable.Col(imageIDColumn).Eq(imageID),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting galleries for image %d: %w\", imageID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) CountByImageID(ctx context.Context, imageID int) (int, error) {\n\tjoinTable := galleriesImagesJoinTable\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(joinTable).Where(joinTable.Col(imageIDColumn).Eq(imageID))\n\treturn count(ctx, q)\n}\n\nfunc (qb *GalleryStore) FindUserGalleryByTitle(ctx context.Context, title string) ([]*models.Gallery, error) {\n\ttable := qb.table()\n\n\tsq := dialect.From(table).LeftJoin(\n\t\tgalleriesFilesJoinTable,\n\t\tgoqu.On(galleriesFilesJoinTable.Col(galleryIDColumn).Eq(table.Col(idColumn))),\n\t).Select(table.Col(idColumn)).Where(\n\t\ttable.Col(\"folder_id\").IsNull(),\n\t\tgalleriesFilesJoinTable.Col(\"file_id\").IsNull(),\n\t\ttable.Col(\"title\").Eq(title),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting user galleries for title %s: %w\", title, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryStore) Count(ctx context.Context) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(qb.table())\n\treturn count(ctx, q)\n}\n\nfunc (qb *GalleryStore) All(ctx context.Context) ([]*models.Gallery, error) {\n\treturn qb.getMany(ctx, qb.selectDataset())\n}\n\nfunc (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {\n\tif galleryFilter == nil {\n\t\tgalleryFilter = &models.GalleryFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := galleryRepository.newQuery()\n\tdistinctIDs(&query, galleryTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    galleriesFilesTable,\n\t\t\t\tonClause: \"galleries_files.gallery_id = galleries.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    fileTable,\n\t\t\t\tonClause: \"galleries_files.file_id = files.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    folderTable,\n\t\t\t\tonClause: \"files.parent_folder_id = folders.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    fingerprintTable,\n\t\t\t\tonClause: \"files_fingerprints.file_id = galleries_files.file_id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    folderTable,\n\t\t\t\tas:       \"gallery_folder\",\n\t\t\t\tonClause: \"galleries.folder_id = gallery_folder.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    galleriesChaptersTable,\n\t\t\t\tonClause: \"galleries_chapters.gallery_id = galleries.id\",\n\t\t\t},\n\t\t)\n\n\t\t// add joins for files and checksum\n\t\tfilepathColumn := \"folders.path || '\" + string(filepath.Separator) + \"' || files.basename\"\n\t\tsearchColumns := []string{\"galleries.title\", \"gallery_folder.path\", filepathColumn, \"files_fingerprints.fingerprint\", \"galleries_chapters.title\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &galleryFilterHandler{\n\t\tgalleryFilter: galleryFilter,\n\t})\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.setGallerySort(&query, findFilter); err != nil {\n\t\treturn nil, err\n\t}\n\tquery.sortAndPagination += getPagination(findFilter)\n\n\treturn &query, nil\n}\n\nfunc (qb *GalleryStore) Query(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) {\n\tquery, err := qb.makeQuery(ctx, galleryFilter, findFilter)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tidsResult, countResult, err := query.executeFind(ctx)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tgalleries, err := qb.FindMany(ctx, idsResult)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn galleries, countResult, nil\n}\n\nfunc (qb *GalleryStore) QueryCount(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) {\n\tquery, err := qb.makeQuery(ctx, galleryFilter, findFilter)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn query.executeCount(ctx)\n}\n\nvar gallerySortOptions = sortOptions{\n\t\"created_at\",\n\t\"date\",\n\t\"file_count\",\n\t\"file_mod_time\",\n\t\"id\",\n\t\"images_count\",\n\t\"path\",\n\t\"performer_count\",\n\t\"random\",\n\t\"rating\",\n\t\"tag_count\",\n\t\"title\",\n\t\"updated_at\",\n}\n\nfunc (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.FindFilterType) error {\n\tif findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == \"\" {\n\t\treturn nil\n\t}\n\n\tsort := findFilter.GetSort(\"path\")\n\tdirection := findFilter.GetDirection()\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := gallerySortOptions.validateSort(sort); err != nil {\n\t\treturn err\n\t}\n\n\taddFileTable := func() {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    galleriesFilesTable,\n\t\t\t\tonClause: \"galleries_files.gallery_id = galleries.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    fileTable,\n\t\t\t\tonClause: \"galleries_files.file_id = files.id\",\n\t\t\t},\n\t\t)\n\t}\n\n\taddFolderTable := func() {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    folderTable,\n\t\t\t\tonClause: \"folders.id = galleries.folder_id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    folderTable,\n\t\t\t\tas:       \"file_folder\",\n\t\t\t\tonClause: \"files.parent_folder_id = file_folder.id\",\n\t\t\t},\n\t\t)\n\t}\n\n\tswitch sort {\n\tcase \"file_count\":\n\t\tquery.sortAndPagination += getCountSort(galleryTable, galleriesFilesTable, galleryIDColumn, direction)\n\tcase \"images_count\":\n\t\tquery.sortAndPagination += getCountSort(galleryTable, galleriesImagesTable, galleryIDColumn, direction)\n\tcase \"tag_count\":\n\t\tquery.sortAndPagination += getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction)\n\tcase \"performer_count\":\n\t\tquery.sortAndPagination += getCountSort(galleryTable, performersGalleriesTable, galleryIDColumn, direction)\n\tcase \"path\":\n\t\t// special handling for path\n\t\taddFileTable()\n\t\taddFolderTable()\n\t\tquery.sortAndPagination += fmt.Sprintf(\" ORDER BY COALESCE(folders.path, '') || COALESCE(file_folder.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s\", direction)\n\tcase \"file_mod_time\":\n\t\tsort = \"mod_time\"\n\t\taddFileTable()\n\t\tquery.sortAndPagination += getSort(sort, direction, fileTable)\n\tcase \"title\":\n\t\taddFileTable()\n\t\taddFolderTable()\n\t\tquery.sortAndPagination += \" ORDER BY COALESCE(galleries.title, files.basename, basename(COALESCE(folders.path, ''))) COLLATE NATURAL_CI \" + direction + \", file_folder.path COLLATE NATURAL_CI \" + direction\n\tdefault:\n\t\tquery.sortAndPagination += getSort(sort, direction, \"galleries\")\n\t}\n\n\t// Whatever the sorting, always use title/id as a final sort\n\tquery.sortAndPagination += \", COALESCE(galleries.title, galleries.id) COLLATE NATURAL_CI ASC\"\n\n\treturn nil\n}\n\nfunc (qb *GalleryStore) GetURLs(ctx context.Context, galleryID int) ([]string, error) {\n\treturn galleriesURLsTableMgr.get(ctx, galleryID)\n}\n\nfunc (qb *GalleryStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error {\n\tconst firstPrimary = false\n\treturn galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID})\n}\n\nfunc (qb *GalleryStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) {\n\treturn galleryRepository.performers.getIDs(ctx, id)\n}\n\nfunc (qb *GalleryStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {\n\treturn galleryRepository.tags.getIDs(ctx, id)\n}\n\nfunc (qb *GalleryStore) GetImageIDs(ctx context.Context, galleryID int) ([]int, error) {\n\treturn galleryRepository.images.getIDs(ctx, galleryID)\n}\n\nfunc (qb *GalleryStore) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error {\n\treturn galleryRepository.images.insertOrIgnore(ctx, galleryID, imageIDs...)\n}\n\nfunc (qb *GalleryStore) RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error {\n\treturn galleryRepository.images.destroyJoins(ctx, galleryID, imageIDs...)\n}\n\nfunc (qb *GalleryStore) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error {\n\t// Delete the existing joins and then create new ones\n\treturn galleryRepository.images.replace(ctx, galleryID, imageIDs)\n}\n\nfunc (qb *GalleryStore) SetCover(ctx context.Context, galleryID int, coverImageID int) error {\n\treturn imageGalleriesTableMgr.setCover(ctx, coverImageID, galleryID)\n}\n\nfunc (qb *GalleryStore) ResetCover(ctx context.Context, galleryID int) error {\n\treturn imageGalleriesTableMgr.resetCover(ctx, galleryID)\n}\n\nfunc (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) {\n\treturn galleryRepository.scenes.getIDs(ctx, id)\n}\n\nfunc (qb *GalleryStore) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error {\n\treturn galleriesScenesTableMgr.insertJoins(ctx, galleryID, sceneIDs)\n}\n"
  },
  {
    "path": "pkg/sqlite/gallery_chapter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst (\n\tgalleriesChaptersTable = \"galleries_chapters\"\n)\n\ntype galleryChapterRow struct {\n\tID         int       `db:\"id\" goqu:\"skipinsert\"`\n\tTitle      string    `db:\"title\"` // TODO: make db schema (and gql schema) nullable\n\tImageIndex int       `db:\"image_index\"`\n\tGalleryID  int       `db:\"gallery_id\"`\n\tCreatedAt  Timestamp `db:\"created_at\"`\n\tUpdatedAt  Timestamp `db:\"updated_at\"`\n}\n\nfunc (r *galleryChapterRow) fromGalleryChapter(o models.GalleryChapter) {\n\tr.ID = o.ID\n\tr.Title = o.Title\n\tr.ImageIndex = o.ImageIndex\n\tr.GalleryID = o.GalleryID\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n}\n\nfunc (r *galleryChapterRow) resolve() *models.GalleryChapter {\n\tret := &models.GalleryChapter{\n\t\tID:         r.ID,\n\t\tTitle:      r.Title,\n\t\tImageIndex: r.ImageIndex,\n\t\tGalleryID:  r.GalleryID,\n\t\tCreatedAt:  r.CreatedAt.Timestamp,\n\t\tUpdatedAt:  r.UpdatedAt.Timestamp,\n\t}\n\n\treturn ret\n}\n\ntype galleryChapterRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *galleryChapterRowRecord) fromPartial(o models.GalleryChapterPartial) {\n\t// TODO: replace with setNullString after schema is made nullable\n\t// r.setNullString(\"title\", o.Title)\n\t// saves a null input as the empty string\n\tif o.Title.Set {\n\t\tr.set(\"title\", o.Title.Value)\n\t}\n\tr.setInt(\"image_index\", o.ImageIndex)\n\tr.setInt(\"gallery_id\", o.GalleryID)\n\tr.setTimestamp(\"created_at\", o.CreatedAt)\n\tr.setTimestamp(\"updated_at\", o.UpdatedAt)\n}\n\ntype GalleryChapterStore struct {\n\trepository\n\n\ttableMgr *table\n}\n\nfunc NewGalleryChapterStore() *GalleryChapterStore {\n\treturn &GalleryChapterStore{\n\t\trepository: repository{\n\t\t\ttableName: galleriesChaptersTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\ttableMgr: galleriesChaptersTableMgr,\n\t}\n}\n\nfunc (qb *GalleryChapterStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *GalleryChapterStore) selectDataset() *goqu.SelectDataset {\n\treturn dialect.From(qb.table()).Select(qb.table().All())\n}\n\nfunc (qb *GalleryChapterStore) Create(ctx context.Context, newObject *models.GalleryChapter) error {\n\tvar r galleryChapterRow\n\tr.fromGalleryChapter(*newObject)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject = *updated\n\n\treturn nil\n}\n\nfunc (qb *GalleryChapterStore) Update(ctx context.Context, updatedObject *models.GalleryChapter) error {\n\tvar r galleryChapterRow\n\tr.fromGalleryChapter(*updatedObject)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *GalleryChapterStore) UpdatePartial(ctx context.Context, id int, partial models.GalleryChapterPartial) (*models.GalleryChapter, error) {\n\tr := galleryChapterRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(partial)\n\n\tif len(r.Record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn qb.find(ctx, id)\n}\n\nfunc (qb *GalleryChapterStore) Destroy(ctx context.Context, id int) error {\n\treturn qb.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *GalleryChapterStore) Find(ctx context.Context, id int) (*models.GalleryChapter, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *GalleryChapterStore) FindMany(ctx context.Context, ids []int) ([]*models.GalleryChapter, error) {\n\tret := make([]*models.GalleryChapter, len(ids))\n\n\ttable := qb.table()\n\tq := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(ids))\n\tunsorted, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, s := range unsorted {\n\t\ti := slices.Index(ids, s.ID)\n\t\tret[i] = s\n\t}\n\n\tfor i := range ret {\n\t\tif ret[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"gallery chapter with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *GalleryChapterStore) find(ctx context.Context, id int) (*models.GalleryChapter, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *GalleryChapterStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.GalleryChapter, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *GalleryChapterStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.GalleryChapter, error) {\n\tconst single = false\n\tvar ret []*models.GalleryChapter\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f galleryChapterRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GalleryChapterStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) {\n\tquery := `\n\t\tSELECT galleries_chapters.* FROM galleries_chapters\n\t\tWHERE galleries_chapters.gallery_id = ?\n\t\tGROUP BY galleries_chapters.id\n\t\tORDER BY galleries_chapters.image_index ASC\n\t`\n\targs := []interface{}{galleryID}\n\treturn qb.queryGalleryChapters(ctx, query, args)\n}\n\nfunc (qb *GalleryChapterStore) queryGalleryChapters(ctx context.Context, query string, args []interface{}) ([]*models.GalleryChapter, error) {\n\tconst single = false\n\tvar ret []*models.GalleryChapter\n\tif err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error {\n\t\tvar f galleryChapterRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/sqlite/gallery_chapter_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestChapterFindByGalleryID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tmqb := db.GalleryChapter\n\n\t\tgalleryID := galleryIDs[galleryIdxWithChapters]\n\t\tchapters, err := mqb.FindByGalleryID(ctx, galleryID)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding chapters: %s\", err.Error())\n\t\t}\n\n\t\tassert.Greater(t, len(chapters), 0)\n\t\tfor _, chapter := range chapters {\n\t\t\tassert.Equal(t, galleryIDs[galleryIdxWithChapters], chapter.GalleryID)\n\t\t}\n\n\t\tchapters, err = mqb.FindByGalleryID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding chapter: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, chapters, 0)\n\n\t\treturn nil\n\t})\n}\n\n// TODO Update\n// TODO Destroy\n// TODO Find\n"
  },
  {
    "path": "pkg/sqlite/gallery_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"regexp\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype galleryFilterHandler struct {\n\tgalleryFilter *models.GalleryFilterType\n}\n\nfunc (qb *galleryFilterHandler) validate() error {\n\tgalleryFilter := qb.galleryFilter\n\tif galleryFilter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(galleryFilter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif subFilter := galleryFilter.SubFilter(); subFilter != nil {\n\t\tsqb := &galleryFilterHandler{galleryFilter: subFilter}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *galleryFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tgalleryFilter := qb.galleryFilter\n\tif galleryFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := galleryFilter.SubFilter()\n\tif sf != nil {\n\t\tsub := &galleryFilterHandler{sf}\n\t\thandleSubFilter(ctx, sub, f, galleryFilter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *galleryFilterHandler) criterionHandler() criterionHandler {\n\tfilter := qb.galleryFilter\n\treturn compoundHandler{\n\t\tintCriterionHandler(filter.ID, \"galleries.id\", nil),\n\t\tstringCriterionHandler(filter.Title, \"galleries.title\"),\n\t\tstringCriterionHandler(filter.Code, \"galleries.code\"),\n\t\tstringCriterionHandler(filter.Details, \"galleries.details\"),\n\t\tstringCriterionHandler(filter.Photographer, \"galleries.photographer\"),\n\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif filter.Checksum != nil {\n\t\t\t\tgalleryRepository.addGalleriesFilesTable(f)\n\t\t\t\tf.addLeftJoin(fingerprintTable, \"fingerprints_md5\", \"galleries_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'\")\n\t\t\t}\n\n\t\t\tstringCriterionHandler(filter.Checksum, \"fingerprints_md5.fingerprint\")(ctx, f)\n\t\t}),\n\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif filter.IsZip != nil {\n\t\t\t\tgalleryRepository.addGalleriesFilesTable(f)\n\t\t\t\tif *filter.IsZip {\n\n\t\t\t\t\tf.addWhere(\"galleries_files.file_id IS NOT NULL\")\n\t\t\t\t} else {\n\t\t\t\t\tf.addWhere(\"galleries_files.file_id IS NULL\")\n\t\t\t\t}\n\t\t\t}\n\t\t}),\n\n\t\tqb.pathCriterionHandler(filter.Path),\n\t\tqb.parentFolderCriterionHandler(filter.ParentFolder),\n\t\tqb.fileCountCriterionHandler(filter.FileCount),\n\t\tintCriterionHandler(filter.Rating100, \"galleries.rating\", nil),\n\t\tqb.urlsCriterionHandler(filter.URL),\n\t\tboolCriterionHandler(filter.Organized, \"galleries.organized\", nil),\n\t\tqb.missingCriterionHandler(filter.IsMissing),\n\t\tqb.tagsCriterionHandler(filter.Tags),\n\t\tqb.tagCountCriterionHandler(filter.TagCount),\n\t\tqb.performersCriterionHandler(filter.Performers),\n\t\tqb.performerCountCriterionHandler(filter.PerformerCount),\n\t\tqb.scenesCriterionHandler(filter.Scenes),\n\t\tqb.hasChaptersCriterionHandler(filter.HasChapters),\n\t\tstudioCriterionHandler(galleryTable, filter.Studios),\n\t\tqb.performerTagsCriterionHandler(filter.PerformerTags),\n\t\tqb.averageResolutionCriterionHandler(filter.AverageResolution),\n\t\tqb.imageCountCriterionHandler(filter.ImageCount),\n\t\tqb.performerFavoriteCriterionHandler(filter.PerformerFavorite),\n\t\tqb.performerAgeCriterionHandler(filter.PerformerAge),\n\t\t&dateCriterionHandler{filter.Date, \"galleries.date\", nil},\n\t\t&timestampCriterionHandler{filter.CreatedAt, \"galleries.created_at\", nil},\n\t\t&timestampCriterionHandler{filter.UpdatedAt, \"galleries.updated_at\", nil},\n\n\t\t&customFieldsFilterHandler{\n\t\t\ttable: galleriesCustomFieldsTable.GetTable(),\n\t\t\tfkCol: galleryIDColumn,\n\t\t\tc:     filter.CustomFields,\n\t\t\tidCol: \"galleries.id\",\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scenes_galleries.scene_id\",\n\t\t\trelatedRepo:    sceneRepository.repository,\n\t\t\trelatedHandler: &sceneFilterHandler{filter.ScenesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tgalleryRepository.scenes.innerJoin(f, \"\", \"galleries.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"galleries_images.image_id\",\n\t\t\trelatedRepo:    imageRepository.repository,\n\t\t\trelatedHandler: &imageFilterHandler{filter.ImagesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tgalleryRepository.images.innerJoin(f, \"\", \"galleries.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"performers_join.performer_id\",\n\t\t\trelatedRepo:    performerRepository.repository,\n\t\t\trelatedHandler: &performerFilterHandler{filter.PerformersFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tgalleryRepository.performers.innerJoin(f, \"performers_join\", \"galleries.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"galleries.studio_id\",\n\t\t\trelatedRepo:    studioRepository.repository,\n\t\t\trelatedHandler: &studioFilterHandler{filter.StudiosFilter},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"gallery_tag.tag_id\",\n\t\t\trelatedRepo:    tagRepository.repository,\n\t\t\trelatedHandler: &tagFilterHandler{filter.TagsFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tgalleryRepository.tags.innerJoin(f, \"gallery_tag\", \"galleries.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol: \"files.id\",\n\t\t\trelatedRepo:  fileRepository.repository,\n\t\t\trelatedHandler: &fileFilterHandler{\n\t\t\t\tfileFilter: filter.FilesFilter,\n\t\t\t\tisRelated:  true,\n\t\t\t},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tgalleryRepository.addFilesTable(f)\n\t\t\t\tgalleryRepository.addFoldersTable(f)\n\t\t\t},\n\t\t\t// don't use a subquery; join directly\n\t\t\tdirectJoin: true,\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol: \"gallery_folder.id\",\n\t\t\trelatedRepo:  folderRepository.repository,\n\t\t\trelatedHandler: &folderFilterHandler{\n\t\t\t\tfolderFilter: filter.FoldersFilter,\n\t\t\t\ttable:        \"gallery_folder\",\n\t\t\t\tisRelated:    true,\n\t\t\t},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tf.addLeftJoin(folderTable, \"gallery_folder\", \"galleries.folder_id = gallery_folder.id\")\n\t\t\t},\n\t\t\t// don't use a subquery; join directly\n\t\t\tdirectJoin: true,\n\t\t},\n\t}\n}\n\nfunc (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: galleryTable,\n\t\tprimaryFK:    galleryIDColumn,\n\t\tjoinTable:    galleriesURLsTable,\n\t\tstringColumn: galleriesURLColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tgalleriesURLsTableMgr.join(f, \"\", \"galleries.id\")\n\t\t},\n\t}\n\n\treturn h.handler(url)\n}\n\nfunc (qb *galleryFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {\n\treturn multiCriterionHandlerBuilder{\n\t\tprimaryTable: galleryTable,\n\t\tforeignTable: foreignTable,\n\t\tjoinTable:    joinTable,\n\t\tprimaryFK:    galleryIDColumn,\n\t\tforeignFK:    foreignFK,\n\t\taddJoinsFunc: addJoinsFunc,\n\t}\n}\n\nfunc (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif c != nil {\n\t\t\tgalleryRepository.addFoldersTable(f)\n\t\t\tf.addLeftJoin(folderTable, \"gallery_folder\", \"galleries.folder_id = gallery_folder.id\")\n\n\t\t\tconst pathColumn = \"folders.path\"\n\t\t\tconst basenameColumn = \"files.basename\"\n\t\t\tconst folderPathColumn = \"gallery_folder.path\"\n\n\t\t\taddWildcards := true\n\t\t\tnot := false\n\n\t\t\tif modifier := c.Modifier; c.Modifier.IsValid() {\n\t\t\t\tswitch modifier {\n\t\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\t\tclause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)\n\t\t\t\t\tclause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, false)\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, orClauses(clause, clause2))\n\t\t\t\tcase models.CriterionModifierExcludes:\n\t\t\t\t\tnot = true\n\t\t\t\t\tclause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)\n\t\t\t\t\tclause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, true)\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, orClauses(clause, clause2))\n\t\t\t\tcase models.CriterionModifierEquals:\n\t\t\t\t\taddWildcards = false\n\t\t\t\t\tclause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)\n\t\t\t\t\tclause2 := makeClause(folderPathColumn+\" LIKE ?\", c.Value)\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, orClauses(clause, clause2))\n\t\t\t\tcase models.CriterionModifierNotEquals:\n\t\t\t\t\taddWildcards = false\n\t\t\t\t\tnot = true\n\t\t\t\t\tclause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)\n\t\t\t\t\tclause2 := makeClause(folderPathColumn+\" NOT LIKE ?\", c.Value)\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, orClauses(clause, clause2))\n\t\t\t\tcase models.CriterionModifierMatchesRegex:\n\t\t\t\t\tif _, err := regexp.Compile(c.Value); err != nil {\n\t\t\t\t\t\tf.setError(err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tfilepathColumn := fmt.Sprintf(\"%s || '%s' || %s\", pathColumn, string(filepath.Separator), basenameColumn)\n\t\t\t\t\tclause := makeClause(fmt.Sprintf(\"%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?\", pathColumn, basenameColumn, filepathColumn), c.Value)\n\t\t\t\t\tclause2 := makeClause(fmt.Sprintf(\"%s IS NOT NULL AND %[1]s regexp ?\", folderPathColumn), c.Value)\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, orClauses(clause, clause2))\n\t\t\t\tcase models.CriterionModifierNotMatchesRegex:\n\t\t\t\t\tif _, err := regexp.Compile(c.Value); err != nil {\n\t\t\t\t\t\tf.setError(err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tfilepathColumn := fmt.Sprintf(\"%s || '%s' || %s\", pathColumn, string(filepath.Separator), basenameColumn)\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s IS NULL OR %s IS NULL OR %s NOT regexp ?\", pathColumn, basenameColumn, filepathColumn), c.Value)\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s IS NULL OR %[1]s NOT regexp ?\", folderPathColumn), c.Value)\n\t\t\t\tcase models.CriterionModifierIsNull:\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''\", pathColumn, basenameColumn))\n\t\t\t\t\tf.addWhere(fmt.Sprintf(\"%s IS NULL OR TRIM(%[1]s) = ''\", folderPathColumn))\n\t\t\t\tcase models.CriterionModifierNotNull:\n\t\t\t\t\tclause := makeClause(fmt.Sprintf(\"%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''\", pathColumn, basenameColumn))\n\t\t\t\t\tclause2 := makeClause(fmt.Sprintf(\"%s IS NOT NULL AND TRIM(%[1]s) != ''\", folderPathColumn))\n\t\t\t\t\tf.whereClauses = append(f.whereClauses, orClauses(clause, clause2))\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"unsupported string filter modifier\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif folder == nil {\n\t\t\treturn\n\t\t}\n\n\t\tgalleryRepository.addFoldersTable(f)\n\t\tf.addLeftJoin(folderTable, \"gallery_folder\", \"galleries.folder_id = gallery_folder.id\")\n\n\t\tcriterion := *folder\n\t\tswitch criterion.Modifier {\n\t\tcase models.CriterionModifierEquals:\n\t\t\tcriterion.Modifier = models.CriterionModifierIncludes\n\t\tcase models.CriterionModifierNotEquals:\n\t\t\tcriterion.Modifier = models.CriterionModifierExcludes\n\t\t}\n\n\t\t// only allow includes or excludes filters\n\t\tif criterion.Modifier != models.CriterionModifierIncludes && criterion.Modifier != models.CriterionModifierExcludes {\n\t\t\tf.setError(fmt.Errorf(\"invalid modifier for parent folder criterion: %s\", criterion.Modifier))\n\t\t}\n\n\t\tif len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {\n\t\t\treturn\n\t\t}\n\n\t\t// combine excludes if excludes modifier is selected\n\t\tif criterion.Modifier == models.CriterionModifierExcludes {\n\t\t\tcriterion.Modifier = models.CriterionModifierIncludes\n\t\t\tcriterion.Excludes = append(criterion.Excludes, criterion.Value...)\n\t\t\tcriterion.Value = nil\n\t\t}\n\n\t\tif len(criterion.Value) > 0 {\n\t\t\tvaluesClause, err := getHierarchicalValues(ctx, criterion.Value, \"folders\", \"\", \"parent_folder_id\", \"parent_folder_id\", criterion.Depth)\n\t\t\tif err != nil {\n\t\t\t\tf.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// combine clauses with OR to handle zip file or folder\n\t\t\tc1 := makeClause(fmt.Sprintf(\"folders.parent_folder_id IN (SELECT column2 FROM (%s))\", valuesClause))\n\t\t\tc2 := makeClause(fmt.Sprintf(\"gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))\", valuesClause))\n\t\t\tf.whereClauses = append(f.whereClauses, orClauses(c1, c2))\n\t\t}\n\n\t\tif len(criterion.Excludes) > 0 {\n\t\t\tvaluesClause, err := getHierarchicalValues(ctx, criterion.Excludes, \"folders\", \"\", \"parent_folder_id\", \"parent_folder_id\", criterion.Depth)\n\t\t\tif err != nil {\n\t\t\t\tf.setError(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf.addWhere(fmt.Sprintf(\"folders.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL\", valuesClause))\n\t\t\tf.addWhere(fmt.Sprintf(\"gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL\", valuesClause))\n\t\t}\n\t}\n}\n\nfunc (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: galleryTable,\n\t\tjoinTable:    galleriesFilesTable,\n\t\tprimaryFK:    galleryIDColumn,\n\t}\n\n\treturn h.handler(fileCount)\n}\n\nfunc (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif isMissing != nil && *isMissing != \"\" {\n\t\t\tswitch *isMissing {\n\t\t\tcase \"url\":\n\t\t\t\tgalleriesURLsTableMgr.join(f, \"\", \"galleries.id\")\n\t\t\t\tf.addWhere(\"gallery_urls.url IS NULL\")\n\t\t\tcase \"scenes\":\n\t\t\t\tf.addLeftJoin(\"scenes_galleries\", \"scenes_join\", \"scenes_join.gallery_id = galleries.id\")\n\t\t\t\tf.addWhere(\"scenes_join.gallery_id IS NULL\")\n\t\t\tcase \"studio\":\n\t\t\t\tf.addWhere(\"galleries.studio_id IS NULL\")\n\t\t\tcase \"performers\":\n\t\t\t\tgalleryRepository.performers.join(f, \"performers_join\", \"galleries.id\")\n\t\t\t\tf.addWhere(\"performers_join.gallery_id IS NULL\")\n\t\t\tcase \"date\":\n\t\t\t\tf.addWhere(\"galleries.date IS NULL OR galleries.date IS \\\"\\\"\")\n\t\t\tcase \"tags\":\n\t\t\t\tgalleryRepository.tags.join(f, \"tags_join\", \"galleries.id\")\n\t\t\t\tf.addWhere(\"tags_join.gallery_id IS NULL\")\n\t\t\tcase \"cover\":\n\t\t\t\tf.addLeftJoin(\"galleries_images\", \"cover_join\", \"cover_join.gallery_id = galleries.id AND cover_join.cover = 1\")\n\t\t\t\tf.addWhere(\"cover_join.image_id IS NULL\")\n\t\t\tdefault:\n\t\t\t\tif err := validateIsMissing(*isMissing, []string{\n\t\t\t\t\t\"title\", \"code\", \"rating\", \"details\", \"photographer\",\n\t\t\t\t}); err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWhere(\"(galleries.\" + *isMissing + \" IS NULL OR TRIM(galleries.\" + *isMissing + \") = '')\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *galleryFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\th := joinedHierarchicalMultiCriterionHandlerBuilder{\n\t\tprimaryTable: galleryTable,\n\t\tforeignTable: tagTable,\n\t\tforeignFK:    \"tag_id\",\n\n\t\trelationsTable: \"tags_relations\",\n\t\tjoinAs:         \"gallery_tag\",\n\t\tjoinTable:      galleriesTagsTable,\n\t\tprimaryFK:      galleryIDColumn,\n\t}\n\n\treturn h.handler(tags)\n}\n\nfunc (qb *galleryFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: galleryTable,\n\t\tjoinTable:    galleriesTagsTable,\n\t\tprimaryFK:    galleryIDColumn,\n\t}\n\n\treturn h.handler(tagCount)\n}\n\nfunc (qb *galleryFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc {\n\taddJoinsFunc := func(f *filterBuilder) {\n\t\tgalleryRepository.scenes.join(f, \"\", \"galleries.id\")\n\t\tf.addLeftJoin(\"scenes\", \"\", \"scenes_galleries.scene_id = scenes.id\")\n\t}\n\th := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, \"scene_id\", addJoinsFunc)\n\treturn h.handler(scenes)\n}\n\nfunc (qb *galleryFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {\n\th := joinedMultiCriterionHandlerBuilder{\n\t\tprimaryTable: galleryTable,\n\t\tjoinTable:    performersGalleriesTable,\n\t\tjoinAs:       \"performers_join\",\n\t\tprimaryFK:    galleryIDColumn,\n\t\tforeignFK:    performerIDColumn,\n\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tgalleryRepository.performers.join(f, \"performers_join\", \"galleries.id\")\n\t\t},\n\t}\n\n\treturn h.handler(performers)\n}\n\nfunc (qb *galleryFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: galleryTable,\n\t\tjoinTable:    performersGalleriesTable,\n\t\tprimaryFK:    galleryIDColumn,\n\t}\n\n\treturn h.handler(performerCount)\n}\n\nfunc (qb *galleryFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: galleryTable,\n\t\tjoinTable:    galleriesImagesTable,\n\t\tprimaryFK:    galleryIDColumn,\n\t}\n\n\treturn h.handler(imageCount)\n}\n\nfunc (qb *galleryFilterHandler) hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif hasChapters != nil {\n\t\t\tf.addLeftJoin(\"galleries_chapters\", \"\", \"galleries_chapters.gallery_id = galleries.id\")\n\t\t\tif *hasChapters == \"true\" {\n\t\t\t\tf.addHaving(\"count(galleries_chapters.gallery_id) > 0\")\n\t\t\t} else {\n\t\t\t\tf.addWhere(\"galleries_chapters.id IS NULL\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *galleryFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler {\n\treturn &joinedPerformerTagsHandler{\n\t\tcriterion:      tags,\n\t\tprimaryTable:   galleryTable,\n\t\tjoinTable:      performersGalleriesTable,\n\t\tjoinPrimaryKey: galleryIDColumn,\n\t}\n}\n\nfunc (qb *galleryFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performerfavorite != nil {\n\t\t\tf.addLeftJoin(\"performers_galleries\", \"\", \"galleries.id = performers_galleries.gallery_id\")\n\n\t\t\tif *performerfavorite {\n\t\t\t\t// contains at least one favorite\n\t\t\t\tf.addLeftJoin(\"performers\", \"\", \"performers.id = performers_galleries.performer_id\")\n\t\t\t\tf.addWhere(\"performers.favorite = 1\")\n\t\t\t} else {\n\t\t\t\t// contains zero favorites\n\t\t\t\tf.addLeftJoin(`(SELECT performers_galleries.gallery_id as id FROM performers_galleries \nJOIN performers ON performers.id = performers_galleries.performer_id\nGROUP BY performers_galleries.gallery_id HAVING SUM(performers.favorite) = 0)`, \"nofaves\", \"galleries.id = nofaves.id\")\n\t\t\t\tf.addWhere(\"performers_galleries.gallery_id IS NULL OR nofaves.id IS NOT NULL\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *galleryFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performerAge != nil {\n\t\t\tf.addInnerJoin(\"performers_galleries\", \"\", \"galleries.id = performers_galleries.gallery_id\")\n\t\t\tf.addInnerJoin(\"performers\", \"\", \"performers_galleries.performer_id = performers.id\")\n\n\t\t\tf.addWhere(\"galleries.date != '' AND performers.birthdate != ''\")\n\t\t\tf.addWhere(\"galleries.date IS NOT NULL AND performers.birthdate IS NOT NULL\")\n\n\t\t\tageCalc := \"cast(strftime('%Y.%m%d', galleries.date) - strftime('%Y.%m%d', performers.birthdate) as int)\"\n\t\t\twhereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2)\n\t\t\tf.addWhere(whereClause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *galleryFilterHandler) averageResolutionCriterionHandler(resolution *models.ResolutionCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif resolution != nil && resolution.Value.IsValid() {\n\t\t\tgalleryRepository.images.join(f, \"images_join\", \"galleries.id\")\n\t\t\tf.addLeftJoin(\"images\", \"\", \"images_join.image_id = images.id\")\n\t\t\tf.addLeftJoin(\"images_files\", \"\", \"images.id = images_files.image_id\")\n\t\t\tf.addLeftJoin(\"image_files\", \"\", \"images_files.file_id = image_files.file_id\")\n\n\t\t\tmn := resolution.Value.GetMinResolution()\n\t\t\tmx := resolution.Value.GetMaxResolution()\n\n\t\t\tconst widthHeight = \"avg(MIN(image_files.width, image_files.height))\"\n\n\t\t\tswitch resolution.Modifier {\n\t\t\tcase models.CriterionModifierEquals:\n\t\t\t\tf.addHaving(fmt.Sprintf(\"%s BETWEEN %d AND %d\", widthHeight, mn, mx))\n\t\t\tcase models.CriterionModifierNotEquals:\n\t\t\t\tf.addHaving(fmt.Sprintf(\"%s NOT BETWEEN %d AND %d\", widthHeight, mn, mx))\n\t\t\tcase models.CriterionModifierLessThan:\n\t\t\t\tf.addHaving(fmt.Sprintf(\"%s < %d\", widthHeight, mn))\n\t\t\tcase models.CriterionModifierGreaterThan:\n\t\t\t\tf.addHaving(fmt.Sprintf(\"%s > %d\", widthHeight, mx))\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/gallery_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"math\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nvar invalidID = -1\n\nfunc loadGalleryRelationships(ctx context.Context, expected models.Gallery, actual *models.Gallery) error {\n\tif expected.URLs.Loaded() {\n\t\tif err := actual.LoadURLs(ctx, db.Gallery); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.SceneIDs.Loaded() {\n\t\tif err := actual.LoadSceneIDs(ctx, db.Gallery); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.TagIDs.Loaded() {\n\t\tif err := actual.LoadTagIDs(ctx, db.Gallery); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.PerformerIDs.Loaded() {\n\t\tif err := actual.LoadPerformerIDs(ctx, db.Gallery); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.Files.Loaded() {\n\t\tif err := actual.LoadFiles(ctx, db.Gallery); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// clear Path, Checksum, PrimaryFileID\n\tif expected.Path == \"\" {\n\t\tactual.Path = \"\"\n\t}\n\tif expected.PrimaryFileID == nil {\n\t\tactual.PrimaryFileID = nil\n\t}\n\n\treturn nil\n}\n\nfunc Test_galleryQueryBuilder_Create(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"1337\"\n\t\turl          = \"url\"\n\t\trating       = 60\n\t\tdetails      = \"details\"\n\t\tphotographer = \"photographer\"\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\n\t\tgalleryFile = makeFileWithID(fileIdxStartGalleryFiles)\n\t)\n\n\tdate, _ := models.ParseDate(\"2003-02-01\")\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.Gallery\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.Gallery{\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tDetails:      details,\n\t\t\t\tPhotographer: photographer,\n\t\t\t\tRating:       &rating,\n\t\t\t\tOrganized:    true,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithScene],\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{sceneIDs[sceneIdx1WithPerformer], sceneIDs[sceneIdx1WithStudio]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with file\",\n\t\t\tmodels.Gallery{\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tDetails:      details,\n\t\t\t\tPhotographer: photographer,\n\t\t\t\tRating:       &rating,\n\t\t\t\tOrganized:    true,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithScene],\n\t\t\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t\t\tgalleryFile,\n\t\t\t\t}),\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{sceneIDs[sceneIdx1WithPerformer], sceneIDs[sceneIdx1WithStudio]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid studio id\",\n\t\t\tmodels.Gallery{\n\t\t\t\tStudioID: &invalidID,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid scene id\",\n\t\t\tmodels.Gallery{\n\t\t\t\tSceneIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.Gallery{\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid performer id\",\n\t\t\tmodels.Gallery{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\ts := tt.newObject\n\t\t\tvar fileIDs []models.FileID\n\t\t\tif s.Files.Loaded() {\n\t\t\t\tfileIDs = []models.FileID{s.Files.List()[0].Base().ID}\n\t\t\t}\n\n\t\t\tif err := qb.Create(ctx, &models.CreateGalleryInput{\n\t\t\t\tGallery: &s,\n\t\t\t\tFileIDs: fileIDs,\n\t\t\t}); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(s.ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(s.ID)\n\n\t\t\tcopy := tt.newObject\n\t\t\tcopy.ID = s.ID\n\n\t\t\t// load relationships\n\t\t\tif err := loadGalleryRelationships(ctx, copy, &s); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, s)\n\n\t\t\t// ensure can find the scene\n\t\t\tfound, err := qb.Find(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tif !assert.NotNil(found) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGalleryRelationships(ctx, copy, found); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *found)\n\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc makeGalleryFileWithID(i int) *models.BaseFile {\n\tret := makeGalleryFile(i)\n\tret.ID = galleryFileIDs[i]\n\treturn ret\n}\n\nfunc Test_galleryQueryBuilder_Update(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"code\"\n\t\turl          = \"url\"\n\t\trating       = 60\n\t\tdetails      = \"details\"\n\t\tphotographer = \"photographer\"\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\tdate, _ := models.ParseDate(\"2003-02-01\")\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject *models.Gallery\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:           galleryIDs[galleryIdxWithScene],\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tDetails:      details,\n\t\t\t\tPhotographer: photographer,\n\t\t\t\tRating:       &rating,\n\t\t\t\tOrganized:    true,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithScene],\n\t\t\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t\t\tmakeGalleryFileWithID(galleryIdxWithScene),\n\t\t\t\t}),\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{sceneIDs[sceneIdx1WithPerformer], sceneIDs[sceneIdx1WithStudio]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear nullables\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:           galleryIDs[galleryIdxWithImage],\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{}),\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tOrganized:    true,\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear scene ids\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:           galleryIDs[galleryIdxWithScene],\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tOrganized:    true,\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear tag ids\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:           galleryIDs[galleryIdxWithTag],\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tOrganized:    true,\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear performer ids\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:           galleryIDs[galleryIdxWithPerformer],\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tOrganized:    true,\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid studio id\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:        galleryIDs[galleryIdxWithImage],\n\t\t\t\tOrganized: true,\n\t\t\t\tStudioID:  &invalidID,\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid scene id\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:        galleryIDs[galleryIdxWithImage],\n\t\t\t\tOrganized: true,\n\t\t\t\tSceneIDs:  models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:        galleryIDs[galleryIdxWithImage],\n\t\t\t\tOrganized: true,\n\t\t\t\tTagIDs:    models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid performer id\",\n\t\t\t&models.Gallery{\n\t\t\t\tID:           galleryIDs[galleryIdxWithImage],\n\t\t\t\tOrganized:    true,\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tcopy := *tt.updatedObject\n\n\t\t\tif err := qb.Update(ctx, &models.UpdateGalleryInput{\n\t\t\t\tGallery: tt.updatedObject,\n\t\t\t}); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.updatedObject.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.Find() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGalleryRelationships(ctx, copy, s); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *s)\n\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc clearGalleryFileIDs(gallery *models.Gallery) {\n\tif gallery.Files.Loaded() {\n\t\tfor _, f := range gallery.Files.List() {\n\t\t\tf.Base().ID = 0\n\t\t}\n\t}\n}\n\nfunc clearGalleryPartial() models.GalleryPartial {\n\t// leave mandatory fields\n\treturn models.GalleryPartial{\n\t\tTitle:        models.OptionalString{Set: true, Null: true},\n\t\tCode:         models.OptionalString{Set: true, Null: true},\n\t\tDetails:      models.OptionalString{Set: true, Null: true},\n\t\tPhotographer: models.OptionalString{Set: true, Null: true},\n\t\tURLs:         &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},\n\t\tDate:         models.OptionalDate{Set: true, Null: true},\n\t\tRating:       models.OptionalInt{Set: true, Null: true},\n\t\tStudioID:     models.OptionalInt{Set: true, Null: true},\n\t\tTagIDs:       &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t\tPerformerIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t}\n}\n\nfunc Test_galleryQueryBuilder_UpdatePartial(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"code\"\n\t\tdetails      = \"details\"\n\t\tphotographer = \"photographer\"\n\t\turl          = \"url\"\n\t\trating       = 60\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\n\t\tdate, _ = models.ParseDate(\"2003-02-01\")\n\t)\n\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.GalleryPartial\n\t\twant    models.Gallery\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tgalleryIDs[galleryIdxWithImage],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tTitle:        models.NewOptionalString(title),\n\t\t\t\tCode:         models.NewOptionalString(code),\n\t\t\t\tDetails:      models.NewOptionalString(details),\n\t\t\t\tPhotographer: models.NewOptionalString(photographer),\n\t\t\t\tURLs: &models.UpdateStrings{\n\t\t\t\t\tValues: []string{url},\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tDate:      models.NewOptionalDate(date),\n\t\t\t\tRating:    models.NewOptionalInt(rating),\n\t\t\t\tOrganized: models.NewOptionalBool(true),\n\t\t\t\tStudioID:  models.NewOptionalInt(studioIDs[studioIdxWithGallery]),\n\t\t\t\tCreatedAt: models.NewOptionalTime(createdAt),\n\t\t\t\tUpdatedAt: models.NewOptionalTime(updatedAt),\n\n\t\t\t\tSceneIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{sceneIDs[sceneIdxWithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithGallery], tagIDs[tagIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithGallery], performerIDs[performerIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tID:           galleryIDs[galleryIdxWithImage],\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tDetails:      details,\n\t\t\t\tPhotographer: photographer,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tRating:       &rating,\n\t\t\t\tOrganized:    true,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithGallery],\n\t\t\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t\t\tmakeGalleryFile(galleryIdxWithImage),\n\t\t\t\t}),\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{sceneIDs[sceneIdxWithGallery]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGallery]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithGallery], performerIDs[performerIdx1WithDupName]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear all\",\n\t\t\tgalleryIDs[galleryIdxWithImage],\n\t\t\tclearGalleryPartial(),\n\t\t\tmodels.Gallery{\n\t\t\t\tID: galleryIDs[galleryIdxWithImage],\n\t\t\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t\t\tmakeGalleryFile(galleryIdxWithImage),\n\t\t\t\t}),\n\t\t\t\tSceneIDs:     models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid id\",\n\t\t\tinvalidID,\n\t\t\tmodels.GalleryPartial{},\n\t\t\tmodels.Gallery{},\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Gallery\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGalleryRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tclearGalleryFileIDs(got)\n\t\t\tassert.Equal(tt.want, *got)\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGalleryRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tclearGalleryFileIDs(s)\n\t\t\tassert.Equal(tt.want, *s)\n\t\t})\n\t}\n}\n\nfunc Test_galleryQueryBuilder_UpdatePartialRelationships(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.GalleryPartial\n\t\twant    models.Gallery\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"add scenes\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tSceneIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[sceneIdx1WithStudio], tagIDs[sceneIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tSceneIDs: models.NewRelatedIDs(append(indexesToIDs(sceneIDs, sceneGalleries.reverseLookup(galleryIdx1WithImage)),\n\t\t\t\t\tsceneIDs[sceneIdx1WithStudio],\n\t\t\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add tags\",\n\t\t\tgalleryIDs[galleryIdxWithTwoTags],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tTagIDs: models.NewRelatedIDs(append(indexesToIDs(tagIDs, galleryTags[galleryIdxWithTwoTags]),\n\t\t\t\t\ttagIDs[tagIdx1WithDupName],\n\t\t\t\t\ttagIDs[tagIdx1WithImage],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add performers\",\n\t\t\tgalleryIDs[galleryIdxWithTwoPerformers],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, galleryPerformers[galleryIdxWithTwoPerformers]),\n\t\t\t\t\tperformerIDs[performerIdx1WithDupName],\n\t\t\t\t\tperformerIDs[performerIdx1WithImage],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate scenes\",\n\t\t\tgalleryIDs[galleryIdxWithScene],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tSceneIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{sceneIDs[sceneIdxWithGallery], sceneIDs[sceneIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tSceneIDs: models.NewRelatedIDs(append(indexesToIDs(sceneIDs, sceneGalleries.reverseLookup(galleryIdxWithScene)),\n\t\t\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate tags\",\n\t\t\tgalleryIDs[galleryIdxWithTwoTags],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithGallery], tagIDs[tagIdx1WithScene]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tTagIDs: models.NewRelatedIDs(append(indexesToIDs(tagIDs, galleryTags[galleryIdxWithTwoTags]),\n\t\t\t\t\ttagIDs[tagIdx1WithScene],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate performers\",\n\t\t\tgalleryIDs[galleryIdxWithTwoPerformers],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithGallery], performerIDs[performerIdx1WithScene]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, galleryPerformers[galleryIdxWithTwoPerformers]),\n\t\t\t\t\tperformerIDs[performerIdx1WithScene],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add invalid scenes\",\n\t\t\tgalleryIDs[galleryIdxWithScene],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tSceneIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"add invalid tags\",\n\t\t\tgalleryIDs[galleryIdxWithTwoTags],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"add invalid performers\",\n\t\t\tgalleryIDs[galleryIdxWithTwoPerformers],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"remove scenes\",\n\t\t\tgalleryIDs[galleryIdxWithScene],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tSceneIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{sceneIDs[sceneIdxWithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tSceneIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove tags\",\n\t\t\tgalleryIDs[galleryIdxWithTwoTags],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx2WithGallery]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove performers\",\n\t\t\tgalleryIDs[galleryIdxWithTwoPerformers],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx2WithGallery]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated scenes\",\n\t\t\tgalleryIDs[galleryIdxWithScene],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tSceneIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[sceneIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tSceneIDs: models.NewRelatedIDs([]int{sceneIDs[sceneIdxWithGallery]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated tags\",\n\t\t\tgalleryIDs[galleryIdxWithTwoTags],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tTagIDs: models.NewRelatedIDs(indexesToIDs(tagIDs, galleryTags[galleryIdxWithTwoTags])),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated performers\",\n\t\t\tgalleryIDs[galleryIdxWithTwoPerformers],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Gallery{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(indexesToIDs(performerIDs, galleryPerformers[galleryIdxWithTwoPerformers])),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tqb := db.Gallery\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGalleryRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := loadGalleryRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// only compare fields that were in the partial\n\t\t\tif tt.partial.PerformerIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.PerformerIDs.List(), got.PerformerIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.PerformerIDs.List(), s.PerformerIDs.List())\n\t\t\t}\n\t\t\tif tt.partial.TagIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.TagIDs.List(), got.TagIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.TagIDs.List(), s.TagIDs.List())\n\t\t\t}\n\t\t\tif tt.partial.SceneIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.SceneIDs.List(), got.SceneIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.SceneIDs.List(), s.SceneIDs.List())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_GalleryStore_UpdatePartialCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tid       int\n\t\tpartial  models.GalleryPartial\n\t\texpected map[string]interface{} // nil to use the partial\n\t}{\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"partial custom fields\",\n\t\t\tgalleryIDs[galleryIdxWithTwoTags],\n\t\t\tmodels.GalleryPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\t\t\"new_field\": \"new\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"int\":       int64(2),\n\t\t\t\t\"real\":      1.2,\n\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\"new_field\": \"new\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Gallery\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t_, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GalleryStore.UpdatePartial() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure custom fields are correct\n\t\t\tcf, err := qb.GetCustomFields(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GalleryStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.expected == nil {\n\t\t\t\tassert.Equal(tt.partial.CustomFields.Full, cf)\n\t\t\t} else {\n\t\t\t\tassert.Equal(tt.expected, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_galleryQueryBuilder_Destroy(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgalleryIDs[galleryIdxWithScene],\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tif err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.Destroy() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\t// ensure cannot be found\n\t\t\ti, err := qb.Find(ctx, tt.id)\n\n\t\t\tassert.Nil(err)\n\t\t\tassert.Nil(i)\n\t\t\treturn\n\n\t\t})\n\t}\n}\n\nfunc makeGalleryWithID(index int) *models.Gallery {\n\tconst includeScenes = true\n\tret := makeGallery(index, includeScenes)\n\tret.ID = galleryIDs[index]\n\n\tret.Files = models.NewRelatedFiles([]models.File{makeGalleryFile(index)})\n\n\treturn ret\n}\n\nfunc Test_galleryQueryBuilder_Find(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    *models.Gallery\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgalleryIDs[galleryIdxWithImage],\n\t\t\tmakeGalleryWithID(galleryIdxWithImage),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\tgalleryIDs[galleryIdxWithTwoPerformers],\n\t\t\tmakeGalleryWithID(galleryIdxWithTwoPerformers),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\tgalleryIDs[galleryIdxWithTwoTags],\n\t\t\tmakeGalleryWithID(galleryIdxWithTwoTags),\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.Find(ctx, tt.id)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.Find() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif got != nil {\n\t\t\t\t// load relationships\n\t\t\t\tif err := loadGalleryRelationships(ctx, *tt.want, got); err != nil {\n\t\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tclearGalleryFileIDs(got)\n\t\t\t}\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc postFindGalleries(ctx context.Context, want []*models.Gallery, got []*models.Gallery) error {\n\tfor i, s := range got {\n\t\t// load relationships\n\t\tif i < len(want) {\n\t\t\tif err := loadGalleryRelationships(ctx, *want[i], s); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tclearGalleryFileIDs(s)\n\t}\n\n\treturn nil\n}\n\nfunc Test_galleryQueryBuilder_FindMany(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tids     []int\n\t\twant    []*models.Gallery\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid with relationships\",\n\t\t\t[]int{galleryIDs[galleryIdxWithImage], galleryIDs[galleryIdxWithTwoPerformers], galleryIDs[galleryIdxWithTwoTags]},\n\t\t\t[]*models.Gallery{\n\t\t\t\tmakeGalleryWithID(galleryIdxWithImage),\n\t\t\t\tmakeGalleryWithID(galleryIdxWithTwoPerformers),\n\t\t\t\tmakeGalleryWithID(galleryIdxWithTwoTags),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t[]int{galleryIDs[galleryIdxWithImage], galleryIDs[galleryIdxWithTwoPerformers], invalidID},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindMany(ctx, tt.ids)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.FindMany() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindGalleries(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_galleryQueryBuilder_FindByChecksum(t *testing.T) {\n\tgetChecksum := func(index int) string {\n\t\treturn getGalleryStringValue(index, checksumField)\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tchecksum string\n\t\twant     []*models.Gallery\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgetChecksum(galleryIdxWithImage),\n\t\t\t[]*models.Gallery{makeGalleryWithID(galleryIdxWithImage)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid checksum\",\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\tgetChecksum(galleryIdxWithTwoPerformers),\n\t\t\t[]*models.Gallery{makeGalleryWithID(galleryIdxWithTwoPerformers)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\tgetChecksum(galleryIdxWithTwoTags),\n\t\t\t[]*models.Gallery{makeGalleryWithID(galleryIdxWithTwoTags)},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByChecksum(ctx, tt.checksum)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.FindByChecksum() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindGalleries(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_galleryQueryBuilder_FindByChecksums(t *testing.T) {\n\tgetChecksum := func(index int) string {\n\t\treturn getGalleryStringValue(index, checksumField)\n\t}\n\n\ttests := []struct {\n\t\tname      string\n\t\tchecksums []string\n\t\twant      []*models.Gallery\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"valid with relationships\",\n\t\t\t[]string{\n\t\t\t\tgetChecksum(galleryIdxWithImage),\n\t\t\t\tgetChecksum(galleryIdxWithTwoPerformers),\n\t\t\t\tgetChecksum(galleryIdxWithTwoTags),\n\t\t\t},\n\t\t\t[]*models.Gallery{\n\t\t\t\tmakeGalleryWithID(galleryIdxWithImage),\n\t\t\t\tmakeGalleryWithID(galleryIdxWithTwoPerformers),\n\t\t\t\tmakeGalleryWithID(galleryIdxWithTwoTags),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with invalid\",\n\t\t\t[]string{\n\t\t\t\tgetChecksum(galleryIdxWithImage),\n\t\t\t\tgetChecksum(galleryIdxWithTwoPerformers),\n\t\t\t\t\"invalid checksum\",\n\t\t\t\tgetChecksum(galleryIdxWithTwoTags),\n\t\t\t},\n\t\t\t[]*models.Gallery{\n\t\t\t\tmakeGalleryWithID(galleryIdxWithImage),\n\t\t\t\tmakeGalleryWithID(galleryIdxWithTwoPerformers),\n\t\t\t\tmakeGalleryWithID(galleryIdxWithTwoTags),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByChecksums(ctx, tt.checksums)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.FindByChecksum() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindGalleries(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_galleryQueryBuilder_FindByPath(t *testing.T) {\n\tgetPath := func(index int) string {\n\t\treturn getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(index))\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tpath    string\n\t\twant    []*models.Gallery\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgetPath(galleryIdxWithImage),\n\t\t\t[]*models.Gallery{makeGalleryWithID(galleryIdxWithImage)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid path\",\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\tgetPath(galleryIdxWithTwoPerformers),\n\t\t\t[]*models.Gallery{makeGalleryWithID(galleryIdxWithTwoPerformers)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\tgetPath(galleryIdxWithTwoTags),\n\t\t\t[]*models.Gallery{makeGalleryWithID(galleryIdxWithTwoTags)},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByPath(ctx, tt.path)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.FindByPath() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindGalleries(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_galleryQueryBuilder_FindBySceneID(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tsceneID int\n\t\twant    []*models.Gallery\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\t[]*models.Gallery{makeGalleryWithID(galleryIdxWithScene)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindBySceneID(ctx, tt.sceneID)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.FindBySceneID() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindGalleries(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_galleryQueryBuilder_FindByImageID(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\timageID int\n\t\twant    []*models.Gallery\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\timageIDs[imageIdxWithTwoGalleries],\n\t\t\t[]*models.Gallery{\n\t\t\t\tmakeGalleryWithID(galleryIdx1WithImage),\n\t\t\t\tmakeGalleryWithID(galleryIdx2WithImage),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\timageIDs[imageIdx1WithPerformer],\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByImageID(ctx, tt.imageID)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.FindByImageID() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindGalleries(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGalleryRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_galleryQueryBuilder_CountByImageID(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\timageID int\n\t\twant    int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\timageIDs[imageIdxWithTwoGalleries],\n\t\t\t2,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\timageIDs[imageIdx1WithPerformer],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.CountByImageID(ctx, tt.imageID)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.CountByImageID() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"galleryQueryBuilder.CountByImageID() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc galleriesToIDs(i []*models.Gallery) []int {\n\tvar ret []int\n\tfor _, ii := range i {\n\t\tret = append(ret, ii.ID)\n\t}\n\n\treturn ret\n}\n\nfunc Test_galleryStore_FindByFileID(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tfileID  models.FileID\n\t\tinclude []int\n\t\texclude []int\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgalleryFileIDs[galleryIdx1WithImage],\n\t\t\t[]int{galleryIdx1WithImage},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidFileID,\n\t\t\tnil,\n\t\t\t[]int{galleryIdx1WithImage},\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByFileID(ctx, tt.fileID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GalleryStore.FindByFileID() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor _, f := range got {\n\t\t\t\tclearGalleryFileIDs(f)\n\t\t\t}\n\n\t\t\tids := galleriesToIDs(got)\n\t\t\tinclude := indexesToIDs(galleryIDs, tt.include)\n\t\t\texclude := indexesToIDs(galleryIDs, tt.exclude)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_galleryStore_FindByFolderID(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tfolderID models.FolderID\n\t\tinclude  []int\n\t\texclude  []int\n\t}{\n\t\t// TODO - add folder gallery\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidFolderID,\n\t\t\tnil,\n\t\t\t[]int{galleryIdxWithImage},\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByFolderID(ctx, tt.folderID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GalleryStore.FindByFolderID() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor _, f := range got {\n\t\t\t\tclearGalleryFileIDs(f)\n\t\t\t}\n\n\t\t\tids := galleriesToIDs(got)\n\t\t\tinclude := indexesToIDs(imageIDs, tt.include)\n\t\t\texclude := indexesToIDs(imageIDs, tt.exclude)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGalleryQueryQ(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tconst galleryIdx = 0\n\n\t\tq := getGalleryStringValue(galleryIdx, pathField)\n\t\tgalleryQueryQ(ctx, t, q, galleryIdx)\n\n\t\treturn nil\n\t})\n}\n\nfunc galleryQueryQ(ctx context.Context, t *testing.T, q string, expectedGalleryIdx int) {\n\tqb := db.Gallery\n\n\tfilter := models.FindFilterType{\n\t\tQ: &q,\n\t}\n\tgalleries, _, err := qb.Query(ctx, nil, &filter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying gallery: %s\", err.Error())\n\t\treturn\n\t}\n\n\tassert.Len(t, galleries, 1)\n\tgallery := galleries[0]\n\tassert.Equal(t, galleryIDs[expectedGalleryIdx], gallery.ID)\n\n\t// no Q should return all results\n\tfilter.Q = nil\n\tgalleries, _, err = qb.Query(ctx, nil, &filter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying gallery: %s\", err.Error())\n\t}\n\n\tassert.Len(t, galleries, totalGalleries)\n}\n\nfunc TestGalleryQueryPath(t *testing.T) {\n\tconst galleryIdx = 1\n\tgalleryPath := getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(galleryIdx))\n\n\ttests := []struct {\n\t\tname  string\n\t\tinput models.StringCriterionInput\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    galleryPath,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    galleryPath,\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"matches regex\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    \"gallery.*1_Path\",\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    \"gallery.*1_Path\",\n\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, count, err := qb.Query(ctx, &models.GalleryFilterType{\n\t\t\t\tPath: &tt.input,\n\t\t\t}, nil)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GalleryStore.TestSceneQueryPath() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotEqual(t, 0, count)\n\n\t\t\tfor _, gallery := range got {\n\t\t\t\tverifyString(t, gallery.Path, tt.input)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc verifyGalleriesPath(ctx context.Context, t *testing.T, pathCriterion models.StringCriterionInput) {\n\tgalleryFilter := models.GalleryFilterType{\n\t\tPath: &pathCriterion,\n\t}\n\n\tsqb := db.Gallery\n\tgalleries, _, err := sqb.Query(ctx, &galleryFilter, nil)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying gallery: %s\", err.Error())\n\t}\n\n\tfor _, gallery := range galleries {\n\t\tverifyString(t, gallery.Path, pathCriterion)\n\t}\n}\n\nfunc TestGalleryQueryPathOr(t *testing.T) {\n\tconst gallery1Idx = 1\n\tconst gallery2Idx = 2\n\n\tgallery1Path := getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(gallery1Idx))\n\tgallery2Path := getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(gallery2Idx))\n\n\tgalleryFilter := models.GalleryFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    gallery1Path,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.GalleryFilterType]{\n\t\t\tOr: &models.GalleryFilterType{\n\t\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\t\tValue:    gallery2Path,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\n\t\tif !assert.Len(t, galleries, 2) {\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, gallery1Path, galleries[0].Path)\n\t\tassert.Equal(t, gallery2Path, galleries[1].Path)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryPathAndRating(t *testing.T) {\n\tconst galleryIdx = 1\n\tgalleryPath := getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(galleryIdx))\n\tgalleryRating := getIntPtr(getRating(galleryIdx))\n\n\tgalleryFilter := models.GalleryFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    galleryPath,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.GalleryFilterType]{\n\t\t\tAnd: &models.GalleryFilterType{\n\t\t\t\tRating100: &models.IntCriterionInput{\n\t\t\t\t\tValue:    *galleryRating,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\n\t\tif !assert.Len(t, galleries, 1) {\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, galleryPath, galleries[0].Path)\n\t\tassert.Equal(t, *galleryRating, *galleries[0].Rating)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryPathNotRating(t *testing.T) {\n\tconst galleryIdx = 1\n\n\tgalleryRating := getRating(galleryIdx)\n\n\tpathCriterion := models.StringCriterionInput{\n\t\tValue:    \"gallery_.*1_Path\",\n\t\tModifier: models.CriterionModifierMatchesRegex,\n\t}\n\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    int(galleryRating.Int64),\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tgalleryFilter := models.GalleryFilterType{\n\t\tPath: &pathCriterion,\n\t\tOperatorFilter: models.OperatorFilter[models.GalleryFilterType]{\n\t\t\tNot: &models.GalleryFilterType{\n\t\t\t\tRating100: &ratingCriterion,\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\n\t\tfor _, gallery := range galleries {\n\t\t\tverifyString(t, gallery.Path, pathCriterion)\n\t\t\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\t\t\tverifyIntPtr(t, gallery.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryIllegalQuery(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst galleryIdx = 1\n\tsubFilter := models.GalleryFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    getGalleryStringValue(galleryIdx, \"Path\"),\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tgalleryFilter := &models.GalleryFilterType{\n\t\tOperatorFilter: models.OperatorFilter[models.GalleryFilterType]{\n\t\t\tAnd: &subFilter,\n\t\t\tOr:  &subFilter,\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\n\t\t_, _, err := sqb.Query(ctx, galleryFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\tgalleryFilter.Or = nil\n\t\tgalleryFilter.Not = &subFilter\n\t\t_, _, err = sqb.Query(ctx, galleryFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\tgalleryFilter.And = nil\n\t\tgalleryFilter.Or = &subFilter\n\t\t_, _, err = sqb.Query(ctx, galleryFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryURL(t *testing.T) {\n\tconst sceneIdx = 1\n\tgalleryURL := getGalleryStringValue(sceneIdx, urlField)\n\n\turlCriterion := models.StringCriterionInput{\n\t\tValue:    galleryURL,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tfilter := models.GalleryFilterType{\n\t\tURL: &urlCriterion,\n\t}\n\n\tverifyFn := func(g *models.Gallery) {\n\t\tt.Helper()\n\t\turls := g.URLs.List()\n\t\tvar url string\n\t\tif len(urls) > 0 {\n\t\t\turl = urls[0]\n\t\t}\n\n\t\tverifyString(t, url, urlCriterion)\n\t}\n\n\tverifyGalleryQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyGalleryQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierMatchesRegex\n\turlCriterion.Value = \"gallery_.*1_URL\"\n\tverifyGalleryQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyGalleryQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierIsNull\n\turlCriterion.Value = \"\"\n\tverifyGalleryQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyGalleryQuery(t, filter, verifyFn)\n}\n\nfunc verifyGalleryQuery(t *testing.T, filter models.GalleryFilterType, verifyFn func(s *models.Gallery)) {\n\twithTxn(func(ctx context.Context) error {\n\t\tt.Helper()\n\t\tsqb := db.Gallery\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &filter, nil)\n\n\t\tfor _, g := range galleries {\n\t\t\tif err := g.LoadURLs(ctx, sqb); err != nil {\n\t\t\t\tt.Errorf(\"Error loading gallery URLs: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\t// assume it should find at least one\n\t\tassert.Greater(t, len(galleries), 0)\n\n\t\tfor _, gallery := range galleries {\n\t\t\tverifyFn(gallery)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryRating100(t *testing.T) {\n\tconst rating = 60\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    rating,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyGalleriesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyGalleriesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyGalleriesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyGalleriesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierIsNull\n\tverifyGalleriesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyGalleriesRating100(t, ratingCriterion)\n}\n\nfunc verifyGalleriesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tRating100: &ratingCriterion,\n\t\t}\n\n\t\tgalleries, _, err := sqb.Query(ctx, &galleryFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying gallery: %s\", err.Error())\n\t\t}\n\n\t\tfor _, gallery := range galleries {\n\t\t\tverifyIntPtr(t, gallery.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryIsMissingScene(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Gallery\n\t\tisMissing := \"scenes\"\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getGalleryStringValue(galleryIdxWithScene, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tgalleries, _, err := qb.Query(ctx, &galleryFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying gallery: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, galleries, 0)\n\n\t\tfindFilter.Q = nil\n\t\tgalleries, _, err = qb.Query(ctx, &galleryFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying gallery: %s\", err.Error())\n\t\t}\n\n\t\t// ensure non of the ids equal the one with gallery\n\t\tfor _, gallery := range galleries {\n\t\t\tassert.NotEqual(t, galleryIDs[galleryIdxWithScene], gallery.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc queryGallery(ctx context.Context, t *testing.T, sqb models.GalleryReader, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) []*models.Gallery {\n\tgalleries, _, err := sqb.Query(ctx, galleryFilter, findFilter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying gallery: %s\", err.Error())\n\t}\n\n\treturn galleries\n}\n\nfunc TestGalleryQueryIsMissingStudio(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tisMissing := \"studio\"\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getGalleryStringValue(galleryIdxWithStudio, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\n\t\tassert.Len(t, galleries, 0)\n\n\t\tfindFilter.Q = nil\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\n\t\t// ensure non of the ids equal the one with studio\n\t\tfor _, gallery := range galleries {\n\t\t\tassert.NotEqual(t, galleryIDs[galleryIdxWithStudio], gallery.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryIsMissingPerformers(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tisMissing := \"performers\"\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getGalleryStringValue(galleryIdxWithPerformer, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\n\t\tassert.Len(t, galleries, 0)\n\n\t\tfindFilter.Q = nil\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\n\t\tassert.True(t, len(galleries) > 0)\n\n\t\t// ensure non of the ids equal the one with galleries\n\t\tfor _, gallery := range galleries {\n\t\t\tassert.NotEqual(t, galleryIDs[galleryIdxWithPerformer], gallery.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryIsMissingTags(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tisMissing := \"tags\"\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getGalleryStringValue(galleryIdxWithTwoTags, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\n\t\tassert.Len(t, galleries, 0)\n\n\t\tfindFilter.Q = nil\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\n\t\tassert.True(t, len(galleries) > 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryIsMissingDate(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tisMissing := \"date\"\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\n\t\t// one in four galleries have no date\n\t\tassert.Len(t, galleries, int(math.Ceil(float64(totalGalleries)/4)))\n\n\t\t// ensure date is null\n\t\tfor _, g := range galleries {\n\t\t\tassert.Nil(t, g.Date)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryPerformers(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      models.MultiCriterionInput\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdxWithGallery]),\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx1WithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformer,\n\t\t\t\tgalleryIdxWithTwoPerformers,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithImage,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx1WithGallery]),\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx2WithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithTwoPerformers,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformer,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[performerIdx1WithGallery])},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{galleryIdxWithTwoPerformers},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\t[]int{galleryIdxWithTag},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformer,\n\t\t\t\tgalleryIdxWithTwoPerformers,\n\t\t\t\tgalleryIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformer,\n\t\t\t\tgalleryIdxWithTwoPerformers,\n\t\t\t\tgalleryIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\t[]int{galleryIdxWithTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx1WithGallery]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx2WithGallery]),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithTwoPerformers},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithThreePerformers,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx1WithGallery]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx2WithGallery]),\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{\n\t\t\t\tPerformers: &tt.filter,\n\t\t\t}, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"GalleryStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := galleriesToIDs(results)\n\n\t\t\tinclude := indexesToIDs(galleryIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(galleryIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGalleryQueryTags(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      models.HierarchicalMultiCriterionInput\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithGallery]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithTag,\n\t\t\t\tgalleryIdxWithTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithImage,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithGallery]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[tagIdx1WithGallery])},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{galleryIdxWithTwoTags},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\t[]int{galleryIdx1WithPerformer},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithTag,\n\t\t\t\tgalleryIdxWithTwoTags,\n\t\t\t\tgalleryIdxWithThreeTags,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithTag,\n\t\t\t\tgalleryIdxWithTwoTags,\n\t\t\t\tgalleryIdxWithThreeTags,\n\t\t\t},\n\t\t\t[]int{galleryIdx1WithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithGallery]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithGallery]),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithTwoTags},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithThreeTags,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithGallery]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithGallery]),\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{\n\t\t\t\tTags: &tt.filter,\n\t\t\t}, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"GalleryStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := galleriesToIDs(results)\n\n\t\t\tinclude := indexesToIDs(imageIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(imageIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGalleryQueryStudio(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tq               string\n\t\tstudioCriterion models.HierarchicalMultiCriterionInput\n\t\texpectedIDs     []int\n\t\twantErr         bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\t\"\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{galleryIDs[galleryIdxWithStudio]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tgetGalleryStringValue(galleryIdxWithStudio, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t},\n\t\t\t[]int{},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes includes null\",\n\t\t\tgetGalleryStringValue(galleryIdxWithImage, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t},\n\t\t\t[]int{galleryIDs[galleryIdxWithImage]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\t\"\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t\t[]int{galleryIDs[galleryIdxWithStudio]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tgetGalleryStringValue(galleryIdxWithStudio, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGallery]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t},\n\t\t\t[]int{},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tstudioCriterion := tt.studioCriterion\n\n\t\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\t\tStudios: &studioCriterion,\n\t\t\t}\n\n\t\t\tvar findFilter *models.FindFilterType\n\t\t\tif tt.q != \"\" {\n\t\t\t\tfindFilter = &models.FindFilterType{\n\t\t\t\t\tQ: &tt.q,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tgallerys := queryGallery(ctx, t, qb, &galleryFilter, findFilter)\n\n\t\t\tassert.ElementsMatch(t, galleriesToIDs(gallerys), tt.expectedIDs)\n\t\t})\n\t}\n}\n\nfunc TestGalleryQueryStudioDepth(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tdepth := 2\n\t\tstudioCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGrandChild]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    &depth,\n\t\t}\n\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tStudios: &studioCriterion,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\t\tassert.Len(t, galleries, 1)\n\n\t\tdepth = 1\n\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\t\tassert.Len(t, galleries, 0)\n\n\t\tstudioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\t\tassert.Len(t, galleries, 1)\n\n\t\t// ensure id is correct\n\t\tassert.Equal(t, galleryIDs[galleryIdxWithGrandChildStudio], galleries[0].ID)\n\n\t\tdepth = 2\n\n\t\tstudioCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGrandChild]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\tDepth:    &depth,\n\t\t}\n\n\t\tq := getGalleryStringValue(galleryIdxWithGrandChildStudio, pathField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\t\tassert.Len(t, galleries, 0)\n\n\t\tdepth = 1\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\t\tassert.Len(t, galleries, 1)\n\n\t\tstudioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\t\tassert.Len(t, galleries, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryPerformerTags(t *testing.T) {\n\tallDepth := -1\n\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.GalleryFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithPerformer]),\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformerTag,\n\t\t\t\tgalleryIdxWithPerformerTwoTags,\n\t\t\t\tgalleryIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformer,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes sub-tags\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithParentAndChild]),\n\t\t\t\t\t},\n\t\t\t\t\tDepth:    &allDepth,\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformerParentTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformer,\n\t\t\t\tgalleryIdxWithPerformerTag,\n\t\t\t\tgalleryIdxWithPerformerTwoTags,\n\t\t\t\tgalleryIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformer,\n\t\t\t\tgalleryIdxWithPerformerTag,\n\t\t\t\tgalleryIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes performer tag tagIdx2WithPerformer\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{galleryIdxWithTwoPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes sub-tags\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithParentAndChild]),\n\t\t\t\t\t},\n\t\t\t\t\tDepth:    &allDepth,\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformer,\n\t\t\t\tgalleryIdxWithPerformerTag,\n\t\t\t\tgalleryIdxWithPerformerTwoTags,\n\t\t\t\tgalleryIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tgalleryIdxWithPerformerParentTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdx1WithImage},\n\t\t\t[]int{galleryIdxWithPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithPerformerTag},\n\t\t\t[]int{galleryIdx1WithImage},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tnil,\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, _, err := db.Gallery.Query(ctx, tt.filter, tt.findFilter)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ImageStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := galleriesToIDs(results)\n\n\t\t\tinclude := indexesToIDs(galleryIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(galleryIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGalleryQueryTagCount(t *testing.T) {\n\tconst tagCount = 1\n\ttagCountCriterion := models.IntCriterionInput{\n\t\tValue:    tagCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyGalleriesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyGalleriesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyGalleriesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyGalleriesTagCount(t, tagCountCriterion)\n}\n\nfunc verifyGalleriesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tTagCount: &tagCountCriterion,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\t\tassert.Greater(t, len(galleries), 0)\n\n\t\tfor _, gallery := range galleries {\n\t\t\tif err := gallery.LoadTagIDs(ctx, sqb); err != nil {\n\t\t\t\tt.Errorf(\"gallery.LoadTagIDs() error = %v\", err)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tverifyInt(t, len(gallery.TagIDs.List()), tagCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryPerformerCount(t *testing.T) {\n\tconst performerCount = 1\n\tperformerCountCriterion := models.IntCriterionInput{\n\t\tValue:    performerCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyGalleriesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyGalleriesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyGalleriesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyGalleriesPerformerCount(t, performerCountCriterion)\n}\n\nfunc verifyGalleriesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tPerformerCount: &performerCountCriterion,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\t\tassert.Greater(t, len(galleries), 0)\n\n\t\tfor _, gallery := range galleries {\n\t\t\tif err := gallery.LoadPerformerIDs(ctx, sqb); err != nil {\n\t\t\t\tt.Errorf(\"gallery.LoadPerformerIDs() error = %v\", err)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tverifyInt(t, len(gallery.PerformerIDs.List()), performerCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryAverageResolution(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Gallery\n\t\tresolution := models.ResolutionEnumLow\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tAverageResolution: &models.ResolutionCriterionInput{\n\t\t\t\tValue:    resolution,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t}\n\n\t\t// not verifying average - just ensure we get at least one\n\t\tgalleries := queryGallery(ctx, t, qb, &galleryFilter, nil)\n\t\tassert.Greater(t, len(galleries), 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryImageCount(t *testing.T) {\n\tconst imageCount = 0\n\timageCountCriterion := models.IntCriterionInput{\n\t\tValue:    imageCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyGalleriesImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyGalleriesImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyGalleriesImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyGalleriesImageCount(t, imageCountCriterion)\n}\n\nfunc verifyGalleriesImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tImageCount: &imageCountCriterion,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, nil)\n\t\tassert.Greater(t, len(galleries), -1)\n\n\t\tfor _, gallery := range galleries {\n\t\t\tpp := 0\n\n\t\t\tresult, err := db.Image.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: &models.FindFilterType{\n\t\t\t\t\t\tPerPage: &pp,\n\t\t\t\t\t},\n\t\t\t\t\tCount: true,\n\t\t\t\t},\n\t\t\t\tImageFilter: &models.ImageFilterType{\n\t\t\t\t\tGalleries: &models.MultiCriterionInput{\n\t\t\t\t\t\tValue:    []string{strconv.Itoa(gallery.ID)},\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, result.Count, imageCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQuerySorting(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tsortBy          string\n\t\tdir             models.SortDirectionEnum\n\t\tfirstGalleryIdx int // -1 to ignore\n\t\tlastGalleryIdx  int\n\t}{\n\t\t{\n\t\t\t\"file mod time\",\n\t\t\t\"file_mod_time\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"path\",\n\t\t\t\"path\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"title\",\n\t\t\t\"title\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, _, err := qb.Query(ctx, nil, &models.FindFilterType{\n\t\t\t\tSort:      &tt.sortBy,\n\t\t\t\tDirection: &tt.dir,\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GalleryStore.TestGalleryQuerySorting() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !assert.Greater(len(got), 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// scenes should be in same order as indexes\n\t\t\tfirstGallery := got[0]\n\t\t\tlastGallery := got[len(got)-1]\n\n\t\t\tif tt.firstGalleryIdx != -1 {\n\t\t\t\tfirstID := galleryIDs[tt.firstGalleryIdx]\n\t\t\t\tassert.Equal(firstID, firstGallery.ID)\n\t\t\t}\n\t\t\tif tt.lastGalleryIdx != -1 {\n\t\t\t\tlastID := galleryIDs[tt.lastGalleryIdx]\n\t\t\t\tassert.Equal(lastID, lastGallery.ID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGalleryStore_AddImages(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tgalleryID int\n\t\timageIDs  []int\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"single\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t[]int{imageIDs[imageIdx1WithPerformer]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"multiple\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t[]int{imageIDs[imageIdx1WithPerformer], imageIDs[imageIdx1WithStudio]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid gallery id\",\n\t\t\tinvalidID,\n\t\t\t[]int{imageIDs[imageIdx1WithPerformer]},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"single invalid\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t[]int{invalidID},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"one invalid\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t[]int{imageIDs[imageIdx1WithPerformer], invalidID},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"existing\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t[]int{imageIDs[imageIdxWithGallery]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"one new\",\n\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t[]int{imageIDs[imageIdx1WithPerformer], imageIDs[imageIdxWithGallery]},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tif err := qb.AddImages(ctx, tt.galleryID, tt.imageIDs...); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"GalleryStore.AddImages() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure image was added\n\t\t\timageIDs, err := qb.GetImageIDs(ctx, tt.galleryID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GalleryStore.GetImageIDs() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert := assert.New(t)\n\t\t\tfor _, wantedID := range tt.imageIDs {\n\t\t\t\tassert.Contains(imageIDs, wantedID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGalleryStore_RemoveImages(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tgalleryID int\n\t\timageIDs  []int\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"single\",\n\t\t\tgalleryIDs[galleryIdxWithTwoImages],\n\t\t\t[]int{imageIDs[imageIdx1WithGallery]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"multiple\",\n\t\t\tgalleryIDs[galleryIdxWithTwoImages],\n\t\t\t[]int{imageIDs[imageIdx1WithGallery], imageIDs[imageIdx2WithGallery]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid gallery id\",\n\t\t\tinvalidID,\n\t\t\t[]int{imageIDs[imageIdx1WithGallery]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"single invalid\",\n\t\t\tgalleryIDs[galleryIdxWithTwoImages],\n\t\t\t[]int{invalidID},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"one invalid\",\n\t\t\tgalleryIDs[galleryIdxWithTwoImages],\n\t\t\t[]int{imageIDs[imageIdx1WithGallery], invalidID},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not existing\",\n\t\t\tgalleryIDs[galleryIdxWithTwoImages],\n\t\t\t[]int{imageIDs[imageIdxWithPerformer]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"one existing\",\n\t\t\tgalleryIDs[galleryIdxWithTwoImages],\n\t\t\t[]int{imageIDs[imageIdx1WithPerformer], imageIDs[imageIdx1WithGallery]},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Gallery\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tif err := qb.RemoveImages(ctx, tt.galleryID, tt.imageIDs...); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"GalleryStore.RemoveImages() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure image was removed\n\t\t\timageIDs, err := qb.GetImageIDs(ctx, tt.galleryID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GalleryStore.GetImageIDs() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert := assert.New(t)\n\t\t\tfor _, excludedID := range tt.imageIDs {\n\t\t\t\tassert.NotContains(imageIDs, excludedID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGalleryQueryHasChapters(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\t\thasChapters := \"true\"\n\t\tgalleryFilter := models.GalleryFilterType{\n\t\t\tHasChapters: &hasChapters,\n\t\t}\n\n\t\tq := getGalleryStringValue(galleryIdxWithChapters, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tgalleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\n\t\tassert.Len(t, galleries, 1)\n\t\tassert.Equal(t, galleryIDs[galleryIdxWithChapters], galleries[0].ID)\n\n\t\thasChapters = \"false\"\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\t\tassert.Len(t, galleries, 0)\n\n\t\tfindFilter.Q = nil\n\t\tgalleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)\n\n\t\tassert.NotEqual(t, 0, len(galleries))\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGallerySetAndResetCover(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Gallery\n\n\t\timagePath2 := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx2WithGallery))\n\n\t\tresult, err := db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages])\n\t\tassert.Nil(t, err)\n\t\tassert.Nil(t, result)\n\n\t\terr = sqb.SetCover(ctx, galleryIDs[galleryIdxWithTwoImages], imageIDs[imageIdx2WithGallery])\n\t\tassert.Nil(t, err)\n\n\t\tresult, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages])\n\t\tassert.Nil(t, err)\n\t\tassert.Equal(t, result.Path, imagePath2)\n\n\t\terr = sqb.ResetCover(ctx, galleryIDs[galleryIdxWithTwoImages])\n\t\tassert.Nil(t, err)\n\n\t\tresult, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages])\n\t\tassert.Nil(t, err)\n\t\tassert.Nil(t, result)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGalleryQueryCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      *models.GalleryFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue:    []any{getGalleryStringValue(galleryIdxWithImage, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithImage},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGalleryStringValue(galleryIdxWithImage, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\t\tValue:    []any{getGalleryStringValue(galleryIdxWithImage, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{galleryIdxWithImage},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue:    []any{getGalleryStringValue(galleryIdxWithImage, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithImage},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGalleryStringValue(galleryIdxWithImage, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\t\tValue:    []any{getGalleryStringValue(galleryIdxWithImage, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{galleryIdxWithImage},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"regex\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*17_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithPerformerTag},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid regex\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGalleryStringValue(galleryIdxWithPerformerTag, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*17_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{galleryIdxWithPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid not matches regex\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGalleryStringValue(galleryIdxWithImage, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"not existing\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithImage},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGalleryStringValue(galleryIdxWithImage, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithImage},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"between\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{galleryIdxWithImage},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not between\",\n\t\t\t&models.GalleryFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGalleryStringValue(galleryIdxWithImage, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{galleryIdxWithImage},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgalleries, _, err := db.Gallery.Query(ctx, tt.filter, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"GalleryStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := galleriesToIDs(galleries)\n\t\t\tinclude := indexesToIDs(galleryIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(galleryIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TODO Count\n// TODO All\n// TODO Query\n// TODO Destroy\n"
  },
  {
    "path": "pkg/sqlite/group.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst (\n\tgroupTable    = \"groups\"\n\tgroupIDColumn = \"group_id\"\n\n\tgroupFrontImageBlobColumn = \"front_image_blob\"\n\tgroupBackImageBlobColumn  = \"back_image_blob\"\n\n\tgroupsTagsTable = \"groups_tags\"\n\n\tgroupURLsTable = \"group_urls\"\n\tgroupURLColumn = \"url\"\n\n\tgroupRelationsTable = \"groups_relations\"\n)\n\ntype groupRow struct {\n\tID            int         `db:\"id\" goqu:\"skipinsert\"`\n\tName          zero.String `db:\"name\"`\n\tAliases       zero.String `db:\"aliases\"`\n\tDuration      null.Int    `db:\"duration\"`\n\tDate          NullDate    `db:\"date\"`\n\tDatePrecision null.Int    `db:\"date_precision\"`\n\t// expressed as 1-100\n\tRating      null.Int    `db:\"rating\"`\n\tStudioID    null.Int    `db:\"studio_id,omitempty\"`\n\tDirector    zero.String `db:\"director\"`\n\tDescription zero.String `db:\"description\"`\n\tCreatedAt   Timestamp   `db:\"created_at\"`\n\tUpdatedAt   Timestamp   `db:\"updated_at\"`\n\n\t// not used in resolutions or updates\n\tFrontImageBlob zero.String `db:\"front_image_blob\"`\n\tBackImageBlob  zero.String `db:\"back_image_blob\"`\n}\n\nfunc (r *groupRow) fromGroup(o models.Group) {\n\tr.ID = o.ID\n\tr.Name = zero.StringFrom(o.Name)\n\tr.Aliases = zero.StringFrom(o.Aliases)\n\tr.Duration = intFromPtr(o.Duration)\n\tr.Date = NullDateFromDatePtr(o.Date)\n\tr.DatePrecision = datePrecisionFromDatePtr(o.Date)\n\tr.Rating = intFromPtr(o.Rating)\n\tr.StudioID = intFromPtr(o.StudioID)\n\tr.Director = zero.StringFrom(o.Director)\n\tr.Description = zero.StringFrom(o.Synopsis)\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n}\n\nfunc (r *groupRow) resolve() *models.Group {\n\tret := &models.Group{\n\t\tID:        r.ID,\n\t\tName:      r.Name.String,\n\t\tAliases:   r.Aliases.String,\n\t\tDuration:  nullIntPtr(r.Duration),\n\t\tDate:      r.Date.DatePtr(r.DatePrecision),\n\t\tRating:    nullIntPtr(r.Rating),\n\t\tStudioID:  nullIntPtr(r.StudioID),\n\t\tDirector:  r.Director.String,\n\t\tSynopsis:  r.Description.String,\n\t\tCreatedAt: r.CreatedAt.Timestamp,\n\t\tUpdatedAt: r.UpdatedAt.Timestamp,\n\t}\n\n\treturn ret\n}\n\ntype groupRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *groupRowRecord) fromPartial(o models.GroupPartial) {\n\tr.setNullString(\"name\", o.Name)\n\tr.setNullString(\"aliases\", o.Aliases)\n\tr.setNullInt(\"duration\", o.Duration)\n\tr.setNullDate(\"date\", \"date_precision\", o.Date)\n\tr.setNullInt(\"rating\", o.Rating)\n\tr.setNullInt(\"studio_id\", o.StudioID)\n\tr.setNullString(\"director\", o.Director)\n\tr.setNullString(\"description\", o.Synopsis)\n\tr.setTimestamp(\"created_at\", o.CreatedAt)\n\tr.setTimestamp(\"updated_at\", o.UpdatedAt)\n}\n\ntype groupRepositoryType struct {\n\trepository\n\tscenes repository\n\ttags   joinRepository\n}\n\nvar (\n\tgroupRepository = groupRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: groupTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\tscenes: repository{\n\t\t\ttableName: groupsScenesTable,\n\t\t\tidColumn:  groupIDColumn,\n\t\t},\n\t\ttags: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: groupsTagsTable,\n\t\t\t\tidColumn:  groupIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     tagIDColumn,\n\t\t\tforeignTable: tagTable,\n\t\t\torderBy:      tagTableSortSQL,\n\t\t},\n\t}\n)\n\ntype GroupStore struct {\n\tblobJoinQueryBuilder\n\tcustomFieldsStore\n\ttagRelationshipStore\n\tgroupRelationshipStore\n\n\ttableMgr *table\n}\n\nfunc NewGroupStore(blobStore *BlobStore) *GroupStore {\n\treturn &GroupStore{\n\t\tblobJoinQueryBuilder: blobJoinQueryBuilder{\n\t\t\tblobStore: blobStore,\n\t\t\tjoinTable: groupTable,\n\t\t},\n\t\tcustomFieldsStore: customFieldsStore{\n\t\t\ttable: groupsCustomFieldsTable,\n\t\t\tfk:    groupsCustomFieldsTable.Col(groupIDColumn),\n\t\t},\n\t\ttagRelationshipStore: tagRelationshipStore{\n\t\t\tidRelationshipStore: idRelationshipStore{\n\t\t\t\tjoinTable: groupsTagsTableMgr,\n\t\t\t},\n\t\t},\n\t\tgroupRelationshipStore: groupRelationshipStore{\n\t\t\ttable: groupRelationshipTableMgr,\n\t\t},\n\n\t\ttableMgr: groupTableMgr,\n\t}\n}\n\nfunc (qb *GroupStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *GroupStore) selectDataset() *goqu.SelectDataset {\n\treturn dialect.From(qb.table()).Select(qb.table().All())\n}\n\nfunc (qb *GroupStore) Create(ctx context.Context, newObject *models.Group) error {\n\tvar r groupRow\n\tr.fromGroup(*newObject)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif newObject.URLs.Loaded() {\n\t\tconst startPos = 0\n\t\tif err := groupsURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil {\n\t\treturn err\n\t}\n\n\tif err := qb.groupRelationshipStore.createContainingRelationships(ctx, id, newObject.ContainingGroups); err != nil {\n\t\treturn err\n\t}\n\n\tif err := qb.groupRelationshipStore.createSubRelationships(ctx, id, newObject.SubGroups); err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject = *updated\n\n\treturn nil\n}\n\nfunc (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models.GroupPartial) (*models.Group, error) {\n\tr := groupRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(partial)\n\n\tif len(r.Record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.URLs != nil {\n\t\tif err := groupsURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := qb.tagRelationshipStore.modifyRelationships(ctx, id, partial.TagIDs); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.groupRelationshipStore.modifyContainingRelationships(ctx, id, partial.ContainingGroups); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.groupRelationshipStore.modifySubRelationships(ctx, id, partial.SubGroups); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn qb.find(ctx, id)\n}\n\nfunc (qb *GroupStore) Update(ctx context.Context, updatedObject *models.Group) error {\n\tvar r groupRow\n\tr.fromGroup(*updatedObject)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\tif updatedObject.URLs.Loaded() {\n\t\tif err := groupsURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil {\n\t\treturn err\n\t}\n\n\tif err := qb.groupRelationshipStore.replaceContainingRelationships(ctx, updatedObject.ID, updatedObject.ContainingGroups); err != nil {\n\t\treturn err\n\t}\n\n\tif err := qb.groupRelationshipStore.replaceSubRelationships(ctx, updatedObject.ID, updatedObject.SubGroups); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *GroupStore) Destroy(ctx context.Context, id int) error {\n\t// must handle image checksums manually\n\tif err := qb.destroyImages(ctx, id); err != nil {\n\t\treturn err\n\t}\n\n\treturn groupRepository.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *GroupStore) Find(ctx context.Context, id int) (*models.Group, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *GroupStore) FindMany(ctx context.Context, ids []int) ([]*models.Group, error) {\n\tret := make([]*models.Group, len(ids))\n\n\ttable := qb.table()\n\tif err := batchExec(ids, defaultBatchSize, func(batch []int) error {\n\t\tq := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))\n\t\tunsorted, err := qb.getMany(ctx, q)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range unsorted {\n\t\t\ti := slices.Index(ids, s.ID)\n\t\t\tret[i] = s\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor i := range ret {\n\t\tif ret[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"group with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *GroupStore) find(ctx context.Context, id int) (*models.Group, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *GroupStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Group, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *GroupStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Group, error) {\n\tconst single = false\n\tvar ret []*models.Group\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f groupRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GroupStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) {\n\t// query := \"SELECT * FROM groups WHERE name = ?\"\n\t// if nocase {\n\t// \tquery += \" COLLATE NOCASE\"\n\t// }\n\t// query += \" LIMIT 1\"\n\twhere := \"name = ?\"\n\tif nocase {\n\t\twhere += \" COLLATE NOCASE\"\n\t}\n\tsq := qb.selectDataset().Prepared(true).Where(goqu.L(where, name)).Limit(1)\n\tret, err := qb.get(ctx, sq)\n\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GroupStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) {\n\t// query := \"SELECT * FROM groups WHERE name\"\n\t// if nocase {\n\t// \tquery += \" COLLATE NOCASE\"\n\t// }\n\t// query += \" IN \" + getInBinding(len(names))\n\twhere := \"name\"\n\tif nocase {\n\t\twhere += \" COLLATE NOCASE\"\n\t}\n\twhere += \" IN \" + getInBinding(len(names))\n\tvar args []interface{}\n\tfor _, name := range names {\n\t\targs = append(args, name)\n\t}\n\tsq := qb.selectDataset().Prepared(true).Where(goqu.L(where, args...))\n\tret, err := qb.getMany(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GroupStore) Count(ctx context.Context) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(qb.table())\n\treturn count(ctx, q)\n}\n\nfunc (qb *GroupStore) All(ctx context.Context) ([]*models.Group, error) {\n\ttable := qb.table()\n\n\treturn qb.getMany(ctx, qb.selectDataset().Order(\n\t\ttable.Col(\"name\").Asc(),\n\t\ttable.Col(idColumn).Asc(),\n\t))\n}\n\nfunc (qb *GroupStore) makeQuery(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\tif groupFilter == nil {\n\t\tgroupFilter = &models.GroupFilterType{}\n\t}\n\n\tquery := groupRepository.newQuery()\n\tdistinctIDs(&query, groupTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tsearchColumns := []string{\"groups.name\", \"groups.aliases\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &groupFilterHandler{\n\t\tgroupFilter: groupFilter,\n\t})\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.setGroupSort(&query, findFilter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tquery.sortAndPagination += getPagination(findFilter)\n\n\treturn &query, nil\n}\n\nfunc (qb *GroupStore) Query(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) ([]*models.Group, int, error) {\n\tquery, err := qb.makeQuery(ctx, groupFilter, findFilter)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tidsResult, countResult, err := query.executeFind(ctx)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tgroups, err := qb.FindMany(ctx, idsResult)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn groups, countResult, nil\n}\n\nfunc (qb *GroupStore) QueryCount(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (int, error) {\n\tquery, err := qb.makeQuery(ctx, groupFilter, findFilter)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn query.executeCount(ctx)\n}\n\nvar groupSortOptions = sortOptions{\n\t\"created_at\",\n\t\"date\",\n\t\"duration\",\n\t\"id\",\n\t\"name\",\n\t\"random\",\n\t\"rating\",\n\t\"scenes_count\",\n\t\"o_counter\",\n\t\"sub_group_order\",\n\t\"tag_count\",\n\t\"updated_at\",\n}\n\nfunc (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindFilterType) error {\n\tvar sort string\n\tvar direction string\n\tif findFilter == nil {\n\t\tsort = \"name\"\n\t\tdirection = \"ASC\"\n\t} else {\n\t\tsort = findFilter.GetSort(\"name\")\n\t\tdirection = findFilter.GetDirection()\n\t}\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := groupSortOptions.validateSort(sort); err != nil {\n\t\treturn err\n\t}\n\n\tswitch sort {\n\tcase \"sub_group_order\":\n\t\t// sub_group_order is a special sort that sorts by the order_index of the subgroups\n\t\tif query.hasJoin(\"groups_parents\") {\n\t\t\tquery.sortAndPagination += getSort(\"order_index\", direction, \"groups_parents\")\n\t\t} else {\n\t\t\t// this will give unexpected results if the query is not filtered by a parent group and\n\t\t\t// the group has multiple parents and order indexes\n\t\t\tquery.joinSort(groupRelationsTable, \"\", \"groups.id = groups_relations.sub_id\")\n\t\t\tquery.sortAndPagination += getSort(\"order_index\", direction, groupRelationsTable)\n\t\t}\n\tcase \"tag_count\":\n\t\tquery.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction)\n\tcase \"scenes_count\": // generic getSort won't work for this\n\t\tquery.sortAndPagination += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction)\n\tcase \"o_counter\":\n\t\tquery.sortAndPagination += qb.sortByOCounter(direction)\n\tdefault:\n\t\tquery.sortAndPagination += getSort(sort, direction, \"groups\")\n\t}\n\n\t// Whatever the sorting, always use name/id as a final sort\n\tquery.sortAndPagination += \", COALESCE(groups.name, groups.id) COLLATE NATURAL_CI ASC\"\n\treturn nil\n}\n\nfunc (qb *GroupStore) queryGroups(ctx context.Context, query string, args []interface{}) ([]*models.Group, error) {\n\tconst single = false\n\tvar ret []*models.Group\n\tif err := groupRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error {\n\t\tvar f groupRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GroupStore) UpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error {\n\treturn qb.UpdateImage(ctx, groupID, groupFrontImageBlobColumn, frontImage)\n}\n\nfunc (qb *GroupStore) UpdateBackImage(ctx context.Context, groupID int, backImage []byte) error {\n\treturn qb.UpdateImage(ctx, groupID, groupBackImageBlobColumn, backImage)\n}\n\nfunc (qb *GroupStore) destroyImages(ctx context.Context, groupID int) error {\n\tif err := qb.DestroyImage(ctx, groupID, groupFrontImageBlobColumn); err != nil {\n\t\treturn err\n\t}\n\tif err := qb.DestroyImage(ctx, groupID, groupBackImageBlobColumn); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *GroupStore) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) {\n\treturn qb.GetImage(ctx, groupID, groupFrontImageBlobColumn)\n}\n\nfunc (qb *GroupStore) HasFrontImage(ctx context.Context, groupID int) (bool, error) {\n\treturn qb.HasImage(ctx, groupID, groupFrontImageBlobColumn)\n}\n\nfunc (qb *GroupStore) GetBackImage(ctx context.Context, groupID int) ([]byte, error) {\n\treturn qb.GetImage(ctx, groupID, groupBackImageBlobColumn)\n}\n\nfunc (qb *GroupStore) HasBackImage(ctx context.Context, groupID int) (bool, error) {\n\treturn qb.HasImage(ctx, groupID, groupBackImageBlobColumn)\n}\n\nfunc (qb *GroupStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Group, error) {\n\tquery := `SELECT DISTINCT groups.*\nFROM groups\nINNER JOIN groups_scenes ON groups.id = groups_scenes.group_id\nINNER JOIN performers_scenes ON performers_scenes.scene_id = groups_scenes.scene_id\nWHERE performers_scenes.performer_id = ?\n`\n\targs := []interface{}{performerID}\n\treturn qb.queryGroups(ctx, query, args)\n}\n\nfunc (qb *GroupStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) {\n\tquery := `SELECT COUNT(DISTINCT groups_scenes.group_id) AS count\nFROM groups_scenes\nINNER JOIN performers_scenes ON performers_scenes.scene_id = groups_scenes.scene_id\nWHERE performers_scenes.performer_id = ?\n`\n\targs := []interface{}{performerID}\n\treturn groupRepository.runCountQuery(ctx, query, args)\n}\n\nfunc (qb *GroupStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Group, error) {\n\tquery := `SELECT groups.*\nFROM groups\nWHERE groups.studio_id = ?\n`\n\targs := []interface{}{studioID}\n\treturn qb.queryGroups(ctx, query, args)\n}\n\nfunc (qb *GroupStore) CountByStudioID(ctx context.Context, studioID int) (int, error) {\n\tquery := `SELECT COUNT(1) AS count\nFROM groups\nWHERE groups.studio_id = ?\n`\n\targs := []interface{}{studioID}\n\treturn groupRepository.runCountQuery(ctx, query, args)\n}\n\nfunc (qb *GroupStore) GetURLs(ctx context.Context, groupID int) ([]string, error) {\n\treturn groupsURLsTableMgr.get(ctx, groupID)\n}\n\n// FindSubGroupIDs returns a list of group IDs where a group in the ids list is a sub-group of the parent group\nfunc (qb *GroupStore) FindSubGroupIDs(ctx context.Context, containingID int, ids []int) ([]int, error) {\n\t/*\n\t\tSELECT gr.sub_id FROM groups_relations gr\n\t\tWHERE gr.containing_id = :parentID AND gr.sub_id IN (:ids);\n\t*/\n\ttable := groupRelationshipTableMgr.table\n\tq := dialect.From(table).Prepared(true).\n\t\tSelect(table.Col(\"sub_id\")).Where(\n\t\ttable.Col(\"containing_id\").Eq(containingID),\n\t\ttable.Col(\"sub_id\").In(ids),\n\t)\n\n\tconst single = false\n\tvar ret []int\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar id int\n\t\tif err := r.Scan(&id); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, id)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\n// FindInAscestors returns a list of group IDs where a group in the ids list is an ascestor of the ancestor group IDs\nfunc (qb *GroupStore) FindInAncestors(ctx context.Context, ascestorIDs []int, ids []int) ([]int, error) {\n\t/*\n\t\tWITH RECURSIVE ascestors AS (\n\t\t SELECT g.id AS parent_id FROM groups g WHERE g.id IN (:ascestorIDs)\n\t\t UNION\n\t\t SELECT gr.containing_id FROM groups_relations gr INNER JOIN ascestors a ON a.parent_id = gr.sub_id\n\t\t)\n\t\tSELECT p.parent_id FROM ascestors p WHERE p.parent_id IN (:ids);\n\t*/\n\ttable := qb.table()\n\tconst ascestors = \"ancestors\"\n\tconst parentID = \"parent_id\"\n\tq := dialect.From(ascestors).Prepared(true).\n\t\tWithRecursive(ascestors,\n\t\t\tdialect.From(qb.table()).Select(table.Col(idColumn).As(parentID)).\n\t\t\t\tWhere(table.Col(idColumn).In(ascestorIDs)).\n\t\t\t\tUnion(\n\t\t\t\t\tdialect.From(groupRelationsJoinTable).InnerJoin(\n\t\t\t\t\t\tgoqu.I(ascestors), goqu.On(goqu.I(\"parent_id\").Eq(goqu.I(\"sub_id\"))),\n\t\t\t\t\t).Select(\"containing_id\"),\n\t\t\t\t),\n\t\t).Select(parentID).Where(goqu.I(parentID).In(ids))\n\n\tconst single = false\n\tvar ret []int\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar id int\n\t\tif err := r.Scan(&id); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, id)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *GroupStore) sortByOCounter(direction string) string {\n\t// need to sum the o_counter from scenes and images\n\treturn \" ORDER BY (\" + selectGroupOCountSQL + \") \" + direction\n}\n"
  },
  {
    "path": "pkg/sqlite/group_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype groupFilterHandler struct {\n\tgroupFilter *models.GroupFilterType\n}\n\nfunc (qb *groupFilterHandler) validate() error {\n\tgroupFilter := qb.groupFilter\n\tif groupFilter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(groupFilter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif subFilter := groupFilter.SubFilter(); subFilter != nil {\n\t\tsqb := &groupFilterHandler{groupFilter: subFilter}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *groupFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tgroupFilter := qb.groupFilter\n\tif groupFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := groupFilter.SubFilter()\n\tif sf != nil {\n\t\tsub := &groupFilterHandler{sf}\n\t\thandleSubFilter(ctx, sub, f, groupFilter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nvar groupHierarchyHandler = hierarchicalRelationshipHandler{\n\tprimaryTable:  groupTable,\n\trelationTable: groupRelationsTable,\n\taliasPrefix:   groupTable,\n\tparentIDCol:   \"containing_id\",\n\tchildIDCol:    \"sub_id\",\n}\n\nfunc (qb *groupFilterHandler) criterionHandler() criterionHandler {\n\tgroupFilter := qb.groupFilter\n\treturn compoundHandler{\n\t\tstringCriterionHandler(groupFilter.Name, \"groups.name\"),\n\t\tstringCriterionHandler(groupFilter.Director, \"groups.director\"),\n\t\tstringCriterionHandler(groupFilter.Synopsis, \"groups.description\"),\n\t\tintCriterionHandler(groupFilter.Rating100, \"groups.rating\", nil),\n\t\tfloatIntCriterionHandler(groupFilter.Duration, \"groups.duration\", nil),\n\t\tqb.missingCriterionHandler(groupFilter.IsMissing),\n\t\tqb.urlsCriterionHandler(groupFilter.URL),\n\t\tstudioCriterionHandler(groupTable, groupFilter.Studios),\n\t\tqb.performersCriterionHandler(groupFilter.Performers),\n\t\tqb.tagsCriterionHandler(groupFilter.Tags),\n\t\tqb.tagCountCriterionHandler(groupFilter.TagCount),\n\t\tqb.groupOCounterCriterionHandler(groupFilter.OCounter),\n\t\tqb.sceneCountCriterionHandler(groupFilter.SceneCount),\n\t\t&dateCriterionHandler{groupFilter.Date, \"groups.date\", nil},\n\t\tgroupHierarchyHandler.ParentsCriterionHandler(groupFilter.ContainingGroups),\n\t\tgroupHierarchyHandler.ChildrenCriterionHandler(groupFilter.SubGroups),\n\t\tgroupHierarchyHandler.ParentCountCriterionHandler(groupFilter.ContainingGroupCount),\n\t\tgroupHierarchyHandler.ChildCountCriterionHandler(groupFilter.SubGroupCount),\n\t\t&timestampCriterionHandler{groupFilter.CreatedAt, \"groups.created_at\", nil},\n\t\t&timestampCriterionHandler{groupFilter.UpdatedAt, \"groups.updated_at\", nil},\n\n\t\t&customFieldsFilterHandler{\n\t\t\ttable: groupsCustomFieldsTable.GetTable(),\n\t\t\tfkCol: groupIDColumn,\n\t\t\tc:     groupFilter.CustomFields,\n\t\t\tidCol: \"groups.id\",\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"groups_scenes.scene_id\",\n\t\t\trelatedRepo:    sceneRepository.repository,\n\t\t\trelatedHandler: &sceneFilterHandler{groupFilter.ScenesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tgroupRepository.scenes.innerJoin(f, \"\", \"groups.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"groups.studio_id\",\n\t\t\trelatedRepo:    studioRepository.repository,\n\t\t\trelatedHandler: &studioFilterHandler{groupFilter.StudiosFilter},\n\t\t},\n\t}\n}\n\nfunc (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif isMissing != nil && *isMissing != \"\" {\n\t\t\tswitch *isMissing {\n\t\t\tcase \"front_image\":\n\t\t\t\tf.addWhere(\"groups.front_image_blob IS NULL\")\n\t\t\tcase \"back_image\":\n\t\t\t\tf.addWhere(\"groups.back_image_blob IS NULL\")\n\t\t\tcase \"scenes\":\n\t\t\t\tf.addLeftJoin(\"groups_scenes\", \"\", \"groups_scenes.group_id = groups.id\")\n\t\t\t\tf.addWhere(\"groups_scenes.scene_id IS NULL\")\n\t\t\tcase \"url\":\n\t\t\t\tgroupsURLsTableMgr.join(f, \"\", \"groups.id\")\n\t\t\t\tf.addWhere(\"group_urls.url IS NULL\")\n\t\t\tcase \"studio\":\n\t\t\t\tf.addWhere(\"groups.studio_id IS NULL\")\n\t\t\tcase \"performers\":\n\t\t\t\tf.addLeftJoin(\"groups_scenes\", \"gs_perf\", \"groups.id = gs_perf.group_id\")\n\t\t\t\tf.addLeftJoin(\"performers_scenes\", \"ps_perf\", \"gs_perf.scene_id = ps_perf.scene_id\")\n\t\t\t\tf.addWhere(\"ps_perf.performer_id IS NULL\")\n\t\t\tcase \"tags\":\n\t\t\t\tgroupRepository.tags.join(f, \"tags_join\", \"groups.id\")\n\t\t\t\tf.addWhere(\"tags_join.group_id IS NULL\")\n\t\t\tdefault:\n\t\t\t\tif err := validateIsMissing(*isMissing, []string{\n\t\t\t\t\t\"aliases\", \"description\", \"director\", \"date\", \"rating\",\n\t\t\t\t}); err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWhere(\"(groups.\" + *isMissing + \" IS NULL OR TRIM(groups.\" + *isMissing + \") = '')\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *groupFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: groupTable,\n\t\tprimaryFK:    groupIDColumn,\n\t\tjoinTable:    groupURLsTable,\n\t\tstringColumn: groupURLColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tgroupsURLsTableMgr.join(f, \"\", \"groups.id\")\n\t\t},\n\t}\n\n\treturn h.handler(url)\n}\n\nfunc (qb *groupFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performers != nil {\n\t\t\tif performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif performers.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tf.addLeftJoin(\"groups_scenes\", \"\", \"groups.id = groups_scenes.group_id\")\n\t\t\t\tf.addLeftJoin(\"performers_scenes\", \"\", \"groups_scenes.scene_id = performers_scenes.scene_id\")\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"performers_scenes.performer_id IS %s NULL\", notClause))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(performers.Value) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar args []interface{}\n\t\t\tfor _, arg := range performers.Value {\n\t\t\t\targs = append(args, arg)\n\t\t\t}\n\n\t\t\t// Hack, can't apply args to join, nor inner join on a left join, so use CTE instead\n\t\t\tf.addWith(`groups_performers AS (\n\t\t\t\tSELECT groups_scenes.group_id, performers_scenes.performer_id\n\t\t\t\tFROM groups_scenes\n\t\t\t\tINNER JOIN performers_scenes ON groups_scenes.scene_id = performers_scenes.scene_id\n\t\t\t\tWHERE performers_scenes.performer_id IN`+getInBinding(len(performers.Value))+`\n\t\t\t)`, args...)\n\t\t\tf.addLeftJoin(\"groups_performers\", \"\", \"groups.id = groups_performers.group_id\")\n\n\t\t\tswitch performers.Modifier {\n\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\tf.addWhere(\"groups_performers.performer_id IS NOT NULL\")\n\t\t\tcase models.CriterionModifierIncludesAll:\n\t\t\t\tf.addWhere(\"groups_performers.performer_id IS NOT NULL\")\n\t\t\t\tf.addHaving(\"COUNT(DISTINCT groups_performers.performer_id) = ?\", len(performers.Value))\n\t\t\tcase models.CriterionModifierExcludes:\n\t\t\t\tf.addWhere(\"groups_performers.performer_id IS NULL\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *groupFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\th := joinedHierarchicalMultiCriterionHandlerBuilder{\n\t\tprimaryTable: groupTable,\n\t\tforeignTable: tagTable,\n\t\tforeignFK:    \"tag_id\",\n\n\t\trelationsTable: \"tags_relations\",\n\t\tjoinAs:         \"group_tag\",\n\t\tjoinTable:      groupsTagsTable,\n\t\tprimaryFK:      groupIDColumn,\n\t}\n\n\treturn h.handler(tags)\n}\n\nfunc (qb *groupFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: groupTable,\n\t\tjoinTable:    groupsTagsTable,\n\t\tprimaryFK:    groupIDColumn,\n\t}\n\n\treturn h.handler(count)\n}\n\nfunc (qb *groupFilterHandler) sceneCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: groupTable,\n\t\tjoinTable:    groupsScenesTable,\n\t\tprimaryFK:    groupIDColumn,\n\t}\n\n\treturn h.handler(count)\n}\n\n// used for sorting and filtering on group o-count\nvar selectGroupOCountSQL = utils.StrFormat(\n\t\"SELECT SUM(o_counter) \"+\n\t\t\"FROM (\"+\n\t\t\"SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {groups_scenes} s \"+\n\t\t\"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} \"+\n\t\t\"LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id \"+\n\t\t\"WHERE s.{group_id} = {group}.id \"+\n\t\t\")\",\n\tmap[string]interface{}{\n\t\t\"group\":          groupTable,\n\t\t\"group_id\":       groupIDColumn,\n\t\t\"groups_scenes\":  groupsScenesTable,\n\t\t\"scenes\":         sceneTable,\n\t\t\"scene_id\":       sceneIDColumn,\n\t\t\"scenes_o_dates\": scenesODatesTable,\n\t\t\"o_date\":         sceneODateColumn,\n\t},\n)\n\nfunc (qb *groupFilterHandler) groupOCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif count == nil {\n\t\t\treturn\n\t\t}\n\n\t\tlhs := \"(\" + selectGroupOCountSQL + \")\"\n\t\tclause, args := getIntCriterionWhereClause(lhs, *count)\n\n\t\tf.addWhere(clause, args...)\n\t}\n\n}\n"
  },
  {
    "path": "pkg/sqlite/group_relationships.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n)\n\ntype groupRelationshipRow struct {\n\tContainingID int         `db:\"containing_id\"`\n\tSubID        int         `db:\"sub_id\"`\n\tOrderIndex   int         `db:\"order_index\"`\n\tDescription  zero.String `db:\"description\"`\n}\n\nfunc (r groupRelationshipRow) resolve(useContainingID bool) models.GroupIDDescription {\n\tid := r.ContainingID\n\tif !useContainingID {\n\t\tid = r.SubID\n\t}\n\n\treturn models.GroupIDDescription{\n\t\tGroupID:     id,\n\t\tDescription: r.Description.String,\n\t}\n}\n\ntype groupRelationshipStore struct {\n\ttable *table\n}\n\nfunc (s *groupRelationshipStore) GetContainingGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) {\n\tconst idIsContaining = false\n\treturn s.getGroupRelationships(ctx, id, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) GetSubGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) {\n\tconst idIsContaining = true\n\treturn s.getGroupRelationships(ctx, id, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) getGroupRelationships(ctx context.Context, id int, idIsContaining bool) ([]models.GroupIDDescription, error) {\n\tcol := \"containing_id\"\n\tif !idIsContaining {\n\t\tcol = \"sub_id\"\n\t}\n\n\ttable := s.table.table\n\tq := dialect.Select(table.All()).\n\t\tFrom(table).\n\t\tWhere(table.Col(col).Eq(id)).\n\t\tOrder(table.Col(\"order_index\").Asc())\n\n\tconst single = false\n\tvar ret []models.GroupIDDescription\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar row groupRelationshipRow\n\t\tif err := rows.StructScan(&row); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, row.resolve(!idIsContaining))\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"getting group relationships from %s: %w\", table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\n// getMaxOrderIndex gets the maximum order index for the containing group with the given id\nfunc (s *groupRelationshipStore) getMaxOrderIndex(ctx context.Context, containingID int) (int, error) {\n\tidColumn := s.table.table.Col(\"containing_id\")\n\n\tq := dialect.Select(goqu.MAX(\"order_index\")).\n\t\tFrom(s.table.table).\n\t\tWhere(idColumn.Eq(containingID))\n\n\tvar maxOrderIndex zero.Int\n\tif err := querySimple(ctx, q, &maxOrderIndex); err != nil {\n\t\treturn 0, fmt.Errorf(\"getting max order index: %w\", err)\n\t}\n\n\treturn int(maxOrderIndex.Int64), nil\n}\n\n// createRelationships creates relationships between a group and other groups.\n// If idIsContaining is true, the provided id is the containing group.\nfunc (s *groupRelationshipStore) createRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions, idIsContaining bool) error {\n\tif d.Loaded() {\n\t\tfor i, v := range d.List() {\n\t\t\torderIndex := i + 1\n\n\t\t\tr := groupRelationshipRow{\n\t\t\t\tContainingID: id,\n\t\t\t\tSubID:        v.GroupID,\n\t\t\t\tOrderIndex:   orderIndex,\n\t\t\t\tDescription:  zero.StringFrom(v.Description),\n\t\t\t}\n\n\t\t\tif !idIsContaining {\n\t\t\t\t// get the max order index of the containing groups sub groups\n\t\t\t\tcontainingID := v.GroupID\n\t\t\t\tmaxOrderIndex, err := s.getMaxOrderIndex(ctx, containingID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tr.ContainingID = v.GroupID\n\t\t\t\tr.SubID = id\n\t\t\t\tr.OrderIndex = maxOrderIndex + 1\n\t\t\t}\n\n\t\t\t_, err := s.table.insert(ctx, r)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"inserting into %s: %w\", s.table.table.GetTable(), err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\treturn nil\n}\n\n// createRelationships creates relationships between a group and other groups.\n// If idIsContaining is true, the provided id is the containing group.\nfunc (s *groupRelationshipStore) createContainingRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error {\n\tconst idIsContaining = false\n\treturn s.createRelationships(ctx, id, d, idIsContaining)\n}\n\n// createRelationships creates relationships between a group and other groups.\n// If idIsContaining is true, the provided id is the containing group.\nfunc (s *groupRelationshipStore) createSubRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error {\n\tconst idIsContaining = true\n\treturn s.createRelationships(ctx, id, d, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) replaceRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions, idIsContaining bool) error {\n\t// always destroy the existing relationships even if the new list is empty\n\tif err := s.destroyAllJoins(ctx, id, idIsContaining); err != nil {\n\t\treturn err\n\t}\n\n\treturn s.createRelationships(ctx, id, d, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) replaceContainingRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error {\n\tconst idIsContaining = false\n\treturn s.replaceRelationships(ctx, id, d, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) replaceSubRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error {\n\tconst idIsContaining = true\n\treturn s.replaceRelationships(ctx, id, d, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) modifyRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions, idIsContaining bool) error {\n\tif v == nil {\n\t\treturn nil\n\t}\n\n\tswitch v.Mode {\n\tcase models.RelationshipUpdateModeSet:\n\t\treturn s.replaceJoins(ctx, id, *v, idIsContaining)\n\tcase models.RelationshipUpdateModeAdd:\n\t\treturn s.addJoins(ctx, id, v.Groups, idIsContaining)\n\tcase models.RelationshipUpdateModeRemove:\n\t\ttoRemove := make([]int, len(v.Groups))\n\t\tfor i, vv := range v.Groups {\n\t\t\ttoRemove[i] = vv.GroupID\n\t\t}\n\t\treturn s.destroyJoins(ctx, id, toRemove, idIsContaining)\n\t}\n\n\treturn nil\n}\n\nfunc (s *groupRelationshipStore) modifyContainingRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions) error {\n\tconst idIsContaining = false\n\treturn s.modifyRelationships(ctx, id, v, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) modifySubRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions) error {\n\tconst idIsContaining = true\n\treturn s.modifyRelationships(ctx, id, v, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) addJoins(ctx context.Context, id int, groups []models.GroupIDDescription, idIsContaining bool) error {\n\t// if we're adding to a containing group, get the max order index first\n\tvar maxOrderIndex int\n\tif idIsContaining {\n\t\tvar err error\n\t\tmaxOrderIndex, err = s.getMaxOrderIndex(ctx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor i, vv := range groups {\n\t\tr := groupRelationshipRow{\n\t\t\tDescription: zero.StringFrom(vv.Description),\n\t\t}\n\n\t\tif idIsContaining {\n\t\t\tr.ContainingID = id\n\t\t\tr.SubID = vv.GroupID\n\t\t\tr.OrderIndex = maxOrderIndex + (i + 1)\n\t\t} else {\n\t\t\t// get the max order index of the containing groups sub groups\n\t\t\tcontainingMaxOrderIndex, err := s.getMaxOrderIndex(ctx, vv.GroupID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tr.ContainingID = vv.GroupID\n\t\t\tr.SubID = id\n\t\t\tr.OrderIndex = containingMaxOrderIndex + 1\n\t\t}\n\n\t\t_, err := s.table.insert(ctx, r)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"inserting into %s: %w\", s.table.table.GetTable(), err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (s *groupRelationshipStore) destroyAllJoins(ctx context.Context, id int, idIsContaining bool) error {\n\ttable := s.table.table\n\tidColumn := table.Col(\"containing_id\")\n\tif !idIsContaining {\n\t\tidColumn = table.Col(\"sub_id\")\n\t}\n\n\tq := dialect.Delete(table).Where(idColumn.Eq(id))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"destroying %s: %w\", table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *groupRelationshipStore) replaceJoins(ctx context.Context, id int, v models.UpdateGroupDescriptions, idIsContaining bool) error {\n\tif err := s.destroyAllJoins(ctx, id, idIsContaining); err != nil {\n\t\treturn err\n\t}\n\n\t// convert to RelatedGroupDescriptions\n\trgd := models.NewRelatedGroupDescriptions(v.Groups)\n\treturn s.createRelationships(ctx, id, rgd, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) destroyJoins(ctx context.Context, id int, toRemove []int, idIsContaining bool) error {\n\ttable := s.table.table\n\tidColumn := table.Col(\"containing_id\")\n\tfkColumn := table.Col(\"sub_id\")\n\tif !idIsContaining {\n\t\tidColumn = table.Col(\"sub_id\")\n\t\tfkColumn = table.Col(\"containing_id\")\n\t}\n\n\tq := dialect.Delete(table).Where(idColumn.Eq(id), fkColumn.In(toRemove))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"destroying %s: %w\", table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *groupRelationshipStore) getOrderIndexOfSubGroup(ctx context.Context, containingGroupID int, subGroupID int) (int, error) {\n\ttable := s.table.table\n\tq := dialect.Select(\"order_index\").\n\t\tFrom(table).\n\t\tWhere(\n\t\t\ttable.Col(\"containing_id\").Eq(containingGroupID),\n\t\t\ttable.Col(\"sub_id\").Eq(subGroupID),\n\t\t)\n\n\tvar orderIndex null.Int\n\tif err := querySimple(ctx, q, &orderIndex); err != nil {\n\t\treturn 0, fmt.Errorf(\"getting order index: %w\", err)\n\t}\n\n\tif !orderIndex.Valid {\n\t\treturn 0, fmt.Errorf(\"sub-group %d not found in containing group %d\", subGroupID, containingGroupID)\n\t}\n\n\treturn int(orderIndex.Int64), nil\n}\n\nfunc (s *groupRelationshipStore) getGroupIDAtOrderIndex(ctx context.Context, containingGroupID int, orderIndex int) (*int, error) {\n\ttable := s.table.table\n\tq := dialect.Select(table.Col(\"sub_id\")).From(table).Where(\n\t\ttable.Col(\"containing_id\").Eq(containingGroupID),\n\t\ttable.Col(\"order_index\").Eq(orderIndex),\n\t)\n\n\tvar ret null.Int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn nil, fmt.Errorf(\"getting sub id for order index: %w\", err)\n\t}\n\n\tif !ret.Valid {\n\t\treturn nil, nil\n\t}\n\n\tintRet := int(ret.Int64)\n\treturn &intRet, nil\n}\n\nfunc (s *groupRelationshipStore) getOrderIndexAfterOrderIndex(ctx context.Context, containingGroupID int, orderIndex int) (int, error) {\n\ttable := s.table.table\n\tq := dialect.Select(goqu.MIN(\"order_index\")).From(table).Where(\n\t\ttable.Col(\"containing_id\").Eq(containingGroupID),\n\t\ttable.Col(\"order_index\").Gt(orderIndex),\n\t)\n\n\tvar ret null.Int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, fmt.Errorf(\"getting order index: %w\", err)\n\t}\n\n\tif !ret.Valid {\n\t\treturn orderIndex + 1, nil\n\t}\n\n\treturn int(ret.Int64), nil\n}\n\n// incrementOrderIndexes increments the order_index value of all sub-groups in the containing group at or after the given index\nfunc (s *groupRelationshipStore) incrementOrderIndexes(ctx context.Context, groupID int, indexBefore int) error {\n\ttable := s.table.table\n\n\t// WORKAROUND - sqlite won't allow incrementing the value directly since it causes a\n\t// unique constraint violation.\n\t// Instead, we first set the order index to a negative value temporarily\n\t// see https://stackoverflow.com/a/7703239/695786\n\tq := dialect.Update(table).Set(exp.Record{\n\t\t\"order_index\": goqu.L(\"-order_index\"),\n\t}).Where(\n\t\ttable.Col(\"containing_id\").Eq(groupID),\n\t\ttable.Col(\"order_index\").Gte(indexBefore),\n\t)\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"updating %s: %w\", table.GetTable(), err)\n\t}\n\n\tq = dialect.Update(table).Set(exp.Record{\n\t\t\"order_index\": goqu.L(\"1-order_index\"),\n\t}).Where(\n\t\ttable.Col(\"containing_id\").Eq(groupID),\n\t\ttable.Col(\"order_index\").Lt(0),\n\t)\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"updating %s: %w\", table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *groupRelationshipStore) reorderSubGroup(ctx context.Context, groupID int, subGroupID int, insertPointID int, insertAfter bool) error {\n\tinsertPointIndex, err := s.getOrderIndexOfSubGroup(ctx, groupID, insertPointID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// if we're setting before\n\tif insertAfter {\n\t\tinsertPointIndex, err = s.getOrderIndexAfterOrderIndex(ctx, groupID, insertPointIndex)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// increment the order index of all sub-groups after and including the insertion point\n\tif err := s.incrementOrderIndexes(ctx, groupID, int(insertPointIndex)); err != nil {\n\t\treturn err\n\t}\n\n\t// set the order index of the sub-group to the insertion point\n\ttable := s.table.table\n\tq := dialect.Update(table).Set(exp.Record{\n\t\t\"order_index\": insertPointIndex,\n\t}).Where(\n\t\ttable.Col(\"containing_id\").Eq(groupID),\n\t\ttable.Col(\"sub_id\").Eq(subGroupID),\n\t)\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"updating %s: %w\", table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *groupRelationshipStore) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error {\n\tconst idIsContaining = true\n\n\tif err := s.addJoins(ctx, groupID, subGroups, idIsContaining); err != nil {\n\t\treturn err\n\t}\n\n\tids := make([]int, len(subGroups))\n\tfor i, v := range subGroups {\n\t\tids[i] = v.GroupID\n\t}\n\n\tif insertIndex != nil {\n\t\t// get the id of the sub-group at the insert index\n\t\tinsertPointID, err := s.getGroupIDAtOrderIndex(ctx, groupID, *insertIndex)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif insertPointID == nil {\n\t\t\t// if the insert index is out of bounds, just assume adding to the end\n\t\t\treturn nil\n\t\t}\n\n\t\t// reorder the sub-groups\n\t\tconst insertAfter = false\n\t\tif err := s.ReorderSubGroups(ctx, groupID, ids, *insertPointID, insertAfter); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (s *groupRelationshipStore) RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error {\n\tconst idIsContaining = true\n\treturn s.destroyJoins(ctx, groupID, subGroupIDs, idIsContaining)\n}\n\nfunc (s *groupRelationshipStore) ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error {\n\tfor _, id := range subGroupIDs {\n\t\tif err := s.reorderSubGroup(ctx, groupID, id, insertPointID, insertAfter); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/group_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/intslice\"\n)\n\nfunc loadGroupRelationships(ctx context.Context, expected models.Group, actual *models.Group) error {\n\tif expected.URLs.Loaded() {\n\t\tif err := actual.LoadURLs(ctx, db.Group); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.TagIDs.Loaded() {\n\t\tif err := actual.LoadTagIDs(ctx, db.Group); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.ContainingGroups.Loaded() {\n\t\tif err := actual.LoadContainingGroupIDs(ctx, db.Group); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.SubGroups.Loaded() {\n\t\tif err := actual.LoadSubGroupIDs(ctx, db.Group); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc Test_GroupStore_Create(t *testing.T) {\n\tvar (\n\t\tname                       = \"name\"\n\t\turl                        = \"url\"\n\t\taliases                    = \"alias1, alias2\"\n\t\tdirector                   = \"director\"\n\t\trating                     = 60\n\t\tduration                   = 34\n\t\tsynopsis                   = \"synopsis\"\n\t\tdate, _                    = models.ParseDate(\"2003-02-01\")\n\t\tcontainingGroupDescription = \"containingGroupDescription\"\n\t\tsubGroupDescription        = \"subGroupDescription\"\n\t\tcreatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.Group\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.Group{\n\t\t\t\tName:     name,\n\t\t\t\tDuration: &duration,\n\t\t\t\tDate:     &date,\n\t\t\t\tRating:   &rating,\n\t\t\t\tStudioID: &studioIDs[studioIdxWithGroup],\n\t\t\t\tDirector: director,\n\t\t\t\tSynopsis: synopsis,\n\t\t\t\tURLs:     models.NewRelatedStrings([]string{url}),\n\t\t\t\tTagIDs:   models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),\n\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},\n\t\t\t\t}),\n\t\t\t\tSubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription},\n\t\t\t\t}),\n\t\t\t\tAliases:   aliases,\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.Group{\n\t\t\t\tName:   name,\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid containing group id\",\n\t\t\tmodels.Group{\n\t\t\t\tName:             name,\n\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid sub group id\",\n\t\t\tmodels.Group{\n\t\t\t\tName:      name,\n\t\t\t\tSubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tp := tt.newObject\n\t\t\tif err := qb.Create(ctx, &p); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"GroupStore.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(p.ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(p.ID)\n\n\t\t\tcopy := tt.newObject\n\t\t\tcopy.ID = p.ID\n\n\t\t\t// load relationships\n\t\t\tif err := loadGroupRelationships(ctx, copy, &p); err != nil {\n\t\t\t\tt.Errorf(\"loadGroupRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, p)\n\n\t\t\t// ensure can find the group\n\t\t\tfound, err := qb.Find(ctx, p.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tif !assert.NotNil(found) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGroupRelationships(ctx, copy, found); err != nil {\n\t\t\t\tt.Errorf(\"loadGroupRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.Equal(copy, *found)\n\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc Test_groupQueryBuilder_Update(t *testing.T) {\n\tvar (\n\t\tname                       = \"name\"\n\t\turl                        = \"url\"\n\t\taliases                    = \"alias1, alias2\"\n\t\tdirector                   = \"director\"\n\t\trating                     = 60\n\t\tduration                   = 34\n\t\tsynopsis                   = \"synopsis\"\n\t\tdate, _                    = models.ParseDate(\"2003-02-01\")\n\t\tcontainingGroupDescription = \"containingGroupDescription\"\n\t\tsubGroupDescription        = \"subGroupDescription\"\n\t\tcreatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject models.Group\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.Group{\n\t\t\t\tID:       groupIDs[groupIdxWithTag],\n\t\t\t\tName:     name,\n\t\t\t\tDuration: &duration,\n\t\t\t\tDate:     &date,\n\t\t\t\tRating:   &rating,\n\t\t\t\tStudioID: &studioIDs[studioIdxWithGroup],\n\t\t\t\tDirector: director,\n\t\t\t\tSynopsis: synopsis,\n\t\t\t\tURLs:     models.NewRelatedStrings([]string{url}),\n\t\t\t\tTagIDs:   models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),\n\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},\n\t\t\t\t}),\n\t\t\t\tSubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription},\n\t\t\t\t}),\n\t\t\t\tAliases:   aliases,\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear tag ids\",\n\t\t\tmodels.Group{\n\t\t\t\tID:     groupIDs[groupIdxWithTag],\n\t\t\t\tName:   name,\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear containing ids\",\n\t\t\tmodels.Group{\n\t\t\t\tID:               groupIDs[groupIdxWithParent],\n\t\t\t\tName:             name,\n\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear sub ids\",\n\t\t\tmodels.Group{\n\t\t\t\tID:        groupIDs[groupIdxWithChild],\n\t\t\t\tName:      name,\n\t\t\t\tSubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid studio id\",\n\t\t\tmodels.Group{\n\t\t\t\tID:       groupIDs[groupIdxWithScene],\n\t\t\t\tName:     name,\n\t\t\t\tStudioID: &invalidID,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.Group{\n\t\t\t\tID:     groupIDs[groupIdxWithScene],\n\t\t\t\tName:   name,\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid containing group id\",\n\t\t\tmodels.Group{\n\t\t\t\tID:               groupIDs[groupIdxWithScene],\n\t\t\t\tName:             name,\n\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid sub group id\",\n\t\t\tmodels.Group{\n\t\t\t\tID:        groupIDs[groupIdxWithScene],\n\t\t\t\tName:      name,\n\t\t\t\tSubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Group\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tactual := tt.updatedObject\n\t\t\texpected := tt.updatedObject\n\n\t\t\tif err := qb.Update(ctx, &actual); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"groupQueryBuilder.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, actual.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"groupQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGroupRelationships(ctx, expected, s); err != nil {\n\t\t\t\tt.Errorf(\"loadGroupRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(expected, *s)\n\t\t})\n\t}\n}\n\nvar clearGroupPartial = models.GroupPartial{\n\t// leave mandatory fields\n\tAliases:          models.OptionalString{Set: true, Null: true},\n\tSynopsis:         models.OptionalString{Set: true, Null: true},\n\tDirector:         models.OptionalString{Set: true, Null: true},\n\tDuration:         models.OptionalInt{Set: true, Null: true},\n\tURLs:             &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},\n\tDate:             models.OptionalDate{Set: true, Null: true},\n\tRating:           models.OptionalInt{Set: true, Null: true},\n\tStudioID:         models.OptionalInt{Set: true, Null: true},\n\tTagIDs:           &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\tContainingGroups: &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet},\n\tSubGroups:        &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet},\n}\n\nfunc emptyGroup(idx int) models.Group {\n\treturn models.Group{\n\t\tID:               groupIDs[idx],\n\t\tName:             groupNames[idx],\n\t\tTagIDs:           models.NewRelatedIDs([]int{}),\n\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),\n\t\tSubGroups:        models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),\n\t}\n}\n\nfunc Test_groupQueryBuilder_UpdatePartial(t *testing.T) {\n\tvar (\n\t\tname                       = \"name\"\n\t\turl                        = \"url\"\n\t\taliases                    = \"alias1, alias2\"\n\t\tdirector                   = \"director\"\n\t\trating                     = 60\n\t\tduration                   = 34\n\t\tsynopsis                   = \"synopsis\"\n\t\tdate, _                    = models.ParseDate(\"2003-02-01\")\n\t\tcontainingGroupDescription = \"containingGroupDescription\"\n\t\tsubGroupDescription        = \"subGroupDescription\"\n\t\tcreatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.GroupPartial\n\t\twant    models.Group\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tgroupIDs[groupIdxWithScene],\n\t\t\tmodels.GroupPartial{\n\t\t\t\tName:     models.NewOptionalString(name),\n\t\t\t\tDirector: models.NewOptionalString(director),\n\t\t\t\tSynopsis: models.NewOptionalString(synopsis),\n\t\t\t\tAliases:  models.NewOptionalString(aliases),\n\t\t\t\tURLs: &models.UpdateStrings{\n\t\t\t\t\tValues: []string{url},\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tDate:      models.NewOptionalDate(date),\n\t\t\t\tDuration:  models.NewOptionalInt(duration),\n\t\t\t\tRating:    models.NewOptionalInt(rating),\n\t\t\t\tStudioID:  models.NewOptionalInt(studioIDs[studioIdxWithGroup]),\n\t\t\t\tCreatedAt: models.NewOptionalTime(createdAt),\n\t\t\t\tUpdatedAt: models.NewOptionalTime(updatedAt),\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithGroup], tagIDs[tagIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tContainingGroups: &models.UpdateGroupDescriptions{\n\t\t\t\t\tGroups: []models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription},\n\t\t\t\t\t\t{GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tSubGroups: &models.UpdateGroupDescriptions{\n\t\t\t\t\tGroups: []models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription},\n\t\t\t\t\t\t{GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Group{\n\t\t\t\tID:        groupIDs[groupIdxWithScene],\n\t\t\t\tName:      name,\n\t\t\t\tDirector:  director,\n\t\t\t\tSynopsis:  synopsis,\n\t\t\t\tAliases:   aliases,\n\t\t\t\tURLs:      models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:      &date,\n\t\t\t\tDuration:  &duration,\n\t\t\t\tRating:    &rating,\n\t\t\t\tStudioID:  &studioIDs[studioIdxWithGroup],\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\tTagIDs:    models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),\n\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription},\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription},\n\t\t\t\t}),\n\t\t\t\tSubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription},\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription},\n\t\t\t\t}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear all\",\n\t\t\tgroupIDs[groupIdxWithScene],\n\t\t\tclearGroupPartial,\n\t\t\temptyGroup(groupIdxWithScene),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear tag ids\",\n\t\t\tgroupIDs[groupIdxWithTag],\n\t\t\tclearGroupPartial,\n\t\t\temptyGroup(groupIdxWithTag),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear group relationships\",\n\t\t\tgroupIDs[groupIdxWithParentAndChild],\n\t\t\tclearGroupPartial,\n\t\t\temptyGroup(groupIdxWithParentAndChild),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add containing group\",\n\t\t\tgroupIDs[groupIdxWithParent],\n\t\t\tmodels.GroupPartial{\n\t\t\t\tContainingGroups: &models.UpdateGroupDescriptions{\n\t\t\t\t\tGroups: []models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Group{\n\t\t\t\tID:   groupIDs[groupIdxWithParent],\n\t\t\t\tName: groupNames[groupIdxWithParent],\n\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithChild]},\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},\n\t\t\t\t}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add sub group\",\n\t\t\tgroupIDs[groupIdxWithChild],\n\t\t\tmodels.GroupPartial{\n\t\t\t\tSubGroups: &models.UpdateGroupDescriptions{\n\t\t\t\t\tGroups: []models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Group{\n\t\t\t\tID:   groupIDs[groupIdxWithChild],\n\t\t\t\tName: groupNames[groupIdxWithChild],\n\t\t\t\tSubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithParent]},\n\t\t\t\t\t{GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription},\n\t\t\t\t}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove containing group\",\n\t\t\tgroupIDs[groupIdxWithParent],\n\t\t\tmodels.GroupPartial{\n\t\t\t\tContainingGroups: &models.UpdateGroupDescriptions{\n\t\t\t\t\tGroups: []models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: groupIDs[groupIdxWithChild]},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Group{\n\t\t\t\tID:               groupIDs[groupIdxWithParent],\n\t\t\t\tName:             groupNames[groupIdxWithParent],\n\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove sub group\",\n\t\t\tgroupIDs[groupIdxWithChild],\n\t\t\tmodels.GroupPartial{\n\t\t\t\tSubGroups: &models.UpdateGroupDescriptions{\n\t\t\t\t\tGroups: []models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: groupIDs[groupIdxWithParent]},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Group{\n\t\t\t\tID:        groupIDs[groupIdxWithChild],\n\t\t\t\tName:      groupNames[groupIdxWithChild],\n\t\t\t\tSubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid id\",\n\t\t\tinvalidID,\n\t\t\tmodels.GroupPartial{},\n\t\t\tmodels.Group{},\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Group\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"groupQueryBuilder.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGroupRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadGroupRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, *got)\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"groupQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadGroupRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadGroupRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, *s)\n\t\t})\n\t}\n}\n\nfunc Test_GroupStore_UpdatePartialCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tid       int\n\t\tpartial  models.GroupPartial\n\t\texpected map[string]interface{} // nil to use the partial\n\t}{\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\tgroupIDs[groupIdxWithChild],\n\t\t\tmodels.GroupPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\tgroupIDs[groupIdxWithChild],\n\t\t\tmodels.GroupPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"partial custom fields\",\n\t\t\tgroupIDs[groupIdxWithTwoTags],\n\t\t\tmodels.GroupPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\t\t\"new_field\": \"new\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"int\":       int64(3),\n\t\t\t\t\"real\":      0.3,\n\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\"new_field\": \"new\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Group\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t_, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.UpdatePartial() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure custom fields are correct\n\t\t\tcf, err := qb.GetCustomFields(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.expected == nil {\n\t\t\t\tassert.Equal(tt.partial.CustomFields.Full, cf)\n\t\t\t} else {\n\t\t\t\tassert.Equal(tt.expected, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGroupFindByName(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tmqb := db.Group\n\n\t\tname := groupNames[groupIdxWithScene] // find a group by name\n\n\t\tgroup, err := mqb.FindByName(ctx, name, false)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding groups: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, groupNames[groupIdxWithScene], group.Name)\n\n\t\tname = groupNames[groupIdxWithDupName] // find a group by name nocase\n\n\t\tgroup, err = mqb.FindByName(ctx, name, true)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding groups: %s\", err.Error())\n\t\t}\n\t\t// groupIdxWithDupName and groupIdxWithScene should have similar names ( only diff should be Name vs NaMe)\n\t\t//group.Name should match with groupIdxWithScene since its ID is before moveIdxWithDupName\n\t\tassert.Equal(t, groupNames[groupIdxWithScene], group.Name)\n\t\t//group.Name should match with groupIdxWithDupName if the check is not case sensitive\n\t\tassert.Equal(t, strings.ToLower(groupNames[groupIdxWithDupName]), strings.ToLower(group.Name))\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGroupFindByNames(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tvar names []string\n\n\t\tmqb := db.Group\n\n\t\tnames = append(names, groupNames[groupIdxWithScene]) // find groups by names\n\n\t\tgroups, err := mqb.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding groups: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, groups, 1)\n\t\tassert.Equal(t, groupNames[groupIdxWithScene], groups[0].Name)\n\n\t\tgroups, err = mqb.FindByNames(ctx, names, true) // find groups by names nocase\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding groups: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, groups, 2) // groupIdxWithScene and groupIdxWithDupName\n\t\tassert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[0].Name))\n\t\tassert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[1].Name))\n\n\t\treturn nil\n\t})\n}\n\nfunc groupsToIDs(i []*models.Group) []int {\n\tret := make([]int, len(i))\n\tfor i, v := range i {\n\t\tret[i] = v.ID\n\t}\n\n\treturn ret\n}\n\nfunc TestGroupQuery(t *testing.T) {\n\tvar (\n\t\tfrontImage = \"front_image\"\n\t\tbackImage  = \"back_image\"\n\t)\n\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.GroupFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"is missing front image\",\n\t\t\tnil,\n\t\t\t&models.GroupFilterType{\n\t\t\t\tIsMissing: &frontImage,\n\t\t\t},\n\t\t\t// just ensure that it doesn't error\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is missing back image\",\n\t\t\tnil,\n\t\t\t&models.GroupFilterType{\n\t\t\t\tIsMissing: &backImage,\n\t\t\t},\n\t\t\t// just ensure that it doesn't error\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"scene count equals 1\",\n\t\t\tnil,\n\t\t\t&models.GroupFilterType{\n\t\t\t\tSceneCount: &models.IntCriterionInput{\n\t\t\t\t\tValue:    1,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{groupIdxWithScene},\n\t\t\t[]int{groupIdxWithParentAndChild},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"scene count less than 1\",\n\t\t\tnil,\n\t\t\t&models.GroupFilterType{\n\t\t\t\tSceneCount: &models.IntCriterionInput{\n\t\t\t\t\tValue:    1,\n\t\t\t\t\tModifier: models.CriterionModifierLessThan,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{groupIdxWithParentAndChild},\n\t\t\t[]int{groupIdxWithScene},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, _, err := db.Group.Query(ctx, tt.filter, tt.findFilter)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"GroupQueryBuilder.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := groupsToIDs(results)\n\t\t\tinclude := indexesToIDs(performerIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(performerIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGroupQueryStudio(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tmqb := db.Group\n\t\tstudioCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGroup]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\tgroupFilter := models.GroupFilterType{\n\t\t\tStudios: &studioCriterion,\n\t\t}\n\n\t\tgroups, _, err := mqb.Query(ctx, &groupFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying group: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, groups, 1)\n\n\t\t// ensure id is correct\n\t\tassert.Equal(t, groupIDs[groupIdxWithStudio], groups[0].ID)\n\n\t\tstudioCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGroup]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t}\n\n\t\tq := getGroupStringValue(groupIdxWithStudio, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tgroups, _, err = mqb.Query(ctx, &groupFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying group: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, groups, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGroupQueryURL(t *testing.T) {\n\tconst sceneIdx = 1\n\tgroupURL := getGroupStringValue(sceneIdx, urlField)\n\n\turlCriterion := models.StringCriterionInput{\n\t\tValue:    groupURL,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tfilter := models.GroupFilterType{\n\t\tURL: &urlCriterion,\n\t}\n\n\tverifyFn := func(n *models.Group) {\n\t\tt.Helper()\n\n\t\turls := n.URLs.List()\n\t\tvar url string\n\t\tif len(urls) > 0 {\n\t\t\turl = urls[0]\n\t\t}\n\n\t\tverifyString(t, url, urlCriterion)\n\t}\n\n\tverifyGroupQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyGroupQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierMatchesRegex\n\turlCriterion.Value = \"group_.*1_URL\"\n\tverifyGroupQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyGroupQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierIsNull\n\turlCriterion.Value = \"\"\n\tverifyGroupQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyGroupQuery(t, filter, verifyFn)\n}\n\nfunc TestGroupQueryURLExcludes(t *testing.T) {\n\twithRollbackTxn(func(ctx context.Context) error {\n\t\tmqb := db.Group\n\n\t\t// create group with two URLs\n\t\tgroup := models.Group{\n\t\t\tName: \"TestGroupQueryURLExcludes\",\n\t\t\tURLs: models.NewRelatedStrings([]string{\n\t\t\t\t\"aaa\",\n\t\t\t\t\"bbb\",\n\t\t\t}),\n\t\t}\n\n\t\terr := mqb.Create(ctx, &group)\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating group: %w\", err)\n\t\t}\n\n\t\t// query for groups that exclude the URL \"aaa\"\n\t\turlCriterion := models.StringCriterionInput{\n\t\t\tValue:    \"aaa\",\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t}\n\n\t\tnameCriterion := models.StringCriterionInput{\n\t\t\tValue:    group.Name,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t}\n\n\t\tfilter := models.GroupFilterType{\n\t\t\tURL:  &urlCriterion,\n\t\t\tName: &nameCriterion,\n\t\t}\n\n\t\tgroups := queryGroups(ctx, t, &filter, nil)\n\t\tassert.Len(t, groups, 0, \"Expected no groups to be found\")\n\n\t\t// query for groups that exclude the URL \"ccc\"\n\t\turlCriterion.Value = \"ccc\"\n\t\tgroups = queryGroups(ctx, t, &filter, nil)\n\n\t\tif assert.Len(t, groups, 1, \"Expected one group to be found\") {\n\t\t\tassert.Equal(t, group.Name, groups[0].Name)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyGroupQuery(t *testing.T, filter models.GroupFilterType, verifyFn func(s *models.Group)) {\n\twithTxn(func(ctx context.Context) error {\n\t\tt.Helper()\n\t\tsqb := db.Group\n\n\t\tgroups := queryGroups(ctx, t, &filter, nil)\n\n\t\tfor _, group := range groups {\n\t\t\tif err := group.LoadURLs(ctx, sqb); err != nil {\n\t\t\t\tt.Errorf(\"Error loading group relationships: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\t// assume it should find at least one\n\t\tassert.Greater(t, len(groups), 0)\n\n\t\tfor _, m := range groups {\n\t\t\tverifyFn(m)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc queryGroups(ctx context.Context, t *testing.T, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) []*models.Group {\n\tsqb := db.Group\n\tgroups, _, err := sqb.Query(ctx, groupFilter, findFilter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying group: %s\", err.Error())\n\t}\n\n\treturn groups\n}\n\nfunc TestGroupQueryTags(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttagCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithGroup]),\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithGroup]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\tgroupFilter := models.GroupFilterType{\n\t\t\tTags: &tagCriterion,\n\t\t}\n\n\t\t// ensure ids are correct\n\t\tgroups := queryGroups(ctx, t, &groupFilter, nil)\n\t\tassert.Len(t, groups, 3)\n\t\tfor _, group := range groups {\n\t\t\tassert.True(t, group.ID == groupIDs[groupIdxWithTag] || group.ID == groupIDs[groupIdxWithTwoTags] || group.ID == groupIDs[groupIdxWithThreeTags])\n\t\t}\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithGroup]),\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithGroup]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t}\n\n\t\tgroups = queryGroups(ctx, t, &groupFilter, nil)\n\n\t\tif assert.Len(t, groups, 2) {\n\t\t\tassert.Equal(t, sceneIDs[groupIdxWithTwoTags], groups[0].ID)\n\t\t\tassert.Equal(t, sceneIDs[groupIdxWithThreeTags], groups[1].ID)\n\t\t}\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithGroup]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t}\n\n\t\tq := getSceneStringValue(groupIdxWithTwoTags, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tgroups = queryGroups(ctx, t, &groupFilter, &findFilter)\n\t\tassert.Len(t, groups, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGroupQueryTagCount(t *testing.T) {\n\tconst tagCount = 1\n\ttagCountCriterion := models.IntCriterionInput{\n\t\tValue:    tagCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyGroupsTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyGroupsTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyGroupsTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyGroupsTagCount(t, tagCountCriterion)\n}\n\nfunc verifyGroupsTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Group\n\t\tgroupFilter := models.GroupFilterType{\n\t\t\tTagCount: &tagCountCriterion,\n\t\t}\n\n\t\tgroups := queryGroups(ctx, t, &groupFilter, nil)\n\t\tassert.Greater(t, len(groups), 0)\n\n\t\tfor _, group := range groups {\n\t\t\tids, err := sqb.GetTagIDs(ctx, group.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, len(ids), tagCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGroupQuerySorting(t *testing.T) {\n\tsort := \"scenes_count\"\n\tdirection := models.SortDirectionEnumDesc\n\tfindFilter := models.FindFilterType{\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tgroups := queryGroups(ctx, t, nil, &findFilter)\n\n\t\t// scenes should be in same order as indexes\n\t\tfirstGroup := groups[0]\n\n\t\tassert.Equal(t, groupIDs[groupIdxWithScene], firstGroup.ID)\n\n\t\t// sort in descending order\n\t\tdirection = models.SortDirectionEnumAsc\n\n\t\tgroups = queryGroups(ctx, t, nil, &findFilter)\n\t\tlastGroup := groups[len(groups)-1]\n\n\t\tassert.Equal(t, groupIDs[groupIdxWithParentAndScene], lastGroup.ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGroupQuerySortOrderIndex(t *testing.T) {\n\tsort := \"sub_group_order\"\n\tdirection := models.SortDirectionEnumDesc\n\tfindFilter := models.FindFilterType{\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\tgroupFilter := models.GroupFilterType{\n\t\tContainingGroups: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    intslice.IntSliceToStringSlice([]int{groupIdxWithChild}),\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\t// just ensure there are no errors\n\t\t_, _, err := db.Group.Query(ctx, &groupFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying group: %s\", err.Error())\n\t\t}\n\n\t\t_, _, err = db.Group.Query(ctx, nil, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying group: %s\", err.Error())\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestGroupUpdateFrontImage(t *testing.T) {\n\tif err := withRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Group\n\n\t\t// create group to test against\n\t\tconst name = \"TestGroupUpdateGroupImages\"\n\t\tgroup := models.Group{\n\t\t\tName: name,\n\t\t}\n\t\terr := qb.Create(ctx, &group)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating group: %s\", err.Error())\n\t\t}\n\n\t\treturn testUpdateImage(t, ctx, group.ID, qb.UpdateFrontImage, qb.GetFrontImage)\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestGroupUpdateBackImage(t *testing.T) {\n\tif err := withRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Group\n\n\t\t// create group to test against\n\t\tconst name = \"TestGroupUpdateGroupImages\"\n\t\tgroup := models.Group{\n\t\t\tName: name,\n\t\t}\n\t\terr := qb.Create(ctx, &group)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating group: %s\", err.Error())\n\t\t}\n\n\t\treturn testUpdateImage(t, ctx, group.ID, qb.UpdateBackImage, qb.GetBackImage)\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestGroupQueryContainingGroups(t *testing.T) {\n\tconst nameField = \"Name\"\n\n\ttype criterion struct {\n\t\tvalueIdxs []int\n\t\tmodifier  models.CriterionModifier\n\t\tdepth     int\n\t}\n\n\ttests := []struct {\n\t\tname        string\n\t\tc           criterion\n\t\tq           string\n\t\tincludeIdxs []int\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithChild},\n\t\t\t\tmodels.CriterionModifierIncludes,\n\t\t\t\t0,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithParent},\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithChild},\n\t\t\t\tmodels.CriterionModifierExcludes,\n\t\t\t\t0,\n\t\t\t},\n\t\t\tgetGroupStringValue(groupIdxWithParent, nameField),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"includes (all levels)\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithGrandChild},\n\t\t\t\tmodels.CriterionModifierIncludes,\n\t\t\t\t-1,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},\n\t\t},\n\t\t{\n\t\t\t\"includes (1 level)\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithGrandChild},\n\t\t\t\tmodels.CriterionModifierIncludes,\n\t\t\t\t1,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tcriterion{\n\t\t\t\tnil,\n\t\t\t\tmodels.CriterionModifierIsNull,\n\t\t\t\t0,\n\t\t\t},\n\t\t\tgetGroupStringValue(groupIdxWithParent, nameField),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tcriterion{\n\t\t\t\tnil,\n\t\t\t\tmodels.CriterionModifierNotNull,\n\t\t\t\t0,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithParentAndChild, groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndScene},\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\tvalueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs)\n\t\texpectedIDs := indexesToIDs(groupIDs, tt.includeIdxs)\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgroupFilter := &models.GroupFilterType{\n\t\t\t\tContainingGroups: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    intslice.IntSliceToStringSlice(valueIDs),\n\t\t\t\t\tModifier: tt.c.modifier,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tif tt.c.depth != 0 {\n\t\t\t\tgroupFilter.ContainingGroups.Depth = &tt.c.depth\n\t\t\t}\n\n\t\t\tfindFilter := models.FindFilterType{}\n\t\t\tif tt.q != \"\" {\n\t\t\t\tfindFilter.Q = &tt.q\n\t\t\t}\n\n\t\t\tgroups, _, err := qb.Query(ctx, groupFilter, &findFilter)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.Query() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tgroupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })\n\t\t\tassert.ElementsMatch(t, expectedIDs, groupIDs)\n\t\t})\n\t}\n}\n\nfunc TestGroupQuerySubGroups(t *testing.T) {\n\tconst nameField = \"Name\"\n\n\ttype criterion struct {\n\t\tvalueIdxs []int\n\t\tmodifier  models.CriterionModifier\n\t\tdepth     int\n\t}\n\n\ttests := []struct {\n\t\tname         string\n\t\tc            criterion\n\t\tq            string\n\t\texpectedIdxs []int\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithParent},\n\t\t\t\tmodels.CriterionModifierIncludes,\n\t\t\t\t0,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithChild},\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithParent},\n\t\t\t\tmodels.CriterionModifierExcludes,\n\t\t\t\t0,\n\t\t\t},\n\t\t\tgetGroupStringValue(groupIdxWithChild, nameField),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"includes (all levels)\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithGrandParent},\n\t\t\t\tmodels.CriterionModifierIncludes,\n\t\t\t\t-1,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithGrandChild, groupIdxWithParentAndChild},\n\t\t},\n\t\t{\n\t\t\t\"includes (1 level)\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithGrandParent},\n\t\t\t\tmodels.CriterionModifierIncludes,\n\t\t\t\t1,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithGrandChild, groupIdxWithParentAndChild},\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tcriterion{\n\t\t\t\tnil,\n\t\t\t\tmodels.CriterionModifierIsNull,\n\t\t\t\t0,\n\t\t\t},\n\t\t\tgetGroupStringValue(groupIdxWithChild, nameField),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tcriterion{\n\t\t\t\tnil,\n\t\t\t\tmodels.CriterionModifierNotNull,\n\t\t\t\t0,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithGrandChild, groupIdxWithChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\tvalueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs)\n\t\texpectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgroupFilter := &models.GroupFilterType{\n\t\t\t\tSubGroups: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    intslice.IntSliceToStringSlice(valueIDs),\n\t\t\t\t\tModifier: tt.c.modifier,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tif tt.c.depth != 0 {\n\t\t\t\tgroupFilter.SubGroups.Depth = &tt.c.depth\n\t\t\t}\n\n\t\t\tfindFilter := models.FindFilterType{}\n\t\t\tif tt.q != \"\" {\n\t\t\t\tfindFilter.Q = &tt.q\n\t\t\t}\n\n\t\t\tgroups, _, err := qb.Query(ctx, groupFilter, &findFilter)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.Query() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tgroupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })\n\t\t\tassert.ElementsMatch(t, expectedIDs, groupIDs)\n\t\t})\n\t}\n}\n\nfunc TestGroupQueryContainingGroupCount(t *testing.T) {\n\tconst nameField = \"Name\"\n\n\ttests := []struct {\n\t\tname         string\n\t\tvalue        int\n\t\tmodifier     models.CriterionModifier\n\t\tq            string\n\t\texpectedIdxs []int\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t1,\n\t\t\tmodels.CriterionModifierEquals,\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene},\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t1,\n\t\t\tmodels.CriterionModifierNotEquals,\n\t\t\tgetGroupStringValue(groupIdxWithParent, nameField),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"less than\",\n\t\t\t1,\n\t\t\tmodels.CriterionModifierLessThan,\n\t\t\tgetGroupStringValue(groupIdxWithParent, nameField),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"greater than\",\n\t\t\t0,\n\t\t\tmodels.CriterionModifierGreaterThan,\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene},\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\texpectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgroupFilter := &models.GroupFilterType{\n\t\t\t\tContainingGroupCount: &models.IntCriterionInput{\n\t\t\t\t\tValue:    tt.value,\n\t\t\t\t\tModifier: tt.modifier,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tfindFilter := models.FindFilterType{}\n\t\t\tif tt.q != \"\" {\n\t\t\t\tfindFilter.Q = &tt.q\n\t\t\t}\n\n\t\t\tgroups, _, err := qb.Query(ctx, groupFilter, &findFilter)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.Query() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tgroupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })\n\t\t\tassert.ElementsMatch(t, expectedIDs, groupIDs)\n\t\t})\n\t}\n}\n\nfunc TestGroupQuerySubGroupCount(t *testing.T) {\n\tconst nameField = \"Name\"\n\n\ttests := []struct {\n\t\tname         string\n\t\tvalue        int\n\t\tmodifier     models.CriterionModifier\n\t\tq            string\n\t\texpectedIdxs []int\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t1,\n\t\t\tmodels.CriterionModifierEquals,\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t1,\n\t\t\tmodels.CriterionModifierNotEquals,\n\t\t\tgetGroupStringValue(groupIdxWithChild, nameField),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"less than\",\n\t\t\t1,\n\t\t\tmodels.CriterionModifierLessThan,\n\t\t\tgetGroupStringValue(groupIdxWithChild, nameField),\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"greater than\",\n\t\t\t0,\n\t\t\tmodels.CriterionModifierGreaterThan,\n\t\t\t\"\",\n\t\t\t[]int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\texpectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgroupFilter := &models.GroupFilterType{\n\t\t\t\tSubGroupCount: &models.IntCriterionInput{\n\t\t\t\t\tValue:    tt.value,\n\t\t\t\t\tModifier: tt.modifier,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tfindFilter := models.FindFilterType{}\n\t\t\tif tt.q != \"\" {\n\t\t\t\tfindFilter.Q = &tt.q\n\t\t\t}\n\n\t\t\tgroups, _, err := qb.Query(ctx, groupFilter, &findFilter)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.Query() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tgroupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })\n\t\t\tassert.ElementsMatch(t, expectedIDs, groupIDs)\n\t\t})\n\t}\n}\n\nfunc TestGroupFindInAncestors(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tancestorIdxs []int\n\t\tidxs         []int\n\t\texpectedIdxs []int\n\t}{\n\t\t{\n\t\t\t\"basic\",\n\t\t\t[]int{groupIdxWithGrandParent},\n\t\t\t[]int{groupIdxWithGrandChild},\n\t\t\t[]int{groupIdxWithGrandChild},\n\t\t},\n\t\t{\n\t\t\t\"same\",\n\t\t\t[]int{groupIdxWithScene},\n\t\t\t[]int{groupIdxWithScene},\n\t\t\t[]int{groupIdxWithScene},\n\t\t},\n\t\t{\n\t\t\t\"no matches\",\n\t\t\t[]int{groupIdxWithGrandParent},\n\t\t\t[]int{groupIdxWithScene},\n\t\t\tnil,\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\tancestorIDs := indexesToIDs(groupIDs, tt.ancestorIdxs)\n\t\tids := indexesToIDs(groupIDs, tt.idxs)\n\t\texpectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tfound, err := qb.FindInAncestors(ctx, ancestorIDs, ids)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.FindInAncestors() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tassert.ElementsMatch(t, found, expectedIDs)\n\t\t})\n\t}\n}\n\nfunc TestGroupReorderSubGroups(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tsubGroupLen int\n\t\tidxsToMove  []int\n\t\tinsertLoc   int\n\t\tinsertAfter bool\n\t\t// order of elements, using original indexes\n\t\texpectedIdxs []int\n\t}{\n\t\t{\n\t\t\t\"move single back before\",\n\t\t\t5,\n\t\t\t[]int{2},\n\t\t\t1,\n\t\t\tfalse,\n\t\t\t[]int{0, 2, 1, 3, 4},\n\t\t},\n\t\t{\n\t\t\t\"move single forward before\",\n\t\t\t5,\n\t\t\t[]int{2},\n\t\t\t4,\n\t\t\tfalse,\n\t\t\t[]int{0, 1, 3, 2, 4},\n\t\t},\n\t\t{\n\t\t\t\"move multiple back before\",\n\t\t\t5,\n\t\t\t[]int{3, 2, 4},\n\t\t\t0,\n\t\t\tfalse,\n\t\t\t[]int{3, 2, 4, 0, 1},\n\t\t},\n\t\t{\n\t\t\t\"move multiple forward before\",\n\t\t\t5,\n\t\t\t[]int{2, 1, 0},\n\t\t\t4,\n\t\t\tfalse,\n\t\t\t[]int{3, 2, 1, 0, 4},\n\t\t},\n\t\t{\n\t\t\t\"move single back after\",\n\t\t\t5,\n\t\t\t[]int{2},\n\t\t\t0,\n\t\t\ttrue,\n\t\t\t[]int{0, 2, 1, 3, 4},\n\t\t},\n\t\t{\n\t\t\t\"move single forward after\",\n\t\t\t5,\n\t\t\t[]int{2},\n\t\t\t4,\n\t\t\ttrue,\n\t\t\t[]int{0, 1, 3, 4, 2},\n\t\t},\n\t\t{\n\t\t\t\"move multiple back after\",\n\t\t\t5,\n\t\t\t[]int{3, 2, 4},\n\t\t\t0,\n\t\t\tfalse,\n\t\t\t[]int{0, 3, 2, 4, 1},\n\t\t},\n\t\t{\n\t\t\t\"move multiple forward after\",\n\t\t\t5,\n\t\t\t[]int{2, 1, 0},\n\t\t\t4,\n\t\t\tfalse,\n\t\t\t[]int{3, 4, 2, 1, 0},\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\t// create the group\n\t\t\tgroup := models.Group{\n\t\t\t\tName: \"TestGroupReorderSubGroups\",\n\t\t\t}\n\n\t\t\tif err := qb.Create(ctx, &group); err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.Create() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// and sub-groups\n\t\t\tidxToId := make([]int, tt.subGroupLen)\n\n\t\t\tfor i := 0; i < tt.subGroupLen; i++ {\n\t\t\t\tsubGroup := models.Group{\n\t\t\t\t\tName: fmt.Sprintf(\"SubGroup %d\", i),\n\t\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: group.ID},\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t\t\tif err := qb.Create(ctx, &subGroup); err != nil {\n\t\t\t\t\tt.Errorf(\"GroupStore.Create() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tidxToId[i] = subGroup.ID\n\t\t\t}\n\n\t\t\t// reorder\n\t\t\tidsToMove := indexesToIDs(idxToId, tt.idxsToMove)\n\t\t\tinsertID := idxToId[tt.insertLoc]\n\t\t\tif err := qb.ReorderSubGroups(ctx, group.ID, idsToMove, insertID, tt.insertAfter); err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.ReorderSubGroups() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// validate the new order\n\t\t\tgd, err := qb.GetSubGroupDescriptions(ctx, group.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.GetSubGroupDescriptions() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tnewIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })\n\t\t\tnewIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })\n\n\t\t\tassert.ElementsMatch(t, tt.expectedIdxs, newIdxs)\n\t\t})\n\t}\n}\n\nfunc TestGroupAddSubGroups(t *testing.T) {\n\ttests := []struct {\n\t\tname                string\n\t\texistingSubGroupLen int\n\t\tinsertGroupsLen     int\n\t\tinsertLoc           int\n\t\t// order of elements, using original indexes\n\t\texpectedIdxs []int\n\t}{\n\t\t{\n\t\t\t\"append single\",\n\t\t\t4,\n\t\t\t1,\n\t\t\t999,\n\t\t\t[]int{0, 1, 2, 3, 4},\n\t\t},\n\t\t{\n\t\t\t\"insert single middle\",\n\t\t\t4,\n\t\t\t1,\n\t\t\t2,\n\t\t\t[]int{0, 1, 4, 2, 3},\n\t\t},\n\t\t{\n\t\t\t\"insert single start\",\n\t\t\t4,\n\t\t\t1,\n\t\t\t0,\n\t\t\t[]int{4, 0, 1, 2, 3},\n\t\t},\n\t\t{\n\t\t\t\"append multiple\",\n\t\t\t4,\n\t\t\t2,\n\t\t\t999,\n\t\t\t[]int{0, 1, 2, 3, 4, 5},\n\t\t},\n\t\t{\n\t\t\t\"insert multiple middle\",\n\t\t\t4,\n\t\t\t2,\n\t\t\t2,\n\t\t\t[]int{0, 1, 4, 5, 2, 3},\n\t\t},\n\t\t{\n\t\t\t\"insert multiple start\",\n\t\t\t4,\n\t\t\t2,\n\t\t\t0,\n\t\t\t[]int{4, 5, 0, 1, 2, 3},\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\t// create the group\n\t\t\tgroup := models.Group{\n\t\t\t\tName: \"TestGroupReorderSubGroups\",\n\t\t\t}\n\n\t\t\tif err := qb.Create(ctx, &group); err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.Create() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// and sub-groups\n\t\t\tidxToId := make([]int, tt.existingSubGroupLen+tt.insertGroupsLen)\n\n\t\t\tfor i := 0; i < tt.existingSubGroupLen; i++ {\n\t\t\t\tsubGroup := models.Group{\n\t\t\t\t\tName: fmt.Sprintf(\"Existing SubGroup %d\", i),\n\t\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: group.ID},\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t\t\tif err := qb.Create(ctx, &subGroup); err != nil {\n\t\t\t\t\tt.Errorf(\"GroupStore.Create() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tidxToId[i] = subGroup.ID\n\t\t\t}\n\n\t\t\t// and sub-groups to insert\n\t\t\tfor i := 0; i < tt.insertGroupsLen; i++ {\n\t\t\t\tsubGroup := models.Group{\n\t\t\t\t\tName: fmt.Sprintf(\"Inserted SubGroup %d\", i),\n\t\t\t\t}\n\n\t\t\t\tif err := qb.Create(ctx, &subGroup); err != nil {\n\t\t\t\t\tt.Errorf(\"GroupStore.Create() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tidxToId[i+tt.existingSubGroupLen] = subGroup.ID\n\t\t\t}\n\n\t\t\t// convert ids to description\n\t\t\tidDescriptions := make([]models.GroupIDDescription, tt.insertGroupsLen)\n\t\t\tfor i, id := range idxToId[tt.existingSubGroupLen:] {\n\t\t\t\tidDescriptions[i] = models.GroupIDDescription{GroupID: id}\n\t\t\t}\n\n\t\t\t// add\n\t\t\tif err := qb.AddSubGroups(ctx, group.ID, idDescriptions, &tt.insertLoc); err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.AddSubGroups() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// validate the new order\n\t\t\tgd, err := qb.GetSubGroupDescriptions(ctx, group.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.GetSubGroupDescriptions() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tnewIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })\n\t\t\tnewIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })\n\n\t\t\tassert.ElementsMatch(t, tt.expectedIdxs, newIdxs)\n\t\t})\n\t}\n}\n\nfunc TestGroupRemoveSubGroups(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tsubGroupLen int\n\t\tremoveIdxs  []int\n\t\t// order of elements, using original indexes\n\t\texpectedIdxs []int\n\t}{\n\t\t{\n\t\t\t\"remove last\",\n\t\t\t4,\n\t\t\t[]int{3},\n\t\t\t[]int{0, 1, 2},\n\t\t},\n\t\t{\n\t\t\t\"remove first\",\n\t\t\t4,\n\t\t\t[]int{0},\n\t\t\t[]int{1, 2, 3},\n\t\t},\n\t\t{\n\t\t\t\"remove middle\",\n\t\t\t4,\n\t\t\t[]int{2},\n\t\t\t[]int{0, 1, 3},\n\t\t},\n\t\t{\n\t\t\t\"remove multiple\",\n\t\t\t4,\n\t\t\t[]int{1, 3},\n\t\t\t[]int{0, 2},\n\t\t},\n\t\t{\n\t\t\t\"remove all\",\n\t\t\t4,\n\t\t\t[]int{0, 1, 2, 3},\n\t\t\t[]int{},\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\t// create the group\n\t\t\tgroup := models.Group{\n\t\t\t\tName: \"TestGroupReorderSubGroups\",\n\t\t\t}\n\n\t\t\tif err := qb.Create(ctx, &group); err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.Create() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// and sub-groups\n\t\t\tidxToId := make([]int, tt.subGroupLen)\n\n\t\t\tfor i := 0; i < tt.subGroupLen; i++ {\n\t\t\t\tsubGroup := models.Group{\n\t\t\t\t\tName: fmt.Sprintf(\"Existing SubGroup %d\", i),\n\t\t\t\t\tContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{\n\t\t\t\t\t\t{GroupID: group.ID},\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t\t\tif err := qb.Create(ctx, &subGroup); err != nil {\n\t\t\t\t\tt.Errorf(\"GroupStore.Create() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tidxToId[i] = subGroup.ID\n\t\t\t}\n\n\t\t\tidsToRemove := indexesToIDs(idxToId, tt.removeIdxs)\n\t\t\tif err := qb.RemoveSubGroups(ctx, group.ID, idsToRemove); err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.RemoveSubGroups() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// validate the new order\n\t\t\tgd, err := qb.GetSubGroupDescriptions(ctx, group.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.GetSubGroupDescriptions() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tnewIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })\n\t\t\tnewIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })\n\n\t\t\tassert.ElementsMatch(t, tt.expectedIdxs, newIdxs)\n\t\t})\n\t}\n}\n\nfunc TestGroupFindSubGroupIDs(t *testing.T) {\n\ttests := []struct {\n\t\tname               string\n\t\tcontainingGroupIdx int\n\t\tsubIdxs            []int\n\t\texpectedIdxs       []int\n\t}{\n\t\t{\n\t\t\t\"overlap\",\n\t\t\tgroupIdxWithGrandChild,\n\t\t\t[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},\n\t\t\t[]int{groupIdxWithParentAndChild},\n\t\t},\n\t\t{\n\t\t\t\"non-overlap\",\n\t\t\tgroupIdxWithGrandChild,\n\t\t\t[]int{groupIdxWithGrandParent},\n\t\t\t[]int{},\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\tgroupIdxWithScene,\n\t\t\t[]int{groupIdxWithDupName},\n\t\t\t[]int{},\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\t[]int{invalidID},\n\t\t\t[]int{},\n\t\t},\n\t}\n\n\tqb := db.Group\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tsubIDs := indexesToIDs(groupIDs, tt.subIdxs)\n\n\t\t\tid := indexToID(groupIDs, tt.containingGroupIdx)\n\n\t\t\tfound, err := qb.FindSubGroupIDs(ctx, id, subIDs)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"GroupStore.FindSubGroupIDs() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// get ids of groups\n\t\t\tfoundIdxs := sliceutil.Map(found, func(id int) int { return slices.Index(groupIDs, id) })\n\n\t\t\tassert.ElementsMatch(t, tt.expectedIdxs, foundIdxs)\n\t\t})\n\t}\n}\n\nfunc TestGroupQueryCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      *models.GroupFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue:    []any{getGroupStringValue(groupIdxWithChild, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{groupIdxWithChild},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGroupStringValue(groupIdxWithChild, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\t\tValue:    []any{getGroupStringValue(groupIdxWithChild, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{groupIdxWithChild},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue:    []any{getGroupStringValue(groupIdxWithChild, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{groupIdxWithChild},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGroupStringValue(groupIdxWithChild, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\t\tValue:    []any{getGroupStringValue(groupIdxWithChild, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{groupIdxWithChild},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"regex\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*11_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{groupIdxWithChildWithScene},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid regex\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGroupStringValue(groupIdxWithChildWithScene, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*11_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{groupIdxWithChildWithScene},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid not matches regex\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGroupStringValue(groupIdxWithGrandParent, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"not existing\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{groupIdxWithGrandParent},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGroupStringValue(groupIdxWithGrandParent, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{groupIdxWithGrandParent},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"between\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{groupIdxWithTag},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not between\",\n\t\t\t&models.GroupFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getGroupStringValue(groupIdxWithTag, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{groupIdxWithTag},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgroups, _, err := db.Group.Query(ctx, tt.filter, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"GroupStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := groupsToIDs(groups)\n\t\t\tinclude := indexesToIDs(groupIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(groupIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TODO Update\n// TODO Destroy - ensure image is destroyed\n// TODO Find\n// TODO Count\n// TODO All\n// TODO Query\n"
  },
  {
    "path": "pkg/sqlite/history.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"time\"\n)\n\ntype viewDateManager struct {\n\ttableMgr *viewHistoryTable\n}\n\nfunc (qb *viewDateManager) GetViewDates(ctx context.Context, id int) ([]time.Time, error) {\n\treturn qb.tableMgr.getDates(ctx, id)\n}\n\nfunc (qb *viewDateManager) GetManyViewDates(ctx context.Context, ids []int) ([][]time.Time, error) {\n\treturn qb.tableMgr.getManyDates(ctx, ids)\n}\n\nfunc (qb *viewDateManager) CountViews(ctx context.Context, id int) (int, error) {\n\treturn qb.tableMgr.getCount(ctx, id)\n}\n\nfunc (qb *viewDateManager) GetManyViewCount(ctx context.Context, ids []int) ([]int, error) {\n\treturn qb.tableMgr.getManyCount(ctx, ids)\n}\n\nfunc (qb *viewDateManager) CountAllViews(ctx context.Context) (int, error) {\n\treturn qb.tableMgr.getAllCount(ctx)\n}\n\nfunc (qb *viewDateManager) CountUniqueViews(ctx context.Context) (int, error) {\n\treturn qb.tableMgr.getUniqueCount(ctx)\n}\n\nfunc (qb *viewDateManager) LastView(ctx context.Context, id int) (*time.Time, error) {\n\treturn qb.tableMgr.getLastDate(ctx, id)\n}\n\nfunc (qb *viewDateManager) GetManyLastViewed(ctx context.Context, ids []int) ([]*time.Time, error) {\n\treturn qb.tableMgr.getManyLastDate(ctx, ids)\n\n}\n\nfunc (qb *viewDateManager) AddViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\treturn qb.tableMgr.addDates(ctx, id, dates)\n}\n\nfunc (qb *viewDateManager) DeleteViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\treturn qb.tableMgr.deleteDates(ctx, id, dates)\n}\n\nfunc (qb *viewDateManager) DeleteAllViews(ctx context.Context, id int) (int, error) {\n\treturn qb.tableMgr.deleteAllDates(ctx, id)\n}\n\ntype oDateManager struct {\n\ttableMgr *viewHistoryTable\n}\n\nfunc (qb *oDateManager) GetODates(ctx context.Context, id int) ([]time.Time, error) {\n\treturn qb.tableMgr.getDates(ctx, id)\n}\n\nfunc (qb *oDateManager) GetManyODates(ctx context.Context, ids []int) ([][]time.Time, error) {\n\treturn qb.tableMgr.getManyDates(ctx, ids)\n}\n\nfunc (qb *oDateManager) GetOCount(ctx context.Context, id int) (int, error) {\n\treturn qb.tableMgr.getCount(ctx, id)\n}\n\nfunc (qb *oDateManager) GetManyOCount(ctx context.Context, ids []int) ([]int, error) {\n\treturn qb.tableMgr.getManyCount(ctx, ids)\n}\n\nfunc (qb *oDateManager) GetAllOCount(ctx context.Context) (int, error) {\n\treturn qb.tableMgr.getAllCount(ctx)\n}\n\nfunc (qb *oDateManager) GetUniqueOCount(ctx context.Context) (int, error) {\n\treturn qb.tableMgr.getUniqueCount(ctx)\n}\n\nfunc (qb *oDateManager) AddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\treturn qb.tableMgr.addDates(ctx, id, dates)\n}\n\nfunc (qb *oDateManager) DeleteO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\treturn qb.tableMgr.deleteDates(ctx, id, dates)\n}\n\nfunc (qb *oDateManager) ResetO(ctx context.Context, id int) (int, error) {\n\treturn qb.tableMgr.deleteAllDates(ctx, id)\n}\n"
  },
  {
    "path": "pkg/sqlite/image.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"slices\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n)\n\nconst imageTable = \"images\"\n\nconst (\n\timageIDColumn         = \"image_id\"\n\tperformersImagesTable = \"performers_images\"\n\timagesTagsTable       = \"images_tags\"\n\timagesFilesTable      = \"images_files\"\n\timagesURLsTable       = \"image_urls\"\n\timageURLColumn        = \"url\"\n)\n\ntype imageRow struct {\n\tID    int         `db:\"id\" goqu:\"skipinsert\"`\n\tTitle zero.String `db:\"title\"`\n\tCode  zero.String `db:\"code\"`\n\t// expressed as 1-100\n\tRating        null.Int    `db:\"rating\"`\n\tDate          NullDate    `db:\"date\"`\n\tDatePrecision null.Int    `db:\"date_precision\"`\n\tDetails       zero.String `db:\"details\"`\n\tPhotographer  zero.String `db:\"photographer\"`\n\tOrganized     bool        `db:\"organized\"`\n\tOCounter      int         `db:\"o_counter\"`\n\tStudioID      null.Int    `db:\"studio_id,omitempty\"`\n\tCreatedAt     Timestamp   `db:\"created_at\"`\n\tUpdatedAt     Timestamp   `db:\"updated_at\"`\n}\n\nfunc (r *imageRow) fromImage(i models.Image) {\n\tr.ID = i.ID\n\tr.Title = zero.StringFrom(i.Title)\n\tr.Code = zero.StringFrom(i.Code)\n\tr.Rating = intFromPtr(i.Rating)\n\tr.Date = NullDateFromDatePtr(i.Date)\n\tr.DatePrecision = datePrecisionFromDatePtr(i.Date)\n\tr.Details = zero.StringFrom(i.Details)\n\tr.Photographer = zero.StringFrom(i.Photographer)\n\tr.Organized = i.Organized\n\tr.OCounter = i.OCounter\n\tr.StudioID = intFromPtr(i.StudioID)\n\tr.CreatedAt = Timestamp{Timestamp: i.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: i.UpdatedAt}\n}\n\ntype imageQueryRow struct {\n\timageRow\n\tPrimaryFileID         null.Int    `db:\"primary_file_id\"`\n\tPrimaryFileFolderPath zero.String `db:\"primary_file_folder_path\"`\n\tPrimaryFileBasename   zero.String `db:\"primary_file_basename\"`\n\tPrimaryFileChecksum   zero.String `db:\"primary_file_checksum\"`\n}\n\nfunc (r *imageQueryRow) resolve() *models.Image {\n\tret := &models.Image{\n\t\tID:           r.ID,\n\t\tTitle:        r.Title.String,\n\t\tCode:         r.Code.String,\n\t\tRating:       nullIntPtr(r.Rating),\n\t\tDate:         r.Date.DatePtr(r.DatePrecision),\n\t\tDetails:      r.Details.String,\n\t\tPhotographer: r.Photographer.String,\n\t\tOrganized:    r.Organized,\n\t\tOCounter:     r.OCounter,\n\t\tStudioID:     nullIntPtr(r.StudioID),\n\n\t\tPrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID),\n\t\tChecksum:      r.PrimaryFileChecksum.String,\n\n\t\tCreatedAt: r.CreatedAt.Timestamp,\n\t\tUpdatedAt: r.UpdatedAt.Timestamp,\n\t}\n\n\tif r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid {\n\t\tret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String)\n\t}\n\n\treturn ret\n}\n\ntype imageRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *imageRowRecord) fromPartial(i models.ImagePartial) {\n\tr.setNullString(\"title\", i.Title)\n\tr.setNullString(\"code\", i.Code)\n\tr.setNullInt(\"rating\", i.Rating)\n\tr.setNullDate(\"date\", \"date_precision\", i.Date)\n\tr.setNullString(\"details\", i.Details)\n\tr.setNullString(\"photographer\", i.Photographer)\n\tr.setBool(\"organized\", i.Organized)\n\tr.setInt(\"o_counter\", i.OCounter)\n\tr.setNullInt(\"studio_id\", i.StudioID)\n\tr.setTimestamp(\"created_at\", i.CreatedAt)\n\tr.setTimestamp(\"updated_at\", i.UpdatedAt)\n}\n\ntype imageRepositoryType struct {\n\trepository\n\tperformers joinRepository\n\tgalleries  joinRepository\n\ttags       joinRepository\n\tfiles      filesRepository\n}\n\nfunc (r *imageRepositoryType) addImagesFilesTable(f *filterBuilder) {\n\tf.addLeftJoin(imagesFilesTable, \"\", \"images_files.image_id = images.id\")\n}\n\nfunc (r *imageRepositoryType) addFilesTable(f *filterBuilder) {\n\tr.addImagesFilesTable(f)\n\tf.addLeftJoin(fileTable, \"\", \"images_files.file_id = files.id\")\n}\n\nfunc (r *imageRepositoryType) addFoldersTable(f *filterBuilder) {\n\tr.addFilesTable(f)\n\tf.addLeftJoin(folderTable, \"\", \"files.parent_folder_id = folders.id\")\n}\n\nfunc (r *imageRepositoryType) addImageFilesTable(f *filterBuilder) {\n\tr.addImagesFilesTable(f)\n\tf.addLeftJoin(imageFileTable, \"\", \"image_files.file_id = images_files.file_id\")\n}\n\nvar (\n\timageRepository = imageRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: imageTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\n\t\tperformers: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: performersImagesTable,\n\t\t\t\tidColumn:  imageIDColumn,\n\t\t\t},\n\t\t\tfkColumn: performerIDColumn,\n\t\t},\n\n\t\tgalleries: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: galleriesImagesTable,\n\t\t\t\tidColumn:  imageIDColumn,\n\t\t\t},\n\t\t\tfkColumn: galleryIDColumn,\n\t\t},\n\n\t\tfiles: filesRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: imagesFilesTable,\n\t\t\t\tidColumn:  imageIDColumn,\n\t\t\t},\n\t\t},\n\n\t\ttags: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: imagesTagsTable,\n\t\t\t\tidColumn:  imageIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     tagIDColumn,\n\t\t\tforeignTable: tagTable,\n\t\t\torderBy:      tagTableSortSQL,\n\t\t},\n\t}\n)\n\ntype ImageStore struct {\n\tcustomFieldsStore\n\n\ttableMgr *table\n\toCounterManager\n\n\trepo *storeRepository\n}\n\nfunc NewImageStore(r *storeRepository) *ImageStore {\n\treturn &ImageStore{\n\t\tcustomFieldsStore: customFieldsStore{\n\t\t\ttable: imagesCustomFieldsTable,\n\t\t\tfk:    imagesCustomFieldsTable.Col(imageIDColumn),\n\t\t},\n\t\ttableMgr:        imageTableMgr,\n\t\toCounterManager: oCounterManager{imageTableMgr},\n\t\trepo:            r,\n\t}\n}\n\nfunc (qb *ImageStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *ImageStore) selectDataset() *goqu.SelectDataset {\n\ttable := qb.table()\n\tfiles := fileTableMgr.table\n\tfolders := folderTableMgr.table\n\tchecksum := fingerprintTableMgr.table\n\n\treturn dialect.From(table).LeftJoin(\n\t\timagesFilesJoinTable,\n\t\tgoqu.On(\n\t\t\timagesFilesJoinTable.Col(imageIDColumn).Eq(table.Col(idColumn)),\n\t\t\timagesFilesJoinTable.Col(\"primary\").Eq(1),\n\t\t),\n\t).LeftJoin(\n\t\tfiles,\n\t\tgoqu.On(files.Col(idColumn).Eq(imagesFilesJoinTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\tfolders,\n\t\tgoqu.On(folders.Col(idColumn).Eq(files.Col(\"parent_folder_id\"))),\n\t).LeftJoin(\n\t\tchecksum,\n\t\tgoqu.On(\n\t\t\tchecksum.Col(fileIDColumn).Eq(imagesFilesJoinTable.Col(fileIDColumn)),\n\t\t\tchecksum.Col(\"type\").Eq(models.FingerprintTypeMD5),\n\t\t),\n\t).Select(\n\t\tqb.table().All(),\n\t\timagesFilesJoinTable.Col(fileIDColumn).As(\"primary_file_id\"),\n\t\tfolders.Col(\"path\").As(\"primary_file_folder_path\"),\n\t\tfiles.Col(\"basename\").As(\"primary_file_basename\"),\n\t\tchecksum.Col(\"fingerprint\").As(\"primary_file_checksum\"),\n\t)\n}\n\nfunc (qb *ImageStore) Create(ctx context.Context, newObject *models.CreateImageInput) error {\n\tvar r imageRow\n\tr.fromImage(*newObject.Image)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(newObject.FileIDs) > 0 {\n\t\tconst firstPrimary = true\n\t\tif err := imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.URLs.Loaded() {\n\t\tconst startPos = 0\n\t\tif err := imagesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.PerformerIDs.Loaded() {\n\t\tif err := imagesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif newObject.TagIDs.Loaded() {\n\t\tif err := imagesTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.GalleryIDs.Loaded() {\n\t\tif err := imageGalleriesTableMgr.insertJoins(ctx, id, newObject.GalleryIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, id, models.CustomFieldsInput{\n\t\tFull: newObject.CustomFields,\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject.Image = *updated\n\n\treturn nil\n}\n\nfunc (qb *ImageStore) UpdatePartial(ctx context.Context, id int, partial models.ImagePartial) (*models.Image, error) {\n\tr := imageRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(partial)\n\n\tif len(r.Record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.GalleryIDs != nil {\n\t\tif err := imageGalleriesTableMgr.modifyJoins(ctx, id, partial.GalleryIDs.IDs, partial.GalleryIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.URLs != nil {\n\t\tif err := imagesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.PerformerIDs != nil {\n\t\tif err := imagesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.TagIDs != nil {\n\t\tif err := imagesTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.PrimaryFileID != nil {\n\t\tif err := imagesFilesTableMgr.setPrimary(ctx, id, *partial.PrimaryFileID); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn qb.find(ctx, id)\n}\n\nfunc (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) error {\n\tvar r imageRow\n\tr.fromImage(*updatedObject)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\tif updatedObject.URLs.Loaded() {\n\t\tif err := imagesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.PerformerIDs.Loaded() {\n\t\tif err := imagesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.TagIDs.Loaded() {\n\t\tif err := imagesTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.GalleryIDs.Loaded() {\n\t\tif err := imageGalleriesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.GalleryIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.Files.Loaded() {\n\t\tfileIDs := make([]models.FileID, len(updatedObject.Files.List()))\n\t\tfor i, f := range updatedObject.Files.List() {\n\t\t\tfileIDs[i] = f.Base().ID\n\t\t}\n\n\t\tif err := imagesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (qb *ImageStore) Destroy(ctx context.Context, id int) error {\n\treturn qb.tableMgr.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *ImageStore) Find(ctx context.Context, id int) (*models.Image, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *ImageStore) FindMany(ctx context.Context, ids []int) ([]*models.Image, error) {\n\timages := make([]*models.Image, len(ids))\n\n\tif err := batchExec(ids, defaultBatchSize, func(batch []int) error {\n\t\tq := qb.selectDataset().Prepared(true).Where(qb.table().Col(idColumn).In(batch))\n\t\tunsorted, err := qb.getMany(ctx, q)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range unsorted {\n\t\t\ti := slices.Index(ids, s.ID)\n\t\t\timages[i] = s\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor i := range images {\n\t\tif images[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"image with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn images, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *ImageStore) find(ctx context.Context, id int) (*models.Image, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Image, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().Prepared(true).Where(\n\t\ttable.Col(idColumn).Eq(\n\t\t\tsq,\n\t\t),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *ImageStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Image, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Image, error) {\n\tconst single = false\n\tvar ret []*models.Image\n\tvar lastID int\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f imageQueryRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ti := f.resolve()\n\n\t\tif i.ID == lastID {\n\t\t\treturn fmt.Errorf(\"internal error: multiple rows returned for single image id %d\", i.ID)\n\t\t}\n\t\tlastID = i.ID\n\n\t\tret = append(ret, i)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\n// Returns the custom cover for the gallery, if one has been set.\nfunc (qb *ImageStore) CoverByGalleryID(ctx context.Context, galleryID int) (*models.Image, error) {\n\ttable := qb.table()\n\n\tsq := dialect.From(table).\n\t\tInnerJoin(\n\t\t\tgalleriesImagesJoinTable,\n\t\t\tgoqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))),\n\t\t).\n\t\tSelect(table.Col(idColumn)).\n\t\tWhere(goqu.And(\n\t\t\tgalleriesImagesJoinTable.Col(\"gallery_id\").Eq(galleryID),\n\t\t\tgalleriesImagesJoinTable.Col(\"cover\").Eq(true),\n\t\t))\n\n\tq := qb.selectDataset().Prepared(true).Where(\n\t\ttable.Col(idColumn).Eq(\n\t\t\tsq,\n\t\t),\n\t)\n\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting cover for gallery %d: %w\", galleryID, err)\n\t}\n\n\tswitch {\n\tcase len(ret) > 1:\n\t\treturn nil, fmt.Errorf(\"internal error: multiple covers returned for gallery %d\", galleryID)\n\tcase len(ret) == 1:\n\t\treturn ret[0], nil\n\tdefault:\n\t\treturn nil, nil\n\t}\n}\n\nfunc (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, error) {\n\tfileIDs, err := imageRepository.files.get(ctx, id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// use fileStore to load files\n\tfiles, err := qb.repo.File.Find(ctx, fileIDs...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([]models.File, len(files))\n\tcopy(ret, files)\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) {\n\tconst primaryOnly = false\n\treturn imageRepository.files.getMany(ctx, ids, primaryOnly)\n}\n\nfunc (qb *ImageStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error) {\n\ttable := qb.table()\n\n\tsq := dialect.From(table).\n\t\tInnerJoin(\n\t\t\timagesFilesJoinTable,\n\t\t\tgoqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))),\n\t\t).\n\t\tSelect(table.Col(idColumn)).Where(imagesFilesJoinTable.Col(fileIDColumn).Eq(fileID))\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting image by file id %d: %w\", fileID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) {\n\tjoinTable := imagesFilesJoinTable\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(joinTable).Where(joinTable.Col(fileIDColumn).Eq(fileID))\n\treturn count(ctx, q)\n}\n\nfunc (qb *ImageStore) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Image, error) {\n\ttable := qb.table()\n\tfingerprintTable := fingerprintTableMgr.table\n\n\tvar ex []exp.Expression\n\n\tfor _, v := range fp {\n\t\tex = append(ex, goqu.And(\n\t\t\tfingerprintTable.Col(\"type\").Eq(v.Type),\n\t\t\tfingerprintTable.Col(\"fingerprint\").Eq(v.Fingerprint),\n\t\t))\n\t}\n\n\tsq := dialect.From(table).\n\t\tInnerJoin(\n\t\t\timagesFilesJoinTable,\n\t\t\tgoqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))),\n\t\t).\n\t\tInnerJoin(\n\t\t\tfingerprintTable,\n\t\t\tgoqu.On(fingerprintTable.Col(fileIDColumn).Eq(imagesFilesJoinTable.Col(fileIDColumn))),\n\t\t).\n\t\tSelect(table.Col(idColumn)).Where(goqu.Or(ex...))\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting image by fingerprints: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) FindByChecksum(ctx context.Context, checksum string) ([]*models.Image, error) {\n\treturn qb.FindByFingerprints(ctx, []models.Fingerprint{\n\t\t{\n\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\tFingerprint: checksum,\n\t\t},\n\t})\n}\n\nvar defaultGalleryOrder = []exp.OrderedExpression{\n\tgoqu.L(\"COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI\").Asc(),\n\tgoqu.L(\"COALESCE(images.title, images.id) COLLATE NATURAL_CI\").Asc(),\n}\n\nfunc (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) {\n\ttable := qb.table()\n\n\tsq := dialect.From(table).\n\t\tInnerJoin(\n\t\t\tgalleriesImagesJoinTable,\n\t\t\tgoqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))),\n\t\t).\n\t\tSelect(table.Col(idColumn)).Where(\n\t\tgalleriesImagesJoinTable.Col(\"gallery_id\").Eq(galleryID),\n\t)\n\n\tq := qb.selectDataset().Prepared(true).Where(\n\t\ttable.Col(idColumn).Eq(\n\t\t\tsq,\n\t\t),\n\t).Order(defaultGalleryOrder...)\n\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting images for gallery %d: %w\", galleryID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().\n\t\tInnerJoin(\n\t\t\tgalleriesImagesJoinTable,\n\t\t\tgoqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))),\n\t\t).\n\t\tWhere(galleriesImagesJoinTable.Col(galleryIDColumn).Eq(galleryID)).\n\t\tPrepared(true).\n\t\tOrder(defaultGalleryOrder...).\n\t\tLimit(1).Offset(index)\n\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting images for gallery %d: %w\", galleryID, err)\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, error) {\n\tjoinTable := goqu.T(galleriesImagesTable)\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(joinTable).Where(joinTable.Col(\"gallery_id\").Eq(galleryID))\n\treturn count(ctx, q)\n}\n\nfunc (qb *ImageStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) {\n\ttable := qb.table()\n\tjoinTable := performersImagesJoinTable\n\tq := dialect.Select(goqu.COALESCE(goqu.SUM(\"o_counter\"), 0)).From(table).InnerJoin(joinTable, goqu.On(table.Col(idColumn).Eq(joinTable.Col(imageIDColumn)))).Where(joinTable.Col(performerIDColumn).Eq(performerID))\n\n\tvar ret int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) {\n\ttable := qb.table()\n\tq := dialect.Select(goqu.COALESCE(goqu.SUM(\"o_counter\"), 0)).From(table).Where(\n\t\ttable.Col(studioIDColumn).Eq(studioID),\n\t)\n\n\tvar ret int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) OCount(ctx context.Context) (int, error) {\n\ttable := qb.table()\n\n\tq := dialect.Select(goqu.COALESCE(goqu.SUM(\"o_counter\"), 0)).From(table)\n\tvar ret int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Image, error) {\n\ttable := qb.table()\n\tfileTable := goqu.T(fileTable)\n\n\tsq := dialect.From(table).\n\t\tInnerJoin(\n\t\t\timagesFilesJoinTable,\n\t\t\tgoqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))),\n\t\t).\n\t\tInnerJoin(\n\t\t\tfileTable,\n\t\t\tgoqu.On(imagesFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))),\n\t\t).\n\t\tSelect(table.Col(idColumn)).Where(\n\t\tfileTable.Col(\"parent_folder_id\").Eq(folderID),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting image by folder: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) {\n\ttable := qb.table()\n\tfileTable := goqu.T(fileTable)\n\n\tsq := dialect.From(table).\n\t\tInnerJoin(\n\t\t\timagesFilesJoinTable,\n\t\t\tgoqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))),\n\t\t).\n\t\tInnerJoin(\n\t\t\tfileTable,\n\t\t\tgoqu.On(imagesFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))),\n\t\t).\n\t\tSelect(table.Col(idColumn)).Where(\n\t\tfileTable.Col(\"zip_file_id\").Eq(zipFileID),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting image by zip file: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) Count(ctx context.Context) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(qb.table())\n\treturn count(ctx, q)\n}\n\nfunc (qb *ImageStore) Size(ctx context.Context) (float64, error) {\n\ttable := qb.table()\n\tfileTable := fileTableMgr.table\n\tq := dialect.Select(\n\t\tgoqu.COALESCE(goqu.SUM(fileTableMgr.table.Col(\"size\")), 0),\n\t).From(table).InnerJoin(\n\t\timagesFilesJoinTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(imagesFilesJoinTable.Col(imageIDColumn))),\n\t).InnerJoin(\n\t\tfileTable,\n\t\tgoqu.On(imagesFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))),\n\t)\n\tvar ret float64\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) All(ctx context.Context) ([]*models.Image, error) {\n\treturn qb.getMany(ctx, qb.selectDataset())\n}\n\nfunc (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {\n\tif imageFilter == nil {\n\t\timageFilter = &models.ImageFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := imageRepository.newQuery()\n\tdistinctIDs(&query, imageTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    imagesFilesTable,\n\t\t\t\tonClause: \"images_files.image_id = images.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    fileTable,\n\t\t\t\tonClause: \"images_files.file_id = files.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    folderTable,\n\t\t\t\tonClause: \"files.parent_folder_id = folders.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    fingerprintTable,\n\t\t\t\tonClause: \"files_fingerprints.file_id = images_files.file_id\",\n\t\t\t},\n\t\t)\n\n\t\tfilepathColumn := \"folders.path || '\" + string(filepath.Separator) + \"' || files.basename\"\n\t\tsearchColumns := []string{\"images.title\", \"images.details\", filepathColumn, \"files_fingerprints.fingerprint\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &imageFilterHandler{\n\t\timageFilter: imageFilter,\n\t})\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.setImageSortAndPagination(&query, findFilter); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &query, nil\n}\n\nfunc (qb *ImageStore) Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) {\n\tquery, err := qb.makeQuery(ctx, options.ImageFilter, options.FindFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult, err := qb.queryGroupedFields(ctx, options, *query)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error querying aggregate fields: %w\", err)\n\t}\n\n\tidsResult, err := query.findIDs(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding IDs: %w\", err)\n\t}\n\n\tresult.IDs = idsResult\n\treturn result, nil\n}\n\nfunc (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.ImageQueryOptions, query queryBuilder) (*models.ImageQueryResult, error) {\n\tif !options.Count && !options.Megapixels && !options.TotalSize {\n\t\t// nothing to do - return empty result\n\t\treturn models.NewImageQueryResult(qb), nil\n\t}\n\n\taggregateQuery := imageRepository.newQuery()\n\n\tif options.Count {\n\t\taggregateQuery.addColumn(\"COUNT(DISTINCT temp.id) as total\")\n\t}\n\n\tif options.Megapixels {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    imagesFilesTable,\n\t\t\t\tonClause: \"images_files.image_id = images.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    imageFileTable,\n\t\t\t\tonClause: \"images_files.file_id = image_files.file_id\",\n\t\t\t},\n\t\t)\n\t\tquery.addColumn(\"COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels\")\n\t\taggregateQuery.addColumn(\"COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels\")\n\t}\n\n\tif options.TotalSize {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    imagesFilesTable,\n\t\t\t\tonClause: \"images_files.image_id = images.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    fileTable,\n\t\t\t\tonClause: \"images_files.file_id = files.id\",\n\t\t\t},\n\t\t)\n\t\tquery.addColumn(\"COALESCE(files.size, 0) as size\")\n\t\taggregateQuery.addColumn(\"SUM(temp.size) as size\")\n\t}\n\n\tconst includeSortPagination = false\n\taggregateQuery.from = fmt.Sprintf(\"(%s) as temp\", query.toSQL(includeSortPagination))\n\n\tout := struct {\n\t\tTotal      int\n\t\tMegapixels null.Float\n\t\tSize       null.Float\n\t}{}\n\tif err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := models.NewImageQueryResult(qb)\n\tret.Count = out.Total\n\tret.Megapixels = out.Megapixels.Float64\n\tret.TotalSize = out.Size.Float64\n\treturn ret, nil\n}\n\nfunc (qb *ImageStore) QueryCount(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) {\n\tquery, err := qb.makeQuery(ctx, imageFilter, findFilter)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn query.executeCount(ctx)\n}\n\nvar imageSortOptions = sortOptions{\n\t\"created_at\",\n\t\"date\",\n\t\"file_count\",\n\t\"file_mod_time\",\n\t\"filesize\",\n\t\"id\",\n\t\"o_counter\",\n\t\"path\",\n\t\"performer_count\",\n\t\"random\",\n\t\"rating\",\n\t\"resolution\",\n\t\"tag_count\",\n\t\"title\",\n\t\"updated_at\",\n}\n\nfunc (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *models.FindFilterType) error {\n\tsortClause := \"\"\n\n\tif findFilter != nil && findFilter.Sort != nil && *findFilter.Sort != \"\" {\n\t\tsort := findFilter.GetSort(\"title\")\n\t\tdirection := findFilter.GetDirection()\n\n\t\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\t\tif err := imageSortOptions.validateSort(sort); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// translate sort field\n\t\tif sort == \"file_mod_time\" {\n\t\t\tsort = \"mod_time\"\n\t\t}\n\n\t\taddFilesJoin := func() {\n\t\t\tq.addJoins(\n\t\t\t\tjoin{\n\t\t\t\t\tsort:     true,\n\t\t\t\t\ttable:    imagesFilesTable,\n\t\t\t\t\tonClause: \"images_files.image_id = images.id\",\n\t\t\t\t},\n\t\t\t\tjoin{\n\t\t\t\t\tsort:     true,\n\t\t\t\t\ttable:    fileTable,\n\t\t\t\t\tonClause: \"images_files.file_id = files.id\",\n\t\t\t\t},\n\t\t\t)\n\t\t}\n\n\t\taddFolderJoin := func() {\n\t\t\tq.addJoins(join{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    folderTable,\n\t\t\t\tonClause: \"files.parent_folder_id = folders.id\",\n\t\t\t})\n\t\t}\n\n\t\tswitch sort {\n\t\tcase \"path\":\n\t\t\taddFilesJoin()\n\t\t\taddFolderJoin()\n\t\t\tsortClause = \" ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI \" + direction\n\t\tcase \"file_count\":\n\t\t\tsortClause = getCountSort(imageTable, imagesFilesTable, imageIDColumn, direction)\n\t\tcase \"tag_count\":\n\t\t\tsortClause = getCountSort(imageTable, imagesTagsTable, imageIDColumn, direction)\n\t\tcase \"performer_count\":\n\t\t\tsortClause = getCountSort(imageTable, performersImagesTable, imageIDColumn, direction)\n\t\tcase \"mod_time\", \"filesize\":\n\t\t\taddFilesJoin()\n\t\t\tsortClause = getSort(sort, direction, \"files\")\n\t\tcase \"resolution\":\n\t\t\taddFilesJoin()\n\t\t\tq.addJoins(join{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    imageFileTable,\n\t\t\t\tonClause: \"images_files.file_id = image_files.file_id\",\n\t\t\t})\n\t\t\tsortClause = \" ORDER BY MIN(image_files.width, image_files.height) \" + direction\n\t\tcase \"title\":\n\t\t\taddFilesJoin()\n\t\t\taddFolderJoin()\n\t\t\tsortClause = \" ORDER BY COALESCE(images.title, files.basename) COLLATE NATURAL_CI \" + direction + \", folders.path COLLATE NATURAL_CI \" + direction\n\t\tdefault:\n\t\t\tsortClause = getSort(sort, direction, \"images\")\n\t\t}\n\n\t\t// Whatever the sorting, always use title/id as a final sort\n\t\tsortClause += \", COALESCE(images.title, images.id) COLLATE NATURAL_CI ASC\"\n\t}\n\n\tq.sortAndPagination = sortClause + getPagination(findFilter)\n\n\treturn nil\n}\n\nfunc (qb *ImageStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error {\n\tconst firstPrimary = false\n\treturn imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID})\n}\n\n// RemoveFileID removes the file ID from the image.\n// If the file ID is the primary file, then the next file in the list is set as the primary file.\nfunc (qb *ImageStore) RemoveFileID(ctx context.Context, id int, fileID models.FileID) error {\n\tfileIDs, err := imagesFilesTableMgr.get(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting file IDs for image %d: %w\", id, err)\n\t}\n\n\tfileIDs = sliceutil.Filter(fileIDs, func(f models.FileID) bool {\n\t\treturn f != fileID\n\t})\n\n\treturn imagesFilesTableMgr.replaceJoins(ctx, id, fileIDs)\n}\n\nfunc (qb *ImageStore) GetGalleryIDs(ctx context.Context, imageID int) ([]int, error) {\n\treturn imageRepository.galleries.getIDs(ctx, imageID)\n}\n\n// func (qb *imageQueryBuilder) UpdateGalleries(ctx context.Context, imageID int, galleryIDs []int) error {\n// \t// Delete the existing joins and then create new ones\n// \treturn qb.galleriesRepository().replace(ctx, imageID, galleryIDs)\n// }\n\nfunc (qb *ImageStore) GetPerformerIDs(ctx context.Context, imageID int) ([]int, error) {\n\treturn imageRepository.performers.getIDs(ctx, imageID)\n}\n\nfunc (qb *ImageStore) UpdatePerformers(ctx context.Context, imageID int, performerIDs []int) error {\n\t// Delete the existing joins and then create new ones\n\treturn imageRepository.performers.replace(ctx, imageID, performerIDs)\n}\n\nfunc (qb *ImageStore) GetTagIDs(ctx context.Context, imageID int) ([]int, error) {\n\treturn imageRepository.tags.getIDs(ctx, imageID)\n}\n\nfunc (qb *ImageStore) UpdateTags(ctx context.Context, imageID int, tagIDs []int) error {\n\t// Delete the existing joins and then create new ones\n\treturn imageRepository.tags.replace(ctx, imageID, tagIDs)\n}\n\nfunc (qb *ImageStore) GetURLs(ctx context.Context, imageID int) ([]string, error) {\n\treturn imagesURLsTableMgr.get(ctx, imageID)\n}\n"
  },
  {
    "path": "pkg/sqlite/image_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype imageFilterHandler struct {\n\timageFilter *models.ImageFilterType\n}\n\nfunc (qb *imageFilterHandler) validate() error {\n\timageFilter := qb.imageFilter\n\tif imageFilter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(imageFilter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif subFilter := imageFilter.SubFilter(); subFilter != nil {\n\t\tsqb := &imageFilterHandler{imageFilter: subFilter}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *imageFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\timageFilter := qb.imageFilter\n\tif imageFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := imageFilter.SubFilter()\n\tif sf != nil {\n\t\tsub := &imageFilterHandler{sf}\n\t\thandleSubFilter(ctx, sub, f, imageFilter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *imageFilterHandler) criterionHandler() criterionHandler {\n\timageFilter := qb.imageFilter\n\treturn compoundHandler{\n\t\tintCriterionHandler(imageFilter.ID, \"images.id\", nil),\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif imageFilter.Checksum != nil {\n\t\t\t\timageRepository.addImagesFilesTable(f)\n\t\t\t\tf.addInnerJoin(fingerprintTable, \"fingerprints_md5\", \"images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'\")\n\t\t\t}\n\n\t\t\tstringCriterionHandler(imageFilter.Checksum, \"fingerprints_md5.fingerprint\")(ctx, f)\n\t\t}),\n\n\t\t&phashDistanceCriterionHandler{\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\timageRepository.addImagesFilesTable(f)\n\t\t\t\tf.addLeftJoin(fingerprintTable, \"fingerprints_phash\", \"images_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'\")\n\t\t\t},\n\t\t\tcriterion: imageFilter.PhashDistance,\n\t\t},\n\n\t\tstringCriterionHandler(imageFilter.Title, \"images.title\"),\n\t\tstringCriterionHandler(imageFilter.Code, \"images.code\"),\n\t\tstringCriterionHandler(imageFilter.Details, \"images.details\"),\n\t\tstringCriterionHandler(imageFilter.Photographer, \"images.photographer\"),\n\n\t\tpathCriterionHandler(imageFilter.Path, \"folders.path\", \"files.basename\", imageRepository.addFoldersTable),\n\t\tqb.fileCountCriterionHandler(imageFilter.FileCount),\n\t\tintCriterionHandler(imageFilter.Rating100, \"images.rating\", nil),\n\t\tintCriterionHandler(imageFilter.OCounter, \"images.o_counter\", nil),\n\t\tboolCriterionHandler(imageFilter.Organized, \"images.organized\", nil),\n\t\t&dateCriterionHandler{imageFilter.Date, \"images.date\", nil},\n\t\tqb.urlsCriterionHandler(imageFilter.URL),\n\n\t\tresolutionCriterionHandler(imageFilter.Resolution, \"image_files.height\", \"image_files.width\", imageRepository.addImageFilesTable),\n\t\torientationCriterionHandler(imageFilter.Orientation, \"image_files.height\", \"image_files.width\", imageRepository.addImageFilesTable),\n\t\tqb.missingCriterionHandler(imageFilter.IsMissing),\n\n\t\tqb.tagsCriterionHandler(imageFilter.Tags),\n\t\tqb.tagCountCriterionHandler(imageFilter.TagCount),\n\t\tqb.galleriesCriterionHandler(imageFilter.Galleries),\n\t\tqb.performersCriterionHandler(imageFilter.Performers),\n\t\tqb.performerCountCriterionHandler(imageFilter.PerformerCount),\n\t\tstudioCriterionHandler(imageTable, imageFilter.Studios),\n\t\tqb.performerTagsCriterionHandler(imageFilter.PerformerTags),\n\t\tqb.performerFavoriteCriterionHandler(imageFilter.PerformerFavorite),\n\t\tqb.performerAgeCriterionHandler(imageFilter.PerformerAge),\n\t\t&timestampCriterionHandler{imageFilter.CreatedAt, \"images.created_at\", nil},\n\t\t&timestampCriterionHandler{imageFilter.UpdatedAt, \"images.updated_at\", nil},\n\n\t\t&customFieldsFilterHandler{\n\t\t\ttable: imagesCustomFieldsTable.GetTable(),\n\t\t\tfkCol: imageIDColumn,\n\t\t\tc:     imageFilter.CustomFields,\n\t\t\tidCol: \"images.id\",\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"galleries_images.gallery_id\",\n\t\t\trelatedRepo:    galleryRepository.repository,\n\t\t\trelatedHandler: &galleryFilterHandler{imageFilter.GalleriesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\timageRepository.galleries.innerJoin(f, \"\", \"images.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"performers_join.performer_id\",\n\t\t\trelatedRepo:    performerRepository.repository,\n\t\t\trelatedHandler: &performerFilterHandler{imageFilter.PerformersFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\timageRepository.performers.innerJoin(f, \"performers_join\", \"images.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"images.studio_id\",\n\t\t\trelatedRepo:    studioRepository.repository,\n\t\t\trelatedHandler: &studioFilterHandler{imageFilter.StudiosFilter},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"image_tag.tag_id\",\n\t\t\trelatedRepo:    tagRepository.repository,\n\t\t\trelatedHandler: &tagFilterHandler{imageFilter.TagsFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\timageRepository.tags.innerJoin(f, \"image_tag\", \"images.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol: \"files.id\",\n\t\t\trelatedRepo:  fileRepository.repository,\n\t\t\trelatedHandler: &fileFilterHandler{\n\t\t\t\tfileFilter: imageFilter.FilesFilter,\n\t\t\t\tisRelated:  true,\n\t\t\t},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\timageRepository.addFilesTable(f)\n\t\t\t\timageRepository.addFoldersTable(f)\n\t\t\t},\n\t\t\t// don't use a subquery; join directly\n\t\t\tdirectJoin: true,\n\t\t},\n\t}\n}\n\nfunc (qb *imageFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: imageTable,\n\t\tjoinTable:    imagesFilesTable,\n\t\tprimaryFK:    imageIDColumn,\n\t}\n\n\treturn h.handler(fileCount)\n}\n\nfunc (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif isMissing != nil && *isMissing != \"\" {\n\t\t\tswitch *isMissing {\n\t\t\tcase \"url\":\n\t\t\t\timagesURLsTableMgr.join(f, \"\", \"images.id\")\n\t\t\t\tf.addWhere(\"image_urls.url IS NULL\")\n\t\t\tcase \"studio\":\n\t\t\t\tf.addWhere(\"images.studio_id IS NULL\")\n\t\t\tcase \"performers\":\n\t\t\t\timageRepository.performers.join(f, \"performers_join\", \"images.id\")\n\t\t\t\tf.addWhere(\"performers_join.image_id IS NULL\")\n\t\t\tcase \"galleries\":\n\t\t\t\timageRepository.galleries.join(f, \"galleries_join\", \"images.id\")\n\t\t\t\tf.addWhere(\"galleries_join.image_id IS NULL\")\n\t\t\tcase \"tags\":\n\t\t\t\timageRepository.tags.join(f, \"tags_join\", \"images.id\")\n\t\t\t\tf.addWhere(\"tags_join.image_id IS NULL\")\n\t\t\tdefault:\n\t\t\t\tif err := validateIsMissing(*isMissing, []string{\n\t\t\t\t\t\"title\", \"details\", \"photographer\", \"date\", \"code\", \"rating\",\n\t\t\t\t}); err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWhere(\"(images.\" + *isMissing + \" IS NULL OR TRIM(images.\" + *isMissing + \") = '')\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: imageTable,\n\t\tprimaryFK:    imageIDColumn,\n\t\tjoinTable:    imagesURLsTable,\n\t\tstringColumn: imageURLColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\timagesURLsTableMgr.join(f, \"\", \"images.id\")\n\t\t},\n\t}\n\n\treturn h.handler(url)\n}\n\nfunc (qb *imageFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {\n\treturn multiCriterionHandlerBuilder{\n\t\tprimaryTable: imageTable,\n\t\tforeignTable: foreignTable,\n\t\tjoinTable:    joinTable,\n\t\tprimaryFK:    imageIDColumn,\n\t\tforeignFK:    foreignFK,\n\t\taddJoinsFunc: addJoinsFunc,\n\t}\n}\n\nfunc (qb *imageFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\th := joinedHierarchicalMultiCriterionHandlerBuilder{\n\t\tprimaryTable: imageTable,\n\t\tforeignTable: tagTable,\n\t\tforeignFK:    \"tag_id\",\n\n\t\trelationsTable: \"tags_relations\",\n\t\tjoinAs:         \"image_tag\",\n\t\tjoinTable:      imagesTagsTable,\n\t\tprimaryFK:      imageIDColumn,\n\t}\n\n\treturn h.handler(tags)\n}\n\nfunc (qb *imageFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: imageTable,\n\t\tjoinTable:    imagesTagsTable,\n\t\tprimaryFK:    imageIDColumn,\n\t}\n\n\treturn h.handler(tagCount)\n}\n\nfunc (qb *imageFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc {\n\taddJoinsFunc := func(f *filterBuilder) {\n\t\tif galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll {\n\t\t\tf.addInnerJoin(galleriesImagesTable, \"\", \"galleries_images.image_id = images.id\")\n\t\t\tf.addInnerJoin(galleryTable, \"\", \"galleries_images.gallery_id = galleries.id\")\n\t\t}\n\t}\n\th := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc)\n\n\treturn h.handler(galleries)\n}\n\nfunc (qb *imageFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {\n\th := joinedMultiCriterionHandlerBuilder{\n\t\tprimaryTable: imageTable,\n\t\tjoinTable:    performersImagesTable,\n\t\tjoinAs:       \"performers_join\",\n\t\tprimaryFK:    imageIDColumn,\n\t\tforeignFK:    performerIDColumn,\n\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\timageRepository.performers.join(f, \"performers_join\", \"images.id\")\n\t\t},\n\t}\n\n\treturn h.handler(performers)\n}\n\nfunc (qb *imageFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: imageTable,\n\t\tjoinTable:    performersImagesTable,\n\t\tprimaryFK:    imageIDColumn,\n\t}\n\n\treturn h.handler(performerCount)\n}\n\nfunc (qb *imageFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performerfavorite != nil {\n\t\t\tf.addLeftJoin(\"performers_images\", \"\", \"images.id = performers_images.image_id\")\n\n\t\t\tif *performerfavorite {\n\t\t\t\t// contains at least one favorite\n\t\t\t\tf.addLeftJoin(\"performers\", \"\", \"performers.id = performers_images.performer_id\")\n\t\t\t\tf.addWhere(\"performers.favorite = 1\")\n\t\t\t} else {\n\t\t\t\t// contains zero favorites\n\t\t\t\tf.addLeftJoin(`(SELECT performers_images.image_id as id FROM performers_images\nJOIN performers ON performers.id = performers_images.performer_id\nGROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, \"nofaves\", \"images.id = nofaves.id\")\n\t\t\t\tf.addWhere(\"performers_images.image_id IS NULL OR nofaves.id IS NOT NULL\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *imageFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performerAge != nil {\n\t\t\tf.addInnerJoin(\"performers_images\", \"\", \"images.id = performers_images.image_id\")\n\t\t\tf.addInnerJoin(\"performers\", \"\", \"performers_images.performer_id = performers.id\")\n\n\t\t\tf.addWhere(\"images.date != '' AND performers.birthdate != ''\")\n\t\t\tf.addWhere(\"images.date IS NOT NULL AND performers.birthdate IS NOT NULL\")\n\n\t\t\tageCalc := \"cast(strftime('%Y.%m%d', images.date) - strftime('%Y.%m%d', performers.birthdate) as int)\"\n\t\t\twhereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2)\n\t\t\tf.addWhere(whereClause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *imageFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler {\n\treturn &joinedPerformerTagsHandler{\n\t\tcriterion:      tags,\n\t\tprimaryTable:   imageTable,\n\t\tjoinTable:      performersImagesTable,\n\t\tjoinPrimaryKey: imageIDColumn,\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/image_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc loadImageRelationships(ctx context.Context, expected models.Image, actual *models.Image) error {\n\tif expected.URLs.Loaded() {\n\t\tif err := actual.LoadURLs(ctx, db.Image); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.GalleryIDs.Loaded() {\n\t\tif err := actual.LoadGalleryIDs(ctx, db.Image); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.TagIDs.Loaded() {\n\t\tif err := actual.LoadTagIDs(ctx, db.Image); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.PerformerIDs.Loaded() {\n\t\tif err := actual.LoadPerformerIDs(ctx, db.Image); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.Files.Loaded() {\n\t\tif err := actual.LoadFiles(ctx, db.Image); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// clear Path, Checksum, PrimaryFileID\n\tif expected.Path == \"\" {\n\t\tactual.Path = \"\"\n\t}\n\tif expected.Checksum == \"\" {\n\t\tactual.Checksum = \"\"\n\t}\n\tif expected.PrimaryFileID == nil {\n\t\tactual.PrimaryFileID = nil\n\t}\n\n\treturn nil\n}\n\nfunc Test_imageQueryBuilder_Create(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"code\"\n\t\trating       = 60\n\t\tdetails      = \"details\"\n\t\tphotographer = \"photographer\"\n\t\tocounter     = 5\n\t\turl          = \"url\"\n\t\tdate, _      = models.ParseDate(\"2003-02-01\")\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\n\t\timageFile = makeFileWithID(fileIdxStartImageFiles)\n\t)\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.CreateImageInput\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.CreateImageInput{\n\t\t\t\tImage: &models.Image{\n\t\t\t\t\tTitle:        title,\n\t\t\t\t\tCode:         code,\n\t\t\t\t\tRating:       &rating,\n\t\t\t\t\tDate:         &date,\n\t\t\t\t\tDetails:      details,\n\t\t\t\t\tPhotographer: photographer,\n\t\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\t\tOrganized:    true,\n\t\t\t\t\tOCounter:     ocounter,\n\t\t\t\t\tStudioID:     &studioIDs[studioIdxWithImage],\n\t\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}),\n\t\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}),\n\t\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}),\n\t\t\t\t},\n\t\t\t\tCustomFields: testCustomFields,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with file\",\n\t\t\tmodels.CreateImageInput{\n\t\t\t\tImage: &models.Image{\n\t\t\t\t\tTitle:        title,\n\t\t\t\t\tCode:         code,\n\t\t\t\t\tRating:       &rating,\n\t\t\t\t\tDate:         &date,\n\t\t\t\t\tDetails:      details,\n\t\t\t\t\tPhotographer: photographer,\n\t\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\t\tOrganized:    true,\n\t\t\t\t\tOCounter:     ocounter,\n\t\t\t\t\tStudioID:     &studioIDs[studioIdxWithImage],\n\t\t\t\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t\t\t\timageFile.(*models.ImageFile),\n\t\t\t\t\t}),\n\t\t\t\t\tPrimaryFileID: &imageFile.Base().ID,\n\t\t\t\t\tPath:          imageFile.Base().Path,\n\t\t\t\t\tCreatedAt:     createdAt,\n\t\t\t\t\tUpdatedAt:     updatedAt,\n\t\t\t\t\tGalleryIDs:    models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}),\n\t\t\t\t\tTagIDs:        models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}),\n\t\t\t\t\tPerformerIDs:  models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}),\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid studio id\",\n\t\t\tmodels.CreateImageInput{\n\t\t\t\tImage: &models.Image{\n\t\t\t\t\tStudioID: &invalidID,\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid gallery id\",\n\t\t\tmodels.CreateImageInput{\n\t\t\t\tImage: &models.Image{\n\t\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.CreateImageInput{\n\t\t\t\tImage: &models.Image{\n\t\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid performer id\",\n\t\t\tmodels.CreateImageInput{\n\t\t\t\tImage: &models.Image{\n\t\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tvar fileIDs []models.FileID\n\t\t\tif tt.newObject.Files.Loaded() {\n\t\t\t\tfor _, f := range tt.newObject.Files.List() {\n\t\t\t\t\tfileIDs = append(fileIDs, f.Base().ID)\n\t\t\t\t}\n\t\t\t}\n\t\t\ts := *tt.newObject.Image\n\t\t\tif err := qb.Create(ctx, &models.CreateImageInput{\n\t\t\t\tImage:   &s,\n\t\t\t\tFileIDs: fileIDs,\n\t\t\t}); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(s.ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(s.ID)\n\n\t\t\tcopy := *tt.newObject.Image\n\t\t\tcopy.ID = s.ID\n\n\t\t\t// load relationships\n\t\t\tif err := loadImageRelationships(ctx, copy, &s); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, s)\n\n\t\t\t// ensure can find the image\n\t\t\tfound, err := qb.Find(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadImageRelationships(ctx, copy, found); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *found)\n\t\t})\n\t}\n}\n\nfunc clearImageFileIDs(image *models.Image) {\n\tif image.Files.Loaded() {\n\t\tfor _, f := range image.Files.List() {\n\t\t\tf.Base().ID = 0\n\t\t}\n\t}\n}\n\nfunc makeImageFileWithID(i int) *models.ImageFile {\n\tret := makeImageFile(i)\n\tret.ID = imageFileIDs[i]\n\treturn ret\n}\n\nfunc Test_imageQueryBuilder_Update(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"code\"\n\t\trating       = 60\n\t\turl          = \"url\"\n\t\tdetails      = \"details\"\n\t\tphotographer = \"photographer\"\n\t\tdate, _      = models.ParseDate(\"2003-02-01\")\n\t\tocounter     = 5\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject *models.Image\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\t&models.Image{\n\t\t\t\tID:           imageIDs[imageIdxWithGallery],\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tRating:       &rating,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tDetails:      details,\n\t\t\t\tPhotographer: photographer,\n\t\t\t\tOrganized:    true,\n\t\t\t\tOCounter:     ocounter,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithImage],\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear nullables\",\n\t\t\t&models.Image{\n\t\t\t\tID:           imageIDs[imageIdxWithGallery],\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tOrganized:    true,\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear gallery ids\",\n\t\t\t&models.Image{\n\t\t\t\tID:           imageIDs[imageIdxWithGallery],\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tOrganized:    true,\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear tag ids\",\n\t\t\t&models.Image{\n\t\t\t\tID:           imageIDs[imageIdxWithTag],\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tOrganized:    true,\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear performer ids\",\n\t\t\t&models.Image{\n\t\t\t\tID:           imageIDs[imageIdxWithPerformer],\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tOrganized:    true,\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid studio id\",\n\t\t\t&models.Image{\n\t\t\t\tID:        imageIDs[imageIdxWithGallery],\n\t\t\t\tOrganized: true,\n\t\t\t\tStudioID:  &invalidID,\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid gallery id\",\n\t\t\t&models.Image{\n\t\t\t\tID:         imageIDs[imageIdxWithGallery],\n\t\t\t\tOrganized:  true,\n\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\tCreatedAt:  createdAt,\n\t\t\t\tUpdatedAt:  updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\t&models.Image{\n\t\t\t\tID:        imageIDs[imageIdxWithGallery],\n\t\t\t\tOrganized: true,\n\t\t\t\tTagIDs:    models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid performer id\",\n\t\t\t&models.Image{\n\t\t\t\tID:           imageIDs[imageIdxWithGallery],\n\t\t\t\tOrganized:    true,\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Image\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tcopy := *tt.updatedObject\n\n\t\t\tif err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.updatedObject.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadImageRelationships(ctx, copy, s); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *s)\n\t\t})\n\t}\n}\n\nfunc clearImagePartial() models.ImagePartial {\n\t// leave mandatory fields\n\treturn models.ImagePartial{\n\t\tTitle:        models.OptionalString{Set: true, Null: true},\n\t\tCode:         models.OptionalString{Set: true, Null: true},\n\t\tDetails:      models.OptionalString{Set: true, Null: true},\n\t\tPhotographer: models.OptionalString{Set: true, Null: true},\n\t\tRating:       models.OptionalInt{Set: true, Null: true},\n\t\tURLs:         &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},\n\t\tDate:         models.OptionalDate{Set: true, Null: true},\n\t\tStudioID:     models.OptionalInt{Set: true, Null: true},\n\t\tGalleryIDs:   &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t\tTagIDs:       &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t\tPerformerIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t}\n}\n\nfunc Test_imageQueryBuilder_UpdatePartial(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"code\"\n\t\tdetails      = \"details\"\n\t\tphotographer = \"photographer\"\n\t\trating       = 60\n\t\turl          = \"url\"\n\t\tdate, _      = models.ParseDate(\"2003-02-01\")\n\t\tocounter     = 5\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.ImagePartial\n\t\twant    models.Image\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\timageIDs[imageIdx1WithGallery],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tTitle:        models.NewOptionalString(title),\n\t\t\t\tCode:         models.NewOptionalString(code),\n\t\t\t\tDetails:      models.NewOptionalString(details),\n\t\t\t\tPhotographer: models.NewOptionalString(photographer),\n\t\t\t\tRating:       models.NewOptionalInt(rating),\n\t\t\t\tURLs: &models.UpdateStrings{\n\t\t\t\t\tValues: []string{url},\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tDate:      models.NewOptionalDate(date),\n\t\t\t\tOrganized: models.NewOptionalBool(true),\n\t\t\t\tOCounter:  models.NewOptionalInt(ocounter),\n\t\t\t\tStudioID:  models.NewOptionalInt(studioIDs[studioIdxWithImage]),\n\t\t\t\tCreatedAt: models.NewOptionalTime(createdAt),\n\t\t\t\tUpdatedAt: models.NewOptionalTime(updatedAt),\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdxWithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithImage], tagIDs[tagIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tID:           imageIDs[imageIdx1WithGallery],\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tDetails:      details,\n\t\t\t\tPhotographer: photographer,\n\t\t\t\tRating:       &rating,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tOrganized:    true,\n\t\t\t\tOCounter:     ocounter,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithImage],\n\t\t\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t\t\tmakeImageFile(imageIdx1WithGallery),\n\t\t\t\t}),\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear all\",\n\t\t\timageIDs[imageIdx1WithGallery],\n\t\t\tclearImagePartial(),\n\t\t\tmodels.Image{\n\t\t\t\tID:       imageIDs[imageIdx1WithGallery],\n\t\t\t\tOCounter: getOCounter(imageIdx1WithGallery),\n\t\t\t\tFiles: models.NewRelatedFiles([]models.File{\n\t\t\t\t\tmakeImageFile(imageIdx1WithGallery),\n\t\t\t\t}),\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid id\",\n\t\t\tinvalidID,\n\t\t\tmodels.ImagePartial{},\n\t\t\tmodels.Image{},\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Image\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadImageRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tclearImageFileIDs(got)\n\n\t\t\tassert.Equal(tt.want, *got)\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadImageRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tclearImageFileIDs(s)\n\t\t\tassert.Equal(tt.want, *s)\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_UpdatePartialRelationships(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.ImagePartial\n\t\twant    models.Image\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"add galleries\",\n\t\t\timageIDs[imageIdxWithGallery],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdx1WithImage], galleryIDs[galleryIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, imageGalleries[imageIdxWithGallery]),\n\t\t\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t\t\tgalleryIDs[galleryIdx1WithPerformer],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add tags\",\n\t\t\timageIDs[imageIdxWithTwoTags],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tTagIDs: models.NewRelatedIDs(append(\n\t\t\t\t\t[]int{\n\t\t\t\t\t\ttagIDs[tagIdx1WithGallery],\n\t\t\t\t\t\ttagIDs[tagIdx1WithDupName],\n\t\t\t\t\t},\n\t\t\t\t\tindexesToIDs(tagIDs, imageTags[imageIdxWithTwoTags])...,\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add performers\",\n\t\t\timageIDs[imageIdxWithTwoPerformers],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, imagePerformers[imageIdxWithTwoPerformers]),\n\t\t\t\t\tperformerIDs[performerIdx1WithDupName],\n\t\t\t\t\tperformerIDs[performerIdx1WithGallery],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate galleries\",\n\t\t\timageIDs[imageIdxWithGallery],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdxWithImage], galleryIDs[galleryIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, imageGalleries[imageIdxWithGallery]),\n\t\t\t\t\tgalleryIDs[galleryIdx1WithPerformer],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate tags\",\n\t\t\timageIDs[imageIdxWithTwoTags],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithImage], tagIDs[tagIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tTagIDs: models.NewRelatedIDs(append(\n\t\t\t\t\t[]int{tagIDs[tagIdx1WithGallery]},\n\t\t\t\t\tindexesToIDs(tagIDs, imageTags[imageIdxWithTwoTags])...,\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate performers\",\n\t\t\timageIDs[imageIdxWithTwoPerformers],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, imagePerformers[imageIdxWithTwoPerformers]),\n\t\t\t\t\tperformerIDs[performerIdx1WithGallery],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add invalid galleries\",\n\t\t\timageIDs[imageIdxWithGallery],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"add invalid tags\",\n\t\t\timageIDs[imageIdxWithTwoTags],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"add invalid performers\",\n\t\t\timageIDs[imageIdxWithTwoPerformers],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"remove galleries\",\n\t\t\timageIDs[imageIdxWithGallery],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdxWithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove tags\",\n\t\t\timageIDs[imageIdxWithTwoTags],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx2WithImage]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove performers\",\n\t\t\timageIDs[imageIdxWithTwoPerformers],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx2WithImage]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated galleries\",\n\t\t\timageIDs[imageIdxWithGallery],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdx1WithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated tags\",\n\t\t\timageIDs[imageIdxWithTwoTags],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tTagIDs: models.NewRelatedIDs(indexesToIDs(tagIDs, imageTags[imageIdxWithTwoTags])),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated performers\",\n\t\t\timageIDs[imageIdxWithTwoPerformers],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Image{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(indexesToIDs(performerIDs, imagePerformers[imageIdxWithTwoPerformers])),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tqb := db.Image\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadImageRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := loadImageRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// only compare fields that were in the partial\n\t\t\tif tt.partial.PerformerIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.PerformerIDs.List(), got.PerformerIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.PerformerIDs.List(), s.PerformerIDs.List())\n\t\t\t}\n\t\t\tif tt.partial.TagIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.TagIDs.List(), got.TagIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.TagIDs.List(), s.TagIDs.List())\n\t\t\t}\n\t\t\tif tt.partial.GalleryIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.GalleryIDs.List(), got.GalleryIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.GalleryIDs.List(), s.GalleryIDs.List())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_ImageStore_UpdatePartialCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tid       int\n\t\tpartial  models.ImagePartial\n\t\texpected map[string]interface{} // nil to use the partial\n\t}{\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\timageIDs[imageIdx1WithGallery],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\timageIDs[imageIdx1WithGallery],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"partial custom fields\",\n\t\t\timageIDs[imageIdxWithStudio],\n\t\t\tmodels.ImagePartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\t\t\"new_field\": \"new\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"int\":       int64(2),\n\t\t\t\t\"real\":      1.2,\n\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\"new_field\": \"new\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Image\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t_, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ImageStore.UpdatePartial() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure custom fields are correct\n\t\t\tcf, err := qb.GetCustomFields(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ImageStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.expected == nil {\n\t\t\t\tassert.Equal(tt.partial.CustomFields.Full, cf)\n\t\t\t} else {\n\t\t\t\tassert.Equal(tt.expected, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_IncrementOCounter(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"increment\",\n\t\t\timageIDs[1],\n\t\t\t2,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\t0,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.IncrementOCounter(ctx, tt.id)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.IncrementOCounter() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.IncrementOCounter() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_DecrementOCounter(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"decrement\",\n\t\t\timageIDs[2],\n\t\t\t1,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"zero\",\n\t\t\timageIDs[0],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\t0,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.DecrementOCounter(ctx, tt.id)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.DecrementOCounter() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.DecrementOCounter() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_ResetOCounter(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"decrement\",\n\t\t\timageIDs[2],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"zero\",\n\t\t\timageIDs[0],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\t0,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.ResetOCounter(ctx, tt.id)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.ResetOCounter() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.ResetOCounter() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_Destroy(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\timageIDs[imageIdxWithGallery],\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tif err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.Destroy() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\t// ensure cannot be found\n\t\t\ti, err := qb.Find(ctx, tt.id)\n\n\t\t\tassert.Nil(err)\n\t\t\tassert.Nil(i)\n\t\t})\n\t}\n}\n\nfunc makeImageWithID(index int) *models.Image {\n\tconst fromDB = true\n\tret := makeImage(index)\n\tret.ID = imageIDs[index]\n\n\tret.Files = models.NewRelatedFiles([]models.File{makeImageFile(index)})\n\n\treturn ret\n}\n\nfunc Test_imageQueryBuilder_Find(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    *models.Image\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\timageIDs[imageIdxWithGallery],\n\t\t\tmakeImageWithID(imageIdxWithGallery),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\timageIDs[imageIdxWithTwoPerformers],\n\t\t\tmakeImageWithID(imageIdxWithTwoPerformers),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\timageIDs[imageIdxWithTwoTags],\n\t\t\tmakeImageWithID(imageIdxWithTwoTags),\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.Find(ctx, tt.id)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.Find() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif got != nil {\n\t\t\t\t// load relationships\n\t\t\t\tif err := loadImageRelationships(ctx, *tt.want, got); err != nil {\n\t\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tclearImageFileIDs(got)\n\t\t\t}\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc postFindImages(ctx context.Context, want []*models.Image, got []*models.Image) error {\n\tfor i, s := range got {\n\t\t// load relationships\n\t\tif i < len(want) {\n\t\t\tif err := loadImageRelationships(ctx, *want[i], s); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tclearImageFileIDs(s)\n\t}\n\n\treturn nil\n}\n\nfunc Test_imageQueryBuilder_FindMany(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tids     []int\n\t\twant    []*models.Image\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid with relationships\",\n\t\t\t[]int{imageIDs[imageIdxWithGallery], imageIDs[imageIdxWithTwoPerformers], imageIDs[imageIdxWithTwoTags]},\n\t\t\t[]*models.Image{\n\t\t\t\tmakeImageWithID(imageIdxWithGallery),\n\t\t\t\tmakeImageWithID(imageIdxWithTwoPerformers),\n\t\t\t\tmakeImageWithID(imageIdxWithTwoTags),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t[]int{imageIDs[imageIdxWithGallery], imageIDs[imageIdxWithTwoPerformers], invalidID},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.FindMany(ctx, tt.ids)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.FindMany() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindImages(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.FindMany() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_FindByChecksum(t *testing.T) {\n\tgetChecksum := func(index int) string {\n\t\treturn getImageStringValue(index, checksumField)\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tchecksum string\n\t\twant     []*models.Image\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgetChecksum(imageIdxWithGallery),\n\t\t\t[]*models.Image{makeImageWithID(imageIdxWithGallery)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid checksum\",\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\tgetChecksum(imageIdxWithTwoPerformers),\n\t\t\t[]*models.Image{makeImageWithID(imageIdxWithTwoPerformers)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\tgetChecksum(imageIdxWithTwoTags),\n\t\t\t[]*models.Image{makeImageWithID(imageIdxWithTwoTags)},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByChecksum(ctx, tt.checksum)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.FindByChecksum() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindImages(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_FindByFingerprints(t *testing.T) {\n\tgetChecksum := func(index int) string {\n\t\treturn getImageStringValue(index, checksumField)\n\t}\n\n\ttests := []struct {\n\t\tname         string\n\t\tfingerprints []models.Fingerprint\n\t\twant         []*models.Image\n\t\twantErr      bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\t[]models.Fingerprint{\n\t\t\t\t{\n\t\t\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\t\t\tFingerprint: getChecksum(imageIdxWithGallery),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]*models.Image{makeImageWithID(imageIdxWithGallery)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t[]models.Fingerprint{\n\t\t\t\t{\n\t\t\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\t\t\tFingerprint: \"invalid checksum\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\t[]models.Fingerprint{\n\t\t\t\t{\n\t\t\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\t\t\tFingerprint: getChecksum(imageIdxWithTwoPerformers),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]*models.Image{makeImageWithID(imageIdxWithTwoPerformers)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\t[]models.Fingerprint{\n\t\t\t\t{\n\t\t\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\t\t\tFingerprint: getChecksum(imageIdxWithTwoTags),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]*models.Image{makeImageWithID(imageIdxWithTwoTags)},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByFingerprints(ctx, tt.fingerprints)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.FindByChecksum() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindImages(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_FindByGalleryID(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tgalleryID int\n\t\twant      []*models.Image\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgalleryIDs[galleryIdxWithTwoImages],\n\t\t\t[]*models.Image{makeImageWithID(imageIdx1WithGallery), makeImageWithID(imageIdx2WithGallery)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\tgalleryIDs[galleryIdx1WithPerformer],\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByGalleryID(ctx, tt.galleryID)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.FindByGalleryID() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindImages(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadImageRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc Test_imageQueryBuilder_CountByGalleryID(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tgalleryID int\n\t\twant      int\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgalleryIDs[galleryIdxWithTwoImages],\n\t\t\t2,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\tgalleryIDs[galleryIdx1WithPerformer],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.CountByGalleryID(ctx, tt.galleryID)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.CountByGalleryID() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"imageQueryBuilder.CountByGalleryID() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc imagesToIDs(i []*models.Image) []int {\n\tvar ret []int\n\tfor _, ii := range i {\n\t\tret = append(ret, ii.ID)\n\t}\n\n\treturn ret\n}\n\nfunc Test_imageStore_FindByFileID(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tfileID  models.FileID\n\t\tinclude []int\n\t\texclude []int\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\timageFileIDs[imageIdxWithGallery],\n\t\t\t[]int{imageIdxWithGallery},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidFileID,\n\t\t\tnil,\n\t\t\t[]int{imageIdxWithGallery},\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByFileID(ctx, tt.fileID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ImageStore.FindByFileID() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor _, f := range got {\n\t\t\t\tclearImageFileIDs(f)\n\t\t\t}\n\n\t\t\tids := imagesToIDs(got)\n\t\t\tinclude := indexesToIDs(imageIDs, tt.include)\n\t\t\texclude := indexesToIDs(imageIDs, tt.exclude)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_imageStore_FindByFolderID(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tfolderID models.FolderID\n\t\tinclude  []int\n\t\texclude  []int\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tfolderIDs[folderIdxWithImageFiles],\n\t\t\t[]int{imageIdxWithGallery},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidFolderID,\n\t\t\tnil,\n\t\t\t[]int{imageIdxWithGallery},\n\t\t},\n\t\t{\n\t\t\t\"parent folder\",\n\t\t\tfolderIDs[folderIdxForObjectFiles],\n\t\t\tnil,\n\t\t\t[]int{imageIdxWithGallery},\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByFolderID(ctx, tt.folderID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ImageStore.FindByFolderID() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor _, f := range got {\n\t\t\t\tclearImageFileIDs(f)\n\t\t\t}\n\n\t\t\tids := imagesToIDs(got)\n\t\t\tinclude := indexesToIDs(imageIDs, tt.include)\n\t\t\texclude := indexesToIDs(imageIDs, tt.exclude)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_imageStore_FindByZipFileID(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tzipFileID models.FileID\n\t\tinclude   []int\n\t\texclude   []int\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tfileIDs[fileIdxZip],\n\t\t\t[]int{imageIdxInZip},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidFileID,\n\t\t\tnil,\n\t\t\t[]int{imageIdxInZip},\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByZipFileID(ctx, tt.zipFileID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ImageStore.FindByZipFileID() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor _, f := range got {\n\t\t\t\tclearImageFileIDs(f)\n\t\t\t}\n\n\t\t\tids := imagesToIDs(got)\n\t\t\tinclude := indexesToIDs(imageIDs, tt.include)\n\t\t\texclude := indexesToIDs(imageIDs, tt.exclude)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestImageQueryQ(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tconst imageIdx = 2\n\n\t\tq := getImageStringValue(imageIdx, titleField)\n\n\t\tsqb := db.Image\n\n\t\timageQueryQ(ctx, t, sqb, q, imageIdx)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryQ_Details(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tconst imageIdx = 3\n\n\t\tq := getImageStringValue(imageIdx, detailsField)\n\n\t\tsqb := db.Image\n\n\t\timageQueryQ(ctx, t, sqb, q, imageIdx)\n\n\t\treturn nil\n\t})\n}\n\nfunc queryImagesWithCount(ctx context.Context, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) {\n\tresult, err := sqb.Query(ctx, models.ImageQueryOptions{\n\t\tQueryOptions: models.QueryOptions{\n\t\t\tFindFilter: findFilter,\n\t\t\tCount:      true,\n\t\t},\n\t\tImageFilter: imageFilter,\n\t})\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\timages, err := result.Resolve(ctx)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn images, result.Count, nil\n}\n\nfunc imageQueryQ(ctx context.Context, t *testing.T, sqb models.ImageReader, q string, expectedImageIdx int) {\n\tfilter := models.FindFilterType{\n\t\tQ: &q,\n\t}\n\timages := queryImages(ctx, t, sqb, nil, &filter)\n\n\tassert.Len(t, images, 1)\n\timage := images[0]\n\tassert.Equal(t, imageIDs[expectedImageIdx], image.ID)\n\n\tcount, err := sqb.QueryCount(ctx, nil, &filter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t}\n\tassert.Equal(t, len(images), count)\n\n\t// no Q should return all results\n\tfilter.Q = nil\n\timages = queryImages(ctx, t, sqb, nil, &filter)\n\n\tassert.Len(t, images, totalImages)\n}\n\nfunc verifyImageQuery(t *testing.T, filter models.ImageFilterType, verifyFn func(ctx context.Context, s *models.Image)) {\n\tt.Helper()\n\twithTxn(func(ctx context.Context) error {\n\t\tt.Helper()\n\t\tsqb := db.Image\n\n\t\timages := queryImages(ctx, t, sqb, &filter, nil)\n\n\t\t// assume it should find at least one\n\t\tassert.Greater(t, len(images), 0)\n\n\t\tfor _, image := range images {\n\t\t\tverifyFn(ctx, image)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryURL(t *testing.T) {\n\tconst imageIdx = 1\n\timageURL := getImageStringValue(imageIdx, urlField)\n\turlCriterion := models.StringCriterionInput{\n\t\tValue:    imageURL,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\tfilter := models.ImageFilterType{\n\t\tURL: &urlCriterion,\n\t}\n\n\tverifyFn := func(ctx context.Context, o *models.Image) {\n\t\tt.Helper()\n\n\t\tif err := o.LoadURLs(ctx, db.Image); err != nil {\n\t\t\tt.Errorf(\"Error loading scene URLs: %v\", err)\n\t\t}\n\n\t\turls := o.URLs.List()\n\t\tvar url string\n\t\tif len(urls) > 0 {\n\t\t\turl = urls[0]\n\t\t}\n\n\t\tverifyString(t, url, urlCriterion)\n\t}\n\n\tverifyImageQuery(t, filter, verifyFn)\n\turlCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyImageQuery(t, filter, verifyFn)\n\turlCriterion.Modifier = models.CriterionModifierMatchesRegex\n\turlCriterion.Value = \"image_.*1_URL\"\n\tverifyImageQuery(t, filter, verifyFn)\n\turlCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyImageQuery(t, filter, verifyFn)\n\turlCriterion.Modifier = models.CriterionModifierIsNull\n\turlCriterion.Value = \"\"\n\tverifyImageQuery(t, filter, verifyFn)\n\turlCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyImageQuery(t, filter, verifyFn)\n}\n\nfunc TestImageQueryPath(t *testing.T) {\n\tconst imageIdx = 1\n\timagePath := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx))\n\n\tpathCriterion := models.StringCriterionInput{\n\t\tValue:    imagePath,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyImagePath(t, pathCriterion, 1)\n\n\tpathCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyImagePath(t, pathCriterion, totalImages-1)\n\n\tpathCriterion.Modifier = models.CriterionModifierMatchesRegex\n\tpathCriterion.Value = \"image_.*01_Path\"\n\tverifyImagePath(t, pathCriterion, 1) // TODO - 2 if zip path is included\n\n\tpathCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyImagePath(t, pathCriterion, totalImages-1) // TODO - -2 if zip path is included\n}\n\nfunc verifyImagePath(t *testing.T, pathCriterion models.StringCriterionInput, expected int) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\timageFilter := models.ImageFilterType{\n\t\t\tPath: &pathCriterion,\n\t\t}\n\n\t\timages := queryImages(ctx, t, sqb, &imageFilter, nil)\n\n\t\tassert.Equal(t, expected, len(images), \"number of returned images\")\n\n\t\tfor _, image := range images {\n\t\t\tverifyString(t, image.Path, pathCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryPathOr(t *testing.T) {\n\tconst image1Idx = 1\n\tconst image2Idx = 2\n\n\timage1Path := getFilePath(folderIdxWithImageFiles, getImageBasename(image1Idx))\n\timage2Path := getFilePath(folderIdxWithImageFiles, getImageBasename(image2Idx))\n\n\timageFilter := models.ImageFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    image1Path,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.ImageFilterType]{\n\t\t\tOr: &models.ImageFilterType{\n\t\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\t\tValue:    image2Path,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\n\t\timages := queryImages(ctx, t, sqb, &imageFilter, nil)\n\n\t\tif !assert.Len(t, images, 2) {\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, image1Path, images[0].Path)\n\t\tassert.Equal(t, image2Path, images[1].Path)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryPathAndRating(t *testing.T) {\n\tconst imageIdx = 1\n\timagePath := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx))\n\timageRating := getRating(imageIdx)\n\n\timageFilter := models.ImageFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    imagePath,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.ImageFilterType]{\n\t\t\tAnd: &models.ImageFilterType{\n\t\t\t\tRating100: &models.IntCriterionInput{\n\t\t\t\t\tValue:    int(imageRating.Int64),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\n\t\timages := queryImages(ctx, t, sqb, &imageFilter, nil)\n\n\t\tif !assert.Len(t, images, 1) {\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, imagePath, images[0].Path)\n\t\tassert.Equal(t, int(imageRating.Int64), *images[0].Rating)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryPathNotRating(t *testing.T) {\n\tconst imageIdx = 1\n\n\timageRating := getRating(imageIdx)\n\n\tpathCriterion := models.StringCriterionInput{\n\t\tValue:    \"image_.*1_Path\",\n\t\tModifier: models.CriterionModifierMatchesRegex,\n\t}\n\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    int(imageRating.Int64),\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\timageFilter := models.ImageFilterType{\n\t\tPath: &pathCriterion,\n\t\tOperatorFilter: models.OperatorFilter[models.ImageFilterType]{\n\t\t\tNot: &models.ImageFilterType{\n\t\t\t\tRating100: &ratingCriterion,\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\n\t\timages := queryImages(ctx, t, sqb, &imageFilter, nil)\n\n\t\tfor _, image := range images {\n\t\t\tverifyString(t, image.Path, pathCriterion)\n\t\t\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\t\t\tverifyIntPtr(t, image.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageIllegalQuery(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst imageIdx = 1\n\tsubFilter := models.ImageFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    getImageStringValue(imageIdx, \"Path\"),\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\timageFilter := &models.ImageFilterType{\n\t\tOperatorFilter: models.OperatorFilter[models.ImageFilterType]{\n\t\t\tAnd: &subFilter,\n\t\t\tOr:  &subFilter,\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\n\t\t_, _, err := queryImagesWithCount(ctx, sqb, imageFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\timageFilter.Or = nil\n\t\timageFilter.Not = &subFilter\n\t\t_, _, err = queryImagesWithCount(ctx, sqb, imageFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\timageFilter.And = nil\n\t\timageFilter.Or = &subFilter\n\t\t_, _, err = queryImagesWithCount(ctx, sqb, imageFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryRating100(t *testing.T) {\n\tconst rating = 60\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    rating,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyImagesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyImagesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyImagesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyImagesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierIsNull\n\tverifyImagesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyImagesRating100(t, ratingCriterion)\n}\n\nfunc verifyImagesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\timageFilter := models.ImageFilterType{\n\t\t\tRating100: &ratingCriterion,\n\t\t}\n\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tfor _, image := range images {\n\t\t\tverifyIntPtr(t, image.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryOCounter(t *testing.T) {\n\tconst oCounter = 1\n\toCounterCriterion := models.IntCriterionInput{\n\t\tValue:    oCounter,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyImagesOCounter(t, oCounterCriterion)\n\n\toCounterCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyImagesOCounter(t, oCounterCriterion)\n\n\toCounterCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyImagesOCounter(t, oCounterCriterion)\n\n\toCounterCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyImagesOCounter(t, oCounterCriterion)\n}\n\nfunc verifyImagesOCounter(t *testing.T, oCounterCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\timageFilter := models.ImageFilterType{\n\t\t\tOCounter: &oCounterCriterion,\n\t\t}\n\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tfor _, image := range images {\n\t\t\tverifyInt(t, image.OCounter, oCounterCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryResolution(t *testing.T) {\n\tverifyImagesResolution(t, models.ResolutionEnumLow)\n\tverifyImagesResolution(t, models.ResolutionEnumStandard)\n\tverifyImagesResolution(t, models.ResolutionEnumStandardHd)\n\tverifyImagesResolution(t, models.ResolutionEnumFullHd)\n\tverifyImagesResolution(t, models.ResolutionEnumFourK)\n\tverifyImagesResolution(t, models.ResolutionEnum(\"unknown\"))\n}\n\nfunc verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\timageFilter := models.ImageFilterType{\n\t\t\tResolution: &models.ResolutionCriterionInput{\n\t\t\t\tValue:    resolution,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t}\n\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tfor _, image := range images {\n\t\t\tif err := image.LoadPrimaryFile(ctx, db.File); err != nil {\n\t\t\t\tt.Errorf(\"Error loading primary file: %s\", err.Error())\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tf := image.Files.Primary()\n\t\t\tvf, ok := f.(models.VisualFile)\n\t\t\tif !ok {\n\t\t\t\tt.Errorf(\"Error: image primary file is not a visual file (is type %T)\", f)\n\t\t\t}\n\t\t\tverifyImageResolution(t, vf.GetHeight(), resolution)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyImageResolution(t *testing.T, height int, resolution models.ResolutionEnum) {\n\tif !resolution.IsValid() {\n\t\treturn\n\t}\n\n\tassert := assert.New(t)\n\n\tswitch resolution {\n\tcase models.ResolutionEnumLow:\n\t\tassert.True(height < 480)\n\tcase models.ResolutionEnumStandard:\n\t\tassert.True(height >= 480 && height < 720)\n\tcase models.ResolutionEnumStandardHd:\n\t\tassert.True(height >= 720 && height < 1080)\n\tcase models.ResolutionEnumFullHd:\n\t\tassert.True(height >= 1080 && height < 2160)\n\tcase models.ResolutionEnumFourK:\n\t\tassert.True(height >= 2160)\n\t}\n}\n\nfunc TestImageQueryIsMissingGalleries(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\tisMissing := \"galleries\"\n\t\timageFilter := models.ImageFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getImageStringValue(imageIdxWithGallery, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, images, 0)\n\n\t\tfindFilter.Q = nil\n\t\timages, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.Greater(t, len(images), 0)\n\n\t\t// ensure non of the ids equal the one with gallery\n\t\tfor _, image := range images {\n\t\t\tassert.NotEqual(t, imageIDs[imageIdxWithGallery], image.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryIsMissingStudio(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\tisMissing := \"studio\"\n\t\timageFilter := models.ImageFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getImageStringValue(imageIdxWithStudio, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, images, 0)\n\n\t\tfindFilter.Q = nil\n\t\timages, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\t// ensure non of the ids equal the one with studio\n\t\tfor _, image := range images {\n\t\t\tassert.NotEqual(t, imageIDs[imageIdxWithStudio], image.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryIsMissingPerformers(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\tisMissing := \"performers\"\n\t\timageFilter := models.ImageFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getImageStringValue(imageIdxWithPerformer, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, images, 0)\n\n\t\tfindFilter.Q = nil\n\t\timages, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.True(t, len(images) > 0)\n\n\t\t// ensure non of the ids equal the one with performers\n\t\tfor _, image := range images {\n\t\t\tassert.NotEqual(t, imageIDs[imageIdxWithPerformer], image.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryIsMissingTags(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\tisMissing := \"tags\"\n\t\timageFilter := models.ImageFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getImageStringValue(imageIdxWithTwoTags, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, images, 0)\n\n\t\tfindFilter.Q = nil\n\t\timages, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.True(t, len(images) > 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryIsMissingRating(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\tisMissing := \"rating\"\n\t\timageFilter := models.ImageFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.True(t, len(images) > 0)\n\n\t\t// ensure rating is null\n\t\tfor _, image := range images {\n\t\t\tassert.Nil(t, image.Rating)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryGallery(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\tgalleryCriterion := models.MultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(galleryIDs[galleryIdxWithImage]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\timageFilter := models.ImageFilterType{\n\t\t\tGalleries: &galleryCriterion,\n\t\t}\n\n\t\timages := queryImages(ctx, t, sqb, &imageFilter, nil)\n\t\tassert.Len(t, images, 1)\n\n\t\t// ensure ids are correct\n\t\tfor _, image := range images {\n\t\t\tassert.True(t, image.ID == imageIDs[imageIdxWithGallery])\n\t\t}\n\n\t\tgalleryCriterion = models.MultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(galleryIDs[galleryIdx1WithImage]),\n\t\t\t\tstrconv.Itoa(galleryIDs[galleryIdx2WithImage]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t}\n\n\t\timages = queryImages(ctx, t, sqb, &imageFilter, nil)\n\n\t\tassert.Len(t, images, 1)\n\t\tassert.Equal(t, imageIDs[imageIdxWithTwoGalleries], images[0].ID)\n\n\t\tgalleryCriterion = models.MultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(performerIDs[galleryIdx1WithImage]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t}\n\n\t\tq := getImageStringValue(imageIdxWithTwoGalleries, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\timages = queryImages(ctx, t, sqb, &imageFilter, &findFilter)\n\t\tassert.Len(t, images, 0)\n\n\t\tq = getImageStringValue(imageIdxWithPerformer, titleField)\n\t\timages = queryImages(ctx, t, sqb, &imageFilter, &findFilter)\n\t\tassert.Len(t, images, 1)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryPerformers(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      models.MultiCriterionInput\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdxWithImage]),\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx1WithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformer,\n\t\t\t\timageIdxWithTwoPerformers,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithGallery,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx1WithImage]),\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx2WithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithTwoPerformers,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformer,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[performerIdx1WithImage])},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{imageIdxWithTwoPerformers},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\t[]int{imageIdxWithTag},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformer,\n\t\t\t\timageIdxWithTwoPerformers,\n\t\t\t\timageIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformer,\n\t\t\t\timageIdxWithTwoPerformers,\n\t\t\t\timageIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\t[]int{imageIdxWithTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx1WithImage]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx2WithImage]),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdxWithTwoPerformers},\n\t\t\t[]int{\n\t\t\t\timageIdxWithThreePerformers,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx1WithImage]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx2WithImage]),\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.Image.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tImageFilter: &models.ImageFilterType{\n\t\t\t\t\tPerformers: &tt.filter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ImageStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(imageIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(imageIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestImageQueryTags(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      models.HierarchicalMultiCriterionInput\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithImage]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithTag,\n\t\t\t\timageIdxWithTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithGallery,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithImage]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[tagIdx1WithImage])},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{imageIdxWithTwoTags},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\t[]int{imageIdx1WithPerformer},\n\t\t\t[]int{\n\t\t\t\timageIdxWithTag,\n\t\t\t\timageIdxWithTwoTags,\n\t\t\t\timageIdxWithThreeTags,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithTag,\n\t\t\t\timageIdxWithTwoTags,\n\t\t\t\timageIdxWithThreeTags,\n\t\t\t},\n\t\t\t[]int{imageIdx1WithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithImage]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithImage]),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdxWithTwoTags},\n\t\t\t[]int{\n\t\t\t\timageIdxWithThreeTags,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithImage]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithImage]),\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.Image.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tImageFilter: &models.ImageFilterType{\n\t\t\t\t\tTags: &tt.filter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ImageStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(imageIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(imageIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestImageQueryStudio(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tq               string\n\t\tstudioCriterion models.HierarchicalMultiCriterionInput\n\t\texpectedIDs     []int\n\t\twantErr         bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\t\"\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{imageIDs[imageIdxWithStudio]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tgetImageStringValue(imageIdxWithStudio, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t},\n\t\t\t[]int{},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes includes null\",\n\t\t\tgetImageStringValue(imageIdxWithGallery, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t},\n\t\t\t[]int{imageIDs[imageIdxWithGallery]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\t\"\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t\t[]int{imageIDs[imageIdxWithStudio]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tgetImageStringValue(imageIdxWithStudio, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithImage]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t},\n\t\t\t[]int{},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tstudioCriterion := tt.studioCriterion\n\n\t\t\timageFilter := models.ImageFilterType{\n\t\t\t\tStudios: &studioCriterion,\n\t\t\t}\n\n\t\t\tvar findFilter *models.FindFilterType\n\t\t\tif tt.q != \"\" {\n\t\t\t\tfindFilter = &models.FindFilterType{\n\t\t\t\t\tQ: &tt.q,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\timages := queryImages(ctx, t, qb, &imageFilter, findFilter)\n\n\t\t\tassert.ElementsMatch(t, imagesToIDs(images), tt.expectedIDs)\n\t\t})\n\t}\n}\n\nfunc TestImageQueryStudioDepth(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\tdepth := 2\n\t\tstudioCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGrandChild]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    &depth,\n\t\t}\n\n\t\timageFilter := models.ImageFilterType{\n\t\t\tStudios: &studioCriterion,\n\t\t}\n\n\t\timages := queryImages(ctx, t, sqb, &imageFilter, nil)\n\t\tassert.Len(t, images, 1)\n\n\t\tdepth = 1\n\n\t\timages = queryImages(ctx, t, sqb, &imageFilter, nil)\n\t\tassert.Len(t, images, 0)\n\n\t\tstudioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}\n\t\timages = queryImages(ctx, t, sqb, &imageFilter, nil)\n\t\tassert.Len(t, images, 1)\n\n\t\t// ensure id is correct\n\t\tassert.Equal(t, imageIDs[imageIdxWithGrandChildStudio], images[0].ID)\n\n\t\tdepth = 2\n\n\t\tstudioCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGrandChild]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\tDepth:    &depth,\n\t\t}\n\n\t\tq := getImageStringValue(imageIdxWithGrandChildStudio, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\timages = queryImages(ctx, t, sqb, &imageFilter, &findFilter)\n\t\tassert.Len(t, images, 0)\n\n\t\tdepth = 1\n\t\timages = queryImages(ctx, t, sqb, &imageFilter, &findFilter)\n\t\tassert.Len(t, images, 1)\n\n\t\tstudioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}\n\t\timages = queryImages(ctx, t, sqb, &imageFilter, &findFilter)\n\t\tassert.Len(t, images, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc queryImages(ctx context.Context, t *testing.T, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) []*models.Image {\n\timages, _, err := queryImagesWithCount(ctx, sqb, imageFilter, findFilter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying images: %s\", err.Error())\n\t}\n\n\treturn images\n}\n\nfunc TestImageQueryPerformerTags(t *testing.T) {\n\tallDepth := -1\n\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.ImageFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithPerformer]),\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformerTag,\n\t\t\t\timageIdxWithPerformerTwoTags,\n\t\t\t\timageIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformer,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes sub-tags\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithParentAndChild]),\n\t\t\t\t\t},\n\t\t\t\t\tDepth:    &allDepth,\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformerParentTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformer,\n\t\t\t\timageIdxWithPerformerTag,\n\t\t\t\timageIdxWithPerformerTwoTags,\n\t\t\t\timageIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformer,\n\t\t\t\timageIdxWithPerformerTag,\n\t\t\t\timageIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes performer tag tagIdx2WithPerformer\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{imageIdxWithTwoPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes sub-tags\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithParentAndChild]),\n\t\t\t\t\t},\n\t\t\t\t\tDepth:    &allDepth,\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformer,\n\t\t\t\timageIdxWithPerformerTag,\n\t\t\t\timageIdxWithPerformerTwoTags,\n\t\t\t\timageIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\timageIdxWithPerformerParentTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdxWithGallery},\n\t\t\t[]int{imageIdxWithPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdxWithPerformerTag},\n\t\t\t[]int{imageIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tnil,\n\t\t\t&models.ImageFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.Image.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tImageFilter: tt.filter,\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: tt.findFilter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ImageStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(imageIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(imageIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestImageQueryTagCount(t *testing.T) {\n\tconst tagCount = 1\n\ttagCountCriterion := models.IntCriterionInput{\n\t\tValue:    tagCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyImagesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyImagesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyImagesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyImagesTagCount(t, tagCountCriterion)\n}\n\nfunc verifyImagesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\timageFilter := models.ImageFilterType{\n\t\t\tTagCount: &tagCountCriterion,\n\t\t}\n\n\t\timages := queryImages(ctx, t, sqb, &imageFilter, nil)\n\t\tassert.Greater(t, len(images), 0)\n\n\t\tfor _, image := range images {\n\t\t\tids, err := sqb.GetTagIDs(ctx, image.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, len(ids), tagCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryPerformerCount(t *testing.T) {\n\tconst performerCount = 1\n\tperformerCountCriterion := models.IntCriterionInput{\n\t\tValue:    performerCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyImagesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyImagesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyImagesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyImagesPerformerCount(t, performerCountCriterion)\n}\n\nfunc verifyImagesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Image\n\t\timageFilter := models.ImageFilterType{\n\t\t\tPerformerCount: &performerCountCriterion,\n\t\t}\n\n\t\timages := queryImages(ctx, t, sqb, &imageFilter, nil)\n\t\tassert.Greater(t, len(images), 0)\n\n\t\tfor _, image := range images {\n\t\t\tids, err := sqb.GetPerformerIDs(ctx, image.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, len(ids), performerCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQuerySorting(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tsortBy   string\n\t\tdir      models.SortDirectionEnum\n\t\tfirstIdx int // -1 to ignore\n\t\tlastIdx  int\n\t}{\n\t\t{\n\t\t\t\"file mod time\",\n\t\t\t\"file_mod_time\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"file size\",\n\t\t\t\"filesize\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"path\",\n\t\t\t\"path\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"date\",\n\t\t\t\"date\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\timageIdxWithTwoGalleries,\n\t\t\timageIdxWithGrandChildStudio,\n\t\t},\n\t}\n\n\tqb := db.Image\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: &models.FindFilterType{\n\t\t\t\t\t\tSort:      &tt.sortBy,\n\t\t\t\t\t\tDirection: &tt.dir,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ImageStore.TestImageQuerySorting() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\timages, err := got.Resolve(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ImageStore.TestImageQuerySorting() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !assert.Greater(len(images), 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// image should be in same order as indexes\n\t\t\tfirst := images[0]\n\t\t\tlast := images[len(images)-1]\n\n\t\t\tif tt.firstIdx != -1 {\n\t\t\t\tfirstID := sceneIDs[tt.firstIdx]\n\t\t\t\tassert.Equal(firstID, first.ID)\n\t\t\t}\n\t\t\tif tt.lastIdx != -1 {\n\t\t\t\tlastID := sceneIDs[tt.lastIdx]\n\t\t\t\tassert.Equal(lastID, last.ID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestImageQueryPagination(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tperPage := 1\n\t\tfindFilter := models.FindFilterType{\n\t\t\tPerPage: &perPage,\n\t\t}\n\n\t\tsqb := db.Image\n\t\timages, _, err := queryImagesWithCount(ctx, sqb, nil, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, images, 1)\n\n\t\tfirstID := images[0].ID\n\n\t\tpage := 2\n\t\tfindFilter.Page = &page\n\t\timages, _, err = queryImagesWithCount(ctx, sqb, nil, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, images, 1)\n\t\tsecondID := images[0].ID\n\t\tassert.NotEqual(t, firstID, secondID)\n\n\t\tperPage = 2\n\t\tpage = 1\n\n\t\timages, _, err = queryImagesWithCount(ctx, sqb, nil, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying image: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, images, 2)\n\t\tassert.Equal(t, firstID, images[0].ID)\n\t\tassert.Equal(t, secondID, images[1].ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestImageQueryCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      *models.ImageFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue:    []any{getImageStringValue(imageIdx1WithGallery, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdx1WithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getImageStringValue(imageIdx1WithGallery, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\t\tValue:    []any{getImageStringValue(imageIdx1WithGallery, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{imageIdx1WithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue:    []any{getImageStringValue(imageIdx1WithGallery, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdx1WithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getImageStringValue(imageIdx1WithGallery, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\t\tValue:    []any{getImageStringValue(imageIdx1WithGallery, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{imageIdx1WithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"regex\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*17_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdxWithPerformerTag},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid regex\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getImageStringValue(imageIdxWithPerformerTag, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*17_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{imageIdxWithPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid not matches regex\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getImageStringValue(imageIdx1WithGallery, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"not existing\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdx1WithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getImageStringValue(imageIdx1WithGallery, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdx1WithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"between\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{imageIdx2WithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not between\",\n\t\t\t&models.ImageFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getImageStringValue(imageIdx2WithGallery, titleField),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{imageIdx2WithGallery},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresult, err := db.Image.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tImageFilter: tt.filter,\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ImageStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\timages, err := result.Resolve(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ImageStore.Query().Resolve() error = %v\", err)\n\t\t\t}\n\n\t\t\tids := imagesToIDs(images)\n\t\t\tinclude := indexesToIDs(imageIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(imageIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TODO Count\n// TODO SizeCount\n// TODO All\n"
  },
  {
    "path": "pkg/sqlite/migrate.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/golang-migrate/migrate/v4\"\n\tsqlite3mig \"github.com/golang-migrate/migrate/v4/database/sqlite3\"\n\t\"github.com/golang-migrate/migrate/v4/source/iofs\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nfunc (db *Database) needsMigration() bool {\n\treturn db.schemaVersion != appSchemaVersion\n}\n\ntype Migrator struct {\n\tdb   *Database\n\tconn *sqlx.DB\n\tm    *migrate.Migrate\n}\n\nfunc NewMigrator(db *Database) (*Migrator, error) {\n\tm := &Migrator{\n\t\tdb: db,\n\t}\n\n\tconst disableForeignKeys = true\n\tconst writable = true\n\tvar err error\n\tm.conn, err = m.db.open(disableForeignKeys, writable)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tm.conn.SetMaxOpenConns(maxReadConnections)\n\tm.conn.SetMaxIdleConns(maxReadConnections)\n\tm.conn.SetConnMaxIdleTime(dbConnTimeout)\n\n\tm.m, err = m.getMigrate()\n\n\t// if error encountered, close the connection\n\tif err != nil {\n\t\tm.Close()\n\t}\n\n\treturn m, err\n}\n\nfunc (m *Migrator) Close() {\n\tif m.m != nil {\n\t\tm.m.Close()\n\t\tm.m = nil\n\t}\n}\n\nfunc (m *Migrator) CurrentSchemaVersion() uint {\n\tdatabaseSchemaVersion, _, _ := m.m.Version()\n\treturn databaseSchemaVersion\n}\n\nfunc (m *Migrator) RequiredSchemaVersion() uint {\n\treturn appSchemaVersion\n}\n\nfunc (m *Migrator) getMigrate() (*migrate.Migrate, error) {\n\tmigrations, err := iofs.New(migrationsBox, \"migrations\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdriver, err := sqlite3mig.WithInstance(m.conn.DB, &sqlite3mig.Config{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// use sqlite3Driver so that migration has access to durationToTinyInt\n\treturn migrate.NewWithInstance(\n\t\t\"iofs\",\n\t\tmigrations,\n\t\tm.db.dbPath,\n\t\tdriver,\n\t)\n}\n\nfunc (m *Migrator) RunMigration(ctx context.Context, newVersion uint) error {\n\tdatabaseSchemaVersion, _, _ := m.m.Version()\n\n\tif newVersion != databaseSchemaVersion+1 {\n\t\treturn fmt.Errorf(\"invalid migration version %d, expected %d\", newVersion, databaseSchemaVersion+1)\n\t}\n\n\t// run pre migrations as needed\n\tif err := m.runCustomMigrations(ctx, preMigrations[newVersion]); err != nil {\n\t\treturn fmt.Errorf(\"running pre migrations for schema version %d: %w\", newVersion, err)\n\t}\n\n\tif err := m.m.Steps(1); err != nil {\n\t\t// migration failed\n\t\treturn err\n\t}\n\n\t// run post migrations as needed\n\tif err := m.runCustomMigrations(ctx, postMigrations[newVersion]); err != nil {\n\t\treturn fmt.Errorf(\"running post migrations for schema version %d: %w\", newVersion, err)\n\t}\n\n\t// update the schema version\n\tm.db.schemaVersion, _, _ = m.m.Version()\n\n\treturn nil\n}\n\nfunc (m *Migrator) runCustomMigrations(ctx context.Context, fns []customMigrationFunc) error {\n\tfor _, fn := range fns {\n\t\tif err := m.runCustomMigration(ctx, fn); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *Migrator) runCustomMigration(ctx context.Context, fn customMigrationFunc) error {\n\tif err := fn(ctx, m.conn); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (m *Migrator) PostMigrate(ctx context.Context) error {\n\t// optimise the database\n\tvar err error\n\tlogger.Info(\"Running database analyze\")\n\n\t// don't use Optimize/vacuum as this adds a significant amount of time\n\t// to the migration\n\terr = analyze(ctx, m.conn)\n\n\tif err == nil {\n\t\tlogger.Debug(\"Flushing WAL\")\n\t\terr = flushWAL(ctx, m.conn)\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error optimising database: %s\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (db *Database) getDatabaseSchemaVersion() (uint, error) {\n\tm, err := NewMigrator(db)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer m.Close()\n\n\tret, _, _ := m.m.Version()\n\treturn ret, nil\n}\n\nfunc (db *Database) ReInitialise() error {\n\treturn db.initialise()\n}\n\n// RunAllMigrations runs all migrations to bring the database up to the current schema version\nfunc (db *Database) RunAllMigrations() error {\n\tctx := context.Background()\n\n\tm, err := NewMigrator(db)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer m.Close()\n\n\tdatabaseSchemaVersion, _, _ := m.m.Version()\n\tstepNumber := appSchemaVersion - databaseSchemaVersion\n\tif stepNumber != 0 {\n\t\tlogger.Infof(\"Migrating database from version %d to %d\", databaseSchemaVersion, appSchemaVersion)\n\n\t\t// run each migration individually, and run custom migrations as needed\n\t\tvar i uint = 1\n\t\tfor ; i <= stepNumber; i++ {\n\t\t\tnewVersion := databaseSchemaVersion + i\n\t\t\tif err := m.RunMigration(ctx, newVersion); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/10_image_tables.up.sql",
    "content": "-- recreate scenes, studios and performers tables\nALTER TABLE `studios` rename to `_studios_old`;\nALTER TABLE `scenes` rename to `_scenes_old`;\nALTER TABLE `performers` RENAME TO `_performers_old`;\nALTER TABLE `movies` rename to `_movies_old`;\n\n-- remove studio image\nCREATE TABLE `studios` (\n  `id` integer not null primary key autoincrement,\n  `checksum` varchar(255) not null,\n  `name` varchar(255),\n  `url` varchar(255),\n  `parent_id` integer DEFAULT NULL CHECK ( id IS NOT parent_id ) REFERENCES studios(id) on delete set null,\n  `created_at` datetime not null,\n  `updated_at` datetime not null\n);\n\nDROP INDEX `studios_checksum_unique`;\nDROP INDEX `index_studios_on_name`;\nDROP INDEX `index_studios_on_checksum`;\n\nCREATE UNIQUE INDEX `studios_checksum_unique` on `studios` (`checksum`);\nCREATE INDEX `index_studios_on_name` on `studios` (`name`);\nCREATE INDEX `index_studios_on_checksum` on `studios` (`checksum`);\n\n-- remove scene cover\nCREATE TABLE `scenes` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510) not null,\n  `checksum` varchar(255) not null,\n  `title` varchar(255),\n  `details` text,\n  `url` varchar(255),\n  `date` date,\n  `rating` tinyint,\n  `size` varchar(255),\n  `duration` float,\n  `video_codec` varchar(255),\n  `audio_codec` varchar(255),\n  `width` tinyint,\n  `height` tinyint,\n  `framerate` float,\n  `bitrate` integer,\n  `studio_id` integer,\n  `o_counter` tinyint not null default 0,\n  `format` varchar(255),\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  -- changed from cascade delete\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n);\n\nDROP INDEX IF EXISTS `scenes_path_unique`;\nDROP INDEX IF EXISTS `scenes_checksum_unique`;\nDROP INDEX IF EXISTS `index_scenes_on_studio_id`;\n\nCREATE UNIQUE INDEX `scenes_path_unique` on `scenes` (`path`);\nCREATE UNIQUE INDEX `scenes_checksum_unique` on `scenes` (`checksum`);\nCREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`);\n\n-- remove performer image\nCREATE TABLE `performers` (\n  `id` integer not null primary key autoincrement,\n  `checksum` varchar(255) not null,\n  `name` varchar(255),\n  `gender` varchar(20),\n  `url` varchar(255),\n  `twitter` varchar(255),\n  `instagram` varchar(255),\n  `birthdate` date,\n  `ethnicity` varchar(255),\n  `country` varchar(255),\n  `eye_color` varchar(255),\n  `height` varchar(255),\n  `measurements` varchar(255),\n  `fake_tits` varchar(255),\n  `career_length` varchar(255),\n  `tattoos` varchar(255),\n  `piercings` varchar(255),\n  `aliases` varchar(255),\n  `favorite` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null\n);\n\nDROP INDEX `performers_checksum_unique`;\nDROP INDEX `index_performers_on_name`;\n\nCREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`);\nCREATE INDEX `index_performers_on_name` on `performers` (`name`);\n\n-- remove front_image and back_image\nCREATE TABLE `movies` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(255) not null,\n  `aliases` varchar(255),\n  `duration` integer,\n  `date` date,\n  `rating` tinyint,\n  `studio_id` integer,\n  `director` varchar(255),\n  `synopsis` text,\n  `checksum` varchar(255) not null,\n  `url` varchar(255),\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete set null\n);\n\nDROP INDEX `movies_name_unique`;\nDROP INDEX `movies_checksum_unique`;\nDROP INDEX `index_movies_on_studio_id`;\n\nCREATE UNIQUE INDEX `movies_name_unique` on `movies` (`name`);\nCREATE UNIQUE INDEX `movies_checksum_unique` on `movies` (`checksum`);\nCREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`);\n\n-- recreate the tables referencing the above tables to correct their references\nALTER TABLE `galleries` rename to `_galleries_old`;\nALTER TABLE `performers_scenes` rename to `_performers_scenes_old`;\nALTER TABLE `scene_markers` rename to `_scene_markers_old`;\nALTER TABLE `scene_markers_tags` rename to `_scene_markers_tags_old`;\nALTER TABLE `scenes_tags` rename to `_scenes_tags_old`;\nALTER TABLE `movies_scenes` rename to `_movies_scenes_old`;\nALTER TABLE `scraped_items` rename to `_scraped_items_old`;\n\nCREATE TABLE `galleries` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510) not null,\n  `checksum` varchar(255) not null,\n  `scene_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\n\nDROP INDEX IF EXISTS `index_galleries_on_scene_id`;\nDROP INDEX IF EXISTS `galleries_path_unique`;\nDROP INDEX IF EXISTS `galleries_checksum_unique`;\n\nCREATE INDEX `index_galleries_on_scene_id` on `galleries` (`scene_id`);\nCREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`);\nCREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`);\n\nCREATE TABLE `performers_scenes` (\n  `performer_id` integer,\n  `scene_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\n\nDROP INDEX `index_performers_scenes_on_scene_id`;\nDROP INDEX `index_performers_scenes_on_performer_id`;\n\nCREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`);\nCREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`);\n\nCREATE TABLE `scene_markers` (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255) not null,\n  `seconds` float not null,\n  `primary_tag_id` integer not null,\n  `scene_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`primary_tag_id`) references `tags`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\n\nDROP INDEX `index_scene_markers_on_scene_id`;\nDROP INDEX `index_scene_markers_on_primary_tag_id`;\n\nCREATE INDEX `index_scene_markers_on_scene_id` on `scene_markers` (`scene_id`);\nCREATE INDEX `index_scene_markers_on_primary_tag_id` on `scene_markers` (`primary_tag_id`);\n\nCREATE TABLE `scene_markers_tags` (\n  `scene_marker_id` integer,\n  `tag_id` integer,\n  foreign key(`scene_marker_id`) references `scene_markers`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`)\n);\n\nDROP INDEX `index_scene_markers_tags_on_tag_id`;\nDROP INDEX `index_scene_markers_tags_on_scene_marker_id`;\n\nCREATE INDEX `index_scene_markers_tags_on_tag_id` on `scene_markers_tags` (`tag_id`);\nCREATE INDEX `index_scene_markers_tags_on_scene_marker_id` on `scene_markers_tags` (`scene_marker_id`);\n\nCREATE TABLE `scenes_tags` (\n  `scene_id` integer,\n  `tag_id` integer,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`)\n);\n\nDROP INDEX `index_scenes_tags_on_tag_id`;\nDROP INDEX `index_scenes_tags_on_scene_id`;\n\nCREATE INDEX `index_scenes_tags_on_tag_id` on `scenes_tags` (`tag_id`);\nCREATE INDEX `index_scenes_tags_on_scene_id` on `scenes_tags` (`scene_id`);\n\nCREATE TABLE `movies_scenes` (\n  `movie_id` integer,\n  `scene_id` integer,\n  `scene_index` tinyint,\n  foreign key(`movie_id`) references `movies`(`id`) on delete cascade,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete cascade\n);\n\nDROP INDEX `index_movies_scenes_on_movie_id`;\nDROP INDEX `index_movies_scenes_on_scene_id`;\n\nCREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`);\nCREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`);\n\n-- remove movie_id since doesn't appear to be used\nCREATE TABLE `scraped_items` (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255),\n  `description` text,\n  `url` varchar(255),\n  `date` date,\n  `rating` varchar(255),\n  `tags` varchar(510),\n  `models` varchar(510),\n  `episode` integer,\n  `gallery_filename` varchar(255),\n  `gallery_url` varchar(510),\n  `video_filename` varchar(255),\n  `video_url` varchar(255),\n  `studio_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`)\n);\n\nDROP INDEX `index_scraped_items_on_studio_id`;\n\nCREATE INDEX `index_scraped_items_on_studio_id` on `scraped_items` (`studio_id`);\n\n-- now populate from the old tables\n-- these tables are changed so require the full column def\nINSERT INTO `studios` \n  (\n    `id`,\n    `checksum`,\n    `name`,\n    `url`,\n    `parent_id`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `checksum`,\n    `name`,\n    `url`,\n    `parent_id`,\n    `created_at`,\n    `updated_at`\n  FROM `_studios_old`;\n\nINSERT INTO `scenes`\n  (\n    `id`,\n    `path`,\n    `checksum`,\n    `title`,\n    `details`,\n    `url`,\n    `date`,\n    `rating`,\n    `size`,\n    `duration`,\n    `video_codec`,\n    `audio_codec`,\n    `width`,\n    `height`,\n    `framerate`,\n    `bitrate`,\n    `studio_id`,\n    `o_counter`,\n    `format`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `path`,\n    `checksum`,\n    `title`,\n    `details`,\n    `url`,\n    `date`,\n    `rating`,\n    `size`,\n    `duration`,\n    `video_codec`,\n    `audio_codec`,\n    `width`,\n    `height`,\n    `framerate`,\n    `bitrate`,\n    `studio_id`,\n    `o_counter`,\n    `format`,\n    `created_at`,\n    `updated_at`\n  FROM `_scenes_old`;\n\nINSERT INTO `performers` \n  (\n    `id`,\n    `checksum`,\n    `name`,\n    `gender`,\n    `url`,\n    `twitter`,\n    `instagram`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `career_length`,\n    `tattoos`,\n    `piercings`,\n    `aliases`,\n    `favorite`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT\n    `id`,\n    `checksum`,\n    `name`,\n    `gender`,\n    `url`,\n    `twitter`,\n    `instagram`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `career_length`,\n    `tattoos`,\n    `piercings`,\n    `aliases`,\n    `favorite`,\n    `created_at`,\n    `updated_at`\n  FROM `_performers_old`;\n\nINSERT INTO `movies`\n  (\n    `id`,\n    `name`,\n    `aliases`,\n    `duration`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `director`,\n    `synopsis`,\n    `checksum`,\n    `url`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT\n    `id`,\n    `name`,\n    `aliases`,\n    `duration`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `director`,\n    `synopsis`,\n    `checksum`,\n    `url`,\n    `created_at`,\n    `updated_at`\n  FROM `_movies_old`;\n\nINSERT INTO `scraped_items`\n  (\n    `id`,\n    `title`,\n    `description`,\n    `url`,\n    `date`,\n    `rating`,\n    `tags`,\n    `models`,\n    `episode`,\n    `gallery_filename`,\n    `gallery_url`,\n    `video_filename`,\n    `video_url`,\n    `studio_id`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT\n    `id`,\n    `title`,\n    `description`,\n    `url`,\n    `date`,\n    `rating`,\n    `tags`,\n    `models`,\n    `episode`,\n    `gallery_filename`,\n    `gallery_url`,\n    `video_filename`,\n    `video_url`,\n    `studio_id`,\n    `created_at`,\n    `updated_at`\n  FROM `_scraped_items_old`;\n\n-- these tables are a direct copy\nINSERT INTO `galleries` SELECT * from `_galleries_old`;\nINSERT INTO `performers_scenes` SELECT * from `_performers_scenes_old`;\nINSERT INTO `scene_markers` SELECT * from `_scene_markers_old`;\nINSERT INTO `scene_markers_tags` SELECT * from `_scene_markers_tags_old`;\nINSERT INTO `scenes_tags` SELECT * from `_scenes_tags_old`;\nINSERT INTO `movies_scenes` SELECT * from `_movies_scenes_old`;\n\n-- populate covers in separate table\nCREATE TABLE `scenes_cover` (\n  `scene_id` integer,\n  `cover` blob not null,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\nCREATE UNIQUE INDEX `index_scene_covers_on_scene_id` on `scenes_cover` (`scene_id`);\n\nINSERT INTO `scenes_cover` \n  (\n    `scene_id`,\n    `cover`\n  )\n  SELECT `id`, `cover` from `_scenes_old` where `cover` is not null;\n\n-- put performer images in separate table\nCREATE TABLE `performers_image` (\n  `performer_id` integer,\n  `image` blob not null,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE\n);\n\nCREATE UNIQUE INDEX `index_performer_image_on_performer_id` on `performers_image` (`performer_id`);\n\nINSERT INTO `performers_image` \n  (\n    `performer_id`,\n    `image`\n  )\n  SELECT `id`, `image` from `_performers_old` where `image` is not null;\n\n-- put studio images in separate table\nCREATE TABLE `studios_image` (\n  `studio_id` integer,\n  `image` blob not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE\n);\n\nCREATE UNIQUE INDEX `index_studio_image_on_studio_id` on `studios_image` (`studio_id`);\n\nINSERT INTO `studios_image` \n  (\n    `studio_id`,\n    `image`\n  )\n  SELECT `id`, `image` from `_studios_old` where `image` is not null;\n\n-- put movie images in separate table\nCREATE TABLE `movies_images` (\n  `movie_id` integer,\n  `front_image` blob not null,\n  `back_image` blob,\n  foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE\n);\n\nCREATE UNIQUE INDEX `index_movie_images_on_movie_id` on `movies_images` (`movie_id`);\n\nINSERT INTO `movies_images` \n  (\n    `movie_id`,\n    `front_image`,\n    `back_image`\n  )\n  SELECT `id`, `front_image`, `back_image` from `_movies_old` where `front_image` is not null;\n\n-- drop old tables\nDROP TABLE `_scenes_old`;\nDROP TABLE `_studios_old`;\nDROP TABLE `_performers_old`;\nDROP TABLE `_movies_old`;\nDROP TABLE `_galleries_old`;\nDROP TABLE `_performers_scenes_old`;\nDROP TABLE `_scene_markers_old`;\nDROP TABLE `_scene_markers_tags_old`;\nDROP TABLE `_scenes_tags_old`;\nDROP TABLE `_movies_scenes_old`;\nDROP TABLE `_scraped_items_old`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/11_tag_image.up.sql",
    "content": "CREATE TABLE `tags_image` (\n  `tag_id` integer,\n  `image` blob not null,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);\n\nCREATE UNIQUE INDEX `index_tag_image_on_tag_id` on `tags_image` (`tag_id`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/12_oshash.up.sql",
    "content": "\n-- need to change scenes.checksum to be nullable\nALTER TABLE `scenes` rename to `_scenes_old`;\n\nCREATE TABLE `scenes` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510) not null,\n  -- nullable\n  `checksum` varchar(255),\n  -- add oshash\n  `oshash` varchar(255),\n  `title` varchar(255),\n  `details` text,\n  `url` varchar(255),\n  `date` date,\n  `rating` tinyint,\n  `size` varchar(255),\n  `duration` float,\n  `video_codec` varchar(255),\n  `audio_codec` varchar(255),\n  `width` tinyint,\n  `height` tinyint,\n  `framerate` float,\n  `bitrate` integer,\n  `studio_id` integer,\n  `o_counter` tinyint not null default 0,\n  `format` varchar(255),\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL,\n  -- add check to ensure at least one hash is set\n  CHECK (`checksum` is not null or `oshash` is not null)\n);\n\nDROP INDEX IF EXISTS `scenes_path_unique`;\nDROP INDEX IF EXISTS `scenes_checksum_unique`;\nDROP INDEX IF EXISTS `index_scenes_on_studio_id`;\n\nCREATE UNIQUE INDEX `scenes_path_unique` on `scenes` (`path`);\nCREATE UNIQUE INDEX `scenes_checksum_unique` on `scenes` (`checksum`);\nCREATE UNIQUE INDEX `scenes_oshash_unique` on `scenes` (`oshash`);\nCREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`);\n\n-- recreate the tables referencing scenes to correct their references\nALTER TABLE `galleries` rename to `_galleries_old`;\nALTER TABLE `performers_scenes` rename to `_performers_scenes_old`;\nALTER TABLE `scene_markers` rename to `_scene_markers_old`;\nALTER TABLE `scene_markers_tags` rename to `_scene_markers_tags_old`;\nALTER TABLE `scenes_tags` rename to `_scenes_tags_old`;\nALTER TABLE `movies_scenes` rename to `_movies_scenes_old`;\nALTER TABLE `scenes_cover` rename to `_scenes_cover_old`;\n\nCREATE TABLE `galleries` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510) not null,\n  `checksum` varchar(255) not null,\n  `scene_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\n\nDROP INDEX IF EXISTS `index_galleries_on_scene_id`;\nDROP INDEX IF EXISTS `galleries_path_unique`;\nDROP INDEX IF EXISTS `galleries_checksum_unique`;\n\nCREATE INDEX `index_galleries_on_scene_id` on `galleries` (`scene_id`);\nCREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`);\nCREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`);\n\nCREATE TABLE `performers_scenes` (\n  `performer_id` integer,\n  `scene_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\n\nDROP INDEX `index_performers_scenes_on_scene_id`;\nDROP INDEX `index_performers_scenes_on_performer_id`;\n\nCREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`);\nCREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`);\n\nCREATE TABLE `scene_markers` (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255) not null,\n  `seconds` float not null,\n  `primary_tag_id` integer not null,\n  `scene_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`primary_tag_id`) references `tags`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\n\nDROP INDEX `index_scene_markers_on_scene_id`;\nDROP INDEX `index_scene_markers_on_primary_tag_id`;\n\nCREATE INDEX `index_scene_markers_on_scene_id` on `scene_markers` (`scene_id`);\nCREATE INDEX `index_scene_markers_on_primary_tag_id` on `scene_markers` (`primary_tag_id`);\n\nCREATE TABLE `scene_markers_tags` (\n  `scene_marker_id` integer,\n  `tag_id` integer,\n  foreign key(`scene_marker_id`) references `scene_markers`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`)\n);\n\nDROP INDEX `index_scene_markers_tags_on_tag_id`;\nDROP INDEX `index_scene_markers_tags_on_scene_marker_id`;\n\nCREATE INDEX `index_scene_markers_tags_on_tag_id` on `scene_markers_tags` (`tag_id`);\nCREATE INDEX `index_scene_markers_tags_on_scene_marker_id` on `scene_markers_tags` (`scene_marker_id`);\n\nCREATE TABLE `scenes_tags` (\n  `scene_id` integer,\n  `tag_id` integer,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`)\n);\n\nDROP INDEX `index_scenes_tags_on_tag_id`;\nDROP INDEX `index_scenes_tags_on_scene_id`;\n\nCREATE INDEX `index_scenes_tags_on_tag_id` on `scenes_tags` (`tag_id`);\nCREATE INDEX `index_scenes_tags_on_scene_id` on `scenes_tags` (`scene_id`);\n\nCREATE TABLE `movies_scenes` (\n  `movie_id` integer,\n  `scene_id` integer,\n  `scene_index` tinyint,\n  foreign key(`movie_id`) references `movies`(`id`) on delete cascade,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete cascade\n);\n\nDROP INDEX `index_movies_scenes_on_movie_id`;\nDROP INDEX `index_movies_scenes_on_scene_id`;\n\nCREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`);\nCREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`);\n\nCREATE TABLE `scenes_cover` (\n  `scene_id` integer,\n  `cover` blob not null,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\nDROP INDEX `index_scene_covers_on_scene_id`;\n\nCREATE UNIQUE INDEX `index_scene_covers_on_scene_id` on `scenes_cover` (`scene_id`);\n\n-- now populate from the old tables\n-- these tables are changed so require the full column def\nINSERT INTO `scenes`\n  (\n    `id`,\n    `path`,\n    `checksum`,\n    `title`,\n    `details`,\n    `url`,\n    `date`,\n    `rating`,\n    `size`,\n    `duration`,\n    `video_codec`,\n    `audio_codec`,\n    `width`,\n    `height`,\n    `framerate`,\n    `bitrate`,\n    `studio_id`,\n    `o_counter`,\n    `format`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `path`,\n    `checksum`,\n    `title`,\n    `details`,\n    `url`,\n    `date`,\n    `rating`,\n    `size`,\n    `duration`,\n    `video_codec`,\n    `audio_codec`,\n    `width`,\n    `height`,\n    `framerate`,\n    `bitrate`,\n    `studio_id`,\n    `o_counter`,\n    `format`,\n    `created_at`,\n    `updated_at`\n  FROM `_scenes_old`;\n\n-- these tables are a direct copy\nINSERT INTO `galleries` SELECT * from `_galleries_old`;\nINSERT INTO `performers_scenes` SELECT * from `_performers_scenes_old`;\nINSERT INTO `scene_markers` SELECT * from `_scene_markers_old`;\nINSERT INTO `scene_markers_tags` SELECT * from `_scene_markers_tags_old`;\nINSERT INTO `scenes_tags` SELECT * from `_scenes_tags_old`;\nINSERT INTO `movies_scenes` SELECT * from `_movies_scenes_old`;\nINSERT INTO `scenes_cover` SELECT * from `_scenes_cover_old`;\n\n-- drop old tables\nDROP TABLE `_scenes_old`;\nDROP TABLE `_galleries_old`;\nDROP TABLE `_performers_scenes_old`;\nDROP TABLE `_scene_markers_old`;\nDROP TABLE `_scene_markers_tags_old`;\nDROP TABLE `_scenes_tags_old`;\nDROP TABLE `_movies_scenes_old`;\nDROP TABLE `_scenes_cover_old`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/12_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\nfunc post12(ctx context.Context, db *sqlx.DB) error {\n\tm := schema12Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn m.migrateConfig(ctx)\n}\n\ntype schema12Migrator struct {\n\tmigrator\n}\n\nfunc (m *schema12Migrator) migrateConfig(ctx context.Context) error {\n\t// if there are no scene files in the database, then default the\n\t// VideoFileNamingAlgorithm config setting to oshash and calculateMD5 to\n\t// false, otherwise set them to true for backwards compatibility purposes\n\tvar count int\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\tquery := \"SELECT COUNT(*) from `scenes`\"\n\n\t\treturn tx.Get(&count, query)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tusingMD5 := count != 0\n\tdefaultAlgorithm := models.HashAlgorithmOshash\n\tif usingMD5 {\n\t\tlogger.Infof(\"Defaulting video file naming algorithm to %s\", models.HashAlgorithmMd5)\n\t\tdefaultAlgorithm = models.HashAlgorithmMd5\n\t}\n\n\tc := config.GetInstance()\n\n\tc.SetDefault(config.VideoFileNamingAlgorithm, defaultAlgorithm)\n\tc.SetDefault(config.CalculateMD5, usingMD5)\n\tif err := c.Write(); err != nil {\n\t\tlogger.Errorf(\"Error while writing configuration file: %s\", err.Error())\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(12, post12)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/13_images.up.sql",
    "content": "CREATE TABLE `images` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510) not null,\n  `checksum` varchar(255) not null,\n  `title` varchar(255),\n  `rating` tinyint,\n  `size` integer,\n  `width` tinyint,\n  `height` tinyint,\n  `studio_id` integer,\n  `o_counter` tinyint not null default 0,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n);\n\nCREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`);\n\nCREATE TABLE `performers_images` (\n  `performer_id` integer,\n  `image_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_performers_images_on_image_id` on `performers_images` (`image_id`);\nCREATE INDEX `index_performers_images_on_performer_id` on `performers_images` (`performer_id`);\n\nCREATE TABLE `images_tags` (\n  `image_id` integer,\n  `tag_id` integer,\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_images_tags_on_tag_id` on `images_tags` (`tag_id`);\nCREATE INDEX `index_images_tags_on_image_id` on `images_tags` (`image_id`);\n\n-- need to recreate galleries to add foreign key\nALTER TABLE `galleries` rename to `_galleries_old`;\n\nCREATE TABLE `galleries` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510),\n  `checksum` varchar(255) not null,\n  `zip` boolean not null default '0',\n  `title` varchar(255),\n  `url` varchar(255),\n  `date` date,\n  `details` text,\n  `studio_id` integer,\n  `rating` tinyint,\n  `scene_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete SET NULL,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n);\n\nDROP INDEX IF EXISTS `index_galleries_on_scene_id`;\nDROP INDEX IF EXISTS `galleries_path_unique`;\nDROP INDEX IF EXISTS `galleries_checksum_unique`;\n\nCREATE INDEX `index_galleries_on_scene_id` on `galleries` (`scene_id`);\nCREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`);\nCREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`);\nCREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`);\n\nCREATE TABLE `galleries_images` (\n  `gallery_id` integer,\n  `image_id` integer,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_galleries_images_on_image_id` on `galleries_images` (`image_id`);\nCREATE INDEX `index_galleries_images_on_gallery_id` on `galleries_images` (`gallery_id`);\n\nCREATE TABLE `performers_galleries` (\n  `performer_id` integer,\n  `gallery_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_performers_galleries_on_gallery_id` on `performers_galleries` (`gallery_id`);\nCREATE INDEX `index_performers_galleries_on_performer_id` on `performers_galleries` (`performer_id`);\n\nCREATE TABLE `galleries_tags` (\n  `gallery_id` integer,\n  `tag_id` integer,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_galleries_tags_on_tag_id` on `galleries_tags` (`tag_id`);\nCREATE INDEX `index_galleries_tags_on_gallery_id` on `galleries_tags` (`gallery_id`);\n\nINSERT INTO `galleries`\n  (\n    `id`,\n    `path`,\n    `checksum`,\n    `scene_id`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `path`,\n    `checksum`,\n    `scene_id`,\n    `created_at`,\n    `updated_at`\n  FROM `_galleries_old`;\n\nDROP TABLE `_galleries_old`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/14_stash_box_ids.up.sql",
    "content": "CREATE TABLE `scene_stash_ids` (\n  `scene_id` integer,\n  `endpoint` varchar(255),\n  `stash_id` varchar(36),\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\nCREATE TABLE `performer_stash_ids` (\n  `performer_id` integer,\n  `endpoint` varchar(255),\n  `stash_id` varchar(36),\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE\n);\n\nCREATE TABLE `studio_stash_ids` (\n  `studio_id` integer,\n  `endpoint` varchar(255),\n  `stash_id` varchar(36),\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE\n);\n"
  },
  {
    "path": "pkg/sqlite/migrations/15_file_mod_time.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `file_mod_time` datetime;\nALTER TABLE `images` ADD COLUMN `file_mod_time` datetime;\nALTER TABLE `galleries` ADD COLUMN `file_mod_time` datetime;\n"
  },
  {
    "path": "pkg/sqlite/migrations/16_organized_flag.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `organized` boolean not null default '0';\nALTER TABLE `images` ADD COLUMN `organized` boolean not null default '0';\nALTER TABLE `galleries` ADD COLUMN `organized` boolean not null default '0';\n"
  },
  {
    "path": "pkg/sqlite/migrations/17_reset_scene_size.up.sql",
    "content": "UPDATE `scenes` SET `size` = NULL;\n"
  },
  {
    "path": "pkg/sqlite/migrations/18_scene_galleries.up.sql",
    "content": "-- recreate the tables referencing galleries to correct their references\nALTER TABLE `galleries` rename to `_galleries_old`;\nALTER TABLE `galleries_images` rename to `_galleries_images_old`;\nALTER TABLE `galleries_tags` rename to `_galleries_tags_old`;\nALTER TABLE `performers_galleries` rename to `_performers_galleries_old`;\n\nCREATE TABLE `galleries` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510),\n  `checksum` varchar(255) not null,\n  `zip` boolean not null default '0',\n  `title` varchar(255),\n  `url` varchar(255),\n  `date` date,\n  `details` text,\n  `studio_id` integer,\n  `rating` tinyint,\n  `file_mod_time` datetime,\n  `organized` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n);\n\nDROP INDEX IF EXISTS `index_galleries_on_scene_id`;\nDROP INDEX IF EXISTS `galleries_path_unique`;\nDROP INDEX IF EXISTS `galleries_checksum_unique`;\nDROP INDEX IF EXISTS `index_galleries_on_studio_id`;\n\nCREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`);\nCREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`);\nCREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`);\n\nCREATE TABLE `scenes_galleries` (\n  `scene_id` integer,\n  `gallery_id` integer,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_scenes_galleries_on_scene_id` on `scenes_galleries` (`scene_id`);\nCREATE INDEX `index_scenes_galleries_on_gallery_id` on `scenes_galleries` (`gallery_id`);\n\nCREATE TABLE `galleries_images` (\n  `gallery_id` integer,\n  `image_id` integer,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE\n);\n\nDROP INDEX IF EXISTS `index_galleries_images_on_image_id`;\nDROP INDEX IF EXISTS `index_galleries_images_on_gallery_id`;\n\nCREATE INDEX `index_galleries_images_on_image_id` on `galleries_images` (`image_id`);\nCREATE INDEX `index_galleries_images_on_gallery_id` on `galleries_images` (`gallery_id`);\n\nCREATE TABLE `performers_galleries` (\n  `performer_id` integer,\n  `gallery_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE\n);\n\nDROP INDEX IF EXISTS `index_performers_galleries_on_gallery_id`;\nDROP INDEX IF EXISTS `index_performers_galleries_on_performer_id`;\n\nCREATE INDEX `index_performers_galleries_on_gallery_id` on `performers_galleries` (`gallery_id`);\nCREATE INDEX `index_performers_galleries_on_performer_id` on `performers_galleries` (`performer_id`);\n\nCREATE TABLE `galleries_tags` (\n  `gallery_id` integer,\n  `tag_id` integer,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);\n\nDROP INDEX IF EXISTS `index_galleries_tags_on_tag_id`;\nDROP INDEX IF EXISTS `index_galleries_tags_on_gallery_id`;\n\nCREATE INDEX `index_galleries_tags_on_tag_id` on `galleries_tags` (`tag_id`);\nCREATE INDEX `index_galleries_tags_on_gallery_id` on `galleries_tags` (`gallery_id`);\n\n-- populate from the old tables\nINSERT INTO `galleries`\n  (\n    `id`,\n    `path`,\n    `checksum`,\n    `zip`,\n    `title`,\n    `url`,\n    `date`,\n    `details`,\n    `studio_id`,\n    `rating`,\n    `file_mod_time`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `path`,\n    `checksum`,\n    `zip`,\n    `title`,\n    `url`,\n    `date`,\n    `details`,\n    `studio_id`,\n    `rating`,\n    `file_mod_time`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  FROM `_galleries_old`;\n\nINSERT INTO `scenes_galleries`\n  (\n    `scene_id`,\n    `gallery_id`\n  )\n  SELECT\n    `scene_id`,\n    `id`\n  FROM `_galleries_old`\n  WHERE scene_id IS NOT NULL;\n\n-- these tables are a direct copy\nINSERT INTO `galleries_images` SELECT * from `_galleries_images_old`;\nINSERT INTO `galleries_tags` SELECT * from `_galleries_tags_old`;\nINSERT INTO `performers_galleries` SELECT * from `_performers_galleries_old`;\n\n-- drop old tables\nDROP TABLE `_galleries_old`;\nDROP TABLE `_galleries_images_old`;\nDROP TABLE `_galleries_tags_old`;\nDROP TABLE `_performers_galleries_old`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/19_performer_tags.up.sql",
    "content": "CREATE TABLE `performers_tags` (\n  `performer_id` integer NOT NULL,\n  `tag_id` integer NOT NULL,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_performers_tags_on_tag_id` on `performers_tags` (`tag_id`);\nCREATE INDEX `index_performers_tags_on_performer_id` on `performers_tags` (`performer_id`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/1_initial.down.sql",
    "content": "DROP TABLE IF EXISTS scenes;"
  },
  {
    "path": "pkg/sqlite/migrations/1_initial.up.sql",
    "content": "CREATE TABLE `tags` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(255),\n  `created_at` datetime not null,\n  `updated_at` datetime not null\n);\nCREATE TABLE `studios` (\n  `id` integer not null primary key autoincrement,\n  `image` blob not null,\n  `checksum` varchar(255) not null,\n  `name` varchar(255),\n  `url` varchar(255),\n  `created_at` datetime not null,\n  `updated_at` datetime not null\n);\nCREATE TABLE `scraped_items` (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255),\n  `description` text,\n  `url` varchar(255),\n  `date` date,\n  `rating` varchar(255),\n  `tags` varchar(510),\n  `models` varchar(510),\n  `episode` integer,\n  `gallery_filename` varchar(255),\n  `gallery_url` varchar(510),\n  `video_filename` varchar(255),\n  `video_url` varchar(255),\n  `studio_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`)\n);\nCREATE TABLE `scenes_tags` (\n  `scene_id` integer,\n  `tag_id` integer,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`)\n);\nCREATE TABLE `scenes` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510) not null,\n  `checksum` varchar(255) not null,\n  `title` varchar(255),\n  `details` text,\n  `url` varchar(255),\n  `date` date,\n  `rating` tinyint,\n  `size` varchar(255),\n  `duration` float,\n  `video_codec` varchar(255),\n  `audio_codec` varchar(255),\n  `width` tinyint,\n  `height` tinyint,\n  `framerate` float,\n  `bitrate` integer,\n  `studio_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE\n);\nCREATE TABLE `scene_markers_tags` (\n  `scene_marker_id` integer,\n  `tag_id` integer,\n  foreign key(`scene_marker_id`) references `scene_markers`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`)\n);\nCREATE TABLE `scene_markers` (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255) not null,\n  `seconds` float not null,\n  `primary_tag_id` integer not null,\n  `scene_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`primary_tag_id`) references `tags`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\nCREATE TABLE `performers_scenes` (\n  `performer_id` integer,\n  `scene_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\nCREATE TABLE `performers` (\n  `id` integer not null primary key autoincrement,\n  `image` blob not null,\n  `checksum` varchar(255) not null,\n  `name` varchar(255),\n  `url` varchar(255),\n  `twitter` varchar(255),\n  `instagram` varchar(255),\n  `birthdate` date,\n  `ethnicity` varchar(255),\n  `country` varchar(255),\n  `eye_color` varchar(255),\n  `height` varchar(255),\n  `measurements` varchar(255),\n  `fake_tits` varchar(255),\n  `career_length` varchar(255),\n  `tattoos` varchar(255),\n  `piercings` varchar(255),\n  `aliases` varchar(255),\n  `favorite` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null\n);\nCREATE TABLE `galleries` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(510) not null,\n  `checksum` varchar(255) not null,\n  `scene_id` integer,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\nCREATE UNIQUE INDEX `studios_checksum_unique` on `studios` (`checksum`);\nCREATE UNIQUE INDEX `scenes_path_unique` on `scenes` (`path`);\nCREATE UNIQUE INDEX `scenes_checksum_unique` on `scenes` (`checksum`);\nCREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`);\nCREATE INDEX `index_tags_on_name` on `tags` (`name`);\nCREATE INDEX `index_studios_on_name` on `studios` (`name`);\nCREATE INDEX `index_studios_on_checksum` on `studios` (`checksum`);\nCREATE INDEX `index_scraped_items_on_studio_id` on `scraped_items` (`studio_id`);\nCREATE INDEX `index_scenes_tags_on_tag_id` on `scenes_tags` (`tag_id`);\nCREATE INDEX `index_scenes_tags_on_scene_id` on `scenes_tags` (`scene_id`);\nCREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`);\nCREATE INDEX `index_scene_markers_tags_on_tag_id` on `scene_markers_tags` (`tag_id`);\nCREATE INDEX `index_scene_markers_tags_on_scene_marker_id` on `scene_markers_tags` (`scene_marker_id`);\nCREATE INDEX `index_scene_markers_on_scene_id` on `scene_markers` (`scene_id`);\nCREATE INDEX `index_scene_markers_on_primary_tag_id` on `scene_markers` (`primary_tag_id`);\nCREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`);\nCREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`);\nCREATE INDEX `index_performers_on_name` on `performers` (`name`);\nCREATE INDEX `index_performers_on_checksum` on `performers` (`checksum`);\nCREATE INDEX `index_galleries_on_scene_id` on `galleries` (`scene_id`);\nCREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`);\nCREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/20_phash.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `phash` blob;\n"
  },
  {
    "path": "pkg/sqlite/migrations/21_performers_studios_details.up.sql",
    "content": "ALTER TABLE `performers` ADD COLUMN `details` text;\nALTER TABLE `performers` ADD COLUMN `death_date` date;\nALTER TABLE `performers` ADD COLUMN `hair_color` varchar(255);\nALTER TABLE `performers` ADD COLUMN `weight` integer;\nALTER TABLE `studios` ADD COLUMN `details` text;"
  },
  {
    "path": "pkg/sqlite/migrations/22_performers_studios_rating.up.sql",
    "content": "ALTER TABLE `performers` ADD COLUMN `rating` tinyint;\nALTER TABLE `studios` ADD COLUMN `rating` tinyint;\n"
  },
  {
    "path": "pkg/sqlite/migrations/23_scenes_interactive.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `interactive` boolean not null default '0';\n"
  },
  {
    "path": "pkg/sqlite/migrations/24_tag_aliases.up.sql",
    "content": "CREATE TABLE `tag_aliases` (\n  `tag_id` integer,\n  `alias` varchar(255) NOT NULL,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);\n\nCREATE UNIQUE INDEX `tag_aliases_alias_unique` on `tag_aliases` (`alias`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/25_saved_filters.up.sql",
    "content": "CREATE TABLE `saved_filters` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(510) not null,\n  `mode` varchar(255) not null,\n  `filter` blob not null\n);\n\nCREATE UNIQUE INDEX `index_saved_filters_on_mode_name_unique` on `saved_filters` (`mode`, `name`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/26_tag_hierarchy.up.sql",
    "content": "CREATE TABLE tags_relations (\n  parent_id integer,\n  child_id integer,\n  primary key (parent_id, child_id),\n  foreign key (parent_id) references tags(id) on delete cascade,\n  foreign key (child_id) references tags(id) on delete cascade\n);\n"
  },
  {
    "path": "pkg/sqlite/migrations/27_studio_aliases.up.sql",
    "content": "CREATE TABLE `studio_aliases` (\n  `studio_id` integer,\n  `alias` varchar(255) NOT NULL,\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE\n);\n\nCREATE UNIQUE INDEX `studio_aliases_alias_unique` on `studio_aliases` (`alias`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/28_images_indexes.up.sql",
    "content": "DROP INDEX IF EXISTS `images_path_unique`;\n\nCREATE UNIQUE INDEX `images_path_unique` ON `images` (`path`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/29_interactive_speed.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `interactive_speed` int \n"
  },
  {
    "path": "pkg/sqlite/migrations/2_cover_image.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `cover` blob;"
  },
  {
    "path": "pkg/sqlite/migrations/30_ignore_autotag.up..sql",
    "content": "ALTER TABLE `performers` ADD COLUMN `ignore_auto_tag` boolean not null default '0'; \nALTER TABLE `studios` ADD COLUMN `ignore_auto_tag` boolean not null default '0';\nALTER TABLE `tags` ADD COLUMN `ignore_auto_tag` boolean not null default '0';"
  },
  {
    "path": "pkg/sqlite/migrations/31_scenes_captions.up.sql",
    "content": "CREATE TABLE `scene_captions` (\n  `scene_id` integer,\n  `language_code` varchar(255) NOT NULL,\n  `filename` varchar(255) NOT NULL,\n  `caption_type` varchar(255) NOT NULL,\n  primary key (`scene_id`, `language_code`, `caption_type`),\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n"
  },
  {
    "path": "pkg/sqlite/migrations/32_files.up.sql",
    "content": "-- folders may be deleted independently. Don't cascade\nCREATE TABLE `folders` (\n  `id` integer not null primary key autoincrement,\n  `path` varchar(255) NOT NULL,\n  `parent_folder_id` integer,\n  `mod_time` datetime not null,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`parent_folder_id`) references `folders`(`id`) on delete SET NULL\n);\n\nCREATE INDEX `index_folders_on_parent_folder_id` on `folders` (`parent_folder_id`);\nCREATE UNIQUE INDEX `index_folders_on_path_unique` on `folders` (`path`);\n\n-- require reference folders/zip files to be deleted manually first\nCREATE TABLE `files` (\n  `id` integer not null primary key autoincrement,\n  `basename` varchar(255) NOT NULL,\n  `zip_file_id` integer,\n  `parent_folder_id` integer not null,\n  `size` integer NOT NULL,\n  `mod_time` datetime not null,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`parent_folder_id`) references `folders`(`id`),\n  foreign key(`zip_file_id`) references `files`(`id`),\n  CHECK (`basename` != '')\n);\n\nCREATE UNIQUE INDEX `index_files_zip_basename_unique` ON `files` (`zip_file_id`, `parent_folder_id`, `basename`) WHERE `zip_file_id` IS NOT NULL;\nCREATE UNIQUE INDEX `index_files_on_parent_folder_id_basename_unique` on `files` (`parent_folder_id`, `basename`);\nCREATE INDEX `index_files_on_basename` on `files` (`basename`);\n\nALTER TABLE `folders` ADD COLUMN `zip_file_id` integer REFERENCES `files`(`id`);\nCREATE INDEX `index_folders_on_zip_file_id` on `folders` (`zip_file_id`) WHERE `zip_file_id` IS NOT NULL;\n\nCREATE TABLE `files_fingerprints` (\n  `file_id` integer NOT NULL,\n  `type` varchar(255) NOT NULL,\n  `fingerprint` blob NOT NULL,\n  foreign key(`file_id`) references `files`(`id`) on delete CASCADE,\n  PRIMARY KEY (`file_id`, `type`, `fingerprint`)\n);\n\nCREATE INDEX `index_fingerprint_type_fingerprint` ON `files_fingerprints` (`type`, `fingerprint`);\n\nCREATE TABLE `video_files` (\n  `file_id` integer NOT NULL primary key,\n  `duration` float NOT NULL,\n\t`video_codec` varchar(255) NOT NULL,\n\t`format` varchar(255) NOT NULL,\n\t`audio_codec` varchar(255) NOT NULL,\n\t`width` tinyint NOT NULL,\n\t`height` tinyint NOT NULL,\n\t`frame_rate` float NOT NULL,\n\t`bit_rate` integer NOT NULL,\n  `interactive` boolean not null default '0',\n  `interactive_speed` int,\n  foreign key(`file_id`) references `files`(`id`) on delete CASCADE\n);\n\nCREATE TABLE `video_captions` (\n  `file_id` integer NOT NULL,\n  `language_code` varchar(255) NOT NULL,\n  `filename` varchar(255) NOT NULL,\n  `caption_type` varchar(255) NOT NULL,\n  primary key (`file_id`, `language_code`, `caption_type`),\n  foreign key(`file_id`) references `video_files`(`file_id`) on delete CASCADE\n);\n\nCREATE TABLE `image_files` (\n  `file_id` integer NOT NULL primary key,\n  `format` varchar(255) NOT NULL,\n  `width` tinyint NOT NULL,\n\t`height` tinyint NOT NULL,\n  foreign key(`file_id`) references `files`(`id`) on delete CASCADE\n);\n\nCREATE TABLE `images_files` (\n    `image_id` integer NOT NULL,\n    `file_id` integer NOT NULL,\n    `primary` boolean NOT NULL,\n    foreign key(`image_id`) references `images`(`id`) on delete CASCADE,\n    foreign key(`file_id`) references `files`(`id`) on delete CASCADE,\n    PRIMARY KEY(`image_id`, `file_id`)\n);\n\nCREATE INDEX `index_images_files_on_file_id` on `images_files` (`file_id`);\nCREATE UNIQUE INDEX `unique_index_images_files_on_primary` on `images_files` (`image_id`) WHERE `primary` = 1;\n\nCREATE TABLE `galleries_files` (\n    `gallery_id` integer NOT NULL,\n    `file_id` integer NOT NULL,\n    `primary` boolean NOT NULL,\n    foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n    foreign key(`file_id`) references `files`(`id`) on delete CASCADE,\n    PRIMARY KEY(`gallery_id`, `file_id`)\n);\n\nCREATE INDEX `index_galleries_files_file_id` ON `galleries_files` (`file_id`);\nCREATE UNIQUE INDEX `unique_index_galleries_files_on_primary` on `galleries_files` (`gallery_id`) WHERE `primary` = 1;\n\nCREATE TABLE `scenes_files` (\n    `scene_id` integer NOT NULL,\n    `file_id` integer NOT NULL,\n    `primary` boolean NOT NULL,\n    foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n    foreign key(`file_id`) references `files`(`id`) on delete CASCADE,\n    PRIMARY KEY(`scene_id`, `file_id`)\n);\n\nCREATE INDEX `index_scenes_files_file_id` ON `scenes_files` (`file_id`);\nCREATE UNIQUE INDEX `unique_index_scenes_files_on_primary` on `scenes_files` (`scene_id`) WHERE `primary` = 1;\n\nPRAGMA foreign_keys=OFF;\n\nCREATE TABLE `images_new` (\n  `id` integer not null primary key autoincrement,\n  -- REMOVED: `path` varchar(510) not null,\n  -- REMOVED: `checksum` varchar(255) not null,\n  `title` varchar(255),\n  `rating` tinyint,\n  -- REMOVED: `size` integer,\n  -- REMOVED: `width` tinyint,\n  -- REMOVED: `height` tinyint,\n  `studio_id` integer,\n  `o_counter` tinyint not null default 0,\n  `organized` boolean not null default '0',\n  -- REMOVED: `file_mod_time` datetime,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n);\n\nINSERT INTO `images_new`\n  (\n    `id`,\n    `title`,\n    `rating`,\n    `studio_id`,\n    `o_counter`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `title`,\n    `rating`,\n    `studio_id`,\n    `o_counter`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  FROM `images`;\n\n-- create temporary placeholder folder\nINSERT INTO `folders` (`path`, `mod_time`, `created_at`, `updated_at`) VALUES ('', '1970-01-01 00:00:00', '1970-01-01 00:00:00', '1970-01-01 00:00:00');\n\n-- insert image files - we will fix these up in the post-migration\nINSERT INTO `files`\n  (\n    `basename`,\n    `parent_folder_id`,\n    `size`,\n    `mod_time`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT\n    `path`,\n    1,\n    -- special value if null so that it is recalculated\n    COALESCE(`size`, -1),\n    COALESCE(`file_mod_time`, '1970-01-01 00:00:00'),\n    `created_at`,\n    `updated_at`\n  FROM `images`;\n\nINSERT INTO `image_files`\n  (\n    `file_id`,\n    `format`,\n    `width`,\n    `height`\n  )\n  SELECT\n    `files`.`id`,\n    -- special values so that they are recalculated\n    'unset',\n    COALESCE(`images`.`width`, -1),\n    COALESCE(`images`.`height`, -1)\n  FROM `images` INNER JOIN `files` ON `images`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1;\n\nINSERT INTO `images_files`\n  (\n    `image_id`,\n    `file_id`,\n    `primary`\n  )\n  SELECT\n    `images`.`id`,\n    `files`.`id`,\n    1\n  FROM `images` INNER JOIN `files` ON `images`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1;\n\nINSERT INTO `files_fingerprints`\n  (\n    `file_id`,\n    `type`,\n    `fingerprint`\n  )\n  SELECT\n    `files`.`id`,\n    'md5',\n    `images`.`checksum`\n  FROM `images` INNER JOIN `files` ON `images`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1;\n\nDROP TABLE `images`;\nALTER TABLE `images_new` rename to `images`;\n\nCREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`);\n\n\nCREATE TABLE `galleries_new` (\n  `id` integer not null primary key autoincrement,\n  -- REMOVED: `path` varchar(510),\n  -- REMOVED: `checksum` varchar(255) not null,\n  -- REMOVED: `zip` boolean not null default '0',\n  `folder_id` integer,\n  `title` varchar(255),\n  `url` varchar(255),\n  `date` date,\n  `details` text,\n  `studio_id` integer,\n  `rating` tinyint,\n  -- REMOVED: `file_mod_time` datetime,\n  `organized` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL,\n  foreign key(`folder_id`) references `folders`(`id`) on delete SET NULL\n);\n\nINSERT INTO `galleries_new`\n  (\n    `id`,\n    `title`,\n    `url`,\n    `date`,\n    `details`,\n    `studio_id`,\n    `rating`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `title`,\n    `url`,\n    `date`,\n    `details`,\n    `studio_id`,\n    `rating`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  FROM `galleries`;\n\n-- insert gallery files - we will fix these up in the post-migration\nINSERT INTO `files`\n  (\n    `basename`,\n    `parent_folder_id`,\n    `size`,\n    `mod_time`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT\n    `path`,\n    1,\n    -- special value so that it is recalculated\n    -1,\n    COALESCE(`file_mod_time`, '1970-01-01 00:00:00'),\n    `created_at`,\n    `updated_at`\n  FROM `galleries`\n  WHERE `galleries`.`path` IS NOT NULL AND `galleries`.`zip` = '1';\n\n-- insert gallery zip folders - we will fix these up in the post-migration\nINSERT INTO `folders`\n  (\n    `path`,\n    `zip_file_id`,\n    `mod_time`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT\n    `galleries`.`path`,\n    `files`.`id`,\n    '1970-01-01 00:00:00',\n    `galleries`.`created_at`,\n    `galleries`.`updated_at`\n  FROM `galleries` \n  INNER JOIN `files` ON `galleries`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1\n  WHERE `galleries`.`path` IS NOT NULL AND `galleries`.`zip` = '1';\n\n-- set the zip file id of the zip folders\nUPDATE `folders` SET `zip_file_id` = (SELECT `files`.`id` FROM `files` WHERE `folders`.`path` = `files`.`basename`); \n\n-- insert gallery folders - we will fix these up in the post-migration\nINSERT INTO `folders`\n  (\n    `path`,\n    `mod_time`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT\n    `path`,\n    '1970-01-01 00:00:00',\n    `created_at`,\n    `updated_at`\n  FROM `galleries`\n  WHERE `galleries`.`path` IS NOT NULL AND `galleries`.`zip` = '0';\n\nUPDATE `galleries_new` SET `folder_id` = (\n  SELECT `folders`.`id` FROM `folders` INNER JOIN `galleries` ON `galleries_new`.`id` = `galleries`.`id` WHERE `folders`.`path` = `galleries`.`path` AND `galleries`.`zip` = '0'\n);\n\nINSERT INTO `galleries_files`\n  (\n    `gallery_id`,\n    `file_id`,\n    `primary`\n  )\n  SELECT\n    `galleries`.`id`,\n    `files`.`id`,\n    1\n  FROM `galleries` INNER JOIN `files` ON `galleries`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1;\n\nINSERT INTO `files_fingerprints`\n  (\n    `file_id`,\n    `type`,\n    `fingerprint`\n  )\n  SELECT\n    `files`.`id`,\n    'md5',\n    `galleries`.`checksum`\n  FROM `galleries` INNER JOIN `files` ON `galleries`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1;\n\nDROP TABLE `galleries`;\nALTER TABLE `galleries_new` rename to `galleries`;\n\nCREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`);\n-- should only be possible to create a single gallery per folder\nCREATE UNIQUE INDEX `index_galleries_on_folder_id_unique` on `galleries` (`folder_id`);\n\nCREATE TABLE `scenes_new` (\n  `id` integer not null primary key autoincrement,\n  -- REMOVED: `path` varchar(510) not null,\n  -- REMOVED: `checksum` varchar(255),\n  -- REMOVED: `oshash` varchar(255),\n  `title` varchar(255),\n  `details` text,\n  `url` varchar(255),\n  `date` date,\n  `rating` tinyint,\n  -- REMOVED: `size` varchar(255),\n  -- REMOVED: `duration` float,\n  -- REMOVED: `video_codec` varchar(255),\n  -- REMOVED: `audio_codec` varchar(255),\n  -- REMOVED: `width` tinyint,\n  -- REMOVED: `height` tinyint,\n  -- REMOVED: `framerate` float,\n  -- REMOVED: `bitrate` integer,\n  `studio_id` integer,\n  `o_counter` tinyint not null default 0,\n  -- REMOVED: `format` varchar(255),\n  `organized` boolean not null default '0',\n  -- REMOVED: `interactive` boolean not null default '0',\n  -- REMOVED: `interactive_speed` int,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  -- REMOVED: `file_mod_time` datetime,\n  -- REMOVED: `phash` blob,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n  -- REMOVED: CHECK (`checksum` is not null or `oshash` is not null)\n);\n\nINSERT INTO `scenes_new`\n  (\n    `id`,\n    `title`,\n    `details`,\n    `url`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `o_counter`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `title`,\n    `details`,\n    `url`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `o_counter`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  FROM `scenes`;\n\n-- insert scene files - we will fix these up in the post-migration\nINSERT INTO `files`\n  (\n    `basename`,\n    `parent_folder_id`,\n    `size`,\n    `mod_time`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT\n    `path`,\n    1,\n    -- special value if null so that it is recalculated\n    COALESCE(`size`, -1),\n    COALESCE(`file_mod_time`, '1970-01-01 00:00:00'),\n    `created_at`,\n    `updated_at`\n  FROM `scenes`;\n\nINSERT INTO `video_files`\n  (\n    `file_id`,\n    `duration`,\n    `video_codec`,\n    `format`,\n    `audio_codec`,\n    `width`,\n    `height`,\n    `frame_rate`,\n    `bit_rate`,\n    `interactive`,\n    `interactive_speed`\n  )\n  SELECT\n    `files`.`id`,\n    COALESCE(`scenes`.`duration`, -1),\n    -- special values for unset to be updated during scan\n    COALESCE(`scenes`.`video_codec`, 'unset'),\n    COALESCE(`scenes`.`format`, 'unset'),\n    COALESCE(`scenes`.`audio_codec`, 'unset'),\n    COALESCE(`scenes`.`width`, -1),\n    COALESCE(`scenes`.`height`, -1),\n    COALESCE(`scenes`.`framerate`, -1),\n    COALESCE(`scenes`.`bitrate`, -1),\n    `scenes`.`interactive`,\n    `scenes`.`interactive_speed`\n  FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1;\n\nINSERT INTO `scenes_files`\n  (\n    `scene_id`,\n    `file_id`,\n    `primary`\n  )\n  SELECT\n    `scenes`.`id`,\n    `files`.`id`,\n    1\n  FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1;\n\nINSERT INTO `files_fingerprints`\n  (\n    `file_id`,\n    `type`,\n    `fingerprint`\n  )\n  SELECT\n    `files`.`id`,\n    'md5',\n    `scenes`.`checksum`\n  FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1\n  WHERE `scenes`.`checksum` is not null;\n\nINSERT INTO `files_fingerprints`\n  (\n    `file_id`,\n    `type`,\n    `fingerprint`\n  )\n  SELECT\n    `files`.`id`,\n    'oshash',\n    `scenes`.`oshash`\n  FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1\n  WHERE `scenes`.`oshash` is not null;\n\nINSERT INTO `files_fingerprints`\n  (\n    `file_id`,\n    `type`,\n    `fingerprint`\n  )\n  SELECT\n    `files`.`id`,\n    'phash',\n    `scenes`.`phash`\n  FROM `scenes` INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1\n  WHERE `scenes`.`phash` is not null;\n\nINSERT INTO `video_captions`\n  (\n    `file_id`,\n    `language_code`,\n    `filename`,\n    `caption_type`\n  )\n  SELECT\n    `files`.`id`,\n    `scene_captions`.`language_code`,\n    `scene_captions`.`filename`,\n    `scene_captions`.`caption_type`\n  FROM `scene_captions` \n  INNER JOIN `scenes` ON `scene_captions`.`scene_id` = `scenes`.`id`\n  INNER JOIN `files` ON `scenes`.`path` = `files`.`basename` AND `files`.`parent_folder_id` = 1;\n\nDROP TABLE `scenes`;\nDROP TABLE `scene_captions`;\n\nALTER TABLE `scenes_new` rename to `scenes`;\nCREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`);\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/32_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\t\"gopkg.in/guregu/null.v4\"\n)\n\nconst legacyZipSeparator = \"\\x00\"\n\nfunc post32(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 32\")\n\n\tm := schema32Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t\tfolderCache: make(map[string]folderInfo),\n\t}\n\n\tif err := m.migrateFolders(ctx); err != nil {\n\t\treturn fmt.Errorf(\"migrating folders: %w\", err)\n\t}\n\n\tif err := m.migrateFiles(ctx); err != nil {\n\t\treturn fmt.Errorf(\"migrating files: %w\", err)\n\t}\n\n\tif err := m.deletePlaceholderFolder(ctx); err != nil {\n\t\treturn fmt.Errorf(\"deleting placeholder folder: %w\", err)\n\t}\n\n\treturn nil\n}\n\ntype folderInfo struct {\n\tid    int\n\tzipID sql.NullInt64\n}\n\ntype schema32Migrator struct {\n\tmigrator\n\tfolderCache map[string]folderInfo\n}\n\nfunc (m *schema32Migrator) migrateFolders(ctx context.Context) error {\n\tlogger.Infof(\"Migrating folders\")\n\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tlastID := 0\n\tcount := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := \"SELECT `folders`.`id`, `folders`.`path` FROM `folders` INNER JOIN `galleries` ON `galleries`.`folder_id` = `folders`.`id`\"\n\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\"AND `folders`.`id` > %d \", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\"ORDER BY `folders`.`id` LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tvar id int\n\t\t\t\tvar p string\n\n\t\t\t\terr := rows.Scan(&id, &p)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\tcount++\n\n\t\t\t\tparent := filepath.Dir(p)\n\t\t\t\tparentID, zipFileID, err := m.createFolderHierarchy(tx, parent)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t_, err = tx.Exec(\"UPDATE `folders` SET `parent_folder_id` = ?, `zip_file_id` = ? WHERE `id` = ?\", parentID, zipFileID, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d folders\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema32Migrator) migrateFiles(ctx context.Context) error {\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tresult := struct {\n\t\tCount int `db:\"count\"`\n\t}{0}\n\n\tif err := m.db.Get(&result, \"SELECT COUNT(*) AS count FROM `files`\"); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Infof(\"Migrating %d files...\", result.Count)\n\n\tlastID := 0\n\tcount := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\t// using offset for this is slow. Save the last id and filter by that instead\n\t\tquery := \"SELECT `id`, `basename` FROM `files` \"\n\t\tif lastID != 0 {\n\t\t\tquery += fmt.Sprintf(\"WHERE `id` > %d \", lastID)\n\t\t}\n\n\t\tquery += fmt.Sprintf(\"ORDER BY `id` LIMIT %d\", limit)\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tgotSome = true\n\n\t\t\t\tvar id int\n\t\t\t\tvar p string\n\n\t\t\t\terr := rows.Scan(&id, &p)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif strings.Contains(p, legacyZipSeparator) {\n\t\t\t\t\t// remove any null characters from the path\n\t\t\t\t\tp = strings.ReplaceAll(p, legacyZipSeparator, string(filepath.Separator))\n\t\t\t\t}\n\n\t\t\t\tparent := filepath.Dir(p)\n\t\t\t\tbasename := filepath.Base(p)\n\t\t\t\tif parent != \".\" {\n\t\t\t\t\tparentID, zipFileID, err := m.createFolderHierarchy(tx, parent)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\n\t\t\t\t\t_, err = tx.Exec(\"UPDATE `files` SET `parent_folder_id` = ?, `zip_file_id` = ?, `basename` = ? WHERE `id` = ?\", parentID, zipFileID, basename, id)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"migrating file %s: %w\", p, err)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// if we don't reassign from the placeholder, it will fail\n\t\t\t\t\t// so log a warning at least here\n\t\t\t\t\tlogger.Warnf(\"Unable to migrate invalid path: %s\", p)\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tcount++\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d files\", count)\n\n\t\t\t// manual checkpoint to flush wal file\n\t\t\tif _, err := m.db.Exec(\"PRAGMA wal_checkpoint(FULL)\"); err != nil {\n\t\t\t\treturn fmt.Errorf(\"running wal checkpoint: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tlogger.Infof(\"Finished migrating files\")\n\n\treturn nil\n}\n\nfunc (m *schema32Migrator) deletePlaceholderFolder(ctx context.Context) error {\n\t// only delete the placeholder folder if no files/folders are attached to it\n\tresult := struct {\n\t\tCount int `db:\"count\"`\n\t}{0}\n\n\tif err := m.db.Get(&result, \"SELECT COUNT(*) AS count FROM `files` WHERE `parent_folder_id` = 1\"); err != nil {\n\t\treturn err\n\t}\n\n\tif result.Count > 0 {\n\t\treturn fmt.Errorf(\"not deleting placeholder folder because it has %d files\", result.Count)\n\t}\n\n\tresult.Count = 0\n\n\tif err := m.db.Get(&result, \"SELECT COUNT(*) AS count FROM `folders` WHERE `parent_folder_id` = 1\"); err != nil {\n\t\treturn err\n\t}\n\n\tif result.Count > 0 {\n\t\treturn fmt.Errorf(\"not deleting placeholder folder because it has %d folders\", result.Count)\n\t}\n\n\treturn m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.Exec(\"DELETE FROM `folders` WHERE `id` = 1\")\n\t\treturn err\n\t})\n}\n\nfunc (m *schema32Migrator) createFolderHierarchy(tx *sqlx.Tx, p string) (*int, sql.NullInt64, error) {\n\tparent := filepath.Dir(p)\n\n\tif parent == p {\n\t\t// get or create this folder\n\t\treturn m.getOrCreateFolder(tx, p, nil, sql.NullInt64{})\n\t}\n\n\tvar (\n\t\tparentID  *int\n\t\tzipFileID sql.NullInt64\n\t\terr       error\n\t)\n\n\t// try to find parent folder in cache first\n\tfoundEntry, ok := m.folderCache[parent]\n\tif ok {\n\t\tparentID = &foundEntry.id\n\t\tzipFileID = foundEntry.zipID\n\t} else {\n\t\tparentID, zipFileID, err = m.createFolderHierarchy(tx, parent)\n\t\tif err != nil {\n\t\t\treturn nil, sql.NullInt64{}, err\n\t\t}\n\t}\n\n\treturn m.getOrCreateFolder(tx, p, parentID, zipFileID)\n}\n\nfunc (m *schema32Migrator) getOrCreateFolder(tx *sqlx.Tx, path string, parentID *int, zipFileID sql.NullInt64) (*int, sql.NullInt64, error) {\n\tfoundEntry, ok := m.folderCache[path]\n\tif ok {\n\t\treturn &foundEntry.id, foundEntry.zipID, nil\n\t}\n\n\tconst query = \"SELECT `id`, `zip_file_id` FROM `folders` WHERE `path` = ?\"\n\trows, err := tx.Query(query, path)\n\tif err != nil {\n\t\treturn nil, sql.NullInt64{}, err\n\t}\n\tdefer rows.Close()\n\n\tif rows.Next() {\n\t\tvar id int\n\t\tvar zfid sql.NullInt64\n\t\terr := rows.Scan(&id, &zfid)\n\t\tif err != nil {\n\t\t\treturn nil, sql.NullInt64{}, err\n\t\t}\n\n\t\treturn &id, zfid, nil\n\t}\n\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, sql.NullInt64{}, err\n\t}\n\n\tconst insertSQL = \"INSERT INTO `folders` (`path`,`parent_folder_id`,`zip_file_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)\"\n\n\tvar parentFolderID null.Int\n\tif parentID != nil {\n\t\tparentFolderID = null.IntFrom(int64(*parentID))\n\t}\n\n\tnow := time.Now()\n\tresult, err := tx.Exec(insertSQL, path, parentFolderID, zipFileID, time.Time{}, now, now)\n\tif err != nil {\n\t\treturn nil, sql.NullInt64{}, fmt.Errorf(\"creating folder %s: %w\", path, err)\n\t}\n\n\tid, err := result.LastInsertId()\n\tif err != nil {\n\t\treturn nil, sql.NullInt64{}, fmt.Errorf(\"creating folder %s: %w\", path, err)\n\t}\n\n\tidInt := int(id)\n\n\tm.folderCache[path] = folderInfo{id: idInt, zipID: zipFileID}\n\n\treturn &idInt, zipFileID, nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(32, post32)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/32_premigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\nfunc pre32(ctx context.Context, db *sqlx.DB) error {\n\t// verify that folder-based galleries (those with zip = 0 and path is not null) are\n\t// not zip-based. If they are zip based then set zip to 1\n\t// we could still miss some if the path does not exist, but this is the best we can do\n\n\tlogger.Info(\"Running pre-migration for schema version 32\")\n\n\tmm := schema32PreMigrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn mm.migrate(ctx)\n}\n\ntype schema32PreMigrator struct {\n\tmigrator\n}\n\nfunc (m *schema32PreMigrator) migrate(ctx context.Context) error {\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\t// query for galleries with zip = 0 and path not null\n\tresult := struct {\n\t\tCount int `db:\"count\"`\n\t}{0}\n\n\tif err := m.db.Get(&result, \"SELECT COUNT(*) AS count FROM `galleries` WHERE `zip` = '0' AND `path` IS NOT NULL\"); err != nil {\n\t\treturn err\n\t}\n\n\tif result.Count == 0 {\n\t\treturn nil\n\t}\n\n\tlogger.Infof(\"Checking %d galleries for incorrect zip value...\", result.Count)\n\n\tlastID := 0\n\tcount := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := \"SELECT `id`, `path` FROM `galleries` WHERE `zip` = '0' AND `path` IS NOT NULL \"\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\"AND `id` > %d \", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\"ORDER BY `id` LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tvar id int\n\t\t\t\tvar p string\n\n\t\t\t\terr := rows.Scan(&id, &p)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tgotSome = true\n\t\t\t\tlastID = id\n\t\t\t\tcount++\n\n\t\t\t\t// if path does not exist, make no changes\n\t\t\t\t// if it does exist and is a folder, then we ignore it\n\t\t\t\t// otherwise set zip to 1\n\t\t\t\tinfo, err := os.Stat(p)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Warnf(\"unable to verify if %q is a folder due to error %v. Assuming folder-based.\", p, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif info.IsDir() {\n\t\t\t\t\t// ignore it\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tlogger.Infof(\"Correcting %q gallery to be zip-based.\", p)\n\n\t\t\t\t_, err = tx.Exec(\"UPDATE `galleries` SET `zip` = '1' WHERE `id` = ?\", id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Checked %d galleries\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPreMigration(32, pre32)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/33_noop.up.sql",
    "content": "-- no schema changes"
  },
  {
    "path": "pkg/sqlite/migrations/34_indexes.up.sql",
    "content": "CREATE INDEX `index_performer_stash_ids_on_performer_id` ON `performer_stash_ids` (`performer_id`);\nCREATE INDEX `index_scene_stash_ids_on_scene_id` ON `scene_stash_ids` (`scene_id`);\nCREATE INDEX `index_studio_stash_ids_on_studio_id` ON `studio_stash_ids` (`studio_id`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/34_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\ntype schema34Migrator struct {\n\tmigrator\n}\n\nfunc post34(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 34\")\n\n\tm := schema34Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\tobjectCols := []string{\n\t\t\"created_at\",\n\t\t\"updated_at\",\n\t}\n\n\tfilesystemCols := objectCols\n\tfilesystemCols = append(filesystemCols, \"mod_time\")\n\n\tif err := m.migrateObjects(ctx, \"scenes\", objectCols); err != nil {\n\t\treturn fmt.Errorf(\"migrating scenes: %w\", err)\n\t}\n\tif err := m.migrateObjects(ctx, \"images\", objectCols); err != nil {\n\t\treturn fmt.Errorf(\"migrating images: %w\", err)\n\t}\n\tif err := m.migrateObjects(ctx, \"galleries\", objectCols); err != nil {\n\t\treturn fmt.Errorf(\"migrating galleries: %w\", err)\n\t}\n\tif err := m.migrateObjects(ctx, \"files\", filesystemCols); err != nil {\n\t\treturn fmt.Errorf(\"migrating files: %w\", err)\n\t}\n\tif err := m.migrateObjects(ctx, \"folders\", filesystemCols); err != nil {\n\t\treturn fmt.Errorf(\"migrating folders: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema34Migrator) migrateObjects(ctx context.Context, table string, cols []string) error {\n\tlogger.Infof(\"Migrating %s table\", table)\n\n\tquotedCols := make([]string, len(cols)+1)\n\tquotedCols[0] = \"`id`\"\n\twhereClauses := make([]string, len(cols))\n\tupdateClauses := make([]string, len(cols))\n\tfor i, v := range cols {\n\t\tquotedCols[i+1] = \"`\" + v + \"`\"\n\t\twhereClauses[i] = \"`\" + v + \"` like '% %'\"\n\t\tupdateClauses[i] = \"`\" + v + \"` = ?\"\n\t}\n\n\tcolList := strings.Join(quotedCols, \", \")\n\tclauseList := strings.Join(whereClauses, \" OR \")\n\tupdateList := strings.Join(updateClauses, \", \")\n\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tlastID := 0\n\tcount := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := fmt.Sprintf(\"SELECT %s FROM `%s` WHERE (%s)\", colList, table, clauseList)\n\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\" AND `id` > %d \", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\" ORDER BY `id` LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tvar (\n\t\t\t\t\tid int\n\t\t\t\t)\n\n\t\t\t\ttimeValues := make([]interface{}, len(cols)+1)\n\t\t\t\ttimeValues[0] = &id\n\t\t\t\tfor i := range cols {\n\t\t\t\t\tv := time.Time{}\n\t\t\t\t\ttimeValues[i+1] = &v\n\t\t\t\t}\n\n\t\t\t\terr := rows.Scan(timeValues...)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\tcount++\n\n\t\t\t\t// convert incorrect timestamp string to correct one\n\t\t\t\t// based on models.SQLTimestamp\n\t\t\t\targs := make([]interface{}, len(cols)+1)\n\t\t\t\tfor i := range cols {\n\t\t\t\t\ttv := timeValues[i+1].(*time.Time)\n\t\t\t\t\targs[i] = tv.Format(time.RFC3339)\n\t\t\t\t}\n\t\t\t\targs[len(cols)] = id\n\n\t\t\t\tupdateSQL := fmt.Sprintf(\"UPDATE `%s` SET %s WHERE `id` = ?\", table, updateList)\n\n\t\t\t\t_, err = tx.Exec(updateSQL, args...)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d rows\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(34, post34)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/35_assoc_tables.up.sql",
    "content": "-- add primary keys to association tables that are missing them\nPRAGMA foreign_keys=OFF;\n\nCREATE TABLE `performers_image_new` (\n  `performer_id` integer primary key,\n  `image` blob not null,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE\n);\n\nINSERT INTO `performers_image_new`\n  (\n    `performer_id`,\n    `image`\n  )\n  SELECT \n    `performer_id`,\n    `image`\n  FROM `performers_image` WHERE\n  `performer_id` IS NOT NULL;\n\nDROP TABLE `performers_image`;\nALTER TABLE `performers_image_new` rename to `performers_image`;\n\n-- the following index is removed in favour of primary key\n-- CREATE UNIQUE INDEX `index_performer_image_on_performer_id` on `performers_image` (`performer_id`);\n\n\nCREATE TABLE `studios_image_new` (\n  `studio_id` integer primary key,\n  `image` blob not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE\n);\n\nINSERT INTO `studios_image_new`\n  (\n    `studio_id`,\n    `image`\n  )\n  SELECT \n    `studio_id`,\n    `image`\n  FROM `studios_image` WHERE\n  `studio_id` IS NOT NULL;\n\nDROP TABLE `studios_image`;\nALTER TABLE `studios_image_new` rename to `studios_image`;\n\n-- the following index is removed in favour of primary key\n-- CREATE UNIQUE INDEX `index_studio_image_on_studio_id` on `studios_image` (`studio_id`);\n\n\nCREATE TABLE `movies_images_new` (\n  `movie_id` integer primary key,\n  `front_image` blob not null,\n  `back_image` blob,\n  foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE\n);\n\nINSERT INTO `movies_images_new`\n  (\n    `movie_id`,\n    `front_image`,\n    `back_image`\n  )\n  SELECT \n    `movie_id`,\n    `front_image`,\n    `back_image`\n  FROM `movies_images` WHERE\n  `movie_id` IS NOT NULL;\n\nDROP TABLE `movies_images`;\nALTER TABLE `movies_images_new` rename to `movies_images`;\n\n-- the following index is removed in favour of primary key\n-- CREATE UNIQUE INDEX `index_movie_images_on_movie_id` on `movies_images` (`movie_id`);\n\n\nCREATE TABLE `tags_image_new` (\n  `tag_id` integer primary key,\n  `image` blob not null,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);\n\nINSERT INTO `tags_image_new`\n  (\n    `tag_id`,\n    `image`\n  )\n  SELECT \n    `tag_id`,\n    `image`\n  FROM `tags_image` WHERE\n  `tag_id` IS NOT NULL;\n\nDROP TABLE `tags_image`;\nALTER TABLE `tags_image_new` rename to `tags_image`;\n\n-- the following index is removed in favour of primary key\n-- CREATE UNIQUE INDEX `index_tag_image_on_tag_id` on `tags_image` (`tag_id`);\n\n-- add on delete cascade to foreign keys\nCREATE TABLE `performers_scenes_new` (\n  `performer_id` integer,\n  `scene_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  PRIMARY KEY (`scene_id`, `performer_id`)\n);\n\nINSERT INTO `performers_scenes_new`\n  (\n    `performer_id`,\n    `scene_id`\n  )\n  SELECT \n    `performer_id`,\n    `scene_id`\n  FROM `performers_scenes` WHERE \n  `performer_id` IS NOT NULL AND `scene_id` IS NOT NULL\n  ON CONFLICT (`scene_id`, `performer_id`) DO NOTHING;\n\nDROP TABLE `performers_scenes`;\nALTER TABLE `performers_scenes_new` rename to `performers_scenes`;\n\nCREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`);\n\n\nCREATE TABLE `scene_markers_tags_new` (\n  `scene_marker_id` integer,\n  `tag_id` integer,\n  foreign key(`scene_marker_id`) references `scene_markers`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,\n  PRIMARY KEY(`scene_marker_id`, `tag_id`)\n);\n\nINSERT INTO `scene_markers_tags_new`\n  (\n    `scene_marker_id`,\n    `tag_id`\n  )\n  SELECT \n    `scene_marker_id`,\n    `tag_id`\n  FROM `scene_markers_tags` WHERE \n  `scene_marker_id` IS NOT NULL AND `tag_id` IS NOT NULL\n  ON CONFLICT (`scene_marker_id`, `tag_id`) DO NOTHING;\n\nDROP TABLE `scene_markers_tags`;\nALTER TABLE `scene_markers_tags_new` rename to `scene_markers_tags`;\n\nCREATE INDEX `index_scene_markers_tags_on_tag_id` on `scene_markers_tags` (`tag_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_scene_markers_tags_on_scene_marker_id` on `scene_markers_tags` (`scene_marker_id`);\n\n-- add delete cascade to tag_id\nCREATE TABLE `scenes_tags_new` (\n  `scene_id` integer,\n  `tag_id` integer,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,\n  PRIMARY KEY(`scene_id`, `tag_id`)\n);\n\nINSERT INTO `scenes_tags_new`\n  (\n    `scene_id`,\n    `tag_id`\n  )\n  SELECT \n    `scene_id`,\n    `tag_id`\n  FROM `scenes_tags` WHERE \n  `scene_id` IS NOT NULL AND `tag_id` IS NOT NULL\n  ON CONFLICT (`scene_id`, `tag_id`) DO NOTHING;\n\nDROP TABLE `scenes_tags`;\nALTER TABLE `scenes_tags_new` rename to `scenes_tags`;\n\nCREATE INDEX `index_scenes_tags_on_tag_id` on `scenes_tags` (`tag_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_scenes_tags_on_scene_id` on `scenes_tags` (`scene_id`);\n\n\nCREATE TABLE `movies_scenes_new` (\n  `movie_id` integer,\n  `scene_id` integer,\n  `scene_index` tinyint,\n  foreign key(`movie_id`) references `movies`(`id`) on delete cascade,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete cascade,\n  PRIMARY KEY(`movie_id`, `scene_id`)\n);\n\nINSERT INTO `movies_scenes_new`\n  (\n    `movie_id`,\n    `scene_id`,\n    `scene_index`\n  )\n  SELECT \n    `movie_id`,\n    `scene_id`,\n    `scene_index`\n  FROM `movies_scenes` WHERE \n  `movie_id` IS NOT NULL AND `scene_id` IS NOT NULL\n  ON CONFLICT (`movie_id`, `scene_id`) DO NOTHING;\n\nDROP TABLE `movies_scenes`;\nALTER TABLE `movies_scenes_new` rename to `movies_scenes`;\n\nCREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`);\n\n\nCREATE TABLE `scenes_cover_new` (\n  `scene_id` integer primary key,\n  `cover` blob not null,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\nINSERT INTO `scenes_cover_new`\n  (\n    `scene_id`,\n    `cover`\n  )\n  SELECT \n    `scene_id`,\n    `cover`\n  FROM `scenes_cover` WHERE\n  `scene_id` IS NOT NULL;\n\nDROP TABLE `scenes_cover`;\nALTER TABLE `scenes_cover_new` rename to `scenes_cover`;\n\n-- the following index is removed in favour of primary key\n-- CREATE UNIQUE INDEX `index_scene_covers_on_scene_id` on `scenes_cover` (`scene_id`);\n\n\nCREATE TABLE `performers_images_new` (\n  `performer_id` integer,\n  `image_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE,\n  PRIMARY KEY(`image_id`, `performer_id`)\n);\n\nINSERT INTO `performers_images_new`\n  (\n    `performer_id`,\n    `image_id`\n  )\n  SELECT \n    `performer_id`,\n    `image_id`\n  FROM `performers_images` WHERE \n  `performer_id` IS NOT NULL AND `image_id` IS NOT NULL\n  ON CONFLICT (`image_id`, `performer_id`) DO NOTHING;\n\nDROP TABLE `performers_images`;\nALTER TABLE `performers_images_new` rename to `performers_images`;\n\nCREATE INDEX `index_performers_images_on_performer_id` on `performers_images` (`performer_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_performers_images_on_image_id` on `performers_images` (`image_id`);\n\n\nCREATE TABLE `images_tags_new` (\n  `image_id` integer,\n  `tag_id` integer,\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,\n  PRIMARY KEY(`image_id`, `tag_id`)\n);\n\nINSERT INTO `images_tags_new`\n  (\n    `image_id`,\n    `tag_id`\n  )\n  SELECT \n    `image_id`,\n    `tag_id`\n  FROM `images_tags` WHERE \n  `image_id` IS NOT NULL AND `tag_id` IS NOT NULL\n  ON CONFLICT (`image_id`, `tag_id`) DO NOTHING;\n\nDROP TABLE `images_tags`;\nALTER TABLE `images_tags_new` rename to `images_tags`;\n\nCREATE INDEX `index_images_tags_on_tag_id` on `images_tags` (`tag_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_images_tags_on_image_id` on `images_tags` (`image_id`);\n\n\nCREATE TABLE `scene_stash_ids_new` (\n  `scene_id` integer NOT NULL,\n  `endpoint` varchar(255) NOT NULL,\n  `stash_id` varchar(36) NOT NULL,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  PRIMARY KEY(`scene_id`, `endpoint`)\n);\n\nINSERT INTO `scene_stash_ids_new`\n  (\n    `scene_id`,\n    `endpoint`,\n    `stash_id`\n  )\n  SELECT \n    `scene_id`,\n    `endpoint`,\n    `stash_id`\n  FROM `scene_stash_ids` WHERE\n  `scene_id` IS NOT NULL AND `endpoint` IS NOT NULL AND `stash_id` IS NOT NULL;\n\nDROP TABLE `scene_stash_ids`;\nALTER TABLE `scene_stash_ids_new` rename to `scene_stash_ids`;\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_scene_stash_ids_on_scene_id` ON `scene_stash_ids` (`scene_id`);\n\n\nCREATE TABLE `scenes_galleries_new` (\n  `scene_id` integer NOT NULL,\n  `gallery_id` integer NOT NULL,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  PRIMARY KEY(`scene_id`, `gallery_id`)\n);\n\nINSERT INTO `scenes_galleries_new`\n  (\n    `scene_id`,\n    `gallery_id`\n  )\n  SELECT \n    `scene_id`,\n    `gallery_id`\n  FROM `scenes_galleries` WHERE \n  `scene_id` IS NOT NULL AND `gallery_id` IS NOT NULL\n  ON CONFLICT (`scene_id`, `gallery_id`) DO NOTHING;\n\nDROP TABLE `scenes_galleries`;\nALTER TABLE `scenes_galleries_new` rename to `scenes_galleries`;\n\nCREATE INDEX `index_scenes_galleries_on_gallery_id` on `scenes_galleries` (`gallery_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_scenes_galleries_on_scene_id` on `scenes_galleries` (`scene_id`);\n\n\nCREATE TABLE `galleries_images_new` (\n  `gallery_id` integer NOT NULL,\n  `image_id` integer NOT NULL,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE,\n  PRIMARY KEY(`gallery_id`, `image_id`)\n);\n\nINSERT INTO `galleries_images_new`\n  (\n    `gallery_id`,\n    `image_id`\n  )\n  SELECT \n    `gallery_id`,\n    `image_id`\n  FROM `galleries_images` WHERE \n  `image_id` IS NOT NULL AND `gallery_id` IS NOT NULL\n  ON CONFLICT (`gallery_id`, `image_id`) DO NOTHING;\n\nDROP TABLE `galleries_images`;\nALTER TABLE `galleries_images_new` rename to `galleries_images`;\n\nCREATE INDEX `index_galleries_images_on_image_id` on `galleries_images` (`image_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_galleries_images_on_gallery_id` on `galleries_images` (`gallery_id`);\n\n\nCREATE TABLE `performers_galleries_new` (\n  `performer_id` integer NOT NULL,\n  `gallery_id` integer NOT NULL,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  PRIMARY KEY(`gallery_id`, `performer_id`)\n);\n\nINSERT INTO `performers_galleries_new`\n  (\n    `performer_id`,\n    `gallery_id`\n  )\n  SELECT \n    `performer_id`,\n    `gallery_id`\n  FROM `performers_galleries` WHERE\n  `performer_id` IS NOT NULL AND `gallery_id` IS NOT NULL\n  ON CONFLICT (`gallery_id`, `performer_id`) DO NOTHING;\n\nDROP TABLE `performers_galleries`;\nALTER TABLE `performers_galleries_new` rename to `performers_galleries`;\n\nCREATE INDEX `index_performers_galleries_on_performer_id` on `performers_galleries` (`performer_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_performers_galleries_on_gallery_id` on `performers_galleries` (`gallery_id`);\n\n\nCREATE TABLE `galleries_tags_new` (\n  `gallery_id` integer NOT NULL,\n  `tag_id` integer NOT NULL,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,\n  PRIMARY KEY(`gallery_id`, `tag_id`)\n);\n\nINSERT INTO `galleries_tags_new`\n  (\n    `gallery_id`,\n    `tag_id`\n  )\n  SELECT \n    `gallery_id`,\n    `tag_id`\n  FROM `galleries_tags` WHERE \n  `tag_id` IS NOT NULL AND `gallery_id` IS NOT NULL\n  ON CONFLICT (`gallery_id`, `tag_id`) DO NOTHING;\n\nDROP TABLE `galleries_tags`;\nALTER TABLE `galleries_tags_new` rename to `galleries_tags`;\n\nCREATE INDEX `index_galleries_tags_on_tag_id` on `galleries_tags` (`tag_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_galleries_tags_on_gallery_id` on `galleries_tags` (`gallery_id`);\n\n\nCREATE TABLE `performers_tags_new` (\n  `performer_id` integer NOT NULL,\n  `tag_id` integer NOT NULL,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,\n  PRIMARY KEY(`performer_id`, `tag_id`)\n);\n\nINSERT INTO `performers_tags_new`\n  (\n    `performer_id`,\n    `tag_id`\n  )\n  SELECT \n    `performer_id`,\n    `tag_id`\n  FROM `performers_tags` WHERE true\n  ON CONFLICT (`performer_id`, `tag_id`) DO NOTHING;\n\nDROP TABLE `performers_tags`;\nALTER TABLE `performers_tags_new` rename to `performers_tags`;\n\nCREATE INDEX `index_performers_tags_on_tag_id` on `performers_tags` (`tag_id`);\n\n-- the following index is removed in favour of primary key\n-- CREATE INDEX `index_performers_tags_on_performer_id` on `performers_tags` (`performer_id`);\n\n\nCREATE TABLE `tag_aliases_new` (\n  `tag_id` integer NOT NULL,\n  `alias` varchar(255) NOT NULL,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,\n  PRIMARY KEY(`tag_id`, `alias`)\n);\n\nINSERT INTO `tag_aliases_new`\n  (\n    `tag_id`,\n    `alias`\n  )\n  SELECT \n    `tag_id`,\n    `alias`\n  FROM `tag_aliases`;\n\nDROP TABLE `tag_aliases`;\nALTER TABLE `tag_aliases_new` rename to `tag_aliases`;\n\nCREATE UNIQUE INDEX `tag_aliases_alias_unique` on `tag_aliases` (`alias`);\n\n\nCREATE TABLE `studio_aliases_new` (\n  `studio_id` integer NOT NULL,\n  `alias` varchar(255) NOT NULL,\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE,\n  PRIMARY KEY(`studio_id`, `alias`)\n);\n\nINSERT INTO `studio_aliases_new`\n  (\n    `studio_id`,\n    `alias`\n  )\n  SELECT \n    `studio_id`,\n    `alias`\n  FROM `studio_aliases`;\n\nDROP TABLE `studio_aliases`;\nALTER TABLE `studio_aliases_new` rename to `studio_aliases`;\n\nCREATE UNIQUE INDEX `studio_aliases_alias_unique` on `studio_aliases` (`alias`);\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/36_tags_description.up.sql",
    "content": "ALTER TABLE `tags` ADD COLUMN `description` text;\n"
  },
  {
    "path": "pkg/sqlite/migrations/37_iso_country_names.up.sql",
    "content": "UPDATE `performers`\nSET `country` = CASE\n  WHEN LENGTH(TRIM(`country`)) == 2 THEN TRIM(`country`)\n  ELSE CASE `country`\n\t\tWHEN 'Afghanistan' THEN 'AF'\n\t\tWHEN 'Albania' THEN 'AL'\n\t\tWHEN 'Algeria' THEN 'DZ'\n\t\tWHEN 'America' THEN 'US'\n\t\tWHEN 'American' THEN 'US'\n\t\tWHEN 'American Samoa' THEN 'AS'\n\t\tWHEN 'Andorra' THEN 'AD'\n\t\tWHEN 'Angola' THEN 'AO'\n\t\tWHEN 'Anguilla' THEN 'AI'\n\t\tWHEN 'Antarctica' THEN 'AQ'\n\t\tWHEN 'Antigua and Barbuda' THEN 'AG'\n\t\tWHEN 'Argentina' THEN 'AR'\n\t\tWHEN 'Armenia' THEN 'AM'\n\t\tWHEN 'Aruba' THEN 'AW'\n\t\tWHEN 'Australia' THEN 'AU'\n\t\tWHEN 'Austria' THEN 'AT'\n\t\tWHEN 'Azerbaijan' THEN 'AZ'\n\t\tWHEN 'Bahamas' THEN 'BS'\n\t\tWHEN 'Bahrain' THEN 'BH'\n\t\tWHEN 'Bangladesh' THEN 'BD'\n\t\tWHEN 'Barbados' THEN 'BB'\n\t\tWHEN 'Belarus' THEN 'BY'\n\t\tWHEN 'Belgium' THEN 'BE'\n\t\tWHEN 'Belize' THEN 'BZ'\n\t\tWHEN 'Benin' THEN 'BJ'\n\t\tWHEN 'Bermuda' THEN 'BM'\n\t\tWHEN 'Bhutan' THEN 'BT'\n\t\tWHEN 'Bolivia' THEN 'BO'\n\t\tWHEN 'Bosnia and Herzegovina' THEN 'BA'\n\t\tWHEN 'Botswana' THEN 'BW'\n\t\tWHEN 'Bouvet Island' THEN 'BV'\n\t\tWHEN 'Brazil' THEN 'BR'\n\t\tWHEN 'British Indian Ocean Territory' THEN 'IO'\n\t\tWHEN 'Brunei Darussalam' THEN 'BN'\n\t\tWHEN 'Bulgaria' THEN 'BG'\n\t\tWHEN 'Burkina Faso' THEN 'BF'\n\t\tWHEN 'Burundi' THEN 'BI'\n\t\tWHEN 'Cambodia' THEN 'KH'\n\t\tWHEN 'Cameroon' THEN 'CM'\n\t\tWHEN 'Canada' THEN 'CA'\n\t\tWHEN 'Cape Verde' THEN 'CV'\n\t\tWHEN 'Cayman Islands' THEN 'KY'\n\t\tWHEN 'Central African Republic' THEN 'CF'\n\t\tWHEN 'Chad' THEN 'TD'\n\t\tWHEN 'Chile' THEN 'CL'\n\t\tWHEN 'China' THEN 'CN'\n\t\tWHEN 'Christmas Island' THEN 'CX'\n\t\tWHEN 'Cocos (Keeling) Islands' THEN 'CC'\n\t\tWHEN 'Colombia' THEN 'CO'\n\t\tWHEN 'Comoros' THEN 'KM'\n\t\tWHEN 'Congo' THEN 'CG'\n\t\tWHEN 'Congo the Democratic Republic of the' THEN 'CD'\n\t\tWHEN 'Cook Islands' THEN 'CK'\n\t\tWHEN 'Costa Rica' THEN 'CR'\n\t\tWHEN 'Cote D''Ivoire' THEN 'CI'\n\t\tWHEN 'Croatia' THEN 'HR'\n\t\tWHEN 'Cuba' THEN 'CU'\n\t\tWHEN 'Cyprus' THEN 'CY'\n\t\tWHEN 'Czech Republic' THEN 'CZ'\n\t\tWHEN 'Czechia' THEN 'CZ'\n\t\tWHEN 'Denmark' THEN 'DK'\n\t\tWHEN 'Djibouti' THEN 'DJ'\n\t\tWHEN 'Dominica' THEN 'DM'\n\t\tWHEN 'Dominican Republic' THEN 'DO'\n\t\tWHEN 'Ecuador' THEN 'EC'\n\t\tWHEN 'Egypt' THEN 'EG'\n\t\tWHEN 'El Salvador' THEN 'SV'\n\t\tWHEN 'Equatorial Guinea' THEN 'GQ'\n\t\tWHEN 'Eritrea' THEN 'ER'\n\t\tWHEN 'Estonia' THEN 'EE'\n\t\tWHEN 'Ethiopia' THEN 'ET'\n\t\tWHEN 'Falkland Islands (Malvinas)' THEN 'FK'\n\t\tWHEN 'Faroe Islands' THEN 'FO'\n\t\tWHEN 'Fiji' THEN 'FJ'\n\t\tWHEN 'Finland' THEN 'FI'\n\t\tWHEN 'France' THEN 'FR'\n\t\tWHEN 'French Guiana' THEN 'GF'\n\t\tWHEN 'French Polynesia' THEN 'PF'\n\t\tWHEN 'French Southern Territories' THEN 'TF'\n\t\tWHEN 'Gabon' THEN 'GA'\n\t\tWHEN 'Gambia' THEN 'GM'\n\t\tWHEN 'Georgia' THEN 'GE'\n\t\tWHEN 'Germany' THEN 'DE'\n\t\tWHEN 'Ghana' THEN 'GH'\n\t\tWHEN 'Gibraltar' THEN 'GI'\n\t\tWHEN 'Greece' THEN 'GR'\n\t\tWHEN 'Greenland' THEN 'GL'\n\t\tWHEN 'Grenada' THEN 'GD'\n\t\tWHEN 'Guadeloupe' THEN 'GP'\n\t\tWHEN 'Guam' THEN 'GU'\n\t\tWHEN 'Guatemala' THEN 'GT'\n\t\tWHEN 'Guinea' THEN 'GN'\n\t\tWHEN 'Guinea-Bissau' THEN 'GW'\n\t\tWHEN 'Guyana' THEN 'GY'\n\t\tWHEN 'Haiti' THEN 'HT'\n\t\tWHEN 'Heard Island and McDonald Islands' THEN 'HM'\n\t\tWHEN 'Holy See (Vatican City State)' THEN 'VA'\n\t\tWHEN 'Honduras' THEN 'HN'\n\t\tWHEN 'Hong Kong' THEN 'HK'\n\t\tWHEN 'Hungary' THEN 'HU'\n\t\tWHEN 'Iceland' THEN 'IS'\n\t\tWHEN 'India' THEN 'IN'\n\t\tWHEN 'Indonesia' THEN 'ID'\n\t\tWHEN 'Iran' THEN 'IR'\n\t\tWHEN 'Iran Islamic Republic of' THEN 'IR'\n\t\tWHEN 'Iraq' THEN 'IQ'\n\t\tWHEN 'Ireland' THEN 'IE'\n\t\tWHEN 'Israel' THEN 'IL'\n\t\tWHEN 'Italy' THEN 'IT'\n\t\tWHEN 'Jamaica' THEN 'JM'\n\t\tWHEN 'Japan' THEN 'JP'\n\t\tWHEN 'Jordan' THEN 'JO'\n\t\tWHEN 'Kazakhstan' THEN 'KZ'\n\t\tWHEN 'Kenya' THEN 'KE'\n\t\tWHEN 'Kiribati' THEN 'KI'\n\t\tWHEN 'North Korea' THEN 'KP'\n\t\tWHEN 'South Korea' THEN 'KR'\n\t\tWHEN 'Kuwait' THEN 'KW'\n\t\tWHEN 'Kyrgyzstan' THEN 'KG'\n\t\tWHEN 'Lao People''s Democratic Republic' THEN 'LA'\n\t\tWHEN 'Latvia' THEN 'LV'\n\t\tWHEN 'Lebanon' THEN 'LB'\n\t\tWHEN 'Lesotho' THEN 'LS'\n\t\tWHEN 'Liberia' THEN 'LR'\n\t\tWHEN 'Libya' THEN 'LY'\n\t\tWHEN 'Liechtenstein' THEN 'LI'\n\t\tWHEN 'Lithuania' THEN 'LT'\n\t\tWHEN 'Luxembourg' THEN 'LU'\n\t\tWHEN 'Macao' THEN 'MO'\n\t\tWHEN 'Madagascar' THEN 'MG'\n\t\tWHEN 'Malawi' THEN 'MW'\n\t\tWHEN 'Malaysia' THEN 'MY'\n\t\tWHEN 'Maldives' THEN 'MV'\n\t\tWHEN 'Mali' THEN 'ML'\n\t\tWHEN 'Malta' THEN 'MT'\n\t\tWHEN 'Marshall Islands' THEN 'MH'\n\t\tWHEN 'Martinique' THEN 'MQ'\n\t\tWHEN 'Mauritania' THEN 'MR'\n\t\tWHEN 'Mauritius' THEN 'MU'\n\t\tWHEN 'Mayotte' THEN 'YT'\n\t\tWHEN 'Mexico' THEN 'MX'\n\t\tWHEN 'Micronesia Federated States of' THEN 'FM'\n\t\tWHEN 'Moldova' THEN 'MD'\n\t\tWHEN 'Moldova Republic of' THEN 'MD'\n\t\tWHEN 'Moldova, Republic of' THEN 'MD'\n\t\tWHEN 'Monaco' THEN 'MC'\n\t\tWHEN 'Mongolia' THEN 'MN'\n\t\tWHEN 'Montserrat' THEN 'MS'\n\t\tWHEN 'Morocco' THEN 'MA'\n\t\tWHEN 'Mozambique' THEN 'MZ'\n\t\tWHEN 'Myanmar' THEN 'MM'\n\t\tWHEN 'Namibia' THEN 'NA'\n\t\tWHEN 'Nauru' THEN 'NR'\n\t\tWHEN 'Nepal' THEN 'NP'\n\t\tWHEN 'Netherlands' THEN 'NL'\n\t\tWHEN 'New Caledonia' THEN 'NC'\n\t\tWHEN 'New Zealand' THEN 'NZ'\n\t\tWHEN 'Nicaragua' THEN 'NI'\n\t\tWHEN 'Niger' THEN 'NE'\n\t\tWHEN 'Nigeria' THEN 'NG'\n\t\tWHEN 'Niue' THEN 'NU'\n\t\tWHEN 'Norfolk Island' THEN 'NF'\n\t\tWHEN 'North Macedonia Republic of' THEN 'MK'\n\t\tWHEN 'Northern Mariana Islands' THEN 'MP'\n\t\tWHEN 'Norway' THEN 'NO'\n\t\tWHEN 'Oman' THEN 'OM'\n\t\tWHEN 'Pakistan' THEN 'PK'\n\t\tWHEN 'Palau' THEN 'PW'\n\t\tWHEN 'Palestinian Territory Occupied' THEN 'PS'\n\t\tWHEN 'Panama' THEN 'PA'\n\t\tWHEN 'Papua New Guinea' THEN 'PG'\n\t\tWHEN 'Paraguay' THEN 'PY'\n\t\tWHEN 'Peru' THEN 'PE'\n\t\tWHEN 'Philippines' THEN 'PH'\n\t\tWHEN 'Pitcairn' THEN 'PN'\n\t\tWHEN 'Poland' THEN 'PL'\n\t\tWHEN 'Portugal' THEN 'PT'\n\t\tWHEN 'Puerto Rico' THEN 'PR'\n\t\tWHEN 'Qatar' THEN 'QA'\n\t\tWHEN 'Reunion' THEN 'RE'\n\t\tWHEN 'Romania' THEN 'RO'\n\t\tWHEN 'Russia' THEN 'RU'\n\t\tWHEN 'Russian Federation' THEN 'RU'\n\t\tWHEN 'Rwanda' THEN 'RW'\n\t\tWHEN 'Saint Helena' THEN 'SH'\n\t\tWHEN 'Saint Kitts and Nevis' THEN 'KN'\n\t\tWHEN 'Saint Lucia' THEN 'LC'\n\t\tWHEN 'Saint Pierre and Miquelon' THEN 'PM'\n\t\tWHEN 'Saint Vincent and the Grenadines' THEN 'VC'\n\t\tWHEN 'Samoa' THEN 'WS'\n\t\tWHEN 'San Marino' THEN 'SM'\n\t\tWHEN 'Sao Tome and Principe' THEN 'ST'\n\t\tWHEN 'Saudi Arabia' THEN 'SA'\n\t\tWHEN 'Senegal' THEN 'SN'\n\t\tWHEN 'Seychelles' THEN 'SC'\n\t\tWHEN 'Sierra Leone' THEN 'SL'\n\t\tWHEN 'Singapore' THEN 'SG'\n\t\tWHEN 'Slovakia' THEN 'SK'\n\t\tWHEN 'Slovak Republic' THEN 'SK'\n\t\tWHEN 'Slovenia' THEN 'SI'\n\t\tWHEN 'Solomon Islands' THEN 'SB'\n\t\tWHEN 'Somalia' THEN 'SO'\n\t\tWHEN 'South Africa' THEN 'ZA'\n\t\tWHEN 'South Georgia and the South Sandwich Islands' THEN 'GS'\n\t\tWHEN 'Spain' THEN 'ES'\n\t\tWHEN 'Sri Lanka' THEN 'LK'\n\t\tWHEN 'Sudan' THEN 'SD'\n\t\tWHEN 'Suriname' THEN 'SR'\n\t\tWHEN 'Svalbard and Jan Mayen' THEN 'SJ'\n\t\tWHEN 'Eswatini' THEN 'SZ'\n\t\tWHEN 'Sweden' THEN 'SE'\n\t\tWHEN 'Switzerland' THEN 'CH'\n\t\tWHEN 'Syrian Arab Republic' THEN 'SY'\n\t\tWHEN 'Taiwan' THEN 'TW'\n\t\tWHEN 'Tajikistan' THEN 'TJ'\n\t\tWHEN 'Tanzania United Republic of' THEN 'TZ'\n\t\tWHEN 'Thailand' THEN 'TH'\n\t\tWHEN 'Timor-Leste' THEN 'TL'\n\t\tWHEN 'Togo' THEN 'TG'\n\t\tWHEN 'Tokelau' THEN 'TK'\n\t\tWHEN 'Tonga' THEN 'TO'\n\t\tWHEN 'Trinidad and Tobago' THEN 'TT'\n\t\tWHEN 'Tunisia' THEN 'TN'\n\t\tWHEN 'Turkey' THEN 'TR'\n\t\tWHEN 'Turkmenistan' THEN 'TM'\n\t\tWHEN 'Turks and Caicos Islands' THEN 'TC'\n\t\tWHEN 'Tuvalu' THEN 'TV'\n\t\tWHEN 'Uganda' THEN 'UG'\n\t\tWHEN 'Ukraine' THEN 'UA'\n\t\tWHEN 'United Arab Emirates' THEN 'AE'\n\t\tWHEN 'England' THEN 'GB'\n\t\tWHEN 'Great Britain' THEN 'GB'\n\t\tWHEN 'United Kingdom' THEN 'GB'\n\t\tWHEN 'USA' THEN 'US'\n\t\tWHEN 'United States' THEN 'US'\n\t\tWHEN 'United States of America' THEN 'US'\n\t\tWHEN 'United States Minor Outlying Islands' THEN 'UM'\n\t\tWHEN 'Uruguay' THEN 'UY'\n\t\tWHEN 'Uzbekistan' THEN 'UZ'\n\t\tWHEN 'Vanuatu' THEN 'VU'\n\t\tWHEN 'Venezuela' THEN 'VE'\n\t\tWHEN 'Vietnam' THEN 'VN'\n\t\tWHEN 'Virgin Islands British' THEN 'VG'\n\t\tWHEN 'Virgin Islands U.S.' THEN 'VI'\n\t\tWHEN 'Wallis and Futuna' THEN 'WF'\n\t\tWHEN 'Western Sahara' THEN 'EH'\n\t\tWHEN 'Yemen' THEN 'YE'\n\t\tWHEN 'Zambia' THEN 'ZM'\n\t\tWHEN 'Zimbabwe' THEN 'ZW'\n\t\tWHEN 'Åland Islands' THEN 'AX'\n\t\tWHEN 'Bonaire Sint Eustatius and Saba' THEN 'BQ'\n\t\tWHEN 'Curaçao' THEN 'CW'\n\t\tWHEN 'Guernsey' THEN 'GG'\n\t\tWHEN 'Isle of Man' THEN 'IM'\n\t\tWHEN 'Jersey' THEN 'JE'\n\t\tWHEN 'Montenegro' THEN 'ME'\n\t\tWHEN 'Saint Barthélemy' THEN 'BL'\n\t\tWHEN 'Saint Martin (French part)' THEN 'MF'\n\t\tWHEN 'Serbia' THEN 'RS'\n\t\tWHEN 'Sint Maarten (Dutch part)' THEN 'SX'\n\t\tWHEN 'South Sudan' THEN 'SS'\n\t\tWHEN 'Kosovo' THEN 'XK'\n\t\tELSE `country`\n\tEND\nEND;\n"
  },
  {
    "path": "pkg/sqlite/migrations/38_scenes_director_code.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `code` text;\nALTER TABLE `scenes` ADD COLUMN `director` text;\n"
  },
  {
    "path": "pkg/sqlite/migrations/39_performer_height.up.sql",
    "content": "-- add primary keys to association tables that are missing them\nPRAGMA foreign_keys=OFF;\n\nCREATE TABLE `performers_new` (\n  `id` integer not null primary key autoincrement,\n  `checksum` varchar(255) not null,\n  `name` varchar(255),\n  `gender` varchar(20),\n  `url` varchar(255),\n  `twitter` varchar(255),\n  `instagram` varchar(255),\n  `birthdate` date,\n  `ethnicity` varchar(255),\n  `country` varchar(255),\n  `eye_color` varchar(255),\n  -- changed from varchar(255)\n  `height` int,\n  `measurements` varchar(255),\n  `fake_tits` varchar(255),\n  `career_length` varchar(255),\n  `tattoos` varchar(255),\n  `piercings` varchar(255),\n  `aliases` varchar(255),\n  `favorite` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  `details` text, \n  `death_date` date, \n  `hair_color` varchar(255), \n  `weight` integer, \n  `rating` tinyint, \n  `ignore_auto_tag` boolean not null default '0'\n);\n\nINSERT INTO `performers_new`\n  (\n    `id`,\n    `checksum`,\n    `name`,\n    `gender`,\n    `url`,\n    `twitter`,\n    `instagram`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `career_length`,\n    `tattoos`,\n    `piercings`,\n    `aliases`,\n    `favorite`,\n    `created_at`,\n    `updated_at`,\n    `details`,\n    `death_date`,\n    `hair_color`,\n    `weight`,\n    `rating`,\n    `ignore_auto_tag`\n  )\n  SELECT \n    `id`,\n    `checksum`,\n    `name`,\n    `gender`,\n    `url`,\n    `twitter`,\n    `instagram`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    CASE `height`\n      WHEN '' THEN NULL\n      WHEN NULL THEN NULL\n      ELSE CAST(`height` as int)\n    END,\n    `measurements`,\n    `fake_tits`,\n    `career_length`,\n    `tattoos`,\n    `piercings`,\n    `aliases`,\n    `favorite`,\n    `created_at`,\n    `updated_at`,\n    `details`,\n    `death_date`,\n    `hair_color`,\n    `weight`,\n    `rating`,\n    `ignore_auto_tag`\n  FROM `performers`;\n\nDROP TABLE `performers`;\nALTER TABLE `performers_new` rename to `performers`;\n\nCREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`);\nCREATE INDEX `index_performers_on_name` on `performers` (`name`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/3_o_counter.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `o_counter` tinyint not null default 0;\n"
  },
  {
    "path": "pkg/sqlite/migrations/40_newratings.up.sql",
    "content": "UPDATE `scenes` SET `rating` = (`rating` * 20) WHERE `rating` < 6;\nUPDATE `galleries` SET `rating` = (`rating` * 20) WHERE `rating` < 6;\nUPDATE `images` SET `rating` = (`rating` * 20) WHERE `rating` < 6;\nUPDATE `movies` SET `rating` = (`rating` * 20) WHERE `rating` < 6;\nUPDATE `performers` SET `rating` = (`rating` * 20) WHERE `rating` < 6;\nUPDATE `studios` SET `rating` = (`rating` * 20) WHERE `rating` < 6;"
  },
  {
    "path": "pkg/sqlite/migrations/41_scene_activity.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `resume_time` float not null default 0;\nALTER TABLE `scenes` ADD COLUMN `last_played_at` datetime default null;\nALTER TABLE `scenes` ADD COLUMN `play_count` tinyint not null default 0;\nALTER TABLE `scenes` ADD COLUMN `play_duration` float not null default 0;"
  },
  {
    "path": "pkg/sqlite/migrations/42_performer_disambig_aliases.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\nCREATE TABLE `performer_aliases` (\n  `performer_id` integer NOT NULL,\n  `alias` varchar(255) NOT NULL,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  PRIMARY KEY(`performer_id`, `alias`)\n);\n\nCREATE INDEX `performer_aliases_alias` on `performer_aliases` (`alias`);\n\nDROP INDEX `performers_checksum_unique`;\n\n-- drop aliases and checksum\n-- add disambiguation\n\nCREATE TABLE `performers_new` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(255),\n  `disambiguation` varchar(255),\n  `gender` varchar(20),\n  `url` varchar(255),\n  `twitter` varchar(255),\n  `instagram` varchar(255),\n  `birthdate` date,\n  `ethnicity` varchar(255),\n  `country` varchar(255),\n  `eye_color` varchar(255),\n  `height` int,\n  `measurements` varchar(255),\n  `fake_tits` varchar(255),\n  `career_length` varchar(255),\n  `tattoos` varchar(255),\n  `piercings` varchar(255),\n  `favorite` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  `details` text, \n  `death_date` date, \n  `hair_color` varchar(255), \n  `weight` integer, \n  `rating` tinyint, \n  `ignore_auto_tag` boolean not null default '0'\n);\n\nINSERT INTO `performers_new`\n  (\n    `id`,\n    `name`,\n    `gender`,\n    `url`,\n    `twitter`,\n    `instagram`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `career_length`,\n    `tattoos`,\n    `piercings`,\n    `favorite`,\n    `created_at`,\n    `updated_at`,\n    `details`,\n    `death_date`,\n    `hair_color`,\n    `weight`,\n    `rating`,\n    `ignore_auto_tag`\n  )\n  SELECT \n    `id`,\n    `name`,\n    `gender`,\n    `url`,\n    `twitter`,\n    `instagram`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `career_length`,\n    `tattoos`,\n    `piercings`,\n    `favorite`,\n    `created_at`,\n    `updated_at`,\n    `details`,\n    `death_date`,\n    `hair_color`,\n    `weight`,\n    `rating`,\n    `ignore_auto_tag`\n  FROM `performers`;\n\nINSERT INTO `performer_aliases`\n  (\n    `performer_id`,\n    `alias`\n  )\n  SELECT \n    `id`,\n    `aliases`\n  FROM `performers`\n  WHERE `performers`.`aliases` IS NOT NULL AND `performers`.`aliases` != '';\n\nDROP TABLE `performers`;\nALTER TABLE `performers_new` rename to `performers`;\n\n\n-- these will be executed in the post-migration\n-- CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL;\n-- CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL;\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/42_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\ntype schema42Migrator struct {\n\tmigrator\n}\n\nfunc post42(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 42\")\n\n\tm := schema42Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\tif err := m.migrate(ctx); err != nil {\n\t\treturn fmt.Errorf(\"migrating performer aliases: %w\", err)\n\t}\n\n\tif err := m.migrateDuplicatePerformers(ctx); err != nil {\n\t\treturn fmt.Errorf(\"migrating duplicate performers: %w\", err)\n\t}\n\n\t// do this after duplicate performer detection, since setting disambiguation\n\t// breaks the duplicate disambiguation setting code\n\tif err := m.migratePerformersDisam(ctx); err != nil {\n\t\treturn fmt.Errorf(\"migrating performer names: %w\", err)\n\t}\n\n\tif err := m.executeSchemaChanges(); err != nil {\n\t\treturn fmt.Errorf(\"executing schema changes: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema42Migrator) migrate(ctx context.Context) error {\n\tlogger.Info(\"Migrating performer aliases\")\n\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tlastID := 0\n\tcount := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := \"SELECT `performer_id`, `alias` FROM `performer_aliases`\"\n\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\" WHERE `performer_id` > %d \", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\" ORDER BY `performer_id` LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tvar (\n\t\t\t\t\tid      int\n\t\t\t\t\taliases string\n\t\t\t\t)\n\n\t\t\t\terr := rows.Scan(&id, &aliases)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\tcount++\n\n\t\t\t\tif err := m.migratePerformerAliases(tx, id, aliases); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d rows\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema42Migrator) migratePerformerAliases(tx *sqlx.Tx, id int, aliases string) error {\n\t// split aliases by , or /\n\taliasList := strings.FieldsFunc(aliases, func(r rune) bool {\n\t\treturn strings.ContainsRune(\",/\", r)\n\t})\n\n\tif len(aliasList) < 2 {\n\t\t// existing value is fine\n\t\treturn nil\n\t}\n\n\t// delete the existing row\n\tif _, err := tx.Exec(\"DELETE FROM `performer_aliases` WHERE `performer_id` = ?\", id); err != nil {\n\t\treturn err\n\t}\n\n\t// trim whitespace from each alias\n\tfor i, alias := range aliasList {\n\t\taliasList[i] = strings.TrimSpace(alias)\n\t}\n\n\t// remove duplicates\n\taliasList = sliceutil.AppendUniques(nil, aliasList)\n\n\t// insert aliases into table\n\tfor _, alias := range aliasList {\n\t\t_, err := tx.Exec(\"INSERT INTO `performer_aliases` (`performer_id`, `alias`) VALUES (?, ?)\", id, alias)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema42Migrator) migratePerformersDisam(ctx context.Context) error {\n\tlogger.Info(\"Migrating performer disambiguation\")\n\n\tconst (\n\t\tlimit    = 1\n\t\tlogEvery = 10000\n\t)\n\n\tcount := 0\n\tlastID := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := `\nSELECT id, name FROM performers WHERE performers.name like '% (%)'`\n\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\" AND `id` > %d \", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\" ORDER BY `id` LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tvar (\n\t\t\t\t\tid   int\n\t\t\t\t\tname string\n\t\t\t\t)\n\n\t\t\t\terr := rows.Scan(&id, &name)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tgotSome = true\n\t\t\t\tlastID = id\n\t\t\t\tcount++\n\n\t\t\t\tif err := m.massagePerformerName(tx, id, name); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d performers\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// extracts the performer name and disambiguation from the name field based on\n// the format \"name (disambiguation)\".\nvar performerDisRE = regexp.MustCompile(`^((?:[^(\\s]+\\s)+)\\(([^)]+)\\)$`)\n\nfunc (m *schema42Migrator) massagePerformerName(tx *sqlx.Tx, performerID int, name string) error {\n\n\tr := performerDisRE.FindStringSubmatch(name)\n\tif len(r) != 3 {\n\t\t// ignore corner case invalid names\n\t\treturn nil\n\t}\n\n\t// get the performer name and disambiguation from the capturing groups\n\t// trim the trailing whitespace (single only) from the name\n\tnewName := strings.TrimSuffix(r[1], \" \")\n\tnewDis := r[2]\n\n\tlogger.Infof(\"Separating %q into %q and disambiguation %q\", name, newName, newDis)\n\n\t_, err := tx.Exec(\"UPDATE performers SET name = ?, disambiguation = ? WHERE id = ?\", newName, newDis, performerID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema42Migrator) migrateDuplicatePerformers(ctx context.Context) error {\n\tlogger.Info(\"Migrating duplicate performers\")\n\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tcount := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := `\nSELECT id, name FROM performers WHERE performers.disambiguation IS NULL AND EXISTS (\n  SELECT 1 FROM performers p2 WHERE \n    performers.name = p2.name AND\n\tperformers.rowid > p2.rowid\n)`\n\n\t\t\tquery += fmt.Sprintf(\" ORDER BY `id` LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tvar (\n\t\t\t\t\tid   int\n\t\t\t\t\tname string\n\t\t\t\t)\n\n\t\t\t\terr := rows.Scan(&id, &name)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tgotSome = true\n\t\t\t\tcount++\n\n\t\t\t\tif err := m.migrateDuplicatePerformer(tx, id, name); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d performers\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema42Migrator) migrateDuplicatePerformer(tx *sqlx.Tx, performerID int, name string) error {\n\t// get the highest value of disambiguation for this performer name\n\tquery := `\nSELECT disambiguation FROM performers WHERE name = ? ORDER BY disambiguation DESC LIMIT 1`\n\n\tvar disambiguation sql.NullString\n\tif err := tx.Get(&disambiguation, query, name); err != nil {\n\t\treturn err\n\t}\n\n\tnewDisambiguation := 1\n\n\t// if there is no disambiguation, set it to 1\n\tif disambiguation.Valid {\n\t\tnumericDis, err := strconv.Atoi(disambiguation.String)\n\t\tif err != nil {\n\t\t\t// shouldn't happen\n\t\t\treturn err\n\t\t}\n\n\t\tnewDisambiguation = numericDis + 1\n\t}\n\n\tlogger.Infof(\"Adding disambiguation '%d' for performer %q\", newDisambiguation, name)\n\n\t_, err := tx.Exec(\"UPDATE performers SET disambiguation = ? WHERE id = ?\", strconv.Itoa(newDisambiguation), performerID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema42Migrator) executeSchemaChanges() error {\n\treturn m.execAll([]string{\n\t\t\"CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL\",\n\t\t\"CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL\",\n\t})\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(42, post42)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/43_image_date_url.up.sql",
    "content": "ALTER TABLE `images` ADD COLUMN `url` varchar(255);\nALTER TABLE `images` ADD COLUMN `date` date;"
  },
  {
    "path": "pkg/sqlite/migrations/44_gallery_chapters.up.sql",
    "content": "CREATE TABLE `galleries_chapters` (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255) not null,\n  `image_index` integer not null,\n  `gallery_id` integer not null,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE\n);\nCREATE INDEX `index_galleries_chapters_on_gallery_id` on `galleries_chapters` (`gallery_id`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/45_blobs.up.sql",
    "content": "CREATE TABLE `blobs` (\n    `checksum` varchar(255) NOT NULL PRIMARY KEY,\n    `blob` blob\n);\n\nALTER TABLE `tags` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`);\nALTER TABLE `studios` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`);\nALTER TABLE `performers` ADD COLUMN `image_blob` varchar(255) REFERENCES `blobs`(`checksum`);\nALTER TABLE `scenes` ADD COLUMN `cover_blob` varchar(255) REFERENCES `blobs`(`checksum`);\n\nALTER TABLE `movies` ADD COLUMN `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`);\nALTER TABLE `movies` ADD COLUMN `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`);\n\n-- performed in the post-migration\n-- DROP TABLE `tags_image`;\n-- DROP TABLE `studios_image`;\n-- DROP TABLE `performers_image`;\n-- DROP TABLE `scenes_cover`;\n-- DROP TABLE `movies_images`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/45_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/hash/md5\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype schema45Migrator struct {\n\tmigrator\n\thasBlobs bool\n}\n\nfunc post45(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 45\")\n\n\tm := schema45Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\tif err := m.migrateImagesTable(ctx, migrateImagesTableOptions{\n\t\tjoinTable: \"tags_image\",\n\t\tjoinIDCol: \"tag_id\",\n\t\tdestTable: \"tags\",\n\t\tcols: []migrateImageToBlobOptions{\n\t\t\t{\n\t\t\t\tjoinImageCol: \"image\",\n\t\t\t\tdestCol:      \"image_blob\",\n\t\t\t},\n\t\t},\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to migrate images table for tags: %w\", err)\n\t}\n\n\tif err := m.migrateImagesTable(ctx, migrateImagesTableOptions{\n\t\tjoinTable: \"studios_image\",\n\t\tjoinIDCol: \"studio_id\",\n\t\tdestTable: \"studios\",\n\t\tcols: []migrateImageToBlobOptions{\n\t\t\t{\n\t\t\t\tjoinImageCol: \"image\",\n\t\t\t\tdestCol:      \"image_blob\",\n\t\t\t},\n\t\t},\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to migrate images table for studios: %w\", err)\n\t}\n\n\tif err := m.migrateImagesTable(ctx, migrateImagesTableOptions{\n\t\tjoinTable: \"performers_image\",\n\t\tjoinIDCol: \"performer_id\",\n\t\tdestTable: \"performers\",\n\t\tcols: []migrateImageToBlobOptions{\n\t\t\t{\n\t\t\t\tjoinImageCol: \"image\",\n\t\t\t\tdestCol:      \"image_blob\",\n\t\t\t},\n\t\t},\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to migrate images table for performers: %w\", err)\n\t}\n\n\tif err := m.migrateImagesTable(ctx, migrateImagesTableOptions{\n\t\tjoinTable: \"scenes_cover\",\n\t\tjoinIDCol: \"scene_id\",\n\t\tdestTable: \"scenes\",\n\t\tcols: []migrateImageToBlobOptions{\n\t\t\t{\n\t\t\t\tjoinImageCol: \"cover\",\n\t\t\t\tdestCol:      \"cover_blob\",\n\t\t\t},\n\t\t},\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to migrate images table for scenes: %w\", err)\n\t}\n\n\tif err := m.migrateImagesTable(ctx, migrateImagesTableOptions{\n\t\tjoinTable: \"movies_images\",\n\t\tjoinIDCol: \"movie_id\",\n\t\tdestTable: \"movies\",\n\t\tcols: []migrateImageToBlobOptions{\n\t\t\t{\n\t\t\t\tjoinImageCol: \"front_image\",\n\t\t\t\tdestCol:      \"front_image_blob\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tjoinImageCol: \"back_image\",\n\t\t\t\tdestCol:      \"back_image_blob\",\n\t\t\t},\n\t\t},\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to migrate images table for movies: %w\", err)\n\t}\n\n\ttablesToDrop := []string{\n\t\t\"tags_image\",\n\t\t\"studios_image\",\n\t\t\"performers_image\",\n\t\t\"scenes_cover\",\n\t\t\"movies_images\",\n\t}\n\n\tfor _, table := range tablesToDrop {\n\t\tif err := m.dropTable(ctx, table); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to drop table %s: %w\", table, err)\n\t\t}\n\t}\n\n\tif err := m.migrateConfig(ctx); err != nil {\n\t\treturn fmt.Errorf(\"failed to migrate config: %w\", err)\n\t}\n\n\treturn nil\n}\n\ntype migrateImageToBlobOptions struct {\n\tjoinImageCol string\n\tdestCol      string\n}\n\ntype migrateImagesTableOptions struct {\n\tjoinTable string\n\tjoinIDCol string\n\tdestTable string\n\tcols      []migrateImageToBlobOptions\n}\n\nfunc (o migrateImagesTableOptions) selectColumns() string {\n\tvar cols []string\n\tfor _, c := range o.cols {\n\t\tcols = append(cols, \"`\"+c.joinImageCol+\"`\")\n\t}\n\n\treturn strings.Join(cols, \", \")\n}\n\nfunc (m *schema45Migrator) migrateImagesTable(ctx context.Context, options migrateImagesTableOptions) error {\n\tlogger.Infof(\"Moving %s to blobs table\", options.joinTable)\n\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tcount := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := fmt.Sprintf(\"SELECT %s, %s FROM `%s`\", options.joinIDCol, options.selectColumns(), options.joinTable)\n\n\t\t\tquery += fmt.Sprintf(\" LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tm.hasBlobs = true\n\n\t\t\t\tvar id int\n\n\t\t\t\tresult := make([]interface{}, len(options.cols)+1)\n\t\t\t\tresult[0] = &id\n\t\t\t\tfor i := range options.cols {\n\t\t\t\t\tv := []byte{}\n\t\t\t\t\tresult[i+1] = &v\n\t\t\t\t}\n\n\t\t\t\terr := rows.Scan(result...)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tgotSome = true\n\t\t\t\tcount++\n\n\t\t\t\tfor i, col := range options.cols {\n\t\t\t\t\timage := result[i+1].(*[]byte)\n\n\t\t\t\t\tif len(*image) > 0 {\n\t\t\t\t\t\tif err := m.insertImage(tx, *image, id, options.destTable, col.destCol); err != nil {\n\t\t\t\t\t\t\treturn err\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// delete the row from the join table so we don't process it again\n\t\t\t\tdeleteSQL := utils.StrFormat(\"DELETE FROM `{joinTable}` WHERE `{joinIDCol}` = ?\", utils.StrFormatMap{\n\t\t\t\t\t\"joinTable\": options.joinTable,\n\t\t\t\t\t\"joinIDCol\": options.joinIDCol,\n\t\t\t\t})\n\t\t\t\tif _, err := tx.Exec(deleteSQL, id); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d images\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema45Migrator) insertImage(tx *sqlx.Tx, data []byte, id int, destTable string, destCol string) error {\n\t// calculate checksum and insert into blobs table\n\tchecksum := md5.FromBytes(data)\n\n\tif _, err := tx.Exec(\"INSERT INTO `blobs` (`checksum`, `blob`) VALUES (?, ?) ON CONFLICT DO NOTHING\", checksum, data); err != nil {\n\t\treturn err\n\t}\n\n\t// set the tag image checksum\n\tupdateSQL := utils.StrFormat(\"UPDATE `{destTable}` SET `{destCol}` = ? WHERE `id` = ?\", utils.StrFormatMap{\n\t\t\"destTable\": destTable,\n\t\t\"destCol\":   destCol,\n\t})\n\tif _, err := tx.Exec(updateSQL, checksum, id); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema45Migrator) dropTable(ctx context.Context, table string) error {\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\tlogger.Debugf(\"Dropping %s\", table)\n\t\t_, err := tx.Exec(fmt.Sprintf(\"DROP TABLE `%s`\", table))\n\t\treturn err\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema45Migrator) migrateConfig(ctx context.Context) error {\n\tc := config.GetInstance()\n\n\t// if we don't have blobs, and storage is already set, then don't overwrite\n\tif !m.hasBlobs && c.GetBlobsStorage().IsValid() {\n\t\tlogger.Infof(\"Blobs storage already set, not overwriting\")\n\t\treturn nil\n\t}\n\n\t// if we have blobs in the database, then default to database storage\n\t// otherwise default to filesystem storage\n\tdefaultStorage := config.BlobStorageTypeFilesystem\n\tif m.hasBlobs || c.GetBlobsPath() == \"\" {\n\t\tdefaultStorage = config.BlobStorageTypeDatabase\n\t}\n\n\tlogger.Infof(\"Setting blobs storage to %s\", defaultStorage.String())\n\tc.SetInterface(config.BlobsStorage, defaultStorage)\n\tif err := c.Write(); err != nil {\n\t\tlogger.Errorf(\"Error while writing configuration file: %s\", err.Error())\n\t}\n\n\t// if default scan settings are set, then set to generate scene covers by default\n\tscanDefaults := c.GetDefaultScanSettings()\n\tif scanDefaults != nil {\n\t\tscanDefaults.ScanGenerateCovers = true\n\t\tc.SetInterface(config.DefaultScanSettings, scanDefaults)\n\t\tif err := c.Write(); err != nil {\n\t\t\tlogger.Errorf(\"Error while writing configuration file: %s\", err.Error())\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(45, post45)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/46_penis_stats.up.sql",
    "content": "ALTER TABLE `performers` ADD COLUMN `penis_length` float;\r\nALTER TABLE `performers` ADD COLUMN `circumcised` varchar[10];"
  },
  {
    "path": "pkg/sqlite/migrations/47_scene_urls.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\nCREATE TABLE `scene_urls` (\n  `scene_id` integer NOT NULL,\n  `position` integer NOT NULL,\n  `url` varchar(255) NOT NULL,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,\n  PRIMARY KEY(`scene_id`, `position`, `url`)\n);\n\nCREATE INDEX `scene_urls_url` on `scene_urls` (`url`);\n\n-- drop url\nCREATE TABLE \"scenes_new\" (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255),\n  `details` text,\n  `date` date,\n  `rating` tinyint,\n  `studio_id` integer,\n  `o_counter` tinyint not null default 0,\n  `organized` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null, \n  `code` text, \n  `director` text, \n  `resume_time` float not null default 0, \n  `last_played_at` datetime default null, \n  `play_count` tinyint not null default 0, \n  `play_duration` float not null default 0, \n  `cover_blob` varchar(255) REFERENCES `blobs`(`checksum`),\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n);\n\nINSERT INTO `scenes_new`\n  (\n    `id`,\n    `title`,\n    `details`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `o_counter`,\n    `organized`,\n    `created_at`,\n    `updated_at`,\n    `code`,\n    `director`,\n    `resume_time`,\n    `last_played_at`,\n    `play_count`,\n    `play_duration`,\n    `cover_blob`\n  )\n  SELECT \n    `id`,\n    `title`,\n    `details`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `o_counter`,\n    `organized`,\n    `created_at`,\n    `updated_at`,\n    `code`,\n    `director`,\n    `resume_time`,\n    `last_played_at`,\n    `play_count`,\n    `play_duration`,\n    `cover_blob`\n  FROM `scenes`;\n\nINSERT INTO `scene_urls`\n  (\n    `scene_id`,\n    `position`,\n    `url`\n  )\n  SELECT \n    `id`,\n    '0',\n    `url`\n  FROM `scenes`\n  WHERE `scenes`.`url` IS NOT NULL AND `scenes`.`url` != '';\n\nDROP INDEX `index_scenes_on_studio_id`;\nDROP TABLE `scenes`;\nALTER TABLE `scenes_new` rename to `scenes`;\n\nCREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`);\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/48_cleanup.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\n-- Cleanup old invalid dates\nUPDATE `scenes` SET `date` = NULL WHERE `date` = '0001-01-01' OR `date` = '';\nUPDATE `galleries` SET `date` = NULL WHERE `date` = '0001-01-01' OR `date` = '';\nUPDATE `performers` SET `birthdate` = NULL WHERE `birthdate` = '0001-01-01' OR `birthdate` = '';\nUPDATE `performers` SET `death_date` = NULL WHERE `death_date` = '0001-01-01' OR `death_date` = '';\n\n-- Delete scene markers with missing scenes\nDELETE FROM `scene_markers` WHERE `scene_id` IS NULL;\n\n-- make scene_id not null\nDROP INDEX `index_scene_markers_on_scene_id`;\nDROP INDEX `index_scene_markers_on_primary_tag_id`;\n\nCREATE TABLE `scene_markers_new` (\n  `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n  `title` VARCHAR(255) NOT NULL,\n  `seconds` FLOAT NOT NULL,\n  `primary_tag_id` INTEGER NOT NULL,\n  `scene_id` INTEGER NOT NULL,\n  `created_at` DATETIME NOT NULL,\n  `updated_at` DATETIME NOT NULL,\n  FOREIGN KEY(`primary_tag_id`) REFERENCES `tags`(`id`),\n  FOREIGN KEY(`scene_id`) REFERENCES `scenes`(`id`)\n);\nINSERT INTO `scene_markers_new` SELECT * FROM `scene_markers`;\n\nDROP TABLE `scene_markers`;\nALTER TABLE `scene_markers_new` RENAME TO `scene_markers`;\n\nCREATE INDEX `index_scene_markers_on_primary_tag_id` ON `scene_markers`(`primary_tag_id`);\nCREATE INDEX `index_scene_markers_on_scene_id` ON `scene_markers`(`scene_id`);\n\n-- drop unused scraped items table\nDROP TABLE IF EXISTS `scraped_items`;\n\n-- remove checksum from movies\nDROP INDEX `movies_checksum_unique`;\nDROP INDEX `movies_name_unique`;\n\nCREATE TABLE `movies_new` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(255) not null,\n  `aliases` varchar(255),\n  `duration` integer,\n  `date` date,\n  `rating` tinyint,\n  `studio_id` integer REFERENCES `studios`(`id`) ON DELETE SET NULL,\n  `director` varchar(255),\n  `synopsis` text,\n  `url` varchar(255),\n  `created_at` datetime not null,\n  `updated_at` datetime not null, \n  `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`), \n  `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`)\n);\n\nINSERT INTO `movies_new` SELECT `id`, `name`, `aliases`, `duration`, `date`, `rating`, `studio_id`, `director`, `synopsis`, `url`, `created_at`, `updated_at`, `front_image_blob`, `back_image_blob` FROM `movies`;\n\nDROP TABLE `movies`;\nALTER TABLE `movies_new` RENAME TO `movies`;\n\nCREATE UNIQUE INDEX `index_movies_on_name_unique` ON `movies`(`name`);\nCREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`);\n\n-- remove checksum from studios\nDROP INDEX `index_studios_on_checksum`;\nDROP INDEX `index_studios_on_name`;\nDROP INDEX `studios_checksum_unique`;\n\nCREATE TABLE `studios_new` (\n  `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n  `name` VARCHAR(255) NOT NULL,\n  `url` VARCHAR(255),\n  `parent_id` INTEGER DEFAULT NULL CHECK (`id` IS NOT `parent_id`) REFERENCES `studios`(`id`) ON DELETE SET NULL,\n  `created_at` DATETIME NOT NULL,\n  `updated_at` DATETIME NOT NULL,\n  `details` TEXT,\n  `rating` TINYINT,\n  `ignore_auto_tag` BOOLEAN NOT NULL DEFAULT FALSE,\n  `image_blob` VARCHAR(255) REFERENCES `blobs`(`checksum`)\n);\nINSERT INTO `studios_new` SELECT `id`, `name`, `url`, `parent_id`, `created_at`, `updated_at`, `details`, `rating`, `ignore_auto_tag`, `image_blob` FROM `studios`;\n\nDROP TABLE `studios`;\nALTER TABLE `studios_new` RENAME TO `studios`;\n\nCREATE UNIQUE INDEX `index_studios_on_name_unique` ON `studios`(`name`);\n\nPRAGMA foreign_keys=ON;"
  },
  {
    "path": "pkg/sqlite/migrations/48_premigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\nfunc pre48(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running pre-migration for schema version 48\")\n\n\tm := schema48PreMigrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\tif err := m.validateScrapedItems(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := m.fixStudioNames(ctx); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype schema48PreMigrator struct {\n\tmigrator\n}\n\nfunc (m *schema48PreMigrator) validateScrapedItems(ctx context.Context) error {\n\tvar count int\n\n\trow := m.db.QueryRowx(\"SELECT COUNT(*) FROM scraped_items\")\n\terr := row.Scan(&count)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif count == 0 {\n\t\treturn nil\n\t}\n\n\treturn fmt.Errorf(\"found %d row(s) in scraped_items table, cannot migrate\", count)\n}\n\nfunc (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error {\n\t// First remove NULL names\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.Exec(\"UPDATE studios SET name = 'NULL' WHERE name IS NULL\")\n\t\treturn err\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Then remove duplicate names\n\n\tdupes := make(map[string][]int)\n\n\t// collect names\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\trows, err := tx.Query(\"SELECT id, name FROM studios ORDER BY name, id\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfirst := true\n\t\tvar lastName string\n\n\t\tfor rows.Next() {\n\t\t\tvar (\n\t\t\t\tid   int\n\t\t\t\tname string\n\t\t\t)\n\n\t\t\terr := rows.Scan(&id, &name)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif first {\n\t\t\t\tfirst = false\n\t\t\t\tlastName = name\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif lastName == name {\n\t\t\t\tdupes[name] = append(dupes[name], id)\n\t\t\t} else {\n\t\t\t\tlastName = name\n\t\t\t}\n\t\t}\n\n\t\treturn rows.Err()\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// rename them\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\tfor name, ids := range dupes {\n\t\t\ti := 0\n\t\t\tfor _, id := range ids {\n\t\t\t\tvar newName string\n\t\t\t\tfor j := 0; ; j++ {\n\t\t\t\t\ti++\n\t\t\t\t\tnewName = fmt.Sprintf(\"%s (%d)\", name, i)\n\n\t\t\t\t\tvar count int\n\n\t\t\t\t\trow := tx.QueryRowx(\"SELECT COUNT(*) FROM studios WHERE name = ?\", newName)\n\t\t\t\t\terr := row.Scan(&count)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\n\t\t\t\t\tif count == 0 {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\n\t\t\t\t\t// try up to 100 times to find a unique name\n\t\t\t\t\tif j == 100 {\n\t\t\t\t\t\treturn fmt.Errorf(\"cannot make unique studio name for %s\", name)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlogger.Infof(\"Renaming duplicate studio id %d to %s\", id, newName)\n\t\t\t\t_, err := tx.Exec(\"UPDATE studios SET name = ? WHERE id = ?\", newName, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPreMigration(48, pre48)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/49_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\nvar migrate49TypeResolution = map[string][]string{\n\t\"Boolean\": {\n\t\t/*\n\t\t\t\"organized\",\n\t\t\t\"interactive\",\n\t\t\t\"ignore_auto_tag\",\n\t\t\t\"performer_favorite\",\n\t\t\t\"filter_favorites\",\n\t\t*/\n\t},\n\t\"Int\": {\n\t\t\"id\",\n\t\t\"rating\",\n\t\t\"rating100\",\n\t\t\"o_counter\",\n\t\t\"duration\",\n\t\t\"tag_count\",\n\t\t\"age\",\n\t\t\"height\",\n\t\t\"height_cm\",\n\t\t\"weight\",\n\t\t\"scene_count\",\n\t\t\"marker_count\",\n\t\t\"image_count\",\n\t\t\"gallery_count\",\n\t\t\"performer_count\",\n\t\t\"interactive_speed\",\n\t\t\"resume_time\",\n\t\t\"play_count\",\n\t\t\"play_duration\",\n\t\t\"parent_count\",\n\t\t\"child_count\",\n\t\t\"performer_age\",\n\t\t\"file_count\",\n\t},\n\t\"Float\": {\n\t\t\"penis_length\",\n\t},\n\t\"Object\": {\n\t\t\"tags\",\n\t\t\"performers\",\n\t\t\"studios\",\n\t\t\"movies\",\n\t\t\"galleries\",\n\t\t\"parents\",\n\t\t\"children\",\n\t\t\"scene_tags\",\n\t\t\"performer_tags\",\n\t},\n}\nvar migrate49NameChanges = map[string]string{\n\t\"rating\":             \"rating100\",\n\t\"parent_studios\":     \"parents\",\n\t\"child_studios\":      \"children\",\n\t\"parent_tags\":        \"parents\",\n\t\"child_tags\":         \"children\",\n\t\"child_tag_count\":    \"child_count\",\n\t\"parent_tag_count\":   \"parent_count\",\n\t\"height\":             \"height_cm\",\n\t\"imageIsMissing\":     \"is_missing\",\n\t\"sceneIsMissing\":     \"is_missing\",\n\t\"galleryIsMissing\":   \"is_missing\",\n\t\"performerIsMissing\": \"is_missing\",\n\t\"tagIsMissing\":       \"is_missing\",\n\t\"studioIsMissing\":    \"is_missing\",\n\t\"movieIsMissing\":     \"is_missing\",\n\t\"favorite\":           \"filter_favorites\",\n\t\"hasMarkers\":         \"has_markers\",\n\t\"parentTags\":         \"parents\",\n\t\"childTags\":          \"children\",\n\t\"phash\":              \"phash_distance\",\n\t\"scene_code\":         \"code\",\n\t\"hasChapters\":        \"has_chapters\",\n\t\"sceneChecksum\":      \"checksum\",\n\t\"galleryChecksum\":    \"checksum\",\n\t\"sceneTags\":          \"scene_tags\",\n\t\"performerTags\":      \"performer_tags\",\n\t\"stash_id\":           \"stash_id_endpoint\",\n}\n\nfunc post49(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 49\")\n\n\tm := schema49Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn m.migrateSavedFilters(ctx)\n}\n\ntype schema49Migrator struct {\n\tmigrator\n}\n\nfunc (m *schema49Migrator) migrateSavedFilters(ctx context.Context) error {\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\trows, err := tx.Query(\"SELECT id, mode, find_filter FROM saved_filters ORDER BY id\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfor rows.Next() {\n\t\t\tvar (\n\t\t\t\tid         int\n\t\t\t\tmode       models.FilterMode\n\t\t\t\tfindFilter string\n\t\t\t)\n\n\t\t\terr := rows.Scan(&id, &mode, &findFilter)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tasRawMessage := json.RawMessage(findFilter)\n\n\t\t\tnewFindFilter, err := m.getFindFilter(asRawMessage)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to get find filter for saved filter  %s : %w\", findFilter, err)\n\t\t\t}\n\n\t\t\tobjectFilter, err := m.getObjectFilter(mode, asRawMessage)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to get object filter for saved filter  %s : %w\", findFilter, err)\n\t\t\t}\n\n\t\t\tuiOptions, err := m.getDisplayOptions(asRawMessage)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to get display options for saved filter  %s : %w\", findFilter, err)\n\t\t\t}\n\n\t\t\t_, err = tx.Exec(\"UPDATE saved_filters SET find_filter = ?, object_filter = ?, ui_options = ? WHERE id = ?\", newFindFilter, objectFilter, uiOptions, id)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to update saved filter %d: %w\", id, err)\n\t\t\t}\n\t\t}\n\n\t\treturn rows.Err()\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema49Migrator) getDisplayOptions(data json.RawMessage) (json.RawMessage, error) {\n\ttype displayOptions struct {\n\t\tDisplayMode *int `json:\"disp\"`\n\t\tZoomIndex   *int `json:\"z\"`\n\t}\n\n\tvar opts displayOptions\n\tif err := json.Unmarshal(data, &opts); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal display options: %w\", err)\n\t}\n\n\tret := make(map[string]interface{})\n\tif opts.DisplayMode != nil {\n\t\tret[\"display_mode\"] = *opts.DisplayMode\n\t}\n\tif opts.ZoomIndex != nil {\n\t\tret[\"zoom_index\"] = *opts.ZoomIndex\n\t}\n\n\treturn json.Marshal(ret)\n}\n\nfunc (m *schema49Migrator) getFindFilter(data json.RawMessage) (json.RawMessage, error) {\n\ttype findFilterJson struct {\n\t\tQ         *string `json:\"q\"`\n\t\tPage      *int    `json:\"page\"`\n\t\tPerPage   *int    `json:\"perPage\"`\n\t\tSort      *string `json:\"sortby\"`\n\t\tDirection *string `json:\"sortdir\"`\n\t}\n\n\tppDefault := 40\n\tpageDefault := 1\n\tqDefault := \"\"\n\tsortDefault := \"date\"\n\tasc := \"asc\"\n\tff := findFilterJson{Q: &qDefault, Page: &pageDefault, PerPage: &ppDefault, Sort: &sortDefault, Direction: &asc}\n\tif err := json.Unmarshal(data, &ff); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal find filter: %w\", err)\n\t}\n\n\tnewDir := strings.ToUpper(*ff.Direction)\n\tff.Direction = &newDir\n\n\ttype findFilterRewrite struct {\n\t\tQ         *string `json:\"q\"`\n\t\tPage      *int    `json:\"page\"`\n\t\tPerPage   *int    `json:\"per_page\"`\n\t\tSort      *string `json:\"sort\"`\n\t\tDirection *string `json:\"direction\"`\n\t}\n\n\tfr := findFilterRewrite(ff)\n\n\treturn json.Marshal(fr)\n}\n\nfunc (m *schema49Migrator) getObjectFilter(mode models.FilterMode, data json.RawMessage) (json.RawMessage, error) {\n\ttype criteriaJson struct {\n\t\tCriteria []string `json:\"c\"`\n\t}\n\n\tvar c criteriaJson\n\tif err := json.Unmarshal(data, &c); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal object filter: %w\", err)\n\t}\n\n\tret := make(map[string]interface{})\n\tfor _, raw := range c.Criteria {\n\t\tif err := m.convertCriterion(mode, ret, raw); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn json.Marshal(ret)\n}\n\nfunc (m *schema49Migrator) convertCriterion(mode models.FilterMode, out map[string]interface{}, criterion string) error {\n\t// convert to a map\n\tret := make(map[string]interface{})\n\n\tif err := json.Unmarshal([]byte(criterion), &ret); err != nil {\n\t\treturn fmt.Errorf(\"failed to unmarshal criterion: %w\", err)\n\t}\n\n\tfield := ret[\"type\"].(string)\n\t// Some names are deprecated\n\tif newFieldName, ok := migrate49NameChanges[field]; ok {\n\t\tfield = newFieldName\n\t}\n\tdelete(ret, \"type\")\n\n\t// unset the value for IS_NULL or NOT_NULL modifiers\n\tmodifier := models.CriterionModifier(ret[\"modifier\"].(string))\n\tif modifier == models.CriterionModifierIsNull || modifier == models.CriterionModifierNotNull {\n\t\tdelete(ret, \"value\")\n\t} else {\n\t\t// Find out whether the object needs some adjustment/has non-string content attached\n\t\t// Only adjust if value is present\n\t\tif v, ok := ret[\"value\"]; ok && v != nil {\n\t\t\tvar err error\n\t\t\tswitch {\n\t\t\tcase arrayContains(migrate49TypeResolution[\"Boolean\"], field):\n\t\t\t\tret[\"value\"], err = m.adjustCriterionValue(ret[\"value\"], \"bool\")\n\t\t\tcase arrayContains(migrate49TypeResolution[\"Int\"], field):\n\t\t\t\tret[\"value\"], err = m.adjustCriterionValue(ret[\"value\"], \"int\")\n\t\t\tcase arrayContains(migrate49TypeResolution[\"Float\"], field):\n\t\t\t\tret[\"value\"], err = m.adjustCriterionValue(ret[\"value\"], \"float64\")\n\t\t\tcase arrayContains(migrate49TypeResolution[\"Object\"], field):\n\t\t\t\tret[\"value\"], err = m.adjustCriterionValue(ret[\"value\"], \"object\")\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to adjust criterion value for %q: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tout[field] = ret\n\n\treturn nil\n}\n\nfunc arrayContains(sl []string, name string) bool {\n\tfor _, value := range sl {\n\t\tif value == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// General Function for converting the types inside a criterion\nfunc (m *schema49Migrator) adjustCriterionValue(value interface{}, typ string) (interface{}, error) {\n\tif mapvalue, ok := value.(map[string]interface{}); ok {\n\t\t// Primitive values and lists of them\n\t\tvar err error\n\t\tfor _, next := range []string{\"value\", \"value2\"} {\n\t\t\tif valmap, ok := mapvalue[next].([]string); ok {\n\t\t\t\tvar valNewMap []interface{}\n\t\t\t\tfor index, v := range valmap {\n\t\t\t\t\tvalNewMap[index], err = m.convertValue(v, typ)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tmapvalue[next] = valNewMap\n\t\t\t} else if _, ok := mapvalue[next]; ok {\n\t\t\t\tmapvalue[next], err = m.convertValue(mapvalue[next], typ)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Items\n\t\tfor _, next := range []string{\"items\", \"excluded\"} {\n\t\t\tif _, ok := mapvalue[next]; ok {\n\t\t\t\tmapvalue[next], err = m.adjustCriterionItem(mapvalue[next])\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Those Values are always Int\n\t\tfor _, next := range []string{\"Distance\", \"Depth\"} {\n\t\t\tif _, ok := mapvalue[next]; ok {\n\t\t\t\tmapvalue[next], err = strconv.ParseInt(mapvalue[next].(string), 10, 64)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn mapvalue, nil\n\t} else if _, ok := value.(string); ok {\n\t\t// Singular Primitive Values\n\t\treturn m.convertValue(value, typ)\n\t} else if listvalue, ok := value.([]interface{}); ok {\n\t\t// Items as a singular value, as well as singular lists\n\t\tvar err error\n\t\tif typ == \"object\" {\n\t\t\tvalue, err = m.adjustCriterionItem(value)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t} else {\n\t\t\tfor index, val := range listvalue {\n\t\t\t\tlistvalue[index], err = m.convertValue(val, typ)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tvalue = listvalue\n\t\t}\n\n\t\treturn value, nil\n\t} else if _, ok := value.(int); ok {\n\t\treturn value, nil\n\t} else if _, ok := value.(float64); ok {\n\t\treturn value, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"could not recognize format of value %v\", value)\n}\n\n// Converts values inside a criterion that represent some objects, like performer or studio.\nfunc (m *schema49Migrator) adjustCriterionItem(value interface{}) (interface{}, error) {\n\t// Basically, this first converts step by step the value, after that it adjusts id and Depth (of parent/child studios) to int\n\tif itemlist, ok := value.([]interface{}); ok {\n\t\tvar itemNewList []interface{}\n\t\tfor _, val := range itemlist {\n\t\t\tif val, ok := val.(map[string]interface{}); ok {\n\t\t\t\tnewItem := make(map[string]interface{})\n\t\t\t\tfor index, v := range val {\n\t\t\t\t\tif v, ok := v.(string); ok {\n\t\t\t\t\t\tswitch index {\n\t\t\t\t\t\tcase \"id\":\n\t\t\t\t\t\t\tif formattedOut, ok := strconv.ParseInt(v, 10, 64); ok == nil {\n\t\t\t\t\t\t\t\tnewItem[\"id\"] = formattedOut\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"Depth\":\n\t\t\t\t\t\t\tif formattedOut, ok := strconv.ParseInt(v, 10, 64); ok == nil {\n\t\t\t\t\t\t\t\tnewItem[\"Depth\"] = formattedOut\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\tnewItem[index] = v\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\titemNewList = append(itemNewList, newItem)\n\t\t\t}\n\t\t}\n\t\treturn itemNewList, nil\n\t}\n\treturn nil, fmt.Errorf(\"could not recognize %v as an item list\", value)\n}\n\n// Converts a value of type string to its according type, given by string\nfunc (m *schema49Migrator) convertValue(value interface{}, typ string) (interface{}, error) {\n\tvalueType := reflect.TypeOf(value).Name()\n\tif typ == valueType || (typ == \"int\" && valueType == \"float64\") || (typ == \"float64\" && valueType == \"int\") || value == \"\" {\n\t\treturn value, nil\n\t}\n\n\tif val, ok := value.(string); ok {\n\t\tswitch typ {\n\t\tcase \"float64\":\n\t\t\treturn strconv.ParseFloat(val, 64)\n\t\tcase \"int\":\n\t\t\treturn strconv.ParseInt(val, 10, 64)\n\t\tcase \"bool\":\n\t\t\treturn strconv.ParseBool(val)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"no valid conversion type for %v, need bool, int or float64\", typ)\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"cannot convert %v (%T) to %s\", value, value, typ)\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(49, post49)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/49_saved_filter_refactor.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\n-- remove filter column\nCREATE TABLE `saved_filters_new` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(510) not null,\n  `mode` varchar(255) not null,\n  `find_filter` blob,\n  `object_filter` blob,\n  `ui_options` blob\n);\n\n-- move filter data into find_filter to be migrated in the post-migration\nINSERT INTO `saved_filters_new`\n  (\n    `id`,\n    `name`,\n    `mode`,\n    `find_filter`\n  )\n  SELECT \n    `id`,\n    `name`,\n    `mode`,\n    `filter`\n  FROM `saved_filters`;\n\nDROP INDEX `index_saved_filters_on_mode_name_unique`;\nDROP TABLE `saved_filters`;\nALTER TABLE `saved_filters_new` rename to `saved_filters`;\n\nCREATE UNIQUE INDEX `index_saved_filters_on_mode_name_unique` on `saved_filters` (`mode`, `name`);\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/4_movie.up.sql",
    "content": "CREATE TABLE `movies` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(255),\n  `aliases` varchar(255),\n  `duration` varchar(6),\n  `date` date,\n  `rating` varchar(1),\n  `director` varchar(255),\n  `synopsis` text,\n  `front_image` blob not null,\n  `back_image` blob,\n  `checksum` varchar(255) not null,\n  `url` varchar(255),\n  `created_at` datetime not null,\n  `updated_at` datetime not null\n);\nCREATE TABLE `movies_scenes` (\n  `movie_id` integer,\n  `scene_id` integer,\n  `scene_index` varchar(2),\n  foreign key(`movie_id`) references `movies`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\n\n\nALTER TABLE `scraped_items` ADD COLUMN `movie_id` integer;\nCREATE UNIQUE INDEX `movies_checksum_unique` on `movies` (`checksum`);\nCREATE UNIQUE INDEX `index_movie_id_scene_index_unique` ON `movies_scenes` ( `movie_id`, `scene_index` );\nCREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`);\nCREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`);\n\n\n"
  },
  {
    "path": "pkg/sqlite/migrations/50_image_urls.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\nCREATE TABLE `image_urls` (\n  `image_id` integer NOT NULL,\n  `position` integer NOT NULL,\n  `url` varchar(255) NOT NULL,\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE,\n  PRIMARY KEY(`image_id`, `position`, `url`)\n);\n\nCREATE INDEX `image_urls_url` on `image_urls` (`url`);\n\n-- drop url\nCREATE TABLE \"images_new\" (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255),\n  `rating` tinyint,\n  `studio_id` integer,\n  `o_counter` tinyint not null default 0,\n  `organized` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null, \n  `date` date,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n);\n\nINSERT INTO `images_new`\n  (\n    `id`,\n    `title`,\n    `rating`,\n    `studio_id`,\n    `o_counter`,\n    `organized`,\n    `created_at`,\n    `updated_at`,\n    `date`\n  )\n  SELECT \n    `id`,\n    `title`,\n    `rating`,\n    `studio_id`,\n    `o_counter`,\n    `organized`,\n    `created_at`,\n    `updated_at`,\n    `date`\n  FROM `images`;\n\nINSERT INTO `image_urls`\n  (\n    `image_id`,\n    `position`,\n    `url`\n  )\n  SELECT \n    `id`,\n    '0',\n    `url`\n  FROM `images`\n  WHERE `images`.`url` IS NOT NULL AND `images`.`url` != '';\n\nDROP INDEX `index_images_on_studio_id`;\nDROP TABLE `images`;\nALTER TABLE `images_new` rename to `images`;\n\nCREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`);\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/51_gallery_urls.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\nCREATE TABLE `gallery_urls` (\n  `gallery_id` integer NOT NULL,\n  `position` integer NOT NULL,\n  `url` varchar(255) NOT NULL,\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,\n  PRIMARY KEY(`gallery_id`, `position`, `url`)\n);\n\nCREATE INDEX `gallery_urls_url` on `gallery_urls` (`url`);\n\n-- drop url\nCREATE TABLE `galleries_new` (\n  `id` integer not null primary key autoincrement,\n  `folder_id` integer,\n  `title` varchar(255),\n  `date` date,\n  `details` text,\n  `studio_id` integer,\n  `rating` tinyint,\n  `organized` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL,\n  foreign key(`folder_id`) references `folders`(`id`) on delete SET NULL\n);\n\nINSERT INTO `galleries_new`\n  (\n    `id`,\n    `folder_id`,\n    `title`,\n    `date`,\n    `details`,\n    `studio_id`,\n    `rating`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `folder_id`,\n    `title`,\n    `date`,\n    `details`,\n    `studio_id`,\n    `rating`,\n    `organized`,\n    `created_at`,\n    `updated_at`\n  FROM `galleries`;\n\nINSERT INTO `gallery_urls`\n  (\n    `gallery_id`,\n    `position`,\n    `url`\n  )\n  SELECT \n    `id`,\n    '0',\n    `url`\n  FROM `galleries`\n  WHERE `galleries`.`url` IS NOT NULL AND `galleries`.`url` != '';\n\nDROP INDEX `index_galleries_on_studio_id`;\nDROP INDEX `index_galleries_on_folder_id_unique`;\nDROP TABLE `galleries`;\nALTER TABLE `galleries_new` rename to `galleries`;\n\nCREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`);\nCREATE UNIQUE INDEX `index_galleries_on_folder_id_unique` on `galleries` (`folder_id`);\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/52_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\ntype schema52Migrator struct {\n\tmigrator\n}\n\nfunc post52(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 52\")\n\n\tm := schema52Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn m.migrate(ctx)\n}\n\nfunc (m *schema52Migrator) migrate(ctx context.Context) error {\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\tquery := \"SELECT `folders`.`id`, `folders`.`path`, `parent_folder`.`path` FROM `folders` \" +\n\t\t\t\"INNER JOIN `folders` AS `parent_folder` ON `parent_folder`.`id` = `folders`.`parent_folder_id`\"\n\n\t\trows, err := tx.Query(query)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfor rows.Next() {\n\t\t\tvar (\n\t\t\t\tid               int\n\t\t\t\tfolderPath       string\n\t\t\t\tparentFolderPath string\n\t\t\t)\n\n\t\t\terr := rows.Scan(&id, &folderPath, &parentFolderPath)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// ensure folder path is correct\n\t\t\tif !strings.HasPrefix(folderPath, parentFolderPath) {\n\t\t\t\tlogger.Debugf(\"folder path %s does not have prefix %s. Correcting...\", folderPath, parentFolderPath)\n\n\t\t\t\t// get the basename of the zip folder path and append it to the correct path\n\t\t\t\tfolderBasename := filepath.Base(folderPath)\n\t\t\t\tcorrectPath := filepath.Join(parentFolderPath, folderBasename)\n\n\t\t\t\tlogger.Infof(\"correcting folder path %s to %s\", folderPath, correctPath)\n\n\t\t\t\t// ensure the correct path is unique\n\t\t\t\tvar v int\n\t\t\t\tisEmptyErr := tx.Get(&v, \"SELECT 1 FROM folders WHERE path = ?\", correctPath)\n\t\t\t\tif isEmptyErr != nil && !errors.Is(isEmptyErr, sql.ErrNoRows) {\n\t\t\t\t\treturn fmt.Errorf(\"error checking if correct path %s is unique: %w\", correctPath, isEmptyErr)\n\t\t\t\t}\n\n\t\t\t\tif isEmptyErr == nil {\n\t\t\t\t\t// correct path is not unique, log and skip\n\t\t\t\t\tlogger.Warnf(\"correct path %s already exists, skipping...\", correctPath)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif _, err := tx.Exec(\"UPDATE folders SET path = ? WHERE id = ?\", correctPath, id); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error updating folder path %s to %s: %w\", folderPath, correctPath, err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn rows.Err()\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(52, post52)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/52_zip_folder_data_correct.up.sql",
    "content": "-- no schema changes"
  },
  {
    "path": "pkg/sqlite/migrations/53_gallery_photographer_code.up.sql",
    "content": "ALTER TABLE `galleries` ADD COLUMN `code` text;\nALTER TABLE `galleries` ADD COLUMN `photographer` text;\n"
  },
  {
    "path": "pkg/sqlite/migrations/54_image_code_details_photographer.up.sql",
    "content": "ALTER TABLE `images` ADD COLUMN `code` text;\nALTER TABLE `images` ADD COLUMN `photographer` text;\nALTER TABLE `images` ADD COLUMN `details` text;"
  },
  {
    "path": "pkg/sqlite/migrations/55_manual_history.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\nCREATE TABLE `scenes_view_dates` (\n  `scene_id` integer,\n  `view_date` datetime not null,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\nCREATE TABLE `scenes_o_dates` (\n  `scene_id` integer,\n  `o_date` datetime not null,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\n-- drop o_counter, play_count and last_played_at\nCREATE TABLE \"scenes_new\" (\n  `id` integer not null primary key autoincrement,\n  `title` varchar(255),\n  `details` text,\n  `date` date,\n  `rating` tinyint,\n  `studio_id` integer,\n  `organized` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null, \n  `code` text, \n  `director` text, \n  `resume_time` float not null default 0, \n  `play_duration` float not null default 0, \n  `cover_blob` varchar(255) REFERENCES `blobs`(`checksum`),\n  foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL\n);\n\nINSERT INTO `scenes_new`\n  (\n    `id`,\n    `title`,\n    `details`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `organized`,\n    `created_at`,\n    `updated_at`,\n    `code`,\n    `director`,\n    `resume_time`,\n    `play_duration`,\n    `cover_blob`\n  )\n  SELECT \n    `id`,\n    `title`,\n    `details`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `organized`,\n    `created_at`,\n    `updated_at`,\n    `code`,\n    `director`,\n    `resume_time`,\n    `play_duration`,\n    `cover_blob`\n  FROM `scenes`;\n\nWITH max_view_count AS (\n  SELECT MAX(play_count) AS max_count\n  FROM scenes\n), numbers AS (\n  SELECT 1 AS n\n  FROM max_view_count\n  UNION ALL\n  SELECT n + 1\n  FROM numbers\n  WHERE n < (SELECT max_count FROM max_view_count)\n)\nINSERT INTO scenes_view_dates (scene_id, view_date)\nSELECT scenes.id, \n       CASE \n         WHEN numbers.n = scenes.play_count THEN COALESCE(scenes.last_played_at, scenes.created_at) \n         ELSE scenes.created_at\n       END AS view_date\nFROM scenes\nJOIN numbers\nWHERE numbers.n <= scenes.play_count;\n\nWITH numbers AS (\n  SELECT 1 AS n\n  UNION ALL\n  SELECT n + 1\n  FROM numbers\n  WHERE n < (SELECT MAX(o_counter) FROM scenes)\n)\nINSERT INTO scenes_o_dates (scene_id, o_date)\nSELECT scenes.id, \n       CASE \n         WHEN numbers.n <= scenes.o_counter THEN scenes.created_at\n       END AS o_date\nFROM scenes\nCROSS JOIN numbers\nWHERE numbers.n <= scenes.o_counter;\n\nDROP INDEX `index_scenes_on_studio_id`;\nDROP TABLE `scenes`;\nALTER TABLE `scenes_new` rename to `scenes`;\n\nCREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`);\n\nPRAGMA foreign_keys=ON;"
  },
  {
    "path": "pkg/sqlite/migrations/55_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\ntype schema55Migrator struct {\n\tmigrator\n}\n\nfunc post55(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 55\")\n\n\tm := schema55Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn m.migrate(ctx)\n}\n\nfunc (m *schema55Migrator) migrate(ctx context.Context) error {\n\t// the last_played_at column was storing in a different format than the rest of the timestamps\n\t// convert the play history date to the correct format\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\tquery := \"SELECT DISTINCT `scene_id`, `view_date` FROM `scenes_view_dates`\"\n\n\t\trows, err := tx.Query(query)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfor rows.Next() {\n\t\t\tvar (\n\t\t\t\tid       int\n\t\t\t\tviewDate sqlite.Timestamp\n\t\t\t)\n\n\t\t\terr := rows.Scan(&id, &viewDate)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tutcTimestamp := sqlite.UTCTimestamp{\n\t\t\t\tTimestamp: viewDate,\n\t\t\t}\n\n\t\t\t// convert the timestamp to the correct format\n\t\t\tif _, err := tx.Exec(\"UPDATE scenes_view_dates SET view_date = ? WHERE view_date = ?\", utcTimestamp, viewDate.Timestamp); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error correcting view date %s to %s: %w\", viewDate.Timestamp, viewDate, err)\n\t\t\t}\n\t\t}\n\n\t\treturn rows.Err()\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(55, post55)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/56_studio_favorite.up.sql",
    "content": "ALTER TABLE `studios` ADD COLUMN `favorite` boolean not null default '0';\n"
  },
  {
    "path": "pkg/sqlite/migrations/57_tag_favorite.up.sql",
    "content": "ALTER TABLE `tags` ADD COLUMN `favorite` boolean not null default '0';"
  },
  {
    "path": "pkg/sqlite/migrations/58_config_correct.up.sql",
    "content": "-- no schema changes"
  },
  {
    "path": "pkg/sqlite/migrations/58_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/spf13/cast\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype schema58Migrator struct {\n\tmigrator\n}\n\nfunc post58(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 58\")\n\n\tm := schema58Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn m.migrate()\n}\n\nfunc (m *schema58Migrator) migrate() error {\n\tif err := m.migrateConfig(); err != nil {\n\t\treturn fmt.Errorf(\"failed to migrate config: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// fromSnakeCase converts a string from snake_case to camelCase\nfunc (m *schema58Migrator) fromSnakeCase(v string) string {\n\tvar buf bytes.Buffer\n\tleadingUnderscore := true\n\tcapvar := false\n\tfor i, c := range v {\n\t\tswitch {\n\t\tcase c == '_' && !leadingUnderscore && i > 0:\n\t\t\tcapvar = true\n\t\tcase c == '_' && leadingUnderscore:\n\t\t\tbuf.WriteRune(c)\n\t\tcase capvar:\n\t\t\tbuf.WriteRune(unicode.ToUpper(c))\n\t\t\tcapvar = false\n\t\tdefault:\n\t\t\tleadingUnderscore = false\n\t\t\tbuf.WriteRune(c)\n\t\t}\n\t}\n\treturn buf.String()\n}\n\n// fromSnakeCaseMap recursively converts a map using snake_case keys to camelCase keys\nfunc (m *schema58Migrator) fromSnakeCaseMap(mm map[string]interface{}) map[string]interface{} {\n\treturn m.fromSnakeCaseValue(mm).(map[string]interface{})\n}\n\nfunc (m *schema58Migrator) fromSnakeCaseValue(val interface{}) interface{} {\n\tswitch v := val.(type) {\n\tcase map[interface{}]interface{}:\n\t\tret := cast.ToStringMap(v)\n\t\tfor k, vv := range ret {\n\t\t\tadjKey := m.fromSnakeCase(k)\n\t\t\tret[adjKey] = m.fromSnakeCaseValue(vv)\n\t\t}\n\t\treturn ret\n\tcase map[string]interface{}:\n\t\tret := make(map[string]interface{})\n\t\tfor k, vv := range v {\n\t\t\tadjKey := m.fromSnakeCase(k)\n\t\t\tret[adjKey] = m.fromSnakeCaseValue(vv)\n\t\t}\n\t\treturn ret\n\tcase []interface{}:\n\t\tret := make([]interface{}, len(v))\n\t\tfor i, vv := range v {\n\t\t\tret[i] = m.fromSnakeCaseValue(vv)\n\t\t}\n\t\treturn ret\n\tdefault:\n\t\treturn v\n\t}\n}\n\n// renameKey renames a fully qualified key name in a map\nfunc (m *schema58Migrator) renameKey(mm map[string]interface{}, from, to string) {\n\tnm := utils.NestedMap(mm)\n\tv, found := nm.Get(from)\n\tif !found {\n\t\treturn\n\t}\n\n\tnm.Delete(from)\n\tnm.Set(to, v)\n}\n\nfunc (m *schema58Migrator) renameFrontPageContentKeys(ui map[string]interface{}) {\n\tfrontPageContent, found := ui[\"frontPageContent\"].([]interface{})\n\tif !found {\n\t\treturn\n\t}\n\n\tfor _, v := range frontPageContent {\n\t\tvm := v.(map[string]interface{})\n\t\tm.renameKey(vm, \"savedfilterid\", \"savedFilterId\")\n\t\tm.renameKey(vm, \"sortby\", \"sortBy\")\n\t}\n}\n\nfunc (m *schema58Migrator) migrateConfig() error {\n\tc := config.GetInstance()\n\n\torgPath := c.GetConfigFile()\n\n\tif orgPath == \"\" {\n\t\t// no config file to migrate (usually in a test)\n\t\treturn nil\n\t}\n\n\tui := c.GetUIConfiguration()\n\tif len(ui) == 0 {\n\t\t// no UI config to migrate\n\t\treturn nil\n\t}\n\n\t// save a backup of the original config file\n\tbackupPath := fmt.Sprintf(\"%s.57.%s\", orgPath, time.Now().Format(\"20060102_150405\"))\n\n\tdata, err := c.Marshal()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal backup config: %w\", err)\n\t}\n\n\tlogger.Infof(\"Backing up config to %s\", backupPath)\n\tif err := os.WriteFile(backupPath, data, 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write backup config: %w\", err)\n\t}\n\n\t// migrate the plugin and UI configs from snake_case to camelCase\n\tif ui != nil {\n\t\tui = m.fromSnakeCaseMap(ui)\n\n\t\t// find and rename specific frontEndPage keys\n\t\tm.renameFrontPageContentKeys(ui)\n\n\t\tc.SetUIConfiguration(ui)\n\t}\n\n\tplugins := c.GetAllPluginConfiguration()\n\tnewPlugins := make(map[string]interface{})\n\tfor key, value := range plugins {\n\t\tkey = m.fromSnakeCase(key)\n\t\tnewPlugins[key] = m.fromSnakeCaseMap(value)\n\t}\n\n\tc.SetInterface(config.PluginsSetting, newPlugins)\n\tif err := c.Write(); err != nil {\n\t\treturn fmt.Errorf(\"failed to write config: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(58, post58)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/59_movie_urls.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\nCREATE TABLE `movie_urls` (\n  `movie_id` integer NOT NULL,\n  `position` integer NOT NULL,\n  `url` varchar(255) NOT NULL,\n  foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE,\n  PRIMARY KEY(`movie_id`, `position`, `url`)\n);\n\nCREATE INDEX `movie_urls_url` on `movie_urls` (`url`);\n\n-- drop url\nCREATE TABLE `movies_new` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(255) not null,\n  `aliases` varchar(255),\n  `duration` integer,\n  `date` date,\n  `rating` tinyint,\n  `studio_id` integer REFERENCES `studios`(`id`) ON DELETE SET NULL,\n  `director` varchar(255),\n  `synopsis` text,\n  `created_at` datetime not null,\n  `updated_at` datetime not null, \n  `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`), \n  `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`)\n);\n\nINSERT INTO `movies_new`\n  (\n    `id`,\n    `name`,\n    `aliases`,\n    `duration`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `director`,\n    `synopsis`,\n    `created_at`,\n    `updated_at`,\n    `front_image_blob`,\n    `back_image_blob`\n  )\n  SELECT \n    `id`,\n    `name`,\n    `aliases`,\n    `duration`,\n    `date`,\n    `rating`,\n    `studio_id`,\n    `director`,\n    `synopsis`,\n    `created_at`,\n    `updated_at`,\n    `front_image_blob`,\n    `back_image_blob`\n  FROM `movies`;\n\nINSERT INTO `movie_urls`\n  (\n    `movie_id`,\n    `position`,\n    `url`\n  )\n  SELECT \n    `id`,\n    '0',\n    `url`\n  FROM `movies`\n  WHERE `movies`.`url` IS NOT NULL AND `movies`.`url` != '';\n\nDROP INDEX `index_movies_on_name_unique`;\nDROP INDEX `index_movies_on_studio_id`;\nDROP TABLE `movies`;\nALTER TABLE `movies_new` rename to `movies`;\n\nCREATE INDEX `index_movies_on_name` ON `movies`(`name`);\nCREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`);\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/5_performer_gender.down.sql",
    "content": "\nPRAGMA foreign_keys=off;\n\n-- need to re-create the performers table without the added column.\n-- also need re-create the performers_scenes table due to the foreign key\n\n-- rename existing performers table\nALTER TABLE `performers` RENAME TO `performers_old`;\nALTER TABLE `performers_scenes` RENAME TO `performers_scenes_old`;\n\n-- drop the indexes\nDROP INDEX IF EXISTS `index_performers_on_name`;\nDROP INDEX IF EXISTS `index_performers_on_checksum`;\nDROP INDEX IF EXISTS `index_performers_scenes_on_scene_id`;\nDROP INDEX IF EXISTS `index_performers_scenes_on_performer_id`;\n\n-- recreate the tables\nCREATE TABLE `performers` (\n  `id` integer not null primary key autoincrement,\n  `image` blob not null,\n  `checksum` varchar(255) not null,\n  `name` varchar(255),\n  `url` varchar(255),\n  `twitter` varchar(255),\n  `instagram` varchar(255),\n  `birthdate` date,\n  `ethnicity` varchar(255),\n  `country` varchar(255),\n  `eye_color` varchar(255),\n  `height` varchar(255),\n  `measurements` varchar(255),\n  `fake_tits` varchar(255),\n  `career_length` varchar(255),\n  `tattoos` varchar(255),\n  `piercings` varchar(255),\n  `aliases` varchar(255),\n  `favorite` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null\n);\n\nCREATE TABLE `performers_scenes` (\n  `performer_id` integer,\n  `scene_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\n\nINSERT INTO `performers` \n  SELECT \n  `id`,\n  `image`,\n  `checksum`,\n  `name`,\n  `url`,\n  `twitter`,\n  `instagram`,\n  `birthdate`,\n  `ethnicity`,\n  `country`,\n  `eye_color`,\n  `height`,\n  `measurements`,\n  `fake_tits`,\n  `career_length`,\n  `tattoos`,\n  `piercings`,\n  `aliases`,\n  `favorite`,\n  `created_at`,\n  `updated_at`\n  FROM `performers_old`;\n\nINSERT INTO `performers_scenes`\n  SELECT\n  `performer_id`,\n  `scene_id`\n  FROM `performers_scenes_old`;\n\nDROP TABLE `performers_scenes_old`;\nDROP TABLE `performers_old`;\n\n-- re-create the indexes after removing the old tables\nCREATE INDEX `index_performers_on_name` on `performers` (`name`);\nCREATE INDEX `index_performers_on_checksum` on `performers` (`checksum`);\nCREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`);\nCREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`);\n\nPRAGMA foreign_keys=on;\n"
  },
  {
    "path": "pkg/sqlite/migrations/5_performer_gender.up.sql",
    "content": "ALTER TABLE `performers` ADD COLUMN `gender` varchar(20);\n"
  },
  {
    "path": "pkg/sqlite/migrations/60_default_filter_move.up.sql",
    "content": "-- no schema changes\n-- default filters will be removed in post-migration"
  },
  {
    "path": "pkg/sqlite/migrations/60_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\ntype schema60Migrator struct {\n\tmigrator\n}\n\nfunc post60(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 60\")\n\n\tm := schema60Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn m.migrate(ctx)\n}\n\nfunc (m *schema60Migrator) decodeJSON(s string, v interface{}) {\n\tif s == \"\" {\n\t\treturn\n\t}\n\n\tif err := json.Unmarshal([]byte(s), v); err != nil {\n\t\tlogger.Errorf(\"error decoding json %q: %v\", s, err)\n\t}\n}\n\ntype schema60DefaultFilters map[string]interface{}\n\nfunc (m *schema60Migrator) migrate(ctx context.Context) error {\n\n\t// save default filters into the UI config\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\tquery := \"SELECT id, mode, find_filter, object_filter, ui_options FROM `saved_filters` WHERE `name` = ''\"\n\n\t\trows, err := tx.Query(query)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tdefaultFilters := make(schema60DefaultFilters)\n\n\t\tfor rows.Next() {\n\t\t\tvar (\n\t\t\t\tid              int\n\t\t\t\tmode            string\n\t\t\t\tfindFilterStr   string\n\t\t\t\tobjectFilterStr string\n\t\t\t\tuiOptionsStr    string\n\t\t\t)\n\n\t\t\tif err := rows.Scan(&id, &mode, &findFilterStr, &objectFilterStr, &uiOptionsStr); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// convert the filters to the correct format\n\t\t\tfindFilter := make(map[string]interface{})\n\t\t\tobjectFilter := make(map[string]interface{})\n\t\t\tuiOptions := make(map[string]interface{})\n\n\t\t\tm.decodeJSON(findFilterStr, &findFilter)\n\t\t\tm.decodeJSON(objectFilterStr, &objectFilter)\n\t\t\tm.decodeJSON(uiOptionsStr, &uiOptions)\n\n\t\t\to := map[string]interface{}{\n\t\t\t\t\"mode\":          mode,\n\t\t\t\t\"find_filter\":   findFilter,\n\t\t\t\t\"object_filter\": objectFilter,\n\t\t\t\t\"ui_options\":    uiOptions,\n\t\t\t}\n\n\t\t\tdefaultFilters[strings.ToLower(mode)] = o\n\t\t}\n\n\t\tif err := rows.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := m.saveDefaultFilters(defaultFilters); err != nil {\n\t\t\treturn fmt.Errorf(\"saving default filters: %w\", err)\n\t\t}\n\n\t\t// remove the default filters from the database\n\t\tquery = \"DELETE FROM `saved_filters` WHERE `name` = ''\"\n\t\tif _, err := tx.Exec(query); err != nil {\n\t\t\treturn fmt.Errorf(\"deleting default filters: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema60Migrator) saveDefaultFilters(defaultFilters schema60DefaultFilters) error {\n\tif len(defaultFilters) == 0 {\n\t\tlogger.Debugf(\"no default filters to save\")\n\t\treturn nil\n\t}\n\n\t// save the default filters into the UI config\n\tconfig := config.GetInstance()\n\n\torgPath := config.GetConfigFile()\n\n\tif orgPath == \"\" {\n\t\t// no config file to migrate (usually in a test or new system)\n\t\tlogger.Debugf(\"no config file to migrate\")\n\t\treturn nil\n\t}\n\n\tuiConfig := config.GetUIConfiguration()\n\tif uiConfig == nil {\n\t\tuiConfig = make(map[string]interface{})\n\t}\n\n\t// if the defaultFilters key already exists, don't overwrite them\n\tif _, found := uiConfig[\"defaultFilters\"]; found {\n\t\tlogger.Warn(\"defaultFilters already exists in the UI config, skipping migration\")\n\t\treturn nil\n\t}\n\n\tif err := m.backupConfig(orgPath); err != nil {\n\t\treturn fmt.Errorf(\"backing up config: %w\", err)\n\t}\n\n\tuiConfig[\"defaultFilters\"] = map[string]interface{}(defaultFilters)\n\tconfig.SetUIConfiguration(uiConfig)\n\n\tif err := config.Write(); err != nil {\n\t\treturn fmt.Errorf(\"failed to write config: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema60Migrator) backupConfig(orgPath string) error {\n\tc := config.GetInstance()\n\n\t// save a backup of the original config file\n\tbackupPath := fmt.Sprintf(\"%s.59.%s\", orgPath, time.Now().Format(\"20060102_150405\"))\n\n\tdata, err := c.Marshal()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal backup config: %w\", err)\n\t}\n\n\tlogger.Infof(\"Backing up config to %s\", backupPath)\n\tif err := os.WriteFile(backupPath, data, 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write backup config: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(60, post60)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/61_movie_tags.up.sql",
    "content": "CREATE TABLE `movies_tags` (\n  `movie_id` integer NOT NULL,\n  `tag_id` integer NOT NULL,\n  foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,\n  PRIMARY KEY(`movie_id`, `tag_id`)\n);\n\nCREATE INDEX `index_movies_tags_on_tag_id` on `movies_tags` (`tag_id`);\nCREATE INDEX `index_movies_tags_on_movie_id` on `movies_tags` (`movie_id`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/62_performer_urls.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\nCREATE TABLE `performer_urls` (\n  `performer_id` integer NOT NULL,\n  `position` integer NOT NULL,\n  `url` varchar(255) NOT NULL,\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,\n  PRIMARY KEY(`performer_id`, `position`, `url`)\n);\n\nCREATE INDEX `performers_urls_url` on `performer_urls` (`url`);\n\n-- drop url, twitter and instagram\n-- make name not null\nCREATE TABLE `performers_new` (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(255) not null,\n  `disambiguation` varchar(255),\n  `gender` varchar(20),\n  `birthdate` date,\n  `ethnicity` varchar(255),\n  `country` varchar(255),\n  `eye_color` varchar(255),\n  `height` int,\n  `measurements` varchar(255),\n  `fake_tits` varchar(255),\n  `career_length` varchar(255),\n  `tattoos` varchar(255),\n  `piercings` varchar(255),\n  `favorite` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  `details` text, \n  `death_date` date, \n  `hair_color` varchar(255), \n  `weight` integer, \n  `rating` tinyint, \n  `ignore_auto_tag` boolean not null default '0', \n  `image_blob` varchar(255) REFERENCES `blobs`(`checksum`), \n  `penis_length` float, \n  `circumcised` varchar[10]\n);\n\nINSERT INTO `performers_new`\n  (\n    `id`,\n    `name`,\n    `disambiguation`,\n    `gender`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `career_length`,\n    `tattoos`,\n    `piercings`,\n    `favorite`,\n    `created_at`,\n    `updated_at`,\n    `details`,\n    `death_date`,\n    `hair_color`,\n    `weight`,\n    `rating`,\n    `ignore_auto_tag`,\n    `image_blob`,\n    `penis_length`,\n    `circumcised`\n  )\n  SELECT \n    `id`,\n    `name`,\n    `disambiguation`,\n    `gender`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `career_length`,\n    `tattoos`,\n    `piercings`,\n    `favorite`,\n    `created_at`,\n    `updated_at`,\n    `details`,\n    `death_date`,\n    `hair_color`,\n    `weight`,\n    `rating`,\n    `ignore_auto_tag`,\n    `image_blob`,\n    `penis_length`,\n    `circumcised`\n  FROM `performers`;\n\nINSERT INTO `performer_urls`\n  (\n    `performer_id`,\n    `position`,\n    `url`\n  )\n  SELECT \n    `id`,\n    '0',\n    `url`\n  FROM `performers`\n  WHERE `performers`.`url` IS NOT NULL AND `performers`.`url` != '';\n\nINSERT INTO `performer_urls`\n  (\n    `performer_id`,\n    `position`,\n    `url`\n  )\n  SELECT \n    `id`,\n    (SELECT count(*) FROM `performer_urls` WHERE `performer_id` = `performers`.`id`)+1,\n    CASE\n      WHEN `twitter` LIKE 'http%://%' THEN `twitter`\n      ELSE 'https://www.twitter.com/' || `twitter`\n    END\n  FROM `performers`\n  WHERE `performers`.`twitter` IS NOT NULL AND `performers`.`twitter` != '';\n\nINSERT INTO `performer_urls`\n  (\n    `performer_id`,\n    `position`,\n    `url`\n  )\n  SELECT \n    `id`,\n    (SELECT count(*) FROM `performer_urls` WHERE `performer_id` = `performers`.`id`)+1,\n    CASE\n      WHEN `instagram` LIKE 'http%://%' THEN `instagram`\n      ELSE 'https://www.instagram.com/' || `instagram`\n    END\n  FROM `performers`\n  WHERE `performers`.`instagram` IS NOT NULL AND `performers`.`instagram` != '';\n\nDROP INDEX IF EXISTS `performers_name_disambiguation_unique`;\nDROP INDEX IF EXISTS `performers_name_unique`;\nDROP TABLE IF EXISTS `performers`;\nALTER TABLE `performers_new` rename to `performers`;\n\nCREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL;\nCREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL;\n\nPRAGMA foreign_keys=ON;\n"
  },
  {
    "path": "pkg/sqlite/migrations/63_studio_tags.up.sql",
    "content": "CREATE TABLE `studios_tags` (\n  `studio_id` integer NOT NULL,\n  `tag_id` integer NOT NULL,\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE,\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,\n  PRIMARY KEY(`studio_id`, `tag_id`)\n);\n\nCREATE INDEX `index_studios_tags_on_tag_id` on `studios_tags` (`tag_id`);"
  },
  {
    "path": "pkg/sqlite/migrations/64_fixes.up.sql",
    "content": "PRAGMA foreign_keys=OFF;\n\n-- recreate scenes_view_dates adding not null to scene_id and adding indexes\nCREATE TABLE `scenes_view_dates_new` (\n  `scene_id` integer not null,\n  `view_date` datetime not null,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\nINSERT INTO `scenes_view_dates_new`\n  (\n    `scene_id`,\n    `view_date`\n  )\n  SELECT \n    `scene_id`,\n    `view_date`\n  FROM `scenes_view_dates`\n  WHERE `scenes_view_dates`.`scene_id` IS NOT NULL;\n\nDROP INDEX IF EXISTS `index_scenes_view_dates`;\nDROP TABLE `scenes_view_dates`;\nALTER TABLE `scenes_view_dates_new` rename to `scenes_view_dates`;\nCREATE INDEX `index_scenes_view_dates` ON `scenes_view_dates` (`scene_id`);\n\n-- recreate scenes_o_dates adding not null to scene_id and adding indexes\nCREATE TABLE `scenes_o_dates_new` (\n  `scene_id` integer not null,\n  `o_date` datetime not null,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\nINSERT INTO `scenes_o_dates_new`\n  (\n    `scene_id`,\n    `o_date`\n  )\n  SELECT \n    `scene_id`,\n    `o_date`\n  FROM `scenes_o_dates`\n  WHERE `scenes_o_dates`.`scene_id` IS NOT NULL;\n\nDROP INDEX IF EXISTS `index_scenes_o_dates`;\nDROP TABLE `scenes_o_dates`;\nALTER TABLE `scenes_o_dates_new` rename to `scenes_o_dates`;\nCREATE INDEX `index_scenes_o_dates` ON `scenes_o_dates` (`scene_id`);\n\nPRAGMA foreign_keys=ON;"
  },
  {
    "path": "pkg/sqlite/migrations/64_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\n// this is a copy of the 55 post migration\n// some non-UTC dates were missed, so we need to correct them\n\ntype schema64Migrator struct {\n\tmigrator\n}\n\nfunc post64(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 64\")\n\n\tm := schema64Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn m.migrate(ctx)\n}\n\nfunc (m *schema64Migrator) migrate(ctx context.Context) error {\n\t// the last_played_at column was storing in a different format than the rest of the timestamps\n\t// convert the play history date to the correct format\n\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\tquery := \"SELECT DISTINCT `scene_id`, `view_date` FROM `scenes_view_dates`\"\n\n\t\trows, err := tx.Query(query)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfor rows.Next() {\n\t\t\tvar (\n\t\t\t\tid       int\n\t\t\t\tviewDate sqlite.Timestamp\n\t\t\t)\n\n\t\t\terr := rows.Scan(&id, &viewDate)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// skip if already in the correct format\n\t\t\tif viewDate.Timestamp.Location() == time.UTC {\n\t\t\t\tlogger.Debugf(\"view date %s is already in the correct format\", viewDate.Timestamp)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tutcTimestamp := sqlite.UTCTimestamp{\n\t\t\t\tTimestamp: viewDate,\n\t\t\t}\n\n\t\t\t// convert the timestamp to the correct format\n\t\t\tlogger.Debugf(\"correcting view date %q to UTC date %q for scene %d\", viewDate.Timestamp, viewDate.Timestamp.UTC(), id)\n\t\t\tr, err := tx.Exec(\"UPDATE scenes_view_dates SET view_date = ? WHERE scene_id = ? AND (view_date = ? OR view_date = ?)\", utcTimestamp, id, viewDate.Timestamp, viewDate)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error correcting view date %s to %s: %w\", viewDate.Timestamp, viewDate, err)\n\t\t\t}\n\n\t\t\trowsAffected, err := r.RowsAffected()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif rowsAffected == 0 {\n\t\t\t\treturn fmt.Errorf(\"no rows affected when updating view date %s to %s for scene %d\", viewDate.Timestamp, viewDate.Timestamp.UTC(), id)\n\t\t\t}\n\t\t}\n\n\t\treturn rows.Err()\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(64, post64)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/65_movie_group_rename.up.sql",
    "content": "ALTER TABLE `movies` RENAME TO `groups`;\nALTER TABLE `groups` RENAME COLUMN `synopsis` TO `description`;\n\nDROP INDEX `index_movies_on_name`;\nCREATE INDEX `index_groups_on_name` ON `groups`(`name`);\nDROP INDEX `index_movies_on_studio_id`;\nCREATE INDEX `index_groups_on_studio_id` on `groups` (`studio_id`);\n\nALTER TABLE `movie_urls` RENAME TO `group_urls`;\nALTER TABLE `group_urls` RENAME COLUMN `movie_id` TO `group_id`;\n\nDROP INDEX `movie_urls_url`;\nCREATE INDEX `group_urls_url` on `group_urls` (`url`);\n\nALTER TABLE `movies_tags` RENAME TO `groups_tags`;\nALTER TABLE `groups_tags` RENAME COLUMN `movie_id` TO `group_id`;\n\nDROP INDEX `index_movies_tags_on_tag_id`;\nCREATE INDEX `index_groups_tags_on_tag_id` on `groups_tags` (`tag_id`);\nDROP INDEX `index_movies_tags_on_movie_id`;\nCREATE INDEX `index_groups_tags_on_movie_id` on `groups_tags` (`group_id`);\n\nALTER TABLE `movies_scenes` RENAME TO `groups_scenes`;\nALTER TABLE `groups_scenes` RENAME COLUMN `movie_id` TO `group_id`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/65_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\ntype schema65Migrator struct {\n\tmigrator\n}\n\nfunc post65(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 65\")\n\n\tm := schema65Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\treturn m.migrate()\n}\n\nfunc (m *schema65Migrator) migrate() error {\n\tif err := m.migrateConfig(); err != nil {\n\t\treturn fmt.Errorf(\"failed to migrate config: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema65Migrator) migrateConfig() error {\n\tc := config.GetInstance()\n\n\torgPath := c.GetConfigFile()\n\n\tif orgPath == \"\" {\n\t\t// no config file to migrate (usually in a test)\n\t\treturn nil\n\t}\n\n\titems := c.GetMenuItems()\n\treplaced := false\n\n\t// replace \"movies\" with \"groups\" in the menu items\n\tfor i, item := range items {\n\t\tif item == \"movies\" {\n\t\t\titems[i] = \"groups\"\n\t\t\treplaced = true\n\t\t}\n\t}\n\n\tif !replaced {\n\t\treturn nil\n\t}\n\n\t// save a backup of the original config file\n\tbackupPath := fmt.Sprintf(\"%s.64.%s\", orgPath, time.Now().Format(\"20060102_150405\"))\n\n\tdata, err := c.Marshal()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal backup config: %w\", err)\n\t}\n\n\tlogger.Infof(\"Backing up config to %s\", backupPath)\n\tif err := os.WriteFile(backupPath, data, 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write backup config: %w\", err)\n\t}\n\n\tc.SetInterface(config.MenuItems, items)\n\n\tif err := c.Write(); err != nil {\n\t\treturn fmt.Errorf(\"failed to write config: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(65, post65)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/66_gallery_cover.up.sql",
    "content": "ALTER TABLE `galleries_images` ADD COLUMN `cover` BOOLEAN NOT NULL DEFAULT 0;\nCREATE UNIQUE INDEX `index_galleries_images_gallery_id_cover` on `galleries_images` (`gallery_id`, `cover`) WHERE `cover` = 1;"
  },
  {
    "path": "pkg/sqlite/migrations/67_group_relationships.up.sql",
    "content": "CREATE TABLE `groups_relations` (\n  `containing_id` integer not null,\n  `sub_id` integer not null,\n  `order_index` integer not null,\n  `description` varchar(255),\n  primary key (`containing_id`, `sub_id`),\n  foreign key (`containing_id`) references `groups`(`id`) on delete cascade,\n  foreign key (`sub_id`) references `groups`(`id`) on delete cascade,\n  check (`containing_id` != `sub_id`)\n);\n\nCREATE INDEX `index_groups_relations_sub_id` ON `groups_relations` (`sub_id`);\nCREATE UNIQUE INDEX `index_groups_relations_order_index_unique` ON `groups_relations` (`containing_id`, `order_index`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/68_image_studio_index.up.sql",
    "content": "-- with the existing index, if no images have a studio id, then the index is \n-- not used when filtering by studio id. The assumption with this change is that\n-- most images don't have a studio id, so filtering by non-null studio id should\n-- be faster with this index. This is a tradeoff, as filtering by null studio id\n-- will be slower.\nDROP INDEX index_images_on_studio_id;\nCREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`) WHERE `studio_id` IS NOT NULL;"
  },
  {
    "path": "pkg/sqlite/migrations/69_stash_id_updated_at.up.sql",
    "content": "ALTER TABLE `performer_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z';\nALTER TABLE `scene_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z';\nALTER TABLE `studio_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z';\n"
  },
  {
    "path": "pkg/sqlite/migrations/6_scenes_format.up.sql",
    "content": "ALTER TABLE `scenes` ADD COLUMN `format` varchar(255);\n"
  },
  {
    "path": "pkg/sqlite/migrations/70_markers_end.up.sql",
    "content": "ALTER TABLE scene_markers ADD COLUMN end_seconds FLOAT;"
  },
  {
    "path": "pkg/sqlite/migrations/71_custom_fields.up.sql",
    "content": "CREATE TABLE `performer_custom_fields` (\n  `performer_id` integer NOT NULL,\n  `field` varchar(64) NOT NULL,\n  `value` BLOB NOT NULL,\n  PRIMARY KEY (`performer_id`, `field`),\n  foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_performer_custom_fields_field_value` ON `performer_custom_fields` (`field`, `value`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/72_tag_sort_name.up.sql",
    "content": "ALTER TABLE `tags` ADD COLUMN `sort_name` varchar(255);\n\n"
  },
  {
    "path": "pkg/sqlite/migrations/73_studio_urls.up.sql",
    "content": "CREATE TABLE `studio_urls` (\n  `studio_id` integer NOT NULL,\n  `position` integer NOT NULL,\n  `url` varchar(255) NOT NULL,\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE,\n  PRIMARY KEY(`studio_id`, `position`, `url`)\n);\n\nCREATE INDEX `studio_urls_url` on `studio_urls` (`url`);\n\nINSERT INTO `studio_urls`\n  (\n    `studio_id`,\n    `position`,\n    `url`\n  )\n  SELECT \n    `id`,\n    '0',\n    `url`\n  FROM `studios`\n  WHERE `studios`.`url` IS NOT NULL AND `studios`.`url` != '';\n\nALTER TABLE `studios` DROP COLUMN `url`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/74_tag_stash_ids.up.sql",
    "content": "CREATE TABLE `tag_stash_ids` (\n  `tag_id` integer,\n  `endpoint` varchar(255),\n  `stash_id` varchar(36),\n  `updated_at` datetime not null default '1970-01-01T00:00:00Z',\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);"
  },
  {
    "path": "pkg/sqlite/migrations/75_date_precision.up.sql",
    "content": "ALTER TABLE \"scenes\" ADD COLUMN \"date_precision\" TINYINT;\nALTER TABLE \"images\" ADD COLUMN \"date_precision\" TINYINT;\nALTER TABLE \"galleries\" ADD COLUMN \"date_precision\" TINYINT;\nALTER TABLE \"groups\" ADD COLUMN \"date_precision\" TINYINT;\nALTER TABLE \"performers\" ADD COLUMN \"birthdate_precision\" TINYINT;\nALTER TABLE \"performers\" ADD COLUMN \"death_date_precision\" TINYINT;\n\nUPDATE \"scenes\" SET \"date_precision\" = 0 WHERE \"date\" IS NOT NULL;\nUPDATE \"images\" SET \"date_precision\" = 0 WHERE \"date\" IS NOT NULL;\nUPDATE \"galleries\" SET \"date_precision\" = 0 WHERE \"date\" IS NOT NULL;\nUPDATE \"groups\" SET \"date_precision\" = 0 WHERE \"date\" IS NOT NULL;\nUPDATE \"performers\" SET \"birthdate_precision\" = 0 WHERE \"birthdate\" IS NOT NULL;\nUPDATE \"performers\" SET \"death_date_precision\" = 0 WHERE \"death_date\" IS NOT NULL;  \n"
  },
  {
    "path": "pkg/sqlite/migrations/76_studio_custom_fields.up.sql",
    "content": "CREATE TABLE `studio_custom_fields` (\n  `studio_id` integer NOT NULL,\n  `field` varchar(64) NOT NULL,\n  `value` BLOB NOT NULL,\n  PRIMARY KEY (`studio_id`, `field`),\n  foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_studio_custom_fields_field_value` ON `studio_custom_fields` (`field`, `value`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/77_tag_custom_fields.up.sql",
    "content": "CREATE TABLE `tag_custom_fields` (\n  `tag_id` integer NOT NULL,\n  `field` varchar(64) NOT NULL,\n  `value` BLOB NOT NULL,\n  PRIMARY KEY (`tag_id`, `field`),\n  foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_tag_custom_fields_field_value` ON `tag_custom_fields` (`field`, `value`);"
  },
  {
    "path": "pkg/sqlite/migrations/78_performer_career_dates.up.sql",
    "content": "ALTER TABLE \"performers\" ADD COLUMN \"career_start\" integer;\nALTER TABLE \"performers\" ADD COLUMN \"career_end\" integer;\n"
  },
  {
    "path": "pkg/sqlite/migrations/78_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n)\n\ntype schema78Migrator struct {\n\tmigrator\n}\n\nfunc post78(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 78\")\n\n\tm := schema78Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t}\n\n\tif err := m.migrateCareerLength(ctx); err != nil {\n\t\treturn fmt.Errorf(\"migrating career_length: %w\", err)\n\t}\n\n\tif err := m.dropCareerLength(); err != nil {\n\t\treturn fmt.Errorf(\"dropping career_length column: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema78Migrator) migrateCareerLength(ctx context.Context) error {\n\tlogger.Info(\"Migrating career_length to career_start/career_end\")\n\n\tconst limit = 1000\n\n\tlastID := 0\n\tparsed := 0\n\tunparseable := 0\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := `SELECT id, career_length FROM performers\n\t\t\t\tWHERE career_length IS NOT NULL AND career_length != ''`\n\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\" AND id > %d\", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\" ORDER BY id LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tvar (\n\t\t\t\t\tid           int\n\t\t\t\t\tcareerLength string\n\t\t\t\t)\n\n\t\t\t\tif err := rows.Scan(&id, &careerLength); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\n\t\t\t\tstart, end, err := models.ParseYearRangeString(careerLength)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Warnf(\"Could not parse career_length %q for performer %d: %v — preserving as custom field\", careerLength, id, err)\n\n\t\t\t\t\tif err := m.preserveAsCustomField(tx, id, careerLength); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"preserving career_length for performer %d: %w\", id, err)\n\t\t\t\t\t}\n\t\t\t\t\tunparseable++\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif err := m.updateCareerFields(tx, id, start, end); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"updating career fields for performer %d: %w\", id, err)\n\t\t\t\t}\n\t\t\t\tparsed++\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tlogger.Infof(\"Career length migration complete: %d parsed, %d unparseable (preserved as custom fields)\", parsed, unparseable)\n\treturn nil\n}\n\nfunc (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *models.Date, end *models.Date) error {\n\tvar (\n\t\tstartYear, endYear *int\n\t)\n\n\tif start != nil {\n\t\tyear := start.Year()\n\t\tstartYear = &year\n\t}\n\tif end != nil {\n\t\tyear := end.Year()\n\t\tendYear = &year\n\t}\n\n\t_, err := tx.Exec(\n\t\t\"UPDATE performers SET career_start = ?, career_end = ? WHERE id = ?\",\n\t\tstartYear, endYear, id,\n\t)\n\treturn err\n}\n\nfunc (m *schema78Migrator) preserveAsCustomField(tx *sqlx.Tx, id int, value string) error {\n\t// check if a career_length custom field already exists\n\tvar existing sql.NullString\n\terr := tx.Get(&existing, \"SELECT value FROM performer_custom_fields WHERE performer_id = ? AND field = 'career_length'\", id)\n\tif err == nil {\n\t\tlogger.Debugf(\"career_length custom field already exists for performer %d, skipping\", id)\n\t\treturn nil\n\t}\n\n\t_, err = tx.Exec(\n\t\t\"INSERT INTO performer_custom_fields (performer_id, field, value) VALUES (?, 'career_length', ?)\",\n\t\tid, value,\n\t)\n\treturn err\n}\n\nfunc (m *schema78Migrator) dropCareerLength() error {\n\tlogger.Info(\"Dropping career_length column from performers table\")\n\treturn m.execAll([]string{\n\t\t\"ALTER TABLE performers DROP COLUMN career_length\",\n\t})\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(78, post78)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/79_scene_custom_fields.up.sql",
    "content": "CREATE TABLE `scene_custom_fields` (\n  `scene_id` integer NOT NULL,\n  `field` varchar(64) NOT NULL,\n  `value` BLOB NOT NULL,\n  PRIMARY KEY (`scene_id`, `field`),\n  foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_scene_custom_fields_field_value` ON `scene_custom_fields` (`field`, `value`);"
  },
  {
    "path": "pkg/sqlite/migrations/7_performer_optimization.up.sql",
    "content": "DROP INDEX `performers_checksum_unique`;\nDROP INDEX `index_performers_on_name`;\nDROP INDEX `index_performers_on_checksum`;\nALTER TABLE `performers` RENAME TO `temp_old_performers`;\nCREATE TABLE `performers` (\n  `id` integer not null primary key autoincrement,\n  `checksum` varchar(255) not null,\n  `name` varchar(255),\n  `gender` varchar(20),\n  `url` varchar(255),\n  `twitter` varchar(255),\n  `instagram` varchar(255),\n  `birthdate` date,\n  `ethnicity` varchar(255),\n  `country` varchar(255),\n  `eye_color` varchar(255),\n  `height` varchar(255),\n  `measurements` varchar(255),\n  `fake_tits` varchar(255),\n  `career_length` varchar(255),\n  `tattoos` varchar(255),\n  `piercings` varchar(255),\n  `aliases` varchar(255),\n  `favorite` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  `image` blob not null\n);\nCREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`);\nCREATE INDEX `index_performers_on_name` on `performers` (`name`);\nINSERT INTO `performers` (\n  `id`,\n  `checksum`,\n  `name`,\n  `gender`,\n  `url`,\n  `twitter`,\n  `instagram`,\n  `birthdate`,\n  `ethnicity`,\n  `country`,\n  `eye_color`,\n  `height`,\n  `measurements`,\n  `fake_tits`,\n  `career_length`,\n  `tattoos`,\n  `piercings`,\n  `aliases`,\n  `favorite`,\n  `created_at`,\n  `updated_at`,\n  `image`\n)\nSELECT \n  `id`,\n  `checksum`,\n  `name`,\n  `gender`,\n  `url`,\n  `twitter`,\n  `instagram`,\n  `birthdate`,\n  `ethnicity`,\n  `country`,\n  `eye_color`,\n  `height`,\n  `measurements`,\n  `fake_tits`,\n  `career_length`,\n  `tattoos`,\n  `piercings`,\n  `aliases`,\n  `favorite`,\n  `created_at`,\n  `updated_at`,\n  `image`\nFROM `temp_old_performers`;\n\nDROP INDEX `index_performers_scenes_on_scene_id`;\nDROP INDEX `index_performers_scenes_on_performer_id`;\nALTER TABLE performers_scenes RENAME TO temp_old_performers_scenes;\nCREATE TABLE `performers_scenes` (\n  `performer_id` integer,\n  `scene_id` integer,\n  foreign key(`performer_id`) references `performers`(`id`),\n  foreign key(`scene_id`) references `scenes`(`id`)\n);\nCREATE INDEX `index_performers_scenes_on_scene_id` on `performers_scenes` (`scene_id`);\nCREATE INDEX `index_performers_scenes_on_performer_id` on `performers_scenes` (`performer_id`);\nINSERT INTO `performers_scenes` (\n  `performer_id`,\n  `scene_id`\n)\nSELECT \n  `performer_id`,\n  `scene_id`\nFROM `temp_old_performers_scenes`;\n\nDROP TABLE `temp_old_performers`;\nDROP TABLE `temp_old_performers_scenes`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/80_studio_organized.up.sql",
    "content": "ALTER TABLE `studios` ADD COLUMN `organized` boolean not null default '0';"
  },
  {
    "path": "pkg/sqlite/migrations/81_gallery_custom_fields.up.sql",
    "content": "CREATE TABLE `gallery_custom_fields` (\n  `gallery_id` integer NOT NULL,\n  `field` varchar(64) NOT NULL,\n  `value` BLOB NOT NULL,\n  PRIMARY KEY (`gallery_id`, `field`),\n  foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_gallery_custom_fields_field_value` ON `gallery_custom_fields` (`field`, `value`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/82_group_custom_fields.up.sql",
    "content": "CREATE TABLE `group_custom_fields` (\n  `group_id` integer NOT NULL,\n  `field` varchar(64) NOT NULL,\n  `value` BLOB NOT NULL,\n  PRIMARY KEY (`group_id`, `field`),\n  foreign key(`group_id`) references `groups`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_group_custom_fields_field_value` ON `group_custom_fields` (`field`, `value`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/83_image_custom_fields.up.sql",
    "content": "CREATE TABLE `image_custom_fields` (\n  `image_id` integer NOT NULL,\n  `field` varchar(64) NOT NULL,\n  `value` BLOB NOT NULL,\n  PRIMARY KEY (`image_id`, `field`),\n  foreign key(`image_id`) references `images`(`id`) on delete CASCADE\n);\n\nCREATE INDEX `index_image_custom_fields_field_value` ON `image_custom_fields` (`field`, `value`);\n"
  },
  {
    "path": "pkg/sqlite/migrations/84_folder_basename.up.sql",
    "content": "-- we cannot add basename column directly because we require it to be NOT NULL\n-- recreate folders table with basename column\nPRAGMA foreign_keys=OFF;\n\nCREATE TABLE `folders_new` (\n  `id` integer not null primary key autoincrement,\n  `basename` varchar(255) NOT NULL,\n  `path` varchar(255) NOT NULL,\n  `parent_folder_id` integer,\n  `zip_file_id` integer REFERENCES `files`(`id`),\n  `mod_time` datetime not null,\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  foreign key(`parent_folder_id`) references `folders`(`id`) on delete SET NULL\n);\n\n-- copy data from old table to new table, setting basename to path temporarily\nINSERT INTO `folders_new` (\n    `id`, \n    `basename`, \n    `path`, \n    `parent_folder_id`, \n    `zip_file_id`,\n    `mod_time`, \n    `created_at`, \n    `updated_at`\n) SELECT \n    `id`, \n    `path`, \n    `path`, \n    `parent_folder_id`, \n    `zip_file_id`,\n    `mod_time`, \n    `created_at`, \n    `updated_at`\nFROM `folders`;\n\nDROP INDEX IF EXISTS `index_folders_on_parent_folder_id`;\nDROP INDEX IF EXISTS `index_folders_on_path_unique`;\nDROP INDEX IF EXISTS `index_folders_on_zip_file_id`;\nDROP TABLE `folders`;\n\nALTER TABLE `folders_new` RENAME TO `folders`;\n\nCREATE UNIQUE INDEX `index_folders_on_path_unique` on `folders` (`path`);\nCREATE UNIQUE INDEX `index_folders_on_parent_folder_id_basename_unique` on `folders` (`parent_folder_id`, `basename`);\nCREATE INDEX `index_folders_on_zip_file_id` on `folders` (`zip_file_id`) WHERE `zip_file_id` IS NOT NULL;\nCREATE INDEX `index_folders_on_basename` on `folders` (`basename`);\n\nPRAGMA foreign_keys=ON;"
  },
  {
    "path": "pkg/sqlite/migrations/84_postmigrate.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\t\"gopkg.in/guregu/null.v4\"\n)\n\nfunc post84(ctx context.Context, db *sqlx.DB) error {\n\tlogger.Info(\"Running post-migration for schema version 84\")\n\n\tm := schema84Migrator{\n\t\tmigrator: migrator{\n\t\t\tdb: db,\n\t\t},\n\t\tfolderCache: make(map[string]folderInfo),\n\t}\n\n\trootPaths := config.GetInstance().GetStashPaths().Paths()\n\n\tif err := m.createMissingFolderHierarchies(ctx, rootPaths); err != nil {\n\t\treturn fmt.Errorf(\"creating missing folder hierarchies: %w\", err)\n\t}\n\n\tif err := m.fixIncorrectParents(ctx, rootPaths); err != nil {\n\t\treturn fmt.Errorf(\"fixing incorrect parent folders: %w\", err)\n\t}\n\n\tif err := m.migrateFolders(ctx); err != nil {\n\t\treturn fmt.Errorf(\"migrating folders: %w\", err)\n\t}\n\n\treturn nil\n}\n\ntype schema84Migrator struct {\n\tmigrator\n\tfolderCache map[string]folderInfo\n}\n\nfunc (m *schema84Migrator) createMissingFolderHierarchies(ctx context.Context, rootPaths []string) error {\n\t// before we set the basenames, we need to address any folders that are missing their\n\t// parent folders.\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tlastID := 0\n\tcount := 0\n\tlogged := false\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := \"SELECT `folders`.`id`, `folders`.`path` FROM `folders` WHERE `folders`.`parent_folder_id` IS NULL \"\n\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\"AND `folders`.`id` > %d \", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\"ORDER BY `folders`.`id` LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\t// log once if we find any folders with missing parent folders\n\t\t\t\tif !logged {\n\t\t\t\t\tlogger.Info(\"Migrating folders with missing parents...\")\n\t\t\t\t\tlogged = true\n\t\t\t\t}\n\n\t\t\t\tvar id int\n\t\t\t\tvar p string\n\n\t\t\t\terr := rows.Scan(&id, &p)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\tcount++\n\n\t\t\t\t// don't try to create parent folders for root paths\n\t\t\t\tif slices.Contains(rootPaths, p) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tparentDir := filepath.Dir(p)\n\t\t\t\tif parentDir == p {\n\t\t\t\t\t// this can happen if the path is something like \"C:\\\", where the parent directory is the same as the current directory\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tparentID, err := m.getOrCreateFolderHierarchy(tx, parentDir, rootPaths)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error creating parent folder for folder %d %q: %w\", id, p, err)\n\t\t\t\t}\n\n\t\t\t\tif parentID == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// now set the parent folder ID for the current folder\n\t\t\t\tlogger.Debugf(\"Migrating folder %d %q: setting parent folder ID to %d\", id, p, *parentID)\n\n\t\t\t\t_, err = tx.Exec(\"UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?\", *parentID, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error setting parent folder for folder %d %q: %w\", id, p, err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d folders\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema84Migrator) findFolderByPath(tx *sqlx.Tx, path string) (*int, error) {\n\tquery := \"SELECT `folders`.`id` FROM `folders` WHERE `folders`.`path` = ?\"\n\n\tvar id int\n\tif err := tx.Get(&id, query, path); err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, nil\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &id, nil\n}\n\n// this is a copy of the GetOrCreateFolderHierarchy function from pkg/file/folder.go,\n// but modified to use low-level SQL queries instead of the models.FolderFinderCreator interface, to avoid\nfunc (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string, rootPaths []string) (*int, error) {\n\t// get or create folder hierarchy\n\tfolderID, err := m.findFolderByPath(tx, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif folderID == nil {\n\t\tvar parentID *int\n\n\t\tif !slices.Contains(rootPaths, path) {\n\t\t\tparentPath := filepath.Dir(path)\n\n\t\t\t// it's possible that the parent path is the same as the current path, if there are folders outside\n\t\t\t// of the root paths. In that case, we should just return nil for the parent ID.\n\t\t\tif parentPath == path {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\n\t\t\tparentID, err = m.getOrCreateFolderHierarchy(tx, parentPath, rootPaths)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\n\t\tlogger.Debugf(\"%s doesn't exist. Creating new folder entry...\", path)\n\n\t\t// we need to set basename to path, which will be addressed in the next step\n\t\tconst insertSQL = \"INSERT INTO `folders` (`path`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)\"\n\n\t\tvar parentFolderID null.Int\n\t\tif parentID != nil {\n\t\t\tparentFolderID = null.IntFrom(int64(*parentID))\n\t\t}\n\n\t\tnow := time.Now()\n\t\tresult, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"creating folder %s: %w\", path, err)\n\t\t}\n\n\t\tid, err := result.LastInsertId()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"creating folder %s: %w\", path, err)\n\t\t}\n\n\t\tidInt := int(id)\n\t\tfolderID = &idInt\n\t}\n\n\treturn folderID, nil\n}\n\nfunc (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []string) error {\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tlastID := 0\n\tcount := 0\n\tfixed := 0\n\tlogged := false\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := \"SELECT f.id, f.path, f.parent_folder_id, pf.path AS parent_path \" +\n\t\t\t\t\"FROM folders f \" +\n\t\t\t\t\"JOIN folders pf ON f.parent_folder_id = pf.id \"\n\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\"WHERE f.id > %d \", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\"ORDER BY f.id LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tvar id int\n\t\t\t\tvar p string\n\t\t\t\tvar parentFolderID int\n\t\t\t\tvar parentPath string\n\n\t\t\t\terr := rows.Scan(&id, &p, &parentFolderID, &parentPath)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\tcount++\n\n\t\t\t\texpectedParent := filepath.Dir(p)\n\t\t\t\tif expectedParent == parentPath {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif !logged {\n\t\t\t\t\tlogger.Info(\"Fixing folders with incorrect parent folder assignments...\")\n\t\t\t\t\tlogged = true\n\t\t\t\t}\n\n\t\t\t\tcorrectParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error getting/creating correct parent for folder %d %q: %w\", id, p, err)\n\t\t\t\t}\n\n\t\t\t\tif correctParentID == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tlogger.Debugf(\"Fixing folder %d %q: changing parent_folder_id from %d to %d\", id, p, parentFolderID, *correctParentID)\n\n\t\t\t\t_, err = tx.Exec(\"UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?\", *correctParentID, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error fixing parent folder for folder %d %q: %w\", id, p, err)\n\t\t\t\t}\n\n\t\t\t\tfixed++\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Checked %d folders\", count)\n\t\t}\n\t}\n\n\tif fixed > 0 {\n\t\tlogger.Infof(\"Fixed %d folders with incorrect parent assignments\", fixed)\n\t}\n\n\treturn nil\n}\n\nfunc (m *schema84Migrator) migrateFolders(ctx context.Context) error {\n\tconst (\n\t\tlimit    = 1000\n\t\tlogEvery = 10000\n\t)\n\n\tlastID := 0\n\tcount := 0\n\tlogged := false\n\n\tfor {\n\t\tgotSome := false\n\n\t\tif err := m.withTxn(ctx, func(tx *sqlx.Tx) error {\n\t\t\tquery := \"SELECT `folders`.`id`, `folders`.`path` FROM `folders` \"\n\n\t\t\tif lastID != 0 {\n\t\t\t\tquery += fmt.Sprintf(\"WHERE `folders`.`id` > %d \", lastID)\n\t\t\t}\n\n\t\t\tquery += fmt.Sprintf(\"ORDER BY `folders`.`id` LIMIT %d\", limit)\n\n\t\t\trows, err := tx.Query(query)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tif !logged {\n\t\t\t\t\tlogger.Infof(\"Migrating folders to set basenames...\")\n\t\t\t\t\tlogged = true\n\t\t\t\t}\n\n\t\t\t\tvar id int\n\t\t\t\tvar p string\n\n\t\t\t\terr := rows.Scan(&id, &p)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tlastID = id\n\t\t\t\tgotSome = true\n\t\t\t\tcount++\n\n\t\t\t\tbasename := filepath.Base(p)\n\t\t\t\tlogger.Debugf(\"Migrating folder %d %q: setting basename to %q\", id, p, basename)\n\t\t\t\t_, err = tx.Exec(\"UPDATE `folders` SET `basename` = ? WHERE `id` = ?\", basename, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"error migrating folder %d %q: %w\", id, p, err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Err()\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !gotSome {\n\t\t\tbreak\n\t\t}\n\n\t\tif count%logEvery == 0 {\n\t\t\tlogger.Infof(\"Migrated %d folders\", count)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc init() {\n\tsqlite.RegisterPostMigration(84, post84)\n}\n"
  },
  {
    "path": "pkg/sqlite/migrations/85_performer_career_dates.up.sql",
    "content": "-- have to change the type of the career start/end columns so need to recreate the table\nPRAGMA foreign_keys=OFF;\n\nCREATE TABLE IF NOT EXISTS \"performers_new\" (\n  `id` integer not null primary key autoincrement,\n  `name` varchar(255) not null,\n  `disambiguation` varchar(255),\n  `gender` varchar(20),\n  `birthdate` date,\n  `birthdate_precision` TINYINT, \n  `ethnicity` varchar(255),\n  `country` varchar(255),\n  `eye_color` varchar(255),\n  `height` int,\n  `measurements` varchar(255),\n  `fake_tits` varchar(255),\n  `tattoos` varchar(255),\n  `piercings` varchar(255),\n  `favorite` boolean not null default '0',\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  `details` text, \n  `death_date` date, \n  `death_date_precision` TINYINT, \n  `hair_color` varchar(255), \n  `weight` integer, \n  `rating` tinyint, \n  `ignore_auto_tag` boolean not null default '0', \n  `penis_length` float, \n  `circumcised` varchar[10], \n  `career_start` date, \n  `career_start_precision` TINYINT, \n  `career_end` date, \n  `career_end_precision` TINYINT,\n  `image_blob` varchar(255) REFERENCES `blobs`(`checksum`)\n);\n\nINSERT INTO `performers_new` (\n    `id`,\n    `name`,\n    `disambiguation`,\n    `gender`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `tattoos`,\n    `piercings`,\n    `favorite`,\n    `created_at`,\n    `updated_at`,\n    `details`,\n    `death_date`,\n    `hair_color`,\n    `weight`,\n    `rating`,\n    `ignore_auto_tag`,\n    `image_blob`,\n    `penis_length`,\n    `circumcised`,\n    `birthdate_precision`,\n    `death_date_precision`,\n    `career_start`,\n    `career_end`\n) SELECT \n    `id`,\n    `name`,\n    `disambiguation`,\n    `gender`,\n    `birthdate`,\n    `ethnicity`,\n    `country`,\n    `eye_color`,\n    `height`,\n    `measurements`,\n    `fake_tits`,\n    `tattoos`,\n    `piercings`,\n    `favorite`,\n    `created_at`,\n    `updated_at`,\n    `details`,\n    `death_date`,\n    `hair_color`,\n    `weight`,\n    `rating`,\n    `ignore_auto_tag`,\n    `image_blob`,\n    `penis_length`,\n    `circumcised`,\n    `birthdate_precision`,\n    `death_date_precision`,\n    CAST(`career_start` AS TEXT),\n    CAST(`career_end` AS TEXT)\nFROM `performers`;\n\nDROP INDEX IF EXISTS `performers_name_disambiguation_unique`;\nDROP INDEX IF EXISTS `performers_name_unique`;\nDROP TABLE `performers`;\n\nALTER TABLE `performers_new` RENAME TO `performers`;\n\nUPDATE \"performers\" SET `career_start` = CONCAT(`career_start`, '-01-01'), \"career_start_precision\" = 2 WHERE \"career_start\" IS NOT NULL;\nUPDATE \"performers\" SET `career_end` = CONCAT(`career_end`, '-01-01'), \"career_end_precision\" = 2 WHERE \"career_end\" IS NOT NULL;  \n\nCREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL;\nCREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL;\n\nPRAGMA foreign_keys=ON;"
  },
  {
    "path": "pkg/sqlite/migrations/8_movie_fix.up.sql",
    "content": "ALTER TABLE `movies` rename to `_movies_old`;\nALTER TABLE `movies_scenes` rename to `_movies_scenes_old`;\n\nDROP INDEX IF EXISTS `movies_checksum_unique`;\nDROP INDEX IF EXISTS `index_movie_id_scene_index_unique`;\nDROP INDEX IF EXISTS `index_movies_scenes_on_movie_id`;\nDROP INDEX IF EXISTS `index_movies_scenes_on_scene_id`;\n\n-- recreate the movies table with fixed column types and constraints\nCREATE TABLE `movies` (\n  `id` integer not null primary key autoincrement,\n  -- add not null\n  `name` varchar(255) not null,\n  `aliases` varchar(255),\n  -- varchar(6) -> integer\n  `duration` integer,\n  `date` date,\n  -- varchar(1) -> tinyint\n  `rating` tinyint,\n  `studio_id` integer,\n  `director` varchar(255),\n  `synopsis` text,\n  `checksum` varchar(255) not null,\n  `url` varchar(255),\n  `created_at` datetime not null,\n  `updated_at` datetime not null,\n  `front_image` blob not null,\n  `back_image` blob,\n  foreign key(`studio_id`) references `studios`(`id`) on delete set null\n);\nCREATE TABLE `movies_scenes` (\n  `movie_id` integer,\n  `scene_id` integer,\n  -- varchar(2) -> tinyint\n  `scene_index` tinyint,\n  foreign key(`movie_id`) references `movies`(`id`) on delete cascade,\n  foreign key(`scene_id`) references `scenes`(`id`) on delete cascade\n);\n\n-- add unique index on movie name\nCREATE UNIQUE INDEX `movies_name_unique` on `movies` (`name`);\nCREATE UNIQUE INDEX `movies_checksum_unique` on `movies` (`checksum`);\n-- remove unique index on movies_scenes\nCREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`);\nCREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`);\nCREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`);\n\n-- custom functions cannot accept NULL values, so massage the old data\nUPDATE `_movies_old` set `duration` = 0 WHERE `duration` IS NULL;\n\n-- now populate from the old tables\nINSERT INTO `movies`\n  (\n    `id`,\n    `name`,\n    `aliases`,\n    `duration`,\n    `date`,\n    `rating`,\n    `director`,\n    `synopsis`,\n    `front_image`,\n    `back_image`,\n    `checksum`,\n    `url`,\n    `created_at`,\n    `updated_at`\n  )\n  SELECT \n    `id`,\n    `name`,\n    `aliases`,\n    durationToTinyInt(`duration`),\n    `date`,\n    CAST(`rating` as tinyint),\n    `director`,\n    `synopsis`,\n    `front_image`,\n    `back_image`,\n    `checksum`,\n    `url`,\n    `created_at`,\n    `updated_at`\n  FROM `_movies_old`\n  -- ignore null named movies\n  WHERE `name` is not null;\n\n-- durationToTinyInt returns 0 if it cannot parse the string\n-- set these values to null instead\nUPDATE `movies` SET `duration` = NULL WHERE `duration` = 0;\n\nINSERT INTO `movies_scenes` \n  (\n    `movie_id`,\n    `scene_id`,\n    `scene_index`\n  )\n  SELECT\n    `movie_id`,\n    `scene_id`,\n    CAST(`scene_index` as tinyint)\n  FROM `_movies_scenes_old`;\n\n-- drop old tables\nDROP TABLE `_movies_scenes_old`;\nDROP TABLE `_movies_old`;\n"
  },
  {
    "path": "pkg/sqlite/migrations/9_studios_parent_studio.up.sql",
    "content": "ALTER TABLE studios \n    ADD COLUMN parent_id INTEGER DEFAULT NULL CHECK ( id IS NOT parent_id ) REFERENCES studios(id) on delete set null;\n    CREATE INDEX index_studios_on_parent_id on studios (parent_id);"
  },
  {
    "path": "pkg/sqlite/migrations/README.md",
    "content": "# Creating a migration\n\n1. Create new migration file in the migrations directory with the format `NN_description.up.sql`, where `NN` is the next sequential number.\n\n2. Update `pkg/sqlite/database.go` to update the `appSchemaVersion` value to the new migration number.\n\nFor migrations requiring complex logic or config file changes, see existing custom migrations for examples."
  },
  {
    "path": "pkg/sqlite/migrations/custom_migration.go",
    "content": "package migrations\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/jmoiron/sqlx\"\n)\n\ntype migrator struct {\n\tdb *sqlx.DB\n}\n\nfunc (m *migrator) withTxn(ctx context.Context, fn func(tx *sqlx.Tx) error) error {\n\ttx, err := m.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"beginning transaction: %w\", err)\n\t}\n\n\tdefer func() {\n\t\tif p := recover(); p != nil {\n\t\t\t// a panic occurred, rollback and repanic\n\t\t\t_ = tx.Rollback()\n\t\t\tpanic(p)\n\t\t}\n\n\t\tif err != nil {\n\t\t\t// something went wrong, rollback\n\t\t\t_ = tx.Rollback()\n\t\t} else {\n\t\t\t// all good, commit\n\t\t\terr = tx.Commit()\n\t\t}\n\t}()\n\n\terr = fn(tx)\n\treturn err\n}\n\nfunc (m *migrator) execAll(stmts []string) error {\n\tfor _, stmt := range stmts {\n\t\tif _, err := m.db.Exec(stmt); err != nil {\n\t\t\treturn fmt.Errorf(\"executing statement %s: %w\", stmt, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/performer.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n)\n\nconst (\n\tperformerTable         = \"performers\"\n\tperformerIDColumn      = \"performer_id\"\n\tperformersAliasesTable = \"performer_aliases\"\n\tperformerAliasColumn   = \"alias\"\n\tperformersTagsTable    = \"performers_tags\"\n\n\tperformerURLsTable = \"performer_urls\"\n\tperformerURLColumn = \"url\"\n\n\tperformerImageBlobColumn = \"image_blob\"\n)\n\ntype performerRow struct {\n\tID                   int         `db:\"id\" goqu:\"skipinsert\"`\n\tName                 null.String `db:\"name\"` // TODO: make schema non-nullable\n\tDisambigation        zero.String `db:\"disambiguation\"`\n\tGender               zero.String `db:\"gender\"`\n\tBirthdate            NullDate    `db:\"birthdate\"`\n\tBirthdatePrecision   null.Int    `db:\"birthdate_precision\"`\n\tEthnicity            zero.String `db:\"ethnicity\"`\n\tCountry              zero.String `db:\"country\"`\n\tEyeColor             zero.String `db:\"eye_color\"`\n\tHeight               null.Int    `db:\"height\"`\n\tMeasurements         zero.String `db:\"measurements\"`\n\tFakeTits             zero.String `db:\"fake_tits\"`\n\tPenisLength          null.Float  `db:\"penis_length\"`\n\tCircumcised          zero.String `db:\"circumcised\"`\n\tCareerStart          NullDate    `db:\"career_start\"`\n\tCareerStartPrecision null.Int    `db:\"career_start_precision\"`\n\tCareerEnd            NullDate    `db:\"career_end\"`\n\tCareerEndPrecision   null.Int    `db:\"career_end_precision\"`\n\tTattoos              zero.String `db:\"tattoos\"`\n\tPiercings            zero.String `db:\"piercings\"`\n\tFavorite             bool        `db:\"favorite\"`\n\tCreatedAt            Timestamp   `db:\"created_at\"`\n\tUpdatedAt            Timestamp   `db:\"updated_at\"`\n\t// expressed as 1-100\n\tRating             null.Int    `db:\"rating\"`\n\tDetails            zero.String `db:\"details\"`\n\tDeathDate          NullDate    `db:\"death_date\"`\n\tDeathDatePrecision null.Int    `db:\"death_date_precision\"`\n\tHairColor          zero.String `db:\"hair_color\"`\n\tWeight             null.Int    `db:\"weight\"`\n\tIgnoreAutoTag      bool        `db:\"ignore_auto_tag\"`\n\n\t// not used in resolution or updates\n\tImageBlob zero.String `db:\"image_blob\"`\n}\n\nfunc (r *performerRow) fromPerformer(o models.Performer) {\n\tr.ID = o.ID\n\tr.Name = null.StringFrom(o.Name)\n\tr.Disambigation = zero.StringFrom(o.Disambiguation)\n\tif o.Gender != nil && o.Gender.IsValid() {\n\t\tr.Gender = zero.StringFrom(o.Gender.String())\n\t}\n\tr.Birthdate = NullDateFromDatePtr(o.Birthdate)\n\tr.BirthdatePrecision = datePrecisionFromDatePtr(o.Birthdate)\n\tr.Ethnicity = zero.StringFrom(o.Ethnicity)\n\tr.Country = zero.StringFrom(o.Country)\n\tr.EyeColor = zero.StringFrom(o.EyeColor)\n\tr.Height = intFromPtr(o.Height)\n\tr.Measurements = zero.StringFrom(o.Measurements)\n\tr.FakeTits = zero.StringFrom(o.FakeTits)\n\tr.PenisLength = null.FloatFromPtr(o.PenisLength)\n\tif o.Circumcised != nil && o.Circumcised.IsValid() {\n\t\tr.Circumcised = zero.StringFrom(o.Circumcised.String())\n\t}\n\tr.CareerStart = NullDateFromDatePtr(o.CareerStart)\n\tr.CareerStartPrecision = datePrecisionFromDatePtr(o.CareerStart)\n\tr.CareerEnd = NullDateFromDatePtr(o.CareerEnd)\n\tr.CareerEndPrecision = datePrecisionFromDatePtr(o.CareerEnd)\n\tr.Tattoos = zero.StringFrom(o.Tattoos)\n\tr.Piercings = zero.StringFrom(o.Piercings)\n\tr.Favorite = o.Favorite\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n\tr.Rating = intFromPtr(o.Rating)\n\tr.Details = zero.StringFrom(o.Details)\n\tr.DeathDate = NullDateFromDatePtr(o.DeathDate)\n\tr.DeathDatePrecision = datePrecisionFromDatePtr(o.DeathDate)\n\tr.HairColor = zero.StringFrom(o.HairColor)\n\tr.Weight = intFromPtr(o.Weight)\n\tr.IgnoreAutoTag = o.IgnoreAutoTag\n}\n\nfunc (r *performerRow) resolve() *models.Performer {\n\tret := &models.Performer{\n\t\tID:             r.ID,\n\t\tName:           r.Name.String,\n\t\tDisambiguation: r.Disambigation.String,\n\t\tBirthdate:      r.Birthdate.DatePtr(r.BirthdatePrecision),\n\t\tEthnicity:      r.Ethnicity.String,\n\t\tCountry:        r.Country.String,\n\t\tEyeColor:       r.EyeColor.String,\n\t\tHeight:         nullIntPtr(r.Height),\n\t\tMeasurements:   r.Measurements.String,\n\t\tFakeTits:       r.FakeTits.String,\n\t\tPenisLength:    nullFloatPtr(r.PenisLength),\n\t\tCareerStart:    r.CareerStart.DatePtr(r.CareerStartPrecision),\n\t\tCareerEnd:      r.CareerEnd.DatePtr(r.CareerEndPrecision),\n\t\tTattoos:        r.Tattoos.String,\n\t\tPiercings:      r.Piercings.String,\n\t\tFavorite:       r.Favorite,\n\t\tCreatedAt:      r.CreatedAt.Timestamp,\n\t\tUpdatedAt:      r.UpdatedAt.Timestamp,\n\t\t// expressed as 1-100\n\t\tRating:        nullIntPtr(r.Rating),\n\t\tDetails:       r.Details.String,\n\t\tDeathDate:     r.DeathDate.DatePtr(r.DeathDatePrecision),\n\t\tHairColor:     r.HairColor.String,\n\t\tWeight:        nullIntPtr(r.Weight),\n\t\tIgnoreAutoTag: r.IgnoreAutoTag,\n\t}\n\n\tif r.Gender.ValueOrZero() != \"\" {\n\t\tv := models.GenderEnum(r.Gender.String)\n\t\tret.Gender = &v\n\t}\n\n\tif r.Circumcised.ValueOrZero() != \"\" {\n\t\tv := models.CircumcisedEnum(r.Circumcised.String)\n\t\tret.Circumcised = &v\n\t}\n\n\treturn ret\n}\n\ntype performerRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *performerRowRecord) fromPartial(o models.PerformerPartial) {\n\tr.setString(\"name\", o.Name)\n\tr.setNullString(\"disambiguation\", o.Disambiguation)\n\tr.setNullString(\"gender\", o.Gender)\n\tr.setNullDate(\"birthdate\", \"birthdate_precision\", o.Birthdate)\n\tr.setNullString(\"ethnicity\", o.Ethnicity)\n\tr.setNullString(\"country\", o.Country)\n\tr.setNullString(\"eye_color\", o.EyeColor)\n\tr.setNullInt(\"height\", o.Height)\n\tr.setNullString(\"measurements\", o.Measurements)\n\tr.setNullString(\"fake_tits\", o.FakeTits)\n\tr.setNullFloat64(\"penis_length\", o.PenisLength)\n\tr.setNullString(\"circumcised\", o.Circumcised)\n\tr.setNullDate(\"career_start\", \"career_start_precision\", o.CareerStart)\n\tr.setNullDate(\"career_end\", \"career_end_precision\", o.CareerEnd)\n\tr.setNullString(\"tattoos\", o.Tattoos)\n\tr.setNullString(\"piercings\", o.Piercings)\n\tr.setBool(\"favorite\", o.Favorite)\n\tr.setTimestamp(\"created_at\", o.CreatedAt)\n\tr.setTimestamp(\"updated_at\", o.UpdatedAt)\n\tr.setNullInt(\"rating\", o.Rating)\n\tr.setNullString(\"details\", o.Details)\n\tr.setNullDate(\"death_date\", \"death_date_precision\", o.DeathDate)\n\tr.setNullString(\"hair_color\", o.HairColor)\n\tr.setNullInt(\"weight\", o.Weight)\n\tr.setBool(\"ignore_auto_tag\", o.IgnoreAutoTag)\n}\n\ntype performerRepositoryType struct {\n\trepository\n\n\ttags     joinRepository\n\tstashIDs stashIDRepository\n\n\tscenes    joinRepository\n\timages    joinRepository\n\tgalleries joinRepository\n}\n\nvar (\n\tperformerRepository = performerRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: performerTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\ttags: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: performersTagsTable,\n\t\t\t\tidColumn:  performerIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     tagIDColumn,\n\t\t\tforeignTable: tagTable,\n\t\t\torderBy:      tagTableSortSQL,\n\t\t},\n\t\tstashIDs: stashIDRepository{\n\t\t\trepository{\n\t\t\t\ttableName: \"performer_stash_ids\",\n\t\t\t\tidColumn:  performerIDColumn,\n\t\t\t},\n\t\t},\n\t\tscenes: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: performersScenesTable,\n\t\t\t\tidColumn:  performerIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     sceneIDColumn,\n\t\t\tforeignTable: sceneTable,\n\t\t},\n\t\timages: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: performersImagesTable,\n\t\t\t\tidColumn:  performerIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     imageIDColumn,\n\t\t\tforeignTable: imageTable,\n\t\t},\n\t\tgalleries: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: performersGalleriesTable,\n\t\t\t\tidColumn:  performerIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     galleryIDColumn,\n\t\t\tforeignTable: galleryTable,\n\t\t},\n\t}\n)\n\ntype PerformerStore struct {\n\tblobJoinQueryBuilder\n\tcustomFieldsStore\n\n\ttableMgr *table\n}\n\nfunc NewPerformerStore(blobStore *BlobStore) *PerformerStore {\n\treturn &PerformerStore{\n\t\tblobJoinQueryBuilder: blobJoinQueryBuilder{\n\t\t\tblobStore: blobStore,\n\t\t\tjoinTable: performerTable,\n\t\t},\n\t\tcustomFieldsStore: customFieldsStore{\n\t\t\ttable: performersCustomFieldsTable,\n\t\t\tfk:    performersCustomFieldsTable.Col(performerIDColumn),\n\t\t},\n\t\ttableMgr: performerTableMgr,\n\t}\n}\n\nfunc (qb *PerformerStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *PerformerStore) selectDataset() *goqu.SelectDataset {\n\treturn dialect.From(qb.table()).Select(qb.table().All())\n}\n\nfunc (qb *PerformerStore) Create(ctx context.Context, newObject *models.CreatePerformerInput) error {\n\tvar r performerRow\n\tr.fromPerformer(*newObject.Performer)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif newObject.Aliases.Loaded() {\n\t\tif err := performersAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.URLs.Loaded() {\n\t\tconst startPos = 0\n\t\tif err := performersURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.TagIDs.Loaded() {\n\t\tif err := performersTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.StashIDs.Loaded() {\n\t\tif err := performersStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tconst partial = false\n\tif err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject.Performer = *updated\n\n\treturn nil\n}\n\nfunc (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial models.PerformerPartial) (*models.Performer, error) {\n\tr := performerRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(partial)\n\n\tif len(r.Record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.Aliases != nil {\n\t\tif err := performersAliasesTableMgr.modifyJoins(ctx, id, partial.Aliases.Values, partial.Aliases.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.URLs != nil {\n\t\tif err := performersURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.TagIDs != nil {\n\t\tif err := performersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.StashIDs != nil {\n\t\tif err := performersStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn qb.find(ctx, id)\n}\n\nfunc (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.UpdatePerformerInput) error {\n\tvar r performerRow\n\tr.fromPerformer(*updatedObject.Performer)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\tif updatedObject.Aliases.Loaded() {\n\t\tif err := performersAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.URLs.Loaded() {\n\t\tif err := performersURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.TagIDs.Loaded() {\n\t\tif err := performersTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.StashIDs.Loaded() {\n\t\tif err := performersStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *PerformerStore) Destroy(ctx context.Context, id int) error {\n\t// must handle image checksums manually\n\tif err := qb.destroyImage(ctx, id); err != nil {\n\t\treturn err\n\t}\n\n\treturn performerRepository.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *PerformerStore) Find(ctx context.Context, id int) (*models.Performer, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *PerformerStore) FindMany(ctx context.Context, ids []int) ([]*models.Performer, error) {\n\ttableMgr := performerTableMgr\n\tret := make([]*models.Performer, len(ids))\n\n\tif err := batchExec(ids, defaultBatchSize, func(batch []int) error {\n\t\tq := goqu.Select(\"*\").From(tableMgr.table).Where(tableMgr.byIDInts(batch...))\n\t\tunsorted, err := qb.getMany(ctx, q)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range unsorted {\n\t\t\ti := slices.Index(ids, s.ID)\n\t\t\tret[i] = s\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor i := range ret {\n\t\tif ret[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"performer with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *PerformerStore) find(ctx context.Context, id int) (*models.Performer, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Performer, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().Where(\n\t\ttable.Col(idColumn).Eq(\n\t\t\tsq,\n\t\t),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *PerformerStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Performer, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *PerformerStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Performer, error) {\n\tconst single = false\n\tvar ret []*models.Performer\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f performerRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) {\n\tsq := dialect.From(scenesPerformersJoinTable).Select(scenesPerformersJoinTable.Col(performerIDColumn)).Where(\n\t\tscenesPerformersJoinTable.Col(sceneIDColumn).Eq(sceneID),\n\t)\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting performers for scene %d: %w\", sceneID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) {\n\tsq := dialect.From(performersImagesJoinTable).Select(performersImagesJoinTable.Col(performerIDColumn)).Where(\n\t\tperformersImagesJoinTable.Col(imageIDColumn).Eq(imageID),\n\t)\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting performers for image %d: %w\", imageID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Performer, error) {\n\tsq := dialect.From(performersGalleriesJoinTable).Select(performersGalleriesJoinTable.Col(performerIDColumn)).Where(\n\t\tperformersGalleriesJoinTable.Col(galleryIDColumn).Eq(galleryID),\n\t)\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting performers for gallery %d: %w\", galleryID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) {\n\tclause := \"name \"\n\tif nocase {\n\t\tclause += \"COLLATE NOCASE \"\n\t}\n\tclause += \"IN \" + getInBinding(len(names))\n\n\tvar args []interface{}\n\tfor _, name := range names {\n\t\targs = append(args, name)\n\t}\n\n\tsq := qb.selectDataset().Prepared(true).Where(\n\t\tgoqu.L(clause, args...),\n\t)\n\tret, err := qb.getMany(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting performers by names: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) CountByTagID(ctx context.Context, tagID int) (int, error) {\n\tjoinTable := performersTagsJoinTable\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID))\n\treturn count(ctx, q)\n}\n\nfunc (qb *PerformerStore) Count(ctx context.Context) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(qb.table())\n\treturn count(ctx, q)\n}\n\nfunc (qb *PerformerStore) All(ctx context.Context) ([]*models.Performer, error) {\n\ttable := qb.table()\n\treturn qb.getMany(ctx, qb.selectDataset().Order(table.Col(\"name\").Asc()))\n}\n\nfunc (qb *PerformerStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Performer, error) {\n\t// TODO - Query needs to be changed to support queries of this type, and\n\t// this method should be removed\n\ttable := qb.table()\n\tsq := dialect.From(table).Select(table.Col(idColumn))\n\t// TODO - disabled alias matching until we get finer control over it\n\t// .LeftJoin(\n\t// \tperformersAliasesJoinTable,\n\t// \tgoqu.On(performersAliasesJoinTable.Col(performerIDColumn).Eq(table.Col(idColumn))),\n\t// )\n\n\tvar whereClauses []exp.Expression\n\n\tfor _, w := range words {\n\t\twhereClauses = append(whereClauses, table.Col(\"name\").Like(w+\"%\"))\n\t\t// TODO - see above\n\t\t// whereClauses = append(whereClauses, performersAliasesJoinTable.Col(\"alias\").Like(w+\"%\"))\n\t}\n\n\tsq = sq.Where(\n\t\tgoqu.Or(whereClauses...),\n\t\ttable.Col(\"ignore_auto_tag\").Eq(0),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting performers for autotag: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {\n\tif performerFilter == nil {\n\t\tperformerFilter = &models.PerformerFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := performerRepository.newQuery()\n\tdistinctIDs(&query, performerTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tquery.join(performersAliasesTable, \"\", \"performer_aliases.performer_id = performers.id\")\n\t\tsearchColumns := []string{\"performers.name\", \"performer_aliases.alias\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &performerFilterHandler{\n\t\tperformerFilter: performerFilter,\n\t})\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar err error\n\tquery.sortAndPagination, err = qb.getPerformerSort(findFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tquery.sortAndPagination += getPagination(findFilter)\n\n\treturn &query, nil\n}\n\nfunc (qb *PerformerStore) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {\n\tquery, err := qb.makeQuery(ctx, performerFilter, findFilter)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tidsResult, countResult, err := query.executeFind(ctx)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tperformers, err := qb.FindMany(ctx, idsResult)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn performers, countResult, nil\n}\n\nfunc (qb *PerformerStore) QueryCount(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (int, error) {\n\tquery, err := qb.makeQuery(ctx, performerFilter, findFilter)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn query.executeCount(ctx)\n}\n\nfunc (qb *PerformerStore) sortByOCounter(direction string) string {\n\t// need to sum the o_counter from scenes and images\n\treturn \" ORDER BY (\" + selectPerformerOCountSQL + \") \" + direction\n}\n\nfunc (qb *PerformerStore) sortByPlayCount(direction string) string {\n\t// need to sum the o_counter from scenes and images\n\treturn \" ORDER BY (\" + selectPerformerPlayCountSQL + \") \" + direction\n}\n\n// used for sorting on performer last o_date\nvar selectPerformerLastOAtSQL = utils.StrFormat(\n\t\"SELECT MAX(o_date) FROM (\"+\n\t\t\"SELECT {o_date} FROM {performers_scenes} s \"+\n\t\t\"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} \"+\n\t\t\"LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id \"+\n\t\t\"WHERE s.{performer_id} = {performers}.id\"+\n\t\t\")\",\n\tmap[string]interface{}{\n\t\t\"performer_id\":      performerIDColumn,\n\t\t\"performers\":        performerTable,\n\t\t\"performers_scenes\": performersScenesTable,\n\t\t\"scenes\":            sceneTable,\n\t\t\"scene_id\":          sceneIDColumn,\n\t\t\"scenes_o_dates\":    scenesODatesTable,\n\t\t\"o_date\":            sceneODateColumn,\n\t},\n)\n\nfunc (qb *PerformerStore) sortByLastOAt(direction string) string {\n\t// need to get the o_dates from scenes\n\treturn \" ORDER BY (\" + selectPerformerLastOAtSQL + \") \" + direction\n}\n\n// used for sorting on performer latest scene\nvar selectPerformerLatestSceneSQL = utils.StrFormat(\n\t\"SELECT MAX(date) FROM (\"+\n\t\t\"SELECT {date} FROM {performers_scenes} s \"+\n\t\t\"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} \"+\n\t\t\"WHERE s.{performer_id} = {performers}.id\"+\n\t\t\")\",\n\tmap[string]interface{}{\n\t\t\"performer_id\":      performerIDColumn,\n\t\t\"performers\":        performerTable,\n\t\t\"performers_scenes\": performersScenesTable,\n\t\t\"scenes\":            sceneTable,\n\t\t\"scene_id\":          sceneIDColumn,\n\t\t\"date\":              sceneDateColumn,\n\t},\n)\n\nfunc (qb *PerformerStore) sortByLatestScene(direction string) string {\n\t// need to get the latest date from scenes\n\treturn \" ORDER BY (\" + selectPerformerLatestSceneSQL + \") \" + direction\n}\n\n// used for sorting on performer last view_date\nvar selectPerformerLastPlayedAtSQL = utils.StrFormat(\n\t\"SELECT MAX(view_date) FROM (\"+\n\t\t\"SELECT {view_date} FROM {performers_scenes} s \"+\n\t\t\"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} \"+\n\t\t\"LEFT JOIN {scenes_view_dates} ON {scenes_view_dates}.{scene_id} = {scenes}.id \"+\n\t\t\"WHERE s.{performer_id} = {performers}.id\"+\n\t\t\")\",\n\tmap[string]interface{}{\n\t\t\"performer_id\":      performerIDColumn,\n\t\t\"performers\":        performerTable,\n\t\t\"performers_scenes\": performersScenesTable,\n\t\t\"scenes\":            sceneTable,\n\t\t\"scene_id\":          sceneIDColumn,\n\t\t\"scenes_view_dates\": scenesViewDatesTable,\n\t\t\"view_date\":         sceneViewDateColumn,\n\t},\n)\n\nfunc (qb *PerformerStore) sortByLastPlayedAt(direction string) string {\n\t// need to get the view_dates from scenes\n\treturn \" ORDER BY (\" + selectPerformerLastPlayedAtSQL + \") \" + direction\n}\n\n// used for sorting by total scene duration\nvar selectPerformerScenesDurationSQL = utils.StrFormat(\n\t\"SELECT COALESCE(SUM(video_files.duration), 0) FROM {performers_scenes} s \"+\n\t\t\"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} \"+\n\t\t\"LEFT JOIN {scenes_files} ON {scenes_files}.{scene_id} = {scenes}.id \"+\n\t\t\"LEFT JOIN video_files ON video_files.file_id = {scenes_files}.file_id \"+\n\t\t\"WHERE s.{performer_id} = {performers}.id\",\n\tmap[string]interface{}{\n\t\t\"performer_id\":      performerIDColumn,\n\t\t\"performers\":        performerTable,\n\t\t\"performers_scenes\": performersScenesTable,\n\t\t\"scenes\":            sceneTable,\n\t\t\"scene_id\":          sceneIDColumn,\n\t\t\"scenes_files\":      scenesFilesTable,\n\t},\n)\n\nfunc (qb *PerformerStore) sortByScenesDuration(direction string) string {\n\t// need to sum duration from all scenes for this performer\n\treturn \" ORDER BY (\" + selectPerformerScenesDurationSQL + \") \" + direction\n}\n\n// used for sorting by total scene file size\nvar selectPerformerScenesSizeSQL = utils.StrFormat(\n\t\"SELECT COALESCE(SUM({files}.size), 0) FROM {performers_scenes} s \"+\n\t\t\"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} \"+\n\t\t\"LEFT JOIN {scenes_files} ON {scenes_files}.{scene_id} = {scenes}.id \"+\n\t\t\"LEFT JOIN {files} ON {files}.id = {scenes_files}.file_id \"+\n\t\t\"WHERE s.{performer_id} = {performers}.id\",\n\tmap[string]interface{}{\n\t\t\"performer_id\":      performerIDColumn,\n\t\t\"performers\":        performerTable,\n\t\t\"performers_scenes\": performersScenesTable,\n\t\t\"scenes\":            sceneTable,\n\t\t\"scene_id\":          sceneIDColumn,\n\t\t\"scenes_files\":      scenesFilesTable,\n\t\t\"files\":             fileTable,\n\t},\n)\n\nfunc (qb *PerformerStore) sortByScenesSize(direction string) string {\n\treturn \" ORDER BY (\" + selectPerformerScenesSizeSQL + \") \" + direction\n}\n\nvar performerSortOptions = sortOptions{\n\t\"birthdate\",\n\t\"career_start\",\n\t\"career_end\",\n\t\"created_at\",\n\t\"galleries_count\",\n\t\"height\",\n\t\"id\",\n\t\"images_count\",\n\t\"last_o_at\",\n\t\"last_played_at\",\n\t\"latest_scene\",\n\t\"measurements\",\n\t\"name\",\n\t\"o_counter\",\n\t\"penis_length\",\n\t\"play_count\",\n\t\"random\",\n\t\"rating\",\n\t\"scenes_count\",\n\t\"scenes_duration\",\n\t\"scenes_size\",\n\t\"tag_count\",\n\t\"updated_at\",\n\t\"weight\",\n}\n\nfunc (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (string, error) {\n\tvar sort string\n\tvar direction string\n\tif findFilter == nil {\n\t\tsort = \"name\"\n\t\tdirection = \"ASC\"\n\t} else {\n\t\tsort = findFilter.GetSort(\"name\")\n\t\tdirection = findFilter.GetDirection()\n\t}\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := performerSortOptions.validateSort(sort); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tsortQuery := \"\"\n\tswitch sort {\n\tcase \"tag_count\":\n\t\tsortQuery += getCountSort(performerTable, performersTagsTable, performerIDColumn, direction)\n\tcase \"scenes_count\":\n\t\tsortQuery += getCountSort(performerTable, performersScenesTable, performerIDColumn, direction)\n\tcase \"scenes_duration\":\n\t\tsortQuery += qb.sortByScenesDuration(direction)\n\tcase \"scenes_size\":\n\t\tsortQuery += qb.sortByScenesSize(direction)\n\tcase \"images_count\":\n\t\tsortQuery += getCountSort(performerTable, performersImagesTable, performerIDColumn, direction)\n\tcase \"galleries_count\":\n\t\tsortQuery += getCountSort(performerTable, performersGalleriesTable, performerIDColumn, direction)\n\tcase \"play_count\":\n\t\tsortQuery += qb.sortByPlayCount(direction)\n\tcase \"o_counter\":\n\t\tsortQuery += qb.sortByOCounter(direction)\n\tcase \"last_played_at\":\n\t\tsortQuery += qb.sortByLastPlayedAt(direction)\n\tcase \"last_o_at\":\n\t\tsortQuery += qb.sortByLastOAt(direction)\n\tcase \"latest_scene\":\n\t\tsortQuery += qb.sortByLatestScene(direction)\n\tdefault:\n\t\tsortQuery += getSort(sort, direction, \"performers\")\n\t}\n\n\t// Whatever the sorting, always use name/id as a final sort\n\tsortQuery += \", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC\"\n\treturn sortQuery, nil\n}\n\nfunc (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {\n\treturn performerRepository.tags.getIDs(ctx, id)\n}\n\nfunc (qb *PerformerStore) GetImage(ctx context.Context, performerID int) ([]byte, error) {\n\treturn qb.blobJoinQueryBuilder.GetImage(ctx, performerID, performerImageBlobColumn)\n}\n\nfunc (qb *PerformerStore) HasImage(ctx context.Context, performerID int) (bool, error) {\n\treturn qb.blobJoinQueryBuilder.HasImage(ctx, performerID, performerImageBlobColumn)\n}\n\nfunc (qb *PerformerStore) UpdateImage(ctx context.Context, performerID int, image []byte) error {\n\treturn qb.blobJoinQueryBuilder.UpdateImage(ctx, performerID, performerImageBlobColumn, image)\n}\n\nfunc (qb *PerformerStore) destroyImage(ctx context.Context, performerID int) error {\n\treturn qb.blobJoinQueryBuilder.DestroyImage(ctx, performerID, performerImageBlobColumn)\n}\n\nfunc (qb *PerformerStore) GetAliases(ctx context.Context, performerID int) ([]string, error) {\n\treturn performersAliasesTableMgr.get(ctx, performerID)\n}\n\nfunc (qb *PerformerStore) GetURLs(ctx context.Context, performerID int) ([]string, error) {\n\treturn performersURLsTableMgr.get(ctx, performerID)\n}\n\nfunc (qb *PerformerStore) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) {\n\treturn performersStashIDsTableMgr.get(ctx, performerID)\n}\n\nfunc (qb *PerformerStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) {\n\tsq := dialect.From(performersStashIDsJoinTable).Select(performersStashIDsJoinTable.Col(performerIDColumn)).Where(\n\t\tperformersStashIDsJoinTable.Col(\"stash_id\").Eq(stashID.StashID),\n\t\tperformersStashIDsJoinTable.Col(\"endpoint\").Eq(stashID.Endpoint),\n\t)\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting performers for stash ID %s: %w\", stashID.StashID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) {\n\ttable := qb.table()\n\tsq := dialect.From(table).LeftJoin(\n\t\tperformersStashIDsJoinTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(performersStashIDsJoinTable.Col(performerIDColumn))),\n\t).Select(table.Col(idColumn))\n\n\tif hasStashID {\n\t\tsq = sq.Where(\n\t\t\tperformersStashIDsJoinTable.Col(\"stash_id\").IsNotNull(),\n\t\t\tperformersStashIDsJoinTable.Col(\"endpoint\").Eq(stashboxEndpoint),\n\t\t)\n\t} else {\n\t\tsq = sq.Where(\n\t\t\tperformersStashIDsJoinTable.Col(\"stash_id\").IsNull(),\n\t\t)\n\t}\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting performers for stash-box endpoint %s: %w\", stashboxEndpoint, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *PerformerStore) Merge(ctx context.Context, source []int, destination int) error {\n\tif len(source) == 0 {\n\t\treturn nil\n\t}\n\n\tinBinding := getInBinding(len(source))\n\n\targs := []interface{}{destination}\n\tsrcArgs := make([]interface{}, len(source))\n\tfor i, id := range source {\n\t\tif id == destination {\n\t\t\treturn errors.New(\"cannot merge where source == destination\")\n\t\t}\n\t\tsrcArgs[i] = id\n\t}\n\n\targs = append(args, srcArgs...)\n\n\tperformerTables := map[string]string{\n\t\tperformersScenesTable:    sceneIDColumn,\n\t\tperformersGalleriesTable: galleryIDColumn,\n\t\tperformersImagesTable:    imageIDColumn,\n\t\tperformersTagsTable:      tagIDColumn,\n\t}\n\n\targs = append(args, destination)\n\n\t// for each table, update source performer ids to destination performer id, ignoring duplicates\n\tfor table, idColumn := range performerTables {\n\t\t_, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+`\nSET performer_id = ?\nWHERE performer_id IN `+inBinding+`\nAND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.performer_id = ?)`,\n\t\t\targs...,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// delete source performer ids from the table where they couldn't be set\n\t\tif _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE performer_id IN `+inBinding, srcArgs...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, id := range source {\n\t\terr := qb.Destroy(ctx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/performer_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype performerFilterHandler struct {\n\tperformerFilter *models.PerformerFilterType\n}\n\nfunc (qb *performerFilterHandler) validate() error {\n\tfilter := qb.performerFilter\n\tif filter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(filter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif subFilter := filter.SubFilter(); subFilter != nil {\n\t\tsqb := &performerFilterHandler{performerFilter: subFilter}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// if legacy height filter used, ensure only supported modifiers are used\n\tif filter.Height != nil {\n\t\t// treat as an int filter\n\t\tintCrit := &models.IntCriterionInput{\n\t\t\tModifier: filter.Height.Modifier,\n\t\t}\n\t\tif !intCrit.ValidModifier() {\n\t\t\treturn fmt.Errorf(\"invalid height modifier: %s\", filter.Height.Modifier)\n\t\t}\n\n\t\t// ensure value is a valid number\n\t\tif _, err := strconv.Atoi(filter.Height.Value); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid height value: %s\", filter.Height.Value)\n\t\t}\n\t}\n\n\t// if legacy career length filter used, ensure only supported modifiers are used and value is valid\n\tif filter.CareerLength != nil {\n\t\tcareerLength := filter.CareerLength\n\t\tswitch careerLength.Modifier {\n\t\tcase models.CriterionModifierEquals:\n\t\t\tstart, end, err := models.ParseYearRangeString(careerLength.Value)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid career length value: %s\", careerLength.Value)\n\t\t\t}\n\t\t\t// ensure career start/end is not set\n\t\t\tif start != nil && filter.CareerStart != nil {\n\t\t\t\treturn fmt.Errorf(\"cannot use legacy CareerLength filter with CareerStart filter\")\n\t\t\t}\n\t\t\tif end != nil && filter.CareerEnd != nil {\n\t\t\t\treturn fmt.Errorf(\"cannot use legacy CareerLength filter with CareerEnd filter\")\n\t\t\t}\n\t\tcase models.CriterionModifierIsNull, models.CriterionModifierNotNull:\n\t\t\t// valid modifiers, no value parsing needed\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"invalid career length modifier: %s\", careerLength.Modifier)\n\t\t}\n\t}\n\n\t// validate date formats\n\tif filter.Birthdate != nil && filter.Birthdate.Value != \"\" {\n\t\tif _, err := models.ParseDate(filter.Birthdate.Value); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid birthdate value: %s\", filter.Birthdate.Value)\n\t\t}\n\t}\n\tif filter.DeathDate != nil && filter.DeathDate.Value != \"\" {\n\t\tif _, err := models.ParseDate(filter.DeathDate.Value); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid death date value: %s\", filter.DeathDate.Value)\n\t\t}\n\t}\n\tif filter.CareerStart != nil && filter.CareerStart.Value != \"\" {\n\t\tif _, err := models.ParseDate(filter.CareerStart.Value); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid career start value: %s\", filter.CareerStart.Value)\n\t\t}\n\t}\n\tif filter.CareerEnd != nil && filter.CareerEnd.Value != \"\" {\n\t\tif _, err := models.ParseDate(filter.CareerEnd.Value); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid career end value: %s\", filter.CareerEnd.Value)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *performerFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tfilter := qb.performerFilter\n\tif filter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := filter.SubFilter()\n\tif sf != nil {\n\t\tsub := &performerFilterHandler{sf}\n\t\thandleSubFilter(ctx, sub, f, filter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *performerFilterHandler) criterionHandler() criterionHandler {\n\t// make a copy of the filter to modify with legacy conversions without affecting original filter used for subfilters\n\tfilter := *qb.performerFilter\n\tconst tableName = performerTable\n\theightCmCrit := filter.HeightCm\n\n\tconvertLegacyCareerLengthFilter(&filter)\n\n\treturn compoundHandler{\n\t\tstringCriterionHandler(filter.Name, tableName+\".name\"),\n\t\tstringCriterionHandler(filter.Disambiguation, tableName+\".disambiguation\"),\n\t\tstringCriterionHandler(filter.Details, tableName+\".details\"),\n\n\t\tboolCriterionHandler(filter.FilterFavorites, tableName+\".favorite\", nil),\n\t\tboolCriterionHandler(filter.IgnoreAutoTag, tableName+\".ignore_auto_tag\", nil),\n\n\t\tyearFilterCriterionHandler(filter.BirthYear, tableName+\".birthdate\"),\n\t\tyearFilterCriterionHandler(filter.DeathYear, tableName+\".death_date\"),\n\n\t\tqb.performerAgeFilterCriterionHandler(filter.Age),\n\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif gender := filter.Gender; gender != nil {\n\t\t\t\tgenderCopy := *gender\n\t\t\t\tif genderCopy.Value.IsValid() && len(genderCopy.ValueList) == 0 {\n\t\t\t\t\tgenderCopy.ValueList = []models.GenderEnum{genderCopy.Value}\n\t\t\t\t}\n\n\t\t\t\tv := utils.StringerSliceToStringSlice(genderCopy.ValueList)\n\t\t\t\tenumCriterionHandler(genderCopy.Modifier, v, tableName+\".gender\")(ctx, f)\n\t\t\t}\n\t\t}),\n\n\t\tqb.performerIsMissingCriterionHandler(filter.IsMissing),\n\t\tstringCriterionHandler(filter.Ethnicity, tableName+\".ethnicity\"),\n\t\tstringCriterionHandler(filter.Country, tableName+\".country\"),\n\t\tstringCriterionHandler(filter.EyeColor, tableName+\".eye_color\"),\n\n\t\t// special handler for legacy height filter\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif heightCmCrit == nil && filter.Height != nil {\n\t\t\t\theightCm, _ := strconv.Atoi(filter.Height.Value) // already validated\n\t\t\t\theightCmCrit = &models.IntCriterionInput{\n\t\t\t\t\tValue:    heightCm,\n\t\t\t\t\tModifier: filter.Height.Modifier,\n\t\t\t\t}\n\t\t\t}\n\t\t}),\n\n\t\tintCriterionHandler(heightCmCrit, tableName+\".height\", nil),\n\n\t\tstringCriterionHandler(filter.Measurements, tableName+\".measurements\"),\n\t\tstringCriterionHandler(filter.FakeTits, tableName+\".fake_tits\"),\n\t\tfloatCriterionHandler(filter.PenisLength, tableName+\".penis_length\", nil),\n\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif circumcised := filter.Circumcised; circumcised != nil {\n\t\t\t\tv := utils.StringerSliceToStringSlice(circumcised.Value)\n\t\t\t\tenumCriterionHandler(circumcised.Modifier, v, tableName+\".circumcised\")(ctx, f)\n\t\t\t}\n\t\t}),\n\n\t\t// CareerLength filter is deprecated and non-functional (column removed in schema 78)\n\t\t&dateCriterionHandler{filter.CareerStart, tableName + \".career_start\", nil},\n\t\t&dateCriterionHandler{filter.CareerEnd, tableName + \".career_end\", nil},\n\t\tstringCriterionHandler(filter.Tattoos, tableName+\".tattoos\"),\n\t\tstringCriterionHandler(filter.Piercings, tableName+\".piercings\"),\n\t\tintCriterionHandler(filter.Rating100, tableName+\".rating\", nil),\n\t\tstringCriterionHandler(filter.HairColor, tableName+\".hair_color\"),\n\t\tqb.urlsCriterionHandler(filter.URL),\n\t\tintCriterionHandler(filter.Weight, tableName+\".weight\", nil),\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif filter.StashID != nil {\n\t\t\t\tperformerRepository.stashIDs.join(f, \"performer_stash_ids\", \"performers.id\")\n\t\t\t\tstringCriterionHandler(filter.StashID, \"performer_stash_ids.stash_id\")(ctx, f)\n\t\t\t}\n\t\t}),\n\t\t&stashIDCriterionHandler{\n\t\t\tc:                 filter.StashIDEndpoint,\n\t\t\tstashIDRepository: &performerRepository.stashIDs,\n\t\t\tstashIDTableAs:    \"performer_stash_ids\",\n\t\t\tparentIDCol:       \"performers.id\",\n\t\t},\n\t\t&stashIDsCriterionHandler{\n\t\t\tc:                 filter.StashIDsEndpoint,\n\t\t\tstashIDRepository: &performerRepository.stashIDs,\n\t\t\tstashIDTableAs:    \"performer_stash_ids\",\n\t\t\tparentIDCol:       \"performers.id\",\n\t\t},\n\n\t\tqb.aliasCriterionHandler(filter.Aliases),\n\n\t\tqb.tagsCriterionHandler(filter.Tags),\n\n\t\tqb.studiosCriterionHandler(filter.Studios),\n\n\t\tqb.groupsCriterionHandler(filter.Groups),\n\n\t\tqb.appearsWithCriterionHandler(filter.Performers),\n\n\t\tqb.tagCountCriterionHandler(filter.TagCount),\n\t\tqb.sceneCountCriterionHandler(filter.SceneCount),\n\t\tqb.markerCountCriterionHandler(filter.MarkerCount),\n\t\tqb.imageCountCriterionHandler(filter.ImageCount),\n\t\tqb.galleryCountCriterionHandler(filter.GalleryCount),\n\t\tqb.playCounterCriterionHandler(filter.PlayCount),\n\t\tqb.oCounterCriterionHandler(filter.OCounter),\n\t\t&dateCriterionHandler{filter.Birthdate, tableName + \".birthdate\", nil},\n\t\t&dateCriterionHandler{filter.DeathDate, tableName + \".death_date\", nil},\n\t\t&timestampCriterionHandler{filter.CreatedAt, tableName + \".created_at\", nil},\n\t\t&timestampCriterionHandler{filter.UpdatedAt, tableName + \".updated_at\", nil},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scene_markers.id\",\n\t\t\trelatedRepo:    sceneMarkerRepository.repository,\n\t\t\trelatedHandler: &sceneMarkerFilterHandler{filter.MarkersFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tperformerRepository.scenes.innerJoin(f, \"\", \"performers.id\")\n\t\t\t\tf.addInnerJoin(sceneMarkerTable, \"\", \"scene_markers.scene_id = performers_scenes.scene_id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"performers_scenes.scene_id\",\n\t\t\trelatedRepo:    sceneRepository.repository,\n\t\t\trelatedHandler: &sceneFilterHandler{filter.ScenesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tperformerRepository.scenes.innerJoin(f, \"\", \"performers.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"performers_images.image_id\",\n\t\t\trelatedRepo:    imageRepository.repository,\n\t\t\trelatedHandler: &imageFilterHandler{filter.ImagesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tperformerRepository.images.innerJoin(f, \"\", \"performers.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"performers_galleries.gallery_id\",\n\t\t\trelatedRepo:    galleryRepository.repository,\n\t\t\trelatedHandler: &galleryFilterHandler{filter.GalleriesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tperformerRepository.galleries.innerJoin(f, \"\", \"performers.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"performer_tag.tag_id\",\n\t\t\trelatedRepo:    tagRepository.repository,\n\t\t\trelatedHandler: &tagFilterHandler{filter.TagsFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tperformerRepository.tags.innerJoin(f, \"performer_tag\", \"performers.id\")\n\t\t\t},\n\t\t},\n\n\t\t&customFieldsFilterHandler{\n\t\t\ttable: performersCustomFieldsTable.GetTable(),\n\t\t\tfkCol: performerIDColumn,\n\t\t\tc:     filter.CustomFields,\n\t\t\tidCol: \"performers.id\",\n\t\t},\n\t}\n}\n\nfunc convertLegacyCareerLengthFilter(filter *models.PerformerFilterType) {\n\t// convert legacy career length filter to career start/end filters\n\tif filter.CareerLength != nil {\n\t\tcareerLength := filter.CareerLength\n\t\tswitch careerLength.Modifier {\n\t\tcase models.CriterionModifierEquals:\n\t\t\tstart, end, _ := models.ParseYearRangeString(careerLength.Value)\n\t\t\tif start != nil {\n\t\t\t\tstart = &models.Date{\n\t\t\t\t\tTime:      start.AddDate(0, 0, -1), // make exclusive\n\t\t\t\t\tPrecision: models.DatePrecisionDay,\n\t\t\t\t}\n\t\t\t\tfilter.CareerStart = &models.DateCriterionInput{\n\t\t\t\t\tValue:    start.String(),\n\t\t\t\t\tModifier: models.CriterionModifierGreaterThan,\n\t\t\t\t}\n\t\t\t}\n\t\t\tif end != nil {\n\t\t\t\tend = &models.Date{\n\t\t\t\t\tTime:      end.AddDate(1, 0, 0), // make exclusive\n\t\t\t\t\tPrecision: models.DatePrecisionDay,\n\t\t\t\t}\n\t\t\t\tfilter.CareerEnd = &models.DateCriterionInput{\n\t\t\t\t\tValue:    end.String(), // plus one to make it exclusive\n\t\t\t\t\tModifier: models.CriterionModifierLessThan,\n\t\t\t\t}\n\t\t\t}\n\t\tcase models.CriterionModifierIsNull:\n\t\t\tfilter.CareerStart = &models.DateCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t}\n\t\t\tfilter.CareerEnd = &models.DateCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t}\n\t\tcase models.CriterionModifierNotNull:\n\t\t\tfilter.CareerStart = &models.DateCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t}\n\t\t\tfilter.CareerEnd = &models.DateCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t}\n\t\t}\n\t}\n}\n\n// TODO - we need to provide a whitelist of possible values\nfunc (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif isMissing != nil && *isMissing != \"\" {\n\t\t\tswitch *isMissing {\n\t\t\tcase \"url\":\n\t\t\t\tperformersURLsTableMgr.join(f, \"\", \"performers.id\")\n\t\t\t\tf.addWhere(\"performer_urls.url IS NULL\")\n\t\t\tcase \"scenes\": // Deprecated: use `scene_count == 0` filter instead\n\t\t\t\tf.addLeftJoin(performersScenesTable, \"scenes_join\", \"scenes_join.performer_id = performers.id\")\n\t\t\t\tf.addWhere(\"scenes_join.scene_id IS NULL\")\n\t\t\tcase \"image\":\n\t\t\t\tf.addWhere(\"performers.image_blob IS NULL\")\n\t\t\tcase \"stash_id\":\n\t\t\t\tperformersStashIDsTableMgr.join(f, \"performer_stash_ids\", \"performers.id\")\n\t\t\t\tf.addWhere(\"performer_stash_ids.performer_id IS NULL\")\n\t\t\tcase \"aliases\":\n\t\t\t\tperformersAliasesTableMgr.join(f, \"\", \"performers.id\")\n\t\t\t\tf.addWhere(\"performer_aliases.alias IS NULL\")\n\t\t\tcase \"tags\":\n\t\t\t\tf.addLeftJoin(performersTagsTable, \"tags_join\", \"tags_join.performer_id = performers.id\")\n\t\t\t\tf.addWhere(\"tags_join.performer_id IS NULL\")\n\t\t\tdefault:\n\t\t\t\tif err := validateIsMissing(*isMissing, []string{\n\t\t\t\t\t\"disambiguation\", \"gender\", \"birthdate\", \"death_date\",\n\t\t\t\t\t\"ethnicity\", \"country\", \"hair_color\", \"eye_color\", \"height\", \"weight\",\n\t\t\t\t\t\"measurements\", \"fake_tits\", \"penis_length\", \"circumcised\",\n\t\t\t\t\t\"career_start\", \"career_end\", \"tattoos\", \"piercings\", \"details\", \"rating\",\n\t\t\t\t}); err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWhere(\"(performers.\" + *isMissing + \" IS NULL OR TRIM(performers.\" + *isMissing + \") = '')\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif age != nil && age.Modifier.IsValid() {\n\t\t\tclause, args := getIntCriterionWhereClause(\n\t\t\t\t\"cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)\",\n\t\t\t\t*age,\n\t\t\t)\n\t\t\tf.addWhere(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *performerFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: performerTable,\n\t\tprimaryFK:    performerIDColumn,\n\t\tjoinTable:    performerURLsTable,\n\t\tstringColumn: performerURLColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tperformersURLsTableMgr.join(f, \"\", \"performers.id\")\n\t\t},\n\t}\n\n\treturn h.handler(url)\n}\n\nfunc (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: performerTable,\n\t\tprimaryFK:    performerIDColumn,\n\t\tjoinTable:    performersAliasesTable,\n\t\tstringColumn: performerAliasColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tperformersAliasesTableMgr.join(f, \"\", \"performers.id\")\n\t\t},\n\t}\n\n\treturn h.handler(alias)\n}\n\nfunc (qb *performerFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\th := joinedHierarchicalMultiCriterionHandlerBuilder{\n\t\tprimaryTable: performerTable,\n\t\tforeignTable: tagTable,\n\t\tforeignFK:    \"tag_id\",\n\n\t\trelationsTable: \"tags_relations\",\n\t\tjoinAs:         \"performer_tag\",\n\t\tjoinTable:      performersTagsTable,\n\t\tprimaryFK:      performerIDColumn,\n\t}\n\n\treturn h.handler(tags)\n}\n\nfunc (qb *performerFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: performerTable,\n\t\tjoinTable:    performersTagsTable,\n\t\tprimaryFK:    performerIDColumn,\n\t}\n\n\treturn h.handler(count)\n}\n\nfunc (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: performerTable,\n\t\tjoinTable:    performersScenesTable,\n\t\tprimaryFK:    performerIDColumn,\n\t}\n\n\treturn h.handler(count)\n}\n\nfunc (qb *performerFilterHandler) markerCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif count != nil {\n\t\t\tperformerRepository.scenes.innerJoin(f, \"\", \"performers.id\")\n\n\t\t\tconst query = `(SELECT COUNT(*) FROM scene_markers \n  INNER JOIN scenes ON scene_markers.scene_id = scenes.id\n  INNER JOIN performers_scenes ON performers_scenes.scene_id = scenes.id\n  WHERE performers_scenes.performer_id = performers.id)`\n\n\t\t\tclause, args := getIntCriterionWhereClause(query, *count)\n\t\t\tf.addWhere(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: performerTable,\n\t\tjoinTable:    performersImagesTable,\n\t\tprimaryFK:    performerIDColumn,\n\t}\n\n\treturn h.handler(count)\n}\n\nfunc (qb *performerFilterHandler) galleryCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: performerTable,\n\t\tjoinTable:    performersGalleriesTable,\n\t\tprimaryFK:    performerIDColumn,\n\t}\n\n\treturn h.handler(count)\n}\n\n// used for sorting and filtering on performer o-count\nvar selectPerformerOCountSQL = utils.StrFormat(\n\t\"SELECT SUM(o_counter) \"+\n\t\t\"FROM (\"+\n\t\t\"SELECT SUM(o_counter) as o_counter from {performers_images} s \"+\n\t\t\"LEFT JOIN {images} ON {images}.id = s.{images_id} \"+\n\t\t\"WHERE s.{performer_id} = {performers}.id \"+\n\t\t\"UNION ALL \"+\n\t\t\"SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {performers_scenes} s \"+\n\t\t\"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} \"+\n\t\t\"LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id \"+\n\t\t\"WHERE s.{performer_id} = {performers}.id \"+\n\t\t\")\",\n\tmap[string]interface{}{\n\t\t\"performers_images\": performersImagesTable,\n\t\t\"images\":            imageTable,\n\t\t\"performer_id\":      performerIDColumn,\n\t\t\"images_id\":         imageIDColumn,\n\t\t\"performers\":        performerTable,\n\t\t\"performers_scenes\": performersScenesTable,\n\t\t\"scenes\":            sceneTable,\n\t\t\"scene_id\":          sceneIDColumn,\n\t\t\"scenes_o_dates\":    scenesODatesTable,\n\t\t\"o_date\":            sceneODateColumn,\n\t},\n)\n\n// used for sorting and filtering play count on performer view count\nvar selectPerformerPlayCountSQL = utils.StrFormat(\n\t\"SELECT COUNT(DISTINCT {view_date}) FROM (\"+\n\t\t\"SELECT {view_date} FROM {performers_scenes} s \"+\n\t\t\"LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} \"+\n\t\t\"LEFT JOIN {scenes_view_dates} ON {scenes_view_dates}.{scene_id} = {scenes}.id \"+\n\t\t\"WHERE s.{performer_id} = {performers}.id\"+\n\t\t\")\",\n\tmap[string]interface{}{\n\t\t\"performer_id\":      performerIDColumn,\n\t\t\"performers\":        performerTable,\n\t\t\"performers_scenes\": performersScenesTable,\n\t\t\"scenes\":            sceneTable,\n\t\t\"scene_id\":          sceneIDColumn,\n\t\t\"scenes_view_dates\": scenesViewDatesTable,\n\t\t\"view_date\":         sceneViewDateColumn,\n\t},\n)\n\nfunc (qb *performerFilterHandler) oCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif count == nil {\n\t\t\treturn\n\t\t}\n\n\t\tlhs := \"(\" + selectPerformerOCountSQL + \")\"\n\t\tclause, args := getIntCriterionWhereClause(lhs, *count)\n\n\t\tf.addWhere(clause, args...)\n\t}\n}\n\nfunc (qb *performerFilterHandler) playCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif count == nil {\n\t\t\treturn\n\t\t}\n\n\t\tlhs := \"(\" + selectPerformerPlayCountSQL + \")\"\n\t\tclause, args := getIntCriterionWhereClause(lhs, *count)\n\n\t\tf.addWhere(clause, args...)\n\t}\n}\n\nfunc (qb *performerFilterHandler) studiosCriterionHandler(studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif studios != nil {\n\t\t\tformatMaps := []utils.StrFormatMap{\n\t\t\t\t{\n\t\t\t\t\t\"primaryTable\": sceneTable,\n\t\t\t\t\t\"joinTable\":    performersScenesTable,\n\t\t\t\t\t\"primaryFK\":    sceneIDColumn,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"primaryTable\": imageTable,\n\t\t\t\t\t\"joinTable\":    performersImagesTable,\n\t\t\t\t\t\"primaryFK\":    imageIDColumn,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"primaryTable\": galleryTable,\n\t\t\t\t\t\"joinTable\":    performersGalleriesTable,\n\t\t\t\t\t\"primaryFK\":    galleryIDColumn,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tif studios.Modifier == models.CriterionModifierIsNull || studios.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif studios.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tvar conditions []string\n\t\t\t\tfor _, c := range formatMaps {\n\t\t\t\t\tf.addLeftJoin(c[\"joinTable\"].(string), \"\", fmt.Sprintf(\"%s.performer_id = performers.id\", c[\"joinTable\"]))\n\t\t\t\t\tf.addLeftJoin(c[\"primaryTable\"].(string), \"\", fmt.Sprintf(\"%s.%s = %s.id\", c[\"joinTable\"], c[\"primaryFK\"], c[\"primaryTable\"]))\n\n\t\t\t\t\tconditions = append(conditions, fmt.Sprintf(\"%s.studio_id IS NULL\", c[\"primaryTable\"]))\n\t\t\t\t}\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s (%s)\", notClause, strings.Join(conditions, \" AND \")))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(studios.Value) == 0 && len(studios.Excludes) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar clauseCondition string\n\n\t\t\tswitch studios.Modifier {\n\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\t// return performers who appear in scenes/images/galleries with any of the given studios\n\t\t\t\tclauseCondition = \"NOT\"\n\t\t\tcase models.CriterionModifierExcludes:\n\t\t\t\t// exclude performers who appear in scenes/images/galleries with any of the given studios\n\t\t\t\tclauseCondition = \"\"\n\t\t\tdefault:\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(studios.Value) > 0 {\n\t\t\t\tconst derivedPerformerStudioTable = \"performer_studio\"\n\t\t\t\tvaluesClause, err := getHierarchicalValues(ctx, studios.Value, studioTable, \"\", \"parent_id\", \"child_id\", studios.Depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWith(\"studio(root_id, item_id) AS (\" + valuesClause + \")\")\n\n\t\t\t\ttemplStr := `SELECT performer_id FROM {primaryTable}\n\t\tINNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK}\n\t\tINNER JOIN studio ON {primaryTable}.studio_id = studio.item_id`\n\n\t\t\t\tvar unions []string\n\t\t\t\tfor _, c := range formatMaps {\n\t\t\t\t\tunions = append(unions, utils.StrFormat(templStr, c))\n\t\t\t\t}\n\n\t\t\t\tf.addWith(fmt.Sprintf(\"%s AS (%s)\", derivedPerformerStudioTable, strings.Join(unions, \" UNION \")))\n\n\t\t\t\tf.addLeftJoin(derivedPerformerStudioTable, \"\", fmt.Sprintf(\"performers.id = %s.performer_id\", derivedPerformerStudioTable))\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s.performer_id IS %s NULL\", derivedPerformerStudioTable, clauseCondition))\n\t\t\t}\n\n\t\t\t// #6412 - handle excludes as well\n\t\t\tif len(studios.Excludes) > 0 {\n\t\t\t\texcludeValuesClause, err := getHierarchicalValues(ctx, studios.Excludes, studioTable, \"\", \"parent_id\", \"child_id\", studios.Depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWith(\"exclude_studio(root_id, item_id) AS (\" + excludeValuesClause + \")\")\n\n\t\t\t\texcludeTemplStr := `SELECT performer_id FROM {primaryTable}\n\tINNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK}\n\tINNER JOIN exclude_studio ON {primaryTable}.studio_id = exclude_studio.item_id`\n\n\t\t\t\tvar unions []string\n\t\t\t\tfor _, c := range formatMaps {\n\t\t\t\t\tunions = append(unions, utils.StrFormat(excludeTemplStr, c))\n\t\t\t\t}\n\n\t\t\t\tconst excludePerformerStudioTable = \"performer_studio_exclude\"\n\t\t\t\tf.addWith(fmt.Sprintf(\"%s AS (%s)\", excludePerformerStudioTable, strings.Join(unions, \" UNION \")))\n\n\t\t\t\tf.addLeftJoin(excludePerformerStudioTable, \"\", fmt.Sprintf(\"performers.id = %s.performer_id\", excludePerformerStudioTable))\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s.performer_id IS NULL\", excludePerformerStudioTable))\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *performerFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif groups != nil {\n\t\t\tif groups.Modifier == models.CriterionModifierIsNull || groups.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif groups.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tf.addLeftJoin(performersScenesTable, \"\", \"performers_scenes.performer_id = performers.id\")\n\t\t\t\tf.addLeftJoin(groupsScenesTable, \"\", \"performers_scenes.scene_id = groups_scenes.scene_id\")\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s groups_scenes.group_id IS NULL\", notClause))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(groups.Value) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar clauseCondition string\n\n\t\t\tswitch groups.Modifier {\n\t\t\tcase models.CriterionModifierIncludes:\n\t\t\t\t// return performers who appear in scenes with any of the given groups\n\t\t\t\tclauseCondition = \"NOT\"\n\t\t\tcase models.CriterionModifierExcludes:\n\t\t\t\t// exclude performers who appear in scenes with any of the given groups\n\t\t\t\tclauseCondition = \"\"\n\t\t\tdefault:\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst derivedPerformerGroupTable = \"performer_group\"\n\n\t\t\t// Simplified approach: direct group-scene-performer relationship without hierarchy\n\t\t\tvar args []interface{}\n\t\t\tfor _, val := range groups.Value {\n\t\t\t\targs = append(args, val)\n\t\t\t}\n\n\t\t\t// If depth is specified and not 0, we need hierarchy, otherwise use simple approach\n\t\t\tdepthVal := 0\n\t\t\tif groups.Depth != nil {\n\t\t\t\tdepthVal = *groups.Depth\n\t\t\t}\n\n\t\t\tif depthVal == 0 {\n\t\t\t\t// Simple case: no hierarchy, direct group relationship\n\t\t\t\tf.addWith(fmt.Sprintf(\"group_values(id) AS (VALUES %s)\", strings.Repeat(\"(?),\", len(groups.Value)-1)+\"(?)\"), args...)\n\n\t\t\t\ttemplStr := `SELECT performer_id FROM {joinTable}\n\tINNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id\n\tINNER JOIN group_values ON {primaryTable}.{groupFK} = group_values.id`\n\n\t\t\t\tformatMaps := []utils.StrFormatMap{\n\t\t\t\t\t{\n\t\t\t\t\t\t\"primaryTable\": groupsScenesTable,\n\t\t\t\t\t\t\"joinTable\":    performersScenesTable,\n\t\t\t\t\t\t\"primaryFK\":    sceneIDColumn,\n\t\t\t\t\t\t\"groupFK\":      groupIDColumn,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\tvar unions []string\n\t\t\t\tfor _, c := range formatMaps {\n\t\t\t\t\tunions = append(unions, utils.StrFormat(templStr, c))\n\t\t\t\t}\n\n\t\t\t\tf.addWith(fmt.Sprintf(\"%s AS (%s)\", derivedPerformerGroupTable, strings.Join(unions, \" UNION \")))\n\t\t\t} else {\n\t\t\t\t// Complex case: with hierarchy\n\t\t\t\tvar depthCondition string\n\t\t\t\tif depthVal != -1 {\n\t\t\t\t\tdepthCondition = fmt.Sprintf(\"WHERE depth < %d\", depthVal)\n\t\t\t\t}\n\n\t\t\t\t// Build recursive CTE for group hierarchy\n\t\t\t\thierarchyQuery := fmt.Sprintf(`group_hierarchy AS (\nSELECT sub_id AS root_id, sub_id AS item_id, 0 AS depth FROM groups_relations WHERE sub_id IN%s\nUNION\nSELECT root_id, sub_id, depth + 1 FROM groups_relations INNER JOIN group_hierarchy ON item_id = containing_id %s\n)`, getInBinding(len(groups.Value)), depthCondition)\n\n\t\t\t\tf.addRecursiveWith(hierarchyQuery, args...)\n\n\t\t\t\ttemplStr := `SELECT performer_id FROM {joinTable}\n\tINNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id\n\tINNER JOIN group_hierarchy ON {primaryTable}.{groupFK} = group_hierarchy.item_id`\n\n\t\t\t\tformatMaps := []utils.StrFormatMap{\n\t\t\t\t\t{\n\t\t\t\t\t\t\"primaryTable\": groupsScenesTable,\n\t\t\t\t\t\t\"joinTable\":    performersScenesTable,\n\t\t\t\t\t\t\"primaryFK\":    sceneIDColumn,\n\t\t\t\t\t\t\"groupFK\":      groupIDColumn,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\tvar unions []string\n\t\t\t\tfor _, c := range formatMaps {\n\t\t\t\t\tunions = append(unions, utils.StrFormat(templStr, c))\n\t\t\t\t}\n\n\t\t\t\tf.addWith(fmt.Sprintf(\"%s AS (%s)\", derivedPerformerGroupTable, strings.Join(unions, \" UNION \")))\n\t\t\t}\n\n\t\t\tf.addLeftJoin(derivedPerformerGroupTable, \"\", fmt.Sprintf(\"performers.id = %s.performer_id\", derivedPerformerGroupTable))\n\t\t\tf.addWhere(fmt.Sprintf(\"%s.performer_id IS %s NULL\", derivedPerformerGroupTable, clauseCondition))\n\t\t}\n\t}\n}\n\nfunc (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performers != nil {\n\t\t\tformatMaps := []utils.StrFormatMap{\n\t\t\t\t{\n\t\t\t\t\t\"primaryTable\": performersScenesTable,\n\t\t\t\t\t\"joinTable\":    performersScenesTable,\n\t\t\t\t\t\"primaryFK\":    sceneIDColumn,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"primaryTable\": performersImagesTable,\n\t\t\t\t\t\"joinTable\":    performersImagesTable,\n\t\t\t\t\t\"primaryFK\":    imageIDColumn,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"primaryTable\": performersGalleriesTable,\n\t\t\t\t\t\"joinTable\":    performersGalleriesTable,\n\t\t\t\t\t\"primaryFK\":    galleryIDColumn,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tif len(performers.Value) == '0' {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst derivedPerformerPerformersTable = \"performer_performers\"\n\n\t\t\tvaluesClause := strings.Join(performers.Value, \"),(\")\n\n\t\t\tf.addWith(\"performer(id) AS (VALUES(\" + valuesClause + \"))\")\n\n\t\t\ttemplStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable}\n\t\t\tINNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK}\n\t\t\tINNER JOIN performer ON {primaryTable}.performer_id = performer.id\n\t\t\tWHERE {primaryTable}2.performer_id != performer.id`\n\n\t\t\tif performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 {\n\t\t\t\ttemplStr += `\n\t\t\t\t\t\t\tGROUP BY {primaryTable}2.performer_id\n\t\t\t\t\t\t\tHAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)`\n\t\t\t}\n\n\t\t\tvar unions []string\n\t\t\tfor _, c := range formatMaps {\n\t\t\t\tunions = append(unions, utils.StrFormat(templStr, c))\n\t\t\t}\n\n\t\t\tf.addWith(fmt.Sprintf(\"%s AS (%s)\", derivedPerformerPerformersTable, strings.Join(unions, \" UNION \")))\n\n\t\t\tf.addInnerJoin(derivedPerformerPerformersTable, \"\", fmt.Sprintf(\"performers.id = %s.performer_id\", derivedPerformerPerformersTable))\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/performer_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nvar testCustomFields = map[string]interface{}{\n\t\"string\": \"aaa\",\n\t\"int\":    int64(123), // int64 to match the type of the field in the database\n\t\"real\":   1.23,\n}\n\nfunc loadPerformerRelationships(ctx context.Context, expected models.Performer, actual *models.Performer) error {\n\tif expected.Aliases.Loaded() {\n\t\tif err := actual.LoadAliases(ctx, db.Performer); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.URLs.Loaded() {\n\t\tif err := actual.LoadURLs(ctx, db.Performer); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.TagIDs.Loaded() {\n\t\tif err := actual.LoadTagIDs(ctx, db.Performer); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.StashIDs.Loaded() {\n\t\tif err := actual.LoadStashIDs(ctx, db.Performer); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc Test_PerformerStore_Create(t *testing.T) {\n\tvar (\n\t\tname           = \"name\"\n\t\tdisambiguation = \"disambiguation\"\n\t\tgender         = models.GenderEnumFemale\n\t\tdetails        = \"details\"\n\t\turl            = \"url\"\n\t\ttwitter        = \"twitter\"\n\t\tinstagram      = \"instagram\"\n\t\turls           = []string{url, twitter, instagram}\n\t\trating         = 3\n\t\tethnicity      = \"ethnicity\"\n\t\tcountry        = \"country\"\n\t\teyeColor       = \"eyeColor\"\n\t\theight         = 134\n\t\tmeasurements   = \"measurements\"\n\t\tfakeTits       = \"fakeTits\"\n\t\tpenisLength    = 1.23\n\t\tcircumcised    = models.CircumcisedEnumCut\n\t\tcareerStart    = models.DateFromYear(2005)\n\t\tcareerEnd      = models.DateFromYear(2015)\n\t\ttattoos        = \"tattoos\"\n\t\tpiercings      = \"piercings\"\n\t\taliases        = []string{\"alias1\", \"alias2\"}\n\t\thairColor      = \"hairColor\"\n\t\tweight         = 123\n\t\tignoreAutoTag  = true\n\t\tfavorite       = true\n\t\tendpoint1      = \"endpoint1\"\n\t\tendpoint2      = \"endpoint2\"\n\t\tstashID1       = \"stashid1\"\n\t\tstashID2       = \"stashid2\"\n\t\tcreatedAt      = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt      = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\n\t\tbirthdate, _ = models.ParseDate(\"2003-02-01\")\n\t\tdeathdate, _ = models.ParseDate(\"2023-02-01\")\n\t)\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.CreatePerformerInput\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.CreatePerformerInput{\n\t\t\t\tPerformer: &models.Performer{\n\t\t\t\t\tName:           name,\n\t\t\t\t\tDisambiguation: disambiguation,\n\t\t\t\t\tGender:         &gender,\n\t\t\t\t\tURLs:           models.NewRelatedStrings(urls),\n\t\t\t\t\tBirthdate:      &birthdate,\n\t\t\t\t\tEthnicity:      ethnicity,\n\t\t\t\t\tCountry:        country,\n\t\t\t\t\tEyeColor:       eyeColor,\n\t\t\t\t\tHeight:         &height,\n\t\t\t\t\tMeasurements:   measurements,\n\t\t\t\t\tFakeTits:       fakeTits,\n\t\t\t\t\tPenisLength:    &penisLength,\n\t\t\t\t\tCircumcised:    &circumcised,\n\t\t\t\t\tCareerStart:    &careerStart,\n\t\t\t\t\tCareerEnd:      &careerEnd,\n\t\t\t\t\tTattoos:        tattoos,\n\t\t\t\t\tPiercings:      piercings,\n\t\t\t\t\tFavorite:       favorite,\n\t\t\t\t\tRating:         &rating,\n\t\t\t\t\tDetails:        details,\n\t\t\t\t\tDeathDate:      &deathdate,\n\t\t\t\t\tHairColor:      hairColor,\n\t\t\t\t\tWeight:         &weight,\n\t\t\t\t\tIgnoreAutoTag:  ignoreAutoTag,\n\t\t\t\t\tTagIDs:         models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}),\n\t\t\t\t\tAliases:        models.NewRelatedStrings(aliases),\n\t\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t\tCustomFields: testCustomFields,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.CreatePerformerInput{\n\t\t\t\tPerformer: &models.Performer{\n\t\t\t\t\tName:   name,\n\t\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Performer\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tp := tt.newObject\n\t\t\tif err := qb.Create(ctx, &p); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(p.ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(p.ID)\n\n\t\t\tcopy := *tt.newObject.Performer\n\t\t\tcopy.ID = p.ID\n\n\t\t\t// load relationships\n\t\t\tif err := loadPerformerRelationships(ctx, copy, p.Performer); err != nil {\n\t\t\t\tt.Errorf(\"loadPerformerRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *p.Performer)\n\n\t\t\t// ensure can find the performer\n\t\t\tfound, err := qb.Find(ctx, p.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"PerformerStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tif !assert.NotNil(found) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadPerformerRelationships(ctx, copy, found); err != nil {\n\t\t\t\tt.Errorf(\"loadPerformerRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.Equal(copy, *found)\n\n\t\t\t// ensure custom fields are set\n\t\t\tcf, err := qb.GetCustomFields(ctx, p.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"PerformerStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.newObject.CustomFields, cf)\n\t\t})\n\t}\n}\n\nfunc Test_PerformerStore_Update(t *testing.T) {\n\tvar (\n\t\tname           = \"name\"\n\t\tdisambiguation = \"disambiguation\"\n\t\tgender         = models.GenderEnumFemale\n\t\tdetails        = \"details\"\n\t\turl            = \"url\"\n\t\ttwitter        = \"twitter\"\n\t\tinstagram      = \"instagram\"\n\t\turls           = []string{url, twitter, instagram}\n\t\trating         = 3\n\t\tethnicity      = \"ethnicity\"\n\t\tcountry        = \"country\"\n\t\teyeColor       = \"eyeColor\"\n\t\theight         = 134\n\t\tmeasurements   = \"measurements\"\n\t\tfakeTits       = \"fakeTits\"\n\t\tpenisLength    = 1.23\n\t\tcircumcised    = models.CircumcisedEnumCut\n\t\tcareerStart    = models.DateFromYear(2005)\n\t\tcareerEnd      = models.DateFromYear(2015)\n\t\ttattoos        = \"tattoos\"\n\t\tpiercings      = \"piercings\"\n\t\taliases        = []string{\"alias1\", \"alias2\"}\n\t\thairColor      = \"hairColor\"\n\t\tweight         = 123\n\t\tignoreAutoTag  = true\n\t\tfavorite       = true\n\t\tendpoint1      = \"endpoint1\"\n\t\tendpoint2      = \"endpoint2\"\n\t\tstashID1       = \"stashid1\"\n\t\tstashID2       = \"stashid2\"\n\t\tcreatedAt      = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt      = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\n\t\tbirthdate, _ = models.ParseDate(\"2003-02-01\")\n\t\tdeathdate, _ = models.ParseDate(\"2023-02-01\")\n\t)\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject models.UpdatePerformerInput\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.UpdatePerformerInput{\n\t\t\t\tPerformer: &models.Performer{\n\t\t\t\t\tID:             performerIDs[performerIdxWithGallery],\n\t\t\t\t\tName:           name,\n\t\t\t\t\tDisambiguation: disambiguation,\n\t\t\t\t\tGender:         &gender,\n\t\t\t\t\tURLs:           models.NewRelatedStrings(urls),\n\t\t\t\t\tBirthdate:      &birthdate,\n\t\t\t\t\tEthnicity:      ethnicity,\n\t\t\t\t\tCountry:        country,\n\t\t\t\t\tEyeColor:       eyeColor,\n\t\t\t\t\tHeight:         &height,\n\t\t\t\t\tMeasurements:   measurements,\n\t\t\t\t\tFakeTits:       fakeTits,\n\t\t\t\t\tPenisLength:    &penisLength,\n\t\t\t\t\tCircumcised:    &circumcised,\n\t\t\t\t\tCareerStart:    &careerStart,\n\t\t\t\t\tCareerEnd:      &careerEnd,\n\t\t\t\t\tTattoos:        tattoos,\n\t\t\t\t\tPiercings:      piercings,\n\t\t\t\t\tFavorite:       favorite,\n\t\t\t\t\tRating:         &rating,\n\t\t\t\t\tDetails:        details,\n\t\t\t\t\tDeathDate:      &deathdate,\n\t\t\t\t\tHairColor:      hairColor,\n\t\t\t\t\tWeight:         &weight,\n\t\t\t\t\tIgnoreAutoTag:  ignoreAutoTag,\n\t\t\t\t\tAliases:        models.NewRelatedStrings(aliases),\n\t\t\t\t\tTagIDs:         models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}),\n\t\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear nullables\",\n\t\t\tmodels.UpdatePerformerInput{\n\t\t\t\tPerformer: &models.Performer{\n\t\t\t\t\tID:       performerIDs[performerIdxWithGallery],\n\t\t\t\t\tAliases:  models.NewRelatedStrings([]string{}),\n\t\t\t\t\tURLs:     models.NewRelatedStrings([]string{}),\n\t\t\t\t\tTagIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear tag ids\",\n\t\t\tmodels.UpdatePerformerInput{\n\t\t\t\tPerformer: &models.Performer{\n\t\t\t\t\tID:     performerIDs[sceneIdxWithTag],\n\t\t\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\tmodels.UpdatePerformerInput{\n\t\t\t\tPerformer: &models.Performer{\n\t\t\t\t\tID: performerIDs[performerIdxWithGallery],\n\t\t\t\t},\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\tmodels.UpdatePerformerInput{\n\t\t\t\tPerformer: &models.Performer{\n\t\t\t\t\tID: performerIDs[performerIdxWithGallery],\n\t\t\t\t},\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.UpdatePerformerInput{\n\t\t\t\tPerformer: &models.Performer{\n\t\t\t\t\tID:     performerIDs[sceneIdxWithGallery],\n\t\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Performer\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tcopy := *tt.updatedObject.Performer\n\n\t\t\tif err := qb.Update(ctx, &tt.updatedObject); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.updatedObject.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"PerformerStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadPerformerRelationships(ctx, copy, s); err != nil {\n\t\t\t\tt.Errorf(\"loadPerformerRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *s)\n\n\t\t\t// ensure custom fields are correct\n\t\t\tif tt.updatedObject.CustomFields.Full != nil {\n\t\t\t\tcf, err := qb.GetCustomFields(ctx, tt.updatedObject.ID)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"PerformerStore.GetCustomFields() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tassert.Equal(tt.updatedObject.CustomFields.Full, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc clearPerformerPartial() models.PerformerPartial {\n\tnullString := models.OptionalString{Set: true, Null: true}\n\tnullDate := models.OptionalDate{Set: true, Null: true}\n\tnullInt := models.OptionalInt{Set: true, Null: true}\n\tnullFloat := models.OptionalFloat64{Set: true, Null: true}\n\n\t// leave mandatory fields\n\treturn models.PerformerPartial{\n\t\tDisambiguation: nullString,\n\t\tGender:         nullString,\n\t\tURLs:           &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},\n\t\tBirthdate:      nullDate,\n\t\tEthnicity:      nullString,\n\t\tCountry:        nullString,\n\t\tEyeColor:       nullString,\n\t\tHeight:         nullInt,\n\t\tMeasurements:   nullString,\n\t\tFakeTits:       nullString,\n\t\tPenisLength:    nullFloat,\n\t\tCircumcised:    nullString,\n\t\tCareerStart:    nullDate,\n\t\tCareerEnd:      nullDate,\n\t\tTattoos:        nullString,\n\t\tPiercings:      nullString,\n\t\tAliases:        &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},\n\t\tRating:         nullInt,\n\t\tDetails:        nullString,\n\t\tDeathDate:      nullDate,\n\t\tHairColor:      nullString,\n\t\tWeight:         nullInt,\n\t\tTagIDs:         &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t\tStashIDs:       &models.UpdateStashIDs{Mode: models.RelationshipUpdateModeSet},\n\t}\n}\n\nfunc Test_PerformerStore_UpdatePartial(t *testing.T) {\n\tvar (\n\t\tname           = \"name\"\n\t\tdisambiguation = \"disambiguation\"\n\t\tgender         = models.GenderEnumFemale\n\t\tdetails        = \"details\"\n\t\turl            = \"url\"\n\t\ttwitter        = \"twitter\"\n\t\tinstagram      = \"instagram\"\n\t\turls           = []string{url, twitter, instagram}\n\t\trating         = 3\n\t\tethnicity      = \"ethnicity\"\n\t\tcountry        = \"country\"\n\t\teyeColor       = \"eyeColor\"\n\t\theight         = 143\n\t\tmeasurements   = \"measurements\"\n\t\tfakeTits       = \"fakeTits\"\n\t\tpenisLength    = 1.23\n\t\tcircumcised    = models.CircumcisedEnumCut\n\t\tcareerStart    = models.DateFromYear(2005)\n\t\tcareerEnd      = models.DateFromYear(2015)\n\t\ttattoos        = \"tattoos\"\n\t\tpiercings      = \"piercings\"\n\t\taliases        = []string{\"alias1\", \"alias2\"}\n\t\thairColor      = \"hairColor\"\n\t\tweight         = 123\n\t\tignoreAutoTag  = true\n\t\tfavorite       = true\n\t\tendpoint1      = \"endpoint1\"\n\t\tendpoint2      = \"endpoint2\"\n\t\tstashID1       = \"stashid1\"\n\t\tstashID2       = \"stashid2\"\n\t\tcreatedAt      = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt      = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\n\t\tbirthdate, _ = models.ParseDate(\"2003-02-01\")\n\t\tdeathdate, _ = models.ParseDate(\"2023-02-01\")\n\t)\n\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.PerformerPartial\n\t\twant    models.Performer\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tperformerIDs[performerIdxWithDupName],\n\t\t\tmodels.PerformerPartial{\n\t\t\t\tName:           models.NewOptionalString(name),\n\t\t\t\tDisambiguation: models.NewOptionalString(disambiguation),\n\t\t\t\tGender:         models.NewOptionalString(gender.String()),\n\t\t\t\tURLs: &models.UpdateStrings{\n\t\t\t\t\tValues: urls,\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tBirthdate:    models.NewOptionalDate(birthdate),\n\t\t\t\tEthnicity:    models.NewOptionalString(ethnicity),\n\t\t\t\tCountry:      models.NewOptionalString(country),\n\t\t\t\tEyeColor:     models.NewOptionalString(eyeColor),\n\t\t\t\tHeight:       models.NewOptionalInt(height),\n\t\t\t\tMeasurements: models.NewOptionalString(measurements),\n\t\t\t\tFakeTits:     models.NewOptionalString(fakeTits),\n\t\t\t\tPenisLength:  models.NewOptionalFloat64(penisLength),\n\t\t\t\tCircumcised:  models.NewOptionalString(circumcised.String()),\n\t\t\t\tCareerStart:  models.NewOptionalDate(careerStart),\n\t\t\t\tCareerEnd:    models.NewOptionalDate(careerEnd),\n\t\t\t\tTattoos:      models.NewOptionalString(tattoos),\n\t\t\t\tPiercings:    models.NewOptionalString(piercings),\n\t\t\t\tAliases: &models.UpdateStrings{\n\t\t\t\t\tValues: aliases,\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tFavorite:      models.NewOptionalBool(favorite),\n\t\t\t\tRating:        models.NewOptionalInt(rating),\n\t\t\t\tDetails:       models.NewOptionalString(details),\n\t\t\t\tDeathDate:     models.NewOptionalDate(deathdate),\n\t\t\t\tHairColor:     models.NewOptionalString(hairColor),\n\t\t\t\tWeight:        models.NewOptionalInt(weight),\n\t\t\t\tIgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag),\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\tStashIDs: []models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tCreatedAt: models.NewOptionalTime(createdAt),\n\t\t\t\tUpdatedAt: models.NewOptionalTime(updatedAt),\n\t\t\t},\n\t\t\tmodels.Performer{\n\t\t\t\tID:             performerIDs[performerIdxWithDupName],\n\t\t\t\tName:           name,\n\t\t\t\tDisambiguation: disambiguation,\n\t\t\t\tGender:         &gender,\n\t\t\t\tURLs:           models.NewRelatedStrings(urls),\n\t\t\t\tBirthdate:      &birthdate,\n\t\t\t\tEthnicity:      ethnicity,\n\t\t\t\tCountry:        country,\n\t\t\t\tEyeColor:       eyeColor,\n\t\t\t\tHeight:         &height,\n\t\t\t\tMeasurements:   measurements,\n\t\t\t\tFakeTits:       fakeTits,\n\t\t\t\tPenisLength:    &penisLength,\n\t\t\t\tCircumcised:    &circumcised,\n\t\t\t\tCareerStart:    &careerStart,\n\t\t\t\tCareerEnd:      &careerEnd,\n\t\t\t\tTattoos:        tattoos,\n\t\t\t\tPiercings:      piercings,\n\t\t\t\tAliases:        models.NewRelatedStrings(aliases),\n\t\t\t\tFavorite:       favorite,\n\t\t\t\tRating:         &rating,\n\t\t\t\tDetails:        details,\n\t\t\t\tDeathDate:      &deathdate,\n\t\t\t\tHairColor:      hairColor,\n\t\t\t\tWeight:         &weight,\n\t\t\t\tIgnoreAutoTag:  ignoreAutoTag,\n\t\t\t\tTagIDs:         models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}),\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear all\",\n\t\t\tperformerIDs[performerIdxWithTwoTags],\n\t\t\tclearPerformerPartial(),\n\t\t\tmodels.Performer{\n\t\t\t\tID:            performerIDs[performerIdxWithTwoTags],\n\t\t\t\tName:          getPerformerStringValue(performerIdxWithTwoTags, \"Name\"),\n\t\t\t\tFavorite:      getPerformerBoolValue(performerIdxWithTwoTags),\n\t\t\t\tURLs:          models.NewRelatedStrings([]string{}),\n\t\t\t\tAliases:       models.NewRelatedStrings([]string{}),\n\t\t\t\tTagIDs:        models.NewRelatedIDs([]int{}),\n\t\t\t\tStashIDs:      models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t\tIgnoreAutoTag: getIgnoreAutoTag(performerIdxWithTwoTags),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid id\",\n\t\t\tinvalidID,\n\t\t\tmodels.PerformerPartial{},\n\t\t\tmodels.Performer{},\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Performer\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := loadPerformerRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadPerformerRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, *got)\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"PerformerStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadPerformerRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadPerformerRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, *s)\n\t\t})\n\t}\n}\n\nfunc Test_PerformerStore_UpdatePartialCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tid       int\n\t\tpartial  models.PerformerPartial\n\t\texpected map[string]interface{} // nil to use the partial\n\t}{\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\tperformerIDs[performerIdxWithGallery],\n\t\t\tmodels.PerformerPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\tperformerIDs[performerIdxWithGallery],\n\t\t\tmodels.PerformerPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"partial custom fields\",\n\t\t\tperformerIDs[performerIdxWithGallery],\n\t\t\tmodels.PerformerPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\t\t\"new_field\": \"new\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"int\":       int64(3),\n\t\t\t\t\"real\":      1.3,\n\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\"new_field\": \"new\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Performer\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t_, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"PerformerStore.UpdatePartial() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure custom fields are correct\n\t\t\tcf, err := qb.GetCustomFields(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"PerformerStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.expected == nil {\n\t\t\t\tassert.Equal(tt.partial.CustomFields.Full, cf)\n\t\t\t} else {\n\t\t\t\tassert.Equal(tt.expected, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPerformerFindBySceneID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tpqb := db.Performer\n\t\tsceneID := sceneIDs[sceneIdxWithPerformer]\n\n\t\tperformers, err := pqb.FindBySceneID(ctx, sceneID)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t}\n\n\t\tif !assert.Equal(t, 1, len(performers)) {\n\t\t\treturn nil\n\t\t}\n\n\t\tperformer := performers[0]\n\n\t\tassert.Equal(t, getPerformerStringValue(performerIdxWithScene, \"Name\"), performer.Name)\n\n\t\tperformers, err = pqb.FindBySceneID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 0, len(performers))\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerFindByImageID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tpqb := db.Performer\n\t\timageID := imageIDs[imageIdxWithPerformer]\n\n\t\tperformers, err := pqb.FindByImageID(ctx, imageID)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t}\n\n\t\tif !assert.Equal(t, 1, len(performers)) {\n\t\t\treturn nil\n\t\t}\n\n\t\tperformer := performers[0]\n\n\t\tassert.Equal(t, getPerformerStringValue(performerIdxWithImage, \"Name\"), performer.Name)\n\n\t\tperformers, err = pqb.FindByImageID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 0, len(performers))\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerFindByGalleryID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tpqb := db.Performer\n\t\tgalleryID := galleryIDs[galleryIdxWithPerformer]\n\n\t\tperformers, err := pqb.FindByGalleryID(ctx, galleryID)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t}\n\n\t\tif !assert.Equal(t, 1, len(performers)) {\n\t\t\treturn nil\n\t\t}\n\n\t\tperformer := performers[0]\n\n\t\tassert.Equal(t, getPerformerStringValue(performerIdxWithGallery, \"Name\"), performer.Name)\n\n\t\tperformers, err = pqb.FindByGalleryID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 0, len(performers))\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerFindByNames(t *testing.T) {\n\tgetNames := func(p []*models.Performer) []string {\n\t\tvar ret []string\n\t\tfor _, pp := range p {\n\t\t\tret = append(ret, pp.Name)\n\t\t}\n\t\treturn ret\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tvar names []string\n\n\t\tpqb := db.Performer\n\n\t\tnames = append(names, performerNames[performerIdxWithScene]) // find performers by names\n\n\t\tperformers, err := pqb.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performers: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, performers, 1)\n\t\tassert.Equal(t, performerNames[performerIdxWithScene], performers[0].Name)\n\n\t\tperformers, err = pqb.FindByNames(ctx, names, true) // find performers by names nocase\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performers: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, performers, 2) // performerIdxWithScene and performerIdxWithDupName\n\t\tassert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[0].Name))\n\t\tassert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[1].Name))\n\n\t\tnames = append(names, performerNames[performerIdx1WithScene]) // find performers by names ( 2 names )\n\n\t\tperformers, err = pqb.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performers: %s\", err.Error())\n\t\t}\n\t\tretNames := getNames(performers)\n\t\tassert.Equal(t, names, retNames)\n\n\t\tperformers, err = pqb.FindByNames(ctx, names, true) // find performers by names ( 2 names nocase)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performers: %s\", err.Error())\n\t\t}\n\t\tretNames = getNames(performers)\n\t\tassert.Equal(t, []string{\n\t\t\tperformerNames[performerIdxWithScene],\n\t\t\tperformerNames[performerIdx1WithScene],\n\t\t\tperformerNames[performerIdx1WithDupName],\n\t\t\tperformerNames[performerIdxWithDupName],\n\t\t}, retNames)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryEthnicityOr(t *testing.T) {\n\tconst performer1Idx = 1\n\tconst performer2Idx = 2\n\n\tperformer1Eth := getPerformerStringValue(performer1Idx, \"Ethnicity\")\n\tperformer2Eth := getPerformerStringValue(performer2Idx, \"Ethnicity\")\n\n\tperformerFilter := models.PerformerFilterType{\n\t\tEthnicity: &models.StringCriterionInput{\n\t\t\tValue:    performer1Eth,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.PerformerFilterType]{\n\t\t\tOr: &models.PerformerFilterType{\n\t\t\t\tEthnicity: &models.StringCriterionInput{\n\t\t\t\t\tValue:    performer2Eth,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\n\t\tassert.Len(t, performers, 2)\n\t\tassert.Equal(t, performer1Eth, performers[0].Ethnicity)\n\t\tassert.Equal(t, performer2Eth, performers[1].Ethnicity)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryEthnicityAndRating(t *testing.T) {\n\tconst performerIdx = 1\n\tperformerEth := getPerformerStringValue(performerIdx, \"Ethnicity\")\n\tperformerRating := int(getRating(performerIdx).Int64)\n\n\tperformerFilter := models.PerformerFilterType{\n\t\tEthnicity: &models.StringCriterionInput{\n\t\t\tValue:    performerEth,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.PerformerFilterType]{\n\t\t\tAnd: &models.PerformerFilterType{\n\t\t\t\tRating100: &models.IntCriterionInput{\n\t\t\t\t\tValue:    performerRating,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\n\t\tif !assert.Len(t, performers, 1) {\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, performerEth, performers[0].Ethnicity)\n\t\tif assert.NotNil(t, performers[0].Rating) {\n\t\t\tassert.Equal(t, performerRating, *performers[0].Rating)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryEthnicityNotRating(t *testing.T) {\n\tconst performerIdx = 1\n\n\tperformerRating := getRating(performerIdx)\n\n\tethCriterion := models.StringCriterionInput{\n\t\tValue:    \"performer_.*1_Ethnicity\",\n\t\tModifier: models.CriterionModifierMatchesRegex,\n\t}\n\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    int(performerRating.Int64),\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tperformerFilter := models.PerformerFilterType{\n\t\tEthnicity: &ethCriterion,\n\t\tOperatorFilter: models.OperatorFilter[models.PerformerFilterType]{\n\t\t\tNot: &models.PerformerFilterType{\n\t\t\t\tRating100: &ratingCriterion,\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\n\t\tfor _, performer := range performers {\n\t\t\tverifyString(t, performer.Ethnicity, ethCriterion)\n\t\t\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\t\t\tverifyIntPtr(t, performer.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerIllegalQuery(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst performerIdx = 1\n\tsubFilter := models.PerformerFilterType{\n\t\tEthnicity: &models.StringCriterionInput{\n\t\t\tValue:    getPerformerStringValue(performerIdx, \"Ethnicity\"),\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\ttests := []struct {\n\t\tname   string\n\t\tfilter models.PerformerFilterType\n\t}{\n\t\t{\n\t\t\t// And and Or in the same filter\n\t\t\t\"AndOr\",\n\t\t\tmodels.PerformerFilterType{\n\t\t\t\tOperatorFilter: models.OperatorFilter[models.PerformerFilterType]{\n\t\t\t\t\tAnd: &subFilter,\n\t\t\t\t\tOr:  &subFilter,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t// And and Not in the same filter\n\t\t\t\"AndNot\",\n\t\t\tmodels.PerformerFilterType{\n\t\t\t\tOperatorFilter: models.OperatorFilter[models.PerformerFilterType]{\n\t\t\t\t\tAnd: &subFilter,\n\t\t\t\t\tNot: &subFilter,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t// Or and Not in the same filter\n\t\t\t\"OrNot\",\n\t\t\tmodels.PerformerFilterType{\n\t\t\t\tOperatorFilter: models.OperatorFilter[models.PerformerFilterType]{\n\t\t\t\t\tOr:  &subFilter,\n\t\t\t\t\tNot: &subFilter,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"invalid height modifier\",\n\t\t\tmodels.PerformerFilterType{\n\t\t\t\tHeight: &models.StringCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\tValue:    \"123\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"invalid height value\",\n\t\t\tmodels.PerformerFilterType{\n\t\t\t\tHeight: &models.StringCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue:    \"foo\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tsqb := db.Performer\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\t_, _, err := sqb.Query(ctx, &tt.filter, nil)\n\t\t\tassert.NotNil(err)\n\t\t})\n\t}\n}\n\nfunc TestPerformerQueryIgnoreAutoTag(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tignoreAutoTag := true\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tIgnoreAutoTag: &ignoreAutoTag,\n\t\t}\n\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\n\t\tassert.Len(t, performers, int(math.Ceil(float64(totalPerformers)/5)))\n\t\tfor _, p := range performers {\n\t\t\tassert.True(t, p.IgnoreAutoTag)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQuery(t *testing.T) {\n\tvar (\n\t\tendpoint = performerStashID(performerIdxWithGallery).Endpoint\n\t\tstashID  = performerStashID(performerIdxWithGallery).StashID\n\t\tstashID2 = performerStashID(performerIdx1WithGallery).StashID\n\t\tstashIDs = []*string{&stashID, &stashID2}\n\t)\n\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.PerformerFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashID:  &stashID,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashID:  &stashID,\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"null stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery, performerIdx1WithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithGallery, performerIdx1WithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"null stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithGallery, performerIdx1WithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery, performerIdx1WithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"circumcised (cut)\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tCircumcised: &models.CircumcisionCriterionInput{\n\t\t\t\t\tValue:    []models.CircumcisedEnum{models.CircumcisedEnumCut},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdx1WithScene},\n\t\t\t[]int{performerIdxWithScene, performerIdx2WithScene},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"circumcised (excludes cut)\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tCircumcised: &models.CircumcisionCriterionInput{\n\t\t\t\t\tValue:    []models.CircumcisedEnum{models.CircumcisedEnumCut},\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdx2WithScene},\n\t\t\t// performerIdxWithScene has null value\n\t\t\t[]int{performerIdx1WithScene, performerIdxWithScene},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"include scene studio\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(studioIDs[studioIdxWithScenePerformer])},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithSceneStudio},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"include image studio\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(studioIDs[studioIdxWithImagePerformer])},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithImageStudio},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"include gallery studio\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(studioIDs[studioIdxWithGalleryPerformer])},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGalleryStudio},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude scene studio\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(studioIDs[studioIdxWithScenePerformer])},\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithSceneStudio},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude image studio\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(studioIDs[studioIdxWithImagePerformer])},\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithImageStudio},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude gallery studio\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(studioIDs[studioIdxWithGalleryPerformer])},\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithGalleryStudio},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"include and exclude scene studio\",\n\t\t\tnil,\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(studioIDs[studioIdx1WithTwoScenePerformer])},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\tExcludes: []string{strconv.Itoa(studioIDs[studioIdx2WithTwoScenePerformer])},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithTwoSceneStudio},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tperformers, _, err := db.Performer.Query(ctx, tt.filter, tt.findFilter)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := performersToIDs(performers)\n\t\t\tinclude := indexesToIDs(performerIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(performerIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPerformerQueryCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      *models.PerformerFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue:    []any{getPerformerStringValue(performerIdxWithGallery, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getPerformerStringValue(performerIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\t\tValue:    []any{getPerformerStringValue(performerIdxWithGallery, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue:    []any{getPerformerStringValue(performerIdxWithGallery, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getPerformerStringValue(performerIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\t\tValue:    []any{getPerformerStringValue(performerIdxWithGallery, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"regex\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*13_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid regex\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getPerformerStringValue(performerIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*13_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid not matches regex\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getPerformerStringValue(performerIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"not existing\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getPerformerStringValue(performerIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"between\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierBetween,\n\t\t\t\t\t\tValue:    []any{0.05, 0.15},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{performerIdx1WithScene},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not between\",\n\t\t\t&models.PerformerFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getPerformerStringValue(performerIdx1WithScene, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotBetween,\n\t\t\t\t\t\tValue:    []any{0.05, 0.15},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{performerIdx1WithScene},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tperformers, _, err := db.Performer.Query(ctx, tt.filter, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := performersToIDs(performers)\n\t\t\tinclude := indexesToIDs(performerIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(performerIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPerformerQueryPenisLength(t *testing.T) {\n\tvar upper = 4.0\n\n\ttests := []struct {\n\t\tname     string\n\t\tmodifier models.CriterionModifier\n\t\tvalue    float64\n\t\tvalue2   *float64\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\tmodels.CriterionModifierEquals,\n\t\t\t1,\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tmodels.CriterionModifierNotEquals,\n\t\t\t1,\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"greater than\",\n\t\t\tmodels.CriterionModifierGreaterThan,\n\t\t\t1,\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"between\",\n\t\t\tmodels.CriterionModifierBetween,\n\t\t\t2,\n\t\t\t&upper,\n\t\t},\n\t\t{\n\t\t\t\"greater than\",\n\t\t\tmodels.CriterionModifierNotBetween,\n\t\t\t2,\n\t\t\t&upper,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\tmodels.CriterionModifierIsNull,\n\t\t\t0,\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tmodels.CriterionModifierNotNull,\n\t\t\t0,\n\t\t\tnil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tfilter := &models.PerformerFilterType{\n\t\t\t\tPenisLength: &models.FloatCriterionInput{\n\t\t\t\t\tModifier: tt.modifier,\n\t\t\t\t\tValue:    tt.value,\n\t\t\t\t\tValue2:   tt.value2,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tperformers, _, err := db.Performer.Query(ctx, filter, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"PerformerStore.Query() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfor _, p := range performers {\n\t\t\t\tverifyFloat(t, p.PenisLength, *filter.PenisLength)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc verifyFloat(t *testing.T, value *float64, criterion models.FloatCriterionInput) bool {\n\tt.Helper()\n\tassert := assert.New(t)\n\tswitch criterion.Modifier {\n\tcase models.CriterionModifierEquals:\n\t\treturn assert.NotNil(value) && assert.Equal(criterion.Value, *value)\n\tcase models.CriterionModifierNotEquals:\n\t\treturn assert.NotNil(value) && assert.NotEqual(criterion.Value, *value)\n\tcase models.CriterionModifierGreaterThan:\n\t\treturn assert.NotNil(value) && assert.Greater(*value, criterion.Value)\n\tcase models.CriterionModifierLessThan:\n\t\treturn assert.NotNil(value) && assert.Less(*value, criterion.Value)\n\tcase models.CriterionModifierBetween:\n\t\treturn assert.NotNil(value) && assert.GreaterOrEqual(*value, criterion.Value) && assert.LessOrEqual(*value, *criterion.Value2)\n\tcase models.CriterionModifierNotBetween:\n\t\treturn assert.NotNil(value) && assert.True(*value < criterion.Value || *value > *criterion.Value2)\n\tcase models.CriterionModifierIsNull:\n\t\treturn assert.Nil(value)\n\tcase models.CriterionModifierNotNull:\n\t\treturn assert.NotNil(value)\n\t}\n\n\treturn false\n}\n\nfunc TestPerformerQueryForAutoTag(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttqb := db.Performer\n\n\t\tname := performerNames[performerIdx1WithScene] // find a performer by name\n\n\t\tperformers, err := tqb.QueryForAutoTag(ctx, []string{name})\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding performers: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, performers, 2)\n\t\tassert.Equal(t, strings.ToLower(performerNames[performerIdx1WithScene]), strings.ToLower(performers[0].Name))\n\t\tassert.Equal(t, strings.ToLower(performerNames[performerIdx1WithScene]), strings.ToLower(performers[1].Name))\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerUpdatePerformerImage(t *testing.T) {\n\tif err := withRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Performer\n\n\t\t// create performer to test against\n\t\tconst name = \"TestPerformerUpdatePerformerImage\"\n\t\tperformer := models.Performer{\n\t\t\tName: name,\n\t\t}\n\t\terr := qb.Create(ctx, &models.CreatePerformerInput{Performer: &performer})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating performer: %s\", err.Error())\n\t\t}\n\n\t\treturn testUpdateImage(t, ctx, performer.ID, qb.UpdateImage, qb.GetImage)\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestPerformerQueryAge(t *testing.T) {\n\tconst age = 19\n\tageCriterion := models.IntCriterionInput{\n\t\tValue:    age,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyPerformerAge(t, ageCriterion)\n\n\tageCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyPerformerAge(t, ageCriterion)\n\n\tageCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyPerformerAge(t, ageCriterion)\n\n\tageCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyPerformerAge(t, ageCriterion)\n}\n\nfunc verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Performer\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tAge: &ageCriterion,\n\t\t}\n\n\t\tperformers, _, err := qb.Query(ctx, &performerFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying performer: %s\", err.Error())\n\t\t}\n\n\t\tnow := time.Now()\n\t\tfor _, performer := range performers {\n\t\t\tcd := now\n\n\t\t\tif performer.DeathDate != nil {\n\t\t\t\tcd = performer.DeathDate.Time\n\t\t\t}\n\n\t\t\td := performer.Birthdate.Time\n\t\t\tage := cd.Year() - d.Year()\n\t\t\t// using YearDay screws up on leap years\n\t\t\tif cd.Month() < d.Month() || (cd.Month() == d.Month() && cd.Day() < d.Day()) {\n\t\t\t\tage = age - 1\n\t\t\t}\n\n\t\t\tif !verifyInt(t, age, ageCriterion) {\n\t\t\t\tt.Errorf(\"Performer birthdate: %s, deathdate: %s\", performer.Birthdate.String(), performer.DeathDate.String())\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryLegacyCareerLength(t *testing.T) {\n\tconst value = \"2002 - 2012\"\n\n\ttests := []struct {\n\t\tname            string\n\t\tc               models.StringCriterionInput\n\t\tcareerStartCrit *models.DateCriterionInput\n\t\tcareerEndCrit   *models.DateCriterionInput\n\t\terr             bool\n\t}{\n\t\t{\n\t\t\tname: \"valid format\",\n\t\t\tc: models.StringCriterionInput{\n\t\t\t\tValue:    value,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t\tcareerStartCrit: &models.DateCriterionInput{\n\t\t\t\tValue:    \"2001-12-31\",\n\t\t\t\tModifier: models.CriterionModifierGreaterThan,\n\t\t\t},\n\t\t\tcareerEndCrit: &models.DateCriterionInput{\n\t\t\t\tValue:    \"2013-01-01\",\n\t\t\t\tModifier: models.CriterionModifierLessThan,\n\t\t\t},\n\t\t\terr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid format\",\n\t\t\tc: models.StringCriterionInput{\n\t\t\t\tValue:    \"invalid format\",\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"is null\",\n\t\t\tc: models.StringCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\tcareerStartCrit: &models.DateCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\tcareerEndCrit: &models.DateCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\terr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"not null\",\n\t\t\tc: models.StringCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\tcareerStartCrit: &models.DateCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\tcareerEndCrit: &models.DateCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\terr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid modifier\",\n\t\t\tc: models.StringCriterionInput{\n\t\t\t\tValue:    value,\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t}\n\n\tqb := db.Performer\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tperformers, _, err := qb.Query(ctx, &models.PerformerFilterType{\n\t\t\t\tCareerLength: &tt.c,\n\t\t\t}, nil)\n\n\t\t\tif err != nil && !tt.err {\n\t\t\t\tt.Errorf(\"Error querying performer: %s\", err.Error())\n\t\t\t} else if err == nil && tt.err {\n\t\t\t\tt.Errorf(\"Expected error but got none\")\n\t\t\t}\n\n\t\t\tif err != nil || tt.err {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(performers) == 0 {\n\t\t\t\tt.Errorf(\"Expected to find performers but found none\")\n\t\t\t}\n\n\t\t\tfor _, performer := range performers {\n\t\t\t\tverifyDatePtr(t, performer.CareerStart, *tt.careerStartCrit)\n\t\t\t\tverifyDatePtr(t, performer.CareerEnd, *tt.careerEndCrit)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPerformerQueryCareerStart(t *testing.T) {\n\tconst value = \"2002\"\n\tcriterion := models.DateCriterionInput{\n\t\tValue:    value,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Performer\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tCareerStart: &criterion,\n\t\t}\n\n\t\tperformers, _, err := qb.Query(ctx, &performerFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying performer: %s\", err.Error())\n\t\t}\n\n\t\tfor _, performer := range performers {\n\t\t\tverifyDatePtr(t, performer.CareerStart, criterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryCareerEnd(t *testing.T) {\n\tconst value = \"2012\"\n\tcriterion := models.DateCriterionInput{\n\t\tValue:    value,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Performer\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tCareerEnd: &criterion,\n\t\t}\n\n\t\tperformers, _, err := qb.Query(ctx, &performerFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying performer: %s\", err.Error())\n\t\t}\n\n\t\tfor _, performer := range performers {\n\t\t\tverifyDatePtr(t, performer.CareerEnd, criterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryURL(t *testing.T) {\n\tconst sceneIdx = 1\n\tperformerURL := getPerformerStringValue(sceneIdx, urlField)\n\n\turlCriterion := models.StringCriterionInput{\n\t\tValue:    performerURL,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tfilter := models.PerformerFilterType{\n\t\tURL: &urlCriterion,\n\t}\n\n\tverifyFn := func(g *models.Performer) {\n\t\tt.Helper()\n\n\t\turls := g.URLs.List()\n\t\tvar url string\n\t\tif len(urls) > 0 {\n\t\t\turl = urls[0]\n\t\t}\n\n\t\tverifyString(t, url, urlCriterion)\n\t}\n\n\tverifyPerformerQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyPerformerQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierMatchesRegex\n\turlCriterion.Value = \"performer_.*1_URL\"\n\tverifyPerformerQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyPerformerQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierIsNull\n\turlCriterion.Value = \"\"\n\tverifyPerformerQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyPerformerQuery(t, filter, verifyFn)\n}\n\nfunc verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verifyFn func(s *models.Performer)) {\n\twithTxn(func(ctx context.Context) error {\n\t\tt.Helper()\n\t\tperformers := queryPerformers(ctx, t, &filter, nil)\n\n\t\tfor _, performer := range performers {\n\t\t\tif err := performer.LoadURLs(ctx, db.Performer); err != nil {\n\t\t\t\tt.Errorf(\"Error loading url relationships: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\t// assume it should find at least one\n\t\tassert.Greater(t, len(performers), 0)\n\n\t\tfor _, p := range performers {\n\t\t\tverifyFn(p)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc queryPerformers(ctx context.Context, t *testing.T, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer {\n\tt.Helper()\n\tperformers, _, err := db.Performer.Query(ctx, performerFilter, findFilter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying performers: %s\", err.Error())\n\t}\n\n\treturn performers\n}\n\nfunc TestPerformerQueryTags(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttagCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithPerformer]),\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tTags: &tagCriterion,\n\t\t}\n\n\t\t// ensure ids are correct\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\t\tassert.Len(t, performers, 2)\n\t\tfor _, performer := range performers {\n\t\t\tassert.True(t, performer.ID == performerIDs[performerIdxWithTag] || performer.ID == performerIDs[performerIdxWithTwoTags])\n\t\t}\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t}\n\n\t\tperformers = queryPerformers(ctx, t, &performerFilter, nil)\n\n\t\tassert.Len(t, performers, 1)\n\t\tassert.Equal(t, sceneIDs[performerIdxWithTwoTags], performers[0].ID)\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t}\n\n\t\tq := getSceneStringValue(performerIdxWithTwoTags, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tperformers = queryPerformers(ctx, t, &performerFilter, &findFilter)\n\t\tassert.Len(t, performers, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryTagCount(t *testing.T) {\n\tconst tagCount = 1\n\ttagCountCriterion := models.IntCriterionInput{\n\t\tValue:    tagCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyPerformersTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyPerformersTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyPerformersTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyPerformersTagCount(t, tagCountCriterion)\n}\n\nfunc verifyPerformersTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Performer\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tTagCount: &tagCountCriterion,\n\t\t}\n\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\t\tassert.Greater(t, len(performers), 0)\n\n\t\tfor _, performer := range performers {\n\t\t\tids, err := sqb.GetTagIDs(ctx, performer.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, len(ids), tagCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQuerySceneCount(t *testing.T) {\n\tconst sceneCount = 1\n\tsceneCountCriterion := models.IntCriterionInput{\n\t\tValue:    sceneCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyPerformersSceneCount(t, sceneCountCriterion)\n\n\tsceneCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyPerformersSceneCount(t, sceneCountCriterion)\n\n\tsceneCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyPerformersSceneCount(t, sceneCountCriterion)\n\n\tsceneCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyPerformersSceneCount(t, sceneCountCriterion)\n}\n\nfunc verifyPerformersSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tSceneCount: &sceneCountCriterion,\n\t\t}\n\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\t\tassert.Greater(t, len(performers), 0)\n\n\t\tfor _, performer := range performers {\n\t\t\tids, err := db.Scene.FindByPerformerID(ctx, performer.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, len(ids), sceneCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryImageCount(t *testing.T) {\n\tconst imageCount = 1\n\timageCountCriterion := models.IntCriterionInput{\n\t\tValue:    imageCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyPerformersImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyPerformersImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyPerformersImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyPerformersImageCount(t, imageCountCriterion)\n}\n\nfunc verifyPerformersImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tImageCount: &imageCountCriterion,\n\t\t}\n\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\t\tassert.Greater(t, len(performers), 0)\n\n\t\tfor _, performer := range performers {\n\t\t\tpp := 0\n\n\t\t\tresult, err := db.Image.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: &models.FindFilterType{\n\t\t\t\t\t\tPerPage: &pp,\n\t\t\t\t\t},\n\t\t\t\t\tCount: true,\n\t\t\t\t},\n\t\t\t\tImageFilter: &models.ImageFilterType{\n\t\t\t\t\tPerformers: &models.MultiCriterionInput{\n\t\t\t\t\t\tValue:    []string{strconv.Itoa(performer.ID)},\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, result.Count, imageCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryGalleryCount(t *testing.T) {\n\tconst galleryCount = 1\n\tgalleryCountCriterion := models.IntCriterionInput{\n\t\tValue:    galleryCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyPerformersGalleryCount(t, galleryCountCriterion)\n\n\tgalleryCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyPerformersGalleryCount(t, galleryCountCriterion)\n\n\tgalleryCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyPerformersGalleryCount(t, galleryCountCriterion)\n\n\tgalleryCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyPerformersGalleryCount(t, galleryCountCriterion)\n}\n\nfunc verifyPerformersGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tGalleryCount: &galleryCountCriterion,\n\t\t}\n\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\t\tassert.Greater(t, len(performers), 0)\n\n\t\tfor _, performer := range performers {\n\t\t\tpp := 0\n\n\t\t\t_, count, err := db.Gallery.Query(ctx, &models.GalleryFilterType{\n\t\t\t\tPerformers: &models.MultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(performer.ID)},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t}, &models.FindFilterType{\n\t\t\t\tPerPage: &pp,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, count, galleryCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryStudio(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttestCases := []struct {\n\t\t\tstudioIndex    int\n\t\t\tperformerIndex int\n\t\t}{\n\t\t\t{studioIndex: studioIdxWithScenePerformer, performerIndex: performerIdxWithSceneStudio},\n\t\t\t{studioIndex: studioIdxWithImagePerformer, performerIndex: performerIdxWithImageStudio},\n\t\t\t{studioIndex: studioIdxWithGalleryPerformer, performerIndex: performerIdxWithGalleryStudio},\n\t\t}\n\n\t\tfor _, tc := range testCases {\n\t\t\tstudioCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[tc.studioIndex]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t}\n\n\t\t\tperformerFilter := models.PerformerFilterType{\n\t\t\t\tStudios: &studioCriterion,\n\t\t\t}\n\n\t\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\n\t\t\tassert.Len(t, performers, 1)\n\n\t\t\t// ensure id is correct\n\t\t\tassert.Equal(t, performerIDs[tc.performerIndex], performers[0].ID)\n\n\t\t\tstudioCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[tc.studioIndex]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t}\n\n\t\t\tq := getPerformerStringValue(tc.performerIndex, \"Name\")\n\t\t\tfindFilter := models.FindFilterType{\n\t\t\t\tQ: &q,\n\t\t\t}\n\n\t\t\tperformers = queryPerformers(ctx, t, &performerFilter, &findFilter)\n\t\t\tassert.Len(t, performers, 0)\n\t\t}\n\n\t\t// test NULL/not NULL\n\t\tq := getPerformerStringValue(performerIdx1WithImage, \"Name\")\n\t\tperformerFilter := &models.PerformerFilterType{\n\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t}\n\t\tfindFilter := &models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tperformers := queryPerformers(ctx, t, performerFilter, findFilter)\n\t\tassert.Len(t, performers, 1)\n\t\tassert.Equal(t, imageIDs[performerIdx1WithImage], performers[0].ID)\n\n\t\tq = getPerformerStringValue(performerIdxWithSceneStudio, \"Name\")\n\t\tperformers = queryPerformers(ctx, t, performerFilter, findFilter)\n\t\tassert.Len(t, performers, 0)\n\n\t\tperformerFilter.Studios.Modifier = models.CriterionModifierNotNull\n\t\tperformers = queryPerformers(ctx, t, performerFilter, findFilter)\n\t\tassert.Len(t, performers, 1)\n\t\tassert.Equal(t, imageIDs[performerIdxWithSceneStudio], performers[0].ID)\n\n\t\tq = getPerformerStringValue(performerIdx1WithImage, \"Name\")\n\t\tperformers = queryPerformers(ctx, t, performerFilter, findFilter)\n\t\tassert.Len(t, performers, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerStashIDs(t *testing.T) {\n\tif err := withRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Performer\n\n\t\t// create scene to test against\n\t\tconst name = \"TestPerformerStashIDs\"\n\t\tperformer := &models.Performer{\n\t\t\tName: name,\n\t\t}\n\t\tif err := qb.Create(ctx, &models.CreatePerformerInput{Performer: performer}); err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating performer: %s\", err.Error())\n\t\t}\n\n\t\tif err := performer.LoadStashIDs(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttestPerformerStashIDs(ctx, t, performer)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc testPerformerStashIDs(ctx context.Context, t *testing.T, s *models.Performer) {\n\t// ensure no stash IDs to begin with\n\tassert.Len(t, s.StashIDs.List(), 0)\n\n\t// add stash ids\n\tconst stashIDStr = \"stashID\"\n\tconst endpoint = \"endpoint\"\n\tstashID := models.StashID{\n\t\tStashID:   stashIDStr,\n\t\tEndpoint:  endpoint,\n\t\tUpdatedAt: epochTime,\n\t}\n\n\tqb := db.Performer\n\n\t// update stash ids and ensure was updated\n\tvar err error\n\ts, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{\n\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\tStashIDs: []models.StashID{stashID},\n\t\t\tMode:     models.RelationshipUpdateModeSet,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tif err := s.LoadStashIDs(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Equal(t, []models.StashID{stashID}, s.StashIDs.List())\n\n\t// remove stash ids and ensure was updated\n\ts, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{\n\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\tStashIDs: []models.StashID{stashID},\n\t\t\tMode:     models.RelationshipUpdateModeRemove,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tif err := s.LoadStashIDs(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Len(t, s.StashIDs.List(), 0)\n}\n\nfunc TestPerformerQueryRating100(t *testing.T) {\n\tconst rating = 60\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    rating,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyPerformersRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyPerformersRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyPerformersRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyPerformersRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierIsNull\n\tverifyPerformersRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyPerformersRating100(t, ratingCriterion)\n}\n\nfunc verifyPerformersRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tperformerFilter := models.PerformerFilterType{\n\t\t\tRating100: &ratingCriterion,\n\t\t}\n\n\t\tperformers := queryPerformers(ctx, t, &performerFilter, nil)\n\n\t\tfor _, performer := range performers {\n\t\t\tverifyIntPtr(t, performer.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc performerQueryIsMissing(ctx context.Context, t *testing.T, m string) []*models.Performer {\n\tperformerFilter := models.PerformerFilterType{\n\t\tIsMissing: &m,\n\t}\n\n\treturn queryPerformers(ctx, t, &performerFilter, nil)\n}\n\nfunc TestPerformerQueryIsMissingRating(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tperformers := performerQueryIsMissing(ctx, t, \"rating\")\n\n\t\tassert.True(t, len(performers) > 0)\n\n\t\tfor _, performer := range performers {\n\t\t\tassert.Nil(t, performer.Rating)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryIsMissingImage(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tperformers := performerQueryIsMissing(ctx, t, \"image\")\n\n\t\tassert.True(t, len(performers) > 0)\n\n\t\tfor _, performer := range performers {\n\t\t\timg, err := db.Performer.GetImage(ctx, performer.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"error getting performer image: %s\", err.Error())\n\t\t\t}\n\t\t\tassert.Nil(t, img)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQueryIsMissingAlias(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tperformers := performerQueryIsMissing(ctx, t, \"aliases\")\n\n\t\tassert.True(t, len(performers) > 0)\n\n\t\tfor _, performer := range performers {\n\t\t\ta, err := db.Performer.GetAliases(ctx, performer.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"error getting performer aliases: %s\", err.Error())\n\t\t\t}\n\t\t\tassert.Nil(t, a)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerQuerySortScenesCount(t *testing.T) {\n\tsort := \"scenes_count\"\n\tdirection := models.SortDirectionEnumDesc\n\tfindFilter := &models.FindFilterType{\n\t\tSort:      &sort,\n\t\tDirection: &direction,\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\t// just ensure it queries without error\n\t\tperformers, _, err := db.Performer.Query(ctx, nil, findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying performers: %s\", err.Error())\n\t\t}\n\n\t\tassert.True(t, len(performers) > 0)\n\n\t\t// first performer should be performerIdx1WithScene\n\t\tfirstPerformer := performers[0]\n\n\t\tassert.Equal(t, performerIDs[performerIdx1WithScene], firstPerformer.ID)\n\n\t\t// sort in ascending order\n\t\tdirection = models.SortDirectionEnumAsc\n\n\t\tperformers, _, err = db.Performer.Query(ctx, nil, findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying performers: %s\", err.Error())\n\t\t}\n\n\t\tassert.True(t, len(performers) > 0)\n\t\tlastPerformer := performers[len(performers)-1]\n\n\t\tassert.Equal(t, performerIDs[performerIdxWithTwoSceneStudio], lastPerformer.ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerCountByTagID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Performer\n\t\tcount, err := sqb.CountByTagID(ctx, tagIDs[tagIdxWithPerformer])\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error counting performers: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 1, count)\n\n\t\tcount, err = sqb.CountByTagID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error counting performers: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 0, count)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerCount(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Performer\n\t\tcount, err := sqb.Count(ctx)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error counting performers: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, totalPerformers, count)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestPerformerAll(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Performer\n\t\tall, err := sqb.All(ctx)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error counting performers: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, all, totalPerformers)\n\n\t\treturn nil\n\t})\n}\n\nfunc performersToIDs(i []*models.Performer) []int {\n\tret := make([]int, len(i))\n\tfor i, v := range i {\n\t\tret[i] = v.ID\n\t}\n\n\treturn ret\n}\n\nfunc TestPerformerStore_FindByStashID(t *testing.T) {\n\ttype args struct {\n\t\tstashID models.StashID\n\t}\n\ttests := []struct {\n\t\tname        string\n\t\tstashID     models.StashID\n\t\texpectedIDs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\tname:        \"existing\",\n\t\t\tstashID:     performerStashID(performerIdxWithScene),\n\t\t\texpectedIDs: []int{performerIDs[performerIdxWithScene]},\n\t\t\twantErr:     false,\n\t\t},\n\t\t{\n\t\t\tname: \"non-existing\",\n\t\t\tstashID: models.StashID{\n\t\t\t\tStashID:  getPerformerStringValue(performerIdxWithScene, \"stashid\"),\n\t\t\t\tEndpoint: \"non-existing\",\n\t\t\t},\n\t\t\texpectedIDs: []int{},\n\t\t\twantErr:     false,\n\t\t},\n\t}\n\n\tqb := db.Performer\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.FindByStashID(ctx, tt.stashID)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.FindByStashID() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.ElementsMatch(t, performersToIDs(got), tt.expectedIDs)\n\t\t})\n\t}\n}\n\nfunc TestPerformerStore_FindByStashIDStatus(t *testing.T) {\n\ttype args struct {\n\t\tstashID models.StashID\n\t}\n\ttests := []struct {\n\t\tname             string\n\t\thasStashID       bool\n\t\tstashboxEndpoint string\n\t\tinclude          []int\n\t\texclude          []int\n\t\twantErr          bool\n\t}{\n\t\t{\n\t\t\tname:             \"existing\",\n\t\t\thasStashID:       true,\n\t\t\tstashboxEndpoint: getPerformerStringValue(performerIdxWithScene, \"endpoint\"),\n\t\t\tinclude:          []int{performerIdxWithScene},\n\t\t\twantErr:          false,\n\t\t},\n\t\t{\n\t\t\tname:             \"non-existing\",\n\t\t\thasStashID:       true,\n\t\t\tstashboxEndpoint: getPerformerStringValue(performerIdxWithScene, \"non-existing\"),\n\t\t\texclude:          []int{performerIdxWithScene},\n\t\t\twantErr:          false,\n\t\t},\n\t\t{\n\t\t\tname:             \"!hasStashID\",\n\t\t\thasStashID:       false,\n\t\t\tstashboxEndpoint: getPerformerStringValue(performerIdxWithScene, \"endpoint\"),\n\t\t\tinclude:          []int{performerIdxWithTwoScenes},\n\t\t\texclude:          []int{performerIdx2WithScene},\n\t\t\twantErr:          false,\n\t\t},\n\t}\n\n\tqb := db.Performer\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.FindByStashIDStatus(ctx, tt.hasStashID, tt.stashboxEndpoint)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.FindByStashIDStatus() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(performerIDs, tt.include)\n\t\t\texclude := indexesToIDs(performerIDs, tt.exclude)\n\n\t\t\tids := performersToIDs(got)\n\n\t\t\tassert := assert.New(t)\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPerformerMerge(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tsrcIdxs []int\n\t\tdestIdx int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"merge into self\",\n\t\t\tsrcIdxs: []int{performerIdx1WithDupName},\n\t\t\tdestIdx: performerIdx1WithDupName,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"merge multiple\",\n\t\t\tsrcIdxs: []int{\n\t\t\t\tperformerIdx2WithScene,\n\t\t\t\tperformerIdxWithTwoScenes,\n\t\t\t\tperformerIdx1WithImage,\n\t\t\t\tperformerIdxWithTwoImages,\n\t\t\t\tperformerIdxWithGallery,\n\t\t\t\tperformerIdxWithTwoGalleries,\n\t\t\t\tperformerIdxWithTag,\n\t\t\t\tperformerIdxWithTwoTags,\n\t\t\t},\n\t\t\tdestIdx: tagIdxWithPerformer,\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tqb := db.Performer\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t// load src tag ids to compare after merge\n\t\t\tperformerTagIds := make(map[int][]int)\n\t\t\tfor _, srcIdx := range tt.srcIdxs {\n\t\t\t\tsrcPerformer, err := qb.Find(ctx, performerIDs[srcIdx])\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t\t\t}\n\t\t\t\tif err := srcPerformer.LoadTagIDs(ctx, qb); err != nil {\n\t\t\t\t\tt.Errorf(\"Error loading performer tag IDs: %s\", err.Error())\n\t\t\t\t}\n\t\t\t\tsrcTagIDs := srcPerformer.TagIDs.List()\n\t\t\t\tperformerTagIds[srcIdx] = srcTagIDs\n\t\t\t}\n\n\t\t\terr := qb.Merge(ctx, indexesToIDs(tagIDs, tt.srcIdxs), tagIDs[tt.destIdx])\n\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.Merge() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure source performers are destroyed\n\t\t\tfor _, srcIdx := range tt.srcIdxs {\n\t\t\t\tp, err := qb.Find(ctx, performerIDs[srcIdx])\n\n\t\t\t\t// not found returns nil performer and nil error\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tassert.Nil(p)\n\t\t\t}\n\n\t\t\t// ensure items point to new performer\n\t\t\tfor _, srcIdx := range tt.srcIdxs {\n\t\t\t\tsceneIdxs := scenePerformers.reverseLookup(srcIdx)\n\t\t\t\tfor _, sceneIdx := range sceneIdxs {\n\t\t\t\t\ts, err := db.Scene.Find(ctx, sceneIDs[sceneIdx])\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Errorf(\"Error finding scene: %s\", err.Error())\n\t\t\t\t\t}\n\t\t\t\t\tif err := s.LoadPerformerIDs(ctx, db.Scene); err != nil {\n\t\t\t\t\t\tt.Errorf(\"Error loading scene performer IDs: %s\", err.Error())\n\t\t\t\t\t}\n\t\t\t\t\tscenePerformerIDs := s.PerformerIDs.List()\n\n\t\t\t\t\tassert.Contains(scenePerformerIDs, performerIDs[tt.destIdx])\n\t\t\t\t\tassert.NotContains(scenePerformerIDs, performerIDs[srcIdx])\n\t\t\t\t}\n\n\t\t\t\timageIdxs := imagePerformers.reverseLookup(srcIdx)\n\t\t\t\tfor _, imageIdx := range imageIdxs {\n\t\t\t\t\ti, err := db.Image.Find(ctx, imageIDs[imageIdx])\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Errorf(\"Error finding image: %s\", err.Error())\n\t\t\t\t\t}\n\t\t\t\t\tif err := i.LoadPerformerIDs(ctx, db.Image); err != nil {\n\t\t\t\t\t\tt.Errorf(\"Error loading image performer IDs: %s\", err.Error())\n\t\t\t\t\t}\n\t\t\t\t\timagePerformerIDs := i.PerformerIDs.List()\n\n\t\t\t\t\tassert.Contains(imagePerformerIDs, performerIDs[tt.destIdx])\n\t\t\t\t\tassert.NotContains(imagePerformerIDs, performerIDs[srcIdx])\n\t\t\t\t}\n\n\t\t\t\tgalleryIdxs := galleryPerformers.reverseLookup(srcIdx)\n\t\t\t\tfor _, galleryIdx := range galleryIdxs {\n\t\t\t\t\tg, err := db.Gallery.Find(ctx, galleryIDs[galleryIdx])\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Errorf(\"Error finding gallery: %s\", err.Error())\n\t\t\t\t\t}\n\t\t\t\t\tif err := g.LoadPerformerIDs(ctx, db.Gallery); err != nil {\n\t\t\t\t\t\tt.Errorf(\"Error loading gallery performer IDs: %s\", err.Error())\n\t\t\t\t\t}\n\t\t\t\t\tgalleryPerformerIDs := g.PerformerIDs.List()\n\n\t\t\t\t\tassert.Contains(galleryPerformerIDs, performerIDs[tt.destIdx])\n\t\t\t\t\tassert.NotContains(galleryPerformerIDs, performerIDs[srcIdx])\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// ensure tags were merged\n\t\t\tdestPerformer, err := qb.Find(ctx, performerIDs[tt.destIdx])\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Error finding performer: %s\", err.Error())\n\t\t\t}\n\t\t\tif err := destPerformer.LoadTagIDs(ctx, qb); err != nil {\n\t\t\t\tt.Errorf(\"Error loading performer tag IDs: %s\", err.Error())\n\t\t\t}\n\t\t\tdestTagIDs := destPerformer.TagIDs.List()\n\n\t\t\tfor _, srcIdx := range tt.srcIdxs {\n\t\t\t\tfor _, tagID := range performerTagIds[srcIdx] {\n\t\t\t\t\tassert.Contains(destTagIDs, tagID)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TODO Update\n// TODO Destroy\n// TODO Find\n// TODO Query\n"
  },
  {
    "path": "pkg/sqlite/phash.go",
    "content": "package sqlite\n\nimport \"github.com/corona10/goimagehash\"\n\nfunc phashDistanceFn(phash1 int64, phash2 int64) (int64, error) {\n\thash1 := goimagehash.NewImageHash(uint64(phash1), goimagehash.PHash)\n\thash2 := goimagehash.NewImageHash(uint64(phash2), goimagehash.PHash)\n\tdistance, _ := hash1.Distance(hash2)\n\treturn int64(distance), nil\n}\n"
  },
  {
    "path": "pkg/sqlite/query.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype queryBuilder struct {\n\trepository *repository\n\n\tcolumns []string\n\tfrom    string\n\n\tjoins         joins\n\twhereClauses  []string\n\thavingClauses []string\n\twithClauses   []string\n\trecursiveWith bool\n\n\twithArgs   []interface{}\n\tjoinArgs   []interface{}\n\twhereArgs  []interface{}\n\thavingArgs []interface{}\n\n\tsortAndPagination string\n}\n\nfunc (qb queryBuilder) allArgs() []interface{} {\n\tvar args []interface{}\n\targs = append(args, qb.withArgs...)\n\targs = append(args, qb.joinArgs...)\n\targs = append(args, qb.whereArgs...)\n\targs = append(args, qb.havingArgs...)\n\treturn args\n}\n\nfunc (qb queryBuilder) body(includeSortPagination bool) string {\n\treturn fmt.Sprintf(\"SELECT %s FROM %s%s\", strings.Join(qb.columns, \", \"), qb.from, qb.joins.toSQL(includeSortPagination))\n}\n\nfunc (qb *queryBuilder) addColumn(column string) {\n\tqb.columns = append(qb.columns, column)\n}\n\nfunc (qb queryBuilder) toSQL(includeSortPagination bool) string {\n\tbody := qb.body(includeSortPagination)\n\n\twithClause := \"\"\n\tif len(qb.withClauses) > 0 {\n\t\tvar recursive string\n\t\tif qb.recursiveWith {\n\t\t\trecursive = \" RECURSIVE \"\n\t\t}\n\t\twithClause = \"WITH \" + recursive + strings.Join(qb.withClauses, \", \") + \" \"\n\t}\n\n\tbody = withClause + qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses)\n\tif includeSortPagination {\n\t\tbody += qb.sortAndPagination\n\t}\n\n\treturn body\n}\n\nfunc (qb queryBuilder) findIDs(ctx context.Context) ([]int, error) {\n\tconst includeSortPagination = true\n\tsql := qb.toSQL(includeSortPagination)\n\treturn qb.repository.runIdsQuery(ctx, sql, qb.allArgs())\n}\n\nfunc (qb queryBuilder) executeFind(ctx context.Context) ([]int, int, error) {\n\tconst includeSortPagination = true\n\tbody := qb.body(includeSortPagination)\n\treturn qb.repository.executeFindQuery(ctx, body, qb.allArgs(), qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith)\n}\n\nfunc (qb queryBuilder) executeCount(ctx context.Context) (int, error) {\n\tconst includeSortPagination = false\n\tbody := qb.body(includeSortPagination)\n\n\twithClause := \"\"\n\tif len(qb.withClauses) > 0 {\n\t\tvar recursive string\n\t\tif qb.recursiveWith {\n\t\t\trecursive = \" RECURSIVE \"\n\t\t}\n\t\twithClause = \"WITH \" + recursive + strings.Join(qb.withClauses, \", \") + \" \"\n\t}\n\n\tbody = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses)\n\tcountQuery := withClause + qb.repository.buildCountQuery(body)\n\treturn qb.repository.runCountQuery(ctx, countQuery, qb.allArgs())\n}\n\nfunc (qb *queryBuilder) addWhere(clauses ...string) {\n\tfor _, clause := range clauses {\n\t\tif len(clause) > 0 {\n\t\t\tqb.whereClauses = append(qb.whereClauses, clause)\n\t\t}\n\t}\n}\n\nfunc (qb *queryBuilder) addHaving(clauses ...string) {\n\tfor _, clause := range clauses {\n\t\tif len(clause) > 0 {\n\t\t\tqb.havingClauses = append(qb.havingClauses, clause)\n\t\t}\n\t}\n}\n\nfunc (qb *queryBuilder) addWith(recursive bool, clauses ...string) {\n\tfor _, clause := range clauses {\n\t\tif len(clause) > 0 {\n\t\t\tqb.withClauses = append(qb.withClauses, clause)\n\t\t}\n\t}\n\n\tqb.recursiveWith = qb.recursiveWith || recursive\n}\n\nfunc (qb *queryBuilder) addArg(args ...interface{}) {\n\tqb.whereArgs = append(qb.whereArgs, args...)\n}\n\nfunc (qb *queryBuilder) addHavingArg(args ...interface{}) {\n\tqb.havingArgs = append(qb.havingArgs, args...)\n}\n\nfunc (qb *queryBuilder) hasJoin(alias string) bool {\n\tfor _, j := range qb.joins {\n\t\tif j.alias() == alias {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (qb *queryBuilder) join(table, as, onClause string) {\n\tnewJoin := join{\n\t\ttable:    table,\n\t\tas:       as,\n\t\tonClause: onClause,\n\t\tjoinType: \"LEFT\",\n\t}\n\n\tqb.joins.add(newJoin)\n}\n\nfunc (qb *queryBuilder) joinSort(table, as, onClause string) {\n\tnewJoin := join{\n\t\tsort:     true,\n\t\ttable:    table,\n\t\tas:       as,\n\t\tonClause: onClause,\n\t\tjoinType: \"LEFT\",\n\t}\n\n\tqb.joins.add(newJoin)\n}\n\nfunc (qb *queryBuilder) addJoins(joins ...join) {\n\tfor _, j := range joins {\n\t\tif qb.joins.addUnique(j) {\n\t\t\tqb.joinArgs = append(qb.joinArgs, j.args...)\n\t\t}\n\t}\n}\n\nfunc (qb *queryBuilder) addFilter(f *filterBuilder) error {\n\terr := f.getError()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tclause, args := f.generateWithClauses()\n\tif len(clause) > 0 {\n\t\tqb.addWith(f.recursiveWith, clause)\n\t}\n\tif len(args) > 0 {\n\t\tqb.withArgs = append(qb.withArgs, args...)\n\t}\n\n\tqb.addJoins(f.getAllJoins()...)\n\n\tclause, args = f.generateWhereClauses()\n\tif len(clause) > 0 {\n\t\tqb.addWhere(clause)\n\t}\n\tif len(args) > 0 {\n\t\tqb.addArg(args...)\n\t}\n\n\tclause, args = f.generateHavingClauses()\n\tif len(clause) > 0 {\n\t\tqb.addHaving(clause)\n\t}\n\tif len(args) > 0 {\n\t\tqb.addHavingArg(args...)\n\t}\n\n\treturn nil\n}\n\nfunc (qb *queryBuilder) parseQueryString(columns []string, q string) {\n\tspecs := models.ParseSearchString(q)\n\n\tfor _, t := range specs.MustHave {\n\t\tvar clauses []string\n\n\t\tfor _, column := range columns {\n\t\t\tclauses = append(clauses, column+\" LIKE ?\")\n\t\t\tqb.addArg(like(t))\n\t\t}\n\n\t\tqb.addWhere(\"(\" + strings.Join(clauses, \" OR \") + \")\")\n\t}\n\n\tfor _, t := range specs.MustNot {\n\t\tfor _, column := range columns {\n\t\t\tqb.addWhere(coalesce(column) + \" NOT LIKE ?\")\n\t\t\tqb.addArg(like(t))\n\t\t}\n\t}\n\n\tfor _, set := range specs.AnySets {\n\t\tvar clauses []string\n\n\t\tfor _, column := range columns {\n\t\t\tfor _, v := range set {\n\t\t\t\tclauses = append(clauses, column+\" LIKE ?\")\n\t\t\t\tqb.addArg(like(v))\n\t\t\t}\n\t\t}\n\n\t\tqb.addWhere(\"(\" + strings.Join(clauses, \" OR \") + \")\")\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/record.go",
    "content": "package sqlite\n\nimport (\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n)\n\ntype updateRecord struct {\n\texp.Record\n}\n\nfunc (r *updateRecord) set(destField string, v interface{}) {\n\tr.Record[destField] = v\n}\n\nfunc (r *updateRecord) setString(destField string, v models.OptionalString) {\n\tif v.Set {\n\t\tif v.Null {\n\t\t\tpanic(\"null value not allowed in optional string\")\n\t\t}\n\t\tr.set(destField, v.Value)\n\t}\n}\n\nfunc (r *updateRecord) setNullString(destField string, v models.OptionalString) {\n\tif v.Set {\n\t\tr.set(destField, zero.StringFromPtr(v.Ptr()))\n\t}\n}\n\nfunc (r *updateRecord) setBool(destField string, v models.OptionalBool) {\n\tif v.Set {\n\t\tif v.Null {\n\t\t\tpanic(\"null value not allowed in optional bool\")\n\t\t}\n\t\tr.set(destField, v.Value)\n\t}\n}\n\nfunc (r *updateRecord) setInt(destField string, v models.OptionalInt) {\n\tif v.Set {\n\t\tif v.Null {\n\t\t\tpanic(\"null value not allowed in optional int\")\n\t\t}\n\t\tr.set(destField, v.Value)\n\t}\n}\n\nfunc (r *updateRecord) setNullInt(destField string, v models.OptionalInt) {\n\tif v.Set {\n\t\tr.set(destField, intFromPtr(v.Ptr()))\n\t}\n}\n\n// func (r *updateRecord) setInt64(destField string, v models.OptionalInt64) {\n// \tif v.Set {\n// \t\tif v.Null {\n// \t\t\tpanic(\"null value not allowed in optional int64\")\n// \t\t}\n// \t\tr.set(destField, v.Value)\n// \t}\n// }\n\n// func (r *updateRecord) setNullInt64(destField string, v models.OptionalInt64) {\n// \tif v.Set {\n// \t\tr.set(destField, null.IntFromPtr(v.Ptr()))\n// \t}\n// }\n\nfunc (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) {\n\tif v.Set {\n\t\tif v.Null {\n\t\t\tpanic(\"null value not allowed in optional float64\")\n\t\t}\n\t\tr.set(destField, v.Value)\n\t}\n}\n\nfunc (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) {\n\tif v.Set {\n\t\tr.set(destField, null.FloatFromPtr(v.Ptr()))\n\t}\n}\n\nfunc (r *updateRecord) setTimestamp(destField string, v models.OptionalTime) {\n\tif v.Set {\n\t\tif v.Null {\n\t\t\tpanic(\"null value not allowed in optional time\")\n\t\t}\n\t\tr.set(destField, Timestamp{Timestamp: v.Value})\n\t}\n}\n\n//nolint:golint,unused\nfunc (r *updateRecord) setNullTimestamp(destField string, v models.OptionalTime) {\n\tif v.Set {\n\t\tr.set(destField, NullTimestampFromTimePtr(v.Ptr()))\n\t}\n}\n\nfunc (r *updateRecord) setNullDate(destField string, precisionField string, v models.OptionalDate) {\n\tif v.Set {\n\t\tr.set(destField, NullDateFromDatePtr(v.Ptr()))\n\t\tr.set(precisionField, datePrecisionFromDatePtr(v.Ptr()))\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/regex.go",
    "content": "package sqlite\n\nimport (\n\t\"regexp\"\n\n\tlru \"github.com/hashicorp/golang-lru/v2\"\n)\n\n// size of the regex LRU cache in elements.\n// A small number number was chosen because it's most likely use is for a\n// single query - this function gets called for every row in the (filtered)\n// results. It's likely to only need no more than 1 or 2 in any given query.\n// After that point, it's just sitting in the cache and is unlikely to be used\n// again.\nconst regexCacheSize = 10\n\nvar regexCache *lru.Cache[string, *regexp.Regexp]\n\nfunc init() {\n\tregexCache, _ = lru.New[string, *regexp.Regexp](regexCacheSize)\n}\n\n// regexFn is registered as an SQLite function as \"regexp\"\n// It uses an LRU cache to cache recent regex patterns to reduce CPU load over\n// identical patterns.\nfunc regexFn(re, s string) (bool, error) {\n\tcompiled, ok := regexCache.Get(re)\n\tif !ok {\n\t\tvar err error\n\t\tcompiled, err = regexp.Compile(re)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tregexCache.Add(re, compiled)\n\t}\n\n\treturn compiled.MatchString(s), nil\n}\n"
  },
  {
    "path": "pkg/sqlite/relationships.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype idRelationshipStore struct {\n\tjoinTable *joinTable\n}\n\nfunc (s *idRelationshipStore) createRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error {\n\tif fkIDs.Loaded() {\n\t\tif err := s.joinTable.insertJoins(ctx, id, fkIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (s *idRelationshipStore) modifyRelationships(ctx context.Context, id int, fkIDs *models.UpdateIDs) error {\n\tif fkIDs != nil {\n\t\tif err := s.joinTable.modifyJoins(ctx, id, fkIDs.IDs, fkIDs.Mode); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (s *idRelationshipStore) replaceRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error {\n\tif fkIDs.Loaded() {\n\t\tif err := s.joinTable.replaceJoins(ctx, id, fkIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sqlite/repository.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/jmoiron/sqlx\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst idColumn = \"id\"\n\ntype repository struct {\n\ttableName string\n\tidColumn  string\n}\n\nfunc (r *repository) getAll(ctx context.Context, id int, f func(rows *sqlx.Rows) error) error {\n\tstmt := fmt.Sprintf(\"SELECT * FROM %s WHERE %s = ?\", r.tableName, r.idColumn)\n\treturn r.queryFunc(ctx, stmt, []interface{}{id}, false, f)\n}\n\nfunc (r *repository) destroyExisting(ctx context.Context, ids []int) error {\n\tfor _, id := range ids {\n\t\texists, err := r.exists(ctx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !exists {\n\t\t\treturn fmt.Errorf(\"%s %d does not exist in %s\", r.idColumn, id, r.tableName)\n\t\t}\n\t}\n\n\treturn r.destroy(ctx, ids)\n}\n\nfunc (r *repository) destroy(ctx context.Context, ids []int) error {\n\tfor _, id := range ids {\n\t\tstmt := fmt.Sprintf(\"DELETE FROM %s WHERE %s = ?\", r.tableName, r.idColumn)\n\t\tif _, err := dbWrapper.Exec(ctx, stmt, id); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *repository) exists(ctx context.Context, id int) (bool, error) {\n\tstmt := fmt.Sprintf(\"SELECT %s FROM %s WHERE %s = ? LIMIT 1\", r.idColumn, r.tableName, r.idColumn)\n\tstmt = r.buildCountQuery(stmt)\n\n\tc, err := r.runCountQuery(ctx, stmt, []interface{}{id})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\treturn c == 1, nil\n}\n\nfunc (r *repository) buildCountQuery(query string) string {\n\treturn \"SELECT COUNT(*) as count FROM (\" + query + \") as temp\"\n}\n\nfunc (r *repository) runCountQuery(ctx context.Context, query string, args []interface{}) (int, error) {\n\tresult := struct {\n\t\tInt int `db:\"count\"`\n\t}{0}\n\n\t// Perform query and fetch result\n\tif err := dbWrapper.Get(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn 0, err\n\t}\n\n\treturn result.Int, nil\n}\n\nfunc (r *repository) runIdsQuery(ctx context.Context, query string, args []interface{}) ([]int, error) {\n\tvar result []struct {\n\t\tInt int `db:\"id\"`\n\t}\n\n\tif err := dbWrapper.Select(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn []int{}, fmt.Errorf(\"running query: %s [%v]: %w\", query, args, err)\n\t}\n\n\tvsm := make([]int, len(result))\n\tfor i, v := range result {\n\t\tvsm[i] = v.Int\n\t}\n\treturn vsm, nil\n}\n\nfunc (r *repository) queryFunc(ctx context.Context, query string, args []interface{}, single bool, f func(rows *sqlx.Rows) error) error {\n\trows, err := dbWrapper.QueryxContext(ctx, query, args...)\n\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tif err := f(rows); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif single {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif err := rows.Err(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// queryStruct executes a query and scans the result into the provided struct.\n// Unlike the other query methods, this will return an error if no rows are found.\nfunc (r *repository) queryStruct(ctx context.Context, query string, args []interface{}, out interface{}) error {\n\t// changed from queryFunc, since it was not logging the performance correctly,\n\t// since the query doesn't actually execute until Scan is called\n\tif err := dbWrapper.Get(ctx, out, query, args...); err != nil {\n\t\treturn fmt.Errorf(\"executing query: %s [%v]: %w\", query, args, err)\n\t}\n\n\treturn nil\n}\n\nfunc (r *repository) querySimple(ctx context.Context, query string, args []interface{}, out interface{}) error {\n\trows, err := dbWrapper.Queryx(ctx, query, args...)\n\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\tif rows.Next() {\n\t\tif err := rows.Scan(out); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := rows.Err(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (r *repository) buildQueryBody(body string, whereClauses []string, havingClauses []string) string {\n\tif len(whereClauses) > 0 {\n\t\tbody = body + \" WHERE \" + strings.Join(whereClauses, \" AND \") // TODO handle AND or OR\n\t}\n\tif len(havingClauses) > 0 {\n\t\tbody = body + \" GROUP BY \" + r.tableName + \".id \"\n\t\tbody = body + \" HAVING \" + strings.Join(havingClauses, \" AND \") // TODO handle AND or OR\n\t}\n\n\treturn body\n}\n\nfunc (r *repository) executeFindQuery(ctx context.Context, body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string, withClauses []string, recursiveWith bool) ([]int, int, error) {\n\tbody = r.buildQueryBody(body, whereClauses, havingClauses)\n\n\twithClause := \"\"\n\tif len(withClauses) > 0 {\n\t\tvar recursive string\n\t\tif recursiveWith {\n\t\t\trecursive = \" RECURSIVE \"\n\t\t}\n\t\twithClause = \"WITH \" + recursive + strings.Join(withClauses, \", \") + \" \"\n\t}\n\n\tcountQuery := withClause + r.buildCountQuery(body)\n\tidsQuery := withClause + body + sortAndPagination\n\n\t// Perform query and fetch result\n\tvar countResult int\n\tvar countErr error\n\tvar idsResult []int\n\tvar idsErr error\n\n\tcountResult, countErr = r.runCountQuery(ctx, countQuery, args)\n\tidsResult, idsErr = r.runIdsQuery(ctx, idsQuery, args)\n\n\tif countErr != nil {\n\t\treturn nil, 0, fmt.Errorf(\"error executing count query with SQL: %s, args: %v, error: %s\", countQuery, args, countErr.Error())\n\t}\n\tif idsErr != nil {\n\t\treturn nil, 0, fmt.Errorf(\"error executing find query with SQL: %s, args: %v, error: %s\", idsQuery, args, idsErr.Error())\n\t}\n\n\treturn idsResult, countResult, nil\n}\n\nfunc (r *repository) newQuery() queryBuilder {\n\treturn queryBuilder{\n\t\trepository: r,\n\t}\n}\n\nfunc (r *repository) join(j joiner, as string, parentIDCol string) {\n\tt := r.tableName\n\tif as != \"\" {\n\t\tt = as\n\t}\n\tj.addLeftJoin(r.tableName, as, fmt.Sprintf(\"%s.%s = %s\", t, r.idColumn, parentIDCol))\n}\n\nfunc (r *repository) innerJoin(j joiner, as string, parentIDCol string) {\n\tt := r.tableName\n\tif as != \"\" {\n\t\tt = as\n\t}\n\tj.addInnerJoin(r.tableName, as, fmt.Sprintf(\"%s.%s = %s\", t, r.idColumn, parentIDCol))\n}\n\ntype joiner interface {\n\taddLeftJoin(table, as, onClause string, args ...interface{})\n\taddInnerJoin(table, as, onClause string, args ...interface{})\n}\n\ntype joinRepository struct {\n\trepository\n\tfkColumn string\n\n\t// fields for ordering\n\tforeignTable string\n\torderBy      string\n}\n\nfunc (r *joinRepository) getIDs(ctx context.Context, id int) ([]int, error) {\n\tvar joinStr string\n\tif r.foreignTable != \"\" {\n\t\tjoinStr = fmt.Sprintf(\" INNER JOIN %s ON %[1]s.id = %s.%s\", r.foreignTable, r.tableName, r.fkColumn)\n\t}\n\n\tquery := fmt.Sprintf(`SELECT %[2]s.%[1]s as id from %s%s WHERE %s = ?`, r.fkColumn, r.tableName, joinStr, r.idColumn)\n\n\tif r.orderBy != \"\" {\n\t\tquery += \" ORDER BY \" + r.orderBy\n\t}\n\n\treturn r.runIdsQuery(ctx, query, []interface{}{id})\n}\n\nfunc (r *joinRepository) insert(ctx context.Context, id int, foreignIDs ...int) error {\n\tstmt, err := dbWrapper.Prepare(ctx, fmt.Sprintf(\"INSERT INTO %s (%s, %s) VALUES (?, ?)\", r.tableName, r.idColumn, r.fkColumn))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer stmt.Close()\n\n\tfor _, fk := range foreignIDs {\n\t\tif _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// insertOrIgnore inserts a join into the table, silently failing in the event that a conflict occurs (ie when the join already exists)\nfunc (r *joinRepository) insertOrIgnore(ctx context.Context, id int, foreignIDs ...int) error {\n\tstmt, err := dbWrapper.Prepare(ctx, fmt.Sprintf(\"INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING\", r.tableName, r.idColumn, r.fkColumn))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer stmt.Close()\n\n\tfor _, fk := range foreignIDs {\n\t\tif _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (r *joinRepository) destroyJoins(ctx context.Context, id int, foreignIDs ...int) error {\n\tstmt := fmt.Sprintf(\"DELETE FROM %s WHERE %s = ? AND %s IN %s\", r.tableName, r.idColumn, r.fkColumn, getInBinding(len(foreignIDs)))\n\n\targs := make([]interface{}, len(foreignIDs)+1)\n\targs[0] = id\n\tfor i, v := range foreignIDs {\n\t\targs[i+1] = v\n\t}\n\n\tif _, err := dbWrapper.Exec(ctx, stmt, args...); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (r *joinRepository) replace(ctx context.Context, id int, foreignIDs []int) error {\n\tif err := r.destroy(ctx, []int{id}); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, fk := range foreignIDs {\n\t\tif err := r.insert(ctx, id, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype captionRepository struct {\n\trepository\n}\n\nfunc (r *captionRepository) get(ctx context.Context, id models.FileID) ([]*models.VideoCaption, error) {\n\tquery := fmt.Sprintf(\"SELECT %s, %s, %s from %s WHERE %s = ?\", captionCodeColumn, captionFilenameColumn, captionTypeColumn, r.tableName, r.idColumn)\n\tvar ret []*models.VideoCaption\n\terr := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error {\n\t\tvar captionCode string\n\t\tvar captionFilename string\n\t\tvar captionType string\n\n\t\tif err := rows.Scan(&captionCode, &captionFilename, &captionType); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tcaption := &models.VideoCaption{\n\t\t\tLanguageCode: captionCode,\n\t\t\tFilename:     captionFilename,\n\t\t\tCaptionType:  captionType,\n\t\t}\n\t\tret = append(ret, caption)\n\t\treturn nil\n\t})\n\treturn ret, err\n}\n\nfunc (r *captionRepository) insert(ctx context.Context, id models.FileID, caption *models.VideoCaption) (sql.Result, error) {\n\tstmt := fmt.Sprintf(\"INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, ?)\", r.tableName, r.idColumn, captionCodeColumn, captionFilenameColumn, captionTypeColumn)\n\treturn dbWrapper.Exec(ctx, stmt, id, caption.LanguageCode, caption.Filename, caption.CaptionType)\n}\n\nfunc (r *captionRepository) replace(ctx context.Context, id models.FileID, captions []*models.VideoCaption) error {\n\tif err := r.destroy(ctx, []int{int(id)}); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, caption := range captions {\n\t\tif _, err := r.insert(ctx, id, caption); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype stringRepository struct {\n\trepository\n\tstringColumn string\n}\n\nfunc (r *stringRepository) get(ctx context.Context, id int) ([]string, error) {\n\tquery := fmt.Sprintf(\"SELECT %s from %s WHERE %s = ?\", r.stringColumn, r.tableName, r.idColumn)\n\tvar ret []string\n\terr := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error {\n\t\tvar out string\n\t\tif err := rows.Scan(&out); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, out)\n\t\treturn nil\n\t})\n\treturn ret, err\n}\n\nfunc (r *stringRepository) insert(ctx context.Context, id int, s string) (sql.Result, error) {\n\tstmt := fmt.Sprintf(\"INSERT INTO %s (%s, %s) VALUES (?, ?)\", r.tableName, r.idColumn, r.stringColumn)\n\treturn dbWrapper.Exec(ctx, stmt, id, s)\n}\n\nfunc (r *stringRepository) replace(ctx context.Context, id int, newStrings []string) error {\n\tif err := r.destroy(ctx, []int{id}); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, s := range newStrings {\n\t\tif _, err := r.insert(ctx, id, s); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype stashIDRepository struct {\n\trepository\n}\n\ntype stashIDs []models.StashID\n\nfunc (s *stashIDs) Append(o interface{}) {\n\t*s = append(*s, o.(models.StashID))\n}\n\nfunc (s *stashIDs) New() interface{} {\n\treturn &models.StashID{}\n}\n\nfunc (r *stashIDRepository) get(ctx context.Context, id int) ([]models.StashID, error) {\n\tquery := fmt.Sprintf(\"SELECT stash_id, endpoint, updated_at from %s WHERE %s = ?\", r.tableName, r.idColumn)\n\tvar ret stashIDs\n\terr := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error {\n\t\tvar v stashIDRow\n\t\tif err := rows.StructScan(&v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tret.Append(v.resolve())\n\t\treturn nil\n\t})\n\treturn ret, err\n}\n\ntype filesRepository struct {\n\trepository\n}\n\ntype relatedFileRow struct {\n\tID      int           `db:\"id\"`\n\tFileID  models.FileID `db:\"file_id\"`\n\tPrimary bool          `db:\"primary\"`\n}\n\nfunc idToIndexMap(ids []int) map[int]int {\n\tret := make(map[int]int)\n\tfor i, id := range ids {\n\t\tret[id] = i\n\t}\n\treturn ret\n}\n\nfunc (r *filesRepository) getMany(ctx context.Context, ids []int, primaryOnly bool) ([][]models.FileID, error) {\n\tvar primaryClause string\n\tif primaryOnly {\n\t\tprimaryClause = \" AND `primary` = 1\"\n\t}\n\n\tquery := fmt.Sprintf(\"SELECT %s as id, file_id, `primary` from %s WHERE %[1]s IN %[3]s%s\", r.idColumn, r.tableName, getInBinding(len(ids)), primaryClause)\n\n\tidi := make([]interface{}, len(ids))\n\tfor i, id := range ids {\n\t\tidi[i] = id\n\t}\n\n\tvar fileRows []relatedFileRow\n\tif err := r.queryFunc(ctx, query, idi, false, func(rows *sqlx.Rows) error {\n\t\tvar f relatedFileRow\n\n\t\tif err := rows.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfileRows = append(fileRows, f)\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([][]models.FileID, len(ids))\n\tidToIndex := idToIndexMap(ids)\n\n\tfor _, row := range fileRows {\n\t\tid := row.ID\n\t\tfileID := row.FileID\n\n\t\tif row.Primary {\n\t\t\t// prepend to list\n\t\t\tret[idToIndex[id]] = append([]models.FileID{fileID}, ret[idToIndex[id]]...)\n\t\t} else {\n\t\t\tret[idToIndex[id]] = append(ret[idToIndex[id]], row.FileID)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (r *filesRepository) get(ctx context.Context, id int) ([]models.FileID, error) {\n\tquery := fmt.Sprintf(\"SELECT file_id, `primary` from %s WHERE %s = ?\", r.tableName, r.idColumn)\n\n\ttype relatedFile struct {\n\t\tFileID  models.FileID `db:\"file_id\"`\n\t\tPrimary bool          `db:\"primary\"`\n\t}\n\n\tvar ret []models.FileID\n\tif err := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error {\n\t\tvar f relatedFile\n\n\t\tif err := rows.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif f.Primary {\n\t\t\t// prepend to list\n\t\t\tret = append([]models.FileID{f.FileID}, ret...)\n\t\t} else {\n\t\t\tret = append(ret, f.FileID)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n"
  },
  {
    "path": "pkg/sqlite/saved_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst (\n\tsavedFilterTable       = \"saved_filters\"\n\tsavedFilterDefaultName = \"\"\n)\n\ntype savedFilterRow struct {\n\tID           int               `db:\"id\" goqu:\"skipinsert\"`\n\tMode         models.FilterMode `db:\"mode\"`\n\tName         string            `db:\"name\"`\n\tFindFilter   string            `db:\"find_filter\"`\n\tObjectFilter string            `db:\"object_filter\"`\n\tUIOptions    string            `db:\"ui_options\"`\n}\n\nfunc encodeJSONOrEmpty(v interface{}) string {\n\tif v == nil {\n\t\treturn \"\"\n\t}\n\n\tencoded, err := json.Marshal(v)\n\tif err != nil {\n\t\tlogger.Errorf(\"error encoding json %v: %v\", v, err)\n\t}\n\n\treturn string(encoded)\n}\n\nfunc decodeJSON(s string, v interface{}) {\n\tif s == \"\" {\n\t\treturn\n\t}\n\n\tif err := json.Unmarshal([]byte(s), v); err != nil {\n\t\tlogger.Errorf(\"error decoding json %q: %v\", s, err)\n\t}\n}\n\nfunc (r *savedFilterRow) fromSavedFilter(o models.SavedFilter) {\n\tr.ID = o.ID\n\tr.Mode = o.Mode\n\tr.Name = o.Name\n\n\t// encode the filters as json\n\tr.FindFilter = encodeJSONOrEmpty(o.FindFilter)\n\tr.ObjectFilter = encodeJSONOrEmpty(o.ObjectFilter)\n\tr.UIOptions = encodeJSONOrEmpty(o.UIOptions)\n}\n\nfunc (r *savedFilterRow) resolve() *models.SavedFilter {\n\tret := &models.SavedFilter{\n\t\tID:   r.ID,\n\t\tMode: r.Mode,\n\t\tName: r.Name,\n\t}\n\n\t// decode the filters from json\n\tif r.FindFilter != \"\" {\n\t\tret.FindFilter = &models.FindFilterType{}\n\t\tdecodeJSON(r.FindFilter, &ret.FindFilter)\n\t}\n\tif r.ObjectFilter != \"\" {\n\t\tret.ObjectFilter = make(map[string]interface{})\n\t\tdecodeJSON(r.ObjectFilter, &ret.ObjectFilter)\n\t}\n\tif r.UIOptions != \"\" {\n\t\tret.UIOptions = make(map[string]interface{})\n\t\tdecodeJSON(r.UIOptions, &ret.UIOptions)\n\t}\n\n\treturn ret\n}\n\ntype SavedFilterStore struct {\n\trepository\n\ttableMgr *table\n}\n\nfunc NewSavedFilterStore() *SavedFilterStore {\n\treturn &SavedFilterStore{\n\t\trepository: repository{\n\t\t\ttableName: savedFilterTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\ttableMgr: savedFilterTableMgr,\n\t}\n}\n\nfunc (qb *SavedFilterStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *SavedFilterStore) selectDataset() *goqu.SelectDataset {\n\treturn dialect.From(qb.table()).Select(qb.table().All())\n}\n\nfunc (qb *SavedFilterStore) Create(ctx context.Context, newObject *models.SavedFilter) error {\n\tvar r savedFilterRow\n\tr.fromSavedFilter(*newObject)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.Find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject = *updated\n\n\treturn nil\n}\n\nfunc (qb *SavedFilterStore) Update(ctx context.Context, updatedObject *models.SavedFilter) error {\n\tvar r savedFilterRow\n\tr.fromSavedFilter(*updatedObject)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *SavedFilterStore) Destroy(ctx context.Context, id int) error {\n\treturn qb.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *SavedFilterStore) Find(ctx context.Context, id int) (*models.SavedFilter, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *SavedFilterStore) FindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) {\n\tret := make([]*models.SavedFilter, len(ids))\n\n\ttable := qb.table()\n\tq := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(ids))\n\tunsorted, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, s := range unsorted {\n\t\ti := slices.Index(ids, s.ID)\n\t\tret[i] = s\n\t}\n\n\tif !ignoreNotFound {\n\t\tfor i := range ret {\n\t\t\tif ret[i] == nil {\n\t\t\t\treturn nil, fmt.Errorf(\"filter with id %d not found\", ids[i])\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *SavedFilterStore) find(ctx context.Context, id int) (*models.SavedFilter, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SavedFilterStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.SavedFilter, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *SavedFilterStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.SavedFilter, error) {\n\tconst single = false\n\tvar ret []*models.SavedFilter\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f savedFilterRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SavedFilterStore) FindByMode(ctx context.Context, mode models.FilterMode) ([]*models.SavedFilter, error) {\n\t// SELECT * FROM %s WHERE mode = ? AND name != ? ORDER BY name ASC\n\ttable := qb.table()\n\n\t// TODO - querying on groups needs to include movies\n\t// remove this when we migrate to remove the movies filter mode in the database\n\tvar whereClause exp.Expression\n\n\tif mode == models.FilterModeGroups || mode == models.FilterModeMovies {\n\t\twhereClause = goqu.Or(\n\t\t\ttable.Col(\"mode\").Eq(models.FilterModeGroups),\n\t\t\ttable.Col(\"mode\").Eq(models.FilterModeMovies),\n\t\t)\n\t} else {\n\t\twhereClause = table.Col(\"mode\").Eq(mode)\n\t}\n\n\tsq := qb.selectDataset().Prepared(true).Where(whereClause).Order(table.Col(\"name\").Asc())\n\tret, err := qb.getMany(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SavedFilterStore) All(ctx context.Context) ([]*models.SavedFilter, error) {\n\treturn qb.getMany(ctx, qb.selectDataset())\n}\n"
  },
  {
    "path": "pkg/sqlite/saved_filter_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestSavedFilterFind(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsavedFilter, err := db.SavedFilter.Find(ctx, savedFilterIDs[savedFilterIdxImage])\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding saved filter: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, savedFilterIDs[savedFilterIdxImage], savedFilter.ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSavedFilterFindByMode(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsavedFilters, err := db.SavedFilter.FindByMode(ctx, models.FilterModeScenes)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding saved filters: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, savedFilters, 1)\n\t\tassert.Equal(t, savedFilterIDs[savedFilterIdxScene], savedFilters[0].ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSavedFilterDestroy(t *testing.T) {\n\tconst filterName = \"filterToDestroy\"\n\tfilterQ := \"\"\n\tfilterPage := 1\n\tfilterPerPage := 40\n\tfilterSort := \"date\"\n\tfilterDirection := models.SortDirectionEnumAsc\n\tfindFilter := models.FindFilterType{\n\t\tQ:         &filterQ,\n\t\tPage:      &filterPage,\n\t\tPerPage:   &filterPerPage,\n\t\tSort:      &filterSort,\n\t\tDirection: &filterDirection,\n\t}\n\tobjectFilter := map[string]interface{}{\n\t\t\"test\": \"foo\",\n\t}\n\tuiOptions := map[string]interface{}{\n\t\t\"display_mode\": 1,\n\t\t\"zoom_index\":   1,\n\t}\n\tvar id int\n\n\t// create the saved filter to destroy\n\twithTxn(func(ctx context.Context) error {\n\t\tnewFilter := models.SavedFilter{\n\t\t\tName:         filterName,\n\t\t\tMode:         models.FilterModeScenes,\n\t\t\tFindFilter:   &findFilter,\n\t\t\tObjectFilter: objectFilter,\n\t\t\tUIOptions:    uiOptions,\n\t\t}\n\t\terr := db.SavedFilter.Create(ctx, &newFilter)\n\n\t\tif err == nil {\n\t\t\tid = newFilter.ID\n\t\t}\n\n\t\treturn err\n\t})\n\n\twithTxn(func(ctx context.Context) error {\n\t\treturn db.SavedFilter.Destroy(ctx, id)\n\t})\n\n\t// now try to find it\n\twithTxn(func(ctx context.Context) error {\n\t\tfound, err := db.SavedFilter.Find(ctx, id)\n\t\tif err == nil {\n\t\t\tassert.Nil(t, found)\n\t\t}\n\n\t\treturn err\n\t})\n}\n\n// TODO Update\n// TODO Destroy\n// TODO Find\n// TODO GetMarkerStrings\n// TODO Wall\n// TODO Query\n"
  },
  {
    "path": "pkg/sqlite/scene.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nconst (\n\tsceneTable            = \"scenes\"\n\tscenesFilesTable      = \"scenes_files\"\n\tsceneIDColumn         = \"scene_id\"\n\tsceneDateColumn       = \"date\"\n\tperformersScenesTable = \"performers_scenes\"\n\tscenesTagsTable       = \"scenes_tags\"\n\tscenesGalleriesTable  = \"scenes_galleries\"\n\tgroupsScenesTable     = \"groups_scenes\"\n\tscenesURLsTable       = \"scene_urls\"\n\tsceneURLColumn        = \"url\"\n\tscenesViewDatesTable  = \"scenes_view_dates\"\n\tsceneViewDateColumn   = \"view_date\"\n\tscenesODatesTable     = \"scenes_o_dates\"\n\tsceneODateColumn      = \"o_date\"\n\n\tsceneCoverBlobColumn = \"cover_blob\"\n)\n\nvar findExactDuplicateQuery = `\nSELECT GROUP_CONCAT(DISTINCT scene_id) as ids\nFROM (\n\tSELECT scenes.id as scene_id\n\t\t, video_files.duration as file_duration\n\t\t, files.size as file_size\n\t\t, files_fingerprints.fingerprint as phash\n\t\t, abs(max(video_files.duration) OVER (PARTITION by files_fingerprints.fingerprint) - video_files.duration) as durationDiff\n\tFROM scenes\n\tINNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id)\n\tINNER JOIN files ON (scenes_files.file_id = files.id)\n\tINNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash')\n\tINNER JOIN video_files ON (files.id == video_files.file_id)\n)\nWHERE durationDiff <= ?1\n    OR ?1 < 0   --  Always TRUE if the parameter is negative.\n                --  That will disable the durationDiff checking.\nGROUP BY phash\nHAVING COUNT(phash) > 1\n\tAND COUNT(DISTINCT scene_id) > 1\nORDER BY SUM(file_size) DESC;\n`\n\nvar findAllPhashesQuery = `\nSELECT scenes.id as id\n    , files_fingerprints.fingerprint as phash\n    , video_files.duration as duration\nFROM scenes\nINNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id)\nINNER JOIN files ON (scenes_files.file_id = files.id)\nINNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash')\nINNER JOIN video_files ON (files.id == video_files.file_id)\nORDER BY files.size DESC;\n`\n\ntype sceneRow struct {\n\tID            int         `db:\"id\" goqu:\"skipinsert\"`\n\tTitle         zero.String `db:\"title\"`\n\tCode          zero.String `db:\"code\"`\n\tDetails       zero.String `db:\"details\"`\n\tDirector      zero.String `db:\"director\"`\n\tDate          NullDate    `db:\"date\"`\n\tDatePrecision null.Int    `db:\"date_precision\"`\n\t// expressed as 1-100\n\tRating       null.Int  `db:\"rating\"`\n\tOrganized    bool      `db:\"organized\"`\n\tStudioID     null.Int  `db:\"studio_id,omitempty\"`\n\tCreatedAt    Timestamp `db:\"created_at\"`\n\tUpdatedAt    Timestamp `db:\"updated_at\"`\n\tResumeTime   float64   `db:\"resume_time\"`\n\tPlayDuration float64   `db:\"play_duration\"`\n\n\t// not used in resolutions or updates\n\tCoverBlob zero.String `db:\"cover_blob\"`\n}\n\nfunc (r *sceneRow) fromScene(o models.Scene) {\n\tr.ID = o.ID\n\tr.Title = zero.StringFrom(o.Title)\n\tr.Code = zero.StringFrom(o.Code)\n\tr.Details = zero.StringFrom(o.Details)\n\tr.Director = zero.StringFrom(o.Director)\n\tr.Date = NullDateFromDatePtr(o.Date)\n\tr.DatePrecision = datePrecisionFromDatePtr(o.Date)\n\tr.Rating = intFromPtr(o.Rating)\n\tr.Organized = o.Organized\n\tr.StudioID = intFromPtr(o.StudioID)\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n\tr.ResumeTime = o.ResumeTime\n\tr.PlayDuration = o.PlayDuration\n}\n\ntype sceneQueryRow struct {\n\tsceneRow\n\tPrimaryFileID         null.Int    `db:\"primary_file_id\"`\n\tPrimaryFileFolderPath zero.String `db:\"primary_file_folder_path\"`\n\tPrimaryFileBasename   zero.String `db:\"primary_file_basename\"`\n\tPrimaryFileOshash     zero.String `db:\"primary_file_oshash\"`\n\tPrimaryFileChecksum   zero.String `db:\"primary_file_checksum\"`\n}\n\nfunc (r *sceneQueryRow) resolve() *models.Scene {\n\tret := &models.Scene{\n\t\tID:        r.ID,\n\t\tTitle:     r.Title.String,\n\t\tCode:      r.Code.String,\n\t\tDetails:   r.Details.String,\n\t\tDirector:  r.Director.String,\n\t\tDate:      r.Date.DatePtr(r.DatePrecision),\n\t\tRating:    nullIntPtr(r.Rating),\n\t\tOrganized: r.Organized,\n\t\tStudioID:  nullIntPtr(r.StudioID),\n\n\t\tPrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID),\n\t\tOSHash:        r.PrimaryFileOshash.String,\n\t\tChecksum:      r.PrimaryFileChecksum.String,\n\n\t\tCreatedAt: r.CreatedAt.Timestamp,\n\t\tUpdatedAt: r.UpdatedAt.Timestamp,\n\n\t\tResumeTime:   r.ResumeTime,\n\t\tPlayDuration: r.PlayDuration,\n\t}\n\n\tif r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid {\n\t\tret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String)\n\t}\n\n\treturn ret\n}\n\ntype sceneRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *sceneRowRecord) fromPartial(o models.ScenePartial) {\n\tr.setNullString(\"title\", o.Title)\n\tr.setNullString(\"code\", o.Code)\n\tr.setNullString(\"details\", o.Details)\n\tr.setNullString(\"director\", o.Director)\n\tr.setNullDate(\"date\", \"date_precision\", o.Date)\n\tr.setNullInt(\"rating\", o.Rating)\n\tr.setBool(\"organized\", o.Organized)\n\tr.setNullInt(\"studio_id\", o.StudioID)\n\tr.setTimestamp(\"created_at\", o.CreatedAt)\n\tr.setTimestamp(\"updated_at\", o.UpdatedAt)\n\tr.setFloat64(\"resume_time\", o.ResumeTime)\n\tr.setFloat64(\"play_duration\", o.PlayDuration)\n}\n\ntype sceneRepositoryType struct {\n\trepository\n\tgalleries  joinRepository\n\ttags       joinRepository\n\tperformers joinRepository\n\tgroups     repository\n\n\tfiles filesRepository\n\n\tstashIDs stashIDRepository\n}\n\nvar (\n\tsceneRepository = sceneRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: sceneTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\tgalleries: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: scenesGalleriesTable,\n\t\t\t\tidColumn:  sceneIDColumn,\n\t\t\t},\n\t\t\tfkColumn: galleryIDColumn,\n\t\t},\n\t\ttags: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: scenesTagsTable,\n\t\t\t\tidColumn:  sceneIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     tagIDColumn,\n\t\t\tforeignTable: tagTable,\n\t\t\torderBy:      tagTableSortSQL,\n\t\t},\n\t\tperformers: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: performersScenesTable,\n\t\t\t\tidColumn:  sceneIDColumn,\n\t\t\t},\n\t\t\tfkColumn: performerIDColumn,\n\t\t},\n\t\tgroups: repository{\n\t\t\ttableName: groupsScenesTable,\n\t\t\tidColumn:  sceneIDColumn,\n\t\t},\n\t\tfiles: filesRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: scenesFilesTable,\n\t\t\t\tidColumn:  sceneIDColumn,\n\t\t\t},\n\t\t},\n\t\tstashIDs: stashIDRepository{\n\t\t\trepository{\n\t\t\t\ttableName: \"scene_stash_ids\",\n\t\t\t\tidColumn:  sceneIDColumn,\n\t\t\t},\n\t\t},\n\t}\n)\n\ntype SceneStore struct {\n\tblobJoinQueryBuilder\n\tcustomFieldsStore\n\n\ttableMgr *table\n\toDateManager\n\tviewDateManager\n\n\trepo *storeRepository\n}\n\nfunc NewSceneStore(r *storeRepository, blobStore *BlobStore) *SceneStore {\n\treturn &SceneStore{\n\t\tblobJoinQueryBuilder: blobJoinQueryBuilder{\n\t\t\tblobStore: blobStore,\n\t\t\tjoinTable: sceneTable,\n\t\t},\n\t\tcustomFieldsStore: customFieldsStore{\n\t\t\ttable: scenesCustomFieldsTable,\n\t\t\tfk:    scenesCustomFieldsTable.Col(sceneIDColumn),\n\t\t},\n\n\t\ttableMgr:        sceneTableMgr,\n\t\tviewDateManager: viewDateManager{scenesViewTableMgr},\n\t\toDateManager:    oDateManager{scenesOTableMgr},\n\t\trepo:            r,\n\t}\n}\n\nfunc (qb *SceneStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *SceneStore) selectDataset() *goqu.SelectDataset {\n\ttable := qb.table()\n\tfiles := fileTableMgr.table\n\tfolders := folderTableMgr.table\n\tchecksum := fingerprintTableMgr.table.As(\"fingerprint_md5\")\n\toshash := fingerprintTableMgr.table.As(\"fingerprint_oshash\")\n\n\treturn dialect.From(table).LeftJoin(\n\t\tscenesFilesJoinTable,\n\t\tgoqu.On(\n\t\t\tscenesFilesJoinTable.Col(sceneIDColumn).Eq(table.Col(idColumn)),\n\t\t\tscenesFilesJoinTable.Col(\"primary\").Eq(1),\n\t\t),\n\t).LeftJoin(\n\t\tfiles,\n\t\tgoqu.On(files.Col(idColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn))),\n\t).LeftJoin(\n\t\tfolders,\n\t\tgoqu.On(folders.Col(idColumn).Eq(files.Col(\"parent_folder_id\"))),\n\t).LeftJoin(\n\t\tchecksum,\n\t\tgoqu.On(\n\t\t\tchecksum.Col(fileIDColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn)),\n\t\t\tchecksum.Col(\"type\").Eq(models.FingerprintTypeMD5),\n\t\t),\n\t).LeftJoin(\n\t\toshash,\n\t\tgoqu.On(\n\t\t\toshash.Col(fileIDColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn)),\n\t\t\toshash.Col(\"type\").Eq(models.FingerprintTypeOshash),\n\t\t),\n\t).Select(\n\t\tqb.table().All(),\n\t\tscenesFilesJoinTable.Col(fileIDColumn).As(\"primary_file_id\"),\n\t\tfolders.Col(\"path\").As(\"primary_file_folder_path\"),\n\t\tfiles.Col(\"basename\").As(\"primary_file_basename\"),\n\t\tchecksum.Col(\"fingerprint\").As(\"primary_file_checksum\"),\n\t\toshash.Col(\"fingerprint\").As(\"primary_file_oshash\"),\n\t)\n}\n\nfunc (qb *SceneStore) Create(ctx context.Context, newObject *models.Scene, fileIDs []models.FileID) error {\n\tvar r sceneRow\n\tr.fromScene(*newObject)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(fileIDs) > 0 {\n\t\tconst firstPrimary = true\n\t\tif err := scenesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.URLs.Loaded() {\n\t\tconst startPos = 0\n\t\tif err := scenesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.PerformerIDs.Loaded() {\n\t\tif err := scenesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif newObject.TagIDs.Loaded() {\n\t\tif err := scenesTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.GalleryIDs.Loaded() {\n\t\tif err := scenesGalleriesTableMgr.insertJoins(ctx, id, newObject.GalleryIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.StashIDs.Loaded() {\n\t\tif err := scenesStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.Groups.Loaded() {\n\t\tif err := scenesGroupsTableMgr.insertJoins(ctx, id, newObject.Groups.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject = *updated\n\n\treturn nil\n}\n\nfunc (qb *SceneStore) UpdatePartial(ctx context.Context, id int, partial models.ScenePartial) (*models.Scene, error) {\n\tr := sceneRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(partial)\n\n\tif len(r.Record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.URLs != nil {\n\t\tif err := scenesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.PerformerIDs != nil {\n\t\tif err := scenesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.TagIDs != nil {\n\t\tif err := scenesTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.GalleryIDs != nil {\n\t\tif err := scenesGalleriesTableMgr.modifyJoins(ctx, id, partial.GalleryIDs.IDs, partial.GalleryIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.StashIDs != nil {\n\t\tif err := scenesStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.GroupIDs != nil {\n\t\tif err := scenesGroupsTableMgr.modifyJoins(ctx, id, partial.GroupIDs.Groups, partial.GroupIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif partial.PrimaryFileID != nil {\n\t\tif err := scenesFilesTableMgr.setPrimary(ctx, id, *partial.PrimaryFileID); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn qb.find(ctx, id)\n}\n\nfunc (qb *SceneStore) Update(ctx context.Context, updatedObject *models.Scene) error {\n\tvar r sceneRow\n\tr.fromScene(*updatedObject)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\tif updatedObject.URLs.Loaded() {\n\t\tif err := scenesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.PerformerIDs.Loaded() {\n\t\tif err := scenesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.TagIDs.Loaded() {\n\t\tif err := scenesTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.GalleryIDs.Loaded() {\n\t\tif err := scenesGalleriesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.GalleryIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.StashIDs.Loaded() {\n\t\tif err := scenesStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.Groups.Loaded() {\n\t\tif err := scenesGroupsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Groups.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.Files.Loaded() {\n\t\tfileIDs := make([]models.FileID, len(updatedObject.Files.List()))\n\t\tfor i, f := range updatedObject.Files.List() {\n\t\t\tfileIDs[i] = f.ID\n\t\t}\n\n\t\tif err := scenesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *SceneStore) Destroy(ctx context.Context, id int) error {\n\t// must handle image checksums manually\n\tif err := qb.destroyCover(ctx, id); err != nil {\n\t\treturn err\n\t}\n\n\t// scene markers should be handled prior to calling destroy\n\t// galleries should be handled prior to calling destroy\n\n\treturn qb.tableMgr.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *SceneStore) Find(ctx context.Context, id int) (*models.Scene, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\n// FindByIDs finds multiple scenes by their IDs.\n// No check is made to see if the scenes exist, and the order of the returned scenes\n// is not guaranteed to be the same as the order of the input IDs.\nfunc (qb *SceneStore) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {\n\tscenes := make([]*models.Scene, 0, len(ids))\n\n\ttable := qb.table()\n\tif err := batchExec(ids, defaultBatchSize, func(batch []int) error {\n\t\tq := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))\n\t\tunsorted, err := qb.getMany(ctx, q)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tscenes = append(scenes, unsorted...)\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn scenes, nil\n}\n\nfunc (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {\n\tscenes := make([]*models.Scene, len(ids))\n\n\tunsorted, err := qb.FindByIDs(ctx, ids)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, s := range unsorted {\n\t\ti := slices.Index(ids, s.ID)\n\t\tscenes[i] = s\n\t}\n\n\tfor i := range scenes {\n\t\tif scenes[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"scene with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn scenes, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *SceneStore) find(ctx context.Context, id int) (*models.Scene, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Scene, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().Where(\n\t\ttable.Col(idColumn).Eq(\n\t\t\tsq,\n\t\t),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *SceneStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Scene, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *SceneStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Scene, error) {\n\tconst single = false\n\tvar ret []*models.Scene\n\tvar lastID int\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f sceneQueryRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\t\tif s.ID == lastID {\n\t\t\treturn fmt.Errorf(\"internal error: multiple rows returned for single scene id %d\", s.ID)\n\t\t}\n\t\tlastID = s.ID\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) GetFiles(ctx context.Context, id int) ([]*models.VideoFile, error) {\n\tfileIDs, err := sceneRepository.files.get(ctx, id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// use fileStore to load files\n\tfiles, err := qb.repo.File.Find(ctx, fileIDs...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := make([]*models.VideoFile, len(files))\n\tfor i, f := range files {\n\t\tvar ok bool\n\t\tret[i], ok = f.(*models.VideoFile)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"expected file to be *file.VideoFile not %T\", f)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) {\n\tconst primaryOnly = false\n\treturn sceneRepository.files.getMany(ctx, ids, primaryOnly)\n}\n\nfunc (qb *SceneStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) {\n\tsq := dialect.From(scenesFilesJoinTable).Select(scenesFilesJoinTable.Col(sceneIDColumn)).Where(\n\t\tscenesFilesJoinTable.Col(fileIDColumn).Eq(fileID),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting scenes by file id %d: %w\", fileID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) {\n\tsq := dialect.From(scenesFilesJoinTable).Select(scenesFilesJoinTable.Col(sceneIDColumn)).Where(\n\t\tscenesFilesJoinTable.Col(fileIDColumn).Eq(fileID),\n\t\tscenesFilesJoinTable.Col(\"primary\").Eq(1),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting scenes by primary file id %d: %w\", fileID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) {\n\tjoinTable := scenesFilesJoinTable\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(joinTable).Where(joinTable.Col(fileIDColumn).Eq(fileID))\n\treturn count(ctx, q)\n}\n\nfunc (qb *SceneStore) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Scene, error) {\n\tfingerprintTable := fingerprintTableMgr.table\n\n\tvar ex []exp.Expression\n\n\tfor _, v := range fp {\n\t\tex = append(ex, goqu.And(\n\t\t\tfingerprintTable.Col(\"type\").Eq(v.Type),\n\t\t\tfingerprintTable.Col(\"fingerprint\").Eq(v.Fingerprint),\n\t\t))\n\t}\n\n\tsq := dialect.From(scenesFilesJoinTable).\n\t\tInnerJoin(\n\t\t\tfingerprintTable,\n\t\t\tgoqu.On(fingerprintTable.Col(fileIDColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn))),\n\t\t).\n\t\tSelect(scenesFilesJoinTable.Col(sceneIDColumn)).Where(goqu.Or(ex...))\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting scenes by fingerprints: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) FindByChecksum(ctx context.Context, checksum string) ([]*models.Scene, error) {\n\treturn qb.FindByFingerprints(ctx, []models.Fingerprint{\n\t\t{\n\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\tFingerprint: checksum,\n\t\t},\n\t})\n}\n\nfunc (qb *SceneStore) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) {\n\treturn qb.FindByFingerprints(ctx, []models.Fingerprint{\n\t\t{\n\t\t\tType:        models.FingerprintTypeOshash,\n\t\t\tFingerprint: oshash,\n\t\t},\n\t})\n}\n\nfunc (qb *SceneStore) FindByPath(ctx context.Context, p string) ([]*models.Scene, error) {\n\tfilesTable := fileTableMgr.table\n\tfoldersTable := folderTableMgr.table\n\tbasename := filepath.Base(p)\n\tdir := filepath.Dir(p)\n\n\t// replace wildcards\n\tbasename = strings.ReplaceAll(basename, \"*\", \"%\")\n\tdir = strings.ReplaceAll(dir, \"*\", \"%\")\n\n\tsq := dialect.From(scenesFilesJoinTable).InnerJoin(\n\t\tfilesTable,\n\t\tgoqu.On(filesTable.Col(idColumn).Eq(scenesFilesJoinTable.Col(fileIDColumn))),\n\t).InnerJoin(\n\t\tfoldersTable,\n\t\tgoqu.On(foldersTable.Col(idColumn).Eq(filesTable.Col(\"parent_folder_id\"))),\n\t).Select(scenesFilesJoinTable.Col(sceneIDColumn)).Where(\n\t\tfoldersTable.Col(\"path\").Like(dir),\n\t\tfilesTable.Col(\"basename\").Like(basename),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, fmt.Errorf(\"getting scene by path %s: %w\", p, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Scene, error) {\n\tsq := dialect.From(scenesPerformersJoinTable).Select(scenesPerformersJoinTable.Col(sceneIDColumn)).Where(\n\t\tscenesPerformersJoinTable.Col(performerIDColumn).Eq(performerID),\n\t)\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting scenes for performer %d: %w\", performerID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Scene, error) {\n\tsq := dialect.From(galleriesScenesJoinTable).Select(galleriesScenesJoinTable.Col(sceneIDColumn)).Where(\n\t\tgalleriesScenesJoinTable.Col(galleryIDColumn).Eq(galleryID),\n\t)\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting scenes for gallery %d: %w\", galleryID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) {\n\tjoinTable := scenesPerformersJoinTable\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(joinTable).Where(joinTable.Col(performerIDColumn).Eq(performerID))\n\treturn count(ctx, q)\n}\n\nfunc (qb *SceneStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) {\n\ttable := qb.table()\n\tjoinTable := scenesPerformersJoinTable\n\toHistoryTable := goqu.T(scenesODatesTable)\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(table).InnerJoin(\n\t\toHistoryTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))),\n\t).InnerJoin(\n\t\tjoinTable,\n\t\tgoqu.On(\n\t\t\ttable.Col(idColumn).Eq(joinTable.Col(sceneIDColumn)),\n\t\t),\n\t).Where(joinTable.Col(performerIDColumn).Eq(performerID))\n\n\tvar ret int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) OCountByGroupID(ctx context.Context, groupID int) (int, error) {\n\ttable := qb.table()\n\tjoinTable := scenesGroupsJoinTable\n\toHistoryTable := goqu.T(scenesODatesTable)\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(table).InnerJoin(\n\t\toHistoryTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))),\n\t).InnerJoin(\n\t\tjoinTable,\n\t\tgoqu.On(\n\t\t\ttable.Col(idColumn).Eq(joinTable.Col(sceneIDColumn)),\n\t\t),\n\t).Where(joinTable.Col(groupIDColumn).Eq(groupID))\n\n\tvar ret int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) {\n\ttable := qb.table()\n\toHistoryTable := goqu.T(scenesODatesTable)\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(table).InnerJoin(\n\t\toHistoryTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))),\n\t).Where(table.Col(studioIDColumn).Eq(studioID))\n\n\tvar ret int\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) {\n\tsq := dialect.From(scenesGroupsJoinTable).Select(scenesGroupsJoinTable.Col(sceneIDColumn)).Where(\n\t\tscenesGroupsJoinTable.Col(groupIDColumn).Eq(groupID),\n\t)\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting scenes for group %d: %w\", groupID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) Count(ctx context.Context) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(qb.table())\n\treturn count(ctx, q)\n}\n\nfunc (qb *SceneStore) Size(ctx context.Context) (float64, error) {\n\ttable := qb.table()\n\tfileTable := fileTableMgr.table\n\tq := dialect.Select(\n\t\tgoqu.COALESCE(goqu.SUM(fileTableMgr.table.Col(\"size\")), 0),\n\t).From(table).InnerJoin(\n\t\tscenesFilesJoinTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(scenesFilesJoinTable.Col(sceneIDColumn))),\n\t).InnerJoin(\n\t\tfileTable,\n\t\tgoqu.On(scenesFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))),\n\t)\n\tvar ret float64\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) Duration(ctx context.Context) (float64, error) {\n\ttable := qb.table()\n\tvideoFileTable := videoFileTableMgr.table\n\n\tq := dialect.Select(\n\t\tgoqu.COALESCE(goqu.SUM(videoFileTable.Col(\"duration\")), 0),\n\t).From(table).InnerJoin(\n\t\tscenesFilesJoinTable,\n\t\tgoqu.On(scenesFilesJoinTable.Col(\"scene_id\").Eq(table.Col(idColumn))),\n\t).InnerJoin(\n\t\tvideoFileTable,\n\t\tgoqu.On(videoFileTable.Col(\"file_id\").Eq(scenesFilesJoinTable.Col(\"file_id\"))),\n\t)\n\n\tvar ret float64\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) PlayDuration(ctx context.Context) (float64, error) {\n\ttable := qb.table()\n\n\tq := dialect.Select(goqu.COALESCE(goqu.SUM(\"play_duration\"), 0)).From(table)\n\n\tvar ret float64\n\tif err := querySimple(ctx, q, &ret); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\n// TODO - currently only used by unit test\nfunc (qb *SceneStore) CountByStudioID(ctx context.Context, studioID int) (int, error) {\n\ttable := qb.table()\n\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(table).Where(table.Col(studioIDColumn).Eq(studioID))\n\treturn count(ctx, q)\n}\n\nfunc (qb *SceneStore) countMissingFingerprints(ctx context.Context, fpType string) (int, error) {\n\tfpTable := fingerprintTableMgr.table.As(\"fingerprints_temp\")\n\n\tq := dialect.From(scenesFilesJoinTable).LeftJoin(\n\t\tfpTable,\n\t\tgoqu.On(\n\t\t\tscenesFilesJoinTable.Col(fileIDColumn).Eq(fpTable.Col(fileIDColumn)),\n\t\t\tfpTable.Col(\"type\").Eq(fpType),\n\t\t),\n\t).Select(goqu.COUNT(goqu.DISTINCT(scenesFilesJoinTable.Col(sceneIDColumn)))).Where(fpTable.Col(\"fingerprint\").IsNull())\n\n\treturn count(ctx, q)\n}\n\n// CountMissingChecksum returns the number of scenes missing a checksum value.\nfunc (qb *SceneStore) CountMissingChecksum(ctx context.Context) (int, error) {\n\treturn qb.countMissingFingerprints(ctx, \"md5\")\n}\n\n// CountMissingOSHash returns the number of scenes missing an oshash value.\nfunc (qb *SceneStore) CountMissingOSHash(ctx context.Context) (int, error) {\n\treturn qb.countMissingFingerprints(ctx, \"oshash\")\n}\n\nfunc (qb *SceneStore) Wall(ctx context.Context, q *string) ([]*models.Scene, error) {\n\ts := \"\"\n\tif q != nil {\n\t\ts = *q\n\t}\n\n\ttable := qb.table()\n\tqq := qb.selectDataset().Prepared(true).Where(table.Col(\"details\").Like(\"%\" + s + \"%\")).Order(goqu.L(\"RANDOM()\").Asc()).Limit(80)\n\treturn qb.getMany(ctx, qq)\n}\n\nfunc (qb *SceneStore) All(ctx context.Context) ([]*models.Scene, error) {\n\ttable := qb.table()\n\tfileTable := fileTableMgr.table\n\tfolderTable := folderTableMgr.table\n\n\treturn qb.getMany(ctx, qb.selectDataset().Order(\n\t\tfolderTable.Col(\"path\").Asc(),\n\t\tfileTable.Col(\"basename\").Asc(),\n\t\ttable.Col(\"date\").Asc(),\n\t))\n}\n\nfunc (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {\n\tif sceneFilter == nil {\n\t\tsceneFilter = &models.SceneFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := sceneRepository.newQuery()\n\tdistinctIDs(&query, sceneTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    scenesFilesTable,\n\t\t\t\tonClause: \"scenes_files.scene_id = scenes.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    fileTable,\n\t\t\t\tonClause: \"scenes_files.file_id = files.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    folderTable,\n\t\t\t\tonClause: \"files.parent_folder_id = folders.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    fingerprintTable,\n\t\t\t\tonClause: \"files_fingerprints.file_id = scenes_files.file_id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    sceneMarkerTable,\n\t\t\t\tonClause: \"scene_markers.scene_id = scenes.id\",\n\t\t\t},\n\t\t)\n\n\t\tfilepathColumn := \"folders.path || '\" + string(filepath.Separator) + \"' || files.basename\"\n\t\tsearchColumns := []string{\"scenes.title\", \"scenes.details\", filepathColumn, \"files_fingerprints.fingerprint\", \"scene_markers.title\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &sceneFilterHandler{\n\t\tsceneFilter: sceneFilter,\n\t})\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.setSceneSort(&query, findFilter); err != nil {\n\t\treturn nil, err\n\t}\n\tquery.sortAndPagination += getPagination(findFilter)\n\n\treturn &query, nil\n}\n\nfunc (qb *SceneStore) Query(ctx context.Context, options models.SceneQueryOptions) (*models.SceneQueryResult, error) {\n\tquery, err := qb.makeQuery(ctx, options.SceneFilter, options.FindFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult, err := qb.queryGroupedFields(ctx, options, *query)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error querying aggregate fields: %w\", err)\n\t}\n\n\tidsResult, err := query.findIDs(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error finding IDs: %w\", err)\n\t}\n\n\tresult.IDs = idsResult\n\treturn result, nil\n}\n\nfunc (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.SceneQueryOptions, query queryBuilder) (*models.SceneQueryResult, error) {\n\tif !options.Count && !options.TotalDuration && !options.TotalSize {\n\t\t// nothing to do - return empty result\n\t\treturn models.NewSceneQueryResult(qb), nil\n\t}\n\n\taggregateQuery := sceneRepository.newQuery()\n\n\tif options.Count {\n\t\taggregateQuery.addColumn(\"COUNT(DISTINCT temp.id) as total\")\n\t}\n\n\tif options.TotalDuration {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    scenesFilesTable,\n\t\t\t\tonClause: \"scenes_files.scene_id = scenes.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    videoFileTable,\n\t\t\t\tonClause: \"scenes_files.file_id = video_files.file_id\",\n\t\t\t},\n\t\t)\n\t\tquery.addColumn(\"COALESCE(video_files.duration, 0) as duration\")\n\t\taggregateQuery.addColumn(\"SUM(temp.duration) as duration\")\n\t}\n\n\tif options.TotalSize {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\ttable:    scenesFilesTable,\n\t\t\t\tonClause: \"scenes_files.scene_id = scenes.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\ttable:    fileTable,\n\t\t\t\tonClause: \"scenes_files.file_id = files.id\",\n\t\t\t},\n\t\t)\n\t\tquery.addColumn(\"COALESCE(files.size, 0) as size\")\n\t\taggregateQuery.addColumn(\"SUM(temp.size) as size\")\n\t}\n\n\tconst includeSortPagination = false\n\taggregateQuery.from = fmt.Sprintf(\"(%s) as temp\", query.toSQL(includeSortPagination))\n\n\tout := struct {\n\t\tTotal    int\n\t\tDuration null.Float\n\t\tSize     null.Float\n\t}{}\n\tif err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {\n\t\treturn nil, err\n\t}\n\n\tret := models.NewSceneQueryResult(qb)\n\tret.Count = out.Total\n\tret.TotalDuration = out.Duration.Float64\n\tret.TotalSize = out.Size.Float64\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) QueryCount(ctx context.Context, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) (int, error) {\n\tquery, err := qb.makeQuery(ctx, sceneFilter, findFilter)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn query.executeCount(ctx)\n}\n\nvar sceneSortOptions = sortOptions{\n\t\"bitrate\",\n\t\"created_at\",\n\t\"code\",\n\t\"date\",\n\t\"file_count\",\n\t\"filesize\",\n\t\"duration\",\n\t\"file_mod_time\",\n\t\"framerate\",\n\t\"group_scene_number\",\n\t\"id\",\n\t\"interactive\",\n\t\"interactive_speed\",\n\t\"last_o_at\",\n\t\"last_played_at\",\n\t\"movie_scene_number\",\n\t\"o_counter\",\n\t\"organized\",\n\t\"performer_count\",\n\t\"play_count\",\n\t\"play_duration\",\n\t\"resume_time\",\n\t\"path\",\n\t\"perceptual_similarity\",\n\t\"random\",\n\t\"rating\",\n\t\"resolution\",\n\t\"studio\",\n\t\"tag_count\",\n\t\"title\",\n\t\"updated_at\",\n\t\"performer_age\",\n}\n\nfunc (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindFilterType) error {\n\tif findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == \"\" {\n\t\treturn nil\n\t}\n\tsort := findFilter.GetSort(\"title\")\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := sceneSortOptions.validateSort(sort); err != nil {\n\t\treturn err\n\t}\n\n\taddFileTable := func() {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    scenesFilesTable,\n\t\t\t\tonClause: \"scenes_files.scene_id = scenes.id\",\n\t\t\t},\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    fileTable,\n\t\t\t\tonClause: \"scenes_files.file_id = files.id\",\n\t\t\t},\n\t\t)\n\t}\n\n\taddVideoFileTable := func() {\n\t\taddFileTable()\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    videoFileTable,\n\t\t\t\tonClause: \"video_files.file_id = scenes_files.file_id\",\n\t\t\t},\n\t\t)\n\t}\n\n\taddFolderTable := func() {\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    folderTable,\n\t\t\t\tonClause: \"files.parent_folder_id = folders.id\",\n\t\t\t},\n\t\t)\n\t}\n\n\tdirection := findFilter.GetDirection()\n\tswitch sort {\n\tcase \"movie_scene_number\":\n\t\tquery.joinSort(groupsScenesTable, \"\", \"scenes.id = groups_scenes.scene_id\")\n\t\tquery.sortAndPagination += getSort(\"scene_index\", direction, groupsScenesTable)\n\tcase \"group_scene_number\":\n\t\tquery.joinSort(groupsScenesTable, \"scene_group\", \"scenes.id = scene_group.scene_id\")\n\t\tquery.sortAndPagination += getSort(\"scene_index\", direction, \"scene_group\")\n\tcase \"tag_count\":\n\t\tquery.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction)\n\tcase \"performer_count\":\n\t\tquery.sortAndPagination += getCountSort(sceneTable, performersScenesTable, sceneIDColumn, direction)\n\tcase \"file_count\":\n\t\tquery.sortAndPagination += getCountSort(sceneTable, scenesFilesTable, sceneIDColumn, direction)\n\tcase \"path\":\n\t\t// special handling for path\n\t\taddFileTable()\n\t\taddFolderTable()\n\t\tquery.sortAndPagination += fmt.Sprintf(\" ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s\", direction)\n\tcase \"perceptual_similarity\":\n\t\t// special handling for phash\n\t\taddFileTable()\n\t\tquery.addJoins(\n\t\t\tjoin{\n\t\t\t\tsort:     true,\n\t\t\t\ttable:    fingerprintTable,\n\t\t\t\tas:       \"fingerprints_phash\",\n\t\t\t\tonClause: \"scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'\",\n\t\t\t},\n\t\t)\n\n\t\tquery.sortAndPagination += \" ORDER BY fingerprints_phash.fingerprint \" + direction + \", files.size DESC\"\n\tcase \"bitrate\":\n\t\tsort = \"bit_rate\"\n\t\taddVideoFileTable()\n\t\tquery.sortAndPagination += getSort(sort, direction, videoFileTable)\n\tcase \"file_mod_time\":\n\t\tsort = \"mod_time\"\n\t\taddFileTable()\n\t\tquery.sortAndPagination += getSort(sort, direction, fileTable)\n\tcase \"framerate\":\n\t\tsort = \"frame_rate\"\n\t\taddVideoFileTable()\n\t\tquery.sortAndPagination += getSort(sort, direction, videoFileTable)\n\tcase \"resolution\":\n\t\taddVideoFileTable()\n\t\tquery.sortAndPagination += fmt.Sprintf(\" ORDER BY MIN(%s.width, %s.height) %s\", videoFileTable, videoFileTable, getSortDirection(direction))\n\tcase \"filesize\":\n\t\taddFileTable()\n\t\tquery.sortAndPagination += getSort(sort, direction, fileTable)\n\tcase \"duration\":\n\t\taddVideoFileTable()\n\t\tquery.sortAndPagination += getSort(sort, direction, videoFileTable)\n\tcase \"interactive\", \"interactive_speed\":\n\t\taddVideoFileTable()\n\t\tquery.sortAndPagination += getSort(sort, direction, videoFileTable)\n\tcase \"title\":\n\t\taddFileTable()\n\t\taddFolderTable()\n\t\tquery.sortAndPagination += \" ORDER BY COALESCE(scenes.title, files.basename) COLLATE NATURAL_CI \" + direction + \", folders.path COLLATE NATURAL_CI \" + direction\n\tcase \"play_count\":\n\t\tquery.sortAndPagination += getCountSort(sceneTable, scenesViewDatesTable, sceneIDColumn, direction)\n\tcase \"last_played_at\":\n\t\tquery.sortAndPagination += fmt.Sprintf(\" ORDER BY (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) %s\", scenesViewDatesTable, sceneIDColumn, sceneTable, getSortDirection(direction))\n\tcase \"last_o_at\":\n\t\tquery.sortAndPagination += fmt.Sprintf(\" ORDER BY (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) %s\", scenesODatesTable, sceneIDColumn, sceneTable, getSortDirection(direction))\n\tcase \"o_counter\":\n\t\tquery.sortAndPagination += getCountSort(sceneTable, scenesODatesTable, sceneIDColumn, direction)\n\tcase \"performer_age\":\n\t\t// Looking at the youngest performer by default\n\t\taggregation := \"MIN\"\n\t\tif direction == \"DESC\" {\n\t\t\t// When sorting by performer_'s age DESC, I should consider the oldest performer instead\n\t\t\taggregation = \"MAX\"\n\t\t}\n\t\tfallback := \"NULL\"\n\t\tif direction == \"ASC\" {\n\t\t\t// When sorting ascending, NULLs are first by default. Coalescing to the MAX int value supported by sqlite\n\t\t\tfallback = \"9223372036854775807\"\n\t\t}\n\t\tquery.sortAndPagination += fmt.Sprintf(\n\t\t\t\" ORDER BY (SELECT COALESCE(%s(JulianDay(scenes.date) - JulianDay(performers.birthdate)), %s) FROM %s as performers INNER JOIN %s AS aggregation WHERE performers.id = aggregation.%s AND aggregation.%s = %s.id) %s\",\n\t\t\taggregation,\n\t\t\tfallback,\n\t\t\tperformerTable,\n\t\t\tperformersScenesTable,\n\t\t\tperformerIDColumn,\n\t\t\tsceneIDColumn,\n\t\t\tsceneTable,\n\t\t\tgetSortDirection(direction),\n\t\t)\n\tcase \"studio\":\n\t\tquery.joinSort(studioTable, \"\", \"scenes.studio_id = studios.id\")\n\t\tquery.sortAndPagination += getSort(\"name\", direction, studioTable)\n\tdefault:\n\t\tquery.sortAndPagination += getSort(sort, direction, \"scenes\")\n\t}\n\n\t// Whatever the sorting, always use title/id as a final sort\n\tquery.sortAndPagination += \", COALESCE(scenes.title, scenes.id) COLLATE NATURAL_CI ASC\"\n\n\treturn nil\n}\n\nfunc (qb *SceneStore) SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) {\n\tif err := qb.tableMgr.checkIDExists(ctx, id); err != nil {\n\t\treturn false, err\n\t}\n\n\trecord := goqu.Record{}\n\n\tif resumeTime != nil {\n\t\trecord[\"resume_time\"] = resumeTime\n\t}\n\n\tif playDuration != nil {\n\t\trecord[\"play_duration\"] = goqu.L(\"play_duration + ?\", playDuration)\n\t}\n\n\tif len(record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, record); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\treturn true, nil\n}\n\nfunc (qb *SceneStore) ResetActivity(ctx context.Context, id int, resetResume bool, resetDuration bool) (bool, error) {\n\tif err := qb.tableMgr.checkIDExists(ctx, id); err != nil {\n\t\treturn false, err\n\t}\n\n\trecord := goqu.Record{}\n\n\tif resetResume {\n\t\trecord[\"resume_time\"] = 0.0\n\t}\n\n\tif resetDuration {\n\t\trecord[\"play_duration\"] = 0.0\n\t}\n\n\tif len(record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, record); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\treturn true, nil\n}\n\nfunc (qb *SceneStore) GetURLs(ctx context.Context, sceneID int) ([]string, error) {\n\treturn scenesURLsTableMgr.get(ctx, sceneID)\n}\n\nfunc (qb *SceneStore) GetCover(ctx context.Context, sceneID int) ([]byte, error) {\n\treturn qb.GetImage(ctx, sceneID, sceneCoverBlobColumn)\n}\n\nfunc (qb *SceneStore) HasCover(ctx context.Context, sceneID int) (bool, error) {\n\treturn qb.HasImage(ctx, sceneID, sceneCoverBlobColumn)\n}\n\nfunc (qb *SceneStore) UpdateCover(ctx context.Context, sceneID int, image []byte) error {\n\treturn qb.UpdateImage(ctx, sceneID, sceneCoverBlobColumn, image)\n}\n\nfunc (qb *SceneStore) destroyCover(ctx context.Context, sceneID int) error {\n\treturn qb.DestroyImage(ctx, sceneID, sceneCoverBlobColumn)\n}\n\nfunc (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []models.FileID) error {\n\t// assuming a file can only be assigned to a single scene\n\tif err := scenesFilesTableMgr.destroyJoins(ctx, fileIDs); err != nil {\n\t\treturn err\n\t}\n\n\t// assign primary only if destination has no files\n\texistingFileIDs, err := sceneRepository.files.get(ctx, sceneID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfirstPrimary := len(existingFileIDs) == 0\n\treturn scenesFilesTableMgr.insertJoins(ctx, sceneID, firstPrimary, fileIDs)\n}\n\nfunc (qb *SceneStore) GetGroups(ctx context.Context, id int) (ret []models.GroupsScenes, err error) {\n\tret = []models.GroupsScenes{}\n\n\tif err := sceneRepository.groups.getAll(ctx, id, func(rows *sqlx.Rows) error {\n\t\tvar ms groupsScenesRow\n\t\tif err := rows.StructScan(&ms); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, ms.resolve(id))\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error {\n\tconst firstPrimary = false\n\treturn scenesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID})\n}\n\nfunc (qb *SceneStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) {\n\treturn sceneRepository.performers.getIDs(ctx, id)\n}\n\nfunc (qb *SceneStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {\n\treturn sceneRepository.tags.getIDs(ctx, id)\n}\n\nfunc (qb *SceneStore) GetGalleryIDs(ctx context.Context, id int) ([]int, error) {\n\treturn sceneRepository.galleries.getIDs(ctx, id)\n}\n\nfunc (qb *SceneStore) AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error {\n\treturn scenesGalleriesTableMgr.addJoins(ctx, sceneID, galleryIDs)\n}\n\nfunc (qb *SceneStore) GetStashIDs(ctx context.Context, sceneID int) ([]models.StashID, error) {\n\treturn sceneRepository.stashIDs.get(ctx, sceneID)\n}\n\nfunc (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) {\n\tvar dupeIds [][]int\n\tif distance == 0 {\n\t\tvar ids []string\n\t\tif err := dbWrapper.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, id := range ids {\n\t\t\tstrIds := strings.Split(id, \",\")\n\t\t\tvar sceneIds []int\n\t\t\tfor _, strId := range strIds {\n\t\t\t\tif intId, err := strconv.Atoi(strId); err == nil {\n\t\t\t\t\tsceneIds = sliceutil.AppendUnique(sceneIds, intId)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// filter out\n\t\t\tif len(sceneIds) > 1 {\n\t\t\t\tdupeIds = append(dupeIds, sceneIds)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tvar hashes []*utils.Phash\n\n\t\tif err := sceneRepository.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error {\n\t\t\tphash := utils.Phash{\n\t\t\t\tBucket:   -1,\n\t\t\t\tDuration: -1,\n\t\t\t}\n\t\t\tif err := rows.StructScan(&phash); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\thashes = append(hashes, &phash)\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tdupeIds = utils.FindDuplicates(hashes, distance, durationDiff)\n\t}\n\n\tvar duplicates [][]*models.Scene\n\tfor _, sceneIds := range dupeIds {\n\t\tif scenes, err := qb.FindMany(ctx, sceneIds); err == nil {\n\t\t\tduplicates = append(duplicates, scenes)\n\t\t}\n\t}\n\n\tsortByPath(duplicates)\n\n\treturn duplicates, nil\n}\n\nfunc sortByPath(scenes [][]*models.Scene) {\n\tlessFunc := func(i int, j int) bool {\n\t\tfirstPathI := getFirstPath(scenes[i])\n\t\tfirstPathJ := getFirstPath(scenes[j])\n\t\treturn firstPathI < firstPathJ\n\t}\n\tsort.SliceStable(scenes, lessFunc)\n}\n\nfunc getFirstPath(scenes []*models.Scene) string {\n\tvar firstPath string\n\tfor i, scene := range scenes {\n\t\tif i == 0 || scene.Path < firstPath {\n\t\t\tfirstPath = scene.Path\n\t\t}\n\t}\n\treturn firstPath\n}\n"
  },
  {
    "path": "pkg/sqlite/scene_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype sceneFilterHandler struct {\n\tsceneFilter *models.SceneFilterType\n}\n\nfunc (qb *sceneFilterHandler) validate() error {\n\tsceneFilter := qb.sceneFilter\n\tif sceneFilter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(sceneFilter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif subFilter := sceneFilter.SubFilter(); subFilter != nil {\n\t\tsqb := &sceneFilterHandler{sceneFilter: subFilter}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *sceneFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tsceneFilter := qb.sceneFilter\n\tif sceneFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := sceneFilter.SubFilter()\n\tif sf != nil {\n\t\tsub := &sceneFilterHandler{sf}\n\t\thandleSubFilter(ctx, sub, f, sceneFilter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *sceneFilterHandler) criterionHandler() criterionHandler {\n\tsceneFilter := qb.sceneFilter\n\treturn compoundHandler{\n\t\tintCriterionHandler(sceneFilter.ID, \"scenes.id\", nil),\n\t\tpathCriterionHandler(sceneFilter.Path, \"folders.path\", \"files.basename\", qb.addFoldersTable),\n\t\tqb.fileCountCriterionHandler(sceneFilter.FileCount),\n\t\tstringCriterionHandler(sceneFilter.Title, \"scenes.title\"),\n\t\tstringCriterionHandler(sceneFilter.Code, \"scenes.code\"),\n\t\tstringCriterionHandler(sceneFilter.Details, \"scenes.details\"),\n\t\tstringCriterionHandler(sceneFilter.Director, \"scenes.director\"),\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif sceneFilter.Oshash != nil {\n\t\t\t\tqb.addSceneFilesTable(f)\n\t\t\t\tf.addLeftJoin(fingerprintTable, \"fingerprints_oshash\", \"scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'\")\n\t\t\t}\n\n\t\t\tstringCriterionHandler(sceneFilter.Oshash, \"fingerprints_oshash.fingerprint\")(ctx, f)\n\t\t}),\n\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif sceneFilter.Checksum != nil {\n\t\t\t\tqb.addSceneFilesTable(f)\n\t\t\t\tf.addLeftJoin(fingerprintTable, \"fingerprints_md5\", \"scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'\")\n\t\t\t}\n\n\t\t\tstringCriterionHandler(sceneFilter.Checksum, \"fingerprints_md5.fingerprint\")(ctx, f)\n\t\t}),\n\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif sceneFilter.Phash != nil {\n\t\t\t\t// backwards compatibility\n\t\t\t\th := phashDistanceCriterionHandler{\n\t\t\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\t\t\tqb.addSceneFilesTable(f)\n\t\t\t\t\t\tf.addLeftJoin(fingerprintTable, \"fingerprints_phash\", \"scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'\")\n\t\t\t\t\t},\n\t\t\t\t\tcriterion: &models.PhashDistanceCriterionInput{\n\t\t\t\t\t\tValue:    sceneFilter.Phash.Value,\n\t\t\t\t\t\tModifier: sceneFilter.Phash.Modifier,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\th.handle(ctx, f)\n\t\t\t}\n\t\t}),\n\n\t\t&phashDistanceCriterionHandler{\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tqb.addSceneFilesTable(f)\n\t\t\t\tf.addLeftJoin(fingerprintTable, \"fingerprints_phash\", \"scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'\")\n\t\t\t},\n\t\t\tcriterion: sceneFilter.PhashDistance,\n\t\t},\n\n\t\tintCriterionHandler(sceneFilter.Rating100, \"scenes.rating\", nil),\n\t\tqb.oCountCriterionHandler(sceneFilter.OCounter),\n\t\tboolCriterionHandler(sceneFilter.Organized, \"scenes.organized\", nil),\n\n\t\tfloatIntCriterionHandler(sceneFilter.Duration, \"video_files.duration\", qb.addVideoFilesTable),\n\t\tresolutionCriterionHandler(sceneFilter.Resolution, \"video_files.height\", \"video_files.width\", qb.addVideoFilesTable),\n\t\torientationCriterionHandler(sceneFilter.Orientation, \"video_files.height\", \"video_files.width\", qb.addVideoFilesTable),\n\t\tfloatIntCriterionHandler(sceneFilter.Framerate, \"ROUND(video_files.frame_rate)\", qb.addVideoFilesTable),\n\t\tintCriterionHandler(sceneFilter.Bitrate, \"video_files.bit_rate\", qb.addVideoFilesTable),\n\t\tqb.codecCriterionHandler(sceneFilter.VideoCodec, \"video_files.video_codec\", qb.addVideoFilesTable),\n\t\tqb.codecCriterionHandler(sceneFilter.AudioCodec, \"video_files.audio_codec\", qb.addVideoFilesTable),\n\n\t\tqb.hasMarkersCriterionHandler(sceneFilter.HasMarkers),\n\t\tqb.isMissingCriterionHandler(sceneFilter.IsMissing),\n\t\tqb.urlsCriterionHandler(sceneFilter.URL),\n\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif sceneFilter.StashID != nil {\n\t\t\t\tsceneRepository.stashIDs.join(f, \"scene_stash_ids\", \"scenes.id\")\n\t\t\t\tstringCriterionHandler(sceneFilter.StashID, \"scene_stash_ids.stash_id\")(ctx, f)\n\t\t\t}\n\t\t}),\n\t\t&stashIDCriterionHandler{\n\t\t\tc:                 sceneFilter.StashIDEndpoint,\n\t\t\tstashIDRepository: &sceneRepository.stashIDs,\n\t\t\tstashIDTableAs:    \"scene_stash_ids\",\n\t\t\tparentIDCol:       \"scenes.id\",\n\t\t},\n\t\t&stashIDsCriterionHandler{\n\t\t\tc:                 sceneFilter.StashIDsEndpoint,\n\t\t\tstashIDRepository: &sceneRepository.stashIDs,\n\t\t\tstashIDTableAs:    \"scene_stash_ids\",\n\t\t\tparentIDCol:       \"scenes.id\",\n\t\t},\n\n\t\tqb.stashIDCountCriterionHandler(sceneFilter.StashIDCount),\n\n\t\tboolCriterionHandler(sceneFilter.Interactive, \"video_files.interactive\", qb.addVideoFilesTable),\n\t\tintCriterionHandler(sceneFilter.InteractiveSpeed, \"video_files.interactive_speed\", qb.addVideoFilesTable),\n\n\t\tqb.captionCriterionHandler(sceneFilter.Captions),\n\n\t\tfloatIntCriterionHandler(sceneFilter.ResumeTime, \"scenes.resume_time\", nil),\n\t\tfloatIntCriterionHandler(sceneFilter.PlayDuration, \"scenes.play_duration\", nil),\n\t\tqb.playCountCriterionHandler(sceneFilter.PlayCount),\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif sceneFilter.LastPlayedAt != nil {\n\t\t\t\tf.addLeftJoin(\n\t\t\t\t\tfmt.Sprintf(\"(SELECT %s, MAX(%s) as last_played_at FROM %s GROUP BY %s)\", sceneIDColumn, sceneViewDateColumn, scenesViewDatesTable, sceneIDColumn),\n\t\t\t\t\t\"scene_last_view\",\n\t\t\t\t\tfmt.Sprintf(\"scene_last_view.%s = scenes.id\", sceneIDColumn),\n\t\t\t\t)\n\t\t\t\th := timestampCriterionHandler{sceneFilter.LastPlayedAt, \"IFNULL(last_played_at, datetime(0))\", nil}\n\t\t\t\th.handle(ctx, f)\n\t\t\t}\n\t\t}),\n\n\t\tqb.tagsCriterionHandler(sceneFilter.Tags),\n\t\tqb.tagCountCriterionHandler(sceneFilter.TagCount),\n\t\tqb.performersCriterionHandler(sceneFilter.Performers),\n\t\tqb.performerCountCriterionHandler(sceneFilter.PerformerCount),\n\t\tstudioCriterionHandler(sceneTable, sceneFilter.Studios),\n\n\t\tqb.groupsCriterionHandler(sceneFilter.Groups),\n\t\tqb.moviesCriterionHandler(sceneFilter.Movies),\n\n\t\tqb.galleriesCriterionHandler(sceneFilter.Galleries),\n\t\tqb.performerTagsCriterionHandler(sceneFilter.PerformerTags),\n\t\tqb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite),\n\t\tqb.performerAgeCriterionHandler(sceneFilter.PerformerAge),\n\t\tqb.duplicatedCriterionHandler(sceneFilter.Duplicated),\n\t\t&dateCriterionHandler{sceneFilter.Date, \"scenes.date\", nil},\n\t\t&timestampCriterionHandler{sceneFilter.CreatedAt, \"scenes.created_at\", nil},\n\t\t&timestampCriterionHandler{sceneFilter.UpdatedAt, \"scenes.updated_at\", nil},\n\n\t\t&customFieldsFilterHandler{\n\t\t\ttable: scenesCustomFieldsTable.GetTable(),\n\t\t\tfkCol: sceneIDColumn,\n\t\t\tc:     sceneFilter.CustomFields,\n\t\t\tidCol: \"scenes.id\",\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scenes_galleries.gallery_id\",\n\t\t\trelatedRepo:    galleryRepository.repository,\n\t\t\trelatedHandler: &galleryFilterHandler{sceneFilter.GalleriesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tsceneRepository.galleries.innerJoin(f, \"\", \"scenes.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"performers_join.performer_id\",\n\t\t\trelatedRepo:    performerRepository.repository,\n\t\t\trelatedHandler: &performerFilterHandler{sceneFilter.PerformersFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tsceneRepository.performers.innerJoin(f, \"performers_join\", \"scenes.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scenes.studio_id\",\n\t\t\trelatedRepo:    studioRepository.repository,\n\t\t\trelatedHandler: &studioFilterHandler{sceneFilter.StudiosFilter},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scene_tag.tag_id\",\n\t\t\trelatedRepo:    tagRepository.repository,\n\t\t\trelatedHandler: &tagFilterHandler{sceneFilter.TagsFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tsceneRepository.tags.innerJoin(f, \"scene_tag\", \"scenes.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"groups_scenes.group_id\",\n\t\t\trelatedRepo:    groupRepository.repository,\n\t\t\trelatedHandler: &groupFilterHandler{sceneFilter.MoviesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tsceneRepository.groups.innerJoin(f, \"\", \"scenes.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol: \"files.id\",\n\t\t\trelatedRepo:  fileRepository.repository,\n\t\t\trelatedHandler: &fileFilterHandler{\n\t\t\t\tfileFilter: sceneFilter.FilesFilter,\n\t\t\t\tisRelated:  true,\n\t\t\t},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tqb.addFilesTable(f)\n\t\t\t\tqb.addFoldersTable(f)\n\t\t\t},\n\t\t\t// don't use a subquery; join directly\n\t\t\tdirectJoin: true,\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scene_markers.id\",\n\t\t\trelatedRepo:    sceneMarkerRepository.repository,\n\t\t\trelatedHandler: &sceneMarkerFilterHandler{sceneFilter.MarkersFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tf.addInnerJoin(\"scene_markers\", \"\", \"scenes.id\")\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (qb *sceneFilterHandler) addSceneFilesTable(f *filterBuilder) {\n\tf.addLeftJoin(scenesFilesTable, \"\", \"scenes_files.scene_id = scenes.id\")\n}\n\nfunc (qb *sceneFilterHandler) addFilesTable(f *filterBuilder) {\n\tqb.addSceneFilesTable(f)\n\tf.addLeftJoin(fileTable, \"\", \"scenes_files.file_id = files.id\")\n}\n\nfunc (qb *sceneFilterHandler) addFoldersTable(f *filterBuilder) {\n\tqb.addFilesTable(f)\n\tf.addLeftJoin(folderTable, \"\", \"files.parent_folder_id = folders.id\")\n}\n\nfunc (qb *sceneFilterHandler) addVideoFilesTable(f *filterBuilder) {\n\tqb.addSceneFilesTable(f)\n\tf.addLeftJoin(videoFileTable, \"\", \"video_files.file_id = scenes_files.file_id\")\n}\n\nfunc (qb *sceneFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tjoinTable:    scenesViewDatesTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t}\n\n\treturn h.handler(count)\n}\n\nfunc (qb *sceneFilterHandler) oCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tjoinTable:    scenesODatesTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t}\n\n\treturn h.handler(count)\n}\n\nfunc (qb *sceneFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tjoinTable:    scenesFilesTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t}\n\n\treturn h.handler(fileCount)\n}\n\nfunc (qb *sceneFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.DuplicationCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif duplicatedFilter == nil {\n\t\t\treturn\n\t\t}\n\n\t\t// Handle legacy 'duplicated' field - treat as phash if phash not explicitly set\n\t\t//nolint:staticcheck\n\t\tif duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil {\n\t\t\t//nolint:staticcheck\n\t\t\tduplicatedFilter.Phash = duplicatedFilter.Duplicated\n\t\t}\n\n\t\t// Handle explicit fields\n\t\tif duplicatedFilter.Phash != nil {\n\t\t\tqb.addSceneFilesTable(f)\n\t\t\tqb.applyPhashDuplication(f, *duplicatedFilter.Phash)\n\t\t}\n\n\t\tif duplicatedFilter.StashID != nil {\n\t\t\tqb.applyStashIDDuplication(f, *duplicatedFilter.StashID)\n\t\t}\n\n\t\tif duplicatedFilter.Title != nil {\n\t\t\tqb.applyTitleDuplication(f, *duplicatedFilter.Title)\n\t\t}\n\n\t\tif duplicatedFilter.URL != nil {\n\t\t\tqb.applyURLDuplication(f, *duplicatedFilter.URL)\n\t\t}\n\t}\n}\n\n// getCountOperator returns \">\" for duplicated items (count > 1) or \"=\" for unique items (count = 1)\nfunc getCountOperator(duplicated bool) string {\n\tif duplicated {\n\t\treturn \">\"\n\t}\n\treturn \"=\"\n}\n\nfunc (qb *sceneFilterHandler) applyPhashDuplication(f *filterBuilder, duplicated bool) {\n\t// TODO: Wishlist item: Implement Distance matching\n\tv := getCountOperator(duplicated)\n\tf.addInnerJoin(\"(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) \"+v+\" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)\", \"scph\", \"scenes_files.file_id = scph.file_id\")\n}\n\nfunc (qb *sceneFilterHandler) applyStashIDDuplication(f *filterBuilder, duplicated bool) {\n\tv := getCountOperator(duplicated)\n\t// Find stash_ids that appear on more than one scene\n\tf.addInnerJoin(\"(SELECT scene_id FROM scene_stash_ids INNER JOIN (SELECT stash_id FROM scene_stash_ids GROUP BY stash_id HAVING COUNT(DISTINCT scene_id) \"+v+\" 1) dupes ON scene_stash_ids.stash_id = dupes.stash_id)\", \"scsi\", \"scenes.id = scsi.scene_id\")\n}\n\nfunc (qb *sceneFilterHandler) applyTitleDuplication(f *filterBuilder, duplicated bool) {\n\tv := getCountOperator(duplicated)\n\t// Find titles that appear on more than one scene (excluding empty titles)\n\tf.addInnerJoin(\"(SELECT id FROM scenes WHERE title != '' AND title IS NOT NULL AND title IN (SELECT title FROM scenes WHERE title != '' AND title IS NOT NULL GROUP BY title HAVING COUNT(*) \"+v+\" 1))\", \"sctitle\", \"scenes.id = sctitle.id\")\n}\n\nfunc (qb *sceneFilterHandler) applyURLDuplication(f *filterBuilder, duplicated bool) {\n\tv := getCountOperator(duplicated)\n\t// Find URLs that appear on more than one scene\n\tf.addInnerJoin(\"(SELECT scene_id FROM scene_urls INNER JOIN (SELECT url FROM scene_urls GROUP BY url HAVING COUNT(DISTINCT scene_id) \"+v+\" 1) dupes ON scene_urls.url = dupes.url)\", \"scurl\", \"scenes.id = scurl.scene_id\")\n}\n\nfunc (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif codec != nil {\n\t\t\tif addJoinFn != nil {\n\t\t\t\taddJoinFn(f)\n\t\t\t}\n\n\t\t\tstringCriterionHandler(codec, codecColumn)(ctx, f)\n\t\t}\n\t}\n}\n\nfunc (qb *sceneFilterHandler) hasMarkersCriterionHandler(hasMarkers *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif hasMarkers != nil {\n\t\t\tf.addLeftJoin(\"scene_markers\", \"\", \"scene_markers.scene_id = scenes.id\")\n\t\t\tif *hasMarkers == \"true\" {\n\t\t\t\tf.addHaving(\"count(scene_markers.scene_id) > 0\")\n\t\t\t} else {\n\t\t\t\tf.addWhere(\"scene_markers.id IS NULL\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif isMissing != nil && *isMissing != \"\" {\n\t\t\tswitch *isMissing {\n\t\t\tcase \"url\":\n\t\t\t\tscenesURLsTableMgr.join(f, \"\", \"scenes.id\")\n\t\t\t\tf.addWhere(\"scene_urls.url IS NULL\")\n\t\t\tcase \"galleries\":\n\t\t\t\tsceneRepository.galleries.join(f, \"galleries_join\", \"scenes.id\")\n\t\t\t\tf.addWhere(\"galleries_join.scene_id IS NULL\")\n\t\t\tcase \"studio\":\n\t\t\t\tf.addWhere(\"scenes.studio_id IS NULL\")\n\t\t\tcase \"movie\", \"group\":\n\t\t\t\tsceneRepository.groups.join(f, \"groups_join\", \"scenes.id\")\n\t\t\t\tf.addWhere(\"groups_join.scene_id IS NULL\")\n\t\t\tcase \"performers\":\n\t\t\t\tsceneRepository.performers.join(f, \"performers_join\", \"scenes.id\")\n\t\t\t\tf.addWhere(\"performers_join.scene_id IS NULL\")\n\t\t\tcase \"date\":\n\t\t\t\tf.addWhere(`scenes.date IS NULL OR scenes.date IS \"\"`)\n\t\t\tcase \"tags\":\n\t\t\t\tsceneRepository.tags.join(f, \"tags_join\", \"scenes.id\")\n\t\t\t\tf.addWhere(\"tags_join.scene_id IS NULL\")\n\t\t\tcase \"stash_id\":\n\t\t\t\tsceneRepository.stashIDs.join(f, \"scene_stash_ids\", \"scenes.id\")\n\t\t\t\tf.addWhere(\"scene_stash_ids.scene_id IS NULL\")\n\t\t\tcase \"phash\":\n\t\t\t\tqb.addSceneFilesTable(f)\n\t\t\t\tf.addLeftJoin(fingerprintTable, \"fingerprints_phash\", \"scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'\")\n\t\t\t\tf.addWhere(\"fingerprints_phash.fingerprint IS NULL\")\n\t\t\tcase \"cover\":\n\t\t\t\tf.addWhere(\"scenes.cover_blob IS NULL\")\n\t\t\tdefault:\n\t\t\t\tif err := validateIsMissing(*isMissing, []string{\n\t\t\t\t\t\"title\", \"code\", \"details\", \"director\", \"rating\",\n\t\t\t\t}); err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWhere(\"(scenes.\" + *isMissing + \" IS NULL OR TRIM(scenes.\" + *isMissing + \") = '')\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t\tjoinTable:    scenesURLsTable,\n\t\tstringColumn: sceneURLColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tscenesURLsTableMgr.join(f, \"\", \"scenes.id\")\n\t\t},\n\t}\n\n\treturn h.handler(url)\n}\n\nfunc (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {\n\treturn multiCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tforeignTable: foreignTable,\n\t\tjoinTable:    joinTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t\tforeignFK:    foreignFK,\n\t\taddJoinsFunc: addJoinsFunc,\n\t}\n}\n\nfunc (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t\tjoinTable:    videoCaptionsTable,\n\t\tstringColumn: captionCodeColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tqb.addSceneFilesTable(f)\n\t\t\tf.addLeftJoin(videoCaptionsTable, \"\", \"video_captions.file_id = scenes_files.file_id\")\n\t\t},\n\t\texcludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {\n\t\t\texcludeClause := `scenes.id NOT IN (\n\t\t\t\tSELECT scenes_files.scene_id from scenes_files \n\t\t\t\tINNER JOIN video_captions on video_captions.file_id = scenes_files.file_id \n\t\t\t\tWHERE video_captions.language_code LIKE ?\n\t\t\t)`\n\t\t\tf.addWhere(excludeClause, criterion.Value)\n\n\t\t\t// TODO - should we also exclude null values?\n\t\t},\n\t}\n\n\treturn h.handler(captions)\n}\n\nfunc (qb *sceneFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\th := joinedHierarchicalMultiCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tforeignTable: tagTable,\n\t\tforeignFK:    \"tag_id\",\n\n\t\trelationsTable: \"tags_relations\",\n\t\tjoinAs:         \"scene_tag\",\n\t\tjoinTable:      scenesTagsTable,\n\t\tprimaryFK:      sceneIDColumn,\n\t}\n\n\treturn h.handler(tags)\n}\n\nfunc (qb *sceneFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tjoinTable:    scenesTagsTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t}\n\n\treturn h.handler(tagCount)\n}\n\nfunc (qb *sceneFilterHandler) stashIDCountCriterionHandler(stashIDCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tjoinTable:    \"scene_stash_ids\",\n\t\tprimaryFK:    sceneIDColumn,\n\t}\n\n\treturn h.handler(stashIDCount)\n}\n\nfunc (qb *sceneFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {\n\th := joinedMultiCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tjoinTable:    performersScenesTable,\n\t\tjoinAs:       \"performers_join\",\n\t\tprimaryFK:    sceneIDColumn,\n\t\tforeignFK:    performerIDColumn,\n\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tsceneRepository.performers.join(f, \"performers_join\", \"scenes.id\")\n\t\t},\n\t}\n\n\treturn h.handler(performers)\n}\n\nfunc (qb *sceneFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tjoinTable:    performersScenesTable,\n\t\tprimaryFK:    sceneIDColumn,\n\t}\n\n\treturn h.handler(performerCount)\n}\n\nfunc (qb *sceneFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performerfavorite != nil {\n\t\t\tf.addLeftJoin(\"performers_scenes\", \"\", \"scenes.id = performers_scenes.scene_id\")\n\n\t\t\tif *performerfavorite {\n\t\t\t\t// contains at least one favorite\n\t\t\t\tf.addLeftJoin(\"performers\", \"\", \"performers.id = performers_scenes.performer_id\")\n\t\t\t\tf.addWhere(\"performers.favorite = 1\")\n\t\t\t} else {\n\t\t\t\t// contains zero favorites\n\t\t\t\tf.addLeftJoin(`(SELECT performers_scenes.scene_id as id FROM performers_scenes\nJOIN performers ON performers.id = performers_scenes.performer_id\nGROUP BY performers_scenes.scene_id HAVING SUM(performers.favorite) = 0)`, \"nofaves\", \"scenes.id = nofaves.id\")\n\t\t\t\tf.addWhere(\"performers_scenes.scene_id IS NULL OR nofaves.id IS NOT NULL\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performerAge != nil {\n\t\t\tf.addInnerJoin(\"performers_scenes\", \"\", \"scenes.id = performers_scenes.scene_id\")\n\t\t\tf.addInnerJoin(\"performers\", \"\", \"performers_scenes.performer_id = performers.id\")\n\n\t\t\tf.addWhere(\"scenes.date != '' AND performers.birthdate != ''\")\n\t\t\tf.addWhere(\"scenes.date IS NOT NULL AND performers.birthdate IS NOT NULL\")\n\n\t\t\tageCalc := \"cast(strftime('%Y.%m%d', scenes.date) - strftime('%Y.%m%d', performers.birthdate) as int)\"\n\t\t\twhereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2)\n\t\t\tf.addWhere(whereClause, args...)\n\t\t}\n\t}\n}\n\n// legacy handler\nfunc (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc {\n\taddJoinsFunc := func(f *filterBuilder) {\n\t\tsceneRepository.groups.join(f, \"\", \"scenes.id\")\n\t\tf.addLeftJoin(\"groups\", \"\", \"groups_scenes.group_id = groups.id\")\n\t}\n\th := qb.getMultiCriterionHandlerBuilder(groupTable, groupsScenesTable, \"group_id\", addJoinsFunc)\n\treturn h.handler(movies)\n}\n\nfunc (qb *sceneFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\th := joinedHierarchicalMultiCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tforeignTable: groupTable,\n\t\tforeignFK:    \"group_id\",\n\n\t\trelationsTable: groupRelationsTable,\n\t\tparentFK:       \"containing_id\",\n\t\tchildFK:        \"sub_id\",\n\t\tjoinAs:         \"scene_group\",\n\t\tjoinTable:      groupsScenesTable,\n\t\tprimaryFK:      sceneIDColumn,\n\t}\n\n\treturn h.handler(groups)\n}\n\nfunc (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc {\n\taddJoinsFunc := func(f *filterBuilder) {\n\t\tsceneRepository.galleries.join(f, \"\", \"scenes.id\")\n\t\tf.addLeftJoin(\"galleries\", \"\", \"scenes_galleries.gallery_id = galleries.id\")\n\t}\n\th := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, \"gallery_id\", addJoinsFunc)\n\treturn h.handler(galleries)\n}\n\nfunc (qb *sceneFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler {\n\treturn &joinedPerformerTagsHandler{\n\t\tcriterion:      tags,\n\t\tprimaryTable:   sceneTable,\n\t\tjoinTable:      performersScenesTable,\n\t\tjoinPrimaryKey: sceneIDColumn,\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/scene_marker.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"gopkg.in/guregu/null.v4\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst (\n\tsceneMarkerTable      = \"scene_markers\"\n\tsceneMarkersTagsTable = \"scene_markers_tags\"\n\tsceneMarkerIDColumn   = \"scene_marker_id\"\n)\n\nconst countSceneMarkersForTagQuery = `\nSELECT scene_markers.id FROM scene_markers\nLEFT JOIN scene_markers_tags as tags_join on tags_join.scene_marker_id = scene_markers.id\nWHERE tags_join.tag_id = ? OR scene_markers.primary_tag_id = ?\nGROUP BY scene_markers.id\n`\n\ntype sceneMarkerRow struct {\n\tID           int        `db:\"id\" goqu:\"skipinsert\"`\n\tTitle        string     `db:\"title\"` // TODO: make db schema (and gql schema) nullable\n\tSeconds      float64    `db:\"seconds\"`\n\tPrimaryTagID int        `db:\"primary_tag_id\"`\n\tSceneID      int        `db:\"scene_id\"`\n\tCreatedAt    Timestamp  `db:\"created_at\"`\n\tUpdatedAt    Timestamp  `db:\"updated_at\"`\n\tEndSeconds   null.Float `db:\"end_seconds\"`\n}\n\nfunc (r *sceneMarkerRow) fromSceneMarker(o models.SceneMarker) {\n\tr.ID = o.ID\n\tr.Title = o.Title\n\tr.Seconds = o.Seconds\n\tif o.EndSeconds != nil {\n\t\tr.EndSeconds = null.FloatFrom(*o.EndSeconds)\n\t}\n\tr.PrimaryTagID = o.PrimaryTagID\n\tr.SceneID = o.SceneID\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n}\n\nfunc (r *sceneMarkerRow) resolve() *models.SceneMarker {\n\tret := &models.SceneMarker{\n\t\tID:           r.ID,\n\t\tTitle:        r.Title,\n\t\tSeconds:      r.Seconds,\n\t\tEndSeconds:   r.EndSeconds.Ptr(),\n\t\tPrimaryTagID: r.PrimaryTagID,\n\t\tSceneID:      r.SceneID,\n\t\tCreatedAt:    r.CreatedAt.Timestamp,\n\t\tUpdatedAt:    r.UpdatedAt.Timestamp,\n\t}\n\n\treturn ret\n}\n\ntype sceneMarkerRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) {\n\t// TODO: replace with setNullString after schema is made nullable\n\t// r.setNullString(\"title\", o.Title)\n\t// saves a null input as the empty string\n\tif o.Title.Set {\n\t\tr.set(\"title\", o.Title.Value)\n\t}\n\tr.setFloat64(\"seconds\", o.Seconds)\n\tr.setNullFloat64(\"end_seconds\", o.EndSeconds)\n\tr.setInt(\"primary_tag_id\", o.PrimaryTagID)\n\tr.setInt(\"scene_id\", o.SceneID)\n\tr.setTimestamp(\"created_at\", o.CreatedAt)\n\tr.setTimestamp(\"updated_at\", o.UpdatedAt)\n}\n\ntype sceneMarkerRepositoryType struct {\n\trepository\n\n\tscenes repository\n\ttags   joinRepository\n}\n\nvar (\n\tsceneMarkerRepository = sceneMarkerRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: sceneMarkerTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\tscenes: repository{\n\t\t\ttableName: sceneTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\ttags: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: sceneMarkersTagsTable,\n\t\t\t\tidColumn:  sceneMarkerIDColumn,\n\t\t\t},\n\t\t\tfkColumn: tagIDColumn,\n\t\t},\n\t}\n)\n\ntype SceneMarkerStore struct{}\n\nfunc NewSceneMarkerStore() *SceneMarkerStore {\n\treturn &SceneMarkerStore{}\n}\n\nfunc (qb *SceneMarkerStore) table() exp.IdentifierExpression {\n\treturn sceneMarkerTableMgr.table\n}\n\nfunc (qb *SceneMarkerStore) selectDataset() *goqu.SelectDataset {\n\treturn dialect.From(qb.table()).Select(qb.table().All())\n}\n\nfunc (qb *SceneMarkerStore) Create(ctx context.Context, newObject *models.SceneMarker) error {\n\tvar r sceneMarkerRow\n\tr.fromSceneMarker(*newObject)\n\n\tid, err := sceneMarkerTableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject = *updated\n\n\treturn nil\n}\n\nfunc (qb *SceneMarkerStore) UpdatePartial(ctx context.Context, id int, partial models.SceneMarkerPartial) (*models.SceneMarker, error) {\n\tr := sceneMarkerRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(partial)\n\n\tif len(r.Record) > 0 {\n\t\tif err := sceneMarkerTableMgr.updateByID(ctx, id, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.TagIDs != nil {\n\t\tif err := sceneMarkersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"modifying scene marker tags: %w\", err)\n\t\t}\n\t}\n\n\treturn qb.find(ctx, id)\n}\n\nfunc (qb *SceneMarkerStore) Update(ctx context.Context, updatedObject *models.SceneMarker) error {\n\tvar r sceneMarkerRow\n\tr.fromSceneMarker(*updatedObject)\n\n\tif err := sceneMarkerTableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *SceneMarkerStore) Destroy(ctx context.Context, id int) error {\n\treturn sceneMarkerRepository.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *SceneMarkerStore) Find(ctx context.Context, id int) (*models.SceneMarker, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *SceneMarkerStore) FindMany(ctx context.Context, ids []int) ([]*models.SceneMarker, error) {\n\tret := make([]*models.SceneMarker, len(ids))\n\n\ttable := qb.table()\n\tq := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(ids))\n\tunsorted, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, s := range unsorted {\n\t\ti := slices.Index(ids, s.ID)\n\t\tret[i] = s\n\t}\n\n\tfor i := range ret {\n\t\tif ret[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"scene marker with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *SceneMarkerStore) find(ctx context.Context, id int) (*models.SceneMarker, error) {\n\tq := qb.selectDataset().Where(sceneMarkerTableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *SceneMarkerStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.SceneMarker, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *SceneMarkerStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.SceneMarker, error) {\n\tconst single = false\n\tvar ret []*models.SceneMarker\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f sceneMarkerRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneMarkerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error) {\n\tquery := `\n\t\tSELECT scene_markers.* FROM scene_markers\n\t\tWHERE scene_markers.scene_id = ?\n\t\tGROUP BY scene_markers.id\n\t\tORDER BY scene_markers.seconds ASC\n\t`\n\targs := []interface{}{sceneID}\n\treturn qb.querySceneMarkers(ctx, query, args)\n}\n\nfunc (qb *SceneMarkerStore) CountByTagID(ctx context.Context, tagID int) (int, error) {\n\targs := []interface{}{tagID, tagID}\n\treturn sceneMarkerRepository.runCountQuery(ctx, sceneMarkerRepository.buildCountQuery(countSceneMarkersForTagQuery), args)\n}\n\nfunc (qb *SceneMarkerStore) GetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*models.MarkerStringsResultType, error) {\n\tquery := \"SELECT count(*) as `count`, scene_markers.id as id, scene_markers.title as title FROM scene_markers\"\n\tif q != nil {\n\t\tquery += \" WHERE title LIKE '%\" + *q + \"%'\"\n\t}\n\tquery += \" GROUP BY title\"\n\tif sort != nil && *sort == \"count\" {\n\t\tquery += \" ORDER BY `count` DESC\"\n\t} else {\n\t\tquery += \" ORDER BY title ASC\"\n\t}\n\tvar args []interface{}\n\treturn qb.queryMarkerStringsResultType(ctx, query, args)\n}\n\nfunc (qb *SceneMarkerStore) Wall(ctx context.Context, q *string) ([]*models.SceneMarker, error) {\n\ts := \"\"\n\tif q != nil {\n\t\ts = *q\n\t}\n\n\ttable := qb.table()\n\tqq := qb.selectDataset().Prepared(true).Where(table.Col(\"title\").Like(\"%\" + s + \"%\")).Order(goqu.L(\"RANDOM()\").Asc()).Limit(80)\n\treturn qb.getMany(ctx, qq)\n}\n\nfunc (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {\n\tif sceneMarkerFilter == nil {\n\t\tsceneMarkerFilter = &models.SceneMarkerFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := sceneMarkerRepository.newQuery()\n\tdistinctIDs(&query, sceneMarkerTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tquery.join(sceneTable, \"\", \"scenes.id = scene_markers.scene_id\")\n\t\tquery.join(tagTable, \"\", \"scene_markers.primary_tag_id = tags.id\")\n\t\tsearchColumns := []string{\"scene_markers.title\", \"scenes.title\", \"tags.name\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &sceneMarkerFilterHandler{\n\t\tsceneMarkerFilter: sceneMarkerFilter,\n\t})\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := qb.setSceneMarkerSort(&query, findFilter); err != nil {\n\t\treturn nil, err\n\t}\n\tquery.sortAndPagination += getPagination(findFilter)\n\n\treturn &query, nil\n}\n\nfunc (qb *SceneMarkerStore) Query(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) ([]*models.SceneMarker, int, error) {\n\tquery, err := qb.makeQuery(ctx, sceneMarkerFilter, findFilter)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tidsResult, countResult, err := query.executeFind(ctx)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tsceneMarkers, err := qb.FindMany(ctx, idsResult)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn sceneMarkers, countResult, nil\n}\n\nfunc (qb *SceneMarkerStore) QueryCount(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (int, error) {\n\tquery, err := qb.makeQuery(ctx, sceneMarkerFilter, findFilter)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn query.executeCount(ctx)\n}\n\nvar sceneMarkerSortOptions = sortOptions{\n\t\"created_at\",\n\t\"id\",\n\t\"title\",\n\t\"random\",\n\t\"scene_id\",\n\t\"scenes_updated_at\",\n\t\"seconds\",\n\t\"updated_at\",\n\t\"duration\",\n}\n\nfunc (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter *models.FindFilterType) error {\n\tsort := findFilter.GetSort(\"title\")\n\tdirection := findFilter.GetDirection()\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := sceneMarkerSortOptions.validateSort(sort); err != nil {\n\t\treturn err\n\t}\n\n\tswitch sort {\n\tcase \"scenes_updated_at\":\n\t\tsort = \"updated_at\"\n\t\tquery.joinSort(sceneTable, \"\", \"scenes.id = scene_markers.scene_id\")\n\t\tquery.sortAndPagination += getSort(sort, direction, sceneTable)\n\tcase \"title\":\n\t\tquery.joinSort(tagTable, \"\", \"scene_markers.primary_tag_id = tags.id\")\n\t\tquery.sortAndPagination += \" ORDER BY COALESCE(NULLIF(scene_markers.title,''), tags.name) COLLATE NATURAL_CI \" + direction\n\tcase \"duration\":\n\t\tsort = \"(scene_markers.end_seconds - scene_markers.seconds)\"\n\t\tquery.sortAndPagination += getSort(sort, direction, sceneMarkerTable)\n\tdefault:\n\t\tquery.sortAndPagination += getSort(sort, direction, sceneMarkerTable)\n\t}\n\n\tquery.sortAndPagination += \", scene_markers.scene_id ASC, scene_markers.seconds ASC\"\n\treturn nil\n}\n\nfunc (qb *SceneMarkerStore) querySceneMarkers(ctx context.Context, query string, args []interface{}) ([]*models.SceneMarker, error) {\n\tconst single = false\n\tvar ret []*models.SceneMarker\n\tif err := sceneMarkerRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error {\n\t\tvar f sceneMarkerRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *SceneMarkerStore) queryMarkerStringsResultType(ctx context.Context, query string, args []interface{}) ([]*models.MarkerStringsResultType, error) {\n\trows, err := dbWrapper.Queryx(ctx, query, args...)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tmarkerStrings := make([]*models.MarkerStringsResultType, 0)\n\tfor rows.Next() {\n\t\tmarkerString := models.MarkerStringsResultType{}\n\t\tif err := rows.StructScan(&markerString); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tmarkerStrings = append(markerStrings, &markerString)\n\t}\n\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn markerStrings, nil\n}\n\nfunc (qb *SceneMarkerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {\n\treturn sceneMarkerRepository.tags.getIDs(ctx, id)\n}\n\nfunc (qb *SceneMarkerStore) UpdateTags(ctx context.Context, id int, tagIDs []int) error {\n\t// Delete the existing joins and then create new ones\n\treturn sceneMarkerRepository.tags.replace(ctx, id, tagIDs)\n}\n\nfunc (qb *SceneMarkerStore) Count(ctx context.Context) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(qb.table())\n\treturn count(ctx, q)\n}\n\nfunc (qb *SceneMarkerStore) All(ctx context.Context) ([]*models.SceneMarker, error) {\n\treturn qb.getMany(ctx, qb.selectDataset())\n}\n"
  },
  {
    "path": "pkg/sqlite/scene_marker_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype sceneMarkerFilterHandler struct {\n\tsceneMarkerFilter *models.SceneMarkerFilterType\n}\n\nfunc (qb *sceneMarkerFilterHandler) validate() error {\n\treturn nil\n}\n\nfunc (qb *sceneMarkerFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tsceneMarkerFilter := qb.sceneMarkerFilter\n\tif sceneMarkerFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *sceneMarkerFilterHandler) joinScenes(f *filterBuilder) {\n\tsceneMarkerRepository.scenes.innerJoin(f, \"\", \"scene_markers.scene_id\")\n}\n\nfunc (qb *sceneMarkerFilterHandler) criterionHandler() criterionHandler {\n\tsceneMarkerFilter := qb.sceneMarkerFilter\n\treturn compoundHandler{\n\t\tqb.tagIDCriterionHandler(sceneMarkerFilter.TagID),\n\t\tqb.tagsCriterionHandler(sceneMarkerFilter.Tags),\n\t\tqb.sceneTagsCriterionHandler(sceneMarkerFilter.SceneTags),\n\t\tqb.performersCriterionHandler(sceneMarkerFilter.Performers),\n\t\tqb.scenesCriterionHandler(sceneMarkerFilter.Scenes),\n\t\tfloatCriterionHandler(sceneMarkerFilter.Duration, \"COALESCE(scene_markers.end_seconds - scene_markers.seconds, NULL)\", nil),\n\t\t&timestampCriterionHandler{sceneMarkerFilter.CreatedAt, \"scene_markers.created_at\", nil},\n\t\t&timestampCriterionHandler{sceneMarkerFilter.UpdatedAt, \"scene_markers.updated_at\", nil},\n\t\t&dateCriterionHandler{sceneMarkerFilter.SceneDate, \"scenes.date\", qb.joinScenes},\n\t\t&timestampCriterionHandler{sceneMarkerFilter.SceneCreatedAt, \"scenes.created_at\", qb.joinScenes},\n\t\t&timestampCriterionHandler{sceneMarkerFilter.SceneUpdatedAt, \"scenes.updated_at\", qb.joinScenes},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scenes.id\",\n\t\t\trelatedRepo:    sceneRepository.repository,\n\t\t\trelatedHandler: &sceneFilterHandler{sceneMarkerFilter.SceneFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tqb.joinScenes(f)\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (qb *sceneMarkerFilterHandler) tagIDCriterionHandler(tagID *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif tagID != nil {\n\t\t\tf.addLeftJoin(\"scene_markers_tags\", \"\", \"scene_markers_tags.scene_marker_id = scene_markers.id\")\n\n\t\t\tf.addWhere(\"(scene_markers.primary_tag_id = ? OR scene_markers_tags.tag_id = ?)\", *tagID, *tagID)\n\t\t}\n\t}\n}\n\nfunc (qb *sceneMarkerFilterHandler) tagsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif criterion != nil {\n\t\t\ttags := criterion.CombineExcludes()\n\n\t\t\tif tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull {\n\t\t\t\tvar notClause string\n\t\t\t\tif tags.Modifier == models.CriterionModifierNotNull {\n\t\t\t\t\tnotClause = \"NOT\"\n\t\t\t\t}\n\n\t\t\t\tf.addLeftJoin(\"scene_markers_tags\", \"\", \"scene_markers.id = scene_markers_tags.scene_marker_id\")\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"%s scene_markers_tags.tag_id IS NULL\", notClause))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tags.Modifier == models.CriterionModifierEquals && tags.Depth != nil && *tags.Depth != 0 {\n\t\t\t\tf.setError(fmt.Errorf(\"depth is not supported for equals modifier for marker tag filtering\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(tags.Value) == 0 && len(tags.Excludes) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(tags.Value) > 0 {\n\t\t\t\tvaluesClause, err := getHierarchicalValues(ctx, tags.Value, tagTable, \"tags_relations\", \"parent_id\", \"child_id\", tags.Depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tf.addWith(`marker_tags AS (\n\tSELECT mt.scene_marker_id, t.column1 AS root_tag_id FROM scene_markers_tags mt\n\tINNER JOIN (` + valuesClause + `) t ON t.column2 = mt.tag_id\n\tUNION\n\tSELECT m.id, t.column1 FROM scene_markers m\n\tINNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id\n\t)`)\n\n\t\t\t\tf.addLeftJoin(\"marker_tags\", \"\", \"marker_tags.scene_marker_id = scene_markers.id\")\n\n\t\t\t\tswitch tags.Modifier {\n\t\t\t\tcase models.CriterionModifierEquals:\n\t\t\t\t\t// includes only the provided ids\n\t\t\t\t\tf.addWhere(\"marker_tags.root_tag_id IS NOT NULL\")\n\t\t\t\t\ttagsLen := len(tags.Value)\n\t\t\t\t\tf.addHaving(fmt.Sprintf(\"count(distinct marker_tags.root_tag_id) IS %d\", tagsLen))\n\t\t\t\t\t// decrement by one to account for primary tag id\n\t\t\t\t\tf.addWhere(\"(SELECT COUNT(*) FROM scene_markers_tags s WHERE s.scene_marker_id = scene_markers.id) = ?\", tagsLen-1)\n\t\t\t\tcase models.CriterionModifierNotEquals:\n\t\t\t\t\tf.setError(fmt.Errorf(\"not equals modifier is not supported for scene marker tags\"))\n\t\t\t\tdefault:\n\t\t\t\t\taddHierarchicalConditionClauses(f, tags, \"marker_tags\", \"root_tag_id\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(criterion.Excludes) > 0 {\n\t\t\t\tvaluesClause, err := getHierarchicalValues(ctx, tags.Excludes, tagTable, \"tags_relations\", \"parent_id\", \"child_id\", tags.Depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tclause := \"scene_markers.id NOT IN (SELECT scene_markers_tags.scene_marker_id FROM scene_markers_tags WHERE scene_markers_tags.tag_id IN (SELECT column2 FROM (%s)))\"\n\t\t\t\tf.addWhere(fmt.Sprintf(clause, valuesClause))\n\n\t\t\t\tf.addWhere(fmt.Sprintf(\"scene_markers.primary_tag_id NOT IN (SELECT column2 FROM (%s))\", valuesClause))\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *sceneMarkerFilterHandler) sceneTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif tags != nil {\n\t\t\tf.addLeftJoin(\"scenes_tags\", \"\", \"scene_markers.scene_id = scenes_tags.scene_id\")\n\n\t\t\th := joinedHierarchicalMultiCriterionHandlerBuilder{\n\t\t\t\tprimaryTable: \"scene_markers\",\n\t\t\t\tprimaryKey:   sceneIDColumn,\n\t\t\t\tforeignTable: tagTable,\n\t\t\t\tforeignFK:    tagIDColumn,\n\n\t\t\t\trelationsTable: \"tags_relations\",\n\t\t\t\tjoinTable:      \"scenes_tags\",\n\t\t\t\tjoinAs:         \"marker_scenes_tags\",\n\t\t\t\tprimaryFK:      sceneIDColumn,\n\t\t\t}\n\n\t\t\th.handler(tags).handle(ctx, f)\n\t\t}\n\t}\n}\n\nfunc (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {\n\th := joinedMultiCriterionHandlerBuilder{\n\t\tprimaryTable: sceneTable,\n\t\tjoinTable:    performersScenesTable,\n\t\tjoinAs:       \"performers_join\",\n\t\tprimaryFK:    sceneIDColumn,\n\t\tforeignFK:    performerIDColumn,\n\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tf.addLeftJoin(performersScenesTable, \"performers_join\", \"performers_join.scene_id = scene_markers.scene_id\")\n\t\t},\n\t}\n\n\thandler := h.handler(performers)\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performers == nil {\n\t\t\treturn\n\t\t}\n\n\t\t// Make sure scenes is included, otherwise excludes filter fails\n\t\tqb.joinScenes(f)\n\t\thandler(ctx, f)\n\t}\n}\n\nfunc (qb *sceneMarkerFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc {\n\taddJoinsFunc := func(f *filterBuilder) {\n\t\tf.addLeftJoin(sceneTable, \"markers_scenes\", \"markers_scenes.id = scene_markers.scene_id\")\n\t}\n\th := multiCriterionHandlerBuilder{\n\t\tprimaryTable: sceneMarkerTable,\n\t\tforeignTable: \"markers_scenes\",\n\t\tjoinTable:    \"\",\n\t\tprimaryFK:    sceneIDColumn,\n\t\tforeignFK:    sceneIDColumn,\n\t\taddJoinsFunc: addJoinsFunc,\n\t}\n\treturn h.handler(scenes)\n}\n"
  },
  {
    "path": "pkg/sqlite/scene_marker_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"slices\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestMarkerFindBySceneID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tmqb := db.SceneMarker\n\n\t\tsceneID := sceneIDs[sceneIdxWithMarkers]\n\t\tmarkers, err := mqb.FindBySceneID(ctx, sceneID)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding markers: %s\", err.Error())\n\t\t}\n\n\t\tassert.Greater(t, len(markers), 0)\n\t\tfor _, marker := range markers {\n\t\t\tassert.Equal(t, sceneIDs[sceneIdxWithMarkers], marker.SceneID)\n\t\t}\n\n\t\tmarkers, err = mqb.FindBySceneID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding marker: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, markers, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestMarkerCountByTagID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tmqb := db.SceneMarker\n\n\t\tmarkerCount, err := mqb.CountByTagID(ctx, tagIDs[tagIdxWithPrimaryMarkers])\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling CountByTagID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 6, markerCount)\n\n\t\tmarkerCount, err = mqb.CountByTagID(ctx, tagIDs[tagIdxWithMarkers])\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling CountByTagID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 2, markerCount)\n\n\t\tmarkerCount, err = mqb.CountByTagID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling CountByTagID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 0, markerCount)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestMarkerQueryQ(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tq := getSceneTitle(sceneIdxWithMarkers)\n\t\tm, _, err := db.SceneMarker.Query(ctx, nil, &models.FindFilterType{\n\t\t\tQ: &q,\n\t\t})\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying scene markers: %s\", err.Error())\n\t\t}\n\n\t\tif !assert.Greater(t, len(m), 0) {\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, sceneIDs[sceneIdxWithMarkers], m[0].SceneID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestMarkerQuerySortBySceneUpdated(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsort := \"scenes_updated_at\"\n\t\t_, _, err := db.SceneMarker.Query(ctx, nil, &models.FindFilterType{\n\t\t\tSort: &sort,\n\t\t})\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying scene markers: %s\", err.Error())\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyIDs(t *testing.T, modifier models.CriterionModifier, values []int, results []int) {\n\tt.Helper()\n\tswitch modifier {\n\tcase models.CriterionModifierIsNull:\n\t\tassert.Len(t, results, 0)\n\tcase models.CriterionModifierNotNull:\n\t\tassert.NotEqual(t, 0, len(results))\n\tcase models.CriterionModifierIncludes:\n\t\tfor _, v := range values {\n\t\t\tassert.Contains(t, results, v)\n\t\t}\n\tcase models.CriterionModifierExcludes:\n\t\tfor _, v := range values {\n\t\t\tassert.NotContains(t, results, v)\n\t\t}\n\tcase models.CriterionModifierEquals:\n\t\tfor _, v := range values {\n\t\t\tassert.Contains(t, results, v)\n\t\t}\n\t\tassert.Len(t, results, len(values))\n\tcase models.CriterionModifierNotEquals:\n\t\tfoundAll := true\n\t\tfor _, v := range values {\n\t\t\tif !slices.Contains(results, v) {\n\t\t\t\tfoundAll = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif foundAll && len(results) == len(values) {\n\t\t\tt.Errorf(\"expected ids not equal to %v - found %v\", values, results)\n\t\t}\n\t}\n}\n\nfunc TestMarkerQueryTags(t *testing.T) {\n\ttype test struct {\n\t\tname         string\n\t\tmarkerFilter *models.SceneMarkerFilterType\n\t\tfindFilter   *models.FindFilterType\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\ttestTags := func(t *testing.T, m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) {\n\t\t\ttagIDs, err := db.SceneMarker.GetTagIDs(ctx, m.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"error getting marker tag ids: %v\", err)\n\t\t\t}\n\n\t\t\t// HACK - if modifier isn't null/not null, then add the primary tag id\n\t\t\tif markerFilter.Tags.Modifier != models.CriterionModifierIsNull && markerFilter.Tags.Modifier != models.CriterionModifierNotNull {\n\t\t\t\ttagIDs = append(tagIDs, m.PrimaryTagID)\n\t\t\t}\n\n\t\t\tvalues, _ := stringslice.StringSliceToIntSlice(markerFilter.Tags.Value)\n\t\t\tverifyIDs(t, markerFilter.Tags.Modifier, values, tagIDs)\n\t\t}\n\n\t\tcases := []test{\n\t\t\t{\n\t\t\t\t\"is null\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"not null\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"includes\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue: []string{\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithMarkers]),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"includes all\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t\t\t\tValue: []string{\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithMarkers]),\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithMarkers]),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"equals\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue: []string{\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithPrimaryMarkers]),\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithMarkers]),\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithMarkers]),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t// not equals not supported\n\t\t\t// {\n\t\t\t// \t\"not equals\",\n\t\t\t// \t&models.SceneMarkerFilterType{\n\t\t\t// \t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t// \t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t// \t\t\tValue: []string{\n\t\t\t// \t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithScene]),\n\t\t\t// \t\t\t\tstrconv.Itoa(tagIDs[tagIdx3WithScene]),\n\t\t\t// \t\t\t},\n\t\t\t// \t\t},\n\t\t\t// \t},\n\t\t\t// \tnil,\n\t\t\t// },\n\t\t\t{\n\t\t\t\t\"excludes\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue: []string{\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithMarkers]),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t}\n\n\t\tfor _, tc := range cases {\n\t\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t\tmarkers := queryMarkers(ctx, t, db.SceneMarker, tc.markerFilter, tc.findFilter)\n\t\t\t\tassert.Greater(t, len(markers), 0)\n\t\t\t\tfor _, m := range markers {\n\t\t\t\t\ttestTags(t, m, tc.markerFilter)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestMarkerQuerySceneTags(t *testing.T) {\n\ttype test struct {\n\t\tname         string\n\t\tmarkerFilter *models.SceneMarkerFilterType\n\t\tfindFilter   *models.FindFilterType\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\ttestTags := func(t *testing.T, m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) {\n\t\t\ts, err := db.Scene.Find(ctx, m.SceneID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"error getting marker tag ids: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := s.LoadTagIDs(ctx, db.Scene); err != nil {\n\t\t\t\tt.Errorf(\"error getting marker tag ids: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttagIDs := s.TagIDs.List()\n\t\t\tvalues, _ := stringslice.StringSliceToIntSlice(markerFilter.SceneTags.Value)\n\t\t\tverifyIDs(t, markerFilter.SceneTags.Modifier, values, tagIDs)\n\t\t}\n\n\t\tcases := []test{\n\t\t\t{\n\t\t\t\t\"is null\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tSceneTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"not null\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tSceneTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"includes\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tSceneTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue: []string{\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx3WithScene]),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"includes all\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tSceneTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t\t\t\tValue: []string{\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithScene]),\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx3WithScene]),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"equals\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tSceneTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue: []string{\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithScene]),\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx3WithScene]),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t\t// not equals not supported\n\t\t\t// {\n\t\t\t// \t\"not equals\",\n\t\t\t// \t&models.SceneMarkerFilterType{\n\t\t\t// \t\tSceneTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t// \t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t// \t\t\tValue: []string{\n\t\t\t// \t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithScene]),\n\t\t\t// \t\t\t\tstrconv.Itoa(tagIDs[tagIdx3WithScene]),\n\t\t\t// \t\t\t},\n\t\t\t// \t\t},\n\t\t\t// \t},\n\t\t\t// \tnil,\n\t\t\t// },\n\t\t\t{\n\t\t\t\t\"excludes\",\n\t\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\t\tSceneTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue: []string{\n\t\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithScene]),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tnil,\n\t\t\t},\n\t\t}\n\n\t\tfor _, tc := range cases {\n\t\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t\tmarkers := queryMarkers(ctx, t, db.SceneMarker, tc.markerFilter, tc.findFilter)\n\t\t\t\tassert.Greater(t, len(markers), 0)\n\t\t\t\tfor _, m := range markers {\n\t\t\t\t\ttestTags(t, m, tc.markerFilter)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc markersToIDs(i []*models.SceneMarker) []int {\n\tret := make([]int, len(i))\n\tfor i, v := range i {\n\t\tret[i] = v.ID\n\t}\n\n\treturn ret\n}\n\nfunc TestMarkerQueryDuration(t *testing.T) {\n\ttype test struct {\n\t\tname         string\n\t\tmarkerFilter *models.SceneMarkerFilterType\n\t\tinclude      []int\n\t\texclude      []int\n\t}\n\n\tcases := []test{\n\t\t{\n\t\t\t\"is null\",\n\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\tDuration: &models.FloatCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{markerIdxWithScene},\n\t\t\t[]int{markerIdxWithDuration},\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\tDuration: &models.FloatCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{markerIdxWithDuration},\n\t\t\t[]int{markerIdxWithScene},\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\tDuration: &models.FloatCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue:    markerIdxWithDuration,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{markerIdxWithDuration},\n\t\t\t[]int{markerIdx2WithDuration, markerIdxWithScene},\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\tDuration: &models.FloatCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\tValue:    markerIdx2WithDuration,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{markerIdxWithDuration},\n\t\t\t[]int{markerIdx2WithDuration, markerIdxWithScene},\n\t\t},\n\t\t{\n\t\t\t\"greater than\",\n\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\tDuration: &models.FloatCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierGreaterThan,\n\t\t\t\t\tValue:    markerIdxWithDuration,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{markerIdx2WithDuration},\n\t\t\t[]int{markerIdxWithDuration, markerIdxWithScene},\n\t\t},\n\t\t{\n\t\t\t\"less than\",\n\t\t\t&models.SceneMarkerFilterType{\n\t\t\t\tDuration: &models.FloatCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierLessThan,\n\t\t\t\t\tValue:    markerIdx2WithDuration,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{markerIdxWithDuration},\n\t\t\t[]int{markerIdx2WithDuration, markerIdxWithScene},\n\t\t},\n\t}\n\n\tqb := db.SceneMarker\n\n\tfor _, tt := range cases {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, _, err := qb.Query(ctx, tt.markerFilter, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"SceneMarkerStore.Query() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := markersToIDs(got)\n\t\t\tinclude := indexesToIDs(markerIDs, tt.include)\n\t\t\texclude := indexesToIDs(markerIDs, tt.exclude)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n\n}\n\nfunc queryMarkers(ctx context.Context, t *testing.T, sqb models.SceneMarkerReader, markerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) []*models.SceneMarker {\n\tt.Helper()\n\tresult, _, err := sqb.Query(ctx, markerFilter, findFilter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying markers: %v\", err)\n\t}\n\n\treturn result\n}\n\n// TODO Update\n// TODO Destroy\n// TODO Find\n// TODO GetMarkerStrings\n// TODO Wall\n// TODO Count\n// TODO All\n// TODO Query\n"
  },
  {
    "path": "pkg/sqlite/scene_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/intslice\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc loadSceneRelationships(ctx context.Context, expected models.Scene, actual *models.Scene) error {\n\tif expected.URLs.Loaded() {\n\t\tif err := actual.LoadURLs(ctx, db.Scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif expected.GalleryIDs.Loaded() {\n\t\tif err := actual.LoadGalleryIDs(ctx, db.Scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.TagIDs.Loaded() {\n\t\tif err := actual.LoadTagIDs(ctx, db.Scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.PerformerIDs.Loaded() {\n\t\tif err := actual.LoadPerformerIDs(ctx, db.Scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.Groups.Loaded() {\n\t\tif err := actual.LoadGroups(ctx, db.Scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.StashIDs.Loaded() {\n\t\tif err := actual.LoadStashIDs(ctx, db.Scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.Files.Loaded() {\n\t\tif err := actual.LoadFiles(ctx, db.Scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// clear Path, Checksum, PrimaryFileID\n\tif expected.Path == \"\" {\n\t\tactual.Path = \"\"\n\t}\n\tif expected.Checksum == \"\" {\n\t\tactual.Checksum = \"\"\n\t}\n\tif expected.OSHash == \"\" {\n\t\tactual.OSHash = \"\"\n\t}\n\tif expected.PrimaryFileID == nil {\n\t\tactual.PrimaryFileID = nil\n\t}\n\n\treturn nil\n}\n\nfunc Test_sceneQueryBuilder_Create(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"1337\"\n\t\tdetails      = \"details\"\n\t\tdirector     = \"director\"\n\t\turl          = \"url\"\n\t\trating       = 60\n\t\tresumeTime   = 10.0\n\t\tplayDuration = 34.0\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tsceneIndex   = 123\n\t\tsceneIndex2  = 234\n\t\tendpoint1    = \"endpoint1\"\n\t\tendpoint2    = \"endpoint2\"\n\t\tstashID1     = \"stashid1\"\n\t\tstashID2     = \"stashid2\"\n\n\t\tdate, _ = models.ParseDate(\"2003-02-01\")\n\n\t\tvideoFile = makeFileWithID(fileIdxStartVideoFiles)\n\t)\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.Scene\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.Scene{\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tDetails:      details,\n\t\t\t\tDirector:     director,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tRating:       &rating,\n\t\t\t\tOrganized:    true,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithScene],\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}),\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithScene],\n\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithStudio],\n\t\t\t\t\t\tSceneIndex: &sceneIndex2,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tResumeTime:   float64(resumeTime),\n\t\t\t\tPlayDuration: playDuration,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with file\",\n\t\t\tmodels.Scene{\n\t\t\t\tTitle:     title,\n\t\t\t\tCode:      code,\n\t\t\t\tDetails:   details,\n\t\t\t\tDirector:  director,\n\t\t\t\tURLs:      models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:      &date,\n\t\t\t\tRating:    &rating,\n\t\t\t\tOrganized: true,\n\t\t\t\tStudioID:  &studioIDs[studioIdxWithScene],\n\t\t\t\tFiles: models.NewRelatedVideoFiles([]*models.VideoFile{\n\t\t\t\t\tvideoFile.(*models.VideoFile),\n\t\t\t\t}),\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}),\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithScene],\n\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithStudio],\n\t\t\t\t\t\tSceneIndex: &sceneIndex2,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tResumeTime:   resumeTime,\n\t\t\t\tPlayDuration: playDuration,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid studio id\",\n\t\t\tmodels.Scene{\n\t\t\t\tStudioID: &invalidID,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid gallery id\",\n\t\t\tmodels.Scene{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.Scene{\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid performer id\",\n\t\t\tmodels.Scene{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid group id\",\n\t\t\tmodels.Scene{\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    invalidID,\n\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tvar fileIDs []models.FileID\n\t\t\tif tt.newObject.Files.Loaded() {\n\t\t\t\tfor _, f := range tt.newObject.Files.List() {\n\t\t\t\t\tfileIDs = append(fileIDs, f.ID)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ts := tt.newObject\n\t\t\tif err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(s.ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(s.ID)\n\n\t\t\tcopy := tt.newObject\n\t\t\tcopy.ID = s.ID\n\n\t\t\t// load relationships\n\t\t\tif err := loadSceneRelationships(ctx, copy, &s); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, s)\n\n\t\t\t// ensure can find the scene\n\t\t\tfound, err := qb.Find(ctx, s.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tif !assert.NotNil(found) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadSceneRelationships(ctx, copy, found); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.Equal(copy, *found)\n\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc clearSceneFileIDs(scene *models.Scene) {\n\tif scene.Files.Loaded() {\n\t\tfor _, f := range scene.Files.List() {\n\t\t\tf.Base().ID = 0\n\t\t}\n\t}\n}\n\nfunc makeSceneFileWithID(i int) *models.VideoFile {\n\tret := makeSceneFile(i)\n\tret.ID = sceneFileIDs[i]\n\treturn ret\n}\n\nfunc Test_sceneQueryBuilder_Update(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"1337\"\n\t\tdetails      = \"details\"\n\t\tdirector     = \"director\"\n\t\turl          = \"url\"\n\t\trating       = 60\n\t\tresumeTime   = 10.0\n\t\tplayDuration = 34.0\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tsceneIndex   = 123\n\t\tsceneIndex2  = 234\n\t\tendpoint1    = \"endpoint1\"\n\t\tendpoint2    = \"endpoint2\"\n\t\tstashID1     = \"stashid1\"\n\t\tstashID2     = \"stashid2\"\n\n\t\tdate, _ = models.ParseDate(\"2003-02-01\")\n\t)\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject *models.Scene\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\t&models.Scene{\n\t\t\t\tID:           sceneIDs[sceneIdxWithGallery],\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tDetails:      details,\n\t\t\t\tDirector:     director,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tRating:       &rating,\n\t\t\t\tOrganized:    true,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithScene],\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}),\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithScene],\n\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithStudio],\n\t\t\t\t\t\tSceneIndex: &sceneIndex2,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tResumeTime:   resumeTime,\n\t\t\t\tPlayDuration: playDuration,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear nullables\",\n\t\t\t&models.Scene{\n\t\t\t\tID:           sceneIDs[sceneIdxWithSpacedName],\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tGroups:       models.NewRelatedGroups([]models.GroupsScenes{}),\n\t\t\t\tStashIDs:     models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear gallery ids\",\n\t\t\t&models.Scene{\n\t\t\t\tID:         sceneIDs[sceneIdxWithGallery],\n\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear tag ids\",\n\t\t\t&models.Scene{\n\t\t\t\tID:     sceneIDs[sceneIdxWithTag],\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear performer ids\",\n\t\t\t&models.Scene{\n\t\t\t\tID:           sceneIDs[sceneIdxWithPerformer],\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear groups\",\n\t\t\t&models.Scene{\n\t\t\t\tID:     sceneIDs[sceneIdxWithGroup],\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid studio id\",\n\t\t\t&models.Scene{\n\t\t\t\tID:       sceneIDs[sceneIdxWithGallery],\n\t\t\t\tStudioID: &invalidID,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid gallery id\",\n\t\t\t&models.Scene{\n\t\t\t\tID:         sceneIDs[sceneIdxWithGallery],\n\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\t&models.Scene{\n\t\t\t\tID:     sceneIDs[sceneIdxWithGallery],\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid performer id\",\n\t\t\t&models.Scene{\n\t\t\t\tID:           sceneIDs[sceneIdxWithGallery],\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid group id\",\n\t\t\t&models.Scene{\n\t\t\t\tID: sceneIDs[sceneIdxWithSpacedName],\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    invalidID,\n\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tcopy := *tt.updatedObject\n\n\t\t\tif err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.updatedObject.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadSceneRelationships(ctx, copy, s); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *s)\n\t\t})\n\t}\n}\n\nfunc clearScenePartial() models.ScenePartial {\n\t// leave mandatory fields\n\treturn models.ScenePartial{\n\t\tTitle:        models.OptionalString{Set: true, Null: true},\n\t\tCode:         models.OptionalString{Set: true, Null: true},\n\t\tDetails:      models.OptionalString{Set: true, Null: true},\n\t\tDirector:     models.OptionalString{Set: true, Null: true},\n\t\tURLs:         &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},\n\t\tDate:         models.OptionalDate{Set: true, Null: true},\n\t\tRating:       models.OptionalInt{Set: true, Null: true},\n\t\tStudioID:     models.OptionalInt{Set: true, Null: true},\n\t\tGalleryIDs:   &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t\tTagIDs:       &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t\tPerformerIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t\tStashIDs:     &models.UpdateStashIDs{Mode: models.RelationshipUpdateModeSet},\n\t}\n}\n\nfunc Test_sceneQueryBuilder_UpdatePartial(t *testing.T) {\n\tvar (\n\t\ttitle        = \"title\"\n\t\tcode         = \"1337\"\n\t\tdetails      = \"details\"\n\t\tdirector     = \"director\"\n\t\turl          = \"url\"\n\t\trating       = 60\n\t\tresumeTime   = 10.0\n\t\tplayDuration = 34.0\n\t\tcreatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt    = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tsceneIndex   = 123\n\t\tsceneIndex2  = 234\n\t\tendpoint1    = \"endpoint1\"\n\t\tendpoint2    = \"endpoint2\"\n\t\tstashID1     = \"stashid1\"\n\t\tstashID2     = \"stashid2\"\n\n\t\tdate, _ = models.ParseDate(\"2003-02-01\")\n\t)\n\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.ScenePartial\n\t\twant    models.Scene\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tsceneIDs[sceneIdxWithSpacedName],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tTitle:    models.NewOptionalString(title),\n\t\t\t\tCode:     models.NewOptionalString(code),\n\t\t\t\tDetails:  models.NewOptionalString(details),\n\t\t\t\tDirector: models.NewOptionalString(director),\n\t\t\t\tURLs: &models.UpdateStrings{\n\t\t\t\t\tValues: []string{url},\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tDate:      models.NewOptionalDate(date),\n\t\t\t\tRating:    models.NewOptionalInt(rating),\n\t\t\t\tOrganized: models.NewOptionalBool(true),\n\t\t\t\tStudioID:  models.NewOptionalInt(studioIDs[studioIdxWithScene]),\n\t\t\t\tCreatedAt: models.NewOptionalTime(createdAt),\n\t\t\t\tUpdatedAt: models.NewOptionalTime(updatedAt),\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdxWithScene]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithScene], tagIDs[tagIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tGroupIDs: &models.UpdateGroupIDs{\n\t\t\t\t\tGroups: []models.GroupsScenes{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithScene],\n\t\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithStudio],\n\t\t\t\t\t\t\tSceneIndex: &sceneIndex2,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\tStashIDs: []models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tResumeTime:   models.NewOptionalFloat64(resumeTime),\n\t\t\t\tPlayDuration: models.NewOptionalFloat64(playDuration),\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tID: sceneIDs[sceneIdxWithSpacedName],\n\t\t\t\tFiles: models.NewRelatedVideoFiles([]*models.VideoFile{\n\t\t\t\t\tmakeSceneFile(sceneIdxWithSpacedName),\n\t\t\t\t}),\n\t\t\t\tTitle:        title,\n\t\t\t\tCode:         code,\n\t\t\t\tDetails:      details,\n\t\t\t\tDirector:     director,\n\t\t\t\tURLs:         models.NewRelatedStrings([]string{url}),\n\t\t\t\tDate:         &date,\n\t\t\t\tRating:       &rating,\n\t\t\t\tOrganized:    true,\n\t\t\t\tStudioID:     &studioIDs[studioIdxWithScene],\n\t\t\t\tCreatedAt:    createdAt,\n\t\t\t\tUpdatedAt:    updatedAt,\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}),\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithScene],\n\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithStudio],\n\t\t\t\t\t\tSceneIndex: &sceneIndex2,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tResumeTime:   resumeTime,\n\t\t\t\tPlayDuration: playDuration,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear all\",\n\t\t\tsceneIDs[sceneIdxWithSpacedName],\n\t\t\tclearScenePartial(),\n\t\t\tmodels.Scene{\n\t\t\t\tID: sceneIDs[sceneIdxWithSpacedName],\n\t\t\t\tFiles: models.NewRelatedVideoFiles([]*models.VideoFile{\n\t\t\t\t\tmakeSceneFile(sceneIdxWithSpacedName),\n\t\t\t\t}),\n\t\t\t\tGalleryIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\tTagIDs:       models.NewRelatedIDs([]int{}),\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\tGroups:       models.NewRelatedGroups([]models.GroupsScenes{}),\n\t\t\t\tStashIDs:     models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t\tPlayDuration: getScenePlayDuration(sceneIdxWithSpacedName),\n\t\t\t\tResumeTime:   getSceneResumeTime(sceneIdxWithSpacedName),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid id\",\n\t\t\tinvalidID,\n\t\t\tmodels.ScenePartial{},\n\t\t\tmodels.Scene{},\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Scene\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadSceneRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ignore file ids\n\t\t\tclearSceneFileIDs(got)\n\n\t\t\tassert.Equal(tt.want, *got)\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadSceneRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// ignore file ids\n\t\t\tclearSceneFileIDs(s)\n\n\t\t\tassert.Equal(tt.want, *s)\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) {\n\tvar (\n\t\tsceneIndex  = 123\n\t\tsceneIndex2 = 234\n\t\tendpoint1   = \"endpoint1\"\n\t\tendpoint2   = \"endpoint2\"\n\t\tstashID1    = \"stashid1\"\n\t\tstashID2    = \"stashid2\"\n\n\t\tgroupScenes = []models.GroupsScenes{\n\t\t\t{\n\t\t\t\tGroupID:    groupIDs[groupIdxWithDupName],\n\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t},\n\t\t\t{\n\t\t\t\tGroupID:    groupIDs[groupIdxWithStudio],\n\t\t\t\tSceneIndex: &sceneIndex2,\n\t\t\t},\n\t\t}\n\n\t\tstashIDs = []models.StashID{\n\t\t\t{\n\t\t\t\tStashID:   stashID1,\n\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\tUpdatedAt: epochTime,\n\t\t\t},\n\t\t\t{\n\t\t\t\tStashID:   stashID2,\n\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\tUpdatedAt: epochTime,\n\t\t\t},\n\t\t}\n\t)\n\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.ScenePartial\n\t\twant    models.Scene\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"add galleries\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdx1WithImage], galleryIDs[galleryIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, sceneGalleries[sceneIdxWithGallery]),\n\t\t\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t\t\tgalleryIDs[galleryIdx1WithPerformer],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add identical galleries\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdx1WithImage], galleryIDs[galleryIdx1WithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, sceneGalleries[sceneIdxWithGallery]),\n\t\t\t\t\tgalleryIDs[galleryIdx1WithImage],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add tags\",\n\t\t\tsceneIDs[sceneIdxWithTwoTags],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tTagIDs: models.NewRelatedIDs(append(\n\t\t\t\t\t[]int{\n\t\t\t\t\t\ttagIDs[tagIdx1WithGallery],\n\t\t\t\t\t\ttagIDs[tagIdx1WithDupName],\n\t\t\t\t\t},\n\t\t\t\t\tindexesToIDs(tagIDs, sceneTags[sceneIdxWithTwoTags])...,\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add identical tags\",\n\t\t\tsceneIDs[sceneIdxWithTwoTags],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tTagIDs: models.NewRelatedIDs(append(\n\t\t\t\t\t[]int{\n\t\t\t\t\t\ttagIDs[tagIdx1WithDupName],\n\t\t\t\t\t},\n\t\t\t\t\tindexesToIDs(tagIDs, sceneTags[sceneIdxWithTwoTags])...,\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add performers\",\n\t\t\tsceneIDs[sceneIdxWithTwoPerformers],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, scenePerformers[sceneIdxWithTwoPerformers]),\n\t\t\t\t\tperformerIDs[performerIdx1WithDupName],\n\t\t\t\t\tperformerIDs[performerIdx1WithGallery],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add identical performers\",\n\t\t\tsceneIDs[sceneIdxWithTwoPerformers],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithDupName], performerIDs[performerIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, scenePerformers[sceneIdxWithTwoPerformers]),\n\t\t\t\t\tperformerIDs[performerIdx1WithDupName],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add groups\",\n\t\t\tsceneIDs[sceneIdxWithGroup],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGroupIDs: &models.UpdateGroupIDs{\n\t\t\t\t\tGroups: groupScenes,\n\t\t\t\t\tMode:   models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGroups: models.NewRelatedGroups(append([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0],\n\t\t\t\t\t},\n\t\t\t\t}, groupScenes...)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add groups to empty\",\n\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGroupIDs: &models.UpdateGroupIDs{\n\t\t\t\t\tGroups: groupScenes,\n\t\t\t\t\tMode:   models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithDupName],\n\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithStudio],\n\t\t\t\t\t\tSceneIndex: &sceneIndex2,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add stash ids\",\n\t\t\tsceneIDs[sceneIdxWithSpacedName],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\tMode:     models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tStashIDs: models.NewRelatedStashIDs(append([]models.StashID{sceneStashID(sceneIdxWithSpacedName)}, stashIDs...)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate galleries\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdxWithScene], galleryIDs[galleryIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs(append(indexesToIDs(galleryIDs, sceneGalleries[sceneIdxWithGallery]),\n\t\t\t\t\tgalleryIDs[galleryIdx1WithPerformer],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate tags\",\n\t\t\tsceneIDs[sceneIdxWithTwoTags],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithScene], tagIDs[tagIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tTagIDs: models.NewRelatedIDs(append(\n\t\t\t\t\t[]int{tagIDs[tagIdx1WithGallery]},\n\t\t\t\t\tindexesToIDs(tagIDs, sceneTags[sceneIdxWithTwoTags])...,\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate performers\",\n\t\t\tsceneIDs[sceneIdxWithTwoPerformers],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithGallery]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(append(indexesToIDs(performerIDs, scenePerformers[sceneIdxWithTwoPerformers]),\n\t\t\t\t\tperformerIDs[performerIdx1WithGallery],\n\t\t\t\t)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate groups\",\n\t\t\tsceneIDs[sceneIdxWithGroup],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGroupIDs: &models.UpdateGroupIDs{\n\t\t\t\t\tGroups: append([]models.GroupsScenes{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tGroupID:    groupIDs[groupIdxWithScene],\n\t\t\t\t\t\t\tSceneIndex: &sceneIndex,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\tgroupScenes...,\n\t\t\t\t\t),\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGroups: models.NewRelatedGroups(append([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0],\n\t\t\t\t\t},\n\t\t\t\t}, groupScenes...)),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add duplicate stash ids\",\n\t\t\tsceneIDs[sceneIdxWithSpacedName],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\tStashIDs: []models.StashID{\n\t\t\t\t\t\tsceneStashID(sceneIdxWithSpacedName),\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{sceneStashID(sceneIdxWithSpacedName)}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"add invalid galleries\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"add invalid tags\",\n\t\t\tsceneIDs[sceneIdxWithTwoTags],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"add invalid performers\",\n\t\t\tsceneIDs[sceneIdxWithTwoPerformers],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{invalidID},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"add invalid groups\",\n\t\t\tsceneIDs[sceneIdxWithGroup],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGroupIDs: &models.UpdateGroupIDs{\n\t\t\t\t\tGroups: []models.GroupsScenes{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tGroupID: invalidID,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"remove galleries\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdxWithScene]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove tags\",\n\t\t\tsceneIDs[sceneIdxWithTwoTags],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithScene]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tTagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx2WithScene]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove performers\",\n\t\t\tsceneIDs[sceneIdxWithTwoPerformers],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithScene]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx2WithScene]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove groups\",\n\t\t\tsceneIDs[sceneIdxWithGroup],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGroupIDs: &models.UpdateGroupIDs{\n\t\t\t\t\tGroups: []models.GroupsScenes{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tGroupID: groupIDs[groupIdxWithScene],\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove stash ids\",\n\t\t\tsceneIDs[sceneIdxWithSpacedName],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\tStashIDs: []models.StashID{sceneStashID(sceneIdxWithSpacedName)},\n\t\t\t\t\tMode:     models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated galleries\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGalleryIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{galleryIDs[galleryIdx1WithImage]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated tags\",\n\t\t\tsceneIDs[sceneIdxWithTwoTags],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithPerformer]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tTagIDs: models.NewRelatedIDs(indexesToIDs(tagIDs, sceneTags[sceneIdxWithTwoTags])),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated performers\",\n\t\t\tsceneIDs[sceneIdxWithTwoPerformers],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tPerformerIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{performerIDs[performerIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tPerformerIDs: models.NewRelatedIDs(indexesToIDs(performerIDs, scenePerformers[sceneIdxWithTwoPerformers])),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated groups\",\n\t\t\tsceneIDs[sceneIdxWithGroup],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tGroupIDs: &models.UpdateGroupIDs{\n\t\t\t\t\tGroups: []models.GroupsScenes{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tGroupID: groupIDs[groupIdxWithDupName],\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tGroups: models.NewRelatedGroups([]models.GroupsScenes{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0],\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"remove unrelated stash ids\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tmodels.ScenePartial{\n\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\tMode:     models.RelationshipUpdateModeRemove,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodels.Scene{\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{sceneStashID(sceneIdxWithGallery)}),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tqb := db.Scene\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadSceneRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := loadSceneRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// only compare fields that were in the partial\n\t\t\tif tt.partial.PerformerIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.PerformerIDs.List(), got.PerformerIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.PerformerIDs.List(), s.PerformerIDs.List())\n\t\t\t}\n\t\t\tif tt.partial.TagIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.TagIDs.List(), got.TagIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.TagIDs.List(), s.TagIDs.List())\n\t\t\t}\n\t\t\tif tt.partial.GalleryIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.GalleryIDs.List(), got.GalleryIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.GalleryIDs.List(), s.GalleryIDs.List())\n\t\t\t}\n\t\t\tif tt.partial.GroupIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.Groups.List(), got.Groups.List())\n\t\t\t\tassert.ElementsMatch(tt.want.Groups.List(), s.Groups.List())\n\t\t\t}\n\t\t\tif tt.partial.StashIDs != nil {\n\t\t\t\tassert.ElementsMatch(tt.want.StashIDs.List(), got.StashIDs.List())\n\t\t\t\tassert.ElementsMatch(tt.want.StashIDs.List(), s.StashIDs.List())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_AddO(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"increment\",\n\t\t\tsceneIDs[1],\n\t\t\t1,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\t0,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.AddO(ctx, tt.id, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.AddO() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(got) != tt.want {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.AddO() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_DeleteO(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"decrement\",\n\t\t\tsceneIDs[2],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"zero\",\n\t\t\tsceneIDs[0],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.DeleteO(ctx, tt.id, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.DeleteO() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(got) != tt.want {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.DeleteO() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_ResetO(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"decrement\",\n\t\t\tsceneIDs[2],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"zero\",\n\t\t\tsceneIDs[0],\n\t\t\t0,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.ResetO(ctx, tt.id)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.ResetO() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.ResetOCounter() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_ResetWatchCount(t *testing.T) {\n\treturn\n}\n\nfunc Test_sceneQueryBuilder_Destroy(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tif err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.Destroy() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\t// ensure cannot be found\n\t\t\ti, err := qb.Find(ctx, tt.id)\n\n\t\t\tassert.Nil(err)\n\t\t\tassert.Nil(i)\n\t\t})\n\t}\n}\n\nfunc makeSceneWithID(index int) *models.Scene {\n\tret := makeScene(index)\n\tret.ID = sceneIDs[index]\n\n\tret.Files = models.NewRelatedVideoFiles([]*models.VideoFile{makeSceneFile(index)})\n\n\treturn ret\n}\n\nfunc Test_sceneQueryBuilder_Find(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\twant    *models.Scene\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tsceneIDs[sceneIdxWithSpacedName],\n\t\t\tmakeSceneWithID(sceneIdxWithSpacedName),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidID,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with galleries\",\n\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\tmakeSceneWithID(sceneIdxWithGallery),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\tsceneIDs[sceneIdxWithTwoPerformers],\n\t\t\tmakeSceneWithID(sceneIdxWithTwoPerformers),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\tsceneIDs[sceneIdxWithTwoTags],\n\t\t\tmakeSceneWithID(sceneIdxWithTwoTags),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with groups\",\n\t\t\tsceneIDs[sceneIdxWithGroup],\n\t\t\tmakeSceneWithID(sceneIdxWithGroup),\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.Find(ctx, tt.id)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.Find() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif got != nil {\n\t\t\t\t// load relationships\n\t\t\t\tif err := loadSceneRelationships(ctx, *tt.want, got); err != nil {\n\t\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tclearSceneFileIDs(got)\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc postFindScenes(ctx context.Context, want []*models.Scene, got []*models.Scene) error {\n\tfor i, s := range got {\n\t\t// load relationships\n\t\tif i < len(want) {\n\t\t\tif err := loadSceneRelationships(ctx, *want[i], s); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tclearSceneFileIDs(s)\n\t}\n\n\treturn nil\n}\n\nfunc Test_sceneQueryBuilder_FindMany(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tids     []int\n\t\twant    []*models.Scene\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid with relationships\",\n\t\t\t[]int{\n\t\t\t\tsceneIDs[sceneIdxWithGallery],\n\t\t\t\tsceneIDs[sceneIdxWithTwoPerformers],\n\t\t\t\tsceneIDs[sceneIdxWithTwoTags],\n\t\t\t\tsceneIDs[sceneIdxWithGroup],\n\t\t\t},\n\t\t\t[]*models.Scene{\n\t\t\t\tmakeSceneWithID(sceneIdxWithGallery),\n\t\t\t\tmakeSceneWithID(sceneIdxWithTwoPerformers),\n\t\t\t\tmakeSceneWithID(sceneIdxWithTwoTags),\n\t\t\t\tmakeSceneWithID(sceneIdxWithGroup),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t[]int{sceneIDs[sceneIdxWithGallery], sceneIDs[sceneIdxWithTwoPerformers], invalidID},\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindMany(ctx, tt.ids)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.FindMany() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindScenes(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_FindByChecksum(t *testing.T) {\n\tgetChecksum := func(index int) string {\n\t\treturn getSceneStringValue(index, checksumField)\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tchecksum string\n\t\twant     []*models.Scene\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgetChecksum(sceneIdxWithSpacedName),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithSpacedName)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid checksum\",\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with galleries\",\n\t\t\tgetChecksum(sceneIdxWithGallery),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithGallery)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\tgetChecksum(sceneIdxWithTwoPerformers),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithTwoPerformers)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\tgetChecksum(sceneIdxWithTwoTags),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithTwoTags)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with groups\",\n\t\t\tgetChecksum(sceneIdxWithGroup),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithGroup)},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByChecksum(ctx, tt.checksum)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.FindByChecksum() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindScenes(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_FindByOSHash(t *testing.T) {\n\tgetOSHash := func(index int) string {\n\t\treturn getSceneStringValue(index, \"oshash\")\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\toshash  string\n\t\twant    []*models.Scene\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgetOSHash(sceneIdxWithSpacedName),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithSpacedName)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid oshash\",\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with galleries\",\n\t\t\tgetOSHash(sceneIdxWithGallery),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithGallery)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\tgetOSHash(sceneIdxWithTwoPerformers),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithTwoPerformers)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\tgetOSHash(sceneIdxWithTwoTags),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithTwoTags)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with groups\",\n\t\t\tgetOSHash(sceneIdxWithGroup),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithGroup)},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.FindByOSHash(ctx, tt.oshash)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.FindByOSHash() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindScenes(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.FindByOSHash() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_FindByPath(t *testing.T) {\n\tgetPath := func(index int) string {\n\t\treturn getFilePath(folderIdxWithSceneFiles, getSceneBasename(index))\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tpath    string\n\t\twant    []*models.Scene\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgetPath(sceneIdxWithSpacedName),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithSpacedName)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\t\"invalid path\",\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with galleries\",\n\t\t\tgetPath(sceneIdxWithGallery),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithGallery)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with performers\",\n\t\t\tgetPath(sceneIdxWithTwoPerformers),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithTwoPerformers)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with tags\",\n\t\t\tgetPath(sceneIdxWithTwoTags),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithTwoTags)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with groups\",\n\t\t\tgetPath(sceneIdxWithGroup),\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithGroup)},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByPath(ctx, tt.path)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.FindByPath() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindScenes(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_sceneQueryBuilder_FindByGalleryID(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tgalleryID int\n\t\twant      []*models.Scene\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tgalleryIDs[galleryIdxWithScene],\n\t\t\t[]*models.Scene{makeSceneWithID(sceneIdxWithGallery)},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\tgalleryIDs[galleryIdx1WithPerformer],\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByGalleryID(ctx, tt.galleryID)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.FindByGalleryID() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := postFindScenes(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadSceneRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc TestSceneCountByPerformerID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tcount, err := sqb.CountByPerformerID(ctx, performerIDs[performerIdxWithScene])\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error counting scenes: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 1, count)\n\n\t\tcount, err = sqb.CountByPerformerID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error counting scenes: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, 0, count)\n\n\t\treturn nil\n\t})\n}\n\nfunc scenesToIDs(i []*models.Scene) []int {\n\tret := make([]int, len(i))\n\tfor i, v := range i {\n\t\tret[i] = v.ID\n\t}\n\n\treturn ret\n}\n\nfunc Test_sceneStore_FindByFileID(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tfileID  models.FileID\n\t\tinclude []int\n\t\texclude []int\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tsceneFileIDs[sceneIdx1WithPerformer],\n\t\t\t[]int{sceneIdx1WithPerformer},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidFileID,\n\t\t\tnil,\n\t\t\t[]int{sceneIdx1WithPerformer},\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.FindByFileID(ctx, tt.fileID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"SceneStore.FindByFileID() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor _, f := range got {\n\t\t\t\tclearSceneFileIDs(f)\n\t\t\t}\n\n\t\t\tids := scenesToIDs(got)\n\t\t\tinclude := indexesToIDs(galleryIDs, tt.include)\n\t\t\texclude := indexesToIDs(galleryIDs, tt.exclude)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_sceneStore_CountByFileID(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tfileID models.FileID\n\t\twant   int\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tsceneFileIDs[sceneIdxWithTwoPerformers],\n\t\t\t1,\n\t\t},\n\t\t{\n\t\t\t\"invalid\",\n\t\t\tinvalidFileID,\n\t\t\t0,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.CountByFileID(ctx, tt.fileID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"SceneStore.CountByFileID() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_sceneStore_CountMissingChecksum(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\twant int\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\t0,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.CountMissingChecksum(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"SceneStore.CountMissingChecksum() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_sceneStore_CountMissingOshash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\twant int\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\t0,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.CountMissingOSHash(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"SceneStore.CountMissingOSHash() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestSceneWall(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\n\t\tconst sceneIdx = 2\n\t\twallQuery := getSceneStringValue(sceneIdx, \"Details\")\n\t\tscenes, err := sqb.Wall(ctx, &wallQuery)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding scenes: %s\", err.Error())\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Len(t, scenes, 1)\n\t\tscene := scenes[0]\n\t\tassert.Equal(t, sceneIDs[sceneIdx], scene.ID)\n\t\tscenePath := getFilePath(folderIdxWithSceneFiles, getSceneBasename(sceneIdx))\n\t\tassert.Equal(t, scenePath, scene.Path)\n\n\t\twallQuery = \"not exist\"\n\t\tscenes, err = sqb.Wall(ctx, &wallQuery)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding scene: %s\", err.Error())\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Len(t, scenes, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryQ(t *testing.T) {\n\tconst sceneIdx = 2\n\n\tq := getSceneStringValue(sceneIdx, titleField)\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\n\t\tsceneQueryQ(ctx, t, sqb, q, sceneIdx)\n\n\t\treturn nil\n\t})\n}\n\nfunc queryScene(ctx context.Context, t *testing.T, sqb models.SceneReader, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) []*models.Scene {\n\tt.Helper()\n\tresult, err := sqb.Query(ctx, models.SceneQueryOptions{\n\t\tQueryOptions: models.QueryOptions{\n\t\t\tFindFilter: findFilter,\n\t\t\tCount:      true,\n\t\t},\n\t\tSceneFilter:   sceneFilter,\n\t\tTotalDuration: true,\n\t\tTotalSize:     true,\n\t})\n\tif err != nil {\n\t\tt.Errorf(\"Error querying scene: %v\", err)\n\t\treturn nil\n\t}\n\n\tscenes, err := result.Resolve(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"Error resolving scenes: %v\", err)\n\t}\n\n\treturn scenes\n}\n\nfunc sceneQueryQ(ctx context.Context, t *testing.T, sqb models.SceneReader, q string, expectedSceneIdx int) {\n\tfilter := models.FindFilterType{\n\t\tQ: &q,\n\t}\n\tscenes := queryScene(ctx, t, sqb, nil, &filter)\n\n\tif !assert.Len(t, scenes, 1) {\n\t\treturn\n\t}\n\tscene := scenes[0]\n\tassert.Equal(t, sceneIDs[expectedSceneIdx], scene.ID)\n\n\t// no Q should return all results\n\tfilter.Q = nil\n\tpp := totalScenes\n\tfilter.PerPage = &pp\n\tscenes = queryScene(ctx, t, sqb, nil, &filter)\n\n\tassert.Len(t, scenes, totalScenes)\n}\n\nfunc TestSceneQuery(t *testing.T) {\n\tvar (\n\t\tendpoint = sceneStashID(sceneIdxWithGallery).Endpoint\n\t\tstashID  = sceneStashID(sceneIdxWithGallery).StashID\n\t\tstashID2 = sceneStashID(sceneIdxWithPerformer).StashID\n\t\tstashIDs = []*string{&stashID, &stashID2}\n\n\t\tdepth = -1\n\t)\n\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.SceneFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"specific resume time\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tResumeTime: &models.IntCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue:    int(getSceneResumeTime(sceneIdxWithGallery)),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\t[]int{sceneIdxWithGroup},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"specific play duration\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPlayDuration: &models.IntCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue:    int(getScenePlayDuration(sceneIdxWithGallery)),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\t[]int{sceneIdxWithGroup},\n\t\t\tfalse,\n\t\t},\n\t\t// {\n\t\t// \t\"specific play count\",\n\t\t// \tnil,\n\t\t// \t&models.SceneFilterType{\n\t\t// \t\tPlayCount: &models.IntCriterionInput{\n\t\t// \t\t\tModifier: models.CriterionModifierEquals,\n\t\t// \t\t\tValue:    getScenePlayCount(sceneIdxWithGallery),\n\t\t// \t\t},\n\t\t// \t},\n\t\t// \t[]int{sceneIdxWithGallery},\n\t\t// \t[]int{sceneIdxWithGroup},\n\t\t// \tfalse,\n\t\t// },\n\t\t{\n\t\t\t\"stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashID:  &stashID,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashID:  &stashID,\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"null stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery, sceneIdxWithPerformer},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithGallery, sceneIdxWithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"null stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithGallery, sceneIdxWithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery, sceneIdxWithPerformer},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"with studio id 0 including child studios\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{\"0\"},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\tDepth:    &depth,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"single stash id\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDCount: &models.IntCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue:    1,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery, sceneIdxWithPerformer},\n\t\t\t[]int{sceneIdxWithGroup},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"less than one stash id\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tStashIDCount: &models.IntCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierLessThan,\n\t\t\t\t\tValue:    1,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGroup},\n\t\t\t[]int{sceneIdxWithGallery, sceneIdxWithPerformer},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.Scene.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tSceneFilter: tt.filter,\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: tt.findFilter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(sceneIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(sceneIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryPath(t *testing.T) {\n\tconst (\n\t\tsceneIdx      = 1\n\t\totherSceneIdx = 2\n\t)\n\tfolder := folderPaths[folderIdxWithSceneFiles]\n\tbasename := getSceneBasename(sceneIdx)\n\tscenePath := getFilePath(folderIdxWithSceneFiles, getSceneBasename(sceneIdx))\n\n\ttests := []struct {\n\t\tname        string\n\t\tinput       models.StringCriterionInput\n\t\tmustInclude []int\n\t\tmustExclude []int\n\t}{\n\t\t{\n\t\t\t\"equals full path\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    scenePath,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t\t[]int{sceneIdx},\n\t\t\t[]int{otherSceneIdx},\n\t\t},\n\t\t{\n\t\t\t\"equals full path wildcard\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    filepath.Join(folder, \"scene_0001_%\"),\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t\t[]int{sceneIdx},\n\t\t\t[]int{otherSceneIdx},\n\t\t},\n\t\t{\n\t\t\t\"not equals full path\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    scenePath,\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t},\n\t\t\t[]int{otherSceneIdx},\n\t\t\t[]int{sceneIdx},\n\t\t},\n\t\t{\n\t\t\t\"includes folder name\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    folder,\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{sceneIdx},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"includes base name\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    basename,\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{sceneIdx},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"includes full path\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    scenePath,\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{sceneIdx},\n\t\t\t[]int{otherSceneIdx},\n\t\t},\n\t\t{\n\t\t\t\"matches regex\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    \"scene_.*1_Path\",\n\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t},\n\t\t\t[]int{sceneIdx},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\tmodels.StringCriterionInput{\n\t\t\t\tValue:    \"scene_.*1_Path\",\n\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdx},\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tgot, err := qb.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tSceneFilter: &models.SceneFilterType{\n\t\t\t\t\tPath: &tt.input,\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.TestSceneQueryPath() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tmustInclude := indexesToIDs(sceneIDs, tt.mustInclude)\n\t\t\tmustExclude := indexesToIDs(sceneIDs, tt.mustExclude)\n\n\t\t\tmissing := sliceutil.Exclude(mustInclude, got.IDs)\n\t\t\tif len(missing) > 0 {\n\t\t\t\tt.Errorf(\"SceneStore.TestSceneQueryPath() missing expected IDs: %v\", missing)\n\t\t\t}\n\n\t\t\tnotExcluded := sliceutil.Intersect(mustExclude, got.IDs)\n\t\t\tif len(notExcluded) > 0 {\n\t\t\t\tt.Errorf(\"SceneStore.TestSceneQueryPath() expected IDs to be excluded: %v\", notExcluded)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryURL(t *testing.T) {\n\tconst sceneIdx = 1\n\tsceneURL := getSceneStringValue(sceneIdx, urlField)\n\n\turlCriterion := models.StringCriterionInput{\n\t\tValue:    sceneURL,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tfilter := models.SceneFilterType{\n\t\tURL: &urlCriterion,\n\t}\n\n\tverifyFn := func(s *models.Scene) {\n\t\tt.Helper()\n\n\t\turls := s.URLs.List()\n\t\tvar url string\n\t\tif len(urls) > 0 {\n\t\t\turl = urls[0]\n\t\t}\n\n\t\tverifyString(t, url, urlCriterion)\n\t}\n\n\tverifySceneQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifySceneQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierMatchesRegex\n\turlCriterion.Value = \"scene_.*1_URL\"\n\tverifySceneQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifySceneQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierIsNull\n\turlCriterion.Value = \"\"\n\tverifySceneQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotNull\n\tverifySceneQuery(t, filter, verifyFn)\n}\n\nfunc TestSceneQueryPathOr(t *testing.T) {\n\tconst scene1Idx = 1\n\tconst scene2Idx = 2\n\n\tscene1Path := getFilePath(folderIdxWithSceneFiles, getSceneBasename(scene1Idx))\n\tscene2Path := getFilePath(folderIdxWithSceneFiles, getSceneBasename(scene2Idx))\n\n\tsceneFilter := models.SceneFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    scene1Path,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.SceneFilterType]{\n\t\t\tOr: &models.SceneFilterType{\n\t\t\t\tPath: &models.StringCriterionInput{\n\t\t\t\t\tValue:    scene2Path,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tif !assert.Len(t, scenes, 2) {\n\t\t\treturn nil\n\t\t}\n\t\tassert.Equal(t, scene1Path, scenes[0].Path)\n\t\tassert.Equal(t, scene2Path, scenes[1].Path)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryPathAndRating(t *testing.T) {\n\tconst sceneIdx = 1\n\tscenePath := getFilePath(folderIdxWithSceneFiles, getSceneBasename(sceneIdx))\n\tsceneRating := int(getRating(sceneIdx).Int64)\n\n\tsceneFilter := models.SceneFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    scenePath,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.SceneFilterType]{\n\t\t\tAnd: &models.SceneFilterType{\n\t\t\t\tRating100: &models.IntCriterionInput{\n\t\t\t\t\tValue:    sceneRating,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tif !assert.Len(t, scenes, 1) {\n\t\t\treturn nil\n\t\t}\n\t\tassert.Equal(t, scenePath, scenes[0].Path)\n\t\tassert.Equal(t, sceneRating, *scenes[0].Rating)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryPathNotRating(t *testing.T) {\n\tconst sceneIdx = 1\n\n\tsceneRating := getRating(sceneIdx)\n\n\tpathCriterion := models.StringCriterionInput{\n\t\tValue:    \"scene_.*1_Path\",\n\t\tModifier: models.CriterionModifierMatchesRegex,\n\t}\n\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    int(sceneRating.Int64),\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tsceneFilter := models.SceneFilterType{\n\t\tPath: &pathCriterion,\n\t\tOperatorFilter: models.OperatorFilter[models.SceneFilterType]{\n\t\t\tNot: &models.SceneFilterType{\n\t\t\t\tRating100: &ratingCriterion,\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tfor _, scene := range scenes {\n\t\t\tverifyString(t, scene.Path, pathCriterion)\n\t\t\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\t\t\tverifyIntPtr(t, scene.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneIllegalQuery(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst sceneIdx = 1\n\tsubFilter := models.SceneFilterType{\n\t\tPath: &models.StringCriterionInput{\n\t\t\tValue:    getSceneStringValue(sceneIdx, \"Path\"),\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tsceneFilter := &models.SceneFilterType{\n\t\tOperatorFilter: models.OperatorFilter[models.SceneFilterType]{\n\t\t\tAnd: &subFilter,\n\t\t\tOr:  &subFilter,\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\n\t\tqueryOptions := models.SceneQueryOptions{\n\t\t\tSceneFilter: sceneFilter,\n\t\t}\n\n\t\t_, err := sqb.Query(ctx, queryOptions)\n\t\tassert.NotNil(err)\n\n\t\tsceneFilter.Or = nil\n\t\tsceneFilter.Not = &subFilter\n\t\t_, err = sqb.Query(ctx, queryOptions)\n\t\tassert.NotNil(err)\n\n\t\tsceneFilter.And = nil\n\t\tsceneFilter.Or = &subFilter\n\t\t_, err = sqb.Query(ctx, queryOptions)\n\t\tassert.NotNil(err)\n\n\t\treturn nil\n\t})\n}\n\nfunc verifySceneQuery(t *testing.T, filter models.SceneFilterType, verifyFn func(s *models.Scene)) {\n\tt.Helper()\n\twithTxn(func(ctx context.Context) error {\n\t\tt.Helper()\n\t\tsqb := db.Scene\n\n\t\tscenes := queryScene(ctx, t, sqb, &filter, nil)\n\n\t\tfor _, scene := range scenes {\n\t\t\tif err := scene.LoadRelationships(ctx, sqb); err != nil {\n\t\t\t\tt.Errorf(\"Error loading scene relationships: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\t// assume it should find at least one\n\t\tassert.Greater(t, len(scenes), 0)\n\n\t\tfor _, scene := range scenes {\n\t\t\tverifyFn(scene)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyScenesPath(t *testing.T, pathCriterion models.StringCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tPath: &pathCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tfor _, scene := range scenes {\n\t\t\tverifyString(t, scene.Path, pathCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyStringPtr(t *testing.T, value *string, criterion models.StringCriterionInput) {\n\tt.Helper()\n\tassert := assert.New(t)\n\tif criterion.Modifier == models.CriterionModifierIsNull {\n\t\tif value != nil && *value == \"\" {\n\t\t\t// correct\n\t\t\treturn\n\t\t}\n\t\tassert.Nil(value, \"expect is null values to be null\")\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\tassert.NotNil(value, \"expect is null values to be null\")\n\t\tassert.Greater(len(*value), 0)\n\t}\n\tif criterion.Modifier == models.CriterionModifierEquals {\n\t\tassert.Equal(criterion.Value, *value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotEquals {\n\t\tassert.NotEqual(criterion.Value, *value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierMatchesRegex {\n\t\tassert.NotNil(value)\n\t\tassert.Regexp(regexp.MustCompile(criterion.Value), *value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotMatchesRegex {\n\t\tif value == nil {\n\t\t\t// correct\n\t\t\treturn\n\t\t}\n\t\tassert.NotRegexp(regexp.MustCompile(criterion.Value), value)\n\t}\n}\n\nfunc verifyString(t *testing.T, value string, criterion models.StringCriterionInput) {\n\tt.Helper()\n\tassert := assert.New(t)\n\tswitch criterion.Modifier {\n\tcase models.CriterionModifierEquals:\n\t\tassert.Equal(criterion.Value, value)\n\tcase models.CriterionModifierNotEquals:\n\t\tassert.NotEqual(criterion.Value, value)\n\tcase models.CriterionModifierMatchesRegex:\n\t\tassert.Regexp(regexp.MustCompile(criterion.Value), value)\n\tcase models.CriterionModifierNotMatchesRegex:\n\t\tassert.NotRegexp(regexp.MustCompile(criterion.Value), value)\n\tcase models.CriterionModifierIsNull:\n\t\tassert.Equal(\"\", value)\n\tcase models.CriterionModifierNotNull:\n\t\tassert.NotEqual(\"\", value)\n\t}\n}\n\nfunc verifyStringList(t *testing.T, values []string, criterion models.StringCriterionInput) {\n\tt.Helper()\n\tassert := assert.New(t)\n\tswitch criterion.Modifier {\n\tcase models.CriterionModifierIsNull:\n\t\tassert.Empty(values)\n\tcase models.CriterionModifierNotNull:\n\t\tassert.NotEmpty(values)\n\tdefault:\n\t\tfor _, v := range values {\n\t\t\tverifyString(t, v, criterion)\n\t\t}\n\t}\n}\n\nfunc TestSceneQueryRating100(t *testing.T) {\n\tconst rating = 60\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    rating,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyScenesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyScenesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyScenesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyScenesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierIsNull\n\tverifyScenesRating100(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyScenesRating100(t, ratingCriterion)\n}\n\nfunc verifyScenesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tRating100: &ratingCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tfor _, scene := range scenes {\n\t\t\tverifyIntPtr(t, scene.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyIntPtr(t *testing.T, value *int, criterion models.IntCriterionInput) {\n\tt.Helper()\n\tassert := assert.New(t)\n\tif criterion.Modifier == models.CriterionModifierIsNull {\n\t\tassert.Nil(value, \"expect is null values to be null\")\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\tassert.NotNil(value, \"expect is null values to be null\")\n\t}\n\tif criterion.Modifier == models.CriterionModifierEquals {\n\t\tassert.Equal(criterion.Value, *value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotEquals {\n\t\tassert.NotEqual(criterion.Value, *value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierGreaterThan {\n\t\tassert.True(*value > criterion.Value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierLessThan {\n\t\tassert.True(*value < criterion.Value)\n\t}\n}\n\nfunc verifyDatePtr(t *testing.T, value *models.Date, criterion models.DateCriterionInput) {\n\tt.Helper()\n\tassert := assert.New(t)\n\tif criterion.Modifier == models.CriterionModifierIsNull {\n\t\tassert.Nil(value, \"expect is null values to be null\")\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotNull {\n\t\tassert.NotNil(value, \"expect not null values to be not null\")\n\t}\n\tif criterion.Modifier == models.CriterionModifierEquals {\n\t\tdate, _ := models.ParseDate(criterion.Value)\n\t\tassert.Equal(date, *value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotEquals {\n\t\tdate, _ := models.ParseDate(criterion.Value)\n\t\tassert.NotEqual(date, *value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierGreaterThan {\n\t\tdate, _ := models.ParseDate(criterion.Value)\n\t\tassert.True(value.After(date))\n\t}\n\tif criterion.Modifier == models.CriterionModifierLessThan {\n\t\tdate, _ := models.ParseDate(criterion.Value)\n\t\tassert.True(date.After(*value))\n\t}\n}\n\nfunc TestSceneQueryOCounter(t *testing.T) {\n\tconst oCounter = 1\n\toCounterCriterion := models.IntCriterionInput{\n\t\tValue:    oCounter,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyScenesOCounter(t, oCounterCriterion)\n\n\toCounterCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyScenesOCounter(t, oCounterCriterion)\n\n\toCounterCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyScenesOCounter(t, oCounterCriterion)\n\n\toCounterCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyScenesOCounter(t, oCounterCriterion)\n}\n\nfunc verifyScenesOCounter(t *testing.T, oCounterCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tOCounter: &oCounterCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tfor _, scene := range scenes {\n\t\t\tcount, err := sqb.GetOCount(ctx, scene.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Error getting ocounter: %v\", err)\n\t\t\t}\n\t\t\tverifyInt(t, count, oCounterCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyInt(t *testing.T, value int, criterion models.IntCriterionInput) bool {\n\tt.Helper()\n\tassert := assert.New(t)\n\tif criterion.Modifier == models.CriterionModifierEquals {\n\t\treturn assert.Equal(criterion.Value, value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotEquals {\n\t\treturn assert.NotEqual(criterion.Value, value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierGreaterThan {\n\t\treturn assert.Greater(value, criterion.Value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierLessThan {\n\t\treturn assert.Less(value, criterion.Value)\n\t}\n\n\treturn true\n}\n\nfunc TestSceneQueryDuration(t *testing.T) {\n\tduration := 200.432\n\n\tdurationCriterion := models.IntCriterionInput{\n\t\tValue:    int(duration),\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\tverifyScenesDuration(t, durationCriterion)\n\n\tdurationCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyScenesDuration(t, durationCriterion)\n\n\tdurationCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyScenesDuration(t, durationCriterion)\n\n\tdurationCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyScenesDuration(t, durationCriterion)\n\n\tdurationCriterion.Modifier = models.CriterionModifierIsNull\n\tverifyScenesDuration(t, durationCriterion)\n\n\tdurationCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyScenesDuration(t, durationCriterion)\n}\n\nfunc verifyScenesDuration(t *testing.T, durationCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tDuration: &durationCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tfor _, scene := range scenes {\n\t\t\tif err := scene.LoadPrimaryFile(ctx, db.File); err != nil {\n\t\t\t\tt.Errorf(\"Error querying scene files: %v\", err)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tduration := scene.Files.Primary().Duration\n\t\t\tif durationCriterion.Modifier == models.CriterionModifierEquals {\n\t\t\t\tassert.True(t, duration >= float64(durationCriterion.Value) && duration < float64(durationCriterion.Value+1))\n\t\t\t} else if durationCriterion.Modifier == models.CriterionModifierNotEquals {\n\t\t\t\tassert.True(t, duration < float64(durationCriterion.Value) || duration >= float64(durationCriterion.Value+1))\n\t\t\t} else {\n\t\t\t\tverifyFloat64(t, duration, durationCriterion)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyFloat64(t *testing.T, value float64, criterion models.IntCriterionInput) {\n\tassert := assert.New(t)\n\tif criterion.Modifier == models.CriterionModifierEquals {\n\t\tassert.Equal(float64(criterion.Value), value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierNotEquals {\n\t\tassert.NotEqual(float64(criterion.Value), value)\n\t}\n\tif criterion.Modifier == models.CriterionModifierGreaterThan {\n\t\tassert.True(value > float64(criterion.Value))\n\t}\n\tif criterion.Modifier == models.CriterionModifierLessThan {\n\t\tassert.True(value < float64(criterion.Value))\n\t}\n}\n\nfunc verifyFloat64Ptr(t *testing.T, value *float64, criterion models.IntCriterionInput) {\n\tassert := assert.New(t)\n\tswitch criterion.Modifier {\n\tcase models.CriterionModifierIsNull:\n\t\tassert.Nil(value, \"expect is null values to be null\")\n\tcase models.CriterionModifierNotNull:\n\t\tassert.NotNil(value, \"expect is not null values to not be null\")\n\tcase models.CriterionModifierEquals:\n\t\tassert.EqualValues(float64(criterion.Value), value)\n\tcase models.CriterionModifierNotEquals:\n\t\tassert.NotEqualValues(float64(criterion.Value), value)\n\tcase models.CriterionModifierGreaterThan:\n\t\tassert.True(value != nil && *value > float64(criterion.Value))\n\tcase models.CriterionModifierLessThan:\n\t\tassert.True(value != nil && *value < float64(criterion.Value))\n\t}\n}\n\nfunc TestSceneQueryResolution(t *testing.T) {\n\tverifyScenesResolution(t, models.ResolutionEnumLow)\n\tverifyScenesResolution(t, models.ResolutionEnumStandard)\n\tverifyScenesResolution(t, models.ResolutionEnumStandardHd)\n\tverifyScenesResolution(t, models.ResolutionEnumFullHd)\n\tverifyScenesResolution(t, models.ResolutionEnumFourK)\n\tverifyScenesResolution(t, models.ResolutionEnum(\"unknown\"))\n}\n\nfunc verifyScenesResolution(t *testing.T, resolution models.ResolutionEnum) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tResolution: &models.ResolutionCriterionInput{\n\t\t\t\tValue:    resolution,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tfor _, scene := range scenes {\n\t\t\tif err := scene.LoadPrimaryFile(ctx, db.File); err != nil {\n\t\t\t\tt.Errorf(\"Error querying scene files: %v\", err)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tf := scene.Files.Primary()\n\t\t\theight := 0\n\t\t\tif f != nil {\n\t\t\t\theight = f.Height\n\t\t\t}\n\t\t\tverifySceneResolution(t, &height, resolution)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifySceneResolution(t *testing.T, height *int, resolution models.ResolutionEnum) {\n\tif !resolution.IsValid() {\n\t\treturn\n\t}\n\n\tassert := assert.New(t)\n\tassert.NotNil(height)\n\tif t.Failed() {\n\t\treturn\n\t}\n\n\th := *height\n\n\tswitch resolution {\n\tcase models.ResolutionEnumLow:\n\t\tassert.True(h < 480)\n\tcase models.ResolutionEnumStandard:\n\t\tassert.True(h >= 480 && h < 720)\n\tcase models.ResolutionEnumStandardHd:\n\t\tassert.True(h >= 720 && h < 1080)\n\tcase models.ResolutionEnumFullHd:\n\t\tassert.True(h >= 1080 && h < 2160)\n\tcase models.ResolutionEnumFourK:\n\t\tassert.True(h >= 2160)\n\t}\n}\n\nfunc TestAllResolutionsHaveResolutionRange(t *testing.T) {\n\tfor _, resolution := range models.AllResolutionEnum {\n\t\tassert.NotZero(t, resolution.GetMinResolution(), \"Define resolution range for %s in extension_resolution.go\", resolution)\n\t\tassert.NotZero(t, resolution.GetMaxResolution(), \"Define resolution range for %s in extension_resolution.go\", resolution)\n\t}\n}\n\nfunc TestSceneQueryResolutionModifiers(t *testing.T) {\n\tif err := withRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Scene\n\t\tsceneNoResolution, _ := createScene(ctx, 0, 0)\n\t\tfirstScene540P, _ := createScene(ctx, 960, 540)\n\t\tsecondScene540P, _ := createScene(ctx, 1280, 719)\n\t\tfirstScene720P, _ := createScene(ctx, 1280, 720)\n\t\tsecondScene720P, _ := createScene(ctx, 1280, 721)\n\t\tthirdScene720P, _ := createScene(ctx, 1920, 1079)\n\t\tscene1080P, _ := createScene(ctx, 1920, 1080)\n\n\t\tscenesEqualTo720P := queryScenes(ctx, t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierEquals)\n\t\tscenesNotEqualTo720P := queryScenes(ctx, t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierNotEquals)\n\t\tscenesGreaterThan720P := queryScenes(ctx, t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierGreaterThan)\n\t\tscenesLessThan720P := queryScenes(ctx, t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierLessThan)\n\n\t\tassert.Subset(t, scenesEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P})\n\t\tassert.NotSubset(t, scenesEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P})\n\n\t\tassert.Subset(t, scenesNotEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P})\n\t\tassert.NotSubset(t, scenesNotEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P})\n\n\t\tassert.Subset(t, scenesGreaterThan720P, []*models.Scene{scene1080P})\n\t\tassert.NotSubset(t, scenesGreaterThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, firstScene720P, secondScene720P, thirdScene720P})\n\n\t\tassert.Subset(t, scenesLessThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P})\n\t\tassert.NotSubset(t, scenesLessThan720P, []*models.Scene{scene1080P, firstScene720P, secondScene720P, thirdScene720P})\n\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc queryScenes(ctx context.Context, t *testing.T, queryBuilder models.SceneReaderWriter, resolution models.ResolutionEnum, modifier models.CriterionModifier) []*models.Scene {\n\tsceneFilter := models.SceneFilterType{\n\t\tResolution: &models.ResolutionCriterionInput{\n\t\t\tValue:    resolution,\n\t\t\tModifier: modifier,\n\t\t},\n\t}\n\n\t// needed so that we don't hit the default limit of 25 scenes\n\tpp := 1000\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\treturn queryScene(ctx, t, queryBuilder, &sceneFilter, findFilter)\n}\n\nfunc createScene(ctx context.Context, width int, height int) (*models.Scene, error) {\n\tname := fmt.Sprintf(\"TestSceneQueryResolutionModifiers %d %d\", width, height)\n\n\tsceneFile := &models.VideoFile{\n\t\tBaseFile: &models.BaseFile{\n\t\t\tBasename:       name,\n\t\t\tParentFolderID: folderIDs[folderIdxWithSceneFiles],\n\t\t},\n\t\tWidth:  width,\n\t\tHeight: height,\n\t}\n\n\tif err := db.File.Create(ctx, sceneFile); err != nil {\n\t\treturn nil, err\n\t}\n\n\tscene := &models.Scene{}\n\n\tif err := db.Scene.Create(ctx, scene, []models.FileID{sceneFile.ID}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn scene, nil\n}\n\nfunc TestSceneQueryHasMarkers(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\thasMarkers := \"true\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tHasMarkers: &hasMarkers,\n\t\t}\n\n\t\tq := getSceneStringValue(sceneIdxWithMarkers, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.Len(t, scenes, 1)\n\t\tassert.Equal(t, sceneIDs[sceneIdxWithMarkers], scenes[0].ID)\n\n\t\thasMarkers = \"false\"\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\t\tassert.Len(t, scenes, 0)\n\n\t\tfindFilter.Q = nil\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.NotEqual(t, 0, len(scenes))\n\n\t\t// ensure non of the ids equal the one with gallery\n\t\tfor _, scene := range scenes {\n\t\t\tassert.NotEqual(t, sceneIDs[sceneIdxWithMarkers], scene.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryIsMissingGallery(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tisMissing := \"galleries\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getSceneStringValue(sceneIdxWithGallery, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.Len(t, scenes, 0)\n\n\t\tfindFilter.Q = nil\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\t// ensure non of the ids equal the one with gallery\n\t\tfor _, scene := range scenes {\n\t\t\tassert.NotEqual(t, sceneIDs[sceneIdxWithGallery], scene.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryIsMissingStudio(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tisMissing := \"studio\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getSceneStringValue(sceneIdxWithStudio, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.Len(t, scenes, 0)\n\n\t\tfindFilter.Q = nil\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\t// ensure non of the ids equal the one with studio\n\t\tfor _, scene := range scenes {\n\t\t\tassert.NotEqual(t, sceneIDs[sceneIdxWithStudio], scene.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryIsMissingMovies(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tisMissing := \"movie\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getSceneStringValue(sceneIdxWithGroup, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.Len(t, scenes, 0)\n\n\t\tfindFilter.Q = nil\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\t// ensure non of the ids equal the one with movies\n\t\tfor _, scene := range scenes {\n\t\t\tassert.NotEqual(t, sceneIDs[sceneIdxWithGroup], scene.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryIsMissingPerformers(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tisMissing := \"performers\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getSceneStringValue(sceneIdxWithPerformer, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.Len(t, scenes, 0)\n\n\t\tfindFilter.Q = nil\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.True(t, len(scenes) > 0)\n\n\t\t// ensure non of the ids equal the one with movies\n\t\tfor _, scene := range scenes {\n\t\t\tassert.NotEqual(t, sceneIDs[sceneIdxWithPerformer], scene.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryIsMissingDate(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tisMissing := \"date\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\t// one in four scenes have no date\n\t\tassert.Len(t, scenes, int(math.Ceil(float64(totalScenes)/4)))\n\n\t\t// ensure date is null\n\t\tfor _, scene := range scenes {\n\t\t\tassert.Nil(t, scene.Date)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryIsMissingTags(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tisMissing := \"tags\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getSceneStringValue(sceneIdxWithTwoTags, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.Len(t, scenes, 0)\n\n\t\tfindFilter.Q = nil\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\n\t\tassert.True(t, len(scenes) > 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryIsMissingRating(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tisMissing := \"rating\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tassert.True(t, len(scenes) > 0)\n\n\t\t// ensure rating is null\n\t\tfor _, scene := range scenes {\n\t\t\tassert.Nil(t, scene.Rating)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryIsMissingPhash(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tisMissing := \"phash\"\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tif !assert.Len(t, scenes, 1) {\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, sceneIDs[sceneIdxMissingPhash], scenes[0].ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryPerformers(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      models.MultiCriterionInput\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdxWithScene]),\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx1WithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformer,\n\t\t\t\tsceneIdxWithTwoPerformers,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithGallery,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx1WithScene]),\n\t\t\t\t\tstrconv.Itoa(performerIDs[performerIdx2WithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithTwoPerformers,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformer,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[performerIdx1WithScene])},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithTwoPerformers},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\t[]int{sceneIdxWithTag},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformer,\n\t\t\t\tsceneIdxWithTwoPerformers,\n\t\t\t\tsceneIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformer,\n\t\t\t\tsceneIdxWithTwoPerformers,\n\t\t\t\tsceneIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\t[]int{sceneIdxWithTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx1WithScene]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx2WithScene]),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithTwoPerformers},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithThreePerformers,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tmodels.MultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx1WithScene]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[performerIdx2WithScene]),\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.Scene.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tSceneFilter: &models.SceneFilterType{\n\t\t\t\t\tPerformers: &tt.filter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(sceneIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(sceneIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryTags(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      models.HierarchicalMultiCriterionInput\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithScene]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithTag,\n\t\t\t\tsceneIdxWithTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithGallery,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithScene]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[tagIdx1WithScene])},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithTwoTags},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t},\n\t\t\t[]int{sceneIdx1WithPerformer},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithTag,\n\t\t\t\tsceneIdxWithTwoTags,\n\t\t\t\tsceneIdxWithMarkerAndTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithTag,\n\t\t\t\tsceneIdxWithTwoTags,\n\t\t\t\tsceneIdxWithMarkerAndTag,\n\t\t\t},\n\t\t\t[]int{sceneIdx1WithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithScene]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithScene]),\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithTwoTags},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithThreeTags,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithScene]),\n\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithScene]),\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.Scene.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tSceneFilter: &models.SceneFilterType{\n\t\t\t\t\tTags: &tt.filter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(sceneIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(sceneIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryPerformerTags(t *testing.T) {\n\tallDepth := -1\n\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.SceneFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithPerformer]),\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformerTag,\n\t\t\t\tsceneIdxWithPerformerTwoTags,\n\t\t\t\tsceneIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformer,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes sub-tags\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithParentAndChild]),\n\t\t\t\t\t},\n\t\t\t\t\tDepth:    &allDepth,\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformerParentTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformer,\n\t\t\t\tsceneIdxWithPerformerTag,\n\t\t\t\tsceneIdxWithPerformerTwoTags,\n\t\t\t\tsceneIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes all\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithPerformer]),\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformerTwoTags,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformer,\n\t\t\t\tsceneIdxWithPerformerTag,\n\t\t\t\tsceneIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes performer tag tagIdx2WithPerformer\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\tValue:    []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithTwoPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes sub-tags\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithParentAndChild]),\n\t\t\t\t\t},\n\t\t\t\t\tDepth:    &allDepth,\n\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformer,\n\t\t\t\tsceneIdxWithPerformerTag,\n\t\t\t\tsceneIdxWithPerformerTwoTags,\n\t\t\t\tsceneIdxWithTwoPerformerTag,\n\t\t\t},\n\t\t\t[]int{\n\t\t\t\tsceneIdxWithPerformerParentTag,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"is null\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdx1WithPerformer},\n\t\t\t[]int{sceneIdxWithPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithPerformerTag},\n\t\t\t[]int{sceneIdx1WithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tnil,\n\t\t\t&models.SceneFilterType{\n\t\t\t\tPerformerTags: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\tValue: []string{\n\t\t\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithPerformer]),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresults, err := db.Scene.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tSceneFilter: tt.filter,\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: tt.findFilter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(sceneIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(sceneIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(results.IDs, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryStudio(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tq               string\n\t\tstudioCriterion models.HierarchicalMultiCriterionInput\n\t\texpectedIDs     []int\n\t\twantErr         bool\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\t\"\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t},\n\t\t\t[]int{sceneIDs[sceneIdxWithStudio]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tgetSceneStringValue(sceneIdxWithStudio, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t},\n\t\t\t[]int{},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes includes null\",\n\t\t\tgetSceneStringValue(sceneIdxWithGallery, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t},\n\t\t\t[]int{sceneIDs[sceneIdxWithGallery]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"equals\",\n\t\t\t\"\",\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t\t[]int{sceneIDs[sceneIdxWithStudio]},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\tgetSceneStringValue(sceneIdxWithStudio, titleField),\n\t\t\tmodels.HierarchicalMultiCriterionInput{\n\t\t\t\tValue: []string{\n\t\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithScene]),\n\t\t\t\t},\n\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t},\n\t\t\t[]int{},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tstudioCriterion := tt.studioCriterion\n\n\t\t\tsceneFilter := models.SceneFilterType{\n\t\t\t\tStudios: &studioCriterion,\n\t\t\t}\n\n\t\t\tvar findFilter *models.FindFilterType\n\t\t\tif tt.q != \"\" {\n\t\t\t\tfindFilter = &models.FindFilterType{\n\t\t\t\t\tQ: &tt.q,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tscenes := queryScene(ctx, t, qb, &sceneFilter, findFilter)\n\n\t\t\tassert.ElementsMatch(t, scenesToIDs(scenes), tt.expectedIDs)\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryStudioDepth(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tdepth := 2\n\t\tstudioCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGrandChild]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    &depth,\n\t\t}\n\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tStudios: &studioCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\t\tassert.Len(t, scenes, 1)\n\n\t\tdepth = 1\n\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, nil)\n\t\tassert.Len(t, scenes, 0)\n\n\t\tstudioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, nil)\n\t\tassert.Len(t, scenes, 1)\n\n\t\t// ensure id is correct\n\t\tassert.Equal(t, sceneIDs[sceneIdxWithGrandChildStudio], scenes[0].ID)\n\t\tdepth = 2\n\n\t\tstudioCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithGrandChild]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\tDepth:    &depth,\n\t\t}\n\n\t\tq := getSceneStringValue(sceneIdxWithGrandChildStudio, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\t\tassert.Len(t, scenes, 0)\n\n\t\tdepth = 1\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\t\tassert.Len(t, scenes, 1)\n\n\t\tstudioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])}\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\t\tassert.Len(t, scenes, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneGroups(t *testing.T) {\n\ttype criterion struct {\n\t\tvalueIdxs []int\n\t\tmodifier  models.CriterionModifier\n\t\tdepth     int\n\t}\n\n\ttests := []struct {\n\t\tname        string\n\t\tc           criterion\n\t\tq           string\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t}{\n\t\t{\n\t\t\t\"includes\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithScene},\n\t\t\t\tmodels.CriterionModifierIncludes,\n\t\t\t\t0,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{sceneIdxWithGroup},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithScene},\n\t\t\t\tmodels.CriterionModifierExcludes,\n\t\t\t\t0,\n\t\t\t},\n\t\t\tgetSceneStringValue(sceneIdxWithGroup, titleField),\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithGroup},\n\t\t},\n\t\t{\n\t\t\t\"includes (depth = 1)\",\n\t\t\tcriterion{\n\t\t\t\t[]int{groupIdxWithChildWithScene},\n\t\t\t\tmodels.CriterionModifierIncludes,\n\t\t\t\t1,\n\t\t\t},\n\t\t\t\"\",\n\t\t\t[]int{sceneIdxWithGroupWithParent},\n\t\t\tnil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tvalueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs)\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tsceneFilter := &models.SceneFilterType{\n\t\t\t\tGroups: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    intslice.IntSliceToStringSlice(valueIDs),\n\t\t\t\t\tModifier: tt.c.modifier,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tif tt.c.depth != 0 {\n\t\t\t\tsceneFilter.Groups.Depth = &tt.c.depth\n\t\t\t}\n\n\t\t\tfindFilter := &models.FindFilterType{}\n\t\t\tif tt.q != \"\" {\n\t\t\t\tfindFilter.Q = &tt.q\n\t\t\t}\n\n\t\t\tresults, err := db.Scene.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tSceneFilter: sceneFilter,\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: findFilter,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"SceneStore.Query() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tinclude := indexesToIDs(sceneIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(sceneIDs, tt.excludeIdxs)\n\n\t\t\tassert.Subset(results.IDs, include)\n\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(results.IDs, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryMovies(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tmovieCriterion := models.MultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(groupIDs[groupIdxWithScene]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tMovies: &movieCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tassert.Len(t, scenes, 1)\n\n\t\t// ensure id is correct\n\t\tassert.Equal(t, sceneIDs[sceneIdxWithGroup], scenes[0].ID)\n\n\t\tmovieCriterion = models.MultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(groupIDs[groupIdxWithScene]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t}\n\n\t\tq := getSceneStringValue(sceneIdxWithGroup, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)\n\t\tassert.Len(t, scenes, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryPhashDuplicated(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tduplicated := true\n\t\tphashCriterion := models.DuplicationCriterionInput{\n\t\t\tDuplicated: &duplicated,\n\t\t}\n\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tDuplicated: &phashCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\n\t\tassert.Len(t, scenes, dupeScenePhashes*2)\n\n\t\tduplicated = false\n\n\t\tscenes = queryScene(ctx, t, sqb, &sceneFilter, nil)\n\t\t// -1 for missing phash\n\t\tassert.Len(t, scenes, totalScenes-(dupeScenePhashes*2)-1)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQuerySorting(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tsortBy        string\n\t\tdir           models.SortDirectionEnum\n\t\tfirstSceneIdx int // -1 to ignore\n\t\tlastSceneIdx  int\n\t}{\n\t\t{\n\t\t\t\"bitrate\",\n\t\t\t\"bitrate\",\n\t\t\tmodels.SortDirectionEnumAsc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"duration\",\n\t\t\t\"duration\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"file mod time\",\n\t\t\t\"file_mod_time\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"file size\",\n\t\t\t\"filesize\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"frame rate\",\n\t\t\t\"framerate\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"path\",\n\t\t\t\"path\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"perceptual_similarity\",\n\t\t\t\"perceptual_similarity\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"play_count\",\n\t\t\t\"play_count\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"last_played_at\",\n\t\t\t\"last_played_at\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"resume_time\",\n\t\t\t\"resume_time\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"play_duration\",\n\t\t\t\"play_duration\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\t-1,\n\t\t},\n\t\t{\n\t\t\t\"performer_age\",\n\t\t\t\"performer_age\",\n\t\t\tmodels.SortDirectionEnumDesc,\n\t\t\t-1,\n\t\t\t-1,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\t\t\tgot, err := qb.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: &models.FindFilterType{\n\t\t\t\t\t\tSort:      &tt.sortBy,\n\t\t\t\t\t\tDirection: &tt.dir,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.TestSceneQuerySorting() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tscenes, err := got.Resolve(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"sceneQueryBuilder.TestSceneQuerySorting() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !assert.Greater(len(scenes), 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// scenes should be in same order as indexes\n\t\t\tfirstScene := scenes[0]\n\t\t\tlastScene := scenes[len(scenes)-1]\n\n\t\t\tif tt.firstSceneIdx != -1 {\n\t\t\t\tfirstSceneID := sceneIDs[tt.firstSceneIdx]\n\t\t\t\tassert.Equal(firstSceneID, firstScene.ID)\n\t\t\t}\n\t\t\tif tt.lastSceneIdx != -1 {\n\t\t\t\tlastSceneID := sceneIDs[tt.lastSceneIdx]\n\t\t\t\tassert.Equal(lastSceneID, lastScene.ID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryPagination(t *testing.T) {\n\tperPage := 1\n\tfindFilter := models.FindFilterType{\n\t\tPerPage: &perPage,\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tscenes := queryScene(ctx, t, sqb, nil, &findFilter)\n\n\t\tassert.Len(t, scenes, 1)\n\n\t\tfirstID := scenes[0].ID\n\n\t\tpage := 2\n\t\tfindFilter.Page = &page\n\t\tscenes = queryScene(ctx, t, sqb, nil, &findFilter)\n\n\t\tassert.Len(t, scenes, 1)\n\t\tsecondID := scenes[0].ID\n\t\tassert.NotEqual(t, firstID, secondID)\n\n\t\tperPage = 2\n\t\tpage = 1\n\n\t\tscenes = queryScene(ctx, t, sqb, nil, &findFilter)\n\t\tassert.Len(t, scenes, 2)\n\t\tassert.Equal(t, firstID, scenes[0].ID)\n\t\tassert.Equal(t, secondID, scenes[1].ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryTagCount(t *testing.T) {\n\tconst tagCount = 1\n\ttagCountCriterion := models.IntCriterionInput{\n\t\tValue:    tagCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyScenesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyScenesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyScenesTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyScenesTagCount(t, tagCountCriterion)\n}\n\nfunc verifyScenesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tTagCount: &tagCountCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\t\tassert.Greater(t, len(scenes), 0)\n\n\t\tfor _, scene := range scenes {\n\t\t\tif err := scene.LoadTagIDs(ctx, sqb); err != nil {\n\t\t\t\tt.Errorf(\"scene.LoadTagIDs() error = %v\", err)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tverifyInt(t, len(scene.TagIDs.List()), tagCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneQueryPerformerCount(t *testing.T) {\n\tconst performerCount = 1\n\tperformerCountCriterion := models.IntCriterionInput{\n\t\tValue:    performerCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyScenesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyScenesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyScenesPerformerCount(t, performerCountCriterion)\n\n\tperformerCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyScenesPerformerCount(t, performerCountCriterion)\n}\n\nfunc verifyScenesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\t\tsceneFilter := models.SceneFilterType{\n\t\t\tPerformerCount: &performerCountCriterion,\n\t\t}\n\n\t\tscenes := queryScene(ctx, t, sqb, &sceneFilter, nil)\n\t\tassert.Greater(t, len(scenes), 0)\n\n\t\tfor _, scene := range scenes {\n\t\t\tif err := scene.LoadPerformerIDs(ctx, sqb); err != nil {\n\t\t\t\tt.Errorf(\"scene.LoadPerformerIDs() error = %v\", err)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tverifyInt(t, len(scene.PerformerIDs.List()), performerCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestFindByMovieID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\n\t\tscenes, err := sqb.FindByGroupID(ctx, groupIDs[groupIdxWithScene])\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling FindByMovieID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, scenes, 1)\n\t\tassert.Equal(t, sceneIDs[sceneIdxWithGroup], scenes[0].ID)\n\n\t\tscenes, err = sqb.FindByGroupID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling FindByMovieID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, scenes, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestFindByPerformerID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Scene\n\n\t\tscenes, err := sqb.FindByPerformerID(ctx, performerIDs[performerIdxWithScene])\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling FindByPerformerID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, scenes, 1)\n\t\tassert.Equal(t, sceneIDs[sceneIdxWithPerformer], scenes[0].ID)\n\n\t\tscenes, err = sqb.FindByPerformerID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling FindByPerformerID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, scenes, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneUpdateSceneCover(t *testing.T) {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tqb := db.Scene\n\n\t\tsceneID := sceneIDs[sceneIdxWithGallery]\n\n\t\treturn testUpdateImage(t, ctx, sceneID, qb.UpdateCover, qb.GetCover)\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestSceneStashIDs(t *testing.T) {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tqb := db.Scene\n\n\t\t// create scene to test against\n\t\tconst name = \"TestSceneStashIDs\"\n\t\tscene := &models.Scene{\n\t\t\tTitle: name,\n\t\t}\n\t\tif err := qb.Create(ctx, scene, nil); err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating scene: %s\", err.Error())\n\t\t}\n\n\t\tif err := scene.LoadStashIDs(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttestSceneStashIDs(ctx, t, scene)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc testSceneStashIDs(ctx context.Context, t *testing.T, s *models.Scene) {\n\t// ensure no stash IDs to begin with\n\tassert.Len(t, s.StashIDs.List(), 0)\n\n\t// add stash ids\n\tconst stashIDStr = \"stashID\"\n\tconst endpoint = \"endpoint\"\n\tstashID := models.StashID{\n\t\tStashID:   stashIDStr,\n\t\tEndpoint:  endpoint,\n\t\tUpdatedAt: epochTime,\n\t}\n\n\tqb := db.Scene\n\n\t// update stash ids and ensure was updated\n\tvar err error\n\ts, err = qb.UpdatePartial(ctx, s.ID, models.ScenePartial{\n\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\tStashIDs: []models.StashID{stashID},\n\t\t\tMode:     models.RelationshipUpdateModeSet,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tif err := s.LoadStashIDs(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Equal(t, []models.StashID{stashID}, s.StashIDs.List())\n\n\t// remove stash ids and ensure was updated\n\ts, err = qb.UpdatePartial(ctx, s.ID, models.ScenePartial{\n\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\tStashIDs: []models.StashID{stashID},\n\t\t\tMode:     models.RelationshipUpdateModeRemove,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tif err := s.LoadStashIDs(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Len(t, s.StashIDs.List(), 0)\n}\n\nfunc TestSceneQueryQTrim(t *testing.T) {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tqb := db.Scene\n\n\t\texpectedID := sceneIDs[sceneIdxWithSpacedName]\n\n\t\ttype test struct {\n\t\t\tquery string\n\t\t\tid    int\n\t\t\tcount int\n\t\t}\n\t\ttests := []test{\n\t\t\t{query: \" zzz    yyy    \", id: expectedID, count: 1},\n\t\t\t{query: \"   \\\"zzz yyy xxx\\\" \", id: expectedID, count: 1},\n\t\t\t{query: \"zzz\", id: expectedID, count: 1},\n\t\t\t{query: \"\\\" zzz    yyy    \\\"\", count: 0},\n\t\t\t{query: \"\\\"zzz    yyy\\\"\", count: 0},\n\t\t\t{query: \"\\\" zzz yyy\\\"\", count: 0},\n\t\t\t{query: \"\\\"zzz yyy  \\\"\", count: 0},\n\t\t}\n\n\t\tfor _, tst := range tests {\n\t\t\tf := models.FindFilterType{\n\t\t\t\tQ: &tst.query,\n\t\t\t}\n\t\t\tscenes := queryScene(ctx, t, qb, nil, &f)\n\n\t\t\tassert.Len(t, scenes, tst.count)\n\t\t\tif len(scenes) > 0 {\n\t\t\t\tassert.Equal(t, tst.id, scenes[0].ID)\n\t\t\t}\n\t\t}\n\n\t\tfindFilter := models.FindFilterType{}\n\t\tscenes := queryScene(ctx, t, qb, nil, &findFilter)\n\t\tassert.NotEqual(t, 0, len(scenes))\n\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestSceneStore_All(t *testing.T) {\n\tqb := db.Scene\n\n\twithRollbackTxn(func(ctx context.Context) error {\n\t\tgot, err := qb.All(ctx)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.All() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// it's possible that other tests have created scenes\n\t\tassert.GreaterOrEqual(t, len(got), len(sceneIDs))\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneStore_FindDuplicates(t *testing.T) {\n\tqb := db.Scene\n\n\twithRollbackTxn(func(ctx context.Context) error {\n\t\tdistance := 0\n\t\tdurationDiff := -1.\n\t\tgot, err := qb.FindDuplicates(ctx, distance, durationDiff)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.FindDuplicates() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Len(t, got, dupeScenePhashes)\n\n\t\tdistance = 1\n\t\tdurationDiff = -1.\n\t\tgot, err = qb.FindDuplicates(ctx, distance, durationDiff)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.FindDuplicates() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Len(t, got, dupeScenePhashes)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneStore_AssignFiles(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tsceneID int\n\t\tfileID  models.FileID\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\tsceneFileIDs[sceneIdx1WithStudio],\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid file id\",\n\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\tinvalidFileID,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid scene id\",\n\t\t\tinvalidID,\n\t\t\tsceneFileIDs[sceneIdx1WithStudio],\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\twithRollbackTxn(func(ctx context.Context) error {\n\t\t\t\tif err := qb.AssignFiles(ctx, tt.sceneID, []models.FileID{tt.fileID}); (err != nil) != tt.wantErr {\n\t\t\t\t\tt.Errorf(\"SceneStore.AssignFiles() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t})\n\t}\n}\n\nfunc TestSceneStore_AddView(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tsceneID       int\n\t\texpectedCount int\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"valid\",\n\t\t\tsceneIDs[sceneIdx1WithPerformer],\n\t\t\t1, //getScenePlayCount(sceneIdx1WithPerformer) + 1,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid scene id\",\n\t\t\tinvalidID,\n\t\t\t0,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\twithRollbackTxn(func(ctx context.Context) error {\n\t\t\t\tviews, err := qb.AddViews(ctx, tt.sceneID, nil)\n\t\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\t\tt.Errorf(\"SceneStore.AddView() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\t}\n\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tassert := assert.New(t)\n\t\t\t\tassert.Equal(tt.expectedCount, len(views))\n\n\t\t\t\t// find the scene and check the count\n\t\t\t\tcount, err := qb.CountViews(ctx, tt.sceneID)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"SceneStore.CountViews() error = %v\", err)\n\t\t\t\t}\n\n\t\t\t\tlastView, err := qb.LastView(ctx, tt.sceneID)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"SceneStore.LastView() error = %v\", err)\n\t\t\t\t}\n\n\t\t\t\tassert.Equal(tt.expectedCount, count)\n\t\t\t\tassert.True(lastView.After(time.Now().Add(-1 * time.Minute)))\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t})\n\t}\n}\n\nfunc TestSceneStore_DecrementWatchCount(t *testing.T) {\n\treturn\n}\n\nfunc TestSceneStore_SaveActivity(t *testing.T) {\n\tvar (\n\t\tresumeTime   = 111.2\n\t\tplayDuration = 98.7\n\t)\n\n\ttests := []struct {\n\t\tname         string\n\t\tsceneIdx     int\n\t\tresumeTime   *float64\n\t\tplayDuration *float64\n\t\twantErr      bool\n\t}{\n\t\t{\n\t\t\t\"both\",\n\t\t\tsceneIdx1WithPerformer,\n\t\t\t&resumeTime,\n\t\t\t&playDuration,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"resumeTime only\",\n\t\t\tsceneIdx1WithPerformer,\n\t\t\t&resumeTime,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"playDuration only\",\n\t\t\tsceneIdx1WithPerformer,\n\t\t\tnil,\n\t\t\t&playDuration,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"none\",\n\t\t\tsceneIdx1WithPerformer,\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid scene id\",\n\t\t\t-1,\n\t\t\t&resumeTime,\n\t\t\t&playDuration,\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Scene\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\twithRollbackTxn(func(ctx context.Context) error {\n\t\t\t\tid := -1\n\t\t\t\tif tt.sceneIdx != -1 {\n\t\t\t\t\tid = sceneIDs[tt.sceneIdx]\n\t\t\t\t}\n\n\t\t\t\t_, err := qb.SaveActivity(ctx, id, tt.resumeTime, tt.playDuration)\n\t\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\t\tt.Errorf(\"SceneStore.SaveActivity() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\t}\n\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tassert := assert.New(t)\n\n\t\t\t\t// find the scene and check the values\n\t\t\t\tscene, err := qb.Find(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"SceneStore.Find() error = %v\", err)\n\t\t\t\t}\n\n\t\t\t\texpectedResumeTime := getSceneResumeTime(tt.sceneIdx)\n\t\t\t\texpectedPlayDuration := getScenePlayDuration(tt.sceneIdx)\n\n\t\t\t\tif tt.resumeTime != nil {\n\t\t\t\t\texpectedResumeTime = *tt.resumeTime\n\t\t\t\t}\n\t\t\t\tif tt.playDuration != nil {\n\t\t\t\t\texpectedPlayDuration += *tt.playDuration\n\t\t\t\t}\n\n\t\t\t\tassert.Equal(expectedResumeTime, scene.ResumeTime)\n\t\t\t\tassert.Equal(expectedPlayDuration, scene.PlayDuration)\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t})\n\t}\n}\n\nfunc TestSceneQueryCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      *models.SceneFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue:    []any{getSceneStringValue(sceneIdxWithGallery, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getSceneTitle(sceneIdxWithGallery),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\t\tValue:    []any{getSceneStringValue(sceneIdxWithGallery, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue:    []any{getSceneStringValue(sceneIdxWithGallery, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getSceneTitle(sceneIdxWithGallery),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\t\tValue:    []any{getSceneStringValue(sceneIdxWithGallery, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"regex\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*17_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithTwoPerformerTag},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid regex\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getSceneTitle(sceneIdxWithTwoPerformerTag),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*17_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithTwoPerformerTag},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid not matches regex\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getSceneTitle(sceneIdxWithGallery),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"not existing\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getSceneTitle(sceneIdxWithGallery),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"between\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{sceneIdxWithPerformer},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not between\",\n\t\t\t&models.SceneFilterType{\n\t\t\t\tTitle: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getSceneTitle(sceneIdxWithPerformer),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{sceneIdxWithPerformer},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tresult, err := db.Scene.Query(ctx, models.SceneQueryOptions{\n\t\t\t\tSceneFilter: tt.filter,\n\t\t\t})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SceneStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tscenes, err := result.Resolve(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"SceneStore.Query().Resolve() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := scenesToIDs(scenes)\n\t\t\tinclude := indexesToIDs(sceneIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(sceneIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TODO Count\n// TODO SizeCount\n\n// TODO - this should be in history_test and generalised\nfunc TestSceneStore_CountAllViews(t *testing.T) {\n\twithRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Scene\n\n\t\tsceneID := sceneIDs[sceneIdx1WithPerformer]\n\n\t\t// get the current play count\n\t\tcurrentCount, err := qb.CountAllViews(ctx)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.CountAllViews() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// add a view\n\t\t_, err = qb.AddViews(ctx, sceneID, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.AddViews() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// get the new play count\n\t\tnewCount, err := qb.CountAllViews(ctx)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.CountAllViews() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, currentCount+1, newCount)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestSceneStore_CountUniqueViews(t *testing.T) {\n\twithRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Scene\n\n\t\tsceneID := sceneIDs[sceneIdx1WithPerformer]\n\n\t\t// get the current play count\n\t\tcurrentCount, err := qb.CountUniqueViews(ctx)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.CountUniqueViews() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// add a view\n\t\t_, err = qb.AddViews(ctx, sceneID, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.AddViews() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// add a second view\n\t\t_, err = qb.AddViews(ctx, sceneID, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.AddViews() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// get the new play count\n\t\tnewCount, err := qb.CountUniqueViews(ctx)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SceneStore.CountUniqueViews() error = %v\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tassert.Equal(t, currentCount+1, newCount)\n\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "pkg/sqlite/setup_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/internal/manager/config\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n\n\t// necessary to register custom migrations\n\t_ \"github.com/stashapp/stash/pkg/sqlite/migrations\"\n)\n\nvar epochTime = time.Unix(0, 0).UTC()\n\nconst (\n\tspacedSceneTitle = \"zzz yyy xxx\"\n)\n\nconst (\n\tfolderIdxRoot = iota\n\tfolderIdxWithSubFolder\n\tfolderIdxWithParentFolder\n\tfolderIdxWithFiles\n\tfolderIdxInZip\n\n\tfolderIdxForObjectFiles\n\tfolderIdxWithImageFiles\n\tfolderIdxWithGalleryFiles\n\tfolderIdxWithSceneFiles\n\n\ttotalFolders\n)\n\nconst (\n\tfileIdxZip = iota\n\tfileIdxInZip\n\n\tfileIdxStartVideoFiles\n\tfileIdxStartImageFiles\n\tfileIdxStartGalleryFiles\n\n\ttotalFiles\n)\n\nconst (\n\tsceneIdxWithGroup = iota\n\tsceneIdxWithGallery\n\tsceneIdxWithPerformer\n\tsceneIdx1WithPerformer\n\tsceneIdx2WithPerformer\n\tsceneIdxWithTwoPerformers\n\tsceneIdxWithThreePerformers\n\tsceneIdxWithTag\n\tsceneIdxWithTwoTags\n\tsceneIdxWithThreeTags\n\tsceneIdxWithMarkerAndTag\n\tsceneIdxWithMarkerTwoTags\n\tsceneIdxWithStudio\n\tsceneIdx1WithStudio\n\tsceneIdx2WithStudio\n\tsceneIdxWithMarkers\n\tsceneIdxWithPerformerTag\n\tsceneIdxWithTwoPerformerTag\n\tsceneIdxWithPerformerTwoTags\n\tsceneIdxWithSpacedName\n\tsceneIdxWithStudioPerformer\n\tsceneIdx1WithTwoStudioPerformer\n\tsceneIdx2WithTwoStudioPerformer\n\tsceneIdxWithGrandChildStudio\n\tsceneIdxMissingPhash\n\tsceneIdxWithPerformerParentTag\n\tsceneIdxWithGroupWithParent\n\t// new indexes above\n\tlastSceneIdx\n\n\ttotalScenes = lastSceneIdx + 3\n)\n\nconst dupeScenePhashes = 2\n\nconst (\n\timageIdxWithGallery = iota\n\timageIdx1WithGallery\n\timageIdx2WithGallery\n\timageIdxWithTwoGalleries\n\timageIdxWithPerformer\n\timageIdx1WithPerformer\n\timageIdx2WithPerformer\n\timageIdxWithTwoPerformers\n\timageIdxWithThreePerformers\n\timageIdxWithTag\n\timageIdxWithTwoTags\n\timageIdxWithThreeTags\n\timageIdxWithStudio\n\timageIdx1WithStudio\n\timageIdx2WithStudio\n\timageIdxWithStudioPerformer\n\timageIdxInZip\n\timageIdxWithPerformerTag\n\timageIdxWithTwoPerformerTag\n\timageIdxWithPerformerTwoTags\n\timageIdxWithGrandChildStudio\n\timageIdxWithPerformerParentTag\n\t// new indexes above\n\ttotalImages\n)\n\nconst (\n\tperformerIdxWithScene = iota\n\tperformerIdx1WithScene\n\tperformerIdx2WithScene\n\tperformerIdx3WithScene\n\tperformerIdxWithTwoScenes\n\tperformerIdxWithImage\n\tperformerIdxWithTwoImages\n\tperformerIdx1WithImage\n\tperformerIdx2WithImage\n\tperformerIdx3WithImage\n\tperformerIdxWithTag\n\tperformerIdx2WithTag\n\tperformerIdxWithTwoTags\n\tperformerIdxWithGallery\n\tperformerIdxWithTwoGalleries\n\tperformerIdx1WithGallery\n\tperformerIdx2WithGallery\n\tperformerIdx3WithGallery\n\tperformerIdxWithSceneStudio\n\tperformerIdxWithImageStudio\n\tperformerIdxWithGalleryStudio\n\tperformerIdxWithTwoSceneStudio\n\tperformerIdxWithParentTag\n\t// new indexes above\n\t// performers with dup names start from the end\n\tperformerIdx1WithDupName\n\tperformerIdxWithDupName\n\n\tperformersNameCase   = performerIdx1WithDupName\n\tperformersNameNoCase = 2\n\n\ttotalPerformers = performersNameCase + performersNameNoCase\n)\n\nconst (\n\tgroupIdxWithScene = iota\n\tgroupIdxWithStudio\n\tgroupIdxWithTag\n\tgroupIdxWithTwoTags\n\tgroupIdxWithThreeTags\n\tgroupIdxWithGrandChild\n\tgroupIdxWithChild\n\tgroupIdxWithParentAndChild\n\tgroupIdxWithParent\n\tgroupIdxWithGrandParent\n\tgroupIdxWithParentAndScene\n\tgroupIdxWithChildWithScene\n\t// groups with dup names start from the end\n\tgroupIdxWithDupName\n\n\tgroupsNameCase   = groupIdxWithDupName\n\tgroupsNameNoCase = 1\n)\n\nconst (\n\tgalleryIdxWithScene = iota\n\tgalleryIdxWithChapters\n\tgalleryIdxWithImage\n\tgalleryIdx1WithImage\n\tgalleryIdx2WithImage\n\tgalleryIdxWithTwoImages\n\tgalleryIdxWithPerformer\n\tgalleryIdx1WithPerformer\n\tgalleryIdx2WithPerformer\n\tgalleryIdxWithTwoPerformers\n\tgalleryIdxWithThreePerformers\n\tgalleryIdxWithTag\n\tgalleryIdxWithTwoTags\n\tgalleryIdxWithThreeTags\n\tgalleryIdxWithStudio\n\tgalleryIdx1WithStudio\n\tgalleryIdx2WithStudio\n\tgalleryIdxWithPerformerTag\n\tgalleryIdxWithTwoPerformerTag\n\tgalleryIdxWithPerformerTwoTags\n\tgalleryIdxWithStudioPerformer\n\tgalleryIdxWithGrandChildStudio\n\tgalleryIdxWithoutFile\n\tgalleryIdxWithPerformerParentTag\n\t// new indexes above\n\tlastGalleryIdx\n\n\ttotalGalleries = lastGalleryIdx + 1\n)\n\nconst (\n\ttagIdxWithScene = iota\n\ttagIdx1WithScene\n\ttagIdx2WithScene\n\ttagIdx3WithScene\n\ttagIdxWithPrimaryMarkers\n\ttagIdxWithMarkers\n\ttagIdxWithCoverImage\n\ttagIdxWithImage\n\ttagIdx1WithImage\n\ttagIdx2WithImage\n\ttagIdx3WithImage\n\ttagIdxWithPerformer\n\ttagIdx1WithPerformer\n\ttagIdx2WithPerformer\n\ttagIdxWithStudio\n\ttagIdx1WithStudio\n\ttagIdx2WithStudio\n\ttagIdxWithGallery\n\ttagIdx1WithGallery\n\ttagIdx2WithGallery\n\ttagIdx3WithGallery\n\ttagIdxWithChildTag\n\ttagIdxWithParentTag\n\ttagIdxWithGrandChild\n\ttagIdxWithParentAndChild\n\ttagIdxWithGrandParent\n\ttagIdx2WithMarkers\n\ttagIdxWithGroup\n\ttagIdx1WithGroup\n\ttagIdx2WithGroup\n\ttagIdx3WithGroup\n\t// new indexes above\n\t// tags with dup names start from the end\n\ttagIdx1WithDupName\n\ttagIdxWithDupName\n\n\ttagsNameNoCase = 2\n\ttagsNameCase   = tagIdx1WithDupName\n\n\ttotalTags = tagsNameCase + tagsNameNoCase\n)\n\nconst (\n\tstudioIdxWithScene = iota\n\tstudioIdxWithTwoScenes\n\tstudioIdxWithGroup\n\tstudioIdxWithChildStudio\n\tstudioIdxWithParentStudio\n\tstudioIdxWithImage\n\tstudioIdxWithTwoImages\n\tstudioIdxWithGallery\n\tstudioIdxWithTwoGalleries\n\tstudioIdxWithScenePerformer\n\tstudioIdxWithImagePerformer\n\tstudioIdxWithGalleryPerformer\n\tstudioIdx1WithTwoScenePerformer\n\tstudioIdx2WithTwoScenePerformer\n\tstudioIdxWithTag\n\tstudioIdx2WithTag\n\tstudioIdxWithTwoTags\n\tstudioIdxWithParentTag\n\tstudioIdxWithGrandChild\n\tstudioIdxWithParentAndChild\n\tstudioIdxWithGrandParent\n\t// new indexes above\n\t// studios with dup names start from the end\n\tstudioIdxWithDupName\n\n\tstudiosNameCase   = studioIdxWithDupName\n\tstudiosNameNoCase = 1\n\n\ttotalStudios = studiosNameCase + studiosNameNoCase\n)\n\nconst (\n\tmarkerIdxWithScene = iota\n\tmarkerIdxWithTag\n\tmarkerIdxWithSceneTag\n\tmarkerIdxWithDuration\n\tmarkerIdx2WithDuration\n\ttotalMarkers\n)\n\nconst (\n\tchapterIdxWithGallery = iota\n\ttotalChapters\n)\n\nconst (\n\tsavedFilterIdxScene = iota\n\tsavedFilterIdxImage\n\n\t// new indexes above\n\ttotalSavedFilters\n)\n\nconst (\n\tpathField            = \"Path\"\n\tchecksumField        = \"Checksum\"\n\ttitleField           = \"Title\"\n\tdetailsField         = \"Details\"\n\turlField             = \"URL\"\n\tzipPath              = \"zipPath.zip\"\n\tfirstSavedFilterName = \"firstSavedFilterName\"\n)\n\nvar (\n\tfolderIDs      []models.FolderID\n\tfileIDs        []models.FileID\n\tsceneFileIDs   []models.FileID\n\timageFileIDs   []models.FileID\n\tgalleryFileIDs []models.FileID\n\tchapterIDs     []int\n\n\tsceneIDs       []int\n\timageIDs       []int\n\tperformerIDs   []int\n\tgroupIDs       []int\n\tgalleryIDs     []int\n\ttagIDs         []int\n\tstudioIDs      []int\n\tmarkerIDs      []int\n\tsavedFilterIDs []int\n\n\tfolderPaths []string\n\n\ttagNames       []string\n\tstudioNames    []string\n\tgroupNames     []string\n\tperformerNames []string\n)\n\ntype idAssociation struct {\n\tfirst  int\n\tsecond int\n}\n\ntype linkMap map[int][]int\n\nfunc (m linkMap) reverseLookup(idx int) []int {\n\tvar result []int\n\n\tfor k, v := range m {\n\t\tfor _, vv := range v {\n\t\t\tif vv == idx {\n\t\t\t\tresult = append(result, k)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn result\n}\n\nvar (\n\tfolderParentFolders = map[int]int{\n\t\tfolderIdxWithSubFolder:    folderIdxRoot,\n\t\tfolderIdxForObjectFiles:   folderIdxRoot,\n\t\tfolderIdxWithParentFolder: folderIdxWithSubFolder,\n\t\tfolderIdxWithSceneFiles:   folderIdxForObjectFiles,\n\t\tfolderIdxWithImageFiles:   folderIdxForObjectFiles,\n\t\tfolderIdxWithGalleryFiles: folderIdxForObjectFiles,\n\t}\n\n\tfileFolders = map[int]int{\n\t\tfileIdxZip:   folderIdxWithFiles,\n\t\tfileIdxInZip: folderIdxInZip,\n\t}\n\n\tfolderZipFiles = map[int]int{\n\t\tfolderIdxInZip: fileIdxZip,\n\t}\n\n\tfileZipFiles = map[int]int{\n\t\tfileIdxInZip: fileIdxZip,\n\t}\n)\n\nvar (\n\tsceneTags = linkMap{\n\t\tsceneIdxWithTag:           {tagIdxWithScene},\n\t\tsceneIdxWithTwoTags:       {tagIdx1WithScene, tagIdx2WithScene},\n\t\tsceneIdxWithThreeTags:     {tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene},\n\t\tsceneIdxWithMarkerAndTag:  {tagIdx3WithScene},\n\t\tsceneIdxWithMarkerTwoTags: {tagIdx2WithScene, tagIdx3WithScene},\n\t}\n\n\tscenePerformers = linkMap{\n\t\tsceneIdxWithPerformer:           {performerIdxWithScene},\n\t\tsceneIdxWithTwoPerformers:       {performerIdx1WithScene, performerIdx2WithScene},\n\t\tsceneIdxWithThreePerformers:     {performerIdx1WithScene, performerIdx2WithScene, performerIdx3WithScene},\n\t\tsceneIdxWithPerformerTag:        {performerIdxWithTag},\n\t\tsceneIdxWithTwoPerformerTag:     {performerIdxWithTag, performerIdx2WithTag},\n\t\tsceneIdxWithPerformerTwoTags:    {performerIdxWithTwoTags},\n\t\tsceneIdx1WithPerformer:          {performerIdxWithTwoScenes},\n\t\tsceneIdx2WithPerformer:          {performerIdxWithTwoScenes},\n\t\tsceneIdxWithStudioPerformer:     {performerIdxWithSceneStudio},\n\t\tsceneIdx1WithTwoStudioPerformer: {performerIdxWithTwoSceneStudio},\n\t\tsceneIdx2WithTwoStudioPerformer: {performerIdxWithTwoSceneStudio},\n\t\tsceneIdxWithPerformerParentTag:  {performerIdxWithParentTag},\n\t}\n\n\tsceneGalleries = linkMap{\n\t\tsceneIdxWithGallery: {galleryIdxWithScene},\n\t}\n\n\tsceneGroups = linkMap{\n\t\tsceneIdxWithGroup:           {groupIdxWithScene},\n\t\tsceneIdxWithGroupWithParent: {groupIdxWithParentAndScene},\n\t}\n\n\tsceneStudios = map[int]int{\n\t\tsceneIdxWithStudio:              studioIdxWithScene,\n\t\tsceneIdx1WithStudio:             studioIdxWithTwoScenes,\n\t\tsceneIdx2WithStudio:             studioIdxWithTwoScenes,\n\t\tsceneIdxWithStudioPerformer:     studioIdxWithScenePerformer,\n\t\tsceneIdx1WithTwoStudioPerformer: studioIdx1WithTwoScenePerformer,\n\t\tsceneIdx2WithTwoStudioPerformer: studioIdx2WithTwoScenePerformer,\n\t\tsceneIdxWithGrandChildStudio:    studioIdxWithGrandParent,\n\t}\n)\n\ntype markerSpec struct {\n\tsceneIdx      int\n\tprimaryTagIdx int\n\ttagIdxs       []int\n}\n\nvar (\n\t// indexed by marker\n\tmarkerSpecs = []markerSpec{\n\t\t{sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, nil},\n\t\t{sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers}},\n\t\t{sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdx2WithMarkers}},\n\t\t{sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers, tagIdx2WithMarkers}},\n\t\t{sceneIdxWithMarkerAndTag, tagIdxWithPrimaryMarkers, nil},\n\t\t{sceneIdxWithMarkerTwoTags, tagIdxWithPrimaryMarkers, nil},\n\t}\n)\n\ntype chapterSpec struct {\n\tgalleryIdx int\n\ttitle      string\n\timageIndex int\n}\n\nvar (\n\t// indexed by chapter\n\tchapterSpecs = []chapterSpec{\n\t\t{galleryIdxWithChapters, \"Test1\", 10},\n\t}\n)\n\nvar (\n\timageGalleries = linkMap{\n\t\timageIdxWithGallery:      {galleryIdxWithImage},\n\t\timageIdx1WithGallery:     {galleryIdxWithTwoImages},\n\t\timageIdx2WithGallery:     {galleryIdxWithTwoImages},\n\t\timageIdxWithTwoGalleries: {galleryIdx1WithImage, galleryIdx2WithImage},\n\t}\n\timageStudios = map[int]int{\n\t\timageIdxWithStudio:           studioIdxWithImage,\n\t\timageIdx1WithStudio:          studioIdxWithTwoImages,\n\t\timageIdx2WithStudio:          studioIdxWithTwoImages,\n\t\timageIdxWithStudioPerformer:  studioIdxWithImagePerformer,\n\t\timageIdxWithGrandChildStudio: studioIdxWithGrandParent,\n\t}\n\timageTags = linkMap{\n\t\timageIdxWithTag:       {tagIdxWithImage},\n\t\timageIdxWithTwoTags:   {tagIdx1WithImage, tagIdx2WithImage},\n\t\timageIdxWithThreeTags: {tagIdx1WithImage, tagIdx2WithImage, tagIdx3WithImage},\n\t}\n\timagePerformers = linkMap{\n\t\timageIdxWithPerformer:          {performerIdxWithImage},\n\t\timageIdxWithTwoPerformers:      {performerIdx1WithImage, performerIdx2WithImage},\n\t\timageIdxWithThreePerformers:    {performerIdx1WithImage, performerIdx2WithImage, performerIdx3WithImage},\n\t\timageIdxWithPerformerTag:       {performerIdxWithTag},\n\t\timageIdxWithTwoPerformerTag:    {performerIdxWithTag, performerIdx2WithTag},\n\t\timageIdxWithPerformerTwoTags:   {performerIdxWithTwoTags},\n\t\timageIdx1WithPerformer:         {performerIdxWithTwoImages},\n\t\timageIdx2WithPerformer:         {performerIdxWithTwoImages},\n\t\timageIdxWithStudioPerformer:    {performerIdxWithImageStudio},\n\t\timageIdxWithPerformerParentTag: {performerIdxWithParentTag},\n\t}\n)\n\nvar (\n\tgalleryPerformers = linkMap{\n\t\tgalleryIdxWithPerformer:          {performerIdxWithGallery},\n\t\tgalleryIdxWithTwoPerformers:      {performerIdx1WithGallery, performerIdx2WithGallery},\n\t\tgalleryIdxWithThreePerformers:    {performerIdx1WithGallery, performerIdx2WithGallery, performerIdx3WithGallery},\n\t\tgalleryIdxWithPerformerTag:       {performerIdxWithTag},\n\t\tgalleryIdxWithTwoPerformerTag:    {performerIdxWithTag, performerIdx2WithTag},\n\t\tgalleryIdxWithPerformerTwoTags:   {performerIdxWithTwoTags},\n\t\tgalleryIdx1WithPerformer:         {performerIdxWithTwoGalleries},\n\t\tgalleryIdx2WithPerformer:         {performerIdxWithTwoGalleries},\n\t\tgalleryIdxWithStudioPerformer:    {performerIdxWithGalleryStudio},\n\t\tgalleryIdxWithPerformerParentTag: {performerIdxWithParentTag},\n\t}\n\n\tgalleryStudios = map[int]int{\n\t\tgalleryIdxWithStudio:           studioIdxWithGallery,\n\t\tgalleryIdx1WithStudio:          studioIdxWithTwoGalleries,\n\t\tgalleryIdx2WithStudio:          studioIdxWithTwoGalleries,\n\t\tgalleryIdxWithStudioPerformer:  studioIdxWithGalleryPerformer,\n\t\tgalleryIdxWithGrandChildStudio: studioIdxWithGrandParent,\n\t}\n\n\tgalleryTags = linkMap{\n\t\tgalleryIdxWithTag:       {tagIdxWithGallery},\n\t\tgalleryIdxWithTwoTags:   {tagIdx1WithGallery, tagIdx2WithGallery},\n\t\tgalleryIdxWithThreeTags: {tagIdx1WithGallery, tagIdx2WithGallery, tagIdx3WithGallery},\n\t}\n)\n\nvar (\n\tgroupStudioLinks = [][2]int{\n\t\t{groupIdxWithStudio, studioIdxWithGroup},\n\t}\n\n\tgroupTags = linkMap{\n\t\tgroupIdxWithTag:       {tagIdxWithGroup},\n\t\tgroupIdxWithTwoTags:   {tagIdx1WithGroup, tagIdx2WithGroup},\n\t\tgroupIdxWithThreeTags: {tagIdx1WithGroup, tagIdx2WithGroup, tagIdx3WithGroup},\n\t}\n)\n\nvar (\n\tstudioParentLinks = [][2]int{\n\t\t{studioIdxWithChildStudio, studioIdxWithParentStudio},\n\t\t{studioIdxWithGrandChild, studioIdxWithParentAndChild},\n\t\t{studioIdxWithParentAndChild, studioIdxWithGrandParent},\n\t}\n)\n\nvar (\n\tstudioTags = linkMap{\n\t\tstudioIdxWithTag:       {tagIdxWithStudio},\n\t\tstudioIdx2WithTag:      {tagIdx2WithStudio},\n\t\tstudioIdxWithTwoTags:   {tagIdx1WithStudio, tagIdx2WithStudio},\n\t\tstudioIdxWithParentTag: {tagIdxWithParentAndChild},\n\t}\n)\n\nvar (\n\tperformerTags = linkMap{\n\t\tperformerIdxWithTag:       {tagIdxWithPerformer},\n\t\tperformerIdx2WithTag:      {tagIdx2WithPerformer},\n\t\tperformerIdxWithTwoTags:   {tagIdx1WithPerformer, tagIdx2WithPerformer},\n\t\tperformerIdxWithParentTag: {tagIdxWithParentAndChild},\n\t}\n)\n\nvar (\n\ttagParentLinks = [][2]int{\n\t\t{tagIdxWithChildTag, tagIdxWithParentTag},\n\t\t{tagIdxWithGrandChild, tagIdxWithParentAndChild},\n\t\t{tagIdxWithParentAndChild, tagIdxWithGrandParent},\n\t}\n)\n\nvar (\n\tgroupParentLinks = [][2]int{\n\t\t{groupIdxWithChild, groupIdxWithParent},\n\t\t{groupIdxWithGrandChild, groupIdxWithParentAndChild},\n\t\t{groupIdxWithParentAndChild, groupIdxWithGrandParent},\n\t\t{groupIdxWithChildWithScene, groupIdxWithParentAndScene},\n\t}\n)\n\nfunc indexesToIDs(ids []int, indexes []int) []int {\n\tret := make([]int, len(indexes))\n\tfor i, idx := range indexes {\n\t\tret[i] = indexToID(ids, idx)\n\t}\n\n\treturn ret\n}\n\nfunc indexToID(ids []int, idx int) int {\n\tif idx < 0 {\n\t\treturn invalidID\n\t}\n\treturn ids[idx]\n}\n\nfunc indexesToIDPtrs[T any](ids []T, indexes []int) []*T {\n\tret := make([]*T, len(indexes))\n\tfor i, idx := range indexes {\n\t\tret[i] = indexToIDPtr(ids, idx)\n\t}\n\n\treturn ret\n}\n\nfunc indexToIDPtr[T any](ids []T, idx int) *T {\n\tif idx < 0 {\n\t\treturn nil\n\t}\n\treturn &ids[idx]\n}\n\nfunc indexFromID(ids []int, id int) int {\n\tfor i, v := range ids {\n\t\tif v == id {\n\t\t\treturn i\n\t\t}\n\t}\n\n\treturn -1\n}\n\nvar db *sqlite.Database\n\nfunc TestMain(m *testing.M) {\n\t// initialise empty config - needed by some migrations\n\t_ = config.InitializeEmpty()\n\n\tret := runTests(m)\n\tos.Exit(ret)\n}\n\nfunc withTxn(f func(ctx context.Context) error) error {\n\treturn txn.WithTxn(context.Background(), db, f)\n}\n\nfunc withRollbackTxn(f func(ctx context.Context) error) error {\n\tvar ret error\n\twithTxn(func(ctx context.Context) error {\n\t\tret = f(ctx)\n\t\treturn errors.New(\"fake error for rollback\")\n\t})\n\n\treturn ret\n}\n\nfunc runWithRollbackTxn(t *testing.T, name string, f func(t *testing.T, ctx context.Context)) {\n\twithRollbackTxn(func(ctx context.Context) error {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tf(t, ctx)\n\t\t})\n\t\treturn nil\n\t})\n}\n\nfunc testTeardown(databaseFile string) {\n\terr := db.Close()\n\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\terr = os.Remove(databaseFile)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n}\n\nfunc runTests(m *testing.M) int {\n\t// create the database file\n\tf, err := os.CreateTemp(\"\", \"*.sqlite\")\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"Could not create temporary file: %s\", err.Error()))\n\t}\n\n\tf.Close()\n\tdatabaseFile := f.Name()\n\tdb = sqlite.NewDatabase()\n\tdb.SetBlobStoreOptions(sqlite.BlobStoreOptions{\n\t\tUseDatabase: true,\n\t\t// don't use filesystem\n\t})\n\n\tif err := db.Open(databaseFile); err != nil {\n\t\tpanic(fmt.Sprintf(\"Could not initialize database: %s\", err.Error()))\n\t}\n\n\t// defer close and delete the database\n\tdefer testTeardown(databaseFile)\n\n\terr = populateDB()\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"Could not populate database: %s\", err.Error()))\n\t}\n\n\t// run the tests\n\treturn m.Run()\n}\n\nfunc populateDB() error {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tif err := createFolders(ctx); err != nil {\n\t\t\treturn fmt.Errorf(\"creating folders: %w\", err)\n\t\t}\n\n\t\tif err := createFiles(ctx); err != nil {\n\t\t\treturn fmt.Errorf(\"creating files: %w\", err)\n\t\t}\n\n\t\tif err := linkFoldersToZip(ctx); err != nil {\n\t\t\treturn fmt.Errorf(\"linking folders to zip files: %w\", err)\n\t\t}\n\n\t\tif err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating tags: %s\", err.Error())\n\t\t}\n\n\t\tif err := createGroups(ctx, db.Group, groupsNameCase, groupsNameNoCase); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating groups: %s\", err.Error())\n\t\t}\n\n\t\tif err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating performers: %s\", err.Error())\n\t\t}\n\n\t\tif err := createStudios(ctx, studiosNameCase, studiosNameNoCase); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating studios: %s\", err.Error())\n\t\t}\n\n\t\tif err := createGalleries(ctx, totalGalleries); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating galleries: %s\", err.Error())\n\t\t}\n\n\t\tif err := createScenes(ctx, totalScenes); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating scenes: %s\", err.Error())\n\t\t}\n\n\t\tif err := createImages(ctx, totalImages); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating images: %s\", err.Error())\n\t\t}\n\n\t\tif err := addTagImage(ctx, db.Tag, tagIdxWithCoverImage); err != nil {\n\t\t\treturn fmt.Errorf(\"error adding tag image: %s\", err.Error())\n\t\t}\n\n\t\tif err := createSavedFilters(ctx, db.SavedFilter, totalSavedFilters); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating saved filters: %s\", err.Error())\n\t\t}\n\n\t\tif err := linkGroupStudios(ctx, db.Group); err != nil {\n\t\t\treturn fmt.Errorf(\"error linking group studios: %s\", err.Error())\n\t\t}\n\n\t\tif err := linkStudiosParent(ctx); err != nil {\n\t\t\treturn fmt.Errorf(\"error linking studios parent: %s\", err.Error())\n\t\t}\n\n\t\tif err := linkTagsParent(ctx, db.Tag); err != nil {\n\t\t\treturn fmt.Errorf(\"error linking tags parent: %s\", err.Error())\n\t\t}\n\n\t\tif err := linkGroupsParent(ctx, db.Group); err != nil {\n\t\t\treturn fmt.Errorf(\"error linking tags parent: %s\", err.Error())\n\t\t}\n\n\t\tfor _, ms := range markerSpecs {\n\t\t\tif err := createMarker(ctx, db.SceneMarker, ms); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error creating scene marker: %s\", err.Error())\n\t\t\t}\n\t\t}\n\t\tfor _, cs := range chapterSpecs {\n\t\t\tif err := createChapter(ctx, db.GalleryChapter, cs); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error creating gallery chapter: %s\", err.Error())\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc getFolderPath(index int, parentFolderIdx *int) string {\n\tpath := getPrefixedStringValue(\"folder\", index, pathField)\n\n\tif parentFolderIdx != nil {\n\t\treturn filepath.Join(folderPaths[*parentFolderIdx], path)\n\t}\n\n\treturn path\n}\n\nfunc getFolderBasename(index int, parentFolderIdx *int) string {\n\treturn filepath.Base(getFolderPath(index, parentFolderIdx))\n}\n\nfunc getFolderModTime(index int) time.Time {\n\treturn time.Date(2000, 1, (index%10)+1, 0, 0, 0, 0, time.UTC)\n}\n\nfunc makeFolder(i int) models.Folder {\n\tvar folderID *models.FolderID\n\tvar folderIdx *int\n\tif pidx, ok := folderParentFolders[i]; ok {\n\t\tfolderIdx = &pidx\n\t\tv := folderIDs[pidx]\n\t\tfolderID = &v\n\t}\n\n\treturn models.Folder{\n\t\tParentFolderID: folderID,\n\t\tDirEntry: models.DirEntry{\n\t\t\t// zip files have to be added after creating files\n\t\t\tModTime: getFolderModTime(i),\n\t\t},\n\t\tPath: getFolderPath(i, folderIdx),\n\t}\n}\n\nfunc createFolders(ctx context.Context) error {\n\tqb := db.Folder\n\n\tfor i := 0; i < totalFolders; i++ {\n\t\tfolder := makeFolder(i)\n\n\t\tif err := qb.Create(ctx, &folder); err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating folder [%d] %v+: %s\", i, folder, err.Error())\n\t\t}\n\n\t\tfolderIDs = append(folderIDs, folder.ID)\n\t\tfolderPaths = append(folderPaths, folder.Path)\n\t}\n\n\treturn nil\n}\n\nfunc linkFoldersToZip(ctx context.Context) error {\n\t// link folders to zip files\n\tfor folderIdx, fileIdx := range folderZipFiles {\n\t\tfolderID := folderIDs[folderIdx]\n\t\tfileID := fileIDs[fileIdx]\n\n\t\tf, err := db.Folder.Find(ctx, folderID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error finding folder [%d] to link to zip file [%d]\", folderID, fileID)\n\t\t}\n\n\t\tf.ZipFileID = &fileID\n\n\t\tif err := db.Folder.Update(ctx, f); err != nil {\n\t\t\treturn fmt.Errorf(\"Error linking folder [%d] to zip file [%d]: %s\", folderIdx, fileIdx, err.Error())\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc getFileBaseName(index int) string {\n\treturn getPrefixedStringValue(\"file\", index, \"basename\")\n}\n\nfunc getFileStringValue(index int, field string) string {\n\treturn getPrefixedStringValue(\"file\", index, field)\n}\n\nfunc getFileModTime(index int) time.Time {\n\treturn getFolderModTime(index)\n}\n\nfunc getFilePhash(index int) int64 {\n\treturn int64(index * 567)\n}\n\nfunc getFileFingerprints(index int) []models.Fingerprint {\n\treturn []models.Fingerprint{\n\t\t{\n\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\tFingerprint: getPrefixedStringValue(\"file\", index, \"md5\"),\n\t\t},\n\t\t{\n\t\t\tType:        models.FingerprintTypeOshash,\n\t\t\tFingerprint: getPrefixedStringValue(\"file\", index, \"oshash\"),\n\t\t},\n\t\t{\n\t\t\tType:        models.FingerprintTypePhash,\n\t\t\tFingerprint: getFilePhash(index),\n\t\t},\n\t}\n}\n\nfunc getFileSize(index int) int64 {\n\treturn int64(index) * 10\n}\n\nfunc getFileDuration(index int) float64 {\n\tduration := (index % 4) + 1\n\tduration = duration * 100\n\n\treturn float64(duration) + 0.432\n}\n\nfunc makeFile(i int) models.File {\n\tfolderID := folderIDs[fileFolders[i]]\n\tif folderID == 0 {\n\t\tfolderID = folderIDs[folderIdxWithFiles]\n\t}\n\n\tvar zipFileID *models.FileID\n\tif zipFileIndex, found := fileZipFiles[i]; found {\n\t\tzipFileID = &fileIDs[zipFileIndex]\n\t}\n\n\tvar ret models.File\n\tbaseFile := &models.BaseFile{\n\t\tBasename:       getFileBaseName(i),\n\t\tParentFolderID: folderID,\n\t\tDirEntry: models.DirEntry{\n\t\t\t// zip files have to be added after creating files\n\t\t\tModTime:   getFileModTime(i),\n\t\t\tZipFileID: zipFileID,\n\t\t},\n\t\tFingerprints: getFileFingerprints(i),\n\t\tSize:         getFileSize(i),\n\t}\n\n\tret = baseFile\n\n\tif i >= fileIdxStartVideoFiles && i < fileIdxStartImageFiles {\n\t\tret = &models.VideoFile{\n\t\t\tBaseFile:   baseFile,\n\t\t\tFormat:     getFileStringValue(i, \"format\"),\n\t\t\tWidth:      getWidth(i),\n\t\t\tHeight:     getHeight(i),\n\t\t\tDuration:   getFileDuration(i),\n\t\t\tVideoCodec: getFileStringValue(i, \"videoCodec\"),\n\t\t\tAudioCodec: getFileStringValue(i, \"audioCodec\"),\n\t\t\tFrameRate:  getFileDuration(i) * 2,\n\t\t\tBitRate:    int64(getFileDuration(i)) * 3,\n\t\t}\n\t} else if i >= fileIdxStartImageFiles && i < fileIdxStartGalleryFiles {\n\t\tret = &models.ImageFile{\n\t\t\tBaseFile: baseFile,\n\t\t\tFormat:   getFileStringValue(i, \"format\"),\n\t\t\tWidth:    getWidth(i),\n\t\t\tHeight:   getHeight(i),\n\t\t}\n\t}\n\n\treturn ret\n}\n\nfunc createFiles(ctx context.Context) error {\n\tqb := db.File\n\n\tfor i := 0; i < totalFiles; i++ {\n\t\tfile := makeFile(i)\n\n\t\tif err := qb.Create(ctx, file); err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating file [%d] %v+: %s\", i, file, err.Error())\n\t\t}\n\n\t\tfileIDs = append(fileIDs, file.Base().ID)\n\t}\n\n\treturn nil\n}\n\nfunc getPrefixedStringValue(prefix string, index int, field string) string {\n\treturn fmt.Sprintf(\"%s_%04d_%s\", prefix, index, field)\n}\n\nfunc getPrefixedNullStringValue(prefix string, index int, field string) sql.NullString {\n\tif index > 0 && index%5 == 0 {\n\t\treturn sql.NullString{}\n\t}\n\tif index > 0 && index%6 == 0 {\n\t\treturn sql.NullString{\n\t\t\tString: \"\",\n\t\t\tValid:  true,\n\t\t}\n\t}\n\treturn sql.NullString{\n\t\tString: getPrefixedStringValue(prefix, index, field),\n\t\tValid:  true,\n\t}\n}\n\nfunc getSceneStringValue(index int, field string) string {\n\treturn getPrefixedStringValue(\"scene\", index, field)\n}\n\nfunc getScenePhash(index int, field string) int64 {\n\treturn int64(index % (totalScenes - dupeScenePhashes) * 1234)\n}\n\nfunc getSceneStringPtr(index int, field string) *string {\n\tv := getPrefixedStringValue(\"scene\", index, field)\n\treturn &v\n}\n\nfunc getSceneNullStringPtr(index int, field string) *string {\n\treturn getStringPtrFromNullString(getPrefixedNullStringValue(\"scene\", index, field))\n}\n\nfunc getSceneEmptyString(index int, field string) string {\n\tv := getSceneNullStringPtr(index, field)\n\tif v == nil {\n\t\treturn \"\"\n\t}\n\n\treturn *v\n}\n\nfunc getSceneTitle(index int) string {\n\tswitch index {\n\tcase sceneIdxWithSpacedName:\n\t\treturn spacedSceneTitle\n\tdefault:\n\t\treturn getSceneStringValue(index, titleField)\n\t}\n}\n\nfunc getRating(index int) sql.NullInt64 {\n\trating := index % 6\n\treturn sql.NullInt64{Int64: int64(rating * 20), Valid: rating > 0}\n}\n\nfunc getIntPtr(r sql.NullInt64) *int {\n\tif !r.Valid {\n\t\treturn nil\n\t}\n\n\tv := int(r.Int64)\n\treturn &v\n}\n\nfunc getStringPtrFromNullString(r sql.NullString) *string {\n\tif !r.Valid || r.String == \"\" {\n\t\treturn nil\n\t}\n\n\tv := r.String\n\treturn &v\n}\n\nfunc getStringPtr(r string) *string {\n\tif r == \"\" {\n\t\treturn nil\n\t}\n\n\treturn &r\n}\n\nfunc getEmptyStringFromPtr(v *string) string {\n\tif v == nil {\n\t\treturn \"\"\n\t}\n\n\treturn *v\n}\n\nfunc getOCounter(index int) int {\n\treturn index % 3\n}\n\nfunc getSceneDuration(index int) float64 {\n\tduration := index + 1\n\tduration = duration * 100\n\n\treturn float64(duration) + 0.432\n}\n\nfunc getHeight(index int) int {\n\theights := []int{200, 240, 300, 480, 700, 720, 800, 1080, 1500, 2160, 3000}\n\theight := heights[index%len(heights)]\n\treturn height\n}\n\nfunc getWidth(index int) int {\n\theight := getHeight(index)\n\treturn height * 2\n}\n\nfunc getObjectDate(index int) *models.Date {\n\tdates := []string{\"null\", \"2000-01-01\", \"0001-01-01\", \"2001-02-03\"}\n\tdate := dates[index%len(dates)]\n\n\tif date == \"null\" {\n\t\treturn nil\n\t}\n\n\tret, _ := models.ParseDate(date)\n\treturn &ret\n}\n\nfunc sceneStashIDs(i int) []models.StashID {\n\tif i%5 == 0 {\n\t\treturn nil\n\t}\n\treturn []models.StashID{sceneStashID(i)}\n}\n\nfunc sceneStashID(i int) models.StashID {\n\treturn models.StashID{\n\t\tStashID:   getSceneStringValue(i, \"stashid\"),\n\t\tEndpoint:  getSceneStringValue(0, \"endpoint\"),\n\t\tUpdatedAt: epochTime,\n\t}\n}\n\nfunc getSceneBasename(index int) string {\n\treturn getSceneStringValue(index, pathField)\n}\n\nfunc makeSceneFile(i int) *models.VideoFile {\n\tfp := []models.Fingerprint{\n\t\t{\n\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\tFingerprint: getSceneStringValue(i, checksumField),\n\t\t},\n\t\t{\n\t\t\tType:        models.FingerprintTypeOshash,\n\t\t\tFingerprint: getSceneStringValue(i, \"oshash\"),\n\t\t},\n\t}\n\n\tif i != sceneIdxMissingPhash {\n\t\tfp = append(fp, models.Fingerprint{\n\t\t\tType:        models.FingerprintTypePhash,\n\t\t\tFingerprint: getScenePhash(i, \"phash\"),\n\t\t})\n\t}\n\n\treturn &models.VideoFile{\n\t\tBaseFile: &models.BaseFile{\n\t\t\tPath:           getFilePath(folderIdxWithSceneFiles, getSceneBasename(i)),\n\t\t\tBasename:       getSceneBasename(i),\n\t\t\tParentFolderID: folderIDs[folderIdxWithSceneFiles],\n\t\t\tFingerprints:   fp,\n\t\t},\n\t\tDuration: getSceneDuration(i),\n\t\tHeight:   getHeight(i),\n\t\tWidth:    getWidth(i),\n\t}\n}\n\nfunc getScenePlayDuration(index int) float64 {\n\tif index%5 == 0 {\n\t\treturn 0\n\t}\n\n\treturn float64(index%5) * 123.4\n}\n\nfunc getSceneResumeTime(index int) float64 {\n\tif index%5 == 0 {\n\t\treturn 0\n\t}\n\n\treturn float64(index%5) * 1.2\n}\n\nfunc makeScene(i int) *models.Scene {\n\ttitle := getSceneTitle(i)\n\tdetails := getSceneStringValue(i, \"Details\")\n\n\tvar studioID *int\n\tif _, ok := sceneStudios[i]; ok {\n\t\tv := studioIDs[sceneStudios[i]]\n\t\tstudioID = &v\n\t}\n\n\tgids := indexesToIDs(galleryIDs, sceneGalleries[i])\n\tpids := indexesToIDs(performerIDs, scenePerformers[i])\n\ttids := indexesToIDs(tagIDs, sceneTags[i])\n\n\tmids := indexesToIDs(groupIDs, sceneGroups[i])\n\n\tgroups := make([]models.GroupsScenes, len(mids))\n\tfor i, m := range mids {\n\t\tgroups[i] = models.GroupsScenes{\n\t\t\tGroupID: m,\n\t\t}\n\t}\n\n\trating := getRating(i)\n\n\treturn &models.Scene{\n\t\tTitle:   title,\n\t\tDetails: details,\n\t\tURLs: models.NewRelatedStrings([]string{\n\t\t\tgetSceneEmptyString(i, urlField),\n\t\t}),\n\t\tRating:       getIntPtr(rating),\n\t\tDate:         getObjectDate(i),\n\t\tStudioID:     studioID,\n\t\tGalleryIDs:   models.NewRelatedIDs(gids),\n\t\tPerformerIDs: models.NewRelatedIDs(pids),\n\t\tTagIDs:       models.NewRelatedIDs(tids),\n\t\tGroups:       models.NewRelatedGroups(groups),\n\t\tStashIDs:     models.NewRelatedStashIDs(sceneStashIDs(i)),\n\t\tPlayDuration: getScenePlayDuration(i),\n\t\tResumeTime:   getSceneResumeTime(i),\n\t}\n}\n\nfunc getSceneCustomFields(index int) map[string]interface{} {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"string\": getSceneStringValue(index, \"custom\"),\n\t\t\"int\":    int64(index % 5),\n\t\t\"real\":   float64(index) / 10,\n\t}\n}\n\nfunc createScenes(ctx context.Context, n int) error {\n\tsqb := db.Scene\n\tfqb := db.File\n\n\tfor i := 0; i < n; i++ {\n\t\tf := makeSceneFile(i)\n\t\tif err := fqb.Create(ctx, f); err != nil {\n\t\t\treturn fmt.Errorf(\"creating scene file: %w\", err)\n\t\t}\n\t\tsceneFileIDs = append(sceneFileIDs, f.ID)\n\n\t\tscene := makeScene(i)\n\n\t\tif err := sqb.Create(ctx, scene, []models.FileID{f.ID}); err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating scene %v+: %s\", scene, err.Error())\n\t\t}\n\n\t\tif err := sqb.SetCustomFields(ctx, scene.ID, models.CustomFieldsInput{Full: getSceneCustomFields(i)}); err != nil {\n\t\t\treturn fmt.Errorf(\"Error setting custom fields for scene %d: %s\", scene.ID, err.Error())\n\t\t}\n\n\t\tsceneIDs = append(sceneIDs, scene.ID)\n\t}\n\n\treturn nil\n}\n\nfunc getImageStringValue(index int, field string) string {\n\treturn fmt.Sprintf(\"image_%04d_%s\", index, field)\n}\n\nfunc getImageNullStringPtr(index int, field string) *string {\n\treturn getStringPtrFromNullString(getPrefixedNullStringValue(\"image\", index, field))\n}\n\nfunc getImageEmptyString(index int, field string) string {\n\tv := getImageNullStringPtr(index, field)\n\tif v == nil {\n\t\treturn \"\"\n\t}\n\n\treturn *v\n}\n\nfunc getImageBasename(index int) string {\n\treturn getImageStringValue(index, pathField)\n}\n\nfunc getImageCustomFields(index int) map[string]interface{} {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"string\": getImageStringValue(index, \"custom\"),\n\t\t\"int\":    int64(index % 5),\n\t\t\"real\":   float64(index) / 10,\n\t}\n}\n\nfunc makeImageFile(i int) *models.ImageFile {\n\treturn &models.ImageFile{\n\t\tBaseFile: &models.BaseFile{\n\t\t\tPath:           getFilePath(folderIdxWithImageFiles, getImageBasename(i)),\n\t\t\tBasename:       getImageBasename(i),\n\t\t\tParentFolderID: folderIDs[folderIdxWithImageFiles],\n\t\t\tFingerprints: []models.Fingerprint{\n\t\t\t\t{\n\t\t\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\t\t\tFingerprint: getImageStringValue(i, checksumField),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tHeight: getHeight(i),\n\t\tWidth:  getWidth(i),\n\t}\n}\n\nfunc makeImage(i int) *models.Image {\n\ttitle := getImageStringValue(i, titleField)\n\tvar studioID *int\n\tif _, ok := imageStudios[i]; ok {\n\t\tv := studioIDs[imageStudios[i]]\n\t\tstudioID = &v\n\t}\n\n\tgids := indexesToIDs(galleryIDs, imageGalleries[i])\n\tpids := indexesToIDs(performerIDs, imagePerformers[i])\n\ttids := indexesToIDs(tagIDs, imageTags[i])\n\n\treturn &models.Image{\n\t\tTitle:   title,\n\t\tDetails: getImageStringValue(i, detailsField),\n\t\tRating:  getIntPtr(getRating(i)),\n\t\tDate:    getObjectDate(i),\n\t\tURLs: models.NewRelatedStrings([]string{\n\t\t\tgetImageEmptyString(i, urlField),\n\t\t}),\n\t\tOCounter:     getOCounter(i),\n\t\tStudioID:     studioID,\n\t\tGalleryIDs:   models.NewRelatedIDs(gids),\n\t\tPerformerIDs: models.NewRelatedIDs(pids),\n\t\tTagIDs:       models.NewRelatedIDs(tids),\n\t}\n}\n\nfunc createImages(ctx context.Context, n int) error {\n\tqb := db.Image\n\tfqb := db.File\n\n\tfor i := 0; i < n; i++ {\n\t\tf := makeImageFile(i)\n\t\tif i == imageIdxInZip {\n\t\t\tf.ZipFileID = &fileIDs[fileIdxZip]\n\t\t}\n\n\t\tif err := fqb.Create(ctx, f); err != nil {\n\t\t\treturn fmt.Errorf(\"creating image file: %w\", err)\n\t\t}\n\t\timageFileIDs = append(imageFileIDs, f.ID)\n\n\t\timage := makeImage(i)\n\n\t\terr := qb.Create(ctx, &models.CreateImageInput{\n\t\t\tImage:        image,\n\t\t\tFileIDs:      []models.FileID{f.ID},\n\t\t\tCustomFields: getImageCustomFields(i),\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating image %v+: %s\", image, err.Error())\n\t\t}\n\n\t\timageIDs = append(imageIDs, image.ID)\n\t}\n\n\treturn nil\n}\n\nfunc getGalleryStringValue(index int, field string) string {\n\treturn getPrefixedStringValue(\"gallery\", index, field)\n}\n\nfunc getGalleryNullStringValue(index int, field string) sql.NullString {\n\treturn getPrefixedNullStringValue(\"gallery\", index, field)\n}\n\nfunc getGalleryNullStringPtr(index int, field string) *string {\n\treturn getStringPtrFromNullString(getPrefixedNullStringValue(\"gallery\", index, field))\n}\n\nfunc getGalleryEmptyString(index int, field string) string {\n\tv := getGalleryNullStringPtr(index, field)\n\tif v == nil {\n\t\treturn \"\"\n\t}\n\n\treturn *v\n}\n\nfunc getGalleryBasename(index int) string {\n\treturn getGalleryStringValue(index, pathField)\n}\n\nfunc makeGalleryFile(i int) *models.BaseFile {\n\treturn &models.BaseFile{\n\t\tPath:           getFilePath(folderIdxWithGalleryFiles, getGalleryBasename(i)),\n\t\tBasename:       getGalleryBasename(i),\n\t\tParentFolderID: folderIDs[folderIdxWithGalleryFiles],\n\t\tFingerprints: []models.Fingerprint{\n\t\t\t{\n\t\t\t\tType:        models.FingerprintTypeMD5,\n\t\t\t\tFingerprint: getGalleryStringValue(i, checksumField),\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc makeGallery(i int, includeScenes bool) *models.Gallery {\n\tvar studioID *int\n\tif _, ok := galleryStudios[i]; ok {\n\t\tv := studioIDs[galleryStudios[i]]\n\t\tstudioID = &v\n\t}\n\n\tpids := indexesToIDs(performerIDs, galleryPerformers[i])\n\ttids := indexesToIDs(tagIDs, galleryTags[i])\n\n\tret := &models.Gallery{\n\t\tTitle: getGalleryStringValue(i, titleField),\n\t\tURLs: models.NewRelatedStrings([]string{\n\t\t\tgetGalleryEmptyString(i, urlField),\n\t\t}),\n\t\tRating:       getIntPtr(getRating(i)),\n\t\tDate:         getObjectDate(i),\n\t\tStudioID:     studioID,\n\t\tPerformerIDs: models.NewRelatedIDs(pids),\n\t\tTagIDs:       models.NewRelatedIDs(tids),\n\t}\n\n\tif includeScenes {\n\t\tret.SceneIDs = models.NewRelatedIDs(indexesToIDs(sceneIDs, sceneGalleries.reverseLookup(i)))\n\t}\n\n\treturn ret\n}\n\nfunc getGalleryCustomFields(index int) map[string]interface{} {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"string\": getGalleryStringValue(index, \"custom\"),\n\t\t\"int\":    int64(index % 5),\n\t\t\"real\":   float64(index) / 10,\n\t}\n}\n\nfunc createGalleries(ctx context.Context, n int) error {\n\tgqb := db.Gallery\n\tfqb := db.File\n\n\tfor i := 0; i < n; i++ {\n\t\tvar fileIDs []models.FileID\n\t\tif i != galleryIdxWithoutFile {\n\t\t\tf := makeGalleryFile(i)\n\t\t\tif err := fqb.Create(ctx, f); err != nil {\n\t\t\t\treturn fmt.Errorf(\"creating gallery file: %w\", err)\n\t\t\t}\n\t\t\tgalleryFileIDs = append(galleryFileIDs, f.ID)\n\t\t\tfileIDs = []models.FileID{f.ID}\n\t\t} else {\n\t\t\tgalleryFileIDs = append(galleryFileIDs, 0)\n\t\t}\n\n\t\t// gallery relationship will be created with galleries\n\t\tconst includeScenes = false\n\t\tgallery := makeGallery(i, includeScenes)\n\n\t\terr := gqb.Create(ctx, &models.CreateGalleryInput{\n\t\t\tGallery:      gallery,\n\t\t\tFileIDs:      fileIDs,\n\t\t\tCustomFields: getGalleryCustomFields(i),\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating gallery %v+: %s\", gallery, err.Error())\n\t\t}\n\n\t\tgalleryIDs = append(galleryIDs, gallery.ID)\n\t}\n\n\treturn nil\n}\n\nfunc getGroupStringValue(index int, field string) string {\n\treturn getPrefixedStringValue(\"group\", index, field)\n}\n\nfunc getGroupNullStringValue(index int, field string) string {\n\tret := getPrefixedNullStringValue(\"group\", index, field)\n\n\treturn ret.String\n}\n\nfunc getGroupEmptyString(index int, field string) string {\n\tv := getPrefixedNullStringValue(\"group\", index, field)\n\tif !v.Valid {\n\t\treturn \"\"\n\t}\n\n\treturn v.String\n}\n\nfunc getGroupCustomFields(index int) map[string]interface{} {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"string\": getGroupStringValue(index, \"custom\"),\n\t\t\"int\":    int64(index % 5),\n\t\t\"real\":   float64(index) / 10,\n\t}\n}\n\n// createGroups creates n groups with plain Name and o groups with camel cased NaMe included\nfunc createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o int) error {\n\tconst namePlain = \"Name\"\n\tconst nameNoCase = \"NaMe\"\n\n\tfor i := 0; i < n+o; i++ {\n\t\tindex := i\n\t\tname := namePlain\n\n\t\ttids := indexesToIDs(tagIDs, groupTags[i])\n\n\t\tif i >= n { // i<n tags get normal names\n\t\t\tname = nameNoCase       // i>=n groups get dup names if case is not checked\n\t\t\tindex = n + o - (i + 1) // for the name to be the same the number (index) must be the same also\n\t\t} // so count backwards to 0 as needed\n\t\t// groups [ i ] and [ n + o - i - 1  ] should have similar names with only the Name!=NaMe part different\n\n\t\tname = getGroupStringValue(index, name)\n\t\tgroup := models.Group{\n\t\t\tName: name,\n\t\t\tURLs: models.NewRelatedStrings([]string{\n\t\t\t\tgetGroupEmptyString(i, urlField),\n\t\t\t}),\n\t\t\tTagIDs: models.NewRelatedIDs(tids),\n\t\t}\n\n\t\terr := mqb.Create(ctx, &group)\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating group [%d] %v+: %s\", i, group, err.Error())\n\t\t}\n\n\t\tcustomFields := getGroupCustomFields(i)\n\t\tif customFields != nil {\n\t\t\tif err := mqb.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{Full: customFields}); err != nil {\n\t\t\t\treturn fmt.Errorf(\"Error setting custom fields for group %d: %s\", group.ID, err.Error())\n\t\t\t}\n\t\t}\n\n\t\tgroupIDs = append(groupIDs, group.ID)\n\t\tgroupNames = append(groupNames, group.Name)\n\t}\n\n\treturn nil\n}\n\nfunc getPerformerStringValue(index int, field string) string {\n\treturn getPrefixedStringValue(\"performer\", index, field)\n}\n\nfunc getPerformerNullStringValue(index int, field string) string {\n\tret := getPrefixedNullStringValue(\"performer\", index, field)\n\n\treturn ret.String\n}\n\nfunc getPerformerEmptyString(index int, field string) string {\n\tv := getPrefixedNullStringValue(\"performer\", index, field)\n\tif !v.Valid {\n\t\treturn \"\"\n\t}\n\n\treturn v.String\n}\n\nfunc getPerformerBoolValue(index int) bool {\n\tindex = index % 2\n\treturn index == 1\n}\n\nfunc getPerformerBirthdate(index int) *models.Date {\n\tconst minAge = 18\n\tbirthdate := time.Now()\n\tbirthdate = birthdate.AddDate(-minAge-index, -1, -1)\n\n\tret := models.Date{\n\t\tTime: birthdate,\n\t}\n\treturn &ret\n}\n\nfunc getPerformerDeathDate(index int) *models.Date {\n\tif index != 5 {\n\t\treturn nil\n\t}\n\n\tdeathDate := time.Now()\n\tdeathDate = deathDate.AddDate(-index+1, -1, -1)\n\n\tret := models.Date{\n\t\tTime: deathDate,\n\t}\n\treturn &ret\n}\n\nfunc getPerformerCareerStart(index int) *models.Date {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\tdate := models.DateFromYear(2000 + index)\n\treturn &date\n}\n\nfunc getPerformerCareerEnd(index int) *models.Date {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\t// only set career_end for even indices\n\tif index%2 == 0 {\n\t\tdate := models.DateFromYear(2010 + index)\n\t\treturn &date\n\t}\n\treturn nil\n}\n\nfunc getPerformerPenisLength(index int) *float64 {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\tret := float64(index)\n\treturn &ret\n}\n\nfunc getPerformerCircumcised(index int) *models.CircumcisedEnum {\n\tvar ret models.CircumcisedEnum\n\tswitch {\n\tcase index%3 == 0:\n\t\treturn nil\n\tcase index%3 == 1:\n\t\tret = models.CircumcisedEnumCut\n\tdefault:\n\t\tret = models.CircumcisedEnumUncut\n\t}\n\n\treturn &ret\n}\n\nfunc getIgnoreAutoTag(index int) bool {\n\treturn index%5 == 0\n}\n\nfunc performerStashID(i int) models.StashID {\n\treturn models.StashID{\n\t\tStashID:  getPerformerStringValue(i, \"stashid\"),\n\t\tEndpoint: getPerformerStringValue(0, \"endpoint\"),\n\t}\n}\n\nfunc performerAliases(i int) []string {\n\tif i%5 == 0 {\n\t\treturn []string{}\n\t}\n\n\treturn []string{getPerformerStringValue(i, \"alias\")}\n}\n\nfunc getPerformerCustomFields(index int) map[string]interface{} {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"string\": getPerformerStringValue(index, \"custom\"),\n\t\t\"int\":    int64(index % 5),\n\t\t\"real\":   float64(index) / 10,\n\t}\n}\n\n// createPerformers creates n performers with plain Name and o performers with camel cased NaMe included\nfunc createPerformers(ctx context.Context, n int, o int) error {\n\tpqb := db.Performer\n\n\tconst namePlain = \"Name\"\n\tconst nameNoCase = \"NaMe\"\n\n\tname := namePlain\n\n\tfor i := 0; i < n+o; i++ {\n\t\tindex := i\n\n\t\tif i >= n { // i<n tags get normal names\n\t\t\tname = nameNoCase       // i>=n performers get dup names if case is not checked\n\t\t\tindex = n + o - (i + 1) // for the name to be the same the number (index) must be the same also\n\t\t} // so count backwards to 0 as needed\n\t\t// performers [ i ] and [ n + o - i - 1  ] should have similar names with only the Name!=NaMe part different\n\n\t\ttids := indexesToIDs(tagIDs, performerTags[i])\n\n\t\tperformer := models.Performer{\n\t\t\tName:           getPerformerStringValue(index, name),\n\t\t\tDisambiguation: getPerformerStringValue(index, \"disambiguation\"),\n\t\t\tAliases:        models.NewRelatedStrings(performerAliases(index)),\n\t\t\tURLs: models.NewRelatedStrings([]string{\n\t\t\t\tgetPerformerEmptyString(i, urlField),\n\t\t\t}),\n\t\t\tFavorite:      getPerformerBoolValue(i),\n\t\t\tBirthdate:     getPerformerBirthdate(i),\n\t\t\tDeathDate:     getPerformerDeathDate(i),\n\t\t\tDetails:       getPerformerStringValue(i, \"Details\"),\n\t\t\tEthnicity:     getPerformerStringValue(i, \"Ethnicity\"),\n\t\t\tPenisLength:   getPerformerPenisLength(i),\n\t\t\tCircumcised:   getPerformerCircumcised(i),\n\t\t\tRating:        getIntPtr(getRating(i)),\n\t\t\tIgnoreAutoTag: getIgnoreAutoTag(i),\n\t\t\tTagIDs:        models.NewRelatedIDs(tids),\n\t\t}\n\n\t\tperformer.CareerStart = getPerformerCareerStart(i)\n\t\tperformer.CareerEnd = getPerformerCareerEnd(i)\n\n\t\tif (index+1)%5 != 0 {\n\t\t\tperformer.StashIDs = models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\tperformerStashID(i),\n\t\t\t})\n\t\t}\n\n\t\terr := pqb.Create(ctx, &models.CreatePerformerInput{\n\t\t\tPerformer:    &performer,\n\t\t\tCustomFields: getPerformerCustomFields(i),\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating performer %v+: %s\", performer, err.Error())\n\t\t}\n\n\t\tperformerIDs = append(performerIDs, performer.ID)\n\t\tperformerNames = append(performerNames, performer.Name)\n\t}\n\n\treturn nil\n}\nfunc getTagBoolValue(index int) bool {\n\tindex = index % 2\n\treturn index == 1\n}\nfunc getTagStringValue(index int, field string) string {\n\treturn \"tag_\" + strconv.FormatInt(int64(index), 10) + \"_\" + field\n}\n\nfunc getTagSceneCount(id int) int {\n\tidx := indexFromID(tagIDs, id)\n\treturn len(sceneTags.reverseLookup(idx))\n}\n\nfunc getTagMarkerCount(id int) int {\n\tcount := 0\n\tidx := indexFromID(tagIDs, id)\n\tfor _, s := range markerSpecs {\n\t\tif s.primaryTagIdx == idx || slices.Contains(s.tagIdxs, idx) {\n\t\t\tcount++\n\t\t}\n\t}\n\n\treturn count\n}\n\nfunc getTagImageCount(id int) int {\n\tidx := indexFromID(tagIDs, id)\n\treturn len(imageTags.reverseLookup(idx))\n}\n\nfunc getTagGalleryCount(id int) int {\n\tidx := indexFromID(tagIDs, id)\n\treturn len(galleryTags.reverseLookup(idx))\n}\n\nfunc getTagPerformerCount(id int) int {\n\tidx := indexFromID(tagIDs, id)\n\treturn len(performerTags.reverseLookup(idx))\n}\n\nfunc getTagStudioCount(id int) int {\n\tidx := indexFromID(tagIDs, id)\n\treturn len(studioTags.reverseLookup(idx))\n}\n\nfunc getTagParentCount(id int) int {\n\tif id == tagIDs[tagIdxWithParentTag] || id == tagIDs[tagIdxWithGrandParent] || id == tagIDs[tagIdxWithParentAndChild] {\n\t\treturn 1\n\t}\n\n\treturn 0\n}\n\nfunc getTagChildCount(id int) int {\n\tif id == tagIDs[tagIdxWithChildTag] || id == tagIDs[tagIdxWithGrandChild] || id == tagIDs[tagIdxWithParentAndChild] {\n\t\treturn 1\n\t}\n\n\treturn 0\n}\n\nfunc tagStashID(i int) models.StashID {\n\treturn models.StashID{\n\t\tStashID:  getTagStringValue(i, \"stashid\"),\n\t\tEndpoint: getTagStringValue(0, \"endpoint\"),\n\t}\n}\n\nfunc getTagCustomFields(index int) map[string]interface{} {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"string\": getTagStringValue(index, \"custom\"),\n\t\t\"int\":    int64(index % 5),\n\t\t\"real\":   float64(index) / 10,\n\t}\n}\n\n// createTags creates n tags with plain Name and o tags with camel cased NaMe included\nfunc createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) error {\n\tconst namePlain = \"Name\"\n\tconst nameNoCase = \"NaMe\"\n\n\tname := namePlain\n\n\tfor i := 0; i < n+o; i++ {\n\t\tindex := i\n\n\t\tif i >= n { // i<n tags get normal names\n\t\t\tname = nameNoCase       // i>=n tags get dup names if case is not checked\n\t\t\tindex = n + o - (i + 1) // for the name to be the same the number (index) must be the same also\n\t\t} // so count backwards to 0 as needed\n\t\t// tags [ i ] and [ n + o - i - 1  ] should have similar names with only the Name!=NaMe part different\n\n\t\ttag := models.Tag{\n\t\t\tName:          getTagStringValue(index, name),\n\t\t\tIgnoreAutoTag: getIgnoreAutoTag(i),\n\t\t}\n\n\t\tif (index+1)%5 != 0 {\n\t\t\ttag.StashIDs = models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\ttagStashID(i),\n\t\t\t})\n\t\t}\n\n\t\terr := tqb.Create(ctx, &models.CreateTagInput{\n\t\t\tTag:          &tag,\n\t\t\tCustomFields: getTagCustomFields(i),\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating tag %v+: %s\", tag, err.Error())\n\t\t}\n\n\t\t// add alias\n\t\talias := getTagStringValue(i, \"Alias\")\n\t\tif err := tqb.UpdateAliases(ctx, tag.ID, []string{alias}); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting tag alias: %s\", err.Error())\n\t\t}\n\n\t\ttagIDs = append(tagIDs, tag.ID)\n\t\ttagNames = append(tagNames, tag.Name)\n\t}\n\n\treturn nil\n}\n\nfunc getStudioStringValue(index int, field string) string {\n\treturn getPrefixedStringValue(\"studio\", index, field)\n}\n\nfunc getStudioNullStringValue(index int, field string) string {\n\tret := getPrefixedNullStringValue(\"studio\", index, field)\n\n\treturn ret.String\n}\n\nfunc getStudioCustomFields(index int) map[string]interface{} {\n\tif index%5 == 0 {\n\t\treturn nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"string\": getStudioStringValue(index, \"custom\"),\n\t\t\"int\":    int64(index % 5),\n\t\t\"real\":   float64(index) / 10,\n\t}\n}\n\nfunc createStudio(ctx context.Context, sqb *sqlite.StudioStore, name string, parentID *int, customFields map[string]interface{}) (*models.Studio, error) {\n\tstudio := models.Studio{\n\t\tName: name,\n\t}\n\n\tif parentID != nil {\n\t\tstudio.ParentID = parentID\n\t}\n\n\terr := createStudioFromModel(ctx, sqb, &studio, customFields)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &studio, nil\n}\n\nfunc createStudioFromModel(ctx context.Context, sqb *sqlite.StudioStore, studio *models.Studio, customFields map[string]interface{}) error {\n\terr := sqb.Create(ctx, &models.CreateStudioInput{\n\t\tStudio:       studio,\n\t\tCustomFields: customFields,\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Error creating studio %v+: %s\", studio, err.Error())\n\t}\n\n\treturn nil\n}\n\nfunc getStudioBoolValue(index int) bool {\n\tindex = index % 2\n\treturn index == 1\n}\n\nfunc getStudioEmptyString(index int, field string) string {\n\tv := getPrefixedNullStringValue(\"studio\", index, field)\n\tif !v.Valid {\n\t\treturn \"\"\n\t}\n\n\treturn v.String\n}\n\nfunc getStudioStringList(index int, field string) []string {\n\tv := getStudioEmptyString(index, field)\n\tif v == \"\" {\n\t\treturn []string{}\n\t}\n\n\treturn []string{v}\n}\n\n// createStudios creates n studios with plain Name and o studios with camel cased NaMe included\nfunc createStudios(ctx context.Context, n int, o int) error {\n\tsqb := db.Studio\n\tconst namePlain = \"Name\"\n\tconst nameNoCase = \"NaMe\"\n\n\tfor i := 0; i < n+o; i++ {\n\t\tindex := i\n\t\tname := namePlain\n\n\t\tif i >= n { // i<n studios get normal names\n\t\t\tname = nameNoCase       // i>=n studios get dup names if case is not checked\n\t\t\tindex = n + o - (i + 1) // for the name to be the same the number (index) must be the same also\n\t\t} // so count backwards to 0 as needed\n\t\t// studios [ i ] and [ n + o - i - 1  ] should have similar names with only the Name!=NaMe part different\n\n\t\tname = getStudioStringValue(index, name)\n\t\ttids := indexesToIDs(tagIDs, studioTags[i])\n\t\tstudio := models.Studio{\n\t\t\tName:          name,\n\t\t\tURLs:          models.NewRelatedStrings(getStudioStringList(i, urlField)),\n\t\t\tFavorite:      getStudioBoolValue(index),\n\t\t\tIgnoreAutoTag: getIgnoreAutoTag(i),\n\t\t\tTagIDs:        models.NewRelatedIDs(tids),\n\t\t}\n\t\t// only add aliases for some scenes\n\t\tif i == studioIdxWithGroup || i%5 == 0 {\n\t\t\talias := getStudioStringValue(i, \"Alias\")\n\t\t\tstudio.Aliases = models.NewRelatedStrings([]string{alias})\n\t\t}\n\t\terr := createStudioFromModel(ctx, sqb, &studio, getStudioCustomFields(i))\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tstudioIDs = append(studioIDs, studio.ID)\n\t\tstudioNames = append(studioNames, studio.Name)\n\t}\n\n\treturn nil\n}\n\nfunc getMarkerEndSeconds(index int) *float64 {\n\tif index != markerIdxWithDuration && index != markerIdx2WithDuration {\n\t\treturn nil\n\t}\n\tret := float64(index)\n\treturn &ret\n}\n\nfunc createMarker(ctx context.Context, mqb models.SceneMarkerReaderWriter, markerSpec markerSpec) error {\n\tmarkerIdx := len(markerIDs)\n\tmarker := models.SceneMarker{\n\t\tSceneID:      sceneIDs[markerSpec.sceneIdx],\n\t\tPrimaryTagID: tagIDs[markerSpec.primaryTagIdx],\n\t\tEndSeconds:   getMarkerEndSeconds(markerIdx),\n\t}\n\n\terr := mqb.Create(ctx, &marker)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating marker %v+: %w\", marker, err)\n\t}\n\n\tmarkerIDs = append(markerIDs, marker.ID)\n\n\tif len(markerSpec.tagIdxs) > 0 {\n\t\tnewTagIDs := []int{}\n\n\t\tfor _, tagIdx := range markerSpec.tagIdxs {\n\t\t\tnewTagIDs = append(newTagIDs, tagIDs[tagIdx])\n\t\t}\n\n\t\tif err := mqb.UpdateTags(ctx, marker.ID, newTagIDs); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating marker/tag join: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc createChapter(ctx context.Context, mqb models.GalleryChapterReaderWriter, chapterSpec chapterSpec) error {\n\tchapter := models.GalleryChapter{\n\t\tGalleryID:  sceneIDs[chapterSpec.galleryIdx],\n\t\tTitle:      chapterSpec.title,\n\t\tImageIndex: chapterSpec.imageIndex,\n\t}\n\n\terr := mqb.Create(ctx, &chapter)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating chapter %v+: %w\", chapter, err)\n\t}\n\n\tchapterIDs = append(chapterIDs, chapter.ID)\n\n\treturn nil\n}\n\nfunc getSavedFilterMode(index int) models.FilterMode {\n\tswitch index {\n\tcase savedFilterIdxScene:\n\t\treturn models.FilterModeScenes\n\tcase savedFilterIdxImage:\n\t\treturn models.FilterModeImages\n\tdefault:\n\t\treturn models.FilterModeScenes\n\t}\n}\n\nfunc getSavedFilterName(index int) string {\n\tif index <= savedFilterIdxImage {\n\t\t// use the same name for the first two - should be possible\n\t\treturn firstSavedFilterName\n\t}\n\n\treturn getPrefixedStringValue(\"savedFilter\", index, \"Name\")\n}\n\nfunc createSavedFilters(ctx context.Context, qb models.SavedFilterReaderWriter, n int) error {\n\tfor i := 0; i < n; i++ {\n\t\tfilterQ := \"\"\n\t\tfilterPage := i\n\t\tfilterPerPage := i * 40\n\t\tfilterSort := \"date\"\n\t\tfilterDirection := models.SortDirectionEnumAsc\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ:         &filterQ,\n\t\t\tPage:      &filterPage,\n\t\t\tPerPage:   &filterPerPage,\n\t\t\tSort:      &filterSort,\n\t\t\tDirection: &filterDirection,\n\t\t}\n\t\tsavedFilter := models.SavedFilter{\n\t\t\tMode:       getSavedFilterMode(i),\n\t\t\tName:       getSavedFilterName(i),\n\t\t\tFindFilter: &findFilter,\n\t\t\tObjectFilter: map[string]interface{}{\n\t\t\t\t\"test\": \"object\",\n\t\t\t},\n\t\t\tUIOptions: map[string]interface{}{\n\t\t\t\t\"display_mode\": 1,\n\t\t\t\t\"zoom_index\":   1,\n\t\t\t},\n\t\t}\n\n\t\terr := qb.Create(ctx, &savedFilter)\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating saved filter %v+: %s\", savedFilter, err.Error())\n\t\t}\n\n\t\tsavedFilterIDs = append(savedFilterIDs, savedFilter.ID)\n\t}\n\n\treturn nil\n}\n\nfunc doLinks(links [][2]int, fn func(idx1, idx2 int) error) error {\n\tfor _, l := range links {\n\t\tif err := fn(l[0], l[1]); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc linkGroupStudios(ctx context.Context, mqb models.GroupWriter) error {\n\treturn doLinks(groupStudioLinks, func(groupIndex, studioIndex int) error {\n\t\tgroup := models.GroupPartial{\n\t\t\tStudioID: models.NewOptionalInt(studioIDs[studioIndex]),\n\t\t}\n\t\t_, err := mqb.UpdatePartial(ctx, groupIDs[groupIndex], group)\n\n\t\treturn err\n\t})\n}\n\nfunc linkStudiosParent(ctx context.Context) error {\n\tqb := db.Studio\n\treturn doLinks(studioParentLinks, func(parentIndex, childIndex int) error {\n\t\tinput := &models.StudioPartial{\n\t\t\tID:       studioIDs[childIndex],\n\t\t\tParentID: models.NewOptionalInt(studioIDs[parentIndex]),\n\t\t}\n\t\t_, err := qb.UpdatePartial(ctx, *input)\n\n\t\treturn err\n\t})\n}\n\nfunc linkTagsParent(ctx context.Context, qb models.TagReaderWriter) error {\n\treturn doLinks(tagParentLinks, func(parentIndex, childIndex int) error {\n\t\ttagID := tagIDs[childIndex]\n\t\tparentTags, err := qb.FindByChildTagID(ctx, tagID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar parentIDs []int\n\t\tfor _, parentTag := range parentTags {\n\t\t\tparentIDs = append(parentIDs, parentTag.ID)\n\t\t}\n\n\t\tparentIDs = append(parentIDs, tagIDs[parentIndex])\n\n\t\treturn qb.UpdateParentTags(ctx, tagID, parentIDs)\n\t})\n}\n\nfunc linkGroupsParent(ctx context.Context, qb models.GroupReaderWriter) error {\n\treturn doLinks(groupParentLinks, func(parentIndex, childIndex int) error {\n\t\tgroupID := groupIDs[childIndex]\n\n\t\tp := models.GroupPartial{\n\t\t\tContainingGroups: &models.UpdateGroupDescriptions{\n\t\t\t\tGroups: []models.GroupIDDescription{\n\t\t\t\t\t{GroupID: groupIDs[parentIndex]},\n\t\t\t\t},\n\t\t\t\tMode: models.RelationshipUpdateModeAdd,\n\t\t\t},\n\t\t}\n\n\t\t_, err := qb.UpdatePartial(ctx, groupID, p)\n\t\treturn err\n\t})\n}\n\nfunc addTagImage(ctx context.Context, qb models.TagWriter, tagIndex int) error {\n\treturn qb.UpdateImage(ctx, tagIDs[tagIndex], []byte(\"image\"))\n}\n"
  },
  {
    "path": "pkg/sqlite/sql.go",
    "content": "package sqlite\n\nimport (\n\t\"fmt\"\n\t\"math/rand\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc selectAll(tableName string) string {\n\tidColumn := getColumn(tableName, \"*\")\n\treturn \"SELECT \" + idColumn + \" FROM \" + tableName + \" \"\n}\n\nfunc distinctIDs(qb *queryBuilder, tableName string) {\n\tqb.addColumn(\"DISTINCT \" + getColumn(tableName, \"id\"))\n\tqb.from = tableName\n}\n\nfunc selectIDs(qb *queryBuilder, tableName string) {\n\tqb.addColumn(getColumn(tableName, \"id\"))\n\tqb.from = tableName\n}\n\nfunc getColumn(tableName string, columnName string) string {\n\treturn tableName + \".\" + columnName\n}\n\nfunc getPagination(findFilter *models.FindFilterType) string {\n\tif findFilter == nil {\n\t\tpanic(\"nil find filter for pagination\")\n\t}\n\n\tif findFilter.IsGetAll() {\n\t\treturn \" \"\n\t}\n\n\treturn getPaginationSQL(findFilter.GetPage(), findFilter.GetPageSize())\n}\n\nfunc getPaginationSQL(page int, perPage int) string {\n\tpage = (page - 1) * perPage\n\treturn \" LIMIT \" + strconv.Itoa(perPage) + \" OFFSET \" + strconv.Itoa(page) + \" \"\n}\n\nconst randomSeedPrefix = \"random_\" // prefix for random sort\n\ntype sortOptions []string\n\nfunc (o sortOptions) validateSort(sort string) error {\n\tif strings.HasPrefix(sort, randomSeedPrefix) {\n\t\t// seed as a parameter from the UI\n\t\tseedStr := sort[len(randomSeedPrefix):]\n\t\t_, err := strconv.ParseUint(seedStr, 10, 64)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid random seed: %s\", seedStr)\n\t\t}\n\t\treturn nil\n\t}\n\n\tfor _, v := range o {\n\t\tif v == sort {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"invalid sort: %s\", sort)\n}\n\nfunc validateIsMissing(isMissing string, allowed []string) error {\n\tfor _, v := range allowed {\n\t\tif v == isMissing {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"invalid is_missing field: %s\", isMissing)\n}\n\nfunc getSortDirection(direction string) string {\n\tif direction != \"ASC\" && direction != \"DESC\" {\n\t\treturn \"ASC\"\n\t} else {\n\t\treturn direction\n\t}\n}\nfunc getSort(sort string, direction string, tableName string) string {\n\tdirection = getSortDirection(direction)\n\n\tswitch {\n\tcase strings.HasSuffix(sort, \"_count\"):\n\t\tvar relationTableName = strings.TrimSuffix(sort, \"_count\") // TODO: pluralize?\n\t\tcolName := getColumn(relationTableName, \"id\")\n\t\treturn \" ORDER BY COUNT(distinct \" + colName + \") \" + direction\n\tcase strings.Compare(sort, \"filesize\") == 0:\n\t\tcolName := getColumn(tableName, \"size\")\n\t\treturn \" ORDER BY \" + colName + \" \" + direction\n\tcase strings.HasPrefix(sort, randomSeedPrefix):\n\t\t// seed as a parameter from the UI\n\t\tseedStr := sort[len(randomSeedPrefix):]\n\t\tseed, err := strconv.ParseUint(seedStr, 10, 64)\n\t\tif err != nil {\n\t\t\t// fallback to a random seed\n\t\t\tseed = rand.Uint64()\n\t\t}\n\t\treturn getRandomSort(tableName, direction, seed)\n\tcase strings.Compare(sort, \"random\") == 0:\n\t\treturn getRandomSort(tableName, direction, rand.Uint64())\n\tdefault:\n\t\tcolName := getColumn(tableName, sort)\n\t\tif strings.Contains(sort, \".\") {\n\t\t\tcolName = sort\n\t\t}\n\t\tif strings.Compare(sort, \"name\") == 0 {\n\t\t\treturn \" ORDER BY \" + colName + \" COLLATE NATURAL_CI \" + direction\n\t\t}\n\t\tif strings.Compare(sort, \"title\") == 0 {\n\t\t\treturn \" ORDER BY \" + colName + \" COLLATE NATURAL_CI \" + direction\n\t\t}\n\n\t\treturn \" ORDER BY \" + colName + \" \" + direction\n\t}\n}\n\nfunc getRandomSort(tableName string, direction string, seed uint64) string {\n\t// cap seed at 10^8\n\tseed %= 1e8\n\n\tcolName := getColumn(tableName, \"id\")\n\n\t// https://stackoverflow.com/questions/21949795#comment33255354_21949859\n\t// p1 := 52959209\n\t// p2 := 1047483763\n\t// p3 := 2147483647\n\t// n := <colName>\n\t// ORDER BY ((n+seed)*(n+seed)*p1 + (n+seed)*p2) % p3\n\t// since sqlite converts overflowing numbers to reals, a custom db function that uses uints with overflow should be faster,\n\t// however in practice the overhead of calling a custom function vastly outweighs the benefits\n\treturn fmt.Sprintf(\" ORDER BY mod((%[1]s + %[2]d) * (%[1]s + %[2]d) * 52959209 + (%[1]s + %[2]d) * 1047483763, 2147483647) %[3]s\", colName, seed, direction)\n}\n\nfunc getCountSort(primaryTable, joinTable, primaryFK, direction string) string {\n\treturn fmt.Sprintf(\" ORDER BY (SELECT COUNT(*) FROM %s AS sort WHERE sort.%s = %s.id) %s\", joinTable, primaryFK, primaryTable, getSortDirection(direction))\n}\n\n// getStringSearchClause returns a sqlClause for searching strings in the provided columns.\n// It is used for includes and excludes string criteria.\nfunc getStringSearchClause(columns []string, q string, not bool) sqlClause {\n\tvar likeClauses []string\n\tvar args []interface{}\n\n\tnotStr := \"\"\n\tbinaryType := \" OR \"\n\tif not {\n\t\tnotStr = \" NOT\"\n\t\tbinaryType = \" AND \"\n\t}\n\tq = strings.TrimSpace(q)\n\ttrimmedQuery := strings.Trim(q, \"\\\"\")\n\n\tif trimmedQuery == q {\n\t\tq = regexp.MustCompile(`\\s+`).ReplaceAllString(q, \" \")\n\t\tqueryWords := strings.Split(q, \" \")\n\t\t// Search for any word\n\t\tfor _, word := range queryWords {\n\t\t\tfor _, column := range columns {\n\t\t\t\tlikeClauses = append(likeClauses, column+notStr+\" LIKE ?\")\n\t\t\t\targs = append(args, \"%\"+word+\"%\")\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Search the exact query\n\t\tfor _, column := range columns {\n\t\t\tlikeClauses = append(likeClauses, column+notStr+\" LIKE ?\")\n\t\t\targs = append(args, \"%\"+trimmedQuery+\"%\")\n\t\t}\n\t}\n\tlikes := strings.Join(likeClauses, binaryType)\n\n\treturn makeClause(\"(\"+likes+\")\", args...)\n}\n\nfunc getEnumSearchClause(column string, enumVals []string, not bool) sqlClause {\n\tvar args []interface{}\n\n\tnotStr := \"\"\n\tif not {\n\t\tnotStr = \" NOT\"\n\t}\n\n\tclause := fmt.Sprintf(\"(%s%s IN %s)\", column, notStr, getInBinding(len(enumVals)))\n\tfor _, enumVal := range enumVals {\n\t\targs = append(args, enumVal)\n\t}\n\n\treturn makeClause(clause, args...)\n}\n\nfunc getInBinding(length int) string {\n\tbindings := strings.Repeat(\"?, \", length)\n\tbindings = strings.TrimRight(bindings, \", \")\n\treturn \"(\" + bindings + \")\"\n}\n\nfunc getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) {\n\treturn getIntWhereClause(column, input.Modifier, input.Value, input.Value2)\n}\n\nfunc getIntWhereClause(column string, modifier models.CriterionModifier, value int, upper *int) (string, []interface{}) {\n\tif upper == nil {\n\t\tu := 0\n\t\tupper = &u\n\t}\n\n\targs := []interface{}{value, *upper}\n\treturn getNumericWhereClause(column, modifier, args)\n}\n\nfunc getFloatCriterionWhereClause(column string, input models.FloatCriterionInput) (string, []interface{}) {\n\treturn getFloatWhereClause(column, input.Modifier, input.Value, input.Value2)\n}\n\nfunc getFloatWhereClause(column string, modifier models.CriterionModifier, value float64, upper *float64) (string, []interface{}) {\n\tif upper == nil {\n\t\tu := 0.0\n\t\tupper = &u\n\t}\n\n\targs := []interface{}{value, *upper}\n\treturn getNumericWhereClause(column, modifier, args)\n}\n\nfunc getNumericWhereClause(column string, modifier models.CriterionModifier, args []interface{}) (string, []interface{}) {\n\tsingleArgs := args[0:1]\n\n\tswitch modifier {\n\tcase models.CriterionModifierIsNull:\n\t\treturn fmt.Sprintf(\"%s IS NULL\", column), nil\n\tcase models.CriterionModifierNotNull:\n\t\treturn fmt.Sprintf(\"%s IS NOT NULL\", column), nil\n\tcase models.CriterionModifierEquals:\n\t\treturn fmt.Sprintf(\"%s = ?\", column), singleArgs\n\tcase models.CriterionModifierNotEquals:\n\t\treturn fmt.Sprintf(\"%s != ?\", column), singleArgs\n\tcase models.CriterionModifierBetween:\n\t\treturn fmt.Sprintf(\"%s BETWEEN ? AND ?\", column), args\n\tcase models.CriterionModifierNotBetween:\n\t\treturn fmt.Sprintf(\"%s NOT BETWEEN ? AND ?\", column), args\n\tcase models.CriterionModifierLessThan:\n\t\treturn fmt.Sprintf(\"%s < ?\", column), singleArgs\n\tcase models.CriterionModifierGreaterThan:\n\t\treturn fmt.Sprintf(\"%s > ?\", column), singleArgs\n\t}\n\n\tpanic(\"unsupported numeric modifier type \" + modifier)\n}\n\nfunc getDateCriterionWhereClause(column string, input models.DateCriterionInput) (string, []interface{}) {\n\treturn getDateWhereClause(column, input.Modifier, input.Value, input.Value2)\n}\n\nfunc getDateWhereClause(column string, modifier models.CriterionModifier, value string, upper *string) (string, []interface{}) {\n\tif upper == nil {\n\t\tu := time.Now().AddDate(0, 0, 1).Format(time.RFC3339)\n\t\tupper = &u\n\t}\n\n\tvalueDate, _ := models.ParseDate(value)\n\tdate := Date{Date: valueDate.Time}\n\n\targs := []interface{}{date}\n\tbetweenArgs := []interface{}{date, *upper}\n\n\tswitch modifier {\n\tcase models.CriterionModifierIsNull:\n\t\treturn fmt.Sprintf(\"(%s IS NULL OR %s = '')\", column, column), nil\n\tcase models.CriterionModifierNotNull:\n\t\treturn fmt.Sprintf(\"(%s IS NOT NULL AND %s != '')\", column, column), nil\n\tcase models.CriterionModifierEquals:\n\t\treturn fmt.Sprintf(\"%s = ?\", column), args\n\tcase models.CriterionModifierNotEquals:\n\t\treturn fmt.Sprintf(\"%s != ?\", column), args\n\tcase models.CriterionModifierBetween:\n\t\treturn fmt.Sprintf(\"%s BETWEEN ? AND ?\", column), betweenArgs\n\tcase models.CriterionModifierNotBetween:\n\t\treturn fmt.Sprintf(\"%s NOT BETWEEN ? AND ?\", column), betweenArgs\n\tcase models.CriterionModifierLessThan:\n\t\treturn fmt.Sprintf(\"%s < ?\", column), args\n\tcase models.CriterionModifierGreaterThan:\n\t\treturn fmt.Sprintf(\"%s > ?\", column), args\n\t}\n\n\tpanic(\"unsupported date modifier type\")\n}\n\nfunc getTimestampCriterionWhereClause(column string, input models.TimestampCriterionInput) (string, []interface{}) {\n\treturn getTimestampWhereClause(column, input.Modifier, input.Value, input.Value2)\n}\n\nfunc getTimestampWhereClause(column string, modifier models.CriterionModifier, value string, upper *string) (string, []interface{}) {\n\tif upper == nil {\n\t\tu := time.Now().AddDate(0, 0, 1).Format(time.RFC3339)\n\t\tupper = &u\n\t}\n\n\targs := []interface{}{value}\n\tbetweenArgs := []interface{}{value, *upper}\n\n\tswitch modifier {\n\tcase models.CriterionModifierIsNull:\n\t\treturn fmt.Sprintf(\"%s IS NULL\", column), nil\n\tcase models.CriterionModifierNotNull:\n\t\treturn fmt.Sprintf(\"%s IS NOT NULL\", column), nil\n\tcase models.CriterionModifierEquals:\n\t\treturn fmt.Sprintf(\"%s = ?\", column), args\n\tcase models.CriterionModifierNotEquals:\n\t\treturn fmt.Sprintf(\"%s != ?\", column), args\n\tcase models.CriterionModifierBetween:\n\t\treturn fmt.Sprintf(\"%s BETWEEN ? AND ?\", column), betweenArgs\n\tcase models.CriterionModifierNotBetween:\n\t\treturn fmt.Sprintf(\"%s NOT BETWEEN ? AND ?\", column), betweenArgs\n\tcase models.CriterionModifierLessThan:\n\t\treturn fmt.Sprintf(\"%s < ?\", column), args\n\tcase models.CriterionModifierGreaterThan:\n\t\treturn fmt.Sprintf(\"%s > ?\", column), args\n\t}\n\n\tpanic(\"unsupported date modifier type\")\n}\n\n// returns where clause and having clause\nfunc getMultiCriterionClause(primaryTable, foreignTable, joinTable, primaryFK, foreignFK string, criterion *models.MultiCriterionInput) (string, string) {\n\twhereClause := \"\"\n\thavingClause := \"\"\n\tswitch criterion.Modifier {\n\tcase models.CriterionModifierIncludes:\n\t\t// includes any of the provided ids\n\t\tif joinTable != \"\" {\n\t\t\twhereClause = joinTable + \".\" + foreignFK + \" IN \" + getInBinding(len(criterion.Value))\n\t\t} else {\n\t\t\twhereClause = foreignTable + \".id IN \" + getInBinding(len(criterion.Value))\n\t\t}\n\tcase models.CriterionModifierIncludesAll:\n\t\t// includes all of the provided ids\n\t\tif joinTable != \"\" {\n\t\t\twhereClause = joinTable + \".\" + foreignFK + \" IN \" + getInBinding(len(criterion.Value))\n\t\t\thavingClause = \"count(distinct \" + joinTable + \".\" + foreignFK + \") IS \" + strconv.Itoa(len(criterion.Value))\n\t\t} else {\n\t\t\twhereClause = foreignTable + \".id IN \" + getInBinding(len(criterion.Value))\n\t\t\thavingClause = \"count(distinct \" + foreignTable + \".id) IS \" + strconv.Itoa(len(criterion.Value))\n\t\t}\n\tcase models.CriterionModifierExcludes:\n\t\t// excludes all of the provided ids\n\t\tif joinTable != \"\" {\n\t\t\twhereClause = primaryTable + \".id not in (select \" + joinTable + \".\" + primaryFK + \" from \" + joinTable + \" where \" + joinTable + \".\" + foreignFK + \" in \" + getInBinding(len(criterion.Value)) + \")\"\n\t\t} else {\n\t\t\twhereClause = \"not exists (select s.id from \" + primaryTable + \" as s where s.id = \" + primaryTable + \".id and s.\" + foreignFK + \" in \" + getInBinding(len(criterion.Value)) + \")\"\n\t\t}\n\t}\n\n\treturn whereClause, havingClause\n}\n\nfunc getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterion models.IntCriterionInput) (string, []interface{}) {\n\tlhs := fmt.Sprintf(\"(SELECT COUNT(*) FROM %s s WHERE s.%s = %s.id)\", joinTable, primaryFK, primaryTable)\n\treturn getIntCriterionWhereClause(lhs, criterion)\n}\n\nfunc coalesce(column string) string {\n\treturn fmt.Sprintf(\"COALESCE(%s, '')\", column)\n}\n\nfunc like(v string) string {\n\treturn \"%\" + v + \"%\"\n}\n\ntype sqlTable string\n\nfunc (t sqlTable) Name() string {\n\treturn string(t)\n}\n\nfunc (t sqlTable) Col(n string) string {\n\treturn fmt.Sprintf(\"%s.%s\", string(t), n)\n}\n"
  },
  {
    "path": "pkg/sqlite/stash_id_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype stashIDReaderWriter interface {\n\tGetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error)\n\tUpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error\n}\n\nfunc testStashIDReaderWriter(ctx context.Context, t *testing.T, r stashIDReaderWriter, id int) {\n\t// ensure no stash IDs to begin with\n\ttestNoStashIDs(ctx, t, r, id)\n\n\t// ensure GetStashIDs with non-existing also returns none\n\ttestNoStashIDs(ctx, t, r, -1)\n\n\t// add stash ids\n\tconst stashIDStr = \"stashID\"\n\tconst endpoint = \"endpoint\"\n\tstashID := models.StashID{\n\t\tStashID:   stashIDStr,\n\t\tEndpoint:  endpoint,\n\t\tUpdatedAt: epochTime,\n\t}\n\n\t// update stash ids and ensure was updated\n\tif err := r.UpdateStashIDs(ctx, id, []models.StashID{stashID}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\ttestStashIDs(ctx, t, r, id, []models.StashID{stashID})\n\n\t// update non-existing id - should return error\n\tif err := r.UpdateStashIDs(ctx, -1, []models.StashID{stashID}); err == nil {\n\t\tt.Error(\"expected error when updating non-existing id\")\n\t}\n\n\t// remove stash ids and ensure was updated\n\tif err := r.UpdateStashIDs(ctx, id, []models.StashID{}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\ttestNoStashIDs(ctx, t, r, id)\n}\n\nfunc testNoStashIDs(ctx context.Context, t *testing.T, r stashIDReaderWriter, id int) {\n\tt.Helper()\n\tstashIDs, err := r.GetStashIDs(ctx, id)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Len(t, stashIDs, 0)\n}\n\nfunc testStashIDs(ctx context.Context, t *testing.T, r stashIDReaderWriter, id int, expected []models.StashID) {\n\tt.Helper()\n\tstashIDs, err := r.GetStashIDs(ctx, id)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Equal(t, stashIDs, expected)\n}\n"
  },
  {
    "path": "pkg/sqlite/studio.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/studio\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nconst (\n\tstudioTable    = \"studios\"\n\tstudioIDColumn = \"studio_id\"\n\n\tstudioURLsTable = \"studio_urls\"\n\tstudioURLColumn = \"url\"\n\n\tstudioAliasesTable    = \"studio_aliases\"\n\tstudioAliasColumn     = \"alias\"\n\tstudioParentIDColumn  = \"parent_id\"\n\tstudioNameColumn      = \"name\"\n\tstudioImageBlobColumn = \"image_blob\"\n\tstudiosTagsTable      = \"studios_tags\"\n)\n\ntype studioRow struct {\n\tID        int         `db:\"id\" goqu:\"skipinsert\"`\n\tName      zero.String `db:\"name\"`\n\tParentID  null.Int    `db:\"parent_id,omitempty\"`\n\tCreatedAt Timestamp   `db:\"created_at\"`\n\tUpdatedAt Timestamp   `db:\"updated_at\"`\n\t// expressed as 1-100\n\tRating        null.Int    `db:\"rating\"`\n\tFavorite      bool        `db:\"favorite\"`\n\tDetails       zero.String `db:\"details\"`\n\tIgnoreAutoTag bool        `db:\"ignore_auto_tag\"`\n\tOrganized     bool        `db:\"organized\"`\n\n\t// not used in resolutions or updates\n\tImageBlob zero.String `db:\"image_blob\"`\n}\n\nfunc (r *studioRow) fromStudio(o models.Studio) {\n\tr.ID = o.ID\n\tr.Name = zero.StringFrom(o.Name)\n\tr.ParentID = intFromPtr(o.ParentID)\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n\tr.Rating = intFromPtr(o.Rating)\n\tr.Favorite = o.Favorite\n\tr.Details = zero.StringFrom(o.Details)\n\tr.IgnoreAutoTag = o.IgnoreAutoTag\n\tr.Organized = o.Organized\n}\n\nfunc (r *studioRow) resolve() *models.Studio {\n\tret := &models.Studio{\n\t\tID:            r.ID,\n\t\tName:          r.Name.String,\n\t\tParentID:      nullIntPtr(r.ParentID),\n\t\tCreatedAt:     r.CreatedAt.Timestamp,\n\t\tUpdatedAt:     r.UpdatedAt.Timestamp,\n\t\tRating:        nullIntPtr(r.Rating),\n\t\tFavorite:      r.Favorite,\n\t\tDetails:       r.Details.String,\n\t\tIgnoreAutoTag: r.IgnoreAutoTag,\n\t\tOrganized:     r.Organized,\n\t}\n\n\treturn ret\n}\n\ntype studioRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *studioRowRecord) fromPartial(o models.StudioPartial) {\n\tr.setNullString(\"name\", o.Name)\n\tr.setNullInt(\"parent_id\", o.ParentID)\n\tr.setTimestamp(\"created_at\", o.CreatedAt)\n\tr.setTimestamp(\"updated_at\", o.UpdatedAt)\n\tr.setNullInt(\"rating\", o.Rating)\n\tr.setBool(\"favorite\", o.Favorite)\n\tr.setNullString(\"details\", o.Details)\n\tr.setBool(\"ignore_auto_tag\", o.IgnoreAutoTag)\n\tr.setBool(\"organized\", o.Organized)\n}\n\ntype studioRepositoryType struct {\n\trepository\n\n\tstashIDs stashIDRepository\n\ttags     joinRepository\n\n\tscenes    repository\n\timages    repository\n\tgalleries repository\n\tgroups    repository\n}\n\nvar (\n\tstudioRepository = studioRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: studioTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\tstashIDs: stashIDRepository{\n\t\t\trepository{\n\t\t\t\ttableName: \"studio_stash_ids\",\n\t\t\t\tidColumn:  studioIDColumn,\n\t\t\t},\n\t\t},\n\t\tscenes: repository{\n\t\t\ttableName: sceneTable,\n\t\t\tidColumn:  studioIDColumn,\n\t\t},\n\t\timages: repository{\n\t\t\ttableName: imageTable,\n\t\t\tidColumn:  studioIDColumn,\n\t\t},\n\t\tgalleries: repository{\n\t\t\ttableName: galleryTable,\n\t\t\tidColumn:  studioIDColumn,\n\t\t},\n\t\tgroups: repository{\n\t\t\ttableName: groupTable,\n\t\t\tidColumn:  studioIDColumn,\n\t\t},\n\t\ttags: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: studiosTagsTable,\n\t\t\t\tidColumn:  studioIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     tagIDColumn,\n\t\t\tforeignTable: tagTable,\n\t\t\torderBy:      tagTableSortSQL,\n\t\t},\n\t}\n)\n\ntype StudioStore struct {\n\tblobJoinQueryBuilder\n\tcustomFieldsStore\n\ttagRelationshipStore\n\n\ttableMgr *table\n}\n\nfunc NewStudioStore(blobStore *BlobStore) *StudioStore {\n\treturn &StudioStore{\n\t\tblobJoinQueryBuilder: blobJoinQueryBuilder{\n\t\t\tblobStore: blobStore,\n\t\t\tjoinTable: studioTable,\n\t\t},\n\t\tcustomFieldsStore: customFieldsStore{\n\t\t\ttable: studiosCustomFieldsTable,\n\t\t\tfk:    studiosCustomFieldsTable.Col(studioIDColumn),\n\t\t},\n\t\ttagRelationshipStore: tagRelationshipStore{\n\t\t\tidRelationshipStore: idRelationshipStore{\n\t\t\t\tjoinTable: studiosTagsTableMgr,\n\t\t\t},\n\t\t},\n\n\t\ttableMgr: studioTableMgr,\n\t}\n}\n\nfunc (qb *StudioStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *StudioStore) selectDataset() *goqu.SelectDataset {\n\treturn dialect.From(qb.table()).Select(qb.table().All())\n}\n\nfunc (qb *StudioStore) Create(ctx context.Context, newObject *models.CreateStudioInput) error {\n\tvar err error\n\n\tvar r studioRow\n\tr.fromStudio(*newObject.Studio)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif newObject.Aliases.Loaded() {\n\t\tif err := studio.ValidateAliases(ctx, id, newObject.Aliases.List(), qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := studiosAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.URLs.Loaded() {\n\t\tconst startPos = 0\n\t\tif err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil {\n\t\treturn err\n\t}\n\n\tif newObject.StashIDs.Loaded() {\n\t\tif err := studiosStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tconst partial = false\n\tif err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject.Studio = *updated\n\treturn nil\n}\n\nfunc (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPartial) (*models.Studio, error) {\n\tr := studioRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(input)\n\n\tif len(r.Record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, input.ID, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif input.Aliases != nil {\n\t\tif err := studiosAliasesTableMgr.modifyJoins(ctx, input.ID, input.Aliases.Values, input.Aliases.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif input.URLs != nil {\n\t\tif err := studiosURLsTableMgr.modifyJoins(ctx, input.ID, input.URLs.Values, input.URLs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif input.StashIDs != nil {\n\t\tif err := studiosStashIDsTableMgr.modifyJoins(ctx, input.ID, input.StashIDs.StashIDs, input.StashIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, input.ID, input.CustomFields); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn qb.find(ctx, input.ID)\n}\n\n// This is only used by the Import/Export functionality\nfunc (qb *StudioStore) Update(ctx context.Context, updatedObject *models.UpdateStudioInput) error {\n\tvar r studioRow\n\tr.fromStudio(*updatedObject.Studio)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\tif updatedObject.Aliases.Loaded() {\n\t\tif err := studiosAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.URLs.Loaded() {\n\t\tif err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil {\n\t\treturn err\n\t}\n\n\tif updatedObject.StashIDs.Loaded() {\n\t\tif err := studiosStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *StudioStore) Destroy(ctx context.Context, id int) error {\n\t// must handle image checksums manually\n\tif err := qb.destroyImage(ctx, id); err != nil {\n\t\treturn err\n\t}\n\n\treturn studioRepository.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *StudioStore) Find(ctx context.Context, id int) (*models.Studio, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *StudioStore) FindMany(ctx context.Context, ids []int) ([]*models.Studio, error) {\n\tret := make([]*models.Studio, len(ids))\n\n\ttable := qb.table()\n\tif err := batchExec(ids, defaultBatchSize, func(batch []int) error {\n\t\tq := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))\n\t\tunsorted, err := qb.getMany(ctx, q)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range unsorted {\n\t\t\ti := slices.Index(ids, s.ID)\n\t\t\tret[i] = s\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor i := range ret {\n\t\tif ret[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"studio with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *StudioStore) find(ctx context.Context, id int) (*models.Studio, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *StudioStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Studio, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *StudioStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Studio, error) {\n\tconst single = false\n\tvar ret []*models.Studio\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f studioRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *StudioStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Studio, error) {\n\ttable := qb.table()\n\n\tq := qb.selectDataset().Where(\n\t\ttable.Col(idColumn).Eq(\n\t\t\tsq,\n\t\t),\n\t)\n\n\treturn qb.getMany(ctx, q)\n}\n\nfunc (qb *StudioStore) FindChildren(ctx context.Context, id int) ([]*models.Studio, error) {\n\t// SELECT studios.* FROM studios WHERE studios.parent_id = ?\n\ttable := qb.table()\n\tsq := qb.selectDataset().Where(table.Col(studioParentIDColumn).Eq(id))\n\tret, err := qb.getMany(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *StudioStore) FindBySceneID(ctx context.Context, sceneID int) (*models.Studio, error) {\n\t// SELECT studios.* FROM studios JOIN scenes ON studios.id = scenes.studio_id WHERE scenes.id = ? LIMIT 1\n\ttable := qb.table()\n\tscenes := sceneTableMgr.table\n\tsq := qb.selectDataset().Join(\n\t\tscenes, goqu.On(table.Col(idColumn), scenes.Col(studioIDColumn)),\n\t).Where(\n\t\tscenes.Col(idColumn),\n\t).Limit(1)\n\tret, err := qb.get(ctx, sq)\n\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *StudioStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Studio, error) {\n\t// query := \"SELECT * FROM studios WHERE name = ?\"\n\t// if nocase {\n\t// \tquery += \" COLLATE NOCASE\"\n\t// }\n\t// query += \" LIMIT 1\"\n\twhere := \"name = ?\"\n\tif nocase {\n\t\twhere += \" COLLATE NOCASE\"\n\t}\n\tsq := qb.selectDataset().Prepared(true).Where(goqu.L(where, name)).Limit(1)\n\tret, err := qb.get(ctx, sq)\n\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *StudioStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error) {\n\tsq := dialect.From(studiosStashIDsJoinTable).Select(studiosStashIDsJoinTable.Col(studioIDColumn)).Where(\n\t\tstudiosStashIDsJoinTable.Col(\"stash_id\").Eq(stashID.StashID),\n\t\tstudiosStashIDsJoinTable.Col(\"endpoint\").Eq(stashID.Endpoint),\n\t)\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting studios for stash ID %s: %w\", stashID.StashID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *StudioStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Studio, error) {\n\ttable := qb.table()\n\tsq := dialect.From(table).LeftJoin(\n\t\tstudiosStashIDsJoinTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(studiosStashIDsJoinTable.Col(studioIDColumn))),\n\t).Select(table.Col(idColumn))\n\n\tif hasStashID {\n\t\tsq = sq.Where(\n\t\t\tstudiosStashIDsJoinTable.Col(\"stash_id\").IsNotNull(),\n\t\t\tstudiosStashIDsJoinTable.Col(\"endpoint\").Eq(stashboxEndpoint),\n\t\t)\n\t} else {\n\t\tsq = sq.Where(\n\t\t\tstudiosStashIDsJoinTable.Col(\"stash_id\").IsNull(),\n\t\t)\n\t}\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting studios for stash-box endpoint %s: %w\", stashboxEndpoint, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *StudioStore) Count(ctx context.Context) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(qb.table())\n\treturn count(ctx, q)\n}\n\nfunc (qb *StudioStore) All(ctx context.Context) ([]*models.Studio, error) {\n\ttable := qb.table()\n\treturn qb.getMany(ctx, qb.selectDataset().Order(table.Col(studioNameColumn).Asc()))\n}\n\nfunc (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) {\n\t// TODO - Query needs to be changed to support queries of this type, and\n\t// this method should be removed\n\ttable := qb.table()\n\tsq := dialect.From(table).Select(table.Col(idColumn)).LeftJoin(\n\t\tstudiosAliasesJoinTable,\n\t\tgoqu.On(studiosAliasesJoinTable.Col(studioIDColumn).Eq(table.Col(idColumn))),\n\t)\n\n\tvar whereClauses []exp.Expression\n\n\tfor _, w := range words {\n\t\twhereClauses = append(whereClauses, table.Col(studioNameColumn).Like(w+\"%\"))\n\t\twhereClauses = append(whereClauses, studiosAliasesJoinTable.Col(\"alias\").Like(w+\"%\"))\n\t}\n\n\tsq = sq.Where(\n\t\tgoqu.Or(whereClauses...),\n\t\ttable.Col(\"ignore_auto_tag\").Eq(0),\n\t)\n\n\tret, err := qb.findBySubquery(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting studios for autotag: %w\", err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {\n\tif studioFilter == nil {\n\t\tstudioFilter = &models.StudioFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := studioRepository.newQuery()\n\tdistinctIDs(&query, studioTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tquery.join(studioAliasesTable, \"\", \"studio_aliases.studio_id = studios.id\")\n\t\tsearchColumns := []string{\"studios.name\", \"studio_aliases.alias\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &studioFilterHandler{\n\t\tstudioFilter: studioFilter,\n\t})\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar err error\n\tquery.sortAndPagination, err = qb.getStudioSort(findFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tquery.sortAndPagination += getPagination(findFilter)\n\n\treturn &query, nil\n}\n\nfunc (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) {\n\tquery, err := qb.makeQuery(ctx, studioFilter, findFilter)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tidsResult, countResult, err := query.executeFind(ctx)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tstudios, err := qb.FindMany(ctx, idsResult)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn studios, countResult, nil\n}\n\nfunc (qb *StudioStore) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) {\n\tquery, err := qb.makeQuery(ctx, studioFilter, findFilter)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn query.executeCount(ctx)\n}\n\nfunc (qb *StudioStore) sortByScenesDuration(direction string) string {\n\treturn fmt.Sprintf(` ORDER BY (\n\t\tSELECT COALESCE(SUM(video_files.duration), 0)\n\t\tFROM %s\n\t\tLEFT JOIN %s ON %s.%s = %s.id\n\t\tLEFT JOIN video_files ON video_files.file_id = %s.file_id\n\t\tWHERE %s.%s = %s.id\n\t) %s`, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction))\n}\n\nfunc (qb *StudioStore) sortByScenesSize(direction string) string {\n\treturn fmt.Sprintf(` ORDER BY (\n\t\tSELECT COALESCE(SUM(%s.size), 0)\n\t\tFROM %s\n\t\tLEFT JOIN %s ON %s.%s = %s.id\n\t\tLEFT JOIN %s ON %s.id = %s.file_id\n\t\tWHERE %s.%s = %s.id\n\t) %s`, fileTable, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction))\n}\n\n// used for sorting on performer latest scene\nvar selectStudioLatestSceneSQL = utils.StrFormat(\n\t\"SELECT MAX(date) FROM (\"+\n\t\t\"SELECT {date} FROM {scenes} s \"+\n\t\t\"WHERE s.{studio_id} = {studios}.id\"+\n\t\t\")\",\n\tmap[string]interface{}{\n\t\t\"scenes\":    sceneTable,\n\t\t\"studios\":   studioTable,\n\t\t\"studio_id\": studioIDColumn,\n\t\t\"date\":      sceneDateColumn,\n\t},\n)\n\nfunc (qb *StudioStore) sortByLatestScene(direction string) string {\n\t// need to get the latest date from scenes\n\treturn \" ORDER BY (\" + selectStudioLatestSceneSQL + \") \" + direction\n}\n\nvar studioSortOptions = sortOptions{\n\t\"child_count\",\n\t\"created_at\",\n\t\"galleries_count\",\n\t\"id\",\n\t\"images_count\",\n\t\"latest_scene\",\n\t\"name\",\n\t\"scenes_count\",\n\t\"scenes_duration\",\n\t\"scenes_size\",\n\t\"random\",\n\t\"rating\",\n\t\"tag_count\",\n\t\"updated_at\",\n}\n\nfunc (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, error) {\n\tvar sort string\n\tvar direction string\n\tif findFilter == nil {\n\t\tsort = \"name\"\n\t\tdirection = \"ASC\"\n\t} else {\n\t\tsort = findFilter.GetSort(\"name\")\n\t\tdirection = findFilter.GetDirection()\n\t}\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := studioSortOptions.validateSort(sort); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tsortQuery := \"\"\n\tswitch sort {\n\tcase \"tag_count\":\n\t\tsortQuery += getCountSort(studioTable, studiosTagsTable, studioIDColumn, direction)\n\tcase \"scenes_count\":\n\t\tsortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction)\n\tcase \"scenes_duration\":\n\t\tsortQuery += qb.sortByScenesDuration(direction)\n\tcase \"scenes_size\":\n\t\tsortQuery += qb.sortByScenesSize(direction)\n\tcase \"images_count\":\n\t\tsortQuery += getCountSort(studioTable, imageTable, studioIDColumn, direction)\n\tcase \"galleries_count\":\n\t\tsortQuery += getCountSort(studioTable, galleryTable, studioIDColumn, direction)\n\tcase \"child_count\":\n\t\tsortQuery += getCountSort(studioTable, studioTable, studioParentIDColumn, direction)\n\tcase \"latest_scene\":\n\t\tsortQuery += qb.sortByLatestScene(direction)\n\tdefault:\n\t\tsortQuery += getSort(sort, direction, \"studios\")\n\t}\n\n\t// Whatever the sorting, always use name/id as a final sort\n\tsortQuery += \", COALESCE(studios.name, studios.id) COLLATE NATURAL_CI ASC\"\n\treturn sortQuery, nil\n}\n\nfunc (qb *StudioStore) GetImage(ctx context.Context, studioID int) ([]byte, error) {\n\treturn qb.blobJoinQueryBuilder.GetImage(ctx, studioID, studioImageBlobColumn)\n}\n\nfunc (qb *StudioStore) HasImage(ctx context.Context, studioID int) (bool, error) {\n\treturn qb.blobJoinQueryBuilder.HasImage(ctx, studioID, studioImageBlobColumn)\n}\n\nfunc (qb *StudioStore) UpdateImage(ctx context.Context, studioID int, image []byte) error {\n\treturn qb.blobJoinQueryBuilder.UpdateImage(ctx, studioID, studioImageBlobColumn, image)\n}\n\nfunc (qb *StudioStore) destroyImage(ctx context.Context, studioID int) error {\n\treturn qb.blobJoinQueryBuilder.DestroyImage(ctx, studioID, studioImageBlobColumn)\n}\n\nfunc (qb *StudioStore) GetStashIDs(ctx context.Context, studioID int) ([]models.StashID, error) {\n\treturn studiosStashIDsTableMgr.get(ctx, studioID)\n}\n\nfunc (qb *StudioStore) GetAliases(ctx context.Context, studioID int) ([]string, error) {\n\treturn studiosAliasesTableMgr.get(ctx, studioID)\n}\n\nfunc (qb *StudioStore) GetURLs(ctx context.Context, studioID int) ([]string, error) {\n\treturn studiosURLsTableMgr.get(ctx, studioID)\n}\n"
  },
  {
    "path": "pkg/sqlite/studio_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype studioFilterHandler struct {\n\tstudioFilter *models.StudioFilterType\n}\n\nfunc (qb *studioFilterHandler) validate() error {\n\tstudioFilter := qb.studioFilter\n\tif studioFilter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(studioFilter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif subFilter := studioFilter.SubFilter(); subFilter != nil {\n\t\tsqb := &studioFilterHandler{studioFilter: subFilter}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *studioFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\tstudioFilter := qb.studioFilter\n\tif studioFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := studioFilter.SubFilter()\n\tif sf != nil {\n\t\tsub := &studioFilterHandler{sf}\n\t\thandleSubFilter(ctx, sub, f, studioFilter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nfunc (qb *studioFilterHandler) criterionHandler() criterionHandler {\n\tstudioFilter := qb.studioFilter\n\treturn compoundHandler{\n\t\tstringCriterionHandler(studioFilter.Name, studioTable+\".name\"),\n\t\tstringCriterionHandler(studioFilter.Details, studioTable+\".details\"),\n\t\tqb.urlsCriterionHandler(studioFilter.URL),\n\t\tintCriterionHandler(studioFilter.Rating100, studioTable+\".rating\", nil),\n\t\tboolCriterionHandler(studioFilter.Favorite, studioTable+\".favorite\", nil),\n\t\tboolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+\".ignore_auto_tag\", nil),\n\t\tboolCriterionHandler(studioFilter.Organized, studioTable+\".organized\", nil),\n\n\t\tcriterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {\n\t\t\tif studioFilter.StashID != nil {\n\t\t\t\tstudioRepository.stashIDs.join(f, \"studio_stash_ids\", \"studios.id\")\n\t\t\t\tstringCriterionHandler(studioFilter.StashID, \"studio_stash_ids.stash_id\")(ctx, f)\n\t\t\t}\n\t\t}),\n\t\t&stashIDCriterionHandler{\n\t\t\tc:                 studioFilter.StashIDEndpoint,\n\t\t\tstashIDRepository: &studioRepository.stashIDs,\n\t\t\tstashIDTableAs:    \"studio_stash_ids\",\n\t\t\tparentIDCol:       \"studios.id\",\n\t\t},\n\t\t&stashIDsCriterionHandler{\n\t\t\tc:                 studioFilter.StashIDsEndpoint,\n\t\t\tstashIDRepository: &studioRepository.stashIDs,\n\t\t\tstashIDTableAs:    \"studio_stash_ids\",\n\t\t\tparentIDCol:       \"studios.id\",\n\t\t},\n\n\t\tqb.isMissingCriterionHandler(studioFilter.IsMissing),\n\t\tqb.tagCountCriterionHandler(studioFilter.TagCount),\n\t\tqb.sceneCountCriterionHandler(studioFilter.SceneCount),\n\t\tqb.imageCountCriterionHandler(studioFilter.ImageCount),\n\t\tqb.galleryCountCriterionHandler(studioFilter.GalleryCount),\n\t\tqb.groupCountCriterionHandler(studioFilter.GroupCount),\n\t\tqb.parentCriterionHandler(studioFilter.Parents),\n\t\tqb.aliasCriterionHandler(studioFilter.Aliases),\n\t\tqb.tagsCriterionHandler(studioFilter.Tags),\n\t\tqb.childCountCriterionHandler(studioFilter.ChildCount),\n\t\t&timestampCriterionHandler{studioFilter.CreatedAt, studioTable + \".created_at\", nil},\n\t\t&timestampCriterionHandler{studioFilter.UpdatedAt, studioTable + \".updated_at\", nil},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scenes.id\",\n\t\t\trelatedRepo:    sceneRepository.repository,\n\t\t\trelatedHandler: &sceneFilterHandler{studioFilter.ScenesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tstudioRepository.scenes.innerJoin(f, \"\", \"studios.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"images.id\",\n\t\t\trelatedRepo:    imageRepository.repository,\n\t\t\trelatedHandler: &imageFilterHandler{studioFilter.ImagesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tstudioRepository.images.innerJoin(f, \"\", \"studios.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"galleries.id\",\n\t\t\trelatedRepo:    galleryRepository.repository,\n\t\t\trelatedHandler: &galleryFilterHandler{studioFilter.GalleriesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tstudioRepository.galleries.innerJoin(f, \"\", \"studios.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"groups.id\",\n\t\t\trelatedRepo:    groupRepository.repository,\n\t\t\trelatedHandler: &groupFilterHandler{studioFilter.GroupsFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tstudioRepository.groups.innerJoin(f, \"\", \"studios.id\")\n\t\t\t},\n\t\t},\n\n\t\t&customFieldsFilterHandler{\n\t\t\ttable: studiosCustomFieldsTable.GetTable(),\n\t\t\tfkCol: studioIDColumn,\n\t\t\tc:     studioFilter.CustomFields,\n\t\t\tidCol: \"studios.id\",\n\t\t},\n\t}\n}\n\nfunc (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif isMissing != nil && *isMissing != \"\" {\n\t\t\tswitch *isMissing {\n\t\t\tcase \"url\":\n\t\t\t\tstudiosURLsTableMgr.join(f, \"\", \"studios.id\")\n\t\t\t\tf.addWhere(\"studio_urls.url IS NULL\")\n\t\t\tcase \"image\":\n\t\t\t\tf.addWhere(\"studios.image_blob IS NULL\")\n\t\t\tcase \"stash_id\":\n\t\t\t\tstudioRepository.stashIDs.join(f, \"studio_stash_ids\", \"studios.id\")\n\t\t\t\tf.addWhere(\"studio_stash_ids.studio_id IS NULL\")\n\t\t\tcase \"aliases\":\n\t\t\t\tstudiosAliasesTableMgr.join(f, \"\", \"studios.id\")\n\t\t\t\tf.addWhere(\"studio_aliases.alias IS NULL\")\n\t\t\tcase \"tags\":\n\t\t\t\tf.addLeftJoin(studiosTagsTable, \"tags_join\", \"tags_join.studio_id = studios.id\")\n\t\t\t\tf.addWhere(\"tags_join.studio_id IS NULL\")\n\t\t\tdefault:\n\t\t\t\tif err := validateIsMissing(*isMissing, []string{\n\t\t\t\t\t\"details\", \"rating\",\n\t\t\t\t}); err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWhere(\"(studios.\" + *isMissing + \" IS NULL OR TRIM(studios.\" + *isMissing + \") = '')\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *studioFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif sceneCount != nil {\n\t\t\tf.addLeftJoin(\"scenes\", \"\", \"scenes.studio_id = studios.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct scenes.id)\", *sceneCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *studioFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif imageCount != nil {\n\t\t\tf.addLeftJoin(\"images\", \"\", \"images.studio_id = studios.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct images.id)\", *imageCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif galleryCount != nil {\n\t\t\tf.addLeftJoin(\"galleries\", \"\", \"galleries.studio_id = studios.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct galleries.id)\", *galleryCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *studioFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif groupCount != nil {\n\t\t\tf.addLeftJoin(\"groups\", \"\", \"groups.studio_id = studios.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct groups.id)\", *groupCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc {\n\th := countCriterionHandlerBuilder{\n\t\tprimaryTable: studioTable,\n\t\tjoinTable:    studiosTagsTable,\n\t\tprimaryFK:    studioIDColumn,\n\t}\n\n\treturn h.handler(tagCount)\n}\n\nfunc (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc {\n\taddJoinsFunc := func(f *filterBuilder) {\n\t\tf.addLeftJoin(\"studios\", \"parent_studio\", \"parent_studio.id = studios.parent_id\")\n\t}\n\th := multiCriterionHandlerBuilder{\n\t\tprimaryTable: studioTable,\n\t\tforeignTable: \"parent_studio\",\n\t\tjoinTable:    \"\",\n\t\tprimaryFK:    studioIDColumn,\n\t\tforeignFK:    \"parent_id\",\n\t\taddJoinsFunc: addJoinsFunc,\n\t}\n\treturn h.handler(parents)\n}\n\nfunc (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: studioTable,\n\t\tprimaryFK:    studioIDColumn,\n\t\tjoinTable:    studioAliasesTable,\n\t\tstringColumn: studioAliasColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tstudiosAliasesTableMgr.join(f, \"\", \"studios.id\")\n\t\t},\n\t}\n\n\treturn h.handler(alias)\n}\n\nfunc (qb *studioFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: studioTable,\n\t\tprimaryFK:    studioIDColumn,\n\t\tjoinTable:    studioURLsTable,\n\t\tstringColumn: studioURLColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\tstudiosURLsTableMgr.join(f, \"\", \"studios.id\")\n\t\t},\n\t}\n\n\treturn h.handler(url)\n}\n\nfunc (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif childCount != nil {\n\t\t\tf.addLeftJoin(\"studios\", \"children_count\", \"children_count.parent_id = studios.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct children_count.id)\", *childCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *studioFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {\n\th := joinedHierarchicalMultiCriterionHandlerBuilder{\n\t\tprimaryTable: studioTable,\n\t\tforeignTable: tagTable,\n\t\tforeignFK:    \"tag_id\",\n\n\t\trelationsTable: \"tags_relations\",\n\t\tjoinTable:      studiosTagsTable,\n\t\tjoinAs:         \"studio_tag\",\n\t\tprimaryFK:      studioIDColumn,\n\t}\n\n\treturn h.handler(tags)\n}\n"
  },
  {
    "path": "pkg/sqlite/studio_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestStudioFindByName(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\n\t\tname := studioNames[studioIdxWithScene] // find a studio by name\n\n\t\tstudio, err := sqb.FindByName(ctx, name, false)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding studios: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, studioNames[studioIdxWithScene], studio.Name)\n\n\t\tname = studioNames[studioIdxWithDupName] // find a studio by name nocase\n\n\t\tstudio, err = sqb.FindByName(ctx, name, true)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding studios: %s\", err.Error())\n\t\t}\n\t\t// studioIdxWithDupName and studioIdxWithScene should have similar names ( only diff should be Name vs NaMe)\n\t\t//studio.Name should match with studioIdxWithScene since its ID is before studioIdxWithDupName\n\t\tassert.Equal(t, studioNames[studioIdxWithScene], studio.Name)\n\t\t//studio.Name should match with studioIdxWithDupName if the check is not case sensitive\n\t\tassert.Equal(t, strings.ToLower(studioNames[studioIdxWithDupName]), strings.ToLower(studio.Name))\n\n\t\treturn nil\n\t})\n}\n\nfunc loadStudioRelationships(ctx context.Context, expected models.Studio, actual *models.Studio) error {\n\tif expected.Aliases.Loaded() {\n\t\tif err := actual.LoadAliases(ctx, db.Studio); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.URLs.Loaded() {\n\t\tif err := actual.LoadURLs(ctx, db.Studio); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.TagIDs.Loaded() {\n\t\tif err := actual.LoadTagIDs(ctx, db.Studio); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.StashIDs.Loaded() {\n\t\tif err := actual.LoadStashIDs(ctx, db.Studio); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc Test_StudioStore_Create(t *testing.T) {\n\tvar (\n\t\tname          = \"name\"\n\t\tdetails       = \"details\"\n\t\turl           = \"url\"\n\t\trating        = 3\n\t\taliases       = []string{\"alias1\", \"alias2\"}\n\t\tignoreAutoTag = true\n\t\torganized     = true\n\t\tfavorite      = true\n\t\tendpoint1     = \"endpoint1\"\n\t\tendpoint2     = \"endpoint2\"\n\t\tstashID1      = \"stashid1\"\n\t\tstashID2      = \"stashid2\"\n\t\tcreatedAt     = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt     = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.CreateStudioInput\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.CreateStudioInput{\n\t\t\t\tStudio: &models.Studio{\n\t\t\t\t\tName:          name,\n\t\t\t\t\tURLs:          models.NewRelatedStrings([]string{url}),\n\t\t\t\t\tFavorite:      favorite,\n\t\t\t\t\tRating:        &rating,\n\t\t\t\t\tDetails:       details,\n\t\t\t\t\tIgnoreAutoTag: ignoreAutoTag,\n\t\t\t\t\tOrganized:     organized,\n\t\t\t\t\tTagIDs:        models.NewRelatedIDs([]int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}),\n\t\t\t\t\tAliases:       models.NewRelatedStrings(aliases),\n\t\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t\tCustomFields: testCustomFields,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.CreateStudioInput{\n\t\t\t\tStudio: &models.Studio{\n\t\t\t\t\tName:   name,\n\t\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Studio\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tp := tt.newObject\n\t\t\tif err := qb.Create(ctx, &p); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"StudioStore.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(p.ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(p.ID)\n\n\t\t\tcopy := *tt.newObject.Studio\n\t\t\tcopy.ID = p.ID\n\n\t\t\t// load relationships\n\t\t\tif err := loadStudioRelationships(ctx, copy, p.Studio); err != nil {\n\t\t\t\tt.Errorf(\"loadStudioRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *p.Studio)\n\n\t\t\t// ensure can find the Studio\n\t\t\tfound, err := qb.Find(ctx, p.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"StudioStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tif !assert.NotNil(found) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadStudioRelationships(ctx, copy, found); err != nil {\n\t\t\t\tt.Errorf(\"loadStudioRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.Equal(copy, *found)\n\n\t\t\t// ensure custom fields are set\n\t\t\tcf, err := qb.GetCustomFields(ctx, p.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"StudioStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.newObject.CustomFields, cf)\n\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc Test_StudioStore_Update(t *testing.T) {\n\tvar (\n\t\tname          = \"name\"\n\t\tdetails       = \"details\"\n\t\turl           = \"url\"\n\t\trating        = 3\n\t\taliases       = []string{\"aliasX\", \"aliasY\"}\n\t\tignoreAutoTag = true\n\t\torganized     = true\n\t\tfavorite      = true\n\t\tendpoint1     = \"endpoint1\"\n\t\tendpoint2     = \"endpoint2\"\n\t\tstashID1      = \"stashid1\"\n\t\tstashID2      = \"stashid2\"\n\t\tcreatedAt     = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt     = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject models.UpdateStudioInput\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.UpdateStudioInput{\n\t\t\t\tStudio: &models.Studio{\n\t\t\t\t\tID:            studioIDs[studioIdxWithGallery],\n\t\t\t\t\tName:          name,\n\t\t\t\t\tURLs:          models.NewRelatedStrings([]string{url}),\n\t\t\t\t\tFavorite:      favorite,\n\t\t\t\t\tRating:        &rating,\n\t\t\t\t\tDetails:       details,\n\t\t\t\t\tIgnoreAutoTag: ignoreAutoTag,\n\t\t\t\t\tOrganized:     organized,\n\t\t\t\t\tAliases:       models.NewRelatedStrings(aliases),\n\t\t\t\t\tTagIDs:        models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}),\n\t\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear nullables\",\n\t\t\tmodels.UpdateStudioInput{\n\t\t\t\tStudio: &models.Studio{\n\t\t\t\t\tID:       studioIDs[studioIdxWithGallery],\n\t\t\t\t\tName:     name, // name is mandatory\n\t\t\t\t\tURLs:     models.NewRelatedStrings([]string{}),\n\t\t\t\t\tAliases:  models.NewRelatedStrings([]string{}),\n\t\t\t\t\tTagIDs:   models.NewRelatedIDs([]int{}),\n\t\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear tag ids\",\n\t\t\tmodels.UpdateStudioInput{\n\t\t\t\tStudio: &models.Studio{\n\t\t\t\t\tID:     studioIDs[sceneIdxWithTag],\n\t\t\t\t\tName:   name, // name is mandatory\n\t\t\t\t\tTagIDs: models.NewRelatedIDs([]int{}),\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\tmodels.UpdateStudioInput{\n\t\t\t\tStudio: &models.Studio{\n\t\t\t\t\tID:   studioIDs[studioIdxWithGallery],\n\t\t\t\t\tName: name, // name is mandatory\n\t\t\t\t},\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\tmodels.UpdateStudioInput{\n\t\t\t\tStudio: &models.Studio{\n\t\t\t\t\tID:   studioIDs[studioIdxWithGallery],\n\t\t\t\t\tName: name, // name is mandatory\n\t\t\t\t},\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid tag id\",\n\t\t\tmodels.UpdateStudioInput{\n\t\t\t\tStudio: &models.Studio{\n\t\t\t\t\tID:     studioIDs[sceneIdxWithGallery],\n\t\t\t\t\tName:   name, // name is mandatory\n\t\t\t\t\tTagIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Studio\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tcopy := *tt.updatedObject.Studio\n\n\t\t\tif err := qb.Update(ctx, &tt.updatedObject); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"StudioStore.Update() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.updatedObject.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"StudioStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadStudioRelationships(ctx, copy, s); err != nil {\n\t\t\t\tt.Errorf(\"loadStudioRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *s)\n\n\t\t\t// ensure custom fields are correct\n\t\t\tif tt.updatedObject.CustomFields.Full != nil {\n\t\t\t\tcf, err := qb.GetCustomFields(ctx, tt.updatedObject.ID)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"StudioStore.GetCustomFields() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tassert.Equal(tt.updatedObject.CustomFields.Full, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc clearStudioPartial() models.StudioPartial {\n\tnullString := models.OptionalString{Set: true, Null: true}\n\tnullInt := models.OptionalInt{Set: true, Null: true}\n\n\t// leave mandatory fields\n\treturn models.StudioPartial{\n\t\tURLs:     &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},\n\t\tAliases:  &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},\n\t\tRating:   nullInt,\n\t\tDetails:  nullString,\n\t\tTagIDs:   &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},\n\t\tStashIDs: &models.UpdateStashIDs{Mode: models.RelationshipUpdateModeSet},\n\t}\n}\n\nfunc Test_StudioStore_UpdatePartial(t *testing.T) {\n\tvar (\n\t\tname          = \"name\"\n\t\tdetails       = \"details\"\n\t\turl           = \"url\"\n\t\taliases       = []string{\"aliasX\", \"aliasY\"}\n\t\trating        = 3\n\t\tignoreAutoTag = true\n\t\torganized     = true\n\t\tfavorite      = true\n\t\tendpoint1     = \"endpoint1\"\n\t\tendpoint2     = \"endpoint2\"\n\t\tstashID1      = \"stashid1\"\n\t\tstashID2      = \"stashid2\"\n\t\tcreatedAt     = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t\tupdatedAt     = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)\n\t)\n\n\ttests := []struct {\n\t\tname    string\n\t\tid      int\n\t\tpartial models.StudioPartial\n\t\twant    models.Studio\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tstudioIDs[studioIdxWithDupName],\n\t\t\tmodels.StudioPartial{\n\t\t\t\tName: models.NewOptionalString(name),\n\t\t\t\tURLs: &models.UpdateStrings{\n\t\t\t\t\tValues: []string{url},\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tAliases: &models.UpdateStrings{\n\t\t\t\t\tValues: aliases,\n\t\t\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tFavorite:      models.NewOptionalBool(favorite),\n\t\t\t\tRating:        models.NewOptionalInt(rating),\n\t\t\t\tDetails:       models.NewOptionalString(details),\n\t\t\t\tIgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag),\n\t\t\t\tOrganized:     models.NewOptionalBool(organized),\n\t\t\t\tTagIDs: &models.UpdateIDs{\n\t\t\t\t\tIDs:  []int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\t\t\tStashIDs: []models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tMode: models.RelationshipUpdateModeSet,\n\t\t\t\t},\n\t\t\t\tCreatedAt: models.NewOptionalTime(createdAt),\n\t\t\t\tUpdatedAt: models.NewOptionalTime(updatedAt),\n\t\t\t},\n\t\t\tmodels.Studio{\n\t\t\t\tID:            studioIDs[studioIdxWithDupName],\n\t\t\t\tName:          name,\n\t\t\t\tURLs:          models.NewRelatedStrings([]string{url}),\n\t\t\t\tAliases:       models.NewRelatedStrings(aliases),\n\t\t\t\tFavorite:      favorite,\n\t\t\t\tRating:        &rating,\n\t\t\t\tDetails:       details,\n\t\t\t\tIgnoreAutoTag: ignoreAutoTag,\n\t\t\t\tOrganized:     organized,\n\t\t\t\tTagIDs:        models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}),\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\tCreatedAt: createdAt,\n\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear all\",\n\t\t\tstudioIDs[studioIdxWithTwoTags],\n\t\t\tclearStudioPartial(),\n\t\t\tmodels.Studio{\n\t\t\t\tID:            studioIDs[studioIdxWithTwoTags],\n\t\t\t\tName:          getStudioStringValue(studioIdxWithTwoTags, \"Name\"),\n\t\t\t\tFavorite:      getStudioBoolValue(studioIdxWithTwoTags),\n\t\t\t\tAliases:       models.NewRelatedStrings([]string{}),\n\t\t\t\tTagIDs:        models.NewRelatedIDs([]int{}),\n\t\t\t\tStashIDs:      models.NewRelatedStashIDs([]models.StashID{}),\n\t\t\t\tIgnoreAutoTag: getIgnoreAutoTag(studioIdxWithTwoTags),\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid id\",\n\t\t\tinvalidID,\n\t\t\tmodels.StudioPartial{Name: models.NewOptionalString(name)},\n\t\t\tmodels.Studio{},\n\t\t\ttrue,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Studio\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\ttt.partial.ID = tt.id\n\n\t\t\tgot, err := qb.UpdatePartial(ctx, tt.partial)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"StudioStore.UpdatePartial() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := loadStudioRelationships(ctx, tt.want, got); err != nil {\n\t\t\t\tt.Errorf(\"loadStudioRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, *got)\n\n\t\t\ts, err := qb.Find(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"StudioStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadStudioRelationships(ctx, tt.want, s); err != nil {\n\t\t\t\tt.Errorf(\"loadStudioRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.want, *s)\n\t\t})\n\t}\n}\n\nfunc Test_StudioStore_UpdatePartialCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tid       int\n\t\tpartial  models.StudioPartial\n\t\texpected map[string]interface{} // nil to use the partial\n\t}{\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\tstudioIDs[studioIdxWithGallery],\n\t\t\tmodels.StudioPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\tstudioIDs[studioIdxWithGallery],\n\t\t\tmodels.StudioPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"partial custom fields\",\n\t\t\tstudioIDs[studioIdxWithGallery],\n\t\t\tmodels.StudioPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\t\t\"new_field\": \"new\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"int\":       int64(2),\n\t\t\t\t\"real\":      0.7,\n\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\"new_field\": \"new\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Studio\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\ttt.partial.ID = tt.id\n\n\t\t\t_, err := qb.UpdatePartial(ctx, tt.partial)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"StudioStore.UpdatePartial() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure custom fields are correct\n\t\t\tcf, err := qb.GetCustomFields(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"StudioStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.expected == nil {\n\t\t\t\tassert.Equal(tt.partial.CustomFields.Full, cf)\n\t\t\t} else {\n\t\t\t\tassert.Equal(tt.expected, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStudioQueryNameOr(t *testing.T) {\n\tconst studio1Idx = 1\n\tconst studio2Idx = 2\n\n\tstudio1Name := getStudioStringValue(studio1Idx, \"Name\")\n\tstudio2Name := getStudioStringValue(studio2Idx, \"Name\")\n\n\tstudioFilter := models.StudioFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    studio1Name,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.StudioFilterType]{\n\t\t\tOr: &models.StudioFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    studio2Name,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\n\t\tstudios := queryStudio(ctx, t, sqb, &studioFilter, nil)\n\n\t\tassert.Len(t, studios, 2)\n\t\tassert.Equal(t, studio1Name, studios[0].Name)\n\t\tassert.Equal(t, studio2Name, studios[1].Name)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryNameAndUrl(t *testing.T) {\n\tconst studioIdx = 1\n\tstudioName := getStudioStringValue(studioIdx, \"Name\")\n\tstudioUrl := getStudioNullStringValue(studioIdx, urlField)\n\n\tstudioFilter := models.StudioFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    studioName,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t\tOperatorFilter: models.OperatorFilter[models.StudioFilterType]{\n\t\t\tAnd: &models.StudioFilterType{\n\t\t\t\tURL: &models.StringCriterionInput{\n\t\t\t\t\tValue:    studioUrl,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\n\t\tstudios := queryStudio(ctx, t, sqb, &studioFilter, nil)\n\n\t\tif !assert.Len(t, studios, 1) {\n\t\t\treturn nil\n\t\t}\n\n\t\tif err := studios[0].LoadURLs(ctx, db.Studio); err != nil {\n\t\t\tt.Errorf(\"Error loading studio relationships: %v\", err)\n\t\t}\n\n\t\tassert.Equal(t, studioName, studios[0].Name)\n\t\tassert.Equal(t, []string{studioUrl}, studios[0].URLs.List())\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryNameNotUrl(t *testing.T) {\n\tconst studioIdx = 1\n\n\tstudioUrl := getStudioNullStringValue(studioIdx, urlField)\n\n\tnameCriterion := models.StringCriterionInput{\n\t\tValue:    \"studio_.*1_Name\",\n\t\tModifier: models.CriterionModifierMatchesRegex,\n\t}\n\n\turlCriterion := models.StringCriterionInput{\n\t\tValue:    studioUrl,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tstudioFilter := models.StudioFilterType{\n\t\tName: &nameCriterion,\n\t\tOperatorFilter: models.OperatorFilter[models.StudioFilterType]{\n\t\t\tNot: &models.StudioFilterType{\n\t\t\t\tURL: &urlCriterion,\n\t\t\t},\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\n\t\tstudios := queryStudio(ctx, t, sqb, &studioFilter, nil)\n\n\t\tfor _, studio := range studios {\n\t\t\tif err := studio.LoadURLs(ctx, db.Studio); err != nil {\n\t\t\t\tt.Errorf(\"Error loading studio relationships: %v\", err)\n\t\t\t}\n\n\t\t\tverifyString(t, studio.Name, nameCriterion)\n\t\t\turlCriterion.Modifier = models.CriterionModifierNotEquals\n\t\t\tverifyStringList(t, studio.URLs.List(), urlCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioIllegalQuery(t *testing.T) {\n\tassert := assert.New(t)\n\n\tconst studioIdx = 1\n\tsubFilter := models.StudioFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    getStudioStringValue(studioIdx, \"Name\"),\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tstudioFilter := &models.StudioFilterType{\n\t\tOperatorFilter: models.OperatorFilter[models.StudioFilterType]{\n\t\t\tAnd: &subFilter,\n\t\t\tOr:  &subFilter,\n\t\t},\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\n\t\t_, _, err := sqb.Query(ctx, studioFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\tstudioFilter.Or = nil\n\t\tstudioFilter.Not = &subFilter\n\t\t_, _, err = sqb.Query(ctx, studioFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\tstudioFilter.And = nil\n\t\tstudioFilter.Or = &subFilter\n\t\t_, _, err = sqb.Query(ctx, studioFilter, nil)\n\t\tassert.NotNil(err)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryIgnoreAutoTag(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tignoreAutoTag := true\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tIgnoreAutoTag: &ignoreAutoTag,\n\t\t}\n\n\t\tsqb := db.Studio\n\n\t\tstudios := queryStudio(ctx, t, sqb, &studioFilter, nil)\n\n\t\tassert.Len(t, studios, int(math.Ceil(float64(totalStudios)/5)))\n\t\tfor _, s := range studios {\n\t\t\tassert.True(t, s.IgnoreAutoTag)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryForAutoTag(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttqb := db.Studio\n\n\t\tname := studioNames[studioIdxWithGroup] // find a studio by name\n\n\t\tstudios, err := tqb.QueryForAutoTag(ctx, []string{name})\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding studios: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, studios, 1)\n\t\tassert.Equal(t, strings.ToLower(studioNames[studioIdxWithGroup]), strings.ToLower(studios[0].Name))\n\n\t\tname = getStudioStringValue(studioIdxWithGroup, \"Alias\")\n\t\tstudios, err = tqb.QueryForAutoTag(ctx, []string{name})\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding studios: %s\", err.Error())\n\t\t}\n\t\tif assert.Len(t, studios, 1) {\n\t\t\tassert.Equal(t, studioIDs[studioIdxWithGroup], studios[0].ID)\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryParent(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\t\tstudioCriterion := models.MultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithChildStudio]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tParents: &studioCriterion,\n\t\t}\n\n\t\tstudios, _, err := sqb.Query(ctx, &studioFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying studio: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, studios, 1)\n\n\t\t// ensure id is correct\n\t\tassert.Equal(t, sceneIDs[studioIdxWithParentStudio], studios[0].ID)\n\n\t\tstudioCriterion = models.MultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(studioIDs[studioIdxWithChildStudio]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t}\n\n\t\tq := getStudioStringValue(studioIdxWithParentStudio, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tstudios, _, err = sqb.Query(ctx, &studioFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying studio: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, studios, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioDestroyParent(t *testing.T) {\n\tconst parentName = \"parent\"\n\tconst childName = \"child\"\n\n\t// create parent and child studios\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tcreatedParent, err := createStudio(ctx, db.Studio, parentName, nil, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating parent studio: %s\", err.Error())\n\t\t}\n\n\t\tparentID := createdParent.ID\n\t\tcreatedChild, err := createStudio(ctx, db.Studio, childName, &parentID, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating child studio: %s\", err.Error())\n\t\t}\n\n\t\tsqb := db.Studio\n\n\t\t// destroy the parent\n\t\terr = sqb.Destroy(ctx, createdParent.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error destroying parent studio: %s\", err.Error())\n\t\t}\n\n\t\t// destroy the child\n\t\terr = sqb.Destroy(ctx, createdChild.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error destroying child studio: %s\", err.Error())\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestStudioFindChildren(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\n\t\tstudios, err := sqb.FindChildren(ctx, studioIDs[studioIdxWithChildStudio])\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling FindChildren: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, studios, 1)\n\t\tassert.Equal(t, studioIDs[studioIdxWithParentStudio], studios[0].ID)\n\n\t\tstudios, err = sqb.FindChildren(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"error calling FindChildren: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, studios, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioUpdateClearParent(t *testing.T) {\n\tconst parentName = \"clearParent_parent\"\n\tconst childName = \"clearParent_child\"\n\n\t// create parent and child studios\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tcreatedParent, err := createStudio(ctx, db.Studio, parentName, nil, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating parent studio: %s\", err.Error())\n\t\t}\n\n\t\tparentID := createdParent.ID\n\t\tcreatedChild, err := createStudio(ctx, db.Studio, childName, &parentID, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating child studio: %s\", err.Error())\n\t\t}\n\n\t\tsqb := db.Studio\n\n\t\t// clear the parent id from the child\n\t\tinput := models.StudioPartial{\n\t\t\tID:       createdChild.ID,\n\t\t\tParentID: models.NewOptionalIntPtr(nil),\n\t\t}\n\n\t\tupdatedStudio, err := sqb.UpdatePartial(ctx, input)\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error updated studio: %s\", err.Error())\n\t\t}\n\n\t\tif updatedStudio.ParentID != nil {\n\t\t\treturn errors.New(\"updated studio has parent ID set\")\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestStudioUpdateStudioImage(t *testing.T) {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tqb := db.Studio\n\n\t\t// create studio to test against\n\t\tconst name = \"TestStudioUpdateStudioImage\"\n\t\tcreated, err := createStudio(ctx, db.Studio, name, nil, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating studio: %s\", err.Error())\n\t\t}\n\n\t\treturn testUpdateImage(t, ctx, created.ID, qb.UpdateImage, qb.GetImage)\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestStudioQuerySceneCount(t *testing.T) {\n\tconst sceneCount = 1\n\tsceneCountCriterion := models.IntCriterionInput{\n\t\tValue:    sceneCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyStudiosSceneCount(t, sceneCountCriterion)\n\n\tsceneCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyStudiosSceneCount(t, sceneCountCriterion)\n\n\tsceneCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyStudiosSceneCount(t, sceneCountCriterion)\n\n\tsceneCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyStudiosSceneCount(t, sceneCountCriterion)\n}\n\nfunc verifyStudiosSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tSceneCount: &sceneCountCriterion,\n\t\t}\n\n\t\tstudios := queryStudio(ctx, t, sqb, &studioFilter, nil)\n\t\tassert.Greater(t, len(studios), 0)\n\n\t\tfor _, studio := range studios {\n\t\t\tsceneCount, err := db.Scene.CountByStudioID(ctx, studio.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, sceneCount, sceneCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryImageCount(t *testing.T) {\n\tconst imageCount = 1\n\timageCountCriterion := models.IntCriterionInput{\n\t\tValue:    imageCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyStudiosImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyStudiosImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyStudiosImageCount(t, imageCountCriterion)\n\n\timageCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyStudiosImageCount(t, imageCountCriterion)\n}\n\nfunc verifyStudiosImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tImageCount: &imageCountCriterion,\n\t\t}\n\n\t\tstudios := queryStudio(ctx, t, sqb, &studioFilter, nil)\n\t\tassert.Greater(t, len(studios), 0)\n\n\t\tfor _, studio := range studios {\n\t\t\tpp := 0\n\n\t\t\tresult, err := db.Image.Query(ctx, models.ImageQueryOptions{\n\t\t\t\tQueryOptions: models.QueryOptions{\n\t\t\t\t\tFindFilter: &models.FindFilterType{\n\t\t\t\t\t\tPerPage: &pp,\n\t\t\t\t\t},\n\t\t\t\t\tCount: true,\n\t\t\t\t},\n\t\t\t\tImageFilter: &models.ImageFilterType{\n\t\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\t\tValue:    []string{strconv.Itoa(studio.ID)},\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, result.Count, imageCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryGalleryCount(t *testing.T) {\n\tconst galleryCount = 1\n\tgalleryCountCriterion := models.IntCriterionInput{\n\t\tValue:    galleryCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyStudiosGalleryCount(t, galleryCountCriterion)\n\n\tgalleryCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyStudiosGalleryCount(t, galleryCountCriterion)\n\n\tgalleryCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyStudiosGalleryCount(t, galleryCountCriterion)\n\n\tgalleryCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyStudiosGalleryCount(t, galleryCountCriterion)\n}\n\nfunc verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tGalleryCount: &galleryCountCriterion,\n\t\t}\n\n\t\tstudios := queryStudio(ctx, t, sqb, &studioFilter, nil)\n\t\tassert.Greater(t, len(studios), 0)\n\n\t\tfor _, studio := range studios {\n\t\t\tpp := 0\n\n\t\t\t_, count, err := db.Gallery.Query(ctx, &models.GalleryFilterType{\n\t\t\t\tStudios: &models.HierarchicalMultiCriterionInput{\n\t\t\t\t\tValue:    []string{strconv.Itoa(studio.ID)},\n\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t},\n\t\t\t}, &models.FindFilterType{\n\t\t\t\tPerPage: &pp,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, count, galleryCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioStashIDs(t *testing.T) {\n\tif err := withRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Studio\n\n\t\t// create studio to test against\n\t\tconst name = \"TestStudioStashIDs\"\n\t\tcreated, err := createStudio(ctx, db.Studio, name, nil, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating studio: %s\", err.Error())\n\t\t}\n\n\t\tstudio, err := qb.Find(ctx, created.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error getting studio: %s\", err.Error())\n\t\t}\n\n\t\tif err := studio.LoadStashIDs(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttestStudioStashIDs(ctx, t, studio)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc testStudioStashIDs(ctx context.Context, t *testing.T, s *models.Studio) {\n\tqb := db.Studio\n\n\tif err := s.LoadStashIDs(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\t// ensure no stash IDs to begin with\n\tassert.Len(t, s.StashIDs.List(), 0)\n\n\t// add stash ids\n\tconst stashIDStr = \"stashID\"\n\tconst endpoint = \"endpoint\"\n\tstashID := models.StashID{\n\t\tStashID:  stashIDStr,\n\t\tEndpoint: endpoint,\n\t}\n\n\t// update stash ids and ensure was updated\n\tinput := models.StudioPartial{\n\t\tID: s.ID,\n\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\tStashIDs: []models.StashID{stashID},\n\t\t\tMode:     models.RelationshipUpdateModeSet,\n\t\t},\n\t}\n\tvar err error\n\ts, err = qb.UpdatePartial(ctx, input)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tif err := s.LoadStashIDs(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\t// #5563 - set the UpdatedAt field to epoch\n\tstashID.UpdatedAt = epochTime\n\n\tassert.Equal(t, []models.StashID{stashID}, s.StashIDs.List())\n\n\t// remove stash ids and ensure was updated\n\tinput = models.StudioPartial{\n\t\tID: s.ID,\n\t\tStashIDs: &models.UpdateStashIDs{\n\t\t\tStashIDs: []models.StashID{stashID},\n\t\t\tMode:     models.RelationshipUpdateModeRemove,\n\t\t},\n\t}\n\ts, err = qb.UpdatePartial(ctx, input)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tif err := s.LoadStashIDs(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Len(t, s.StashIDs.List(), 0)\n}\n\nfunc TestStudioQueryURL(t *testing.T) {\n\tconst sceneIdx = 1\n\tstudioURL := getStudioStringValue(sceneIdx, urlField)\n\n\turlCriterion := models.StringCriterionInput{\n\t\tValue:    studioURL,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tfilter := models.StudioFilterType{\n\t\tURL: &urlCriterion,\n\t}\n\n\tverifyFn := func(ctx context.Context, g *models.Studio) {\n\t\tt.Helper()\n\t\tif err := g.LoadURLs(ctx, db.Studio); err != nil {\n\t\t\tt.Errorf(\"Error loading studio relationships: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tverifyStringList(t, g.URLs.List(), urlCriterion)\n\t}\n\n\tverifyStudioQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyStudioQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierMatchesRegex\n\turlCriterion.Value = \"studio_.*1_URL\"\n\tverifyStudioQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyStudioQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierIsNull\n\turlCriterion.Value = \"\"\n\tverifyStudioQuery(t, filter, verifyFn)\n\n\turlCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyStudioQuery(t, filter, verifyFn)\n}\n\nfunc TestStudioQueryRating(t *testing.T) {\n\tconst rating = 60\n\tratingCriterion := models.IntCriterionInput{\n\t\tValue:    rating,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyStudiosRating(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyStudiosRating(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyStudiosRating(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyStudiosRating(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierIsNull\n\tverifyStudiosRating(t, ratingCriterion)\n\n\tratingCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyStudiosRating(t, ratingCriterion)\n}\n\nfunc queryStudios(ctx context.Context, t *testing.T, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio {\n\tt.Helper()\n\tstudios, _, err := db.Studio.Query(ctx, studioFilter, findFilter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying studio: %s\", err.Error())\n\t}\n\n\treturn studios\n}\n\nfunc TestStudioQueryTags(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttagCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithStudio]),\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithStudio]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tTags: &tagCriterion,\n\t\t}\n\n\t\t// ensure ids are correct\n\t\tstudios := queryStudios(ctx, t, &studioFilter, nil)\n\t\tassert.Len(t, studios, 2)\n\t\tfor _, studio := range studios {\n\t\t\tassert.True(t, studio.ID == studioIDs[studioIdxWithTag] || studio.ID == studioIDs[studioIdxWithTwoTags])\n\t\t}\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithStudio]),\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx2WithStudio]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludesAll,\n\t\t}\n\n\t\tstudios = queryStudios(ctx, t, &studioFilter, nil)\n\n\t\tassert.Len(t, studios, 1)\n\t\tassert.Equal(t, sceneIDs[studioIdxWithTwoTags], studios[0].ID)\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdx1WithStudio]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t}\n\n\t\tq := getSceneStringValue(studioIdxWithTwoTags, titleField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\tstudios = queryStudios(ctx, t, &studioFilter, &findFilter)\n\t\tassert.Len(t, studios, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryTagCount(t *testing.T) {\n\tconst tagCount = 1\n\ttagCountCriterion := models.IntCriterionInput{\n\t\tValue:    tagCount,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyStudiosTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyStudiosTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyStudiosTagCount(t, tagCountCriterion)\n\n\ttagCountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyStudiosTagCount(t, tagCountCriterion)\n}\n\nfunc verifyStudiosTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tTagCount: &tagCountCriterion,\n\t\t}\n\n\t\tstudios := queryStudios(ctx, t, &studioFilter, nil)\n\t\tassert.Greater(t, len(studios), 0)\n\n\t\tfor _, studio := range studios {\n\t\t\tids, err := sqb.GetTagIDs(ctx, studio.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tverifyInt(t, len(ids), tagCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(ctx context.Context, s *models.Studio)) {\n\twithTxn(func(ctx context.Context) error {\n\t\tt.Helper()\n\t\tsqb := db.Studio\n\n\t\tstudios := queryStudio(ctx, t, sqb, &filter, nil)\n\n\t\t// assume it should find at least one\n\t\tassert.Greater(t, len(studios), 0)\n\n\t\tfor _, studio := range studios {\n\t\t\tverifyFn(ctx, studio)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc verifyStudiosRating(t *testing.T, ratingCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tRating100: &ratingCriterion,\n\t\t}\n\n\t\tstudios, _, err := sqb.Query(ctx, &studioFilter, nil)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying studio: %s\", err.Error())\n\t\t}\n\n\t\tfor _, studio := range studios {\n\t\t\tverifyIntPtr(t, studio.Rating, ratingCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestStudioQueryIsMissingRating(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\t\tisMissing := \"rating\"\n\t\tstudioFilter := models.StudioFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tstudios, _, err := sqb.Query(ctx, &studioFilter, nil)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying studio: %s\", err.Error())\n\t\t}\n\n\t\tassert.True(t, len(studios) > 0)\n\n\t\tfor _, studio := range studios {\n\t\t\tassert.Nil(t, studio.Rating)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc queryStudio(ctx context.Context, t *testing.T, sqb models.StudioReader, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio {\n\tstudios, _, err := sqb.Query(ctx, studioFilter, findFilter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying studio: %s\", err.Error())\n\t}\n\n\treturn studios\n}\n\nfunc TestStudioQueryName(t *testing.T) {\n\tconst studioIdx = 1\n\tstudioName := getStudioStringValue(studioIdx, \"Name\")\n\n\tnameCriterion := &models.StringCriterionInput{\n\t\tValue:    studioName,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tstudioFilter := models.StudioFilterType{\n\t\tName: nameCriterion,\n\t}\n\n\tverifyFn := func(ctx context.Context, studio *models.Studio) {\n\t\tverifyString(t, studio.Name, *nameCriterion)\n\t}\n\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n\n\tnameCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n\n\tnameCriterion.Modifier = models.CriterionModifierMatchesRegex\n\tnameCriterion.Value = \"studio_.*1_Name\"\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n\n\tnameCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n}\n\nfunc TestStudioQueryAlias(t *testing.T) {\n\tconst studioIdx = studioIdxWithGroup\n\tstudioName := getStudioStringValue(studioIdx, \"Alias\")\n\n\taliasCriterion := &models.StringCriterionInput{\n\t\tValue:    studioName,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tstudioFilter := models.StudioFilterType{\n\t\tAliases: aliasCriterion,\n\t}\n\n\tverifyFn := func(ctx context.Context, studio *models.Studio) {\n\t\tt.Helper()\n\t\taliases, err := db.Studio.GetAliases(ctx, studio.ID)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying studios: %s\", err.Error())\n\t\t}\n\n\t\tvar alias string\n\t\tif len(aliases) > 0 {\n\t\t\talias = aliases[0]\n\t\t}\n\n\t\tverifyString(t, alias, *aliasCriterion)\n\t}\n\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierMatchesRegex\n\taliasCriterion.Value = \"studio_.*2_Alias\"\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierIsNull\n\taliasCriterion.Value = \"\"\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyStudioQuery(t, studioFilter, verifyFn)\n}\n\nfunc TestStudioAlias(t *testing.T) {\n\tif err := withRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Studio\n\n\t\t// create studio to test against\n\t\tconst name = \"TestStudioAlias\"\n\t\tcreated, err := createStudio(ctx, db.Studio, name, nil, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating studio: %s\", err.Error())\n\t\t}\n\n\t\tstudio, err := qb.Find(ctx, created.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error getting studio: %s\", err.Error())\n\t\t}\n\n\t\tif err := studio.LoadStashIDs(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttestStudioAlias(ctx, t, studio)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc testStudioAlias(ctx context.Context, t *testing.T, s *models.Studio) {\n\tqb := db.Studio\n\tif err := s.LoadAliases(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\t// ensure no alias to begin with\n\tassert.Len(t, s.Aliases.List(), 0)\n\n\taliases := []string{\"alias1\", \"alias2\"}\n\n\t// update alias and ensure was updated\n\tinput := models.StudioPartial{\n\t\tID: s.ID,\n\t\tAliases: &models.UpdateStrings{\n\t\t\tValues: aliases,\n\t\t\tMode:   models.RelationshipUpdateModeSet,\n\t\t},\n\t}\n\tvar err error\n\ts, err = qb.UpdatePartial(ctx, input)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tif err := s.LoadAliases(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Equal(t, aliases, s.Aliases.List())\n\n\t// remove alias and ensure was updated\n\tinput = models.StudioPartial{\n\t\tID: s.ID,\n\t\tAliases: &models.UpdateStrings{\n\t\t\tValues: aliases,\n\t\t\tMode:   models.RelationshipUpdateModeRemove,\n\t\t},\n\t}\n\ts, err = qb.UpdatePartial(ctx, input)\n\tif err != nil {\n\t\tt.Error(err.Error())\n\t}\n\n\tif err := s.LoadAliases(ctx, qb); err != nil {\n\t\tt.Error(err.Error())\n\t\treturn\n\t}\n\n\tassert.Len(t, s.Aliases.List(), 0)\n}\n\n// TestStudioQueryFast does a quick test for major errors, no result verification\nfunc TestStudioQueryFast(t *testing.T) {\n\n\ttsString := \"test\"\n\ttsInt := 1\n\n\ttestStringCriterion := models.StringCriterionInput{\n\t\tValue:    tsString,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\ttestIncludesMultiCriterion := models.MultiCriterionInput{\n\t\tValue:    []string{tsString},\n\t\tModifier: models.CriterionModifierIncludes,\n\t}\n\ttestIntCriterion := models.IntCriterionInput{\n\t\tValue:    tsInt,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tnameFilter := models.StudioFilterType{\n\t\tName: &testStringCriterion,\n\t}\n\taliasesFilter := models.StudioFilterType{\n\t\tAliases: &testStringCriterion,\n\t}\n\tstashIDFilter := models.StudioFilterType{\n\t\tStashID: &testStringCriterion,\n\t}\n\turlFilter := models.StudioFilterType{\n\t\tURL: &testStringCriterion,\n\t}\n\tratingFilter := models.StudioFilterType{\n\t\tRating100: &testIntCriterion,\n\t}\n\tsceneCountFilter := models.StudioFilterType{\n\t\tSceneCount: &testIntCriterion,\n\t}\n\timageCountFilter := models.StudioFilterType{\n\t\tSceneCount: &testIntCriterion,\n\t}\n\tparentsFilter := models.StudioFilterType{\n\t\tParents: &testIncludesMultiCriterion,\n\t}\n\n\tfilters := []models.StudioFilterType{nameFilter, aliasesFilter, stashIDFilter, urlFilter, ratingFilter, sceneCountFilter, imageCountFilter, parentsFilter}\n\n\tmissingStrings := []string{\"image\", \"stash_id\", \"details\"}\n\n\tfor _, m := range missingStrings {\n\t\tfilters = append(filters, models.StudioFilterType{\n\t\t\tIsMissing: &m,\n\t\t})\n\t}\n\n\tsortbyStrings := []string{\"scenes_count\", \"images_count\", \"galleries_count\", \"created_at\", \"updated_at\", \"name\", \"random_26819649\", \"rating\"}\n\n\tvar findFilters []models.FindFilterType\n\n\tfor _, sb := range sortbyStrings {\n\t\tfindFilters = append(findFilters, models.FindFilterType{\n\t\t\tQ:       &tsString,\n\t\t\tPage:    &tsInt,\n\t\t\tPerPage: &tsInt,\n\t\t\tSort:    &sb,\n\t\t})\n\n\t}\n\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Studio\n\t\tfor _, f := range filters {\n\t\t\tfor _, ff := range findFilters {\n\t\t\t\t_, _, err := sqb.Query(ctx, &f, &ff)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Error querying studio: %s\", err.Error())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc studiesToIDs(i []*models.Studio) []int {\n\tret := make([]int, len(i))\n\tfor i, v := range i {\n\t\tret[i] = v.ID\n\t}\n\n\treturn ret\n}\n\nfunc TestStudioQueryCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      *models.StudioFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue:    []any{getStudioStringValue(studioIdxWithTwoScenes, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{studioIdxWithTwoScenes},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getStudioStringValue(studioIdxWithTwoScenes, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\t\tValue:    []any{getStudioStringValue(studioIdxWithTwoScenes, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{studioIdxWithTwoScenes},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue:    []any{getStudioStringValue(studioIdxWithTwoScenes, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{studioIdxWithTwoScenes},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getStudioStringValue(studioIdxWithTwoScenes, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\t\tValue:    []any{getStudioStringValue(studioIdxWithTwoScenes, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{studioIdxWithTwoScenes},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"regex\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*1_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{studioIdxWithTwoScenes},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid regex\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getStudioStringValue(studioIdxWithTwoScenes, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*1_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{studioIdxWithTwoScenes},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid not matches regex\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getStudioStringValue(studioIdxWithTwoScenes, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"not existing\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{studioIdxWithTwoScenes},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getStudioStringValue(studioIdxWithTwoScenes, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{studioIdxWithTwoScenes},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"between\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{studioIdxWithGroup},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not between\",\n\t\t\t&models.StudioFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getStudioStringValue(studioIdxWithGroup, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{studioIdxWithGroup},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tstudios, _, err := db.Studio.Query(ctx, tt.filter, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"StudioStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := studiesToIDs(studios)\n\t\t\tinclude := indexesToIDs(studioIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(studioIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TODO Create\n// TODO Update\n// TODO Destroy\n// TODO Find\n// TODO FindBySceneID\n// TODO Count\n// TODO All\n// TODO AllSlim\n// TODO Query\n"
  },
  {
    "path": "pkg/sqlite/table.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"gopkg.in/guregu/null.v4\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\ntype table struct {\n\ttable    exp.IdentifierExpression\n\tidColumn exp.IdentifierExpression\n}\n\ntype NotFoundError struct {\n\tID    int\n\tTable string\n}\n\nfunc (e *NotFoundError) Error() string {\n\treturn fmt.Sprintf(\"id %d does not exist in %s\", e.ID, e.Table)\n}\n\nfunc (t *table) insert(ctx context.Context, o interface{}) (sql.Result, error) {\n\tq := dialect.Insert(t.table).Prepared(true).Rows(o)\n\tret, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"inserting into %s: %w\", t.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *table) insertID(ctx context.Context, o interface{}) (int, error) {\n\tresult, err := t.insert(ctx, o)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tret, err := result.LastInsertId()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn int(ret), nil\n}\n\nfunc (t *table) updateByID(ctx context.Context, id interface{}, o interface{}) error {\n\tq := dialect.Update(t.table).Prepared(true).Set(o).Where(t.byID(id))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"updating %s: %w\", t.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (t *table) byID(id interface{}) exp.Expression {\n\treturn t.idColumn.Eq(id)\n}\n\nfunc (t *table) byIDInts(ids ...int) exp.Expression {\n\tii := make([]interface{}, len(ids))\n\tfor i, id := range ids {\n\t\tii[i] = id\n\t}\n\treturn t.idColumn.In(ii...)\n}\n\nfunc (t *table) idExists(ctx context.Context, id interface{}) (bool, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(t.table).Where(t.byID(id))\n\n\tvar count int\n\tif err := querySimple(ctx, q, &count); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn count == 1, nil\n}\n\nfunc (t *table) checkIDExists(ctx context.Context, id int) error {\n\texists, err := t.idExists(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !exists {\n\t\treturn &NotFoundError{ID: id, Table: t.table.GetTable()}\n\t}\n\n\treturn nil\n}\n\nfunc (t *table) destroyExisting(ctx context.Context, ids []int) error {\n\tfor _, id := range ids {\n\t\texists, err := t.idExists(ctx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !exists {\n\t\t\treturn &NotFoundError{\n\t\t\t\tID:    id,\n\t\t\t\tTable: t.table.GetTable(),\n\t\t\t}\n\t\t}\n\t}\n\n\treturn t.destroy(ctx, ids)\n}\n\nfunc (t *table) destroy(ctx context.Context, ids []int) error {\n\tq := dialect.Delete(t.table).Where(t.idColumn.In(ids))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"destroying %s: %w\", t.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (t *table) join(j joiner, as string, parentIDCol string) {\n\ttableName := t.table.GetTable()\n\ttt := tableName\n\tif as != \"\" {\n\t\ttt = as\n\t}\n\tj.addLeftJoin(tableName, as, fmt.Sprintf(\"%s.%s = %s\", tt, t.idColumn.GetCol(), parentIDCol))\n}\n\n// func (t *table) get(ctx context.Context, q *goqu.SelectDataset, dest interface{}) error {\n// \ttx, err := getTx(ctx)\n// \tif err != nil {\n// \t\treturn err\n// \t}\n\n// \tsql, args, err := q.ToSQL()\n// \tif err != nil {\n// \t\treturn fmt.Errorf(\"generating sql: %w\", err)\n// \t}\n\n// \treturn tx.GetContext(ctx, dest, sql, args...)\n// }\n\ntype joinTable struct {\n\ttable\n\tfkColumn exp.IdentifierExpression\n\n\t// required for ordering\n\tforeignTable *table\n\torderBy      exp.OrderedExpression\n}\n\nfunc (t *joinTable) invert() *joinTable {\n\treturn &joinTable{\n\t\ttable: table{\n\t\t\ttable:    t.table.table,\n\t\t\tidColumn: t.fkColumn,\n\t\t},\n\t\tfkColumn:     t.table.idColumn,\n\t\tforeignTable: t.foreignTable,\n\t\torderBy:      t.orderBy,\n\t}\n}\n\nfunc (t *joinTable) get(ctx context.Context, id int) ([]int, error) {\n\tq := dialect.Select(t.fkColumn).From(t.table.table).Where(t.idColumn.Eq(id))\n\n\tif t.orderBy != nil {\n\t\tif t.foreignTable != nil {\n\t\t\tq = q.InnerJoin(t.foreignTable.table, goqu.On(t.foreignTable.idColumn.Eq(t.fkColumn)))\n\t\t}\n\t\tq = q.Order(t.orderBy)\n\t}\n\n\tconst single = false\n\tvar ret []int\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar fk int\n\t\tif err := rows.Scan(&fk); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, fk)\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"getting foreign keys from %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *joinTable) insertJoins(ctx context.Context, id int, foreignIDs []int) error {\n\t// manually create SQL so that we can prepare once\n\t// ignore duplicates\n\tq := fmt.Sprintf(\"INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING\", t.table.table.GetTable(), t.idColumn.GetCol(), t.fkColumn.GetCol())\n\n\tstmt, err := dbWrapper.Prepare(ctx, q)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer stmt.Close()\n\n\t// eliminate duplicates\n\tforeignIDs = sliceutil.AppendUniques(nil, foreignIDs)\n\n\tfor _, fk := range foreignIDs {\n\t\tif _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *joinTable) replaceJoins(ctx context.Context, id int, foreignIDs []int) error {\n\tif err := t.destroy(ctx, []int{id}); err != nil {\n\t\treturn err\n\t}\n\n\treturn t.insertJoins(ctx, id, foreignIDs)\n}\n\nfunc (t *joinTable) addJoins(ctx context.Context, id int, foreignIDs []int) error {\n\t// get existing foreign keys\n\tfks, err := t.get(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// only add foreign keys that are not already present\n\tforeignIDs = sliceutil.Exclude(foreignIDs, fks)\n\treturn t.insertJoins(ctx, id, foreignIDs)\n}\n\nfunc (t *joinTable) destroyJoins(ctx context.Context, id int, foreignIDs []int) error {\n\tq := dialect.Delete(t.table.table).Where(\n\t\tt.idColumn.Eq(id),\n\t\tt.fkColumn.In(foreignIDs),\n\t)\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"destroying %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (t *joinTable) modifyJoins(ctx context.Context, id int, foreignIDs []int, mode models.RelationshipUpdateMode) error {\n\tswitch mode {\n\tcase models.RelationshipUpdateModeSet:\n\t\treturn t.replaceJoins(ctx, id, foreignIDs)\n\tcase models.RelationshipUpdateModeAdd:\n\t\treturn t.addJoins(ctx, id, foreignIDs)\n\tcase models.RelationshipUpdateModeRemove:\n\t\treturn t.destroyJoins(ctx, id, foreignIDs)\n\t}\n\n\treturn nil\n}\n\ntype stashIDTable struct {\n\ttable\n}\n\ntype stashIDRow struct {\n\tStashID   null.String `db:\"stash_id\"`\n\tEndpoint  null.String `db:\"endpoint\"`\n\tUpdatedAt Timestamp   `db:\"updated_at\"`\n}\n\nfunc (r *stashIDRow) resolve() models.StashID {\n\treturn models.StashID{\n\t\tStashID:   r.StashID.String,\n\t\tEndpoint:  r.Endpoint.String,\n\t\tUpdatedAt: r.UpdatedAt.Timestamp,\n\t}\n}\n\nfunc (t *stashIDTable) get(ctx context.Context, id int) ([]models.StashID, error) {\n\tq := dialect.Select(\"endpoint\", \"stash_id\", \"updated_at\").From(t.table.table).Where(t.idColumn.Eq(id))\n\n\tconst single = false\n\tvar ret []models.StashID\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar v stashIDRow\n\t\tif err := rows.StructScan(&v); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, v.resolve())\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"getting stash ids from %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nvar epochTime = time.Unix(0, 0).UTC()\n\nfunc (t *stashIDTable) insertJoin(ctx context.Context, id int, v models.StashID) (sql.Result, error) {\n\t// #5563 - it's possible that zero-value updated at timestamps are provided via import\n\t// replace them with the epoch time\n\tif v.UpdatedAt.IsZero() {\n\t\tv.UpdatedAt = epochTime\n\t}\n\n\tvar q = dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), \"endpoint\", \"stash_id\", \"updated_at\").Vals(\n\t\tgoqu.Vals{id, v.Endpoint, v.StashID, v.UpdatedAt},\n\t)\n\tret, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"inserting into %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *stashIDTable) insertJoins(ctx context.Context, id int, v []models.StashID) error {\n\tfor _, fk := range v {\n\t\tif _, err := t.insertJoin(ctx, id, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *stashIDTable) replaceJoins(ctx context.Context, id int, v []models.StashID) error {\n\tif err := t.destroy(ctx, []int{id}); err != nil {\n\t\treturn err\n\t}\n\n\treturn t.insertJoins(ctx, id, v)\n}\n\nfunc (t *stashIDTable) addJoins(ctx context.Context, id int, v []models.StashID) error {\n\t// get existing foreign keys\n\tfks, err := t.get(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// only add values that are not already present\n\tvar filtered []models.StashID\n\tfor _, vv := range v {\n\t\tfor _, e := range fks {\n\t\t\tif vv.Endpoint == e.Endpoint {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfiltered = append(filtered, vv)\n\t\t}\n\t}\n\treturn t.insertJoins(ctx, id, filtered)\n}\n\nfunc (t *stashIDTable) destroyJoins(ctx context.Context, id int, v []models.StashID) error {\n\tfor _, vv := range v {\n\t\tq := dialect.Delete(t.table.table).Where(\n\t\t\tt.idColumn.Eq(id),\n\t\t\tt.table.table.Col(\"endpoint\").Eq(vv.Endpoint),\n\t\t\tt.table.table.Col(\"stash_id\").Eq(vv.StashID),\n\t\t)\n\n\t\tif _, err := exec(ctx, q); err != nil {\n\t\t\treturn fmt.Errorf(\"destroying %s: %w\", t.table.table.GetTable(), err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *stashIDTable) modifyJoins(ctx context.Context, id int, v []models.StashID, mode models.RelationshipUpdateMode) error {\n\tswitch mode {\n\tcase models.RelationshipUpdateModeSet:\n\t\treturn t.replaceJoins(ctx, id, v)\n\tcase models.RelationshipUpdateModeAdd:\n\t\treturn t.addJoins(ctx, id, v)\n\tcase models.RelationshipUpdateModeRemove:\n\t\treturn t.destroyJoins(ctx, id, v)\n\t}\n\n\treturn nil\n}\n\ntype stringTable struct {\n\ttable\n\tstringColumn exp.IdentifierExpression\n}\n\nfunc (t *stringTable) get(ctx context.Context, id int) ([]string, error) {\n\tq := dialect.Select(t.stringColumn).From(t.table.table).Where(t.idColumn.Eq(id))\n\n\tconst single = false\n\tvar ret []string\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar v string\n\t\tif err := rows.Scan(&v); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, v)\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"getting stash ids from %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *stringTable) insertJoin(ctx context.Context, id int, v string) (sql.Result, error) {\n\tq := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), t.stringColumn.GetCol()).Vals(\n\t\tgoqu.Vals{id, v},\n\t)\n\tret, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"inserting into %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *stringTable) insertJoins(ctx context.Context, id int, v []string) error {\n\tfor _, fk := range v {\n\t\tif _, err := t.insertJoin(ctx, id, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *stringTable) replaceJoins(ctx context.Context, id int, v []string) error {\n\tif err := t.destroy(ctx, []int{id}); err != nil {\n\t\treturn err\n\t}\n\n\treturn t.insertJoins(ctx, id, v)\n}\n\nfunc (t *stringTable) addJoins(ctx context.Context, id int, v []string) error {\n\t// get existing foreign keys\n\texisting, err := t.get(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// only add values that are not already present\n\tfiltered := sliceutil.Exclude(v, existing)\n\treturn t.insertJoins(ctx, id, filtered)\n}\n\nfunc (t *stringTable) destroyJoins(ctx context.Context, id int, v []string) error {\n\tfor _, vv := range v {\n\t\tq := dialect.Delete(t.table.table).Where(\n\t\t\tt.idColumn.Eq(id),\n\t\t\tt.stringColumn.Eq(vv),\n\t\t)\n\n\t\tif _, err := exec(ctx, q); err != nil {\n\t\t\treturn fmt.Errorf(\"destroying %s: %w\", t.table.table.GetTable(), err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *stringTable) modifyJoins(ctx context.Context, id int, v []string, mode models.RelationshipUpdateMode) error {\n\tswitch mode {\n\tcase models.RelationshipUpdateModeSet:\n\t\treturn t.replaceJoins(ctx, id, v)\n\tcase models.RelationshipUpdateModeAdd:\n\t\treturn t.addJoins(ctx, id, v)\n\tcase models.RelationshipUpdateModeRemove:\n\t\treturn t.destroyJoins(ctx, id, v)\n\t}\n\n\treturn nil\n}\n\ntype orderedValueTable[T comparable] struct {\n\ttable\n\tvalueColumn exp.IdentifierExpression\n}\n\nfunc (t *orderedValueTable[T]) positionColumn() exp.IdentifierExpression {\n\tconst positionColumn = \"position\"\n\treturn t.table.table.Col(positionColumn)\n}\n\nfunc (t *orderedValueTable[T]) get(ctx context.Context, id int) ([]T, error) {\n\tq := dialect.Select(t.valueColumn).From(t.table.table).Where(t.idColumn.Eq(id)).Order(t.positionColumn().Asc())\n\n\tconst single = false\n\tvar ret []T\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar v T\n\t\tif err := rows.Scan(&v); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, v)\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"getting stash ids from %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *orderedValueTable[T]) insertJoin(ctx context.Context, id int, position int, v T) (sql.Result, error) {\n\tq := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), t.positionColumn().GetCol(), t.valueColumn.GetCol()).Vals(\n\t\tgoqu.Vals{id, position, v},\n\t)\n\tret, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"inserting into %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *orderedValueTable[T]) insertJoins(ctx context.Context, id int, startPos int, v []T) error {\n\tfor i, fk := range v {\n\t\tif _, err := t.insertJoin(ctx, id, i+startPos, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *orderedValueTable[T]) replaceJoins(ctx context.Context, id int, v []T) error {\n\tif err := t.destroy(ctx, []int{id}); err != nil {\n\t\treturn err\n\t}\n\n\tconst startPos = 0\n\treturn t.insertJoins(ctx, id, startPos, v)\n}\n\nfunc (t *orderedValueTable[T]) addJoins(ctx context.Context, id int, v []T) error {\n\t// get existing foreign keys\n\texisting, err := t.get(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// only add values that are not already present\n\tfiltered := sliceutil.Exclude(v, existing)\n\n\tif len(filtered) == 0 {\n\t\treturn nil\n\t}\n\n\tstartPos := len(existing)\n\treturn t.insertJoins(ctx, id, startPos, filtered)\n}\n\nfunc (t *orderedValueTable[T]) destroyJoins(ctx context.Context, id int, v []T) error {\n\texisting, err := t.get(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting existing %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\tnewValue := sliceutil.Exclude(existing, v)\n\tif len(newValue) == len(existing) {\n\t\treturn nil\n\t}\n\n\treturn t.replaceJoins(ctx, id, newValue)\n}\n\nfunc (t *orderedValueTable[T]) modifyJoins(ctx context.Context, id int, v []T, mode models.RelationshipUpdateMode) error {\n\tswitch mode {\n\tcase models.RelationshipUpdateModeSet:\n\t\treturn t.replaceJoins(ctx, id, v)\n\tcase models.RelationshipUpdateModeAdd:\n\t\treturn t.addJoins(ctx, id, v)\n\tcase models.RelationshipUpdateModeRemove:\n\t\treturn t.destroyJoins(ctx, id, v)\n\t}\n\n\treturn nil\n}\n\ntype scenesGroupsTable struct {\n\ttable\n}\n\ntype groupsScenesRow struct {\n\tSceneID    null.Int `db:\"scene_id\"`\n\tGroupID    null.Int `db:\"group_id\"`\n\tSceneIndex null.Int `db:\"scene_index\"`\n}\n\nfunc (r groupsScenesRow) resolve(sceneID int) models.GroupsScenes {\n\treturn models.GroupsScenes{\n\t\tGroupID:    int(r.GroupID.Int64),\n\t\tSceneIndex: nullIntPtr(r.SceneIndex),\n\t}\n}\n\nfunc (t *scenesGroupsTable) get(ctx context.Context, id int) ([]models.GroupsScenes, error) {\n\tq := dialect.Select(\"group_id\", \"scene_index\").From(t.table.table).Where(t.idColumn.Eq(id))\n\n\tconst single = false\n\tvar ret []models.GroupsScenes\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar v groupsScenesRow\n\t\tif err := rows.StructScan(&v); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, v.resolve(id))\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"getting scene groups from %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *scenesGroupsTable) insertJoin(ctx context.Context, id int, v models.GroupsScenes) (sql.Result, error) {\n\tq := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), \"group_id\", \"scene_index\").Vals(\n\t\tgoqu.Vals{id, v.GroupID, intFromPtr(v.SceneIndex)},\n\t)\n\tret, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"inserting into %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *scenesGroupsTable) insertJoins(ctx context.Context, id int, v []models.GroupsScenes) error {\n\tfor _, fk := range v {\n\t\tif _, err := t.insertJoin(ctx, id, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *scenesGroupsTable) replaceJoins(ctx context.Context, id int, v []models.GroupsScenes) error {\n\tif err := t.destroy(ctx, []int{id}); err != nil {\n\t\treturn err\n\t}\n\n\treturn t.insertJoins(ctx, id, v)\n}\n\nfunc (t *scenesGroupsTable) addJoins(ctx context.Context, id int, v []models.GroupsScenes) error {\n\t// get existing foreign keys\n\tfks, err := t.get(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// only add values that are not already present\n\tvar filtered []models.GroupsScenes\n\tfor _, vv := range v {\n\t\tfound := false\n\n\t\tfor _, e := range fks {\n\t\t\tif vv.GroupID == e.GroupID {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\tfiltered = append(filtered, vv)\n\t\t}\n\t}\n\treturn t.insertJoins(ctx, id, filtered)\n}\n\nfunc (t *scenesGroupsTable) destroyJoins(ctx context.Context, id int, v []models.GroupsScenes) error {\n\tfor _, vv := range v {\n\t\tq := dialect.Delete(t.table.table).Where(\n\t\t\tt.idColumn.Eq(id),\n\t\t\tt.table.table.Col(\"group_id\").Eq(vv.GroupID),\n\t\t)\n\n\t\tif _, err := exec(ctx, q); err != nil {\n\t\t\treturn fmt.Errorf(\"destroying %s: %w\", t.table.table.GetTable(), err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *scenesGroupsTable) modifyJoins(ctx context.Context, id int, v []models.GroupsScenes, mode models.RelationshipUpdateMode) error {\n\tswitch mode {\n\tcase models.RelationshipUpdateModeSet:\n\t\treturn t.replaceJoins(ctx, id, v)\n\tcase models.RelationshipUpdateModeAdd:\n\t\treturn t.addJoins(ctx, id, v)\n\tcase models.RelationshipUpdateModeRemove:\n\t\treturn t.destroyJoins(ctx, id, v)\n\t}\n\n\treturn nil\n}\n\ntype imageGalleriesTable struct {\n\tjoinTable\n}\n\nfunc (t *imageGalleriesTable) setCover(ctx context.Context, id int, galleryID int) error {\n\tif err := t.resetCover(ctx, galleryID); err != nil {\n\t\treturn err\n\t}\n\n\ttable := t.table.table\n\n\tq := dialect.Update(table).Prepared(true).Set(goqu.Record{\n\t\t\"cover\": true,\n\t}).Where(t.idColumn.Eq(id), table.Col(galleryIDColumn).Eq(galleryID))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"setting cover flag in %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (t *imageGalleriesTable) resetCover(ctx context.Context, galleryID int) error {\n\ttable := t.table.table\n\n\tq := dialect.Update(table).Prepared(true).Set(goqu.Record{\n\t\t\"cover\": false,\n\t}).Where(\n\t\ttable.Col(galleryIDColumn).Eq(galleryID),\n\t\ttable.Col(\"cover\").Eq(true),\n\t)\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"unsetting cover flags in %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\ntype relatedFilesTable struct {\n\ttable\n}\n\n// type scenesFilesRow struct {\n// \tSceneID int           `db:\"scene_id\"`\n// \tPrimary bool          `db:\"primary\"`\n// \tFileID  models.FileID `db:\"file_id\"`\n// }\n\n// get returns the file IDs related to the provided scene ID\n// the primary file is returned first\nfunc (t *relatedFilesTable) get(ctx context.Context, id int) ([]models.FileID, error) {\n\tq := dialect.Select(\"file_id\").From(t.table.table).Where(t.idColumn.Eq(id)).Order(t.table.table.Col(\"primary\").Desc())\n\n\tconst single = false\n\tvar ret []models.FileID\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar v models.FileID\n\t\tif err := rows.Scan(&v); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tret = append(ret, v)\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"getting related files from %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *relatedFilesTable) insertJoin(ctx context.Context, id int, primary bool, fileID models.FileID) error {\n\tq := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), \"primary\", \"file_id\").Vals(\n\t\tgoqu.Vals{id, primary, fileID},\n\t)\n\t_, err := exec(ctx, q)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"inserting into %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (t *relatedFilesTable) insertJoins(ctx context.Context, id int, firstPrimary bool, fileIDs []models.FileID) error {\n\tfor i, fk := range fileIDs {\n\t\tif err := t.insertJoin(ctx, id, firstPrimary && i == 0, fk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (t *relatedFilesTable) replaceJoins(ctx context.Context, id int, fileIDs []models.FileID) error {\n\tif err := t.destroy(ctx, []int{id}); err != nil {\n\t\treturn err\n\t}\n\n\tconst firstPrimary = true\n\treturn t.insertJoins(ctx, id, firstPrimary, fileIDs)\n}\n\n// destroyJoins destroys all entries in the table with the provided fileIDs\nfunc (t *relatedFilesTable) destroyJoins(ctx context.Context, fileIDs []models.FileID) error {\n\tq := dialect.Delete(t.table.table).Where(t.table.table.Col(\"file_id\").In(fileIDs))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"destroying file joins in %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\nfunc (t *relatedFilesTable) setPrimary(ctx context.Context, id int, fileID models.FileID) error {\n\ttable := t.table.table\n\n\tq := dialect.Update(table).Prepared(true).Set(goqu.Record{\n\t\t\"primary\": 0,\n\t}).Where(t.idColumn.Eq(id), table.Col(fileIDColumn).Neq(fileID))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"unsetting primary flags in %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\tq = dialect.Update(table).Prepared(true).Set(goqu.Record{\n\t\t\"primary\": 1,\n\t}).Where(t.idColumn.Eq(id), table.Col(fileIDColumn).Eq(fileID))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn fmt.Errorf(\"setting primary flag in %s: %w\", t.table.table.GetTable(), err)\n\t}\n\n\treturn nil\n}\n\ntype viewHistoryTable struct {\n\ttable\n\tdateColumn exp.IdentifierExpression\n}\n\nfunc (t *viewHistoryTable) getDates(ctx context.Context, id int) ([]time.Time, error) {\n\ttable := t.table.table\n\n\tq := dialect.Select(\n\t\tt.dateColumn,\n\t).From(table).Where(\n\t\tt.idColumn.Eq(id),\n\t).Order(t.dateColumn.Desc())\n\n\tconst single = false\n\tvar ret []time.Time\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar date Timestamp\n\t\tif err := rows.Scan(&date); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tret = append(ret, date.Timestamp)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *viewHistoryTable) getManyDates(ctx context.Context, ids []int) ([][]time.Time, error) {\n\ttable := t.table.table\n\n\tq := dialect.Select(\n\t\tt.idColumn,\n\t\tt.dateColumn,\n\t).From(table).Where(\n\t\tt.idColumn.In(ids),\n\t).Order(t.dateColumn.Desc())\n\n\tret := make([][]time.Time, len(ids))\n\tidToIndex := idToIndexMap(ids)\n\n\tconst single = false\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar id int\n\t\tvar date Timestamp\n\t\tif err := rows.Scan(&id, &date); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tidx := idToIndex[id]\n\t\tret[idx] = append(ret[idx], date.Timestamp)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *viewHistoryTable) getLastDate(ctx context.Context, id int) (*time.Time, error) {\n\ttable := t.table.table\n\tq := dialect.Select(t.dateColumn).From(table).Where(\n\t\tt.idColumn.Eq(id),\n\t).Order(t.dateColumn.Desc()).Limit(1)\n\n\tvar date NullTimestamp\n\tif err := querySimple(ctx, q, &date); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn date.TimePtr(), nil\n}\n\nfunc (t *viewHistoryTable) getManyLastDate(ctx context.Context, ids []int) ([]*time.Time, error) {\n\ttable := t.table.table\n\n\tq := dialect.Select(\n\t\tt.idColumn,\n\t\tgoqu.MAX(t.dateColumn),\n\t).From(table).Where(\n\t\tt.idColumn.In(ids),\n\t).GroupBy(t.idColumn)\n\n\tret := make([]*time.Time, len(ids))\n\tidToIndex := idToIndexMap(ids)\n\n\tconst single = false\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar id int\n\n\t\t// MAX appears to return a string, so handle it manually\n\t\tvar dateString string\n\n\t\tif err := rows.Scan(&id, &dateString); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tt, err := time.Parse(TimestampFormat, dateString)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"parsing date %v: %w\", dateString, err)\n\t\t}\n\n\t\tidx := idToIndex[id]\n\t\tret[idx] = &t\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *viewHistoryTable) getCount(ctx context.Context, id int) (int, error) {\n\ttable := t.table.table\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(table).Where(t.idColumn.Eq(id))\n\n\tconst single = true\n\tvar ret int\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tif err := rows.Scan(&ret); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *viewHistoryTable) getManyCount(ctx context.Context, ids []int) ([]int, error) {\n\ttable := t.table.table\n\n\tq := dialect.Select(\n\t\tt.idColumn,\n\t\tgoqu.COUNT(t.dateColumn),\n\t).From(table).Where(\n\t\tt.idColumn.In(ids),\n\t).GroupBy(t.idColumn)\n\n\tret := make([]int, len(ids))\n\tidToIndex := idToIndexMap(ids)\n\n\tconst single = false\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tvar id int\n\t\tvar count int\n\t\tif err := rows.Scan(&id, &count); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tidx := idToIndex[id]\n\t\tret[idx] = count\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *viewHistoryTable) getAllCount(ctx context.Context) (int, error) {\n\ttable := t.table.table\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(table)\n\n\tconst single = true\n\tvar ret int\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tif err := rows.Scan(&ret); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *viewHistoryTable) getUniqueCount(ctx context.Context) (int, error) {\n\ttable := t.table.table\n\tq := dialect.Select(goqu.COUNT(goqu.DISTINCT(t.idColumn))).From(table)\n\n\tconst single = true\n\tvar ret int\n\tif err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {\n\t\tif err := rows.Scan(&ret); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (t *viewHistoryTable) addDates(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\ttable := t.table.table\n\n\tif len(dates) == 0 {\n\t\tdates = []time.Time{time.Now()}\n\t}\n\n\tfor _, d := range dates {\n\t\tq := dialect.Insert(table).Cols(t.idColumn.GetCol(), t.dateColumn.GetCol()).Vals(\n\t\t\t// convert all dates to UTC\n\t\t\tgoqu.Vals{id, UTCTimestamp{Timestamp{d}}},\n\t\t)\n\n\t\tif _, err := exec(ctx, q); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"inserting into %s: %w\", table.GetTable(), err)\n\t\t}\n\t}\n\n\treturn t.getDates(ctx, id)\n}\n\nfunc (t *viewHistoryTable) deleteDates(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {\n\ttable := t.table.table\n\n\tmostRecent := false\n\tif len(dates) == 0 {\n\t\tmostRecent = true\n\t\tdates = []time.Time{time.Now()}\n\t}\n\n\tfor _, date := range dates {\n\t\tvar subquery *goqu.SelectDataset\n\t\tif mostRecent {\n\t\t\t// delete the most recent\n\t\t\tsubquery = dialect.Select(\"rowid\").From(table).Where(\n\t\t\t\tt.idColumn.Eq(id),\n\t\t\t).Order(t.dateColumn.Desc()).Limit(1)\n\t\t} else {\n\t\t\tsubquery = dialect.Select(\"rowid\").From(table).Where(\n\t\t\t\tt.idColumn.Eq(id),\n\t\t\t\tt.dateColumn.Eq(UTCTimestamp{Timestamp{date}}),\n\t\t\t).Limit(1)\n\t\t}\n\n\t\tq := dialect.Delete(table).Where(goqu.I(\"rowid\").Eq(subquery))\n\n\t\tif _, err := exec(ctx, q); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"deleting from %s: %w\", table.GetTable(), err)\n\t\t}\n\t}\n\n\treturn t.getDates(ctx, id)\n}\n\nfunc (t *viewHistoryTable) deleteAllDates(ctx context.Context, id int) (int, error) {\n\ttable := t.table.table\n\tq := dialect.Delete(table).Where(t.idColumn.Eq(id))\n\n\tif _, err := exec(ctx, q); err != nil {\n\t\treturn 0, fmt.Errorf(\"resetting dates for id %v: %w\", id, err)\n\t}\n\n\treturn t.getCount(ctx, id)\n}\n\ntype sqler interface {\n\tToSQL() (sql string, params []interface{}, err error)\n}\n\nfunc exec(ctx context.Context, stmt sqler) (sql.Result, error) {\n\ttx, err := getTx(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsql, args, err := stmt.ToSQL()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"generating sql: %w\", err)\n\t}\n\n\tlogger.Tracef(\"SQL: %s [%v]\", sql, args)\n\tret, err := tx.ExecContext(ctx, sql, args...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"executing `%s` [%v]: %w\", sql, args, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc count(ctx context.Context, q *goqu.SelectDataset) (int, error) {\n\tvar count int\n\tif err := querySimple(ctx, q, &count); err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn count, nil\n}\n\nfunc queryFunc(ctx context.Context, query *goqu.SelectDataset, single bool, f func(rows *sqlx.Rows) error) error {\n\tq, args, err := query.ToSQL()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trows, err := dbWrapper.QueryxContext(ctx, q, args...)\n\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn fmt.Errorf(\"querying `%s` [%v]: %w\", q, args, err)\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tif err := f(rows); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif single {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif err := rows.Err(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc querySimple(ctx context.Context, query *goqu.SelectDataset, out interface{}) error {\n\tq, args, err := query.ToSQL()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trows, err := dbWrapper.QueryxContext(ctx, q, args...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"querying `%s` [%v]: %w\", q, args, err)\n\t}\n\tdefer rows.Close()\n\n\tif rows.Next() {\n\t\tif err := rows.Scan(out); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := rows.Err(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// func cols(table exp.IdentifierExpression, cols []string) []interface{} {\n// \tvar ret []interface{}\n// \tfor _, c := range cols {\n// \t\tret = append(ret, table.Col(c))\n// \t}\n// \treturn ret\n// }\n"
  },
  {
    "path": "pkg/sqlite/tables.go",
    "content": "package sqlite\n\nimport (\n\t\"github.com/doug-martin/goqu/v9\"\n\n\t_ \"github.com/doug-martin/goqu/v9/dialect/sqlite3\"\n)\n\nvar dialect = goqu.Dialect(\"sqlite3\")\n\nvar (\n\tgalleriesImagesJoinTable  = goqu.T(galleriesImagesTable)\n\timagesTagsJoinTable       = goqu.T(imagesTagsTable)\n\tperformersImagesJoinTable = goqu.T(performersImagesTable)\n\timagesFilesJoinTable      = goqu.T(imagesFilesTable)\n\timagesURLsJoinTable       = goqu.T(imagesURLsTable)\n\timagesCustomFieldsTable   = goqu.T(\"image_custom_fields\")\n\n\tgalleriesFilesJoinTable      = goqu.T(galleriesFilesTable)\n\tgalleriesTagsJoinTable       = goqu.T(galleriesTagsTable)\n\tperformersGalleriesJoinTable = goqu.T(performersGalleriesTable)\n\tgalleriesScenesJoinTable     = goqu.T(galleriesScenesTable)\n\tgalleriesURLsJoinTable       = goqu.T(galleriesURLsTable)\n\tgalleriesCustomFieldsTable   = goqu.T(\"gallery_custom_fields\")\n\n\tscenesFilesJoinTable      = goqu.T(scenesFilesTable)\n\tscenesTagsJoinTable       = goqu.T(scenesTagsTable)\n\tscenesPerformersJoinTable = goqu.T(performersScenesTable)\n\tscenesStashIDsJoinTable   = goqu.T(\"scene_stash_ids\")\n\tscenesGroupsJoinTable     = goqu.T(groupsScenesTable)\n\tscenesURLsJoinTable       = goqu.T(scenesURLsTable)\n\tscenesCustomFieldsTable   = goqu.T(\"scene_custom_fields\")\n\n\tsceneMarkersTagsJoinTable = goqu.T(sceneMarkersTagsTable)\n\n\tperformersAliasesJoinTable  = goqu.T(performersAliasesTable)\n\tperformersURLsJoinTable     = goqu.T(performerURLsTable)\n\tperformersTagsJoinTable     = goqu.T(performersTagsTable)\n\tperformersStashIDsJoinTable = goqu.T(\"performer_stash_ids\")\n\tperformersCustomFieldsTable = goqu.T(\"performer_custom_fields\")\n\n\tstudiosAliasesJoinTable  = goqu.T(studioAliasesTable)\n\tstudiosURLsJoinTable     = goqu.T(studioURLsTable)\n\tstudiosTagsJoinTable     = goqu.T(studiosTagsTable)\n\tstudiosStashIDsJoinTable = goqu.T(\"studio_stash_ids\")\n\tstudiosCustomFieldsTable = goqu.T(\"studio_custom_fields\")\n\n\tgroupsURLsJoinTable     = goqu.T(groupURLsTable)\n\tgroupsTagsJoinTable     = goqu.T(groupsTagsTable)\n\tgroupRelationsJoinTable = goqu.T(groupRelationsTable)\n\tgroupsCustomFieldsTable = goqu.T(\"group_custom_fields\")\n\n\ttagsAliasesJoinTable  = goqu.T(tagAliasesTable)\n\ttagRelationsJoinTable = goqu.T(tagRelationsTable)\n\ttagsStashIDsJoinTable = goqu.T(\"tag_stash_ids\")\n\ttagsCustomFieldsTable = goqu.T(\"tag_custom_fields\")\n)\n\nvar (\n\timageTableMgr = &table{\n\t\ttable:    goqu.T(imageTable),\n\t\tidColumn: goqu.T(imageTable).Col(idColumn),\n\t}\n\n\timagesFilesTableMgr = &relatedFilesTable{\n\t\ttable: table{\n\t\t\ttable:    imagesFilesJoinTable,\n\t\t\tidColumn: imagesFilesJoinTable.Col(imageIDColumn),\n\t\t},\n\t}\n\n\timageGalleriesTableMgr = &imageGalleriesTable{\n\t\tjoinTable: joinTable{\n\t\t\ttable: table{\n\t\t\t\ttable:    galleriesImagesJoinTable,\n\t\t\t\tidColumn: galleriesImagesJoinTable.Col(imageIDColumn),\n\t\t\t},\n\t\t\tfkColumn: galleriesImagesJoinTable.Col(galleryIDColumn),\n\t\t},\n\t}\n\n\timagesTagsTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    imagesTagsJoinTable,\n\t\t\tidColumn: imagesTagsJoinTable.Col(imageIDColumn),\n\t\t},\n\t\tfkColumn:     imagesTagsJoinTable.Col(tagIDColumn),\n\t\tforeignTable: tagTableMgr,\n\t\torderBy:      tagTableSort,\n\t}\n\n\timagesPerformersTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    performersImagesJoinTable,\n\t\t\tidColumn: performersImagesJoinTable.Col(imageIDColumn),\n\t\t},\n\t\tfkColumn: performersImagesJoinTable.Col(performerIDColumn),\n\t}\n\n\timagesURLsTableMgr = &orderedValueTable[string]{\n\t\ttable: table{\n\t\t\ttable:    imagesURLsJoinTable,\n\t\t\tidColumn: imagesURLsJoinTable.Col(imageIDColumn),\n\t\t},\n\t\tvalueColumn: imagesURLsJoinTable.Col(imageURLColumn),\n\t}\n)\n\nvar (\n\tgalleryTableMgr = &table{\n\t\ttable:    goqu.T(galleryTable),\n\t\tidColumn: goqu.T(galleryTable).Col(idColumn),\n\t}\n\n\tgalleriesFilesTableMgr = &relatedFilesTable{\n\t\ttable: table{\n\t\t\ttable:    galleriesFilesJoinTable,\n\t\t\tidColumn: galleriesFilesJoinTable.Col(galleryIDColumn),\n\t\t},\n\t}\n\n\tgalleriesTagsTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    galleriesTagsJoinTable,\n\t\t\tidColumn: galleriesTagsJoinTable.Col(galleryIDColumn),\n\t\t},\n\t\tfkColumn:     galleriesTagsJoinTable.Col(tagIDColumn),\n\t\tforeignTable: tagTableMgr,\n\t\torderBy:      tagTableSort,\n\t}\n\n\tgalleriesPerformersTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    performersGalleriesJoinTable,\n\t\t\tidColumn: performersGalleriesJoinTable.Col(galleryIDColumn),\n\t\t},\n\t\tfkColumn: performersGalleriesJoinTable.Col(performerIDColumn),\n\t}\n\n\tgalleriesScenesTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    galleriesScenesJoinTable,\n\t\t\tidColumn: galleriesScenesJoinTable.Col(galleryIDColumn),\n\t\t},\n\t\tfkColumn: galleriesScenesJoinTable.Col(sceneIDColumn),\n\t}\n\n\tgalleriesChaptersTableMgr = &table{\n\t\ttable:    goqu.T(galleriesChaptersTable),\n\t\tidColumn: goqu.T(galleriesChaptersTable).Col(idColumn),\n\t}\n\n\tgalleriesURLsTableMgr = &orderedValueTable[string]{\n\t\ttable: table{\n\t\t\ttable:    galleriesURLsJoinTable,\n\t\t\tidColumn: galleriesURLsJoinTable.Col(galleryIDColumn),\n\t\t},\n\t\tvalueColumn: galleriesURLsJoinTable.Col(galleriesURLColumn),\n\t}\n)\n\nvar (\n\tsceneTableMgr = &table{\n\t\ttable:    goqu.T(sceneTable),\n\t\tidColumn: goqu.T(sceneTable).Col(idColumn),\n\t}\n\n\tsceneMarkerTableMgr = &table{\n\t\ttable:    goqu.T(sceneMarkerTable),\n\t\tidColumn: goqu.T(sceneMarkerTable).Col(idColumn),\n\t}\n\n\tsceneMarkersTagsTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    sceneMarkersTagsJoinTable,\n\t\t\tidColumn: sceneMarkersTagsJoinTable.Col(sceneMarkerIDColumn),\n\t\t},\n\t\tfkColumn:     sceneMarkersTagsJoinTable.Col(tagIDColumn),\n\t\tforeignTable: tagTableMgr,\n\t\torderBy:      tagTableSort,\n\t}\n\n\tscenesFilesTableMgr = &relatedFilesTable{\n\t\ttable: table{\n\t\t\ttable:    scenesFilesJoinTable,\n\t\t\tidColumn: scenesFilesJoinTable.Col(sceneIDColumn),\n\t\t},\n\t}\n\n\tscenesTagsTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    scenesTagsJoinTable,\n\t\t\tidColumn: scenesTagsJoinTable.Col(sceneIDColumn),\n\t\t},\n\t\tfkColumn:     scenesTagsJoinTable.Col(tagIDColumn),\n\t\tforeignTable: tagTableMgr,\n\t\torderBy:      tagTableSort,\n\t}\n\n\tscenesPerformersTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    scenesPerformersJoinTable,\n\t\t\tidColumn: scenesPerformersJoinTable.Col(sceneIDColumn),\n\t\t},\n\t\tfkColumn: scenesPerformersJoinTable.Col(performerIDColumn),\n\t}\n\n\tscenesGalleriesTableMgr = galleriesScenesTableMgr.invert()\n\n\tscenesStashIDsTableMgr = &stashIDTable{\n\t\ttable: table{\n\t\t\ttable:    scenesStashIDsJoinTable,\n\t\t\tidColumn: scenesStashIDsJoinTable.Col(sceneIDColumn),\n\t\t},\n\t}\n\n\tscenesGroupsTableMgr = &scenesGroupsTable{\n\t\ttable: table{\n\t\t\ttable:    scenesGroupsJoinTable,\n\t\t\tidColumn: scenesGroupsJoinTable.Col(sceneIDColumn),\n\t\t},\n\t}\n\n\tscenesURLsTableMgr = &orderedValueTable[string]{\n\t\ttable: table{\n\t\t\ttable:    scenesURLsJoinTable,\n\t\t\tidColumn: scenesURLsJoinTable.Col(sceneIDColumn),\n\t\t},\n\t\tvalueColumn: scenesURLsJoinTable.Col(sceneURLColumn),\n\t}\n\n\tscenesViewTableMgr = &viewHistoryTable{\n\t\ttable: table{\n\t\t\ttable:    goqu.T(scenesViewDatesTable),\n\t\t\tidColumn: goqu.T(scenesViewDatesTable).Col(sceneIDColumn),\n\t\t},\n\t\tdateColumn: goqu.T(scenesViewDatesTable).Col(sceneViewDateColumn),\n\t}\n\n\tscenesOTableMgr = &viewHistoryTable{\n\t\ttable: table{\n\t\t\ttable:    goqu.T(scenesODatesTable),\n\t\t\tidColumn: goqu.T(scenesODatesTable).Col(sceneIDColumn),\n\t\t},\n\t\tdateColumn: goqu.T(scenesODatesTable).Col(sceneODateColumn),\n\t}\n)\n\nvar (\n\tfileTableMgr = &table{\n\t\ttable:    goqu.T(fileTable),\n\t\tidColumn: goqu.T(fileTable).Col(idColumn),\n\t}\n\n\tvideoFileTableMgr = &table{\n\t\ttable:    goqu.T(videoFileTable),\n\t\tidColumn: goqu.T(videoFileTable).Col(fileIDColumn),\n\t}\n\n\timageFileTableMgr = &table{\n\t\ttable:    goqu.T(imageFileTable),\n\t\tidColumn: goqu.T(imageFileTable).Col(fileIDColumn),\n\t}\n\n\tfolderTableMgr = &table{\n\t\ttable:    goqu.T(folderTable),\n\t\tidColumn: goqu.T(folderTable).Col(idColumn),\n\t}\n\n\tfingerprintTableMgr = &table{\n\t\ttable:    goqu.T(fingerprintTable),\n\t\tidColumn: goqu.T(fingerprintTable).Col(idColumn),\n\t}\n)\n\nvar (\n\tperformerTableMgr = &table{\n\t\ttable:    goqu.T(performerTable),\n\t\tidColumn: goqu.T(performerTable).Col(idColumn),\n\t}\n\n\tperformersAliasesTableMgr = &stringTable{\n\t\ttable: table{\n\t\t\ttable:    performersAliasesJoinTable,\n\t\t\tidColumn: performersAliasesJoinTable.Col(performerIDColumn),\n\t\t},\n\t\tstringColumn: performersAliasesJoinTable.Col(performerAliasColumn),\n\t}\n\n\tperformersURLsTableMgr = &orderedValueTable[string]{\n\t\ttable: table{\n\t\t\ttable:    performersURLsJoinTable,\n\t\t\tidColumn: performersURLsJoinTable.Col(performerIDColumn),\n\t\t},\n\t\tvalueColumn: performersURLsJoinTable.Col(performerURLColumn),\n\t}\n\n\tperformersTagsTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    performersTagsJoinTable,\n\t\t\tidColumn: performersTagsJoinTable.Col(performerIDColumn),\n\t\t},\n\t\tfkColumn:     performersTagsJoinTable.Col(tagIDColumn),\n\t\tforeignTable: tagTableMgr,\n\t\torderBy:      tagTableSort,\n\t}\n\n\tperformersStashIDsTableMgr = &stashIDTable{\n\t\ttable: table{\n\t\t\ttable:    performersStashIDsJoinTable,\n\t\t\tidColumn: performersStashIDsJoinTable.Col(performerIDColumn),\n\t\t},\n\t}\n)\n\nvar (\n\tstudioTableMgr = &table{\n\t\ttable:    goqu.T(studioTable),\n\t\tidColumn: goqu.T(studioTable).Col(idColumn),\n\t}\n\n\tstudiosAliasesTableMgr = &stringTable{\n\t\ttable: table{\n\t\t\ttable:    studiosAliasesJoinTable,\n\t\t\tidColumn: studiosAliasesJoinTable.Col(studioIDColumn),\n\t\t},\n\t\tstringColumn: studiosAliasesJoinTable.Col(studioAliasColumn),\n\t}\n\n\tstudiosURLsTableMgr = &orderedValueTable[string]{\n\t\ttable: table{\n\t\t\ttable:    studiosURLsJoinTable,\n\t\t\tidColumn: studiosURLsJoinTable.Col(studioIDColumn),\n\t\t},\n\t\tvalueColumn: studiosURLsJoinTable.Col(studioURLColumn),\n\t}\n\n\tstudiosTagsTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    studiosTagsJoinTable,\n\t\t\tidColumn: studiosTagsJoinTable.Col(studioIDColumn),\n\t\t},\n\t\tfkColumn:     studiosTagsJoinTable.Col(tagIDColumn),\n\t\tforeignTable: tagTableMgr,\n\t\torderBy:      tagTableSort,\n\t}\n\n\tstudiosStashIDsTableMgr = &stashIDTable{\n\t\ttable: table{\n\t\t\ttable:    studiosStashIDsJoinTable,\n\t\t\tidColumn: studiosStashIDsJoinTable.Col(studioIDColumn),\n\t\t},\n\t}\n)\n\nvar (\n\ttagTableMgr = &table{\n\t\ttable:    goqu.T(tagTable),\n\t\tidColumn: goqu.T(tagTable).Col(idColumn),\n\t}\n\n\t// formerly: goqu.COALESCE(tagTableMgr.table.Col(\"sort_name\"), tagTableMgr.table.Col(\"name\")).Asc()\n\ttagTableSort    = goqu.L(\"COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI\").Asc()\n\ttagTableSortSQL = \"COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI ASC\"\n\n\ttagsAliasesTableMgr = &stringTable{\n\t\ttable: table{\n\t\t\ttable:    tagsAliasesJoinTable,\n\t\t\tidColumn: tagsAliasesJoinTable.Col(tagIDColumn),\n\t\t},\n\t\tstringColumn: tagsAliasesJoinTable.Col(tagAliasColumn),\n\t}\n\n\ttagsParentTagsTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    tagRelationsJoinTable,\n\t\t\tidColumn: tagRelationsJoinTable.Col(tagChildIDColumn),\n\t\t},\n\t\tfkColumn:     tagRelationsJoinTable.Col(tagParentIDColumn),\n\t\tforeignTable: tagTableMgr,\n\t\torderBy:      tagTableSort,\n\t}\n\n\ttagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert()\n\n\ttagsStashIDsTableMgr = &stashIDTable{\n\t\ttable: table{\n\t\t\ttable:    tagsStashIDsJoinTable,\n\t\t\tidColumn: tagsStashIDsJoinTable.Col(tagIDColumn),\n\t\t},\n\t}\n)\n\nvar (\n\tgroupTableMgr = &table{\n\t\ttable:    goqu.T(groupTable),\n\t\tidColumn: goqu.T(groupTable).Col(idColumn),\n\t}\n\n\tgroupsURLsTableMgr = &orderedValueTable[string]{\n\t\ttable: table{\n\t\t\ttable:    groupsURLsJoinTable,\n\t\t\tidColumn: groupsURLsJoinTable.Col(groupIDColumn),\n\t\t},\n\t\tvalueColumn: groupsURLsJoinTable.Col(groupURLColumn),\n\t}\n\n\tgroupsTagsTableMgr = &joinTable{\n\t\ttable: table{\n\t\t\ttable:    groupsTagsJoinTable,\n\t\t\tidColumn: groupsTagsJoinTable.Col(groupIDColumn),\n\t\t},\n\t\tfkColumn:     groupsTagsJoinTable.Col(tagIDColumn),\n\t\tforeignTable: tagTableMgr,\n\t\torderBy:      tagTableSort,\n\t}\n\n\tgroupRelationshipTableMgr = &table{\n\t\ttable: groupRelationsJoinTable,\n\t}\n)\n\nvar (\n\tblobTableMgr = &table{\n\t\ttable:    goqu.T(blobTable),\n\t\tidColumn: goqu.T(blobTable).Col(blobChecksumColumn),\n\t}\n)\n\nvar (\n\tsavedFilterTableMgr = &table{\n\t\ttable:    goqu.T(savedFilterTable),\n\t\tidColumn: goqu.T(savedFilterTable).Col(idColumn),\n\t}\n)\n"
  },
  {
    "path": "pkg/sqlite/tag.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/doug-martin/goqu/v9\"\n\t\"github.com/doug-martin/goqu/v9/exp\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"gopkg.in/guregu/null.v4\"\n\t\"gopkg.in/guregu/null.v4/zero\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nconst (\n\ttagTable        = \"tags\"\n\ttagIDColumn     = \"tag_id\"\n\ttagAliasesTable = \"tag_aliases\"\n\ttagAliasColumn  = \"alias\"\n\n\ttagImageBlobColumn = \"image_blob\"\n\n\ttagRelationsTable = \"tags_relations\"\n\ttagParentIDColumn = \"parent_id\"\n\ttagChildIDColumn  = \"child_id\"\n)\n\ntype tagRow struct {\n\tID            int         `db:\"id\" goqu:\"skipinsert\"`\n\tName          null.String `db:\"name\"` // TODO: make schema non-nullable\n\tSortName      zero.String `db:\"sort_name\"`\n\tFavorite      bool        `db:\"favorite\"`\n\tDescription   zero.String `db:\"description\"`\n\tIgnoreAutoTag bool        `db:\"ignore_auto_tag\"`\n\tCreatedAt     Timestamp   `db:\"created_at\"`\n\tUpdatedAt     Timestamp   `db:\"updated_at\"`\n\n\t// not used in resolutions or updates\n\tImageBlob zero.String `db:\"image_blob\"`\n}\n\nfunc (r *tagRow) fromTag(o models.Tag) {\n\tr.ID = o.ID\n\tr.Name = null.StringFrom(o.Name)\n\tr.SortName = zero.StringFrom((o.SortName))\n\tr.Favorite = o.Favorite\n\tr.Description = zero.StringFrom(o.Description)\n\tr.IgnoreAutoTag = o.IgnoreAutoTag\n\tr.CreatedAt = Timestamp{Timestamp: o.CreatedAt}\n\tr.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}\n}\n\nfunc (r *tagRow) resolve() *models.Tag {\n\tret := &models.Tag{\n\t\tID:            r.ID,\n\t\tName:          r.Name.String,\n\t\tSortName:      r.SortName.String,\n\t\tFavorite:      r.Favorite,\n\t\tDescription:   r.Description.String,\n\t\tIgnoreAutoTag: r.IgnoreAutoTag,\n\t\tCreatedAt:     r.CreatedAt.Timestamp,\n\t\tUpdatedAt:     r.UpdatedAt.Timestamp,\n\t}\n\n\treturn ret\n}\n\ntype tagPathRow struct {\n\ttagRow\n\tPath string `db:\"path\"`\n}\n\nfunc (r *tagPathRow) resolve() *models.TagPath {\n\tret := &models.TagPath{\n\t\tTag:  *r.tagRow.resolve(),\n\t\tPath: r.Path,\n\t}\n\n\treturn ret\n}\n\ntype tagRowRecord struct {\n\tupdateRecord\n}\n\nfunc (r *tagRowRecord) fromPartial(o models.TagPartial) {\n\tr.setString(\"name\", o.Name)\n\tr.setNullString(\"sort_name\", o.SortName)\n\tr.setNullString(\"description\", o.Description)\n\tr.setBool(\"favorite\", o.Favorite)\n\tr.setBool(\"ignore_auto_tag\", o.IgnoreAutoTag)\n\tr.setTimestamp(\"created_at\", o.CreatedAt)\n\tr.setTimestamp(\"updated_at\", o.UpdatedAt)\n}\n\ntype tagRepositoryType struct {\n\trepository\n\n\taliases  stringRepository\n\tstashIDs stashIDRepository\n\n\tscenes     joinRepository\n\timages     joinRepository\n\tgalleries  joinRepository\n\tgroups     joinRepository\n\tperformers joinRepository\n\tstudios    joinRepository\n}\n\nvar (\n\ttagRepository = tagRepositoryType{\n\t\trepository: repository{\n\t\t\ttableName: tagTable,\n\t\t\tidColumn:  idColumn,\n\t\t},\n\t\taliases: stringRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: tagAliasesTable,\n\t\t\t\tidColumn:  tagIDColumn,\n\t\t\t},\n\t\t\tstringColumn: tagAliasColumn,\n\t\t},\n\t\tstashIDs: stashIDRepository{\n\t\t\trepository{\n\t\t\t\ttableName: \"tag_stash_ids\",\n\t\t\t\tidColumn:  tagIDColumn,\n\t\t\t},\n\t\t},\n\t\tscenes: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: scenesTagsTable,\n\t\t\t\tidColumn:  tagIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     sceneIDColumn,\n\t\t\tforeignTable: sceneTable,\n\t\t},\n\t\timages: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: imagesTagsTable,\n\t\t\t\tidColumn:  tagIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     imageIDColumn,\n\t\t\tforeignTable: imageTable,\n\t\t},\n\t\tgalleries: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: galleriesTagsTable,\n\t\t\t\tidColumn:  tagIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     galleryIDColumn,\n\t\t\tforeignTable: galleryTable,\n\t\t},\n\t\tgroups: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: groupsTagsTable,\n\t\t\t\tidColumn:  tagIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     groupIDColumn,\n\t\t\tforeignTable: groupTable,\n\t\t},\n\t\tperformers: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: performersTagsTable,\n\t\t\t\tidColumn:  tagIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     performerIDColumn,\n\t\t\tforeignTable: performerTable,\n\t\t},\n\t\tstudios: joinRepository{\n\t\t\trepository: repository{\n\t\t\t\ttableName: studiosTagsTable,\n\t\t\t\tidColumn:  tagIDColumn,\n\t\t\t},\n\t\t\tfkColumn:     studioIDColumn,\n\t\t\tforeignTable: studioTable,\n\t\t},\n\t}\n)\n\ntype TagStore struct {\n\tblobJoinQueryBuilder\n\tcustomFieldsStore\n\n\ttableMgr *table\n}\n\nfunc NewTagStore(blobStore *BlobStore) *TagStore {\n\treturn &TagStore{\n\t\tblobJoinQueryBuilder: blobJoinQueryBuilder{\n\t\t\tblobStore: blobStore,\n\t\t\tjoinTable: tagTable,\n\t\t},\n\t\tcustomFieldsStore: customFieldsStore{\n\t\t\ttable: tagsCustomFieldsTable,\n\t\t\tfk:    tagsCustomFieldsTable.Col(tagIDColumn),\n\t\t},\n\t\ttableMgr: tagTableMgr,\n\t}\n}\n\nfunc (qb *TagStore) table() exp.IdentifierExpression {\n\treturn qb.tableMgr.table\n}\n\nfunc (qb *TagStore) selectDataset() *goqu.SelectDataset {\n\treturn dialect.From(qb.table()).Select(qb.table().All())\n}\n\nfunc (qb *TagStore) Create(ctx context.Context, newObject *models.CreateTagInput) error {\n\tvar r tagRow\n\tr.fromTag(*newObject.Tag)\n\n\tid, err := qb.tableMgr.insertID(ctx, r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif newObject.Aliases.Loaded() {\n\t\tif err := tagsAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.ParentIDs.Loaded() {\n\t\tif err := tagsParentTagsTableMgr.insertJoins(ctx, id, newObject.ParentIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.ChildIDs.Loaded() {\n\t\tif err := tagsChildTagsTableMgr.insertJoins(ctx, id, newObject.ChildIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif newObject.StashIDs.Loaded() {\n\t\tif err := tagsStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tconst partial = false\n\tif err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil {\n\t\treturn err\n\t}\n\n\tupdated, err := qb.find(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"finding after create: %w\", err)\n\t}\n\n\t*newObject.Tag = *updated\n\n\treturn nil\n}\n\nfunc (qb *TagStore) UpdatePartial(ctx context.Context, id int, partial models.TagPartial) (*models.Tag, error) {\n\tr := tagRowRecord{\n\t\tupdateRecord{\n\t\t\tRecord: make(exp.Record),\n\t\t},\n\t}\n\n\tr.fromPartial(partial)\n\n\tif len(r.Record) > 0 {\n\t\tif err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.Aliases != nil {\n\t\tif err := tagsAliasesTableMgr.modifyJoins(ctx, id, partial.Aliases.Values, partial.Aliases.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.ParentIDs != nil {\n\t\tif err := tagsParentTagsTableMgr.modifyJoins(ctx, id, partial.ParentIDs.IDs, partial.ParentIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.ChildIDs != nil {\n\t\tif err := tagsChildTagsTableMgr.modifyJoins(ctx, id, partial.ChildIDs.IDs, partial.ChildIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif partial.StashIDs != nil {\n\t\tif err := tagsStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn qb.find(ctx, id)\n}\n\nfunc (qb *TagStore) Update(ctx context.Context, updatedObject *models.UpdateTagInput) error {\n\tvar r tagRow\n\tr.fromTag(*updatedObject.Tag)\n\n\tif err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {\n\t\treturn err\n\t}\n\n\tif updatedObject.Aliases.Loaded() {\n\t\tif err := tagsAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.ParentIDs.Loaded() {\n\t\tif err := tagsParentTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.ParentIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.ChildIDs.Loaded() {\n\t\tif err := tagsChildTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.ChildIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif updatedObject.StashIDs.Loaded() {\n\t\tif err := tagsStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (qb *TagStore) Destroy(ctx context.Context, id int) error {\n\t// must handle image checksums manually\n\tif err := qb.destroyImage(ctx, id); err != nil {\n\t\treturn err\n\t}\n\n\t// cannot unset primary_tag_id in scene_markers because it is not nullable\n\tcountQuery := \"SELECT COUNT(*) as count FROM scene_markers where primary_tag_id = ?\"\n\targs := []interface{}{id}\n\tprimaryMarkers, err := tagRepository.runCountQuery(ctx, countQuery, args)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif primaryMarkers > 0 {\n\t\treturn errors.New(\"cannot delete tag used as a primary tag in scene markers\")\n\t}\n\n\treturn tagRepository.destroyExisting(ctx, []int{id})\n}\n\n// returns nil, nil if not found\nfunc (qb *TagStore) Find(ctx context.Context, id int) (*models.Tag, error) {\n\tret, err := qb.find(ctx, id)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, nil\n\t}\n\treturn ret, err\n}\n\nfunc (qb *TagStore) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) {\n\tret := make([]*models.Tag, len(ids))\n\n\ttable := qb.table()\n\tif err := batchExec(ids, defaultBatchSize, func(batch []int) error {\n\t\tq := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))\n\t\tunsorted, err := qb.getMany(ctx, q)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, s := range unsorted {\n\t\t\ti := slices.Index(ids, s.ID)\n\t\t\tret[i] = s\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor i := range ret {\n\t\tif ret[i] == nil {\n\t\t\treturn nil, fmt.Errorf(\"tag with id %d not found\", ids[i])\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *TagStore) find(ctx context.Context, id int) (*models.Tag, error) {\n\tq := qb.selectDataset().Where(qb.tableMgr.byID(id))\n\n\tret, err := qb.get(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\n// returns nil, sql.ErrNoRows if not found\nfunc (qb *TagStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Tag, error) {\n\tret, err := qb.getMany(ctx, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ret) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\n\treturn ret[0], nil\n}\n\nfunc (qb *TagStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Tag, error) {\n\tconst single = false\n\tvar ret []*models.Tag\n\tif err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {\n\t\tvar f tagRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *TagStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tLEFT JOIN scenes_tags as scenes_join on scenes_join.tag_id = tags.id\n\t\tWHERE scenes_join.scene_id = ?\n\t\tGROUP BY tags.id\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{sceneID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tLEFT JOIN performers_tags as performers_join on performers_join.tag_id = tags.id\n\t\tWHERE performers_join.performer_id = ?\n\t\tGROUP BY tags.id\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{performerID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tLEFT JOIN images_tags as images_join on images_join.tag_id = tags.id\n\t\tWHERE images_join.image_id = ?\n\t\tGROUP BY tags.id\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{imageID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tLEFT JOIN galleries_tags as galleries_join on galleries_join.tag_id = tags.id\n\t\tWHERE galleries_join.gallery_id = ?\n\t\tGROUP BY tags.id\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{galleryID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tLEFT JOIN groups_tags as groups_join on groups_join.tag_id = tags.id\n\t\tWHERE groups_join.group_id = ?\n\t\tGROUP BY tags.id\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{groupID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tLEFT JOIN scene_markers_tags as scene_markers_join on scene_markers_join.tag_id = tags.id\n\t\tWHERE scene_markers_join.scene_marker_id = ?\n\t\tGROUP BY tags.id\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{sceneMarkerID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tLEFT JOIN studios_tags as studios_join on studios_join.tag_id = tags.id\n\t\tWHERE studios_join.studio_id = ?\n\t\tGROUP BY tags.id\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{studioID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {\n\t// query := \"SELECT * FROM tags WHERE name = ?\"\n\t// if nocase {\n\t// \tquery += \" COLLATE NOCASE\"\n\t// }\n\t// query += \" LIMIT 1\"\n\twhere := \"name = ?\"\n\tif nocase {\n\t\twhere += \" COLLATE NOCASE\"\n\t}\n\tsq := qb.selectDataset().Prepared(true).Where(goqu.L(where, name)).Limit(1)\n\tret, err := qb.get(ctx, sq)\n\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Tag, error) {\n\t// query := \"SELECT * FROM tags WHERE name\"\n\t// if nocase {\n\t// \tquery += \" COLLATE NOCASE\"\n\t// }\n\t// query += \" IN \" + getInBinding(len(names))\n\twhere := \"name\"\n\tif nocase {\n\t\twhere += \" COLLATE NOCASE\"\n\t}\n\twhere += \" IN \" + getInBinding(len(names))\n\tvar args []interface{}\n\tfor _, name := range names {\n\t\targs = append(args, name)\n\t}\n\tsq := qb.selectDataset().Prepared(true).Where(goqu.L(where, args...))\n\tret, err := qb.getMany(ctx, sq)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) {\n\tsq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where(\n\t\ttagsStashIDsJoinTable.Col(\"stash_id\").Eq(stashID.StashID),\n\t\ttagsStashIDsJoinTable.Col(\"endpoint\").Eq(stashID.Endpoint),\n\t)\n\n\tidsQuery := qb.selectDataset().Where(\n\t\tqb.table().Col(idColumn).In(sq),\n\t)\n\n\tret, err := qb.getMany(ctx, idsQuery)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting tags for stash ID %s: %w\", stashID.StashID, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *TagStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) {\n\ttable := qb.table()\n\tsq := dialect.From(table).LeftJoin(\n\t\ttagsStashIDsJoinTable,\n\t\tgoqu.On(table.Col(idColumn).Eq(tagsStashIDsJoinTable.Col(tagIDColumn))),\n\t).Select(table.Col(idColumn))\n\n\tif hasStashID {\n\t\tsq = sq.Where(\n\t\t\ttagsStashIDsJoinTable.Col(\"stash_id\").IsNotNull(),\n\t\t\ttagsStashIDsJoinTable.Col(\"endpoint\").Eq(stashboxEndpoint),\n\t\t)\n\t} else {\n\t\tsq = sq.Where(\n\t\t\ttagsStashIDsJoinTable.Col(\"stash_id\").IsNull(),\n\t\t)\n\t}\n\n\tidsQuery := qb.selectDataset().Where(\n\t\ttable.Col(idColumn).In(sq),\n\t)\n\n\tret, err := qb.getMany(ctx, idsQuery)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting tags for stash-box endpoint %s: %w\", stashboxEndpoint, err)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) {\n\treturn tagsParentTagsTableMgr.get(ctx, relatedID)\n}\n\nfunc (qb *TagStore) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) {\n\treturn tagsChildTagsTableMgr.get(ctx, relatedID)\n}\n\nfunc (qb *TagStore) FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tINNER JOIN tags_relations ON tags_relations.child_id = tags.id\n\t\tWHERE tags_relations.parent_id = ?\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{parentID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) FindByChildTagID(ctx context.Context, parentID int) ([]*models.Tag, error) {\n\tquery := `\n\t\tSELECT tags.* FROM tags\n\t\tINNER JOIN tags_relations ON tags_relations.parent_id = tags.id\n\t\tWHERE tags_relations.child_id = ?\n\t`\n\tquery += qb.getDefaultTagSort()\n\targs := []interface{}{parentID}\n\treturn qb.queryTags(ctx, query, args)\n}\n\nfunc (qb *TagStore) CountByParentTagID(ctx context.Context, parentID int) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(goqu.T(\"tags\")).\n\t\tInnerJoin(goqu.T(\"tags_relations\"), goqu.On(goqu.I(\"tags_relations.parent_id\").Eq(goqu.I(\"tags.id\")))).\n\t\tWhere(goqu.I(\"tags_relations.child_id\").Eq(goqu.V(parentID))) // Pass the parentID here\n\treturn count(ctx, q)\n}\n\nfunc (qb *TagStore) CountByChildTagID(ctx context.Context, childID int) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(goqu.T(\"tags\")).\n\t\tInnerJoin(goqu.T(\"tags_relations\"), goqu.On(goqu.I(\"tags_relations.child_id\").Eq(goqu.I(\"tags.id\")))).\n\t\tWhere(goqu.I(\"tags_relations.parent_id\").Eq(goqu.V(childID))) // Pass the childID here\n\treturn count(ctx, q)\n}\n\nfunc (qb *TagStore) Count(ctx context.Context) (int, error) {\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(qb.table())\n\treturn count(ctx, q)\n}\n\nfunc (qb *TagStore) All(ctx context.Context) ([]*models.Tag, error) {\n\ttable := qb.table()\n\n\treturn qb.getMany(ctx, qb.selectDataset().Order(\n\t\tgoqu.L(\"COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI\").Asc(),\n\t\ttable.Col(idColumn).Asc(),\n\t))\n}\n\nfunc (qb *TagStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Tag, error) {\n\t// TODO - Query needs to be changed to support queries of this type, and\n\t// this method should be removed\n\tquery := selectAll(tagTable)\n\tquery += \" LEFT JOIN tag_aliases ON tag_aliases.tag_id = tags.id\"\n\n\tvar whereClauses []string\n\tvar args []interface{}\n\n\tfor _, w := range words {\n\t\tww := w + \"%\"\n\t\twhereClauses = append(whereClauses, \"tags.name like ?\")\n\t\targs = append(args, ww)\n\n\t\t// include aliases\n\t\twhereClauses = append(whereClauses, \"tag_aliases.alias like ?\")\n\t\targs = append(args, ww)\n\t}\n\n\twhereOr := \"(\" + strings.Join(whereClauses, \" OR \") + \")\"\n\twhere := strings.Join([]string{\n\t\t\"tags.ignore_auto_tag = 0\",\n\t\twhereOr,\n\t}, \" AND \")\n\treturn qb.queryTags(ctx, query+\" WHERE \"+where, args)\n}\n\nfunc (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) ([]*models.Tag, int, error) {\n\tif tagFilter == nil {\n\t\ttagFilter = &models.TagFilterType{}\n\t}\n\tif findFilter == nil {\n\t\tfindFilter = &models.FindFilterType{}\n\t}\n\n\tquery := tagRepository.newQuery()\n\tdistinctIDs(&query, tagTable)\n\n\tif q := findFilter.Q; q != nil && *q != \"\" {\n\t\tquery.join(tagAliasesTable, \"\", \"tag_aliases.tag_id = tags.id\")\n\t\tsearchColumns := []string{\"tags.name\", \"tag_aliases.alias\", \"tags.sort_name\"}\n\t\tquery.parseQueryString(searchColumns, *q)\n\t}\n\n\tfilter := filterBuilderFromHandler(ctx, &tagFilterHandler{\n\t\ttagFilter: tagFilter,\n\t})\n\n\tif err := query.addFilter(filter); err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tvar err error\n\tquery.sortAndPagination, err = qb.getTagSort(&query, findFilter)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tquery.sortAndPagination += getPagination(findFilter)\n\tidsResult, countResult, err := query.executeFind(ctx)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\ttags, err := qb.FindMany(ctx, idsResult)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\treturn tags, countResult, nil\n}\n\nvar tagSortOptions = sortOptions{\n\t\"created_at\",\n\t\"galleries_count\",\n\t\"groups_count\",\n\t\"id\",\n\t\"images_count\",\n\t\"movies_count\",\n\t\"studios_count\",\n\t\"name\",\n\t\"performers_count\",\n\t\"random\",\n\t\"scene_markers_count\",\n\t\"scenes_count\",\n\t\"scenes_duration\",\n\t\"scenes_size\",\n\t\"updated_at\",\n}\n\nfunc (qb *TagStore) sortByScenesDuration(direction string) string {\n\treturn fmt.Sprintf(` ORDER BY (\n\t\tSELECT COALESCE(SUM(video_files.duration), 0)\n\t\tFROM %s\n\t\tLEFT JOIN %s ON %s.id = %s.%s\n\t\tLEFT JOIN %s ON %s.%s = %s.id\n\t\tLEFT JOIN video_files ON video_files.file_id = %s.file_id\n\t\tWHERE %s.%s = %s.id\n\t) %s`, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction))\n}\n\nfunc (qb *TagStore) sortByScenesSize(direction string) string {\n\treturn fmt.Sprintf(` ORDER BY (\n\t\tSELECT COALESCE(SUM(%s.size), 0)\n\t\tFROM %s\n\t\tLEFT JOIN %s ON %s.id = %s.%s\n\t\tLEFT JOIN %s ON %s.%s = %s.id\n\t\tLEFT JOIN %s ON %s.id = %s.file_id\n\t\tWHERE %s.%s = %s.id\n\t) %s`, fileTable, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction))\n}\n\nfunc (qb *TagStore) getDefaultTagSort() string {\n\treturn getSort(\"name\", \"ASC\", \"tags\")\n}\n\nfunc (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilterType) (string, error) {\n\tvar sort string\n\tvar direction string\n\tif findFilter == nil {\n\t\tsort = \"name\"\n\t\tdirection = \"ASC\"\n\t} else {\n\t\tsort = findFilter.GetSort(\"name\")\n\t\tdirection = findFilter.GetDirection()\n\t}\n\n\t// CVE-2024-32231 - ensure sort is in the list of allowed sorts\n\tif err := tagSortOptions.validateSort(sort); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tsortQuery := \"\"\n\tswitch sort {\n\tcase \"name\":\n\t\tsortQuery += fmt.Sprintf(\" ORDER BY COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI %s\", getSortDirection(direction))\n\tcase \"scenes_count\":\n\t\tsortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction)\n\tcase \"scenes_duration\":\n\t\tsortQuery += qb.sortByScenesDuration(direction)\n\tcase \"scenes_size\":\n\t\tsortQuery += qb.sortByScenesSize(direction)\n\tcase \"scene_markers_count\":\n\t\tsortQuery += fmt.Sprintf(\" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s\", getSortDirection(direction))\n\tcase \"images_count\":\n\t\tsortQuery += getCountSort(tagTable, imagesTagsTable, tagIDColumn, direction)\n\tcase \"galleries_count\":\n\t\tsortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction)\n\tcase \"performers_count\":\n\t\tsortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction)\n\tcase \"studios_count\":\n\t\tsortQuery += getCountSort(tagTable, studiosTagsTable, tagIDColumn, direction)\n\tcase \"movies_count\", \"groups_count\":\n\t\tsortQuery += getCountSort(tagTable, groupsTagsTable, tagIDColumn, direction)\n\tdefault:\n\t\tsortQuery += getSort(sort, direction, \"tags\")\n\t}\n\n\t// Whatever the sorting, always use sort_name/name/id as a final sort\n\tsortQuery += \", COALESCE(tags.sort_name, tags.name, tags.id) COLLATE NATURAL_CI ASC\"\n\treturn sortQuery, nil\n}\n\nfunc (qb *TagStore) queryTags(ctx context.Context, query string, args []interface{}) ([]*models.Tag, error) {\n\tconst single = false\n\tvar ret []*models.Tag\n\tif err := tagRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error {\n\t\tvar f tagRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ts := f.resolve()\n\n\t\tret = append(ret, s)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *TagStore) queryTagPaths(ctx context.Context, query string, args []interface{}) ([]*models.TagPath, error) {\n\tconst single = false\n\tvar ret []*models.TagPath\n\tif err := tagRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error {\n\t\tvar f tagPathRow\n\t\tif err := r.StructScan(&f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tt := f.resolve()\n\n\t\tret = append(ret, t)\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc (qb *TagStore) GetImage(ctx context.Context, tagID int) ([]byte, error) {\n\treturn qb.blobJoinQueryBuilder.GetImage(ctx, tagID, tagImageBlobColumn)\n}\n\nfunc (qb *TagStore) HasImage(ctx context.Context, tagID int) (bool, error) {\n\treturn qb.blobJoinQueryBuilder.HasImage(ctx, tagID, tagImageBlobColumn)\n}\n\nfunc (qb *TagStore) UpdateImage(ctx context.Context, tagID int, image []byte) error {\n\treturn qb.blobJoinQueryBuilder.UpdateImage(ctx, tagID, tagImageBlobColumn, image)\n}\n\nfunc (qb *TagStore) destroyImage(ctx context.Context, tagID int) error {\n\treturn qb.blobJoinQueryBuilder.DestroyImage(ctx, tagID, tagImageBlobColumn)\n}\n\nfunc (qb *TagStore) GetAliases(ctx context.Context, tagID int) ([]string, error) {\n\treturn tagRepository.aliases.get(ctx, tagID)\n}\n\nfunc (qb *TagStore) UpdateAliases(ctx context.Context, tagID int, aliases []string) error {\n\treturn tagRepository.aliases.replace(ctx, tagID, aliases)\n}\n\nfunc (qb *TagStore) GetStashIDs(ctx context.Context, tagID int) ([]models.StashID, error) {\n\treturn tagsStashIDsTableMgr.get(ctx, tagID)\n}\n\nfunc (qb *TagStore) UpdateStashIDs(ctx context.Context, tagID int, stashIDs []models.StashID) error {\n\treturn tagsStashIDsTableMgr.replaceJoins(ctx, tagID, stashIDs)\n}\n\nfunc (qb *TagStore) Merge(ctx context.Context, source []int, destination int) error {\n\tif len(source) == 0 {\n\t\treturn nil\n\t}\n\n\tinBinding := getInBinding(len(source))\n\n\targs := []interface{}{destination}\n\tsrcArgs := make([]interface{}, len(source))\n\tfor i, id := range source {\n\t\tif id == destination {\n\t\t\treturn errors.New(\"cannot merge where source == destination\")\n\t\t}\n\t\tsrcArgs[i] = id\n\t}\n\n\targs = append(args, srcArgs...)\n\n\ttagTables := map[string]string{\n\t\tscenesTagsTable:      sceneIDColumn,\n\t\t\"scene_markers_tags\": \"scene_marker_id\",\n\t\tgalleriesTagsTable:   galleryIDColumn,\n\t\timagesTagsTable:      imageIDColumn,\n\t\t\"performers_tags\":    \"performer_id\",\n\t\t\"studios_tags\":       \"studio_id\",\n\t\tgroupsTagsTable:      \"group_id\",\n\t}\n\n\targs = append(args, destination)\n\n\t// for each table, update source tag ids to destination tag id, ignoring duplicates\n\tfor table, idColumn := range tagTables {\n\t\t_, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+`\nSET tag_id = ?\nWHERE tag_id IN `+inBinding+`\nAND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.tag_id = ?)`,\n\t\t\targs...,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// delete source tag ids from the table where they couldn't be set\n\t\tif _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE tag_id IN `+inBinding, srcArgs...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t_, err := dbWrapper.Exec(ctx, \"UPDATE \"+sceneMarkerTable+\" SET primary_tag_id = ? WHERE primary_tag_id IN \"+inBinding, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = dbWrapper.Exec(ctx, \"INSERT INTO \"+tagAliasesTable+\" (tag_id, alias) SELECT ?, name FROM \"+tagTable+\" WHERE id IN \"+inBinding, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = dbWrapper.Exec(ctx, \"UPDATE \"+tagAliasesTable+\" SET tag_id = ? WHERE tag_id IN \"+inBinding, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Merge StashIDs - move all source StashIDs to destination (ignoring duplicates)\n\t_, err = dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+\"tag_stash_ids\"+`\nSET tag_id = ?\nWHERE tag_id IN `+inBinding, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Delete remaining source StashIDs that couldn't be moved (duplicates)\n\tif _, err := dbWrapper.Exec(ctx, `DELETE FROM tag_stash_ids WHERE tag_id IN `+inBinding, srcArgs...); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, id := range source {\n\t\terr = qb.Destroy(ctx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *TagStore) UpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error {\n\tif _, err := dbWrapper.Exec(ctx, \"DELETE FROM tags_relations WHERE child_id = ?\", tagID); err != nil {\n\t\treturn err\n\t}\n\n\tif len(parentIDs) > 0 {\n\t\tvar args []interface{}\n\t\tvar values []string\n\t\tfor _, parentID := range parentIDs {\n\t\t\tvalues = append(values, \"(? , ?)\")\n\t\t\targs = append(args, parentID, tagID)\n\t\t}\n\n\t\tquery := \"INSERT INTO tags_relations (parent_id, child_id) VALUES \" + strings.Join(values, \", \")\n\t\tif _, err := dbWrapper.Exec(ctx, query, args...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *TagStore) UpdateChildTags(ctx context.Context, tagID int, childIDs []int) error {\n\tif _, err := dbWrapper.Exec(ctx, \"DELETE FROM tags_relations WHERE parent_id = ?\", tagID); err != nil {\n\t\treturn err\n\t}\n\n\tif len(childIDs) > 0 {\n\t\tvar args []interface{}\n\t\tvar values []string\n\t\tfor _, childID := range childIDs {\n\t\t\tvalues = append(values, \"(? , ?)\")\n\t\t\targs = append(args, tagID, childID)\n\t\t}\n\n\t\tquery := \"INSERT INTO tags_relations (parent_id, child_id) VALUES \" + strings.Join(values, \", \")\n\t\tif _, err := dbWrapper.Exec(ctx, query, args...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// FindAllAncestors returns a slice of TagPath objects, representing all\n// ancestors of the tag with the provided id.\nfunc (qb *TagStore) FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) {\n\tinBinding := getInBinding(len(excludeIDs) + 1)\n\n\tquery := `WITH RECURSIVE\nparents AS (\n\tSELECT t.id AS parent_id, t.id AS child_id, t.name as path FROM tags t WHERE t.id = ?\n\tUNION\n\tSELECT tr.parent_id, tr.child_id, t.name || '->' || p.path as path FROM tags_relations tr INNER JOIN parents p ON p.parent_id = tr.child_id JOIN tags t ON t.id = tr.parent_id WHERE tr.parent_id NOT IN` + inBinding + `\n)\nSELECT t.*, p.path FROM tags t INNER JOIN parents p ON t.id = p.parent_id\n`\n\n\texcludeArgs := []interface{}{tagID}\n\tfor _, excludeID := range excludeIDs {\n\t\texcludeArgs = append(excludeArgs, excludeID)\n\t}\n\targs := []interface{}{tagID}\n\targs = append(args, append(append(excludeArgs, excludeArgs...), excludeArgs...)...)\n\n\treturn qb.queryTagPaths(ctx, query, args)\n}\n\n// FindAllDescendants returns a slice of TagPath objects, representing all\n// descendants of the tag with the provided id.\nfunc (qb *TagStore) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) {\n\tinBinding := getInBinding(len(excludeIDs) + 1)\n\n\tquery := `WITH RECURSIVE\nchildren AS (\n\tSELECT t.id AS parent_id, t.id AS child_id, t.name as path FROM tags t WHERE t.id = ?\n\tUNION\n\tSELECT tr.parent_id, tr.child_id, c.path || '->' || t.name as path FROM tags_relations tr INNER JOIN children c ON c.child_id = tr.parent_id JOIN tags t ON t.id = tr.child_id WHERE tr.child_id NOT IN` + inBinding + `\n)\nSELECT t.*, c.path FROM tags t INNER JOIN children c ON t.id = c.child_id\n`\n\n\texcludeArgs := []interface{}{tagID}\n\tfor _, excludeID := range excludeIDs {\n\t\texcludeArgs = append(excludeArgs, excludeID)\n\t}\n\targs := []interface{}{tagID}\n\targs = append(args, append(append(excludeArgs, excludeArgs...), excludeArgs...)...)\n\n\treturn qb.queryTagPaths(ctx, query, args)\n}\n\ntype tagRelationshipStore struct {\n\tidRelationshipStore\n}\n\nfunc (s *tagRelationshipStore) CountByTagID(ctx context.Context, tagID int) (int, error) {\n\tjoinTable := s.joinTable.table.table\n\tq := dialect.Select(goqu.COUNT(\"*\")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID))\n\treturn count(ctx, q)\n}\n\nfunc (s *tagRelationshipStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {\n\treturn s.joinTable.get(ctx, id)\n}\n"
  },
  {
    "path": "pkg/sqlite/tag_filter.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype tagFilterHandler struct {\n\ttagFilter *models.TagFilterType\n}\n\nfunc (qb *tagFilterHandler) validate() error {\n\ttagFilter := qb.tagFilter\n\tif tagFilter == nil {\n\t\treturn nil\n\t}\n\n\tif err := validateFilterCombination(tagFilter.OperatorFilter); err != nil {\n\t\treturn err\n\t}\n\n\tif subFilter := tagFilter.SubFilter(); subFilter != nil {\n\t\tsqb := &tagFilterHandler{tagFilter: subFilter}\n\t\tif err := sqb.validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (qb *tagFilterHandler) handle(ctx context.Context, f *filterBuilder) {\n\ttagFilter := qb.tagFilter\n\tif tagFilter == nil {\n\t\treturn\n\t}\n\n\tif err := qb.validate(); err != nil {\n\t\tf.setError(err)\n\t\treturn\n\t}\n\n\tsf := tagFilter.SubFilter()\n\tif sf != nil {\n\t\tsub := &tagFilterHandler{sf}\n\t\thandleSubFilter(ctx, sub, f, tagFilter.OperatorFilter)\n\t}\n\n\tf.handleCriterion(ctx, qb.criterionHandler())\n}\n\nvar tagHierarchyHandler = hierarchicalRelationshipHandler{\n\tprimaryTable:  tagTable,\n\trelationTable: tagRelationsTable,\n\taliasPrefix:   tagTable,\n\tparentIDCol:   \"parent_id\",\n\tchildIDCol:    \"child_id\",\n}\n\nfunc (qb *tagFilterHandler) criterionHandler() criterionHandler {\n\ttagFilter := qb.tagFilter\n\treturn compoundHandler{\n\t\tstringCriterionHandler(tagFilter.Name, tagTable+\".name\"),\n\t\tstringCriterionHandler(tagFilter.SortName, tagTable+\".sort_name\"),\n\t\tqb.aliasCriterionHandler(tagFilter.Aliases),\n\n\t\tboolCriterionHandler(tagFilter.Favorite, tagTable+\".favorite\", nil),\n\t\tstringCriterionHandler(tagFilter.Description, tagTable+\".description\"),\n\t\tboolCriterionHandler(tagFilter.IgnoreAutoTag, tagTable+\".ignore_auto_tag\", nil),\n\n\t\tqb.isMissingCriterionHandler(tagFilter.IsMissing),\n\t\tqb.sceneCountCriterionHandler(tagFilter.SceneCount),\n\t\tqb.imageCountCriterionHandler(tagFilter.ImageCount),\n\t\tqb.galleryCountCriterionHandler(tagFilter.GalleryCount),\n\t\tqb.performerCountCriterionHandler(tagFilter.PerformerCount),\n\t\tqb.studioCountCriterionHandler(tagFilter.StudioCount),\n\n\t\tqb.groupCountCriterionHandler(tagFilter.GroupCount),\n\t\tqb.groupCountCriterionHandler(tagFilter.MovieCount),\n\n\t\tqb.markerCountCriterionHandler(tagFilter.MarkerCount),\n\t\ttagHierarchyHandler.ParentsCriterionHandler(tagFilter.Parents),\n\t\ttagHierarchyHandler.ChildrenCriterionHandler(tagFilter.Children),\n\t\ttagHierarchyHandler.ParentCountCriterionHandler(tagFilter.ParentCount),\n\t\ttagHierarchyHandler.ChildCountCriterionHandler(tagFilter.ChildCount),\n\n\t\t&stashIDCriterionHandler{\n\t\t\tc:                 tagFilter.StashIDEndpoint,\n\t\t\tstashIDRepository: &tagRepository.stashIDs,\n\t\t\tstashIDTableAs:    \"tag_stash_ids\",\n\t\t\tparentIDCol:       \"tags.id\",\n\t\t},\n\t\t&stashIDsCriterionHandler{\n\t\t\tc:                 tagFilter.StashIDsEndpoint,\n\t\t\tstashIDRepository: &tagRepository.stashIDs,\n\t\t\tstashIDTableAs:    \"tag_stash_ids\",\n\t\t\tparentIDCol:       \"tags.id\",\n\t\t},\n\n\t\t&timestampCriterionHandler{tagFilter.CreatedAt, \"tags.created_at\", nil},\n\t\t&timestampCriterionHandler{tagFilter.UpdatedAt, \"tags.updated_at\", nil},\n\n\t\t&customFieldsFilterHandler{\n\t\t\ttable: tagsCustomFieldsTable.GetTable(),\n\t\t\tfkCol: tagIDColumn,\n\t\t\tc:     tagFilter.CustomFields,\n\t\t\tidCol: \"tags.id\",\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"scenes_tags.scene_id\",\n\t\t\trelatedRepo:    sceneRepository.repository,\n\t\t\trelatedHandler: &sceneFilterHandler{tagFilter.ScenesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\ttagRepository.scenes.innerJoin(f, \"\", \"tags.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"images_tags.image_id\",\n\t\t\trelatedRepo:    imageRepository.repository,\n\t\t\trelatedHandler: &imageFilterHandler{tagFilter.ImagesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\ttagRepository.images.innerJoin(f, \"\", \"tags.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"galleries_tags.gallery_id\",\n\t\t\trelatedRepo:    galleryRepository.repository,\n\t\t\trelatedHandler: &galleryFilterHandler{tagFilter.GalleriesFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\ttagRepository.galleries.innerJoin(f, \"\", \"tags.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"groups_tags.group_id\",\n\t\t\trelatedRepo:    groupRepository.repository,\n\t\t\trelatedHandler: &groupFilterHandler{tagFilter.GroupsFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\ttagRepository.groups.innerJoin(f, \"\", \"tags.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"performers_tags.performer_id\",\n\t\t\trelatedRepo:    performerRepository.repository,\n\t\t\trelatedHandler: &performerFilterHandler{tagFilter.PerformersFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\ttagRepository.performers.innerJoin(f, \"\", \"tags.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"studios_tags.studio_id\",\n\t\t\trelatedRepo:    studioRepository.repository,\n\t\t\trelatedHandler: &studioFilterHandler{tagFilter.StudiosFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\ttagRepository.studios.innerJoin(f, \"\", \"tags.id\")\n\t\t\t},\n\t\t},\n\n\t\t&relatedFilterHandler{\n\t\t\trelatedIDCol:   \"markers_tags.marker_id\",\n\t\t\trelatedRepo:    sceneMarkerRepository.repository,\n\t\t\trelatedHandler: &sceneMarkerFilterHandler{tagFilter.MarkersFilter},\n\t\t\tjoinFn: func(f *filterBuilder) {\n\t\t\t\tf.addWith(`markers_tags AS (\n\t\t\t\tSELECT mt.scene_marker_id AS marker_id, mt.tag_id AS tag_id FROM scene_markers_tags mt\n\t\t\t\tUNION\n\t\t\t\tSELECT m.id, m.primary_tag_id FROM scene_markers m\n\t\t\t\t)`)\n\t\t\t\tf.addInnerJoin(\"markers_tags\", \"\", \"markers_tags.tag_id = tags.id\")\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc {\n\th := stringListCriterionHandlerBuilder{\n\t\tprimaryTable: tagTable,\n\t\tprimaryFK:    tagIDColumn,\n\t\tjoinTable:    tagAliasesTable,\n\t\tstringColumn: tagAliasColumn,\n\t\taddJoinTable: func(f *filterBuilder) {\n\t\t\ttagRepository.aliases.join(f, \"\", \"tags.id\")\n\t\t},\n\t}\n\n\treturn h.handler(alias)\n}\n\nfunc (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif isMissing != nil && *isMissing != \"\" {\n\t\t\tswitch *isMissing {\n\t\t\tcase \"image\":\n\t\t\t\tf.addWhere(\"tags.image_blob IS NULL\")\n\t\t\tcase \"aliases\":\n\t\t\t\ttagRepository.aliases.join(f, \"\", \"tags.id\")\n\t\t\t\tf.addWhere(\"tag_aliases.alias IS NULL\")\n\t\t\tcase \"stash_id\":\n\t\t\t\ttagRepository.stashIDs.join(f, \"tag_stash_ids\", \"tags.id\")\n\t\t\t\tf.addWhere(\"tag_stash_ids.tag_id IS NULL\")\n\t\t\tdefault:\n\t\t\t\tif err := validateIsMissing(*isMissing, []string{\n\t\t\t\t\t\"description\",\n\t\t\t\t}); err != nil {\n\t\t\t\t\tf.setError(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tf.addWhere(\"(tags.\" + *isMissing + \" IS NULL OR TRIM(tags.\" + *isMissing + \") = '')\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (qb *tagFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif sceneCount != nil {\n\t\t\tf.addLeftJoin(\"scenes_tags\", \"\", \"scenes_tags.tag_id = tags.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct scenes_tags.scene_id)\", *sceneCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *tagFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif imageCount != nil {\n\t\t\tf.addLeftJoin(\"images_tags\", \"\", \"images_tags.tag_id = tags.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct images_tags.image_id)\", *imageCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *tagFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif galleryCount != nil {\n\t\t\tf.addLeftJoin(\"galleries_tags\", \"\", \"galleries_tags.tag_id = tags.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct galleries_tags.gallery_id)\", *galleryCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif performerCount != nil {\n\t\t\tf.addLeftJoin(\"performers_tags\", \"\", \"performers_tags.tag_id = tags.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct performers_tags.performer_id)\", *performerCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif studioCount != nil {\n\t\t\tf.addLeftJoin(\"studios_tags\", \"\", \"studios_tags.tag_id = tags.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct studios_tags.studio_id)\", *studioCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *tagFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif groupCount != nil {\n\t\t\tf.addLeftJoin(\"groups_tags\", \"\", \"groups_tags.tag_id = tags.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct groups_tags.group_id)\", *groupCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n\nfunc (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc {\n\treturn func(ctx context.Context, f *filterBuilder) {\n\t\tif markerCount != nil {\n\t\t\tf.addLeftJoin(\"scene_markers_tags\", \"\", \"scene_markers_tags.tag_id = tags.id\")\n\t\t\tf.addLeftJoin(\"scene_markers\", \"\", \"scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id\")\n\t\t\tclause, args := getIntCriterionWhereClause(\"count(distinct scene_markers.id)\", *markerCount)\n\n\t\t\tf.addHaving(clause, args...)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/tag_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestMarkerFindBySceneMarkerID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttqb := db.Tag\n\n\t\tmarkerID := markerIDs[markerIdxWithTag]\n\n\t\ttags, err := tqb.FindBySceneMarkerID(ctx, markerID)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tagIDs[tagIdxWithMarkers], tags[0].ID)\n\n\t\ttags, err = tqb.FindBySceneMarkerID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagFindByGroupID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttqb := db.Tag\n\n\t\tgroupID := groupIDs[groupIdxWithTag]\n\n\t\ttags, err := tqb.FindByGroupID(ctx, groupID)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tagIDs[tagIdxWithGroup], tags[0].ID)\n\n\t\ttags, err = tqb.FindByGroupID(ctx, 0)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagFindByName(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttqb := db.Tag\n\n\t\tname := tagNames[tagIdxWithScene] // find a tag by name\n\n\t\ttag, err := tqb.FindByName(ctx, name, false)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\n\t\tassert.Equal(t, tagNames[tagIdxWithScene], tag.Name)\n\n\t\tname = tagNames[tagIdxWithDupName] // find a tag by name nocase\n\n\t\ttag, err = tqb.FindByName(ctx, name, true)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\t\t// tagIdxWithDupName and tagIdxWithScene should have similar names ( only diff should be Name vs NaMe)\n\t\t//tag.Name should match with tagIdxWithScene since its ID is before tagIdxWithDupName\n\t\tassert.Equal(t, tagNames[tagIdxWithScene], tag.Name)\n\t\t//tag.Name should match with tagIdxWithDupName if the check is not case sensitive\n\t\tassert.Equal(t, strings.ToLower(tagNames[tagIdxWithDupName]), strings.ToLower(tag.Name))\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryIgnoreAutoTag(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tignoreAutoTag := true\n\t\ttagFilter := models.TagFilterType{\n\t\t\tIgnoreAutoTag: &ignoreAutoTag,\n\t\t}\n\n\t\tsqb := db.Tag\n\n\t\ttags := queryTags(ctx, t, sqb, &tagFilter, nil)\n\n\t\tassert.Len(t, tags, int(math.Ceil(float64(totalTags)/5)))\n\t\tfor _, s := range tags {\n\t\t\tassert.True(t, s.IgnoreAutoTag)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryForAutoTag(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\ttqb := db.Tag\n\n\t\tname := tagNames[tagIdx1WithScene] // find a tag by name\n\n\t\ttags, err := tqb.QueryForAutoTag(ctx, []string{name})\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 2)\n\t\tlcName := tagNames[tagIdx1WithScene]\n\t\tassert.Equal(t, strings.ToLower(lcName), strings.ToLower(tags[0].Name))\n\t\tassert.Equal(t, strings.ToLower(lcName), strings.ToLower(tags[1].Name))\n\n\t\t// find by alias\n\t\tname = getTagStringValue(tagIdx1WithScene, \"Alias\")\n\t\ttags, err = tqb.QueryForAutoTag(ctx, []string{name})\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tagIDs[tagIdx1WithScene], tags[0].ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagFindByNames(t *testing.T) {\n\tvar names []string\n\n\twithTxn(func(ctx context.Context) error {\n\t\ttqb := db.Tag\n\n\t\tnames = append(names, tagNames[tagIdxWithScene]) // find tags by names\n\n\t\ttags, err := tqb.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tagNames[tagIdxWithScene], tags[0].Name)\n\n\t\ttags, err = tqb.FindByNames(ctx, names, true) // find tags by names nocase\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, tags, 2) // tagIdxWithScene and tagIdxWithDupName\n\t\tassert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[0].Name))\n\t\tassert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[1].Name))\n\n\t\tnames = append(names, tagNames[tagIdx1WithScene]) // find tags by names ( 2 names )\n\n\t\ttags, err = tqb.FindByNames(ctx, names, false)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, tags, 2) // tagIdxWithScene and tagIdx1WithScene\n\t\tassert.Equal(t, tagNames[tagIdxWithScene], tags[0].Name)\n\t\tassert.Equal(t, tagNames[tagIdx1WithScene], tags[1].Name)\n\n\t\ttags, err = tqb.FindByNames(ctx, names, true) // find tags by names ( 2 names nocase)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error finding tags: %s\", err.Error())\n\t\t}\n\t\tassert.Len(t, tags, 4) // tagIdxWithScene and tagIdxWithDupName , tagIdx1WithScene and tagIdx1WithDupName\n\t\tassert.Equal(t, tagNames[tagIdxWithScene], tags[0].Name)\n\t\tassert.Equal(t, tagNames[tagIdx1WithScene], tags[1].Name)\n\t\tassert.Equal(t, tagNames[tagIdx1WithDupName], tags[2].Name)\n\t\tassert.Equal(t, tagNames[tagIdxWithDupName], tags[3].Name)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQuerySort(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Tag\n\n\t\tsortBy := \"scenes_count\"\n\t\tdir := models.SortDirectionEnumDesc\n\t\tfindFilter := &models.FindFilterType{\n\t\t\tSort:      &sortBy,\n\t\t\tDirection: &dir,\n\t\t}\n\n\t\ttags := queryTags(ctx, t, sqb, nil, findFilter)\n\t\tassert := assert.New(t)\n\t\tassert.Equal(tagIDs[tagIdx2WithScene], tags[0].ID)\n\n\t\tsortBy = \"scene_markers_count\"\n\t\ttags = queryTags(ctx, t, sqb, nil, findFilter)\n\t\tassert.Equal(tagIDs[tagIdxWithPrimaryMarkers], tags[0].ID)\n\n\t\tsortBy = \"images_count\"\n\t\ttags = queryTags(ctx, t, sqb, nil, findFilter)\n\t\tassert.Equal(tagIDs[tagIdx1WithImage], tags[0].ID)\n\n\t\tsortBy = \"galleries_count\"\n\t\ttags = queryTags(ctx, t, sqb, nil, findFilter)\n\t\tassert.Equal(tagIDs[tagIdx1WithGallery], tags[0].ID)\n\n\t\tsortBy = \"performers_count\"\n\t\ttags = queryTags(ctx, t, sqb, nil, findFilter)\n\t\tassert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID)\n\n\t\tsortBy = \"studios_count\"\n\t\ttags = queryTags(ctx, t, sqb, nil, findFilter)\n\t\tassert.Equal(tagIDs[tagIdx2WithStudio], tags[0].ID)\n\n\t\tsortBy = \"movies_count\"\n\t\ttags = queryTags(ctx, t, sqb, nil, findFilter)\n\t\tassert.Equal(tagIDs[tagIdx1WithGroup], tags[0].ID)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryName(t *testing.T) {\n\tconst tagIdx = 1\n\ttagName := getSceneStringValue(tagIdx, \"Name\")\n\n\tnameCriterion := &models.StringCriterionInput{\n\t\tValue:    tagName,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\ttagFilter := &models.TagFilterType{\n\t\tName: nameCriterion,\n\t}\n\n\tverifyFn := func(ctx context.Context, tag *models.Tag) {\n\t\tverifyString(t, tag.Name, *nameCriterion)\n\t}\n\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n\n\tnameCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n\n\tnameCriterion.Modifier = models.CriterionModifierMatchesRegex\n\tnameCriterion.Value = \"tag_.*1_Name\"\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n\n\tnameCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n}\n\nfunc TestTagQueryAlias(t *testing.T) {\n\tconst tagIdx = 1\n\ttagName := getSceneStringValue(tagIdx, \"Alias\")\n\n\taliasCriterion := &models.StringCriterionInput{\n\t\tValue:    tagName,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\ttagFilter := &models.TagFilterType{\n\t\tAliases: aliasCriterion,\n\t}\n\n\tverifyFn := func(ctx context.Context, tag *models.Tag) {\n\t\taliases, err := db.Tag.GetAliases(ctx, tag.ID)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tags: %s\", err.Error())\n\t\t}\n\n\t\tvar alias string\n\t\tif len(aliases) > 0 {\n\t\t\talias = aliases[0]\n\t\t}\n\n\t\tverifyString(t, alias, *aliasCriterion)\n\t}\n\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierMatchesRegex\n\taliasCriterion.Value = \"tag_.*1_Alias\"\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierNotMatchesRegex\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierIsNull\n\taliasCriterion.Value = \"\"\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n\n\taliasCriterion.Modifier = models.CriterionModifierNotNull\n\tverifyTagQuery(t, tagFilter, nil, verifyFn)\n}\n\nfunc verifyTagQuery(t *testing.T, tagFilter *models.TagFilterType, findFilter *models.FindFilterType, verifyFn func(ctx context.Context, t *models.Tag)) {\n\twithTxn(func(ctx context.Context) error {\n\t\tsqb := db.Tag\n\n\t\ttags := queryTags(ctx, t, sqb, tagFilter, findFilter)\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyFn(ctx, tag)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc queryTags(ctx context.Context, t *testing.T, qb models.TagReader, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) []*models.Tag {\n\tt.Helper()\n\ttags, _, err := qb.Query(ctx, tagFilter, findFilter)\n\tif err != nil {\n\t\tt.Errorf(\"Error querying tags: %s\", err.Error())\n\t}\n\n\treturn tags\n}\n\nfunc tagsToIDs(i []*models.Tag) []int {\n\tret := make([]int, len(i))\n\tfor i, v := range i {\n\t\tret[i] = v.ID\n\t}\n\n\treturn ret\n}\n\nfunc TestTagQuery(t *testing.T) {\n\tvar (\n\t\tendpoint = tagStashID(tagIdxWithPerformer).Endpoint\n\t\tstashID  = tagStashID(tagIdxWithPerformer).StashID\n\t\tstashID2 = tagStashID(tagIdx1WithPerformer).StashID\n\t\tstashIDs = []*string{&stashID, &stashID2}\n\t)\n\n\ttests := []struct {\n\t\tname        string\n\t\tfindFilter  *models.FindFilterType\n\t\tfilter      *models.TagFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.TagFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashID:  &stashID,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithPerformer},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.TagFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashID:  &stashID,\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{tagIdxWithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"null stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.TagFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{tagIdxWithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null stash id with endpoint\",\n\t\t\tnil,\n\t\t\t&models.TagFilterType{\n\t\t\t\tStashIDEndpoint: &models.StashIDCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithPerformer},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.TagFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithPerformer, tagIdx1WithPerformer},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"exclude stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.TagFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tStashIDs: stashIDs,\n\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{tagIdxWithPerformer, tagIdx1WithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"null stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.TagFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{tagIdxWithPerformer, tagIdx1WithPerformer},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null stash ids with endpoint\",\n\t\t\tnil,\n\t\t\t&models.TagFilterType{\n\t\t\t\tStashIDsEndpoint: &models.StashIDsCriterionInput{\n\t\t\t\t\tEndpoint: &endpoint,\n\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithPerformer, tagIdx1WithPerformer},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\ttags, _, err := db.Tag.Query(ctx, tt.filter, tt.findFilter)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"PerformerStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := tagsToIDs(tags)\n\t\t\tinclude := indexesToIDs(tagIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(tagIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTagQueryIsMissingImage(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\tisMissing := \"image\"\n\t\ttagFilter := models.TagFilterType{\n\t\t\tIsMissing: &isMissing,\n\t\t}\n\n\t\tq := getTagStringValue(tagIdxWithCoverImage, \"name\")\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\ttags, _, err := qb.Query(ctx, &tagFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tag: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 0)\n\n\t\tfindFilter.Q = nil\n\t\ttags, _, err = qb.Query(ctx, &tagFilter, &findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tag: %s\", err.Error())\n\t\t}\n\n\t\t// ensure non of the ids equal the one with image\n\t\tfor _, tag := range tags {\n\t\t\tassert.NotEqual(t, tagIDs[tagIdxWithCoverImage], tag.ID)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQuerySceneCount(t *testing.T) {\n\tcountCriterion := models.IntCriterionInput{\n\t\tValue:    1,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyTagSceneCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagSceneCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyTagSceneCount(t, countCriterion)\n\n\tcountCriterion.Value = 0\n\tcountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyTagSceneCount(t, countCriterion)\n}\n\nfunc verifyTagSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\ttagFilter := models.TagFilterType{\n\t\t\tSceneCount: &sceneCountCriterion,\n\t\t}\n\n\t\ttags, _, err := qb.Query(ctx, &tagFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tag: %s\", err.Error())\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyInt(t, getTagSceneCount(tag.ID), sceneCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryMarkerCount(t *testing.T) {\n\tcountCriterion := models.IntCriterionInput{\n\t\tValue:    1,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyTagMarkerCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagMarkerCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyTagMarkerCount(t, countCriterion)\n\n\tcountCriterion.Value = 0\n\tcountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyTagMarkerCount(t, countCriterion)\n}\n\nfunc verifyTagMarkerCount(t *testing.T, markerCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\ttagFilter := models.TagFilterType{\n\t\t\tMarkerCount: &markerCountCriterion,\n\t\t}\n\n\t\ttags, _, err := qb.Query(ctx, &tagFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tag: %s\", err.Error())\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyInt(t, getTagMarkerCount(tag.ID), markerCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryImageCount(t *testing.T) {\n\tcountCriterion := models.IntCriterionInput{\n\t\tValue:    1,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyTagImageCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagImageCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyTagImageCount(t, countCriterion)\n\n\tcountCriterion.Value = 0\n\tcountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyTagImageCount(t, countCriterion)\n}\n\nfunc verifyTagImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\ttagFilter := models.TagFilterType{\n\t\t\tImageCount: &imageCountCriterion,\n\t\t}\n\n\t\ttags, _, err := qb.Query(ctx, &tagFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tag: %s\", err.Error())\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyInt(t, getTagImageCount(tag.ID), imageCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryGalleryCount(t *testing.T) {\n\tcountCriterion := models.IntCriterionInput{\n\t\tValue:    1,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyTagGalleryCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagGalleryCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyTagGalleryCount(t, countCriterion)\n\n\tcountCriterion.Value = 0\n\tcountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyTagGalleryCount(t, countCriterion)\n}\n\nfunc verifyTagGalleryCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\ttagFilter := models.TagFilterType{\n\t\t\tGalleryCount: &imageCountCriterion,\n\t\t}\n\n\t\ttags, _, err := qb.Query(ctx, &tagFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tag: %s\", err.Error())\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyInt(t, getTagGalleryCount(tag.ID), imageCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryPerformerCount(t *testing.T) {\n\tcountCriterion := models.IntCriterionInput{\n\t\tValue:    1,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyTagPerformerCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagPerformerCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyTagPerformerCount(t, countCriterion)\n\n\tcountCriterion.Value = 0\n\tcountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyTagPerformerCount(t, countCriterion)\n}\n\nfunc verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\ttagFilter := models.TagFilterType{\n\t\t\tPerformerCount: &imageCountCriterion,\n\t\t}\n\n\t\ttags, _, err := qb.Query(ctx, &tagFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tag: %s\", err.Error())\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyInt(t, getTagPerformerCount(tag.ID), imageCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryStudioCount(t *testing.T) {\n\tcountCriterion := models.IntCriterionInput{\n\t\tValue:    1,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyTagStudioCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagStudioCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyTagStudioCount(t, countCriterion)\n\n\tcountCriterion.Value = 0\n\tcountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyTagStudioCount(t, countCriterion)\n}\n\nfunc verifyTagStudioCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\ttagFilter := models.TagFilterType{\n\t\t\tStudioCount: &imageCountCriterion,\n\t\t}\n\n\t\ttags, _, err := qb.Query(ctx, &tagFilter, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error querying tag: %s\", err.Error())\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyInt(t, getTagStudioCount(tag.ID), imageCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryParentCount(t *testing.T) {\n\tcountCriterion := models.IntCriterionInput{\n\t\tValue:    1,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyTagParentCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagParentCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyTagParentCount(t, countCriterion)\n\n\tcountCriterion.Value = 0\n\tcountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyTagParentCount(t, countCriterion)\n}\n\nfunc verifyTagParentCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\ttagFilter := models.TagFilterType{\n\t\t\tParentCount: &sceneCountCriterion,\n\t\t}\n\n\t\ttags := queryTags(ctx, t, qb, &tagFilter, nil)\n\n\t\tif len(tags) == 0 {\n\t\t\tt.Error(\"Expected at least one tag\")\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyInt(t, getTagParentCount(tag.ID), sceneCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryChildCount(t *testing.T) {\n\tcountCriterion := models.IntCriterionInput{\n\t\tValue:    1,\n\t\tModifier: models.CriterionModifierEquals,\n\t}\n\n\tverifyTagChildCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierNotEquals\n\tverifyTagChildCount(t, countCriterion)\n\n\tcountCriterion.Modifier = models.CriterionModifierLessThan\n\tverifyTagChildCount(t, countCriterion)\n\n\tcountCriterion.Value = 0\n\tcountCriterion.Modifier = models.CriterionModifierGreaterThan\n\tverifyTagChildCount(t, countCriterion)\n}\n\nfunc verifyTagChildCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\ttagFilter := models.TagFilterType{\n\t\t\tChildCount: &sceneCountCriterion,\n\t\t}\n\n\t\ttags := queryTags(ctx, t, qb, &tagFilter, nil)\n\n\t\tif len(tags) == 0 {\n\t\t\tt.Error(\"Expected at least one tag\")\n\t\t}\n\n\t\tfor _, tag := range tags {\n\t\t\tverifyInt(t, getTagChildCount(tag.ID), sceneCountCriterion)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryParent(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tconst nameField = \"Name\"\n\t\tsqb := db.Tag\n\t\ttagCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithChildTag]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\ttagFilter := models.TagFilterType{\n\t\t\tParents: &tagCriterion,\n\t\t}\n\n\t\ttags := queryTags(ctx, t, sqb, &tagFilter, nil)\n\n\t\tassert.Len(t, tags, 1)\n\n\t\t// ensure id is correct\n\t\tassert.Equal(t, tagIDs[tagIdxWithParentTag], tags[0].ID)\n\n\t\ttagCriterion.Modifier = models.CriterionModifierExcludes\n\n\t\tq := getTagStringValue(tagIdxWithParentTag, nameField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 0)\n\n\t\tdepth := -1\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithGrandChild]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    &depth,\n\t\t}\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, nil)\n\t\tassert.Len(t, tags, 2)\n\n\t\tdepth = 1\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, nil)\n\t\tassert.Len(t, tags, 2)\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t}\n\t\tq = getTagStringValue(tagIdxWithGallery, nameField)\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tagIDs[tagIdxWithGallery], tags[0].ID)\n\n\t\tq = getTagStringValue(tagIdxWithParentTag, nameField)\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 0)\n\n\t\ttagCriterion.Modifier = models.CriterionModifierNotNull\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tagIDs[tagIdxWithParentTag], tags[0].ID)\n\n\t\tq = getTagStringValue(tagIdxWithGallery, nameField)\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagQueryChild(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tconst nameField = \"Name\"\n\n\t\tsqb := db.Tag\n\t\ttagCriterion := models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithParentTag]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t}\n\n\t\ttagFilter := models.TagFilterType{\n\t\t\tChildren: &tagCriterion,\n\t\t}\n\n\t\ttags := queryTags(ctx, t, sqb, &tagFilter, nil)\n\n\t\tassert.Len(t, tags, 1)\n\n\t\t// ensure id is correct\n\t\tassert.Equal(t, sceneIDs[tagIdxWithChildTag], tags[0].ID)\n\n\t\ttagCriterion.Modifier = models.CriterionModifierExcludes\n\n\t\tq := getTagStringValue(tagIdxWithChildTag, nameField)\n\t\tfindFilter := models.FindFilterType{\n\t\t\tQ: &q,\n\t\t}\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 0)\n\n\t\tdepth := -1\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tValue: []string{\n\t\t\t\tstrconv.Itoa(tagIDs[tagIdxWithGrandParent]),\n\t\t\t},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    &depth,\n\t\t}\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, nil)\n\t\tassert.Len(t, tags, 2)\n\n\t\tdepth = 1\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, nil)\n\t\tassert.Len(t, tags, 2)\n\n\t\ttagCriterion = models.HierarchicalMultiCriterionInput{\n\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t}\n\t\tq = getTagStringValue(tagIdxWithGallery, nameField)\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tagIDs[tagIdxWithGallery], tags[0].ID)\n\n\t\tq = getTagStringValue(tagIdxWithChildTag, nameField)\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 0)\n\n\t\ttagCriterion.Modifier = models.CriterionModifierNotNull\n\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tagIDs[tagIdxWithChildTag], tags[0].ID)\n\n\t\tq = getTagStringValue(tagIdxWithGallery, nameField)\n\t\ttags = queryTags(ctx, t, sqb, &tagFilter, &findFilter)\n\t\tassert.Len(t, tags, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagUpdateTagImage(t *testing.T) {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\n\t\t// create tag to test against\n\t\tconst name = \"TestTagUpdateTagImage\"\n\t\ttag := models.CreateTagInput{\n\t\t\tTag: &models.Tag{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t}\n\t\terr := qb.Create(ctx, &tag)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating tag: %s\", err.Error())\n\t\t}\n\n\t\treturn testUpdateImage(t, ctx, tag.ID, qb.UpdateImage, qb.GetImage)\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestTagUpdateAlias(t *testing.T) {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\n\t\t// create tag to test against\n\t\tconst name = \"TestTagUpdateAlias\"\n\t\ttag := models.CreateTagInput{\n\t\t\tTag: &models.Tag{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t}\n\t\terr := qb.Create(ctx, &tag)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating tag: %s\", err.Error())\n\t\t}\n\n\t\taliases := []string{\"updatedAlias1\", \"updatedAlias2\"}\n\t\terr = qb.UpdateAliases(ctx, tag.ID, aliases)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error updating tag aliases: %s\", err.Error())\n\t\t}\n\n\t\t// ensure aliases set\n\t\tstoredAliases, err := qb.GetAliases(ctx, tag.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error getting aliases: %s\", err.Error())\n\t\t}\n\t\tassert.Equal(t, aliases, storedAliases)\n\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestTagStashIDs(t *testing.T) {\n\tif err := withTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\n\t\t// create tag to test against\n\t\tconst name = \"TestTagStashIDs\"\n\t\ttag := models.CreateTagInput{\n\t\t\tTag: &models.Tag{\n\t\t\t\tName: name,\n\t\t\t},\n\t\t}\n\t\terr := qb.Create(ctx, &tag)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating tag: %s\", err.Error())\n\t\t}\n\n\t\ttestStashIDReaderWriter(ctx, t, qb, tag.ID)\n\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc TestTagFindByStashID(t *testing.T) {\n\twithTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\n\t\t// create tag to test against\n\t\tconst name = \"TestTagFindByStashID\"\n\t\tconst stashID = \"stashid\"\n\t\tconst endpoint = \"endpoint\"\n\t\ttag := models.CreateTagInput{\n\t\t\tTag: &models.Tag{\n\t\t\t\tName:     name,\n\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{{StashID: stashID, Endpoint: endpoint}}),\n\t\t\t},\n\t\t}\n\t\terr := qb.Create(ctx, &tag)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error creating tag: %s\", err.Error())\n\t\t}\n\n\t\t// find by stash ID\n\t\ttags, err := qb.FindByStashID(ctx, models.StashID{StashID: stashID, Endpoint: endpoint})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error finding by stash ID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 1)\n\t\tassert.Equal(t, tag.ID, tags[0].ID)\n\n\t\t// find by non-existent stash ID\n\t\ttags, err = qb.FindByStashID(ctx, models.StashID{StashID: \"nonexistent\", Endpoint: endpoint})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Error finding by stash ID: %s\", err.Error())\n\t\t}\n\n\t\tassert.Len(t, tags, 0)\n\n\t\treturn nil\n\t})\n}\n\nfunc TestTagMerge(t *testing.T) {\n\tassert := assert.New(t)\n\n\t// merge tests - perform these in a transaction that we'll rollback\n\tif err := withRollbackTxn(func(ctx context.Context) error {\n\t\tqb := db.Tag\n\t\tmqb := db.SceneMarker\n\n\t\t// try merging into same tag\n\t\terr := qb.Merge(ctx, []int{tagIDs[tagIdx1WithScene]}, tagIDs[tagIdx1WithScene])\n\t\tassert.NotNil(err)\n\n\t\t// merge everything into tagIdxWithScene\n\t\tsrcIdxs := []int{\n\t\t\ttagIdx1WithScene,\n\t\t\ttagIdx2WithScene,\n\t\t\ttagIdxWithPrimaryMarkers,\n\t\t\ttagIdxWithMarkers,\n\t\t\ttagIdxWithCoverImage,\n\t\t\ttagIdxWithImage,\n\t\t\ttagIdx1WithImage,\n\t\t\ttagIdx2WithImage,\n\t\t\ttagIdxWithPerformer,\n\t\t\ttagIdx1WithPerformer,\n\t\t\ttagIdx2WithPerformer,\n\t\t\ttagIdxWithStudio,\n\t\t\ttagIdx1WithStudio,\n\t\t\ttagIdx2WithStudio,\n\t\t\ttagIdxWithGallery,\n\t\t\ttagIdx1WithGallery,\n\t\t\ttagIdx2WithGallery,\n\t\t\ttagIdx1WithGroup,\n\t\t\ttagIdx2WithGroup,\n\t\t}\n\t\tvar srcIDs []int\n\t\tfor _, idx := range srcIdxs {\n\t\t\tsrcIDs = append(srcIDs, tagIDs[idx])\n\t\t}\n\n\t\tdestID := tagIDs[tagIdxWithScene]\n\t\tif err = qb.Merge(ctx, srcIDs, destID); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// ensure other tags are deleted\n\t\tfor _, tagId := range srcIDs {\n\t\t\tt, err := qb.Find(ctx, tagId)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tassert.Nil(t)\n\t\t}\n\n\t\t// ensure aliases are set on the destination\n\t\tdestAliases, err := qb.GetAliases(ctx, destID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, tagIdx := range srcIdxs {\n\t\t\tassert.Contains(destAliases, getTagStringValue(tagIdx, \"Name\"))\n\t\t}\n\n\t\t// ensure scene points to new tag\n\t\ts, err := db.Scene.Find(ctx, sceneIDs[sceneIdxWithTwoTags])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := s.LoadTagIDs(ctx, db.Scene); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tsceneTagIDs := s.TagIDs.List()\n\n\t\tassert.Contains(sceneTagIDs, destID)\n\n\t\t// ensure marker points to new tag\n\t\tmarker, err := mqb.Find(ctx, markerIDs[markerIdxWithTag])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tassert.Equal(destID, marker.PrimaryTagID)\n\n\t\tmarkerTagIDs, err := mqb.GetTagIDs(ctx, marker.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tassert.Contains(markerTagIDs, destID)\n\n\t\t// ensure image points to new tag\n\t\timageTagIDs, err := db.Image.GetTagIDs(ctx, imageIDs[imageIdxWithTwoTags])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tassert.Contains(imageTagIDs, destID)\n\n\t\tg, err := db.Gallery.Find(ctx, galleryIDs[galleryIdxWithTwoTags])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := g.LoadTagIDs(ctx, db.Gallery); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// ensure gallery points to new tag\n\t\tassert.Contains(g.TagIDs.List(), destID)\n\n\t\t// ensure performer points to new tag\n\t\tperformerTagIDs, err := db.Performer.GetTagIDs(ctx, performerIDs[performerIdxWithTwoTags])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tassert.Contains(performerTagIDs, destID)\n\n\t\t// ensure studio points to new tag\n\t\tstudioTagIDs, err := db.Studio.GetTagIDs(ctx, studioIDs[studioIdxWithTwoTags])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tassert.Contains(studioTagIDs, destID)\n\n\t\t// ensure group points to new tag\n\t\tgroup, err := db.Group.Find(ctx, groupIDs[groupIdxWithTwoTags])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := group.LoadTagIDs(ctx, db.Group); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tgroupTagIDs := group.TagIDs.List()\n\n\t\tassert.Contains(groupTagIDs, destID)\n\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Error(err.Error())\n\t}\n}\n\nfunc loadTagRelationships(ctx context.Context, expected models.Tag, actual *models.Tag) error {\n\tif expected.Aliases.Loaded() {\n\t\tif err := actual.LoadAliases(ctx, db.Tag); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.ParentIDs.Loaded() {\n\t\tif err := actual.LoadParentIDs(ctx, db.Tag); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.ChildIDs.Loaded() {\n\t\tif err := actual.LoadChildIDs(ctx, db.Tag); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif expected.StashIDs.Loaded() {\n\t\tif err := actual.LoadStashIDs(ctx, db.Tag); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc Test_TagStore_Create(t *testing.T) {\n\tvar (\n\t\tname          = \"name\"\n\t\tsortName      = \"sortName\"\n\t\tdescription   = \"description\"\n\t\tfavorite      = true\n\t\tignoreAutoTag = true\n\t\taliases       = []string{\"alias1\", \"alias2\"}\n\t\tendpoint1     = \"endpoint1\"\n\t\tendpoint2     = \"endpoint2\"\n\t\tstashID1      = \"stashid1\"\n\t\tstashID2      = \"stashid2\"\n\t\tcreatedAt     = epochTime\n\t\tupdatedAt     = epochTime\n\t)\n\n\ttests := []struct {\n\t\tname      string\n\t\tnewObject models.CreateTagInput\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.CreateTagInput{\n\t\t\t\tTag: &models.Tag{\n\t\t\t\t\tName:          name,\n\t\t\t\t\tSortName:      sortName,\n\t\t\t\t\tDescription:   description,\n\t\t\t\t\tFavorite:      favorite,\n\t\t\t\t\tIgnoreAutoTag: ignoreAutoTag,\n\t\t\t\t\tAliases:       models.NewRelatedStrings(aliases),\n\t\t\t\t\tParentIDs:     models.NewRelatedIDs([]int{tagIDs[tagIdxWithScene]}),\n\t\t\t\t\tChildIDs:      models.NewRelatedIDs([]int{tagIDs[tagIdx1WithScene]}),\n\t\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t\tCustomFields: testCustomFields,\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid parent id\",\n\t\t\tmodels.CreateTagInput{\n\t\t\t\tTag: &models.Tag{\n\t\t\t\t\tName:      name,\n\t\t\t\t\tParentIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid child id\",\n\t\t\tmodels.CreateTagInput{\n\t\t\t\tTag: &models.Tag{\n\t\t\t\t\tName:     name,\n\t\t\t\t\tChildIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Tag\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tp := tt.newObject\n\t\t\tif err := qb.Create(ctx, &p); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"TagStore.Create() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Zero(p.ID)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NotZero(p.ID)\n\n\t\t\tcopy := *tt.newObject.Tag\n\t\t\tcopy.ID = p.ID\n\n\t\t\t// load relationships\n\t\t\tif err := loadTagRelationships(ctx, copy, p.Tag); err != nil {\n\t\t\t\tt.Errorf(\"loadTagRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(copy, *p.Tag)\n\n\t\t\t// ensure can find the tag\n\t\t\tfound, err := qb.Find(ctx, p.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"TagStore.Find() error = %v\", err)\n\t\t\t}\n\n\t\t\tif !assert.NotNil(found) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadTagRelationships(ctx, copy, found); err != nil {\n\t\t\t\tt.Errorf(\"loadTagRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.Equal(copy, *found)\n\n\t\t\t// ensure custom fields are set\n\t\t\tcf, err := qb.GetCustomFields(ctx, p.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"TagStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(tt.newObject.CustomFields, cf)\n\n\t\t\treturn\n\t\t})\n\t}\n}\n\nfunc Test_TagStore_Update(t *testing.T) {\n\tvar (\n\t\tname          = \"name\"\n\t\tsortName      = \"sortName\"\n\t\tdescription   = \"description\"\n\t\tfavorite      = true\n\t\tignoreAutoTag = true\n\t\taliases       = []string{\"alias1\", \"alias2\"}\n\t\tendpoint1     = \"endpoint1\"\n\t\tendpoint2     = \"endpoint2\"\n\t\tstashID1      = \"stashid1\"\n\t\tstashID2      = \"stashid2\"\n\t\tcreatedAt     = epochTime\n\t\tupdatedAt     = epochTime\n\t)\n\n\ttests := []struct {\n\t\tname          string\n\t\tupdatedObject models.UpdateTagInput\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\t\"full\",\n\t\t\tmodels.UpdateTagInput{\n\t\t\t\tTag: &models.Tag{\n\t\t\t\t\tID:            tagIDs[tagIdxWithGallery],\n\t\t\t\t\tName:          name,\n\t\t\t\t\tSortName:      sortName,\n\t\t\t\t\tDescription:   description,\n\t\t\t\t\tFavorite:      favorite,\n\t\t\t\t\tIgnoreAutoTag: ignoreAutoTag,\n\t\t\t\t\tAliases:       models.NewRelatedStrings(aliases),\n\t\t\t\t\tParentIDs:     models.NewRelatedIDs([]int{tagIDs[tagIdxWithScene]}),\n\t\t\t\t\tChildIDs:      models.NewRelatedIDs([]int{tagIDs[tagIdx1WithScene]}),\n\t\t\t\t\tStashIDs: models.NewRelatedStashIDs([]models.StashID{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID1,\n\t\t\t\t\t\t\tEndpoint:  endpoint1,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tStashID:   stashID2,\n\t\t\t\t\t\t\tEndpoint:  endpoint2,\n\t\t\t\t\t\t\tUpdatedAt: epochTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t\tCreatedAt: createdAt,\n\t\t\t\t\tUpdatedAt: updatedAt,\n\t\t\t\t},\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{\n\t\t\t\t\t\t\"string\": \"updated\",\n\t\t\t\t\t\t\"int\":    int64(999),\n\t\t\t\t\t\t\"real\":   9.99,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\tmodels.UpdateTagInput{\n\t\t\t\tTag: &models.Tag{\n\t\t\t\t\tID:   tagIDs[tagIdxWithGallery],\n\t\t\t\t\tName: tagNames[tagIdxWithGallery],\n\t\t\t\t},\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\tmodels.UpdateTagInput{\n\t\t\t\tTag: &models.Tag{\n\t\t\t\t\tID:   tagIDs[tagIdxWithGallery],\n\t\t\t\t\tName: tagNames[tagIdxWithGallery],\n\t\t\t\t},\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid parent id\",\n\t\t\tmodels.UpdateTagInput{\n\t\t\t\tTag: &models.Tag{\n\t\t\t\t\tID:        tagIDs[tagIdxWithGallery],\n\t\t\t\t\tName:      tagNames[tagIdxWithGallery],\n\t\t\t\t\tParentIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"invalid child id\",\n\t\t\tmodels.UpdateTagInput{\n\t\t\t\tTag: &models.Tag{\n\t\t\t\t\tID:       tagIDs[tagIdxWithGallery],\n\t\t\t\t\tName:     tagNames[tagIdxWithGallery],\n\t\t\t\t\tChildIDs: models.NewRelatedIDs([]int{invalidID}),\n\t\t\t\t},\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tqb := db.Tag\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tp := tt.updatedObject\n\t\t\tif err := qb.Update(ctx, &p); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"TagStore.Update() error = %v, wantErr = %v\", err, tt.wantErr)\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts, err := qb.Find(ctx, tt.updatedObject.ID)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"TagStore.Find() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// load relationships\n\t\t\tif err := loadTagRelationships(ctx, *tt.updatedObject.Tag, s); err != nil {\n\t\t\t\tt.Errorf(\"loadTagRelationships() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.Equal(*tt.updatedObject.Tag, *s)\n\n\t\t\t// ensure custom fields are correct\n\t\t\tif tt.updatedObject.CustomFields.Full != nil {\n\t\t\t\tcf, err := qb.GetCustomFields(ctx, tt.updatedObject.ID)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"TagStore.GetCustomFields() error = %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tassert.Equal(tt.updatedObject.CustomFields.Full, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_TagStore_UpdatePartialCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tid       int\n\t\tpartial  models.TagPartial\n\t\texpected map[string]interface{} // nil to use the partial\n\t}{\n\t\t{\n\t\t\t\"set custom fields\",\n\t\t\ttagIDs[tagIdxWithGallery],\n\t\t\tmodels.TagPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: testCustomFields,\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"clear custom fields\",\n\t\t\ttagIDs[tagIdxWithGallery],\n\t\t\tmodels.TagPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tFull: map[string]interface{}{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"partial custom fields\",\n\t\t\ttagIDs[tagIdxWithGallery],\n\t\t\tmodels.TagPartial{\n\t\t\t\tCustomFields: models.CustomFieldsInput{\n\t\t\t\t\tPartial: map[string]interface{}{\n\t\t\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\t\t\"new_field\": \"new\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"int\":       int64(2),\n\t\t\t\t\"real\":      float64(1.7),\n\t\t\t\t\"string\":    \"bbb\",\n\t\t\t\t\"new_field\": \"new\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tqb := db.Tag\n\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t_, err := qb.UpdatePartial(ctx, tt.id, tt.partial)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"TagStore.UpdatePartial() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// ensure custom fields are correct\n\t\t\tcf, err := qb.GetCustomFields(ctx, tt.id)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"TagStore.GetCustomFields() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.expected == nil {\n\t\t\t\tassert.Equal(tt.partial.CustomFields.Full, cf)\n\t\t\t} else {\n\t\t\t\tassert.Equal(tt.expected, cf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTagQueryCustomFields(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      *models.TagFilterType\n\t\tincludeIdxs []int\n\t\texcludeIdxs []int\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\t\"equals\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\t\tValue:    []any{getTagStringValue(tagIdxWithGallery, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not equals\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getTagStringValue(tagIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotEquals,\n\t\t\t\t\t\tValue:    []any{getTagStringValue(tagIdxWithGallery, \"custom\")},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{tagIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"includes\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\t\t\t\tValue:    []any{getTagStringValue(tagIdxWithGallery, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"excludes\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getTagStringValue(tagIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierExcludes,\n\t\t\t\t\t\tValue:    []any{getTagStringValue(tagIdxWithGallery, \"custom\")[9:]},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{tagIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"regex\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*17_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid regex\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"not matches regex\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getTagStringValue(tagIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\".*17_custom\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{tagIdxWithGallery},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"invalid not matches regex\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotMatchesRegex,\n\t\t\t\t\t\tValue:    []any{\"[\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"null\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getTagStringValue(tagIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"not existing\",\n\t\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not null\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getTagStringValue(tagIdxWithGallery, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"string\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotNull,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdxWithGallery},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"between\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]int{tagIdx2WithScene},\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"not between\",\n\t\t\t&models.TagFilterType{\n\t\t\t\tName: &models.StringCriterionInput{\n\t\t\t\t\tValue:    getTagStringValue(tagIdx2WithScene, \"Name\"),\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t},\n\t\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t\t{\n\t\t\t\t\t\tField:    \"real\",\n\t\t\t\t\t\tModifier: models.CriterionModifierNotBetween,\n\t\t\t\t\t\tValue:    []any{0.15, 0.25},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnil,\n\t\t\t[]int{tagIdx2WithScene},\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\trunWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {\n\t\t\tassert := assert.New(t)\n\n\t\t\ttags, _, err := db.Tag.Query(ctx, tt.filter, nil)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"TagStore.Query() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tids := tagsToIDs(tags)\n\t\t\tinclude := indexesToIDs(tagIDs, tt.includeIdxs)\n\t\t\texclude := indexesToIDs(tagIDs, tt.excludeIdxs)\n\n\t\t\tfor _, i := range include {\n\t\t\t\tassert.Contains(ids, i)\n\t\t\t}\n\t\t\tfor _, e := range exclude {\n\t\t\t\tassert.NotContains(ids, e)\n\t\t\t}\n\t\t})\n\t}\n\n\t// Test combining text search (findFilter.Q) with custom field filters.\n\t// This verifies that positional args are bound in the correct order\n\t// when JOINs (from custom fields) and WHERE (from text search) both\n\t// have parameterized placeholders.\n\trunWithRollbackTxn(t, \"equals with text search\", func(t *testing.T, ctx context.Context) {\n\t\tassert := assert.New(t)\n\n\t\ttagName := getTagStringValue(tagIdxWithGallery, \"Name\")\n\t\tq := tagName\n\t\tfindFilter := &models.FindFilterType{Q: &q}\n\n\t\ttagFilter := &models.TagFilterType{\n\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t{\n\t\t\t\t\tField:    \"string\",\n\t\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t\t\tValue:    []any{getTagStringValue(tagIdxWithGallery, \"custom\")},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\ttags, _, err := db.Tag.Query(ctx, tagFilter, findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"TagStore.Query() error = %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tids := tagsToIDs(tags)\n\t\tassert.Contains(ids, tagIDs[tagIdxWithGallery])\n\t\tassert.Len(tags, 1)\n\t})\n\n\trunWithRollbackTxn(t, \"is_null with text search\", func(t *testing.T, ctx context.Context) {\n\t\tassert := assert.New(t)\n\n\t\ttagName := getTagStringValue(tagIdxWithGallery, \"Name\")\n\t\tq := tagName\n\t\tfindFilter := &models.FindFilterType{Q: &q}\n\n\t\ttagFilter := &models.TagFilterType{\n\t\t\tCustomFields: []models.CustomFieldCriterionInput{\n\t\t\t\t{\n\t\t\t\t\tField:    \"not existing\",\n\t\t\t\t\tModifier: models.CriterionModifierIsNull,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\ttags, _, err := db.Tag.Query(ctx, tagFilter, findFilter)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"TagStore.Query() error = %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tids := tagsToIDs(tags)\n\t\tassert.Contains(ids, tagIDs[tagIdxWithGallery])\n\t\tassert.Len(tags, 1)\n\t})\n}\n\n// TODO Destroy\n// TODO Find\n// TODO FindBySceneID\n// TODO FindBySceneMarkerID\n// TODO Count\n// TODO All\n// TODO AllSlim\n// TODO Query\n"
  },
  {
    "path": "pkg/sqlite/timestamp.go",
    "content": "package sqlite\n\nimport (\n\t\"database/sql/driver\"\n\t\"time\"\n)\n\nconst TimestampFormat = time.RFC3339\n\n// Timestamp represents a time stored in RFC3339 format.\ntype Timestamp struct {\n\tTimestamp time.Time\n}\n\n// Scan implements the Scanner interface.\nfunc (t *Timestamp) Scan(value interface{}) error {\n\tt.Timestamp = value.(time.Time)\n\treturn nil\n}\n\n// Value implements the driver Valuer interface.\nfunc (t Timestamp) Value() (driver.Value, error) {\n\treturn t.Timestamp.Format(TimestampFormat), nil\n}\n\n// UTCTimestamp stores a time in UTC.\n// TODO - Timestamp should use UTC by default\ntype UTCTimestamp struct {\n\tTimestamp\n}\n\n// Value implements the driver Valuer interface.\nfunc (t UTCTimestamp) Value() (driver.Value, error) {\n\treturn t.Timestamp.Timestamp.UTC().Format(TimestampFormat), nil\n}\n\n// NullTimestamp represents a nullable time stored in RFC3339 format.\ntype NullTimestamp struct {\n\tTimestamp time.Time\n\tValid     bool\n}\n\n// Scan implements the Scanner interface.\nfunc (t *NullTimestamp) Scan(value interface{}) error {\n\tvar ok bool\n\tt.Timestamp, ok = value.(time.Time)\n\tif !ok {\n\t\tt.Timestamp = time.Time{}\n\t\tt.Valid = false\n\t\treturn nil\n\t}\n\n\tt.Valid = true\n\treturn nil\n}\n\n// Value implements the driver Valuer interface.\nfunc (t NullTimestamp) Value() (driver.Value, error) {\n\tif !t.Valid {\n\t\treturn nil, nil\n\t}\n\n\treturn t.Timestamp.Format(TimestampFormat), nil\n}\n\nfunc (t NullTimestamp) TimePtr() *time.Time {\n\tif !t.Valid {\n\t\treturn nil\n\t}\n\n\ttimestamp := t.Timestamp\n\treturn &timestamp\n}\n\nfunc NullTimestampFromTimePtr(t *time.Time) NullTimestamp {\n\tif t == nil {\n\t\treturn NullTimestamp{Valid: false}\n\t}\n\treturn NullTimestamp{Timestamp: *t, Valid: true}\n}\n"
  },
  {
    "path": "pkg/sqlite/transaction.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"runtime/debug\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/mattn/go-sqlite3\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype key int\n\nconst (\n\ttxnKey key = iota + 1\n\tdbKey\n\twritableKey\n)\n\nfunc (db *Database) WithDatabase(ctx context.Context) (context.Context, error) {\n\t// if we are already in a transaction or have a database already, just use it\n\tif tx, _ := getDBReader(ctx); tx != nil {\n\t\treturn ctx, nil\n\t}\n\n\treturn context.WithValue(ctx, dbKey, db.readDB), nil\n}\n\nfunc (db *Database) Begin(ctx context.Context, writable bool) (context.Context, error) {\n\tif tx, _ := getTx(ctx); tx != nil {\n\t\t// log the stack trace so we can see\n\t\tlogger.Error(string(debug.Stack()))\n\n\t\treturn nil, fmt.Errorf(\"already in transaction\")\n\t}\n\n\tdbtx := db.readDB\n\tif writable {\n\t\tdbtx = db.writeDB\n\t}\n\n\ttx, err := dbtx.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"beginning transaction: %w\", err)\n\t}\n\n\tctx = context.WithValue(ctx, writableKey, writable)\n\n\treturn context.WithValue(ctx, txnKey, tx), nil\n}\n\nfunc (db *Database) Commit(ctx context.Context) error {\n\ttx, err := getTx(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer db.txnComplete(ctx)\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Database) Rollback(ctx context.Context) error {\n\ttx, err := getTx(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer db.txnComplete(ctx)\n\n\tif err := tx.Rollback(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (db *Database) txnComplete(ctx context.Context) {\n}\n\nfunc getTx(ctx context.Context) (*sqlx.Tx, error) {\n\ttx, ok := ctx.Value(txnKey).(*sqlx.Tx)\n\tif !ok || tx == nil {\n\t\treturn nil, fmt.Errorf(\"not in transaction\")\n\t}\n\treturn tx, nil\n}\n\nfunc getDBReader(ctx context.Context) (dbReader, error) {\n\t// get transaction first if present\n\ttx, ok := ctx.Value(txnKey).(*sqlx.Tx)\n\tif !ok || tx == nil {\n\t\t// try to get database if present\n\t\tdb, ok := ctx.Value(dbKey).(*sqlx.DB)\n\t\tif !ok || db == nil {\n\t\t\treturn nil, fmt.Errorf(\"not in transaction\")\n\t\t}\n\t\treturn db, nil\n\t}\n\treturn tx, nil\n}\n\nfunc (db *Database) IsLocked(err error) bool {\n\tvar sqliteError sqlite3.Error\n\tif errors.As(err, &sqliteError) {\n\t\treturn sqliteError.Code == sqlite3.ErrBusy\n\t}\n\treturn false\n}\n\nfunc (db *Database) Repository() models.Repository {\n\treturn models.Repository{\n\t\tTxnManager:     db,\n\t\tBlob:           db.Blobs,\n\t\tFile:           db.File,\n\t\tFolder:         db.Folder,\n\t\tGallery:        db.Gallery,\n\t\tGalleryChapter: db.GalleryChapter,\n\t\tImage:          db.Image,\n\t\tGroup:          db.Group,\n\t\tPerformer:      db.Performer,\n\t\tScene:          db.Scene,\n\t\tSceneMarker:    db.SceneMarker,\n\t\tStudio:         db.Studio,\n\t\tTag:            db.Tag,\n\t\tSavedFilter:    db.SavedFilter,\n\t}\n}\n"
  },
  {
    "path": "pkg/sqlite/transaction_test.go",
    "content": "//go:build integration\n// +build integration\n\npackage sqlite_test\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\n// this test is left commented out as it is not deterministic.\n// func TestConcurrentExclusiveTxn(t *testing.T) {\n// \tconst (\n// \t\tworkers    = 8\n// \t\tloops      = 100\n// \t\tinnerLoops = 10\n// \t\tsleepTime  = 2 * time.Millisecond\n// \t)\n// \tctx := context.Background()\n\n// \tvar wg sync.WaitGroup\n// \tfor k := 0; k < workers; k++ {\n// \t\twg.Add(1)\n// \t\tgo func(wk int) {\n// \t\t\tfor l := 0; l < loops; l++ {\n// \t\t\t\t// change this to WithReadTxn to see locked database error\n// \t\t\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n// \t\t\t\t\tfor ll := 0; ll < innerLoops; ll++ {\n// \t\t\t\t\t\tscene := &models.Scene{\n// \t\t\t\t\t\t\tTitle: \"test\",\n// \t\t\t\t\t\t}\n\n// \t\t\t\t\t\tif err := db.Scene.Create(ctx, scene, nil); err != nil {\n// \t\t\t\t\t\t\treturn err\n// \t\t\t\t\t\t}\n\n// \t\t\t\t\t\tif err := db.Scene.Destroy(ctx, scene.ID); err != nil {\n// \t\t\t\t\t\t\treturn err\n// \t\t\t\t\t\t}\n// \t\t\t\t\t}\n// \t\t\t\t\ttime.Sleep(sleepTime)\n\n// \t\t\t\t\treturn nil\n// \t\t\t\t}); err != nil {\n// \t\t\t\t\tt.Errorf(\"worker %d loop %d: %v\", wk, l, err)\n// \t\t\t\t}\n// \t\t\t}\n\n// \t\t\twg.Done()\n// \t\t}(k)\n// \t}\n\n// \twg.Wait()\n// }\n\nfunc signalOtherThread(c chan struct{}) error {\n\tselect {\n\tcase c <- struct{}{}:\n\t\treturn nil\n\tcase <-time.After(10 * time.Second):\n\t\treturn errors.New(\"timed out signalling other thread\")\n\t}\n}\n\nfunc waitForOtherThread(c chan struct{}) error {\n\tselect {\n\tcase <-c:\n\t\treturn nil\n\tcase <-time.After(10 * time.Second):\n\t\treturn errors.New(\"timed out waiting for other thread\")\n\t}\n}\n\n// this test is left commented as it's no longer possible to write to the database\n// with a read-only transaction.\n\n// func TestConcurrentReadTxn(t *testing.T) {\n// \tvar wg sync.WaitGroup\n// \tctx := context.Background()\n// \tc := make(chan struct{})\n\n// \t// first thread\n// \twg.Add(2)\n// \tgo func() {\n// \t\tdefer wg.Done()\n// \t\tif err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error {\n// \t\t\tscene := &models.Scene{\n// \t\t\t\tTitle: \"test\",\n// \t\t\t}\n\n// \t\t\tif err := db.Scene.Create(ctx, scene, nil); err != nil {\n// \t\t\t\treturn err\n// \t\t\t}\n\n// \t\t\t// wait for other thread to start\n// \t\t\tif err := signalOtherThread(c); err != nil {\n// \t\t\t\treturn err\n// \t\t\t}\n// \t\t\tif err := waitForOtherThread(c); err != nil {\n// \t\t\t\treturn err\n// \t\t\t}\n\n// \t\t\tif err := db.Scene.Destroy(ctx, scene.ID); err != nil {\n// \t\t\t\treturn err\n// \t\t\t}\n\n// \t\t\treturn nil\n// \t\t}); err != nil {\n// \t\t\tt.Errorf(\"unexpected error in first thread: %v\", err)\n// \t\t}\n// \t}()\n\n// \t// second thread\n// \tgo func() {\n// \t\tdefer wg.Done()\n// \t\t_ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error {\n// \t\t\t// wait for first thread\n// \t\t\tif err := waitForOtherThread(c); err != nil {\n// \t\t\t\tt.Errorf(err.Error())\n// \t\t\t\treturn err\n// \t\t\t}\n\n// \t\t\tdefer func() {\n// \t\t\t\tif err := signalOtherThread(c); err != nil {\n// \t\t\t\t\tt.Errorf(err.Error())\n// \t\t\t\t}\n// \t\t\t}()\n\n// \t\t\tscene := &models.Scene{\n// \t\t\t\tTitle: \"test\",\n// \t\t\t}\n\n// \t\t\t// expect error when we try to do this, as the other thread has already\n// \t\t\t// modified this table\n// \t\t\t// this takes time to fail, so we need to wait for it\n// \t\t\tif err := db.Scene.Create(ctx, scene, nil); err != nil {\n// \t\t\t\tif !db.IsLocked(err) {\n// \t\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n// \t\t\t\t}\n// \t\t\t\treturn err\n// \t\t\t} else {\n// \t\t\t\tt.Errorf(\"expected locked error in second thread\")\n// \t\t\t}\n\n// \t\t\treturn nil\n// \t\t})\n// \t}()\n\n// \twg.Wait()\n// }\n\nfunc TestConcurrentExclusiveAndReadTxn(t *testing.T) {\n\tvar wg sync.WaitGroup\n\tctx := context.Background()\n\tc := make(chan struct{})\n\n\t// first thread\n\twg.Add(2)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n\t\t\tscene := &models.Scene{\n\t\t\t\tTitle: \"test\",\n\t\t\t}\n\n\t\t\tif err := db.Scene.Create(ctx, scene, nil); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// wait for other thread to start\n\t\t\tif err := signalOtherThread(c); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif err := waitForOtherThread(c); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := db.Scene.Destroy(ctx, scene.ID); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tt.Errorf(\"unexpected error in first thread: %v\", err)\n\t\t}\n\t}()\n\n\t// second thread\n\tgo func() {\n\t\tdefer wg.Done()\n\t\t_ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error {\n\t\t\t// wait for first thread\n\t\t\tif err := waitForOtherThread(c); err != nil {\n\t\t\t\tt.Error(err.Error())\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tdefer func() {\n\t\t\t\tif err := signalOtherThread(c); err != nil {\n\t\t\t\t\tt.Error(err.Error())\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\tif _, err := db.Scene.Find(ctx, sceneIDs[sceneIdx1WithPerformer]); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\t}()\n\n\twg.Wait()\n}\n\n// this test is left commented out as it is not deterministic.\n// func TestConcurrentExclusiveAndReadTxns(t *testing.T) {\n// \tconst (\n// \t\twriteWorkers = 4\n// \t\treadWorkers  = 4\n// \t\tloops        = 200\n// \t\tinnerLoops   = 10\n// \t\tsleepTime  = 1 * time.Millisecond\n// \t)\n// \tctx := context.Background()\n\n// \tvar wg sync.WaitGroup\n// \tfor k := 0; k < writeWorkers; k++ {\n// \t\twg.Add(1)\n// \t\tgo func(wk int) {\n// \t\t\tfor l := 0; l < loops; l++ {\n// \t\t\t\tif err := txn.WithTxn(ctx, db, func(ctx context.Context) error {\n// \t\t\t\t\tfor ll := 0; ll < innerLoops; ll++ {\n// \t\t\t\t\t\tscene := &models.Scene{\n// \t\t\t\t\t\t\tTitle: \"test\",\n// \t\t\t\t\t\t}\n\n// \t\t\t\t\t\tif err := db.Scene.Create(ctx, scene, nil); err != nil {\n// \t\t\t\t\t\t\treturn err\n// \t\t\t\t\t\t}\n\n// \t\t\t\t\t\tif err := db.Scene.Destroy(ctx, scene.ID); err != nil {\n// \t\t\t\t\t\t\treturn err\n// \t\t\t\t\t\t}\n// \t\t\t\t\t}\n// \t\t\t\t\ttime.Sleep(sleepTime)\n\n// \t\t\t\t\treturn nil\n// \t\t\t\t}); err != nil {\n// \t\t\t\t\tt.Errorf(\"write worker %d loop %d: %v\", wk, l, err)\n// \t\t\t\t}\n// \t\t\t}\n\n// \t\t\twg.Done()\n// \t\t}(k)\n// \t}\n\n// \tfor k := 0; k < readWorkers; k++ {\n// \t\twg.Add(1)\n// \t\tgo func(wk int) {\n// \t\t\tfor l := 0; l < loops; l++ {\n// \t\t\t\tif err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error {\n// \t\t\t\t\tfor ll := 0; ll < innerLoops; ll++ {\n// \t\t\t\t\t\tif _, err := db.Scene.Find(ctx, sceneIDs[ll%totalScenes]); err != nil {\n// \t\t\t\t\t\t\treturn err\n// \t\t\t\t\t\t}\n// \t\t\t\t\t}\n// \t\t\t\t\ttime.Sleep(sleepTime)\n\n// \t\t\t\t\treturn nil\n// \t\t\t\t}); err != nil {\n// \t\t\t\t\tt.Errorf(\"read worker %d loop %d: %v\", wk, l, err)\n// \t\t\t\t}\n// \t\t\t}\n\n// \t\t\twg.Done()\n// \t\t}(k)\n// \t}\n\n// \twg.Wait()\n// }\n"
  },
  {
    "path": "pkg/sqlite/tx.go",
    "content": "package sqlite\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/stashapp/stash/pkg/logger\"\n)\n\nconst (\n\tslowLogTime = time.Millisecond * 200\n)\n\ntype dbReader interface {\n\tGet(dest interface{}, query string, args ...interface{}) error\n\tGetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error\n\tSelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error\n\tQueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error)\n}\n\ntype stmt struct {\n\t*sql.Stmt\n\tquery string\n}\n\nfunc logSQL(start time.Time, query string, args ...interface{}) {\n\tsince := time.Since(start)\n\tif since >= slowLogTime {\n\t\tlogger.Debugf(\"SLOW SQL [%v]: %s, args: %v\", since, query, args)\n\t} else {\n\t\tlogger.Tracef(\"SQL [%v]: %s, args: %v\", since, query, args)\n\t}\n}\n\ntype dbWrapperType struct{}\n\nvar dbWrapper = dbWrapperType{}\n\nfunc sqlError(err error, sql string, args ...interface{}) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\treturn fmt.Errorf(\"error executing `%s` [%v]: %w\", sql, args, err)\n}\n\nfunc (*dbWrapperType) Get(ctx context.Context, dest interface{}, query string, args ...interface{}) error {\n\ttx, err := getDBReader(ctx)\n\tif err != nil {\n\t\treturn sqlError(err, query, args...)\n\t}\n\n\tstart := time.Now()\n\terr = tx.GetContext(ctx, dest, query, args...)\n\tlogSQL(start, query, args...)\n\n\treturn sqlError(err, query, args...)\n}\n\nfunc (*dbWrapperType) Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error {\n\ttx, err := getDBReader(ctx)\n\tif err != nil {\n\t\treturn sqlError(err, query, args...)\n\t}\n\n\tstart := time.Now()\n\terr = tx.SelectContext(ctx, dest, query, args...)\n\tlogSQL(start, query, args...)\n\n\treturn sqlError(err, query, args...)\n}\n\nfunc (*dbWrapperType) Queryx(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) {\n\ttx, err := getDBReader(ctx)\n\tif err != nil {\n\t\treturn nil, sqlError(err, query, args...)\n\t}\n\n\tstart := time.Now()\n\tret, err := tx.QueryxContext(ctx, query, args...)\n\tlogSQL(start, query, args...)\n\n\treturn ret, sqlError(err, query, args...)\n}\n\nfunc (*dbWrapperType) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) {\n\treturn dbWrapper.Queryx(ctx, query, args...)\n}\n\nfunc (*dbWrapperType) NamedExec(ctx context.Context, query string, arg interface{}) (sql.Result, error) {\n\ttx, err := getTx(ctx)\n\tif err != nil {\n\t\treturn nil, sqlError(err, query, arg)\n\t}\n\n\tstart := time.Now()\n\tret, err := tx.NamedExecContext(ctx, query, arg)\n\tlogSQL(start, query, arg)\n\n\treturn ret, sqlError(err, query, arg)\n}\n\nfunc (*dbWrapperType) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {\n\ttx, err := getTx(ctx)\n\tif err != nil {\n\t\treturn nil, sqlError(err, query, args...)\n\t}\n\n\tstart := time.Now()\n\tret, err := tx.ExecContext(ctx, query, args...)\n\tlogSQL(start, query, args...)\n\n\treturn ret, sqlError(err, query, args...)\n}\n\n// Prepare creates a prepared statement.\nfunc (*dbWrapperType) Prepare(ctx context.Context, query string, args ...interface{}) (*stmt, error) {\n\ttx, err := getTx(ctx)\n\tif err != nil {\n\t\treturn nil, sqlError(err, query, args...)\n\t}\n\n\t// nolint:sqlclosecheck\n\tret, err := tx.PrepareContext(ctx, query)\n\tif err != nil {\n\t\treturn nil, sqlError(err, query, args...)\n\t}\n\n\treturn &stmt{\n\t\tquery: query,\n\t\tStmt:  ret,\n\t}, nil\n}\n\nfunc (*dbWrapperType) ExecStmt(ctx context.Context, stmt *stmt, args ...interface{}) (sql.Result, error) {\n\t_, err := getTx(ctx)\n\tif err != nil {\n\t\treturn nil, sqlError(err, stmt.query, args...)\n\t}\n\n\tstart := time.Now()\n\tret, err := stmt.ExecContext(ctx, args...)\n\tlogSQL(start, stmt.query, args...)\n\n\treturn ret, sqlError(err, stmt.query, args...)\n}\n"
  },
  {
    "path": "pkg/sqlite/values.go",
    "content": "package sqlite\n\nimport (\n\t\"gopkg.in/guregu/null.v4\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\n// null package does not provide methods to convert null.Int to int pointer\nfunc intFromPtr(i *int) null.Int {\n\tif i == nil {\n\t\treturn null.NewInt(0, false)\n\t}\n\n\treturn null.IntFrom(int64(*i))\n}\n\nfunc nullIntPtr(i null.Int) *int {\n\tif !i.Valid {\n\t\treturn nil\n\t}\n\n\tv := int(i.Int64)\n\treturn &v\n}\n\nfunc nullFloatPtr(i null.Float) *float64 {\n\tif !i.Valid {\n\t\treturn nil\n\t}\n\n\tv := float64(i.Float64)\n\treturn &v\n}\n\nfunc nullIntFolderIDPtr(i null.Int) *models.FolderID {\n\tif !i.Valid {\n\t\treturn nil\n\t}\n\n\tv := models.FolderID(i.Int64)\n\n\treturn &v\n}\n\nfunc nullIntFileIDPtr(i null.Int) *models.FileID {\n\tif !i.Valid {\n\t\treturn nil\n\t}\n\n\tv := models.FileID(i.Int64)\n\n\treturn &v\n}\n\nfunc nullIntFromFileIDPtr(i *models.FileID) null.Int {\n\tif i == nil {\n\t\treturn null.NewInt(0, false)\n\t}\n\n\treturn null.IntFrom(int64(*i))\n}\n\nfunc nullIntFromFolderIDPtr(i *models.FolderID) null.Int {\n\tif i == nil {\n\t\treturn null.NewInt(0, false)\n\t}\n\n\treturn null.IntFrom(int64(*i))\n}\n"
  },
  {
    "path": "pkg/stashbox/client.go",
    "content": "// Package stashbox provides a client interface to a stash-box server instance.\npackage stashbox\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"regexp\"\n\n\t\"github.com/Yamashou/gqlgenc/clientv2\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n\t\"github.com/stashapp/stash/pkg/stashbox/graphql\"\n\n\t\"golang.org/x/time/rate\"\n)\n\n// DefaultMaxRequestsPerMinute is the default maximum number of requests per minute.\nconst DefaultMaxRequestsPerMinute = 240\n\n// Client represents the client interface to a stash-box server instance.\ntype Client struct {\n\tclient     *graphql.Client\n\thttpClient *http.Client\n\tbox        models.StashBox\n\n\tmaxRequestsPerMinute int\n\n\t// tag patterns to be excluded\n\texcludeTagRE []*regexp.Regexp\n}\n\ntype ClientOption func(*Client)\n\nfunc ExcludeTagPatterns(patterns []string) ClientOption {\n\treturn func(c *Client) {\n\t\tc.excludeTagRE = scraper.CompileExclusionRegexps(patterns)\n\t}\n}\n\nfunc MaxRequestsPerMinute(n int) ClientOption {\n\treturn func(c *Client) {\n\t\tif n > 0 {\n\t\t\tc.maxRequestsPerMinute = n\n\t\t}\n\t}\n}\n\nfunc setApiKeyHeader(apiKey string) clientv2.RequestInterceptor {\n\treturn func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error {\n\t\treq.Header.Set(\"ApiKey\", apiKey)\n\t\treturn next(ctx, req, gqlInfo, res)\n\t}\n}\n\nfunc rateLimit(n int) clientv2.RequestInterceptor {\n\tperSec := float64(n) / 60\n\tlimiter := rate.NewLimiter(rate.Limit(perSec), 1)\n\n\treturn func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error {\n\t\tif err := limiter.Wait(ctx); err != nil {\n\t\t\t// should only happen if the context is canceled\n\t\t\treturn err\n\t\t}\n\n\t\treturn next(ctx, req, gqlInfo, res)\n\t}\n}\n\n// NewClient returns a new instance of a stash-box client.\nfunc NewClient(box models.StashBox, options ...ClientOption) *Client {\n\tret := &Client{\n\t\tbox:                  box,\n\t\tmaxRequestsPerMinute: DefaultMaxRequestsPerMinute,\n\t\thttpClient:           http.DefaultClient,\n\t}\n\n\tif box.MaxRequestsPerMinute > 0 {\n\t\tret.maxRequestsPerMinute = box.MaxRequestsPerMinute\n\t}\n\n\tfor _, option := range options {\n\t\toption(ret)\n\t}\n\n\tauthHeader := setApiKeyHeader(box.APIKey)\n\tlimitRequests := rateLimit(ret.maxRequestsPerMinute)\n\n\tclient := &graphql.Client{\n\t\tClient: clientv2.NewClient(ret.httpClient, box.Endpoint, nil, authHeader, limitRequests),\n\t}\n\n\tret.client = client\n\n\treturn ret\n}\n\nfunc (c Client) GetUser(ctx context.Context) (*graphql.Me, error) {\n\treturn c.client.Me(ctx)\n}\n"
  },
  {
    "path": "pkg/stashbox/draft.go",
    "content": "package stashbox\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\n\t\"github.com/Yamashou/gqlgenc/clientv2\"\n\t\"github.com/Yamashou/gqlgenc/graphqljson\"\n)\n\nfunc (c *Client) submitDraft(ctx context.Context, query string, input interface{}, image io.Reader, ret interface{}) error {\n\tvars := map[string]interface{}{\n\t\t\"input\": input,\n\t}\n\n\tr := &clientv2.Request{\n\t\tQuery:         query,\n\t\tVariables:     vars,\n\t\tOperationName: \"\",\n\t}\n\n\trequestBody, err := json.Marshal(r)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"encode: %w\", err)\n\t}\n\n\tbody := &bytes.Buffer{}\n\twriter := multipart.NewWriter(body)\n\tif err := writer.WriteField(\"operations\", string(requestBody)); err != nil {\n\t\treturn err\n\t}\n\n\tif image != nil {\n\t\tif err := writer.WriteField(\"map\", \"{ \\\"0\\\": [\\\"variables.input.image\\\"] }\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tpart, _ := writer.CreateFormFile(\"0\", \"draft\")\n\t\tif _, err := io.Copy(part, image); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else if err := writer.WriteField(\"map\", \"{}\"); err != nil {\n\t\treturn err\n\t}\n\n\twriter.Close()\n\n\treq, _ := http.NewRequestWithContext(ctx, \"POST\", c.box.Endpoint, body)\n\treq.Header.Add(\"Content-Type\", writer.FormDataContentType())\n\treq.Header.Set(\"ApiKey\", c.box.APIKey)\n\n\thttpClient := c.client.Client.Client\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tresponseBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttype response struct {\n\t\tData   json.RawMessage `json:\"data\"`\n\t\tErrors json.RawMessage `json:\"errors\"`\n\t}\n\n\tvar respGQL response\n\n\tif err := json.Unmarshal(responseBytes, &respGQL); err != nil {\n\t\treturn fmt.Errorf(\"failed to decode data %s: %w\", string(responseBytes), err)\n\t}\n\n\tif len(respGQL.Errors) > 0 {\n\t\t// try to parse standard graphql error\n\t\terrors := &clientv2.GqlErrorList{}\n\t\tif e := json.Unmarshal(responseBytes, errors); e != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse graphql errors. Response content %s - %w \", string(responseBytes), e)\n\t\t}\n\n\t\treturn errors\n\t}\n\n\tif err := graphqljson.UnmarshalData(respGQL.Data, ret); err != nil {\n\t\treturn err\n\t}\n\n\treturn err\n}\n\n// we can't currently use this due to https://github.com/Yamashou/gqlgenc/issues/109\n// func uploadImage(image io.Reader) client.HTTPRequestOption {\n// \treturn func(req *http.Request) {\n// \t\tif image == nil {\n// \t\t\t// return without changing anything\n// \t\t\treturn\n// \t\t}\n\n// \t\t// we can't handle errors in here, so if one happens, just return\n// \t\t// without changing anything.\n\n// \t\t// repackage the request to include the image\n// \t\tbodyBytes, err := ioutil.ReadAll(req.Body)\n// \t\tif err != nil {\n// \t\t\treturn\n// \t\t}\n\n// \t\tnewBody := &bytes.Buffer{}\n// \t\twriter := multipart.NewWriter(newBody)\n// \t\t_ = writer.WriteField(\"operations\", string(bodyBytes))\n\n// \t\tif err := writer.WriteField(\"map\", \"{ \\\"0\\\": [\\\"variables.input.image\\\"] }\"); err != nil {\n// \t\t\treturn\n// \t\t}\n// \t\tpart, _ := writer.CreateFormFile(\"0\", \"draft\")\n// \t\tif _, err := io.Copy(part, image); err != nil {\n// \t\t\treturn\n// \t\t}\n\n// \t\twriter.Close()\n\n// \t\t// now set the request body to this new body\n// \t\treq.Body = io.NopCloser(newBody)\n// \t\treq.ContentLength = int64(newBody.Len())\n// \t\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n// \t}\n// }\n"
  },
  {
    "path": "pkg/stashbox/graphql/generated_client.go",
    "content": "// Code generated by github.com/Yamashou/gqlgenc, DO NOT EDIT.\n\npackage graphql\n\nimport (\n\t\"context\"\n\n\t\"github.com/Yamashou/gqlgenc/clientv2\"\n)\n\ntype StashBoxGraphQLClient interface {\n\tFindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesBySceneFingerprints, error)\n\tSearchScene(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchScene, error)\n\tSearchPerformer(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchPerformer, error)\n\tFindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error)\n\tFindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error)\n\tFindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error)\n\tFindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error)\n\tQueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error)\n\tSubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error)\n\tMe(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error)\n\tSubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error)\n\tSubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitPerformerDraft, error)\n}\n\ntype Client struct {\n\tClient *clientv2.Client\n}\n\nfunc NewClient(cli clientv2.HttpClient, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient {\n\treturn &Client{Client: clientv2.NewClient(cli, baseURL, options, interceptors...)}\n}\n\ntype URLFragment struct {\n\tURL  string \"json:\\\"url\\\" graphql:\\\"url\\\"\"\n\tType string \"json:\\\"type\\\" graphql:\\\"type\\\"\"\n}\n\nfunc (t *URLFragment) GetURL() string {\n\tif t == nil {\n\t\tt = &URLFragment{}\n\t}\n\treturn t.URL\n}\nfunc (t *URLFragment) GetType() string {\n\tif t == nil {\n\t\tt = &URLFragment{}\n\t}\n\treturn t.Type\n}\n\ntype ImageFragment struct {\n\tID     string \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tURL    string \"json:\\\"url\\\" graphql:\\\"url\\\"\"\n\tWidth  int    \"json:\\\"width\\\" graphql:\\\"width\\\"\"\n\tHeight int    \"json:\\\"height\\\" graphql:\\\"height\\\"\"\n}\n\nfunc (t *ImageFragment) GetID() string {\n\tif t == nil {\n\t\tt = &ImageFragment{}\n\t}\n\treturn t.ID\n}\nfunc (t *ImageFragment) GetURL() string {\n\tif t == nil {\n\t\tt = &ImageFragment{}\n\t}\n\treturn t.URL\n}\nfunc (t *ImageFragment) GetWidth() int {\n\tif t == nil {\n\t\tt = &ImageFragment{}\n\t}\n\treturn t.Width\n}\nfunc (t *ImageFragment) GetHeight() int {\n\tif t == nil {\n\t\tt = &ImageFragment{}\n\t}\n\treturn t.Height\n}\n\ntype StudioFragment struct {\n\tName    string                 \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n\tID      string                 \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tAliases []string               \"json:\\\"aliases\\\" graphql:\\\"aliases\\\"\"\n\tUrls    []*URLFragment         \"json:\\\"urls\\\" graphql:\\\"urls\\\"\"\n\tParent  *StudioFragment_Parent \"json:\\\"parent,omitempty\\\" graphql:\\\"parent\\\"\"\n\tImages  []*ImageFragment       \"json:\\\"images\\\" graphql:\\\"images\\\"\"\n}\n\nfunc (t *StudioFragment) GetName() string {\n\tif t == nil {\n\t\tt = &StudioFragment{}\n\t}\n\treturn t.Name\n}\nfunc (t *StudioFragment) GetID() string {\n\tif t == nil {\n\t\tt = &StudioFragment{}\n\t}\n\treturn t.ID\n}\nfunc (t *StudioFragment) GetAliases() []string {\n\tif t == nil {\n\t\tt = &StudioFragment{}\n\t}\n\treturn t.Aliases\n}\nfunc (t *StudioFragment) GetUrls() []*URLFragment {\n\tif t == nil {\n\t\tt = &StudioFragment{}\n\t}\n\treturn t.Urls\n}\nfunc (t *StudioFragment) GetParent() *StudioFragment_Parent {\n\tif t == nil {\n\t\tt = &StudioFragment{}\n\t}\n\treturn t.Parent\n}\nfunc (t *StudioFragment) GetImages() []*ImageFragment {\n\tif t == nil {\n\t\tt = &StudioFragment{}\n\t}\n\treturn t.Images\n}\n\ntype TagFragment struct {\n\tName        string                \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n\tID          string                \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tDescription *string               \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n\tAliases     []string              \"json:\\\"aliases\\\" graphql:\\\"aliases\\\"\"\n\tCategory    *TagFragment_Category \"json:\\\"category,omitempty\\\" graphql:\\\"category\\\"\"\n}\n\nfunc (t *TagFragment) GetName() string {\n\tif t == nil {\n\t\tt = &TagFragment{}\n\t}\n\treturn t.Name\n}\nfunc (t *TagFragment) GetID() string {\n\tif t == nil {\n\t\tt = &TagFragment{}\n\t}\n\treturn t.ID\n}\nfunc (t *TagFragment) GetDescription() *string {\n\tif t == nil {\n\t\tt = &TagFragment{}\n\t}\n\treturn t.Description\n}\nfunc (t *TagFragment) GetAliases() []string {\n\tif t == nil {\n\t\tt = &TagFragment{}\n\t}\n\treturn t.Aliases\n}\nfunc (t *TagFragment) GetCategory() *TagFragment_Category {\n\tif t == nil {\n\t\tt = &TagFragment{}\n\t}\n\treturn t.Category\n}\n\ntype MeasurementsFragment struct {\n\tBandSize *int    \"json:\\\"band_size,omitempty\\\" graphql:\\\"band_size\\\"\"\n\tCupSize  *string \"json:\\\"cup_size,omitempty\\\" graphql:\\\"cup_size\\\"\"\n\tWaist    *int    \"json:\\\"waist,omitempty\\\" graphql:\\\"waist\\\"\"\n\tHip      *int    \"json:\\\"hip,omitempty\\\" graphql:\\\"hip\\\"\"\n}\n\nfunc (t *MeasurementsFragment) GetBandSize() *int {\n\tif t == nil {\n\t\tt = &MeasurementsFragment{}\n\t}\n\treturn t.BandSize\n}\nfunc (t *MeasurementsFragment) GetCupSize() *string {\n\tif t == nil {\n\t\tt = &MeasurementsFragment{}\n\t}\n\treturn t.CupSize\n}\nfunc (t *MeasurementsFragment) GetWaist() *int {\n\tif t == nil {\n\t\tt = &MeasurementsFragment{}\n\t}\n\treturn t.Waist\n}\nfunc (t *MeasurementsFragment) GetHip() *int {\n\tif t == nil {\n\t\tt = &MeasurementsFragment{}\n\t}\n\treturn t.Hip\n}\n\ntype BodyModificationFragment struct {\n\tLocation    string  \"json:\\\"location\\\" graphql:\\\"location\\\"\"\n\tDescription *string \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n}\n\nfunc (t *BodyModificationFragment) GetLocation() string {\n\tif t == nil {\n\t\tt = &BodyModificationFragment{}\n\t}\n\treturn t.Location\n}\nfunc (t *BodyModificationFragment) GetDescription() *string {\n\tif t == nil {\n\t\tt = &BodyModificationFragment{}\n\t}\n\treturn t.Description\n}\n\ntype PerformerFragment struct {\n\tID              string                      \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName            string                      \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n\tDisambiguation  *string                     \"json:\\\"disambiguation,omitempty\\\" graphql:\\\"disambiguation\\\"\"\n\tAliases         []string                    \"json:\\\"aliases\\\" graphql:\\\"aliases\\\"\"\n\tGender          *GenderEnum                 \"json:\\\"gender,omitempty\\\" graphql:\\\"gender\\\"\"\n\tMergedIds       []string                    \"json:\\\"merged_ids\\\" graphql:\\\"merged_ids\\\"\"\n\tDeleted         bool                        \"json:\\\"deleted\\\" graphql:\\\"deleted\\\"\"\n\tMergedIntoID    *string                     \"json:\\\"merged_into_id,omitempty\\\" graphql:\\\"merged_into_id\\\"\"\n\tUrls            []*URLFragment              \"json:\\\"urls\\\" graphql:\\\"urls\\\"\"\n\tImages          []*ImageFragment            \"json:\\\"images\\\" graphql:\\\"images\\\"\"\n\tBirthDate       *string                     \"json:\\\"birth_date,omitempty\\\" graphql:\\\"birth_date\\\"\"\n\tDeathDate       *string                     \"json:\\\"death_date,omitempty\\\" graphql:\\\"death_date\\\"\"\n\tEthnicity       *EthnicityEnum              \"json:\\\"ethnicity,omitempty\\\" graphql:\\\"ethnicity\\\"\"\n\tCountry         *string                     \"json:\\\"country,omitempty\\\" graphql:\\\"country\\\"\"\n\tEyeColor        *EyeColorEnum               \"json:\\\"eye_color,omitempty\\\" graphql:\\\"eye_color\\\"\"\n\tHairColor       *HairColorEnum              \"json:\\\"hair_color,omitempty\\\" graphql:\\\"hair_color\\\"\"\n\tHeight          *int                        \"json:\\\"height,omitempty\\\" graphql:\\\"height\\\"\"\n\tMeasurements    *MeasurementsFragment       \"json:\\\"measurements\\\" graphql:\\\"measurements\\\"\"\n\tBreastType      *BreastTypeEnum             \"json:\\\"breast_type,omitempty\\\" graphql:\\\"breast_type\\\"\"\n\tCareerStartYear *int                        \"json:\\\"career_start_year,omitempty\\\" graphql:\\\"career_start_year\\\"\"\n\tCareerEndYear   *int                        \"json:\\\"career_end_year,omitempty\\\" graphql:\\\"career_end_year\\\"\"\n\tTattoos         []*BodyModificationFragment \"json:\\\"tattoos,omitempty\\\" graphql:\\\"tattoos\\\"\"\n\tPiercings       []*BodyModificationFragment \"json:\\\"piercings,omitempty\\\" graphql:\\\"piercings\\\"\"\n}\n\nfunc (t *PerformerFragment) GetID() string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.ID\n}\nfunc (t *PerformerFragment) GetName() string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Name\n}\nfunc (t *PerformerFragment) GetDisambiguation() *string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Disambiguation\n}\nfunc (t *PerformerFragment) GetAliases() []string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Aliases\n}\nfunc (t *PerformerFragment) GetGender() *GenderEnum {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Gender\n}\nfunc (t *PerformerFragment) GetMergedIds() []string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.MergedIds\n}\nfunc (t *PerformerFragment) GetDeleted() bool {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Deleted\n}\nfunc (t *PerformerFragment) GetMergedIntoID() *string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.MergedIntoID\n}\nfunc (t *PerformerFragment) GetUrls() []*URLFragment {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Urls\n}\nfunc (t *PerformerFragment) GetImages() []*ImageFragment {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Images\n}\nfunc (t *PerformerFragment) GetBirthDate() *string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.BirthDate\n}\nfunc (t *PerformerFragment) GetDeathDate() *string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.DeathDate\n}\nfunc (t *PerformerFragment) GetEthnicity() *EthnicityEnum {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Ethnicity\n}\nfunc (t *PerformerFragment) GetCountry() *string {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Country\n}\nfunc (t *PerformerFragment) GetEyeColor() *EyeColorEnum {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.EyeColor\n}\nfunc (t *PerformerFragment) GetHairColor() *HairColorEnum {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.HairColor\n}\nfunc (t *PerformerFragment) GetHeight() *int {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Height\n}\nfunc (t *PerformerFragment) GetMeasurements() *MeasurementsFragment {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Measurements\n}\nfunc (t *PerformerFragment) GetBreastType() *BreastTypeEnum {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.BreastType\n}\nfunc (t *PerformerFragment) GetCareerStartYear() *int {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.CareerStartYear\n}\nfunc (t *PerformerFragment) GetCareerEndYear() *int {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.CareerEndYear\n}\nfunc (t *PerformerFragment) GetTattoos() []*BodyModificationFragment {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Tattoos\n}\nfunc (t *PerformerFragment) GetPiercings() []*BodyModificationFragment {\n\tif t == nil {\n\t\tt = &PerformerFragment{}\n\t}\n\treturn t.Piercings\n}\n\ntype PerformerAppearanceFragment struct {\n\tAs        *string            \"json:\\\"as,omitempty\\\" graphql:\\\"as\\\"\"\n\tPerformer *PerformerFragment \"json:\\\"performer\\\" graphql:\\\"performer\\\"\"\n}\n\nfunc (t *PerformerAppearanceFragment) GetAs() *string {\n\tif t == nil {\n\t\tt = &PerformerAppearanceFragment{}\n\t}\n\treturn t.As\n}\nfunc (t *PerformerAppearanceFragment) GetPerformer() *PerformerFragment {\n\tif t == nil {\n\t\tt = &PerformerAppearanceFragment{}\n\t}\n\treturn t.Performer\n}\n\ntype FingerprintFragment struct {\n\tAlgorithm FingerprintAlgorithm \"json:\\\"algorithm\\\" graphql:\\\"algorithm\\\"\"\n\tHash      string               \"json:\\\"hash\\\" graphql:\\\"hash\\\"\"\n\tDuration  int                  \"json:\\\"duration\\\" graphql:\\\"duration\\\"\"\n}\n\nfunc (t *FingerprintFragment) GetAlgorithm() *FingerprintAlgorithm {\n\tif t == nil {\n\t\tt = &FingerprintFragment{}\n\t}\n\treturn &t.Algorithm\n}\nfunc (t *FingerprintFragment) GetHash() string {\n\tif t == nil {\n\t\tt = &FingerprintFragment{}\n\t}\n\treturn t.Hash\n}\nfunc (t *FingerprintFragment) GetDuration() int {\n\tif t == nil {\n\t\tt = &FingerprintFragment{}\n\t}\n\treturn t.Duration\n}\n\ntype SceneFragment struct {\n\tID           string                         \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tTitle        *string                        \"json:\\\"title,omitempty\\\" graphql:\\\"title\\\"\"\n\tCode         *string                        \"json:\\\"code,omitempty\\\" graphql:\\\"code\\\"\"\n\tDetails      *string                        \"json:\\\"details,omitempty\\\" graphql:\\\"details\\\"\"\n\tDirector     *string                        \"json:\\\"director,omitempty\\\" graphql:\\\"director\\\"\"\n\tDuration     *int                           \"json:\\\"duration,omitempty\\\" graphql:\\\"duration\\\"\"\n\tDate         *string                        \"json:\\\"date,omitempty\\\" graphql:\\\"date\\\"\"\n\tUrls         []*URLFragment                 \"json:\\\"urls\\\" graphql:\\\"urls\\\"\"\n\tImages       []*ImageFragment               \"json:\\\"images\\\" graphql:\\\"images\\\"\"\n\tStudio       *StudioFragment                \"json:\\\"studio,omitempty\\\" graphql:\\\"studio\\\"\"\n\tTags         []*TagFragment                 \"json:\\\"tags\\\" graphql:\\\"tags\\\"\"\n\tPerformers   []*PerformerAppearanceFragment \"json:\\\"performers\\\" graphql:\\\"performers\\\"\"\n\tFingerprints []*FingerprintFragment         \"json:\\\"fingerprints\\\" graphql:\\\"fingerprints\\\"\"\n}\n\nfunc (t *SceneFragment) GetID() string {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.ID\n}\nfunc (t *SceneFragment) GetTitle() *string {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Title\n}\nfunc (t *SceneFragment) GetCode() *string {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Code\n}\nfunc (t *SceneFragment) GetDetails() *string {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Details\n}\nfunc (t *SceneFragment) GetDirector() *string {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Director\n}\nfunc (t *SceneFragment) GetDuration() *int {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Duration\n}\nfunc (t *SceneFragment) GetDate() *string {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Date\n}\nfunc (t *SceneFragment) GetUrls() []*URLFragment {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Urls\n}\nfunc (t *SceneFragment) GetImages() []*ImageFragment {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Images\n}\nfunc (t *SceneFragment) GetStudio() *StudioFragment {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Studio\n}\nfunc (t *SceneFragment) GetTags() []*TagFragment {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Tags\n}\nfunc (t *SceneFragment) GetPerformers() []*PerformerAppearanceFragment {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Performers\n}\nfunc (t *SceneFragment) GetFingerprints() []*FingerprintFragment {\n\tif t == nil {\n\t\tt = &SceneFragment{}\n\t}\n\treturn t.Fingerprints\n}\n\ntype StudioFragment_Parent struct {\n\tID   string \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName string \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *StudioFragment_Parent) GetID() string {\n\tif t == nil {\n\t\tt = &StudioFragment_Parent{}\n\t}\n\treturn t.ID\n}\nfunc (t *StudioFragment_Parent) GetName() string {\n\tif t == nil {\n\t\tt = &StudioFragment_Parent{}\n\t}\n\treturn t.Name\n}\n\ntype TagFragment_Category struct {\n\tDescription *string \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n\tID          string  \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName        string  \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *TagFragment_Category) GetDescription() *string {\n\tif t == nil {\n\t\tt = &TagFragment_Category{}\n\t}\n\treturn t.Description\n}\nfunc (t *TagFragment_Category) GetID() string {\n\tif t == nil {\n\t\tt = &TagFragment_Category{}\n\t}\n\treturn t.ID\n}\nfunc (t *TagFragment_Category) GetName() string {\n\tif t == nil {\n\t\tt = &TagFragment_Category{}\n\t}\n\treturn t.Name\n}\n\ntype SceneFragment_Studio_StudioFragment_Parent struct {\n\tID   string \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName string \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *SceneFragment_Studio_StudioFragment_Parent) GetID() string {\n\tif t == nil {\n\t\tt = &SceneFragment_Studio_StudioFragment_Parent{}\n\t}\n\treturn t.ID\n}\nfunc (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {\n\tif t == nil {\n\t\tt = &SceneFragment_Studio_StudioFragment_Parent{}\n\t}\n\treturn t.Name\n}\n\ntype SceneFragment_Tags_TagFragment_Category struct {\n\tDescription *string \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n\tID          string  \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName        string  \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *SceneFragment_Tags_TagFragment_Category) GetDescription() *string {\n\tif t == nil {\n\t\tt = &SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.Description\n}\nfunc (t *SceneFragment_Tags_TagFragment_Category) GetID() string {\n\tif t == nil {\n\t\tt = &SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.ID\n}\nfunc (t *SceneFragment_Tags_TagFragment_Category) GetName() string {\n\tif t == nil {\n\t\tt = &SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.Name\n}\n\ntype FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {\n\tID   string \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName string \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string {\n\tif t == nil {\n\t\tt = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}\n\t}\n\treturn t.ID\n}\nfunc (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {\n\tif t == nil {\n\t\tt = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}\n\t}\n\treturn t.Name\n}\n\ntype FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category struct {\n\tDescription *string \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n\tID          string  \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName        string  \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {\n\tif t == nil {\n\t\tt = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.Description\n}\nfunc (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetID() string {\n\tif t == nil {\n\t\tt = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.ID\n}\nfunc (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetName() string {\n\tif t == nil {\n\t\tt = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.Name\n}\n\ntype SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {\n\tID   string \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName string \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string {\n\tif t == nil {\n\t\tt = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}\n\t}\n\treturn t.ID\n}\nfunc (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {\n\tif t == nil {\n\t\tt = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}\n\t}\n\treturn t.Name\n}\n\ntype SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category struct {\n\tDescription *string \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n\tID          string  \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName        string  \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {\n\tif t == nil {\n\t\tt = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.Description\n}\nfunc (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetID() string {\n\tif t == nil {\n\t\tt = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.ID\n}\nfunc (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetName() string {\n\tif t == nil {\n\t\tt = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.Name\n}\n\ntype FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {\n\tID   string \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName string \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string {\n\tif t == nil {\n\t\tt = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}\n\t}\n\treturn t.ID\n}\nfunc (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {\n\tif t == nil {\n\t\tt = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}\n\t}\n\treturn t.Name\n}\n\ntype FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category struct {\n\tDescription *string \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n\tID          string  \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName        string  \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string {\n\tif t == nil {\n\t\tt = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.Description\n}\nfunc (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetID() string {\n\tif t == nil {\n\t\tt = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.ID\n}\nfunc (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetName() string {\n\tif t == nil {\n\t\tt = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{}\n\t}\n\treturn t.Name\n}\n\ntype FindStudio_FindStudio_StudioFragment_Parent struct {\n\tID   string \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName string \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *FindStudio_FindStudio_StudioFragment_Parent) GetID() string {\n\tif t == nil {\n\t\tt = &FindStudio_FindStudio_StudioFragment_Parent{}\n\t}\n\treturn t.ID\n}\nfunc (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {\n\tif t == nil {\n\t\tt = &FindStudio_FindStudio_StudioFragment_Parent{}\n\t}\n\treturn t.Name\n}\n\ntype FindTag_FindTag_TagFragment_Category struct {\n\tDescription *string \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n\tID          string  \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName        string  \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *FindTag_FindTag_TagFragment_Category) GetDescription() *string {\n\tif t == nil {\n\t\tt = &FindTag_FindTag_TagFragment_Category{}\n\t}\n\treturn t.Description\n}\nfunc (t *FindTag_FindTag_TagFragment_Category) GetID() string {\n\tif t == nil {\n\t\tt = &FindTag_FindTag_TagFragment_Category{}\n\t}\n\treturn t.ID\n}\nfunc (t *FindTag_FindTag_TagFragment_Category) GetName() string {\n\tif t == nil {\n\t\tt = &FindTag_FindTag_TagFragment_Category{}\n\t}\n\treturn t.Name\n}\n\ntype QueryTags_QueryTags_Tags_TagFragment_Category struct {\n\tDescription *string \"json:\\\"description,omitempty\\\" graphql:\\\"description\\\"\"\n\tID          string  \"json:\\\"id\\\" graphql:\\\"id\\\"\"\n\tName        string  \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetDescription() *string {\n\tif t == nil {\n\t\tt = &QueryTags_QueryTags_Tags_TagFragment_Category{}\n\t}\n\treturn t.Description\n}\nfunc (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetID() string {\n\tif t == nil {\n\t\tt = &QueryTags_QueryTags_Tags_TagFragment_Category{}\n\t}\n\treturn t.ID\n}\nfunc (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetName() string {\n\tif t == nil {\n\t\tt = &QueryTags_QueryTags_Tags_TagFragment_Category{}\n\t}\n\treturn t.Name\n}\n\ntype QueryTags_QueryTags struct {\n\tCount int            \"json:\\\"count\\\" graphql:\\\"count\\\"\"\n\tTags  []*TagFragment \"json:\\\"tags\\\" graphql:\\\"tags\\\"\"\n}\n\nfunc (t *QueryTags_QueryTags) GetCount() int {\n\tif t == nil {\n\t\tt = &QueryTags_QueryTags{}\n\t}\n\treturn t.Count\n}\nfunc (t *QueryTags_QueryTags) GetTags() []*TagFragment {\n\tif t == nil {\n\t\tt = &QueryTags_QueryTags{}\n\t}\n\treturn t.Tags\n}\n\ntype Me_Me struct {\n\tName string \"json:\\\"name\\\" graphql:\\\"name\\\"\"\n}\n\nfunc (t *Me_Me) GetName() string {\n\tif t == nil {\n\t\tt = &Me_Me{}\n\t}\n\treturn t.Name\n}\n\ntype SubmitSceneDraft_SubmitSceneDraft struct {\n\tID *string \"json:\\\"id,omitempty\\\" graphql:\\\"id\\\"\"\n}\n\nfunc (t *SubmitSceneDraft_SubmitSceneDraft) GetID() *string {\n\tif t == nil {\n\t\tt = &SubmitSceneDraft_SubmitSceneDraft{}\n\t}\n\treturn t.ID\n}\n\ntype SubmitPerformerDraft_SubmitPerformerDraft struct {\n\tID *string \"json:\\\"id,omitempty\\\" graphql:\\\"id\\\"\"\n}\n\nfunc (t *SubmitPerformerDraft_SubmitPerformerDraft) GetID() *string {\n\tif t == nil {\n\t\tt = &SubmitPerformerDraft_SubmitPerformerDraft{}\n\t}\n\treturn t.ID\n}\n\ntype FindScenesBySceneFingerprints struct {\n\tFindScenesBySceneFingerprints [][]*SceneFragment \"json:\\\"findScenesBySceneFingerprints\\\" graphql:\\\"findScenesBySceneFingerprints\\\"\"\n}\n\nfunc (t *FindScenesBySceneFingerprints) GetFindScenesBySceneFingerprints() [][]*SceneFragment {\n\tif t == nil {\n\t\tt = &FindScenesBySceneFingerprints{}\n\t}\n\treturn t.FindScenesBySceneFingerprints\n}\n\ntype SearchScene struct {\n\tSearchScene []*SceneFragment \"json:\\\"searchScene\\\" graphql:\\\"searchScene\\\"\"\n}\n\nfunc (t *SearchScene) GetSearchScene() []*SceneFragment {\n\tif t == nil {\n\t\tt = &SearchScene{}\n\t}\n\treturn t.SearchScene\n}\n\ntype SearchPerformer struct {\n\tSearchPerformer []*PerformerFragment \"json:\\\"searchPerformer\\\" graphql:\\\"searchPerformer\\\"\"\n}\n\nfunc (t *SearchPerformer) GetSearchPerformer() []*PerformerFragment {\n\tif t == nil {\n\t\tt = &SearchPerformer{}\n\t}\n\treturn t.SearchPerformer\n}\n\ntype FindPerformerByID struct {\n\tFindPerformer *PerformerFragment \"json:\\\"findPerformer,omitempty\\\" graphql:\\\"findPerformer\\\"\"\n}\n\nfunc (t *FindPerformerByID) GetFindPerformer() *PerformerFragment {\n\tif t == nil {\n\t\tt = &FindPerformerByID{}\n\t}\n\treturn t.FindPerformer\n}\n\ntype FindSceneByID struct {\n\tFindScene *SceneFragment \"json:\\\"findScene,omitempty\\\" graphql:\\\"findScene\\\"\"\n}\n\nfunc (t *FindSceneByID) GetFindScene() *SceneFragment {\n\tif t == nil {\n\t\tt = &FindSceneByID{}\n\t}\n\treturn t.FindScene\n}\n\ntype FindStudio struct {\n\tFindStudio *StudioFragment \"json:\\\"findStudio,omitempty\\\" graphql:\\\"findStudio\\\"\"\n}\n\nfunc (t *FindStudio) GetFindStudio() *StudioFragment {\n\tif t == nil {\n\t\tt = &FindStudio{}\n\t}\n\treturn t.FindStudio\n}\n\ntype FindTag struct {\n\tFindTag *TagFragment \"json:\\\"findTag,omitempty\\\" graphql:\\\"findTag\\\"\"\n}\n\nfunc (t *FindTag) GetFindTag() *TagFragment {\n\tif t == nil {\n\t\tt = &FindTag{}\n\t}\n\treturn t.FindTag\n}\n\ntype QueryTags struct {\n\tQueryTags QueryTags_QueryTags \"json:\\\"queryTags\\\" graphql:\\\"queryTags\\\"\"\n}\n\nfunc (t *QueryTags) GetQueryTags() *QueryTags_QueryTags {\n\tif t == nil {\n\t\tt = &QueryTags{}\n\t}\n\treturn &t.QueryTags\n}\n\ntype SubmitFingerprint struct {\n\tSubmitFingerprint bool \"json:\\\"submitFingerprint\\\" graphql:\\\"submitFingerprint\\\"\"\n}\n\nfunc (t *SubmitFingerprint) GetSubmitFingerprint() bool {\n\tif t == nil {\n\t\tt = &SubmitFingerprint{}\n\t}\n\treturn t.SubmitFingerprint\n}\n\ntype Me struct {\n\tMe *Me_Me \"json:\\\"me,omitempty\\\" graphql:\\\"me\\\"\"\n}\n\nfunc (t *Me) GetMe() *Me_Me {\n\tif t == nil {\n\t\tt = &Me{}\n\t}\n\treturn t.Me\n}\n\ntype SubmitSceneDraft struct {\n\tSubmitSceneDraft SubmitSceneDraft_SubmitSceneDraft \"json:\\\"submitSceneDraft\\\" graphql:\\\"submitSceneDraft\\\"\"\n}\n\nfunc (t *SubmitSceneDraft) GetSubmitSceneDraft() *SubmitSceneDraft_SubmitSceneDraft {\n\tif t == nil {\n\t\tt = &SubmitSceneDraft{}\n\t}\n\treturn &t.SubmitSceneDraft\n}\n\ntype SubmitPerformerDraft struct {\n\tSubmitPerformerDraft SubmitPerformerDraft_SubmitPerformerDraft \"json:\\\"submitPerformerDraft\\\" graphql:\\\"submitPerformerDraft\\\"\"\n}\n\nfunc (t *SubmitPerformerDraft) GetSubmitPerformerDraft() *SubmitPerformerDraft_SubmitPerformerDraft {\n\tif t == nil {\n\t\tt = &SubmitPerformerDraft{}\n\t}\n\treturn &t.SubmitPerformerDraft\n}\n\nconst FindScenesBySceneFingerprintsDocument = `query FindScenesBySceneFingerprints ($fingerprints: [[FingerprintQueryInput!]!]!) {\n\tfindScenesBySceneFingerprints(fingerprints: $fingerprints) {\n\t\t... SceneFragment\n\t}\n}\nfragment SceneFragment on Scene {\n\tid\n\ttitle\n\tcode\n\tdetails\n\tdirector\n\tduration\n\tdate\n\turls {\n\t\t... URLFragment\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n\tstudio {\n\t\t... StudioFragment\n\t}\n\ttags {\n\t\t... TagFragment\n\t}\n\tperformers {\n\t\t... PerformerAppearanceFragment\n\t}\n\tfingerprints {\n\t\t... FingerprintFragment\n\t}\n}\nfragment URLFragment on URL {\n\turl\n\ttype\n}\nfragment ImageFragment on Image {\n\tid\n\turl\n\twidth\n\theight\n}\nfragment StudioFragment on Studio {\n\tname\n\tid\n\taliases\n\turls {\n\t\t... URLFragment\n\t}\n\tparent {\n\t\tname\n\t\tid\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n}\nfragment TagFragment on Tag {\n\tname\n\tid\n\tdescription\n\taliases\n\tcategory {\n\t\tid\n\t\tname\n\t\tdescription\n\t}\n}\nfragment PerformerAppearanceFragment on PerformerAppearance {\n\tas\n\tperformer {\n\t\t... PerformerFragment\n\t}\n}\nfragment PerformerFragment on Performer {\n\tid\n\tname\n\tdisambiguation\n\taliases\n\tgender\n\tmerged_ids\n\tdeleted\n\tmerged_into_id\n\turls {\n\t\t... URLFragment\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n\tbirth_date\n\tdeath_date\n\tethnicity\n\tcountry\n\teye_color\n\thair_color\n\theight\n\tmeasurements {\n\t\t... MeasurementsFragment\n\t}\n\tbreast_type\n\tcareer_start_year\n\tcareer_end_year\n\ttattoos {\n\t\t... BodyModificationFragment\n\t}\n\tpiercings {\n\t\t... BodyModificationFragment\n\t}\n}\nfragment MeasurementsFragment on Measurements {\n\tband_size\n\tcup_size\n\twaist\n\thip\n}\nfragment BodyModificationFragment on BodyModification {\n\tlocation\n\tdescription\n}\nfragment FingerprintFragment on Fingerprint {\n\talgorithm\n\thash\n\tduration\n}\n`\n\nfunc (c *Client) FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesBySceneFingerprints, error) {\n\tvars := map[string]any{\n\t\t\"fingerprints\": fingerprints,\n\t}\n\n\tvar res FindScenesBySceneFingerprints\n\tif err := c.Client.Post(ctx, \"FindScenesBySceneFingerprints\", FindScenesBySceneFingerprintsDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst SearchSceneDocument = `query SearchScene ($term: String!) {\n\tsearchScene(term: $term) {\n\t\t... SceneFragment\n\t}\n}\nfragment SceneFragment on Scene {\n\tid\n\ttitle\n\tcode\n\tdetails\n\tdirector\n\tduration\n\tdate\n\turls {\n\t\t... URLFragment\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n\tstudio {\n\t\t... StudioFragment\n\t}\n\ttags {\n\t\t... TagFragment\n\t}\n\tperformers {\n\t\t... PerformerAppearanceFragment\n\t}\n\tfingerprints {\n\t\t... FingerprintFragment\n\t}\n}\nfragment URLFragment on URL {\n\turl\n\ttype\n}\nfragment ImageFragment on Image {\n\tid\n\turl\n\twidth\n\theight\n}\nfragment StudioFragment on Studio {\n\tname\n\tid\n\taliases\n\turls {\n\t\t... URLFragment\n\t}\n\tparent {\n\t\tname\n\t\tid\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n}\nfragment TagFragment on Tag {\n\tname\n\tid\n\tdescription\n\taliases\n\tcategory {\n\t\tid\n\t\tname\n\t\tdescription\n\t}\n}\nfragment PerformerAppearanceFragment on PerformerAppearance {\n\tas\n\tperformer {\n\t\t... PerformerFragment\n\t}\n}\nfragment PerformerFragment on Performer {\n\tid\n\tname\n\tdisambiguation\n\taliases\n\tgender\n\tmerged_ids\n\tdeleted\n\tmerged_into_id\n\turls {\n\t\t... URLFragment\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n\tbirth_date\n\tdeath_date\n\tethnicity\n\tcountry\n\teye_color\n\thair_color\n\theight\n\tmeasurements {\n\t\t... MeasurementsFragment\n\t}\n\tbreast_type\n\tcareer_start_year\n\tcareer_end_year\n\ttattoos {\n\t\t... BodyModificationFragment\n\t}\n\tpiercings {\n\t\t... BodyModificationFragment\n\t}\n}\nfragment MeasurementsFragment on Measurements {\n\tband_size\n\tcup_size\n\twaist\n\thip\n}\nfragment BodyModificationFragment on BodyModification {\n\tlocation\n\tdescription\n}\nfragment FingerprintFragment on Fingerprint {\n\talgorithm\n\thash\n\tduration\n}\n`\n\nfunc (c *Client) SearchScene(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchScene, error) {\n\tvars := map[string]any{\n\t\t\"term\": term,\n\t}\n\n\tvar res SearchScene\n\tif err := c.Client.Post(ctx, \"SearchScene\", SearchSceneDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst SearchPerformerDocument = `query SearchPerformer ($term: String!) {\n\tsearchPerformer(term: $term) {\n\t\t... PerformerFragment\n\t}\n}\nfragment PerformerFragment on Performer {\n\tid\n\tname\n\tdisambiguation\n\taliases\n\tgender\n\tmerged_ids\n\tdeleted\n\tmerged_into_id\n\turls {\n\t\t... URLFragment\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n\tbirth_date\n\tdeath_date\n\tethnicity\n\tcountry\n\teye_color\n\thair_color\n\theight\n\tmeasurements {\n\t\t... MeasurementsFragment\n\t}\n\tbreast_type\n\tcareer_start_year\n\tcareer_end_year\n\ttattoos {\n\t\t... BodyModificationFragment\n\t}\n\tpiercings {\n\t\t... BodyModificationFragment\n\t}\n}\nfragment URLFragment on URL {\n\turl\n\ttype\n}\nfragment ImageFragment on Image {\n\tid\n\turl\n\twidth\n\theight\n}\nfragment MeasurementsFragment on Measurements {\n\tband_size\n\tcup_size\n\twaist\n\thip\n}\nfragment BodyModificationFragment on BodyModification {\n\tlocation\n\tdescription\n}\n`\n\nfunc (c *Client) SearchPerformer(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchPerformer, error) {\n\tvars := map[string]any{\n\t\t\"term\": term,\n\t}\n\n\tvar res SearchPerformer\n\tif err := c.Client.Post(ctx, \"SearchPerformer\", SearchPerformerDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst FindPerformerByIDDocument = `query FindPerformerByID ($id: ID!) {\n\tfindPerformer(id: $id) {\n\t\t... PerformerFragment\n\t}\n}\nfragment PerformerFragment on Performer {\n\tid\n\tname\n\tdisambiguation\n\taliases\n\tgender\n\tmerged_ids\n\tdeleted\n\tmerged_into_id\n\turls {\n\t\t... URLFragment\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n\tbirth_date\n\tdeath_date\n\tethnicity\n\tcountry\n\teye_color\n\thair_color\n\theight\n\tmeasurements {\n\t\t... MeasurementsFragment\n\t}\n\tbreast_type\n\tcareer_start_year\n\tcareer_end_year\n\ttattoos {\n\t\t... BodyModificationFragment\n\t}\n\tpiercings {\n\t\t... BodyModificationFragment\n\t}\n}\nfragment URLFragment on URL {\n\turl\n\ttype\n}\nfragment ImageFragment on Image {\n\tid\n\turl\n\twidth\n\theight\n}\nfragment MeasurementsFragment on Measurements {\n\tband_size\n\tcup_size\n\twaist\n\thip\n}\nfragment BodyModificationFragment on BodyModification {\n\tlocation\n\tdescription\n}\n`\n\nfunc (c *Client) FindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error) {\n\tvars := map[string]any{\n\t\t\"id\": id,\n\t}\n\n\tvar res FindPerformerByID\n\tif err := c.Client.Post(ctx, \"FindPerformerByID\", FindPerformerByIDDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst FindSceneByIDDocument = `query FindSceneByID ($id: ID!) {\n\tfindScene(id: $id) {\n\t\t... SceneFragment\n\t}\n}\nfragment SceneFragment on Scene {\n\tid\n\ttitle\n\tcode\n\tdetails\n\tdirector\n\tduration\n\tdate\n\turls {\n\t\t... URLFragment\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n\tstudio {\n\t\t... StudioFragment\n\t}\n\ttags {\n\t\t... TagFragment\n\t}\n\tperformers {\n\t\t... PerformerAppearanceFragment\n\t}\n\tfingerprints {\n\t\t... FingerprintFragment\n\t}\n}\nfragment URLFragment on URL {\n\turl\n\ttype\n}\nfragment ImageFragment on Image {\n\tid\n\turl\n\twidth\n\theight\n}\nfragment StudioFragment on Studio {\n\tname\n\tid\n\taliases\n\turls {\n\t\t... URLFragment\n\t}\n\tparent {\n\t\tname\n\t\tid\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n}\nfragment TagFragment on Tag {\n\tname\n\tid\n\tdescription\n\taliases\n\tcategory {\n\t\tid\n\t\tname\n\t\tdescription\n\t}\n}\nfragment PerformerAppearanceFragment on PerformerAppearance {\n\tas\n\tperformer {\n\t\t... PerformerFragment\n\t}\n}\nfragment PerformerFragment on Performer {\n\tid\n\tname\n\tdisambiguation\n\taliases\n\tgender\n\tmerged_ids\n\tdeleted\n\tmerged_into_id\n\turls {\n\t\t... URLFragment\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n\tbirth_date\n\tdeath_date\n\tethnicity\n\tcountry\n\teye_color\n\thair_color\n\theight\n\tmeasurements {\n\t\t... MeasurementsFragment\n\t}\n\tbreast_type\n\tcareer_start_year\n\tcareer_end_year\n\ttattoos {\n\t\t... BodyModificationFragment\n\t}\n\tpiercings {\n\t\t... BodyModificationFragment\n\t}\n}\nfragment MeasurementsFragment on Measurements {\n\tband_size\n\tcup_size\n\twaist\n\thip\n}\nfragment BodyModificationFragment on BodyModification {\n\tlocation\n\tdescription\n}\nfragment FingerprintFragment on Fingerprint {\n\talgorithm\n\thash\n\tduration\n}\n`\n\nfunc (c *Client) FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) {\n\tvars := map[string]any{\n\t\t\"id\": id,\n\t}\n\n\tvar res FindSceneByID\n\tif err := c.Client.Post(ctx, \"FindSceneByID\", FindSceneByIDDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst FindStudioDocument = `query FindStudio ($id: ID, $name: String) {\n\tfindStudio(id: $id, name: $name) {\n\t\t... StudioFragment\n\t}\n}\nfragment StudioFragment on Studio {\n\tname\n\tid\n\taliases\n\turls {\n\t\t... URLFragment\n\t}\n\tparent {\n\t\tname\n\t\tid\n\t}\n\timages {\n\t\t... ImageFragment\n\t}\n}\nfragment URLFragment on URL {\n\turl\n\ttype\n}\nfragment ImageFragment on Image {\n\tid\n\turl\n\twidth\n\theight\n}\n`\n\nfunc (c *Client) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) {\n\tvars := map[string]any{\n\t\t\"id\":   id,\n\t\t\"name\": name,\n\t}\n\n\tvar res FindStudio\n\tif err := c.Client.Post(ctx, \"FindStudio\", FindStudioDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst FindTagDocument = `query FindTag ($id: ID, $name: String) {\n\tfindTag(id: $id, name: $name) {\n\t\t... TagFragment\n\t}\n}\nfragment TagFragment on Tag {\n\tname\n\tid\n\tdescription\n\taliases\n\tcategory {\n\t\tid\n\t\tname\n\t\tdescription\n\t}\n}\n`\n\nfunc (c *Client) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) {\n\tvars := map[string]any{\n\t\t\"id\":   id,\n\t\t\"name\": name,\n\t}\n\n\tvar res FindTag\n\tif err := c.Client.Post(ctx, \"FindTag\", FindTagDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) {\n\tqueryTags(input: $input) {\n\t\tcount\n\t\ttags {\n\t\t\t... TagFragment\n\t\t}\n\t}\n}\nfragment TagFragment on Tag {\n\tname\n\tid\n\tdescription\n\taliases\n\tcategory {\n\t\tid\n\t\tname\n\t\tdescription\n\t}\n}\n`\n\nfunc (c *Client) QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) {\n\tvars := map[string]any{\n\t\t\"input\": input,\n\t}\n\n\tvar res QueryTags\n\tif err := c.Client.Post(ctx, \"QueryTags\", QueryTagsDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) {\n\tsubmitFingerprint(input: $input)\n}\n`\n\nfunc (c *Client) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) {\n\tvars := map[string]any{\n\t\t\"input\": input,\n\t}\n\n\tvar res SubmitFingerprint\n\tif err := c.Client.Post(ctx, \"SubmitFingerprint\", SubmitFingerprintDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst MeDocument = `query Me {\n\tme {\n\t\tname\n\t}\n}\n`\n\nfunc (c *Client) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) {\n\tvars := map[string]any{}\n\n\tvar res Me\n\tif err := c.Client.Post(ctx, \"Me\", MeDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst SubmitSceneDraftDocument = `mutation SubmitSceneDraft ($input: SceneDraftInput!) {\n\tsubmitSceneDraft(input: $input) {\n\t\tid\n\t}\n}\n`\n\nfunc (c *Client) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) {\n\tvars := map[string]any{\n\t\t\"input\": input,\n\t}\n\n\tvar res SubmitSceneDraft\n\tif err := c.Client.Post(ctx, \"SubmitSceneDraft\", SubmitSceneDraftDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nconst SubmitPerformerDraftDocument = `mutation SubmitPerformerDraft ($input: PerformerDraftInput!) {\n\tsubmitPerformerDraft(input: $input) {\n\t\tid\n\t}\n}\n`\n\nfunc (c *Client) SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitPerformerDraft, error) {\n\tvars := map[string]any{\n\t\t\"input\": input,\n\t}\n\n\tvar res SubmitPerformerDraft\n\tif err := c.Client.Post(ctx, \"SubmitPerformerDraft\", SubmitPerformerDraftDocument, &res, vars, interceptors...); err != nil {\n\t\tif c.Client.ParseDataWhenErrors {\n\t\t\treturn &res, err\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn &res, nil\n}\n\nvar DocumentOperationNames = map[string]string{\n\tFindScenesBySceneFingerprintsDocument: \"FindScenesBySceneFingerprints\",\n\tSearchSceneDocument:                   \"SearchScene\",\n\tSearchPerformerDocument:               \"SearchPerformer\",\n\tFindPerformerByIDDocument:             \"FindPerformerByID\",\n\tFindSceneByIDDocument:                 \"FindSceneByID\",\n\tFindStudioDocument:                    \"FindStudio\",\n\tFindTagDocument:                       \"FindTag\",\n\tQueryTagsDocument:                     \"QueryTags\",\n\tSubmitFingerprintDocument:             \"SubmitFingerprint\",\n\tMeDocument:                            \"Me\",\n\tSubmitSceneDraftDocument:              \"SubmitSceneDraft\",\n\tSubmitPerformerDraftDocument:          \"SubmitPerformerDraft\",\n}\n"
  },
  {
    "path": "pkg/stashbox/graphql/generated_models.go",
    "content": "// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.\n\npackage graphql\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/99designs/gqlgen/graphql\"\n)\n\ntype DraftData interface {\n\tIsDraftData()\n}\n\ntype EditDetails interface {\n\tIsEditDetails()\n}\n\ntype EditTarget interface {\n\tIsEditTarget()\n}\n\ntype NotificationData interface {\n\tIsNotificationData()\n}\n\ntype SceneDraftPerformer interface {\n\tIsSceneDraftPerformer()\n}\n\ntype SceneDraftStudio interface {\n\tIsSceneDraftStudio()\n}\n\ntype SceneDraftTag interface {\n\tIsSceneDraftTag()\n}\n\ntype ActivateNewUserInput struct {\n\tName          string `json:\"name\"`\n\tActivationKey string `json:\"activation_key\"`\n\tPassword      string `json:\"password\"`\n}\n\ntype ApplyEditInput struct {\n\tID string `json:\"id\"`\n}\n\ntype BodyModification struct {\n\tLocation    string  `json:\"location\"`\n\tDescription *string `json:\"description,omitempty\"`\n}\n\ntype BodyModificationCriterionInput struct {\n\tLocation    *string           `json:\"location,omitempty\"`\n\tDescription *string           `json:\"description,omitempty\"`\n\tModifier    CriterionModifier `json:\"modifier\"`\n}\n\ntype BodyModificationInput struct {\n\tLocation    string  `json:\"location\"`\n\tDescription *string `json:\"description,omitempty\"`\n}\n\ntype BreastTypeCriterionInput struct {\n\tValue    *BreastTypeEnum   `json:\"value,omitempty\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype CancelEditInput struct {\n\tID string `json:\"id\"`\n}\n\ntype CommentCommentedEdit struct {\n\tComment *EditComment `json:\"comment\"`\n}\n\nfunc (CommentCommentedEdit) IsNotificationData() {}\n\ntype CommentOwnEdit struct {\n\tComment *EditComment `json:\"comment\"`\n}\n\nfunc (CommentOwnEdit) IsNotificationData() {}\n\ntype CommentVotedEdit struct {\n\tComment *EditComment `json:\"comment\"`\n}\n\nfunc (CommentVotedEdit) IsNotificationData() {}\n\ntype DateCriterionInput struct {\n\tValue    string            `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype DownvoteOwnEdit struct {\n\tEdit *Edit `json:\"edit\"`\n}\n\nfunc (DownvoteOwnEdit) IsNotificationData() {}\n\ntype Draft struct {\n\tID      string    `json:\"id\"`\n\tCreated time.Time `json:\"created\"`\n\tExpires time.Time `json:\"expires\"`\n\tData    DraftData `json:\"data\"`\n}\n\ntype DraftEntity struct {\n\tName string  `json:\"name\"`\n\tID   *string `json:\"id,omitempty\"`\n}\n\nfunc (DraftEntity) IsSceneDraftPerformer() {}\n\nfunc (DraftEntity) IsSceneDraftStudio() {}\n\nfunc (DraftEntity) IsSceneDraftTag() {}\n\ntype DraftEntityInput struct {\n\tName string  `json:\"name\"`\n\tID   *string `json:\"id,omitempty\"`\n}\n\ntype DraftFingerprint struct {\n\tHash      string               `json:\"hash\"`\n\tAlgorithm FingerprintAlgorithm `json:\"algorithm\"`\n\tDuration  int                  `json:\"duration\"`\n}\n\ntype DraftSubmissionStatus struct {\n\tID *string `json:\"id,omitempty\"`\n}\n\ntype Edit struct {\n\tID   string `json:\"id\"`\n\tUser *User  `json:\"user,omitempty\"`\n\t// Object being edited - null if creating a new object\n\tTarget     EditTarget     `json:\"target,omitempty\"`\n\tTargetType TargetTypeEnum `json:\"target_type\"`\n\t// Objects to merge with the target. Only applicable to merges\n\tMergeSources []EditTarget  `json:\"merge_sources\"`\n\tOperation    OperationEnum `json:\"operation\"`\n\tBot          bool          `json:\"bot\"`\n\tDetails      EditDetails   `json:\"details,omitempty\"`\n\t// Previous state of fields being modified - null if operation is create or delete.\n\tOldDetails EditDetails `json:\"old_details,omitempty\"`\n\t// Entity specific options\n\tOptions  *PerformerEditOptions `json:\"options,omitempty\"`\n\tComments []*EditComment        `json:\"comments\"`\n\tVotes    []*EditVote           `json:\"votes\"`\n\t//  = Accepted - Rejected\n\tVoteCount int `json:\"vote_count\"`\n\t// Is the edit considered destructive.\n\tDestructive bool           `json:\"destructive\"`\n\tStatus      VoteStatusEnum `json:\"status\"`\n\tApplied     bool           `json:\"applied\"`\n\tUpdateCount int            `json:\"update_count\"`\n\tUpdatable   bool           `json:\"updatable\"`\n\tCreated     time.Time      `json:\"created\"`\n\tUpdated     *time.Time     `json:\"updated,omitempty\"`\n\tClosed      *time.Time     `json:\"closed,omitempty\"`\n\tExpires     *time.Time     `json:\"expires,omitempty\"`\n}\n\ntype EditComment struct {\n\tID      string    `json:\"id\"`\n\tUser    *User     `json:\"user,omitempty\"`\n\tDate    time.Time `json:\"date\"`\n\tComment string    `json:\"comment\"`\n\tEdit    *Edit     `json:\"edit\"`\n}\n\ntype EditCommentInput struct {\n\tID      string `json:\"id\"`\n\tComment string `json:\"comment\"`\n}\n\ntype EditInput struct {\n\t// Not required for create type\n\tID        *string       `json:\"id,omitempty\"`\n\tOperation OperationEnum `json:\"operation\"`\n\t// Only required for merge type\n\tMergeSourceIds []string `json:\"merge_source_ids,omitempty\"`\n\tComment        *string  `json:\"comment,omitempty\"`\n\t// Edit submitted by an automated script. Requires bot permission\n\tBot *bool `json:\"bot,omitempty\"`\n}\n\ntype EditQueryInput struct {\n\t// Filter by user id\n\tUserID *string `json:\"user_id,omitempty\"`\n\t// Filter by status\n\tStatus *VoteStatusEnum `json:\"status,omitempty\"`\n\t// Filter by operation\n\tOperation *OperationEnum `json:\"operation,omitempty\"`\n\t// Filter by vote count\n\tVoteCount *IntCriterionInput `json:\"vote_count,omitempty\"`\n\t// Filter by applied status\n\tApplied *bool `json:\"applied,omitempty\"`\n\t// Filter by target type\n\tTargetType *TargetTypeEnum `json:\"target_type,omitempty\"`\n\t// Filter by target id\n\tTargetID *string `json:\"target_id,omitempty\"`\n\t// Filter by favorite status\n\tIsFavorite *bool `json:\"is_favorite,omitempty\"`\n\t// Filter by user voted status\n\tVoted *UserVotedFilterEnum `json:\"voted,omitempty\"`\n\t// Filter to bot edits only\n\tIsBot *bool `json:\"is_bot,omitempty\"`\n\t// Filter out user's own edits\n\tIncludeUserSubmitted *bool             `json:\"include_user_submitted,omitempty\"`\n\tPage                 int               `json:\"page\"`\n\tPerPage              int               `json:\"per_page\"`\n\tDirection            SortDirectionEnum `json:\"direction\"`\n\tSort                 EditSortEnum      `json:\"sort\"`\n}\n\ntype EditVote struct {\n\tUser *User        `json:\"user,omitempty\"`\n\tDate time.Time    `json:\"date\"`\n\tVote VoteTypeEnum `json:\"vote\"`\n}\n\ntype EditVoteInput struct {\n\tID   string       `json:\"id\"`\n\tVote VoteTypeEnum `json:\"vote\"`\n}\n\ntype EyeColorCriterionInput struct {\n\tValue    *EyeColorEnum     `json:\"value,omitempty\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype FailedOwnEdit struct {\n\tEdit *Edit `json:\"edit\"`\n}\n\nfunc (FailedOwnEdit) IsNotificationData() {}\n\ntype FavoritePerformerEdit struct {\n\tEdit *Edit `json:\"edit\"`\n}\n\nfunc (FavoritePerformerEdit) IsNotificationData() {}\n\ntype FavoritePerformerScene struct {\n\tScene *Scene `json:\"scene\"`\n}\n\nfunc (FavoritePerformerScene) IsNotificationData() {}\n\ntype FavoriteStudioEdit struct {\n\tEdit *Edit `json:\"edit\"`\n}\n\nfunc (FavoriteStudioEdit) IsNotificationData() {}\n\ntype FavoriteStudioScene struct {\n\tScene *Scene `json:\"scene\"`\n}\n\nfunc (FavoriteStudioScene) IsNotificationData() {}\n\ntype Fingerprint struct {\n\tHash      string               `json:\"hash\"`\n\tAlgorithm FingerprintAlgorithm `json:\"algorithm\"`\n\tDuration  int                  `json:\"duration\"`\n\t// number of times this fingerprint has been submitted (excluding reports)\n\tSubmissions int `json:\"submissions\"`\n\t// number of times this fingerprint has been reported\n\tReports int       `json:\"reports\"`\n\tCreated time.Time `json:\"created\"`\n\tUpdated time.Time `json:\"updated\"`\n\t// true if the current user submitted this fingerprint\n\tUserSubmitted bool `json:\"user_submitted\"`\n\t// true if the current user reported this fingerprint\n\tUserReported bool `json:\"user_reported\"`\n}\n\ntype FingerprintEditInput struct {\n\tUserIds     []string             `json:\"user_ids,omitempty\"`\n\tHash        string               `json:\"hash\"`\n\tAlgorithm   FingerprintAlgorithm `json:\"algorithm\"`\n\tDuration    int                  `json:\"duration\"`\n\tCreated     time.Time            `json:\"created\"`\n\tSubmissions *int                 `json:\"submissions,omitempty\"`\n\tUpdated     *time.Time           `json:\"updated,omitempty\"`\n}\n\ntype FingerprintInput struct {\n\t// assumes current user if omitted. Ignored for non-modify Users\n\tUserIds   []string             `json:\"user_ids,omitempty\"`\n\tHash      string               `json:\"hash\"`\n\tAlgorithm FingerprintAlgorithm `json:\"algorithm\"`\n\tDuration  int                  `json:\"duration\"`\n}\n\ntype FingerprintQueryInput struct {\n\tHash      string               `json:\"hash\"`\n\tAlgorithm FingerprintAlgorithm `json:\"algorithm\"`\n}\n\ntype FingerprintSubmission struct {\n\tSceneID     string                     `json:\"scene_id\"`\n\tFingerprint *FingerprintInput          `json:\"fingerprint\"`\n\tUnmatch     *bool                      `json:\"unmatch,omitempty\"`\n\tVote        *FingerprintSubmissionType `json:\"vote,omitempty\"`\n}\n\ntype FingerprintedSceneEdit struct {\n\tEdit *Edit `json:\"edit\"`\n}\n\nfunc (FingerprintedSceneEdit) IsNotificationData() {}\n\ntype FuzzyDate struct {\n\tDate     string           `json:\"date\"`\n\tAccuracy DateAccuracyEnum `json:\"accuracy\"`\n}\n\ntype GenerateInviteCodeInput struct {\n\tKeys *int `json:\"keys,omitempty\"`\n\tUses *int `json:\"uses,omitempty\"`\n\tTTL  *int `json:\"ttl,omitempty\"`\n}\n\ntype GrantInviteInput struct {\n\tUserID string `json:\"user_id\"`\n\tAmount int    `json:\"amount\"`\n}\n\ntype HairColorCriterionInput struct {\n\tValue    *HairColorEnum    `json:\"value,omitempty\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype IDCriterionInput struct {\n\tValue    []string          `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype Image struct {\n\tID     string `json:\"id\"`\n\tURL    string `json:\"url\"`\n\tWidth  int    `json:\"width\"`\n\tHeight int    `json:\"height\"`\n}\n\ntype ImageCreateInput struct {\n\tURL  *string         `json:\"url,omitempty\"`\n\tFile *graphql.Upload `json:\"file,omitempty\"`\n}\n\ntype ImageDestroyInput struct {\n\tID string `json:\"id\"`\n}\n\ntype ImageUpdateInput struct {\n\tID  string  `json:\"id\"`\n\tURL *string `json:\"url,omitempty\"`\n}\n\ntype IntCriterionInput struct {\n\tValue    int               `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype InviteKey struct {\n\tID      string     `json:\"id\"`\n\tUses    *int       `json:\"uses,omitempty\"`\n\tExpires *time.Time `json:\"expires,omitempty\"`\n}\n\ntype MarkNotificationReadInput struct {\n\tType NotificationEnum `json:\"type\"`\n\tID   string           `json:\"id\"`\n}\n\ntype Measurements struct {\n\tCupSize  *string `json:\"cup_size,omitempty\"`\n\tBandSize *int    `json:\"band_size,omitempty\"`\n\tWaist    *int    `json:\"waist,omitempty\"`\n\tHip      *int    `json:\"hip,omitempty\"`\n}\n\ntype MultiIDCriterionInput struct {\n\tValue    []string          `json:\"value,omitempty\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype MultiStringCriterionInput struct {\n\tValue    []string          `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype Mutation struct {\n}\n\ntype NewUserInput struct {\n\tEmail     string  `json:\"email\"`\n\tInviteKey *string `json:\"invite_key,omitempty\"`\n}\n\ntype Notification struct {\n\tCreated time.Time        `json:\"created\"`\n\tRead    bool             `json:\"read\"`\n\tData    NotificationData `json:\"data\"`\n}\n\ntype Performer struct {\n\tID             string         `json:\"id\"`\n\tName           string         `json:\"name\"`\n\tDisambiguation *string        `json:\"disambiguation,omitempty\"`\n\tAliases        []string       `json:\"aliases\"`\n\tGender         *GenderEnum    `json:\"gender,omitempty\"`\n\tUrls           []*URL         `json:\"urls\"`\n\tBirthdate      *FuzzyDate     `json:\"birthdate,omitempty\"`\n\tBirthDate      *string        `json:\"birth_date,omitempty\"`\n\tDeathDate      *string        `json:\"death_date,omitempty\"`\n\tAge            *int           `json:\"age,omitempty\"`\n\tEthnicity      *EthnicityEnum `json:\"ethnicity,omitempty\"`\n\tCountry        *string        `json:\"country,omitempty\"`\n\tEyeColor       *EyeColorEnum  `json:\"eye_color,omitempty\"`\n\tHairColor      *HairColorEnum `json:\"hair_color,omitempty\"`\n\t// Height in cm\n\tHeight          *int                `json:\"height,omitempty\"`\n\tMeasurements    *Measurements       `json:\"measurements\"`\n\tCupSize         *string             `json:\"cup_size,omitempty\"`\n\tBandSize        *int                `json:\"band_size,omitempty\"`\n\tWaistSize       *int                `json:\"waist_size,omitempty\"`\n\tHipSize         *int                `json:\"hip_size,omitempty\"`\n\tBreastType      *BreastTypeEnum     `json:\"breast_type,omitempty\"`\n\tCareerStartYear *int                `json:\"career_start_year,omitempty\"`\n\tCareerEndYear   *int                `json:\"career_end_year,omitempty\"`\n\tTattoos         []*BodyModification `json:\"tattoos,omitempty\"`\n\tPiercings       []*BodyModification `json:\"piercings,omitempty\"`\n\tImages          []*Image            `json:\"images\"`\n\tDeleted         bool                `json:\"deleted\"`\n\tEdits           []*Edit             `json:\"edits\"`\n\tSceneCount      int                 `json:\"scene_count\"`\n\tScenes          []*Scene            `json:\"scenes\"`\n\t// IDs of performers that were merged into this one\n\tMergedIds []string `json:\"merged_ids\"`\n\t// ID of performer that replaces this one\n\tMergedIntoID *string            `json:\"merged_into_id,omitempty\"`\n\tStudios      []*PerformerStudio `json:\"studios\"`\n\tIsFavorite   bool               `json:\"is_favorite\"`\n\tCreated      time.Time          `json:\"created\"`\n\tUpdated      time.Time          `json:\"updated\"`\n}\n\nfunc (Performer) IsEditTarget() {}\n\nfunc (Performer) IsSceneDraftPerformer() {}\n\ntype PerformerAppearance struct {\n\tPerformer *Performer `json:\"performer\"`\n\t// Performing as alias\n\tAs *string `json:\"as,omitempty\"`\n}\n\ntype PerformerAppearanceInput struct {\n\tPerformerID string `json:\"performer_id\"`\n\t// Performing as alias\n\tAs *string `json:\"as,omitempty\"`\n}\n\ntype PerformerCreateInput struct {\n\tName            string                   `json:\"name\"`\n\tDisambiguation  *string                  `json:\"disambiguation,omitempty\"`\n\tAliases         []string                 `json:\"aliases,omitempty\"`\n\tGender          *GenderEnum              `json:\"gender,omitempty\"`\n\tUrls            []*URLInput              `json:\"urls,omitempty\"`\n\tBirthdate       *string                  `json:\"birthdate,omitempty\"`\n\tDeathdate       *string                  `json:\"deathdate,omitempty\"`\n\tEthnicity       *EthnicityEnum           `json:\"ethnicity,omitempty\"`\n\tCountry         *string                  `json:\"country,omitempty\"`\n\tEyeColor        *EyeColorEnum            `json:\"eye_color,omitempty\"`\n\tHairColor       *HairColorEnum           `json:\"hair_color,omitempty\"`\n\tHeight          *int                     `json:\"height,omitempty\"`\n\tCupSize         *string                  `json:\"cup_size,omitempty\"`\n\tBandSize        *int                     `json:\"band_size,omitempty\"`\n\tWaistSize       *int                     `json:\"waist_size,omitempty\"`\n\tHipSize         *int                     `json:\"hip_size,omitempty\"`\n\tBreastType      *BreastTypeEnum          `json:\"breast_type,omitempty\"`\n\tCareerStartYear *int                     `json:\"career_start_year,omitempty\"`\n\tCareerEndYear   *int                     `json:\"career_end_year,omitempty\"`\n\tTattoos         []*BodyModificationInput `json:\"tattoos,omitempty\"`\n\tPiercings       []*BodyModificationInput `json:\"piercings,omitempty\"`\n\tImageIds        []string                 `json:\"image_ids,omitempty\"`\n\tDraftID         *string                  `json:\"draft_id,omitempty\"`\n}\n\ntype PerformerDestroyInput struct {\n\tID string `json:\"id\"`\n}\n\ntype PerformerDraft struct {\n\tID              *string  `json:\"id,omitempty\"`\n\tName            string   `json:\"name\"`\n\tDisambiguation  *string  `json:\"disambiguation,omitempty\"`\n\tAliases         *string  `json:\"aliases,omitempty\"`\n\tGender          *string  `json:\"gender,omitempty\"`\n\tBirthdate       *string  `json:\"birthdate,omitempty\"`\n\tDeathdate       *string  `json:\"deathdate,omitempty\"`\n\tUrls            []string `json:\"urls,omitempty\"`\n\tEthnicity       *string  `json:\"ethnicity,omitempty\"`\n\tCountry         *string  `json:\"country,omitempty\"`\n\tEyeColor        *string  `json:\"eye_color,omitempty\"`\n\tHairColor       *string  `json:\"hair_color,omitempty\"`\n\tHeight          *string  `json:\"height,omitempty\"`\n\tMeasurements    *string  `json:\"measurements,omitempty\"`\n\tBreastType      *string  `json:\"breast_type,omitempty\"`\n\tTattoos         *string  `json:\"tattoos,omitempty\"`\n\tPiercings       *string  `json:\"piercings,omitempty\"`\n\tCareerStartYear *int     `json:\"career_start_year,omitempty\"`\n\tCareerEndYear   *int     `json:\"career_end_year,omitempty\"`\n\tImage           *Image   `json:\"image,omitempty\"`\n}\n\nfunc (PerformerDraft) IsDraftData() {}\n\ntype PerformerDraftInput struct {\n\tID              *string         `json:\"id,omitempty\"`\n\tDisambiguation  *string         `json:\"disambiguation,omitempty\"`\n\tName            string          `json:\"name\"`\n\tAliases         *string         `json:\"aliases,omitempty\"`\n\tGender          *string         `json:\"gender,omitempty\"`\n\tBirthdate       *string         `json:\"birthdate,omitempty\"`\n\tDeathdate       *string         `json:\"deathdate,omitempty\"`\n\tUrls            []string        `json:\"urls,omitempty\"`\n\tEthnicity       *string         `json:\"ethnicity,omitempty\"`\n\tCountry         *string         `json:\"country,omitempty\"`\n\tEyeColor        *string         `json:\"eye_color,omitempty\"`\n\tHairColor       *string         `json:\"hair_color,omitempty\"`\n\tHeight          *string         `json:\"height,omitempty\"`\n\tMeasurements    *string         `json:\"measurements,omitempty\"`\n\tBreastType      *string         `json:\"breast_type,omitempty\"`\n\tTattoos         *string         `json:\"tattoos,omitempty\"`\n\tPiercings       *string         `json:\"piercings,omitempty\"`\n\tCareerStartYear *int            `json:\"career_start_year,omitempty\"`\n\tCareerEndYear   *int            `json:\"career_end_year,omitempty\"`\n\tImage           *graphql.Upload `json:\"image,omitempty\"`\n}\n\ntype PerformerEdit struct {\n\tName           *string        `json:\"name,omitempty\"`\n\tDisambiguation *string        `json:\"disambiguation,omitempty\"`\n\tAddedAliases   []string       `json:\"added_aliases,omitempty\"`\n\tRemovedAliases []string       `json:\"removed_aliases,omitempty\"`\n\tGender         *GenderEnum    `json:\"gender,omitempty\"`\n\tAddedUrls      []*URL         `json:\"added_urls,omitempty\"`\n\tRemovedUrls    []*URL         `json:\"removed_urls,omitempty\"`\n\tBirthdate      *string        `json:\"birthdate,omitempty\"`\n\tDeathdate      *string        `json:\"deathdate,omitempty\"`\n\tEthnicity      *EthnicityEnum `json:\"ethnicity,omitempty\"`\n\tCountry        *string        `json:\"country,omitempty\"`\n\tEyeColor       *EyeColorEnum  `json:\"eye_color,omitempty\"`\n\tHairColor      *HairColorEnum `json:\"hair_color,omitempty\"`\n\t// Height in cm\n\tHeight           *int                `json:\"height,omitempty\"`\n\tCupSize          *string             `json:\"cup_size,omitempty\"`\n\tBandSize         *int                `json:\"band_size,omitempty\"`\n\tWaistSize        *int                `json:\"waist_size,omitempty\"`\n\tHipSize          *int                `json:\"hip_size,omitempty\"`\n\tBreastType       *BreastTypeEnum     `json:\"breast_type,omitempty\"`\n\tCareerStartYear  *int                `json:\"career_start_year,omitempty\"`\n\tCareerEndYear    *int                `json:\"career_end_year,omitempty\"`\n\tAddedTattoos     []*BodyModification `json:\"added_tattoos,omitempty\"`\n\tRemovedTattoos   []*BodyModification `json:\"removed_tattoos,omitempty\"`\n\tAddedPiercings   []*BodyModification `json:\"added_piercings,omitempty\"`\n\tRemovedPiercings []*BodyModification `json:\"removed_piercings,omitempty\"`\n\tAddedImages      []*Image            `json:\"added_images,omitempty\"`\n\tRemovedImages    []*Image            `json:\"removed_images,omitempty\"`\n\tDraftID          *string             `json:\"draft_id,omitempty\"`\n\tAliases          []string            `json:\"aliases\"`\n\tUrls             []*URL              `json:\"urls\"`\n\tImages           []*Image            `json:\"images\"`\n\tTattoos          []*BodyModification `json:\"tattoos\"`\n\tPiercings        []*BodyModification `json:\"piercings\"`\n}\n\nfunc (PerformerEdit) IsEditDetails() {}\n\ntype PerformerEditDetailsInput struct {\n\tName            *string                  `json:\"name,omitempty\"`\n\tDisambiguation  *string                  `json:\"disambiguation,omitempty\"`\n\tAliases         []string                 `json:\"aliases,omitempty\"`\n\tGender          *GenderEnum              `json:\"gender,omitempty\"`\n\tUrls            []*URLInput              `json:\"urls,omitempty\"`\n\tBirthdate       *string                  `json:\"birthdate,omitempty\"`\n\tDeathdate       *string                  `json:\"deathdate,omitempty\"`\n\tEthnicity       *EthnicityEnum           `json:\"ethnicity,omitempty\"`\n\tCountry         *string                  `json:\"country,omitempty\"`\n\tEyeColor        *EyeColorEnum            `json:\"eye_color,omitempty\"`\n\tHairColor       *HairColorEnum           `json:\"hair_color,omitempty\"`\n\tHeight          *int                     `json:\"height,omitempty\"`\n\tCupSize         *string                  `json:\"cup_size,omitempty\"`\n\tBandSize        *int                     `json:\"band_size,omitempty\"`\n\tWaistSize       *int                     `json:\"waist_size,omitempty\"`\n\tHipSize         *int                     `json:\"hip_size,omitempty\"`\n\tBreastType      *BreastTypeEnum          `json:\"breast_type,omitempty\"`\n\tCareerStartYear *int                     `json:\"career_start_year,omitempty\"`\n\tCareerEndYear   *int                     `json:\"career_end_year,omitempty\"`\n\tTattoos         []*BodyModificationInput `json:\"tattoos,omitempty\"`\n\tPiercings       []*BodyModificationInput `json:\"piercings,omitempty\"`\n\tImageIds        []string                 `json:\"image_ids,omitempty\"`\n\tDraftID         *string                  `json:\"draft_id,omitempty\"`\n}\n\ntype PerformerEditInput struct {\n\tEdit *EditInput `json:\"edit\"`\n\t// Not required for destroy type\n\tDetails *PerformerEditDetailsInput `json:\"details,omitempty\"`\n\t// Controls aliases modification for merges and name modifications\n\tOptions *PerformerEditOptionsInput `json:\"options,omitempty\"`\n}\n\ntype PerformerEditOptions struct {\n\t// Set performer alias on scenes without alias to old name if name is changed\n\tSetModifyAliases bool `json:\"set_modify_aliases\"`\n\t// Set performer alias on scenes attached to merge sources to old name\n\tSetMergeAliases bool `json:\"set_merge_aliases\"`\n}\n\ntype PerformerEditOptionsInput struct {\n\t// Set performer alias on scenes without alias to old name if name is changed\n\tSetModifyAliases *bool `json:\"set_modify_aliases,omitempty\"`\n\t// Set performer alias on scenes attached to merge sources to old name\n\tSetMergeAliases *bool `json:\"set_merge_aliases,omitempty\"`\n}\n\ntype PerformerQueryInput struct {\n\t// Searches name and disambiguation - assumes like query unless quoted\n\tNames *string `json:\"names,omitempty\"`\n\t// Searches name only - assumes like query unless quoted\n\tName *string `json:\"name,omitempty\"`\n\t// Search aliases only - assumes like query unless quoted\n\tAlias          *string               `json:\"alias,omitempty\"`\n\tDisambiguation *StringCriterionInput `json:\"disambiguation,omitempty\"`\n\tGender         *GenderFilterEnum     `json:\"gender,omitempty\"`\n\t// Filter to search urls - assumes like query unless quoted\n\tURL             *string                         `json:\"url,omitempty\"`\n\tBirthdate       *DateCriterionInput             `json:\"birthdate,omitempty\"`\n\tDeathdate       *DateCriterionInput             `json:\"deathdate,omitempty\"`\n\tBirthYear       *IntCriterionInput              `json:\"birth_year,omitempty\"`\n\tAge             *IntCriterionInput              `json:\"age,omitempty\"`\n\tEthnicity       *EthnicityFilterEnum            `json:\"ethnicity,omitempty\"`\n\tCountry         *StringCriterionInput           `json:\"country,omitempty\"`\n\tEyeColor        *EyeColorCriterionInput         `json:\"eye_color,omitempty\"`\n\tHairColor       *HairColorCriterionInput        `json:\"hair_color,omitempty\"`\n\tHeight          *IntCriterionInput              `json:\"height,omitempty\"`\n\tCupSize         *StringCriterionInput           `json:\"cup_size,omitempty\"`\n\tBandSize        *IntCriterionInput              `json:\"band_size,omitempty\"`\n\tWaistSize       *IntCriterionInput              `json:\"waist_size,omitempty\"`\n\tHipSize         *IntCriterionInput              `json:\"hip_size,omitempty\"`\n\tBreastType      *BreastTypeCriterionInput       `json:\"breast_type,omitempty\"`\n\tCareerStartYear *IntCriterionInput              `json:\"career_start_year,omitempty\"`\n\tCareerEndYear   *IntCriterionInput              `json:\"career_end_year,omitempty\"`\n\tTattoos         *BodyModificationCriterionInput `json:\"tattoos,omitempty\"`\n\tPiercings       *BodyModificationCriterionInput `json:\"piercings,omitempty\"`\n\t// Filter by performerfavorite status for the current user\n\tIsFavorite *bool `json:\"is_favorite,omitempty\"`\n\t// Filter by a performer they have performed in scenes with\n\tPerformedWith *string `json:\"performed_with,omitempty\"`\n\t// Filter by a studio\n\tStudioID  *string           `json:\"studio_id,omitempty\"`\n\tPage      int               `json:\"page\"`\n\tPerPage   int               `json:\"per_page\"`\n\tDirection SortDirectionEnum `json:\"direction\"`\n\tSort      PerformerSortEnum `json:\"sort\"`\n}\n\ntype PerformerScenesInput struct {\n\t// Filter by another performer that also performs in the scenes\n\tPerformedWith *string `json:\"performed_with,omitempty\"`\n\t// Filter by a studio\n\tStudioID *string `json:\"studio_id,omitempty\"`\n\t// Filter by tags\n\tTags *MultiIDCriterionInput `json:\"tags,omitempty\"`\n}\n\ntype PerformerStudio struct {\n\tStudio     *Studio `json:\"studio\"`\n\tSceneCount int     `json:\"scene_count\"`\n}\n\ntype PerformerUpdateInput struct {\n\tID              string                   `json:\"id\"`\n\tName            *string                  `json:\"name,omitempty\"`\n\tDisambiguation  *string                  `json:\"disambiguation,omitempty\"`\n\tAliases         []string                 `json:\"aliases,omitempty\"`\n\tGender          *GenderEnum              `json:\"gender,omitempty\"`\n\tUrls            []*URLInput              `json:\"urls,omitempty\"`\n\tBirthdate       *string                  `json:\"birthdate,omitempty\"`\n\tDeathdate       *string                  `json:\"deathdate,omitempty\"`\n\tEthnicity       *EthnicityEnum           `json:\"ethnicity,omitempty\"`\n\tCountry         *string                  `json:\"country,omitempty\"`\n\tEyeColor        *EyeColorEnum            `json:\"eye_color,omitempty\"`\n\tHairColor       *HairColorEnum           `json:\"hair_color,omitempty\"`\n\tHeight          *int                     `json:\"height,omitempty\"`\n\tCupSize         *string                  `json:\"cup_size,omitempty\"`\n\tBandSize        *int                     `json:\"band_size,omitempty\"`\n\tWaistSize       *int                     `json:\"waist_size,omitempty\"`\n\tHipSize         *int                     `json:\"hip_size,omitempty\"`\n\tBreastType      *BreastTypeEnum          `json:\"breast_type,omitempty\"`\n\tCareerStartYear *int                     `json:\"career_start_year,omitempty\"`\n\tCareerEndYear   *int                     `json:\"career_end_year,omitempty\"`\n\tTattoos         []*BodyModificationInput `json:\"tattoos,omitempty\"`\n\tPiercings       []*BodyModificationInput `json:\"piercings,omitempty\"`\n\tImageIds        []string                 `json:\"image_ids,omitempty\"`\n}\n\n// The query root for this schema\ntype Query struct {\n}\n\ntype QueryEditsResultType struct {\n\tCount int     `json:\"count\"`\n\tEdits []*Edit `json:\"edits\"`\n}\n\ntype QueryExistingPerformerInput struct {\n\tName           *string  `json:\"name,omitempty\"`\n\tDisambiguation *string  `json:\"disambiguation,omitempty\"`\n\tUrls           []string `json:\"urls\"`\n}\n\ntype QueryExistingPerformerResult struct {\n\tEdits      []*Edit      `json:\"edits\"`\n\tPerformers []*Performer `json:\"performers\"`\n}\n\ntype QueryExistingSceneInput struct {\n\tTitle        *string             `json:\"title,omitempty\"`\n\tStudioID     *string             `json:\"studio_id,omitempty\"`\n\tFingerprints []*FingerprintInput `json:\"fingerprints\"`\n}\n\ntype QueryExistingSceneResult struct {\n\tEdits  []*Edit  `json:\"edits\"`\n\tScenes []*Scene `json:\"scenes\"`\n}\n\ntype QueryNotificationsInput struct {\n\tPage       int               `json:\"page\"`\n\tPerPage    int               `json:\"per_page\"`\n\tType       *NotificationEnum `json:\"type,omitempty\"`\n\tUnreadOnly *bool             `json:\"unread_only,omitempty\"`\n}\n\ntype QueryNotificationsResult struct {\n\tCount         int             `json:\"count\"`\n\tNotifications []*Notification `json:\"notifications\"`\n}\n\ntype QueryPerformersResultType struct {\n\tCount      int          `json:\"count\"`\n\tPerformers []*Performer `json:\"performers\"`\n}\n\ntype QueryScenesResultType struct {\n\tCount  int      `json:\"count\"`\n\tScenes []*Scene `json:\"scenes\"`\n}\n\ntype QuerySitesResultType struct {\n\tCount int     `json:\"count\"`\n\tSites []*Site `json:\"sites\"`\n}\n\ntype QueryStudiosResultType struct {\n\tCount   int       `json:\"count\"`\n\tStudios []*Studio `json:\"studios\"`\n}\n\ntype QueryTagCategoriesResultType struct {\n\tCount         int            `json:\"count\"`\n\tTagCategories []*TagCategory `json:\"tag_categories\"`\n}\n\ntype QueryTagsResultType struct {\n\tCount int    `json:\"count\"`\n\tTags  []*Tag `json:\"tags\"`\n}\n\ntype QueryUsersResultType struct {\n\tCount int     `json:\"count\"`\n\tUsers []*User `json:\"users\"`\n}\n\ntype ResetPasswordInput struct {\n\tEmail string `json:\"email\"`\n}\n\ntype RevokeInviteInput struct {\n\tUserID string `json:\"user_id\"`\n\tAmount int    `json:\"amount\"`\n}\n\ntype RoleCriterionInput struct {\n\tValue    []RoleEnum        `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype Scene struct {\n\tID             string                 `json:\"id\"`\n\tTitle          *string                `json:\"title,omitempty\"`\n\tDetails        *string                `json:\"details,omitempty\"`\n\tDate           *string                `json:\"date,omitempty\"`\n\tReleaseDate    *string                `json:\"release_date,omitempty\"`\n\tProductionDate *string                `json:\"production_date,omitempty\"`\n\tUrls           []*URL                 `json:\"urls\"`\n\tStudio         *Studio                `json:\"studio,omitempty\"`\n\tTags           []*Tag                 `json:\"tags\"`\n\tImages         []*Image               `json:\"images\"`\n\tPerformers     []*PerformerAppearance `json:\"performers\"`\n\tFingerprints   []*Fingerprint         `json:\"fingerprints\"`\n\tDuration       *int                   `json:\"duration,omitempty\"`\n\tDirector       *string                `json:\"director,omitempty\"`\n\tCode           *string                `json:\"code,omitempty\"`\n\tDeleted        bool                   `json:\"deleted\"`\n\tEdits          []*Edit                `json:\"edits\"`\n\tCreated        time.Time              `json:\"created\"`\n\tUpdated        time.Time              `json:\"updated\"`\n}\n\nfunc (Scene) IsEditTarget() {}\n\ntype SceneCreateInput struct {\n\tTitle          *string                     `json:\"title,omitempty\"`\n\tDetails        *string                     `json:\"details,omitempty\"`\n\tUrls           []*URLInput                 `json:\"urls,omitempty\"`\n\tDate           string                      `json:\"date\"`\n\tProductionDate *string                     `json:\"production_date,omitempty\"`\n\tStudioID       *string                     `json:\"studio_id,omitempty\"`\n\tPerformers     []*PerformerAppearanceInput `json:\"performers,omitempty\"`\n\tTagIds         []string                    `json:\"tag_ids,omitempty\"`\n\tImageIds       []string                    `json:\"image_ids,omitempty\"`\n\tFingerprints   []*FingerprintEditInput     `json:\"fingerprints\"`\n\tDuration       *int                        `json:\"duration,omitempty\"`\n\tDirector       *string                     `json:\"director,omitempty\"`\n\tCode           *string                     `json:\"code,omitempty\"`\n}\n\ntype SceneDestroyInput struct {\n\tID string `json:\"id\"`\n}\n\ntype SceneDraft struct {\n\tID             *string               `json:\"id,omitempty\"`\n\tTitle          *string               `json:\"title,omitempty\"`\n\tCode           *string               `json:\"code,omitempty\"`\n\tDetails        *string               `json:\"details,omitempty\"`\n\tDirector       *string               `json:\"director,omitempty\"`\n\tUrls           []string              `json:\"urls,omitempty\"`\n\tDate           *string               `json:\"date,omitempty\"`\n\tProductionDate *string               `json:\"production_date,omitempty\"`\n\tStudio         SceneDraftStudio      `json:\"studio,omitempty\"`\n\tPerformers     []SceneDraftPerformer `json:\"performers\"`\n\tTags           []SceneDraftTag       `json:\"tags,omitempty\"`\n\tImage          *Image                `json:\"image,omitempty\"`\n\tFingerprints   []*DraftFingerprint   `json:\"fingerprints\"`\n}\n\nfunc (SceneDraft) IsDraftData() {}\n\ntype SceneDraftInput struct {\n\tID             *string             `json:\"id,omitempty\"`\n\tTitle          *string             `json:\"title,omitempty\"`\n\tCode           *string             `json:\"code,omitempty\"`\n\tDetails        *string             `json:\"details,omitempty\"`\n\tDirector       *string             `json:\"director,omitempty\"`\n\tURL            *string             `json:\"url,omitempty\"`\n\tUrls           []string            `json:\"urls,omitempty\"`\n\tDate           *string             `json:\"date,omitempty\"`\n\tProductionDate *string             `json:\"production_date,omitempty\"`\n\tStudio         *DraftEntityInput   `json:\"studio,omitempty\"`\n\tPerformers     []*DraftEntityInput `json:\"performers\"`\n\tTags           []*DraftEntityInput `json:\"tags,omitempty\"`\n\tImage          *graphql.Upload     `json:\"image,omitempty\"`\n\tFingerprints   []*FingerprintInput `json:\"fingerprints\"`\n}\n\ntype SceneEdit struct {\n\tTitle          *string `json:\"title,omitempty\"`\n\tDetails        *string `json:\"details,omitempty\"`\n\tAddedUrls      []*URL  `json:\"added_urls,omitempty\"`\n\tRemovedUrls    []*URL  `json:\"removed_urls,omitempty\"`\n\tDate           *string `json:\"date,omitempty\"`\n\tProductionDate *string `json:\"production_date,omitempty\"`\n\tStudio         *Studio `json:\"studio,omitempty\"`\n\t// Added or modified performer appearance entries\n\tAddedPerformers     []*PerformerAppearance `json:\"added_performers,omitempty\"`\n\tRemovedPerformers   []*PerformerAppearance `json:\"removed_performers,omitempty\"`\n\tAddedTags           []*Tag                 `json:\"added_tags,omitempty\"`\n\tRemovedTags         []*Tag                 `json:\"removed_tags,omitempty\"`\n\tAddedImages         []*Image               `json:\"added_images,omitempty\"`\n\tRemovedImages       []*Image               `json:\"removed_images,omitempty\"`\n\tAddedFingerprints   []*Fingerprint         `json:\"added_fingerprints,omitempty\"`\n\tRemovedFingerprints []*Fingerprint         `json:\"removed_fingerprints,omitempty\"`\n\tDuration            *int                   `json:\"duration,omitempty\"`\n\tDirector            *string                `json:\"director,omitempty\"`\n\tCode                *string                `json:\"code,omitempty\"`\n\tDraftID             *string                `json:\"draft_id,omitempty\"`\n\tUrls                []*URL                 `json:\"urls\"`\n\tPerformers          []*PerformerAppearance `json:\"performers\"`\n\tTags                []*Tag                 `json:\"tags\"`\n\tImages              []*Image               `json:\"images\"`\n\tFingerprints        []*Fingerprint         `json:\"fingerprints\"`\n}\n\nfunc (SceneEdit) IsEditDetails() {}\n\ntype SceneEditDetailsInput struct {\n\tTitle          *string                     `json:\"title,omitempty\"`\n\tDetails        *string                     `json:\"details,omitempty\"`\n\tUrls           []*URLInput                 `json:\"urls,omitempty\"`\n\tDate           *string                     `json:\"date,omitempty\"`\n\tProductionDate *string                     `json:\"production_date,omitempty\"`\n\tStudioID       *string                     `json:\"studio_id,omitempty\"`\n\tPerformers     []*PerformerAppearanceInput `json:\"performers,omitempty\"`\n\tTagIds         []string                    `json:\"tag_ids,omitempty\"`\n\tImageIds       []string                    `json:\"image_ids,omitempty\"`\n\tDuration       *int                        `json:\"duration,omitempty\"`\n\tDirector       *string                     `json:\"director,omitempty\"`\n\tCode           *string                     `json:\"code,omitempty\"`\n\tFingerprints   []*FingerprintInput         `json:\"fingerprints,omitempty\"`\n\tDraftID        *string                     `json:\"draft_id,omitempty\"`\n}\n\ntype SceneEditInput struct {\n\tEdit *EditInput `json:\"edit\"`\n\t// Not required for destroy type\n\tDetails *SceneEditDetailsInput `json:\"details,omitempty\"`\n}\n\ntype SceneQueryInput struct {\n\t// Filter to search title and details - assumes like query unless quoted\n\tText *string `json:\"text,omitempty\"`\n\t// Filter to search title - assumes like query unless quoted\n\tTitle *string `json:\"title,omitempty\"`\n\t// Filter to search urls - assumes like query unless quoted\n\tURL *string `json:\"url,omitempty\"`\n\t// Filter by date\n\tDate *DateCriterionInput `json:\"date,omitempty\"`\n\t// Filter by production date\n\tProductionDate *DateCriterionInput `json:\"production_date,omitempty\"`\n\t// Filter to only include scenes with this studio\n\tStudios *MultiIDCriterionInput `json:\"studios,omitempty\"`\n\t// Filter to only include scenes with this studio as primary or parent\n\tParentStudio *string `json:\"parentStudio,omitempty\"`\n\t// Filter to only include scenes with these tags\n\tTags *MultiIDCriterionInput `json:\"tags,omitempty\"`\n\t// Filter to only include scenes with these performers\n\tPerformers *MultiIDCriterionInput `json:\"performers,omitempty\"`\n\t// Filter to include scenes with performer appearing as alias\n\tAlias *StringCriterionInput `json:\"alias,omitempty\"`\n\t// Filter to only include scenes with these fingerprints\n\tFingerprints *MultiStringCriterionInput `json:\"fingerprints,omitempty\"`\n\t// Filter by favorited entity\n\tFavorites *FavoriteFilter `json:\"favorites,omitempty\"`\n\t// Filter to scenes with fingerprints submitted by the user\n\tHasFingerprintSubmissions *bool             `json:\"has_fingerprint_submissions,omitempty\"`\n\tPage                      int               `json:\"page\"`\n\tPerPage                   int               `json:\"per_page\"`\n\tDirection                 SortDirectionEnum `json:\"direction\"`\n\tSort                      SceneSortEnum     `json:\"sort\"`\n}\n\ntype SceneUpdateInput struct {\n\tID             string                      `json:\"id\"`\n\tTitle          *string                     `json:\"title,omitempty\"`\n\tDetails        *string                     `json:\"details,omitempty\"`\n\tUrls           []*URLInput                 `json:\"urls,omitempty\"`\n\tDate           *string                     `json:\"date,omitempty\"`\n\tProductionDate *string                     `json:\"production_date,omitempty\"`\n\tStudioID       *string                     `json:\"studio_id,omitempty\"`\n\tPerformers     []*PerformerAppearanceInput `json:\"performers,omitempty\"`\n\tTagIds         []string                    `json:\"tag_ids,omitempty\"`\n\tImageIds       []string                    `json:\"image_ids,omitempty\"`\n\tFingerprints   []*FingerprintEditInput     `json:\"fingerprints,omitempty\"`\n\tDuration       *int                        `json:\"duration,omitempty\"`\n\tDirector       *string                     `json:\"director,omitempty\"`\n\tCode           *string                     `json:\"code,omitempty\"`\n}\n\ntype Site struct {\n\tID          string              `json:\"id\"`\n\tName        string              `json:\"name\"`\n\tDescription *string             `json:\"description,omitempty\"`\n\tURL         *string             `json:\"url,omitempty\"`\n\tRegex       *string             `json:\"regex,omitempty\"`\n\tValidTypes  []ValidSiteTypeEnum `json:\"valid_types\"`\n\tIcon        string              `json:\"icon\"`\n\tCreated     time.Time           `json:\"created\"`\n\tUpdated     time.Time           `json:\"updated\"`\n}\n\ntype SiteCreateInput struct {\n\tName        string              `json:\"name\"`\n\tDescription *string             `json:\"description,omitempty\"`\n\tURL         *string             `json:\"url,omitempty\"`\n\tRegex       *string             `json:\"regex,omitempty\"`\n\tValidTypes  []ValidSiteTypeEnum `json:\"valid_types\"`\n}\n\ntype SiteDestroyInput struct {\n\tID string `json:\"id\"`\n}\n\ntype SiteUpdateInput struct {\n\tID          string              `json:\"id\"`\n\tName        string              `json:\"name\"`\n\tDescription *string             `json:\"description,omitempty\"`\n\tURL         *string             `json:\"url,omitempty\"`\n\tRegex       *string             `json:\"regex,omitempty\"`\n\tValidTypes  []ValidSiteTypeEnum `json:\"valid_types\"`\n}\n\ntype StashBoxConfig struct {\n\tHostURL                    string `json:\"host_url\"`\n\tRequireInvite              bool   `json:\"require_invite\"`\n\tRequireActivation          bool   `json:\"require_activation\"`\n\tVotePromotionThreshold     *int   `json:\"vote_promotion_threshold,omitempty\"`\n\tVoteApplicationThreshold   int    `json:\"vote_application_threshold\"`\n\tVotingPeriod               int    `json:\"voting_period\"`\n\tMinDestructiveVotingPeriod int    `json:\"min_destructive_voting_period\"`\n\tVoteCronInterval           string `json:\"vote_cron_interval\"`\n\tGuidelinesURL              string `json:\"guidelines_url\"`\n\tRequireSceneDraft          bool   `json:\"require_scene_draft\"`\n\tEditUpdateLimit            int    `json:\"edit_update_limit\"`\n\tRequireTagRole             bool   `json:\"require_tag_role\"`\n}\n\ntype StringCriterionInput struct {\n\tValue    string            `json:\"value\"`\n\tModifier CriterionModifier `json:\"modifier\"`\n}\n\ntype Studio struct {\n\tID           string                     `json:\"id\"`\n\tName         string                     `json:\"name\"`\n\tAliases      []string                   `json:\"aliases\"`\n\tUrls         []*URL                     `json:\"urls\"`\n\tParent       *Studio                    `json:\"parent,omitempty\"`\n\tChildStudios []*Studio                  `json:\"child_studios\"`\n\tImages       []*Image                   `json:\"images\"`\n\tDeleted      bool                       `json:\"deleted\"`\n\tIsFavorite   bool                       `json:\"is_favorite\"`\n\tCreated      time.Time                  `json:\"created\"`\n\tUpdated      time.Time                  `json:\"updated\"`\n\tPerformers   *QueryPerformersResultType `json:\"performers\"`\n}\n\nfunc (Studio) IsEditTarget() {}\n\nfunc (Studio) IsSceneDraftStudio() {}\n\ntype StudioCreateInput struct {\n\tName     string      `json:\"name\"`\n\tAliases  []string    `json:\"aliases,omitempty\"`\n\tUrls     []*URLInput `json:\"urls,omitempty\"`\n\tParentID *string     `json:\"parent_id,omitempty\"`\n\tImageIds []string    `json:\"image_ids,omitempty\"`\n}\n\ntype StudioDestroyInput struct {\n\tID string `json:\"id\"`\n}\n\ntype StudioEdit struct {\n\tName *string `json:\"name,omitempty\"`\n\t// Added and modified URLs\n\tAddedUrls      []*URL   `json:\"added_urls,omitempty\"`\n\tRemovedUrls    []*URL   `json:\"removed_urls,omitempty\"`\n\tParent         *Studio  `json:\"parent,omitempty\"`\n\tAddedImages    []*Image `json:\"added_images,omitempty\"`\n\tRemovedImages  []*Image `json:\"removed_images,omitempty\"`\n\tAddedAliases   []string `json:\"added_aliases,omitempty\"`\n\tRemovedAliases []string `json:\"removed_aliases,omitempty\"`\n\tImages         []*Image `json:\"images\"`\n\tUrls           []*URL   `json:\"urls\"`\n}\n\nfunc (StudioEdit) IsEditDetails() {}\n\ntype StudioEditDetailsInput struct {\n\tName     *string     `json:\"name,omitempty\"`\n\tAliases  []string    `json:\"aliases,omitempty\"`\n\tUrls     []*URLInput `json:\"urls,omitempty\"`\n\tParentID *string     `json:\"parent_id,omitempty\"`\n\tImageIds []string    `json:\"image_ids,omitempty\"`\n}\n\ntype StudioEditInput struct {\n\tEdit *EditInput `json:\"edit\"`\n\t// Not required for destroy type\n\tDetails *StudioEditDetailsInput `json:\"details,omitempty\"`\n}\n\ntype StudioQueryInput struct {\n\t// Filter to search name - assumes like query unless quoted\n\tName *string `json:\"name,omitempty\"`\n\t// Filter to search studio name, aliases and parent studio name - assumes like query unless quoted\n\tNames *string `json:\"names,omitempty\"`\n\t// Filter to search url - assumes like query unless quoted\n\tURL       *string           `json:\"url,omitempty\"`\n\tParent    *IDCriterionInput `json:\"parent,omitempty\"`\n\tHasParent *bool             `json:\"has_parent,omitempty\"`\n\t// Filter by studio favorite status for the current user\n\tIsFavorite *bool             `json:\"is_favorite,omitempty\"`\n\tPage       int               `json:\"page\"`\n\tPerPage    int               `json:\"per_page\"`\n\tDirection  SortDirectionEnum `json:\"direction\"`\n\tSort       StudioSortEnum    `json:\"sort\"`\n}\n\ntype StudioUpdateInput struct {\n\tID       string      `json:\"id\"`\n\tName     *string     `json:\"name,omitempty\"`\n\tAliases  []string    `json:\"aliases,omitempty\"`\n\tUrls     []*URLInput `json:\"urls,omitempty\"`\n\tParentID *string     `json:\"parent_id,omitempty\"`\n\tImageIds []string    `json:\"image_ids,omitempty\"`\n}\n\ntype Tag struct {\n\tID          string       `json:\"id\"`\n\tName        string       `json:\"name\"`\n\tDescription *string      `json:\"description,omitempty\"`\n\tAliases     []string     `json:\"aliases\"`\n\tDeleted     bool         `json:\"deleted\"`\n\tEdits       []*Edit      `json:\"edits\"`\n\tCategory    *TagCategory `json:\"category,omitempty\"`\n\tCreated     time.Time    `json:\"created\"`\n\tUpdated     time.Time    `json:\"updated\"`\n}\n\nfunc (Tag) IsEditTarget() {}\n\nfunc (Tag) IsSceneDraftTag() {}\n\ntype TagCategory struct {\n\tID          string       `json:\"id\"`\n\tName        string       `json:\"name\"`\n\tGroup       TagGroupEnum `json:\"group\"`\n\tDescription *string      `json:\"description,omitempty\"`\n}\n\ntype TagCategoryCreateInput struct {\n\tName        string       `json:\"name\"`\n\tGroup       TagGroupEnum `json:\"group\"`\n\tDescription *string      `json:\"description,omitempty\"`\n}\n\ntype TagCategoryDestroyInput struct {\n\tID string `json:\"id\"`\n}\n\ntype TagCategoryUpdateInput struct {\n\tID          string        `json:\"id\"`\n\tName        *string       `json:\"name,omitempty\"`\n\tGroup       *TagGroupEnum `json:\"group,omitempty\"`\n\tDescription *string       `json:\"description,omitempty\"`\n}\n\ntype TagCreateInput struct {\n\tName        string   `json:\"name\"`\n\tDescription *string  `json:\"description,omitempty\"`\n\tAliases     []string `json:\"aliases,omitempty\"`\n\tCategoryID  *string  `json:\"category_id,omitempty\"`\n}\n\ntype TagDestroyInput struct {\n\tID string `json:\"id\"`\n}\n\ntype TagEdit struct {\n\tName           *string      `json:\"name,omitempty\"`\n\tDescription    *string      `json:\"description,omitempty\"`\n\tAddedAliases   []string     `json:\"added_aliases,omitempty\"`\n\tRemovedAliases []string     `json:\"removed_aliases,omitempty\"`\n\tCategory       *TagCategory `json:\"category,omitempty\"`\n\tAliases        []string     `json:\"aliases\"`\n}\n\nfunc (TagEdit) IsEditDetails() {}\n\ntype TagEditDetailsInput struct {\n\tName        *string  `json:\"name,omitempty\"`\n\tDescription *string  `json:\"description,omitempty\"`\n\tAliases     []string `json:\"aliases,omitempty\"`\n\tCategoryID  *string  `json:\"category_id,omitempty\"`\n}\n\ntype TagEditInput struct {\n\tEdit *EditInput `json:\"edit\"`\n\t// Not required for destroy type\n\tDetails *TagEditDetailsInput `json:\"details,omitempty\"`\n}\n\ntype TagQueryInput struct {\n\t// Filter to search name, aliases and description - assumes like query unless quoted\n\tText *string `json:\"text,omitempty\"`\n\t// Searches name and aliases - assumes like query unless quoted\n\tNames *string `json:\"names,omitempty\"`\n\t// Filter to search name - assumes like query unless quoted\n\tName *string `json:\"name,omitempty\"`\n\t// Filter to category ID\n\tCategoryID *string           `json:\"category_id,omitempty\"`\n\tPage       int               `json:\"page\"`\n\tPerPage    int               `json:\"per_page\"`\n\tDirection  SortDirectionEnum `json:\"direction\"`\n\tSort       TagSortEnum       `json:\"sort\"`\n}\n\ntype TagUpdateInput struct {\n\tID          string   `json:\"id\"`\n\tName        *string  `json:\"name,omitempty\"`\n\tDescription *string  `json:\"description,omitempty\"`\n\tAliases     []string `json:\"aliases,omitempty\"`\n\tCategoryID  *string  `json:\"category_id,omitempty\"`\n}\n\ntype URL struct {\n\tURL  string `json:\"url\"`\n\tType string `json:\"type\"`\n\tSite *Site  `json:\"site\"`\n}\n\ntype URLInput struct {\n\tURL    string `json:\"url\"`\n\tSiteID string `json:\"site_id\"`\n}\n\ntype UpdatedEdit struct {\n\tEdit *Edit `json:\"edit\"`\n}\n\nfunc (UpdatedEdit) IsNotificationData() {}\n\ntype User struct {\n\tID   string `json:\"id\"`\n\tName string `json:\"name\"`\n\t// Should not be visible to other users\n\tRoles []RoleEnum `json:\"roles,omitempty\"`\n\t// Should not be visible to other users\n\tEmail *string `json:\"email,omitempty\"`\n\t// Should not be visible to other users\n\tAPIKey                    *string            `json:\"api_key,omitempty\"`\n\tNotificationSubscriptions []NotificationEnum `json:\"notification_subscriptions\"`\n\t//  Vote counts by type\n\tVoteCount *UserVoteCount `json:\"vote_count\"`\n\t//  Edit counts by status\n\tEditCount *UserEditCount `json:\"edit_count\"`\n\t// Calls to the API from this user over a configurable time period\n\tAPICalls          int          `json:\"api_calls\"`\n\tInvitedBy         *User        `json:\"invited_by,omitempty\"`\n\tInviteTokens      *int         `json:\"invite_tokens,omitempty\"`\n\tActiveInviteCodes []string     `json:\"active_invite_codes,omitempty\"`\n\tInviteCodes       []*InviteKey `json:\"invite_codes,omitempty\"`\n}\n\ntype UserChangeEmailInput struct {\n\tExistingEmailToken *string `json:\"existing_email_token,omitempty\"`\n\tNewEmailToken      *string `json:\"new_email_token,omitempty\"`\n\tNewEmail           *string `json:\"new_email,omitempty\"`\n}\n\ntype UserChangePasswordInput struct {\n\t// Password in plain text\n\tExistingPassword *string `json:\"existing_password,omitempty\"`\n\tNewPassword      string  `json:\"new_password\"`\n\tResetKey         *string `json:\"reset_key,omitempty\"`\n}\n\ntype UserCreateInput struct {\n\tName string `json:\"name\"`\n\t// Password in plain text\n\tPassword    string     `json:\"password\"`\n\tRoles       []RoleEnum `json:\"roles\"`\n\tEmail       string     `json:\"email\"`\n\tInvitedByID *string    `json:\"invited_by_id,omitempty\"`\n}\n\ntype UserDestroyInput struct {\n\tID string `json:\"id\"`\n}\n\ntype UserEditCount struct {\n\tAccepted          int `json:\"accepted\"`\n\tRejected          int `json:\"rejected\"`\n\tPending           int `json:\"pending\"`\n\tImmediateAccepted int `json:\"immediate_accepted\"`\n\tImmediateRejected int `json:\"immediate_rejected\"`\n\tFailed            int `json:\"failed\"`\n\tCanceled          int `json:\"canceled\"`\n}\n\ntype UserQueryInput struct {\n\t// Filter to search user name - assumes like query unless quoted\n\tName *string `json:\"name,omitempty\"`\n\t// Filter to search email - assumes like query unless quoted\n\tEmail *string `json:\"email,omitempty\"`\n\t// Filter by roles\n\tRoles *RoleCriterionInput `json:\"roles,omitempty\"`\n\t// Filter by api key\n\tAPIKey *string `json:\"apiKey,omitempty\"`\n\t// Filter by successful edits\n\tSuccessfulEdits *IntCriterionInput `json:\"successful_edits,omitempty\"`\n\t// Filter by unsuccessful edits\n\tUnsuccessfulEdits *IntCriterionInput `json:\"unsuccessful_edits,omitempty\"`\n\t// Filter by votes on successful edits\n\tSuccessfulVotes *IntCriterionInput `json:\"successful_votes,omitempty\"`\n\t// Filter by votes on unsuccessful edits\n\tUnsuccessfulVotes *IntCriterionInput `json:\"unsuccessful_votes,omitempty\"`\n\t// Filter by number of API calls\n\tAPICalls *IntCriterionInput `json:\"api_calls,omitempty\"`\n\t// Filter by user that invited\n\tInvitedBy *string `json:\"invited_by,omitempty\"`\n\tPage      int     `json:\"page\"`\n\tPerPage   int     `json:\"per_page\"`\n}\n\ntype UserUpdateInput struct {\n\tID   string  `json:\"id\"`\n\tName *string `json:\"name,omitempty\"`\n\t// Password in plain text\n\tPassword *string    `json:\"password,omitempty\"`\n\tRoles    []RoleEnum `json:\"roles,omitempty\"`\n\tEmail    *string    `json:\"email,omitempty\"`\n}\n\ntype UserVoteCount struct {\n\tAbstain         int `json:\"abstain\"`\n\tAccept          int `json:\"accept\"`\n\tReject          int `json:\"reject\"`\n\tImmediateAccept int `json:\"immediate_accept\"`\n\tImmediateReject int `json:\"immediate_reject\"`\n}\n\ntype Version struct {\n\tHash      string `json:\"hash\"`\n\tBuildTime string `json:\"build_time\"`\n\tBuildType string `json:\"build_type\"`\n\tVersion   string `json:\"version\"`\n}\n\ntype BreastTypeEnum string\n\nconst (\n\tBreastTypeEnumNatural BreastTypeEnum = \"NATURAL\"\n\tBreastTypeEnumFake    BreastTypeEnum = \"FAKE\"\n\tBreastTypeEnumNa      BreastTypeEnum = \"NA\"\n)\n\nvar AllBreastTypeEnum = []BreastTypeEnum{\n\tBreastTypeEnumNatural,\n\tBreastTypeEnumFake,\n\tBreastTypeEnumNa,\n}\n\nfunc (e BreastTypeEnum) IsValid() bool {\n\tswitch e {\n\tcase BreastTypeEnumNatural, BreastTypeEnumFake, BreastTypeEnumNa:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e BreastTypeEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *BreastTypeEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = BreastTypeEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid BreastTypeEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e BreastTypeEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *BreastTypeEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e BreastTypeEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype CriterionModifier string\n\nconst (\n\t// =\n\tCriterionModifierEquals CriterionModifier = \"EQUALS\"\n\t// !=\n\tCriterionModifierNotEquals CriterionModifier = \"NOT_EQUALS\"\n\t// >\n\tCriterionModifierGreaterThan CriterionModifier = \"GREATER_THAN\"\n\t// <\n\tCriterionModifierLessThan CriterionModifier = \"LESS_THAN\"\n\t// IS NULL\n\tCriterionModifierIsNull CriterionModifier = \"IS_NULL\"\n\t// IS NOT NULL\n\tCriterionModifierNotNull CriterionModifier = \"NOT_NULL\"\n\t// INCLUDES ALL\n\tCriterionModifierIncludesAll CriterionModifier = \"INCLUDES_ALL\"\n\tCriterionModifierIncludes    CriterionModifier = \"INCLUDES\"\n\tCriterionModifierExcludes    CriterionModifier = \"EXCLUDES\"\n)\n\nvar AllCriterionModifier = []CriterionModifier{\n\tCriterionModifierEquals,\n\tCriterionModifierNotEquals,\n\tCriterionModifierGreaterThan,\n\tCriterionModifierLessThan,\n\tCriterionModifierIsNull,\n\tCriterionModifierNotNull,\n\tCriterionModifierIncludesAll,\n\tCriterionModifierIncludes,\n\tCriterionModifierExcludes,\n}\n\nfunc (e CriterionModifier) IsValid() bool {\n\tswitch e {\n\tcase CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierIncludesAll, CriterionModifierIncludes, CriterionModifierExcludes:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e CriterionModifier) String() string {\n\treturn string(e)\n}\n\nfunc (e *CriterionModifier) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = CriterionModifier(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid CriterionModifier\", str)\n\t}\n\treturn nil\n}\n\nfunc (e CriterionModifier) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *CriterionModifier) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e CriterionModifier) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype DateAccuracyEnum string\n\nconst (\n\tDateAccuracyEnumYear  DateAccuracyEnum = \"YEAR\"\n\tDateAccuracyEnumMonth DateAccuracyEnum = \"MONTH\"\n\tDateAccuracyEnumDay   DateAccuracyEnum = \"DAY\"\n)\n\nvar AllDateAccuracyEnum = []DateAccuracyEnum{\n\tDateAccuracyEnumYear,\n\tDateAccuracyEnumMonth,\n\tDateAccuracyEnumDay,\n}\n\nfunc (e DateAccuracyEnum) IsValid() bool {\n\tswitch e {\n\tcase DateAccuracyEnumYear, DateAccuracyEnumMonth, DateAccuracyEnumDay:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e DateAccuracyEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *DateAccuracyEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = DateAccuracyEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid DateAccuracyEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e DateAccuracyEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *DateAccuracyEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e DateAccuracyEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype EditSortEnum string\n\nconst (\n\tEditSortEnumCreatedAt EditSortEnum = \"CREATED_AT\"\n\tEditSortEnumUpdatedAt EditSortEnum = \"UPDATED_AT\"\n\tEditSortEnumClosedAt  EditSortEnum = \"CLOSED_AT\"\n)\n\nvar AllEditSortEnum = []EditSortEnum{\n\tEditSortEnumCreatedAt,\n\tEditSortEnumUpdatedAt,\n\tEditSortEnumClosedAt,\n}\n\nfunc (e EditSortEnum) IsValid() bool {\n\tswitch e {\n\tcase EditSortEnumCreatedAt, EditSortEnumUpdatedAt, EditSortEnumClosedAt:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e EditSortEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *EditSortEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = EditSortEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid EditSortEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e EditSortEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *EditSortEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e EditSortEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype EthnicityEnum string\n\nconst (\n\tEthnicityEnumCaucasian     EthnicityEnum = \"CAUCASIAN\"\n\tEthnicityEnumBlack         EthnicityEnum = \"BLACK\"\n\tEthnicityEnumAsian         EthnicityEnum = \"ASIAN\"\n\tEthnicityEnumIndian        EthnicityEnum = \"INDIAN\"\n\tEthnicityEnumLatin         EthnicityEnum = \"LATIN\"\n\tEthnicityEnumMiddleEastern EthnicityEnum = \"MIDDLE_EASTERN\"\n\tEthnicityEnumMixed         EthnicityEnum = \"MIXED\"\n\tEthnicityEnumOther         EthnicityEnum = \"OTHER\"\n)\n\nvar AllEthnicityEnum = []EthnicityEnum{\n\tEthnicityEnumCaucasian,\n\tEthnicityEnumBlack,\n\tEthnicityEnumAsian,\n\tEthnicityEnumIndian,\n\tEthnicityEnumLatin,\n\tEthnicityEnumMiddleEastern,\n\tEthnicityEnumMixed,\n\tEthnicityEnumOther,\n}\n\nfunc (e EthnicityEnum) IsValid() bool {\n\tswitch e {\n\tcase EthnicityEnumCaucasian, EthnicityEnumBlack, EthnicityEnumAsian, EthnicityEnumIndian, EthnicityEnumLatin, EthnicityEnumMiddleEastern, EthnicityEnumMixed, EthnicityEnumOther:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e EthnicityEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *EthnicityEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = EthnicityEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid EthnicityEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e EthnicityEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *EthnicityEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e EthnicityEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype EthnicityFilterEnum string\n\nconst (\n\tEthnicityFilterEnumUnknown       EthnicityFilterEnum = \"UNKNOWN\"\n\tEthnicityFilterEnumCaucasian     EthnicityFilterEnum = \"CAUCASIAN\"\n\tEthnicityFilterEnumBlack         EthnicityFilterEnum = \"BLACK\"\n\tEthnicityFilterEnumAsian         EthnicityFilterEnum = \"ASIAN\"\n\tEthnicityFilterEnumIndian        EthnicityFilterEnum = \"INDIAN\"\n\tEthnicityFilterEnumLatin         EthnicityFilterEnum = \"LATIN\"\n\tEthnicityFilterEnumMiddleEastern EthnicityFilterEnum = \"MIDDLE_EASTERN\"\n\tEthnicityFilterEnumMixed         EthnicityFilterEnum = \"MIXED\"\n\tEthnicityFilterEnumOther         EthnicityFilterEnum = \"OTHER\"\n)\n\nvar AllEthnicityFilterEnum = []EthnicityFilterEnum{\n\tEthnicityFilterEnumUnknown,\n\tEthnicityFilterEnumCaucasian,\n\tEthnicityFilterEnumBlack,\n\tEthnicityFilterEnumAsian,\n\tEthnicityFilterEnumIndian,\n\tEthnicityFilterEnumLatin,\n\tEthnicityFilterEnumMiddleEastern,\n\tEthnicityFilterEnumMixed,\n\tEthnicityFilterEnumOther,\n}\n\nfunc (e EthnicityFilterEnum) IsValid() bool {\n\tswitch e {\n\tcase EthnicityFilterEnumUnknown, EthnicityFilterEnumCaucasian, EthnicityFilterEnumBlack, EthnicityFilterEnumAsian, EthnicityFilterEnumIndian, EthnicityFilterEnumLatin, EthnicityFilterEnumMiddleEastern, EthnicityFilterEnumMixed, EthnicityFilterEnumOther:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e EthnicityFilterEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *EthnicityFilterEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = EthnicityFilterEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid EthnicityFilterEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e EthnicityFilterEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *EthnicityFilterEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e EthnicityFilterEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype EyeColorEnum string\n\nconst (\n\tEyeColorEnumBlue  EyeColorEnum = \"BLUE\"\n\tEyeColorEnumBrown EyeColorEnum = \"BROWN\"\n\tEyeColorEnumGrey  EyeColorEnum = \"GREY\"\n\tEyeColorEnumGreen EyeColorEnum = \"GREEN\"\n\tEyeColorEnumHazel EyeColorEnum = \"HAZEL\"\n\tEyeColorEnumRed   EyeColorEnum = \"RED\"\n)\n\nvar AllEyeColorEnum = []EyeColorEnum{\n\tEyeColorEnumBlue,\n\tEyeColorEnumBrown,\n\tEyeColorEnumGrey,\n\tEyeColorEnumGreen,\n\tEyeColorEnumHazel,\n\tEyeColorEnumRed,\n}\n\nfunc (e EyeColorEnum) IsValid() bool {\n\tswitch e {\n\tcase EyeColorEnumBlue, EyeColorEnumBrown, EyeColorEnumGrey, EyeColorEnumGreen, EyeColorEnumHazel, EyeColorEnumRed:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e EyeColorEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *EyeColorEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = EyeColorEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid EyeColorEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e EyeColorEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *EyeColorEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e EyeColorEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype FavoriteFilter string\n\nconst (\n\tFavoriteFilterPerformer FavoriteFilter = \"PERFORMER\"\n\tFavoriteFilterStudio    FavoriteFilter = \"STUDIO\"\n\tFavoriteFilterAll       FavoriteFilter = \"ALL\"\n)\n\nvar AllFavoriteFilter = []FavoriteFilter{\n\tFavoriteFilterPerformer,\n\tFavoriteFilterStudio,\n\tFavoriteFilterAll,\n}\n\nfunc (e FavoriteFilter) IsValid() bool {\n\tswitch e {\n\tcase FavoriteFilterPerformer, FavoriteFilterStudio, FavoriteFilterAll:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e FavoriteFilter) String() string {\n\treturn string(e)\n}\n\nfunc (e *FavoriteFilter) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = FavoriteFilter(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid FavoriteFilter\", str)\n\t}\n\treturn nil\n}\n\nfunc (e FavoriteFilter) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *FavoriteFilter) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e FavoriteFilter) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype FingerprintAlgorithm string\n\nconst (\n\tFingerprintAlgorithmMd5    FingerprintAlgorithm = \"MD5\"\n\tFingerprintAlgorithmOshash FingerprintAlgorithm = \"OSHASH\"\n\tFingerprintAlgorithmPhash  FingerprintAlgorithm = \"PHASH\"\n)\n\nvar AllFingerprintAlgorithm = []FingerprintAlgorithm{\n\tFingerprintAlgorithmMd5,\n\tFingerprintAlgorithmOshash,\n\tFingerprintAlgorithmPhash,\n}\n\nfunc (e FingerprintAlgorithm) IsValid() bool {\n\tswitch e {\n\tcase FingerprintAlgorithmMd5, FingerprintAlgorithmOshash, FingerprintAlgorithmPhash:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e FingerprintAlgorithm) String() string {\n\treturn string(e)\n}\n\nfunc (e *FingerprintAlgorithm) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = FingerprintAlgorithm(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid FingerprintAlgorithm\", str)\n\t}\n\treturn nil\n}\n\nfunc (e FingerprintAlgorithm) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *FingerprintAlgorithm) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e FingerprintAlgorithm) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype FingerprintSubmissionType string\n\nconst (\n\t// Positive vote\n\tFingerprintSubmissionTypeValid FingerprintSubmissionType = \"VALID\"\n\t// Report as invalid\n\tFingerprintSubmissionTypeInvalid FingerprintSubmissionType = \"INVALID\"\n\t// Remove vote\n\tFingerprintSubmissionTypeRemove FingerprintSubmissionType = \"REMOVE\"\n)\n\nvar AllFingerprintSubmissionType = []FingerprintSubmissionType{\n\tFingerprintSubmissionTypeValid,\n\tFingerprintSubmissionTypeInvalid,\n\tFingerprintSubmissionTypeRemove,\n}\n\nfunc (e FingerprintSubmissionType) IsValid() bool {\n\tswitch e {\n\tcase FingerprintSubmissionTypeValid, FingerprintSubmissionTypeInvalid, FingerprintSubmissionTypeRemove:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e FingerprintSubmissionType) String() string {\n\treturn string(e)\n}\n\nfunc (e *FingerprintSubmissionType) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = FingerprintSubmissionType(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid FingerprintSubmissionType\", str)\n\t}\n\treturn nil\n}\n\nfunc (e FingerprintSubmissionType) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *FingerprintSubmissionType) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e FingerprintSubmissionType) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype GenderEnum string\n\nconst (\n\tGenderEnumMale              GenderEnum = \"MALE\"\n\tGenderEnumFemale            GenderEnum = \"FEMALE\"\n\tGenderEnumTransgenderMale   GenderEnum = \"TRANSGENDER_MALE\"\n\tGenderEnumTransgenderFemale GenderEnum = \"TRANSGENDER_FEMALE\"\n\tGenderEnumIntersex          GenderEnum = \"INTERSEX\"\n\tGenderEnumNonBinary         GenderEnum = \"NON_BINARY\"\n)\n\nvar AllGenderEnum = []GenderEnum{\n\tGenderEnumMale,\n\tGenderEnumFemale,\n\tGenderEnumTransgenderMale,\n\tGenderEnumTransgenderFemale,\n\tGenderEnumIntersex,\n\tGenderEnumNonBinary,\n}\n\nfunc (e GenderEnum) IsValid() bool {\n\tswitch e {\n\tcase GenderEnumMale, GenderEnumFemale, GenderEnumTransgenderMale, GenderEnumTransgenderFemale, GenderEnumIntersex, GenderEnumNonBinary:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e GenderEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *GenderEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = GenderEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid GenderEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e GenderEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *GenderEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e GenderEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype GenderFilterEnum string\n\nconst (\n\tGenderFilterEnumUnknown           GenderFilterEnum = \"UNKNOWN\"\n\tGenderFilterEnumMale              GenderFilterEnum = \"MALE\"\n\tGenderFilterEnumFemale            GenderFilterEnum = \"FEMALE\"\n\tGenderFilterEnumTransgenderMale   GenderFilterEnum = \"TRANSGENDER_MALE\"\n\tGenderFilterEnumTransgenderFemale GenderFilterEnum = \"TRANSGENDER_FEMALE\"\n\tGenderFilterEnumIntersex          GenderFilterEnum = \"INTERSEX\"\n\tGenderFilterEnumNonBinary         GenderFilterEnum = \"NON_BINARY\"\n)\n\nvar AllGenderFilterEnum = []GenderFilterEnum{\n\tGenderFilterEnumUnknown,\n\tGenderFilterEnumMale,\n\tGenderFilterEnumFemale,\n\tGenderFilterEnumTransgenderMale,\n\tGenderFilterEnumTransgenderFemale,\n\tGenderFilterEnumIntersex,\n\tGenderFilterEnumNonBinary,\n}\n\nfunc (e GenderFilterEnum) IsValid() bool {\n\tswitch e {\n\tcase GenderFilterEnumUnknown, GenderFilterEnumMale, GenderFilterEnumFemale, GenderFilterEnumTransgenderMale, GenderFilterEnumTransgenderFemale, GenderFilterEnumIntersex, GenderFilterEnumNonBinary:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e GenderFilterEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *GenderFilterEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = GenderFilterEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid GenderFilterEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e GenderFilterEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *GenderFilterEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e GenderFilterEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype HairColorEnum string\n\nconst (\n\tHairColorEnumBlonde   HairColorEnum = \"BLONDE\"\n\tHairColorEnumBrunette HairColorEnum = \"BRUNETTE\"\n\tHairColorEnumBlack    HairColorEnum = \"BLACK\"\n\tHairColorEnumRed      HairColorEnum = \"RED\"\n\tHairColorEnumAuburn   HairColorEnum = \"AUBURN\"\n\tHairColorEnumGrey     HairColorEnum = \"GREY\"\n\tHairColorEnumBald     HairColorEnum = \"BALD\"\n\tHairColorEnumVarious  HairColorEnum = \"VARIOUS\"\n\tHairColorEnumWhite    HairColorEnum = \"WHITE\"\n\tHairColorEnumOther    HairColorEnum = \"OTHER\"\n)\n\nvar AllHairColorEnum = []HairColorEnum{\n\tHairColorEnumBlonde,\n\tHairColorEnumBrunette,\n\tHairColorEnumBlack,\n\tHairColorEnumRed,\n\tHairColorEnumAuburn,\n\tHairColorEnumGrey,\n\tHairColorEnumBald,\n\tHairColorEnumVarious,\n\tHairColorEnumWhite,\n\tHairColorEnumOther,\n}\n\nfunc (e HairColorEnum) IsValid() bool {\n\tswitch e {\n\tcase HairColorEnumBlonde, HairColorEnumBrunette, HairColorEnumBlack, HairColorEnumRed, HairColorEnumAuburn, HairColorEnumGrey, HairColorEnumBald, HairColorEnumVarious, HairColorEnumWhite, HairColorEnumOther:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e HairColorEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *HairColorEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = HairColorEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid HairColorEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e HairColorEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *HairColorEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e HairColorEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype NotificationEnum string\n\nconst (\n\tNotificationEnumFavoritePerformerScene NotificationEnum = \"FAVORITE_PERFORMER_SCENE\"\n\tNotificationEnumFavoritePerformerEdit  NotificationEnum = \"FAVORITE_PERFORMER_EDIT\"\n\tNotificationEnumFavoriteStudioScene    NotificationEnum = \"FAVORITE_STUDIO_SCENE\"\n\tNotificationEnumFavoriteStudioEdit     NotificationEnum = \"FAVORITE_STUDIO_EDIT\"\n\tNotificationEnumCommentOwnEdit         NotificationEnum = \"COMMENT_OWN_EDIT\"\n\tNotificationEnumDownvoteOwnEdit        NotificationEnum = \"DOWNVOTE_OWN_EDIT\"\n\tNotificationEnumFailedOwnEdit          NotificationEnum = \"FAILED_OWN_EDIT\"\n\tNotificationEnumCommentCommentedEdit   NotificationEnum = \"COMMENT_COMMENTED_EDIT\"\n\tNotificationEnumCommentVotedEdit       NotificationEnum = \"COMMENT_VOTED_EDIT\"\n\tNotificationEnumUpdatedEdit            NotificationEnum = \"UPDATED_EDIT\"\n\tNotificationEnumFingerprintedSceneEdit NotificationEnum = \"FINGERPRINTED_SCENE_EDIT\"\n)\n\nvar AllNotificationEnum = []NotificationEnum{\n\tNotificationEnumFavoritePerformerScene,\n\tNotificationEnumFavoritePerformerEdit,\n\tNotificationEnumFavoriteStudioScene,\n\tNotificationEnumFavoriteStudioEdit,\n\tNotificationEnumCommentOwnEdit,\n\tNotificationEnumDownvoteOwnEdit,\n\tNotificationEnumFailedOwnEdit,\n\tNotificationEnumCommentCommentedEdit,\n\tNotificationEnumCommentVotedEdit,\n\tNotificationEnumUpdatedEdit,\n\tNotificationEnumFingerprintedSceneEdit,\n}\n\nfunc (e NotificationEnum) IsValid() bool {\n\tswitch e {\n\tcase NotificationEnumFavoritePerformerScene, NotificationEnumFavoritePerformerEdit, NotificationEnumFavoriteStudioScene, NotificationEnumFavoriteStudioEdit, NotificationEnumCommentOwnEdit, NotificationEnumDownvoteOwnEdit, NotificationEnumFailedOwnEdit, NotificationEnumCommentCommentedEdit, NotificationEnumCommentVotedEdit, NotificationEnumUpdatedEdit, NotificationEnumFingerprintedSceneEdit:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e NotificationEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *NotificationEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = NotificationEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid NotificationEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e NotificationEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *NotificationEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e NotificationEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype OperationEnum string\n\nconst (\n\tOperationEnumCreate  OperationEnum = \"CREATE\"\n\tOperationEnumModify  OperationEnum = \"MODIFY\"\n\tOperationEnumDestroy OperationEnum = \"DESTROY\"\n\tOperationEnumMerge   OperationEnum = \"MERGE\"\n)\n\nvar AllOperationEnum = []OperationEnum{\n\tOperationEnumCreate,\n\tOperationEnumModify,\n\tOperationEnumDestroy,\n\tOperationEnumMerge,\n}\n\nfunc (e OperationEnum) IsValid() bool {\n\tswitch e {\n\tcase OperationEnumCreate, OperationEnumModify, OperationEnumDestroy, OperationEnumMerge:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e OperationEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *OperationEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = OperationEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid OperationEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e OperationEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *OperationEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e OperationEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype PerformerSortEnum string\n\nconst (\n\tPerformerSortEnumName            PerformerSortEnum = \"NAME\"\n\tPerformerSortEnumBirthdate       PerformerSortEnum = \"BIRTHDATE\"\n\tPerformerSortEnumDeathdate       PerformerSortEnum = \"DEATHDATE\"\n\tPerformerSortEnumSceneCount      PerformerSortEnum = \"SCENE_COUNT\"\n\tPerformerSortEnumCareerStartYear PerformerSortEnum = \"CAREER_START_YEAR\"\n\tPerformerSortEnumDebut           PerformerSortEnum = \"DEBUT\"\n\tPerformerSortEnumLastScene       PerformerSortEnum = \"LAST_SCENE\"\n\tPerformerSortEnumCreatedAt       PerformerSortEnum = \"CREATED_AT\"\n\tPerformerSortEnumUpdatedAt       PerformerSortEnum = \"UPDATED_AT\"\n)\n\nvar AllPerformerSortEnum = []PerformerSortEnum{\n\tPerformerSortEnumName,\n\tPerformerSortEnumBirthdate,\n\tPerformerSortEnumDeathdate,\n\tPerformerSortEnumSceneCount,\n\tPerformerSortEnumCareerStartYear,\n\tPerformerSortEnumDebut,\n\tPerformerSortEnumLastScene,\n\tPerformerSortEnumCreatedAt,\n\tPerformerSortEnumUpdatedAt,\n}\n\nfunc (e PerformerSortEnum) IsValid() bool {\n\tswitch e {\n\tcase PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumDeathdate, PerformerSortEnumSceneCount, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumLastScene, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e PerformerSortEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *PerformerSortEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = PerformerSortEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid PerformerSortEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e PerformerSortEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *PerformerSortEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e PerformerSortEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype RoleEnum string\n\nconst (\n\tRoleEnumRead   RoleEnum = \"READ\"\n\tRoleEnumVote   RoleEnum = \"VOTE\"\n\tRoleEnumEdit   RoleEnum = \"EDIT\"\n\tRoleEnumModify RoleEnum = \"MODIFY\"\n\tRoleEnumAdmin  RoleEnum = \"ADMIN\"\n\t// May generate invites without tokens\n\tRoleEnumInvite RoleEnum = \"INVITE\"\n\t// May grant and rescind invite tokens and resind invite keys\n\tRoleEnumManageInvites RoleEnum = \"MANAGE_INVITES\"\n\tRoleEnumBot           RoleEnum = \"BOT\"\n\tRoleEnumReadOnly      RoleEnum = \"READ_ONLY\"\n\tRoleEnumEditTags      RoleEnum = \"EDIT_TAGS\"\n)\n\nvar AllRoleEnum = []RoleEnum{\n\tRoleEnumRead,\n\tRoleEnumVote,\n\tRoleEnumEdit,\n\tRoleEnumModify,\n\tRoleEnumAdmin,\n\tRoleEnumInvite,\n\tRoleEnumManageInvites,\n\tRoleEnumBot,\n\tRoleEnumReadOnly,\n\tRoleEnumEditTags,\n}\n\nfunc (e RoleEnum) IsValid() bool {\n\tswitch e {\n\tcase RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e RoleEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *RoleEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = RoleEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid RoleEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e RoleEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *RoleEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e RoleEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype SceneSortEnum string\n\nconst (\n\tSceneSortEnumTitle     SceneSortEnum = \"TITLE\"\n\tSceneSortEnumDate      SceneSortEnum = \"DATE\"\n\tSceneSortEnumTrending  SceneSortEnum = \"TRENDING\"\n\tSceneSortEnumCreatedAt SceneSortEnum = \"CREATED_AT\"\n\tSceneSortEnumUpdatedAt SceneSortEnum = \"UPDATED_AT\"\n)\n\nvar AllSceneSortEnum = []SceneSortEnum{\n\tSceneSortEnumTitle,\n\tSceneSortEnumDate,\n\tSceneSortEnumTrending,\n\tSceneSortEnumCreatedAt,\n\tSceneSortEnumUpdatedAt,\n}\n\nfunc (e SceneSortEnum) IsValid() bool {\n\tswitch e {\n\tcase SceneSortEnumTitle, SceneSortEnumDate, SceneSortEnumTrending, SceneSortEnumCreatedAt, SceneSortEnumUpdatedAt:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e SceneSortEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *SceneSortEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = SceneSortEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid SceneSortEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e SceneSortEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *SceneSortEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e SceneSortEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype SortDirectionEnum string\n\nconst (\n\tSortDirectionEnumAsc  SortDirectionEnum = \"ASC\"\n\tSortDirectionEnumDesc SortDirectionEnum = \"DESC\"\n)\n\nvar AllSortDirectionEnum = []SortDirectionEnum{\n\tSortDirectionEnumAsc,\n\tSortDirectionEnumDesc,\n}\n\nfunc (e SortDirectionEnum) IsValid() bool {\n\tswitch e {\n\tcase SortDirectionEnumAsc, SortDirectionEnumDesc:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e SortDirectionEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *SortDirectionEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = SortDirectionEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid SortDirectionEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e SortDirectionEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *SortDirectionEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e SortDirectionEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype StudioSortEnum string\n\nconst (\n\tStudioSortEnumName      StudioSortEnum = \"NAME\"\n\tStudioSortEnumCreatedAt StudioSortEnum = \"CREATED_AT\"\n\tStudioSortEnumUpdatedAt StudioSortEnum = \"UPDATED_AT\"\n)\n\nvar AllStudioSortEnum = []StudioSortEnum{\n\tStudioSortEnumName,\n\tStudioSortEnumCreatedAt,\n\tStudioSortEnumUpdatedAt,\n}\n\nfunc (e StudioSortEnum) IsValid() bool {\n\tswitch e {\n\tcase StudioSortEnumName, StudioSortEnumCreatedAt, StudioSortEnumUpdatedAt:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e StudioSortEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *StudioSortEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = StudioSortEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid StudioSortEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e StudioSortEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *StudioSortEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e StudioSortEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype TagGroupEnum string\n\nconst (\n\tTagGroupEnumPeople TagGroupEnum = \"PEOPLE\"\n\tTagGroupEnumScene  TagGroupEnum = \"SCENE\"\n\tTagGroupEnumAction TagGroupEnum = \"ACTION\"\n)\n\nvar AllTagGroupEnum = []TagGroupEnum{\n\tTagGroupEnumPeople,\n\tTagGroupEnumScene,\n\tTagGroupEnumAction,\n}\n\nfunc (e TagGroupEnum) IsValid() bool {\n\tswitch e {\n\tcase TagGroupEnumPeople, TagGroupEnumScene, TagGroupEnumAction:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e TagGroupEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *TagGroupEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = TagGroupEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid TagGroupEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e TagGroupEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *TagGroupEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e TagGroupEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype TagSortEnum string\n\nconst (\n\tTagSortEnumName      TagSortEnum = \"NAME\"\n\tTagSortEnumCreatedAt TagSortEnum = \"CREATED_AT\"\n\tTagSortEnumUpdatedAt TagSortEnum = \"UPDATED_AT\"\n)\n\nvar AllTagSortEnum = []TagSortEnum{\n\tTagSortEnumName,\n\tTagSortEnumCreatedAt,\n\tTagSortEnumUpdatedAt,\n}\n\nfunc (e TagSortEnum) IsValid() bool {\n\tswitch e {\n\tcase TagSortEnumName, TagSortEnumCreatedAt, TagSortEnumUpdatedAt:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e TagSortEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *TagSortEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = TagSortEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid TagSortEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e TagSortEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *TagSortEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e TagSortEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype TargetTypeEnum string\n\nconst (\n\tTargetTypeEnumScene     TargetTypeEnum = \"SCENE\"\n\tTargetTypeEnumStudio    TargetTypeEnum = \"STUDIO\"\n\tTargetTypeEnumPerformer TargetTypeEnum = \"PERFORMER\"\n\tTargetTypeEnumTag       TargetTypeEnum = \"TAG\"\n)\n\nvar AllTargetTypeEnum = []TargetTypeEnum{\n\tTargetTypeEnumScene,\n\tTargetTypeEnumStudio,\n\tTargetTypeEnumPerformer,\n\tTargetTypeEnumTag,\n}\n\nfunc (e TargetTypeEnum) IsValid() bool {\n\tswitch e {\n\tcase TargetTypeEnumScene, TargetTypeEnumStudio, TargetTypeEnumPerformer, TargetTypeEnumTag:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e TargetTypeEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *TargetTypeEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = TargetTypeEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid TargetTypeEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e TargetTypeEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *TargetTypeEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e TargetTypeEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype UserChangeEmailStatus string\n\nconst (\n\tUserChangeEmailStatusConfirmOld   UserChangeEmailStatus = \"CONFIRM_OLD\"\n\tUserChangeEmailStatusConfirmNew   UserChangeEmailStatus = \"CONFIRM_NEW\"\n\tUserChangeEmailStatusExpired      UserChangeEmailStatus = \"EXPIRED\"\n\tUserChangeEmailStatusInvalidToken UserChangeEmailStatus = \"INVALID_TOKEN\"\n\tUserChangeEmailStatusSuccess      UserChangeEmailStatus = \"SUCCESS\"\n\tUserChangeEmailStatusError        UserChangeEmailStatus = \"ERROR\"\n)\n\nvar AllUserChangeEmailStatus = []UserChangeEmailStatus{\n\tUserChangeEmailStatusConfirmOld,\n\tUserChangeEmailStatusConfirmNew,\n\tUserChangeEmailStatusExpired,\n\tUserChangeEmailStatusInvalidToken,\n\tUserChangeEmailStatusSuccess,\n\tUserChangeEmailStatusError,\n}\n\nfunc (e UserChangeEmailStatus) IsValid() bool {\n\tswitch e {\n\tcase UserChangeEmailStatusConfirmOld, UserChangeEmailStatusConfirmNew, UserChangeEmailStatusExpired, UserChangeEmailStatusInvalidToken, UserChangeEmailStatusSuccess, UserChangeEmailStatusError:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e UserChangeEmailStatus) String() string {\n\treturn string(e)\n}\n\nfunc (e *UserChangeEmailStatus) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = UserChangeEmailStatus(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid UserChangeEmailStatus\", str)\n\t}\n\treturn nil\n}\n\nfunc (e UserChangeEmailStatus) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *UserChangeEmailStatus) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e UserChangeEmailStatus) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype UserVotedFilterEnum string\n\nconst (\n\tUserVotedFilterEnumAbstain  UserVotedFilterEnum = \"ABSTAIN\"\n\tUserVotedFilterEnumAccept   UserVotedFilterEnum = \"ACCEPT\"\n\tUserVotedFilterEnumReject   UserVotedFilterEnum = \"REJECT\"\n\tUserVotedFilterEnumNotVoted UserVotedFilterEnum = \"NOT_VOTED\"\n)\n\nvar AllUserVotedFilterEnum = []UserVotedFilterEnum{\n\tUserVotedFilterEnumAbstain,\n\tUserVotedFilterEnumAccept,\n\tUserVotedFilterEnumReject,\n\tUserVotedFilterEnumNotVoted,\n}\n\nfunc (e UserVotedFilterEnum) IsValid() bool {\n\tswitch e {\n\tcase UserVotedFilterEnumAbstain, UserVotedFilterEnumAccept, UserVotedFilterEnumReject, UserVotedFilterEnumNotVoted:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e UserVotedFilterEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *UserVotedFilterEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = UserVotedFilterEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid UserVotedFilterEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e UserVotedFilterEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *UserVotedFilterEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e UserVotedFilterEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype ValidSiteTypeEnum string\n\nconst (\n\tValidSiteTypeEnumPerformer ValidSiteTypeEnum = \"PERFORMER\"\n\tValidSiteTypeEnumScene     ValidSiteTypeEnum = \"SCENE\"\n\tValidSiteTypeEnumStudio    ValidSiteTypeEnum = \"STUDIO\"\n)\n\nvar AllValidSiteTypeEnum = []ValidSiteTypeEnum{\n\tValidSiteTypeEnumPerformer,\n\tValidSiteTypeEnumScene,\n\tValidSiteTypeEnumStudio,\n}\n\nfunc (e ValidSiteTypeEnum) IsValid() bool {\n\tswitch e {\n\tcase ValidSiteTypeEnumPerformer, ValidSiteTypeEnumScene, ValidSiteTypeEnumStudio:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e ValidSiteTypeEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *ValidSiteTypeEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = ValidSiteTypeEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid ValidSiteTypeEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e ValidSiteTypeEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *ValidSiteTypeEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e ValidSiteTypeEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype VoteStatusEnum string\n\nconst (\n\tVoteStatusEnumAccepted          VoteStatusEnum = \"ACCEPTED\"\n\tVoteStatusEnumRejected          VoteStatusEnum = \"REJECTED\"\n\tVoteStatusEnumPending           VoteStatusEnum = \"PENDING\"\n\tVoteStatusEnumImmediateAccepted VoteStatusEnum = \"IMMEDIATE_ACCEPTED\"\n\tVoteStatusEnumImmediateRejected VoteStatusEnum = \"IMMEDIATE_REJECTED\"\n\tVoteStatusEnumFailed            VoteStatusEnum = \"FAILED\"\n\tVoteStatusEnumCanceled          VoteStatusEnum = \"CANCELED\"\n)\n\nvar AllVoteStatusEnum = []VoteStatusEnum{\n\tVoteStatusEnumAccepted,\n\tVoteStatusEnumRejected,\n\tVoteStatusEnumPending,\n\tVoteStatusEnumImmediateAccepted,\n\tVoteStatusEnumImmediateRejected,\n\tVoteStatusEnumFailed,\n\tVoteStatusEnumCanceled,\n}\n\nfunc (e VoteStatusEnum) IsValid() bool {\n\tswitch e {\n\tcase VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected, VoteStatusEnumFailed, VoteStatusEnumCanceled:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e VoteStatusEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *VoteStatusEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = VoteStatusEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid VoteStatusEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e VoteStatusEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *VoteStatusEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e VoteStatusEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n\ntype VoteTypeEnum string\n\nconst (\n\tVoteTypeEnumAbstain VoteTypeEnum = \"ABSTAIN\"\n\tVoteTypeEnumAccept  VoteTypeEnum = \"ACCEPT\"\n\tVoteTypeEnumReject  VoteTypeEnum = \"REJECT\"\n\t// Immediately accepts the edit - bypassing the vote\n\tVoteTypeEnumImmediateAccept VoteTypeEnum = \"IMMEDIATE_ACCEPT\"\n\t// Immediately rejects the edit - bypassing the vote\n\tVoteTypeEnumImmediateReject VoteTypeEnum = \"IMMEDIATE_REJECT\"\n)\n\nvar AllVoteTypeEnum = []VoteTypeEnum{\n\tVoteTypeEnumAbstain,\n\tVoteTypeEnumAccept,\n\tVoteTypeEnumReject,\n\tVoteTypeEnumImmediateAccept,\n\tVoteTypeEnumImmediateReject,\n}\n\nfunc (e VoteTypeEnum) IsValid() bool {\n\tswitch e {\n\tcase VoteTypeEnumAbstain, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject:\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (e VoteTypeEnum) String() string {\n\treturn string(e)\n}\n\nfunc (e *VoteTypeEnum) UnmarshalGQL(v any) error {\n\tstr, ok := v.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"enums must be strings\")\n\t}\n\n\t*e = VoteTypeEnum(str)\n\tif !e.IsValid() {\n\t\treturn fmt.Errorf(\"%s is not a valid VoteTypeEnum\", str)\n\t}\n\treturn nil\n}\n\nfunc (e VoteTypeEnum) MarshalGQL(w io.Writer) {\n\tfmt.Fprint(w, strconv.Quote(e.String()))\n}\n\nfunc (e *VoteTypeEnum) UnmarshalJSON(b []byte) error {\n\ts, err := strconv.Unquote(string(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn e.UnmarshalGQL(s)\n}\n\nfunc (e VoteTypeEnum) MarshalJSON() ([]byte, error) {\n\tvar buf bytes.Buffer\n\te.MarshalGQL(&buf)\n\treturn buf.Bytes(), nil\n}\n"
  },
  {
    "path": "pkg/stashbox/performer.go",
    "content": "package stashbox\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sliceutil/stringslice\"\n\t\"github.com/stashapp/stash/pkg/stashbox/graphql\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n\t\"golang.org/x/text/cases\"\n\t\"golang.org/x/text/language\"\n)\n\n// QueryPerformer queries stash-box for performers using a query string.\nfunc (c Client) QueryPerformer(ctx context.Context, queryStr string) ([]*models.ScrapedPerformer, error) {\n\tperformers, err := c.queryPerformer(ctx, queryStr)\n\n\t// set the deprecated image field\n\tfor _, p := range performers {\n\t\tif len(p.Images) > 0 {\n\t\t\tp.Image = &p.Images[0]\n\t\t}\n\t}\n\n\treturn performers, err\n}\n\nfunc (c Client) queryPerformer(ctx context.Context, queryStr string) ([]*models.ScrapedPerformer, error) {\n\tperformers, err := c.client.SearchPerformer(ctx, queryStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tperformerFragments := performers.SearchPerformer\n\n\tvar ret []*models.ScrapedPerformer\n\tvar ignoredTags []string\n\tfor _, fragment := range performerFragments {\n\t\tperformer := performerFragmentToScrapedPerformer(*fragment)\n\n\t\t// exclude tags that match the excludeTagRE\n\t\tvar thisIgnoredTags []string\n\t\tperformer.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, performer.Tags)\n\t\tignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)\n\n\t\tret = append(ret, performer)\n\t}\n\n\tscraper.LogIgnoredTags(ignoredTags)\n\n\treturn ret, nil\n}\n\n// QueryPerformers queries stash-box for performers using a list of names.\nfunc (c Client) QueryPerformers(ctx context.Context, names []string) ([][]*models.ScrapedPerformer, error) {\n\tret := make([][]*models.ScrapedPerformer, len(names))\n\tfor i, name := range names {\n\t\tif name != \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar err error\n\t\tret[i], err = c.queryPerformer(ctx, name)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc findURL(urls []*graphql.URLFragment, urlType string) *string {\n\tfor _, u := range urls {\n\t\tif u.Type == urlType {\n\t\t\tret := u.URL\n\t\t\treturn &ret\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc enumToStringPtr(e fmt.Stringer, titleCase bool) *string {\n\tif e != nil {\n\t\tret := strings.ReplaceAll(e.String(), \"_\", \" \")\n\t\tif titleCase {\n\t\t\tc := cases.Title(language.Und)\n\t\t\tret = c.String(strings.ToLower(ret))\n\t\t}\n\t\treturn &ret\n\t}\n\n\treturn nil\n}\n\nfunc translateGender(gender *graphql.GenderEnum) *string {\n\tvar res models.GenderEnum\n\tswitch *gender {\n\tcase graphql.GenderEnumMale:\n\t\tres = models.GenderEnumMale\n\tcase graphql.GenderEnumFemale:\n\t\tres = models.GenderEnumFemale\n\tcase graphql.GenderEnumIntersex:\n\t\tres = models.GenderEnumIntersex\n\tcase graphql.GenderEnumTransgenderFemale:\n\t\tres = models.GenderEnumTransgenderFemale\n\tcase graphql.GenderEnumTransgenderMale:\n\t\tres = models.GenderEnumTransgenderMale\n\tcase graphql.GenderEnumNonBinary:\n\t\tres = models.GenderEnumNonBinary\n\t}\n\n\tif res != \"\" {\n\t\tstrVal := res.String()\n\t\treturn &strVal\n\t}\n\treturn nil\n}\n\nfunc formatMeasurements(m *graphql.MeasurementsFragment) *string {\n\tif m != nil && m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil {\n\t\tret := fmt.Sprintf(\"%d%s-%d-%d\", *m.BandSize, *m.CupSize, *m.Waist, *m.Hip)\n\t\treturn &ret\n\t}\n\n\treturn nil\n}\n\nfunc formatCareerLength(start, end *int) *string {\n\tif start == nil && end == nil {\n\t\treturn nil\n\t}\n\n\tvar ret string\n\tswitch {\n\tcase end == nil:\n\t\tret = fmt.Sprintf(\"%d -\", *start)\n\tcase start == nil:\n\t\tret = fmt.Sprintf(\"- %d\", *end)\n\tdefault:\n\t\tret = fmt.Sprintf(\"%d - %d\", *start, *end)\n\t}\n\n\treturn &ret\n}\n\nfunc formatBodyModifications(m []*graphql.BodyModificationFragment) *string {\n\tif len(m) == 0 {\n\t\treturn nil\n\t}\n\n\tvar retSlice []string\n\tfor _, f := range m {\n\t\tif f.Description == nil {\n\t\t\tretSlice = append(retSlice, f.Location)\n\t\t} else {\n\t\t\tretSlice = append(retSlice, fmt.Sprintf(\"%s, %s\", f.Location, *f.Description))\n\t\t}\n\t}\n\n\tret := strings.Join(retSlice, \"; \")\n\treturn &ret\n}\n\nfunc fetchImage(ctx context.Context, client *http.Client, url string) (*string, error) {\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresp, err := client.Do(req)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// determine the image type and set the base64 type\n\tcontentType := resp.Header.Get(\"Content-Type\")\n\tif contentType == \"\" {\n\t\tcontentType = http.DetectContentType(body)\n\t}\n\n\timg := \"data:\" + contentType + \";base64,\" + utils.GetBase64StringFromData(body)\n\treturn &img, nil\n}\n\nfunc performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.ScrapedPerformer {\n\timages := []string{}\n\tfor _, image := range p.Images {\n\t\timages = append(images, image.URL)\n\t}\n\n\tsp := &models.ScrapedPerformer{\n\t\tName:               &p.Name,\n\t\tDisambiguation:     p.Disambiguation,\n\t\tCountry:            p.Country,\n\t\tMeasurements:       formatMeasurements(p.Measurements),\n\t\tCareerLength:       formatCareerLength(p.CareerStartYear, p.CareerEndYear),\n\t\tTattoos:            formatBodyModifications(p.Tattoos),\n\t\tPiercings:          formatBodyModifications(p.Piercings),\n\t\tTwitter:            findURL(p.Urls, \"TWITTER\"),\n\t\tRemoteSiteID:       &p.ID,\n\t\tRemoteDeleted:      p.Deleted,\n\t\tRemoteMergedIntoId: p.MergedIntoID,\n\t\tImages:             images,\n\t\t// TODO - tags not currently supported\n\t\t// graphql schema change to accommodate this. Leave off for now.\n\t}\n\n\tif len(sp.Images) > 0 {\n\t\tsp.Image = &sp.Images[0]\n\t}\n\n\tif p.Height != nil && *p.Height > 0 {\n\t\ths := strconv.Itoa(*p.Height)\n\t\tsp.Height = &hs\n\t}\n\n\tif p.CareerStartYear != nil {\n\t\tcs := strconv.Itoa(*p.CareerStartYear)\n\t\tsp.CareerStart = &cs\n\t}\n\n\tif p.CareerEndYear != nil {\n\t\tce := strconv.Itoa(*p.CareerEndYear)\n\t\tsp.CareerEnd = &ce\n\t}\n\n\tif p.BirthDate != nil {\n\t\tsp.Birthdate = padFuzzyDate(p.BirthDate)\n\t}\n\n\tif p.DeathDate != nil {\n\t\tsp.DeathDate = padFuzzyDate(p.DeathDate)\n\t}\n\n\tif p.Gender != nil {\n\t\tsp.Gender = translateGender(p.Gender)\n\t}\n\n\tif p.Ethnicity != nil {\n\t\tsp.Ethnicity = enumToStringPtr(p.Ethnicity, true)\n\t}\n\n\tif p.EyeColor != nil {\n\t\tsp.EyeColor = enumToStringPtr(p.EyeColor, true)\n\t}\n\n\tif p.HairColor != nil {\n\t\tsp.HairColor = enumToStringPtr(p.HairColor, true)\n\t}\n\n\tif p.BreastType != nil {\n\t\tsp.FakeTits = enumToStringPtr(p.BreastType, true)\n\t}\n\n\tif len(p.Aliases) > 0 {\n\t\t// #4437 - stash-box may return aliases that are equal to the performer name\n\t\t// filter these out\n\t\tp.Aliases = sliceutil.Filter(p.Aliases, func(s string) bool {\n\t\t\treturn !strings.EqualFold(s, p.Name)\n\t\t})\n\n\t\t// #4596 - stash-box may return duplicate aliases. Filter these out\n\t\tp.Aliases = stringslice.UniqueFold(p.Aliases)\n\n\t\talias := strings.Join(p.Aliases, \", \")\n\t\tsp.Aliases = &alias\n\t}\n\n\tfor _, u := range p.Urls {\n\t\tsp.URLs = append(sp.URLs, u.URL)\n\t}\n\n\treturn sp\n}\n\nfunc padFuzzyDate(date *string) *string {\n\tif date == nil {\n\t\treturn nil\n\t}\n\n\tvar paddedDate string\n\tswitch len(*date) {\n\tcase 10:\n\t\tpaddedDate = *date\n\tcase 7:\n\t\tpaddedDate = fmt.Sprintf(\"%s-01\", *date)\n\tcase 4:\n\t\tpaddedDate = fmt.Sprintf(\"%s-01-01\", *date)\n\t}\n\treturn &paddedDate\n}\n\n// FindPerformerByID queries stash-box for a performer by ID.\nfunc (c Client) FindPerformerByID(ctx context.Context, id string) (*models.ScrapedPerformer, error) {\n\tperformer, err := c.client.FindPerformerByID(ctx, id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif performer.FindPerformer == nil {\n\t\treturn nil, nil\n\t}\n\n\tret := performerFragmentToScrapedPerformer(*performer.FindPerformer)\n\n\treturn ret, nil\n}\n\n// FindPerformerByName queries stash-box for a performer by name.\n// Unlike QueryPerformer, this function will only return a performer if the name matches exactly.\nfunc (c Client) FindPerformerByName(ctx context.Context, name string) (*models.ScrapedPerformer, error) {\n\tperformers, err := c.client.SearchPerformer(ctx, name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret *models.ScrapedPerformer\n\tfor _, performer := range performers.SearchPerformer {\n\t\tif strings.EqualFold(performer.Name, name) {\n\t\t\tret = performerFragmentToScrapedPerformer(*performer)\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\n// SubmitPerformerDraft submits a performer draft to stash-box.\n// The performer parameter must have aliases, URLs and stash IDs loaded.\nfunc (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Performer, img []byte) (*string, error) {\n\tdraft := graphql.PerformerDraftInput{}\n\tvar image io.Reader\n\tendpoint := c.box.Endpoint\n\n\tif len(img) > 0 {\n\t\timage = bytes.NewReader(img)\n\t}\n\n\tif performer.Name != \"\" {\n\t\tdraft.Name = performer.Name\n\t}\n\tif performer.Disambiguation != \"\" {\n\t\tdraft.Disambiguation = &performer.Disambiguation\n\t}\n\tif performer.Birthdate != nil {\n\t\td := performer.Birthdate.String()\n\t\tdraft.Birthdate = &d\n\t}\n\tif performer.Country != \"\" {\n\t\tdraft.Country = &performer.Country\n\t}\n\tif performer.Ethnicity != \"\" {\n\t\tdraft.Ethnicity = &performer.Ethnicity\n\t}\n\tif performer.EyeColor != \"\" {\n\t\tdraft.EyeColor = &performer.EyeColor\n\t}\n\tif performer.FakeTits != \"\" {\n\t\tdraft.BreastType = &performer.FakeTits\n\t}\n\tif performer.Gender != nil && performer.Gender.IsValid() {\n\t\tv := performer.Gender.String()\n\t\tdraft.Gender = &v\n\t}\n\tif performer.HairColor != \"\" {\n\t\tdraft.HairColor = &performer.HairColor\n\t}\n\tif performer.Height != nil {\n\t\tv := strconv.Itoa(*performer.Height)\n\t\tdraft.Height = &v\n\t}\n\tif performer.Measurements != \"\" {\n\t\tdraft.Measurements = &performer.Measurements\n\t}\n\tif performer.Piercings != \"\" {\n\t\tdraft.Piercings = &performer.Piercings\n\t}\n\tif performer.Tattoos != \"\" {\n\t\tdraft.Tattoos = &performer.Tattoos\n\t}\n\tif len(performer.Aliases.List()) > 0 {\n\t\taliases := strings.Join(performer.Aliases.List(), \",\")\n\t\tdraft.Aliases = &aliases\n\t}\n\tif performer.CareerStart != nil {\n\t\tyear := performer.CareerStart.Year()\n\t\tdraft.CareerStartYear = &year\n\t}\n\tif performer.CareerEnd != nil {\n\t\tyear := performer.CareerEnd.Year()\n\t\tdraft.CareerEndYear = &year\n\t}\n\n\tif len(performer.URLs.List()) > 0 {\n\t\tdraft.Urls = performer.URLs.List()\n\t}\n\n\tvar stashID *string\n\tfor _, v := range performer.StashIDs.List() {\n\t\tc := v\n\t\tif v.Endpoint == endpoint {\n\t\t\tstashID = &c.StashID\n\t\t\tbreak\n\t\t}\n\t}\n\tdraft.ID = stashID\n\n\tvar id *string\n\tvar ret graphql.SubmitPerformerDraft\n\terr := c.submitDraft(ctx, graphql.SubmitPerformerDraftDocument, draft, image, &ret)\n\tid = ret.SubmitPerformerDraft.ID\n\n\treturn id, err\n\n\t// ret, err := c.client.SubmitPerformerDraft(ctx, draft, uploadImage(image))\n\t// if err != nil {\n\t// \treturn nil, err\n\t// }\n\n\t// id := ret.SubmitPerformerDraft.ID\n\t// return id, nil\n}\n"
  },
  {
    "path": "pkg/stashbox/scene.go",
    "content": "package stashbox\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/scraper\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/stashbox/graphql\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\n// QueryScene queries stash-box for scenes using a query string.\nfunc (c Client) QueryScene(ctx context.Context, queryStr string) ([]*models.ScrapedScene, error) {\n\tscenes, err := c.client.SearchScene(ctx, queryStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsceneFragments := scenes.SearchScene\n\n\tvar ret []*models.ScrapedScene\n\tvar ignoredTags []string\n\tfor _, s := range sceneFragments {\n\t\tss, err := c.sceneFragmentToScrapedScene(ctx, s)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar thisIgnoredTags []string\n\t\tss.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, ss.Tags)\n\t\tignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)\n\n\t\tret = append(ret, ss)\n\t}\n\n\tscraper.LogIgnoredTags(ignoredTags)\n\n\treturn ret, nil\n}\n\n// FindStashBoxScenesByFingerprints queries stash-box for a scene using the\n// scene's MD5/OSHASH checksum, or PHash.\nfunc (c Client) FindSceneByFingerprints(ctx context.Context, fps models.Fingerprints) ([]*models.ScrapedScene, error) {\n\tres, err := c.FindScenesByFingerprints(ctx, []models.Fingerprints{fps})\n\tif len(res) > 0 {\n\t\treturn res[0], err\n\t}\n\treturn nil, err\n}\n\n// FindScenesByFingerprints queries stash-box for scenes using every\n// scene's MD5/OSHASH checksum, or PHash, and returns results in the same order\n// as the input slice.\nfunc (c Client) FindScenesByFingerprints(ctx context.Context, fps []models.Fingerprints) ([][]*models.ScrapedScene, error) {\n\tvar fingerprints [][]*graphql.FingerprintQueryInput\n\n\tfor _, fp := range fps {\n\t\tfingerprints = append(fingerprints, convertFingerprints(fp))\n\t}\n\n\treturn c.findScenesByFingerprints(ctx, fingerprints)\n}\n\nfunc convertFingerprints(fps models.Fingerprints) []*graphql.FingerprintQueryInput {\n\tvar ret []*graphql.FingerprintQueryInput\n\n\tfor _, f := range fps {\n\t\tvar i = &graphql.FingerprintQueryInput{}\n\t\tswitch f.Type {\n\t\tcase models.FingerprintTypeMD5:\n\t\t\ti.Algorithm = graphql.FingerprintAlgorithmMd5\n\t\t\ti.Hash = f.String()\n\t\tcase models.FingerprintTypeOshash:\n\t\t\ti.Algorithm = graphql.FingerprintAlgorithmOshash\n\t\t\ti.Hash = f.String()\n\t\tcase models.FingerprintTypePhash:\n\t\t\ti.Algorithm = graphql.FingerprintAlgorithmPhash\n\t\t\ti.Hash = utils.PhashToString(f.Int64())\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\n\t\tif !i.Algorithm.IsValid() {\n\t\t\tcontinue\n\t\t}\n\n\t\tret = append(ret, i)\n\t}\n\n\treturn ret\n}\n\nfunc (c Client) findScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*models.ScrapedScene, error) {\n\tvar results [][]*models.ScrapedScene\n\n\t// filter out nils\n\tvar validScenes [][]*graphql.FingerprintQueryInput\n\tfor _, s := range scenes {\n\t\tif len(s) > 0 {\n\t\t\tvalidScenes = append(validScenes, s)\n\t\t}\n\t}\n\n\tvar ignoredTags []string\n\n\tfor i := 0; i < len(validScenes); i += 40 {\n\t\tend := i + 40\n\t\tif end > len(validScenes) {\n\t\t\tend = len(validScenes)\n\t\t}\n\t\tscenes, err := c.client.FindScenesBySceneFingerprints(ctx, validScenes[i:end])\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, sceneFragments := range scenes.FindScenesBySceneFingerprints {\n\t\t\tvar sceneResults []*models.ScrapedScene\n\t\t\tfor _, scene := range sceneFragments {\n\t\t\t\tss, err := c.sceneFragmentToScrapedScene(ctx, scene)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\n\t\t\t\tvar thisIgnoredTags []string\n\t\t\t\tss.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, ss.Tags)\n\t\t\t\tignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)\n\n\t\t\t\tsceneResults = append(sceneResults, ss)\n\t\t\t}\n\t\t\tresults = append(results, sceneResults)\n\t\t}\n\t}\n\n\tscraper.LogIgnoredTags(ignoredTags)\n\n\t// repopulate the results to be the same order as the input\n\tret := make([][]*models.ScrapedScene, len(scenes))\n\tupTo := 0\n\n\tfor i, v := range scenes {\n\t\tif len(v) > 0 {\n\t\t\tret[i] = results[upTo]\n\t\t\tupTo++\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.SceneFragment) (*models.ScrapedScene, error) {\n\tstashID := s.ID\n\n\tss := &models.ScrapedScene{\n\t\tTitle:        s.Title,\n\t\tCode:         s.Code,\n\t\tDate:         s.Date,\n\t\tDetails:      s.Details,\n\t\tDirector:     s.Director,\n\t\tURL:          findURL(s.Urls, \"STUDIO\"),\n\t\tDuration:     s.Duration,\n\t\tRemoteSiteID: &stashID,\n\t\tFingerprints: getFingerprints(s),\n\t\t// Image\n\t\t// stash_id\n\t}\n\n\tfor _, u := range s.Urls {\n\t\tss.URLs = append(ss.URLs, u.URL)\n\t}\n\n\tif len(ss.URLs) > 0 {\n\t\tss.URL = &ss.URLs[0]\n\t}\n\n\tif len(s.Images) > 0 {\n\t\t// TODO - #454 code sorts images by aspect ratio according to a wanted\n\t\t// orientation. I'm just grabbing the first for now\n\t\tss.Image = getFirstImage(ctx, c.httpClient, s.Images)\n\t}\n\n\tss.URLs = make([]string, len(s.Urls))\n\tfor i, u := range s.Urls {\n\t\tss.URLs[i] = u.URL\n\t}\n\n\tif s.Studio != nil {\n\t\tvar err error\n\t\tss.Studio, err = c.resolveStudio(ctx, s.Studio)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tfor _, p := range s.Performers {\n\t\tsp := performerFragmentToScrapedPerformer(*p.Performer)\n\t\tss.Performers = append(ss.Performers, sp)\n\t}\n\n\tfor _, t := range s.Tags {\n\t\tst := &models.ScrapedTag{\n\t\t\tName:         t.Name,\n\t\t\tRemoteSiteID: &t.ID,\n\t\t}\n\t\tss.Tags = append(ss.Tags, st)\n\t}\n\n\treturn ss, nil\n}\n\nfunc getFirstImage(ctx context.Context, client *http.Client, images []*graphql.ImageFragment) *string {\n\tret, err := fetchImage(ctx, client, images[0].URL)\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\tlogger.Warnf(\"Error fetching image %s: %s\", images[0].URL, err.Error())\n\t}\n\n\treturn ret\n}\n\nfunc getFingerprints(scene *graphql.SceneFragment) []*models.StashBoxFingerprint {\n\tfingerprints := []*models.StashBoxFingerprint{}\n\tfor _, fp := range scene.Fingerprints {\n\t\tfingerprint := models.StashBoxFingerprint{\n\t\t\tAlgorithm: fp.Algorithm.String(),\n\t\t\tHash:      fp.Hash,\n\t\t\tDuration:  fp.Duration,\n\t\t}\n\t\tfingerprints = append(fingerprints, &fingerprint)\n\t}\n\treturn fingerprints\n}\n\ntype SceneDraft struct {\n\t// Files, URLs, StashIDs must be loaded\n\tScene *models.Scene\n\t// StashIDs must be loaded\n\tPerformers []*models.Performer\n\t// StashIDs must be loaded\n\tStudio *models.Studio\n\t// StashIDs must be loaded\n\tTags  []*models.Tag\n\tCover []byte\n}\n\nfunc (c Client) SubmitSceneDraft(ctx context.Context, d SceneDraft) (*string, error) {\n\tdraft := newSceneDraftInput(d, c.box.Endpoint)\n\tvar image io.Reader\n\n\tif len(d.Cover) > 0 {\n\t\timage = bytes.NewReader(d.Cover)\n\t}\n\n\tvar id *string\n\tvar ret graphql.SubmitSceneDraft\n\terr := c.submitDraft(ctx, graphql.SubmitSceneDraftDocument, draft, image, &ret)\n\tid = ret.SubmitSceneDraft.ID\n\n\treturn id, err\n\n\t// ret, err := c.client.SubmitSceneDraft(ctx, draft, uploadImage(image))\n\t// if err != nil {\n\t// \treturn nil, err\n\t// }\n\n\t// id := ret.SubmitSceneDraft.ID\n\t// return id, nil\n}\n\nfunc newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput {\n\tscene := d.Scene\n\n\tdraft := graphql.SceneDraftInput{}\n\n\tif scene.Title != \"\" {\n\t\tdraft.Title = &scene.Title\n\t}\n\tif scene.Code != \"\" {\n\t\tdraft.Code = &scene.Code\n\t}\n\tif scene.Details != \"\" {\n\t\tdraft.Details = &scene.Details\n\t}\n\tif scene.Director != \"\" {\n\t\tdraft.Director = &scene.Director\n\t}\n\tdraft.Urls = scene.URLs.List()\n\n\tif scene.Date != nil {\n\t\tv := scene.Date.String()\n\t\tdraft.Date = &v\n\t}\n\n\tif d.Studio != nil {\n\t\tstudio := d.Studio\n\n\t\tstudioDraft := graphql.DraftEntityInput{\n\t\t\tName: studio.Name,\n\t\t}\n\n\t\tstashIDs := studio.StashIDs.List()\n\t\tfor _, stashID := range stashIDs {\n\t\t\tc := stashID\n\t\t\tif stashID.Endpoint == endpoint {\n\t\t\t\tstudioDraft.ID = &c.StashID\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tdraft.Studio = &studioDraft\n\t}\n\n\tfingerprints := []*graphql.FingerprintInput{}\n\n\tfor _, f := range scene.Files.List() {\n\t\tduration := f.Duration\n\n\t\tif duration != 0 {\n\t\t\tfingerprints = appendFingerprintsUnique(fingerprints, fileFingerprintsToInputGraphQL(f.Fingerprints, int(duration))...)\n\t\t}\n\t}\n\tdraft.Fingerprints = fingerprints\n\n\tscenePerformers := d.Performers\n\n\tinputPerformers := []*graphql.DraftEntityInput{}\n\tfor _, p := range scenePerformers {\n\t\tperformerDraft := graphql.DraftEntityInput{\n\t\t\tName: p.Name,\n\t\t}\n\n\t\tstashIDs := p.StashIDs.List()\n\t\tfor _, stashID := range stashIDs {\n\t\t\tc := stashID\n\t\t\tif stashID.Endpoint == endpoint {\n\t\t\t\tperformerDraft.ID = &c.StashID\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tinputPerformers = append(inputPerformers, &performerDraft)\n\t}\n\tdraft.Performers = inputPerformers\n\n\tvar tags []*graphql.DraftEntityInput\n\tsceneTags := d.Tags\n\tfor _, tag := range sceneTags {\n\t\ttagDraft := graphql.DraftEntityInput{Name: tag.Name}\n\n\t\tstashIDs := tag.StashIDs.List()\n\t\tfor _, stashID := range stashIDs {\n\t\t\tif stashID.Endpoint == endpoint {\n\t\t\t\ttagDraft.ID = &stashID.StashID\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\ttags = append(tags, &tagDraft)\n\t}\n\tdraft.Tags = tags\n\n\tstashIDs := scene.StashIDs.List()\n\tvar stashID *string\n\tfor _, v := range stashIDs {\n\t\tif v.Endpoint == endpoint {\n\t\t\tvv := v.StashID\n\t\t\tstashID = &vv\n\t\t\tbreak\n\t\t}\n\t}\n\tdraft.ID = stashID\n\n\treturn draft\n}\n\nfunc fileFingerprintsToInputGraphQL(fps models.Fingerprints, duration int) []*graphql.FingerprintInput {\n\tvar ret []*graphql.FingerprintInput\n\n\tfor _, f := range fps {\n\t\tvar i = &graphql.FingerprintInput{\n\t\t\tDuration: duration,\n\t\t}\n\t\tswitch f.Type {\n\t\tcase models.FingerprintTypeMD5:\n\t\t\ti.Algorithm = graphql.FingerprintAlgorithmMd5\n\t\t\ti.Hash = f.String()\n\t\tcase models.FingerprintTypeOshash:\n\t\t\ti.Algorithm = graphql.FingerprintAlgorithmOshash\n\t\t\ti.Hash = f.String()\n\t\tcase models.FingerprintTypePhash:\n\t\t\ti.Algorithm = graphql.FingerprintAlgorithmPhash\n\t\t\ti.Hash = utils.PhashToString(f.Int64())\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\n\t\tif !i.Algorithm.IsValid() {\n\t\t\tcontinue\n\t\t}\n\n\t\tret = appendFingerprintUnique(ret, i)\n\t}\n\n\treturn ret\n}\n\nfunc (c Client) SubmitFingerprints(ctx context.Context, scenes []*models.Scene) (bool, error) {\n\tendpoint := c.box.Endpoint\n\n\tvar fingerprints []graphql.FingerprintSubmission\n\n\tfor _, scene := range scenes {\n\t\tstashIDs := scene.StashIDs.List()\n\t\tsceneStashID := \"\"\n\t\tfor _, stashID := range stashIDs {\n\t\t\tif stashID.Endpoint == endpoint {\n\t\t\t\tsceneStashID = stashID.StashID\n\t\t\t}\n\t\t}\n\n\t\tif sceneStashID == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, f := range scene.Files.List() {\n\t\t\tduration := f.Duration\n\n\t\t\tif duration == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfps := fileFingerprintsToInputGraphQL(f.Fingerprints, int(duration))\n\t\t\tfor _, fp := range fps {\n\t\t\t\tfingerprints = append(fingerprints, graphql.FingerprintSubmission{\n\t\t\t\t\tSceneID:     sceneStashID,\n\t\t\t\t\tFingerprint: fp,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn c.submitFingerprints(ctx, fingerprints)\n}\n\nfunc (c Client) submitFingerprints(ctx context.Context, fingerprints []graphql.FingerprintSubmission) (bool, error) {\n\tfor _, fingerprint := range fingerprints {\n\t\t_, err := c.client.SubmitFingerprint(ctx, fingerprint)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\treturn true, nil\n}\n\nfunc appendFingerprintUnique(v []*graphql.FingerprintInput, toAdd *graphql.FingerprintInput) []*graphql.FingerprintInput {\n\tfor _, vv := range v {\n\t\tif vv.Algorithm == toAdd.Algorithm && vv.Hash == toAdd.Hash {\n\t\t\treturn v\n\t\t}\n\t}\n\n\treturn append(v, toAdd)\n}\n\nfunc appendFingerprintsUnique(v []*graphql.FingerprintInput, toAdd ...*graphql.FingerprintInput) []*graphql.FingerprintInput {\n\tfor _, a := range toAdd {\n\t\tv = appendFingerprintUnique(v, a)\n\t}\n\n\treturn v\n}\n"
  },
  {
    "path": "pkg/stashbox/studio.go",
    "content": "package stashbox\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/stashbox/graphql\"\n)\n\nfunc (c Client) resolveStudio(ctx context.Context, s *graphql.StudioFragment) (*models.ScrapedStudio, error) {\n\tscraped := studioFragmentToScrapedStudio(*s)\n\n\tif s.Parent != nil {\n\t\tparentStudio, err := c.client.FindStudio(ctx, &s.Parent.ID, nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif parentStudio.FindStudio == nil {\n\t\t\treturn scraped, nil\n\t\t}\n\n\t\tscraped.Parent, err = c.resolveStudio(ctx, parentStudio.FindStudio)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn scraped, nil\n}\n\nfunc (c Client) FindStudio(ctx context.Context, query string) (*models.ScrapedStudio, error) {\n\tvar studio *graphql.FindStudio\n\n\t_, err := uuid.Parse(query)\n\tif err == nil {\n\t\t// Confirmed the user passed in a Stash ID\n\t\tstudio, err = c.client.FindStudio(ctx, &query, nil)\n\t} else {\n\t\t// Otherwise assume they're searching on a name\n\t\tstudio, err = c.client.FindStudio(ctx, nil, &query)\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ret *models.ScrapedStudio\n\tif studio.FindStudio != nil {\n\t\tret, err = c.resolveStudio(ctx, studio.FindStudio)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn ret, nil\n}\n\nfunc studioFragmentToScrapedStudio(s graphql.StudioFragment) *models.ScrapedStudio {\n\timages := []string{}\n\tfor _, image := range s.Images {\n\t\timages = append(images, image.URL)\n\t}\n\n\taliases := strings.Join(s.Aliases, \", \")\n\n\tst := &models.ScrapedStudio{\n\t\tName:         s.Name,\n\t\tAliases:      &aliases,\n\t\tImages:       images,\n\t\tRemoteSiteID: &s.ID,\n\t}\n\n\tfor _, u := range s.Urls {\n\t\tst.URLs = append(st.URLs, u.URL)\n\t}\n\n\tif len(st.Images) > 0 {\n\t\tst.Image = &st.Images[0]\n\t}\n\n\treturn st\n}\n"
  },
  {
    "path": "pkg/stashbox/tag.go",
    "content": "package stashbox\n\nimport (\n\t\"context\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/stashbox/graphql\"\n)\n\n// QueryTag searches for tags by name or ID.\n// If query is a valid UUID, it searches by ID (returns single result).\n// Otherwise, it searches by name (returns multiple results).\nfunc (c Client) QueryTag(ctx context.Context, query string) ([]*models.ScrapedTag, error) {\n\t_, err := uuid.Parse(query)\n\tif err == nil {\n\t\t// Query is a UUID, use findTag for exact match\n\t\treturn c.findTagByID(ctx, query)\n\t}\n\t// Otherwise search by name\n\treturn c.queryTagsByName(ctx, query)\n}\n\nfunc (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTag, error) {\n\ttag, err := c.client.FindTag(ctx, &id, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif tag.FindTag == nil {\n\t\treturn nil, nil\n\t}\n\n\tret := tagFragmentToScrapedTag(*tag.FindTag)\n\treturn []*models.ScrapedTag{ret}, nil\n}\n\nfunc (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) {\n\tinput := graphql.TagQueryInput{\n\t\tName:      &name,\n\t\tPage:      1,\n\t\tPerPage:   25,\n\t\tDirection: graphql.SortDirectionEnumAsc,\n\t\tSort:      graphql.TagSortEnumName,\n\t}\n\n\tresult, err := c.client.QueryTags(ctx, input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif result.QueryTags.Tags == nil {\n\t\treturn nil, nil\n\t}\n\n\tvar ret []*models.ScrapedTag\n\tfor _, t := range result.QueryTags.Tags {\n\t\tret = append(ret, tagFragmentToScrapedTag(*t))\n\t}\n\n\treturn ret, nil\n}\n\nfunc tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag {\n\tret := &models.ScrapedTag{\n\t\tName:         t.Name,\n\t\tDescription:  t.Description,\n\t\tRemoteSiteID: &t.ID,\n\t}\n\n\tif len(t.Aliases) > 0 {\n\t\tret.AliasList = t.Aliases\n\t}\n\n\tif t.Category != nil {\n\t\tret.Parent = &models.ScrapedTag{\n\t\t\tName:        t.Category.Name,\n\t\t\tDescription: t.Category.Description,\n\t\t}\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/studio/doc.go",
    "content": "// Package studio provides the application logic for studio functionality.\npackage studio\n"
  },
  {
    "path": "pkg/studio/export.go",
    "content": "package studio\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype FinderImageStashIDGetter interface {\n\tmodels.StudioGetter\n\tmodels.AliasLoader\n\tmodels.URLLoader\n\tmodels.StashIDLoader\n\tGetImage(ctx context.Context, studioID int) ([]byte, error)\n\tmodels.CustomFieldsReader\n}\n\n// ToJSON converts a Studio object into its JSON equivalent.\nfunc ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models.Studio) (*jsonschema.Studio, error) {\n\tnewStudioJSON := jsonschema.Studio{\n\t\tName:          studio.Name,\n\t\tDetails:       studio.Details,\n\t\tFavorite:      studio.Favorite,\n\t\tIgnoreAutoTag: studio.IgnoreAutoTag,\n\t\tOrganized:     studio.Organized,\n\t\tCreatedAt:     json.JSONTime{Time: studio.CreatedAt},\n\t\tUpdatedAt:     json.JSONTime{Time: studio.UpdatedAt},\n\t}\n\n\tif studio.ParentID != nil {\n\t\tparent, err := reader.Find(ctx, *studio.ParentID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting parent studio: %v\", err)\n\t\t}\n\n\t\tif parent != nil {\n\t\t\tnewStudioJSON.ParentStudio = parent.Name\n\t\t}\n\t}\n\n\tif studio.Rating != nil {\n\t\tnewStudioJSON.Rating = *studio.Rating\n\t}\n\n\tif err := studio.LoadAliases(ctx, reader); err != nil {\n\t\treturn nil, fmt.Errorf(\"loading studio aliases: %w\", err)\n\t}\n\tnewStudioJSON.Aliases = studio.Aliases.List()\n\n\tif err := studio.LoadURLs(ctx, reader); err != nil {\n\t\treturn nil, fmt.Errorf(\"loading studio URLs: %w\", err)\n\t}\n\tnewStudioJSON.URLs = studio.URLs.List()\n\n\tif err := studio.LoadStashIDs(ctx, reader); err != nil {\n\t\treturn nil, fmt.Errorf(\"loading studio stash ids: %w\", err)\n\t}\n\tnewStudioJSON.StashIDs = studio.StashIDs.List()\n\n\tvar err error\n\tnewStudioJSON.CustomFields, err = reader.GetCustomFields(ctx, studio.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting studio custom fields: %v\", err)\n\t}\n\n\timage, err := reader.GetImage(ctx, studio.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error getting studio image: %v\", err)\n\t}\n\n\tif len(image) > 0 {\n\t\tnewStudioJSON.Image = utils.GetBase64StringFromData(image)\n\t}\n\n\treturn &newStudioJSON, nil\n}\n"
  },
  {
    "path": "pkg/studio/export_test.go",
    "content": "package studio\n\nimport (\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\tnoImageID             = 2\n\terrImageID            = 3\n\tmissingParentStudioID = 4\n\terrStudioID           = 5\n\tcustomFieldsID        = 6\n\n\tparentStudioID    = 10\n\tmissingStudioID   = 11\n\terrParentStudioID = 12\n\terrCustomFieldsID = 13\n)\n\nvar (\n\tstudioName        = \"testStudio\"\n\turl               = \"url\"\n\tdetails           = \"details\"\n\tparentStudioName  = \"parentStudio\"\n\tautoTagIgnored    = true\n\tstudioOrganized   = true\n\temptyCustomFields = make(map[string]interface{})\n\tcustomFields      = map[string]interface{}{\n\t\t\"customField1\": \"customValue1\",\n\t}\n)\n\nvar studioID = 1\nvar rating = 5\nvar parentStudio models.Studio = models.Studio{\n\tName: parentStudioName,\n}\n\nvar imageBytes = []byte(\"imageBytes\")\n\nvar aliases = []string{\"alias\"}\nvar stashID = models.StashID{\n\tStashID:  \"StashID\",\n\tEndpoint: \"Endpoint\",\n}\nvar stashIDs = []models.StashID{\n\tstashID,\n}\n\nconst image = \"aW1hZ2VCeXRlcw==\"\n\nvar (\n\tcreateTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local)\n\tupdateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local)\n)\n\nfunc createFullStudio(id int, parentID int) models.Studio {\n\tret := models.Studio{\n\t\tID:            id,\n\t\tName:          studioName,\n\t\tURLs:          models.NewRelatedStrings([]string{url}),\n\t\tDetails:       details,\n\t\tFavorite:      true,\n\t\tCreatedAt:     createTime,\n\t\tUpdatedAt:     updateTime,\n\t\tRating:        &rating,\n\t\tIgnoreAutoTag: autoTagIgnored,\n\t\tOrganized:     studioOrganized,\n\t\tAliases:       models.NewRelatedStrings(aliases),\n\t\tTagIDs:        models.NewRelatedIDs([]int{}),\n\t\tStashIDs:      models.NewRelatedStashIDs(stashIDs),\n\t}\n\n\tif parentID != 0 {\n\t\tret.ParentID = &parentID\n\t}\n\n\treturn ret\n}\n\nfunc createEmptyStudio(id int) models.Studio {\n\treturn models.Studio{\n\t\tID:        id,\n\t\tCreatedAt: createTime,\n\t\tUpdatedAt: updateTime,\n\t\tURLs:      models.NewRelatedStrings([]string{}),\n\t\tAliases:   models.NewRelatedStrings([]string{}),\n\t\tTagIDs:    models.NewRelatedIDs([]int{}),\n\t\tStashIDs:  models.NewRelatedStashIDs([]models.StashID{}),\n\t}\n}\n\nfunc createFullJSONStudio(parentStudio, image string, aliases []string, customFields map[string]interface{}) *jsonschema.Studio {\n\treturn &jsonschema.Studio{\n\t\tName:     studioName,\n\t\tURLs:     []string{url},\n\t\tDetails:  details,\n\t\tFavorite: true,\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tParentStudio:  parentStudio,\n\t\tImage:         image,\n\t\tRating:        rating,\n\t\tAliases:       aliases,\n\t\tStashIDs:      stashIDs,\n\t\tIgnoreAutoTag: autoTagIgnored,\n\t\tOrganized:     studioOrganized,\n\t\tCustomFields:  customFields,\n\t}\n}\n\nfunc createEmptyJSONStudio() *jsonschema.Studio {\n\treturn &jsonschema.Studio{\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tAliases:      []string{},\n\t\tURLs:         []string{},\n\t\tStashIDs:     []models.StashID{},\n\t\tCustomFields: emptyCustomFields,\n\t}\n}\n\ntype testScenario struct {\n\tinput        models.Studio\n\tcustomFields map[string]interface{}\n\texpected     *jsonschema.Studio\n\terr          bool\n}\n\nvar scenarios []testScenario\n\nfunc initTestTable() {\n\tscenarios = []testScenario{\n\t\t{\n\t\t\tcreateFullStudio(studioID, parentStudioID),\n\t\t\temptyCustomFields,\n\t\t\tcreateFullJSONStudio(parentStudioName, image, []string{\"alias\"}, emptyCustomFields),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateFullStudio(customFieldsID, parentStudioID),\n\t\t\tcustomFields,\n\t\t\tcreateFullJSONStudio(parentStudioName, image, []string{\"alias\"}, customFields),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateEmptyStudio(noImageID),\n\t\t\temptyCustomFields,\n\t\t\tcreateEmptyJSONStudio(),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateFullStudio(errImageID, parentStudioID),\n\t\t\temptyCustomFields,\n\t\t\tcreateFullJSONStudio(parentStudioName, \"\", []string{\"alias\"}, emptyCustomFields),\n\t\t\t// failure to get image is not an error\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateFullStudio(missingParentStudioID, missingStudioID),\n\t\t\temptyCustomFields,\n\t\t\tcreateFullJSONStudio(\"\", image, []string{\"alias\"}, emptyCustomFields),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateFullStudio(errStudioID, errParentStudioID),\n\t\t\temptyCustomFields,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\tcreateFullStudio(errCustomFieldsID, parentStudioID),\n\t\t\tcustomFields,\n\t\t\tnil,\n\t\t\t// failure to get custom fields should cause an error\n\t\t\ttrue,\n\t\t},\n\t}\n}\n\nfunc TestToJSON(t *testing.T) {\n\tinitTestTable()\n\n\tdb := mocks.NewDatabase()\n\n\timageErr := errors.New(\"error getting image\")\n\n\tdb.Studio.On(\"GetImage\", testCtx, studioID).Return(imageBytes, nil).Once()\n\tdb.Studio.On(\"GetImage\", testCtx, noImageID).Return(nil, nil).Once()\n\tdb.Studio.On(\"GetImage\", testCtx, errImageID).Return(nil, imageErr).Once()\n\tdb.Studio.On(\"GetImage\", testCtx, missingParentStudioID).Return(imageBytes, nil).Maybe()\n\tdb.Studio.On(\"GetImage\", testCtx, errStudioID).Return(imageBytes, nil).Maybe()\n\tdb.Studio.On(\"GetImage\", testCtx, customFieldsID).Return(imageBytes, nil).Once()\n\n\tparentStudioErr := errors.New(\"error getting parent studio\")\n\n\tdb.Studio.On(\"Find\", testCtx, parentStudioID).Return(&parentStudio, nil)\n\tdb.Studio.On(\"Find\", testCtx, missingStudioID).Return(nil, nil)\n\tdb.Studio.On(\"Find\", testCtx, errParentStudioID).Return(nil, parentStudioErr)\n\n\tcustomFieldsErr := errors.New(\"error getting custom fields\")\n\n\tdb.Studio.On(\"GetCustomFields\", testCtx, studioID).Return(emptyCustomFields, nil).Once()\n\tdb.Studio.On(\"GetCustomFields\", testCtx, customFieldsID).Return(customFields, nil).Once()\n\tdb.Studio.On(\"GetCustomFields\", testCtx, missingParentStudioID).Return(emptyCustomFields, nil).Once()\n\tdb.Studio.On(\"GetCustomFields\", testCtx, noImageID).Return(emptyCustomFields, nil).Once()\n\tdb.Studio.On(\"GetCustomFields\", testCtx, errImageID).Return(emptyCustomFields, nil).Once()\n\tdb.Studio.On(\"GetCustomFields\", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once()\n\n\tfor i, s := range scenarios {\n\t\tstudio := s.input\n\t\tjson, err := ToJSON(testCtx, db.Studio, &studio)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/studio/import.go",
    "content": "package studio\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ImporterReaderWriter interface {\n\tmodels.StudioCreatorUpdater\n\tFindByName(ctx context.Context, name string, nocase bool) (*models.Studio, error)\n}\n\nvar ErrParentStudioNotExist = errors.New(\"parent studio does not exist\")\n\ntype Importer struct {\n\tReaderWriter        ImporterReaderWriter\n\tTagWriter           models.TagFinderCreator\n\tInput               jsonschema.Studio\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\tID           int\n\tstudio       models.Studio\n\tcustomFields models.CustomFieldMap\n\timageData    []byte\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\ti.studio = studioJSONtoStudio(i.Input)\n\ti.customFields = i.Input.CustomFields\n\n\tif err := i.populateParentStudio(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.populateTags(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tvar err error\n\tif len(i.Input.Image) > 0 {\n\t\ti.imageData, err = utils.ProcessBase64Image(i.Input.Image)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid image: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) populateTags(ctx context.Context) error {\n\tif len(i.Input.Tags) > 0 {\n\n\t\ttags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, p := range tags {\n\t\t\ti.studio.TagIDs.Add(p.ID)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {\n\ttags, err := tagWriter.FindByNames(ctx, names, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar pluckedNames []string\n\tfor _, tag := range tags {\n\t\tpluckedNames = append(pluckedNames, tag.Name)\n\t}\n\n\tmissingTags := sliceutil.Filter(names, func(name string) bool {\n\t\treturn !slices.Contains(pluckedNames, name)\n\t})\n\n\tif len(missingTags) > 0 {\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\treturn nil, fmt.Errorf(\"tags [%s] not found\", strings.Join(missingTags, \", \"))\n\t\t}\n\n\t\tif missingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\tcreatedTags, err := createTags(ctx, tagWriter, missingTags)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error creating tags: %v\", err)\n\t\t\t}\n\n\t\t\ttags = append(tags, createdTags...)\n\t\t}\n\n\t\t// ignore if MissingRefBehaviour set to Ignore\n\t}\n\n\treturn tags, nil\n}\n\nfunc createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) {\n\tvar ret []*models.Tag\n\tfor _, name := range names {\n\t\tnewTag := models.NewTag()\n\t\tnewTag.Name = name\n\n\t\terr := tagWriter.Create(ctx, &models.CreateTagInput{\n\t\t\tTag: &newTag,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tret = append(ret, &newTag)\n\t}\n\n\treturn ret, nil\n}\n\nfunc (i *Importer) populateParentStudio(ctx context.Context) error {\n\tif i.Input.ParentStudio != \"\" {\n\t\tstudio, err := i.ReaderWriter.FindByName(ctx, i.Input.ParentStudio, false)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error finding studio by name: %v\", err)\n\t\t}\n\n\t\tif studio == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn ErrParentStudioNotExist\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tparentID, err := i.createParentStudio(ctx, i.Input.ParentStudio)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\ti.studio.ParentID = &parentID\n\t\t\t}\n\t\t} else {\n\t\t\ti.studio.ParentID = &studio.ID\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) createParentStudio(ctx context.Context, name string) (int, error) {\n\tnewStudio := models.NewCreateStudioInput()\n\tnewStudio.Name = name\n\n\terr := i.ReaderWriter.Create(ctx, &newStudio)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn newStudio.ID, nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\tif len(i.imageData) > 0 {\n\t\tif err := i.ReaderWriter.UpdateImage(ctx, id, i.imageData); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting studio image: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\treturn i.Input.Name\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\tconst nocase = false\n\texisting, err := i.ReaderWriter.FindByName(ctx, i.Name(), nocase)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif existing != nil {\n\t\tid := existing.ID\n\t\treturn &id, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\terr := i.ReaderWriter.Create(ctx, &models.CreateStudioInput{\n\t\tStudio:       &i.studio,\n\t\tCustomFields: i.customFields,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating studio: %v\", err)\n\t}\n\n\tid := i.studio.ID\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\tstudio := i.studio\n\tstudio.ID = id\n\terr := i.ReaderWriter.Update(ctx, &models.UpdateStudioInput{\n\t\tStudio: &studio,\n\t\tCustomFields: models.CustomFieldsInput{\n\t\t\tFull: i.customFields,\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating existing studio: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio {\n\tnewStudio := models.Studio{\n\t\tName:          studioJSON.Name,\n\t\tAliases:       models.NewRelatedStrings(studioJSON.Aliases),\n\t\tDetails:       studioJSON.Details,\n\t\tFavorite:      studioJSON.Favorite,\n\t\tIgnoreAutoTag: studioJSON.IgnoreAutoTag,\n\t\tOrganized:     studioJSON.Organized,\n\t\tCreatedAt:     studioJSON.CreatedAt.GetTime(),\n\t\tUpdatedAt:     studioJSON.UpdatedAt.GetTime(),\n\n\t\tTagIDs:   models.NewRelatedIDs([]int{}),\n\t\tStashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs),\n\t}\n\n\tif len(studioJSON.URLs) > 0 {\n\t\tnewStudio.URLs = models.NewRelatedStrings(studioJSON.URLs)\n\t} else {\n\t\turls := []string{}\n\t\tif studioJSON.URL != \"\" {\n\t\t\turls = append(urls, studioJSON.URL)\n\t\t}\n\n\t\tif len(urls) > 0 {\n\t\t\tnewStudio.URLs = models.NewRelatedStrings(urls)\n\t\t}\n\t}\n\n\tif studioJSON.Rating != 0 {\n\t\tnewStudio.Rating = &studioJSON.Rating\n\t}\n\n\treturn newStudio\n}\n"
  },
  {
    "path": "pkg/studio/import_test.go",
    "content": "package studio\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nconst invalidImage = \"aW1hZ2VCeXRlcw&&\"\n\nconst (\n\tstudioNameErr      = \"studioNameErr\"\n\texistingStudioName = \"existingStudioName\"\n\n\texistingStudioID = 100\n\texistingTagID    = 105\n\terrTagsID        = 106\n\n\texistingParentStudioName = \"existingParentStudioName\"\n\texistingParentStudioErr  = \"existingParentStudioErr\"\n\tmissingParentStudioName  = \"existingParentStudioName\"\n\n\texistingTagName = \"existingTagName\"\n\texistingTagErr  = \"existingTagErr\"\n\tmissingTagName  = \"missingTagName\"\n)\n\nvar testCtx = context.Background()\n\nfunc TestImporterName(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Studio{\n\t\t\tName: studioName,\n\t\t},\n\t}\n\n\tassert.Equal(t, studioName, i.Name())\n}\n\nfunc TestImporterPreImport(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Studio{\n\t\t\tName:          studioName,\n\t\t\tImage:         invalidImage,\n\t\t\tIgnoreAutoTag: autoTagIgnored,\n\t\t\tOrganized:     studioOrganized,\n\t\t},\n\t}\n\n\terr := i.PreImport(testCtx)\n\n\tassert.NotNil(t, err)\n\n\ti.Input.Image = image\n\n\terr = i.PreImport(testCtx)\n\n\tassert.Nil(t, err)\n\n\ti.Input = *createFullJSONStudio(studioName, image, []string{\"alias\"}, customFields)\n\ti.Input.ParentStudio = \"\"\n\n\terr = i.PreImport(testCtx)\n\n\tassert.Nil(t, err)\n\texpectedStudio := createFullStudio(0, 0)\n\texpectedStudio.ParentID = nil\n\tassert.Equal(t, expectedStudio, i.studio)\n\tassert.Equal(t, models.CustomFieldMap(customFields), i.customFields)\n}\n\nfunc TestImporterPreImportWithTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter:        db.Studio,\n\t\tTagWriter:           db.Tag,\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t\tInput: jsonschema.Studio{\n\t\t\tTags: []string{\n\t\t\t\texistingTagName,\n\t\t\t},\n\t\t},\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagName}, false).Return([]*models.Tag{\n\t\t{\n\t\t\tID:   existingTagID,\n\t\t\tName: existingTagName,\n\t\t},\n\t}, nil).Once()\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{existingTagErr}, false).Return(nil, errors.New(\"FindByNames error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingTagID, i.studio.TagIDs.List()[0])\n\n\ti.Input.Tags = []string{existingTagErr}\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTag(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Studio{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Run(func(args mock.Arguments) {\n\t\tt := args.Get(1).(*models.CreateTagInput)\n\t\tt.Tag.ID = existingTagID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingTagID, i.studio.TagIDs.List()[0])\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Studio{\n\t\t\tTags: []string{\n\t\t\t\tmissingTagName,\n\t\t\t},\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Tag.On(\"FindByNames\", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()\n\tdb.Tag.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateTagInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithParent(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tInput: jsonschema.Studio{\n\t\t\tName:         studioName,\n\t\t\tImage:        image,\n\t\t\tParentStudio: existingParentStudioName,\n\t\t},\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, existingParentStudioName, false).Return(&models.Studio{\n\t\tID: existingStudioID,\n\t}, nil).Once()\n\tdb.Studio.On(\"FindByName\", testCtx, existingParentStudioErr, false).Return(nil, errors.New(\"FindByName error\")).Once()\n\n\terr := i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.studio.ParentID)\n\n\ti.Input.ParentStudio = existingParentStudioErr\n\terr = i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingParent(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tInput: jsonschema.Studio{\n\t\t\tName:         studioName,\n\t\t\tImage:        image,\n\t\t\tParentStudio: missingParentStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumFail,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingParentStudioName, false).Return(nil, nil).Times(3)\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Run(func(args mock.Arguments) {\n\t\ts := args.Get(1).(*models.CreateStudioInput)\n\t\ts.Studio.ID = existingStudioID\n\t}).Return(nil)\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\terr = i.PreImport(testCtx)\n\tassert.Nil(t, err)\n\tassert.Equal(t, existingStudioID, *i.studio.ParentID)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPreImportWithMissingParentCreateErr(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tInput: jsonschema.Studio{\n\t\t\tName:         studioName,\n\t\t\tImage:        image,\n\t\t\tParentStudio: missingParentStudioName,\n\t\t},\n\t\tMissingRefBehaviour: models.ImportMissingRefEnumCreate,\n\t}\n\n\tdb.Studio.On(\"FindByName\", testCtx, missingParentStudioName, false).Return(nil, nil).Once()\n\tdb.Studio.On(\"Create\", testCtx, mock.AnythingOfType(\"*models.CreateStudioInput\")).Return(errors.New(\"Create error\"))\n\n\terr := i.PreImport(testCtx)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPostImport(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Studio{\n\t\t\tAliases: []string{\"alias\"},\n\t\t},\n\t\timageData: imageBytes,\n\t}\n\n\tupdateStudioImageErr := errors.New(\"UpdateImage error\")\n\n\tdb.Studio.On(\"UpdateImage\", testCtx, studioID, imageBytes).Return(nil).Once()\n\tdb.Studio.On(\"UpdateImage\", testCtx, errImageID, imageBytes).Return(updateStudioImageErr).Once()\n\n\terr := i.PostImport(testCtx, studioID)\n\tassert.Nil(t, err)\n\n\terr = i.PostImport(testCtx, errImageID)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterFindExistingID(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tTagWriter:    db.Tag,\n\t\tInput: jsonschema.Studio{\n\t\t\tName: studioName,\n\t\t},\n\t}\n\n\terrFindByName := errors.New(\"FindByName error\")\n\tdb.Studio.On(\"FindByName\", testCtx, studioName, false).Return(nil, nil).Once()\n\tdb.Studio.On(\"FindByName\", testCtx, existingStudioName, false).Return(&models.Studio{\n\t\tID: existingStudioID,\n\t}, nil).Once()\n\tdb.Studio.On(\"FindByName\", testCtx, studioNameErr, false).Return(nil, errFindByName).Once()\n\n\tid, err := i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.Nil(t, err)\n\n\ti.Input.Name = existingStudioName\n\tid, err = i.FindExistingID(testCtx)\n\tassert.Equal(t, existingStudioID, *id)\n\tassert.Nil(t, err)\n\n\ti.Input.Name = studioNameErr\n\tid, err = i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestCreate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tstudio := models.Studio{\n\t\tName: studioName,\n\t}\n\n\tstudioErr := models.Studio{\n\t\tName: studioNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tTagWriter:    db.Tag,\n\t\tstudio:       studio,\n\t}\n\n\terrCreate := errors.New(\"Create error\")\n\tdb.Studio.On(\"Create\", testCtx, &models.CreateStudioInput{Studio: &studio}).Run(func(args mock.Arguments) {\n\t\ts := args.Get(1).(*models.CreateStudioInput)\n\t\ts.ID = studioID\n\t}).Return(nil).Once()\n\tdb.Studio.On(\"Create\", testCtx, &models.CreateStudioInput{Studio: &studioErr}).Return(errCreate).Once()\n\n\tid, err := i.Create(testCtx)\n\tassert.Equal(t, studioID, *id)\n\tassert.Nil(t, err)\n\n\ti.studio = studioErr\n\tid, err = i.Create(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestUpdate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tstudio := models.Studio{\n\t\tName: studioName,\n\t}\n\n\tstudioErr := models.Studio{\n\t\tName: studioNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.Studio,\n\t\tTagWriter:    db.Tag,\n\t\tstudio:       studio,\n\t}\n\n\terrUpdate := errors.New(\"Update error\")\n\n\t// id needs to be set for the mock input\n\tstudio.ID = studioID\n\tdb.Studio.On(\"Update\", testCtx, &models.UpdateStudioInput{Studio: &studio}).Return(nil).Once()\n\n\terr := i.Update(testCtx, studioID)\n\tassert.Nil(t, err)\n\n\ti.studio = studioErr\n\n\t// need to set id separately\n\tstudioErr.ID = errImageID\n\tdb.Studio.On(\"Update\", testCtx, &models.UpdateStudioInput{Studio: &studioErr}).Return(errUpdate).Once()\n\n\terr = i.Update(testCtx, errImageID)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/studio/query.go",
    "content": "package studio\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc ByName(ctx context.Context, qb models.StudioQueryer, name string) (*models.Studio, error) {\n\tf := &models.StudioFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    name,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tpp := 1\n\tret, count, err := qb.Query(ctx, f, &models.FindFilterType{\n\t\tPerPage: &pp,\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif count > 0 {\n\t\treturn ret[0], nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc ByAlias(ctx context.Context, qb models.StudioQueryer, alias string) (*models.Studio, error) {\n\tf := &models.StudioFilterType{\n\t\tAliases: &models.StringCriterionInput{\n\t\t\tValue:    alias,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tpp := 1\n\tret, count, err := qb.Query(ctx, f, &models.FindFilterType{\n\t\tPerPage: &pp,\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif count > 0 {\n\t\treturn ret[0], nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc CountByTagID(ctx context.Context, qb models.StudioQueryer, id int, depth *int) (int, error) {\n\tfilter := &models.StudioFilterType{\n\t\tTags: &models.HierarchicalMultiCriterionInput{\n\t\t\tValue:    []string{strconv.Itoa(id)},\n\t\t\tModifier: models.CriterionModifierIncludes,\n\t\t\tDepth:    depth,\n\t\t},\n\t}\n\n\treturn qb.QueryCount(ctx, filter, nil)\n}\n"
  },
  {
    "path": "pkg/studio/validate.go",
    "content": "package studio\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nvar (\n\tErrNameMissing       = errors.New(\"studio name must not be blank\")\n\tErrEmptyAlias        = errors.New(\"studio alias must not be an empty string\")\n\tErrStudioOwnAncestor = errors.New(\"studio cannot be an ancestor of itself\")\n)\n\ntype NameExistsError struct {\n\tName string\n}\n\nfunc (e *NameExistsError) Error() string {\n\treturn fmt.Sprintf(\"studio with name '%s' already exists\", e.Name)\n}\n\ntype NameUsedByAliasError struct {\n\tName        string\n\tOtherStudio string\n}\n\nfunc (e *NameUsedByAliasError) Error() string {\n\treturn fmt.Sprintf(\"name '%s' is used as alias for '%s'\", e.Name, e.OtherStudio)\n}\n\n// EnsureStudioNameUnique returns an error if the studio name provided\n// is used as a name or alias of another existing tag.\nfunc EnsureStudioNameUnique(ctx context.Context, id int, name string, qb models.StudioQueryer) error {\n\t// ensure name is unique\n\tsameNameStudio, err := ByName(ctx, qb, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif sameNameStudio != nil && id != sameNameStudio.ID {\n\t\treturn &NameExistsError{\n\t\t\tName: name,\n\t\t}\n\t}\n\n\t// query by alias\n\tsameNameStudio, err = ByAlias(ctx, qb, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif sameNameStudio != nil && id != sameNameStudio.ID {\n\t\treturn &NameUsedByAliasError{\n\t\t\tName:        name,\n\t\t\tOtherStudio: sameNameStudio.Name,\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc ValidateAliases(ctx context.Context, id int, aliases []string, qb models.StudioQueryer) error {\n\tfor _, a := range aliases {\n\t\tif err := validateName(ctx, id, a, qb); err != nil {\n\t\t\tif errors.Is(err, ErrNameMissing) {\n\t\t\t\treturn ErrEmptyAlias\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc ValidateCreate(ctx context.Context, studio models.CreateStudioInput, qb models.StudioQueryer) error {\n\tif err := validateName(ctx, 0, studio.Name, qb); err != nil {\n\t\treturn err\n\t}\n\n\tif studio.Aliases.Loaded() && len(studio.Aliases.List()) > 0 {\n\t\tif err := ValidateAliases(ctx, 0, studio.Aliases.List(), qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc validateName(ctx context.Context, studioID int, name string, qb models.StudioQueryer) error {\n\tif name == \"\" {\n\t\treturn ErrNameMissing\n\t}\n\n\tif err := EnsureStudioNameUnique(ctx, studioID, name, qb); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype ValidateModifyReader interface {\n\tmodels.StudioGetter\n\tmodels.StudioQueryer\n\tmodels.AliasLoader\n}\n\n// Checks to make sure that:\n// 1. The studio exists locally\n// 2. The studio is not its own ancestor\n// 3. The studio's aliases are unique\n// 4. The name is unique\nfunc ValidateModify(ctx context.Context, s models.StudioPartial, qb ValidateModifyReader) error {\n\texisting, err := qb.Find(ctx, s.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif existing == nil {\n\t\treturn fmt.Errorf(\"studio with id %d not found\", s.ID)\n\t}\n\n\tnewParentID := s.ParentID.Ptr()\n\n\tif newParentID != nil {\n\t\tif err := validateParent(ctx, s.ID, *newParentID, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif s.Aliases != nil {\n\t\tif err := existing.LoadAliases(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\teffectiveAliases := s.Aliases.Apply(existing.Aliases.List())\n\n\t\tif err := ValidateAliases(ctx, s.ID, effectiveAliases, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif s.Name.Set && s.Name.Value != existing.Name {\n\t\tif err := validateName(ctx, s.ID, s.Name.Value, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc validateParent(ctx context.Context, studioID int, newParentID int, qb models.StudioGetter) error {\n\tif newParentID == studioID {\n\t\treturn ErrStudioOwnAncestor\n\t}\n\n\t// ensure there is no cyclic dependency\n\tparentStudio, err := qb.Find(ctx, newParentID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error finding parent studio: %v\", err)\n\t}\n\n\tif parentStudio == nil {\n\t\treturn fmt.Errorf(\"studio with id %d not found\", newParentID)\n\t}\n\n\tif parentStudio.ParentID != nil {\n\t\treturn validateParent(ctx, studioID, *parentStudio.ParentID, qb)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/studio/validate_test.go",
    "content": "package studio\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nfunc nameFilter(n string) *models.StudioFilterType {\n\treturn &models.StudioFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    n,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n}\n\nfunc TestValidateName(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tconst (\n\t\tname1   = \"name 1\"\n\t\tnewName = \"new name\"\n\t)\n\n\texisting1 := models.Studio{\n\t\tID:   1,\n\t\tName: name1,\n\t}\n\n\tpp := 1\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\tdb.Studio.On(\"Query\", testCtx, nameFilter(name1), findFilter).Return([]*models.Studio{&existing1}, 1, nil)\n\tdb.Studio.On(\"Query\", testCtx, mock.Anything, findFilter).Return(nil, 0, nil)\n\n\ttests := []struct {\n\t\ttName string\n\t\tname  string\n\t\twant  error\n\t}{\n\t\t{\"missing name\", \"\", ErrNameMissing},\n\t\t{\"new name\", newName, nil},\n\t\t{\"existing name\", name1, &NameExistsError{name1}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.tName, func(t *testing.T) {\n\t\t\tgot := validateName(testCtx, 0, tt.name, db.Studio)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestValidateUpdateName(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tconst (\n\t\tname1   = \"name 1\"\n\t\tname2   = \"name 2\"\n\t\tnewName = \"new name\"\n\t)\n\n\texisting1 := models.Studio{\n\t\tID:   1,\n\t\tName: name1,\n\t}\n\texisting2 := models.Studio{\n\t\tID:   2,\n\t\tName: name2,\n\t}\n\n\tpp := 1\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\tdb.Studio.On(\"Query\", testCtx, nameFilter(name1), findFilter).Return([]*models.Studio{&existing1}, 1, nil)\n\tdb.Studio.On(\"Query\", testCtx, nameFilter(name2), findFilter).Return([]*models.Studio{&existing2}, 2, nil)\n\tdb.Studio.On(\"Query\", testCtx, mock.Anything, findFilter).Return(nil, 0, nil)\n\n\ttests := []struct {\n\t\ttName  string\n\t\tstudio models.Studio\n\t\tname   string\n\t\twant   error\n\t}{\n\t\t{\"missing name\", existing1, \"\", ErrNameMissing},\n\t\t{\"same name\", existing2, name2, nil},\n\t\t{\"new name\", existing1, newName, nil},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.tName, func(t *testing.T) {\n\t\t\tgot := validateName(testCtx, tt.studio.ID, tt.name, db.Studio)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestValidateUpdateAliases(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tconst (\n\t\tname1    = \"name 1\"\n\t\tname2    = \"name 2\"\n\t\talias1   = \"alias 1\"\n\t\tnewAlias = \"new alias\"\n\t)\n\n\texisting1 := models.Studio{\n\t\tID:   1,\n\t\tName: name1,\n\t}\n\texisting2 := models.Studio{\n\t\tID:   2,\n\t\tName: name2,\n\t}\n\n\tpp := 1\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\taliasFilter := func(n string) *models.StudioFilterType {\n\t\treturn &models.StudioFilterType{\n\t\t\tAliases: &models.StringCriterionInput{\n\t\t\t\tValue:    n,\n\t\t\t\tModifier: models.CriterionModifierEquals,\n\t\t\t},\n\t\t}\n\t}\n\n\t// name1 matches existing1 name - ok\n\tdb.Studio.On(\"Query\", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil)\n\tdb.Studio.On(\"Query\", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil)\n\n\t// name2 matches existing2 name - error\n\tdb.Studio.On(\"Query\", testCtx, nameFilter(name2), findFilter).Return([]*models.Studio{&existing2}, 1, nil)\n\n\t// alias matches existing alias - error\n\tdb.Studio.On(\"Query\", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil)\n\tdb.Studio.On(\"Query\", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Studio{&existing2}, 1, nil)\n\n\t// valid alias\n\tdb.Studio.On(\"Query\", testCtx, nameFilter(\"valid\"), findFilter).Return(nil, 0, nil)\n\tdb.Studio.On(\"Query\", testCtx, aliasFilter(\"valid\"), findFilter).Return(nil, 0, nil)\n\n\ttests := []struct {\n\t\ttName   string\n\t\tstudio  models.Studio\n\t\taliases []string\n\t\twant    error\n\t}{\n\t\t{\"valid alias\", existing1, []string{alias1}, nil},\n\t\t{\"alias duplicates other name\", existing1, []string{name2}, &NameExistsError{name2}},\n\t\t{\"alias duplicates other alias\", existing1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}},\n\t\t{\"valid new alias\", existing1, []string{\"valid\"}, nil},\n\t\t{\"empty alias\", existing1, []string{\"\"}, ErrEmptyAlias},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.tName, func(t *testing.T) {\n\t\t\tgot := ValidateAliases(testCtx, tt.studio.ID, tt.aliases, db.Studio)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/tag/doc.go",
    "content": "// Package tag provides application logic for tag objects.\npackage tag\n"
  },
  {
    "path": "pkg/tag/export.go",
    "content": "package tag\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/logger\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype FinderAliasImageGetter interface {\n\tGetAliases(ctx context.Context, studioID int) ([]string, error)\n\tGetImage(ctx context.Context, tagID int) ([]byte, error)\n\tFindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error)\n\tGetCustomFields(ctx context.Context, id int) (map[string]interface{}, error)\n\tmodels.StashIDLoader\n}\n\n// ToJSON converts a Tag object into its JSON equivalent.\nfunc ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) {\n\tnewTagJSON := jsonschema.Tag{\n\t\tName:          tag.Name,\n\t\tSortName:      tag.SortName,\n\t\tDescription:   tag.Description,\n\t\tFavorite:      tag.Favorite,\n\t\tIgnoreAutoTag: tag.IgnoreAutoTag,\n\t\tCreatedAt:     json.JSONTime{Time: tag.CreatedAt},\n\t\tUpdatedAt:     json.JSONTime{Time: tag.UpdatedAt},\n\t}\n\n\taliases, err := reader.GetAliases(ctx, tag.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting tag aliases: %v\", err)\n\t}\n\n\tnewTagJSON.Aliases = aliases\n\n\tif err := tag.LoadStashIDs(ctx, reader); err != nil {\n\t\treturn nil, fmt.Errorf(\"loading tag stash ids: %w\", err)\n\t}\n\n\tstashIDs := tag.StashIDs.List()\n\tif len(stashIDs) > 0 {\n\t\tnewTagJSON.StashIDs = stashIDs\n\t}\n\n\timage, err := reader.GetImage(ctx, tag.ID)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error getting tag image: %v\", err)\n\t}\n\n\tif len(image) > 0 {\n\t\tnewTagJSON.Image = utils.GetBase64StringFromData(image)\n\t}\n\n\tparents, err := reader.FindByChildTagID(ctx, tag.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting parents: %v\", err)\n\t}\n\n\tnewTagJSON.Parents = GetNames(parents)\n\n\tnewTagJSON.CustomFields, err = reader.GetCustomFields(ctx, tag.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"getting tag custom fields: %v\", err)\n\t}\n\n\treturn &newTagJSON, nil\n}\n\n// GetDependentTagIDs returns a slice of unique tag IDs that this tag references.\nfunc GetDependentTagIDs(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) ([]int, error) {\n\tvar ret []int\n\n\tparents, err := reader.FindByChildTagID(ctx, tag.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting parents: %v\", err)\n\t}\n\n\tfor _, tt := range parents {\n\t\ttoAdd, err := GetDependentTagIDs(ctx, reader, tt)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error getting dependent tag IDs: %v\", err)\n\t\t}\n\n\t\tret = sliceutil.AppendUniques(ret, toAdd)\n\t\tret = sliceutil.AppendUnique(ret, tt.ID)\n\t}\n\n\treturn ret, nil\n}\n\nfunc GetIDs(tags []*models.Tag) []int {\n\tvar results []int\n\tfor _, tag := range tags {\n\t\tresults = append(results, tag.ID)\n\t}\n\n\treturn results\n}\n\nfunc GetNames(tags []*models.Tag) []string {\n\tvar results []string\n\tfor _, tag := range tags {\n\t\tresults = append(results, tag.Name)\n\t}\n\n\treturn results\n}\n"
  },
  {
    "path": "pkg/tag/export_test.go",
    "content": "package tag\n\nimport (\n\t\"errors\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/json\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\ttagID = iota + 1\n\tcustomFieldsID\n\tnoImageID\n\terrImageID\n\terrAliasID\n\twithParentsID\n\terrParentsID\n\terrCustomFieldsID\n)\n\nconst (\n\ttagName     = \"testTag\"\n\tsortName    = \"sortName\"\n\tdescription = \"description\"\n)\n\nvar (\n\tautoTagIgnored = true\n\tcreateTime     = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC)\n\tupdateTime     = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC)\n\n\temptyCustomFields = make(map[string]interface{})\n\tcustomFields      = map[string]interface{}{\n\t\t\"customField1\": \"customValue1\",\n\t}\n)\n\nfunc createTag(id int) models.Tag {\n\treturn models.Tag{\n\t\tID:            id,\n\t\tName:          tagName,\n\t\tSortName:      sortName,\n\t\tFavorite:      true,\n\t\tDescription:   description,\n\t\tIgnoreAutoTag: autoTagIgnored,\n\t\tCreatedAt:     createTime,\n\t\tUpdatedAt:     updateTime,\n\t}\n}\n\nfunc createJSONTag(aliases []string, image string, parents []string, withCustomFields bool) *jsonschema.Tag {\n\tret := &jsonschema.Tag{\n\t\tName:          tagName,\n\t\tSortName:      sortName,\n\t\tFavorite:      true,\n\t\tDescription:   description,\n\t\tAliases:       aliases,\n\t\tIgnoreAutoTag: autoTagIgnored,\n\t\tCreatedAt: json.JSONTime{\n\t\t\tTime: createTime,\n\t\t},\n\t\tUpdatedAt: json.JSONTime{\n\t\t\tTime: updateTime,\n\t\t},\n\t\tImage:        image,\n\t\tParents:      parents,\n\t\tCustomFields: emptyCustomFields,\n\t}\n\n\tif withCustomFields {\n\t\tret.CustomFields = customFields\n\t}\n\n\treturn ret\n}\n\ntype testScenario struct {\n\ttag          models.Tag\n\tcustomFields map[string]interface{}\n\texpected     *jsonschema.Tag\n\terr          bool\n}\n\nvar scenarios []testScenario\n\nfunc initTestTable() {\n\tscenarios = []testScenario{\n\t\t{\n\t\t\tcreateTag(tagID),\n\t\t\temptyCustomFields,\n\t\t\tcreateJSONTag([]string{\"alias\"}, image, nil, false),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateTag(customFieldsID),\n\t\t\tcustomFields,\n\t\t\tcreateJSONTag([]string{\"alias\"}, image, nil, true),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateTag(noImageID),\n\t\t\temptyCustomFields,\n\t\t\tcreateJSONTag(nil, \"\", nil, false),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateTag(errImageID),\n\t\t\temptyCustomFields,\n\t\t\tcreateJSONTag(nil, \"\", nil, false),\n\t\t\t// getting the image should not cause an error\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateTag(errAliasID),\n\t\t\temptyCustomFields,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\tcreateTag(withParentsID),\n\t\t\temptyCustomFields,\n\t\t\tcreateJSONTag(nil, image, []string{\"parent\"}, false),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tcreateTag(errParentsID),\n\t\t\temptyCustomFields,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\tcreateTag(errCustomFieldsID),\n\t\t\tcustomFields,\n\t\t\tnil,\n\t\t\ttrue,\n\t\t},\n\t}\n}\n\nfunc TestToJSON(t *testing.T) {\n\tinitTestTable()\n\n\tdb := mocks.NewDatabase()\n\n\timageErr := errors.New(\"error getting image\")\n\taliasErr := errors.New(\"error getting aliases\")\n\tparentsErr := errors.New(\"error getting parents\")\n\tcustomFieldsErr := errors.New(\"error getting custom fields\")\n\n\tdb.Tag.On(\"GetAliases\", testCtx, tagID).Return([]string{\"alias\"}, nil).Once()\n\tdb.Tag.On(\"GetAliases\", testCtx, customFieldsID).Return([]string{\"alias\"}, nil).Once()\n\tdb.Tag.On(\"GetAliases\", testCtx, noImageID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetAliases\", testCtx, errImageID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetAliases\", testCtx, errAliasID).Return(nil, aliasErr).Once()\n\tdb.Tag.On(\"GetAliases\", testCtx, withParentsID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetAliases\", testCtx, errParentsID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetAliases\", testCtx, errCustomFieldsID).Return(nil, nil).Once()\n\n\tdb.Tag.On(\"GetStashIDs\", testCtx, tagID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetStashIDs\", testCtx, customFieldsID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetStashIDs\", testCtx, noImageID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetStashIDs\", testCtx, errImageID).Return(nil, nil).Once()\n\t// errAliasID test fails before GetStashIDs is called, so no mock needed\n\tdb.Tag.On(\"GetStashIDs\", testCtx, withParentsID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetStashIDs\", testCtx, errParentsID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetStashIDs\", testCtx, errCustomFieldsID).Return(nil, nil).Once()\n\n\tdb.Tag.On(\"GetImage\", testCtx, tagID).Return(imageBytes, nil).Once()\n\tdb.Tag.On(\"GetImage\", testCtx, customFieldsID).Return(imageBytes, nil).Once()\n\tdb.Tag.On(\"GetImage\", testCtx, noImageID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetImage\", testCtx, errImageID).Return(nil, imageErr).Once()\n\tdb.Tag.On(\"GetImage\", testCtx, withParentsID).Return(imageBytes, nil).Once()\n\tdb.Tag.On(\"GetImage\", testCtx, errParentsID).Return(nil, nil).Once()\n\tdb.Tag.On(\"GetImage\", testCtx, errCustomFieldsID).Return(nil, nil).Once()\n\n\tdb.Tag.On(\"FindByChildTagID\", testCtx, tagID).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByChildTagID\", testCtx, customFieldsID).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByChildTagID\", testCtx, noImageID).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByChildTagID\", testCtx, withParentsID).Return([]*models.Tag{{Name: \"parent\"}}, nil).Once()\n\tdb.Tag.On(\"FindByChildTagID\", testCtx, errParentsID).Return(nil, parentsErr).Once()\n\tdb.Tag.On(\"FindByChildTagID\", testCtx, errImageID).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByChildTagID\", testCtx, errCustomFieldsID).Return(nil, nil).Once()\n\n\tdb.Tag.On(\"GetCustomFields\", testCtx, tagID).Return(emptyCustomFields, nil).Once()\n\tdb.Tag.On(\"GetCustomFields\", testCtx, customFieldsID).Return(customFields, nil).Once()\n\tdb.Tag.On(\"GetCustomFields\", testCtx, noImageID).Return(emptyCustomFields, nil).Once()\n\tdb.Tag.On(\"GetCustomFields\", testCtx, errImageID).Return(emptyCustomFields, nil).Once()\n\tdb.Tag.On(\"GetCustomFields\", testCtx, withParentsID).Return(emptyCustomFields, nil).Once()\n\tdb.Tag.On(\"GetCustomFields\", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once()\n\n\tfor i, s := range scenarios {\n\t\ttag := s.tag\n\t\tjson, err := ToJSON(testCtx, db.Tag, &tag)\n\n\t\tswitch {\n\t\tcase !s.err && err != nil:\n\t\t\tt.Errorf(\"[%d] unexpected error: %s\", i, err.Error())\n\t\tcase s.err && err == nil:\n\t\t\tt.Errorf(\"[%d] expected error not returned\", i)\n\t\tdefault:\n\t\t\tassert.Equal(t, s.expected, json, \"[%d]\", i)\n\t\t}\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/tag/import.go",
    "content": "package tag\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\ntype ImporterReaderWriter interface {\n\tmodels.TagCreatorUpdater\n\tFindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error)\n}\n\ntype ParentTagNotExistError struct {\n\tmissingParent string\n}\n\nfunc (e ParentTagNotExistError) Error() string {\n\treturn fmt.Sprintf(\"parent tag <%s> does not exist\", e.missingParent)\n}\n\nfunc (e ParentTagNotExistError) MissingParent() string {\n\treturn e.missingParent\n}\n\ntype Importer struct {\n\tReaderWriter        ImporterReaderWriter\n\tInput               jsonschema.Tag\n\tMissingRefBehaviour models.ImportMissingRefEnum\n\n\ttag          models.Tag\n\timageData    []byte\n\tcustomFields map[string]interface{}\n}\n\nfunc (i *Importer) PreImport(ctx context.Context) error {\n\ti.tag = models.Tag{\n\t\tName:          i.Input.Name,\n\t\tSortName:      i.Input.SortName,\n\t\tDescription:   i.Input.Description,\n\t\tFavorite:      i.Input.Favorite,\n\t\tIgnoreAutoTag: i.Input.IgnoreAutoTag,\n\t\tStashIDs:      models.NewRelatedStashIDs(i.Input.StashIDs),\n\t\tCreatedAt:     i.Input.CreatedAt.GetTime(),\n\t\tUpdatedAt:     i.Input.UpdatedAt.GetTime(),\n\t}\n\n\tvar err error\n\tif len(i.Input.Image) > 0 {\n\t\ti.imageData, err = utils.ProcessBase64Image(i.Input.Image)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid image: %v\", err)\n\t\t}\n\t}\n\n\ti.customFields = i.Input.CustomFields\n\n\treturn nil\n}\n\nfunc (i *Importer) PostImport(ctx context.Context, id int) error {\n\tif len(i.imageData) > 0 {\n\t\tif err := i.ReaderWriter.UpdateImage(ctx, id, i.imageData); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting tag image: %v\", err)\n\t\t}\n\t}\n\n\tif err := i.ReaderWriter.UpdateAliases(ctx, id, i.Input.Aliases); err != nil {\n\t\treturn fmt.Errorf(\"error setting tag aliases: %v\", err)\n\t}\n\n\tparents, err := i.getParents(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := i.ReaderWriter.UpdateParentTags(ctx, id, parents); err != nil {\n\t\treturn fmt.Errorf(\"error setting parents: %v\", err)\n\t}\n\n\tif len(i.customFields) > 0 {\n\t\tif err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{\n\t\t\tFull: i.customFields,\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"error setting tag custom fields: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) Name() string {\n\treturn i.Input.Name\n}\n\nfunc (i *Importer) FindExistingID(ctx context.Context) (*int, error) {\n\tconst nocase = false\n\texisting, err := i.ReaderWriter.FindByName(ctx, i.Name(), nocase)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif existing != nil {\n\t\tid := existing.ID\n\t\treturn &id, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (i *Importer) Create(ctx context.Context) (*int, error) {\n\terr := i.ReaderWriter.Create(ctx, &models.CreateTagInput{\n\t\tTag:          &i.tag,\n\t\tCustomFields: i.customFields,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating tag: %v\", err)\n\t}\n\n\tid := i.tag.ID\n\treturn &id, nil\n}\n\nfunc (i *Importer) Update(ctx context.Context, id int) error {\n\ttag := i.tag\n\ttag.ID = id\n\terr := i.ReaderWriter.Update(ctx, &models.UpdateTagInput{\n\t\tTag: &tag,\n\t\tCustomFields: models.CustomFieldsInput{\n\t\t\tFull: i.customFields,\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error updating existing tag: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (i *Importer) getParents(ctx context.Context) ([]int, error) {\n\tvar parents []int\n\tfor _, parent := range i.Input.Parents {\n\t\ttag, err := i.ReaderWriter.FindByName(ctx, parent, false)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error finding parent by name: %v\", err)\n\t\t}\n\n\t\tif tag == nil {\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumFail {\n\t\t\t\treturn nil, ParentTagNotExistError{missingParent: parent}\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {\n\t\t\t\tparentID, err := i.createParent(ctx, parent)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tparents = append(parents, parentID)\n\t\t\t}\n\t\t} else {\n\t\t\tparents = append(parents, tag.ID)\n\t\t}\n\t}\n\n\treturn parents, nil\n}\n\nfunc (i *Importer) createParent(ctx context.Context, name string) (int, error) {\n\tnewTag := models.NewTag()\n\tnewTag.Name = name\n\n\terr := i.ReaderWriter.Create(ctx, &models.CreateTagInput{\n\t\tTag: &newTag,\n\t})\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn newTag.ID, nil\n}\n"
  },
  {
    "path": "pkg/tag/import_test.go",
    "content": "package tag\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/jsonschema\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nconst image = \"aW1hZ2VCeXRlcw==\"\nconst invalidImage = \"aW1hZ2VCeXRlcw&&\"\n\nvar imageBytes = []byte(\"imageBytes\")\n\nconst (\n\ttagNameErr      = \"tagNameErr\"\n\texistingTagName = \"existingTagName\"\n\n\texistingTagID = 100\n)\n\nvar testCtx = context.Background()\n\nfunc TestImporterName(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Tag{\n\t\t\tName: tagName,\n\t\t},\n\t}\n\n\tassert.Equal(t, tagName, i.Name())\n}\n\nfunc TestImporterPreImport(t *testing.T) {\n\ti := Importer{\n\t\tInput: jsonschema.Tag{\n\t\t\tName:          tagName,\n\t\t\tSortName:      sortName,\n\t\t\tDescription:   description,\n\t\t\tImage:         invalidImage,\n\t\t\tIgnoreAutoTag: autoTagIgnored,\n\t\t},\n\t}\n\n\terr := i.PreImport(testCtx)\n\n\tassert.NotNil(t, err)\n\n\ti.Input.Image = image\n\n\terr = i.PreImport(testCtx)\n\n\tassert.Nil(t, err)\n}\n\nfunc TestImporterPostImport(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Tag,\n\t\tInput: jsonschema.Tag{\n\t\t\tAliases: []string{\"alias\"},\n\t\t},\n\t\timageData: imageBytes,\n\t}\n\n\tupdateTagImageErr := errors.New(\"UpdateImage error\")\n\tupdateTagAliasErr := errors.New(\"UpdateAlias error\")\n\tupdateTagParentsErr := errors.New(\"UpdateParentTags error\")\n\n\tdb.Tag.On(\"UpdateAliases\", testCtx, tagID, i.Input.Aliases).Return(nil).Once()\n\tdb.Tag.On(\"UpdateAliases\", testCtx, errAliasID, i.Input.Aliases).Return(updateTagAliasErr).Once()\n\tdb.Tag.On(\"UpdateAliases\", testCtx, withParentsID, i.Input.Aliases).Return(nil).Once()\n\tdb.Tag.On(\"UpdateAliases\", testCtx, errParentsID, i.Input.Aliases).Return(nil).Once()\n\n\tdb.Tag.On(\"UpdateImage\", testCtx, tagID, imageBytes).Return(nil).Once()\n\tdb.Tag.On(\"UpdateImage\", testCtx, errAliasID, imageBytes).Return(nil).Once()\n\tdb.Tag.On(\"UpdateImage\", testCtx, errImageID, imageBytes).Return(updateTagImageErr).Once()\n\tdb.Tag.On(\"UpdateImage\", testCtx, withParentsID, imageBytes).Return(nil).Once()\n\tdb.Tag.On(\"UpdateImage\", testCtx, errParentsID, imageBytes).Return(nil).Once()\n\n\tvar parentTags []int\n\tdb.Tag.On(\"UpdateParentTags\", testCtx, tagID, parentTags).Return(nil).Once()\n\tdb.Tag.On(\"UpdateParentTags\", testCtx, withParentsID, []int{100}).Return(nil).Once()\n\tdb.Tag.On(\"UpdateParentTags\", testCtx, errParentsID, []int{100}).Return(updateTagParentsErr).Once()\n\n\tdb.Tag.On(\"FindByName\", testCtx, \"Parent\", false).Return(&models.Tag{ID: 100}, nil)\n\n\terr := i.PostImport(testCtx, tagID)\n\tassert.Nil(t, err)\n\n\terr = i.PostImport(testCtx, errImageID)\n\tassert.NotNil(t, err)\n\n\terr = i.PostImport(testCtx, errAliasID)\n\tassert.NotNil(t, err)\n\n\ti.Input.Parents = []string{\"Parent\"}\n\terr = i.PostImport(testCtx, withParentsID)\n\tassert.Nil(t, err)\n\n\terr = i.PostImport(testCtx, errParentsID)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterPostImportParentMissing(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Tag,\n\t\tInput:        jsonschema.Tag{},\n\t\timageData:    imageBytes,\n\t}\n\n\tcreateID := 1\n\tcreateErrorID := 2\n\tcreateFindErrorID := 3\n\tcreateFoundID := 4\n\tfailID := 5\n\tfailFindErrorID := 6\n\tfailFoundID := 7\n\tignoreID := 8\n\tignoreFindErrorID := 9\n\tignoreFoundID := 10\n\n\tfindError := errors.New(\"failed finding parent\")\n\n\tvar emptyParents []int\n\n\tdb.Tag.On(\"UpdateImage\", testCtx, mock.Anything, mock.Anything).Return(nil)\n\tdb.Tag.On(\"UpdateAliases\", testCtx, mock.Anything, mock.Anything).Return(nil)\n\n\tdb.Tag.On(\"FindByName\", testCtx, \"Create\", false).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, \"CreateError\", false).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, \"CreateFindError\", false).Return(nil, findError).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, \"CreateFound\", false).Return(&models.Tag{ID: 101}, nil).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, \"Fail\", false).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, \"FailFindError\", false).Return(nil, findError)\n\tdb.Tag.On(\"FindByName\", testCtx, \"FailFound\", false).Return(&models.Tag{ID: 102}, nil).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, \"Ignore\", false).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, \"IgnoreFindError\", false).Return(nil, findError)\n\tdb.Tag.On(\"FindByName\", testCtx, \"IgnoreFound\", false).Return(&models.Tag{ID: 103}, nil).Once()\n\n\tdb.Tag.On(\"UpdateParentTags\", testCtx, createID, []int{100}).Return(nil).Once()\n\tdb.Tag.On(\"UpdateParentTags\", testCtx, createFoundID, []int{101}).Return(nil).Once()\n\tdb.Tag.On(\"UpdateParentTags\", testCtx, failFoundID, []int{102}).Return(nil).Once()\n\tdb.Tag.On(\"UpdateParentTags\", testCtx, ignoreID, emptyParents).Return(nil).Once()\n\tdb.Tag.On(\"UpdateParentTags\", testCtx, ignoreFoundID, []int{103}).Return(nil).Once()\n\n\tdb.Tag.On(\"Create\", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool {\n\t\treturn input.Tag.Name == \"Create\"\n\t})).Run(func(args mock.Arguments) {\n\t\tinput := args.Get(1).(*models.CreateTagInput)\n\t\tinput.Tag.ID = 100\n\t}).Return(nil).Once()\n\tdb.Tag.On(\"Create\", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool {\n\t\treturn input.Tag.Name == \"CreateError\"\n\t})).Return(errors.New(\"failed creating parent\")).Once()\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumCreate\n\ti.Input.Parents = []string{\"Create\"}\n\terr := i.PostImport(testCtx, createID)\n\tassert.Nil(t, err)\n\n\ti.Input.Parents = []string{\"CreateError\"}\n\terr = i.PostImport(testCtx, createErrorID)\n\tassert.NotNil(t, err)\n\n\ti.Input.Parents = []string{\"CreateFindError\"}\n\terr = i.PostImport(testCtx, createFindErrorID)\n\tassert.NotNil(t, err)\n\n\ti.Input.Parents = []string{\"CreateFound\"}\n\terr = i.PostImport(testCtx, createFoundID)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumFail\n\ti.Input.Parents = []string{\"Fail\"}\n\terr = i.PostImport(testCtx, failID)\n\tassert.NotNil(t, err)\n\n\ti.Input.Parents = []string{\"FailFindError\"}\n\terr = i.PostImport(testCtx, failFindErrorID)\n\tassert.NotNil(t, err)\n\n\ti.Input.Parents = []string{\"FailFound\"}\n\terr = i.PostImport(testCtx, failFoundID)\n\tassert.Nil(t, err)\n\n\ti.MissingRefBehaviour = models.ImportMissingRefEnumIgnore\n\ti.Input.Parents = []string{\"Ignore\"}\n\terr = i.PostImport(testCtx, ignoreID)\n\tassert.Nil(t, err)\n\n\ti.Input.Parents = []string{\"IgnoreFindError\"}\n\terr = i.PostImport(testCtx, ignoreFindErrorID)\n\tassert.NotNil(t, err)\n\n\ti.Input.Parents = []string{\"IgnoreFound\"}\n\terr = i.PostImport(testCtx, ignoreFoundID)\n\tassert.Nil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestImporterFindExistingID(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ti := Importer{\n\t\tReaderWriter: db.Tag,\n\t\tInput: jsonschema.Tag{\n\t\t\tName: tagName,\n\t\t},\n\t}\n\n\terrFindByName := errors.New(\"FindByName error\")\n\tdb.Tag.On(\"FindByName\", testCtx, tagName, false).Return(nil, nil).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, existingTagName, false).Return(&models.Tag{\n\t\tID: existingTagID,\n\t}, nil).Once()\n\tdb.Tag.On(\"FindByName\", testCtx, tagNameErr, false).Return(nil, errFindByName).Once()\n\n\tid, err := i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.Nil(t, err)\n\n\ti.Input.Name = existingTagName\n\tid, err = i.FindExistingID(testCtx)\n\tassert.Equal(t, existingTagID, *id)\n\tassert.Nil(t, err)\n\n\ti.Input.Name = tagNameErr\n\tid, err = i.FindExistingID(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestCreate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ttag := models.Tag{\n\t\tName: tagName,\n\t}\n\n\ttagErr := models.Tag{\n\t\tName: tagNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.Tag,\n\t\ttag:          tag,\n\t}\n\n\terrCreate := errors.New(\"Create error\")\n\tdb.Tag.On(\"Create\", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool {\n\t\treturn input.Tag.Name == tag.Name\n\t})).Run(func(args mock.Arguments) {\n\t\tinput := args.Get(1).(*models.CreateTagInput)\n\t\tinput.Tag.ID = tagID\n\t}).Return(nil).Once()\n\tdb.Tag.On(\"Create\", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool {\n\t\treturn input.Tag.Name == tagErr.Name\n\t})).Return(errCreate).Once()\n\n\tid, err := i.Create(testCtx)\n\tassert.Equal(t, tagID, *id)\n\tassert.Nil(t, err)\n\n\ti.tag = tagErr\n\tid, err = i.Create(testCtx)\n\tassert.Nil(t, id)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n\nfunc TestUpdate(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\ttag := models.Tag{\n\t\tName: tagName,\n\t}\n\n\ttagErr := models.Tag{\n\t\tName: tagNameErr,\n\t}\n\n\ti := Importer{\n\t\tReaderWriter: db.Tag,\n\t\ttag:          tag,\n\t}\n\n\terrUpdate := errors.New(\"Update error\")\n\n\t// id needs to be set for the mock input\n\ttag.ID = tagID\n\ttagInput := models.UpdateTagInput{\n\t\tTag: &tag,\n\t}\n\tdb.Tag.On(\"Update\", testCtx, &tagInput).Return(nil).Once()\n\n\terr := i.Update(testCtx, tagID)\n\tassert.Nil(t, err)\n\n\ti.tag = tagErr\n\n\t// need to set id separately\n\ttagErr.ID = errImageID\n\terrInput := models.UpdateTagInput{\n\t\tTag: &tagErr,\n\t}\n\tdb.Tag.On(\"Update\", testCtx, &errInput).Return(errUpdate).Once()\n\n\terr = i.Update(testCtx, errImageID)\n\tassert.NotNil(t, err)\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/tag/query.go",
    "content": "package tag\n\nimport (\n\t\"context\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nfunc ByName(ctx context.Context, qb models.TagQueryer, name string) (*models.Tag, error) {\n\tf := &models.TagFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    name,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tpp := 1\n\tret, count, err := qb.Query(ctx, f, &models.FindFilterType{\n\t\tPerPage: &pp,\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif count > 0 {\n\t\treturn ret[0], nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc ByAlias(ctx context.Context, qb models.TagQueryer, alias string) (*models.Tag, error) {\n\tf := &models.TagFilterType{\n\t\tAliases: &models.StringCriterionInput{\n\t\t\tValue:    alias,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n\n\tpp := 1\n\tret, count, err := qb.Query(ctx, f, &models.FindFilterType{\n\t\tPerPage: &pp,\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif count > 0 {\n\t\treturn ret[0], nil\n\t}\n\n\treturn nil, nil\n}\n"
  },
  {
    "path": "pkg/tag/update.go",
    "content": "package tag\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\ntype NameExistsError struct {\n\tName string\n}\n\nfunc (e *NameExistsError) Error() string {\n\treturn fmt.Sprintf(\"tag with name '%s' already exists\", e.Name)\n}\n\ntype NameUsedByAliasError struct {\n\tName     string\n\tOtherTag string\n}\n\nfunc (e *NameUsedByAliasError) Error() string {\n\treturn fmt.Sprintf(\"name '%s' is used as alias for '%s'\", e.Name, e.OtherTag)\n}\n\ntype InvalidTagHierarchyError struct {\n\tDirection       string\n\tCurrentRelation string\n\tInvalidTag      string\n\tApplyingTag     string\n\tTagPath         string\n}\n\nfunc (e *InvalidTagHierarchyError) Error() string {\n\tif e.ApplyingTag == \"\" {\n\t\treturn fmt.Sprintf(\"cannot apply tag \\\"%s\\\" as a %s of tag as it is already %s\", e.InvalidTag, e.Direction, e.CurrentRelation)\n\t}\n\n\treturn fmt.Sprintf(\"cannot apply tag \\\"%s\\\" as a %s of \\\"%s\\\" as it is already %s (%s)\", e.InvalidTag, e.Direction, e.ApplyingTag, e.CurrentRelation, e.TagPath)\n}\n\n// EnsureTagNameUnique returns an error if the tag name provided\n// is used as a name or alias of another existing tag.\nfunc EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagQueryer) error {\n\t// ensure name is unique\n\tsameNameTag, err := ByName(ctx, qb, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif sameNameTag != nil && id != sameNameTag.ID {\n\t\treturn &NameExistsError{\n\t\t\tName: name,\n\t\t}\n\t}\n\n\t// query by alias\n\tsameNameTag, err = ByAlias(ctx, qb, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif sameNameTag != nil && id != sameNameTag.ID {\n\t\treturn &NameUsedByAliasError{\n\t\t\tName:     name,\n\t\t\tOtherTag: sameNameTag.Name,\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagQueryer) error {\n\tfor _, a := range aliases {\n\t\tif err := EnsureTagNameUnique(ctx, id, a, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype RelationshipFinder interface {\n\tFindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error)\n\tFindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error)\n\tmodels.TagRelationLoader\n}\n\nfunc ValidateHierarchyNew(ctx context.Context, parentIDs, childIDs []int, qb RelationshipFinder) error {\n\tallAncestors := make(map[int]*models.TagPath)\n\tallDescendants := make(map[int]*models.TagPath)\n\n\tfor _, parentID := range parentIDs {\n\t\tparentsAncestors, err := qb.FindAllAncestors(ctx, parentID, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, ancestorTag := range parentsAncestors {\n\t\t\tallAncestors[ancestorTag.ID] = ancestorTag\n\t\t}\n\t}\n\n\tfor _, childID := range childIDs {\n\t\tchildsDescendants, err := qb.FindAllDescendants(ctx, childID, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, descendentTag := range childsDescendants {\n\t\t\tallDescendants[descendentTag.ID] = descendentTag\n\t\t}\n\t}\n\n\t// Validate that the tag is not a parent of any of its ancestors\n\tvalidateParent := func(testID int) error {\n\t\tif parentTag, exists := allDescendants[testID]; exists {\n\t\t\treturn &InvalidTagHierarchyError{\n\t\t\t\tDirection:       \"parent\",\n\t\t\t\tCurrentRelation: \"a descendant\",\n\t\t\t\tInvalidTag:      parentTag.Name,\n\t\t\t\tTagPath:         parentTag.Path,\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\t// Validate that the tag is not a child of any of its ancestors\n\tvalidateChild := func(testID int) error {\n\t\tif childTag, exists := allAncestors[testID]; exists {\n\t\t\treturn &InvalidTagHierarchyError{\n\t\t\t\tDirection:       \"child\",\n\t\t\t\tCurrentRelation: \"an ancestor\",\n\t\t\t\tInvalidTag:      childTag.Name,\n\t\t\t\tTagPath:         childTag.Path,\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tfor _, parentID := range parentIDs {\n\t\tif err := validateParent(parentID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, childID := range childIDs {\n\t\tif err := validateChild(childID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc ValidateHierarchyExisting(ctx context.Context, tag *models.Tag, parentIDs, childIDs []int, qb RelationshipFinder) error {\n\tallAncestors := make(map[int]*models.TagPath)\n\tallDescendants := make(map[int]*models.TagPath)\n\n\tparentsAncestors, err := qb.FindAllAncestors(ctx, tag.ID, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, ancestorTag := range parentsAncestors {\n\t\tallAncestors[ancestorTag.ID] = ancestorTag\n\t}\n\n\tchildsDescendants, err := qb.FindAllDescendants(ctx, tag.ID, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, descendentTag := range childsDescendants {\n\t\tallDescendants[descendentTag.ID] = descendentTag\n\t}\n\n\tvalidateParent := func(testID int) error {\n\t\tif parentTag, exists := allDescendants[testID]; exists {\n\t\t\treturn &InvalidTagHierarchyError{\n\t\t\t\tDirection:       \"parent\",\n\t\t\t\tCurrentRelation: \"a descendant\",\n\t\t\t\tInvalidTag:      parentTag.Name,\n\t\t\t\tApplyingTag:     tag.Name,\n\t\t\t\tTagPath:         parentTag.Path,\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tvalidateChild := func(testID int) error {\n\t\tif childTag, exists := allAncestors[testID]; exists {\n\t\t\treturn &InvalidTagHierarchyError{\n\t\t\t\tDirection:       \"child\",\n\t\t\t\tCurrentRelation: \"an ancestor\",\n\t\t\t\tInvalidTag:      childTag.Name,\n\t\t\t\tApplyingTag:     tag.Name,\n\t\t\t\tTagPath:         childTag.Path,\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tfor _, parentID := range parentIDs {\n\t\tif err := validateParent(parentID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, childID := range childIDs {\n\t\tif err := validateChild(childID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/tag/update_test.go",
    "content": "package tag\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n)\n\nvar testUniqueHierarchyTags = map[int]*models.Tag{\n\t1: {\n\t\tID:   1,\n\t\tName: \"one\",\n\t},\n\t2: {\n\t\tID:   2,\n\t\tName: \"two\",\n\t},\n\t3: {\n\t\tID:   3,\n\t\tName: \"three\",\n\t},\n\t4: {\n\t\tID:   4,\n\t\tName: \"four\",\n\t},\n}\n\nvar testUniqueHierarchyTagPaths = map[int]*models.TagPath{\n\t1: {\n\t\tTag: *testUniqueHierarchyTags[1],\n\t},\n\t2: {\n\t\tTag: *testUniqueHierarchyTags[2],\n\t},\n\t3: {\n\t\tTag: *testUniqueHierarchyTags[3],\n\t},\n\t4: {\n\t\tTag: *testUniqueHierarchyTags[4],\n\t},\n}\n\ntype testUniqueHierarchyCase struct {\n\tid       int\n\tparents  []*models.Tag\n\tchildren []*models.Tag\n\n\tonFindAllAncestors   []*models.TagPath\n\tonFindAllDescendants []*models.TagPath\n\n\texpectedError string\n}\n\nvar testUniqueHierarchyCases = []testUniqueHierarchyCase{\n\t{\n\t\tid:                   1,\n\t\tparents:              []*models.Tag{},\n\t\tchildren:             []*models.Tag{},\n\t\tonFindAllAncestors:   []*models.TagPath{},\n\t\tonFindAllDescendants: []*models.TagPath{},\n\t\texpectedError:        \"\",\n\t},\n\t{\n\t\tid:       1,\n\t\tparents:  []*models.Tag{testUniqueHierarchyTags[2]},\n\t\tchildren: []*models.Tag{testUniqueHierarchyTags[3]},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3],\n\t\t},\n\t\texpectedError: \"\",\n\t},\n\t{\n\t\tid:       2,\n\t\tparents:  []*models.Tag{testUniqueHierarchyTags[3]},\n\t\tchildren: make([]*models.Tag, 0),\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\texpectedError: \"\",\n\t},\n\t{\n\t\tid: 2,\n\t\tparents: []*models.Tag{\n\t\t\ttestUniqueHierarchyTags[3],\n\t\t\ttestUniqueHierarchyTags[4],\n\t\t},\n\t\tchildren: []*models.Tag{},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[4],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\texpectedError: \"\",\n\t},\n\t{\n\t\tid:       2,\n\t\tparents:  []*models.Tag{},\n\t\tchildren: []*models.Tag{testUniqueHierarchyTags[3]},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3],\n\t\t},\n\t\texpectedError: \"\",\n\t},\n\t{\n\t\tid:      2,\n\t\tparents: []*models.Tag{},\n\t\tchildren: []*models.Tag{\n\t\t\ttestUniqueHierarchyTags[3],\n\t\t\ttestUniqueHierarchyTags[4],\n\t\t},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[4],\n\t\t},\n\t\texpectedError: \"\",\n\t},\n\t{\n\t\tid:       1,\n\t\tparents:  []*models.Tag{testUniqueHierarchyTags[2]},\n\t\tchildren: []*models.Tag{testUniqueHierarchyTags[3]},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2], testUniqueHierarchyTagPaths[3],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3],\n\t\t},\n\t\texpectedError: \"cannot apply tag \\\"three\\\" as a child of \\\"one\\\" as it is already an ancestor ()\",\n\t},\n\t{\n\t\tid:       1,\n\t\tparents:  []*models.Tag{testUniqueHierarchyTags[2]},\n\t\tchildren: []*models.Tag{testUniqueHierarchyTags[3]},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[2],\n\t\t},\n\t\texpectedError: \"cannot apply tag \\\"two\\\" as a parent of \\\"one\\\" as it is already a descendant ()\",\n\t},\n\t{\n\t\tid:       1,\n\t\tparents:  []*models.Tag{testUniqueHierarchyTags[3]},\n\t\tchildren: []*models.Tag{testUniqueHierarchyTags[3]},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3],\n\t\t},\n\t\texpectedError: \"cannot apply tag \\\"three\\\" as a parent of \\\"one\\\" as it is already a descendant ()\",\n\t},\n\t{\n\t\tid: 1,\n\t\tparents: []*models.Tag{\n\t\t\ttestUniqueHierarchyTags[2],\n\t\t},\n\t\tchildren: []*models.Tag{\n\t\t\ttestUniqueHierarchyTags[3],\n\t\t},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[2],\n\t\t},\n\t\texpectedError: \"cannot apply tag \\\"two\\\" as a parent of \\\"one\\\" as it is already a descendant ()\",\n\t},\n\t{\n\t\tid:       1,\n\t\tparents:  []*models.Tag{testUniqueHierarchyTags[2]},\n\t\tchildren: []*models.Tag{testUniqueHierarchyTags[2]},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[2],\n\t\t},\n\t\texpectedError: \"cannot apply tag \\\"two\\\" as a parent of \\\"one\\\" as it is already a descendant ()\",\n\t},\n\t{\n\t\tid:       2,\n\t\tparents:  []*models.Tag{testUniqueHierarchyTags[1]},\n\t\tchildren: []*models.Tag{testUniqueHierarchyTags[3]},\n\t\tonFindAllAncestors: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[1],\n\t\t},\n\t\tonFindAllDescendants: []*models.TagPath{\n\t\t\ttestUniqueHierarchyTagPaths[3], testUniqueHierarchyTagPaths[1],\n\t\t},\n\t\texpectedError: \"cannot apply tag \\\"one\\\" as a parent of \\\"two\\\" as it is already a descendant ()\",\n\t},\n}\n\nfunc TestEnsureHierarchy(t *testing.T) {\n\tfor _, tc := range testUniqueHierarchyCases {\n\t\ttestEnsureHierarchy(t, tc)\n\t}\n}\n\nfunc testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase) {\n\tdb := mocks.NewDatabase()\n\n\tvar parentIDs, childIDs []int\n\tfind := make(map[int]*models.Tag)\n\tfind[tc.id] = testUniqueHierarchyTags[tc.id]\n\tif tc.parents != nil {\n\t\tparentIDs = make([]int, 0)\n\t\tfor _, parent := range tc.parents {\n\t\t\tif parent.ID != tc.id {\n\t\t\t\tfind[parent.ID] = parent\n\t\t\t\tparentIDs = append(parentIDs, parent.ID)\n\t\t\t}\n\t\t}\n\t}\n\n\tif tc.children != nil {\n\t\tchildIDs = make([]int, 0)\n\t\tfor _, child := range tc.children {\n\t\t\tif child.ID != tc.id {\n\t\t\t\tfind[child.ID] = child\n\t\t\t\tchildIDs = append(childIDs, child.ID)\n\t\t\t}\n\t\t}\n\t}\n\n\tdb.Tag.On(\"FindAllAncestors\", testCtx, mock.AnythingOfType(\"int\"), []int(nil)).Return(func(ctx context.Context, tagID int, excludeIDs []int) []*models.TagPath {\n\t\treturn tc.onFindAllAncestors\n\t}, func(ctx context.Context, tagID int, excludeIDs []int) error {\n\t\tif tc.onFindAllAncestors != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"undefined ancestors for: %d\", tagID)\n\t}).Maybe()\n\n\tdb.Tag.On(\"FindAllDescendants\", testCtx, mock.AnythingOfType(\"int\"), []int(nil)).Return(func(ctx context.Context, tagID int, excludeIDs []int) []*models.TagPath {\n\t\treturn tc.onFindAllDescendants\n\t}, func(ctx context.Context, tagID int, excludeIDs []int) error {\n\t\tif tc.onFindAllDescendants != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"undefined descendants for: %d\", tagID)\n\t}).Maybe()\n\n\tres := ValidateHierarchyExisting(testCtx, testUniqueHierarchyTags[tc.id], parentIDs, childIDs, db.Tag)\n\n\tassert := assert.New(t)\n\n\tif tc.expectedError != \"\" {\n\t\tif assert.NotNil(res) {\n\t\t\tassert.Equal(tc.expectedError, res.Error())\n\t\t}\n\t} else {\n\t\tassert.Nil(res)\n\t}\n\n\tdb.AssertExpectations(t)\n}\n"
  },
  {
    "path": "pkg/tag/validate.go",
    "content": "package tag\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n)\n\nvar (\n\tErrNameMissing = errors.New(\"tag name must not be blank\")\n)\n\ntype NotFoundError struct {\n\tid int\n}\n\nfunc (e *NotFoundError) Error() string {\n\treturn fmt.Sprintf(\"tag with id %d not found\", e.id)\n}\n\nfunc ValidateCreate(ctx context.Context, tag models.Tag, qb models.TagReader) error {\n\tif tag.Name == \"\" {\n\t\treturn ErrNameMissing\n\t}\n\n\tif err := EnsureTagNameUnique(ctx, 0, tag.Name, qb); err != nil {\n\t\treturn err\n\t}\n\n\tif tag.Aliases.Loaded() {\n\t\tif err := EnsureAliasesUnique(ctx, tag.ID, tag.Aliases.List(), qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(tag.ParentIDs.List()) > 0 || len(tag.ChildIDs.List()) > 0 {\n\t\tif err := ValidateHierarchyNew(ctx, tag.ParentIDs.List(), tag.ChildIDs.List(), qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc ValidateUpdate(ctx context.Context, id int, partial models.TagPartial, qb models.TagReader) error {\n\texisting, err := qb.Find(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif existing == nil {\n\t\treturn &NotFoundError{id}\n\t}\n\n\tif partial.Name.Set {\n\t\tif partial.Name.Value == \"\" {\n\t\t\treturn ErrNameMissing\n\t\t}\n\n\t\tif err := EnsureTagNameUnique(ctx, id, partial.Name.Value, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif partial.Aliases != nil {\n\t\tif err := existing.LoadAliases(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tnewAliases := partial.Aliases.Apply(existing.Aliases.List())\n\n\t\tif err := EnsureAliasesUnique(ctx, id, newAliases, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif partial.ParentIDs != nil || partial.ChildIDs != nil {\n\t\tif err := existing.LoadParentIDs(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := existing.LoadChildIDs(ctx, qb); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tparentIDs := partial.ParentIDs\n\t\tif parentIDs == nil {\n\t\t\tparentIDs = &models.UpdateIDs{IDs: existing.ParentIDs.List(), Mode: models.RelationshipUpdateModeSet}\n\t\t}\n\n\t\tchildIDs := partial.ChildIDs\n\t\tif childIDs == nil {\n\t\t\tchildIDs = &models.UpdateIDs{IDs: existing.ChildIDs.List(), Mode: models.RelationshipUpdateModeSet}\n\t\t}\n\n\t\tif err := ValidateHierarchyExisting(ctx, existing, parentIDs.Apply(existing.ParentIDs.List()), childIDs.Apply(existing.ChildIDs.List()), qb); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/tag/validate_test.go",
    "content": "package tag\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/models/mocks\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc nameFilter(n string) *models.TagFilterType {\n\treturn &models.TagFilterType{\n\t\tName: &models.StringCriterionInput{\n\t\t\tValue:    n,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n}\n\nfunc aliasFilter(n string) *models.TagFilterType {\n\treturn &models.TagFilterType{\n\t\tAliases: &models.StringCriterionInput{\n\t\t\tValue:    n,\n\t\t\tModifier: models.CriterionModifierEquals,\n\t\t},\n\t}\n}\n\nfunc TestEnsureAliasesUnique(t *testing.T) {\n\tdb := mocks.NewDatabase()\n\n\tconst (\n\t\tname1    = \"name 1\"\n\t\tname2    = \"name 2\"\n\t\talias1   = \"alias 1\"\n\t\tnewAlias = \"new alias\"\n\t)\n\n\texisting2 := models.Tag{\n\t\tID:   2,\n\t\tName: name2,\n\t}\n\n\tpp := 1\n\tfindFilter := &models.FindFilterType{\n\t\tPerPage: &pp,\n\t}\n\n\t// name1 matches existing1 name - ok\n\t// EnsureAliasesUnique calls EnsureTagNameUnique.\n\t// EnsureTagNameUnique calls ByName then ByAlias.\n\n\t// Case 1: valid alias\n\t// ByName \"alias 1\" -> nil\n\t// ByAlias \"alias 1\" -> nil\n\tdb.Tag.On(\"Query\", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil)\n\tdb.Tag.On(\"Query\", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil)\n\n\t// Case 2: alias duplicates existing2 name\n\t// ByName \"name 2\" -> existing2\n\tdb.Tag.On(\"Query\", testCtx, nameFilter(name2), findFilter).Return([]*models.Tag{&existing2}, 1, nil)\n\n\t// Case 3: alias duplicates existing2 alias\n\t// ByName \"new alias\" -> nil\n\t// ByAlias \"new alias\" -> existing2\n\tdb.Tag.On(\"Query\", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil)\n\tdb.Tag.On(\"Query\", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Tag{&existing2}, 1, nil)\n\n\ttests := []struct {\n\t\ttName   string\n\t\tid      int\n\t\taliases []string\n\t\twant    error\n\t}{\n\t\t{\"valid alias\", 1, []string{alias1}, nil},\n\t\t{\"alias duplicates other name\", 1, []string{name2}, &NameExistsError{name2}},\n\t\t{\"alias duplicates other alias\", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.tName, func(t *testing.T) {\n\t\t\tgot := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/txn/hooks.go",
    "content": "package txn\n\nimport (\n\t\"context\"\n)\n\ntype key int\n\nconst (\n\thookManagerKey key = iota + 1\n)\n\ntype hookManager struct {\n\tpreCommitHooks    []TxnFunc\n\tpostCommitHooks   []MustFunc\n\tpostRollbackHooks []MustFunc\n\tpostCompleteHooks []MustFunc\n}\n\nfunc (m *hookManager) register(ctx context.Context) context.Context {\n\treturn context.WithValue(ctx, hookManagerKey, m)\n}\n\nfunc hookManagerCtx(ctx context.Context) *hookManager {\n\tm, ok := ctx.Value(hookManagerKey).(*hookManager)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn m\n}\n\nfunc executeHooks(ctx context.Context, hooks []TxnFunc) error {\n\t// we need to return the first error\n\tfor _, h := range hooks {\n\t\tif err := h(ctx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc executeMustHooks(ctx context.Context, hooks []MustFunc) {\n\tfor _, h := range hooks {\n\t\th(ctx)\n\t}\n}\n\nfunc (m *hookManager) executePostCommitHooks(ctx context.Context) {\n\texecuteMustHooks(ctx, m.postCommitHooks)\n}\n\nfunc (m *hookManager) executePostRollbackHooks(ctx context.Context) {\n\texecuteMustHooks(ctx, m.postRollbackHooks)\n}\n\nfunc (m *hookManager) executePreCommitHooks(ctx context.Context) error {\n\treturn executeHooks(ctx, m.preCommitHooks)\n}\n\nfunc (m *hookManager) executePostCompleteHooks(ctx context.Context) {\n\texecuteMustHooks(ctx, m.postCompleteHooks)\n}\n\nfunc AddPreCommitHook(ctx context.Context, hook TxnFunc) {\n\tm := hookManagerCtx(ctx)\n\tm.preCommitHooks = append(m.preCommitHooks, hook)\n}\n\nfunc AddPostCommitHook(ctx context.Context, hook MustFunc) {\n\tm := hookManagerCtx(ctx)\n\tm.postCommitHooks = append(m.postCommitHooks, hook)\n}\n\nfunc AddPostRollbackHook(ctx context.Context, hook MustFunc) {\n\tm := hookManagerCtx(ctx)\n\tm.postRollbackHooks = append(m.postRollbackHooks, hook)\n}\n\nfunc AddPostCompleteHook(ctx context.Context, hook MustFunc) {\n\tm := hookManagerCtx(ctx)\n\tm.postCompleteHooks = append(m.postCompleteHooks, hook)\n}\n"
  },
  {
    "path": "pkg/txn/transaction.go",
    "content": "// Package txn provides functions for running transactions.\npackage txn\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\ntype Manager interface {\n\tBegin(ctx context.Context, writable bool) (context.Context, error)\n\tCommit(ctx context.Context) error\n\tRollback(ctx context.Context) error\n\n\tIsLocked(err error) bool\n}\n\ntype DatabaseProvider interface {\n\tWithDatabase(ctx context.Context) (context.Context, error)\n}\n\n// TxnFunc is a function that is used in transaction hooks.\n// It should return an error if something went wrong.\ntype TxnFunc func(ctx context.Context) error\n\n// MustFunc is a function that is used in transaction hooks.\n// It does not return an error.\ntype MustFunc func(ctx context.Context)\n\n// WithTxn executes fn in a transaction. If fn returns an error then\n// the transaction is rolled back. Otherwise it is committed.\n// This function will call m.Begin with writable = true.\n// This function should be used for making changes to the database.\nfunc WithTxn(ctx context.Context, m Manager, fn TxnFunc) error {\n\tconst (\n\t\texecComplete = true\n\t\twritable     = true\n\t)\n\treturn withTxn(ctx, m, fn, writable, execComplete)\n}\n\n// WithReadTxn executes fn in a transaction. If fn returns an error then\n// the transaction is rolled back. Otherwise it is committed.\n// This function will call m.Begin with writable = false.\nfunc WithReadTxn(ctx context.Context, m Manager, fn TxnFunc) error {\n\tconst (\n\t\texecComplete = true\n\t\twritable     = false\n\t)\n\treturn withTxn(ctx, m, fn, writable, execComplete)\n}\n\nfunc withTxn(ctx context.Context, m Manager, fn TxnFunc, writable bool, execCompleteOnLocked bool) error {\n\t// post-hooks should be executed with the outside context\n\ttxnCtx, err := begin(ctx, m, writable)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thookMgr := hookManagerCtx(txnCtx)\n\n\tdefer func() {\n\t\tif p := recover(); p != nil {\n\t\t\t// a panic occurred, rollback and repanic\n\t\t\trollback(txnCtx, m)\n\t\t\tpanic(p)\n\t\t}\n\n\t\tif err != nil {\n\t\t\t// something went wrong, rollback\n\t\t\trollback(txnCtx, m)\n\n\t\t\t// execute post-hooks with outside context\n\t\t\thookMgr.executePostRollbackHooks(ctx)\n\n\t\t\tif execCompleteOnLocked || !m.IsLocked(err) {\n\t\t\t\thookMgr.executePostCompleteHooks(ctx)\n\t\t\t}\n\t\t} else {\n\t\t\t// all good, commit\n\t\t\terr = commit(txnCtx, m)\n\n\t\t\t// execute post-hooks with outside context\n\t\t\thookMgr.executePostCommitHooks(ctx)\n\t\t\thookMgr.executePostCompleteHooks(ctx)\n\t\t}\n\n\t}()\n\n\terr = fn(txnCtx)\n\treturn err\n}\n\nfunc begin(ctx context.Context, m Manager, writable bool) (context.Context, error) {\n\tvar err error\n\tctx, err = m.Begin(ctx, writable)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\thm := hookManager{}\n\tctx = hm.register(ctx)\n\n\treturn ctx, nil\n}\n\nfunc commit(ctx context.Context, m Manager) error {\n\thookMgr := hookManagerCtx(ctx)\n\tif err := hookMgr.executePreCommitHooks(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := m.Commit(ctx); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc rollback(ctx context.Context, m Manager) {\n\tif err := m.Rollback(ctx); err != nil {\n\t\treturn\n\t}\n}\n\n// WithDatabase executes fn with the context provided by p.WithDatabase.\n// It does not run inside a transaction, so all database operations will be\n// executed in their own transaction.\nfunc WithDatabase(ctx context.Context, p DatabaseProvider, fn TxnFunc) error {\n\tvar err error\n\tctx, err = p.WithDatabase(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn fn(ctx)\n}\n\n// Retryer is a provides WithTxn function that retries the transaction\n// if it fails with a locked database error.\n// Transactions are run in exclusive mode.\ntype Retryer struct {\n\tManager Manager\n\t// use value < 0 to retry forever\n\tRetries int\n\tOnFail  func(ctx context.Context, err error, attempt int) error\n}\n\nfunc (r Retryer) WithTxn(ctx context.Context, fn TxnFunc) error {\n\tvar attempt int\n\tvar err error\n\tfor attempt = 1; attempt <= r.Retries || r.Retries < 0; attempt++ {\n\t\tconst (\n\t\t\texecComplete = false\n\t\t\texclusive    = true\n\t\t)\n\t\terr = withTxn(ctx, r.Manager, fn, exclusive, execComplete)\n\n\t\tif err == nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tif !r.Manager.IsLocked(err) {\n\t\t\treturn err\n\t\t}\n\n\t\tif r.OnFail != nil {\n\t\t\tif err := r.OnFail(ctx, err, attempt); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"failed after %d attempts: %w\", attempt, err)\n}\n"
  },
  {
    "path": "pkg/utils/boolean.go",
    "content": "package utils\n\n// IsTrue returns true if the bool pointer is not nil and true.\nfunc IsTrue(b *bool) bool {\n\treturn b != nil && *b\n}\n"
  },
  {
    "path": "pkg/utils/date.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"time\"\n)\n\nfunc ParseDateStringAsTime(dateString string) (time.Time, error) {\n\t// https://stackoverflow.com/a/20234207 WTF?\n\n\tt, e := time.Parse(time.RFC3339, dateString)\n\tif e == nil {\n\t\treturn t, nil\n\t}\n\n\tt, e = time.Parse(\"2006-01-02\", dateString)\n\tif e == nil {\n\t\treturn t, nil\n\t}\n\n\tt, e = time.Parse(\"2006-01-02 15:04:05\", dateString)\n\tif e == nil {\n\t\treturn t, nil\n\t}\n\n\treturn time.Time{}, fmt.Errorf(\"ParseDateStringAsTime failed: dateString <%s>\", dateString)\n}\n"
  },
  {
    "path": "pkg/utils/date_test.go",
    "content": "package utils\n\nimport (\n\t\"testing\"\n)\n\nfunc TestParseDateStringAsTime(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tinput       string\n\t\texpectError bool\n\t}{\n\t\t// Full date formats (existing support)\n\t\t{\"RFC3339\", \"2014-01-02T15:04:05Z\", false},\n\t\t{\"Date only\", \"2014-01-02\", false},\n\t\t{\"Date with time\", \"2014-01-02 15:04:05\", false},\n\n\t\t// Invalid formats\n\t\t{\"Invalid format\", \"not-a-date\", true},\n\t\t{\"Empty string\", \"\", true},\n\t\t{\"Year-Month\", \"2006-08\", true},\n\t\t{\"Year only\", \"2014\", true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := ParseDateStringAsTime(tt.input)\n\n\t\t\tif tt.expectError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error for input %q, but got none\", tt.input)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Unexpected error for input %q: %v\", tt.input, err)\n\t\t\t\t}\n\t\t\t\tif result.IsZero() {\n\t\t\t\t\tt.Errorf(\"Expected non-zero time for input %q\", tt.input)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/doc.go",
    "content": "// Package utils provides various utility functions for the application.\npackage utils\n"
  },
  {
    "path": "pkg/utils/func.go",
    "content": "package utils\n\n// Do executes each function in the slice in order. If any function returns an error, it is returned immediately.\nfunc Do(fn []func() error) error {\n\tfor _, f := range fn {\n\t\tif err := f(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/utils/http.go",
    "content": "package utils\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/stashapp/stash/pkg/hash/md5\"\n)\n\n// Returns an MD5 hash of data, formatted for use as an HTTP ETag header.\n// Intended for use with `http.ServeContent`, to respond to conditional requests.\nfunc GenerateETag(data []byte) string {\n\thash := md5.FromBytes(data)\n\treturn `\"` + hash + `\"`\n}\n\nfunc setStaticContentCacheControl(w http.ResponseWriter, r *http.Request) {\n\tif r.URL.Query().Has(\"t\") {\n\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=31536000, immutable\")\n\t} else {\n\t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\t}\n}\n\n// Serves static content, adding Cache-Control: no-cache and a generated ETag header.\n// Responds to conditional requests using the ETag.\nfunc ServeStaticContent(w http.ResponseWriter, r *http.Request, data []byte) {\n\tsetStaticContentCacheControl(w, r)\n\tw.Header().Set(\"ETag\", GenerateETag(data))\n\n\thttp.ServeContent(w, r, \"\", time.Time{}, bytes.NewReader(data))\n}\n\n// Serves static content at filepath, adding Cache-Control: no-cache.\n// Responds to conditional requests using the file modtime.\nfunc ServeStaticFile(w http.ResponseWriter, r *http.Request, filepath string) {\n\tsetStaticContentCacheControl(w, r)\n\n\thttp.ServeFile(w, r, filepath)\n}\n\nfunc toHTTPError(err error) (msg string, httpStatus int) {\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\treturn \"404 page not found\", http.StatusNotFound\n\t}\n\tif errors.Is(err, fs.ErrPermission) {\n\t\treturn \"403 Forbidden\", http.StatusForbidden\n\t}\n\treturn \"500 Internal Server Error\", http.StatusInternalServerError\n}\n\n// ServeStaticFileModTime serves a static file at the given path using the given modTime instead of the file modTime.\nfunc ServeStaticFileModTime(w http.ResponseWriter, r *http.Request, path string, modTime time.Time) {\n\tsetStaticContentCacheControl(w, r)\n\n\tdir, file := filepath.Split(path)\n\tfs := http.Dir(dir)\n\n\tf, err := fs.Open(file)\n\tif err != nil {\n\t\tmsg, code := toHTTPError(err)\n\t\thttp.Error(w, msg, code)\n\t\treturn\n\t}\n\tdefer f.Close()\n\n\td, err := f.Stat()\n\tif err != nil {\n\t\tmsg, code := toHTTPError(err)\n\t\thttp.Error(w, msg, code)\n\t\treturn\n\t}\n\n\thttp.ServeContent(w, r, d.Name(), modTime, f)\n}\n"
  },
  {
    "path": "pkg/utils/image.go",
    "content": "package utils\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"time\"\n)\n\n// Timeout to get the image. Includes transfer time. May want to make this\n// configurable at some point.\nconst imageGetTimeout = time.Second * 60\n\nconst base64RE = `^data:.+\\/(.+);base64,(.*)$`\n\n// ProcessImageInput transforms an image string either from a base64 encoded\n// string, or from a URL, and returns the image as a byte slice\nfunc ProcessImageInput(ctx context.Context, imageInput string) ([]byte, error) {\n\tif imageInput == \"\" {\n\t\treturn []byte{}, nil\n\t}\n\n\tregex := regexp.MustCompile(base64RE)\n\tif regex.MatchString(imageInput) {\n\t\td, err := ProcessBase64Image(imageInput)\n\t\treturn d, err\n\t}\n\n\t// assume input is a URL. Read it.\n\treturn ReadImageFromURL(ctx, imageInput)\n}\n\n// ReadImageFromURL returns image data from a URL\nfunc ReadImageFromURL(ctx context.Context, url string) ([]byte, error) {\n\tclient := &http.Client{\n\t\tTransport: &http.Transport{ // ignore insecure certificates\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t\tProxy:           http.ProxyFromEnvironment,\n\t\t},\n\n\t\tTimeout: imageGetTimeout,\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// assume is a URL for now\n\n\t// set the host of the URL as the referer\n\tif req.URL.Scheme != \"\" {\n\t\treq.Header.Set(\"Referer\", req.URL.Scheme+\"://\"+req.Host+\"/\")\n\t}\n\treq.Header.Set(\"User-Agent\", getUserAgent())\n\n\tresp, err := client.Do(req)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif resp.StatusCode >= 400 {\n\t\treturn nil, fmt.Errorf(\"http error %d\", resp.StatusCode)\n\t}\n\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn body, nil\n}\n\n// ProcessBase64Image transforms a base64 encoded string from a form post and\n// returns the image itself as a byte slice.\nfunc ProcessBase64Image(imageString string) ([]byte, error) {\n\tif imageString == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty image string\")\n\t}\n\n\tregex := regexp.MustCompile(base64RE)\n\tmatches := regex.FindStringSubmatch(imageString)\n\tvar encodedString string\n\tif len(matches) > 2 {\n\t\tencodedString = regex.FindStringSubmatch(imageString)[2]\n\t} else {\n\t\tencodedString = imageString\n\t}\n\timageData, err := GetDataFromBase64String(encodedString)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn imageData, nil\n}\n\n// GetDataFromBase64String returns the given base64 encoded string as a byte slice\nfunc GetDataFromBase64String(encodedString string) ([]byte, error) {\n\treturn base64.StdEncoding.DecodeString(encodedString)\n}\n\n// GetBase64StringFromData returns the given byte slice as a base64 encoded string\nfunc GetBase64StringFromData(data []byte) string {\n\treturn base64.StdEncoding.EncodeToString(data)\n}\n\nfunc ServeImage(w http.ResponseWriter, r *http.Request, image []byte) {\n\tcontentType := http.DetectContentType(image)\n\tif contentType == \"text/xml; charset=utf-8\" || contentType == \"text/plain; charset=utf-8\" {\n\t\tcontentType = \"image/svg+xml\"\n\t}\n\n\tw.Header().Set(\"Content-Type\", contentType)\n\tServeStaticContent(w, r, image)\n}\n"
  },
  {
    "path": "pkg/utils/map.go",
    "content": "package utils\n\nimport (\n\t\"strings\"\n)\n\n// NestedMap is a map that supports nested keys.\n// It is expected that the nested maps are of type map[string]interface{}\ntype NestedMap map[string]interface{}\n\nfunc (m NestedMap) Get(key string) (interface{}, bool) {\n\tfields := strings.Split(key, \".\")\n\n\tcurrent := m\n\n\tfor _, f := range fields[:len(fields)-1] {\n\t\tv, found := current[f]\n\t\tif !found {\n\t\t\treturn nil, false\n\t\t}\n\n\t\tcurrent, _ = v.(map[string]interface{})\n\t\tif current == nil {\n\t\t\treturn nil, false\n\t\t}\n\t}\n\n\tret, found := current[fields[len(fields)-1]]\n\treturn ret, found\n}\n\nfunc (m NestedMap) Set(key string, value interface{}) {\n\tfields := strings.Split(key, \".\")\n\n\tcurrent := m\n\n\tfor _, f := range fields[:len(fields)-1] {\n\t\tv, ok := current[f].(map[string]interface{})\n\t\tif !ok {\n\t\t\tv = make(map[string]interface{})\n\t\t\tcurrent[f] = v\n\t\t}\n\n\t\tcurrent = v\n\t}\n\n\tcurrent[fields[len(fields)-1]] = value\n}\n\nfunc (m NestedMap) Delete(key string) {\n\tfields := strings.Split(key, \".\")\n\n\tcurrent := m\n\n\tfor _, f := range fields[:len(fields)-1] {\n\t\tv, ok := current[f].(map[string]interface{})\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\n\t\tcurrent = v\n\t}\n\n\tdelete(current, fields[len(fields)-1])\n}\n\n// MergeMaps merges src into dest. If a key exists in both maps, the value from src is used.\nfunc MergeMaps(dest map[string]interface{}, src map[string]interface{}) {\n\tfor k, v := range src {\n\t\tif _, ok := dest[k]; ok {\n\t\t\tif srcMap, ok := v.(map[string]interface{}); ok {\n\t\t\t\tif destMap, ok := dest[k].(map[string]interface{}); ok {\n\t\t\t\t\tMergeMaps(destMap, srcMap)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tdest[k] = v\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/map_test.go",
    "content": "package utils\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestNestedMapGet(t *testing.T) {\n\tm := NestedMap{\n\t\t\"foo\": map[string]interface{}{\n\t\t\t\"bar\": map[string]interface{}{\n\t\t\t\t\"baz\": \"qux\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttests := []struct {\n\t\tname  string\n\t\tkey   string\n\t\twant  interface{}\n\t\tfound bool\n\t}{\n\t\t{\n\t\t\tname:  \"Get a value from a nested map\",\n\t\t\tkey:   \"foo.bar.baz\",\n\t\t\twant:  \"qux\",\n\t\t\tfound: true,\n\t\t},\n\t\t{\n\t\t\tname:  \"Get a value from a nested map with a missing key\",\n\t\t\tkey:   \"foo.bar.quux\",\n\t\t\twant:  nil,\n\t\t\tfound: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"Get a value from a nested map with a missing key\",\n\t\t\tkey:   \"foo.quux.baz\",\n\t\t\twant:  nil,\n\t\t\tfound: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"Get a value from a nested map with a missing key\",\n\t\t\tkey:   \"quux.bar.baz\",\n\t\t\twant:  nil,\n\t\t\tfound: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"Get a value from a nested map with a missing key\",\n\t\t\tkey:   \"foo.bar\",\n\t\t\twant:  map[string]interface{}{\"baz\": \"qux\"},\n\t\t\tfound: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, found := m.Get(tt.key)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"NestedMap.Get() got = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t\tif found != tt.found {\n\t\t\t\tt.Errorf(\"NestedMap.Get() found = %v, want %v\", found, tt.found)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNestedMapSet(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tkey      string\n\t\texisting NestedMap\n\t\twant     NestedMap\n\t}{\n\t\t{\n\t\t\tname:     \"Set a value in a nested map\",\n\t\t\tkey:      \"foo.bar.baz\",\n\t\t\texisting: NestedMap{},\n\t\t\twant: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": map[string]interface{}{\n\t\t\t\t\t\t\"baz\": \"qux\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Overwrite existing value\",\n\t\t\tkey:  \"foo.bar\",\n\t\t\texisting: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"old\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"qux\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Set a value overwriting a primitive with a nested map\",\n\t\t\tkey:  \"foo.bar\",\n\t\t\texisting: NestedMap{\n\t\t\t\t\"foo\": \"bar\",\n\t\t\t},\n\t\t\twant: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"qux\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttt.existing.Set(tt.key, \"qux\")\n\t\t\tif !reflect.DeepEqual(tt.existing, tt.want) {\n\t\t\t\tt.Errorf(\"NestedMap.Set() got = %v, want %v\", tt.existing, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNestedMapDelete(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tkey      string\n\t\texisting NestedMap\n\t\twant     NestedMap\n\t}{\n\t\t{\n\t\t\tname: \"Delete non existing value\",\n\t\t\tkey:  \"foo.bar.baa\",\n\t\t\texisting: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": map[string]interface{}{\n\t\t\t\t\t\t\"baz\": \"qux\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": map[string]interface{}{\n\t\t\t\t\t\t\"baz\": \"qux\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Delete existing value\",\n\t\t\tkey:  \"foo.bar\",\n\t\t\texisting: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"old\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Delete existing map\",\n\t\t\tkey:  \"foo.bar\",\n\t\t\texisting: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": map[string]interface{}{\n\t\t\t\t\t\t\"baz\": \"qux\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: NestedMap{\n\t\t\t\t\"foo\": map[string]interface{}{},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttt.existing.Delete(tt.key)\n\t\t\tif !reflect.DeepEqual(tt.existing, tt.want) {\n\t\t\t\tt.Errorf(\"NestedMap.Set() got = %v, want %v\", tt.existing, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMergeMaps(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tdest   map[string]interface{}\n\t\tsrc    map[string]interface{}\n\t\tresult map[string]interface{}\n\t}{\n\t\t{\n\t\t\tname: \"Merge two maps\",\n\t\t\tdest: map[string]interface{}{\n\t\t\t\t\"foo\": \"bar\",\n\t\t\t},\n\t\t\tsrc: map[string]interface{}{\n\t\t\t\t\"baz\": \"qux\",\n\t\t\t},\n\t\t\tresult: map[string]interface{}{\n\t\t\t\t\"foo\": \"bar\",\n\t\t\t\t\"baz\": \"qux\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Merge two maps with overlapping keys\",\n\t\t\tdest: map[string]interface{}{\n\t\t\t\t\"foo\": \"bar\",\n\t\t\t\t\"baz\": \"qux\",\n\t\t\t},\n\t\t\tsrc: map[string]interface{}{\n\t\t\t\t\"baz\": \"quux\",\n\t\t\t},\n\t\t\tresult: map[string]interface{}{\n\t\t\t\t\"foo\": \"bar\",\n\t\t\t\t\"baz\": \"quux\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Merge two maps with overlapping keys and nested maps\",\n\t\t\tdest: map[string]interface{}{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"baz\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsrc: map[string]interface{}{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"qux\": \"quux\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tresult: map[string]interface{}{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"baz\",\n\t\t\t\t\t\"qux\": \"quux\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Merge two maps with overlapping keys and nested maps\",\n\t\t\tdest: map[string]interface{}{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"baz\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsrc: map[string]interface{}{\n\t\t\t\t\"foo\": \"qux\",\n\t\t\t},\n\t\t\tresult: map[string]interface{}{\n\t\t\t\t\"foo\": \"qux\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Merge two maps with overlapping keys and nested maps\",\n\t\t\tdest: map[string]interface{}{\n\t\t\t\t\"foo\": \"qux\",\n\t\t\t},\n\t\t\tsrc: map[string]interface{}{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"baz\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tresult: map[string]interface{}{\n\t\t\t\t\"foo\": map[string]interface{}{\n\t\t\t\t\t\"bar\": \"baz\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tMergeMaps(tt.dest, tt.src)\n\t\t\tif !reflect.DeepEqual(tt.dest, tt.result) {\n\t\t\t\tt.Errorf(\"NestedMap.Set() got = %v, want %v\", tt.dest, tt.result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/mutex.go",
    "content": "package utils\n\nimport \"sync\"\n\n// MutexManager manages access to mutexes using a mutex type and key.\ntype MutexManager struct {\n\tmapChan chan map[string]<-chan struct{}\n}\n\n// NewMutexManager returns a new instance of MutexManager.\nfunc NewMutexManager() *MutexManager {\n\tret := &MutexManager{\n\t\tmapChan: make(chan map[string]<-chan struct{}, 1),\n\t}\n\n\tinitial := make(map[string]<-chan struct{})\n\tret.mapChan <- initial\n\n\treturn ret\n}\n\n// Claim blocks until the mutex for the mutexType and key pair is available.\n// The mutex is then claimed by the calling code until the provided done\n// channel is closed.\nfunc (csm *MutexManager) Claim(mutexType string, key string, done <-chan struct{}) {\n\tmapKey := mutexType + \"_\" + key\n\tsuccess := false\n\n\tvar existing <-chan struct{}\n\tfor !success {\n\t\t// grab the map\n\t\tm := <-csm.mapChan\n\n\t\t// get the entry for the given key\n\t\tnewEntry := m[mapKey]\n\n\t\t// if its the existing entry or nil, then it's available, add our channel\n\t\tif newEntry == nil || newEntry == existing {\n\t\t\tm[mapKey] = done\n\t\t\tsuccess = true\n\t\t}\n\n\t\t// return the map\n\t\tcsm.mapChan <- m\n\n\t\t// if there is an existing entry, now we can wait for it to\n\t\t// finish, then repeat the process\n\t\tif newEntry != nil {\n\t\t\texisting = newEntry\n\t\t\t<-newEntry\n\t\t}\n\t}\n\n\t// add to goroutine to remove from the map only\n\tgo func() {\n\t\t<-done\n\n\t\tm := <-csm.mapChan\n\n\t\tif m[mapKey] == done {\n\t\t\tdelete(m, mapKey)\n\t\t}\n\n\t\tcsm.mapChan <- m\n\t}()\n}\n\ntype MutexField[T any] struct {\n\tmutex sync.RWMutex\n\tvalue T\n}\n\nfunc (mf *MutexField[T]) Get() T {\n\tmf.mutex.RLock()\n\tdefer mf.mutex.RUnlock()\n\treturn mf.value\n}\n\nfunc (mf *MutexField[T]) Set(value T) {\n\tmf.mutex.Lock()\n\tdefer mf.mutex.Unlock()\n\tmf.value = value\n}\n\nfunc (mf *MutexField[T]) SetFunc(f func(T) T) {\n\tmf.mutex.Lock()\n\tdefer mf.mutex.Unlock()\n\tmf.value = f(mf.value)\n}\n"
  },
  {
    "path": "pkg/utils/mutex_test.go",
    "content": "package utils\n\nimport (\n\t\"sync\"\n\t\"testing\"\n)\n\n// should be run with -race\nfunc TestMutexManager(t *testing.T) {\n\tm := NewMutexManager()\n\n\tmap1 := make(map[string]bool)\n\tmap2 := make(map[string]bool)\n\tmap3 := make(map[string]bool)\n\tmaps := []map[string]bool{\n\t\tmap1,\n\t\tmap2,\n\t\tmap3,\n\t}\n\n\ttypes := []string{\n\t\t\"foo\",\n\t\t\"foo\",\n\t\t\"bar\",\n\t}\n\n\tconst key = \"baz\"\n\n\tconst workers = 8\n\tconst loops = 300\n\tvar wg sync.WaitGroup\n\tfor k := 0; k < workers; k++ {\n\t\twg.Add(1)\n\t\tgo func(wk int) {\n\t\t\tdefer wg.Done()\n\t\t\tfor l := 0; l < loops; l++ {\n\t\t\t\tfunc(l int) {\n\t\t\t\t\tc := make(chan struct{})\n\t\t\t\t\tdefer close(c)\n\n\t\t\t\t\tm.Claim(types[l%3], key, c)\n\n\t\t\t\t\tmaps[l%3][key] = true\n\t\t\t\t}(l)\n\t\t\t}\n\t\t}(k)\n\t}\n\n\twg.Wait()\n}\n"
  },
  {
    "path": "pkg/utils/phash.go",
    "content": "package utils\n\nimport (\n\t\"math\"\n\t\"strconv\"\n\n\t\"github.com/corona10/goimagehash\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n)\n\ntype Phash struct {\n\tSceneID   int     `db:\"id\"`\n\tHash      int64   `db:\"phash\"`\n\tDuration  float64 `db:\"duration\"`\n\tNeighbors []int\n\tBucket    int\n}\n\nfunc FindDuplicates(hashes []*Phash, distance int, durationDiff float64) [][]int {\n\tfor i, scene := range hashes {\n\t\tsceneHash := goimagehash.NewImageHash(uint64(scene.Hash), goimagehash.PHash)\n\t\tfor j, neighbor := range hashes {\n\t\t\tif i != j && scene.SceneID != neighbor.SceneID {\n\t\t\t\tneighbourDurationDistance := 0.\n\t\t\t\tif scene.Duration > 0 && neighbor.Duration > 0 {\n\t\t\t\t\tneighbourDurationDistance = math.Abs(scene.Duration - neighbor.Duration)\n\t\t\t\t}\n\t\t\t\tif (neighbourDurationDistance <= durationDiff) || (durationDiff < 0) {\n\t\t\t\t\tneighborHash := goimagehash.NewImageHash(uint64(neighbor.Hash), goimagehash.PHash)\n\t\t\t\t\tneighborDistance, _ := sceneHash.Distance(neighborHash)\n\t\t\t\t\tif neighborDistance <= distance {\n\t\t\t\t\t\tscene.Neighbors = append(scene.Neighbors, j)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvar buckets [][]int\n\tfor _, scene := range hashes {\n\t\tif len(scene.Neighbors) > 0 && scene.Bucket == -1 {\n\t\t\tbucket := len(buckets)\n\t\t\tscenes := []int{scene.SceneID}\n\t\t\tscene.Bucket = bucket\n\t\t\tfindNeighbors(bucket, scene.Neighbors, hashes, &scenes)\n\n\t\t\tif len(scenes) > 1 {\n\t\t\t\tbuckets = append(buckets, scenes)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn buckets\n}\n\nfunc findNeighbors(bucket int, neighbors []int, hashes []*Phash, scenes *[]int) {\n\tfor _, id := range neighbors {\n\t\thash := hashes[id]\n\t\tif hash.Bucket == -1 {\n\t\t\thash.Bucket = bucket\n\t\t\t*scenes = sliceutil.AppendUnique(*scenes, hash.SceneID)\n\t\t\tfindNeighbors(bucket, hash.Neighbors, hashes, scenes)\n\t\t}\n\t}\n}\n\nfunc PhashToString(phash int64) string {\n\treturn strconv.FormatUint(uint64(phash), 16)\n}\n\nfunc StringToPhash(s string) (int64, error) {\n\tret, err := strconv.ParseUint(s, 16, 64)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn int64(ret), nil\n}\n"
  },
  {
    "path": "pkg/utils/reflect.go",
    "content": "package utils\n\nimport \"reflect\"\n\n// NotNilFields returns the matching tag values of fields from an object that are not nil.\n// Panics if the provided object is not a struct.\nfunc NotNilFields(subject interface{}, tag string) []string {\n\tvalue := reflect.ValueOf(subject)\n\tstructType := value.Type()\n\n\tif structType.Kind() != reflect.Struct {\n\t\tpanic(\"subject must be struct\")\n\t}\n\n\tvar ret []string\n\n\tfor i := 0; i < value.NumField(); i++ {\n\t\tfield := value.Field(i)\n\n\t\tkind := field.Type().Kind()\n\t\tif (kind == reflect.Ptr || kind == reflect.Slice) && !field.IsNil() {\n\t\t\ttagValue := structType.Field(i).Tag.Get(tag)\n\t\t\tif tagValue != \"\" {\n\t\t\t\tret = append(ret, tagValue)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/utils/reflect_test.go",
    "content": "package utils\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestNotNilFields(t *testing.T) {\n\tv := \"value\"\n\tvar zeroStr string\n\n\ttype testObject struct {\n\t\tptrField      *string `tag:\"ptrField\"`\n\t\tnoTagField    *string\n\t\totherTagField *string  `otherTag:\"otherTagField\"`\n\t\tsliceField    []string `tag:\"sliceField\"`\n\t}\n\n\ttype args struct {\n\t\tsubject interface{}\n\t\ttag     string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant []string\n\t}{\n\t\t{\n\t\t\t\"basic\",\n\t\t\targs{\n\t\t\t\ttestObject{\n\t\t\t\t\tptrField:      &v,\n\t\t\t\t\tnoTagField:    &v,\n\t\t\t\t\totherTagField: &v,\n\t\t\t\t\tsliceField:    []string{v},\n\t\t\t\t},\n\t\t\t\t\"tag\",\n\t\t\t},\n\t\t\t[]string{\"ptrField\", \"sliceField\"},\n\t\t},\n\t\t{\n\t\t\t\"empty\",\n\t\t\targs{\n\t\t\t\ttestObject{},\n\t\t\t\t\"tag\",\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"zero values\",\n\t\t\targs{\n\t\t\t\ttestObject{\n\t\t\t\t\tptrField:      &zeroStr,\n\t\t\t\t\tnoTagField:    &zeroStr,\n\t\t\t\t\totherTagField: &zeroStr,\n\t\t\t\t\tsliceField:    []string{},\n\t\t\t\t},\n\t\t\t\t\"tag\",\n\t\t\t},\n\t\t\t[]string{\"ptrField\", \"sliceField\"},\n\t\t},\n\t\t{\n\t\t\t\"other tag\",\n\t\t\targs{\n\t\t\t\ttestObject{\n\t\t\t\t\tptrField:      &v,\n\t\t\t\t\tnoTagField:    &v,\n\t\t\t\t\totherTagField: &v,\n\t\t\t\t\tsliceField:    []string{v},\n\t\t\t\t},\n\t\t\t\t\"otherTag\",\n\t\t\t},\n\t\t\t[]string{\"otherTagField\"},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := NotNilFields(tt.args.subject, tt.args.tag); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"NotNilFields() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/resources.go",
    "content": "package utils\n\nvar PendingGenerateResource, _ = GetDataFromBase64String(\"iVBORw0KGgoAAAANSUhEUgAAAfQAAADwBAMAAAAEHosbAAAAG1BMVEUAAADMzMyZmZkzMzNmZmZ/f38ZGRmysrJMTEwh+DPkAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAEOklEQVR4nO3YzW/bNhjHcfpN9jGP8iIfI2xrdrQLrLs67Zpdo21pelTWdbnGSTPvaBdosT+7z8OXxAFkIDu0Vrfv59AmpEjoJ1IkFecAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMC/JCqfNNV0Dja1GR0Vv36+O/piLLrsNdVo9EyaKnpzbfLkMZ1vfnptIGdnZ6XUDTUbo0+lOJKiqUlDFy1mqbP5aUPNpujZfK92b6WpSUMXLeYHfHXVULPpvvtiS0P1mFRfQfSLnYaaTffd8SvDsHF9eGQX7eCj2y2+n79z9hCeFq/0/7fzJ2HCp4LsPJ+EJWHln1P/1vmlfqZV0itvrSp0IX9WS9c/yk9cV2Q3lbaQjzPdcQNdtQ81+lhsBevpbxcheihwlyIHIXp1/5ZXujFa9JXIXy51Ib/L0s2tlY8eS1vIx6mu3HHxU7mv0eWbni56Q/mtLEP0UODK/GUeopez1LYv757rcpfpweBHbRu7kEqWA3n9QU570/GzVNpCFucHmWUycyP9+aLwM3q6q7+F6KEg05FchejzZWp7oe/7yq660qtd6kLy2g3H+iSuwiYRSlvIH2mk7uvEdeXSp9GZXto4h+ihYKD13ZDAFvjS2rjjQy0c+3TZfRf6IKyFvUUaPZW2kE8+dl1bsI9n7mLXL3rzSRzPVKAJ0+DdR7ep39/TqyY2e1IXOj9cVvu5Yg1jaQvJYrH4TmeofwSnfrj0hi3kNEQPBcNdW85qa2ET/nyx0F/sQCv7oVzq1IXf9t2bD+Kjp9IWiq9hx9/hod/hOwf+FBdXeF/gOg+jh4a+TR7OfFKnLvz58Fx/9NFTaQul6IWO/uJB9M56dBv1eG01s3/tOdiMWVzfRY9d2FWXkr8O73oqbaEYvRsPZzGplT4YdXvX46iHU+8ovuvOpeipC7uquvFdpXe9nWL0gW29b+qU1Ja56Xr0gSbohWtt4QvLvR1usmcpeurCrrKXIox6Km2hGL2v27eNYkxqk/p4PbrVD8K1A/+9apu87tw2qjF66sIvevrojndSw7VTUJvE6OHkMUlJp3ouna9Ht/ppuDaTGzvI2fnHjjS7KXrqwo/6TOfIji2OqbSF0kGrysNBNr7a+eSprEfX+pcSr13J7S8yLmudBt+Gg2zoJ3bh3/X9fyr9rB/mH1NpC6Xo3fj5EgdZt+wHE97q9+K1I9uullXtTzb53TKXuggrvBSXMhvo50u3rXvbXXT3vvje3UXXj9a/H2xuzv1cfEzX/jEvTtxKfxmV18v76LGLsK8Xs95imZU3qfTr1vyXuv+83gsd42Lbd7EVI/tyy7d9F1uRyateOd72XWyHfaY2/d32f8C2q1YeTD6/7Pn1ybbvAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAL+ITrOa6fKadMcEAAAAASUVORK5CYII=\")\n"
  },
  {
    "path": "pkg/utils/strings.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\ntype StrFormatMap map[string]interface{}\n\n// StrFormat formats the provided format string, replacing placeholders\n// in the form of \"{fieldName}\" with the values in the provided\n// StrFormatMap.\n//\n// For example,\n//\n//\tStrFormat(\"{foo} bar {baz}\", StrFormatMap{\n//\t    \"foo\": \"bar\",\n//\t    \"baz\": \"abc\",\n//\t})\n//\n// would return: \"bar bar abc\"\nfunc StrFormat(format string, m StrFormatMap) string {\n\targs := make([]string, len(m)*2)\n\ti := 0\n\n\tfor k, v := range m {\n\t\targs[i] = fmt.Sprintf(\"{%s}\", k)\n\t\targs[i+1] = fmt.Sprint(v)\n\t\ti += 2\n\t}\n\n\treturn strings.NewReplacer(args...).Replace(format)\n}\n\n// StringerSliceToStringSlice converts a slice of fmt.Stringers to a slice of strings.\nfunc StringerSliceToStringSlice[V fmt.Stringer](v []V) []string {\n\tret := make([]string, len(v))\n\tfor i, vv := range v {\n\t\tret[i] = vv.String()\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/utils/strings_test.go",
    "content": "package utils\n\nimport \"fmt\"\n\nfunc ExampleStrFormat() {\n\tfmt.Println(StrFormat(\"{foo} bar {baz}\", StrFormatMap{\n\t\t\"foo\": \"bar\",\n\t\t\"baz\": \"abc\",\n\t}))\n\t// Output:\n\t// bar bar abc\n}\n"
  },
  {
    "path": "pkg/utils/time.go",
    "content": "package utils\n\nimport \"time\"\n\n// Timeout executes the provided todo function, and waits for it to return. If\n// the function does not return before the waitTime duration is elapsed, then\n// onTimeout is executed, passing a channel that will be closed when the\n// function returns.\nfunc Timeout(todo func(), waitTime time.Duration, onTimeout func(done chan struct{})) {\n\tdone := make(chan struct{})\n\n\tgo func() {\n\t\ttodo()\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done: // on time, just exit\n\tcase <-time.After(waitTime):\n\t\tonTimeout(done)\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/url.go",
    "content": "package utils\n\nimport \"regexp\"\n\n// URLFromHandle adds the site URL to the input if the input is not already a URL\n// siteURL must not end with a slash\nfunc URLFromHandle(input string, siteURL string) string {\n\t// if the input is already a URL, return it\n\tre := regexp.MustCompile(`^https?://`)\n\tif re.MatchString(input) {\n\t\treturn input\n\t}\n\n\treturn siteURL + \"/\" + input\n}\n"
  },
  {
    "path": "pkg/utils/url_test.go",
    "content": "package utils\n\nimport \"testing\"\n\nfunc TestURLFromHandle(t *testing.T) {\n\ttype args struct {\n\t\tinput   string\n\t\tsiteURL string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"input is already a URL https\",\n\t\t\targs: args{\n\t\t\t\tinput:   \"https://foo.com\",\n\t\t\t\tsiteURL: \"https://bar.com\",\n\t\t\t},\n\t\t\twant: \"https://foo.com\",\n\t\t},\n\t\t{\n\t\t\tname: \"input is already a URL http\",\n\t\t\targs: args{\n\t\t\t\tinput:   \"http://foo.com\",\n\t\t\t\tsiteURL: \"https://bar.com\",\n\t\t\t},\n\t\t\twant: \"http://foo.com\",\n\t\t},\n\t\t{\n\t\t\tname: \"input is not a URL\",\n\t\t\targs: args{\n\t\t\t\tinput:   \"foo\",\n\t\t\t\tsiteURL: \"https://foo.com\",\n\t\t\t},\n\t\t\twant: \"https://foo.com/foo\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := URLFromHandle(tt.args.input, tt.args.siteURL); got != tt.want {\n\t\t\t\tt.Errorf(\"URLFromHandle() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/urlmap.go",
    "content": "package utils\n\nimport \"strings\"\n\n// URLMap is a map of URL prefixes to filesystem locations\ntype URLMap map[string]string\n\n// GetFilesystemLocation returns the adjusted URL and the filesystem location\nfunc (m URLMap) GetFilesystemLocation(url string) (newURL string, fsPath string) {\n\tnewURL = url\n\tif m == nil {\n\t\treturn\n\t}\n\n\troot := m[\"/\"]\n\tfor k, v := range m {\n\t\tif k != \"/\" && strings.HasPrefix(url, k) {\n\t\t\tnewURL = strings.TrimPrefix(url, k)\n\t\t\tfsPath = v\n\t\t\treturn\n\t\t}\n\t}\n\n\tif root != \"\" {\n\t\tfsPath = root\n\t\treturn\n\t}\n\n\treturn\n}\n"
  },
  {
    "path": "pkg/utils/urlmap_test.go",
    "content": "package utils\n\nimport (\n\t\"testing\"\n)\n\nfunc TestURLMap_GetFilesystemLocation(t *testing.T) {\n\t// create the URLMap\n\turlMap := make(URLMap)\n\turlMap[\"/\"] = \"root\"\n\turlMap[\"/foo\"] = \"bar\"\n\n\tempty := make(URLMap)\n\tvar nilMap URLMap\n\n\ttests := []struct {\n\t\tname       string\n\t\turlMap     URLMap\n\t\turl        string\n\t\twantNewURL string\n\t\twantFsPath string\n\t}{\n\t\t{\n\t\t\tname:       \"simple\",\n\t\t\turlMap:     urlMap,\n\t\t\turl:        \"/foo/bar\",\n\t\t\twantNewURL: \"/bar\",\n\t\t\twantFsPath: \"bar\",\n\t\t},\n\t\t{\n\t\t\tname:       \"root\",\n\t\t\turlMap:     urlMap,\n\t\t\turl:        \"/baz\",\n\t\t\twantNewURL: \"/baz\",\n\t\t\twantFsPath: \"root\",\n\t\t},\n\t\t{\n\t\t\tname:       \"root\",\n\t\t\turlMap:     urlMap,\n\t\t\turl:        \"/baz\",\n\t\t\twantNewURL: \"/baz\",\n\t\t\twantFsPath: \"root\",\n\t\t},\n\t\t{\n\t\t\tname:       \"empty\",\n\t\t\turlMap:     empty,\n\t\t\turl:        \"/xyz\",\n\t\t\twantNewURL: \"/xyz\",\n\t\t\twantFsPath: \"\",\n\t\t},\n\t\t{\n\t\t\tname:       \"nil\",\n\t\t\turlMap:     nilMap,\n\t\t\turl:        \"/xyz\",\n\t\t\twantNewURL: \"/xyz\",\n\t\t\twantFsPath: \"\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotNewURL, gotFsPath := tt.urlMap.GetFilesystemLocation(tt.url)\n\t\t\tif gotNewURL != tt.wantNewURL {\n\t\t\t\tt.Errorf(\"URLMap.GetFilesystemLocation() gotNewURL = %v, want %v\", gotNewURL, tt.wantNewURL)\n\t\t\t}\n\t\t\tif gotFsPath != tt.wantFsPath {\n\t\t\t\tt.Errorf(\"URLMap.GetFilesystemLocation() gotFsPath = %v, want %v\", gotFsPath, tt.wantFsPath)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/user_agent.go",
    "content": "package utils\n\nimport \"runtime\"\n\n// valid UA from https://user-agents.net\nconst Safari = \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15/iY0wnXbs-59\"\nconst FirefoxWindows = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0\"\nconst FirefoxLinux = \"Mozilla/5.0 (X11; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0\"\nconst FirefoxLinuxArm = \"Mozilla/5.0 (X11; Linux armv7l; rv:86.0) Gecko/20100101 Firefox/86.0\"\nconst FirefoxLinuxArm64 = \"Mozilla/5.0 (X11; Linux aarch64; rv:86.0) Gecko/20100101 Firefox/86.0\"\n\n// getUserAgent returns a valid User Agent string that matches the running os/arch\nfunc getUserAgent() string {\n\tarch := runtime.GOARCH\n\tos := runtime.GOOS\n\n\tswitch os {\n\tcase \"darwin\":\n\t\treturn Safari\n\tcase \"windows\":\n\t\treturn FirefoxWindows\n\tcase \"linux\":\n\t\tswitch arch {\n\t\tcase \"arm\":\n\t\t\treturn FirefoxLinuxArm\n\t\tcase \"arm64\":\n\t\t\treturn FirefoxLinuxArm64\n\t\tcase \"amd64\":\n\t\t\treturn FirefoxLinux\n\t\tdefault:\n\t\t\treturn FirefoxLinux\n\t\t}\n\tdefault:\n\t\treturn FirefoxLinux\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/vtt.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"math\"\n)\n\n// from stdlib's time.go\nfunc norm(hi, lo, base int) (nhi, nlo int) {\n\tif lo < 0 {\n\t\tn := (-lo-1)/base + 1\n\t\thi -= n\n\t\tlo += n * base\n\t}\n\tif lo >= base {\n\t\tn := lo / base\n\t\thi += n\n\t\tlo -= n * base\n\t}\n\treturn hi, lo\n}\n\n// GetVTTTime returns a timestamp appropriate for VTT files (hh:mm:ss.mmm)\nfunc GetVTTTime(fracSeconds float64) string {\n\tif fracSeconds < 0 || math.IsNaN(fracSeconds) || math.IsInf(fracSeconds, 0) {\n\t\treturn \"00:00:00.000\"\n\t}\n\n\tvar msec, sec, mnt, hour int\n\tmsec = int(fracSeconds * 1000)\n\tsec, msec = norm(sec, msec, 1000)\n\tmnt, sec = norm(mnt, sec, 60)\n\thour, mnt = norm(hour, mnt, 60)\n\n\treturn fmt.Sprintf(\"%02d:%02d:%02d.%03d\", hour, mnt, sec, msec)\n\n}\n"
  },
  {
    "path": "pkg/utils/vtt_test.go",
    "content": "package utils\n\nimport (\n\t\"math\"\n\t\"testing\"\n)\n\nfunc TestZeroTimestamp(t *testing.T) {\n\tif want, got := \"00:00:00.000\", GetVTTTime(0); want != got {\n\t\tt.Errorf(\"TestZeroTimestamp: GetVTTTime(0) = %v; want %v\", got, want)\n\t}\n}\n\nfunc TestValidTimestamp(t *testing.T) {\n\ts := 0.1\n\tif want, got := \"00:00:00.100\", GetVTTTime(s); want != got {\n\t\tt.Errorf(\"TestValidTimestamp: GetVTTTime(%v) = %v; want %v\", s, got, want)\n\t}\n\ts = ((24+1)*60+1)*60 + 1 + 0.1\n\tif want, got := \"25:01:01.100\", GetVTTTime(s); want != got {\n\t\tt.Errorf(\"TestValidTimestamp: GetVTTTime(%v) = %v; want %v\", s, got, want)\n\t}\n}\n\n// Negative timestamps are not defined by WebVTT.\nfunc TestNegativeTimestamp(t *testing.T) {\n\tif want, got := \"00:00:00.000\", GetVTTTime(-1); want != got {\n\t\tt.Errorf(\"TestNegativeTimestamp: GetVTTTime(-1) = %v; want %v\", got, want)\n\t}\n}\n\nfunc TestInvalidTimestamp(t *testing.T) {\n\tif want, got := \"00:00:00.000\", GetVTTTime(math.NaN()); want != got {\n\t\tt.Errorf(\"TestInvalidTimestamp: GetVTTTime(NaN) = %v; want %v\", got, want)\n\t}\n\tif want, got := \"00:00:00.000\", GetVTTTime(math.Inf(1)); want != got {\n\t\tt.Errorf(\"TestInvalidTimestamp: GetVTTTime(Inf) = %v; want %v\", got, want)\n\t}\n\tif want, got := \"00:00:00.000\", GetVTTTime(math.Inf(-1)); want != got {\n\t\tt.Errorf(\"TestInvalidTimestamp: GetVTTTime(-Inf) = %v; want %v\", got, want)\n\t}\n}\n"
  },
  {
    "path": "scripts/generateLoginLocales.go",
    "content": "//go:build ignore\n// +build ignore\n\npackage main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/utils\"\n)\n\nfunc main() {\n\tverbose := len(os.Args) > 1 && os.Args[1] == \"-v\"\n\n\tfmt.Printf(\"Generating login locales\\n\")\n\n\t// read all json files in the locales directory\n\t// and extract only the login part\n\n\t// assume running from ui directory\n\tdirFS := os.DirFS(filepath.Join(\"v2.5\", \"src\", \"locales\"))\n\n\t// ensure the login/locales directory exists\n\tif err := fsutil.EnsureDir(filepath.Join(\"login\", \"locales\")); err != nil {\n\t\tpanic(err)\n\t}\n\n\tfs.WalkDir(dirFS, \".\", func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\tif filepath.Ext(path) != \".json\" {\n\t\t\treturn nil\n\t\t}\n\n\t\t// extract the login part\n\t\t// from the json file\n\t\tsrc, err := dirFS.Open(path)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tdefer src.Close()\n\t\tdata, err := io.ReadAll(src)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tm := make(utils.NestedMap)\n\t\tif err := json.Unmarshal(data, &m); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tl, found := m.Get(\"login\")\n\t\tif !found {\n\t\t\t// nothing to do\n\t\t\treturn nil\n\t\t}\n\n\t\t// create new json file\n\t\t// with only the login part\n\t\tif verbose {\n\t\t\tfmt.Printf(\"Writing %s\\n\", d.Name())\n\t\t}\n\n\t\tf, err := os.Create(filepath.Join(\"login\", \"locales\", d.Name()))\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tdefer f.Close()\n\t\te := json.NewEncoder(f)\n\t\tif err := e.Encode(l); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "scripts/generate_icons.sh",
    "content": "#!/bin/bash\n# Update the Stash icon throughout the project from a master stash-logo.png\n\n# Imagemagick, and go packages icns and rsrc are required.\n# Copy a high-resolution stash-logo.png to this stash/scripts folder\n# and run this script from said folder, commit the result.\n\nif [ ! -f \"stash-logo.png\" ]; then\n    echo \"stash-logo.png not found.\"\n    exit\nfi\n\nif [ -z \"$GOPATH\" ]; then\n    echo \"GOPATH environment variable not set\"\n    exit\nfi\n\nif [ ! -e \"$GOPATH/bin/rsrc\" ]; then\n    echo \"Missing Dependency:\"\n    echo \"Please run the following /outside/ of the stash folder:\"\n    echo \"go install github.com/akavel/rsrc@latest\" \n    exit\nfi\n\nif [ ! -e \"$GOPATH/bin/icnsify\" ]; then\n    echo \"Missing Dependency:\"\n    echo \"Please run the following /outside/ of the stash folder:\"\n    echo \"go install github.com/jackmordaunt/icns/v2/cmd/icnsify@latest\" \n    exit\nfi\n\n# Favicon, used for web favicon, windows systray icon, windows executable icon\nconvert stash-logo.png -define icon:auto-resize=256,64,48,32,16 favicon.ico\ncp favicon.ico ../ui/v2.5/public/\n\n# Build .syso for Windows icon, consumed by linker while building stash-win.exe\n\"$GOPATH\"/bin/rsrc -ico favicon.ico -o icon_windows.syso\nmv icon_windows.syso ../pkg/desktop/\n\n# *nixes systray icon\nconvert stash-logo.png -resize x256 favicon.png\ncp favicon.png ../ui/v2.5/public/\n\n# MacOS, used for bundle icon\n# https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html\n\"$GOPATH\"/bin/icnsify -i stash-logo.png -o icon.icns\nmv icon.icns macos-bundle/Contents/Resources/icon.icns\n\n# cleanup\nrm favicon.png favicon.ico"
  },
  {
    "path": "scripts/getDate.go",
    "content": "// +build ignore\n\npackage main\n\nimport \"fmt\"\nimport \"time\"\n\nfunc main() {\n\tnow := time.Now().Format(\"2006-01-02 15:04:05\")\n\n\tfmt.Printf(\"%s\", now)\n}\n"
  },
  {
    "path": "scripts/macos-bundle/Contents/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleExecutable</key>\n\t<string>stash</string>\n\t<key>CFBundleIconFile</key>\n\t<string>icon.icns</string>\n\t<key>CFBundleTypeIconFile</key>\n\t<string>icon.icns</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>cc.stashapp.stash</string>\n\t<key>NSHighResolutionCapable</key>\n\t<string>True</string>\n\t<key>LSUIElement</key>\n\t<string>1</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "scripts/test_db_generator/README.md",
    "content": "This is a quick and dirty go script for generating a contrived database for testing purposes.\n\nEdit the `config.yml` file to your liking. The numbers indicate the number of objects to generate, the `naming` section indicates the files from which to generate names.\n\nMay cause unexpected behaviour if run against an existing database file.\n\nTo run - from the `test_db_generator`:\n`go run .`\n\nThe database file will be generated in the current directory."
  },
  {
    "path": "scripts/test_db_generator/config.yml",
    "content": "database: generated.sqlite\nscenes: 30000\nimages: 4000000\ngalleries: 1500\nchapters: 3000\nmarkers: 3000\nperformers: 10000\nstudios: 1500\ntags: 1500\nnaming:\n  scenes: scene.txt\n  performers:\n    male: male.txt\n    female: female.txt\n    surname: surname.txt\n  galleries: scene.txt\n  studios: studio.txt\n  tags: scene.txt\n  images: scene.txt\n"
  },
  {
    "path": "scripts/test_db_generator/female.txt",
    "content": "A.J.\nAaiyah\nAali\naaliyah\nAalyiah\nAaralyn\nAarielle\nAarin\nAarolyn\nAayla\nAbagail\nAbagelle\nAbany\nAbbey\nAbbi\nAbbie\nAbbraxa\nAbby\nAbegail\nAbelia\nAbelinda\nAbella\nAbhilasha\nAbi\nAbia\nAbigail\nAbigaile\nAbril\nAbrill\nAbsinthe\nAcropolis\nAda\nAdahlia\nAdajja\nAdalisa\nAdalyn\nAdanna\nAdara\nAddam\nAddee\nAddi\nAddie\nAddison\nAddy\nAddyson\nAdel\nAdela\nAdelaida\nAdele\nAdelia\nAdelina\nAdeline\nAdelisa\nAdell\nAdella\nAdelle\nAdelyn\nAden\nAdessa\nAdhasa\nAdia\nAdiamo\nAdicktion\nAdin\nAdina\nAdira\nAdison\nAdl\nAdley\nAdney\nAdora\nAdoration\nAdreanna\nAdreena\nAdrenalyn\nAdrenalynn\nAdri\nAdria\nAdrian\nAdriana\nAdrianna\nAdrianne\nAdrien\nAdrienn\nAdrienne\nAdrienz\nAdriyana\nAdry\nAdryanna\nAdvril\nAdy\nAdyen\nAelita\nAeon\nAerial\nAeris\nAfelia\nAfina\nAfox\nAfra\nAfrica\nAfrodita\nAfrodite\nAfrodithe\nAfroditte\nAfrodity\nAften\nAfton\nAgata\nAgatha\nAgathe\nAgatta\nAgeha\nAggie\nAghata\nAghatha\nAgita\nAglaya\nAglona\nAgnejka\nAgnes\nAgnesa\nAgnese\nAgness\nAgnessa\nAgneta\nAgnetta\nAgnise\nAgnyese\nAgota\nAhayla\nAhisa\nAhn\nAhna\nAhryan\nAhud\nAicha\nAico\nAida\nAidan\nAiden\nAidra\nAika\nAiko\nAila\nAileen\nAilek\nAimee\nAimeelynn\nAimer\nAina\nAinara\nAinsley\nAiny\nAirita\nAirlady\nAirodite\nAisha\nAisis\nAislin\nAitor\nAiwe\nAiya\nAiyana\nAiza\nAj\nAja\nAjenda\nAjpa\nAk\nAkari\nAkarra\nAkasha\nAkemi\nAkemy\nAKGingersnaps\nAki\nAkilina\nAkira\nAkita\nAkkara\nAkropolis\nAktavia\nAkyra\nAlaiah\nAlaina\nAlamea\nAlana\nAlanah\nAlanaleigh\nAlani\nAlania\nAlanis\nAlanna\nAlannah\nAlannis\nAlanova\nAlasa\nAlaska\nAlaura\nAlay\nAlaya\nAlayah\nAlayla\nAlayna\nAlba\nAlbert\nAlberta\nAlbertina\nAlbina\nAlby\nAldana\nAldena\nAldi\nAle\nAlea\nAlecextra\nAlecia\nAlecto\nAleena\nAleera\nAleesha\nAleftina\nAlegra\nAleigh\nAleijsha\nAlejandra\nAleks\nAleksa\nAleksaise\nAleksandra\nAlektra\nAlena\nAlena*\nAlenia\nAlesha\nAlesia\nAleska\nAlessa\nAlessandra\nAlessandria\nAlessia\nAlesya\nAletta\nAlex\nalexa\nAlexandera\nAlexandra\nAlexandrea\nAlexandria\nAlexcia\nAlexes\nAlexextra\nAlexi\nAlexia\nAlexiasky\nalexis\nAlexiss\nAlexiz\nAlexsis\nAlexus\nAlexx\nAlexxa\nAlexxx\nAlexxxis\nAlexy\nAlexya\nAli\nAlia\nAliah\nAliana\nAlianna\nAlica\nAlice\nAlice*\nAlice,\nAlice85JJ\nAliceafterDark\nAlicia\nAlicia*\nAlicija\nAlicyn\nAlida\nAlie\nAliela\nAlien\nAliesha\nAliha\nAlin\nAlina\nAlinda\nAline\nAliny\nAliona\nAlis\nAlisa\nAlisandra\nAlise\nAlisha\nAlishia\nAlisia\nAlison\nAlissa\nAlissia\nAlisson\nAlissya\nAlita\nAlitzia\nAlive\nAlix\nAlixus\nAliya\nAliyah\nAliysa\nAliz\nAliza\nAlize\nAljena\nAlla\nAllan\nAllana\nAllanah\nAllatra\nAllaura\nAllayah\nAllayana\nAllee\nAllegra\nAllen\nAllesandra\nAllex\nAllexis\nAlli\nAllie\nAlliehaze\nAllisan\nAllison\nAllister\nAlliyah\nAlloa\nAllona\nAllora\nAllsa\nAllura\nAllure\nAlly\nAllyana\nAllyann\nAllysa\nAllysah\nAllysia\nAllysin\nAllyson\nAllyssa\nAlma\nAlmond\nAlocica\nAloha\nAlona\nAlondra\nAlonna\nAlonya\nAlora\nAloura\nAlsana\nAlsu\nAlta\nAltea\nAlura\nAlux\nAlvero\nAly\nAlya\nAlyce\nAlycia\nAlyia\nAlyiah\nAlykat\nAlyn\nAlyona\nAlysa\nAlysee\nAlysha\nAlysia\nAlyson\nAlyssa\nAlyssia\nAlysson\nAlyx\nAlyz'e\nAm\nAma\nAmabella\nAmabielle\nAmadea\nAmahi\nAmai\nAmalia\nAmalie\nAmanda\nAmandae\nAmandah\nAmandine\nAmara\nAmaranta\nAmaretta\nAmari\nAmaris\namarna\nAmateur\nAmathyst\nAmatuer\nAmaya\nAmazon\nAmba\nAmbar\nAmber\nAmbergrand\nAmberi\nAmberlee\nAmberr\nAmbery\nAmbika\nAmbra\nAmbrosia\nAmbyr\nAmee\nAmeena\nAmel\nAmeli\nAmelia\nAmelie\nAmelinda\nAmeliya\nAmely\nAmerica\nAmerie\nAmethyst\nAmetista\nAmi\nAmia\nAmica\nAmie\nAmiee\nAmile\nAmilia\nAmilian\nAmina\nAmira\nAmirah\nAmmey\nAmmon\nAmmy\nAmo\nAmor\nAmora\nAmwlie\nAmy\nAmy,\nAmybrooke\nAmyiaa\nAmyna\nAmyra\nAmysativa\nAmyza\nana\nAnaa\nAnabel\nAnabela\nAnabell\nAnabella\nAnabelle\nAnabeth\nAnael\nAnaidha\nAnaile\nAnainda\nAnais\nAnalee\nAnalia\nAnaline\nAnalise\nAnalyssa\nAnamarie\nAnanova\nAnanta\nAnas\nAnastacia\nAnastasha\nAnastasia\nAnastasija\nAnastasiya\nAnastassia\nAnastasya\nAnastaysa\nAnastaza\nAnastazia\nAnastazie\nAnata\nAnavelle\nAnaya\nAncsi\nAnda\nAndchana\nAndee\nAnder\nAndi\nAndia\nAndie\nAndre\nAndrea\nAndreea\nAndreena\nAndreina\nAndri\nAndria\nAndrianna\nAndriena\nAndy\nAndys\nAnea\nAnechka\nAneli\nAnelie\nAnell\nAnella\nAnelly\nAnelys\nAnemona\nAnesha\nAnet\nAneta\nAnete\nAnett\nAnetta\nAnette\nAnezka\nAnfisa\nAnge\nAngee\nAngeka\nAngel\nAngela\nAngela**\nAngela****\nAngele\nAngeleena\nAngelena\nAngeles\nAngeli\nAngelia\nAngelic\nAngelica\nAngelik\nAngelika\nAngelin\nAngelina\nAngelinaash\nAngeline\nAngelique\nAngeliXXX\nAngell\nAngella\nAngelli\nAngellina\nAngelline\nAngellyna\nAngellyne\nAngelyca\nAngelyna\nAngelynne\nAnges\nAngie\nAngiela\nAngienoir\nAngry\nAngy\nAngyelkana\nAni\nAnia\nAnie\nAnife\nAnija\nAnik\nAnika\nAnike\nAnikka\nAniko\nAnila\nAnina\nAninha\nAnisa\nAnise\nAnisha\nAnisiya\nAnissa\nAnita\nAnitra\nAnitta\naniya\nAnjanette\nAnjela\nAnjelica\nAnjelina\nAnjelyze\nAnjie\nAnjii\nAnn\nAnna\nAnna**\nAnna,\nAnna_Rose\nAnnabel\nAnnabella\nAnnabelle\nAnnabellle\nAnnalee\nAnnalisa\nAnna-Marie\nAnnara\nAnnastevens\nAnne\nAnne-angel\nAnneje\nAnneke\nAnneli\nAnneliese\nAnnellise\nAnnemari\nAnnemarie\nAnne-Marie\nAnnet\nAnneta\nAnnett\nAnnette\nAnni\nAnnie\nAnnik\nAnnika\nAnnika,\nAnnina\nAnnis\nAnnita\nAnnlore\nAnn-lyz\nAnnmarie\nAnn-marie\nAnnMarie\nAnn-Marie\nAnny\nAnoli\nAnorei\nAnoushka\nAnouska\nAnri\nAnsie\nAnsley\nAntea\nAnthia\nAntiana\nAntica\nAntinia\nAntoanett\nAntoinette\nAnton\nAntonella\nAntonia\nAntoniette\nAntonina\nAntonya\nAntonyia\nAntuanetta\nAntynia\nAnunka\nAnushka\nAnuta\nAny\nAnya\nAnyjah\nAnyl\nAnyssa\nAnyutka\nAnzelika\nAoki\nAon\nAphrodesia\nAphrodite\nApolonia\nApple\napril\nAprilia\nApriloneil\nApryl\nAqua\nAra\nArabella\nArabelle\nArachnia\nAracoeli\nAragne\nAralyn\nAranka\nAranxa\nAraya\nArcadia\nArchida\nArdelia\nAreana\nAreena\nArejay\nArelis\nAreta\nArgentina\nAria\nAriadna\nAriah\nArial\nArian\nAriana\nAriandra\nAriane\nArianna\nArianny\nAriany\nArie\nAriel\nAriel(AKA\nAriela\nariella\nArielle\nArienn\nAries\nArietta\nArijna\nArika\nArin\nArina\nAris\nArissa\nAristeia\nArkida\nArlene\nArlenne\nArlet\nArlisa\nArmani\nArmany\nAroana\nArpita\nArria\nArriana\nArrow\nArroyo\nArryn\nArs\nArtemida\nArtemis\nArtemisia\nArteya\nAruba\nAruna\nArvil\nArwen\nArya\nAryah\nAryan\nAryana\nAryell\nAsa\nAsdis\nAsh\nAsha\nAshanti\nAshari\nAshawni\nAshawrya\nAshden\nAshdon\nAshe\nAsheerah\nAshely\nAshiana\nAshle\nAshleah\nAshlee\nAshleigh\nAshlen\nAshley\nAshleyjane\nAshli\nAshlie\nAshly\nAshlyn\nAshlynn\nAshlynrae\nAshson\nAshton\nAshura\nAsia\nAsian\nAsiana\nAsiniya\nAsley\nAsmaret\nAsmareth\nAsol\nAsole\nAsolia\nAspen\nAsphyxia\nAspid\nAssh\nAssoli\nAsta\nAstra\nAstrid\nAstrud\nAsuna\nAsya\nAtena\nAtenas\nAthena\nAthina\nAtina\nAtlanta\nAtlantis\nAubree\nAubrey\nAubrie\nAubrielle\nAubry\nAuburn\nAuddi\nAudee\nAudie\nAudra\nAudrey\nAudri\nAudriana\nAudrianna\nAudrie\nAudrina\nAudrinna\nAudry\naugust\nAugustina\nAundrea\nAurelia\nAurelie\nAurelly\nAuriana\nAurielee\nAurita\nAurmi\nAurora\nAurore\nAuroura\nAussie\nAustin\nAustine\nAustral\nAustyn\nAutilia\nAutum\nAutumm\nAutumn\nAuxanna\nava\nAvalon\nAvanti\nAveena\nAvena\nAveri\nAverie\nAvery\nAvi\nAvia\nAviana\nAvidat\nAvina\nAvinna\nAviva\nAvona\nAvonna\nAvory\nAvri\nAvril\nAvrill\nAvrora\nAvrova\nAvy\nAvya\nAvylee\nAvylynn\nAwanda\nAxelle\nAxen\nAxis\nAya\nAyana\nAyane\nAyanna\nAyano\nAyda\nAyden\nAydie\nAyla\nAylin\nAyline\nAymee\nAyn\nAysha\nAysla\nAyumi\nAyumu\nAyza\nAza\nAzaelia\nAzalea\nAzaria\nAzella\nAziza\nAzlea\nAzrael\nAzul\nAzura\nAzure\nAzyza\nAzzi\nBabaloun\nBabalu\nBabba\nBabe\nBabette\nBabsy\nBabuci\nBabuska\nBaby\nBabydoll\nBabylou\nBabymoni\nBabyshar\nBac\nBacardi\nBad\nBadiya\nBADKITTYYY\nBadLady\nBadlittlegrrl\nBado\nBaffy\nBagheera\nBagira\nBagyraa\nBaiba\nBailee\nBailey\nBailie\nBala\nBalina\nBam\nBamba\nBambi\nBambie\nBambina\nBamboo\nBamby\nBanesca\nBara\nBarb\nBarba\nBarbamiska\nBarbara\nBarbarah\nBarbarela\nBarbarella\nBarbary\nBarber\nBarbi\nBarbie\nBarbora\nBarbra\nBarby\nBarett\nBarra\nBarracuda\nBarran\nBarrett\nBartscha\nBarunka\nBashti\nBay\nBaylee\nBaylie\nBayliss\nBb\nBea\nBeata\nBeatha\nBeatrice\nBeatris\nBeatrix\nBeau\nBeaue\nBeauty\nBebe\nBebel\nBeca\nBecca\nBecki\nBeckie\nBecky\nBedeli\nBee\nBees\nBeeue\nBehind\nBeiley\nBeili\nBeka\nBekah\nBela\nBelacortes\nBelicia\nBelina\nBelinda\nBelinha\nBelka\nBell\nBella\nBella,\nBelladonna\nBellah\nBella-Marie\nBella-Nikole\nBellavitana\nBelle\nBellena\nBelleniko\nBellina\nBellize\nBelonika\nBena\nBendy\nBenextra\nBenji\nBenta\nBente\nBentley\nBenzey\nBerenice\nBeretta\nBerinice\nBerlin\nBerlina\nBerlyn\nBernadett\nBernadetta\nBernadette\nBernice\nBernie\nBeronica\nBerta\nBertha\nBerthe\nBess\nBessi\nBessie\nBessy\nBeta\nBetania\nBetcee\nBeth\nBethany\nBethina\nBeti\nBeto\nBetsey\nBetsy\nBetta\nBettey\nBetti\nBettie\nBettina\nBetty\nBety\nBeverly\nBexa\nBexxxy\nBhiankha\nBia\nBianca\nBiancka\nBianka\nBiatriss,\nBibette\nBibi\nBicky\nBig\nBigwil\nBijou\nBilli\nBillie\nBilly\nBinky\nBionca\nBirdy\nBisexual\nBJ\nBjorg\nBlack\nBlackberry\nBlacke\nBlaiden\nBlair\nBlaire\nBlake\nBlakely\nBlakey\nBlanca\nBlanche\nBlandine\nBlane\nBlanka\nBlaten\nBlaze\nBlazer\nBliss\nBlond\nBlonde\nBlondie\nBlondy\nBloom\nBlossom\nBlu\nBlue\nBlueberry\nBobbi\nBobbie\nBobby\nBodana\nBodylicious\nBogdana\nBoglarka\nBojinka\nBolivia\nBolly\nBombshell\nBoni\nBonie\nBonita\nBonne\nBonni\nBonnie\nBonny\nBony\nBoo\nBoom\nBoomerang\nBorbella\nBoroka\nBorya\nBoxxy\nBrady\nBranda\nBrandalyn\nBrandee\nBrandi\nBrandie\nBrandii\nBrandon\nBrandy\nBrandyextra\nBrazil\nBrazilla\nBrazzers\nBre\nBrea\nBreana\nBreanna\nBreanne\nBreat\nBreathe\nBree\nBreeanna\nBreena\nBrekell\nBrenda\nBrendy\nBrenna\nbrett\nBretta\nBreyelle\nBreyta\nBri\nBria\nbriana\nBrianna\nBriar\nBrice\nBridges\nBridget\nBridgete\nBridgett\nBridgette\nBrie\nBriella\nBrielle\nBriget\nBrigett\nBrigi\nBrigit\nBrigita\nBrigite\nBrigitt\nBrigitta\nBrigitte\nBrik\nBrill\nBrin\nBrind\nBrit\nBritanny\nBritany\nBrithany\nBritne\nBritney\nBritny\nBritt\nBritta\nBrittaney\nBrittaneyextra\nBrittani\nBrittania\nBrittanie\nBrittany\nBrittney\nBrittneyextra\nBrittny\nBrixley\nBriz\nBriza\nBrizit\nBrodi\nBronte\nBronze\nBrook\nBrooke\nBrookie\nBrooklyn\nBrooklyne\nBrooklynn\nBrookyln\nBrown\nBruan\nBrucie\nBrun\nBruna\nBrunna\nBryana\nBryanna\nBryce\nBryci\nBrylee\nBryn\nBrynn\nBubbles\nBubi\nBuddah\nBuffy\nBugatti\nBulgari\nBulija\nBulma\nBulsinesa\nBumikia\nBunni\nBunnie\nBunny\nBusty\nButter\nButterfly\nBuxom\nByanca\nBysya\nC\nC.G.\nC.J.\nCabana\nCabiria\nCaddy\nCadence\nCadey\nCaesaria\nCaila\nCailey\nCaise\nCait\nCaitin\nCaitlin\nCala\nCalenita\ncali\nCalibri\nCalico\nCalina\nCalisi\nCalista\nCalisyn\nCalli\nCallie\nCalliedee\nCalliste\nCalypsa\nCalypso\nCam\nCambrey\nCamelia\nCamelita\nCameo\nCameran\nCameron\nCameronxoxo\nCameryn\nCami\nCamie\nCamil\nCamila\nCamile\nCamilla\nCamille\nCammie\nCammille\nCamrie\nCamryn\nCanara\nCandace\nCandalyn\nCandance\nCande\nCandee\nCandel\nCandela\nCandi\nCandice\nCandide\nCandie\nCandis\nCandise\nCandy\nCandybelle\nCanela\nCanella\nCanyon\nCaomei\nCapri\nCaprice\nCapris\nCaprise\nCapry\nCara\nCaralyn\nCaramel\nCaramellito\nCareena\nCaren\nCaress\nCaressa\nCaresse\nCarey\nCari\nCarie\nCarin\nCarina\nCarine\nCarissa\nCariza\nCarla\nCarla**\nCarla4Garda\nCarleigh\nCarley\nCarli\nCarlie\nCarlin\nCarly\nCarlyn\nCarlynn\nCarman\nCarmel\nCarmela\nCarmeline\nCarmelita\nCarmella\nCarmen\nCarmene\nCarmilla\nCarmin\nCarmina\nCarol\nCarola\nCarole\nCarolin\nCarolina\nCaroline\nCarolizi\nCaroll\nCarolline\nCarolyn\nCarolyne\nCaron\nCarre\nCarrie\nCarrina\nCarrine\nCarrmen\nCarrol\nCarrole\nCarroll\nCarry\nCarson\nCarter\nCasada\nCasana\nCasandra\nCasca\nCasey\nCashamere\nCashmere\nCasi\nCasia\nCasie\nCasper\nCassady\nCassandra\nCassara\nCassey\nCassi\nCassia\nCassidey\nCassidy\nCassie\nCassy\nCassye\nCat\nCatalia\nCatalin\nCatalina\nCatalya\nCatania\nCatarina\nCate\nCaterine\nCath\nCathaleen\nCatharina\nCatherina\nCatherine\nCathiaextra\nCathleen\nCathrin\nCathrine\nCathy\nCathy_B\nCati\nCatia\nCatie\nCatina\nCatlin\nCatlyn\nCatrina\nCatryn\nCats\nCatt\nCatti\nCatty\nCatwoman\nCaty\nCayden\nCayenne\nCayla\nCaylee\nCaylian\nCayton\nCb\nCC\nCe\nCece\nCecelia\nCecil\nCecilia\nCecille\nCecily\nCedella\nCee\nCeira\nCelena\nCelest\nCeleste\nCelestia\nCelestine\nCelezte\nCelia\nCelina\nCelinange\nCeline\nCeliny\nCeran\nCerecita\nCerise\nCessa\nChadd\nChade\nChaise\nChalice\nChampagne\nChance\nChandler\nChandra\nchanel\nChanell\nChanelle\nChannel\nChannone\nChanonne\nChanta\nChantal\nChanta-Rose\nChantay\nChante\nChantel\nChantell\nChantelle\nChanty\nChapel\nChar\nCharina\nCharisma\nCharisse\nCharity\nCharlee\nCharlei\nCharlen\nCharlena\nCharlene\nCharley\nCharli\nCharlie\nCharlielynn\nCharlise\nCharlize\nCharlly\nCharlot\nCharlott\nCharlotta\nCharlotte\nCharly\nCharlys\nCharlyse\nCharmaine\nCharmane\nCharmed\nCharmel\nCharo\nCharol\nCharolette\nCharry\nCharumati\nChas\nChase\nChaseextra\nChasey\nChasi\nChasidy\nChasity\nChastity\nchavon\nChayanna\nChayanne\nChayd\nChayen\nChayse\nChazz\nChechi\nChecky\nCheer\nCheetah\nChela\nChelci\nChelsea\nChelsey\nChelsie\nChelsy\nChenin\nChennin\nCher\nCherelle\nCheri\nCherie\nCherise\nCherish\nCherokee\nCherri\nCherrie\nCherries\nCherry\nChery\nCheryl\nChesire\nChesley\nChessie\nChevon\nChevy\nCheyanne\nCheyenne\nChi\nChiara\nChiarra\nChica\nChicago\nChichi\nChicky\nChiki\nChikie\nChikita\nChilli\nChimille\nChina\nChinara\nChinita\nChin-nai\nChintia\nChintya\nChips\nChipy\nChiqui\nChiquita\nChloe\nChloé\nChloee\nChloejames\nChloeLynn\nChloey\nChlooe\nChocky\nChocolate\nChole\nCholey\nChontelle\nChris\nChriss\nChrissie\nChrissy\nChrista\nChristal\nChristel\nChristelle\nChristen\nChristgen\nChristi\nChristia\nChristian\nChristiana\nChristianne\nChristiano\nchristie\nChristien\nChristin\nChristina\nChristine\nChristixana\nChristoff\nChristoph\nChristy\nChrys\nChrysantem\nChrystal\nChrystina\nChrystine\nChudamani\nChudina\nChula\nChumundra\nChyanne\nChyna\nChynaWhite\nCia\nCiara\nCiarra\nCibely\nCica\nCicciolina\nCici\nCiera\nCierra\nCigogniatella\nCikita\nCila\nCilla\nCindee\nCinderella\nCindi\nCindy\nCindyrella\nCinna\nCinnamon\nCinta\nCinthia\nCinthya\nCinti\nCintia\nCintija\nCintya\nCipriana\nCira\nCirce\nCirenia\nCiri\nCitah\nCj\nClair\nClaire\nClanddi\nClar\nClara\nClare\nClarice\nClarise\nClarissa\nClarisse\nClarixa\nClark\nClary\nClassy\nClaudi\nClaudia\nClaudia**\nClaudie\nClaudine\nClayra\nClea\nCleare\nClementine\nCleo\nCleopatra\nCler\nClio\nClockwork\nCloe\nCloee\nCloey\nClouey\nClover\nCoby\nCocco\nCoco\nCocoa\nCoddie\nCodi\nCodie\nCody\nCoffee\nCoffey\nColby\nCole\nColette\nColleen\nCollette\nCollie\nConchita\nConie\nConnie\nConny\nConstance\nConstantine\nConsuela\nConsuelo\nContessa\nCony\nCookie\nCoolmona\nCora\nCoral\nCoralee\nCoralie\nCoralina\nCoralyn\nCorazon\nCorbin\nCordelia\nCoreena\nCori\nCorie\nCorin\nCorina\nCorinna\nCorinne\nCornelia\nCorrine\nCorry\nCortknee\nCory\nCosette\nCosima\nCosmia\nCostanza\nCountry\nCourtney\nCourtnie\nCowgirl\nCoxy\nCrave\nCream\nCrecy\nCreme\nCricket\nCrimson\nCris\nCrismary\nCriss\nCrissey\nCrissie\nCrissy\nCrissysnow\nCrista\nCristal\nCristaliana\nCristhina\nCristi\nCristian\nCristiane\nCristien\nCristin\nCristina\nCristine\nCristy\nCrosby\nCrystal\nCrystalynn\nCrystina\nCrystl\nCsila\nCsilla\nCsuka\nCumshot\nCurious\nCurly\nCustomer\nCute\nCutie\nCyara\nCybelle\nCydel\nCydella\nCyhanne\nCyle\nCynara\nCyndi\nCyndy\nCynthia\nCynthya\nCyntia\nCypher\nCyprus\nCyrstal\nCyrus\nCytherea\nD.C.\nDacada\nDachuki\nDacota\nDaeja\nDaffney\nDafna\nDafne\nDag\nDaggy\nDagmar\nDagmara\nDahlia\nDai\nDaiana\nDaicy\nDaiga\nDaikiri\nDailany\nDaina\nDaineris\nDaiquiri\nDaisy\nDaizy\nDajen\nDakini\nDakoda\nDakota\nDaksani\nDalan\nDalatika\nDalene\nDalia\nDaliah\nDalila\nDalilah\nDalilla\nDaliy\nDallas\nDalle\nDalny\nDamanie\nDamaris\nDame\nDamien\nDamiyenextra\nDamyanti\nDana\nDanae\nDanalea\nDanaya\nDandara\nDaneila\nDangerdoll\nDani\nDania\nDanica\nDaniela\nDaniele\nDaniella\nDanielle\nDanielo\nDaniely\nDanika\nDanira\nDanissa\nDanlee\nDanley\nDanlia\nDanna\nDanni\nDanny\nDany\nDanyel\nDaphne\nDaphnee\nDaphnie\nDaquiri\nDara\nDarby\nDarce\nDarci\nDarcia\nDarcie\nDarcy\nDarenzia\nDaria\nDarian\nDariana\nDariel\nDarien\nDarin\nDarina\nDariya\nDarla\nDarlene\nDarling\nDarma\nDarryl\nDarshani\nDarya\nDaryl\nDaryn\nDaryna\nDasani\nDascha\nDasha\nDashia\nDasi\nDasia\nDasie\nDasy\nDatse\nDava\nDavani\nDavia\nDavie\nDavina\nDavon\nDavy\nDawkins\nDawn\nDawna\nDawnee\nDawson\nDaya\nDayana\nDayanne\nDaylene\nDaylynn\nDayna\nDayse\nDaysie\nDayton\nDaytona\nDayvid\nDay-Z-Jay\nDayzjha\nDazz\nDD\nDea\nDeadra\nDeaf\nDeana\nDeanna\nDeauxma\nDebbie\nDebby\nDebella\nDe'Bella\nDebi\nDebie\nDebora\nDeborah\nDebra\nDeby\nDecksana\nDede\nDee\nDeedee\nDeejay\nDeena\nDeeni\nDeepika\nDefrancesca\nDeidra\nDeina\nDeirdre\nDeitra\ndeja\nDejia\nDel\nDelaney\nDelfin\nDelfynn\nDelia\nDeliah\nDelicious\nDelightful-Debbie\nDelila\nDelilah\nDelirious\nDelizi\nDella\nDelores\nDelotta\nDelphina\nDelphine\nDelta\nDelyla\nDelzangel\nDemetris\nDemi\nDemia\nDemida\nDemmi\nDemmy\nDemona\nDemonia\nDemonika\nDemy\nDena\nDenali\nDeneice\nDeni\nDenice\nDeniese\nDenis\nDenisa\nDenise\nDeniseextra\nDeniska\nDenissa\nDenisse\nDenni\nDennise\nDeny\nDenys\nDenyse\nDerek\nDerik\nDerya\nDesani\nDeserae\nDesert\nDesi\nDesika\nDesirae\nDesire\nDesiree\nDesray\nDessa\nDessarrey\nDessert\nDesteny\nDestinee\nDestinty\nDestiny\nDestinyextra\nDetox\nDetroit\nDetty\nDev\nDevaki\nDevaun\nDeven\nDevi\nDevildog\nDevin\nDevina\nDevinn\nDevlyn\nDevon\nDevonte\nDevora\nDevyn\nDewey\nDexlynn\nDeyny\nDeysy\nDez\nDezeray\nDezire\nDgil\nDgill\nDharma\nDi\nDia\nDiamond\nDiana\nDiana,\nDiane\nDianna\nDianne\nDicksani\nDiDevi\nDidi\nDido\nDie\nDiem\nDijana\nDike\nDila\nDilaila\nDilion\nDillan\nDillion\nDillon\nDima\nDimitra\nDimond\nDimonty\nDina\nDinah\nDinara\nDinna\nDionisia\nDionne\nDior\nDiora\nDiore\nDiorr\nDiosa\nDipti\nDirty\nDistania\nDita\nDiti\nDitta\nDitty\nDiva\nDiverse\nDivina\nDivine\nDivinity\nDixie\nDixon\nDiya\nDiyana\nDizel\nDjein\nDjiana\nDjulianaa\nDksana\nDlava\nDnay\nDobrila\nDoDa\nDolce\nDoll\nDollce\nDollie\nDolly\nDolores\nDolorian\nDomenica\nDomenika\nDomina\nDominic\nDominica\nDominick\nDominicka\nDominika\nDominikka\nDominique\nDominka\nDominno\nDomino\nDomonique\nDona\nDonatela\nDonatella\nDonita\nDonna\nDonya\nDora\nDoreen\nDorety,\nDori\nDoria\nDorian\nDorida\nDorina\nDorine\nDoris\nDorka\nDoro\nDorota\nDorothe\nDorothea\nDorothee\nDorothy\nDoroty\nDorro\nDors\nDorthoy\nDory\nDot\nDragaya\nDragonlily\nDraven\nDray\nDrea\nDresden\nDrew\nDrika\nDrimla\nDru\nDrunna\nDruuna\nDuda\nDulce\nDulcia\nDulcinea\nDuli\nDull23\nDulsineya\nDuna\nDunia\nDunn\nDunya\nDuran\nDurga\nDushenka\nDusk\nDusya\nDyana\nDyanna\nDyanne\nDylan\nDylann\nDyllan\nDynamite\nDynasty\nEadie\nEana\nEasah\nEasy\nEbba\nEbbi\nEbbonie\nEbonita\nEbony\nEcho\nEcstasy\nEddi\nEddison\nEden\nEdenadams\nEdible\nEdie\nEdina\nEdit\nEdita\nEdith\nEdna\nEdo\nEduarda\nEdwarda\nEdwige\nEdy\nEdyn\nEdyphia\nEffie\nEffy\nEgypt\nEidyia\nEileen\nEilin\nEilona\nEimi\nEinve\nEkaterina\nEkenedilichukwu\nEl\nela\nElaina\nElaine\nElana\nElaura\nElayna\nEleanor\nElectra\nElectre\nElektra\nElen\nElena\nElenora\nEleonora\nElexis\nElfie\nElga\nElha\nEli\nEliana\nElianie\nElicia\nElida\nElin\nElina\nElindi\nElinor\nElis\nElisa\nElisabet\nElisabeth\nElisabeth,\nElisaveta\nElise\nElisha\nElishka\nElisia\nEliska\nElison\nEliss\nElissa\nElisse\nElita\nEliz\nEliza\nElizabet\nElizabeth\nElizabethanne\nElizaveta\nElizibeth\nElke\nElla\nElle\nEllen\nEllena\nElley\nElli\nEllie\nEllin\nEllina\nEllington\nEllis\nEllison\nElly\nEllyella\nElma\nElnara\nEloa\nElody\nElona\nElouisa\nElsa\nElse\nElvira\nEly\nElycia\nElysa\nElysee\nElyssa\nElza\nEma\nEmanuel\nEmanuela\nEmanuele\nEmanuell\nEmanuella\nEmanuelle\nEmanuely\nEmber\nEmbry\nEmeche\nEmelia\nEmelie\nEmerald\nEmerode\nEmery\nEmese\nEmi\nEmilee\nEmili\nEmilia\nEmiliana\nEmilianna\nEmilie\nEmillia\nEmilly\nEmily\nEminot\nEmjay\nEmma\nEmma_Brown\nEmmanuelle\nEmmeline\nEmmi\nEmmy\nEmori\nEmpera\nEmuna\nEmy\nEmylia\nEna\nEndless\nEndlessa\nEndza\nEngel\nEngi\nEni\nEnigma\nEniko\nEnnessi\nEnnie\nEnny\nEnny*\nEnolla\nEnricque\nEnrika\nEntice\nEnvi\nEnvy\nEnza\nEodit\nEos\nEpiphany\nEpisode\nEra\nErato\nErian\nEric\nErica\nErick\nEricka\nErika\nErikah\nErike\nEriko\nErin\nErina\nEris\nErnesta\nErnestine\nErotica\nErrin\nErykuh\nErzsebet\nEsegna\nEselda\nEsenia\nEsis\nEsme\nEsmeralda\nEsmerelda\nEsmi\nEsperanse\nEsperanza\nEsperenza\nEssence\nEssy\nEstee\nEstefana\nEstefany\nEstela\nEstella\nEstelle\nEster\nEsther\nEstrelah\nEstrella\nEstreya\nEszmeralda\nEszter\nEtalia\nEthel\nEtheleen\nEthelle\nEtna\nEtta\nEufrat\nEugenia\nEugenya\nEujenya\nEunice\nEunique\nEuphoria\nEva\nEvah\nEvalynn\nEvan\nEvangelina\nEvangeline\nEvanni\nEvdokia\nEve\nEvelin\nEvelina\nEveline\nEvelyn\nEvelyne\nEvelynn\nEven\nEverlin\nEverly\nEvette\nEvey\nEvgenia\nEvgeniya\nEvi\nEvia\nEvie\nEvika\nEvila\nEvilyn\nEvita\nEvolet\nEvonna\nEwa\nEwe\nExtreme\nEyla\nEzster\nF.\nFabian\nFabiana\nFabiane\nFabiola\nFae\nFah\nFaina\nFairy\nFaith\nFake\nFalana\nFalicha\nFallon\nFamous\nFan\nFania\nFannie\nFanny\nFantasy\nFantina\nFarah\nFarrah\nFashion\nFast\nFathima\nFatima\nFatime\nFatzilla\nFawn\nFawna\nFawnna\nFawny\nFaye\nFayex\nFayina\nFayth\nFe\nFeathers\nFebby\nFebe\nFederica\nFederico\nFedorova\nFedra\nFeeona\nFefy\nFelecia\nFelicia\nFelicity\nFelina\nFelisha\nFelisia\nFelix\nFelony\nFendi\nFenna\nFenny\nFeodora\nFerdanda\nFerggy\nFernanda\nFernandaSW\nFernandinha\nFernannda\nFernenda\nFeroky\nFerrara\nFerrera\nFerriana\nFery\nFetish\nFey\nFiera\nFiero\nFifi\nFilippa\nFinch\nFinesse\nFiona\nFione\nFira\nFire\nFirnanda\nFirstclassxxx\nFit\nFitness\nFitXXX\nFiva\nFlaca\nFlame\nFlarice\nFlavia\nFlavinha\nFlelucia\nFleur\nFlick\nFlicka\nFlor\nFlora\nFlorane\nFloranse\nFlorence\nFlorencia\nFlorina\nFlorinda\nFlower\nFloya\nFluah\nFonda\nFovea\nFox\nFoxi\nFoxie\nFoxies\nFoxii\nFoxxi\nFoxxxies\nFoxxy\nFoxy\nFrances\nFrancesca\nFrancesco\nFranceska\nFranchesca\nFrancheska\nFranchezca\nFrancie\nFrancine\nFranciska\nFrancoise\nFrancys\nFranki\nFrankie\nFranky\nFranny\nFranscina\nFransheliz\nFranziska\nFraulein\nFrayda\nFrea\nFreaky\nFreddie\nFrederica\nFree\nFreedom\nFreja\nFrench\nFrenchie\nFrenchy\nFrenky\nFreshblonde\nFresia\nFreya\nFreyja\nFrida\nFriday\nFrideric\nFrieda\nFriend\nFrisky\nFrost\nFroya\nFrujina\nFruzsi\nFujiko\nFunky\nFuria\nFuriya\nG.i.\nGabana\nGabbi\nGabbie\nGabby\nGabi\nGabina\nGabriel\nGabriela\nGabriele\nGabriella\nGabrielle\nGabrim\nGaby\nGaga\nGage\nGaia\nGail\nGala\nGalaxy\nGalechka\nGalia\nGalidiva\nGalilea\nGalina\nGaliyah\nGandhali\nGandhari\nGanna\nGarcia\nGasha\nGaston\nGattina\nGauge\nGaviota\nGaya\nGeah\nGeana\nGeena\nGeiser\nGeisha\nGeizer\nGela\nGelina\nGelya\nGelyn\nGem\nGema\nGemini\nGemma\nGen\nGena\nGenerosa\nGenesis\nGeneva\nGenevieve\nGenevievre\nGeni\nGenia\nGenice\nGenie\nGenna\nGenny\nGensen\nGentilly\nGentle\nGeny\nGenya\nGeona\nGeorgette\nGeorgia\nGeorgianna\nGeorgie\nGeorgina\nGera\nGeraldine\nGerda\nGermiona\nGerra\nGerri\nGerta\nGertie\nGessica\nGetties\nGettin\nGetty\nGeyshila\nGI\nGia\nGiada\nGiana\nGiancarlo\nGiani\nGianna\nGiaoni\nGibson\nGidget\nGieselle\nGiggy\nGigi\nGiiGi\nGilda\nGILF\nGili\nGillian\nGily\nGin\nGina\nGinebra\nGinette\nGinger\nGingerlee\nGingers\nGingie\nGinie\nGinjer\nGinn\nGinna\nGinnie\nGinny\nGino\nGinta\nGinyer\nGioia\nGiorgia\nGiorgiana\nGiovana\nGiovanna\nGiovanni\nGirls\nGisela\nGisele\nGisell\nGiselle\nGisellex\nGisha\nGisile\nGislene\nGisselle\nGita\nGitta\nGitti\nGiulia\nGiuliamma\nGiuliana\nGiuno\nGizella\nGizelle\nGizelly\nGizzelle\nGladys\nGlasha\nGlenda\nGlitter\nGloria\nGlorie\nGlory\nGlory,\nGlorya\nGlubayana\nGobi\nGoca\nGoddess\nGoddness\nGodiva\nGogo\nGold\nGoldee\nGolden\nGoldi\nGoldie\nGoldy\nGoldye\nGorgy\nGoth\nGotti\nGoulnara\nGrace\nGrace*\nGracelynn\nGraci\nGracie\nGraciela\nGrae\nGrase,\nGrayson\nGraziella\nGrazy\nGreen\nGresy\nGreta\nGreta,\nGretchen\nGrete\nGretta\nGrety\nGrisha\nGrunya\nGuerlain\nGueysha\nGuiliana\nGuillermo\nGujarati\nGulissa\nGulliana\nGuna\nGunnar\nGunta\nGustavo\nGuy\nGuyanna\nGuyta\nGwen\nGwena\nGwendoline\nGwyneth\nGya\nGyana\nGyanti\nGyna\nGynger\nGyongy\nGyöngy\nGyongyi\nGyorgy\nGyorgyi\nGypsy\nGyselle\nHabibi\nHaddie\nHadjara\nHadley\nHaide\nHaighlee\nHailee\nHaileey\nHailey\nHailie\nHaily\nHairy\nHajni\nHalena\nHaley\nHaleysweet\nHalia\nHalie\nHalina\nHalle\nHallee\nHalli\nHally\nHalmia\nHalona\nHaly\nHamyna\nHan\nHana\nHanah\nHandee\nHanela\nHanka\nHanna\nHannah\nHara\nHari\nHarlee\nHarley\nHarlow\nHarlowe\nHarmoni\nHarmonie\nHarmony\nHarold\nHarper\nHarriett\nHarris\nHarumi\nHaty\nHavana\nHavanna\nHaven\nHavoc\nHaydee\nHayden\nHaylee\nHayley\nHayli\nHaylie\nHaylo\nHazel\nHeather\nHeaven\nHeda\nHeddie\nHedvika\nHeena\nHei\nHeidi\nHeidy\nHela\nHelen\nHelena\nHelene\nHelenna\nHelga\nHellen\nHellena\nHellene\nHellga\nHelli\nHellion\nHellizabeth\nHelly\nHeloisa\nHemlata\nHene\nHenessy\nHeni\nHenley\nHenna\nHennesie\nHenriett\nHenrietta\nHenriette\nHerda\nHermione\nHermionie\nHermyna\nHershey\nHetera\nHettie\nHetty\nHexxus\nHiady\nHilaire\nHilaria\nHilary\nHilina\nHillary\nHilliary\nHilo\nHime\nHimera\nHippy\nHitomi\nHoley\nHolland\nHolli\nHollie\nHollow\nHolly\nHollyfox\nHollyvanhough\nHollywhood\nHollywood\nHombre\nHomemade\nHona\nHoney\nHonney\nHonour\nHope\nHorny\nHot\nHotkinkyjo\nHottie\nHouston\nHrisanta\nHulda\nHümerya\nHunni\nHunter\nHurricane\nHydie\nHydii\nHypnotica\nHypnotiq\nIanisha\nIara\nIcarus\nIce\nicelafox\nIda\nIdel\nIdelsy\nIelza\nIeva\nIevina\nIggy\nIhra\nIlana\nIldi\nIldico\nIldiko\nIldy\nIleen\nIlessya\nIlina\nIlka\nIllana\nIlly\nIlna\nIlona\nIlsa\nIlze\nIman\nImani\nImanie\nImma\nImogene\nIna\nInalka\nInari\nIndia\nIndiana\nIndianna\nIndica\nIndigo\nIndio\nIndira\nIndra\nIndy\nInes\nInessa\nInez\nInfanta\nInfinity\nInga\nInga,\nIngrid\nInia\nInitha\nInna\nInnes\nInness\nInnocencia\nInta\nInus\nIoana\nIona\nIonella\nIra\nIraina\nIren\nIrena\nIrene\nIrenka\nIrida\nIrie\nIrin\nIrina\nIris\nIrish\nIrisha\nIrishka\nIrma\nIruki\nIsa\nIsabel\nIsabela\nIsabele\nIsabeli\nIsabell\nIsabella\nIsabelle\nIsacc\nIsada\nIsadora\nIsaphan\nIsaxx\nIsebella\nIsha\nIshani\nIsida\nIsis\nIsizzu\nIskra\nIsla\nIsland\nIslavoika\nIsobel\nIspahan\nIssa\nIssabella\nIssis\nItalia\nItna\nIul\nIva\nIvana\nIvanka\nIvanna\nIveta\nIvett\nIvetta\nIvette\nIvetty\nIvey\nIvi\nIvija\nIvka\nIvon\nIvona\nIvonne\nIvory\nIvy\nIwia\nIya\nIyana\nIyesha\nIyeva\nIyulta\nIza\nIzabella\nIzabelly\nIzadora\nIzamar\nIzces\nIzi\nIzida\nIzobella\nIzy\nIzy-bella\nIzzi\nIzzie\nIzzy\nJ\nJ.\nJ.J.\nJ.T.\nJacinda\nJack\nJackeline\nJacki\nJackie\nJacklin\nJacklyn\nJacky\nJaclene\nJacline\nJaclyn\nJaco\nJacqay\nJacquelin\njacqueline\nJacquelinet\nJacquelinne\nJacquelyn\nJacqui\nJacy\nJacyline\nJacynda\nJada\nJadan\njade\nJaded\nJadee\nJadelyn\nJaden\nJadena\nJadis\nJady\nJadyn\nJae\nJaeden\nJaeline\nJaelyn\nJagdelfe\nJagger\nJahn\nJahna\nJai\nJaiden\nJaime\nJaimi\nJaimie\nJaiyona\nJakeline\nJakie\nJalace\nJamacia\nJamaica\nJamey\nJami\nJamie\nJamina\nJana\nJanae\nJanah\nJanaina\nJanavi\nJanay\nJanaya\nJandi\nJandra\nJane\nJanea\nJaneextra\nJanelle\nJanessa\nJanet\nJanetextra\nJaneth\nJanett\nJanette\nJaney\nJani\nJanice\nJanie\nJanika\nJanine\nJanis\nJanka\nJanna\nJanne\nJannelle\nJannet\nJannete\nJannette\nJanny\nJannys\nJanuary\nJany\nJapan\nJaque\nJaquelin\nJaqueline\nJaquelline\nJaquelyn\nJaqui\nJared\nJarmila\nJarushka\nJasha\nJasika\nJaslene\nJaslin\nJasline\nJasmeen\nJasmen\nJasmin\nJasmina\nJasmine\nJasmine*\nJasmyn\nJasse\nJassi\nJassica\nJassie\nJassy\nJaszmina\nJaxxa\nJay\nJaya\nJayashree\nJaycee\nJaycie\nJayda\nJaydah\nJaydan\nJayda's\nJayde\nJayded\nJayden\nJaydence\nJaye\nJayj\nJayla\nJaylee\nJayleine\nJaylene\nJaylie\nJaylin\nJaylnn\nJaylyn\nJaylynn\nJayma\nJayme\nJaymee\nJayna\nJayne\nJayogen\nJazabella\nJazel\nJazella\nJazlin\nJazlyn\nJazmin\nJazmine\nJazmyn\nJazmyneextra\nJazy\nJazz\nJazzi\nJazzmin\nJazzmine\nJazzy\nJc\nJean\nJeanette\nJeanie\nJeanine\nJeanna\nJeanne\nJeannie\nJeanny\nJecica\nJecika\nJecky\nJeff\nJeleana\nJelena\nJelice\nJellibean\nJellie\nJelly\nJely\nJema\nJemeni\nJemini\nJemma\nJen\nJena\nJenae\nJenaveve\nJene\nJenelle\nJenessa\nJenet\nJenette\nJenevieve\nJeni\nJenia\nJenica\nJenifer\nJeniffer\nJenis\nJenla\nJenn\nJenna\nJennai\nJennavie\nJennaxxx\nJennay\nJennet\nJenneva\nJenni\nJenniah\nJennie\nJennifer\nJennifer,\nJennla\nJenny\nJenny***\nJennyfer\nJensen\nJentina\nJeny\nJenya\nJera\nJereni\nJericha\nJerilyn\nJerri\nJerrika\nJerry\nJersee\nJersey\nJerzi\nJes\nJesebella\nJesica\nJesicca\nJesie\nJesika\nJeska\nJess\njessa\nJessae\nJessamine\nJesse\nJessee\nJessey\nJessi\nJessica\nJessica,\nJessica_Malony\nJessicaextra\nJessicca\nJessie\nJessie-Renee\nJessika\nJessmindra\nJessy\nJessy*\nJessyca\nJessye\nJessyka\nJesyka\nJet\nJett\nJetta\nJeva\nJewel\nJewelextra\nJeweliette\nJewell\nJewels\nJewelz\nJewley\nJey\nJeycy\nJeymey\nJezabel\nJezabelle\nJezaree\nJeze\nJezebel\nJezebelle\nJezebeth\nJezelle\nJezhabel\nJezla\nJezzicat\nJharin\nJhazira\nJhenya\nJia\nJilana\nJill\nJillean\nJillian\nJilly\nJilova\nJim\nJimena\nJina\nJinger\nJinglzz\nJini\nJinjer\nJinny\nJiselle\nJitka\nJiz\nJizelle\nJizzelle\nJj\nJkwon\nJ-lo\nJme\nJo\nJoan\nJoana\nJoanie\nJoann\njoanna\nJoanne\nJocalynn\nJocelyn\nJocelyne\nJocelynn\nJoceyln\nJoclyn\nJoclynn\nJodi\nJodie\nJody\nJoei\nJoel\nJoelean\nJoelle\nJoelyn\nJoey\nJoeye\nJohane\nJohanna\nJohanne\nJohannes\nJohn\nJohnni\nJohnnie\nJohnny\nJojo\nJola\nJolanna\nJolee\nJolene\nJoleyn\nJoleyna\nJoli\nJolie\nJolieen\nJolina\nJolisa\nJolly\nJoly\nJolyne\nJonathan\nJonni\nJonsone\nJorani\nJordan\nJordana\nJordanbliss\nJordann\nJordanna\nJordanne\nJorden\nJordi\nJordie\nJordin\nJordinextra\nJordy\nJordyn\nJordynn\nJori\nJosefina\nJosefine\nJoselina\nJoseline\nJoselyn\nJosephine\nJosette\nJosey\nJosi\nJosie\nJosline\nJoslyn\nJoss\nJosta\nJosy\nJoulie\nJourdan\nJourney\nJovana\nJovanna\nJoy\nJoyce\nJozephine\nJR\nJu\nJuana\nJuanita\nJuany\nJubilee\nJudi\nJudie\nJudit\nJudita\nJudith\nJuditta\nJudy\nJudyt\nJuelz\nJuicee\nJuicy\nJuja\nJujana\nJuju\nJul\nJule\nJules\nJuli\nJulia\nJulian\nJuliana\nJuliane\nJulianna\nJulianne\nJulie\nJuliea\nJulieann\nJulien\nJulienne\nJuliet\nJulieta\nJuliete\nJuliett\nJuliette\nJulissa\nJulius\nJuliya\nJuly\nJulya\nJulyana\nJulyanova\nJulytis\nJulz\nJulze\nJuman\nJune\nJunko\nJuno\nJureka\nJust\nJustene\nJustice\nJustin\nJustina\nJustine\nJustyn\nJustyne\nJynn\nJynx\nK.\nK.C.\nKa\nKacee\nKacey\nKaci\nKacie\nKacy\nKaden\nKadence\nKadia\nKadri\nKadru\nKady\nKaedyn\nKaegan\nKaeina\nKaela\nKaely\nKaelyn\nKaerin\nKagney\nKahi\nKahlen\nKahlisa\nKahlista\nKaho\nKai\nKaia\nKaiia\nKail\nKaila\nKaila-Mai\nKailani\nKailey\nKaily\nKainoa\nKaira\nKaire\nKaisa\nKaiserin\nKaisey\nKaisha\nKait\nKaitlin\nKaitlyn\nKaitlynn\nKaity\nKaiya\nKaiyla\nKajira\nKakey\nKala\nKalani\nKalea\nKaleah\nKalee\nKaleena\nKaleesy\nKalei\nKaley\nKali\nKalie\nKalila\nKalilane\nKalina\nKalisy\nKaliy\nKalla\nKallie\nKallisto\nKally\nKaly\nKalyssa\nKama\nKamala\nKamani\nKamay\nKameya\nKami\nKamila\nKamila.\nKamilla\nKamille\nKamlyn\nKammy\nKamryn\nKan\nKandace\nKandall\nKandee\nKandi\nKandice\nKandie\nKandy\nKani\nKanya\nKapri\nKapriznaya\nKara\nKaran\nKaranovak\nKaraoku\nKaratai\nKaren\nKarena\nKarensisima\nKaressa\nKari\nKarib\nKarie\nKarima\nKarin\nKarina\nKarine\nKariney\nKarinne\nKarisma\nKarissa\nKarla\nKarlee\nKarlie\nKarlin\nKarly\nKarlye\nKarma\nKarmen\nKarmin\nKaro\nKarol\nKarola\nKarolin\nKarolina\nKaroline\nKaroll\nKarolline\nKarolly\nKarolyne\nKarrey\nKarri\nKarrina\nKarrlie\nKarrol\nKarry\nKarson\nKarter\nKaryn\nKaryna\nKasandra\nKase\nKasey\nKash\nKasha\nKashmir\nKasia\nKasmine\nKasorn\nKassandra\nKassey\nKassie\nKassius\nKassondra\nKassyana\nKastumi\nKat\nKata\nKatala\nKataleena\nKatalin\nKatalina\nKataliza\nKataljna\nKatallina\nKatalyn\nKatalynix\nKatalynka\nKatana\nKatanya\nKatarina\nKatarinka\nkate\nKatej\nKatelyn\nKatelyne\nKaterin\nKaterina\nKaterine\nKatey\nKath\nKathalina\nKatharina\nKatharine\nKatherin\nKatherine\nKatherinne\nKathi\nKathia\nKathleen\nKathlen\nKathryn\nKathy\nKati\nKatia\nKatie\nKatiee\nKatiejordin\nKatiek\nKatija\nKatika\nKatilly\nKatin\nKatina\nKatinka\nKatiy\nKatja\nKatka\nKatkam\nKatlein\nKatlyn\nKatra\nKatreena\nKatrell\nKatrena\nKatrin\nkatrina\nKatrine\nKatrinTequila\nKatsi\nKatsumi\nKatsuni\nKatt\nKatti\nKattie\nKatty\nKaty\nKatya\nKauana\nKaula\nKavane\nKawanna\nKay\nKaya\nKaycee\nKayden\nKaydence\nKayia\nKayla\nKaylah\nKaylan\nkaylani\nKaylann\nKaylee\nKayleen\nKayleigh\nKayley\nKayli\nKaylia\nKaylie\nKaylin\nKayly\nKaylyn\nKaylynn\nKayme\nKayne\nKaytee\nKaz\nKc\nKea\nKeana\nKeanna\nKeanni\nKecey\nKecy\nKeeani\nKeegan\nKeeley\nKeely\nKeena\nKeensahra\nKeeth\nKefren\nKehlani\nKeiko\nKeila\nKeilani\nKeira\nKeisha\nKeit\nKeita\nKeith\nKeithy\nKeity\nKeiyra\nKelen\nKeli\nKelle\nKelley\nKelli\nKellie\nKelly\nKellyA\nKellyextra\nKelm\nKelsey\nKelsi\nKelsie\nKelsy\nKely\nKendal\nKendall\nKendra\nKendyll\nKenet\nKenia\nKenley\nKenna\nKenndra\nKennedy\nKenneth\nKenni\nKensey\nKenya\nKenza\nKenze\nKenzi\nKenzie\nKeoki\nKerani\nKeri\nKerie\nKerija\nKerra\nKerri\nKerry\nKerry-Louise\nKersti\nKerti\nKertu\nKery\nKesare\nKesha\nKesia\nKesidy\nKessi\nKessie\nKessy\nKetrin\nKetthy\nKetti\nKetty\nKety\nKevinn\nKeylie\nKeymore\nKeymy\nKeyona\nKeyty\nKhadisha\nKhaleesi\nKhalista\nKharlie\nKharyi\nKhaya\nKhia\nKhira\nKhloe\nKhris\nKhyanna\nKia\nKiana\nKianna\nKiara\nKiaraxx\nKiarra\nKiera\nKiere\nKierra\nKierstin\nKierstyn\nKik\nKika\nKiki\nKikko\nKiko\nKiley\nKili\nKilla\nKim\nKimber\nKimberlee\nKimberley\nkimberly\nKimbra\nKimeleane\nKimi\nKimiko\nKimmie\nKimmy\nKimora\nKimXXX\nKimy\nKina\nKinga\nKinky\nKinley\nKinnley\nKinsley\nKinuski\nKinzie\nKinzy\nKira\nKiralanai\nKirani\nKirati\nKirby\nKiri\nKirin\nKiriztina\nKirke\nKirra\nKirschley\nKirsten\nKirstin\nKirsty\nKirylam\nKisa\nKisha\nKiska\nKiss\nKissa\nKissy\nKit\nKita\nKitana\nKiti\nKitkat\nKitri\nKitten\nKitti\nKittie\nKittina\nKittina*\nKitty\nKiwi\nKiyanna\nKizzy\nKlara\nKlarisa\nKlarissa\nKlaudia\nKlea\nKleio\nKlementine\nKlenot\nKleo\nKleopatra\nKler\nKloe\nKloey\nKloffina\nKlotild\nKno\nKo\nKobe\nKobi\nKodi\nKody\nKoell\nKohl\nKoika\nKokie\nKokkine\nKoko\nKokohontas\nKoks\nKoni\nKora\nKorene\nKori\nKorina\nKornelia\nKortney\nKortni\nKortny\nKosa\nKosame\nKosane\nKoty\nKouroko\nKourtney\nKoy\nKoyuki\nKravanna\nKream\nKreatris\nKris\nKrisei\nKriss\nKrissie\nKrissy\nKrista\nKristal\nKristall\nKristana\nKristarah\nKristel\nKristell\nKristen\nKristi\nKristian\nKristie\nKristin\nKristina\nKristinarose\nKristine\nKristty\nKristy\nKristyna\nKristynka\nKrisy\nKrisztin\nKrisztina\nKriztina\nKrsi\nKrysta\nKrystal\nKrystena\nKrysti\nKrystina\nKrysty\nKrystyna\nKsandra\nKsantia\nKsara\nKsena\nKsenia\nKsenija\nKseniy\nKseniya\nKsenya\nKsu\nKsucha\nKsuColt\nKsurina\nKsusha\nKtee\nKumani\nKumara\nKurious\nKuznec\nKveta\nKvetoslava\nKy\nKya\nKyaa\nKyah\nKyan\nKyana\nKyanna\nKyara\nKykola\nKyla\nKylani\nKylea\nKylee\nKyleigh\nKyler\nKylie\nKym\nKymber\nKymberlee\nKymberlie\nKymberly\nKymora\nKynthia\nKypa\nKyra\nKyrashina\nKyrin\nKysara\nKytiana\nL.\nLa\nLabelly\nLacee\nLacey\nLachasse\nLachelle\nLaChere\nLachia\nLaci\nLacie\nLacy\nLada\nLaddie\nLadie\nLadonna\nLady\nLaeh\nLaela\nLaeticia\nLaetitia\nLafee\nLagoon\nLagoona\nLahia\nLaiken\nLaila\nLailanie\nLailonni\nLailouni\nLaima\nLain\nLaina\nLainey\nLaiza\nLajla\nLaka\nLake\nLakota\nLala\nLalassa\nLalita\nLallasa\nLalovv\nLaly\nLamia\nLana\nLanaviolet\nLance\nLandon\nLane\nLanewood\nLaney\nLani\nLanie\nLanka\nLanna\nLanne\nLannie\nLanny\nLaora\nLaoura\nLapoehica\nLappi\nLapreece\nLara\nLaraan\nLareina\nLarem\nLarin\nLa'Rin\nLarina\nLarisa\nLarissa\nLarkin\nLarra\nLaryne\nLaryssa\nLasirena69\nLaska\nLatalli\nLataya\nLatex\nLatia\nLaticia\nLatika\nLatin\nLatina\nLatmi\nLatoya\nLaura\nLaura*\nLauracrystal\nLaurah\nLauralai\nLauralyn\nLaure\nLaurea\nLaurean\nLaureen\nLaurel\nLauren\nLaurence\nLaurianne\nLaurie\nLaurine\nLaurita\nLauro\nLauryl\nLauryn\nLavana\nLavanda\nLavatta\nLavender\nLavin\nLavina\nLavish\nLawanda\nLaya\nLaycee\nLayden\nLayla\nLaylah\nLayla-Jade\nLaylani\nLayloni\nLaylynn\nLayma\nLayna\nLayne\nLaysa\nLayton\nLaz\nLC\nLcdc\nLea\nLeah\nLeana\nLeandra\nLeanella\nLeann\nLeanna\nLeanne\nLeannella\nLeanni\nLeaya\nLecette\nLecher\nLeda\nLedona\nLee\nLeea\nLeeanne\nLeeda\nLeela\nLeena\nLeenda\nLeenuh\nLeesa\nLeeza\nLei\nLeia\nLeida\nLeigh\nLeighlani\nLeighton\nLeihla\nLeila\nLeilani\nLeili\nLeilla\nLek\nLeka\nLeksi\nLeksy\nLela\nLelani\nLellie\nLellou\nLelloy\nLelu\nLelya\nLena\nLenai\nLenda\nLendsay\nLeni\nLenia\nLenina\nLenka\nLenna\nLennox\nLennoz\nLenny\nLentia\nLeny\nLenya\nLeo\nLeoa\nLeoLulu\nLeona\nLeonella\nLeonelle\nLeoni\nLeonie\nLeonora\nLeonsia\nLeony\nLepidoptera\nLera\nLerika\nLerissa\nLerou\nLesa\nLesley\nLeslie\nLesperansa\nLessie\nLesslane\nLessy\nLesya\nLethal\nLeticia\nLeticiya\nLettie\nLetty\nLevina\nLex\nLexa\nLexas\nlexi\nLexian\nLexiana\nLexiangel\nLexid\nLexidiamond\nLexie\nLexii\nLexis\nLexxi\nLexxis\nLexxxi\nLexxxus\nLexxy\nLexy\nLeya\nLeyla\nLeylani\nLeylou\nLeyre\nLeza\nLezley\nLi\nLia\nLiaa\nLiah\nLiams\nLian\nLiana\nLiandra\nLiania\nLianna\nLianne\nLibby\nLiberta\nLiberty\nLibit\nLibuse\nLichelle\nLicie\nLicije\nLicious\nLickable\nLicky\nLida\nLidia\nLidiya\nLidy\nLielani\nLien\nLiena\nLiga\nLight\nLightfairy\nLija\nLika\nLil\nLila\nLilah\nLildre\nLili\nLilia\nLilian\nLiliana\nLiliane\nLilianna\nLilianne\nLilie\nLilien\nLilit\nLilith\nLilla\nLillandra\nLilli\nLillia\nLillian\nLillianne\nLillie\nLillike\nLillis\nLillith\nLilly\nLilo\nLiloo\nLilou\nLilouch\nLilu\nlily\nLily_Cat\nLilya\nLilyan\nLilyana\nLilyanna\nLilyna\nLin\nLina\nLinda\nLinda_Sweet\nLindababy\nLinde\nLindie\nLindsay\nLindsey\nLindsia\nLindsy\nLindy\nLindzey\nLinet\nLinette\nLinna\nLinnea\nLinnette\nLinny\nLinsay\nLinsey\nLinx\nLinzee\nLinzi\nLiolya\nLiona\nLionees\nLioness\nLipa\nLis\nLisa\nLisaextra\nLisamor\nLisbeth\nLisel\nLisen\nLisette\nLisey\nLisi\nLiss\nLissa\nLita\nLitia\nLittle\nLiuba\nLiuko\nliv\nLivia\nLivie\nLiya\nLiyera\nLiyla\nLiz\nLiza\nLizabeta\nLizaveta\nLizeth\nLizette\nLizi\nLizie\nLizka\nLizz\nLizzette\nLizzie\nLizzy\nLjuba\nLlana\nLluna\nLo\nLoana\nLocke\nLogan\nLohra\nLoida\nLois\nLoisa\nlola\nLola**\nLola*****\nLolah\nLolana\nLolashut\nLoli\nLolita\nLo-Lita\nLolitka\nLolla\nLolli\nLollie\nLollipop\nLolly\nLollypop\nLolo\nLona\nLonda\nLondon\nLondyn\nLong\nLoni\nLonnie\nLonny\nLoona\nLopes\nLoquis\nLora\nLoraine\nLoredana\nLoree\nLoreen\nLorelai\nLorelei\nLorelie\nLoren\nLorena\nLorene\nLorenia\nLoretta\nLorette\nLorey\nLori\nLorie\nLorina\nLorinda\nLorine\nLoris\nLorna\nLorraine\nLorrelai\nLory\nLoser\nLote\nLotta\nLotti\nLottie\nLotty\nLotus\nLou\nLouisa\nLouise\nLoula\nLoulaLou\nLoulou\nLoura\nLourdes\nLoureen\nLovanna\nLove\nLovely\nLovenia\nLoventa\nLovette\nLovisa\nLovita\nLoylita\nLua\nLuana\nLuanah\nLuanna\nLuba\nLubice\nLubochka\nLuca\nLuccia\nLucette\nLuchya\nLuci\nLucia\nLuciana\nLucianna\nLucie\nLucie****\nLucie,\nLucile\nLucilla\nLucina\nLuck\nLuckey\nLucky\nLucle\nLucretia\nLucy\nLucyka\nLucylux\nLucynova\nLuda\nLudiya\nLudmila\nLudmilla\nLudwiga\nLudy\nLuigina\nLuisa\nLuissa\nLuiza\nLukava\nLukki\nLula\nLullu\nLulu\nLuly\nLuma\nLuna\nLunae\nLupe\nLupei\nLupita\nLus\nLuschious\nLuscious\nLusi\nLusie\nLusil\nLusila\nLusilla\nLusita\nLussi\nLussy\nLusy\nLusya\nLuventa\nLuvy\nLux\nLuxe\nLuxury\nLuysan\nLuz\nLuzbel\nLuzzy\nLya\nLyanna\nLyava\nLydia\nLydie\nLyen\nLyla\nLylia\nLylie\nLylith\nLylitty\nLyliya\nLylla\nLylyta\nLyn\nLyna\nLynda\nLyndsey\nLyndsy\nLyne\nLynn\nLynna\nLynne\nLynnlove\nLynx\nLyra\nLysa\nLystra\nLyudmilla\nLyudmyla\nM.\nM.J.\nMa\nMaara\nMaarit\nMabel\nMable\nMacarena\nMaccy\nMacey\nMacha\nMachella\nMaci\nMacie\nMackenzee\nMackenzie\nMacroc\nMacy\nMad\nMadalena\nMadam\nMaddey\nMaddi\nMaddie\nMaddison\nMaddy\nMadeinCanarias\nMadeleine\nMadelen\nMadeline\nMadelyn\nMadelyne\nMadge\nMadi\nMadien\nMadisen\nMadisin\nMadison\nMadisson\nMadlen\nMadlena\nMadleyn\nMadlin\nMadonna\nMaduri\nMae\nMaeketa\nMaelynn\nMaeva\nMafalda\nMag\nMagalie\nMagda\nMagdalen\nMagdalena\nMagdalene\nMagdi\nMagdolna\nMagela\nMagella\nMaggie\nMagic\nMagy\nMahamari\nMaheda\nMahina\nMahlia\nMahogany\nMahumari\nMai\nMaia\nMaible\nMaija\nMaikana\nMaila\nMailan\nMailani\nMailly\nMaira\nMaisie\nMaitland\nMaitresse\nMaiya\nMaja\nMajida\nMajo\nMak\nMakali\nMakana\nmakayla\nMakbota\nMakenna\nMakenzie\nMalacka\nMalaysia\nMaleah\nMaleena\nMalena\nMalezia\nMali\nMalia\nMaliana\nMalibu\nMalica\nMalicia\nMalika\nMalin\nMalina\nMalinda\nMalisha\nMalitia\nMaliyah\nMallory\nMalloy\nMalone\nMaloo\nMaloree\nMalorie\nMalory\nMalou\nMalu\nMalusha\nMalvina\nMalvine\nMalwina\nMalya\nMalyska\nMam\nMame\nMami\nMamie\nMancy\nManda\nMandalay\nMandee\nMandi\nMandie\nMandii\nMandy\nManga\nManindra\nMannella\nManoela\nManon\nMansa\nManu\nManuela\nManuella\nManya\nManyika\nManzell\nMar\nMara\nMaralyn\nMarcela\nMarcele\nMarcelina\nMarceline\nMarcella\nMarcella_C\nMarcellinha\nMarcelly\nMarcena\nMarci\nMarcia\nMarcie\nMarcona\nMarcy\nMareen\nMaren\nMarena\nMarfa\nMargaret\nMargareta\nMargarete\nMargareth\nMargarethe\nMargarette\nMargarita\nMargaux\nMargery\nMargitta\nMargo\nMargot\nMargrett\nMarhyan\nMari\nMaria\nMariah\nMariam\nMarian\nMariana\nMariann\nMarianna\nMarianne\nMariar\nMaribel\nMarica\nMaricella\nMarie\nMarie-Anne\nMariel\nMarie-laure\nMarielena\nMariella\nMarielou\nMarien\nMarienne\nMariesa\nMarietta\nMarija\nMarijana\nMarije\nMarijo\nMarika\nMarila\nMarilin\nMarille\nMarilyn\nMarilynn\nMarimar\nMarina\nMarine\nMarinella\nMarinka\nMarins\nMarion\nMarisa\nMarisaextra\nMarisela\nMarisha\nMariska\nMarisol\nMarisole\nMarissa\nMarit\nMaritrini\nMaritza\nMariya\nMarizza\nMark\nMarkelly\nMarketa\nMarki\nMarkie\nMarkiza\nMarky\nMarkyza\nMarla\nMarleana\nMarlee\nMarleigh\nMarlena\nMarlene\nMarlette\nMarley\nMarli\nMarlice\nMarlie\nMarlyn\nMarnie\nMarquetta\nMarquize\nMarri\nMarria\nMarrietta\nMarry\nMarsa\nMarsela\nMarselina\nMarsha\nMarsila\nMarta\nMartha\nMartina\nMartinaz\nMartine\nMartini\nMartins\nMarusha\nMarushka\nMarusia\nMarusya\nMary\nMarya\nMaryah\nMaryana\nMaryann\nMary-Ann\nMaryanne\nMary-Dee\nMaryel\nMaryja\nMaryjane\nMary-Jane\nMaryjean\nMary-Kate\nMarylin\nMaryline\nMarylon\nMaryna\nMarysol\nMasa\nMaserati\nMasha\nMasie\nMasked\nMason\nMaster\nMasuimi\nMatao\nMathea\nMathilda\nMathilde\nMatilda\nMatilde\nMattie\nMature\nMatylda\nMaude\nMaura\nMaureen\nMaurina\nMaurissa\nMauro\nMaven\nMax\nMaxi\nMaxim\nMaxime\nMaxine\nMaxx\nMay\nMaya\nMaya,\nMayaextra\nMayara\nMayarah\nMayerly\nMayhem\nMayim\nMayine\nMayla\nMaylee\nMaylin\nMayline\nMayna\nMayo\nMayola\nMaza\nMazsa\nMazy\nMazzaratie\nMazzy\nMc\nMcCoy\nMckayla\nMckenzee\nMckenzi\nMckenzie\nMcQueen\nMe\nMea\nMeadow\nMeagan\nMeagen\nMeara\nMecca\nMecha\nMeddie\nMedea\nMedlin\nMedora\nMedusa\nMeeham\nMeesha\nMeg\nMegan\nMegane\nMeggan\nMeggi\nMeggie\nMeggy\nMeghan\nMeghann\nMegia\nMegie\nMegifa\nMei\nMeiko\nMeilani\nMeira\nMeka\nMekeilah\nMekellah\nMeko\nMel\nMela\nMelaine\nMelana\nMelane\nMelanei\nMelania\nMelanie\nMelany\nMelea\nMelena\nMelenna\nMeli\nMeliah\nMelika\nMelina\nMelinda\nMelisa\nMelisande\nMelisia\nMelissa\nMelissza\nMelita\nMeliza\nMelizza\nMell\nMella\nMellanie\nMellie\nMellisa\nMellisandra\nMellissa\nMelly\nMelodee\nMelodie\nMelodii\nMelody\nMelodyWilde\nMelon\nMeloney\nMelonie\nMelony\nMelory\nMelrose\nMemphis\nMena\nMenage\nMendi\nMeng\nMeow\nMercedes\nMercedesz\nMercedez\nMerci\nMercia\nMercury\nMercy\nMerda\nMeredith\nMerelyn\nMeren\nMeretrix\nMeri\nMeriah\nMeridian\nMeriesa\nMerilee\nMerilin\nMerilyn\nMeriosa\nMerissa\nMeriva\nMerlina\nMerllin\nMerri\nMerrie\nMerrion\nMerry\nMersi\nMerszedes\nMery\nMeryl\nMerylin\nMessua\nMessy\nMette\nMexi\nMey\nMi\nMia\nMia*\nMia**\nMiahilton\nMiako\nMianna\nMiayah\nMica\nMicah\nMicara\nMicca\nMicha\nMichael\nMichaela\nMichaelaIsizzu\nMichaella\nMichel\nMichele\nMicheli\nMichell\nMichelle\nMichelleextra\nMichellemoist\nMichellemyers\nMicka\nMickaella\nMicke\nMickey\nMicki\nMickie\nMickinzie\nMicky\nMida\nMidge\nMidnite\nMidori\nMiel\nMiela\nMiesha\nMihaNika\nMiho\nMiia\nMija\nMika\nMikaela\nMikaella\nMikaelle\nMikah\nMikana\nMikayla\nMike\nMikela\nMiki\nMikita\nMikka\nMikki\nMikky\nMiko\nMiky\nMila\nMila,\nMilada\nMilair\nMilan\nMilana\nMilaya\nMilcah\nMilea\nMiledy\nMileena\nMilena\nMilene\nMiley\nMileyann\nMilf\nMili\nMilia\nMilian\nMiliani\nMilisa\nMilissa\nMilka\nMilla\nMillena\nMilli\nMillia\nMillian\nMillie\nMilly\nMilu\nMiluska\nMily\nMima\nMimi\nMimosa\nMin\nMina\nMindee\nMindi\nMindori\nMindy\nMinel\nMinerva\nmini\nMinka\nMinna\nMinnie\nMinny\nMinori\nMiosotis\nMira\nMirabel\nMirabella\nMiracle\nMirage\nMirai\nMirami\nMiranda\nMirayn\nMireira\nMirela\nMirella\nMirelle\nMiren\nMiri\nMiriam\nMirinda\nMirjam\nMirka\nMiroslava\nMirra\nMirta\nMiryam\nMis\nMisa\nMisa-f\nMiscani\nMischa\nMischel\nMischell\nMischelle\nMischievous\nMisel\nMish\nMisha\nMishel\nMishelle\nMishka\nMishkany\nMishulinka\nMishy\nMiss\nMissa\nMissi\nMissie\nMisslove\nMissty\nmissy\nMisti\nMistress\nMisty\nMitsuki\nMitzi\nMitzy\nMiu\nMiuk\nMiulee\nMivina\nMiya\nMiyabi\nMiyamme\nMiyuki\nMiza\nMizz\nMj\nMJFresh\nMlle\nMo\nMoana\nMocca\nMocha\nModels\nMoet\nMohini\nMoira\nMoka\nMolleuex\nMollie\nMolly\nMollymadison\nMoloko\nMom\nMomoko\nMomy\nMona\nMonalee\nMonaliza\nMonchi\nMone\nMonet\nMonetextra\nMoni\nMonic\nMonica\nMonicca\nMonicka\nMonicue\nMonik\nMonika\nMoniq\nmonique\nMonlave\nMonna\nMonroe\nMontana\nMontanna\nMonti\nMontse\nMony\nMoon\nMoonlight\nMora\nMoray\nMorena\nMoretta\nMorey\nMorgalny\nMorgan\nMorgana\nMorgane\nMorgann\nMorghan\nMoriah\nMorning\nMorocha\nMorrigan\nMortica\nMorticia\nMorven\nMother-Daughter\nMouna\nMounia\nMount\nMoxxie\nMoxxxies\nMoxxy\nMoyanne\nMr.Charlie\nMr.Long\nMrs\nMrs.\nMs\nMs.\nMs.ForbiddenLoyalty\nMs.Yummy\nMspanther\nMulani\nMultiple\nMunequita\nMuriel\nMurina\nMurka\nMusky\nMuza\nMy\nMya\nMyah\nMyeshia\nMyiuki\nMyka\nMykaela\nMykka\nMyla\nMylen\nMylena\nMylene\nMyli\nMylie\nMylka\nMynxx\nMyra\nMyriam\nMyrille\nMyrka\nMyrna\nMyshell\nMystery\nMysti\nMystica\nMystika\nMystique\nMySweetApple\nMz\nMz.\nMz.Twilight\nNaara\nNada\nNadea\nNadegda\nNadejda\nNadezda\nNadezhda\nNadi\nNadia\nNadija\nNadin\nNadina\nNadine\nNadira\nNadiya\nNadya\nNadyenka\nNagini\nNaia\nNaidyne\nNaimee\nNaiomi\nNaira\nNaja\nNajra\nNakia\nNakiah\nNakita\nNala\nNami\nNan\nNana\nNanay\nNancie\nNancy\nNanda\nNandi\nNanette\nNaney\nNani\nNannccy\nNanney\nNannie\nNanny\nNanoe\nNansy\nNaoimi\nNaomi\nNaomie\nNaomy\nNara\nNarcissa\nNari\nNariah\nNarkiss\nNarlie\nNasita\nNassy\nNasta\nNastasy\nNastaya\nNastia\nNastie\nNastika\nNastiya\nNastja\nNasty\nNastya\nNastyhka\nNata\nNata?lie\nNatal\nNatalee\nNatali\nNatalia\nNataliah\nNatalie\nNataliex\nNataliia\nNatalija\nNatalin\nNatalissa\nNataliy\nNataliya\nNatalli\nNatallia\nNatallie\nNatally\nNataly\nNatalya\nNatana\nNatany\nNataria\nNatascha\nNatasha\nNatashaextra\nNatashia\nNatcha\nNathalie\nNathaly\nNathasha\nNatie\nNatile\nNatisha\nNatosha\nNatti\nNatty\nNatusya\nNaty\nNaudi\nNaudia\nNaudie\nNaudya\nNaughtia\nNaughty\nNaurin\nNautica\nNavaeh\nNaveah\nNaveen\nNaya\nNayma\nNayomi\nNayra\nNazar\nNea\nNecro\nNedda\nNedra\nNeecie\nNeela\nNeesa\nNefertiti\nNeilla\nNeisa\nNekane\nNela\nNell\nNella\nNelli\nNellie\nNelly\nNelya\nNelys\nNena\nNenetl\nNeona\nNerea\nNeriah\nNerine\nNesa\nNess\nNessa\nNessi\nNessie\nNessy\nNessye\nNestee\nNesti\nNesty\nNetra\nNetta\nNetty\nNetu\nNeva\nNevaeh\nNeve\nNeveah\nNevena\nNezvera\nNia\nNiana\nNica\nNicca\nNiccole\nNichol\nNichole\nNicholee\nNici\nNicka\nNickel\nNickey\nNicki\nNickie\nNickol\nNickolay\nNicky\nNico\nNicol\nNicola\nnicole\nNicoleextra\nNicoleray\nNicoletta\nNicolette\nNicolety\nNicolina\nNicoline\nNicoll\nNicolle\nNicollette\nNicotine\nNicova\nNieves\nNigell\nNight\nNika\nNikala\nNikara\nNike\nNiki\nNikia\nNikic\nNikida\nNikita\nNikitta\nNikka\nNikki\nNikkie\nNikkita\nNikkivee\nNikko\nNikky\nNikol\nNikola\nNikole\nNikoleta\nNikolett\nNikoletta\nNikolla\nNiksha\nNiky\nNikysweet\nNila\nNilah\nNilaya\nNilla\nNimfa\nNina\nNinel\nNinelly\nNinety\nNinita\nNinna\nNinola\nNinoska\nNinouska\nNipsey\nNipsy\nNira\nNisha\nNissa\nNita\nNitca\nNitty\nNiurka\nNiva\nNivea\nNixie\nNiya\nNiza\nNo\nNoah\nNocera\nNody\nNoe\nNoel\nNoela\nNoelani\nNoelio\nNoelle\nNoemi\nNoemie\nNoemilk\nNoemy\nNoFaceGirl\nNoira\nNola\nNoleta\nNolita\nNollie\nNoma\nNomi\nNomy\nNona\nNoname\nNoni\nNonna\nNoon\nNora\nNora,\nNorah\nNordica\nNoreen\nNori\nNorina\nNorma\nNorman\nNorth\nNouvelle\nNova\nNovalie\nNovi\nNovia\nNozomi\nNubia\nNubiles\nNuch\nNung\nNury\nNya\nNyah\nNychole\nNyeema\nNyikita\nNyla\nNym\nNyna\nNyomi\nNyrobi\nNysha\nNyusha\nNyx\nOana\nOasis\nObsession\nOcean\nOceane\nOctavia\nOda\nOdara\nOdell\nOdessa\nOdette\nOdile\nOfelia\nOffilia\nOG\nOgzija\nOhana\nOi\nOkami\nOklahoma\nOksana\nOksy\nOktavia\nOla\nOlarita\nOlay\nOldrich\nOle\nOleanna\nOleg\nOleja\nOlena\nOlesia\nOlesya\nOlga\nOlha\nOli\nOlia\nOliana\nOlien\nOlimpia\nOlina\nOliva\nOlive\nOlivia\nOliviana\nOlivie\nOliviya\nOlivya\nOlja\nOlla\nOlli\nOllie\nOllivia\nOlwen\nOlya\nOlympia\nOlympia_C\nOndinne\nOnia\nOnix\nOnna\nOnyx\nOpal\nOphelia\nOphelie\nOprah\nOra\nOrchidea\nOretha\nOrhidea\nOriana\nOriel\nOrika\nOrina\nOrlane\nOrlenda\nOrnelia\nOrnella\nOrsay\nOrsi\nOrsolya\nOrssi\nOrsy\nOrvelia\nOsa\nOscar\nOthelia\nOuan\nOvidie\nOxana\nOxanna\nOxaunna\nOxiana\nOxijana\nOxy\nP.J.\nPabloextra\nPada\nPage\nPageextra\nPaglia\nPahola\nPaige\nPaisley\nPalloma\nPalma\nPaloma\nPalomino\nPalova\nPalva\nPam\nPamela\nPammy\nPandara\nPandora\nPantera\nPanther\nPanthera\nPaola\nPaolina\nPaolla\nPapaya\nParadise\nParis\nParisa\nParish\nParke\nParker\nParticia\nParty\nParvin\nPason\nPassion\nPat\nPatira\nPatricia\nPatricie\nPatrick\nPatricya\nPatrikia\nPatris\nPatrisha\nPatritcy\nPatriza\nPatsy\nPattie\nPatty\nPaty\nPaul\nPaula\nPaulina\nPauline\nPaulinha\nPavla\nPavlina\nPaxton\nPayge\nPayton\nP-Chan\nPD\nPeace\nPeach\nPeaches\nPeachess\nPeachy\nPeanut\nPearl\nPearlin\nPebbles\nPecosa\nPeggy\nPelageya\nPellenia\nPenelopa\npenelope\nPeneloppe\nPenni\npenny\nPepa\nPepper\nPercy\nPerfect\nPeris\nPerla\nPerri\nPerry\nPersia\nPersian\nPersona\nPersuajon\nPersuasion\nPet\nPeta\nPete\nPeter\nPetia\nPetite\nPetka\nPetra\nPetra_C\nPetral\nPetraska\nPetronela\nPetrova\nPetty\nPeyton\nPhelanie\nPheobe\nPheona\nPheonix\nPhil\nPhilipina\nPhilippe\nPhilmore\nPhoebe\nPhoenix\nPhylicia\nPhylisha\nPhyllisha\nPia\nPierre\nPietra\nPiggy\nPike\nPilar\nPim\nPina\npink\nPinkule\nPinky\nPiper\nPippa\nPiros\nPiroshka\nPiroska\nPixi\nPixie\nPixiee\nPixxxi\nPlagebabe\nPlatainito\nPlayful\nPlayfull\nPleasure\nPlenty\nPoca\nPocahontas\nPochontas\nPocket\nPoesha\nPokahontas\nPola\nPoliana\nPolin\nPolina\nPolli\nPolly\nPoly\nPonny\nPoopea\nPop\nPopira\nPoppy\nPorcelain\nPorche\nPorscha\nPorsche\nPorschea\nPorsha\nPortia\nPoteera\nPrada\nPradah\nPraskovia\nPraveena\nPraya\nPrecious\nPreeda\nPregnant\nPrescilia\nPresley\nPressley\nPricilia\nPricilla\nPrima\nPrincess\nPrincessa\nPrincyany\nPrinzzess\nPriscila\nPriscilia\nPriscilla\nPristine\nPriva\nPriya\nProdigy\nPromesita\nPromise\nProxy\nPrycliss\nPryscila\nPublic\nPuma\nPure\nPurl\nPurple\nPusia\nPussika\nPussy\nPussycat\nPussykat\nPusya\nPutri\nPyper\nPyrah\nQueen\nQueeni\nQueenie\nQueenlin\nQuenna\nQuesta\nQuezia\nQuin\nQuincy\nQuinn\nQutie\nRachael\nRachel\nRachel_C\nRachele\nRachelextra\nRachell\nRachelle\nRachida\nRachyda\nRacquel\nRada\nRadina\nRadislava\nRadka\nRadona\nRaduschka\nRady\nRae\nRaeah\nRaeleen\nRaelynn\nRaena\nRafaela\nRafaella\nRafaila\nRaffaella\nRahda\nRahyndee\nRaica\nRaikova\nRailee\nRain\nRaina\nRainbow\nRaine\nRaini\nRainia\nRainy\nRaisa\nRaissa\nRaj\nRakely\nRalina\nRamba\nRambakhsh\nRami\nRamona\nRamu\nRana\nRandee\nRandi\nRandolf\nRandy\nRane\nRangeni\nRani\nRanie\nRaphaela\nRaphaella\nRaquel\nRaquelextra\nRaquelle\nRashae\nRashmika\nRatna\nRaul\nRava\nraven\nRavon\nRavyn\nRay\nRaya\nRayana\nRayann\nRayanne\nRaychel\nRaychelle\nRaye\nRaylan\nRaylene\nRaylin\nRaylyn\nRaylynn\nRayna\nRayne\nRaysa\nRayssa\nRayveness\nRea\nReagan\nReba\nRebbeca\nRebeca\nRebecca\nRebeccablue\nRebeka\nRebekah\nRebel\nRebell\nRebequita\nRed\nRedKiteKat\nRedly\nRee\nReecy\nReeka\nreena\nReese\nRegan\nReggie\nRegi\nRegina\nRegine\nRehtaeh\nRei\nReighlei\nReilly\nReina\nReisha\nReislin\nReka\nRemi\nRemira\nRemmy\nRemy\nRena\nRenae\nRenata\nRenate\nRenatta\nRene\nrenee\nReni\nRenna\nRennata\nReny\nReVAY\nReyka\nReyla\nReyna\nRezika\nRezza\nRharri\nRhaya\nRheanna\nRheina\nRhiana\nRhianna\nRhiannan\nRhiannon\nRhonda\nRhyanna\nRhylee\nRhyse\nRia\nRiana\nRianna\nRiba\nRicarda\nRicardo\nrichelle\nRici\nRick\nRickextra\nRicki\nRickie\nRick-O-Shea\nRidick\nRiesa\nRihana\nRihanna\nRihannon\nRija\nRikki\nRikky\nRilee\nRiley\nRilynn\nRima\nRimma\nRin\nRina\nRinialta\nRio\nRiomarxxx\nRisi\nRisika\nRissa\nRita\nRitta\nRiva\nRiver\nRivera\nRiya\nRiyanna\nRiza\nRizzo\nRobbin\nRobby\nRobbye\nRoberta\nRobin\nRobyn\nRochelle\nRocío\nRock\nRockell\nRocki\nRockie\nRocky\nRococo\nRod\nRodolph\nRoelly\nRoggie\nrogue\nRoka\nRolando\nRoma\nRomana\nRomance\nRomanetta\nRomi\nRomie\nRomina\nRomona\nRomy\nRon\nRonda\nRonita\nRonni\nRonta\nRopebaby\nRoquel\nRorie\nRosa\nRosalia\nRosalie\nRosalina\nRosalinda\nRosaline\nRosalyn\nRosanna\nRosanne\nRosario\nRoscoe\nRose\nRosea\nRose'ana\nRoseanna\nRoseAnne\nRosee\nRoseline\nRosella\nRoselyn\nRosemary\nRoses\nRoshell\nRosie\nRosina\nRosita\nRoss\nRossa\nRossana\nRossanaextra\nRossella\nRosses\nRossinka\nRossis\nRosy\nRowena\nRox\nRoxana\nRoxane\nRoxanna\nRoxanne\nRoxee\nRoxetta\nRoxette\nRoxi\nRoxie\nRoxii\nRoxsy\nRoxxanne\nRoxxi\nRoxxxie\nRoxxxy\nRoxxy\nRoxy\nRoyalty\nRoza\nRozalia\nRozalina\nRozalind\nRozarka\nRozen\nRozita\nRozsa\nRubber\nRubberDoll\nRubby\nRubee\nRubi\nRubin\nRuby\nRucca\nRudy\nRumika\nRusal\nRusalka\nRusanna\nRusita\nRuslana\nRussia\nRusty\nRuta\nRuth\nRuthwas\nRyaan\nRyan\nRyana\nRyann\nRyanna\nryder\nRye\nRylee\nRyley\nRylie\nRylynn\nRyon\nRyta\nRyzele\nRyzell\nS.\nSaana\nSabana\nSaber\nSabian\nSabien\nSabina\nSabine\nSable\nSabree\nSabrena\nSabrin\nSabrina\nSabrina-Jade\nSabrine\nSabrinka\nSabrisse\nSabryna\nSabyne\nSacha\nSade\nsadie\nSadiebanks\nSadine\nSafi\nSafina\nSafira\nSafire\nSafo\nSage\nSahara\nSahenka\nSahily\nSaidat\nSaige\nSailor\nSaki\nSakura\nSalem\nSalena\nSalina\nSalinas\nSalley\nSally\nSalma\nSalome\nSalomi\nSalomja\nSam\nSamali\nSamanta\nSamante\nsamantha\nSambuca\nSami\nSamia\nSamie\nSamilla\nSamira\nSamm\nSammi\nSammie\nSammy\nSammy-Jayne\nSamone\nSamora\nSamuela\nSamy\nSamyra\nSana\nSanaei\nSandee\nSandi\nSandie\nSandora\nSandra\nSandri\nSandrine\nSandro\nSandy\nSandysummers\nSanie\nSanita\nSanity\nSanja\nSanjeit\nSanna\nSanny\nSanta\nSantina\nSany\nSanya\nSaphir\nSaphire\nSapphira\nSapphire\nSara\nsarah\nSarahjo\nSarahsweets\nSarai\nSarala\nSaranah\nSaray\nSarena\nSari\nSaria\nSariah\nSarika\nSarka\nSarolta\nSarra\nSarrah\nSascha\nSasha\nSashaa\nSashenka\nSashia\nSashina\nSaskia\nSassy\nSath\nSati\nSatin\nSatine\nSatiny\nSativa\nSaundra\nSausha\nSava\nSavana\nSavanah\nSavanna\nSavannah\nSavina\nSavvy\nSavy\nSawyer\nSaya\nSayeh\nSayra\nSayuri\nSayurii\nScar\nScaret\nScarlet\nScarlett\nScarlette\nScarlettfay\nScarlit\nScarlotte\nSchilla\nSchlucki\nScott\nScotti\nScyley\nSea\nSean\nSear\nSebass\nSebastiane\nSecille\nSecret\nSeda\nSedona\nSeejulie\nSeka\nSelah\nSelby\nSelena\nSelene\nSelenna\nSeleste\nSelexia\nSelina\nSelita\nSelma\nSelva\nSelvaggia\nSelvagia\nSemija\nSemmi\nSemmie\nSendi\nSendy\nSensai\nSensi\nSensious\nSensual\nSeny\nSenyualo\nSephora\nSeptember\nSequoia\nSera\nSerafima\nSeranade\nSeraphima\nSeraphine\nSeren\nSerena\nSerendipity\nSerene\nSerenity\nSereyna\nSerilla\nSerina\nSerpente\nSerrena\nSeven\nSeverin\nSex\nSexi\nSexis\nSexual\nSexy\nSha\nShae\nShaena\nShafry\nShai\nShaina\nShairy\nShakila\nShakra\nShalina\nShally\nShame\nShana\nShandra\nShane\nShani\nShania\nShanice\nShanie\nShanis\nShanna\nShannon\nShannya\nShanon\nShantal\nShantel\nShanti\nShanty\nShany\nShar\nShara\nShardae\nSharee\nShari\nSharika\nSharin\nSharka\nSharla\nSharon\nSharone\nSharron\nShasha\nShasta\nShataya\nShauna\nShavelle\nShawna\nShawnee\nShawnie\nShay\nShaye\nShayenne\nShayina\nShayla\nShaylen\nShaylene\nShayna\nShayne\nShazia\nShea\nSheala\nSheeba\nSheehan\nSheela\nSheena\nShefali\nSheila\nShelbe\nShelbee\nShelby\nSheli\nShelia\nShellen\nShelley\nShellie\nShelly\nShena\nSheq\nSherazade\nSherazadee\nSheree\nShereese\nSherezade\nSheri\nSherice\nSheridan\nSheril\nSherill\nSherina\nSherly\nSherom\nSheron\nSherri\nSherry\nShery\nSheryl\nShevelle\nSheyla\nSheylley\nShi\nShia\nShiela\nShila\nShilo\nShiloh\nShione\nShira\nShirley\nShiva\nShivay\nSholeh\nShona\nShortie\nShorty\nshrima\nshy\nShyann\nShyla\nShyler\nShylina\nShyne\nShyra\nSia\nSian\nSiarilis\nSiarrs\nSibilla\nSibyl\nSicilia\nSicily\nSid\nSidney\nSidny\nSidonia\nSidonie\nSidra\nSielle\nSiena\nSienna\nSiera\nSierra\nSigal\nSigourney\nSigy\nSigyta\nSiiri\nSila\nSilena\nSilk\nSilky\nSilla\nSilva\nSilvana\nSilveo\nSilver\nSilvia\nSilvie\nSilvija\nSilviya\nSilvy\nSima\nSimella\nSimi\nSimira\nSimmer\nSimon\nSimona\nSimone\nSimonia\nSimony\nSimran\nSina\nSincere\nSincerre\nSindee\nSinderella\nSindi\nSindy\nSinead\nSinful\nSinia\nSinn\nSinnamon\nSinovia\nSinstar\nSintia\nSinty\nSinya\nSiouxsie\nSiraell\nSirale\nSiren\nSirena\nSirenita\nSiri\nSirmione\nSirvi\nSisa\nSisi\nSissy\nSister\nSisy\nSita\nSiya\nSizi\nSkarlett\nSkarlit\nSkarlitt\nSkie\nSkigh\nSkiley\nSkin\nSklya\nSky\nSkye\nSkyeler\nSkyla\nSkylar\nSkyler\nSkylor\nSkylynn\nSkyy\nSkyye\nSlatsjana\nSlava\nSlave\nSlavina\nSlavka\nSlay\nSlevie\nSlight\nSlim\nSloan\nSloane\nSlone\nSlut\nSly\nSmiley\nSmilla\nSmith\nSmokey\nSmokie\nSneila\nSnistcx\nSnoopy\nSnow\nSochee\nSoffia\nSoffie\nSofi\nSofia\nSofía\nSofie\nSofija\nSofy\nSofya\nSohan\nSohley\nSoileda\nSol\nSola\nSolah\nSolana\nSolange\nSolaya\nSolaZola\nSole\nSolei\nSoleil\nSolhey\nSolsa\nSolstice\nSolveig\nSom\nSoma\nSomiet\nSommer\nSona\nSonam\nSondra\nSondrine\nSonechka\nSong\nSonia\nSonita\nSoniy\nSonja\nSonny\nSony\nSonya\nSoolin\nSoon\nSophea\nSophei\nSophia\nSophiana\nSophie\nSophya\nSopia\nSorana\nSoraya\nSorayan\nSotra\nSovereign\nSowan\nSowanna\nSoyivania\nSparkes\nSparky\nSparta\nSpecial\nSpencer\nSpice\nSpicy\nSprenda\nSprinda\nSpring\nSprmda\nSpunky\nSreta\nSsindy\nStacee\nStacey\nStaci\nStacie\nStacy\nStalfra\nStar\nStardom\nStario\nStarla\nStarlett\nStarly\nStarr\nStarri\nStasey\nStasha\nStasia\nStassi\nStasy\nStasya\nStaxxx\nSteadman\nStef\nStefana\nStefani\nStefania\nStefanie\nStefanija\nStefany\nSteffanie\nSteffany\nSteffi\nSteffie\nStefy\nStela\nSteliana\nStella\nStella_C\nStellah\nSteorra\nStepanka\nStepanska\nSteph\nStephani\nStephanie\nStephanies\nStephanna\nStephannie\nStephany\nStephie\nStephy\nStepphanie\nSterling\nStesha\nSteve\nSteven\nSteveo\nStevie\nSthefany\nStiffany\nStock\nStonell\nStoney\nStormy\nStorri\nStorry\nStoya\nStracy\nStrawberry\nStrokahontas\nStunning\nSu\nSubil\nSuckable\nSue\nSuelen\nSuellen\nSugar\nSugian\nSuhaila\nSukanja\nSukanya\nSuki\nSukra\nSully\nSultana\nSumi\nSummer\nSummeran\nSummersilver\nSun\nSundy\nSunisa\nSunni\nSunnie\nSunny\nSunrise\nSunset\nSunshine\nSunshyne\nSuny\nSury\nSurya\nSusa\nSusan\nSusan,\nSusana\nSusane\nSusann\nSusanna\nSusannah\nSusanne\nSusi\nSusian\nSusie\nSusy\nSuwanne\nSuzan\nSuzana\nSuzanna\nSuzanne\nSuze\nSuzette\nSuzi\nSuzie\nSuzumi\nSuzy\nSuzy*\nSveera\nSveta\nSvetik\nSvetlana\nSvitlana\nSwaberry\nSwabery\nSwan\nSwany\nSweet\nSweetie\nSweety\nSwiss\nSybelle\nSybil\nSybille\nSycamore\nSyd\nSydeney\nSydnee\nSydney\nSydonia\nSyesha\nSyl\nSylva\nSylvana\nSylvi\nSylvia\nSylvie\nSymbia\nSymone\nSyndee\nSyndi\nSyndy\nSynthia\nSyren\nSyvally\nSyvette\nSzabina\nSzabo\nSzabrina\nSzabyna\nSzandi\nSzasza\nSzelina\nSzidonia\nSzilvia\nSzindy\nSzofy\nSzofya\nSzonja\nSzuzanne\nSzuzie\nSzuzy\nT.\nT.J.\nT.J.Hart\nTabatha\nTabby\nTabetha\nTabita\nTabitha\nTacori\nTaft\nTafy\nTahlia\nTahlita\nTahnee\nTai\nTaija\nTailor\nTainah\nTaira\nTais\nTaisa\nTaisiya\nTaissia\nTaj\nTaja\nTajza\nTakota\nTakya\nTala\nTalana\nTali\nTalia\nTaliah\nTalin\nTalina\nTalisa\nTalita\nTall\nTallie\nTallulah\nTalula\nTalya\nTam\nTama\nTamar\nTamara\nTamaya\nTamber\nTami\nTamila\nTamiry\nTammi\nTammie\nTammy\nTamra\nTana\nTanata\nTandy\nTanga\nTangi\nTani\nTania\nTanichka\nTaniella\nTanielle\nTanika\nTanita\nTanja\nTank\nTanna\nTanner\nTannermays\nTanvi\nTanya\nTaopus\nTapanga\nTapenga\nTaquila\nTara\nTaraextra\nTarah\nTaralynn\nTareva\nTarja\nTarra\nTarsila\nTaryn\nTasha\nTasia\nTassie\nTasty\nTata\nTatalila\nTatana\nTati\nTatiana\nTatiane\nTatianna\nTatiyana\nTatiyna\nTatjana\nTatty\nTatum\nTatumn\nTaty\nTatyana\nTaurus\nTavalia\nTavia\nTawnee\nTawney\nTawni\nTawny\nTawny-Brie\nTay\nTaya\nTaybre\nTayla\nTaylan\nTaylee\nTayler\nTaylir\nTayllor\nTaylor\nTaylorann\nTaylorextra\nTaysha\nTaytum\nTayza\nTaz\nTchanka\nTD\nTea\nTeacherOfMagic\nTeagan\nTeaganism\nTeagen\nTeal\nTeamuku\nTeana\nTeanna\nTease\nTecey\nTed\nTeddi\nTeddy\nTeegan\nTeekah\nTeela\nTeen\nTeena\nTeenah\nTeera\nTeeta\nTeffany\nTegan\nTellula\nTempe\nTemptation\nTemptress\nTennesse\nTennila\nTeoni\nTequila\nTera\nTeran\nTere\nTerenka\nTerenza\nTeresa\nTeresina\nTeressa\nTeresse\nTereza\nTerezka\nTerezska\nTeri\nTerika\nTerka\nTerra\nTerrance\nTerri\nTerry\nTerryn\nTery\nTesia\nTesla\nTesoro\nTess\nTessa\nTessalia\nTetti\nTetyana\nTexas\nThai\nThaina\nThais\nThaise\nThalia\nThallia\nThatty\nThatyana\nThaylor\nThayna\nThayne\nThe\nThea\nThecla\nTheia\nThemis\nThena\nTheo\nTheodora\nTheona\nThepair\nTheresa\nTherese\nThereza\nTheza\nThia\nThunder\nThundy\nThurzday\nTia\nTiacox\nTialer\nTiana\nTianna\nTiara\nTiaz\nTibby\nTibor\nTidus\nTieler\nTiere\nTierra\nTif\nTifany\nTifereth\nTiff\nTiffan\nTiffanee\nTiffani\nTiffanie\nTiffanny\nTiffany\nTifffany\nTifini\nTiger\nTigerr\nTiggle\nTigra\nTigress\nTihana\nTihanna\nTii\nTila\nTilana\nTilda\nTiler\nTilly\nTimber\nTimea\nTimi\nTimycat\nTina\nTindra\nTini\nTinka\nTinker\nTinkerbell\nTinkerbelle\nTinna\nTinslee\nTiny\nTipsy\nTira\nTiry\nTisa\nTish\nTisha\nTison\nTissy\nTita\nTitiella\nTj\nTobee\nTobi\nTodd\nToken\nTolly\nTom\nToma\nTomi\nTomiko\nTommi\nTommie\nTommy\nTomnat\nTomo\nTomy\nTona\nToni\nTonia\nTonisha\nTony\nTonya\nTootsie\nTopanga\nTopaz\nTopmodel\nTorekeny\nTori\nTorontina\nTorrey\nTorri\nTorrid\nTorrie\nTorry\nTory\nTosh\nTosha\nTosya\nTotally\nTotaly\nTotti\nToxic\nTracey\nTraci\nTracy\nTrana\nTrasy\nTravers\nTreasure\nTrenton\nTresseme\nTressy\nTreza\nTria\nTriada\nTricia\nTricsy\nTrillium\nTrillum\nTrimly\nTrina\nTrinety\nTrinidad\nTriniti\nTrinitie\nTrinity\nTrish\nTrisha\nTrishna\nTrista\nTristal\nTristan\nTristana\nTristano\nTristen\nTristian\nTristina\nTristyn\nTrixi\nTrixie\nTrixx\nTrixy\nTru\nTryme\nTrystan\nTsunami\nTubbea\nTucker\nTuesday\nTuhina\nTulia\nTunde\nTundella\nTweety\nTwiggy\nTwilight\nTwix\nTy\nTya\nTyana\nTyann\nTye\nTyera\nTyextra\nTyffany\nTyger\nTyla\nTylar\nTylene\nTyler\nTylla\nTylo\nTylor\nTyna\nTyra\nTyran\nTysen\nTyung\nUalilou\nUla\nUlia\nUliana\nUliane\nUliya\nUlpiana\nUlrika\nUltima\nUlya\nUlyana\nUma\nUna\nUnique\nUnknown\nUpadhriti\nUrsula\nUsca\nUsha\nUtah\nV\nV.\nVada\nVadmacs\nVai\nVainilla\nVal\nValarie\nValda\nValeli\nValencia\nvalentina\nValentina_C\nValentine\nValeri\nValeria\nValerie\nValeriya\nValery\nValerye\nValeska\nValice\nValika\nVallarie\nVallerie\nValletta\nVally\nValoria\nValory\nValtina\nValya\nVanalika\nVanda\nVaneesa\nVanesa\nVaness\nvanessa\nVanessa**\nVanett\nVanezza\nVania\nVanila\nVanilla\nVanina\nVanita\nVanity\nVanna\nVannah\nVanny\nVany\nVanya\nVarious\nVarla\nVarya\nVashti\nVasilina\nVasilisa\nVasilissa\nVavilia\nVayana\nVedrana\nVee\nVeiki\nVeila\nVelia\nVelicity\nVella\nVelma\nVelonka\nVelvet\nVenday\nVendetta\nVendi\nVendula\nVendy\nVeneisse\nVenera\nVenessa\nVenice\nVenoly\nVentura\nVenus\nVenuse\nVera\nVeranica\nVeranika\nVerasha\nVerbena\nVerena\nVeri\nVero\nVerona\nveronica\nVeronik\nVeronika\nVeronika,\nVeronique\nVerronica\nVerta\nVeruca\nVerunka\nVerushka\nVeryca\nVeryica\nVesna\nVetra\nVia\nViana\nVianey\nVic\nVica\nVicca\nVick\nVicki\nVickie\nVicktoria\nVicky\nVico\nVictori\nVictoria\nVictoriasweet\nVictorie\nVictorija\nVictoriya\nVictory\nVida\nVienna\nViera\nVika\nVikalita\nViki\nViki,\nVikki\nViktoria\nViktorie\nViktorija\nViktorina\nViktoriya\nViktory\nViktorya\nViky\nVila\nVilia\nVilma\nVimala\nVina\nVinette\nVinjera\nVinna\nVinnie\nVio\nViol\nViola\nViolet\nVioleta\nViolett\nVioletta\nVioletta,\nViolette\nViollette\nVionah\nViorica\nViper\nVipera\nVirag\nVirgin\nVirgina\nVirginee\nVirginia\nVirginie\nVirginy\nVirgo\nVisconti\nVishna\nVishra\nVita\nVittoria\nViv\nViva\nVive\nVivi\nVivian\nViviana\nViviane\nVivianee\nViviania\nViviann\nVivianna\nVivianne\nVivica\nVivie\nVivien\nVivienn\nVivienne\nVivika\nVixen\nVixxen\nVixxxen\nVlada\nVladimira\nVladlena\nVlaska\nVlasta\nVlena\nVolkanik\nVos\nVova\nVulpix\nVV\nVyona\nVyvan\nVyxen\nWaitney\nWalda\nWaleria\nWaleska\nWalleria\nWalt\nWalteenie\nWanda\nWandy\nWanessa\nWednesday\nWeed\nWeendy\nWelesa\nWelli\nWelly\nWendee\nWendi\nWendie\nWendy\nWener\nWenessy\nWenona\nWesly\nWest\nWetty\nWhinny\nWhiskey\nWhit\nWhite\nWhitney\nWiana\nWibeke\nWickey\nWild\nWildassholeslut\nWildcat\nWilde\nWildy\nWilhelmina\nWilla\nWillie\nWillow\nWilma\nWilmar\nWinni\nWinnie\nWinny\nWinona\nWinter\nWintersky\nWisha\nWiska\nWithney\nWitta\nWivien\nWren\nWynona\nX\nXana\nXandra\nXandy\nXania\nXara\nXasia\nXaya\nXeena\nXena\nXenia\nXenija\nXenni\nXenta\nXianna\nXica\nXiemena\nXiomara\nX-Lady\nXo\nXotica\nXseila\nXtin\nXvai\nXXX\nXxxena\nXyla\nYahaira\nYahira\nYahra\nYaiselys\nYaisha\nYakima\nYalena\nYamile\nYana\nYanel\nYanet\nYaneta\nYani\nYanie\nYanina\nYanire\nYanka\nYanna\nYannie\nYara\nYarenis\nYari\nYarina\nYarisa\nYarissa\nYasemin\nYasmeen\nYasmeena\nYasmin\nYasmina\nYasmine\nYasmyn\nYasmyne\nYassica\nYaya\nYazmin\nYazmina\nYda\nYekaterina\nYelena\nYeley\nYello\nYemmi\nYena\nYeni\nYenka\nYenna\nYenny\nYesenia\nYesica\nYesina\nYessica\nYeva\nYevanne\nYevonne\nYhivi\nYieva\nYiki\nYillie\nYisela\nYlane\nYlena\nYlia\nYoanna\nYoha\nYohana\nYohane\nYoia\nYola\nYolanda\nYoli\nYolka\nYollanda\nYollanta\nYou\nYsana\nYudita\nYuffie\nYui\nYuki\nYukikon\nYukki\nYulia\nYulia,\nYulianna\nYulie\nYulissa\nYuliya\nYuly\nYulya\nYumi\nYumy\nYuno\nYuri\nYurizan\nYvett\nYvetta\nYvette\nYvone\nYvonne\nYvy\nZ.\nZaawaadi\nZabrina\nZaccara\nZadie\nZadyn\nZafira\nZafiro\nZagri\nZaisha\nZana\nZandra\nZaneta\nZania\nZanita\nZanna\nZara\nZarena\nZarina\nZarrah\nZarreena\nZasha\nZaya\nZayda\nZaylen\nZaza\nZazi\nZazie\nZdenka\nZee\nZeek\nZeina\nZelda\nZelina\nZelma\nZemira\nZena\nZenda\nZenia\nZenya\nZeo\nZerah\nZerella\nZerra\nZeta\nZeyna\nZhang\nZhanna\nZhenya\nZia\nZiba\nZiggy\nZigri\nZilla\nZina\nZinai\nZintra\nZita\nZizi\nZladka\nZlata\nZo\nZoe\nZoé\nZoey\nZofia\nZofka\nZoi\nZoie\nZoie.\nZoila\nZola\nZolvita\nZolvyta\nZoodie\nZooey\nZora\nZorah\nZoraya\nZoryana\nZoy\nZoya\nZoya,\nZsabina\nZsanett\nZsazsa\nZsizsi\nZsofia\nZsu\nZsuja\nZsuza\nZsuzsa\nZsuzsana\nZuana\nZufia\nZuleika\nZuleima\nZuri\nZusie\nZuzana\nZuzanna\nZuzu\nZylona\nZyna\nМonik\n"
  },
  {
    "path": "scripts/test_db_generator/makeTestDB.go",
    "content": "//go:build tools\n// +build tools\n\npackage main\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log\"\n\t\"math\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"gopkg.in/yaml.v2\"\n\n\t\"github.com/stashapp/stash/pkg/file\"\n\t\"github.com/stashapp/stash/pkg/fsutil\"\n\t\"github.com/stashapp/stash/pkg/hash/md5\"\n\t\"github.com/stashapp/stash/pkg/models\"\n\t\"github.com/stashapp/stash/pkg/sliceutil\"\n\t\"github.com/stashapp/stash/pkg/sqlite\"\n\t\"github.com/stashapp/stash/pkg/txn\"\n)\n\nconst batchSize = 50000\n\n// create an example database by generating a number of scenes, markers,\n// performers, studios, galleries, chapters and tags, and associating between them all\n\ntype config struct {\n\tDatabase   string       `yaml:\"database\"`\n\tScenes     int          `yaml:\"scenes\"`\n\tMarkers    int          `yaml:\"markers\"`\n\tImages     int          `yaml:\"images\"`\n\tGalleries  int          `yaml:\"galleries\"`\n\tChapters   int          `yaml:\"chapters\"`\n\tPerformers int          `yaml:\"performers\"`\n\tStudios    int          `yaml:\"studios\"`\n\tTags       int          `yaml:\"tags\"`\n\tNaming     namingConfig `yaml:\"naming\"`\n}\n\nvar (\n\trepo     models.Repository\n\tc        *config\n\tdb       *sqlite.Database\n\tfolderID file.FolderID\n)\n\nfunc main() {\n\trand.Seed(time.Now().UnixNano())\n\n\tvar err error\n\tc, err = loadConfig()\n\tif err != nil {\n\t\tlog.Fatalf(\"couldn't load configuration: %v\", err)\n\t}\n\n\tinitNaming(*c)\n\n\tdb = sqlite.NewDatabase()\n\trepo = db.TxnRepository()\n\n\tlogf(\"Initializing database...\")\n\tif err = db.Open(c.Database); err != nil {\n\t\tlog.Fatalf(\"couldn't initialize database: %v\", err)\n\t}\n\tlogf(\"Populating database...\")\n\tpopulateDB()\n}\n\nfunc loadConfig() (*config, error) {\n\tret := &config{}\n\n\tfile, err := os.Open(\"config.yml\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\tparser := yaml.NewDecoder(file)\n\tparser.SetStrict(true)\n\terr = parser.Decode(&ret)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc populateDB() {\n\tmakeTags(c.Tags)\n\tmakeStudios(c.Studios)\n\tmakePerformers(c.Performers)\n\tmakeScenes(c.Scenes)\n\tmakeImages(c.Images)\n\tmakeGalleries(c.Galleries)\n\tmakeChapters(c.Chapters)\n\tmakeMarkers(c.Markers)\n}\n\nfunc withTxn(f func(ctx context.Context) error) error {\n\treturn txn.WithTxn(context.Background(), db, f)\n}\n\nfunc retry(attempts int, fn func() error) error {\n\tvar err error\n\tfor tries := 0; tries < attempts; tries++ {\n\t\terr = fn()\n\t\tif err == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn err\n}\n\nfunc getOrCreateFolder(ctx context.Context, p string) (*file.Folder, error) {\n\tret, err := repo.Folder.FindByPath(ctx, p)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif ret != nil {\n\t\treturn ret, nil\n\t}\n\n\tvar parentID *file.FolderID\n\n\tif p != \".\" {\n\t\tparent := path.Dir(p)\n\t\tparentFolder, err := getOrCreateFolder(ctx, parent)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tparentID = &parentFolder.ID\n\t}\n\n\tf := file.Folder{\n\t\tPath:           p,\n\t\tParentFolderID: parentID,\n\t}\n\n\tif err := repo.Folder.Create(ctx, &f); err != nil {\n\t\treturn nil, err\n\t}\n\n\tret = &f\n\treturn ret, nil\n}\n\nfunc makeTags(n int) {\n\tlogf(\"creating %d tags...\", n)\n\tfor i := 0; i < n; i++ {\n\t\tif err := retry(100, func() error {\n\t\t\treturn withTxn(func(ctx context.Context) error {\n\t\t\t\tname := names[c.Naming.Tags].generateName(1)\n\t\t\t\ttag := models.Tag{\n\t\t\t\t\tName: name,\n\t\t\t\t}\n\n\t\t\t\tcreated, err := repo.Tag.Create(ctx, tag)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif rand.Intn(100) > 5 {\n\t\t\t\t\tt, _, err := repo.Tag.Query(ctx, nil, getRandomFilter(1))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\n\t\t\t\t\tif len(t) > 0 && t[0].ID != created.ID {\n\t\t\t\t\t\tif err := repo.Tag.UpdateParentTags(ctx, created.ID, []int{t[0].ID}); err != nil {\n\t\t\t\t\t\t\treturn err\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n}\n\nfunc makeStudios(n int) {\n\tlogf(\"creating %d studios...\", n)\n\tfor i := 0; i < n; i++ {\n\t\tif err := retry(100, func() error {\n\t\t\treturn withTxn(func(ctx context.Context) error {\n\t\t\t\tname := names[c.Naming.Tags].generateName(rand.Intn(5) + 1)\n\t\t\t\tstudio := models.Studio{\n\t\t\t\t\tName:     sql.NullString{String: name, Valid: true},\n\t\t\t\t\tChecksum: md5.FromString(name),\n\t\t\t\t}\n\n\t\t\t\tif rand.Intn(100) > 5 {\n\t\t\t\t\tss, _, err := repo.Studio.Query(ctx, nil, getRandomFilter(1))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\n\t\t\t\t\tif len(ss) > 0 {\n\t\t\t\t\t\tstudio.ParentID = sql.NullInt64{\n\t\t\t\t\t\t\tInt64: int64(ss[0].ID),\n\t\t\t\t\t\t\tValid: true,\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t_, err := repo.Studio.Create(ctx, studio)\n\t\t\t\treturn err\n\t\t\t})\n\t\t}); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n}\n\nfunc makePerformers(n int) {\n\tlogf(\"creating %d performers...\", n)\n\tfor i := 0; i < n; i++ {\n\t\tif err := retry(100, func() error {\n\t\t\treturn withTxn(func(ctx context.Context) error {\n\t\t\t\tname := generatePerformerName()\n\t\t\t\tperformer := &models.Performer{\n\t\t\t\t\tName:     name,\n\t\t\t\t\tChecksum: md5.FromString(name),\n\t\t\t\t}\n\n\t\t\t\t// TODO - set tags\n\n\t\t\t\terr := repo.Performer.Create(ctx, performer)\n\t\t\t\tif err != nil {\n\t\t\t\t\terr = fmt.Errorf(\"error creating performer with name: %s: %s\", performer.Name, err.Error())\n\t\t\t\t}\n\t\t\t\treturn err\n\t\t\t})\n\t\t}); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n}\n\nfunc generateBaseFile(parentFolderID file.FolderID, path string) *file.BaseFile {\n\treturn &file.BaseFile{\n\t\tBasename:       path,\n\t\tParentFolderID: parentFolderID,\n\t\tFingerprints: []file.Fingerprint{\n\t\t\tfile.Fingerprint{\n\t\t\t\tType:        \"md5\",\n\t\t\t\tFingerprint: md5.FromString(path),\n\t\t\t},\n\t\t\tfile.Fingerprint{\n\t\t\t\tType:        \"oshash\",\n\t\t\t\tFingerprint: md5.FromString(path),\n\t\t\t},\n\t\t},\n\t\tCreatedAt: time.Now(),\n\t\tUpdatedAt: time.Now(),\n\t}\n}\n\nfunc generateVideoFile(parentFolderID file.FolderID, path string) file.File {\n\tw, h := getResolution()\n\n\treturn &file.VideoFile{\n\t\tBaseFile: generateBaseFile(parentFolderID, path),\n\t\tDuration: rand.Float64() * 14400,\n\t\tHeight:   h,\n\t\tWidth:    w,\n\t}\n}\n\nfunc makeVideoFile(ctx context.Context, path string) (file.File, error) {\n\tfolderPath := fsutil.GetIntraDir(path, 2, 2)\n\tparentFolder, err := getOrCreateFolder(ctx, folderPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tf := generateVideoFile(parentFolder.ID, path)\n\n\tif err := repo.File.Create(ctx, f); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn f, nil\n}\n\nfunc logf(f string, args ...interface{}) {\n\tlog.Printf(f+\"\\n\", args...)\n}\n\nfunc makeScenes(n int) {\n\tlogf(\"creating %d scenes...\", n)\n\tfor i := 0; i < n; {\n\t\t// do in batches of 1000\n\t\tbatch := i + batchSize\n\n\t\tif err := withTxn(func(ctx context.Context) error {\n\t\t\tfor ; i < batch && i < n; i++ {\n\t\t\t\tscene := generateScene(i)\n\t\t\t\tscene.StudioID = getRandomStudioID(ctx)\n\t\t\t\tmakeSceneRelationships(ctx, &scene)\n\n\t\t\t\tpath := md5.FromString(\"scene/\" + strconv.Itoa(i))\n\t\t\t\tf, err := makeVideoFile(ctx, path)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := repo.Scene.Create(ctx, &scene, []file.ID{f.Base().ID}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tlogf(\"... created %d scenes\", i)\n\t}\n}\n\nfunc getResolution() (int, int) {\n\tres := models.AllResolutionEnum[rand.Intn(len(models.AllResolutionEnum))]\n\th := res.GetMaxResolution()\n\tvar w int\n\tif h == 240 || h == 480 || rand.Intn(10) == 9 {\n\t\tw = h * 4 / 3\n\t} else {\n\t\tw = h * 16 / 9\n\t}\n\n\tif rand.Intn(10) == 9 {\n\t\treturn h, w\n\t}\n\n\treturn w, h\n}\n\nfunc getBool() {\n\treturn rand.Intn(2) == 0\n}\n\nfunc getDate() time.Time {\n\ts := rand.Int63n(time.Now().Unix())\n\n\treturn time.Unix(s, 0)\n}\n\nfunc generateScene(i int) models.Scene {\n\treturn models.Scene{\n\t\tTitle: names[c.Naming.Scenes].generateName(rand.Intn(7) + 1),\n\t\tDate: &models.Date{\n\t\t\tTime: getDate(),\n\t\t},\n\t\tCreatedAt: time.Now(),\n\t\tUpdatedAt: time.Now(),\n\t}\n}\n\nfunc generateImageFile(parentFolderID file.FolderID, path string) file.File {\n\tw, h := getResolution()\n\n\treturn &file.ImageFile{\n\t\tBaseFile: generateBaseFile(parentFolderID, path),\n\t\tHeight:   h,\n\t\tWidth:    w,\n\t\tClip:     getBool(),\n\t}\n}\n\nfunc makeImageFile(ctx context.Context, path string) (file.File, error) {\n\tfolderPath := fsutil.GetIntraDir(path, 2, 2)\n\tparentFolder, err := getOrCreateFolder(ctx, folderPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tf := generateImageFile(parentFolder.ID, path)\n\n\tif err := repo.File.Create(ctx, f); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn f, nil\n}\n\nfunc makeImages(n int) {\n\tlogf(\"creating %d images...\", n)\n\tfor i := 0; i < n; {\n\t\t// do in batches of 1000\n\t\tbatch := i + batchSize\n\t\tif err := withTxn(func(ctx context.Context) error {\n\t\t\tfor ; i < batch && i < n; i++ {\n\t\t\t\timage := generateImage(i)\n\t\t\t\timage.StudioID = getRandomStudioID(ctx)\n\t\t\t\tmakeImageRelationships(ctx, &image)\n\n\t\t\t\tpath := md5.FromString(\"image/\" + strconv.Itoa(i))\n\t\t\t\tf, err := makeImageFile(ctx, path)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := repo.Image.Create(ctx, &models.ImageCreateInput{\n\t\t\t\t\tImage:   &image,\n\t\t\t\t\tFileIDs: []file.ID{f.Base().ID},\n\t\t\t\t}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlogf(\"... created %d images\", i)\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n}\n\nfunc generateImage(i int) models.Image {\n\treturn models.Image{\n\t\tTitle:     names[c.Naming.Images].generateName(rand.Intn(7) + 1),\n\t\tCreatedAt: time.Now(),\n\t\tUpdatedAt: time.Now(),\n\t}\n}\n\nfunc makeGalleries(n int) {\n\tlogf(\"creating %d galleries...\", n)\n\tfor i := 0; i < n; {\n\t\t// do in batches of 1000\n\t\tbatch := i + batchSize\n\n\t\tif err := withTxn(func(ctx context.Context) error {\n\t\t\tfor ; i < batch && i < n; i++ {\n\t\t\t\tgallery := generateGallery(i)\n\t\t\t\tgallery.StudioID = getRandomStudioID(ctx)\n\t\t\t\tgallery.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 0, 15))\n\t\t\t\tgallery.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx))\n\n\t\t\t\tpath := md5.FromString(\"gallery/\" + strconv.Itoa(i))\n\t\t\t\tf, err := makeZipFile(ctx, path)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif err := repo.Gallery.Create(ctx, &gallery, []file.ID{f.Base().ID}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tmakeGalleryRelationships(ctx, &gallery)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tlogf(\"... created %d galleries\", i)\n\t}\n}\n\nfunc generateZipFile(parentFolderID file.FolderID, path string) file.File {\n\treturn generateBaseFile(parentFolderID, path)\n}\n\nfunc makeZipFile(ctx context.Context, path string) (file.File, error) {\n\tfolderPath := fsutil.GetIntraDir(path, 2, 2)\n\tparentFolder, err := getOrCreateFolder(ctx, folderPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tf := generateZipFile(parentFolder.ID, path)\n\n\tif err := repo.File.Create(ctx, f); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn f, nil\n}\n\nfunc generateGallery(i int) models.Gallery {\n\treturn models.Gallery{\n\t\tTitle: names[c.Naming.Galleries].generateName(rand.Intn(7) + 1),\n\t\tDate: &models.Date{\n\t\t\tTime: getDate(),\n\t\t},\n\t\tCreatedAt: time.Now(),\n\t\tUpdatedAt: time.Now(),\n\t}\n}\n\nfunc makeChapters(n int) {\n\tlogf(\"creating %d chapters...\", n)\n\tfor i := 0; i < n; {\n\t\t// do in batches of 1000\n\t\tbatch := i + batchSize\n\t\tif err := withTxn(func(ctx context.Context) error {\n\t\t\tfor ; i < batch && i < n; i++ {\n\t\t\t\tchapter := generateChapter(i)\n\t\t\t\tchapter.GalleryID = models.NullInt64(int64(getRandomGallery()))\n\n\t\t\t\tcreated, err := repo.GalleryChapter.Create(ctx, chapter)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlogf(\"... created %d chapters\", i)\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n}\n\nfunc generateChapter(i int) models.GalleryChapter {\n\treturn models.GalleryChapter{\n\t\tTitle:      names[c.Naming.Galleries].generateName(rand.Intn(7) + 1),\n\t\tImageIndex: rand.Intn(200),\n\t}\n}\n\nfunc makeMarkers(n int) {\n\tlogf(\"creating %d markers...\", n)\n\tfor i := 0; i < n; {\n\t\t// do in batches of 1000\n\t\tbatch := i + batchSize\n\t\tif err := withTxn(func(ctx context.Context) error {\n\t\t\tfor ; i < batch && i < n; i++ {\n\t\t\t\tmarker := generateMarker(i)\n\t\t\t\tmarker.SceneID = models.NullInt64(int64(getRandomScene()))\n\t\t\t\tmarker.PrimaryTagID = getRandomTags(ctx, 1, 1)[0]\n\n\t\t\t\tcreated, err := repo.SceneMarker.Create(ctx, marker)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\ttags := getRandomTags(ctx, 0, 5)\n\t\t\t\t// remove primary tag\n\t\t\t\ttags = sliceutil.Exclude(tags, []int{marker.PrimaryTagID})\n\t\t\t\tif err := repo.SceneMarker.UpdateTags(ctx, created.ID, tags); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlogf(\"... created %d markers\", i)\n\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n}\n\nfunc generateMarker(i int) models.SceneMarker {\n\treturn models.SceneMarker{\n\t\tTitle: names[c.Naming.Scenes].generateName(rand.Intn(7) + 1),\n\t}\n}\n\nfunc getRandomFilter(n int) *models.FindFilterType {\n\tseed := math.Floor(rand.Float64() * math.Pow10(8))\n\tsortBy := fmt.Sprintf(\"random_%.f\", seed)\n\treturn &models.FindFilterType{\n\t\tSort:    &sortBy,\n\t\tPerPage: &n,\n\t}\n}\n\nfunc getRandomStudioID(ctx context.Context) *int {\n\tif rand.Intn(10) == 0 {\n\t\treturn nil\n\t}\n\n\t// s, _, err := r.Studio().Query(nil, getRandomFilter(1))\n\t// if err != nil {\n\t// \tpanic(err)\n\t// }\n\n\tv := rand.Intn(c.Studios) + 1\n\treturn &v\n}\n\nfunc makeSceneRelationships(ctx context.Context, s *models.Scene) {\n\t// add tags\n\ts.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 0, 15))\n\n\t// add performers\n\ts.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx))\n}\n\nfunc makeImageRelationships(ctx context.Context, i *models.Image) {\n\t// there are typically many more images. For performance reasons\n\t// only a small proportion should have tags/performers\n\n\t// add tags\n\tif rand.Intn(100) == 0 {\n\t\ti.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 1, 15))\n\t}\n\n\t// add performers\n\tif rand.Intn(100) <= 1 {\n\t\ti.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx))\n\t}\n}\n\nfunc makeGalleryRelationships(ctx context.Context, g *models.Gallery) {\n\t// add images\n\timageIDs := getRandomImages(ctx)\n\tif len(imageIDs) > 0 {\n\t\tif err := repo.Gallery.UpdateImages(ctx, g.ID, imageIDs); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n}\n\nfunc getRandomPerformers(ctx context.Context) []int {\n\tn := rand.Intn(5)\n\n\tvar ret []int\n\t// if n > 0 {\n\t// \tp, _, err := r.Performer().Query(nil, getRandomFilter(n))\n\t// \tif err != nil {\n\t// \t\tpanic(err)\n\t// \t}\n\n\t// \tfor _, pp := range p {\n\t// \t\tret = sliceutil.AppendUnique(ret, pp.ID)\n\t// \t}\n\t// }\n\n\tfor i := 0; i < n; i++ {\n\t\tret = sliceutil.AppendUnique(ret, rand.Intn(c.Performers)+1)\n\t}\n\n\treturn ret\n}\n\nfunc getRandomScene() int {\n\treturn rand.Intn(c.Scenes) + 1\n}\n\nfunc getRandomGallery() int {\n\treturn rand.Intn(c.Galleries) + 1\n}\n\nfunc getRandomTags(ctx context.Context, min, max int) []int {\n\tvar n int\n\tif min == max {\n\t\tn = min\n\t} else {\n\t\tn = rand.Intn(max-min) + min\n\t}\n\n\tvar ret []int\n\t// if n > 0 {\n\t// \tt, _, err := r.Tag().Query(nil, getRandomFilter(n))\n\t// \tif err != nil {\n\t// \t\tpanic(err)\n\t// \t}\n\n\t// \tfor _, tt := range t {\n\t// \t\tret = sliceutil.AppendUnique(ret, tt.ID)\n\t// \t}\n\t// }\n\n\tfor i := 0; i < n; i++ {\n\t\tret = sliceutil.AppendUnique(ret, rand.Intn(c.Tags)+1)\n\t}\n\n\treturn ret\n}\n\nfunc getRandomImages(ctx context.Context) []int {\n\tn := rand.Intn(500)\n\n\tvar ret []int\n\t// if n > 0 {\n\t// \tt, _, err := r.Image().Query(nil, getRandomFilter(n))\n\t// \tif err != nil {\n\t// \t\tpanic(err)\n\t// \t}\n\n\t// \tfor _, tt := range t {\n\t// \t\tret = sliceutil.AppendUnique(ret, tt.ID)\n\t// \t}\n\t// }\n\n\tfor i := 0; i < n; i++ {\n\t\tret = sliceutil.AppendUnique(ret, rand.Intn(c.Images)+1)\n\t}\n\n\treturn ret\n}\n"
  },
  {
    "path": "scripts/test_db_generator/male.txt",
    "content": "A.J.\nAarin\nAaron\nAbdul\nAbe\nAbel\nAbraham\nAbu\nAce\nAchil\nAdam\nAddison\nAden\nAdiel\nAdonis\nAdria\nAdrian\nAdriana\nAdrianno\nAdriano\nAdrielly\nAds\nAdvik\nAgatha\nAgattha\nAgent\nAgo\nAgs\nAhmed\nAidan\nAiden\nAirin\nAiumy\nAj\nAjay\nAK\nAkos\nAl\nAlain\nAlam\nAlan\nAlaska\nAlb\nAl-b\nAlbert\nAlberto\nAldo\nAlec\nAleck\nAlecmiller\nAlejandro\nAlek\nAleks\nAlen\nAlesandra\nAlessandro\nAlessia\nAlessio\nAlex\nAlex****\nAlexander\nAlexandre\nAlexei\nAlexia\nAlexis\nAlexsander\nAlexx\nAlexxx\nAlexy\nAlfie\nAlfred\nAlfredo\nAli\nAline\nAllan\nAllen\nAlonzo\nAlpacabowel\nAlrik\nAlson\nAmanda\nAmandha\nAmani\nAmartinsa\nAmaya\nAmelio\nAmir\nAmos\nAms\nAnaconda\nAnders\nAnderson\nAndre\nAndrea\nAndreas\nAndreia\nAndrej\nAndres\nAndressa\nAndrew\nAndrey\nAndreya\nAndrezza\nAndro\nAndron\nAndy\nAngel\nAngelly\nAngelo\nAnita\nAnonymous\nAnthonie\nAnthony\nAntoine\nAnton\nAntoni\nAntonio\nAnubis\nAnzus\nApollo\nAquiles\nArad\nAras\nArcanjo\nArcher\nArchie\nAretha\nArgo\nArgos\nAri\nAries\nArmando\nArmond\nArnie\nArnold\nArny\nAron\nArpad\nArt\nArtem\nArthur\nArtur\nArturo\nAsa\nAsante\nAsher\nAshland\nAshton\nAslan\nAssassin\nAston\nAstral\nAtlanta\nAtreyu\nAtticus\nAugust\nAustin\nAvangard\nAvatar\nAvenger\nAvery\nAxe\nAxel\nAxil\nAxl\nAyden\nAyer\nAymeric\nBackey\nBad\nBadger\nBady\nBaily\nBalls\nBalu\nBama\nBambino\nBar\nBarbara\nBarret\nBarrett\nBarron\nBarry\nBart\nBash\nBasil\nBastien\nBazuca\nBear\nBeatriiz\nBeau\nBeaux\nBebel\nBegasus\nBellamy\nBen\nBenie\nBenito\nBenjamin\nBenn\nBennett\nBenny\nBenson\nBentley\nBenton\nBerke\nBia\nBianca\nBiannca\nBiff\nBig\nBigdick\nBigg\nBiggz\nBignj\nBill\nBillie\nBilly\nBily\nBinx\nBishop\nBj\nBlack\nBlaine\nBlak\nBlake\nBlanca\nBlaze\nBlonder\nBls\nBlu\nBlue\nBnasty\nBo\nBob\nBobbie\nBobby\nBobi\nBoby\nBogdan\nBone-O\nBoner\nBony\nBoomer\nBoris\nBoston\nBostonbrawler\nBottom\nBoxer\nBoyce\nBoyd\nBrad\nBraden\nBradford\nBradley\nBradly\nBradon\nBrady\nBrain\nBran\nBrando\nBrandon\nBrannon\nBranson\nBrant\nBrat\nBravo\nBraxton\nBrayden\nBrayen\nBreezy\nBrendan\nBrenden\nBrendon\nBrenn\nBrennan\nBrenner\nBrennon\nBrent\nBrett\nBreyden\nBrian\nBrice\nBrick\nBrickzilla\nBrisa\nBritan\nBroc\nBrock\nBroden\nBroderick\nBrodie\nBrody\nBrogan\nBrooks\nBrother\nBruce\nBruna\nBrunna\nBruno\nBryan\nBryant\nBryce\nBrycen\nBrysen\nBryson\nBt\nBuck\nBud\nBuddy\nBum\nBurke\nBurton\nBuster\nButch\nByonda\nByron\nC.\nC.J.\nCabrera\nCade\nCaden\nCaesar\nCage\nCail\nCailean\nCain\nCaio\nCal\nCaleb\nCalhoun\nCalixto\nCallum\nCalvin\nCam\nCamden\nCameron\nCamila\nCamilly\nCampbell\nCan\nCano\nCapoeira\nCaptain\nCarl\nCarla\nCarlita\nCarlo\nCarlos\nCarlton\nCarmello\nCarole\nCarrol\nCarson\nCarter\nCary\nCasey\nCash\nCasper\nCassian\nCassidy\nCastro\nCavin\nCayden\nCazden\nCc\nCedric\nCesar\nChad\nChance\nChandler\nChapels\nCharlamagne\nCharles\nCharley\nCharlie\nChase\nChawane\nChayenne\nChaz\nChelsia\nChester\nChet\nCheyne\nChico\nChikito\nChiky\nChilli\nChip\nChoky\nChose\nChris\nChriss\nChristian\nChristoph\nChristopher\nChristos\nChuck\nChucky\nChulo\nCiccle\nCintia\nCj\nCK\nClark\nClarke\nClaude\nClaudea\nClaudio\nClaudya\nClay\nClayton\nCliff\nClint\nClinton\nClover\nClyde\nCoach\nCoby\nCodey\nCodi\nCody\nCoen\nColby\nColden\nCole\nColeman\nColin\nCollin\nCollins\nColm\nColt\nColton\nCommando\nConer\nConner\nConnor\nConny\nConor\nConrad\nCooper\nCorbin\nCorey\nCort\nCorvin\nCory\nCoty\nCoudy\nCousin\nCraig\nCraven\nCris\nCriss\nCristian\nCristiane\nCruize\nCruz\nCsaba\nCsoky\nCt\nCuntre\nCurt\nCurtis\nCutler\nCyara\nCybelle\nCyrus\nD\nD.\nD.Arclyte\nD.O.\nD2\nDacotah\nDada\nDaddy\nDade\nDak\nDakota\nDalas\nDale\nDallas\nDalton\nDamaso\nDamian\nDamien\nDamion\nDamon\nDan\nDanarama\nDane\nDanelle\nD'Angelo\nDani\nDaniel\nDaniela\nDanii\nDanko\nDann\nDanny\nDante\nDant'e\nDanton\nDany\nDanyela\nDaphynne\nDarcy\nDarell\nDaren\nDarian\nDarin\nDario\nDarius\nDarko\nDarnell\nDarrel\nDarrell\nDarren\nDarron\nDarryl\nDarwin\nDaryl\nDato\nDave\nDavey\nDavid\nDavida\nDavide\nDavie\nDavin\nDavis\nDawny\nDax\nDaxx\nDayane\nDayanna\nDaymin\nDayn\nDayton\nDeacon\nDeAiro\nDean\nDeAngelo\nDeclan\nDeep\nDeezki\nDellon\nDemetri\nDemetrixxx\nDemond\nDen\nDeniro\nDenis\nDenis*\nDennis\nDenny\nDenson\nDenton\nDeny\nDereck\nDerek\nDerick\nDermott\nDeron\nDerrek\nDerrell\nDerrick\nDeshaun\nDeshawn\nDesmond\nDeston\nDeviant\nDevils\nDevin\nDevlin\nDevon\nDex\nDexter\nDexx\nDhones\nDiablo\nDick\nDiego\nDiesel\nDieter\nDiether\nDietrich\nDiezel\nDillan\nDillion\nDillon\nDimitri\nDimitry\nDingo\nDinio\nDino\nDion\nDirk\nDirty\nDixon\nDizzy\nDj\nDolan\nDolce\nDolche\nDolf\nDom\nDomenic\nDomenico\nDomineko\nDominic\nDominik\nDominique\nDomonic\nDomonique\nDon\nDonald\nDonato\nDonnie\nDonnovan\nDonny\nDonovan\nDonte\nDorian\nDoug\nDougie\nDouglas\nDrago\nDragon\nDrak\nDrake\nDraven\nDredd\nDrehyden\nDrew\nDriely\nDrnasty\nDru\nDsnoop\nDuane\nDuda\nDuke\nDuncan\nDustin\nDusty\nDustyn\nDutch\nDvda\nDviano\nDwayne\nDwight\nDylan\nE.J.\nEamon\nEarl\nEd\nEdan\nEddie\nEddiie\nEddy\nEdin\nEdison\nEdmond\nEdu\nEduard\nEduardo\nEdward\nEdwin\nEj\nEkzavir\nEl\nEli\nEliesky\nElijah\nElio\nElisha\nElliot\nElliott\nEllis\nElmilio\nElton\nEly\nElye\nEmanuel\nEmerg\nEmerge\nE-MERGE\nEmerson\nEmi\nEmilio\nEmilly\nEmir\nEmmanuel\nEmmett\nEmoke\nEmory\nEnio\nEnrico\nEnrique\nEnzo\nEr\nEric\nErick\nErik\nErika\nErin\nErnie\nErob\nEros\nErycka\nEsteban\nEthan\nEtienne\nEugene\nEvan\nEvec\nEverett\nEviie\nEvo\nEwan\nEzra\nFabiana\nFabio\nFabiola\nFabricia\nFabrizio\nFabyana\nFalco\nFalcon\nFame\nFarell\nFatimah\nFaube\nFaun\nFausto\nFavio\nFehu\nFelipe\nFelishia\nFelix\nFena\nFenomen\nFer\nFerdinando\nFerenc\nFernanda\nFernando\nFigi\nFilippo\nFilthy\nFingaz\nFinn\nFlash\nFlex\nFlexx\nFlipper\nFloyd\nFly\nFlynt\nFord\nFornaro\nForrest\nFoster\nFox\nFran\nFranc\nFrancesco\nFrancine\nFrancis\nFrancisco\nFranck\nFranco\nFrancois\nFrank\nFrankie\nFranklin\nFranko\nFranky\nFred\nFreddie\nFreddy\nFrederic\nFrederick\nFredrick\nFrei\nFrenk\nFrenky\nFrew\nFuller\nGabe\nGaberial\nGabor\nGabriel\nGabriela\nGabriella\nGabriely\nGabryela\nGael\nGage\nGalen\nGang\nGareth\nGarett\nGarin\nGarren\nGarrett\nGarry\nGarth\nGary\nGator\nGaucho\nGavi\nGavin\nGaz\nGC\nGemini\nGene\nGenerallee\nGeo\nGeoff\nGeoffrey\nGeorge\nGeorgie\nGeorgio\nGeri\nGerry\nGerson\nGery\nGG\nGiacomo\nGianni\nGiany\nGideon\nGilberto\nGino\nGio\nGiorgio\nGiovanni\nGirth\nGisele\nGleica\nGleice\nGlen\nGlenn\nGodiva\nGold\nGonzo\nGood\nGoran\nGordon\nGraham\nGraicy\nGrandpa\nGrant\nGrayson\nGrazieli\nGreg\nGregg\nGregor\nGregory\nGreyson\nGriffin\nGruff\nGuadalupe\nGuatemal\nGuerimca\nGuillaume\nGunner\nGunter\nGus\nGustav\nGuy\nGyovanna\nH3ll4Sl00tz\nHacan\nHaghata\nHagi\nHaigen\nHakan\nHal\nHammer\nHank\nHans\nHanz\nHarisson\nHarley\nHarmon\nHarris\nHarrison\nHarry\nHart\nHarvey\nHatman\nHayden\nHayes\nHeath\nHector\nHefty\nHelen\nHeloiza\nHendrick\nHenier\nHenry\nHerald\nHerschel\nHez\nHijo\nHilda\nHobie\nHolden\nHoly\nHomer\nHooks\nHorse\nHorus\nHotkarl\nHoward\nHowie\nHoyt\nHoytt\nHudson\nHugh\nHugo\nHumberto\nHung\nHunter\nHygor\nIan\nIbe\nIce\nIgor\nIke\nIl\nIlan\nIllz\nImmanuel\nIndiana\nIntrigue\nIpauta\nIrv\nIsaac\nIsabele\nIsabella\nIsabelle\nIsabelly\nIsabely\nIsaiah\nIsiah\nIsrael\nIssac\nIvan\nIvo\nIzaak\nIzadora\nJ\nJ.\nJ.J\nJ.J.\nJ.P\nJ.R.\nJ.T.\nJac\nJace\nJacen\nJack\nJack23\nJackeline\nJackie\nJackk\nJacklyne\nJackson\nJacob\nJacques\nJade\nJaden\nJadyn\nJae\nJael\nJag\nJaime\nJair\nJaison\nJake\nJakob\nJalif\nJalil\nJamal\nJames\nJames*\nJameson\nJamie\nJamison\nJan\nJanily\nJanira\nJanos\nJarec\nJared\nJarek\nJarod\nJarret\nJarrod\nJason\nJasper\nJasten\nJavier\nJavy\nJax\nJaxon\nJaxton\nJaxx\nJaxxx\nJay\nJayce\nJayden\nJaymus\nJayson\nJazz\nJB\nJbrown\nJc\nJD\nJean\nJean-Claude\nJean-Luc\nJean-Pierre\nJean-Yves\nJeb\nJed\nJeff\nJeffrey\nJenaveve\nJener\nJenner\nJennifer\nJeovanni\nJeph\nJeremey\nJeremiah\nJeremie\nJeremy\nJericob\nJermaine\nJermal\nJerome\nJerry\nJesse\nJessie\nJessy\nJesus\nJet\nJett\nJeyden\nJez\nJhenifer\nJhon\nJhonny\nJhony\nJigz\nJim\nJimmie\nJimmy\nJiri\nJj\nJlee\njmac\nJ-Mac\nJoachim\nJoaquin\nJodi\nJodie\nJoe\nJoel\nJoemac\nJoey\nJohan\nJohann\nJohn\nJohnathan\nJohnathn\nJohnathon\nJohnny\nJohny\nJohnyy\nJon\nJonah\nJonan\nJonas\nJonathan\nJones\nJonna\nJonni\nJonny\nJordan\nJordano\nJorden\nJordi\nJorge\nJose\nJoseph\nJosh\nJoshua\nJoshva\nJosiah\nJoss\nJovan\nJoy\nJoyce\nJp\nJr\nJrock\nJt\nJuan\nJuani\nJuanito\nJudas\nJudass\nJudd\nJude\nJules\nJulian\nJuliana\nJulianne\nJuliano\nJulio\nJulius\nJun\nJune's\nJunior\nJurek\nJust\nJusten\nJustice\nJustin\nJusty\nJuuh\nK\nK.\nK.D.\nKace\nKade\nKaden\nKadu\nKaelon\nKaffy\nKai\nKaike\nKaikie\nKaleb\nKalena\nKam\nKamil\nKamily\nKane\nKanil\nKaren\nKarim\nKarl\nKarlo\nKarol\nKarter\nKash\nKastiel\nKawanni\nKawany\nKayden\nKb\nKc\nKdawg\nKeane\nKeanuu\nKeefe\nKeenan\nKeiran\nKeith\nKeizy\nKellin\nKelly\nKelso\nKelvin\nKemer\nKen\nKenard\nKendall\nKendo\nKendro\nKeni\nKennan\nKenny\nKent\nKenta\nKenton\nKetenlly\nKev\nKevin\nKeving\nKhriztian\nKich\nKid\nKidd\nKiefer\nKieran\nKike\nKilliam\nKillian\nKing\nKinky\nKinsey\nKip\nKipp\nKipper\nKirby\nKirk\nKiro\nKirtane\nKit\nKJ\nKlein\nKlint\nKnight\nKnox\nKoby\nKoda\nKody\nKoji\nKolby\nKoose\nKory\nKostya\nKotly\nKr\nKris\nKriss\nKrist\nKristian\nKristof\nKristofer\nKrys\nKrzysztof\nKurt\nKurtis\nKwang\nKye\nKyle\nKyler\nL.T.\nLabely\nLachlan\nLady\nLadys\nLamar\nLancaster\nLance\nLancelot\nLandon\nLane\nLargo\nLarry\nLars\nLaszlo\nLauro\nLavinia\nLaviny\nLawrence\nLawson\nLayza\nLazlo\nLe\nLeah\nLeander\nLeche\nLee\nLeei\nLefty\nLegrand\nLeif\nLein\nLenita\nLenny\nLeny\nLeo\nLeon\nLeona\nLeonardo\nLeonel\nLeslie\nLeticia\nLeticya\nLetterio\nLetticia\nLev\nLevi\nLevy\nLew\nLewis\nLex\nLexington\nLeyluken\nLiam\nLibor\nLiev\nLil\nLink\nLionel\nLiza\nLj\nLK\nLlee\nLloyd\nLoan\nLobo\nLoco\nLogan\nLohara\nLondon\nLong\nLonnie\nLopez\nLorenita\nLorenzo\nLorin\nLou\nLouie\nLouis\nLoupan\nLourranny\nLt\nLuana\nLuane\nLuc\nLuca\nLucas\nLucca\nLuciana\nLucianna\nLuciano\nLucimara\nLucio\nLucius\nLucke\nLucky\nLucus\nLudo\nLugh\nLuis\nLuka\nLukas\nLuke\nLush\nLuther\nLutro\nLyl\nLyle\nLynda\nMac\nMacana\nMack\nMad\nMaddox\nMadmax\nMaestro\nMagnum\nMaikel\nMaikl\nMajor\nMakaveli\nMalachi\nMalcolm\nMalek\nMam\nMandingo\nManny\nManu\nManuel\nMara\nMarc\nMarcel\nMarcela\nMarcelinha\nMarcella\nMarcello\nMarcelo\nMarcia\nMarcila\nMarcinha\nMarco\nMarcos\nMarcus\nMarek\nMareo\nMariana\nMarinho\nMario\nMarjorie\nMark\nMarko\nMarkov\nMarkus\nMarquee\nMars\nMarshall\nMarten\nMarti\nMartin\nMartty\nMarty\nMarvin\nMason\nMassimo\nMaster\nMat\nMateo\nMatheu\nMatheus\nMathew\nMatie\nMatt\nMatteo\nMatthew\nMatty\nMaui\nMaurice\nMauricio\nMaverick\nMax\nMaxim\nMaximilian\nMaximillion\nMaximo\nMaximus\nMaxmilian\nMaxwell\nMaxx\nMayla\nMayorye\nMazee\nMazus\nMC\nMchico\nMcKensie\nMegur\nMeiling\nMell\nMelyssa\nMerlin\nMesa\nMeu\nMicah\nMichael\nMichal\nMichel\nMichelle\nMichelli\nMichelly\nMichi\nMichy\nMick\nMickael\nMickey\nMicky\nMiguel\nMihail\nMik\nMike\nMikel\nMikey\nMikhail\nMikkal\nMiky\nMil\nMilan\nMiles\nMilky\nMiller\nMilo\nMilton\nMindo\nMiquel\nMiran\nMirek\nMiriany\nMirko\nMiro\nMischu\nMisha\nMitch\nMitchell\nMitt\nMJ\nMkax\nMo\nMocha\nMoe\nMohamed\nMohawk\nMojo\nMomo\nMoney\nMontana\nMontgomery\nMonty\nMookie\nMope\nMoreno\nMorgan\nMoses\nMoss\nMr\nMr.\nMred\nMugur\nMunick\nMurray\nMykul\nMyles\nMyllena\nMyth\nNacho\nNade\nNando\nNartan\nNash\nNastro\nNasty\nNat\nNatalia\nNatalie\nNatasha\nNate\nNathan\nNathaniel\nNeal\nNed\nNeeo\nNeil\nNelson\nNeo\nNers\nNestor\nNic\nNich\nNicholas\nNick\nNicko\nNickolas\nNicky\nNico\nNicolai\nNicolas\nNicoli\nNicolly\nNicoly\nNicoo\nNigella\nNik\nNikke\nNikki\nNikko\nNikky\nNiko\nNikolas\nNina\nNino\nNitro\nNixon\nNoa\nNoah\nNoel\nNolan\nNomad\nNorby\nNovis\nOcho\nOdin\nOiliver\nOleksandr\nOliver\nOmar\nOrian\nOrlando\nOrson\nOscar\nOsiris\nOteo\nOtis\nOtto\nOwen\nP\nP.J.\nPablo\nPaco\nPaddy\nPal\nPalmer\nPamel\nPamela\nPamella\nPanama\nPandemonium\nPapa\nPapi\nParis\nPark\nParker\nPascal\nPat\nPatric\nPatricia\nPatrick\nPatrik\nPatty\nPatvik\nPaul\nPaulah\nPaulinha\nPaulo\nPaulos\nPauly\nPavel\nPavlos\nPayton\nPedro\nPehy\nPepa\nPepe\nPepino\npepito\nPerola\nPerr\nPerry\nPet\nPete\nPeter\nPeterson\nPetr\nPeyton\nPg\nPhat\nPhenix\nPheonix\nPhil\nPhilip\nPhilippe\nPhillip\nPhilly\nPhoenix\nPierce\nPiercing\nPierre\nPietro\nPike\nPimp\nPiotr\nPit\nPlaton\nPoax\nPoke\nPollyana\nPonch\nPorno\nPorsero\nPorto\nPotro\nPowell\nPracik\nPremek\nPrescott\nPreslee\nPressure\nPreston\nPretty\nPrimo\nPrince\nPriscila\nProdigy\nPup\nPupcheer\nPyetro\nQuake\nQuentin\nQuinton\nQuron\nRabeche\nRace\nRad\nRadar\nRadim\nRafa\nRafael\nRafaele\nRafaella\nRafaely\nRaian\nRaiin\nRaissa\nRalph\nRam\nRambo\nRami\nRamiro\nRamon\nRamsey\nRamy\nRan\nRand\nRandall\nRandy\nRaoul\nRaphael\nRaphaela\nRaphaelly\nRaquelle\nRassy\nRastapica\nRaul\nRay\nRayane\nRaymond\nRaymone\nRb\nReady\nReckless\nRed\nRedd\nReed\nReese\nReg\nRegan\nReggie\nReid\nReinhardt\nRemigio\nRemo\nRemy\nRenato\nReno\nReo\nRex\nRey\nRhett\nRicardo\nRicci\nRich\nRichard\nRichi\nRichie\nRichy\nRick\nRickie\nRicky\nRico\nRidge\nRik\nRikk\nRiley\nRimo\nRinata\nRiny-Rey\nRion\nRitchie\nRiver\nRj\nRls\nRo\nRob\nRobbie\nRobby\nRobert\nRoberta\nRoberto\nRobin\nRobson\nRoby\nRocco\nRochielle\nRock\nRocke\nRocky\nRoco\nRod\nRodney\nRodolfo\nRodri\nRodrigo\nRogan\nRoge\nRoger\nRogue\nRok\nRokki\nRoland\nRollie\nRoly\nRoman\nRome\nRomeo\nRon\nRonald\nRonnie\nRonny\nRoosevelt\nRory\nRossa\nRowan\nRox\nRoy\nRoyce\nRt\nRuben\nRuckus\nRudolpho\nRudy\nRuka\nRumenito\nRuss\nRussell\nRusty\nRyan\nRyann\nRybot\nRycky\nRylan\nRyu\nSabara\nSabby\nSabreena\nSabrina\nSajkov\nSam\nSamara\nSami\nSamking\nSammy\nSampson\nSamuel\nSan\nSandro\nSandy\nSanti\nSantiago\nSantino\nSarah\nSascha\nSasha\nSaul\nSavage\nSavannah\nSavkov\nSawyer\nSaxon\nSchweger\nScooter\nScorpio\nScott\nScottie\nScotty\nScout\nSeamus\nSean\nSeb\nSebas\nSebastian\nSebastien\nSerge\nSergeant\nSergei\nSergey\nSergi\nSergio\nSeth\nSevyan\nSexy\nSeymore\nSeymour\nSgt\nSgt.\nShad\nShades\nShaft\nShaggy\nShaira\nShakira\nShane\nShannara\nSharok\nShaun\nShaw\nShawn\nShay\nSheldon\nSherman\nShey\nShigeki\nShine\nSho\nShort\nShreddz\nSilas\nSilver\nSilvester\nSilvio\nSiman\nSimon\nSimone\nSin\nSinclair\nSir\nSkilar\nSkip\nSkitz\nSkorpio\nSkott\nSky\nSkylar\nSkyler\nSkyy\nSlade\nSlave\nSledge\nSlim\nSlimPoke\nSlone\nSlovac\nSlut\nSly\nSmall\nSmassh\nSmugglerblood\nSoldier\nSonny\nSophy\nSpence\nSpencer\nSpider\nSpits\nStallion\nStan\nStanley\nStas\nSte\nSteave\nSteavn\nStefan\nStefany\nStephan\nStephen\nSterling\nSteve\nSteven\nStevenrush\nStevo\nStew\nStewart\nStig\nStill\nStirling\nStiv\nStrong\nStu\nStuart\nStyx\nSuares\nSuarez\nSuitcase\nSullivan\nSummer\nSundowner\nSunny\nSuren\nSurge\nSurya\nSutton\nSuzanna\nSuzuki\nSuzy\nSven\nSwen\nSwiss\nSybill\nSylvan\nSzilard\nT\nT.\nT.J.\nTad\nTai\nTaiana\nTailor\nTaina\nTakuo\nTallen\nTalon\nTalyta\nTamara\nTangerine\nTank\nTanner\nTannor\nTarzan\nTate\nTawele\nTaye\nTayla\nTaylor\nTayte\nTayveon\nTeacher\nTed\nTeddy\nTee\nTegan\nTener\nTeo\nTerell\nTerence\nTerrell\nTerry\nTex\nTexas\nThad\nThaiis\nThallyne\nThays\nThaysla\nThe\nTheo\nTheodore\nThierry\nThirteen\nThomas\nThor\nThore\nThrax\nThyle\nTigger\nTiko\nTim\nTimarrie\nTimmi\nTimmy\nTimo\nTimothy\nTimoti\nTino\nTitof\nTitus\nTj\nToastboy\nTob\nTober\nTobey\nTobias\nToby\nTodd\nToffy\nTokyo\nTom\nTomas\nTomi\nTomm\nTommie\nTommy\nTone\nToni\nToniyo\nTonny\nTony\nTop\nTopher\nTorque\nTorrey\nTory\ntotaleurosex\nTotto\nToudy\nTrace\nTravis\nTrelino\nTrent\nTrenton\nTrentton\nTrever\nTrevor\nTrey\nTripp\nTristan\nTristin\nTroopers\nTroy\nTru\nTruman\nT-Stone\nTT\nTuca\nTucker\nTy\nTyce\nTyler\nTyr\nTyra\nTyrone\nTyson\nUdi\nUgly\nUlan\nUrbic\nUrijah\nVader\nVadim\nVal\nValentin\nValentina\nValentino\nVan\nVanbam\nVance\nVandayme\nVander\nVane\nVanessa\nVasya\nVaughn\nVeaceslav\nVega\nVen\nVenom\nVernanda\nVeronica\nVianca\nVic\nVictor\nVictoria\nVidel\nVikto\nViktor\nVillem\nVin\nVince\nVincent\nVinn\nVinnie\nVinny\nVitali\nVitally\nVitaly\nVito\nVitoria\nVittorio\nVitya\nViv\nViviane\nVixtor\nVlad\nVolt\nVoodoo\nWade\nWagner\nWalker\nWallace\nWanessa\nWanny\nWar\nWarner\nWarren\nWayne\nWedding\nWein\nWes\nWesley\nWest\nWild\nWilde\nWill\nWilliam\nWillian\nWillie\nWillis\nWilly\nWilson\nWindom\nWinston\nWolf\nWolfie\nWoody\nWoop\nWrex\nWrexxx\nWyatt\nXander\nXavi\nXavier\nXenar\nXL\nXman\nYago\nYan\nYanessa\nYanick\nYanka\nYanos\nYasmim\nYates\nYen\nYoachim\nYolo\nYoulian\nYris\nYuliana\nYunior\nYura\nYuri\nYves\nZac\nZach\nZachary\nZack\nZaddy\nZak\nZakk\nZander\nZane\nZaq\nZario\nZayne\nZdeno\nZeak\nZeb\nZed\nZeek\nZeke\nZenza\nZeth\nZeus\nZhane\nZidane\nZiggy\nZion\nZoliboy\nZoltan\nZor\nZsolt\nZsur\nZyzzje\n"
  },
  {
    "path": "scripts/test_db_generator/naming.go",
    "content": "//go:build tools\n// +build tools\n\npackage main\n\nimport (\n\t\"bufio\"\n\t\"math/rand\"\n\t\"os\"\n\t\"strings\"\n)\n\nvar names map[string]*naming\n\ntype performerNamingConfig struct {\n\tMale    string `yaml:\"male\"`\n\tFemale  string `yaml:\"female\"`\n\tSurname string `yaml:\"surname\"`\n}\n\ntype namingConfig struct {\n\tScenes     string                `yaml:\"scenes\"`\n\tPerformers performerNamingConfig `yaml:\"performers\"`\n\tGalleries  string                `yaml:\"galleries\"`\n\tStudios    string                `yaml:\"studios\"`\n\tImages     string                `yaml:\"images\"`\n\tTags       string                `yaml:\"tags\"`\n}\n\ntype naming struct {\n\tnames []string\n}\n\nfunc (n naming) generateName(words int) string {\n\tvar ret []string\n\tfor i := 0; i < words; i++ {\n\t\tw := rand.Intn(len(n.names))\n\t\tret = append(ret, n.names[w])\n\t}\n\n\treturn strings.Join(ret, \" \")\n}\n\nfunc createNaming(fn string) (*naming, error) {\n\tfile, err := os.Open(fn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\tret := &naming{}\n\ts := bufio.NewScanner(file)\n\tfor s.Scan() {\n\t\tret.names = append(ret.names, s.Text())\n\t}\n\n\tif err := s.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ret, nil\n}\n\nfunc initNaming(c config) {\n\tnames = make(map[string]*naming)\n\tload := func(v string) {\n\t\tif names[v] == nil {\n\t\t\tvar err error\n\t\t\tnames[v], err = createNaming(v)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t}\n\t}\n\n\tn := c.Naming\n\tload(n.Galleries)\n\tload(n.Images)\n\tload(n.Scenes)\n\tload(n.Studios)\n\tload(n.Tags)\n\tload(n.Performers.Female)\n\tload(n.Performers.Male)\n\tload(n.Performers.Surname)\n}\n\nfunc generatePerformerName() string {\n\tfemale := rand.Intn(4) > 0\n\twordRand := rand.Intn(100)\n\tgivenNames := 1\n\tsurnames := 1\n\tif wordRand < 3 {\n\t\tgivenNames = 2\n\t} else if wordRand < 26 {\n\t\tsurnames = 0\n\t}\n\n\tfn := c.Naming.Performers.Female\n\tif !female {\n\t\tfn = c.Naming.Performers.Male\n\t}\n\n\tname := names[fn].generateName(givenNames)\n\tif surnames > 0 {\n\t\tname += \" \" + names[c.Naming.Performers.Surname].generateName(1)\n\t}\n\n\treturn name\n}\n"
  },
  {
    "path": "scripts/test_db_generator/scene.txt",
    "content": "0%\n10%\n100\n100%\n1000\n1000000\n1000facials\n1000th\n1001\n100lb\n100lbs\n100pct\n100pt\n100pts\n100th\n101\n1011096\n101Big\n10-1JJ\n102\n103\n104\n105\n106\n107\n108\n109\n109lb\n10-Member\n10K\n10-man\n10on1\n10's\n10th\n1-0The\n10v10\n11\n110\n110%\n11-Inch\n11th\n12\n1-2\n1-2-3-4\n12inch\n12-Inch\n12-man\n12th\n12-Year\n13\n13-Inch\n13-Inches\n13th\n14\n14in\n14inch\n14inches\n14th\n14thBrutal\n15\n150lbs\n155lb\n15-man\n15on1\n15pts\n15th\n16\n16th\n17\n1-800-BIG\n1-800-Flowers--A-Make love\n1-800-Sucking\n1-800-THIC-DIC\n18th\n18-year\n18-year-old\n18-year-olds\n18years\n18yo\n18yr\n18yrs\n19\n1-900-MAKE LOVE-MEE\n1950's\n1955\n1957\n1965\n1969\n19th\n19ths\n19-year\n19-year-old\n19-Year-Old's\n19yo\n19YOs\n19YO's\n19yr\n1Hr\n1K\n1of2\n1of3\n1on1\n1on3\n1on4\n1pm\n1st-Time\n2\n20\n20%\n200\n200$\n2000\n20000\n2003\n2004\n2005\n2007\n2008\n2009\n2009-01-12\n2009-01-13\n2009-01-15\n2009-01-16\n2009-01-20\n200th\n2010\n2010-12-06\n2011\n2012\n2013\n2014\n2015\n2016\n2017\n2018\n2018's\n2019\n2020\n2021\n20s\n20something\n20-somethings\n20th\n20-Year-Old\n20YO\n20yr\n21\n21sextury\n21sextury-shooting\n21st\n21-year-old\n21YO\n21YO's\n21yr\n22\n22year\n22-year-old\n22yo\n22yr\n23\n23-minute\n23-year-old\n23years\n23yo\n23yr's\n24\n2-4-6-Squirt\n247\n24-7\n24-year-old\n25\n25-year-old\n26\n26-year-old\n27\n27th\n27-year-old\n28\n28-year-old\n29\n2am\n2BBC\n2-Member\n2Cute\n2draws\n2-Endurance\n2Enjoyment\n2Extreme\n2for1\n2-For-1\n2-Girl\n2Girls\n2-guys\n2Heavy\n2hot\n2-lip\n2nd\n2of2\n2of3\n2on1\n2on2\n2on3\n2Riley'n'You\n2RUFF4HER\n2toyfun\n2x\n3\n3%\n30\n300th\n31\n32\n32DD\n32DDs\n32DD's\n32F\n32FF\n32G\n32G's\n32H\n33\n33F\n34\n34d\n34DD\n34DD’s\n34DDD\n34DDDs\n34E\n34f\n34F’s\n34FF\n34F's\n34G\n34GG\n34k\n34M-cups\n35\n35yr\n36\n36D\n36DD\n36DD's\n36E\n36F\n36FF\n36JJ\n36M\n36N\n37\n38\n38DDD\n38DDD-cupper\n38G\n38G-cup\n38G-Cups\n38GGG-Cup\n38G's\n38H\n38J-Cup\n38J-cups\n38K\n38N-cup\n39\n3am\n3BBC\n3-Member\n3d\n3-D\n3DD\n3D's\n3-Girl\n3-Hole\n3-Load\n3of3\n3on1\n3-On-1\n3on1anal\n3on1slut\n3on2\n3on3\n3ple\n3pm\n3pts\n3rd\n3s\n3some\n3-some\n3Some\n3-Some\n3somes\n3Some's\n3Somes-Me\n3Sum\n3way\n3-way\n4\n40\n400-dollar\n400th\n40H\n40inch\n40-Inch\n40-Love;\n40oz\n40's\n40Something\n40th\n41\n41G\n42\n420\n420th\n42Es\n42G\n42G-cups\n43\n44\n45\n45inch\n46\n46F\n47\n48\n49\n49-year-old\n4A\n4BBC\n4BC\n4Beejs\n4Both\n4Buxom\n4BWC\n4-Member\n4Early\n4Endurance\n4Ever\n4Facing\n4Intense\n4k\n4nicating\n4on1\n4-On-1\n4on1+DAP+8ia+Piss\n4on2\n4on3\n4-On-3\n4Orgasm\n4-play\n4pm\n4Sadistic\n4Sexual\n4-Sexual\n4some\n4-Some\n4Submitting\n4swallow\n4th\n4Today\n4U\n4v4\n4vbathtubteen\n4-way\n4-wheeler\n5\n5$\n50\n5'0\n5-0\n500th\n5-0NEWS\n50Plus\n50PlusMILF\n50PlusMILFscom\n50s\n50th\n50-year-old\n51\n5'10\n52\n5-4-3-2-1\n57-year-old\n58\n58YO\n59\n5A\n5BBC\n5-Member\n5FACIALS\n5Full\n5k\n5Live\n5Modified\n5on1\n5-On-1\n5on1with\n5on2\n5on4\n5on5\n5's\n5Sexual\n5-Sexual\n5some\n5swallow\n5SWALLOWS\n5th\n5vs2\n5way\n6\n60\n6'0\n6000rpm\n600th\n60P\n60Plus\n60's\n60something\n60th\n60-year-old\n60-year-old's\n61\n61-year-old\n62\n62-Inch\n63\n63-year-old\n64\n64-year-old\n65\n66\n66-year-old\n67\n67-year-old\n68\n68YO\n69\n69'\n69’ing\n69’s\n69er\n69ers\n69ing\n69s\n69's\n69YO\n6-Member\n6-Creampie\n6on1\n6-On-1\n6on2\n6th\n7\n70\n70s\n70's\n70-year-old\n71\n71-year-old\n72\n73\n74\n75\n75k\n76\n77\n78\n79\n7-Bang\n7-Member\n7dapPose\n7-foot\n7on1\n7on2\n7-On-2\n7on3\n7's\n7th\n8\n80\n80%\n80s\n80's\n81\n82\n83\n84\n85\n85lb\n86\n86'd\n87\n88\n89\n8am\n8-Ball\n8-Member\n8it\n8on1\n8-On-1\n8on2\n8pm\n8th\n9\n90\n90%\n900th\n90210\n90lbs\n90s\n91\n92\n93\n94\n95\n9-5\n95D\n96\n97\n97%\n98\n99\n9-Fun-Fun\n9on1\n9on3\n9th\n9thBig\na\nà\nA+\na2a\na2m\nA2P\nA6usive\nAA\nAAA\nAaaaa\nAaah\nAAAhh-OOOOOGAH\nAali\nAaliayh\nAaliyah\nAaliyah Love\nAaliyah's\nAaliyan\nAalyiah\nAanda\nAanl\nAaralyn\nAariella\nAarielle\nAarin\nAarolyn\nAaron\nAaron's\nAasphyxia\nAayla\nab\nAbagelle\nabandon\nabandoned\nAbba\nAbbbootyato\nAbbey\nAbbey's\nAbbi\nAbbie\nAbbie's\nAbbi's\nAbbondanza\nAbbondanzaBig\nAbbott\nAbby\nAbby’s\nAbbys\nAbby's\nAbc\nABC's\nAbdalla\nAbducted\nabduction\nAbe\nAbel\nAbelha\nAbelia\nAbelinda\nAbella\nAbella’s\nAbellas\nAbella's\nAbelun\nAbetting\nAbi\nAbia\nAbide\nAbiding\nAbiertas\nAbigail\nAbigaile\nAbigaile's\nAbigail's\nAbigial\nAbilities\nability\nAbject\nablaze\nable\nAblego\nAbnormal\naboard\nAbominal\nabou\nA-bouncing\nabout\nAbout?\nabov\nabove\nAbra\nAbracadabra\nAbraham\nAbrasador\nAbrascrewdabra\nAbrasive\nAturkey\nAbrielle\nAbril\nAbrill\nAbrillantar\nAbrina\nabroad\nA-Broad\nabs\nAbsences\nAbsences?\nAbsent\nAbsinthe\nabsolute\nabsolutely\nAb-Solutely\nAbsorb\nAbsorbing\nAbstinence\nabstract\nABubble\nAbundance\nabundant\nabuse\nabused\nabusedAll\nabusedNipple\nAbuser\nabuses\nAbuseSomething\nAbusing\nabusive\nAbysm\nAbyss\nAC\nAcadamy\nAcademia\nAcademic\nAcademics\nAcademy\nAccede\nAccel\naccent\nAccents\naccept\nAcceptance\naccepted\nacceptedThis\nAccepting\naccepts\nAcces\naccess\nAccessible\nAccessories\nAccessory\naccident\naccidental\naccidentally\nAcclaim\nacclaimed\nAccommodate\nAccommodation\nAccommodations\naccomodate\nAccomplished\nAccomplishment\nAccomplishments\naccording\nAccount\nAccountable\naccountant\nAccountants\nAccountant's\nAccounting\nAccoutrement\nAccuracy\nAccurate\nAccused\naccustomed\nace\nAcecaria's\nAceitoso\naces\nache\nAcheivers\naches\nachieve\nAchieved\nAchievement\nAchiever\nAchievers\nachieves\nAchieving\nAchin\naching\nAchora\nAchtung\nAchy\nAcid\nAcikin\nAcing\nAcionna\nAcita\nAckerman\nAclamat\nA-Clbooty\nACME\nAmemberalypse\nA-Coming\nAcon\nAcosta\nAcostón\nAcqua\nacquaintance\nAcquaintances?\nAcquainted\nacquire\nacquired\nAcquisition\nAcre\nAcrobat\nacrobatic\nacrobatics\nAcrobats\nAcropolis\nacross\nAcro-Yoga\nact\nacting\nactio\naction\nACTION\nAction2\nActionDevastating\nactionKicks\nACTIONLast\nactionOnly\nactions\nActivate\nActive\nActivist\nactivities\nactivity\nActor\nactors\nActor's\nactress\nactresse\nacts\nactual\nActuality\nactually\nAcuatic\nAcuerdo\nAcucat\nAcusado\nAcworth\nad\nAda\nAdagio\nAdair\nAdalisa's\nAdam\nAdamo\nAdams\nAdamson\nAdams's\nAdan\nAdara\nAdarah\nAdara's\nAday\nadd\nAddams\nadded\nAddee\nAddi\nAdjohnsons\nAdjohnsonted\nAdjohnsontion\naddict\naddicted\naddiction\nAddictions\naddictive\naddicts\naddictsamazingly\nAddie\nAddies\nAdding\nAddio\nAddis\nAddison\naddisonerosebgvid\nAddison's\nAddition\nadditional\nAddress\nadds\nAddyson\nAdel\nAdela\nAdelaida\nAdele\nAdelia\nAdelina\nAdelina's\nAdeline\nAdell\nAdelle\nAdelle's\nAdelle-ta\nAdel's\nAdelyn\nAdepts\nAdessa\nAdia\nAdicción\nAjohnsontion\nAdidas\nAdin\nAdina\nAdin's\nAdios\nAdira\nAdira’s\nAdira's\nAdjani\nadjectives\nAdjourned\nAdjust\nadjustment\nadjusts\nAdley\nAdmin\nadminister\nadministered\nadministers\nAdministrative\nAdministrator\nAdmirable\nAdmiration\nAdmire\nadmired\nadmirer\nadmires\nadmiring\nadmission\nAdmissions\nAdmit\nadmits\nAdmitting\nAdo\nAdolescence\nAdolescente\nAdonis\nAdopt\nadopted\nAdopts\nAdora\nAdorabe\nadorable\nAdorably\nadoration\nadore\nAdored\nadores\nAdoring\nadorn\nAdornment\nAdoro\nAdreena\nAdreena's\nAdrenaline\nAdrenalyn\nAdrenalynn\nAdri\nAdria\nAdrian\nAdriana\nAdriana’s\nAdrianaconda\nAdrianas\nAdriana's\nadrianna\nAdriannas\nAdrianna's\nAdrianne\nAdriano\nAdriano's\nAdria's\nAdrien\nAdrienn\nAdrienne\nAdrienz\nAdriyana\nAdry\nAdscensio\nadult\nAdulteration\nAdulteress\nAdulterio\nAdulterous\nAdultery\nadv\nadva\nadvance\nAdvanced\nAdvances\nadvantage\nadvantages\nAdvenger\nAdvent\nadventure\nAdventure'\nAdventurers\nadventures\nadventuress\nadventurous\nAdvertence\nAdvertia\nAdvertised\nAdvertising\nadvice\nAdvices\nAdvised\nAdvisor\nAdvocate\nAee\nAegris\nAem\nAeon\nAerial\nAerials\nAeris\naerobic\naerobics\nAero-MELON-ic\nAerolineas\nAeterna\nAF\nAfar\nAfer\naffair\nAffaire\naffairs\nAffect\nAffection\nAffectionate\nAffections\nAffina\nAffinity\nAffirmation\nAffix\nAffliction\nAffluent\nafford\nAffordable\nAffraid\nAffricate\nAffront\nAfghan\nAficianado\naficionadas\nAficionados\nAfina\nAfomyn\nAfor\nAfortunado\nafraid\nA-Freud\nAfri\nAfrica\nafrican\nAfrika\nAfro\nAfroAsian\nAfrocentric\nAfrodisiac\nAfrodita\nAfroditas\nAfrodite\nAfrodithe\nAfrodity\nAfrodiziac\nAfro-Latina's\nAfrozilla\nafte\nAften\nafter\nAfterburner\nAftercare\nAfter-Game\nafterglow\nAfterhours\nAfter-Hours\nAfterlife\nAftermath\nAfternnoon\nafternoo\nafternoon\nAfternoons\nafterparty\nafter-party\nAfterparty\nAfter-party\nAfterpoon\nafterschool\nAfter-school\nAftershower\nAfter-shower\nAftertaste\nAfterthoughts\nafterward\nAfterwork\nAfterworkout\nAfton\nagain\nAgain?\nagain?PLEASE??\nAgain's\nagainst\nagan\nAgans\nagape\nAgbootyi\nAgata\nAgatha\nAgathas\nAgave\nage\naged\nAgeha\nAgel\nAgeless\nagency\nagenda\nagent\nagent'\nAgent\nagents\nagent's\nAgents\nAgent's\nages\nAggie\nAggression\naggressive\nAggressively\naggro-make loveed\nAghora\nAghora's\nagile\nagility\nAgio\nAgita\nAgitace\nAgitated\nAglais\nAglaya\nAglaya's\nAgnes\nAgnesa\nAgnese\nAgneska\nAgness\nAgnessa\nAgneta\nAgnetta\nAgnise\nAgnyese\nago\nAgogi\nAgonizing\nAgony\nagony?\nAgos\nAgota\nAgradecido\nA-grades\nAgree\nagreed\nagreement\nagrees\nAgressive\nA-Groovin'\nAground\nAgua\nAguas\nAguchi\nAguilar\nAguilara\nAguilera\nAh\nAhab\nahead\na-Head\nAhead\nAheadTrustfund\nAhegao\nahelpless\nAhh\nAhhh\nAhhhh\nAhhok\nAhna\nAhnn\nAhnyjah\nAhoj\na-hole\nAholics\nAhontas\nAhora?\nAhoy\nAhrya\nAhryan\nAhud\nAi\naid\nAida\nAidan\nAiden\nAiden's\nAiding\nAidra\nAidra's\nAids\nAika's\nAiko's\nAila\nAileen\nAiling\nAilment\naim\nAimee\nAimes\nAiming\nAimoto\nAims\nain’t\nAina\nAinara\nAinava\nAinsley\naint\nain't\nAint\nAin't\nAintzira\nAiny\nair\nAirbags\nAirBnB\nAire\nAiri\nAiring\nAirita\nAirline\nairlines\nairplane\nairport\nAirs\nairtight\nair-tight\nAirtight\nAir-Tight\nAIRTIGHT\nAirtightsuper\nAirways\nAisha\nAisha's\nAisle\nAislin\nAiwe\nAiya\nAiyana\nAiza\nAj\nAJ'\nAJ’s\nAja\nAjauro\nAjay\nAjenda\nAj's\naka\nAkari\nAkarra\nAkasha\nAkashova\nAkashova's\nAketa\nAki\nAkira\nAkizuki\nAkkara\nAktavia\nAkulova\nAkward\nal\nala\nAlabama\nalabaster\nAlaina\nAlaine\nAlamea\nAlan\nAlana\nAlanah\nAlanah's\nAlana's\nAlani\nAlanis\nAlanna\nAlanna's\nAlannis\nalarm\nAlarming\nAlaska\nAlaura\nAlaura's\nala-veegee\nAlayah\nAlayaha\nAlayna\nAlba\nAlbarez\nAlbergo\nAlbert\nAlbertina\nAlberto\nAlbert's\nAlbina\nAlbina's\nAlbright\nAlbright's\nAlbrite\nAlbrite's\nalbum\nalbumJessy\nAlbuquerque\nAlby\nAlby's\nAlcala\nAlcantara\nAlcantara's\nAlcedo\nAlcohol\nalcoves\nAldamen's\nAldo\nAlea\nAlec\nAlecia\nAleck\nAlectia\nAleera\nAleesha\nAleftina\nalegbra\nAlegria\nalegría\nAleigh\nAlejandra\nAlek\nAlekeias\nAleks\nAleksa\nAleksaise\nAleksandra\nAleksa's\nalektra\nAlektrafied\nAlektrafying\nAlektra's\nAlektric\nAlen\nAlena\nAlena's\nAlenia\nAlenushka\nAlergía\nalert\nALERTStrappado\nAlesbian\nAlesha\nAlesia\nAleska\nAleska's\nAlessa\nAlessandra\nAlessandra's\nAlessia\nAlessio\nAlesya\nAletta\nAletta Ocean\nAletta's\nAlex\nAlex’s\nAlexa\nAlexander\nAlexandra\nAlexandra's\nAlexandria\nAlexa's\nAlexi\nAlexia\nAlexia's\nAlexis\nAlexi's\nAlexis's\nAlexisTexas\nAlex's\nAlexsa\nAlexsis\nAlexus\nAlexxa\nAlexxa's\nAlexxx\nAlexy\nAlexya\nAlfano\nAlfred\nAlfredo\nalgebra\nAlge-bra\nAlgo\nAli\nAlia\nAliana\nAlia's\nAlibi\nAlice\nAlice’s\nAlice85JJ\nAlicensual\nAlices\nAlice's\nalicia\nAlicia's\nAlicija\nAlicious\nalien\naliens\nAliesha\nAlighatti\nAlign\nAligning\nAlii\nAlik\nalike\nAlikins\nAlimony\nAlin\nAlina\nAlina’s\nAlina's\nAline\nAliona\nAlis\nAli's\nAlisa\nAlisandra\nAlisa's\nAlise\nAlisha\nAlishia\nAlisia\nAlison\nAlison's\nAlissa\nAlissa's\nAlissia\nAlisson\nAlissya\nAlisyn\nAlita\nAlitta\nAlitzia\nAlive\nAlix\nAlix's\nAliya\nAliyah\nAliyas\nAliya's\nAliyeva\nAliysa\nAliz\nAliza\nAlize\nAliz's\nall\nA-L-L\nall?\nall_over_marleigh\nAlla\nAll-Access\nAll-American\nAllanah\nAll-Anal\nall-around\nAlla's\nAllatra\nAllaura\nAllayah\nAll-black\nAllblue1\nAllblue2\nAllbrite\nAllbum\nAllee\nAllegiance\nAllegra\nAllegro\nAllen\nAllenias\nAllens\nAllergic\nAllesandra\nAllex\nalley\nAlley's\nalleyway\nAllflowers\nAll-Girl\nAll-Holes\nAlli\nAlli’s\nAlliance\nallie\nAllies\nAllie's\nalliesin\nAll-in\nall-inclusive\nAllis\nAllisa\nAllison\nAllison's\nAllister\nAllister's\nall'italiana\nAlliyah\nAll-lesbian\nAlllison\nAll-natural\nAllnetting\nAll-night\nAlloa\nAllogaj\nAllood\nAllora\nAllout\nallover\nAllow\nAllowance\nallowed\nallowed??\nallows\nAllpink\nAllpink2\nAllpinktoy\nAllred\nAll's\nAll-Sexclusive\nAllstar\nAll-Star\nAllstars\nAll-Stars\nAll-Swallowing\nAlltogether\nAllura\nallure\nAllureas\nAllured\nAllurement\nalluring\nAllwet\nAll-white\nallwith\nAllwood\nAllwood's\nally\nAllyana\nAllyce\nAllyellow\nall-you-can-eat\nAllys\nAlly's\nAllysa\nAllysa's\nAllysin\nAllyson\nAllyssa\nAllyssa's\nAlma\nAlma's\nAlmeida\nAlmighty\nalmond\nalmost\nAlnite\naloen\nAloha\nAlona\nalone\nAlone?\nalong\nAlong?\nAlonna\nAlonzo\nalot\nAlota\nalotta\nAloud\nAloura\nAlpes\nalpha\nalpine\nalready\nAlready?\nAlrededor\nAlri\nalright\nAlrik\nALS\nAlsana\nalso\nAlson\nAlsu\nalt\naltar\nalt-chick\nAltea\nAlter\nAlterations\naltercation\nalternative\nAlternatives\nAltero\nAlters\nAlt-Girl\nAlthough\nAltid\nAlto\nAltogether\nAltruism\nAlura\nAlura's\nAlusis\nAlvares\nAlvares's\nAlves\nAlvin\nAlvoco\nalwa\nalways\nAly\nAlya\nAlyce\nAlycia\nAlycia's\nAlyia\nAlyiah\nAlyn\nAlyona\nAlysa\nAlysa's\nAlyse\nAlysee\nAlysha\nAlyshine\nAlysia\nAlyson\nAlyssa\nAlyssas\nAlyssa's\nAlyssia\nAlyssia's\nAlyx\nam\nAm?\nama\nAmabella\nAmabella's\nAmadea\nAmadom\nAmai\nAmaizing\nAmalia\nAmalie\nAmana\nAmanda\nAmandae\nAmandas\nAmanda's\nAmande\nAmandi\nAmante\nAmantes\nAmara\nAmara’s\nAmaranta\nAmara's\nAmare\nAmari\nAmaris\nAmari's\nAmarna\nAmarna's\nAmartia\nAmasian\namateur\namateurs\nAmateur's\nAmatista\nAmato\nAmatores\nAmatuer\nA-Mature\nA-matures\nAmaxa\nAmaya\namaze\namazed\namazement\namazes\namazing\namazingAA059\namazingCalifornia\namazingly\nAmazingness\namazingSarah\namazon\nAmazona\namazonas\nAmazonia\nAmazonian\nAmazonian's\nAmazonion\namazons\namazon's\nAmazons\nAmazon's\nAmber\nAmberlee\nAmbers\nAmber's\nAmberVision\nAmbidextrous\nAmbiency\nAmbient\nAmbika\nambition\nambitions\nAmbitious\nAmbra\nAmbrose\nAmbrosia\nambulance\nambush\nAmbushed\nAmbushes\nAmbyr\nAmder\nAmee\nAmel\nAmelia\nAmelia's\nAmelie\nAmelie's\nAmendo\nAmends\nAmenities\nAmeno\nAmerica\nAmerican\nAMERICANA\nAmericano\nAmericans\nAmerican's\nAmerica's\nAmerika\nAmes\nAmeso\nAmes's\nAmethyst\nAmetisto\nAmey\nAmi\nAmia\nAmia’s\nAmia's\nAmicable\namidst\nAmie\nAmiee\nAmiee's\namiga\nAMIGO\nAmigos\nAmile\nAmilia\nAmilian\nAmillion\nAmina\nAmina's\nAmira\nAmirah\nAmirah's\nAmira's\nAmish\nAmistoso\nAmmettere\nAmmey\nAmmi\nAmmy\nAmnesia\nAmnesiac\nAmo\nAmomile\namong\namongst\nAmor\nAmora\nAmore\nAmorele\nAmorina\nAmorios\nAmorous\nAmor's\nAmos\namount\namour\nAmoure\nAmour's\nAmp\namp;\nAmphora\nample\namsterdam\nAmuse\nAmuse-Bouche\namusement\nAmuses\nAmusing\nAmuzo\nAmy\nAmy?\nAmyiaa\nAmyna\nAmy's\nAmyza\nan\nAna\nAnabel\nAnabela\nAnabell\nAnabella\nAnabelle\nanaconda\nanaconda's\nAnacondas\nAnaidha\nAnais\nanal\nAnal'\nANAL\nAnal?\nANAL_FIST\nanal+DP+airtight\nAnal-addict\nAnal-Curious\nANALDP\nAnale\nAnaled\nAnalentines\nAnales\nANALesthesia\nAnalEyez\nanalfish\nAnalFist\nAnalFisting\nAnal-Gape\nAnal-Gaping\nAnal-happy\nAnalia\nAnalia's\nAnaliese\nAnalingus\nAnalists\nAnalize\nAnalized\nAnalized'\nanalizers\nAnalizing\nAnalland\nAnalled\nAnallove\nanal-loving\nanally\nAnal-ly\nAnally-driven\nAnally-flavored\nAnalmalistic\nAnalmals\nAnal-mistress\nanal-obsessed\nAnalplay\nAnalplug\nAnal's\nAnalsexology\nanaltaker\nAnalTeenAngelscom\nAnaltoy\nAnaltub\nanalversary\nAnal-versary\nANALversery\nanaly\nANALyse\nanal-ysis\nAnalysis\nAnal-ysis\nAnalyssa\nAnalyst\nAnal-ytics\nAnalyze\nanalyzed\nAnal-yzed\nAnalyzing\nAnal-yzing\nAnanda\nAnanta\nAnarchy\nAna's\nAnastacia\nAnastasia\nAnastasia’s\nAnastasia's\nAnastasija\nAnastasiya\nAnastaysa\nAnastaysha\nAnastaysha's\nAnatomically\nanatomy\nAnatropous\nA-naughty\nAnaya\nAnbel\nAnchieta\nAncho\nanchored\nAnchorman\nAnchors\nAnchorwoman\nAncient\nAncwhores\nand\nAnda\nAndchana\nAndee's\nAnder\nAnderevi\nAnders\nAnderson\nAnderson's\nAnderssen\nAnderssen's\nAndersson\nandher\nAndi\nAndie\nAndies\nAndi's\nAndrade\nAndraste\nAndre\nAndrea\nAndreas\nAndrea's\nAndrei\nAndrei’s\nAndreia\nAndreina\nAndreina's\nAndres\nAndressa\nAndrew\nAndrews\nAndrey\nAndria\nAndrianna\nAndrina\nAndrogynous\nAndrogyny\nandroid\nAndroids\nAndru\nAndryely\nAndy\nAndya\nAndylyn\nAndylynn\nAndy's\nAneli\nAnelise\nAnell\nAnella\nAnella's\nAnelly\nAnesthetic\nAnet\nAneta\nAnett\nAnetta\nAnette\nAnezka\nAnfisa\nAnge\nangel\nÁngel\nAngel?\nAngel’s\nAngela\nAngela's\nAngelena\nAngelene\nAngeles\nAngelface\nAngeli\nangelic\nAngelica\nAngelica~Hotter\nAngelicas\nAngelica's\nAngelico\nAngelik\nAngelika\nAngelikas\nAngelin\nAngelina\nAngelina's\nAngeline\nAngelique\nAngelis\nAngeliXXX\nAngell\nAngella\nAngelle\nAngellina\nAngellyna\nAngellyne\nAngelo\nangels\nAngel's\nANGELS\nangels_in_lace\nAngelyna\nAnger\nAngie\nAngiela\nangie's\nAngill\nangle\nangles\nAnglin\nAngling\nangry\nAngsty\nAnguish\nAngy\nAngyelkana\nAnh\nAni\nAnia\nAnie\nAnija\nAnik\nAnika\nAnikas\nAnike\nAnikka\nAnikkas\nAnikka's\nAniko\nAnila\nanilingus\nanimal\nanimalistic\nanimals\nAnimated\nAnime\nAnimilastic\nAnina\nAnina's\nAni's\nAnisha\nAnisiya\nAnissa\nAnissa’s\nAnissa's\nAniston\nAniston’s\nAniston's\nAnita\nA-nita\nAnita?\nAnita's\nAnitta\nAniya\nAnja\nAnjali\nAnjanette\nAnjel\nAnjela\nAnjelica\nanjelina\nAnjie\nAnjii\nankle\nankles\nAnklet\nAnn\nAnn’s\nAnna\nAnnabel\nAnnabell\nAnnabella\nAnnabelle\nAnnabelle's\nAnnalisa\nAnnalise\nAnnals\nAnna-Marie\nAnnara\nAnnas\nAnna's\nAnne\nAnne-angel\nAnneje\nAnneke\nAnnellise\nAnne-Marie\nAnne's\nAnnet\nAnnett\nAnnetta\nAnnette\nAnni\nannie\nannielicious\nAnnies\nAnnie's\nannihilated\nannihilates\nAnnihilating\nAnnihilation\nAnnihilator\nAnnihilator0-0\nAnnihilator1-0vsAnnie\nAnnika\nAnnikas\nAnnika's\nAnnina\nAnniverasry\nanniversary\nAnnlore\nAnn-lyz\nAnnmarie\nAnn-marie\nAnnMarie\nAnnoga\nAnnouncement\nAnnouncements\nannouncer\nAnnoushka\nAnnoy\nAnnoyed\nAnnoying\nAnnoyingly\nAnnPrincess\nAnns\nAnn's\nAnnShe\nannual\nAnnuity\nAnny\nAnny's\nAnoga\nAnomaly\nAnon\nanonymous\nAnorei\nAnos\nAños\nanot\nanoth\nAnotha\nanother\nanother's\nAnoushka\nAnri\nAnsey\nAnsito\nAnspermio\nAnstice\nAnstro\nanswer\nanswered\nAnswering\nAnswers\nAnt\nAntala\nAnte\nAntes\nAntex\nanthing\nAnthology\nAnthonie\nAnthony\nAnthony's\nanthropologist\nAnti\nAntica\nAnticipado\nAnticipated\nAnticipating\nanticipation\nAnticipations\nantics\nAntidepressant\nANTIFA\nAntilles\nantique\nAntiques\nanti's\nAntiso\nAnti-stress\nAnti-Vicio\nAntivirus\nAnton\nAntonella\nAntonella's\nAntonia\nAntonia's\nAntoniette\nAntonina\nAntonio\nAntonya\nAntonya's\nAnts\nAntynia\nAntynia's\nAntything\nAnubis\nAnunka\nanus\nAnvil\nanxiety\nanxious\nany\nAnya\nAnya'\nAnya's\nanybody\nanymore\nanyone\nanyone?\nAnyone??\nAnyplace\nAnyssa\nanything\nanything?\nanytime\nAnyutka\nAnyutka’s\nanyway\nAnyway?\nanywhere\nAnywhere?\nAnza\nAnzai\nAoi\nA-Okay\nAommy\nAona\nAor\nAorta\nap\nApake\napart\napartment\napartment?\nApasionado\nApathy\nApe\nA-peeling?\napertif\nAperture\napecan\nAphla\nAphrodesiacs\nAphrodisia\naphrodisiac\nAphrodite\nA-Play\nA-Plus\nApocalipse\nApocalpse\napocalypse\nApocalyptic\nApocolypse\nApofasi\nApologies\nApologizes\napology\nApolonia\nApolonias\nApont\nA-Poppin'\nApotheosis\napp\nAppach\nApparently\nApparire\nappartment\nAppbootyionata\nappeal\nappealing\nAppear\nappearance\nAppearances\nappearanceYou\nappears\nAppease\nAppeased\nAppetency\nappetiser\nAppecan\nappecane\nAppecanes\nAppecanion\nAppecanties\nappetizer\nappetizing\nApplaud\napplauds\nApplause\napple\nApple?\nApplebottom\nApple-Bottom\nApplefingers\napplegate\nApplegates\nApplegate's\nApplelolli\napples\nApples?\nAppliances\nApplicant\nApplicants\napplication\napplications\napplies\nApply\nApplying\nAppna\nAppoinment\nappointment\nAppointments\nAppraisal\nappraiser\nAppreciated\nAppreciates\nAppreciating\nappreciation\nAppreciative\nApprehensive\nApprent-BOOTY\nApprentice\napprenticeship\nApproach\nApproached\nApproaching\nappropriate\napproval\nApprove\napproved\nApres\nAprès\nApretado\nAprey\nAprietos\nApril\nApril's\nApryl\napt\nApcanude\nACat\nAqa\naqua\nAquafina\nAquafun\nAquamarin\nAquaphobia\nAquarium\nAqua-Set\nAquatic\nAquesta\nAquinas\nar\nArab\nArabella\nArabelle\nArabelle's\nArabian\nArabic\nArachnia\nARACNAPHOBIA?\nArad\nAradia\nArad's\nArainyday\nAralyn\nArargund\nAraujo\nAraya\nAraya's\nArc\narcade\nArcadia\nArcane\narch\narched\narchedBoth\nArcher\nArchery\narches\nArchida\nArchie\narching\narchingMade\nArchitect\narchive\nArchives\nArch's\nArclyte\nArclyte's\nArdel\nArdell\nArden\nArdent\nArdi\nArdolino\nArdor\nare\narea\nAreana\nAreil\naren\naren'\nArena\narent\naren't\nAreolae\nAreolas\narepink?\nArgaki\nArgan\nArgant\nArgentina\nArgentinian\nArgento\nArgiles\nArgos\nArguably\nArguing\nargument\nArguments\nAri\nAria\nAriadna\nAriadny\nArial\nArian\nAriana\nAriana’s\nAriana's\nAriane\nArianna\nArianny\nArias\nAria's\nAridity\nArie\nariel\nAriela\nArielAKA\nAriel-make loveing-X\nAriella\nAriellas\nAriella's\nArielle\nArielle's\nAriel's\nArielX\nAries\nArietta\nArietta’s\nArietta's\nArijna\nArika\nArilyn\nArina\nArina's\nArise\nArising\nAristocrat\nAristocrats\nArizona\nark\nArkansas\nArlecchino\nArlenne\nArlet's\nArlisa\narm\nArmada\nArmageddon\nArmani\nArmanis\nArmbar\narmchair\narm-chair\nArmchair\nArm-chair\nArmed\nArmenian\nArmoire\nArmond\narmor\narmory\nArmory;\nArmour\nArmpit\narmpits\narms\nArmstrong\nArmwrestling\narmy\nArmybabe\nArmy's\nArnal\nArnie\nArnold\na-rocking\nAroma\nAromas\naround\nAroundOn\nAround-the-Member\narousal\narouse\naroused\nArouseMe\narouses\nArousin'\narousing\nArowyn\nArpoone\nArquez\narrange\narranged\narrangement\nArrangements\narranges\nArrangment\nArrazoi\narrest\nArrested\nArresting\nArrgh\nArriana\nArriba\nArrival\nArrivals\narrive\narrived\narrives\narriving\nArrogant\nArrow\nArroyo\nArs\narse\nArsed\narsehole\narsenal\nArsh\nart\nArtem\nArtemis\nArtemisia\nArteya\nArtful\nArthur\nArticulation\nArtifamily\nArtificia\nArtificial\nArtimake loveed\nArtillery\nArt-inspired\nartist\nartista\nArtiste\nArtistess\nartistic\nArtistically\nartistlet's\nArtistry\nartists\nartist's\nArtists\nArtist's\nArtRock\narts\nArt's\nArtsy\nArtur\nArty\nAruba's\nAruna\nAruna's\nArwen\nArwen's\nArya\nArya’s\nAryana\nAryana's\nAryanna\nArya's\nAryell\nas\nA's\nAS\nAsa\nAsagi\nAsami\nAsanas\nAsanty\nasap\nAsa's\nAscendancy\nAscending\nAscension\nAserta\nAsfen\nash\nAsha\nAShag\nashamed\nAshden\nAshe\nAshely\nAshen\nAsher\nAsher's\nashes\nAshe's\nAshey\nAshi\nAshiana\nAshland\nashlee\nAshlee's\nAshleigh\nAshlen\nashley\nAshley’s\nAshleys\nAshley's\nAshli\nAshlie\nAshli's\nAshly\nAshly’s\nAshlyn\nAshlynn\nAshlynne\nAshlynn's\nAshlyns\nAshly's\nAshore\nAshton\nAshton's\nAshtray\nAsí\nasia\nasian\nAsiana\nasians\nAsian's\nAsian-style\nAside\nAsiniya\nAsis\nask\nAskani\nasked\nAsker\nasking\nasks\nasleep\nAsley\nASMR\nASMRK\nAsole\naspects\nAspen\nAspen's\nAsphyxia\nAsphyxia-Live\nAsphyxia's\nasphyxiationwe\nAspid\nAspiration\nAspirations\naspirin\naspiring\nbooty\nbooty?\nbooty_and_heels\nbooty_celebration\nBootyablanca\nBootyacre\nBootyage\nbootyarific\nBootybootyin\nBootybootyin1-2\nBootybootyin2-1\nBootybootyin2-2\nBootybootyination\nBootybootyins\nBootybootyin's\nbootyault\nBootyaults\nbootybanged\nBooty-Banged\nbooty-banging\nBooty-Blessed\nBooty-bombing\nBootycatraz\nBooty-centric\nBootycheeks\n'Bootycoholism'\nBooty-Crammed\nbooty-crammer\nbootycrobatics\nBooty'd\nBooty-Day\nBooty-drilled\nbooty-eating\nbootyed\nBooty-ed\nBootyembled\nBootyembly\nBootyential\nBootyercise\nBootyersize\nBootyertion\nbootyes\nBootyes'\nBOOTYes\nBootyesina\nBootyesinpubliccom\nBootyessing\nBootyessment\nBootyessor\nbootyet\nBootyet?\nbootyets\nBooty-ets\nBOOTYets\nBOOTY-ets\nbooty-examined\nbootyfarting\nBootyfest\nBootyfiller\nBootyfingered\nBooty-fist\nBooty-Fisted\nBooty-fisting\nbooty-flashing\nbootymake love\nbooty-make love\nBootymake love\nBooty-make love\nbooty-make love?\nbootymake loveed\nbooty-make loveed\nBootymake loveed\nBooty-make loveed\nBootyMake loveed\nBooty-Make loveed\nbootymake loveing\nbooty-make loveing\nBootymake loveing\nBooty-Make loveing\nBOOTYMAKE LOVEING\nbootymake loves\nbooty-make loves\nbootyful\nBOOTY-full\nbooty-gaped\nbootygasm\nbootygressive\nBootyh\nbootyhole\nBooty-Hole\nbootyhole?\nbootyholebootyhole\nBootyholefevercom\nBootyholefever's\nbootyholes\nBootyhole's\nBootyia\nbootyiatant\nBootyiduous\nbootyignment\nBOOTY-ignment\nbootyignments\nBootyimilation\nBootyist\nbootyista\nbootyistance\nBooty-istance\nBOOTYistance\nbootyistant\nBooty-istant\nBootyistants\nBootyistant's\nBootyisted\nBootyisting\nBooty-isting\nBOOTYisting\nBootyists\nBooty-ists\nBooty-Jack\nBooty-jizz-jazz\nBootyk\nBootyking\nBooty-king\nBootykissers\nBootylane\nBootyless\nBootylicious\nBooty-lick\nbooty-licker\nbooty-licking\nBootylicking\nBooty-licking\nBooty-loving\nBootyman's\nBooty-mbootyage\nBootymazing\nBOOTY-mazing\nBootymeat\nBootyMen\nBootymends\nBootymissible\nBOOTYMR\nbootyociate\nBooty-O-Rama\nBootyortment\nBootyparade\nBootypirations\nBooty-piring\nBOOTYpiring\nBootyplay\nBooty-plays\nBootyport\nBooty-Port\nbooty-pounding\nBootycat\nBootyquake\nbootyrageousness\nBooty-Reaming\nBootyRider\nbooty-riding\nbooty-rose\nBooty-Seuse\nBooty-signment\nBooty-slapping\nBooty-Slave\nBooty-Spanking\nBOOTYSSStonishing\nBooty-Stretcher\nbooty-stretching\nBooty-surance\nBooty-tasters\nbootytastic\nBooty-tastic\nBOOTYtastic\nBOOTY-tastic\nBOOTYTASTIC\nBootytate\nBootytatics\nBOOTY-Team\nBootythletics\nBootyti\nBooty-To-MILF\nbooty-to-mouth\nBootytonishing\nBooty-tonishing\nBOOTYtonishment\nBOOTY-tor\nBootytounded\nbootytounding\nBooty-tounding\nBOOTYtounding\nbooty-toy\nBootytoy\nBootytronaut\nBOOTYtronomical\nBootytronomy\nBootyualt\nBooty-ualties\nBootyume\nbootyumes\nBooty-Up\nBootyurance\nBootyurdity\nBootyure\nBootyured\nBootyvengers\nbooty-VR\nbootyWe\nBooty-Wrecker\nBootyy\nBootyylum\nBooty-ylum\nBootyzilla\nAsta\nAsti\nAston\nastonishing\nAstoria\nAstounding\nAstoundingly\nAstray\nAstrid\nAstrid's\nAstrological\nAstronomical\nAstudent\nAsturias\nAstuta\nAstyn\nAstyn's\nAsucking\nasylum\nAsymmetric\nat\nat?\nAtarel\natcha\nate\nAtelier\nATGOM\nAthena\nAthenas\nAthena's\nAthens\nAthian\nAthina\nAthina's\nathlete\nathleteBound\nathletes\nAthlete's\nathletic\nAthletics\nAtido\nAtkins\nATL\nAtlanta\natleast\natm\nA-T-M\natmosphere\nATM's\natogm\natom\nAtomic\natoms\nAtone\nAtonement\natop\nAtopos\nATP\nAtrapados\nAtrás\nAtrium\nAtrocious\nattached\nattack\nAttacked\nattacks\nAttactive\nattemp\nattempt\nattempted\nattemptedComplete\nattempting\nAttempts\nAttend\nattendant\nAttendant's\nattends\nattention\nAttention?\nattentions\nAttic\nAtticus\nAttire\nAttison\natcanude\nAttiva\nattorney\nAttracks\nattract\nAttracted\nAttracting\nattraction\nAttractions\nAttractive\nAttracts\nATV\nAtwell\nAtypical\nau\nAubree\nAubree's\naubrey\nAubrey's\nAubrie\nAubry\nAuburn\nauction\nAuctioned\nauctionKaty\nAudacious\nAudacity\nAuddi\nAudee\nAudie\naudience\naudience?\naudienceBrutal\naudienceIsis\nAudiency\nAudiiton\nAudio\nAudiophile\nAudiophilia\nAudit\naudition\n'Audition\nAUDITION\nauditioning\nauditions\nAuditionsDay\nAuditions-Day\nAuditionsSexual\nAUDITON\nAuditons\nAuditorium\nAudra\nAudrey\nAudrey’s\nAudrey's\nAudri\nAudrianna\nAudrie\nAudrina\nAudrina's\nAudrinna\nAudry\nAudtion\nAug\nAugust\nAugustina\nAugustine\nAugusts\nAugust's\nAunque\naunt\nauntie\nAunt's\nAunty\nAu-Pair\naura\nAural\nAurel\nAurelia\nAurelly\nAURMISSION\nAurora\nAurora's\nAuspice\nAussie\nAussie's\nAusten\nAustin\nAustin's\nAustralia\nAustralia’s\nAustralian\nAustralia's\nAustria\nAustrian\nAustyn\nAut\nAuteur\nAuthentic\nAuthentically\nAuthor\nAuthority\nAuthorization\nAutilia\nauto\nAutoeroticism\nAutomatic\nAutomobile\nautoshop\nAutre\nAutum\nAutumn\nAutumns\nAutumn's\nAv\nava\nAva Addams\nAva’s\nAvailability\navailable\nAvalon\nAvalon's\nAvana\nAvano\nAvano's\nAvant\nAvantgarde\nAvanti\nAvarice\nAvaricious\nAvary\nAvas\nAva's\nAvatari\navec\nAveline\nAvena\navenger\nAvengers\nAventures\nAvenue\naverage\nAveri\nAverie\nAversion\navery\nAvery's\nAvi\nAviana\nAviator\nAvilas\nAvilette\nAvina\nAvi's\nAviva\nAvluv\nAvluv's\nAVN\nAvocation\navoid\nAvoiding\navoids\nAvory\nAvouch\nAvril\nAvril’s\nAvrill\nAvril's\nAvrora\nAvrova\nAvy\nAvy's\naw\nawait\nawaited\nawaiting\nA-waiting\nawaits\nAwake\nAwake?\nAwaken\nAwakened\nawakening\nawakens\nAwaking\naward\nawarded\nAwards\nAward-winning\nAwareness\naway\nAwe\nAwe-Inspiring\nAwesamey\nawesome\nAwesomely\nAwesomeness\nAwesome-ness\nAwestruck\nAwful\nAwfully\nawhile\nAwiss\nAwkening\nAwkward\nAwoken\nawsome\nAww\nAwww\nAxe\nAxel\nAxelle\nAxis\nAxx\nAy\nAya\nAyamori\nAyana\nAyanna\nAyano\nAyden\nAydie\naye\nAye-aye\nAyers\nAyla\nAylar\nAylin\nAyline\nAylla\nAymee\nAyn\nAyn's\nAysha\nAyumi\nAyumu\nAyumu's\nAyu's\nAza\nAzalea\nAzar\nAza's\nAzea\nAzella\nAziza\nAzukal\nAzul\nAzula\nAzul's\nAzumi\nAzura\nAzure\nAzyza\nAzz\nb\nB’s\nB4\nba\nbaaaad\nbaaack\nBaam\nBaba\nBabaloun\nBabalu\nBabba\nBabbling\nbabe\nBabe Gives\nbabe?\nBabe’s\nbabel\nbabelicious\nbabe'll\nBabe-Next-Door\nbabes\nbabe's\nBabes\nBabes'\nBabe's\nBABES\nBabes’\nBabette\nBabewatch\nBabeZZ\nBabies\nBabs\nBabsier\nBabsy\nBabuska\nbaby\nBaby?\nBabyblue\nbabydoll\nBaby-Doll\nBabydolls\nBabyface\nBabyfaced\nBaby-faced\nbabyfeet\nBabylon\nBabylou\nBabymoni\nBabyoil\nBabyoil2\nBabyoilrub\nBabys\nBaby's\nBabysat\nBabyscammer\nBabyshower\nBabysit\nbabysitter\nBaby-sitter\nBabysitter?\nbabysitters\nBabysitter's\nbabysitting\nbac\nBacchANAL\nBacchanalia\nBacche\nBacchus\nBach\nBachalorette\nbachelor\nbachelorette\nBachelorettes\nBachelor's\nBacholette\nBach's\nBaciami\nback\nBack?\nbackache\nBack-Alley\nBackbeat\nbackbends\nbackbreaking\nbackBrutal\nBackcountry\nbackdoor\nbackdoor;\nbackdoor-make loveed\nBack-Dooring\nbackdraft\nBacked\nBackfire\nBackflipping\nBackflow\nbackgammon\nBackground\nBackhand\nBackhanded\nbackhole\nBackin\nBacklash\nBacklight\nBacklighting\nBacklit\nBackpack\nbackpacker\nBackpacking\nBackpage\nBackroad\nbackroom\nBack-room\nbacks\nBack's\nbackseat\nBack-Seat\nbackside\nBackspin\nBackstabber\nBackstabbing\nbackstage\nBackster\nBackscanch\nBackstore\nBackstreet\nBack-Stroke\nback-talker\nBacktalker\nbackup\nbackwards\nback-way\nBackwoods\nbackyard\nBacon\nBacstage\nbad\nBada\nbadbooty\nBad-booty\nBadChickes\nbad-boy\nBadboy\nBadder\nbaddest\nbaddie\nBaddies\nBad-Dragon\nbadge\nBadger\nBadger2-2\nBadger2-3\nBadger2-4\nBadine\nBadiya\nBadkitchen\nBADKITTYYY\nBadLady\nBad-Lands\nBadlittlegrrl\nbadly\nBadmilfs\nBadminton\nBadmoms\nBadness\nBadonkadonk\nBadseed\nbae\nBae’s\nBaeb\nBae's\nBaewatch\nBaffy\nbag\nbag?\nBagboy\nBagde\nBagel\nBaggage\nBagged\nBagger\nBaggin\nbagging\nBagheera\nBagnarsi\nBagnato\nBagning\nBagno\nBagpipe\nbags\nBagyraa\nBahama\nBahamas\nBahls\nBaiba\nBail\nBaila\nBailando\nBailarina\nBailed\nBailee\nBailee's\nbailey\nBailey’s\nBailey's\nBailing\nBailouts\nBaily\nBain\nBainug\nBaise-Moi\nBaiser\nbait\nBait?\nBaited\nBaiter\nBaitong\nBaja\nBajar\nBajo\nBaka\nBakAsuna\nbake\nBaked\nbaker\nBaker's\nBakery\nbakes\nBakhtiari\nBaking\nBakis\nBal\nBala\nBalade\nbalance\nBalanced\nBalancing\nBalazs\nBalcano\nBalcon\nbalcony\nBalconybabe\nbald\nBaldachin\nBalfour\nBali\nBalian\nBaliee\nBalina\nbaling\nball\nBALL_DEEP\nBall_Deep_Action\nBallad\nBallat\nBallbreaker\nBallbuster\nBall-busting\nBallDeep\nBallDeepAnalDP\nBALL-DEEP-BOOTYMAKE LOVEING\nBallDeepDAP\nBalldeepDapDP\nBallDeepDP\nballed\nBalled-Room\nBaller\nballerina\nBallerinas\nBallerina's\nBallerinas2\nBallerini\nBallers\nballet\nBallgag\nballin\nBallin'\nBalling\nBall-istic\nBallo\nBallon\nballoon\nballoons\nBalloons1\nBalloons2\nBalloons3\nBallot\nBallpark\nBallroom\nballs\nballs?\nBallsachs\nBallsack\nballsdeep\nBalls-Deep\nBallsDeepAnal\nBallsucker\nBall-sucking\nBallz\nBalm\nbaloney\nbaloogas\nBalouga\nBalsero\nBaltic\nBaltimore\nBam\nBama\nBama-Mama\nBamba\nBambi\nBambie\nBambina\nBambino\nBambi's\nbamboo\nBammelonabe\nBamboozle\nBamby\nBAMF\nBanaki\nbanana\nBanana1\nBananababe\nBananacream\nBananalove\nBanana-mama\nBananana1\nBananana2\nBanana-rama\nbananas\nBananateen\nBananatoy\nBananatwo\nBananavib\nBa-nan-ers\nBananinha\nBananna\nbananza\nband\nBANDERA\nBanderas\nBandicooch\nBandini\nBandini's\nbandit\nbandits\nBandochy\nbandsThey\nbane\nbang\nbangable\nbangagong\nbangarang\nBangathon\nBang-Bang\nbangbros\nBangBros18\nbangbus\nBangbusFirst\nBangmember\nbangcom\nbanged\nbanger\nbangers\nBangfast\nBangg\nBangg-ed\nbangin\nbangin'\nBangin\nBangin'\nBangin’\nbanging\nBangkok\nBangles\nBanglin'\nBangmaid\nBango\nBangover\nbangs\nBang's\nBANGS\nBangs-KOKSS\nBang-Style\nBanguage\nBangWatch\nBangz\nBanho\nBanister\nbank\nBanked\nbanker\nBankers\nBankin\nbanking\nBankroll\nbankrupcy\nBankrupt\nBanks\nBanksand\nBanks's\nBanned\nBanner\nBanner's\nBannister\nBano\nbanquet\nBanshee\nBanx\nBanxx\nBanxxx\nBanxxx's\nBanzai\nBaphomet\nbap-tastic\nBaptising\nBaptism\nBaptized\nbar\nBara\nBaranda\nBaranquilla\nBarb\nBarba\nBarbados\nBarbamiska\nBarbara\nBarbara’s\nBarbara's\nBarbarella\nbarbarian\nBarbarians\nBarbary\nbarbecue\nBarbell\nBarbeque\nbarber\nBarberella\nBarber's\nbarbershop\nBarbershop's\nBarbi\nBarbie\nBarbie’s\nBarbies\nBarbie's\nBarbie-style\nBarbora\nBarbra\nBarbra's\nBarby\nBarcelona\nBard\nBardet\nBardot\nBardot's\nBardoux\nbare\nBare?\nBareback\nBarebacked\nBarebacking\nBarebacks\nBared\nBarefeet\nbarefoot\nBarefooted\nBare-Legged\nbarely\nBarely-legal\nbares\nBaretta\nbarFirst\nbargain\nbargained\nBargaining\nBargains\nBarge\nBarges\nBargo\nBarhopping\nBaring\nBarista\nBarjeau\nBarjo\nBark\nBarkeep\nBarker\nBarking\nBarks\nBarlow\nbarmaid\nbarman\nbarn\nBarnes\nBarnett\nbarnyard\nBaron\nBaroness\nBaroque\nBarr\nBarra\nBarrack\nBarracks\nBarracuda\nBarra's\nBarre\nbarred\nbarrel\nBarrett\nBarrier\nBarriers\nBarrington\nBarrio\nBarron\nBarry\nbars\nBarstool's\nBart\nbartender\nBartenders\nBartender's\nBartending\nBarter\nBartering\nBarters\nBarthelemy\nBartroom\nBarts\nBartscha\nBarunka's\nBarvija\nBarz\nBas\nBascule\nbase\nbaseball\nbaseball?\nbaseball?'\nBaseballs\nBaseballz\nBased\nbasement\nbasementSoon\nbasementWelcome\nBases\nBash\nBashed\nBashful\nBashing\nbasic\nbasics\nBasil\nBasined\nBasinger\nBasis\nBasked\nbasket\nbasket?\nbasketball\nBasketMelons\nbasking\nbaskstage\nBbooty\nBbootyeya\nBbootyt\nBast\nBastard\nbastards\nBaster\nBastiani\nBastinado\nbat\nBatch\nBatchelorette\nBatCop\nbate\nBateador\nBateau\nBateman\nBatemans\nBateman's\nBates\nbath\nBath1\nBath2\nBathbabe\nBathbeauty\nBathbooty\nBathbubbles\nBathbanana\nBathdong\nBathe\nBathed\nBather\nBathers\nbathes\nBathfinger\nBathfun\nBathglbootytoy\nbathhouse\nBathhroom\nbathing\nBathingsuit\nBathory\nBathcat\nBathrabbit\nbathroom\nBathroombabe\nBathroombikini\nBathroombonus\nBathroombrush\nBathroombanana\nBathroomdong\nBathroomfinger\nBathroomfun\nBathroompink\nBathroomcat\nBathroomrub\nBathroomrubdown\nBathrooms\nBathroomteen\nBathroomtoy\nBathroomvibe\nBathrub\nBaths\nBathsuck\nBathtease\nBathteen\nbathtime\nBath-Time\nBathtimerub\nBathtoy\nBathtoys\nbathtub\nBathtub2\nBathtubbeauty\nBathtubfingers\nBathtubfun\nBathtublotion\nBathtubplay\nBathtubcat\nBathtubrabbit\nBathtubs\nBathtubteen\nBathtubtouches\nBathtubtoy\nBathtubkitty\nBathtubvibe\nBathvibe\nbathwater\nBating\nBation\nBatista\nBatman\nbaton\nBats\nBatter\nBattered\nBatteries\nbattering\nBatter's\nBattery\nbatting\nbattle\nbattlemake love\nbattles\nbattleship\nbattleSmaller\nBatty\nBatwing\nBauer\nBaum\nBautizo\nBavarian\nBawdy\nBaxia\nBay\nBaylee\nBaylie\nBayliss\nBayou\nBays\nBay's\nBayside\nBaywatch\nBaywolf\nBaz\nBazooka\nbazookas\nBazooms\nBb\nB-Ball\nbbc\nBBC'\nBBC’s\nBBC=Double\nBBCs\nBBC's\nBBC-throating\nBBCtwo\nBBF\nBBFF\nBBG\nB-B-G\nB-Boy\nbbq\nBBS\nBBTS\nBBW\nBBWs\nBBW's\nBC\nB-Control\nB-Cups\nbday\nb'day\nBday\nB'day\nB-day\nBDay\nB-Day\nBDAY\nBDSM\nbdy\nbe\nbe?\nBe8230;\nbea\nBeab\nbeach\nBeach?\nbeachball\nBeachballs\nBeached\nbeaches\nBeachfront\nbeachnut\nBeachcat\nBeachside\nBeachtime\nbead\nBeaded\nBeaded2\nbeading\nbeads\nBeadtoy\nBeady\nBeal\nbeam\nbean\nBeanbag\nBeanbagtoy\nbeanie\nbeans\nbeanstalk\nbear\nbeard\nbearded\nBeards\nBearing\nbears\nBearskin\nBeart\nbeas\nBeasly\nbeast\nBEAST1st\nBeastly\nBeasts\nBeasty\nbeat\nBeata\nBeata's\nBeatdown\nbeaten\nbeating\nbeatings\nBeat-it\nBeatrice\nBeatricy\nBeatris\nBeatrix\nbeats\nBeaty\nbeau\nBeaudy\nBeaue\nBeaulieu\nBeau's\nBeaut\nBeautful\nbeauties\nbeautif\nBeautification\nbeautiful\nB-E-A-UTIFUL\nbeautifull\nbeautifully\nbeauty\nbeauty?\nBeauty’s\nbeauty's\nBeautyshop\nBeaux\nBeav\nBeave\nbeaver\nbeavered\nBeaverpalooza\nbeavers\nBeaver's\nbeaverville\nBebe\nBebees\nBebel\nbecame\nbecause\nBecca\nBeck\nBecki\nBeckie\nBeckoning\nBeckons\nBecks\nBecky\nBecky's\nbecome\nbecomes\nbecoming\nbed\nbed?\nBed1\nBed2\nBed3\nBedAnd\nbedazzles\nBedbabe\nBedball\nBedbead\nBedbeauty\nBedbird\nBedbeej\nBedbooty\nBedbounce\nBedmember\nBedcream\nBedbanana\nBedding\nBeddong\nBedeli\nBedfellows\nBedfinger\nBedfinger1\nBedfinger2\nBedfingers\nBedfingers2\nBedfingersBTS\nBedfun\nBedfur\nBedhole\nBedï»¿\nBedknobs\nBedlam\nBedland\nBedlingerie\nBedlotion\nBedlove\nBedmasturbation\nBedmates\nBedmirror\nBedosia\nBedpink\nBedplay\nBedplaying1\nBedplaying2\nBedpleasures\nBedpost\nBedcat\nBedrabbit\nBedrock\nbedroom\nBedroom?\nBedroom1\nBedroom2\nBedroombabe\nBedroomchat\nBedroomclit\nBedroomdancer\nBedroomdelight\nBedroombanana\nBedroomdiva\nBedroom-Eyed\nBedroomfingers\nBedroomfingers1\nBedroomfingers2\nBedroomfun\nBedroomlove\nBedroomlust\nBedroomorgasm\nBedroompink\nBedroomplay\nBedroompleasure\nBedroomcat\nBedroomrabbit\nBedroomrub\nBedroomrubdown\nBedroomtease\nBedroomteen\nBedroomtoy\nBedroomvibe\nBeds\nBed's\nBedsex\nbed-sheets\nBedsheets\nbedside\nBedspread\nBedspread1\nBedspread2\nBedstockings\nBedstrip\nBedtalk\nBedtease\nBedteen\nBedtights\nbedtime\nBedtimefun\nBedtimepleasure\nBedtop1\nBedtop2\nBedtouch\nBedtouch1\nBedtoy\nBedtoy2\nBedtoys\nBedkitty\nBedvibe\nbee\nbeef\nbeefcake\nBeefcakes\nBeefin\nbeefsicle\nBeefsteak\nbeefy\nBeehind\nBEE-hind\nBeeline\nbeen\nBeep\nbeer\nBees\nBeet\nbef\nbefor\nbefore\nbefore?\nbeforeAn\nbeforeCountdown\nBefore-party\nbeg\nBegan\nBeganThe\nBeggar\nbegged\nbeggi\nbeggin\nbegging\nbegin\nbegining\nbeginner\nbeginners\nBeginner's\nbeginning\nbeginnings\nbegins\nBegli\nBegonias\nbegs\nBeguiled\nBeguiles\nBehave\nBehaved\nbehaves\nBehavin\nbehaving\nbehavior\nbehavior-ism\nBehaviors\nBehaviour\nBehemoth\nbehind\nBehind?\nbehind-the-scenes\nBehold\nBeholder\nBeibedi\nBeige\nBein\nbeing\nBejeweled\nBejizzed\nBekah\nBekkin\nBel\nBela\nBelarus\nBelarusian\nBelas\nBelated\nBeleco\nBelehradska\nBelgian\nBelgium\nBelicia\nbelie\nBelief\nbelieve\nbeliever\nBelievers\nbelieves\nBelika\nBelinda\nBelinda's\nBelinha\nBelittled\nBelize\nBelizean\nbell\nBell?\nbella\nBelladonna\nBelladonna's\nBellah\nBella-Nikole\nBellas\nBella's\nBellaVendetta\nBellboy\nBellboys\nbelle\nBelle’s\nBelle1stDap\nBelle--Damsel\nBellend\nBelles\nBelle's\nBellezas\nBellgirl\nBellhop\nBelli\nBellies\nBellina\nBellini\nBellini's\nBellis\nbellisima\nbellissima\nBelliximia\nBellman\nBello\nbells\nBell's\nBellucci\nBellucci's\nBelluci\nBellura\nbelly\nBelly-Dancing\nbellywasher\nBellz\nBellz's\nBelong\nbelongings\nbelongs\nbeloved\nbelow\nBelt\nBeltran\nbelts\nBeltway\nBelucci\nBen\nBena\nbench\nBenchfingers\nBenchmark\nBenchpressing\nBenchcats\nBenchwarmers\nbend\nBendable\nBended\nBender\nBend'h'er\nbending\nBendover\nbends\nbendy\nbeneath\nBenedict\nbeneficial\nbenefit\nbenefits\nBeneifts\nBeNice\nBenjamin\nBenjamins\nBenjamin's\nBenji\nBennet\nBennett\nBennett's\nBenny\nBenson\nBenson's\nbent\nBenta\nBentho\nBentley\nBenton\nBent-Over-Time\nBenty\nBentz\nBenue\nBenvenuti\nBenz\nBenzey\nBenz's\nBerated\nBerber\nBerdu\nBeret\nBeretta\nBeretta's\nBerg\nBerger\nBerinice\nBerke\nBerlin\nBerlin's\nBerlosta\nBerlyn\nBermuda\nBernadett\nBernadette\nBernard\nBernice\nBernice's\nBernie\nBeroa\nBerobed\nBeronica\nBeroomorgasm\nBerretta\nBerries\nBerrimore\nberry\nBerrylicious\nBerserk\nBerta\nBerta's\nBertha\nBerthe\nBerton\nBerton's\nBerty\nBesame\nBeside\nbesides\nBeso\nBesos\nBespectacled\nBess\nBessi\nBessie\nbest\nbest…to\nBestest\nBestfriend's\nBestial\nBestie\nBestie’s\nbesties\nBestie's\nBestsellers\nbet\nBet?\nBeta\nBetcha\nBeth\nBethany\nBethanys\nBethany's\nBetheny\nBethere\nBethie\nBeth's\nBethsabe\nBeti\nBetka\nBeTogether\nBetray\nBetrayal\nBetrayals\nBetrayed\nBetraying\nBetrix\nBetrothed\nBets\nBetsey\nBetsy\nBetsy's\nBetta\nbetter\nbetter?\nBettey\nBettie\nBettina\nBettina's\nBetting\nbetty\nBetty?\nBettyfun\nBetty's\nbetwe\nbetween\nBeutiful\nbeverage\nbeverage?\nbeverages\nBeverly\nBeverly's\nBevy\nBeware\nBewitched\nBewitcher\nBewitching\nBewizmi\nBexley\nBeyach\nBeyn\nbeyond\nbf\nBFD\nBff\nBFF’s\nBFFs\nBFF's\nBFFS\nBFFs-In-Law\nbf's\nBFs\nBF's\nBG\nBGA'\nBGB\nBGG\nb-girl\nBG's\nBhiankha\nBhiankha's\nBhoom\nbi\nBi?\nBi_Curious\nBi_Sexual\nBia\nbianca\nBianca's\nBianchi\nBianco\nBianka\nBiatch\nBiatch?\nBi-Babe\nBibi\nBibifox\nBibis\nBibi's\nBible\nBibliotcaria\nbibliotheque\nbicep\nBiceps\nBick\nBicurious\nBi-curious\nbicycle\nBicyclist\nBid\nBidaia\nBidder\nBiddie\nBidding\nBiddy\nBide\nBidet\nBieber\nBieber's\nBieder\nBiella\nBienvenue\nBiergarten\nBietch\nbig\nBig Cans\nbig?\nBig_Gapes\nbig-booty\nBigBooty\nBig-Booty\nbig-bootyed\nBigball\nbig-bapped\nBigbeads\nBigblue\nBig-melon\nbig-meloned\nBigmelonies\nBigmelonsPaola\nBig-Booty\nBig-Turkeyed\nBig-busted\nBig-bum\nbig-member\nbigmembered\nBig-Membered\nbig-johnson\nBigjohnson\nBig-Johnson\nbig-johnsoned\nBigjohnsonens\nBigbanana\nBigdong\nBigg\nBigGape\nbigger\nbiggest\nBiggie\n'Biggie'\nBiggin\nBiggins\nBigglbooty1\nBiggs\nBigguns\nBiggz\nBighouse\nBig-lips\nBigly\nBigmouthful\nBigmouths\nBigone\nBigone2\nBigpink\nBigpinkone1\nBigpinkone2\nBigred\nBigred2\nBigrodus\nBigSausagePizzacom\nBigtime\nBig-can\nbig-cans\nBigcans\nbigcanted\nbig-canted\nBigcanted\nBig-canted\nBig-Cantied\nbig-canty\nBigtoy\nBigtoyfun\nBIGTOYS\nBigvibe\nBigyellow\nBijou\nBijou’s\nBijou's\nBijoux\nbike\nBike?\nbiker\nBiker’s\nbikers\nbiker's\nBikers\nBiker's\nbikershop\nBikes\nBiking\nbikini\nBikini?\nbikini_babes\nBikini2\nBikinibabe\nBikinibed\nBikinibrush\nBikini-Busting\nBikini-clad\nbikinis\nBikinitoy\nBikini-Wearing\nBikinni\nBilas\nBilas's\nBilberry\nBilingual\nbill\nBillable\nBillatis\nBillberry\nBilli\nbilliard\nbilliards\nBilliards1\nBilliards2\nBillie\nBillie's\nbillion\nBillionaire\nBillionaires\nBillionaire's\nbills\nBill's\nbilly\nBilly's\nbimbo\nBimbofication\nBimbos\nbin\nBina\nbind\nBinding\nbinds\nBines\nBing\nBinge\nBingo\nBing's\nBingster\nBinilan\nBinky\nBinoculars\nbio\nBiography\nBiohazard\nbiological\nbiology\nBiondi\nBionic\nBIP\nBipartisan\nBipolar\nBIP's\nBiracial\nbird\nbirdbox\nBirdie\nbirds\nBirds-Eye\nBirdwatching\nbirdy\nbiritish\nBirlfriend\nBirrerie\nbirth\nbirthday\nBirthdays\nBi's\nbiscuit\nBiscuits\nbisex\nbisexual\nBi-sexual\nBiSexual\nBi-Sexual\nbisexuality\nBi-Sexually\nBishop\nBishop's\nBisous\nbistro\nbit\nchick\nChick'\nCHICK\nChickCHICK\nChickboy\nChick-boy\nChick-Boy's\nChickcraft\nChickdom\nchicked\nchickes\nChickes?\nChickez\nChickin\nChickmas\nChick'n\nchick's\nChicktoy\nchicky\nbite\nBiter\nBiters\nbites\nBithius\nbiting\nBitoni\nBitoni's\nBitoniSex\nBits\nBitsy\nBittencourt\nbitter\nBittersweet\nBitties\nBitting\nBitty\nBi-way\nBixinisia\nbiz\nbizarre\nBizkits\nBizz\nBizzare\nBizzarre\nBizzy\nbj\nBj2\nbj's\nBJs\nBJ's\nBl\nBlaar\nBlabber\nblac\nBlacc\nblack\nBlack'\nBLACK\nBlack stepsisters are\nBlack2\nBlackbedtoy\nBlackbelt\nBlackberry\nBlackbirdy\nBlackboard\nBlackbooty\nBlackbootyshorts\nBlackbra1\nBlackbra2\nBlackbull\nBlackbuster\nBlackbusters\nBlackmember\nBlack-Johnsoned\nBlackbanana\nBlackdress\nblacked\nBlackEnded\nBlackened\nBlackeneded\nBlackening\nBlacker\nBlackfishnet\nBlackfox\nblack-haired\nBlacking\nBlackjack\nBlackjacket\nBlacklight\nblacklight_beauty\nBlacklisted\nblackmail\nblackmailed\nBlackmailer\nBlackmailing\nblackmails\nBlackness\nBlacknighty\nBlackonblack\nBlackone\nBlack-On-White\nBlackout\nblacks\nBlack's\nBlacksheer\nBlacksilk\nBlackskirt\nBlackstockings\nBlackstone\nBlacktoy\nBlackundies\nBlack-Up\nBlackvibe\nBlackwell\nBlackwidow\nblade\nBladerunners\nBlading\nBlague\nBlahs\nBlaiden\nBlaine\nBlair\nBlaire\nBlaire's\nBlair's\nblak\nBlake\nBlakely\nBlake'n\nBlakeney\nBlake's\nBlakhart\nBlakovich\nBlam\nBlame\nBlanc\nBlanca\nBlanche\nBlanche's\nBlanchette\nBlanco\nBlandine\nBlank\nBlanka\nblanket\nblankets\nBlasian\nBlasians\nBlasphemy\nblast\nblasted\nBlaster\nBlasters\nblasting\nblasts\nBlaten\nBlayne\nBlazaki\nBlaze\nBlaze’s\nBlazer\nBlazes\nBlaze's\nBlazin\nBlazing\nBleach\nBleach-Blonde\nBleached\nBleacher\nbleachers\nBleaching\nbleeding\nBleins\nblend\nBlended\nBlender\nBlendova\nBless\nblessed\nblessing\nBlessings\nBleu\nblew\nblind\nBlinded\nBlinders\nblindfold\nblindfolded\nBlind-Folded\nBlindfolding\nBlindfolds\nBlinding\nBlindingly\nBlinds\nBlindsided\nbling\nBlink\nBlinkers\nBlinks\nbliss\nblissful\nBlissfully\nBlistering\nBlitz\nBlizzard\nBlk\nblo\nblock\nBlocked\nBlocking\nBlocks\nblog\nblogger\nblogger's\nBloh\nBlokes\nblon\nblond\nBlonda\nBlondage\nblondAnally\nblonde\nblonde’\nBlonde’s\nBlondecutie\nBlonded\nBlonde-haired\nBlondeIt's\nBlonde-on-brunette\nBlonder\nblondes\nblonde's\nBlondes\nBlonde's\nblondes?\nBlondi\nblondie\nblondies\nblondie's\nBlondies\nBlondie's\nBlondike\nblondine\nblondines\nBlondinka\nBlondissima\nblondPushed\nblonds\nblond's\nBlonds\nblondy\nBlone\nblood\nblooded\nBloodline\nBloodlust\nBloods\nBloodthirsty\nbloody\nbloom\nbloomer\nBlooming\nBlooms\nBloom's\nBlooper\nBloopers\nBLOOPERS-Anal\nBLOOPERS-Hiccups\nBLOOPERS-Lollipop\nBLOOPERS-Seducing\nBLOOPERS-XXX\nBloss\nblossom\nBlossoming\nBlossoms\nBlossom's\nblouse\nblow\nBlowage\nBlowback\nBlowdryer\nBlowdryer2\nBlowed\nBlower\nBlowfish\nBlowie\nBlowin\nBlowin’\nblowing\nBlowingbubbles\nBlow-Jay\nbeej\nBeej\nBeej?\nBeej2\nBeejber\nBeejer\nbeejs\nblown\nBlow'n'Member\nBlowob\nBlow-Off\nBlowout\nBlow-Out\nBlowpbooty\nBlowpop\nblows\nBlowup\nBlow-Up\nBlowvocaine\nblu\nBlubber\nblue\nblue?\nBlue’s?\nBlueballs\nbluebell\nBluebra\nBluecollar\nBluecouch\nBluebanana\nBluedots\nBluedress\nblue-eyed\nblue-haired\nBlueheels\nBlueinsert1\nBlueinsert2\nBlueness\nBluenet\nBluepanties\nBluepigtails\nBlueprints\nBlueroom\nblues\nBlue's\nBlueshoestoy\nBlueskirt\nBlueskirt2\nbluest\nBluetop\nBluetounge\nBluetoy\nBluetoy1\nBluetoy2\nBlueundies\nBluevibe\nBluevibrator\nbluey\nbluff\nBlum\nBlumpkin\nBlunder\nBlunders\nBlunt\nbluntness\nBlu's\nblush\nBlush?\nBlushing\nBluzo\nBMF\nBnB\nB'n'B\nBnD\nBnPornstar\nbo\nBoa\nboadacious\nboard\nBoarded\nBoardgame\nBoarding\nBoardroom\nBoardwalk\nBoasting\nboasts\nboat\nboat1\nBoatdock\nBoatfun\nBoatin'\nBoating\nBoats\nbob\nbobbed\nBobbi\nBobbie\nbobbin\nbobbing\nBobbi's\nBobble\nbobby\nbobs\nBob's\nbobuccino\nBocce\nbod\nBoda\nbodacious\nBodalicious\nBodas\nBodeva\nbodied\nbodies\nBods\nbody\nbody?\nBodyart\nbodyBrutal\nbodybuilder\nBody-Builder\nBodybuilders\nBodybuilder's\nbodybuilding\nBodycaress\nbody-checked\nbodyguard\nbodyis\nBodylotion\nbodyoil\nBodyRub\nBody's\nBodyslam\nbodySo\nbodystocking\nbodysuit\nBodyweight\nBoff\nboffed\nBoffing\nboffs\nBogart\nBogdana\nBogeyman\nBoglarka\nBogus\nBohdan\nBohemiam\nBohemian\nBoho\nBoi\nBoil\nboiled\nBoiler\nboiling\nboils\nBoin\nboing\nBoingo\nBoink\nboinked\nBoinker\nboinking\nBoioioing\nboisterous\nBold\nBolder\nBolivar\nBolivia\nBollente\nBollito\nBollo\nBollocks\nBolly\nBollywood\nBolster\nBolstering\nbolt\nBolted\nBolton\nBolton's\nBolts\nbomb\nBomba\nbombarded\nBombardment\nBombasic\nBomb-Booty\nbombastic\nBombay\nBomber\nBombi\nBombing\nBombom\nBombon\nBombproof\nBombs\nbombshell\nBombshell’s\nbombshells\nBombshell's\nBon\nBona\nBonage\nBonan\nbonanaza\nbonanza\nBonbon\nBon-Bon\nBond\nBond’s\nBondag\nbondage\nBondage=\nbondage1st\nBondageBrutal\nbondageCategory\nBondageHuge\nbondageMade\nBond-Con\nbonde\nBonded\nbonding\nbonds\nBond's\nBonds-age\nbone\nBone?\nBone-a-fied\nBoneanza\nboned\nBonefide\nBonemember\nboner\nBonerific\nboners\nBonerville\nbones\nboneshaker\nBones-Maisie\nBonet\nBong\nBongo\nBongos\nBonified\nbonin\nBonin'\nboning\nBoni's\nbonita\nBonjour\nBonked\nbonkers\nBonne\nbonnet\nBonni\nBonnie\nBonnies\nBonnie's\nBonny\nbono\nBons\nbonus\nBonus??\nBony\nBoo\nmelon\nMelonage\nMelonageA\nmelonageBrutal\nMelonalicious\nMelonastic\nMelon-Blasting\nMelon-Bomb\nMelon-day\nmeloned\nMeloner\nMeloners\nMelonmake loveer\nMelonfun\nmelonie\n'Melonielicious'\nmelonies\nMelonies?\nMeloniesbunny\nmelonilicious\nMelonjack\nmelonjob\nMelonlik\nmelonlivious\nMelon-maniac\nmelon-mbootyage\nMelonmusic\nMelonnanza\nMelonoo\nMELON-O-WEEN\nMelonowski\nMelonplay1\nMelonr\nmelons\nMelons?\nMelons-A-Poppin\nMelonsBig\nmelonsmake loveing\nMelonsHard\nMelonwatch\nMelony\nmelonylicious\nMelonzilla\nBoodypest\nBooga\nBoogeyman\nBoogie\nBoogy\nbook\nbookand\nbooked\nBooker\nBookhearts\nBookie\nbooking\nBookmark\nbooks\nBooks?\nBooksfingers\nBookshelf\nbookstore\nbookworm\nBookworm's\nBookworrm\nBookwurm\nboom\nBoombalottie\nboombastic\nbooming\nBoondocks\nBoop\nboost\nBooster\nBoosting\nboot\nBootay\nBootblacking\nBootcamp\nBooted\nbooth\nbootie\nBootied\nBootiepest\nbooties\nBootiful\nBootilicious\nBootious\nBootknocking\nBootlicking\nboots\nBoot's\nBootshoot\nbooty\nbooty?\nBootyCamp\nBootycise\nbootyful\nBooty-ful\nBootyfull\nBootyhole\nBootyism\nbootylicious\nBooty-licious\nBootyliscious\nBootylution\nBootyoligist\nBooty-O's\nBooty-riffic\nBootyrub\nbootys\nBooty's\nBootytopia\nBootz\nBoozing\nBoozy\nBop\nBopper\nBoppers\nBopping\nBorav\nBorbella\nBord\nBordas\nBordeaux\nBordello\nBorder\nBorder-crossing\nBorderless\nBorders\nBordo\nBore\nbored\nBored?\nboredom\nbores\nBorghese\nBorgia\nBoricua\nBoricuas\nboring\nboriqua\nBorja\nborn\nborn?\nBoroka\nBoroka's\nborrow\nborrowed\nBorrowing\nBorya\nbos\nbosom\nbosoms\nbosomy\nboss\nboss?\nBoss’\nBoss’s\nBoss-Chick\nbosses\nBossing\nBosslady's\nBossman\nBossn\nBossom\nboss's\nbossy\nBoston\nBot\nBotandi\nBotanic\nBotanical\nBotanicula\nBotched\nBotches\nBoteille\nBotella\nboth\nboth'\nBoth\nBoth?\nbother\nbothered\nBothering\nBotox\nBots\nbott\nBotta\nbottle\nBottled\nBottlelove\nBottleneck\nBOTTLE-NECK\nbottles\nbottom\nBottom?\nBottomed\nbottoming\nbottomless\nBottoms\nBottom's\nBOTY\nBoucing\nboudoir\nbought\nBouillotte\nBoujee\nBoulder\nBouldering\nboulders\nBoulevard\nbounce\nBounce'\nbounce-a-thon\nBounce-Bounce\nBouncemember\nbounced\nBouncer\nbounces\nbouncin\nBounciness\nbouncing\nbouncing_beauties\nbouncy\nbouncy-booty\nbound\nBoundage\nboundand\nboundaries\nBounded\nboundHer\nbounding\nBoundless\nboundMade\nboundPulled\nBounds\nboundThe\nBOUNS-Tight\nBountiful\nBountifully\nbounty\nBouquet\nBourgeois\nBourgeon\nbout\nboutique\nbow\nBowel\nBowels\nBower\nbowl\nbowling\nBowling+Anal=\nBowman\nBows\nBowsette\nBow-tie\nbox\nBox?\nboxed\nboxer\nBoxers?\nBoxes\nboxing\nBoxxx\nBoxxy\nboy\nboy?\nboy’s\nBoya\nBoyce\nBoy-Crazy\nboyd\nboy'd\nBoyd\nBoyfirend\nBoyfreind's\nboyfriend\nboyfriend;\nBoyfriend?\nboyfriend’s\nboyfriends\nboyfriend's\nBoyfriends\nBoyfriend's\nBoyFriends\nboy-girl\nBoyish\nBoyrfriend\nboys\nboy's\nBoys\nBoy's\nBOYS\nboytoy\nboy-toy\nBoytoy\nBoy-Toy\nBoyToys\nBoyz\nBPM\nbr\nbra\nbra-buster\nBra-busters\nBra-bustin\nBra-busting\nbrace\nBracea\nBraced\nBraceface\nBrace-Face\nBracefaced\nBrace-Faced\nBracelet\nbraces\nBraces?\nbracesFace\nBracket\nBrad\nBradburry\nBradburry's\nBradbury\nBraddock\nBraden\nBradley\nBradon\nBrad's\nBradshaw\nBrady\nBra-free\nBrag\nBrags\nBraided\nbraids\nbrain\nBrainbuster\nBrainer\nbrains\nbrainy\nBrair\nBrake\nBrakes\nBrake's\nbraking\nBraless\nBra-less\nBran\nBranch\nBranching\nbrand\nBrandalyn\nBranded\nBrandee\nBrandees\nBranden\nBrandi\nBrandi’s\nBrandie\nBrandii\nBrandii's\nBrandi's\nbrand-new\nBrandnew\nBrand-new\nbrandon\nBrandon's\nBrand-Spunking-New\nBrandt\nbrandy\nBrandy's\nBranson\nBrant\nBras\nBrasa\nBrash\nBrashly\nBrasil\nBrbooty\nBrbootyiere\nBrbootyieres\nBra-stuffer\nbrat\nBrat?\nBrats\nBrat's\nBratty\nBratwurst\nBraun\nBrauns\nBraun's\nBrave\nBravissima\nBravo\nBra-vo\nBrawd\nBrawen\nBrawler\nBrawler0-0\nBrawler0-5The\nBrawn\nBraxton\nBray\nBrayden\nBrazeau's\nBrazen\nBrazil\nBrazil?\nbrazilian\nBrazilian'\nBrazilians\nBraziliera\nBrazillian\nBrazil's\nBrazzer\nBrazzers\nBrazzertarians\nBrazzerville\nBrazzibots\nBrazzle\nBrdigette\nBrea\nBreach\nBreaching\nbread\nBreadth\nBreadwinner\nbreak\nbreak?\nBreak_up\nBreak-and-Enter\nBreakdown\nbreaker\nbreakers\nBreakey\nbreakfast\nBreakfastspread\nBreak-Her\nBreakin\nBreak-in\nbreaking\nBreakout\nbreaks\nBreaks?\nBreakthrough\nBreakthru\nbreakup\nBreak-Up\nBreakups\nBreaky\nBreanna\nBreanna's\nBreanne\nBreanne's\nturkey\nTurkey7\nturkeyacular\nTurkeyball\nTurkey-cam\nturkeyed\nTurkeyercising\nTurkeyercize\nTurkeyfast\nTurkeyfeeding\nTurkeyfest\nTurkey-Fest\nTurkeyfully\nturkeygate\nTurkeyicle\nturkeyicles\nBreascanude\nturkeyman\nTurkeyman’s\nTurkey-man's\nTurkeymas\nTurkeymeat\nTurkey-men\nTurkeymore\nTurkeyopolis\nTurkeyroom\nturkeys\nturkeysis\nturkeysThis\nTurkeystroke\nturkeyy\nbreath\nBreath-Control\nbreathe\nbreathe;\nBreatheing\nBreatheJust\nbreathing\nBreathless\nbreathtaking\nBreath-taking\nBreathtakingly\nBred\nBree\nBreeanna\nBreed\nBreeder\nBreeders\nbreeding\nBreedom\nbreeds\nBreelsen\nBree's\nbreeze\nBreeze’s\nBreezy\nBrekell\nBremen\nBrend\nBrenda\nBrendan\nBrenda's\nBrendon\nBrendys\nBrenn\nBrenna\nBrennan\nBrennon\nBrent\nBrett\nBretta\nBrett's\nBrew\nBrewed\nBrewing\nBrews\nBrewster\nBrexit\nBreyelle\nBreyta\nBri\nBria\nBrian\nbriana\nBriana's\nBriancon\nBrianna\nbrianna's\nBrian's\nBriar\nBribe\nBribed\nBribery\nBribes\nBribing\nBric-a-brac\nBrice\nBriches\nbrick\nbrickhouse\nBricklayers\nBrickmasterbation\nBricks\nBrick's\nBridal\nbride\nBridemaid\nbrides\nbride's\nBrides\nBride's\nBridesmaid\nbridesmaids\nBridesmaid's\nBride-To-Be\nBridezilla\nbridge\nBridges\nBridget\nBRIDGET\t\tBridget\nBridget's\nBridgett\nbridgette\nBridgette?\nbridgette's\nBriding\nBrie\nBrief\nBriefing\nBriefs\nBriella\nBrielle\nBrielle's\nBrigada\nBrigade\nBrigette\nBriggs\nbright\nbrighten\nBrighter\nBrightest\nbright-eyed\nbrightly\nBrightness\nBright's\nBrigit\nbrigitta\nBrigitte\nBrigitte's\nBrigitting\nBriho\nBrik\nBrill\nBrillante\nbrilliant\nBrill-iant\nBrillo\nBrilloso\nBrillster\nbrim\nBrimbalant\nBrin\nbring\nBringin\nbringing\nbrings\nBring's\nBrink\nBrin's\nBrinx\nBrinx's\nBriquettes\nBrisexual\nbrit\nBrit A Good\nBrit’s\nBritain\nBritannia\nBritanny\nBritany\nBritches\nBrite\nBrithday\nbritish\nBritish-French\nbritney\nBritneys\nBritney's\nBrit-on-Brit\nBrits\nBrit's\nBrits?\nBritt\nBrittani\nBrittania\nBrittanie\nBrittany\nBrittany's\nBrittle\nbrittney\nBrittny\nBrittny's\nBritton\nBritts\nBritt's\nBrix\nBrixley\nBrixton\nBriza\nBrizit\nBrno\nbro\nBro’s\nBroach\nbroad\nBroadcast\nBroadcasting\nbroadcasts\nBroads\nBroadwalk\nBroadway\nBrock\nBrocks\nBroden\nBroderick\nBrodi\nBrodie\nBrody\nBrohstel\nbroke\nBrokelyn\nbroken\nbroken?\nBroker\nBromance\nBromley\nBromo\nBronco\nBronson\nBronte\nBronx\nBronze\nBronzed\nBronzing\nBrook\nbrooke\nBrookelynn\nBrookes\nBrooke's\nBrookie\nBrooklyn\nBrooklyn’s\nBrooklynn\nBrooklyn's\nBrooks\nBrook's\nBrookshire\nBrooks's\nBrookyln\nBroom\nBroomsticks\nBroox\nbros\nbro's\nBros\nBro's\nBROs\nBrossman\nBrotha\nbrothas\nBrothel\nbrother\nBrother?\nBrother’s\nBrotherhood\nbrother-in-law\nBrotherload\nbrotherly\nbrothers\nbrother's\nBrothers\nBrother's\nbroug\nbrought\nbrow\nbrown\nBrownBunny\nbrown-haired\nbrownie\nBrownies\nBrowning's\nBrownlacey\nBrown's\nBrowse\nBrowser\nBrowsin\nBrowsing\nBrrr\nbru\nBruan\nBruce\nBruenette\nBrugal\nBruised\nBruises\nBruja\nBrulee\nBruna\nBruna's\nBrunch\nBrunessa\nBrunet\nBrunett\nbrunette\nBrunette Rides\nBrunette’s\nbrunettes\nbrunette's\nBrunettes\nBrunette's\nBruni\nBruninha\nBrunna\nbrunnete\nbrunnette\nBruno\nBruno's\nbrush\nBrushed\nbrushes\nbrushing\nBrushstroke\nBruta1ity\nbrutal\nbrutalised\nbrutality\nBrutalize\nbrutalized\nbrutally\nbrutalNon-scripted\nbrute-make love\nbrute-make loveed\nbrut-make loveed\nbruthas\nBryan\nBryana\nBryant\nBryce\nBryci\nBrylee\nBryn\nBrynn\nBrysen\nB's\nBsg\nbts\nBTS-20\nBTS-A\nBTS-Abigail\nBTS-All\nBTS-Almost\nBTS-Amateurs\nBTS-An\nBTS-Ana\nBTS-Anal\nBTS-Angelic\nBTS-Animal\nBTS-Aren't\nBTS-Ariel\nBTS-Booty\nBTS-Bootyed\nBTS-Audrey\nBTS-Back\nBTS-Bathroom\nBTS-Beautiful\nBTS-Bella\nBTS-Better\nBTS-Bisexual\nBTS-Chick\nBTS-Black\nBTS-Blacked\nBTS-Blanche\nBTS-Blow\nBTS-Bonnie\nBTS-Bree\nBTS-Bumhole\nBTS-Carmen\nBTS-Carter\nBTS-Casting\nBTS-Cayenne\nBTS-Cindy\nBTS-Coming\nBTS-Cooking\nBTS-Cream\nBTS-Deep\nBTS-Dentist\nBTS-Destruction\nBTS-Detention\nBTS-Dirty\nBTS-Disco\nBTS-Dolls\nBTS-Don't\nBTS-Emily\nBTS-Evil\nBTS-Extra\nBTS-Facial\nBTS-Faithfully\nBTS-Family\nBTS-Fingers\nBTS-Foot\nBTS-Francesca\nBTS-Fun\nBTS-Gangland\nBTS-Gasp\nBTS-Getting\nBTS-Giant\nBTS-Girls\nBTS-Golden\nBTS-Graduation\nBTS-Harmony\nBTS-Help\nBTS-HI\nBTS-Horny\nBTS-Hose\nBTS-Hot\nBTS-I\nBTS-I'm\nBTS-It's\nBTS-Jenaveve\nBTS-Jessie\nBTS-Joanna\nBTS-Join\nBTS-Keisha\nBTS-Korene\nBTS-Late\nBTS-Legs\nBTS-Lena\nBTS-Lessons\nBTS-LeWood\nBTS-LeWood's\nBTS-Lex\nBTS-Lexi\nBTSLex's\nBTS-Living\nBTS-Lovely\nBTS-Lucky\nBTS-Luscious\nBTS-Martial\nBTS-Maureen\nBTS-May\nBTS-Me\nBTS-Michelle\nBTS-MILFs\nBTS-Mommy\nBTS-Mommy's\nBTS-Mother\nBTS-Mother's\nBTS-My\nBTS-Mz\nBTS-Never\nBTS-New\nBTS-No\nBTS-North\nBTS-Nova\nBTS-Oooh\nBTS-Our\nBTS-Paint\nBTS-Paula\nBTS-Phoenix's\nBTS-Pie\nBTS-Pink\nBTS-POV\nBTS-Power\nBTS-Private\nBTS-Prom\nBTS-Psycho\nBTS-Puppet\nBTS-Pure\nBTS-Red\nBTS-Regan\nBTS-Remote\nBTS-Rocco\nBTS-Rocco's\nBTS-Sara\nBTS-Scarlett\nBTS-Seduced\nBTS-Seduction\nBTS-Sensual\nBTS-Sex\nBTS-Sexy\nBTS-Sharing\nBTS-She-Male\nBTS-Silvia\nBTS-Silvia's\nBTS-Sisters\nBTS-Sivia\nBTS-Slutty\nBTS-Sorority\nBTS-Steaming\nBTS-Stepmom's\nBTS-Straight\nBTS-Swallow\nBTS-Swim\nBTS-Tara\nBTS-Tasting\nBTS-Tea\nBTS-Teasing\nBTS-Tech\nBTS-The\nBTS-Tight\nBTS-Toni's\nBTS-Tooth\nBTS-Tori\nBTS-Toying\nBTS-Training\nBTS-Trans\nBTS-Transsexual\nBTS-Twerk\nBTS-Under\nBTS-Undressed\nBTS-Unexpected\nBTS-University\nBTS-Valentine\nBTS-Vanessa\nBTS-Vegas\nBTS-Victoria\nBTS-Volleyball\nBTS-Voluptuous\nBTS-Warm\nBTS-White\nBTS-Will\nBTS-You\nBTS-Young\nBTS-Your\nbu\nBub\nBubba\nbubbilicious\nbubble\nbubble_bums\nBubble-booty\nBubblebath\nBubblebathfun\nBubblebathorgasm\nBubbleblower\nbubble-bum\nBubblebum\nBubble-bum\nBubblefingers\nBubblegum\nBubble-loving\nbubbles\nBubbles2\nBubbletoy\nBubblewand\nBubbley\nBubblez\nBubblicious\nBubbliest\nBubblin\nBubbling\nbubbly\nBub-Bubs\nBubby\nBubi\nBuca\nbuck\nBucked\nbucket\nbuckets\nBuckin\nbucking\nBuckle\nBuckler\nbucks\nbud\nBuda\nBuda'07\nBuda'08\nBuda'10\nBuda'11\nBuda'12\nBudai\nbudapest\nbudapuss\nBudd\nBudder\nBuddha\nbuddies\nBuddie's\nBudding\nbuddy\nbuddy's\nBudene\nBudget\nbuds\nBueller\nBuellers\nBuen\nBuena\nBuenas\nBuenos\nbuff\nBuffer\nbuffet\nBuffett\nBuffin\nbuffy\nBUFU\nbug\nBugatti\nBuggered\nBuggy\nBugman\nBugs\nBugsi's\nBuild\nbuilder\nbuilding\nBuilds\nbuilt\nBuilt-In\nBuisnbooty\nBujoli\nBukake\nbukakke\nbukkake\nBukloj\nbulb\nBulbous\nBuldocek\nBulgari\nBulgaria\nBulgarian\nBulge\nbulging\nBulk\nbull\nBull0-0\nbull-johnsoned\nBulldog\nBulldoggy\nBulldogs\nBulldozed\nBulldozer\nBulldozing\nBullet\nBulletproof\nBullets\nBullfighter\nBullhorn\nbullied\nbullies\nbullly's\nBullpen\nbulls\nBull's\nBullseye\nBullshit\nbully\nbullyBlond\nbullying\nbully's\nBullys\nBully's\nBulma\nbum\nBumblebee\nBumbling\nBumholes\nBumlapping\nBumm\nBummer\nbump\nbumper\nBumpher\nBumpin\nBumpin'\nBumping\nBumpkin\nBumps\nBumpy\nbums\nBum's\nBumtastic\nBun\nBunch\nBunda\nbundle\nbungalow\nBungee\nbunghole\nBunk\nbunker\nBunking\nBunkmate\nBunks\nBunnie\nbunnies\nBunnie's\nBunnington\nbunny\nBunny’s\nBunnybooty\nBunny's\nbunny-style\nbuns\nBunz\nBuongiorno\nBuoy\nBuoys\nBurbs\nBurden\nburger\nBurgers\nburglar\nburglar’s\nBurglarize\nburglars\nburglar's\nBurglars\nburglary\nBurgundy\nburied\nBuries\nBurke\nBurke's\nBurlesque\nBurley-Quinn\nBurly\nBurma\nburn\nBurned\nburner\nBurnett\nBurnin\nBurnin'\nburning\nburns\nBurp\nBurrito\nburst\nbursting\nBursts\nBurton\nBurundanga\nbury\nBurying\nBury's\nbus\nBusBoy\nBusenwunder\nbush\nBushand\nbushes\nbushhiking\nBushido\nBushless\nBushmeat\nBushvibe\nBushwalker\nBushwalking\nbushweed\nbushwhacked\nbushy\nbushzilla\nbusiness\nBusinesslady\nbusinessman\nBusinessman's\nbusinessmen\nbusinesswoman\nBusinesswoman's\nBusker\nBusMan\nBusom\nBussom\nBussy\nbust\nBustani\nbusted\nBustedon\nBuster\nbusters\nbuster's\nBusters\nbustier\nbustiere'\nBustiest\nBustin\nBustin'\nbusting\nBustle\nBustout\nBust-out\nbusts\nbusty\nbusty-ness\nbusy\nBusybodies\nBusybra\nbut\nBUTACA\nButch\nButcher\nButcher0-0\nButders\nbutler\nButler?\nButler’s\nbutler's\nbum\nBum?\nBuma\nbumabulous\nBumafly\nBumaholic\nBumaholics\nBum-bang\nBum-Blasted\nBum-Blessed\nbum-cheeks\nBum-crazed\nBumdad\nbumer\nBumercup\nbumercups\nBumered\nBumerflies\nBum-erflies\nbumerfly\nBumerflylips\nBumerfly's\nBumerican\nBumering\nBumermilk\nbumers\nBumerz\nBumfinger\nbummake love\nBum-make love\nBummake loveed\nBum-Make loveed\nBummake loveer\nBummake loveers\nBummake loveer's\nbummake loveing\nbum-make loveing\nBummake loveing\nBum-Make loveing\nBummake loves\nBum-Make loves\nbumhole\nBumholes\nBuming\nbumload\nBumman\nBummans\nBumman's\nBummore\nbumock\nBumocks\nbumon\nbumons\nBumplay\nBumplays\nBumplayscom\nbumplug\nbum-plug\nBumplug\nbum-pluggin\nBumpluggin\nBumplugs\nBum-plugs\nBumricks\nBumrose\nbumroses\nbums\nBum's\nBUMS\nbumsex\nBum-Sex\nBumshake\nBumslut\nBumsluts\nBum-Slut's\nbum-stretching\nBumstruck\nBumwoman\nBumwomen\nBuuren\nbuxom\nBuxome\nBuxomy\nbuy\nBuyer\nBuyers\nBuyer's\nbuying\nbuys\nBuzonga-rama\nbuzz\nBuzz-Cut\nBuzzed\nbuzzer\nBuzzes\nBuzzin\nbuzzing\nBuzzonga\nBuzzy\nBV\nBV's\nBW\nbwc\nBWD\nBWF\nby\nbye\nBye-Bye\nBygone\nByrne\nByron\nBysmark\nByvasa\nBZ\nbz_plus_sample20\nBZ001\nBZ002\nBZ003\nBZ004\nBZ005\nBZ006\nBZ007\nBZ008\nBZ009\nBZ010\nBZ011\nBZ012\nBZ013\nBZ014\nBZ015\nBZ016\nBZ017\nBZ018\nBZ019\nBZ020\nBZ021\nBZ022\nc\nC20489\nC3P0\nC5\nca\nCA$HH\ncab\nCabaeva\nCaballo\ncabana\nCabang-A-Bro\nCabaret\ncabbie\ncabbies\nCabbie's\ncabby\nCabby's\nCabecera\nCaber\nCabie\ncabin\nCabinet\ncable\nCabo\nCABODAY\nCaboose\nCachier\nCachonda\nCadabra\nCaddy\nCaddyshag\nCade\nCaden\nCadence\ncadet\nCadets\nCadey\nCafÃ©\ncafe\nCafelita\nCaffee\nCamake loveteria\ncag\ncage\ncaged\nCages\nCagey\nCaging\nCailean\nCaimanes\nCaimax\nCain\nCain's\nCairo\nCait\nCaitlin\nCaitlyn\nCajun\ncake\nCake?\ncakealicious\nCakefun\nCakes\nCakes's\nCAL\nCala\nCalabre\nCalada\nCalamity\nCalathe\nCale\nCaleb\nCalendar\nCale's\nCaley\nCalhoun\nCali\nCali’s\ncalibe\ncaliber\nCalibre\nCalibre's\nCalick\nCalico\nCalico's\nCalidad\ncaliente\nCaliente’s\nCaliforina\nCalifornia\nCalifornian\nCalifornication\nCalify\nCaliginosity\nCaligirl\nCalis\nCali's\nCalisi\nCalista\nCalisthenics\nCalisyn\ncall\nCall\tScene\ncall?\nCallahan\nCallaway\nCalle\ncalled\nCaller\ncallgirl\nCall-Girl\nCallie\nCallies\nCallie's\ncalling\nCallipygian\nCalliste\nCalloway\ncalls\nCalls?\nCallum\ncall-up\ncalm\nCalmant\ncalmed\nCalming\nCalmy\nCalogera\ncalories\nCalun\nCalvert\nCalvert’s\nCalvert's\nCalves\nCalvet\nCalvin\nCaly\nCalypso\nCalza\ncam\nCam?\nCama\nCAMARA\ncamarad\nCamargo\nCambio\nCamden\ncame\ncame?\ncamel\nCameltoe\nCamel-Toe\nCameltoes\nCameo\ncamera\nCamerafun\ncameraman\ncamera-man\nCameraman\ncameramans\nCameraman's\nCameraphone\ncameras\nCamera-shy\ncamera--ZOOOM\nCameron\nCamerons\nCameron's\nCamerons's\nCameryn\nCameTo\ncamgirl\nCamgirls\nCam-girls\nCamGirls\nCami\nCamil\nCamila\nCamile\nCamilla\nCamillaafter\nCamilla's\nCamille\nCamille's\nCami's\nCamisa\nCamisia\nCamisole\nCammer\nCammie\nCammille\nCammille's\nCamming\nCammo\nCammy\nCamo\nCamofingers\nCamofingers1\nCamofingers2\nCamostrip\nCamouflage\nCamouflaged\nCamouflages\ncamp\ncampaign\nCampaignin\nCampaigning\nCampamento\nCampbell\ncamper\nCampers\nCampfire\ncampground\nCampgrounds\ncamping\ncampo\nCampos\nCampside\nCampsite\ncampus\ncamcat\nCamrie\nCamryn\nCams\nCamshow\ncan\nCAN??\ncan’t\nCanada\nCanada loves anal\nCanada?\nCanadanal\nCanadesi\nCanadian\nCanadians\ncanal\nCanali\ncanals\nCanapa\nCanara\nCanas\nCancel\nCanceled\nCanceling\nCancelled\nCanceller\nCancho\nCancino\nCancun\nCandace\nCandace's\nCandalyn\nCandee\nCandel\nCandela\nCandelabra\nCandelight\nCandelit\nCandi\nCandice\nCandice's\nCandid\ncandidate\nCandidates\nCandidly\nCandie\nCandied\ncandies\nCandii\nCandis\nCandise\nCandisummers\ncandle\nCandlecoochie\nCandlelight\nCandlelit\nCandle-lit\nCandlelove\nCandleloving\ncandles\nCandlestick\nCandletoy\nCandlewax\nCandor\nCandra\ncandy\nCandy?\nCandy’s\nCandybelle\nCandycane\nCandy-Coated\ncandy-colored\nCandygirl\nCandyland\nCandy-licious\nCandy's\nCandy-sweet\ncane\ncaned\nCaneDay\nCanela\nCanela’s\nCanela's\nCanelaThe\ncanes\nCanine\ncaning\ncaningMade\nCaniora\nCannibal\nCannoli\ncannon\ncannons\ncannot\nCano\nCanoe\nCanon\nCanon's\nCanoodling\ncanopy\ncans\ncant\ncan't\nCant\nCan't\ncantaloupe\ncantaloupes\nCantina\nCanuck\ncanvas\ncanyon\nCaomei\nCaomei's\nCap\nCapable\ncapacity\nCapades\nCapcan\nCape\nCapella\nCapelli\nCaper\nCapers\nCapistrano\nCapital\nCapo\nCapone\nCapones\nCappelli\nCappers\nCappuccino\nCapra\nCapri\nCaprice\nCapricious\nCaprimature\nCaprio\nCapris\nCaps\ncapt\ncaptain\nCaptain’s\ncaptains\nCaptain's\ncaptivate\nCaptivated\nCaptivates\nCaptivating\nCaptivation\ncaptive\ncaptivity\ncaptor\ncapture\ncaptured\ncaptures\nCapturing\ncar\nCar?\nCara\nCaraballo\ncaramel\nCaramelle\ncaravan\nCarb\ncard\nCardboard\ncardgames\nCardille\nCardinale\ncardio\nCardiogasm\nCardo\nCardova\ncards\nCardwell\ncare\ncareer\nCarefree\nCareful\ncarefully\nCaregiver\nCareless\nCaren\nCarerra\nCares\nCares?\ncaresif\ncaress\nCaressa\nCaresse\nCaressed\nCaressers\ncaresses\ncaressing\nCaretaker\nCarey\nCargo\nCari\nCaribbean\nCaribe\nCaricias\nCarie\nCarin\nCarina\nCarina's\nCarine\ncaring\nCarioca\nCarissa\ncarjacked\nCarl\nCarla\nCarla4Garda\nCarla's\nCarleigh\nCarley\nCarli\nCarlisto\nCarlito\nCarlo\nCarlos\nCarlton\nCarly\nCarlyn\nCarlynn\nCarman\nCarmel\nCarmela\ncarmelicious\nCarmeline\nCarmelita\nCarmell\nCarmella\nCarmella's\nCarmen\nCarmens\nCarmen's\nCarmichael\nCarmina\nCarmine\nCarnage\ncarnal\nCarnale\nCarnalis\nCarnality\nCarnevale\nCarni\ncarnival\nCarnivorous\nCarnosa\nCaro\nCaroggio\ncarol\nCarole\nCarolin\nCarolina\nCarolina's\nCaroline\nCaroline's\nCaroll\nCarolline\nCarol's\nCarolyn\nCarolyne\ncarousal\nCarouse\nCarousel\nCarpa\nCarpe\ncarpenter\ncarpenter's\nCarpenters\nCarpenter's\ncarpet\nCarpetfun\nCarpetmunchers\nCarpetspread\nCarpool\nCarr\nCarre\nCarrera\nCarrera's\nCarrerBOOTY\nCarrere\ncarriage\nCarrie\ncarried\nCarriera\ncarries\nCarrie's\nCarrine\nCarrington\nCarrmen\nCarro\nCarroll\ncarrot\nCarrothole\ncarrots\ncarry\nCarry's\ncars\nCarshow\nCarso\nCarson\nCart\nCartagra\ncarte\ncarted\ncartel\nCarter\nCarters\nCarter's\nCartier\nCartoon\nCarts\ncartwheel\nCartwheeler\nCartwheels\nCartwright\nCarumba\nCaruso\nCarvalho\nCarved\nCarves\nCarving\nCarwases\ncarwash\nCary\nCas\ncasa\nCasada\nCasana\nCasani\nCasanova\nCasca\nCascade\ncase\nCasey\nCaseys\nCasey's\ncash\nCash-hungry\nCashier\nCashier's\nCashing\nCashmere\nCashmere's\nCashmire\nCasi\ncasing\ncasino\nCasinos\nCasio\ncasitng\nCaso\nCasper\nCbooty\nCbootyady\nCbootyandra\nCbootyandras\nCbootyandra's\nCbootyette\nCBOOTYh\nCbootyi\nCbootyian\nCbootyia's\nCbootyidey\nCbootyidi\nCbootyidy\nCbootyidy's\ncbootyie\nCbootyies\nCbootyie's\nCbootyini\nCBOOTYINIHard\ncBOOTYtle\nCbootyy\nCbootyye\nCbootyy's\ncast\nCastaway\nCastaways\ncasted\nCastelli\nCastello\ncaster\ncasti\ncastin\ncasting\nCastings\ncastle\nCastles\nCastle's\nCastling\nCASTRATION\nCastro\nCastro's\ncasts\ncasual\ncasually\nCasualties\ncat\nCat?\nCatalan\nCatalimes\nCatalina\nCatalonia\nCatania\nCatarina\nCatBOOTYtrophe\nCatastrophe\ncatastrophic\ncatastrophically\nCataCANic\nCatcalling\ncatch\nCatch'em\ncatcher\nCatchers\ncatches\nCatching\nCate\ncategory\nCater\ncaterer\nCaterine\nCatering\ncatfight\nCatfighting\nCatfish\nCatfished\nCatfishing\nCatgirl\nCathaleen\nCatharios\nCathartic\nCathedra\nCatherina\nCatherine\nCatherine's\nCatheterize\nCathia\nCatholic\nCathrine\nCathy\nCathy's\nCaties\nCatigory5\nCatlyn\nCATRINA\nCatrine\nCatryn\ncats\ncat's\nCats\nCat's\nCatSitter\ncatsuit\nCatt\nCattiva\ncattle\nCattleprod\nCattle-prod?\nCattleprodded\ncatty\nCatwalk\nCatwoman\nCatwoodman\nCaty\nCaucasian\ncaught\nCaught?\ncaught_on_camera\nCaughtBoundaries\nCauke\nCauldron\nCaulfield\ncause\ncaused\ncauses\nCausing\nCaution\ncautious\nCavali\nCavalli\nCavallo\nCavalry\nCavani\nCavanni\nCavanni's\ncave\nCavegirl\nCavemen\ncavern\nCavernous\nCaves\nCave-Whore\nCavialean\nCaviar\nCavities\nCavity\nCAVR\nCayden\nCayenne\nCayenne's\nCayla\nCaylian\nCayo\nCayton\nCazden\nCazen\nCazzo\nCBS\nCBT\nCC\nCCOK\nCC's\nC-Cup\nCds\nCe\nCease\nCece\nCecelia\nCece's\nCecil\nCecila\nCecila’s\nCecile\nCecilia\nCecilia's\nCecily\nCedella\nCedric\nCee\nCEHoe\nCeiling\nCeira\nCeleb\ncelebrate\ncelebrates\nCelebrating\ncelebration\ncelebrity\nCeleb's\nCelena\nCelest\nCeleste\nCelestia\nCelestial\nCelestine\nCelesto\nCelia\nCelibacy?\nCelina\nCelinange\nCelina's\nCeline\ncell\ncellar\nCellblock\nCellia\nCellist\nCellmate\nCellmates\nCello\nCellophan\ncellphone\nCellular\nCemetery\ncensus\nCensus?\nCent\ncenter\nCenterfold\nCenterfolds\nCenterpiece\ncentipede\nCentipenis\nCentral\nCentre\nCentric\ncentury\nCEO\nCEOhhh\nCeo's\nCeramic\nCerania\nCereal\nCerealorgasm\nCeremonies\nCeremony\nCerna\nCerrera\ncertain\ncertainly\nCertification\ncertified\nCervix\nCesar\nCeviche\nCeylon\nCeylonBlondie1-5\nCF\nCfnm\ncfnmbootyage\nCFNMasturbation\nch\nCh1\nCh2\ncha\nChacha\nchachas\nchad\nChade's\nchain\nchained\nchaining\nChainlink\nchains\nChains?\nchair\nChair?\nChair1\nChair2\nChairclitrub\nChaircream\nChairbanana\nChairdong\nChairfinger\nChairfingers\nChairfun\nChairhole\nChairlove\nChairmasturbation\nchairNeck\nchairOrgasm\nChairpanties\nChairpink\nChairplay\nChairplay1\nChairplay2\nChairpleasure\nChaircat\nChairrubbing\nchairs\nChairs1\nChairs2\nChairstrip\nChaircans\nChairtoy\nChairty\nChairvibe\nChaise\nChakra\nChakras\nChalice\nChalizo\nChalk\nChalkboard\nchallenge\nChallenge?\nChallenged\nChallenger\nchallenges\nchallenging\nCham\nchamber\nChambermaid\nChambers\nChambre\nChamille\nChamille's\nchamp\nchampagne\nChampaign\nchampange\nChampayne\nchampion\nchampioninto\nChampionNon-Scripted\nchampionRookie\nchampions\nChampionSeriously\nchampionship\nchampionships\nCHAMPIONSMore\nCHAMPONSHIPVENDETTA\nChamps\nchance\nchances\nChance's\nChancing\nChandelier\nChandler\nchanel\nChanele\nChanel-ing\nChanell\nChanelle\nChanel's\nchange\nchanged\nChangeover\nchanger\nChangeroom\nchanges\nChange-Up\nchanging\nchannel\nChanneling\nChannels\nChanning\nChannson\nChanonne\nChantal\nChanta-Rose\nChantell\nChantelle\nChanting\nChanty\nChaos\nChaos's\nChaotic\nchap\nchapel\nChaperone\nChapman\nChapoteo\nChaps\nChapter\nCharacter\nCharacteristics\nCharades\ncharge\nCharged\ncharger\ncharging\nCharima\nCharina\nChariot\ncharisma\nCharismas\nCharisma's\nCharismatic\nCharitable\nCharity\nCharity's\nCharlee\nCharlee's\nCharlei\nCharlene\nCharles\nCharleston\nCharlette\nCharley\nCharley's\nCharli\nCharlie\nCharlie's\nCharli's\nCharlize\nCharlon\nCharlott\nCharlotta\nCharlotta's\ncharlotte\nCharlottes\nCharlotte's\nCharly\nCharlys\nCharlyse\nCharlyze\ncharm\nCharmaine\nCharmane\nCharmed\nCharmel\nCharmelle\nCharmelle's\nCharmer\nCharmer's\nCharmeure\ncharming\ncharms\nCharnelle\nCharolette\nCharro\nCharwomen\nChary\nChary's\nchas\nchase\nChase’s\nChased\nChaser\nChasers\nChases\nChase's\nChasey\nChasi\nChasin\nchasing\nChasity\nchasm\nChbootyis\nChaste\nChaster\nChastised\nchastisement\nchascany\nChascany's\nchat\nChateau\nChatroom\nChats\nchatting\nchatty\nChaty\nChauffeur\nChauffeurs\nChavez\nChavon\nChavon's\nChayanne\nChayen\nChayenne\nChaynes\nChayse\nChayse'ing\nChaz\nChazz\nChe\ncheap\ncheap-booty\nCheaper\nchearleader\ncheat\ncheated\ncheater\nCheater’s\nCheaters\nCheater's\ncheatin\ncheating\nCheating?\ncheating-booty\ncheats\nChechick\nChechik\nChechik's\ncheck\nchecked\nChecker\nCheckered\ncheckers\nCheckin\nchecking\nCheckmate\nCheckMates\nCheckmating\nCheck-Out\nCheckroom\nchecks\ncheckup\ncheck-up\nCheckup\nCheck-Up\nChecky\nCheek\nCheek-Peeking\ncheeks\nCheeks's\ncheeky\ncheer\nCheermelons\nCheerful\nCheergirl\nCheering\ncheerleader\nCheerleader??\ncheerleaders\ncheerleader's\nCheerleaders\nCheerleader's\nCheerleading\ncheerleads\nCheerlickers\ncheers\nCheertoy\nCheer-Up\nCheese\nCheesecake\nCheesetoy\nCheeta\ncheetah\nCheezy\nchef\nChef’s\nChefa\nChefs\nChef's\nChekc\nChelsea\nchelsea's\nChelsey\nChelsey's\nChelsie\nChelsie's\nChelsy\nChemise\nchemistry\nChenille\nChenin\nChennin\nCherchez\nCheree\nCherelle\nCherie\nCheries\nCherie's\nCherish\nCherished\nCherokee\nCherokee's\ncherrie\ncherries\ncherrilicious\nCherrries\ncherry\nCherry-Eve\nCherryl\ncherrypoppens\nCherrys\nCherry's\nCherub\nChery\ncheryl\nches\nChesly\nchess\nChessie\nChesscat\nChesstoy\nChess-ty\nchest\nchest?\nChested\nChester\nChester's\nChesticles\nChestnuts\nchests\nchesty\nchet\nChet's\nChevallier\nChevelle\nChevys\nchew\nchewed\nchewing\nchews\nChewy\nCheyanne\nCheyene\ncheyenne\nCheyenne's\nCheyne\nChi\nChianti\nChiara\nChiarore\nChic\nchica\nChicago\nChicana\nChicane\nChicas\nchich\nChichi\nchick\nchickas\nchicken\nChickenhead\nchicks\nchick's\nChicks\nChick's\nchicks?\nChickSicilian\nchickster\nChicky\nchico\nChie\nChief\nChief's\nChie's\nChiffon\nChigusa\nChika\nChikita\nChild\nChildbearing\nchildhood\nChildish\nChile\nChilean\nChili\nchill\nChill?\nChilled\nChilli\nchillin\nChillin'\nchilling\nchillout\nchill-out\nChillout\nChill-out\nchills\nchilly\nChillz\nchime\nChimera\nChiminea\nChimney\nchin\nChina\nChinara\nChina's\nChinatown\nChinchilla\nChinese\nChinga\nChinita\nChinny\nChintia\nChip\nChippendale\nChipper\nChips\nChiquita\nChiropractor\nChirstmas\nChis\nchiseled\nChisty\nChit\nChitchat\nChivalry\nchix\nChix's\nChiyoko\nChleo\nChloe\nChloe’s\nChloee\nChloes\nChloe's\nChloey\nChloie\nChlorine\nchocha\nchocholate\nchock\nChocked\nChock-Full\nChocky\nchoco\nchocoalte\nchocolate\nChocolatebooty\nChocolateGimmy\nchocolates\nChocolatey\nChoi\nchoice\nChoices\nchoir\nChoirboy\nchoke\nchoked\nChoker\nChokers\nchokes\nChokey\nchoking\nChoky\nChoky's\nChola\nCholita\nchonga\nchongalicious\nChongas\nChonies\nChoo\nChoo-Choo\nchoose\nChoose?\nchooses\nchoosing\nChop\nChop-Johnsons\nChopper\nChoppers\nChops\nchopstick\nChord\nChords\nChore\nChoreography\nchores\nChoreta\nChoripan\nChorizo\nChorreándose\nChorros\nchose\nChosen\nChow\nChow-Down\nchowing\nChris\nChriselle\nChrismBOOTY\nChrismukkah\nChrissie\nchrissy\nChrist\nChrista\nChristal\nChristall\nChristel\nChristelle\nchristen\nChristening\nChristens\nChristen's\nChristey\nChristi\nChristian\nChristiana\nChristiana's\nChristianne\nChristiansen\nchristie\nChristien\nChristie's\nChristin\nChristina\nChristina's\nChristine\nChristine's\nchristmas\nChristmBooty\nChristm-Booty\nChristmBOOTY\nChristmbootyhe\nChristoph\nChristopher\nChristoph's\nChristou\nChristy\nChristy's\nChritsmas\nChroma\nchrome\nChronic\nchronicles\nChrystal\nChrystall\nChrystin\nChu\nChub\nchubby\nChubette\nChuck\nChuckies\nchugger\nChugs\nChuiiii\nChula\nChulas\nChulita\nChulucila\nChum\nChummy\nChun\nChung\nChunga\nChunks\nchunky\nchupa-chups\nChupando\nchurch\nChurn\nChurned\nChurnin\nChurning\nChurns\nChurrasco\nChurro\nchute\nChyanne\nChyna\nChynaWhite\nCia\nCiao\nCiara\nCibime\nCici\nCiciliya\nCieli\nCielo\nCiera\nCierra\ncig\ncigar\nCigarette\ncigarettes\nCigarillo\nCigarrettes?\nCigarro\nCigars\nCiggy\nCigogniatella\nCikita\nCilla\nCimmerian\nCinammon\nCincinnati\nCinco\nCindee\nCinderella\nCinderella’s\nCinderella's\nCindi\nCindy\nCindyrella\nCindy's\nCINEDOE\ncinema\nCinemas\nCinematic\nCinephile\nCinn\nCinna\nCinnamon\nCinn's\nCinq\nCinquecento\nCinthia\nCinthia's\nCintia\nCintia's\nCintija\nCinturinha\nCinturone\nCip\nCipka\nCipper\nCipriana\nCipriana's\nCirca\ncircle\nCircles\nCircling\nCircuit\nCircuito\nCircular\ncircus\nCiri\nCIRIS\nCirque\nCis\nCisgender\nCita\nCitadel\nCitah\nCitation\nCited\nCithina\ncities\nCitizen\ncitizenship\nCitrus\ncity\nCivil\ncj\nCJ's\nCKM\ncla\nclad\nClaidia\nclaim\nClaimed\nClaiming\nClaims\nClain\nClair\nClaire\nClaire's\nClairette\nClairvoyance\nClairvoyant\nclam\nclamp\nclamped\nclamps\nClams\nclan\nclan?\nClanddi\nClandestine\nclap\nClapper\nClappin\nClapping\nclaps\nclaquent\nClara\nClara's\nClarck\nClare\nClarence\nClarice\nClarig\nclarinet\nClaris\nClarise\nClarissa\nClarisse\nClaritas\nClarity\nClark\nClark’s\nClarke\nClarks\nClark's\nClarkson\nCLARO\nClary\nClase\nClash\nClashing\nclbooty\nCl-Booty\nCLBOOTY\nClbooty?\nclbootyes\nclbootyic\nCl-booty-ic\nCLBOOTYIC\nClbootyica\nclbootyical\nClbootyically\nClbootyico\nclbootyics\nClbootyiest\nClbootyified\nClbootyifieds\nclbootymate\nclbootymates\nclbootyroom\nClbootyy\nclbootyyand\nClaude\nclaudia\nClaudia's\nclaudio\nClaus\nclause\nClaustrophobe\nClaustrophobia\nClaustrophobic\nClaw\nclawing\nClay\nClaymore\nClayton\nclea\nclean\nClean?\ncleaned\ncleaner\nCleaners\nCleanest\nCleani\ncleanie\ncleaning\nCleanliness\nCLEANnDIRTY\ncleans\nCleanse\nCleanser\nClean-Shaved\nclean-shaven\nCleansing\ncleanup\nclean-up\nCleanup\nClean-up\nclear\nClearance\nCleardong\nCleared\nClearing\nclearly\nCleartip\nClea's\ncleavage\nCleavage?\nCleavages\nCleaved\nCleina\nClemens\nClement\nClementine\nCleo\nCleo-Clap-Booty\nCleopatra\nCleopatra's\nCleo's\nclerk\nClerks\nCleveland\nClever\nCliche\nclick\nClicker\nClicks\nclient\nClient Before\nClient’s\nClienta\nclients\nclient's\nClients\nClient's\nClif\nCliff\nCliffea\ncliffs\nClimactic\nClimate\nclimax\nClimaxers\nclimaxes\nclimaxing\nClimaXXX\nClimb\nClimber\nClimbin\nClimbing\nclimbs\nClinch\nCline\nCling\nClinger\nClingier\nclinic\nclinical\nClinically\nClint\nClinton\nClio\nclip\nClipboard\nclipped\nClipping\nclips\nClisto\nclit\nclitacular\nClitannica\nCliterate\nClit-fingering\nClitfun\nClitical\nclit-kiss\nclitLet's\nClitlicking\nClitman\nClitmas\nClitness\nclitoral\nclitorama\nClitorides\nclitoris\nclitourist\nClitrub\nclits\nClit's\nClitter\nclitters\nClittickler\nClittilating\nclitty\nClitvibe\nClit-Wand\nclock\nclocked\nClocks\nclockwork\nCloe\nCloee\nCloei\nClone\nCloned\nClones\nClonesploitation\nclose\nclosed\nClosely\nCloseness\ncloser\nClosers\ncloses\nClosest\ncloset\nCloset?\nCloseted\nClosettalk\ncloseup\nClose-up\nclose-ups\nCloseups\nClose-Ups\nclosing\nClosure\ncloth\nClothed\nclothes\nClothes?\nClothespin\nclothespins\nclothing\ncloths\ncloud\nCloud-Castles\nClouds\nCloudy\nClout\nClove\nclover\nclown\nClowning\nClowns\nClown's\nclub\nclub;\nClubber\nclubber's\nClubbin\nclubbing\nclubby\nClubin\nClublife\nclubsandycom\nclueless\nClues\nClumsy\nClunge\nClunkers\nCluster\nclustermake love\nclutch\nclutches\nClutching\nclutz\nClyde\nC'mere\nCMNM\nC'mon\nco\ncoach\nCoacharoo\nCoaches\nCoaching\nCoachs\nCoach's\nCoal\nCoast\ncoaster\ncoat\ncoated\nCoating\ncoats\nCoax\ncoaxes\ncoaxing\ncob\ncobbler\nCobra\nCobwebs\nCoby\ncoc\nCo-Captain\nCoccos\nCochella\nCochons\nmember\nC-O-C-K\nmember?\nMember’s\nmember-ada's\nMember-addict\nMember-addicted\nMember-Addiction\nMember-A-Banana-Doo\nMemberadile\nMemberafornia\nMemberaholic\nMember-a-Licious\nMemberamania\nMemberamole\nMemberaphrenia\nMember-Arm\nMemberasian\nMemberateel\nMemberation\nMember-Attack\nMemberazoid\nMember-Beating\nMemberblocking\nMemberblocks\nMember-Bot\nMemberboy\nmemberBrutal\nMember-building\nMemberbuster\nMember-Calling\nMemberCam\nMembercentration\nMembercoaster\nMember-Craving\nMember-crazed\nmember-crazy\nMemberdazian\nMember-ditions\nMember-Dogs\nmembered\nMemberenstein\nMemberer\nMemberervention\nMemberet\nMember-expert\nMemberfather\nMemberfidential\nMemberfight\nMembermake loveing\nMemberfun\nMemberhanded\nMemberhandling\nmemberhardening\nmember-hardening\nMember-Holding\nMemberhole\nmember-hungry\nmember-hunt\nMemberin\nMembering\nMembering?\nMember-Inn\nmember'll\nMemberload\nmember-lover\nmember-loving\nMember-Lunch\nMember-Mad\nMembermas\nMembermaster\nMember-Milking\nmember-napped\nMembernapped\nMemberNatbootyia\nMemberness\nMemberney\nMembero\nMember-obsessed\nMemberolate\nMemberold\nmember-o-thon\nMemberout\nMemberpelia\nMemberpit\nmember-pleaser\nMember-pleasing\nmemberpop\nMemberporn\nmember-praising\nmember-punished\nMemberraiser\nmember-ride\nmember-rider\nmemberriding\nmember-riding\nMemberriding\nMember-riding\nMemberrin\nmemberring_toss\nmembers\nmember's\nMembers\nMember's\nMembers?\nmember-sharing\nmemberShe\nmembershower\nmember-shy\nmembersicle\nmember-sicle\nMembersicle\nmembersicles\nMemberslapped\nmember-slobbering\nMembersLoves\nMember-Slut\nMember-smacked\nMembersmith\nMembersmoker\nMember-Sports\nmembersshe\nMemberstar\nMEMBERSTARR\nMember-Sticks\nMemberstuffed\nMember-Stuffs\nmember-su\nMembersuck\nMember-Suck-Come-Back\nmembersucker\nmember-sucker\nMembersucker\nMembersuckers\nmembersucking\nmember-sucking\nMembersucking\nMember-sucking\nMemberSucking\nMember-Sucking\nMembersuckingly\nMember-Suffocated\nMembersultation\nmembersville\nmembertache\nmembertail\nMember-Tail\nMEMBERtail\nmembertails\nmember-taker\nMembertease\nMemberteasing\nMEMBERtion\n'Membertip\nmembertoberfest\nMembertoy\nmember-treatement\nMemberTS\nMemberupational\nMemberwarming\nMemberwash\nMemberwhore\nMember-Whore\nMember-Worshiping\nmembery\nMemberzilla\nCoco\nCocoa\nCocoa-licious\ncoconut\ncoconuts\nCocoon\nCocos\nCoco's\ncocscious\ncocsk\ncoctail\nCoctomom\nCoda\nCoddie\ncode\ncode?\nCodename\nCoder\ncodes\nCodeys\nCodi\nCodie\nCodi's\nCody\nCody's\nCoeath\ncoed\nco-ed\nCoed\nCo-ed\ncoeds\ncoed's\nCoeds\nCoed's\nCo-eds\nCo-ed's\nCo-Eds\nCoelho\nCoen\ncoerced\nCoercion\nCoeur\ncof\ncoffee\ncoffee?\nCoffeeshop\nCoffeetime\nCoge\nCogeme\nCogen\nCoger\nCogerse\nCogida\nCogidas\nCogidón\nCogidos\nCogiendo\nCogiendola\nCognizance\nCohen\nCohstly\nCoin\nCoincidence\nCoincidences\ncoition\nCoitus\nCojas\nCojo\nCokolady\nCola\ncolada\nColby\ncold\nCole\nColegio\nColeman\nCole's\nColette\nColette's\nColibri\nColin\nCollab\nCollaboration\nCollar\nCollared\nCollaring\nCollateral\nColle\ncolleague\ncolleagues\nCollect\nCollecting\ncollection\ncollector\ncollector’s\nCollectors\nCollects\nColleen\ncollege\ncollege-girl\nCollege's\nCollegial\ncollegian\ncollegiate\ncollide\nCollider\nCollie\nCollin\nCollins\ncollision\nCollusion\nColm\nColmea\nColombia\ncolombian\nColombiana\nColombianas\nColombians\nColombian's\nColon\nColonel\nColony\ncolor\nColor?\nColorado\ncolored\nColorful\ncolorful_coochies\ncolors\nColorvibe\ncolossal\nColossus\nColour\ncoloured\nColourful\nColours\nColt\nColter\nColter's\nColton\nColucci\nColumbian\nColumn\nColumnist\nColumns\ncom\nComa\nComatose\nComb\nCombat\ncombatantsSexiest\ncombinatio\ncombination\ncombination?\ncombined\ncombines\nCombining\ncombo\nCombos\nCombustion\ncome\ncomeback\nComeback's\ncomedian\nComedienne\nComedy\nCome-Hither\nComely\nCome-Policias\ncomer\nComers\ncomes\nCome's\nComet\nCometh\nComeuppance\ncomfort\ncomfortable\nComfort-able\nComfortable?\nComforter\nComforting\ncomforts\ncomfy\ncomfy?\ncomic\nComicbook\nComicBookNerd666\nComics\nComiéndose\nComilan\ncoming\ncommand\nCommanded\nCommandeering\nCommander\ncommander's\ncommanding\ncommando\ncommands\nComme\nCommence\nCommencement\ncommentaries\nCommentary\nCommercial\nCommiserations\ncommission\nCommissions\nCommitment\ncommitted\ncommittee\ncommodity\ncommon\nCommon?\nCommunal\nCommunication\nCommunion\nCommunity\nCommute\nCommuters\ncomo\nCompagnia\nCompañeras\nCompañero\nCompañeros\ncompanion\nCompanions\ncompany\nCompare\ncompares\nComparing\nComparison\nComparte\nCompartiendo\nCompbootyionate\ncompatibility\nCompcutie\nCompelled\nCompelling\ncompensates\nCompensation\ncompete\nCompeting\ncompecanion\ncompecanive\nCompecanor\ncompecanors\ncompecanve\ncompilation\ncomplain\nComplaint\nComplaints\nComplementary\nComplementing\ncomplete\ncomplete?\nCompleted\ncompletely\nCompletion\nCompleto\nComplex\nComplexion\nComplicated\nComplicit\ncompliment\nComplimentary\ncompliments\nComply\nCompomises\nCompound\nCompra\nCompromise\nCompromised\nCompromising\nCompton\ncompulsion\nCompulsive\ncomputer\nComputergirl\nComputerlove\nComputertoy\nComputervibe\ncomrade\nCOMT\ncon\nCon-Artist's\nconceive\nConceived\nConcentrado\nconcentrate\nConcentrating\nConcentration\nConcept\nConception\nConcern\nConcerned\nconcerns\nconcert\nConcert?\nConcerto\nConcessie\nConchita\nConcierge\nConclave\nconclusion\nConmembertion\nCon-Member-Tion\nconcoction\nConcrete\nconcubine\nConda\nCondición\nCondiment\nCONDIMENTS\nCondition\nconditioner\nConditioning\ncondo\ncondom\nCondoms\nCondor\nConduciendo\nConduct\nconductor\nCone\nConer\nconfectioner\nconferance\nconference\nConfés\nConfess\nconfesses\nconfession\nConfessional\nConfessionals\nconfessions\nConfesso\nconfidence\nconfident\nConfidential\nconfidently\nConfiding\nConfined\nconfirm\nconfirmed\nconfirms\nConfiscated\nconflict\nConflicted\nConflicts\nConflixxx\nConfluence\nConfront\nConfrontation\nConfronted\nConfronting\nConfronts\nconfused\nConfusedAn\nConfusion\nCongor\nCongrats\ncongratulates\nCongratulations\nCongressman's\nConie\nConiza\nConjugal\nconjured\nConjuring\nConlatio\nConnbootyeur\nConne\nConnect\nconnected\nConnecticut\nConnecting\nconnection\nConnections\nConnects\nConnell\nConner\nConner’s\nConners\nConner's\nConnie\nConnie's\nConning\nConniver\nConnoisseur\nconnoisseur1\nConnoisseurs\nConnor\nConnorligula\nConny\nConnys\nConoce\nConor\nCoños\nConquer\nConquered\nConquerer\nConquering\nConqueror\nconquerors\nconquers\nconquest\nconquests\nconquistadora\nConrad\nConroy\ncons\nConscience\nconscious\nconscious?\nconsciousnessA\nconsecration\nConsensual\nConsent\nConsentia\nConsenting\nconsequences\nConsequential\nConservation\nconservative\nConservatives\nconsider\nConsideration\nConsignment\nConsika\nconsolation\nconsole\nConsoled\nconsoles\nConsoling\nConsort\nconsortium\nconspiracy\nConstance\nConstanse\nconstant\nconstantly\nConstrained\nconstrict\nConstriction\nConstrictor\nConstrual\nConstruct\nConstructawhore\nconstruction\nConstructive\nConsult\nConsultancy\nConsultant\nconsultation\nConsulting\nConsumer\nConsumes\nConsuming\nConsummate\nConsummating\nConsumption\ncont\ncontact\nContain\nContained\ncontainer\nContatte\nContaxis\nContemplating\nContemporary\nContempt\ncontender\ncontent\nContentment\nContento\nContessa\ncontest\ncontestant\nConti\nContinents\ncontinue\nContinued\ncontinues\nContinuing\nContinuous\ncontinuously\nContinuum\nConCanioning\ncontorted\nContortion\nContortionist\nContortionistcom\nContorts\nContour\nContours\ncontr\ncontraband\nContraception\ncontraceptive\ncontract\ncontractor\nContractor5\nContractors\nContracts\nContractual\nContrbootyt\nContrast\nContrasted\nContrasting\ncontrasts\nContreras\nContribution\ncontrol\ncontrol'\nControl\nControl?\nControladora\nControlBoth\nControlla\nControlled\nController\nControlling\nControls\ncontroversial\ncontruction\nConundrum\nConvalescence\nConvalescing\nConvenciendo\nConvenience\nConvenient\nConvent\nConvention\nconversation\nconversion\nConvert\nconverted\nConverter\nConvertible\nConverting\nConverts\nconvict\nConviction\nconvince\nconvinced\nconvinces\nConvincing\nConvo\nConvulsing\nCony\nCoo\ncooch\ncoochie\nCoochies\nCoochy\nCoocoo\nCoo-Coo\ncook\ncooked\nCookery\ncookie\ncookie?\ncookies\nCookie's\nCookies?\ncookin\nCookin?\ncooking\nCooking?\ncooking_with_katana\nCookoff\nCookout\ncooks\ncool\nCoolbabe\nCooldown\nCooled\nCooler\nCoolest\nCooling\nCoolness\nCools\nCo-Op\nCooper\nCooperations\nCooper's\nCoordination\ncooter\nCooters\ncooze\nCoozy\ncop\nCop?\nCopafeel\nCop-a-Feel\nCopier\nCopine\nCoping\ncopper\nCopper's\nCoppertone\nCopping\ncops\ncop's\nCops\nCop's\nCOPS\nCops?\ncopulating\nCopulation\nCopy\ncopy-book\nCopycats\nCoquendam\ncoquette\nCoquettes\nCoquettish\nCora\nCoracao\nCoral\nCoralie\nCoralyn\nCora's\nCorazon\nCorba\nCorbin\ncorbusier\nCord\nCordeindo\nCordella\ncordially\ncords\ncore\nCoreena\nCorey\nCori\nCorin\nCorina\nCorinna\nCorinna's\nCorinne\nCorizo\ncork\nCorking\nCorkscrew\nCorly\ncorn\ncorn?\nCornbread\nCorncob\nCorncobcat\nCornbanana\nCornejo\nCornelia\ncorner\nCornered\nCornerMonday\nCorners\nCornfed\nCorn-Fed\nCornflower\ncornholded\nCornhole\nCornholed\nCornholers\ncornrolls\nCornucopia\nCorny\nCorona\nCoronation\nCoronavirus\nCorpo\nCorporal\ncorporate\nCorporation\nCorps\nCorpse\nCorre\ncorrect\ncorrected\nCorrecting\ncorrection\nCorrectional\nCorrective\ncorrectly\nCorrects\nCorrepecanion\nCorrer\ncorridor\nCorrine\nCorrupt\ncorrupted\nCorrupting\nCorruption\ncorrupts\ncorset\nCorsets\nCort\nCortes\nCortés\nCortez\nCortknee\nCortney\nCorvette\nCory\nCory's\nCosette\nCosier\nCosima\nCosmetic\ncosmetologist\nCosmia\nCosmic\nCosmo\nCosmos\nCosplay\ncosplayer\nCosplaying\nCosset\nCost\nCosta\nCo-Stars\nCostello\nCostiero\nCostina\nCostly\ncosts\ncostume\nCostumed\nCostumes\ncosy\nCotillion\nCOTM\nCoton\nCotone\nCottage\nCotten\ncotton\nCottonpanties\nCoty\ncou\nCouc\ncouch\nCouch2\nCouchbeads\nCouchclit\nCouchclitrub\nCouchmember\nCouchcooch\nCouchcoochie\nCouchcookie\nCouchcream\nCouchbanana\nCouchdong\ncoucher\nCouches\nCouchfinger\nCouchfingers\nCouchfun\nCouchhole\nCouching\nCouchlips\nCouchlove\nCouchmasturbation\nCouchpink\nCouchplay\nCouchplay1\nCouchplay2\nCouchpleasure\nCouchcat\nCouchrub\nCouchrubbing1\nCouchrubbing2\nCouchrubdown\nCouchsocks1\nCouchsocks2\nCouchspread\nCouchsurfer\nCouchsurfing\nCouchtalk\nCouchtease\nCouchteaser\nCouchtoy\nCouchtoy2\nCouchtoys\nCouchvibe\ncougar\ncougar_or_kitten\nCougariffic\nCougar-In-Law\nCougarland\nCougarRecruitscom\nCougars\nCougar's\nCougarville\nCough\ncould\ncouldn\ncouldn't\nCouldnt\nCouldn't\nCouncil\nCoundown\nCounrty\nCounsel\ncounseling\nCounselling\nCounsellor\ncounselor\nCounselors\nCounselor's\ncount\ncountdown\ncounter\nCounterblow\nCountermember\nCounters\nCounterspread\ncountertop\nCountertopbanana\nCountertopdong\nCountertopplay\nCountertoy\nCountervibe\nCountess\nCounting\ncountry\nCountry…\nCountryman's\ncountryside\nCounts\ncounty\nCoupel\ncouple\nCouple’s\nCoupledom\nCouple-Friends\ncouples\nCouple's\ncoupleStripped\nCoupling\nCoupon\nCourage\nCourageous\ncourier\nCourier2\ncourse\ncourt\ncourtesan\nCourthouse\nCourting\nCourtland\nCourtlyn\ncourtney\nCourtney's\nCourtroom\nCourtship\nCourtside\nCourtyard\ncousin\ncousins\nCousin's\ncouture\nCouverture\nCova\nCova's\ncove\nCovelli\ncoven\nCovenant\ncover\nCover?\nCoverage\ncoverall\ncoverd\nCoverDeeper\ncovered\nCovergirl\nCovergirls\nCovering\ncovers\nCovert\nCovet\nCoveted\nCOVID\nCovy\ncow\ncow?\nCowabunga\ncowboy\nCowboys\nCowers\ncowgirl\ncowgirls\ncowoker\ncoworker\nco-worker\nCoworker\nCo-worker\ncoworkers\nco-workers\nCoworkers\nCoworker's\nCo-Workers\nCo-Worker's\nCowpoke\nCox\nCoxFinger\nCoxMake loveed\nCoxFull\nCox's\nCoxx\nCoxxx\nCoxxxx\nCoxz\nCoy\ncoyness\nCozies\ncozy\nCozying\nCP\nCpr\nCPX\ncra\nCraaaaaazy\nCracio\ncrack\ncracked\nCracker\nCrackers\ncracking\nCrack-Jacking\nCracks\nCrack-tivating\nCradle\ncraft\nCrafted\nCraftGood\nCrafting\nCrafts\nCraftsman\ncrafty\nCrag\nCraig\nCraigslist\ncram\nCramhole\nCrammathon\ncrammed\nCramming\nCramorama\nCramp\nCramped\nCrampie\nCramping\nCramps\ncrams\nCrane\nCranium\nCrank\nCrankability\nCranky\nCranne\nCrannies\nCranny\ncrap\nCrappy\ncrash\ncrasher\nCrashers\ncrashes\nCrashing\nCrater\nCravate\ncrave\ncraved\nCraven\ncraver\ncravers\ncraves\ncravin\ncraving\ncravings\nCrawford\nCrawl\nCrawler\nCrawling\ncrawls\ncraze\nCrazed\nCrazier\nCrazies\nCraziest\ncrazy\nCrazynails\nCrazzers\nCreador\nCreagan\ncream\nCream2\nCreama\nCreamapie\nCream-Coated\ncream-covered\ncreamed\nCreamer\nCreamers\nCream-Filled\nCreamfinger\nCreamfun\nCreamin\nCreamin'\ncreaming\nCreamlove\ncreampie\ncream-Pie\nCreampie\nCream-Pie\nCREAMPIE\nCreampie?\ncreampied\nCreampie'd\nCreamPied\ncreampiee\ncreampie'ed\ncreampies\ncream-pies\nCreampies\nCreampie's\nCreamPies\nCream-Pies\nCreamcat\nCreampying\ncreams\nCream's\ncreamsicle\nCreamteen\nCreamcans\nCreamtoy\nCreamvibe\ncreamy\nCreamybanana\nCreapie\ncreapied\ncreate\ncreated\ncreates\nCreating\nCreation\nCreations\ncreative\nCreativity\nCreator\nCreature\nCredentials\ncredit\nCredits\nCredosta\ncreed\nCreek\ncreep\nCreeper\nCreepers\nCreepin\nCreeping\nCreeps\nCreepshot\nCreepshots\nCreepshow\ncreepy\nCremapie\ncreme\nCremosa\nCrempie\nCrempiee\nCrempies\nCrenia\nCrepe\nCrescendo\nCreta\nCrevice\ncrew\ncrewed\ncrib\nCribs\nCricket\ncries\ncrime\nCrimefighters\nCrimefighter's\nCrimes\ncriminal\nCriminally\nCrimped\ncrimper\ncrimson\nCrippled\nCrippler\nCris\ncrisis\nCrispy\nCriss-cross\nCrissie\ncrissy\nCrissy's\nCrist\nCrista\nCristal\nCristalia\nCristalina\nCristi\nCristian\nCristin\nCristina\nCristine\nCristini\nCristy\nCritic\nCroatia\nCroatian\nCrocker\nCroft\nCroft's\nCroissant\nCroix\nCrook\nCrooked\nCrooks\ncrop\ncropped\nCroquet\ncross\ncrossage\ncross-age\nCrossage\nCross-age\nCrossdresser's\ncrossdresses\nCrossdressing\nCrossed\ncrosses\nCrossfire\nCrossfit\nCrossmake love\nCrossing\nCrossover\ncross-partner\nCrossroads\nCross-Training\nCrosswalk\nCrossword\ncrotch\nCROTCHES\ncrotchless\nCrotchlesspanties\nCrouching\nCrouz\ncrow\ncrowd\ncrowded\nCrowd's\ncrown\nCrowne\nCrowned\nCrowne's\nCrowning\nCrrystal\ncruce\nCrucible\nCrucifi3d\ncrucified\nCrucimake loveed\nCrude\nCrue\ncruel\nCruelty\ncruise\ncruiser\ncruisers\nCruises\nCruise's\ncruisin\nCruisin'\ncruising\ncruisy\nCrumpet\nCrump-cans\nCrunch\ncrusade\nCrusader\ncrush\nCrushed\nCrusher\nCrushers\nCrushin\ncrushing\nCrusing\nCrust\nCrusted\nCrutch\nCruz\nCruzCami\nCruzin'\nCruzing\nCruz'ing\nCruz'n\ncry\ncry?\nCrybaby\ncrying\nCrypt\nCrysstal\nCrysta\ncrystal\nCrystalis\nCrystall\nCrystallum\nCrystal's\nCrysteen\nCrystl\nC's\nCSI\nCsilla\nCsuka\nCthulhu\nCtrl\ncu\nCU4\nCuando\nCuarentena\nCuarta\nCuarteto\nCuatro\nCub\nCuba\ncuban\nCubana\nCubanita\nCubanitas\ncubes\nCubicle\nCubicolo\nCubs\nCuca\nCucci\ncuck\nCuckage\nCuckboys\nCucked\nCuck-held\ncuckhold\nCuck-Husband\nCucking\ncuckold\nCuckoldAn\nCuckolded\ncuckolding\nCuckoldress\ncuckolds\ncuckold's\nCuckolds\nCuckold's\nCuckoo\nCuckqueen\nCucks\ncuddl\ncuddle\nCuddler\nCuddles\nCuddling\nCuddly\nCudna\ncue\nCue-less\nCuerda\ncues\nCuff\ncuffed\nCuffing\ncuffs\ncuisine\nCuke\ncul\nCulantro\nCulear\nCulen\nculinary\nCulito\nCullen\nCullet\nCullote\nCulminate\nculmination\nculo\nCulona\nCulos\nCulo's\ncult\nCultivator\nCultural\nCulture\nCulver\nCunilingus\nCuninlingus\nCunni\ncunnie\ncunnies\nCunnilinguists\ncunnilingus\nCunnilini\nCunning\nCunningham\ncunny\ncup\nCupboard\nCupcake\nCupcakes\nCupid\nCupidity\nCupids\nCupid's\ncups\ncup's\nCups\nCup-Stretching\nCura\nCurb\ncurbed\nCurbside\ncure\ncured\ncured?\ncures\nCurfew\nCuriel\ncuring\nCuriosities\ncuriosity\ncurious\nCurious?\ncuriousity\nCuriously\ncurl\nCurler\nCurlers\nCurlia\nCurls\ncurly\nCurly-haired\nCurranta\nCurricular\nCurriculum\nCurry\nCurrying\ncurse\nCursed\nCurseGia\nCurt\nCurtai\nCurtain\ncurtains\nCurtin\nCurtis\ncurvaceous\nCurvacious\ncurvalicious\nCurve\ncurvealicious\ncurved\ncurves\nCurves'\nCurvetoy\nCurvey\nCurvier\ncurvy\nCurvy-Booty\ncurvylicious\ncushi\nCushin\ncushion\nCustard\nCustodial\nCustodian\nCustodian's\nCustody\ncustom\nCustome\ncustomer\ncustomers\nCustomer's\nCustoms\nCuswapping\ncut\nCut?\nCutbacks\ncute\nCute'\nCUTE\nCute Friend\ncute?\ncute-a-licious\nCute-Bootyed\nCutebraids\nCutelingerie\nCute-looking\ncuteness\nCutepanties\ncutest\ncutester\nCutey\ncutie\nCutie'\nCUTIE\ncutie’s\nCutie-blondie\ncutie-pie\nCutiepie\ncuties\ncutie's\nCuties\nCuties'\nCutie's\nCutiesGalore\nCutiesGalorecom\nCutleries\nCutlery\nCutoffs\nCut-offs\ncuts\nCutters\ncuttest\nCut-Throat\nCuttie\nCutties\nCuttin\nCutting\nCutty\nCuty\nCuve\nCuvee\ncuz\nCuzzler\nCyan\nCyara\ncyber\nCybergranny\nCybersex\nCyberteen\nCybill\nCyborg\nCycene\nCycle\nCycles\nCycling\ncyclist\nCyclone\ncyclops\nCyd\nCyd's\nCyle\nCyndi\nCyndi's\nCynical\nCyns\nCynthia\nCynthia’s\nCynthya\nCyntia\nCypher\nCyprus\nCyrstal\nCyrus\nCyrus's\nCytherea\nCytherea's\nCytheria\nCZ331\nCzarina\nCze\nczech\nCzech’s\nCzech'08\nCzech'09\nCzech'10\nCzech'11\nCzech'12\nCzechHunter\nCzech-ie\nCzeching\nCzech-ing\nCzechmate\nCzechmates\nCzechs\nCzech's\nd\nD***\nd’oeuvre\nD’s\nD20\nD8\nda\nd'Abramov\nDacada\nDachs\nDaciesa\nDacota\ndad\ndad?\ndad’s\nDadcrush\ndaddies\ndaddy\nDaddy'\n'Daddy\ndaddy?\nDaddy’s\nDaddy-Dom\nDaddy-Make love\ndaddy's\nDaddys\nDaddy's\nDade\nDadivoso\ndad's\nDads\nDad's\nDae\nDafeny\nDaffodils\nDafne\nDagger\nDaggers\nDagmar\nDahl\nDahlia\nDahliah\nDahlia's\nDahly\nDai\nDaiana\nDaikiri\nDailany\ndaily\nDaines\nDainty\nDaiquiri\nDaire\nD'Aire\nDaire's\nDairy\nDai's\nDaisies\ndaisy\nDaisy?\nDaisychain\nDaisydukes\nDaisy's\nDaizy\nDaizy's\nDajen\nDak\nDakina\nDakoda\nDakota\nDakotas\nDakota's\nDal\nDalatika\nDalaunay\nDale\nDali\nDalia\nDalila\nDallas\nDalle\nDally\nDalny\nDalong\nDalroas\nDalton\nDalush\nDalushious\nDaly\ndam\nDama\nDamadedos\ndamage\nDamaged\nDamages\nDamaris\ndame\nDamelo\ndames\nDame's\nDAMES\nDamesHer\nDamian\nDamien\ndamion\ndamn\ndamned\nDamnit\nDamo\nDamoika\nDamon\nDamon?\nDamone\nDamon's\nD'Amour\nDamp\ndamsel\nDamsels\nDan\nDana\nDanae\nDanali\nDanarama\nDanas\nDana's\nDanaya\nDancando\ndance\ndance?\nDanceAnd\ndanced\nDancefloor\nDance-Off\ndancer\ndancer?\ndancers\nDancer's\ndances\nDances1\nDances2\nDanceslotion\nDancespread\nDancestrip1\nDancestrip2\nDancey\nDancin\ndancing\nDancingjeans\nDancingstrip1\nDancingstrip2\nDancoj\ndandelion\nDandy\nDane\nDane's\nDang\nD'Angelo\ndanger\nDanger’s\nDangerbooty\ndangerous\nDangerously\nDangers\nDanger's\nDanger-Veruca\nDangle\nDangling\nDani\nDania\nDanica\nDanicas\nDaniel\nDaniela\nDaniela's\nDaniele\nDaniella\nDaniellas\nDanielle\nDanielle's\nDanielly\nDaniels\nDanielsova\nDani-Make loveing-Daniels\nDanii\nDanika\nDanis\nDani's\nDank\nDanlee\nDanleku\nDanna\nDanni\nDanniels\nDanni's\nDanny\nDannyBoy\nDannys\nDanny's\nDans\nDan's\nDanseuse\nDansing\ndante\nDantes\nDantric\ndanube\nDany\nDany's\nDaor\nDaor's\ndap\nDAP?\nDAP_make loveing\nDAP+P\nDap+Cat\nDAP+Vag\nDAP3on1\nDAPArwen's\nDAP-certified\nDAP'd\ndap'ed\nDaped\nDAP'ed\nDAP'edSZ618\nDapes\nDAP-Fest\nDAPGAPES\nDaphne\nDaphnee\nDaphne's\nDaphnie\nDaphynne\nDap'n'Roses\ndapped\ndappes\nD'Apres\nd'apres-midi\nDAPs\nDAPV\nDaquiri\nDara\nDara's\nDarby\nDarby's\nDarcee's\nDarci\nDarcia\nDarcia's\nDarcie\nDarcie's\nDArclyte\nDarcy\ndare\nDare?\nDare”\ndared\ndaredevil\ndares\nDare's\nDaria\nDarian\nDaria's\nDariel\nDarien\nDarin\nDarina\ndaring\nDarius\nDarjaneling\ndark\nDark?\nDarkangel\nDarker\ndarkest\ndark-haired\nDarkholme\nDarkko\nDarkko's\nDarkly\ndarkness\nDarko\nDarkroom\nDark's\nDarkside\nDarkX\nDarla\nDarlene\nDarlin\nDarlin'\ndarling\ndarlings\ndarling's\nDarlings\nDarling's\ndarn\nDarrel\nDarren\nDarryl\nDarts\nDarya\nDaryl\nDaryl's\ndaryn\nDaryna\nDarzsarika\nDas\ndasani\nDash\nDash’s\nDasha\nDasha's\nDashing\nDashuka\nDasi\nDasia's\nDasilva\nDASP\nDbooty\nDbootyis\nDasy\nDat\ndate\nDates\nDate's\nDate-Swap\ndating\nDatse\nD'Atteinte\ndaughter\ndaughter?\ndaughter’s\nDaughter-in-Law\nDaughterly\nDaughter-Mom\ndaughter's\nDaughters\nDaughter's\nDaugther\nDaugther's\nDaunting\nDava\nDavani\nDave\nDavey\nDavia\nDavid\nDavida\nDavid's\nDavie\nDavila\nD'Avila\nD'Avilla\nDavina\nDavina's\nDavinci\nDavis\nDavis-Fitt\nDavon\nDAVP\nDavy\nDawgzzz\nDawkins\ndawn\nDawning\nDawns\nDawn's\nDawson\nDax\nday\nDay'\nDAY\nday?\nDaya\nDaya’s\ndayAftercare\nDayana\nDayanara\nDayanne\ndaybed\nDaybeddong\nDaybedcat\nDaybedrub\nDaybedspread\nDaybedtoy\nDaybreak\nDaycare\nDaydream\ndaydreamer\ndaydreaming\ndaydreams\nDaye\nDaylene\nDaylene's\ndaylight\ndaylights\nDay-Live\nDayLIVE\nDayLynn\nDayna\nDayne\nDay-off\ndays\nDay's\nDaysie\nDaysy\nDaytime\nDayton\nDaytona\nDayzjha\nDaza\nDaze\nDazia\nD'azia\nDazzle\nDazzler\nDazzles\nDazzling\nDBJ\nD-Cup\nD-cups\nDd\nD'd\nDD\nDD-Cup\nDDD\nDDDs\nDDD's\nDDF\nDDs\nDD's\nDD'sWill\nde\nDe4th\nDea\nDeacon\nDeacon’s\ndead\ndeadbeat\nDeadliest\nDeadline\nDeadly\nDeadpool\nDeadra\nDeaf\nDeaky\ndeal\nDeal?\ndealer\nDealers\nDealer's\nDealing\ndeals\nDeal's\ndealt\ndeams\ndean\nDeana\nDeAngelo\nDeanna\nDean-riding\nDean's\ndeap\ndear\nDear?\ndearest\nDearly\nDearmond\nDeArmondImmobile\nDearmond's\nDeArmondSpread\nDeArmondTrapped\ndeath\nDeauxma\nDeb\nDebacle\nDebajo\nDebased\nDebasement\nDebate\nDebater\nDebaters\nDebauchee\nDebaucherous\ndebauchery\nDebauching\nDebbie\nDebbie's\nDebby\nDe'Bella\nDebello\nDebi\nDebilitating\nDebora\nDeborah\nDebowe\nDebra\nDebs\ndebt\ndebtor\nDebts\ndebut\ndebutant\ndebutante\nDebutantes\nDebuting\ndebuts\nDEBUMeam\nDecade\ndecadence\nDecadency\nDecadent\nDecades\nDeCapri\nDeCarlo\nDeceit\nDeceitful\ndeceive\nDeceived\ndeceiving\nDecember\nDecembers\nDecember's\nDecency\nDeceptacon\nDeception\nDeceptions\nDeceptive\nDeceptively\nDecesions\ndecide\ndecided\ndecides\ndecieving\ndecimated\nDecimation\nDecimator\ndecision\nDecisione\nDecisions\nDecisive\nDeck\nDecker\nDeclan\nDeclaration\nDeclare\nDecline\nDecluttering\nDeco\nDeco'\nDecollaring\nDecompress\nDeconstructing\nDe-constructing\nDecorate\ndecorating\nDecoration\nDecorative\nDecorator\nDecoy\nDed\nDede\nDede's\ndedicated\nDEDOS\ndee\nDee-cent\nDeed\nDE'ed\nDeedee\ndeeds\nDeejay\nDeel\nDeelicious\nDeelight\nDee-manding\nDeen\nDeena\nDeeni\nDeens\nDeen's\ndeep\nDee-P\nDEEP\nDeep?\nDeepAn\nDeep-Cleavaged\nDeep-Johnsoned\nDeepen\ndeeper\nDeepest\nDeepfinger\ndeep-fingered\nDeep-fingering\nDeepfingers\nDeep-Ho\ndeeply\nDeeporgasm\nDee-posit\nDeepPrison\nDeepthoat\nDeepthorats\ndeepthroat\ndeep-throat\nDeepthroat\nDeep-throat\nDeepThroat\nDeep-Throat\nDEEPTHROAT\ndeepthroated\ndeep-throated\nDeepthroated\nDeep-Throated\ndeepthroater\nDeep-Throater\nDeep-Throater's\nDeepthroatfrenzy\ndeepthroating\nDeep-throating\nDeepthroating's\ndeep-throatist\ndeepthroats\ndeep-throats\nDeepthroats\nDeep-Throats\nDeep-Woods\nDeepy\nDeer\nDees\nDee's\nDeez\nDef\nDefaced\ndefeat\ndefeated\nDefective\nDeFeet\nDefeets\nDefendi\ndefense\nDefenseless\nDefete\nDefiance\ndefiant\nDefied\nDefies\ndefiled\nDefilement\nDefiling\nDefine\nDefined\nDefining\ndefinit\ndefinitely\ndefinition\nDefinitive\ndeflorated\ndeflorating\ndefloration\ndeflorators\ndeflower\ndeflowered\nde-flowered\nDeflowered\ndefloweret\nDeflowering\nDe-Flowering\ndeflowers\nDefrancesca\nDefrancesco\ndefy\nDefying\nDeGarden\ndegradation\nDegrade\nDegraded\nDegrades\ndegrading\nDegre\ndegree\ndegrees\nDegrey\nDegrey?\nDeGreyOrgasmal\nDegustation\nDegustia\nDei\nDeicide's\nDeirdre\nDeithe\ndeja\nDeja-Vu\nDejaWhooooooohooooo\nDejeuner\nDeJour\nDekoian\ndel\nDelacroix\nDelacuze\nDelage\nDelane\nDelaney\nDelano\nDelano's\nDelatori\nDelatossa\nDelatosso\nDelatta\nDelaunay\nDelaure\nDelaware\ndelay\nDelayed\nDelbenai\nDelcroix\nDele\ndelectable\nDelectably\nDelectation\nDelegate\nDeLeon\nDelete\nDeleted\nDeli\nDelia\nDeliah\nDeliberately\ndelicacy\ndelicate\nDelicately\nDelicato\nDelice\nDelice's\nDeliciosa\ndelicious\nDeliciously\ndeliciousness\ndeligh\ndelight\nDe-Light\nDelighted\ndelightful\nDelightfully\ndelights\nDelight's\nDelila\nDelilah\nDelilah's\nDelinquent\nDelinquents\nDelirious\nDeliriously\nDelite\ndeliver\ndelivered\nDeliveries\nDelivering\ndelivers\ndelivery\nDelivery Guy\nDeliveryman\nDella\nDellai\nDellai's\nDella's\nDelmar\nDelmonico\nDelor\nDelotta\nDelphi\nDelphina\nDelphine\nDelprado\nDelray\nDelta\nDeltas\nDelta's\nDeltore\nDeltore's\nDeluca\nDeluge\nDelush's\nDelusion\nDelusional\nDelusions\nDelux\ndeluxe\nDeluxe's\ndemand\ndemanded\nDemanding\ndemands\nDemarco\nDemeaning\nDemeanor\nDemellza\nDemented\nDemento\nDemer\nDeMer's\ndemi\nDemia\nDemida\nDemi's\ndemise\nDemitri\nDemitri's\nDemmy\nDemmy's\nDemo\nDemoed\ndemolish\ndemolished\nDemolishers\nDemolishing\nDemolition\ndemon\nDemon?\nDemone\nDemonia\nDeMonia's\nDemonic\ndemons\nDemon's\ndemonstrate\ndemonstrates\nDemonstrating\ndemonstration\nDeMoore\nDemore\nDemoted\nDemotion\nDemure\nDemystified\nden\nDena\nDenae\nDenae's\nDendro\nDeni\ndenial\nDenice\nDenicek's\ndenied\nDeniese\ndenim\nDenis\nDenisa\nDenise\nDenise's\nDeniska\nDenisse\nDeniz\nDenmark\nDenni\nDennis\nDenta\ndental\nDentata\ndentist\ndentist?\nDentists\nDenver\nDenvile\nDenville\nDenx\nDeny\nDenyle\nDe'Nyle\nDenys\nDeomm\ndepartment\nDepartmental\nDeparture\ndepend\nDependance\nDependent\nDeployment\ndeported\nDeportment\ndeposit\nDepositing\nDeposits\nDepot\ndepraved\nDepraving\nDepravity\ndepressed\ndepression\nDeprivation\nDeprived\ndepth\ndepths\nDeputy\nDer\nDera\nDera's\nderby\nDereck\nDerek\nDerek's\nDerelict\nDereven\nDerick\nDeris\nDermott\nDe-Robed\nDeron\nDerrek\nDerrick\nDerricks\nDerriere\nDerrieres\nDert-eeh\nDeRusky\nDerza\nDerza's\nDes\nDesanges\nDesani\nDesantis\nDescending\nDescent\ndesciption\nDescribe\ndescribes\ndescription\nDesensitized\nDeserae\nDeseray\ndesert\nDeserted\nDeserts\ndeserve\ndeserved\ndeserves\ndeserving\nDeshiri\nDesi\nDesia\nDesign\nDesignated\nDesigned\ndesigner\ndesigners\nDesigning\ndesirable\nDesirae\ndesire\ndesired\ndesiree\nDesiree's\nDesireful\ndesires\nDesiring\nDesirous\nDesist\ndesk\nDeskbanana\nDeskfinger\nDeskfinger2\nDeskfingers\nDeskfingers2\nDeskcat\nDesktease\nDesktop\nDeskvibe\nDesmond\nDesolate\nDesoriente\nDesorra\nDeSouza\ndesperate\ndesperately\nDesperation\nDespertar\nDespídeme\nDespiértame\ndespite\nDesrey\ndessert\nDessert?\nDesserts\ndessous\ndestination\ndestinations\ndestinaton\ndestined\nDestinee\ndestiny\ndestiny's\nDesto\ndestoyed\nde-stress\nDestress\nDe-Stressers\ndestroy\ndestroyed\nDestroyer\nDestroyers\ndestroying\ndestroys\ndestrozado\ndestructed\ndestruction\ndestructive\nDestruir\ndetachable\nDetached\nDetail\nDetailing\nDetails\nDetained\nDetalla\ndetective\ndetective's\nDetectives\nDetengan\ndetension\ndetention\ndeterminationSaffron\ndetermine\nDetermined\nDetermining\nDethrone\nDethroned\nDetonation\ndetour\nDetours\nDettwiller\nDeuce\nDeuces\nDeus\nDeutera\nDeutsche\nDeutschland\nDeux\nDeva\nDeVale\ndevastated\nDevastates\ndevastating\nDevastatingly\nDevastation\nDevastator\nDevaun\nDevelopment\nDeven\nDeveraux\nDevereaux\nDevestation\nDevi\nDeviance\ndeviant\nDeviants\ndevice\nDevicebondage\nDeviceBondagecom\ndevices\nDevide\ndevil\nDevil’s\nDeviled\ndevilish\nDevilish-suckening\nDeville\nDeville's\nDevillish\ndevils\nDevil's\nDevin\ndevine\nDE-VINE\nDevine's\nDevinn\nDevious\nDevirginization\nDevirginize\nDevirginized\nde-virginizes\nDevirginizes\nDevis\nDeVita\nDevo\nDevoe\nDevon\nDevonLee\nDevons\nDevon's\nDevora\ndevoted\nDevotes\ndevotion\nDevour\nDevoured\nDevourer\ndevouring\ndevours\nDevyn\nDevyn's\ndew\nDewey\nDewy\nDex\nDexter\nDexterity\nDexter's\nDey\nDeyny\nDeytrois\nDez\nDezeray\ndharma's\nDhoe\ndi\nDia\nDía\nDiabla\nDiablita\nDiablo\nDiabolic\nDiabolical\ndiabolically\nDiabolique\nDiagnos-booty\ndiagnose\nDiagnosis\nDiagnosis;\nDiairies\nDiakosmo\nDial\nDial-A\nDial-a-Date\nDialing\nDialogue\nDiamanti\ndiamond\nDiamond;\nDiamond-Day\ndiamonds\nDiamond's\nDiamondThe\nDiana\nDiana's\nDiane\nDiangelo\nDianna\nDianne\ndiapers\nDiaries\ndiary\nDiaryI\ndiary's\nDias\nDiastasi\nDiaz\nDiazz\nDibbs\nDibs\nDic\nDicapri\nDicaprio\nDice\njohnson\nD-I-C-K\njohnson?\njohnson’s\nJohnsonalicious\nJohnsonam\nJohnsonamentary\njohnsonconnection\njohnson-down\nJohnsondown\nJohnson-down\njohnsoned\nJohnsoned-down\nJohnsonen\nJohnsonens\nJohnsonerators\nJohnsonerdown\nJohnsonFan\nJohnsonFriction\nJohnsonhancement\nJohnsonhead\nJohnsonie\njohnsonin\njohnsonin'\nJohnsonin\njohnsoning\njohnsonlashing\nJohnsonlicious\nJohnsonline\nJohnsonlish\nJOHNSONlivery\nJohnsonly\nJohnsonmas\nJohnsonmatized\nJohnsonness\nJohnsonnosis\nJohnson-O-Gram\nJohnsonorette\nJohnsonotine\nJohnson-Riding\nJohnsonrupting\njohnsons\nJohnson's\nJOHNSONS\njohnsons?\nJohnsonsmissal\nJohnsonspection\nJohnsonstraction\nJohnson-stributor\nJohnsonstruction\nJohnsonsturbance\nJohnsonsuckers\nJohnsonsucking\nJohnson-Swingin\njohnsontating\nJohnsontation\nJohnson-tation\njohnson-teasing\nJohnsontection\nJOHNSONtector\nJohnsontention\nJohnsontrap\nJohnsonus\njohnsony\nJohnsony's\nDicopu\ndictates\nDictating\nDictation\nDictator\ndid\ndid?\nDidas\nDiddle\nDiddled\nDiddler\nDiddles\nDiddling\nDiDevi\nDidgeridoo\nDidi\ndidn\ndidn’\ndidn't\nDidnt\nDidn't\nDido\ndie\nDied\nDiego\nDiego's\nDiem\nDiem's\nDies\nDiesel\nDiesel's\ndiet\nDietrich\nDiezel\ndif\nDifeo\ndiffere\ndifference\nDifference?\nDifferences\ndifferent\nDifficult\nDifficulties\nDiffusion\nDifrancesco\nDIFS\ndig\nDigalyn\nDigest\ndigger\ndiggers\ndiggin\nDigging\nDigital\ndigits\nDigivanni\nDignity\nDigression\ndigs\nDii\nDijana\nDiji\nDike\nDikes\nDikki\nDila\nDilan\nbanana\nbanana?\nbanana_drone\nBanana1\nBanana2\nBananachair\nBananacouch\nBananacream\nbananacycle\nBananacycles\nBananadiva\nBananaer\nBananaers\nBananafitness\nBananaforme\nbanana-make love\nBananamake love\nBananafun\nBananafun1\nBananafun2\nBananain'\nbananaing\nBananalaundry\nBananalove\nBananaplay\nBanana-pleasing\nBanana-riding\nbanana-rific\nbananas\nbanana's\nBananas\nBanana's\nBananasit\nBananasqueeze\nBananateen\nBananatime1\nBananatoy\nBananatwo\nBananavibe\nBananazer\ndildum\nDilection\nDilemma\nDilf\nDILFs\nDiligence\nDiligenis\nDiligent\ndiligently\nDill\ndillan\nDillion\nDillions\nDillion's\nDillon\nDillon's\nDilo\nDiloa\nDim\nDima\nDimarco\nDimarco's\nDiMarcoThe\nDimas\ndime\ndimension\nDimensions\nDimepiece\ndimes\nDimez\nDimitri\nDimitri's\nDimitry\nDimond\nDimonty\nDimpled\ndimples\nDina\nDinae\nDinamico\nDinara\nDine\nDined\ndiner\nDinero\ndines\nding\nDingalinger\nDingo\nDingy\nDinida\ndining\nDiningroom\nDiningroomdelight\nDiniz\nDinker\ndinner\ndinner?\nDinnerFisting\nDinnerReminding\nDinners\nDinner's\nDinnerTable\nDino\nDino-Sized\nDinov\nDionne\nDionne's\nDionta\nDionysian\nDior\nDiora\nDiore\nDiore's\nDior-itized\nDior's\nDiosa\ndip\nDiploma\nDiplomacy\nDiplomatic\nDiplomat's\ndipped\nDipper\ndippers\nDipper's\nDippied\ndippin\ndippin'\nDippin\ndipping\ndips\nDiraya\nDire\ndirect\ndirected\ndirecting\ndirection\ndirections\ndirectly\ndirector\ndirectors\nDirector's\nDirects\nDirk\ndirrrrty\nDirrrty\nDirrty\ndirt\nDirtier\ndirtiest\ndirtty\ndirty\nDirtyfinger\nDirtyHuge\nDirty-minded\nDirty-Mouthed\nDirty-talking\nDis\nDis?\ndisadvantage\nDisagree\ndisappe\ndisappear\ndisappearance\ndisappearing\nDISAPPEARS\ndisappoint\nDisappointed\nDisappointing\nDisarm\nDisarmed\nDisaster\nDisasters\ndisc\nDiscerning\nDischarged\ndisciple\nDisciplina\nDisciplinarian\nDisciplinary\ndiscipline\ndisciplined\ndisciplines\nDisciplining\ndisclosure\nDis-Clothe-Her\ndisco\nDisconnected\nDiscotheque\ndiscount\ndiscounts\nDiscourse\ndiscover\ndiscovered\ndiscoveries\ndiscovering\ndiscovers\ndiscovery\nDiscreet\nDiscretion\nDisculpa\nDiscusses\ndiscussing\ndiscussion\nDiscussions\ndisease\nDisfrutar\ndisgrace\ndisgraced\nDisgraceful\nDisgraces\nDisgruntled\nDisguise\nDisguised\nDisguises\nDisgusting\ndish\nDishes\nDishing\nDishonest\nDishonor\nDishwasher\nDishwashing\ndislikes\nDismissal\nDisney\nDisneyland\nDisobedience\ndisobedient\nDisobey\nDisobeying\ndisobeys\nDisorder\nDisorderly\nDisparo\nDispatch\nDispenser\nDispenses\nDisplacement\ndisplay\nDisplayed\ndisplaying\ndisplays\nDisplease\nDispleasure\nDisposa\nDisposal\nDisposition\nDispuesto\nDispute\nDisqualified\nDisquiet\nDisrespect\nDisrespectful\nDisrespecting\nDisrobe\nDisrobed\nDisrobing\nDisruption\nDisruptive\nDissapoints\nDissatisfied\nDisseminate\nDisses\nDissolute\ndistance\nDistancing\nDistant\nDistensione\nDistorted\nDistorter\ndistrac\nDistract\ndistracted\nDistracting\ndistractingll\ndistraction\ndistractions\ndistracts\ndistraught\nDistress\nDistressed\nDistrict\nDisturb\nDisturbance\nDisturbang\nDisturbed\nDisturbing\nDita\nditch\nditched\nditches\nDitching\nDitona\nDittle\nDITTO\nDitty\ndiva\nDivan\nDivano\ndivas\nDiva's\ndive\nDivers\nDiverse\nDiversion\nDiversionary\ndiversions\nDiversity\nDivertida\ndives\ndivide\nDivided\nDivination\ndivine\nDivineas\nDivinely\nDivine's\ndiving\nDivinis\nDivinity\nDivino\ndivisionVeteran\nDivo\ndivorce\nDivorcé\nDivorce?\nDivorce-bound\ndivorced\ndivorcee\nDivorcée\nDivorcees\nDivsis\nDix\nDixias\nDixie\nDixie’s\nDixie's\nDixon\nDixon's\nDIY\nDiya\nDiyana\nDizel\nDizzie\ndizzy\nDizzying\nDj\nDjefucmi\nDjeniffer\nDjiana\nDJing\nDjpink\nDjcat\nDJ's\nDJThe\nDjulianaa\nDL\nDleon\nDmitry\nDMs\nDMV\nDNA\nDnay\nDNothing\ndo\nDo?\nDoan\nDoans\nDoble\nDobrila\ndoc\nDoc?\nDoc’s\nDocciare\nDocciatura\nDocile\ndock\nDockside\nDockworker\nDockworkers\ndocs\ndoc's\nDocs\nDoc's\ndoctor\ndoctor?\nDoctor’s\nDoctorate\nDoctoring\ndoctors\ndoctor's\nDoctors\nDoctor's\nDocu-Film\nDodds\nDodge\nDodgeball\ndodgeballs\ndoe\nDoe-eyed\ndoes\ndoesn\ndoesn'\ndoesn’t\ndoesnt\ndoesn't\nDoesnt\nDoesn't\nd'Oeuvres\ndog\nDog’s\nDogboy\ndogged\ndoggie\ndoggie-style\nDoggin'\nDogging\ndoggy\ndoggystyle\ndoggy-style\nDoggystyle\nDoggy-style\ndogs\nDog's\nDogsitter\nDogsitting\nDohmer\ndoi\ndoin\ndoing\nDoing?\ndoing??\nDoIntroducing\ndoitforyourbrother\nDo-It-Yourselfer\nDojo\nDolce\nDolce's\nDolcezza\nDolci\nDole\ndoles\nDolf\ndoll\ndollar\nDollars\nDollas\nDollce\ndolled\ndollface\nDoll-faced\nDollhouse\ndollie\nDollification\nDolling\nDollop\ndolls\ndoll's\nDolls\nDoll's\ndolly\nDolly's\nDollywood\nDollz\nDolore\nDolores\ndolphin\nDolphins\nDolphintoy\nD'olya\ndom\nDomain\nDomanda\nDomark\nDombootytic\nDomCon\nDome\nDomenica\nDomenique\ndomestic\nDom-estic\nDomesticated\ndomina\ndominachick\nDominance\ndominance100%\ndominant\ndominant?\nDominante\ndominas\nDomina's\nDominasian\nDominasians\nDominata\ndominate\ndominated\nDominateHer\ndominates\ndominating\ndomination\nDomination…\ndominationand\nDominative\ndominator\nDominators\nDominatriqszzzz\ndominatrix\nDominca\nDomingo\nDominic\nDominica\nDominican\nDominicana\nDominicans\nDominica's\nDominick\nDominik\nDominika\nDominikka\nDominique\nDominno\nDominno's\nDomino\nDominoes\nDomino's\nDominus\ndomme\nDommed\nDomme-ination\ndommes\nDomme's\nDOMMING\ndoms\nDom's\ndon\ndon?t\ndon’t\nDona\nDonald\nDona's\nDonate\ndonated\nDonates\nDonation\ndonations\ndone\ndone?\nDonell\ndoneYou\ndong\nDonga\nDongalicious\nDonged\nDonger\nDongfun\nDonghole\ndongin\nDonging\nDongky\nDonglove\nDongs\nDong's\nDongzilla\ndonjon\nDonk\nDonk-a-Donk\ndonkey\nDonkeys\ndonna\nDonnaBell\nDonnas\nDonna's\nDonnaTrapped\nDonnaWorld\nDonnie\nDonny\nDonor\nDonors\nDonovan\nDon's\ndont\ndon't\nDont\nDon't\nDon't?\nDontcha\nDon'ts\nDonut\nDonuts\nDoo\nDoodle\nDoodles\nDoodling\nDoom\nDoomsday\ndoor\ndoorbooty\nDoorbell\ndoorBrutal\nDoorfingers1\nDoorfingers2\nDoorman\nDoorman's\nDoormat\ndoors\nDoor's\ndoorstep\nDoor-to-Door\ndoorway\nDoorwayorgasm\nDopamine\nDoppelbanger\nDoppelmake loveer\nDoppelganger\nDora\nDora's\nDore\nDoreen\nDorev\nDori\nDoria\nDorian\nDorina\nDorina’s\nDoris\nDoris's\ndork\nDorka\nDorks\nDorky\nDorky's\ndorm\ndorm_party_zone\ndormitory\nDormmates\ndorms\nDornelles\nDorothe\nDorothea\nDorothy\nDorothy's\nDors\nDory\nDos\nDosage\ndose\nDoses\nDo-Si-Do\ndosis\nDossier\nDot\nDote\nDotfingers\ndots\ndotted\ndotti\ndoube\ndouble\nDouble'\nDOUBLE\nDouble_Fisting\ndouble_or_nothing\nDouble-Anal\nDoubleanalized\nDoubleanalyzed\nDoubleB\nDouble-Blow\nDouble-Booked\nDouble-busted\nDoublemember\nDouble-Membered\nDouble-Cream\nDoublecross\nDoubled\nDouble-D\nDouble-D`s\nDouble-Johnson\nDouble-Johnsoning\nDouble-digged\nDoublebanana\nDouble-Dip\nDouble-dipped\nDouble-dipping\ndoubledong\ndouble-dong\nDoubledong\nDouble-Dong\nDouble-Dong's\ndouble-drilled\nDouble-drilling\nDouble-D's\ndouble-ended\nDoubleended\nDoubleended2\ndouble-ender\nDoubleface\nDoublefinger\nDoubleFist\ndouble-make love\ndoublemake loveed\ndouble-make loveed\nDoublemake loveed\nDouble-make loveed\ndouble-make loveing\nDoublemake loveing\nDouble-make loveing\nDouble-Handed\nDouble-headed\nDoubleheader\nDouble-Loving\nDouble-Newbie-Make loveing-Machines-Robot-Ram-Session\nDouble-Nipple\nDouble-Nutted\nDouble-O-Sexy\ndouble-penetrated\nDouble-Penetrates\nDouble-Penetration\ndouble-pleasing\nDoublepleasure\nDouble-Rainbow\ndoubles\nDouble's\nDouble-Shift\nDouble-sided\nDouble-squirting\nDoublesse\ndoublestuffed\nDouble-stuffed\ndouble-suck\ndouble-team\ndouble-teamed\nDoubleteamed\nDouble-teamed\nDouble-Teaming\nDoubleTrouble\nDouble-Vag\nDouble-Vaginal\ndoublicious\nDoubling\ndoubt\nDoubtfire\nDoubtmake loveer\ndoubtful\nDouce\nDoucement\nDouceur\ndouche\nDouchebag\nDouchy\nDoug\ndough\ndoughnut\nDoughnuts\nDoughy\nDougie\nDouglas\nDouple\ndoused\nDousing\nDoux\nDova\nDova’s\nDove\nDovemake loveing\nDover\nDoves\nDovey\ndow\nDowdy\ndown\nDown For\ndown?\nDownblouse\nDownBoss\nDownhill\nDown-Home\nDowning\ndownloadable\nDown'n\nDownpour\nDownright\nDowns\nDowns'\nDownsizing\ndownstairs\ndownstairs?\nDowntime\nDownton\ndowntown\nDownward\nDox;\ndoxies\ndoxy\nDozed\nDozen\nDozing\ndp\nDP'\nDP_ing\nDp’d\nDP’ed\nDP+AFist\nDP+DAP\nDP5145\nDP-Curious\nDp'd\nDPd\nDP'd\ndped\ndp'ed\nDped\nDp'ed\nDPed\nDP'ed\nDP'edRS10\nDp'ing\nDPleased\nDP-Lover\nDPP\nDPP+ANAL\ndp's\nDPs\nDP's\nDr\nDracula's\nDraft\ndrag\nDragana\ndragged\nDragon\nDragon0-0The\nDragon2-0\nDragon2-0vsTara\nDragon5-2\nDRAGONIntroducing\nDragonlilly\nDragonlily\nDragonLilyHard\nDragonLily's\nDragons\nDragon's\nDragons0-0\nDragons1-0\nDragonsBrutal\nDragonvs\nDrag-Race\ndrags\ndrain\ndrained\nDrainer\nDrainers\ndraining\ndrains\ndrake\ndrakes\ndrake's\nDrakes\nDrake's\ndrama\nDraning\nDrapery\nDrastic\nDraven\ndraw\nDrawer\ndrawers\ndrawing\ndrawn\ndraws\nDrDeepthroat\ndre\nDrea\nDread\nDread's\ndream\ndream?\nDreamboat\nDreamcatcher\ndreamed\ndreamer\nDreamera\ndreamers\nDreammake love\nDreamgasm\ndreamgirl\nDreamgirls\nDreamin\nDreamin'\ndreaming\ndreamland\nDreamquest\ndreams\nDream's\nDREAMS\ndreamsicle\nDreamt\nDreamtime\nDreamwork\ndreamworld\ndreamy\nDreamz\nDredd\nDredd’s\nDredd's\ndrench\ndrenched\nDrenches\nDresden\nDresden's\ndress\nDress?\nDressbanana1\nDressbanana2\ndressed\ndresser\nDresserkitty\ndresses\nDressfingers\ndressing\nDressingroom\nDressmaker\nDressplay\nDresstrip\ndress-up\nDressup\nDress-Up\nDressupfun\nDrew\nDrey\ndribble\nDrielly\nDries\nDrift\nDrifter\ndrifting\ndrill\nDrill-Do\nDRILLDO\ndrilled\nDrillers\nDrillin\ndrilling\nDrillings\ndrills\ndrill-sergeant\nDrimla\ndrink\ndrinker\ndrinkershe\nDrinkfinger\ndrinking\ndrinks\ndrip\nDrippin\ndripping\ndrips\ndrive\nDrive-By\nDrive-in\ndriven\ndriver\ndrivers\nDriver's\ndrives\ndriveway\nDrivin\ndriving\nDrizzle\nDrizzled\ndrizzling\nDrizzy\nDroids\ndrone\nDroned\nDrone-Hunter\ndrool\nDrool-Filled\ndrooling\nDrools\nDroolz\ndrop\nDroplets\nDrop-off\nDropout\nDropouts\ndropped\nDropper\nDroppin\nDroppin'\ndropping\nDroppings\ndrops\nDrought\ndrove\ndrown\nDrowned\nDrowning\nDrowns\nDrozd\nDrPresley's\nDru\nDrug\nDrugs\nDruid\nDruid0-0\nDrum\nDrum’n’Bbooty\nDrumbeat\nDrummer\nDrumming\nDrumond\nDrums\nDrumstick\nDrunk\nDrunken\nDruuna\ndry\ndryer\nDryerkitty\nDrying\nDrywall\nDs\nD's\nDSD\nDSL\nDSLs\nDSO\nDt\nDTAF\nD-tention\nDTF\ndu\ndual\nDuality\nDuarte\nDuarte's\nDuarth\ndub\nDuba\nDubai\nDubai's\nDubble\nDubois\nDubrova's\nDUBUI\nDucati\nDucati's\nDucatti\nDucha\nDuch-BOOTY\nduchess\nduck\nDuckhead\nDuckling\nducky\nDuct\nduct-taped\nDuda\ndude\ndude’s\ndudes\ndude's\nDudes\nDude's\nDuds\nDue\nDuece\nDuel\nDueling\nDuenya\nDues\nduet\nDuett\nDuette\nDuex\nDugs\nDuh\nDUI\nDuJour\nduke\ndukes\nDukka\nDulce\nDulcinea\ndull\nDull23\nDullkight\nDulsinesa\nDulsineya\nDumaire\ndumb\nDumballs\nDumbbooty\nDumbbell\nDumbmake love\nDummies\nDummy\ndump\ndumped\nDumper\nDumping\nDumpling\ndumps\nDumpster\nDun\nDuname\nDuncan\nDune\ndungeon\nDungeons\nDunia\nDUnit\nDunk\ndunked\nDunkin\nDunks\nDunn\nDunya\nduo\nduo's\nDuped\nDuper\nDuplicity\nDuplika\nDupree\nDupri\nDupuis\nDur\nDura\nDuran\nDuress\nDurganova\nDurham\nduri\nduring\nDuro\nDurose\nDurring\nDushenka\nDusk\nDust\nDusted\nDuster\nDustin\nDusting\nDusts\ndusty\nDusya\nDutch\nduties\nDutiful\nDutra\nDutxa\nduty\nDuval\nDuvall\nDuvalle\nDuvalleLicious\nDuvalle's\nDuvet\nDuvets\nDuvy\nDuvy's\nDuxe\nDuxe's\nDuxxx\nDuz\nDV\nDv8\ndvd\nDVP\nDVP+A\nDVP'd\nDVPdouble\nDVP'ed\nDwarf\nDweeb's\nDwellers\nDwight\nD'ya\nDyanna\nDye\nDyeing\nDyer\nDyer's\ndying\ndyke\nDykeachusetts\nDyked\nDykenamic\ndykes\nDyke-town\nDylan\nDylan-Day\nDylann\nDylann's\nDylan's\nDymes\ndynamic\nDynamics\ndynamite\nDynamo\nDynam-O\nDynasty\nDynie\nDynomite\nDYNO-mite\nDyogrammaton\nDysmake lovetional\ndysfunction\nDystopian\ndystopic\nDz\ne\nE1\nE10\nE11\nE12\nE13\nE14\nE15\nE16\nE17\nE18\nE19\nE2\nE20\nE21\nE3\nE4\nE5\nE6\nE7\nE8\nE9\nea\neac\neach\neachother\nEadie\neager\neagerly\nEagerness\neagle\nEar\nEar?\nearlier\nearly\nEarly?\nearn\nearned\nearning\nearns\nearrings\nears\nearth\nEarth?\nEarthling\nearthly\nEarthquake\nEarthy\nease\neases\nEasier\nEasiest\neasily\nEasing\neast\nEast2\nEast3\nEastasi\nEaste\neaster\neastern\nEaster's\nEaston\nEastwick\nEastwood\neasy\neasy-going\nEasy-Peasy\neat\nEat?\nEate\neaten\neater\nEaters\neatery\neatin\neating\neatout\neats\neatter\nEaves\neavesdropping\nEaze\nEbba\nEbenezer\nEbonita's\nebony\nebony's\nEbonys\nEbony's\neMelonStore\nEbulient\nEc\nEccentric\nEcho\nEchoes\nEclectic\nEcletic\nEclipse\nEconomics\nEcos\nEcstacy\necstasy\necstatic\nEctasy\nE-cup\ned\nedating\nEddi\nEddie\nEddie?\nEddie's\nEddition\nEddy\neden\nEden’s\nEden's\nedge\nedged\nEdgemaster\nEdger\nEdges\nEdge's\nedging\nEdgy\nedible\nEdiction\nEdin\nedina\nEdi-Quit?\nEdison\nEdit\nEdita\nedited\nEdith\nedition\nEditor\nedits\nEdi-Whore\nEdo\nEduarda\nEducando\nEducate\nEducated\nEducates\nEducating\neducation\neducational\nEducator\nEdward\nEdwards\nEdwige\nEdyn\nEE\nEEE\nEek\nEenie\nEff\nEffect\nEffective\nEffects\nEffervescent\nefficient\nEffie\nEffie-cient\nEffie's\neffort\nEffortless\nEffortlessly\nefforts\nEffy\nEfula\negg\neggplant\nEggroll\neggs\nEggstravaganza\nEgless\nEgnatia\nEgo\nEgoias\nEgos\nEgotistical\nEgypt\nEgyptian\nEh\neh?\nEhf-Eye-Ehn-Eee\nEhh\nei\nEidyia\nEiffel\neight\neighteen\nEighteenth\neighteen-year-old\nEighth\nEighties\nEIGHTNew\nEileen\nEilish\nEilsa\nEimi\nEin\neine\nEinve\nEisley\neither\nEivissa\nejaculate\nEjaculates\nEjaculating\nEjaculation\nEjaculator\nEkaterina\nEkina\nel\nEla\nelaborately\nElaina\nElaina's\nElaine\nElana\nElania\nElasias\nElastic\nElastik\nElation\nElatis\nElaura\nElber\nElbow\nelbows\nElcida\nElder\nelderly\nElders\nEldery\nEle\nEleanor\nEleanor's\nElecro\nElecrto-DP\nElected\nElection\nElecto\nElectra\nElectre\nelectric\nElectrical\nElectrically\nElectric-haired\nelectrician\nelectricians\nelectricity\nelectrified\nelectrifying\nelectro\nElectroAnal\nElectro-Anally-Destroyed\nElectro-Anal-Slut\nElectro-Ballerina\nElectro-BDSM\nElectro-bootcamp\nElectroBoy\nElectro-Christmas\nElectrocise\nElectromember\nElectro-Control\nElectrocuted\nelectrocutes\nelectro-domination\nElectrodomination\nElectro-Fem-Make loveing\nElectro-Fisted\nElectromake love\nElectromake loveed\nElectro-make loveed\nELECTROMAKE LOVEED\nElectro-Make love-Fest\nElectro-Make loveing\nElectromake loves\nElectrogasms\nElectro-hazed\nElectro-Lesbian\nElectro-Lez\nElectro-Limits\nElectronic\nElectro-Orgasms\nElectro-Painslut\nElectro-Pet\nElectroPlug\nElectropunished\nelectrosex\nElectro-sex\nelectrosexed\nElectrosexes\nElectroshock\nElectroslave\nElectro-Slave\nelectroslut\nElectrosluts\nElectroslutscom\nelectro-stim\nElectro-strap-on\nElectro-Submissive\nElectro-torture\nElectrsluts\nElegance\nElegancia\nelegant\nElegantly\nElektra\nElektrafying\nElement\nElemental\nelementary\nElementas\nelements\nElen\nElena\nElena's\nElenora\nElen's\nEleonora\nelephant\nElesimin\nElevage\nElevate\nElevated\nElevated'\nelevator\nEleven\nEleviax\nElexis\nElextia\nElextrosexes\nelf\nElfie\nelfs\nelf's\nElga\nElha\nEli\nEliana\nElias\nElie\nElijah\nelimination\neliminationLoser\nElin\nElina\nElindi\nElinor\nElinor's\nElios\nElis\nElisa\nElisabet\nElisabeth\nElisabetta\nElisaveta\nElise\nElisha\nElishka\nEliska\nEliska's\nElisse\nelite\nElites\nElium\nelixir\nEliza\nelizabeth\nEliza's\nElizaveta\nElke\nElla\nElla's\nElle\nElleha\nEllen\nEllena\nElleny\nEllery\nElles\nElle's\nElley\nElli\nEllia\nEllie\nEllie's\nEllin\nEllina\nEllington\nEllinis\nElliot\nElliott\nElliptic\nElliptical\nEllis\nEllison\nEllwood\nElly\nElmerita\nElmeritta\nElmer's\nElnara\nEloa\nElojianias\nElona\nElouisa\nEL-O-V-E\nElsa\nelse\nelsethis\nElson\nElton\nElusive\nelven\nElves\nElvgren\nElvira\nElvis\nElya\nElycia\nElysa\nElyse\nElysee\nElysium\nem\nEm’\nEma\nEmail\nE-mail\nemails\nE-male\nEmanuel\nEmanuelle\nEmasculate\nEmasculation\nEmbace\nEmbargo\nEmbark\nEmbarking\nEmbarks\nEmbarrbooty\nembarrbootyed\nEmbarrbootying\nEmber\nEmbers\nEmber's\nEmbezzlement\nEmbezzler\nEmblem\nEmbrace\nEmbraced\nEmbraces\nEmbracing\nEmeche\nEmelie\neMemories\nEmerald\nEmerald's\nEmerge\nemergency\nEmerode\nEmerson\nEmery\nEmi\nEmiko\nEmilee\nEmilee's\nEmili\nEmilia\nEmiliana\nEmilianna\nEmilianna's\nEmilia's\nEmilie\nEmilio\nEmily\nEmily's\nEmino\nEminse\nEmiri's\nEmitia\nEmjay\nEmma\nEmma’s\nEmmanuelle\nEmma's\nEmmett\nEmmi\nEmmily\nEmmy\nEmo\nEmoke\nEmoke's\nEmon\nEmori\nEmory\nEmosexual\nemotion\nEmotional\nemotionally\nemotions\nEmpacando\nEmpapada\nEmpera\nEmphasis\nEmphatic\nEmpire\nEmpiria\nemployee\nemployees\nEmployee's\nemployer\nEmployers\nEmployment\nEmporium\nEmpowered\nEmpowering\nEmpowerment\nEmpress\nempties\nEmptiness\nempty\nEMS\nEMT\nEmuna\nEmy\nEmylia\nEmy's\nen\nEna\nEnact\nenamorada\nEnamored\nEnamour\nEnamoured\nenc\nEncanta\nEncased\nEncasement\nenchant\nenchanted\nEnchanter\nEnchanting\nEnchantress\nEnchantresses\nEnchants\nenco\nencore\nencounter\nencounters\nencouragement\nEncourages\nEncouragment\nEncuentra\nEncyclezpedia\nend\nEndearment\nEndeavor\nEndeavors\nended\nender\nending\nEnding?\nending”\nendings\nendless\nendlessly\nEndorphin\nends\nEndurance\nendure\nendures\nenduring\nEnebadeyhea\nenema\nenemas\nEnemies\nEnemy\nEnergea\nEnergetic\nEnergize\nEnergizer\nenergy\nEnfermera\nEnfermere\nEnfoque\nenforcement\nEnforcer\nengage\nengaged\nEngaged?\nEngagement\nengages\nEngaging\nEngel\nEngi\nengine\nEngineering\nEngineers\nengines\nEngland\nEngland's\nEnglisch?\nenglish\nEnglish?\nEnglishman\nEnglishmen\nengulf\nEngulfs\nenhanced\nenhancement\nEnhancer\nEnigma\nEnigmatic\nEniko\nenjoy\nEnjoyably\nenjoyed\nEnjoyin\nenjoying\nenjoyment\nenjoys\nenjoy's\nEnjoys\nEnkalis\nenlarged\nEnlargement\nEnlighten\nEnlightenment\nEnn\nEnnie\nEnny\nEnojada\nEnolla\nEnorme\nEnormes\nenormous\nEnormously\nenoug\nenough\nenough?\nEnough's\nEnquire\nEnrica\nEnrich\nEnrique\nEnsada\nEnséñame\nEnseñandole\nEnslaved\nEnslavement\nenslaves\nEnslaving\nEnsnare\nEnsnared\nEnsnares\nEnsues\nensure\nensures\nentanglement\nenter\nentered\nentering\nEnterprise\nenters\nentertaiment\nentertain\nentertained\nentertaining\nentertainment\nentertains\nEnthrall\nEnthralling\nenthusiasm\nenthusibootytic\nenthusiast\nenthusiastic\nEnthusiastically\nEnthusiasts\nEntice\nEnticed\nEnticement\nEnticers\nEntices\nEnticing\nentire\nEncanled\nEntourage\nentrance\nen-trance\nEntrance\nEntranced\nentre\nEntree\nEntrenamiento\nentrepreneur\nEntrepreneurial\nEntretenido\nEntrevistando\nEntries\nEntry\nEntwine\nEntwined\nEnvidia\nEnvious\nenvy\nEnvy-Me\nEnyjoing\nEnyoj\nEnza\nEnzo\nEolika\nEp\nep1\nEp-1\nep10\nep11\nep12\nep13\nep14\nep15\nep16\nep17\nep18\nep2\nEp-2\nep3\nEp-3\nep4\nEp-4\nep5\nEp-5\nep6\nEp-6\nep7\nEp-7\nep8\nEp-8\nep9\nEphigenia\nEphrasi\nepic\nÉpico\nEpicurean's\nEpilogue\nEpiphany\nepisode\nEpisodes\nepitome\nEpoch\nEpps\neps\nEpulari\nequal\nequally\nequals\nEquation\nEquestrian\nequipment\nEquipped\ner\nera\nEradius\neras\nErase\nÉrase\nErasers\nEraxmus\nErect\nerected\nerectile\nerection\nErection-Maker\nerections\nEreti\nErgonomic\nErian\nEric\nerica\nErica's\nErick\nEricka\nErickson\nEricksonThe\nEric's\nErik\nerika\nErikas\nErika's\nErike\nErin\nErina\nErina's\nErinn\nErin's\nErnesta\nErnie\nero-action\nErodict\nEroge\nErogenous\nEroica\nEromeni\nEros\nErotias\nerotic\nerotica\nEroticaX\neroticism\nEroticismic\nErotico\nErotics\nErotik\nErotique\nErotisi\nErotsis\nErox\nErrand\nerrands\nErrin\nError\nErtisi\nErupt\neruption\neruptions\nErupts\nErzsebet\nes\nEscaladies\nEscalated\nEscalating\nEscalayer\nEscándalo\nescapade\nescapades\nescape\nEscaped\nEscapee\nescapes\nEscaping\nEscapism\nEscapist\nEscobar\nEscondidas\nescort\nEscort?\nEscorted\nEscorting\nEscorts\nEscort's\nEscotes\nescrewed\nese\nEsenia\nEsida\nEsis\nEskade\nEskimo\nESL\nEsm\nEsme\nEsmeralda\nEsmerelda\nEsmi\nESOL\nEsoteric\nEspana\nEspanol\nEspanola\nEspecial\nespecially\nEsperanse's\nEsperanza\nEsperar\nEspere\nEsperenza\nespionage\nEsposa\nEsposas\nesposo\nEsprit\nEspuma\nEspume\nessay\nessence\nEssential\nessentials\nEssentias\nEssex\nEssy\nesta\nestá\nEstacionamiento\nEstacy\nestate\nEstates\nEsteban\nEsteem\nEstella\nEstelle\nEster\nEsther\nE-Stim\nEstírame\nEs-tiremos\nEsto\nEstrada\nEstranged\nestrella\nEstremis\nEstrés\nEstreya\nEstuary\nEstudiante\nEstudios\nEsu\nEszter\nEt\nEtain\nEtalia's\nEtarot\netc\nETENDO\nEternal\nEternalis\nEternally\nEternian\nEternitas\nEternity\nEth\nEthan\nEther\nEthereal\nEthic\nEthics\nEthnic\nEthni-city\nEthno\nEtiquette\nEtna\nEtoile\nEtretat\nEtrev\nEts\nEufrat\nEufrats\nEufrat's\nEugene\nEugenia\nEugenya\nEujenya\nEunique\nEuphoria\nEuphoric\nEurasian\neuro\nEurobabe\nEuro-Filth\nEuro-Milf\nEurope\nEuropean\neuropean_teen_hardcore\nEuropeans\nEurope's\nEuro-Punk\nEuros\nEuros?\neurosex\neuro-slut\nEuroslut\nEuro-Teen\nEuroticas\nEutihia\nEutoco\nev\neva\nEva﻿\nEva\nEvah\nEvaluate\nEvaluation\nEvaluations\nEvalution\nEvan\nEvangeline\nEvangelion\nEvanni\nEvans\nEvan's\nEva's\neve\nEve?\nEvelin\nEvelina\nEvelina's\nEveline\nEveline's\nEveling\nEvelin's\nEvelyn\nEvelyn’s\nEvelyne\nEvelyne's\nEvelynn\nEvelyn's\neven\nevening\nevenings\nEvening's\nEvens\nEvent\nEventful\nEventide\nEventPart\nevents\neventThe\neventVendetta\never\never?\never-atingle\nEverett\nEverett's\nEverglade\nEverglades\nEverhard\nEverheart\never-horny\nEver-Hungry\nEver-Incredible\nEveritts\nEverlasting\nEverlating\nEverlina\nEverly\nEvermoore\nEvermore\neverrrr\nEverson\nevery\n'Every\nEVERY\neverybody\nEverybody's\neveryday\neveryone\nEveryone's\neverything\neverything?\nEverythingBum\nEverythings\nEverything's\neverythingtwo\nEveryting\nEveryway\neverywhere\nEverywhere;\nEves\nEve's\nEvesa\nEvette\nEvey\nEvgen\nEvgenia\nEvgeniy\nEvgeniya\nEvi\nEvia\nEvianis\nEviction\nEvidence\nEvie\nEvie Olson\nEvie's\nevil\nEviliax\nEvils\nEvilution\nEvilyn\nEvins\nEvita\nEvol\nevolution\nEvolved\nEvolving\nEvy\nEwan\nEwig\nex\nEx_girlfriends\nEx_Husband's\nexact\nexactly\nExacts\nExage\nexam\nExam-blue\nExámenes\nexamination\nExamine\nexamined\nexaminer\nExaminers\nexamines\nexamining\nExam-muscles\nexamp\nExample\nexams\nEx-Babysitter\nEx-Banker's\nEx-BF's\nex-bosses\nEx-Boyfriend's\nExcavation\nExcavations\nExcavator\nExcellence\nexcellent\nExcept\nException\nExceptional\nexcercise\nExcerpt\nExcess\nExcessive\nexchange\nexchanges\nExchanging\nEx-Cheerleader\nexci\nExcitable\nexcitation\nexcite\nexcited\nexcited?\nexcitement\nexcites\nexciting\nExclusiv\nexclusive\nexclusively\nExclusives\nEx-con\nex-cons\nexcruciating\nexcursion\nexcursions\nexcuse\nExcuses\nexecution\nExecutions\nexecutive\nexercise\nExercise1\nExercise2\nExercisebike\nExercisefun\nExercisegirl\nexercises\nexercises?\nExercisetoy\nExercisevibe\nExercising\nExertion\nExes\nEx-Gay\nExgf\nEx-Girl\nex-girlfriend\nEx-girlfriend's\nex-gymnast\nExhale\nexhausted\nExhausting\nexhaustion\nExhausts\nExhibit\nexhibition\nexhibitionism\nexhibitionist\nexhibitionists\nExhilarated\nExhilarating\nExhilaration\nEx-Housewife\nex-husband\nExile\nExiled\nExilis\nExist\nExit\nEx-lovers\nexlusive\nEx-Machina\nEx-Marine\nEx-Marines\nEx-Military\nEx-Model\nexo\nExonaration\nExorcise\nExorcising\nexorcism\nexotic\nExotica\nExotics\nExoticx\nExotix\nexpand\nExpander\nExpanding\nexpands\nexpect\nExpect?\nExpectarea\nexpectation\nexpectations\nexpected\nexpecting\nEXPECTO\nexpedition\nexpeditions\nExpel\nExpelled\nExpensive\nexperien\nexperience\nexperience?\nexperienceAbused\nexperienced\nexperienceFantasy\nexperiences\nexperiencing\nexperiment\nExperimental\nexperimentation\nExperimentations\nExperimented\nExperimenting\nExperimentLIVE\nexperiments\nexpert\nexpertise\nexpertly\nexperts\nexpert's\nExpert-Tease\nExpiation\nexpirienced\nexplain\nExplains\nexplicit\nExplode\nexploder\nexplodes\nExploding\nexploit\nexploited\nExploiting\nexploits\nexploration\nexplorations\nExplorative\nExploratory\nexplore\nexplored\nExplorer\nexplorers\nexplores\nExplore's\nexploring\nexplosion\nExplosions\nexplosive\nExplosively\nexpo\nEx-Porn\nExport\nExposÃ©\nexpose\nexposed\nexposedthis\nexposes\nExpoSexo\nexposing\nExposition\nexposure\nExpress\nexpressing\nExpression\nExpressionist\nexpulsion\nexquisite\nEx's\nEx-Shame\nEx-stripper\nEx-Suegra\nextase\nExtasi\nExtasis\nextasy\nExtend\nExtended\nExtension\nextensions\nExtorted\nExtortion\nextra\nextra?\nExtract\nextracted\nExtracting\nExtraction\nExtraction'\nExtractor\nExtracts\nExtracurricular\nExtracurriculars\nExtradition\nExtra-hot\nExtra-Jordaniary\nExtramarital\nExtra-Marital\nExtraño\nextraordinair\nextraordinaire\nExtraordinar\nExtraordinarie\nExtraordinary\nextras\nExtravagant\nextravaganza\nExtrema\nextreme\nextremely\nExtremes\nExtremity\nExubera\nExuberant\nExudes\nExus\nEx-virgin\nEx-Wife\nEx-Wife's\nEx-Wives\nExx\neXXXam\nExxxceptions\neXXXchange\nExxxciting\nExxxotic\nExxxotica\neXXXplorations\neXXXplosive\nExxxpress\nExxxta\neXXXtra\nExxxtrasmall\neXXXtravaganza\neye\nEyeball\neyeballs\nEyebrow\neye-candy\nEyecandy\nEye-candy\neye-catching\neyed\nEyeful\nEyegasm\neyeglbootyes\neyelash\nEyeliner\nEye-Opening\neyes\nEyes?\neyesgolden\neyesight\nEye-to-eye\nEye-watering\nEzhen\nEzra\nEzster\nf\nF*ck\nF@k\nfa\nFab\nFabel\nFabiane\nFabio\nFabiola\nFable\nFabled\nFabric\nFabrizio\nFabula\nfabulous\nFaby\nFacade\nFaccia\nface\nface?\nFace’s\nFaceturkey\nfaced\nFace-Down\nFace-Down-Booty-Up\nFace-First\nfacemake love\nFace-make love\nfacemake loveed\nface-make loveed\nFacemake loveed\nFace-make loveed\nFaceMake loveed\nFace-Make loveed\nface-make loveing\nFacemake loveing\nFace-make loveing\nfaceful\nFacella\nFacemuck\nface-painted\nfaces\nFace-Saturating\nFace-sit\nface-sitter\nFacesitter\nFace-sitter\nfacesitting\nFace-sitting\nFace-Slapping\nface-smothering\nFace-Soaking\nface-to-cat\nfacial\nFacial'd\nfacialed\nFacialised\nfacialized\nFacialized'\nfacials\nFACIL\nFacilitator\nFacility\nfacing\nfact\nfactor\nfactory\nFactory's\nFacts\nFad\nFade\nFadeny\nFades\nFado\nFae\nFaeries\nFaery\nFae's\nFahrenheit\nfail\nFailed\nFailing\nfails\nfailure\nFailures\nFaina\nfaint\nfair\nFairchild\nFaire\nFairer\nfairest\nfairie\nfairies\nFair-Weathered\nfairy\nfairytale\nfaith\nfaithful\nFaithfully\nfake\nFake?\nFakehub\nFaker\nfakes\nFakin\nfaking\nFalaise\nFa-la-l\nFalcao\nFalco\nFalcon\nFalcon's\nFalikoz\nFalizia\nfall\nFallaciously\nFallen\nFalling\nFallon\nfalls\nFalse\nFalsely\nFaltoya\nFaltoyano\nFam\nFamChaser\nfame\nFame'\nFAME\nfamed\nFamena\nFamer\nFamilial\nfamiliar\nfamiliar?\nFamilies\nfamily\nFamily's\nFamilystrokes\nFamished\nfamous\nFamous?\nFamously\nfan\nfanatic\nfanatics\nFanatka\nfanboy\nFanboys\nFanboy's\nfancied\nfancies\nfancy\nFancy Francy sucks\nFandango\nFanFirst\nFangirl\nFangirls\nFangs\nFanlingerie\nFannie\nFanning\nfanny\nFancat\nfans\nFan's\nFANS\nFansexual\nFanta\nfantas\nFantasee\nfantasi\nFantasia\nFantasía\nFantasias\nfantasie\nfantasies\nfantasize\nfantasized\nfantasizes\nfantasizing\nFantbootyee\nFantBOOTYtic\nfantastic\nFan-tastic\nFANTASTIC\nFantastical\nFantastically\nFantastico\nFantastik\nFantastisch\nFantasty\nfantasy\nFantasy;\nFantasy?\nFantasytime\nFantazome\nFantazy\nFantina\nFany\nFap\nfapping\nFappy\nFaq\nfar\nFara\nFarah\nFaraway\nfare\nFarel\nfarewell\nFargua\nFarias\nFaris\nfarm\nFarmboy\nFarmer\nFarmers\nFarmer's\nFarmgirl\nFarmhouse\nFarmin'\nFarmland\nFaro\nFarrah\nFarrah's\nFarrell\nFarris\nFarsk\nfart\nfarting\nfarts\nFarwell\nFascinating\nFascination\nFascinators\nfashion\nfashion_frenzy\nFashionable\nFashionably\nfashioned\nfashionista\nFashionistas\nFashionists\nfashions\nFbootyhionably\nfast\nFast And Easy\nfasten\nfaster\nFasterova\nFasterovation\nFast-Food\nfat\nFatal\nfatale\nFatale's\nfate\nFate?\nFates\nfather\nFather’s\nFather-In-Law\nFatherly\nfather's\nFathers\nFather's\nFatigue\nFatigued\nFATIKA\nFatima\nFatter\nFatter?\nFattest\nfatty\nFatzilla\nfaucet\nFaucetmasturbation\nFauci\nfault\nFaultless\nFaust\nFaustine\nFauve\nFaux\nFauxcest\nFauxmance\nFauxtographer\nFav\nfave\nfavor\nFavorita\nfavorite\nFavoritefingers\nFavoriteglbootytoy\nfavorites\nfavorrite\nFavors\nfavour\nfavourite\nfavourites\nFavouritism\nfavours\nFawn\nFawna\nFawndeli\nFawning\nFawny\nFawx\nFawx's\nFay\nFaye\nFaye's\nFaye-sers\nFayez\nFayth\nFayyes\nFBI\nFbi1\nFbitwo\nfck\nfck'd\nF-cup\nF-Cupper\nfe\nfear\nFeared\nFeargasms\nFearing\nFearless\nfears\nfearsome\nfeast\nFeaster\nFeasting\nfeastival\nFeasts\nfeat\nFeatBrooklyn\nfeather\nFeatherlight\nFeathers\nfeatherweight\nfeatherweights\nFeatherweights2\nFeatherweigth\nFeats\nfeature\nfeature’s\nFeatureAmerica's\nfeatured\nfeatures\nFeaturette\nfeaturing\nFeb\nFebby\nFebby’s\nFebby's\nFebe's\nFebruary\nFeb's\nfed\nfederal\nFederica\nFeds\nfee\nfeed\nFeed?\nFeeder\nfeeding\nFeedings\nfeeds\nFeedThe\nfeel\nfeel?\nFeelers\nFeelgood\nFeelgood's\nfeeli\nFeelin\nFeelin'\nfeeling\nfeeling_herself\nfeelings\nfeels\nFeely\nfees\nfeet\nFeet?\nFeetish\nfeet-loving\nFeetogenic\nFeetpink\nfeetsies\nFeetures\nfeigning\nfeigns\nFeildwork\nFein\nfeisty\nFelaktig\nFelated\nFelatio\nFelecia\nFelice\nFelicia\nfelicia's\nFelicias\nFelicity\nFelicity's\nFelina\nfeline\nFelinias\nFelipa\nFelisias\nFelix\nFelix's\nFeliz\nFeliza\nfell\nfella\nfellas\nfellatio\nfellow\nfellows\nFelon\nFelony\nFelony'd\nFelony'ed\nFelony's\nFelonySomeone's\nFelonyThe\nfelt\nFelucci\nfem\nfemale\nfemales\nFembot\nFemcy\nfemdom\nFemDomme\nFeminin\nFeminine\nFemininity\nfeminist\nFeminization\nFeminized\nfemme\nFemmeDom\nFemmes\nfence\nFences\nfend\nFender\nFendi\nFenestra\nFenetre\nFeng\nFenix\nFennec\nFennixia\nFenomeno\nFenox\nfenwick\nFeodo\nFer\nFeral\nFerarri\nFergalicious\nFerme\nFern\nFernanda\nFernandes\nFernandez\nFernandi\nFernandinha\nFernando\nferocious\nFerociously\nFerrah\nFerrara\nFerrara's\nFerrari\nFERRARIThe\nFerraz\nFerre\nFerreira\nferrer\nFerrera\nFerrera's\nFerreri\nFerrero\nferret\nFerretti\nFerri\nFerriana\nFerris\nFerro\nFerry\nFertile\nFertility\nFertilizer\nFerty\nFervent\nFervor\nFervour\nFesser\nFesser's\nfest\nfestival\nfestive\nFestivities\nFestivity\nfet\nFetching\nFetisch\nfetish\nfetishes\nFetishest\nfetishism\nfetish-ism\nFetishism\nfetishist\nFetishista\nfetishists\nFetishist's\nFettered\nFetti\nFeud\nFevari\nfever\nFevered\nFeverish\nFeverishly\nFever's\nfew\nFey\nFey's\nFEZ\nFf\nFFkitty\nFFM\nFHM\nFi\nfiance\nfiancé\nfiancee\nfiancée\nFiancee's\nfiance's\nfiancé's\nFianle\nFiasco\nFiasko\nFiat\nFiax\nFib\nFickle\nFiction\nFiddle\nfiddler\nfiddles\nFiddling\nFidelity\nfidget\nFidgeting\nFidgets\nFidikeia\nfield\nFields\nField's\nfiend\nFiendish\nFiends\nFiend's\nFiera\nfierce\nfiery\nfiesta\nfiesty\nFifi\nFifteen\nFifth\nFifties\nFifty\nFifty-two\nFIFun\nFigging\nfight\nfighter\nFighters\nfighting\nfights\nFignering\nFigueroa\nfigure\nFigurehead\nFigures\nFijiria\nFile\nfiles\nFilet\nFiling\nFilipina\nFilipino\nFilippa\nfill\nFilla\nFille\nfilled\nfilledtaking\nFiller\nFill'er\nfillies\nFillin\nFill-In\nfilling\nFilling?\nFillmore\nfills\nFills Her\nFilly\nfilm\nfilmed\nfilming\nfilmmaking\nfilmOut\nfilms\nFilter\nfilth\nfilthiest\nfilthy\nfin\nfinal\nfinale\nFinalMake loveing\nFinalist\nFinality\nFinalized\nfinally\nFinalmente\nfinals\nfinalsA\nFinance\nFinances\nfinancial\nFinancially\nfind\nFinders\nfinding\nfinds\nfine\nFine-Booty\nFine-bootyed\nfinely\nFiner\nFines\nfinesse\nfinest\nFine-Tuning\nfinger\nFingeraction\nfingerbang\nfingerbanged\nfinger-banged\nFingerbanger\nFingerbanging\nFinger-banging\nFingerbangs\nFingerbed\nFingerbedfun\nFingerchair\nFingerchat\nFingercouch\nFingercouch1\nFingercouch2\nFingercream\nFingerdeep\nFingerdelight\nFingerbanana\nfingered\nFingerfrenzy\nFingermake love\nFingermake loveed\nFingermake loveer\nFingermake loveing\nFinger-Make loveing\nfingermake loves\nFingerfun\nFingerfun1\nFingerfun2\nFingerhole\nFingeri\nfingering\nFingerings\nFingerlicious\nFingerlick\nFinger-lickin\nFingerlickn\nFingerlove\nFingerpink\nFingerplay\nFingerplay1\nFingerplay2\nFingerpound\nFingerrub\nfingers\nFinger's\nFIngers\nfingers?\nfingers?All\nFingers1\nFingers2\nFingersaurus\nFingersdeep\nFingersdeep2\nFingersin\nFingersin1\nFingersin2\nFingerspaz\nFingerstwo\nFingertalk\nFingertease\nFingerteen\nFingertime\nFingertips\nFingerkitty\nfinish\nfinished\nFinisher\nfinishes\nFinishing\nFinland\nfinn\nFinne\nFinnish\nFino\nFintesso\nFiolet\nFiona\nFiore\nFiorentino\nFiori\nFiorissima\nfir\nFira\nfiraplace\nFirasa\nfire\nFireball\nfirecracker\nfirecrotch\nfired\nFired?\nFirefighter\nFirefighters\nFirefly\nFirehouse\nFirelight\nFireman's\nfirend's\nfireplace\nFireplacedance\nFireplacefun\nfires\nFire's\nFIRES\nFireside\nFirestarter\nFirestone\nFirestorm\nFirework\nfireworks\nFireworks?\nfirey\nFiring\nfirm\nfirm-bodied\nfirmest\nFirmly\nfirs\nfirst\nFirst?\nFirstA\nFirst-clbooty\nfirst-ever\nFirstfingers\nFirstGape\nFirsthand\nfirstHogtied\nFirstie\nfirsting\nfirst-IR-and-DP\nfirst-place\nFirstrabbit1\nFirstrabbit2\nfirsts\nfirst-time\nFirsttime\nFirst-time\nfirsttimer\nfirst-timer\nFirsttimer\nFirst-timer\nfirsttimers\nfirst-timers\nFirsttimetoy\nFirsty\nFisa\nFiseca\nfisging\nFish\nfished\nFisher\nFisherman\nFishermans\nFisher's\nFishes\nFishin\nFishin'\nfishing\nFishline\nfishnet\nFishnetbeauty\nFishnetdress\nFishnetfinger\nfishnets\nFisikal\nfising\nfisiting\nfisitng\nfist\nFIST+MAKE LOVEING\nfisted\nFistedTara\nfister\nFisters\nFistmake loveed\nfistful\nFistfull\nfisting\nFisting?\nFisting??\nFistings\nFisting's\nFISTINGThe\nFisting-Virginity\nFistivity\nfists\nfit\nFit?\nFitball\nFitch\nFitgerald\nfitness\nFitnessfingers\nfits\nFitt\nfitted\nFittest\nfitting\nFitXXX\nFitzergood\nfive\nFive-O\nFivesome\nFive-Star\nfive-way\nFiveway\nfix\nFixated\nfixation\nFixations\nFixe\nfixed\nFixer\nFixer'\nFixer-Upper\nfixes\nfixin'\nFixin\nfixing\nFixins\nFix-it\nFixx\nFixxxer\nFl\nFlaccid\nFlag\nFlagrante\nFlail\nflair\nflame\nFlamenco\nFlamer\nflames\nFlameThe\nFlamez\nflaming\nflamingo\nFlamingos\nFlana\nFlapper\nflaps\nFlare\nFlared\nflash\nFlashback\nFlashbacks\nFlashcard\nFlashed\nflasher\nflashers\nflashes\nflashing\nFlashlight\nFlashpoint\nflashy\nflat\nFlatline\nflatmate\nflatmates\nFlattie\nFlatties\nFlattie's\nFlaunt\nflaunting\nflaunts\nFlava\nFlavia\nFlavis\nflavor\nflavored\nflavorings\nflavors\nFlavour\nflavour?\nFlavours\nFlaw\nFlawless\nFlawlessly\nFlaxi\nFlaxy\nFlea\nFleeced\nflees\nFleet\nFleeting\nFleischfabrik\nFleiss\nflesh\nFleshmember\nfleshed\nFleshjack\nFleshlight\nfleshy\nFleurette\nFleurs\nFlex\nFlexablefingerfun\nFlexed\nflexes\nFlexfingers\nFlexi\nflexibility\nflexible\nFlexie\nFlexin\nflexing\nflex-test\nFlextime\nFleXXXibility\nFlexy\nFlexy's\nflick\nFlicker\nFlicking\nFlicks\nflies\nflight\nFlights\nFlimes\nfling\nFlingcom\nflinger\nflings\nFlint\nFlintstones\nFlip\nFlip-flop\nFlip-Make love\nFlip-Make loves\nFlippant\nFlipping\nflips\nflirt\nFlirtacious\nFlirtation\nFlirtations\nflirtatiou\nFlirtatious\nFlirter\nflirting\nflirts\nFlirt's\nflirty\nFlirtz\nFlix\nFlixxx\nFlo\nFloare\nFloat\nFloat?\nFloatation\nFloaters\nFloatie\nfloaties\nFloating\nflog\nflogged\nfloggedNipples\nflogger\nflogging\nflogs\nFlood\nFloodgates\nFlooding\nfloods\nfloor\nFloor?\nFloora\nFloorbooty\nFloorcooch\nFloored\nFloorfellow\nFloorfinger\nFloorfingers\nFloorflex\nFloorfun\nFloorgasm\nFloororgasm\nFloorpie\nFloorplay\nFloorcat\nFloorrubbing\nFloors\nFloortoy\nFloorvibe\nFloozie\nFloozies\nfloozy\nFlop\nFlopper\nFloppers\nfloppy\nFloppytoy\nFlor\nFlora\nFloral\nFloralia\nFloranc\nFlorancia\nFlorane\nFloranse\nFlorante\nFlore\nFlorea\nFlorence\nFlorencia\nFlorera\nFlores\nFloresgala\nFlorida\nFloridas\nFloridian\nFloridian's\nFlorina\nFlorinda\nFlorist\nFlorophilia\nFloss\nFlossing\nFlotation\nflour\nflourished\nflow\nflower\nFlowerbed1\nFlowerbed2\nFlowerbra\nFlowercouch\nFlowerfinger\nFlowering\nFlowerpower\nFlowercat\nflowers\nFlower's\nFlowers1\nFlowers2\nFlowerskirt\nFlowerthong\nflowery\nflowing\nflows\nFloxia\nFloya\nFloyd\nFlu\nFlub\nFluent\nfluff\nfluffer\nFluffing\nFluffs\nfluffy\nFluid\nFluid?\nFluidity\nFluids\nFlujo\nFluk\nFlunking\nFluorescent\nflush\nflustered\nflute\nflutes\nFlutist\nFlutter\nfly\nFlyer\nFlyers\nFlyest\nflying\nFlynn\nFlynn's\nFlynt\nflys\nFlyswatter\nFM\nFM001\nFM002\nFM003\nFM004\nFM005\nFM006\nFM007\nFM008\nFM009\nFM010\nFM011\nFM012\nF-Machine\nFmodels\nfo\nFo’\nfoam\nFoamy\nfocus\nFocused\nfocuses\nFocusing\nFoe\nFofie\nFog\nfogy\nFold\nfolded\nFolder\nfolding\nFoldout\nfolds\nFoliage\nFolk\nfolklore\nfolks\nFollar\nFollbooty\nFollies\nfollow\nfollowed\nFollowers\nfollowing\nfollows\nFolly\nFolsom\nFoments\nfond\nFondeling\nFonder\nFondia\nFondle\nfondled\nFondler\nFondlers\nfondles\nfondling\nFondue\nFong\nFonic\nFontaine\nFontana\nFontes\nFonthys\nFontini\nfoo\nfood\nFoodfight\nFoodfun\nFoodie\nFoodstuffs\nFoodtruck\nFoojob\nfool\nFool’s\nfooled\nFoolin\nFoolin'\nFooling\nFoolish\nfools\nfool's\nFools\nFool's\nFoosball\nFoosballers\nFooseball\nFoostieBabes\nfoot\nfootage\nFoot-Booty-Make loveing\nFootbal\nfootball\nFootballas\nfootballer\nFootballing\nFootballs\nFootdance\nFooted\nFooter\nFootFet\nfootfetish\nfoot-fetish\nFOOTFETISH\nFootfob\nFootmake love\nFoot-Make loveed\nFootie\nFooties\nfoot'-ile\nfooting\nFoot-Jerking\nfootjob\nFoot-job\nFootjobbers\nFootjobbing\nFoot-Jobbing\nFootjobs\nFootlocker\nFootlong\nFoot-Long\nFootloose\nfootman\nFootman's\nFootmodel\nFootography\nfootplay\nFoot-Play\nFootprints\nFoots-A-Make lovein'\nFootscapde\nFootsex\nfootsie\nFootsiebabes\nfootsies\nFoot-Sniffing\nFootsploitation\nFootsteps\nFootstool\nFootsy\nFoot-Teaser\nFootwear\nFootwhores\nFootworship\nFootWorshipcom\nfooty\nFoqual\nfor\nFor Agent's Help\nfor?\nFor…\nForbidden\nForcast\nforce\nforced\nForcefully\nForces\nFord\nFord's\nFore\nForecast\nForecasting\nForeclosure\nForehead\nforeign\nforeigner\nForeigners\nForema\nForeman\nForeman's\nForenoon\nforeplay\nForeplaying\nForeplays\nForero\nForeskin\nforest\nForester's\nForestfingers\nforetells\nForeva\nforever\nForever?\nForever-stunning\nforget\nForgetful\nForget-me-not\nforgets\nforgetting\nForging\nForgivable\nforgive\nforgiveness\nForgives\nforgoes\nforgot\nforgotten\nFork\nForked\nForked-Tongue\nForks\nforlorn\nform\nFormal\nFormar\nFormation\nformed\nformer\nFormerly\nFormidable\nForming\nFormula\nformulated\nFornic-Asian\nFornicates\nFornicating\nfornication\nFornications\nForno\nForPsychology\nforrest\nForsaken\nFort\nforte\nForte's\nforth\nFortis\nForcanude\nFortnight\nFortuna\nFortuna's\nfortunate\nfortune\nForty\nForty-love\nForum\nForver\nforward\nforward's\nforwardThe\nForza\nFoster\nFostering\nFosters\nFoster's\nFoto\nFotógrafo\nFotos\nfou\nFoul\nFoulari\nFoulmouthed\nFoul-Mouthed\nFoun\nfound\nFoundation\nFoundations\nFoundmember\nfountain\nFountains\nfour\nFour-Eyed\nFourfinger\nFourfingers\nFourgy\nFour-Hand\nFour-Hands\nFourne's\nfourplay\nfours\nfoursome\nFour-some\nfoursomeRS056\nFour-Step\nfourth\nfourway\nfour-way\nFourway\nFour-way\nFovea\nfox\nFox?\nFox1\nFoxBack\nFoxed\nfoxes\nFoxGENESIS\nfoxhole\nFoxi\nFoxies\nFoxii\nfox's\nFoxtail\nFoxx\nFoxxi\nFoxx's\nFoxxx\nFoxxxies\nFoxxx's\nFoxxxy\nFoxxy\nFoxxy's\nFoxxySeducing\nfoxy\nFoxy's\nFoyer\nfr\nFractions\nFracture\nFraga\nFragile\nFragments\nFragrance\nFragrant\nframe\nframed\nframes\nFraming\nFran\nfrançais\nFrance\nFrances\nFrancesca\nFrancesca's\nFranceska\nFrancheazca\nFranchesca\nFrancheska\nFranchezca\nFranchezca's\nFranchezka\nFrancis\nFrancisco\nFranciska\nFranciska?\nFranciska's\nFranck\nFranco\nFrancoise\nFranco's\nFrancsca\nFrancy\nFrancys\nFrank\nFrankenmember\nFrankenjohnson\nFrankenslut\nFrankenstein\nFrank-footer\nFrankie\nFrankie's\nFranklin\nFranklin?\nFranks\nFrank's\nFranky\nFranny\nFranny's\nFrantastic\nFrantic\nfranticly\nFranziska\nFranziska's\nfrat\nfratatas\nFraternity\nFraternization\nFraternizing\nFrathouse\nFraud\nfreak\nFreaked\nFreakin\nFreaking\nFreakout\nfreaks\nFreaksbee\nFreakshow\nFreakum\nfreaky\nFreckle\nfreckled\nFreckle-faced\nfreckles\nFred\nFreddie\nFreddy\nFrederica\nfree\nFreed\nfreedom\nFreedom?\nFreefall\nFree-Flowing\nFree-For-All\nFreeing\nFreelance\nFreeloader\nfreely\nFreeOnes\nfrees\nFree-Spirited\nFreestyle\nFreetime\nFree-Useful\nFreeway\nFreewill\nFreeze\nFreezes\nFreienwalde\nfreight\nfreind\nFreja\nfrench\n'French\nFrench-Asian\nFrenchie\nFrenchie Anissa Kate Tight Juicy Cat Filled\nFrenchie's\nFrenchmaid\nFrenchman\nFrench-Pressed\nFrenchy\nFrenemies\nFrenetic\nfrenzie\nfrenzied\nfrenzy\nFrequent\nFresco\nfresh\nfresh_paint\nFresh’s\nFreshblonde\nFreshen\nFreshened\nFreshening\nfresh-faced\nFreshgirl\nfreshly\nfreshman\nFreshmeat\nFreshmen\nfreshness\nFreshcat\nFret\nFreud\nFreudian\nFreuds\nFrey\nFreya\nFreya's\nFreye\nFri\nFriction\nFrida\nFrida's\nfriday\nFridays\nFrideric\nfridge\nFried\nfriend\nfriend'\nFriend\nfriend?\nfriend’s\nfriendly\nfriends\nfriend's\nFriends\nFriend's\nFriends?\nFriends??\nfriendship\nfriendsover\nFriendzone\nFriend-Zone\nFries\nFriggin'\nfrigging\nFright\nFrightful\nFrigid\nfrill\nFrills\nFrilly\nFringe\nFringed\nFrisbee\nFrisco\nfrisk\nFrisked\nFriskie\nFrisking\nfrisky\nfrist\nFrito\nFrivol\nFrivola\nFrivolity\nFrivolous\nFrizzy\nfro\nfrock\nFrog\nFrogface\nFroggy\nFroid\nFrolic\nFrolick\nFrolickers\nfrolicking\nfrolics\nfrom\nFrom?\nFronat\nfront\nFrontal\nfrontier\nFrontin\nfrontside\nFrost\nfrosted\nfrosting\nFrost's\nFrosty\nFrotandoselo\nFrothy\nFrottage\nfrown\nFrozen\nfruit\nFruitful\nfruition\nFruitoil\nfruits\nfruit-shake\nfruity\nFrujina\nfrustrated\nFrustration\nfrustrations\nfrutata\nfrutatas\nFrutis\nFrutti\nFruttissima\nFRUTY\nFryer\nFrying\nF's\nFS001\nFS002\nFS004\nFS005\nFS006\nFS007\nFS008\nFS009\nFS010\nFS011\nFS012\nFS013\nFS015\nFS016\nFS017\nFS018\nFS019\nFS020\nFS021\nFS022\nFS023\nFS024\nFS025\nFS026\nFS027\nFS028\nFS029\nFS030\nFS031\nFS032\nFS033\nFS034\nFS035\nFS036\nFS037\nFS038\nFS039\nft\nFT5\nFTA\nFTM\nFTW\nFTY\nfu\nFuchsia\nmake love\nF-U-C-K\nmake love?\nMake loveability\nMake loveabilly\nmake loveable\nMake loveall\nMake love-all-you-want\nMake love-Along\nMake love-a-lot\nMake lovealution\nmake loveathon\nmake love-a-thon\nMake loveathon\nMake love-A-Thon\nMake loveation\nMake loveaway\nMake love-ball\nMake lovebit\nMake loveboy\nMake loveboys\nMake love-Break\nmake love-buddy\nMake lovebuddy\nMake love-Buddy\nMake lovebunny\nmake loved\nMAKE LOVE'D\nMake love-damental\nMake lovedate\nMake love-Date\nMake loveday\nMake love-day\nmake lovedoll\nMake love-doll\nMake lovedolls\nMake love-Dominated\nmake lovedown\nmake lovee\nmake loveed\nmake loveed?\nmake loveedCherry\nmake loveedFlogged\nmake loveedNon-Scritped\nMake loveed-With\nMake loveemon\nmake loveen\nmake loveening\nmake loveenings\nMake loveenstein\nmake loveer\nMake loveerella\nMake loveerfly\nmake loveeria\nmake loveers\nMake loveer's\nMake loveery\nmake lovees\nMake loveeth\nMake love-Face\nMake lovefeast\nmake lovefest\nmake love-fest\nMake lovefest\nMake love-fest\nMake loveFest\nMake love-Fest\nMake lovefinger\nMake love-fit\nmake lovehole\nmake love-hole\nmake loveholes\nmake love-hungry\nMake loveidy\nMake loveie\nmake loveign\nmake lovein\nmake lovein'\nMake lovein\nMake lovein'\nMAKE LOVEIN\nMAKE LOVEIN'\nmake loveing\nmake loveing?\nmake loveing_euro_milf\nMake loveingA\nMake loveingFired\nmake loveingmachine\nmake loveingmachines\nMake loveing-Machines\nMAKE LOVEINGMACHINES\nMake loveingMachinescom\nmake loveingof\nMake loveings\nMake loveing's\nmake loveingThe\nmake love-it\nMake lovelined\nMake lovelist\nmake love-love\nmake love-loving\nmake lovemachine\nmake love-machine\nMake lovemance\nMake love-mas\nmake love-me\nMake loveme1\nMake loveo\nMake love-Off\nMake loveography\nMake lovepbooty\nMake lovepointment\nMake love-punished\nMake love-raising\nmake loves\nMake lovesall\nMake lovesaw\nMake love-schooled\nMake lovesgiving\nMake love-Slopped\nmake loveslut\nMake lovesluts\nMake love-Squirting\nMake lovestarter\nmake love-stretched\nMake lovestyle\nmake lovetastic\nMake loveTeam\nMake lovetion\nMake lovetions\nMake lovetivity\nMake lovetoberfest\nMake lovetory\nMake lovetown\nmake love-toy\nMake lovetoy\nMake lovetoys\nMake loveture\nMake loveu\nMake loveula\nMake love-Up\nMake loveus\nMake loveventure\nMake loveWhoreberfest\nMake lovey\nMake lovezilla\nFudge\nFuego\nFuel\nFuente\nFuentes\nFuerte\nFufilling\nFufillment\nFugicans\nFugitiva\nFugitive\nFugitives\nFukabod\nFukalaties\nFukin\nfulfil\nfulfill\nfulfilled\nfulfilling\nFulfillingshower\nFulfillment\nfulfills\nFulfilment\nFulfils\nfull\nfull?\nfull0mouth\nfull-bodied\nFull-Body\nFuller\nFullest\nFull-figured\nFulls\nFull-Service\nFull-time\nfully\nFully-clothed\nFuma\nFumble\nfun\nfun?\nFunbag\nFunbags\nfunbox\nFunction\nFunctional\nFund\nfundamental\nfundamentals\nfunday\nFunbanana\nFundraiser\nFundraising\nfunds\nFuneral\nfunfair\nFunfingers\nFuninthekitchen\nFunk\nFunkenstein\nFunky\nFunkytown\nFun-loving\nfunnel\nFunneled\nFunnelled\nFunner\nfunny\nFunoodle\nFunroom\nFunsie\nFunsize\nfun-sized\nFunsized\nFun-Sized\nFun-Steve\nfuntime\nFuntine\nFuntoy\nFunvibe\nFunvibrator\nFunWith\nFunzies\nFun-zy\nFuochi\nFUPA\nfur\nFür\nfurburger\nFur-burger\nFurburgers\nfurious\nFuriously\nFuriya\nfurniture\nfurpie\nFurrious\nfurry\nFurry-Bushed\nFurryfinger\nFurrycat\nfurther\nFurtive\nfury\nFuse\nFusion\nFuss?\nFussball\nFussy\nFutbol\nFutile\nFutomomo\nFuton\nFutonfun\nfuture\nFuturistic\nFux\nFuxpress\nFuzion\nFuzz\nFuzzfingers\nfuzzies\nFuzzy\nFuzzypuss\nFWB\nFYERFLI\nFyre\nFyres\nF-Zone\ng\nG33K\nga\nGabanna\nGabba\nGabbano\nGabbi\nGabbie\nGabbie's\nGabbriella\nGabby\nGabe\nGab-Fest\nGabgbang\nGabi\nGabina\nGabi's\nGables\nGabor\nGabriel\nGabriela\nGabriela's\nGabriele\nGabriella\nGabriellas\nGabriella's\nGabrielle\nGabrielli\ngabriels\nGabriely\nGabrim\nGaby\ngadget\nGadgeteer\nGadget-maniac\ngadgets\nGael\nGaffe\ngag\ngaga\nGage\nGaged\nGagfest\ngagged\ngaggedmade\ngaggedNipples\ngaggedWhite\ngagger\nGaggging\nGaggin\ngagging\nGagland\ngags\nGaia\nGaia's\nGaidinian\nGaiety\nGail\nGaillardise\nGail's\nGain\ngains\nGaite\nGaiters\nGakuin\ngal\nGala\nGalactic\nGalactical\nGalano\nGalanti\nGala's\nGalateo\nGa-Laura\ngalaxy\nGalen\nGali\nGalina\nGalkina\nGallant\nGallardo\nGalleas\ngallery\nGalley\nGallians\nGallic\nGallitia\nGallixias\ngallon\ngallons\nGallow\ngalore\ngals\nGal's\nGaltian\nGalvanize\ngam\nGambe\nGambit\nGamble\nGambler\ngambler's\nGamblers\nGamble's\ngambling\ngame\nGame?\nGameday\nGameplay\ngamer\ngamer_girls\nGamers\ngames\nGaming\ngams\nGamus\nganbang\nGanbanged\nGandgang\nGanell\ngang\nGangback\ngangband\ngangbang\ngang-bang\nGangbang\nGang-bang\nGangBang\nGang-Bang\nGANGBANG\nGangBang??\ngangbanged\nGang-Banged\nGangbanger\ngang-bangers\nGangbangers\nGangbangFirst\nGangbanging\nGang-Banging\nGangbangs\nganged\ngangmake loveed\ngangland\nGangs\nGangsta\nGangstas\ngangster\nGangsters\nGangster's\ngap\nGap?\nGapable\ngape\nGape'\nGAPE\ngaped\ngapefart\ngapefarting\nGape-Farting\ngapefarts\nGapeLandcom\nGapeolexa\nGaper\ngapers\ngapes\nGapes'n'Roses\nGAPESPROLAPSE\nGapeteers\nGapezone\nGapin\ngaping\nGapolexa\ngapped\nGaps\ngarage\ngarage's\ngarbage\nGarcia\nGarcía\nGarcia’s\nGarcon\nGarde\nGardell\ngarden\nGardenea\ngardener\ngardeners\ngardener's\nGardeners\nGardener's\nGardenias\ngardening\nGardeno\nGardens\nGarden's\nGardentouches\nGardner\nGardner’s\nGareth\nGarett\nGargantuan\nGargle\nGargles\ngargling\nGariella\nGarin\nGarnet\nGarrett\nGart\nGarter\ngarterbelt\nGarters\nGarth\nGartner\nGary\nGarza\nGarza's\ngas\nGash\nGasket\nGaslighting\nGasm\nGasman\nGasolina\ngasoline\nGasp\ngasps\nGbootyet\nGbootying\nGate\nGatekeeper\nGates\nGathering\nGator's\nGattina\nGattu\nGaucha\nGauge\nGaultier\nGauntlet\nGautier\ngave\nGavin\nGaviria\nGaviria's\nGawk\nGawker\nGawking\ngay\nGay?\nGaybait\nGaybors\ngayelles\nGaykakke\nGaykkake\nGaylifenetwork\nGaymates\nGaymer\nGaynor\nGay's\nGaytrix\nGayville\nGaywatch\ngaze\nGazebo\nGazed\nGazelle\ngazing\ngazmask\nGazonga\ngazongas\nGazumba\ngazungas\nGb\nGB'\nGBQ\nge\nGeane\ngear\nGears\nGearshift\ngearstick\nG'ed\nGee\ngeek\ngeeks\nGeek's\ngeeky\nGeena\nGeezer\nGeezers\nGeezer's\nGeiser\ngeisha\nGeisha's\nGeizer\nGeizer?\ngel\nGel?\nGela\nGelato\nGelya\nGelyn\ngem\nGema\nGemini\nGemini?\nGemini's\nGemma\nGems\nGemstone\nGen\nGender\nGene\nGeneral\ngeneration\nGenerational\ngenerations\ngenero\nGenerosity\nGenerous\nGenerously\nGenesis\nGenessis\nGeneva\nGenevieve\nGenevieve's\nGenevievre\nGenice\nGenie\nGenies\nGeni's\nGenital\nGenitalia\nGenitals\ngenius\ngenocide\nGente\nGenteel\nGentil\nGentilla\nGentilly\ngentle\ngentleman\nGentleman’s\ngentleman's\ngentlemen\ngentlemen's\nGentlepleasure\nGentlewomen's\nGentley\ngently\nGentrified\ngenuine\nGenya\nGeoff\ngeography\nGeometric\ngeometry\nGeorge\nGeorge's\nGeorgia\nGeorgiana\nGeorgia's\nGeorgie\nGeorgie's\nGeorgina\nGeorgio\nGeovanna's\nGera\nGerald\nGeraldine\nGerber\nGerda\nGeri\nGeriatric\nGerina\nGerm\nGermain\ngerman\nGermans\nGerman-style\nGermany\nGermiona\nGermione\nGermophobe\nGerson\nGerson's\ngest\nGesture\nGestures\nget\nget?\ngetaway\nGet-Away\nGet-A-Way\nGetflix'n\nGet-Out\ngets\nget's\nGets\nGet's\nGETS\ngetsFisted\nGetter\nGetti\ngettin\ngettin’\ngetting\nget-together\nGetty\nGettysburg\ngetz\nG-Extreme\ngeyser\nGeyshila\ngf\nGFE\nG-Force\nGfs\nGf's\nGG\ngg006\nGG023\nGG025\ngg044\ngg046\nGG064\ngg080\ngg083\ngg087exclusive\ngg088\nGG093\nGG094\ngg097exclusive\nGG099\nGG103\nGG104\ngg106\nGG109\nGG110\nGG111\nGG114\nGG115\nGG122\nGG123\nGG125\nGG127\nGG128\ngg129\nGG132\ngg133\nGG135\nGG136\nGG137\nGG138\nGG141\nGG142\nGG143\ngg150\ngg151\nGG156\ngg157\ngg158\ngg159\ngg160\nGG162\ngg164\nGG167\nGG168\nGG169\nGG170\nGG174\nGG175\ngg178\nGG179\ngg180\ngg182\ngg184\ngg186\ngg187\ngg191\nGG196\nGG198\nGG199\nGG200\nGG202\ngg203\ngg204\nGG206\ngg207\nGG208\nGG209\nGG210\nGG211\nGG213\nGG215\nGG216\nGG217\nGG218\nGG220\nGG223\nGG224\ngg225\nGG226\nGG227\nGG228\nGG230\nGG231\nGG232\nGG233\nGG234\nGG236\nGG238\nGG239\nGG241\nGG242\nGG244\nGG246\nGG247\nGG248\nGG250\nGG253\nGG254\nGG255\nGG257\nGG258\nGG260\nGG261\nGG262\nGG264\nGG265\ngg267\ngg269\ngg270\nGG271\nGG272\ngg273\nGG276\nGG277\nGG279\nGG280\ngg281\nGG283\nGG284\nGG285\ngg286\ngg287\ngg289\nGG290\nGG291\nGG293\nGG294\ngg295\ngg296\ngg298\ngg299\nGG300\nGG301\ngg302\nGG304\nGG305\nGG306\ngg307\ngg308\nGG310\ngg311\ngg312\nGG314\nGG316\ngg317\ngg318\ngg321\ngg322\nGG324\nGG325\ngg326\nGG338\nGG342\ngg344\nGG345\ngg346\nGG347\ngg348\ngg349\ngg350\ngg351\ngg352\nGG354\nGG355\nGG356\ngg362\ngg367\ngg368\ngg370\ngg371\ngg372\ngg373\ngg374\ngg377\ngg379\nGG380\nGG381\nGG387\ngg389\nGG392\nGG393\nGG395\nGG396\nGG398\nGG399\ngg402\nGG408\ngg410\ngg411\nGG413\ngg414\nGG415\ngg417\nGG418\ngg419\ngg420\ngg421\ngg422\ngg423\nGG450\nGG459\nGG480\nGG481\nGG482\ngg483\nGG484\nGG486\nGG488\ngg489\nGG490\ngg491\ngg494\ngg498\ngg501\ngg502\ngg509\ngg510\ngg511\ngg513\ngg514\ngg515\ngg517\ngg518\ngg519\ngg520\ngg521\ngg522\nGG524\ngg526\ngg528\ngg530\ngg531\nGG532\ngg535\ngg536\ngg537\ngg539\ngg541\ngg547\ngg549\ngg553\ngg555\nGG556\ngg558\ngg561exclusive\nGGB\nGGDP\nGgLab\nGgorgeous\nGhettman\nGhetto\nghost\nGhostbusters\nGhosted\nGhostlusters\nGhostly\nGhosts\nGhouls\ngi\nGia\nGía\nGiaciglio\nGiacomo\nGiada\nGiana\ngianna\nGianna’s\nGiannas\nGianna's\nGianni\ngiant\nGiantess\nGia's\nGibson\nGibson's\nGicane\nGiddy\nGiddyup\nGideon\ngidget\nGiepky\ngift\ngifted\ngifts\nGift-Wrapped\ngig\nGigalo\nGigante\ngigantic\nGIGANTICA\nGigan-Can\nGigantor\nGiggle\nGigglegasm\ngiggler\nGiggles\nGiggling\ngiggly\nGiggy\nGigi\nGigi's\ngigolo\nGigolos\nGil\nGilbert\nGild\nGilded\nGilf\nGILFs\nGILF's\nGili\nGillis\nGilty\nGimme\nGimmie\nGimp\nGin\nGina\nGina’s\nGinas\nGina's\nGinebra\nGinette\nginger\nGingerly\nGingers\nGinger's\nGingervitis\nGingie\nGinna\ngino\nGinomous\nGinormous\nGinta\nGio\nGIO001\nGIO002\nGIO003\nGIO005\nGIO006\nGIO011\nGIO012\nGIO013\nGIO014\nGIO015\nGIO016\nGIO017\nGIO019\nGIO021\nGIO022\nGIO023\nGIO025\nGIO030\nGIO032\nGIO033\nGIO035\nGIO036\nGIO037\nGIO038\nGIO039\nGIO040\nGIO041\nGIO042\nGIO044\nGIO045\nGIO046\nGIO047\nGIO048\nGIO049\nGIO050\nGIO051\nGIO052\nGIO053\nGIO054\nGIO055\nGIO056\nGIO057\nGIO058\nGIO059\nGIO060\nGIO061\nGIO062\nGIO063\nGIO064\nGIO065\nGIO066\nGIO067\nGIO068\nGIO069\nGIO070\nGIO071\nGIO072\nGIO073\nGIO074\nGIO075\nGIO076\nGIO077\nGIO078\nGIO079\nGIO080\nGIO081\nGIO082\nGIO083\nGIO084\nGIO085\nGIO086\nGIO087\nGIO088\nGIO089\nGIO090\nGIO091\nGIO092\nGIO093\nGIO094\nGIO095\nGIO096\nGIO097\nGIO098\nGIO099\nGIO100\nGIO1000\nGIO1001\nGIO1002\nGIO1003\nGIO1004\nGIO1005\nGIO1006\nGIO1007\nGIO1008\nGIO1009\nGIO101\nGIO1010\nGIO1011\nGIO1012\nGIO1013\nGIO1014\nGIO1015\nGIO1016\nGIO1017\nGIO1018\nGIO1019\nGIO102\nGIO1020\nGIO1021\nGIO1022\nGIO1023\nGIO1024\nGIO1025\nGIO1026\nGIO1027\nGIO1028\nGIO1029\nGIO103\nGIO1030\nGIO1031\nGIO1032\nGIO1033\nGIO1034\nGIO1035\nGIO1036\nGIO1037\nGIO1038\nGIO1039\nGIO104\nGIO1040\nGIO1041\nGIO1042\nGIO1043\nGIO1044\nGIO1045\nGIO1046\nGIO1047\nGIO1048\nGIO1049\nGIO105\nGIO1050\nGIO1051\nGIO1052\nGIO1053\nGIO1054\nGIO1055\nGIO1056\nGIO1057\nGIO1058\nGIO1059\nGIO106\nGIO1060\nGIO1061\nGIO1062\nGIO1063\nGIO1064\nGIO1065\nGIO1066\nGIO1067\nGIO1068\nGIO1069\nGIO107\nGIO1070\nGIO1071\nGIO1072\nGIO1073\nGIO1074\nGIO1075\nGIO1076\nGIO1077\nGIO1078\nGIO1079\nGIO108\nGIO1080\nGIO1081\nGIO1082\nGIO1083\nGIO1084\nGIO1085\nGIO1086\nGIO1087\nGIO1088\nGIO1089\nGIO109\nGIO1090\nGIO1091\nGIO1092\nGIO1093\nGIO1094\nGIO1095\nGIO1096\nGIO1097\nGIO1098\nGIO1099\nGIO110\nGIO1100\nGIO1101\nGIO1102\nGIO1103\nGIO1104\nGIO1105\nGIO1106\nGIO1107\nGIO1108\nGIO1109\nGIO111\nGIO1110\nGIO1111\nGIO1112\nGIO1113\nGIO1114\nGIO1115\nGIO1116\nGIO1117\nGIO1118\nGIO1119\nGIO112\nGIO1120\nGIO1121\nGIO1122\nGIO1123\nGIO1124\nGIO1125\nGIO1126\nGIO1127\nGIO1128\nGIO1129\nGIO113\nGIO1130\nGIO1131\nGIO1132\nGIO1133\nGIO1134\nGIO1135\nGIO1136\nGIO1137\nGIO1138\nGIO1139\nGIO114\nGIO1140\nGIO1141\nGIO1142\nGIO1143\nGIO1144\nGIO1145\nGIO1146\nGIO1147\nGIO1148\nGIO1149\nGIO115\nGIO1150\nGIO1151\nGIO1152\nGIO1153\nGIO1154\nGIO1155\nGIO1156\nGIO1157\nGIO1158\nGIO1159\nGIO116\nGIO1160\nGIO1161\nGIO1162\nGIO1163\nGIO1164\nGIO1165\nGIO1166\nGIO1167\nGIO1168\nGIO1169\nGIO117\nGIO1170\nGIO1172\nGIO1173\nGIO1174\nGIO1175\nGIO1176\nGIO1177\nGIO1178\nGIO1179\nGIO118\nGIO1180\nGIO1181\nGIO1182\nGIO1183\nGIO1184\nGIO1185\nGIO1186\nGIO1187\nGIO1188\nGIO1189\nGIO119\nGIO1190\nGIO1191\nGIO1192\nGIO1193\nGIO1194\nGIO1195\nGIO1196\nGIO1197\nGIO1198\nGIO1199\nGIO120\nGIO1200\nGIO1201\nGIO1202\nGIO1203\nGIO1204\nGIO1205\nGIO1206\nGIO1207\nGIO1208\nGIO1209\nGIO121\nGIO1210\nGIO1211\nGIO1212\nGIO1213\nGIO1214\nGIO1215\nGIO1216\nGIO1217\nGIO1218\nGIO1219\nGIO122\nGIO1220\nGIO1221\nGIO1222\nGIO1223\nGIO1224\nGIO1225\nGIO1226\nGIO1227\nGIO1228\nGIO1229\nGIO123\nGIO1230\nGIO1231\nGIO1232\nGIO1233\nGIO1234\nGIO1235\nGIO1236\nGIO1237\nGIO1238\nGIO1239\nGIO124\nGIO1240\nGIO1241\nGIO1242\nGIO1243\nGIO1244\nGIO1245\nGIO1246\nGIO1247\nGIO1248\nGIO1249\nGIO125\nGIO1250\nGIO1251\nGIO1252\nGIO1253\nGIO1254\nGIO1255\nGIO1256\nGIO1257\nGIO1258\nGIO1259\nGIO126\nGIO1260\nGIO1261\nGIO1262\nGIO1263\nGIO1265\nGIO1266\nGIO1267\nGIO1268\nGIO1269\nGIO127\nGIO1270\nGIO1271\nGIO1272\nGIO1273\nGIO1274\nGIO1275\nGIO1276\nGIO1277\nGIO1278\nGIO1279\nGIO128\nGIO1280\nGIO1281\nGIO1282\nGIO1283\nGIO1284\nGIO1285\nGIO1286\nGIO1287\nGIO1288\nGIO1289\nGIO129\nGIO1290\nGIO1291\nGIO1292\nGIO1293\nGIO1294\nGIO1295\nGIO1296\nGIO1297\nGIO1298\nGIO1299\nGIO130\nGIO1300\nGIO1301\nGIO1302\nGIO1303\nGIO1304\nGIO1305\nGIO1306\nGIO1307\nGIO1308\nGIO1309\nGIO131\nGIO1310\nGIO1311\nGIO1312\nGIO1313\nGIO1314\nGIO1315\nGIO1316\nGIO1317\nGIO1318\nGIO1319\nGIO132\nGIO1320\nGIO1321\nGIO1322\nGIO1323\nGIO1324\nGIO1325\nGIO1326\nGIO1327\nGIO1328\nGIO1329\nGIO133\nGIO1330\nGIO1331\nGIO1332\nGIO1333\nGIO1334\nGIO1335\nGIO1336\nGIO1337\nGIO1338\nGIO1339\nGIO134\nGIO1340\nGIO1341\nGIO1342\nGIO1343\nGIO1344\nGIO1345\nGIO1346\nGIO1347\nGIO1348\nGIO1349\nGIO135\nGIO1350\nGIO1351\nGIO1352\nGIO1353\nGIO1354\nGIO1355\nGIO1356\nGIO1357\nGIO1358\nGIO1359\nGIO136\nGIO1360\nGIO1361\nGIO1362\nGIO1363\nGIO1364\nGIO1365\nGIO1366\nGIO1367\nGIO1368\nGIO1369\nGIO137\nGIO1370\nGIO1371\nGIO1372\nGIO1373\nGIO1374\nGIO1375\nGIO1376\nGIO1377\nGIO1378\nGIO1379\nGIO138\nGIO1380\nGIO1381\nGIO1382\nGIO1383\nGIO1384\nGIO1385\nGIO1386\nGIO1387\nGIO1388\nGIO1389\nGIO139\nGIO1390\nGIO1391\nGIO1392\nGIO1393\nGIO1394\nGIO1395\nGIO1396\nGIO1397\nGIO1398\nGIO1399\nGIO140\nGIO1400\nGIO1401\nGIO1402\nGIO1403\nGIO1404\nGIO1405\nGIO1406\nGIO1407\nGIO1408\nGIO1409\nGIO141\nGIO1410\nGIO1411\nGIO1412\nGIO1413\nGIO1414\nGIO1415\nGIO1416\nGIO1417\nGIO1418\nGIO1419\nGIO142\nGIO1420\nGIO1421\nGIO1422\nGIO1423\nGIO1424\nGIO1425\nGIO1426\nGIO1427\nGIO1428\nGIO1429\nGIO143\nGIO1430\nGIO1431\nGIO1432\nGIO1433\nGIO1434\nGIO1435\nGIO1436\nGIO1437\nGIO1438\nGIO1439\nGIO144\nGIO1440\nGIO1441\nGIO1442\nGIO1443\nGIO1444\nGIO1445\nGIO1446\nGIO1447\nGIO1448\nGIO1449\nGIO145\nGIO1450\nGIO1451\nGIO1452\nGIO1453\nGIO1454\nGIO1455\nGIO1456\nGIO1457\nGIO1458\nGIO1459\nGIO146\nGIO1460\nGIO1461\nGIO1462\nGIO1463\nGIO1464\nGIO1465\nGIO1466\nGIO1467\nGIO1468\nGIO1469\nGIO147\nGIO1470\nGIO1471\nGIO1472\nGIO1473\nGIO1474\nGIO1475\nGIO1476\nGIO1477\nGIO1478\nGIO1479\nGIO148\nGIO1480\nGIO1481\nGIO1482\nGIO1483\nGIO1484\nGIO1485\nGIO1486\nGIO1487\nGIO1488\nGIO1489\nGIO149\nGIO1490\nGIO1491\nGIO1492\nGIO1493\nGIO1494\nGIO1495\nGIO1496\nGIO1497\nGIO1498\nGIO1499\nGIO150\nGIO1500\nGIO1501\nGIO1502\nGIO1503\nGIO1504\nGIO1505\nGIO1506\nGIO1507\nGIO1508\nGIO1509\nGIO151\nGIO1510\nGIO1511\nGIO1512\nGIO1513\nGIO1514\nGIO1515\nGIO1516\nGIO1517\nGIO1518\nGIO1519\nGIO152\nGIO1520\nGIO1521\nGIO1522\nGIO1523\nGIO1524\nGIO1525\nGIO1526\nGIO1527\nGIO1528\nGIO1529\nGIO153\nGIO1530\nGIO1531\nGIO1532\nGIO1533\nGIO1534\nGIO1535\nGIO1536\nGIO1537\nGIO1538\nGIO1539\nGIO154\nGIO1540\nGIO1541\nGIO1542\nGIO1543\nGIO1544\nGIO1545\nGIO1546\nGIO1547\nGIO1549\nGIO155\nGIO1550\nGIO1551\nGIO1552\nGIO1553\nGIO1554\nGIO1555\nGIO1556\nGIO1557\nGIO1558\nGIO1559\nGIO156\nGIO1561\nGIO1562\nGIO1563\nGIO1566\nGIO1567\nGIO1568\nGIO1569\nGIO157\nGIO1570\nGIO1571\nGIO1574\nGIO1575\nGIO1576\nGIO1578\nGIO1579\nGIO158\nGIO1580\nGIO1582\nGIO1583\nGIO1585\nGIO1586\nGIO1587\nGIO159\nGIO1590\nGIO1591\nGIO1592\nGIO1593\nGIO1594\nGIO1595\nGIO1596\nGIO1597\nGIO1598\nGIO1599\nGIO160\nGIO1601\nGIO1602\nGIO1604\nGIO1605\nGIO1606\nGIO1607\nGIO1608\nGIO161\nGIO162\nGIO163\nGIO164\nGIO1642\nGIO1649\nGIO165\nGIO166\nGIO167\nGIO1673\nGIO168\nGIO1687\nGIO169\nGIO170\nGIO171\nGIO172\nGIO173\nGIO174\nGIO175\nGIO176\nGIO177\nGIO178\nGIO179\nGIO18\nGIO180\nGIO181\nGIO182\nGIO183\nGIO184\nGIO185\nGIO186\nGIO187\nGIO188\nGIO189\nGIO190\nGIO191\nGIO192\nGIO193\nGIO194\nGIO195\nGIO196\nGIO197\nGIO198\nGIO199\nGIO20\nGIO200\nGIO201\nGIO202\nGIO203\nGIO204\nGIO205\nGIO206\nGIO207\nGIO208\nGIO209\nGIO210\nGIO211\nGIO212\nGIO213\nGIO214\nGIO215\nGIO216\nGIO217\nGIO218\nGIO219\nGIO220\nGIO221\nGIO222\nGIO223\nGIO224\nGIO225\nGIO226\nGIO227\nGIO228\nGIO229\nGIO230\nGIO231\nGIO232\nGIO233\nGIO234\nGIO235\nGIO236\nGIO237\nGIO238\nGIO239\nGIO24\nGIO240\nGIO241\nGIO242\nGIO243\nGIO244\nGIO245\nGIO246\nGIO247\nGIO248\nGIO249\nGIO250\nGIO251\nGIO252\nGIO253\nGIO254\nGIO255\nGIO256\nGIO257\nGIO258\nGIO259\nGIO26\nGIO260\nGIO261\nGIO262\nGIO263\nGIO264\nGIO265\nGIO266\nGIO267\nGIO268\nGIO269\nGIO27\nGIO270\nGIO271\nGIO272\nGIO273\nGIO274\nGIO275\nGIO276\nGIO277\nGIO278\nGIO28\nGIO280\nGIO281\nGIO282\nGIO283\nGIO284\nGIO285\nGIO286\nGIO287\nGIO288\nGIO289\nGIO29\nGIO290\nGIO291\nGIO292\nGIO293\nGIO294\nGIO295\nGIO296\nGIO297\nGIO298\nGIO299\nGIO300\nGIO301\nGIO302\nGIO303\nGIO304\nGIO305\nGIO306\nGIO307\nGIO308\nGIO309\nGIO31\nGIO310\nGIO311\nGIO312\nGIO313\nGIO314\nGIO315\nGIO316\nGIO317\nGIO318\nGIO319\nGIO320\nGIO321\nGIO322\nGIO323\nGIO324\nGIO325\nGIO326\nGIO327\nGIO328\nGIO329\nGIO330\nGIO331\nGIO332\nGIO333\nGIO334\nGIO335\nGIO336\nGIO337\nGIO338\nGIO339\nGIO34\nGIO340\nGIO341\nGIO342\nGIO343\nGIO344\nGIO345\nGIO346\nGIO347\nGIO348\nGIO349\nGIO350\nGIO351\nGIO352\nGIO353\nGIO354\nGIO355\nGIO356\nGIO357\nGIO358\nGIO359\nGIO360\nGIO361\nGIO362\nGIO363\nGIO364\nGIO365\nGIO366\nGIO367\nGIO368\nGIO369\nGIO370\nGIO371\nGIO372\nGIO373\nGIO374\nGIO375\nGIO376\nGIO377\nGIO378\nGIO379\nGIO380\nGIO381\nGIO382\nGIO383\nGIO384\nGIO385\nGIO386\nGIO387\nGIO388\nGIO389\nGIO390\nGIO391\nGIO392\nGIO393\nGIO394\nGIO395\nGIO396\nGIO397\nGIO398\nGIO399\nGIO400\nGIO401\nGIO402\nGIO403\nGIO404\nGIO405\nGIO406\nGIO407\nGIO408\nGIO409\nGIO410\nGIO411\nGIO412\nGIO413\nGIO414\nGIO415\nGIO416\nGIO417\nGIO418\nGIO419\nGIO420\nGIO421\nGIO423\nGIO424\nGIO425\nGIO426\nGIO427\nGIO428\nGIO429\nGIO430\nGIO431\nGIO432\nGIO433\nGIO434\nGIO435\nGIO436\nGIO437\nGIO438\nGIO439\nGIO440\nGIO441\nGIO442\nGIO443\nGIO444\nGIO445\nGIO446\nGIO447\nGIO448\nGIO449\nGIO450\nGIO451\nGIO452\nGIO453\nGIO454\nGIO455\nGIO456\nGIO457\nGIO458\nGIO459\nGIO460\nGIO461\nGIO462\nGIO463\nGIO464\nGIO465\nGIO466\nGIO467\nGIO468\nGIO469\nGIO470\nGIO471\nGIO472\nGIO473\nGIO474\nGIO475\nGIO476\nGIO477\nGIO478\nGIO479\nGIO480\nGIO481\nGIO482\nGIO483\nGIO484\nGIO485\nGIO486\nGIO487\nGIO488\nGIO489\nGIO490\nGIO491\nGIO492\nGIO493\nGIO494\nGIO495\nGIO496\nGIO497\nGIO498\nGIO499\nGIO500\nGIO501\nGIO502\nGIO503\nGIO504\nGIO505\nGIO506\nGIO507\nGIO508\nGIO509\nGIO510\nGIO511\nGIO512\nGIO513\nGIO514\nGIO515\nGIO516\nGIO517\nGIO518\nGIO519\nGIO521\nGIO522\nGIO523\nGIO524\nGIO525\nGIO526\nGIO528\nGIO529\nGIO530\nGIO531\nGIO532\nGIO533\nGIO534\nGIO536\nGIO538\nGIO539\nGIO541\nGIO542\nGIO544\nGIO545\nGIO547\nGIO548\nGIO549\nGIO550\nGIO552\nGIO554\nGIO556\nGIO557\nGIO558\nGIO559\nGIO560\nGIO561\nGIO562\nGIO564\nGIO565\nGIO566\nGIO567\nGIO569\nGIO571\nGIO572\nGIO573\nGIO574\nGIO575\nGIO576\nGIO577\nGIO578\nGIO579\nGIO580\nGIO581\nGIO582\nGIO583\nGIO584\nGIO586\nGIO587\nGIO588\nGIO589\nGIO590\nGIO591\nGIO592\nGIO594\nGIO595\nGIO596\nGIO597\nGIO598\nGIO599\nGIO600\nGIO601\nGIO602\nGIO603\nGIO605\nGIO606\nGIO607\nGIO608\nGIO609\nGIO610\nGIO611\nGIO612\nGIO613\nGIO614\nGIO615\nGIO616\nGIO617\nGIO618\nGIO620\nGIO621\nGIO622\nGIO623\nGIO624\nGIO625\nGIO626\nGIO628\nGIO629\nGIO631\nGIO632\nGIO633\nGIO634\nGIO636\nGIO637\nGIO638\nGIO639\nGIO640\nGIO641\nGIO642\nGIO643\nGIO644\nGIO645\nGIO646\nGIO647\nGIO648\nGIO649\nGIO650\nGIO651\nGIO652\nGIO653\nGIO654\nGIO655\nGIO656\nGIO657\nGIO658\nGIO659\nGIO660\nGIO661\nGIO662\nGIO663\nGIO664\nGIO666\nGIO667\nGIO668\nGIO669\nGIO670\nGIO671\nGIO672\nGIO673\nGIO674\nGIO675\nGIO676\nGIO677\nGIO678\nGIO679\nGIO680\nGIO681\nGIO682\nGIO683\nGIO684\nGIO685\nGIO686\nGIO687\nGIO688\nGIO689\nGIO690\nGIO691\nGIO692\nGIO693\nGIO694\nGIO695\nGIO696\nGIO697\nGIO698\nGIO699\nGIO700\nGIO701\nGIO702\nGIO703\nGIO704\nGIO705\nGIO706\nGIO707\nGIO708\nGIO709\nGIO710\nGIO711\nGIO712\nGIO713\nGIO714\nGIO715\nGIO716\nGIO717\nGIO718\nGIO719\nGIO720\nGIO721\nGIO722\nGIO723\nGIO724\nGIO725\nGIO726\nGIO727\nGIO728\nGIO730\nGIO731\nGIO732\nGIO733\nGIO734\nGIO735\nGIO736\nGIO737\nGIO738\nGIO739\nGIO740\nGIO741\nGIO742\nGIO743\nGIO744\nGIO745\nGIO746\nGIO747\nGIO748\nGIO749\nGIO750\nGIO751\nGIO752\nGIO753\nGIO754\nGIO755\nGIO756\nGIO757\nGIO758\nGIO759\nGIO760\nGIO761\nGIO762\nGIO763\nGIO764\nGIO765\nGIO766\nGIO767\nGIO768\nGIO769\nGIO770\nGIO771\nGIO772\nGIO773\nGIO774\nGIO775\nGIO776\nGIO777\nGIO778\nGIO779\nGIO780\nGIO781\nGIO782\nGIO783\nGIO784\nGIO786\nGIO787\nGIO788\nGIO789\nGIO790\nGIO791\nGIO792\nGIO793\nGIO794\nGIO795\nGIO796\nGIO797\nGIO798\nGIO799\nGIO800\nGIO801\nGIO802\nGIO803\nGIO804\nGIO805\nGIO806\nGIO807\nGIO808\nGIO809\nGIO810\nGIO811\nGIO812\nGIO813\nGIO814\nGIO815\nGIO816\nGIO817\nGIO819\nGIO820\nGIO821\nGIO822\nGIO823\nGIO824\nGIO825\nGIO826\nGIO827\nGIO828\nGIO829\nGIO830\nGIO831\nGIO832\nGIO833\nGIO834\nGIO835\nGIO836\nGIO837\nGIO838\nGIO840\nGIO841\nGIO842\nGIO843\nGIO844\nGIO845\nGIO846\nGIO847\nGIO848\nGIO849\nGIO850\nGIO851\nGIO853\nGIO855\nGIO856\nGIO857\nGIO858\nGIO859\nGIO860\nGIO861\nGIO862\nGIO863\nGIO864\nGIO865\nGIO866\nGIO867\nGIO868\nGIO869\nGIO870\nGIO871\nGIO872\nGIO873\nGIO874\nGIO875\nGIO876\nGIO877\nGIO878\nGIO879\nGIO880\nGIO881\nGIO883\nGIO884\nGIO885\nGIO886\nGIO887\nGIO888\nGIO889\nGIO890\nGIO891\nGIO892\nGIO893\nGIO894\nGIO895\nGIO896\nGIO897\nGIO898\nGIO899\nGIO901\nGIO903\nGIO905\nGIO906\nGIO907\nGIO908\nGIO909\nGIO910\nGIO911\nGIO912\nGIO913\nGIO914\nGIO915\nGIO916\nGIO917\nGIO918\nGIO919\nGIO920\nGIO921\nGIO922\nGIO923\nGIO924\nGIO925\nGIO926\nGIO927\nGIO928\nGIO929\nGIO930\nGIO931\nGIO932\nGIO933\nGIO934\nGIO935\nGIO936\nGIO937\nGIO938\nGIO939\nGIO940\nGIO941\nGIO942\nGIO943\nGIO944\nGIO945\nGIO946\nGIO947\nGIO948\nGIO949\nGIO950\nGIO951\nGIO952\nGIO953\nGIO954\nGIO955\nGIO956\nGIO957\nGIO958\nGIO959\nGIO960\nGIO961\nGIO962\nGIO963\nGIO964\nGIO965\nGIO966\nGIO967\nGIO968\nGIO969\nGIO970\nGIO971\nGIO972\nGIO973\nGIO974\nGIO975\nGIO976\nGIO977\nGIO978\nGIO979\nGIO980\nGIO981\nGIO982\nGIO983\nGIO984\nGIO985\nGIO986\nGIO987\nGIO988\nGIO989\nGIO990\nGIO991\nGIO992\nGIO993\nGIO994\nGIO995\nGIO996\nGIO997\nGIO998\nGIO999\nGiochi\nGioia\nGioia's\nGiorgeous\nGiorgia\nGiorgiana\nGiorgio\nGiorgio's\nGiotto\nGiovana\nGiovanna\nGiovanni\nGipsy\ngir\nGira\nGirando\ngirl\nGirl'\nGIrl\nGIRL**\ngirl?\ngirl`s\ngirl’s\ngirl+1\nGirlBang\ngirl-boy\nGirl-Brutal\ngirlcore\nGirlfest\nGirlfight\ngirlfrie\ngirlfriend\nGirl-Friend\nGIRLfriend\nGirlfriend?\nGirlfriend’s\nGirlfriend-make loveing\ngirlfriends\ngirlfriend's\nGirlfriends\nGirlfriend's\nGirlfriend-selling\ngirl-girl\ngirl-girls\ngirlie\ngirlies\ngirlish\ngirlMade\nGirlMUST\ngirl-next-door\nGirl-Next-Door’s\ngirl-on-girl\nGirlongirl\nGirl-on-girl\nGirlOrgasm\nGirlriend?\ngirls\ngirl's\nGirls\nGirl's\nGIRLS\nGirls?\nGirlscout\ngirlsex\nGirlshower\ngirls-night-out\nGirlsshower\nGirlstuckonmember\nGirlsway\nGirlsway's\ngirlThe\ngirly\ngirlТs\ngirsex\ngirth\ngirthy\nGisela\nGisele\nGiselle\nGiselle's\nGisha\nGisias\nGism\nGisselle\nGisselle's\nGissonias\nGita\nGita's\nGit-Go\nGitta\nGitta's\ngitties\nGivana\nGivanna\ngive\nGiveaway\ngiven\nGivens\ngiver\ngives\nGive's\nGiveYourMoneyToWomen\nGivin\ngiving\nGiz\nGizelle\nGizm\nGizmo\nGizzelle\ngl\nGL001\nGL002\nGL003\nGL004\nGL005\nGL006\nGL007\nGL008\nGL009\nGL010\nGL011\nGL012\nGL013\nGL014\nGL015\nGL016\nGL017\nGL018\nGL019\nGL020\nGL021\nGL022\nGL023\nGL024\nGL025\nGL026\nGL027\nGL028\nGL029\nGL030\nGL031\nGL032\nGL033\nGL034\nGL035\nGL036\nGL037\nGL038\nGL039\nGL040\nGL041\nGL042\nGL043\nGL044\nGL045\nGL046\nGL047\nGL048\nGL049\nGL050\nGL051\nGL052\nGL053\nGL054\nGL055\nGL056\nGL057\nGL058\nGL059\nGL060\nGL061\nGL062\nGL063\nGL064\nGL065\nGL066\nGL067\nGL068\nGL069\nGL070\nGL071\nGL072\nGL073\nGL074\nGL075\nGL076\nGL077\nGL078\nGL079\nGL080\nGL081\nGL082\nGL083\nGL084\nGL085\nGL086\nGL087\nGL088\nGL089\nGL090\nGL091\nGL092\nGL093\nGL094\nGL095\nGL096\nGL097\nGL098\nGL099\nGL100\nGL101\nGL102\nGL103\nGL104\nGL105\nGL106\nGL107\nGL108\nGL109\nGL110\nGL111\nGL112\nGL113\nGL114\nGL115\nGL116\nGL117\nGL118\nGL119\nGL120\nGL121\nGL122\nGL123\nGL124\nGL125\nGL126\nGL127\nGL128\nGL129\nGL130\nGL131\nGL132\nGL133\nGL134\nGL135\nGL136\nGL137\nGL138\nGL139\nGL140\nGL141\nGL142\nGL143\nGL144\nGL145\nGL146\nGL147\nGL148\nGL149\nGL150\nGL151\nGL152\nGL153\nGL154\nGL155\nGL156\nGL157\nGL158\nGL159\nGL160\nGL161\nGL162\nGL163\nGL164\nGL165\nGL166\nGL167\nGL168\nGL169\nGL170\nGL171\nGL172\nGL173\nGL174\nGL175\nGL176\nGL177\nGL178\nGL179\nGL180\nGL181\nGL182\nGL183\nGL184\nGL185\nGL186\nGL187\nGL188\nGL189\nGL190\nGL191\nGL192\nGL193\nGL194\nGL195\nGL196\nGL197\nGL198\nGL199\nGL200\nGL201\nGL202\nGL203\nGL204\nGL205\nGL206\nGL207\nGL208\nGL209\nGL210\nGL211\nGL212\nGL213\nGL214\nGL215\nGL216\nGL217\nGL218\nGL219\nGL220\nGL221\nGL222\nGL223\nGL224\nGL225\nGL226\nGL227\nGL228\nGL229\nGL230\nGL231\nGL232\nGL233\nGL234\nGL235\nGL236\nGL237\nGL238\nGL239\nGL240\nGL241\nGL242\nGL243\nGL244\nGL245\nGL246\nGL247\nGL248\nGL249\nGL250\nGL251\nGL252\nGL253\nGL254\nGL255\nGL256\nGL257\nGL258\nGL259\nGL260\nGL261\nGL262\nGL263\nGL264\nGL265\nGL266\nGL267\nGL268\nGL269\nGL270\nGL271\nGL272\nGL273\nGL274\nGL275\nGL276\nGL277\nGL278\nGL279\nGL280\nGL281\nGL282\nGL283\nGL284\nGL285\nGL286\nGL287\nGL288\nGL289\nGL290\nGL291\nGL292\nGL293\nGL294\nGL295\nGL296\nGL297\nGL298\nGL299\nGL300\nGL301\nGL302\nGL303\nGL304\nGL305\nGL306\nGL307\nGL308\nGL309\nGL310\nGL311\nGL312\nGL313\nGL314\nGL315\nGL316\nGL317\nGL318\nGL319\nGL320\nGL321\nGL322\nGL323\nGL324\nGL325\nGL326\nGL327\nGL328\nGL329\nGL330\nGL331\nGL332\nGL333\nGL334\nGL335\nGL336\nGL337\nGL338\nGL339\nGL340\nGL341\nGL342\nGL343\nGL344\nGL345\nGL346\nGL347\nGL348\nGL349\nGL350\nGL351\nGL352\nGL353\nGL354\nGL355\nGL356\nGL357\nGL358\nGL359\nGL360\nGL361\nGL362\nGL363\nGL364\nGL365\nGL366\nGL367\nGL368\nGL369\nGL370\nGL371\nGL372\nGL373\nGL374\nGL376\nGL377\nGL378\nGL379\nGL380\nGL381\nGL382\nGL383\nGL385\nGL386\nGL387\nGL388\nGL389\nGL390\nGL392\nGL393\nGlacier\nglad\nGlad-He-Ate-Her\nGladiator\nGladiators\nGladly\nGladys\nglam\nGlam-Anal\nGlamas\nGlamazon\nGlam-But-Nasty\nglamcore\nGlam-Core\nglamgal\nGlamkore\nglamorous\nGlamorously\nglamour\nGlamour-Booty\nglamoured\nGlamourous\nGlamours\nGlams\nGlam's\nGlance\nGland\nGlands\nGlans\nGlasha\nglbooty\nGlbootybeauty\nGlbootymember\nGlbootyjohnson\nGlbootybanana\nGlbootybanana1\nGlbootybanana2\nGlbootydong\nGlbootydoor\nglbootyes\nGlbootyhole\nGlbootycat\nGlbootytable\nGlbootytoy\nGlbootyy\nGlaze\nglazed\nGlazee\nGlazin'\nglazing\nGleam\nGleaming\ngleams\nGlee\nGleeful\nGlee's\nGlen\nGlenda\nGlenn\nGlide\nGlider\nGlides\nGlimmer\nGlimmering\nglimpse\nglisten\nglisteni\nGlistening\nGlister\nGlitter\nGlittering\nGlitters\nGlittery\nGlitz\nGlobal\nglobe\nglobes\nGlock\nGlom\nGlomming\ngloom\nGloomy\nGloria\nGlorification\nglorious\nGloriously\nglory\ngloryhole\nGloryholes\nGloss\nGlo-Up\nglove\nGloved\nGlover\ngloves\nglow\nGlower\nGlower's\nGlowing\nGlows\nGlowsticks\nGlowup\nGlubayana\nGlue\nglutes\nGluteus\nGluttony\nGnardians\nGnarly\nGnawing\nGnome\nGnomes\ngo\ngo?\ngoal\nGoalie\nGoalposts\nGoals\ngoatee\nGoatmilkers\nGob\ngobble\ngobbler\ngobbles\ngobblin\ngobbling\nGoblin\nGoblins\nGoca\ngod\nGodBOOTY\nGoddes\ngoddess\nGoddess’\nGoddess13-7\nGoddess6-3The\nGoddessAnnette\ngoddesses\nGoddesses0-2\nGoddessesRound\nGoddess-next-door\nGoddess's\nGoddness\ngodess\ngodesses\nGodessess\nGodfather\nGodiva\nGodliness\nGodly\nGodmother\ngods\nGodsend\nGodshack\nGodzilla\nGodzilla's\nGoege\ngoer\nGoergeous\ngoers\ngoes\ngoe's\nGoes\ngofer\nGoggle\nGoggles\ngo-go\nGogo\nGo-go\nGoGo\nGo-Go\nGo-goo\nGoil\ngoin\nGoin'\ngoing\nGoKart\nGo-Kart\ngold\nGoldberg\ngolddigger\nGold-digger\nGold-Digging\nGoldea\ngolden\nGoldenbeads\nGoldenchair\nGolden-Clad\nGoldenbanana\nGoldendong\nGoldengirl\nGolden-Haired\nGoldenshower\nGoldentoy\nGoldenvibe\nGoldessa\nGoldeu\nGoldfinger\nGoldmake loveer\nGoldi\nGoldimembers\ngoldie\nGoldielocks\nGoldie's\nGoldilocks\nGoldiloxxx\nGoldis\nGoldmilf\nGoldnerova\nGoldone1\nGoldone2\nGolds\nGold's\nGoldsilky\nGoldtop\nGoldtoy\nGoldtoy1\nGoldtoy2\nGoldye\ngolf\nGolfer\nGolfers\nGolfind\nGolfing\nGolfinger\nGoliath\nGoliatheena\nGoliaths\nGolly\nGolubeva\nGomes\nGomez\nGomez's\nGonads\nGondola\ngone\nGong\ngonna\nGonne\nGonz\nGonzales\nGonzalez\ngonzo\nGonzocom\ngoo\ngood\nGood?\ngoodbye\ngood-bye\nGoodbye\nGooder\ngood-make loveing\nGoodgirl\ngoodie\ngoodies\nGoodmorning\ngood-natured\ngoodness\nGoodnight\ngoods\nGoodtimes\nGoodvibe\nGoodwife\ngoody\nGoody-Good\nGooed\ngooey\nGoof'd\nGooGoo\nGoons\nGOOOAALLL\ngoood\nGOOOOAAAALLLLLLL\nGoop\ngoose\ngoosebumps\ngor\nGordon\nGordos\nGorg\ngorge\nGorged\ngorgeous\nGorgeously\ngorgeous-ness\nGorging\nGosh\ngossip\nGossiping\nGossips\ngot\nGotcha\nGoteam\ngoth\nGoth-Chick\ngothey\ngothic\nGOTM\nGots\nGot's\ngotta\ngotten\nGotti\ngourmet\nGovernale\nGoverness\nGovernor\nGown\nGozaimasu\nGozinya\nGozzi\nGP001\nGP002\nGP003\nGP004\nGP005\nGP006\nGP007\nGP008\nGP009\nGP010\nGP011\nGP012\nGP013\nGP014\nGP015\nGP016\nGP017\nGP019\nGP020\nGP021\nGP022\nGP023\nGP024\nGP025\nGP026\nGP027\nGP028\nGP029\nGP030\nGP031\nGP032\nGP033\nGP034\nGP035\nGP036\nGP037\nGP038\nGP039\nGP040\nGP041\nGP042\nGP043\nGP044\nGP045\nGP046\nGP047\nGP048\nGP049\nGP050\nGP051\nGP052\nGP053\nGP054\nGP055\nGP056\nGP057\nGP058\nGP059\nGP060\nGP061\nGP062\nGP063\nGP064\nGP065\nGP066\nGP067\nGP068\nGP069\nGP070\nGP071\nGP072\nGP073\nGP074\nGP075\nGP076\nGP077\nGP078\nGP079\nGP080\nGP081\nGP082\nGP083\nGP084\nGP085\nGP086\nGP087\nGP088\nGP089\nGP090\nGP091\nGP092\nGP093\nGP094\nGP095\nGP096\nGP097\nGP098\nGP099\nGP100\nGP1000\nGP1001\nGP1002\nGP1003\nGP1004\nGP1005\nGP1006\nGP1007\nGP1008\nGP1009\nGP101\nGP1010\nGP1011\nGP1012\nGP1013\nGP1014\nGP1015\nGP1016\nGP1017\nGP1018\nGP1019\nGP102\nGP1020\nGP1021\nGP1022\nGP1023\nGP1024\nGP1025\nGP1026\nGP1027\nGP1028\nGP1029\nGP103\nGP1030\nGP1031\nGP1032\nGP1033\nGP1034\nGP1035\nGP1036\nGP1037\nGP1038\nGP104\nGP1040\nGP1042\nGP1043\nGP1044\nGP1045\nGP1046\nGP1047\nGP1048\nGP1049\nGP105\nGP1050\nGP1051\nGP1052\nGP1053\nGP1054\nGP1055\nGP1056\nGP1057\nGP1058\nGP1059\nGP106\nGP1060\nGP1061\nGP1062\nGP1063\nGP1064\nGP1065\nGP1066\nGP1067\nGP1068\nGP1069\nGP107\nGP1070\nGP1071\nGP1072\nGP1073\nGP1074\nGP1075\nGP1076\nGP1077\nGP1078\nGP1079\nGP108\nGP1080\nGP1081\nGP1082\nGP1083\nGP1084\nGP1085\nGP1086\nGP1087\nGP1088\nGP1089\nGP109\nGP1090\nGP1091\nGP1092\nGP1093\nGP1095\nGP1096\nGP1097\nGP1098\nGP1099\nGP110\nGP1100\nGP1101\nGP1102\nGP1103\nGP1104\nGP1106\nGP1107\nGP1108\nGP1109\nGP111\nGP1110\nGP1111\nGP1113\nGP1114\nGP1115\nGP1116\nGP1117\nGP1118\nGP1119\nGP112\nGP1120\nGP1121\nGP1122\nGP1123\nGP1124\nGP1125\nGP1126\nGP1127\nGP1128\nGP1129\nGP113\nGP1130\nGP1131\nGP1132\nGP1133\nGP1134\nGP1135\nGP1136\nGP1137\nGP1139\nGP114\nGP1140\nGP1141\nGP1142\nGP1143\nGP1144\nGP1145\nGP1146\nGP1147\nGP1148\nGP1149\nGP115\nGP1150\nGP1151\nGP1152\nGP1153\nGP1154\nGP1155\nGP1156\nGP1157\nGP1159\nGP116\nGP1160\nGP1161\nGP1162\nGP1163\nGP1164\nGP1165\nGP1166\nGP1167\nGP1168\nGP1169\nGP117\nGP1170\nGP1171\nGP1172\nGP1173\nGP1174\nGP1175\nGP1176\nGP1177\nGP1178\nGP118\nGP1180\nGP1181\nGP1182\nGP1183\nGP1184\nGP1185\nGP1186\nGP1187\nGP1188\nGP1189\nGP119\nGP1190\nGP1191\nGP1192\nGP1193\nGP1194\nGP1195\nGP1196\nGP1197\nGP1198\nGP1199\nGP120\nGP1200\nGP1201\nGP1202\nGP1203\nGP1204\nGP1205\nGP1206\nGP1207\nGP1208\nGP1209\nGP121\nGP1210\nGP1211\nGP1212\nGP1213\nGP1214\nGP1215\nGP1216\nGP1217\nGP1218\nGP1219\nGP122\nGP1220\nGP1221\nGP1222\nGP1223\nGP1224\nGP1225\nGP1226\nGP1227\nGP1228\nGP1229\nGP123\nGP1230\nGP1231\nGP1232\nGP1234\nGP1236\nGP1237\nGP1238\nGP1239\nGP124\nGP1240\nGP1241\nGP1242\nGP1243\nGP1244\nGP1245\nGP1246\nGP1247\nGP1248\nGP1249\nGP125\nGP1250\nGP1251\nGP1252\nGP1253\nGP1254\nGP1255\nGP1256\nGP1257\nGP1258\nGP1259\nGP126\nGP1260\nGP1261\nGP1262\nGP1263\nGP1264\nGP1265\nGP1266\nGP1267\nGP1268\nGP1269\nGP127\nGP1270\nGP1271\nGP1272\nGP1273\nGP1274\nGP1275\nGP1276\nGP1277\nGP1278\nGP1279\nGP128\nGP1280\nGP1281\nGP1282\nGP1283\nGP1284\nGP1285\nGP1286\nGP1287\nGP1288\nGP1289\nGP129\nGP1290\nGP1291\nGP1292\nGP1293\nGP1294\nGP1295\nGP1296\nGP1297\nGP1298\nGP1299\nGP130\nGP1300\nGP1301\nGP1302\nGP1303\nGP1304\nGP1305\nGP1306\nGP1307\nGP1309\nGP131\nGP1310\nGP1311\nGP1312\nGP1313\nGP1315\nGP1316\nGP1317\nGP1318\nGP1319\nGP132\nGP1320\nGP1321\nGP1323\nGP1324\nGP1325\nGP1326\nGP1327\nGP1328\nGP1329\nGP133\nGP1330\nGP1332\nGP1333\nGP1334\nGP1335\nGP1336\nGP1337\nGP1338\nGP1339\nGP134\nGP1340\nGP1341\nGP1342\nGP1343\nGP1344\nGP1345\nGP1346\nGP1347\nGP1349\nGP135\nGP1350\nGP1351\nGP1352\nGP1353\nGP1354\nGP1355\nGP1356\nGP1357\nGP1358\nGP1359\nGP136\nGP1360\nGP1361\nGP1362\nGP1364\nGP1365\nGP1367\nGP1369\nGP137\nGP1370\nGP1371\nGP1372\nGP1373\nGP1374\nGP1375\nGP1376\nGP1377\nGP1379\nGP138\nGP1380\nGP1381\nGP1382\nGP1383\nGP1384\nGP1385\nGP1386\nGP1387\nGP1388\nGP1389\nGP139\nGP1390\nGP1392\nGP1393\nGP1394\nGP1395\nGP1396\nGP1397\nGP1398\nGP1399\nGP140\nGP1400\nGP1401\nGP1402\nGP1403\nGP1404\nGP1405\nGP1406\nGP1407\nGP1408\nGP1409\nGP141\nGP1410\nGP1411\nGP1412\nGP1413\nGP1414\nGP1415\nGP1416\nGP1417\nGP1419\nGP142\nGP1420\nGP1421\nGP1422\nGP1423\nGP1424\nGP1425\nGP1426\nGP1427\nGP1428\nGP1429\nGP143\nGP1430\nGP1433\nGP1434\nGP1439\nGP144\nGP1440\nGP1443\nGP1446\nGP1447\nGP1448\nGP1449\nGP145\nGP1450\nGP1452\nGP1458\nGP1459\nGP146\nGP1460\nGP1461\nGP1465\nGP147\nGP1471\nGP1475\nGP1478\nGP148\nGP1481\nGP1485\nGP149\nGP1490\nGP1491\nGP1495\nGP1498\nGP150\nGP1508\nGP151\nGP1512\nGP152\nGP1520\nGP1526\nGP1529\nGP153\nGP1532\nGP1537\nGP1539\nGP154\nGP1540\nGP1547\nGP155\nGP1553\nGP1554\nGP1556\nGP1558\nGP156\nGP1561\nGP1563\nGP1564\nGP1567\nGP157\nGP1572\nGP1573\nGP1579\nGP158\nGP1580\nGP1581\nGP1582\nGP1588\nGP1589\nGP159\nGP1596\nGP1598\nGP1599\nGP160\nGP1601\nGP1603\nGP1606\nGP1608\nGP161\nGP1611\nGP1613\nGP1616\nGP1619\nGP162\nGP1622\nGP1626\nGP1628\nGP163\nGP1632\nGP1633\nGP1635\nGP1637\nGP1638\nGP164\nGP1641\nGP1642\nGP1645\nGP1648\nGP1649\nGP165\nGP1652\nGP1653\nGP1655\nGP1658\nGP166\nGP1661\nGP1665\nGP1668\nGP1669\nGP167\nGP1671\nGP1673\nGP1675\nGP1676\nGP1679\nGP168\nGP1682\nGP1683\nGP1686\nGP1689\nGP169\nGP1691\nGP1692\nGP1696\nGP170\nGP1700\nGP1701\nGP171\nGP172\nGP173\nGP174\nGP175\nGP176\nGP177\nGP178\nGP179\nGP180\nGP181\nGP182\nGP183\nGP184\nGP185\nGP186\nGP187\nGP188\nGP189\nGP190\nGP191\nGP192\nGP193\nGP194\nGP195\nGP196\nGP197\nGP198\nGP199\nGP200\nGP201\nGP202\nGP203\nGP204\nGP205\nGP206\nGP207\nGP208\nGP209\nGP210\nGP211\nGP212\nGP213\nGP214\nGP215\nGP216\nGP217\nGP218\nGP219\nGP220\nGP221\nGP222\nGP223\nGP224\nGP225\nGP226\nGP227\nGP228\nGP229\nGP230\nGP231\nGP232\nGP233\nGP234\nGP235\nGP236\nGP237\nGP238\nGP239\nGP240\nGP241\nGP242\nGP243\nGP244\nGP245\nGP246\nGP247\nGP248\nGP249\nGP250\nGP251\nGP252\nGP253\nGP254\nGP255\nGP256\nGP257\nGP258\nGP259\nGP260\nGP261\nGP262\nGP263\nGP264\nGP265\nGP266\nGP267\nGP268\nGP269\nGP270\nGP271\nGP272\nGP273\nGP274\nGP275\nGP276\nGP277\nGP278\nGP279\nGP280\nGP281\nGP282\nGP283\nGP284\nGP285\nGP286\nGP287\nGP288\nGP289\nGP290\nGP291\nGP292\nGP293\nGP294\nGP295\nGP296\nGP297\nGP298\nGP299\nGP300\nGP301\nGP302\nGP303\nGP304\nGP305\nGP306\nGP307\nGP308\nGP309\nGP310\nGP311\nGP312\nGP313\nGP314\nGP315\nGP316\nGP317\nGP318\nGP319\nGP320\nGP321\nGP322\nGP323\nGP324\nGP325\nGP326\nGP327\nGP328\nGP329\nGP330\nGP331\nGP332\nGP333\nGP334\nGP335\nGP336\nGP337\nGP338\nGP339\nGP340\nGP341\nGP342\nGP343\nGP344\nGP345\nGP346\nGP347\nGP348\nGP349\nGP350\nGP351\nGP352\nGP353\nGP354\nGP355\nGP356\nGP357\nGP358\nGP359\nGP360\nGP361\nGP362\nGP363\nGP364\nGP365\nGP366\nGP367\nGP368\nGP369\nGP370\nGP371\nGP372\nGP373\nGP374\nGP375\nGP376\nGP377\nGP378\nGP379\nGP380\nGP381\nGP382\nGP383\nGP384\nGP385\nGP386\nGP387\nGP388\nGP389\nGP390\nGP391\nGP392\nGP393\nGP394\nGP395\nGP396\nGP397\nGP398\nGP399\nGP400\nGP401\nGP402\nGP403\nGP404\nGP405\nGP406\nGP407\nGP408\nGP409\nGP410\nGP411\nGP412\nGP413\nGP414\nGP415\nGP416\nGP417\nGP418\nGP419\nGP420\nGP421\nGP422\nGP423\nGP424\nGP425\nGP426\nGP427\nGP428\nGP429\nGP430\nGP431\nGP432\nGP433\nGP434\nGP435\nGP436\nGP437\nGP438\nGP439\nGP440\nGP441\nGP442\nGP443\nGP444\nGP445\nGP446\nGP447\nGP448\nGP449\nGP450\nGP451\nGP452\nGP453\nGP454\nGP455\nGP456\nGP457\nGP458\nGP459\nGP460\nGP461\nGP462\nGP463\nGP464\nGP465\nGP466\nGP467\nGP468\nGP469\nGP470\nGP471\nGP472\nGP473\nGP474\nGP475\nGP476\nGP477\nGP478\nGP479\nGP480\nGP481\nGP482\nGP483\nGP484\nGP485\nGP486\nGP487\nGP488\nGP489\nGP490\nGP491\nGP492\nGP493\nGP494\nGP495\nGP496\nGP497\nGP498\nGP499\nGP500\nGP501\nGP502\nGP503\nGP504\nGP505\nGP506\nGP507\nGP508\nGP509\nGP510\nGP511\nGP512\nGP513\nGP514\nGP515\nGP516\nGP517\nGP518\nGP519\nGP520\nGP521\nGP522\nGP523\nGP524\nGP525\nGP526\nGP527\nGP528\nGP529\nGP530\nGP531\nGP532\nGP533\nGP534\nGP535\nGP536\nGP537\nGP538\nGP539\nGP540\nGP541\nGP542\nGP543\nGP544\nGP545\nGP546\nGP547\nGP548\nGP549\nGP550\nGP551\nGP552\nGP553\nGP554\nGP556\nGP557\nGP558\nGP559\nGP560\nGP561\nGP562\nGP563\nGP564\nGP565\nGP566\nGP567\nGP568\nGP569\nGP570\nGP571\nGP572\nGP573\nGP574\nGP575\nGP576\nGP577\nGP578\nGP579\nGP580\nGP581\nGP582\nGP583\nGP584\nGP585\nGP586\nGP587\nGP588\nGP589\nGP590\nGP591\nGP592\nGP593\nGP594\nGP595\nGP596\nGP597\nGP598\nGP599\nGP600\nGP601\nGP602\nGP603\nGP604\nGP605\nGP606\nGP607\nGP608\nGP609\nGP610\nGP611\nGP612\nGP613\nGP614\nGP615\nGP616\nGP617\nGP618\nGP619\nGP620\nGP621\nGP622\nGP623\nGP624\nGP625\nGP626\nGP627\nGP628\nGP629\nGP630\nGP631\nGP632\nGP633\nGP634\nGP635\nGP636\nGP637\nGP638\nGP639\nGP640\nGP641\nGP642\nGP643\nGP644\nGP645\nGP646\nGP647\nGP648\nGP649\nGP650\nGP651\nGP652\nGP653\nGP654\nGP655\nGP656\nGP657\nGP658\nGP659\nGP660\nGP661\nGP662\nGP663\nGP664\nGP665\nGP666\nGP667\nGP668\nGP669\nGP670\nGP671\nGP672\nGP673\nGP674\nGP675\nGP676\nGP677\nGP678\nGP679\nGP680\nGP681\nGP682\nGP683\nGP684\nGP685\nGP686\nGP687\nGP688\nGP689\nGP690\nGP691\nGP692\nGP693\nGP694\nGP695\nGP696\nGP697\nGP698\nGP699\nGP700\nGP701\nGP702\nGP703\nGP704\nGP705\nGP706\nGP707\nGP708\nGP709\nGP710\nGP711\nGP712\nGP713\nGP714\nGP715\nGP716\nGP717\nGP718\nGP719\nGP720\nGP721\nGP722\nGP723\nGP724\nGP725\nGP726\nGP727\nGP728\nGP729\nGP730\nGP731\nGP732\nGP733\nGP734\nGP735\nGP736\nGP737\nGP738\nGP739\nGP740\nGP741\nGP742\nGP743\nGP744\nGP745\nGP746\nGP747\nGP748\nGP749\nGP750\nGP751\nGP752\nGP759\nGP760\nGP761\nGP762\nGP763\nGP764\nGP765\nGP766\nGP767\nGP768\nGP769\nGP770\nGP771\nGP772\nGP773\nGP774\nGP775\nGP776\nGP777\nGP778\nGP779\nGP780\nGP781\nGP782\nGP783\nGP784\nGP785\nGP786\nGP787\nGP788\nGP789\nGP790\nGP791\nGP792\nGP793\nGP794\nGP795\nGP796\nGP797\nGP798\nGP799\nGP800\nGP801\nGP802\nGP803\nGP804\nGP805\nGP806\nGP807\nGP808\nGP809\nGP810\nGP811\nGP812\nGP813\nGP814\nGP815\nGP816\nGP817\nGP818\nGP819\nGP820\nGP821\nGP822\nGP823\nGP824\nGP825\nGP826\nGP827\nGP828\nGP829\nGP830\nGP831\nGP832\nGP833\nGP834\nGP835\nGP836\nGP837\nGP838\nGP839\nGP840\nGP841\nGP842\nGP843\nGP844\nGP845\nGP846\nGP847\nGP848\nGP849\nGP850\nGP851\nGP852\nGP853\nGP854\nGP855\nGP856\nGP857\nGP858\nGP859\nGP860\nGP861\nGP862\nGP863\nGP864\nGP865\nGP866\nGP867\nGP868\nGP869\nGP870\nGP871\nGP872\nGP873\nGP874\nGP875\nGP876\nGP877\nGP878\nGP879\nGP880\nGP881\nGP882\nGP883\nGP884\nGP885\nGP886\nGP887\nGP888\nGP889\nGP890\nGP891\nGP892\nGP893\nGP894\nGP895\nGP896\nGP897\nGP898\nGP899\nGP900\nGP901\nGP902\nGP903\nGP904\nGP905\nGP906\nGP907\nGP908\nGP909\nGP910\nGP911\nGP912\nGP913\nGP914\nGP915\nGP916\nGP917\nGP918\nGP920\nGP921\nGP922\nGP923\nGP924\nGP925\nGP926\nGP927\nGP928\nGP929\nGP930\nGP931\nGP932\nGP933\nGP934\nGP935\nGP936\nGP937\nGP938\nGP939\nGP940\nGP941\nGP942\nGP943\nGP944\nGP945\nGP946\nGP947\nGP948\nGP949\nGP950\nGP951\nGP952\nGP953\nGP954\nGP955\nGP956\nGP957\nGP958\nGP959\nGP960\nGP961\nGP962\nGP963\nGP964\nGP965\nGP966\nGP967\nGP968\nGP969\nGP970\nGP971\nGP972\nGP973\nGP974\nGP975\nGP976\nGP977\nGP978\nGP979\nGP980\nGP981\nGP982\nGP983\nGP984\nGP985\nGP986\nGP987\nGP988\nGP989\nGP990\nGP991\nGP992\nGP993\nGP994\nGP995\nGP996\nGP997\nGP998\nGP999\nGPA\nGPS\nGr8\nGrab\ngrabbed\nGrabber\nGrabbers\nGrabbin\ngrabbing\nGrabbit\nGrabby\nGrab-It\ngrabs\ngrace\ngraceful\nGracefully\nGrace's\nGraci\nGracias\nGracie\nGracie's\ngracing\nGracio\nGracious\ngrad\ngrade\ngrades\nGrades?\nGrading\nGraduate\ngraduated\nGraduates\nGraduating\nGraduation\nGrady\nGrae\nGraf\nGrafenberg\nGraff\ngraffiti\nGraham\nGraham's\nGram\nGramma\nGran\ngrand\nGrandads\nGrandchild's\nGranddaughter’s\nGranddaughter's\nGrande\n'Grande\ngrandes\nGrande's\nGrandey\ngrandfather\nGrandi\ngrandma\ngrandmas\nGrandma's\ngrandmotherfirst\nGrandmother's\ngrandpa\nGrandparents\ngrandpa's\nGrandpas\nGrandpa's\nGrand's\nGrandson\ngrandson's\nGranger\nGranger's\nGrannie\ngrannies\ngranny\nGrannymake loveer\nGranny-make loveer\ngranny's\nGran's\nGrant\ngranted\nGrantia\nGranting\ngrants\nGrape\ngrapefruit\ngrapes\nGrapes1\nGrapes2\ngrapevines\nGraphic\nGraphically\nGrappa\nGrappler\nGrappler0-0The\nGrappler6-4\nGrapplers\nGrappling\ngrapvines\nGras\nGrase\nGrasp\ngrasping\ngrbooty\nGrbootyfield\nGrbootyfingers\nGrbootyo\ngrateful\nGratification\nGratifies\nGratify\nGratio\nGratis\ngracanude\nGrave\nGravel\nGraves\nGrave-y\nGraveyard\nGravitiy\ngravity\nGravy\nGray\nGray-Bearded\nGraycouch\nGraylandia\nGrayroom\nGrays\nGray's\nGrayson\nGrayvibrator\nGraze\nGrazia\nGraziella\ngrazing\nGrazy\nGreampie\ngrease\ngreased\nGreasy\ngreat\ngreatest\nGreatful\nGreatGapes\ngreatly\nGreatness\nGreats\ngreazy\nGreece\ngreedy\nGreek\ngreen\nGreencard\nGreenchair\nGreenchair2\nGreenbanana\nGreene\nGreener\nGreenery\nGreenest\ngreen-eyed\nGreenfinger\nGreengreen1\nGreengreen2\nGreenmachine\nGreenmachine2\nGreenpanties\nGreenpanties1\nGreenpanties2\nGreenpants\nGreenroom\nGreens\nGreen's\nGreenshirt\nGreentop\nGreentoy\nGreenvelle\nGreenvibe\nGreenville\ngreet\ngreeting\ngreetings\nGreets\nGreg\nGregg\nGregory\nGrenarz\nGreta\nGreta's\nGretchen\nGretta\nGrety\ngrew\nGrey\nGrey’s\ngrey-haired\nGreys\nGrey's\nGreyson\nGrid\nGridiron\nGrief\nGriffin\nGriffin's\nGriffith\nGriffith's\nGriffol\nGrifos\ngrill\nGrilled\nGrilling\ngril's\nGrim\nGrimes\nGrimlock\nGrin\nGrinch\nGrind\nGrinder\nGrinders\nGrindhouse\nGrindin'\ngrinding\ngrinds\nG-Ring\nGrinning\nGrins\ngrip\ngrips\nGrisha\nGrisha's\nGristle\ngritty\nGroan\ngroans\ngrocer\nGroceries\nGrocery\nGroggy\nGroin\nGroins\ngroo\nGrooling\nGroom\nGroomed\nGroomers\nGrooming\nGrooms\ngroomsmen\nGroove\nGroovin\nGroovy\nGrope\ngrope_and_poke\nGroped\nGroper\nGropin'\ngroping\ngropist\nGros\nGross\nGrotto\nground\nGrounded\nGrounds\ngroup\ngroupie\ngroupies\nGroupies?\nGroupist\nGroupists\nGroupsex\nGrout\nGrove\nGrovel\nGroveling\nGrow\nGrow?\nGrower\ngrowing\ngrown\ngrows\nGrowth\nGrrl\nGrrrr-eat\nGrub\nGrubber\nGrubbing\nGruda\ngrudge\ngrueling\nGrunts\nGrunya\nG's\nG's?\nGSI\ng-spot\nG-spot?\nG-Spots\ng-string\nGstring\nG-string\nGstringteen\nGTFO\nGTL\nGTR\nGuadalajara\nGuage\nGuantana-hoe\nguarantee\nguaranteed\nguarantees\nguard\nGuard’s\nGuardado\nGuardados\nguardian\nGuardiana\nGuardians\nGuarding\nguards\nGuard's\nGucci\nGuerra\nguess\nguessing\nguest\nguesthouse\nguests\nguff;\nGuffy\nguidance\nGuidanceinto\nguide\nGuided\nguides\nGuiding\nGuidos\nGuilhermina\nGuiliana's\nGuillette\nGuilt\nGuiltless\nGuilty\nGuilty?\nGuimaraes\nguitar\nGuitarfun\nGuitarist\nGuitars\nGulch\nGullet\nGulliana\nGullible\nGulnea\nGulp\nGulped\nGulping\ngulps\ngum\nGumbo\nGumby\nGumdrop\ngummy\ngun\nGunn\nGunner\nGunner's\nGunns\nGunn's\nguns\nGuns?\nGunta\nGuppie\nGurgle\nGurgling\nGurl\nguru\ngurus\nGus\nGush\nGusher\nGushers\ngushes\ngushing\nGushy\nGusta\nGustas\nGustav\nGustavo\nGustavo's\nGusto\ngustozo\nGut\nGutierrez\nGutierrez's\nguts\nGutter\nGutterballs\nGutterman-files\nguy\nguy;\nGuy?\nguy?s\nguy’s\nguy=hot\nGuyAgain\nGuyanna\nGuy-Curious\nguy-next-door\nguys\nguy's\nGuys\nGuy�s\nGuys\nGuy's\nguys?\nguysdouble\nGuyta\nGuzman\nGuzzeler\nGuzzle\nguzzler\nGuzzlers\nGuzzles\nguzzlin\nguzzling\nGwardo\nGwen\nGwendoline\nGwyneth\nGya\nGyanti\nGyaru\nGya's\ngym\nGym?\nGymmelonie\nGymnasjohnson\nGymnasim\nGymnbootytics\ngymnast\nGymnast?\nGymnast4-5\nGymnast5-5\nGymnastCurrent\ngymnastic\ngymnastics\ngymnastREAL\ngymnasts\nGymnasty\nGymnist\ngymroom\nGym's\nGymtoy\nGyn\nGyna\nGynecological\ngynecologist\ngynecology\nGyno\nGyongy\nGyorgy\nGypsy\nGypsy's\nGyration\nGyro\nh\nH-2-HO\nH2o\nH3ll4SL00tz\nha\nHa’s\nHabana\nHabía\nHabib\nHabit\nHabitación\nHabitat\nHabitats\nHabits\nHabitude\nHablas\nHablo\nhace\nHacienda\nHaciéndolo\nHack\nHacked\nHacker\nHacks\nhad\nhadcore\nHades\nHadid\nHadjara\nHadley\nHadley's\nHadn't\nHag\nHaggles\nHags\nhaha\nHAHC\nHaide\nHaight\nHaikus\nhail\nHaileey\nhailey\nHailey's\nHaili\nHaily\nhair\nHairbrush\nHaircut\nHaircuts\nHairdo\nhairdresser\nHairdressers\nhaired\nHairpie\nhairs\nHAIRSPRAY\nHairstyling\nHairstylist\nhairy\nhairy-cat\nHairy's\nHairykitty\nHaitian\nHaize\nHajni\nHal\nHalee\nHalena\nhaley\nHaley's\nhalf\nHalf-lesbian\nhalf-naked\nHalftime\nHalf-Time\nHalfway\nHalia\nHalie\nHalili\nHalina\nhall\nHall’s\nHalle\nHallelujah\nHalle's\nHallo?\nHallowanking\nHalloway\nhalloween\nHallowe'en\nHALLOWEEN\nhalloweeny\nhalloweiner\nHallowiener\nHallcat\nHalls\nhallsof\nHalltoy\nhallucinates\nhallway\nHall-Way\nHallwayfun\nHallwaystripper\nHally\nHally's\nHalo\nHalona\nHalsted\nHalston\nham\nHamaSam\nHambre\nHamburger\nHamilfton\nHamiltoe\nHamilton\nHamiltonThe\nhammer\nhammered\nHammer-Make love\nHammerin\nHammering\nHammer's\nHammie\nhammies\nHammock\nHamper\nHams\nHamsel\nHamyna\nHan\nHana\nHanah\nHana's\nhand\nHand?\nhandbag\nHandmelons\nhandcuffed\nHandcuffing\nhandcuffs\nhanded\nHandee\nhandful\nHandfulls\nHandfuls\nHandicam\nHandiwork\nhandy\nhandys\nhandle\nHandled\nhandler\nhandles\nhandlin\nhandling\nhandmade\nHandmaidens\nHandmaid's\nHandomatic\nHand-picked\nHandprints\nhands\nHands?\nHandsaw\nHands-free\nhandshake\nhandsome\nHandsomely\nhands-on\nHandsy\nHandwriting\nhandy\nhandyman\nhandyman's\nhandymen\nHandywoman's\nhang\nhanger\nhangers\nHangin\nhanging\nHangout\nhangover\nHangup\nHanjob\nHank\nHanka\nHankering\nHanky\nHanna\nHannah\nHannah's\nHanna's\nHans\nHanson\nhappen\nhappen?\nhappened\nhappening\nHap-penis\nhappens\nHappenstance\nhappier\nhappiest\nhappily\nhappiness\nhappy\nHappy?\nhappy_halloween\nHappycamper\nHappy-Ending\nhar\nHara\nHarajuku\nharbooty\nHarbootyed\nharBOOTYment\nHarbootyment'\nHarbor\nharcore\nHarcourt\nhard\n-Hard\nHARD\nhard?\nHard-Booty\nHardball\nhard-boat\nhard-bodied\nHardbodied\nhardbody\nHard-Member\nhardcode\nhardcor\nhardcore\nHard-core\nHARDCORE\nhardcore?\nHardcore1\nHardcore2\nHardcore3\nHardcoreblooper\nHardcorebeej\nHardcoreBTS\nhardcoreThe\nHardcorevibe\nHardcroe\nhardCruelest\nHarddrive\nharden\nHardened\nhardening\nhardens\nharder\nHarder?\nhardest\nHardin\nHarding\nhardly\nHardnik\nhardon\nhard-on\nHard-Ons\nHardore\nhardpulled\nhardware\nHardwood\nHardworking\nHard-working\nHardworkingbabe\nHardX\nHardy\nharem\nHarkmoore\nHarleen\nHarlequin\nharlet\nHarley\nHarleys\nHarley's\nharlot\nHarlots\nHarlow\nHarlowe\nHarlow's\nHarm\nHarmless\nHarmoni\nHarmonic\nHar-Monicca\nHarmonie\nHarmonious\nHarmonize\nharmony\nHarmony’s\nHarmony's\nharness\nHarper\nHarper's\nHarpoonhottie\nHarrbootyment\nHarrington\nHarris\nHarrison\nHarry\nHarsh\nharsh-make loveing\nHart\nHart?\nHartley\nHartley's\nHartlova\nHartman\nHart-on\nHart's\nHarua\nHaruhi\nHaruka\nHaruna's\nHarvard\nharvest\nHarvey\nhas\nHase\nHase's\nHashtag\nhasn't\nHasta\nHasti\nhat\nHatch\nHatchet\nhate\nHated\nHatemake love\nHate-Make love\nHatemake loveing\nHate-Monger\nHaters\nhates\nHath\nHats\nHatter\nHaughty\nHaul\nHaulin\nHaunted\nHaunting\nHaus?\nHause\nHausser\nHaute\nhav\nHavana\nHavanah\nhave\nhave?\nhaven\nhavent\nhaven't\nHavent\nHaven't\nHavin\nhaving\nHavoc\nHavoc's\nHaw\nHawaii\nHawaiian\nHawaii's\nhawk\nHawkHandsome\nHawkins\nHawks\nHawt\nHawthorne\nhay\nHaydee\nHayden\nHayes\nHaylee\nHayley\nHayli\nHaylie\nhayloft\nHayride\nHays\nHaysPart\nHaytni\nHazard\nHazardous\nhaze\nhazed\nhazel\nHazel's\nHazer\nHazes\nHaze's\nHazey\nHazing\nHazy\nHazzard\nH-Cup\nH-Cups\nHD\nHDV\nhe\nhe’ll\nhe’s\nhead\nhead?\nheadache\nHeadaches\nHeadAnd\nheaded\nheader\nHeadEx\nHeadfirst\nHeadgear\nHeadhunter\nheading\nHeadknocker\nHeadless\nHeadlight\nHeadlights\nHeadliner\nHeadlines\nheadlock\nheadlocks\nHeadmaster\nHeadmaster?\nHeadmaster's\nHeadmistress\nHeadphones\nHeadquarters\nHeadrush\nheads\nheadshop\nHeadshot\nHeadshots\nHeadspace\nHeadstand\nHeadstrong\nhead-thrashing\nHeaducation\nHeady\nHeal\nHealed\nHealer\nhealing\nheals\nhealth\nhealthcare\nhealthy\nHeaping\nhear\nheard\nHears\nheart\nheartA\nHeartache\nHeartbeat\nHeartbeats\nHeartbreak\nHeartbreaker\nHeartbreakers\nHearted\nHeartfelt\nHearth\nHeartpanties\nhearts\nHeart's\nHeart-Shaped\nHeartShut\nHeartskirt\nHeartthrob\nHeartwarming\nHearty\nheat\nHeated\nHeath\nHeathenous\nheather\nHeathers\nHeather's\nHeatin\nheating\nheats\nHeatwave\nheaven\nHeaven?\nheavenly\nheavens\nHeaven's\nHeaven-sent\nheavily\nHeaving\nheavy\nHeavy-turkeyed\nHeavy-Hanging\nHeavy-Hootered\nHeavyweight\nHeck\nHeckler\nHectate\nHector\nHeddie\nhedges\nHedo\nHedonism\nHedonista\nHedonistas\nHedonistic\nHedvika\nheel\nheeled\nheeling\nheels\nHeelsandstockings\nHeelsplay1\nHeelsplay2\nHeelsshoot\nheely\nHeena\nHef's\nHefty\nHei\nHeidi\nHeidi's\nHeidy\nHeight\nHeighten\nHeights\nHeine\nheiney\nheinie\nHeinous\nHeiress\nHeirloom\nheist\nHekix\nHela\nheld\nHelen\nHelena\nHelena?\nHelena’s\nHelena's\nHelene\nHelfire\nHelga\nHelian\nHelimemberter\nHelicopter\nHelimas\nHelionix\nHelios\nHelisika\nhell\nhella\nHell-Candy\nHellcat\nHellcats\nHellen\nHellfire\nHellfire's\nHellhound\nHelli\nHellie\nHellish\nhellIsis\nhellLosers\nhellMade\nhello\nHello Brooke Wylde\nHellooo\nHellooooo\nHelloVeronica\nHellraiser\nHells\nHell's\nhellThe\nHelluva\nHellvira\nHelly\nhelmet\nhelp\nhelp?\nhelped\nhelper\nhelpers\nhelpful\nhelping\nhelpings\nhelpless\nhelplessFingered\nhelplessly\nHelplessness\nhelplessPowerful\nhelplessStripped\nhelps\nHelsing\nHelsinki?\nHelvetia\nHemingway\nHempburne\nHen\nHendrick\nhendrix\nHendrix's\nHenedy\nHenessy\nHenessy's\nHenger\nHengher\nHengst\nHeni\nHenley\nHenna\nHennessey\nHennessy\nHennesy\nHenriett\nHenrietta\nHenriette\nHenry\nHens\nHentai\nher\n-Her\nHER\nher;\nher?\nHer-Booty-Meant\nherCries\nHercules\nHerda\nhere\nhere?\nhere's\nHeres\nHere's\nHeretical\nHermana\nHermanastro\nHermanita\nHermano\nHermionie\nHermit\nHermosa\nHermosas\nHermyna\nhero\nHeroes\nHeroically\nHeroin\nHeroína\nHeroine\nHeroines\nHero's\nHerrera\nHerrera's\nHerringbone\nHerrlich\nhers\nherse\nherself\nHershey\nHerst\nherthen\nherCans\nhe's\nHesitant\nhesitation\nHettie's\nHetty\nHeven\nHevika\nHexe\nHexxx\nHey\nHeydays\nHeys\nHeywood\nHG012\nHG013\nHG014\nHG015\nHG017\nHG018\nHG019\nHG020\nHG021\nHG022\nHG023\nHG024\nHG025\nHG026\nHG027\nHG028\nHG029\nHG030\nHG031\nHG032\nHG033\nHG034\nHG035\nHH\nHH-cup\nhi\nhialeah\nhiatus\nHibachi\nHibernation\nHibiscus\nHiboys\nHicks\nHicksville\nhidden\nHiddenly\nhide\nHide-and-seek\nHideaway\nHideous\nHideout\nhides\nHide-The-Thong\nhiding\nHiearchy\nHielo\nhigh\nHighbrow\nhigh-clbooty\nhigheels\nHigh-end\nHigh-Energy\nHigher\nhighest\nhigh-heel\nhigh-heeled\nhigh-in\nHigh-life\nHighlight\nhighlighter\nHighlighting\nHighlight's\nhighly\nHigh-Maintenence\nHighness\nHigh-Power\nHigh-Powered\nHigh-Quality\nHighrise\nHigh-rise\nhighs\nHighschool\nhigh-speed\nHigh-Stakes\nhigh-style\nHigh-Sugar\nhigh-tops\nHigh-Waisted\nhighway\nhighways\nHija\nHijab\nHijack\nHi-Jacker\nhijacks\nHIJASTRO\nhi-jinks\nHijinks\nHi-jinks\nHijinx\nHijinxs\nHijo\nHikaru's\nhike\nHiked-Up\nhiker\nHikers\nhikes\nhiking\nHilary\nHilda\nHilix\nhill\nHillary\nHillarys\nHillary's\nHillbillly\nHillbilly\nHilled\nhills\nHill's\nHills's\nHillton\nHilo\nHilson\nHilt\nHilton\nHilton's\nhim\nhim?\nHimalayas\nHim--And\nHime\nHime’s\nHimedorei\nHimera\nHime's\nHimita\nhimself\nHina\nHinade\nHinata\nHind\nHindsight\nHiney\nHinnian\nHint\nhints\nhip\nHip-Hop\nHip-hope\nhippie\nHippies\nHippo\nHippy\nhips\nHipster\nHipsters\nhiquita\nhire\nhired\nhiree\nHiremia\nHires\nhiring\nHiroko\nHirst\nHirsute\nhis\nHispania\nHispanic\nHistoire\nHistorical\nhistory\nhit\nHitachi\nHitch\nhitchmembering\nHitched\nhitcher\nHitches\nHitchmake loveing\nhitchhiker\nhitchhiker's\nHitchhikers\nHitchhiker's\nhitchhiking\nHitch-Hiking\nhitchiker\nHitchin\nHitching\nHither\nHithoxia\nHitman\nHitomi\nHitomi's\nhits\nHitter\nHitters\nHittin\nhitting\nHIUGE\nHix\nHix?\nHix's\nHiyori\nHLF\nHmm\nhmmmm\nhms125\nhms127\nho\nHo…\nHoagie\nHoarder\nHoarding\nhobbies\nhobby\nHobie\nHobo\nHockey\nHockey-Can\nHocus\nHodessa\nHo-Down\nhoe\nHoedown\nHoelidays\nhoes\nHoe's\nHOES\nHoesiery\nHoetel\nHOE-tel\nHog\nHogar\nHogger\nHogging\nHogh\nHogited\nhogtie\nhogtied\nhogtiedAnal\nHogtiedand\nHogtiedBig\nHogTiedcom\nHogtied's\nHogtiedSucks\nHogtiedThe\nHogtiedTying\nHogtiedWe\nhogtiefinger\nHogwarts\nHohan\nHo-ho-hot\nHokey\nHola\nHolashower\nhold\nHolden\nholder\nHolding\nholds\nhold-up\nhole\nHole?\nHoled\nHolefinger\nHole-in-One\nHole-istic\nholes\nholes?Pretty\nHoley\nHole-y\nHoli\nHolic\nholiday\nHo-liday\nHOLIDAY\nholiday_hottness\nholidays\nHoliday's\nHOLIDAYS\nHolidayslast\nholidaze\nHolier\nHoliest\nHolistic\nHolland\nHollander\nHollanderanal\nHolland's\nHolli\nHolliday\nhollie\nHollow\nHolloway\nholly\n'Holly\nholly?\nHolly’s\nHollyday\nHolly-Days\nHolly-Land\nHollys\nHolly's\nHOLLYWEIRD\nHolly-Whore\nhollywood\nHollywood's\nHollyWould\nHolm\nHolmes\nHologram\nHolographic\nHolosexual\nholster\nHoly\nHolyjohnson\nHoly-hotness\nHolyjeans\nHolz\nHomage\nHomance\nhome\nhome?\nHomebody\nhomeboy\nHomebreaker\nHome-Brew\nHomebuyers\nHome-Called\nHomecoming\nHome-EC\nHomegrown\nhomeless\nhomemade\nhome-made\nHomemade\nHome-made\nhomemaker\nHomeowner\nHomer\nHomeroom\nHomer's\nHomerun\nHomeschool\nHome-school\nHomeschooled\nHome-Schooled\nHomeSharing\nHome-Sharing\nHomesick\nHometown\nhomevideo\nhomevids\nhomework\nHomeworks\nHomewrecker\nHomewreckers\nHomewrecking\nhomie\nhomies\nHomie's\nHomme\nHomo\nHomoErotic\nHomonym\nHomoPod\nHoncho\nHonduran\nhones\nhonest\nHonestly\nhoney\nHoney'\nHoney?\nHoney’s\nHoneycomb\nHoneycups\nHoneydrop\nhoneyed\nhoneylee\nhoneymoon\nHoneymooners\nhoneypot\nhoney-pot\nHoneypot\nhoneys\nhoney's\nHoneys\nHoney's\nHONEYS\nHoney-Tressed\nHoneywell\nHong\nHonies\nHonig\nHonk\nHonkers\nHonking\nHonky\nHonney\nHonoka\nHonoka's\nHonolulu\nHonor\nHonors\nHonour\nHonour's\nHoo\nHooch\nhoochie\nHoochies\nhood\nhoodie\nHoodrat\nHood-Rats\nhoods\nhook\nhookah\nHookahgirl\nHookahhottie\nHookahvibe\nHooke\nhooked\nhooked_on_latinas\nhooker\nHookers\nHooker's\nHookey\nHookie\nHookin\nHooking\nhooks\nHookuh\nhookup\nhook-up\nHookup\nHook-up\nHookups\nhooky\nHoola\nHoola-Hoop\nhoop\nHooped\nHooping\nhoops\nHoopty\nHooray\nHoosier\nhooter\nHooterrific\nhooters\nHooterville\nhooterween\nhoover\nhop\nHopalot\nhope\nhoped\nHopeful\nHopefuls\nhopeless\nhopelessly\nhopes\nHope's\nHop-hop\nhoping\nHopkins\nHopper\nhopping\nHoppy\nhops\nhopscotch\nHop-scotching\nHopskeet\nhor\nHorde\nhordes\nhorizon\nhorizons\nHorizont\nHorizontal\nHormone\nhormones\nHormy\nhorn\nHornball\nhornballin\nhorn-balls\nHorndog\nHorne\nHorned\nHorney\nHornicitis\nhornie\nhornier\nhorniest\nHorniest?\nhorniness\nHornio\nHorno\nHorno's\nHornstein\nhorny\nhorny?\nHornycratic\nHornyness\nHornyteen\nhoroscope\nHorrible\nhorrific\nHorror\nHorrors\nHors\nhorse\nHorseback\nHorseCowboy\nhorseHard\nhorseplay\nHorseplaying\nhorserider\nhorse-riding\nHorses\nHorseshoe\nHorsin\nHorsing\nHos\nHo's\nHOs\nhose\nhosed\nHoser\nhoses\nHose-Wearing\nHosiery\nHosing\nHospitable\nhospital\nhospitality\nHospitalized\nhost\nhostage\nHostages\nHosted\nhostel\nHostess\nHostesses\nHostile\nhosts\nhot\nhot?\nhot_dog_stand\nHot-Bootyed\nhot-blooded\nHot-Bodied\nHotbox\nHotcore\nhotdog\nhotdogging\nhotel\nHotelroom\nhotels=Horny\nHotie\nHotkinkyjo\nHotlanta\nHotline\nHotly\nhotness\nHot'n'horny\nHotoni\nHotpants\nHotpinkcat\nHotrod\nhots\nHot's\nHotshot\nHotSpot\nhotster\nHott\nHot-tempered\nhotter\nHotter?\nhottest\nhottie\nHottie'\nHOTTIE\nHottie’s\nHottieland\nhotties\nhottie's\nHotties\nHotties'\nHottie's\nHotties?\nHottness\nhottster\nHottub\nHot-Tub\nHottubaction\nhotty\nhotwife\nHotwife's\nHotwives\nHotYlek\nHoudini\nhound\nhour\nhour?\nHourglbooty\nhours\nHourwife\nhouse\nhouse?\nhouse_auditions\nHouseboy\nHouseboys\nHousebroken\nHousecall\nhousecalls\nHouseguest\nHousehold\nHousehusband\nhousekeeper\nhousekeeper's\nHousekeepers\nHousekeeper's\nHousekeeping\nhousemaid\nHousemaid's\nHousemates\nHouse-Owned\nHouse-Party\nHousepet\nHouses\nHousesitter\nHousesitting\nHouse-Sitting\nHousewarming\nhousewife\nhousewifes\nHousewife's\nHousewive\nhousewives\nhousework\nHousework?\nHousing\nHousmaid\nHouston\nhouswifes\nhovering\nhow\nHoward\nHowdy\nHowell\nHowell's\nHowever\nHowie\nHowl\nHowling\nhows\nHow's\nHoyt\nHR\nHR'\nHrisanta\nHrs\nHsu\nHT\nHTML\nhuband's\nHubba\nHubbie\nHubbie's\nhubby\nhubby's\nHuddle\nHudson\nHudson's\nHudsonThe\nHuff\nhug\nHugarian\nhuge\nHuge-Meloned\nHuge-Member\nHugemember's\nhuge-johnsoned\nHugedong\nHugeGapes\nHugely\nHUGE-MAMMARIES\nhuge-meloned\nHugest\nHuge-Canted\nHugevibe\nHuggin\nHugging\nHugh\nHughes\nHugo\nhugs\nHuh\nHui\nhula\nHulahoop\nHula-Hooping\nHulk\nhum\nhuman\nHumanitarian\nhumanity\nHumans\nhumble\nHum-Bug-Her\nhumdinger\nHúmedo\nhumilated\nhumilation\nhumiliate\nhumiliated\nhumiliatedLong\nhumiliates\nhumiliating\nhumiliation\nHumiliaton\nHumility\nHummer\nHumming\nHummingbird\nhumms\nhumongous\nHumor\nhump\nhump-a-lot\nHumpday\nHumped\nHumper\nHumpers\nHumpette\nHump-happy\nHumpin\nHumpin'\nhumping\nHumpme\nHumps\nHump-Starting\nHumpy\nHunagarian\nhundred\nhung\nhungarian\nHungarian?\nHungarians\nHungarian's\nhungary\nHungary's\nhunger\nHungers\nHunger-soothing\nHUNGover\nhungry\nHungy\nhunk\nhunks\nhunk's\nHunks\nHunk's\nHunni\nHunnie\nHunnies\nhunny\nhuns\nhunt\nHunted\nhunter\nHunterland\nhunters\nHunter's\nHuntin'\nhunting\nHuntingCandidate\nHuntley\nHuntLorelei\nHuntress\nhunts\nHunt's\nHuntsman\nHupnosis\nHurdle\nHurley\nHurrah\nHurricane\nhurries\nhurry\nhurrywait\nhurt\nhurt?\nHurtin\nhurting\nhurts\nhusbanb's\nhusband\nHusband?\nHusband???\nhusband’s\nhusbands\nhusband's\nHusbands\nHusband's\nHush\nHussie\nHussy\nHustla\nHustlaBall\nhustle\nHustled\nhustler\nHustlers\nHustler's\nHustles\nHustling\nhut\nHuxley\nHXC\nhybrid\nHyde\nHyde-The-Salami\nHydi\nHydie\nHydii\nHydra\nHydrated\nHydraulic\nhydroplane\nHygiene\nHygor\nhymen\nHynten\nHype\nHyped\nHypemares\nHyper\nHyperactive\nHypersexuality\nHypnagogia\nHypno\nhypnotherapist\nhypnotic\nHypnotica\nHypnotiq\nhypnotize\nHypnotized\nHypnotizing\nHypocritical\nhysteria\nHyteen\ni\nI\b\nI'\nI̵\nI?\nI?m\nI’\nI’ll\nI’m\nI’ve\nIâ??\nIâ??m\nIamalexis\nian\nIbarra\nIbarra's\nIbiza\niBone\nIcarus\nice\nice-cream\nIcecream\nIcecreamplay\nIce-Cube\nIced\nIce'd\nIcehot\niceicebaby\nIceing\nIcekitchen\nIcelandic\nIceman\nIcenipples1\nIcecat\nIces\nIcey\nIch\nichelle\nIchijo\nIchika\nicicle\nIcicle's\nIcing\nicky\niMember\nIcon\nIconic\nicons\nI-Cup\nIcy\nId\nI'd\nID\nID?\nIda\nIdaho\nidea\nideal\nIdealize\nideas\nIdee\nIdentical\nIdencany\nIdeph\nIdilius\nIdiot\nIdiota\nidiots\nIdle\nidly\nIdol\nIdolas\nIdolatry\nIdols\nIDs?\nidyll\nIdyllic\nIelza\nIeva\nIevina\nif\nif?\nIfamora\niMake loveable\nIG\nIggy\nIgnea\nignite\nIgnites\nignition\nIgnorant\nIgnore\nignores\nIgnories\nIgor\nIguacu\nIhra\nII\nIibhabhu\nIII\nIily\nIkan\nIkon\nIkonas\nIl\nIlabete\nIlan\nIlana\nIldi\nIldico\nIldy\nIleen\nIlia\nIlia’s\nIlias\nIlina\nIljimae\nill\ni'll\nIll\nI'll\nIllegal\nIllicit\nIllikas\nillness\nILLUMINATED\nIllumination\nillusion\nIllusionist\nIllusions\nIllustrated\nIllustrious\nIlna\nIlona\nIlona’s\nIlona's\nIlova\nIlze\nIm\nI'm\n'Im\nI'M\nIma\nImaculas\nimage\nImagery\nImages\nimagi\nimaginable\nImaginary\nImaginasian\nimagination\nImaginations\nimagine\nImagined\nimagines\nimagining\nImaginings\nIman\nImani\niMasturbate\nImbibing\nImbued\nImitates\nImitating\nimmaculate\nImmanuel\nimmature\nimmediate\nImmediately\nimmense\nImmerse\nImmersed\nimmersion\nImmersive\nImmigrant\nimmigrants\nimmigration\nImmobility\nimmobilization\nImmobilized\nimmobilizing\nImmoral\nImmortal\nImmunity\nimoaN\nImogene\nimpact\nimpacted\nImpacto\nImpale\nimpaled\nimpales\nImpaling\nImpbootyioned\nImpatiens\nImpatient\nImpeccable\nImpede\nImperfection\nImpish\nImplant\nImplanta\nimplants\nImplements\nimplodes\nImpomptu\nimport\nimportance\nImportant\nImportant?\nImported\nImports\nimpose\nimpossible\nImposter\nImpostor\nImpotence\nimpound\nImpounding\nImpregnate\nImpregnated\nImpregnating\nImpregnation\nImpress\nimpressed\nimpresses\nImpressing\nImpression\nImpressionable\nImpressions\nimpressive\nimprints\nImprisonment\nImpromptu\nImproper\nImprov\nimprove\nimprovement\nImprovements\nimproves\nImprovin\nImproving\nImprovisation\nImprovise\nImprovised\nImpudent\nImpugn\nImpulse\nImpulses\nImpulsive\nImpulsiveness\nImpure\nImpurity\nIm-pussible\nin\nin Aaliyah's\nin;\nin?\nin_the_garden_of_eden\nIn…But\nIna\nInadequacy\nInadequate\nIn-A-Gadda-Da-Vida\nInalka\nInamorata\nInappropriate\nInari\nIna's\nInasikaias\nInaugural\nInauguration\nInbo\nInc\nIncandesce\nIncandescence\nIncantu\nincentive\nIncentives\ninception\nIncestuous\ninch\ninches\nInches--No\nInchworm\nIncident\nIncision\nIncite\nInclude\nincluded\nincludes\nincluding\ninclusive\ninmembernito\nIncognito\nincome\nIncomparable\nIncompetent\nInconceivable\nincongruity\nIncongruous\nInconvenient\nIncorporated\nincredible\nincredibly\nIncubus\nInculcation\nIndecency\nIndecent\nIndecisive\nindeed\nIndemni-Ds\nIndemnity\nIndenfor\nIndependance\nIndependant\nindependence\nIndependencia\nIndependent\nIn-Depth\nindescribable\nInDesiree\nIndestructible\nIndia\nIndian\nIndiana\nIndianna\nIndianna's\nIndians\nIndia's\nIndica\nInjohnsonment\nIndietro\nIndifference\nIndigo\nIndigo's\nIndira\nIndirect\nIndiscreción\nIndiscretion\nIndiscretions\nIndonesian\nindoor\nIndoors\nIndra\nInducement\ninducer\ninduces\ninducing\nInduct\ninducted\nindulge\nIndulgence\nIndulgences\nIndulgent\nindulges\nindulging\nIndustrial\nindustry\nIndy\nIneffable\nInes\ninescapable\nInessa\ninevitable\ninexperienced\nInexplicable\nIn-family\ninfamous\nInfantile\nInfatuate\ninfatuated\nInfatuating\nInfatuation\nInfectious\nInference\nInfernal\nInferno\nInfestation\nInfidelicanty\nInfidelity\nInfiel\nInfield\ninfiltrates\nInfiltrating\nInfiltration\nInfinite\nInfinitea\nInfinity\nInfirmary\nInflatable\nInflate\nInflated\ninflates\nInflict\ninflicted\nInflicts\ninfluence\nInfluenced\nInfluencer\nInfluencers\nInfluences\nInfluencia\ninfo\ninfo\\\ninform\nInformal\nInformant\nInformation\nInFRICTION\nInfuse\nInfused\nIng\nInga\nInga's\nIngenious\nIngenue\nInglourious\nIngredient\nIngredients\nIngrid\nInhale\ninhales\nIn-Her\nInherent\nInherit\nInheritance\nInheriting\nInheritor\nIn-Her-Peace\nIn-her-view\nInhibition\ninhibitions\nin-home\nInhuman\nInia\nInilian\ninimitable\nInitha\ninitiate\nInitiated\ninitiates\ninitiating\ninitiation\nInitiations\nInitiative\ninjected\ninjection\nInjections\ninjured\nInjuries\ninjury\nink\nInka\ninked\ninked-up\nIn-Kleined\nInko\nInk's\nInky\nIn-Law\nIn-Lawful\nIn-Laws\nInledning\nin--Lexy\nin-love\nInmaris\ninmate\ninmates\nInMe\nInMy\ninn\nInna\nInnaki\nInnapropriate\nInna's\nInnate\ninner\nInnerself\nInnerspace\nInner-Space\nINNES\nInness\nInnessa\ninnie\nInnings\nInnnocence\ninno\ninnoce\ninnocenc\ninnocence\nInnocence?\nInnocencia\ninnocent\n'Innocent\nINNOCENT\nInnocent?\nInnocentfun\ninnocently\nInnocentTrinity\ninnocentWe\nInnovation\nInnovations\nInnuendo\nInny\nIn-Out\nInpromptu\nInquisition\ninquisitive\nIn-Room\nIns\nInsanal\ninsane\ninsanely\ninsanity\ninsanity?\ninsanityWe\nInsatiability\ninsatiable\ninsatiables\nInsatiably\ninsatiate\ninsatiately\nInseamly\nInsecurity\nInsemination\nInseminator\ninsert\nInsertables\ninserted\nInserters\ninsertibles\ninserting\ninsertion\ninsertions\ninserts\nInsex\nInsextion\nInsiatiable\ninside\nInside-Her\nInside-Out\nInsider\nInsieme\ninsight\nInsights\nInsignis\nInsinuation\nInsist\nInsolia\nInsomnia\nInsomniac\nInsomnio\ninspect\ninspected\nInspect-Hers\nInspecting\ninspection\nInspections\nInspector\ninspectors\nInspects\ninspiration\nInspirational\ninspirations\nInspirazione\ninspire\ninspired\ninspires\ninspiring\nInsta\nInsta-boner\nInstafilm\nInstagirl\nInstagram\nInstallation\nInstalled\nInstaller\ninstallment\nInstallments\nInsta-MILF\nInstance\ninstant\ninstantly\nInsta-Stalker\nInstatiable\ninstead\nInstigating\nInstigation\nInstigator\nInstinct\nInstinctive\nInstincts\nInscanute\nInstruct\ninstruction\ninstructional\ninstructions\ninstructor\ninstructors\ninstructor's\nInstructors\nInstructor's\nInstructs\ninstrume\ninstrument\nInstrumental\nInstruments\nInstuctions\nInsubordinate\nInsubordination\nInsulo\ninsurance\ninsured\nInsurgent\nIn-sync\nint\nInta\nintake\nIntakeExtreme\nIntakeFeatured\nIntakeSado-Masochists\nIntakeSelecting\nIntakeThe\nIntakeThree\nIntame\nInte\nintel\nIntellectuals\nIntelligence\nintence\nintense\nintensely\nIntenseminivibe\nintensifies\nIntensify\nIntensions\nIntensita'\nintensity\nintensive\nIntensively\nIntent\nIntention\nintentions\nIntenxa\ninter\nInteracial\nInteraction\nInteractions\nInteractive\nInterceptado\nIntercepted\nInterchange\nInterconnected\nintercourse\nInterdict\ninterest\ninterested\ninteresting\ninterests\nInterface\nInterference\nIntergalactic\nInterim\ninterior\nInteriors\nInterlaced\ninterlocked\nInterlude\ninterludes\nIntermezzo\nintermixed\nInter-mixed\nintern\ninternal\nIntern-al\nInternally\ninternational\ninterne\ninternet\nInter-net\nInternetsCom\ninternetThe\nInterning\nintern's\nInterns\nIntern's\ninternse\ninternship\nInternships\nInteroffice\ninterogati\nInterogating\nInter-Oral\nInterpreter's\nInterprets\ninterracial\nInterracial'\nINTERRACIAL\ninterraciall\nInterrogate\nInterrogated\ninterrogates\nInterrogating\ninterrogation\nInterrogator\ninterrupt\ninterrupted\nInterrupting\nInterruption\nInterruptions\ninterrupts\nintertwined\nInterupted\nInterval\nIntervenes\nIntervention\nInterventionzz\ninterview\ninterview'\nInterview\nInterview?\nInterview1\nInterview2\nInterview3\nINTERVIEW-A\nINTERVIEW-Abigail\nINTERVIEW-Alix\nINTERVIEW-Brooklyn\ninterviewed\ninterviewee\nInterviewfun\nInterviewing\nINTERVIEW-Mommy\nINTERVIEW-Nurse\nINTERVIEW-Penny\ninterviews\nINTERVIEWS_Corrupt\nINTERVIEWS_Couples\nINTERVIEWS-A\nINTERVIEWS-Abigail\nINTERVIEWS-Alana\nINTERVIEWS-Alina\nINTERVIEWS-Ana\nINTERVIEW-Sara\nINTERVIEWS-Ariana\nINTERVIEWS-August\nINTERVIEWS-Bree\nINTERVIEWS-Carmen\nINTERVIEWS-Carter\nINTERVIEWS-Cherie\nINTERVIEWS-Coming\nINTERVIEWS-Dana\nINTERVIEWS-Emma\nINTERVIEWS-Holly\nINTERVIEWS-India\nINTERVIEWS-It's\nINTERVIEWS-Janice\nINTERVIEWS-Kota\nINTERVIEWS-Lena\nINTERVIEWS-Lollipop\nINTERVIEWS-Misty\nINTERVIEWS-Mom's\nINTERVIEWS-Nina\nINTERVIEWS-No\nINTERVIEWS-Phoenix\nINTERVIEWS-Samantha\nINTERVIEWS-Sara\nINTERVIEWS-Sasha\nINTERVIEWS-Serena\nINTERVIEWS-Shyla\nINTERVIEWS-Stills\nINTERVIEWS-Swim\nINTERVIEWS-Tanya\nINTERVIEWS-Tara\nINTERVIEWS-Teanna\nINTERVIEWS-Tech\nINTERVIEWS-Teen\nINTERVIEWS-Tiff\nINTERVIEWS-Under\nINTERVIEWS-Vanessa\nINTERVIEW-Tasha\nInterviewtwo\nINTERVIEW-Vanessa\ninTHE\nIntima\nintimacy\nintimate\nintimately\nIntimidates\nIntimita\nIntimity\nIntimity?\nIntimo\nincanation\nIn-Can-Pendence\nIntl\ninto\nIntolerable\nintoOblivion\nintoSexual\nIntovis\nIn-town\nintoxicate\nintoxicates\nIntoxicating\nintoxication\nIntra-Office\nIntrigante\nIntrigue\nintrigued\nintriguing\nIntro\nintroduc\nintroduce\nintroduced\nIntroduces\nIntroducing\nIntroducingKenna\nIntroducingValentina\nintroduction\nIntrospection\nIntrospective\nintruder\nIntruders\nIntruding\nintrusion\nIntrusive\nIntuition\nInutility\ninvade\ninvaded\nInvader\nInvaders\ninvades\nInvading\nInvasian\ninvasion\nInvasión\nInvasive\nInvented\nInvention\nInventions\ninventive\nINVERTAMAKE LOVEED\ninverted\ninvest\nInvestigadora\ninvestigated\ninvestigates\nInvestigating\ninvestigation\nInvestigations\ninvestigator\nInvescanure\nInvestment\nInvestor\nInvestor-In-Law\ninvisible\ninvitation\ninvite\ninvited\ninvites\ninviting\nInvito\ninvolved\ninvolves\nInward\nInWay\nIo\nIoana\nIoana's\nIodinegirl\nion\nIona\nIonella\nIowan\niPhone\nIplay\nir\nIra\nIra's\nIreland\nIreland;\nIrelandMake loveed\nIrelandPorn\nIrelandSpread\nIren\nIrena\nIrene\nIrenes\nIrenka\nIresistable\nIrina\nIrina's\nIris\nIrisela\nIrish\nIrisha\nIRL\nIrma\nIroha's\niron\nIronfingers\nironing\nIrons\nIronToy\nIrrational\nIrreconcilable\nirrepressable\nIrresilian\nIrresistable\nirresistible\nirresistibly\nIrresponsible\nIrresponsible?\nIrrigator\nIRS\nIruki\nis\nIs?\nIsa\nIsaac\nIsaacs\nIsabel\nIsabela\nIsabeli\nIsabell\nisabella\nisabelladior\nIsabellas\nIsabella's\nIsabelle\nIsabelli\nIsabell's\nIsabelly\nIsabel's\nIsadora\nIsaiah\nIsamar\nisCATEGORY\nIschia\nIshii\nIsHustling\nIsiah\nIsiah's\nisis\nIsis's\nIsizzu\nIskra\nIsla\nisland\nislands\nIsles\nisn’t\nisn't\nIsnt\nIsn't\nIsobel\nIsolation\niSpank\nIsrael\nIsraeli-American\nIssabella\nIssac\nIssak\nissue\nissues\nIssues'\nist\nIstanbul\nIsThe\niSZ1532\nit\nit\b\nIt\nit?\nIt`s\nIt‘s\nit’s\nItâ??s\nItaka\nItalia\nitalian\nItaliana\nItalia's\nItaly\nitch\nItchin\nitching\nitMember\nit-Countdown\nitem\nItenoe\nItinero\nIt'll\nItmenias\nItNow\nIto\nitR\nits\nit's\nIts\nIt�s\nIts\nIt's\nItsal\nItself\nItsy\nItsy-Bitsy\nItty\nItty-bitty\nIt'z\nIuno\nIV\nIV001\nIV002\nIV003\nIV004\nIV005\nIV006\nIV007\nIV008\nIV009\nIV010\nIV011\nIV012\nIV013\nIV0134\nIV0135\nIV014\nIV015\nIV017\nIV018\nIV019\nIV020\nIV021\nIV022\nIV023\nIV024\nIV025\nIV026\nIV027\nIV028\nIV029\nIV030\nIV031\nIV032\nIV033\nIV034\nIV035\nIV036\nIV037\nIV038\nIV039\nIV040\nIV041\nIV042\nIV043\nIV044\nIV045\nIV046\nIV047\nIV048\nIV049\nIV050\nIV051\nIV052\nIV053\nIV054\nIV055\nIV056\nIV057\nIV058\nIV059\nIV060\nIV061\nIV062\nIV063\nIV064\nIV065\nIV066\nIV067\nIV068\nIV069\nIV070\nIV071\nIV073\nIV074\nIV075\nIV076\nIV077\nIV078\nIV079\nIV080\nIV081\nIV082\nIV083\nIV084\nIV085\nIV086\nIV087\nIV088\nIV089\nIV090\nIV091\nIV092\nIV093\nIV094\nIV095\nIV096\nIV097\nIV098\nIV099\nIV100\nIV101\nIV102\nIV103\nIV104\nIV105\nIV106\nIV107\nIV108\nIV109\nIV110\nIV111\nIV112\nIV113\nIV114\nIV115\nIV116\nIV117\nIV118\nIV119\nIV120\nIV121\nIV122\nIV123\nIV124\nIV126\nIV127\nIV128\nIV129\nIV130\nIV131\nIV132\nIV133\nIV136\nIV137\nIV138\nIV139\nIV140\nIV141\nIV142\nIV143\nIV144\nIV145\nIV146\nIV147\nIV148\nIV149\nIV150\nIV152\nIV153\nIV154\nIV155\nIV156\nIV157\nIV158\nIV159\nIV160\nIV161\nIV162\nIV163\nIV164\nIV165\nIV166\nIV167\nIV168\nIV169\nIV170\nIV171\nIV172\nIV173\nIV174\nIV175\nIV176\nIV177\nIV178\nIV179\nIV180\nIV181\nIV182\nIV183\nIV184\nIV185\nIV186\nIV188\nIV189\nIV190\nIV191\nIV192\nIV193\nIV194\nIV195\nIV196\nIV197\nIV198\nIV199\nIV200\nIV201\nIV202\nIV203\nIV204\nIV205\nIV206\nIV207\nIV208\nIV209\nIV210\nIV211\nIV212\nIV213\nIV214\nIV215\nIV216\nIV217\nIV218\nIV219\nIV220\nIV221\nIV222\nIV223\nIV224\nIV225\nIV226\nIV227\nIV228\nIV229\nIV230\nIV231\nIV232\nIV233\nIV234\nIV235\nIV236\nIV237\nIV238\nIV239\nIV240\nIV241\nIV242\nIV243\nIV244\nIV245\nIV246\nIV247\nIV248\nIV249\nIV250\nIV251\nIV252\nIV253\nIV254\nIV255\nIV256\nIV257\nIV258\nIV259\nIV260\nIV261\nIV262\nIV263\nIV264\nIV265\nIV266\nIV267\nIV268\nIV269\nIV270\nIV271\nIV272\nIV273\nIV274\nIV275\nIV276\nIV277\nIV278\nIV279\nIV280\nIV281\nIV282\nIV283\nIV284\nIV285\nIV286\nIV287\nIV288\nIV289\nIV290\nIV291\nIV292\nIV293\nIV294\nIV295\nIV296\nIV297\nIV298\nIV299\nIV300\nIV301\nIV302\nIV303\nIV304\nIV305\nIV306\nIV307\nIV308\nIV309\nIV310\nIV311\nIV312\nIV313\nIV314\nIV315\nIV316\nIV317\nIV318\nIV319\nIV320\nIV321\nIV322\nIV323\nIV324\nIV325\nIV326\nIV327\nIV328\nIV329\nIV330\nIV331\nIV332\nIV333\nIV334\nIV335\nIV336\nIV337\nIV338\nIV339\nIV340\nIV341\nIV342\nIV343\nIV344\nIV345\nIV346\nIV347\nIV348\nIV349\nIV350\nIV351\nIV352\nIV353\nIV354\nIV355\nIV356\nIV357\nIV358\nIV359\nIV360\nIV361\nIV362\nIV363\nIV364\nIV365\nIV366\nIV367\nIV368\nIV369\nIV370\nIV371\nIV372\nIV373\nIV374\nIV375\nIV376\nIV377\nIV378\nIV379\nIV380\nIV381\nIV383\nIV384\nIV385\nIV386\nIV387\nIV388\nIV389\nIV390\nIV391\nIV392\nIV393\nIV394\nIV395\nIV396\nIV397\nIV398\nIV399\nIV400\nIV401\nIV402\nIV403\nIV404\nIV405\nIV406\nIV407\nIV408\nIV409\nIV410\nIV411\nIV412\nIV413\nIV414\nIV415\nIV416\nIV417\nIV418\nIV419\nIV420\nIV421\nIV422\nIV423\nIV424\nIV425\nIV426\nIV427\nIV428\nIV429\nIV430\nIV431\nIV432\nIV433\nIV434\nIV435\nIV436\nIV437\nIV438\nIV439\nIV440\nIV441\nIV442\nIV443\nIV444\nIV445\nIV446\nIV447\nIV448\nIV449\nIV450\nIV451\nIV452\nIV453\nIV454\nIV455\nIV456\nIV457\nIV458\nIV459\nIV460\nIV461\nIV462\nIV463\nIV464\nIV465\nIV466\nIV467\nIV468\nIV469\nIV470\nIV471\nIV472\nIV473\nIV474\nIV475\nIV476\nIV477\nIV478\nIV479\nIV480\nIV481\nIV482\nIV483\nIV484\nIV485\nIV486\nIV487\nIV488\nIV489\nIV490\nIV491\nIV492\nIV493\nIV495\nIV496\nIV497\nIV498\nIV499\nIV500\nIV501\nIV502\nIV503\nIV504\nIV505\nIV506\nIV507\nIV508\nIV509\nIV510\nIV511\nIV512\nIV513\nIV514\nIV515\nIV516\nIV517\nIV518\nIV519\nIV520\nIV521\nIV522\nIV523\nIV524\nIV525\nIV530\nIva\nIvan\nIvana\nIvana's\nIvanka\nIvanna\nIvanova\nIvans\nIve\nI've\nIves\nIves's\nIveta\nIveta's\nIvette\nIvette's\nIvetti\nIvey\nIvi\nIvija\nIvilis\nIvon\nIvona\nIvonne\nIvories\nivory\nIvory's\nIvy\nIvy65279;\nIvys\nIvy's\nIwia\nIwia's\nIX\nIya\nIyana\nIyesna\nIyeva\nIyulta\nIz\nIza\nIzabela\nIzabella\nIzabelly\nIzadora\nIzamar\nIzamar's\nIza's\nIzi\nIzy\nIzy-Bella\nIzzy\nIzzy-bella\nj\nJa\nJabber\nJaboos\nJace\nJacen\njack\njacked\nJackeline\nJackelyn\nJacker\nJacket\nJackhammer\nJackhammered\nJackhammering\nJacki\njackie\nJackie?\nJackie's\nJackin\njacking\nJack-It\nJackknife\nJacklyn\nJackman\nJackmon\nJackoff\nJack-off\nJackoffanator\njackpot\njacks\nJack's\nJackson\nJacky\nJaclene's\nJaclin\nJacline\nJaclyn\nJaclyn's\nJacme\njacob\nJacobs\njacquelin\nJacqueline\nJacques\njacusi\nJacusy\njacuzzi\nJacuzzitoy\nJacy\nJacyline\nJada\nJada’s\nJada-Da\nJada-Dee\nJadan\nJadas\nJada's\nJade\nJade’s\nJaded\nJadee's\nJaden\nJadenThe\nJAdePart\nJades\nJade's\nJadis\nJadora\nJadore\nJadorlabit\nJadyn\nJae\nJae’s\nJaeline\nJaelyn\nJae's\nJager\nJagger\nJagger's\nJag-Her\nJaguar\nJahna\nJahoobie\njahoobies\nJai\nJaiden\nJaie\nJaiere\njail\nJailbait\nJailbird\nJailbirds\njailbreak\nJailbreaks\njailhouse\nJailing\nJailor\nJaime\nJaimes\nJaimie\njaimy\nJaine\nJaite\nJake\nJakeline\nJakie\nJakki\nJakob\njakuzzi\nJalace\nJalapeno\nJalif\nJalique\njam\nJama\nJamacia\nJamaica\nJamaican\nJamal\nJamás\nJambalaya\nJamboree\nJames\nJamesDay\nJameson\nJames's\nJamesSophistication\nJamey\njamie\nJamie's\nJamison\nJamma\njammed\nJammen\nJammer\nJAMMIES\njammin\nJammin’\njamming\njams\nJamsson\nJana\nJanae\nJanay\nJandi\nJane\nJane?\nJane’s\nJane1\nJanea\nJaneiro\nJanelle\nJanelle's\nJanes\nJane's\nJanessa\nJanessa's\nJanet\nJaneth\nJanet's\nJanett\nJanette\nJaneva\nJaney\nJanice\nJanie\nJanika\nJanilla\nJanine\njanitor\nJanitorial\nJanitor's\nJanna\nJanne's\nJannete\nJanny\nJansen\nJansen's\nJanson\nJanson's\nJantzen\nJantzen's\nJanuary\nJanuary's\nJany\nJapan\nJapanese\nJapanMarica\nJaquelin\nJaqueline\nJaquelyn\nJaquline\nJar\nJarako\nJared\nJarek\nJargon\nJark\nJarod\nJARS\nJarushka\nJas\nJasime\nJaslene\nJaslin\nJasline\nJasmeen\njasmin\nJasmina\nJasmine\nJasmines\nJasmine's\nJasmin's\nJasmyn\nJasmyne\nJASNA\njason\nJason's\nJasper\nJbootyica\nJbootyie\nJaszmina\nJaunt\nJaven\nJavier\nJaw\nJawbreakers\nJaw-dropping\nJAWS\nJax\nJaxin\nJaxon\nJaxton\nJaxx\nJaxxa\nJaxxx\njay\nJay’s\nJayce\nJaycee\nJaycie\nJayda\nJayda's\nJayde\nJayded\nJayden\nJaydence\nJayden's\nJaye\nJay-ed\nJayes\nJaye's\nJayla\nJaylee\nJaylee's\nJaylen\nJaylene\nJaylene's\nJaylie\nJaylin\nJaylyn\nJaylynn\nJayma\nJayme\nJaymes\nJaymus\nJayn\njayna\nJayna's\nJayne\nJayne's\nJayogen\nJay's\nJayson\nJayy\nJazella\nJazling's\nJazlyn\nJazmeen\njazmin\nJazmine\nJazmyn\nJazmyne\nJazmyn's\nJazy\njazz\nJazzi\nJazzmin\nJazzy\nJC\nJDx3\nJe\njealous\nJealous?\njealousy\nJean\nJeanette\nJeanie\nJeanie?\nJeanine\nJean-Luc\nJeannie\njeans\nJean's\nJeans2\nJeanshorts\nJeanskirt\nJeanskirt2\nJeanspanties\nJeanspanties2\nJeansstrip\nJeanstrip1\nJeb\nJecica\nJecika\nJed\nJedi\nJeep\nJeeves\nJeez\nJefa\nJefe\nJeff\nJeffrey\nJeggings\nJekyll\nJelena\nJelentes\nJelice\nJello\njelly\nJem\nJemini\nJemma\nJem's\nJen\nJena\nJenae\nJenaveve\nJenaveve's\nJenelle\nJenessa\nJenet\nJenevieve\nJenga\nJeni\nJenia\nJenifer\nJenla\nJenn\nJenna\nJennacide\nJennas\nJenna's\nJenne\nJenner\nJenner's\njenni\nJennie\nJennifer\nJennifer?\nJennifers\nJennifer's\nJenning\nJennings\nJenni's\njenny\nJennyfer\nJennyfer's\nJenny's\nJens0n\nJensen\nJensen's\nJenson\nJenson's\nJentina\nJeny\nJenya\nJenyBaby\nJeny's\nJeoparjohnson\nJeopardy\njer\nJera\nJeremiah\nJeremie\nJeremy\nJeremy's\nJericha\njerk\nJerkaholics\njerked\nJerker\njerkin\nJerkin'\njerking\njerkoff\njerks\nJerky\nJermaine\nJerri\nJerrika\nJerry\nJersey\nJerseys\njerzi\nJerzi's\nJerzy\nJesebella\nJesibelle\nJeska\nJess\nJessa\nJessa’s\nJessae\nJessa's\nJesse\nJesse’s\nJesse's\nJessi\njessica\njessica's\nJessicas\nJessica's\nJessicca\nJessie\nJessie?\nJessies\nJessie's\nJessika\nJessi's\nJessop\nJess's\nJessy\nJessyca\nJessye\nJessyka\nJessy's\nJester\nJester0-0\nJesting\nJesus\nJet\nJett\nJett's\nJeunes\nJeva\njewel\nJewel’s\nJeweldefinitely\nJewell\nJewelled\nJewells\njewelry\njewels\nJewel's\nJewels's\nJewelz\nJewing\nJewish\nJews\nJey\nJeycy\nJezabel\nJezabelle\nJezabel's\nJeze\nJezebel\nJezebelle\nJezel\nJezelle\nJezus\nJezzabel\nJezzabelle\nJezzicat\nJhenifer\nJhons\nJia\nJia's\nJibber\nJigging\njiggle\nJiggled\nJiggler\nJigglers\nJiggles\njigglin\nJiggling\nJiggly\njiggy\njigs\nJigsaws\nJill\nJill'\nJill’s\nJilled\nJillian\nJillian's\nJillin\nJills\nJill's\nJilly\njilted\nJim\nJimena\nJimmie\nJimmy\nJimmy's\nJina\njingle\nJingles\nJinx\nJiselle\njism\nJitka\nJitsu\nJitters\nJiu\nJive\nJiz\njizm\njizz\nJizzardry\nJizz-Cuzi\nJizzcuzzi\njizzed\nJizzelle\njizzes\nJizzing\nJizzle\nJizzm\nJizzperiment\nJizz-Queen\nJizzwold\njizzy\nJizzz\nJJ\nJJ-cup\nJL\nJmac\nJ-mac\nJMac\nJMac?\nJmac’s\nJMac's\nJme\nJo\nJoachim\nJoan\nJoana\nJoana's\nJoanie\nJoann\nJoanna\nJoanna's\nJoanne\nJoanne's\nJoaquin\njob\njob?\nJobber\njobs\nJobs?\nJocelyn\nJocelyne\nJocelynn\nJoceyln\njock\nJocked\nJockey\nJockeys\njocks\nJock's\nJockstrap\nJocky\nJoclyn\nJoclynn\nJodi\nJodidamente\nJodie\nJody\nJody's\nJoe\nJoel\nJoelean\nJoelle\nJoey\nJoey's\nJog\njogger\nJogger’s\nJoggin\njogging\njogging_make love_buddies\nJohana\nJohane\nJohanna\nJohanne\nJohansen\nJohanson\nJohanson's\nJohanssen\nJohanssen's\nJohansson\nJohn\nJohnni\nJohnnie\nJohnny\nJohnny?\nJohnnys\nJohnny's\nJohn's\njohnson\nJohnsons\nJohnson's\nJOI\nJoie\njoin\njoined\njoining\njoins\njoint\nJojo\nJojo's\njoke\nJoker\njokers\njoking\nJolatu\nJolean\nJolee\nJolene\nJoleyn\njoli\nJolie\nJolie's\nJoli's\nJolle\nJollee\nJollies\njolly\nJolly's\nJon\nJonah\nJonas\nJonathan\nJonelle\nJones\nJone's\nJonesen\nJoneses\nJonesing\nJones's\nJonez\nJonni\njonny\nJons\nJonz\nJooey\nJooging\nJoones\nJoons\nJorani\njordan\nJordana\nJordan-Live\nJordan's\nJorden\nJordi\njordi_gets_layton\nJordiElNinoPolla44541_316\nJordin\nJordin=Amazing\nJordin's\nJordi's\nJordon\nJordy\nJordyn\nJordynn\nJored\nJorge\nJori\nJorja\nJornad\nJo's\nJoseline\nJoseline's\nJoselyn\nJoseph\nJosephine\nJosephine's\nJosette\nJosh\nJosh's\nJoshua\nJosi\nJosiah\nJosie\nJosline\nJoslyn\nJoslyn's\nJoss\nJosta\nJosy\nJoue\nJouet\njour\nJOUR-\tCat\nJourdan\njournalist\njourney\nJoven\njoy\nJoya\nJoyance\nJoyce\nJoyeux\njoyful\nJoyous\nJoyride\nJoy-ride'\njoys\nJoy's\nJoystick\nJoysticker\njoy-sticks\nJoysticks\nJozephine\nJozy\nJP\nJr\nJ's\nJS\nJt\nJT's\nju\nJuan\nJuanita\nJuanitas\nJuan's\nJucy\nJudas\nJudd\nJude\njudge\njudgement\nJudi\nJudit\nJudith\nJuditta\nJudo\nJudy\nJudy's\njudystar\nJudyt's\nJuega\nJuegos\nJuelz\njug\nJugmake loveed\nJugmake loveer\njugg\njuggabum\nJUGGcuzzi\nJuggernaut\njuggmake loveed\nJuggMake loveer\nJuggmake loveers\nJuggle\njuggler\njugglin\nJuggling\njugg-ly\nJugg-of-War\njuggs\njuggs?\nJuggy\nJuggz\nJugosa\njugs\nJugtastic\njuice\nJuiced\nJuicer\nJuicers\njuices\nJuiciest\nJuicing\njuicy\njuicy-booty\nJuicyBut\nJuicybanana\nJuicyfinger\nJuicyfingers\nJuicyhole\nJuicypink\nJuicytoy\nJuicykitty\nJuicyvibe\nJuja\nJuju\nJukebox\nJukut\nJul\nJulea\nJulep\nJules\nJules's\nJuli\nJulia\nJulian\nJuliana\nJuliana's\nJulianna\nJulianna's\nJulia's\njulie\nJulieann\nJulies\nJulie's\nJuliet\nJulieta\nJuliets\nJuliet's\nJuliett\nJuliette\nJuliette's\nJulio\nJulissa\nJulius\nJuliya\nJulliete\nJulliett\njuly\nJuly's\nJulytis\nJulz\nJuman\nJumbo\njumbolicious\nJumbos\njump\nJump?\nJumped\nJumper\nJumpin\njumping\nJumprope\nJumpropetoy\njumps\nJumpstart\nJumpsuit\nJumptoy\njunction\nJune\nJune’s\nJune's\nJung\njungle\nJunglebanana\nJungleland\nJunglecat\nJungletoy\nJunior\nJuniper\njunk\njunkie\njunkies\nJunky\njunkyard\nJupiter\nJura\nJuramento\nJurbootyic\nJurek\nJureka\nJureka's\nJury\njus\njust\n'Just\nJUST\nJuste\njustice\nJustified\nJustify\nJustin\nJustina\nJustine\nJusts\nJusttalk\nJusy\nJuuuust\nJuviel\nJuy\nJW01\nJynn\nJynn's\nJynx\nJynx'd\nJynx's\nJynz\nk\nKabiria\nkabobs\nKace\nKacee\nKacey\nKacey's\nKaci\nKacie\nKacie's\nKaciesta\nKacy\nKacy's\nKade\nKaden\nKadence\nKaden's\nKade's\nKady\nKae\nKaedyn\nKaelon\nKaely\nKaelyn\nKage\nKagney\nKagney's\nKahill\nKahlen\nKahlista\nKahrlie\nKahuna\nkai\nKaia\nKaihatsu\nKail\nKaila\nKailani\nKailani's\nKailey\nKaily\nKain\nKaine\nKainoa\nKaira\nKai's\nKaisa\nKaisa's\nKaiserin's\nKaisey\nKaisha\nKait\nKaiti\nKaitlin\nKaitlyn\nKaitlynn\nKaitu\nKaity\nKaiya\nKajira\nKakes\nKakey\nKakku\nKal\nKala\nKalaban\nKalani\nKalea\nKaleah\nKaleb\nKalen\nKalena\nKalena's\nKaley\nKali\nKalian\nKaliana\nKalib\nKalifornia\nKalina\nKalina's\nKalis\nKali's\nKalisy\nKallie\nKalliny\nKalliny's\nKallisto\nKaltava\nKalvetti\nKaly\nKama\nKamasutra\nKameia\nKameya\nkami\nKamikatze\nKamikaze\nKamila\nKamilla\nKamille\nKamille's\nKamilly\nKami's\nKamitia\nKammia\nKammy\nKamryn\nKamys\nKan\nKanada\nKanalu\nKanda\nKandace\nKandall\nkandi\nKandice\nKandie\nKandi's\nKandi-sweet\nKandy\nKandy's\nKane\nKane-Final\nKane's\nKangaroo\nKangaroo's\nKani\nKanon's\nKansas\nKansen\nKanye's\nKao\nKaori\nKaos\nKapers\nKappa\nKapri\nKapris\nKapriznaya\nKara\nKarah\nKaramb\nKaramel\nKaraoke\nKaraoke?\nKaraoku\nKara's\nKaratai\nKarate\nKarea\nKarel\nKarela\nKaren\nKaren's\nKarera\nKarerra's\nKari\nKarie\nKarim\nKarin\nkarina\nKarina's\nKaring\nKarisa\nKarisma\nKarissa\nKarissa's\nKarl\nKarla\nKarla's\nKarlee\nKarlee's\nKarlie\nKarlies\nKarlie's\nkarly\nkarma\nKarma?\nKarma’s\nKarma's\nKarmen\nKarmen's\nKaroke\nKarol\nKarola\nKarolin\nKarolina\nKaroline\nKarolin's\nKaroll\nKaroly\nKarpri\nKarrera\nKarrina\nKarrlie\nKarrlie's\nKarro\nKarry\nKarson\nKarson's\nKart\nKartel\nKarter\nKarter's\nkarting\nKaryn\nkaryna\nKasandra\nKasanov\nKasanova\nKasanya\nKase\nKasey\nKaseys\nKasey's\nKash\nKasha\nKasia\nKaslo\nKbootyandra\nKbootyey\nkbootyey's\nKbootyi\nKbootyid\nKbootyidy\nKbootyidy's\nKbootyie\nKbootyin\nKbootyius\nKbootyondra\nKbootyondra's\nKbootyy\nKbootyyana\nKastle\nKastravec\nkat\nKata\nKatachi\nKatala\nKatala's\nKatalin\nKatalina\nKatalina's\nKataliza\nKatalyn\nKatalynix\nKatalynka\nKatana\nKatana's\nKatarina\nKatarina's\nKatarinka\nKatava\nKatchings\nKate\nKateikyoushi\nKatelyn\nKaterina\nKaterina's\nKates\nKate's\nKateXXXX\nKatey\nKath\nKathalina\nKatharina\nKatharine\nKathe\nKatherine\nKathi\nKathia\nKathia's\nKathleen\nKathryn\nKathrynn\nKathy\nKati\nKatia\nKatia's\nKatie\nKatie's\nKatija\nKatin\nKatinka\nKatiy\nKatja\nKatka\nKatkam\nKatlein\nKatlyn\nKato\nKatok\nKatra\nKatreena\nKatrin\nKatrina\nkatrina_pays_her_rent\nKatrina‘s\nKatrina’s\nKatrinas\nKatrina's\nKatrin's\nKats\nKat's\nKatseye\nKatseyes\nKatsumi\nKatsuni\nKatsuni-Melissa\nKatsuni's\nKatt\nKatt’s\nKattHow\nKattie\nKatti's\nKatt's\nKatty\nKatty's\nKatusha\nKaty\nKatya\nKatya's\nKaty's\nKatzerl\nkauai\nKavalli\nKavane\nKavelle\nKavelli\nKavika\nKawaii\nKawanni\nKawany\nkay\nKayak\nKaycee\nKayden\nKaydence\nKaydenized\nKayden's\nKaye\nkayla\nKaylah\nKaylani\nKaylani's\nKayla's\nKayle\nKaylee\nKaylee's\nKayleigh\nKayleigh's\nKayley\nKayli\nKaylie\nKaylyn\nKaylynn\nkayme\nKayne\nKay's\nKaytlin\nKayy\nKayy’s\nKay-Yay\nkaz\nKazakh\nKazakhstan-Russian\nKC\nK-Dawg\nke\nKea\nKeagan\nKeahola\nKean\nKeana\nKeane\nKeanna\nKeanni\nKeat\nkebab\nKecey\nKeeani\nKeefe\nKeegan\nKeegan's\nKeelings\nKeely\nKeely’s\nkeen\nKeena\nKeensahra\nkeep\nKeepaway\nKeep'em\nkeeper\nkeepers\nKeepin\nKeeping\nkeeps\nKeester\nKefrem\nKegels\nKegger\nKehlani\nKeifer\nKeiko\nKeilani\nKeilani's\nKeira\nKeiran\nKeiran's\nKeira's\nKeisha\nKeisha's\nkeister\nKeith\nKeiyra\nKeizy\nKeke\nKelana\nKelay\nKelayThat's\nKelemen\nKeli\nKeli's\nKeller\nKelley\nKelli\nKellie\nKellin\nKelli's\nkelly\nKelly’s\nKelly0-0\nKellyfind's\nKellyfire\nKellyi\nkelly's\nKellys\nKelly's\nkellywells\nKelsey\nKelsi\nKelsi’s\nKelsie\nKelsi's\nKelter\nKelvin\nKen\nKendal\nKendall\nKendall's\nKendra\nKendraLust3246_1006\nKendras\nKendra's\nKendrick\nKendyll\nKenmake lovey\nKeni\nKenig\nKenley\nKenna\nKennan\nKenna's\nKennedy\nKennel\nKennels\nKenner\nKenneth\nKenny\nKensey\nKensia\nKensley\nKent\nKenton\nKentucky\nKenya\nKenza\nKenzi\nKenzie\nKenzies\nKenzie's\nKenzi's\nKeoki\nkept\nKerara\nKerchief\nKeri\nKerio\nKerkove\nkerra\nKerry\nKerry's\nKert\nKerti\nKery\nKesha\nKesha's\nKesidy\nKessef\nKessie\nKessyling\nKestos\nKethalo\nKetty\nKety\nKety's\nKeutbooty\nKevin\nkey\nkey?\nKeyboard\nKeyce\nKeyes\nKeyla\nKeylar\nKeyless\nKeymore\nkeys\nKeys's\nKeyty\nKeyz\nKhadija\nKhadisha\nKhaide\nKhaleesi\nKhalifa\nKhalifa's\nKhalira\nKhan\nKhapri\nKharlie\nKharlies\nKhia\nKhloe\nKhloe’s\nKhloe's\nKhole\nKhyanna\nKi\nKia\nKiana\nKianna\nKianna's\nKiara\nKiara's\nKiarra\nKiaya\nkick\nKick-Booty\nKickboxer\nKickboxing\nkicked\nkickedThen\nKickin\nKicking\nkicking_off_the_new_year\nkicks\nkid\nkidding\nKidnap\nKidnapped\nKidnapping\nkid's\nKids\nkielbasa\nkielbasas\nKiera\nKieran\nKiera's\nKiere\nKierra\nKierstin\nKiev\nKik\nKika\nKiki\nKikis\nKiki's\nKilemian\nKiley\nKileys\nKilgore\nkill\nKilla\nKilled\nkiller\nKiller0-0\nKiller4-1\nkillers\nKillian\nkillin\nKilling\nKillroy\nKills\nKilted\nKilts\nKim\nKimber\nKimberee\nKimberlee\nKimberley\nKimberlli\nkimberly\nKimberly's\nKimber's\nKimbery\nKimbey\nKimbo\nKimeleane\nKimiwagu\nKimm\nKimmi\nKimmie\nkimmy\nKimmys\nKimmy's\nKimono\nKimora\nKimora's\nKims\nKim's\nKimura\nKimXXX\nkin\nKina\nKina's\nKincade\nKincade's\nKincaid\nkind\nkinda\nkindergarten\nKindling\nKindly\nKindness\nKindred\nkinds\nKindYasmin\nKinean\nKinely\nking\nKinga\nkingdom\nKings\nKing's\nKingsley\nKingston\nKingstone\nkink\nKinkaid\nkink-a-thon\nKinkcom\nKinked\nKinker\nkinkiest\nKin-Killer-Cade\nKinkiness\nKinkMan\nKinkMen\nkinks\nkinkster\nKinkster’s\nKinksters\nkinky\nKinkycat\nKinkySpa\nKinley\nKinna\nKinnasias\nKinnky\nKino\nKinski\nKinski's\nKinsley\nKinuski\nKinuski's\nKinzie\nKinzy\nKinzyjo\nKip\nKipp\nKipper\nKir\nKira\nKira’s\nKiras\nKira's\nKiray\nKirby\nKirei\nKirill\nKirin\nKirishima\nKiriztina\nKirk\nKirra\nKirschley\nKirschner\nKirshley\nKirsten\nKirstey\nKirsty\nkis\nKisa\nKisabon\nKisaku\nKiska\nKiskaNastja\nKismet\nkiss\nkiss?\nKissa\nKissable\nKissa's\nkissed\nkisser\nKissers\nKisser's\nkisses\nKissin'\nkissing\nKisspanties\nKissy\nkit\nKita\nKitana\nKitana's\nkitchen\nKitchen?\nKitchen1\nKitchen2\nKitchenbikini\nKitchenblack\nKitchenbubbles\nKitchenclit\nKitchenmember\nKitchencooch\nKitchencookie\nKitchencounter\nKitchencream\nKitchencutie\nKitchenbanana\nKitchenbanana2\nKitchendong\nKitchenfinger\nKitchenfingers\nKitchenfun\nKitchenheels\nKitchenkun\nKitchenlove\nKitchenoil\nKitchenorgasm\nKitchenpink\nKitchenplay\nKitchenplaytime\nKitchenpleasure\nKitchencat\nKitchenrub\nKitchen's\nKitchenspread\nKitchenstrip\nKitchenstrip1\nKitchenstrip2\nKitchentable\nKitchentalk\nKitchentalks\nKitchenteen\nKitchencans\nKitchentoy\nKitchentoys\nKitchenkitty\nKitchenvibe\nKitchenvibrator\nKitchenvid\nkite\nKites\nKitkat\nKitsen\nKitsuen\nkitten\nKitten0-0\nKitten0-1\nkittens\nKitti\nKittie\nKitties\nKittie's\nKittin\nKittina\nKittina's\nKittinish\nkitty\nKitty’s\nKittyblue\nKittyblue2\nkittycat\nKitty-Cat\nKitty-Kitty\nKittys\nKitty's\nKiu\nKiuchi\nKiwi\nKiya\nKiyanna\nki-yay\nKizzy\nK-JUGS\nKlaman\nKlambi\nKlamias\nKlara\nKlarafication\nKlarafied\nKlaras\nKlara's\nKlarisa\nKlarissa\nKlbooty\nKlbootyic\nKlbootyy\nKlaudia\nKlaudia's\nKlavdya\nKlay\nKlaymour\nKleavage\nKleen\nKleevage\nKlein\nKlein's\nKleio\nKleio's\nKlementine\nKlenot\nKlenot's\nKleopatra\nKleopatra's\nKleptomaniac\nKlien\nKlimax\nKline\nKlint\nKloe\nKloes\nKloey\nKlon-dyke\nKloten\nKlub\nKlutz\nKlyde\nKlynn\nkn\nKnead\nKneaded\nKneading\nknee\nKneehighs\nKnee-Highs\nkneel\nkneeled\nkneeling\nkneels\nknees\nKneesocks\nkneesSquirts\nknew\nknickers\nknife\nknight\nKnightley\nKnightly\nKnights\nKnight's\nKnit\nKnite\nKnitting\nKnives\nkno\nknob\nKnobbers\nKnobbing\nKnobbit\nKnobs\nknock\nKnockboot\nknocked\nKnocked-up\nKnocker\nknockerhood\nknockers\nKnockin\nKnockin'\nknocking\nKnock-knock\nknockout\nKnock-Out\nKnockouts\nknocks\nKnot\nknots\nKnotted\nKnotting\nKnotty\nknow\nKnow?\nknowing\nknowledge\nknown\nknows\nknow's\nKnows\nKnox\nKnox’s\nKnoxville\nKnoxx\nKnoxxx\nKnuckle\nKnuckling\nKO\nKoal\nKobain\nKobayakawa\nKobe\nKobi\nKoboldt\nKobolt\nKoby\nKoda\nKodi\nKodis\nKody\nKoga\nKohana\nKohl\nKoi\nKoika\nKoinesa\nKokcast\nKokie\nKokkine\nKoko\nKo-ko\nKokohontas\nKokone\nKokoro\nKoks\nKolby\nKole\nKolida\nKolt\nKombat\nKombatant\nKombat's\nKomet\nKomic\nKon\nKong\nKonga\nKonn\nKonno\nKonnor\nKony\nKooky\nKoos\nKora\nKoral\nKora's\nKord\nKorean\nKorena\nKorene\nKorene's\nKori\nKorina\nKorina's\nKoritsi\nKornelia\nKornelia?\nKorra\nKorra's\nKorrs\nKors\nKorss\nKort\nKorti\nKortney\nKortney's\nKortny\nKory\nKosame\nKosame’s\nKosher\nKoshka\nKoster\nKostia\nKot\nKota\nKOUCH\nKougar\nKoukej\nKourtney\nKova\nKovacs\nKovalick\nKova's\nKovi\nKox\nKox's\nKoxxx\nKpop\nKrampus\nKranky\nKravanna\nKraves\nKraving\nKrazy\nKream\nKreams\nKreatris\nkreme\nKressler\nKrey\nKrilus\nKringle\nKrios\nKris\nKriss\nKrissie\nKrissy\nKrissy's\nKrist\nkrista\nKristal\nKristall\nKristar\nKrista's\nKristen\nKristens\nKristen's\nKristi\nKristian\nKristie\nKristin\nkristina\nKristinas\nKristina's\nKristine\nKristof\nKristofer\nKristy\nKristyna\nKristy's\nKrisztian\nKrisztina\nKriztina\nKroff\nKross\nKrsti\nKrunch\nKrunk\nKrysta\nKrystal\nKrystal'\nKrysta's\nKrystina\nKrystol\nKrysty\nKrystyna\nKrytal\nKsara\nKsenia\nKsenija\nKseniya\nKsu\nKsucha\nKsuColt\nKsurina\nksusha\nKta\nKu\nKuche\nKuckmal\nKudanfer\nKuhnya\nKulani\nKum\nKumbaya\nKummings\nKums\nKum's\nKung\nKupcakes\nKurl\nKurt\nKurtis\nKush\nKush's\nKushy\nKwang\nKya\nKyaa\nKyaa's\nKyah\nKyanna\nKyara\nKyla\nKylan\nKyle\nKylea\nKylee\nKyleen\nKylee's\nKyleigh\nKyler\nKyler's\nKyle's\nKylie\nKylie’s\nKylie's\nKym\nKymber\nKymberlee\nKymora\nKyra\nKyras\nKyra's\nKyrashina\nKyrgyzstan\nKyrin\nKyro\nkyttie\nl\nL·O·L·A\nL0la\nla\nlà\nLaa\nlab\nLaBarbara\nLabeau\nLaBeauDay\nLaBeauFinal\nLabeau's\nlabel\nlabels\nlabia\nLab-ia\nLabial\nLabias\nLabor\nlaboratory\nLaborer\nLabour\nLabyrinths\nlace\nLaced\nLacerrific\nLace's\nLacesocks\nlacey\nLacey2\nLaceypanties\nLacey's\nLachelle\nLachelle-Shocked\nLachlan\nLaci\nlacie\nLacing\nLaci's\nLack\nLackey\nLacks\nLamember\nLacourt\nLacourt's\nLaCroft\nLacroix\nLaCroix's\nLacrosse\nLactate\nLactates\nlactating\nLactation\nLactose\nLacuna\nlacy\nLacyee\nLacy's\nlad\nLada\nLada's\nLadd\nladder\nLaddercat\nLaddie\nLadie\nladies\nLadies’\nLadiocha\nLadles\nLadonna\nLadora\nlads\nlady\nLady?\nLadyboy\nLadyboys\nLadyboy's\nladyfingers\nLadyhood\nladykiller\nLadyland?\nlady's\nLadys\nLady's\nLady-Wrecker\nLaela\nLaela's\nLaele\nLaeticia\nLaecania\nLafee\nLaFemmeDC\nLafferty\nLafouine\nlaFox\nLagina\nLagina's\nLago\nLagoon\nLagoona\nLagrimas\nLai\nlaid\nLaiema\nLaiken\nLaikesis\nLaila\nLailonni\nLaima\nLaimiga\nLain\nLaine\nLainey\nLair\nLaistner\nlait\nLaka\nLakai\nlake\nLakefront\nLakehurst's\nLakers\nLakeside\nLake-Side\nLaki\nLakko\nLakme\nlala\nLala’s\nLalovv\nLaly\nLam\nLamb\nLambchops\nLambo\nLame\nLament\nLamina\nLaMore\nl'amour\nLamour\nL'amour\nLamoure\nLamour's\nlamp\nLampada\nLampoons\nLamy\nLan\nLana\nLana’s\nLana's\nLance\nLancer\nLanchester's\nLancome\nland\nlanded\nLanders\nlanding\nlandlady\nlandlord\nLandlords\nLandlord's\nLandmark\nLandon\nlands\nLandscape\nlandscaper\nLandscaping\nlane\nLanes\nLane's\nLanette\nLanewood\nLaney\nLang\nLangdon\nLange\nLanger\nLange's\nLangford\nLang-ster\nlanguage\nLanguages\nlanguid\nLanguorous\nLani\nLanie\nLanik's\nLanina\nLank\nLanka\nLanky\nLanna\nLanne\nLannie\nLanny\nLansing\nLantern\nLanxxx\nLanz\nLanza\nLaoura\nlap\nLAPD\nLapdance\nLapdancer's\nLapiedra\nLapped\nLappers\nLappi\nlapping\nlaps\nLapse\nlaptop\nLaput\nLara\nLaraan\nLarah\nLara's\nL'Arc\nLarceny\nLareina\nLarem’s\nLaren\nlarge\nlargest\nLarimar\nLarin\nLa'Rin\nLarisa\nLarissa\nLarissa's\nLark\nLarker\nLarkin\nLarkin's\nLarkson\nLarn\nLarnock\nLarocco\nLaroche\nLARP\nLARPing\nLarra\nLars\nl'art\nLarue\nLaryne\nLarynt\nLaryssa\nlas\nLA's\nLaSage\nLascito\nLasciva\nLascivia\nLascivious\nLaser\nLash\nLashey\nLashiene\nlashing\nlashings\nLasirena\nLasirena69\nLaska\nlbooty\nLbootyies\nlast\nLast?\nLaster\nLastine\nLasting\nLast-Minute\nLasts\nLasvegas\nLaszlo\nLat\nLata\nLatch\nlate\nLateena\nLately\nLately?\nLateness\nLatenight\nlater\nlatest\nlatex\nLatex-Clad\nlatexed\nLatexlover\nlatexwear\nlatex-wearing\nLatexxx\nLather\nLathered\nLatherers\nLathering\nlathers\nLathery\nLathin\nLati\nLatimore\nlatin\nlatina\nLatina'\nLATINA\nLatina’s\nlatina-looking\nlatinas\nLatina's\nlatino\nLatinos\nLati's\nLacanude\nLato\nLatoya\nLatria\nLatrine\nlatte\nLatvia\nLatvian\nLaty\nLau\nlaude\nLaudely\nLauderdale\nLaugh\nlaughed\nLaughing\nLaughs\nlaunch\nLaunched\nlaunches\nLaunching\nLaundering\nLaundr-O-Buns\nlaundromat\nlaundry\nLaundryfun\nLaundrylove\nlaura\nLaura’s\nLaurah\nLauralai\nLauralyn\nLaura's\nLaure\nLaure’s\nLaureen\nLaurel\nLauren\nLauren’s\nLaurence\nLauren's\nLaurent\nLaurianne\nLaurie\nLaurita\nLauro\nLauro's\nLauryn\nLauryn's\nLauryn-TestedFirst\nLava\nLavaggio\nLavalamp\nLavanda\nLavany\nLavation\nLavatory\nLavay\nLave\nLaVeaux\nlavender\nLavendo\nLaVere\nLaveviews\nLavey\nLavey's\nLavico\nLavikan\nLavina\nLavinia\nLavish\nLavita\nlaw\nLaw’s\nLaw+\nLawanda\nLawanda's\nLawbreakers\nLawda\nLawful\nLawless\nlawn\nlawnchair\nlawn-chair\nLawnchair\nLawnfingers\nlawns\nLawnschair\nLawrence\nlaws\nLaw's\nLawson\nLawsuit\nlawyer\nlawyers\nLawyer's\nlay\nLayci\nLayden's\nlayed\nLayer\nLayers\nLayin\nlaying\nlayla\nLaylah\nlaylalei\nLaylani\nLayla's\nLayloni\nLayl's\nLayn\nLayna\nLayna's\nLayne\nLayo\nLayout\nlayover\nlays\nLay's\nLaysa\nLayton\nLazing\nLazure\nlazy\nlbs\nLC\nle\nlea\nlead\nLeada\nleadAin't\nleader\nleaders\nleading\nleads\nLeadych\nleaf\nLeafing\nleague\nleagues\nleah\nLeah’s\nLeahOne\nLeah's\nLeahThe\nleak\nLeak?\nLeaked\nLeaking\nleaky\nLeal\nLeal's\nLealtad\nlean\nLeana\nLeander\nLeandra\nLeane\nleanella\nLeaning\nLeann\nLeanna\nLeanna's\nLeanne\nLeanne´s\nLeanne’s\nLeanni\nleans\nLeap\nLeapord\nLear\nlearn\nLearned\nlearner\nlearners\nLearnin\nlearning\nlearns\nLearn's\nlearnt\nLea's\nlease\nLeash\nLeashed\nLeasing\nleast\nleather\nLeather-Clad\nLeatherfinger1\nLeatherfinger2\nLeathers\nLeaticia\nLeau\nL'EAU\nleav\nleave\nleave-it\nleaver's\nleaves\nleaving\nLeBeau\nLebelle\nLebelle's\nLebowski\nLección\nLecerf\nLeChance\nleche\nLecher\nLecherous\nLechery\nLechter\nLeclair\nLeclaire\nLeCroix\nlecture\nlectures\nLecturing\nLed\nLeda\nLeda's\nLediana\nlee\nLeea\nLeeane\nLeeanne\nLeeBOOTY\nLeeBreaking\nLee'd\nLee'ds\nLeeg\nleegy\nLeela\nLeeloo\nLeena\nLeenda\nLeene\nLeeNO\nLeenuh\nLee's\nLeeSensory\nLeeStrappado\nLeeWater\nLeeway\nLeeya\nLeeza\nLefleur\nLeflour\nleft\nLefty\nleg\nLegacy\nLegado\nlegal\nLegalicious\nLegalize\nLegally\nLegalPorno\nLegami\nlegend\nlegendary\nlegends\nlegged\nleggings\nleggy\nLegion\nLegit\nLeglani\nLeg-Lock\nLegman's\nlegs\nLegs;\nLegs-A-Poppin'\nlegseager\nLegsex\nLegwarmers\nLegwear\nLeha\nlei\nlei’d\nlei'd\nLeid\nLeiddi\nLeidy\nLeif\nLeigh\nLeigh-d\nLeighlani\nLeigh's\nLeight\nLeighton\nLeih\nLeihla\nLeila\nLeila’s\nLeilani\nLeilanis\nLeilani's\nLeila's\nLeili\nLeionni\nLei's\nleisure\nLeisurely\nLeisures\nLekaten\nLeksi\nLeksya\nLektion\nLela\nLelani\nLelaniExotic\nLelani's\nLelas\nLe-Le-Le-LELA\nLella\nLelya\nLemay\nLemisis\nLemme\nLemmore\nLemon\nlemonade\nLemonlime\nLemons\nLemos\nLena\nLena's\nlend\nLending\nlends\nLenee\nLenee'\nLenee's\nLenehan\nleng\nLength\nlengths\nlengthy\nLengua\nLenin\nLenina\nLenix\nLenka\nLenka's\nLenko\nLenko's\nLenna\nLennon\nLennon's\nLennox\nLenny\nLeNoir\nLenore\nLenox\nlens\nLenses\nLentamente\nLento\nLenvin\nLeny\nLeo\nLeon\nLeona\nLeona?\nLeona's\nLeone\nLeonella\nLeonelle\nLeones\nLeoni\nLeonie\nLeonora\nLeon's\nLeony\nleopard\nLeopardesses\nLeopardlove\nLeopardprint\nLeopard-Print\nLeopard's\nleotard\nleotards\nLepidoptera\nleprechaun\nLepti\nLera\nLerissa\nlerk\nLes\nLe's\nLesbaliens\nLesbehonest\nlesbi\nLesbia\nlesbian\n-Lesbian\nLESBIAN\nlesbian?\nLesbian’s\nLesbianal\nlesbianism\nlesbians\nlesbian's\nLesbians\nLesbian's\nLESBIANS\nlesbians?\nLesbians’\nLesbians1\nLesbians2\nLesbianX\nLesbiennes\nlesbifriends\nlesbo\nlesbos\nlesdom\nLesha\nLesia\nLesian\nLesley\nLesli\nLeslie\nLeslie's\nLe'Slut\nLesperansa\nLesperansa's\nless\n'Less\nLesser\nlesson\nlesson?\nLessonand\nLessonMake love\nlessons\nLesson's\nlesssons\nLester\nLestni\nLesya\nlet\nlet’s\nLethal\nLeticia\nLeticiya's\nLecania\nLetizia\nlets\nlet's\nLets\nLet's\nLetscook\nLetstalk\nletter\nLetters\nlettin\nletting\nLettino\nLetty\nLetyte\nLevanta\nLevantado\nLevay's\nlevel\nLeveling\nlevels\nLever\nleverage\nLeveraged\nLevi\nLevina\nLevine\nLevi's\nLew\nlewd\nLewdness\nLewinski\nLewis\nLeWood\nLeWood's\nLex\nLex’s\nLexa\nLexas\nLex'd\nLexecutioner\nLexi\nLexie\nLexie's\nLexii\nLexing\nLexing0-0\nLexington\nLexis\nLexi's\nLex's\nLexus\nLexx\nLexxi\nLexxis\nLexxi's\nLexxus\nLexxxi\nLexxxi's\nLexxxus\nLexy\nLexy's\nLey\nLeya\nLeya's\nLeyla\nLeyla's\nLeylou\nlez\nLez-booty\nLezB\nlez-babes\nLezbabes\nLezbian\nLezbo\nLezMelonies\nLezbos\nLezcoin\nLezcuties\nLezCutiescom\nlezdom\nLez-domme\nLezian\nLezis\nLezley\nLezlie\nlezze\nlezzie\nlezzies\nLezzy\nL-fox\nli\nLi’s\nLia\nLiah\nliaison\nliaisons\nLiam\nLiana\nLiandra\nLianna\nLianna's\nliar\nLiason\nLiatre\nLibation\nLibby\nLibellula\nLiberal\nLiberando\nLiberate\nliberated\nLIberating\nLiberation\nLiberations\nLiberian\nLibero\nLiberta\nLiberte\nLiberties\nlibertine\nLibertines\nLiberty\nLibidineux\nlibido\nlibidos\nLibidus\nLibit\nLibitis\nlibrarian\nlibrary\nLibre\nLibro\nlicalatinpuss\nLicealias\nlicence\nlicense\nLicensed\nlicentious\nLichelle\nLichelle's\nLicie\nLicious\nlick\nlickable\nLick-A-Melon\nLickALike\nlickalottapuss\nlick-a-thon\nLickathon\nlicked\nlicker\nLickerdale\nlickers\nLickfest\nLicki-Make lovea\nlickin\nLickin'\nlicking\nlickings\nLickity\nLickout\nlicks\nLicks?\nLicky\nLicno\nLicorice\nLicx\nLida\nLidi\nLidia\nLidian\nLidiane\nLido\nLidya\nlie\nLied\nLiefde\nLielani\nLiena\nLien's\nlies\nLieta\nLieutenant\nLiev\nlife\nLife?\nLife…\nlife-changing\nLifegaurd\nLifegaurd's\nlifeguard\nlifeguard's\nlifelong\nLife's\nLifesaving\nlifestyle\nlifestyler\nLifestyles\nlifetime\nlifetimes\nlift\nlifted\nLifter\nlifters\nLifting\nlifts\nLiga\nLigaments\nlight\nLightening\nlightens\nLighter\nLightfall\nLighthouse\nLighting\nLightning\nLightcat\nlights\nLight-skinned\nLightspeed\nlightweight\nLightweights\nLik\nLika\nlike\nLike?\nliked\nlikely\nLiken\nlikes\nlike's\nLikes\nLikey\nLiking\nlil\nLi'l\nLIL\nLil’\nLila\nLilac\nLilah\nLila's\nLili\nLilia\nLilian\nLiliana\nLiliane\nLilianne\nLilia's\nLilien\nLilies\nLili's\nLilit\nlilith\nLilith's\nLilla\nLilli\nLillian\nLilliane\nLilliane's\nLillianne\nLillies\nLillike\nLilly\nLillyiana\nLilly's\nLilo\nLiloo\nLilou\nLilu\nLilu's\nLilvibe\nlily\nLilya\nLilyan\nLilyana\nLilyanna\nLilyan's\nLilys\nLily's\nLima\nlimber\nLimbering\nLimbo\nlimbs\nlime\nLimelight\nLimena\nLimiania\nlimit\nLimitations\nLimited\nlimitFlogged\nLimitless\nlimits\nLimo\nLimoScene\nlimousine\nLimp\nLimpia\nLimpieza\nLimtis\nlin\nLina\nLinares\nLina's\nLincoln\nLincon\nLinda\nLinda's\nLindsay\nLindsey\nLindsey's\nLindy\nline\nLineax\nLined\nlinen\nliner\nlines\nLinet\nLinet's\nLinfa\nLing\nLingam\nLinger\nlingerie\nLingerie1\nLingerie2\nLingeriefun\nLingeries\nLingerie's\nLingering\nLingers\nLingImpaled\nLingo\nLing's\nLingSuch\nLinguist\nlinguists\nLinias\nLinikas\nLining\nLinings\nlink\nLinked\nLinking\nLinLin\nLinn\nLinn's\nLinoA\nLin's\nLINT\nLintuko\nLinx\nLinz\nLinzee\nLio\nlion\nLiona\nLiona's\nLioness\nLionn\nlions\nlion's\nLions\nLion's\nLionsmane\nlip\nLipoldino\nLipped\nLipps\nlips\nLipsmacking\nlipstick\nLiquer\nliquid\nLiquids\nLiquis\nLiquor\nLira\nLiriope\nLis\nLi's\nlisa\nLisa's\nLisboa\nLisel\nLisette\nLisey\nLisey's\nLispy\nLiss\nLissa\nlist\nLista\nlisten\nListener\nlistening\nListening?\nListens\nlister\nListing\nlit\nLita\nLitanies\nLita's\nLite\nLiteral\nliterally\nLiterary\nLiterate\nliterature\nlithe\nLithuania\nLithuanian\nLitigante\nLit's\nlitt\nLitte\nlitter\nLitterbug\nlittering\nlittle\nLittle?\nLittlecoochie\nLittlered\nlittles\nLittle's\nLittlest\nLittletoy1\nLittletoy2\nLitto\nLiu\nLiv\nlive\nLivecam\nlived\nLivefeed\nLive-In\nLively\nliverpool\nlives\nLivestream\nLivestreaming\nLivia\nLivin\nLivin'\nliving\nlivingroom\nLivingston\nLiv's\nLix\nLiya\nLiyera\nLiyla\nliz\nLiza\nLizamania\nLizard\nLizards\nLiza's\nLizaveta\nLizette\nLizi\nLizka\nLiz's\nLizy\nLizz\nLizzie\nLizzies\nLizz's\nLizzy\nll\nLlana\nLlegando\nLlegar\nlll\nLluvia\nLmonde\nlo\nload\nloaded\nLoading\nloads\nLoad-us\nloan\nLoand\nLoaned\nLoans\nLoarn\nLoaves\nLoba\nLobby\nLobbyist\nLobo\nLobotomy\nLobov\nLobov's\nLoca\nlocal\nLocals\nlocation\nLochai\nLochai's\nLock\nlockdown\nLocke\nlocked\nlocker\nLockerroom\nlockers\nLocke's\nLockett\nLockhart\nLocking\nLockjaw\nlocks\nlocksFinger\nLocksmith\nLockup\nLockwood\nloco\nlodge\nloft\nlofty\nlog\nLogan\nLogans\nLogan's\nLogero\nLogger\nLoggers\nLogic\nLogistics\nLogjammin\nLogun\nLohan\nLohany\nLoida\nloins\nLois\nLok\nLokita\nlola\nLola-Chanel\nLolana\nLola's\nlolipop\nlolita\nLolitka\nLolla\nLollapalooza\nLolli\nLollimember\nLolli-Lolli\nlollipop\nLolli-pop\nLollipopper\nlollipops\nLolli-Pops\nLolli's\nLolly\nLolly’s\nLollypop\nLolly's\nlolo\nLombard\nLomeli\nLomias\nLominar\nLona\nlondie\nlondon\nLondoner\nLondons\nLondon's\nLondons;\nLondres\nLondyn\nLone\nLoneliness\nlonely\nlonely_player\nLoner\nlonesome\nlong\nLong?\nLong-awaited\nLong-Distance\nLongdress\nlonger\nLongest\nlong-haired\nLonghaired\nLong-haired\nLonghorn\nLongin\nLonging\nLongings\nLong-lasting\nLongleg\nlong-legged\nLonglegs\nlongs\nLong's\nlongstocking\nLong-Stockings\nlong-term\nlongtime\nlong-time\nLongtime\nLong-time\nLong-Tongue\nLongwood\nLoni\nLonie's\nLoni's\nLonnie\nloo\nLood\nlook\nLook?\nlookalike\nLook-alike\nLook-a-like\nLook-Alike\nLookalikes\nLooke\nlooked\nLooker\nlooki\nlookin\nlooking\nLookout\nlooks\nLooky\nLoom\nLooming\nLooms\nLoona\nLooner\nLoony\nLooooong\nloop\nLoophole\nLoops\nloose\nloosen\nLoosened\nLoosening\nLoosens\nlooses\nLoosey\nloosing\nLoot\nLooters\nLopes\nLopez\nLopez's\nLoppes\nLoquita\nLor\nLora\nLoraine\nLora's\nLord\nLord’s\nLordly\nLords\nLord's\nLordship\nLordy\nLore\nLoree\nLoreen\nLorelei\nLorelei's\nLoreley\nLoren\nLorena\nLorenia\nLorenn\nLorenn's\nLoren's\nLorenzi\nLorenzini\nLorenzo\nLorenzza\nLore's\nLoretta\nLori\nLorien\nLorinian\nLorna\nLorraine\nLorraine's\nLorrane\nLory\nlos\nlose\nLose?\nLosens\nloser\nlosers\nLoser's\nloses\nlosing\nLoso\nloss\nlost\nlot\nLotharios\nlotion\nLotion2\nLotiondance\nLotioned\nLotionfinger\nLotionfingers\nLotionfun\nLotionhole\nLotionlove\nLotionmusic\nLotionnipples\nLotionplay\nLotionrabbit\nLotionrub\nLotionrubdown\nLotions\nLotionspread\nLotionstretch\nLotionstrip\nLotioncans\nLotiontoes\nLotiontoy\nLotiontoy2\nLotionvids\nlots\nlotsa\nLotsalotion\nLott\nlotta\nLotta's\nLottery\nLotti\nLottie\nLottis\nLotto\nLotty\nLotty's\nLotus\nLou\nloud\nLouder\nloudest\nloudly\nLoudmouth\nLoudmouths\nLouie\nLouis\nLouisa\nLouise\nLoulaLou\nLoulou\nLoungchair\nlounge\nLounger\nLoungers\nLoungin\nLoungin'\nlounging\nLourdes\nLoureen\nLouse\nLousy\nLouvel\nLova\nLovato\nlove\nlove?\nLove_Hate\nLove’s\nLove0-0vsThea\nLove1-0vsAlly\nLove2-0vsTara\nLove3-0vsKrystal\nLove4-0vsHolly\nLoveballs\nLovebirds\nloved\nLoveDice\nlovedoll\nLovedream\nLovee\nLovefest\nLovejoy\nLovelace\nLoveless\nLovelier\nlovelies\nLoveliness\nLovell\nLovelle\nLovelna\nLovelock\nLove-Love\nlovely\nlovely_day_for_an_orgy\nlovely_lucia\nLovelybanana\nLovelylace\nLovely's\nlovemaker\nlovemaking\nLoven\nLovena\nLovenia\nLovens\nLovenz\nLovePart\nlover\nLover'\nLover’s\nloverboy\nLoverMan\nlovers\nlover's\nLovers\nLover's\nloves\nLove's\nLOVES\nLoveseat\nLoveseatoy\nLoves'Em\nLoveSensi\nlovess\nLovesTo\nLovestruck\nlove-train\nLovette\nLovey\nLovey-Dovey\nLovia\nlovin\nlovin'\nLovin\nLovin'\nlovin’\nloving\nlovingly\nLovisa\nLovit\nLovita\nLovKox\nLovliness\nLovly\nLovska\nlow\nLowden\nLowdown\nLowe\nLower\nlowered\nlowering\nLowers\nLowest\nLowkey\nLowrider\nLox\nLoxx\nLoxxx\nLoy\nLoyal\nLoyalty\nLoylita\nLozaro\nLP\nLP's\nLtd\nlu\nLua\nLuana\nLuana's\nLuanna\nLuau\nLuba\nLubana\nLubber\nlube\nLubed\nLube-Dripping\nLube-Farting\nLube-O-Mania\nLube-Oozing\nLuber\nLuberc\nlubes\nLubetoy\nLubice's\nLubin'\nLubing\nlubing_the_tube\nLubov\nlubricated\nLubricating\nLubrication\nLubricous\nLuc\nluca\nLucas\nLuca's\nL'Uccello\nLucci\nluccia\nLucciono\nLuce\nLucent\nLucette\nLucette's\nLucha\nLuchik\nLuci\nLucia\nLuciana\nLucianna\nLucia's\nLucie\nLucies\nLucie's\nLucifer\nLucille\nlucious\nLucius\nluck\nLucke\nLucked\nluckiest\nLuckily\nlucks\nlucky\nLucky?\nLucky's\nLuckystar\nLucle\nLucretia\nLucy\nLucyka\nLucy's\nLuda\nLudmila\nLudwiga\nLudy\nLug\nLuggage\nLui\nLuigina\nLuis\nLuisa\nLuisa's\nLuissa\nLuiza\nLuka\nLukas\nLuke\nLukics\nLula\nLullaby\nLullu\nLullu's\nL'Ultimo\nLulu\nLului\nLulu's\nLuma\nlumb\nLumber\nLumberjack\nLumberjack's\nLumberjill\nlumberman\nLumbersexual\nLumbersexuals\nLumia\nLumina\nLuminated\nLuminescense\nLuminosias\nLuminus\nlump\nLumps\nluna\nLuna’s\nLunaaaaaaaaaaaaa\nLuna-Day\nLunar\nLunas\nLuna's\nLuna-tic\nlunch\nLunchbreak\nLuncheon\nLunchtime\nLuncinka\nLunesa\nLunettes\nLungo\nLupe\nLupin\nLupita\nlure\nlured\nlures\nLure's\nLuring\nLurker\nLurks\nLus\nLuschious\nLuscios\nluscious\nlusciousness\nLuscivious\nLusconi\nLusful\nlush\nLushes\nLushious\nLusi\nLusie\nLusil\nLusila\nLussi\nLussi's\nLussy\nlust\nLust'\nLust?\nLust-Crazed\nlustful\nlustfully\nLustin\nLusting\nLustre\nLustrous\nlusts\nLust's\nlusty\nlusty_in_leggings\nlusty_landlord\nLusy\nLusya\nLusy's\nLuther\nLutro\nLutro's\nLuukmee\nLuv\nLuvana\nLuvena\nLuvgood\nLuvin\nLuvly\nLuvrz\nLuvs\nLuv's\nluvv\nLuvv's\nlux\nLux;\nLuxa\nLuxe\nLuxea\nLuxia\nLuxian\nLuxify\nLux's\nLuxurie\nluxurious\nluxury\nLuxx\nLuxxx\nLuxy\nLuysan\nLuz\nLuzbel\nlV\nLW\nLy\nLya\nLyall\nLyana\nLyanna\nLya's\nLyava\nLyderi\nLydia\nLydie\nLyen\nLyft\nlying\nLyla\nLyla's\nLyle\nLylia\nLylith\nLylith's\nLylla\nLylyta\nLyn\nLyna\nLyndon\nLyndsay\nLyndsey\nLyndsy\nLyne\nlynn\nLynn?\nLynna\nLynnBobbi\nLynnDay\nLynn-Day\nLynne\nLynne's\nLynn's\nLynx\nLynxxx\nLyon\nLyons\nLyra\nLyra's\nlyrics\nLys\nLysa\nLyssa\nLystra\nLyudmilla\nm\nma\nm-a\nMa\nMa’am\nMA001\nMA002\nMA003\nMA004\nMA005\nMA006\nMA007\nMA008\nMA009\nMA010\nMA011\nMA012\nMA013\nMA014\nMA015\nMA016\nMA017\nMA018\nMA019\nMA020\nMA021\nMA022\nMA023\nMA024\nMA025\nMA026\nMA027\nMA028\nMA029\nMA030\nMA031\nMA032\nMA033\nMA034\nMA035\nMA036\nMA037\nMA038\nMA039\nMA040\nMA041\nMA042\nMA043\nMA044\nMA045\nMA046\nMA047\nMA048\nMA049\nMA050\nMA051\nMA052\nMA053\nMA054\nMA055\nMA056\nMA057\nMA058\nMA059\nMA060\nMA061\nMA062\nMA063\nMA064\nMA065\nMA066\nMA067\nMA068\nMA069\nMA070\nMA071\nMA072\nMA073\nMA074\nMA075\nMA076\nMA077\nMA078\nMA079\nMA080\nMA081\nMA082\nMA083\nMA084\nMA085\nMA086\nMA087\nMA088\nMA089\nMA090\nMA091\nMA092\nMA093\nMA094\nMA095\nMA096\nMA097\nMA098\nMA099\nMA100\nMA101\nMA102\nMA103\nMA104\nMA105\nMA106\nMA107\nMA108\nMA109\nMA110\nMA111\nMA112\nMA113\nMA114\nMA115\nMA116\nMA117\nMA118\nMaako\nMaam\nMa'am\nma'am?\nMaara\nMaax\nMabel\nMabelle\nMabel's\nMac\nMacarena\nMacarena's\nMacaroni\nMacMeloner\nMacc\nmaccy\nMace\nMacey\nMach\nMachado\nMachella\nMachina\nmachine\nMachine?\nMachine0-0\nMachine0-1\nmachined\nMachine-Make loveed\nMachine-Make loveing\nMachinehead\nmachines\nMachines-Isis\nmachineSkynet\nmachining\nMachinist\nmacho\nMaci\nMack\nMackenzee\nmackenzie\nMackenzie's\nMacking\nMack's\nMaclane\nMac's\nMacy\nmad\nMadador\nMadalena\nMadam\nMadame\nMadame's\nMaddey\nMaddi\nMaddie\nMaddness\nMaddox\nMaddox's\nMaddron\nMaddux\nMaddy\nMaddy's\nmade\nMadeInCanarias\nMadeleine\nMadeline\nMadeline's\nMadelyn\nMadelyne\nMadelyn's\nMademoiselle\nMadeo\nMadge\nmadhouse\nMadina\nMadisin\nMadison\nMadisons\nMadison's\nMadlen\nMadlena\nMadleyn\nMadlin\nmadly\nMadMan\nMadmen\nmadness\nMadonna\nMadori\nMadrastra\nMadrastras\nMadri\nMadrid\nMadysinn\nMae\nMaeketa\nMaelynn\nMae's\nMaestra\nmaestro\nMaeva\nMafalda\nmafia\nmag\nMagalie\nMagarette\nmagazine\nMagazines\nMagda\nMagdalena\nMagdalene\nMagdi\nMagdolna\nMagdolna's\nMageda\nMagella\nMagenta\nMaggie\nMaggies\nMaggie's\nMaggio\nmagic\nmagic?\nMagica\nmagical\nMagically\nmagician\nMagicians\nMagician's\nMagick\nMagicmonkey\nMagico\nMagicstick33\nMagictoys\nMagicwand\nMagicwandfun\nMagicwandoil\nMagicwandplay\nMagicwandrub\nMagicwandsquirt\nMagie\nMagixxx\nMagling\nMagma\nMagna\nMagne\nmagnet\nmagnetic\nmagnetism\nMagneto\nMagnificence\nmagnificent\nMagnificently\nMagnifique\nMagnitude\nMagnolia\nMagnum\nMagnusson\nMagnusson's\nMagrinha\nMaguire\nMaguire's\nMagumbos\nMagy\nMagy's\nMahaghani\nMahmeloneh\nMahem\nMahina\nMahlia\nMahogany\nMahoro\nMahoro's\nMai\nMaia\nMaia's\nmaid\nMaid?\nMaid’s\nMAIDD\nMaiden\nMaidens\nmaids\nmaid's\nMaids\nMaid's\nMaiestas\nMaika\nMaikana\nMaiko\nmail\nMaila\nMailbox\nMailboy\nMailed\nMailing\nmailman\nMail-order\nMailroom\nmain\nMaine\nmainstream\nMaint\nMaintaining\nMaintains\nmaintenance\nMaira\nMais\nMai's\nMaisie\nMaison\nMaitland\nMaitresse\nMaja\nmajestic\nMajesty\nMajesty's\nmajical\nmajor\nMajorette\nMak\nMakali\nmakayla\nMakayla’s\nmakayla's\nMakbota\nmake\nmake_you_a_man\nMake'em\nMakenna\nMakenzee\nMakeout\nmakeover\nMake-Over\nmaker\nmakers\nmakes\nMakeshift\nmakeup\nmake-up\nMakeup\nMake-up\nMakeups\nMaki\nMakin\nmaking\nMaki's\nMaklaryn\nMakoto\nMaktia\nMal\nMalai\nMalao\nMalati\nMalaya\nMalaysia\nMalcolm\nMalcontent\nMaldives\nMaldonado\nmale\nMale-Dom\nMalena\nMalena's\nmale-pet\nmales\nMaletero\nmaleXslave\nmalfunction\nMalgranda\nMali\nMalia\nMaliakan\nMalia's\nMalibu\nMalica\nMalice\nMalicia\nMaliera\nMaligned\nMalina\nMalitia\nMalkova\nMalkova’s\nMalkova's\nmall\nMall?\nmallet\nMallorca\nMallory\nMallorys\nMalloy\nmallrat\nMallrats\nMALO\nMalone\nMaloo\nMaloo's\nMalorie\nMalory\nMalpractice\nMaltease\nMalted\nMaltese\nMalvina\nMalvo\nMalwina\nMalya\nMalya's\nMam\nmama\nMamá\nMama’s\nMamacita\nMamada\nMamador\nMamalicious\nmamalona\nMamas\nMama's\nMamás\nMamazon\nMamazons\nMamba\nMambo\nMame\nMamecu\nMami\nMamie\nmamma\nMammalian\nmammaries\nmammary\nMamma's\nMammograb\nMammoth\nMamories\nmams\nmam's\nMams\nMam-tastic\nman\nman-\nMan\nman?\nMan’s\nmanaged\nManagement\nmanager\nManager’s\nManager's\nmanages\nManaging\nManana\nManarote\nManauel's\nMan'c\nMancini\nMan-Cream\nMancy\nMandatory\nMandee\nMandi\nMandingo\nM-A-N-D-I-N-G-O\nMandingo’s\nMandingo's\nMandorla\nMandrinare\nMandroid\nmandy\nMandys\nMandy's\nMane\nman-eater\nManeater\nMan-eater\nManEater\nMan-Eater\nManeaters\nMan-eating\nManelli\nManeuvers\nManga\nMangiatti\nMangler\nMangles\nMango\nmanhandle\nmanhandled\nManhandler\nManhandles\nmanhandling\nManhattan\nmanhood\nMan-Hungry\nman-hunt'\nManhunters\nmania\nmaniac\nManiacs\nManiturkey\nManic\nManicure\nManicured\nManifestation\nManifesting\nmanifests\nManificence\nManilla\nManipulation\nManipulative\nManipulator\nManjuice\nMankind\nmanly\nman-meat\nManmeat\nMan-Milk\nMann\nMannequin\nMannequins\nmanner\nmanners\nManners?\nmanning\nManny\nMano\nManoew\nManon\nManor\nManow\nmans\nman's\nMans\nMan's\nManscaper\nmanservant\nmanservant's\nmansion\nMan's-Man's\nManson\nManson's\nman-stud\nMansur\nMantequilla\nMANTIS\nMantlepiece\nManu\nManual\nManually\nManuel\nManuel’s\nManuela\nManuels\nManuel's\nManuels’s\nmany\nMANYANA\nManzanillo\nmap\nMapa\nMaple\nMaple's\nM'Appelle\nMaps\nMar\nMara\nMaraca\nMaracas\nmarathon\nMarble\nMarbles\nMarc\nMarceau\nMarcel\nMarcela\nMarceline's\nMarcelinha\nMarcella\nMarcellina\nMarcellinha\nMarcelly\nMarcelo\nmarch\nMarchDirty\nMarche\nMarchelli\nMarchelly\nMarching\nMarch's\nMarchThe\nMarci\nMarcia\nMarco\nMarcona\nMarcus\nMarcy\nMardi\nMare\nMarea\nMaree\nMareen\nMarelica\nMaren\nMarf\nMarga\nMargaret\nMargareta\nMargareth\nMargarethe\nMargareth's\nMargarita\nMargarita's\nMarge\nMargery\nMargherita\nMargitta\nMargo\nMargo's\nMargot\nMarguerita\nMarguetta\nMarhyan\nMari\nMaria\nmariachi\nMariah\nMariah's\nMariam\nMarian\nMariana\nMariann\nMarianna\nMarianne\nMaria's\nMarica\nMarica's\nMaridos\nmarie\nMarie’s\nMariemake loveed\nMariel\nMarie-laure\nMarielou\nMarie's\nMarija\nMarijana\nMarije\nMarika\nMarille\nMarilyn\nMarilyn’s\nMarilyn's\nMarin\nMarina\nMarinan\nMarinas\nMarina's\nmarinate\nMarine\nMarine?\nMarines\nMarinho\nMarinian\nMarinista\nMario\nMarionette\nMarionettes\nMarisa\nMarisa's\nMarisha\nMariska\nMarisol\nMarisole\nMarison\nMarissa\nMarissa's\nmarital\nMaritrini\nMaritza\nMariya\nMariyu\nMarizza\nMarjay\nmark\nmarked\nMarker\nMarkered\nMarkers\nmarket\nMarketa\nmarketing\nMarketplace\nMarking\nMarkings\nMarkova\nmarks\nMarkup\nMarkus\nMarky\nMarleigh\nMarlena\nMarlene\nMarley\nMarley's\nMarli\nMarlie\nMarlijane\nMarlowe\nMarlyn\nMarmaro\nMarmorino\nMarq?\nMarques\nMarquesine\nMarquetta\nMarquis\nMarquise\nMarquita\nMarquize\nMarra\nmarriage\n'Marriage\nMARRIAGE\nmarried\nMarried?\nMarrlenas\nMarrum\nmarry\nMarrying\nMars\nMarseille\nMarsela\nMarselina\nMarsha\nMarshal\nMarshall\nMarsha's\nMarshmallow\nMarshmallowed\nMarshmallows\nMarsov\nMart\nMarta\nMarta's\nMartell\nMarten\nMartha\nMartha's\nMarti\nmartial\nMartin\nmartina\nMartina's\nMartine\nMartinez\nMartini\nMartinis\nMartins\nMartix\nMarton\nMartoonis\nMarty\nMartyna\nMartyr\nMarushka\nMarusia\nMarusya\nmarvel\nMarvellous\nmarvelous\nmarvels\nMarvin\nMarx\nMarxism\nMarxxx\nMary\nMarya\nMaryann\nMary-Ann\nMaryel\nMaryja\nMaryjane\nMaryjanes\nMaryJean\nMary-Kate\nMarylin\nMaryline\nMaryln\nMary's\nMarysole\nMas\nMasacre\nMasaje\nMasajeando\nMasajista\nMasakowa\nmascara\nMascot\nMasculin\nMasculinity-Training\nMaserati\nMaserati's\nMaseratti\nmash\nMasha\nMashaOlja\nMashed\nMashes\nMashing\nMashroom\nmask\nmasked\nMasking\nmasks\nmask's\nMasks\nMasochist\nmasochistic\nmason\nMasonFormer\nMason's\nMasonThe\nMasonu\nmasquerade\nMasquerading\nMasqurade\nmbooty\nMbootyacre\nMbootyacred\nmbootyag\nmbootyage\nMbootyage'\nmbootyage?\nmbootyage’s\nMbootyageCreepcom\nmbootyaged\nMbootyager\nmbootyagers\nmbootyages\nmbootyaging\nMbootyagynist\nmbootyes\nmbootyeur\nMbootyeur?\nmbootyeurs\nmbootyeur's\nMbootyeurs\nMbootyeur's\nmbootyeuse\nmbootyeuse?\nMbootyeuses\nMbootyeuse's\nMbootyey\nMbootying\nmbootyive\nMbootyivebanana\nmbootyively\nMBOOTYIVEWelcome\nMbootymerized\nMbootyo-Lick-My-Cat\nmbootysive\nMbootyterpiece\nmbootyuese\nMbootyumptions\nMast\nmaster\nmaster’s\nmaster…then\nMasterbater\nMasterbates\nMasterbating\nmasterbation\nMasterclbooty\nmasterful\nMastering\nmasterpiece\nmasters\nmaster's\nMasters\nMaster's\nMasterson\nMasterson's\nMasterstroke\nmasterworks\nMastery\nMasticator\nmastrpiece\nmastur\nmasturb\nmasturba\nMastur-Baiting\nMasturbatacular\nmasturbate\nMasturbate?\nmasturbated\nMasturbater\nmasturbates\nMasturbati\nmasturbating\nMasturbating?\nmasturbation\nmasturbations\nmasturbation's\nMasturbations\nMasturbation's\nmasturbator\nMasturbator1\nMasturbator2\nMasturbatory\nmasturbatum\nMasturpiece\nMasuimi\nMasurbating\nmat\nmat?\nmat100%\nmatador\nMatarazzo\nMatawhore\nmatch\nmatch?\nMATCHAriel\nMatched\nmatches\nmatching\nmatchLosers\nmatchmaker\nMatch-Making\nmatchThe\nmatchup\nMatch-up\nMATCHUP\nMatchYou\nmate\nMatene\nMatenon\nMateo\nmaterial\nMaternal\nmates\nmatey\nmatFinger\nmath\nMathea\nMathematics\nMathers\nMathews\nMathilda\nMathilde\nMathletes\nmaths\nMatic\nMatiki\nMatilda\nMatilda's\nMatin\nMatinee\nMating\nmatNon-Scripted\nMatriarch\nMatri-Moly\nMatrimonial\nMatrimonio\nMatrimony\nMatrix\nmats\nmatStill\nMatt\nmatter\nmatters\nMatthew\nMatthews\nMattie\nmattress\nmaturates\nmature\nMature-lover\nmatures\nMaturity\nMatylda\nMaude\nMaui\nmauling\nmauls\nMaureen\nMaurice\nMaurina\nMaus\nmaven\nMavens\nMaver\nMaverick\nmax\nMaxe\nMaxed\nMaxi\nMaxim\nMaxima\nMaximas\nMaxim's\nmaximum\nMaximus\nMaxine\nmaxNasty\nMaxwell\nMaxx\nMaxximum\nMaxxx\nMaxxxed\nmay\nmaya\nMaya’s\nMayan\nMay-Anne\nMayans\nMayara\nMaya's\nmaybe\nMaybeYes\nMayday\nMayde\nMaye\nMayeDay\nMayer\nMayers\nMayes\nMayfair\nMaygers\nmayhem\nMay-Hem\nMayhemBooty\nMayhem's\nMayhemThe\nMayla\nMaylene\nMayna\nMayne\nMayo\nMayor\nMayors\nMays\nMay's\nMayshag\nMayson\nMayweather\nMaywood\nMaza\nMaze\nMaze's\nMazesterbation\nMazol\nMazsa\nMazy\nMazz\nMAZZA\nMazzaratie\nMazzy\nMazzy's\nM-Bargo\nMc\nmc_mulanirivera\nMC2=Booty\nMcAdams\nMcCarthy\nMcCarthyStruggles\nMcClain\nMcCray\nMcCray's\nMcCree\nMcDaniels\nMcgee\nmcjemeni\nMcKayla\nMcKenna\nMckenzee\nMcKenzi\nMckenzie\nMcKenzie’s\nMckenzie's\nMcKinnon\nMcLain\nMcLane\nMcLaren\nMcPipe\nMccat\nMcQueen\nMcRae\nMcSlutty\nM-Cup\nMD\nMdalexandria\nme\nMe'\nME\nMe\tScene\nme?\nMea\nmeadow\nMeadowlark\nMeadows\nMeagan\nmeal\nMeals\nMealtime\nmean\nmean?\nMeaner\nMeanest\nMeania\nmeaning\nMeaningful\nmeans\nmeant\nMeanwhile\nmeasure\nMeasured\nmeasurements\nmeasures\nMeasuring\nmeat\nmeat?\nMeatball\nMeatballas\nMeatballs?\nMeatboy\nmeateater\nmeated\nMeater\nmeat'ing\nMeating\n'Meating\n'Meat'ing\nMeatloaf\nmeats\nMeat's\nmeatsDay\nmeatsPrincess\nMeatstick\nMeatsword\nmeaty\nMeatYoung\nMecha\nMecha-Meat\nmechanic\nmechanical\nMechanically\nmechanics\nmechanic's\nMechanics\nMechanic's\nMechanique\nMechanique-Day\nmed\nmedal\nMeddie\nMeddie's\nMeddison\nMeddler\nMeddling\nMedea\nMedellin\nmedia\nMedias\nMediating\nMediation\nMediator\nMedic\nmedical\nmedically\nMedicare\nMedication\nmedicinal\nmedicine\nmedicinefootjob\nMedics\nmedieval\nMedina\nMedinad\nMedison\nMeditate\nMeditating\nmeditation\nMeditative\nMedite\nMediterranean\nmedium\nMedley\nmeds\nMedusa\nMedvedenko\nMeen\nMeenie\nmeet\nmeeting\nMeetings\nmeets\nmeetup\nMeg\nmega\nMega-Meloned\nMega-Melons\nMega-busty\nMegaFarts\nMegan\nMegane\nmegan's\nMegans\nMegan's\nMega-Stacked\nMega-Toy\nMegerinte\nMeggie\nMeggy\nMeghan\nMegia\nMegie\nMegisto\nMegnis\nMegu\nMegumi\nMegu's\nMegyn\nMehise\nMei\nMeI'm\nmeister\nmejor\nMekeilah\nMekina\nMekins\nMekki\nMeko\nMel\nMela\nMelainny\nMelana\nMelancholia\nMelancholy\nMelane\nMelania\nMelanie\nMelanieeeee\nMelanies\nMelanie's\nMelany\nMelba\nmelee\nMelika\nMelina\nMelinda\nMeliority\nMelisa\nMelissa\nmelissa_loves_eating_out\nMelissa's\nMelita\nMelitia\nMeliza\nMell\nMella\nMellaine\nMellanie\nMellanies\nMelle\nMellie\nMellikis\nMellisa\nMello\nMellons\nMellorar\nMellow\nMellowed\nMelly\nMelo\nMelodee\nMelodie\nmelodies\nMelodiosi\nMelodrama\nmelody\nMelody's\nmelon\nmelonas\nMelone\nMelone's\nMelonie\nmelons\nMelon's\nMelony\nMelory\nMelrose\nMel's\nmelt\nMeltdown\nmelted\nMelter\nMelting\nmelts\nMelyna\nMelyna's\nmembe\nmember\nmembers\nMember's\nMEMBERS\nMEMBER'S\nmembership\nMemberships\nMembrane\nMeme\nMemento\nMementos\nMemoir\nMemoirs\nmemorable\nMEMORIA\nMemorial\nmemories\nMemorized\nmemory\nMemphis\nMemphis's\nmen\nMena\nMenacing\nmenage\nMénage\nMenage-A-Trois\nMenagerie\nMenaje\nMenatel\nMendelson\nMendes\nMendez\nMendini\nMendiny\nMendosa\nMendoza\nMendoza's\nMeneth\nMenezes\nMenInPaincom\nMen-itation\nMenkui\nMen-on-Edgers\nMenow\nmens\nmen's\nMens\nMen's\nmenstrual\nmental\nMentality\nmentally\nmention\nMentiras\nMentoni\nmentor\nMentors\nMentorship\nmenu\nmeor\nmeow\nMeows\nMeOWW\nmepositions\nMer\nMercana\nMercedes\nMercedesz\nMercedez\nMercer\nmerchandise\nMerci\nMerciful\nMerciless\nmercilessly\nMercurial\nMercurio\nMercury\nMercutio\nmercy\nmere\nMeren\nMerengue\nMeretrix\nmerger\nMerging\nMeri\nMeriah\nMerica's\nMerida\nMeridian\nMeriesa\nMerikas\nMerilee\nMerilyn\nMerino\nMerissa\nMerit\nMeritocracy\nmerits\nMerlin\nMerlina\nmermaid\nmermaids\nMerri\nMerrick\nMerrie\nmerrier\nMerriest\nMerriment\nMerrry\nmerry\nMerryman\nMerszedes\nMery\nMeryl\nMes\nMe's\nMesa\nMesade\nMesera\nmesh\nMeshed\nMeshing\nMesica\nMesmerised\nMesmerize\nMesmerized\nmesmerizer\nMesmerizing\nmesmorized\nMesok\nmess\nmessage\nMessages\nmessall\nmessed\nMessege\nmessenger\nMessenger's\nMesses\nMessiah\nMessie\nMessing\nmessy\nmet\nMeta\nmetal\nmetalBrutally\nmetalhelpless\nMetallic\nMetallica\nMetallurgy\nMetamorphosis\nMetart\nmetemela\nMeter\nmeters\nmethod\nmethods\nMetro\nMeuri\nMewg\nMex\nMexi\nMexican\nMexicana\nMexicans?\nMexico\nMey\nMeyers\nMey's\nMezuri\nMFF\nMgr\nmi\nmia\nMia'\nMIA\nMia Sucks\nMia’s\nMiah\nmiami\nMiamiBeach\nMiami's\nMias\nMia's\nmic\nMicah\nMicah's\nMicara\nMicha\nMichael\nMichaela\nMichaella\nMichaels\nMichael's\nMichaelsFormer\nMichaelswith\nMichah's\nMicha's\nMicheal\nMicheals\nMichel\nMichele\nMichell\nmichelle\nMichelle-girl\nMichelles\nMichelle's\nMichelly\nMichigan\nMichova\nMichova's\nMick\nMicka\nMickaella\nMickey\nMickey's\nMick's\nMicky\nMicky's\nMicro\nMicrophone\nMid\nMida\nMid-Afternoon\nmid-air\nMidame\nMidas\nmidday\nMiddl\nmiddle\nmiddle-aged\nMiddleman\nMidget\nMidian\nMid-Life\nmidnight\nMidnightTs\nMidori\nmidst\nMidsummer\nMidsummer's\nMidterm\nMid-Terms\nMidtown\nMidWest\nMidwestern\nMidwife\nMidwinter\nMid-Workout\nMiela\nmight\nMightily\nmighty\nMightyMistresscom\nmigraine\nMigrant\nMiguel\nMiHaDoan\nMihane\nMihaylik\nMihee\nMiho\nMiho's\nMiina's\nMika\nMikado\nMikaela\nMikaelle\nMikako's\nMikami\nmikayla\nMikayla's\nMike\nMikela\nMike's\nMikey\nMikeys\nMikey's\nMikhail\nMiki\nMikita\nMikka\nMikkey\nMikki\nMiko\nMikoah\nMikos\nMiko's\nMiku\nMiku's\nMiky\nMiky's\nMila\nMila’s\nMilada\nMiladies\nMilady\nMilah\nMilan\nMilana\nMilani\nMilano\nMilano's\nMilas\nMila's\nMilcah\nMild\nMild?\nMild's\nMile\nmile-high\nMilena\nMilena's\nmiles\nMilestone\nMiley\nMiley’s\nMiley's\nmilf\nMilf'\nMiLF\nMILF'\nMILF?\nMilf’s\nMILF+Anal=Goodtimes\nMilf+Yoga=\nMilfalicious\nMILF-A-RIFFIC\nMILF-BBC\nMILF-Box\nMilfbury\nMILFCategory\nMILF-cation\nMilfCruisercom\nMILF-Elle\nMilfer\nMilf-Estate\nMILFfidelity\nMILFHer\nMilfhunting\nMilfier\nmilfing\nMILF-In-Law\nMilfish\nmilf-jaculation\nMILFLacey\nMilfland\nMilflicious\nMILFlife\nMilfman\nMILFMANIA\nMILF-next-door's\nMilfomaniac\nMilf-O-Maniacs\nMilfomanic\nMILFriendly\nmilfs\nMilf's\nMILFs\nMILF's\nMILFS\nMilfshake\nMILFsitter\nMILFTALK\nMilftastic\nMilfy\nMILF-y\nMILFY\nMilgram's\nMili\nMili’s\nMilia\nMilian\nMilias\nMilina\nMilion\nMili's\nmilitary\nMilitary-Grade\nmilk\nMilk?\nMilk+Chocolate\nMilka\nmilked\nMilker\nMilkers\nMilk-Filled\nMilkfun\nMilkin\nmilking\nMilk-Lubed\nMilkmaid\nMilkmaids\nMilkman\nMilkman's\nmilks\nmilkshake\nMilk-woman\nmilky\nMill\nMilla\nMillan\nMilla's\nMillena\nMillenium\nMillennia\nMillennials\nMillennium\nmiller\nMiller's\nMilli\nMillian\nMillie\nmillion\nmillionaire\nMillionaire?\nMillion-Dollar\nMillions\nMillón\nMillonaria\nMills\nMilly\nMilly's\nMilo\nMilsa\nMilton\nMilu\nMilu's\nMily\nMime\nMimi\nMimicry\nMimis\nMimi's\nMimosa\nMimy\nmin\nMina\nMinage\nMinaj\nMinal\nMinamos\nMinardi\nMinardi's\nMinarotte\nMinas\nMina's\nMinat\nmind\nMinda\nMind-bending\nmindblowing\nmind-blowing\nMindblowing\nMind-blowing\nMind-Body\nminded\nMindmake love\nMindmake loveed\nMind-Make loveed\nMindmake loveing\nMindful\nMindi\nmindless\nMindo\nMindReader\nminds\nMind-Spinning\nMindy\nMindy's\nmine\nMine?\nMineshaft\nMiney\nMingle\nMingus\nmini\nMiniature\nminigangbang\nmini-gangbang\nmini-golf\nMini-Market\nminimum\nmining\nminions\nMinirabbit\nminiskirt\nmini-skirt\nMiniskirt\nMini-Skirt\nminiskirts\nMinistry\nMini-tournament\nMinitoy\nmini-vacation\nminivan\nMinivibe\nMiniwand\nMink\nMinka\nMinka's\nMinks\nMinks-Live\nMinnesota\nMinnie\nMinnie's\nMinor\nMinore\nMinority\nMinsk\nMint\nMinty\nminute\nminutes\nminx\nMinxes\nMinxy\nMiosotis\nMira\nMirabel\nMirabella\nMirabel's\nmiracle\nMIRA-cle\nMiracles\nMiraculous\nMiraculously\nmirador\nMirage\nMirame\nMiran\nMiranda\nMiranda's\nMiran's\nMira's\nMirayn\nMirela\nMirella\nMiriam\nMirinda\nMirka\nMiroir\nMiroslava\nmirror\nMirror1\nMirror2\nMirrora\nMirrorb\nMirrorc\nMirrorcle\nMirrordong\nmirrored\nMirrorfingers\nMirrorfun\nMirrorfun1\nMirrorfun2\nMirroring\nMirrorplay\nMirrorcat\nmirrors\nMirror's\nMirrortoy\nMirrorvibe\nMirta\nMirta's\nmis\nMisa\nMisa-f\nMisappropriation\nMisbehave\nMisbehaved\nmisbehavers\nMisbehaves\nMisbehavin'\nmisbehaving\nMisbehavior\nMisbehaviour\nMiscellanea\nMischa\nMischa's\nMischel\nMischelle\nMischel's\nMischief\nMischievous\nMischievously\nMisconception\nMisconduct\nMisdeed\nMise\nMisel\nmiserable\nMisery\nMisfit\nMisha\nMisha?\nMishap\nMishaps\nMisha's\nMishelle\nMishka\nMishka's\nMishy\nMishy's\nMisletoe\nMismatches\nmiss\nmissed\nmisses\nMissi\nMissies\nmissile\nmissin'?\nmissing\nmission\nmissionary\nMississippi\nMissles\nMisstep\nMissus\nmissy\nMissy’s\nMissypink\nMissy's\nmist\nmistake\nMistaken\nmistakes\nMister\nMister?\nMisterWant\nMisti\nMistiDawn\nMisti's\nMistiya\nMistle\nMistleblow\nmistlejohnson\nMistletoe\nMistreated\nMistreatment\nmistress\nMistresses\nmistress's\nMistressTs\nMisty\nMisty’s\nMisty's\nMisunderstanding\nMisuzu\nMisy\nMitch\nMitchel\nMitchell\nMiteva\nMithiani\nMitias\nMitos\nMitsiia\nMitsuki\nMitt\nmitten\nMitzi\nMitzy\nMiu\nMiura's\nMiu's\nMivina\nmix\nmixed\nMixers\nMixes\nMixi\nMixin\nmixing\nMixi's\nMixology\nmixture\nMixup\nMix-up\nMiya\nMiyabi\nMiyamme\nmiyu\nMiyuki\nMizu\nMizuna\nMizz\nMJ\nMK\nMke\nM'lady?\nMLB\nMLIB\nMLK\nMlle\nM'lord?\nmm\nMMA\nM-Man\nMMF\nMm'Kay\nmmm\nMMMF\nMmmia\nmmmm\nMmmmm\nMmmMmm\nMn\nMnemonica\nmo\nmoan\nMoana\nmoaned\nMoaner\nMoaners\nmoaning\nmoans\nmob\nmobile\nMobster\nMobsters\nMobster's\nMoby\nMobybanana\nMobybanana2\nMobytoy\nMobytoy2\nMocca\nMocha\nMock\nMod\nModa\nMode\nmodel\nmodel'\nModel\nmodel?\nModel’s\nModelbabe\nmodel-esque\nModelesque\nmodeling\nModella\nModelling\nmodels\nmodel's\nModels\nModel's\nmodern\nModerna\nModest\nmodest-looking\nModification\nmodified\nModnoy\nMoe\nMoeller\nMOFO\nMofos\nMo-Girls\nMogul\nmohawk\nMohogany\nMohr\nMoi\nmoi?\nMoi-Meme\nMoira\nMoire\nmoist\nmoistened\nMoister\nMoistiza\nMoisture\nMoisturize\nMoisturizes\nMojado\nMojave\nMojito\nMojo\nMoka\nMoka's\nMokhov\nMolbooty\nMold\nMoldavian\nMole\nmolestation\nmolested\nMolester\nMoley\nMolivi\nMoll\nMolli\nMollis\nMolloy\nMolly\nMollys\nMolly's\nMoly\nmom\nmom?\nMom’s\nMoma\nMom-Daughter\nmoment\nmoments\nMoment's\nMomentum\nMominator\nMominatrix\nmomma\nmommas\nMomma's\nMommie\nMommies\nmommy\nMommys\nMommy's\nMommy-Son\nmom-next-door\nMom-Night\nMomo\nMomoko\nMomos\nmoms\nmom's\nMoms\nMom's\nMomsen\nMomshell\nMoms-In-Law\nMomSwap\nmomy\nMon\nMona\nMona’s\nMonaco\nMonae\nMonaee\nMonaghan\nMonalee\nMonalee's\nMonaliza\nMonarch\nMona's\nMonastery\nMonchi\nMonchi's\nMonday\nMondays\nMonday's\nMondo\nmone\nMonela\nMonelli\nMonet\nMonetizing\nmoney\nmoney'\nMoney\nMoney?\nmoneyand\nMoneyBalls\nmoneymaker\nmoney-making\nmoney's\nMongolian\nMoni\nMonic\nmonica\nMonicas\nMonica's\nMonicka\nMonik\nMonika\nMonikas\nMonika's\nmonique\nMonique's\nMonir\nMonitor\nmonkey\nMonkeying\nMonkeyrocker\nMonkeytoy\nMonna\nMonochromic\nMonogamy\nMonologue\nMonroe\nMonroe’s\nMonroeLook\nMonroe's\nMonrow\nMonroy\nMonsoon\nMonstars\nmonsted\nmonster\nMonstermember\nMonsterjohnson\nmonsterous\nMonsterProlapse\nmonsters\nMonster's\nmonstre\nMonstro\nmonstrous\nMonstruo\nMonta\nMontada\nMontag\nMontage\nMontana\nMonte\nMontego\nMonteiro\nMontenegro\nMontenegro's\nMontero\nMontes\nMontgomery\nmonth\nmonth?\nMonthHere's\nMonthly\nmonths\nMontmartre\nMontreal\nMontse\nMontsrous\nMonty\nMonument\nMonumental\nMonus\nMony\nmood\nmoods\nmoon\nMoon’s\nMoondance\nMoone\nMoone'\nMooned\nMoone's\nMooning\nmoonlight\nMoonlights\nMoonNR278\nmoons\nMoon's\nMoonshine\nMoonstruck\nMoor\nMoore\nMoore?\nMooreBooming\nMoorehead\nMooreHuge\nMooreOiled\nMoores\nMoore's\nMooreSpicy\nMoors\nMooseknuckle\nmooseknuckles\nmop\nmoping\nmopped\nMopper\nMopping\nmops\nmor\nMora\nMorado\nMoraes\nMoral\nMORALE\nMorales\nMorals\nMorango\nMorante\nMoratlia\nMoray\nmore\nmore?\nMoreau\nMorefingers\nMorel\nMorelotion\nMorena\nMorenita\nMoreno\nMoreno's\nMore's\nMoresex\nmoresome\nmoresomes\nmorethan\nMoretta\nMoretta's\nMoretti\nMorg\nMorgalny\nMorgan\nMorgan?\nMorgana\nMorgan-Baller\nMorgane\nMorgann\nMorgans\nMorgan's\nMo'rgasms\nMorgen\nMorghan\nMori\nMoriah\nMoriah's\nMorich\nMoriel;\nMoring\nMorir\nMormon\nMormons\nMorn\nMorna\nMorna's\nmornin\nmorning\nmornings\nMorning's\nMorningstar\nMorning-Time\nMoroccan\nMorocha\nMorphed\nMorpheus\nMorr\nMorre\nMorris\nMorrison\nMorsel\nMort\nmortal\nmortgage\nMorticia\nMorven\nmos\nMosaic\nMoscow\nMoses\nMoss\nmost\nMoster\nMostest\nmostly\nMosuli\nmotel\nMotela\nMoth\nmotha\nMothamake lovea\nmother\nMother’s\nMotherboard\nMother-Daughter\nMothermake loveer\nmothermake loveers\nMothermake loveing\nMother-In-Law\nMother-In-Law's\nMother-In-Lust\nMotherless\nMotherload\nMotherlover\nMotherly\nmother's\nMothers\nMother's\nmotion\nMotions\nMotivated\nMotivates\nMotivating\nmotivation\nmotivational\nMotivator\nMotive\nMotives\nMOTM\nMoto\nMotomura\nmotor\nMotorbike\nmotorbikes\nmotorboat\nMotorboating\nMotorbunny\nmotorcycle\nmotorcycles\nMotorist\nMotorized\nMotoro\nmotors\nMOTYLIA\nMouche\nMouna\nmounatin\nMound\nmounds\nMounia\nmount\nmountain\nMountainous\nmountains\nMounted\nMounth\nMountian\nMountians\nMountin\nmounting\nMountings\nmounts\nMour\nMoura\nMoure\nmourning\nmouse\nmousetraps\nMousse\nmouth\nmouth?\nmouth-and-sole\nmouth-booty\nmouth-banged\nMouthed\nmouthmake loveed\nMouth-Make loveed\nmouthful\nMouthful=\nMouthfull\nMouth-Full\nmouthfull?\nmouthfulls\nmouthfuls\nMouthful's\nmouths\nmouth's\nMouths\nMouth's\nMouth-To-Mommy\nMouth-To-Mouth\nmouth-to-cat\nMouthwash\nmouth-watering\nMouthwatering\nMouth-watering\nmouthy\nmove\nmoved\nMoveie\nmovement\nMover\nmovers\nmoves\nmovie\nMovieA\nmovies\nMovin\nmoving\nmoving_in_on_busty_neighbor\nMoviovi\nmowin\nmows\nMoxie\nMoxxie\nMoxy\nMozaika\nMP\nMr\nMrblue\nMrbunny\nMrCreep\nMrpink\nMrpinky\nMrs\nMrs?\nMrsAlexander\nMrsClaus's\nMrsLuv's\nMrwiggles\nMs\nM's\nMsLondon\nMsRose\nMuay\nmuch\nMuch?\nMucha\nMucho\nMuchos\nMud\nMuddy\nMudshark's\nMuerte\nMueve\nmuff\nMuffdiver\nMuffdiving\nMuff-Diving\nMuffet\nmufffin\nmuffin\nMuffin'\nmuffin?\nMuffing\nmuffins\nMuffin-Top\nmuff-pleaser\nMuffs\nMug\nmugged\nMugler\nMugur\nMuhica\nMujer\nMujeres\nMukbang\nMuladhara\nMulani\nmulanirivera_re\nMulani's\nmulato\nMulatto\nMulch\nMulder\nMule\nMules\nMulino\nMulisa\nMuller\nMult\nMulte\nMulti\nMulticolored\nMulti-Course\nmultijohnson\nmulti-johnson\nMultijohnson\nMulti-johnson\nMulti-Flood\nmultimillion\nMulti-orgasm\nMultiorgasmic\nMulti-orgasmic\nmulti-orgasms\nMulti-Person\nmultiple\nMultipleAirplane\nMultipleFacial\nMultiples\nMultiply\nmulti-popped\nMultipurpose\nMultisquirt\nMulti-Squirt\nMulcanask\nMulcanasking\nmulti-tasks\nMulcanasks\nMulcanoy\nmum\nMummification\nmummified\nMummify\nMummy\nMummy's\nMums\nMum's\nMunch\nmunchable\nMunched\nMuncher\nmunchers\nmunches\nMunchies\nmunchin\nmunching\nMunchkin\nMunchkinland\nMundial\nMungary\nmungry\nMunicipal\nMunkey\nMunroe\nMur\nMurder\nMurka\nMurphy\nMurray\nMUSA\nmusc\nmuscle\nMusclebate\nmuscled\nmuscle-head\nMuscle-MILF\nmusclemy\nmuscles\nMuscly\nmuscular\nmuse\nMuse’s\nMuses\nMuse's\nmuseum\nMushroom\nmusic\nMusica\nMusical\nmusician\nMusicians\nMusic-inspired\nMusicpanties1\nMusicpanties2\nMusicspread\nMusicstrip1\nMusicstrip2\nMusictouch1\nMusictouch2\nMusicvibe\nMusing\nMusings\nMusink\nMusk\nMusketeers\nMusky\nMuslim\nmust\nMustache\nMustang\nMustard\nMust-Have\nmust-see\nmusturbates\nMust've\nMust-Watch\nMutant\nMuted\nMuthas\nMutherload\nMuti\nMutiny\nMutton\nmutual\nMutually\nmuy\nMuza\nMuzzled\nMVP\nMVCat\nmy\nmya\nMyah\nMya's\nMymelonies\nMymelons\nMyMember\nMyeon\nMyers\nMyfingers\nMykonos\nMylee\nMylena\nMylen's\nMyles\nMylka\nMyluv\nMynor\nMynx\nMyra\nMyranda\nMyra's\nMyrelly\nMyriam\nMyrka\nMyrna\nMyrnajoy\nMyrtille\nMyrtle\nmyself\nMyshell\nMysocks\nMysophobia\nMyst\nMystere\nMysteries\nMysteriosuly\nmysterious\nMystery\nMysti\nMystic\nMystica\nmystical\nMystique\nMyth\nMythical\nMythos\nMytoy1\nMytoy2\nMz\nn\nn`dap\nN00b\nN00bs;\nNa\nNabakova\nNacci\nNacci's\nNacho\nNachos\nNacho's\nNacole\nNad\nnada\nNadezhda\nNadi\nnadia\nNadias\nNadia's\nNadin\nNadina\nNadine\nNadira\nNadja\nNadya\nNadya's\nNaey\nNaghavi\nNagini\nNagini's\nNagy\nNah\nNahtanha\nNaia\nNaia's\nNaidyne\nNaika\nnail\nnailed\nNailhead\nNailin\nnailing\nNailpolish\nnails\nNaira's\nnaive\nNaïve\nNajra\nNakai\nnaked\nNaked?\nNakedandwet\nNakedcom\nnakedness\nNakedtalk\nNakia\nNakita\nNakya\nNala\nNala's\nNalga\nNalgas\nnam\nnamastanal\nNamastay-On-The-Member\nNamaste\nNamat\nname\nname?\nnamed\nnames\nNami\nNamisi\nNamlyn\nNan\nNana\nNancy\nNancy's\nNanda\nNani\nNanjo\nNannccy\nNanney\nnannie\nNannies\nnanny\nNanny’s\nNanny's\nNanny-to-Porn\nNano\nNanoe\nNanpa\nNaoimi\nnaomi\nNaomie\nNaomie's\nNaomi's\nNaomy\nnap\nNap?\nNapa\nNapier\nNapis\nNapoli\nNapped\nNappi\nNapping\nNappi's\nNapsturbate\nNaptime\nNaranja\nNarc\nNarcissa\nNarcissism\nNarcissist\nNarcissistic\nNarcissus\nNard\nNarga\nNari\nNariah\nNari's\nNarkiss\nNarlie\nNarra\nNarrated\nnarrow\nNarrowtoy1\nNarrowtoy2\nNarsea\nNarumiya\nnas\nNash\nNasha\nNashville\nNasita\nNbooty\nNbootysty\nNbootyy\nNasta\nNastaha\nNastia\nNastie\nnastier\nNasties\nnastiest\nNastja\nnasty\nNastya\nNastya's\nNasty-Booty\nNastyhka\nNastyShagging\nNat\nNata\nNatacha\nNatalee\nNatali\nNatalia\nNatalia?\nNatalias\nNatalia's\nNatalie\nNatalie’s\nNatalies\nNatalie's\nNatalija\nNatalija’s\nNataliya\nNatalli\nNatallie\nNatalli's\nNataly\nNatalya\nNatalya's\nNataly's\nNatana\nNataran\nNatascha\nNatasha\nNatasha's\nNatashia\nNatbootyia\nNatbootyia's\nNatch\nNate\nNathalie\nNathaly\nNathan\nNathaniel\nNathan's\nNathany\nNathon\nNatia\nNation\nNational\nnative\nNativias\nNatsha\nNatsuss\nNatsy\nNATTI\nNatty\nNattys\nNatty's\nnatual\nNatura\nnatural\nNatural-Meloned\nNatural-born\nNaturale\nNaturalist\nNaturally\nNaturalMILF\nNaturalcat\nnaturals\nNaturaly\nnature\nNaturel\nnatures\nNature's\nNatureteen\nnaturist\nNaturly\nNaturoslut\nNatusia\nNatusya\nNaty\nnau\nNaudi\nnaudia\nNaudya\nnaug\nnaughtier\nNaughties\nnaughtiest\nNaughtily\nnaughtiness\nnaughty\nNaughty?\nNaughtyfishnet\nNaughtygirl\nNaughtykitchen\nNaughtyMag\nNaughtyness\nNaughtynurse\nNaughtynympho\nNaughtyschoolgirl\nNaughy\nnaugthy\nNaugthys\nNausty\nnautica\nNautical\nNautica's\nNautral\nNautural\nNava-Hoes\nNavajo\nNavaro\nNavarro\nNaveen\nNavigate\nNavigating\nNavy\nNawlins\nN'awlins\nNaxy\nNayasha\nNaymod\nNayomi\nnaпve\nN-cups\nNDA\nne\nNeal\nNeapolitan\nnear\nnearly\nNeat\nNeaveh\nNebbouh\nNecesita\nNecessary\nnecessities\nneck\nNeckin'\nnecklace\nnecklaces\nNecktie\nNecro\nNecromantic\nNecronomimember\nnectar\nNed\nNeecie\nneed\nNeeda\nneeded\nNeedful\nNeeding\nneedle\nneeds\nNeeds--And\nNeedy\nNeeka\nNeela\nNeeo\nNeeo's\nNeesa\nneew\nNefarious\nNeglected\nNeglectful\nNeglecting\nnegligee\nNegligence\nNegociaciones\nnegotiates\nnegotiations\nNegotiator\nNegrao\nNegro\nneigbor\nNeigbourhood\nNeige\nNeighboorhood\nNeighboors\nneighbor\nneighbor?\nneighbor’s\nneighborh\nneighborhood\nNeighboring\nneighborly\nneighbors\nneighbor's\nNeighbors\nNeighbor's\nNeighbors’\nNeighborwhore\nneighbour\nneighbourhood\nNeighbourly\nNeighbours\nNeighbrohood\nNeil\nNeill\nNeilla\nneither\nNek\nNekane\nNekane's\nNeko's\nNell\nNella\nNella's\nNelli\nNellie\nNellification\nNellifiied\nNelli's\nNelly\nnelly's\nNelson\nNelya\nNelya's\nnemesis\nNemyo\nNena\nNenas\nNenetl's\nNeo\nNeon\nNeona\nNeonas\nnephew\nnephews\nnephew's\nnerd\nNerd?\nnerds\nnerd's\nNerds\nNerd's\nnerdy\nNerdz\nNerea\nNereid\nNerf\nNeri\nNeriah\nNerine\nNero\nNerve\nnerves\nNerville\nnervou\nnervous\nNervously\nNervs\nNess\nNessa\nNessaja\nNessi\nNesso\nNessy\nnest\nNestee\nNesters\nNesti\nNestling\nNestor\nNesty\nNesty?\nNesty's\nnet\nNeta\nNetjohnsons\nNetdress\nNetfingers\nnether\nNetherlands\nNetorare\nNets\nNetta\nnetted\nNetting\nNetty\nNetu\nNetwork\nNeu\nnev\nNevada\nNevadah\nNevaeh\nNevaeh-nevaehland\nNevaeh's\nNeveah\nNevena\nnever\nNever-Before-Seen\nNeverending\nNever-ending\nNevermore\nn'Everything\nNeves\nnew\nNew?\nNewb\nnewbie\nNewbie’s\nnewbies\nNewbie's\nNewborns\nNewbs\nNewby\nnewcomer\nNewcomer’s\nnewcomers\nnewcomer's\nNewcomers\nNewcomer's\nnewcommer\nNew-Daddy\nnewest\nnewly\nNewlywed\nNewlyweds\nNewman\nNew'n'nasty\nNewrabbit1\nnews\nNewsCast\nnewsdesk\nnewspaper\nNewspapers\nNewstand\nNewtoys\nNewvibe\nnext\nNext?\nnext-door\nNextdoor\nNext-Door\nNgahau\nni\nNia\nNiana\nNia's\nnibbl\nNibble\nNibbler\nnibbles\nNibbling\nNic\nNica\nNicci\nNiccole\nnice\nnice?\nnicely\nNice's\nnicest\nNicholas\nNichole\nNic-Hole\nNichole's\nNichols\nNicholson\nNici\nNick\nNicka\nNickel\nNickels\nNickey\nNicki\nNickie\nNickle\nNicko\nNickol\nNicks\nNick's\nNicky\nNicky's\nNicley\nNico\nNicol\nNicola\nNicolai\nNicolas\nnicole\nNicole’s\nNicole's\nNicolett\nNicoletta\nNicolette\nNicolette's\nNicoli\nNicoline\nNicoll\nNicolly\nNicols\nNicoly\nNicotine\nNiece\nNiece's\nNielsen\nNievez\nNiffy\nnifty\nNigel\nNigella\nNigga\nNigga's\nNigh\nnight\nnight?\nNightAnal\nnightcap\nnightclub\nNightcrawler\nNighter\nNightFeature\nNightfinger\nNightgown\nNightguzzler\nnightie\nNighties\nNightime\nNightingale\nNightlife\nNight-Life\nNightly\nnightmare\nNightmare4-0\nNightmare5-0The\nNightmares\nnights\nNight's\nNightshift\nNightstick\nNightsuckers\nnighttime\nnight-time\nNighttime\nNight-time\nnightvision\nNighty\nNighty1\nNighty2\nNigora\nNigora's\nNik\nNika\nNikara\nNikara's\nNika's\nNike\nNikea\nNiki\nNiki’s\nNiki's\nNikita\nNikita's\nNikitta\nNikka\nnikki\nNikki?\nNikkie\nNikkis\nNikki's\nNikkita's\nNikko\nNikky\nNikkys\nNikky's\nNiko\nNikol\nNikola\nNikole\nNikole's\nNikolett\nNikoletta\nNikolla\nNikolly\nNikol's\nNiky\nNikyta\nNila\nNila's\nnile\nNile's\nNilla\nNilla's\nNillox\nNils\nNilsson\nnimble\nNina\nNina’s\nNina's\nnine\nNinel\nNinelly\nNiner\nNiñera\nnineteen\nNineteenth\nnineteen-year-old\nNineteen-y-o\nNinfe's\nNinfo\nNing\nninja\nNinja9-0\nNinjas\nNinja's\nNinja's1-0\nNinouska\nNior\nNip\nNippity\nnipple\nNippled\nNippledon\nNipple-licking\nnippleodeon\nNipple-pierced\nnipples\nnipples?\nNipplesBrutal\nnipplesHas\nNippley\nNippleys\nnipplicious\nNippples\nNipps\nnips\nNira\nNirvana\nNirvanal\nNirvana's\nNisha\nNishino\nNita\nNita's\nNite\nNitro\nNitty\nNix\nNixie\nNixon\nNixon's\nNiya\nNizmir\nNK\nNK's\nno\nNo?\nNo1\nNo1986744\nNo2\nNo2231568\nNo4469525\nNo6698547\nNo69\nNo7485960\nNoa\nNoah\nNoapte\nNob\nNobili\nNoble\nnobody\nnobody's\nNobodys\nNobody's\nNobs\nNobu\nNoche\nNockers\nNocturnal\nNodding\nNoe\nNoel\nNoell\nNoelle\nNoelle's\nNoel's\nNoemi\nNoemie\nNoemilk\nNoemy\nNoey\nNog\nNogueira\nNoir\nNoire\nNoire's\nNoiret\nNoir's\nnoise\nNoises\nnoisy\nNok\nNolan\nNola's\nNoleta\nNo-Limits\nNollie\nNomad\nNomar\nNomi\nNominated\nNominee\nNomizo\nNomura\nNomy\nnon\nNona\nNoname\nnon-biased\nnon-conversationalist\nNone\nNon-Fiction\nNoni\nNonna\nNonny\nNo-Nonsense\nnonscripted\nnon-scripted\nNon-Smoking\nnonstop\nnon-stop\nNonstop\nNon-stop\nnon-stopPain\nNoob\nNoobes\nNoodle\nNoodles\nNook\nNookie\nNookies\nNooks\nNooky\nnoon\nNooner\nNo-orgasm\nNopanties\nNoCat\nNora\nNorah\nNora's\nNorby\nNord\nNordic\nNORE\nNoRestForTheBooty\nNorhman\nNorina\nnorma\nnormal\nNormalized'\nNormally\nNorman\nNormandie\nNorma's\nnorth\nNorthern\nNorth's\nNorton\nNorway\nnos\nnose\nNosed\nNosesta\nNosey\nNosh\nNostalgia\nnostalgic\nNostalgie\nnosy\nnot\nnot?\nNotable\nnotch\nnote\nNotebook\nNotes\nnot-her-boyfriend\nNothin\nnothing\nnothingness\nNothings\nNothing's\nnotice\nnoticed\nNoticing\nnotorious\nnotoriously\nNot-Quite-Aunt\nNot-So-Friendly\nnot-so-innocent\nnot-so-secret\nNot-So-Timid\nNottingham\nNotty\nNotty's\nNourishing\nNouveau\nNouvelle\nNov\nNova\nNovack\nNovaes\nNovag\nNovais\nNovak\nNovalie\nNova's\nNovea\nNoveau\nnovel\nNovelas\nNovels\nNovember\nNovember's\nNovia\nnovice\nNovice's\nNovio\nNovitas\nNov's\nnow\nnow?\nNowak\nNowhere\nNox\nNoxiania\nNoxx\nNozomi\nNozomi's\nNozzle\nNR001\nNR002\nNR003\nNR004\nNR005\nNR006\nNR007\nNR008\nNR009\nNR010\nNR011\nNR012\nNR013\nNR014\nNR015\nNR016\nNR017\nNR018\nNR019\nNR020\nNR021\nNR022\nNR023\nNR024\nNR025\nNR026\nNR027\nNR028\nNR029\nNR030\nNR031\nNR032\nNR033\nNR034\nNR035\nNR036\nNR037\nNR038\nNR039\nNR040\nNR041\nNR042\nNR043\nNR044\nNR045\nNR046\nNR047\nNR048\nNR049\nNR050\nNR051\nNR052\nNR053\nNR054\nNR055\nNR056\nNR057\nNR058\nNR059\nNR060\nNR061\nNR062\nNR063\nNR064\nNR065\nNR066\nNR067\nNR068\nNR069\nNR070\nNR071\nNR072\nNR073\nNR074\nNR075\nNR076\nNR077\nNR078\nNR079\nNR080\nNR081\nNR082\nNR083\nNR084\nNR085\nNR086\nNR087\nNR088\nNR089\nNR090\nNR091\nNR092\nNR093\nNR094\nNR095\nNR096\nNR097\nNR098\nNR099\nNR100\nNR101\nNR102\nNR103\nNR104\nNR105\nNR106\nNR107\nNR108\nNR109\nNR110\nNR111\nNR112\nNR113\nNR114\nNR115\nNR116\nNR117\nNR118\nNR119\nNR120\nNR121\nNR122\nNR123\nNR124\nNR125\nNR126\nNR127\nNR128\nNR129\nNR130\nNR131\nNR132\nNR133\nNR134\nNR135\nNR136\nNR137\nNR138\nNR139\nNR140\nNR141\nNR142\nNR143\nNR144\nNR145\nNR146\nNR147\nNR148\nNR149\nNR150\nNR151\nNR152\nNR153\nNR154\nNR156\nNR157\nNR158\nNR160\nNR161\nNR162\nNR163\nNR164\nNR165\nNR166\nNR167\nNR168\nNR169\nNR170\nNR171\nNR172\nNR173\nNR174\nNR175\nNR176\nNR177\nNR178\nNR179\nNR180\nNR181\nNR182\nNR183\nNR184\nNR185\nNR186\nNR187\nNR188\nNR189\nNR190\nNR191\nNR192\nNR193\nNR194\nNR195\nNR196\nNR197\nNR198\nNR199\nNR200\nNR201\nNR202\nNR203\nNR204\nNR205\nNR206\nNR208\nNR209\nNR210\nNR211\nNR212\nNR213\nNR214\nNR215\nNR216\nNR217\nNR218\nNR219\nNR220\nNR221\nNR222\nNR223\nNR224\nNR225\nNR226\nNR227\nNR228\nNR229\nNR230\nNR231\nNR232\nNR233\nNR234\nNR235\nNR236\nNR237\nNR238\nNR239\nNR240\nNR241\nNR242\nNR243\nNR244\nNR245\nNR246\nNR247\nNR248\nNR249\nNR250\nNR251\nNR252\nNR253\nNR254\nNR255\nNR256\nNR257\nNR258\nNR259\nNR260\nNR261\nNR262\nNR263\nNR264\nNR265\nNR266\nNR267\nNR268\nNR269\nNR270\nNR271\nNR272\nNR273\nNR274\nNR275\nNR276\nNR277\nNR279\nNR280\nNR281\nNR282\nNR283\nNR284\nNR285\nNR286\nNR287\nNR288\nNR289\nNR290\nNR291\nNR292\nNR293\nNR294\nNR295\nNR296\nNR297\nNR299\nNR300\nNR301\nNR302\nNR303\nNR304\nNR305\nNR306\nNR307\nNR308\nNR309\nNR310\nNR311\nNR312\nNR313\nNR314\nNR315\nNR316\nNR317\nNR318\nNR319\nNR320\nNR321\nNR322\nNR323\nNR324\nNR325\nNR326\nNR327\nNR328\nNR329\nNR330\nNR331\nNR332\nNR333\nNR334\nNR335\nNR336\nNR337\nNR338\nNR339\nNR340\nNR341\nNR342\nNR343\nNR344\nNR345\nNR346\nNR347\nNR348\nNR349\nNR350\nNR351\nNR352\nNR353\nNR354\nNR355\nNR356\nNR357\nNR358\nNR359\nNR360\nNR361\nNR362\nNR364\nNR365\nNR366\nNR367\nNR368\nNR369\nNR370\nNR371\nNR372\nNR373\nNR374\nNR375\nNR377\nNR378\nNR379\nNR380\nNR381\nNR382\nNR383\nNR384\nNR385\nNR386\nNR387\nNR388\nNR389\nNR390\nNR391\nNSA\nNSFW\nNth\nNtyce\nNu\nNuar\nNub\nNubian\nnubile\nnubiles\nNubilestrip\nNuclear\nnud\nNudaFightClub\nnude\nNudeFighClub\nNudefightClub\nnudefightclubcom\nnudes\nNudeterraneo\nNudey\nNudie\nNudies\nNudism\nnudist\nNudista\nNudists\nnudity\nNueva\nNuff\nNuisance\nNuit\nnulled\nnum\nNumb\nnumber\nnumbers\nNumerani\nNumero\nNúmero\nNummies\nnum-nums\nnums\nnun\nnunchucks\nNun-Chucks\nNunchuk\nNuns\nNunsploitation\nNuptials\nnurse\nNurse?\nNursed\nnursery\nnurses\nnurse's\nNurses\nNurse's\nNURSES\nNursie\nnursing\nNurture\nnuru\nNuru?\nNuru-Gasmic\nNury\nnut\nnutbuster\nNutcracker\nNut-Cracker\nnutella\nNutflix\nNuthouse\nNutjob\nNutley's\nnutload\nNutritional\nNutritious\nnuts\nNuts?\nnutsack\nNutsucker\nNutt\nNutted\nNuttfill\nNuttin\nNutting\nNutt's\nnutty\nNutz\nnuyorico\nNuzzle\nnuzzles\nNuzzling\nnwecomer\nny\nNya\nNyc\nNyce\nNychole\nNYDP\nNYE\nNyeema\nNyikita\nNyla\nNyla's\nnylon\nNylon-clad\nNylonlover\nnylons\nNym\nnymph\nnymphette\nnympho\nNympho-Chondriac\nNympho-Insomniac\nNympholepsy\nNymphomanager\nNymphomania\nnymphomaniac\nNymph-o-maniac\nNymphomaniac's\nNymphoManiacs\nnymphos\nnymphs\nNymphsomnia\nNympomaniac\nNyna\nNyobi\nNyomi\nNyomi's\nNypho\nNyx\no\n'O\nO’Reilly\nO’Reilly’s\nOakley\nOaks\nOara\nOasie\nOasis\nOath\nOB\nObama\nO'Banyon\nObcasio\nobedience\nobedient\nobediently\nObelisk\nObession\nObey\nObeying\nobeys\nOBGYN\nobject\nObjectification\nObjectified\nobjectify\nObjectives\nObjects\nObligations\noblige\nObliges\nOblivian\noblivion\nOblivious\nObnoxious\nObscene\nOBSCENITY\nObscuration\nObscure\nObsenities\nObservation\nObserve\nObserver\nObservers\nobsessed\nObsesses\nobsession\nobsessions\nObsessive\nObstacle\nObstruction\nObvious\nObzira\nOcbootyion\noccasion\noccasions\noccbootyion\nOccbootyions\nOcchi\nOccult\nOccupancy\nOccupied\nOccupy\nOccupying\nocean\nOcean'\nOceana\nOceane\nOceanic\nOceans\nOcean's\nOceanside\nOceanview\nOcelo\nOcho\no'clock\nO'Member\nO'Connell\nO'Connor\nO'Connor's\nOct\noctane\nOctavia\nOctober\nOctoberFest\nOctomom\nOctocats\nOctocat's\nod\nOda\nOdanost\nOdare\nO'dare\nOdd\nOddities\nOddjobs\nOdds\nOddcanties\nOddyssey\nOde\nO'dell\nOdessa\nOdette\nOdeur\nOdile\nOdile's\nOdin\nOdio\nOdmiana\nOdyssey\nof\nO'Face\nOfelia\nOferta\noff\noff?\noff…and\nOffbeat\nOff-Duty\nOffence\nOffender\nOffenders\nOffense\nOffense?\noffer\noffered\noffering\nOfferings\noffers\noffic\noffice\nOffice?\nOfficebreak\nOfficefingers\nOfficeorgasm\nOfficepleasure\nofficer\nofficer?\nofficers\nOfficeteen\nOfficetoy\nofficial\nOfficially\noff-limits\noffline\nOffroad\noffRS079\nOffshore\nOffstage\nOficina\nofSlave\noften\nOMAKE LOVE\nogle\nOgled\nOgling\nOgre\nOgzija\noh\noh_brother_dont_make love_my_gf\nOhana\nO'Hara\nOhayoo\nOhh\nOhhh\nOHHHH\nOhhhhh\nOhio\nOh-Oh\nO'Horny?\nOhs\nOh's\nOh-So-Limber\nOh-so-pro\nOi\noil\noil_wrestling\nOilmelons\nOilbanana\nOil-Drenched\noiled\nOiledfun\noiledCat\noiled-up\nOilfinger\nOilfingers\nOilfun\noiling\nOilled\nOil-Lubed\nOilpleasure\nOilrub\noils\nOil-Slick\nOil-Soaked\nOiltoy\nOiltoys\noily\nOilybodyrub\nOinare\nOingo\nOink\nOinks\nOishi\nOJ\nok\nOK?\nOkami\nOkay\nOkey\nOkie\nOklahoma\nOksana\nOksy\nOktavia\nOktoberTurkey\nOktoberTurkeys\nOktoberfest\nOktobermake loveed\nol\nOla\nOlas\nold\noldBoth\nOldboyardee\nOlde\nolder\nOlder-Woman\nOldest\noldie\nOldies\nold-new\nolds\nold's\nOlds\nOld's\noldschool\nOld-school\nOld-young\nOldYoung\nOld-Young\nole\nOlena\nOleo\nOlesia\nOlesya\nOlesya's\nOleum\nOlga\nOlia\nOlimpia\nOliva\nOlive\nOliveira\nOliver\nOlivia\nOlivia’s\nOlivia's\nOliviya\nOliviya's\nOlivya\nOlja\nOlja2\nOlla\nOlle\nOlli\nOllie\nOllies\nOllivia\nOlousian\nO'lovely\nOL's\nOlsen\nOlsen’s\nOlson\nOlya\nOlympia\nOlympic\nOlympics\nOlympus\nOlyvia\nOmar\nOmar's\nOmbrage\nOmega\nOmg\nOmidee\nOmilia\nOmradet\nOmsin\nO-My-God\non\non?\non1\non--and\nOn-Call\non-camera\non-campus\non-carpet\nonce\nOnce'\nonce_in_awhile\nOnde\nOndeto\nOndorio\none\none?\nOne-actress\nOne-Boned\nOnee-san\nOne-Eyed\noneil\nO'neil\nO'Neil-ing\nOne-Night\nOne-on-One\nOnepiece\nOneReal\nones\nOne's\none-sided\nOnesie\nOneSlave\nOne-Stop\nONEThe\nOneTight\none-time\nOne-timer\nOne-Way\none-woman\nOne-zy\nOn-Her\nOnia\nonion\nOnirica\nOniva\nonkitchen\nonline\nOn-Location\nOnlookers\nonly\nOnna\nOnoma\nOnsen\nonstage\nOn-Stage\nOn-The-Job\nonto\nOny\nO'n'Y\nO-n-Y\nOnyx\noo\nooey\nOoga\nOoh\nOoh-La-La\nOoo\nOOO`s\nOooh\nOoooh\nOooooo\nOoops\nOoopsie\nOOOWEEE\nOops\nOosawa\nOoze\nOoze'n\noozes\nOozing\nop\nOpacity\nOpal\nopen\nopened\nopener\nOpenerVendetta11-3\nopening\nOpen-minded\nOpen-Mouthed\nopens\nopera\nOperated\nOperatic\noperating\noperation\noperator\nOphelia\nOphelie\nopinion\nOpinions\nOp-op-optometrist\nOPP\nOPP?\nOppa\nOpponent\nOpponents\nOpportunist\nOpportunite\nopportunities\nopportunity\noppose\nopposite\nopposites\nOpsss\noptical\nOptima\nOptimistic\nOption\noptional\noptions\nOpulence\nor\nor?\nOra\nOracle\noral\nOralfice\nOralficePart\nOralists\norally\norange\nOrange1\nOrange2\nOrangedress\nOrangefinger\nOrangeflowers\nOrangegrove\nOrangelingerie\nOrangepanty\noranges\nOranges?\nOrangetop\nOrasa\nOratory\nOrbit\nOrbital\nOrbs\nOrchard\norchestrates\nOrchestrating\nOrchid\nOrchidea\nordeal\norder\nOrder?\nordered\nordering\nOrderlies\nOrderly\norders\nOrdinary\nO'Reilley\nOreilly\nO'reilly\nOReilly\nO'Reilly\nOreilly's\nO'Reilly's\nO'ReilyUntil\nO-Ren\noreo\nOretha\nOrgams\nOrgan?\nOrganic\nOrganica\norganize\nOrganizer\nOrgans\norgasm\nORGASMAGEDDON\nOrgasm-a-thon\nORGASMATHON\nOrgasmatose\nORGASMATRON\norgasmed\nOrgasmerator\norgasmic\nOrgasmicfaction\norgasming\nOrgasmo\norgasm-oldie\norgasms\norgasms?A\norgasmsExtreme\nOrgasmsScreaming\norgasmswhile\norgasm-tools\nOrgasmtron\norgasm-ville\norgazma\nOrgazmatron\norgiastic\nOrgies\nOrgspams\norgy\nOrgyriffic\nOrhidea\nOrian\nOriana\nOrianna\nOrient\nOrienTAIL\noriental\nOrientation\nOriente\noriented\norifice\nOrifices\nOrigin\noriginal\nOriginals\nOrigins\nO'Riley\nOrina\nO-Ring\nOriole\nOrion\nOrion's\nOrismus\nOrita\nOrlando\nOrlane\nOrleans\nOrlov\nOrlov's\nOrnament\nornaments\nOrnella\nOrphanage\nOrpheus\nOrsay\nOrsay's\nOrsi\nOrsolya\nOrsolya's\nOrspasm\nOrssi\nOrtega\nOrtega’s\nOrth\nOrthodontic\nOrthodontics\nOrtiz\nOrto\nO'Ryan\nOs\nO's\nOsa\nOsada\nOscar\nOscillations\nOserino\nOshea\nOshte\nOsiris\nOso\nO'Spheres\nOssa\nOstanyes\nOstena\nOstentativo\nOstrica\not\nOtaku\nOthelia\nOthello\nother\nOther’s\nothers\nother's\nOthers\nOther's\nOtis\nO-Canty\nOTK\nOtowa\nOtra\nOtter\nOtto\nottoman\nOttomana\nOttomanorgasm\nou\nOuan\nouch\nOuchy\nOui\nOuija\nOu-Lorena\nOunce\nour\nOuranos\nOurs\nourselves\nOuside\nout\nout'\nOut\nOut\t\nout?\nOutage\nOUTAKES-TS\nOutback\noutburst\nOutcome\nOut-Creeping\noutdoor\nOutdoorfingers\nOutdoorfingersBTS\nOutdoorfun\nOutdooring\nOutdoormagic\nOutdoorplay\nOutdoorpleasure\nOutdoorcat\nOutdoorrub\noutdoors\nOutdoorsy\nOutdoorteen\nOutdoortouches\nOutdoortoy\nOutdoorvibe\nOuted\nOuter\nOutercourse\nOutfield\noutfit\noutfits\nOutfoxed\nOutmake loveing\nOut-Horn\noutie\nOuting\nOutlandishly\nOutlast\nOutlaw\nOutlaws\nOutline\nOutlines\noutNamaste\nOutnumbered\nOut-of-this-world\nOut-of-town\noutoor\nOutOr\noutrageous\nOutreach\nouts\noutside\nOutsideaction\nOutsideplaytime\nOutsidepleasure\noutsides\nOutsidetease\nOutsidetoy\nOutsmarting\nOutsourcing\noutstanding\noutta\nOuttage\nouttakes\nOut-takes\nOUTTAKES\nOUTTAKES-A\nOUTTAKES-Anal\nOUTTAKES-Belladonna's\nOUTTAKES-Cream\nOUTTAKES-Deep\nOUTTAKES-Gape\nOUTTAKES-Girl\nOUTTAKES-Lil\nOUTTAKES-Milk\nOUTTAKES-Pretty\nOUTTAKES-Cat\nOUTTAKES-Slutty\nOUTTAKES-Strap\nOUTTAKES-TS\nOUTTAKES-Winking\nOut-Whores\noutwits\noutz\nOva\nOval\nOvaries\novation\nove\nOven\nover\nover?\nover4\nOverachiever\nOverachievers\nOverall\nOveralls\nOverbearing\nOverboard\nOvercast\novercome\novercomes\novercoming\noverCrotch\nOverdose\noverdrive\noverdue\nOver-Easy\nOverexploited\nOverexposed\nOverfall\noverfloat\nOverflood\nOverflow\nOVERFLOWED\nOverflowing\noverflows\noverMake loveed\noverg\nOvergrown\nOverhead\noverhears\nOverheated\nOverheating\nOverindulges\noverjoyed\nOverkill\noverload\nOverloaded\nOverloadEvery\nOverlooked\nOverly\nOvernight\noverOH\nOverpower\noverpowered\nOverprotecting\nOverprotective\nOverride\nOverrides\novers\nOverseas\nOversexed\nOvershare\nover-siz\nOversized\nOversnatch\noverSquirting\nOverstayed\nOverstep\nOverstuffed\nOvertaken\nOvertakes\nOver-The-Shoulder\nOver-The-Top\nOverthink\novertime\nOverwatchers\noverwhelm\noverwhelmed\nOverwhelming\noverwhile\noverwork\nOverworked\noverxxxposure\nOverzealous\nOwe\nowed\nOwen\nOwens\nowes\nOwl\nown\nOwned\nowner\nOwner's\nOwning\nowns\nOx\nOxana\nOxiana\nOxijana\nOxuanna's\nOxy\nOxygen\nOxygen?\nOye\nOyeloca\nOynx\nOyster\noz\nOzaki\nOzio\nOzzie\np\nP001\nP002\nP003\nP004\nP005\nP006\np1\np2\nP3\nP4\nP90Sex\npa\nPA?\nPablo\nPace\npaced\npaces\nPachino\nPacific\nPacifica\nPacifico\npacifier\npack\npackag\npackage\npackages\nPackaging\npacked\nPacker\nPackers\npackin\nPackin'\npacking\npacks\nPaco\nPact\nPad\nPadded\npaddle\npaddled\nPaddling\nPaddys\nPaddy's\nPadova\npadrastro\npadres\nPads\nPaella\nPag\nPaganelli\nPage\nPage’s\npageant\npages\nPaging\nPago\nPagota\nPai\npaid\nPaige\nPaiges\nPaige's\npain\nPainal\nPainball\nPaine\npainful\nPainfull\nPainfully\nPains\npainslut\npaint\nPaintball\nPaintballers\npaintballin\npaintbrush\npainted\npainter\nPainters\nPainter's\npainting\npaintings\npaints\nPainttoy\npair\nPaired\npairing\nPairings\npairs\nPaisa\nPaisley\nPaisley's\nPaizuri\npajama\nPajamababe\nPajamafun\nPajamas\nPajamas1\nPajam'Booty\nPajaritas\nPajave\nPajixian\nPak\nPakistani\npal\nPala\nPalabras\npalace\nPalate\nPalavering\nPalazzo\npale\nPalemon\nPalenisko\nPale-skinned\nPaleta\nPalette\nPalin\nPalinuro\npalm\nPalma\nPalmas\nPalmer\nPalmer's\nPalmistry\nPalmita\nPalms\nPaloma\nPalomino\nPalooza\nPALOOZAAAAWESOME\nPals\nPaltrova\nPaltrova's\nPalvotra's\nPam\nPamela\nPamela's\nPamella\nPampas\npamper\nPampered\npampering\nPampers\nPam's\npan\nPanacea\nPanamanian\npanaroma\nPanax\nPanaxeia\nPancake\nPancakes\nPancake-tipped\nPanchina\nPancho\nPanda\nPANDAMONIUM\nPandas\npandemic\nPandemonium\nPandora\nPandoras\nPandora's\nPang\nPanic\nPanic's\nPanky\nPanni\nPanocha\nPanorama\npanoramic\npans\nPantera\nPanteras\nPanth\npanther\nPanthera\nPanthera's\npanthers\nPantie\nPantied\npanties\npanties?\npanties_down\nPanties2\nPantiesplay\nPantiessocks1\nPantiesWe're\nPantihose\nPantiless\nPanting\nPantomime\npants\nPants?\npants_prank_gone_wrong\nPantsing\nPantsuit\npanty\nPantyheels1\nPantyheels2\nPantyHOES\npantyholicious\npantyhose\nPantyhosed\nPantyhoser\npantyhoses\nPantyless\nPanty-less\nPantymaniac\nPantyMan's\nPantyplay\nPantys\nPanty-Sniffer\nPantyspread\nPantystuff\nPao\nPaola\nPaola's\nPaouk\nPapa\nPapá\nPaparazzi\nPapas\nPapaveri\nPapavero\nPapaya\npaper\nPaperboy\nPaperhanger\nPapers\nPapertrail\nPaperwork\nPapi\nPapi's\nPappa\npaps\nPar\npara\nParable\nParachute\nParada\nParade\nparaded\nParades\nParadis\nParadisaic\nparadise\nParadise's\nParadisiac\nParadiso\nParamake love\nParaguayan\nparalegal\nParalized\nParallel\nParalyzed\nParamedic\nParamedic's\nParamour\nParamours\nParanoid\nParanormal\nParasol\nParasol1\nParasol2\nParcker\nParcker's\nPardon\nParecian\nParent\nParental\nParenting\nparents\nParent's\nParents?\nParent-Teacher\nPareo\nParfait\nParis\nParis?\nParisch\nParish\nparisian\nPariss\npark\nParke\nParked\nParker\nParkers\nParker's\nParkin\nparking\nParks\nParlez\nParliamentary\nparlor\nparlour\nParodies\nParody\nparole\nparolee\nParque\nParrish\nparrot\npart\npart1\nPart-1\npart2\nPart-2\npart3\nPart-3\nPart4\nPart-4\nParte\nparted\nPartia\nPartialism\npartially\nParticipant\nParticipates\nparticularly\nPartie\nPartiers\nParties\nParting\nPartly\npartner\npartners\nPartner's\npartnership\nparts\nPart-Time\nparty\nparty?\nPartyConsort\nPartyDiamonds\nParty-Exploring\npartygirl\nPartygoer\nPartygoers\npartying\nparty's\npartys_over\nPartytime\nParusa\nParvati\nParvin\nPary\nParys\npas\nPA's\nPasa\nPasarie\nPascal\nPaseia\nPasha\nPasion\nPasión\nPasito\nPason\npbooty\npbootyage\nPbootyed\npbootyenger\npbootyengers\npbootyes\nPbootying\npbootyion\npbootyional\npbootyionate\npbootyionated\npbootyionately\nPbootyion-bound\nPbootyione\nPbootyion-filled\nPbootyion-HD\nPbootyionistas\npbootyions\nPbootyion's\nPbootyive\npbootyport\nPbootytime\nPbootyword\npast\npasta\nPastel\nPastels\nPasties\npastime\nPastimes\nPastor\nPastors\nPastries\npastry\npasttime\nPastures\nPasuna\nPat\nPatadas\nPatap\nPataski\npatch\nPat-Down\nPaternal\nPaternity\npath\npath'\nPath\nPathetic\nPathfinder\nPathos\nPaths\nPatick's\npatience\npatient\nPatientia\npatiently\npatients\npatient's\nPatients\nPatient's\npatio\nPatiofun\nPatiopink\nPatiocat\nPatiostrip\nPatiotoy\nPatootie\nPatr\nPatriarchy\npatricia\nPatricia's\nPatrick\nPatrick’s\nPatricks\nPatrick's\npatriotic\nPatriotica\nPatriots\nPatrisha\nPatritcy\nPatrizia\npatrol\nPatron\npatrons\nPats\nPat's\nPatted\nPatti\nPatties\nPattinson\nPatty\nPatty’s\nPattys\nPatty's\nPaty\nPaul\nPaul’s\nPaula\nPaula's\nPaulina\nPauline\nPaulo\nPaul's\nPause\nPavel\nPavers\nPavilion\nPavlina\nPavlova\nPavslut\npawg\nPawn\npaws\nPax\nPaxionalis\nPaxionaria\nPaxioni\nPax's\nPaxton\npay\npayback\nPayback's\nPaycheck\npay-day\nPayday\nPay-For-Play\npaying\nPayless\nPayload\npayment\nPayments\nPayne\npayoff\npays\nPayton\nPayton’s\nPaytons\nPayton's\nPc\nPct\nPD\nPDA\nPDed\npe\nPeac\npeace\nPeaceLove\npeach\npeach?\nPeachbloom\npeaches\nPeachess\nPeachez\nPeach's\nPeachtacular\nPeachtree\npeachy\npea-MEMBER\nPeamember\nPeamembering\nPeak\npeaks\nPea-Nist\nPeanut\nPear\nPear-fect\npearl\nPearlescent\nPearlin\nPearlized\nPearlcat\npearls\nPearl's\nPearly\nPears\npeas\npeasant\nPeasants\nPebblean\nPecan\nPeccadillo\nPeccati\npecker\npecker?\npeckers\nPecks\nPecosa\nPeculiar\nPed\nPedagogue\npedagogy\npedal\nPedaling\nPeddler\nPeddlers\nPeddles\nPedentes\nPedestal\npedestrian\nPedi\nPedicure\nPedicured\nPedro\npeds\npee\nPEEach\npee-cake\npee'd\npeeeeee\npeeing\npeek\nPeekaboo\nPeek-a-boo\nPeek-a-Melon\nPeek-A-Bush\nPeeker\npeekers\npeeking\npeeks\nPeel\nPeeler\nPeeling\nPeels\nPeen\nPeen-ata\npeep\npeeped\nPeepee\nPee-Pee\npeeper\npeepers\nPeeper's\nPeephole\nPeepin\nPeepin'\npeeping\nPeeping-Tom\nPee-pod\nPeeps\nPeepshow\npeer\nPeering\npees\npeeter\nPeEve\nPeevert\nPeg\nPega\npegged\nPegging\nPeggy\npegs\nPeida\nPelba\nPele\nPele's\nPeligro\nPellenia\nPelli\nPelon\nPelt\nPelvic\nPemiha\nPen\nPena\nPeña\nPenado\nPenal\npenalty\nPenance\nPenatration\nPenchant\npenchants\npencil\nPendant\nPendragon\nPendulum\nPendulums\nPenelopa\npenelope\nPenelope?\nPenelope's\nPeneloppe\npeneration\npenetate\nPenetracion\nPenetrando\npenetrate\npenetrated\npenetratedSkull\npenetrates\npenetrating\npenetration\npenetrations\npenetrative\npenetrator\nPenetrators\npenettration\nPenhouse\nPenile\npenis\npenises\nPenisgate\nPenisin\nPenn\nPenned\nPenni\nPenny\nPenny's\npen-pal\nPenpals\nPencat\nPens\nPensando\nPensano\nPensia\nPension\nPent\nPenthouse\nPentola\nPentration\nPent-Up\npeople\nPeoples\nPeople's\nPep\nPepe\nPepes\npepper\npeppering\nPepperoni\nPeppers\nPepper's\nPeppy\nPequeña\nper\nPerceive\nPercent\nperception\nPerceptive\nPerch\nPerchance\nPercolator\nPercy\nPerder\nPerdiendo\nPerdition\nPerdure\nPerez\nPerez's\nperfe\nperfect\nPerfectgift\nPerfecting\nperfection\nPerfectionist\nperfectly\nPerfectcans\nPerfekta\nPerferct\nperfo\nPerforated\nperform\nperformance\nPerformances\nperformed\nperformer\nperformers\nperforming\nperforms\nPerfumada\nperfume\nPerhaps\nperhaps?\nPeridot\nPeril\nPerils\nPerimeter\nperiod\nPeriodical\nPeris\nPeriscope\nPeriscoping\nperk\nPerked\nPerking\nperks\nperky\nPerkyteen\nPerl\nPerla\nPerlea\nPerles\nPermagape\nPermanent\nPermiscuous\npermission\nPermutations\nPerola\nPeron\nPerova\nPerp\nPerpension\nPerpetrator\nPerpetual\nPerpetually\nperra\nPerri\nPerri's\nPerro\nPerry\nPerrys\nPerry's\nPerryVision\nPers-Anal\nPerscribes\nPersevere\nPersevering\nPersia\nPersian\nPersia's\nPersiko\npersimmon\nPersine\nPersist\nPersistant\npersistence\nPersistent\nperson\nPersona\npersonal\nPersonalities\npersonality\nPersonalized\nPersonally\nPersonified\nPersonnels\nPersons\nperspective\npersuaded\npersuades\nPersuadiendo\nPersuading\nPersuajaun\npersuasion\nPersuasive\nPersuation\npert\nPertu\nPerturbed\nPeruvian\nperv\nPerv-Anon\nPerved\nperverse\nperversion\nperversions\nperversity\npervert\nPervertables\nperverted\nPervertido\nperverts\nPervesion\nPervin\nPerving\npervs\nPerv's\nPERVsonal\npervy\nPesaro\nPeso\nPesquisa\npest\nPestering\npet\nPeta\nPeta’s\nPetal\nPetals\nPeta's\nPetch\nPete\npeter\nPeters\nPeter's\nPetersburg\nPeterson\nPete's\npetetration\nPet-Ho\nPetina\npecan\nPecana\npecane\nPecanes\nPecane's\nPecaneteen\nPecanion\nPecanioner\nPecanioners\nPecanioning\npecanions\nPecans\nPetr\nPetra\nPetram\nPetra's\nPetraska\nPetrify\nPetrin\nPetrol\nPetronela\nPetrov\nPetrova\nPetrovicky\npets\nPetshop\npetting\nPetty\nPetulance\nPeyton\nPezzini\nPF\nPF's\nPhallic\nPhallus\nPhangasm\nPhantasm\nPhantom\nPharma\nPharmacist\nPharmacy\nPharoah's\nPhase\nPhases\nphat\nPhat-Booty\nPhat-Bootyed\nPhatjohnson's\nPhatt\nPhaver\nPhD\nPhJohnson\nPhelpz\nPhenix\nPhenix's\nPhenom\nphenomenal\nphenomenon\nPhenominal\nPheona\nPheromone\nPhet\nPhil\nPhilanderer\nPhilip\nPhilippe\nPhilips\nphillies\nPhillip\nPhillips\nPhillips's\nPhilly\nPhilosophical\nphilosophy\nPhinico\nPhire\nPho\nPhobia\nPhobic\nPhoebe\nphoenix\nPhoenix's\nPhoenixxx\nphone\nPhone?\nPhonebooth\nphoned\nPhoneix\nPhone-order\nPhonecat\nphones\nPhones?\nPhonesex\nPhonestrip1\nPhonestrip2\nPhony\nPhot-ho\nphoto\nPhotomemberied\nPhotocopy\nPhotog\nPhotogenic\nPhotogra-Perv\nphotographer\nPhotographer’s\nphotographer's\nPhotographic\nphotographs\nphotography\nPhotogs\nPhotog's\nphoto-lover\nphotos\nPhotosession\nPhotosessions\nPhotosets\nphotoshoot\nPhoto-Shoot\nPhotoshooting\nPhotoshoots\nPhotosynthesex\nPHP\nphrase\nPhuket\nPhylicia\nPhyllisha\nPhylloma\nphys\nphysical\nphysically\nPhysicals\nPhysician's\nphysics\nphysio\nPhysiotherapy\nphysique\nPi\nPi?\nPiacere\nPiaf\nPiaff\nPiaff's\nPianino\nPianist\nPianists\nPianist's\npiano\npianoand\nPianocat\nPiatz\npic\nPicante\nPicbootyo\nPicco\nPiccole\npick\npicked\npicked-up\nPicker\npickers\nPickin\nPicking\nPickings\npickle\npickles\nPick-Me-Up\nPickpockets\npicks\npickup\npick-up\nPickup\nPick-up\nPickUp\nPick-Up\npickuper\nPickups\nPick-ups\nPicky\npicnic\nPicnicker\nPicnickers\nPimembersso\npics\npicture\nPicture?\npicture_perfect_euro_babe\nPicture-Perfect\npictures\nPicturesque\nPicutre\npie\nPie?\npiece\nPieces\npied\nPieds\nPielda\npier\nPierce\npierced\nPiercedangel\nPierced-cat\npiercing\nPiercingly\npiercings\nPierre\nPierson\npies\nPietra\nPie-Trap\nPietro\npig\nPiggie\nPiggies\npiggy\nPiggyback\npiggyPart\npigiama\nPiglet\nPigs\nPigtail\npigtailed\npig-tailed\nPigtailed\nPig-tailed\npigtails\nPigtailtoy\nPijamada\nPikachu\nPikahoe\nPikaPies\npike\nPikea\npilates\npile\nPiledrive\npiledriver\nPile-Driver\nPiledrives\nPiledriving\nPile-driving\npilgrims\npill\npillow\nPillowcase\nPillowfight\nPillow-Fight\nPillowfighting\npillows\nPillows2\nPillowstoy\nPills\npilot\npilot's\nPilots\nPim\npimp\npimped\nPimpin\nPimpin'\nPimping\nPimpology\nPimp's\npin\npina\nPinata\nPinattas\npinball\nPinch\nPinching\nPinco\nPine\nPineapple\npineapples\nPineda\nPine-etration\nPines\nPing\nPinga\nPingpong\nPingpongos\nPinheiro\npining\npink\nPink?\nPinkbed\nPinkbedplay\nPinkbedtoy\nPinkbikini\nPinkblot\nPinkbra\nPinkbraplay\nPinkchat\nPinkmember\nPink'd\nPinkdance\nPinkbanana\nPinkdot\nPinkdress\nPinkdressshirt\nPinker\nPinkerton\npinkest\nPinkfinger\nPinkfingers\nPinkfloor\nPinkflower\nPinkglbooty\nPinkgstring\nPink-Hair\nPink-haired\nPinkhearts\nPinkheels\nPinkhole\nPinkish\nPinklace\nPinklingerie\nPinklips\nPinklove\nPinkmirror\nPinkmusic\nPink'N\npinkness\nPinknightgown\nPinknighties\nPinko\nPinkpanties\nPinkpanties2\nPinkpanty\nPinkpink\nPinkplaidpanties\nPinkplay\nPinkpleasure\nPinkcat\nPinkrabbit\nPinks\nPink's\nPinksheer\nPinkskirt\nPinkspread\nPinkspread1\nPinksuit\nPinkswirl\nPinkthong\nPinktop\nPinktop2\nPinktoy\nPinktoy1\nPinktoy2\nPinktoyfun\nPinkunder\nPinkundies\nPinkundies1\nPinkundies2\nPinkvibe\nPinkviberator\nPinkvibrator\nPinkvibrator1\nPinkvibrator2\nPinkwhite\npinky\nPinkyvibe\nPinna\npinned\nPinning\nPinos\nPinosea\npins\npint\nPint?\nPinta\nPint-sized\nPintura\npinup\nPin-up\nPinups\nPinup's\nPin-Ups\nPinx\nPioneer\nPiotr\nPiovorno\nPipa\npipe\nPiped\nPipe-ing\nPipeline\npipeperr\nPiper\nPiperfawn\nPipers\nPiper's\npipes\nPipi\nPiping\nPippa\nPiquant\nPiquante\nPiqued\nPiranha\npirate\nPirate0-0The\nPirate12-1The\nPirates\nPirate's\nPirates0-3\nPirates's0-1\nPirelli\nPirez\nPiros\nPiroshka\nPirouette\nPirouettes\npiss\npissed\npisser\npisses\npissin\npissing\nPissings\npissmop\npissMyBootyOff\npissy\npistol\nPistols\nPistol's\nPiston\nPit\npitch\nPitched\nPitcher\nPitchin\nPitching\nPitiful\nPits\nPitstop\nPitt\nPittsburg's\nPity\npix\nPixandVideocom\nPixel\npixie\nPixie-Haired\nPixie's\npizza\nPizzaboy's\nPizza-Delivery\nPizzazz\nPJ\nPjs\nPJ's\npl\npla\nplac\nplace\nPlace?\nPlacebo\nPlaced\nPlacement\nplaceNon-scripted\nPlacer\nplaces\nPlacidus\nPlaciz\nPlad-Skirted\nplaeasing\nPlaga\nPlagebabe\nPlagiarism\nPlagiarist\nplaid\nPlaid2\nPlaiddress\nPlaidfingers\nPlaidskirt\nplain\nplains\nPlaisir\nPlaisirs\nplait\nPlam's\nplan\nPlan-demonium\nPlane\nplanet\nPlanetaria\nplanetary\nplanets\nPlank\nPlanking\nplanned\nplanner\nplanning\nplans\nPlant\nPlantain\nPlanting\nPlantplay\nPlants\nPlasata\nPl-Booty\nPlbootyter\nPlaster\nplastere\nplastered\nPlastering\nplastic\nPlastics\nplate\nPlatered\nPlates\nPlatform\nplatforms\nPlatina\nPlatinum\nPlatinum's\nPlatonic\nPlatoon\nplatter\nPlaud\nPlaudo\nPlavati\nPlavusa\nplay\nPlay?\nplaya\nPlaya'\nPlay-along\nPlayana\nPlayback\nPlayback's\nPlayball\nPlaybook\nplayboy\nplaydate\nPlay-Doh\nPlaydolls\nplayed\nplayer\nPlayer2\nPlayerbanana\nplayers\nPlayer's\nplayful\nPlayfull\nPlayfully\nplayfulness\nPlaygirl\nplayground\nPlay'h'er\nPlayhouse\nPlayin\nPlayin'\nplaying\n'Playing\nPlaying?\nPlayingBoss\nPlayingTeacher\nPlayingwithfood\nPlayingwithherfood\nPlaying-Yoga\nPlayland\nPlaylist\nPlaymate\nPlaymates\nPlayoffs\nPlaypals\nPlayroom\nplays\nPlay's\nplayspace\nPlayStation\nplaything\nPlaythings\nplaytime\nPlaytoy\nplaywith\nPlaywithme\nPlaywithme2\nPlaza\nplea\nPlead\npleads\npleasant\nPleasantly\npleasantries\nPleasantry\nplease\nplease?\npleaseand\npleased\npleased_to_lick\nPleasent\npleaser\nPleasers\npleases\npleasin\nPleasin'\npleasing\npleasur\npleasurable\nPleasurably\npleasure\nPleasure'\nPleasure?\nPleasurebath\npleasured\nPleasure-Filled\nPleasureful\nPleasurehole\nPleasureland\npleasureMbootyive\nPleasurement\nPleasurers\npleasures\nPleasure's\nPLEASURES\nPleasure-Seeker\nPleasuresome\nPleasuretown\nPleasureville\npleasuring\nPleated\npleather\nPlebe\nPlebian\npledge\nPledges\nPledge's\nPledging\nPleezer\nPleiadeans\nPleiades\nPlena\nPlener\nPlenilunia\nplenty\nPlesir\nPlesira\nPlesure\nPletiena\nPlew\npliable\nPliant\nPlight\nPlllllease\nPlomo\nPlot\nPlots\nPloughs\nPlow\nplowed\nPlower\nPlow-Her\nplowing\nPlows\nPloy\nploys\nPluck\nplucked\nPlucking\nplug\nPlugaru\nplugged\nPluggers\npluggin\nplugging\nPlug-In\nplugs\nPlum\nplumber\nplumbers\nplumber's\nPlumbers\nPlumber's\nplumbing\nPlumelet\nplump\nPlumped\nPlumper\nPlumpcans\nPlumpy\nPlundered\nPlundering\nPlunders\nplunge\nplunger\nPlungers\nplunges\nPlunging\nPlural\nplus\nPlush\nPlz\nPM\nPMAO\nPMS\npo\nPoax\npocahontas\nPocajones\nPocha\nPochatonas\npocka\npocket\nPocket?\nPocketrocket\nPockets\npocketSuck\nPockmarks\nPocoho\nPod\nPodcast\nPodcastin\nPodiatric\nPodiatry\nPodium\npoem\npoet\nPoetic\nPoetics\nPoetique\nPoetry\npogo\nPoigne\npoint\npointCan\npointe\npointed\npointer\nPointers\nPointing\npointMade\nPoint-of-View\nPoints\npointy\nPoised\npoison\nPokahontas\npoke\nPoke’Slut\nPoke-a-Dots\npoked\npoke-her\nPokehergeist\nPokemon\nPokecat\npoker\npokes\nPokey\nPok-her\npokin\npoking\nPola\nPolaines\nPolana\nPoland\nPolar\nPolarama\nPolaris\nPolarity\nPolaroid\npole\nPoledance\nPoledancer\nPolena\npoles\nPolette\npolice\nPolice'\npoliceman\npolicemans\nPoliceman's\npolicewoman\nPolicewoman’s\nPolicewoman's\nPolicy\nPolijohnsons\nPolina\nPolina's\npoling\npolish\npolished\npolisher\npolishes\npolishing\nPolite\nPolitical\npolitician\npoliticians\nPolitician's\nPolitics\nPolka\nPolkadotcat\nPolkadots\nPolkadots2\nPoll\nPolla\npollas\nPollinating\nPollinic\nPolls\nPolly\nPolo\nPoltergayst\nPoltrona\npolvo\nPoly\nPolyamor-Booty\nPolyamory\nPolygraph\nPolynesia\nPolynesian\npom\nPomegranaterub\npompadour\nPompino\npoms\nPon\nPonciano\nPond\nPong\nPonies\nPons\npony\nPonytail\nponytails\nPoodle\nPoodle-wacky\nPoofhat\nPooh\nPookluk\npool\npool'\nPool\npool?\npool_boy_peeper\nPoolboy\nPoolboy’s\nPoolboys\nPoolboy's\nPoolbanana\nPoolean\nPoolfun\nPoolguy\nPoolhall\nPool-Hopping\nPoolhouse\nPool-house\nPooling\nPool-Man\nPoolo\nPoolphotoshoot\nPoolplaytime\nPoolcat\nPoolcatBTS\nPools\nPool's\npoolside\nPool-Side\nPOOLSIDE\npoolside_cat_persuasion\nPoolsidebabe\nPoolsidecutie\nPoolsidefingers\nPoolsidefingersBTS\nPoolsidefun\nPoolsideplay\nPoolsideSlut\nPooltable\nPooltime\nPooltowel\nPooltoy\nPooltoys?\nPoolvibe\npoon\nPoonani\nPoonanny\npoondocks\npoonie\npoonies\nPoonjab\nPoons\npoontang\nPoon-Tang\npoop\npooper\nPoophole\npoor\nPoot\npooter\npootie\nPooty\nPooty-tang\npop\nPopcicle\nPopcorn\nPope\nPopes\nPopo\nPopova\npopp\npopped\npopped?\nPoppens\nPopper\nPopperz\nPoppet\npoppin\nPoppin'\nPoppin’\npopping\nPoppins\nPoppy\nPoppy's\npops\npop-shot\nPopshots\npopsicle\nPopsiclefun\nPopsiclecat\nPopsicles\npopu\npopular\npopular?\nPopularity\nPOP-UP\npor\nPorcelain\nPorcelaine\nporch\nPorche\nPorcupine\nPorcupines\nPore\npores\npork\nPorked\nPorkin\nporking\nPorkman\nPorkman's\nporks\nporn\nporn?\npornBut\nPornero\nPornfadential\nPorn-Fantasy\nPornhub\nPornification\nPornisity\nPornland\nporno\nPornocide\nPornogothic\nPornographer\nPornographic\nPornography\nPornoman\nPornomatic\nPornocat\nPornos\nPornoXX\nPorns\nPorn's\nPornsluts\npornstar\nPorn-Star\nPORNSTAR\nPornstar?\nPornstar’s\npornstars\npornstar's\nPornstars\nPornstar's\nPornStars\nPorn-Stars\nPORNSTARS\nPornstarslikeitbig\nPorn-Store\nPorridge\nPorscha\nPorsche\nPorschea\nPorsha\nPorshe\nPort\nPortable\nPorte\nPorter\nportfolio\nPortia\nportion\nPortions\nPortland\nPortman\nPorto\nPortrait\nPortraits\nports\nPortugal\nPortuguese\nPosare\npose\nPoseable\nposed\nPoser\nposes\nPosessions\nPosesta\nPosey\nposh\nPosiente\nposin\nposing\npositiion\nposition\npositionbj\nPositionBrutally\nPositioned\nPositioning\nPositionMake\npositions\nPositive\nPositively\nPossa\nPosse\nPosses\nPossessed\nPossessione\nPossessive\nPossi\nPossibilities\npossible\npossibly\nPossums\npost\nPostal\nPostcard\nPoster\nposterior\nposterity\nPost-Make love\nPost-Game\nPostgirl\nPost-graduate\nPosting\npostion\nPostions\nPost-Jogging\npostman\nPostman's\nPost-Match\npost-orgasm\nPost-Party\nPostponed\nPost-preggo\nPost-Shower\nPosture\nPost-Work\nPost-workout\nPost-Yoga\npot\npotbootyium\nPotato\nPotatoes\npotential\npotient\nPotion\nPot-O-Greens\nPotpouri\nPotpouri2\nPotro\nPotter\nPott'h'er\nPotting\nPotty-Mouth\nPottypants\nPouch\nPoulin\nPounce\nPounces\nPouncing\npound\nPoundable\nPoundage\nPound-A-Thon\npounded\nPounded?\nPounder\npounderosa\nPoundin\nPoundin'\npounding\nPounding101\npoundings\npounds\nPound-town\nPoupos\npour\nPourin\npouring\npours\npout\npouty\npov\nPOV'\nPovfingers\npow\nPowder\nPowell\npower\npower-drilling\nPowered\nPowerMake love\nPower-Make love\nPOWERMAKE LOVE\npower-make loveed\nPowermake loveed\nPower-make loveed\nPowermake loveing\nPower-make loveing\npowerful\nPowerhouse\nPowering\nPowerless\nPower-Plowing\npowers\nPower's\nPowertool\nPOWPounding\nPozo\nPozzi\nPP\nPPP\nPP's\npr\nPra\npractical\nPractically\npractice\nPracticed\npractices\npracticing\nPractise\nPrada\nPradah\nPrado\nPraesto\nPraga\nPrague\nPragues\nPrague's\nPraha\nPrairie\nPraise\nPrance?\nprancing\nprank\nPranked\nPrankers\nPranking\npranks\nPrankster\nPranksters\nPrankster's\nPrat\nPrather\nPratt\nPray\nprayer\nprayerMade\nPrayers\nPraying\nPrays\nPre\nPreacher\nPreachers\nPreacher's\nPrecarious\nPreceptor\nPrecinct\nprecious\nPrecipitation\nPrecision\nprecocious\nPre-Dance\nPre-Date\nPredator\nPredatory\npredicament\nPredicaments\nPredilection\nPre-Dip\nPreeda\nPreening\npre-facial\nprefer\npreference\npreferences\nPreferential\nPreferred\nprefers\nPre-Festival\nPre-Fiesta\nPre-Folsom\nPre-make love\npre-make loveing\npre-game\nPreggers\nPreggo\nPregnancy\npregnant\nPregnant?\nPrego\nPreguntas\nPre-Halloween\nPreheated\nPre-Honeymoon\nPrejudice\nPrelim\nprelude\nPremarriage\npremature\nPremier\nPremiere\nPremisses\npremium\nPrensley\nPrenup\nPre-Nup\nPre-op\nPrep\npreparation\npreparations\nprepare\nprepared\nPreparedness\nprepares\npreparing\nPre-Party\nPreperation\nPrepped\nPrepper\nPreppies\nPrepping\nPreppy\npreps\nPrerequisite\nPrerogative\nPres\nPre-Scene\npreschool\nPrescience\nPrescilia\nPrescott\nprescribe\nprescribed\nprescribes\nprescription\nPre-Season\nPresence\npresens\npresent\npresentation\nPresented\nPresenting\npresents\nPresentsKelly\nPreservation\nPreserve\nPreserved\nPreseting\npresets\nPre-Sex\nPresident\nPresidential\nPresident's\nPreslee\nPresleigh\nPresley\nPresley's\npress\nPressed\nPressing\nPressley\nPressued\npressure\nPressure’s\nPressured\nPressures\nPrestatie\nPrestigious\nPresto\nPreston\nPrestons\nPreston's\npre-study\nPresume\nPretel\npretend\npretending\npretends\nPretiosa\nPreto\nPre-Trip\nprettier\npretties\nPrettiest\nPrettily\npretty\nPretty'\npretty_pink\nPrettyDirty\nPrettyinpink\nPrettyman\nPrettypanties\nPrettypink\nPrettycat\npretzel\nPre-vacation\nprevent\nPreventive\npreview\nPre-Wedding\nPre-Workout\nprey\nPrezotte\nPreztelible\nPriapus\nprice\npriceless\nPrice's\nPricey\nPricilia\nPricilla\nPricing\nprick\npricked\npricker\nPrickie\nprickin\nPricking\nPrickly\nPricknabber\npricks\npride\nPRIER\npriest\nPriests\nPrim\nPrima\nprimal\nPrimary\nPrimas\nPrimavera\nprime\nprimed\nPrimer\nPrimera\nPrimetime\nPrimia\nPrimias\nPrimp\nPrimped\nPrimping\nPrince\nPrinces\nPrince's\nprincess\nprincesses\nPrincesseven\nPrincesspleasure\nPrincess's\nprincipal\nPrinci-Pal\nprincipals\nPrincipal's\nPrinciple\nPrinciples\nprint\nprints\nPrinzzess\nprior\nPriorities\npriority\nPriro\nPriscila\nPriscilla\nPrisclia\nPriscylla\nPrise\nPrism\nprison\nprisoner\nprisoners\nPrisoner's\nPrissy\npristine\nPristine’s\nPristy\nPritty\nPriva\nprivacy\nPrivada\nPriv-Bootyy\nprivate\nPrivates\nPrivate's\nPrivation\nprivilege\nPrivileged\nPrivileges\nPrix\nPriya\nPriyas\nPriya's\nprize\nPrized\nprizepound\nprizes\npro\nprobability\nProbable\nprobably\nPro-Baller\nProbando\nProbation\nprobe\nProbe-Ation\nprobed\nprobes\nprobing\nproblem\nProblemo\nproblems\nProblem-Solver\nProcedure\nprocedure?\nprocedures\nProceed\nprocess\nProcessing\nProclaimed\nProclivity\nprocrastinate\nProcrastination\nProcrasturbating\nProcurer\nprod\nprodded\nprodessional\nProdigal\nprodigy\nproduce\nproducer\nProducers\nProducer's\nproduces\nProduct\nproduction\nProductions\nProductive\nProductos\nProducts\nProf\nProfanity\nProfesor\nProfess\nprofession\nprofessional\nProfessionalism\nProfessionals\nprofessor\nProfessorial\nprofessor's\nProfessors\nProfessor's\nProficient\nprofile\nProfilin\nprofit\nProfiteering\nProfits\nProfound\nProfundamente\nProfundo\nProfusely\nprogram\nProgrammed\nProgramming\nProgress\nProgression\nProhibida\nProhibition\nproject\nProject?\nprojectile\nProjecting\nProjection\nProjections\nProjector\nProkopi\nPROLAPS\nprolapse\nProlapsed\nprolapsefisting\nProlapses\nProlapsing\nProletariat\nProlonged\nprom\nPromenade\nPromesita\nProminent\nPromiscuous\npromise\npromised\npromises\npromising\nPromo\npromoted\nPromoter\nPromoting\npromotion\nPromotional\nPromotions\nprone\nprong\nPronia\nPronto\nproof\nProp\npropa\nPropellant\nPropelled\nproper\nproperly\nproperty\nProphetic\nProportioned\nProportions\nproposal\nPropose\nProposes\nProposing\nProposition\nPros\nProse\nProspect\nprospects\nProsper\nprostate\nProsthetic\nproscanute\nProscanute?\nProscanutes\nprotect\nprotecter\nProtecting\nprotection\nProtective\nProtector\nProtects\nProtege\nProtegee\nprotein\nprotest\nProtestor\nProtocol\nProtocols\nPrototype\nProtuberances\nproud\nproudly\nProvance\nprove\nproved\nprovement\nProven\nProverbs\nproves\nprovide\nProvided\nProvider\nprovides\nProviding\nProving\nProvision\nProvita\nProvocateur\nProvocation\nprovocative\nProvocatively\nProvoked\nprovokes\nprovoking\nPrower\nProwess\nprowl\nProwler\nProwling\nProwls\nProxy\nPrrrrrNelli\nPrude\nPrudes\nPrudish\nPruning\nPrurience\nPrurient\nPryce\nprying\nPS\nPs-al\nPseudo\nPsico\nPsicologa\nPSL\nPsych\nPsyche\nPsychedelic\npsychiatrist\nPsychic\nPsy-Chic\nPsycho\nPsycho-Anal-ized\nPsychodrama\nPsychological\nPsychologist\nPsychology\nPsycho-Medical\nPsychopathic\nPsychosexual\nPsychotherapist\nPsychotherapy\nPsychotic\nPsylocke\npt\npt1\nPt-1\npt2\npt3\nPt4\nPT5\nPT6\nPTA\nPTM\nPu\npub\nPube-less\npubic\npublic\nPublicfinger\npublicly\nPubliccat\nPublisher\nPuck\nPucker\npuckered\nPucks\npudding\npuddle\npuddles\nPudera\nPudgy\nPUEDO\nPuertas\nPuerto\nPuff\nPuffed\npuffies\npuffy\nPuffy-Nippled\nPuffynipples\nPuffycat\nPuffycans\nPuh\npuke\nPulaski\nPulcher\npull\npulled\npuller\nPullin\npulling\nPullout\npulls\nPullum\nPulmonary\nPulp\npulsates\npulsating\nPulsation\npulse\npulsing\npulverized\nPulverizer\nPuma\nPuma's\npumbas\nPumkin\nPummel\npummeled\nPummeling\npummels\npump\nPump?\nPumpage\npumped\nPumper\nPumpin\nPumpin'\npumping\nPumpings's\nPumpkin\npumpkins\npumps\nPumptoy\nPun\npunani\npunch\nPunching\npunish\nPunishable\npunished\npunisher\npunishes\nPunish-make loveing\npunishing\npunishment\npunishments\npunk\nPunked\npunk-emo\nPunking\nPunkrock\nPunk-rock\npunks\nPunk's\nPuño\npunshied\nPunt\npuntang\nPuntas\nPunter\nPunts\nPunxxx\nPuny\nPUNYtive\nPunzel\nPup\nPupae\npupil\nPupil's\nPuppet\nPuppeteer\nPuppetcat\nPuppets\nPuppies\npuppy\nPups\nPuraj\npurchase\nPurchased\npurdy\npure\nPureheart\nPurely\nPurgatory\nPurge\nPurged\npurging\nPuri\nPurification\nPurified\nPurify\nPurin\nPurissima\npurity\npurple\nPurple2\nPurplebath\nPurplebanana\nPurpledong\nPurplenails1\nPurpleplay\nPurplepower\nPurplecat\nPurpleribbon\nPurples\nPurplething\nPurpletoy\nPurpletoy2\nPurplevibe\nPurplevibrator\nPurplewand\npurpose\npurposes\npurr\nPurrfect\nPurr-fect\nPurring\nPurrr\nPurrrfect\nPurrrrfect\npurrrring\npurrrrr\nPurrrrrrrrrr\npurrrs\npurrs\nPurse\nPursuajon\nPursued\nPursuing\nPursuit\nPursuits\nPurveyors\npus\nPus-C\npush\npushed\nPusher\npushes\nPushin\npushing\nPushover\nPush-Up\npushy\npuss\nPussan\nPusscriptions\nPussie\ncats\ncats?\nPussika\ncat\nP-U-S-S-Y\ncat?\ncat_party\nCat’s\ncatand\ncatAriel\nCatback\nCatbeads\nCatbottomboy\nCatcam\ncatcat\nCatcats\nCatcats'\nCatcat's\nCatchat\nCat-coaching\nCatcraft\nCatbanana\ncat-driller\nCat-eaters\nCatfinger\nCatfingers\nCatfoot\nCatfooting\nCat-Make love\ncatmake loveer\ncat-make loveing\nCatfun\nCatgram\nCathole\nCatinboots\ncatJane\nCatkat\ncatlicious\ncat-licking\nCatlicking\nCatlove\nCat-loving\ncatMade\nCatmania\nCatmans\nCatman's\nCatmobile\ncatnice\nCat-O\nCatology\nCat-pierced\nCatpink\nCatplay\ncatplays\nCat-pleasing\nCatplug\nCatpoppin\nCatrub\ncats\nCat's\nCatspread\nCat-Squirting\nCattalk\nCattease\nCattive\nCat-To-Face\nCat-To-Mouth\nCattouches\nCattoy\nCatTS\nCatvibe\nCatville\nPusua\nPusya\nput\nputa\nPutachen\nPutang\nPutas\nPucana\nPut-Out\nputs\nputt\nPutter\nPuttin\nputting\nPuttputt\nPutt-Putt\nPutts\nputty\nPuurrfect\nPuzzle\nPuzzled\nPuzzling\nPVC\nPwn'd\nPWND\nPwns\nPyjama\nPyr\nPyrah's\nPyramid\nPyromaniac\nPysch\nPython\nPython's\nQ\nQ\tWhat\nQAP\nQB\nQelibar\nQt\nQu\nQuad\nquadruple\nquadruple-zippered\nQuake\nQuakes\nQuakin\nQuaking\nQualifications\nQualified\nQualified?\nQualified???\nqualities\nquality\nQuandary\nQuancany\nquantum\nQUAP\nQuaran-babes\nQuaranKink\nquarantine\nQuarantined\nQuarrel\nquarry\nQuarter\nquarterback\nQuartered\nQuarter-Final\nquarterfinals\nQuarterly\nQuarters\nQuartet\nQuatre\nque\nQuebec\nQuebecois\nQuebecoise\nQuedate\nQuédate\nQueef\nQueefing\nqueef-o-matic\nqueen\nQueen?\nQueen-Brittany\nQueendom\nQueenFormer\nQueen-Kristal\nQueenPin\nqueens\nQueen's\nQUEENS\nqueer\nQuena\nquench\nquenched\nQuenchers\nquenches\nQuenching\nQuentin\nquest\nQuesta\nQuesting\nquestion\nQuestion?\nQuestionable\nquestioned\nquestions\nQuetesh\nqui\nquic\nquick\nquick?\nquickener\nQuickest\nquick-fire\nquickie\nQuickies\nQuickiy\nquickly\nQuicksand\nQuicksuck\nquicky\nQuid\nQuien\nQuieres\nQuiero\nquiet\nquietly\nquim\nquims\nQuin\nQuincy\nQuinn\nQuinno\nQuinns\nQuinn's\nQuintero\nQuinteros\nQuinton\nquirky\nquit\nquite\nquits\nQuitters\nQuitting\nQuiver\nQuiverin\nquivering\nquivers\nQuixote\nQuiz\nQuo\nQuota\nQuote\nQutie\nr\nra\nrabbit\nRabbit?\nRabbitbed\nRabchickat\nRabbitclit\nRabbitcream\nRabbitmake loveer\nRabbitfun\nRabbitfun1\nRabbithole\nRabbitlove\nRabbitplay\nRabbits\nRabbit's\nRabbittime\nRabbittoy\nRabbittoyBTS\nRabbitvibe\nRabiona\nrace\nRacer\nRacers\nRaces\nRacetrack\nRaceway\nRacey\nRachael\nRachaels\nRachael's\nRacheal\nRachel\nRachele\nRachele's\nRachell\nRachelle\nRachels\nRachel's\nRachida\nRachyda\nracial\nracing\nRacist\nrack\nracked\nRack'em\nRacket\nRackin\nRacking\nRacks\nRacquet\nRacquetball\nracy\nRada\nRadar\nRadek\nRader\nRadeva\nRadiance\nradiant\nRadiate\nRadiating\nRadical\nRadically\nRadient\nRadi-ho\nRadikal\nRadio\nRadioShag\nRadislava\nRadka\nRadke\nRadra\nRady\nRae\nRaee\nRaelynn\nRaelynn's\nRae's\nRafaela\nRafaeli\nRafaella\nRafail\nRafealy\nRaffaella\nRaffle\nRaft\nrag\nRagan\nragdoll\nRag-Doll\nrage\nRager\nrages\nRaggi\nRagin\nraging\nRags\nRahda\nRahnie\nRah-Rah\nRahyndee\nRahyndee's\nRai\nraid\nRaided\nRaiden\nRaider\nRaiders\nRaiding\nraids\nRaiin\nRail\nrailed\nRailen\nRailin\nrailing\nRails\nrailway\nRaily\nrain\nRaina\nRainb\nRainblow\nRainBlow4k\nRainboot\nrainbow\nRainbows\nRainbow's\nRainbowstripes\nRainbox\nRainCan\nRaincheck\nRaincoat\nRaine\nrained\nRained-Out\nRaines\nRainfall\nRaini\nRainia\nraining\nrains\nRain's\nrainy\nRaisa\nRaisa's\nraise\nRaised\nraiser\nraises\nRaising\nRaison\nRaissa\nRaissas\nRaje\nRajni\nRakel\nRaketa\nRako\nRally\nRalph\nRam\nRama\nRamalama\nRambina\nRambler\nRambling\nRambunctious\nRamdy\nRami\nRamirez\nRamiro\nrammed\nRammer\nRammin'\nramming\nRamon\nRamon’s\nRamona\nRamondini\nRamone\nRamons\nRamon's\nRamos\nRamp\nRampage\nRampaging\nRampant\nRampling\nRamsey\nRamu\nRamzi\nRamzi's\nRanae\nRanasim\nranch\nRancher\nRancher's\nRancho\nRand\nRandall\nRandi\nRandii\nRando\nrandom\nRandomActsOfBeejs\nrandy\nRane\nrang\nrange\nRangel\nranger\nRangers\nRank\nranked\nRanked13TH\nRankedThe\nrankedvsMonica\nRankings\nranks\nRanok\nRansom\nRant\nranting\nRaoni\nRap\nRapace\nRapaces\nRaphael\nRaphaela\nRaphaella\nRaphael's\nRapid\nrapper\nRaptor\nrapture\nRapturous\nRaquel\nRaquelle\nrare\nRaree\nRaree-Show\nraring\nRarite\nRarity\nRasberry\nrascal\nRascals\nRashae\nRasmali\nrastasex\nrat\nRate\nRated\nRates\nRather\nRating\nRato\nRats\nRattler\nRauemi\nRaul\nRaul's\nraunch\nRaunchiest\nraunchy\nRauncy\nravage\nravaged\nravages\nRavaging\nrave\nraven\nRaveness\nraven-haired\nRavenna\nRavenous\nRavens\nRaven's\nRaven-tressed\nRaver\nRavers\nRaves\nraving\nRavish\nravished\nravishes\nRavishess\nravishing\nraw\nRawdog\nRawdogging\nRaw-Dogs\nRawDp\nRawhide\nRawr\nRaxx\nRaxxx\nRaxxx’s\nray\nRaya\nRayan\nRayana\nRayane\nRayanne\nRayas\nRaye\nRayes\nRaye's\nRayj\nRaylene\nRaylene's\nRayles\nRaylin\nRaylyn\nRaymond\nRayndee\nRayne\nRayne?\nRayneAnal\nRayneChained\nRaynes\nRayne's\nRAYNES\nRayno\nRays\nRayssa\nRayven\nRayveness\nRayvenness\nRayye\nRaz\nRaziona\nRB\nRC\nrd\nRD1-RD4\nRD2\nRD3\nRD3?\nRD4\nRDWho\nRe\nrea\nreach\nReacher\nreaches\nReaching\nReacquainted\nreact\nReaction\nReactions\nreacts\nRead\nRead?\nreader\nReaders\nreadhead\nReadheads\nreading\nreading?\nreads\nready\nready?\nReagan\nReagans\nReagan's\nReagonomical\nreal\nReal?\nReal??\nReal-Meloned\nRealdoll\nRealease\nRealest\nrealise\nRealism\nRealiteen\nreality\nReality?\nRealizations\nrealize\nrealizes\nreal-life\nreally\nRealm\nRealms\nReal-State\nrealtime\nrealtor\nRealtor’s\nrealtor's\nRealty\nReam\nreamed\nReaming\nreams\nReaper\nReaping\nrear\nRear-end\nRear-Ending\nRearranged\nrears\nRearview\nreason\nreasons\nrebootyure\nReavealing\nReball\nRebate\nRebeca\nRebecca\nRebecca’s\nRebeccas\nRebecca's\nRebeka\nRebekah\nRebekka\nrebel\nRebel'\nRebel’s\nRebelious\nRebell\nrebellion\nRebellious\nRebels\nRebel's\nRebirth\nRebka\nRebooting\nReborn\nRebote\nrebound\nRebounding\nRebounds\nRebumal\nRecall\nRecalling\nRecan\nRecapture\nrece\nreceive\nReceived\nreceiver\nreceives\nreceiving\nRecente\nRecently\nReceptacle\nReception\nReceptionist\nRecession\nRecibir\nrecieve\nrecipe\nRecipes\nRecipient\nReciprocal\nReciprocity\nRecital\nReckless\nRecklessness\nReckoning\nReclamacio\nReclamation\nRecline\nReclined\nReclines\nRecluse\nReco\nrecognise\nrecognises\nRecognition\nRecognize\nrecognized\nRecollect\nRecollection\nrecommendation\nRecommended\nRecompencen\nReconcile\nReconciled\nReconciliation\nRecondite\nReconditioning\nReconnaissance\nReconnecting\nReconnects\nReconsider\nReconstruct\nReconstructed\nreconstruction\nrecord\nRecorda\nRecord-Breaking\nrecorder\nrecording\nRecording?\nrecordings\nRecovery\nRecrea\nrecreation\nRecreational\nRecreations\nRec-room\nrecruit\nrecruiter\nRecruiterAshlynn\nRecruiters\nRecruiting\nRecruitment\nRecruits\nrectal\nRectally\nrectum\nRecuperation\nRecycled\nrecycles\nRecycling\nred\nRed’s\nRedbed\nRedbones\nRedbra\nRedbra2\nRedBrutal\nRedcape\nRedcarpet\nRedchair\nredcoats\nRedd\nRed-D\nRedjohnson\nRedbanana\nReddress\nRedecorate\nRedecorators\nRedeeming\nRedefined\nRedefining\nredemption\nRedfingers\nred-haired\nRedhaired\nRed-haired\nred-handed\nred-hat\nredhead\nred-head\nRedhead\nRed-Head\nREDHEAD\nRedhead Siri Gets\nRedhead’s\nredheaded\nRed-headed\nRedhead-Next-Door\nredheads\nredhead's\nred-heads\nRedheads\nRedhead's\nred-hot\nRedhot\nRed-hot\nRediscovers\nRedlight\nRedlingerie\nRedlipstick1\nRedlipstick2\nRedlipstick3\nRedmond\nRedmond's\nRedneck\nRednecks\nRedo\nRe-Do\nRedolence\nRedpearls\nRedplaid\nRedpolkadots\nRedred\nRedress\nRedroom\nReds\nRed's\nRedshoes\nRedshoestoy\nRedshoestoy2\nRedstockings\nRedtease\nRedtoy\nReduce\nreduced\nReduction\nReduction?\nRedux\nRedvibe\nRedvibrator\nRedwoods\nREDY\nRedz\nReece\nReece's\nReed\nReeder\nReed's\nReedThe\nReeducation\nReedy'?\nReeka\nReel\nReelin\nreeling\nreels\nReena\nReenacting\nReenactment\nRe-enactment\nReena's\nReenergize\nRees\nreese\nReese-On\nReese's\nReeves\nref\nRefer\nReferee\nReference\nReferral\nReferred\nRefined\nRefining\nReflected\nReflecting\nreflection\nReflections\nreflex\nReflexes\nReflexology\nReflexXxions\nReform\nReformed\nReformer\nrefreshed\nRefreshers\nRefreshing\nrefreshment\nRefreshments\nRefreshness\nRefrigerated\nrefuge\nrefugees\nRefund\nRefunds\nRefurbished\nrefuse\nrefused\nRefuses\nRegal\nRegalo\nRegan\nRegaño\nregards\nRegelli\nReggeaton\nReggie\nRe-Gifted\nRegina\nRegina's\nRegine\nRegistered\nRegistrar\nRegistration\nRegistry\nReglas\nRegon\nRegreso\nRegret\nRegretless\nRegrets\nRegretted\nregula\nregular\nRegular?\nRegular's\nrehab\nRehabilitation\nrehearsal\nrehired\nRei\nReich\nReid\nReid's\nReif\nReif's\nReighlei\nReign\nReigning\nReignite\nReigniting\nReigns\nReign's\nReiki\nReiko\nReiko's\nReilly\nreimbursement\nRein\nreina\nReines\nReinforcement\nReins\nReinvented\nReinventing\nReislin\nReject\nRejected\nRejects\nRejoice\nRejuvenate\nrejuvenated\nRejuvenating\nReka\nRekindle\nRekindled\nRekindles\nRekindling\nrel\nRelación\nRelajar\nRelated\nRelation\nrelations\nrelationship\nrelationships\nRelative\nRelatively\nRelatives\nrelaunch\nRelaunch-12\nRelaunch-13\nRelaunch-14\nRelaunch-15\nRelaunch-16\nRelaunch-17\nrelax\nrelaxation\nrelaxed\nrelaxes\nrelaxin\nrelaxing\nrelaxology\nRelaxurbation\nRelay\nrelease\nrelease”\nReleased\nReleaser\nreleases\nReleasing\nrelentless\nrelentlessly\nRelexed\nrelict\nrelief\nrelieve\nRelieved\nreliever\nrelieves\nRelieving\nReligion\nreligious\nReliqua\nRelish\nRelive\nrelives\nReliving\nReload\nreloaded\nRelota\nReluctant\nRem\nRema\nremain\nRemains\nremarkable\nRemaster\nRemasterBlonde\nRemasterd\nremastered\nRemasteredFirst\nrematch\nremediation\nRemedies\nremedy\nrememb\nremember\nRememberence\nremembering\nremembers\nRemi\nRemind\nRemindal\nReminder\nReminding\nRemington\nReminisce\nReminiscence\nRemission\nRemix\nremote\nRemote-Control\nRemotely\nremoval\nRemovals\nRemove\nremoved\nremoves\nRemoving\nRemsteredYoga\nRemy\nRemys\nRemy's\nRena\nRenae\nRenae's\nRenaissance\nRenata\nRenata's\nRenate\nRenato's\nRendered\nRendevous\nrendez\nrendezvous\nRendez-vous\nRene\nrenee\nrenegade\nRenewal\nRenewed\nRenewing\nRenitent\nRenna\nRennt\nReno\nRenovate\nRenovation\nrenovations\nRenovator\nRenowned\nrent\nRent?\nRent’s\nRenta\nRent-A-Ho\nrental\nRent-A-Pornstar\nRented\nRenters\nRenter's\nRenting\nrents\nRent's\nRenyolds\nReoccuring\nReon\nrep\nrepaid\nrepair\nrepaired\nrepairman\nrepairman's\nrepairmen\nrepairs\nreparation\nreparations\nRepay\nRepaying\nrepays\nRepeat\nRepeated\nRepeatedly\nReplace\nreplaced\nreplacement\nreplaces\nReplacing\nReplay\nReplenishing\nReplica\nReply\nRepo\nRepopulate\nreport\nreporter\nReporting\nReppin\nRepresent\nRepresentation\nRepressed\nReprimanded\nReprisal\nReproduction\nreprogrammed\nReprogramming\nReps\nRepublic\nreputation\nRepute\nrequest\nrequested\nrequested*\nRequests\nRequiem\nRequired\nRequirements\nRequires\nre-release\nRe-Released\nRerouted\nReruns\nRes\nResa\nrescue\nrescued\nrescuer\nrescues\nRescuing\nresearch\nResearches\nResearching\nResembles\nResepct\nReservation\nReservations\nreserve\nReserves\nReservoir\nResidence\nresident\nresist\nResistance\nResisting\nResita\nRe-Sized\nResolution\nresolutions\nResolve\nresolved\nResolving\nResonate\nResort\nResorts\nResourceful\nResources\nrespect\nRespectful\nResponding\nresponds\nResponse\nResponsibilities\nResponsibility\nrest\nrest?\nrestaurant\nRestful\nResting\nResting?\nrestless\nRestocking\nReStockings\nRestomake loveers\nRestom\nRestrain\nrestrained\nRestraint\nRestraints\nRestricted\nRestriction\nRestrictions\nrestrictive\nrestroom\nRestwhoreant\nresult\nresults\nResume\nResumed\nResurrection\nResuscitating\nResuscitation\nRetail\nRetake\nRetaliation\nRetato\nRetentive\nRetire\nretired\nRetirement\nRetiring\nRetraction\nRetraining\nRe-Training\nRetreat\nRetribution\nretro\nRetrospect\nretun\nreturn\nreturned\nReturnee’s\nReturning\nreturns\nReturns's\nreturnswith\nreunion\nReuniones\nReunite\nReunited\nReunited?\nReuse\nrev\nRevamped\nReve\nreveal\nrevealed\nrevealedthis\nRevealer\nrevealing\nreveals\nRevees\nRevel\nRevelas\nRevelation\nRevelations\nRevelry\nrevenge\nrevenged\nREVENO\nRevere\nReverence\nReverend\nReverie\nReversal\nreverse\nreview\nReviewer\nReviewers\nReviews\nRevisited\nrevisits\nRevitalized\nrevoir\nRevolt\nRevolution\nRevolver\nRevolving\nrevs\nRevved\nrevving\nreward\nReward?\nrewarded\nRewarding\nrewards\nRewash\nRewind\nre-writes\nRex\nRexian\nRex's\nRey\nReyes\nReyez\nReyka\nReyna\nReynolds\nRey's\nRez\nRezika\nRhannion\nRhapsody\nRharri\nRheanna\nRheina\nRhet\nRhetica\nRhett\nRhianna\nRhiannon\nRhinestone\nRhoades\nRhoades’\nRhoades's\nRhodes\nRhodes’\nRhodes's\nRhombus\nRhonda\nRhound\nRhrianna\nRhyder\nRhyese\nRhylee\nRhylee's\nRhymes\nRhyse\nRhysee\nRhythm\nRhythmic\nRi\nRia\nRiall\nRiana\nRiante\nRia's\nRiazi\nRib\nRibald\nribbed\nribbing\nribbon\nRibbons\nRibeiro\nRibetza\nRibon's\nRibs\nRica\nRican\nRicarda\nRicardo\nRicardo's\nRica's\nRicci\nRicci's\nRicco\nRice\nrich\nRichard\nRichards\nRichardsen\nRichardson\nRiche\nRichele\nRichelle\nRichelle's\nRiches\nRichey\nRichie\nRichman\nRichness\nRich's\nrichter\nRichy\nRick\nRicki\nRickie\nRickshaw\nRicky\nRicky's\nRico\nRicosf\nRicshaw\nrid\nRida\nRidden\nRidding\nRiddle\nRiddle's\nride\nride?\nride_with_me\nRide'em\nRideEm\nRide-Her\nRide-Him\nrider\nRide'r\nRider?\nRiders\nRider's\nrides\nrideshare\nRidge\nRidged\nRidgedbanana\nRidgemont\nRi-Johnson-ulous\nRidiculed\nRidiculous\nRidiculously\nRidin\nRidin'\nriding\nriding?\nriding_dirty\nRied\nRieds\nRiesling\nRifles\nrig\nRiga\nRigged\nRigh\nright\nright?\nrighteous\nrighteously\nrighteousness\nrights\nRigid\nRígido\nRigin\nRigor\nRigorous\nRigth\nRihana\nRihana's\nRihanna\nRihanna's\nRihannon\nRija\nRiker\nRikk\nRikke\nRikki\nRiled\nRilee\nRiley\nRiley’s\nRiley'd\nRileys\nRiley's\nRileyThe\nRily\nRilynn\nrim\nRima\nRima's\nRimers\nRimes\nrimjob\nrimjobs\nRimma\nRimmed\nRimmer\nRimmers\nrimming\nRimmy\nRims\nRin\nRina\nRinad\nRinaldi\nRina's\nRinata\nring\nRing?\nringer\nringers\nRingetsu\nringing\nRingmaster\nRingpop\nrings\nring's\nRings\nRinialta\nrink\nRinse\nRinsed\nRinseoff\nRinseoff2\nRinsing\nRio\nRion\nRios\nRio's\nRíos\nRiot\nRiotGoth\nrip\nripe\nRipened\nRipera\nRipest\nripped\nRipped-up\nRipper\nRippin\nRipping\nRippling\nrips\nRisa\nRisa's\nrise\nriser\nRisers\nrises\nRisika\nrising\nRisingstar\nRisingstar's\nRisk\nrisks\nrisky\nRisque\nRisqué\nRissa\nRistorante\nRita\nRitam\nRita's\nRitchie\nRite\nRites\nRitina\nRitta\nRitter\nritual\nRitual--Part\nRituals\nRitula\nRitz\nRitzy\nRius\nRival\nrivalries\nRivalry\nrivals\nRival's\nRivas\nRivas's\nriver\nRivera\nRiverboat\nRiveretta\nRiveria\nrivers\nRiver's\nriverside\nRiversThe\nRiviara\nRiviera\nRiviera's\nRix\nRixel\nRiya\nRiyanna\nRiza\nRizzo\nRizzo's\nRJ\nRJs\nRk\nRND\nro\nroad\nRoadblock\nRoad-Head\nRoadie\nRoadies\nRoadmap\nroads\nroadside\nroadtrip\nRoadtripper\nroam\nRoaming\nRoar\nroaring\nRoast\nroasted\nRoasting\nrob\nRoba\nRoba-Vergas\nRobaxaMAKE LOVE\nrobbed\n'Robbed\nrobber\nRobber’s\nrobbers\nrobbery\nRobbie\nRobbie's\nRobbin\nrobbing\nRobbins\nRobby\nRobbye\nRobby's\nRobe\nRobefun\nRobert\nRoberta\nRoberto\nRoberts\nRobert's\nrobery\nRobetalk\nrobics\nRobin\nRobins\nRobin's\nRobinson\nRobles\nRobomember\nrobo-pork\nrobot\nrobotic\nRobotically\nrobots\nRob's\nRobson\nRobust\nrobyn\nRoc\nRoca\nRoccaforte\nRoccia\nRocco\nRocco'\nRocco's\nRoche\nRochelle\nRochelly\nRocheux\nrock\nRockabilly\nRockabooty\nRocke\nrocked\nrocker\nRockers\nRocker's\nrocket\nRocketing\nRocket-Launcher\nRockette\nRockford\nrock-hard\nRocki\nRockie\nrockin\nRockin'\nrocking\nRockledge\nRock'n\nRock'nDoll\nRock-Nipples-Suffer\nrock'n'roll\nRock-N-Roll\nRocknrolla\nrocks\nRockspring\nrockstar\nRockwell\nRockwood\nRockwood's\nRocky\nRocky's\nrod\nRod'\nRodaci\nRodding\nrode\nrodeo\nRodeo's\nRoderick\nRodgers\nRodise\nRodney\nRodrigues\nRodriguez\nRodriguez's\nRodriquez\nrods\nRod's\nRogen\nRogen's\nRoger\nRogers\nRoggie\nrogue\nRoguish\nRoja\nRojas\nRojo\nRok\nRoka\nRokie\nrol\nRoland\nrole\nRolemodel\nroleplay\nrole-play\nRoleplay\nRole-Play\nRoleplaying\nroleplays\nroles\nRolinda\nroll\nRolland\nRolland's\nrolled\nroller\nRollerBlade\nRollerblading\nRollercoaster\nroller-memberster\nRollergasm\nRollergirl\nRollers\nRollerskate\nRollerskating\nRollick\nRollin\nrolling\nrolls\nRoma\nRomain\nRomaine\nRomain's\nRoman\nRomana\nromance\nromanced\nRomance's\nromancing\nRomanetta\nRomani\nRomania\nromanian\nRomanian’s\nRomania's\nRomani's\nRomano\nRomanoff\nRomanova\nRomanova's\nRoman's\nromant\nromantic\nRomantica\nRomantically\nRomantics\nRombicana\nRome\nRomee\nRomeo\nRomeos\nRomero\nRomi\nRomi’s\nRomina\nRomi's\nrommamates\nRommmate\nromp\nRompe\nRomper\nRompers\nRompin\nromping\nRomy\nRon\nRoncero\nRonda\nRonda's\nRondeur\nRone\nRone's\nRonita\nRonni\nRonnie\nRon's\nRonta\nRoo\nroof\nRoofer\nrooftop\nRoof-Top\nRooftopcans\nRook\nrookie\nrookieBrutal\nrookies\nRookie's\nroom\nRoom?\nroomate\nRoomates\nRoomfull\nRoomie\nroomies\nroommate\nRoommate’s\nroommates\nroommate's\nRoommates\nRoommate's\nrooms\nRoomy\nrooster\nroosters\nRoot\nRooting\nroots\nrope\nRopebaby's\nroped\nropeMade\nropeOrgasms\nRopeplay\nRoper\nropes\nRope-Tied\nRoping\nRoquero\nRorie\nRorschach\nRory\nRosa\nRosae\nRosalie\nRosalina\nRosalyn\nRosana\nRosanna\nRosano\nRosario\nrose\nRose’s\nRosea\nRoseanna\nrosebud\nrosebuds\nRosebud's\nRosebum\nRoseDay\nRose-Day\nRosee\nRoseias\nRose-Live\nRosella\nRosemary\nRosen\nRosen's\nRosepool\nRosero\nroses\nRose's\nRoses's\nRosetta\nRosewood\nRosey\nRoshell\nRosie\nRosies\nRoslita\nRoss\nRossa\nRossella\nRossella's\nRosses\nRossi\nRossi's\nRossi-Vision\nRosso\nRostik\nRoswell\nrosy\nRosy-white\nRosz\nRotaco\nRotating\nROTC\nRoth\nRotisserie\nRoto-Drilled\nRotten\nrotten_experience_at_the_strip_club\nRotten's\nRotten-Uncontrollable\nRotterdam\nRotts\nrotund\nRouge\nrough\nroughed\nRougher\nroughest\nRough-make loveing\nrough-make loves\nroughly\nRoulette\nround\nRound?\nRoundbooty\nRound-booty\nRounded\nRoundest\nRoundPerfect\nrounds\nround-up\nRoundup\nRound-up\nRouse\nrousing\nRouso\nRousse\nRousso\nRoute\nroutine\nroutines\nroutins\nRoux\nroving\nrow\nRowan\nrowdy\nRowe\nRowen\nRowena\nRox\nRoxana\nRoxana's\nRoxane\nRoxanna\nRoxanne\nRoxanne's\nRoxee\nRoxee's\nRoxetta\nRoxette\nRoxi\nRoxie\nRoxii\nRoxina\nRoxing\nRoxi's\nRoxx\nRoxxi\nRoxxie\nRoxxx\nRoxxxie\nRoxxx's\nRoxxxy\nRoxxy\nRoxy\nRoxy's\nRoy\nRoy’s\nroyal\nRoyalblue\nRoyale\nRoyally\nroyalty\nRoyalty's\nRoyce\nRoy's\nRoz\nRoza\nRozas\nRoze\nRozen\nRozker\nRPG\nRPMs\nRR\nRrrrock\nRrrrrip\nRS001\nRS002\nRS003\nRS004\nRS005\nRS006\nRS015\nRS018\nRS019\nRS023\nRS024\nRS033\nRS036\nRS037\nRS038\nRS039\nRS040\nRS042\nRS051\nRS054\nRS055\nRS058\nRS059\nRS062\nRS064\nRS066\nRS067\nRS068\nRS069\nRS07\nRS070\nRS071\nRS073\nRS074\nRS076\nRS078\nRS08\nRS080\nRS081\nRS082\nRS083\nRS084\nRS085\nRS086\nRS087\nRS088\nRS089\nRS090\nRS091\nRS092\nRS093\nRS094\nRS095\nRS096\nRS097\nRS098\nRS099\nRS100\nRS101\nRS102\nRS103\nRS104\nRS105\nRS106\nRS107\nRS108\nRS109\nRS11\nRS110\nRS111\nRS112\nRS113\nRS114\nRS115\nRS116\nRS117\nRS118\nRS119\nRS12\nRS120\nRS121\nRS122\nRS123\nRS124\nRS125\nRS126\nRS127\nRS128\nRS129\nRS13\nRS130\nRS131\nRS132\nRS133\nRS134\nRS135\nRS136\nRS137\nRS138\nRS139\nRS14\nRS140\nRS141\nRS142\nRS143\nRS144\nRS145\nRS146\nRS147\nRS148\nRS149\nRS150\nRS151\nRS152\nRS153\nRS154\nRS155\nRS156\nRS157\nRS158\nRS159\nRS16\nRS160\nRS161\nRS162\nRS163\nRS164\nRS165\nRS166\nRS167\nRS168\nRS169\nRS17\nRS170\nRS171\nRS172\nRS173\nRS174\nRS176\nRS177\nRS178\nRS179\nRS180\nRS181\nRS182\nRS183\nRS184\nRS185\nRS186\nRS187\nRS188\nRS189\nRS190\nRS192\nRS193\nRS195\nRS196\nRS197\nRS199\nRS20\nRS200\nRS201\nRS202\nRS203\nRS204\nRS205\nRS206\nRS207\nRS208\nRS209\nRS21\nRS210\nRS211\nRS212\nRS213\nRS214\nRS215\nRS216\nRS217\nRS218\nRS219\nRS22\nRS220\nRS221\nRS222\nRS223\nRS224\nRS225\nRS226\nRS227\nRS228\nRS229\nRS231\nRS232\nRS233\nRS234\nRS235\nRS236\nRS237\nRS239\nRS240\nRS241\nRS242\nRS243\nRS244\nRS245\nRS246\nRS247\nRS248\nRS249\nRS25\nRS250\nRS251\nRS252\nRS253\nRS254\nRS255\nRS256\nRS257\nRS258\nRS259\nRS26\nRS260\nRS261\nRS262\nRS263\nRS264\nRS265\nRS266\nRS267\nRS268\nRS27\nRS270\nRS271\nRS272\nRS273\nRS274\nRS275\nRS277\nRS278\nRS28\nRS29\nRS30\nRS31\nRS32\nRS34\nRS35\nRS41\nRS43\nRS44\nRS45\nRS46\nRS47\nRS48\nRS49\nRS50\nRS52\nRS53\nRS57\nRS60\nRS61\nRS63\nRS65\nRS72\nRS75\nRS77\nRS9\nRtuh\nru\nrub\nrub_the_pole\nRubADorothea\nRub-a-Dub\nRub-a-dub-dub\nRubAJessy\nRub-A-Jug-Jug\nRub-And-Tug-Tub\nrubbed\nrubber\nRubberDoll\nRubbers\nRubbertoy\nRubbin\nRubbin'\nrubbing\nRubbing1\nRubbing2\nrubdown\nrub-down\nRubdown\nRub-down\nRubdown?\nRubee\nRuben\nRubenesque\nRubens\nRubes\nRubi\nRubies\nRubio\nRubi's\nrub-n-tug\nRuboff\nRub-out\nrubs\nRuburana\nRuby\nRuby’s\nRubys\nRuby's\nRuca's\nRucava\nRucca\nRuckus\nRud\nRude\nRudy\nruff\nRuffle\nRuffling\nRUFF'N'TUFF\nrug\nRugby\nRugbanana\nRugfinger\nRugplay\nRugs1\nRugs2\nRuhl\nruin\nruined\nRuining\nruins\nRuiz\nRuka\nrule\nRuler\nrules\nRuling\nRumba\nRumble\nRumbles\nRumbling\nRumia\nRuminating\nRumor\nRumors\nrumour\nRumours\nrump\nrumpalicious\nrumps\nRumspringa\nRumuri\nRumzta\nrun\nRuna\nRunaway\nRunaways\nRunaway's\nRungs\nrun-in\nRunnalingus\nRunner\nRunner's\nRunneth\nRunnin\nrunning\nRunning?\nruns\nrunway\nRural\nRurale\nRuse\nrush\nRushes\nrushing\nRushMoore\nRush's\nRusian\nRuski\nRuslan\nRuss\nRussel\nRussell\nRussell's\nRusset\nRussia\nrussian\nrussians\nRussian's\nRusskie\nRusso\nRusso's\nRustic\nrusty\nRut\nRuth\nruthless\nruthlessly\nRuuun\nRuuuun\nRuzzare\nRV\nRx\nRX’s\nRx's\nRya\nRyaan\nRyad\nRyan\nRyanIs\nRyann\nRyanne\nRyans\nRyan's\nRyde\nRyden\nRyder\nRyder's\nRydes\nrye\nRylan\nRylee\nRyley\nRylie\nRylie's\nRylin\nRyllei\nRyo\nRyon\nRysie\nRyta\nRyu\nRyzele\nRyzell\ns\nS001\nS002\nS003\nS004\nS005\nS006\nS01E01\nS01E02\nS01E03\nS02E01\nS1\nS10\nS10E1\nS10E10\nS10E2\nS10E3\nS10E4\nS10E5\nS10E6\nS10E7\nS10E8\nS10E9\nS11\nS11E1\nS11E10\nS11E2\nS11E3\nS11E4\nS11E5\nS11E6\nS11E7\nS11E8\nS11E9\nS12E1\nS12E10\nS12E2\nS12E3\nS12E4\nS12E5\nS12E6\nS12E7\nS12E8\nS12E9\nS13E1\nS13E10\nS13E2\nS13E3\nS13E4\nS13E5\nS13E6\nS13E7\nS13E8\nS13E9\nS14E1\nS14E10\nS14E3\nS14E4\nS14E5\nS14E6\nS14E7\nS14E8\nS14E9\nS15E1\nS15E2\nS15E3\nS15E4\nS15E5\nS15E6\nS15E7\nS15E8\nS16E10\nS16E3\nS16E5\nS16E6\nS16E7\nS16E8\nS16E9\nS17E1\nS17E10\nS17E2\nS17E3\nS17E4\nS17E5\nS17E6\nS17E7\nS17E8\nS17E9\nS18E1\nS18E10\nS18E2\nS18E3\nS18E4\nS18E5\nS18E6\nS18E7\nS18E8\nS18E9\nS19E1\nS19E10\nS19E2\nS19E3\nS19E4\nS19E5\nS19E6\nS19E7\nS19E8\nS19E9\nS1E1\nS1E10\nS1E2\nS1E3\nS1E4\nS1E5\nS1E6\nS1E7\nS1E8\nS1E9\nS2\nS20E1\nS20E10\nS20E2\nS20E3\nS20E4\nS20E5\nS20E6\nS20E7\nS20E8\nS20E9\nS21E1\nS21E10\nS21E2\nS21E3\nS21E4\nS21E5\nS21E6\nS21E7\nS21E8\nS21E9\nS22E1\nS2E1\nS2E10\nS2E2\nS2E3\nS2E4\nS2E5\nS2E6\nS2E7\nS2E8\nS2E9\nS3E1\nS3E10\nS3E2\nS3E3\nS3E4\nS3E5\nS3E6\nS3E7\nS3E8\nS3E9\nS4E1\nS4E10\nS4E2\nS4E3\nS4E4\nS4E5\nS4E6\nS4E7\nS4E8\nS4E9\nS5E1\nS5E10\nS5E2\nS5E3\nS5E4\nS5E5\nS5E6\nS5E7\nS5E8\nS5E9\nS6E1\nS6E10\nS6E2\nS6E3\nS6E4\nS6E5\nS6E6\nS6E7\nS6E8\nS6E9\nS7E1\nS7E10\nS7E2\nS7E3\nS7E4\nS7E5\nS7E6\nS7E7\nS7E8\nS7E9\nS8E1\nS8E10\nS8E2\nS8E3\nS8E4\nS8E5\nS8E6\nS8E7\nS8E8\nS8E9\nS9E1\nS9E10\nS9E2\nS9E3\nS9E4\nS9E5\nS9E6\nS9E7\nS9E8\nS9E9\nsa\nSaabo\nSaana\nSaavy\nSab\nSaba\nSabado\nSabana\nSabara\nSabbia\nSabby\nSabelle\nSaber\nSabien\nSabina\nSable\nSabor\nSabotage\nSabreena\nsabrina\nsabrina's\nSabrinaVova\nSabrine\nSabrinka\nSabrisse\nSabrosa\nSabryna\nSac\nSaca\nsacapunta\nSacha\nsack\nsacks\nSacral\nSacramento\nSacred\nSacrifice\nSacrifices\nSacrificial\nSacrilegious\nSacrilicious\nSacs\nsad\nsaddle\nSaddles\nSaddle-Up\nSade\nSadie\nSadie’s\nSadie's\nSadique\nSadism\nSadist\nSadist0-1\nsadistic\nsadistic???\nSadistically\nSadists\nSadist's\nSadlyBut\nsadness\nSado\nSafado\nsafari\nsafe\nsafely\nSafest\nSafety\nSafeword\nSaffron\nSafi\nSafira\nSafire\nSafo\nSaga\nSagami\nSagara\nSage\nSage's\nSahara\nSahari\nSahenka\nSahmara\nSai\nsaid\nSaige\nSaige20yr\nSaigeSexy\nSaigeTTOO\nSail\nSailboat\nsailing\nsailor\nSailor?\nSailors\nSailor's\nSails\nsaint\nsaint?\nSaint-Amour\nSaint-Amour's\nSaint-Patrick's\nSaints\nSaint's\nSaintThe\nSainz\nsake\nSakj\nSakova\nSakura\nSakura's\nSal\nSAL001\nSAL002\nSAL003\nSAL004\nSAL005\nSAL006\nSAL007\nSAL008\nSAL009\nSAL011\nSAL012\nSAL013\nSAL014\nSAL016\nSAL017\nSAL018\nSAL019\nSAL020\nSAL021\nSAL022\nSAL023\nSAL024\nSAL025\nSAL026\nSAL027\nSAL028\nSAL10\nSAL15\nSalacious\nsalad\nSalad?\nsalami\nsalary\nSalat\nSalazar\nSalchicha\nSalchichon\nSaldus\nsale\nsales\nSalesgirl\nsalesman\nsalesman's\nSalesmanship\nSalesmen\nsaleswoman\nSales-Woman\nSaleswomen\nSaliery\nSalina\nSalinas\nSalina's\nSaline\nSalinia\nSaliva\nSalivates\nSalivating\nSalivation\nSallai\nSalley\nSally\nSally's\nSalma\nSalome\nSalome's\nSalomja\nsalon\nSalón\nSalonsecret\nSaloon\nSalopes\nSalottia\nSalsa\nSalt\nSalty\nsalutations\nsalute\nSaluting\nSalvadorian\nSalvaje\nSalvajes\nsalvation\nSalvatore\nSalve\nsam\nSamanta\nSamanth\nSamantha\nSamantha2\nSamantha's\nSamara\nSamaritan\nSamaritans\nSamatha's\nSamba\nSamba-Cubana\nSambuca\nsame\nSamhain\nSami\nSamia\nSamie\nSamilla\nSamira\nSamira's\nSami's\nSamm\nSammi\nSammie\nSammie's\nSammi's\nSammy\nSammy-Jayne\nSammy's\nSamntha\nSamoan\nSamone\nSamora\nsample\nSamples\nSampling\nSampson\nSamson\nsamsonite\nSamuel\nSamuella's\nSamurai\nSamurai-Make loveed\nSamy\nSamyer\nSamyra\nSan\nSana\nSancha\nSanches\nSanchez\nSanchez's\nSanccany\nsanctuary\nsand\nSanda\nSandal\nsandals\nsandbox\nSandee\nSandee's\nSander\nSanders\nSandi\nSandie\nSandis\nSandman\nSandobar\nSandora\nSandra\nSandra's\nSandrine\nSandro's\nSands\nSandsational\nsandwich\nsandwiched\nSandwiches\nSandy\nSandy?\nSandy’s\nSandyChat\nSandys\nSandy's\nsane\nSanger\nSanie\nsanity\nSanja\nSanny\nsanta\nSANTA?Come\nSanta’s\nSantana\nSantana’s\nSantanna's\nsantas\nSanta's\nSantavibe\nSante\nSantez\nSanthiago\nSanthiago's\nSanti\nSantiago\nSantini\nSantis\nSanti's\nSanto\nSantoro\nSantos\nsanwiched\nSanya\nSap\nSaphic\nSaphir\nSaphire\nSaphire's\nSapore\nsapphic\nSapphically\nsapphics\nSapphira\nSapphire\nSapphires\nSapphos\nSar\nsara\nSarabria\nSarada\nSarah\nsarah's\nSarai\nSaraJay\nSaran\nSara'nade\nSaran's\nSaras\nSara's\nSaray\nSarena\nSarge\nSarge's\nSari\nSaria\nSarina\nSaritha\nSarr\nSarrah\nSarte\nSartre\nSartre's\nSAS\nSasafrbooty\nSascha\nSascha's\nSash\nSasha\nSashaa\nSasha's\nSashina\nSashu\nSasiavis\nsaskia\nSbooty\nSbootyy\nsat\nSata\nSatan\nSatanic\nSatan's\nSatellite\nsatin\nSatine\nSatinella\nSatinrobe\nSatinrobe2\nSatintoy\nsatisfaction\nsatisfactions\nsatisfied\nsatisfies\nSatismake love-tion\nsatisfy\nSatisfyin'\nsatisfying\nSativa\nSativa's\nSativva\nSatomi\nSatomi's\nSaturday\nSaturdays\nSatynge\nsauce\nSaucey\nSaucier\nsaucy\nSaul\nSault\nsauna\nSaundra\nsausage\nsausage?\nsausages\nsaute\nSauvage\nSavabbah\nSavage\nSavagely\nSavages\nSavaging\nSavalas\nSavana\nSavanah\nSavana's\nSavanna\nSavannah\nSavannahs\nSavannah's\nSavannnah\nsave\nsaved\nSaver\nsaves\nSaving\nSavings\nsavior\nSavor\nSavorers\nSavoring\nsavors\nsavory\nSavoury\nSavvy\nSavvy's\nsaw\nSawaru\nSawyer\nSax\nsay\nsay?\nSaya\nSaya's\nSayeh\nsaying\nSayonara\nsays\nSayurii\nsbanged\nSc\nSc1\nSc2\nSc3\nSc4\nScaffolding\nscale\nScaleno\nscam\nscammer\nScamming\nscams\nscan\nscandal\nScandales\nScandalous\nScandals\nScanner\nScanno\nScanties\nScantonato\nScantron\nScape\nScapes\nScaping\nScar\nscare\nScarecrow\nscared\nScaredy\nscares\nscarf\nScarfs\nscaring\nScaris\nScarlet\nScarlets\nScarlet's\nScarlett\nScarlette\nScarlette's\nScarlett's\nScarlit\nScary\nScarzan\nScavenger\nScavengers\nScavilan\nSce\nScellen\nScenario\nscene\nscene1\nScene2\nScene3\nScene4\nscene901205\nscene901206\nscene901207\nscene901208\nscene90140\nscene90141\nscene90142\nscene90143\nscene90144\nscene90367\nscene90368\nscene90369\nscene90370\nscene90371\nscene90374\nscene90375\nscene90376\nscene90377\nscene90378\nscene90379\nscene90380\nscene90381\nscene90382\nscene90383\nscenery\nscenes\nScenic\nScent\nScented\nscenting\nScentual\nScepter\nSceptical\nsch\nSchaft\nSchedule\nScheduled\nSchedules\nScheme\nScheming\nSchiffer\nSchilla\nSchinaider\nSchinider\nSchisma\nSchizzi\nSchlampe\nschlong\nSchlonged\nschlongs\nschlub\nSchmidt\nSchmitt\nSchmovie\nSchneider\nSchnitzel\nScholar?\nscholarship\nScholastic\nschool\nSchool?\nschoolboy\nSchoolboys\nSchooled\nschoolgirl\nschoolgirls\nschoolgirl's\nSchoolgirls\nSchoolgirl's\nSchoolgirltoy\nSchoolgirltreat\nSchoolgirlupskirt\nSchoolhouse\nSchoolin\nschooling\nschoolmaster\nschoolmate\nSchoolroom\nschools\nSchool's\nSchoolvibe\nSchoolyard\nSchoolzone\nSchpitz\nSchtupping\nSchultz\nSchwartz\nSchwartz's\nSchwarz\nSchweider\nSci\nscience\nsciences\nScientific\nScientist\nscientists\nScienCANS\nSci-fi\nSciFi\nSci-Fi\nScimitar\nScintillating\nscissor\nscissored\nscissoring\nscissors\nscissorsIs\nSco\nscold\nScolding\nScooby\nscoop\nScooped\nScooping\nscoops\nScoot\nscooter\nScooters\nScooting\nScooty\nScope\nScoperta\nScoping\nScorching\nscore\nScored\nSCORELAND\nscores\nScoring\nScorn\nScorned\nScornedKrissy's\nScorpio\nscorpion\nScorpion0-3\nScorpion1-4\nscorpions\nScot\nscotch\nscotchin\nScotching\nScotish\nScotland\nScott\nScotte\nScottish\nScott's\nScotty\nScotty's\nScoundrel\nScouring\nscout\nscouting\nscouts\nScout's\nScrambled\nScrap\nScrapbook\nscrapheap\nScraping\nscrappy\nscratch\nScratching\nScrawny\nscream\nscream?\nscreamed\nscreamer\nScreamers\nscreamin\nscreaming\nscreams\nscree\nscreen\nscreening\nscrew\nScrewberX\nscrewdiver\nScrewdriver\nscrewed\nscrewing\nScrewl\nscrews\nscript\nscritped\nScrotum\nscrub\nScrub-a-dub\nScrubbed\nScrubbing\nScrubs\nScrum\nScrum-dilly-icious\nscrumptious\nScrumptuous\nScuba\nScuffle\nSculler\nScully\nSculpt\nSculpted\nSculptura\nSculptures\nScyley\nse\nSe7en\nsea\nsea_munkeys\nSeÃ±oritas\nSeacoast\nSea-Do\nSea-Green\nSeagulls\nSeajay\nseal\nSealed\nSealer\nSealing\nseals\nSeaman\nSeamed\nSeamen\nSeamless\nSeams\nSeamstress\nSeamus\nSean\nseance\nSeang\nsearch\nsearched\nsearches\nsearching\nSearde\nSeart\nseas\nSeashell\nseaside\nseason\nseason12\nSeasonal\nSeasoned\nSeasonless\nseasons\nseason's\nSeasons\nseat\nSeatbelt\nSeattle\nSebastian\nSebastian's\nSebastien\nsec\nSecco\nsecluded\nSeclusion\nSecolo\nsecond\nsecondary\nseconds\nSecratary\nsecret\nsecret?\nSecretaria\nSecretarial\nSecretaries\nsecretary\nSecret-Ary\nSecretary’s\nSecretaryIt's\nsecretary's\nSecretforrest\nSecretions\nSecretive\nsecretly\nSecreto\nSecretos\nsecrets\nSecret's\nSection\nSector\nsecure\nsecured\nSecuring\nSecuri-Cans\nsecurity\nSecurity's\nSecur-Canty\nSeda\nSedane\nSedona\nSedora\nseduce\nSeduce?\nseduced\nSeducer\nseducers\nseduces\nseducing\nSeductio\nseduction\nseductions\nSeductioon\nseductive\nseductively\nSeductiveness\nSeductora\nseductress\nSeduisante\nSeduses\nSEDUX\nSeduxia\nsee\nSee?\nseed\nSeeding\nseeds\nSeedy\nseeing\nseek\nSeeker\nseekers\nSeeker's\nseeking\nseeks\nseem\nSeemed\nseems\nseen\nsees\nSeesaw\nsee-through\nSeethrough\nSee-Through\nsee-through's\nsee-thru\nSeethru\nSee-Worthy\nSegreto\nSeguna\nSegunda\nSeguro\nSeiber\nSeifuku\nSeinfeld\nSeins\nSeisen\nSeize\nseizes\nSeizure\nSeka\nSeka's\nSekova\nSelah\nSelana\nSelardi\nSelby\nSelect\nSelected\nSelection\nselects\nSelena\nSelenas\nSelena's\nSelene\nSelexia's\nSelextra\nself\nSelf-Avowed\nSelf-Care\nSelf-Conscious\nSelf-Defense\nSelf-Esteem\nself-fisting\nSelf-make love\nSelf-gratification\nSelfibate\nselfie\nSelfie-Conscious\nSelfie-Esteem\nselfies\nSelfie-teeny\nSelf-Indulgence\nSelfish\nSelf-isolation\nSelfless\nSelf-love\nSelf-Pleasing\nSelfpleasure\nSelf-Pleasure\nSelf-Pleasured\nSelf-Reflection\nSelfsatisfaction\nSelf-Satisfying\nSelf-Serve\nSelf-Service\nSelf-Serving\nSelfshooting\nSelfshot\nSelf-Spanking\nSelf-Starter\nSelf-Suspension\nSelf-Tied\nself-touching\nSelho\nSeliene\nSelikan\nSelina\nSelixa\nSelkes\nsell\nSeller\nSeller's\nselling\nSelling?\nSellout\nsells\nSelma\nSelvaggia\nSelvaggia's\nSelviagga\nSemak\nSemejanza\nsemen\nSemence\nSemen-Splashed\nSemester\nsemi\nsemi-final\nSemifinal\nSemi-final\nSemi-Finals\nsemiglobes\nSemikols\nSeminar\nSeminole\nSemiSecret\nSemper\nSempre\nSenator\nSenator's\nsend\nSender\nsending\nSendoff\nSend-Off\nsends\nSendy\nSene\nSenior\nSeniorita\nSennin\nSeñor\nSenora\nSenorita\nSeñorita\nSeñorita's\nsenos\nSenpai\nSensa\nSensacional\nsensation\nSensation'\nsensational\nsensations\nSensation-Seeking\nSensazione\nSense\nSensei\nSenseiki\nsenseless\nsenselesss\nSenselss\nsenses\nSensi\nSensibles\nSensious\nSensi's\nsensitive\nSensitivity\nSenso\nSensory\nsensu\nsensual\nSensualia\nSensualis\nSensualist\nSensualists\nsensuality\nsensually\nsensuous\nSensuously\nSensus\nsent\nSentence\nSentenced\nSenti\nSentir\nSentono\nseparate\nSeparation\nsepare\nSepatown\nSephora\nSept\nSeptember\nSeptember's\nSequana\nsequel\nSequin\nser\nSera\nSerafima\nSeraph\nSeraphine\nSera's\nSerb\nSerbia\nSerbian\nSerbians\nSerbian's\nSereia\nSeren\nserena\nserenades\nSerenading\nSerena's\nSerendipity\nserene\nSerengheti\nserenity\nSerenitys\nSerenity's\nSereyna\nSerge\nSergeant\nSergeant's\nSergei\nSergey\nSergio\nserial\nseries\nSerilla\nSerilla?\nSerilla's\nSerina\nSerina's\nSerinity\nserio\nserious\nSerious?\nseriously\nSermon\nSerpent\nSerpente's\nSerpentine\nSerpents\nSerrena\nservant\nServants\nServant's\nserve\nserved\nServed?\nServer\nserves\nservice\nService?\nservice”\nserviced\nservices\nServicin'\nservicing\nServicio\nserving\nservings\nServitude\nses\nSesame\nsesh\nsess\nsession\nsession'\nSession\nSessionFisted\nsessions\nSessionTesting\nSesso\nset\nSeth\nSetian\nsets\nSetter\nsetting\nSettings\nsettle\nSettlement\nSettling\nSetup\nSet-up\nsev\nSeva\nseven\nSeven-Stud\nSeventh\nSeventy-Two\nSeven-Year\nseveral\nSevere\nseverely\nSeverence\nSeverin\nsevice's\nSevilla\nSev's\nsew\nsewer\nSewing\nsex\nsex;\nsex?\nsex_toy_party\nSex+Finance\nSex…FOREVER\nsex385\nsex-addict\nSex-addicted\nSexadelic\nSexagon\nSex-A-Gram\nsexaholic\nSexaholics\nSexam\nSexample\nSex-apade\nSexart\nSexathon\nSexaul\nsexbomb\nSexbook\nSexbot\nSex-Bot\nsexcapade\nsexcapades\nSexcellent\nSexcercise\nSexcess\nSex-chatting\nsex-crazed\nSexcstasy\nSexdoll\nSexecutive\nsex-ed\nSexed\nSex-Education\nsexercise\nSex-ercise\nSex-er-cise\nSex-Ercise\nSEXercise\nSexercise?\nsexercises\nSexes\nSex-filled\nsex-for-cash\nsex-fury\nsexgame\nsex-goddes\nSexhibitionist\nsex-hungry\nSexi\nSexians\nsexier\nsexiest\nsexin\nSex-in-a-car\nSexinator\nSexiness\nSexing\nSexinyourcitycom\nSexist\nSex-Kitten\nSexless\nSexlightenment\nSexlove\nsex-loving\nsex-lust\nsex-m\nsex-machine\nSexmachine\nSex-Machine\nsexmachines\nSexmas\nsex-mate\nSexmate\nSexmex\nsexnastics\nSexo\nSex-O\nSexo?\nSex-Obsessed\nSexologist\nSexolution\nSex-O-Rama\nsexorcism\nSexorcist\nSexpectations\nSexpelled\nSexperate\nSexperience\nSEXperiences\nSexperiment\nsexperiments\nSexpert\nSexperts\nSexpionage\nSexpiration\nSexploitation\nsexplorations\nSexposed\nsexpot\nSexpots\nSexpot's\nSexpress\nSexpressionism\nSexpresso\nSexPro\nSexquisition\nSeX-Rays\nSexsational\nSexshark\nsexshop\nSexsomnia\nSexspeare\nSexssion\nSex-Starved\nSex-scanute\nSext\nSextalk\nSextalk1\nSextalk2\nSextalking\nsextape\nSex-Tape\nSextasy\nSexteens\nSexter\nSexterminator\nSextet\nSexthics\nSexthlete\nSextia\nsexting\nsextini\nSextion\nSexton\nSextortion\nsextoy\nsextoying\nsextoys\nSex-Toys\nSextra\nSextracurricular\nSextra-Curricular\nSextravaganza\nSextricity\nSexts\nSextury\nSexty\nsexuaity\nsexual\nsexuality\nSexualization\nsexually\nSexually'\nSexually-obsessed\nSexualy\nSex-Vid\nSexWax\nSexway\nSexwick\nSex-work\nSexworking\nSexx\nSexxlexia\nSexxual\nSexxx\nseXXXperience\nSexxxploration\nSexxxpose\nSeXXXretary\nSeXXXStar\nSexxxton\nSeXXXtreme\nSeXXXual\nSexxxy\nSexxy\nSexxyBlack\nsexy\n'Sexy\nSEXY\nSexy Russian\nsexy_pirate\nSexy-booty\nSexydenimskirt\nSexydressup\nSexyheels\nSexykitten\nSexylingerie\nSexymaid\nSexyness\nSexynurse\nSexypanties\nSexypink\nSexyplayer\nSexyrubdown\nSexysanta\nSexyschoolgirl\nSexyshorts\nSexysilver\nSexysocks\nSexystockings\nSexystudent\nSexytime\nSexytoy\nsey\nSeychelles\nSeymour\nSF\nSF's\nSgh\nSgt\nSgt?\nsh\nSh*t\nSha\nShack\nShackin\nShackled\nshackles\nshade\nShaded\nshades\nShadoes\nshadow\nShadowland\nShadowplay\nShadows\nShady\nShae\nShafry\nshaft\nSHAFTED\nShafting\nShafts\nshag\nShag?\nShagadelic\nShageroo\nshagged\nshaggin\nShaggin'\nShaggin’\nshagging\nshaggy\nShaggy's\nshags\nShagwell\nShai\nShain\nShaine\nShakalaka\nshake\nShake'\nShakedown\nshaken\nShaker\nShakers\nshakes\nShakila\nShakin\nShakin'\nshaking\nShakira's\nShakti\nShalina\nShalke\nshall\nShallow\nShalt\nShaman\nshame\nShamed\nShamefaced\nShameful\nShamefully\nshameless\nShamelessly\nShamelessness\nShaming\nShamon\nShampoo\nShamrock\nShan\nShana\nShananigans\nShand\nShane\nShane's\nShangri\nShangri-la\nShania\nShanice\nShanie\nShanis\nShank\nShankar\nShanke\nShanna\nShannon\nShannons\nShannon's\nShannya\nShan's\nShantastic\nShantel\nShantels\nShanti\nShanti's\nshanty\nShanya\nshape\nshaped\nshapely\nshapes\nShapeshifter\nShaping\nShara\nSharapova\nShara's\nShards\nshare\nShareable\nshared\nSharee\nSharers\nshares\nsharing\nshark\nSharka\nSharka's\nSharkbait\nSharks\nShark's\nSharok\nSharon\nSharon's\nsharp\nSharpening\nsharpshooter\nShar's\nShasta\nShataya\nShatki\nShattered\nShattering\nShauna\nshave\nshaved\nShavedkitty\nShavedcat\nShavell\nShavelle\nshaven\nshaven-haven\nShaver\nShavers\nshaves\nShaves1\nShaves2\nShavin\nshaving\nShavon\nShaw\nShawn\nShawna\nShawna's\nShawnie\nShaw's\nShaw-Slut\nShay\nShaye\nShayenne\nShayla\nShaylee\nShayna\nShayne\nShays\nShay's\nShazam\nShazia\nShbang\nshe\nshe'\nShe\nshe?\nshe’\nShe’ll\nshe’s\nShea\nShead\nSheala\nShearing\nShea's\nShe-Boner\nShebop\nShe-member\nshed\nshe'd\nShed\nShedding\nshe-devil\nShe-Devil?\nShe-Devils\nShe-Johnson\nSheds\nSheehan\nSheen\nSheena\nSheena's\nSheen's\nShe-E-O\nSheep\nSheepish\nSheeple\nSheep's\nSheepskin\nsheer\nSheerbooty\nSheerly\nSheerskirt\nSheertoy\nSheerwhite\nSheerwindow\nSheet\nsheets\nSheik\nSheila\nSheila's\nShelby\nSheldon\nShelf\nSheli\nshe-lion\nshell\nshe'll\nShell\nShe'll\nSHELL\nshell?\nShelley\nShelley's\nShellgirls\nShelly\nShelly's\nShelson\nshelter\nsheltered\nShelter-in-cat\nShelves\nShelves2\nShemale\nShe-male\nSheMales\nShe-Males\nshenanigans\nshenanigan's\nShenanigans\nShenanigens\nShennanigans\nShepard\nShepard's\nSherazade\nSherazadee\nSheree\nShereese\nSheri\nSherice\nSheridan\nSherif\nSheriff\nSheril\nSherlock\nSherly\nSherman\nSheroes\nSheron\nSherri\nSherry\nSherry's\nSherwood\nSheryl\nSheryl's\nshes\nshe's\nShes\nShe's\nShesHorny\nShespeaks\nSheSquats\nShevasana\nShevelle\nShevon\nShevon's\nShevonThe\nShe-Wolf\nShey\nSheyla\nSheylla\nSHFT\nShhh\nshhhhh\nShhhhhhhhh\nShi\nShi?\nShiatsu\nShibari\nShibori\nShicoksu\nShieffer\nShield\nShields\nShiffer\nshift\nShifter\nshifts\nShift's\nShikira\nShila\nShiloh\nShimizu\nshimmer\nShimmering\nShin\nShindig\nshine\nshine_me_off\nshines\nShine's\nShiney\nshining\nSHINJU\nShinning\nshinny\nShino's\nshiny\nShiny1\nShiny2\nShinytoy\nShione\nShione's\nShip\nshipment\nShipped\nships\nshiptease\nShipwrecked\nShira\nShiri\nShirley\nShirma\nshirt\nShirtless\nshish-kebab\nshit\nShitless\nShitty\nShiva\nShiver\nshivers\nShizen\nShizzle\nShlong\nShlya\nShmondoms\nShnizzy\nsho\nShock\nshocked\nshocker\nshocking\nShockingly\nshocks\nShockspot\nshoe\nShoechair\nShoed\nShoefetish\nshoe-make loveed\nshoe-less\nshoes\nShoestoy\nShoesucker\nShojo\nShona\nShona's\nShook\nshoot\nshoot\t34d-24-34\nShoot19's\nShootAbandoned\nshootamateur\nshootand\nShootatom's\nShootBreaking\nShootBrutal\nshooter\nShootin\nshooting\nshootNo\nShootredlights\nshootReverse\nshoots\nshootShe\nShootThe\nShootWhere\nshop\nShopaholic\nshopaholic’s\nShopaholics\nShopgirl\nShopGrifter\nShoplicker\nshoplifter\nShoplifters\nShoplifting\nShoppe\nShopper\nshoppers\nShoppin\nshopping\nShopping?\nshoppingsexsmiles\nshops\nshore\nShoreline\nShores\nshort\nShortage\nShortblack\nShortcake\nshortcomings\nShortcut\nshorter\nShortest\nshorthair\nShort-haired\nShort-Hared\nShortie\nShorting\nShortly\nshorts\nShorts2\nShortshorts\nShortskirt\nShortskirt1\nShortskirt2\nShortsplay1\nShortsplay2\nShortstop\nShortstouch1\nShortstoy\nShorty\nshot\nshot?\nShotgun\nShotgun?\nshots\nshou\nshould\nShould?\nShoulda\nshoulder\nshoulders\nshouldn't\nShouldnt\nShouldn't\nShout\nShoutout\nshove\nshoved\nshoves\nShoving\nshow\nShow?\nShowAll\nShowbiz\nshowBrutal\nshowcase\nShowcased\nShowcases\nShowcasing\nshowdown\nshowed\nshower\nshower?\nShower1\nShower2\nShowerblast\nShowerbooty\nShowerbanana\nshowered\nShowerfun\nShowerfun2\nShowerglbooty\nShowerhead\nshowering\nShowerlove\nShowerorgasm\nShowerplay\nShowerpleasure\nShowerpole\nShowerpower\nShowerroom\nShowerrub\nshowers\nShowersnatch\nShowerspray1\nShowerspray2\nShowerteen\nshowertime\nShowertime1\nShowertime2\nShowertoy\nShowertoy1\nShowertoy2\nShowertwo\nShowervibe\nShow-Final\nshowgirl\nShowgirls\nshowi\nshowin\nshowing\nShowme\nshown\nShow'N\nshow-off\nShowoff\nShow-off\nShow-Offs\nshowcat\nshows\nshow's\nShows\nShowstopper\nShowStrict\nShowThe\nshowtime\nShox\nShphinchter-stretching\nShredded\nShreddin\nshredmill\nShreds\nshrew\nShrewd\nShrima\nShrimping\nShrimpy\nShrine\nshrink\nshucked\nShudder-Member\nShuddering\nshuffle\nShui\nShultz\nShush\nshut\nShutdown\nShutter\nShutterbug\nShutting\nshuttle\nShuttlemember\nshy\nShy?\nShy??\nShyla\nShyla-la-la\nshylas\nShyla's\nShylina\nShylove\nShyly\nShyn\nShyne\nShyness\nShyra\nShyrley\nShy's\nsi\nSia\nSialis\nSiam\nSiamese\nSIberia\nSiberian\nSibian\nSibilla\nSibling\nSibling’s\nSiblings\nSibling's\nSiblings’\nSicilia\nSicilian\nSicilia's\nsick\nSickest\nSickness\nSicle'\nsid\nside\nSidemelon\nSide-By-Side\nSidechick\nSided\nSidekick\nSidelines\nSidepiece\nsideplay\nsides\nSideshow\nSidetracked\nSidewalk\nSideways\nSide-Winder\nSidnee\nSidney\nSidney's\nSidonia\nSidonie\nSidra\nSidra's\nSie\nSieb\nSiege\nSielle-1\nSielle-2\nSiempre\nSienna\nSiennaMexico\nSiennas\nSienna's\nSierra\nSierra's\nSiesta\nSiffredi\nSifu\nSigal\nSigal's\nSigh\nSighing\nsighs\nsight\nSighting\nsightings\nsights\nsightseeing\nsightseer\nsign\nSignal\nsignals\nSignatory\nSignature\nSigned?\nSignin\nsigning\nSigns\nSigourney\nSigyta\nSiiri\ns'il\nSil\nS'il\nSila\nSilas\nSila's\nSilectico\nSilena\nsilence\nSilenced\nSilences\nsilent\nSilently\nSilexia\nSilhouette\nSilhoutte\nsilicone\nSilienta\nsilk\nSilken\nSilkpj\nSilktalking\nSilktalking2\nsilky\nSilky-Sheathed\nSilla\nsilly\nSilva\nSilvana\nSilveira\nsilver\nSilverback\nSilverbullet\nSilverchairs\nSilverbanana\nSilverfriend\nSilvercat\nSilver's\nSilverspecial\nSilverstone\nSilver-Tongued\nSilvertoy\nSilvervibe\nSilvervibrator\nSilverware\nSilvia\nsilvia's\nSilviaStacy\nSilvie\nSilvie's\nSilvija\nSilvis\nSilviya\nSilvy\nsim\nSimari\nSimella\nSimera\nSimi\nSimilar\nSimilarities\nSimilo\nSimira\nSimi's\nSimmering\nSimmers\nSimmons\nSimms\nSi-Moan\nSimoes\nSimon\nSimona\nSimona's\nSimone\nSimone's\nSimons\nSimon's\nSimony\nSimony's\nSimos\nSimp\nsimple\nsimply\nSimpson\nSimpsons\nSims\nSimulacrum\nsimulant\nSimulation\nSim-ulation\nSimulator\nSimultaneous\nsimultaneously\nsin\nSina\nSinaloa\nSinbad\nsince\nSincere\nSincerela\nSincerely\nSincerre\nSinclair\nSinclaire\nSinclaire's\nSinclair's\nsindee\nSindee's\nSindell\nSinderella\nSinderson\nSindey\nSindi\nsindy\nSindy's\nSinead\nSinead's\nSineplex\nSinergy\nSinetika\nSinFormer\nSinfox\nsinful\nSinfully\nsing\nSingalong\nSingame\nsinger\nsinging\nSinging-bird\nsingle\nsingles\nSingletailing\nSingola\nsings\nSinister\nsink\nSinkati\nSinking\nSinkpink\nSinks\nSinlia\nSinn\nsinnamon\nSinnead\nSinned\nSinner\nsinners\nSinner's\nSinnful\nSinnfully\nSinning\nSinns\nSinn's\nSinnsational\nSinonimo\nSinota\nSinovia\nsins\nSin's\nSinsacion\nSins's\nSinstar\nsinterview\nSintia\nSinz\nSioux\nSiouxie\nSiouxsie\nSip\nSippin\nSipping\nsir\nSir?\nSira\nSirale\nSirale's\nSire\nsiren\nSirena\nSirena69\nSirene\nsirens\nSiren's\nSires\nSiri\nSiri-ously\nSiri's\nSirtari\nSirvienta\nsis\nSis??\nSisi\nSislovesme\nSissification\nsissy\nsista\nSistah\nSistante\nsister\nSister?\nSister’s\nSisterhood\nSister-in-law\nSisterly\nsisters\nsister's\nSisters\nSister's\nSISTERS\nSisters-In-Lust\nSisterswap\nSisterthis\nSisy\nsit\nSitdown\nsite\nsites\nSitfinger\nSitfinger2\nSith\nSitophilia\nsits\nsitter\nSitters\nSitter's\nSittin\nsitting\nsituation\nsituations\nSivia\nsix\nSixBang\nSix-Foot\nSix-Man\nSix-On-One\nSix's\nSixtantes\nSixteen\nsixty\nsixty-nine\nSixtynine\nSixty-nine\nSixtyniner\nSixtyniners\nsixty-year-old\nSixx\nSixxx\nSiya\nSizable\nsize\nsized\nSizes\nSizi\nSizis\nSizi's\nsizzle\nSizzler\nSizzlers\nsizzles\nSizzlin\nSizzlin'\nsizzling\nSizzzzzzzze\nSk8ter\nSkafos\nSkank\nSkank?\nSkanks\nSkank's\nSkanky\nSkapandi\nSkat\nskate\nSkateboard\nSkateboarder\nSkateboarding\nSkatepark\nskater\nskates\nSkatewhoreding\nskating\nSkaylar\nSkeet\nSkeeter\nSkeeters\nskeeting\nSkeets\nSkeetSearch\nSkeleton\nSkeletons\nSkeletor\nSkeletorPart\nSkellington\nSkepazo\nSkeptical\nSketch\nSketching\nsketchy\nskewered\nski\nSkibunny\nSkie\nSkies\nSkilful\nskill\nskilled\nSkillet\nskillful\nskills\nskillset\nSkillz\nSkim\nSkimmer\nSkimming\nSkimpy\nskin\nSkinflix\nSkinfluence\nSkinflute\nskinn\nskinned\nskinny\nSkinnydipping\nSkin-on-Skin\nSkinride\nskins\nSkin's\nSkinski\nSkint\nskintight\nSkin-tight\nSkin-Tillating\nskip\nSkipper\nSkippers\nSkipping\nSkips\nSkirmishers\nskirt\nSkirt?\nSkirt1\nSkirt2\nSkirtdance\nSkirtfinger\nSkirtfinger2\nSkirtfingers\nSkirtfun\nSkirtgirl\nSkirtoy\nskirts\nSkirtstrip\nSkirttouch\nSkirtundies\nSkis\nSkool\nSkrilla\nskull\nSkullbikini\nskull-make loveed\nSkulls\nsky\nSkyar\nSkydiver\nSkydiving\nskye\nSkyes\nSkye's\nSkyhigh\nSky-high\nSkyla\nskylar\nSkylar's\nSkylas\nSkyler\nSkylight\nSkyline\nSkyline's\nSkyLocal\nSkylor\nSkymm\nSkype\nSkyrim\nSkys\nSky's\nSkyScared\nskyscraper\nskyscrapers\nSkyy\nSl\nSlacker\nSlackin\nslacking\nSlade\nSlader\nSlade's\nSlag\nSlager\nslam\nSlam-Make love\nSlam-Make loveed\nSlamin\nSlammage\nslammed\nSlammer\nSlammin\nSlammin'\nslamming\nslams\nSlamwich\nSlang\nslangs\nSlant\nslap\nSlapped\nSlapper\nSlappin\nslapping\nSlaps\nSlate\nSlater\nSlather\nSlathered\nSlaughter\nslaughterhouse\nSlav\nSlava\nslave\nslave?\nslaveboy\nSlaveboys\nSlavehood\nSlave-Master\nSlavery\nslaves\nslave's\nSlaves\nSlave's\nSlaves100\nSlavesDay\nSlavic\nSlavina\nSlaving\nSlay\nSlayed\nslayer\nSlayher\nSlayin\nSlaying\nslays\nSleak\nSleaze\nSleazebag\nsleazy\nSled\nSledding\nSledgehammer'\nsleek\nsleep\nSleeper\nSleeping\nSleepless\nsleepover\nSleep-Over\nsleeps\nsleepsack\nSleepwalking\nSleep-With-Me\nsleepy\nSleeve\nSleeves\nSleezy\nslend\nslender\nSleuth\nSlevie\nslice\nSlices\nslick\nSlick-Johnson\nSlicked\nSlicker\nSlickest\nslide\nSlide?\nSlider\nSliders\nslides\nsliding\nSligen\nSlightest\nslim\nSlime\nSlimed\nSlimmer\nSlims\nSlimy\nsling\nSlingin\nSlinging\nSlingshot\nSlink\nSlinks\nSlinky\nslip\nSlipe\nSliping\nSlip'n\nSlippage\nslipped\nSlipper\nslippers\nslippery\nSlipperyoil\nslippin\nSlipping\nslips\nSlip-Up\nslit\nSlithering\nSlithers\nslits\nslit's\nSlits\nSliver\nslo\nsloan\nSloane\nslob\nSlobaknob\nslobbe\nslobber\nSlobberbone\nslobbering\nslobbers\nSlobber-Soaked\nSlobber-Swilling\nSlobbery\nslobbin\nslobbing\nSlobby\nslobs\nslogan\nSlo-Mo\nSlone\nSlop\nSlope\nSlopes\nSlop-Gagging\nSloppier\nsloppiest\nsloppy\nslot\nslots\nSlovak\nSlovakia\nSlovakian\nSlovenian\nSlovokian\nslow\nslowly\nSlowness\nSlows\nslu\nSLUDS\nSlugger\nslumber\nSlumbering\nSlumberparty\nSlumdog\nSlumlord's\nSlumming\nslur\nslurp\nslurpee\nSlurpees\nslurpin\nslurping\nslurps\nSlurpy\nSlush\nslut\nSlut'\nSLUT\nSlut?\nslut3_isabelladior\nSlutA\nSlut-ber\nSlutcakes\nSluteen\nSluterday\nSluticide\nSlutlove\nSlutmother\nSluto\nSlutretary\nSlutry\nsluts\nslut's\nSluts\nSlut's\nSLUTS\nSlutsformation\nSlut-Shaming\nslutsYou\nSlutt\nsluttie\nSluttier\nSluttiest\nSlutting\nsluttish\nslutty\nslutty_selvaggia\nslutty's\nSlutwalker\nsluty\nsly\nslyman\nSM\nsma\nsmack\nsmackaback\nsmack-down\nSmackdown\nSmackdown's\nsmacked\nSmacker\nSmackin\nSmacking\nsmacks\nsmacktacular\nsmall\nsmall-meloned\nsmaller\nSmaller?\nsmallest\nSmalls\nsmall-cans\nsmall-canted\nSmalltown\nSmanhoto\nsmart\nSmart-booty\nSmarter\nSmartly\nsmartphone\nSmarty\nsmash\nsmashed\nSmashin\nSmashing\nSmear\nsmeared\nsmears\nSmeeny\nsmell\nSmells\nSmeraldi\nsmile\nSmile?\nsmiles\nsmiley\nSmiley's\nSmilin\nsmiling\nSmilla\nSmith\nSmiths\nSmith's\nsmitten\nSmocking\nSmoke\nSmoke?\nSmoked\nSmoker\nSmokers\nsmokes\nSmokeshow\nSmokestoy1\nSmokestoy2\nSmokey\nSmokie\nsmokin\nSmokin'\nSmokin’\nsmoking\nSmoky\nSmoldering\nsmoob\nSmooch\nSmoochers\nSmoochin\nSmoooth\nSmooshed\nsmooth\nSmoothe\nSmoother\nsmoothering\nsmoothest\nSmoothie\nSmoothies\nSmoothing\nSmoothness\nsmooths\nsmooth-skinned\nSmooty\nSmore\nS'More\nS'mores\nSmorgasbord\nSmorjai\nsmother\nsmothered\nsmothering\nsmothers\nsmoulders\nSmrhova\nSMS\nSmug\nsmuggler\nSmuggling\nSmurf\nsmut\nsmutted\nSmutty\nSmyth\nsn\nsnack\nSnacker\nSnackin\nsnacking\nsnacks\nSnacky\nSnag\nSnagged\nsnail\nsnake\nSnake-charmer\nsnakecharms\nsnakes\nSnake's\nSnakeskin\nsnap\nSnapchat\nSnapped\nsnapper\nSnappin\nsnapping\nSnappy\nsnaps\nSnapshot\nSnapshots\nSnare\nsnatch\nSnatchbox\nsnatchchat\nsnatched\nsnatcher\nSnatchers\nSnatches\nSnatching\nsneak\nSneakaway\nSneaker\nsneakers\nSneakin\nSneaking\nsneak--peek\nsneaks\nSneakshot\nSneaky\nsneaky_salon\nSnider\nSniff\nSniffer\nsniffers\nsniffing\nSniffs\nSnip\nSniper\nSnippity\nSnitch\nSnitches\nSnitch's\nSnobby\nSnogging\nsnooker\nSnooky?\nSnoop\nSnooper\nSnooping\nSnooze\nSnoozing\nSnore\nSnoring\nSnorkel\nsnow\nSnowball\nSnowballin'\nSnowballing\nsnowballs\nsnowbird\nSnowboard\nSnowboarder\nsnowboarding\nSnowbooty\nSnowbound\nSnowbunny\nSnowed\nsnowflake\nsnowflake?\nSnowjob\nSnow's\nSnowShow\nSnow-white\nsnowy\nsnuck\nsnug\nSnugg\nSnuggle\nSnuggles\nso\nSoak\nsoaked\nSoaked'\nSoaker\nSoakers\nSoakher\nsoaking\nSoakingwet\nsoaks\nSoap\nSoaped\nSoapin\nsoaping\nsoaps\nSoapsuds\nSoapkitty\nsoapy\nSoapybody\nSoapybum\nSoapyfingers\nSoapyfun\nSoapyshower\nSoapysituation\nSoapyteen\nSoares\nSoaring\nSoavitia\nSoBe\nSober\nSobre\nSobriety\nSobrina\nSoc\nSoCal\nsoccer\nSoccerlove\nSoccertoy\nsocial\nSOCIAL-Abigail\nsocialite\nsociety\nsock\nsocked\nSocket\nSock-Filled\nSockies\nsocking\nsocks\nSockstoy\nSockstoy1\nSockstoy2\nSoco\nSoda\nSodom\nSodomize\nSodomized\nSodomizes\nSodomizing\nSodomy\nSoers\nsofa\nSoffice\nSoffie\nSofi\nSofia\nSofia's\nSofie\nSofie's\nSofi's\nsoft\nSoftball\nsoftcore\nSoftened\nSoftening\nSofter\nSoftest\nSoftie\nSoftlight\nSoftlotionlove\nsoftly\nSoftness\nSoftcat\nsoftware\nSofy\nSofya\nSoggy\nSogni\nSohley\nSoho\nSo-Hoe\nsoInnocent\nSoir\nsoiree\nSol\nSola\nsolace\nSolah\nSolamente\nSolana\nSolange\nSolania\nSolar\nSolari\nSolaris\nsolarium\nSolaya\nSold\nsoldier\nsoldiers\nsoldier's\nSoldiers\nSoldier's\nSoldier-soldier\nsole\nSolecism\nSoleggiato\nSolei\nSoleil\nSoleil's\nSoleli\nSole-ly\nSolemates\nsoles\nSolhey\nSolicitor\nSolicits\nSolid\nsolidify\nSolis\nsolitaire\nSolitare\nSolitary\nSolitude\nSollis\nsolo\nSologirl\nsoloing\nsolo-ing\nSoloing\nSoloist\nSolos\nSolstice\nsolution\nSolutions\nsolve\nsolved\nsolves\nSolving\nsom\nSoma\nSombrero\nsome\nsomebody\nSOMEBODY'S\nsomehow\nsomeone\nsomeone?Enough\nsomeone's\nsomethin\nsomething\nSomething's\nsometi\nSometime\nsometimes\nSomewhat\nsomewhere\nSomian\nSomiet\nSommer\nSommers\nSommerz\nSomnific\nSomthing\nson\nSon?\nson’s\nSona\nSonata\nsonay\nSonay's\nSonechka\nsong\nSongbird\nSongerie\nSongwriting\nSoni\nSonia\nsonialopez\nSonia's\nson-in-law\nSonita\nSoniy\nSonja\nSonja's\nSonny\nSonorilo\nsons\nson's\nSons\nSon's\nSonya\nSonya's\nSoo\nSoolin\nSoolyn\nsoon\nSoon?\nSooner\nsooo\nsoooo\nSooooo\nSooth\nSoothe\nsoothes\nSoothing\nSop\nSophei\nSophi\nSophia\nSophia’\nSophias\nSophia's\nsophie\nSophie's\nSophie-sticated\nsophisticated\nsophistication\nSophomore\nSophya\nsoppin\nsopping\nSoppy\nSoprano\nSorana\nSoraya\nsorbet\nsorceress\nSorcery\nsordi\nsore\nSorest\nSoroity\nsorority\nSorority's\nSorprese\nSorrenti\nSorrow\nsorry\nsort\nSOS\nSosh\nSoThat\nSoto\nSottile\nsoul\nSoul??\nSoul’s\nSoulful\nSoulmate\nSoulmates\nsouls\nSoul-sucking\nsound\nSounded\nsounding\nsounds\nsoup\nSour\nsource\nSourire\nSous\nSousa\nSout\nsouth\nSouthBeach\nSouthcentral\nSoutheast\nsouthern\nSouthwest\nsouvenir\nSouvenirs\nSouza\nSovereign\nSoverign\nSoviet\nSowing\nSoxxx\nSoy\nSp3cial\nspa\nspace\nSpacenuts\nspaces\nSpaceship\nspaceships\nSpade\nspades\nSpade's\nSpaghetti\nSpain\nSpains\nSpan\nSpandex\nSpandexxx\nSpangled\nSpanglish\nSpaniard\nSpaniard's\nspanish\nspanishand\nspank\nspanked\nSpankenmember's\nSpankers\nspanking\nspankings\nSpanko\nSpankomania\nspanks\nSpanksgiving\nSpankvibe\nspanky\nSpar\nspare\nSparetime\nSpare-time\nSparing\nspark\nSparking\nsparkle\nSparkled\nSparklers\nSparkles\nSparkletoy\nSparkling\nSparkly\nSparkplug\nSparkplugs\nsparks\nSpark's\nSparksThe\nSparkx\nsparky\nSparkys\nSparky's\nSparkz\nSparring\nSparrow\nSparrow's\nSparta\nSpartan\nSpartica\nSpartica0-0\nSparx\nSparx's\nSparxx\nsparxxx\nSpa's\nSpasms\nSpatio\nSpattered\nSpatula\nSpaz\nSpeads\nspeak\nSpeakeasy\nspeaker\nSpeaking\nspeaks\nSpeaky\nSpear\nspeared\nspearm\nspears\nspecial\nspecial”\nspecialist\nSpecialists\nSpecially\nSpecial-Orders\nSpecials\nSpecialty\nSpecialty?\nSpecies\nSpecific\nSpecifications\nspecimen\nSpecs\nspectacle\nspectacles\nspectacular\nspectator\nSpectrum\nSpecu-Fun\nspeculum\nSpecu-Yum\nSpeech\nspeechless\nspeed\nSpeedbumps\nSpeeding\nSpeedo\nSpeedos\nSpeedy\nspell\nSpell?\nSpellbinding\nSpellbound\nSpelled\nSpelling\nSpellpound\nspells\nSpelunking\nSpence\nSpencer\nSpencers\nspend\nspending\nspends\nspent\nSpera\nsperm\nSperm?\nSperma\nSperm-addict\nSpermaid\nSpermatic\nSpermbanks\nspermcoktail\nspermed\nSperm-Hungry\nSperminate-Her\nSperminator\nSperm-Man\nsperm-nog\nsperms\nSperm-Slathered\nSperm-Slopped\nSpermsucker\nSperm-Swallowing\nSpermy\nSpesa\nSpew\nSpews\nSPH\nSpheres\nSphinchter\nSphinctacular\nsphincter\nSphincter-Smashing\nSphinx\nSphyncter\nspi\nSpiaggia\nspice\nspices\nSpicey\nSpicing\nSpick\nspicy\nSpider\nSpiderman\nSpiderwoman\nspied\nSpiegler\nSpielberg\nSpielen\nspies\nSpietato\nSpigot\nSpike\nSpikeytoy\nspill\nSpilled\nSpilling\nSpills\nSpilt\nspin\nSpin-Cycle\nspine\nSpinnder\nspinner\nSpinner’s\nSpinnerBTS\nSpinners\nSpinner's\nSpinning\nSpins\nSpiral\nSpiraldong1\nSpiraldong2\nSpiralpop\nSpirals\nspirit\nSpirited\nSpiritique\nSpirito\nspirits\nspiritual\nSpirituous\nSpirm\nspit\nSpit-Drenched\nspiteful\nspitmeister\nSpitroast\nspitroasted\nSpit-Roasted\nSpitroasting\nSpits\nSpit's\nSPITS\nSpitshine\nSpit-Smeared\nSpit-Soaked\nSpitters\nspitting\nSpittle\nSpitty\nSplainin\nSplak\nsplash\nSplashdown\nsplashed\nSplasher\nsplashes\nSplashin\nSplashing\nsplashy\nSplat\nSplatter\nsplattered\nSplattering\nsplayed\nSplays\nSplendes\nSplendid\nsplendor\nSplish\nSplish-Splash\nsplit\nSplit-End\nsplits\nsplit-screen\nSplits-tacular\nsplitsville\nSplitting\nSplit-Tongued\nSplooge\nsplooged\nSplooger\nSplooshed\nSplosh\nSploshed\nSploshing\nsplurge\nsplurges\nSplurging\nspoil\nspoiled\nSpoiler\nSpoilers\nspoiling\nspoils\nspoilt\nspoke\nSponge\nSpongebath\nSpongeclit\nsponsor\nSpontanebooty\nSpontaneity\nspontaneous\nSpoof\nSpook\nspooked\nSpooking\nSpooktacular\nSpooky\nSpool\nspoon\nSpoon-Feeds\nSpoonful\nSpooning\nSpoons\nspor\nsport\nsportin\nSporting\nsports\nSportsgear\nSportsmanship\nSportsmen\nSportstars\nSportster\nSports-Widow\nSportswoman\nsporty\nspot\nSpotmember\nSpotless\nSpotlight\nSpotlights\nspots\nspotted\nSpotter\nspotting\nSpouse\nsprain\nsprained\nSprawled\nspray\nsprayed\nspraying\nsprays\nSprchac\nspread\nSpread?\nSpread-Eagle\nSpreader\nSpreaders\nSpreadin\nSpreadin'\nspreading\nspreadMade\nspreadPulled\nspreads\nSpreadtalks\nSpreadtwo\nspreadum\nSpreadz\nSprechen\nspree\nspring\nSpringbreak\nSpringlare\nSprings\nSpringtime\nSpringvalley\nSpringvalley's\nspringy\nsprinkle\nSprinkler\nSprinkles\nSprint\nSprinting\nSprite\nSprites\nSpritz\nSprocket\nSproket\nSprouting\nSprouts\nSpruce\nSprung\nSpryte\nSpun\nspunk\nspunked\nspunkmeister\nspunks\nSpunk-Slopped\nSpunk-Soaked\nSpunk-swallowing\nSpunky\nspur\nSpurs\nspurt\nSpurts\nSPX001\nSPX002\nSPX003\nSPX004\nSPX005\nSPX006\nspy\nspycam\nspy-cam-make loveed\nSpyce\nSpycey\nSpyder\nSpy-filmed\nSpyhole\nspying\nSpymaster\nspy's\nSpyware\nSqeaky\nSquabble\nsquad\nSquare\nSquared\nsquash\nSquashed\nsquat\nsquats\nSquatter\nSquatter's\nSquattin\nsquatting\nSquaws\nSqueak\nSqueaks\nSqueaky\nsqueal\nSquealer\nSquealers\nSquealing\nsqueals\nSqueegee\nSquee-Jizz\nSqueeky\nsqueeze\nsqueezed\nSqueezer\nSqueezers\nsqueezes\nSqueezin'\nSqueezing\nSqueezins\nSqueky\nSquiggles\nsquiritng\nSquirm\nSquirming\nsquirms\nsquirmy\nSquirrel\nSquirrrrrt\nsquirt\nSquirt'\nSQUIRT\nsquirt?\nSquirtacular\nSquirtage\nSquirtarium\nSquirt-a-thon\nSquirtation\nSquirtbath\nSquirtboarding\nSquirtdown\nSquirted\nsquirter\nSquirters\nSquirter's\nSquirtfest\nSquirtgasms\nSquirtgun\nSquirt-Gushing\nSquirticular\nSquirtin\nSquirtin'\nsquirting\nsquirting?\nSQUIRTING+FIST\nSquirtingDouble\nSquirting-Gaping-DP-Double-Vag\nSquirting's\nsquirt-land\nSquirtmania\nSquirt-Off\nSquirt-O-thon\nSquirt-O-Vision\nSquirtR\nsquirts\nSquirtsalot\nSquirtsational\nSquirt-Soaked\nSquirtus\nSquirt-Wet\nsquirty\nSquiting\nSqurting\nSqurts\nSra\nSS\nSsindy\nsss\nSssexy\nSsy\nst\nStab\nStabber\nStabbin\nStabbing\nStabby\nStability\nStabilna\nStable\nStable-Boy\nStablehand's\nStables\nStace\nStacee\nStacey\nStacey's\nStaci\nStacie\nStaci's\nstack\nstacked\nStacked'\nSTACKED\nStacked-Out\nStacker\nStackers\nStackhouse\nStackin\nstacking\nStacks\nStacky\nStacy\nStacys\nStacy's\nstaff\nStaffer\nStag\nstage\nStaggering\nStagliano\nStagliano's\nStagnant\nstain\nStained\nStains\nStair\nstaircase\nStaircaseorgasm\nStairfun\nStairmaster\nStairplay\nStaircat\nstairs\nstairs?\nStairs2\nStairvibrator\nstairway\nStairwayfun\nStairwaycat\nStairways\nStairwaytoy\nstairwell\nstake\nStakeout\nStakes\nStaks\nStalk\nStalked\nStalked?\nstalker\nStalkerâ€™s\nStalkerland\nStalkers\nStalkHER\nStalking\nStalkings\nstalks\nStall\nStalling\nstallion\nstallions\nStallone\nStallone's\nStalls\nstamina\nStamina?\nstamp\nStamped\nstamping\nStan\nStana\nStance\nstand\nStandard\nstandards\nStandby\nStand-In\nstanding\nStandoff\nstands\nStand-up\nStanley\nStanton\nStanwick\nStanza\nstappado\nStappado'd\nstar\nStar?\nStar’s\nStaranzano\nStarCurrent\nstardom\nStardust\nStare\nStares\nStarfall\nstarfish\nStarmake loveed\nstaring\nstarjuice\nStark\nstarl\nStarla\nstarlet\nstarlets\nstarlet's\nStarlets\nStarlet's\nStarlett\nStarlette\nStarlettea\nStarletto\nStarlight\nStarLocal\nStarly\nStarmaker\nStarnger\nStarr\nStarr;\nStarr’s\nStarrAnal\nStarrHogtie\nStarri\nstarring\nStarr's\nStarry\nstars\nStar's\nStars?\nStar-Spangled\nStarstruck\nstart\nStart?\nStartdom\nstarted\nStarter\nstarters\nStartin\nstarting\nStartled\nStartlet\nStartling\nstarts\nStart-Up\nstarved\nStarvin\nstarving\nStarz\nStas\nStasey\nStash\nStasha\nStasia\nStasi's\nStbootyi\nStbootyia\nStasy\nStasy’s\nStasya\nStasy's\nStat\nstate\nStately\nStatic\nstation\nStationary\nStatue\nStatuesque\nStatuette\nstature\nstatus\nStax\nStaxx\nStaxxx\nstay\nStay?\nStay-At-Home\nstaycation\nStay-cation\nstayed\nStayin\nstaying\nstays\nSt-Clair\nSt-Croixx\nstead\nSteady\nsteak\nsteal\nStealer\nstealing\nsteals\nStealth\nStealthy\nsteam\nSteamed\nsteaming\nsteaming-hot\nSteamrolled\nSteamroom\nsteams\nSteamworks\nsteamy\nSteed\nsteel\nSteele\nSteelers\nSteele's\nsteeped\nsteer\nSteering\nStefan\nStefana\nStefani\nStefania\nStefania's\nStefanie\nStefanie's\nStefano\nStefanos\nStefany\nSteffanie\nSteffany\nSteffie\nStegal\nSteil\nStein\nSteinkopf\nSteliana\nStella\nStella’s\nStellar\nStellas\nStella's\nStello\nStemei\nStems\nStem-tastic\nStena\nStenciling\nStendhall\nstep\nStepa\nStepanska\nStep-Aunt\nStepchick\nstepbro\nstep-bro\nStepbro\nStep-Bro\nStepbro??\nStepbro’s\nStepbros\nStepbro's\nStep-Bros\nStep-Bro's\nstepbrother\nstep-brother\nStepbrother\nStep-brother\nStepBrother\nStep-Brother\nstepbrother’s\nStep-Brother’s\nStepbrotherly\nStep-Brotherly\nstepbrother's\nstep-brothers\nStepbrothers\nStepbrother's\nStep-Brother's\nStepmember\nStepcousin\nStep-cousin\nStep-Cousin's\nstep-d\nstepdad\nstep-dad\nStepdad\nStep-dad\nStepDad\nStep-Dad\nStepdad?\nstepdad’s\nStepdaddy\nStep-Daddy\nstep-daddy's\nStepdaddy's\nStep-Daddy's\nstepdads\nstepdad's\nstep-dad's\nStepdads\nStepdad's\nStepDads\nStepDad's\nStep-Dads\nStep-Dad's\nStepDadWith\nStepdaugher\nstepdaughter\nStep-Daughter\nSTEPDAUGHTER\nStepdaughter’s\nstepdaughter's\nStepdaughters\nStepdaughter's\nStep-Daughters\nStep-Daughter's\nstepddad\nStepjohnson\nStep-Douche\nStepfamily\nStep-Family\nstepfather\nStep-father\nStepfather’s\nStep-father’s\nStepfathers\nStepfather's\nStep-Fathers\nStep-Father's\nStepford\nStepgrandpa\nStep-Grandpa\nSteph\nStephani\nStephanie\nStephanie's\nStephani's\nStephannie\nStephany\nStephen\nStephens\nStephie\nStep-In-Love\nStepis\nStepisters\nStepkids\nStep-Lessons\nStepmama\nStepMILF\nstepmom\nstep-mom\nStepmom\nStep-mom\nStepMom\nStep-Mom\nstepmom’s\nStep-Mom’s\nStepmommy\nStep-Mommy\nstepmoms\nStepmom's\nStep-mom's\nStepMom's\nStep-Mom's\nstepmother\nStep-mother\nSTEPMOTHER\nStep-Mother-Daughter\nStepmothers\nStepmother's\nStep-Mother's\nstep-neice\nstep-niece\nStep-Nieces\nStep-Parents\nStep-Parent's\nstepped\nStepping\nSteppy\nsteps\nStepShower\nStepsibiling\nStepsibling\nStep-Sibling\nStepsiblings\nStep-siblings\nStepSiblings\nStep-Siblings\nstepsibs\nstepsis\nstep-sis\nStepsis\nStep-sis\nStepSis\nStep-Sis\nStepsis’\nStepsis's\nstepsister\nstep-sister\nStepsister\nStep-sister\nStepSister\nStep-Sister\nStepsister’s\nStep-Sister’s\nstepsisters\nStepsister's\nStep-sisters\nStepSisters\nStep-Sisters\nStep-Sister's\nSTEP-Sisters\nStepsisters’\nstepson\nstep-son\nStepson\nStep-son\nStepSon\nStep-Son\nSTEPSON\nstepson’s\nStepsons\nStepson's\nStep-Sons\nStep-Son's\nStepThief\nStep-Tradesies\nstep-uncle\nStepuncle\nStepuncles\nStep-Walker\nSteren\nSterling\nSterling's\nSterlyng\nStern\nStern's\nSteroid\nsterted\nstethoscope\nSteve\nSteven\nStevens\nSteven's\nSTEVENS\nStevens's\nStevensThe\nStevensThis\nStevenz\nStevie\nStevie's\nstevo\nStew\nSteward\nstewardess\nSteward's\nStewart\nStewart?18\nSthefanny\nSthefany\nstick\nsticked\nSticker\nStickers\nStickin\nsticking\nsticks\nsticky\nSticky1\nSticky2\nStickyfun\nSticky-Sweet\nStiel\nstiff\nStiffany\nstiffen\nstiffener\nStiffening\nstiffens\nstiffie\nstiffies\nStiffly\nstiffy\nstiletto\nstilettos\nstill\nStillar\nStills\nStilts\nStim\nStimula\nStimulant\nStimulas\nstimulate\nStimulated\nstimulates\nstimulating\nstimulation\nStimulations\nStimulators\nStimulus\nsting\nStinger\nStinging\nStingray\nStingrey\nStink\nstinkhole\nStinkin\nStinking\nStinky\nstiptease\nstipteasing\nStir\nStirling\nstirred\nStirring\nstirs\nScanch\nScanches\nStIves\nSt-Ives\nStJames\nstoat\nstock\nstockade\nStocked\nStockholm\nstocking\nStockinged\nstockings\nstockings?\nStockings1\nStockings2\nStockingshoot\nStockingshoot2\nStockingtoy\nstocks\nStoke\nStoked\nStokely\nstokes\nStokley\nstole\nstolen\nStoli\nStolidity\nStolie\nStom\nstomach\nStomp\nStompers\nStomping\nStomps\nstone\nStone’s\nStoned\nStoneGirl\nStonell\nStonem\nStones\nStone's\nStonewall\nStoney\nStood\nstool\nStools\nSTOOPID\nstop\nStopI'm\nstopped\nStopper\nStoppers\nStoppin'\nstopping\nstoppingORGASMS\nstops\nstorage\nstore\nStored\nStoreroom\nStoreShe\nstorewith\nstories\nstorm\nStormDay\nStormin\nStorming\nStorms\nStorm's\nStormtrooper's\nstormy\nStormy's\nStorri\nstory\nStoryboard\nStorytime\nStoune\nStove\nStovne\nStow\nStowaway\nStowaway's\nStoya\nStPatty's\nST-PORNO'S\nStr8\nStracciatella\nStracy\nStraddles\nStraddling\nstraight\nstraight?\nStraighten\nStraightened\nStraightening\nStraightforward\nstraightjacket\nStraight-To-Anal\nStrain\nStrait\nStraitjackets\nstranded\nStrandling\nstrange\nstranger\nstrangers\nstranger's\nStrangers\nStranger's\nSTRANGERS\nStrangest\nstrangulation\nstrap\nStrap_On\nStrap-busting\nStrapdomme\nStrap-Mom\nstrapon\nstrap-on\nStrapon\nStrap-on\nStrapon;\nStrap-On'ed\nStrap-ons\nStrap-on's\nStrap-Ons\nStrap-On's\nstrappado\nstrappado'd\nstrappadoIntense\nstrapped\nstrapping\nstrappy\nStrategies\nStrategy\nStratosphere\nStraw\nStrawberries\nstrawberry\nStrawberry1\nStraws\nstray\nstreached\nStreak\nStreaker\nStreaking\nStreaks\nstream\nStreamen\nStreaming\nstreams\nStrectched\nstrecthed\nstreet\nStreetBound\nstreets\nstreetwalker\nStreetwalking\nStrelnice\nstrength\nstres\nstress\nStressed\nStress-Free\nstressful\nStress-handling\nStressless\nstretch\nStretch?\nStretchdong\nstretched\nStretcher\nStretchers\nstretches\nStretch-her\nStretchin\nstretching\nStretch-Sister\nStretchy\nStretton\nStrickly\nstrict\nStrictly\nstride\nstrike\nStriker\nstrikes\nStriking\nString\nStringers\nStringing\nStringkini\nstrings\nStrings'\nstrip\nStrip’n\nstripclub\nStrip-Club\nStripe\nStripease\nstriped\nStripedfingers\nStripedpanties\nStripecat\nstriper\nStripers\nstripes\nStripes2\nStripeskirt\nStripestockings\nStriplotion1\nStriplotion2\nStrip-O-Gram\nstripped\nStripped-Down\nstripper\nStripper’s\nstrippers\nstripper's\nStrippers\nStripper's\nStrippin\nstripping\nstrips\nStripshow\nStripspread\nstriptease\nstripteasedouble\nStripteaser\nstripteases\nstripteasing\nstrip-teasing\nStripteasing\nStripteasy\nstriptesae\nstriptise\nStriscio\nStrok\nStrokahontas\nstroke\nstroked\nStrokemoff\nStroker\nStrokers\nstrokes\nstrokin\nStrokin'\nstroking\nstroll\nStrolls\nstrong\nstronger\nstrongest\nstrongly\nStrong-Willed\nstruck\nStructure\nStrudel\nstruggle\nstruggles\nstruggling\nStrum\nStrummin\nStrumming\nStrumpet\nstrung\nStrut\nstruts\nStrutting\nStryker\nstu\nStuart\nStub\nStubborn\nStuccoed\nstuck\nStucked\nStuck-Up\nstud\nStud?\nStud’s\nstudent\nStudent?\nstudent’s\nStudentDisobedient\nstudent-first\nstudents\nstudent's\nStudents\nStudent's\nStudent-teacher\nStudfinder\nstudies\nstudio\nstudiogonzo\nstudios\nStudiotoys\nStudious\nStudiovibe\nStudly\nstuds\nstud's\nStuds\nStud's\nStuds?\nStudway\nstudy\nStudy-Abroad\nStudy-Buddy\nstudymake love\nstudying\nstuff\nstuff?\nstuffed\nstuffening\nStuffer\nStuffers\nStuff-Her\nStuffin\nstuffing\nStuffings\nStuffins\nstuffs\nStulback\nstumble\nstumbles\nStun\nStunna\nStunnas\nstunned\nstunner\nStunner’s\nstunners\nStunner's\nstunni\nstunning\nStunningly\nStuns\nStunt\nStunts\nStupendous\nstupid\nSturdy\nSturm\nStuttering\nSt-Valentine's\nStyes\nstyle\nStyle'\nstyled\nStyles\nStyles\tSuper\nStylesDoing\nStyles's\nStylez\nStylez's\nStylin\nStylin'\nstylish\nStylishly\nstylist\nStylists\nStylle\nsu\nSuave\nSuaves\nsub\nSub’s\nSubbing\nsub-boy’s\nsubby\nSubculture\nSubDivideMe\nsubdued\nsubgriff\nSubhumanoid\nSubibaja\nSubil\nSubject\nSubjected\nSubjects\nSubjugated\nSublet\nSubletter\nSublime\nSubliminal\nsubmarine\nSubmarines\nsubmerged\nSubmi\nsubmission\nsubmissions\nsubmissive\nSubmissived\nsubmissives\nSubmissive's\nsubmit\nSubmit?\nsubmits\nsubmitted\nsubmitThen\nSubmitting\nsubs\nSub's\nSubscribed\nSubscription\nsubscriptions\nSubservient\nsubspace\nsub-space\nSubspace\nsubstantial\nsubscanute\nsubscanutes\nSubscanution\nSubtext\nSubcanle\nSubtle\nSubtly\nSubtracting\nSuburban\nSuburbia\nSuburbs\nSubversive\nsubway\nSucceed\nsuccess\nSuccessful\nsuccessfully\nSucc-sex\nsuccubus\nSuccubus0-0\nSucculence\nsucculent\nSucculento\nSucculents\nSuce\nsuch\nSuchell\nSucia\nsuck\nsuck?\nSucka\nSuckable\nSuckage\nSuck-cess\nSUCKcess\nsucked\nsuckedso\nsucker\nsuckered\nsuckers\nSuckfest\nSuck-Her\nSuckie\nsuckin\nsucking\nSucking'\nSUCKING\nSuckjob\nSuckled\nSucklers\nsuck-loving\nSuck-n-Make love\nsuckoff\nSuck-off\nSuckretary\nsucks\nSuckseed\nSucksess\nSuck-Sex\nSuckstress\nSuck-That-Member\nSUCKtion\nsucky\nsuction\nSuctionmember\nSuctiondong\nSuctioned\nsudden\nSuddenly\nSUDORA\nSuds\nSudsing\nSudsy\nSudz\nSue\nSue’s\nsuede\nSuedecouch\nSuelen\nSueno\nSuerte\nSue's\nsuffer\nsuffered\nsuffering\nsuffers\nsuffersCategory\nsuffersthough\nSufficient\nSuffin\nsuffocate\nSuffocated\nSuga\nSugal\nsugar\nSugar?\nSugarbabe\nSugarbaby\nSugar-Coated\nsugardaddy\nsugardaddy's\nSugarmommy\nSugars\nSugar's\nSuggested\nsuggestion\nSuggestive\nsuggests\nSugian\nsuicidal\nSuicide\nsuit\nSuitcase\nSuite\nSuited\nSuiteheart\nsuitor\nsuits\nSuka\nSuki\nSukis\nSullivan\nsullying\nSultan\nSultan's\nSultress\nsultry\nSultry’s\nSum\nSumkit\nSumma\nsummer\nsummer?\nSummer’s\nSummerbreeze\nSummerlin\nSummers\nSummer's\nSummers's\nsummertime\n'Summertime\nSummery\nSummit\nSummon\nSummoned\nSummoning\nSummons\nsumptuous\nSumthin\nSumthin'\nsun\nSunbaked\nSunbath\nSunbathbeauty\nSunbather\nSunbathers\nsunbathing\nSunbeam\nSunbreak\nSunburned\nSunburnt\nSuncatcher\nsunchair\nsundae\nsundae?\nSundaes\nsunday\nSundays\nSundeck\nsundown\nSun-drenched\nsundress\nSundy\nSunfired\nsunflower\nSunflowers\nSunflowers2\nSunflowertoy\nSunglbooty\nSunglbootyes\nsunk\nSunkissed\nSun-kissed\nsunlight\nSunlit\nSunn\nsunni\nSunnie\nSunning\nSunnixa\nSunnny\nsunny\nSunnydale\nSunnyday\nSunnydeck\nSunnys\nSunny's\nSunpool\nSunrays\nsunrise\nSunroom\nSuns\nSun's\nSunscreen\nsunset\nSunset-\nSUNSET\nSunsetaneous\nsunshine\nSunshine's\n'Sunshining'\nSunshower\nSunshyne\nsuntan\nsuntanned\nSuono\nsupa\nSupa-Dupa\nsuper\nSúper\nSuper-agile\nsuperb\nSuperball\nSuperbang\nSuperbarbies\nSuperbia\nsuperbly\nSupermelon\nSuperbowl\nsuper-busty\nSupercalafraga-Lick\nsuper-charged\nSupercharged\nSuper-Charged\nSupermember\nSupercute\nSuperdanglers\nSuper-Doc\nSuper-Femme\nSuperfetation\nSuper-Freak\nSuper-freaky\nSuper-make love\nSupergirl\nSupergranny\nSuperhero\nSuperhot\nSuper-hot\nSUPERHOT\nsuper-hottie\nSuper-Hung\nsuper-hungry\nSuperintendent\nSuperior\nSuperiority\nSuperlatives\nSuperman\nSupermarket\nSuper-Minka\nsupermod\nsupermodel\nSupermonster\nSupernatural\nSuper-natural\nSupernaturally\nSupernaturals\nSupernova\nSuperpowers\nsuper-se\nsupersedes\nSuper-sexy\nSupershowers\nSupersize\nSupersized\nSuper-sized\nSuper-sizes\nsuperslut\nSuper-slut\nSupersluts\nSupersoak\nSupersoaker\nSuperSophie\nSupersperm\nsuper-stacked\nsuperstar\nSuperstars\nSuperstar's\nSuper-Stunner\nSupersucker\nSupersweet\nSuper-Tight\nSuper-cans\nSupervibe\nSupervillians\nSupervised\nsupervision\nSupervisor\nSupervixen\nSuperwand\nSuper-wild\nSuperwoman\nSuplex\nsupper\nSupper's\nsupple\nSupplies\nsupply\nSupport\nsupported\nSupporting\nSupportive\nsuppose\nsupposed\nSuppository\nSuppresses\nSupravaginal\nSuprema\nsupremacy\nsupreme\nSupremely\nsuprise\nSuprised\nSuprisingly\nSur\nsure\nSurefire\nSurewood\nSurf\nSurface\nSurfaces\nSurfacing\nSurfboard\nsurfer\nSurfer’s\nSurfer's\nsurfin\nsurfing\nSurfistinha\nSurfs\nSurf's\nSurge\nSurgeon\nsurgeons\nsurgery\nSurgical\nSuri\nSurnaturel\nsurprise\nSurprise'\nsurprised\nsurprises\nSurprising\nSurprisingly\nSurreal\nsurrealist\nSurrender\nSurrendering\nsurrenders\nSurrender's\nSurrogate\nSurrounded\nSurta\nSurveillance\nSurvey\nSurveying\nSurvival\nSurvivalist\nSurvive\nSurvived\nsurvives\nSurviving\nSurvivor\nSus\nsusan\nSusan?\nSusana\nSusane\nSusane's\nSusanna\nSusannah\nSusanne\nSusan's\nSusceptible\nsushi\nSusi\nSusie\nsuspect\nsuspect's\nsuspend\nsuspended\nsuspendedMake loveed\nsuspenders\nSuspends\nSuspense\nsuspension\nsuspensionCaned\nSuspensionFirst\nsuspensionHow\nsuspensions\nSuspensionStripped\nSUSPENSIONTwo\nsuspicious\nSuspiciously\nSusy\nSusy's\nSutra\nSutra's\nSutton\nSuxxx\nSuz\nSuzan\nSuzana\nSuzanna\nSuzanne\nSuzanny\nSuze\nSuzi\nSuzie\nSuzie’s\nSuzie's\nSuziha\nSuzi's\nSuzuki\nSuzumi\nSuzumi's\nSuzy\nSuzy's\nSuzzie\nSV\nSveiki\nsvelte\nSveta\nSvetik\nSvetlana\nSvitlana\nSvitlana's\nSvjat\nSvobodova\nSwaberry\nSwabery\nSwaddled\nSwag\nswagbunxious\nswagger\nSwallop\nswallow\nswallow?\nswallowed\nSwalloween\nswallower\nSwallowers\nSwallowHer\nswallowing\nswallows\nSwallow's\nSWALLOWs\nswamp\nSwan\nSwank\nSwanlike\nSwann\nSwans\nSwan's\nSwany\nswap\nSwap'\nSWAP\nSwaperoo\nSwapped\nSwappers\nSwappin\nswapping\nswaps\nSwarmed\nswarthy\nSwartz\nsway\nswayin\nSwaying\nSwayze\nSwayzo\nSwaziland\nswear\nSwears\nsweat\nSweater\nSweater2\nSweaters\nSweatfest\nSweatheart\nSweathearts\nSweatin\nsweating\nsweatingsquirting\nsweats\nSweatshop\nsweaty\nSwed\nSwede\nSwede's\nswedish\nSweeet\nSweep\nSweeper\nSweepstakes\nsweet\nsweet_booty\nsweet_hole\nsweet_juice\nsweet_sweater_anal_angel\nsweetcake\nSweeten\nSweetened\nSweetener\nSweetening\nsweetens\nsweeter\nsweetest\nsweetheart\nSweetheart’s\nsweethearts\nSweetheart's\nSweethole\nsweetie\nSweetie?\nsweeties\nSweet-looking\nsweetly\nSweet'n\nSweetness\nSweet-ness\nsweets\nSweet's\nSweets's\nSweetstorm\nSweetsweet\nSweety\nSweetz\nSwell\nSwelling\nswells\nswelter\nSweltering\nSwept\nswerve\nSwetie\nswift\nSwift's\nswim\nswim?\nswiming\nswimmer\nswimmers\nSwimmer's\nSwimmin'\nswimming\nSwimming?\nswimmingpool\nswims\nswimsuit\nSwimsuits\nSwimteam\nswimwear\nSwindled\nSwindling\nSwine\nswing\nswinger\nswingers\nswinger's\nSwingers\nSwinger's\nswingin\nswinging\nSwings\nSwingtime\nSwipe\nSwipes\nSwiping\nswirl\nSwirling\nswirls\nSwish\nSwiss\nswitch\nSwitchable\nSwitchables\nSwitch-aroo\nSwitch-a-roo\nSwitchblade\nSwitcheroo\nswitches\nSwitching\nswitchs\nSwitch-Up\nSwitzerland\nSwix\nSwole\nSwolemates\nSwoll\nswollen\nSwoon\nSwooning\nSwoop\nsword\nSwords\nSwtich\nSwurvy\nSX573\nSX797\nSy\nSybelle\nsybian\nSybian1\nSybian2\nSybianAny\nSybianchat\nSybianfun\nSybianHelpless\nsybianmade\nSybianpleasure\nSybianride\nSybians\nSybian's\nSybiantwo\nSybil\nSybilissima\nSybill\nSybille\nSybil's\nSyd\nSyde\nSydnee\nSydnee's\nSydney\nSydneys\nSydney's\nSydonia\nSye\nSyllabus\nSylvana\nSylver\nSylvi\nSylvia\nSylviana\nSylvie\nSylvi's\nSymbiotic\nSymbol\nSymon\nSymone\nSymone's\nsympathy\nsymphony\nSympli\nsync\nSynchronicity\nSynchronize\nSynchronized\nSyndee\nSyndrome\nSyndy\nSynergy\nSynesthesia\nSynful\nSynn\nSynolo\nsynonymous\nSynthia\nSynz\nSyra\nSyre\nSyren\nSyrens\nSyren's\nSyre's\nSyrines\nSyrup\nSyrupy\nSystem\nSystems\nSytnyy\nSz\nSzabina\nSzalontai\nSzandi\nSzandi-Baby\nSzanto\nSzelina\nSzerepet\nSzilvia\nSzindy\nSzofy\nSzofya\nSzoke?\nSzombathely\nSzuza\nSzuzanne\nSzuzanne's\nSzuzie\nt\nT4K\nta\nTab\ntabatha\nTabby\nTabby's\nTabela\nTabitha\ntable\nTablefinger\nTablefingers\nTablefun\nTablelove\nTableplay\nTablecat\ntables\nTablespread\ntablet\nTableteen\nTabletop\nTable-top\nTabletopfun\nTabletopplay\nTabletopcat\nTabletopspread\nTabletoptoy\nTabletouch\nTabletoy\nTablekitty\nTablevibe\nTabloids\ntaboo\ntaboos\nTaboo's\nTabouret\nTac\nTachmee\ntacking\ntackle\ntackles\nTackling\nTaco\nTacori\nTacos\nTactic\ntactical\nTactically\ntactics\nTactus\nTad\nTae\nTae's\nTaft\ntag\nTaggart\nTaggart's\ntagged\nTaggin\nTagging\nTAGS\nTagteam\nTag-Team\nTag-Teamed\nTag-teaming\nTahiti\nTahnee\nTahoe\nTai\ntail\nTailbone\ntailed\nTailgate\nTailgating\nTailing\nTaillon\ntailor\nTailored\nTailoring\nTailpipe\ntails\nTAILSandTHIGH\nT'aime\nTaiming\nTainah\ntaint\nTainted\nTais\nTaisa\nTaisiya\nTaissia\nTaissia's\nTaisya\nTaj\nTaja\ntak\ntake\nTake A\ntake?\ntake?Obviously\ntake_it_all\ntake-Down\nTakedown\nTakedowns\ntaken\nTakeNoPrisoners\nTakeoff\nTake-Off\ntake-off?\ntakeon\ntakeout\nTake-out\ntakeover\ntaker\nTakers\ntakes\ntake's\nTakes\nTakes A\ntakes?\ntakexs\ntakin\ntaking\nTakuo\nTala\nTala's\nTalbootyo\ntale\ntalent\ntalented\ntalents\ntales\nTali\nTalia\nTaliana\nTalia's\nTalina\nTalita\ntalk\ntalk?\ntalked\ntalker\nTalkers\nTalker's\nTalkin\nTalkin'\ntalking\ntalks\nTalkspread\nTalkspread1\nTalkspread2\nTalktome\ntall\nTallahbootyee\ntallest\nTallia\nTallie\ntally\ntallywacker\ntally-whacker?\nTallywhackers\nTalon\nTalore\nTalous\nTam\nTama\nTamale\nTamana\nTamara\nTamber\nTambien\nTambor\ntame\ntamed\nTameka\nTamer\nTamers\ntames\ntaming\nTamingTammie\nTammi\nTammie\nTammy\nTammy's\nTampa\nTampering\nTamra\ntan\nTana\nTanaka\nTana's\nTanata\nTandem\nTandy\ntang\nTangerine\nTangerines\nTangi\nTangible\ntangle\nTangled\nTangling\ntango\nTangy\nTania\nTaniella\nTanielle\nTanika\nTanita\nTanitsu\nTanja\ntank\nTanka\nTank-Top\nTanline\nTan-lined\ntanlines\ntanned\nTannedbabe\nTanner\nTannerly\nTanners\nTanner's\ntanning\nTanny\nTans\nTanskirt\nTantalises\nTantalising\ntantalize\nTantalized\ntantalizer\nTantalizers\ntantalizes\ntantalizing\nTantalizingly\nTantra\ntantric\nTantrum\nTanx\nTanya\nTanyaa\nTanya's\nTanza\nTao\ntap\nTapanga\nTapas\ntape\nTape?\nTaped\ntapeManhandled\ntapes\ntape-slave\nTaping\nTapped\nTapper\nTappin\nTapping\ntaps\nTara\nTara’s\nTara's\ntarde\nTardy\nTareas\nTarey\nTarget\nTargets\nTariamvi\nTarian\nTarja\nTarot\ntarp\nTarps\nTarra\nTarra's\nTarrasque\ntart\nTartan\nTartar\ntarts\nTart's\nTaryn\nTarzan\ntas\nTa's\ntase\nTased\nTash\nTasha\nTasha's\nTashiro\nTasia\ntask\nTaskforce\nTasks\nTbootyels\ntaste\nTaste?\ntaste-a-thon\ntastebuds\nTasted\nTastee\nTastees\nTasteful\nTaster\nTasters\ntastes\nTastey\nTastic\nTastier\ntastiest\nTastiness\ntasting\ntastings\ntasty\nTasty-Booty\nTastyfingers\ntat\nTata\nTatalila\nTatana's\nta-tas\nta-ta's\nTatas\nTa-tas\nTata-Tastic\nTate\nTater\ntaters\ntatersHeavy\ntatersSexual\nTaterz\nTati\nTatiana\nTatiana's\nTatiane\nTatianna\nTatiyana\nTatjana\nTatjana's\nTatoo\nTatra\ntats\nTatt\nTattas\nTatta's\nTatted\nTatted-Up\nTattle\nTattletale\ntatto\nTattoed\ntattoo\nTattoo?\ntattoo_shop\ntattooed\nTattoo'ed\nTattooedpierced\nTattooing\ntattoos\nTatts\nTatum\nTatumn\nTatum's\nTatya\nTatyana\ntaught\nTaunt\nTaunted\ntaunts\nTaurus\nTaut\nTavalia\nTavares\nTavern\ntawdry\nTawni\nTawny\nTax\ntaxed\nTaxes\ntaxi\nTaxing\ntaxis\nTaxman\nTay\nTaya\nTaybre\nTayla\nTaylan\nTayla's\nTaylee\nTayler\nTayler's\ntaylor\nTaylored\nTaylors\nTaylor's\nTaytum\nTazed\nTazing\nT-Ball\nT-Beauty\nTBYB\nTchanka\nTchekan\nTchernei\ntcherney\nTDD\nT-Diva's\nT-Doll\nte\ntea\nTeabag\nTeabaggers\nteach\nteacher\nteacher?\nteacher’s\nteachers\nteacher's\nTeachers\nTeacher's\nTEACHERS\nTeacher-Student\nTeacher-to-be\nteaches\nTeachin\nteaching\nteachings\nTeagan\nTeaganism\nTeagan's\nTeager\nTeajul\nTeal\nTeale\nteam\nTeamBrutal\nteamed\nTeamed-Extreme\nteamedFinger\nTeam-make love\nteamMake loveing\nTeamin\nteaming\nTeammate\nteammates\nTeamRain\nteams\nTeam's\nTeamSkeet\nTeamster\nteamwork\nTeanna\nTeapot\ntear\nteardrop\nTearing\ntears\nTears?\ntease\nTea-se\nTEASE\nTease?\nteased\nTeasen\nteaser\nteaser?\nteasers\nTeasers’\nteases\nTeasin\nTeasin'\nTeasin’\nteasing\nTeasy\nTeatime\nTeatimetoys\nTeazy\nTebow\nTech\ntechie\nTechnic\nTechnical\ntechnician\nTechnicolor\ntechnique\ntechniques\nTechno\nTechnology\nTecnician\nTed\nTeddi\nteddy\nTeddybear\nTeddy's\nTedesco\nTEDxxx\ntee\nTeegan\nTeeing\nTeem\nteen\nteen’s\nTeena\nteenage\nTeenagebedroom\nteenager\nteenagers\nTeenager's\nTeenah\nTEEN-aholics\nTeenbed\nTeenchat\nTeendong\nTeened\nTeenfidelity's\nTeenfinger\nTeenfingers\nTeenfondle\nTeengasm\nteengirl\nTeenhole\nTeenholes\nteenie\nTeeniechat\nTeenieholes\nTeenies\nTeenjugs\nTeenlicious\nTeenlove\nTeen-Lover's\nTeenorgasm\nTeenpink\nTeenpinkcat\nTeencat\nteens\nteen's\nTeens\nTeen's\nTeens4Sale\nTeenshower\nteen-slut\nTeentalk\nTeentie\nTeentime\nTeencans\nTeentoy\nTeentoys\nTeenkitty\nTeenvibe\nTeenworkout\nteeny\nteeny?\nTeenyblacked\nteeny's\nTeera\nTees\nteeth\nTeets\nTeeya\nTeflon\nTegan\nTei\nTeighiana\nTeighjiana\nTekiara\nTelefonando\nTelegram\nTelemarket\nTelenovela\ntelepathy\ntelephone\nTelephoning\nTelePoondo\nTelescope\nTelethon\nTelevision\nTeliko\nTeliulis\ntell\nTell?\nteller\ntelling\ntells\nTelltale\nTellula\nTemenian\nTemp\nTempe\ntemper\nTemperament\nTemperances\ntemperature\nTempered\nTempest\nTempestuous\nTempio\nTemplar\ntemple\ntempo\nTemporary\nTempress\nTemps\nTemp's\ntempt\ntemptation\nTemptations\ntempted\ntemptin\ntempting\ntemptress\ntemptresses\ntempts\nTemtping\nten\nTenacious\ntenant\nTenants\nTend\nTendance\ntendencies\ntender\ntenderes\nTenderized\ntenderly\ntenderness\nTenderoni\nTending\nTends\nTenebrarum\nTengo\nTen-I-See\nTenkias\nTenna\nTennessee\nTennessees\ntennis\ntennis-court\nTennisSimple\nTenniskitty\nTenpin\nTENS\nTense\ntension\ntensions\nTenso\ntent\nTentacle\nTentant\ntents\nTenue\nTeo\ntequila\nTequila's\nTequis\ntera\nTeradise\nTerapia\nTera's\nTeratai\nTeraxe\nTercera\nTere\nTerell\nTerenza\nTeresa\nTeresa's\nTeresse\nTereza\nTerezska\nTeri\nTeria\nTerka\nTerm\nTerminates\nTerminating\nterminator\nTerminator's\nTermi-Nigga\nTerms\nTerra\nTerrace\nTerracotta\nterrain\nterrbooty\nTerrbootye\nTerrazza\nTerrazzo\nTerrestrials\nTerri\nterrible\nTerribly\nterrific\nTerri-fic\nTERRIFIC\nTerrifying\nTerriority\nTerri's\nTerritorial\nTerritory\nterror\nTerrorist's\nTerrorize\nterrorized\nTerrorizing\nTerry\nTerryn\nTerry's\nTesia\nTesla\nTess\nTessa\nTessa's\nTesse\nTessy\ntest\ntest2\nTest-Drives\ntested\nTester\nTest-make loves\nTesticle\ntesticles\nTestin\ntesting\ntestMade\ntestosterone\ntests\nTesty\nTetangas\ntetas\nTete-A-Tete\ntethered\nTetona\nTetti\nTetyana\nTevan\nTex\nTexan\nTexans\nTexas\nTexbooty\nTex-Booty\nTexas-size\nTexas-Sized\nTexeira\nTexMex\nTex's\ntext\ntextbook\nTexter\nTexting\nTexts\nTextual\nTeyra\nTF's\nTFSN\ntgif\nTGIFriday\nTgirl\nT-Girl\nT-Girls\nT-Girl's\nth\ntha\nThad\nThai\nThai?\nThai-ed\nThailand\nThaina\nThais\nThai's\nThaissa\nThal\nThalia\nThalya\nthan\nThane\nThang\nThangs\nthank\nThanked\nthankful\nThanking\nthanks\nThanksgiving\nThanks-Giving\nThankyou\nThar\nthat\nthat?\nThat??\nThat’s\nthats\nthat's\nThats\nThat's\nThaw\nThawed\nThay\nThayer\nThays\nthe\n'The\nTHe\nThe Colombian membersucking\nthe_booty_bunch\nthe_turkey_best_friend\nthe2008\nThea\nThea's\ntheater\ntheatre\nTheatro\nThebes\nThebunny1\nThebunny2\nThecla\nthee\nthee?\ntheese\ntheft\nThegirlnextdoor\nTheia\nTheif\ntheir\nthem\nthem?\nTheme\nthemed\nThemis\nthemselves\nthen\nTheo\nTheodora\ntheory\nTheo's\nThepair\nTherabbit\nTherabbit1\nTherabbit2\nTherapeutic\nTheraphsody\ntherapist\nTherapist’s\ntherapists\ntherapist's\ntherapy\nthere\nthere?\nthere’s\nthereâ€¦â€¦\nthere's\nTheres\nThere's\nTheresa\nTherese\nThereza\nThermal\nthermometer\nthese\nThese?\nThesis\nThespian\nThesybian\nTheThe\nThetub\nTheUpperFloorcom\nThevibrator\nthey\nThey’re\nTheyll\nthey're\nTheyre\nThey're\nthey've\nthi\nThia\nThicc\nThiccness\nThich\nthick\nThicken\nThickens\nThicker\nthicket\nthickicious\nThickie\nThickness\nThick-shaped\nThicky\nthief\nThierry\nthieve\nThievery\nthieves\nThieving\nthigh\nThigh-high\nthigh-highs\nThighhighs\nthighs\nThighs?\nthin\nthing\nThing?\nthings\nthing's\nThings\nthink\nthink?\nThinker\nthinking\nThinking?\nthinks\nThinks?\nThinner\nthir\nthird\nthirst\nThirsting\nThirsts\nthirsty\nthirsty?\nThirteen\nThirty\nThirty-Eight\nthis\nThis?\nThis??\nThisGirlSucks\nThiz\nThoat\nThoated\nThom\nThomas\nThomas's\nThompson\nthong\nThongs\nThor\nThorn\nthorne\nthorns\nThornton?\nThornton's\nthorough\nThoroughbred\nThoroughly\nthose\nThot\nThots\nThottie\nThotty\nThou\nthough\nthought\nthoughts\nthousan\nthousand\nthousands\nthousandth\nThrashed\nThrasher\nThrashin\nThrashing\nThre\nThread\nThreat\nthree\nThreefifteen\nThreefifteen2\nThreefinger\nThreefingergirl\nThree-Hole\nthree-on-two\nThree-Ring\nThrees\nThree's\nTHREEShannon\nThreeSlave\nthreesom\nthreesome\nthreesomes\nThreesome's\nthreesone\nThree-Timing\nthreeway\nthree-way\nThreeway\nThree-way\nThreeways\nThreshold\nThresholds\nthressome\nThrice\nThrift\nThrifty\nthrill\nThrilla\nThrilled\nThriller\nThrillers\nThrill-Her\nthrilling\nthrills\nthroat\nthroat?\nthroated\nthroat-filled\nThroatFlogged\nThroatmake love\nthroat-make loveed\nthroat-make loveing\nThroatmake loveing\nThroat-make loveing\nThroathing\nthroating\nthroat-cat\nthroats\nThroatskills\nThroatsluts\nThroaty\nthrob\nthrobber\nThrobbin\nthrobbing\nthrobs\nThrone\nThrones\nthrottle\nthrough\nThrough A\nthrow\nThrowback\nthrowdown\nthrowing\nthrown\nthrows\nThru\nthrust\nThrusted\nThrusters\nThrusting\nthrusts\nThug\nthugs\nThugzilla\nthumb\nthumbs\nThump\nThumped\nThumper\nThumping\nThunda\nthunder\nThunderballs\nThunderbolt\nThundermelons\nThunderclit\nThunderstorm\nThundy\nThura\nThurman\nThurough\nThursday\nthy\nThyself\nti\nTia\nTía\nTiah\nTian\nTiana\nTianas\nTiana's\nTianna\nTiara\nTias\nTia's\nTibby's\nTibor\nTic\nTichuana\nTick\nticket\ntickets\nticking\ntickle\nTICKLE?\ntickled\nTickler\nticklers\ntickles\nticklin\ntickling\nticklish\nTickly\nTick-Tick\ntidal\nTiddie\nTiddy\ntide\nTideni\nTides\nTidus\nTidy\ntie\nTieand\nTiebreaker\nTie-Breaker\ntieBrutal\ntied\nTied-up\nTiempo\nTiene\nTierra\nTiers\nties\ntieSounds\ntieZippered\nTifa\nTifani\nTifanny\nTifany\ntifany's\nTifereth\nTifereth's\nTiff\nTiffani\nTiffaniRox\nTiffanny\ntiffany\nTiffanys\nTiffany's\nTiffian\nTiffian's\nTiffisa\nTiffs\nTifini\nTimake loveing\nTig\ntiger\nTigerr\nTigerrIf\nTigerr's\nTiger's\nTiger-Sperm\nTiger-Style\nTiggest\nTiggle\ntight\nTightbooty\ntightbodied\ntight-bodied\nTighten\ntightener\ntighter\nTighter?\ntightest\nTighthole\ntightly\ntightlyGets\ntightness\nTightness?\ntight-cat\ntights\ntightster\nTightteen\nTighty\nTiglians\nTigra\ntigress\nTii\nTijuana\nTik\nTikar\nTiki\nTiki-Taka\nTikTokThots\ntil\n'Til\nTila\nTilda\nTilden\nTiled\ntiler\nTiles\nTiletoy\nTilf\ntill\nTilli\nTilly\nTilt\nTim\nTimber\nTimbers\ntime\nTime?\nTimea\nTimea<\nTimea's\nTimeChick\nTimebomb's\ntimed\nTimeless\nTimeline\ntimeon-camera\nTimeout\ntimer\ntimers\ntimes\nTime's\ntimes’\ntimeshe\nTimetable\nTimeZZ\nTimi\ntimid\nTiming\nTimmy\nTimo\nTimoax\nTimo's\nTimothy\nTimur\nTin\nT'in\ntina\nTina's\nTinder\nTindr\nTindra\ntingle\nTingled\nTingler\ntingling\nTingly\nTini\ntinier\nTiniest\nTini's\nTinka\nTinker\nTinkerbelle\ntinkle\ntinkled\ntinkler\ntinkling\nTinley\nTinos\nTinsel\nTinslee\ntint\nTinted\ntiny\nTiny4K\nTinypink\nTinycat\nTinyskirt\nTinyteen\nTinytoy\nTinykitty\ntip\nTip?\nTipfak\nTipper\nTippers\nTippin\nTipping\ntippy\ntips\ntipsy\ntip-toe\nTiptoe\nTiptoed\nTiptoeing\ntiptoes\ntip-toes\nTiptoes\nTira\ntire\ntired\nTired'\ntired?\ntireless\nTires\nTis\n'Tis\nTisa\nTisdale\ntissue\nTissy\nTissy's\ncan\nCANal\nCanamazed\nCanan\ncananic\ncanans\nCanantic\nCanastic\nCan-a-thon\nCanball\nCanbuster\nCan-Chi\ncan-fuc\nCanmake love\nCan-make love\ncanmake loveed\nCan-make loveed\nCan-Make loveer's\nCan-make loveing\nCANMAKE LOVEING\nCanmake loves\nCan-make loves\nCani\nCanies\nCanilated\nCanilating\nCan-ilations\nCanillate\nCanillated\nCanillating\nCanillation\nCaninterview\ncanjob\ncanle\nCanlicious\nCan-liscious\nCan-Mas\nCanmbootyage\nCanmasters\nCanness\nCannic\nCano\nCanorial\ncan-prints\nCanrubinterview\ncans\nCan's\nCans\ncans?\ncans?Then\ncansAbused\nCans-A-Palooza\ncans-big\ncansDominates\nCansgiving\ncansHair\nCans-out\ncansSuffers\ncansTotally\nCantalated\nCan-Tart\nCantays\ncanted\nCanter\nCantes\ncantie\ncantied\nCantie-Lick\nCantierub\ncanties\nCantie's\nCANTIES\ncanties?\nCANTIESS\nCantilicious\ncants\nCantsburgh\ncanty\ncanty_bar\nCanty-And-Anal\ncantymake love\nCanty-Make love\nCantyMake loveed\nCanty-Make loveed\ncantymake loveing\ncanty-make loveing\nCantymake loveing\nCanty-Make loveing\nCantymake loves\nCanty-Make loves\ncantylicious\nCanty-opolis\nCantypalooza\ncanty's\nCantytastic\nCantywhombus\nCanual\nCanus\ncanwank\ncan-wank\nCanwank\nCanz\nTiziana\nTJ\ntjhis\nTJ's\nTKO\nTLC\nT-Mac\nT-MILF's\ntmwVRnet\nT'nA\nTNA\nTNT\nT-N-T\nto\nTo?\ntoast\nToasting\nToasty\nTobacco\nTober\nTober's\nTobey\nTobi\nTobias\nToby\nToby's\nToca\nTocar\nTochier\nTock\ntoday\ntoday?\nToday’s\ntoday's\nTodays\nToday's\nTodd\ntoe\nToe-Curling\nToed\nToefetish\ntoe-ful\nToe-gather\ntoeing\ntoe-in-one\nToeland\ntoeless\nToe-licking\ntoenails\ntoes\ntoesExtreme\ntoesocks\ntoe-suck\nToe-sucker\ntoe-sucking\ntoesYea\nToe-tal\nToe-tally\nToey\nToffee\nTog\ntoga\nTogas\ntogeth\ntogether\ntogether?\ntogetherBrutally\nTogetherness\nTogs\ntoi\ntoilet\nToiletries\nToilets\nToilette\nTok\ntoken\nTokens\ntoker\nToki\ntokin\nTokyo\ntol\nTolcca\ntold\nToledo\nTolerance\nTolerances\nTolerated\nToliet\nToll\nTolls\nTolls'\ntom\nToma\nTomahawked\nTomas\nTomas's\nTomato\nTomb\nTombois\ntomboy\nTomboy's\nTomcatï¿½s\nTomcat's\nTomei\nTomi\nTomiko\nTommi\nTommie\nTommy\nTommys\nTommy's\nTomo\nTomoka's\nTomorrow\nTompson\nToms\nTom's\nton\nTone\ntoned\nTonen\ntones\nToney\ntong\nTongs\nTongta\ntongue\nTongued\nTongue-Filled\nTongue-Make loveed\ntongue-polishing\nTonguers\ntongues\nTonguing\nToni\nTonic\ntonight\ntonight?\ntonight's\nToning\nTonis\nToni's\nTonk\nTonkaow\nTonlew\ntons\nTonsil\ntonsils\nTony\nTony?\nTonya\nTonya's\nTony's\nTonz\ntoo\ntoo?\nToobig1\nToobig2\ntook\ntool\nTooled\nTooling\ntools\nToon\nTooshy\ntooter\nTooters\ntooth\nToothbrush\nToothbrushfun\ntootsie\ntootsies\nToot-Z-Roll\ntop\nTOP_TAP\nTopanga\nTopaz\ntop-floor\ntop-heavy\nTopheavy\nTop-heavy\nTopher\nTopics\ntopless\ntop-model\ntopped\nTopper\nToppers\nToppin\ntopping\ntops\nTop's\nTOPS\nTop-shelf\nToque\nTora\ntore\nToren\nTori\nTori’s\ntorment\ntormented\nTormenting\ntorments\ntorn\nTornado\nTornado0-0\ntorned\nTornjeans1\nTornjeans2\nTorn's\nToro\nToronto\ntorpedo\ntorpedoes\ntorpedos\nTorpido\nTorque\nTorque's\nTorrent\nTorres\nTorrey\nTorrez\nTorrid\nTorrie\nTorri's\nTort\nTortoise\ntorture\ntorture?Short\nTortureand\ntortured\ntorturedCountdown\ntorturedMade\ntortureextreme\ntorture-rack\nTortures\ntorturing\ntorturous\nTory\nTory Lane is back\nToryn\nTory's\nTosh\nTosha\ntoSquirt\nToss\ntossed\ntosses\ntossing\nTot\ntotal\nTotaled\ntotaleurosex\ntotally\nTotalmente\ntotals\ntotem\nTothe\nTOTM\nToto\nTotti\nTotty\nTotum\ntou\ntouch\nTouchable\nTouchdown\nTouche\ntouched\nToucher\ntouches\nTouchin\ntouching\nTouchingly\nTouchstrip\nTouchy\nTouchy-Feely\ntough\nTough?\nToughen\ntougher\ntoughest\nToughest?\nToughgirl\ntoughly\ntoujours\nTouko's\nTouluk\nTounging\ntour\nTour-De-Madison\nTourism\ntourist\ntourist's\nTourists\nTourist's\ntournament\ntourney\nTours\ntow\nTowards\ntowed\ntowel\nTowels\ntower\nTowered\nTowering\nTowers\nTowing\ntown\ntown;\nTown?\nTowner\nTowners\nTownhouse\nTownsend\nTownsfolk\nToxic\ntoy\ntoy?\nToya\nToybath\nToybed\nToybed2\nToybox\ntoyboy\nToycouch\nToycouch2\nToycushion1\nToycushion2\nToy-Johnson\ntoydoll\ntoyed\nToyer\nToyers\nToy-Filled\nToyfinger\nToyfingers\nToy-For-Her\nToymake love\nToymake loveer\nToymake loves\nToyfun\nToyfun1\nToyfun2\nToygasm\nToyin\ntoying\nToyjoy\nToyjuice\nToyko\ntoyland\nToyless\nToylotion\nToylotion1\nToylove\nToyplay\ntoys\nToy's\nTOYS\nToys?\nToys2\nToysex\nToys'r'fun\nToys-Tastic\nToytease\nToyteaser\nToytub\nTP\nTPing\nTPMosterToys\nTr\nTrabajando\ntrabajas\nTrabajo\nTrace\nTraces\nTracey\nTraci\nTracion\ntrack\nTracked\nTracker\nTracking\ntracks\nTracksuit\nTraction\nTracy\ntrade\ntraded\nTrade-Make love\nTrade-Off\nTrader\nTraders\ntrades\nTrading\nTradition\ntraditional\nTraditions\nTraduci\ntraffic\nTraga\nTragándome\nTragedy\ntrail\ntrailer\nTrails\ntrain\ntrained\ntrainee\ntrainees\ntrainer\ntrainer’s\nTrainer-Aiden\ntrainers\nTrainer's\nTrainess\nTrain-Her\nTrainHER\ntrainig\ntraining\nTrainingFeatured\ntrains\ntram\ntramp\nTrampede\nTramples\ntrampling\ntrampoline\nTramp-oline\nTramps\nTrance\nTrangible\nTraniing\nTraning\nTrannies\ntranny\nTranquil\nTranquility\nTranquille\nTranquilo\ntrans\nTransaction\nTransAngels\nTranscendence\nTranscendent\nTranscendental\nTranscending\nTranscontinental\nTrans-Cop's\nTransexual\nTransfer\nTransferring\nTransfixed\nTransformation\nTransformed\nTransforms\nTransgender\nTransgenders\nTrans-Girl\ntransgression\nTransgressions\nTransit\nTransition\nTranslate\ntranslation\nTrans-lation\nTranslator\nTrans-Lesbianism'\nTranslicious\nTranslucent\nTransmission\ntranspa\nTransparency\nTransparent\nTransportation\nTranssexual\nTranssexuals\nTransvescane\nTrans-Visions\nTrans-Xperience\ntrap\ntrapped\nTrappers\nTrapping\ntraps\nTrasero\ntrash\nTrashcan\nTrashed\nTrashes\nTrashing\nTrash-Talking\nTrashy\nTrasks\ntravel\ntraveland\nTraveled\ntraveler\ntraveling\nTravelled\ntraveller\ntravelling\ntravel-loving\ntravels\nTravezz\nTraviesa\nTravis\nTravojago\ntray\nTre\nTreachery\nTread\nTreadmill\ntreason\ntreasure\nTreasured\ntreasures\ntreat\ntreat?\ntreated\ntreatement\nTreater\ntreating\ntreatment\ntreatments\ntreats\nTreaty\nTreaza\nTrebati\nTrecking\ntree\ntree?\nTreehouse\nTreehouse?\ntrees\nTreesome\nTreeswing\nTreetop\nTrejsi\nTrek\nTrekkie\nTrekking\ntremble\ntrembles\ntrembling\ntremendous\ntremendously\ntremors\nTrench\nTrend\ntrendy\nTrent\nTrenton\nTres\nTrespbootyer\nTrespbootyers\nTrespbootying\nTresses\nTresspaser\nTresspbootying\nTrevor\nTrexxx\nTrey\nTrez\nTreza\ntri\nTria\nTriage\ntrial\ntrials\ntriangle\ntriangles\nTribootyathlon\ntrib\nTribal\nTribbers\ntribbing\nTribbmill\ntribs\ntribulations\ntribute\nTricia\ntrick\ntrick?\ntricked\nTrickery\nTricking\nTrickle\ntrickles\ntricknology\ntricks\nTrickshots\nTrickster\ntricky\nTri-color\nTricsys\nTricycle\nTrident\ntried\ntries\nTrifecta\nTrifero\nTrifling\nTrimake loveta\ntrigger\ntriggers\nTrillium\nTrillium's\ntrilogy\ntrim\nTrimble\nTrimly\ntrimmed\ntrimming\ntrimmings\nTrims\nTrina\nTrina's\nTrinety\nTrinety's\nTriniti\ntrinity\nTrinitys\nTrinity's\nTrinota\nTrinty\ntrio\nTrío\ntrip\nTrip'\ntriple\nTriple-Anal\nTriple-D\nTriple-duty\nTriple-G\nTriplePen\nTriplet\nTriple-Teaming\ntriplets\nTriple-X\nTriplicate\nTripod\nTripp\nTripper\nTrippin\nTripping\nTripple\nTrippy\ntrips\nTrish\nTrisha\nTrisha's\nTriska\nTriss\nTrist\nTrista\nTristan\nTristan's\nTristen\nTristyn\ntriumphant\nTriumvirate\nTrivial\nTrix\nTrixi\ntrixie\nTrixie's\nTrixxx\nTrixy\nTrochos\nTrogata\ntrois\nTroll\nTrolley\nTrollin\ntrolling\nTrollop\nTrolls\nTrolly\ntrombone\nTromboner\ntrombones\ntrooper\nTroops\ntrophy\ntrophy?Will\ntropic\ntropical\nTropicana\nTropicarium\nTropics\ntrot\ntroub\ntrouble\nTrouble?\nTroubled\ntroublemaker\nTroublemakers\ntroubles\nTroubleshoot\nTroubleshooting\nTroublesome\nTrounced\nTrouper\nTroupers\ntrouser\nTrout\nTroy\nTroyka\nTrtikova\nTru\nTruant\nTruce\ntruck\ntrucker\nTruckers\nTruckin\nTruckin'\nTruckloads\ntrue\nTrueAnal\nTruelove\ntruly\nTruman\nTrump\nTrumped\ntrumpet\ntruncheon\ntrunk\ntrunks?\ntrust\nTrustfund\nTrusting\nTrusts\nTrustworthy\nTrusty\ntruth\nTruth?\ntry\nTry?\ntryhard\nTryin\ntrying\nTryme\nTryme's\nTry-Ons\ntryout\nTry-Out\ntryouts\nTry-outs\nTryp\nTry's\ntryst\ntryst?\nTs\nTS+Stud\nTSA\nTSAnal\nTshirt\nT-shirt\nT-shirts\nTSKelly\nTS-On-Female\nTS-On-Cat\nTS-on-TS\nTSPC\nTSPH\nTsCatHunters\nTsCatHunterscom\nTS's\nTSS\nTsSeduction\nTsSeductioncom\nTsSeductioncomNot\nTsuma\ntsunami\nTsunami1-1\nTsunami's\nTsurara's\nTsuyabi\nTsuyayoku\nTT\ntu\nTú\ntub\nTub?\nTubbin\nTubbin'\nTubbing\ntube\nTubesocks\ntubesteak\nTubfun\nTubing\nTubpleasure\nTubrub\nTubsational\nTubshave\nTubshave2\nTubtease\nTubtoy\nTubtoyfun\nTubkitty\nTubular\nTucci\nTucked\nTucker\nTuckered\nTucker's\nTuckerThe\nTuesdae\nTuesday\nTuesdays\nTUF\nTuff\ntuft\ntug\nTugboat\nTugged\ntugger\nTuggernaut\nTuggie\nTuggin\ntugging\nTugjob\nTug-o-Whore\ntugs\ntuition\nTuitui\nTulias\ntulip\nTulips\nTumble\nTumbling\ntummy\nTuna\nTunde\nTundella\nTundia\ntune\ntuned\nTunel\nTunes\nTune-up\nTungboy\nTuning\ntunnel\ntunnels\nTunner\nTupelo\nTupperware\nTurbation\nTurbo\nTurbolenza\nTurchesi\nturf\nTurist\nTurista\nTuristi\nturkey\nTurkish\nturn\nTurnabout\nTurnah\nturnaround\nTurndown\nturned\nTurner\nTurner's\nTurnerThe\nTurnin\nturning\nturn-on\nTurnover\nturns\nTurnt\nturquoise\nTurtle\nTurtles\nTus\ntush\nTushie\ntushies\nTushy\ntussies\nTussle\nTussles\ntutee\ntutor\ntutor’s\ntutored\ntutorial\nTutorialPart\ntutoring\ntutors\ntutor's\nTutors\nTutor's\nTutti\nTutu\nTuxedo\ntv\nTW001\nTW002\nTW003\nTW004\nTW005\nTW006\nTW007\nTW008\nTW009\nTW010\nTW011\nTW012\nTW013\nTW014\ntwacker\ntwackin\nTwain\nT'was\nkitty\nKittych\nKittyie\nkitty-ress\nkittys\nkitty's\nKittys\nKittytable\nKitty-tasters\nKittyter\nKittyvibe\ntweakin\nTweaking\nTweeks\nTweet\nTweety\nTwelve\nTwenty\nTwenty-toe\nTwenty-two\ntwerk\nTwerk?\nTwerkalicious\ntwerker\nTwerkin\ntwerking\nTwerkout\ntwerks\nTwerkshop\nTwerktastic\ntwice\nTwiddle\nTwiddles\ntwig\nTwiggs\nTwigs\nTwigs's\ntwilight\ntwin\ntwinddle\nTwine\ntwineBondage\nTwink\ntwinkie\ntwinkle\ntwinklers\ntwinkles\nTwinkling\nTwinks\nTwinky\nTwinning\ntwins\nTwins-Video\ntwirl\nTwirler\nTwirling\ntwirls\ntwist\ntwisted\ntwister\ntwisters\nTWIST-HER\nTwisting\nTwists\nTwisty\nTwistyn\nTwistys\nTwisty's\ntwitch\nTwitching\nTwitchxxx\nTwits\nTwix\ntwo\ntwo?\nTwo-Boy\ntwo-member\nTwoDestroy\nTwofer\nTwo-fer\nTwofinger\nTwofingermake love\nTwofingerfun\nTwofingers\nTwo-guy\nTwoLesbians\nTwo-Lips\ntwo-man\nTwo-on-One\ntwo-on-two\nTwoReal\ntwos\nTwo's\nTwoSexual\nTwosie\nTwoSlave\nTwosome\ntwo-step\nTwo-Stroke\nTwo-tailed\nTwo-Timer\ntwo-timing\nTwotoys1\nTwotoys2\nTwoTrust\nTwo-way\nTxojkev\nTxt\nTy\nTya\nTyann\nTycoon\nTyela\nTyelar\nTyher\ntying\nTyla\nTylan\nTylar\nTylee\nTylene\nTyler\nTyler's\nTylo\nTylo's\nTyna\ntype\nType??\nTypecasting\ntypes\ntypewriter\ntypical\nTypika\nTyping\nTypist\nTypo\nTyra\nTyra's\nTyrell\ntyres\nTyrius\nTyron\nTysen\nTyson\nTyten\ntzarin\nu\nUber\nuberbabe\nUberchick\nUbersex\nUbus\nUdine\nUDP\nUehara\nUena\nUFC\nUglies\nugly\nUh\nUhl\nUk\nUkie\nUkraine\nUkraines\nUkraine's\nUkrainian\nUkrainian's\nUkranian\nUkulele\nUlia\nUliana\nUlrika\nUlterior\nUltima\nültima\nultimat\nultimate\nUltimateSurrendercom\nUltimatum\nultra\nultra-anal\nUltra-Curvy\nUltra-Intimate\nUltra-Pink\nUluvpunani\nUlya\nUlyana\nUm\nUma\nUma’s\nUmbrella\nUm-hmm\nun\nuna\nUnabashed\nunable\nUnacademic\nUnacceptable\nUnadulterated\nUnaga\nUnauthorized\nUnbecoming\nunbelievable\nUnbelievably\nUn-Bliss\nUnbound\nUnboxing\nUnbreakable\nUnBREElievable\nunbridled\nUnbroken\nUnbuckles\nUnbumon\nUnbumoned\nUnbumoning\nun-caged\nUncaged\nUncanny\nUncensored\nUnchain\nUnchained\nUn-Charm\nUncharted\nUnchaste\nUnclasp\nuncle\nUncles\nUncle's\nUnclog\nUnclogging\nUnclogs\nUnclothing\nUncollared\nUncolors\nuncomfortable\nUncommon\nUncomplicated\nUnconciousness\nunconsciousness\nuncontrollable\nuncontrollably\nuncontrolled\nUnconventional\nUncooperative\nUncorked\nUncoupling\nUncover\nuncovered\nUncovers\nuncut\nUnda\nUndecided\nundefeated\nundeniable\nundeniably\nunder\nunder_the_covers_agent\nUndermelon\nUnderCova\nundercover\nUnder-Desk\nUnderdog\nUnderestimate\nunderground\nunderneath\nUnderpaid\nunderrated\nUndersecretary\nUnderskirt\nUnderstall\nunderstand\nUnderstand?\nUnderstandable\nUnderstanding\nUnderstands\nunderstood\nUndertaker\nUnderthesheets\nUnder-The-Table\nunderwater\nunderwear\nUnderwear1\nUnderwear2\nUnderwearfingers\nUnderwearfingers2\nUnderwearplay\nUnderwood\nUnderworld\nUndiefun\nundies\nUndies1\nUndies2\nUndiesplay1\nUndiesplay2\nUndine\nUndisciplined\nUndiscovered\nUndisputed\nundoes\nUndone\nundoubtedly\nundress\nundressed\nUndresser\nundresses\nUn-dressher\nundressing\nUndriscriminating\nune\nUneasy\nunemployed\nUnemployment\nUnending\nUneven-aged\nunexpected\nunexpectedly\nUnfair\nunfaithful\nUnfaithfully\nUnfaithfuls\nunfamiliar\nUnfilled\nUnfiltered\nUnfinished\nUnfolding\nUnforeseen\nunforgettable\nunforgiving\nUnforseen\nUnfortunate\nUnfriendly\nunmake loveed\nunmake loveing\nUnmake loveingbelievable\nUnfulfilled\nUNGODLY\nUngrateful\nUnhappily\nUnhappiness\nunhappy\nUnheard\nUnhidden\nUnhinged\nUnholy\nUnhooking\nUnhooks\nuni\nUnias\nUniche\nUnicorn\nUnicornAmateur\nUnicorns\nuniform\nuniformed\nUniforms\nUnik\nuninhibited\nUninvited\nUnion\nunique\nUnison\nunit\nunite\nUnited\nUnits\nUnity\nUniversal\nuniverse\nuniversity\nunknowing\nUnknowingly\nunknown\nunlawful\nunleash\nunleashed\nunleashes\nUnleashing\nunless\nunlikely\nUnlimited\nunload\nUnloaded\nUnloading\nunloads\nunlocked\nUnlocking\nUnmasked\nUnmentionable\nunneeded\nUnnianis\nunnoticed\nUno\nUnobtainable\nUnorthoDoc\nunorthodox\nUn-Orthodox\nUnpack\nUnpacked\nUnpacking\nUnpeeled\nUnplug\nUnplugged\nUnpolished\nunpredictable\nUnprofessional\nunprotected\nUnpure\nUnqualified\nunquestionably\nUnrated\nUnravel\nUnraveling\nUnreachable\nUnread\nUnreal\nUnregistered\nUnreliable\nUnrequited\nunreserved\nUnrestrained\nUnrestricted\nUnrivaled\nUnruly\nUns\nUnSafe\nUN-SANCTIONED\nUnsatisfied\nUnsatisfied?\nunscripted\nun-scripted\nUnscripted\nUnsealed\nUnseen\nUnselfishly\nunSEXpected\nunshaved\nUnshaven\nUnsinkable\nUnspeakable\nUnspoiled\nUnspoken\nunstoppable\nUnstoppaBull\nUn-Straight\nUnsuccessful\nUnsucked\nUnsuit\nUnsullied\nUnsupervised\nUnsuspecting\nUntamable\nUntameable\nUntamed\nunthinkable\nUntie\nuntil\nUntill\nUncanled\nUnto\nUntold\nUntouchable\nuntouched\nUntraditional\nUntrainable\nUntranslated\nUntreatable\nUntwine\nunusual\nUnvarnished\nUnveil\nunveiled\nUnveiling\nunveils\nUnwanted\nUnwelcome\nunwilling\nunwind\nUnwinding\nUnwinds\nUnwittingly\nUnwrap\nUnwrapped\nUnwrapping\nUnwraps\nUnzip\nUnzippable\nunzipped\nUnzippity\nup\nup?\nUp’\nUPBOOTY\nUpbeat\nUPCLAIR\nUp-close\nUpcoming\nupdate\nUPDATE*Webbed\nUPDATEBound\nupdateDragon\nupdateN\nupdates\nUpdateThe\nUpgrade\nUpgrading\nUpholsterer\nupkeep\nUpload\nUploading\nupon\nUpper\nUpRanked\nUprising\nUps\nUPSASHA\nupset\nupside\nUpsidedown\nUpside-Down\nupskirt\nUpskirts\nupstairs\nUpstate\nupThe\nUptight\nUptown\nUPYOUR\nupYou're\nur\nUra\nUranus\nurban\nurge\nUrgencia\nUrgency\nUrgent\nUrgenzze\nurges\nurinal\nurinals\nUrinates\nurine\nUrsa\nUrsula\nUruguayan\nus\nUs?\nUsa\nUSC\nuse\nUse?\nused\nUsedAbused\nusef\nUseful\nUseless\nUselesss\nuses\nUsher\nusing\nuss\nUSSR\nusual\nusually\nUSUn-Ex\nUtah\nUtensil\nUtensils\nUtility\nUtilizes\nutmost\nUtopia\nUtter\nutterly\nUV\nUzbek\nv\nV20\nVa\nVaca\nVacaciones\nVacancy\nVacant\nvacation\nvacationer\nVacationing\nvacations\nVacay\nVaccuum\nVaChina\nVachs\nVacillate\nVacillation\nvacuum\nvacuuming\nvacuums\nVada\nVadim\nVaesen\nvag\nVagÄ­na\nVag-A-Thon\nVagazzled\nVAG-entine's\nVag-etable\nVaggy\nvagin\nvagina\nvaginal\nVaginal-Penetration\nVaginas\nVaginavibe\nvagitarian\nVag-itarian\nVagitarians\nVag-Oh\nvagrant\nVagvibe\nVahn\nVahntastic\nVai\nVain\nVajayJay\nVajenga\nVa-J-J\nVal\nValami\nValan\nValarie\nValda\nValdi\nVale\nValeAs\nValeJust\nValenteen\nValentein\nValentien\nValentien's\nValentín\nValentina\nValentina’s\nValentinas\nValentina's\nvalentine\nValentine?\nValentine’s\nvalentines\nValentine's\nValentino\nValentino's\nValencans\nValenttina\nValentyne\nValenzuela\nValeri\nValeria\nValeria’s\nValerie\nValeries\nValerie's\nValeriya\nValery\nValerya\nVale's\nValeski\nvalet\nValet?\nValeto\nValetta\nValhalla\nValiant\nValidation\nValizias\nValkyrie\nVallarie\nValle\nVallenssa\nVallery\nValles\nValletta\nvalley\nValleys\nValley's\nValli\nVally\nValmont\nValory\nValour\nVal's\nValtana\nvaluable\nValue\nValues\nValve\nValya\nValya's\nVamos\nvamp\nVampira\nVampire\nVampire?\nVampirella\nVampires\nVampire's\nVAMPIRES\nvamps\nVamp's\nvan\nVana\nvanbootyy\nVance\nVanda\nVandal\nVandalize\nVandals\nVanda's\nVandella\nVandella’s\nVandella's\nVander\nVandeven\nVandory\nVanella\nVanesa\nvanessa\nVanessa’s\nVanessa's\nVanguard\nVanguard's\nVania\nVaniity\nVaniitys\nVaniity's\nvanilla\nVanilli\nvanishing\nVanita\nVanity\nVanityfun\nVanitymirror\nVanitycat\nVanitytoy\nVanityvibe\nVanna\nVannah\nVannah's\nVanna's\nVanoza\nVans\nVant\nVanta\nVanwan\nVaper\nvapes\nVapors\nVarchie\nVargas\nVariable\nVariate\nVariations\nVariety\nVarious\nVarsity\nVarvara\nVarya\nVas\nvase\nvases\nVasilina\nVasilisa\nVasilisa's\nVasillisa\nVasques\nVasquez\nVast\nVastly\nVaudeville\nVaughn\nVaughn's\nVault\nVa-Va-Voom\nVa-Va-Vooms\nVayana\nV-Card\nVday\nV-day\nveal\nVeces\nVecino\nVecru\nVedrana\nVee\nVeeg\nveegee\nveegees\nVeg\nVega\nVega’s\nvegan\nVegas\nVega's\nVegBOOTY\nVegeance\nVegesexual\nvegetable\nvegetables\nVegetable's\nVegetarian\nVegetarians\nVeggie\nVeggie-girl\nveggies\nVehicle\nVeiki\nVeil\nVeils\nVein\nVeintoy\nveiny\nVeinytoy\nVelada\nVelasques\nVelasquez\nVelez\nVelia\nVelicity\nVelicity's\nVellonc\nVellons\nVelma\nVelmont\nVelonka\nVelour\nvelvet\nVelvetanus\nVelvetchair\nVelvet's\nVelvett\nvelvety\nVemiss\nVena\nVenday\nVendetta\nVendetta7-0The\nVendettas\nVendetti\nVending\nVendula\nVendula's\nVend-Whore\nVendy\nVendy's\nVenera\nVenera's\nVeneration\nVeness\nVenessa\nVenezuela\nVenezuelan\nvengeance\nvengeance'\nVengeance\nVengeanceSurprise\nVENGEANCEThe\nVENGEANCEVendetta13-3\nVengeful\nVengo\nVenice\nVenida\nVenirse\nVenirte\nVennue\nVenok\nVenom\nVenomous\nVente\nVenter\nVenton\nventrilo-clit\nVentura\nVentura's\nVenture\nVenturi\nVENU5\nvenus\nVenzelata\nver\nVera\nVeracruz\nVeranda\nVerano\nVeras\nVera's\nVerbally\nVerbena\nVerco\nVerda\nVerde\nVerdes\nVerdi\nverdict\nVerdoyant\nVerdure\nVerene\nVerga\nvergota\nVerhooks\nVerified\nVeritas\nVerlant\nVermillion\nVermont\nvermouth\nVern's\nVerona\nVerona's\nVeroni\nveronica\nVeronica'\nVeronica’s\nVeronicas\nVeronica's\nVeronik\nveronika\nVeronika's\nVeronique\nVerrattu\nVerronica\nVers\nVersa\nVersatil\nVersatile\nversion\nVerspanken\nVersu\nversus\nVerta\nvertical\nvertigo\nVeruca\nVeruca's\nVerunka\nvery\nVeryica\nVesco\nVesela\nVesna\nVespoli\nVespoli's\nVessel\nVessir\nVessir's\nvest\nVesta\nVestale\nVestibule\nvet\nveteran\nveterans\nVeto\nVetro\nVette\nVette's\nVeure\nVexx\nvez\nVHSeX\nVi\nVi0lation\nvia\nviagra\nVialenta\nViana\nViano\nVia's\nvib\nvibe\nVibebed\nVibeclit\nVibecouch\nVibecream\nVibed\nVibefun\nVibelove\nVibecat\nvibes\nVibesocks\nVibesquirt\nVibeteen\nVibing\nVibraciones\nVibrador\nVibrance\nvibrant\nVibras\nvibrated\nvibratedStretched\nVibrates\nvibrating\nvibration\nVibrational\nvibrations\nvibrator\nVibrator1\nVibrator2\nVibrator-And-Switch\nvibratorEach\nVibratorfun\nVibratorplay\nvibrators\nVibratortoy\nvibro\nvibroing\nvibros\nVibtrate\nVic\nVica\nVicarious\nVicariously\nVicca\nVice\nViceland\nvices\nVicious\nviciously\nVicki\nVickie\nVicki's\nVicktoria\nVicky\nVickys\nVicky's\nvictim\nVictimized\nvictim's\nvictor\nVictorelly\nVictorely\nVictori\nvictoria\nVictoria’s\nVictorian\nVictorias\nVictoria's\nVictorija\nVictoriya\nvictory\nvictoryLoser\nvictoryNasty\nvid\nvida\nVidal\nVidas\nVIDE\nVidel\nVidel's\nvideo\nVídeo\nVIDEO**********\nVideo5007\nVideochats\nVideogames\nvideos\nVideoshoot\nVideotape\nVidis\nVidra\nvids\nvie\nVienna\nVienna’s\nViennas\nVienna's\nVienta\nViento\nViera\nViesdati\nVietnam\nVietnamese\nview\nView?\nViewed\nviewer\nviewing\nviews\nVigaro\nVigilante\nVignette\nVigor\nVigorous\nVigorously\nVII\nVIII\nVik\nVika\nVika's\nViki\nViking\nVikki\nViktor\nViktoria\nViktoriah\nViktorias\nViktoria's\nViktorica\nViktorie\nViktorija\nViktorina\nViktoriya\nVila\nVilhena\nVilia\nVilikias\nvilla\nVillage\nVillagia\nVillain\nVillainess\nVillainous\nVillains\nVille\nVillianess\nVilorean\nVina\nVina’s\nVina's\nVince\nVincent\nVindictive\nvine\nvines\nVinestoy\nVineyard\nVinjera\nVinna\nVinnie\nVinny\nVino\nvintage\nVinyasa\nVinyl\nviola\nViolate\nviolated\nviolates\nViolation\nviolence\nviolent\nviolently\nViolet\nViolet’s\nvioletblue\nViolete\nViolets\nViolet's\nViolett\nVioletta\nViolette\nViolette's\nvioli\nViolin\nViona\nViona's\nvip\nViper\nVipera\nVipers\nVips\nVICat\nVirag\nVirago\nviral\nVirgeus\nvirgin\nvirgin?\nvirgin???\nVirgina\nVirgina’s\nvirginal\nvirgindisgraced\nVirgine\nVirginee\nVirginee?\nvirging\nVirginia\nVirginie\nvirginity\nVirginity?\nvirgins\nvirgin's\nVirgins\nVirgin's\nVIRGINS\nVirginty\nVirginy\nVirgo\nViri\nVirian\nVirile\nvirtual\nVirtually\nvirtue\nvirtuoso\nvirus\nVisa\nVisby\nViscara\nVisconni\nVisconti\nvisible\nvision\nvisionaries\nvisions\nvisit\nVisita\nvisitfor\nvisiting\nvisitor\nvisitors\nvisits\nvista\nvistisTickled\nVisual\nVisualizazi\nVit\nvita\nVitae\nVital\nVitalik\nvitality\nVitaliy\nVitalize\nvitals\nVitamin\nVitamine\nvitamins\nVita's\nVitio\nVito\nVitor\nVitoria\nVittoria\nVitus\nViv\nViva\nvivacious\nVivan\nvive\nVivi\nVivian\nViviana\nVivianas\nVivianee\nViviania\nVivianna\nVivianne\nViviany\nVivica\nVivid\nVivie\nVivien\nVivienne\nVivienne’s\nVivien's\nVivinias\nVivo\nvivre\nViv's\nvixen\nVixen’s\nVixenation\nvixens\nVixen's\nVixens’\nVixon\nVixon's\nVixtor's\nVixxen\nVixXxen\nVixxxens\nViya\nVizacao\nViziosa\nVj\nVlad\nVlada\nVladimira\nVladlena\nVlaska\nVlasta\nVlema\nVlena\nvlog\nvlogger\nVlogger's\nVlogging\nVocab\nVocabulary\nvocal\nVocalization\nvocals\nVode\nVodka\nVoeden\nVog\nVogel\nVogue\nVoguel\nvoice\nVoice'mouth\nVoices\nVoicing\nVoid\nvol\nVol1\nVol14\nvol2\nvol3\nVol4\nVol8\nVolcanic\nvolcano\nVoldok\nVolga\nVolkanik\nVolkova\nvollbracht\nVolley\nvolleyball\nVolleyballin\nVolleyballs\nVolleying\nVolpetti\nVolt\nvoltage\nvolts\nVolt's\nvolume\nVoluminous\nVolunteer\nVolunteers\nVolup\nVoluptous\nVoluptueux\nVoluptuos\nvoluptuosa\nVoluptuosgirl\nvoluptuous\nVon\nVonage\nVonDoom\nVonDoom's\nVoneva\nVonn\nVon's\nVoo\nvoodoo\nVoom\nvor\nvoracious\nVore\nVore-racious\nVore's\nVoris\nVosoni\nVoss\nVosse\nVostra\nvote\nvoted\nVoter\nvotes\nVoting\nVouchers\nVougue\nVoules\nVoulez\nvous\nVouyerlove\nvouz\nVov\nVova\nVow\nVows\nVox\nVoxx\nVoxxx\nVoxxx's\nVoy\nVoyage\nVoyager\nVoyagers\nVoyages\nVoyageur\nvoyeur\nVoyeur’s\nVoyeurbath1\nVoyeurdelight\nVoyeurism\nVoyeuristic\nVoyeurs\nVoyeur's\nvr\nVrod\nVroom\nvroom-vrooom\nvs\nV's\nVS\nvs DP\nvsAdrianna\nvsAmi\nvsAva\nvsClaire\nvsDa\nvsDarling\nvsFaye\nvsGwen\nvsJennifer\nvsKarrlie\nvsKiki\nvsKirra\nvsLorena\nvsMadison\nvsPenny\nVSRANKED\nvsRyan\nvsSamantha\nvsSarah\nvsSasha\nvsSmokie\nvsThe\nvsTia\nvsTrina\nvsVai\nvsWinter\nVu\nVue\nVuelvete\nVuiton\nVuitton\nVuk\nVulgar\nVulnerable\nvulnerableWe\nVulture\nvuluptuous\nVULVA\nVution\nVXN\nVyona\nVyvan\nVyxen\nw\nW****App\nwa\nWabbits\nwad\nWade\nWaders\nWads\nwaffle\nWag\nWage\nWager\nwagered\nWagner\nWagnerThe\nWagon\nWags\nWahine\nWaif\nWailin\nWailing\nWaine\nwaist\nwait\nWait?\nwaited\nwaiter\nWaiter’s\nwaiter's\nWaitHow\nwaiting\nWaitingroom\nWaitlist\nwaitress\nWaitress's\nwaits\nWaka\nwake\nWake-N-Make love\nwakes\nwakeup\nwake-up\nWakeup\nWake-up\nWakeup1\nWakeup2\nWakeupplay1\nWakeupplay2\nWakey\nWakey-wakey\nwaking\nWales\nWaleska\nwalk\nwalk?\nWalkabout\nwalked\nWalker\nwalkers\nWalkin\nWalk-in\nwalking\nWalkiria\nWalkout\nwalks\nwall\nWallace\nwallCareful\nWalled\nWallet\nwallher\nWallop\nWalloped\nwallowed\nWall-painting\nwalls\nWalner\nWalnut\nWaltz\nWAM\nwan\nwand\nWanda\nWandbeads\nWander\nwandered\nWanderer\nwandering\nWanderlust\nWanding\nWands\nWandtoy\nWandtoys\nWandvibe\nWane\nWanessa\nwang\nWaning\nwank\nwanker\nwanker's\nWankin\nwanking\nWanking?\nWankmus\nwanks\nWanksta\nWankster\nWanksy\nwanna\nWanna-Bang-O\nwannabe\nWanny\nwant\nwant?\nwanted\nwanting\nWanton\nwants\nwant's\nWants\nwar\nWarai\nward\nWarden\nWarden's\nwardrobe\nwarehouse\nwares\nwarfield\nWarhol\nwarm\nwarmed\nWarmer\nWarmers\nwarming\nWarms\nWarmth\nwarmup\nwarm-up\nWarmup\nWarm-up\nWarn\nWarner\nwarning\nWarranty\nWarren\nwarrior\nWarriors\nwars\nwas\nWasabi\nwash\nWashboard\nwashed\nwasher\nWashers\nWasherkitty\nwashes\nwashing\nWashington\nWashroom\nWashup\nwasn't\nWasnt\nWasn't\nWASP\nWaspy\nwaste\nWasted\nWastelands\nWaster\nwastes\nWasting\nwatc\nwatch\nwatch?\nWatcha\nWatchdog\nwatched\nWatcher\nWatchers\nwatches\nWatchful\nWatchin\nwatching\nwatching?\nwater\nwaterboarded\nwaterboarding\nWaterbondage\nWaterboy\nWatercolor\nWatercolors\nwatercooler\nWatercouch\nWatercourse\nwatered\nwaterfall\nWaterfalls\nWaterflow\nWatergate\nwatergirl\nWaterhole\nwatering\nWaterlogged\nWatermelon\nwatermelons\nWaterpark\nWatercat\nwaters\nWater's\nwaterside\nWatersplash\nwatersport\nwatersports\nwater-sports\nWatersports\nWaterwall\nwaterworks\nWaterworld\nWating\nWatson\nWatson's\nWattado\nwav\nwave\nwaves\nWaving\nWavy\nwax\nwaxed\nwaxes\nwaxGIO279\nwaxin\nWaxing\nWaxy\nway\nWayAgain\nWayfaring\nWay-Interview\nWayne\nways\nWayward\nwc\nwe\nWe?\nwe’re\nwe’ve\nweâ??ve\nWeak\nweaken\nweakens\nWealth\nwealthy\nWeapon\nWeapons\nwear\nwear?\nwearing\nwears\nWeasley\nWeather\nweathers\nWeaves\nWeavin'\nweb\nWebb\nWebbed\nwebcam\nWeb-Cam\nWebcamer\nWebcammer\nWebcamming\nWebcams\nWebcams?\nWebinars\nwebsite\nWed\nWe'd\nWedd\nwedding\nWedge\nWedged\nWedgie\nWedgies\nWednesday\nwee\nWeed\nweek\nWeek?\nWeekdays\nweekend\nweek-end\nWeekend\nWeekly\nWeeknight\nweeks\nweek's\nWeeks\nWeenie\nWeenies\nWeeny\nWeggie\nWeigel\nWeighs\nweight\nweighted\nWeightlifter\nweights\nWeiner\nWEINERschnitzel\nWeird\nWeirdo\nWeix\nwelc\nWelcom\nwelcome\nwelcomed\nwelcomes\nwelcoming\nWeld\nwelfare\nWelians\nwell\nWe'll\nwell_versed\nWell-Built\nwell-deserved\nWell-dressed\nWell-Endowed\nwell-make loveed\nwell-hung\nWellin\nwell-known\nWell-Needed\nWellness\nWell-orchestrated\nWell-paid\nWell-prepared\nWell-red\nWells\nWell-served\nwellSkin\nWell-spent\nWells's\nWelsh\nWelter\nWelterweight\nWelther\nwench\nWenches\nWendee\nWendi\nWENDUS\nWendy\nWendy's\nWenessy\nWenona\nWenonaformer\nWenona's\nWenonaThere\nwent\nwer\nWe'r\nwere\nwe're\nWere\nWe're\nWeremilf\nWes\nWesley\nwest\nWestbound\nWestern\nwestgate\nWeston\nWest's\nWestsun\nwet\nwet?\nWetdream\nWetdreams\nWetero\nWetfingers\nWethole\nWetland\nWetly\nWetne\nwetness\nwet'n'wild\nWetpleasure\nWetcat\nwets\nWet's\nWetsx\nWett\nWet-T\nWetted\nWetteen\nWetteencans\nwetter\nwettest\nWettie\nWetting\nWetcans\nWetkitty\nWetty\nwe've\nWeve\nWe've\nwe-we\nwh\nwha\nWha?\nWhaaa\nWHAAAT\nwhack\nWhacked\nwhacking\nWhacks\nWhale\nWhaling\nWham\nWhamies\nWhammy\nwhat\nwhat?\nWhat??\nwhat?s\nwhat_is_in_your_luggage\nwhat’s\nWhatâ??s\nWHATCH\nWhatcha\nwhatever\nwhat's\nWhats\nWhat's\nwhatsoever\nwhe\nWheat\nWheater\nWheel\nWheelbarre\nWheelchair\nWheeler\nWheeler's\nwheels\nwhen\nWhence\nWhenever\nwhere\nWhere's\nWhere've\nwherever\nWhets\nwhich\nWhicker\nWhiff\nWhig\nwhile\nWhile?\nwhille\nwhilst\nWhim\nWhimpering\nWhimpers\nwhims\nwhimsical\nWhining\nWhinny\nWhinny's\nWhiny\nwhip\nWhip?\nwhipbooty\nWhipcream\nWhiped\nwhipped\nWhippedBootycom\nWhippedcream\nwhipping\nwhips\nWhirl\nWhirlpool\nWhirlwind\nwhisker\nwhiskers\nWhisking\nWhisky\nWhisper\nwhisperer\nWhispering\nWhispers\nwhistle\nWhistles\nWhistling\nwhite\nWhite’s\nWhitebed\nWhitebanana\nWhitedress\nWhitegarder\nwhitelace\nWhitelace1\nWhitelace2\nWhitelingerie\nWhiteneko\nWhitenighty\nWhiteout\nWhitepants\nWhiter\nWhiteRoom\nWhites\nWhite's\nWhiteshag\nWhitesheer\nWhitesilk\nWhiteskirt\nwhitesocks\nWhitestocking\nWhitestockings\nWhitethong\nWhitetop\nWhitevibrator\nWhitey\nwhith\nWhithney's\nWhitie\nWhitney\nWhitney’s\nWhitneys\nWhitney's\nWhiz\nwho\nwho?\nWho\\'s\nWho’s\nwhoa\nwhoChloe\nWho'd\nWhodoyougot?\nWhoever\nwho-ha's\nwhole\nWholesome\nWhom\nwhom?\nWhome\nwhomever\nWhoomp\nWhoopee\nwhoops\nWhooties\nWhooty\nWhopper\nwhoppers\nWhopping\nWhor\nWhorange\nWhorderly\nwhore\nW-H-O-R-E\nWhore?\nwhore5144\nWhore-a-tied\nwhorebound\nWhorecade\nwhored\nWhoreder\nWhoreders\nWhore-ders\nWHOREders\nWhoredom\nWhoredrobe\nWhorehouse\nWhoreleans\nWhore-lem\nWhoremonger\nWhoreObics\nWhore-Off\nWhore-O-Scopes\nWhore-O-Scoping\nWhoreporate\nWhorerror\nwhores\nwhore's\nwHores\nWhore's\nWHORES\nWhoreschach\nWhoresome\nWhorever\nWhorey\nWhoreywood\nWhoriental\nWhorientation\nWhorin\nWhoring\nWhorish\nWhornitas\nWhoronation\nwhorred\nWhorriors\nWhory\nwho's\nWhos\nWho's\nwhose\nwhy\nWhyte\nwi\nWibeke\nWibeke's\nWiccan\nWick\nwicked\nWickedgirlcom\nWickedly\nWickedscenescom\nWicker\nWicker1\nWicker2\nwicket\nWickey\nwicks\nWicky\nWicky's\nWiddow\nwide\nwide-eyed\nwide-open\nWider\nWidescreen\nwidget\nwidow\nWidower\nwidows\nWidow's\nWielding\nWields\nWien\nwiener\nWieners\nWienies\nWienold\nWiesenthal\nwife\nWife?\nwife’s\nWifeFeels\nwife-make loveing\nWife-I\nWifely\nwife's\nWifes\nWife's\nWifeswap\nWife-Swap\nwifey\nWifeys\nWiffle\nWifi\nWi-fi\nWi-Fi?\nWifing\nwig\nWiggle\nwiggles\nWiggling\nWiggly\nwih\nwild\nwild_teen_lets_loose\nWildbootyholeslut\nwildcat\nWildcats\nWildchild\nwilde\nwilder\nWildera\nwilderness\nWilder's\nWilde's\nwildest\nWildeThe\nwildfire\nWildflowers\nWild-hot\nWilding\nWildlife\nwildlife_photography\nwildly\nWildness\nWild-One\nWilds\nWild's\nwildwest\nWildy\nWildy?\nwiley\nWiley's\nWILF\nWilfred\nWilfried\nWILFs\nwill\nWilla\nWille\nWilliam\nWilliams\nWilliamsends\nWillie\nwilling\nWillingness\nWillis\nWilll\nWillow\nWillows\nWillowy\nwills\nwilly\nWilma\nWilson\nWilt\nWimbledon\nWimp\nWimpy\nwin\nwin?\nwin?Which\nwind\nWinding\nWindmill\nWindmills\nwindow\nWindow2\nWindowcandy\nWindowfingers\nWindowlove\nWindowcat\nwindows\nWindow's\nWindowsha\nwindowsill\nWindowstrip\nWindowtease\nWindowtoy\nWindowkitty\nwinds\nwindy\nwine\nWinecellarstrip\nWined\nwineglbooty\nWines\nWinetaster\nWing\nwinged\nWingman\nWingmen\nwings\nWinifred\nWinkers\nwinkin\nwinking\nwinks\nWinn\nwinner\nwinners\nWinner's\nWinni\nWinnie\nWinnie's\nwinning\nwinnings\nwinOne\nwins\nWin's\nwins?\nWinslett\nWinston\nwinter\nwinters\nWinter's\nWinters's\nwintertime\nWinter-time\nwin-win\nWinwin\nWin-Win\nWipe\nwiped\nWiping\nWipizzle\nWire\nwired\nWiredcat\nWiredcatcom\nWires\nWiring\nWisdom\nWise\nwisely\nwish\nwishes\nWishful\nWishing\nwishlist\nwishmaster\nwiska\nWiska's\nwit\nwitch\nWitchcraft\nWitchery\nwitches\nWitch's\nWitchy\nwith\nWith Cute\nWith Female\nWithdrawals\nWithdrawn\nWitheneko\nWithers\nwithin\nWithney\nwithout\nWithstands\nWitness\nwitnesses\nWits\nWive\nwives\nWivien\nWiyar\nWizais\nWizard\nWizard's\nwizz\nWKRP\nWlt\nwo\nWobble\nWobbly\nwoes\nWok\nwoke\nwoken\nWoking\nwolf\nWolfe\nWolff\nWolfie\nWolfox\nWolf's\nWolves\nwom\nwoman\nWoman’s\nwoman-handle\nWomanhood\nwoman's\nWomans\nWoman's\nwomen\nWo-Men\nWOMEN\nwomen's\nWomens\nWomen's\nwon\nWon’t\nwond\nwonder\nwonderful\nWonder-ful\nWonderfully\nwondergirl\nwondering\nWonderjugs\nWonder-Lana\nwonderland\nWonderlust\nWonderment\nwonders\nWonderwoman\nWondrous\nWondrously\nWong\nwont\nwon't\nWont\nWon't\nWonton\nWoo\nwood\nWoodcutter's\nwooden\nWoodies\nwoodland\nWoodman\nWoodroom\nWoodrow\nwoods\nWood's\nwoodsman\nWoodward\nwoody\nWoodymaker\nWoodz\nWooly\nWoopee\nWoor\nWoos\nWooty\nWord\nWord?\nWordless\nwords\nwore\nWorhship\nwork\nwork?\nworkaday\nWorkaholic\nworkand\nWork-Boss\nworkday\nworke\nworked\nworker\nworkers\nworker's\nWorkers\nWorker's\nWorkforce\nWorkin\nworking\nWorkinggirl\nworkload\nWork-Load\nWorkman\nWorkman's\nworkout\nWorkout'\nWork-out\nWORKOUT\nworkout?\nWorkoutfun\nWorkoutrubdown\nWorkouts\nWorkouttime\nWorkouttoy\nWorkoutwand\nWorkover\nworkplace\nworks\nworkshop\nWorkspace\nworld\nWorld?\nworldBrutal\nWorldcup\nworlds\nworld's\nWorlds\nWorld's\nworldThis\nworldwide\nWorlwide\nworm\nworn\nWorries\nworry\nWorse\nworship\nworshiped\nWorshiper\nWorshipers\nWorshipful\nworshiping\nworshipped\nWorshipper\nWorshippers\nWorshipping\nworships\nworst\nworth\nworthless\nWorthshiping\nworthwhile\nworthy\nWorthy?\nWoship\nwoul\nwould\nwouldn't\nWould've\nWound\nWounded\nWounds\nwow\nWowie\nwows\nWOW's\nWowcans\nWowwi-Pop\nwowzers\nwra\nWrangler\nWrangles\nWranglin\nWrangling\nwrap\nwrapped\nWrapper\nwrappin\nwrapping\nwraps\nWrbootylin\nwrath\nWreck\nWreckage\nwrecked\nWreckening\nWrecker\nWrecking\nWrecking-Ball\nWreckless\nWrecks\nWren\nwrench\nwrenches\nWreslters\nWrester\nwresting\nwrestingLoser\nwrestle\nWrestlemake loveing\nwrestler\nwrestlers\nwrestlersCrushing\nwrestles\nwrestling\nwrestling1\nwrestlingBlond\nwrestlingCrushing\nwrestlingLoser\nWrestlingRd2\nwrestlingWinner\nWriggles\nWright\nwringer\nwrinkled\nWrinkly\nwrist\nwrists\nwrite\nWriter\nWriters\nWriter's\nWrites\nwrithing\nwriting\nWritten\nwrong\nWronged\nWrongs\nwrote\nws\nWSG\nWtf\nWTMake loveing\nwth\nWudda\nWunf\nWUNF-220\nWurlitzer\nWuze\nWw\nwwworgasmalleycom\nwwwRoccoFunClubcom\nWyatt\nWylde\nWylder\nWylde's\nWynn\nWynona\nWynters\nWyoming\nWyson\nWyte\nx\nX_Mas\nx2\nX3\nX69\nXana\nXander\nXander's\nXandra\nXandy\nXania\nXArt\nX-Art\nXasia\nXaya\nXBEAUTIFIED\nXBox\nXcite\nXeena\nXellix\nXempra\nXenia\nXenija\nXeniya\nXenta\nXes\nXFeatured\nX-Files\nX-Girlfriend\nXI\nXiannas\nXII\nXIII\nXimena\nXIV\nXIX\nXL\nXLGirl\nXLGirls\nXLGirlscom\nXLVII\nXmarksthespot\nxmas\nX-mas\nXMas\nX-Mas\nXMAS\nXmastoy\nX-Men\nxo\nXotiko\nXoXo\nXPart\nXperience\nX-perience\nXposed\nX-Posure\nXrated\nX-rated\nX-Ray\nX's\nXS\nX-Sensual\nXspana\nXTC\nXtra\nX-tra\nXtreme\nX-treme\nXV\nXVI\nXvideos\nXVII\nXVIII\nXvsBella\nXX\nXXHoliday\nXXI\nXXII\nXXIV\nXXIX\nXXL\nXXV\nXXVI\nXXVII\nXXVIII\nxxx\nXXXcersices\nXXXercise\nXXXL\nXXX-L\nxxxmas\nXXX-mas\nXXXMas\nXXX-Mas\nXXXMAS\nXXX-MBootyacre\nXXX-Men\nXXXpert\nXXXplorations\nXXX-POSED\nXXX-Ray\nXXXtra\nXXX-tra\nXXXtream\nXXXtreme\nXXXtremely\nXXXX\nXXXXAnal\nXyla\ny\nya\nYabani\nyacht\nYachts\nYaeger\nYago\nYah\nYahshua\nYahshua's\nYaiselys\nYaisha\nYakilia\nYakuza'\nYalena\nYalinn\nYammy\nYams\nYana\nyang\nYanie\nYanika\nyank\nYanka\nYankee\nYanker\nYankers\nYanking\nYanna\nYara\nYarbles\nyard\nYari\nYas\nYa's\nYasmeen\nYasmi\nYasmim\nYasmin\nYasmine\nYasmin's\nYasmyne\nYay\nYaya\nYda\nYe\nyea\nYeaaaah-smine\nyeah\nyear\nYear’s\nYearbook\nYearly\nyearn\nyearnin\nYearning\nyearns\nyears\nyear's\nYears\nYear's\nyearsLet\nYee\nYeeaaaH\nYeehaw\nYee-haw\nYekaterina\nYelena\nyellow\nYellowbeads\nYellowlove\nYellowpanties\nYellowpanty\nYellowpigs\nYellowroom\nYellowskirt\nYellowstripes\nYellowtop\nYellowtoy\nYellowvibrator\nyells\nYena\nYenaikoto\nYenka\nYenna\nYenona\nYep\nyer\nyes\nYes?\nYesenia\nyesterday\nYesterday's\nyet\nYet?\nYew\nYfimertria\nYggdrasil\nYgrasia\nYhivi\nYhivi's\nYi\nYieva\nYiki\nYillie\nyin\nYing\nYippee\nYippie\ny'know\nYlane\nYlena\nYmare\nYmnos\nYnati\nYnotradiocom\nyo\nYoanna\nYodeleh\nYo-Face\nyoga\nYoga'\nYogas\nYoga-tta\nYoghurt\nYogi\nYogic\nYogini\nYogis\nyogurt\nYogurt1\nYogurtwo\nYoha\nYoha's\nYoked\nYokuso\nYola\nYola's\nYollanda\nYollanta\nYOLO\nYon\nYonder\nYong\nYoni\nYooouuuuuuuu\nYork\nYorker\nYORKPart\nYORKWiredcat\nYo'Self\nyou\nyou?\nyou?ve\nYou´ll\nYou’d\nyou’ll\nyou’re\nyou’ve\nyou…\nyouAirtight\nyou'd\nyouDon't\nyou'l\nyou'll\nYoull\nYou'll\nyoun\nyoung\nyounger\nyoungest\nYoungling\nYoungman\nYoungs\nYoung's\nyoungster\nyoungsters\nYoung'un\nYoung'uns\nyour\nyoure\nyou're\nYoure\nYou're\nyour-face-of-fest\nyours\nyourself\nyourself?\nyourself?Why\nyouth\nyouthful\nyou've\nYouve\nYou've\nYoyo\nYo-Yo\nYoyowitch\nYperogi\nyr\nYsana\nYu\nYudi\nYuffie\nYui\nYui's\nYuki\nYukki\nYukon\nYul\nYulan\nYule\nYuletide\nYulia\nYuliana\nYulia's\nYuliya\nYull\nYulya\nyum\nYume\nYumi\nyummie\nYummies\nyummy\nYumtastic\nYung\nYunno\nYuno\nYup\nYura\nYuri\nYuri's\nYurizan\nYurizan's\nYurjen\nYu's\nYuzu\nYveta\nYvett\nYvetta\nYvette\nYvette's\nYvy\nZ\nZaawaadi\nZac\nZacarra\nZach\nZachary\nZack\nZack's\nZaddy\nZadie's\nZadyn\nZaffiro\nZafinia\nZafira\nZafira's\nZafiro\nZaguna\nZaisha\nZak\nZaltana\nZan\nZana\nZane\nZania\nZanita\nZanmai\nZanna\nZanni\nzapped\nZappers\nzaps\nZara\nZara's\nZarena\nZaria\nZarina\nZario\nZarrah\nZatleskej\nZavese\nZavites\nZaya\nZaydyn\nZazie\nZazie's\nZde\nZdenka\nZeba\nZebedee\nzebra\nZebrawood\nZecchi\nZedyna\nZee\nZee's\nZeina\nZeitgeist\nZeke\nZelda\nZelda's\nZelediz\nZelina\nZelinsk\nZelma\nZelsamina\nZemskaya\nzen\nZena\nZenda\nZengin\nZenia\nZen's\nZenya\nZenza\nZephyr\nZeppelin\nzero\nZero'd\nZeroes\nZero-Cat\nZero-Cat-Cat\nZerva\nzest\nZesty\nZeta\nZeth\nZexy\nZhanna\nZhenia\nZhenya\nZhu\nZiba\nZidane\nZieda\nZiggy\nZilian\nZilla\nZille\nZima\nZina\nZinai\nZingibro\nzinging\nZinn\nZintra\nZip\nZipline\nzipper\nzipperand\nzippered\nzippers\nzippers?\nZippity\nZippy\nzips\nZita\nZladka\nZlata\nZo\nZocal\nZodiac\nZoe\nZoel\nZoe's\nZoey\nZoey's\nZofia\nZoi\nzoie\nZoli\nZoliboy\nZoli-casting\nZolty\nZolva\nZolvita\nZolvyta\nzombie\nZombies\nZombooty\nzone\nZoned\nZones\nZoning\nzoo\nZoom\nzoom-ins\nZora\nZoraya's\nZorra\nZoryana\nZova\nZoya\nZ'S\nZsabina\nZsanett\nZsofia\nZsuja\nZsuza\nZsuzsa\nZsuzsana\nZu\nZucchini\nZucco\nZucker\nZuda\nZufia\nZuleima\nZuma\nZumba\nZumbas\nZur\nZusen\nZusie\nZuzana\nZuzana’s\nZuzana's\nZuzanna\nZuzantasies\nZuzu\nZya\nZylona\nZyna\nZZ\nZZBA\nZZInc's\nZZ's\nСhloe\n"
  },
  {
    "path": "scripts/test_db_generator/studio.txt",
    "content": "\n&\n1\n1000\n18\n18videoz\n1By-Day\n2\n21\n21Sextury\n3\n30\n3D\n3DX\n40\n40oz\n4K\n50\n5K\n60\n8th\na\nAbella\nAbigail\nAbout\nAbroad\nAbused\nAcademie\nAcrobats\nAdriano\nAdult\nAdultery\nAdventures\nAffair\nAfter\nAge\nAgent\nAlbum\nAletta\nAll\nAllBlackX\nAllstars\nALS\nAlysa\nAmateurs\nAmber\nAmerica\nAmerican\nAnalyzed\nAnatomik\nand\nAnetta\nAngel\nAngels\nAniston\nAnna\nApartment\nArchives\nArrest\nArt\nAsia\nAsian\nAsians\nASMR\nAss\nAssablanca\nAsses\nat\nAthletics\nATMovs\nAttack\nAttackers\nAuditions\nAva\nAwesome\nB\nBabes\nBaby\nBabysitters\nBack\nBackroom\nBad\nBAEB\nBall\nBallerinas\nBalls\nBanana\nBang\nBang!\nBangbros\nBanged\nBBC\nBe\nBear\nBears\nBeautiful\nBeauty\nBeauty4K\nBed\nBellesa\nBells\nBest\nBesuch\nBetter\nBF\nBFF\nBFFs\nBGG\nBi\nBiEmpire\nBig\nBikini\nBirds\nBites\nBlack\nBlacked\nBlockbuster\nBlondes\nBlow\nBlowpass\nBlows\nBlue\nBondage\nBonus\nBoob\nBoobs\nBookworms\nBooty\nBootylicious\nBoss\nBottom\nBounce\nBound\nBounty\nBox\nBoxxx\nBoys\nBrace\nBratty\nBrazil\nBrazzers\nBreak\nBritney\nBromo\nBros\nBrown\nBrutal\nBubble\nBuchanon\nBuero\nBully\nBums\nBunnies\nBurning\nBus\nBush\nBushy\nBusted\nBusty\nBustyz\nButt\nButtman\nButtplays\nButts\nBy\nCam\nCamel\nCamp\nCamSoda\nCan\nCaptain\nCardiogasm\nCasting\nCasual\nCaught\nCFNM\nChannel\nCharles\nChaser\nCheat\nCheating\nCheckup\nCherie\nCherry\nChicks\nChix\nChoking\nChongas\nChristoph\nChristoph's\nCity\nClark\nClassic\nClassics\nClassroom\nClips\nClub\nCoach\nCody\nCoeds\nCoin\nCollege\nColombia\nCompetition\nConfessions\nConrad\nContent\nControl\nCop\nCops\nCore\nCorvus\ncosplay\nCouch\nCouch-HD\nCouch-X\nCougar\nCougars\nCountry\nCouples\nCourtesans\nCrashers\nCrazy\nCream\nCreampie\nCreampies\nCreep\nCrew\nCruelty\nCuckold\nCurry\nCurves\nCuties\nCZ\nCzech\nD\nDadcrush\nDaddies\nDaddys\nDaddy's\nDad's\nDaily\nDandy\nDane\nDanger\nDangerous\nDani\nDaniels\nDare\nDarkko\nDarkX\nDarlings\nDate\nDaughter\nDaughter's\nDawn\nDay\nDaydreams\nDDF\nDebt\nDee\nDeen\nDeep\nDeeper\nDefiled\nDeluxe\nDera\nDesire\nDesiree\nDetention\nDeviant\nDevice\nDeville\nDevil's\nDiary\nDigital\nDirty\nDisgrace\nDisgraced\nDivine\nDo\nDoctor\nDoe\nDog\nDogfart\nDollars\nDominated\nDongs\nDon't\nDoor\nDorm\nDose\nDouble\nDP\nDreams-HD\nDressing\nDrill\nDrilled\nDriver\nDriving\nDrone\nDudes\nDulce\nDungeon\nDVD\nEbony\nEdge\nEighteen\nElectro\nElegant\nElfie\nEmily\nEmpire\nEnema\nEnslaved\nEpisodes\nErito\nErotic\nErotica\nEroticaX\nErrotica\nEternal\nEuro\nEva\nEve\nEverything\nEvil\nEx\nExotic\nExperience\nExperiment\nExploited\nExtras\nExtreme\nExxtra\nExxxtra\nF\nFab\nFaced\nFaces\nFacial\nFacials\nFactor\nFactory\nFake\nFakehub\nFam\nFame\nFamilies\nFamily\nFanatics\nFantasies\nFantasy\nFast\nFat\nFeature\nFeatures\nFeet\nFemale\nFemdom\nFerrara\nFest\nFever\nFiesta\nFight\nFiles\nFilm\nFilms\nFilthy\nFinally\nFirst\nFitness\nFive\nFlesh\nFlipside\nFlix\nFlixxx\nFloor\nFlora\nFlower\nFocus\nFoot\nFootsie\nfor\nForever\nFoster\nFoxes\nFreak\nFreaks\nFrenzy\nFreshman\nFridays\nFriend\nFriend's\nFrisky\nFrom\nFull\nFun\nGag-n-Gape\nGalanti\nGalore\nGals\nGang\nGape\nGapeland\nGaping\nGay\nGF\nGFs\nGG\nGhetto\nGina\nGinger\nGiorgio\nGiorgio's\nGirl\nGirlcore\nGirlfriend\nGirlfriends\nGirlfriend's\nGirls\nGirlsway\nGive\nGlamkore\nGlasses\nGlory\nGo\nGods\nGoes\nGone\nGonna\nGonzo\nGot\nGrandi\nGrandmas\nGrandpas\nGranny\nGraves\nGrind\nGulpers\nGum\nGym\nHairy\nHandson\nHappy\nHard\nHardcore\nHardX\nHave\nHD\nHe\nHeart\nHellcat\nHentai\nHer\nHero\nHigh\nHim\nHoby\nHogtied\nHole\nHome\nHoneys\nHook\nHorny\nHospital\nHostel\nHot\nHouse\nHousewife\nHow\nHub\nHuge\nHumpers\nHunter\nHunters\nI\nIcon\nIdol\nImmoral\nin\nInch\nInitiations\nInlaws\nInnocent\nIntermixed\nInterviews\nInvasion\nis\nIsiah\nit\nJake\nJames\nJane\nJay\nJizz\nJoanna\nJoey\nJohn\nJohnny\nJOI\nJones\nJonni\nJordan\nJordi\nJoy\nJug\nJul\nJules\nJurassic\nKathia\nKeiran\nKelly\nKendra\nKings\nKink\nKinky\nKissing\nKnow\nKnows\nKombat\nLA\nLab\nLabs\nLacy\nLady\nLand\nLandry\nLatin\nlatina\nLatinas\nLayna\nLee\nLeg\nLegal\nLegs\nLennon\nlesb\nLesbea\nLesbian\nLesbians\nLesbianX\nLeslie\nLessons\nLets\nLet's\nLetsDoeIt\nLeWood\nLexington\nLez\nLibertines\nLick\nLife\nLike\nLil\nLimit\nLittle\nLive\nLiving\nLoad\nLoads\nLoca\nLolly\nLook\nLove\nLovely\nLovers\nLoves\nLow\nLubed\nLuna\nLunaXJames\nLust\nLusty\nLuxury\nMac\nMachine\nMachines\nMade\nMadison\nMag\nMagical\nMaid\nMake\nMale\nMalone\nMandy\nMania\nManuel\nMassage\nMasseur\nMasterpiece\nMatthew\nMature\nMatures\nMaxwell\nMe\nMean\nMeat\nMedia\nMega\nMen\nMetArt\nMetro\nMex\nMiami\nMickey\nMicky\nMighty\nMike\nMike's\nMile\nmilehigh\nmilf\nMilfed\nMILFs\nMilk\nMilking\nMinutes\nMistress\nMixedX\nMod\nModel\nModels\nMofos\nMom\nMom’s\nMommies\nMommy\nMommy's\nMoms\nMoney\nMonster\nMonsters\nMoone\nMouth\nMouthful\nMouths\nMovs\nMr\nMr.\nMrs.\nMunch\nMy\nn\nNacho\nNaked\nNanny\nNasty\nNatural\nNaturals\nNaughty\nNeighbor\nNerdy\nNet\nNetwork\nNew\nNewbie\nNews\nNext\nNicole\nNight\nNinjas\nNo\nNobili\nNoir\nNorth\nNot\nNow\nNubile\nNubiles\nNubilesET\nNude\nNuru\nNVG\nNylonsX\nNympho\nO\nOasis\nObsession\nOcean\nof\nOffice\nOfficial\nOG\nOld\nOlder\nOmar\non\nOnline\nOnly\nOpen\nOral\nOrgasm\nOrgasms\nOrgies\nOrgy\nOriginals\nOut\nOverload\nOye\nPain\nPanty\nPantyhose\nPaper\nPapi\nParade\nParlor\nParodies\nParody\nParties\nParty\nPass\nPassenger\nPassion\nPassions\nPatch\nPatrick\nPatrol\nPawg\nPee\nPenny\nPerfect\nPerv\nPervert\nPervs\nPeter\nPetite\nPI\nPickups\nPie\nPierre\nPies\nPimp\nPimps\nPink\nPinko\nPix\nPlay\nPlayground\nPlays\nPlus\nPop\nPops\nPorn\nPornfidelity\nPorno\npornportalvod\nPornstar\nPornstars\nPortal\nPOV\nPOVD\nPOVGod\nPower\nppvod9901\nPremium\nPremiun\nPretty\nPrime\nPrincess\nPrivate\nPro\nProductions\nProject\nProjects\nProperty\nPros\nPSE\nPublic\nPunished\nPure\nPussy\nQueen\nQuest\nQuickies\nRachel\nRacks\nRammed\nRampage\nRanger\nRapid\nRaw\nRawcut\nReal\nReality\nRealityJunkies\nReckless\nReislin\nRelaxxxed\nRemastered\nRest\nRevell\nRevenge\nRich\nRK\nRoadside\nRoadtrip\nRocco\nRodgers\nRoom\nRooms\nRose\nRound\nRub\nRV\nRylsky\nSaint\nSame\nSandy\nSandy's\nSapphic\nSaturday\nSC\nScam\nScan\nScarlett\nSchool\nScore\nScoreland\nScout\nScreampies\nScrew\nSean\nseancody\nSecret\nSeduced\nSeduction\nSeductive\nSee\nSeeking\nSelects\nSelf\nSell\nSensations\nSensual\nSeries\nService\nSessions\nSex\nSexArt\nSextreme\nSextury\nSexy\nShabby\nShades\nShady\nShape\nShare\nShe\nShelf\nShemale\nShemales\nShes\nShe's\nShoot\nShoplyfter\nShow\nSiblings\nSides\nSiffredi\nSilvera\nSilverstone\nSilvia\nSin\nSinemale\nSineplex\nSinner\nSinners\nSis\nSister\nSister's\nSites\nSitters\nSix\nSkeet\nSleazy\nSmall\nSmuts\nSneaky\nSoapy\nSocal\nSofia\nSoft\nSolo\nSomething\nSophie\nSOS\nSoup\nSpa\nSpank\nSpeculum\nSperm\nSpermantino\nSpice\nSpoiled\nSports\nSpy\nSquad\nSquirt\nSquirtalicious\nSquirted\nSquirting\nStabbin\nStaff\nStar\nStarr\nStars\nSteele\nStep\nStepdad\nstepfam\nStepmom\nSticks\nSticky\nStories\nStr8\nStranded\nStrangers\nStrap\nStrapon\nStreet\nStrokes\nStudies\nStunning\nSubmission\nSubmissived\nSucks\nSugar\nSummer\nSun\nSuper\nSurprise\nSurrender\nSwallowed\nSwallows\nSwap\nSweet\nSweethearts\nSweeties\nT&A\nTable\nTaboo\nTake\nTales\nTalks\nTape\nTapes\nTarra\nTaxi\nTeach\nTeacher\nTeal\nTeam\nTeamed\nTeamSkeet\nTeasers\nteen\nTeenfidelity\nTeens\nTeeny\nTera\nTgirls\nThat\nthe\nThief\nThis\nThomas\nThroat\nThroats\nThugs\nTied\nTime\nTimes\nTiny\nto\nToe\nTogether\nTonight's\nTop\nTorment\nTour\nTow\nToy\nTraining\nTrans\nTransfixed\nTransHarder\nTranssensual\nTranssexual\nTrick\nTrickery\nTricky\nTruck\nTrue\nTry\nTryouts\nTS\nTube8Vip\nTucci\nTugs\nTurning\nTushy\nTwatter\ntwink\nTwistys\nUK\nUltimate\nUnder\nUndies\nUniform\nUniversity\nUnleashed\nUnrelatedX\nUnscripted\nUp\nUpper\nUps\nUs\nVacation\nValley\nVault\nVery\nVidal\nVideo\nVideos\nVids\nView\nVintage\nVIP\nVirgin\nVirgins\nVision\nViv\nVixen\nVote\nvr\nVRT\nWake\nWants\nWatch\nWatching\nWater\nWay\nWe\nWeb\nWeddings\nWet\nWhen\nWhipped\nWhite\nWhore\nWhy\nWicked\nWide\nWife\nWife's\nWild\nWill\nWillis\nWired\nWith\nWives\nWomen\nWoodman\nWork\nWorking\nWorkout\nWorld\nWorldwide\nWorship\nWOW\nx\nXander\nX-Angels\nX-Art\nxChimera\nXEmpire\nXL\nXXX\nYear\nYears\nYNGR\nYoga\nYoung\nYoungBusty\nYounger\nYour\nYouth\nZebra\nZoliboy\nZZ\nZZplus"
  },
  {
    "path": "scripts/test_db_generator/surname.txt",
    "content": "A\nA.\nAaane\nAB\nAbada\nAbel\nAbott\nAbramov\nAbrego\nAbril\nAC\nAcacia\nAcberg\nAccel\nAce\nAcecaria\nAchora\nAckerman\nAcon\nAcosta\nAdair\nAdams\nAdamson\nAdan\nAdara\naddams\nAddamson\nAddis\nAddison\nAdel\nAdele\nAdelia\nAdelle\nAdin\nAdjani\nAdkins\nAdley\nAdorable\nAdore\nAdrolino\nAE\nAeriend\nAeryne\nAF\nAffair\nAfrodita\nAgave\nAghora\nAguchi\nAguiar\nAguilar\nAguilara\nAguilera\nAguirre\nAhud\nAikawa\nAikens\nAilo\nAima\nAimes\nAims\nAinse\nAire\nAisha\nAJ\nAjali\nAjauro\nAK\nAkashova\nAkesson\nAkira\nAkizuki\nAkulova\nAlani\nAlanis\nAlarcón\nAlba\nAlbarez\nAlbina\nAlbright\nAlbrite\nAlbuquerque\nAlcala\nAlcalá\nAlcantara\nAldamen\nAlegra\nAleigh\nAlena\nAlencer\nAlessandro\nAlexa\nalexander\nAlexandra\nAlexandre\nAlexia\nAlexis\nAlexus\nAlfano\nAli\nAlice\nAlien\nAlighatti\nAlii\nAlika\nAlisa\nAlixus\nAliyeva\nAlize\nAllbutt\nAllen\nAllens\nAlley\nAllison\nAllure\nAllwood\nAllyn\nAlma\nAlmeida\nAloha\nAlol\nAlpina\nAlser\nAlta\nAlton\nAlure\nAlvares\nAlvarez\nAlverson\nAlves\nAlyse\nAlysha\nAmada\nAmanda\nAmandi\nAmante\nAmari\nAmarillo\nAmateurs\nAmatista\nAmato\nAmber\nAmbrose\nAmbrosia\nAmbrosio\nAmbrus\names\nAmeto\nAmey\nAmillian\nAmillion\nAmilton\nAmira\nAmo\nAmor\nAmora\nAmoral\nAmore\nAmorel\nAmorim\nAmorina\nAmour\nAmoure\nAmsel\nAmy\nAmylee\nAn\nAna\nAnabell\nAnabelle\nAnal\nAnalese\nAnasova\nAnbel\nAnca\nand\nAnders\nAnderson\nAnderssen\nAndersson\nAndis\nAndrade\nAndrea\nAndrews\nAnelise\nAnge\nangel\nAngeles\nAngeli\nAngelica\nAngelika\nAngelina\nAngeline\nAngelique\nAngelis\nAngelo\nAngels\nAngora\nAnguita\nAngy\nAnh\nAnime\nAnisova\nAniston\nAniton\nAnjali\nann\nAnna\nAnne\nAnnoga\nAnomia\nAnsell\nAntala\nAnthony\nAntistia\nAntoinette\nAntonia\nAnya\nAnzai\nAorta\nApont\nAppach\nAppelgate\nApple\nApplebottom\nApplegate\nApples\nApril\nAquinas\nAquino\nAragon\nArana\nAraujo\nArcand\nArce\nArch\nArcher\nArdell\nArden\nArdolino\nAreana\nArenas\nArgan\nArgant\nArgiles\nAria\nAriadna\nArial\nArian\nArianna\nArias\nAriego\nAries\nArina\nAriza\nArmand\nArmandi\nArmani\nArmour\nArnal\nArnaz\nArowyn\nArquez\nArres\nArroyo\nArsh\nArt\nArwen\nArya\nAryna\nAsagi\nAsano\nAsanty\nAsh\nAshby\nAshe\nAsher\nAshlee\nAshley\nAshlyn\nAshton\nAsian\nAsis\nAskani\nAskara\nAson\nAspen\nAss\nAssange\nAsset\nAsstrophe\nAsti\nAston\nAstona\nAstoria\nAstyn\nAthena\nAthena)\nAtkins\nAttison\nAtwell\nAu\nAubrey\nAudley\nAudrey\nAugust\nAuguste\nAugustine\nAura\nAurelia\nAurelli\nAurora\nAusin\nAusten\nAustin\nAustyn\nAvalon\nAvalos\nAvana\nAvano\nAvanti\nAvary\nAvatari\nAvery\nAvion\navluv\nAvni\nAvril\nAwesome\nAxe\nAxel\nAxx\nAyers\nAyn\nAzar\nAzejedo\nAzul\nAzur\nAzure\nAzz\nAzzure\nB\nB*\nB.\nBaba\nBabe\nBabeurre\nBabi\nBaby\nBacardi\nBaccchi\nBacci\nBach\nBadeau\nBadseed\nBae\nBaez\nBailey\nBaileys\nBailey's\nBaja\nBaker\nBakhtiari\nBal\nBala\nBalcano\nBaleay\nBalentyne\nBales\nBalestra\nBalezi\nBalian\nBallerina\nBallerini\nBallhaus\nBallixxx\nBalls\nBalouga\nBam\nBambi\nBambina\nBambola\nBamby\nBanana\nBananas\nBancroft\nBandera\nBanderas\nBandini\nBandit\nBang\nBange\nBangg\nBangkok\nBangles\nBangs\nBanister\nBank\nbanks\nBanner\nBannister\nBanx\nBanxx\nBanxxx\nBaracho\nBarb\nBarbela\nBarber\nBarbi\nBarbie\nBarby\nBarcelona\nBardeaux\nBardot\nBardoux\nBare\nBaren\nBargo\nBarjeau\nBarjo\nBarker\nBarnes\nBarnett\nBaron\nBarra\nBarrett\nBarrington\nBarron\nBarrony\nBarroso\nBarry\nBartelli\nBarthelemy\nBarts\nBaru\nBarz\nBase\nBasi\nBasinger\nBaster\nBastos\nBateman\nBates\nBathory\nBauer\nBaum\nBauxx\nBavel\nBaweric\nBaxter\nbay\nBayres\nBaz\nBazin\nBb\nBeach\nBeal\nBean\nBear\nBeasley\nBeast\nBeatty\nBeau\nBeaudy\nBeaulieu\nBeautiful\nBeautty\nBeauty\nBeaver\nBeaz\nBeck\nBee\nBeil\nBel\nBela\nBelgium\nBeli\nBelize\nbell\nBella\nBelladonna\nBellamy\nBellana\nBelle\nBelli\nBellick\nBellini\nBellis\nBellisima\nBello\nBells\nBellucci\nBelluci\nBellz\nBelmont\nBelov\nBelova\nBeltran\nBelucci\nBender\nBendz\nBenett\nBenjamins\nBenji\nBennet\nBennett\nBennette\nBensi\nBenson\nBentho\nBentley\nBenton\nBentz\nbenz\nBer\nBerber\nBeretta\nBerg\nBerger\nBerk\nBerlin\nBerlusconi\nBermudez\nBernat\nBerne\nBerriman\nBerrimore\nBerry\nBerrymore\nBerti\nBerton\nBertoni\nBerty\nBesk\nBest\nBeth\nBetsy\nBexley\nBeyle\nBeyn\nBhai\nBiaggi\nBianca\nBianchi\nBianchy\nBianco\nBibi\nBible\nBie\nBieber\nBieder\nBiel\nBig\nBigboobs\nBiggs\nBijou\nBilas\nBilberry\nBiliss\nBillard\nBillberry\nBina\nBing\nBinge\nBinx\nBirch\nBird\nBit\nBitch\nBitencourt\nBitencourty\nBitencurt\nBitoni\nBittencourt\nBitties\nBittoni\nBiza\nBizarre\nBizart\nBizzare\nBizzarre\nBl\nBlaar\nBlac\nBlacc\nBlach\nBlack\nBlackberry\nBlackbirdy\nBlackburn\nBlackfox\nBlackie\nBlacks\nBlackwell\nBlade\nBlaine\nBlair\nBlake\nBlakhart\nBlakovich\nBlanc\nBlanca\nBlance\nBlanche\nBlanchett\nBlanchette\nBlanco\nBlank\nBlanks\nBlase\nBlau\nBlayne\nBlaze\nBleins\nBlendova\nBlessed\nBleu\nBleue\nBleur\nBlew\nBlewitt\nBlige\nBlighe\nBliss\nBloh\nBlond\nBlonde\nBlondi\nBlondie\nBlondinka\nBlondson\nBlondy\nBloom\nBlooms\nBlossom\nBlossoms\nBlow\nBlows\nBlu\nBlue\nBlueberry\nBlume\nBlun\nBlunt\nBlush\nBly\nBocchi\nBodeva\nBody\nBoggs\nBold\nBombom\nBombon\nBon\nBonbon\nBond\nBonds\nBone\nBones\nBonet\nBong\nBonkar\nBonnet\nBony\nBoo\nBoob\nBoobies\nBoobs\nBoomer\nBoop\nBooty\nBorav\nBordas\nBordeaux\nBorders\nBorges\nBorghese\nBorja\nBorrelli\nBorres\nBose\nBoshe\nBoss\nBottoms\nBounce\nBound\nBounto\nBounty\nBourdain\nBouye\nBow\nBowltree\nBows\nBoyd\nBoyer\nBr\nBraces\nBrachto\nBradburry\nBradentine\nBradley\nBradshaw\nBradshow\nBrady\nBradyn\nBragg\nBranch\nBranco\nBrand\nBrandao\nBrandy\nBranson\nBrasil\nBrass\nBratislava\nBratt\nBratty\nBraun\nBravo\nBrawen\nBray\nBrazil\nBrazzle\nBree\nBreelsen\nBreeze\nBrelle\nBrend\nBrendy\nBrent\nBrett\nBrian\nBriancon\nBriar\nBrice\nBricks\nBridge\nBrielle\nBriggs\nBright\nBrighton\nBrigitta\nBrika\nBril\nBriley\nBrill\nBrinx\nBrite\nBritish\nBritni\nBrito\nBritt\nBrittany\nBritton\nBrix\nBrixton\nBroadway\nBrock\nBrokelyn\nBronstein\nBronx\nBrook\nBrooke\nBrookes\nBrooks\nBrookshire\nBroox\nBrown\nBrugal\nBruni\nBryant\nBryce\nBrymova\nBryne\nBrynn\nBsg\nBubble\nBubbles\nBubblez\nBuccarelli\nBucci\nBuck\nBucxxx\nBudai\nBudds\nBueno\nBug\nBugatti\nBujoli\nBuks\nBulgari\nBulgaria\nBull\nBullock\nBulovar\nBumm\nBunn\nBunnell\nBunnington\nBunny\nBunz\nBurd\nBurke\nBurma\nBurnett\nBurning\nBurns\nBurrow\nBurst\nBurton\nBush\nBuss\nBuster\nButland\nButt\nButterfly\nButterz\nButton\nButts\nByasusky\nBye\nByens\nBynes\nByrd\nByrne\nBysmark\nBytes\nByttencourt\nc\nC.\nCa\nCabaeva\nCade\nCadilac\nCadillac\nCadsa\nCage\nCaimanes\nCain\nCaine\nCaitlin\nCajth\nCake\nCakes\nCalabrase\nCalabre\nCalanthe\nCaley\nCalibre\nCalienta\nCaliente\nCalis\nCallaway\nCallegary\nCalliaro\nCallipygiah\nCallipygian\nCalloway\nCalogera\nCalvert\nCaly\nCalypso\nCam\nCamacho\nCambel\nCambridge\nCamden\nCameo\nCameron\nCamerun\nCami\nCamila\nCamile\nCamilla\nCamille\nCampbel\nCampbell\nCampol\nCampos\nCan\nCanada\nCanali\nCanalis\nCanape\nCanary\nCancaster\nCancellieri\nCancino\nCandi\nCandy\nCane\nCanela\nCannibal\nCannon\nCano\nCanon\nCanyon\nCapella\nCapelli\nCapers\nCapone\nCappelli\nCapri\nCaprice\nCapris\nCaraballo\nCaracciolo\nCaramel\nCarbon\nCardenas\nCardinale\nCardo\nCardona\nCardoso\nCardova\nCardwell\nCare\nCarey\nCarina\nCarioca\nCarlene\nCarlisle\nCarlisto\nCarlton\nCarmell\nCarmen\nCarmichael\nCarmine\nCaro\nCarolin\nCaroline\nCarpenter\nCarr\nCarrera\nCarrere\nCarrie\nCarrington\nCarris\nCars\nCarso\nCarson\nCartel\ncarter\nCartier\nCartwright\nCaruso\nCarvajal\nCarvalho\nCas\nCasanova\nCase\nCasey\nCash\nCashley\nCashmere\nCasio\nCasper\nCass\nCassidi\nCassidy\nCastaneda\nCastellanos\nCastellari\nCastelli\nCastello\nCastillo\nCastle\nCastro\nCaszo\nCat\nCatalina\nCates\nCatherine\nCatori\nCats\nCattiva\nCatwoman\nCaulfield\nCavali\nCavalli\nCavanni\nCazso\nCe\nCee\nCeleste\nCelesti\nCelestine\nCelesto\nCelima\nCerna\nCerrano\nCerrutti\nChachanhsy\nChain\nChainz\nChalizo\nChambers\nChampayne\nChan\nChance\nChandler\nChanel\nChanelle\nChannel\nChanning\nChannson\nChanson\nChaos\nChaplin\nChar\nCharity\nCharles\nCharleston\nCharlize\nCharlon\nCharlotte\nCharm\nCharmelle\nCharming\nCharms\nCharmz\nCharnelle\nChase\nChaster\nChavez\nChaynes\nChe\nChechick\nChechik\nCheeks\nCheri\nCherie\nCherrie\nCherry\nChery\nCheshire\nChevalier\nChevelle\nChhavi\nchi\nChiarari\nChic\nChief\nChika\nChilds\nChillz\nChimera\nChina\nChloe\nChocky\nChoco\nChoice\nChrist\nChristal\nChristian\nChristiansen\nChristie\nChristin\nChristine\nChristy\nChristyn\nChrivtin\nChrystall\nChrystin\nChu\nChung\nChups\nChurcher\nCiccero\nCielo\nCienfuegos\nCindee\nCinderella\nCindy\nCinn\nCinta\nCiss\nClair\nClaire\nClairette\nClara\nClare\nClarence\nClarig\nClarissa\nClark\nClarke\nClarkson\nClarrise\nClary\nClass\nClassy\nClaudia\nClaus\nClavier\nClay\nClayton\nClear\nCleavage\nClegg\nClementine\nCleo\nClif\nCline\nClinton\nClit\nclitman\nCloe\nClose\nCloud\nClouds\nClove\nClover\nClutch\nClyde\nCoal\nCoast\nCobain\nCobra\nCoburn\nCockold\nCocks\nCoco\nCocoa\nCoda\nCodere\nCohen\nCohstly\nColada\nColby\nCold\nCole\nColibri\nColiun\nCollien\nCollins\nColombara\nColt\nComet\nComfort\nCompilation\nConfidential\nConner\nConners\nConny\nConrad\nConroy\nConstance\nConti\nContrares\nContreras\nConvince\nCool\nCooper\nCopafeel\nCopper\nCora\nCorason\nCorazon\nCordina\nCordova\nCore\nCorly\nCornejo\nCorona\nCorpo\nCorpse\nCorrera\nCors\nCortes\nCortez\nCosta\nCostello\nCostina\nCoton\nCotton\nCougar\nCount\nCourcelles\nCourt\nCourtland\nCourtney\nCouto\nCouture\nCova\nCovelli\nCovet\ncox\nCoxx\nCoxxx\nCoxz\nCoyne\nCraft\nCraig\nCrane\nCrash\nCraven\nCraves\nCrawford\nCrazy\nCream\nCreams\nCreamy\nCreepshow\nCrewz\nCrimson\nCrist\nCrista\nCristal\nCristaldi\nCristelli\nCristine\nCristoff\nCristy\nCroft\nCross\nCrouz\nCrow\nCrowley\nCrown\nCrowne\nCrox\nCru\nCrue\nCruise\nCrunch\nCrush\nCruss\nCruz\nCrysstal\ncrystal\nCrystalis\nCrystall\nCucci\nCuckold\nCudna\nCuervo\nCulen\nCullen\nCum\nCumings\nCumming\nCummings\nCummins\nCummore\nCummz\nCums\nCumstar\nCumz\nCunt\nCupcakes\nCupps\nCure\nCuriel\nCurly\nCurran\nCurrie\nCurry\nCurtis\nCurvaceous\nCurve\nCurves\nCute\nCutie\nCuty\nCuvee\nCyan\nCyns\nCypher\nCyprus\nCyrus\nD\nD'\nD.\nda\nDadivoso\nDae\nDagger\nDahl\nDahlia\nDahmer\nDai\nDaikira\nDaikiri\nDaily\nDain\nDaines\nDaire\nD'Aire\nDaisy\nDaize\nDajmont\nDake\nDakota\nDaley\nDalhart\nDali\nDallas\nDalong\nDalton\nDalush\nDaly\nDama\nDamage\nDames\nDamn\nDamon\nDamone\nDamour\nDamzel\nDanae\nDanali\nDance\nDancy\nDane\nDanells\nD'angelo\nDanger\nDaniellas\nDanielle\nDaniels\nDanielsova\nDanika\nDanleku\nDanseuse\nDantas\nDantes\nDantric\nDanu\nDanvers\nDaor\nDarby\nDare\nDark\nDarkley\nDarko\nDarlin\nDarling\nDarmon\nDash\nDashwood\nDasilva\nD'Ass\nDast\nDatz\nDava\nDavai\nDavida\nDavidson\nDavies\nDavin\nDavinci\nDavis\nDavor\nDawkins\nDawn\nDawsome\nDawson\nDax\nDay\nDaye\nDayer\nDayida\nDayne\nDaysie\nDaza\nDaze\nD'Bouar\nDcamps\nde\nDé\nDea\nDean\nDeAngelo\nDearest\nDearmond\nDeavoux\nDeb\nDebowe\nDebreaux\nDecarlo\nDecker\ndee\nDeelight\nDeen\nDeep\nDeer\nDefortuna\nDeFrancesco\nDeGarcia\nDeGarden\nDeGrey\nDeicide\nDejour\ndel\nDela\nDelacroix\nDelage\nDelancey\nDelane\nDelani\nDelanie\nDelano\nDelatori\nDelatossa\nDelatosso\nDelaunay\nDelaure\nDelbene\nDelcroix\nDele\nDeleon\nDelgado\nDelia\nDelice\nDelicious\nDelight\nDella\nDellai\nDellaMorte\nDellatossa\nDelmonico\nDelor\nDelovo\nDelphine\nDelray\nDelrio\nDeltore\nDeluca\nDeluna\nDelux\nDeluxe\nDeluxxx\nDeluxxxe\nDemarchi\nDemarco\nDemeanor\nDemellza\nDemelzza\nDemer\nDemiko\nDemoan\nDemon\nDemone\nDeMonia\nDemonte\nDemoore\nDemore\nDeMoro\nDena\nDenae\nDenise\nDenvile\nDenville\nDenyle\nDe'Nyle\nDeRay\nDerek\nDerriere\nDerusky\nDerza\nDesade\nDesado\nDeSaire\nDesanges\nDesantis\nDesilva\nDesire\nDesiree\ndeSoura\nDesouza\nDespedida\nDesrey\nDetalosso\nDettwiller\nDeuce\nDevant\nDeveaux\nDeVell\nDevelle\nDevere\nDevereaux\nDevi\nDevil\nDeville\nDevin\nDevine\nDevis\nDevita\nDevlin\nDevoe\nDevon\nDevons\nDeVore\nDevour\nDeVries\nDevy\nDevyne\nDew\nDewinter\nDey\nDeytrois\ndi\nDia\nDiablo\nDial\nDiamant\nDiamanti\nDiamond\nDiamonde\nDiamonds\nDiamondz\nDiana\nDiane\nDiangelo\nDias\nDiaz\nDiazz\nDicapri\nDicaprio\nDiCarlo\nDicas\nDice\nDickens\nDickson\nDiego\nDiem\nDiesel\nDiFeo\nDiGivanni\nDii\nDikarlo\nDikki\nDikky\nDilapri\nDiletto\nDillan\nDillion\nDillon\nDimarco\nDimez\nDimitra\nDimone\nDimples\nDiniz\nDinov\nDior\nDiore\nDire\nDirty\nDis\nDiva\nDivan\nDiver\nDivine\nDivis\nDixon\nDJ\nD'leigh\nDlux\nDobrev\nDodds\nDoe\nDoggystyle\nDol\nDolar\nDolce\nDolci\nDoll\nDollar\nDolls\nDollxxx\nDolly\nDom\nDominca\nDomingo\nDominic\nDominica\nDominick\nDominik\nDomore\ndona\nDonal\nDonaldson\nDonau\nDoneaway\nDonell\nDonna\nDonovan\nDoore\nDoran\nDore\nDoren\nDorev\nDori\nDos\nDoublei\nDouche\nDouglas\nDoux\nDova\nDove\nDowns\nDraagen\nDrabinova\nDrae\nDragon\nDrake\nDream\nDreams\nDreamxxx\nDreamz\nDreems\nDrew\nDrole\nDroppz\nDropz\nDrozd\nDrum\nDryli\nDu\nDuarte\nDubai\nDubois\nDubrova\nDucati\nDucatti\nDuchess\nDuke\nDukes\nDulce\nDumaire\nDumb\nDuncan\nDunes\nDunkin\nDunn\nDupree\nDupri\nDuran\nDurganova\nDuro\nDuRose\nDust\nDutch\nDutra\nDuval\nDuvalle\nDuxe\nDuxes\nD'Vine\nDwaine\nDylan\nDymes\nDynamite\nDynasty\nE\nE.\nEare\nEasily\nEaster\nEaston\nEasy\nEbony\nEcho\nEden\nEdge\nEdible\nEdison\nEdita\nEdmonds\nEdward\nEdwards\nEga\nEggers\nEilish\nEince\nEisley\nEkina\nEl\nElekes\nElektra\nEleniak\nElfie\nElisa\nElise\nEliss\nElizabeth\nElla\nElle\nElleny\nEllington\nEllis\nEllwood\nElly\nEllyson\nElmer\nElmeritta\nElson\nElvgren\nElyse\nEmbers\nEmerald\nEmerson\nEmilia\nEmily\nEmino\nEmma\nEmmerson\nEmpera\nEnali\nEndi\nEngland\nEnglish\nEntice\nEnvy\nEpps\nErickson\nEscobar\nEsm\nEssance\nEssence\nEssex\nEstefanía\nEstela\nEstella\nEstelle\nEstrada\nEstrada.\nEt\nEternity\nEtoile\nEubanks\nEvan\nEvangeline\nEvans\nEvans&Zoe\nEve\nEvelin\nEvelina\nEveret\nEverhart\nEverheart\nEvermoore\nEvers\nEverson\nEves\nEvins\nEvol\nExclusiv\nExe\nExico\nExpress\nExtreme\nExx\nEye\nEyes\nEzra\nF\nF.\nFabel\nFabiana\nFacella\nFae\nFair\nFairbanks\nFairchild\nFairlane\nFairy\nFaith\nFakta\nFalco\nFalcon\nFalcone\nFalk\nFall\nFallon\nFalls\nFaltoyano\nFancy\nFantanelli\nFantasia\nFantasy\nFantazy\nFantini\nFara\nFarce\nFare\nFarel\nFaris\nFarley\nFarlow\nFarrah\nFarrell\nFasterova\nFatale\nFatally\nFate\nFaucett\nFaust\nFauve\nFaux\nFawn\nFawndeli\nFawx\nFay\nFaye\nFayez\nFe\nFears\nFeaver\nFederova\nFeel\nFeeling\nFeels\nFeirah\nFeist\nFelaktig\nFelicity\nFeline\nFelix\nFellucci\nFelon\nFelucci\nFemme\nFendi\nFendii\nFenix\nFenn\nFenox\nFer\nFerara\nFerard\nFerari\nFerera\nFererro\nFernandes\nFernandez\nFerocious\nferrara\nFerraren\nFerrari\nFerraz\nFerre\nFerreira\nFerrer\nFerrera\nFerreri\nFerrero\nFerretti\nFerri\nFerry\nFesser\nFestiny\nFetti\nFetucci\nFevari\nFever\nFey\nFeya\nFG\nFice\nFichner\nFields\nFierce\nFierro\nFiesta\nFigueroa\nFillmore\nFilth\nFina\nFince\nFine\nFinish\nFinn\nFinnish\nFiona\nFione\nFiore\nFiorentino\nFiori\nFire\nFires\nFirst\nFisher\nFist\nFitgerald\nFitness\nFitzgerald\nFixx\nFlair\nFlame\nFlames\nFlamez\nFlash\nFlaxy\nFleiss\nFletcher\nFleurette\nFlex\nFlexy\nFlight\nFlimes\nFlint\nFlirt\nFlirts\nFlor\nFlora\nFlorentino\nFlores\nFlori\nFlow\nFlower\nFlowers\nFlux\nFlynn\nFodor\nFoirce\nFolass\nFollass\nFolwer\nFong\nFontaine\nFontana\nFontanelli\nFontes\nFontez\nFontinelly\nFord\nFore\nForeplay\nForero\nForever\nForrest\nFortuna\nFortune\nForza\nFoster\nFox\nFoxe\nFoxel\nFoxx\nFoxxe\nfoxxx\nFoxxxe\nFoxy\nFraga\nFrancesca\nFrancis\nFranco\nFrank\nFranklin\nFrankova\nFranks\nFraunce\nFray\nFrazier\nFreak\nFreaxxx\nFreedom\nFreeti\nFreire\nFreitas\nFrench\nFresh\nFrey\nFrias\nFriday\nFrontaine\nFrost\nFuckdoll\nFuckingham\nFuentes\nFukme\nFuli\nFunki\nFurious\nFux\nFuxx\nFyre\nFyres\nG\nG.\nGabanna\nGables\nGabor\nGaborova\nGabriel\nGadget\nGaga\nGain\nGala\nGalatea\nGalen\nGales\nGalkina\nGalla\nGallardo\nGalvez\nGamble\nGandi\nGang\nGangiev\nGap\nGape\nGapes\nGarcia\nGarcía\nGardell\nGarden\nGardenia\nGardner\nGarin\nGarmendia\nGarnet\nGartner\nGarza\nGasset\nGates\nGatsby\nGattebb\nGaucha\nGaucho\nGaugha\nGaultier\nGaviria\nGayle\nGaynor\nGee\nGehtr\nGelato\nGem\nGemes\nGemini\nGemma\nGenocide\nGensen\nGently\nGentry\nGeorge\nGeorgia\nGerald\nGere\nGergo\nGerson\nGetsit\nGetty\nGetz\nGhettman\nGhost\nGia\nGiacomo\nGiana\nGiancarlo\nGianino\nGianna\nGiavonni\nGibbons\nGibson\nGiepky\nGil\nGilbert\nGill\nGimenez\nGin\nGina\nGinger\nGingersnaps\nGinna\nGiovanna\nGiovanni\nGirl\nGivana\nGivanna\nGivemore\ngivens\nGivonna\nGizmo\nGlace\nGlam\nGlasford\nGlass\nGlaze\nGleason\nGlee\nGlide\nGlock\nGlory\nGlover\nGlower\nGoddard\nGoddess\nGodess\nGodiva\nGola\ngold\nGoldberg\nGolden\nGoldeu\nGoldfinger\nGoldi\nGoldis\nGoldnerova\nGolfinger\nGolubeva\nGomes\nGomez\nGomory\nGonzales\nGonzalez\nGood\nGoody\nGordon\nGore\nGorly\nGortace\nGotti\nGottie\nGozzi\nGrabbit\nGrace\nGracen\nGracie\nGraham\nGrahm\nGrain\nGram\nGrand\nGrande\nGrandey\nGrandi\nGranger\nGrant\nGraves\nGray\nGrays\nGrazzi\nGreat\nGreco\nGreen\nGreene\nGreenvelle\nGregorio\nGregory\nGrey\nGreys\nGri\nGriffin\nGriffith\nGrillo\nGrim\nGrind\nGrindhouse\nGrinds\nGross\nGrout\nGrove\nGruda\nGucci\nGuerlin\nGuerra\nGuerro\nGuess\nGuillen\nGuitierrez\nGulobeva\nGun\nGunn\nGunner\nGunns\nGutierrez\nGutter\nGuzman\nGyn\nGyongy\nH\nH.\nHa\nHadid\nHadjara\nHail\nhair\nHaire\nHaize\nHalborg\nHale\nHalili\nHall\nHally\nHalo\nHalston\nHamilton\nHammer\nHampton\nHamsel\nHana\nHanah\nHanes\nHank\nHanna\nHannah\nHansen\nHanson\nHarcourt\nHard\nHardcore\nHardin\nHarding\nHardon\nHardt\nHardy\nHari\nHarley\nHarllow\nHarlow\nHarmon\nHarper\nHarrington\nHarris\nHarrison\nHart\nHartley\nHartlova\nHartman\nHartz\nHaruhi\nHase\nHassana\nHastar\nHatano\nHatter\nHatzi\nHavel\nHaven\nHavli\nHawian\nHawke\nHawkens\nHawkins\nHawthorne\nHay\nHayden\nHayes\nHaynes\nHays\nHaze\nHazel\nHeart\nHeartazz\nHeartley\nHeartly\nHearts\nHeat\nHeather\nHeaven\nHeavens\nHeidi\nHeidy\nHeiress\nHelen\nHell\nHellfire\nHemingway\nHempburn\nHempburne\nHendrix\nHenessy\nHenger\nHengher\nHennessy\nHennesy\nHenrix\nHermosa\nHernandes\nHernandez\nHerrara\nHerrera\nHershey\nHess\nHeveyn\nHex\nHexxx\nHeys\nHicks\nHidden\nHide\nHigh\nHighlight\nHill\nHills\nHillton\nHilson\nHilton\nHim\nHimera\nHix\nHo\nHoboy\nHoffner\nHohan\nHoiz\nHola\nHole\nHoliday\nHollan\nHolland\nHollander\nHolliday\nHollie\nHollish\nHollister\nHolloway\nHolly\nHollywood\nHolm\nHolmes\nHolo\nHolsten\nHolt\nHolz\nHoney\nHoneywell\nHontas\nHook\nHooterz\nHoove\nHope\nHopkins\nHopper\nHore\nHorn\nHorne\nHorny\nHorticu\nHose\nHot\nHotcore\nHotie\nHott\nHottie\nHotwife\nHouston\nHovorkova\nHoward\nHowe\nHowell\nHoyos\nHoyt\nHsu\nHudson\nHughes\nHulk\nHumes\nHunt\nHuntel\nhunter\nHuntington\nHuntley\nHuntsman\nHurley\nHurlie\nHurt\nHush\nHussy\nHustle\nHuxlem\nHuxley\nHyde\nHydra\nHymes\nHype\nI\nI.\nIanova\nIbarra\nIbge\nIbiza\nIce\nIces\nIdol\nIdols\nII\nIljimae\nIllarionova\nIloua\nIlova\nIlsa\nImelda\nIn\nIncognita\nIndica\nIndie\nIndy\nInge\nIngretton\nInk\nInked\nInky\nInnaki\nIreland\nIrene\nIrie\nIris\nIrish\nIron\nIrons\nIrrova\nIsabel\nIsabella\nIsabelle\nIshtar\nIsis\nIsizzu\nIsland\nIsles\nIto\nIvana\nIvanov\nIvanova\nIvanovich\nIvans\nIve\nIves\nIvey\nIvory\nIvy\nJ\nJ.\nJackman\nJackme\nJackmon\nJackson\nJacme\nJacobs\nJacusy\nJade\nJadorlabit\nJae\nJager\nJagger\nJaguar\nJai\nJaimes\nJain\nJaine\nJakal\nJakarta\nJalace\nJale\nJam\nJambo\nJames\nJameson\nJamison\nJamma\nJammer\nJamsson\nJamuel\nJane\nJane1\nJanes\nJanine\nJano\nJanson\nJantzen\nJarako\nJardelli\nJark\nJarw\nJasmine\nJasper\nJax\nJaxin\nJaxson\nJaxxx\nJay\nJaydan\nJayde\nJayden\nJaye\nJayme\nJaymes\nJayn\nJayne\nJayy\nJazz\nJazzy\nJean\nJefferson\njem\nJen\nJenay\nJeneu\nJenkens\nJenkins\nJenna\nJenner\nJennings\nJennsen\nJensen\nJenson\nJersey\nJes\nJess\nJessi\nJessie\nJessop\nJessy\nJessyca\nJet\nJett\nJevaux\nJewel\nJewell\nJewells\nJewels\nJewelz\nJey\nJezel\nJ'Honson\nJifio\nJill\nJiminez\nJinkcego\nJizz\nJo\nJoe\nJoel\nJohanson\nJohanssen\nJohansson\nJohnes\nJohnson\nJohnston\nJoice\nJoker\nJolee\nJolei\nJoleigh\nJolene\nJoli\nJolie\nJollee\nJolye\nJolyn\nJones\nJons\nJoobiez\nJoones\nJoons\nJordan\nJordawn\nJorden\nJordin\nJose\nJoy\nJubalie\nJude\nJudge\nJuggs\nJuggsy\nJuice\nJuicy\nJuja\nJuju\nJul\nJule\nJules\nJulia\nJuliana\nJulianna\nJulie\nJuliet\nJuliett\nJuliette\nJulliett\nJune\nJung\nJungev\nJungle\nJuniper\nJust\nJustice\nJustin\nJustine\nJynx\nK\nK.\nKa\nKaboom\nKade\nKae\nKage\nKahil\nKahill\nKahlua\nKahsaklahwee\nKai\nKailey\nKain\nKaine\nKait\nKaitu\nKakes\nKakku\nKala\nKalani\nKaleb\nKalermen\nKali\nKaliana\nKalifornia\nKalisy\nKallos\nKally\nKaltava\nKalvetti\nKalypso\nKam\nKamikatze\nKamil\nKandy\nKane\nKani\nKano\nKaos\nKapri\nKaptive\nKara\nKaramb\nKardia\nKarel\nKarela\nKarenina\nKarera\nKarerra\nKari\nKarina\nKarinena\nKarins\nKarla\nKarma\nKarmel\nKarr\nKarrera\nKarrs\nKarson\nKartel\nKarter\nKartley\nKaryna\nKas\nKasady\nKasanova\nKase\nKash\nKaslo\nKassandra\nKassidy\nKassin\nKastle\nKastro\nKat\nKataine\nKatava\nKatchings\nKate\nKates\nKathrin\nKathryn\nKatie\nKato\nKats\nKatsaros\nKatseye\nKatseyes\nkatt\nKattz\nKatz\nKatzerl\nKauffman\nKavalli\nKavelli\nKay\nKayden\nKaye\nKayne\nKays\nKaytlin\nKayy\nKaz\nKeagan\nKeahola\nKeAloha\nKean\nKeat\nKeaton\nKeelings\nKeely\nKeen\nKeepsake\nKeite\nKelay\nKelemen\nKeller\nKelley\nKellie\nKelly\nKelter\nKemp\nKen\nKendall\nKendra\nKendrick\nKendrixx\nKennedy\nKenner\nKensley\nKent\nKenya\nKenyon\nKenzi\nKenzie\nKenzington\nKeogh\nKerez\nKerkove\nKerr\nKerstin\nKert\nKes\nKessler\nKester\nKette\nKeutass\nKey\nKeyes\nKeylar\nKeys\nKeyz\nKhaide\nKhaleesi\nKhalessi\nKhalifa\nKhan\nKiki\nKilgore\nKill\nKiller\nKillman\nKim\nKimber\nKimberly\nKimm\nKincaid\nKing\nKingsley\nKingsly\nKingsnorth\nkingston\nKink\nKinkaid\nKinky\nKinkycat\nKinski\nKinskih\nKinsley\nKinz\nKiraly\nKiray\nKirei\nKirschner\nKiss\nKisser\nKisses\nKit\nKita\nKitaine\nKite\nKitsuen\nKitt\nKitten\nKitti\nKittin\nKitty\nKittyyy\nKitz\nKiu\nKlara\nKlarskov\nKlass\nKlassa\nKlay\nKlaymour\nKleavage\nKleevage\nKlein\nKlenot\nKlien\nKlimaxxx\nKline\nKlonk\nKlout\nKlyde\nKnicks\nKnight\nKnightley\nKnightly\nKnights\nKnite\nKnock\nKnots\nKnox\nKnoxville\nKnoxx\nKnoxxx\nKo\nKo.\nKoal\nKobain\nKoda\nKohl\nKoi\nKoks\nKole\nKolege\nKolt\nKomali\nKombat\nKomori\nKonec\nKong\nKonn\nKooper\nKoos\nKorbin\nKord\nKori\nKornikova\nKorrine\nKorrs\nKors\nKort\nKortex\nKorti\nKortny\nKoshi\nKoshka\nKostner\nKot\nKougar\nKova\nKovac\nKovacs\nKovicova\nKox\nKoxx\nKoxxx\nKoyote\nKrabbe\nKraciva\nKraft\nKramer\nKraven\nKream\nKreams\nKreme\nKressler\nKrey\nKriguer\nKris\nKriss\nKristal\nKristar\nKrit\nKroff\nKroft\nKros\nKross\nKrowe\nKrown\nKrush\nKruz\nKryk\nKrystal\nKu\nKudanfer\nKuess\nKulani\nKumar\nKummin\nKummings\nKums\nKupcakes\nKurl\nKurtis\nKush\nKushka\nKwoi\nKy\nKye\nKyle\nKyler\nKyra\nKyrk\nL\nL.\nla\nLabarbara\nlabeau\nLabelle\nLabrent\nLacant\nLace\nLacey\nLacourt\nLacroft\nLacroix\nLady\nLaFemmeDC\nLafferty\nLaflare\nLafouine\nLafuente\nLago\nLahay\nLahren\nLai\nLain\nLaine\nLaird\nLaistner\nLakai\nLake\nLakehurst\nLaken\nLakes\nLali\nLaMann\nLamante\nLamar\nLamas\nLamb\nLamberti\nLambertini\nLambie\nLambo\nLamLam\nLamore\nLaMotta\nLamour\nL'Amour\nLamoure\nLamun\nLamy\nLan\nLana\nLancaster\nLancer\nLanchester\nLancome\nLander\nLanders\nLandry\nLane\nLanette\nLanewood\nLang\nLangdon\nLange\nLanger\nLangford\nLangston\nLani\nLanik\nLanz\nLanza\nLapiedra\nLaput\nLaren\nLarimar\nLarios\nLark\nLarker\nLarkson\nLarn\nLarnock\nLarocca\nLarocco\nLaroche\nLarson\nLarue\nLarynt\nLasage\nLasciva\nLash\nLashay\nLashey\nLashiene\nLass\nLata\nLatenight\nLatex\nLathania\nLati\nLatina\nLatine\nLatrisch\nLatte\nLatvia\nLaure\nLaurel\nLauren\nLaurence\nLaurenn\nLaurent\nLaurenti\nLauryn\nLav\nLava\nLavay\nLaveaux\nLaveux\nLavey\nLavour\nLaw\nLawless\nLawrence\nLawrense\nLawsom\nLawson\nLaxmi\nLay\nLaya\nLayke\nLayn\nLayne\nLayo\nlaysia\nLazure\nLe\nlea\nLeaf\nLeafe\nLeah\nLeal\nLeann\nLeathers\nLebeau\nLebelle\nLeblanc\nLebon\nLebrock\nLecerf\nLeChance\nLechter\nLeclair\nLeclaire\nLeda\nlee\nLeeane\nLeeanne\nLeen\nLeesa\nLefleur\nLeflour\nLegend\nLegends\nLeggy\nlei\nLeiddi\nLeigh\nLeighton\nLeih\nLeila\nLeima\nLelani\nLemay\nLeme\nLemeat\nLemmore\nLemon\nLemore\nLemos\nLena\nLenee\nLenix\nLennon\nLennox\nLeNoir\nLenore\nLenvin\nLeon\nLeone\nLeoni\nLeopard\nLeota\nLere\nLerk\nLes\nLesabre\nLesante\nLeshay\nLesley\nLeslie\nLess\nLesta\nLetto\nLetty\nLeve\nLeveah\nLevi\nLevine\nLevon\nLew\nLewa\nLewis\nLex\nLexa\nLexi\nLexing\nLexington\nLexis\nLexus\nLexx\nLexxx\nLey\nLeywood\nLez\nLezian\nL-fox\nLi\nLiana\nLicious\nLicioux\nLick\nLicks\nLicx\nLicxxx\nLicz\nLiddell\nLiegant\nLigaya\nLight\nLighthouse\nLightman\nLightspeed\nLigotage\nLika\nLike\nLikit\nLil\nLilien\nLillen\nLilly\nLily\nLima\nLin\nLina\nLinares\nLinarez\nLincoln\nLinda\nLindemulder\nLinden\nLindermann\nLindsay\nLine\nLing\nLink\nLinks\nlinn\nLins\nLinsey\nLinx\nLinz\nLion\nLions\nLionsmane\nLipoldina\nLipoldino\nLipps\nLips\nLiques\nLiqueur\nLira\nLisa\nLisboa\nLisbon\nLish\nLissa\nLit\nLita\nLite\nLito\nLitte\nLittle\nLiu\nLively\nLives\nLivia\nLivingston\nLix\nLixx\nLixxx\nLiz\nLiza\nLizz\nLloyd\nLmonde\nLo\nLoand\nLoarn\nLoba\nLobo\nLobos\nLobov\nLocke\nLockett\nLockhart\nLocks\nLoco\nLogan\nLohan\nLoilien\nLok\nLoki\nLokky\nLola\nLollypop\nLoma\nLombana\nLombard\nLomeli\nLondon\nLone\nLong\nLongleg\nLongoria\nLoo\nLoop\nLopes\nLopez\nLor\nLorain\nLord\nLords\nLore\nLorelei\nLorell\nLoren\nLorena\nLorenn\nLorens\nLorenz\nLorenza\nLorenzini\nLorenzza\nLorian\nLory\nLost\nLotharia\nLothario\nLott\nLotts\nLotus\nlou\nLouder\nLouie\nLouis\nLouise\nLouren\nLousada\nLouvel\nLova\nLovato\nlove\nLovebug\nLovecox\nLovecraft\nLovedream\nLovee\nLovehands\nLoveitt\nLovelace\nLoveland\nLovell\nLovelle\nLovelna\nLovelock\nLovely\nLoven\nLovenz\nLover\nLoves\nLovett\nLovette\nLovia\nLovit\nLovitt\nLovly\nLovska\nLowden\nLowe\nLoxx\nLoy\nLozano\nLu\nLua\nLuana\nLuanna\nLuanne\nLuau\nLuba\nLuberc\nLubere\nLuberec\nLubov\nLuca\nLucci\nLucero\nLuchik\nLucia\nLuciano\nLucille\nLucky\nLucy\nLui\nLuicy\nLuis\nLuiza\nLuka\nLuke\nLukics\nLulov\nLuna\nLune\nLure\nLuscious\nLusconi\nLuse\nLush\nLushes\nLusila\nLussy\nLust\nLustra\nLustt\nLuuna\nLuv\nLuvana\nLuvbug\nLuvgood\nLuvv\nLuwis\nLux\nLuxa\nLuxe\nLuxia\nLuxx\nLuxxx\nLuz\nLy\nLya\nLyall\nLykez\nLyn\nLyndon\nLynn\nLynn&Audrey\nLynna\nLynne\nLynx\nLynxxx\nLyon\nLyonn\nLyons\nLyra\nM\nM,\nM.\nMaax\nMac\nMaca\nMacArthur\nMacc\nMace\nMachado\nMack\nMackay\nMacy\nMaddison\nMaddisyn\nMaddox\nMaddron\nMaddux\nMadeline\nMaderas\nMadero\nMadina\nMadisin\nMadison\nMadness\nMadori\nMadrid\nMadron\nMaduire\nMadysinn\nMae\nMaers\nMafra\nMagalhoes\nMagenta\nMagic\nMagical\nMagma\nMagna\nMagne\nMagnum\nMagnusson\nMaguire\nMaho\nMai\nMaia\nMaiden\nMain\nMajor\nMal\nMala\nMalai\nMalani\nMalao\nmalati\nMalcolm\nMaldonado\nMalen\nMaley\nMalibu\nMalice\nMalii\nMalina\nMalkova\nMalle\nMallone\nMallory\nMalo\nMalone\nMalvo\nManarote\nManche\nMancini\nMandala\nMandlikova\nMandorla\nManeater\nManelli\nManga\nMango\nManhattan\nMann\nMannaken\nManning\nManole\nManroe\nMansfield\nMansion\nManson\nMansur\nMaracas\nMarbra\nMarc\nMarcean\nMarceau\nMarcela\nMarcell\nMarcella\nMarcelle\nMarch\nMarchelli\nMarciano\nMarco\nMarcolini\nMarcolliny\nMarconi\nMarcus\nMare\nMaree\nMarf\nMarfa\nMarga\nMargo\nMargot\nMari\nMaria\nMariah\nMarie\nMarika\nMarin\nMarín\nMarinetto\nMariposa\nMark\nMarkova\nMarks\nMarlee\nMarleigh\nMarley\nMarlow\nMarlowe\nMarques\nMárquez\nMarquise\nMarr\nMars\nMarshall\nMartell\nMarti\nMartin\nMartineli\nMartinelli\nMartinelly\nMartines\nmartinez\nMartini\nMartins\nMartix\nMarton\nMarvellou\nMarx\nMarxxx\nMary\nMaryy\nMasochist\nMasome\nMason\nMass\nMassaro\nMassey\nMasters\nMasterson\nMastos\nMastronelli\nMata\nMatarazo\nMatarazzo\nMathers\nMatheus\nMathews\nMathis\nMatos\nMatthews\nMattos\nMatty\nMau\nMaude\nMaui\nMauri\nMavali\nMaver\nMaverick\nMavi\nMax\nMaxim\nMaxima\nMaxx\nMaxxine\nMaxxx\nmay\nMaya\nMaybach\nMayde\nMaye\nMayer\nMayers\nMayes\nMayfair\nMayhem\nMaylee\nMaylen\nMaylene\nMayne\nMayo\nMays\nMayson\nMayweather\nMaywood\nMaz\nMaze\nMazz\nMazza\nMc\nMcadams\nMcArther\nMcbrian\nMcbride\nMcCaine\nMccarthy\nMccay\nMcclain\nMccray\nMcCullough\nMcDonald\nMcGraw\nMcGregor\nMcgwire\nMcheaven\nMckay\nMcKenna\nMckenzi\nMckenzie\nMckinley\nMcKinnon\nMcknight\nMclain\nMcLane\nMclaren\nMcPherson\nMcQueen\nMcQwire\nMcrae\nMcReese\nMe\nMeadors\nMeadow\nMeadows\nMebarak\nMechanique\nMeddison\nMedina\nMeeks\nMeer\nMeg\nMei\nMeir\nMekins\nMekki\nMel\nMelano\nMelatti\nMelba\nMelbourne\nMelchoto\nMelendez\nmelhasova\nMelissa\nMell\nMellow\nMelo\nMelody\nMelon\nMelone\nMeloni\nMelrose\nMemphis\nMena\nMenage\nMenaje\nMendelson\nMendes\nMendexz\nMendez\nMendini\nMendiny\nMendosa\nMendoza\nMenezes\nMentoni\nMenza\nMeor\nMeow\nMercedes\nMercedez\nMercer\nMerches\nMerchesi\nMerci\nMercury\nMercy\nMerino\nMerlot\nMey\nMeyer\nMeyers\nMeys\nMi\nMia\nMiami\nMicca\nMichaels\nMichele\nMichelle\nMichova\nMico\nMihaylik\nMijares\nMike\nMikita\nMikulova\nMilan\nMilana\nMilani\nMilano\nMild\nMiles\nMiley\nMilf\nMilian\nMiliani\nMilk\nMilka\nMilla\nMillan\nmiller\nMillion\nMills\nMillz\nMilo\nMilos\nMilson\nMilstar\nMilton\nMinage\nMinaj\nMinardi\nMinarote\nMinax\nMinerva\nMink\nMinks\nMinor\nMinsk\nMint\nMinx\nMinxxx\nMiny\nMirage\nMirai\nMiraj\nMiranda\nMirova\nMishel\nMissina\nMissy\nMist\nMistical\nMisty\nMitchell\nMitchells\nMiteva\nMiyagi\nMoans\nModa\nModel\nModels\nMoeller\nMohir\nMohr\nMoire\nMoist\nMojado\nMokhov\nMolass\nMoley\nMoll\nMolloy\nMolly\nMoloe\nMom\nMomelli\nMomsen\nMona\nMonaco\nMonae\nMonaee\nMonaghan\nMone\nMonela\nMoneli\nMonelli\nMones\nMonet\nMonett\nMonica\nMonique\nMonir\nMonro\nmonroe\nMonroe.\nMonrow\nMonroy\nMonster\nMontada\nMontaine\nMontana\nMonte\nMonteiro\nMontenegro\nMontero\nMontes\nMontez\nMontgomery\nMontoya\nMonus\nMoody\nMoon\nMoone\nMoonlight\nMoonm\nMoor\nmoore\nMoorhead\nMor\nMora\nMoraes\nMorales\nMoran\nMoranai\nMorano\nMorante\nMorbid\nMore\nMorel\nMorena\nMoreno\nMoretti\nMorgan\nMori\nMorich\nMorietti\nMorillo\nMoris\nMorison\nMorna\nMorningstar\nMoroe\nMoroso\nMorr\nMorre\nMorrgan\nMorris\nMorrison\nMortenroe\nMoss\nMost\nMoth\nMounds\nMoura\nMoure\nMourning\nMouth\nMoy\nMrazkova\nMs\nMugler\nMuhari\nMulatto\nMulino\nMullato\nMuller\nMullet\nMult\nMuniz\nMunoz\nMunroe\nMur\nMurdock\nMurphy\nMurray\nMurzuk\nMusa\nMuse\nMuti\nMyah\nMyers\nMylee\nMyles\nMyluv\nMynah\nMyne\nMynor\nMynx\nMynxx\nMyra\nN\nN.\nNaaz\nNabakova\nNacci\nNacole\nNadia\nNaghavi\nNakai\nNakamo\nNala\nNam\nName\nNapoli\nnappi\nNarilove\nNash\nNasha\nNataf\nNatasha\nNats\nNaughty\nNava\nNava'\nNavaro\nNavarro\nNaveah\nNaylor\nNazionale\nNea\nNeal\nNeale\nNee\nNeeme\nNeha\nNeidth\nNeight\nNeil\nNeill\nNek\nNelle\nNelson\nNemyo\nNeni\nNepal\nNeri\nnero\nNerri\nNerville\nNesso\nNetta\nNevada\nNevadah\nNevaeh\nNevaril\nNevena\nNeves\nNevez\nNext\nNicce\nNice\nNichole\nNichols\nNicholson\nNickels\nNicki\nNicky\nNicol\nNicolas\nnicole\nNicoll\nNicols\nNielsen\nNieto\nNieves\nNievez\nNight\nNightly\nNike\nNikea\nNikita\nNikitina\nNikki\nNikol\nNikola\nNikole\nNikova\nNikulina\nNiky\nNikyta\nNil\nNila\nNile\nNiles\nNils\nNilsson\nNimpho\nNin\nNina\nNine\nNinfo\nNinja\nNirvana\nNisha\nNite\nNitro\nNitzapanus\nNix\nNixon\nNixx\nNixxx\nN-Joy\nNo\nNobili\nNoble\nNoel\nNoelle\nNoir\nNoire\nNoiret\nNoirett\nNoja\nNoletty\nNona\nNord\nNordstrum\nNorhman\nNorman\nNorth\nNorthern\nNorthman\nNorton\nNorwood\nNotty\nNouvelle\nNouwelle\nNova\nNovack\nNovais\nNovak\nNovea\nNover\nNowak\nNowell\nNox\nNoxx\nNuar\nNunes\nNunez\nNuova\nNute\nNutter\nNyce\nNymphet\nNymphette\nNysm\nNyson\nNyte\nNyx\no\nO.\nO’Hara\nOaks\nOara\nObrien\nO'Brilliant\nObscure\nOcean\nOcelo\nOchoa\nO'Connell\nOconnor\nO'Connor\nOctober\nO'Dare\nO'Dell\nof\nOfficial\nOhaire\nOhara\nO'Hara\nOhh\nOi\nOily\nOishi\nOkita\nOksana\nOla\nOlar\nOle\nOlgalit\nOliveira\nOliver\nOlove\nOlovely\nO'lovely\nOlsen\nOlson\nOlsoo\nOlsson\nOmidee\nOn\nOne\nO'Neal\no'neil\nOneil\nO'neil\nONeil\nO'Neil\nOneill\nOnnette\nOnyx\nOolo\nOpal\nOphelia\nOQuinn\nOra\nOrchid\nO'Reilley\nOreilly\nO'reilly\nOReilly\nO'Reilly\nOrger\nOrgy\nOriley\nO'Riley\nOrion\nO'Rion\nOrlov\nOrlowsky\nOrozco\nOrsoia\nOrsolya\nOrsoya\nOrtega\nOrth\nOrtiz\nOrto\nO'Ryan\nOscar\nOshea\nO'Shine\nOso\nOssa\nOsuna\nOthila\nOtis\nOwens\nOzaki\nP\nP.\nPablo\nPac\nPacheco\nPachino\nPacific\nPacino\nPadilha\nPadova\nPaes\nPag\nPage\nPai\nPaige\nPain\nPaine\nPaint\nPaisley\nPaiva\nPaixao\nPajón\nPak\nPalace\nPalam\nPalanco\nPalma\nPalmer\nPaloma\nPalomino\nPaltrova\nPanda\nPanigale\nPantera\nPanther\nPaola\nPaolo\nPaouk\nParadice\nParadis\nParadise\nParcker\nParis\nParisch\nParish\nPariss\nPark\nParkee\nParker\nParks\nParrish\nPartem\nPartia\nParts\nParvati\nParys\nPasion\nPassion\nPatal\nPataski\nPatricia\nPatrick\nPatron\nPatti\nPaul\nPaula\nPavlova\nPaws\npax\nPaxson\nPayne\nPea\nPeach\nPeachbloom\nPeache\nPeaches\nPeachez\nPeachy\nPeacock\nPeake\nPeaks\nPearl\nPearly\nPears\nPearson\nPedals\nPee\nPeida\nPele\nPena\nPeña\nPendavis\nPendragon\nPenn\nPepper\nPeralta\nPerdue\nPerez\nPerfekta\nPeridot\nPerl\nPeron\nPerri\nPerry\nPeta\nPeters\nPeterson\nPetite\nPetra\nPetrasova\nPetrov\nPetrova\nPettite\nPhair\nPhamous\nPhellasio\nPhelpz\nPheonix\nPhilippe\nPhillip\nPhillips\nPhire\nPhoenix\nPhoxxx\nPhucket\nPhuket\nPhukzalot\nPi\nPia\nPiaf\nPiaff\nPiccola\nPie\nPierce\nPierceson\nPierson\nPigale\nPiggy\nPills\nPimienta\nPinay\nPinelli\nPines\nPink\nPinkdot\nPino\nPinx\nPiper\nPiperfawn\nPiquet\nPirelli\nPires\nPitanga\nPixi\nPlant\nPlatinum\nPlay\nPlays\nPleasant\nPleasure\nPleasures\nPleezer\nPlugaru\nPlum\nPochetino\nPoison\nPol\nPolansky\nPolina\nPoll\nPolynesia\nPomodoro\nPonce\nPons\npop\nPopova\nPopp\nPoppens\nPopperz\nPoppos\nPorkman\nPorlote\nPorn\nPorna\nPornero\nPorsche\nPorshe\nPort\nPorter\nPortland\nPortman\nPorto\nPorttioli\nPosa\nPosh\nPossa\nPost\nPotter\nPoulin\nPowder\nPowell\nPower\nPowers\nPoz\nPozzi\nPrada\nPrado\nPraga\nPrat\nPraud\nPrecious\nPreesleyy\nPrego\nPrensley\nPrentice\nPrescott\nPreslee\nPresley\nPresleyy\nPresova\npreston\nPretel\nPrettyman\nPrice\nPriego\nPrince\nPrincess\nPriscilla\nProdo\nProject\nPromisita\nProud\nProvocateur\nPryce\nPucci\nPuller\nPumpkin\nPumpkins\nPunani\nPunzel\nPuppy\nPure\nPureheart\nPurr\nPussan\nPussy\nPutri\nPync\nPyr\nQ\nQ.\nQartel\nQinu\nQorrel\nQueen\nQueens\nQueiros\nQuerber\nQuest\nQuicero\nQuin\nQuinn\nQuinno\nQuintana\nQuintero\nQuinteros\nR\nR.\nRabbit\nRabina\nRachel\nRachelle\nRacquelle\nRada\nRader\nRadeva\nRadke\nRae\nRaee\nRaegan\nRafaelli\nRafail\nRage\nRai\nRaily\nRain\nRainbow\nRaine\nRaines\nRains\nRainz\nRaise\nRaketa\nRako\nRamada\nRamirez\nRamon\nRamondini\nRamone\nRamos\nRampage\nRampling\nRandee\nRandy\nRange\nRangel\nRanieri\nRapace\nRaphael\nRaquel\nRare\nRasmussen\nRav\nRavaged\nRaven\nRaxxx\nRay\nRayann\nRaye\nRayes\nRayles\nRaymond\nRayn\nRayne\nRaynes\nRayno\nRayye\nRaz\nRe\nRead\nReagan\nReagen\nReamz\nReaper\nRebeka\nRebel\nRebelde\nRebell\nRebka\nRecna\nRed\nRedbird\nRedd\nRedgrave\nRedhart\nRedheart\nRedmond\nRedwood\nRedz\nReece\nReed\nReeder\nReene\nReese\nReeves\nRegan\nRegar\nRegency\nRegin\nRei\nReid\nReif\nReign\nReigns\nReilly\nRein\nReina\nReinas\nReindeer\nReines\nReise\nRel\nRemington\nRen\nRenae\nRenay\nRendall\nRene\nRenea\nRenee\nResa\nRessen\nRestrepo\nReturn\nRevamped\nReve\nRevees\nRevere\nRex\nRey\nReyes\nReyez\nReynolds\nRhapsody\nRhea\nRhios\nRhoades\nrhodes\nRhound\nRhyder\nRhydes\nRhymes\nRia\nRibeiro\nRibera\nRiberio\nRica\nRican\nRicci\nRice\nRich\nRichards\nRichardsen\nRichardson\nRiches\nRichey\nRichi\nRichie\nRichman\nRico\nRida\nRiddle\nRide\nRider\nRied\nRiely\nRiesling\nRiggs\nRight\nRights\nRiley\nRima\nRimers\nRimes\nRin\nRina\nRinaldi\nRing\nRio\nRios\nRise\nRisi\nRising\nRisingstar\nRispoli\nRita\nRite\nRitz\nRius\nRiv\nRival\nRivas\nRiver\nRivera\nRiveras\nRivers\nRiviera\nRix\nRixel\nRizel\nRizzi\nRizzo\nRoads\nRobbie\nRobbin\nRobbins\nRoberts\nRobins\nRobinson\nRobles\nRoc\nRoca\nRoccaforte\nRocher\nRochester\nRock\nRocket\nRockette\nRocks\nRockwell\nRode\nRodgers\nRodhes\nRodrigez\nRodrigues\nRodriguez\nRogen\nRogers\nRogerz\nRogue\nRojas\nRoland\nRoll\nRolland\nRoller\nRollings\nRoma\nRomain\nRoman\nRomance\nRomani\nRomano\nRomanoff\nRomanova\nRome\nRomee\nRomero\nRomin\nRone\nRoper\nRosa\nRosae\nRosar\nrose\nRosebug\nRosee\nRosembush\nRosen\nRosenberg\nRoses\nRoss\nRossa\nRosses\nrossi\nRossini\nRosso\nRoswell\nRosy\nRosz\nRoth\nRo'ti\nRotten\nRotti\nRotts\nRouge\nRough\nroulette\nRound\nRoundell\nRouso\nRouss\nRousseau\nRousso\nRovento\nRow\nRowe\nRox\nRoxx\nRoxxx\nRoxy\nRoy\nRoyal\nRoyalle\nRoyce\nRoze\nRozker\nRrock\nRubi\nRubia\nRubin\nRubio\nRuby\nRucka\nRud\nRuiz\nRumor\nRunaway\nRush\nRuso\nRuss\nRussa\nRussel\nRussell\nRusso\nRussof\nRutska\nRx\nRya\nRyad\nryan\nRyann\nRyans\nRydell\nRyden\nRyder\nRydes\nRye\nRylee\nRyu\nRyun\nS\nS.\nS?imonova?\nSaander\nSaase\nSabadra\nSabatiny\nSabbatini\nSabelle\nSable\nSabrina\nSaddler\nSadique\nSadora\nSag\nSage\nSahara\nSahari\nSaige\nSaike\nSaint\nSaint-Amour\nSainz\nSakala\nSakova\nSakura\nSalazar\nSaldana\nSalieri\nSaliery\nSalinas\nSallai\nSalles\nSalome\nSalt\nSalvatore\nSalzedo\nSamara\nSamira\nSamora\nSampaio\nSampson\nSamson\nSamsonit\nSamsonite\nSamuel\nSamuella\nSamuels\nsan\nSancha\nSanches\nSanchez\nSand\nSanders\nSandimas\nSandobar\nSandorran\nSandoval\nSandra\nSands\nSandy\nSandz\nSanger\nSanie\nSanna\nSantamaria\nSantana\nSantanna\nSante\nSantee\nSantez\nSanthiago\nSanti\nSantiago\nSanto\nSantoro\nSantos\nSanz\nSaphire\nSapphire\nSarabria\nSaran\nSartre\nSashu\nSatin\nSativa\nSatynge\nSauerova\nSault\nSaunders\nSauvage\nSavage\nSavoy\nSawamoto\nSawyer\nSax\nSaxo\nScandal\nScaris\nScarlet\nScarlett\nSchiffer\nSchimkova\nSchmidt\nSchmitt\nSchnaider\nSchon\nSchulten\nSchultz\nSchwartz\nSchwarz\nSchweider\nScotland\nScott\nScout\nScream\nScrew\nSculptura\nSeacrast\nSeagrave\nSean\nSeart\nSeashell\nSeback\nSebring\nSecret\nSecrets\nSecura\nSedona\nSee\nSegal\nSeiber\nSeikola\nSekova\nSelice\nSephora\nSerandon\nSeraph\nSerbia\nSereas\nSeron\nSerrano\nSetting\nSev\nSeva\nSeven\nSeverine\nSevilla\nSex\nSexin\nSexlove\nSexon\nSexston\nSexton\nSexwick\nSexx\nSexxx\nSexxxton\nSexy\nSey\nSeymour\nShadows\nShae\nShaft\nShagwell\nShai\nShain\nShaine\nShakti\nShan\nShand\nShane\nShanelle\nShannon\nShanti\nShanty\nShanviya\nShape\nSharada\nSharapova\nShark\nSharm\nSharon\nSharp\nSharpe\nShavon\nShaw\nShay\nShayton\nShea\nShee\nSheilds\nShelby\nShell\nShelson\nShelton\nShepard\nSheperd\nSheppard\nSheridan\nSherwood\nSheryl\nShevon\nSheyla\nShi\nShibari\nShibuya\nShidlerova\nShieffer\nShield\nShields\nShiffer\nShiin\nShine\nShiner\nShira\nShiraz\nShiva\nShmidt\nShore\nShores\nShort\nShortcake\nShorte\nShow\nShowers\nShpak\nShreya\nShy\nShye\nShyn\nShyne\nSi\nSiam\nSianna\nSiberia\nSid\nSidney\nSieb\nSienna\nSierra\nSights\nSikos\nSilent\nSiline\nSilk\nSilky\nSilva\nSilveira\nSilver\nSilvermoon\nSilvers\nSilverstone\nSilvia\nSimari\nSimeone\nSimmers\nSimmons\nSimms\nSimon\nSimone\nSimons\nSimpson\nSin\nSinaloa\nSincere\nSinclair\nSinclaire\nSinderson\nSindy\nSinead\nSinfox\nSinful\nSinger\nSinister\nSinkati\nSinn\nSinner\nSinns\nSins\nSinsacion\nSintonni\nSinz\nSioux\nSipos\nSiren\nSirena\nSirena69\nSires\nSissy\nSisters\nSix\nSixth\nSixty\nSixx\nSixxx\nSkarscard\nSkat\nSkie\nSkies\nSkills\nSkin\nSkinner\nSkinski\nsky\nskye\nSkyes\nSkyhigh\nSkylar\nSkyler\nSkylight\nSkyline\nSkymm\nSkyy\nSkyye\nSkyz\nSlade\nSlader\nSlag\nSlager\nSlam\nSlate\nSlave\nSlem\nSlick\nSligen\nSlim\nSlimmer\nSlit\nSliver\nSloan\nSloppy\nSloss\nSlot\nSlow\nSlutson\nSly\nSM\nSmack\nSmall\nSmalls\nSmart\nSmeraldi\nSmile\nSmiles\nSmiley\nSmiss\nSmith\nSmoke\nSmokes\nSmolina\nSmooth\nSmooty\nSmorjai\nSmrhova\nSnake\nSnakeoil\nSnezna\nSnow\nSoares\nSocho\nSofi\nSoft\nSokol\nSol\nSola\nSolari\nSolaris\nSolei\nSoleil\nSolis\nSollis\nSolo\nSomers\nSommer\nSommers\nSommerville\nSon\nSonay\nSong\nSonic\nSonja\nSonnet\nSophia\nSophie\nSoprano\nSosa\nSosh\nSoto\nSottile\nSoul\nSouls\nSouth\nSouthe\nSouza\nSoxton\nSpace\nSpade\nSpades\nSpain\nSpanks\nSpark\nSparkle\nSparkles\nSparks\nSparkx\nSparkz\nSparrow\nSparx\nSparxx\nSparxxx\nSpazio\nSpears\nSpeed\nSpencer\nSphinx\nSpice\nSpicy\nSpider\nSpielberg\nSpilona\nSpindrift\nSpinks\nSpinner\nSpirit\nSpirits\nSpirm\nSpit\nSplash\nSplit\nSpreadz\nSpring\nSpringer\nSpringfield\nSpringlare\nSpringvalley\nSprouts\nSpyce\nSquaw\nSquirt\nSsens\nSsy\nst\nSt.\nSt.Claire\nSt.James\nStaar\nStabone\nStacey\nStacks\nStacxxx\nStacy\nStafford\nStair\nStaks\nStallion\nStallone\nStalls\nStanwick\nStanza\nStar\nStaranzano\nStarbuck\nStarbux\nStarfall\nStark\nStarks\nStarlet\nStarlett\nStarletto\nStarlight\nStarling\nStarlix\nStarr\nStarshine\nStarxxx\nStarz\nStarzi\nStax\nStaxx\nStaxxx\nStaylon\nStclair\nStclaire\nSteal\nSteale\nSteel\nSteele\nStefani\nSteffan\nSteffanie\nSteffano\nStegal\nSteil\nStein\nStello\nStena\nStendhall\nStephanie\nStephens\nSterling\nSterlyng\nStern\nStetson\nstevens\nStevenson\nStevenz\nStewart\nStewert\nStick\nStiel\nStiles\nStillar\nStills\nSting\nStingrey\nStoke\nStokely\nStokes\nStokley\nStoli\nStone\nStoned\nStonem\nStones\nStorch\nStorm\nStotch\nStoune\nStovne\nStowaway\nStrack\nStrahan\nStraletto\nStrange\nStrauss\nStrawberry\nStreb\nStretton\nStriker\nStrip\nStroker\nStrokes\nStrom\nStrong\nStrutt\nStuart\nStunner\nStunns\nSturm\nStyle\nStyles\nStylez\nStylle\nSu\nSuarez\nSubmi\nSuck\nSudhra\nSue\nSuede\nSuga\nSugar\nSugarcube\nSuicide\nSuizi\nSuka\nSullivan\nSultisz\nSultra\nSultry\nSummer\nSummers\nSummerz\nSun\nSunderland\nSunn\nSunny\nSunrace\nSunset\nSunshine\nSunshyne\nSure\nSurfer\nSurfistinha\nSusi\nSusterman\nSutani\nSutra\nSuz\nSuzie\nSuzuki\nSveltnotska\nSwabery\nSwallow\nSwan\nSwank\nSwann\nSwartz\nSway\nSwayze\nSwed\nSwede\nSweeney\nSweet\nSweetheart\nSweets\nSweetstorm\nSweety\nSweetz\nSwen\nSwenson\nSwift\nSwing\nSwinger\nSwit\nSwix\nSwoon\nSX\nSy\nSyde\nSykes\nSylver\nSym\nSymon\nSymone\nSymz\nSynful\nSynn\nSynns\nSynz\nSyre\nSytnyy\nSz\nSz.\nSzalontai\nSzoke\nT\nT.\nTabitha\nTae\nTaffe\nTaggart\nTai\nTailor\nTalerico\nTali\nTaliana\nTalise\nTality\nTalon\nTalonz\nTalore\nTamayo\nTame\nTames\nTan\nTanaka\nTanelli\nTaner\nTango\nTanner\nTantra\nTao\nTaormino\nTarey\nTassin\nTate\nTatoo\nTattoo\nTatu\nTatum\nTayler\ntaylor\nTaylors\nTchekan\nTchernei\nTcherney\nTea\nTeagan\nTeasdale\nTease\nTeddy\nTedesco\nTeen\nTeena\nTeens\nTeilor\nTeles\nTemplar\nTemple\nTemptations\nTemptem\nTen\nTender\nTequila\nTerra\nTerrace\nTess\nTexas\nTeyra\nThai\nThames\nthe\nTheron\nThick\nThiest\nThom\nThomas\nthompson\nThomson\nThomsons\nThoren\nThorn\nThorne\nThornton\nThreeway\nThumper\nThunder\nThurman\nTia\nTicha\nTickler\nTiffani\nTiffany\nTiffian\nTiger\nTight\nTilashi\nTilden\nTilli\nTilly\nTime\nTina\nTinelli\nTink\nTinley\nTiny\nTion\nTip\nTisdale\nTissen\nTitty\nTiziano\nTNT\n'TNT'\nToc\nTolimb\nTom\nTomankova\nTomas\nTomassi\nTommasi\nToo\nToren\nTores\nTorez\nTormay\nTorn\nToro\nTorrance\nTorres\nTorrez\nTort\nTorvos\nToryn\nToscani\nTosh\nTouch\nTouche\nTouched\nTovar\nToxic\nToy\nTrailer\nTralla\nTran\nTrasks\nTraveler\nTravers\nTravis\nTreasure\nTreasures\nTrece\nTrejsi\nTrends\nTrent\nTrevino\nTrickle\nTrimble\nTrinity\nTrip\nTripp\nTrix\nTrixie\nTrixxx\nTropic\nTrouble\nTroy\nTrue\nTruelove\nTrump\nTruth\nTruu\nTsunami\nTucci\nTucker\nTuesday\nTuft\nTung\nTunnels\nTurk\nTurnah\nTurner\nTwain\nTweeks\nTwice\nTwigs\nTwins\nTy\nTye\nTyelar\nTyler\nTylor\nTyme\nTyna\nTyron\nU\nU.\nUla\nUlalai\nUlly\nUltra\nUluvpunani\nUnco\nUnderhill\nUnderwood\nUndine\nUneek\nUnicorn\nUnik\nUnited\nUnits\nUniverse\nUnuke\nUptop\nUptown\nUr\nUrages\nUri\nUrugua\nV\nV.\nVachs\nVader\nVaesen\nVagine\nVahn\nVail\nVain\nVaine\nValami\nValdes\nValdez\nValdovino\nVale\nValencia\nValente\nValenti\nValentien\nValentin\nValentina\nValentine\nValentino\nValentyne\nValenzuela\nVALERIA\nValerie\nValery\nValkova\nValkyrie\nVallem\nVallery\nValletta\nValley\nValli\nVallis\nValmont\nValor\nValour\nVamp\nvan\nVanaga\nVanburen\nvandella\nVandeven\nVandory\nVanessa\nVanguard\nVanickova\nVanilie\nVanilla\nVanilli\nVanity\nVanoza\nVardinski\nVarella\nVarga\nVargas\nVargaz\nVarney\nVarya\nVasili\nVasques\nVasquez\nVaughn\nVazquez\nVecru\nVecsey\nVedem\nVee\nVega\nVegas\nVegax\nVeils\nVein\nVelasques\nVelasquez\nVelazquez\nVelez\nVeliz\nVella\nVellons\nVelour\nVelvet\nVelvett\nVendetta\nVendetti\nVenera\nVeness\nVenice\nVenom\nVenter\nVenton\nVentura\nVenturi\nVenturini\nVenus\nVera\nVeracruz\nVerde\nVerdi\nVerene\nVerhooks\nVerla\nVerlant\nVermillion\nVeroni\nVerricci\nVerrone\nVersac\nVersace\nVersaci\nVert\nVerwest\nVesela\nVespoli\nVessir\nVesta\nVette\nVex\nVi\nViano\nVibe\nVice\nVicious\nVicky\nVictoria\nVictorie\nVictory\nVictress\nVidal\nVidel\nVider\nVidis\nVie\nVieira\nViera\nViexen\nViksi\nViktoria\nVilla\nVillain\nVillainess\nVillainous\nVillegas\nVimax\nVina\nVincent\nVine\nVinson\nVintage\nVinton\nViolations\nViolet\nViolete\nVioletta\nViolette\nViper\nVirago\nVirdi\nVirgin\nVirgo\nVisby\nViscara\nVisconti\nViskonti\nVita\nVital\nVitale\nVitality\nVitus\nViva\nVivas\nVivian\nVivienne\nVix\nVixen\nVixin\nVixon\nVixxen\nViya\nVog\nVoge\nVogel\nVogue\nVoice\nVokova\nVoldok\nVolkova\nVolpetti\nVolt\nvon\nVonage\nVonDoom\nVoneva\nVonn\nVoo\nVood\nVore\nVortex\nVoss\nVosse\nVouge\nVox\nVox,\nVoxx\nVoxxx\nVoya\nVoyager\nVrea\nVs\nVs.\nVue\nVuiton\nVuitton\nVution\nW\nW.\nWaay\nWade\nWagner\nWaine\nWais\nWaist\nWalack\nWales\nWalker\nWallace\nWalner\nWan\nWander\nWane\nWank\nWant\nWard\nWare\nwarner\nWarren\nWashington\nWasse\nWaste\nWater\nWaterfall\nWaters\nWatson\nWattana\nWayne\nWays\nWayy\nWeasley\nWebb\nWeigel\nWeiss\nWeix\nWelch\nWell\nWelles\nWellin\nWellness\nWellons\nWells\nWels\nWenera\nWest\nWestgate\nWestin\nWestley\nWeston\nWestsun\nWet\nWetsx\nWett\nWeyron\nWhiley\nWhiskey\nWhisper\nWhispers\nWhite\nWhite-Kitten\nWhites\nWhorehall\nWhyte\nWicky\nWiddow\nWidow\nWienold\nWiesenthal\nWiggum\nWil\nwild\nWilde\nWildee\nWilder\nWildman\nWildwood\nWiley\nWilkes\nWill\nWilliam\nWilliams\nWillis\nWilson\nWimberly\nWind\nWindsor\nWine\nWinfe\nWings\nWinks\nWinslett\nWinston\nWinter\nWinters\nWipper\nWire\nWish\nWisky\nWissental\nWitch\nWithe\nWolf\nWolfe\nWolff\nWolfox\nWolk\nWomba\nWonder\nWonderful\nWonderland\nWonders\nWoo\nWood\nWoods\nWoodward\nWoodyhaven\nWoodz\nWoor\nWorder\nWorld\nWorth\nWorthington\nWorthy\nWoulfe\nWow\nWowgue\nWren\nWright\nWrites\nWulam\nWulf\nWumps\nWuze\nWylde\nWylder\nWynn\nWynne\nWynter\nWynters\nWyte\nX\nX.\nXandra\nXcite\nXexe\nXiji\nXinga\nXo\nXore\nXoXo\nXX\nXXL\nXxx\nY\nY.\nYamagucci\nYamamoto\nYande\nYang\nYani\nYankovskaya\nYasmin\nYasmine\nYayo\nYen\nYi\nYin\nYof\nYohansson\nYoko\nYork\nYoun\nYoung\nYoungs\nYoyo\nYoyowitch\nYoyre\nYu\nYuki\nYul\nYulan\nYummy\nYung\nZ\nZ.\nZaguna\nZales\nZalicova\nZaltana\nZan\nZane\nZara\nZatch\nZdrok\nZecchi\nZee\nZemanova\nZen\nZero\nZerva\nZet\nZeta\nZeus\nZex\nZhora\nZiana\nZima\nZinn\nZo\nZoe\nZola\nZolva\nZoom\nZora\nZova\nZur\nZurich\nZvezda\nZya\n"
  },
  {
    "path": "tools.go",
    "content": "//go:build tools\n// +build tools\n\npackage main\n\nimport (\n\t_ \"github.com/99designs/gqlgen\"\n\t_ \"github.com/99designs/gqlgen/graphql/introspection\"\n\t_ \"github.com/Yamashou/gqlgenc\"\n\t_ \"github.com/vektah/dataloaden\"\n\t_ \"github.com/vektra/mockery/v2\"\n)\n"
  },
  {
    "path": "ui/login/login.css",
    "content": "/* try to reflect the default css as much as possible */\n* {\n    box-sizing: border-box;\n}\nhtml {\n    font-size: 14px;\n}\n\nbody {\n    background-color: #202b33;\n    color: #f5f8fa;\n    font-family: -apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Helvetica Neue\",Arial,\"Noto Sans\",sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\";\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n    margin: 0;\n    padding: 0;\n    overflow-y: hidden;\n}\n\nh6 {\n    font-size: 1rem;\n    margin-top: 0;\n    margin-bottom: .5rem;\n    font-weight: 500;\n    line-height: 1.2;\n}\n\nbutton, input {\n    margin: 0;\n    font-family: inherit;\n    font-size: inherit;\n    line-height: inherit;\n}\n\n.card {\n    background-color: #30404d;\n    border-radius: 3px;\n    box-shadow: 0 0 0 1px rgba(16,22,26,.4), 0 0 0 rgba(16,22,26,0), 0 0 0 rgba(16,22,26,0);\n    padding: 20px;\n}\n\n.dialog {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    \n    width: 100%;\n    height: 100vh;\n    padding-right: 15px;\n    padding-left: 15px;\n    margin-right: auto;\n    margin-left: auto;\n}\n\n.form-group {\n    margin-bottom: 1rem;\n}\n\n.form-control {\n    display: block;\n    width: 100%;\n    height: calc(1.5em + .75rem + 2px);\n    padding: .375rem .75rem;\n    font-size: 1rem;\n    font-weight: 400;\n    line-height: 1.5;\n    color: #495057;\n    background-clip: padding-box;\n    border: 1px solid #ced4da;\n    border-radius: .25rem;\n    -webkit-transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;\n    transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;\n}\n\n.text-input {\n    border: 0;\n    box-shadow: 0 0 0 0 rgba(19,124,189,0), 0 0 0 0 rgba(19,124,189,0), 0 0 0 0 rgba(19,124,189,0), inset 0 0 0 1px rgba(16,22,26,.3), inset 0 1px 1px rgba(16,22,26,.4);\n    color: #f5f8fa;\n}\n\n.text-input, .text-input:focus, .text-input[readonly] {\n    background-color: rgba(16,22,26,.3);\n}\n\n.btn {\n    display: inline-block;\n    font-weight: 400;\n    color: #212529;\n    text-align: center;\n    vertical-align: middle;\n    cursor: pointer;\n    -webkit-user-select: none;\n    -moz-user-select: none;\n    -ms-user-select: none;\n    user-select: none;\n    background-color: initial;\n    border: 1px solid transparent;\n    padding: .375rem .75rem;\n    font-size: 1rem;\n    line-height: 1.5;\n    border-radius: .25rem;\n    -webkit-transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;\n    transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;\n}\n\n.btn-primary {\n    color: #fff;\n    background-color: #137cbd;\n    border-color: #137cbd;\n}\n\n.login-error {\n    color: #db3737;\n    font-size: 80%;\n    font-weight: 500;\n    padding-bottom: 1rem;\n}\n\n@media (max-width: 576px) {\n    .card {\n        width: 100%;\n    }\n\n    .dialog {\n        height: auto;\n        margin-top: 50%;\n    }\n\n    .btn-primary {\n        width: 100%;\n    }\n}"
  },
  {
    "path": "ui/login/login.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <base href=\"/%BASE_URL%/\">\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n    <title>Login</title>\n\n    <link rel=\"shortcut icon\" href=\"data:,\">\n    <link rel=\"stylesheet\" href=\"login/login.css\">\n    <link rel=\"stylesheet\" href=\"css\">\n\n    <!-- load locale -->\n    <script>\n        var localeStrings = {\n            username: \"Username\",\n            password: \"Password\",\n            login: \"Login\",\n            invalid_credentials: \"Invalid credentials\",\n            internal_error: \"Unexpected internal error. See logs for more details\"\n        };\n    </script>\n    <script src=\"login/locale\"></script>\n</head>\n\n<script>\n    function login() {\n        var username = document.getElementById(\"username\").value;\n        var password = document.getElementById(\"password\").value;\n        var returnURL = document.getElementById(\"returnURL\").value;\n\n        var xhr = new XMLHttpRequest();\n        xhr.open(\"POST\", \"login\", true);\n        xhr.setRequestHeader(\"Content-Type\", \"application/x-www-form-urlencoded\");\n        xhr.onreadystatechange = function() {\n            if (xhr.readyState == 4) {\n                if (xhr.status == 200) {\n                    window.location.replace(returnURL);\n                } else {\n                    document.getElementsByClassName(\"login-error\")[0].innerHTML = localeStrings.invalid_credentials;\n                }\n            }\n        };\n        xhr.onerror = function() {\n            document.getElementsByClassName(\"login-error\")[0].innerHTML = localeStrings.internal_error;\n        };\n        var body = \"username=\" + encodeURIComponent(username) + \"&password=\" + encodeURIComponent(password) + \"&returnURL=\" + encodeURIComponent(returnURL);\n        xhr.send(body);\n    }\n</script>\n\n<body class=\"login\">\n    <div class=\"dialog\">\n        <div class=\"card\">\n            <form action=\"login\" method=\"POST\" onsubmit=\"event.preventDefault(); login();\">\n                <div class=\"form-group\">\n                    <label for=\"username\"><h6 id=\"username-heading\">Username</h6></label>\n                    <input class=\"text-input form-control\" id=\"username\" name=\"username\" type=\"text\" placeholder=\"Username\" />\n                </div>\n                <div class=\"form-group\">\n                    <label for=\"password\"><h6 id=\"password-heading\">Password</h6></label>\n                    <input class=\"text-input form-control\" id=\"password\" name=\"password\" type=\"password\" placeholder=\"Password\" />\n                </div>\n                <div class=\"login-error\">\n                    {{.Error}}\n                </div>\n\n                <input type=\"hidden\" id=\"returnURL\" name=\"returnURL\" value=\"{{.URL}}\" />\n\n                <div>\n                    <input id=\"login-button\" class=\"btn btn-primary\" type=\"submit\" value=\"Login\">\n                </div>\n            </form>\n        </div>\n    </div>\n\n</body>\n\n<script>\n    document.getElementById(\"username-heading\").innerText = localeStrings.username;\n    document.getElementById(\"password-heading\").innerText = localeStrings.password;\n    document.getElementById(\"username\").placeholder = localeStrings.username;\n    document.getElementById(\"password\").placeholder = localeStrings.password;\n    document.getElementById(\"login-button\").value = localeStrings.login;\n</script>\n</html>\n"
  },
  {
    "path": "ui/ui.go",
    "content": "//go:generate go run -tags=dev ../scripts/generateLoginLocales.go\npackage ui\n\nimport (\n\t\"embed\"\n\t\"io/fs\"\n\t\"runtime\"\n)\n\n//go:embed v2.5/build\nvar uiBox embed.FS\nvar UIBox fs.FS\n\n//go:embed login\nvar loginUIBox embed.FS\nvar LoginUIBox fs.FS\n\nfunc init() {\n\tvar err error\n\tUIBox, err = fs.Sub(uiBox, \"v2.5/build\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tLoginUIBox, err = fs.Sub(loginUIBox, \"login\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n}\n\ntype faviconProvider struct{}\n\nvar FaviconProvider = faviconProvider{}\n\nfunc (p *faviconProvider) GetFavicon() []byte {\n\tif runtime.GOOS == \"windows\" {\n\t\tret, _ := fs.ReadFile(UIBox, \"favicon.ico\")\n\t\treturn ret\n\t}\n\n\treturn p.GetFaviconPng()\n}\n\nfunc (p *faviconProvider) GetFaviconPng() []byte {\n\tret, _ := fs.ReadFile(UIBox, \"favicon.png\")\n\treturn ret\n}\n"
  },
  {
    "path": "ui/v2.5/.editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_style = space\nindent_size = 2\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.md]\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": "ui/v2.5/.eslintrc.json",
    "content": "{\n  \"env\": {\n    \"browser\": true\n  },\n  \"parser\": \"@typescript-eslint/parser\",\n  \"parserOptions\": {\n    \"project\": \"./tsconfig.json\"\n  },\n  \"plugins\": [\"@typescript-eslint\", \"jsx-a11y\"],\n  \"extends\": [\n    \"airbnb-typescript\",\n    \"plugin:import/recommended\",\n    \"plugin:react/recommended\",\n    \"plugin:react/jsx-runtime\",\n    \"airbnb/hooks\",\n    \"prettier\"\n  ],\n  \"settings\": {\n    \"react\": {\n      \"version\": \"detect\"\n    }\n  },\n  \"ignorePatterns\": [\n    \"node_modules/\",\n    \"src/core/generated-graphql.ts\",\n    \"src/pluginApi.d.ts\"\n  ],\n  \"rules\": {\n    \"@typescript-eslint/lines-between-class-members\": \"off\",\n    \"@typescript-eslint/naming-convention\": [\n      \"error\",\n      {\n        \"selector\": \"interface\",\n        \"format\": [\"PascalCase\"],\n        \"custom\": {\n          \"regex\": \"^I[A-Z]\",\n          \"match\": true\n        }\n      }\n    ],\n    \"@typescript-eslint/no-explicit-any\": 2,\n    \"@typescript-eslint/no-use-before-define\": [\n      \"error\",\n      { \"functions\": false, \"classes\": false }\n    ],\n    \"import/extensions\": [\n      \"error\",\n      \"ignorePackages\",\n      {\n        \"js\": \"never\",\n        \"jsx\": \"never\",\n        \"ts\": \"never\",\n        \"tsx\": \"never\"\n      }\n    ],\n    \"import/named\": \"off\",\n    \"import/namespace\": \"off\",\n    \"import/no-unresolved\": \"off\",\n    \"lines-between-class-members\": \"off\",\n    \"no-nested-ternary\": \"off\",\n    \"prefer-destructuring\": [\n      \"error\",\n      {\n        \"VariableDeclarator\": {\n          \"array\": false,\n          \"object\": true\n        },\n        \"AssignmentExpression\": {\n          \"array\": false,\n          \"object\": false\n        }\n      }\n    ],\n    \"react/display-name\": \"off\",\n    \"react/prop-types\": \"off\",\n    \"react/style-prop-object\": [\"error\", { \"allow\": [\"FormattedNumber\"] }],\n    \"spaced-comment\": [\"error\", \"always\", { \"markers\": [\"/\"] }]\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/.prettierignore",
    "content": "*.md\n\n# dependencies\n/node_modules\npnpm-lock.yaml\npnpm-workspace.yaml\n\n# locales\nsrc/locales/**/*.json\n\n# testing\n/coverage\n\n# production\n/build\n\n# generated\nsrc/core/generated-graphql.ts\n"
  },
  {
    "path": "ui/v2.5/.stylelintrc",
    "content": "{\n  \"plugins\": [\"stylelint-order\"],\n  \"customSyntax\": \"postcss-scss\",\n  \"rules\": {\n    \"at-rule-empty-line-before\": [\n      \"always\",\n      {\n        \"except\": [\"after-same-name\", \"first-nested\"],\n        \"ignore\": [\"after-comment\"]\n      }\n    ],\n    \"at-rule-no-vendor-prefix\": true,\n    \"selector-no-vendor-prefix\": true,\n    \"block-no-empty\": true,\n    \"color-hex-length\": \"short\",\n    \"color-no-invalid-hex\": true,\n    \"comment-empty-line-before\": [\n      \"always\",\n      {\n        \"except\": [\"first-nested\"],\n        \"ignore\": [\"stylelint-commands\"]\n      }\n    ],\n    \"comment-whitespace-inside\": \"always\",\n    \"declaration-block-no-shorthand-property-overrides\": true,\n    \"declaration-block-single-line-max-declarations\": 1,\n    \"declaration-no-important\": true,\n    \"font-family-name-quotes\": \"always-where-recommended\",\n    \"function-calc-no-unspaced-operator\": true,\n    \"function-linear-gradient-no-nonstandard-direction\": true,\n    \"function-url-quotes\": \"always\",\n    \"length-zero-no-unit\": true,\n    \"max-nesting-depth\": 4,\n    \"no-descending-specificity\": null,\n    \"no-invalid-double-slash-comments\": true,\n    \"number-max-precision\": 3,\n    \"order/order\": [\"custom-properties\", \"declarations\"],\n    \"order/properties-alphabetical-order\": true,\n    \"rule-empty-line-before\": [\n      \"always-multi-line\",\n      {\n        \"except\": [\"after-single-line-comment\", \"first-nested\"],\n        \"ignore\": [\"after-comment\"]\n      }\n    ],\n    \"selector-max-id\": 1,\n    \"selector-max-type\": 2,\n    \"selector-class-pattern\": \"^(\\\\.*[A-Z]*[a-z]+)+(-[a-z0-9]+)*$\",\n    \"selector-max-universal\": 0,\n    \"selector-type-case\": \"lower\",\n    \"selector-pseudo-element-colon-notation\": \"double\",\n    \"string-no-newline\": true,\n    \"time-min-milliseconds\": 100\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/README.md",
    "content": "This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).\n\n## Available Scripts\n\nIn the project directory, you can run:\n\n### `npm run start`\n\nRuns the app in the development mode.<br />\nOpen [http://localhost:3000](http://localhost:3000) to view it in the browser.\n\nThe page will reload if you make edits.<br />\nYou will also see any lint errors in the console.\n\n### `npm run test`\n\nLaunches the test runner in the interactive watch mode.<br />\nSee the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.\n\n### `npm run build`\n\nBuilds the app for production to the `build` folder.<br />\nIt correctly bundles React in production mode and optimizes the build for the best performance.\n\nThe build is minified and the filenames include the hashes.<br />\nYour app is ready to be deployed!\n\nSee the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.\n\n### `npm run format`\n\nFormats the whitespace of all typescript and scss code with prettier, to ease editing and ensure a common code style.\n\nShould ideally be run before all frontend PRs.\n\n### `npm run eject`\n\n**Note: this is a one-way operation. Once you `eject`, you can’t go back!**\n\nIf you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.\n\nInstead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.\n\nYou don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.\n\n## Learn More\n\nYou can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).\n\nTo learn React, check out the [React documentation](https://reactjs.org/).\n\n### Code Splitting\n\nThis section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting\n\n### Analyzing the Bundle Size\n\nThis section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size\n\n### Making a Progressive Web App\n\nThis section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app\n\n### Advanced Configuration\n\nThis section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration\n\n### Deployment\n\nThis section has moved here: https://facebook.github.io/create-react-app/docs/deployment\n\n### `npm run build` fails to minify\n\nThis section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify\n"
  },
  {
    "path": "ui/v2.5/codegen.ts",
    "content": "import type { CodegenConfig } from \"@graphql-codegen/cli\";\n\nconst config: CodegenConfig = {\n  schema: [\n    \"../../graphql/schema/**/*.graphql\",\n    \"graphql/client-schema.graphql\",\n  ],\n  config: {\n    // makes conflicting fields override rather than error\n    onFieldTypeConflict: (_existing: unknown, other: unknown) => other,\n  },\n  documents: \"graphql/**/*.graphql\",\n  generates: {\n    \"src/core/generated-graphql.ts\": {\n      plugins: [\n        \"time\",\n        \"typescript\",\n        \"typescript-operations\",\n        \"typescript-react-apollo\",\n      ],\n      config: {\n        strictScalars: true,\n        scalars: {\n          Time: \"string\",\n          Timestamp: \"string\",\n          Map: \"{ [key: string]: unknown }\",\n          BoolMap: \"{ [key: string]: boolean }\",\n          PluginConfigMap: \"{ [id: string]: { [key: string]: unknown } }\",\n          Any: \"unknown\",\n          Int64: \"number\",\n          Upload: \"File\",\n          UIConfig: \"src/core/config#IUIConfig\",\n          SavedObjectFilter: \"src/models/list-filter/types#SavedObjectFilter\",\n          SavedUIOptions: \"src/models/list-filter/types#SavedUIOptions\",\n        },\n        withRefetchFn: true,\n      },\n    },\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "ui/v2.5/graphql/client-schema.graphql",
    "content": "scalar UIConfig\nscalar SavedObjectFilter\nscalar SavedUIOptions\n\nextend type ConfigResult {\n  ui: UIConfig!\n}\n\nextend type SavedFilter {\n  object_filter: SavedObjectFilter\n  ui_options: SavedUIOptions\n}\n\nextend input SaveFilterInput {\n  object_filter: SavedObjectFilter\n  ui_options: SavedUIOptions\n}\n\nextend type Mutation {\n  configureUI(input: Map, partial: Map): UIConfig!\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/config.graphql",
    "content": "fragment ConfigGeneralData on ConfigGeneralResult {\n  stashes {\n    path\n    excludeVideo\n    excludeImage\n  }\n  databasePath\n  backupDirectoryPath\n  deleteTrashPath\n  generatedPath\n  metadataPath\n  scrapersPath\n  pluginsPath\n  cachePath\n  blobsPath\n  blobsStorage\n  ffmpegPath\n  ffprobePath\n  calculateMD5\n  videoFileNamingAlgorithm\n  parallelTasks\n  previewAudio\n  previewSegments\n  previewSegmentDuration\n  previewExcludeStart\n  previewExcludeEnd\n  previewPreset\n  transcodeHardwareAcceleration\n  maxTranscodeSize\n  maxStreamingTranscodeSize\n  writeImageThumbnails\n  createImageClipsFromVideos\n  apiKey\n  username\n  password\n  maxSessionAge\n  logFile\n  logOut\n  logLevel\n  logAccess\n  logFileMaxSize\n  useCustomSpriteInterval\n  spriteInterval\n  minimumSprites\n  maximumSprites\n  spriteScreenshotSize\n  createGalleriesFromFolders\n  galleryCoverRegex\n  videoExtensions\n  imageExtensions\n  galleryExtensions\n  excludes\n  imageExcludes\n  customPerformerImageLocation\n  stashBoxes {\n    name\n    endpoint\n    api_key\n    max_requests_per_minute\n  }\n  pythonPath\n  transcodeInputArgs\n  transcodeOutputArgs\n  liveTranscodeInputArgs\n  liveTranscodeOutputArgs\n  drawFunscriptHeatmapRange\n\n  scraperPackageSources {\n    name\n    url\n    local_path\n  }\n  pluginPackageSources {\n    name\n    url\n    local_path\n  }\n}\n\nfragment ConfigInterfaceData on ConfigInterfaceResult {\n  sfwContentMode\n  menuItems\n  soundOnPreview\n  wallShowTitle\n  wallPlayback\n  showScrubber\n  maximumLoopDuration\n  noBrowser\n  notificationsEnabled\n  autostartVideo\n  autostartVideoOnPlaySelected\n  continuePlaylistDefault\n  showStudioAsText\n  css\n  cssEnabled\n  javascript\n  javascriptEnabled\n  customLocales\n  customLocalesEnabled\n  disableCustomizations\n  language\n  imageLightbox {\n    slideshowDelay\n    displayMode\n    scaleUp\n    resetZoomOnNav\n    scrollMode\n    scrollAttemptsBeforeChange\n    disableAnimation\n  }\n  disableDropdownCreate {\n    performer\n    tag\n    studio\n    movie\n    gallery\n  }\n  handyKey\n  funscriptOffset\n  useStashHostedFunscript\n}\n\nfragment ConfigDLNAData on ConfigDLNAResult {\n  serverName\n  enabled\n  port\n  whitelistedIPs\n  interfaces\n  videoSortOrder\n}\n\nfragment ConfigScrapingData on ConfigScrapingResult {\n  scraperUserAgent\n  scraperCertCheck\n  scraperCDPPath\n  excludeTagPatterns\n}\n\nfragment IdentifyFieldOptionsData on IdentifyFieldOptions {\n  field\n  strategy\n  createMissing\n}\n\nfragment IdentifyMetadataOptionsData on IdentifyMetadataOptions {\n  fieldOptions {\n    ...IdentifyFieldOptionsData\n  }\n  setCoverImage\n  setOrganized\n  performerGenders\n  skipMultipleMatches\n  skipMultipleMatchTag\n  skipSingleNamePerformers\n  skipSingleNamePerformerTag\n}\n\nfragment ScraperSourceData on ScraperSource {\n  stash_box_index\n  stash_box_endpoint\n  scraper_id\n}\n\nfragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {\n  scan {\n    # don't get rescan - it should never be defaulted to true\n    scanGenerateCovers\n    scanGeneratePreviews\n    scanGenerateImagePreviews\n    scanGenerateSprites\n    scanGeneratePhashes\n    scanGenerateThumbnails\n    scanGenerateClipPreviews\n  }\n\n  identify {\n    sources {\n      source {\n        ...ScraperSourceData\n      }\n      options {\n        ...IdentifyMetadataOptionsData\n      }\n    }\n    options {\n      ...IdentifyMetadataOptionsData\n    }\n  }\n\n  autoTag {\n    performers\n    studios\n    tags\n  }\n\n  generate {\n    covers\n    sprites\n    previews\n    imagePreviews\n    previewOptions {\n      previewSegments\n      previewSegmentDuration\n      previewExcludeStart\n      previewExcludeEnd\n      previewPreset\n    }\n    markers\n    markerImagePreviews\n    markerScreenshots\n    transcodes\n    phashes\n    interactiveHeatmapsSpeeds\n    clipPreviews\n    imageThumbnails\n  }\n\n  deleteFile\n  deleteGenerated\n}\n\nfragment ConfigData on ConfigResult {\n  general {\n    ...ConfigGeneralData\n  }\n  interface {\n    ...ConfigInterfaceData\n  }\n  dlna {\n    ...ConfigDLNAData\n  }\n  scraping {\n    ...ConfigScrapingData\n  }\n  defaults {\n    ...ConfigDefaultSettingsData\n  }\n  ui\n  plugins\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/file.graphql",
    "content": "fragment FolderData on Folder {\n  id\n  basename\n  path\n}\n\nfragment VideoFileData on VideoFile {\n  id\n  path\n  size\n  mod_time\n  duration\n  video_codec\n  audio_codec\n  width\n  height\n  frame_rate\n  bit_rate\n  fingerprints {\n    type\n    value\n  }\n}\n\nfragment ImageFileData on ImageFile {\n  id\n  path\n  size\n  mod_time\n  width\n  height\n  fingerprints {\n    type\n    value\n  }\n}\n\nfragment GalleryFileData on GalleryFile {\n  id\n  path\n  size\n  mod_time\n  fingerprints {\n    type\n    value\n  }\n}\n\nfragment VisualFileData on VisualFile {\n  ... on BaseFile {\n    id\n    path\n    size\n    mod_time\n    fingerprints {\n      type\n      value\n    }\n  }\n  ... on ImageFile {\n    id\n    path\n    size\n    mod_time\n    width\n    height\n    fingerprints {\n      type\n      value\n    }\n  }\n  ... on VideoFile {\n    id\n    path\n    size\n    mod_time\n    duration\n    video_codec\n    audio_codec\n    width\n    height\n    frame_rate\n    bit_rate\n    fingerprints {\n      type\n      value\n    }\n  }\n}\n\nfragment SelectFolderData on Folder {\n  id\n  path\n  basename\n}\n\nfragment RecursiveFolderData on Folder {\n  ...SelectFolderData\n\n  parent_folders {\n    ...SelectFolderData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/filter.graphql",
    "content": "fragment SavedFilterData on SavedFilter {\n  id\n  mode\n  name\n  find_filter {\n    q\n    page\n    per_page\n    sort\n    direction\n  }\n  object_filter\n  ui_options\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/gallery-chapter.graphql",
    "content": "fragment GalleryChapterData on GalleryChapter {\n  id\n  title\n  image_index\n\n  gallery {\n    id\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/gallery-slim.graphql",
    "content": "fragment SlimGalleryData on Gallery {\n  id\n  title\n  code\n  date\n  urls\n  details\n  photographer\n  rating100\n  organized\n  files {\n    ...GalleryFileData\n  }\n  folder {\n    ...FolderData\n  }\n  image_count\n  chapters {\n    id\n    title\n    image_index\n  }\n  studio {\n    id\n    name\n    image_path\n  }\n  tags {\n    id\n    name\n  }\n  performers {\n    id\n    name\n    gender\n    favorite\n    image_path\n  }\n  scenes {\n    ...SlimSceneData\n  }\n  paths {\n    cover\n    preview\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/gallery.graphql",
    "content": "fragment GalleryData on Gallery {\n  id\n  created_at\n  updated_at\n  title\n  code\n  date\n  urls\n  details\n  photographer\n  rating100\n  organized\n\n  paths {\n    cover\n    preview\n  }\n\n  files {\n    ...GalleryFileData\n  }\n  folder {\n    ...FolderData\n  }\n  image_count\n  chapters {\n    ...GalleryChapterData\n  }\n  studio {\n    ...SlimStudioData\n  }\n  tags {\n    ...SlimTagData\n  }\n\n  performers {\n    ...PerformerData\n  }\n  scenes {\n    ...SlimSceneData\n  }\n\n  custom_fields\n}\n\nfragment SelectGalleryData on Gallery {\n  id\n  title\n  date\n  code\n  studio {\n    name\n  }\n  cover {\n    paths {\n      thumbnail\n    }\n  }\n  paths {\n    preview\n  }\n  files {\n    path\n  }\n  folder {\n    path\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/group-slim.graphql",
    "content": "fragment SlimGroupData on Group {\n  id\n  name\n  front_image_path\n  rating100\n}\n\nfragment SelectGroupData on Group {\n  id\n  name\n  aliases\n  date\n  studio {\n    name\n  }\n  front_image_path\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/group.graphql",
    "content": "# Full fragment for detail views - includes recursive counts\nfragment GroupData on Group {\n  id\n  name\n  aliases\n  duration\n  date\n  rating100\n  director\n\n  studio {\n    ...SlimStudioData\n  }\n\n  tags {\n    ...SlimTagData\n  }\n\n  containing_groups {\n    group {\n      ...SlimGroupData\n    }\n    description\n  }\n\n  synopsis\n  urls\n  front_image_path\n  back_image_path\n  scene_count\n  scene_count_all: scene_count(depth: -1)\n  performer_count\n  performer_count_all: performer_count(depth: -1)\n  sub_group_count\n  sub_group_count_all: sub_group_count(depth: -1)\n  o_counter\n\n  scenes {\n    id\n    title\n  }\n\n  custom_fields\n}\n\n# Lightweight fragment for list views - excludes expensive recursive counts\n# The _all fields (depth: -1) cause 10+ second queries on large databases\nfragment ListGroupData on Group {\n  id\n  name\n  aliases\n  duration\n  date\n  rating100\n  director\n\n  studio {\n    ...SlimStudioData\n  }\n\n  tags {\n    ...SlimTagData\n  }\n\n  containing_groups {\n    group {\n      ...SlimGroupData\n    }\n    description\n  }\n\n  synopsis\n  urls\n  front_image_path\n  back_image_path\n  scene_count\n  performer_count\n  sub_group_count\n  o_counter\n\n  scenes {\n    id\n    title\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/image-slim.graphql",
    "content": "fragment SlimImageData on Image {\n  id\n  title\n  code\n  date\n  urls\n  details\n  photographer\n  rating100\n  organized\n  o_counter\n\n  paths {\n    thumbnail\n    preview\n    image\n  }\n\n  galleries {\n    id\n    title\n    files {\n      path\n    }\n    folder {\n      path\n    }\n  }\n\n  studio {\n    id\n    name\n    image_path\n  }\n\n  tags {\n    id\n    name\n  }\n\n  performers {\n    id\n    name\n    gender\n    favorite\n    image_path\n  }\n\n  visual_files {\n    ...VisualFileData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/image.graphql",
    "content": "fragment ImageData on Image {\n  id\n  title\n  code\n  rating100\n  date\n  urls\n  details\n  photographer\n  organized\n  o_counter\n  created_at\n  updated_at\n\n  paths {\n    thumbnail\n    preview\n    image\n  }\n\n  galleries {\n    ...GalleryData\n  }\n\n  studio {\n    ...SlimStudioData\n  }\n\n  tags {\n    ...SlimTagData\n  }\n\n  performers {\n    ...PerformerData\n  }\n\n  visual_files {\n    ...VisualFileData\n  }\n\n  custom_fields\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/job.graphql",
    "content": "fragment JobData on Job {\n  id\n  status\n  subTasks\n  description\n  progress\n  startTime\n  endTime\n  addTime\n  error\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/log.graphql",
    "content": "fragment LogEntryData on LogEntry {\n  time\n  level\n  message\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/package.graphql",
    "content": "fragment PackageData on Package {\n  package_id\n  name\n  version\n  date\n  metadata\n  sourceURL\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/performer-slim.graphql",
    "content": "fragment SlimPerformerData on Performer {\n  id\n  name\n  disambiguation\n  gender\n  urls\n  image_path\n  favorite\n  ignore_auto_tag\n  country\n  birthdate\n  ethnicity\n  hair_color\n  eye_color\n  height_cm\n  fake_tits\n  penis_length\n  circumcised\n  career_start\n  career_end\n  tattoos\n  piercings\n  alias_list\n  tags {\n    id\n    name\n  }\n  stash_ids {\n    endpoint\n    stash_id\n    updated_at\n  }\n  rating100\n  death_date\n  weight\n}\n\nfragment SelectPerformerData on Performer {\n  id\n  name\n  disambiguation\n  alias_list\n  image_path\n  birthdate\n  death_date\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/performer.graphql",
    "content": "fragment PerformerData on Performer {\n  id\n  name\n  disambiguation\n  urls\n  gender\n  birthdate\n  ethnicity\n  country\n  eye_color\n  height_cm\n  measurements\n  fake_tits\n  penis_length\n  circumcised\n  career_start\n  career_end\n  tattoos\n  piercings\n  alias_list\n  favorite\n  ignore_auto_tag\n  image_path\n  scene_count\n  image_count\n  gallery_count\n  group_count\n  performer_count\n  o_counter\n\n  tags {\n    ...SlimTagData\n  }\n\n  stash_ids {\n    stash_id\n    endpoint\n    updated_at\n  }\n  rating100\n  details\n  death_date\n  hair_color\n  weight\n\n  custom_fields\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/scene-marker.graphql",
    "content": "fragment SceneMarkerData on SceneMarker {\n  id\n  title\n  seconds\n  end_seconds\n  stream\n  preview\n  screenshot\n\n  scene {\n    ...SceneMarkerSceneData\n  }\n\n  primary_tag {\n    id\n    name\n  }\n\n  tags {\n    id\n    name\n  }\n}\n\nfragment SceneMarkerSceneData on Scene {\n  id\n  title\n  files {\n    width\n    height\n    path\n  }\n  performers {\n    id\n    name\n    image_path\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/scene-slim.graphql",
    "content": "fragment SlimSceneData on Scene {\n  id\n  title\n  code\n  details\n  director\n  urls\n  date\n  rating100\n  o_counter\n  organized\n  interactive\n  interactive_speed\n  resume_time\n  play_duration\n  play_count\n\n  files {\n    ...VideoFileData\n  }\n\n  paths {\n    screenshot\n    preview\n    stream\n    webp\n    vtt\n    sprite\n    funscript\n    interactive_heatmap\n    caption\n  }\n\n  scene_markers {\n    id\n    title\n    seconds\n    primary_tag {\n      id\n      name\n    }\n  }\n\n  galleries {\n    id\n    files {\n      path\n    }\n    folder {\n      path\n    }\n    title\n  }\n\n  studio {\n    id\n    name\n    image_path\n  }\n\n  groups {\n    group {\n      id\n      name\n      front_image_path\n    }\n    scene_index\n  }\n\n  tags {\n    id\n    name\n  }\n\n  performers {\n    id\n    name\n    disambiguation\n    gender\n    favorite\n    image_path\n  }\n\n  stash_ids {\n    endpoint\n    stash_id\n    updated_at\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/scene.graphql",
    "content": "fragment SceneData on Scene {\n  id\n  title\n  code\n  details\n  director\n  urls\n  date\n  rating100\n  o_counter\n  organized\n  interactive\n  interactive_speed\n  captions {\n    language_code\n    caption_type\n  }\n  created_at\n  updated_at\n  resume_time\n  last_played_at\n  play_duration\n  play_count\n\n  play_history\n  o_history\n\n  files {\n    ...VideoFileData\n  }\n\n  paths {\n    screenshot\n    preview\n    stream\n    webp\n    vtt\n    sprite\n    funscript\n    interactive_heatmap\n    caption\n  }\n\n  scene_markers {\n    ...SceneMarkerData\n  }\n\n  galleries {\n    ...SlimGalleryData\n  }\n\n  studio {\n    ...SlimStudioData\n  }\n\n  groups {\n    group {\n      ...GroupData\n    }\n    scene_index\n  }\n\n  tags {\n    ...SlimTagData\n  }\n\n  performers {\n    ...PerformerData\n  }\n\n  stash_ids {\n    endpoint\n    stash_id\n    updated_at\n  }\n\n  sceneStreams {\n    url\n    mime_type\n    label\n  }\n\n  custom_fields\n}\n\nfragment SelectSceneData on Scene {\n  id\n  title\n  date\n  code\n  studio {\n    name\n  }\n  files {\n    path\n  }\n  paths {\n    screenshot\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/scrapers.graphql",
    "content": "fragment ScrapedStudioData on ScrapedStudio {\n  stored_id\n  name\n  urls\n  parent {\n    stored_id\n    name\n    urls\n    image\n    details\n    aliases\n    tags {\n      ...ScrapedSceneTagData\n    }\n    remote_site_id\n  }\n  image\n  details\n  aliases\n  tags {\n    ...ScrapedSceneTagData\n  }\n  remote_site_id\n}\n\nfragment ScrapedPerformerData on ScrapedPerformer {\n  stored_id\n  name\n  disambiguation\n  gender\n  urls\n  birthdate\n  ethnicity\n  country\n  eye_color\n  height\n  measurements\n  fake_tits\n  penis_length\n  circumcised\n  career_start\n  career_end\n  tattoos\n  piercings\n  aliases\n  tags {\n    ...ScrapedSceneTagData\n  }\n  images\n  details\n  death_date\n  hair_color\n  weight\n  remote_site_id\n}\n\nfragment ScrapedScenePerformerData on ScrapedPerformer {\n  stored_id\n  name\n  disambiguation\n  gender\n  urls\n  birthdate\n  ethnicity\n  country\n  eye_color\n  height\n  measurements\n  fake_tits\n  penis_length\n  circumcised\n  career_start\n  career_end\n  tattoos\n  piercings\n  aliases\n  tags {\n    ...ScrapedSceneTagData\n  }\n  remote_site_id\n  images\n  details\n  death_date\n  hair_color\n  weight\n}\n\nfragment ScrapedGroupStudioData on ScrapedStudio {\n  stored_id\n  name\n  urls\n}\n\nfragment ScrapedGroupData on ScrapedGroup {\n  name\n  aliases\n  duration\n  date\n  rating\n  director\n  urls\n  synopsis\n  front_image\n  back_image\n\n  studio {\n    ...ScrapedGroupStudioData\n  }\n  tags {\n    ...ScrapedSceneTagData\n  }\n}\n\nfragment ScrapedSceneGroupData on ScrapedGroup {\n  stored_id\n  name\n  aliases\n  duration\n  date\n  rating\n  director\n  urls\n  synopsis\n  front_image\n  back_image\n\n  studio {\n    ...ScrapedGroupStudioData\n  }\n  tags {\n    ...ScrapedSceneTagData\n  }\n}\n\nfragment ScrapedSceneStudioData on ScrapedStudio {\n  stored_id\n  name\n  urls\n  parent {\n    stored_id\n    name\n    urls\n    image\n    details\n    aliases\n    tags {\n      ...ScrapedSceneTagData\n    }\n    remote_site_id\n  }\n  image\n  details\n  aliases\n  tags {\n    ...ScrapedSceneTagData\n  }\n  remote_site_id\n}\n\nfragment ScrapedSceneTagData on ScrapedTag {\n  stored_id\n  name\n  description\n  alias_list\n  parent {\n    stored_id\n    name\n    description\n  }\n  remote_site_id\n}\n\nfragment ScrapedSceneData on ScrapedScene {\n  title\n  code\n  details\n  director\n  urls\n  date\n  image\n  remote_site_id\n\n  file {\n    size\n    duration\n    video_codec\n    audio_codec\n    width\n    height\n    framerate\n    bitrate\n  }\n\n  studio {\n    ...ScrapedSceneStudioData\n  }\n\n  tags {\n    ...ScrapedSceneTagData\n  }\n\n  performers {\n    ...ScrapedScenePerformerData\n  }\n\n  groups {\n    ...ScrapedSceneGroupData\n  }\n\n  fingerprints {\n    hash\n    algorithm\n    duration\n  }\n}\n\nfragment ScrapedGalleryData on ScrapedGallery {\n  title\n  code\n  details\n  urls\n  photographer\n  date\n\n  studio {\n    ...ScrapedSceneStudioData\n  }\n\n  tags {\n    ...ScrapedSceneTagData\n  }\n\n  performers {\n    ...ScrapedScenePerformerData\n  }\n}\n\nfragment ScrapedImageData on ScrapedImage {\n  title\n  code\n  details\n  photographer\n  urls\n  date\n\n  studio {\n    ...ScrapedSceneStudioData\n  }\n\n  tags {\n    ...ScrapedSceneTagData\n  }\n\n  performers {\n    ...ScrapedScenePerformerData\n  }\n}\n\nfragment ScrapedStashBoxSceneData on ScrapedScene {\n  title\n  code\n  details\n  director\n  url\n  date\n  image\n  remote_site_id\n  duration\n\n  file {\n    size\n    duration\n    video_codec\n    audio_codec\n    width\n    height\n    framerate\n    bitrate\n  }\n\n  fingerprints {\n    hash\n    algorithm\n    duration\n  }\n\n  studio {\n    ...ScrapedSceneStudioData\n  }\n\n  tags {\n    ...ScrapedSceneTagData\n  }\n\n  performers {\n    ...ScrapedScenePerformerData\n  }\n\n  groups {\n    ...ScrapedSceneGroupData\n  }\n}\n\nfragment ScrapedStashBoxPerformerData on StashBoxPerformerQueryResult {\n  query\n  results {\n    ...ScrapedScenePerformerData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/studio-slim.graphql",
    "content": "fragment SlimStudioData on Studio {\n  id\n  name\n  image_path\n  stash_ids {\n    endpoint\n    stash_id\n    updated_at\n  }\n  parent_studio {\n    id\n  }\n  details\n  rating100\n  aliases\n  tags {\n    id\n    name\n  }\n  favorite\n  ignore_auto_tag\n  organized\n  o_counter\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/studio.graphql",
    "content": "fragment StudioData on Studio {\n  id\n  name\n  url\n  urls\n  parent_studio {\n    id\n    name\n    url\n    urls\n    image_path\n  }\n  child_studios {\n    id\n    name\n    image_path\n  }\n  ignore_auto_tag\n  organized\n  image_path\n  scene_count\n  scene_count_all: scene_count(depth: -1)\n  image_count\n  image_count_all: image_count(depth: -1)\n  gallery_count\n  gallery_count_all: gallery_count(depth: -1)\n  performer_count\n  performer_count_all: performer_count(depth: -1)\n  group_count\n  group_count_all: group_count(depth: -1)\n  stash_ids {\n    stash_id\n    endpoint\n    updated_at\n  }\n  details\n  rating100\n  favorite\n  aliases\n  tags {\n    ...SlimTagData\n  }\n  o_counter\n  custom_fields\n}\n\nfragment SelectStudioData on Studio {\n  id\n  name\n  aliases\n  details\n  image_path\n\n  parent_studio {\n    id\n    name\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/tag-slim.graphql",
    "content": "fragment SlimTagData on Tag {\n  id\n  name\n  sort_name\n  aliases\n  image_path\n  parent_count\n  child_count\n\n  stash_ids {\n    endpoint\n    stash_id\n    updated_at\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/data/tag.graphql",
    "content": "fragment TagData on Tag {\n  id\n  name\n  sort_name\n  description\n  aliases\n  ignore_auto_tag\n  favorite\n  stash_ids {\n    endpoint\n    stash_id\n    updated_at\n  }\n  image_path\n  scene_count\n  scene_count_all: scene_count(depth: -1)\n  scene_marker_count\n  scene_marker_count_all: scene_marker_count(depth: -1)\n  image_count\n  image_count_all: image_count(depth: -1)\n  gallery_count\n  gallery_count_all: gallery_count(depth: -1)\n  performer_count\n  performer_count_all: performer_count(depth: -1)\n  studio_count\n  studio_count_all: studio_count(depth: -1)\n  group_count\n  group_count_all: group_count(depth: -1)\n\n  parents {\n    ...SlimTagData\n  }\n\n  children {\n    ...SlimTagData\n  }\n\n  custom_fields\n}\n\nfragment SelectTagData on Tag {\n  id\n  name\n  sort_name\n  favorite\n  description\n  aliases\n  image_path\n\n  parents {\n    id\n    name\n    sort_name\n  }\n\n  stash_ids {\n    endpoint\n    stash_id\n    updated_at\n  }\n}\n\n# Optimized fragment for tag list page - excludes expensive recursive *_count_all fields\nfragment TagListData on Tag {\n  id\n  name\n  sort_name\n  description\n  aliases\n  ignore_auto_tag\n  favorite\n  stash_ids {\n    endpoint\n    stash_id\n    updated_at\n  }\n  image_path\n  # Direct counts only - no recursive depth queries\n  scene_count\n  scene_marker_count\n  image_count\n  gallery_count\n  performer_count\n  studio_count\n  group_count\n\n  parents {\n    ...SlimTagData\n  }\n\n  children {\n    ...SlimTagData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/config.graphql",
    "content": "mutation Setup($input: SetupInput!) {\n  setup(input: $input)\n}\n\nmutation Migrate($input: MigrateInput!) {\n  migrate(input: $input)\n}\n\nmutation DownloadFFMpeg {\n  downloadFFMpeg\n}\n\nmutation ConfigureGeneral($input: ConfigGeneralInput!) {\n  configureGeneral(input: $input) {\n    ...ConfigGeneralData\n  }\n}\n\nmutation ConfigureInterface($input: ConfigInterfaceInput!) {\n  configureInterface(input: $input) {\n    ...ConfigInterfaceData\n  }\n}\n\nmutation ConfigureDLNA($input: ConfigDLNAInput!) {\n  configureDLNA(input: $input) {\n    ...ConfigDLNAData\n  }\n}\n\nmutation ConfigureScraping($input: ConfigScrapingInput!) {\n  configureScraping(input: $input) {\n    ...ConfigScrapingData\n  }\n}\n\nmutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) {\n  configureDefaults(input: $input) {\n    ...ConfigDefaultSettingsData\n  }\n}\n\nmutation ConfigureUI($input: Map, $partial: Map) {\n  configureUI(input: $input, partial: $partial)\n}\n\nmutation ConfigureUISetting($key: String!, $value: Any) {\n  configureUISetting(key: $key, value: $value)\n}\n\nmutation GenerateAPIKey($input: GenerateAPIKeyInput!) {\n  generateAPIKey(input: $input)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/dlna.graphql",
    "content": "mutation EnableDLNA($input: EnableDLNAInput!) {\n  enableDLNA(input: $input)\n}\n\nmutation DisableDLNA($input: DisableDLNAInput!) {\n  disableDLNA(input: $input)\n}\n\nmutation AddTempDLNAIP($input: AddTempDLNAIPInput!) {\n  addTempDLNAIP(input: $input)\n}\n\nmutation RemoveTempDLNAIP($input: RemoveTempDLNAIPInput!) {\n  removeTempDLNAIP(input: $input)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/file.graphql",
    "content": "mutation DeleteFiles($ids: [ID!]!) {\n  deleteFiles(ids: $ids)\n}\n\nmutation RevealFileInFileManager($id: ID!) {\n  revealFileInFileManager(id: $id)\n}\n\nmutation RevealFolderInFileManager($id: ID!) {\n  revealFolderInFileManager(id: $id)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/filter.graphql",
    "content": "mutation SaveFilter($input: SaveFilterInput!) {\n  saveFilter(input: $input) {\n    ...SavedFilterData\n  }\n}\n\nmutation DestroySavedFilter($input: DestroyFilterInput!) {\n  destroySavedFilter(input: $input)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/gallery-chapter.graphql",
    "content": "mutation GalleryChapterCreate(\n  $title: String!\n  $image_index: Int!\n  $gallery_id: ID!\n) {\n  galleryChapterCreate(\n    input: { title: $title, image_index: $image_index, gallery_id: $gallery_id }\n  ) {\n    ...GalleryChapterData\n  }\n}\n\nmutation GalleryChapterUpdate(\n  $id: ID!\n  $title: String!\n  $image_index: Int!\n  $gallery_id: ID!\n) {\n  galleryChapterUpdate(\n    input: {\n      id: $id\n      title: $title\n      image_index: $image_index\n      gallery_id: $gallery_id\n    }\n  ) {\n    ...GalleryChapterData\n  }\n}\n\nmutation GalleryChapterDestroy($id: ID!) {\n  galleryChapterDestroy(id: $id)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/gallery.graphql",
    "content": "mutation GalleryCreate($input: GalleryCreateInput!) {\n  galleryCreate(input: $input) {\n    ...GalleryData\n  }\n}\n\nmutation GalleryUpdate($input: GalleryUpdateInput!) {\n  galleryUpdate(input: $input) {\n    ...GalleryData\n  }\n}\n\nmutation BulkGalleryUpdate($input: BulkGalleryUpdateInput!) {\n  bulkGalleryUpdate(input: $input) {\n    ...GalleryData\n  }\n}\n\nmutation GalleriesUpdate($input: [GalleryUpdateInput!]!) {\n  galleriesUpdate(input: $input) {\n    ...GalleryData\n  }\n}\n\nmutation GalleryDestroy(\n  $ids: [ID!]!\n  $delete_file: Boolean\n  $delete_generated: Boolean\n) {\n  galleryDestroy(\n    input: {\n      ids: $ids\n      delete_file: $delete_file\n      delete_generated: $delete_generated\n    }\n  )\n}\n\nmutation AddGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) {\n  addGalleryImages(input: { gallery_id: $gallery_id, image_ids: $image_ids })\n}\n\nmutation RemoveGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) {\n  removeGalleryImages(input: { gallery_id: $gallery_id, image_ids: $image_ids })\n}\n\nmutation SetGalleryCover($gallery_id: ID!, $cover_image_id: ID!) {\n  setGalleryCover(\n    input: { gallery_id: $gallery_id, cover_image_id: $cover_image_id }\n  )\n}\n\nmutation ResetGalleryCover($gallery_id: ID!) {\n  resetGalleryCover(input: { gallery_id: $gallery_id })\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/group.graphql",
    "content": "mutation GroupCreate($input: GroupCreateInput!) {\n  groupCreate(input: $input) {\n    ...GroupData\n  }\n}\n\nmutation GroupUpdate($input: GroupUpdateInput!) {\n  groupUpdate(input: $input) {\n    ...GroupData\n  }\n}\n\nmutation BulkGroupUpdate($input: BulkGroupUpdateInput!) {\n  bulkGroupUpdate(input: $input) {\n    ...GroupData\n  }\n}\n\nmutation GroupDestroy($id: ID!) {\n  groupDestroy(input: { id: $id })\n}\n\nmutation GroupsDestroy($ids: [ID!]!) {\n  groupsDestroy(ids: $ids)\n}\n\nmutation AddGroupSubGroups($input: GroupSubGroupAddInput!) {\n  addGroupSubGroups(input: $input)\n}\n\nmutation RemoveGroupSubGroups($input: GroupSubGroupRemoveInput!) {\n  removeGroupSubGroups(input: $input)\n}\n\nmutation ReorderSubGroups($input: ReorderSubGroupsInput!) {\n  reorderSubGroups(input: $input)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/image.graphql",
    "content": "mutation ImageUpdate($input: ImageUpdateInput!) {\n  imageUpdate(input: $input) {\n    ...SlimImageData\n  }\n}\n\nmutation BulkImageUpdate($input: BulkImageUpdateInput!) {\n  bulkImageUpdate(input: $input) {\n    ...SlimImageData\n  }\n}\n\nmutation ImagesUpdate($input: [ImageUpdateInput!]!) {\n  imagesUpdate(input: $input) {\n    ...SlimImageData\n  }\n}\n\nmutation ImageIncrementO($id: ID!) {\n  imageIncrementO(id: $id)\n}\n\nmutation ImageDecrementO($id: ID!) {\n  imageDecrementO(id: $id)\n}\n\nmutation ImageResetO($id: ID!) {\n  imageResetO(id: $id)\n}\n\nmutation ImageDestroy(\n  $id: ID!\n  $delete_file: Boolean\n  $delete_generated: Boolean\n) {\n  imageDestroy(\n    input: {\n      id: $id\n      delete_file: $delete_file\n      delete_generated: $delete_generated\n    }\n  )\n}\n\nmutation ImagesDestroy(\n  $ids: [ID!]!\n  $delete_file: Boolean\n  $delete_generated: Boolean\n) {\n  imagesDestroy(\n    input: {\n      ids: $ids\n      delete_file: $delete_file\n      delete_generated: $delete_generated\n    }\n  )\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/job.graphql",
    "content": "mutation StopJob($job_id: ID!) {\n  stopJob(job_id: $job_id)\n}\n\nmutation StopAllJobs {\n  stopAllJobs\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/metadata.graphql",
    "content": "mutation MetadataImport {\n  metadataImport\n}\n\nmutation MetadataExport {\n  metadataExport\n}\n\nmutation ExportObjects($input: ExportObjectsInput!) {\n  exportObjects(input: $input)\n}\n\nmutation ImportObjects($input: ImportObjectsInput!) {\n  importObjects(input: $input)\n}\n\nmutation MetadataScan($input: ScanMetadataInput!) {\n  metadataScan(input: $input)\n}\n\nmutation MetadataGenerate($input: GenerateMetadataInput!) {\n  metadataGenerate(input: $input)\n}\n\nmutation MetadataAutoTag($input: AutoTagMetadataInput!) {\n  metadataAutoTag(input: $input)\n}\n\nmutation MetadataIdentify($input: IdentifyMetadataInput!) {\n  metadataIdentify(input: $input)\n}\n\nmutation MetadataClean($input: CleanMetadataInput!) {\n  metadataClean(input: $input)\n}\n\nmutation MetadataCleanGenerated($input: CleanGeneratedInput!) {\n  metadataCleanGenerated(input: $input)\n}\n\nmutation MigrateHashNaming {\n  migrateHashNaming\n}\n\nmutation BackupDatabase($input: BackupDatabaseInput!) {\n  backupDatabase(input: $input)\n}\n\nmutation AnonymiseDatabase($input: AnonymiseDatabaseInput!) {\n  anonymiseDatabase(input: $input)\n}\n\nmutation OptimiseDatabase {\n  optimiseDatabase\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/migration.graphql",
    "content": "mutation MigrateSceneScreenshots($input: MigrateSceneScreenshotsInput!) {\n  migrateSceneScreenshots(input: $input)\n}\n\nmutation MigrateBlobs($input: MigrateBlobsInput!) {\n  migrateBlobs(input: $input)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/performer.graphql",
    "content": "mutation PerformerCreate($input: PerformerCreateInput!) {\n  performerCreate(input: $input) {\n    ...PerformerData\n  }\n}\n\nmutation PerformerUpdate($input: PerformerUpdateInput!) {\n  performerUpdate(input: $input) {\n    ...PerformerData\n  }\n}\n\nmutation BulkPerformerUpdate($input: BulkPerformerUpdateInput!) {\n  bulkPerformerUpdate(input: $input) {\n    ...PerformerData\n  }\n}\n\nmutation PerformerDestroy($id: ID!) {\n  performerDestroy(input: { id: $id })\n}\n\nmutation PerformersDestroy($ids: [ID!]!) {\n  performersDestroy(ids: $ids)\n}\n\nmutation PerformerMerge($input: PerformerMergeInput!) {\n  performerMerge(input: $input) {\n    id\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/plugins.graphql",
    "content": "mutation ReloadPlugins {\n  reloadPlugins\n}\n\nmutation RunPluginTask($plugin_id: ID!, $task_name: String!, $args_map: Map) {\n  runPluginTask(\n    plugin_id: $plugin_id\n    task_name: $task_name\n    args_map: $args_map\n  )\n}\n\nmutation ConfigurePlugin($plugin_id: ID!, $input: Map!) {\n  configurePlugin(plugin_id: $plugin_id, input: $input)\n}\n\nmutation SetPluginsEnabled($enabledMap: BoolMap!) {\n  setPluginsEnabled(enabledMap: $enabledMap)\n}\n\nmutation InstallPluginPackages($packages: [PackageSpecInput!]!) {\n  installPackages(type: Plugin, packages: $packages)\n}\n\nmutation UpdatePluginPackages($packages: [PackageSpecInput!]!) {\n  updatePackages(type: Plugin, packages: $packages)\n}\n\nmutation UninstallPluginPackages($packages: [PackageSpecInput!]!) {\n  uninstallPackages(type: Plugin, packages: $packages)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/scene-marker.graphql",
    "content": "mutation SceneMarkerCreate(\n  $title: String!\n  $seconds: Float!\n  $end_seconds: Float\n  $scene_id: ID!\n  $primary_tag_id: ID!\n  $tag_ids: [ID!] = []\n) {\n  sceneMarkerCreate(\n    input: {\n      title: $title\n      seconds: $seconds\n      end_seconds: $end_seconds\n      scene_id: $scene_id\n      primary_tag_id: $primary_tag_id\n      tag_ids: $tag_ids\n    }\n  ) {\n    ...SceneMarkerData\n  }\n}\n\nmutation SceneMarkerUpdate(\n  $id: ID!\n  $title: String!\n  $seconds: Float!\n  $end_seconds: Float\n  $scene_id: ID!\n  $primary_tag_id: ID!\n  $tag_ids: [ID!] = []\n) {\n  sceneMarkerUpdate(\n    input: {\n      id: $id\n      title: $title\n      seconds: $seconds\n      end_seconds: $end_seconds\n      scene_id: $scene_id\n      primary_tag_id: $primary_tag_id\n      tag_ids: $tag_ids\n    }\n  ) {\n    ...SceneMarkerData\n  }\n}\n\nmutation BulkSceneMarkerUpdate($input: BulkSceneMarkerUpdateInput!) {\n  bulkSceneMarkerUpdate(input: $input) {\n    ...SceneMarkerData\n  }\n}\n\nmutation SceneMarkerDestroy($id: ID!) {\n  sceneMarkerDestroy(id: $id)\n}\n\nmutation SceneMarkersDestroy($ids: [ID!]!) {\n  sceneMarkersDestroy(ids: $ids)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/scene.graphql",
    "content": "mutation SceneCreate($input: SceneCreateInput!) {\n  sceneCreate(input: $input) {\n    ...SceneData\n  }\n}\n\nmutation SceneUpdate($input: SceneUpdateInput!) {\n  sceneUpdate(input: $input) {\n    ...SceneData\n  }\n}\n\nmutation BulkSceneUpdate($input: BulkSceneUpdateInput!) {\n  bulkSceneUpdate(input: $input) {\n    ...SceneData\n  }\n}\n\nmutation ScenesUpdate($input: [SceneUpdateInput!]!) {\n  scenesUpdate(input: $input) {\n    ...SceneData\n  }\n}\n\nmutation SceneSaveActivity(\n  $id: ID!\n  $resume_time: Float\n  $playDuration: Float\n) {\n  sceneSaveActivity(\n    id: $id\n    resume_time: $resume_time\n    playDuration: $playDuration\n  )\n}\n\nmutation SceneResetActivity(\n  $id: ID!\n  $reset_resume: Boolean!\n  $reset_duration: Boolean!\n) {\n  sceneResetActivity(\n    id: $id\n    reset_resume: $reset_resume\n    reset_duration: $reset_duration\n  )\n}\n\nmutation SceneAddPlay($id: ID!, $times: [Timestamp!]) {\n  sceneAddPlay(id: $id, times: $times) {\n    count\n    history\n  }\n}\n\nmutation SceneDeletePlay($id: ID!, $times: [Timestamp!]) {\n  sceneDeletePlay(id: $id, times: $times) {\n    count\n    history\n  }\n}\n\nmutation SceneResetPlayCount($id: ID!) {\n  sceneResetPlayCount(id: $id)\n}\n\nmutation SceneAddO($id: ID!, $times: [Timestamp!]) {\n  sceneAddO(id: $id, times: $times) {\n    count\n    history\n  }\n}\n\nmutation SceneDeleteO($id: ID!, $times: [Timestamp!]) {\n  sceneDeleteO(id: $id, times: $times) {\n    count\n    history\n  }\n}\n\nmutation SceneResetO($id: ID!) {\n  sceneResetO(id: $id)\n}\n\nmutation SceneDestroy(\n  $id: ID!\n  $delete_file: Boolean\n  $delete_generated: Boolean\n) {\n  sceneDestroy(\n    input: {\n      id: $id\n      delete_file: $delete_file\n      delete_generated: $delete_generated\n    }\n  )\n}\n\nmutation ScenesDestroy(\n  $ids: [ID!]!\n  $delete_file: Boolean\n  $delete_generated: Boolean\n) {\n  scenesDestroy(\n    input: {\n      ids: $ids\n      delete_file: $delete_file\n      delete_generated: $delete_generated\n    }\n  )\n}\n\nmutation SceneGenerateScreenshot($id: ID!, $at: Float) {\n  sceneGenerateScreenshot(id: $id, at: $at)\n}\n\nmutation SceneAssignFile($input: AssignSceneFileInput!) {\n  sceneAssignFile(input: $input)\n}\n\nmutation SceneMerge($input: SceneMergeInput!) {\n  sceneMerge(input: $input) {\n    id\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/scrapers.graphql",
    "content": "mutation ReloadScrapers {\n  reloadScrapers\n}\n\nmutation InstallScraperPackages($packages: [PackageSpecInput!]!) {\n  installPackages(type: Scraper, packages: $packages)\n}\n\nmutation UpdateScraperPackages($packages: [PackageSpecInput!]!) {\n  updatePackages(type: Scraper, packages: $packages)\n}\n\nmutation UninstallScraperPackages($packages: [PackageSpecInput!]!) {\n  uninstallPackages(type: Scraper, packages: $packages)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/stash-box.graphql",
    "content": "mutation SubmitStashBoxFingerprints(\n  $input: StashBoxFingerprintSubmissionInput!\n) {\n  submitStashBoxFingerprints(input: $input)\n}\n\nmutation StashBoxBatchPerformerTag($input: StashBoxBatchTagInput!) {\n  stashBoxBatchPerformerTag(input: $input)\n}\n\nmutation StashBoxBatchStudioTag($input: StashBoxBatchTagInput!) {\n  stashBoxBatchStudioTag(input: $input)\n}\n\nmutation StashBoxBatchTagTag($input: StashBoxBatchTagInput!) {\n  stashBoxBatchTagTag(input: $input)\n}\n\nmutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) {\n  submitStashBoxSceneDraft(input: $input)\n}\n\nmutation SubmitStashBoxPerformerDraft($input: StashBoxDraftSubmissionInput!) {\n  submitStashBoxPerformerDraft(input: $input)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/studio.graphql",
    "content": "mutation StudioCreate($input: StudioCreateInput!) {\n  studioCreate(input: $input) {\n    ...StudioData\n  }\n}\n\nmutation StudioUpdate($input: StudioUpdateInput!) {\n  studioUpdate(input: $input) {\n    ...StudioData\n  }\n}\n\nmutation BulkStudioUpdate($input: BulkStudioUpdateInput!) {\n  bulkStudioUpdate(input: $input) {\n    ...StudioData\n  }\n}\n\nmutation StudioDestroy($id: ID!) {\n  studioDestroy(input: { id: $id })\n}\n\nmutation StudiosDestroy($ids: [ID!]!) {\n  studiosDestroy(ids: $ids)\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/mutations/tag.graphql",
    "content": "mutation TagCreate($input: TagCreateInput!) {\n  tagCreate(input: $input) {\n    ...TagData\n  }\n}\n\nmutation TagDestroy($id: ID!) {\n  tagDestroy(input: { id: $id })\n}\n\nmutation TagsDestroy($ids: [ID!]!) {\n  tagsDestroy(ids: $ids)\n}\n\nmutation TagUpdate($input: TagUpdateInput!) {\n  tagUpdate(input: $input) {\n    ...TagData\n  }\n}\n\nmutation BulkTagUpdate($input: BulkTagUpdateInput!) {\n  bulkTagUpdate(input: $input) {\n    ...TagData\n  }\n}\n\nmutation TagsMerge(\n  $source: [ID!]!\n  $destination: ID!\n  $values: TagUpdateInput\n) {\n  tagsMerge(\n    input: { source: $source, destination: $destination, values: $values }\n  ) {\n    ...TagData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/dlna.graphql",
    "content": "query DLNAStatus {\n  dlnaStatus {\n    running\n    until\n    recentIPAddresses\n    allowedIPAddresses {\n      ipAddress\n      until\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/filter.graphql",
    "content": "query FindSavedFilter($id: ID!) {\n  findSavedFilter(id: $id) {\n    ...SavedFilterData\n  }\n}\n\nquery FindSavedFilters($mode: FilterMode) {\n  findSavedFilters(mode: $mode) {\n    ...SavedFilterData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/folder.graphql",
    "content": "query FindRootFoldersForSelect {\n  findFolders(\n    filter: { per_page: -1, sort: \"path\", direction: ASC }\n    folder_filter: { parent_folder: { modifier: IS_NULL } }\n  ) {\n    count\n    folders {\n      ...SelectFolderData\n    }\n  }\n}\n\nquery FindFoldersForQuery(\n  $filter: FindFilterType\n  $folder_filter: FolderFilterType\n  $ids: [ID!]\n) {\n  findFolders(filter: $filter, folder_filter: $folder_filter, ids: $ids) {\n    count\n    folders {\n      ...RecursiveFolderData\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/gallery.graphql",
    "content": "query FindGalleries(\n  $filter: FindFilterType\n  $gallery_filter: GalleryFilterType\n) {\n  findGalleries(gallery_filter: $gallery_filter, filter: $filter) {\n    count\n    galleries {\n      ...SlimGalleryData\n    }\n  }\n}\n\nquery FindGallery($id: ID!) {\n  findGallery(id: $id) {\n    ...GalleryData\n  }\n}\n\nquery FindGalleriesForSelect(\n  $filter: FindFilterType\n  $gallery_filter: GalleryFilterType\n  $ids: [ID!]\n) {\n  findGalleries(filter: $filter, gallery_filter: $gallery_filter, ids: $ids) {\n    count\n    galleries {\n      ...SelectGalleryData\n    }\n  }\n}\n\nquery FindGalleryImageID($id: ID!, $index: Int!) {\n  findGallery(id: $id) {\n    image(index: $index) {\n      id\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/image.graphql",
    "content": "query FindImages(\n  $filter: FindFilterType\n  $image_filter: ImageFilterType\n  $image_ids: [Int!]\n) {\n  findImages(\n    filter: $filter\n    image_filter: $image_filter\n    image_ids: $image_ids\n  ) {\n    count\n    images {\n      ...SlimImageData\n    }\n  }\n}\n\nquery FindImagesMetadata(\n  $filter: FindFilterType\n  $image_filter: ImageFilterType\n  $image_ids: [Int!]\n) {\n  findImages(\n    filter: $filter\n    image_filter: $image_filter\n    image_ids: $image_ids\n  ) {\n    megapixels\n    filesize\n  }\n}\n\nquery FindImage($id: ID!, $checksum: String) {\n  findImage(id: $id, checksum: $checksum) {\n    ...ImageData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/job.graphql",
    "content": "query JobQueue {\n  jobQueue {\n    ...JobData\n  }\n}\n\nquery FindJob($input: FindJobInput!) {\n  findJob(input: $input) {\n    ...JobData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/legacy.graphql",
    "content": "query SceneWall($q: String) {\n  sceneWall(q: $q) {\n    ...SceneData\n  }\n}\n\nquery MarkerWall($q: String) {\n  markerWall(q: $q) {\n    ...SceneMarkerData\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/misc.graphql",
    "content": "query MarkerStrings($q: String, $sort: String) {\n  markerStrings(q: $q, sort: $sort) {\n    id\n    count\n    title\n  }\n}\n\nquery Stats {\n  stats {\n    scene_count\n    scenes_size\n    scenes_duration\n    image_count\n    images_size\n    gallery_count\n    performer_count\n    studio_count\n    group_count\n    tag_count\n    total_o_count\n    total_play_duration\n    total_play_count\n    scenes_played\n  }\n}\n\nquery Logs {\n  logs {\n    ...LogEntryData\n  }\n}\nquery Version {\n  version {\n    version\n    hash\n    build_time\n  }\n}\n\nquery LatestVersion {\n  latestversion {\n    version\n    shorthash\n    release_date\n    url\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/movie.graphql",
    "content": "query FindGroups($filter: FindFilterType, $group_filter: GroupFilterType) {\n  findGroups(filter: $filter, group_filter: $group_filter) {\n    count\n    groups {\n      ...ListGroupData\n    }\n  }\n}\n\nquery FindGroup($id: ID!) {\n  findGroup(id: $id) {\n    ...GroupData\n  }\n}\n\nquery FindGroupsForSelect(\n  $filter: FindFilterType\n  $group_filter: GroupFilterType\n  $ids: [ID!]\n) {\n  findGroups(filter: $filter, group_filter: $group_filter, ids: $ids) {\n    count\n    groups {\n      ...SelectGroupData\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/performer.graphql",
    "content": "query FindPerformers(\n  $filter: FindFilterType\n  $performer_filter: PerformerFilterType\n  $performer_ids: [Int!]\n) {\n  findPerformers(\n    filter: $filter\n    performer_filter: $performer_filter\n    performer_ids: $performer_ids\n  ) {\n    count\n    performers {\n      ...PerformerData\n    }\n  }\n}\n\nquery FindPerformer($id: ID!) {\n  findPerformer(id: $id) {\n    ...PerformerData\n  }\n}\n\nquery FindPerformersForSelect(\n  $filter: FindFilterType\n  $performer_filter: PerformerFilterType\n  $ids: [ID!]\n) {\n  findPerformers(\n    filter: $filter\n    performer_filter: $performer_filter\n    ids: $ids\n  ) {\n    count\n    performers {\n      ...SelectPerformerData\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/plugins.graphql",
    "content": "query Plugins {\n  plugins {\n    id\n    name\n    enabled\n    description\n    url\n    version\n\n    tasks {\n      name\n      description\n    }\n\n    hooks {\n      name\n      description\n      hooks\n    }\n\n    settings {\n      name\n      display_name\n      description\n      type\n    }\n\n    requires\n\n    paths {\n      css\n      javascript\n    }\n  }\n}\n\nquery PluginTasks {\n  pluginTasks {\n    name\n    description\n    plugin {\n      id\n      name\n      enabled\n    }\n  }\n}\n\nquery InstalledPluginPackages {\n  installedPackages(type: Plugin) {\n    ...PackageData\n  }\n}\n\nquery InstalledPluginPackagesStatus {\n  installedPackages(type: Plugin) {\n    ...PackageData\n    source_package {\n      ...PackageData\n    }\n  }\n}\n\nquery AvailablePluginPackages($source: String!) {\n  availablePackages(source: $source, type: Plugin) {\n    ...PackageData\n    requires {\n      package_id\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/scene-marker.graphql",
    "content": "query FindSceneMarkers(\n  $filter: FindFilterType\n  $scene_marker_filter: SceneMarkerFilterType\n) {\n  findSceneMarkers(filter: $filter, scene_marker_filter: $scene_marker_filter) {\n    count\n    scene_markers {\n      ...SceneMarkerData\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/scene.graphql",
    "content": "query FindScenes(\n  $filter: FindFilterType\n  $scene_filter: SceneFilterType\n  $scene_ids: [Int!]\n) {\n  findScenes(\n    filter: $filter\n    scene_filter: $scene_filter\n    scene_ids: $scene_ids\n  ) {\n    count\n    filesize\n    duration\n    scenes {\n      ...SlimSceneData\n    }\n  }\n}\n\nquery FindScenesByPathRegex($filter: FindFilterType) {\n  findScenesByPathRegex(filter: $filter) {\n    count\n    filesize\n    duration\n    scenes {\n      ...SlimSceneData\n    }\n  }\n}\n\nquery FindDuplicateScenes($distance: Int, $duration_diff: Float) {\n  findDuplicateScenes(distance: $distance, duration_diff: $duration_diff) {\n    ...SlimSceneData\n  }\n}\n\nquery FindScene($id: ID!, $checksum: String) {\n  findScene(id: $id, checksum: $checksum) {\n    ...SceneData\n  }\n}\n\nquery FindFullScenes($ids: [Int!]) {\n  findScenes(scene_ids: $ids) {\n    scenes {\n      ...SceneData\n    }\n  }\n}\n\nquery FindSceneMarkerTags($id: ID!) {\n  sceneMarkerTags(scene_id: $id) {\n    tag {\n      id\n      name\n    }\n    scene_markers {\n      ...SceneMarkerData\n    }\n  }\n}\n\nquery ParseSceneFilenames(\n  $filter: FindFilterType!\n  $config: SceneParserInput!\n) {\n  parseSceneFilenames(filter: $filter, config: $config) {\n    count\n    results {\n      scene {\n        ...SlimSceneData\n      }\n      title\n      code\n      details\n      director\n      url\n      date\n      rating\n      studio_id\n      gallery_ids\n      movies {\n        movie_id\n      }\n      performer_ids\n      tag_ids\n    }\n  }\n}\n\nquery SceneStreams($id: ID!) {\n  findScene(id: $id) {\n    sceneStreams {\n      url\n      mime_type\n      label\n    }\n  }\n}\n\nquery FindScenesForSelect(\n  $filter: FindFilterType\n  $scene_filter: SceneFilterType\n  $ids: [ID!]\n) {\n  findScenes(filter: $filter, scene_filter: $scene_filter, ids: $ids) {\n    count\n    scenes {\n      ...SelectSceneData\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/scrapers/scrapers.graphql",
    "content": "query ListPerformerScrapers {\n  listScrapers(types: [PERFORMER]) {\n    id\n    name\n    performer {\n      urls\n      supported_scrapes\n    }\n  }\n}\n\nquery ListSceneScrapers {\n  listScrapers(types: [SCENE]) {\n    id\n    name\n    scene {\n      urls\n      supported_scrapes\n    }\n  }\n}\n\nquery ListGalleryScrapers {\n  listScrapers(types: [GALLERY]) {\n    id\n    name\n    gallery {\n      urls\n      supported_scrapes\n    }\n  }\n}\n\nquery ListImageScrapers {\n  listScrapers(types: [IMAGE]) {\n    id\n    name\n    image {\n      urls\n      supported_scrapes\n    }\n  }\n}\n\nquery ListGroupScrapers {\n  listScrapers(types: [GROUP]) {\n    id\n    name\n    group {\n      urls\n      supported_scrapes\n    }\n  }\n}\n\nquery ScrapeSingleStudio(\n  $source: ScraperSourceInput!\n  $input: ScrapeSingleStudioInput!\n) {\n  scrapeSingleStudio(source: $source, input: $input) {\n    ...ScrapedStudioData\n  }\n}\n\nquery ScrapeSingleTag(\n  $source: ScraperSourceInput!\n  $input: ScrapeSingleTagInput!\n) {\n  scrapeSingleTag(source: $source, input: $input) {\n    ...ScrapedSceneTagData\n  }\n}\n\nquery ScrapeSinglePerformer(\n  $source: ScraperSourceInput!\n  $input: ScrapeSinglePerformerInput!\n) {\n  scrapeSinglePerformer(source: $source, input: $input) {\n    ...ScrapedPerformerData\n  }\n}\n\nquery ScrapeMultiPerformers(\n  $source: ScraperSourceInput!\n  $input: ScrapeMultiPerformersInput!\n) {\n  scrapeMultiPerformers(source: $source, input: $input) {\n    ...ScrapedPerformerData\n  }\n}\n\nquery ScrapePerformerURL($url: String!) {\n  scrapePerformerURL(url: $url) {\n    ...ScrapedPerformerData\n  }\n}\n\nquery ScrapeSingleScene(\n  $source: ScraperSourceInput!\n  $input: ScrapeSingleSceneInput!\n) {\n  scrapeSingleScene(source: $source, input: $input) {\n    ...ScrapedSceneData\n  }\n}\n\nquery ScrapeMultiScenes(\n  $source: ScraperSourceInput!\n  $input: ScrapeMultiScenesInput!\n) {\n  scrapeMultiScenes(source: $source, input: $input) {\n    ...ScrapedSceneData\n  }\n}\n\nquery ScrapeSceneURL($url: String!) {\n  scrapeSceneURL(url: $url) {\n    ...ScrapedSceneData\n  }\n}\n\nquery ScrapeSingleGallery(\n  $source: ScraperSourceInput!\n  $input: ScrapeSingleGalleryInput!\n) {\n  scrapeSingleGallery(source: $source, input: $input) {\n    ...ScrapedGalleryData\n  }\n}\n\nquery ScrapeSingleImage(\n  $source: ScraperSourceInput!\n  $input: ScrapeSingleImageInput!\n) {\n  scrapeSingleImage(source: $source, input: $input) {\n    ...ScrapedImageData\n  }\n}\n\nquery ScrapeGalleryURL($url: String!) {\n  scrapeGalleryURL(url: $url) {\n    ...ScrapedGalleryData\n  }\n}\n\nquery ScrapeImageURL($url: String!) {\n  scrapeImageURL(url: $url) {\n    ...ScrapedImageData\n  }\n}\n\nquery ScrapeGroupURL($url: String!) {\n  scrapeGroupURL(url: $url) {\n    ...ScrapedGroupData\n  }\n}\n\nquery InstalledScraperPackages {\n  installedPackages(type: Scraper) {\n    ...PackageData\n  }\n}\n\nquery InstalledScraperPackagesStatus {\n  installedPackages(type: Scraper) {\n    ...PackageData\n    source_package {\n      ...PackageData\n    }\n  }\n}\n\nquery AvailableScraperPackages($source: String!) {\n  availablePackages(source: $source, type: Scraper) {\n    ...PackageData\n    requires {\n      package_id\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/settings/config.graphql",
    "content": "query Configuration {\n  configuration {\n    ...ConfigData\n  }\n}\n\nquery Directory($path: String) {\n  directory(path: $path) {\n    path\n    parent\n    directories\n  }\n}\n\nquery ValidateStashBox($input: StashBoxInput!) {\n  validateStashBoxCredentials(input: $input) {\n    valid\n    status\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/settings/metadata.graphql",
    "content": "query SystemStatus {\n  systemStatus {\n    databaseSchema\n    databasePath\n    appSchema\n    status\n    configPath\n    os\n    workingDir\n    homeDir\n    ffmpegPath\n    ffprobePath\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/studio.graphql",
    "content": "query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType) {\n  findStudios(filter: $filter, studio_filter: $studio_filter) {\n    count\n    studios {\n      ...StudioData\n    }\n  }\n}\n\nquery FindStudio($id: ID!) {\n  findStudio(id: $id) {\n    ...StudioData\n  }\n}\n\nquery FindStudiosForSelect(\n  $filter: FindFilterType\n  $studio_filter: StudioFilterType\n  $ids: [ID!]\n) {\n  findStudios(filter: $filter, studio_filter: $studio_filter, ids: $ids) {\n    count\n    studios {\n      ...SelectStudioData\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/queries/tag.graphql",
    "content": "query FindTags(\n  $filter: FindFilterType\n  $tag_filter: TagFilterType\n  $ids: [ID!]\n) {\n  findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) {\n    count\n    tags {\n      ...TagData\n    }\n  }\n}\n\nquery FindTag($id: ID!) {\n  findTag(id: $id) {\n    ...TagData\n  }\n}\n\nquery FindTagsForSelect(\n  $filter: FindFilterType\n  $tag_filter: TagFilterType\n  $ids: [ID!]\n) {\n  findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) {\n    count\n    tags {\n      ...SelectTagData\n    }\n  }\n}\n\n# Optimized query for tag list page - uses TagListData fragment without recursive counts\nquery FindTagsForList($filter: FindFilterType, $tag_filter: TagFilterType) {\n  findTags(filter: $filter, tag_filter: $tag_filter) {\n    count\n    tags {\n      ...TagListData\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/graphql/subscriptions.graphql",
    "content": "subscription JobsSubscribe {\n  jobsSubscribe {\n    type\n    job {\n      id\n      status\n      subTasks\n      description\n      progress\n      error\n      startTime\n    }\n  }\n}\n\nsubscription LoggingSubscribe {\n  loggingSubscribe {\n    ...LogEntryData\n  }\n}\n\nsubscription ScanCompleteSubscribe {\n  scanCompleteSubscribe\n}\n"
  },
  {
    "path": "ui/v2.5/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <base href=\"/\" />\n    <meta charset=\"utf-8\" />\n    <link rel=\"shortcut icon\" href=\"favicon.ico\" />\n    <link rel=\"apple-touch-icon\" href=\"apple-touch-icon.png\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, initial-scale=1, maximum-scale=1\"\n    />\n    <meta name=\"theme-color\" content=\"%COLOR%\" />\n    <link rel=\"manifest\" crossorigin=\"use-credentials\" href=\"manifest.json\" />\n    <title>Stash</title>\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/index.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "ui/v2.5/package.json",
    "content": "{\n  \"name\": \"stash\",\n  \"private\": true,\n  \"homepage\": \"./\",\n  \"type\": \"module\",\n  \"packageManager\": \"pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017\",\n  \"scripts\": {\n    \"start\": \"vite\",\n    \"build\": \"vite build\",\n    \"build-ci\": \"npm run validate && npm run build\",\n    \"validate\": \"npm run lint && npm run check && npm run format-check\",\n    \"lint\": \"npm run lint:js && npm run lint:css\",\n    \"lint:css\": \"stylelint --cache \\\"src/**/*.scss\\\"\",\n    \"lint:js\": \"eslint --cache src/\",\n    \"check\": \"tsc --noEmit\",\n    \"eslint\": \"eslint\",\n    \"prettier\": \"prettier\",\n    \"stylelint\": \"stylelint\",\n    \"format\": \"prettier --write . ../../graphql\",\n    \"format-check\": \"prettier --check . ../../graphql\",\n    \"gqlgen\": \"gql-gen --config codegen.ts\",\n    \"extract\": \"NODE_ENV=development extract-messages -l=en,de -o src/locale -d en --flat false 'src/**/!(*.test).tsx'\"\n  },\n  \"dependencies\": {\n    \"@ant-design/react-slick\": \"^1.0.0\",\n    \"@apollo/client\": \"^3.8.10\",\n    \"@formatjs/intl-getcanonicallocales\": \"^2.0.5\",\n    \"@formatjs/intl-locale\": \"^3.0.11\",\n    \"@formatjs/intl-numberformat\": \"^8.3.3\",\n    \"@formatjs/intl-pluralrules\": \"^5.1.8\",\n    \"@fortawesome/fontawesome-svg-core\": \"^7.1.0\",\n    \"@fortawesome/free-brands-svg-icons\": \"^7.1.0\",\n    \"@fortawesome/free-regular-svg-icons\": \"^7.1.0\",\n    \"@fortawesome/free-solid-svg-icons\": \"^7.1.0\",\n    \"@fortawesome/react-fontawesome\": \"^0.2.6\",\n    \"@react-hook/resize-observer\": \"^1.2.6\",\n    \"@silvermine/videojs-airplay\": \"^1.2.0\",\n    \"@silvermine/videojs-chromecast\": \"^1.4.1\",\n    \"@types/react-router-dom\": \"^5.3.3\",\n    \"apollo-upload-client\": \"^18.0.1\",\n    \"base64-blob\": \"^1.4.1\",\n    \"bootstrap\": \"^4.6.2\",\n    \"classnames\": \"^2.3.2\",\n    \"crypto-js\": \"^4.2.0\",\n    \"event-target-polyfill\": \"^0.0.4\",\n    \"flag-icons\": \"^6.6.6\",\n    \"flexbin\": \"^0.2.0\",\n    \"formik\": \"^2.4.5\",\n    \"graphql\": \"^16.8.1\",\n    \"graphql-tag\": \"^2.12.6\",\n    \"graphql-ws\": \"^5.14.3\",\n    \"i18n-iso-countries\": \"^7.5.0\",\n    \"localforage\": \"^1.10.0\",\n    \"lodash-es\": \"^4.17.23\",\n    \"moment\": \"^2.30.1\",\n    \"mousetrap\": \"^1.6.5\",\n    \"mousetrap-pause\": \"^1.0.0\",\n    \"normalize-url\": \"^4.5.1\",\n    \"react\": \"^17.0.2\",\n    \"react-bootstrap\": \"^1.6.6\",\n    \"react-datepicker\": \"^4.10.0\",\n    \"react-dom\": \"^17.0.2\",\n    \"react-helmet\": \"^6.1.0\",\n    \"react-intl\": \"^6.2.8\",\n    \"react-photo-gallery\": \"^8.0.0\",\n    \"react-remark\": \"^2.1.0\",\n    \"react-router-bootstrap\": \"^0.25.0\",\n    \"react-router-dom\": \"^5.3.4\",\n    \"react-router-hash-link\": \"^2.4.3\",\n    \"react-select\": \"^5.7.0\",\n    \"remark-gfm\": \"^1.0.0\",\n    \"resize-observer-polyfill\": \"^1.5.1\",\n    \"slick-carousel\": \"^1.8.1\",\n    \"string.prototype.replaceall\": \"^1.0.7\",\n    \"thehandy\": \"^1.0.3\",\n    \"ua-parser-js\": \"^1.0.34\",\n    \"universal-cookie\": \"^4.0.4\",\n    \"video.js\": \"^7.21.3\",\n    \"videojs-abloop\": \"^1.2.0\",\n    \"videojs-contrib-dash\": \"^5.1.1\",\n    \"videojs-mobile-ui\": \"^0.8.0\",\n    \"videojs-seek-buttons\": \"^3.0.1\",\n    \"videojs-vr\": \"1.8.0\",\n    \"videojs-vtt.js\": \"^0.15.4\",\n    \"yup\": \"^1.3.2\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.20.12\",\n    \"@graphql-codegen/cli\": \"^5.0.0\",\n    \"@graphql-codegen/time\": \"^5.0.0\",\n    \"@graphql-codegen/typescript\": \"^4.0.1\",\n    \"@graphql-codegen/typescript-operations\": \"^4.0.1\",\n    \"@graphql-codegen/typescript-react-apollo\": \"^4.1.0\",\n    \"@types/apollo-upload-client\": \"^18.0.0\",\n    \"@types/crypto-js\": \"^4.2.2\",\n    \"@types/dom-screen-wake-lock\": \"^1.0.3\",\n    \"@types/lodash-es\": \"^4.17.6\",\n    \"@types/mousetrap\": \"^1.6.11\",\n    \"@types/node\": \"^18.13.0\",\n    \"@types/react\": \"^17.0.53\",\n    \"@types/react-datepicker\": \"^4.10.0\",\n    \"@types/react-dom\": \"^17.0.19\",\n    \"@types/react-helmet\": \"^6.1.6\",\n    \"@types/react-router-bootstrap\": \"^0.24.5\",\n    \"@types/react-router-hash-link\": \"^2.4.5\",\n    \"@types/three\": \"^0.154.0\",\n    \"@types/ua-parser-js\": \"^0.7.36\",\n    \"@types/video.js\": \"^7.3.51\",\n    \"@types/videojs-mobile-ui\": \"^0.8.0\",\n    \"@types/videojs-seek-buttons\": \"^2.1.0\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.52.0\",\n    \"@typescript-eslint/parser\": \"^5.52.0\",\n    \"@vitejs/plugin-legacy\": \"^5.4.3\",\n    \"@vitejs/plugin-react\": \"^5.1.0\",\n    \"eslint\": \"^8.34.0\",\n    \"eslint-config-airbnb\": \"^19.0.4\",\n    \"eslint-config-airbnb-typescript\": \"^17.0.0\",\n    \"eslint-config-prettier\": \"^8.6.0\",\n    \"eslint-plugin-import\": \"^2.27.5\",\n    \"eslint-plugin-jsx-a11y\": \"^6.7.1\",\n    \"eslint-plugin-react\": \"^7.32.2\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"extract-react-intl-messages\": \"^4.1.1\",\n    \"postcss\": \"^8.4.31\",\n    \"postcss-scss\": \"^4.0.6\",\n    \"prettier\": \"^2.8.4\",\n    \"sass\": \"^1.58.1\",\n    \"stylelint\": \"^15.10.1\",\n    \"stylelint-order\": \"^6.0.2\",\n    \"terser\": \"^5.9.0\",\n    \"ts-node\": \"^10.9.1\",\n    \"typescript\": \"~4.8.4\",\n    \"vite\": \"^5.4.21\",\n    \"vite-plugin-compression\": \"^0.5.1\",\n    \"vite-tsconfig-paths\": \"^4.0.5\"\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/pnpm-workspace.yaml",
    "content": "onlyBuiltDependencies:\n  - '@parcel/watcher'\n  - core-js\n  - esbuild\n"
  },
  {
    "path": "ui/v2.5/public/manifest.json",
    "content": "{\n  \"short_name\": \"Stash\",\n  \"name\": \"Stash: Porn Organizer\",\n  \"description\": \"Stash allows you to organize and view your own collection of adult video and image files. Think of it like a private PornHub site for your personal porn collection. \",\n  \"icons\": [\n    {\n      \"src\": \"stash_icon.svg\",\n      \"sizes\": \"any\",\n      \"type\": \"image/svg+xml\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"stash_icon.png\",\n      \"sizes\": \"256x256 64x64 32x32 24x24 16x16\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"favicon.png\",\n      \"sizes\": \"256x256 64x64 32x32 24x24 16x16\",\n      \"type\": \"image/png\",\n      \"purpose\": \"monochrome\"\n    }\n  ],\n  \"start_url\": \"/\",\n  \"scope\": \".\",\n  \"display\": \"standalone\",\n  \"theme_color\": \"#394b59\",\n  \"background_color\": \"#202b33\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/@types/mousetrap-pause.d.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n\ndeclare module \"mousetrap-pause\" {\n  import { MousetrapStatic } from \"mousetrap\";\n\n  function MousetrapPause(mousetrap: MousetrapStatic): MousetrapStatic;\n\n  export default MousetrapPause;\n\n  module \"mousetrap\" {\n    interface MousetrapStatic {\n      pause(): void;\n      unpause(): void;\n      pauseCombo(combo: string): void;\n      unpauseCombo(combo: string): void;\n    }\n    interface MousetrapInstance {\n      pause(): void;\n      unpause(): void;\n      pauseCombo(combo: string): void;\n      unpauseCombo(combo: string): void;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/@types/string.prototype.replaceall.d.ts",
    "content": "declare module \"string.prototype.replaceall\" {\n  function replaceAll(\n    searchValue: string | RegExp,\n    replaceValue: string\n  ): string;\n  function replaceAll(\n    searchValue: string | RegExp,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    replacer: (substring: string, ...args: any[]) => string\n  ): string;\n\n  namespace replaceAll {\n    function getPolyfill(): typeof replaceAll;\n    function implementation(): typeof replaceAll;\n    function shim(): void;\n  }\n\n  export default replaceAll;\n}\n"
  },
  {
    "path": "ui/v2.5/src/@types/videojs-abloop.d.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n\ndeclare module \"videojs-abloop\" {\n  import videojs from \"video.js\";\n\n  declare function abLoopPlugin(\n    window: Window & typeof globalThis,\n    player: videojs\n  ): abLoopPlugin.Plugin;\n\n  declare namespace abLoopPlugin {\n    interface Options {\n      start: number | boolean;\n      end: number | boolean;\n      enabled: boolean;\n      loopIfBeforeStart: boolean;\n      loopIfAfterEnd: boolean;\n      pauseBeforeLooping: boolean;\n      pauseAfterLooping: boolean;\n    }\n\n    class Plugin extends videojs.Plugin {\n      getOptions(): Options;\n      setOptions(o: Options): void;\n    }\n  }\n\n  export = abLoopPlugin;\n\n  declare module \"video.js\" {\n    interface VideoJsPlayer {\n      abLoopPlugin: abLoopPlugin.Plugin;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/@types/videojs-contrib-dash.d.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n\ndeclare module \"videojs-contrib-dash\" {\n  class Html5DashJS {\n    /**\n     * Get a list of hooks for a specific lifecycle.\n     *\n     * @param type the lifecycle to get hooks from\n     * @param hook optionally add a hook to the lifecycle\n     * @return an array of hooks or empty if none\n     */\n    static hooks(type: string, hook: Function | Function[]): Function[];\n\n    /**\n     * Add a function hook to a specific dash lifecycle.\n     *\n     * @param type the lifecycle to hook the function to\n     * @param hook the function or array of functions to attach\n     */\n    static hook(type: string, hook: Function | Function[]): void;\n\n    /**\n     * Remove a hook from a specific dash lifecycle.\n     *\n     * @param type the lifecycle that the function hooked to\n     * @param hook the hooked function to remove\n     * @return true if the function was removed, false if not found\n     */\n    static removeHook(type: string, hook: Function): boolean;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/@types/videojs-vr.d.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n\ndeclare module \"videojs-vr\" {\n  import videojs from \"video.js\";\n  // we don't want to depend on THREE.js directly, these are just typedefs for videojs-vr\n  // eslint-disable-next-line import/no-extraneous-dependencies\n  import * as THREE from \"three\";\n\n  declare function videojsVR(options?: videojsVR.Options): videojsVR.Plugin;\n\n  declare namespace videojsVR {\n    const VERSION: typeof videojs.VERSION;\n\n    type ProjectionType =\n      // The video is half sphere and the user should not be able to look behind themselves\n      | \"180\"\n      // Used for side-by-side 180 videos The video is half sphere and the user should not be able to look behind themselves\n      | \"180_LR\"\n      // Used for monoscopic 180 videos The video is half sphere and the user should not be able to look behind themselves\n      | \"180_MONO\"\n      // The video is a sphere\n      | \"360\"\n      | \"Sphere\"\n      | \"equirectangular\"\n      // The video is a cube\n      | \"360_CUBE\"\n      | \"Cube\"\n      // This video is not a 360 video\n      | \"NONE\"\n      // Check player.mediainfo.projection to see if the current video is a 360 video.\n      | \"AUTO\"\n      // Used for side-by-side 360 videos\n      | \"360_LR\"\n      // Used for top-to-bottom 360 videos\n      | \"360_TB\"\n      // Used for Equi-Angular Cubemap videos\n      | \"EAC\"\n      // Used for side-by-side Equi-Angular Cubemap videos\n      | \"EAC_LR\";\n\n    interface Options {\n      /**\n       * Force the cardboard button to display on all devices even if we don't think they support it.\n       *\n       * @default false\n       */\n      forceCardboard?: boolean;\n\n      /**\n       * Whether motion/gyro controls should be enabled.\n       *\n       * @default true on iOS and Android\n       */\n      motionControls?: boolean;\n\n      /**\n       * Defines the projection type.\n       *\n       * @default \"AUTO\"\n       */\n      projection?: ProjectionType;\n\n      /**\n       * This alters the number of segments in the spherical mesh onto which equirectangular videos are projected.\n       * The default is 32 but in some circumstances you may notice artifacts and need to increase this number.\n       *\n       * @default 32\n       */\n      sphereDetail?: number;\n\n      /**\n       * Enable debug logging for this plugin\n       *\n       * @default false\n       */\n      debug?: boolean;\n\n      /**\n       * Use this property to pass the Omnitone library object to the plugin. Please be aware of, the Omnitone library is not included in the build files.\n       */\n      omnitone?: object;\n\n      /**\n       * Default options for the Omnitone library. Please check available options on https://github.com/GoogleChrome/omnitone\n       */\n      omnitoneOptions?: object;\n\n      /**\n       * Feature to disable the togglePlay manually. This functionality is useful in live events so that users cannot stop the live, but still have a controlBar available.\n       *\n       * @default false\n       */\n      disableTogglePlay?: boolean;\n    }\n\n    interface PlayerMediaInfo {\n      /**\n       * This should be set on a source-by-source basis to turn 360 videos on an off depending upon the video.\n       * Note that AUTO is the same as NONE for player.mediainfo.projection.\n       */\n      projection?: ProjectionType;\n    }\n\n    class Plugin extends videojs.Plugin {\n      setProjection(projection: ProjectionType): void;\n      init(): void;\n      reset(): void;\n\n      cameraVector: THREE.Vector3;\n\n      camera: THREE.Camera;\n      scene: THREE.Scene;\n      renderer: THREE.Renderer;\n    }\n  }\n\n  export = videojsVR;\n\n  declare module \"video.js\" {\n    interface VideoJsPlayer {\n      vr: typeof videojsVR;\n      mediainfo?: videojsVR.PlayerMediaInfo;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/@types/videojs-vtt.d.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\n\ndeclare module \"videojs-vtt.js\" {\n  /**\n   * A custom JS error object that is reported through the parser's `onparsingerror` callback.\n   * It has a name, code, and message property, along with all the regular properties that come with a JavaScript error object.\n   *\n   * There are two error codes that can be reported back currently:\n   * * 0 BadSignature\n   * * 1 BadTimeStamp\n   *\n   * Note: Exceptions other then ParsingError will be thrown and not reported.\n   */\n  class ParsingError extends Error {\n    readonly name: string;\n    readonly code: number;\n    readonly message: string;\n  }\n\n  export namespace WebVTT {\n    /**\n     * A parser for the WebVTT spec in JavaScript.\n     */\n    class Parser {\n      /**\n       * The Parser constructor is passed a window object with which it will create new `VTTCues` and `VTTRegions`\n       * as well as an optional `StringDecoder` object which it will use to decode the data that the `parse()` function receives.\n       * For ease of use, a `StringDecoder` is provided via `WebVTT.StringDecoder()`.\n       * If a custom `StringDecoder` object is passed in it must support the API specified by the #whatwg string encoding spec.\n       *\n       * @param window the window object to use\n       * @param vttjs the vtt.js module\n       * @param decoder the decoder to decode `parse()` data with\n       */\n      constructor(window: Window);\n      constructor(window: Window, decoder: TextDecoder);\n      constructor(\n        window: Window,\n        vttjs: typeof import(\"videojs-vtt.js\"),\n        decoder: TextDecoder\n      );\n\n      /**\n       * Callback that is invoked for every region that is correctly parsed. Is passed a `VTTRegion` object.\n       */\n      onregion?: (cue: VTTRegion) => void;\n\n      /**\n       * Callback that is invoked for every cue that is fully parsed. In case of streaming parsing,\n       * `oncue` is delayed until the cue has been completely received. Is passed a `VTTCue` object.\n       */\n      oncue?: (cue: VTTCue) => void;\n\n      /**\n       * Is invoked in response to `flush()` and after the content was parsed completely.\n       */\n      onflush?: () => void;\n\n      /**\n       * Is invoked when a parsing error has occurred. This means that some part of the WebVTT file markup is badly formed.\n       * Is passed a `ParsingError` object.\n       */\n      onparsingerror?: (e: ParsingError) => void;\n\n      /**\n       * Hands data in some format to the parser for parsing. The passed data format is expected to be decodable by the\n       * StringDecoder object that it has. The parser decodes the data and reassembles partial data (streaming), even across line breaks.\n       *\n       * @param data data to be parsed\n       */\n      parse(data: string): this;\n\n      /**\n       * Indicates that no more data is expected and will force the parser to parse any unparsed data that it may have.\n       * Will also trigger `onflush`.\n       */\n      flush(): this;\n    }\n\n    /**\n     * Helper to allow strings to be decoded instead of the default binary utf8 data.\n     */\n    function StringDecoder(): TextDecoder;\n\n    /**\n     * Parses the cue text handed to it into a tree of DOM nodes that mirrors the internal WebVTT node structure of the cue text.\n     * It uses the window object handed to it to construct new HTMLElements and returns a tree of DOM nodes attached to a top level div.\n     *\n     * @param window window object to use\n     * @param cuetext cue text to parse\n     */\n    function convertCueToDOMTree(\n      window: Window,\n      cuetext: string\n    ): HTMLDivElement | null;\n\n    /**\n     * Converts the cuetext of the cues passed to it to DOM trees - by calling convertCueToDOMTree - and then runs the\n     * processing model steps of the WebVTT specification on the divs. The processing model applies the necessary CSS styles\n     * to the cue divs to prepare them for display on the web page. During this process the cue divs get added to a block level element (overlay).\n     * The overlay should be a part of the live DOM as the algorithm will use the computed styles (only of the divs) to do overlap avoidance.\n     *\n     * @param overlay A block level element (usually a div) that the computed cues and regions will be placed into.\n     */\n    function processCues(\n      window: Window,\n      cues: VTTCue[],\n      overlay: Element\n    ): void;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/App.tsx",
    "content": "import React, { Suspense, useEffect, useState } from \"react\";\nimport {\n  Route,\n  Switch,\n  useHistory,\n  useLocation,\n  useRouteMatch,\n} from \"react-router-dom\";\nimport { IntlProvider, CustomFormats, FormattedMessage } from \"react-intl\";\nimport { Helmet } from \"react-helmet\";\nimport cloneDeep from \"lodash-es/cloneDeep\";\nimport mergeWith from \"lodash-es/mergeWith\";\nimport { ToastProvider } from \"src/hooks/Toast\";\nimport { LightboxProvider } from \"src/hooks/Lightbox/context\";\nimport { initPolyfills } from \"src/polyfills\";\n\nimport locales, { registerCountry } from \"src/locales\";\nimport {\n  useConfiguration,\n  useConfigureUI,\n  useSystemStatus,\n} from \"src/core/StashService\";\nimport flattenMessages from \"./utils/flattenMessages\";\nimport * as yup from \"yup\";\nimport Mousetrap from \"mousetrap\";\nimport MousetrapPause from \"mousetrap-pause\";\nimport { ErrorBoundary } from \"./components/ErrorBoundary\";\nimport { MainNavbar } from \"./components/MainNavbar\";\nimport { PageNotFound } from \"./components/PageNotFound\";\nimport * as GQL from \"./core/generated-graphql\";\nimport { makeTitleProps } from \"./hooks/title\";\nimport { LoadingIndicator } from \"./components/Shared/LoadingIndicator\";\n\nimport {\n  ConfigurationProvider,\n  useConfigurationContextOptional,\n} from \"./hooks/Config\";\nimport { ManualProvider } from \"./components/Help/context\";\nimport { InteractiveProvider } from \"./hooks/Interactive/context\";\nimport { ReleaseNotesDialog } from \"./components/Dialogs/ReleaseNotesDialog\";\nimport { releaseNotes } from \"./docs/en/ReleaseNotes\";\nimport { getPlatformURL } from \"./core/createClient\";\nimport { lazyComponent } from \"./utils/lazyComponent\";\nimport { isPlatformUniquelyRenderedByApple } from \"./utils/apple\";\nimport Event from \"./hooks/event\";\n\nimport { PluginRoutes, PluginsLoader } from \"./plugins\";\n\n// import plugin_api to run code\nimport \"./pluginApi\";\nimport { ConnectionMonitor } from \"./ConnectionMonitor\";\nimport { TroubleshootingModeOverlay } from \"./components/TroubleshootingMode/TroubleshootingModeOverlay\";\nimport { PatchFunction } from \"./patch\";\n\nimport moment from \"moment/min/moment-with-locales\";\nimport { ErrorMessage } from \"./components/Shared/ErrorMessage\";\nimport cx from \"classnames\";\n\nconst Performers = lazyComponent(\n  () => import(\"./components/Performers/Performers\")\n);\nconst FrontPage = lazyComponent(\n  () => import(\"./components/FrontPage/FrontPage\")\n);\nconst Scenes = lazyComponent(() => import(\"./components/Scenes/Scenes\"));\nconst Settings = lazyComponent(() => import(\"./components/Settings/Settings\"));\nconst Stats = lazyComponent(() => import(\"./components/Stats\"));\nconst Studios = lazyComponent(() => import(\"./components/Studios/Studios\"));\nconst Galleries = lazyComponent(\n  () => import(\"./components/Galleries/Galleries\")\n);\n\nconst Groups = lazyComponent(() => import(\"./components/Groups/Groups\"));\nconst Tags = lazyComponent(() => import(\"./components/Tags/Tags\"));\nconst Images = lazyComponent(() => import(\"./components/Images/Images\"));\nconst Setup = lazyComponent(() => import(\"./components/Setup/Setup\"));\nconst Migrate = lazyComponent(() => import(\"./components/Setup/Migrate\"));\n\nconst SceneFilenameParser = lazyComponent(\n  () => import(\"./components/SceneFilenameParser/SceneFilenameParser\")\n);\nconst SceneDuplicateChecker = lazyComponent(\n  () => import(\"./components/SceneDuplicateChecker/SceneDuplicateChecker\")\n);\n\nconst appleRendering = isPlatformUniquelyRenderedByApple();\n\ninitPolyfills();\n\nMousetrapPause(Mousetrap);\n\nconst intlFormats: CustomFormats = {\n  date: {\n    long: { year: \"numeric\", month: \"long\", day: \"numeric\" },\n  },\n};\n\nconst defaultLocale = \"en-GB\";\n\nfunction languageMessageString(language: string) {\n  return language.replace(/-/, \"\");\n}\n\nconst AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction(\n  \"App\",\n  (props: React.PropsWithChildren<{}>) => {\n    return <>{props.children}</>;\n  }\n) as React.FC;\n\nconst MainContainer: React.FC = ({ children }) => {\n  // use optional here because the configuration may have be loading or errored\n  const { configuration } = useConfigurationContextOptional() || {};\n  const { sfwContentMode } = configuration?.interface || {};\n\n  return (\n    <div\n      className={cx(\"main container-fluid\", {\n        apple: appleRendering,\n        \"sfw-content-mode\": sfwContentMode,\n      })}\n    >\n      {children}\n    </div>\n  );\n};\n\nfunction translateLanguageLocale(l: string) {\n  // intl doesn't support all locales, so we need to map some to supported ones\n  switch (l) {\n    case \"nn-NO\":\n      // use other Norwegian locale for intl\n      return \"nb-NO\";\n    default:\n      return l;\n  }\n}\n\nexport const App: React.FC = () => {\n  const config = useConfiguration();\n  const [saveUI] = useConfigureUI();\n\n  const { data: systemStatusData } = useSystemStatus();\n\n  const language =\n    config.data?.configuration?.interface?.language ?? defaultLocale;\n  const intlLanguage = translateLanguageLocale(language);\n\n  // use en-GB as default messages if any messages aren't found in the chosen language\n  const [messages, setMessages] = useState<{}>();\n  const [customMessages, setCustomMessages] = useState<{}>();\n\n  useEffect(() => {\n    (async () => {\n      try {\n        const res = await fetch(getPlatformURL(\"customlocales\"));\n        if (res.ok) {\n          setCustomMessages(await res.json());\n        }\n      } catch (err) {\n        console.log(err);\n      }\n    })();\n  }, []);\n\n  useEffect(() => {\n    const setLocale = async () => {\n      const defaultMessageLanguage = languageMessageString(defaultLocale);\n      const messageLanguage = languageMessageString(language);\n\n      // register countries for the chosen language\n      await registerCountry(language);\n\n      const defaultMessages = (await locales[defaultMessageLanguage]()).default;\n      const mergedMessages = cloneDeep(Object.assign({}, defaultMessages));\n      const chosenMessages = (await locales[messageLanguage]()).default;\n\n      mergeWith(\n        mergedMessages,\n        chosenMessages,\n        customMessages,\n        (objVal, srcVal) => {\n          if (srcVal === \"\") {\n            return objVal;\n          }\n        }\n      );\n\n      const newMessages = flattenMessages(mergedMessages);\n\n      yup.setLocale({\n        mixed: {\n          required: newMessages[\"validation.required\"],\n        },\n      });\n\n      setMessages(newMessages);\n      moment.locale([language, defaultLocale]);\n    };\n\n    setLocale();\n  }, [customMessages, language]);\n\n  const location = useLocation();\n  const history = useHistory();\n  const setupMatch = useRouteMatch([\"/setup\", \"/migrate\"]);\n\n  // dispatch event when location changes\n  useEffect(() => {\n    Event.dispatch(\"location\", \"\", { location });\n  }, [location]);\n\n  // redirect to setup or migrate as needed\n  useEffect(() => {\n    if (!systemStatusData) {\n      return;\n    }\n\n    const { status } = systemStatusData.systemStatus;\n\n    if (\n      location.pathname !== \"/setup\" &&\n      status === GQL.SystemStatusEnum.Setup\n    ) {\n      // redirect to setup page\n      history.push(\"/setup\");\n    }\n\n    if (\n      location.pathname !== \"/migrate\" &&\n      status === GQL.SystemStatusEnum.NeedsMigration\n    ) {\n      // redirect to migrate page\n      history.replace(\"/migrate\");\n    }\n  }, [systemStatusData, setupMatch, history, location]);\n\n  function maybeRenderNavbar() {\n    // don't render navbar for setup views\n    if (!setupMatch) {\n      return <MainNavbar />;\n    }\n  }\n\n  function renderContent() {\n    if (!systemStatusData) {\n      return <LoadingIndicator />;\n    }\n\n    return (\n      <ErrorBoundary>\n        <Suspense fallback={<LoadingIndicator />}>\n          <Switch>\n            <Route exact path=\"/\" component={FrontPage} />\n            <Route path=\"/scenes\" component={Scenes} />\n            <Route path=\"/images\" component={Images} />\n            <Route path=\"/galleries\" component={Galleries} />\n            <Route path=\"/performers\" component={Performers} />\n            <Route path=\"/tags\" component={Tags} />\n            <Route path=\"/studios\" component={Studios} />\n            <Route path=\"/groups\" component={Groups} />\n            <Route path=\"/stats\" component={Stats} />\n            <Route path=\"/settings\" component={Settings} />\n            <Route\n              path=\"/sceneFilenameParser\"\n              component={SceneFilenameParser}\n            />\n            <Route\n              path=\"/sceneDuplicateChecker\"\n              component={SceneDuplicateChecker}\n            />\n            <Route path=\"/setup\" component={Setup} />\n            <Route path=\"/migrate\" component={Migrate} />\n            <PluginRoutes />\n            <Route component={PageNotFound} />\n          </Switch>\n        </Suspense>\n      </ErrorBoundary>\n    );\n  }\n\n  function maybeRenderReleaseNotes() {\n    if (setupMatch || !systemStatusData || config.loading || config.error) {\n      return;\n    }\n\n    const lastNoteSeen = config.data?.configuration.ui.lastNoteSeen;\n    const notes = releaseNotes.filter((n) => {\n      return !lastNoteSeen || n.date > lastNoteSeen;\n    });\n\n    if (notes.length === 0) return;\n\n    return (\n      <ReleaseNotesDialog\n        notes={notes}\n        onClose={() => {\n          saveUI({\n            variables: {\n              input: {\n                ...config.data?.configuration.ui,\n                lastNoteSeen: notes[0].date,\n              },\n            },\n          });\n        }}\n      />\n    );\n  }\n\n  const title = config.data?.configuration.ui.title || \"Stash\";\n  const titleProps = makeTitleProps(title);\n\n  if (!messages) {\n    return null;\n  }\n\n  function renderSimple(content: React.ReactNode) {\n    return (\n      <IntlProvider\n        locale={intlLanguage}\n        messages={messages}\n        formats={intlFormats}\n      >\n        <MainContainer>{content}</MainContainer>\n      </IntlProvider>\n    );\n  }\n\n  if (config.loading) {\n    return renderSimple(<LoadingIndicator />);\n  }\n\n  if (config.error) {\n    return renderSimple(\n      <ErrorMessage\n        message={\n          <FormattedMessage\n            id=\"errors.loading_type\"\n            values={{ type: \"configuration\" }}\n          />\n        }\n        error={config.error.message}\n      />\n    );\n  }\n\n  return (\n    <ErrorBoundary>\n      <IntlProvider\n        locale={intlLanguage}\n        messages={messages}\n        formats={intlFormats}\n      >\n        <ToastProvider>\n          <PluginsLoader\n            disableCustomizations={\n              config.data?.configuration?.interface?.disableCustomizations ??\n              false\n            }\n          >\n            <AppContainer>\n              <ConfigurationProvider configuration={config.data!.configuration}>\n                {maybeRenderReleaseNotes()}\n                <ConnectionMonitor />\n                <TroubleshootingModeOverlay />\n                <Suspense fallback={<LoadingIndicator />}>\n                  <LightboxProvider>\n                    <ManualProvider>\n                      <InteractiveProvider>\n                        <Helmet {...titleProps} />\n                        {maybeRenderNavbar()}\n                        <MainContainer>{renderContent()}</MainContainer>\n                      </InteractiveProvider>\n                    </ManualProvider>\n                  </LightboxProvider>\n                </Suspense>\n              </ConfigurationProvider>\n            </AppContainer>\n          </PluginsLoader>\n        </ToastProvider>\n      </IntlProvider>\n    </ErrorBoundary>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/ConnectionMonitor.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { getWSClient, useWSState } from \"./core/StashService\";\nimport { useToast } from \"./hooks/Toast\";\nimport { useIntl } from \"react-intl\";\n\nexport const ConnectionMonitor: React.FC = () => {\n  const Toast = useToast();\n  const intl = useIntl();\n\n  const { state } = useWSState(getWSClient());\n  const [cachedState, setCacheState] = useState<typeof state>(state);\n\n  useEffect(() => {\n    if (cachedState === \"connecting\" && state === \"error\") {\n      Toast.error(\n        intl.formatMessage({\n          id: \"connection_monitor.websocket_connection_failed\",\n        })\n      );\n    }\n\n    if (state === \"connected\" && cachedState === \"error\") {\n      Toast.success(\n        intl.formatMessage({\n          id: \"connection_monitor.websocket_connection_reestablished\",\n        })\n      );\n    }\n\n    setCacheState(state);\n  }, [state, cachedState, Toast, intl]);\n\n  return null;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Changelog/Changelog.tsx",
    "content": "import React from \"react\";\nimport { useChangelogStorage } from \"src/hooks/LocalForage\";\nimport Version from \"./Version\";\nimport V010 from \"src/docs/en/Changelog/v010.md\";\nimport V011 from \"src/docs/en/Changelog/v011.md\";\nimport V020 from \"src/docs/en/Changelog/v020.md\";\nimport V021 from \"src/docs/en/Changelog/v021.md\";\nimport V030 from \"src/docs/en/Changelog/v030.md\";\nimport V040 from \"src/docs/en/Changelog/v040.md\";\nimport V050 from \"src/docs/en/Changelog/v050.md\";\nimport V060 from \"src/docs/en/Changelog/v060.md\";\nimport V070 from \"src/docs/en/Changelog/v070.md\";\nimport V080 from \"src/docs/en/Changelog/v080.md\";\nimport V090 from \"src/docs/en/Changelog/v090.md\";\nimport V0100 from \"src/docs/en/Changelog/v0100.md\";\nimport V0110 from \"src/docs/en/Changelog/v0110.md\";\nimport V0120 from \"src/docs/en/Changelog/v0120.md\";\nimport V0130 from \"src/docs/en/Changelog/v0130.md\";\nimport V0131 from \"src/docs/en/Changelog/v0131.md\";\nimport V0140 from \"src/docs/en/Changelog/v0140.md\";\nimport V0150 from \"src/docs/en/Changelog/v0150.md\";\nimport V0160 from \"src/docs/en/Changelog/v0160.md\";\nimport V0161 from \"src/docs/en/Changelog/v0161.md\";\nimport V0170 from \"src/docs/en/Changelog/v0170.md\";\nimport V0180 from \"src/docs/en/Changelog/v0180.md\";\nimport V0190 from \"src/docs/en/Changelog/v0190.md\";\nimport V0200 from \"src/docs/en/Changelog/v0200.md\";\nimport V0210 from \"src/docs/en/Changelog/v0210.md\";\nimport V0220 from \"src/docs/en/Changelog/v0220.md\";\nimport V0230 from \"src/docs/en/Changelog/v0230.md\";\nimport V0240 from \"src/docs/en/Changelog/v0240.md\";\nimport V0250 from \"src/docs/en/Changelog/v0250.md\";\nimport V0260 from \"src/docs/en/Changelog/v0260.md\";\nimport V0270 from \"src/docs/en/Changelog/v0270.md\";\nimport V0280 from \"src/docs/en/Changelog/v0280.md\";\nimport V0290 from \"src/docs/en/Changelog/v0290.md\";\nimport V0300 from \"src/docs/en/Changelog/v0300.md\";\nimport V0310 from \"src/docs/en/Changelog/v0310.md\";\n\nimport V0290ReleaseNotes from \"src/docs/en/ReleaseNotes/v0290.md\";\n\nimport { MarkdownPage } from \"../Shared/MarkdownPage\";\nimport { FormattedMessage } from \"react-intl\";\n\nconst Changelog: React.FC = () => {\n  const [{ data, loading }, setOpenState] = useChangelogStorage();\n\n  const stashVersion = import.meta.env.VITE_APP_STASH_VERSION;\n  const buildTime = import.meta.env.VITE_APP_DATE;\n\n  let buildDate;\n  if (buildTime) {\n    buildDate = buildTime.substring(0, buildTime.indexOf(\" \"));\n  }\n\n  if (loading) return <></>;\n\n  const openState = data?.versions ?? {};\n\n  const setVersionOpenState = (key: string, state: boolean) =>\n    setOpenState({\n      versions: {\n        ...openState,\n        [key]: state,\n      },\n    });\n\n  interface IStashRelease {\n    version: string;\n    date?: string;\n    page: string;\n    defaultOpen?: boolean;\n    releaseNotes?: string;\n  }\n\n  // after new release:\n  // add entry to releases, using the current* fields\n  // then update the current fields.\n  const currentVersion = stashVersion || \"v0.31.0\";\n  const currentDate = buildDate;\n  const currentPage = V0310;\n\n  const releases: IStashRelease[] = [\n    {\n      version: currentVersion,\n      date: currentDate,\n      page: currentPage,\n      defaultOpen: true,\n    },\n    {\n      version: \"v0.30.1\",\n      date: \"2025-12-18\",\n      page: V0300,\n      releaseNotes: V0290ReleaseNotes,\n    },\n    {\n      version: \"v0.29.3\",\n      date: \"2025-11-06\",\n      page: V0290,\n      releaseNotes: V0290ReleaseNotes,\n    },\n    {\n      version: \"v0.28.1\",\n      date: \"2025-03-20\",\n      page: V0280,\n    },\n    {\n      version: \"v0.27.2\",\n      date: \"2024-10-16\",\n      page: V0270,\n    },\n    {\n      version: \"v0.26.2\",\n      date: \"2024-06-27\",\n      page: V0260,\n    },\n    {\n      version: \"v0.25.1\",\n      date: \"2024-03-13\",\n      page: V0250,\n    },\n    {\n      version: \"v0.24.3\",\n      date: \"2024-01-15\",\n      page: V0240,\n    },\n    {\n      version: \"v0.23.1\",\n      date: \"2023-10-14\",\n      page: V0230,\n    },\n    {\n      version: \"v0.22.1\",\n      date: \"2023-08-21\",\n      page: V0220,\n    },\n    {\n      version: \"v0.21.0\",\n      date: \"2023-06-13\",\n      page: V0210,\n    },\n    {\n      version: \"v0.20.2\",\n      date: \"2023-04-08\",\n      page: V0200,\n    },\n    {\n      version: \"v0.19.1\",\n      date: \"2023-02-21\",\n      page: V0190,\n    },\n    {\n      version: \"v0.18.0\",\n      date: \"2022-11-30\",\n      page: V0180,\n    },\n    {\n      version: \"v0.17.2\",\n      date: \"2022-10-25\",\n      page: V0170,\n    },\n    {\n      version: \"v0.16.1\",\n      date: \"2022-07-26\",\n      page: V0161,\n    },\n    {\n      version: \"v0.16.0\",\n      date: \"2022-07-05\",\n      page: V0160,\n    },\n    {\n      version: \"v0.15.0\",\n      date: \"2022-05-18\",\n      page: V0150,\n    },\n    {\n      version: \"v0.14.0\",\n      date: \"2022-04-11\",\n      page: V0140,\n    },\n    {\n      version: \"v0.13.1\",\n      date: \"2022-03-16\",\n      page: V0131,\n    },\n    {\n      version: \"v0.13.0\",\n      date: \"2022-03-08\",\n      page: V0130,\n    },\n    {\n      version: \"v0.12.0\",\n      date: \"2021-12-29\",\n      page: V0120,\n    },\n    {\n      version: \"v0.11.0\",\n      date: \"2021-11-16\",\n      page: V0110,\n    },\n    {\n      version: \"v0.10.0\",\n      date: \"2021-10-11\",\n      page: V0100,\n    },\n    {\n      version: \"v0.9.0\",\n      date: \"2021-09-06\",\n      page: V090,\n    },\n    {\n      version: \"v0.8.0\",\n      date: \"2021-07-02\",\n      page: V080,\n    },\n    {\n      version: \"v0.7.0\",\n      date: \"2021-05-15\",\n      page: V070,\n    },\n    {\n      version: \"v0.6.0\",\n      date: \"2021-03-29\",\n      page: V060,\n    },\n    {\n      version: \"v0.5.0\",\n      date: \"2021-02-23\",\n      page: V050,\n    },\n    {\n      version: \"v0.4.0\",\n      date: \"2020-11-24\",\n      page: V040,\n    },\n    {\n      version: \"v0.3.0\",\n      date: \"2020-09-02\",\n      page: V030,\n    },\n    {\n      version: \"v0.2.1\",\n      date: \"2020-06-10\",\n      page: V021,\n    },\n    {\n      version: \"v0.2.0\",\n      date: \"2020-06-06\",\n      page: V020,\n    },\n    {\n      version: \"v0.1.1\",\n      date: \"2020-02-25\",\n      page: V011,\n    },\n    {\n      version: \"v0.1.0\",\n      date: \"2020-02-24\",\n      page: V010,\n    },\n  ];\n\n  return (\n    <div className=\"changelog\">\n      <h1 className=\"mb-4\">\n        <FormattedMessage id=\"config.changelog.header\" />\n      </h1>\n      {releases.map((r) => (\n        <Version\n          key={r.version}\n          version={r.version}\n          date={r.date}\n          openState={openState}\n          setOpenState={setVersionOpenState}\n          defaultOpen={r.defaultOpen}\n        >\n          {r.releaseNotes && (\n            <div>\n              <h3 className=\"mt-0\">\n                <FormattedMessage id=\"release_notes\" />\n              </h3>\n              <MarkdownPage page={r.releaseNotes} />\n              <hr />\n            </div>\n          )}\n          <MarkdownPage page={r.page} />\n        </Version>\n      ))}\n    </div>\n  );\n};\n\nexport default Changelog;\n"
  },
  {
    "path": "ui/v2.5/src/components/Changelog/Version.tsx",
    "content": "import { faAngleDown, faAngleUp } from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState } from \"react\";\nimport { Button, Card, Collapse } from \"react-bootstrap\";\nimport { FormattedDate, FormattedMessage } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\n\ninterface IVersionProps {\n  version: string;\n  date?: string;\n  defaultOpen?: boolean;\n  setOpenState: (key: string, state: boolean) => void;\n  openState: Record<string, boolean>;\n}\n\nconst Version: React.FC<IVersionProps> = ({\n  version,\n  date,\n  defaultOpen,\n  openState,\n  setOpenState,\n  children,\n}) => {\n  const [open, setOpen] = useState(\n    defaultOpen ?? openState[version + date] ?? false\n  );\n\n  const updateState = () => {\n    setOpenState(version + date, !open);\n    setOpen(!open);\n  };\n\n  return (\n    <Card className=\"changelog-version\">\n      <Card.Header>\n        <h4 className=\"changelog-version-header d-flex align-items-center\">\n          <Button onClick={updateState} variant=\"link\">\n            <Icon icon={open ? faAngleUp : faAngleDown} className=\"mr-3\" />\n            {version} (\n            {date ? (\n              <FormattedDate value={date} timeZone=\"utc\" />\n            ) : (\n              <FormattedMessage\n                defaultMessage=\"Development Version\"\n                id=\"developmentVersion\"\n              />\n            )}\n            )\n          </Button>\n        </h4>\n      </Card.Header>\n      <Card.Body>\n        <Collapse in={open}>\n          <div className=\"changelog-version-body markdown\">{children}</div>\n        </Collapse>\n      </Card.Body>\n    </Card>\n  );\n};\n\nexport default Version;\n"
  },
  {
    "path": "ui/v2.5/src/components/Changelog/styles.scss",
    "content": ".changelog {\n  margin-bottom: 4rem;\n\n  .btn {\n    color: inherit;\n    font-size: inherit;\n    font-weight: inherit;\n\n    &:focus {\n      text-decoration: unset;\n    }\n\n    &:hover {\n      text-decoration: underline;\n    }\n  }\n\n  .card,\n  .card-body {\n    padding: 0;\n  }\n\n  &-version {\n    &-body {\n      padding: 1rem 2rem;\n    }\n\n    &-header {\n      color: $text-color;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/GenerateDialog.tsx",
    "content": "import React, { useState, useEffect, useMemo } from \"react\";\nimport { Form, Button } from \"react-bootstrap\";\nimport { mutateMetadataGenerate } from \"src/core/StashService\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { useToast } from \"src/hooks/Toast\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { Manual } from \"../Help/Manual\";\nimport { withoutTypename } from \"src/utils/data\";\nimport { GenerateOptions } from \"../Settings/Tasks/GenerateOptions\";\nimport { SettingSection } from \"../Settings/SettingSection\";\nimport { faCogs, faQuestionCircle } from \"@fortawesome/free-solid-svg-icons\";\nimport { SettingsContext } from \"../Settings/context\";\n\ninterface IGenerateDialog {\n  selectedIds?: string[];\n  onClose: () => void;\n  type: \"scene\" | \"image\" | \"gallery\";\n}\n\nexport const GenerateDialog: React.FC<IGenerateDialog> = ({\n  selectedIds,\n  onClose,\n  type,\n}) => {\n  const sceneIDs = type === \"scene\" ? selectedIds : undefined;\n  const imageIDs = type === \"image\" ? selectedIds : undefined;\n  const galleryIDs = type === \"gallery\" ? selectedIds : undefined;\n\n  const { configuration } = useConfigurationContext();\n\n  function getDefaultOptions(): GQL.GenerateMetadataInput {\n    return {\n      sprites: true,\n      phashes: true,\n      previews: true,\n      markers: true,\n      previewOptions: {\n        previewSegments: 0,\n        previewSegmentDuration: 0,\n        previewPreset: GQL.PreviewPreset.Slow,\n      },\n    };\n  }\n\n  const [options, setOptions] = useState<GQL.GenerateMetadataInput>(\n    getDefaultOptions()\n  );\n  const [configRead, setConfigRead] = useState(false);\n  const [showManual, setShowManual] = useState(false);\n  const [animation, setAnimation] = useState(true);\n\n  const intl = useIntl();\n  const Toast = useToast();\n\n  useEffect(() => {\n    if (configRead) {\n      return;\n    }\n\n    // combine the defaults with the system preview generation settings\n    if (configuration?.defaults.generate) {\n      const { generate } = configuration.defaults;\n      setOptions(withoutTypename(generate));\n      setConfigRead(true);\n    }\n\n    if (configuration?.general) {\n      const { general } = configuration;\n      setOptions((existing) => ({\n        ...existing,\n        previewOptions: {\n          ...existing.previewOptions,\n          previewSegments:\n            general.previewSegments ?? existing.previewOptions?.previewSegments,\n          previewSegmentDuration:\n            general.previewSegmentDuration ??\n            existing.previewOptions?.previewSegmentDuration,\n          previewExcludeStart:\n            general.previewExcludeStart ??\n            existing.previewOptions?.previewExcludeStart,\n          previewExcludeEnd:\n            general.previewExcludeEnd ??\n            existing.previewOptions?.previewExcludeEnd,\n          previewPreset:\n            general.previewPreset ?? existing.previewOptions?.previewPreset,\n        },\n      }));\n      setConfigRead(true);\n    }\n  }, [configuration, configRead]);\n\n  const selectionStatus = useMemo(() => {\n    const countableIds: Record<typeof type, string> = {\n      scene: \"countables.scenes\",\n      image: \"countables.images\",\n      gallery: \"countables.galleries\",\n    };\n    const countableId = countableIds[type];\n\n    if (selectedIds) {\n      return (\n        <Form.Group id=\"selected-generate-ids\">\n          <FormattedMessage\n            id=\"config.tasks.generate.generating_scenes\"\n            values={{\n              num: selectedIds.length,\n              scene: intl.formatMessage(\n                {\n                  id: countableId,\n                },\n                {\n                  count: selectedIds.length,\n                }\n              ),\n            }}\n          />\n          .\n        </Form.Group>\n      );\n    }\n    const message = (\n      <span>\n        <FormattedMessage\n          id=\"config.tasks.generate.generating_scenes\"\n          values={{\n            num: intl.formatMessage({ id: \"all\" }),\n            scene: intl.formatMessage(\n              {\n                id: countableId,\n              },\n              {\n                count: 0,\n              }\n            ),\n          }}\n        />\n        .\n      </span>\n    );\n\n    return (\n      <Form.Group className=\"dialog-selected-folders\">\n        <div>{message}</div>\n      </Form.Group>\n    );\n  }, [selectedIds, intl, type]);\n\n  async function onGenerate() {\n    try {\n      await mutateMetadataGenerate({\n        ...options,\n        sceneIDs,\n        imageIDs,\n        galleryIDs,\n      });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.generate\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      onClose();\n    }\n  }\n\n  function onShowManual() {\n    setAnimation(false);\n    setShowManual(true);\n  }\n\n  if (showManual) {\n    return (\n      <Manual\n        animation={false}\n        show\n        onClose={() => setShowManual(false)}\n        defaultActiveTab=\"Tasks.md\"\n      />\n    );\n  }\n\n  return (\n    <ModalComponent\n      show\n      modalProps={{ animation, size: \"lg\" }}\n      icon={faCogs}\n      header={intl.formatMessage({ id: \"actions.generate\" })}\n      accept={{\n        onClick: onGenerate,\n        text: intl.formatMessage({ id: \"actions.generate\" }),\n      }}\n      cancel={{\n        onClick: () => onClose(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      leftFooterButtons={\n        <Button\n          title=\"Help\"\n          className=\"minimal help-button\"\n          onClick={() => onShowManual()}\n        >\n          <Icon icon={faQuestionCircle} />\n        </Button>\n      }\n    >\n      <Form>\n        {selectionStatus}\n        <SettingsContext>\n          <SettingSection>\n            <GenerateOptions\n              type={type}\n              options={options}\n              setOptions={setOptions}\n              selection\n            />\n          </SettingSection>\n        </SettingsContext>\n      </Form>\n    </ModalComponent>\n  );\n};\n\nexport default GenerateDialog;\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx",
    "content": "import React, { useState, useEffect, useCallback } from \"react\";\nimport { Form, Button, Table } from \"react-bootstrap\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport {\n  multiValueSceneFields,\n  SceneField,\n  sceneFieldMessageID,\n  sceneFields,\n} from \"./constants\";\nimport { ThreeStateBoolean } from \"./ThreeStateBoolean\";\nimport {\n  faCheck,\n  faPencilAlt,\n  faTimes,\n} from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IFieldOptionsEditor {\n  options: GQL.IdentifyFieldOptions | undefined;\n  field: SceneField;\n  editField: () => void;\n  editOptions: (o?: GQL.IdentifyFieldOptions | null) => void;\n  editing: boolean;\n  allowSetDefault: boolean;\n  defaultOptions?: GQL.IdentifyMetadataOptionsInput;\n}\n\ninterface IFieldOptions {\n  field: string;\n  strategy: GQL.IdentifyFieldStrategy | undefined;\n  createMissing?: GQL.Maybe<boolean> | undefined;\n}\n\nconst FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({\n  options,\n  field,\n  editField,\n  editOptions,\n  editing,\n  allowSetDefault,\n  defaultOptions,\n}) => {\n  const intl = useIntl();\n\n  const [localOptions, setLocalOptions] = useState<IFieldOptions>();\n\n  const resetOptions = useCallback(() => {\n    let toSet: IFieldOptions;\n    if (!options) {\n      // unset - use default values\n      toSet = {\n        field,\n        strategy: undefined,\n        createMissing: undefined,\n      };\n    } else {\n      toSet = {\n        field,\n        strategy: options.strategy,\n        createMissing: options.createMissing,\n      };\n    }\n    setLocalOptions(toSet);\n  }, [options, field]);\n\n  useEffect(() => {\n    resetOptions();\n  }, [resetOptions]);\n\n  function renderField() {\n    return intl.formatMessage({ id: sceneFieldMessageID(field) });\n  }\n\n  function renderStrategy() {\n    if (!localOptions) {\n      return;\n    }\n\n    const strategies = Object.entries(GQL.IdentifyFieldStrategy);\n    let { strategy } = localOptions;\n    if (strategy === undefined) {\n      if (!allowSetDefault) {\n        strategy = GQL.IdentifyFieldStrategy.Merge;\n      }\n    }\n\n    if (!editing) {\n      if (strategy === undefined) {\n        return intl.formatMessage({ id: \"actions.use_default\" });\n      }\n\n      const f = strategies.find((s) => s[1] === strategy);\n      return intl.formatMessage({\n        id: `actions.${f![0].toLowerCase()}`,\n      });\n    }\n\n    return (\n      <Form.Group>\n        {allowSetDefault ? (\n          <Form.Check\n            type=\"radio\"\n            id={`${field}-strategy-default`}\n            checked={strategy === undefined}\n            onChange={() =>\n              setLocalOptions({\n                ...localOptions,\n                strategy: undefined,\n              })\n            }\n            disabled={!editing}\n            label={intl.formatMessage({ id: \"actions.use_default\" })}\n          />\n        ) : undefined}\n        {strategies.map((f) => (\n          <Form.Check\n            type=\"radio\"\n            key={f[0]}\n            id={`${field}-strategy-${f[0]}`}\n            checked={strategy === f[1]}\n            onChange={() =>\n              setLocalOptions({\n                ...localOptions,\n                strategy: f[1],\n              })\n            }\n            disabled={!editing}\n            label={intl.formatMessage({\n              id: `actions.${f[0].toLowerCase()}`,\n            })}\n          />\n        ))}\n      </Form.Group>\n    );\n  }\n\n  function maybeRenderCreateMissing() {\n    if (!localOptions) {\n      return;\n    }\n\n    if (\n      multiValueSceneFields.includes(localOptions.field as SceneField) &&\n      localOptions.strategy !== GQL.IdentifyFieldStrategy.Ignore\n    ) {\n      const value =\n        localOptions.createMissing === null\n          ? undefined\n          : localOptions.createMissing;\n\n      if (!editing) {\n        if (value === undefined && allowSetDefault) {\n          return intl.formatMessage({ id: \"actions.use_default\" });\n        }\n        if (value) {\n          return <Icon icon={faCheck} className=\"text-success\" />;\n        }\n\n        return <Icon icon={faTimes} className=\"text-danger\" />;\n      }\n\n      const defaultVal = defaultOptions?.fieldOptions?.find(\n        (f) => f.field === localOptions.field\n      )?.createMissing;\n\n      // if allowSetDefault is false, then strategy is considered merge\n      // if its true, then its using the default value and should not be shown here\n      if (localOptions.strategy === undefined && allowSetDefault) {\n        return;\n      }\n\n      return (\n        <ThreeStateBoolean\n          id=\"create-missing\"\n          disabled={!editing}\n          allowUndefined={allowSetDefault}\n          value={value}\n          setValue={(v) =>\n            setLocalOptions({ ...localOptions, createMissing: v })\n          }\n          defaultValue={defaultVal ?? undefined}\n        />\n      );\n    }\n  }\n\n  function onEditOptions() {\n    if (!localOptions) {\n      return;\n    }\n\n    const localOptionsCopy = { ...localOptions };\n    if (localOptionsCopy.strategy === undefined && !allowSetDefault) {\n      localOptionsCopy.strategy = GQL.IdentifyFieldStrategy.Merge;\n    }\n\n    // send null if strategy is undefined\n    if (localOptionsCopy.strategy === undefined) {\n      editOptions(null);\n      resetOptions();\n    } else {\n      let { createMissing } = localOptionsCopy;\n      if (createMissing === undefined && !allowSetDefault) {\n        createMissing = false;\n      }\n\n      editOptions({\n        ...localOptionsCopy,\n        strategy: localOptionsCopy.strategy,\n        createMissing,\n      });\n    }\n  }\n\n  return (\n    <tr>\n      <td>{renderField()}</td>\n      <td>{renderStrategy()}</td>\n      <td>{maybeRenderCreateMissing()}</td>\n      <td className=\"text-right\">\n        {editing ? (\n          <>\n            <Button\n              className=\"minimal text-success\"\n              onClick={() => onEditOptions()}\n            >\n              <Icon icon={faCheck} />\n            </Button>\n            <Button\n              className=\"minimal text-danger\"\n              onClick={() => {\n                editOptions();\n                resetOptions();\n              }}\n            >\n              <Icon icon={faTimes} />\n            </Button>\n          </>\n        ) : (\n          <>\n            <Button className=\"minimal\" onClick={() => editField()}>\n              <Icon icon={faPencilAlt} />\n            </Button>\n          </>\n        )}\n      </td>\n    </tr>\n  );\n};\n\ninterface IFieldOptionsList {\n  fieldOptions?: GQL.IdentifyFieldOptions[];\n  setFieldOptions: (o: GQL.IdentifyFieldOptions[]) => void;\n  setEditingField: (v: boolean) => void;\n  allowSetDefault?: boolean;\n  defaultOptions?: GQL.IdentifyMetadataOptionsInput;\n}\n\nexport const FieldOptionsList: React.FC<IFieldOptionsList> = ({\n  fieldOptions,\n  setFieldOptions,\n  setEditingField,\n  allowSetDefault = true,\n  defaultOptions,\n}) => {\n  const [localFieldOptions, setLocalFieldOptions] =\n    useState<GQL.IdentifyFieldOptions[]>();\n  const [editField, setEditField] = useState<string | undefined>();\n\n  useEffect(() => {\n    if (fieldOptions) {\n      setLocalFieldOptions([...fieldOptions]);\n    } else {\n      setLocalFieldOptions([]);\n    }\n  }, [fieldOptions]);\n\n  function handleEditOptions(o?: GQL.IdentifyFieldOptions | null) {\n    if (!localFieldOptions) {\n      return;\n    }\n\n    if (o !== undefined) {\n      const newOptions = [...localFieldOptions];\n      const index = newOptions.findIndex(\n        (option) => option.field === editField\n      );\n      if (index !== -1) {\n        // if null, then we're removing\n        if (o === null) {\n          newOptions.splice(index, 1);\n        } else {\n          // replace in list\n          newOptions.splice(index, 1, o);\n        }\n      } else if (o !== null) {\n        // don't add if null\n        newOptions.push(o);\n      }\n\n      setFieldOptions(newOptions);\n    }\n\n    setEditField(undefined);\n    setEditingField(false);\n  }\n\n  function onEditField(field: string) {\n    setEditField(field);\n    setEditingField(true);\n  }\n\n  if (!localFieldOptions) {\n    return <></>;\n  }\n\n  return (\n    <Form.Group className=\"scraper-sources mt-3\">\n      <h5>\n        <FormattedMessage id=\"config.tasks.identify.field_options\" />\n      </h5>\n      <Table responsive className=\"field-options-table\">\n        <thead>\n          <tr>\n            <th className=\"w-25\">\n              <FormattedMessage id=\"config.tasks.identify.field\" />\n            </th>\n            <th className=\"w-25\">\n              <FormattedMessage id=\"config.tasks.identify.strategy\" />\n            </th>\n            <th className=\"w-25\">\n              <FormattedMessage id=\"config.tasks.identify.create_missing\" />\n            </th>\n            {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}\n            <th className=\"w-25\" />\n          </tr>\n        </thead>\n        <tbody>\n          {sceneFields.map((f) => (\n            <FieldOptionsEditor\n              key={f}\n              field={f}\n              allowSetDefault={allowSetDefault}\n              options={localFieldOptions.find((o) => o.field === f)}\n              editField={() => onEditField(f)}\n              editOptions={handleEditOptions}\n              editing={f === editField}\n              defaultOptions={defaultOptions}\n            />\n          ))}\n        </tbody>\n      </Table>\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx",
    "content": "import React, { useState, useEffect, useMemo } from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport {\n  mutateMetadataIdentify,\n  useConfiguration,\n  useConfigureDefaults,\n  useListSceneScrapers,\n} from \"src/core/StashService\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { OperationButton } from \"src/components/Shared/OperationButton\";\nimport { useToast } from \"src/hooks/Toast\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { withoutTypename } from \"src/utils/data\";\nimport {\n  SCRAPER_PREFIX,\n  STASH_BOX_PREFIX,\n} from \"src/components/Tagger/constants\";\nimport { DirectorySelectionDialog } from \"src/components/Settings/Tasks/DirectorySelectionDialog\";\nimport { Manual } from \"src/components/Help/Manual\";\nimport { IScraperSource } from \"./constants\";\nimport { OptionsEditor } from \"./Options\";\nimport { SourcesEditor, SourcesList } from \"./Sources\";\nimport {\n  faCogs,\n  faFolderOpen,\n  faQuestionCircle,\n} from \"@fortawesome/free-solid-svg-icons\";\n\nconst autoTagScraperID = \"builtin_autotag\";\n\ninterface IIdentifyDialogProps {\n  selectedIds?: string[];\n  onClose: () => void;\n}\n\nexport const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({\n  selectedIds,\n  onClose,\n}) => {\n  function getDefaultOptions(): GQL.IdentifyMetadataOptionsInput {\n    return {\n      fieldOptions: [\n        {\n          field: \"title\",\n          strategy: GQL.IdentifyFieldStrategy.Overwrite,\n        },\n        {\n          field: \"studio\",\n          strategy: GQL.IdentifyFieldStrategy.Merge,\n          createMissing: true,\n        },\n        {\n          field: \"performers\",\n          strategy: GQL.IdentifyFieldStrategy.Merge,\n          createMissing: true,\n        },\n        {\n          field: \"tags\",\n          strategy: GQL.IdentifyFieldStrategy.Merge,\n          createMissing: true,\n        },\n      ],\n      performerGenders: undefined,\n      setCoverImage: true,\n      setOrganized: false,\n      skipMultipleMatches: true,\n      skipMultipleMatchTag: undefined,\n      skipSingleNamePerformers: true,\n      skipSingleNamePerformerTag: undefined,\n    };\n  }\n\n  const [configureDefaults] = useConfigureDefaults();\n\n  const [options, setOptions] = useState<GQL.IdentifyMetadataOptionsInput>(\n    getDefaultOptions()\n  );\n  const [sources, setSources] = useState<IScraperSource[]>([]);\n  const [editingSource, setEditingSource] = useState<\n    IScraperSource | undefined\n  >();\n  const [paths, setPaths] = useState<string[]>([]);\n  const [showManual, setShowManual] = useState(false);\n  const [settingPaths, setSettingPaths] = useState(false);\n  const [animation, setAnimation] = useState(true);\n  const [editingField, setEditingField] = useState(false);\n  const [savingDefaults, setSavingDefaults] = useState(false);\n\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const { data: configData, error: configError } = useConfiguration();\n  const { data: scraperData, error: scraperError } = useListSceneScrapers();\n\n  const allSources = useMemo(() => {\n    if (!configData || !scraperData) return;\n\n    const ret: IScraperSource[] = [];\n\n    ret.push(\n      ...configData.configuration.general.stashBoxes.map((b, i) => {\n        return {\n          id: `${STASH_BOX_PREFIX}${i}`,\n          displayName: `stash-box: ${b.name}`,\n          stash_box_endpoint: b.endpoint,\n        };\n      })\n    );\n\n    const scrapers = scraperData.listScrapers;\n\n    const fragmentScrapers = scrapers.filter((s) =>\n      s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment)\n    );\n\n    ret.push(\n      ...fragmentScrapers.map((s) => {\n        return {\n          id: `${SCRAPER_PREFIX}${s.id}`,\n          displayName: s.name,\n          scraper_id: s.id,\n        };\n      })\n    );\n\n    return ret;\n  }, [configData, scraperData]);\n\n  const selectionStatus = useMemo(() => {\n    if (selectedIds) {\n      return (\n        <Form.Group id=\"selected-identify-ids\">\n          <FormattedMessage\n            id=\"config.tasks.identify.identifying_scenes\"\n            values={{\n              num: selectedIds.length,\n              scene: intl.formatMessage(\n                {\n                  id: \"countables.scenes\",\n                },\n                {\n                  count: selectedIds.length,\n                }\n              ),\n            }}\n          />\n          .\n        </Form.Group>\n      );\n    }\n    const message = paths.length ? (\n      <div>\n        <FormattedMessage id=\"config.tasks.identify.identifying_from_paths\" />:\n        <ul>\n          {paths.map((p) => (\n            <li key={p}>{p}</li>\n          ))}\n        </ul>\n      </div>\n    ) : (\n      <span>\n        <FormattedMessage\n          id=\"config.tasks.identify.identifying_scenes\"\n          values={{\n            num: intl.formatMessage({ id: \"all\" }),\n            scene: intl.formatMessage(\n              {\n                id: \"countables.scenes\",\n              },\n              {\n                count: 0,\n              }\n            ),\n          }}\n        />\n        .\n      </span>\n    );\n\n    function onClick() {\n      setAnimation(false);\n      setSettingPaths(true);\n    }\n\n    return (\n      <Form.Group className=\"dialog-selected-folders\">\n        <div>\n          {message}\n          <div>\n            <Button\n              title={intl.formatMessage({ id: \"actions.select_folders\" })}\n              onClick={() => onClick()}\n            >\n              <Icon icon={faFolderOpen} />\n            </Button>\n          </div>\n        </div>\n      </Form.Group>\n    );\n  }, [selectedIds, intl, paths]);\n\n  useEffect(() => {\n    if (!configData || !allSources) return;\n\n    const { identify: identifyDefaults } = configData.configuration.defaults;\n\n    if (identifyDefaults) {\n      const mappedSources = identifyDefaults.sources\n        .map((s) => {\n          const found = allSources.find(\n            (ss) =>\n              ss.scraper_id === s.source.scraper_id ||\n              ss.stash_box_endpoint === s.source.stash_box_endpoint\n          );\n\n          if (!found) return;\n\n          const ret: IScraperSource = {\n            ...found,\n          };\n\n          if (s.options) {\n            const sourceOptions = withoutTypename(s.options);\n            sourceOptions.fieldOptions =\n              sourceOptions.fieldOptions?.map(withoutTypename);\n            ret.options = sourceOptions;\n          }\n\n          return ret;\n        })\n        .filter((s) => s) as IScraperSource[];\n\n      setSources(mappedSources);\n      if (identifyDefaults.options) {\n        const defaultOptions = withoutTypename(identifyDefaults.options);\n        defaultOptions.fieldOptions =\n          defaultOptions.fieldOptions?.map(withoutTypename);\n        setOptions(defaultOptions);\n      }\n    } else {\n      // default to first stash-box instance only\n      const stashBox = allSources.find((s) => s.stash_box_endpoint);\n\n      // add auto-tag as well\n      const autoTag = allSources.find(\n        (s) => s.id === `${SCRAPER_PREFIX}${autoTagScraperID}`\n      );\n\n      const newSources: IScraperSource[] = [];\n      if (stashBox) {\n        newSources.push(stashBox);\n      }\n\n      // sanity check - this should always be true\n      if (autoTag) {\n        // don't set organised by default\n        const autoTagCopy = { ...autoTag };\n        autoTagCopy.options = {\n          setOrganized: false,\n          skipMultipleMatches: true,\n          skipSingleNamePerformers: true,\n        };\n        newSources.push(autoTagCopy);\n      }\n\n      setSources(newSources);\n    }\n  }, [allSources, configData]);\n\n  if (configError || scraperError)\n    return <div>{configError ?? scraperError}</div>;\n  if (!allSources || !configData) return <div />;\n\n  function makeIdentifyInput(): GQL.IdentifyMetadataInput {\n    return {\n      sources: sources.map((s) => {\n        return {\n          source: {\n            scraper_id: s.scraper_id,\n            stash_box_endpoint: s.stash_box_endpoint,\n          },\n          options: s.options,\n        };\n      }),\n      options,\n      sceneIDs: selectedIds,\n      paths,\n    };\n  }\n\n  function makeDefaultIdentifyInput() {\n    const ret = makeIdentifyInput();\n    const { sceneIDs, paths: _paths, ...withoutSpecifics } = ret;\n    return withoutSpecifics;\n  }\n\n  async function onIdentify() {\n    try {\n      await mutateMetadataIdentify(makeIdentifyInput());\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.identify\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      onClose();\n    }\n  }\n\n  function getAvailableSources() {\n    // only include scrapers not already present\n    return !editingSource?.id === undefined\n      ? []\n      : allSources?.filter((s) => {\n          return !sources.some((ss) => ss.id === s.id);\n        }) ?? [];\n  }\n\n  function onEditSource(s?: IScraperSource) {\n    setAnimation(false);\n\n    // if undefined, then set a dummy source to create a new one\n    if (!s) {\n      setEditingSource(getAvailableSources()[0]);\n    } else {\n      setEditingSource(s);\n    }\n  }\n\n  function onShowManual() {\n    setAnimation(false);\n    setShowManual(true);\n  }\n\n  function isNewSource() {\n    return !!editingSource && !sources.includes(editingSource);\n  }\n\n  function onSaveSource(s?: IScraperSource) {\n    if (s) {\n      let found = false;\n      const newSources = sources.map((ss) => {\n        if (ss.id === s.id) {\n          found = true;\n          return s;\n        }\n        return ss;\n      });\n\n      if (!found) {\n        newSources.push(s);\n      }\n\n      setSources(newSources);\n    }\n    setEditingSource(undefined);\n  }\n\n  async function setAsDefault() {\n    try {\n      setSavingDefaults(true);\n      await configureDefaults({\n        variables: {\n          input: {\n            identify: makeDefaultIdentifyInput(),\n          },\n        },\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.defaults_set\" },\n          { action: intl.formatMessage({ id: \"actions.identify\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setSavingDefaults(false);\n    }\n  }\n\n  if (editingSource) {\n    return (\n      <SourcesEditor\n        availableSources={getAvailableSources()}\n        source={editingSource}\n        saveSource={onSaveSource}\n        isNew={isNewSource()}\n        defaultOptions={options}\n      />\n    );\n  }\n\n  if (settingPaths) {\n    return (\n      <DirectorySelectionDialog\n        animation={false}\n        allowEmpty\n        initialPaths={paths}\n        onClose={(p) => {\n          if (p) {\n            setPaths(p);\n          }\n          setSettingPaths(false);\n        }}\n      />\n    );\n  }\n\n  if (showManual) {\n    return (\n      <Manual\n        animation={false}\n        show\n        onClose={() => setShowManual(false)}\n        defaultActiveTab=\"Identify.md\"\n      />\n    );\n  }\n\n  return (\n    <ModalComponent\n      modalProps={{ animation, size: \"lg\" }}\n      show\n      icon={faCogs}\n      header={intl.formatMessage({ id: \"actions.identify\" })}\n      accept={{\n        onClick: onIdentify,\n        text: intl.formatMessage({ id: \"actions.identify\" }),\n      }}\n      cancel={{\n        onClick: () => onClose(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      disabled={editingField || savingDefaults || sources.length === 0}\n      footerButtons={\n        <OperationButton\n          variant=\"secondary\"\n          disabled={editingField || savingDefaults}\n          operation={setAsDefault}\n        >\n          <FormattedMessage id=\"actions.set_as_default\" />\n        </OperationButton>\n      }\n      leftFooterButtons={\n        <Button\n          title=\"Help\"\n          className=\"minimal help-button\"\n          onClick={() => onShowManual()}\n        >\n          <Icon icon={faQuestionCircle} />\n        </Button>\n      }\n    >\n      <Form>\n        {selectionStatus}\n        <SourcesList\n          sources={sources}\n          setSources={(s) => setSources(s)}\n          editSource={onEditSource}\n          canAdd={sources.length < allSources.length}\n        />\n        <OptionsEditor\n          options={options}\n          setOptions={(o) => setOptions(o)}\n          setEditingField={(v) => setEditingField(v)}\n        />\n      </Form>\n    </ModalComponent>\n  );\n};\n\nexport default IdentifyDialog;\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx",
    "content": "import React from \"react\";\nimport { Col, Form, Row } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { IScraperSource } from \"./constants\";\nimport { FieldOptionsList } from \"./FieldOptions\";\nimport { ThreeStateBoolean } from \"./ThreeStateBoolean\";\nimport { TagSelect } from \"src/components/Shared/Select\";\nimport { genderList } from \"src/utils/gender\";\n\ninterface IOptionsEditor {\n  options: GQL.IdentifyMetadataOptionsInput;\n  setOptions: (s: GQL.IdentifyMetadataOptionsInput) => void;\n  source?: IScraperSource;\n  defaultOptions?: GQL.IdentifyMetadataOptionsInput;\n  setEditingField: (v: boolean) => void;\n}\n\nexport const OptionsEditor: React.FC<IOptionsEditor> = ({\n  options,\n  setOptions: setOptionsState,\n  source,\n  setEditingField,\n  defaultOptions,\n}) => {\n  const intl = useIntl();\n\n  function setOptions(v: Partial<GQL.IdentifyMetadataOptionsInput>) {\n    setOptionsState({ ...options, ...v });\n  }\n\n  const headingID = !source\n    ? \"config.tasks.identify.default_options\"\n    : \"config.tasks.identify.source_options\";\n  const checkboxProps = {\n    allowUndefined: !!source,\n    indeterminateClassname: \"text-muted\",\n  };\n\n  function maybeRenderMultipleMatchesTag() {\n    if (!options.skipMultipleMatches) {\n      return;\n    }\n\n    return (\n      <Form.Group controlId=\"match_tags\" className=\"ml-3 mt-1 mb-0\" as={Row}>\n        <Form.Label\n          column\n          sm={{ span: 4, offset: 1 }}\n          title={intl.formatMessage({\n            id: \"config.tasks.identify.tag_skipped_matches_tooltip\",\n          })}\n        >\n          <FormattedMessage id=\"config.tasks.identify.tag_skipped_matches\" />\n        </Form.Label>\n        <Col sm>\n          <TagSelect\n            onSelect={(tags) =>\n              setOptions({\n                skipMultipleMatchTag: tags[0]?.id,\n              })\n            }\n            ids={\n              options.skipMultipleMatchTag ? [options.skipMultipleMatchTag] : []\n            }\n            noSelectionString=\"Select/create tag...\"\n            menuPortalTarget={document.body}\n          />\n        </Col>\n      </Form.Group>\n    );\n  }\n\n  function maybeRenderPerformersTag() {\n    if (!options.skipSingleNamePerformers) {\n      return;\n    }\n\n    return (\n      <Form.Group controlId=\"match_tags\" className=\"ml-3 mt-1 mb-0\" as={Row}>\n        <Form.Label\n          column\n          sm={{ span: 4, offset: 1 }}\n          title={intl.formatMessage({\n            id: \"config.tasks.identify.tag_skipped_performer_tooltip\",\n          })}\n        >\n          <FormattedMessage id=\"config.tasks.identify.tag_skipped_performers\" />\n        </Form.Label>\n        <Col sm>\n          <TagSelect\n            onSelect={(tags) =>\n              setOptions({\n                skipSingleNamePerformerTag: tags[0]?.id,\n              })\n            }\n            ids={\n              options.skipSingleNamePerformerTag\n                ? [options.skipSingleNamePerformerTag]\n                : []\n            }\n            noSelectionString=\"Select/create tag...\"\n            menuPortalTarget={document.body}\n          />\n        </Col>\n      </Form.Group>\n    );\n  }\n\n  return (\n    <Form.Group className=\"mb-0\">\n      <Form.Group>\n        <h5>\n          <FormattedMessage\n            id={headingID}\n            values={{ source: source?.displayName }}\n          />\n        </h5>\n        {!source && (\n          <Form.Text className=\"text-muted\">\n            {intl.formatMessage({\n              id: \"config.tasks.identify.explicit_set_description\",\n            })}\n          </Form.Text>\n        )}\n      </Form.Group>\n      <Form.Group className=\"mb-0\">\n        <Form.Group controlId=\"performer-genders\" className=\"mb-2\">\n          <Form.Label>\n            <FormattedMessage id=\"config.tasks.identify.performer_genders\" />\n          </Form.Label>\n          {source && (\n            <Form.Check\n              id=\"performer-genders-use-default\"\n              label={intl.formatMessage({ id: \"actions.use_default\" })}\n              checked={options.performerGenders == null}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n                if (e.currentTarget.checked) {\n                  setOptions({ performerGenders: undefined });\n                } else {\n                  setOptions({\n                    performerGenders:\n                      defaultOptions?.performerGenders ?? genderList.slice(),\n                  });\n                }\n              }}\n            />\n          )}\n          {(options.performerGenders != null || !source) &&\n            genderList.map((gender) => {\n              const performerGenders =\n                options.performerGenders ?? genderList.slice();\n              return (\n                <Form.Check\n                  id={`identify-gender-${gender}`}\n                  key={gender}\n                  label={<FormattedMessage id={`gender_types.${gender}`} />}\n                  checked={performerGenders.includes(gender)}\n                  onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n                    const isChecked = e.currentTarget.checked;\n                    setOptions({\n                      performerGenders: isChecked\n                        ? [...performerGenders, gender]\n                        : performerGenders.filter((g) => g !== gender),\n                    });\n                  }}\n                />\n              );\n            })}\n          <Form.Text className=\"text-muted\">\n            <FormattedMessage id=\"config.tasks.identify.performer_genders_desc\" />\n          </Form.Text>\n        </Form.Group>\n        <ThreeStateBoolean\n          id=\"set-cover-image\"\n          value={\n            options.setCoverImage === null ? undefined : options.setCoverImage\n          }\n          setValue={(v) =>\n            setOptions({\n              setCoverImage: v,\n            })\n          }\n          label={intl.formatMessage({\n            id: \"config.tasks.identify.set_cover_images\",\n          })}\n          defaultValue={defaultOptions?.setCoverImage ?? undefined}\n          {...checkboxProps}\n        />\n        <ThreeStateBoolean\n          id=\"set-organized\"\n          value={\n            options.setOrganized === null ? undefined : options.setOrganized\n          }\n          setValue={(v) =>\n            setOptions({\n              setOrganized: v,\n            })\n          }\n          label={intl.formatMessage({\n            id: \"config.tasks.identify.set_organized\",\n          })}\n          defaultValue={defaultOptions?.setOrganized ?? undefined}\n          {...checkboxProps}\n        />\n      </Form.Group>\n      <ThreeStateBoolean\n        id=\"skip-multiple-match\"\n        value={\n          options.skipMultipleMatches === null\n            ? undefined\n            : options.skipMultipleMatches\n        }\n        setValue={(v) =>\n          setOptions({\n            skipMultipleMatches: v,\n          })\n        }\n        label={intl.formatMessage({\n          id: \"config.tasks.identify.skip_multiple_matches\",\n        })}\n        defaultValue={defaultOptions?.skipMultipleMatches ?? undefined}\n        tooltip={intl.formatMessage({\n          id: \"config.tasks.identify.skip_multiple_matches_tooltip\",\n        })}\n        {...checkboxProps}\n      />\n      {maybeRenderMultipleMatchesTag()}\n      <ThreeStateBoolean\n        id=\"skip-single-name-performers\"\n        value={\n          options.skipSingleNamePerformers === null\n            ? undefined\n            : options.skipSingleNamePerformers\n        }\n        setValue={(v) =>\n          setOptions({\n            skipSingleNamePerformers: v,\n          })\n        }\n        label={intl.formatMessage({\n          id: \"config.tasks.identify.skip_single_name_performers\",\n        })}\n        defaultValue={defaultOptions?.skipSingleNamePerformers ?? undefined}\n        tooltip={intl.formatMessage({\n          id: \"config.tasks.identify.skip_single_name_performers_tooltip\",\n        })}\n        {...checkboxProps}\n      />\n      {maybeRenderPerformersTag()}\n\n      <FieldOptionsList\n        fieldOptions={options.fieldOptions ?? undefined}\n        setFieldOptions={(o) => setOptions({ fieldOptions: o })}\n        setEditingField={setEditingField}\n        allowSetDefault={!!source}\n        defaultOptions={defaultOptions}\n      />\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/IdentifyDialog/Sources.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { Form, Button, ListGroup } from \"react-bootstrap\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { IScraperSource } from \"./constants\";\nimport { OptionsEditor } from \"./Options\";\nimport {\n  faCog,\n  faGripVertical,\n  faMinus,\n  faPencilAlt,\n  faPlus,\n} from \"@fortawesome/free-solid-svg-icons\";\n\ninterface ISourceEditor {\n  isNew: boolean;\n  availableSources: IScraperSource[];\n  source: IScraperSource;\n  saveSource: (s?: IScraperSource) => void;\n  defaultOptions: GQL.IdentifyMetadataOptionsInput;\n}\n\nexport const SourcesEditor: React.FC<ISourceEditor> = ({\n  isNew,\n  availableSources,\n  source: initialSource,\n  saveSource,\n  defaultOptions,\n}) => {\n  const [source, setSource] = useState<IScraperSource>(initialSource);\n  const [editingField, setEditingField] = useState(false);\n\n  const intl = useIntl();\n\n  // if id is empty, then we are adding a new source\n  const headerMsgId = isNew ? \"actions.add\" : \"dialogs.edit_entity_title\";\n  const acceptMsgId = isNew ? \"actions.add\" : \"actions.confirm\";\n\n  function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {\n    const selectedSource = availableSources.find(\n      (s) => s.id === e.currentTarget.value\n    );\n    if (!selectedSource) return;\n\n    setSource({\n      ...source,\n      id: selectedSource.id,\n      displayName: selectedSource.displayName,\n      scraper_id: selectedSource.scraper_id,\n      stash_box_endpoint: selectedSource.stash_box_endpoint,\n    });\n  }\n\n  return (\n    <ModalComponent\n      dialogClassName=\"identify-source-editor\"\n      modalProps={{ animation: false, size: \"lg\" }}\n      show\n      icon={isNew ? faPlus : faPencilAlt}\n      header={intl.formatMessage(\n        { id: headerMsgId },\n        {\n          count: 1,\n          singularEntity: source?.displayName,\n          pluralEntity: source?.displayName,\n        }\n      )}\n      accept={{\n        onClick: () => saveSource(source),\n        text: intl.formatMessage({ id: acceptMsgId }),\n      }}\n      cancel={{\n        onClick: () => saveSource(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      disabled={\n        (!source.scraper_id && !source.stash_box_endpoint) || editingField\n      }\n    >\n      <Form>\n        {isNew && (\n          <Form.Group>\n            <h5>\n              <FormattedMessage id=\"config.tasks.identify.source\" />\n            </h5>\n            <Form.Control\n              as=\"select\"\n              value={source.id}\n              className=\"input-control\"\n              onChange={handleSourceSelect}\n            >\n              {availableSources.map((i) => (\n                <option value={i.id} key={i.id}>\n                  {i.displayName}\n                </option>\n              ))}\n            </Form.Control>\n          </Form.Group>\n        )}\n        <OptionsEditor\n          options={source.options ?? {}}\n          setOptions={(o) => setSource({ ...source, options: o })}\n          source={source}\n          setEditingField={(v) => setEditingField(v)}\n          defaultOptions={defaultOptions}\n        />\n      </Form>\n    </ModalComponent>\n  );\n};\n\ninterface ISourcesList {\n  sources: IScraperSource[];\n  setSources: (s: IScraperSource[]) => void;\n  editSource: (s?: IScraperSource) => void;\n  canAdd: boolean;\n}\n\nexport const SourcesList: React.FC<ISourcesList> = ({\n  sources,\n  setSources,\n  editSource,\n  canAdd,\n}) => {\n  const [tempSources, setTempSources] = useState(sources);\n  const [dragIndex, setDragIndex] = useState<number | undefined>();\n  const [mouseOverIndex, setMouseOverIndex] = useState<number | undefined>();\n\n  useEffect(() => {\n    setTempSources([...sources]);\n  }, [sources]);\n\n  function removeSource(index: number) {\n    const newSources = [...sources];\n    newSources.splice(index, 1);\n    setSources(newSources);\n  }\n\n  function onDragStart(event: React.DragEvent<HTMLElement>, index: number) {\n    event.dataTransfer.effectAllowed = \"move\";\n    setDragIndex(index);\n  }\n\n  function onDragOver(event: React.DragEvent<HTMLElement>, index?: number) {\n    if (dragIndex !== undefined && index !== undefined && index !== dragIndex) {\n      const newSources = [...tempSources];\n      const moved = newSources.splice(dragIndex, 1);\n      newSources.splice(index, 0, moved[0]);\n      setTempSources(newSources);\n      setDragIndex(index);\n    }\n\n    event.dataTransfer.dropEffect = \"move\";\n    event.preventDefault();\n  }\n\n  function onDragOverDefault(event: React.DragEvent<HTMLDivElement>) {\n    event.dataTransfer.dropEffect = \"move\";\n    event.preventDefault();\n  }\n\n  function onDrop() {\n    // assume we've already set the temp source list\n    // feed it up\n    setSources(tempSources);\n    setDragIndex(undefined);\n    setMouseOverIndex(undefined);\n  }\n\n  return (\n    <Form.Group className=\"scraper-sources\" onDragOver={onDragOverDefault}>\n      <h5>\n        <FormattedMessage id=\"config.tasks.identify.sources\" />\n      </h5>\n      <ListGroup as=\"ul\" className=\"scraper-source-list\">\n        {tempSources.map((s, index) => (\n          <ListGroup.Item\n            as=\"li\"\n            key={s.id}\n            className=\"d-flex justify-content-between align-items-center\"\n            draggable={mouseOverIndex === index}\n            onDragStart={(e) => onDragStart(e, index)}\n            onDragEnter={(e) => onDragOver(e, index)}\n            onDrop={() => onDrop()}\n          >\n            <div>\n              <div\n                className=\"minimal text-muted drag-handle\"\n                onMouseEnter={() => setMouseOverIndex(index)}\n                onMouseLeave={() => setMouseOverIndex(undefined)}\n              >\n                <Icon icon={faGripVertical} />\n              </div>\n              {s.displayName}\n            </div>\n            <div>\n              <Button className=\"minimal\" onClick={() => editSource(s)}>\n                <Icon icon={faCog} />\n              </Button>\n              <Button\n                className=\"minimal text-danger\"\n                onClick={() => removeSource(index)}\n              >\n                <Icon icon={faMinus} />\n              </Button>\n            </div>\n          </ListGroup.Item>\n        ))}\n      </ListGroup>\n      {canAdd && (\n        <div className=\"text-right\">\n          <Button\n            className=\"minimal add-scraper-source-button\"\n            onClick={() => editSource()}\n          >\n            <Icon icon={faPlus} />\n          </Button>\n        </div>\n      )}\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/IdentifyDialog/ThreeStateBoolean.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\n\ninterface IThreeStateBoolean {\n  id: string;\n  value: boolean | undefined;\n  setValue: (v: boolean | undefined) => void;\n  allowUndefined?: boolean;\n  label?: React.ReactNode;\n  disabled?: boolean;\n  defaultValue?: boolean;\n  tooltip?: string | undefined;\n}\n\nexport const ThreeStateBoolean: React.FC<IThreeStateBoolean> = ({\n  id,\n  value,\n  setValue,\n  allowUndefined = true,\n  label,\n  disabled,\n  defaultValue,\n  tooltip,\n}) => {\n  const intl = useIntl();\n\n  if (!allowUndefined) {\n    return (\n      <Form.Check\n        id={id}\n        disabled={disabled}\n        checked={value}\n        label={label}\n        onChange={() => setValue(!value)}\n        title={tooltip}\n      />\n    );\n  }\n\n  function getBooleanText(v: boolean) {\n    if (v) {\n      return intl.formatMessage({ id: \"true\" });\n    }\n    return intl.formatMessage({ id: \"false\" });\n  }\n\n  function getButtonText(v: boolean | undefined) {\n    if (v === undefined) {\n      const defaultVal =\n        defaultValue !== undefined ? (\n          <span className=\"default-value\">\n            {\" \"}\n            ({getBooleanText(defaultValue)})\n          </span>\n        ) : (\n          \"\"\n        );\n      return (\n        <span>\n          {intl.formatMessage({ id: \"actions.use_default\" })}\n          {defaultVal}\n        </span>\n      );\n    }\n\n    return getBooleanText(v);\n  }\n\n  function renderModeButton(v: boolean | undefined) {\n    return (\n      <Form.Check\n        type=\"radio\"\n        id={`${id}-value-${v ?? \"undefined\"}`}\n        checked={value === v}\n        onChange={() => setValue(v)}\n        disabled={disabled}\n        label={getButtonText(v)}\n      />\n    );\n  }\n\n  return (\n    <Form.Group>\n      <h6 title={tooltip}>{label}</h6>\n      <Form.Group>\n        {renderModeButton(undefined)}\n        {renderModeButton(false)}\n        {renderModeButton(true)}\n      </Form.Group>\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\n\nexport interface IScraperSource {\n  id: string;\n  displayName: string;\n  stash_box_endpoint?: string;\n  scraper_id?: string;\n  options?: GQL.IdentifyMetadataOptionsInput;\n}\n\nexport const sceneFields = [\n  \"title\",\n  \"code\",\n  \"date\",\n  \"director\",\n  \"details\",\n  \"url\",\n  \"studio\",\n  \"performers\",\n  \"tags\",\n  \"stash_ids\",\n] as const;\nexport type SceneField = (typeof sceneFields)[number];\n\nexport const multiValueSceneFields: SceneField[] = [\n  \"studio\",\n  \"performers\",\n  \"tags\",\n];\n\nexport function sceneFieldMessageID(field: SceneField) {\n  if (field === \"code\") {\n    return \"scene_code\";\n  } else if (field === \"studio\") {\n    return \"studio_and_parent\";\n  }\n\n  return field;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/IdentifyDialog/styles.scss",
    "content": ".identify-source-editor {\n  .default-value {\n    color: #bfccd6;\n  }\n}\n\n.scraper-source-list {\n  .list-group-item {\n    background-color: $textfield-bg;\n    padding: 0.25em;\n\n    .drag-handle {\n      cursor: move;\n      display: inline-block;\n      margin: -0.25em 0.25em -0.25em -0.25em;\n      padding: 0.25em 0.5em 0.25em;\n    }\n\n    .drag-handle:hover,\n    .drag-handle:active,\n    .drag-handle:focus,\n    .drag-handle:focus:active {\n      background-color: initial;\n      border-color: initial;\n      box-shadow: initial;\n    }\n  }\n}\n\n.scraper-sources {\n  .add-scraper-source-button {\n    margin-right: 0.25em;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/ReleaseNotesDialog.tsx",
    "content": "import React from \"react\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { faCogs } from \"@fortawesome/free-solid-svg-icons\";\nimport { useIntl } from \"react-intl\";\nimport { MarkdownPage } from \"../Shared/MarkdownPage\";\nimport { IReleaseNotes } from \"src/docs/en/ReleaseNotes\";\n\ninterface IReleaseNotesDialog {\n  notes: IReleaseNotes[];\n  onClose: () => void;\n}\n\nexport const ReleaseNotesDialog: React.FC<IReleaseNotesDialog> = ({\n  notes,\n  onClose,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <ModalComponent\n      show\n      icon={faCogs}\n      header={intl.formatMessage({ id: \"release_notes\" })}\n      accept={{\n        onClick: onClose,\n        text: intl.formatMessage({ id: \"actions.close\" }),\n      }}\n    >\n      <div className=\"m-n3\">\n        {notes\n          .map((n, i) => (\n            <div key={i} className=\"m-3\">\n              <h3>{n.version}</h3>\n              <MarkdownPage page={n.content} />\n            </div>\n          ))\n          .reduce((accu, curr) => (\n            <>\n              {accu}\n              <hr />\n              {curr}\n            </>\n          ))}\n      </div>\n    </ModalComponent>\n  );\n};\n\nexport default ReleaseNotesDialog;\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/SubmitDraft.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  mutateSubmitStashBoxPerformerDraft,\n  mutateSubmitStashBoxSceneDraft,\n} from \"src/core/StashService\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { getStashboxBase } from \"src/utils/stashbox\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { faPaperPlane } from \"@fortawesome/free-solid-svg-icons\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\n\ninterface IProps {\n  type: \"scene\" | \"performer\";\n  entity: Pick<\n    GQL.SceneDataFragment | GQL.PerformerDataFragment,\n    \"id\" | \"stash_ids\"\n  >;\n  boxes: Pick<GQL.StashBox, \"name\" | \"endpoint\">[];\n  show: boolean;\n  onHide: () => void;\n}\n\nexport const SubmitStashBoxDraft: React.FC<IProps> = ({\n  type,\n  boxes,\n  entity,\n  show,\n  onHide,\n}) => {\n  const intl = useIntl();\n\n  const [selectedBoxIndex, setSelectedBoxIndex] = useState(0);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<string>();\n  const [reviewUrl, setReviewUrl] = useState<string>();\n\n  // this can be undefined, if e.g. boxes is empty\n  // since we aren't using noUncheckedIndexedAccess, add undefined explicitly\n  const selectedBox: (typeof boxes)[number] | undefined =\n    boxes[selectedBoxIndex];\n\n  // #4354: reset state when shown, or if any props change\n  useEffect(() => {\n    if (show) {\n      setSelectedBoxIndex(0);\n      setLoading(false);\n      setError(undefined);\n      setReviewUrl(undefined);\n    }\n  }, [show, type, boxes, entity]);\n\n  async function doSubmit() {\n    if (!selectedBox) return;\n\n    const input = {\n      id: entity.id,\n      stash_box_endpoint: selectedBox.endpoint,\n    };\n\n    if (type === \"scene\") {\n      const r = await mutateSubmitStashBoxSceneDraft(input);\n      return r.data?.submitStashBoxSceneDraft;\n    } else if (type === \"performer\") {\n      const r = await mutateSubmitStashBoxPerformerDraft(input);\n      return r.data?.submitStashBoxPerformerDraft;\n    }\n  }\n\n  async function onSubmit() {\n    if (!selectedBox) return;\n\n    try {\n      setLoading(true);\n      const responseId = await doSubmit();\n\n      const stashboxBase = getStashboxBase(selectedBox.endpoint);\n      if (responseId) {\n        setReviewUrl(`${stashboxBase}drafts/${responseId}`);\n      } else {\n        // if the mutation returned a null id but didn't error, then just link to the drafts page\n        setReviewUrl(`${stashboxBase}drafts`);\n      }\n    } catch (e) {\n      if (e instanceof Error && e.message) {\n        setError(e.message);\n      } else {\n        setError(String(e));\n      }\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  function renderContents() {\n    if (error !== undefined) {\n      return (\n        <>\n          <h6 className=\"mt-2\">\n            <FormattedMessage id=\"stashbox.submission_failed\" />\n          </h6>\n          <div>{error}</div>\n        </>\n      );\n    } else if (reviewUrl !== undefined) {\n      return (\n        <>\n          <h6>\n            <FormattedMessage id=\"stashbox.submission_successful\" />\n          </h6>\n          <div>\n            <ExternalLink href={reviewUrl}>\n              <FormattedMessage\n                id=\"stashbox.go_review_draft\"\n                values={{ endpoint_name: selectedBox?.name }}\n              />\n            </ExternalLink>\n          </div>\n        </>\n      );\n    } else {\n      return (\n        <Form.Group className=\"form-row align-items-end\">\n          <Form.Label className=\"col-6\">\n            <FormattedMessage id=\"stashbox.selected_stash_box\" />:\n          </Form.Label>\n          <Form.Control\n            as=\"select\"\n            onChange={(e) => setSelectedBoxIndex(Number(e.currentTarget.value))}\n            value={selectedBoxIndex}\n            className=\"col-6 input-control\"\n          >\n            {boxes.map((box, i) => (\n              <option value={i} key={`${box.endpoint}-${i}`}>\n                {box.name}\n              </option>\n            ))}\n          </Form.Control>\n        </Form.Group>\n      );\n    }\n  }\n\n  function getFooterProps() {\n    if (error !== undefined || reviewUrl !== undefined) {\n      return {\n        accept: {\n          onClick: () => onHide(),\n        },\n      };\n    }\n\n    // If the scene has an attached stash_id from that endpoint, the operation will be an update\n    const isUpdate =\n      entity.stash_ids.find((id) => id.endpoint === selectedBox?.endpoint) !==\n      undefined;\n\n    return {\n      footerButtons: isUpdate && !loading && (\n        <span className=\"mr-2 align-middle\">\n          <FormattedMessage\n            id=\"stashbox.submit_update\"\n            values={{ endpoint_name: selectedBox?.name }}\n          />\n        </span>\n      ),\n      accept: {\n        onClick: () => onSubmit(),\n        text: intl.formatMessage({\n          id: isUpdate ? \"actions.submit_update\" : \"actions.submit\",\n        }),\n        variant: isUpdate ? \"primary\" : \"success\",\n      },\n      cancel: {\n        onClick: () => onHide(),\n        variant: \"secondary\",\n      },\n    };\n  }\n\n  return (\n    <ModalComponent\n      icon={faPaperPlane}\n      header={intl.formatMessage({ id: \"actions.submit_stash_box\" })}\n      isRunning={loading}\n      show={show}\n      onHide={onHide}\n      disabled={!selectedBox}\n      {...getFooterProps()}\n    >\n      {renderContents()}\n    </ModalComponent>\n  );\n};\n\nexport default SubmitStashBoxDraft;\n"
  },
  {
    "path": "ui/v2.5/src/components/Dialogs/styles.scss",
    "content": "@import \"IdentifyDialog/styles.scss\";\n\n.dialog-selected-folders {\n  & > div {\n    display: flex;\n    justify-content: space-between;\n  }\n}\n\n.form-group {\n  h6,\n  label {\n    &[title]:not([title=\"\"]) {\n      cursor: help;\n      text-decoration: underline dotted;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/ErrorBoundary.tsx",
    "content": "import React from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport { isLazyComponentError } from \"src/utils/lazyComponent\";\n\ninterface IErrorBoundaryProps {\n  children?: React.ReactNode;\n}\n\ntype ErrorInfo = {\n  componentStack: string;\n};\n\ninterface IErrorBoundaryState {\n  error?: Error;\n  errorHelpId?: string;\n  errorInfo?: ErrorInfo;\n}\n\nexport class ErrorBoundary extends React.Component<\n  IErrorBoundaryProps,\n  IErrorBoundaryState\n> {\n  constructor(props: IErrorBoundaryProps) {\n    super(props);\n    this.state = {};\n  }\n\n  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {\n    let errorHelpId: string | undefined;\n    if (isLazyComponentError(error)) {\n      errorHelpId = \"errors.lazy_component_error_help\";\n    }\n    this.setState({\n      error,\n      errorHelpId,\n      errorInfo,\n    });\n  }\n\n  public render() {\n    const { error, errorHelpId, errorInfo } = this.state;\n    if (errorInfo) {\n      // Error path\n      return (\n        <div>\n          <h2>\n            <FormattedMessage id=\"errors.something_went_wrong\" />\n          </h2>\n          {errorHelpId && (\n            <h5>\n              <FormattedMessage id={errorHelpId} />\n            </h5>\n          )}\n          <details className=\"error-message\">\n            {error?.toString()}\n            <br />\n            {errorInfo.componentStack.trim().replaceAll(/^\\s*/gm, \"    \")}\n          </details>\n        </div>\n      );\n    }\n\n    // Normally, just render children\n    return this.props.children;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/FrontPage/Control.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { FrontPageContent, ICustomFilter } from \"src/core/config\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useFindSavedFilter } from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { GalleryRecommendationRow } from \"../Galleries/GalleryRecommendationRow\";\nimport { ImageRecommendationRow } from \"../Images/ImageRecommendationRow\";\nimport { GroupRecommendationRow } from \"../Groups/GroupRecommendationRow\";\nimport { PerformerRecommendationRow } from \"../Performers/PerformerRecommendationRow\";\nimport { SceneRecommendationRow } from \"../Scenes/SceneRecommendationRow\";\nimport { StudioRecommendationRow } from \"../Studios/StudioRecommendationRow\";\nimport { TagRecommendationRow } from \"../Tags/TagRecommendationRow\";\nimport { SceneMarkerRecommendationRow } from \"../Scenes/SceneMarkerRecommendationRow\";\n\ninterface IFilter {\n  mode: GQL.FilterMode;\n  filter: ListFilterModel;\n  header: string;\n}\n\nconst RecommendationRow: React.FC<IFilter> = ({ mode, filter, header }) => {\n  function isTouchEnabled() {\n    return \"ontouchstart\" in window || navigator.maxTouchPoints > 0;\n  }\n\n  const isTouch = isTouchEnabled();\n\n  switch (mode) {\n    case GQL.FilterMode.Scenes:\n      return (\n        <SceneRecommendationRow\n          isTouch={isTouch}\n          filter={filter}\n          header={header}\n        />\n      );\n    case GQL.FilterMode.Studios:\n      return (\n        <StudioRecommendationRow\n          isTouch={isTouch}\n          filter={filter}\n          header={header}\n        />\n      );\n    case GQL.FilterMode.Movies:\n    case GQL.FilterMode.Groups:\n      return (\n        <GroupRecommendationRow\n          isTouch={isTouch}\n          filter={filter}\n          header={header}\n        />\n      );\n    case GQL.FilterMode.Performers:\n      return (\n        <PerformerRecommendationRow\n          isTouch={isTouch}\n          filter={filter}\n          header={header}\n        />\n      );\n    case GQL.FilterMode.Galleries:\n      return (\n        <GalleryRecommendationRow\n          isTouch={isTouch}\n          filter={filter}\n          header={header}\n        />\n      );\n    case GQL.FilterMode.Images:\n      return (\n        <ImageRecommendationRow\n          isTouch={isTouch}\n          filter={filter}\n          header={header}\n        />\n      );\n    case GQL.FilterMode.Tags:\n      return (\n        <TagRecommendationRow\n          isTouch={isTouch}\n          filter={filter}\n          header={header}\n        />\n      );\n    case GQL.FilterMode.SceneMarkers:\n      return (\n        <SceneMarkerRecommendationRow\n          isTouch={isTouch}\n          filter={filter}\n          header={header}\n        />\n      );\n    default:\n      return <></>;\n  }\n};\n\ninterface ISavedFilterResults {\n  savedFilterID: string;\n}\n\nconst SavedFilterResults: React.FC<ISavedFilterResults> = ({\n  savedFilterID,\n}) => {\n  const { configuration: config } = useConfigurationContext();\n  const { loading, data } = useFindSavedFilter(savedFilterID.toString());\n\n  const filter = useMemo(() => {\n    if (!data?.findSavedFilter) return;\n\n    const { mode } = data.findSavedFilter;\n\n    const ret = new ListFilterModel(mode, config);\n    ret.currentPage = 1;\n    ret.configureFromSavedFilter(data.findSavedFilter);\n    ret.randomSeed = -1;\n    return ret;\n  }, [data?.findSavedFilter, config]);\n\n  if (loading || !data?.findSavedFilter || !filter) {\n    return <></>;\n  }\n\n  const { name, mode } = data.findSavedFilter;\n\n  return <RecommendationRow mode={mode} filter={filter} header={name} />;\n};\n\ninterface ICustomFilterProps {\n  customFilter: ICustomFilter;\n}\n\nconst CustomFilterResults: React.FC<ICustomFilterProps> = ({\n  customFilter,\n}) => {\n  const { configuration: config } = useConfigurationContext();\n  const intl = useIntl();\n\n  const filter = useMemo(() => {\n    const itemsPerPage = 25;\n    const ret = new ListFilterModel(customFilter.mode, config);\n    ret.sortBy = customFilter.sortBy;\n    ret.sortDirection = customFilter.direction;\n    ret.itemsPerPage = itemsPerPage;\n    ret.currentPage = 1;\n    ret.randomSeed = -1;\n    return ret;\n  }, [customFilter, config]);\n\n  const header = customFilter.message\n    ? intl.formatMessage(\n        { id: customFilter.message.id },\n        customFilter.message.values\n      )\n    : customFilter.title ?? \"\";\n\n  return (\n    <RecommendationRow\n      mode={customFilter.mode}\n      filter={filter}\n      header={header}\n    />\n  );\n};\n\ninterface IProps {\n  content: FrontPageContent;\n}\n\nexport const Control: React.FC<IProps> = ({ content }) => {\n  switch (content.__typename) {\n    case \"SavedFilter\":\n      if (!content.savedFilterId) {\n        return <div>Error: missing savedFilterId</div>;\n      }\n\n      return (\n        <SavedFilterResults savedFilterID={content.savedFilterId.toString()} />\n      );\n    case \"CustomFilter\":\n      return <CustomFilterResults customFilter={content} />;\n    default:\n      return <></>;\n  }\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx",
    "content": "import React from \"react\";\nimport { Link } from \"react-router-dom\";\nimport Slider from \"@ant-design/react-slick\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { getSlickSliderSettings } from \"src/core/recommendations\";\nimport { RecommendationRow } from \"../FrontPage/RecommendationRow\";\nimport { FormattedMessage } from \"react-intl\";\nimport { PatchComponent } from \"src/patch\";\nimport { UnsupportedCriterion } from \"src/models/list-filter/criteria/criterion\";\nimport { PopoverCard, WarningHoverPopover } from \"../Shared/HoverPopover\";\n\ninterface IProps {\n  className?: string;\n  isTouch: boolean;\n  filter: ListFilterModel;\n  heading: string;\n  count: number;\n  loading: boolean;\n  url: string;\n}\n\nexport const FilteredRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"FilteredRecommendationRow\",\n  (props) => {\n    const cardCount = props.count;\n\n    const unsupportedCriteria = props.filter.criteria.filter(\n      (criterion) => criterion instanceof UnsupportedCriterion\n    );\n\n    const header = unsupportedCriteria.length ? (\n      <div>\n        <span>{props.heading}</span>\n        <WarningHoverPopover\n          placement=\"top\"\n          content={\n            <PopoverCard>\n              <FormattedMessage\n                id=\"unsupported_criteria\"\n                values={{\n                  criteria: unsupportedCriteria\n                    .map((c) => c.criterionOption.type)\n                    .join(\", \"),\n                }}\n              />\n            </PopoverCard>\n          }\n        />\n      </div>\n    ) : (\n      props.heading\n    );\n\n    if (!props.loading && !cardCount) {\n      return null;\n    }\n\n    return (\n      <RecommendationRow\n        className={props.className}\n        header={header}\n        link={\n          <Link to={props.url}>\n            <FormattedMessage id=\"view_all\" />\n          </Link>\n        }\n      >\n        <Slider\n          {...getSlickSliderSettings(\n            cardCount ? cardCount : props.filter.itemsPerPage,\n            props.isTouch\n          )}\n        >\n          {props.children}\n        </Slider>\n      </RecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/FrontPage/FrontPage.tsx",
    "content": "import React, { useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useConfigureUI } from \"src/core/StashService\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { Button } from \"react-bootstrap\";\nimport { FrontPageConfig } from \"./FrontPageConfig\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { Control } from \"./Control\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport {\n  FrontPageContent,\n  generateDefaultFrontPageContent,\n  getFrontPageContent,\n} from \"src/core/config\";\nimport { useScrollToTopOnMount } from \"src/hooks/scrollToTop\";\nimport { PatchComponent } from \"src/patch\";\n\nconst FrontPage: React.FC = PatchComponent(\"FrontPage\", () => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [isEditing, setIsEditing] = useState(false);\n  const [saving, setSaving] = useState(false);\n\n  const [saveUI] = useConfigureUI();\n\n  const { configuration } = useConfigurationContext();\n\n  useScrollToTopOnMount();\n\n  async function onUpdateConfig(content?: FrontPageContent[]) {\n    setIsEditing(false);\n\n    if (!content) {\n      return;\n    }\n\n    setSaving(true);\n    try {\n      await saveUI({\n        variables: {\n          input: {\n            ...configuration?.ui,\n            frontPageContent: content,\n          },\n        },\n      });\n    } catch (e) {\n      Toast.error(e);\n    }\n    setSaving(false);\n  }\n\n  if (saving) {\n    return <LoadingIndicator />;\n  }\n\n  if (isEditing) {\n    return <FrontPageConfig onClose={(content) => onUpdateConfig(content)} />;\n  }\n\n  const ui = configuration?.ui ?? {};\n\n  if (!ui.frontPageContent) {\n    const defaultContent = generateDefaultFrontPageContent(intl);\n    onUpdateConfig(defaultContent);\n  }\n\n  const frontPageContent = getFrontPageContent(ui);\n\n  return (\n    <div className=\"recommendations-container\">\n      <div>\n        {frontPageContent?.map((content, i) => (\n          <Control key={i} content={content} />\n        ))}\n      </div>\n      <div className=\"recommendations-footer\">\n        <Button onClick={() => setIsEditing(true)}>\n          <FormattedMessage id={\"actions.customise\"} />\n        </Button>\n      </div>\n    </div>\n  );\n});\n\nexport default FrontPage;\n"
  },
  {
    "path": "ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { FormattedMessage, IntlShape, useIntl } from \"react-intl\";\nimport { useFindSavedFilters } from \"src/core/StashService\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { Button, Form, Modal } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport {\n  ISavedFilterRow,\n  ICustomFilter,\n  FrontPageContent,\n  generatePremadeFrontPageContent,\n  getFrontPageContent,\n} from \"src/core/config\";\n\ninterface IAddSavedFilterModalProps {\n  onClose: (content?: FrontPageContent) => void;\n  existingSavedFilterIDs: string[];\n  candidates: GQL.FindSavedFiltersQuery;\n}\n\nconst FilterModeToMessageID = {\n  [GQL.FilterMode.Galleries]: \"galleries\",\n  [GQL.FilterMode.Images]: \"images\",\n  [GQL.FilterMode.Movies]: \"groups\",\n  [GQL.FilterMode.Groups]: \"groups\",\n  [GQL.FilterMode.Performers]: \"performers\",\n  [GQL.FilterMode.SceneMarkers]: \"markers\",\n  [GQL.FilterMode.Scenes]: \"scenes\",\n  [GQL.FilterMode.Studios]: \"studios\",\n  [GQL.FilterMode.Tags]: \"tags\",\n};\n\ntype SavedFilter = Pick<GQL.SavedFilter, \"id\" | \"mode\" | \"name\">;\n\nfunction filterTitle(intl: IntlShape, f: SavedFilter) {\n  const typeMessage = intl.formatMessage({ id: FilterModeToMessageID[f.mode] });\n  return `${typeMessage}: ${f.name}`;\n}\n\nconst AddContentModal: React.FC<IAddSavedFilterModalProps> = ({\n  onClose,\n  existingSavedFilterIDs,\n  candidates,\n}) => {\n  const intl = useIntl();\n\n  const premadeFilterOptions = useMemo(\n    () => generatePremadeFrontPageContent(intl),\n    [intl]\n  );\n\n  const [contentType, setContentType] = useState(\n    \"front_page.types.premade_filter\"\n  );\n  const [premadeFilterIndex, setPremadeFilterIndex] = useState<\n    number | undefined\n  >(0);\n  const [savedFilter, setSavedFilter] = useState<string | undefined>();\n\n  function onTypeSelected(t: string) {\n    setContentType(t);\n\n    switch (t) {\n      case \"front_page.types.premade_filter\":\n        setPremadeFilterIndex(0);\n        setSavedFilter(undefined);\n        break;\n      case \"front_page.types.saved_filter\":\n        setPremadeFilterIndex(undefined);\n        setSavedFilter(undefined);\n        break;\n    }\n  }\n\n  function isValid() {\n    switch (contentType) {\n      case \"front_page.types.premade_filter\":\n        return premadeFilterIndex !== undefined;\n      case \"front_page.types.saved_filter\":\n        return savedFilter !== undefined;\n    }\n\n    return false;\n  }\n\n  const savedFilterOptions = useMemo(() => {\n    const ret = [\n      {\n        value: \"\",\n        text: \"\",\n      },\n    ].concat(\n      candidates.findSavedFilters\n        .filter((f) => {\n          return !existingSavedFilterIDs.includes(f.id);\n        })\n        .map((f) => {\n          return {\n            value: f.id,\n            text: filterTitle(intl, f),\n          };\n        })\n    );\n\n    ret.sort((a, b) => {\n      return a.text.localeCompare(b.text);\n    });\n\n    return ret;\n  }, [candidates, existingSavedFilterIDs, intl]);\n\n  function renderTypeSelect() {\n    const options = [\n      \"front_page.types.premade_filter\",\n      \"front_page.types.saved_filter\",\n    ];\n    return (\n      <Form.Group controlId=\"filter\">\n        <Form.Label>\n          <FormattedMessage id=\"type\" />\n        </Form.Label>\n        <Form.Control\n          as=\"select\"\n          value={contentType}\n          onChange={(e) => onTypeSelected(e.target.value)}\n          className=\"btn-secondary\"\n        >\n          {options.map((c) => (\n            <option key={c} value={c}>\n              {intl.formatMessage({ id: c })}\n            </option>\n          ))}\n        </Form.Control>\n      </Form.Group>\n    );\n  }\n\n  function maybeRenderPremadeFiltersSelect() {\n    if (contentType !== \"front_page.types.premade_filter\") return;\n\n    return (\n      <Form.Group controlId=\"premade-filter\">\n        <Form.Label>\n          <FormattedMessage id=\"front_page.types.premade_filter\" />\n        </Form.Label>\n        <Form.Control\n          as=\"select\"\n          value={premadeFilterIndex}\n          onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value))}\n          className=\"btn-secondary\"\n        >\n          {premadeFilterOptions.map((c, i) => (\n            <option key={i} value={i}>\n              {intl.formatMessage({ id: c.message!.id }, c.message!.values)}\n            </option>\n          ))}\n        </Form.Control>\n      </Form.Group>\n    );\n  }\n\n  function maybeRenderSavedFiltersSelect() {\n    if (contentType !== \"front_page.types.saved_filter\") return;\n    return (\n      <Form.Group controlId=\"filter\">\n        <Form.Label>\n          <FormattedMessage id=\"search_filter.name\" />\n        </Form.Label>\n        <Form.Control\n          as=\"select\"\n          value={savedFilter}\n          onChange={(e) => setSavedFilter(e.target.value)}\n          className=\"btn-secondary\"\n        >\n          {savedFilterOptions.map((c) => (\n            <option key={c.value} value={c.value}>\n              {c.text}\n            </option>\n          ))}\n        </Form.Control>\n      </Form.Group>\n    );\n  }\n\n  function doAdd() {\n    switch (contentType) {\n      case \"front_page.types.premade_filter\":\n        onClose(premadeFilterOptions[premadeFilterIndex!]);\n        return;\n      case \"front_page.types.saved_filter\":\n        onClose({\n          __typename: \"SavedFilter\",\n          savedFilterId: parseInt(savedFilter!),\n        });\n        return;\n    }\n\n    onClose();\n  }\n\n  return (\n    <Modal show onHide={() => onClose()}>\n      <Modal.Header>\n        <FormattedMessage id=\"actions.add\" />\n      </Modal.Header>\n      <Modal.Body>\n        <div className=\"dialog-content\">\n          {renderTypeSelect()}\n          {maybeRenderSavedFiltersSelect()}\n          {maybeRenderPremadeFiltersSelect()}\n        </div>\n      </Modal.Body>\n      <Modal.Footer>\n        <Button variant=\"secondary\" onClick={() => onClose()}>\n          <FormattedMessage id=\"actions.cancel\" />\n        </Button>\n        <Button onClick={() => doAdd()} disabled={!isValid()}>\n          <FormattedMessage id=\"actions.add\" />\n        </Button>\n      </Modal.Footer>\n    </Modal>\n  );\n};\n\ninterface IFilterRowProps {\n  content: FrontPageContent;\n  allSavedFilters: SavedFilter[];\n  onDelete: () => void;\n}\n\nconst ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {\n  const intl = useIntl();\n\n  function title() {\n    switch (props.content.__typename) {\n      case \"SavedFilter\":\n        const savedFilterId = String(props.content.savedFilterId);\n        const savedFilter = props.allSavedFilters.find(\n          (f) => f.id === savedFilterId\n        );\n        if (!savedFilter) return \"\";\n        return filterTitle(intl, savedFilter);\n      case \"CustomFilter\":\n        const asCustomFilter = props.content as ICustomFilter;\n        if (asCustomFilter.message)\n          return intl.formatMessage(\n            { id: asCustomFilter.message.id },\n            asCustomFilter.message.values\n          );\n        return asCustomFilter.title ?? \"\";\n    }\n  }\n\n  return (\n    <div className=\"recommendation-row\">\n      <div className=\"recommendation-row-head\">\n        <div>\n          <h2>{title()}</h2>\n        </div>\n        <Button\n          variant=\"danger\"\n          title={intl.formatMessage({ id: \"actions.delete\" })}\n          onClick={() => props.onDelete()}\n        >\n          <FormattedMessage id=\"actions.delete\" />\n        </Button>\n      </div>\n    </div>\n  );\n};\n\ninterface IFrontPageConfigProps {\n  onClose: (content?: FrontPageContent[]) => void;\n}\n\nexport const FrontPageConfig: React.FC<IFrontPageConfigProps> = ({\n  onClose,\n}) => {\n  const { configuration } = useConfigurationContext();\n\n  const ui = configuration?.ui;\n\n  const { data: allFilters, loading } = useFindSavedFilters();\n\n  const [isAdd, setIsAdd] = useState(false);\n  const [currentContent, setCurrentContent] = useState<FrontPageContent[]>([]);\n  const [dragIndex, setDragIndex] = useState<number | undefined>();\n\n  useEffect(() => {\n    if (!allFilters?.findSavedFilters) {\n      return;\n    }\n\n    const frontPageContent = getFrontPageContent(ui);\n    if (frontPageContent) {\n      setCurrentContent(\n        // filter out rows where the saved filter no longer exists\n        frontPageContent.filter((r) => {\n          if (r.__typename === \"SavedFilter\") {\n            const savedFilterId = String(r.savedFilterId);\n            return allFilters.findSavedFilters.some(\n              (f) => f.id === savedFilterId\n            );\n          }\n          return true;\n        })\n      );\n    }\n  }, [allFilters, ui]);\n\n  function onDragStart(event: React.DragEvent<HTMLElement>, index: number) {\n    event.dataTransfer.effectAllowed = \"move\";\n    setDragIndex(index);\n  }\n\n  function onDragOver(event: React.DragEvent<HTMLElement>, index?: number) {\n    if (dragIndex !== undefined && index !== undefined && index !== dragIndex) {\n      const newFilters = [...currentContent];\n      const moved = newFilters.splice(dragIndex, 1);\n      newFilters.splice(index, 0, moved[0]);\n      setCurrentContent(newFilters);\n      setDragIndex(index);\n    }\n\n    event.dataTransfer.dropEffect = \"move\";\n    event.preventDefault();\n  }\n\n  function onDragOverDefault(event: React.DragEvent<HTMLDivElement>) {\n    event.dataTransfer.dropEffect = \"move\";\n    event.preventDefault();\n  }\n\n  function onDrop() {\n    // assume we've already set the temp filter list\n    // feed it up\n    setDragIndex(undefined);\n  }\n\n  if (loading) {\n    return <LoadingIndicator />;\n  }\n\n  const existingSavedFilterIDs = currentContent\n    .filter(\n      (f) =>\n        f.__typename === \"SavedFilter\" && (f as ISavedFilterRow).savedFilterId\n    )\n    .map((f) => (f as ISavedFilterRow).savedFilterId.toString());\n\n  function addSavedFilter(content?: FrontPageContent) {\n    setIsAdd(false);\n\n    if (!content) {\n      return;\n    }\n\n    setCurrentContent([...currentContent, content]);\n  }\n\n  function deleteSavedFilter(index: number) {\n    setCurrentContent(currentContent.filter((f, i) => i !== index));\n  }\n\n  return (\n    <>\n      {isAdd && allFilters && (\n        <AddContentModal\n          candidates={allFilters}\n          existingSavedFilterIDs={existingSavedFilterIDs}\n          onClose={addSavedFilter}\n        />\n      )}\n      <div className=\"recommendations-container recommendations-container-edit\">\n        <div onDragOver={onDragOverDefault}>\n          {currentContent.map((content, index) => (\n            <div\n              key={index}\n              draggable\n              onDragStart={(e) => onDragStart(e, index)}\n              onDragEnter={(e) => onDragOver(e, index)}\n              onDrop={() => onDrop()}\n            >\n              <ContentRow\n                key={index}\n                allSavedFilters={allFilters!.findSavedFilters}\n                content={content}\n                onDelete={() => deleteSavedFilter(index)}\n              />\n            </div>\n          ))}\n          <div className=\"recommendation-row recommendation-row-add\">\n            <div className=\"recommendation-row-head\">\n              <Button\n                className=\"recommendations-add\"\n                variant=\"primary\"\n                onClick={() => setIsAdd(true)}\n              >\n                <FormattedMessage id=\"actions.add\" />\n              </Button>\n            </div>\n          </div>\n        </div>\n        <div className=\"recommendations-footer\">\n          <Button onClick={() => onClose()} variant=\"secondary\">\n            <FormattedMessage id={\"actions.cancel\"} />\n          </Button>\n          <Button onClick={() => onClose(currentContent)}>\n            <FormattedMessage id={\"actions.save\"} />\n          </Button>\n        </div>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/FrontPage/RecommendationRow.tsx",
    "content": "import React, { PropsWithChildren } from \"react\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IProps {\n  className?: string;\n  header: React.ReactNode;\n  link: JSX.Element;\n}\n\nexport const RecommendationRow: React.FC<PropsWithChildren<IProps>> =\n  PatchComponent(\n    \"RecommendationRow\",\n    ({ className, header, link, children }) => (\n      <div className={`recommendation-row ${className}`}>\n        <div className=\"recommendation-row-head\">\n          <div>\n            <h2>{header}</h2>\n          </div>\n          {link}\n        </div>\n        {children}\n      </div>\n    )\n  );\n"
  },
  {
    "path": "ui/v2.5/src/components/FrontPage/styles.scss",
    "content": ".recommendations-container {\n  padding-left: 20px;\n  padding-right: 20px;\n\n  @media (max-width: 576px) {\n    padding-left: 0;\n    padding-right: 0;\n  }\n\n  .recommendations-footer {\n    display: flex;\n    justify-content: right;\n    margin-bottom: 1em;\n    margin-top: 1em;\n\n    button:not(:last-child) {\n      margin-right: 10px;\n    }\n  }\n}\n\n.no-recommendations {\n  font-size: 1.5rem;\n  padding-top: 2rem;\n  text-align: center;\n}\n\n.recommendation-row-head {\n  align-items: center;\n  border-radius: 0;\n  -webkit-box-align: center;\n  -webkit-box-pack: justify;\n  display: flex;\n  justify-content: space-between;\n  padding: 15px 0;\n}\n\n.recommendations-container-edit {\n  .recommendation-row {\n    background-color: $secondary;\n    margin-bottom: 10px;\n\n    &:not(.recommendation-row-add) {\n      cursor: grab;\n    }\n  }\n\n  .recommendation-row-add .recommendation-row-head {\n    justify-content: center;\n  }\n\n  .recommendation-row-head {\n    padding: 15px 10px;\n  }\n}\n\n.recommendation-row-head h2 {\n  display: inline-flex;\n  font-size: 1.25rem;\n  font-weight: 600;\n  margin-bottom: 0;\n  text-transform: uppercase;\n  white-space: normal;\n}\n\n.recommendation-row-head a {\n  display: inline-flex;\n  font-size: 1.2rem;\n  white-space: normal;\n}\n\n.card hr {\n  margin-top: auto;\n}\n\n/* skeletons */\n.skeleton-card {\n  -webkit-animation: cardLoadingAnimation 2s infinite ease-in-out;\n  -moz-animation: cardLoadingAnimation 2s infinite ease-in-out;\n  -o-animation: cardLoadingAnimation 2s infinite ease-in-out;\n  animation: cardLoadingAnimation 2s infinite ease-in-out;\n  background-clip: border-box;\n  background-color: #30404d;\n  border: 1px solid rgba(0, 0, 0, 0.13);\n  border-radius: 3px;\n  box-shadow: 0 0 0 1px #10161a66, 0 0 #10161a00, 0 0 #10161a00;\n  display: flex;\n  flex-direction: column;\n  margin: 5px;\n  overflow: hidden;\n  padding: 0;\n  position: relative;\n  word-wrap: break-word;\n}\n\n@keyframes cardLoadingAnimation {\n  50% {\n    opacity: 0.5;\n  }\n}\n\n.scene-skeleton {\n  max-width: 320px;\n  min-height: 365px;\n  min-width: 320px;\n\n  @media (max-width: 576px) {\n    max-width: 20rem;\n    min-height: 25.2rem;\n    min-width: 20rem;\n  }\n}\n\n.group-skeleton {\n  max-width: 240px;\n  min-height: 540px;\n  min-width: 240px;\n\n  @media (max-width: 576px) {\n    max-width: 16rem;\n    min-height: 34rem;\n    min-width: 16rem;\n  }\n}\n\n.performer-skeleton {\n  max-width: 20rem;\n  min-height: 39.1rem;\n  min-width: 20rem;\n\n  @media (max-width: 576px) {\n    max-width: 16rem;\n    min-height: 33.1rem;\n    min-width: 16rem;\n  }\n}\n\n.image-skeleton,\n.gallery-skeleton {\n  max-width: 320px;\n  min-height: 403.5px;\n  min-width: 320px;\n\n  @media (max-width: 576px) {\n    max-width: 20rem;\n    min-height: 38.5rem;\n    min-width: 20rem;\n  }\n}\n\n.studio-skeleton {\n  max-width: 360px;\n  min-height: 278px;\n  min-width: 360px;\n\n  @media (max-width: 576px) {\n    max-width: 20rem;\n    min-height: 19.8rem;\n    min-width: 20rem;\n  }\n}\n\n.tag-skeleton {\n  max-width: 240px;\n  min-height: 365px;\n  min-width: 240px;\n\n  @media (max-width: 576px) {\n    max-width: 16rem;\n    min-height: 26rem;\n    min-width: 16rem;\n  }\n}\n\n/* Slider */\n.slick-slider {\n  box-sizing: border-box;\n  display: block;\n  position: relative;\n  -webkit-tap-highlight-color: transparent;\n  -ms-touch-action: pan-y;\n  touch-action: pan-y;\n  -webkit-touch-callout: none;\n  -khtml-user-select: none;\n  -ms-user-select: none;\n  -moz-user-select: none;\n  -webkit-user-select: none;\n  user-select: none;\n}\n\n.slick-list {\n  display: block;\n  margin: 0;\n  overflow: hidden;\n  padding: 0;\n  position: relative;\n}\n\n.slick-list:focus {\n  outline: none;\n}\n\n.slick-list.dragging {\n  cursor: pointer;\n  cursor: hand;\n}\n\n.slick-slider .slick-track,\n.slick-slider .slick-list {\n  -moz-transform: translate3d(0, 0, 0);\n  -ms-transform: translate3d(0, 0, 0);\n  -o-transform: translate3d(0, 0, 0);\n  -webkit-transform: translate3d(0, 0, 0);\n  transform: translate3d(0, 0, 0);\n}\n\n.slick-track {\n  display: block;\n  left: 0;\n  margin-left: auto;\n  margin-right: auto;\n  position: relative;\n  top: 0;\n}\n\n.slick-track::before,\n.slick-track::after {\n  content: \"\";\n  display: table;\n}\n\n.slick-track::after {\n  clear: both;\n}\n\n.slick-loading .slick-track {\n  visibility: hidden;\n}\n\n.slick-slide {\n  display: none;\n  float: left;\n\n  height: 100%;\n  min-height: 1px;\n}\n\n[dir=\"rtl\"] .slick-slide {\n  float: right;\n}\n\n.slick-slide img {\n  display: block;\n}\n\n.slick-slide.slick-loading img {\n  display: none;\n}\n\n.slick-slide.dragging img {\n  pointer-events: none;\n}\n\n.slick-initialized .slick-slide {\n  display: block;\n}\n\n.slick-loading .slick-slide {\n  visibility: hidden;\n}\n\n.slick-vertical .slick-slide {\n  border: 1px solid transparent;\n  display: block;\n  height: auto;\n}\n\n.slick-arrow.slick-hidden {\n  display: none;\n}\n\n.slick-loading .slick-list {\n  background: #fff url(\"slick-carousel/slick/ajax-loader.gif\") center center\n    no-repeat;\n}\n\n.slick-list .card-check {\n  display: none;\n}\n\n.container-fluid .slick-track {\n  display: flex;\n}\n\n.container-fluid .slick-slide {\n  display: flex;\n  height: auto;\n}\n\n.slick-slide .card {\n  height: 98%;\n}\n\n.slick-slide .studio-card-image {\n  height: 150px;\n}\n\n@media (max-width: 576px) {\n  .slick-list .scene-card.card,\n  .slick-list .studio-card.card,\n  .slick-list .gallery-card.card,\n  .slick-list .image-card.card {\n    width: 20rem;\n  }\n\n  .slick-list .group-card.card {\n    width: 16rem;\n  }\n\n  .slick-list .performer-card.card {\n    width: 16rem;\n  }\n}\n\n/* Icons */\n@font-face {\n  font-family: slick;\n  font-style: normal;\n  font-weight: normal;\n\n  src: url(\"slick-carousel/slick/fonts/slick.eot\");\n  src: url(\"slick-carousel/slick/fonts/slick.eot?#iefix\")\n      format(\"embedded-opentype\"),\n    url(\"slick-carousel/slick/fonts/slick.woff\") format(\"woff\"),\n    url(\"slick-carousel/slick/fonts/slick.ttf\") format(\"truetype\"),\n    url(\"slick-carousel/slick/fonts/slick.svg#slick\") format(\"svg\");\n}\n\n/* Arrows */\n.slick-prev,\n.slick-next {\n  background: transparent;\n  border: none;\n  color: transparent;\n  cursor: pointer;\n  display: block;\n  font-size: 0;\n  height: 100%;\n  line-height: 0;\n  outline: none;\n  padding: 0;\n  position: absolute;\n  top: 50%;\n  -webkit-transform: translate(0, -50%);\n  -ms-transform: translate(0, -50%);\n  transform: translate(0, -50%);\n  width: 20px;\n}\n\n.slick-prev:hover,\n.slick-prev:focus,\n.slick-next:hover,\n.slick-next:focus {\n  background: transparent;\n  color: transparent;\n  outline: none;\n}\n\n.slick-prev:hover::before,\n.slick-prev:focus::before,\n.slick-next:hover::before,\n.slick-next:focus::before {\n  opacity: 1;\n}\n\n.slick-prev.slick-disabled::before,\n.slick-next.slick-disabled::before {\n  opacity: 0.25;\n}\n\n.slick-prev::before,\n.slick-next::before {\n  color: white;\n  font-family: slick;\n  font-size: 20px;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  line-height: 1;\n  opacity: 0.75;\n}\n\n.slick-prev {\n  left: -20px;\n}\n\n[dir=\"rtl\"] .slick-prev {\n  left: auto;\n  right: -20px;\n}\n\n.slick-prev::before {\n  content: \"←\";\n}\n\n[dir=\"rtl\"] .slick-prev::before {\n  content: \"→\";\n}\n\n.slick-next {\n  right: -25px;\n}\n\n[dir=\"rtl\"] .slick-next {\n  left: -25px;\n  right: auto;\n}\n\n.slick-next::before {\n  content: \"→\";\n}\n\n[dir=\"rtl\"] .slick-next::before {\n  content: \"←\";\n}\n\n/* Dots */\n.slick-dotted.slick-slider {\n  margin-bottom: 30px;\n}\n\n.slick-dots {\n  bottom: -25px;\n  display: block;\n  list-style: none;\n  margin: 0;\n  padding: 0;\n  text-align: center;\n  width: 100%;\n}\n\n.slick-dots li {\n  cursor: pointer;\n  display: inline-block;\n  height: 20px;\n  margin: 0 5px;\n  padding: 0;\n  position: relative;\n  width: 20px;\n}\n\n.slick-dots li button {\n  background: transparent;\n  border: 0;\n  color: transparent;\n  cursor: pointer;\n  display: block;\n  font-size: 0;\n  height: 20px;\n  line-height: 0;\n  outline: none;\n  padding: 5px;\n  width: 20px;\n}\n\n.slick-dots li button:hover,\n.slick-dots li button:focus {\n  outline: none;\n}\n\n.slick-dots li button:hover::before,\n.slick-dots li button:focus::before {\n  opacity: 1;\n}\n\n.slick-dots li button::before {\n  color: white;\n  content: \"-\";\n  font-family: slick;\n  font-size: 50px;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  height: 20px;\n  left: 0;\n  opacity: 0.25;\n  position: absolute;\n  text-align: center;\n  top: 0;\n  width: 20px;\n}\n\n.slick-dots li.slick-active button::before {\n  color: white;\n  opacity: 0.75;\n}\n\n// HACK: compatibility with existing behaviour after removed width from zoom-1 class\n// this should really be changed to use the specific card types instead of a generic zoom-1 class,\n// but this is a quick fix to prevent breaking existing styles\n.recommendation-row .card.zoom-1 {\n  width: 320px;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useGalleryDestroy } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IDeleteGalleryDialogProps {\n  selected: GQL.SlimGalleryDataFragment[];\n  onClose: (confirmed: boolean) => void;\n}\n\nexport const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = (\n  props: IDeleteGalleryDialogProps\n) => {\n  const intl = useIntl();\n  const singularEntity = intl.formatMessage({ id: \"gallery\" });\n  const pluralEntity = intl.formatMessage({ id: \"galleries\" });\n\n  const header = intl.formatMessage(\n    { id: \"dialogs.delete_entity_title\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const toastMessage = intl.formatMessage(\n    { id: \"toast.delete_past_tense\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const message = intl.formatMessage(\n    { id: \"dialogs.delete_entity_desc\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n\n  const { configuration: config } = useConfigurationContext();\n\n  const [deleteFile, setDeleteFile] = useState<boolean>(\n    config?.defaults.deleteFile ?? false\n  );\n  const [deleteGenerated, setDeleteGenerated] = useState<boolean>(\n    config?.defaults.deleteGenerated ?? true\n  );\n\n  const Toast = useToast();\n  const [deleteGallery] = useGalleryDestroy(getGalleriesDeleteInput());\n\n  // Network state\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  function getGalleriesDeleteInput(): GQL.GalleryDestroyInput {\n    return {\n      ids: props.selected.map((gallery) => gallery.id!),\n      delete_file: deleteFile,\n      delete_generated: deleteGenerated,\n    };\n  }\n\n  async function onDelete() {\n    setIsDeleting(true);\n    try {\n      await deleteGallery();\n      Toast.success(toastMessage);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsDeleting(false);\n    props.onClose(true);\n  }\n\n  function maybeRenderDeleteFileAlert() {\n    if (!deleteFile) {\n      return;\n    }\n\n    const deletedFiles: string[] = [];\n\n    props.selected.forEach((s) => {\n      const paths = s.files.map((f) => f.path);\n      deletedFiles.push(...paths);\n    });\n\n    if (deletedFiles.length === 0) {\n      return;\n    }\n\n    const deleteTrashPath = config?.general.deleteTrashPath;\n    const deleteAlertId = deleteTrashPath\n      ? \"dialogs.delete_alert_to_trash\"\n      : \"dialogs.delete_alert\";\n\n    return (\n      <div className=\"delete-dialog alert alert-danger text-break\">\n        <p className=\"font-weight-bold\">\n          <FormattedMessage\n            values={{\n              count: deletedFiles.length,\n              singularEntity: intl.formatMessage({ id: \"file\" }),\n              pluralEntity: intl.formatMessage({ id: \"files\" }),\n            }}\n            id={deleteAlertId}\n          />\n        </p>\n        <ul>\n          {deletedFiles.slice(0, 5).map((s) => (\n            <li key={s}>{s}</li>\n          ))}\n          {deletedFiles.length > 5 && (\n            <FormattedMessage\n              values={{\n                count: deletedFiles.length - 5,\n                singularEntity: intl.formatMessage({ id: \"file\" }),\n                pluralEntity: intl.formatMessage({ id: \"files\" }),\n              }}\n              id=\"dialogs.delete_object_overflow\"\n            />\n          )}\n          <li>\n            <FormattedMessage id=\"dialogs.delete_galleries_extra\" />\n          </li>\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faTrashAlt}\n      header={header}\n      accept={{\n        variant: \"danger\",\n        onClick: onDelete,\n        text: intl.formatMessage({ id: \"actions.delete\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(false),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isDeleting}\n    >\n      <p>{message}</p>\n      {maybeRenderDeleteFileAlert()}\n      <Form>\n        <Form.Check\n          id=\"delete-file\"\n          checked={deleteFile}\n          label={intl.formatMessage({\n            id: \"dialogs.delete_gallery_files\",\n          })}\n          onChange={() => setDeleteFile(!deleteFile)}\n        />\n        <Form.Check\n          id=\"delete-generated\"\n          checked={deleteGenerated}\n          label={intl.formatMessage({\n            id: \"actions.delete_generated_supporting_files\",\n          })}\n          onChange={() => setDeleteGenerated(!deleteGenerated)}\n        />\n      </Form>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useBulkGalleryUpdate } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { StudioSelect } from \"../Shared/Select\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { MultiSet } from \"../Shared/MultiSet\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport {\n  getAggregateInputValue,\n  getAggregatePerformerIds,\n  getAggregateStateObject,\n  getAggregateTagIds,\n  getAggregateStudioId,\n  getAggregateSceneIds,\n} from \"src/utils/bulkUpdate\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { IndeterminateCheckbox } from \"../Shared/IndeterminateCheckbox\";\nimport { BulkUpdateFormGroup, BulkUpdateTextInput } from \"../Shared/BulkUpdate\";\nimport { BulkUpdateDateInput } from \"../Shared/DateInput\";\nimport { getDateError } from \"src/utils/yup\";\n\ninterface IListOperationProps {\n  selected: GQL.SlimGalleryDataFragment[];\n  onClose: (applied: boolean) => void;\n}\n\nconst galleryFields = [\n  \"code\",\n  \"rating100\",\n  \"details\",\n  \"organized\",\n  \"photographer\",\n  \"date\",\n];\n\nexport const EditGalleriesDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [updateInput, setUpdateInput] = useState<GQL.BulkGalleryUpdateInput>({\n    ids: props.selected.map((gallery) => {\n      return gallery.id;\n    }),\n  });\n\n  const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [sceneIds, setSceneIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n\n  const unsetDisabled = props.selected.length < 2;\n\n  const [dateError, setDateError] = useState<string | undefined>();\n\n  const [updateGalleries] = useBulkGalleryUpdate();\n\n  // Network state\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const aggregateState = useMemo(() => {\n    const updateState: Partial<GQL.BulkGalleryUpdateInput> = {};\n    const state = props.selected;\n    updateState.studio_id = getAggregateStudioId(props.selected);\n    const updateTagIds = getAggregateTagIds(props.selected);\n    const updatePerformerIds = getAggregatePerformerIds(props.selected);\n    const updateSceneIds = getAggregateSceneIds(props.selected);\n    let first = true;\n\n    state.forEach((gallery: GQL.SlimGalleryDataFragment) => {\n      getAggregateStateObject(updateState, gallery, galleryFields, first);\n      first = false;\n    });\n\n    return {\n      state: updateState,\n      tagIds: updateTagIds,\n      performerIds: updatePerformerIds,\n      sceneIds: updateSceneIds,\n    };\n  }, [props.selected]);\n\n  // update initial state from aggregate\n  useEffect(() => {\n    setUpdateInput((current) => ({ ...current, ...aggregateState.state }));\n  }, [aggregateState]);\n\n  useEffect(() => {\n    setDateError(getDateError(updateInput.date ?? \"\", intl));\n  }, [updateInput.date, intl]);\n\n  function setUpdateField(input: Partial<GQL.BulkGalleryUpdateInput>) {\n    setUpdateInput((current) => ({ ...current, ...input }));\n  }\n\n  function getGalleryInput(): GQL.BulkGalleryUpdateInput {\n    const galleryInput: GQL.BulkGalleryUpdateInput = {\n      ...updateInput,\n      tag_ids: tagIds,\n      performer_ids: performerIds,\n      scene_ids: sceneIds,\n    };\n\n    // we don't have unset functionality for the rating star control\n    // so need to determine if we are setting a rating or not\n    galleryInput.rating100 = getAggregateInputValue(\n      updateInput.rating100,\n      aggregateState.state.rating100\n    );\n\n    return galleryInput;\n  }\n\n  async function onSave() {\n    setIsUpdating(true);\n    try {\n      await updateGalleries({ variables: { input: getGalleryInput() } });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"galleries\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsUpdating(false);\n  }\n\n  function render() {\n    return (\n      <ModalComponent\n        show\n        icon={faPencilAlt}\n        header={intl.formatMessage(\n          { id: \"dialogs.edit_entity_count_title\" },\n          {\n            count: props?.selected?.length ?? 1,\n            singularEntity: intl.formatMessage({ id: \"gallery\" }),\n            pluralEntity: intl.formatMessage({ id: \"galleries\" }),\n          }\n        )}\n        accept={{\n          onClick: onSave,\n          text: intl.formatMessage({ id: \"actions.apply\" }),\n        }}\n        disabled={isUpdating || !!dateError}\n        cancel={{\n          onClick: () => props.onClose(false),\n          text: intl.formatMessage({ id: \"actions.cancel\" }),\n          variant: \"secondary\",\n        }}\n        isRunning={isUpdating}\n      >\n        <Form>\n          <BulkUpdateFormGroup name=\"rating\">\n            <RatingSystem\n              value={updateInput.rating100}\n              onSetRating={(value) =>\n                setUpdateField({ rating100: value ?? undefined })\n              }\n              disabled={isUpdating}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"scene_code\">\n            <BulkUpdateTextInput\n              value={updateInput.code}\n              valueChanged={(newValue) => setUpdateField({ code: newValue })}\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"date\">\n            <BulkUpdateDateInput\n              value={updateInput.date}\n              valueChanged={(newValue) => setUpdateField({ date: newValue })}\n              unsetDisabled={unsetDisabled}\n              error={dateError}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"photographer\">\n            <BulkUpdateTextInput\n              value={updateInput.photographer}\n              valueChanged={(newValue) =>\n                setUpdateField({ photographer: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"studio\">\n            <StudioSelect\n              onSelect={(items) =>\n                setUpdateField({\n                  studio_id: items.length > 0 ? items[0]?.id : undefined,\n                })\n              }\n              ids={updateInput.studio_id ? [updateInput.studio_id] : []}\n              isDisabled={isUpdating}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"performers\" inline={false}>\n            <MultiSet\n              type={\"performers\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setPerformerIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setPerformerIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={performerIds.ids ?? []}\n              existingIds={aggregateState.performerIds}\n              mode={performerIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"scenes\" inline={false}>\n            <MultiSet\n              type={\"scenes\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setSceneIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setSceneIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={sceneIds.ids ?? []}\n              existingIds={aggregateState.sceneIds}\n              mode={sceneIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"tags\" inline={false}>\n            <MultiSet\n              type={\"tags\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setTagIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setTagIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={tagIds.ids ?? []}\n              existingIds={aggregateState.tagIds}\n              mode={tagIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"details\" inline={false}>\n            <BulkUpdateTextInput\n              value={updateInput.details}\n              valueChanged={(newValue) => setUpdateField({ details: newValue })}\n              unsetDisabled={unsetDisabled}\n              as=\"textarea\"\n            />\n          </BulkUpdateFormGroup>\n\n          <Form.Group controlId=\"organized\">\n            <IndeterminateCheckbox\n              label={intl.formatMessage({ id: \"organized\" })}\n              setChecked={(checked) => setUpdateField({ organized: checked })}\n              checked={updateInput.organized ?? undefined}\n            />\n          </Form.Group>\n        </Form>\n      </ModalComponent>\n    );\n  }\n\n  return render();\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/Galleries.tsx",
    "content": "import React from \"react\";\nimport { Redirect, Route, RouteComponentProps, Switch } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport { useTitleProps } from \"src/hooks/title\";\nimport Gallery from \"./GalleryDetails/Gallery\";\nimport GalleryCreate from \"./GalleryDetails/GalleryCreate\";\nimport { FilteredGalleryList } from \"./GalleryList\";\nimport { View } from \"../List/views\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { ErrorMessage } from \"../Shared/ErrorMessage\";\nimport { useFindGalleryImageID } from \"src/core/StashService\";\n\ninterface IGalleryImageParams {\n  id: string;\n  index: string;\n}\n\nconst GalleryImage: React.FC<RouteComponentProps<IGalleryImageParams>> = ({\n  match,\n}) => {\n  const { id, index: indexStr } = match.params;\n\n  let index = parseInt(indexStr);\n  if (isNaN(index)) {\n    index = 0;\n  }\n\n  const { data, loading, error } = useFindGalleryImageID(id, index);\n\n  if (isNaN(index)) {\n    return <Redirect to={`/galleries/${id}`} />;\n  }\n\n  if (loading) return <LoadingIndicator />;\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findGallery)\n    return <ErrorMessage error={`No gallery found with id ${id}.`} />;\n\n  return <Redirect to={`/images/${data.findGallery.image.id}`} />;\n};\n\nconst Galleries: React.FC = () => {\n  return <FilteredGalleryList view={View.Galleries} />;\n};\n\nconst GalleryRoutes: React.FC = () => {\n  const titleProps = useTitleProps({ id: \"galleries\" });\n  return (\n    <>\n      <Helmet {...titleProps} />\n      <Switch>\n        <Route exact path=\"/galleries\" component={Galleries} />\n        <Route exact path=\"/galleries/new\" component={GalleryCreate} />\n        <Route\n          exact\n          path=\"/galleries/:id/images/:index\"\n          component={GalleryImage}\n        />\n        <Route path=\"/galleries/:id/:tab?\" component={Gallery} />\n      </Switch>\n    </>\n  );\n};\n\nexport default GalleryRoutes;\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryCard.tsx",
    "content": "import { Button, ButtonGroup, OverlayTrigger, Tooltip } from \"react-bootstrap\";\nimport React, { useMemo, useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { GridCard } from \"../Shared/GridCard/GridCard\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { Icon } from \"../Shared/Icon\";\nimport { SceneLink, TagLink } from \"../Shared/TagLink\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport { PerformerPopoverButton } from \"../Shared/PerformerPopoverButton\";\nimport { PopoverCountButton } from \"../Shared/PopoverCountButton\";\nimport NavUtils from \"src/utils/navigation\";\nimport { RatingBanner } from \"../Shared/RatingBanner\";\nimport { faBox, faPlayCircle, faTag } from \"@fortawesome/free-solid-svg-icons\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { StudioOverlay } from \"../Shared/GridCard/StudioOverlay\";\nimport { GalleryPreviewScrubber } from \"./GalleryPreviewScrubber\";\nimport cx from \"classnames\";\nimport { useHistory } from \"react-router-dom\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IGalleryPreviewProps {\n  gallery: GQL.SlimGalleryDataFragment;\n  onScrubberClick?: (index: number) => void;\n  disabled?: boolean;\n}\n\nexport const GalleryPreview: React.FC<IGalleryPreviewProps> = ({\n  gallery,\n  onScrubberClick,\n  disabled,\n}) => {\n  const [imgSrc, setImgSrc] = useState<string | undefined>(\n    gallery.paths.cover ?? undefined\n  );\n\n  return (\n    <div className={cx(\"gallery-card-cover\")}>\n      {!!imgSrc && (\n        <img\n          loading=\"lazy\"\n          className=\"gallery-card-image\"\n          alt={gallery.title ?? \"\"}\n          src={imgSrc}\n        />\n      )}\n      {gallery.image_count > 0 && (\n        <GalleryPreviewScrubber\n          previewPath={gallery.paths.preview}\n          defaultPath={gallery.paths.cover ?? \"\"}\n          imageCount={gallery.image_count}\n          onClick={onScrubberClick}\n          onPathChanged={setImgSrc}\n          disabled={disabled}\n        />\n      )}\n    </div>\n  );\n};\n\ninterface IGalleryCardProps {\n  gallery: GQL.SlimGalleryDataFragment;\n  cardWidth?: number;\n  selecting?: boolean;\n  selected?: boolean | undefined;\n  zoomIndex?: number;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n}\n\nconst GalleryCardPopovers = PatchComponent(\n  \"GalleryCard.Popovers\",\n  (props: IGalleryCardProps) => {\n    function maybeRenderScenePopoverButton() {\n      if (props.gallery.scenes.length === 0) return;\n\n      const popoverContent = props.gallery.scenes.map((scene) => (\n        <SceneLink key={scene.id} scene={scene} />\n      ));\n\n      return (\n        <HoverPopover\n          className=\"scene-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faPlayCircle} />\n            <span>{props.gallery.scenes.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderTagPopoverButton() {\n      if (props.gallery.tags.length <= 0) return;\n\n      const popoverContent = props.gallery.tags.map((tag) => (\n        <TagLink key={tag.id} tag={tag} linkType=\"gallery\" />\n      ));\n\n      return (\n        <HoverPopover\n          className=\"tag-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faTag} />\n            <span>{props.gallery.tags.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderPerformerPopoverButton() {\n      if (props.gallery.performers.length <= 0) return;\n\n      return (\n        <PerformerPopoverButton\n          performers={props.gallery.performers}\n          linkType=\"gallery\"\n        />\n      );\n    }\n\n    function maybeRenderImagesPopoverButton() {\n      if (!props.gallery.image_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"image-count\"\n          type=\"image\"\n          count={props.gallery.image_count}\n          url={NavUtils.makeGalleryImagesUrl(props.gallery)}\n        />\n      );\n    }\n\n    function maybeRenderOrganized() {\n      if (props.gallery.organized) {\n        return (\n          <OverlayTrigger\n            overlay={<Tooltip id=\"organised-tooltip\">{\"Organized\"}</Tooltip>}\n            placement=\"bottom\"\n          >\n            <div className=\"organized\">\n              <Button className=\"minimal\">\n                <Icon icon={faBox} />\n              </Button>\n            </div>\n          </OverlayTrigger>\n        );\n      }\n    }\n\n    function maybeRenderPopoverButtonGroup() {\n      if (\n        props.gallery.scenes.length > 0 ||\n        props.gallery.performers.length > 0 ||\n        props.gallery.tags.length > 0 ||\n        props.gallery.organized ||\n        props.gallery.image_count > 0\n      ) {\n        return (\n          <>\n            <hr />\n            <ButtonGroup className=\"card-popovers\">\n              {maybeRenderImagesPopoverButton()}\n              {maybeRenderTagPopoverButton()}\n              {maybeRenderPerformerPopoverButton()}\n              {maybeRenderScenePopoverButton()}\n              {maybeRenderOrganized()}\n            </ButtonGroup>\n          </>\n        );\n      }\n    }\n\n    return <>{maybeRenderPopoverButtonGroup()}</>;\n  }\n);\n\nconst GalleryCardDetails = PatchComponent(\n  \"GalleryCard.Details\",\n  (props: IGalleryCardProps) => {\n    return (\n      <div className=\"gallery-card__details\">\n        <span className=\"gallery-card__date\">{props.gallery.date}</span>\n        <TruncatedText\n          className=\"gallery-card__description\"\n          text={props.gallery.details}\n          lineCount={3}\n        />\n      </div>\n    );\n  }\n);\n\nconst GalleryCardOverlays = PatchComponent(\n  \"GalleryCard.Overlays\",\n  (props: IGalleryCardProps) => {\n    const ret = useMemo(() => {\n      return (\n        <StudioOverlay\n          studio={props.gallery.studio}\n          disabled={props.selecting}\n        />\n      );\n    }, [props.gallery.studio, props.selecting]);\n\n    return ret;\n  }\n);\n\nconst GalleryCardImage = PatchComponent(\n  \"GalleryCard.Image\",\n  (props: IGalleryCardProps) => {\n    const history = useHistory();\n\n    return (\n      <>\n        <GalleryPreview\n          gallery={props.gallery}\n          onScrubberClick={(i) => {\n            history.push(`/galleries/${props.gallery.id}/images/${i}`);\n          }}\n          disabled={props.selecting}\n        />\n        <RatingBanner rating={props.gallery.rating100} />\n      </>\n    );\n  }\n);\n\nexport const GalleryCard = PatchComponent(\n  \"GalleryCard\",\n  (props: IGalleryCardProps) => {\n    return (\n      <GridCard\n        className={`gallery-card zoom-${props.zoomIndex}`}\n        url={`/galleries/${props.gallery.id}`}\n        width={props.cardWidth}\n        title={galleryTitle(props.gallery)}\n        linkClassName=\"gallery-card-header\"\n        image={<GalleryCardImage {...props} />}\n        overlays={<GalleryCardOverlays {...props} />}\n        details={<GalleryCardDetails {...props} />}\n        popovers={<GalleryCardPopovers {...props} />}\n        selected={props.selected}\n        selecting={props.selecting}\n        onSelectedChanged={props.onSelectedChanged}\n      />\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { GalleryCard } from \"./GalleryCard\";\nimport {\n  useCardWidth,\n  useContainerDimensions,\n} from \"../Shared/GridCard/GridCard\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IGalleryCardGrid {\n  galleries: GQL.SlimGalleryDataFragment[];\n  selectedIds: Set<string>;\n  zoomIndex: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nconst zoomWidths = [280, 340, 480, 640];\n\nexport const GalleryCardGrid: React.FC<IGalleryCardGrid> = PatchComponent(\n  \"GalleryCardGrid\",\n  ({ galleries, selectedIds, zoomIndex, onSelectChange }) => {\n    const [componentRef, { width: containerWidth }] = useContainerDimensions();\n    const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);\n\n    return (\n      <div className=\"row justify-content-center\" ref={componentRef}>\n        {galleries.map((gallery) => (\n          <GalleryCard\n            key={gallery.id}\n            cardWidth={cardWidth}\n            gallery={gallery}\n            zoomIndex={zoomIndex}\n            selecting={selectedIds.size > 0}\n            selected={selectedIds.has(gallery.id)}\n            onSelectedChanged={(selected: boolean, shiftKey: boolean) =>\n              onSelectChange(gallery.id, selected, shiftKey)\n            }\n          />\n        ))}\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/ChapterEntry.tsx",
    "content": "import React from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Button } from \"react-bootstrap\";\n\ninterface IChapterEntries {\n  galleryChapters: GQL.GalleryChapterDataFragment[];\n  onClickChapter: (image_index: number) => void;\n  onEdit: (chapter: GQL.GalleryChapterDataFragment) => void;\n}\n\nexport const ChapterEntries: React.FC<IChapterEntries> = ({\n  galleryChapters,\n  onClickChapter,\n  onEdit,\n}) => {\n  if (!galleryChapters?.length) return <div />;\n\n  const chapterCards = galleryChapters.map((chapter) => {\n    return (\n      <div key={chapter.id}>\n        <hr />\n        <div className=\"row\">\n          <Button\n            variant=\"link\"\n            onClick={() => onClickChapter(chapter.image_index)}\n          >\n            <div className=\"row\">\n              {chapter.title}\n              {chapter.title.length > 0 ? \" - #\" : \"#\"}\n              {chapter.image_index}\n            </div>\n          </Button>\n          <Button\n            variant=\"link\"\n            className=\"ml-auto\"\n            onClick={() => onEdit(chapter)}\n          >\n            <FormattedMessage id=\"actions.edit\" />\n          </Button>\n        </div>\n      </div>\n    );\n  });\n\n  return <div>{chapterCards}</div>;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx",
    "content": "import { Button, Tab, Nav, Dropdown } from \"react-bootstrap\";\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport {\n  useHistory,\n  Link,\n  RouteComponentProps,\n  Redirect,\n} from \"react-router-dom\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Helmet } from \"react-helmet\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  mutateMetadataScan,\n  mutateResetGalleryCover,\n  useFindGallery,\n  useGalleryUpdate,\n} from \"src/core/StashService\";\nimport { lazyComponent } from \"src/utils/lazyComponent\";\n\nconst GenerateDialog = lazyComponent(\n  () => import(\"../../Dialogs/GenerateDialog\")\n);\nimport { ErrorMessage } from \"src/components/Shared/ErrorMessage\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { Counter } from \"src/components/Shared/Counter\";\nimport Mousetrap from \"mousetrap\";\nimport { useGalleryLightbox } from \"src/hooks/Lightbox/hooks\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { OrganizedButton } from \"src/components/Scenes/SceneDetails/OrganizedButton\";\nimport { GalleryEditPanel } from \"./GalleryEditPanel\";\nimport { GalleryDetailPanel } from \"./GalleryDetailPanel\";\nimport { DeleteGalleriesDialog } from \"../DeleteGalleriesDialog\";\nimport { GalleryImagesPanel } from \"./GalleryImagesPanel\";\nimport { GalleryAddPanel } from \"./GalleryAddPanel\";\nimport { GalleryFileInfoPanel } from \"./GalleryFileInfoPanel\";\nimport { GalleryScenesPanel } from \"./GalleryScenesPanel\";\nimport {\n  faEllipsisV,\n  faChevronRight,\n  faChevronLeft,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { galleryPath, galleryTitle } from \"src/core/galleries\";\nimport { GalleryChapterPanel } from \"./GalleryChaptersPanel\";\nimport { useScrollToTopOnMount } from \"src/hooks/scrollToTop\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport cx from \"classnames\";\nimport { useRatingKeybinds } from \"src/hooks/keybinds\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { goBackOrReplace } from \"src/utils/history\";\nimport { FormattedDate } from \"src/components/Shared/Date\";\n\ninterface IProps {\n  gallery: GQL.GalleryDataFragment;\n  add?: boolean;\n}\n\ninterface IGalleryParams {\n  id: string;\n  tab?: string;\n}\n\nexport const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {\n  const history = useHistory();\n  const Toast = useToast();\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n  const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);\n\n  const [collapsed, setCollapsed] = useState(false);\n\n  const [activeTabKey, setActiveTabKey] = useState(\"gallery-details-panel\");\n\n  const setMainTabKey = (newTabKey: string | null) => {\n    if (newTabKey === \"add\") {\n      history.replace(`/galleries/${gallery.id}/add`);\n    } else {\n      history.replace(`/galleries/${gallery.id}`);\n    }\n  };\n\n  const path = useMemo(() => galleryPath(gallery), [gallery]);\n\n  const [updateGallery] = useGalleryUpdate();\n\n  const [organizedLoading, setOrganizedLoading] = useState(false);\n\n  async function onSave(input: GQL.GalleryCreateInput) {\n    await updateGallery({\n      variables: {\n        input: {\n          id: gallery.id,\n          ...input,\n        },\n      },\n    });\n    Toast.success(\n      intl.formatMessage(\n        { id: \"toast.updated_entity\" },\n        { entity: intl.formatMessage({ id: \"gallery\" }).toLocaleLowerCase() }\n      )\n    );\n  }\n\n  const onOrganizedClick = async () => {\n    try {\n      setOrganizedLoading(true);\n      await updateGallery({\n        variables: {\n          input: {\n            id: gallery.id,\n            organized: !gallery.organized,\n          },\n        },\n      });\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setOrganizedLoading(false);\n    }\n  };\n\n  function getCollapseButtonIcon() {\n    return collapsed ? faChevronRight : faChevronLeft;\n  }\n\n  async function onRescan() {\n    if (!gallery || !path) {\n      return;\n    }\n\n    await mutateMetadataScan({\n      paths: [path],\n      rescan: true,\n    });\n\n    Toast.success(\n      intl.formatMessage(\n        { id: \"toast.rescanning_entity\" },\n        {\n          count: 1,\n          singularEntity: intl.formatMessage({ id: \"gallery\" }),\n        }\n      )\n    );\n  }\n\n  async function onResetCover() {\n    try {\n      await mutateResetGalleryCover({\n        gallery_id: gallery.id!,\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"gallery\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function onClickChapter(imageindex: number) {\n    showLightbox(imageindex - 1);\n  }\n\n  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);\n  const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);\n\n  function onDeleteDialogClosed(deleted: boolean) {\n    setIsDeleteAlertOpen(false);\n    if (deleted) {\n      goBackOrReplace(history, \"/galleries\");\n    }\n  }\n\n  function maybeRenderDeleteDialog() {\n    if (isDeleteAlertOpen && gallery) {\n      return (\n        <DeleteGalleriesDialog\n          selected={[{ ...gallery, image_count: NaN }]}\n          onClose={onDeleteDialogClosed}\n        />\n      );\n    }\n  }\n\n  function maybeRenderGenerateDialog() {\n    if (isGenerateDialogOpen) {\n      return (\n        <GenerateDialog\n          selectedIds={[gallery.id]}\n          onClose={() => setIsGenerateDialogOpen(false)}\n          type=\"gallery\"\n        />\n      );\n    }\n  }\n\n  function renderOperations() {\n    return (\n      <Dropdown>\n        <Dropdown.Toggle\n          variant=\"secondary\"\n          id=\"operation-menu\"\n          className=\"minimal\"\n          title={intl.formatMessage({ id: \"operations\" })}\n        >\n          <Icon icon={faEllipsisV} />\n        </Dropdown.Toggle>\n        <Dropdown.Menu className=\"bg-secondary text-white\">\n          {path ? (\n            <Dropdown.Item\n              className=\"bg-secondary text-white\"\n              onClick={() => onRescan()}\n            >\n              <FormattedMessage id=\"actions.rescan\" />\n            </Dropdown.Item>\n          ) : undefined}\n          <Dropdown.Item\n            className=\"bg-secondary text-white\"\n            onClick={() => onResetCover()}\n          >\n            <FormattedMessage id=\"actions.reset_cover\" />\n          </Dropdown.Item>\n          <Dropdown.Item\n            className=\"bg-secondary text-white\"\n            onClick={() => setIsGenerateDialogOpen(true)}\n          >\n            {`${intl.formatMessage({ id: \"actions.generate\" })}…`}\n          </Dropdown.Item>\n          <Dropdown.Item\n            className=\"bg-secondary text-white\"\n            onClick={() => setIsDeleteAlertOpen(true)}\n          >\n            <FormattedMessage\n              id=\"actions.delete\"\n              values={{ entityType: intl.formatMessage({ id: \"gallery\" }) }}\n            />\n          </Dropdown.Item>\n        </Dropdown.Menu>\n      </Dropdown>\n    );\n  }\n\n  function renderTabs() {\n    if (!gallery) {\n      return;\n    }\n\n    return (\n      <Tab.Container\n        activeKey={activeTabKey}\n        onSelect={(k) => k && setActiveTabKey(k)}\n      >\n        <div>\n          <Nav variant=\"tabs\" className=\"mr-auto\">\n            <Nav.Item>\n              <Nav.Link eventKey=\"gallery-details-panel\">\n                <FormattedMessage id=\"details\" />\n              </Nav.Link>\n            </Nav.Item>\n            {gallery.scenes.length >= 1 ? (\n              <Nav.Item>\n                <Nav.Link eventKey=\"gallery-scenes-panel\">\n                  <FormattedMessage\n                    id=\"countables.scenes\"\n                    values={{ count: gallery.scenes.length }}\n                  />\n                </Nav.Link>\n              </Nav.Item>\n            ) : undefined}\n            {path ? (\n              <Nav.Item>\n                <Nav.Link eventKey=\"gallery-file-info-panel\">\n                  <FormattedMessage id=\"file_info\" />\n                  <Counter count={gallery.files.length} hideZero hideOne />\n                </Nav.Link>\n              </Nav.Item>\n            ) : undefined}\n            <Nav.Item>\n              <Nav.Link eventKey=\"gallery-chapter-panel\">\n                <FormattedMessage id=\"chapters\" />\n              </Nav.Link>\n            </Nav.Item>\n            <Nav.Item>\n              <Nav.Link eventKey=\"gallery-edit-panel\">\n                <FormattedMessage id=\"actions.edit\" />\n              </Nav.Link>\n            </Nav.Item>\n          </Nav>\n        </div>\n\n        <Tab.Content>\n          <Tab.Pane eventKey=\"gallery-details-panel\">\n            <GalleryDetailPanel gallery={gallery} />\n          </Tab.Pane>\n          <Tab.Pane\n            className=\"file-info-panel\"\n            eventKey=\"gallery-file-info-panel\"\n          >\n            <GalleryFileInfoPanel gallery={gallery} />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"gallery-chapter-panel\">\n            <GalleryChapterPanel\n              gallery={gallery}\n              onClickChapter={onClickChapter}\n              isVisible={activeTabKey === \"gallery-chapter-panel\"}\n            />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"gallery-edit-panel\" mountOnEnter>\n            <GalleryEditPanel\n              isVisible={activeTabKey === \"gallery-edit-panel\"}\n              gallery={gallery}\n              onSubmit={onSave}\n              onDelete={() => setIsDeleteAlertOpen(true)}\n            />\n          </Tab.Pane>\n          {gallery.scenes.length > 0 && (\n            <Tab.Pane eventKey=\"gallery-scenes-panel\">\n              <GalleryScenesPanel scenes={gallery.scenes} />\n            </Tab.Pane>\n          )}\n        </Tab.Content>\n      </Tab.Container>\n    );\n  }\n\n  function renderRightTabs() {\n    if (!gallery) {\n      return;\n    }\n\n    return (\n      <Tab.Container\n        activeKey={add ? \"add\" : \"images\"}\n        unmountOnExit\n        onSelect={setMainTabKey}\n      >\n        <div>\n          <Nav variant=\"tabs\" className=\"mr-auto\">\n            <Nav.Item>\n              <Nav.Link eventKey=\"images\">\n                <FormattedMessage id=\"images\" />\n              </Nav.Link>\n            </Nav.Item>\n            <Nav.Item>\n              <Nav.Link eventKey=\"add\">\n                <FormattedMessage id=\"actions.add\" />\n              </Nav.Link>\n            </Nav.Item>\n          </Nav>\n        </div>\n\n        <Tab.Content>\n          <Tab.Pane eventKey=\"images\">\n            <GalleryImagesPanel active={!add} gallery={gallery} />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"add\">\n            <GalleryAddPanel active={!!add} gallery={gallery} />\n          </Tab.Pane>\n        </Tab.Content>\n      </Tab.Container>\n    );\n  }\n\n  function setRating(v: number | null) {\n    updateGallery({\n      variables: {\n        input: {\n          id: gallery.id,\n          rating100: v,\n        },\n      },\n    });\n  }\n\n  useRatingKeybinds(\n    true,\n    configuration?.ui.ratingSystemOptions?.type,\n    setRating\n  );\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"a\", () => setActiveTabKey(\"gallery-details-panel\"));\n    Mousetrap.bind(\"c\", () => setActiveTabKey(\"gallery-chapter-panel\"));\n    Mousetrap.bind(\"e\", () => setActiveTabKey(\"gallery-edit-panel\"));\n    Mousetrap.bind(\"f\", () => setActiveTabKey(\"gallery-file-info-panel\"));\n    Mousetrap.bind(\",\", () => setCollapsed(!collapsed));\n\n    return () => {\n      Mousetrap.unbind(\"a\");\n      Mousetrap.unbind(\"c\");\n      Mousetrap.unbind(\"e\");\n      Mousetrap.unbind(\"f\");\n      Mousetrap.unbind(\",\");\n    };\n  });\n\n  const title = galleryTitle(gallery);\n\n  return (\n    <div className=\"row\">\n      <Helmet>\n        <title>{title}</title>\n      </Helmet>\n      {maybeRenderDeleteDialog()}\n      {maybeRenderGenerateDialog()}\n      <div className={`gallery-tabs ${collapsed ? \"collapsed\" : \"\"}`}>\n        <div>\n          <div className=\"gallery-header-container\">\n            {gallery.studio && (\n              <h1 className=\"text-center gallery-studio-image\">\n                <Link to={`/studios/${gallery.studio.id}`}>\n                  <img\n                    src={gallery.studio.image_path ?? \"\"}\n                    alt={`${gallery.studio.name} logo`}\n                    className=\"studio-logo\"\n                  />\n                </Link>\n              </h1>\n            )}\n            <h3\n              className={cx(\"gallery-header\", { \"no-studio\": !gallery.studio })}\n            >\n              <TruncatedText lineCount={2} text={title} />\n            </h3>\n          </div>\n\n          <div className=\"gallery-subheader\">\n            {!!gallery.date && (\n              <span className=\"date\" data-value={gallery.date}>\n                <FormattedDate value={gallery.date} />\n              </span>\n            )}\n          </div>\n\n          <div className=\"gallery-toolbar\">\n            <span className=\"gallery-toolbar-group\">\n              <RatingSystem\n                value={gallery.rating100}\n                onSetRating={setRating}\n                clickToRate\n                withoutContext\n              />\n            </span>\n            <span className=\"gallery-toolbar-group\">\n              <span>\n                <OrganizedButton\n                  loading={organizedLoading}\n                  organized={gallery.organized}\n                  onClick={onOrganizedClick}\n                />\n              </span>\n              <span>{renderOperations()}</span>\n            </span>\n          </div>\n        </div>\n        {renderTabs()}\n      </div>\n      <div className=\"gallery-divider d-none d-xl-block\">\n        <Button onClick={() => setCollapsed(!collapsed)}>\n          <Icon className=\"fa-fw\" icon={getCollapseButtonIcon()} />\n        </Button>\n      </div>\n      <div className={`gallery-container ${collapsed ? \"expanded\" : \"\"}`}>\n        {renderRightTabs()}\n      </div>\n    </div>\n  );\n};\n\nconst GalleryLoader: React.FC<RouteComponentProps<IGalleryParams>> = ({\n  location,\n  match,\n}) => {\n  const { id, tab } = match.params;\n  const { data, loading, error } = useFindGallery(id);\n\n  useScrollToTopOnMount();\n\n  if (loading) return <LoadingIndicator />;\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findGallery)\n    return <ErrorMessage error={`No gallery found with id ${id}.`} />;\n\n  if (tab === \"add\") {\n    return <GalleryPage add gallery={data.findGallery} />;\n  }\n\n  if (tab) {\n    return (\n      <Redirect\n        to={{\n          ...location,\n          pathname: `/galleries/${id}`,\n        }}\n      />\n    );\n  }\n\n  return <GalleryPage gallery={data.findGallery} />;\n};\n\nexport default GalleryLoader;\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx",
    "content": "import React, { useCallback } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { GalleriesCriterion } from \"src/models/list-filter/criteria/galleries\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { FilteredImageList } from \"src/components/Images/ImageList\";\nimport { showWhenSelected } from \"src/components/List/ItemList\";\nimport { mutateAddGalleryImages } from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useIntl } from \"react-intl\";\nimport { faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { IItemListOperation } from \"src/components/List/FilteredListToolbar\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IGalleryAddProps {\n  active: boolean;\n  gallery: GQL.GalleryDataFragment;\n  extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];\n}\n\nexport const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(\n  \"GalleryAddPanel\",\n  ({ active, gallery, extraOperations = [] }) => {\n    const Toast = useToast();\n    const intl = useIntl();\n\n    const filterHook = useCallback(\n      (filter: ListFilterModel) => {\n        const galleryValue = {\n          id: gallery.id,\n          label: galleryTitle(gallery),\n        };\n        // if galleries is already present, then we modify it, otherwise add\n        let galleryCriterion = filter.criteria.find((c) => {\n          return c.criterionOption.type === \"galleries\";\n        }) as GalleriesCriterion | undefined;\n\n        if (\n          galleryCriterion &&\n          galleryCriterion.modifier === GQL.CriterionModifier.Excludes\n        ) {\n          // add the gallery if not present\n          if (\n            !galleryCriterion.value.find((p) => {\n              return p.id === gallery.id;\n            })\n          ) {\n            galleryCriterion.value.push(galleryValue);\n          }\n\n          galleryCriterion.modifier = GQL.CriterionModifier.Excludes;\n        } else {\n          // overwrite\n          galleryCriterion = new GalleriesCriterion();\n          galleryCriterion.modifier = GQL.CriterionModifier.Excludes;\n          galleryCriterion.value = [galleryValue];\n          filter.criteria.push(galleryCriterion);\n        }\n\n        return filter;\n      },\n      [gallery]\n    );\n\n    async function addImages(\n      result: GQL.FindImagesQueryResult,\n      filter: ListFilterModel,\n      selectedIds: Set<string>\n    ) {\n      try {\n        await mutateAddGalleryImages({\n          gallery_id: gallery.id!,\n          image_ids: Array.from(selectedIds.values()),\n        });\n        const imageCount = selectedIds.size;\n        Toast.success(\n          intl.formatMessage(\n            { id: \"toast.added_entity\" },\n            {\n              count: imageCount,\n              singularEntity: intl.formatMessage({ id: \"image\" }),\n              pluralEntity: intl.formatMessage({ id: \"images\" }),\n            }\n          )\n        );\n      } catch (e) {\n        Toast.error(e);\n      }\n    }\n\n    const otherOperations = [\n      ...extraOperations,\n      {\n        text: intl.formatMessage(\n          { id: \"actions.add_to_entity\" },\n          { entityType: intl.formatMessage({ id: \"gallery\" }) }\n        ),\n        onClick: addImages,\n        isDisplayed: showWhenSelected,\n        postRefetch: true,\n        icon: faPlus,\n      },\n    ];\n\n    return (\n      <FilteredImageList\n        filterHook={filterHook}\n        extraOperations={otherOperations}\n        alterQuery={active}\n      />\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChapterForm.tsx",
    "content": "import React from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useFormik } from \"formik\";\nimport * as yup from \"yup\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useGalleryChapterCreate,\n  useGalleryChapterUpdate,\n  useGalleryChapterDestroy,\n} from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport isEqual from \"lodash-es/isEqual\";\nimport { formikUtils } from \"src/utils/form\";\nimport { yupFormikValidate, yupInputNumber } from \"src/utils/yup\";\n\ninterface IGalleryChapterForm {\n  galleryID: string;\n  chapter?: GQL.GalleryChapterDataFragment;\n  onClose: () => void;\n}\n\nexport const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({\n  galleryID,\n  chapter,\n  onClose,\n}) => {\n  const intl = useIntl();\n\n  const [galleryChapterCreate] = useGalleryChapterCreate();\n  const [galleryChapterUpdate] = useGalleryChapterUpdate();\n  const [galleryChapterDestroy] = useGalleryChapterDestroy();\n  const Toast = useToast();\n\n  const isNew = chapter === undefined;\n\n  const schema = yup.object({\n    title: yup.string().ensure(),\n    image_index: yupInputNumber()\n      .integer()\n      .moreThan(0)\n      .required()\n      .label(intl.formatMessage({ id: \"image_index\" })),\n  });\n\n  const initialValues = {\n    title: chapter?.title ?? \"\",\n    image_index: chapter?.image_index ?? 1,\n  };\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: (values) => onSave(schema.cast(values)),\n  });\n\n  async function onSave(input: InputValues) {\n    try {\n      if (isNew) {\n        await galleryChapterCreate({\n          variables: {\n            gallery_id: galleryID,\n            ...input,\n          },\n        });\n      } else {\n        await galleryChapterUpdate({\n          variables: {\n            id: chapter.id,\n            gallery_id: galleryID,\n            ...input,\n          },\n        });\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      onClose();\n    }\n  }\n\n  async function onDelete() {\n    if (isNew) return;\n\n    try {\n      await galleryChapterDestroy({ variables: { id: chapter.id } });\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      onClose();\n    }\n  }\n\n  const splitProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n    },\n    fieldProps: {\n      sm: 9,\n    },\n  };\n  const { renderInputField } = formikUtils(intl, formik, splitProps);\n\n  return (\n    <Form noValidate onSubmit={formik.handleSubmit}>\n      <div className=\"form-container px-3\">\n        {renderInputField(\"title\")}\n        {renderInputField(\"image_index\", \"number\")}\n      </div>\n      <div className=\"buttons-container px-3\">\n        <div className=\"d-flex\">\n          <Button\n            variant=\"primary\"\n            disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}\n            onClick={() => formik.submitForm()}\n          >\n            <FormattedMessage id=\"actions.save\" />\n          </Button>\n          <Button\n            variant=\"secondary\"\n            type=\"button\"\n            onClick={onClose}\n            className=\"ml-2\"\n          >\n            <FormattedMessage id=\"actions.cancel\" />\n          </Button>\n          {!isNew && (\n            <Button\n              variant=\"danger\"\n              className=\"ml-auto\"\n              onClick={() => onDelete()}\n            >\n              <FormattedMessage id=\"actions.delete\" />\n            </Button>\n          )}\n        </div>\n      </div>\n    </Form>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChaptersPanel.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ChapterEntries } from \"./ChapterEntry\";\nimport { GalleryChapterForm } from \"./GalleryChapterForm\";\n\ninterface IGalleryChapterPanelProps {\n  gallery: GQL.GalleryDataFragment;\n  isVisible: boolean;\n  onClickChapter: (index: number) => void;\n}\n\nexport const GalleryChapterPanel: React.FC<IGalleryChapterPanelProps> = ({\n  gallery,\n  isVisible,\n  onClickChapter,\n}) => {\n  const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);\n  const [editingChapter, setEditingChapter] =\n    useState<GQL.GalleryChapterDataFragment>();\n\n  // set up hotkeys\n  useEffect(() => {\n    if (!isVisible) return;\n\n    Mousetrap.bind(\"n\", () => onOpenEditor());\n\n    return () => {\n      Mousetrap.unbind(\"n\");\n    };\n  });\n\n  function onOpenEditor(chapter?: GQL.GalleryChapterDataFragment) {\n    setIsEditorOpen(true);\n    setEditingChapter(chapter ?? undefined);\n  }\n\n  const closeEditor = () => {\n    setEditingChapter(undefined);\n    setIsEditorOpen(false);\n  };\n\n  if (isEditorOpen)\n    return (\n      <GalleryChapterForm\n        galleryID={gallery.id}\n        chapter={editingChapter}\n        onClose={closeEditor}\n      />\n    );\n\n  return (\n    <div>\n      <Button onClick={() => onOpenEditor()}>\n        <FormattedMessage id=\"actions.create_chapters\" />\n      </Button>\n      <div className=\"container\">\n        <ChapterEntries\n          galleryChapters={gallery.chapters}\n          onClickChapter={onClickChapter}\n          onEdit={onOpenEditor}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default GalleryChapterPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useHistory, useLocation } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useGalleryCreate } from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { GalleryEditPanel } from \"./GalleryEditPanel\";\n\nconst GalleryCreate: React.FC = () => {\n  const history = useHistory();\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const location = useLocation();\n  const query = useMemo(() => new URLSearchParams(location.search), [location]);\n  const gallery = {\n    title: query.get(\"q\") ?? undefined,\n  };\n\n  const [createGallery] = useGalleryCreate();\n\n  async function onSave(input: GQL.GalleryCreateInput, andNew?: boolean) {\n    const result = await createGallery({\n      variables: { input },\n    });\n    if (result.data?.galleryCreate) {\n      if (!andNew) {\n        history.push(`/galleries/${result.data.galleryCreate.id}`);\n      }\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          { entity: intl.formatMessage({ id: \"gallery\" }).toLocaleLowerCase() }\n        )\n      );\n    }\n  }\n\n  return (\n    <div className=\"row new-view\">\n      <div className=\"col-md-6\">\n        <h2>\n          <FormattedMessage\n            id=\"actions.create_entity\"\n            values={{ entityType: intl.formatMessage({ id: \"gallery\" }) }}\n          />\n        </h2>\n        <GalleryEditPanel\n          gallery={gallery}\n          isVisible\n          onSubmit={onSave}\n          onDelete={() => {}}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default GalleryCreate;\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx",
    "content": "import React from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport { TagLink } from \"src/components/Shared/TagLink\";\nimport { PerformerCard } from \"src/components/Performers/PerformerCard\";\nimport { sortPerformers } from \"src/core/performers\";\nimport { PhotographerLink } from \"src/components/Shared/Link\";\nimport { CustomFields } from \"src/components/Shared/CustomFields\";\n\ninterface IGalleryDetailProps {\n  gallery: GQL.GalleryDataFragment;\n}\n\nexport const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({\n  gallery,\n}) => {\n  const intl = useIntl();\n\n  function renderDetails() {\n    if (!gallery.details) return;\n    return (\n      <>\n        <h6>\n          <FormattedMessage id=\"details\" />:{\" \"}\n        </h6>\n        <p className=\"pre\">{gallery.details}</p>\n      </>\n    );\n  }\n\n  function renderTags() {\n    if (gallery.tags.length === 0) return;\n    const tags = gallery.tags.map((tag) => (\n      <TagLink key={tag.id} tag={tag} linkType=\"gallery\" />\n    ));\n    return (\n      <>\n        <h6>\n          <FormattedMessage\n            id=\"countables.tags\"\n            values={{ count: gallery.tags.length }}\n          />\n        </h6>\n        {tags}\n      </>\n    );\n  }\n\n  function renderPerformers() {\n    if (gallery.performers.length === 0) return;\n    const performers = sortPerformers(gallery.performers);\n    const cards = performers.map((performer) => (\n      <PerformerCard\n        key={performer.id}\n        performer={performer}\n        ageFromDate={gallery.date ?? undefined}\n      />\n    ));\n\n    return (\n      <>\n        <h6>\n          <FormattedMessage\n            id=\"countables.performers\"\n            values={{ count: gallery.performers.length }}\n          />\n        </h6>\n        <div className=\"row justify-content-center gallery-performers\">\n          {cards}\n        </div>\n      </>\n    );\n  }\n\n  // filename should use entire row if there is no studio\n  const galleryDetailsWidth = gallery.studio ? \"col-9\" : \"col-12\";\n\n  return (\n    <>\n      <div className=\"row\">\n        <div className={`${galleryDetailsWidth} col-12 gallery-details`}>\n          <h6>\n            <FormattedMessage id=\"created_at\" />:{\" \"}\n            {TextUtils.formatDateTime(intl, gallery.created_at)}{\" \"}\n          </h6>\n          <h6>\n            <FormattedMessage id=\"updated_at\" />:{\" \"}\n            {TextUtils.formatDateTime(intl, gallery.updated_at)}{\" \"}\n          </h6>\n          {gallery.code && (\n            <h6>\n              <FormattedMessage id=\"scene_code\" />: {gallery.code}{\" \"}\n            </h6>\n          )}\n          {gallery.photographer && (\n            <h6>\n              <FormattedMessage id=\"photographer\" />:{\" \"}\n              <PhotographerLink\n                photographer={gallery.photographer}\n                linkType=\"gallery\"\n              />\n            </h6>\n          )}\n        </div>\n      </div>\n      <div className=\"row\">\n        <div className=\"col-12\">\n          {renderDetails()}\n          {renderTags()}\n          {renderPerformers()}\n          <CustomFields values={gallery.custom_fields} fullWidth />\n        </div>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Prompt } from \"react-router-dom\";\nimport { Button, Dropdown, Form, Col, Row, SplitButton } from \"react-bootstrap\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport * as yup from \"yup\";\nimport {\n  queryScrapeGallery,\n  queryScrapeGalleryURL,\n  useListGalleryScrapers,\n  mutateReloadScrapers,\n} from \"src/core/StashService\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useFormik } from \"formik\";\nimport { GalleryScrapeDialog } from \"./GalleryScrapeDialog\";\nimport isEqual from \"lodash-es/isEqual\";\nimport { handleUnsavedChanges } from \"src/utils/navigation\";\nimport {\n  Performer,\n  PerformerSelect,\n} from \"src/components/Performers/PerformerSelect\";\nimport {\n  yupDateString,\n  yupFormikValidate,\n  yupUniqueStringList,\n} from \"src/utils/yup\";\nimport { formikUtils } from \"src/utils/form\";\nimport { Studio, StudioSelect } from \"src/components/Studios/StudioSelect\";\nimport { Scene, SceneSelect } from \"src/components/Scenes/SceneSelect\";\nimport { useTagsEdit } from \"src/hooks/tagsEdit\";\nimport { ScraperMenu } from \"src/components/Shared/ScraperMenu\";\nimport {\n  CustomFieldsInput,\n  formatCustomFieldInput,\n} from \"src/components/Shared/CustomFields\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\n\ninterface IProps {\n  gallery: Partial<GQL.GalleryDataFragment>;\n  isVisible: boolean;\n  onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise<void>;\n  onDelete: () => void;\n}\n\nexport const GalleryEditPanel: React.FC<IProps> = ({\n  gallery,\n  isVisible,\n  onSubmit,\n  onDelete,\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const [scenes, setScenes] = useState<Scene[]>([]);\n\n  const [performers, setPerformers] = useState<Performer[]>([]);\n  const [studio, setStudio] = useState<Studio | null>(null);\n\n  const isNew = gallery.id === undefined;\n\n  const scrapers = useListGalleryScrapers();\n\n  const [scrapedGallery, setScrapedGallery] =\n    useState<GQL.ScrapedGallery | null>();\n\n  // Network state\n  const [isLoading, setIsLoading] = useState(false);\n\n  const titleRequired =\n    isNew || (gallery?.files?.length === 0 && !gallery?.folder);\n\n  const schema = yup.object({\n    title: titleRequired ? yup.string().required() : yup.string().ensure(),\n    code: yup.string().ensure(),\n    urls: yupUniqueStringList(intl),\n    date: yupDateString(intl),\n    photographer: yup.string().ensure(),\n    studio_id: yup.string().required().nullable(),\n    performer_ids: yup.array(yup.string().required()).defined(),\n    tag_ids: yup.array(yup.string().required()).defined(),\n    scene_ids: yup.array(yup.string().required()).defined(),\n    details: yup.string().ensure(),\n    custom_fields: yup.object().required().defined(),\n  });\n\n  const initialValues = {\n    title: gallery?.title ?? \"\",\n    code: gallery?.code ?? \"\",\n    urls: gallery?.urls ?? [],\n    date: gallery?.date ?? \"\",\n    photographer: gallery?.photographer ?? \"\",\n    studio_id: gallery?.studio?.id ?? null,\n    performer_ids: (gallery?.performers ?? []).map((p) => p.id),\n    tag_ids: (gallery?.tags ?? []).map((t) => t.id),\n    scene_ids: (gallery?.scenes ?? []).map((s) => s.id),\n    details: gallery?.details ?? \"\",\n    custom_fields: cloneDeep(gallery?.custom_fields ?? {}),\n  };\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const [customFieldsError, setCustomFieldsError] = useState<string>();\n\n  function submit(values: InputValues) {\n    const input = {\n      ...schema.cast(values),\n      custom_fields: formatCustomFieldInput(isNew, values.custom_fields),\n    };\n    onSave(input);\n  }\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: submit,\n  });\n\n  const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(\n    gallery.tags,\n    (ids) => formik.setFieldValue(\"tag_ids\", ids)\n  );\n\n  function onSetScenes(items: Scene[]) {\n    setScenes(items);\n    formik.setFieldValue(\n      \"scene_ids\",\n      items.map((i) => i.id)\n    );\n  }\n\n  function onSetPerformers(items: Performer[]) {\n    setPerformers(items);\n    formik.setFieldValue(\n      \"performer_ids\",\n      items.map((item) => item.id)\n    );\n  }\n\n  function onSetStudio(item: Studio | null) {\n    setStudio(item);\n    formik.setFieldValue(\"studio_id\", item ? item.id : null);\n  }\n\n  useEffect(() => {\n    setPerformers(gallery.performers ?? []);\n  }, [gallery.performers]);\n\n  useEffect(() => {\n    setStudio(gallery.studio ?? null);\n  }, [gallery.studio]);\n\n  useEffect(() => {\n    setScenes(gallery.scenes ?? []);\n  }, [gallery.scenes]);\n\n  useEffect(() => {\n    if (isVisible) {\n      Mousetrap.bind(\"s s\", () => {\n        if (formik.dirty) {\n          formik.submitForm();\n        }\n      });\n      Mousetrap.bind(\"d d\", () => {\n        onDelete();\n      });\n\n      return () => {\n        Mousetrap.unbind(\"s s\");\n        Mousetrap.unbind(\"d d\");\n      };\n    }\n  });\n\n  const fragmentScrapers = useMemo(() => {\n    return (scrapers?.data?.listScrapers ?? []).filter((s) =>\n      s.gallery?.supported_scrapes.includes(GQL.ScrapeType.Fragment)\n    );\n  }, [scrapers]);\n\n  const cover = useMemo(() => {\n    if (gallery?.paths?.cover) {\n      return (\n        <div className=\"gallery-cover\">\n          <img\n            src={gallery.paths.cover}\n            alt={intl.formatMessage({ id: \"cover_image\" })}\n          />\n        </div>\n      );\n    }\n\n    return <div></div>;\n  }, [gallery?.paths?.cover, intl]);\n\n  async function onSave(input: InputValues, andNew?: boolean) {\n    setIsLoading(true);\n    try {\n      await onSubmit(input, andNew);\n      formik.resetForm();\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsLoading(false);\n  }\n\n  async function onSaveAndNewClick() {\n    const input = {\n      ...schema.cast(formik.values),\n      custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),\n    };\n    onSave(input, true);\n  }\n\n  async function onScrapeClicked(s: GQL.ScraperSourceInput) {\n    if (!gallery || !gallery.id) return;\n\n    setIsLoading(true);\n    try {\n      const result = await queryScrapeGallery(s.scraper_id!, gallery.id);\n      if (!result.data || !result.data.scrapeSingleGallery?.length) {\n        Toast.success(\"No galleries found\");\n        return;\n      }\n      setScrapedGallery(result.data.scrapeSingleGallery[0]);\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  async function onReloadScrapers() {\n    setIsLoading(true);\n    try {\n      await mutateReloadScrapers();\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  function onScrapeDialogClosed(data?: GQL.ScrapedGalleryDataFragment) {\n    if (data) {\n      updateGalleryFromScrapedGallery(data);\n    }\n    setScrapedGallery(undefined);\n  }\n\n  function maybeRenderScrapeDialog() {\n    if (!scrapedGallery) {\n      return;\n    }\n\n    const currentGallery = {\n      id: gallery.id!,\n      ...formik.values,\n    };\n\n    return (\n      <GalleryScrapeDialog\n        gallery={currentGallery}\n        galleryStudio={studio}\n        galleryTags={tags}\n        galleryPerformers={performers}\n        scraped={scrapedGallery}\n        onClose={(data) => {\n          onScrapeDialogClosed(data);\n        }}\n      />\n    );\n  }\n\n  function urlScrapable(scrapedUrl: string): boolean {\n    return (scrapers?.data?.listScrapers ?? []).some((s) =>\n      (s?.gallery?.urls ?? []).some((u) => scrapedUrl.includes(u))\n    );\n  }\n\n  function updateGalleryFromScrapedGallery(\n    galleryData: GQL.ScrapedGalleryDataFragment\n  ) {\n    if (galleryData.title) {\n      formik.setFieldValue(\"title\", galleryData.title);\n    }\n\n    if (galleryData.code) {\n      formik.setFieldValue(\"code\", galleryData.code);\n    }\n\n    if (galleryData.details) {\n      formik.setFieldValue(\"details\", galleryData.details);\n    }\n\n    if (galleryData.photographer) {\n      formik.setFieldValue(\"photographer\", galleryData.photographer);\n    }\n\n    if (galleryData.date) {\n      formik.setFieldValue(\"date\", galleryData.date);\n    }\n\n    if (galleryData.urls) {\n      formik.setFieldValue(\"urls\", galleryData.urls);\n    }\n\n    if (galleryData.studio?.stored_id) {\n      onSetStudio({\n        id: galleryData.studio.stored_id,\n        name: galleryData.studio.name ?? \"\",\n        aliases: [],\n      });\n    }\n\n    if (galleryData.performers?.length) {\n      const idPerfs = galleryData.performers.filter((p) => {\n        return p.stored_id !== undefined && p.stored_id !== null;\n      });\n\n      if (idPerfs.length > 0) {\n        onSetPerformers(\n          idPerfs.map((p) => {\n            return {\n              id: p.stored_id!,\n              name: p.name ?? \"\",\n              alias_list: [],\n            };\n          })\n        );\n      }\n    }\n\n    updateTagsStateFromScraper(galleryData.tags ?? undefined);\n  }\n\n  async function onScrapeGalleryURL(url: string) {\n    if (!url) {\n      return;\n    }\n    setIsLoading(true);\n    try {\n      const result = await queryScrapeGalleryURL(url);\n      if (!result || !result.data || !result.data.scrapeGalleryURL) {\n        return;\n      }\n      setScrapedGallery(result.data.scrapeGalleryURL);\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  if (isLoading) return <LoadingIndicator />;\n\n  const splitProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n    },\n    fieldProps: {\n      sm: 9,\n    },\n  };\n  const fullWidthProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n      xl: 12,\n    },\n    fieldProps: {\n      sm: 9,\n      xl: 12,\n    },\n  };\n  const urlProps = isNew\n    ? splitProps\n    : {\n        labelProps: {\n          column: true,\n          md: 3,\n          lg: 12,\n        },\n        fieldProps: {\n          md: 9,\n          lg: 12,\n        },\n      };\n  const { renderField, renderInputField, renderDateField, renderURLListField } =\n    formikUtils(intl, formik, splitProps);\n\n  function renderScenesField() {\n    const title = intl.formatMessage({ id: \"scenes\" });\n    const control = (\n      <SceneSelect\n        values={scenes}\n        onSelect={(items) => onSetScenes(items)}\n        isMulti\n      />\n    );\n\n    return renderField(\"scene_ids\", title, control);\n  }\n\n  function renderStudioField() {\n    const title = intl.formatMessage({ id: \"studio\" });\n    const control = (\n      <StudioSelect\n        onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)}\n        values={studio ? [studio] : []}\n      />\n    );\n\n    return renderField(\"studio_id\", title, control);\n  }\n\n  function renderPerformersField() {\n    const date = (() => {\n      try {\n        return schema.validateSyncAt(\"date\", formik.values);\n      } catch (e) {\n        return undefined;\n      }\n    })();\n\n    const title = intl.formatMessage({ id: \"performers\" });\n    const control = (\n      <PerformerSelect\n        isMulti\n        onSelect={onSetPerformers}\n        values={performers}\n        ageFromDate={date}\n      />\n    );\n\n    return renderField(\"performer_ids\", title, control, fullWidthProps);\n  }\n\n  function renderTagsField() {\n    const title = intl.formatMessage({ id: \"tags\" });\n    return renderField(\"tag_ids\", title, tagsControl(), fullWidthProps);\n  }\n\n  function renderDetailsField() {\n    const props = {\n      labelProps: {\n        column: true,\n        sm: 3,\n        lg: 12,\n      },\n      fieldProps: {\n        sm: 9,\n        lg: 12,\n      },\n    };\n\n    return renderInputField(\"details\", \"textarea\", \"details\", props);\n  }\n\n  return (\n    <div id=\"gallery-edit-details\">\n      <Prompt\n        when={formik.dirty}\n        message={handleUnsavedChanges(intl, \"galleries\", gallery?.id)}\n      />\n\n      {maybeRenderScrapeDialog()}\n      <Form noValidate onSubmit={formik.handleSubmit}>\n        <Row className=\"form-container edit-buttons-container px-3 pt-3\">\n          <div className=\"edit-buttons mb-3 pl-0\">\n            {isNew ? (\n              <SplitButton\n                id=\"gallery-save-split-button\"\n                className=\"edit-button\"\n                variant=\"primary\"\n                disabled={\n                  !isEqual(formik.errors, {}) || customFieldsError !== undefined\n                }\n                title={intl.formatMessage({ id: \"actions.save\" })}\n                onClick={() => formik.submitForm()}\n              >\n                <Dropdown.Item onClick={() => onSaveAndNewClick()}>\n                  <FormattedMessage id=\"actions.save_and_new\" />\n                </Dropdown.Item>\n              </SplitButton>\n            ) : (\n              <Button\n                className=\"edit-button\"\n                variant=\"primary\"\n                disabled={\n                  (!isNew && !formik.dirty) ||\n                  !isEqual(formik.errors, {}) ||\n                  customFieldsError !== undefined\n                }\n                onClick={() => formik.submitForm()}\n              >\n                <FormattedMessage id=\"actions.save\" />\n              </Button>\n            )}\n            <Button\n              className=\"edit-button\"\n              variant=\"danger\"\n              onClick={() => onDelete()}\n            >\n              <FormattedMessage id=\"actions.delete\" />\n            </Button>\n          </div>\n          <div className=\"ml-auto text-right d-flex\">\n            {!isNew && (\n              <ScraperMenu\n                toggle={intl.formatMessage({ id: \"actions.scrape_with\" })}\n                scrapers={fragmentScrapers}\n                onScraperClicked={onScrapeClicked}\n                onReloadScrapers={onReloadScrapers}\n              />\n            )}\n          </div>\n        </Row>\n        <Row className=\"form-container px-3\">\n          <Col lg={7} xl={12}>\n            {renderInputField(\"title\")}\n            {renderInputField(\"code\", \"text\", \"scene_code\")}\n\n            {renderURLListField(\n              \"urls\",\n              onScrapeGalleryURL,\n              urlScrapable,\n              \"urls\",\n              urlProps\n            )}\n\n            {renderDateField(\"date\")}\n            {renderInputField(\"photographer\")}\n\n            {renderScenesField()}\n            {renderStudioField()}\n            {renderPerformersField()}\n            {renderTagsField()}\n          </Col>\n          <Col lg={5} xl={12}>\n            {renderDetailsField()}\n            <Form.Group controlId=\"cover_image\">\n              <Form.Label>\n                <FormattedMessage id=\"cover_image\" />\n              </Form.Label>\n              {cover}\n            </Form.Group>\n\n            <CustomFieldsInput\n              values={formik.values.custom_fields}\n              onChange={(v) => formik.setFieldValue(\"custom_fields\", v)}\n              error={customFieldsError}\n              setError={(e) => setCustomFieldsError(e)}\n            />\n          </Col>\n        </Row>\n      </Form>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport { Accordion, Button, Card } from \"react-bootstrap\";\nimport { FormattedMessage, FormattedTime } from \"react-intl\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { DeleteFilesDialog } from \"src/components/Shared/DeleteFilesDialog\";\nimport { RevealInFilesystemButton } from \"src/components/Shared/RevealInFilesystemButton\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { mutateGallerySetPrimaryFile } from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport TextUtils from \"src/utils/text\";\nimport { TextField, URLsField } from \"src/utils/field\";\n\ninterface IFileInfoPanelProps {\n  folder?: Pick<GQL.Folder, \"id\" | \"path\">;\n  file?: GQL.GalleryFileDataFragment;\n  primary?: boolean;\n  ofMany?: boolean;\n  onSetPrimaryFile?: () => void;\n  onDeleteFile?: () => void;\n  loading?: boolean;\n}\n\nconst FileInfoPanel: React.FC<IFileInfoPanelProps> = (\n  props: IFileInfoPanelProps\n) => {\n  const checksum = props.file?.fingerprints.find((f) => f.type === \"md5\");\n  const path = props.folder ? props.folder.path : props.file?.path ?? \"\";\n  const id = props.folder ? \"folder\" : \"path\";\n\n  return (\n    <div>\n      <dl className=\"container gallery-file-info details-list\">\n        {props.primary && (\n          <>\n            <dt></dt>\n            <dd className=\"primary-file\">\n              <FormattedMessage id=\"primary_file\" />\n            </dd>\n          </>\n        )}\n        <TextField id=\"media_info.md5\" value={checksum?.value} truncate />\n        <TextField id={id}>\n          <span className=\"d-flex align-items-center\">\n            <TruncatedText text={path} />\n            <RevealInFilesystemButton\n              folderId={props.folder?.id}\n              fileId={props.file?.id}\n            />\n          </span>\n        </TextField>\n        {props.file && (\n          <TextField id=\"file_mod_time\">\n            <FormattedTime\n              dateStyle=\"medium\"\n              timeStyle=\"medium\"\n              value={props.file.mod_time ?? 0}\n            />\n          </TextField>\n        )}\n      </dl>\n      {props.ofMany && props.onSetPrimaryFile && !props.primary && (\n        <div>\n          <Button\n            className=\"edit-button\"\n            disabled={props.loading}\n            onClick={props.onSetPrimaryFile}\n          >\n            <FormattedMessage id=\"actions.make_primary\" />\n          </Button>\n          <Button\n            variant=\"danger\"\n            disabled={props.loading}\n            onClick={props.onDeleteFile}\n          >\n            <FormattedMessage id=\"actions.delete_file\" />\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\ninterface IGalleryFileInfoPanelProps {\n  gallery: GQL.GalleryDataFragment;\n}\n\nexport const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (\n  props: IGalleryFileInfoPanelProps\n) => {\n  const Toast = useToast();\n\n  const [loading, setLoading] = useState(false);\n  const [deletingFile, setDeletingFile] = useState<\n    GQL.GalleryFileDataFragment | undefined\n  >();\n\n  const filesPanel = useMemo(() => {\n    if (props.gallery.folder) {\n      return <FileInfoPanel folder={props.gallery.folder} />;\n    }\n\n    if (props.gallery.files.length === 0) {\n      return <></>;\n    }\n\n    if (props.gallery.files.length === 1) {\n      return <FileInfoPanel file={props.gallery.files[0]} />;\n    }\n\n    async function onSetPrimaryFile(fileID: string) {\n      try {\n        setLoading(true);\n        await mutateGallerySetPrimaryFile(props.gallery.id, fileID);\n      } catch (e) {\n        Toast.error(e);\n      } finally {\n        setLoading(false);\n      }\n    }\n\n    return (\n      <Accordion defaultActiveKey={props.gallery.files[0].id}>\n        {deletingFile && (\n          <DeleteFilesDialog\n            onClose={() => setDeletingFile(undefined)}\n            selected={[deletingFile]}\n          />\n        )}\n        {props.gallery.files.map((file, index) => (\n          <Card key={file.id} className=\"gallery-file-card\">\n            <Accordion.Toggle as={Card.Header} eventKey={file.id}>\n              <TruncatedText text={TextUtils.fileNameFromPath(file.path)} />\n            </Accordion.Toggle>\n            <Accordion.Collapse eventKey={file.id}>\n              <Card.Body>\n                <FileInfoPanel\n                  file={file}\n                  primary={index === 0}\n                  ofMany\n                  onSetPrimaryFile={() => onSetPrimaryFile(file.id)}\n                  loading={loading}\n                  onDeleteFile={() => setDeletingFile(file)}\n                />\n              </Card.Body>\n            </Accordion.Collapse>\n          </Card>\n        ))}\n      </Accordion>\n    );\n  }, [props.gallery, loading, Toast, deletingFile]);\n\n  return (\n    <>\n      <dl className=\"container gallery-file-info details-list\">\n        <URLsField id=\"urls\" urls={props.gallery.urls} truncate />\n      </dl>\n\n      {filesPanel}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx",
    "content": "import React, { useCallback } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { GalleriesCriterion } from \"src/models/list-filter/criteria/galleries\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { FilteredImageList } from \"src/components/Images/ImageList\";\nimport {\n  mutateRemoveGalleryImages,\n  mutateSetGalleryCover,\n} from \"src/core/StashService\";\nimport {\n  showWhenSelected,\n  showWhenSingleSelection,\n} from \"src/components/List/ItemList\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useIntl } from \"react-intl\";\nimport { faMinus } from \"@fortawesome/free-solid-svg-icons\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { View } from \"src/components/List/views\";\nimport { PatchComponent } from \"src/patch\";\nimport { IItemListOperation } from \"src/components/List/FilteredListToolbar\";\n\ninterface IGalleryDetailsProps {\n  active: boolean;\n  gallery: GQL.GalleryDataFragment;\n  extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];\n}\n\nexport const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =\n  PatchComponent(\n    \"GalleryImagesPanel\",\n    ({ active, gallery, extraOperations = [] }) => {\n      const intl = useIntl();\n      const Toast = useToast();\n\n      const filterHook = useCallback(\n        (filter: ListFilterModel) => {\n          const galleryValue = {\n            id: gallery.id!,\n            label: galleryTitle(gallery),\n          };\n          // if galleries is already present, then we modify it, otherwise add\n          let galleryCriterion = filter.criteria.find((c) => {\n            return c.criterionOption.type === \"galleries\";\n          }) as GalleriesCriterion | undefined;\n\n          if (\n            galleryCriterion &&\n            (galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll ||\n              galleryCriterion.modifier === GQL.CriterionModifier.Includes)\n          ) {\n            // add the gallery if not present\n            if (\n              !galleryCriterion.value.find((p) => {\n                return p.id === gallery.id;\n              })\n            ) {\n              galleryCriterion.value.push(galleryValue);\n            }\n\n            galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll;\n          } else {\n            // overwrite\n            galleryCriterion = new GalleriesCriterion();\n            galleryCriterion.value = [galleryValue];\n            filter.criteria.push(galleryCriterion);\n          }\n\n          return filter;\n        },\n        [gallery]\n      );\n\n      async function setCover(\n        result: GQL.FindImagesQueryResult,\n        filter: ListFilterModel,\n        selectedIds: Set<string>\n      ) {\n        const coverImageID = selectedIds.values().next();\n        if (coverImageID.done) {\n          // operation should only be displayed when exactly one image is selected\n          return;\n        }\n        try {\n          await mutateSetGalleryCover({\n            gallery_id: gallery.id!,\n            cover_image_id: coverImageID.value,\n          });\n\n          Toast.success(\n            intl.formatMessage(\n              { id: \"toast.updated_entity\" },\n              {\n                entity: intl\n                  .formatMessage({ id: \"gallery\" })\n                  .toLocaleLowerCase(),\n              }\n            )\n          );\n        } catch (e) {\n          Toast.error(e);\n        }\n      }\n\n      async function removeImages(\n        result: GQL.FindImagesQueryResult,\n        filter: ListFilterModel,\n        selectedIds: Set<string>\n      ) {\n        try {\n          await mutateRemoveGalleryImages({\n            gallery_id: gallery.id!,\n            image_ids: Array.from(selectedIds.values()),\n          });\n\n          Toast.success(\n            intl.formatMessage(\n              { id: \"toast.removed_entity\" },\n              {\n                count: selectedIds.size,\n                singularEntity: intl.formatMessage({ id: \"image\" }),\n                pluralEntity: intl.formatMessage({ id: \"images\" }),\n              }\n            )\n          );\n        } catch (e) {\n          Toast.error(e);\n        }\n      }\n\n      const otherOperations = [\n        ...extraOperations,\n        {\n          text: intl.formatMessage({ id: \"actions.set_cover\" }),\n          onClick: setCover,\n          isDisplayed: showWhenSingleSelection,\n        },\n        {\n          text: intl.formatMessage({ id: \"actions.remove_from_gallery\" }),\n          onClick: removeImages,\n          isDisplayed: showWhenSelected,\n          postRefetch: true,\n          icon: faMinus,\n          buttonVariant: \"danger\",\n        },\n      ];\n\n      return (\n        <FilteredImageList\n          filterHook={filterHook}\n          alterQuery={active}\n          extraOperations={otherOperations}\n          view={View.GalleryImages}\n          chapters={gallery.chapters}\n        />\n      );\n    }\n  );\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScenesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SceneCard } from \"src/components/Scenes/SceneCard\";\n\ninterface IGalleryScenesPanelProps {\n  scenes: GQL.SlimSceneDataFragment[];\n}\n\nexport const GalleryScenesPanel: React.FC<IGalleryScenesPanelProps> = ({\n  scenes,\n}) => (\n  <div className=\"container gallery-scenes\">\n    {scenes.map((scene) => (\n      <SceneCard scene={scene} key={scene.id} />\n    ))}\n  </div>\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  ScrapedInputGroupRow,\n  ScrapedStringListRow,\n  ScrapedTextAreaRow,\n} from \"src/components/Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { ScrapeDialog } from \"src/components/Shared/ScrapeDialog/ScrapeDialog\";\nimport {\n  ObjectListScrapeResult,\n  ObjectScrapeResult,\n  ScrapeResult,\n} from \"src/components/Shared/ScrapeDialog/scrapeResult\";\nimport {\n  ScrapedPerformersRow,\n  ScrapedStudioRow,\n} from \"src/components/Shared/ScrapeDialog/ScrapedObjectsRow\";\nimport { sortStoredIdObjects } from \"src/utils/data\";\nimport { Performer } from \"src/components/Performers/PerformerSelect\";\nimport {\n  useCreateScrapedPerformer,\n  useCreateScrapedStudio,\n} from \"src/components/Shared/ScrapeDialog/createObjects\";\nimport { uniq } from \"lodash-es\";\nimport { Tag } from \"src/components/Tags/TagSelect\";\nimport { Studio } from \"src/components/Studios/StudioSelect\";\nimport { useScrapedTags } from \"src/components/Shared/ScrapeDialog/scrapedTags\";\n\ninterface IGalleryScrapeDialogProps {\n  gallery: Partial<GQL.GalleryUpdateInput>;\n  galleryStudio: Studio | null;\n  galleryTags: Tag[];\n  galleryPerformers: Performer[];\n  scraped: GQL.ScrapedGallery;\n\n  onClose: (scrapedGallery?: GQL.ScrapedGallery) => void;\n}\n\nexport const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({\n  gallery,\n  galleryStudio,\n  galleryTags,\n  galleryPerformers,\n  scraped,\n  onClose,\n}) => {\n  const intl = useIntl();\n  const [title, setTitle] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(gallery.title, scraped.title)\n  );\n  const [code, setCode] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(gallery.code, scraped.code)\n  );\n  const [urls, setURLs] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(\n      gallery.urls,\n      scraped.urls\n        ? uniq((gallery.urls ?? []).concat(scraped.urls ?? []))\n        : undefined\n    )\n  );\n  const [date, setDate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(gallery.date, scraped.date)\n  );\n  const [photographer, setPhotographer] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(gallery.photographer, scraped.photographer)\n  );\n  const [studio, setStudio] = useState<ObjectScrapeResult<GQL.ScrapedStudio>>(\n    new ObjectScrapeResult<GQL.ScrapedStudio>(\n      galleryStudio\n        ? {\n            stored_id: galleryStudio.id,\n            name: galleryStudio.name,\n          }\n        : undefined,\n      scraped.studio\n    )\n  );\n  const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(\n    scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined\n  );\n\n  const [performers, setPerformers] = useState<\n    ObjectListScrapeResult<GQL.ScrapedPerformer>\n  >(\n    new ObjectListScrapeResult<GQL.ScrapedPerformer>(\n      sortStoredIdObjects(\n        galleryPerformers.map((p) => ({\n          stored_id: p.id,\n          name: p.name,\n        }))\n      ),\n      sortStoredIdObjects(scraped.performers ?? undefined)\n    )\n  );\n  const [newPerformers, setNewPerformers] = useState<GQL.ScrapedPerformer[]>(\n    scraped.performers?.filter((t) => !t.stored_id) ?? []\n  );\n\n  const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(\n    galleryTags,\n    scraped.tags\n  );\n\n  const [details, setDetails] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(gallery.details, scraped.details)\n  );\n\n  const createNewStudio = useCreateScrapedStudio({\n    scrapeResult: studio,\n    setScrapeResult: setStudio,\n    setNewObject: setNewStudio,\n  });\n\n  const createNewPerformer = useCreateScrapedPerformer({\n    scrapeResult: performers,\n    setScrapeResult: setPerformers,\n    newObjects: newPerformers,\n    setNewObjects: setNewPerformers,\n  });\n\n  // don't show the dialog if nothing was scraped\n  if (\n    [\n      title,\n      code,\n      urls,\n      date,\n      photographer,\n      studio,\n      performers,\n      tags,\n      details,\n    ].every((r) => !r.scraped) &&\n    !newStudio &&\n    newPerformers.length === 0 &&\n    newTags.length === 0\n  ) {\n    onClose();\n    return <></>;\n  }\n\n  function makeNewScrapedItem(): GQL.ScrapedGalleryDataFragment {\n    const newStudioValue = studio.getNewValue();\n\n    return {\n      title: title.getNewValue(),\n      code: code.getNewValue(),\n      urls: urls.getNewValue(),\n      date: date.getNewValue(),\n      photographer: photographer.getNewValue(),\n      studio: newStudioValue,\n      performers: performers.getNewValue(),\n      tags: tags.getNewValue(),\n      details: details.getNewValue(),\n    };\n  }\n\n  function renderScrapeRows() {\n    return (\n      <>\n        <ScrapedInputGroupRow\n          field=\"title\"\n          title={intl.formatMessage({ id: \"title\" })}\n          result={title}\n          onChange={(value) => setTitle(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"code\"\n          title={intl.formatMessage({ id: \"scene_code\" })}\n          result={code}\n          onChange={(value) => setCode(value)}\n        />\n        <ScrapedStringListRow\n          field=\"urls\"\n          title={intl.formatMessage({ id: \"urls\" })}\n          result={urls}\n          onChange={(value) => setURLs(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"date\"\n          title={intl.formatMessage({ id: \"date\" })}\n          placeholder=\"YYYY-MM-DD\"\n          result={date}\n          onChange={(value) => setDate(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"photographer\"\n          title={intl.formatMessage({ id: \"photographer\" })}\n          result={photographer}\n          onChange={(value) => setPhotographer(value)}\n        />\n        <ScrapedStudioRow\n          field=\"studio\"\n          title={intl.formatMessage({ id: \"studios\" })}\n          result={studio}\n          onChange={(value) => setStudio(value)}\n          newStudio={newStudio}\n          onCreateNew={createNewStudio}\n        />\n        <ScrapedPerformersRow\n          field=\"performers\"\n          title={intl.formatMessage({ id: \"performers\" })}\n          result={performers}\n          onChange={(value) => setPerformers(value)}\n          newObjects={newPerformers}\n          onCreateNew={createNewPerformer}\n          ageFromDate={date.useNewValue ? date.newValue : date.originalValue}\n        />\n        {scrapedTagsRow}\n        <ScrapedTextAreaRow\n          field=\"details\"\n          title={intl.formatMessage({ id: \"details\" })}\n          result={details}\n          onChange={(value) => setDetails(value)}\n        />\n      </>\n    );\n  }\n\n  if (linkDialog) {\n    return linkDialog;\n  }\n\n  return (\n    <ScrapeDialog\n      title={intl.formatMessage(\n        { id: \"dialogs.scrape_entity_title\" },\n        { entity_type: intl.formatMessage({ id: \"gallery\" }) }\n      )}\n      onClose={(apply) => {\n        onClose(apply ? makeNewScrapedItem() : undefined);\n      }}\n    >\n      {renderScrapeRows()}\n    </ScrapeDialog>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryList.tsx",
    "content": "import React, { useCallback, useEffect } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport cloneDeep from \"lodash-es/cloneDeep\";\nimport { useHistory } from \"react-router-dom\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useFilteredItemList } from \"../List/ItemList\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { queryFindGalleries, useFindGalleries } from \"src/core/StashService\";\nimport GalleryWallCard from \"./GalleryWallCard\";\nimport { EditGalleriesDialog } from \"./EditGalleriesDialog\";\nimport { DeleteGalleriesDialog } from \"./DeleteGalleriesDialog\";\nimport { ExportDialog } from \"../Shared/ExportDialog\";\nimport { GenerateDialog } from \"../Dialogs/GenerateDialog\";\nimport { GalleryListTable } from \"./GalleryListTable\";\nimport { GalleryCardGrid } from \"./GalleryCardGrid\";\nimport { View } from \"../List/views\";\nimport useFocus from \"src/utils/focus\";\nimport {\n  Sidebar,\n  SidebarPane,\n  SidebarPaneContent,\n  SidebarStateContext,\n  useSidebarState,\n} from \"../Shared/Sidebar\";\nimport { useCloseEditDelete, useFilterOperations } from \"../List/util\";\nimport {\n  FilteredSidebarHeader,\n  useFilteredSidebarKeybinds,\n} from \"../List/Filters/FilterSidebar\";\nimport cx from \"classnames\";\nimport { LoadedContent } from \"../List/PagedList\";\nimport { Pagination, PaginationIndex } from \"../List/Pagination\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport { SidebarStudiosFilter } from \"../List/Filters/StudiosFilter\";\nimport { SidebarPerformersFilter } from \"../List/Filters/PerformersFilter\";\nimport { SidebarTagsFilter } from \"../List/Filters/TagsFilter\";\nimport { SidebarRatingFilter } from \"../List/Filters/RatingFilter\";\nimport { SidebarBooleanFilter } from \"../List/Filters/BooleanFilter\";\nimport { OrganizedCriterionOption } from \"src/models/list-filter/criteria/organized\";\nimport { Button } from \"react-bootstrap\";\nimport {\n  IListFilterOperation,\n  ListOperations,\n} from \"../List/ListOperationButtons\";\nimport {\n  FilteredListToolbar,\n  IItemListOperation,\n} from \"../List/FilteredListToolbar\";\nimport { FilterTags } from \"../List/FilterTags\";\nimport { SidebarAgeFilter } from \"../List/Filters/SidebarAgeFilter\";\nimport { PerformerAgeCriterionOption } from \"src/models/list-filter/galleries\";\nimport { SidebarFolderFilter } from \"../List/Filters/FolderFilter\";\nimport { ParentFolderCriterionOption } from \"src/models/list-filter/criteria/folder\";\n\nconst GalleryList: React.FC<{\n  galleries: GQL.SlimGalleryDataFragment[];\n  filter: ListFilterModel;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}> = PatchComponent(\n  \"GalleryList\",\n  ({ galleries, filter, selectedIds, onSelectChange }) => {\n    if (galleries.length === 0) {\n      return null;\n    }\n\n    if (filter.displayMode === DisplayMode.Grid) {\n      return (\n        <GalleryCardGrid\n          galleries={galleries}\n          selectedIds={selectedIds}\n          zoomIndex={filter.zoomIndex}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.List) {\n      return (\n        <GalleryListTable\n          galleries={galleries}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.Wall) {\n      return (\n        <div className={`GalleryWall zoom-${filter.zoomIndex}`}>\n          {galleries.map((gallery) => (\n            <GalleryWallCard\n              key={gallery.id}\n              gallery={gallery}\n              selected={selectedIds.has(gallery.id)}\n              onSelectedChanged={(selected, shiftKey) =>\n                onSelectChange(gallery.id, selected, shiftKey)\n              }\n              selecting={selectedIds.size > 0}\n            />\n          ))}\n        </div>\n      );\n    }\n\n    return null;\n  }\n);\n\nconst GalleryFilterSidebarSections = PatchContainerComponent(\n  \"FilteredGalleryList.SidebarSections\"\n);\n\nconst SidebarContent: React.FC<{\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  sidebarOpen: boolean;\n  onClose?: () => void;\n  showEditFilter: (editingCriterion?: string) => void;\n  count?: number;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  filter,\n  setFilter,\n  filterHook,\n  view,\n  showEditFilter,\n  sidebarOpen,\n  onClose,\n  count,\n  focus,\n}) => {\n  const showResultsId =\n    count !== undefined ? \"actions.show_count_results\" : \"actions.show_results\";\n\n  const hideStudios = view === View.StudioScenes;\n\n  return (\n    <>\n      <FilteredSidebarHeader\n        sidebarOpen={sidebarOpen}\n        showEditFilter={showEditFilter}\n        filter={filter}\n        setFilter={setFilter}\n        view={view}\n        focus={focus}\n      />\n\n      <GalleryFilterSidebarSections>\n        {!hideStudios && (\n          <SidebarStudiosFilter\n            filter={filter}\n            setFilter={setFilter}\n            filterHook={filterHook}\n          />\n        )}\n        <SidebarPerformersFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarTagsFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarRatingFilter filter={filter} setFilter={setFilter} />\n        <SidebarFolderFilter\n          text={<FormattedMessage id=\"parent_folder\" />}\n          criterionOption={ParentFolderCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"parent_folder\"\n        />\n        <SidebarBooleanFilter\n          title={<FormattedMessage id=\"organized\" />}\n          data-type={OrganizedCriterionOption.type}\n          option={OrganizedCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"organized\"\n        />\n        <SidebarAgeFilter\n          title={<FormattedMessage id=\"performer_age\" />}\n          option={PerformerAgeCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"performer_age\"\n        />\n      </GalleryFilterSidebarSections>\n\n      <div className=\"sidebar-footer\">\n        <Button className=\"sidebar-close-button\" onClick={onClose}>\n          <FormattedMessage id={showResultsId} values={{ count }} />\n        </Button>\n      </div>\n    </>\n  );\n};\n\ninterface IGalleryList {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  alterQuery?: boolean;\n  extraOperations?: IItemListOperation<GQL.FindGalleriesQueryResult>[];\n}\n\nfunction useViewRandom(filter: ListFilterModel, count: number) {\n  const history = useHistory();\n\n  const viewRandom = useCallback(async () => {\n    // query for a random scene\n    if (count === 0) {\n      return;\n    }\n\n    const index = Math.floor(Math.random() * count);\n    const filterCopy = cloneDeep(filter);\n    filterCopy.itemsPerPage = 1;\n    filterCopy.currentPage = index + 1;\n    const singleResult = await queryFindGalleries(filterCopy);\n    if (singleResult.data.findGalleries.galleries.length === 1) {\n      const { id } = singleResult.data.findGalleries.galleries[0];\n      // navigate to the image player page\n      history.push(`/galleries/${id}`);\n    }\n  }, [history, filter, count]);\n\n  return viewRandom;\n}\n\nfunction useAddKeybinds(filter: ListFilterModel, count: number) {\n  const viewRandom = useViewRandom(filter, count);\n\n  useEffect(() => {\n    Mousetrap.bind(\"p r\", () => {\n      viewRandom();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"p r\");\n    };\n  }, [viewRandom]);\n}\n\nexport const FilteredGalleryList = PatchComponent(\n  \"FilteredGalleryList\",\n  (props: IGalleryList) => {\n    const intl = useIntl();\n\n    const searchFocus = useFocus();\n\n    const { filterHook, view, alterQuery, extraOperations = [] } = props;\n\n    // States\n    const {\n      showSidebar,\n      setShowSidebar,\n      sectionOpen,\n      setSectionOpen,\n      loading: sidebarStateLoading,\n    } = useSidebarState(view);\n\n    const { filterState, queryResult, modalState, listSelect, showEditFilter } =\n      useFilteredItemList({\n        filterStateProps: {\n          filterMode: GQL.FilterMode.Galleries,\n          view,\n          useURL: alterQuery,\n        },\n        queryResultProps: {\n          useResult: useFindGalleries,\n          getCount: (r) => r.data?.findGalleries.count ?? 0,\n          getItems: (r) => r.data?.findGalleries.galleries ?? [],\n          filterHook,\n        },\n      });\n\n    const { filter, setFilter } = filterState;\n\n    const { effectiveFilter, result, cachedResult, items, totalCount } =\n      queryResult;\n\n    const {\n      selectedIds,\n      selectedItems,\n      onSelectChange,\n      onSelectAll,\n      onSelectNone,\n      onInvertSelection,\n      hasSelection,\n    } = listSelect;\n\n    const { modal, showModal, closeModal } = modalState;\n\n    // Utility hooks\n    const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({\n      filter,\n      setFilter,\n    });\n\n    useAddKeybinds(effectiveFilter, totalCount);\n    useFilteredSidebarKeybinds({\n      showSidebar,\n      setShowSidebar,\n    });\n\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => {\n        if (hasSelection) {\n          onEdit?.();\n        }\n      });\n\n      Mousetrap.bind(\"d d\", () => {\n        if (hasSelection) {\n          onDelete?.();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"d d\");\n      };\n    });\n\n    const onCloseEditDelete = useCloseEditDelete({\n      closeModal,\n      onSelectNone,\n      result,\n    });\n\n    const viewRandom = useViewRandom(effectiveFilter, totalCount);\n\n    function onExport(all: boolean) {\n      showModal(\n        <ExportDialog\n          exportInput={{\n            galleries: {\n              ids: Array.from(selectedIds.values()),\n              all: all,\n            },\n          }}\n          onClose={() => closeModal()}\n        />\n      );\n    }\n\n    function onEdit() {\n      showModal(\n        <EditGalleriesDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }\n\n    function onDelete() {\n      showModal(\n        <DeleteGalleriesDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }\n\n    function onGenerate() {\n      showModal(\n        <GenerateDialog\n          type=\"gallery\"\n          selectedIds={Array.from(selectedIds.values())}\n          onClose={() => closeModal()}\n        />\n      );\n    }\n\n    const convertedExtraOperations: IListFilterOperation[] =\n      extraOperations.map((o) => ({\n        ...o,\n        isDisplayed: o.isDisplayed\n          ? () => o.isDisplayed!(result, filter, selectedIds)\n          : undefined,\n        onClick: () => {\n          o.onClick(result, filter, selectedIds);\n        },\n      }));\n\n    const otherOperations = [\n      ...convertedExtraOperations,\n      {\n        text: intl.formatMessage({ id: \"actions.select_all\" }),\n        onClick: () => onSelectAll(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_none\" }),\n        onClick: () => onSelectNone(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.invert_selection\" }),\n        onClick: () => onInvertSelection(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.view_random\" }),\n        onClick: viewRandom,\n      },\n      {\n        text: `${intl.formatMessage({ id: \"actions.generate\" })}…`,\n        onClick: onGenerate,\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export\" }),\n        onClick: () => onExport(false),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export_all\" }),\n        onClick: () => onExport(true),\n      },\n    ];\n\n    // render\n    if (sidebarStateLoading) return null;\n\n    const operations = (\n      <ListOperations\n        items={items.length}\n        hasSelection={hasSelection}\n        operations={otherOperations}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        operationsMenuClassName=\"gallery-list-operations-dropdown\"\n      />\n    );\n\n    return (\n      <div\n        className={cx(\"item-list-container gallery-list\", {\n          \"hide-sidebar\": !showSidebar,\n        })}\n      >\n        {modal}\n\n        <SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>\n          <SidebarPane hideSidebar={!showSidebar}>\n            <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>\n              <SidebarContent\n                filter={filter}\n                setFilter={setFilter}\n                filterHook={filterHook}\n                showEditFilter={showEditFilter}\n                view={view}\n                sidebarOpen={showSidebar}\n                onClose={() => setShowSidebar(false)}\n                count={cachedResult.loading ? undefined : totalCount}\n                focus={searchFocus}\n              />\n            </Sidebar>\n            <SidebarPaneContent\n              onSidebarToggle={() => setShowSidebar(!showSidebar)}\n            >\n              <FilteredListToolbar\n                filter={filter}\n                listSelect={listSelect}\n                setFilter={setFilter}\n                showEditFilter={showEditFilter}\n                onDelete={onDelete}\n                onEdit={onEdit}\n                operationComponent={operations}\n                view={view}\n                zoomable\n              />\n\n              <FilterTags\n                criteria={filter.criteria}\n                onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}\n                onRemoveCriterion={removeCriterion}\n                onRemoveAll={clearAllCriteria}\n              />\n\n              <div className=\"pagination-index-container\">\n                <Pagination\n                  currentPage={filter.currentPage}\n                  itemsPerPage={filter.itemsPerPage}\n                  totalItems={totalCount}\n                  onChangePage={(page) => setFilter(filter.changePage(page))}\n                />\n                <PaginationIndex\n                  loading={cachedResult.loading}\n                  itemsPerPage={filter.itemsPerPage}\n                  currentPage={filter.currentPage}\n                  totalItems={totalCount}\n                />\n              </div>\n\n              <LoadedContent loading={result.loading} error={result.error}>\n                <GalleryList\n                  filter={effectiveFilter}\n                  galleries={items}\n                  selectedIds={selectedIds}\n                  onSelectChange={onSelectChange}\n                />\n              </LoadedContent>\n\n              {totalCount > filter.itemsPerPage && (\n                <div className=\"pagination-footer-container\">\n                  <div className=\"pagination-footer\">\n                    <Pagination\n                      itemsPerPage={filter.itemsPerPage}\n                      currentPage={filter.currentPage}\n                      totalItems={totalCount}\n                      onChangePage={setPage}\n                      pagePopupPlacement=\"top\"\n                    />\n                  </div>\n                </div>\n              )}\n            </SidebarPaneContent>\n          </SidebarPane>\n        </SidebarStateContext.Provider>\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryListTable.tsx",
    "content": "import React from \"react\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport NavUtils from \"src/utils/navigation\";\nimport { useIntl } from \"react-intl\";\nimport { objectTitle } from \"src/core/files\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport { useGalleryUpdate } from \"src/core/StashService\";\nimport { IColumn, ListTable } from \"../List/ListTable\";\nimport { useTableColumns } from \"src/hooks/useTableColumns\";\n\ninterface IGalleryListTableProps {\n  galleries: GQL.SlimGalleryDataFragment[];\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nconst TABLE_NAME = \"galleries\";\n\nexport const GalleryListTable: React.FC<IGalleryListTableProps> = (\n  props: IGalleryListTableProps\n) => {\n  const intl = useIntl();\n\n  const [updateGallery] = useGalleryUpdate();\n\n  function setRating(v: number | null, galleryId: string) {\n    if (galleryId) {\n      updateGallery({\n        variables: {\n          input: {\n            id: galleryId,\n            rating100: v,\n          },\n        },\n      });\n    }\n  }\n\n  const CoverImageCell = (gallery: GQL.SlimGalleryDataFragment) => {\n    const title = galleryTitle(gallery);\n\n    return (\n      <Link to={`/galleries/${gallery.id}`}>\n        <img\n          loading=\"lazy\"\n          alt={title}\n          className=\"image-thumbnail\"\n          src={gallery.paths.cover}\n        />\n      </Link>\n    );\n  };\n\n  const TitleCell = (gallery: GQL.SlimGalleryDataFragment) => {\n    const title = galleryTitle(gallery);\n\n    return (\n      <Link to={`/galleries/${gallery.id}`}>\n        <span className=\"ellips-data\">{title}</span>\n      </Link>\n    );\n  };\n\n  const DateCell = (gallery: GQL.SlimGalleryDataFragment) => (\n    <>{gallery.date}</>\n  );\n\n  const RatingCell = (gallery: GQL.SlimGalleryDataFragment) => (\n    <RatingSystem\n      value={gallery.rating100}\n      onSetRating={(value) => setRating(value, gallery.id)}\n      clickToRate\n    />\n  );\n\n  const ImagesCell = (gallery: GQL.SlimGalleryDataFragment) => {\n    return (\n      <Link to={NavUtils.makeGalleryImagesUrl(gallery)}>\n        <span>{gallery.image_count}</span>\n      </Link>\n    );\n  };\n\n  const TagCell = (gallery: GQL.SlimGalleryDataFragment) => (\n    <ul className=\"comma-list overflowable\">\n      {gallery.tags.map((tag) => (\n        <li key={tag.id}>\n          <Link to={NavUtils.makeTagGalleriesUrl(tag)}>\n            <span>{tag.name}</span>\n          </Link>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const PerformersCell = (gallery: GQL.SlimGalleryDataFragment) => (\n    <ul className=\"comma-list overflowable\">\n      {gallery.performers.map((performer) => (\n        <li key={performer.id}>\n          <Link to={NavUtils.makePerformerGalleriesUrl(performer)}>\n            <span>{performer.name}</span>\n          </Link>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const StudioCell = (gallery: GQL.SlimGalleryDataFragment) => {\n    if (gallery.studio) {\n      return (\n        <Link\n          to={NavUtils.makeStudioGalleriesUrl(gallery.studio)}\n          title={gallery.studio.name}\n        >\n          <span className=\"ellips-data\">{gallery.studio.name}</span>\n        </Link>\n      );\n    }\n  };\n\n  const SceneCell = (gallery: GQL.SlimGalleryDataFragment) => (\n    <ul className=\"comma-list\">\n      {gallery.scenes.map((galleryScene) => (\n        <li key={galleryScene.id}>\n          <Link to={`/scenes/${galleryScene.id}`}>\n            <span className=\"ellips-data\">{objectTitle(galleryScene)}</span>\n          </Link>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const PathCell = (scene: GQL.SlimGalleryDataFragment) => (\n    <ul className=\"newline-list overflowable TruncatedText\">\n      {scene.files.map((file) => (\n        <li key={file.id}>\n          <span>{file.path}</span>\n        </li>\n      ))}\n    </ul>\n  );\n\n  interface IColumnSpec {\n    value: string;\n    label: string;\n    defaultShow?: boolean;\n    mandatory?: boolean;\n    render?: (\n      gallery: GQL.SlimGalleryDataFragment,\n      index: number\n    ) => React.ReactNode;\n  }\n\n  const allColumns: IColumnSpec[] = [\n    {\n      value: \"cover_image\",\n      label: intl.formatMessage({ id: \"cover_image\" }),\n      defaultShow: true,\n      render: CoverImageCell,\n    },\n    {\n      value: \"title\",\n      label: intl.formatMessage({ id: \"title\" }),\n      defaultShow: true,\n      mandatory: true,\n      render: TitleCell,\n    },\n    {\n      value: \"date\",\n      label: intl.formatMessage({ id: \"date\" }),\n      defaultShow: true,\n      render: DateCell,\n    },\n    {\n      value: \"rating\",\n      label: intl.formatMessage({ id: \"rating\" }),\n      defaultShow: true,\n      render: RatingCell,\n    },\n    {\n      value: \"code\",\n      label: intl.formatMessage({ id: \"scene_code\" }),\n      render: (s) => <>{s.code}</>,\n    },\n    {\n      value: \"images\",\n      label: intl.formatMessage({ id: \"images\" }),\n      defaultShow: true,\n      render: ImagesCell,\n    },\n    {\n      value: \"tags\",\n      label: intl.formatMessage({ id: \"tags\" }),\n      defaultShow: true,\n      render: TagCell,\n    },\n    {\n      value: \"performers\",\n      label: intl.formatMessage({ id: \"performers\" }),\n      defaultShow: true,\n      render: PerformersCell,\n    },\n    {\n      value: \"studio\",\n      label: intl.formatMessage({ id: \"studio\" }),\n      defaultShow: true,\n      render: StudioCell,\n    },\n    {\n      value: \"scenes\",\n      label: intl.formatMessage({ id: \"scenes\" }),\n      defaultShow: true,\n      render: SceneCell,\n    },\n    {\n      value: \"photographer\",\n      label: intl.formatMessage({ id: \"photographer\" }),\n      render: (s) => <>{s.photographer}</>,\n    },\n    {\n      value: \"path\",\n      label: intl.formatMessage({ id: \"path\" }),\n      render: PathCell,\n    },\n  ];\n\n  const defaultColumns = allColumns\n    .filter((col) => col.defaultShow)\n    .map((col) => col.value);\n\n  const { selectedColumns, saveColumns } = useTableColumns(\n    TABLE_NAME,\n    defaultColumns\n  );\n\n  const columnRenderFuncs: Record<\n    string,\n    (gallery: GQL.SlimGalleryDataFragment, index: number) => React.ReactNode\n  > = {};\n  allColumns.forEach((col) => {\n    if (col.render) {\n      columnRenderFuncs[col.value] = col.render;\n    }\n  });\n\n  function renderCell(\n    column: IColumn,\n    gallery: GQL.SlimGalleryDataFragment,\n    index: number\n  ) {\n    const render = columnRenderFuncs[column.value];\n\n    if (render) return render(gallery, index);\n  }\n\n  return (\n    <ListTable\n      className=\"gallery-table\"\n      items={props.galleries}\n      allColumns={allColumns}\n      columns={selectedColumns}\n      setColumns={(c) => saveColumns(c)}\n      selectedIds={props.selectedIds}\n      onSelectChange={props.onSelectChange}\n      renderCell={renderCell}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useThrottle } from \"src/hooks/throttle\";\nimport { HoverScrubber } from \"../Shared/HoverScrubber\";\nimport cx from \"classnames\";\n\nexport const GalleryPreviewScrubber: React.FC<{\n  className?: string;\n  previewPath: string;\n  defaultPath: string;\n  imageCount: number;\n  onClick?: (imageIndex: number) => void;\n  onPathChanged: React.Dispatch<React.SetStateAction<string | undefined>>;\n  disabled?: boolean;\n}> = ({\n  className,\n  previewPath,\n  defaultPath,\n  imageCount,\n  onClick,\n  onPathChanged,\n  disabled,\n}) => {\n  const [activeIndex, setActiveIndex] = useState<number>();\n  const debounceSetActiveIndex = useThrottle(setActiveIndex, 50);\n\n  function onScrubberClick(index: number) {\n    if (!onClick) {\n      return;\n    }\n\n    onClick(index);\n  }\n\n  useEffect(() => {\n    function getPath() {\n      if (activeIndex === undefined) {\n        return defaultPath;\n      }\n\n      return `${previewPath}/${activeIndex}`;\n    }\n\n    onPathChanged(getPath());\n  }, [activeIndex, defaultPath, previewPath, onPathChanged]);\n\n  return (\n    <div className={cx(\"preview-scrubber\", className)}>\n      <HoverScrubber\n        totalSprites={imageCount}\n        activeIndex={activeIndex}\n        setActiveIndex={(i) => debounceSetActiveIndex(i)}\n        onClick={onScrubberClick}\n        disabled={disabled}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx",
    "content": "import React from \"react\";\nimport { useFindGalleries } from \"src/core/StashService\";\nimport { GalleryCard } from \"./GalleryCard\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { PatchComponent } from \"src/patch\";\nimport { FilteredRecommendationRow } from \"../FrontPage/FilteredRecommendationRow\";\n\ninterface IProps {\n  isTouch: boolean;\n  filter: ListFilterModel;\n  header: string;\n}\n\nexport const GalleryRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"GalleryRecommendationRow\",\n  (props) => {\n    const result = useFindGalleries(props.filter);\n    const count = result.data?.findGalleries.count ?? 0;\n\n    return (\n      <FilteredRecommendationRow\n        className=\"gallery-recommendations\"\n        heading={props.header}\n        url={`/galleries?${props.filter.makeQueryParameters()}`}\n        count={count}\n        loading={result.loading}\n        isTouch={props.isTouch}\n        filter={props.filter}\n      >\n        {result.loading\n          ? [...Array(props.filter.itemsPerPage)].map((i) => (\n              <div\n                key={`_${i}`}\n                className=\"gallery-skeleton skeleton-card\"\n              ></div>\n            ))\n          : result.data?.findGalleries.galleries.map((g) => (\n              <GalleryCard key={g.id} gallery={g} zoomIndex={1} />\n            ))}\n      </FilteredRecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GallerySelect.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport {\n  OptionProps,\n  components as reactSelectComponents,\n  MultiValueGenericProps,\n  SingleValueProps,\n} from \"react-select\";\nimport cx from \"classnames\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindGalleriesForSelect,\n  queryFindGalleriesByIDForSelect,\n  useGalleryCreate,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useIntl } from \"react-intl\";\nimport { defaultMaxOptionsShown } from \"src/core/config\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  FilterSelectComponent,\n  IFilterIDProps,\n  IFilterProps,\n  IFilterValueProps,\n  Option as SelectOption,\n} from \"../Shared/FilterSelect\";\nimport { useCompare } from \"src/hooks/state\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { PatchComponent, PatchFunction } from \"src/patch\";\nimport {\n  ModifierCriterion,\n  CriterionValue,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { PathCriterion } from \"src/models/list-filter/criteria/path\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\n\nexport type Gallery = Pick<GQL.Gallery, \"id\" | \"title\" | \"date\" | \"code\"> & {\n  studio?: Pick<GQL.Studio, \"name\"> | null;\n  files: Pick<GQL.GalleryFile, \"path\">[];\n  folder?: Pick<GQL.Folder, \"path\"> | null;\n  cover?: Pick<GQL.Image, \"paths\"> | null;\n};\ntype Option = SelectOption<Gallery>;\n\ntype ExtraGalleryProps = {\n  hoverPlacement?: Placement;\n  excludeIds?: string[];\n  extraCriteria?: Array<ModifierCriterion<CriterionValue>>;\n};\n\ntype FindGalleriesResult = Awaited<\n  ReturnType<typeof queryFindGalleriesForSelect>\n>[\"data\"][\"findGalleries\"][\"galleries\"];\n\nfunction sortGalleriesByRelevance(\n  input: string,\n  galleries: FindGalleriesResult\n) {\n  return sortByRelevance(input, galleries, galleryTitle, (g) => {\n    return g.files.map((f) => f.path).concat(g.folder?.path ?? []);\n  });\n}\n\nconst gallerySelectSort = PatchFunction(\n  \"GallerySelect.sort\",\n  sortGalleriesByRelevance\n);\n\nconst _GallerySelect: React.FC<\n  IFilterProps & IFilterValueProps<Gallery> & ExtraGalleryProps\n> = (props) => {\n  const [createGallery] = useGalleryCreate();\n\n  const { configuration } = useConfigurationContext();\n  const intl = useIntl();\n  const maxOptionsShown =\n    configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown;\n  const defaultCreatable =\n    !configuration?.interface.disableDropdownCreate.gallery;\n\n  const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);\n\n  async function loadGalleries(input: string): Promise<Option[]> {\n    const filter = new ListFilterModel(GQL.FilterMode.Galleries);\n    filter.searchTerm = input;\n    filter.currentPage = 1;\n    filter.itemsPerPage = maxOptionsShown;\n    filter.sortBy = \"title\";\n    filter.sortDirection = GQL.SortDirectionEnum.Asc;\n\n    if (props.extraCriteria) {\n      filter.criteria = [...props.extraCriteria];\n    }\n\n    const query = await queryFindGalleriesForSelect(filter);\n    let ret = query.data.findGalleries.galleries.filter((gallery) => {\n      // HACK - we should probably exclude these in the backend query, but\n      // this will do in the short-term\n      return !exclude.includes(gallery.id.toString());\n    });\n\n    return gallerySelectSort(input, ret).map((gallery) => ({\n      value: gallery.id,\n      object: gallery,\n    }));\n  }\n\n  const GalleryOption: React.FC<OptionProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    const title = galleryTitle(object);\n\n    // if title does not match the input value but the path does, show the path\n    const { inputValue } = optionProps.selectProps;\n    let matchedPath: string | undefined = \"\";\n    if (!title.toLowerCase().includes(inputValue.toLowerCase())) {\n      matchedPath = object.files?.find((a) =>\n        a.path.toLowerCase().includes(inputValue.toLowerCase())\n      )?.path;\n\n      if (\n        !matchedPath &&\n        object.folder?.path.toLowerCase().includes(inputValue.toLowerCase())\n      ) {\n        matchedPath = object.folder?.path;\n      }\n    }\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <span className=\"gallery-select-option\">\n          <span className=\"gallery-select-row\">\n            {object.cover?.paths?.thumbnail && (\n              <img\n                className=\"gallery-select-image\"\n                src={object.cover.paths.thumbnail}\n                loading=\"lazy\"\n              />\n            )}\n\n            <span className=\"gallery-select-details\">\n              <TruncatedText\n                className=\"gallery-select-title\"\n                text={title}\n                lineCount={1}\n              />\n\n              {object.studio?.name && (\n                <span className=\"gallery-select-studio\">\n                  {object.studio?.name}\n                </span>\n              )}\n\n              {object.date && (\n                <span className=\"gallery-select-date\">{object.date}</span>\n              )}\n\n              {object.code && (\n                <span className=\"gallery-select-code\">{object.code}</span>\n              )}\n            </span>\n          </span>\n\n          {matchedPath && (\n            <span className=\"gallery-select-alias\">{`(${matchedPath})`}</span>\n          )}\n        </span>\n      ),\n    };\n\n    return <reactSelectComponents.Option {...thisOptionProps} />;\n  };\n\n  const GalleryMultiValueLabel: React.FC<\n    MultiValueGenericProps<Option, boolean>\n  > = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: galleryTitle(object),\n    };\n\n    return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;\n  };\n\n  const GalleryValueLabel: React.FC<SingleValueProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: <>{galleryTitle(object)}</>,\n    };\n\n    return <reactSelectComponents.SingleValue {...thisOptionProps} />;\n  };\n\n  const onCreate = async (name: string) => {\n    const result = await createGallery({\n      variables: { input: { title: name } },\n    });\n    return {\n      value: result.data!.galleryCreate!.id,\n      item: result.data!.galleryCreate!,\n      message: \"Created gallery\",\n    };\n  };\n\n  const getNamedObject = (id: string, name: string): Gallery => {\n    return {\n      id,\n      title: name,\n      files: [],\n      folder: null,\n    };\n  };\n\n  const isValidNewOption = (inputValue: string, options: Gallery[]) => {\n    if (!inputValue) {\n      return false;\n    }\n\n    if (\n      options.some((o) => {\n        return galleryTitle(o).toLowerCase() === inputValue.toLowerCase();\n      })\n    ) {\n      return false;\n    }\n\n    return true;\n  };\n\n  return (\n    <FilterSelectComponent<Gallery, boolean>\n      {...props}\n      className={cx(\n        \"gallery-select\",\n        {\n          \"gallery-select-active\": props.active,\n        },\n        props.className\n      )}\n      loadOptions={loadGalleries}\n      getNamedObject={getNamedObject}\n      isValidNewOption={isValidNewOption}\n      components={{\n        Option: GalleryOption,\n        MultiValueLabel: GalleryMultiValueLabel,\n        SingleValue: GalleryValueLabel,\n      }}\n      isMulti={props.isMulti ?? false}\n      creatable={props.creatable ?? defaultCreatable}\n      onCreate={onCreate}\n      placeholder={\n        props.noSelectionString ??\n        intl.formatMessage(\n          { id: \"actions.select_entity\" },\n          {\n            entityType: intl.formatMessage({\n              id: props.isMulti ? \"galleries\" : \"gallery\",\n            }),\n          }\n        )\n      }\n      closeMenuOnSelect={!props.isMulti}\n    />\n  );\n};\n\nexport const GallerySelect = PatchComponent(\"GallerySelect\", _GallerySelect);\n\nconst _GalleryIDSelect: React.FC<\n  IFilterProps & IFilterIDProps<Gallery> & ExtraGalleryProps\n> = (props) => {\n  const { ids, onSelect: onSelectValues } = props;\n\n  const [values, setValues] = useState<Gallery[]>([]);\n  const idsChanged = useCompare(ids);\n\n  function onSelect(items: Gallery[]) {\n    setValues(items);\n    onSelectValues?.(items);\n  }\n\n  async function loadObjectsByID(idsToLoad: string[]): Promise<Gallery[]> {\n    const query = await queryFindGalleriesByIDForSelect(idsToLoad);\n    const { galleries: loadedGalleries } = query.data.findGalleries;\n\n    return loadedGalleries;\n  }\n\n  useEffect(() => {\n    if (!idsChanged) {\n      return;\n    }\n\n    if (!ids || ids?.length === 0) {\n      setValues([]);\n      return;\n    }\n\n    // load the values if we have ids and they haven't been loaded yet\n    const filteredValues = values.filter((v) => ids.includes(v.id.toString()));\n    if (filteredValues.length === ids.length) {\n      return;\n    }\n\n    const load = async () => {\n      const items = await loadObjectsByID(ids);\n      setValues(items);\n    };\n\n    load();\n  }, [ids, idsChanged, values]);\n\n  return <GallerySelect {...props} values={values} onSelect={onSelect} />;\n};\n\nexport const GalleryIDSelect = PatchComponent(\n  \"GalleryIDSelect\",\n  _GalleryIDSelect\n);\n\nfunction getExcludeFilebaseGalleriesFilter() {\n  const ret = new PathCriterion();\n  ret.modifier = GQL.CriterionModifier.IsNull;\n  return ret;\n}\n\nexport const excludeFileBasedGalleries = [getExcludeFilebaseGalleriesFilter()];\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryViewer.tsx",
    "content": "import React, { useCallback, useMemo } from \"react\";\nimport { useLightbox } from \"src/hooks/Lightbox/hooks\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport Gallery, { PhotoClickHandler } from \"react-photo-gallery\";\nimport \"flexbin/flexbin.css\";\nimport {\n  CriterionModifier,\n  useFindImagesQuery,\n} from \"src/core/generated-graphql\";\n\ninterface IProps {\n  galleryId: string;\n}\n\nexport const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {\n  // TODO - add paging - don't load all images at once\n  const pageSize = -1;\n\n  const currentFilter = useMemo(() => {\n    return {\n      per_page: pageSize,\n      sort: \"path\",\n    };\n  }, [pageSize]);\n\n  const { data, loading } = useFindImagesQuery({\n    variables: {\n      filter: currentFilter,\n      image_filter: {\n        galleries: {\n          modifier: CriterionModifier.Includes,\n          value: [galleryId],\n        },\n      },\n    },\n  });\n\n  const images = useMemo(() => data?.findImages?.images ?? [], [data]);\n\n  const lightboxState = useMemo(() => {\n    return {\n      images,\n      showNavigation: false,\n    };\n  }, [images]);\n\n  const showLightbox = useLightbox(lightboxState);\n  const showLightboxOnClick: PhotoClickHandler = useCallback(\n    (event, { index }) => {\n      showLightbox({ initialIndex: index });\n    },\n    [showLightbox]\n  );\n\n  if (loading) return <LoadingIndicator />;\n\n  let photos: {\n    src: string;\n    srcSet?: string | string[] | undefined;\n    sizes?: string | string[] | undefined;\n    width: number;\n    height: number;\n    alt?: string | undefined;\n    key?: string | undefined;\n  }[] = [];\n\n  images.forEach((image, index) => {\n    let imageData = {\n      src: image.paths.thumbnail!,\n      width: image.visual_files[0]?.width ?? 0,\n      height: image.visual_files[0]?.height ?? 0,\n      tabIndex: index,\n      key: image.id ?? index,\n      loading: \"lazy\",\n      className: \"gallery-image\",\n      alt: image.title ?? index.toString(),\n    };\n    photos.push(imageData);\n  });\n\n  return (\n    <div className=\"gallery\">\n      <Gallery photos={photos} onClick={showLightboxOnClick} margin={2.5} />\n    </div>\n  );\n};\n\nexport default GalleryViewer;\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/GalleryWallCard.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport TextUtils from \"src/utils/text\";\nimport { useGalleryLightbox } from \"src/hooks/Lightbox/hooks\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport { GalleryPreviewScrubber } from \"./GalleryPreviewScrubber\";\nimport { useDragMoveSelect } from \"../Shared/GridCard/dragMoveSelect\";\nimport cx from \"classnames\";\n\nconst CLASSNAME = \"GalleryWallCard\";\nconst CLASSNAME_FOOTER = `${CLASSNAME}-footer`;\nconst CLASSNAME_IMG = `${CLASSNAME}-img`;\nconst CLASSNAME_TITLE = `${CLASSNAME}-title`;\nconst CLASSNAME_IMG_CONTAIN = `${CLASSNAME}-img-contain`;\n\ninterface IProps {\n  gallery: GQL.SlimGalleryDataFragment;\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  selecting?: boolean;\n}\n\ntype Orientation = \"landscape\" | \"portrait\";\n\nfunction getOrientation(width: number, height: number): Orientation {\n  return width > height ? \"landscape\" : \"portrait\";\n}\n\nconst GalleryWallCard: React.FC<IProps> = ({\n  gallery,\n  selected,\n  onSelectedChanged,\n  selecting,\n}) => {\n  const intl = useIntl();\n  const [coverOrientation, setCoverOrientation] =\n    React.useState<Orientation>(\"landscape\");\n  const [imageOrientation, setImageOrientation] =\n    React.useState<Orientation>(\"landscape\");\n  const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);\n\n  const { dragProps } = useDragMoveSelect({\n    selecting: selecting || false,\n    selected: selected || false,\n    onSelectedChanged: onSelectedChanged,\n  });\n\n  const cover = gallery?.paths.cover;\n\n  function onCoverLoad(e: React.SyntheticEvent<HTMLImageElement, Event>) {\n    const target = e.target as HTMLImageElement;\n    setCoverOrientation(\n      getOrientation(target.naturalWidth, target.naturalHeight)\n    );\n  }\n\n  function onNonCoverLoad(e: React.SyntheticEvent<HTMLImageElement, Event>) {\n    const target = e.target as HTMLImageElement;\n    setImageOrientation(\n      getOrientation(target.naturalWidth, target.naturalHeight)\n    );\n  }\n\n  const [imgSrc, setImgSrc] = useState<string | undefined>(cover ?? undefined);\n  const title = galleryTitle(gallery);\n  const performerNames = gallery.performers.map((p) => p.name);\n  const performers =\n    performerNames.length >= 2\n      ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(\" & \")]\n      : performerNames;\n\n  function handleCardClick(event: React.MouseEvent) {\n    if (selecting && onSelectedChanged) {\n      onSelectedChanged(!selected, event.shiftKey);\n      return;\n    }\n    showLightboxStart();\n  }\n\n  async function showLightboxStart() {\n    if (gallery.image_count === 0) {\n      return;\n    }\n\n    showLightbox(0);\n  }\n\n  const imgClassname =\n    imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : \"\";\n\n  let shiftKey = false;\n\n  return (\n    <>\n      <section\n        className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation} wall-item`}\n        onClick={handleCardClick}\n        onKeyPress={() => showLightboxStart()}\n        role=\"button\"\n        tabIndex={0}\n        {...dragProps}\n      >\n        {onSelectedChanged && (\n          <Form.Control\n            type=\"checkbox\"\n            className=\"wall-item-check mousetrap\"\n            checked={selected}\n            onChange={() => onSelectedChanged(!selected, shiftKey)}\n            onClick={(\n              event: React.MouseEvent<HTMLInputElement, MouseEvent>\n            ) => {\n              shiftKey = event.shiftKey;\n              event.stopPropagation();\n            }}\n          />\n        )}\n        <RatingSystem value={gallery.rating100} disabled withoutContext />\n        <img\n          loading=\"lazy\"\n          src={imgSrc}\n          alt=\"\"\n          className={cx(CLASSNAME_IMG, imgClassname)}\n          // set orientation based on cover only\n          onLoad={imgSrc === cover ? onCoverLoad : onNonCoverLoad}\n        />\n        <div className=\"lineargradient\">\n          <footer className={CLASSNAME_FOOTER}>\n            <Link\n              to={`/galleries/${gallery.id}`}\n              onClick={(e) => {\n                if (selecting) {\n                  e.preventDefault();\n                  handleCardClick(e);\n                }\n                e.stopPropagation();\n              }}\n            >\n              {title && (\n                <TruncatedText\n                  text={title}\n                  lineCount={1}\n                  className={CLASSNAME_TITLE}\n                />\n              )}\n              <TruncatedText text={performers.join(\", \")} />\n              <div>\n                {gallery.date && TextUtils.formatFuzzyDate(intl, gallery.date)}\n              </div>\n            </Link>\n          </footer>\n          <GalleryPreviewScrubber\n            previewPath={gallery.paths.preview}\n            defaultPath={cover ?? \"\"}\n            imageCount={gallery.image_count}\n            onClick={(i) => {\n              showLightbox(i);\n            }}\n            onPathChanged={setImgSrc}\n          />\n        </div>\n      </section>\n    </>\n  );\n};\n\nexport default GalleryWallCard;\n"
  },
  {
    "path": "ui/v2.5/src/components/Galleries/styles.scss",
    "content": "@use \"sass:math\";\n\n.gallery-image {\n  &:hover {\n    cursor: pointer;\n  }\n}\n\n@include media-breakpoint-only(lg) {\n  .gallery-header-container {\n    align-items: center;\n    display: flex;\n    justify-content: space-between;\n\n    .gallery-header {\n      flex: 0 0 75%;\n      order: 1;\n    }\n\n    .gallery-studio-image {\n      flex: 0 0 25%;\n      order: 2;\n    }\n  }\n}\n\n.gallery-header {\n  flex-basis: auto;\n  font-size: 1.5rem;\n  margin-top: 30px;\n\n  @include media-breakpoint-down(xl) {\n    font-size: 1.75rem;\n  }\n}\n\n.gallery-subheader {\n  display: flex;\n  justify-content: space-between;\n  margin-top: 0.5rem;\n\n  .date {\n    color: $text-muted;\n  }\n\n  .resolution {\n    font-weight: bold;\n  }\n}\n\n.gallery-toolbar {\n  align-items: center;\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 0.25rem;\n  margin-top: 0.5rem;\n  padding-bottom: 0.25rem;\n  width: 100%;\n\n  .gallery-toolbar-group {\n    align-items: center;\n    column-gap: 0.25rem;\n    display: flex;\n    width: 100%;\n\n    &:last-child {\n      justify-content: flex-end;\n    }\n  }\n}\n\n#gallery-details-container {\n  .tab-content {\n    min-height: 15rem;\n  }\n}\n\n.gallery-card {\n  &.card {\n    overflow: hidden;\n    padding: 0;\n    padding-bottom: 1rem;\n\n    @media (max-width: 576px) {\n      width: 100%;\n    }\n  }\n\n  .card-section {\n    margin-top: auto;\n\n    a:hover {\n      text-decoration: none;\n    }\n  }\n\n  .card-popovers {\n    margin-bottom: 0;\n  }\n\n  .card-section-title {\n    color: $text-color;\n  }\n\n  &-cover {\n    position: relative;\n  }\n\n  .preview-scrubber {\n    top: 0;\n  }\n\n  &-image {\n    object-fit: contain;\n  }\n}\n\n.gallery-tabs {\n  max-height: calc(100vh - 4rem);\n\n  overflow: auto;\n  overflow-wrap: break-word;\n  word-wrap: break-word;\n}\n\n$galleryTabWidth: 450px;\n\n@media (min-width: 1200px) {\n  .gallery-tabs {\n    flex: 0 0 $galleryTabWidth;\n    max-width: $galleryTabWidth;\n\n    &.collapsed {\n      display: none;\n    }\n  }\n\n  .gallery-divider {\n    flex: 0 0 15px;\n    max-width: 15px;\n\n    button {\n      background-color: transparent;\n      border: 0;\n      color: $link-color;\n      cursor: pointer;\n      font-size: 10px;\n      font-weight: 800;\n      height: 100%;\n      line-height: 100%;\n      padding: 0;\n      text-align: center;\n      width: 100%;\n\n      &:active:not(:hover),\n      &:focus:not(:hover) {\n        background-color: transparent;\n        border: 0;\n        box-shadow: none;\n      }\n    }\n  }\n\n  .gallery-container {\n    flex: 0 0 calc(100% - #{$galleryTabWidth} - 15px);\n    height: calc(100vh - 4rem);\n    max-width: calc(100% - #{$galleryTabWidth} - 15px);\n    overflow: auto;\n\n    &.expanded {\n      flex: 0 0 calc(100% - 15px);\n      max-width: calc(100% - 15px);\n    }\n  }\n}\n\n.gallery-tabs,\n.gallery-container {\n  padding-left: 15px;\n  padding-right: 15px;\n  position: relative;\n  width: 100%;\n}\n\n@media (min-width: 1200px) {\n  .gallery-container .image-list .filtered-list-toolbar.has-selection {\n    top: 0;\n  }\n}\n@media (min-width: 1200px), (max-width: 575px) {\n  .gallery-performers {\n    .performer-card {\n      width: 15rem;\n\n      &-gallery {\n        height: 22.5rem;\n      }\n\n      &-image {\n        height: 22.5rem;\n        width: 15rem;\n      }\n    }\n  }\n}\n\n#gallery-edit-details {\n  .rating-stars {\n    font-size: 1.3em;\n    height: calc(1.5em + 0.75rem + 2px);\n  }\n\n  .form-group[data-field=\"urls\"] .string-list-input input.form-control {\n    font-size: 0.85em;\n  }\n\n  @include media-breakpoint-up(xl) {\n    .custom-fields-input {\n      .custom-fields-field {\n        flex: 0 0 25%;\n        max-width: 25%;\n      }\n\n      .custom-fields-value {\n        flex: 0 0 75%;\n        max-width: 75%;\n      }\n    }\n  }\n}\n\n.gallery-cover {\n  aspect-ratio: 4 / 3;\n  display: block;\n  height: auto;\n  width: 100%;\n}\n\n.gallery-cover img {\n  height: auto;\n  max-height: 100%;\n  max-width: 100%;\n  object-fit: contain;\n  width: auto;\n}\n\ndiv.GalleryWall {\n  display: flex;\n  flex-wrap: wrap;\n  margin: 0 auto;\n\n  /* Prevents last row from consuming all space and stretching images to oblivion */\n  &::after {\n    content: \"\";\n    flex: auto;\n    flex-grow: 9999;\n  }\n}\n\n.GalleryWallCard {\n  height: auto;\n  padding: 2px;\n  position: relative;\n\n  $width: 96vw;\n\n  &-landscape {\n    flex-grow: 2;\n    width: 96vw;\n  }\n\n  &-portrait {\n    flex-grow: 1;\n    width: 96vw;\n  }\n\n  .lineargradient {\n    background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));\n    bottom: 100px;\n    height: 100px;\n    position: relative;\n  }\n\n  .preview-scrubber {\n    top: 0;\n    z-index: 1;\n  }\n\n  &-img {\n    height: 100%;\n    object-fit: cover;\n    object-position: center 20%;\n    width: 100%;\n\n    &.GalleryWallCard-img-contain {\n      object-fit: contain;\n      object-position: initial;\n    }\n  }\n\n  &-title {\n    font-weight: bold;\n  }\n\n  &-footer {\n    bottom: 20px;\n    padding: 1rem;\n    position: absolute;\n    text-shadow: 1px 1px 3px black;\n    transition: 0s opacity;\n    width: 100%;\n    z-index: 2;\n\n    @media (min-width: 768px) {\n      opacity: 0;\n    }\n\n    &:hover {\n      .GalleryWallCard-title {\n        text-decoration: underline;\n      }\n    }\n\n    a {\n      color: white;\n    }\n  }\n\n  &:hover &-footer {\n    opacity: 1;\n    transition: 1s opacity;\n    transition-delay: 500ms;\n\n    a {\n      text-decoration: none;\n    }\n  }\n\n  .rating-stars,\n  .rating-number {\n    position: absolute;\n    right: 1rem;\n    text-shadow: 1px 1px 3px black;\n    top: 1rem;\n    z-index: 2;\n  }\n\n  .rating-stars {\n    .star-fill-0 .unfilled-star {\n      display: none;\n    }\n\n    .star-fill-10 .unfilled-star,\n    .star-fill-20 .unfilled-star,\n    .star-fill-25 .unfilled-star,\n    .star-fill-30 .unfilled-star,\n    .star-fill-40 .unfilled-star,\n    .star-fill-50 .unfilled-star,\n    .star-fill-60 .unfilled-star,\n    .star-fill-70 .unfilled-star,\n    .star-fill-75 .unfilled-star,\n    .star-fill-80 .unfilled-star,\n    .star-fill-90 .unfilled-star {\n      visibility: hidden;\n    }\n\n    .filled-star {\n      filter: drop-shadow(1px 1px 1px #222);\n    }\n  }\n}\n\ndiv.GalleryWall {\n  @mixin galleryWidth($width) {\n    height: math.div($width, 3) * 2;\n\n    &-landscape {\n      width: $width;\n    }\n\n    &-portrait {\n      width: math.div($width, 2);\n    }\n  }\n\n  .GalleryWallCard {\n    @media (min-width: 576px) {\n      @include galleryWidth(96vw);\n    }\n  }\n\n  &.zoom-0 .GalleryWallCard {\n    @media (min-width: 768px) {\n      @include galleryWidth(16vw);\n    }\n    @media (min-width: 1200px) {\n      @include galleryWidth(10vw);\n    }\n  }\n\n  &.zoom-1 .GalleryWallCard {\n    @media (min-width: 768px) {\n      @include galleryWidth(24vw);\n    }\n    @media (min-width: 1200px) {\n      @include galleryWidth(16vw);\n    }\n  }\n\n  &.zoom-2 .GalleryWallCard {\n    @media (min-width: 768px) {\n      @include galleryWidth(32vw);\n    }\n    @media (min-width: 1200px) {\n      @include galleryWidth(24vw);\n    }\n  }\n\n  &.zoom-3 .GalleryWallCard {\n    @media (min-width: 768px) {\n      @include galleryWidth(48vw);\n    }\n    @media (min-width: 1200px) {\n      @include galleryWidth(32vw);\n    }\n  }\n}\n\n.gallery-file-card.card {\n  margin: 0;\n  padding: 0;\n\n  .card-header {\n    cursor: pointer;\n  }\n\n  dl {\n    margin-bottom: 0;\n  }\n}\n\n.col-form-label {\n  padding-right: 2px;\n}\n\n.gallery-select-option {\n  .gallery-select-row {\n    align-items: center;\n    display: flex;\n    width: 100%;\n\n    .gallery-select-image {\n      background-color: $body-bg;\n      margin-right: 0.4em;\n      max-height: 50px;\n      max-width: 89px;\n      object-fit: contain;\n      object-position: center;\n    }\n\n    .gallery-select-details {\n      display: flex;\n      flex-direction: column;\n      justify-content: flex-start;\n      max-height: 4.1rem;\n      overflow: hidden;\n\n      .gallery-select-title {\n        flex-shrink: 0;\n        white-space: pre-wrap;\n        word-break: break-all;\n      }\n\n      .gallery-select-date,\n      .gallery-select-studio,\n      .gallery-select-code {\n        color: $text-muted;\n        flex-shrink: 0;\n        font-size: 0.9rem;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n      }\n    }\n  }\n\n  .gallery-select-alias {\n    font-size: 0.8rem;\n    font-weight: bold;\n    width: 100%;\n    word-break: break-all;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { MultiSetModeButtons } from \"../Shared/MultiSet\";\nimport {\n  IRelatedGroupEntry,\n  RelatedGroupTable,\n} from \"./GroupDetails/RelatedGroupTable\";\nimport { Group, GroupSelect } from \"./GroupSelect\";\n\nexport const ContainingGroupsMultiSet: React.FC<{\n  existingValue?: IRelatedGroupEntry[];\n  value: IRelatedGroupEntry[];\n  mode: GQL.BulkUpdateIdMode;\n  disabled?: boolean;\n  onUpdate: (value: IRelatedGroupEntry[]) => void;\n  onSetMode: (mode: GQL.BulkUpdateIdMode) => void;\n  menuPortalTarget?: HTMLElement | null;\n}> = (props) => {\n  const { mode, onUpdate, existingValue } = props;\n\n  function onSetMode(m: GQL.BulkUpdateIdMode) {\n    if (m === mode) {\n      return;\n    }\n\n    // if going to Set, set the existing ids\n    if (m === GQL.BulkUpdateIdMode.Set && existingValue) {\n      onUpdate(existingValue);\n      // if going from Set, wipe the ids\n    } else if (\n      m !== GQL.BulkUpdateIdMode.Set &&\n      mode === GQL.BulkUpdateIdMode.Set\n    ) {\n      onUpdate([]);\n    }\n\n    props.onSetMode(m);\n  }\n\n  function onRemoveSet(items: Group[]) {\n    onUpdate(items.map((group) => ({ group })));\n  }\n\n  return (\n    <div className=\"multi-set\">\n      <MultiSetModeButtons mode={mode} onSetMode={onSetMode} />\n      {mode !== GQL.BulkUpdateIdMode.Remove ? (\n        <RelatedGroupTable\n          value={props.value}\n          onUpdate={props.onUpdate}\n          disabled={props.disabled}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      ) : (\n        <GroupSelect\n          onSelect={(items) => onRemoveSet(items)}\n          values={[]}\n          isDisabled={props.disabled}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/EditGroupsDialog.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useBulkGroupUpdate } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { StudioSelect } from \"../Shared/Select\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { MultiSet } from \"../Shared/MultiSet\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport {\n  getAggregateInputValue,\n  getAggregateStateObject,\n  getAggregateTagIds,\n  getAggregateStudioId,\n  getAggregateIds,\n} from \"src/utils/bulkUpdate\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { BulkUpdateFormGroup, BulkUpdateTextInput } from \"../Shared/BulkUpdate\";\nimport { BulkUpdateDateInput } from \"../Shared/DateInput\";\nimport { IRelatedGroupEntry } from \"./GroupDetails/RelatedGroupTable\";\nimport { ContainingGroupsMultiSet } from \"./ContainingGroupsMultiSet\";\nimport { getDateError } from \"src/utils/yup\";\n\ninterface IListOperationProps {\n  selected: GQL.ListGroupDataFragment[];\n  onClose: (applied: boolean) => void;\n}\n\nexport function getAggregateContainingGroups(\n  state: Pick<GQL.ListGroupDataFragment, \"containing_groups\">[]\n) {\n  const sortedLists: IRelatedGroupEntry[][] = state.map((o) =>\n    o.containing_groups\n      .map((oo) => ({\n        group: oo.group,\n        description: oo.description,\n      }))\n      .sort((a, b) => a.group.id.localeCompare(b.group.id))\n  );\n\n  return getAggregateIds(sortedLists);\n}\n\nfunction getAggregateContainingGroupInput(\n  mode: GQL.BulkUpdateIdMode,\n  input: IRelatedGroupEntry[] | undefined,\n  aggregateValues: IRelatedGroupEntry[]\n): GQL.BulkUpdateGroupDescriptionsInput | undefined {\n  if (mode === GQL.BulkUpdateIdMode.Set && (!input || input.length === 0)) {\n    // and all scenes have the same ids,\n    if (aggregateValues.length > 0) {\n      // then unset, otherwise ignore\n      return { mode, groups: [] };\n    }\n  } else {\n    // if input non-empty, then we are setting them\n    return {\n      mode,\n      groups:\n        input?.map((e) => {\n          return { group_id: e.group.id, description: e.description };\n        }) || [],\n    };\n  }\n\n  return undefined;\n}\n\nconst groupFields = [\"rating100\", \"synopsis\", \"director\", \"date\"];\n\nexport const EditGroupsDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [updateInput, setUpdateInput] = useState<GQL.BulkGroupUpdateInput>({\n    ids: props.selected.map((group) => {\n      return group.id;\n    }),\n  });\n\n  const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [containingGroupsMode, setGroupMode] =\n    React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);\n  const [containingGroups, setGroups] = useState<IRelatedGroupEntry[]>();\n\n  const unsetDisabled = props.selected.length < 2;\n\n  const [updateGroups] = useBulkGroupUpdate();\n\n  const [dateError, setDateError] = useState<string | undefined>();\n\n  // Network state\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const aggregateState = useMemo(() => {\n    const updateState: Partial<GQL.BulkGroupUpdateInput> = {};\n    const state = props.selected;\n    updateState.studio_id = getAggregateStudioId(props.selected);\n    const updateTagIds = getAggregateTagIds(props.selected);\n    const aggregateGroups = getAggregateContainingGroups(props.selected);\n    let first = true;\n\n    state.forEach((group: GQL.ListGroupDataFragment) => {\n      getAggregateStateObject(updateState, group, groupFields, first);\n      first = false;\n    });\n\n    return {\n      state: updateState,\n      tagIds: updateTagIds,\n      containingGroups: aggregateGroups,\n    };\n  }, [props.selected]);\n\n  // update initial state from aggregate\n  useEffect(() => {\n    setUpdateInput((current) => ({ ...current, ...aggregateState.state }));\n  }, [aggregateState]);\n\n  useEffect(() => {\n    setDateError(getDateError(updateInput.date ?? \"\", intl));\n  }, [updateInput.date, intl]);\n\n  function setUpdateField(input: Partial<GQL.BulkGroupUpdateInput>) {\n    setUpdateInput((current) => ({ ...current, ...input }));\n  }\n\n  function getGroupInput(): GQL.BulkGroupUpdateInput {\n    const groupInput: GQL.BulkGroupUpdateInput = {\n      ...updateInput,\n      tag_ids: tagIds,\n    };\n\n    // we don't have unset functionality for the rating star control\n    // so need to determine if we are setting a rating or not\n    groupInput.rating100 = getAggregateInputValue(\n      updateInput.rating100,\n      aggregateState.state.rating100\n    );\n\n    groupInput.containing_groups = getAggregateContainingGroupInput(\n      containingGroupsMode,\n      containingGroups,\n      aggregateState.containingGroups\n    );\n\n    return groupInput;\n  }\n\n  async function onSave() {\n    setIsUpdating(true);\n    try {\n      await updateGroups({ variables: { input: getGroupInput() } });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          { entity: intl.formatMessage({ id: \"groups\" }).toLocaleLowerCase() }\n        )\n      );\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsUpdating(false);\n  }\n\n  function render() {\n    return (\n      <ModalComponent\n        show\n        icon={faPencilAlt}\n        header={intl.formatMessage(\n          { id: \"dialogs.edit_entity_count_title\" },\n          {\n            count: props?.selected?.length ?? 1,\n            singularEntity: intl.formatMessage({ id: \"group\" }),\n            pluralEntity: intl.formatMessage({ id: \"groups\" }),\n          }\n        )}\n        accept={{\n          onClick: onSave,\n          text: intl.formatMessage({ id: \"actions.apply\" }),\n        }}\n        disabled={isUpdating || !!dateError}\n        cancel={{\n          onClick: () => props.onClose(false),\n          text: intl.formatMessage({ id: \"actions.cancel\" }),\n          variant: \"secondary\",\n        }}\n        isRunning={isUpdating}\n      >\n        <Form>\n          <BulkUpdateFormGroup name=\"rating\">\n            <RatingSystem\n              value={updateInput.rating100}\n              onSetRating={(value) =>\n                setUpdateField({ rating100: value ?? undefined })\n              }\n              disabled={isUpdating}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"date\">\n            <BulkUpdateDateInput\n              value={updateInput.date}\n              valueChanged={(newValue) => setUpdateField({ date: newValue })}\n              unsetDisabled={unsetDisabled}\n              error={dateError}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"director\">\n            <BulkUpdateTextInput\n              value={updateInput.director}\n              valueChanged={(newValue) =>\n                setUpdateField({ director: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"studio\">\n            <StudioSelect\n              onSelect={(items) =>\n                setUpdateField({\n                  studio_id: items.length > 0 ? items[0]?.id : undefined,\n                })\n              }\n              ids={updateInput.studio_id ? [updateInput.studio_id] : []}\n              isDisabled={isUpdating}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup\n            name=\"containing-groups\"\n            messageId=\"containing_groups\"\n            inline={false}\n          >\n            <ContainingGroupsMultiSet\n              disabled={isUpdating}\n              onUpdate={(v) => setGroups(v)}\n              onSetMode={(newMode) => setGroupMode(newMode)}\n              existingValue={aggregateState.containingGroups ?? []}\n              value={containingGroups ?? []}\n              mode={containingGroupsMode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"tags\" inline={false}>\n            <MultiSet\n              type={\"tags\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setTagIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setTagIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={tagIds.ids ?? []}\n              existingIds={aggregateState.tagIds}\n              mode={tagIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"synopsis\" inline={false}>\n            <BulkUpdateTextInput\n              value={updateInput.synopsis}\n              valueChanged={(newValue) =>\n                setUpdateField({ synopsis: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n              as=\"textarea\"\n            />\n          </BulkUpdateFormGroup>\n        </Form>\n      </ModalComponent>\n    );\n  }\n\n  return render();\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupCard.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { PatchComponent } from \"src/patch\";\nimport { GridCard } from \"../Shared/GridCard/GridCard\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { Icon } from \"../Shared/Icon\";\nimport { SceneLink, TagLink } from \"../Shared/TagLink\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport { FormattedMessage } from \"react-intl\";\nimport { RatingBanner } from \"../Shared/RatingBanner\";\nimport { faPlayCircle, faTag } from \"@fortawesome/free-solid-svg-icons\";\nimport { RelatedGroupPopoverButton } from \"./RelatedGroupPopover\";\nimport { OCounterButton } from \"../Shared/CountButton\";\n\nconst Description: React.FC<{\n  sceneNumber?: number;\n  description?: string;\n}> = ({ sceneNumber, description }) => {\n  if (!sceneNumber && !description) return null;\n\n  return (\n    <>\n      <hr />\n      {sceneNumber !== undefined && (\n        <span className=\"group-scene-number\">\n          <FormattedMessage id=\"scene\" /> #{sceneNumber}\n        </span>\n      )}\n      {description !== undefined && (\n        <span className=\"group-containing-group-description\">\n          {description}\n        </span>\n      )}\n    </>\n  );\n};\n\ninterface IProps {\n  group: GQL.ListGroupDataFragment;\n  cardWidth?: number;\n  sceneNumber?: number;\n  selecting?: boolean;\n  selected?: boolean;\n  zoomIndex?: number;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  fromGroupId?: string;\n  onMove?: (srcIds: string[], targetId: string, after: boolean) => void;\n}\n\nexport const GroupCard: React.FC<IProps> = PatchComponent(\n  \"GroupCard\",\n  ({\n    group,\n    sceneNumber,\n    cardWidth,\n    selecting,\n    selected,\n    zoomIndex,\n    onSelectedChanged,\n    fromGroupId,\n    onMove,\n  }) => {\n    const groupDescription = useMemo(() => {\n      if (!fromGroupId) {\n        return undefined;\n      }\n\n      const containingGroup = group.containing_groups.find(\n        (cg) => cg.group.id === fromGroupId\n      );\n\n      return containingGroup?.description ?? undefined;\n    }, [fromGroupId, group.containing_groups]);\n\n    function maybeRenderScenesPopoverButton() {\n      if (group.scenes.length === 0) return;\n\n      const popoverContent = group.scenes.map((scene) => (\n        <SceneLink key={scene.id} scene={scene} />\n      ));\n\n      return (\n        <HoverPopover\n          className=\"scene-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faPlayCircle} />\n            <span>{group.scenes.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderTagPopoverButton() {\n      if (group.tags.length <= 0) return;\n\n      const popoverContent = group.tags.map((tag) => (\n        <TagLink key={tag.id} linkType=\"group\" tag={tag} />\n      ));\n\n      return (\n        <HoverPopover placement=\"bottom\" content={popoverContent}>\n          <Button className=\"minimal tag-count\">\n            <Icon icon={faTag} />\n            <span>{group.tags.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderOCounter() {\n      if (!group.o_counter) return;\n\n      return <OCounterButton value={group.o_counter} />;\n    }\n\n    function maybeRenderPopoverButtonGroup() {\n      if (\n        sceneNumber ||\n        groupDescription ||\n        group.scenes.length > 0 ||\n        group.tags.length > 0 ||\n        group.containing_groups.length > 0 ||\n        group.sub_group_count > 0\n      ) {\n        return (\n          <>\n            <Description\n              sceneNumber={sceneNumber}\n              description={groupDescription}\n            />\n            <hr />\n            <ButtonGroup className=\"card-popovers\">\n              {maybeRenderScenesPopoverButton()}\n              {maybeRenderTagPopoverButton()}\n              {(group.sub_group_count > 0 ||\n                group.containing_groups.length > 0) && (\n                <RelatedGroupPopoverButton group={group} />\n              )}\n              {maybeRenderOCounter()}\n            </ButtonGroup>\n          </>\n        );\n      }\n    }\n\n    return (\n      <GridCard\n        className={`group-card zoom-${zoomIndex}`}\n        objectId={group.id}\n        onMove={onMove}\n        url={`/groups/${group.id}`}\n        width={cardWidth}\n        title={group.name}\n        linkClassName=\"group-card-header\"\n        image={\n          <>\n            <img\n              loading=\"lazy\"\n              className=\"group-card-image\"\n              alt={group.name ?? \"\"}\n              src={group.front_image_path ?? \"\"}\n            />\n            <RatingBanner rating={group.rating100} />\n          </>\n        }\n        details={\n          <div className=\"group-card__details\">\n            <span className=\"group-card__date\">{group.date}</span>\n            <TruncatedText\n              className=\"group-card__description\"\n              text={group.synopsis}\n              lineCount={3}\n            />\n          </div>\n        }\n        selected={selected}\n        selecting={selecting}\n        onSelectedChanged={onSelectedChanged}\n        popovers={maybeRenderPopoverButtonGroup()}\n      />\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupCardGrid.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { GroupCard } from \"./GroupCard\";\nimport {\n  useCardWidth,\n  useContainerDimensions,\n} from \"../Shared/GridCard/GridCard\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IGroupCardGrid {\n  groups: GQL.ListGroupDataFragment[];\n  selectedIds: Set<string>;\n  zoomIndex: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  fromGroupId?: string;\n  onMove?: (srcIds: string[], targetId: string, after: boolean) => void;\n}\n\nconst zoomWidths = [210, 250, 300, 375];\n\nexport const GroupCardGrid: React.FC<IGroupCardGrid> = PatchComponent(\n  \"GroupCardGrid\",\n  ({ groups, selectedIds, zoomIndex, onSelectChange, fromGroupId, onMove }) => {\n    const [componentRef, { width: containerWidth }] = useContainerDimensions();\n    const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);\n\n    return (\n      <div className=\"row justify-content-center\" ref={componentRef}>\n        {groups.map((p) => (\n          <GroupCard\n            key={p.id}\n            cardWidth={cardWidth}\n            group={p}\n            zoomIndex={zoomIndex}\n            selecting={selectedIds.size > 0}\n            selected={selectedIds.has(p.id)}\n            onSelectedChanged={(selected: boolean, shiftKey: boolean) =>\n              onSelectChange(p.id, selected, shiftKey)\n            }\n            fromGroupId={fromGroupId}\n            onMove={onMove}\n          />\n        ))}\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx",
    "content": "import React, { useCallback, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport { RelatedGroupTable, IRelatedGroupEntry } from \"./RelatedGroupTable\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { useAddSubGroups } from \"src/core/StashService\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  ContainingGroupsCriterionOption,\n  GroupsCriterion,\n} from \"src/models/list-filter/criteria/groups\";\n\ninterface IListOperationProps {\n  containingGroup: GQL.GroupDataFragment;\n  onClose: (applied: boolean) => void;\n}\n\nexport const AddSubGroupsDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const addSubGroups = useAddSubGroups();\n\n  const Toast = useToast();\n\n  const [entries, setEntries] = useState<IRelatedGroupEntry[]>([]);\n\n  const excludeIDs = useMemo(\n    () => [\n      ...props.containingGroup.containing_groups.map((m) => m.group.id),\n      props.containingGroup.id,\n    ],\n    [props.containingGroup]\n  );\n\n  const filterHook = useCallback(\n    (f: ListFilterModel) => {\n      const groupValue = {\n        id: props.containingGroup.id,\n        label: props.containingGroup.name,\n      };\n\n      // filter out sub groups that are already in the containing group\n      const criterion = new GroupsCriterion(ContainingGroupsCriterionOption);\n      criterion.value = {\n        items: [groupValue],\n        depth: 1,\n        excluded: [],\n      };\n      criterion.modifier = GQL.CriterionModifier.Excludes;\n      f.criteria.push(criterion);\n\n      return f;\n    },\n    [props.containingGroup]\n  );\n\n  const onSave = async () => {\n    setIsUpdating(true);\n    try {\n      // add the sub groups\n      await addSubGroups(\n        props.containingGroup.id,\n        entries.map((m) => ({\n          group_id: m.group.id,\n          description: m.description,\n        }))\n      );\n\n      const imageCount = entries.length;\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.added_entity\" },\n          {\n            count: imageCount,\n            singularEntity: intl.formatMessage({ id: \"group\" }),\n            pluralEntity: intl.formatMessage({ id: \"groups\" }),\n          }\n        )\n      );\n\n      props.onClose(true);\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setIsUpdating(false);\n    }\n  };\n\n  return (\n    <ModalComponent\n      show\n      icon={faPlus}\n      header={intl.formatMessage({ id: \"actions.add_sub_groups\" })}\n      accept={{\n        onClick: onSave,\n        text: intl.formatMessage({ id: \"actions.add\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(false),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isUpdating}\n    >\n      <Form>\n        <RelatedGroupTable\n          value={entries}\n          onUpdate={(input) => setEntries(input)}\n          excludeIDs={excludeIDs}\n          filterHook={filterHook}\n          menuPortalTarget={document.body}\n        />\n      </Form>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/Group.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Helmet } from \"react-helmet\";\nimport cx from \"classnames\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useFindGroup,\n  useGroupUpdate,\n  useGroupDestroy,\n} from \"src/core/StashService\";\nimport { useHistory, RouteComponentProps, Redirect } from \"react-router-dom\";\nimport { DetailsEditNavbar } from \"src/components/Shared/DetailsEditNavbar\";\nimport { ErrorMessage } from \"src/components/Shared/ErrorMessage\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { GroupScenesPanel } from \"./GroupScenesPanel\";\nimport {\n  CompressedGroupDetailsPanel,\n  GroupDetailsPanel,\n} from \"./GroupDetailsPanel\";\nimport { GroupEditPanel } from \"./GroupEditPanel\";\nimport { faRefresh, faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { DetailImage } from \"src/components/Shared/DetailImage\";\nimport { useRatingKeybinds } from \"src/hooks/keybinds\";\nimport { useLoadStickyHeader } from \"src/hooks/detailsPanel\";\nimport { useScrollToTopOnMount } from \"src/hooks/scrollToTop\";\nimport { ExternalLinkButtons } from \"src/components/Shared/ExternalLinksButton\";\nimport { BackgroundImage } from \"src/components/Shared/DetailsPage/BackgroundImage\";\nimport { DetailTitle } from \"src/components/Shared/DetailsPage/DetailTitle\";\nimport { ExpandCollapseButton } from \"src/components/Shared/CollapseButton\";\nimport { AliasList } from \"src/components/Shared/DetailsPage/AliasList\";\nimport { HeaderImage } from \"src/components/Shared/DetailsPage/HeaderImage\";\nimport { LightboxLink } from \"src/hooks/Lightbox/LightboxLink\";\nimport {\n  TabTitleCounter,\n  useTabKey,\n} from \"src/components/Shared/DetailsPage/Tabs\";\nimport { Button, Tab, Tabs } from \"react-bootstrap\";\nimport { GroupSubGroupsPanel } from \"./GroupSubGroupsPanel\";\nimport { GroupPerformersPanel } from \"./GroupPerformersPanel\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { goBackOrReplace } from \"src/utils/history\";\n\nconst validTabs = [\"default\", \"scenes\", \"performers\", \"subgroups\"] as const;\ntype TabKey = (typeof validTabs)[number];\n\nfunction isTabKey(tab: string): tab is TabKey {\n  return validTabs.includes(tab as TabKey);\n}\n\nconst GroupTabs: React.FC<{\n  tabKey?: TabKey;\n  group: GQL.GroupDataFragment;\n  abbreviateCounter: boolean;\n}> = ({ tabKey, group, abbreviateCounter }) => {\n  const {\n    scene_count: sceneCount,\n    performer_count: performerCount,\n    sub_group_count: groupCount,\n  } = group;\n\n  const populatedDefaultTab = useMemo(() => {\n    if (sceneCount == 0) {\n      if (performerCount != 0) {\n        return \"performers\";\n      } else if (groupCount !== 0) {\n        return \"subgroups\";\n      }\n    }\n\n    return \"scenes\";\n  }, [sceneCount, performerCount, groupCount]);\n\n  const { setTabKey } = useTabKey({\n    tabKey,\n    validTabs,\n    defaultTabKey: populatedDefaultTab,\n    baseURL: `/groups/${group.id}`,\n  });\n\n  return (\n    <Tabs\n      id=\"group-tabs\"\n      mountOnEnter\n      unmountOnExit\n      activeKey={tabKey}\n      onSelect={setTabKey}\n    >\n      <Tab\n        eventKey=\"scenes\"\n        title={\n          <TabTitleCounter\n            messageID=\"scenes\"\n            count={sceneCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <GroupScenesPanel active={tabKey === \"scenes\"} group={group} />\n      </Tab>\n      <Tab\n        eventKey=\"performers\"\n        title={\n          <TabTitleCounter\n            messageID=\"performers\"\n            count={performerCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <GroupPerformersPanel active={tabKey === \"performers\"} group={group} />\n      </Tab>\n      <Tab\n        eventKey=\"subgroups\"\n        title={\n          <TabTitleCounter\n            messageID=\"sub_groups\"\n            count={groupCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <GroupSubGroupsPanel active={tabKey === \"subgroups\"} group={group} />\n      </Tab>\n    </Tabs>\n  );\n};\n\ninterface IProps {\n  group: GQL.GroupDataFragment;\n  tabKey?: TabKey;\n}\n\ninterface IGroupParams {\n  id: string;\n  tab?: string;\n}\n\nconst GroupPage: React.FC<IProps> = ({ group, tabKey }) => {\n  const intl = useIntl();\n  const history = useHistory();\n  const Toast = useToast();\n\n  // Configuration settings\n  const { configuration } = useConfigurationContext();\n  const uiConfig = configuration?.ui;\n  const enableBackgroundImage = uiConfig?.enableMovieBackgroundImage ?? false;\n  const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;\n  const showAllDetails = uiConfig?.showAllDetails ?? true;\n  const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;\n\n  const [focusedOnFront, setFocusedOnFront] = useState<boolean>(true);\n\n  const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);\n  const loadStickyHeader = useLoadStickyHeader();\n\n  // Editing state\n  const [isEditing, setIsEditing] = useState<boolean>(false);\n  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);\n\n  // Editing group state\n  const [frontImage, setFrontImage] = useState<string | null>();\n  const [backImage, setBackImage] = useState<string | null>();\n  const [encodingImage, setEncodingImage] = useState<boolean>(false);\n\n  const aliases = useMemo(\n    () => (group.aliases ? [group.aliases] : []),\n    [group.aliases]\n  );\n\n  const isDefaultImage =\n    group.front_image_path && group.front_image_path.includes(\"default=true\");\n\n  const lightboxImages = useMemo(() => {\n    const covers = [];\n\n    if (group.front_image_path && !isDefaultImage) {\n      covers.push({\n        paths: {\n          thumbnail: group.front_image_path,\n          image: group.front_image_path,\n        },\n      });\n    }\n\n    if (group.back_image_path) {\n      covers.push({\n        paths: {\n          thumbnail: group.back_image_path,\n          image: group.back_image_path,\n        },\n      });\n    }\n    return covers;\n  }, [group.front_image_path, group.back_image_path, isDefaultImage]);\n\n  const activeFrontImage = useMemo(() => {\n    let existingImage = group.front_image_path;\n    if (isEditing) {\n      if (frontImage === null && existingImage) {\n        const imageURL = new URL(existingImage);\n        imageURL.searchParams.set(\"default\", \"true\");\n        return imageURL.toString();\n      } else if (frontImage) {\n        return frontImage;\n      }\n    }\n\n    return existingImage;\n  }, [isEditing, group.front_image_path, frontImage]);\n\n  const activeBackImage = useMemo(() => {\n    let existingImage = group.back_image_path;\n    if (isEditing) {\n      if (backImage === null) {\n        return undefined;\n      } else if (backImage) {\n        return backImage;\n      }\n    }\n\n    return existingImage;\n  }, [isEditing, group.back_image_path, backImage]);\n\n  const [updateGroup, { loading: updating }] = useGroupUpdate();\n  const [deleteGroup, { loading: deleting }] = useGroupDestroy({\n    id: group.id,\n  });\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"e\", () => toggleEditing());\n    Mousetrap.bind(\"d d\", () => {\n      setIsDeleteAlertOpen(true);\n    });\n    Mousetrap.bind(\",\", () => setCollapsed(!collapsed));\n\n    return () => {\n      Mousetrap.unbind(\"e\");\n      Mousetrap.unbind(\"d d\");\n    };\n  });\n\n  useRatingKeybinds(\n    true,\n    configuration?.ui.ratingSystemOptions?.type,\n    setRating\n  );\n\n  async function onSave(input: GQL.GroupCreateInput) {\n    await updateGroup({\n      variables: {\n        input: {\n          id: group.id,\n          ...input,\n        },\n      },\n    });\n    toggleEditing(false);\n    Toast.success(\n      intl.formatMessage(\n        { id: \"toast.updated_entity\" },\n        { entity: intl.formatMessage({ id: \"group\" }).toLocaleLowerCase() }\n      )\n    );\n  }\n\n  async function onDelete() {\n    try {\n      await deleteGroup();\n    } catch (e) {\n      Toast.error(e);\n      return;\n    }\n\n    goBackOrReplace(history, \"/groups\");\n  }\n\n  function toggleEditing(value?: boolean) {\n    if (value !== undefined) {\n      setIsEditing(value);\n    } else {\n      setIsEditing((e) => !e);\n    }\n    setFrontImage(undefined);\n    setBackImage(undefined);\n  }\n\n  function renderDeleteAlert() {\n    return (\n      <ModalComponent\n        show={isDeleteAlertOpen}\n        icon={faTrashAlt}\n        accept={{\n          text: intl.formatMessage({ id: \"actions.delete\" }),\n          variant: \"danger\",\n          onClick: onDelete,\n        }}\n        cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}\n      >\n        <p>\n          <FormattedMessage\n            id=\"dialogs.delete_confirm\"\n            values={{\n              entityName:\n                group.name ??\n                intl.formatMessage({ id: \"group\" }).toLocaleLowerCase(),\n            }}\n          />\n        </p>\n      </ModalComponent>\n    );\n  }\n\n  function setRating(v: number | null) {\n    if (group.id) {\n      updateGroup({\n        variables: {\n          input: {\n            id: group.id,\n            rating100: v,\n          },\n        },\n      });\n    }\n  }\n\n  if (updating || deleting) return <LoadingIndicator />;\n\n  const headerClassName = cx(\"detail-header\", {\n    edit: isEditing,\n    collapsed,\n    \"full-width\": !collapsed && !compactExpandedDetails,\n  });\n\n  return (\n    <div id=\"group-page\" className=\"row\">\n      <Helmet>\n        <title>{group?.name}</title>\n      </Helmet>\n\n      <div className={headerClassName}>\n        <BackgroundImage\n          imagePath={group.front_image_path ?? undefined}\n          show={enableBackgroundImage && !isEditing}\n        />\n        <div className=\"detail-container\">\n          <HeaderImage encodingImage={encodingImage}>\n            <div className=\"group-images\">\n              {!!activeFrontImage && (\n                <LightboxLink images={lightboxImages}>\n                  <DetailImage\n                    className={`front-cover ${\n                      focusedOnFront ? \"active\" : \"inactive\"\n                    }`}\n                    alt=\"Front Cover\"\n                    src={activeFrontImage}\n                  />\n                </LightboxLink>\n              )}\n              {!!activeBackImage && (\n                <LightboxLink\n                  images={lightboxImages}\n                  index={lightboxImages.length - 1}\n                >\n                  <DetailImage\n                    className={`back-cover ${\n                      !focusedOnFront ? \"active\" : \"inactive\"\n                    }`}\n                    alt=\"Back Cover\"\n                    src={activeBackImage}\n                  />\n                </LightboxLink>\n              )}\n              {!!(activeFrontImage && activeBackImage) && (\n                <Button\n                  className=\"flip\"\n                  onClick={() => setFocusedOnFront(!focusedOnFront)}\n                >\n                  <Icon icon={faRefresh} />\n                </Button>\n              )}\n            </div>\n          </HeaderImage>\n          <div className=\"row\">\n            <div className=\"group-head col\">\n              <DetailTitle name={group.name} classNamePrefix=\"group\">\n                {!isEditing && (\n                  <ExpandCollapseButton\n                    collapsed={collapsed}\n                    setCollapsed={(v) => setCollapsed(v)}\n                  />\n                )}\n                <span className=\"name-icons\">\n                  <ExternalLinkButtons urls={group.urls ?? undefined} />\n                </span>\n              </DetailTitle>\n\n              <AliasList aliases={aliases} />\n              <RatingSystem\n                value={group.rating100}\n                onSetRating={(value) => setRating(value)}\n                clickToRate\n                withoutContext\n              />\n              {!isEditing && (\n                <GroupDetailsPanel\n                  group={group}\n                  collapsed={collapsed}\n                  fullWidth={!collapsed && !compactExpandedDetails}\n                />\n              )}\n              {isEditing ? (\n                <GroupEditPanel\n                  group={group}\n                  onSubmit={onSave}\n                  onCancel={() => toggleEditing()}\n                  onDelete={onDelete}\n                  setFrontImage={setFrontImage}\n                  setBackImage={setBackImage}\n                  setEncodingImage={setEncodingImage}\n                />\n              ) : (\n                <DetailsEditNavbar\n                  objectName={group.name}\n                  isNew={false}\n                  isEditing={isEditing}\n                  onToggleEdit={() => toggleEditing()}\n                  onSave={() => {}}\n                  onImageChange={() => {}}\n                  onDelete={onDelete}\n                />\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {!isEditing && loadStickyHeader && (\n        <CompressedGroupDetailsPanel group={group} />\n      )}\n\n      <div className=\"detail-body\">\n        <div className=\"group-body\">\n          <div className=\"group-tabs\">\n            {!isEditing && (\n              <GroupTabs\n                group={group}\n                tabKey={tabKey}\n                abbreviateCounter={abbreviateCounter}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n      {renderDeleteAlert()}\n    </div>\n  );\n};\n\nconst GroupLoader: React.FC<RouteComponentProps<IGroupParams>> = ({\n  location,\n  match,\n}) => {\n  const { id, tab } = match.params;\n  const { data, loading, error } = useFindGroup(id);\n\n  useScrollToTopOnMount();\n\n  if (tab && !isTabKey(tab)) {\n    return (\n      <Redirect\n        to={{\n          ...location,\n          pathname: `/groups/${id}`,\n        }}\n      />\n    );\n  }\n\n  if (loading) return <LoadingIndicator />;\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findGroup)\n    return <ErrorMessage error={`No group found with id ${id}.`} />;\n\n  return (\n    <GroupPage group={data.findGroup} tabKey={tab as TabKey | undefined} />\n  );\n};\n\nexport default GroupLoader;\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useGroupCreate } from \"src/core/StashService\";\nimport { useHistory, useLocation } from \"react-router-dom\";\nimport { useIntl } from \"react-intl\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { GroupEditPanel } from \"./GroupEditPanel\";\n\nconst GroupCreate: React.FC = () => {\n  const history = useHistory();\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const location = useLocation();\n  const query = useMemo(() => new URLSearchParams(location.search), [location]);\n  const group = {\n    name: query.get(\"q\") ?? undefined,\n  };\n\n  // Editing group state\n  const [frontImage, setFrontImage] = useState<string | null>();\n  const [backImage, setBackImage] = useState<string | null>();\n  const [encodingImage, setEncodingImage] = useState<boolean>(false);\n\n  const [createGroup] = useGroupCreate();\n\n  async function onSave(input: GQL.GroupCreateInput, andNew?: boolean) {\n    const result = await createGroup({\n      variables: { input },\n    });\n    if (result.data?.groupCreate?.id) {\n      if (!andNew) {\n        history.push(`/groups/${result.data.groupCreate.id}`);\n      }\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          { entity: intl.formatMessage({ id: \"group\" }).toLocaleLowerCase() }\n        )\n      );\n    }\n  }\n\n  function renderFrontImage() {\n    if (frontImage) {\n      return (\n        <div className=\"group-image-container\">\n          <img alt=\"Front Cover\" src={frontImage} />\n        </div>\n      );\n    }\n  }\n\n  function renderBackImage() {\n    if (backImage) {\n      return (\n        <div className=\"group-image-container\">\n          <img alt=\"Back Cover\" src={backImage} />\n        </div>\n      );\n    }\n  }\n\n  // TODO: CSS class\n  return (\n    <div className=\"row\">\n      <div className=\"group-details mb-3 col\">\n        <div className=\"logo w-100\">\n          {encodingImage ? (\n            <LoadingIndicator\n              message={intl.formatMessage({ id: \"actions.encoding_image\" })}\n            />\n          ) : (\n            <div className=\"group-images\">\n              {renderFrontImage()}\n              {renderBackImage()}\n            </div>\n          )}\n        </div>\n\n        <GroupEditPanel\n          group={group}\n          onSubmit={onSave}\n          onCancel={() => history.push(\"/groups\")}\n          onDelete={() => {}}\n          setFrontImage={setFrontImage}\n          setBackImage={setBackImage}\n          setEncodingImage={setEncodingImage}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default GroupCreate;\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx",
    "content": "import React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport { DetailItem } from \"src/components/Shared/DetailItem\";\nimport { Link } from \"react-router-dom\";\nimport { DirectorLink } from \"src/components/Shared/Link\";\nimport { GroupLink, TagLink } from \"src/components/Shared/TagLink\";\nimport { CustomFields } from \"src/components/Shared/CustomFields\";\n\ninterface IGroupDescription {\n  group: GQL.SlimGroupDataFragment;\n  description?: string | null;\n}\n\nconst GroupsList: React.FC<{ groups: IGroupDescription[] }> = ({ groups }) => {\n  if (!groups.length) {\n    return null;\n  }\n\n  return (\n    <ul className=\"groups-list\">\n      {groups.map((entry) => (\n        <li key={entry.group.id}>\n          <GroupLink group={entry.group} linkType=\"details\" />\n        </li>\n      ))}\n    </ul>\n  );\n};\n\ninterface IGroupDetailsPanel {\n  group: GQL.GroupDataFragment;\n  collapsed?: boolean;\n  fullWidth?: boolean;\n}\n\nexport const GroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({\n  group,\n  fullWidth,\n}) => {\n  // Network state\n  const intl = useIntl();\n\n  function renderTagsField() {\n    if (!group.tags.length) {\n      return;\n    }\n    return (\n      <ul className=\"pl-0\">\n        {(group.tags ?? []).map((tag) => (\n          <TagLink key={tag.id} linkType=\"group\" tag={tag} />\n        ))}\n      </ul>\n    );\n  }\n\n  return (\n    <div className=\"detail-group\">\n      <DetailItem\n        id=\"duration\"\n        value={\n          group.duration ? TextUtils.secondsToTimestamp(group.duration) : \"\"\n        }\n        fullWidth={fullWidth}\n      />\n      <DetailItem\n        id=\"date\"\n        value={group.date ? TextUtils.formatFuzzyDate(intl, group.date) : \"\"}\n        fullWidth={fullWidth}\n      />\n      <DetailItem\n        id=\"studio\"\n        value={\n          group.studio?.id ? (\n            <Link to={`/studios/${group.studio?.id}`}>\n              {group.studio?.name}\n            </Link>\n          ) : (\n            \"\"\n          )\n        }\n        fullWidth={fullWidth}\n      />\n      <DetailItem\n        id=\"director\"\n        value={\n          group.director ? (\n            <DirectorLink director={group.director} linkType=\"group\" />\n          ) : (\n            \"\"\n          )\n        }\n        fullWidth={fullWidth}\n      />\n      <DetailItem id=\"synopsis\" value={group.synopsis} fullWidth={fullWidth} />\n      <DetailItem id=\"tags\" value={renderTagsField()} fullWidth={fullWidth} />\n      {group.containing_groups.length > 0 && (\n        <DetailItem\n          id=\"containing_groups\"\n          value={<GroupsList groups={group.containing_groups} />}\n          fullWidth={fullWidth}\n        />\n      )}\n      <CustomFields values={group.custom_fields} />\n    </div>\n  );\n};\n\nexport const CompressedGroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({\n  group,\n}) => {\n  function scrollToTop() {\n    window.scrollTo({ top: 0, behavior: \"smooth\" });\n  }\n\n  return (\n    <div className=\"sticky detail-header\">\n      <div className=\"sticky detail-header-group\">\n        <a className=\"group-name\" onClick={() => scrollToTop()}>\n          {group.name}\n        </a>\n        {group?.studio?.name ? (\n          <>\n            <span className=\"detail-divider\">/</span>\n            <span className=\"group-studio\">{group?.studio?.name}</span>\n          </>\n        ) : (\n          \"\"\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport * as yup from \"yup\";\nimport Mousetrap from \"mousetrap\";\nimport {\n  queryScrapeGroupURL,\n  useListGroupScrapers,\n} from \"src/core/StashService\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { DetailsEditNavbar } from \"src/components/Shared/DetailsEditNavbar\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { Modal as BSModal, Form, Button } from \"react-bootstrap\";\nimport TextUtils from \"src/utils/text\";\nimport ImageUtils from \"src/utils/image\";\nimport { useFormik } from \"formik\";\nimport { Prompt } from \"react-router-dom\";\nimport { GroupScrapeDialog } from \"./GroupScrapeDialog\";\nimport isEqual from \"lodash-es/isEqual\";\nimport { handleUnsavedChanges } from \"src/utils/navigation\";\nimport { formikUtils } from \"src/utils/form\";\nimport {\n  yupDateString,\n  yupFormikValidate,\n  yupUniqueStringList,\n} from \"src/utils/yup\";\nimport { Studio, StudioSelect } from \"src/components/Studios/StudioSelect\";\nimport { useTagsEdit } from \"src/hooks/tagsEdit\";\nimport { Group } from \"src/components/Groups/GroupSelect\";\nimport { RelatedGroupTable, IRelatedGroupEntry } from \"./RelatedGroupTable\";\nimport {\n  CustomFieldsInput,\n  formatCustomFieldInput,\n} from \"src/components/Shared/CustomFields\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\n\ninterface IGroupEditPanel {\n  group: Partial<GQL.GroupDataFragment>;\n  onSubmit: (group: GQL.GroupCreateInput, andNew?: boolean) => Promise<void>;\n  onCancel: () => void;\n  onDelete: () => void;\n  setFrontImage: (image?: string | null) => void;\n  setBackImage: (image?: string | null) => void;\n  setEncodingImage: (loading: boolean) => void;\n}\n\nexport const GroupEditPanel: React.FC<IGroupEditPanel> = ({\n  group,\n  onSubmit,\n  onCancel,\n  onDelete,\n  setFrontImage,\n  setBackImage,\n  setEncodingImage,\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const isNew = group.id === undefined;\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [isImageAlertOpen, setIsImageAlertOpen] = useState<boolean>(false);\n\n  const [imageClipboard, setImageClipboard] = useState<string>();\n\n  const Scrapers = useListGroupScrapers();\n  const [scrapedGroup, setScrapedGroup] = useState<GQL.ScrapedGroup>();\n\n  const [studio, setStudio] = useState<Studio | null>(null);\n  const [containingGroups, setContainingGroups] = useState<Group[]>([]);\n\n  const schema = yup.object({\n    name: yup.string().required(),\n    aliases: yup.string().ensure(),\n    duration: yup.number().integer().min(0).nullable().defined(),\n    date: yupDateString(intl),\n    studio_id: yup.string().required().nullable(),\n    tag_ids: yup.array(yup.string().required()).defined(),\n    containing_groups: yup\n      .array(\n        yup.object({\n          group_id: yup.string().required(),\n          description: yup.string().nullable().ensure(),\n        })\n      )\n      .defined(),\n    director: yup.string().ensure(),\n    urls: yupUniqueStringList(intl),\n    synopsis: yup.string().ensure(),\n    front_image: yup.string().nullable().optional(),\n    back_image: yup.string().nullable().optional(),\n    custom_fields: yup.object().required().defined(),\n  });\n\n  const initialValues = {\n    name: group?.name ?? \"\",\n    aliases: group?.aliases ?? \"\",\n    duration: group?.duration ?? null,\n    date: group?.date ?? \"\",\n    studio_id: group?.studio?.id ?? null,\n    tag_ids: (group?.tags ?? []).map((t) => t.id),\n    containing_groups: (group?.containing_groups ?? []).map((m) => {\n      return { group_id: m.group.id, description: m.description ?? \"\" };\n    }),\n    director: group?.director ?? \"\",\n    urls: group?.urls ?? [],\n    synopsis: group?.synopsis ?? \"\",\n    custom_fields: cloneDeep(group?.custom_fields ?? {}),\n  };\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const [customFieldsError, setCustomFieldsError] = useState<string>();\n\n  function submit(values: InputValues) {\n    const input = {\n      ...schema.cast(values),\n      custom_fields: formatCustomFieldInput(isNew, values.custom_fields),\n    };\n    onSave(input);\n  }\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: submit,\n  });\n\n  const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(\n    group.tags,\n    (ids) => formik.setFieldValue(\"tag_ids\", ids)\n  );\n\n  const containingGroupEntries = useMemo(() => {\n    return formik.values.containing_groups\n      .map((m) => {\n        return {\n          group: containingGroups.find((mm) => mm.id === m.group_id),\n          description: m.description,\n        };\n      })\n      .filter((m) => m.group !== undefined) as IRelatedGroupEntry[];\n  }, [formik.values.containing_groups, containingGroups]);\n\n  function onSetStudio(item: Studio | null) {\n    setStudio(item);\n    formik.setFieldValue(\"studio_id\", item ? item.id : null);\n  }\n\n  useEffect(() => {\n    setStudio(group.studio ?? null);\n  }, [group.studio]);\n\n  useEffect(() => {\n    setContainingGroups(group.containing_groups?.map((m) => m.group) ?? []);\n  }, [group.containing_groups]);\n\n  // set up hotkeys\n  useEffect(() => {\n    // Mousetrap.bind(\"u\", (e) => {\n    //   setStudioFocus()\n    //   e.preventDefault();\n    // });\n    Mousetrap.bind(\"s s\", () => {\n      if (formik.dirty) {\n        formik.submitForm();\n      }\n    });\n\n    return () => {\n      // Mousetrap.unbind(\"u\");\n      Mousetrap.unbind(\"s s\");\n    };\n  });\n\n  function updateGroupEditStateFromScraper(\n    state: Partial<GQL.ScrapedGroupDataFragment>\n  ) {\n    if (state.name) {\n      formik.setFieldValue(\"name\", state.name);\n    }\n\n    if (state.aliases) {\n      formik.setFieldValue(\"aliases\", state.aliases);\n    }\n\n    if (state.duration) {\n      const seconds = TextUtils.timestampToSeconds(state.duration);\n      if (seconds) {\n        formik.setFieldValue(\"duration\", seconds);\n      }\n    }\n\n    if (state.date) {\n      formik.setFieldValue(\"date\", state.date);\n    }\n\n    if (state.studio && state.studio.stored_id) {\n      onSetStudio({\n        id: state.studio.stored_id,\n        name: state.studio.name ?? \"\",\n        aliases: [],\n      });\n    }\n\n    if (state.director) {\n      formik.setFieldValue(\"director\", state.director);\n    }\n    if (state.synopsis) {\n      formik.setFieldValue(\"synopsis\", state.synopsis);\n    }\n    if (state.urls) {\n      formik.setFieldValue(\"urls\", state.urls);\n    }\n    updateTagsStateFromScraper(state.tags ?? undefined);\n\n    if (state.front_image) {\n      // image is a base64 string\n      formik.setFieldValue(\"front_image\", state.front_image);\n    }\n    if (state.back_image) {\n      // image is a base64 string\n      formik.setFieldValue(\"back_image\", state.back_image);\n    }\n  }\n\n  async function onSave(input: InputValues, andNew?: boolean) {\n    setIsLoading(true);\n    try {\n      await onSubmit(input, andNew);\n      formik.resetForm();\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsLoading(false);\n  }\n\n  async function onSaveAndNewClick() {\n    const input = {\n      ...schema.cast(formik.values),\n      custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),\n    };\n    onSave(input, true);\n  }\n\n  async function onScrapeGroupURL(url: string) {\n    if (!url) return;\n    setIsLoading(true);\n\n    try {\n      const result = await queryScrapeGroupURL(url);\n      if (!result.data || !result.data.scrapeGroupURL) {\n        return;\n      }\n\n      // if this is a new group, just dump the data\n      if (isNew) {\n        updateGroupEditStateFromScraper(result.data.scrapeGroupURL);\n      } else {\n        setScrapedGroup(result.data.scrapeGroupURL);\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  function urlScrapable(scrapedUrl: string) {\n    return (\n      !!scrapedUrl &&\n      (Scrapers?.data?.listScrapers ?? []).some((s) =>\n        (s?.group?.urls ?? []).some((u) => scrapedUrl.includes(u))\n      )\n    );\n  }\n\n  function maybeRenderScrapeDialog() {\n    if (!scrapedGroup) {\n      return;\n    }\n\n    const currentGroup = {\n      id: group.id!,\n      ...formik.values,\n    };\n\n    // Get image paths for scrape gui\n    currentGroup.front_image = group?.front_image_path;\n    currentGroup.back_image = group?.back_image_path;\n\n    return (\n      <GroupScrapeDialog\n        group={currentGroup}\n        groupStudio={studio}\n        groupTags={tags}\n        scraped={scrapedGroup}\n        onClose={(m) => {\n          onScrapeDialogClosed(m);\n        }}\n      />\n    );\n  }\n\n  function onScrapeDialogClosed(p?: GQL.ScrapedGroupDataFragment) {\n    if (p) {\n      updateGroupEditStateFromScraper(p);\n    }\n    setScrapedGroup(undefined);\n  }\n\n  const encodingImage = ImageUtils.usePasteImage(showImageAlert);\n\n  useEffect(() => {\n    setFrontImage(formik.values.front_image);\n  }, [formik.values.front_image, setFrontImage]);\n\n  useEffect(() => {\n    setBackImage(formik.values.back_image);\n  }, [formik.values.back_image, setBackImage]);\n\n  useEffect(() => {\n    setEncodingImage(encodingImage);\n  }, [setEncodingImage, encodingImage]);\n\n  function onFrontImageLoad(imageData: string | null) {\n    formik.setFieldValue(\"front_image\", imageData);\n  }\n\n  function onFrontImageChange(event: React.FormEvent<HTMLInputElement>) {\n    ImageUtils.onImageChange(event, onFrontImageLoad);\n  }\n\n  function onBackImageLoad(imageData: string | null) {\n    formik.setFieldValue(\"back_image\", imageData);\n  }\n\n  function onBackImageChange(event: React.FormEvent<HTMLInputElement>) {\n    ImageUtils.onImageChange(event, onBackImageLoad);\n  }\n\n  function showImageAlert(imageData: string) {\n    setImageClipboard(imageData);\n    setIsImageAlertOpen(true);\n  }\n\n  function setImageFromClipboard(isFrontImage: boolean) {\n    if (isFrontImage) {\n      formik.setFieldValue(\"front_image\", imageClipboard);\n    } else {\n      formik.setFieldValue(\"back_image\", imageClipboard);\n    }\n\n    setImageClipboard(undefined);\n    setIsImageAlertOpen(false);\n  }\n\n  function renderImageAlert() {\n    return (\n      <BSModal\n        show={isImageAlertOpen}\n        onHide={() => setIsImageAlertOpen(false)}\n      >\n        <BSModal.Body>\n          <p>Select image to set</p>\n        </BSModal.Body>\n        <BSModal.Footer>\n          <div>\n            <Button\n              className=\"mr-2\"\n              variant=\"secondary\"\n              onClick={() => setIsImageAlertOpen(false)}\n            >\n              <FormattedMessage id=\"actions.cancel\" />\n            </Button>\n\n            <Button\n              className=\"mr-2\"\n              onClick={() => setImageFromClipboard(false)}\n            >\n              Back Image\n            </Button>\n            <Button\n              className=\"mr-2\"\n              onClick={() => setImageFromClipboard(true)}\n            >\n              Front Image\n            </Button>\n          </div>\n        </BSModal.Footer>\n      </BSModal>\n    );\n  }\n\n  if (isLoading) return <LoadingIndicator />;\n\n  const {\n    renderField,\n    renderInputField,\n    renderDateField,\n    renderDurationField,\n    renderURLListField,\n  } = formikUtils(intl, formik);\n\n  function renderStudioField() {\n    const title = intl.formatMessage({ id: \"studio\" });\n    const control = (\n      <StudioSelect\n        onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)}\n        values={studio ? [studio] : []}\n      />\n    );\n\n    return renderField(\"studio_id\", title, control);\n  }\n\n  function renderTagsField() {\n    const title = intl.formatMessage({ id: \"tags\" });\n    return renderField(\"tag_ids\", title, tagsControl());\n  }\n\n  function onSetContainingGroupEntries(input: IRelatedGroupEntry[]) {\n    setContainingGroups(input.map((m) => m.group));\n\n    const newGroups = input.map((m) => ({\n      group_id: m.group.id,\n      description: m.description,\n    }));\n\n    formik.setFieldValue(\"containing_groups\", newGroups);\n  }\n\n  function renderContainingGroupsField() {\n    const title = intl.formatMessage({ id: \"containing_groups\" });\n    const control = (\n      <RelatedGroupTable\n        value={containingGroupEntries}\n        onUpdate={onSetContainingGroupEntries}\n        excludeIDs={group.id ? [group.id] : undefined}\n      />\n    );\n\n    return renderField(\"containing_groups\", title, control);\n  }\n\n  // TODO: CSS class\n  return (\n    <div>\n      {isNew && (\n        <h2>\n          {intl.formatMessage(\n            { id: \"actions.add_entity\" },\n            { entityType: intl.formatMessage({ id: \"group\" }) }\n          )}\n        </h2>\n      )}\n\n      <Prompt\n        when={formik.dirty}\n        message={(location, action) => {\n          // Check if it's a redirect after group creation\n          if (action === \"PUSH\" && location.pathname.startsWith(\"/groups/\"))\n            return true;\n\n          return handleUnsavedChanges(intl, \"groups\", group.id)(location);\n        }}\n      />\n\n      <Form noValidate onSubmit={formik.handleSubmit} id=\"group-edit\">\n        {renderInputField(\"name\")}\n        {renderInputField(\"aliases\")}\n        {renderDurationField(\"duration\")}\n        {renderDateField(\"date\")}\n        {renderContainingGroupsField()}\n        {renderStudioField()}\n        {renderInputField(\"director\")}\n        {renderURLListField(\"urls\", onScrapeGroupURL, urlScrapable)}\n        {renderInputField(\"synopsis\", \"textarea\")}\n        {renderTagsField()}\n\n        <CustomFieldsInput\n          values={formik.values.custom_fields}\n          onChange={(v) => formik.setFieldValue(\"custom_fields\", v)}\n          error={customFieldsError}\n          setError={(e) => setCustomFieldsError(e)}\n        />\n      </Form>\n\n      <DetailsEditNavbar\n        objectName={group?.name ?? intl.formatMessage({ id: \"group\" })}\n        isNew={isNew}\n        classNames=\"col-xl-9 mt-3\"\n        isEditing\n        onToggleEdit={onCancel}\n        onSave={formik.handleSubmit}\n        onSaveAndNew={isNew ? onSaveAndNewClick : undefined}\n        saveDisabled={\n          (!isNew && !formik.dirty) ||\n          !isEqual(formik.errors, {}) ||\n          customFieldsError !== undefined\n        }\n        onImageChange={onFrontImageChange}\n        onImageChangeURL={onFrontImageLoad}\n        onClearImage={() => onFrontImageLoad(null)}\n        onBackImageChange={onBackImageChange}\n        onBackImageChangeURL={onBackImageLoad}\n        onClearBackImage={() => onBackImageLoad(null)}\n        onDelete={onDelete}\n      />\n\n      {maybeRenderScrapeDialog()}\n      {renderImageAlert()}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/GroupPerformersPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useGroupFilterHook } from \"src/core/groups\";\nimport { FilteredPerformerList } from \"src/components/Performers/PerformerList\";\nimport { View } from \"src/components/List/views\";\n\ninterface IGroupPerformersPanel {\n  active: boolean;\n  group: GQL.GroupDataFragment;\n  showChildGroupContent?: boolean;\n}\n\nexport const GroupPerformersPanel: React.FC<IGroupPerformersPanel> = ({\n  active,\n  group,\n  showChildGroupContent,\n}) => {\n  const filterHook = useGroupFilterHook(group, showChildGroupContent);\n\n  return (\n    <FilteredPerformerList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.GroupPerformers}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  GroupsCriterion,\n  GroupsCriterionOption,\n} from \"src/models/list-filter/criteria/groups\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { FilteredSceneList } from \"src/components/Scenes/SceneList\";\nimport { View } from \"src/components/List/views\";\n\ninterface IGroupScenesPanel {\n  active: boolean;\n  group: GQL.GroupDataFragment;\n  showSubGroupContent?: boolean;\n}\n\nfunction useFilterHook(\n  group: Pick<GQL.GroupDataFragment, \"id\" | \"name\">,\n  showSubGroupContent?: boolean\n) {\n  return (filter: ListFilterModel) => {\n    const groupValue = { id: group.id, label: group.name };\n    // if group is already present, then we modify it, otherwise add\n    let groupCriterion = filter.criteria.find((c) => {\n      return c.criterionOption.type === \"groups\";\n    }) as GroupsCriterion | undefined;\n\n    if (\n      groupCriterion &&\n      (groupCriterion.modifier === GQL.CriterionModifier.IncludesAll ||\n        groupCriterion.modifier === GQL.CriterionModifier.Includes)\n    ) {\n      // add the group if not present\n      if (\n        !groupCriterion.value.items.find((p) => {\n          return p.id === group.id;\n        })\n      ) {\n        groupCriterion.value.items.push(groupValue);\n      }\n\n      groupCriterion.modifier = GQL.CriterionModifier.IncludesAll;\n    } else {\n      // overwrite\n      groupCriterion = new GroupsCriterion(GroupsCriterionOption);\n      groupCriterion.value = {\n        items: [groupValue],\n        depth: showSubGroupContent ? -1 : 0,\n        excluded: [],\n      };\n      filter.criteria.push(groupCriterion);\n    }\n\n    return filter;\n  };\n}\n\nexport const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({\n  active,\n  group,\n  showSubGroupContent,\n}) => {\n  const filterHook = useFilterHook(group, showSubGroupContent);\n\n  if (group && group.id) {\n    return (\n      <FilteredSceneList\n        filterHook={filterHook}\n        defaultSort=\"group_scene_number\"\n        alterQuery={active}\n        view={View.GroupScenes}\n        fromGroupId={group.id}\n      />\n    );\n  }\n  return <></>;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  ScrapedInputGroupRow,\n  ScrapedImageRow,\n  ScrapedTextAreaRow,\n  ScrapedStringListRow,\n} from \"src/components/Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { ScrapeDialog } from \"src/components/Shared/ScrapeDialog/ScrapeDialog\";\nimport TextUtils from \"src/utils/text\";\nimport {\n  ObjectScrapeResult,\n  ScrapeResult,\n} from \"src/components/Shared/ScrapeDialog/scrapeResult\";\nimport { Studio } from \"src/components/Studios/StudioSelect\";\nimport { useCreateScrapedStudio } from \"src/components/Shared/ScrapeDialog/createObjects\";\nimport { ScrapedStudioRow } from \"src/components/Shared/ScrapeDialog/ScrapedObjectsRow\";\nimport { uniq } from \"lodash-es\";\nimport { Tag } from \"src/components/Tags/TagSelect\";\nimport { useScrapedTags } from \"src/components/Shared/ScrapeDialog/scrapedTags\";\n\ninterface IGroupScrapeDialogProps {\n  group: Partial<GQL.GroupUpdateInput>;\n  groupStudio: Studio | null;\n  groupTags: Tag[];\n  scraped: GQL.ScrapedGroup;\n\n  onClose: (scrapedGroup?: GQL.ScrapedGroup) => void;\n}\n\nexport const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({\n  group,\n  groupStudio: groupStudio,\n  groupTags: groupTags,\n  scraped,\n  onClose,\n}) => {\n  const intl = useIntl();\n\n  const [name, setName] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(group.name, scraped.name)\n  );\n  const [aliases, setAliases] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(group.aliases, scraped.aliases)\n  );\n  const [duration, setDuration] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      TextUtils.secondsToTimestamp(group.duration || 0),\n      // convert seconds to string if it's a number\n      scraped.duration && !isNaN(+scraped.duration)\n        ? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10))\n        : scraped.duration\n    )\n  );\n  const [date, setDate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(group.date, scraped.date)\n  );\n  const [director, setDirector] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(group.director, scraped.director)\n  );\n  const [synopsis, setSynopsis] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(group.synopsis, scraped.synopsis)\n  );\n  const [studio, setStudio] = useState<ObjectScrapeResult<GQL.ScrapedStudio>>(\n    new ObjectScrapeResult<GQL.ScrapedStudio>(\n      groupStudio\n        ? {\n            stored_id: groupStudio.id,\n            name: groupStudio.name,\n          }\n        : undefined,\n      scraped.studio?.stored_id ? scraped.studio : undefined\n    )\n  );\n  const [urls, setURLs] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(\n      group.urls,\n      scraped.urls\n        ? uniq((group.urls ?? []).concat(scraped.urls ?? []))\n        : undefined\n    )\n  );\n  const [frontImage, setFrontImage] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(group.front_image, scraped.front_image)\n  );\n  const [backImage, setBackImage] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(group.back_image, scraped.back_image)\n  );\n\n  const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(\n    scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined\n  );\n\n  const createNewStudio = useCreateScrapedStudio({\n    scrapeResult: studio,\n    setScrapeResult: setStudio,\n    setNewObject: setNewStudio,\n  });\n\n  const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(\n    groupTags,\n    scraped.tags\n  );\n\n  const allFields = [\n    name,\n    aliases,\n    duration,\n    date,\n    director,\n    synopsis,\n    studio,\n    tags,\n    urls,\n    frontImage,\n    backImage,\n  ];\n  // don't show the dialog if nothing was scraped\n  if (\n    allFields.every((r) => !r.scraped) &&\n    !newStudio &&\n    newTags.length === 0\n  ) {\n    onClose();\n    return <></>;\n  }\n\n  function makeNewScrapedItem(): GQL.ScrapedGroup {\n    const newStudioValue = studio.getNewValue();\n    const durationString = duration.getNewValue();\n\n    return {\n      name: name.getNewValue() ?? \"\",\n      aliases: aliases.getNewValue(),\n      duration: durationString,\n      date: date.getNewValue(),\n      director: director.getNewValue(),\n      synopsis: synopsis.getNewValue(),\n      studio: newStudioValue,\n      tags: tags.getNewValue(),\n      urls: urls.getNewValue(),\n      front_image: frontImage.getNewValue(),\n      back_image: backImage.getNewValue(),\n    };\n  }\n\n  function renderScrapeRows() {\n    return (\n      <>\n        <ScrapedInputGroupRow\n          field=\"name\"\n          title={intl.formatMessage({ id: \"name\" })}\n          result={name}\n          onChange={(value) => setName(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"aliases\"\n          title={intl.formatMessage({ id: \"aliases\" })}\n          result={aliases}\n          onChange={(value) => setAliases(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"duration\"\n          title={intl.formatMessage({ id: \"duration\" })}\n          result={duration}\n          onChange={(value) => setDuration(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"date\"\n          title={intl.formatMessage({ id: \"date\" })}\n          placeholder=\"YYYY-MM-DD\"\n          result={date}\n          onChange={(value) => setDate(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"director\"\n          title={intl.formatMessage({ id: \"director\" })}\n          result={director}\n          onChange={(value) => setDirector(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"synopsis\"\n          title={intl.formatMessage({ id: \"synopsis\" })}\n          result={synopsis}\n          onChange={(value) => setSynopsis(value)}\n        />\n        <ScrapedStudioRow\n          field=\"studio\"\n          title={intl.formatMessage({ id: \"studios\" })}\n          result={studio}\n          onChange={(value) => setStudio(value)}\n          newStudio={newStudio}\n          onCreateNew={createNewStudio}\n        />\n        <ScrapedStringListRow\n          field=\"urls\"\n          title={intl.formatMessage({ id: \"urls\" })}\n          result={urls}\n          onChange={(value) => setURLs(value)}\n        />\n        {scrapedTagsRow}\n        <ScrapedImageRow\n          field=\"front_image\"\n          title=\"Front Image\"\n          className=\"group-image\"\n          result={frontImage}\n          onChange={(value) => setFrontImage(value)}\n        />\n        <ScrapedImageRow\n          field=\"back_image\"\n          title=\"Back Image\"\n          className=\"group-image\"\n          result={backImage}\n          onChange={(value) => setBackImage(value)}\n        />\n      </>\n    );\n  }\n\n  if (linkDialog) {\n    return linkDialog;\n  }\n\n  return (\n    <ScrapeDialog\n      title={intl.formatMessage(\n        { id: \"dialogs.scrape_entity_title\" },\n        { entity_type: intl.formatMessage({ id: \"group\" }) }\n      )}\n      onClose={(apply) => {\n        onClose(apply ? makeNewScrapedItem() : undefined);\n      }}\n    >\n      {renderScrapeRows()}\n    </ScrapeDialog>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredGroupList } from \"../GroupList\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  ContainingGroupsCriterionOption,\n  GroupsCriterion,\n} from \"src/models/list-filter/criteria/groups\";\nimport {\n  useRemoveSubGroups,\n  useReorderSubGroupsMutation,\n} from \"src/core/StashService\";\nimport { IItemListOperation } from \"src/components/List/FilteredListToolbar\";\nimport {\n  showWhenNoneSelected,\n  showWhenSelected,\n} from \"src/components/List/ItemList\";\nimport { faMinus, faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport { useIntl } from \"react-intl\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useModal } from \"src/hooks/modal\";\nimport { AddSubGroupsDialog } from \"./AddGroupsDialog\";\nimport { PatchComponent } from \"src/patch\";\nimport { View } from \"src/components/List/views\";\n\nconst useContainingGroupFilterHook = (\n  group: Pick<GQL.StudioDataFragment, \"id\" | \"name\">,\n  showSubGroupContent?: boolean\n) => {\n  return (filter: ListFilterModel) => {\n    const groupValue = { id: group.id, label: group.name };\n    // if studio is already present, then we modify it, otherwise add\n    let groupCriterion = filter.criteria.find((c) => {\n      return c.criterionOption.type === \"containing_groups\";\n    }) as GroupsCriterion | undefined;\n\n    if (groupCriterion) {\n      // add the group if not present\n      if (\n        !groupCriterion.value.items.find((p) => {\n          return p.id === group.id;\n        })\n      ) {\n        groupCriterion.value.items.push(groupValue);\n      }\n    } else {\n      groupCriterion = new GroupsCriterion(ContainingGroupsCriterionOption);\n      groupCriterion.value = {\n        items: [groupValue],\n        excluded: [],\n        depth: showSubGroupContent ? -1 : 0,\n      };\n      groupCriterion.modifier = GQL.CriterionModifier.Includes;\n      filter.criteria.push(groupCriterion);\n    }\n\n    filter.sortBy = \"sub_group_order\";\n    filter.sortDirection = GQL.SortDirectionEnum.Asc;\n\n    return filter;\n  };\n};\n\ninterface IGroupSubGroupsPanel {\n  active: boolean;\n  group: GQL.GroupDataFragment;\n  extraOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];\n}\n\nconst defaultFilter = (() => {\n  const sortBy = \"sub_group_order\";\n  const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, {\n    defaultSortBy: sortBy,\n  });\n\n  // unset the sort by so that its not included in the URL\n  ret.sortBy = undefined;\n\n  return ret;\n})();\n\nexport const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> =\n  PatchComponent(\n    \"GroupSubGroupsPanel\",\n    ({ active, group, extraOperations = [] }) => {\n      const intl = useIntl();\n      const Toast = useToast();\n      const { modal, showModal, closeModal } = useModal();\n\n      const [reorderSubGroups] = useReorderSubGroupsMutation();\n      const mutateRemoveSubGroups = useRemoveSubGroups();\n\n      const filterHook = useContainingGroupFilterHook(group);\n\n      async function removeSubGroups(\n        result: GQL.FindGroupsQueryResult,\n        filter: ListFilterModel,\n        selectedIds: Set<string>\n      ) {\n        try {\n          await mutateRemoveSubGroups(\n            group.id,\n            Array.from(selectedIds.values())\n          );\n\n          Toast.success(\n            intl.formatMessage(\n              { id: \"toast.removed_entity\" },\n              {\n                count: selectedIds.size,\n                singularEntity: intl.formatMessage({ id: \"group\" }),\n                pluralEntity: intl.formatMessage({ id: \"groups\" }),\n              }\n            )\n          );\n        } catch (e) {\n          Toast.error(e);\n        }\n      }\n\n      async function onAddSubGroups() {\n        showModal(\n          <AddSubGroupsDialog containingGroup={group} onClose={closeModal} />\n        );\n      }\n\n      const otherOperations = [\n        ...extraOperations,\n        {\n          text: intl.formatMessage({ id: \"actions.add_sub_groups\" }),\n          onClick: onAddSubGroups,\n          isDisplayed: showWhenNoneSelected,\n          postRefetch: true,\n          icon: faPlus,\n          buttonVariant: \"secondary\",\n        },\n        {\n          text: intl.formatMessage({\n            id: \"actions.remove_from_containing_group\",\n          }),\n          onClick: removeSubGroups,\n          isDisplayed: showWhenSelected,\n          postRefetch: true,\n          icon: faMinus,\n          buttonVariant: \"danger\",\n        },\n      ];\n\n      function onMove(srcIds: string[], targetId: string, after: boolean) {\n        reorderSubGroups({\n          variables: {\n            input: {\n              group_id: group.id,\n              sub_group_ids: srcIds,\n              insert_at_id: targetId,\n              insert_after: after,\n            },\n          },\n        });\n      }\n\n      return (\n        <>\n          {modal}\n          <FilteredGroupList\n            defaultFilter={defaultFilter}\n            filterHook={filterHook}\n            alterQuery={active}\n            fromGroupId={group.id}\n            otherOperations={otherOperations}\n            onMove={onMove}\n            view={View.GroupSubGroups}\n          />\n        </>\n      );\n    }\n  );\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Form, Row, Col } from \"react-bootstrap\";\nimport { Group, GroupSelect } from \"src/components/Groups/GroupSelect\";\nimport cx from \"classnames\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\n\nexport type GroupSceneIndexMap = Map<string, number | undefined>;\n\nexport interface IRelatedGroupEntry {\n  group: Group;\n  description?: GQL.InputMaybe<string> | undefined;\n}\n\nexport const RelatedGroupTable: React.FC<{\n  value: IRelatedGroupEntry[];\n  onUpdate: (input: IRelatedGroupEntry[]) => void;\n  excludeIDs?: string[];\n  filterHook?: (f: ListFilterModel) => ListFilterModel;\n  disabled?: boolean;\n  menuPortalTarget?: HTMLElement | null;\n}> = (props) => {\n  const { value, onUpdate } = props;\n\n  const groupIDs = useMemo(() => value.map((m) => m.group.id), [value]);\n\n  const excludeIDs = useMemo(\n    () => [...groupIDs, ...(props.excludeIDs ?? [])],\n    [props.excludeIDs, groupIDs]\n  );\n\n  const updateFieldChanged = (index: number, description: string | null) => {\n    const newValues = value.map((existing, i) => {\n      if (i === index) {\n        return {\n          ...existing,\n          description,\n        };\n      }\n      return existing;\n    });\n\n    onUpdate(newValues);\n  };\n\n  function onGroupSet(index: number, groups: Group[]) {\n    if (!groups.length) {\n      // remove this entry\n      const newValues = value.filter((_, i) => i !== index);\n      onUpdate(newValues);\n      return;\n    }\n\n    const group = groups[0];\n\n    const newValues = value.map((existing, i) => {\n      if (i === index) {\n        return {\n          ...existing,\n          group: group,\n        };\n      }\n      return existing;\n    });\n\n    onUpdate(newValues);\n  }\n\n  function onNewGroupSet(groups: Group[]) {\n    if (!groups.length) {\n      return;\n    }\n\n    const group = groups[0];\n\n    const newValues = [\n      ...value,\n      {\n        group: group,\n        scene_index: null,\n      },\n    ];\n\n    onUpdate(newValues);\n  }\n\n  return (\n    <div className={cx(\"group-table\", { \"no-groups\": !value.length })}>\n      <Row className=\"group-table-header\">\n        <Col xs={9}></Col>\n        <Form.Label column xs={3} className=\"group-scene-number-header\">\n          <FormattedMessage id=\"description\" />\n        </Form.Label>\n      </Row>\n      {value.map((m, i) => (\n        <Row key={m.group.id} className=\"group-row\">\n          <Col xs={9}>\n            <GroupSelect\n              onSelect={(items) => onGroupSet(i, items)}\n              values={[m.group!]}\n              excludeIds={excludeIDs}\n              filterHook={props.filterHook}\n              isDisabled={props.disabled}\n              menuPortalTarget={props.menuPortalTarget}\n            />\n          </Col>\n          <Col xs={3}>\n            <Form.Control\n              className=\"text-input\"\n              value={m.description ?? \"\"}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n                updateFieldChanged(\n                  i,\n                  e.currentTarget.value === \"\" ? null : e.currentTarget.value\n                );\n              }}\n              disabled={props.disabled}\n            />\n          </Col>\n        </Row>\n      ))}\n      <Row className=\"group-row\">\n        <Col xs={12}>\n          <GroupSelect\n            // re-create this component to refresh the default values updating the excluded ids\n            // setting the key to the length of the groupIDs array will cause the component to re-render\n            key={groupIDs.length}\n            onSelect={(items) => onNewGroupSet(items)}\n            values={[]}\n            excludeIds={excludeIDs}\n            filterHook={props.filterHook}\n            isDisabled={props.disabled}\n            menuPortalTarget={props.menuPortalTarget}\n          />\n        </Col>\n      </Row>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupList.tsx",
    "content": "import React, { useCallback, useEffect } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport cloneDeep from \"lodash-es/cloneDeep\";\nimport Mousetrap from \"mousetrap\";\nimport { useHistory } from \"react-router-dom\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindGroups,\n  useFindGroups,\n  useGroupsDestroy,\n} from \"src/core/StashService\";\nimport { useFilteredItemList } from \"../List/ItemList\";\nimport { ExportDialog } from \"../Shared/ExportDialog\";\nimport { DeleteEntityDialog } from \"../Shared/DeleteEntityDialog\";\nimport { GroupCardGrid } from \"./GroupCardGrid\";\nimport { EditGroupsDialog } from \"./EditGroupsDialog\";\nimport { View } from \"../List/views\";\nimport {\n  FilteredListToolbar,\n  IItemListOperation,\n} from \"../List/FilteredListToolbar\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport useFocus from \"src/utils/focus\";\nimport {\n  Sidebar,\n  SidebarPane,\n  SidebarPaneContent,\n  SidebarStateContext,\n  useSidebarState,\n} from \"../Shared/Sidebar\";\nimport { useCloseEditDelete, useFilterOperations } from \"../List/util\";\nimport {\n  FilteredSidebarHeader,\n  useFilteredSidebarKeybinds,\n} from \"../List/Filters/FilterSidebar\";\nimport {\n  IListFilterOperation,\n  ListOperations,\n} from \"../List/ListOperationButtons\";\nimport cx from \"classnames\";\nimport { FilterTags } from \"../List/FilterTags\";\nimport { Pagination, PaginationIndex } from \"../List/Pagination\";\nimport { LoadedContent } from \"../List/PagedList\";\nimport { SidebarStudiosFilter } from \"../List/Filters/StudiosFilter\";\nimport { SidebarTagsFilter } from \"../List/Filters/TagsFilter\";\nimport { SidebarRatingFilter } from \"../List/Filters/RatingFilter\";\nimport { Button } from \"react-bootstrap\";\n\nconst GroupList: React.FC<{\n  groups: GQL.ListGroupDataFragment[];\n  filter: ListFilterModel;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  fromGroupId?: string;\n  onMove?: (srcIds: string[], targetId: string, after: boolean) => void;\n}> = PatchComponent(\n  \"GroupList\",\n  ({ groups, filter, selectedIds, onSelectChange, fromGroupId, onMove }) => {\n    if (groups.length === 0) {\n      return null;\n    }\n\n    if (filter.displayMode === DisplayMode.Grid) {\n      return (\n        <GroupCardGrid\n          groups={groups ?? []}\n          zoomIndex={filter.zoomIndex}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n          fromGroupId={fromGroupId}\n          onMove={onMove}\n        />\n      );\n    }\n\n    return null;\n  }\n);\n\nconst GroupFilterSidebarSections = PatchContainerComponent(\n  \"FilteredGroupList.SidebarSections\"\n);\n\nconst SidebarContent: React.FC<{\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  sidebarOpen: boolean;\n  onClose?: () => void;\n  showEditFilter: (editingCriterion?: string) => void;\n  count?: number;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  filter,\n  setFilter,\n  filterHook,\n  view,\n  showEditFilter,\n  sidebarOpen,\n  onClose,\n  count,\n  focus,\n}) => {\n  const showResultsId =\n    count !== undefined ? \"actions.show_count_results\" : \"actions.show_results\";\n\n  const hideStudios = view === View.StudioScenes;\n\n  return (\n    <>\n      <FilteredSidebarHeader\n        sidebarOpen={sidebarOpen}\n        showEditFilter={showEditFilter}\n        filter={filter}\n        setFilter={setFilter}\n        view={view}\n        focus={focus}\n      />\n\n      <GroupFilterSidebarSections>\n        {!hideStudios && (\n          <SidebarStudiosFilter\n            filter={filter}\n            setFilter={setFilter}\n            filterHook={filterHook}\n          />\n        )}\n        <SidebarTagsFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarRatingFilter filter={filter} setFilter={setFilter} />\n      </GroupFilterSidebarSections>\n\n      <div className=\"sidebar-footer\">\n        <Button className=\"sidebar-close-button\" onClick={onClose}>\n          <FormattedMessage id={showResultsId} values={{ count }} />\n        </Button>\n      </div>\n    </>\n  );\n};\n\ninterface IGroupListContext {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  defaultFilter?: ListFilterModel;\n  view?: View;\n  alterQuery?: boolean;\n}\n\ninterface IGroupList extends IGroupListContext {\n  fromGroupId?: string;\n  onMove?: (srcIds: string[], targetId: string, after: boolean) => void;\n  otherOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];\n}\n\nfunction useViewRandom(filter: ListFilterModel, count: number) {\n  const history = useHistory();\n\n  const viewRandom = useCallback(async () => {\n    // query for a random scene\n    if (count === 0) {\n      return;\n    }\n\n    const index = Math.floor(Math.random() * count);\n    const filterCopy = cloneDeep(filter);\n    filterCopy.itemsPerPage = 1;\n    filterCopy.currentPage = index + 1;\n    const singleResult = await queryFindGroups(filterCopy);\n    if (singleResult.data.findGroups.groups.length === 1) {\n      const { id } = singleResult.data.findGroups.groups[0];\n      // navigate to the image player page\n      history.push(`/groups/${id}`);\n    }\n  }, [history, filter, count]);\n\n  return viewRandom;\n}\n\nfunction useAddKeybinds(filter: ListFilterModel, count: number) {\n  const viewRandom = useViewRandom(filter, count);\n\n  useEffect(() => {\n    Mousetrap.bind(\"p r\", () => {\n      viewRandom();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"p r\");\n    };\n  }, [viewRandom]);\n}\n\nexport const FilteredGroupList = PatchComponent(\n  \"FilteredGroupList\",\n  (props: IGroupList) => {\n    const intl = useIntl();\n\n    const searchFocus = useFocus();\n\n    const {\n      filterHook,\n      view,\n      alterQuery,\n      onMove,\n      fromGroupId,\n      otherOperations: providedOperations = [],\n      defaultFilter,\n    } = props;\n\n    const withSidebar = view !== View.GroupSubGroups;\n    const filterable = view !== View.GroupSubGroups;\n    const sortable = view !== View.GroupSubGroups;\n\n    // States\n    const {\n      showSidebar,\n      setShowSidebar,\n      sectionOpen,\n      setSectionOpen,\n      loading: sidebarStateLoading,\n    } = useSidebarState(view);\n\n    const { filterState, queryResult, modalState, listSelect, showEditFilter } =\n      useFilteredItemList({\n        filterStateProps: {\n          filterMode: GQL.FilterMode.Groups,\n          defaultFilter,\n          view,\n          useURL: alterQuery,\n        },\n        queryResultProps: {\n          useResult: useFindGroups,\n          getCount: (r) => r.data?.findGroups.count ?? 0,\n          getItems: (r) => r.data?.findGroups.groups ?? [],\n          filterHook,\n        },\n      });\n\n    const { filter, setFilter } = filterState;\n\n    const { effectiveFilter, result, cachedResult, items, totalCount } =\n      queryResult;\n\n    const {\n      selectedIds,\n      selectedItems,\n      onSelectChange,\n      onSelectAll,\n      onSelectNone,\n      onInvertSelection,\n      hasSelection,\n    } = listSelect;\n\n    const { modal, showModal, closeModal } = modalState;\n\n    // Utility hooks\n    const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({\n      filter,\n      setFilter,\n    });\n\n    useAddKeybinds(effectiveFilter, totalCount);\n    useFilteredSidebarKeybinds({\n      showSidebar,\n      setShowSidebar,\n    });\n\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => {\n        if (hasSelection) {\n          onEdit?.();\n        }\n      });\n\n      Mousetrap.bind(\"d d\", () => {\n        if (hasSelection) {\n          onDelete?.();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"d d\");\n      };\n    });\n\n    const onCloseEditDelete = useCloseEditDelete({\n      closeModal,\n      onSelectNone,\n      result,\n    });\n\n    const viewRandom = useViewRandom(effectiveFilter, totalCount);\n\n    function onExport(all: boolean) {\n      showModal(\n        <ExportDialog\n          exportInput={{\n            groups: {\n              ids: Array.from(selectedIds.values()),\n              all: all,\n            },\n          }}\n          onClose={() => closeModal()}\n        />\n      );\n    }\n\n    function onEdit() {\n      showModal(\n        <EditGroupsDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }\n\n    function onDelete() {\n      showModal(\n        <DeleteEntityDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n          singularEntity={intl.formatMessage({ id: \"group\" })}\n          pluralEntity={intl.formatMessage({ id: \"groups\" })}\n          destroyMutation={useGroupsDestroy}\n        />\n      );\n    }\n\n    const convertedExtraOperations: IListFilterOperation[] =\n      providedOperations.map((o) => ({\n        ...o,\n        isDisplayed: o.isDisplayed\n          ? () => o.isDisplayed!(result, filter, selectedIds)\n          : undefined,\n        onClick: () => {\n          o.onClick(result, filter, selectedIds);\n        },\n      }));\n\n    const otherOperations = [\n      ...convertedExtraOperations,\n      {\n        text: intl.formatMessage({ id: \"actions.select_all\" }),\n        onClick: () => onSelectAll(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_none\" }),\n        onClick: () => onSelectNone(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.invert_selection\" }),\n        onClick: () => onInvertSelection(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.view_random\" }),\n        onClick: viewRandom,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export\" }),\n        onClick: () => onExport(false),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export_all\" }),\n        onClick: () => onExport(true),\n      },\n    ];\n\n    // render\n    if (sidebarStateLoading) return null;\n\n    const operations = (\n      <ListOperations\n        items={items.length}\n        hasSelection={hasSelection}\n        operations={otherOperations}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        operationsMenuClassName=\"group-list-operations-dropdown\"\n      />\n    );\n\n    const content = (\n      <>\n        <FilteredListToolbar\n          filter={filter}\n          listSelect={listSelect}\n          setFilter={setFilter}\n          showEditFilter={showEditFilter}\n          onDelete={onDelete}\n          onEdit={onEdit}\n          operationComponent={operations}\n          view={view}\n          zoomable\n          filterable={filterable}\n          sortable={sortable}\n        />\n\n        <FilterTags\n          criteria={filter.criteria}\n          onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}\n          onRemoveCriterion={removeCriterion}\n          onRemoveAll={clearAllCriteria}\n        />\n\n        <div className=\"pagination-index-container\">\n          <Pagination\n            currentPage={filter.currentPage}\n            itemsPerPage={filter.itemsPerPage}\n            totalItems={totalCount}\n            onChangePage={(page) => setFilter(filter.changePage(page))}\n          />\n          <PaginationIndex\n            loading={cachedResult.loading}\n            itemsPerPage={filter.itemsPerPage}\n            currentPage={filter.currentPage}\n            totalItems={totalCount}\n          />\n        </div>\n\n        <LoadedContent loading={result.loading} error={result.error}>\n          <GroupList\n            filter={effectiveFilter}\n            groups={items}\n            selectedIds={selectedIds}\n            onSelectChange={onSelectChange}\n            fromGroupId={fromGroupId}\n            onMove={onMove}\n          />\n        </LoadedContent>\n\n        {totalCount > filter.itemsPerPage && (\n          <div className=\"pagination-footer-container\">\n            <div className=\"pagination-footer\">\n              <Pagination\n                itemsPerPage={filter.itemsPerPage}\n                currentPage={filter.currentPage}\n                totalItems={totalCount}\n                onChangePage={setPage}\n                pagePopupPlacement=\"top\"\n              />\n            </div>\n          </div>\n        )}\n      </>\n    );\n\n    if (!withSidebar) {\n      return content;\n    }\n\n    return (\n      <div\n        className={cx(\"item-list-container group-list\", {\n          \"hide-sidebar\": !showSidebar,\n        })}\n      >\n        {modal}\n\n        <SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>\n          <SidebarPane hideSidebar={!showSidebar}>\n            <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>\n              <SidebarContent\n                filter={filter}\n                setFilter={setFilter}\n                filterHook={filterHook}\n                showEditFilter={showEditFilter}\n                view={view}\n                sidebarOpen={showSidebar}\n                onClose={() => setShowSidebar(false)}\n                count={cachedResult.loading ? undefined : totalCount}\n                focus={searchFocus}\n              />\n            </Sidebar>\n            <SidebarPaneContent\n              onSidebarToggle={() => setShowSidebar(!showSidebar)}\n            >\n              {content}\n            </SidebarPaneContent>\n          </SidebarPane>\n        </SidebarStateContext.Provider>\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx",
    "content": "import React from \"react\";\nimport { useFindGroups } from \"src/core/StashService\";\nimport { GroupCard } from \"./GroupCard\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { PatchComponent } from \"src/patch\";\nimport { FilteredRecommendationRow } from \"../FrontPage/FilteredRecommendationRow\";\n\ninterface IProps {\n  isTouch: boolean;\n  filter: ListFilterModel;\n  header: string;\n}\n\nexport const GroupRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"GroupRecommendationRow\",\n  (props: IProps) => {\n    const result = useFindGroups(props.filter);\n    const count = result.data?.findGroups.count ?? 0;\n\n    return (\n      <FilteredRecommendationRow\n        className=\"group-recommendations\"\n        heading={props.header}\n        url={`/groups?${props.filter.makeQueryParameters()}`}\n        count={count}\n        loading={result.loading}\n        isTouch={props.isTouch}\n        filter={props.filter}\n      >\n        {result.loading\n          ? [...Array(props.filter.itemsPerPage)].map((i) => (\n              <div key={`_${i}`} className=\"group-skeleton skeleton-card\"></div>\n            ))\n          : result.data?.findGroups.groups.map((g) => (\n              <GroupCard key={g.id} group={g} />\n            ))}\n      </FilteredRecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupSelect.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport {\n  OptionProps,\n  components as reactSelectComponents,\n  MultiValueGenericProps,\n  SingleValueProps,\n} from \"react-select\";\nimport cx from \"classnames\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindGroupsForSelect,\n  queryFindGroupsByIDForSelect,\n  useGroupCreate,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useIntl } from \"react-intl\";\nimport { defaultMaxOptionsShown } from \"src/core/config\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  FilterSelectComponent,\n  IFilterIDProps,\n  IFilterProps,\n  IFilterValueProps,\n  Option as SelectOption,\n} from \"../Shared/FilterSelect\";\nimport { useCompare } from \"src/hooks/state\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { PatchComponent, PatchFunction } from \"src/patch\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\n\nexport type Group = Pick<\n  GQL.Group,\n  \"id\" | \"name\" | \"date\" | \"front_image_path\" | \"aliases\"\n> & {\n  studio?: Pick<GQL.Studio, \"name\"> | null;\n};\ntype Option = SelectOption<Group>;\n\ntype FindGroupsResult = Awaited<\n  ReturnType<typeof queryFindGroupsForSelect>\n>[\"data\"][\"findGroups\"][\"groups\"];\n\nfunction sortGroupsByRelevance(input: string, groups: FindGroupsResult) {\n  return sortByRelevance(\n    input,\n    groups,\n    (m) => m.name,\n    (m) => (m.aliases ? [m.aliases] : [])\n  );\n}\n\nconst groupSelectSort = PatchFunction(\n  \"GroupSelect.sort\",\n  sortGroupsByRelevance\n);\n\nexport const GroupSelect: React.FC<\n  IFilterProps &\n    IFilterValueProps<Group> & {\n      hoverPlacement?: Placement;\n      excludeIds?: string[];\n      filterHook?: (f: ListFilterModel) => ListFilterModel;\n    }\n> = PatchComponent(\"GroupSelect\", (props) => {\n  const [createGroup] = useGroupCreate();\n\n  const { configuration } = useConfigurationContext();\n  const intl = useIntl();\n  const maxOptionsShown =\n    configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown;\n  const defaultCreatable =\n    !configuration?.interface.disableDropdownCreate.movie;\n\n  const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);\n\n  async function loadGroups(input: string): Promise<Option[]> {\n    let filter = new ListFilterModel(GQL.FilterMode.Groups);\n    filter.searchTerm = input;\n    filter.currentPage = 1;\n    filter.itemsPerPage = maxOptionsShown;\n    filter.sortBy = \"name\";\n    filter.sortDirection = GQL.SortDirectionEnum.Asc;\n\n    if (props.filterHook) {\n      filter = props.filterHook(filter);\n    }\n\n    const query = await queryFindGroupsForSelect(filter);\n    let ret = query.data.findGroups.groups.filter((group) => {\n      // HACK - we should probably exclude these in the backend query, but\n      // this will do in the short-term\n      return !exclude.includes(group.id.toString());\n    });\n\n    return groupSelectSort(input, ret).map((group) => ({\n      value: group.id,\n      object: group,\n    }));\n  }\n\n  const GroupOption: React.FC<OptionProps<Option, boolean>> = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    const title = object.name;\n\n    // if name does not match the input value but an alias does, show the alias\n    const { inputValue } = optionProps.selectProps;\n    let alias: string | undefined = \"\";\n    if (!title.toLowerCase().includes(inputValue.toLowerCase())) {\n      alias = object.aliases || undefined;\n    }\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <span className=\"group-select-option\">\n          <span className=\"group-select-row\">\n            {object.front_image_path && (\n              <img\n                className=\"group-select-image\"\n                src={object.front_image_path}\n                loading=\"lazy\"\n              />\n            )}\n\n            <span className=\"group-select-details\">\n              <TruncatedText\n                className=\"group-select-title\"\n                text={\n                  <span>\n                    {title}\n                    {alias && (\n                      <span className=\"group-select-alias\">{` (${alias})`}</span>\n                    )}\n                  </span>\n                }\n                lineCount={1}\n              />\n\n              {object.studio?.name && (\n                <span className=\"group-select-studio\">\n                  {object.studio?.name}\n                </span>\n              )}\n\n              {object.date && (\n                <span className=\"group-select-date\">{object.date}</span>\n              )}\n            </span>\n          </span>\n        </span>\n      ),\n    };\n\n    return <reactSelectComponents.Option {...thisOptionProps} />;\n  };\n\n  const GroupMultiValueLabel: React.FC<\n    MultiValueGenericProps<Option, boolean>\n  > = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: object.name,\n    };\n\n    return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;\n  };\n\n  const GroupValueLabel: React.FC<SingleValueProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: <>{object.name}</>,\n    };\n\n    return <reactSelectComponents.SingleValue {...thisOptionProps} />;\n  };\n\n  const onCreate = async (name: string) => {\n    const result = await createGroup({\n      variables: { input: { name } },\n    });\n    return {\n      value: result.data!.groupCreate!.id,\n      item: result.data!.groupCreate!,\n      message: \"Created group\",\n    };\n  };\n\n  const getNamedObject = (id: string, name: string) => {\n    return {\n      id,\n      name,\n    };\n  };\n\n  const isValidNewOption = (inputValue: string, options: Group[]) => {\n    if (!inputValue) {\n      return false;\n    }\n\n    if (\n      options.some((o) => {\n        return (\n          o.name.toLowerCase() === inputValue.toLowerCase() ||\n          o.aliases?.toLowerCase() === inputValue.toLowerCase()\n        );\n      })\n    ) {\n      return false;\n    }\n\n    return true;\n  };\n\n  return (\n    <FilterSelectComponent<Group, boolean>\n      {...props}\n      className={cx(\n        \"group-select\",\n        {\n          \"group-select-active\": props.active,\n        },\n        props.className\n      )}\n      loadOptions={loadGroups}\n      getNamedObject={getNamedObject}\n      isValidNewOption={isValidNewOption}\n      components={{\n        Option: GroupOption,\n        MultiValueLabel: GroupMultiValueLabel,\n        SingleValue: GroupValueLabel,\n      }}\n      isMulti={props.isMulti ?? false}\n      creatable={props.creatable ?? defaultCreatable}\n      onCreate={onCreate}\n      placeholder={\n        props.noSelectionString ??\n        intl.formatMessage(\n          { id: \"actions.select_entity\" },\n          {\n            entityType: intl.formatMessage({\n              id: props.isMulti ? \"groups\" : \"group\",\n            }),\n          }\n        )\n      }\n      closeMenuOnSelect={!props.isMulti}\n    />\n  );\n});\n\nconst _GroupIDSelect: React.FC<IFilterProps & IFilterIDProps<Group>> = (\n  props\n) => {\n  const { ids, onSelect: onSelectValues } = props;\n\n  const [values, setValues] = useState<Group[]>([]);\n  const idsChanged = useCompare(ids);\n\n  function onSelect(items: Group[]) {\n    setValues(items);\n    onSelectValues?.(items);\n  }\n\n  async function loadObjectsByID(idsToLoad: string[]): Promise<Group[]> {\n    const query = await queryFindGroupsByIDForSelect(idsToLoad);\n    const { groups: loadedGroups } = query.data.findGroups;\n\n    return loadedGroups;\n  }\n\n  useEffect(() => {\n    if (!idsChanged) {\n      return;\n    }\n\n    if (!ids || ids?.length === 0) {\n      setValues([]);\n      return;\n    }\n\n    // load the values if we have ids and they haven't been loaded yet\n    const filteredValues = values.filter((v) => ids.includes(v.id.toString()));\n    if (filteredValues.length === ids.length) {\n      return;\n    }\n\n    const load = async () => {\n      const items = await loadObjectsByID(ids);\n      setValues(items);\n    };\n\n    load();\n  }, [ids, idsChanged, values]);\n\n  return <GroupSelect {...props} values={values} onSelect={onSelect} />;\n};\n\nexport const GroupIDSelect = PatchComponent(\"GroupIDSelect\", _GroupIDSelect);\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/GroupTag.tsx",
    "content": "import React from \"react\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { GroupLink } from \"../Shared/TagLink\";\n\nexport const GroupTag: React.FC<{\n  group: Pick<GQL.GroupDataFragment, \"id\" | \"name\" | \"front_image_path\">;\n  linkType?: \"scene\" | \"sub_group\" | \"details\";\n  description?: string;\n}> = ({ group, linkType, description }) => {\n  return (\n    <div className=\"group-tag-container\">\n      <Link to={`/groups/${group.id}`} className=\"group-tag col m-auto zoom-2\">\n        <img\n          className=\"image-thumbnail\"\n          alt={group.name ?? \"\"}\n          src={group.front_image_path ?? \"\"}\n        />\n      </Link>\n      <GroupLink\n        group={group}\n        description={description}\n        linkType={linkType}\n        className=\"d-block\"\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/Groups.tsx",
    "content": "import React from \"react\";\nimport { Route, Switch } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport { useTitleProps } from \"src/hooks/title\";\nimport Group from \"./GroupDetails/Group\";\nimport GroupCreate from \"./GroupDetails/GroupCreate\";\nimport { FilteredGroupList } from \"./GroupList\";\nimport { View } from \"../List/views\";\n\nconst Groups: React.FC = () => {\n  return <FilteredGroupList view={View.Groups} />;\n};\n\nconst GroupRoutes: React.FC = () => {\n  const titleProps = useTitleProps({ id: \"groups\" });\n  return (\n    <>\n      <Helmet {...titleProps} />\n      <Switch>\n        <Route exact path=\"/groups\" component={Groups} />\n        <Route exact path=\"/groups/new\" component={GroupCreate} />\n        <Route path=\"/groups/:id/:tab?\" component={Group} />\n      </Switch>\n    </>\n  );\n};\n\nexport default GroupRoutes;\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx",
    "content": "import {\n  faFilm,\n  faArrowUpLong,\n  faArrowDownLong,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useMemo } from \"react\";\nimport { Button, OverlayTrigger, Tooltip } from \"react-bootstrap\";\nimport { Count } from \"../Shared/PopoverCountButton\";\nimport { Icon } from \"../Shared/Icon\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { Link } from \"react-router-dom\";\nimport NavUtils from \"src/utils/navigation\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useIntl } from \"react-intl\";\nimport { GroupTag } from \"./GroupTag\";\n\ninterface IProps {\n  group: Pick<\n    GQL.ListGroupDataFragment,\n    \"id\" | \"name\" | \"containing_groups\" | \"sub_group_count\"\n  >;\n}\n\nconst ContainingGroupsCount: React.FC<IProps> = ({ group }) => {\n  const { containing_groups: containingGroups } = group;\n\n  const popoverContent = useMemo(() => {\n    if (!containingGroups.length) {\n      return [];\n    }\n\n    return containingGroups.map((entry) => (\n      <GroupTag\n        key={entry.group.id}\n        linkType=\"sub_group\"\n        group={entry.group}\n        description={entry.description ?? undefined}\n      />\n    ));\n  }, [containingGroups]);\n\n  if (!containingGroups.length) {\n    return null;\n  }\n\n  return (\n    <HoverPopover\n      className=\"containing-group-count\"\n      placement=\"bottom\"\n      content={popoverContent}\n    >\n      <Link\n        to={NavUtils.makeContainingGroupsUrl(group)}\n        className=\"related-group-count\"\n      >\n        <Count count={containingGroups.length} />\n        <Icon icon={faArrowUpLong} transform=\"shrink-4\" />\n      </Link>\n    </HoverPopover>\n  );\n};\n\nconst SubGroupCount: React.FC<IProps> = ({ group }) => {\n  const intl = useIntl();\n\n  const count = group.sub_group_count;\n\n  if (!count) {\n    return null;\n  }\n\n  function getTitle() {\n    const pluralCategory = intl.formatPlural(count);\n    const options = {\n      one: \"sub_group\",\n      other: \"sub_groups\",\n    };\n    const plural = intl.formatMessage({\n      id: options[pluralCategory as \"one\"] || options.other,\n    });\n    return `${count} ${plural}`;\n  }\n\n  return (\n    <OverlayTrigger\n      overlay={<Tooltip id={`sub-group-count-tooltip`}>{getTitle()}</Tooltip>}\n      placement=\"bottom\"\n    >\n      <Link\n        to={NavUtils.makeSubGroupsUrl(group)}\n        className=\"related-group-count\"\n      >\n        <Count count={count} />\n        <Icon icon={faArrowDownLong} transform=\"shrink-4\" />\n      </Link>\n    </OverlayTrigger>\n  );\n};\n\nexport const RelatedGroupPopoverButton: React.FC<IProps> = ({ group }) => {\n  return (\n    <span className=\"related-group-popover-button\">\n      <Button className=\"minimal\">\n        <Icon icon={faFilm} />\n        <ContainingGroupsCount group={group} />\n        <SubGroupCount group={group} />\n      </Button>\n    </span>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Groups/styles.scss",
    "content": ".group-card {\n  width: 240px;\n\n  @media (max-width: 576px) {\n    width: 100%;\n  }\n\n  &.card {\n    padding: 0 0 1rem;\n  }\n\n  &-image {\n    object-fit: contain;\n    width: 100%;\n  }\n\n  .group-scene-number,\n  .group-containing-group-description {\n    text-align: center;\n  }\n\n  &__details {\n    margin-bottom: 1rem;\n  }\n}\n\n.group-images {\n  align-items: center;\n  display: flex;\n  flex-direction: row;\n  justify-content: space-evenly;\n  max-width: 100%;\n\n  .group-image-container {\n    box-shadow: none;\n  }\n\n  img {\n    max-width: 100%;\n    object-fit: contain;\n  }\n}\n\n#group-page {\n  .rating-number .text-input {\n    width: auto;\n  }\n\n  // the detail element ids are the same as field type name\n  // which don't follow the correct convention\n  /* stylelint-disable selector-class-pattern */\n  .collapsed {\n    .detail-item.tags,\n    .detail-item.containing_groups {\n      display: none;\n    }\n  }\n  /* stylelint-enable selector-class-pattern */\n}\n\n.group-select-option {\n  .group-select-row {\n    align-items: center;\n    display: flex;\n    width: 100%;\n\n    .group-select-image {\n      background-color: $body-bg;\n      margin-right: 0.4em;\n      max-height: 50px;\n      max-width: 89px;\n      object-fit: contain;\n      object-position: center;\n    }\n\n    .group-select-details {\n      display: flex;\n      flex-direction: column;\n      justify-content: flex-start;\n      max-height: 4.1rem;\n      overflow: hidden;\n\n      .group-select-title {\n        flex-shrink: 0;\n        white-space: pre-wrap;\n        word-break: break-all;\n\n        .group-select-alias {\n          font-size: 0.8rem;\n          font-weight: bold;\n        }\n      }\n\n      .group-select-date,\n      .group-select-studio {\n        color: $text-muted;\n        flex-shrink: 0;\n        font-size: 0.9rem;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n      }\n    }\n  }\n}\n\nbutton.flip {\n  display: none;\n}\n\n#group-page .detail-header:not(.collapsed) {\n  .group-images {\n    padding: 0.375rem 0.75rem;\n    position: relative;\n    z-index: 1;\n\n    button.btn-link {\n      padding: 0;\n      position: relative;\n      transition: all 0.3s;\n      z-index: 1;\n    }\n\n    button:has(.active) {\n      z-index: 2;\n    }\n\n    button:has(.inactive) {\n      opacity: 0.5;\n      padding: 0;\n      transform: rotateY(180deg);\n    }\n\n    button.flip {\n      align-items: center;\n      border-radius: 50%;\n      bottom: -5px;\n      display: flex;\n      font-size: 20px;\n      height: 40px;\n      justify-content: center;\n      padding: 0;\n      position: absolute;\n      right: -5px;\n      width: 40px;\n      z-index: 2;\n    }\n\n    img.active {\n      max-width: 22rem;\n    }\n\n    img.inactive {\n      display: none;\n    }\n  }\n\n  .detail-item .detail-item-title {\n    width: 150px;\n  }\n}\n\n.groups-list {\n  list-style-type: none;\n  padding-inline-start: 0;\n\n  li {\n    display: inline;\n  }\n}\n\n.related-group-popover-button {\n  .containing-group-count {\n    display: inline-block;\n  }\n\n  .related-group-count .fa-icon {\n    color: $text-muted;\n    margin-left: 0;\n    margin-right: 0.25rem;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Help/Manual.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { Modal, Container, Row, Col, Nav, Tab } from \"react-bootstrap\";\nimport Introduction from \"src/docs/en/Manual/Introduction.md\";\nimport Tasks from \"src/docs/en/Manual/Tasks.md\";\nimport AutoTagging from \"src/docs/en/Manual/AutoTagging.md\";\nimport JSONSpec from \"src/docs/en/Manual/JSONSpec.md\";\nimport Configuration from \"src/docs/en/Manual/Configuration.md\";\nimport Interface from \"src/docs/en/Manual/Interface.md\";\nimport Images from \"src/docs/en/Manual/Images.md\";\nimport Scraping from \"src/docs/en/Manual/Scraping.md\";\nimport ScraperDevelopment from \"src/docs/en/Manual/ScraperDevelopment.md\";\nimport Plugins from \"src/docs/en/Manual/Plugins.md\";\nimport ExternalPlugins from \"src/docs/en/Manual/ExternalPlugins.md\";\nimport EmbeddedPlugins from \"src/docs/en/Manual/EmbeddedPlugins.md\";\nimport UIPluginApi from \"src/docs/en/Manual/UIPluginApi.md\";\nimport Tagger from \"src/docs/en/Manual/Tagger.md\";\nimport Contributing from \"src/docs/en/Manual/Contributing.md\";\nimport SceneFilenameParser from \"src/docs/en/Manual/SceneFilenameParser.md\";\nimport KeyboardShortcuts from \"src/docs/en/Manual/KeyboardShortcuts.md\";\nimport Help from \"src/docs/en/Manual/Help.md\";\nimport Deduplication from \"src/docs/en/Manual/Deduplication.md\";\nimport Interactive from \"src/docs/en/Manual/Interactive.md\";\nimport Captions from \"src/docs/en/Manual/Captions.md\";\nimport Identify from \"src/docs/en/Manual/Identify.md\";\nimport Browsing from \"src/docs/en/Manual/Browsing.md\";\nimport TroubleshootingMode from \"src/docs/en/Manual/TroubleshootingMode.md\";\nimport { MarkdownPage } from \"../Shared/MarkdownPage\";\n\ninterface IManualProps {\n  animation?: boolean;\n  show: boolean;\n  onClose: () => void;\n  defaultActiveTab?: string;\n}\n\nexport const Manual: React.FC<IManualProps> = ({\n  animation,\n  show,\n  onClose,\n  defaultActiveTab,\n}) => {\n  const content = [\n    {\n      key: \"Introduction.md\",\n      title: \"Introduction\",\n      content: Introduction,\n    },\n    {\n      key: \"Configuration.md\",\n      title: \"Configuration\",\n      content: Configuration,\n    },\n    {\n      key: \"Interface.md\",\n      title: \"Interface Options\",\n      content: Interface,\n    },\n    {\n      key: \"Tasks.md\",\n      title: \"Tasks\",\n      content: Tasks,\n    },\n    {\n      key: \"Identify.md\",\n      title: \"Identify\",\n      content: Identify,\n      className: \"indent-1\",\n    },\n    {\n      key: \"AutoTagging.md\",\n      title: \"Auto Tagging\",\n      content: AutoTagging,\n      className: \"indent-1\",\n    },\n    {\n      key: \"SceneFilenameParser.md\",\n      title: \"Scene Filename Parser\",\n      content: SceneFilenameParser,\n      className: \"indent-1\",\n    },\n    {\n      key: \"JSONSpec.md\",\n      title: \"JSON Specification\",\n      content: JSONSpec,\n      className: \"indent-1\",\n    },\n    {\n      key: \"Browsing.md\",\n      title: \"Browsing\",\n      content: Browsing,\n    },\n    {\n      key: \"Images.md\",\n      title: \"Images and Galleries\",\n      content: Images,\n    },\n    {\n      key: \"Scraping.md\",\n      title: \"Metadata Scraping\",\n      content: Scraping,\n    },\n    {\n      key: \"ScraperDevelopment.md\",\n      title: \"Scraper Development\",\n      content: ScraperDevelopment,\n      className: \"indent-1\",\n    },\n    {\n      key: \"Plugins.md\",\n      title: \"Plugins\",\n      content: Plugins,\n    },\n    {\n      key: \"ExternalPlugins.md\",\n      title: \"External\",\n      content: ExternalPlugins,\n      className: \"indent-1\",\n    },\n    {\n      key: \"EmbeddedPlugins.md\",\n      title: \"Embedded\",\n      content: EmbeddedPlugins,\n      className: \"indent-1\",\n    },\n    {\n      key: \"UIPluginApi.md\",\n      title: \"UI Plugin API\",\n      content: UIPluginApi,\n      className: \"indent-1\",\n    },\n    {\n      key: \"Tagger.md\",\n      title: \"Scene Tagger\",\n      content: Tagger,\n    },\n    {\n      key: \"Deduplication.md\",\n      title: \"Dupe Checker\",\n      content: Deduplication,\n    },\n    {\n      key: \"Interactive.md\",\n      title: \"Interactivity\",\n      content: Interactive,\n    },\n    {\n      key: \"Captions.md\",\n      title: \"Captions\",\n      content: Captions,\n    },\n    {\n      key: \"KeyboardShortcuts.md\",\n      title: \"Keyboard Shortcuts\",\n      content: KeyboardShortcuts,\n    },\n    {\n      key: \"TroubleshootingMode.md\",\n      title: \"Troubleshooting Mode\",\n      content: TroubleshootingMode,\n    },\n    {\n      key: \"Contributing.md\",\n      title: \"Contributing\",\n      content: Contributing,\n    },\n    {\n      key: \"Help.md\",\n      title: \"Further Help\",\n      content: Help,\n    },\n  ];\n\n  const [activeTab, setActiveTab] = useState<string>();\n\n  useEffect(() => {\n    setActiveTab(defaultActiveTab);\n  }, [defaultActiveTab]);\n\n  // links to other manual pages are specified as \"/help/page.md\"\n  // intercept clicks to these pages and set the tab accordingly\n  function interceptLinkClick(\n    event: React.MouseEvent<HTMLDivElement, MouseEvent>\n  ) {\n    if (event.target instanceof HTMLAnchorElement) {\n      const href = event.target.getAttribute(\"href\");\n      if (href && href.startsWith(\"/help\")) {\n        const newKey = event.target.pathname.substring(\"/help/\".length);\n        setActiveTab(newKey);\n        event.preventDefault();\n      }\n    }\n  }\n\n  return (\n    <Modal\n      animation={animation}\n      show={show}\n      onHide={onClose}\n      dialogClassName=\"modal-dialog-scrollable manual modal-xl\"\n    >\n      <Modal.Header closeButton>\n        <Modal.Title>Help</Modal.Title>\n      </Modal.Header>\n      <Modal.Body>\n        <Container className=\"manual-container\">\n          <Tab.Container\n            activeKey={activeTab ?? content[0].key}\n            onSelect={(k) => k && setActiveTab(k)}\n            id=\"manual-tabs\"\n          >\n            <Row>\n              <Col lg={3} className=\"mb-3 mb-lg-0 manual-toc\">\n                <Nav variant=\"pills\" className=\"flex-column\">\n                  {content.map((c) => {\n                    return (\n                      <Nav.Item key={`${c.key}-nav`}>\n                        <Nav.Link className={c.className} eventKey={c.key}>\n                          {c.title}\n                        </Nav.Link>\n                      </Nav.Item>\n                    );\n                  })}\n                  <hr className=\"d-sm-none\" />\n                </Nav>\n              </Col>\n              <Col lg={9} className=\"manual-content\">\n                <Tab.Content>\n                  {content.map((c) => {\n                    return (\n                      <Tab.Pane\n                        eventKey={c.key}\n                        key={`${c.key}-pane`}\n                        onClick={interceptLinkClick}\n                      >\n                        <MarkdownPage page={c.content} />\n                      </Tab.Pane>\n                    );\n                  })}\n                </Tab.Content>\n              </Col>\n            </Row>\n          </Tab.Container>\n        </Container>\n      </Modal.Body>\n    </Modal>\n  );\n};\n\nexport default Manual;\n"
  },
  {
    "path": "ui/v2.5/src/components/Help/context.tsx",
    "content": "import React, { Suspense, useState } from \"react\";\nimport { Link } from \"react-router-dom\";\nimport { lazyComponent } from \"src/utils/lazyComponent\";\n\nconst Manual = lazyComponent(() => import(\"./Manual\"));\n\ninterface IManualContextState {\n  openManual: (tab?: string) => void;\n}\n\nexport const ManualStateContext = React.createContext<IManualContextState>({\n  openManual: () => {},\n});\n\nexport const ManualProvider: React.FC = ({ children }) => {\n  const [showManual, setShowManual] = useState(false);\n  const [manualLink, setManualLink] = useState<string | undefined>();\n\n  function openManual(tab?: string) {\n    setManualLink(tab);\n    setShowManual(true);\n  }\n\n  return (\n    <ManualStateContext.Provider\n      value={{\n        openManual,\n      }}\n    >\n      <Suspense fallback={<></>}>\n        {showManual && (\n          <Manual\n            show={showManual}\n            onClose={() => setShowManual(false)}\n            defaultActiveTab={manualLink}\n          />\n        )}\n      </Suspense>\n      {children}\n    </ManualStateContext.Provider>\n  );\n};\n\ninterface IManualLink {\n  tab: string;\n}\n\nexport const ManualLink: React.FC<IManualLink> = ({ tab, children }) => {\n  const { openManual } = React.useContext(ManualStateContext);\n\n  return (\n    <Link\n      to={`/help/${tab}.md`}\n      onClick={(e) => {\n        openManual(`${tab}.md`);\n        e.preventDefault();\n      }}\n    >\n      {children}\n    </Link>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Help/styles.scss",
    "content": ".manual {\n  color: $text-color;\n\n  .close {\n    color: $text-color;\n  }\n\n  &-container {\n    padding-left: 1px;\n    padding-right: 5px;\n  }\n\n  &-header,\n  &-body {\n    background-color: $card-bg;\n    color: $text-color;\n    overflow-y: hidden;\n  }\n\n  .indent-1 {\n    padding-left: 2rem;\n  }\n\n  .modal-body {\n    // reset max-height so that we don't end up with two scroll bars\n    max-height: initial;\n  }\n\n  .manual-content,\n  .manual-toc {\n    max-height: calc(100vh - 10rem);\n    overflow-y: auto;\n  }\n\n  @media (max-width: 992px) {\n    .modal-body {\n      overflow-y: auto;\n\n      .manual-content,\n      .manual-toc {\n        max-height: inherit;\n        overflow-y: hidden;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/DeleteImagesDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useImagesDestroy } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IDeleteImageDialogProps {\n  selected: GQL.SlimImageDataFragment[];\n  onClose: (confirmed: boolean) => void;\n}\n\nexport const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = (\n  props: IDeleteImageDialogProps\n) => {\n  const intl = useIntl();\n  const singularEntity = intl.formatMessage({ id: \"image\" });\n  const pluralEntity = intl.formatMessage({ id: \"images\" });\n\n  const header = intl.formatMessage(\n    { id: \"dialogs.delete_entity_title\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const toastMessage = intl.formatMessage(\n    { id: \"toast.delete_past_tense\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const message = intl.formatMessage(\n    { id: \"dialogs.delete_entity_desc\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n\n  const { configuration: config } = useConfigurationContext();\n\n  const [deleteFile, setDeleteFile] = useState<boolean>(\n    config?.defaults.deleteFile ?? false\n  );\n  const [deleteGenerated, setDeleteGenerated] = useState<boolean>(\n    config?.defaults.deleteGenerated ?? true\n  );\n\n  const Toast = useToast();\n  const [deleteImage] = useImagesDestroy(getImagesDeleteInput());\n\n  // Network state\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  function getImagesDeleteInput(): GQL.ImagesDestroyInput {\n    return {\n      ids: props.selected.map((image) => image.id),\n      delete_file: deleteFile,\n      delete_generated: deleteGenerated,\n    };\n  }\n\n  async function onDelete() {\n    setIsDeleting(true);\n    try {\n      await deleteImage();\n      Toast.success(toastMessage);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsDeleting(false);\n    props.onClose(true);\n  }\n\n  function maybeRenderDeleteFileAlert() {\n    if (!deleteFile) {\n      return;\n    }\n\n    const deletedFiles: string[] = [];\n\n    props.selected.forEach((s) => {\n      const paths = s.visual_files.map((f) => f.path);\n      deletedFiles.push(...paths);\n    });\n\n    const deleteTrashPath = config?.general.deleteTrashPath;\n    const deleteAlertId = deleteTrashPath\n      ? \"dialogs.delete_alert_to_trash\"\n      : \"dialogs.delete_alert\";\n\n    return (\n      <div className=\"delete-dialog alert alert-danger text-break\">\n        <p className=\"font-weight-bold\">\n          <FormattedMessage\n            values={{\n              count: deletedFiles.length,\n              singularEntity: intl.formatMessage({ id: \"file\" }),\n              pluralEntity: intl.formatMessage({ id: \"files\" }),\n            }}\n            id={deleteAlertId}\n          />\n        </p>\n        <ul>\n          {deletedFiles.slice(0, 5).map((s) => (\n            <li key={s}>{s}</li>\n          ))}\n          {deletedFiles.length > 5 && (\n            <FormattedMessage\n              values={{\n                count: deletedFiles.length - 5,\n                singularEntity: intl.formatMessage({ id: \"file\" }),\n                pluralEntity: intl.formatMessage({ id: \"files\" }),\n              }}\n              id=\"dialogs.delete_object_overflow\"\n            />\n          )}\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faTrashAlt}\n      header={header}\n      accept={{\n        variant: \"danger\",\n        onClick: onDelete,\n        text: intl.formatMessage({ id: \"actions.delete\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(false),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isDeleting}\n    >\n      <p>{message}</p>\n      {maybeRenderDeleteFileAlert()}\n      <Form>\n        <Form.Check\n          id=\"delete-image\"\n          checked={deleteFile}\n          label={intl.formatMessage({ id: \"actions.delete_file\" })}\n          onChange={() => setDeleteFile(!deleteFile)}\n        />\n        <Form.Check\n          id=\"delete-image-generated\"\n          checked={deleteGenerated}\n          label={intl.formatMessage({\n            id: \"actions.delete_generated_supporting_files\",\n          })}\n          onChange={() => setDeleteGenerated(!deleteGenerated)}\n        />\n      </Form>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/EditImagesDialog.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useBulkImageUpdate } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { StudioSelect } from \"../Shared/Select\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { MultiSet } from \"../Shared/MultiSet\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport {\n  getAggregateInputValue,\n  getAggregatePerformerIds,\n  getAggregateStateObject,\n  getAggregateTagIds,\n  getAggregateStudioId,\n  getAggregateGalleryIds,\n} from \"src/utils/bulkUpdate\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { IndeterminateCheckbox } from \"../Shared/IndeterminateCheckbox\";\nimport { BulkUpdateFormGroup, BulkUpdateTextInput } from \"../Shared/BulkUpdate\";\nimport { BulkUpdateDateInput } from \"../Shared/DateInput\";\nimport { getDateError } from \"src/utils/yup\";\n\ninterface IListOperationProps {\n  selected: GQL.SlimImageDataFragment[];\n  onClose: (applied: boolean) => void;\n}\n\nconst imageFields = [\n  \"code\",\n  \"rating100\",\n  \"details\",\n  \"organized\",\n  \"photographer\",\n  \"date\",\n];\n\nexport const EditImagesDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [updateInput, setUpdateInput] = useState<GQL.BulkImageUpdateInput>({\n    ids: props.selected.map((image) => {\n      return image.id;\n    }),\n  });\n\n  const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [galleryIds, setGalleryIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n\n  const unsetDisabled = props.selected.length < 2;\n\n  const [dateError, setDateError] = useState<string | undefined>();\n\n  const [updateImages] = useBulkImageUpdate();\n\n  // Network state\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const aggregateState = useMemo(() => {\n    const updateState: Partial<GQL.BulkImageUpdateInput> = {};\n    const state = props.selected;\n    updateState.studio_id = getAggregateStudioId(props.selected);\n    const updateTagIds = getAggregateTagIds(props.selected);\n    const updatePerformerIds = getAggregatePerformerIds(props.selected);\n    const updateGalleryIds = getAggregateGalleryIds(props.selected);\n    let first = true;\n\n    state.forEach((image: GQL.SlimImageDataFragment) => {\n      getAggregateStateObject(updateState, image, imageFields, first);\n      first = false;\n    });\n\n    return {\n      state: updateState,\n      tagIds: updateTagIds,\n      performerIds: updatePerformerIds,\n      galleryIds: updateGalleryIds,\n    };\n  }, [props.selected]);\n\n  // update initial state from aggregate\n  useEffect(() => {\n    setUpdateInput((current) => ({ ...current, ...aggregateState.state }));\n  }, [aggregateState]);\n\n  useEffect(() => {\n    setDateError(getDateError(updateInput.date ?? \"\", intl));\n  }, [updateInput.date, intl]);\n\n  function setUpdateField(input: Partial<GQL.BulkImageUpdateInput>) {\n    setUpdateInput((current) => ({ ...current, ...input }));\n  }\n\n  function getImageInput(): GQL.BulkImageUpdateInput {\n    const imageInput: GQL.BulkImageUpdateInput = {\n      ...updateInput,\n      tag_ids: tagIds,\n      performer_ids: performerIds,\n      gallery_ids: galleryIds,\n    };\n\n    // we don't have unset functionality for the rating star control\n    // so need to determine if we are setting a rating or not\n    imageInput.rating100 = getAggregateInputValue(\n      updateInput.rating100,\n      aggregateState.state.rating100\n    );\n\n    return imageInput;\n  }\n\n  async function onSave() {\n    setIsUpdating(true);\n    try {\n      await updateImages({ variables: { input: getImageInput() } });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          { entity: intl.formatMessage({ id: \"images\" }).toLocaleLowerCase() }\n        )\n      );\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsUpdating(false);\n  }\n\n  function render() {\n    return (\n      <ModalComponent\n        show\n        icon={faPencilAlt}\n        header={intl.formatMessage(\n          { id: \"dialogs.edit_entity_count_title\" },\n          {\n            count: props?.selected?.length ?? 1,\n            singularEntity: intl.formatMessage({ id: \"image\" }),\n            pluralEntity: intl.formatMessage({ id: \"images\" }),\n          }\n        )}\n        accept={{\n          onClick: onSave,\n          text: intl.formatMessage({ id: \"actions.apply\" }),\n        }}\n        disabled={isUpdating || !!dateError}\n        cancel={{\n          onClick: () => props.onClose(false),\n          text: intl.formatMessage({ id: \"actions.cancel\" }),\n          variant: \"secondary\",\n        }}\n        isRunning={isUpdating}\n      >\n        <Form>\n          <BulkUpdateFormGroup name=\"rating\">\n            <RatingSystem\n              value={updateInput.rating100}\n              onSetRating={(value) =>\n                setUpdateField({ rating100: value ?? undefined })\n              }\n              disabled={isUpdating}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"scene_code\">\n            <BulkUpdateTextInput\n              value={updateInput.code}\n              valueChanged={(newValue) => setUpdateField({ code: newValue })}\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"date\">\n            <BulkUpdateDateInput\n              value={updateInput.date}\n              valueChanged={(newValue) => setUpdateField({ date: newValue })}\n              unsetDisabled={unsetDisabled}\n              error={dateError}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"photographer\">\n            <BulkUpdateTextInput\n              value={updateInput.photographer}\n              valueChanged={(newValue) =>\n                setUpdateField({ photographer: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"studio\">\n            <StudioSelect\n              onSelect={(items) =>\n                setUpdateField({\n                  studio_id: items.length > 0 ? items[0]?.id : undefined,\n                })\n              }\n              ids={updateInput.studio_id ? [updateInput.studio_id] : []}\n              isDisabled={isUpdating}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"performers\" inline={false}>\n            <MultiSet\n              type={\"performers\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setPerformerIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setPerformerIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={performerIds.ids ?? []}\n              existingIds={aggregateState.performerIds}\n              mode={performerIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"galleries\" inline={false}>\n            <MultiSet\n              type=\"galleries\"\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setGalleryIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setGalleryIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={galleryIds.ids ?? []}\n              existingIds={aggregateState.galleryIds}\n              mode={galleryIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"tags\" inline={false}>\n            <MultiSet\n              type={\"tags\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setTagIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setTagIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={tagIds.ids ?? []}\n              existingIds={aggregateState.tagIds}\n              mode={tagIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"details\" inline={false}>\n            <BulkUpdateTextInput\n              value={updateInput.details}\n              valueChanged={(newValue) => setUpdateField({ details: newValue })}\n              unsetDisabled={unsetDisabled}\n              as=\"textarea\"\n            />\n          </BulkUpdateFormGroup>\n\n          <Form.Group controlId=\"organized\">\n            <IndeterminateCheckbox\n              label={intl.formatMessage({ id: \"organized\" })}\n              setChecked={(checked) => setUpdateField({ organized: checked })}\n              checked={updateInput.organized ?? undefined}\n            />\n          </Form.Group>\n        </Form>\n      </ModalComponent>\n    );\n  }\n\n  return render();\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageCard.tsx",
    "content": "import React, { MouseEvent, useMemo } from \"react\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport cx from \"classnames\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { GalleryLink, TagLink } from \"src/components/Shared/TagLink\";\nimport { HoverPopover } from \"src/components/Shared/HoverPopover\";\nimport { PerformerPopoverButton } from \"src/components/Shared/PerformerPopoverButton\";\nimport { GridCard } from \"src/components/Shared/GridCard/GridCard\";\nimport { RatingBanner } from \"src/components/Shared/RatingBanner\";\nimport {\n  faBox,\n  faImages,\n  faSearch,\n  faTag,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { imageTitle } from \"src/core/files\";\nimport { PatchComponent } from \"src/patch\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport { StudioOverlay } from \"../Shared/GridCard/StudioOverlay\";\nimport { OCounterButton } from \"../Shared/CountButton\";\n\ninterface IImageCardProps {\n  image: GQL.SlimImageDataFragment;\n  cardWidth?: number;\n  selecting?: boolean;\n  selected?: boolean | undefined;\n  zoomIndex: number;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  onPreview?: (ev: MouseEvent) => void;\n}\n\nconst ImageCardPopovers = PatchComponent(\n  \"ImageCard.Popovers\",\n  (props: IImageCardProps) => {\n    function maybeRenderTagPopoverButton() {\n      if (props.image.tags.length <= 0) return;\n\n      const popoverContent = props.image.tags.map((tag) => (\n        <TagLink key={tag.id} tag={tag} linkType=\"image\" />\n      ));\n\n      return (\n        <HoverPopover\n          className=\"tag-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faTag} />\n            <span>{props.image.tags.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderPerformerPopoverButton() {\n      if (props.image.performers.length <= 0) return;\n\n      return (\n        <PerformerPopoverButton\n          performers={props.image.performers}\n          linkType=\"image\"\n        />\n      );\n    }\n\n    function maybeRenderOCounter() {\n      if (props.image.o_counter) {\n        return <OCounterButton value={props.image.o_counter} />;\n      }\n    }\n\n    function maybeRenderGallery() {\n      if (props.image.galleries.length <= 0) return;\n\n      const popoverContent = props.image.galleries.map((gallery) => (\n        <GalleryLink key={gallery.id} gallery={gallery} />\n      ));\n\n      return (\n        <HoverPopover\n          className=\"gallery-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faImages} />\n            <span>{props.image.galleries.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderOrganized() {\n      if (props.image.organized) {\n        return (\n          <div className=\"organized\">\n            <Button className=\"minimal\">\n              <Icon icon={faBox} />\n            </Button>\n          </div>\n        );\n      }\n    }\n\n    if (\n      props.image.tags.length > 0 ||\n      props.image.performers.length > 0 ||\n      props.image.o_counter ||\n      props.image.galleries.length > 0 ||\n      props.image.organized\n    ) {\n      return (\n        <>\n          <hr />\n          <ButtonGroup className=\"card-popovers\">\n            {maybeRenderTagPopoverButton()}\n            {maybeRenderPerformerPopoverButton()}\n            {maybeRenderOCounter()}\n            {maybeRenderGallery()}\n            {maybeRenderOrganized()}\n          </ButtonGroup>\n        </>\n      );\n    }\n\n    return null;\n  }\n);\n\nconst ImageCardDetails = PatchComponent(\n  \"ImageCard.Details\",\n  (props: IImageCardProps) => {\n    return (\n      <div className=\"image-card__details\">\n        <span className=\"image-card__date\">{props.image.date}</span>\n        <TruncatedText\n          className=\"image-card__description\"\n          text={props.image.details}\n          lineCount={3}\n        />\n      </div>\n    );\n  }\n);\n\nconst ImageCardOverlays = PatchComponent(\n  \"ImageCard.Overlays\",\n  (props: IImageCardProps) => {\n    const ret = useMemo(() => {\n      return (\n        <StudioOverlay studio={props.image.studio} disabled={props.selecting} />\n      );\n    }, [props.image.studio, props.selecting]);\n\n    return ret;\n  }\n);\n\nconst ImageCardImage = PatchComponent(\n  \"ImageCard.Image\",\n  (props: IImageCardProps) => {\n    const file = useMemo(\n      () =>\n        props.image.visual_files.length > 0\n          ? props.image.visual_files[0]\n          : undefined,\n      [props.image]\n    );\n\n    function isPortrait() {\n      const width = file?.width ? file.width : 0;\n      const height = file?.height ? file.height : 0;\n      return height > width;\n    }\n\n    const source =\n      props.image.paths.preview != \"\"\n        ? props.image.paths.preview ?? \"\"\n        : props.image.paths.thumbnail ?? \"\";\n    const video = source.includes(\"preview\");\n    const ImagePreview = video ? \"video\" : \"img\";\n\n    return (\n      <>\n        <div className={cx(\"image-card-preview\", { portrait: isPortrait() })}>\n          <ImagePreview\n            loop={video}\n            autoPlay={video}\n            playsInline={video}\n            className=\"image-card-preview-image\"\n            alt={props.image.title ?? \"\"}\n            src={source}\n          />\n          {props.onPreview ? (\n            <div className=\"preview-button\">\n              <Button onClick={props.onPreview}>\n                <Icon icon={faSearch} />\n              </Button>\n            </div>\n          ) : undefined}\n        </div>\n        <RatingBanner rating={props.image.rating100} />\n      </>\n    );\n  }\n);\n\nexport const ImageCard: React.FC<IImageCardProps> = PatchComponent(\n  \"ImageCard\",\n  (props: IImageCardProps) => {\n    return (\n      <GridCard\n        className={`image-card zoom-${props.zoomIndex}`}\n        url={`/images/${props.image.id}`}\n        width={props.cardWidth}\n        title={imageTitle(props.image)}\n        linkClassName=\"image-card-link\"\n        image={<ImageCardImage {...props} />}\n        details={<ImageCardDetails {...props} />}\n        overlays={<ImageCardOverlays {...props} />}\n        popovers={<ImageCardPopovers {...props} />}\n        selected={props.selected}\n        selecting={props.selecting}\n        onSelectedChanged={props.onSelectedChanged}\n      />\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageCardGrid.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ImageCard } from \"./ImageCard\";\nimport {\n  useCardWidth,\n  useContainerDimensions,\n} from \"../Shared/GridCard/GridCard\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IImageCardGrid {\n  images: GQL.SlimImageDataFragment[];\n  selectedIds: Set<string>;\n  zoomIndex: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  onPreview: (index: number, ev: React.MouseEvent<Element, MouseEvent>) => void;\n}\n\nconst zoomWidths = [280, 340, 480, 640];\n\nexport const ImageCardGrid: React.FC<IImageCardGrid> = PatchComponent(\n  \"ImageCardGrid\",\n  ({ images, selectedIds, zoomIndex, onSelectChange, onPreview }) => {\n    const [componentRef, { width: containerWidth }] = useContainerDimensions();\n    const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);\n\n    return (\n      <div className=\"row justify-content-center\" ref={componentRef}>\n        {images.map((image, index) => (\n          <ImageCard\n            key={image.id}\n            cardWidth={cardWidth}\n            image={image}\n            zoomIndex={zoomIndex}\n            selecting={selectedIds.size > 0}\n            selected={selectedIds.has(image.id)}\n            onSelectedChanged={(selected: boolean, shiftKey: boolean) =>\n              onSelectChange(image.id, selected, shiftKey)\n            }\n            onPreview={\n              selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined\n            }\n          />\n        ))}\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageDetails/Image.tsx",
    "content": "import { Tab, Nav, Dropdown } from \"react-bootstrap\";\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useHistory, Link, RouteComponentProps } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport {\n  useFindImage,\n  useImageIncrementO,\n  useImageUpdate,\n  mutateMetadataScan,\n  useImageDecrementO,\n  useImageResetO,\n} from \"src/core/StashService\";\nimport { ErrorMessage } from \"src/components/Shared/ErrorMessage\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { Counter } from \"src/components/Shared/Counter\";\nimport { useToast } from \"src/hooks/Toast\";\nimport * as Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { OCounterButton } from \"src/components/Scenes/SceneDetails/OCounterButton\";\nimport { OrganizedButton } from \"src/components/Scenes/SceneDetails/OrganizedButton\";\nimport { ImageFileInfoPanel } from \"./ImageFileInfoPanel\";\nimport { ImageEditPanel } from \"./ImageEditPanel\";\nimport { ImageDetailPanel } from \"./ImageDetailPanel\";\nimport { DeleteImagesDialog } from \"../DeleteImagesDialog\";\nimport { faEllipsisV } from \"@fortawesome/free-solid-svg-icons\";\nimport { imagePath, imageTitle } from \"src/core/files\";\nimport { isVideo } from \"src/utils/visualFile\";\nimport { useScrollToTopOnMount } from \"src/hooks/scrollToTop\";\nimport { useRatingKeybinds } from \"src/hooks/keybinds\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport TextUtils from \"src/utils/text\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport cx from \"classnames\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { goBackOrReplace } from \"src/utils/history\";\nimport { FormattedDate } from \"src/components/Shared/Date\";\nimport { GenerateDialog } from \"src/components/Dialogs/GenerateDialog\";\n\ninterface IProps {\n  image: GQL.ImageDataFragment;\n}\n\ninterface IImageParams {\n  id: string;\n}\n\nconst ImagePage: React.FC<IProps> = ({ image }) => {\n  const history = useHistory();\n  const Toast = useToast();\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n\n  const [incrementO] = useImageIncrementO(image.id);\n  const [decrementO] = useImageDecrementO(image.id);\n  const [resetO] = useImageResetO(image.id);\n\n  const [updateImage] = useImageUpdate();\n\n  const [organizedLoading, setOrganizedLoading] = useState(false);\n\n  const [activeTabKey, setActiveTabKey] = useState(\"image-details-panel\");\n\n  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);\n  const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);\n\n  async function onSave(input: GQL.ImageUpdateInput) {\n    await updateImage({\n      variables: { input },\n    });\n    Toast.success(\n      intl.formatMessage(\n        { id: \"toast.updated_entity\" },\n        { entity: intl.formatMessage({ id: \"image\" }).toLocaleLowerCase() }\n      )\n    );\n  }\n\n  async function onRescan() {\n    if (!image || !image.visual_files.length) {\n      return;\n    }\n\n    await mutateMetadataScan({\n      paths: [imagePath(image)],\n      rescan: true,\n    });\n\n    Toast.success(\n      intl.formatMessage(\n        { id: \"toast.rescanning_entity\" },\n        {\n          count: 1,\n          singularEntity: intl.formatMessage({ id: \"image\" }),\n        }\n      )\n    );\n  }\n\n  const onOrganizedClick = async () => {\n    try {\n      setOrganizedLoading(true);\n      await updateImage({\n        variables: {\n          input: {\n            id: image.id,\n            organized: !image.organized,\n          },\n        },\n      });\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setOrganizedLoading(false);\n    }\n  };\n\n  const onIncrementClick = async () => {\n    try {\n      await incrementO();\n    } catch (e) {\n      Toast.error(e);\n    }\n  };\n\n  const onDecrementClick = async () => {\n    try {\n      await decrementO();\n    } catch (e) {\n      Toast.error(e);\n    }\n  };\n\n  const onResetClick = async () => {\n    try {\n      await resetO();\n    } catch (e) {\n      Toast.error(e);\n    }\n  };\n\n  function setRating(v: number | null) {\n    updateImage({\n      variables: {\n        input: {\n          id: image.id,\n          rating100: v,\n        },\n      },\n    });\n  }\n\n  useRatingKeybinds(\n    true,\n    configuration?.ui.ratingSystemOptions?.type,\n    setRating\n  );\n\n  function onDeleteDialogClosed(deleted: boolean) {\n    setIsDeleteAlertOpen(false);\n    if (deleted) {\n      goBackOrReplace(history, \"/images\");\n    }\n  }\n\n  function maybeRenderDeleteDialog() {\n    if (isDeleteAlertOpen && image) {\n      return (\n        <DeleteImagesDialog selected={[image]} onClose={onDeleteDialogClosed} />\n      );\n    }\n  }\n\n  function maybeRenderSceneGenerateDialog() {\n    if (isGenerateDialogOpen) {\n      return (\n        <GenerateDialog\n          selectedIds={[image.id]}\n          onClose={() => {\n            setIsGenerateDialogOpen(false);\n          }}\n          type=\"image\"\n        />\n      );\n    }\n  }\n\n  function renderOperations() {\n    return (\n      <Dropdown>\n        <Dropdown.Toggle\n          variant=\"secondary\"\n          id=\"operation-menu\"\n          className=\"minimal\"\n          title=\"Operations\"\n        >\n          <Icon icon={faEllipsisV} />\n        </Dropdown.Toggle>\n        <Dropdown.Menu className=\"bg-secondary text-white\">\n          <Dropdown.Item\n            key=\"rescan\"\n            className=\"bg-secondary text-white\"\n            onClick={() => onRescan()}\n          >\n            <FormattedMessage id=\"actions.rescan\" />\n          </Dropdown.Item>\n          <Dropdown.Item\n            key=\"generate\"\n            className=\"bg-secondary text-white\"\n            onClick={() => setIsGenerateDialogOpen(true)}\n          >\n            <FormattedMessage id=\"actions.generate\" />…\n          </Dropdown.Item>\n          <Dropdown.Item\n            key=\"delete-image\"\n            className=\"bg-secondary text-white\"\n            onClick={() => setIsDeleteAlertOpen(true)}\n          >\n            <FormattedMessage\n              id=\"actions.delete\"\n              values={{ entityType: intl.formatMessage({ id: \"image\" }) }}\n            />\n          </Dropdown.Item>\n        </Dropdown.Menu>\n      </Dropdown>\n    );\n  }\n\n  function renderTabs() {\n    if (!image) {\n      return;\n    }\n\n    return (\n      <Tab.Container\n        activeKey={activeTabKey}\n        onSelect={(k) => k && setActiveTabKey(k)}\n      >\n        <div>\n          <Nav variant=\"tabs\" className=\"mr-auto\">\n            <Nav.Item>\n              <Nav.Link eventKey=\"image-details-panel\">\n                <FormattedMessage id=\"details\" />\n              </Nav.Link>\n            </Nav.Item>\n            <Nav.Item>\n              <Nav.Link eventKey=\"image-file-info-panel\">\n                <FormattedMessage id=\"file_info\" />\n                <Counter count={image.visual_files.length} hideZero hideOne />\n              </Nav.Link>\n            </Nav.Item>\n            <Nav.Item>\n              <Nav.Link eventKey=\"image-edit-panel\">\n                <FormattedMessage id=\"actions.edit\" />\n              </Nav.Link>\n            </Nav.Item>\n          </Nav>\n        </div>\n\n        <Tab.Content>\n          <Tab.Pane eventKey=\"image-details-panel\">\n            <ImageDetailPanel image={image} />\n          </Tab.Pane>\n          <Tab.Pane\n            className=\"file-info-panel\"\n            eventKey=\"image-file-info-panel\"\n          >\n            <ImageFileInfoPanel image={image} />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"image-edit-panel\" mountOnEnter>\n            <ImageEditPanel\n              isVisible={activeTabKey === \"image-edit-panel\"}\n              image={image}\n              onSubmit={onSave}\n              onDelete={() => setIsDeleteAlertOpen(true)}\n            />\n          </Tab.Pane>\n        </Tab.Content>\n      </Tab.Container>\n    );\n  }\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"a\", () => setActiveTabKey(\"image-details-panel\"));\n    Mousetrap.bind(\"e\", () => setActiveTabKey(\"image-edit-panel\"));\n    Mousetrap.bind(\"f\", () => setActiveTabKey(\"image-file-info-panel\"));\n    Mousetrap.bind(\"o\", () => {\n      onIncrementClick();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"a\");\n      Mousetrap.unbind(\"e\");\n      Mousetrap.unbind(\"f\");\n      Mousetrap.unbind(\"o\");\n    };\n  });\n\n  const file = useMemo(\n    () => (image.visual_files.length > 0 ? image.visual_files[0] : undefined),\n    [image]\n  );\n\n  const title = imageTitle(image);\n  const ImageView =\n    image.visual_files.length > 0 && isVideo(image.visual_files[0])\n      ? \"video\"\n      : \"img\";\n\n  const resolution = useMemo(() => {\n    return file?.width && file?.height\n      ? TextUtils.resolution(file?.width, file?.height)\n      : undefined;\n  }, [file?.width, file?.height]);\n\n  return (\n    <div className=\"row\">\n      <Helmet>\n        <title>{title}</title>\n      </Helmet>\n\n      {maybeRenderDeleteDialog()}\n      {maybeRenderSceneGenerateDialog()}\n      <div className=\"image-tabs order-xl-first order-last\">\n        <div>\n          <div className=\"image-header-container\">\n            {image.studio && (\n              <h1 className=\"text-center image-studio-image\">\n                <Link to={`/studios/${image.studio.id}`}>\n                  <img\n                    src={image.studio.image_path ?? \"\"}\n                    alt={`${image.studio.name} logo`}\n                    className=\"studio-logo\"\n                  />\n                </Link>\n              </h1>\n            )}\n            <h3 className={cx(\"image-header\", { \"no-studio\": !image.studio })}>\n              <TruncatedText lineCount={2} text={title} />\n            </h3>\n          </div>\n\n          <div className=\"image-subheader\">\n            <span className=\"date\" data-value={image.date}>\n              {!!image.date && <FormattedDate value={image.date} />}\n            </span>\n            {resolution ? (\n              <span className=\"resolution\" data-value={resolution}>\n                {resolution}\n              </span>\n            ) : undefined}\n          </div>\n        </div>\n\n        <div className=\"image-toolbar\">\n          <span className=\"image-toolbar-group\">\n            <RatingSystem\n              value={image.rating100}\n              onSetRating={setRating}\n              clickToRate\n              withoutContext\n            />\n          </span>\n          <span className=\"image-toolbar-group\">\n            <span>\n              <OCounterButton\n                value={image.o_counter || 0}\n                onIncrement={onIncrementClick}\n                onDecrement={onDecrementClick}\n                onReset={onResetClick}\n              />\n            </span>\n            <span>\n              <OrganizedButton\n                loading={organizedLoading}\n                organized={image.organized}\n                onClick={onOrganizedClick}\n              />\n            </span>\n            <span>{renderOperations()}</span>\n          </span>\n        </div>\n        {renderTabs()}\n      </div>\n      <div className=\"image-container\">\n        {image.visual_files.length > 0 && (\n          <ImageView\n            loop={image.visual_files[0].__typename == \"VideoFile\"}\n            autoPlay={image.visual_files[0].__typename == \"VideoFile\"}\n            playsInline={image.visual_files[0].__typename == \"VideoFile\"}\n            controls={image.visual_files[0].__typename == \"VideoFile\"}\n            className=\"m-sm-auto no-gutter image-image\"\n            style={\n              image.visual_files[0].__typename == \"VideoFile\"\n                ? { width: \"100%\", height: \"100%\" }\n                : {}\n            }\n            alt={title}\n            src={image.paths.image ?? \"\"}\n          />\n        )}\n      </div>\n    </div>\n  );\n};\n\nconst ImageLoader: React.FC<RouteComponentProps<IImageParams>> = ({\n  match,\n}) => {\n  const { id } = match.params;\n  const { data, loading, error } = useFindImage(id);\n\n  useScrollToTopOnMount();\n\n  if (loading) return <LoadingIndicator />;\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findImage)\n    return <ErrorMessage error={`No image found with id ${id}.`} />;\n\n  return <ImagePage image={data.findImage} />;\n};\n\nexport default ImageLoader;\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport { GalleryLink, TagLink } from \"src/components/Shared/TagLink\";\nimport { PerformerCard } from \"src/components/Performers/PerformerCard\";\nimport { sortPerformers } from \"src/core/performers\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { PhotographerLink } from \"src/components/Shared/Link\";\nimport { PatchComponent } from \"../../../patch\";\nimport { CustomFields } from \"src/components/Shared/CustomFields\";\ninterface IImageDetailProps {\n  image: GQL.ImageDataFragment;\n}\n\nexport const ImageDetailPanel: React.FC<IImageDetailProps> = PatchComponent(\n  \"ImageDetailPanel\",\n  (props) => {\n    const intl = useIntl();\n\n    function renderDetails() {\n      if (!props.image.details) return;\n      return (\n        <>\n          <h6>\n            <FormattedMessage id=\"details\" />:{\" \"}\n          </h6>\n          <p className=\"pre\">{props.image.details}</p>\n        </>\n      );\n    }\n\n    function renderTags() {\n      if (props.image.tags.length === 0) return;\n      const tags = props.image.tags.map((tag) => (\n        <TagLink key={tag.id} tag={tag} linkType=\"image\" />\n      ));\n      return (\n        <>\n          <h6>\n            <FormattedMessage\n              id=\"countables.tags\"\n              values={{ count: props.image.tags.length }}\n            />\n          </h6>\n          {tags}\n        </>\n      );\n    }\n\n    function renderPerformers() {\n      if (props.image.performers.length === 0) return;\n      const performers = sortPerformers(props.image.performers);\n      const cards = performers.map((performer) => (\n        <PerformerCard\n          key={performer.id}\n          performer={performer}\n          ageFromDate={props.image.date ?? undefined}\n        />\n      ));\n\n      return (\n        <>\n          <h6>\n            <FormattedMessage\n              id=\"countables.performers\"\n              values={{ count: props.image.performers.length }}\n            />\n          </h6>\n          <div className=\"row justify-content-center image-performers\">\n            {cards}\n          </div>\n        </>\n      );\n    }\n\n    function renderGalleries() {\n      if (props.image.galleries.length === 0) return;\n      const galleries = props.image.galleries.map((gallery) => (\n        <GalleryLink key={gallery.id} gallery={gallery} />\n      ));\n      return (\n        <>\n          <h6>\n            <FormattedMessage\n              id=\"countables.galleries\"\n              values={{ count: props.image.galleries.length }}\n            />\n          </h6>\n          {galleries}\n        </>\n      );\n    }\n\n    // filename should use entire row if there is no studio\n    const imageDetailsWidth = props.image.studio ? \"col-9\" : \"col-12\";\n\n    return (\n      <>\n        <div className=\"row\">\n          <div className={`${imageDetailsWidth} col-12 image-details`}>\n            {renderGalleries()}\n            {\n              <h6>\n                {\" \"}\n                <FormattedMessage id=\"created_at\" />:{\" \"}\n                {TextUtils.formatDateTime(intl, props.image.created_at)}{\" \"}\n              </h6>\n            }\n            {\n              <h6>\n                <FormattedMessage id=\"updated_at\" />:{\" \"}\n                {TextUtils.formatDateTime(intl, props.image.updated_at)}{\" \"}\n              </h6>\n            }\n            {props.image.code && (\n              <h6>\n                <FormattedMessage id=\"scene_code\" />: {props.image.code}{\" \"}\n              </h6>\n            )}\n            {props.image.photographer && (\n              <h6>\n                <FormattedMessage id=\"photographer\" />:{\" \"}\n                <PhotographerLink\n                  photographer={props.image.photographer}\n                  linkType=\"image\"\n                />\n              </h6>\n            )}\n          </div>\n        </div>\n        <div className=\"row\">\n          <div className=\"col-12\">\n            {renderDetails()}\n            {renderTags()}\n            {renderPerformers()}\n            <CustomFields values={props.image.custom_fields} fullWidth />\n          </div>\n        </div>\n      </>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Button, Form, Col, Row } from \"react-bootstrap\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport * as yup from \"yup\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useFormik } from \"formik\";\nimport { Prompt } from \"react-router-dom\";\nimport isEqual from \"lodash-es/isEqual\";\nimport {\n  yupDateString,\n  yupFormikValidate,\n  yupUniqueStringList,\n} from \"src/utils/yup\";\nimport {\n  Performer,\n  PerformerSelect,\n} from \"src/components/Performers/PerformerSelect\";\nimport { formikUtils } from \"src/utils/form\";\nimport {\n  queryScrapeImage,\n  queryScrapeImageURL,\n  useListImageScrapers,\n  mutateReloadScrapers,\n} from \"../../../core/StashService\";\nimport { ImageScrapeDialog } from \"./ImageScrapeDialog\";\nimport { Studio, StudioSelect } from \"src/components/Studios/StudioSelect\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport {\n  Gallery,\n  GallerySelect,\n  excludeFileBasedGalleries,\n} from \"src/components/Galleries/GallerySelect\";\nimport { useTagsEdit } from \"src/hooks/tagsEdit\";\nimport { ScraperMenu } from \"src/components/Shared/ScraperMenu\";\nimport {\n  CustomFieldsInput,\n  formatCustomFieldInput,\n} from \"src/components/Shared/CustomFields\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\n\ninterface IProps {\n  image: GQL.ImageDataFragment;\n  isVisible: boolean;\n  onSubmit: (input: GQL.ImageUpdateInput) => Promise<void>;\n  onDelete: () => void;\n}\n\nexport const ImageEditPanel: React.FC<IProps> = ({\n  image,\n  isVisible,\n  onSubmit,\n  onDelete,\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  // Network state\n  const [isLoading, setIsLoading] = useState(false);\n\n  const [galleries, setGalleries] = useState<Gallery[]>([]);\n  const [performers, setPerformers] = useState<Performer[]>([]);\n  const [studio, setStudio] = useState<Studio | null>(null);\n\n  const isNew = image.id === undefined;\n\n  useEffect(() => {\n    setGalleries(\n      image.galleries?.map((g) => ({\n        id: g.id,\n        title: galleryTitle(g),\n        files: g.files,\n        folder: g.folder,\n      })) ?? []\n    );\n  }, [image.galleries]);\n\n  const scrapers = useListImageScrapers();\n  const [scrapedImage, setScrapedImage] = useState<GQL.ScrapedImage | null>();\n\n  const schema = yup.object({\n    title: yup.string().ensure(),\n    code: yup.string().ensure(),\n    urls: yupUniqueStringList(intl),\n    date: yupDateString(intl),\n    details: yup.string().ensure(),\n    photographer: yup.string().ensure(),\n    gallery_ids: yup.array(yup.string().required()).defined(),\n    studio_id: yup.string().required().nullable(),\n    performer_ids: yup.array(yup.string().required()).defined(),\n    tag_ids: yup.array(yup.string().required()).defined(),\n    custom_fields: yup.object().required().defined(),\n  });\n\n  const initialValues = {\n    title: image.title ?? \"\",\n    code: image.code ?? \"\",\n    urls: image?.urls ?? [],\n    date: image?.date ?? \"\",\n    details: image.details ?? \"\",\n    photographer: image.photographer ?? \"\",\n    gallery_ids: (image.galleries ?? []).map((g) => g.id),\n    studio_id: image.studio?.id ?? null,\n    performer_ids: (image.performers ?? []).map((p) => p.id),\n    tag_ids: (image.tags ?? []).map((t) => t.id),\n    custom_fields: cloneDeep(image.custom_fields ?? {}),\n  };\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const [customFieldsError, setCustomFieldsError] = useState<string>();\n\n  function submit(values: InputValues) {\n    const input = {\n      ...schema.cast(values),\n      custom_fields: formatCustomFieldInput(isNew, values.custom_fields),\n    };\n    onSave(input);\n  }\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: submit,\n  });\n\n  const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(\n    image.tags,\n    (ids) => formik.setFieldValue(\"tag_ids\", ids)\n  );\n\n  function onSetGalleries(items: Gallery[]) {\n    setGalleries(items);\n    formik.setFieldValue(\n      \"gallery_ids\",\n      items.map((i) => i.id)\n    );\n  }\n\n  function onSetPerformers(items: Performer[]) {\n    setPerformers(items);\n    formik.setFieldValue(\n      \"performer_ids\",\n      items.map((item) => item.id)\n    );\n  }\n\n  function onSetStudio(item: Studio | null) {\n    setStudio(item);\n    formik.setFieldValue(\"studio_id\", item ? item.id : null);\n  }\n\n  useEffect(() => {\n    setPerformers(image.performers ?? []);\n  }, [image.performers]);\n\n  useEffect(() => {\n    setStudio(image.studio ?? null);\n  }, [image.studio]);\n\n  useEffect(() => {\n    if (isVisible) {\n      Mousetrap.bind(\"s s\", () => {\n        if (formik.dirty) {\n          formik.submitForm();\n        }\n      });\n      Mousetrap.bind(\"d d\", () => {\n        onDelete();\n      });\n\n      return () => {\n        Mousetrap.unbind(\"s s\");\n        Mousetrap.unbind(\"d d\");\n      };\n    }\n  });\n\n  const fragmentScrapers = useMemo(() => {\n    return (scrapers?.data?.listScrapers ?? []).filter((s) =>\n      s.image?.supported_scrapes.includes(GQL.ScrapeType.Fragment)\n    );\n  }, [scrapers]);\n\n  async function onSave(input: InputValues) {\n    setIsLoading(true);\n    try {\n      await onSubmit({\n        id: image.id,\n        ...input,\n      });\n      formik.resetForm();\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsLoading(false);\n  }\n\n  async function onScrapeClicked(s: GQL.ScraperSourceInput) {\n    if (!image || !image.id) return;\n\n    setIsLoading(true);\n    try {\n      const result = await queryScrapeImage(s.scraper_id!, image.id);\n      if (!result.data || !result.data.scrapeSingleImage?.length) {\n        Toast.success(\"No images found\");\n        return;\n      }\n      setScrapedImage(result.data.scrapeSingleImage[0]);\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  function urlScrapable(scrapedUrl: string): boolean {\n    return (scrapers?.data?.listScrapers ?? []).some((s) =>\n      (s?.image?.urls ?? []).some((u) => scrapedUrl.includes(u))\n    );\n  }\n\n  function updateImageFromScrapedGallery(\n    imageData: GQL.ScrapedImageDataFragment\n  ) {\n    if (imageData.title) {\n      formik.setFieldValue(\"title\", imageData.title);\n    }\n\n    if (imageData.code) {\n      formik.setFieldValue(\"code\", imageData.code);\n    }\n\n    if (imageData.details) {\n      formik.setFieldValue(\"details\", imageData.details);\n    }\n\n    if (imageData.photographer) {\n      formik.setFieldValue(\"photographer\", imageData.photographer);\n    }\n\n    if (imageData.date) {\n      formik.setFieldValue(\"date\", imageData.date);\n    }\n\n    if (imageData.urls) {\n      formik.setFieldValue(\"urls\", imageData.urls);\n    }\n\n    if (imageData.studio?.stored_id) {\n      onSetStudio({\n        id: imageData.studio.stored_id,\n        name: imageData.studio.name ?? \"\",\n        aliases: [],\n      });\n    }\n\n    if (imageData.performers?.length) {\n      const idPerfs = imageData.performers.filter((p) => {\n        return p.stored_id !== undefined && p.stored_id !== null;\n      });\n\n      if (idPerfs.length > 0) {\n        onSetPerformers(\n          idPerfs.map((p) => {\n            return {\n              id: p.stored_id!,\n              name: p.name ?? \"\",\n              alias_list: [],\n            };\n          })\n        );\n      }\n    }\n\n    updateTagsStateFromScraper(imageData.tags ?? undefined);\n  }\n\n  async function onReloadScrapers() {\n    setIsLoading(true);\n    try {\n      await mutateReloadScrapers();\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  async function onScrapeDialogClosed(data?: GQL.ScrapedImageDataFragment) {\n    if (data) {\n      updateImageFromScrapedGallery(data);\n    }\n    setScrapedImage(undefined);\n  }\n\n  async function onScrapeImageURL(url: string) {\n    if (!url) {\n      return;\n    }\n    setIsLoading(true);\n    try {\n      const result = await queryScrapeImageURL(url);\n      if (!result || !result.data || !result.data.scrapeImageURL) {\n        return;\n      }\n      setScrapedImage(result.data.scrapeImageURL);\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  if (isLoading) return <LoadingIndicator />;\n\n  const splitProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n    },\n    fieldProps: {\n      sm: 9,\n    },\n  };\n  const fullWidthProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n      xl: 12,\n    },\n    fieldProps: {\n      sm: 9,\n      xl: 12,\n    },\n  };\n  const urlProps = isNew\n    ? splitProps\n    : {\n        labelProps: {\n          column: true,\n          md: 3,\n          lg: 12,\n        },\n        fieldProps: {\n          md: 9,\n          lg: 12,\n        },\n      };\n  const { renderField, renderInputField, renderDateField, renderURLListField } =\n    formikUtils(intl, formik, splitProps);\n\n  function renderGalleriesField() {\n    const title = intl.formatMessage({ id: \"galleries\" });\n    const control = (\n      <GallerySelect\n        values={galleries}\n        onSelect={(items) => onSetGalleries(items)}\n        isMulti\n        extraCriteria={excludeFileBasedGalleries}\n      />\n    );\n\n    return renderField(\"gallery_ids\", title, control);\n  }\n\n  function renderStudioField() {\n    const title = intl.formatMessage({ id: \"studio\" });\n    const control = (\n      <StudioSelect\n        onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)}\n        values={studio ? [studio] : []}\n      />\n    );\n\n    return renderField(\"studio_id\", title, control);\n  }\n\n  function renderPerformersField() {\n    const date = (() => {\n      try {\n        return schema.validateSyncAt(\"date\", formik.values);\n      } catch (e) {\n        return undefined;\n      }\n    })();\n\n    const title = intl.formatMessage({ id: \"performers\" });\n    const control = (\n      <PerformerSelect\n        isMulti\n        onSelect={onSetPerformers}\n        values={performers}\n        ageFromDate={date}\n      />\n    );\n\n    return renderField(\"performer_ids\", title, control, fullWidthProps);\n  }\n\n  function renderTagsField() {\n    const title = intl.formatMessage({ id: \"tags\" });\n    return renderField(\"tag_ids\", title, tagsControl(), fullWidthProps);\n  }\n\n  function renderDetailsField() {\n    const props = {\n      labelProps: {\n        column: true,\n        sm: 3,\n        lg: 12,\n      },\n      fieldProps: {\n        sm: 9,\n        lg: 12,\n      },\n    };\n\n    return renderInputField(\"details\", \"textarea\", \"details\", props);\n  }\n\n  function maybeRenderScrapeDialog() {\n    if (!scrapedImage) {\n      return;\n    }\n\n    const currentImage = {\n      id: image.id!,\n      ...formik.values,\n    };\n\n    return (\n      <ImageScrapeDialog\n        image={currentImage}\n        imageStudio={studio}\n        imageTags={tags}\n        imagePerformers={performers}\n        scraped={scrapedImage}\n        onClose={(data) => {\n          onScrapeDialogClosed(data);\n        }}\n      />\n    );\n  }\n\n  return (\n    <div id=\"image-edit-details\">\n      <Prompt\n        when={formik.dirty}\n        message={intl.formatMessage({ id: \"dialogs.unsaved_changes\" })}\n      />\n\n      {maybeRenderScrapeDialog()}\n      <Form noValidate onSubmit={formik.handleSubmit}>\n        <Row className=\"form-container edit-buttons-container px-3 pt-3\">\n          <div className=\"edit-buttons mb-3 pl-0\">\n            <Button\n              className=\"edit-button\"\n              variant=\"primary\"\n              disabled={\n                (!isNew && !formik.dirty) ||\n                !isEqual(formik.errors, {}) ||\n                customFieldsError !== undefined\n              }\n              onClick={() => formik.submitForm()}\n            >\n              <FormattedMessage id=\"actions.save\" />\n            </Button>\n            <Button\n              className=\"edit-button\"\n              variant=\"danger\"\n              onClick={() => onDelete()}\n            >\n              <FormattedMessage id=\"actions.delete\" />\n            </Button>\n          </div>\n          <div className=\"ml-auto text-right d-flex\">\n            {!isNew && (\n              <ScraperMenu\n                toggle={intl.formatMessage({ id: \"actions.scrape_with\" })}\n                scrapers={fragmentScrapers}\n                onScraperClicked={onScrapeClicked}\n                onReloadScrapers={onReloadScrapers}\n              />\n            )}\n          </div>\n        </Row>\n        <Row className=\"form-container px-3\">\n          <Col lg={7} xl={12}>\n            {renderInputField(\"title\")}\n            {renderInputField(\"code\", \"text\", \"scene_code\")}\n\n            {renderURLListField(\n              \"urls\",\n              onScrapeImageURL,\n              urlScrapable,\n              \"urls\",\n              urlProps\n            )}\n\n            {renderDateField(\"date\")}\n            {renderInputField(\"photographer\")}\n\n            {renderGalleriesField()}\n            {renderStudioField()}\n            {renderPerformersField()}\n            {renderTagsField()}\n          </Col>\n          <Col lg={5} xl={12}>\n            {renderDetailsField()}\n\n            <CustomFieldsInput\n              values={formik.values.custom_fields}\n              onChange={(v) => formik.setFieldValue(\"custom_fields\", v)}\n              error={customFieldsError}\n              setError={(e) => setCustomFieldsError(e)}\n            />\n          </Col>\n        </Row>\n      </Form>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Accordion, Button, Card } from \"react-bootstrap\";\nimport { FormattedMessage, FormattedTime, useIntl } from \"react-intl\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { DeleteFilesDialog } from \"src/components/Shared/DeleteFilesDialog\";\nimport { RevealInFilesystemButton } from \"src/components/Shared/RevealInFilesystemButton\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { mutateImageSetPrimaryFile } from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport TextUtils from \"src/utils/text\";\nimport { TextField, URLField, URLsField } from \"src/utils/field\";\nimport { FileSize } from \"src/components/Shared/FileSize\";\nimport NavUtils from \"src/utils/navigation\";\n\ninterface IFileInfoPanelProps {\n  file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment;\n  primary?: boolean;\n  ofMany?: boolean;\n  onSetPrimaryFile?: () => void;\n  onDeleteFile?: () => void;\n  loading?: boolean;\n}\n\nconst FileInfoPanel: React.FC<IFileInfoPanelProps> = (\n  props: IFileInfoPanelProps\n) => {\n  const intl = useIntl();\n  const checksum = props.file.fingerprints.find((f) => f.type === \"md5\");\n  const phash = props.file.fingerprints.find((f) => f.type === \"phash\");\n\n  return (\n    <div>\n      <dl className=\"container image-file-info details-list\">\n        {props.primary && (\n          <>\n            <dt></dt>\n            <dd className=\"primary-file\">\n              <FormattedMessage id=\"primary_file\" />\n            </dd>\n          </>\n        )}\n        <TextField id=\"media_info.md5\" value={checksum?.value} truncate />\n        <URLField\n          id=\"media_info.phash\"\n          abbr={intl.formatMessage({ id: \"media_info.phash_meaning\" })}\n          value={phash?.value}\n          url={NavUtils.makeImagesPHashMatchUrl(phash?.value)}\n          target=\"_self\"\n          truncate\n          internal\n        />\n        <TextField id=\"path\">\n          <span className=\"d-flex align-items-center\">\n            <TruncatedText text={props.file.path} />\n            <RevealInFilesystemButton fileId={props.file.id} />\n          </span>\n        </TextField>\n        <TextField id=\"filesize\">\n          <span className=\"text-truncate\">\n            <FileSize size={props.file.size} />\n          </span>\n        </TextField>\n        <TextField id=\"file_mod_time\">\n          <FormattedTime\n            dateStyle=\"medium\"\n            timeStyle=\"medium\"\n            value={props.file.mod_time ?? 0}\n          />\n        </TextField>\n        <TextField\n          id=\"dimensions\"\n          value={`${props.file.width} x ${props.file.height}`}\n          truncate\n        />\n      </dl>\n      {props.ofMany && props.onSetPrimaryFile && !props.primary && (\n        <div>\n          <Button\n            className=\"edit-button\"\n            disabled={props.loading}\n            onClick={props.onSetPrimaryFile}\n          >\n            <FormattedMessage id=\"actions.make_primary\" />\n          </Button>\n          <Button\n            variant=\"danger\"\n            disabled={props.loading}\n            onClick={props.onDeleteFile}\n          >\n            <FormattedMessage id=\"actions.delete_file\" />\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\ninterface IImageFileInfoPanelProps {\n  image: GQL.ImageDataFragment;\n}\n\nexport const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (\n  props: IImageFileInfoPanelProps\n) => {\n  const Toast = useToast();\n\n  const [loading, setLoading] = useState(false);\n  const [deletingFile, setDeletingFile] = useState<\n    GQL.ImageFileDataFragment | GQL.VideoFileDataFragment | undefined\n  >();\n\n  if (props.image.visual_files.length === 0) {\n    return <></>;\n  }\n\n  if (props.image.visual_files.length === 1) {\n    return (\n      <>\n        <dl className=\"container image-file-info details-list\">\n          <URLsField id=\"urls\" urls={props.image.urls} truncate />\n        </dl>\n\n        <FileInfoPanel file={props.image.visual_files[0]} />\n      </>\n    );\n  }\n\n  async function onSetPrimaryFile(fileID: string) {\n    try {\n      setLoading(true);\n      await mutateImageSetPrimaryFile(props.image.id, fileID);\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  return (\n    <Accordion defaultActiveKey={props.image.visual_files[0].id}>\n      {deletingFile && (\n        <DeleteFilesDialog\n          onClose={() => setDeletingFile(undefined)}\n          selected={[deletingFile]}\n        />\n      )}\n      {props.image.visual_files.map((file, index) => (\n        <Card key={file.id} className=\"image-file-card\">\n          <Accordion.Toggle as={Card.Header} eventKey={file.id}>\n            <TruncatedText text={TextUtils.fileNameFromPath(file.path)} />\n          </Accordion.Toggle>\n          <Accordion.Collapse eventKey={file.id}>\n            <Card.Body>\n              <FileInfoPanel\n                file={file}\n                primary={index === 0}\n                ofMany\n                onSetPrimaryFile={() => onSetPrimaryFile(file.id)}\n                onDeleteFile={() => setDeletingFile(file)}\n                loading={loading}\n              />\n            </Card.Body>\n          </Accordion.Collapse>\n        </Card>\n      ))}\n    </Accordion>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  ScrapedInputGroupRow,\n  ScrapedStringListRow,\n  ScrapedTextAreaRow,\n} from \"src/components/Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { ScrapeDialog } from \"src/components/Shared/ScrapeDialog/ScrapeDialog\";\nimport {\n  ObjectListScrapeResult,\n  ObjectScrapeResult,\n  ScrapeResult,\n} from \"src/components/Shared/ScrapeDialog/scrapeResult\";\nimport {\n  ScrapedPerformersRow,\n  ScrapedStudioRow,\n} from \"src/components/Shared/ScrapeDialog/ScrapedObjectsRow\";\nimport { sortStoredIdObjects } from \"src/utils/data\";\nimport { Performer } from \"src/components/Performers/PerformerSelect\";\nimport {\n  useCreateScrapedPerformer,\n  useCreateScrapedStudio,\n} from \"src/components/Shared/ScrapeDialog/createObjects\";\nimport { uniq } from \"lodash-es\";\nimport { Tag } from \"src/components/Tags/TagSelect\";\nimport { Studio } from \"src/components/Studios/StudioSelect\";\nimport { useScrapedTags } from \"src/components/Shared/ScrapeDialog/scrapedTags\";\n\ninterface IImageScrapeDialogProps {\n  image: Partial<GQL.ImageUpdateInput>;\n  imageStudio: Studio | null;\n  imageTags: Tag[];\n  imagePerformers: Performer[];\n  scraped: GQL.ScrapedImage;\n\n  onClose: (scrapedImage?: GQL.ScrapedImage) => void;\n}\n\nexport const ImageScrapeDialog: React.FC<IImageScrapeDialogProps> = ({\n  image,\n  imageStudio,\n  imageTags,\n  imagePerformers,\n  scraped,\n  onClose,\n}) => {\n  const intl = useIntl();\n  const [title, setTitle] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(image.title, scraped.title)\n  );\n  const [code, setCode] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(image.code, scraped.code)\n  );\n  const [urls, setURLs] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(\n      image.urls,\n      scraped.urls\n        ? uniq((image.urls ?? []).concat(scraped.urls ?? []))\n        : undefined\n    )\n  );\n  const [date, setDate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(image.date, scraped.date)\n  );\n\n  const [photographer, setPhotographer] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(image.photographer, scraped.photographer)\n  );\n\n  const [studio, setStudio] = useState<ObjectScrapeResult<GQL.ScrapedStudio>>(\n    new ObjectScrapeResult<GQL.ScrapedStudio>(\n      imageStudio\n        ? {\n            stored_id: imageStudio.id,\n            name: imageStudio.name,\n          }\n        : undefined,\n      scraped.studio?.stored_id ? scraped.studio : undefined\n    )\n  );\n  const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(\n    scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined\n  );\n\n  const [performers, setPerformers] = useState<\n    ObjectListScrapeResult<GQL.ScrapedPerformer>\n  >(\n    new ObjectListScrapeResult<GQL.ScrapedPerformer>(\n      sortStoredIdObjects(\n        imagePerformers.map((p) => ({\n          stored_id: p.id,\n          name: p.name,\n        }))\n      ),\n      sortStoredIdObjects(scraped.performers ?? undefined)\n    )\n  );\n  const [newPerformers, setNewPerformers] = useState<GQL.ScrapedPerformer[]>(\n    scraped.performers?.filter((t) => !t.stored_id) ?? []\n  );\n\n  const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(\n    imageTags,\n    scraped.tags\n  );\n\n  const [details, setDetails] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(image.details, scraped.details)\n  );\n\n  const createNewStudio = useCreateScrapedStudio({\n    scrapeResult: studio,\n    setScrapeResult: setStudio,\n    setNewObject: setNewStudio,\n  });\n\n  const createNewPerformer = useCreateScrapedPerformer({\n    scrapeResult: performers,\n    setScrapeResult: setPerformers,\n    newObjects: newPerformers,\n    setNewObjects: setNewPerformers,\n  });\n\n  // don't show the dialog if nothing was scraped\n  if (\n    [\n      title,\n      code,\n      urls,\n      date,\n      photographer,\n      studio,\n      performers,\n      tags,\n      details,\n    ].every((r) => !r.scraped) &&\n    !newStudio &&\n    newPerformers.length === 0 &&\n    newTags.length === 0\n  ) {\n    onClose();\n    return <></>;\n  }\n\n  function makeNewScrapedItem(): GQL.ScrapedImageDataFragment {\n    const newStudioValue = studio.getNewValue();\n\n    return {\n      title: title.getNewValue(),\n      code: code.getNewValue(),\n      urls: urls.getNewValue(),\n      date: date.getNewValue(),\n      photographer: photographer.getNewValue(),\n      studio: newStudioValue,\n      performers: performers.getNewValue(),\n      tags: tags.getNewValue(),\n      details: details.getNewValue(),\n    };\n  }\n\n  function renderScrapeRows() {\n    return (\n      <>\n        <ScrapedInputGroupRow\n          field=\"title\"\n          title={intl.formatMessage({ id: \"title\" })}\n          result={title}\n          onChange={(value) => setTitle(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"code\"\n          title={intl.formatMessage({ id: \"scene_code\" })}\n          result={code}\n          onChange={(value) => setCode(value)}\n        />\n        <ScrapedStringListRow\n          field=\"urls\"\n          title={intl.formatMessage({ id: \"urls\" })}\n          result={urls}\n          onChange={(value) => setURLs(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"date\"\n          title={intl.formatMessage({ id: \"date\" })}\n          placeholder=\"YYYY-MM-DD\"\n          result={date}\n          onChange={(value) => setDate(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"photographer\"\n          title={intl.formatMessage({ id: \"photographer\" })}\n          result={photographer}\n          onChange={(value) => setPhotographer(value)}\n        />\n        <ScrapedStudioRow\n          field=\"studio\"\n          title={intl.formatMessage({ id: \"studios\" })}\n          result={studio}\n          onChange={(value) => setStudio(value)}\n          newStudio={newStudio}\n          onCreateNew={createNewStudio}\n        />\n        <ScrapedPerformersRow\n          field=\"performers\"\n          title={intl.formatMessage({ id: \"performers\" })}\n          result={performers}\n          onChange={(value) => setPerformers(value)}\n          newObjects={newPerformers}\n          onCreateNew={createNewPerformer}\n        />\n        {scrapedTagsRow}\n        <ScrapedTextAreaRow\n          field=\"details\"\n          title={intl.formatMessage({ id: \"details\" })}\n          result={details}\n          onChange={(value) => setDetails(value)}\n        />\n      </>\n    );\n  }\n\n  if (linkDialog) {\n    return linkDialog;\n  }\n\n  return (\n    <ScrapeDialog\n      title={intl.formatMessage(\n        { id: \"dialogs.scrape_entity_title\" },\n        { entity_type: intl.formatMessage({ id: \"image\" }) }\n      )}\n      onClose={(apply) => {\n        onClose(apply ? makeNewScrapedItem() : undefined);\n      }}\n    >\n      {renderScrapeRows()}\n    </ScrapeDialog>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageList.tsx",
    "content": "import React, {\n  useCallback,\n  useState,\n  useMemo,\n  MouseEvent,\n  useEffect,\n} from \"react\";\nimport { FormattedMessage, FormattedNumber, useIntl } from \"react-intl\";\nimport cloneDeep from \"lodash-es/cloneDeep\";\nimport { useHistory } from \"react-router-dom\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindImages,\n  useFindImages,\n  useFindImagesMetadata,\n} from \"src/core/StashService\";\nimport { useFilteredItemList } from \"../List/ItemList\";\nimport { useLightbox } from \"src/hooks/Lightbox/hooks\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { ImageWallItem } from \"./ImageWallItem\";\nimport { EditImagesDialog } from \"./EditImagesDialog\";\nimport { DeleteImagesDialog } from \"./DeleteImagesDialog\";\nimport \"flexbin/flexbin.css\";\nimport Gallery, { RenderImageProps } from \"react-photo-gallery\";\nimport { ExportDialog } from \"../Shared/ExportDialog\";\nimport { objectTitle } from \"src/core/files\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { ImageCardGrid } from \"./ImageCardGrid\";\nimport { View } from \"../List/views\";\nimport {\n  FilteredListToolbar,\n  IItemListOperation,\n} from \"../List/FilteredListToolbar\";\nimport { FileSize } from \"../Shared/FileSize\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport { GenerateDialog } from \"../Dialogs/GenerateDialog\";\nimport {\n  Sidebar,\n  SidebarPane,\n  SidebarPaneContent,\n  SidebarStateContext,\n  useSidebarState,\n} from \"../Shared/Sidebar\";\nimport { useCloseEditDelete, useFilterOperations } from \"../List/util\";\nimport {\n  FilteredSidebarHeader,\n  useFilteredSidebarKeybinds,\n} from \"../List/Filters/FilterSidebar\";\nimport {\n  IListFilterOperation,\n  ListOperations,\n} from \"../List/ListOperationButtons\";\nimport { FilterTags } from \"../List/FilterTags\";\nimport { Pagination, PaginationIndex } from \"../List/Pagination\";\nimport { LoadedContent } from \"../List/PagedList\";\nimport useFocus from \"src/utils/focus\";\nimport cx from \"classnames\";\nimport { SidebarStudiosFilter } from \"../List/Filters/StudiosFilter\";\nimport { SidebarPerformersFilter } from \"../List/Filters/PerformersFilter\";\nimport { SidebarTagsFilter } from \"../List/Filters/TagsFilter\";\nimport { SidebarRatingFilter } from \"../List/Filters/RatingFilter\";\nimport { SidebarBooleanFilter } from \"../List/Filters/BooleanFilter\";\nimport { Button } from \"react-bootstrap\";\nimport { OrganizedCriterionOption } from \"src/models/list-filter/criteria/organized\";\nimport { SidebarAgeFilter } from \"../List/Filters/SidebarAgeFilter\";\nimport { PerformerAgeCriterionOption } from \"src/models/list-filter/images\";\nimport { SidebarFolderFilter } from \"../List/Filters/FolderFilter\";\n\ninterface IImageWallProps {\n  images: GQL.SlimImageDataFragment[];\n  onChangePage: (page: number) => void;\n  currentPage: number;\n  pageCount: number;\n  handleImageOpen: (index: number) => void;\n  zoomIndex: number;\n  selectedIds?: Set<string>;\n  onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;\n  selecting?: boolean;\n}\n\nconst zoomWidths = [280, 340, 480, 640];\nconst breakpointZoomHeights = [\n  { minWidth: 576, heights: [100, 120, 240, 360] },\n  { minWidth: 768, heights: [120, 160, 240, 480] },\n  { minWidth: 1200, heights: [120, 160, 240, 300] },\n  { minWidth: 1400, heights: [160, 240, 300, 480] },\n];\n\nconst ImageWall: React.FC<IImageWallProps> = ({\n  images,\n  zoomIndex,\n  handleImageOpen,\n  selectedIds,\n  onSelectChange,\n  selecting,\n}) => {\n  const { configuration } = useConfigurationContext();\n  const uiConfig = configuration?.ui;\n\n  const containerRef = React.useRef<HTMLDivElement>(null);\n\n  let photos: {\n    src: string;\n    srcSet?: string | string[] | undefined;\n    sizes?: string | string[] | undefined;\n    width: number;\n    height: number;\n    alt?: string | undefined;\n    key?: string | undefined;\n  }[] = [];\n\n  images.forEach((image, index) => {\n    let imageData = {\n      src:\n        image.paths.preview != \"\"\n          ? image.paths.preview!\n          : image.paths.thumbnail!,\n      width: image.visual_files?.[0]?.width ?? 0,\n      height: image.visual_files?.[0]?.height ?? 0,\n      tabIndex: index,\n      key: image.id,\n      loading: \"lazy\",\n      className: \"gallery-image\",\n      alt: objectTitle(image),\n    };\n    photos.push(imageData);\n  });\n\n  const showLightboxOnClick = useCallback(\n    (event, { index }) => {\n      handleImageOpen(index);\n    },\n    [handleImageOpen]\n  );\n\n  function columns(containerWidth: number) {\n    let preferredSize = zoomWidths[zoomIndex];\n    let columnCount = containerWidth / preferredSize;\n    return Math.round(columnCount);\n  }\n\n  const targetRowHeight = useCallback(\n    (containerWidth: number) => {\n      let zoomHeight = 280;\n      breakpointZoomHeights.forEach((e) => {\n        if (containerWidth >= e.minWidth) {\n          zoomHeight = e.heights[zoomIndex];\n        }\n      });\n      return zoomHeight;\n    },\n    [zoomIndex]\n  );\n\n  // set the max height as a factor of the targetRowHeight\n  // this allows some images to be taller than the target row height\n  // but prevents images from becoming too tall when there is a small number of items\n  const maxHeightFactor = 1.3;\n\n  const renderImage = useCallback(\n    (props: RenderImageProps) => {\n      // #6165 - only use targetRowHeight in row direction\n      const maxHeight =\n        props.direction === \"column\"\n          ? props.photo.height\n          : targetRowHeight(containerRef.current?.offsetWidth ?? 0) *\n            maxHeightFactor;\n      const imageId = props.photo.key;\n      if (!imageId) {\n        return null;\n      }\n      return (\n        <ImageWallItem\n          {...props}\n          maxHeight={maxHeight}\n          selected={selectedIds?.has(imageId)}\n          onSelectedChanged={\n            onSelectChange\n              ? (selected, shiftKey) =>\n                  onSelectChange(imageId, selected, shiftKey)\n              : undefined\n          }\n          selecting={selecting}\n        />\n      );\n    },\n    [targetRowHeight, selectedIds, onSelectChange, selecting]\n  );\n\n  return (\n    <div className=\"gallery\" ref={containerRef}>\n      {photos.length ? (\n        <Gallery\n          photos={photos}\n          renderImage={renderImage}\n          onClick={showLightboxOnClick}\n          margin={uiConfig?.imageWallOptions?.margin!}\n          direction={uiConfig?.imageWallOptions?.direction!}\n          columns={columns}\n          targetRowHeight={targetRowHeight}\n        />\n      ) : null}\n    </div>\n  );\n};\n\ninterface IImageListImages {\n  images: GQL.SlimImageDataFragment[];\n  filter: ListFilterModel;\n  selectedIds: Set<string>;\n  onChangePage: (page: number) => void;\n  pageCount: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  slideshowRunning: boolean;\n  setSlideshowRunning: (running: boolean) => void;\n  chapters?: GQL.GalleryChapterDataFragment[];\n}\n\nconst ImageList: React.FC<IImageListImages> = PatchComponent(\n  \"ImageList\",\n  ({\n    images,\n    filter,\n    selectedIds,\n    onChangePage,\n    pageCount,\n    onSelectChange,\n    slideshowRunning,\n    setSlideshowRunning,\n    chapters = [],\n  }) => {\n    const handleLightBoxPage = useCallback(\n      (props: { direction?: number; page?: number }) => {\n        const { direction, page: newPage } = props;\n\n        if (direction !== undefined) {\n          if (direction < 0) {\n            if (filter.currentPage === 1) {\n              onChangePage(pageCount);\n            } else {\n              onChangePage(filter.currentPage + direction);\n            }\n          } else if (direction > 0) {\n            if (filter.currentPage === pageCount) {\n              // return to the first page\n              onChangePage(1);\n            } else {\n              onChangePage(filter.currentPage + direction);\n            }\n          }\n        } else if (newPage !== undefined) {\n          onChangePage(newPage);\n        }\n      },\n      [onChangePage, filter.currentPage, pageCount]\n    );\n\n    const handleClose = useCallback(() => {\n      setSlideshowRunning(false);\n    }, [setSlideshowRunning]);\n\n    const lightboxState = useMemo(() => {\n      return {\n        images,\n        showNavigation: false,\n        pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,\n        page: filter.currentPage,\n        pages: pageCount,\n        pageSize: filter.itemsPerPage,\n        slideshowEnabled: slideshowRunning,\n        onClose: handleClose,\n      };\n    }, [\n      images,\n      pageCount,\n      filter.currentPage,\n      filter.itemsPerPage,\n      slideshowRunning,\n      handleClose,\n      handleLightBoxPage,\n    ]);\n\n    const showLightbox = useLightbox(\n      lightboxState,\n      filter.sortBy === \"path\" &&\n        filter.sortDirection === GQL.SortDirectionEnum.Asc\n        ? chapters\n        : []\n    );\n\n    const handleImageOpen = useCallback(\n      (index) => {\n        setSlideshowRunning(true);\n        showLightbox({ initialIndex: index, slideshowEnabled: true });\n      },\n      [showLightbox, setSlideshowRunning]\n    );\n\n    function onPreview(index: number, ev: MouseEvent) {\n      handleImageOpen(index);\n      ev.preventDefault();\n    }\n\n    if (filter.displayMode === DisplayMode.Grid) {\n      return (\n        <ImageCardGrid\n          images={images}\n          selectedIds={selectedIds}\n          zoomIndex={filter.zoomIndex}\n          onSelectChange={onSelectChange}\n          onPreview={onPreview}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.Wall) {\n      return (\n        <ImageWall\n          images={images}\n          onChangePage={onChangePage}\n          currentPage={filter.currentPage}\n          pageCount={pageCount}\n          handleImageOpen={handleImageOpen}\n          zoomIndex={filter.zoomIndex}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n          selecting={!!selectedIds && selectedIds.size > 0}\n        />\n      );\n    }\n\n    // should not happen\n    return <></>;\n  }\n);\n\nfunction renderMetadataByline(\n  metadataInfo: GQL.FindImagesMetadataQueryResult | undefined\n) {\n  const megapixels = metadataInfo?.data?.findImages?.megapixels;\n  const size = metadataInfo?.data?.findImages?.filesize;\n\n  if (metadataInfo?.loading) {\n    // return ellipsis\n    return <span className=\"images-stats\">&nbsp;(...)</span>;\n  }\n\n  if (!megapixels && !size) {\n    return;\n  }\n\n  const separator = megapixels && size ? \" - \" : \"\";\n\n  return (\n    <span className=\"images-stats\">\n      &nbsp;(\n      {megapixels ? (\n        <span className=\"images-megapixels\">\n          <FormattedNumber value={megapixels} /> Megapixels\n        </span>\n      ) : undefined}\n      {separator}\n      {size ? (\n        <span className=\"images-size\">\n          <FileSize size={size} />\n        </span>\n      ) : undefined}\n      )\n    </span>\n  );\n}\n\nconst ImageFilterSidebarSections = PatchContainerComponent(\n  \"FilteredImageList.SidebarSections\"\n);\n\nconst SidebarContent: React.FC<{\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  sidebarOpen: boolean;\n  onClose?: () => void;\n  showEditFilter: (editingCriterion?: string) => void;\n  count?: number;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  filter,\n  setFilter,\n  filterHook,\n  view,\n  showEditFilter,\n  sidebarOpen,\n  onClose,\n  count,\n  focus,\n}) => {\n  const showResultsId =\n    count !== undefined ? \"actions.show_count_results\" : \"actions.show_results\";\n\n  const hideStudios = view === View.StudioScenes;\n\n  return (\n    <>\n      <FilteredSidebarHeader\n        sidebarOpen={sidebarOpen}\n        showEditFilter={showEditFilter}\n        filter={filter}\n        setFilter={setFilter}\n        view={view}\n        focus={focus}\n      />\n\n      <ImageFilterSidebarSections>\n        {!hideStudios && (\n          <SidebarStudiosFilter\n            filter={filter}\n            setFilter={setFilter}\n            filterHook={filterHook}\n          />\n        )}\n        <SidebarPerformersFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarTagsFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarRatingFilter filter={filter} setFilter={setFilter} />\n        <SidebarFolderFilter\n          text={<FormattedMessage id=\"folder\" />}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"folder\"\n        />\n        <SidebarBooleanFilter\n          title={<FormattedMessage id=\"organized\" />}\n          data-type={OrganizedCriterionOption.type}\n          option={OrganizedCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"organized\"\n        />\n        <SidebarAgeFilter\n          title={<FormattedMessage id=\"performer_age\" />}\n          option={PerformerAgeCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"performer_age\"\n        />\n      </ImageFilterSidebarSections>\n\n      <div className=\"sidebar-footer\">\n        <Button className=\"sidebar-close-button\" onClick={onClose}>\n          <FormattedMessage id={showResultsId} values={{ count }} />\n        </Button>\n      </div>\n    </>\n  );\n};\n\nfunction useViewRandom(filter: ListFilterModel, count: number) {\n  const history = useHistory();\n\n  const viewRandom = useCallback(async () => {\n    // query for a random image\n    if (count === 0) {\n      return;\n    }\n\n    const index = Math.floor(Math.random() * count);\n    const filterCopy = cloneDeep(filter);\n    filterCopy.itemsPerPage = 1;\n    filterCopy.currentPage = index + 1;\n    const singleResult = await queryFindImages(filterCopy);\n    if (singleResult.data.findImages.images.length === 1) {\n      const { id } = singleResult.data.findImages.images[0];\n      // navigate to the image player page\n      history.push(`/images/${id}`);\n    }\n  }, [history, filter, count]);\n\n  return viewRandom;\n}\n\nfunction useAddKeybinds(filter: ListFilterModel, count: number) {\n  const viewRandom = useViewRandom(filter, count);\n\n  useEffect(() => {\n    Mousetrap.bind(\"p r\", () => {\n      viewRandom();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"p r\");\n    };\n  }, [viewRandom]);\n}\n\ninterface IImageList {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  alterQuery?: boolean;\n  extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];\n  chapters?: GQL.GalleryChapterDataFragment[];\n}\n\nexport const FilteredImageList = PatchComponent(\n  \"FilteredImageList\",\n  (props: IImageList) => {\n    const intl = useIntl();\n\n    const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);\n\n    const searchFocus = useFocus();\n\n    const withSidebar = props.view !== View.GalleryImages;\n\n    const {\n      filterHook,\n      view,\n      alterQuery,\n      extraOperations: providedOperations = [],\n      chapters,\n    } = props;\n\n    // States\n    const {\n      showSidebar,\n      setShowSidebar,\n      sectionOpen,\n      setSectionOpen,\n      loading: sidebarStateLoading,\n    } = useSidebarState(view);\n\n    const {\n      filterState,\n      queryResult,\n      metadataInfo,\n      modalState,\n      listSelect,\n      showEditFilter,\n    } = useFilteredItemList({\n      filterStateProps: {\n        filterMode: GQL.FilterMode.Images,\n        view,\n        useURL: alterQuery,\n      },\n      queryResultProps: {\n        useResult: useFindImages,\n        useMetadataInfo: useFindImagesMetadata,\n        getCount: (r) => r.data?.findImages.count ?? 0,\n        getItems: (r) => r.data?.findImages.images ?? [],\n        filterHook,\n      },\n    });\n\n    const { filter, setFilter } = filterState;\n\n    const { effectiveFilter, result, cachedResult, items, totalCount } =\n      queryResult;\n\n    const metadataByline = useMemo(() => {\n      if (cachedResult.loading) return null;\n\n      return renderMetadataByline(metadataInfo) ?? null;\n    }, [cachedResult.loading, metadataInfo]);\n\n    const {\n      selectedIds,\n      selectedItems,\n      onSelectChange,\n      onSelectAll,\n      onSelectNone,\n      onInvertSelection,\n      hasSelection,\n    } = listSelect;\n\n    const { modal, showModal, closeModal } = modalState;\n\n    // Utility hooks\n    const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({\n      filter,\n      setFilter,\n    });\n\n    useAddKeybinds(filter, totalCount);\n    useFilteredSidebarKeybinds({\n      showSidebar,\n      setShowSidebar,\n    });\n\n    const onCloseEditDelete = useCloseEditDelete({\n      closeModal,\n      onSelectNone,\n      result,\n    });\n\n    const viewRandom = useViewRandom(effectiveFilter, totalCount);\n\n    function onExport(all: boolean) {\n      showModal(\n        <ExportDialog\n          exportInput={{\n            images: {\n              ids: Array.from(selectedIds.values()),\n              all: all,\n            },\n          }}\n          onClose={() => closeModal()}\n        />\n      );\n    }\n\n    const onEdit = useCallback(() => {\n      showModal(\n        <EditImagesDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }, [showModal, selectedItems, onCloseEditDelete]);\n\n    const onDelete = useCallback(() => {\n      showModal(\n        <DeleteImagesDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }, [showModal, selectedItems, onCloseEditDelete]);\n\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => {\n        if (hasSelection) {\n          onEdit?.();\n        }\n      });\n\n      Mousetrap.bind(\"d d\", () => {\n        if (hasSelection) {\n          onDelete?.();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"d d\");\n      };\n    }, [hasSelection, onEdit, onDelete]);\n\n    const convertedExtraOperations: IListFilterOperation[] =\n      providedOperations.map((o) => ({\n        ...o,\n        isDisplayed: o.isDisplayed\n          ? () => o.isDisplayed!(result, filter, selectedIds)\n          : undefined,\n        onClick: () => {\n          o.onClick(result, filter, selectedIds);\n        },\n      }));\n\n    const otherOperations: IListFilterOperation[] = [\n      ...convertedExtraOperations,\n      {\n        text: intl.formatMessage({ id: \"actions.select_all\" }),\n        onClick: () => onSelectAll(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_none\" }),\n        onClick: () => onSelectNone(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.invert_selection\" }),\n        onClick: () => onInvertSelection(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.view_random\" }),\n        onClick: viewRandom,\n      },\n      {\n        text: `${intl.formatMessage({ id: \"actions.generate\" })}…`,\n        onClick: () => {\n          showModal(\n            <GenerateDialog\n              type=\"image\"\n              selectedIds={Array.from(selectedIds.values())}\n              onClose={() => closeModal()}\n            />\n          );\n        },\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export\" }),\n        onClick: () => onExport(false),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export_all\" }),\n        onClick: () => onExport(true),\n      },\n    ];\n\n    // render\n    if (sidebarStateLoading) return null;\n\n    const operations = (\n      <ListOperations\n        items={items.length}\n        hasSelection={hasSelection}\n        operations={otherOperations}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        operationsMenuClassName=\"image-list-operations-dropdown\"\n      />\n    );\n\n    const pageCount = Math.ceil(totalCount / filter.itemsPerPage);\n\n    const content = (\n      <>\n        <FilteredListToolbar\n          filter={filter}\n          listSelect={listSelect}\n          setFilter={setFilter}\n          showEditFilter={showEditFilter}\n          onDelete={onDelete}\n          onEdit={onEdit}\n          operationComponent={operations}\n          view={view}\n          zoomable\n        />\n\n        <FilterTags\n          criteria={filter.criteria}\n          onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}\n          onRemoveCriterion={removeCriterion}\n          onRemoveAll={clearAllCriteria}\n        />\n\n        <div className=\"pagination-index-container\">\n          <Pagination\n            currentPage={filter.currentPage}\n            itemsPerPage={filter.itemsPerPage}\n            totalItems={totalCount}\n            onChangePage={setPage}\n          />\n          <PaginationIndex\n            loading={cachedResult.loading}\n            itemsPerPage={filter.itemsPerPage}\n            currentPage={filter.currentPage}\n            totalItems={totalCount}\n            metadataByline={metadataByline}\n          />\n        </div>\n\n        <LoadedContent loading={result.loading} error={result.error}>\n          <ImageList\n            filter={filter}\n            images={items}\n            onChangePage={setPage}\n            onSelectChange={onSelectChange}\n            pageCount={pageCount}\n            selectedIds={selectedIds}\n            slideshowRunning={slideshowRunning}\n            setSlideshowRunning={setSlideshowRunning}\n            chapters={chapters}\n          />\n        </LoadedContent>\n\n        {totalCount > filter.itemsPerPage && (\n          <div className=\"pagination-footer-container\">\n            <div className=\"pagination-footer\">\n              <Pagination\n                itemsPerPage={filter.itemsPerPage}\n                currentPage={filter.currentPage}\n                totalItems={totalCount}\n                onChangePage={setPage}\n                pagePopupPlacement=\"top\"\n                metadataByline={metadataByline}\n              />\n            </div>\n          </div>\n        )}\n      </>\n    );\n\n    return (\n      <>\n        {modal}\n        {!withSidebar ? (\n          <div className=\"item-list-container image-list\">{content}</div>\n        ) : (\n          <div\n            className={cx(\"item-list-container image-list\", {\n              \"hide-sidebar\": !showSidebar,\n            })}\n          >\n            <SidebarStateContext.Provider\n              value={{ sectionOpen, setSectionOpen }}\n            >\n              <SidebarPane hideSidebar={!showSidebar}>\n                <Sidebar\n                  hide={!showSidebar}\n                  onHide={() => setShowSidebar(false)}\n                >\n                  <SidebarContent\n                    filter={filter}\n                    setFilter={setFilter}\n                    filterHook={filterHook}\n                    showEditFilter={showEditFilter}\n                    view={view}\n                    sidebarOpen={showSidebar}\n                    onClose={() => setShowSidebar(false)}\n                    count={cachedResult.loading ? undefined : totalCount}\n                    focus={searchFocus}\n                  />\n                </Sidebar>\n                <SidebarPaneContent\n                  onSidebarToggle={() => setShowSidebar(!showSidebar)}\n                >\n                  {content}\n                </SidebarPaneContent>\n              </SidebarPane>\n            </SidebarStateContext.Provider>\n          </div>\n        )}\n      </>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageRecommendationRow.tsx",
    "content": "import React from \"react\";\nimport { useFindImages } from \"src/core/StashService\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { ImageCard } from \"./ImageCard\";\nimport { PatchComponent } from \"src/patch\";\nimport { FilteredRecommendationRow } from \"../FrontPage/FilteredRecommendationRow\";\n\ninterface IProps {\n  isTouch: boolean;\n  filter: ListFilterModel;\n  header: string;\n}\n\nexport const ImageRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"ImageRecommendationRow\",\n  (props: IProps) => {\n    const result = useFindImages(props.filter);\n    const count = result.data?.findImages.count ?? 0;\n\n    return (\n      <FilteredRecommendationRow\n        className=\"images-recommendations\"\n        heading={props.header}\n        url={`/images?${props.filter.makeQueryParameters()}`}\n        count={count}\n        loading={result.loading}\n        isTouch={props.isTouch}\n        filter={props.filter}\n      >\n        {result.loading\n          ? [...Array(props.filter.itemsPerPage)].map((i) => (\n              <div key={`_${i}`} className=\"image-skeleton skeleton-card\"></div>\n            ))\n          : result.data?.findImages.images.map((i) => (\n              <ImageCard key={i.id} image={i} zoomIndex={1} />\n            ))}\n      </FilteredRecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/ImageWallItem.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport type { RenderImageProps } from \"react-photo-gallery\";\nimport { useDragMoveSelect } from \"../Shared/GridCard/dragMoveSelect\";\n\ninterface IExtraProps {\n  maxHeight: number;\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  selecting?: boolean;\n}\n\nexport const ImageWallItem: React.FC<RenderImageProps & IExtraProps> = (\n  props: RenderImageProps & IExtraProps\n) => {\n  const { dragProps } = useDragMoveSelect({\n    selecting: props.selecting || false,\n    selected: props.selected || false,\n    onSelectedChanged: props.onSelectedChanged,\n  });\n\n  const height = Math.min(props.maxHeight, props.photo.height);\n  const zoomFactor = height / props.photo.height;\n  const width = props.photo.width * zoomFactor;\n\n  type style = Record<string, string | number | undefined>;\n  var divStyle: style = {\n    margin: props.margin,\n    display: \"block\",\n    position: \"relative\",\n  };\n\n  if (props.direction === \"column\") {\n    divStyle.position = \"absolute\";\n    divStyle.left = props.left;\n    divStyle.top = props.top;\n  }\n\n  var handleClick = function handleClick(\n    event: React.MouseEvent<Element, MouseEvent>\n  ) {\n    if (props.selecting && props.onSelectedChanged) {\n      props.onSelectedChanged(!props.selected, event.shiftKey);\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    if (props.onClick) {\n      props.onClick(event, { index: props.index });\n    }\n  };\n\n  const video = props.photo.src.includes(\"preview\");\n  const ImagePreview = video ? \"video\" : \"img\";\n\n  let shiftKey = false;\n\n  return (\n    <div\n      className=\"wall-item\"\n      style={divStyle}\n      onClick={handleClick}\n      {...dragProps}\n    >\n      {props.onSelectedChanged && (\n        <Form.Control\n          type=\"checkbox\"\n          className=\"wall-item-check mousetrap\"\n          checked={props.selected}\n          onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}\n          onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {\n            shiftKey = event.shiftKey;\n            event.stopPropagation();\n          }}\n        />\n      )}\n      <ImagePreview\n        loop={video}\n        muted={video}\n        playsInline={video}\n        autoPlay={video}\n        key={props.photo.key}\n        src={props.photo.src}\n        width={width}\n        height={height}\n        alt={props.photo.alt}\n        onClick={handleClick}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/Images.tsx",
    "content": "import React from \"react\";\nimport { Route, Switch } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport { useTitleProps } from \"src/hooks/title\";\nimport Image from \"./ImageDetails/Image\";\nimport { FilteredImageList } from \"./ImageList\";\nimport { View } from \"../List/views\";\n\nconst Images: React.FC = () => {\n  return <FilteredImageList view={View.Images} />;\n};\n\nconst ImageRoutes: React.FC = () => {\n  const titleProps = useTitleProps({ id: \"images\" });\n  return (\n    <>\n      <Helmet {...titleProps} />\n      <Switch>\n        <Route exact path=\"/images\" component={Images} />\n        <Route path=\"/images/:id\" component={Image} />\n      </Switch>\n    </>\n  );\n};\n\nexport default ImageRoutes;\n"
  },
  {
    "path": "ui/v2.5/src/components/Images/styles.scss",
    "content": "@include media-breakpoint-only(lg) {\n  .image-header-container {\n    align-items: center;\n    display: flex;\n    justify-content: space-between;\n\n    .image-header {\n      flex: 0 0 75%;\n      order: 1;\n    }\n\n    .image-studio-image {\n      flex: 0 0 25%;\n      order: 2;\n    }\n  }\n}\n\n.image-header {\n  flex-basis: auto;\n  font-size: 1.5rem;\n  margin-top: 30px;\n\n  @include media-breakpoint-down(xl) {\n    font-size: 1.75rem;\n  }\n}\n\n.image-subheader {\n  display: flex;\n  justify-content: space-between;\n  margin-top: 0.5rem;\n\n  .date {\n    color: $text-muted;\n  }\n\n  .resolution {\n    font-weight: bold;\n  }\n}\n\n.image-toolbar {\n  align-items: center;\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 0.25rem;\n  margin-top: 0.5rem;\n  padding-bottom: 0.25rem;\n  width: 100%;\n\n  .image-toolbar-group {\n    align-items: center;\n    column-gap: 0.25rem;\n    display: flex;\n    width: 100%;\n\n    &:last-child {\n      justify-content: flex-end;\n    }\n  }\n}\n\n#image-details-container {\n  .tab-content {\n    min-height: 15rem;\n  }\n\n  .image-description {\n    width: 100%;\n  }\n}\n\n.image-card {\n  &.card {\n    overflow: hidden;\n    padding: 0;\n\n    @media (max-width: 576px) {\n      width: 100%;\n    }\n  }\n\n  .rating-banner {\n    transition: opacity 0.5s;\n  }\n\n  &-preview {\n    align-items: center;\n    display: flex;\n    justify-content: center;\n    margin-bottom: 5px;\n    position: relative;\n\n    &-image {\n      height: 100%;\n      object-fit: contain;\n      width: 100%;\n    }\n\n    &.portrait {\n      .image-card-preview-image {\n        object-fit: contain;\n      }\n    }\n  }\n\n  &:hover {\n    .rating-banner {\n      opacity: 0;\n      transition: opacity 0.5s;\n    }\n  }\n}\n\n.image-tabs {\n  max-height: calc(100vh - 4rem);\n\n  overflow-wrap: break-word;\n  word-wrap: break-word;\n}\n\n$imageTabWidth: 450px;\n\n@media (min-width: 1200px) {\n  .image-tabs {\n    flex: 0 0 $imageTabWidth;\n    max-width: $imageTabWidth;\n    overflow: auto;\n  }\n\n  .image-container {\n    flex: 0 0 calc(100% - #{$imageTabWidth});\n    max-width: calc(100% - #{$imageTabWidth});\n  }\n}\n\n.image-tabs,\n.image-container {\n  padding-left: 15px;\n  padding-right: 15px;\n  position: relative;\n  width: 100%;\n}\n\n.image-container {\n  display: flex;\n\n  img {\n    max-height: calc(100vh - 4rem);\n    max-width: 100%;\n    object-fit: contain;\n  }\n}\n\n@media (min-width: 1200px) {\n  .image-container {\n    height: calc(100vh - 4rem);\n  }\n}\n@media (min-width: 1200px), (max-width: 575px) {\n  .image-performers {\n    .performer-card {\n      width: 15rem;\n\n      &-image {\n        height: 22.5rem;\n      }\n    }\n  }\n}\n\n#image-edit-details {\n  .rating-stars {\n    font-size: 1.3em;\n    height: calc(1.5em + 0.75rem + 2px);\n  }\n\n  .form-group[data-field=\"urls\"] .string-list-input input.form-control {\n    font-size: 0.85em;\n  }\n\n  @include media-breakpoint-up(xl) {\n    .custom-fields-input {\n      .custom-fields-field {\n        flex: 0 0 25%;\n        max-width: 25%;\n      }\n\n      .custom-fields-value {\n        flex: 0 0 75%;\n        max-width: 75%;\n      }\n    }\n  }\n}\n\n.image-file-card.card {\n  margin: 0;\n  padding: 0;\n\n  .card-header {\n    cursor: pointer;\n  }\n\n  dl {\n    margin-bottom: 0;\n  }\n}\n\n.col-form-label {\n  padding-right: 2px;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/List/CriterionEditor.tsx",
    "content": "import cloneDeep from \"lodash-es/cloneDeep\";\nimport React, { useCallback, useMemo } from \"react\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\nimport {\n  DurationCriterion,\n  CriterionValue,\n  ModifierCriterion,\n  IHierarchicalLabeledIdCriterion,\n  NumberCriterion,\n  ILabeledIdCriterion,\n  DateCriterion,\n  TimestampCriterion,\n  BooleanCriterion,\n  Criterion,\n} from \"src/models/list-filter/criteria/criterion\";\nimport {\n  criterionIsHierarchicalLabelValue,\n  criterionIsNumberValue,\n  criterionIsStashIDValue,\n  criterionIsDateValue,\n  criterionIsTimestampValue,\n} from \"src/models/list-filter/types\";\nimport { DurationFilter } from \"./Filters/DurationFilter\";\nimport { NumberFilter } from \"./Filters/NumberFilter\";\nimport { LabeledIdFilter } from \"./Filters/LabeledIdFilter\";\nimport { HierarchicalLabelValueFilter } from \"./Filters/HierarchicalLabelValueFilter\";\nimport { InputFilter } from \"./Filters/InputFilter\";\nimport { DateFilter } from \"./Filters/DateFilter\";\nimport { TimestampFilter } from \"./Filters/TimestampFilter\";\nimport { CountryCriterion } from \"src/models/list-filter/criteria/country\";\nimport { CountrySelect } from \"../Shared/CountrySelect\";\nimport { StashIDCriterion } from \"src/models/list-filter/criteria/stash-ids\";\nimport { StashIDFilter } from \"./Filters/StashIDFilter\";\nimport { RatingCriterion } from \"../../models/list-filter/criteria/rating\";\nimport { RatingFilter } from \"./Filters/RatingFilter\";\nimport { BooleanFilter } from \"./Filters/BooleanFilter\";\nimport { OptionFilter, OptionListFilter } from \"./Filters/OptionFilter\";\nimport { PathFilter } from \"./Filters/PathFilter\";\nimport { PerformersCriterion } from \"src/models/list-filter/criteria/performers\";\nimport PerformersFilter from \"./Filters/PerformersFilter\";\nimport { StudiosCriterion } from \"src/models/list-filter/criteria/studios\";\nimport StudiosFilter from \"./Filters/StudiosFilter\";\nimport { TagsCriterion } from \"src/models/list-filter/criteria/tags\";\nimport TagsFilter from \"./Filters/TagsFilter\";\nimport {\n  PhashCriterion,\n  DuplicatedCriterion,\n} from \"src/models/list-filter/criteria/phash\";\nimport { PhashFilter } from \"./Filters/PhashFilter\";\nimport { DuplicatedFilter } from \"./Filters/DuplicateFilter\";\nimport { PathCriterion } from \"src/models/list-filter/criteria/path\";\nimport { ModifierSelectorButtons } from \"./ModifierSelect\";\nimport { CustomFieldsCriterion } from \"src/models/list-filter/criteria/custom-fields\";\nimport { CustomFieldsFilter } from \"./Filters/CustomFieldsFilter\";\nimport { FolderFilter } from \"./Filters/FolderFilter\";\nimport {\n  FolderCriterion,\n  ParentFolderCriterion,\n} from \"src/models/list-filter/criteria/folder\";\n\ninterface IGenericCriterionEditor {\n  criterion: ModifierCriterion<CriterionValue>;\n  setCriterion: (c: ModifierCriterion<CriterionValue>) => void;\n}\n\nconst GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({\n  criterion,\n  setCriterion,\n}) => {\n  const { options, modifierOptions } = criterion.modifierCriterionOption();\n\n  const showModifierSelector = useMemo(() => {\n    if (\n      criterion instanceof PerformersCriterion ||\n      criterion instanceof StudiosCriterion ||\n      criterion instanceof TagsCriterion ||\n      criterion instanceof FolderCriterion ||\n      criterion instanceof ParentFolderCriterion\n    ) {\n      return false;\n    }\n\n    return modifierOptions && modifierOptions.length > 1;\n  }, [criterion, modifierOptions]);\n\n  const alwaysShowFilter = useMemo(() => {\n    return (\n      criterion instanceof StashIDCriterion ||\n      criterion instanceof PerformersCriterion ||\n      criterion instanceof StudiosCriterion ||\n      criterion instanceof TagsCriterion\n    );\n  }, [criterion]);\n\n  const onChangedModifierSelect = useCallback(\n    (m: CriterionModifier) => {\n      const newCriterion = cloneDeep(criterion);\n      newCriterion.modifier = m;\n      setCriterion(newCriterion);\n    },\n    [criterion, setCriterion]\n  );\n\n  const modifierSelector = useMemo(() => {\n    if (!showModifierSelector) {\n      return;\n    }\n\n    return (\n      <ModifierSelectorButtons\n        options={modifierOptions}\n        value={criterion.modifier}\n        onChanged={onChangedModifierSelect}\n      />\n    );\n  }, [\n    showModifierSelector,\n    modifierOptions,\n    onChangedModifierSelect,\n    criterion.modifier,\n  ]);\n\n  const valueControl = useMemo(() => {\n    function onValueChanged(value: CriterionValue) {\n      const newCriterion = cloneDeep(criterion);\n      newCriterion.value = value;\n      setCriterion(newCriterion);\n    }\n\n    // always show stashID filter\n    if (criterion instanceof StashIDCriterion) {\n      return (\n        <StashIDFilter criterion={criterion} onValueChanged={onValueChanged} />\n      );\n    }\n\n    // Hide the value select if the modifier is \"IsNull\" or \"NotNull\"\n    if (\n      !alwaysShowFilter &&\n      (criterion.modifier === CriterionModifier.IsNull ||\n        criterion.modifier === CriterionModifier.NotNull)\n    ) {\n      return;\n    }\n\n    if (criterion instanceof PerformersCriterion) {\n      return (\n        <PerformersFilter\n          criterion={criterion}\n          setCriterion={(c) => setCriterion(c)}\n        />\n      );\n    }\n\n    if (criterion instanceof StudiosCriterion) {\n      return (\n        <StudiosFilter\n          criterion={criterion}\n          setCriterion={(c) => setCriterion(c)}\n        />\n      );\n    }\n\n    if (criterion instanceof TagsCriterion) {\n      return (\n        <TagsFilter\n          criterion={criterion}\n          setCriterion={(c) => setCriterion(c)}\n        />\n      );\n    }\n\n    if (\n      criterion instanceof FolderCriterion ||\n      criterion instanceof ParentFolderCriterion\n    ) {\n      return (\n        <FolderFilter\n          criterion={criterion}\n          setCriterion={(c) => setCriterion(c)}\n        />\n      );\n    }\n\n    if (criterion instanceof ILabeledIdCriterion) {\n      return (\n        <LabeledIdFilter\n          criterion={criterion}\n          onValueChanged={onValueChanged}\n        />\n      );\n    }\n    if (criterion instanceof IHierarchicalLabeledIdCriterion) {\n      return (\n        <HierarchicalLabelValueFilter\n          criterion={criterion}\n          onValueChanged={onValueChanged}\n        />\n      );\n    }\n    if (\n      options &&\n      !criterionIsHierarchicalLabelValue(criterion.value) &&\n      !criterionIsNumberValue(criterion.value) &&\n      !criterionIsStashIDValue(criterion.value) &&\n      !criterionIsDateValue(criterion.value) &&\n      !criterionIsTimestampValue(criterion.value)\n    ) {\n      if (!Array.isArray(criterion.value)) {\n        return (\n          <OptionFilter criterion={criterion} setCriterion={setCriterion} />\n        );\n      } else {\n        return (\n          <OptionListFilter criterion={criterion} setCriterion={setCriterion} />\n        );\n      }\n    }\n    if (criterion instanceof PathCriterion) {\n      return (\n        <PathFilter criterion={criterion} onValueChanged={onValueChanged} />\n      );\n    }\n    if (criterion instanceof DurationCriterion) {\n      return (\n        <DurationFilter criterion={criterion} onValueChanged={onValueChanged} />\n      );\n    }\n    if (criterion instanceof DateCriterion) {\n      return (\n        <DateFilter criterion={criterion} onValueChanged={onValueChanged} />\n      );\n    }\n    if (criterion instanceof TimestampCriterion) {\n      return (\n        <TimestampFilter\n          criterion={criterion}\n          onValueChanged={onValueChanged}\n        />\n      );\n    }\n    if (criterion instanceof NumberCriterion) {\n      return (\n        <NumberFilter criterion={criterion} onValueChanged={onValueChanged} />\n      );\n    }\n    if (criterion instanceof RatingCriterion) {\n      return (\n        <RatingFilter criterion={criterion} onValueChanged={onValueChanged} />\n      );\n    }\n    if (criterion instanceof PhashCriterion) {\n      return (\n        <PhashFilter criterion={criterion} onValueChanged={onValueChanged} />\n      );\n    }\n    if (\n      criterion instanceof CountryCriterion &&\n      (criterion.modifier === CriterionModifier.Equals ||\n        criterion.modifier === CriterionModifier.NotEquals)\n    ) {\n      return (\n        <CountrySelect\n          value={criterion.value}\n          onChange={(v) => onValueChanged(v)}\n          menuPortalTarget={document.body}\n        />\n      );\n    }\n    return (\n      <InputFilter criterion={criterion} onValueChanged={onValueChanged} />\n    );\n  }, [criterion, setCriterion, options, alwaysShowFilter]);\n\n  return (\n    <div>\n      {modifierSelector}\n      {valueControl}\n    </div>\n  );\n};\n\ninterface ICriterionEditor {\n  criterion: Criterion;\n  setCriterion: (c: Criterion) => void;\n}\n\nexport const CriterionEditor: React.FC<ICriterionEditor> = ({\n  criterion,\n  setCriterion,\n}) => {\n  const filterControl = useMemo(() => {\n    if (criterion instanceof BooleanCriterion) {\n      return (\n        <BooleanFilter criterion={criterion} setCriterion={setCriterion} />\n      );\n    }\n\n    if (criterion instanceof DuplicatedCriterion) {\n      return (\n        <DuplicatedFilter criterion={criterion} setCriterion={setCriterion} />\n      );\n    }\n\n    if (criterion instanceof CustomFieldsCriterion) {\n      return (\n        <CustomFieldsFilter criterion={criterion} setCriterion={setCriterion} />\n      );\n    }\n\n    if (criterion instanceof ModifierCriterion) {\n      return (\n        <GenericCriterionEditor\n          criterion={criterion}\n          setCriterion={setCriterion}\n        />\n      );\n    }\n\n    return null;\n  }, [criterion, setCriterion]);\n\n  return <div className=\"criterion-editor\">{filterControl}</div>;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/EditFilterDialog.tsx",
    "content": "import cloneDeep from \"lodash-es/cloneDeep\";\nimport React, {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Accordion, Button, Card, Form, Modal } from \"react-bootstrap\";\nimport cx from \"classnames\";\nimport {\n  Criterion,\n  CriterionOption,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { getFilterOptions } from \"src/models/list-filter/factory\";\nimport { FilterTags } from \"./FilterTags\";\nimport { CriterionEditor } from \"./CriterionEditor\";\nimport { Icon } from \"../Shared/Icon\";\nimport {\n  faChevronDown,\n  faChevronRight,\n  faTimes,\n  faThumbtack,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { useCompare, usePrevious } from \"src/hooks/state\";\nimport { CriterionType } from \"src/models/list-filter/types\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigureUI, useSaveFilter } from \"src/core/StashService\";\nimport {\n  FilterMode,\n  SavedFilterDataFragment,\n} from \"src/core/generated-graphql\";\nimport { useFocusOnce } from \"src/utils/focus\";\nimport Mousetrap from \"mousetrap\";\nimport ScreenUtils from \"src/utils/screen\";\nimport { LoadFilterDialog, SaveFilterDialog } from \"./SavedFilterList\";\nimport { SearchTermInput } from \"./ListFilter\";\n\ninterface ICriterionList {\n  criteria: string[];\n  currentCriterion?: Criterion;\n  setCriterion: (c: Criterion) => void;\n  criterionOptions: CriterionOption[];\n  pinnedCriterionOptions: CriterionOption[];\n  selected?: CriterionOption;\n  optionSelected: (o?: CriterionOption) => void;\n  onRemoveCriterion: (c: string) => void;\n  onTogglePin: (c: CriterionOption) => void;\n  externallySelected?: boolean;\n}\n\nconst CriterionOptionList: React.FC<ICriterionList> = ({\n  criteria,\n  currentCriterion,\n  setCriterion,\n  criterionOptions,\n  pinnedCriterionOptions,\n  selected,\n  optionSelected,\n  onRemoveCriterion,\n  onTogglePin,\n  externallySelected = false,\n}) => {\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const prevCriterion = usePrevious(currentCriterion);\n\n  const scrolled = useRef(false);\n\n  const type = currentCriterion?.criterionOption.type;\n  const prevType = prevCriterion?.criterionOption.type;\n\n  const criteriaRefs = useMemo(() => {\n    const refs: Record<string, React.RefObject<HTMLDivElement>> = {};\n    criterionOptions.forEach((c) => {\n      refs[c.type] = React.createRef();\n    });\n    pinnedCriterionOptions.forEach((c) => {\n      refs[c.type] = React.createRef();\n    });\n    return refs;\n  }, [criterionOptions, pinnedCriterionOptions]);\n\n  function onSelect(k: string | null) {\n    if (!k) {\n      optionSelected(undefined);\n      return;\n    }\n\n    let option = criterionOptions.find((c) => c.type === k);\n    if (!option) {\n      option = pinnedCriterionOptions.find((c) => c.type === k);\n    }\n\n    if (option) {\n      optionSelected(option);\n    }\n  }\n\n  useEffect(() => {\n    // scrolling to the current criterion doesn't work well when the\n    // dialog is already open, so limit to when we click on the\n    // criterion from the external tags\n    if (\n      externallySelected &&\n      !scrolled.current &&\n      type &&\n      criteriaRefs[type]?.current\n    ) {\n      criteriaRefs[type].current!.scrollIntoView({\n        behavior: \"smooth\",\n        block: \"start\",\n      });\n      scrolled.current = true;\n    }\n  }, [externallySelected, currentCriterion, criteriaRefs, type]);\n\n  function getReleventCriterion(t: CriterionType) {\n    if (currentCriterion?.criterionOption.type === t) {\n      return currentCriterion;\n    }\n\n    return prevCriterion;\n  }\n\n  function removeClicked(ev: React.MouseEvent, t: string) {\n    // needed to prevent the nav item from being selected\n    ev.stopPropagation();\n    ev.preventDefault();\n    onRemoveCriterion(t);\n  }\n\n  function togglePin(ev: React.MouseEvent, c: CriterionOption) {\n    // needed to prevent the nav item from being selected\n    ev.stopPropagation();\n    ev.preventDefault();\n    onTogglePin(c);\n  }\n\n  function renderCard(c: CriterionOption, isPin: boolean) {\n    return (\n      <Card key={c.type} data-type={c.type} ref={criteriaRefs[c.type]!}>\n        <Accordion.Toggle className=\"filter-item-header\" eventKey={c.type}>\n          <span className=\"mr-auto\">\n            <Icon\n              className=\"collapse-icon fa-fw\"\n              icon={type === c.type ? faChevronDown : faChevronRight}\n            />\n            <FormattedMessage\n              id={!sfwContentMode ? c.messageID : c.sfwMessageID ?? c.messageID}\n            />\n          </span>\n          {criteria.some((cc) => c.type === cc) && (\n            <Button\n              className=\"remove-criterion-button\"\n              variant=\"minimal\"\n              onClick={(e) => removeClicked(e, c.type)}\n            >\n              <Icon icon={faTimes} />\n            </Button>\n          )}\n          <Button\n            className=\"pin-criterion-button\"\n            variant=\"minimal\"\n            onClick={(e) => togglePin(e, c)}\n          >\n            <Icon icon={faThumbtack} className={isPin ? \"\" : \"tilted\"} />\n          </Button>\n        </Accordion.Toggle>\n        <Accordion.Collapse eventKey={c.type}>\n          {(type === c.type && currentCriterion) ||\n          (prevType === c.type && prevCriterion) ? (\n            <Card.Body>\n              <CriterionEditor\n                criterion={getReleventCriterion(c.type)!}\n                setCriterion={setCriterion}\n              />\n            </Card.Body>\n          ) : (\n            <Card.Body></Card.Body>\n          )}\n        </Accordion.Collapse>\n      </Card>\n    );\n  }\n\n  return (\n    <Accordion\n      className=\"criterion-list\"\n      activeKey={selected?.type}\n      onSelect={onSelect}\n    >\n      {pinnedCriterionOptions.length !== 0 && (\n        <>\n          {pinnedCriterionOptions.map((c) => renderCard(c, true))}\n          <div className=\"pinned-criterion-divider\" />\n        </>\n      )}\n      {criterionOptions.map((c) => renderCard(c, false))}\n    </Accordion>\n  );\n};\n\nconst FilterModeToConfigKey = {\n  [FilterMode.Galleries]: \"galleries\",\n  [FilterMode.Images]: \"images\",\n  [FilterMode.Movies]: \"groups\",\n  [FilterMode.Groups]: \"groups\",\n  [FilterMode.Performers]: \"performers\",\n  [FilterMode.SceneMarkers]: \"sceneMarkers\",\n  [FilterMode.Scenes]: \"scenes\",\n  [FilterMode.Studios]: \"studios\",\n  [FilterMode.Tags]: \"tags\",\n};\n\nfunction filterModeToConfigKey(filterMode: FilterMode) {\n  return FilterModeToConfigKey[filterMode];\n}\n\ninterface IEditFilterProps {\n  filter: ListFilterModel;\n  editingCriterion?: string;\n  onApply: (filter: ListFilterModel) => void;\n  onCancel: () => void;\n}\n\nexport const EditFilterDialog: React.FC<IEditFilterProps> = ({\n  filter,\n  editingCriterion,\n  onApply,\n  onCancel,\n}) => {\n  const Toast = useToast();\n  const intl = useIntl();\n\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const [searchValue, setSearchValue] = useState(\"\");\n  const [currentFilter, setCurrentFilter] = useState<ListFilterModel>(\n    cloneDeep(filter)\n  );\n  const [criterion, setCriterion] = useState<Criterion>();\n\n  const [searchRef, setSearchFocus] = useFocusOnce(!ScreenUtils.isTouch());\n\n  const [showSaveDialog, setShowSaveDialog] = useState(false);\n  const [savingFilter, setSavingFilter] = useState(false);\n\n  const [showLoadDialog, setShowLoadDialog] = useState(false);\n\n  const saveFilter = useSaveFilter();\n\n  const { criteria } = currentFilter;\n\n  const criteriaList = useMemo(() => {\n    return criteria.map((c) => c.criterionOption.type);\n  }, [criteria]);\n\n  const filterOptions = useMemo(() => {\n    return getFilterOptions(currentFilter.mode);\n  }, [currentFilter.mode]);\n\n  const criterionOptions = useMemo(() => {\n    return [...filterOptions.criterionOptions]\n      .filter((c) => !c.hidden)\n      .sort((a, b) => {\n        return intl\n          .formatMessage({\n            id: !sfwContentMode ? a.messageID : a.sfwMessageID ?? a.messageID,\n          })\n          .localeCompare(\n            intl.formatMessage({\n              id: !sfwContentMode ? b.messageID : b.sfwMessageID ?? b.messageID,\n            })\n          );\n      });\n  }, [intl, sfwContentMode, filterOptions.criterionOptions]);\n\n  const optionSelected = useCallback(\n    (option?: CriterionOption) => {\n      if (!option) {\n        setCriterion(undefined);\n        return;\n      }\n\n      // find the existing criterion if present\n      const existing = criteria.find(\n        (c) => c.criterionOption.type === option.type\n      );\n      if (existing) {\n        setCriterion(existing);\n      } else {\n        const newCriterion = filter.makeCriterion(option.type);\n        setCriterion(newCriterion);\n      }\n    },\n    [filter, criteria]\n  );\n\n  const ui = configuration?.ui ?? {};\n  const [saveUI] = useConfigureUI();\n\n  const filteredOptions = useMemo(() => {\n    const trimmedSearch = searchValue.trim().toLowerCase();\n    if (!trimmedSearch) {\n      return criterionOptions;\n    }\n\n    return criterionOptions.filter((c) => {\n      return intl\n        .formatMessage({\n          id: !sfwContentMode ? c.messageID : c.sfwMessageID ?? c.messageID,\n        })\n        .toLowerCase()\n        .includes(trimmedSearch);\n    });\n  }, [intl, sfwContentMode, searchValue, criterionOptions]);\n\n  const pinnedFilters = useMemo(\n    () => ui.pinnedFilters?.[filterModeToConfigKey(currentFilter.mode)] ?? [],\n    [currentFilter.mode, ui.pinnedFilters]\n  );\n  const pinnedElements = useMemo(\n    () => filteredOptions.filter((c) => pinnedFilters.includes(c.messageID)),\n    [pinnedFilters, filteredOptions]\n  );\n  const unpinnedElements = useMemo(\n    () => filteredOptions.filter((c) => !pinnedFilters.includes(c.messageID)),\n    [pinnedFilters, filteredOptions]\n  );\n\n  const editingCriterionChanged = useCompare(editingCriterion);\n\n  useEffect(() => {\n    if (editingCriterionChanged && editingCriterion) {\n      const option = criterionOptions.find((c) => c.type === editingCriterion);\n      if (option) {\n        optionSelected(option);\n      }\n    }\n  }, [\n    editingCriterion,\n    criterionOptions,\n    optionSelected,\n    editingCriterionChanged,\n  ]);\n\n  useEffect(() => {\n    Mousetrap.bind(\"/\", (e) => {\n      setSearchFocus();\n      e.preventDefault();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"/\");\n    };\n  });\n\n  async function updatePinnedFilters(filters: string[]) {\n    const configKey = filterModeToConfigKey(currentFilter.mode);\n    try {\n      await saveUI({\n        variables: {\n          input: {\n            ...configuration?.ui,\n            pinnedFilters: {\n              ...ui.pinnedFilters,\n              [configKey]: filters,\n            },\n          },\n        },\n      });\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function onTogglePinFilter(f: CriterionOption) {\n    try {\n      const existing = pinnedFilters.find((name) => name === f.messageID);\n      if (existing) {\n        await updatePinnedFilters(\n          pinnedFilters.filter((name) => name !== f.messageID)\n        );\n      } else {\n        await updatePinnedFilters([...pinnedFilters, f.messageID]);\n      }\n    } catch (err) {\n      Toast.error(err);\n    }\n  }\n\n  function replaceCriterion(c: Criterion) {\n    const newFilter = cloneDeep(currentFilter);\n\n    if (!c.isValid()) {\n      // remove from the filter if present\n      const newCriteria = criteria.filter((cc) => {\n        return cc.criterionOption.type !== c.criterionOption.type;\n      });\n\n      newFilter.criteria = newCriteria;\n    } else {\n      let found = false;\n\n      const newCriteria = criteria.map((cc) => {\n        if (cc.criterionOption.type === c.criterionOption.type) {\n          found = true;\n          return c;\n        }\n\n        return cc;\n      });\n\n      if (!found) {\n        newCriteria.push(c);\n      }\n\n      newFilter.criteria = newCriteria;\n    }\n\n    setCurrentFilter(newFilter);\n    setCriterion(c);\n  }\n\n  function removeCriterion(c: Criterion, valueIndex?: number) {\n    if (valueIndex !== undefined) {\n      setCurrentFilter(\n        currentFilter.removeCustomFieldCriterion(\n          c.criterionOption.type,\n          valueIndex\n        )\n      );\n    } else {\n      const newFilter = cloneDeep(currentFilter);\n      const newCriteria = criteria.filter((cc) => {\n        return cc.getId() !== c.getId();\n      });\n\n      newFilter.criteria = newCriteria;\n\n      setCurrentFilter(newFilter);\n      if (criterion?.getId() === c.getId()) {\n        optionSelected(undefined);\n      }\n    }\n  }\n\n  function removeCriterionString(c: string) {\n    const cc = criteria.find((ccc) => ccc.criterionOption.type === c);\n    if (cc) {\n      removeCriterion(cc);\n    }\n  }\n\n  function onClearAll() {\n    const newFilter = cloneDeep(currentFilter);\n    newFilter.criteria = [];\n    setCurrentFilter(newFilter);\n  }\n\n  function onLoadFilter(f: SavedFilterDataFragment) {\n    const newFilter = filter.clone();\n\n    newFilter.currentPage = 1;\n    // #1795 - reset search term if not present in saved filter\n    newFilter.searchTerm = \"\";\n    newFilter.configureFromSavedFilter(f);\n    // #1507 - reset random seed when loaded\n    newFilter.randomSeed = -1;\n\n    onApply(newFilter);\n  }\n\n  async function onSaveFilter(name: string, id?: string) {\n    try {\n      setSavingFilter(true);\n      await saveFilter(filter, name, id);\n\n      Toast.success(\n        intl.formatMessage(\n          {\n            id: \"toast.saved_entity\",\n          },\n          {\n            entity: intl.formatMessage({ id: \"filter\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n      setShowSaveDialog(false);\n      onApply(currentFilter);\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setSavingFilter(false);\n    }\n  }\n\n  return (\n    <>\n      {showSaveDialog && (\n        <SaveFilterDialog\n          mode={filter.mode}\n          onClose={(name, id) => {\n            if (name) {\n              onSaveFilter(name, id);\n            } else {\n              setShowSaveDialog(false);\n            }\n          }}\n          isSaving={savingFilter}\n        />\n      )}\n      {showLoadDialog && (\n        <LoadFilterDialog\n          mode={filter.mode}\n          onClose={(f) => {\n            if (f) {\n              onLoadFilter(f);\n            }\n            setShowLoadDialog(false);\n          }}\n        />\n      )}\n      <Modal\n        show={!showSaveDialog && !showLoadDialog}\n        onHide={() => onCancel()}\n        // need sfw mode class because dialog is outside body\n        className={cx(\"edit-filter-dialog\", {\n          \"sfw-content-mode\": sfwContentMode,\n        })}\n      >\n        <Modal.Header>\n          <div>\n            <FormattedMessage id=\"search_filter.edit_filter\" />\n          </div>\n          <Form.Control\n            className=\"btn-secondary search-input\"\n            onChange={(e) => setSearchValue(e.target.value)}\n            value={searchValue}\n            placeholder={`${intl.formatMessage({ id: \"actions.search\" })}…`}\n            ref={searchRef}\n          />\n        </Modal.Header>\n        <Modal.Body>\n          <div\n            className={cx(\"dialog-content\", {\n              \"criterion-selected\": !!criterion,\n            })}\n          >\n            <div className=\"search-term-row\">\n              <span>\n                <FormattedMessage id=\"search_filter.search_term\" />\n              </span>\n              <SearchTermInput\n                filter={currentFilter}\n                onFilterUpdate={setCurrentFilter}\n              />\n            </div>\n            <CriterionOptionList\n              criteria={criteriaList}\n              currentCriterion={criterion}\n              setCriterion={replaceCriterion}\n              criterionOptions={unpinnedElements}\n              pinnedCriterionOptions={pinnedElements}\n              optionSelected={optionSelected}\n              selected={criterion?.criterionOption}\n              onRemoveCriterion={(c) => removeCriterionString(c)}\n              onTogglePin={(c) => onTogglePinFilter(c)}\n              externallySelected={!!editingCriterion}\n            />\n            {criteria.length > 0 && (\n              <div>\n                <FilterTags\n                  criteria={criteria}\n                  onEditCriterion={(c) => optionSelected(c.criterionOption)}\n                  onRemoveCriterion={removeCriterion}\n                  onRemoveAll={() => onClearAll()}\n                />\n              </div>\n            )}\n          </div>\n        </Modal.Body>\n        <Modal.Footer>\n          <div>\n            <Button\n              variant=\"secondary\"\n              onClick={() => setShowLoadDialog(true)}\n              title={intl.formatMessage({ id: \"actions.load_filter\" })}\n            >\n              <FormattedMessage id=\"actions.load\" />…\n            </Button>\n            <Button\n              variant=\"secondary\"\n              onClick={() => setShowSaveDialog(true)}\n              title={intl.formatMessage({ id: \"actions.save_filter\" })}\n            >\n              <FormattedMessage id=\"actions.save\" />…\n            </Button>\n          </div>\n          <div>\n            <Button variant=\"secondary\" onClick={() => onCancel()}>\n              <FormattedMessage id=\"actions.cancel\" />\n            </Button>\n            <Button onClick={() => onApply(currentFilter)}>\n              <FormattedMessage id=\"actions.apply\" />\n            </Button>\n          </div>\n        </Modal.Footer>\n      </Modal>\n    </>\n  );\n};\n\nexport function useShowEditFilter(props: {\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  showModal: (content: React.ReactNode) => void;\n  closeModal: () => void;\n}) {\n  const { filter, setFilter, showModal, closeModal } = props;\n\n  const showEditFilter = useCallback(\n    (editingCriterion?: string) => {\n      function onApplyEditFilter(f: ListFilterModel) {\n        closeModal();\n        setFilter(f);\n      }\n\n      showModal(\n        <EditFilterDialog\n          filter={filter}\n          onApply={onApplyEditFilter}\n          onCancel={() => closeModal()}\n          editingCriterion={editingCriterion}\n        />\n      );\n    },\n    [filter, setFilter, showModal, closeModal]\n  );\n\n  return showEditFilter;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/List/FilterProvider.tsx",
    "content": "import React from \"react\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { isFunction } from \"lodash-es\";\nimport { useFilterURL } from \"./util\";\n\ninterface IFilterContextOptions {\n  filter: ListFilterModel;\n  setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>;\n}\n\nexport interface IFilterContextState {\n  filter: ListFilterModel;\n  setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>;\n}\n\nexport const FilterStateContext =\n  React.createContext<IFilterContextState | null>(null);\n\nexport const FilterContext = (\n  props: IFilterContextOptions & {\n    children?:\n      | ((props: IFilterContextState) => React.ReactNode)\n      | React.ReactNode;\n  }\n) => {\n  const { filter, setFilter, children } = props;\n\n  const state = {\n    filter,\n    setFilter,\n  };\n\n  return (\n    <FilterStateContext.Provider value={state}>\n      {isFunction(children)\n        ? (children as (props: IFilterContextState) => React.ReactNode)(state)\n        : children}\n    </FilterStateContext.Provider>\n  );\n};\n\nexport function useFilter() {\n  const context = React.useContext(FilterStateContext);\n\n  if (context === null) {\n    throw new Error(\"useFilter must be used within a FilterStateContext\");\n  }\n\n  return context;\n}\n\n// This component is used to set the filter from the URL.\n// It replaces the setFilter function to set the URL instead.\n// It also loads the default filter if the URL is empty.\nexport const SetFilterURL = (props: {\n  defaultFilter?: ListFilterModel;\n  setURL?: boolean;\n  children?:\n    | ((props: IFilterContextState) => React.ReactNode)\n    | React.ReactNode;\n}) => {\n  const { defaultFilter, setURL = true, children } = props;\n\n  const { filter, setFilter: setFilterOrig } = useFilter();\n\n  const { setFilter } = useFilterURL(filter, setFilterOrig, {\n    defaultFilter,\n    active: setURL,\n  });\n\n  return (\n    <FilterContext filter={filter} setFilter={setFilter}>\n      {children}\n    </FilterContext>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/FilterTags.tsx",
    "content": "import React, {\n  PropsWithChildren,\n  useEffect,\n  useLayoutEffect,\n  useReducer,\n  useRef,\n} from \"react\";\nimport { Badge, BadgeProps, Button, Overlay, Popover } from \"react-bootstrap\";\nimport {\n  Criterion,\n  UnsupportedCriterion,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Icon } from \"../Shared/Icon\";\nimport {\n  faExclamationTriangle,\n  faMagnifyingGlass,\n  faTimes,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { BsPrefixProps, ReplaceProps } from \"react-bootstrap/esm/helpers\";\nimport { CustomFieldsCriterion } from \"src/models/list-filter/criteria/custom-fields\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport cx from \"classnames\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\ntype TagItemProps = PropsWithChildren<\n  ReplaceProps<\"span\", BsPrefixProps<\"span\"> & BadgeProps>\n>;\n\nexport const TagItem: React.FC<TagItemProps> = (props) => {\n  const { className, children, ...others } = props;\n  return (\n    <Badge\n      className={cx(\"tag-item\", className)}\n      variant=\"secondary\"\n      {...others}\n    >\n      {children}\n    </Badge>\n  );\n};\n\nexport const FilterTag: React.FC<{\n  className?: string;\n  label: React.ReactNode;\n  onClick: React.MouseEventHandler<HTMLSpanElement>;\n  onRemove: React.MouseEventHandler<HTMLElement>;\n  unsupported?: boolean;\n}> = ({ className, label, onClick, onRemove, unsupported }) => {\n  function handleClick(e: React.MouseEvent<HTMLSpanElement, MouseEvent>) {\n    if (unsupported) {\n      return;\n    }\n    onClick(e);\n  }\n\n  return (\n    <TagItem className={cx(className, { unsupported })} onClick={handleClick}>\n      {unsupported && (\n        <Icon icon={faExclamationTriangle} className=\"unsupported-icon\" />\n      )}\n      {label}\n      <Button\n        variant=\"secondary\"\n        onClick={(e) => {\n          onRemove(e);\n          e.stopPropagation();\n        }}\n      >\n        <Icon icon={faTimes} />\n      </Button>\n    </TagItem>\n  );\n};\n\nconst MoreFilterTags: React.FC<{\n  tags: React.ReactNode[];\n}> = ({ tags }) => {\n  const [showTooltip, setShowTooltip] = React.useState(false);\n  const target = useRef(null);\n\n  if (!tags.length) {\n    return null;\n  }\n\n  function handleMouseEnter() {\n    setShowTooltip(true);\n  }\n\n  function handleMouseLeave() {\n    setShowTooltip(false);\n  }\n\n  return (\n    <>\n      <Overlay target={target.current} placement=\"bottom\" show={showTooltip}>\n        <Popover\n          id=\"more-criteria-popover\"\n          className=\"hover-popover-content\"\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n          onClick={handleMouseLeave}\n        >\n          {tags}\n        </Popover>\n      </Overlay>\n      <Badge\n        ref={target}\n        className={\"tag-item more-tags\"}\n        variant=\"secondary\"\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n      >\n        <FormattedMessage\n          id=\"search_filter.more_filter_criteria\"\n          values={{ count: tags.length }}\n        />\n      </Badge>\n    </>\n  );\n};\n\ninterface IFilterTagsProps {\n  searchTerm?: string;\n  criteria: Criterion[];\n  onEditSearchTerm?: () => void;\n  onEditCriterion: (c: Criterion) => void;\n  onRemoveCriterion: (c: Criterion, valueIndex?: number) => void;\n  onRemoveAll: () => void;\n  onRemoveSearchTerm?: () => void;\n  truncateOnOverflow?: boolean;\n}\n\nexport const FilterTags: React.FC<IFilterTagsProps> = ({\n  searchTerm,\n  criteria,\n  onEditCriterion,\n  onRemoveCriterion,\n  onRemoveAll,\n  onEditSearchTerm,\n  onRemoveSearchTerm,\n  truncateOnOverflow = false,\n}) => {\n  const intl = useIntl();\n  const ref = useRef<HTMLDivElement>(null);\n\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const [cutoff, setCutoff] = React.useState<number | undefined>();\n  const elementGap = 10; // Adjust this value based on your CSS gap or margin\n  const moreTagWidth = 80; // reserve space for the \"more\" tag\n\n  const [, forceUpdate] = useReducer((x) => x + 1, 0);\n\n  const debounceResetCutoff = useDebounce(\n    () => {\n      setCutoff(undefined);\n      // setting cutoff won't trigger a re-render if it's already undefined\n      // so we force a re-render to recalculate the cutoff\n      forceUpdate();\n    },\n    100 // Adjust the debounce delay as needed\n  );\n\n  // trigger recalculation of cutoff when control resizes\n  useEffect(() => {\n    if (!truncateOnOverflow || !ref.current) {\n      return;\n    }\n\n    const resizeObserver = new ResizeObserver(() => {\n      debounceResetCutoff();\n    });\n\n    const { current } = ref;\n    resizeObserver.observe(current);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, [truncateOnOverflow, debounceResetCutoff]);\n\n  // we need to check this on every render, and the call to setCutoff _should_ be safe\n  /* eslint-disable-next-line react-hooks/exhaustive-deps */\n  useLayoutEffect(() => {\n    if (!truncateOnOverflow) {\n      setCutoff(undefined);\n      return;\n    }\n\n    const { current } = ref;\n\n    if (current) {\n      // calculate the number of tags that can fit in the container\n      const containerWidth = current.clientWidth;\n      const children = Array.from(current.children);\n\n      // don't recalculate anything if the more tag is visible and cutoff is already set\n      const moreTags = children.find((child) => {\n        return (child as HTMLElement).classList.contains(\"more-tags\");\n      });\n\n      if (moreTags && cutoff !== undefined) {\n        return;\n      }\n\n      const childTags = children.filter((child) => {\n        return (\n          (child as HTMLElement).classList.contains(\"tag-item\") ||\n          (child as HTMLElement).classList.contains(\"clear-all-button\")\n        );\n      });\n\n      const clearAllButton = children.find((child) => {\n        return (child as HTMLElement).classList.contains(\"clear-all-button\");\n      });\n\n      // calculate the total width without the more tag\n      const defaultTotalWidth = childTags.reduce((total, child, idx) => {\n        return (\n          total +\n          ((child as HTMLElement).offsetWidth ?? 0) +\n          (idx === childTags.length - 1 ? 0 : elementGap)\n        );\n      }, 0);\n\n      if (containerWidth >= defaultTotalWidth) {\n        // if the container is wide enough to fit all tags, reset cutoff\n        setCutoff(undefined);\n        return;\n      }\n\n      let totalWidth = 0;\n      let visibleCount = 0;\n\n      // reserve space for the more tags control\n      totalWidth += moreTagWidth;\n\n      // reserve space for the clear all button if present\n      if (clearAllButton) {\n        totalWidth += (clearAllButton as HTMLElement).offsetWidth ?? 0;\n      }\n\n      for (const child of children) {\n        totalWidth += ((child as HTMLElement).offsetWidth ?? 0) + elementGap;\n        if (totalWidth > containerWidth) {\n          break;\n        }\n        visibleCount++;\n      }\n\n      setCutoff(visibleCount);\n    }\n  });\n\n  function onRemoveCriterionTag(\n    criterion: Criterion,\n    $event: React.MouseEvent<HTMLElement, MouseEvent>,\n    valueIndex?: number\n  ) {\n    if (!criterion) {\n      return;\n    }\n    onRemoveCriterion(criterion, valueIndex);\n    $event.stopPropagation();\n  }\n\n  function onClickCriterionTag(criterion: Criterion) {\n    onEditCriterion(criterion);\n  }\n\n  function getFilterTags(criterion: Criterion) {\n    if (\n      criterion instanceof CustomFieldsCriterion &&\n      criterion.value.length > 1\n    ) {\n      return criterion.value.map((value, index) => {\n        return (\n          <FilterTag\n            key={index}\n            label={criterion.getValueLabel(intl, value)}\n            onClick={() => onClickCriterionTag(criterion)}\n            onRemove={($event) =>\n              onRemoveCriterionTag(criterion, $event, index)\n            }\n          />\n        );\n      });\n    }\n\n    const unsupported = criterion instanceof UnsupportedCriterion;\n\n    return (\n      <FilterTag\n        key={criterion.getId()}\n        label={criterion.getLabel(intl, sfwContentMode)}\n        unsupported={unsupported}\n        onClick={() => onClickCriterionTag(criterion)}\n        onRemove={($event) => onRemoveCriterionTag(criterion, $event)}\n      />\n    );\n  }\n\n  if (criteria.length === 0 && !searchTerm) {\n    return null;\n  }\n\n  const className = \"wrap-tags filter-tags\";\n\n  const filterTags = criteria.map((c) => getFilterTags(c)).flat();\n\n  if (searchTerm && searchTerm.length > 0) {\n    filterTags.unshift(\n      <FilterTag\n        key=\"search-term\"\n        className=\"search-term-filter-tag\"\n        label={\n          <span className=\"search-term\">\n            <Icon icon={faMagnifyingGlass} />\n            {searchTerm}\n          </span>\n        }\n        onClick={() => onEditSearchTerm?.()}\n        onRemove={() => onRemoveSearchTerm?.()}\n      />\n    );\n  }\n\n  const visibleCriteria =\n    cutoff !== undefined ? filterTags.slice(0, cutoff) : filterTags;\n  const hiddenCriteria = cutoff !== undefined ? filterTags.slice(cutoff) : [];\n\n  return (\n    <div className={className} ref={ref}>\n      {visibleCriteria}\n      <MoreFilterTags tags={hiddenCriteria} />\n      {filterTags.length >= 3 && (\n        <Button\n          variant=\"minimal\"\n          className=\"clear-all-button\"\n          onClick={() => onRemoveAll()}\n        >\n          <FormattedMessage id=\"actions.clear\" />\n        </Button>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/FilteredListToolbar.tsx",
    "content": "import React from \"react\";\nimport { QueryResult } from \"@apollo/client\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { IconDefinition } from \"@fortawesome/fontawesome-svg-core\";\nimport { PageSizeSelector, SearchTermInput, SortBySelect } from \"./ListFilter\";\nimport { ListViewButtonGroup } from \"./ListViewOptions\";\nimport {\n  IListFilterOperation,\n  ListOperationButtons,\n} from \"./ListOperationButtons\";\nimport { Button, ButtonGroup, ButtonToolbar } from \"react-bootstrap\";\nimport { View } from \"./views\";\nimport { IListSelect, useFilterOperations } from \"./util\";\nimport { SavedFilterDropdown } from \"./SavedFilterList\";\nimport { FilterButton } from \"./Filters/FilterButton\";\nimport { Icon } from \"../Shared/Icon\";\nimport { faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport { faSquareCheck } from \"@fortawesome/free-regular-svg-icons\";\nimport { useIntl } from \"react-intl\";\nimport cx from \"classnames\";\n\nconst SelectionSection: React.FC<{\n  filter: ListFilterModel;\n  selected: number;\n  onSelectAll: () => void;\n  onSelectNone: () => void;\n}> = ({ selected, onSelectAll, onSelectNone }) => {\n  const intl = useIntl();\n\n  return (\n    <div className=\"selected-items-info\">\n      <Button\n        variant=\"secondary\"\n        className=\"minimal\"\n        onClick={() => onSelectNone()}\n        title={intl.formatMessage({ id: \"actions.select_none\" })}\n      >\n        <Icon icon={faTimes} />\n      </Button>\n      <span className=\"selected-count\">{selected}</span>\n      <Button\n        variant=\"secondary\"\n        className=\"minimal\"\n        onClick={() => onSelectAll()}\n        title={intl.formatMessage({ id: \"actions.select_all\" })}\n      >\n        <Icon icon={faSquareCheck} />\n      </Button>\n    </div>\n  );\n};\n\nexport interface IItemListOperation<T extends QueryResult> {\n  text: string;\n  onClick: (\n    result: T,\n    filter: ListFilterModel,\n    selectedIds: Set<string>\n  ) => Promise<void>;\n  isDisplayed?: (\n    result: T,\n    filter: ListFilterModel,\n    selectedIds: Set<string>\n  ) => boolean;\n  postRefetch?: boolean;\n  icon?: IconDefinition;\n  buttonVariant?: string;\n}\n\nexport interface IFilteredListToolbar {\n  filter: ListFilterModel;\n  setFilter: (\n    value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel)\n  ) => void;\n  showEditFilter: () => void;\n  view?: View;\n  listSelect: IListSelect;\n  onEdit?: () => void;\n  onDelete?: () => void;\n  operations?: IListFilterOperation[];\n  operationComponent?: React.ReactNode;\n  zoomable?: boolean;\n  filterable?: boolean;\n  sortable?: boolean;\n}\n\nexport const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({\n  filter,\n  setFilter,\n  showEditFilter,\n  view,\n  listSelect,\n  onEdit,\n  onDelete,\n  operations,\n  operationComponent,\n  zoomable = false,\n  filterable = true,\n  sortable = true,\n}) => {\n  const filterOptions = filter.options;\n  const { setDisplayMode, setZoom } = useFilterOperations({\n    filter,\n    setFilter,\n  });\n  const { selectedIds, onSelectAll, onSelectNone, onInvertSelection } =\n    listSelect;\n  const hasSelection = selectedIds.size > 0;\n\n  const renderOperations = operationComponent ?? (\n    <ListOperationButtons\n      onSelectAll={onSelectAll}\n      onSelectNone={onSelectNone}\n      onInvertSelection={onInvertSelection}\n      otherOperations={operations}\n      itemsSelected={selectedIds.size > 0}\n      onEdit={onEdit}\n      onDelete={onDelete}\n    />\n  );\n\n  return (\n    <ButtonToolbar\n      className={cx(\"filtered-list-toolbar\", { \"has-selection\": hasSelection })}\n    >\n      {hasSelection ? (\n        <SelectionSection\n          filter={filter}\n          selected={selectedIds.size}\n          onSelectAll={onSelectAll}\n          onSelectNone={onSelectNone}\n        />\n      ) : (\n        <>\n          {filterable && (\n            <SearchTermInput filter={filter} onFilterUpdate={setFilter} />\n          )}\n\n          {filterable && (\n            <ButtonGroup>\n              <SavedFilterDropdown\n                filter={filter}\n                onSetFilter={setFilter}\n                view={view}\n              />\n              <FilterButton\n                onClick={() => showEditFilter()}\n                count={filter.count()}\n              />\n            </ButtonGroup>\n          )}\n\n          {sortable && (\n            <SortBySelect\n              sortBy={filter.sortBy}\n              sortDirection={filter.sortDirection}\n              options={filterOptions.sortByOptions}\n              onChangeSortBy={(e) =>\n                setFilter(filter.setSortBy(e ?? undefined))\n              }\n              onChangeSortDirection={() =>\n                setFilter(filter.toggleSortDirection())\n              }\n              onReshuffleRandomSort={() =>\n                setFilter(filter.reshuffleRandomSort())\n              }\n            />\n          )}\n\n          <PageSizeSelector\n            pageSize={filter.itemsPerPage}\n            setPageSize={(size) => setFilter(filter.setPageSize(size))}\n          />\n        </>\n      )}\n\n      {renderOperations}\n\n      <ListViewButtonGroup\n        displayMode={filter.displayMode}\n        displayModeOptions={filterOptions.displayModeOptions}\n        onSetDisplayMode={setDisplayMode}\n        zoomIndex={zoomable ? filter.zoomIndex : undefined}\n        onSetZoom={zoomable ? setZoom : undefined}\n      />\n    </ButtonToolbar>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/BooleanFilter.tsx",
    "content": "import cloneDeep from \"lodash-es/cloneDeep\";\nimport React, { useMemo } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport {\n  BooleanCriterion,\n  CriterionOption,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { Option, SidebarListFilter } from \"./SidebarListFilter\";\n\ninterface IBooleanFilter {\n  criterion: BooleanCriterion;\n  setCriterion: (c: BooleanCriterion) => void;\n}\n\nexport const BooleanFilter: React.FC<IBooleanFilter> = ({\n  criterion,\n  setCriterion,\n}) => {\n  function onSelect(v: boolean) {\n    const c = cloneDeep(criterion);\n    if ((v && c.value === \"true\") || (!v && c.value === \"false\")) {\n      c.value = \"\";\n    } else {\n      c.value = v ? \"true\" : \"false\";\n    }\n\n    setCriterion(c);\n  }\n\n  return (\n    <div className=\"boolean-filter\">\n      <Form.Check\n        id={`${criterion.getId()}-true`}\n        onChange={() => onSelect(true)}\n        checked={criterion.value === \"true\"}\n        type=\"radio\"\n        label={<FormattedMessage id=\"true\" />}\n      />\n      <Form.Check\n        id={`${criterion.getId()}-false`}\n        onChange={() => onSelect(false)}\n        checked={criterion.value === \"false\"}\n        type=\"radio\"\n        label={<FormattedMessage id=\"false\" />}\n      />\n    </div>\n  );\n};\n\ninterface ISidebarFilter {\n  title?: React.ReactNode;\n  option: CriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  sectionID?: string;\n}\n\nexport const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({\n  title,\n  option,\n  filter,\n  setFilter,\n  sectionID,\n}) => {\n  const intl = useIntl();\n\n  const trueLabel = intl.formatMessage({\n    id: \"true\",\n  });\n  const falseLabel = intl.formatMessage({\n    id: \"false\",\n  });\n\n  const trueOption = useMemo(\n    () => ({\n      id: \"true\",\n      label: trueLabel,\n    }),\n    [trueLabel]\n  );\n\n  const falseOption = useMemo(\n    () => ({\n      id: \"false\",\n      label: falseLabel,\n    }),\n    [falseLabel]\n  );\n\n  const criteria = filter.criteriaFor(option.type) as BooleanCriterion[];\n  const criterion = criteria.length > 0 ? criteria[0] : null;\n\n  const selected: Option[] = useMemo(() => {\n    if (!criterion) return [];\n\n    if (criterion.value === \"true\") {\n      return [trueOption];\n    } else if (criterion.value === \"false\") {\n      return [falseOption];\n    }\n\n    return [];\n  }, [trueOption, falseOption, criterion]);\n\n  const options: Option[] = useMemo(() => {\n    return [trueOption, falseOption].filter((o) => !selected.includes(o));\n  }, [selected, trueOption, falseOption]);\n\n  function onSelect(item: Option) {\n    const newCriterion = criterion ? criterion.clone() : option.makeCriterion();\n\n    newCriterion.value = item.id;\n\n    setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n  }\n\n  function onUnselect() {\n    setFilter(filter.removeCriterion(option.type));\n  }\n\n  return (\n    <>\n      <SidebarListFilter\n        title={title}\n        candidates={options}\n        onSelect={onSelect}\n        onUnselect={onUnselect}\n        selected={selected}\n        singleValue\n        sectionID={sectionID}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/CustomFieldsFilter.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { CustomFieldsCriterion } from \"src/models/list-filter/criteria/custom-fields\";\nimport { Button, Col, Form, Row } from \"react-bootstrap\";\nimport {\n  CriterionModifier,\n  CustomFieldCriterionInput,\n} from \"src/core/generated-graphql\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\nimport { ModifierSelect } from \"../ModifierSelect\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { faCheck, faPencil, faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport { FilterTag } from \"../FilterTags\";\nimport { ModifierCriterion } from \"src/models/list-filter/criteria/criterion\";\n\ninterface ICustomFieldCriterionEditor {\n  criterion?: CustomFieldCriterionInput;\n  setCriterion: (c: CustomFieldCriterionInput) => void;\n  cancel: () => void;\n  editing?: boolean;\n}\n\nfunction getValue(v: string) {\n  // if the value is numeric, convert it to a number\n  const num = Number(v);\n  if (!isNaN(num)) {\n    return num;\n  } else {\n    return v;\n  }\n}\n\nconst CustomFieldCriterionEditor: React.FC<ICustomFieldCriterionEditor> = ({\n  criterion,\n  setCriterion,\n  editing = false,\n  cancel,\n}) => {\n  const intl = useIntl();\n\n  const [field, setField] = React.useState(criterion?.field ?? \"\");\n  const [value, setValue] = React.useState(criterion?.value);\n  const [modifier, setModifier] = React.useState(\n    criterion?.modifier ?? CriterionModifier.Equals\n  );\n\n  const firstValue = value && value.length > 0 ? (value[0] as string) : \"\";\n  const secondValue = value && value.length > 1 ? (value[1] as string) : \"\";\n\n  useEffect(() => {\n    setField((criterion?.field as string) ?? \"\");\n    setValue(criterion?.value ?? []);\n    setModifier(criterion?.modifier ?? CriterionModifier.Equals);\n  }, [criterion]);\n\n  function setFirstValue(v: string) {\n    // convert to numeric if possible\n    const nv = getValue(v);\n\n    if (\n      modifier === CriterionModifier.Between ||\n      modifier === CriterionModifier.NotBetween\n    ) {\n      setValue([nv, secondValue]);\n    } else {\n      setValue([nv]);\n    }\n  }\n\n  function setSecondValue(v: string) {\n    setValue([firstValue, getValue(v)]);\n  }\n\n  function onChangeModifier(m: CriterionModifier) {\n    setModifier(m);\n    if (m === CriterionModifier.IsNull || m === CriterionModifier.NotNull) {\n      setValue(undefined);\n    }\n  }\n\n  function onConfirm() {\n    setCriterion({\n      field,\n      value,\n      modifier,\n    });\n  }\n\n  const firstPlaceholder =\n    modifier === CriterionModifier.Between ||\n    modifier === CriterionModifier.NotBetween\n      ? intl.formatMessage({ id: \"criterion.greater_than\" })\n      : intl.formatMessage({ id: \"custom_fields.value\" });\n\n  const hasTwoValues =\n    modifier === CriterionModifier.Between ||\n    modifier === CriterionModifier.NotBetween;\n\n  return (\n    <Form.Group className=\"custom-field-filter\">\n      <div>\n        <Row noGutters>\n          <Col xs={6}>\n            <Form.Control\n              className=\"btn-secondary\"\n              type=\"text\"\n              placeholder={intl.formatMessage({ id: \"custom_fields.field\" })}\n              onChange={(e) => setField(e.target.value)}\n              value={field}\n            />\n          </Col>\n          <Col xs={6}>\n            <ModifierSelect\n              value={modifier}\n              onChanged={(m) => onChangeModifier(m)}\n            />\n          </Col>\n        </Row>\n        <Row noGutters>\n          {modifier !== CriterionModifier.IsNull &&\n            modifier !== CriterionModifier.NotNull && (\n              <Col xs={hasTwoValues ? 6 : 12}>\n                <Form.Control\n                  placeholder={firstPlaceholder}\n                  className=\"btn-secondary\"\n                  type=\"text\"\n                  onChange={(e) => setFirstValue(e.target.value)}\n                  value={firstValue}\n                />\n              </Col>\n            )}\n          {(modifier === CriterionModifier.Between ||\n            modifier === CriterionModifier.NotBetween) && (\n            <Col xs={6}>\n              <Form.Control\n                placeholder={intl.formatMessage({ id: \"criterion.less_than\" })}\n                className=\"btn-secondary\"\n                type=\"text\"\n                onChange={(e) => setSecondValue(e.target.value)}\n                value={secondValue}\n              />\n            </Col>\n          )}\n        </Row>\n      </div>\n      <div className=\"custom-field-filter-buttons\">\n        <Button variant=\"success\" onClick={() => onConfirm()} disabled={!field}>\n          <Icon icon={faCheck} />\n        </Button>\n        {editing && (\n          <Button variant=\"secondary\" onClick={() => cancel()}>\n            <Icon icon={faTimes} />\n          </Button>\n        )}\n      </div>\n    </Form.Group>\n  );\n};\n\nfunction valueToString(value: unknown[] | undefined | null) {\n  if (!value) return \"\";\n  return value.map((v) => v as string).join(\", \");\n}\n\nconst CustomFieldFilterTag: React.FC<{\n  criterion: CustomFieldCriterionInput;\n  editing?: boolean;\n  onEditCriterion: () => void;\n  onRemoveCriterion: () => void;\n}> = ({ criterion, editing, onEditCriterion, onRemoveCriterion }) => {\n  const intl = useIntl();\n\n  const label = useMemo(() => {\n    const { field, modifier, value } = criterion;\n    const modifierString = ModifierCriterion.getModifierLabel(intl, modifier);\n\n    const str = intl.formatMessage(\n      { id: \"criterion_modifier.format_string\" },\n      {\n        criterion: field,\n        modifierString,\n        valueString: valueToString(value),\n      }\n    );\n\n    if (editing) {\n      return (\n        <span>\n          <Icon icon={faPencil} />\n          {str}\n        </span>\n      );\n    }\n\n    return <>{str}</>;\n  }, [criterion, editing, intl]);\n\n  return (\n    <FilterTag\n      label={label}\n      onClick={onEditCriterion}\n      onRemove={onRemoveCriterion}\n    />\n  );\n};\n\nconst CustomFieldsCriteriaPills: React.FC<{\n  criteria: CustomFieldCriterionInput[];\n  editIndex?: number;\n  onEditCriterion: (index: number) => void;\n  onRemoveCriterion: (index: number) => void;\n}> = ({ criteria, editIndex, onEditCriterion, onRemoveCriterion }) => {\n  return (\n    <div className=\"d-flex justify-content-center mb-2 wrap-tags filter-tags\">\n      {criteria.map((c, index) => (\n        <CustomFieldFilterTag\n          key={index}\n          editing={index === editIndex}\n          criterion={c}\n          onEditCriterion={() => onEditCriterion(index)}\n          onRemoveCriterion={() => onRemoveCriterion(index)}\n        />\n      ))}\n    </div>\n  );\n};\n\ninterface ICustomFieldsFilter {\n  criterion: CustomFieldsCriterion;\n  setCriterion: (c: CustomFieldsCriterion) => void;\n}\n\nfunction initCriterion(\n  criterion: CustomFieldsCriterion\n): CustomFieldsCriterion {\n  return cloneDeep(criterion);\n}\n\nfunction createNewCriterion(): CustomFieldCriterionInput {\n  return {\n    field: \"\",\n    value: [],\n    modifier: CriterionModifier.Equals,\n  };\n}\n\nexport const CustomFieldsFilter: React.FC<ICustomFieldsFilter> = ({\n  criterion,\n  setCriterion,\n}) => {\n  const [localCriterion, setLocalCriterion] = React.useState(\n    initCriterion(criterion)\n  );\n\n  const [editCriterion, setEditCriterion] = useState(createNewCriterion());\n  const editIndex = useMemo(\n    () => localCriterion.value.indexOf(editCriterion),\n    [localCriterion, editCriterion]\n  );\n\n  function updateCriteria(newCriteria: CustomFieldCriterionInput[]) {\n    // update the parent - filter out invalid criteria\n    const validCriteria = newCriteria.filter((c) => c.field !== \"\");\n    const newValue = cloneDeep(criterion);\n    newValue.value = validCriteria;\n    setCriterion(newValue);\n  }\n\n  function onChange(nv: CustomFieldCriterionInput) {\n    const newValue = cloneDeep(localCriterion);\n\n    // if the criterion is new, add it to the list\n    if (editIndex === -1) {\n      newValue.value.push(nv);\n    } else {\n      newValue.value[editIndex] = nv;\n    }\n\n    setLocalCriterion(newValue);\n    updateCriteria(newValue.value);\n    setEditCriterion(createNewCriterion());\n  }\n\n  function onRemove(index: number) {\n    const c = cloneDeep(localCriterion);\n    c.value.splice(index, 1);\n    setLocalCriterion(c);\n    updateCriteria(c.value);\n    if (index === editIndex) {\n      setEditCriterion(createNewCriterion());\n    }\n  }\n\n  return (\n    <Form.Group>\n      <CustomFieldCriterionEditor\n        criterion={editCriterion}\n        editing={editCriterion.field !== \"\"}\n        setCriterion={onChange}\n        cancel={() => setEditCriterion(createNewCriterion())}\n      />\n      <CustomFieldsCriteriaPills\n        criteria={localCriterion.value}\n        editIndex={editIndex !== -1 ? editIndex : undefined}\n        onEditCriterion={(index) =>\n          setEditCriterion(localCriterion.value[index])\n        }\n        onRemoveCriterion={(index) => onRemove(index)}\n      />\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/DateFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { CriterionModifier } from \"../../../core/generated-graphql\";\nimport { IDateValue } from \"../../../models/list-filter/types\";\nimport { ModifierCriterion } from \"../../../models/list-filter/criteria/criterion\";\nimport { DateInput } from \"src/components/Shared/DateInput\";\n\ninterface IDateFilterProps {\n  criterion: ModifierCriterion<IDateValue>;\n  onValueChanged: (value: IDateValue) => void;\n}\n\nexport const DateFilter: React.FC<IDateFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  const intl = useIntl();\n\n  const { value } = criterion;\n\n  function onChanged(newValue: string, property: \"value\" | \"value2\") {\n    const valueCopy = { ...value };\n\n    valueCopy[property] = newValue;\n    onValueChanged(valueCopy);\n  }\n\n  let equalsControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.Equals ||\n    criterion.modifier === CriterionModifier.NotEquals\n  ) {\n    equalsControl = (\n      <Form.Group>\n        <DateInput\n          value={value?.value ?? \"\"}\n          onValueChange={(v) => onChanged(v, \"value\")}\n          placeholder={intl.formatMessage({ id: \"criterion.value\" })}\n        />\n      </Form.Group>\n    );\n  }\n\n  let lowerControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.GreaterThan ||\n    criterion.modifier === CriterionModifier.Between ||\n    criterion.modifier === CriterionModifier.NotBetween\n  ) {\n    lowerControl = (\n      <Form.Group>\n        <DateInput\n          value={value?.value ?? \"\"}\n          onValueChange={(v) => onChanged(v, \"value\")}\n          placeholder={intl.formatMessage({ id: \"criterion.greater_than\" })}\n        />\n      </Form.Group>\n    );\n  }\n\n  let upperControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.LessThan ||\n    criterion.modifier === CriterionModifier.Between ||\n    criterion.modifier === CriterionModifier.NotBetween\n  ) {\n    upperControl = (\n      <Form.Group>\n        <DateInput\n          value={\n            (criterion.modifier === CriterionModifier.LessThan\n              ? value?.value\n              : value?.value2) ?? \"\"\n          }\n          onValueChange={(v) =>\n            onChanged(\n              v,\n              criterion.modifier === CriterionModifier.LessThan\n                ? \"value\"\n                : \"value2\"\n            )\n          }\n          placeholder={intl.formatMessage({ id: \"criterion.less_than\" })}\n        />\n      </Form.Group>\n    );\n  }\n\n  return (\n    <>\n      {equalsControl}\n      {lowerControl}\n      {upperControl}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx",
    "content": "import React, { useCallback, useMemo, useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { Option, SelectedList } from \"./SidebarListFilter\";\nimport {\n  DuplicatedCriterion,\n  DuplicatedCriterionOption,\n  DuplicationFieldId,\n  DUPLICATION_FIELD_IDS,\n  DUPLICATION_FIELD_MESSAGE_IDS,\n} from \"src/models/list-filter/criteria/phash\";\nimport { IndeterminateCheckbox } from \"src/components/Shared/IndeterminateCheckbox\";\nimport { SidebarSection } from \"src/components/Shared/Sidebar\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport { keyboardClickHandler } from \"src/utils/keyboard\";\n\ninterface IDuplicatedFilter {\n  criterion: DuplicatedCriterion;\n  setCriterion: (c: DuplicatedCriterion) => void;\n}\n\nexport const DuplicatedFilter: React.FC<IDuplicatedFilter> = ({\n  criterion,\n  setCriterion,\n}) => {\n  const intl = useIntl();\n\n  function onFieldChange(\n    fieldId: DuplicationFieldId,\n    value: boolean | undefined\n  ) {\n    const c = criterion.clone();\n    if (value === undefined) {\n      delete c.value[fieldId];\n    } else {\n      c.value[fieldId] = value;\n    }\n    setCriterion(c);\n  }\n\n  return (\n    <div className=\"duplicated-filter\">\n      {DUPLICATION_FIELD_IDS.map((fieldId) => (\n        <IndeterminateCheckbox\n          key={fieldId}\n          id={`duplicated-${fieldId}`}\n          label={intl.formatMessage({\n            id: DUPLICATION_FIELD_MESSAGE_IDS[fieldId],\n          })}\n          checked={criterion.value[fieldId]}\n          setChecked={(v) => onFieldChange(fieldId, v)}\n        />\n      ))}\n    </div>\n  );\n};\n\ninterface ISidebarDuplicateFilterProps {\n  title?: React.ReactNode;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  sectionID?: string;\n}\n\nexport const SidebarDuplicateFilter: React.FC<ISidebarDuplicateFilterProps> = ({\n  title,\n  filter,\n  setFilter,\n  sectionID,\n}) => {\n  const intl = useIntl();\n  const [expandedType, setExpandedType] = useState<string | null>(null);\n\n  const trueLabel = intl.formatMessage({ id: \"true\" });\n  const falseLabel = intl.formatMessage({ id: \"false\" });\n\n  // Get label for a duplicate type\n  const getLabel = useCallback(\n    (typeId: DuplicationFieldId) =>\n      intl.formatMessage({ id: DUPLICATION_FIELD_MESSAGE_IDS[typeId] }),\n    [intl]\n  );\n\n  // Get the single duplicated criterion from the filter\n  const getCriterion = useCallback((): DuplicatedCriterion | null => {\n    const criteria = filter.criteriaFor(\n      DuplicatedCriterionOption.type\n    ) as DuplicatedCriterion[];\n    return criteria.length > 0 ? criteria[0] : null;\n  }, [filter]);\n\n  // Get value for a specific type from the criterion\n  const getTypeValue = useCallback(\n    (typeId: DuplicationFieldId): boolean | undefined => {\n      const criterion = getCriterion();\n      if (!criterion) return undefined;\n      return criterion.value[typeId];\n    },\n    [getCriterion]\n  );\n\n  // Build selected items list\n  const selected: Option[] = useMemo(() => {\n    const result: Option[] = [];\n    const criterion = getCriterion();\n    if (!criterion) return result;\n\n    for (const typeId of DUPLICATION_FIELD_IDS) {\n      const value = criterion.value[typeId];\n      if (value !== undefined) {\n        const valueLabel = value ? trueLabel : falseLabel;\n        result.push({\n          id: typeId,\n          label: `${getLabel(typeId)}: ${valueLabel}`,\n        });\n      }\n    }\n\n    return result;\n  }, [getCriterion, trueLabel, falseLabel, getLabel]);\n\n  // Available options - show options that aren't already selected\n  const options = useMemo(() => {\n    const result: { id: DuplicationFieldId; label: string }[] = [];\n\n    for (const typeId of DUPLICATION_FIELD_IDS) {\n      if (getTypeValue(typeId) === undefined) {\n        result.push({ id: typeId, label: getLabel(typeId) });\n      }\n    }\n\n    return result;\n  }, [getTypeValue, getLabel]);\n\n  function onToggleExpand(id: string) {\n    setExpandedType(expandedType === id ? null : id);\n  }\n\n  function onUnselect(item: Option) {\n    const typeId = item.id as DuplicationFieldId;\n    const criterion = getCriterion();\n\n    if (!criterion) return;\n\n    const newCriterion = criterion.clone();\n    delete newCriterion.value[typeId];\n\n    // If no fields are set, remove the criterion entirely\n    const hasAnyValue = DUPLICATION_FIELD_IDS.some(\n      (id) => newCriterion.value[id] !== undefined\n    );\n\n    if (!hasAnyValue) {\n      setFilter(filter.removeCriterion(DuplicatedCriterionOption.type));\n    } else {\n      setFilter(\n        filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion])\n      );\n    }\n    setExpandedType(null);\n  }\n\n  function onSelectValue(typeId: string, value: boolean) {\n    const criterion = getCriterion();\n    const newCriterion = criterion\n      ? criterion.clone()\n      : (DuplicatedCriterionOption.makeCriterion() as DuplicatedCriterion);\n\n    newCriterion.value[typeId as DuplicationFieldId] = value;\n    setFilter(\n      filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion])\n    );\n    setExpandedType(null);\n  }\n\n  return (\n    <SidebarSection\n      className=\"sidebar-list-filter\"\n      text={title}\n      sectionID={sectionID}\n      outsideCollapse={\n        <SelectedList items={selected} onUnselect={(i) => onUnselect(i)} />\n      }\n    >\n      <div className=\"queryable-candidate-list\">\n        <ul>\n          {options.map((opt) => (\n            <React.Fragment key={opt.id}>\n              <li className=\"unselected-object\">\n                <a\n                  onClick={() => onToggleExpand(opt.id)}\n                  onKeyDown={keyboardClickHandler(() => onToggleExpand(opt.id))}\n                  tabIndex={0}\n                >\n                  <div className=\"label-group\">\n                    <Icon\n                      className=\"fa-fw include-button single-value\"\n                      icon={faPlus}\n                    />\n                    <span className=\"unselected-object-label\">{opt.label}</span>\n                  </div>\n                </a>\n              </li>\n              {expandedType === opt.id && (\n                <div className=\"duplicate-sub-options\">\n                  <div\n                    className=\"duplicate-sub-option\"\n                    onClick={() => onSelectValue(opt.id, true)}\n                  >\n                    {trueLabel}\n                  </div>\n                  <div\n                    className=\"duplicate-sub-option\"\n                    onClick={() => onSelectValue(opt.id, false)}\n                  >\n                    {falseLabel}\n                  </div>\n                </div>\n              )}\n            </React.Fragment>\n          ))}\n        </ul>\n      </div>\n    </SidebarSection>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/DurationFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\nimport { DurationInput } from \"src/components/Shared/DurationInput\";\nimport { INumberValue } from \"src/models/list-filter/types\";\nimport { ModifierCriterion } from \"src/models/list-filter/criteria/criterion\";\n\ninterface IDurationFilterProps {\n  criterion: ModifierCriterion<INumberValue>;\n  onValueChanged: (value: INumberValue) => void;\n}\n\nexport const DurationFilter: React.FC<IDurationFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  const intl = useIntl();\n\n  function onChanged(v: number | null, property: \"value\" | \"value2\") {\n    const { value } = criterion;\n    value[property] = v ?? undefined;\n    onValueChanged(value);\n  }\n\n  function renderTop() {\n    let placeholder: string;\n    if (\n      criterion.modifier === CriterionModifier.GreaterThan ||\n      criterion.modifier === CriterionModifier.Between ||\n      criterion.modifier === CriterionModifier.NotBetween\n    ) {\n      placeholder = intl.formatMessage({ id: \"criterion.greater_than\" });\n    } else if (criterion.modifier === CriterionModifier.LessThan) {\n      placeholder = intl.formatMessage({ id: \"criterion.less_than\" });\n    } else {\n      placeholder = intl.formatMessage({ id: \"criterion.value\" });\n    }\n\n    return (\n      <Form.Group>\n        <DurationInput\n          value={criterion.value?.value}\n          setValue={(v) => onChanged(v, \"value\")}\n          placeholder={placeholder}\n        />\n      </Form.Group>\n    );\n  }\n\n  function renderBottom() {\n    if (\n      criterion.modifier !== CriterionModifier.Between &&\n      criterion.modifier !== CriterionModifier.NotBetween\n    ) {\n      return;\n    }\n\n    return (\n      <Form.Group>\n        <DurationInput\n          value={criterion.value?.value2}\n          setValue={(v) => onChanged(v, \"value2\")}\n          placeholder={intl.formatMessage({ id: \"criterion.less_than\" })}\n        />\n      </Form.Group>\n    );\n  }\n\n  return (\n    <>\n      {renderTop()}\n      {renderBottom()}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/FilterButton.tsx",
    "content": "import React from \"react\";\nimport { Badge, Button } from \"react-bootstrap\";\nimport { faFilter } from \"@fortawesome/free-solid-svg-icons\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { useIntl } from \"react-intl\";\n\ninterface IFilterButtonProps {\n  count?: number;\n  onClick: () => void;\n  title?: string;\n}\n\nexport const FilterButton: React.FC<IFilterButtonProps> = ({\n  count = 0,\n  onClick,\n  title,\n}) => {\n  const intl = useIntl();\n\n  if (!title) {\n    title = intl.formatMessage({ id: \"search_filter.edit_filter\" });\n  }\n\n  return (\n    <Button\n      variant=\"secondary\"\n      className=\"filter-button\"\n      onClick={onClick}\n      title={title}\n    >\n      <Icon icon={faFilter} />\n      {count ? (\n        <Badge pill variant=\"info\">\n          {count}\n        </Badge>\n      ) : undefined}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/FilterSidebar.tsx",
    "content": "import React, { useEffect } from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport { SidebarSection } from \"src/components/Shared/Sidebar\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { SearchTermInput } from \"../ListFilter\";\nimport { SidebarSavedFilterList } from \"../SavedFilterList\";\nimport { View } from \"../views\";\nimport useFocus from \"src/utils/focus\";\nimport ScreenUtils from \"src/utils/screen\";\nimport Mousetrap from \"mousetrap\";\nimport { Button } from \"react-bootstrap\";\n\nconst savedFiltersSectionID = \"saved-filters\";\n\nexport const FilteredSidebarHeader: React.FC<{\n  sidebarOpen: boolean;\n  showEditFilter: () => void;\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  view?: View;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  sidebarOpen,\n  showEditFilter,\n  filter,\n  setFilter,\n  view,\n  focus: providedFocus,\n}) => {\n  const localFocus = useFocus();\n  const focus = providedFocus ?? localFocus;\n  const [, setFocus] = focus;\n\n  // Set the focus on the input field when the sidebar is opened\n  // Don't do this on touch devices\n  useEffect(() => {\n    if (sidebarOpen && !ScreenUtils.isTouch()) {\n      setFocus();\n    }\n  }, [sidebarOpen, setFocus]);\n\n  return (\n    <>\n      <div className=\"sidebar-search-container\">\n        <SearchTermInput\n          filter={filter}\n          onFilterUpdate={setFilter}\n          focus={focus}\n        />\n      </div>\n\n      <div>\n        <Button\n          className=\"edit-filter-button\"\n          size=\"sm\"\n          onClick={() => showEditFilter()}\n        >\n          <FormattedMessage id=\"search_filter.edit_filter\" />\n        </Button>\n      </div>\n\n      <SidebarSection\n        className=\"sidebar-saved-filters\"\n        text={<FormattedMessage id=\"search_filter.saved_filters\" />}\n        sectionID={savedFiltersSectionID}\n      >\n        <SidebarSavedFilterList\n          filter={filter}\n          onSetFilter={setFilter}\n          view={view}\n        />\n      </SidebarSection>\n    </>\n  );\n};\n\nexport function useFilteredSidebarKeybinds(props: {\n  showSidebar: boolean;\n  setShowSidebar: (show: boolean) => void;\n}) {\n  const { showSidebar, setShowSidebar } = props;\n\n  // Hide the sidebar when the user presses the \"Esc\" key\n  useEffect(() => {\n    Mousetrap.bind(\"esc\", (e) => {\n      if (showSidebar) {\n        setShowSidebar(false);\n        e.preventDefault();\n      }\n    });\n\n    return () => {\n      Mousetrap.unbind(\"esc\");\n    };\n  }, [showSidebar, setShowSidebar]);\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/FolderFilter.tsx",
    "content": "import React, { useCallback, useEffect, useMemo, useState } from \"react\";\nimport {\n  FolderDataFragment,\n  useFindFoldersForQueryQuery,\n  useFindRootFoldersForSelectQuery,\n} from \"src/core/generated-graphql\";\nimport {\n  ISidebarSectionProps,\n  SidebarSection,\n} from \"src/components/Shared/Sidebar\";\nimport {\n  faChevronDown,\n  faChevronRight,\n  faMinus,\n  faPlus,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { ExpandCollapseButton } from \"src/components/Shared/CollapseButton\";\nimport cx from \"classnames\";\nimport { queryFindSubFolders } from \"src/core/StashService\";\nimport { keyboardClickHandler } from \"src/utils/keyboard\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  FolderCriterion,\n  FolderCriterionOption,\n} from \"src/models/list-filter/criteria/folder\";\nimport { Option, SelectedList } from \"./SidebarListFilter\";\nimport {\n  defineMessages,\n  FormattedMessage,\n  MessageDescriptor,\n  useIntl,\n} from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { DepthSelector } from \"./SelectableFilter\";\nimport ClearableInput from \"src/components/Shared/ClearableInput\";\nimport { useDebouncedState } from \"src/hooks/debounce\";\nimport { ModifierCriterionOption } from \"src/models/list-filter/criteria/criterion\";\n\ninterface IFolder extends FolderDataFragment {\n  children?: IFolder[];\n  expanded: boolean;\n}\n\nconst FolderRow: React.FC<{\n  folder: IFolder;\n  level?: number;\n  canExclude?: boolean;\n  toggleExpanded: (folder: IFolder) => void;\n  onSelect: (folder: IFolder, exclude?: boolean) => void;\n}> = ({ folder, level, toggleExpanded, onSelect, canExclude }) => {\n  return (\n    <>\n      <li\n        className=\"folder-row unselected-object\"\n        style={{ paddingLeft: (level ?? 0) * 5 }}\n      >\n        <a\n          onClick={() => onSelect(folder)}\n          onKeyDown={keyboardClickHandler(() => onSelect(folder))}\n          tabIndex={0}\n        >\n          <span>\n            <span\n              className={cx({\n                empty: folder.children && folder.children.length === 0,\n              })}\n            >\n              <ExpandCollapseButton\n                collapsed={!folder.expanded}\n                setCollapsed={() => toggleExpanded(folder)}\n                collapsedIcon={faChevronRight}\n                notCollapsedIcon={faChevronDown}\n              />\n            </span>\n            {folder.basename}\n          </span>\n          {canExclude && (\n            <Button\n              onClick={(e) => {\n                e.stopPropagation();\n                onSelect(folder, true);\n              }}\n              onKeyDown={(e) => e.stopPropagation()}\n              className=\"minimal exclude-button\"\n            >\n              <span className=\"exclude-button-text\">\n                <FormattedMessage id=\"actions.exclude_lowercase\" />\n              </span>\n              <Icon className=\"fa-fw exclude-icon\" icon={faMinus} />\n            </Button>\n          )}\n        </a>\n      </li>\n      {folder.expanded &&\n        folder.children?.map((child) => (\n          <FolderRow\n            key={child.id}\n            folder={child}\n            level={(level ?? 0) + 1}\n            toggleExpanded={toggleExpanded}\n            onSelect={onSelect}\n            canExclude={canExclude}\n          />\n        ))}\n    </>\n  );\n};\n\nfunction toggleExpandedFn(object: IFolder): (f: IFolder) => IFolder {\n  return (f: IFolder) => {\n    if (f.id === object.id) {\n      return { ...f, expanded: !f.expanded };\n    }\n\n    if (f.children) {\n      return {\n        ...f,\n        children: f.children.map(toggleExpandedFn(object)),\n      };\n    }\n\n    return f;\n  };\n}\n\nfunction replaceFolder(folder: IFolder): (f: IFolder) => IFolder {\n  return (f: IFolder) => {\n    if (f.id === folder.id) {\n      return folder;\n    }\n\n    if (f.children) {\n      return {\n        ...f,\n        children: f.children.map(replaceFolder(folder)),\n      };\n    }\n\n    return f;\n  };\n}\n\nfunction useFolderMap(query: string, skip?: boolean) {\n  const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({\n    skip,\n  });\n\n  const { data: queryFoldersResult } = useFindFoldersForQueryQuery({\n    skip: !query,\n    variables: {\n      filter: { q: query, per_page: 200 },\n    },\n  });\n\n  const rootFolders: IFolder[] = useMemo(() => {\n    const ret = rootFoldersResult?.findFolders.folders ?? [];\n    return ret.map((f) => ({ ...f, expanded: false, children: undefined }));\n  }, [rootFoldersResult]);\n\n  const queryFolders: IFolder[] = useMemo(() => {\n    // construct the folder list from the query result\n    const ret: IFolder[] = [];\n\n    (queryFoldersResult?.findFolders.folders ?? []).forEach((folder) => {\n      if (!folder.parent_folders.length) {\n        // no parents, just add it if not present\n        if (!ret.find((f) => f.id === folder.id)) {\n          ret.push({ ...folder, expanded: true, children: [] });\n        }\n        return;\n      }\n\n      // expand the parent folders\n      let currentParent: IFolder | undefined;\n      for (let i = folder.parent_folders.length - 1; i >= 0; i--) {\n        const thisFolder = folder.parent_folders[i];\n        let existing: IFolder | undefined;\n\n        if (i === folder.parent_folders.length - 1) {\n          // last parent, add the folder as root\n          existing = ret.find((f) => f.id === thisFolder.id);\n          if (!existing) {\n            existing = {\n              ...folder.parent_folders[i],\n              expanded: true,\n              children: [],\n            };\n            ret.push(existing);\n          }\n          currentParent = existing;\n          continue;\n        }\n\n        // find folder in current parent's children\n        // currentParent is guaranteed to be defined here\n        existing = currentParent!.children?.find((f) => f.id === thisFolder.id);\n        if (!existing) {\n          // add to current parent's children\n          existing = {\n            ...thisFolder,\n            expanded: true,\n            children: [],\n          };\n          currentParent!.children!.push(existing);\n        }\n        currentParent = existing;\n      }\n\n      if (!currentParent) {\n        return;\n      }\n\n      if (!currentParent.children) {\n        currentParent.children = [];\n      }\n\n      // currentParent is now the immediate parent folder\n      currentParent!.children!.push({\n        ...folder,\n        expanded: false,\n        children: undefined,\n      });\n    });\n    return ret;\n  }, [queryFoldersResult]);\n\n  const [folderMap, setFolderMap] = React.useState<IFolder[]>([]);\n\n  useEffect(() => {\n    if (!query) {\n      setFolderMap(rootFolders);\n    } else {\n      setFolderMap(queryFolders);\n    }\n  }, [query, rootFolders, queryFolders]);\n\n  async function onToggleExpanded(folder: IFolder) {\n    setFolderMap(folderMap.map(toggleExpandedFn(folder)));\n\n    // query children folders if not already loaded\n    if (folder.children === undefined) {\n      const subFolderResult = await queryFindSubFolders(folder.id);\n      setFolderMap((current) =>\n        current.map(\n          replaceFolder({\n            ...folder,\n            expanded: true,\n            children: subFolderResult.data.findFolders.folders.map((f) => ({\n              ...f,\n              expanded: false,\n            })),\n          })\n        )\n      );\n    }\n  }\n\n  return { folderMap, onToggleExpanded };\n}\n\nfunction getMatchingFolders(folders: IFolder[], query: string): IFolder[] {\n  let matches: IFolder[] = [];\n\n  const queryLower = query.toLowerCase();\n\n  folders.forEach((folder) => {\n    if (\n      folder.basename.toLowerCase().includes(queryLower) ||\n      folder.path.toLowerCase() === queryLower\n    ) {\n      matches.push(folder);\n    }\n\n    if (folder.children) {\n      matches = matches.concat(getMatchingFolders(folder.children, query));\n    }\n  });\n\n  return matches;\n}\n\nexport const FolderSelector: React.FC<{\n  onSelect: (folder: IFolder, exclude?: boolean) => void;\n  canExclude?: boolean;\n  preListContent?: React.ReactNode;\n  folderMap: IFolder[];\n  onToggleExpanded: (folder: IFolder) => void;\n}> = ({\n  onSelect,\n  preListContent,\n  canExclude = false,\n  folderMap,\n  onToggleExpanded,\n}) => {\n  return (\n    <ul className=\"selectable-list\">\n      {preListContent}\n      {folderMap.map((folder) => (\n        <FolderRow\n          key={folder.id}\n          folder={folder}\n          onSelect={(f, exclude) => onSelect(f, exclude)}\n          toggleExpanded={onToggleExpanded}\n          canExclude={canExclude}\n        />\n      ))}\n    </ul>\n  );\n};\n\ninterface IInputFilterProps {\n  criterion: FolderCriterion;\n  setCriterion: (c: FolderCriterion) => void;\n}\n\nexport const FolderFilter: React.FC<IInputFilterProps> = ({\n  criterion,\n  setCriterion,\n}) => {\n  const intl = useIntl();\n  const [query, setQuery] = useState(\"\");\n  const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250);\n\n  const { folderMap, onToggleExpanded } = useFolderMap(query);\n\n  const messages = defineMessages({\n    sub_folder_depth: {\n      id: \"sub_folder_depth\",\n      defaultMessage: \"Levels (empty for all)\",\n    },\n  });\n\n  function criterionOptionTypeToIncludeID(): string {\n    return \"include-sub-folders\";\n  }\n\n  function criterionOptionTypeToIncludeUIString(): MessageDescriptor {\n    const optionType = \"include_sub_folders\";\n\n    return {\n      id: optionType,\n    };\n  }\n\n  function onDepthChanged(depth: number) {\n    // this could be ParentFolderCriterion, but the types are the same\n    const newValue = criterion.clone() as FolderCriterion;\n    newValue.value.depth = depth;\n    setCriterion(newValue);\n  }\n\n  function onSelect(folder: IFolder, exclude: boolean = false) {\n    // toggle selection\n    const newValue = criterion.clone() as FolderCriterion;\n\n    if (!exclude) {\n      if (newValue.value.items.find((i) => i.id === folder.id)) {\n        return;\n      }\n\n      newValue.value.items.push({ id: folder.id, label: folder.path });\n    } else {\n      if (newValue.value.excluded.find((i) => i.id === folder.id)) {\n        return;\n      }\n\n      newValue.value.excluded.push({ id: folder.id, label: folder.path });\n    }\n\n    setCriterion(newValue);\n  }\n\n  const onUnselect = useCallback(\n    (i: Option, excluded?: boolean) => {\n      const newValue = criterion.clone() as FolderCriterion;\n\n      if (!excluded) {\n        newValue.value.items = newValue.value.items.filter(\n          (item) => item.id !== i.id\n        );\n      } else {\n        newValue.value.excluded = newValue.value.excluded.filter(\n          (item) => item.id !== i.id\n        );\n      }\n      setCriterion(newValue);\n    },\n    [criterion, setCriterion]\n  );\n\n  function onEnter() {\n    if (!query) return;\n\n    // if there is a single folder that matches the query, select it\n    const matchingFolders = getMatchingFolders(folderMap, query);\n    if (matchingFolders.length === 1) {\n      onSelect(matchingFolders[0]);\n    }\n  }\n\n  const selectedList = useMemo(() => {\n    const selected: Option[] =\n      criterion.value?.items.map((item) => ({\n        id: item.id,\n        label: item.label,\n      })) ?? [];\n\n    return <SelectedList items={selected} onUnselect={onUnselect} />;\n  }, [criterion, onUnselect]);\n\n  const excludedList = useMemo(() => {\n    const selected: Option[] =\n      criterion.value?.excluded.map((item) => ({\n        id: item.id,\n        label: item.label,\n      })) ?? [];\n\n    return (\n      <SelectedList\n        excluded\n        items={selected}\n        onUnselect={(i) => onUnselect(i, true)}\n      />\n    );\n  }, [criterion, onUnselect]);\n\n  return (\n    <div className=\"folder-filter\">\n      <DepthSelector\n        depth={criterion.value.depth}\n        onDepthChanged={onDepthChanged}\n        id={criterionOptionTypeToIncludeID()}\n        label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}\n        placeholder={intl.formatMessage(messages.sub_folder_depth)}\n      />\n\n      <Form.Group>\n        {selectedList}\n        {excludedList}\n        <ClearableInput\n          value={displayQuery}\n          setValue={(v) => onQueryChange(v)}\n          placeholder={`${intl.formatMessage({ id: \"actions.search\" })}…`}\n          onEnter={onEnter}\n        />\n        <FolderSelector\n          folderMap={folderMap}\n          onToggleExpanded={onToggleExpanded}\n          onSelect={onSelect}\n          canExclude\n        />\n      </Form.Group>\n    </div>\n  );\n};\n\nexport const SidebarFolderFilter: React.FC<\n  ISidebarSectionProps & {\n    filter: ListFilterModel;\n    setFilter: (f: ListFilterModel) => void;\n    criterionOption?: ModifierCriterionOption;\n  }\n> = (props) => {\n  const intl = useIntl();\n  const [skip, setSkip] = useState(true);\n  const [query, setQuery] = useState(\"\");\n  const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250);\n\n  function onOpen() {\n    setSkip(false);\n    props.onOpen?.();\n  }\n\n  const { folderMap, onToggleExpanded } = useFolderMap(query, skip);\n\n  const option = props.criterionOption ?? FolderCriterionOption;\n  const { filter, setFilter } = props;\n\n  const criterion = useMemo(() => {\n    const ret = filter.criteria.find(\n      (c) => c.criterionOption.type === option.type\n    );\n    if (ret) return ret as FolderCriterion;\n\n    const newCriterion = filter.makeCriterion(option.type) as FolderCriterion;\n    return newCriterion;\n  }, [option.type, filter]);\n\n  // if there are multiple values or excluded values, then we show none of the\n  // current values\n  const multipleSelected =\n    criterion.value.items.length > 1 || criterion.value.excluded.length > 0;\n\n  function onSelect(folder: IFolder) {\n    const c = criterion.clone() as FolderCriterion;\n    c.value = {\n      items: [{ id: folder.id, label: folder.path }],\n      depth: 0,\n      excluded: [],\n    };\n\n    const newCriteria = props.filter.criteria.filter(\n      (cc) => cc.criterionOption.type !== option.type\n    );\n\n    if (c.isValid()) newCriteria.push(c);\n\n    setFilter(props.filter.setCriteria(newCriteria));\n  }\n\n  function onSelectSubfolders() {\n    const c = criterion.clone() as FolderCriterion;\n    c.value = {\n      items: c.value?.items ?? [],\n      depth: -1,\n      excluded: c.value?.excluded ?? [],\n    };\n\n    setFilter(props.filter.replaceCriteria(option.type, [c]));\n  }\n\n  const onUnselect = useCallback(\n    (i: Option) => {\n      if (i.className === \"modifier-object\") {\n        // subfolders option\n        const c = criterion.clone() as FolderCriterion;\n        c.value = {\n          items: c.value?.items ?? [],\n          depth: 0,\n          excluded: c.value?.excluded ?? [],\n        };\n\n        setFilter(props.filter.replaceCriteria(option.type, [c]));\n        return;\n      }\n\n      setFilter(props.filter.removeCriterion(option.type));\n    },\n    [props.filter, setFilter, option.type, criterion]\n  );\n\n  function onEnter() {\n    if (!query) return;\n\n    // if there is a single folder that matches the query, select it\n    const matchingFolders = getMatchingFolders(folderMap, query);\n    if (matchingFolders.length === 1) {\n      onSelect(matchingFolders[0]);\n    }\n  }\n\n  const subDirsSelected = criterion.value?.depth === -1;\n\n  const selectedList = useMemo(() => {\n    if (multipleSelected) {\n      return null;\n    }\n\n    const selected: Option[] =\n      criterion.value?.items.map((item) => ({\n        id: item.id,\n        label: item.label,\n      })) ?? [];\n\n    if (subDirsSelected) {\n      selected.push({\n        id: \"subfolders\",\n        label: \"(\" + intl.formatMessage({ id: \"sub_folders\" }) + \")\",\n        className: \"modifier-object\",\n      });\n    }\n\n    return <SelectedList items={selected} onUnselect={onUnselect} />;\n  }, [intl, multipleSelected, subDirsSelected, criterion, onUnselect]);\n\n  const modifierItem = criterion.value.items.length > 0 &&\n    !multipleSelected &&\n    !subDirsSelected && (\n      <li className=\"unselected-object modifier-object\">\n        <a onClick={onSelectSubfolders}>\n          <span>\n            <Icon className={`fa-fw include-button`} icon={faPlus} />\n            (<FormattedMessage id=\"sub_folders\" />)\n          </span>\n        </a>\n      </li>\n    );\n\n  return (\n    <SidebarSection\n      {...props}\n      outsideCollapse={selectedList}\n      onOpen={onOpen}\n      className=\"sidebar-list-filter sidebar-folder-filter\"\n    >\n      <ClearableInput\n        value={displayQuery}\n        setValue={(v) => onQueryChange(v)}\n        placeholder={`${intl.formatMessage({ id: \"actions.search\" })}…`}\n        onEnter={onEnter}\n      />\n\n      <FolderSelector\n        folderMap={folderMap}\n        onToggleExpanded={onToggleExpanded}\n        preListContent={modifierItem}\n        onSelect={(f) => onSelect(f)}\n      />\n    </SidebarSection>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { defineMessages, MessageDescriptor, useIntl } from \"react-intl\";\nimport { FilterSelect, SelectObject } from \"src/components/Shared/Select\";\nimport { ModifierCriterion } from \"src/models/list-filter/criteria/criterion\";\nimport { IHierarchicalLabelValue } from \"src/models/list-filter/types\";\nimport { NumberField } from \"src/utils/form\";\n\ninterface IHierarchicalLabelValueFilterProps {\n  criterion: ModifierCriterion<IHierarchicalLabelValue>;\n  onValueChanged: (value: IHierarchicalLabelValue) => void;\n}\n\nexport const HierarchicalLabelValueFilter: React.FC<\n  IHierarchicalLabelValueFilterProps\n> = ({ criterion, onValueChanged }) => {\n  const criterionOption = criterion.modifierCriterionOption();\n  const { type, inputType } = criterionOption;\n\n  const intl = useIntl();\n\n  if (\n    inputType !== \"studios\" &&\n    inputType !== \"tags\" &&\n    inputType !== \"scene_tags\" &&\n    inputType !== \"performer_tags\" &&\n    inputType !== \"groups\"\n  ) {\n    return null;\n  }\n\n  const messages = defineMessages({\n    studio_depth: {\n      id: \"studio_depth\",\n      defaultMessage: \"Levels (empty for all)\",\n    },\n  });\n\n  function onSelectionChanged(items: SelectObject[]) {\n    const { value } = criterion;\n    value.items = items.map((i) => ({\n      id: i.id,\n      label: i.name ?? i.title ?? \"\",\n    }));\n    onValueChanged(value);\n  }\n\n  function onDepthChanged(depth: number) {\n    const { value } = criterion;\n    value.depth = depth;\n    onValueChanged(value);\n  }\n\n  function criterionOptionTypeToIncludeID(): string {\n    if (inputType === \"studios\") {\n      return \"include-sub-studios\";\n    }\n    if (inputType === \"groups\") {\n      return \"include-sub-groups\";\n    }\n    if (type === \"children\") {\n      return \"include-parent-tags\";\n    }\n    console.log(inputType);\n    return \"include-sub-tags\";\n  }\n\n  function criterionOptionTypeToIncludeUIString(): MessageDescriptor {\n    let id: string;\n    if (inputType === \"studios\") {\n      id = \"include_sub_studios\";\n    } else if (inputType === \"groups\") {\n      id = \"include_sub_groups\";\n    } else if (type === \"children\") {\n      id = \"include_parent_tags\";\n    } else {\n      id = \"include_sub_tags\";\n    }\n\n    return {\n      id,\n    };\n  }\n\n  return (\n    <>\n      <Form.Group>\n        <FilterSelect\n          type={inputType}\n          isMulti\n          onSelect={onSelectionChanged}\n          ids={criterion.value.items.map((labeled) => labeled.id)}\n          menuPortalTarget={document.body}\n        />\n      </Form.Group>\n\n      <Form.Group>\n        <Form.Check\n          id={criterionOptionTypeToIncludeID()}\n          checked={criterion.value.depth !== 0}\n          label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}\n          onChange={() => onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)}\n        />\n      </Form.Group>\n\n      {criterion.value.depth !== 0 && (\n        <Form.Group>\n          <NumberField\n            className=\"btn-secondary\"\n            placeholder={intl.formatMessage(messages.studio_depth)}\n            onChange={(e) =>\n              onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)\n            }\n            defaultValue={\n              criterion.value && criterion.value.depth !== -1\n                ? criterion.value.depth\n                : \"\"\n            }\n            min=\"1\"\n          />\n        </Form.Group>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/InputFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport {\n  ModifierCriterion,\n  CriterionValue,\n} from \"../../../models/list-filter/criteria/criterion\";\n\ninterface IInputFilterProps {\n  criterion: ModifierCriterion<CriterionValue>;\n  onValueChanged: (value: string) => void;\n}\n\nexport const InputFilter: React.FC<IInputFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  function onChanged(event: React.ChangeEvent<HTMLInputElement>) {\n    onValueChanged(event.target.value);\n  }\n\n  return (\n    <>\n      <Form.Group>\n        <Form.Control\n          className=\"btn-secondary\"\n          type={criterion.modifierCriterionOption().inputType}\n          onChange={onChanged}\n          value={criterion.value ? criterion.value.toString() : \"\"}\n        />\n      </Form.Group>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx",
    "content": "import React, { useCallback, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { FilterSelect, SelectObject } from \"src/components/Shared/Select\";\nimport { objectTitle } from \"src/core/files\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { ILoadResults, useCacheResults } from \"src/hooks/data\";\nimport {\n  CriterionOption,\n  ModifierCriterion,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  IHierarchicalLabelValue,\n  ILabeledId,\n  ILabeledValueListValue,\n} from \"src/models/list-filter/types\";\nimport { Option } from \"./SidebarListFilter\";\nimport {\n  CriterionModifier,\n  FilterMode,\n  GalleryFilterType,\n  GroupFilterType,\n  ImageFilterType,\n  InputMaybe,\n  IntCriterionInput,\n  PerformerFilterType,\n  SceneFilterType,\n  SceneMarkerFilterType,\n  StudioFilterType,\n} from \"src/core/generated-graphql\";\nimport { useIntl } from \"react-intl\";\n\ninterface ILabeledIdFilterProps {\n  criterion: ModifierCriterion<ILabeledId[]>;\n  onValueChanged: (value: ILabeledId[]) => void;\n}\n\nexport const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  const criterionOption = criterion.modifierCriterionOption();\n  const { inputType } = criterionOption;\n\n  if (\n    inputType !== \"performers\" &&\n    inputType !== \"studios\" &&\n    inputType !== \"scene_tags\" &&\n    inputType !== \"performer_tags\" &&\n    inputType !== \"tags\" &&\n    inputType !== \"scenes\" &&\n    inputType !== \"groups\" &&\n    inputType !== \"galleries\"\n  ) {\n    return null;\n  }\n\n  function getLabel(i: SelectObject) {\n    switch (inputType) {\n      case \"galleries\":\n        return galleryTitle(i);\n      case \"scenes\":\n        return objectTitle(i);\n    }\n\n    return i.name ?? i.title ?? \"\";\n  }\n\n  function onSelectionChanged(items: SelectObject[]) {\n    onValueChanged(\n      items.map((i) => ({\n        id: i.id,\n        label: getLabel(i),\n      }))\n    );\n  }\n\n  return (\n    <Form.Group>\n      <FilterSelect\n        type={inputType}\n        isMulti\n        onSelect={onSelectionChanged}\n        ids={criterion.value.map((labeled) => labeled.id)}\n        menuPortalTarget={document.body}\n      />\n    </Form.Group>\n  );\n};\n\nexport type ModifierValue = \"any\" | \"none\" | \"any_of\" | \"only\" | \"include_subs\";\n\nexport function getModifierCandidates(props: {\n  modifier: CriterionModifier;\n  defaultModifier: CriterionModifier;\n  hasSelected?: boolean;\n  hasExcluded?: boolean;\n  singleValue?: boolean;\n  hierarchical?: boolean;\n}) {\n  const {\n    modifier,\n    defaultModifier,\n    hasSelected,\n    hasExcluded,\n    singleValue,\n    hierarchical,\n  } = props;\n  const ret: ModifierValue[] = [];\n\n  if (modifier === defaultModifier && !hasSelected && !hasExcluded) {\n    ret.push(\"any\");\n  }\n  if (modifier === defaultModifier && !hasSelected && !hasExcluded) {\n    ret.push(\"none\");\n  }\n  if (!singleValue && modifier === defaultModifier && hasSelected) {\n    ret.push(\"any_of\");\n  }\n  if (\n    hierarchical &&\n    modifier === defaultModifier &&\n    (hasSelected || hasExcluded)\n  ) {\n    ret.push(\"include_subs\");\n  }\n  if (\n    !singleValue &&\n    modifier === defaultModifier &&\n    hasSelected &&\n    !hasExcluded\n  ) {\n    ret.push(\"only\");\n  }\n  return ret;\n}\n\nexport function modifierValueToModifier(key: ModifierValue): CriterionModifier {\n  switch (key) {\n    case \"any\":\n      return CriterionModifier.NotNull;\n    case \"none\":\n      return CriterionModifier.IsNull;\n    case \"any_of\":\n      return CriterionModifier.Includes;\n    case \"only\":\n      return CriterionModifier.Equals;\n  }\n\n  throw new Error(\"Invalid modifier value\");\n}\n\nfunction getDefaultModifier(singleValue: boolean) {\n  if (singleValue) {\n    return CriterionModifier.Includes;\n  }\n  return CriterionModifier.IncludesAll;\n}\n\nexport function useSelectionState(props: {\n  criterion: ModifierCriterion<ILabeledValueListValue>;\n  setCriterion: (c: ModifierCriterion<ILabeledValueListValue>) => void;\n  singleValue?: boolean;\n  hierarchical?: boolean;\n  includeSubMessageID?: string;\n}) {\n  const intl = useIntl();\n\n  const {\n    criterion,\n    setCriterion,\n    singleValue = false,\n    hierarchical = false,\n    includeSubMessageID,\n  } = props;\n  const { modifier } = criterion;\n\n  const defaultModifier = getDefaultModifier(singleValue);\n\n  const selectedModifiers = useMemo(() => {\n    return {\n      any: modifier === CriterionModifier.NotNull,\n      none: modifier === CriterionModifier.IsNull,\n      any_of: !singleValue && modifier === CriterionModifier.Includes,\n      only: !singleValue && modifier === CriterionModifier.Equals,\n      include_subs:\n        hierarchical &&\n        modifier === defaultModifier &&\n        (criterion.value as IHierarchicalLabelValue).depth === -1,\n    };\n  }, [modifier, singleValue, criterion.value, defaultModifier, hierarchical]);\n\n  const selected = useMemo(() => {\n    const modifierValues: Option[] = Object.entries(selectedModifiers)\n      .filter((v) => v[1])\n      .map((v) => {\n        const messageID =\n          v[0] === \"include_subs\"\n            ? includeSubMessageID\n            : `criterion_modifier_values.${v[0]}`;\n\n        return {\n          id: v[0],\n          label: `(${intl.formatMessage({\n            id: messageID,\n          })})`,\n          className: \"modifier-object\",\n        };\n      });\n\n    return modifierValues.concat(\n      criterion.value.items.map((s) => ({\n        id: s.id,\n        label: s.label,\n      }))\n    );\n  }, [intl, selectedModifiers, criterion.value.items, includeSubMessageID]);\n\n  const excluded = useMemo(() => {\n    return criterion.value.excluded.map((s) => ({\n      id: s.id,\n      label: s.label,\n    }));\n  }, [criterion.value.excluded]);\n\n  const includingOnly = modifier == CriterionModifier.Equals;\n  const excludingOnly =\n    modifier == CriterionModifier.Excludes ||\n    modifier == CriterionModifier.NotEquals;\n\n  const onSelect = useCallback(\n    (v: Option, exclude: boolean) => {\n      const newCriterion: ModifierCriterion<ILabeledValueListValue> =\n        criterion.clone();\n\n      if (v.className === \"modifier-object\") {\n        if (v.id === \"include_subs\") {\n          (newCriterion.value as IHierarchicalLabelValue).depth = -1;\n          setCriterion(newCriterion);\n          return;\n        }\n\n        newCriterion.modifier = modifierValueToModifier(v.id as ModifierValue);\n        setCriterion(newCriterion);\n        return;\n      }\n\n      // if only exclude is allowed, then add to excluded\n      if (excludingOnly) {\n        exclude = true;\n      }\n\n      const items = !exclude ? criterion.value.items : criterion.value.excluded;\n      const newItems = [...items, v];\n\n      if (!exclude) {\n        newCriterion.value.items = newItems;\n      } else {\n        newCriterion.value.excluded = newItems;\n      }\n      setCriterion(newCriterion);\n    },\n    [excludingOnly, criterion, setCriterion]\n  );\n\n  const onUnselect = useCallback(\n    (v: Option, exclude: boolean) => {\n      const newCriterion = criterion.clone();\n\n      if (v.className === \"modifier-object\") {\n        if (v.id === \"include_subs\") {\n          newCriterion.value.depth = 0;\n          setCriterion(newCriterion);\n          return;\n        }\n        newCriterion.modifier = defaultModifier;\n        setCriterion(newCriterion);\n        return;\n      }\n\n      const items = !exclude ? criterion.value.items : criterion.value.excluded;\n      const newItems = items.filter((i) => i.id !== v.id);\n\n      if (!exclude) {\n        newCriterion.value.items = newItems;\n      } else {\n        newCriterion.value.excluded = newItems;\n      }\n      setCriterion(newCriterion);\n    },\n    [criterion, setCriterion, defaultModifier]\n  );\n\n  return { selected, excluded, onSelect, onUnselect, includingOnly };\n}\n\nexport function useCriterion(\n  option: CriterionOption,\n  filter: ListFilterModel,\n  setFilter: (f: ListFilterModel) => void\n) {\n  const criterion = useMemo(() => {\n    const ret = filter.criteria.find(\n      (c) => c.criterionOption.type === option.type\n    );\n    if (ret) return ret as ModifierCriterion<ILabeledValueListValue>;\n\n    const newCriterion = filter.makeCriterion(\n      option.type\n    ) as ModifierCriterion<ILabeledValueListValue>;\n    return newCriterion;\n  }, [filter, option]);\n\n  const setCriterion = useCallback(\n    (c: ModifierCriterion<ILabeledValueListValue>) => {\n      const newCriteria = filter.criteria.filter(\n        (cc) => cc.criterionOption.type !== option.type\n      );\n\n      if (c.isValid()) newCriteria.push(c);\n\n      setFilter(filter.setCriteria(newCriteria));\n    },\n    [option.type, setFilter, filter]\n  );\n\n  return { criterion, setCriterion };\n}\n\nexport interface IUseQueryHookProps {\n  q: string;\n  filter?: ListFilterModel;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  skip: boolean;\n}\n\nexport function useQueryState(\n  useQuery: (props: IUseQueryHookProps) => ILoadResults<ILabeledId[]>,\n  filter: ListFilterModel,\n  skip: boolean,\n  options?: {\n    filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  }\n) {\n  const [query, setQuery] = useState(\"\");\n  const { results: queryResults } = useCacheResults(\n    useQuery({ q: query, filter, filterHook: options?.filterHook, skip })\n  );\n\n  return { query, setQuery, queryResults };\n}\n\nexport function useCandidates(props: {\n  criterion: ModifierCriterion<ILabeledValueListValue>;\n  queryResults: ILabeledId[] | undefined;\n  selected: Option[];\n  excluded: Option[];\n  hierarchical?: boolean;\n  singleValue?: boolean;\n  includeSubMessageID?: string;\n}) {\n  const intl = useIntl();\n\n  const {\n    criterion,\n    queryResults,\n    selected,\n    excluded,\n    hierarchical = false,\n    singleValue = false,\n    includeSubMessageID,\n  } = props;\n  const { modifier } = criterion;\n\n  const results = useMemo(() => {\n    if (\n      !queryResults ||\n      modifier === CriterionModifier.IsNull ||\n      modifier === CriterionModifier.NotNull\n    ) {\n      return [];\n    }\n\n    return queryResults.filter(\n      (p) =>\n        selected.find((s) => s.id === p.id) === undefined &&\n        excluded.find((s) => s.id === p.id) === undefined\n    );\n  }, [queryResults, modifier, selected, excluded]);\n\n  const defaultModifier = getDefaultModifier(singleValue);\n\n  const candidates = useMemo(() => {\n    return (results ?? []).map((r) => ({\n      id: r.id,\n      label: r.label,\n    }));\n  }, [results]);\n\n  const modifierCandidates = useMemo(() => {\n    const hierarchicalCandidate =\n      hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1;\n\n    return getModifierCandidates({\n      modifier,\n      defaultModifier,\n      hasSelected: selected.length > 0,\n      hasExcluded: excluded.length > 0,\n      singleValue,\n      hierarchical: hierarchicalCandidate,\n    }).map((v) => {\n      const messageID =\n        v === \"include_subs\"\n          ? includeSubMessageID\n          : `criterion_modifier_values.${v}`;\n\n      return {\n        id: v,\n        label: `(${intl.formatMessage({\n          id: messageID,\n        })})`,\n        className: \"modifier-object\",\n        canExclude: false,\n      };\n    });\n  }, [\n    defaultModifier,\n    intl,\n    modifier,\n    singleValue,\n    selected,\n    excluded,\n    criterion.value,\n    hierarchical,\n    includeSubMessageID,\n  ]);\n\n  return { candidates, modifierCandidates };\n}\n\nexport function useLabeledIdFilterState(props: {\n  option: CriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  useQuery: (props: IUseQueryHookProps) => ILoadResults<ILabeledId[]>;\n  singleValue?: boolean;\n  hierarchical?: boolean;\n  includeSubMessageID?: string;\n}) {\n  const {\n    option,\n    filter,\n    setFilter,\n    filterHook,\n    useQuery,\n    singleValue = false,\n    hierarchical = false,\n    includeSubMessageID,\n  } = props;\n\n  // defer querying until the user opens the filter\n  const [skip, setSkip] = useState(true);\n\n  const { query, setQuery, queryResults } = useQueryState(\n    useQuery,\n    filter,\n    skip,\n    { filterHook }\n  );\n\n  const { criterion, setCriterion } = useCriterion(option, filter, setFilter);\n\n  const { selected, excluded, onSelect, onUnselect, includingOnly } =\n    useSelectionState({\n      criterion,\n      setCriterion,\n      singleValue,\n      hierarchical,\n      includeSubMessageID,\n    });\n\n  const { candidates, modifierCandidates } = useCandidates({\n    criterion,\n    queryResults,\n    selected,\n    excluded,\n    hierarchical,\n    singleValue,\n    includeSubMessageID,\n  });\n\n  const onOpen = useCallback(() => {\n    setSkip(false);\n  }, []);\n\n  return {\n    candidates,\n    modifierCandidates,\n    onSelect,\n    onUnselect,\n    selected,\n    excluded,\n    canExclude: !includingOnly,\n    query,\n    setQuery,\n    onOpen,\n  };\n}\n\nexport function makeQueryVariables(query: string, extraProps: {}) {\n  return {\n    filter: {\n      q: query,\n      per_page: 200,\n    },\n    ...extraProps,\n  };\n}\n\ninterface IFilterType {\n  scenes_filter?: InputMaybe<SceneFilterType>;\n  scene_count?: InputMaybe<IntCriterionInput>;\n  performers_filter?: InputMaybe<PerformerFilterType>;\n  performer_count?: InputMaybe<IntCriterionInput>;\n  galleries_filter?: InputMaybe<GalleryFilterType>;\n  gallery_count?: InputMaybe<IntCriterionInput>;\n  images_filter?: InputMaybe<ImageFilterType>;\n  image_count?: InputMaybe<IntCriterionInput>;\n  groups_filter?: InputMaybe<GroupFilterType>;\n  group_count?: InputMaybe<IntCriterionInput>;\n  studios_filter?: InputMaybe<StudioFilterType>;\n  studio_count?: InputMaybe<IntCriterionInput>;\n  marker_count?: InputMaybe<IntCriterionInput>;\n  markers_filter?: InputMaybe<SceneMarkerFilterType>;\n}\n\nexport function setObjectFilter(\n  out: IFilterType,\n  mode: FilterMode,\n  relatedFilterOutput:\n    | SceneFilterType\n    | PerformerFilterType\n    | GalleryFilterType\n    | GroupFilterType\n    | StudioFilterType\n) {\n  const empty = Object.keys(relatedFilterOutput).length === 0;\n\n  switch (mode) {\n    case FilterMode.Scenes:\n      // if empty, only get objects with scenes\n      if (empty) {\n        out.scene_count = {\n          modifier: CriterionModifier.GreaterThan,\n          value: 0,\n        };\n        break;\n      }\n      out.scenes_filter = relatedFilterOutput as SceneFilterType;\n      break;\n    case FilterMode.Performers:\n      // if empty, only get objects with performers\n      if (empty) {\n        out.performer_count = {\n          modifier: CriterionModifier.GreaterThan,\n          value: 0,\n        };\n        break;\n      }\n      out.performers_filter = relatedFilterOutput as PerformerFilterType;\n      break;\n    case FilterMode.Galleries:\n      // if empty, only get objects with galleries\n      if (empty) {\n        out.gallery_count = {\n          modifier: CriterionModifier.GreaterThan,\n          value: 0,\n        };\n        break;\n      }\n      out.galleries_filter = relatedFilterOutput as GalleryFilterType;\n      break;\n    case FilterMode.Images:\n      // if empty, only get objects with galleries\n      if (empty) {\n        out.image_count = {\n          modifier: CriterionModifier.GreaterThan,\n          value: 0,\n        };\n        break;\n      }\n      out.images_filter = relatedFilterOutput as ImageFilterType;\n      break;\n    case FilterMode.Groups:\n      // if empty, only get objects with groups\n      if (empty) {\n        out.group_count = {\n          modifier: CriterionModifier.GreaterThan,\n          value: 0,\n        };\n        break;\n      }\n      out.groups_filter = relatedFilterOutput as GroupFilterType;\n      break;\n    case FilterMode.Studios:\n      // if empty, only get objects with studios\n      if (empty) {\n        out.studio_count = {\n          modifier: CriterionModifier.GreaterThan,\n          value: 0,\n        };\n        break;\n      }\n      out.studios_filter = relatedFilterOutput as StudioFilterType;\n      break;\n    case FilterMode.SceneMarkers:\n      // if empty, only get objects with scene markers\n      if (empty) {\n        out.marker_count = {\n          modifier: CriterionModifier.GreaterThan,\n          value: 0,\n        };\n        break;\n      }\n      out.markers_filter = relatedFilterOutput as SceneMarkerFilterType;\n      break;\n    default:\n      throw new Error(\"Invalid filter mode\");\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/NumberFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { CriterionModifier } from \"../../../core/generated-graphql\";\nimport { INumberValue } from \"../../../models/list-filter/types\";\nimport { NumberCriterion } from \"../../../models/list-filter/criteria/criterion\";\nimport { NumberField } from \"src/utils/form\";\n\ninterface IDurationFilterProps {\n  criterion: NumberCriterion;\n  onValueChanged: (value: INumberValue) => void;\n}\n\nexport const NumberFilter: React.FC<IDurationFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  const intl = useIntl();\n\n  const { value } = criterion;\n\n  function onChanged(\n    event: React.ChangeEvent<HTMLInputElement>,\n    property: \"value\" | \"value2\"\n  ) {\n    const numericValue = parseInt(event.target.value, 10);\n    const valueCopy = { ...value };\n\n    valueCopy[property] = !Number.isNaN(numericValue) ? numericValue : 0;\n    onValueChanged(valueCopy);\n  }\n\n  let equalsControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.Equals ||\n    criterion.modifier === CriterionModifier.NotEquals\n  ) {\n    equalsControl = (\n      <Form.Group>\n        <NumberField\n          className=\"btn-secondary\"\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            onChanged(e, \"value\")\n          }\n          value={value?.value ?? \"\"}\n          placeholder={intl.formatMessage({ id: \"criterion.value\" })}\n        />\n      </Form.Group>\n    );\n  }\n\n  let lowerControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.GreaterThan ||\n    criterion.modifier === CriterionModifier.Between ||\n    criterion.modifier === CriterionModifier.NotBetween\n  ) {\n    lowerControl = (\n      <Form.Group>\n        <NumberField\n          className=\"btn-secondary\"\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            onChanged(e, \"value\")\n          }\n          value={value?.value ?? \"\"}\n          placeholder={intl.formatMessage({ id: \"criterion.greater_than\" })}\n        />\n      </Form.Group>\n    );\n  }\n\n  let upperControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.LessThan ||\n    criterion.modifier === CriterionModifier.Between ||\n    criterion.modifier === CriterionModifier.NotBetween\n  ) {\n    upperControl = (\n      <Form.Group>\n        <NumberField\n          className=\"btn-secondary\"\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            onChanged(\n              e,\n              criterion.modifier === CriterionModifier.LessThan\n                ? \"value\"\n                : \"value2\"\n            )\n          }\n          value={\n            (criterion.modifier === CriterionModifier.LessThan\n              ? value?.value\n              : value?.value2) ?? \"\"\n          }\n          placeholder={intl.formatMessage({ id: \"criterion.less_than\" })}\n        />\n      </Form.Group>\n    );\n  }\n\n  return (\n    <>\n      {equalsControl}\n      {lowerControl}\n      {upperControl}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/OptionFilter.tsx",
    "content": "import cloneDeep from \"lodash-es/cloneDeep\";\nimport React, { useMemo } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport {\n  CriterionValue,\n  ModifierCriterion,\n  ModifierCriterionOption,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { Option, SidebarListFilter } from \"./SidebarListFilter\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\nimport {\n  getModifierCandidates,\n  ModifierValue,\n  modifierValueToModifier,\n} from \"./LabeledIdFilter\";\nimport { useIntl } from \"react-intl\";\n\ninterface IOptionsFilter {\n  criterion: ModifierCriterion<CriterionValue>;\n  setCriterion: (c: ModifierCriterion<CriterionValue>) => void;\n}\n\nexport const OptionFilter: React.FC<IOptionsFilter> = ({\n  criterion,\n  setCriterion,\n}) => {\n  function onSelect(v: string) {\n    const c = cloneDeep(criterion);\n    if (c.value === v) {\n      c.value = \"\";\n    } else {\n      c.value = v;\n    }\n\n    setCriterion(c);\n  }\n\n  const { options } = criterion.modifierCriterionOption();\n\n  return (\n    <div className=\"option-list-filter\">\n      {options?.map((o) => (\n        <Form.Check\n          id={`${criterion.getId()}-${o.toString()}`}\n          key={o.toString()}\n          onChange={() => onSelect(o.toString())}\n          checked={criterion.value === o.toString()}\n          type=\"radio\"\n          label={o.toString()}\n        />\n      ))}\n    </div>\n  );\n};\n\ninterface IOptionsListFilter {\n  criterion: ModifierCriterion<CriterionValue>;\n  setCriterion: (c: ModifierCriterion<CriterionValue>) => void;\n}\n\nexport const OptionListFilter: React.FC<IOptionsListFilter> = ({\n  criterion,\n  setCriterion,\n}) => {\n  function onSelect(v: string) {\n    const c = cloneDeep(criterion);\n    const cv = c.value as string[];\n    if (cv.includes(v)) {\n      c.value = cv.filter((x) => x !== v);\n    } else {\n      c.value = [...cv, v];\n    }\n\n    setCriterion(c);\n  }\n\n  const { options } = criterion.modifierCriterionOption();\n  const value = criterion.value as string[];\n\n  return (\n    <div className=\"option-list-filter\">\n      {options?.map((o) => (\n        <Form.Check\n          id={`${criterion.getId()}-${o.toString()}`}\n          key={o.toString()}\n          onChange={() => onSelect(o.toString())}\n          checked={value.includes(o.toString())}\n          type=\"checkbox\"\n          label={o.toString()}\n        />\n      ))}\n    </div>\n  );\n};\n\ninterface ISidebarFilter {\n  title?: React.ReactNode;\n  option: ModifierCriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  sectionID?: string;\n}\n\nexport const SidebarOptionFilter: React.FC<ISidebarFilter> = ({\n  title,\n  option,\n  filter,\n  setFilter,\n  sectionID,\n}) => {\n  const intl = useIntl();\n\n  const criteria = filter.criteriaFor(\n    option.type\n  ) as ModifierCriterion<CriterionValue>[];\n  const criterion = criteria.length > 0 ? criteria[0] : null;\n  const { options: criterionOptions = [] } = option;\n  const currentValues = criteria.flatMap((c) => c.value as string[]);\n\n  const hasNullModifiers =\n    option.modifierOptions.includes(CriterionModifier.IsNull) &&\n    option.modifierOptions.includes(CriterionModifier.NotNull);\n\n  const selected: Option[] = useMemo(() => {\n    if (!criterion) return [];\n\n    if (criterion.modifier === CriterionModifier.IsNull) {\n      return [\n        {\n          id: \"none\",\n          label: intl.formatMessage({ id: \"criterion_modifier_values.none\" }),\n        },\n      ];\n    } else if (criterion.modifier === CriterionModifier.NotNull) {\n      return [\n        {\n          id: \"any\",\n          label: intl.formatMessage({ id: \"criterion_modifier_values.any\" }),\n        },\n      ];\n    }\n\n    return criterionOptions\n      .filter((o) => currentValues.includes(o.toString()))\n      .map((o) => ({\n        id: o.toString(),\n        label: o.toLocaleString(),\n      }));\n  }, [criterion, currentValues, criterionOptions, intl]);\n\n  const modifierCandidates: Option[] = useMemo(() => {\n    if (!hasNullModifiers) return [];\n\n    const c = getModifierCandidates({\n      modifier: criterion?.modifier ?? option.defaultModifier,\n      defaultModifier: option.defaultModifier,\n      hasExcluded: false,\n      hasSelected: selected.length > 0,\n      singleValue: true, // so that it doesn't include any_of\n    });\n\n    return c.map((v) => {\n      const messageID = `criterion_modifier_values.${v}`;\n\n      return {\n        id: v,\n        label: `(${intl.formatMessage({\n          id: messageID,\n        })})`,\n        className: \"modifier-object\",\n        canExclude: false,\n      };\n    });\n  }, [criterion, option, selected, hasNullModifiers, intl]);\n\n  const options = useMemo(() => {\n    const o = criterionOptions\n      .filter((oo) => !currentValues.includes(oo.toString()))\n      .map((oo) => ({\n        id: oo.toString(),\n        label: oo.toString(),\n      }));\n\n    return [...modifierCandidates, ...o];\n  }, [criterionOptions, currentValues, modifierCandidates]);\n\n  function onSelect(item: Option) {\n    const newCriterion = criterion ? criterion.clone() : option.makeCriterion();\n\n    if (item.className === \"modifier-object\") {\n      newCriterion.modifier = modifierValueToModifier(item.id as ModifierValue);\n      newCriterion.value = [];\n      setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n      return;\n    }\n\n    const cv = newCriterion.value as string[];\n    if (cv.includes(item.id)) {\n      return;\n    } else {\n      newCriterion.value = [...cv, item.id];\n    }\n\n    setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n  }\n\n  function onUnselect(item: Option) {\n    if (item.className === \"modifier-object\") {\n      const newCriterion = criterion\n        ? criterion.clone()\n        : option.makeCriterion();\n      newCriterion.modifier = option.defaultModifier;\n      setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n      return;\n    }\n\n    setFilter(filter.removeCriterion(option.type));\n  }\n\n  return (\n    <>\n      <SidebarListFilter\n        title={title}\n        candidates={options}\n        onSelect={onSelect}\n        onUnselect={onUnselect}\n        selected={selected}\n        singleValue\n        sectionID={sectionID}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/PathFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { FolderSelect } from \"src/components/Shared/FolderSelect/FolderSelect\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport {\n  ModifierCriterion,\n  CriterionValue,\n} from \"../../../models/list-filter/criteria/criterion\";\n\ninterface IInputFilterProps {\n  criterion: ModifierCriterion<CriterionValue>;\n  onValueChanged: (value: string) => void;\n}\n\nexport const PathFilter: React.FC<IInputFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  const { configuration } = useConfigurationContext();\n  const libraryPaths = configuration?.general.stashes.map((s) => s.path);\n\n  // don't show folder select for regex\n  const regex =\n    criterion.modifier === CriterionModifier.MatchesRegex ||\n    criterion.modifier === CriterionModifier.NotMatchesRegex;\n\n  return (\n    <Form.Group>\n      {regex ? (\n        <Form.Control\n          className=\"btn-secondary\"\n          type={criterion.modifierCriterionOption().inputType}\n          onChange={(v) => onValueChanged(v.target.value)}\n          value={criterion.value ? criterion.value.toString() : \"\"}\n        />\n      ) : (\n        <FolderSelect\n          currentDirectory={criterion.value ? criterion.value.toString() : \"\"}\n          onChangeDirectory={onValueChanged}\n          collapsible\n          quotePath\n          hideError\n          defaultDirectories={libraryPaths}\n        />\n      )}\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/PerformersFilter.tsx",
    "content": "import React, { ReactNode, useMemo } from \"react\";\nimport {\n  PerformersCriterion,\n  PerformersCriterionOption,\n} from \"src/models/list-filter/criteria/performers\";\nimport {\n  CriterionModifier,\n  FindPerformersForSelectQueryVariables,\n  PerformerDataFragment,\n  PerformerFilterType,\n  useFindPerformersForSelectQuery,\n} from \"src/core/generated-graphql\";\nimport { ObjectsFilter } from \"./SelectableFilter\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { CriterionOption } from \"src/models/list-filter/criteria/criterion\";\nimport {\n  IUseQueryHookProps,\n  makeQueryVariables,\n  setObjectFilter,\n  useLabeledIdFilterState,\n} from \"./LabeledIdFilter\";\nimport { SidebarListFilter } from \"./SidebarListFilter\";\nimport { FormattedMessage } from \"react-intl\";\n\ninterface IPerformersFilter {\n  criterion: PerformersCriterion;\n  setCriterion: (c: PerformersCriterion) => void;\n}\n\ninterface IHasModifier {\n  modifier: CriterionModifier;\n}\n\nfunction queryVariables(\n  query: string,\n  f?: ListFilterModel\n): FindPerformersForSelectQueryVariables {\n  const performerFilter: PerformerFilterType = {};\n\n  if (f) {\n    const filterOutput = f.makeFilter();\n\n    // if performer modifier is includes, take it out of the filter\n    if (\n      (filterOutput.performers as IHasModifier)?.modifier ===\n      CriterionModifier.Includes\n    ) {\n      delete filterOutput.performers;\n\n      // TODO - look for same in AND?\n    }\n\n    setObjectFilter(performerFilter, f.mode, filterOutput);\n  }\n\n  return makeQueryVariables(query, { performer_filter: performerFilter });\n}\n\nfunction sortResults(\n  query: string,\n  performers?: Pick<PerformerDataFragment, \"name\" | \"alias_list\" | \"id\">[]\n) {\n  return sortByRelevance(\n    query,\n    performers ?? [],\n    (p) => p.name,\n    (p) => p.alias_list\n  ).map((p) => {\n    return {\n      id: p.id,\n      label: p.name,\n    };\n  });\n}\n\nfunction usePerformerQueryFilter(props: IUseQueryHookProps) {\n  const { q: query, filter: f, skip, filterHook } = props;\n  const appliedFilter = filterHook && f ? filterHook(f.clone()) : f;\n\n  const { data, loading } = useFindPerformersForSelectQuery({\n    variables: queryVariables(query, appliedFilter),\n    skip,\n  });\n\n  const results = useMemo(\n    () => sortResults(query, data?.findPerformers.performers),\n    [data, query]\n  );\n\n  return { results, loading };\n}\n\nfunction usePerformerQuery(query: string, skip?: boolean) {\n  return usePerformerQueryFilter({ q: query, skip: !!skip });\n}\n\nconst PerformersFilter: React.FC<IPerformersFilter> = ({\n  criterion,\n  setCriterion,\n}) => {\n  return (\n    <ObjectsFilter\n      criterion={criterion}\n      setCriterion={setCriterion}\n      useResults={usePerformerQuery}\n    />\n  );\n};\n\nexport const SidebarPerformersFilter: React.FC<{\n  title?: ReactNode;\n  option?: CriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  filterHook?: (f: ListFilterModel) => ListFilterModel;\n  sectionID?: string;\n}> = ({\n  title = <FormattedMessage id=\"performers\" />,\n  option = PerformersCriterionOption,\n  filter,\n  setFilter,\n  filterHook,\n  sectionID = \"performers\",\n}) => {\n  const state = useLabeledIdFilterState({\n    filter,\n    setFilter,\n    filterHook,\n    option,\n    useQuery: usePerformerQueryFilter,\n  });\n\n  return (\n    <SidebarListFilter\n      {...state}\n      data-type={option.type}\n      title={title}\n      sectionID={sectionID}\n    />\n  );\n};\n\nexport default PerformersFilter;\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/PhashFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { IPhashDistanceValue } from \"../../../models/list-filter/types\";\nimport { ModifierCriterion } from \"../../../models/list-filter/criteria/criterion\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\nimport { NumberField } from \"src/utils/form\";\n\ninterface IPhashFilterProps {\n  criterion: ModifierCriterion<IPhashDistanceValue>;\n  onValueChanged: (value: IPhashDistanceValue) => void;\n}\n\nexport const PhashFilter: React.FC<IPhashFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  const intl = useIntl();\n  const { value } = criterion;\n\n  function valueChanged(event: React.ChangeEvent<HTMLInputElement>) {\n    onValueChanged({\n      value: event.target.value,\n      distance: criterion.value.distance,\n    });\n  }\n\n  function distanceChanged(event: React.ChangeEvent<HTMLInputElement>) {\n    let distance = parseInt(event.target.value);\n    if (distance < 0 || isNaN(distance)) {\n      distance = 0;\n    }\n\n    onValueChanged({\n      distance,\n      value: criterion.value.value,\n    });\n  }\n\n  return (\n    <div>\n      <Form.Group>\n        <Form.Control\n          className=\"btn-secondary\"\n          onChange={valueChanged}\n          value={value ? value.value : \"\"}\n          placeholder={intl.formatMessage({ id: \"media_info.phash\" })}\n        />\n      </Form.Group>\n      {criterion.modifier !== CriterionModifier.IsNull &&\n        criterion.modifier !== CriterionModifier.NotNull && (\n          <Form.Group>\n            <NumberField\n              className=\"btn-secondary\"\n              onChange={distanceChanged}\n              value={value ? value.distance : \"\"}\n              placeholder={intl.formatMessage({ id: \"distance\" })}\n            />\n          </Form.Group>\n        )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/RatingFilter.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { CriterionModifier } from \"../../../core/generated-graphql\";\nimport { INumberValue } from \"../../../models/list-filter/types\";\nimport {\n  CriterionOption,\n  ModifierCriterion,\n} from \"../../../models/list-filter/criteria/criterion\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport { RatingStars } from \"src/components/Shared/Rating/RatingStars\";\nimport {\n  defaultRatingStarPrecision,\n  defaultRatingSystemOptions,\n} from \"src/utils/rating\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport {\n  RatingCriterion,\n  RatingCriterionOption,\n} from \"src/models/list-filter/criteria/rating\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { Option, SidebarListFilter } from \"./SidebarListFilter\";\n\ninterface IRatingFilterProps {\n  criterion: ModifierCriterion<INumberValue>;\n  onValueChanged: (value: INumberValue) => void;\n}\n\nexport const RatingFilter: React.FC<IRatingFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  function getRatingSystem(field: \"value\" | \"value2\") {\n    const defaultValue = field === \"value\" ? 0 : undefined;\n\n    return (\n      <div>\n        <RatingSystem\n          value={criterion.value[field]}\n          onSetRating={(value) => {\n            onValueChanged({\n              ...criterion.value,\n              [field]: value ?? defaultValue,\n            });\n          }}\n          valueRequired\n        />\n      </div>\n    );\n  }\n\n  if (\n    criterion.modifier === CriterionModifier.Equals ||\n    criterion.modifier === CriterionModifier.NotEquals ||\n    criterion.modifier === CriterionModifier.GreaterThan ||\n    criterion.modifier === CriterionModifier.LessThan\n  ) {\n    return getRatingSystem(\"value\");\n  }\n\n  if (\n    criterion.modifier === CriterionModifier.Between ||\n    criterion.modifier === CriterionModifier.NotBetween\n  ) {\n    return (\n      <div className=\"rating-filter\">\n        {getRatingSystem(\"value\")}\n        <span className=\"and-divider\">\n          <FormattedMessage id=\"between_and\" />\n        </span>\n        {getRatingSystem(\"value2\")}\n      </div>\n    );\n  }\n\n  return <></>;\n};\n\ninterface ISidebarFilter {\n  title?: React.ReactNode;\n  option?: CriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  sectionID?: string;\n}\n\nconst any = \"any\";\nconst none = \"none\";\n\nexport const SidebarRatingFilter: React.FC<ISidebarFilter> = ({\n  title = <FormattedMessage id=\"rating\" />,\n  option = RatingCriterionOption,\n  filter,\n  setFilter,\n  sectionID = \"rating\",\n}) => {\n  const intl = useIntl();\n\n  const anyLabel = `(${intl.formatMessage({\n    id: \"criterion_modifier_values.any\",\n  })})`;\n  const noneLabel = `(${intl.formatMessage({\n    id: \"criterion_modifier_values.none\",\n  })})`;\n\n  const anyOption = useMemo(\n    () => ({\n      id: \"any\",\n      label: anyLabel,\n      className: \"modifier-object\",\n    }),\n    [anyLabel]\n  );\n\n  const noneOption = useMemo(\n    () => ({\n      id: \"none\",\n      label: noneLabel,\n      className: \"modifier-object\",\n    }),\n    [noneLabel]\n  );\n\n  const { configuration: config } = useConfigurationContext();\n  const ratingSystemOptions =\n    config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;\n\n  const options: Option[] = useMemo(() => {\n    return [anyOption, noneOption];\n  }, [anyOption, noneOption]);\n\n  const criteria = filter.criteriaFor(option.type) as RatingCriterion[];\n  const criterion = criteria.length > 0 ? criteria[0] : null;\n\n  const selected: Option[] = useMemo(() => {\n    if (!criterion) return [];\n\n    if (criterion.modifier === CriterionModifier.NotNull) {\n      return [anyOption];\n    } else if (criterion.modifier === CriterionModifier.IsNull) {\n      return [noneOption];\n    }\n\n    return [];\n  }, [anyOption, noneOption, criterion]);\n\n  const ratingValue = useMemo(() => {\n    if (!criterion || criterion.modifier !== CriterionModifier.GreaterThan) {\n      return null;\n    }\n\n    return criterion.value.value ?? null;\n  }, [criterion]);\n\n  function onSelect(item: Option) {\n    const newCriterion = criterion ? criterion.clone() : option.makeCriterion();\n\n    if (item.id === any) {\n      newCriterion.modifier = CriterionModifier.NotNull;\n      // newCriterion.value\n    } else if (item.id === none) {\n      newCriterion.modifier = CriterionModifier.IsNull;\n    }\n\n    setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n  }\n\n  function onUnselect() {\n    setFilter(filter.removeCriterion(option.type));\n  }\n\n  function onRatingValueChange(value: number | null) {\n    const newCriterion = criterion ? criterion.clone() : option.makeCriterion();\n    if (value === null) {\n      setFilter(filter.removeCriterion(option.type));\n      return;\n    }\n\n    newCriterion.modifier = CriterionModifier.GreaterThan;\n    newCriterion.value.value = value - 1;\n\n    setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n  }\n\n  const ratingStars = (\n    <div className=\"no-icon-margin\">\n      <RatingStars\n        value={ratingValue}\n        onSetRating={onRatingValueChange}\n        precision={\n          ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision\n        }\n        orMore\n      />\n    </div>\n  );\n  return (\n    <>\n      <SidebarListFilter\n        data-type={option.type}\n        title={title}\n        candidates={options}\n        onSelect={onSelect}\n        onUnselect={onUnselect}\n        selected={selected}\n        singleValue\n        preCandidates={ratingValue === null ? ratingStars : undefined}\n        preSelected={ratingValue !== null ? ratingStars : undefined}\n        sectionID={sectionID}\n      />\n      <div></div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/SelectableFilter.tsx",
    "content": "import React, { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport {\n  faCheckCircle,\n  faMinus,\n  faPlus,\n  faTimesCircle,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { faTimesCircle as faTimesCircleRegular } from \"@fortawesome/free-regular-svg-icons\";\nimport { ClearableInput } from \"src/components/Shared/ClearableInput\";\nimport {\n  IHierarchicalLabelValue,\n  ILabeledId,\n  ILabeledValueListValue,\n} from \"src/models/list-filter/types\";\nimport { cloneDeep } from \"lodash-es\";\nimport {\n  ModifierCriterion,\n  IHierarchicalLabeledIdCriterion,\n} from \"src/models/list-filter/criteria/criterion\";\nimport {\n  defineMessages,\n  FormattedMessage,\n  MessageDescriptor,\n  useIntl,\n} from \"react-intl\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\nimport { keyboardClickHandler } from \"src/utils/keyboard\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport useFocus from \"src/utils/focus\";\nimport cx from \"classnames\";\nimport ScreenUtils from \"src/utils/screen\";\nimport { NumberField } from \"src/utils/form\";\n\ninterface ISelectedItem {\n  label: string;\n  excluded?: boolean;\n  onClick: () => void;\n  // true if the object is a special modifier value\n  modifier?: boolean;\n}\n\nconst SelectedItem: React.FC<ISelectedItem> = ({\n  label,\n  excluded = false,\n  onClick,\n  modifier = false,\n}) => {\n  const iconClassName = excluded ? \"exclude-icon\" : \"include-button\";\n  const spanClassName = excluded\n    ? \"excluded-object-label\"\n    : \"selected-object-label\";\n  const [hovered, setHovered] = useState(false);\n\n  const icon = useMemo(() => {\n    if (!hovered) {\n      return excluded ? faTimesCircle : faCheckCircle;\n    }\n\n    return faTimesCircleRegular;\n  }, [hovered, excluded]);\n\n  function onMouseOver() {\n    setHovered(true);\n  }\n\n  function onMouseOut() {\n    setHovered(false);\n  }\n\n  return (\n    <li className={cx(\"selected-object\", { \"modifier-object\": modifier })}>\n      <a\n        onClick={() => onClick()}\n        onKeyDown={keyboardClickHandler(onClick)}\n        onMouseEnter={() => onMouseOver()}\n        onMouseLeave={() => onMouseOut()}\n        onFocus={() => onMouseOver()}\n        onBlur={() => onMouseOut()}\n        tabIndex={0}\n      >\n        <div>\n          <Icon className={`fa-fw ${iconClassName}`} icon={icon} />\n          <span className={spanClassName}>{label}</span>\n        </div>\n        <div></div>\n      </a>\n    </li>\n  );\n};\n\nconst UnselectedItem: React.FC<{\n  onSelect: (exclude: boolean) => void;\n  label: string;\n  canExclude: boolean;\n  // true if the object is a special modifier value\n  modifier?: boolean;\n}> = ({ onSelect, label, canExclude, modifier = false }) => {\n  const includeIcon = <Icon className=\"fa-fw include-button\" icon={faPlus} />;\n  const excludeIcon = <Icon className=\"fa-fw exclude-icon\" icon={faMinus} />;\n\n  return (\n    <li className={cx(\"unselected-object\", { \"modifier-object\": modifier })}>\n      <a\n        onClick={() => onSelect(false)}\n        onKeyDown={keyboardClickHandler(() => onSelect(false))}\n        tabIndex={0}\n      >\n        <div>\n          {includeIcon}\n          <span className=\"unselected-object-label\">{label}</span>\n        </div>\n        <div>\n          {/* TODO item count */}\n          {/* <span className=\"object-count\">{p.id}</span> */}\n          {canExclude && (\n            <Button\n              onClick={(e) => {\n                e.stopPropagation();\n                onSelect(true);\n              }}\n              onKeyDown={(e) => e.stopPropagation()}\n              className=\"minimal exclude-button\"\n            >\n              <span className=\"exclude-button-text\">\n                <FormattedMessage id=\"actions.exclude_lowercase\" />\n              </span>\n              {excludeIcon}\n            </Button>\n          )}\n        </div>\n      </a>\n    </li>\n  );\n};\n\ninterface ISelectableFilter {\n  query: string;\n  onQueryChange: (query: string) => void;\n  modifier: CriterionModifier;\n  showModifierValues: boolean;\n  inputFocus: ReturnType<typeof useFocus>;\n  canExclude: boolean;\n  queryResults: ILabeledId[];\n  selected: ILabeledId[];\n  excluded: ILabeledId[];\n  onSelect: (value: ILabeledId, exclude: boolean) => void;\n  onUnselect: (value: ILabeledId) => void;\n  onSetModifier: (modifier: CriterionModifier) => void;\n  // true if the filter is for a single value\n  singleValue?: boolean;\n}\n\ntype SpecialValue = \"any\" | \"none\" | \"any_of\" | \"only\";\n\nfunction modifierValueToModifier(key: SpecialValue): CriterionModifier {\n  switch (key) {\n    case \"any\":\n      return CriterionModifier.NotNull;\n    case \"none\":\n      return CriterionModifier.IsNull;\n    case \"any_of\":\n      return CriterionModifier.Includes;\n    case \"only\":\n      return CriterionModifier.Equals;\n  }\n}\n\nconst SelectableFilter: React.FC<ISelectableFilter> = ({\n  query,\n  onQueryChange,\n  modifier,\n  showModifierValues,\n  inputFocus,\n  canExclude,\n  queryResults,\n  selected,\n  excluded,\n  onSelect,\n  onUnselect,\n  onSetModifier,\n  singleValue,\n}) => {\n  const intl = useIntl();\n  const objects = useMemo(() => {\n    if (\n      modifier === CriterionModifier.IsNull ||\n      modifier === CriterionModifier.NotNull\n    ) {\n      return [];\n    }\n    return queryResults.filter(\n      (p) =>\n        selected.find((s) => s.id === p.id) === undefined &&\n        excluded.find((s) => s.id === p.id) === undefined\n    );\n  }, [modifier, queryResults, selected, excluded]);\n\n  const includingOnly = modifier == CriterionModifier.Equals;\n  const excludingOnly =\n    modifier == CriterionModifier.Excludes ||\n    modifier == CriterionModifier.NotEquals;\n\n  const modifierValues = useMemo(() => {\n    return {\n      any: modifier === CriterionModifier.NotNull,\n      none: modifier === CriterionModifier.IsNull,\n      any_of: !singleValue && modifier === CriterionModifier.Includes,\n      only: !singleValue && modifier === CriterionModifier.Equals,\n    };\n  }, [modifier, singleValue]);\n\n  const defaultModifier = useMemo(() => {\n    if (singleValue) {\n      return CriterionModifier.Includes;\n    }\n    return CriterionModifier.IncludesAll;\n  }, [singleValue]);\n\n  const availableModifierValues: Record<SpecialValue, boolean> = useMemo(() => {\n    return {\n      any:\n        modifier === defaultModifier &&\n        selected.length === 0 &&\n        excluded.length === 0,\n      none:\n        modifier === defaultModifier &&\n        selected.length === 0 &&\n        excluded.length === 0,\n      any_of:\n        !singleValue && modifier === defaultModifier && selected.length > 1,\n      only:\n        !singleValue &&\n        modifier === defaultModifier &&\n        selected.length > 0 &&\n        excluded.length === 0,\n    };\n  }, [singleValue, defaultModifier, modifier, selected, excluded]);\n\n  function onModifierValueSelect(key: SpecialValue) {\n    const m = modifierValueToModifier(key);\n    onSetModifier(m);\n  }\n\n  function onModifierValueUnselect() {\n    onSetModifier(defaultModifier);\n  }\n\n  function onEnter() {\n    if (objects.length === 1) {\n      onSelect(objects[0], false);\n    }\n  }\n\n  return (\n    <div className=\"selectable-filter\">\n      <ClearableInput\n        focus={inputFocus}\n        value={query}\n        setValue={(v) => onQueryChange(v)}\n        onEnter={onEnter}\n        placeholder={`${intl.formatMessage({ id: \"actions.search\" })}…`}\n      />\n      <ul>\n        {Object.entries(modifierValues).map(([key, value]) => {\n          if (!value) {\n            return null;\n          }\n\n          return (\n            <SelectedItem\n              key={key}\n              onClick={() => onModifierValueUnselect()}\n              label={`(${intl.formatMessage({\n                id: `criterion_modifier_values.${key}`,\n              })})`}\n              modifier\n            />\n          );\n        })}\n        {selected.map((p) => (\n          <SelectedItem\n            key={p.id}\n            label={p.label}\n            excluded={excludingOnly}\n            onClick={() => onUnselect(p)}\n          />\n        ))}\n        {excluded.map((p) => (\n          <li key={p.id} className=\"excluded-object\">\n            <SelectedItem\n              label={p.label}\n              excluded\n              onClick={() => onUnselect(p)}\n            />\n          </li>\n        ))}\n        {showModifierValues && (\n          <>\n            {Object.entries(availableModifierValues).map(([key, value]) => {\n              if (!value) {\n                return null;\n              }\n\n              return (\n                <UnselectedItem\n                  key={key}\n                  onSelect={() => onModifierValueSelect(key as SpecialValue)}\n                  label={`(${intl.formatMessage({\n                    id: `criterion_modifier_values.${key}`,\n                  })})`}\n                  canExclude={false}\n                  modifier\n                />\n              );\n            })}\n          </>\n        )}\n        {objects.map((p) => (\n          <UnselectedItem\n            key={p.id}\n            onSelect={(exclude) => onSelect(p, exclude)}\n            label={p.label}\n            canExclude={canExclude && !includingOnly && !excludingOnly}\n          />\n        ))}\n      </ul>\n    </div>\n  );\n};\n\ninterface IObjectsFilter<T extends ModifierCriterion<ILabeledValueListValue>> {\n  criterion: T;\n  setCriterion: (criterion: T) => void;\n  useResults: (query: string) => { results: ILabeledId[]; loading: boolean };\n  singleValue?: boolean;\n}\n\nexport const ObjectsFilter = <\n  T extends ModifierCriterion<ILabeledValueListValue | IHierarchicalLabelValue>\n>({\n  criterion,\n  setCriterion,\n  useResults,\n  singleValue,\n}: IObjectsFilter<T>) => {\n  const [query, setQuery] = useState(\"\");\n  const [displayQuery, setDisplayQuery] = useState(query);\n\n  const debouncedSetQuery = useDebounce(setQuery, 250);\n  const onQueryChange = useCallback(\n    (input: string) => {\n      setDisplayQuery(input);\n      debouncedSetQuery(input);\n    },\n    [debouncedSetQuery, setDisplayQuery]\n  );\n\n  const [queryResults, setQueryResults] = useState<ILabeledId[]>([]);\n  const { results, loading: resultsLoading } = useResults(query);\n  useEffect(() => {\n    if (!resultsLoading) {\n      setQueryResults(results);\n    }\n  }, [results, resultsLoading]);\n\n  const inputFocus = useFocus();\n  const [, setInputFocus] = inputFocus;\n\n  function onSelect(value: ILabeledId, newExclude: boolean) {\n    let newCriterion: T = cloneDeep(criterion);\n\n    if (newExclude) {\n      if (newCriterion.value.excluded) {\n        newCriterion.value.excluded.push(value);\n      } else {\n        newCriterion.value.excluded = [value];\n      }\n    } else {\n      newCriterion.value.items.push(value);\n    }\n\n    setCriterion(newCriterion);\n\n    // reset filter query after selecting\n    debouncedSetQuery.cancel();\n    setQuery(\"\");\n    setDisplayQuery(\"\");\n\n    // focus the input box\n    // don't do this on touch devices, as it's annoying\n    if (!ScreenUtils.isTouch()) {\n      setInputFocus();\n    }\n  }\n\n  const onUnselect = useCallback(\n    (value: ILabeledId) => {\n      if (!criterion) return;\n\n      let newCriterion: T = cloneDeep(criterion);\n\n      newCriterion.value.items = criterion.value.items.filter(\n        (v) => v.id !== value.id\n      );\n      newCriterion.value.excluded = criterion.value.excluded.filter(\n        (v) => v.id !== value.id\n      );\n\n      setCriterion(newCriterion);\n\n      // focus the input box\n      setInputFocus();\n    },\n    [criterion, setCriterion, setInputFocus]\n  );\n\n  const onSetModifier = useCallback(\n    (modifier: CriterionModifier) => {\n      let newCriterion: T = criterion.clone();\n      newCriterion.modifier = modifier;\n      setCriterion(newCriterion);\n    },\n    [criterion, setCriterion]\n  );\n\n  const sortedSelected = useMemo(() => {\n    const ret = criterion.value.items.slice();\n    ret.sort((a, b) => a.label.localeCompare(b.label));\n    return ret;\n  }, [criterion]);\n\n  const sortedExcluded = useMemo(() => {\n    if (!criterion.value.excluded) return [];\n    const ret = criterion.value.excluded.slice();\n    ret.sort((a, b) => a.label.localeCompare(b.label));\n    return ret;\n  }, [criterion]);\n\n  // if excludes is not a valid modifierOption then we can use `value.excluded`\n  const canExclude =\n    criterion\n      .modifierCriterionOption()\n      .modifierOptions.find((m) => m === CriterionModifier.Excludes) ===\n    undefined;\n\n  return (\n    <SelectableFilter\n      query={displayQuery}\n      onQueryChange={onQueryChange}\n      modifier={criterion.modifier}\n      showModifierValues={!query}\n      inputFocus={inputFocus}\n      canExclude={canExclude}\n      selected={sortedSelected}\n      queryResults={queryResults}\n      onSelect={onSelect}\n      onUnselect={onUnselect}\n      excluded={sortedExcluded}\n      onSetModifier={onSetModifier}\n      singleValue={singleValue}\n    />\n  );\n};\n\nexport const DepthSelector: React.FC<{\n  depth: number | undefined;\n  onDepthChanged: (depth: number) => void;\n  id: string;\n  label?: React.ReactNode;\n  placeholder?: string;\n  disabled?: boolean;\n}> = ({ depth, onDepthChanged, id, label, disabled, placeholder }) => {\n  return (\n    <Form.Group>\n      <Form.Group>\n        <Form.Check\n          id={id}\n          checked={depth !== 0}\n          label={label}\n          onChange={() => onDepthChanged(depth !== 0 ? 0 : -1)}\n          disabled={disabled}\n        />\n      </Form.Group>\n      {depth !== 0 && (\n        <Form.Group>\n          <NumberField\n            className=\"btn-secondary\"\n            placeholder={placeholder}\n            onChange={(e) =>\n              onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)\n            }\n            defaultValue={depth !== -1 ? depth : \"\"}\n            min=\"1\"\n          />\n        </Form.Group>\n      )}\n    </Form.Group>\n  );\n};\n\ninterface IHierarchicalObjectsFilter<T extends IHierarchicalLabeledIdCriterion>\n  extends IObjectsFilter<T> {}\n\nexport const HierarchicalObjectsFilter = <\n  T extends IHierarchicalLabeledIdCriterion\n>(\n  props: IHierarchicalObjectsFilter<T>\n) => {\n  const intl = useIntl();\n  const { criterion, setCriterion } = props;\n\n  const messages = defineMessages({\n    studio_depth: {\n      id: \"studio_depth\",\n      defaultMessage: \"Levels (empty for all)\",\n    },\n  });\n\n  function onDepthChanged(depth: number) {\n    let newCriterion: T = cloneDeep(criterion);\n    newCriterion.value.depth = depth;\n    setCriterion(newCriterion);\n  }\n\n  function criterionOptionTypeToIncludeID(): string {\n    if (criterion.criterionOption.type === \"studios\") {\n      return \"include-sub-studios\";\n    }\n    if (criterion.criterionOption.type === \"children\") {\n      return \"include-parent-tags\";\n    }\n    return \"include-sub-tags\";\n  }\n\n  function criterionOptionTypeToIncludeUIString(): MessageDescriptor {\n    const optionType =\n      criterion.criterionOption.type === \"studios\"\n        ? \"include_sub_studios\"\n        : criterion.criterionOption.type === \"children\"\n        ? \"include_parent_tags\"\n        : \"include_sub_tags\";\n    return {\n      id: optionType,\n    };\n  }\n\n  return (\n    <div>\n      <DepthSelector\n        depth={criterion.value.depth}\n        onDepthChanged={onDepthChanged}\n        id={criterionOptionTypeToIncludeID()}\n        label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}\n        placeholder={intl.formatMessage(messages.studio_depth)}\n      />\n      <ObjectsFilter {...props} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { CriterionModifier } from \"../../../core/generated-graphql\";\nimport { CriterionOption } from \"../../../models/list-filter/criteria/criterion\";\nimport { NumberCriterion } from \"src/models/list-filter/criteria/criterion\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { Option, SidebarListFilter } from \"./SidebarListFilter\";\nimport { DoubleRangeInput } from \"src/components/Shared/DoubleRangeInput\";\nimport { useDebounce } from \"src/hooks/debounce\";\n\ninterface ISidebarFilter {\n  title?: React.ReactNode;\n  option: CriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  sectionID?: string;\n}\n\n// Age presets\nconst AGE_PRESETS = [\n  { id: \"18-25\", label: \"18-25\", min: 18, max: 25 },\n  { id: \"25-35\", label: \"25-35\", min: 25, max: 35 },\n  { id: \"35-45\", label: \"35-45\", min: 35, max: 45 },\n  { id: \"45-60\", label: \"45-60\", min: 45, max: 60 },\n  { id: \"60+\", label: \"60+\", min: 60, max: null },\n];\n\nconst MAX_AGE = 60; // Maximum age for the slider\nconst MAX_LABEL = \"60+\"; // Display label for maximum age\n\nexport const SidebarAgeFilter: React.FC<ISidebarFilter> = ({\n  title,\n  option,\n  filter,\n  setFilter,\n  sectionID,\n}) => {\n  const criteria = filter.criteriaFor(option.type) as NumberCriterion[];\n  const criterion = criteria.length > 0 ? criteria[0] : null;\n\n  // Get current values from criterion\n  const currentMin = criterion?.value?.value ?? 18;\n  const currentMax = criterion?.value?.value2 ?? MAX_AGE;\n\n  const [sliderMin, setSliderMin] = useState(currentMin);\n  const [sliderMax, setSliderMax] = useState(currentMax);\n  const [minInput, setMinInput] = useState(currentMin.toString());\n  const [maxInput, setMaxInput] = useState(\n    currentMax >= MAX_AGE ? MAX_LABEL : currentMax.toString()\n  );\n\n  // Reset slider when criterion is removed externally (via filter tag X)\n  useEffect(() => {\n    if (!criterion) {\n      setSliderMin(18);\n      setSliderMax(MAX_AGE);\n      setMinInput(\"18\");\n      setMaxInput(MAX_LABEL);\n    }\n  }, [criterion]);\n\n  // Determine which preset is selected\n  const selectedPreset = useMemo(() => {\n    if (!criterion) return null;\n\n    // Check if current values match any preset\n    for (const preset of AGE_PRESETS) {\n      if (preset.max === null) {\n        // For \"60+\" preset\n        if (\n          criterion.modifier === CriterionModifier.GreaterThan &&\n          criterion.value.value === preset.min\n        ) {\n          return preset.id;\n        }\n      } else {\n        // For range presets\n        if (\n          criterion.modifier === CriterionModifier.Between &&\n          criterion.value.value === preset.min &&\n          criterion.value.value2 === preset.max\n        ) {\n          return preset.id;\n        }\n      }\n    }\n\n    // Check if it's a custom range or custom GreaterThan\n    if (\n      criterion.modifier === CriterionModifier.Between ||\n      criterion.modifier === CriterionModifier.GreaterThan\n    ) {\n      return \"custom\";\n    }\n\n    return null;\n  }, [criterion]);\n\n  const options: Option[] = useMemo(() => {\n    return AGE_PRESETS.map((preset) => ({\n      id: preset.id,\n      label: preset.label,\n      className: \"age-preset\",\n    }));\n  }, []);\n\n  const selected: Option[] = useMemo(() => {\n    if (!selectedPreset) return [];\n    if (selectedPreset === \"custom\") return [];\n\n    const preset = AGE_PRESETS.find((p) => p.id === selectedPreset);\n    if (preset) {\n      return [\n        {\n          id: preset.id,\n          label: preset.label,\n          className: \"age-preset\",\n        },\n      ];\n    }\n    return [];\n  }, [selectedPreset]);\n\n  function onSelectPreset(item: Option) {\n    const preset = AGE_PRESETS.find((p) => p.id === item.id);\n    if (!preset) return;\n\n    setSliderMin(preset.min);\n    setSliderMax(preset.max ?? MAX_AGE);\n    setMinInput(preset.min.toString());\n    setMaxInput(preset.max === null ? MAX_LABEL : preset.max.toString());\n\n    const currentCriteria = filter.criteriaFor(\n      option.type\n    ) as NumberCriterion[];\n    const currentCriterion =\n      currentCriteria.length > 0 ? currentCriteria[0] : null;\n    const newCriterion = currentCriterion\n      ? currentCriterion.clone()\n      : option.makeCriterion();\n\n    if (preset.max === null) {\n      // \"60+\" - use GreaterThan\n      newCriterion.modifier = CriterionModifier.GreaterThan;\n      newCriterion.value.value = preset.min;\n      newCriterion.value.value2 = undefined;\n    } else {\n      // Range preset - use Between\n      newCriterion.modifier = CriterionModifier.Between;\n      newCriterion.value.value = preset.min;\n      newCriterion.value.value2 = preset.max;\n    }\n\n    setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n  }\n\n  function onUnselectPreset() {\n    setSliderMin(18);\n    setSliderMax(MAX_AGE);\n    setMinInput(\"18\");\n    setMaxInput(MAX_LABEL);\n    setFilter(filter.removeCriterion(option.type));\n  }\n\n  // Parse age input (supports formats like \"25\", \"100+\")\n  function parseAgeInput(input: string): number | null {\n    const trimmed = input.trim().toLowerCase();\n\n    if (trimmed === \"max\" || trimmed === MAX_LABEL.toLowerCase()) {\n      return MAX_AGE;\n    }\n\n    const age = parseInt(trimmed);\n    if (isNaN(age) || age < 18 || age > MAX_AGE) {\n      return null;\n    }\n\n    return age;\n  }\n\n  // Filter update\n  function updateFilter(min: number, max: number) {\n    // If slider is at full range (18 to max), remove the filter entirely\n    if (min === 18 && max >= MAX_AGE) {\n      setFilter(filter.removeCriterion(option.type));\n      return;\n    }\n\n    const currentCriteria = filter.criteriaFor(\n      option.type\n    ) as NumberCriterion[];\n    const currentCriterion =\n      currentCriteria.length > 0 ? currentCriteria[0] : null;\n    const newCriterion = currentCriterion\n      ? currentCriterion.clone()\n      : option.makeCriterion();\n\n    // If max is at MAX_AGE (but min > 18), use GreaterThan\n    if (max >= MAX_AGE) {\n      newCriterion.modifier = CriterionModifier.GreaterThan;\n      newCriterion.value.value = min;\n      newCriterion.value.value2 = undefined;\n    } else {\n      newCriterion.modifier = CriterionModifier.Between;\n      newCriterion.value.value = min;\n      newCriterion.value.value2 = max;\n    }\n\n    setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n  }\n\n  const updateFilterDebounceMS = 300;\n  const debounceUpdateFilter = useDebounce(\n    updateFilter,\n    updateFilterDebounceMS\n  );\n\n  function handleSliderChange(min: number, max: number) {\n    setSliderMin(min);\n    setSliderMax(max);\n    setMinInput(min.toString());\n    setMaxInput(max >= MAX_AGE ? MAX_LABEL : max.toString());\n\n    debounceUpdateFilter(min, max);\n  }\n\n  function handleMinInputChange(value: string) {\n    setMinInput(value);\n  }\n\n  function handleMaxInputChange(value: string) {\n    setMaxInput(value);\n  }\n\n  function handleMinInputBlur() {\n    const parsed = parseAgeInput(minInput);\n    if (parsed !== null && parsed >= 18 && parsed < sliderMax) {\n      handleSliderChange(parsed, sliderMax);\n    } else {\n      // Reset to current value if invalid\n      setMinInput(sliderMin.toString());\n    }\n  }\n\n  function handleMaxInputBlur() {\n    const parsed = parseAgeInput(maxInput);\n    if (parsed !== null && parsed > sliderMin && parsed <= MAX_AGE) {\n      handleSliderChange(sliderMin, parsed);\n    } else {\n      // Reset to current value if invalid\n      setMaxInput(sliderMax >= MAX_AGE ? MAX_LABEL : sliderMax.toString());\n    }\n  }\n\n  const customSlider = (\n    <div className=\"age-slider-container\">\n      <DoubleRangeInput\n        min={18}\n        max={MAX_AGE}\n        value={[sliderMin, sliderMax]}\n        onChange={([min, max]) => handleSliderChange(min, max)}\n        minInput={\n          <input\n            type=\"text\"\n            className=\"age-label-input\"\n            value={minInput}\n            onChange={(e) => handleMinInputChange(e.target.value)}\n            onBlur={handleMinInputBlur}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                e.currentTarget.blur();\n              }\n            }}\n            placeholder=\"18\"\n          />\n        }\n        maxInput={\n          <input\n            type=\"text\"\n            className=\"age-label-input\"\n            value={maxInput}\n            onChange={(e) => handleMaxInputChange(e.target.value)}\n            onBlur={handleMaxInputBlur}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                e.currentTarget.blur();\n              }\n            }}\n            placeholder={MAX_LABEL}\n          />\n        }\n      />\n    </div>\n  );\n\n  return (\n    <SidebarListFilter\n      title={title}\n      candidates={options}\n      onSelect={onSelectPreset}\n      onUnselect={onUnselectPreset}\n      selected={selected}\n      singleValue\n      preCandidates={selectedPreset === null ? customSlider : undefined}\n      preSelected={\n        selectedPreset === \"custom\" || selectedPreset ? customSlider : undefined\n      }\n      sectionID={sectionID}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { CriterionModifier } from \"../../../core/generated-graphql\";\nimport { CriterionOption } from \"../../../models/list-filter/criteria/criterion\";\nimport { DurationCriterion } from \"src/models/list-filter/criteria/criterion\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { Option, SidebarListFilter } from \"./SidebarListFilter\";\nimport TextUtils from \"src/utils/text\";\nimport { DoubleRangeInput } from \"src/components/Shared/DoubleRangeInput\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport { FormattedMessage } from \"react-intl\";\nimport { DurationCriterionOption } from \"src/models/list-filter/scenes\";\n\ninterface ISidebarFilter {\n  title?: React.ReactNode;\n  option?: CriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  sectionID?: string;\n}\n\n// Duration presets in seconds\nconst DURATION_PRESETS = [\n  { id: \"0-5\", label: \"0-5 min\", min: 0, max: 300 },\n  { id: \"5-10\", label: \"5-10 min\", min: 300, max: 600 },\n  { id: \"10-20\", label: \"10-20 min\", min: 600, max: 1200 },\n  { id: \"20-40\", label: \"20-40 min\", min: 1200, max: 2400 },\n  { id: \"40+\", label: \"40+ min\", min: 2400, max: null },\n];\n\nconst MAX_DURATION = 7200; // 2 hours in seconds for the slider\nconst MAX_LABEL = \"2+ hrs\"; // Display label for maximum duration\n\n// Custom step values: 0, 2min (120s), 5min (300s), then 5 minute intervals\nconst DURATION_STEPS = [\n  0, 120, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000, 3300, 3600,\n  3900, 4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 6900, 7200,\n];\n\n// Snap a value to the nearest valid step\nfunction snapToStep(value: number): number {\n  if (value <= 0) return 0;\n  if (value >= MAX_DURATION) return MAX_DURATION;\n\n  // Find the closest step\n  let closest = DURATION_STEPS[0];\n  let minDiff = Math.abs(value - closest);\n\n  for (const step of DURATION_STEPS) {\n    const diff = Math.abs(value - step);\n    if (diff < minDiff) {\n      minDiff = diff;\n      closest = step;\n    }\n  }\n\n  return closest;\n}\n\nexport const SidebarDurationFilter: React.FC<ISidebarFilter> = ({\n  title = <FormattedMessage id=\"duration\" />,\n  option = DurationCriterionOption,\n  filter,\n  setFilter,\n  sectionID = \"duration\",\n}) => {\n  const criteria = filter.criteriaFor(option.type) as DurationCriterion[];\n  const criterion = criteria.length > 0 ? criteria[0] : null;\n\n  // Get current values from criterion\n  const currentMin = criterion?.value?.value ?? 0;\n  const currentMax = criterion?.value?.value2 ?? MAX_DURATION;\n\n  const [sliderMin, setSliderMin] = useState(currentMin);\n  const [sliderMax, setSliderMax] = useState(currentMax);\n  const [minInput, setMinInput] = useState(\n    currentMin === 0 ? \"0m\" : TextUtils.secondsAsTimeString(currentMin)\n  );\n  const [maxInput, setMaxInput] = useState(\n    currentMax >= MAX_DURATION\n      ? MAX_LABEL\n      : TextUtils.secondsAsTimeString(currentMax)\n  );\n\n  // Reset slider when criterion is removed externally (via filter tag X)\n  useEffect(() => {\n    if (!criterion) {\n      setSliderMin(0);\n      setSliderMax(MAX_DURATION);\n      setMinInput(\"0m\");\n      setMaxInput(MAX_LABEL);\n    }\n  }, [criterion]);\n\n  // Determine which preset is selected\n  const selectedPreset = useMemo(() => {\n    if (!criterion) return null;\n\n    // Check if current values match any preset\n    for (const preset of DURATION_PRESETS) {\n      if (preset.max === null) {\n        // For \"40+ min\" preset\n        if (\n          criterion.modifier === CriterionModifier.GreaterThan &&\n          criterion.value.value === preset.min\n        ) {\n          return preset.id;\n        }\n      } else {\n        // For range presets\n        if (\n          criterion.modifier === CriterionModifier.Between &&\n          criterion.value.value === preset.min &&\n          criterion.value.value2 === preset.max\n        ) {\n          return preset.id;\n        }\n      }\n    }\n\n    // Check if it's a custom range or custom GreaterThan\n    if (\n      criterion.modifier === CriterionModifier.Between ||\n      criterion.modifier === CriterionModifier.GreaterThan\n    ) {\n      return \"custom\";\n    }\n\n    return null;\n  }, [criterion]);\n\n  const options: Option[] = useMemo(() => {\n    return DURATION_PRESETS.map((preset) => ({\n      id: preset.id,\n      label: preset.label,\n      className: \"duration-preset\",\n    }));\n  }, []);\n\n  const selected: Option[] = useMemo(() => {\n    if (!selectedPreset) return [];\n    if (selectedPreset === \"custom\") return [];\n\n    const preset = DURATION_PRESETS.find((p) => p.id === selectedPreset);\n    if (preset) {\n      return [\n        {\n          id: preset.id,\n          label: preset.label,\n          className: \"duration-preset\",\n        },\n      ];\n    }\n    return [];\n  }, [selectedPreset]);\n\n  function onSelectPreset(item: Option) {\n    const preset = DURATION_PRESETS.find((p) => p.id === item.id);\n    if (!preset) return;\n\n    const newCriterion = criterion ? criterion.clone() : option.makeCriterion();\n\n    if (preset.max === null) {\n      // \"40+ min\" - use GreaterThan\n      newCriterion.modifier = CriterionModifier.GreaterThan;\n      newCriterion.value.value = preset.min;\n      newCriterion.value.value2 = undefined;\n    } else {\n      // Range preset - use Between\n      newCriterion.modifier = CriterionModifier.Between;\n      newCriterion.value.value = preset.min;\n      newCriterion.value.value2 = preset.max;\n    }\n\n    setSliderMin(preset.min);\n    setSliderMax(preset.max ?? MAX_DURATION);\n    setMinInput(\n      preset.min === 0 ? \"0m\" : TextUtils.secondsAsTimeString(preset.min)\n    );\n    setMaxInput(\n      preset.max === null\n        ? MAX_LABEL\n        : TextUtils.secondsAsTimeString(preset.max)\n    );\n    setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n  }\n\n  function onUnselectPreset() {\n    setFilter(filter.removeCriterion(option.type));\n    setSliderMin(0);\n    setSliderMax(MAX_DURATION);\n    setMinInput(\"0m\");\n    setMaxInput(MAX_LABEL);\n  }\n\n  // Parse time input (supports formats like \"10\", \"1:30\", \"1:30:00\", \"2+ hrs\")\n  function parseTimeInput(input: string): number | null {\n    const trimmed = input.trim().toLowerCase();\n\n    if (trimmed === \"max\" || trimmed === MAX_LABEL.toLowerCase()) {\n      return MAX_DURATION;\n    }\n\n    // Try to parse as pure number (minutes)\n    const minutesOnly = parseFloat(trimmed);\n    if (!isNaN(minutesOnly) && trimmed.indexOf(\":\") === -1) {\n      return Math.round(minutesOnly * 60);\n    }\n\n    // Parse HH:MM:SS or MM:SS format\n    const parts = trimmed.split(\":\").map((p) => parseInt(p));\n    if (parts.some(isNaN)) {\n      return null;\n    }\n\n    if (parts.length === 2) {\n      // MM:SS\n      return parts[0] * 60 + parts[1];\n    } else if (parts.length === 3) {\n      // HH:MM:SS\n      return parts[0] * 3600 + parts[1] * 60 + parts[2];\n    }\n\n    return null;\n  }\n\n  // Debounced filter update\n  function updateFilter(min: number, max: number) {\n    // If slider is at full range (0 to max), remove the filter entirely\n    if (min === 0 && max >= MAX_DURATION) {\n      setFilter(filter.removeCriterion(option.type));\n      return;\n    }\n\n    const newCriterion = criterion ? criterion.clone() : option.makeCriterion();\n\n    // If max is at MAX_DURATION (but min > 0), use GreaterThan\n    if (max >= MAX_DURATION) {\n      newCriterion.modifier = CriterionModifier.GreaterThan;\n      newCriterion.value.value = min;\n      newCriterion.value.value2 = undefined;\n    } else {\n      newCriterion.modifier = CriterionModifier.Between;\n      newCriterion.value.value = min;\n      newCriterion.value.value2 = max;\n    }\n\n    setFilter(filter.replaceCriteria(option.type, [newCriterion]));\n  }\n\n  const updateFilterDebounceMS = 300;\n  const debounceUpdateFilter = useDebounce(\n    updateFilter,\n    updateFilterDebounceMS\n  );\n\n  function handleSliderChange(min: number, max: number) {\n    if (min < 0 || max > MAX_DURATION || min >= max) {\n      return;\n    }\n\n    setSliderMin(min);\n    setSliderMax(max);\n    setMinInput(min === 0 ? \"0m\" : TextUtils.secondsAsTimeString(min));\n    setMaxInput(\n      max >= MAX_DURATION ? MAX_LABEL : TextUtils.secondsAsTimeString(max)\n    );\n\n    debounceUpdateFilter(min, max);\n  }\n\n  function handleMinInputChange(value: string) {\n    setMinInput(value);\n  }\n\n  function handleMaxInputChange(value: string) {\n    setMaxInput(value);\n  }\n\n  function handleMinInputBlur() {\n    const parsed = parseTimeInput(minInput);\n    if (parsed !== null && parsed >= 0 && parsed < sliderMax) {\n      handleSliderChange(parsed, sliderMax);\n    } else {\n      // Reset to current value if invalid\n      setMinInput(\n        sliderMin === 0 ? \"0m\" : TextUtils.secondsAsTimeString(sliderMin)\n      );\n    }\n  }\n\n  function handleMaxInputBlur() {\n    const parsed = parseTimeInput(maxInput);\n    if (parsed !== null && parsed > sliderMin && parsed <= MAX_DURATION) {\n      handleSliderChange(sliderMin, parsed);\n    } else {\n      // Reset to current value if invalid\n      setMaxInput(\n        sliderMax >= MAX_DURATION\n          ? MAX_LABEL\n          : TextUtils.secondsAsTimeString(sliderMax)\n      );\n    }\n  }\n\n  const customSlider = (\n    <DoubleRangeInput\n      className=\"duration-slider\"\n      minInput={\n        <input\n          type=\"text\"\n          className=\"duration-label-input\"\n          value={minInput}\n          onChange={(e) => handleMinInputChange(e.target.value)}\n          onBlur={handleMinInputBlur}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") {\n              e.currentTarget.blur();\n            }\n          }}\n          placeholder=\"0:00\"\n        />\n      }\n      maxInput={\n        <input\n          type=\"text\"\n          className=\"duration-label-input\"\n          value={maxInput}\n          onChange={(e) => handleMaxInputChange(e.target.value)}\n          onBlur={handleMaxInputBlur}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") {\n              e.currentTarget.blur();\n            }\n          }}\n          placeholder={MAX_LABEL}\n        />\n      }\n      min={0}\n      max={MAX_DURATION}\n      value={[sliderMin, sliderMax]}\n      onChange={(vals) => {\n        handleSliderChange(snapToStep(vals[0]), snapToStep(vals[1]));\n      }}\n    />\n  );\n\n  return (\n    <SidebarListFilter\n      title={title}\n      candidates={options}\n      onSelect={onSelectPreset}\n      onUnselect={onUnselectPreset}\n      selected={selected}\n      singleValue\n      preCandidates={selectedPreset === null ? customSlider : undefined}\n      preSelected={\n        selectedPreset === \"custom\" || selectedPreset ? customSlider : undefined\n      }\n      sectionID={sectionID}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx",
    "content": "import React, { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport {\n  faCheckCircle,\n  faMinus,\n  faPlus,\n  faTimesCircle,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { faTimesCircle as faTimesCircleRegular } from \"@fortawesome/free-regular-svg-icons\";\nimport { ClearableInput } from \"src/components/Shared/ClearableInput\";\nimport { useIntl } from \"react-intl\";\nimport { keyboardClickHandler } from \"src/utils/keyboard\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport useFocus from \"src/utils/focus\";\nimport cx from \"classnames\";\nimport ScreenUtils from \"src/utils/screen\";\nimport { SidebarSection } from \"src/components/Shared/Sidebar\";\nimport { TruncatedInlineText } from \"src/components/Shared/TruncatedText\";\n\ninterface ISelectedItem {\n  className?: string;\n  label: string;\n  excluded?: boolean;\n  onClick: () => void;\n  // true if the object is a special modifier value\n  modifier?: boolean;\n}\n\nconst SelectedItem: React.FC<ISelectedItem> = ({\n  className,\n  label,\n  excluded = false,\n  onClick,\n  modifier = false,\n}) => {\n  const iconClassName = excluded ? \"exclude-icon\" : \"include-button\";\n  const spanClassName = excluded\n    ? \"excluded-object-label\"\n    : \"selected-object-label\";\n  const [hovered, setHovered] = useState(false);\n\n  const icon = useMemo(() => {\n    if (!hovered) {\n      return excluded ? faTimesCircle : faCheckCircle;\n    }\n\n    return faTimesCircleRegular;\n  }, [hovered, excluded]);\n\n  function onMouseOver() {\n    setHovered(true);\n  }\n\n  function onMouseOut() {\n    setHovered(false);\n  }\n\n  return (\n    <li\n      className={cx(\"selected-object\", className, {\n        \"modifier-object\": modifier,\n      })}\n    >\n      <a\n        onClick={() => onClick()}\n        onKeyDown={keyboardClickHandler(onClick)}\n        onMouseEnter={() => onMouseOver()}\n        onMouseLeave={() => onMouseOut()}\n        onFocus={() => onMouseOver()}\n        onBlur={() => onMouseOut()}\n        tabIndex={0}\n      >\n        <div className=\"label-group\">\n          <Icon className={`fa-fw ${iconClassName}`} icon={icon} />\n          <TruncatedInlineText className={spanClassName} text={label} />\n        </div>\n      </a>\n    </li>\n  );\n};\n\nconst CandidateItem: React.FC<{\n  className?: string;\n  onSelect: (exclude: boolean) => void;\n  label: string;\n  canExclude?: boolean;\n  modifier?: boolean;\n  singleValue?: boolean;\n}> = ({\n  onSelect,\n  label,\n  canExclude,\n  modifier = false,\n  singleValue = false,\n  className,\n}) => {\n  const singleValueClass = singleValue ? \"single-value\" : \"\";\n  const includeIcon = (\n    <Icon\n      className={`fa-fw include-button ${singleValueClass}`}\n      icon={faPlus}\n    />\n  );\n  const excludeIcon = (\n    <Icon className={`fa-fw exclude-icon ${singleValueClass}`} icon={faMinus} />\n  );\n\n  return (\n    <li\n      className={cx(\"unselected-object\", className, {\n        \"modifier-object\": modifier,\n      })}\n    >\n      <a\n        onClick={() => onSelect(false)}\n        onKeyDown={keyboardClickHandler(() => onSelect(false))}\n        tabIndex={0}\n      >\n        <div className=\"label-group\">\n          {includeIcon}\n          <TruncatedInlineText\n            className=\"unselected-object-label\"\n            text={label}\n          />\n        </div>\n        <div>\n          {/* TODO item count */}\n          {/* <span className=\"object-count\">{p.id}</span> */}\n          {canExclude && (\n            <Button\n              onClick={(e) => {\n                e.stopPropagation();\n                onSelect(true);\n              }}\n              onKeyDown={(e) => e.stopPropagation()}\n              className=\"minimal exclude-button\"\n            >\n              <span className=\"exclude-button-text\">exclude</span>\n              {excludeIcon}\n            </Button>\n          )}\n        </div>\n      </a>\n    </li>\n  );\n};\n\nexport type Option<T = unknown> = {\n  id: string;\n  className?: string;\n  value?: T;\n  label: string;\n  canExclude?: boolean; // defaults to true\n};\n\nexport const SelectedList: React.FC<{\n  items: Option[];\n  onUnselect: (item: Option) => void;\n  excluded?: boolean;\n}> = ({ items, onUnselect, excluded }) => {\n  if (items.length === 0) {\n    return null;\n  }\n\n  return (\n    <ul className={cx(\"selected-list\", { \"excluded-list\": excluded })}>\n      {items.map((p) => (\n        <SelectedItem\n          key={p.id}\n          className={p.className}\n          label={p.label}\n          excluded={excluded}\n          onClick={() => onUnselect(p)}\n        />\n      ))}\n    </ul>\n  );\n};\n\nconst QueryField: React.FC<{\n  focus: ReturnType<typeof useFocus>;\n  value: string;\n  setValue: (query: string) => void;\n  onEnter?: () => void;\n}> = ({ focus, value, setValue, onEnter }) => {\n  const intl = useIntl();\n\n  const [displayQuery, setDisplayQuery] = useState(value);\n  const debouncedSetQuery = useDebounce(setValue, 250);\n\n  useEffect(() => {\n    setDisplayQuery(value);\n  }, [value]);\n\n  const onQueryChange = useCallback(\n    (input: string) => {\n      setDisplayQuery(input);\n      debouncedSetQuery(input);\n    },\n    [debouncedSetQuery, setDisplayQuery]\n  );\n\n  return (\n    <ClearableInput\n      focus={focus}\n      value={displayQuery}\n      setValue={(v) => onQueryChange(v)}\n      placeholder={`${intl.formatMessage({ id: \"actions.search\" })}…`}\n      onEnter={onEnter}\n    />\n  );\n};\n\ninterface IQueryableProps {\n  inputFocus?: ReturnType<typeof useFocus>;\n  query?: string;\n  setQuery?: (query: string) => void;\n  onEnter?: () => void;\n}\n\nexport const CandidateList: React.FC<\n  {\n    items: Option[];\n    onSelect: (item: Option, exclude: boolean) => void;\n    canExclude?: boolean;\n    singleValue?: boolean;\n  } & IQueryableProps\n> = ({\n  inputFocus,\n  query,\n  setQuery,\n  onEnter,\n  items,\n  onSelect,\n  canExclude,\n  singleValue,\n}) => {\n  const showQueryField =\n    inputFocus !== undefined && query !== undefined && setQuery !== undefined;\n\n  return (\n    <div className=\"queryable-candidate-list\">\n      {showQueryField && (\n        <QueryField\n          focus={inputFocus}\n          value={query}\n          setValue={(v) => setQuery(v)}\n          onEnter={onEnter}\n        />\n      )}\n      <ul>\n        {items.map((p) => (\n          <CandidateItem\n            key={p.id}\n            className={p.className}\n            onSelect={(exclude) => onSelect(p, exclude)}\n            label={p.label}\n            canExclude={canExclude && (p.canExclude ?? true)}\n            singleValue={singleValue}\n          />\n        ))}\n      </ul>\n    </div>\n  );\n};\n\nexport const SidebarListFilter: React.FC<{\n  title: React.ReactNode;\n  selected: Option[];\n  excluded?: Option[];\n  candidates: Option[];\n  modifierCandidates?: Option[];\n  singleValue?: boolean;\n  onSelect: (item: Option, exclude: boolean) => void;\n  onUnselect: (item: Option, exclude: boolean) => void;\n  canExclude?: boolean;\n  query?: string;\n  setQuery?: (query: string) => void;\n  preSelected?: React.ReactNode;\n  postSelected?: React.ReactNode;\n  preCandidates?: React.ReactNode;\n  postCandidates?: React.ReactNode;\n  onOpen?: () => void;\n  // used to store open/closed state in SidebarStateContext\n  sectionID?: string;\n}> = ({\n  title,\n  selected,\n  excluded,\n  candidates,\n  modifierCandidates,\n  onSelect,\n  onUnselect,\n  canExclude,\n  query,\n  setQuery,\n  singleValue = false,\n  preCandidates,\n  postCandidates,\n  preSelected,\n  postSelected,\n  onOpen,\n  sectionID,\n}) => {\n  // TODO - sort items?\n\n  const inputFocus = useFocus();\n  const [, setInputFocus] = inputFocus;\n\n  function unselectHook(item: Option, exclude: boolean) {\n    onUnselect(item, exclude);\n\n    // focus the input box\n    // don't do this on touch devices, as it's annoying\n    if (!ScreenUtils.isTouch()) {\n      setInputFocus();\n    }\n  }\n\n  function selectHook(item: Option, exclude: boolean) {\n    onSelect(item, exclude);\n\n    // reset filter query after selecting\n    setQuery?.(\"\");\n\n    // focus the input box\n    // don't do this on touch devices, as it's annoying\n    if (!ScreenUtils.isTouch()) {\n      setInputFocus();\n    }\n  }\n\n  function onEnter() {\n    if (candidates && candidates.length === 1) {\n      selectHook(candidates[0], false);\n    }\n  }\n\n  const items = useMemo(() => {\n    if (!modifierCandidates) {\n      return candidates;\n    }\n\n    return [...modifierCandidates, ...candidates];\n  }, [candidates, modifierCandidates]);\n\n  return (\n    <SidebarSection\n      className=\"sidebar-list-filter\"\n      text={title}\n      sectionID={sectionID}\n      outsideCollapse={\n        <>\n          {preSelected ? <div className=\"extra\">{preSelected}</div> : null}\n          <SelectedList\n            items={selected}\n            onUnselect={(i) => unselectHook(i, false)}\n          />\n          {excluded && (\n            <SelectedList\n              items={excluded}\n              onUnselect={(i) => unselectHook(i, true)}\n              excluded\n            />\n          )}\n          {postSelected ? <div className=\"extra\">{postSelected}</div> : null}\n        </>\n      }\n      onOpen={onOpen}\n    >\n      {preCandidates ? <div className=\"extra\">{preCandidates}</div> : null}\n      <CandidateList\n        items={items}\n        onSelect={selectHook}\n        canExclude={canExclude}\n        inputFocus={inputFocus}\n        query={query}\n        setQuery={setQuery}\n        singleValue={singleValue}\n        onEnter={onEnter}\n      />\n      {postCandidates ? <div className=\"extra\">{postCandidates}</div> : null}\n    </SidebarSection>\n  );\n};\n\nexport function useStaticResults<T>(r: T) {\n  return () => ({ results: r, loading: false });\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/StashIDFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { IStashIDValue } from \"../../../models/list-filter/types\";\nimport { ModifierCriterion } from \"../../../models/list-filter/criteria/criterion\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\n\ninterface IStashIDFilterProps {\n  criterion: ModifierCriterion<IStashIDValue>;\n  onValueChanged: (value: IStashIDValue) => void;\n}\n\nexport const StashIDFilter: React.FC<IStashIDFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  const intl = useIntl();\n  const { value } = criterion;\n\n  function onEndpointChanged(event: React.ChangeEvent<HTMLInputElement>) {\n    onValueChanged({\n      endpoint: event.target.value,\n      stashID: criterion.value.stashID,\n    });\n  }\n\n  function onStashIDChanged(event: React.ChangeEvent<HTMLInputElement>) {\n    onValueChanged({\n      stashID: event.target.value,\n      endpoint: criterion.value.endpoint,\n    });\n  }\n\n  return (\n    <div>\n      <Form.Group>\n        <Form.Control\n          className=\"btn-secondary\"\n          onChange={onEndpointChanged}\n          value={value ? value.endpoint : \"\"}\n          placeholder={intl.formatMessage({ id: \"stash_id_endpoint\" })}\n        />\n      </Form.Group>\n      {criterion.modifier !== CriterionModifier.IsNull &&\n        criterion.modifier !== CriterionModifier.NotNull && (\n          <Form.Group>\n            <Form.Control\n              className=\"btn-secondary\"\n              onChange={onStashIDChanged}\n              value={value ? value.stashID : \"\"}\n              placeholder={intl.formatMessage({ id: \"stash_id\" })}\n            />\n          </Form.Group>\n        )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/StudiosFilter.tsx",
    "content": "import React, { ReactNode, useMemo } from \"react\";\nimport {\n  StudioDataFragment,\n  StudioFilterType,\n  useFindStudiosForSelectQuery,\n} from \"src/core/generated-graphql\";\nimport { HierarchicalObjectsFilter } from \"./SelectableFilter\";\nimport {\n  StudiosCriterion,\n  StudiosCriterionOption,\n} from \"src/models/list-filter/criteria/studios\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { CriterionOption } from \"src/models/list-filter/criteria/criterion\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  IUseQueryHookProps,\n  makeQueryVariables,\n  setObjectFilter,\n  useLabeledIdFilterState,\n} from \"./LabeledIdFilter\";\nimport { SidebarListFilter } from \"./SidebarListFilter\";\nimport { FormattedMessage } from \"react-intl\";\n\ninterface IStudiosFilter {\n  criterion: StudiosCriterion;\n  setCriterion: (c: StudiosCriterion) => void;\n}\n\nfunction queryVariables(query: string, f?: ListFilterModel) {\n  const studioFilter: StudioFilterType = {};\n\n  if (f) {\n    const filterOutput = f.makeFilter();\n\n    // always remove studio filter from the filter\n    // since modifier is includes\n    delete filterOutput.studios;\n\n    // TODO - look for same in AND?\n\n    setObjectFilter(studioFilter, f.mode, filterOutput);\n  }\n\n  return makeQueryVariables(query, { studio_filter: studioFilter });\n}\n\nfunction sortResults(\n  query: string,\n  studios: Pick<StudioDataFragment, \"id\" | \"name\" | \"aliases\">[]\n) {\n  return sortByRelevance(\n    query,\n    studios ?? [],\n    (s) => s.name,\n    (s) => s.aliases\n  ).map((p) => {\n    return {\n      id: p.id,\n      label: p.name,\n    };\n  });\n}\n\nfunction useStudioQueryFilter(props: IUseQueryHookProps) {\n  const { q: query, filter: f, skip, filterHook } = props;\n  const appliedFilter = filterHook && f ? filterHook(f.clone()) : f;\n\n  const { data, loading } = useFindStudiosForSelectQuery({\n    variables: queryVariables(query, appliedFilter),\n    skip,\n  });\n\n  const results = useMemo(\n    () => sortResults(query, data?.findStudios.studios ?? []),\n    [data?.findStudios.studios, query]\n  );\n\n  return { results, loading };\n}\n\nfunction useStudioQuery(query: string, skip?: boolean) {\n  return useStudioQueryFilter({ q: query, skip: !!skip });\n}\n\nconst StudiosFilter: React.FC<IStudiosFilter> = ({\n  criterion,\n  setCriterion,\n}) => {\n  return (\n    <HierarchicalObjectsFilter\n      criterion={criterion}\n      setCriterion={setCriterion}\n      useResults={useStudioQuery}\n      singleValue\n    />\n  );\n};\n\nexport const SidebarStudiosFilter: React.FC<{\n  title?: ReactNode;\n  option?: CriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  filterHook?: (f: ListFilterModel) => ListFilterModel;\n  sectionID?: string;\n}> = ({\n  title = <FormattedMessage id=\"studios\" />,\n  option = StudiosCriterionOption,\n  filter,\n  setFilter,\n  filterHook,\n  sectionID = \"studios\",\n}) => {\n  const state = useLabeledIdFilterState({\n    filter,\n    setFilter,\n    filterHook,\n    option,\n    useQuery: useStudioQueryFilter,\n    singleValue: true,\n    hierarchical: true,\n    includeSubMessageID: \"subsidiary_studios\",\n  });\n\n  return (\n    <SidebarListFilter\n      {...state}\n      data-type={option.type}\n      title={title}\n      sectionID={sectionID}\n    />\n  );\n};\n\nexport default StudiosFilter;\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/TagsFilter.tsx",
    "content": "import React, { ReactNode, useMemo } from \"react\";\nimport {\n  CriterionModifier,\n  TagDataFragment,\n  TagFilterType,\n  useFindTagsForSelectQuery,\n} from \"src/core/generated-graphql\";\nimport { HierarchicalObjectsFilter } from \"./SelectableFilter\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { CriterionOption } from \"src/models/list-filter/criteria/criterion\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  IUseQueryHookProps,\n  makeQueryVariables,\n  setObjectFilter,\n  useLabeledIdFilterState,\n} from \"./LabeledIdFilter\";\nimport { SidebarListFilter } from \"./SidebarListFilter\";\nimport {\n  TagsCriterion,\n  TagsCriterionOption,\n} from \"src/models/list-filter/criteria/tags\";\nimport { FormattedMessage } from \"react-intl\";\n\ninterface ITagsFilter {\n  criterion: TagsCriterion;\n  setCriterion: (c: TagsCriterion) => void;\n}\n\ninterface IHasModifier {\n  modifier: CriterionModifier;\n}\n\nfunction queryVariables(query: string, f?: ListFilterModel) {\n  const tagFilter: TagFilterType = {};\n\n  if (f) {\n    const filterOutput = f.makeFilter();\n\n    // if tag modifier is includes, take it out of the filter\n    if (\n      (filterOutput.tags as IHasModifier)?.modifier ===\n      CriterionModifier.Includes\n    ) {\n      delete filterOutput.tags;\n\n      // TODO - look for same in AND?\n    }\n\n    setObjectFilter(tagFilter, f.mode, filterOutput);\n  }\n\n  return makeQueryVariables(query, { tag_filter: tagFilter });\n}\n\nfunction sortResults(\n  query: string,\n  tags: Pick<TagDataFragment, \"id\" | \"name\" | \"aliases\">[]\n) {\n  return sortByRelevance(\n    query,\n    tags ?? [],\n    (t) => t.name,\n    (t) => t.aliases\n  ).map((p) => {\n    return {\n      id: p.id,\n      label: p.name,\n    };\n  });\n}\n\nfunction useTagQueryFilter(props: IUseQueryHookProps) {\n  const { q: query, filter: f, skip, filterHook } = props;\n  const appliedFilter = filterHook && f ? filterHook(f.clone()) : f;\n\n  const { data, loading } = useFindTagsForSelectQuery({\n    variables: queryVariables(query, appliedFilter),\n    skip,\n  });\n\n  const results = useMemo(\n    () => sortResults(query, data?.findTags.tags ?? []),\n    [data, query]\n  );\n\n  return { results, loading };\n}\n\nfunction useTagQuery(query: string, skip?: boolean) {\n  return useTagQueryFilter({ q: query, skip: !!skip });\n}\n\nconst TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {\n  return (\n    <HierarchicalObjectsFilter\n      criterion={criterion}\n      setCriterion={setCriterion}\n      useResults={useTagQuery}\n    />\n  );\n};\n\nexport const SidebarTagsFilter: React.FC<{\n  title?: ReactNode;\n  option?: CriterionOption;\n  filter: ListFilterModel;\n  setFilter: (f: ListFilterModel) => void;\n  filterHook?: (f: ListFilterModel) => ListFilterModel;\n  sectionID?: string;\n}> = ({\n  title = <FormattedMessage id=\"tags\" />,\n  option = TagsCriterionOption,\n  filter,\n  setFilter,\n  filterHook,\n  sectionID = \"tags\",\n}) => {\n  const state = useLabeledIdFilterState({\n    filter,\n    setFilter,\n    filterHook,\n    option,\n    useQuery: useTagQueryFilter,\n    hierarchical: true,\n    includeSubMessageID: \"sub_tags\",\n  });\n\n  return (\n    <SidebarListFilter\n      {...state}\n      data-type={option.type}\n      title={title}\n      sectionID={sectionID}\n    />\n  );\n};\n\nexport default TagsFilter;\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Filters/TimestampFilter.tsx",
    "content": "import React from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { CriterionModifier } from \"../../../core/generated-graphql\";\nimport { ITimestampValue } from \"../../../models/list-filter/types\";\nimport { ModifierCriterion } from \"../../../models/list-filter/criteria/criterion\";\nimport { DateInput } from \"src/components/Shared/DateInput\";\n\ninterface ITimestampFilterProps {\n  criterion: ModifierCriterion<ITimestampValue>;\n  onValueChanged: (value: ITimestampValue) => void;\n}\n\nexport const TimestampFilter: React.FC<ITimestampFilterProps> = ({\n  criterion,\n  onValueChanged,\n}) => {\n  const intl = useIntl();\n\n  const { value } = criterion;\n\n  function onChanged(newValue: string, property: \"value\" | \"value2\") {\n    const valueCopy = { ...value };\n\n    valueCopy[property] = newValue;\n    onValueChanged(valueCopy);\n  }\n\n  let equalsControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.Equals ||\n    criterion.modifier === CriterionModifier.NotEquals\n  ) {\n    equalsControl = (\n      <Form.Group>\n        <DateInput\n          value={value?.value ?? \"\"}\n          onValueChange={(v) => onChanged(v, \"value\")}\n          placeholder={intl.formatMessage({ id: \"criterion.value\" })}\n          isTime\n        />\n        {/* <Form.Control\n          className=\"btn-secondary\"\n          type=\"text\"\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            onChanged(e, \"value\")\n          }\n          value={value?.value ?? \"\"}\n          placeholder={\n            intl.formatMessage({ id: \"criterion.value\" }) +\n            \" (YYYY-MM-DD HH:MM)\"\n          }\n        /> */}\n      </Form.Group>\n    );\n  }\n\n  let lowerControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.GreaterThan ||\n    criterion.modifier === CriterionModifier.Between ||\n    criterion.modifier === CriterionModifier.NotBetween\n  ) {\n    lowerControl = (\n      <Form.Group>\n        <DateInput\n          value={value?.value ?? \"\"}\n          onValueChange={(v) => onChanged(v, \"value\")}\n          placeholder={intl.formatMessage({ id: \"criterion.greater_than\" })}\n          isTime\n        />\n        {/* <Form.Control\n          className=\"btn-secondary\"\n          type=\"text\"\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            onChanged(e, \"value\")\n          }\n          value={value?.value ?? \"\"}\n          placeholder={\n            intl.formatMessage({ id: \"criterion.greater_than\" }) +\n            \" (YYYY-MM-DD HH:MM)\"\n          }\n        /> */}\n      </Form.Group>\n    );\n  }\n\n  let upperControl: JSX.Element | null = null;\n  if (\n    criterion.modifier === CriterionModifier.LessThan ||\n    criterion.modifier === CriterionModifier.Between ||\n    criterion.modifier === CriterionModifier.NotBetween\n  ) {\n    upperControl = (\n      <Form.Group>\n        <DateInput\n          value={\n            (criterion.modifier === CriterionModifier.LessThan\n              ? value?.value\n              : value?.value2) ?? \"\"\n          }\n          onValueChange={(v) =>\n            onChanged(\n              v,\n              criterion.modifier === CriterionModifier.LessThan\n                ? \"value\"\n                : \"value2\"\n            )\n          }\n          placeholder={intl.formatMessage({ id: \"criterion.less_than\" })}\n          isTime\n        />\n        {/* <Form.Control\n          className=\"btn-secondary\"\n          type=\"text\"\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            onChanged(\n              e,\n              criterion.modifier === CriterionModifier.LessThan\n                ? \"value\"\n                : \"value2\"\n            )\n          }\n          value={\n            (criterion.modifier === CriterionModifier.LessThan\n              ? value?.value\n              : value?.value2) ?? \"\"\n          }\n          placeholder={\n            intl.formatMessage({ id: \"criterion.less_than\" }) +\n            \" (YYYY-MM-DD HH:MM)\"\n          }\n        /> */}\n      </Form.Group>\n    );\n  }\n\n  return (\n    <>\n      {equalsControl}\n      {lowerControl}\n      {upperControl}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/ItemList.tsx",
    "content": "import { QueryResult } from \"@apollo/client\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { useShowEditFilter } from \"src/components/List/EditFilterDialog\";\nimport { IHasID } from \"src/utils/data\";\nimport { useModal } from \"src/hooks/modal\";\nimport {\n  IFilterStateHook,\n  IQueryResultHook,\n  useEnsureValidPage,\n  useFilterOperations,\n  useFilterState,\n  useListKeyboardShortcuts,\n  useListSelect,\n  useQueryResult,\n  useScrollToTopOnPageChange,\n} from \"./util\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\ninterface IFilteredItemList<\n  T extends QueryResult,\n  E extends IHasID = IHasID,\n  M = unknown\n> {\n  filterStateProps: IFilterStateHook;\n  queryResultProps: IQueryResultHook<T, E, M>;\n}\n\n// Provides the common state and behaviour for filtered item list components\nexport function useFilteredItemList<\n  T extends QueryResult,\n  E extends IHasID = IHasID,\n  M = unknown\n>(props: IFilteredItemList<T, E, M>) {\n  const { configuration: config } = useConfigurationContext();\n\n  // States\n  const filterState = useFilterState({\n    config,\n    ...props.filterStateProps,\n  });\n\n  const { filter, setFilter } = filterState;\n\n  const queryResult = useQueryResult({\n    filter,\n    ...props.queryResultProps,\n  });\n  const { result, items, totalCount, pages, metadataInfo } = queryResult;\n\n  const listSelect = useListSelect(items);\n  const { onSelectAll, onSelectNone, onInvertSelection } = listSelect;\n\n  const modalState = useModal();\n  const { showModal, closeModal } = modalState;\n\n  // Utility hooks\n  const { setPage } = useFilterOperations({ filter, setFilter });\n\n  // scroll to the top of the page when the page changes\n  useScrollToTopOnPageChange(filter.currentPage, result.loading);\n\n  // ensure that the current page is valid\n  useEnsureValidPage(filter, totalCount, setFilter);\n\n  const showEditFilter = useShowEditFilter({\n    showModal,\n    closeModal,\n    filter,\n    setFilter,\n  });\n\n  useListKeyboardShortcuts({\n    currentPage: filter.currentPage,\n    onChangePage: setPage,\n    onSelectAll,\n    onSelectNone,\n    onInvertSelection,\n    pages,\n    showEditFilter,\n  });\n\n  return {\n    filterState,\n    queryResult,\n    metadataInfo,\n    listSelect,\n    modalState,\n    showEditFilter,\n  };\n}\n\nexport const showWhenSelected = <T extends QueryResult>(\n  result: T,\n  filter: ListFilterModel,\n  selectedIds: Set<string>\n) => {\n  return selectedIds.size > 0;\n};\n\nexport const showWhenSingleSelection = <T extends QueryResult>(\n  result: T,\n  filter: ListFilterModel,\n  selectedIds: Set<string>\n) => {\n  return selectedIds.size == 1;\n};\n\nexport const showWhenNoneSelected = <T extends QueryResult>(\n  result: T,\n  filter: ListFilterModel,\n  selectedIds: Set<string>\n) => {\n  return selectedIds.size === 0;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/ListFilter.tsx",
    "content": "import React, {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport Mousetrap from \"mousetrap\";\nimport { SortDirectionEnum } from \"src/core/generated-graphql\";\nimport {\n  Button,\n  ButtonGroup,\n  Dropdown,\n  Form,\n  OverlayTrigger,\n  Tooltip,\n  InputGroup,\n  Popover,\n  Overlay,\n} from \"react-bootstrap\";\n\nimport { Icon } from \"../Shared/Icon\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport useFocus from \"src/utils/focus\";\nimport { useIntl } from \"react-intl\";\nimport {\n  faCaretDown,\n  faCaretUp,\n  faCheck,\n  faRandom,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport { ClearableInput } from \"../Shared/ClearableInput\";\nimport { useStopWheelScroll } from \"src/utils/form\";\nimport { ISortByOption } from \"src/models/list-filter/filter-options\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\nexport function useDebouncedSearchInput(\n  filter: ListFilterModel,\n  setFilter: (filter: ListFilterModel) => void\n) {\n  const callback = useCallback(\n    (value: string) => {\n      const newFilter = filter.clone();\n      newFilter.searchTerm = value;\n      newFilter.currentPage = 1;\n      setFilter(newFilter);\n    },\n    [filter, setFilter]\n  );\n\n  const onClear = useCallback(() => callback(\"\"), [callback]);\n\n  const searchCallback = useDebounce(callback, 500);\n\n  return { searchCallback, onClear };\n}\n\nexport const SearchTermInput: React.FC<{\n  filter: ListFilterModel;\n  onFilterUpdate: (newFilter: ListFilterModel) => void;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({ filter, onFilterUpdate, focus: providedFocus }) => {\n  const intl = useIntl();\n  const [localInput, setLocalInput] = useState(filter.searchTerm);\n\n  const localFocus = useFocus();\n  const focus = providedFocus ?? localFocus;\n  const [, setQueryFocus] = focus;\n\n  useEffect(() => {\n    setLocalInput(filter.searchTerm);\n  }, [filter.searchTerm]);\n\n  const { searchCallback, onClear } = useDebouncedSearchInput(\n    filter,\n    onFilterUpdate\n  );\n\n  useEffect(() => {\n    Mousetrap.bind(\"/\", (e) => {\n      setQueryFocus();\n      e.preventDefault();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"/\");\n    };\n  });\n\n  function onSetQuery(value: string) {\n    setLocalInput(value);\n\n    if (!value) {\n      onClear();\n    }\n\n    searchCallback(value);\n  }\n\n  return (\n    <ClearableInput\n      className=\"search-term-input\"\n      focus={focus}\n      value={localInput}\n      setValue={onSetQuery}\n      placeholder={`${intl.formatMessage({ id: \"actions.search\" })}…`}\n    />\n  );\n};\n\nconst PAGE_SIZE_OPTIONS = [\"20\", \"40\", \"60\", \"120\", \"250\", \"500\", \"1000\"];\n\nexport const PageSizeSelector: React.FC<{\n  pageSize: number;\n  setPageSize: (pageSize: number) => void;\n}> = ({ pageSize, setPageSize }) => {\n  const intl = useIntl();\n\n  const perPageSelect = useRef(null);\n  const [perPageInput, perPageFocus] = useFocus();\n  const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false);\n\n  useEffect(() => {\n    if (customPageSizeShowing) {\n      perPageFocus();\n    }\n  }, [customPageSizeShowing, perPageFocus]);\n\n  useStopWheelScroll(perPageInput);\n\n  const pageSizeOptions = useMemo(() => {\n    const ret = PAGE_SIZE_OPTIONS.map((o) => {\n      return {\n        label: o,\n        value: o,\n      };\n    });\n    const currentPerPage = pageSize.toString();\n    if (!ret.find((o) => o.value === currentPerPage)) {\n      ret.push({ label: currentPerPage, value: currentPerPage });\n      ret.sort((a, b) => parseInt(a.value, 10) - parseInt(b.value, 10));\n    }\n\n    ret.push({\n      label: `${intl.formatMessage({ id: \"custom\" })}...`,\n      value: \"custom\",\n    });\n\n    return ret;\n  }, [intl, pageSize]);\n\n  function onChangePageSize(val: string) {\n    if (val === \"custom\") {\n      // added timeout since Firefox seems to trigger the rootClose immediately\n      // without it\n      setTimeout(() => setCustomPageSizeShowing(true), 0);\n      return;\n    }\n\n    setCustomPageSizeShowing(false);\n\n    let pp = parseInt(val, 10);\n    if (Number.isNaN(pp) || pp <= 0) {\n      return;\n    }\n\n    setPageSize(pp);\n  }\n\n  return (\n    <div className=\"page-size-selector\">\n      <Form.Control\n        as=\"select\"\n        ref={perPageSelect}\n        onChange={(e) => onChangePageSize(e.target.value)}\n        value={pageSize.toString()}\n        className=\"btn-secondary\"\n      >\n        {pageSizeOptions.map((s) => (\n          <option value={s.value} key={s.value}>\n            {s.label}\n          </option>\n        ))}\n      </Form.Control>\n      <Overlay\n        target={perPageSelect.current}\n        show={customPageSizeShowing}\n        placement=\"bottom\"\n        rootClose\n        onHide={() => setCustomPageSizeShowing(false)}\n      >\n        <Popover id=\"custom_pagesize_popover\">\n          <Form inline>\n            <InputGroup>\n              {/* can't use NumberField because of the ref */}\n              <Form.Control\n                type=\"number\"\n                min={1}\n                className=\"text-input\"\n                ref={perPageInput}\n                onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) => {\n                  if (e.key === \"Enter\") {\n                    onChangePageSize(\n                      (perPageInput.current as HTMLInputElement)?.value ?? \"\"\n                    );\n                    e.preventDefault();\n                  }\n                }}\n              />\n              <InputGroup.Append>\n                <Button\n                  variant=\"primary\"\n                  onClick={() =>\n                    onChangePageSize(\n                      (perPageInput.current as HTMLInputElement)?.value ?? \"\"\n                    )\n                  }\n                >\n                  <Icon icon={faCheck} />\n                </Button>\n              </InputGroup.Append>\n            </InputGroup>\n          </Form>\n        </Popover>\n      </Overlay>\n    </div>\n  );\n};\n\nexport const SortBySelect: React.FC<{\n  className?: string;\n  sortBy: string | undefined;\n  sortDirection: SortDirectionEnum;\n  options: ISortByOption[];\n  onChangeSortBy: (eventKey: string | null) => void;\n  onChangeSortDirection: () => void;\n  onReshuffleRandomSort: () => void;\n}> = ({\n  className,\n  sortBy,\n  sortDirection,\n  options,\n  onChangeSortBy,\n  onChangeSortDirection,\n  onReshuffleRandomSort,\n}) => {\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const currentSortBy = options.find((o) => o.value === sortBy);\n  const currentSortByMessageID = currentSortBy\n    ? !sfwContentMode\n      ? currentSortBy.messageID\n      : currentSortBy.sfwMessageID ?? currentSortBy.messageID\n    : \"\";\n\n  function renderSortByOptions() {\n    return options\n      .map((o) => {\n        const messageID = !sfwContentMode\n          ? o.messageID\n          : o.sfwMessageID ?? o.messageID;\n        return {\n          message: intl.formatMessage({ id: messageID }),\n          value: o.value,\n        };\n      })\n      .sort((a, b) => a.message.localeCompare(b.message))\n      .map((option) => (\n        <Dropdown.Item\n          onSelect={onChangeSortBy}\n          key={option.value}\n          className=\"bg-secondary text-white\"\n          eventKey={option.value}\n          data-value={option.value}\n        >\n          {option.message}\n        </Dropdown.Item>\n      ));\n  }\n\n  return (\n    <Dropdown as={ButtonGroup} className={`${className ?? \"\"} sort-by-select`}>\n      <InputGroup.Prepend>\n        <Dropdown.Toggle variant=\"secondary\">\n          {currentSortBy\n            ? intl.formatMessage({ id: currentSortByMessageID })\n            : \"\"}\n        </Dropdown.Toggle>\n      </InputGroup.Prepend>\n      <Dropdown.Menu className=\"bg-secondary text-white\">\n        {renderSortByOptions()}\n      </Dropdown.Menu>\n      <OverlayTrigger\n        overlay={\n          <Tooltip id=\"sort-direction-tooltip\">\n            {sortDirection === SortDirectionEnum.Asc\n              ? intl.formatMessage({ id: \"ascending\" })\n              : intl.formatMessage({ id: \"descending\" })}\n          </Tooltip>\n        }\n      >\n        <Button variant=\"secondary\" onClick={onChangeSortDirection}>\n          <Icon\n            icon={\n              sortDirection === SortDirectionEnum.Asc ? faCaretUp : faCaretDown\n            }\n          />\n        </Button>\n      </OverlayTrigger>\n      {sortBy === \"random\" && (\n        <OverlayTrigger\n          overlay={\n            <Tooltip id=\"sort-reshuffle-tooltip\">\n              {intl.formatMessage({ id: \"actions.reshuffle\" })}\n            </Tooltip>\n          }\n        >\n          <Button variant=\"secondary\" onClick={onReshuffleRandomSort}>\n            <Icon icon={faRandom} />\n          </Button>\n        </OverlayTrigger>\n      )}\n    </Dropdown>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/ListOperationButtons.tsx",
    "content": "import React, { PropsWithChildren, useEffect, useMemo } from \"react\";\nimport { Button, ButtonGroup, Dropdown } from \"react-bootstrap\";\nimport Mousetrap from \"mousetrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { IconDefinition } from \"@fortawesome/fontawesome-svg-core\";\nimport { Icon } from \"../Shared/Icon\";\nimport {\n  faEllipsisH,\n  faPencil,\n  faPencilAlt,\n  faPlay,\n  faTrash,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport cx from \"classnames\";\nimport { createPortal } from \"react-dom\";\n\nexport const OperationDropdown: React.FC<\n  PropsWithChildren<{\n    className?: string;\n    menuPortalTarget?: HTMLElement;\n    menuClassName?: string;\n  }>\n> = ({ className, menuPortalTarget, menuClassName, children }) => {\n  if (!children) return null;\n\n  const menu = (\n    <Dropdown.Menu className={cx(\"bg-secondary text-white\", menuClassName)}>\n      {children}\n    </Dropdown.Menu>\n  );\n\n  return (\n    <Dropdown className={className} as={ButtonGroup}>\n      <Dropdown.Toggle variant=\"secondary\" id=\"more-menu\">\n        <Icon icon={faEllipsisH} />\n      </Dropdown.Toggle>\n      {menuPortalTarget ? createPortal(menu, menuPortalTarget) : menu}\n    </Dropdown>\n  );\n};\n\nexport const OperationDropdownItem: React.FC<{\n  text: string;\n  onClick: () => void;\n  className?: string;\n}> = ({ text, onClick, className }) => {\n  return (\n    <Dropdown.Item\n      className={cx(\"bg-secondary text-white\", className)}\n      onClick={onClick}\n    >\n      {text}\n    </Dropdown.Item>\n  );\n};\n\nexport interface IListFilterOperation {\n  text: string;\n  onClick: () => void;\n  isDisplayed?: () => boolean;\n  icon?: IconDefinition;\n  buttonVariant?: string;\n  className?: string;\n}\n\ninterface IListOperationButtonsProps {\n  onSelectAll?: () => void;\n  onSelectNone?: () => void;\n  onInvertSelection?: () => void;\n  onEdit?: () => void;\n  onDelete?: () => void;\n  itemsSelected?: boolean;\n  otherOperations?: IListFilterOperation[];\n}\n\nexport const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({\n  onSelectAll,\n  onSelectNone,\n  onInvertSelection,\n  onEdit,\n  onDelete,\n  itemsSelected,\n  otherOperations,\n}) => {\n  const intl = useIntl();\n\n  useEffect(() => {\n    Mousetrap.bind(\"s a\", () => onSelectAll?.());\n    Mousetrap.bind(\"s n\", () => onSelectNone?.());\n    Mousetrap.bind(\"s i\", () => onInvertSelection?.());\n\n    Mousetrap.bind(\"e\", () => {\n      if (itemsSelected) {\n        onEdit?.();\n      }\n    });\n\n    Mousetrap.bind(\"d d\", () => {\n      if (itemsSelected) {\n        onDelete?.();\n      }\n    });\n\n    return () => {\n      Mousetrap.unbind(\"s a\");\n      Mousetrap.unbind(\"s n\");\n      Mousetrap.unbind(\"s i\");\n      Mousetrap.unbind(\"e\");\n      Mousetrap.unbind(\"d d\");\n    };\n  }, [\n    onSelectAll,\n    onSelectNone,\n    onInvertSelection,\n    itemsSelected,\n    onEdit,\n    onDelete,\n  ]);\n\n  const buttons = useMemo(() => {\n    const ret = (otherOperations ?? []).filter((o) => {\n      if (!o.icon) {\n        return false;\n      }\n\n      if (!o.isDisplayed) {\n        return true;\n      }\n\n      return o.isDisplayed();\n    });\n\n    if (itemsSelected) {\n      if (onEdit) {\n        ret.push({\n          icon: faPencilAlt,\n          text: intl.formatMessage({ id: \"actions.edit\" }),\n          onClick: onEdit,\n        });\n      }\n      if (onDelete) {\n        ret.push({\n          icon: faTrash,\n          text: intl.formatMessage({ id: \"actions.delete\" }),\n          onClick: onDelete,\n          buttonVariant: \"danger\",\n        });\n      }\n    }\n\n    return ret;\n  }, [otherOperations, itemsSelected, onEdit, onDelete, intl]);\n\n  const operationButtons = useMemo(() => {\n    return (\n      <>\n        {buttons.map((button) => {\n          return (\n            <Button\n              key={button.text}\n              variant={button.buttonVariant ?? \"secondary\"}\n              onClick={button.onClick}\n              title={button.text}\n            >\n              <Icon icon={button.icon!} />\n            </Button>\n          );\n        })}\n      </>\n    );\n  }, [buttons]);\n\n  const moreDropdown = useMemo(() => {\n    function renderSelectAll() {\n      if (onSelectAll) {\n        return (\n          <Dropdown.Item\n            key=\"select-all\"\n            className=\"bg-secondary text-white\"\n            onClick={() => onSelectAll?.()}\n          >\n            <FormattedMessage id=\"actions.select_all\" />\n          </Dropdown.Item>\n        );\n      }\n    }\n\n    function renderSelectNone() {\n      if (onSelectNone) {\n        return (\n          <Dropdown.Item\n            key=\"select-none\"\n            className=\"bg-secondary text-white\"\n            onClick={() => onSelectNone?.()}\n          >\n            <FormattedMessage id=\"actions.select_none\" />\n          </Dropdown.Item>\n        );\n      }\n    }\n\n    function renderInvertSelection() {\n      if (onInvertSelection) {\n        return (\n          <Dropdown.Item\n            key=\"invert-selection\"\n            className=\"bg-secondary text-white\"\n            onClick={() => onInvertSelection?.()}\n          >\n            <FormattedMessage id=\"actions.invert_selection\" />\n          </Dropdown.Item>\n        );\n      }\n    }\n\n    const options = [\n      renderSelectAll(),\n      renderSelectNone(),\n      renderInvertSelection(),\n    ].filter((o) => o);\n\n    if (otherOperations) {\n      otherOperations\n        .filter((o) => {\n          // buttons with icons are rendered in the button group\n          if (o.icon) {\n            return false;\n          }\n\n          if (!o.isDisplayed) {\n            return true;\n          }\n\n          return o.isDisplayed();\n        })\n        .forEach((o) => {\n          options.push(\n            <Dropdown.Item\n              key={o.text}\n              className=\"bg-secondary text-white\"\n              onClick={o.onClick}\n            >\n              {o.text}\n            </Dropdown.Item>\n          );\n        });\n    }\n\n    return (\n      <OperationDropdown>\n        {options.length > 0 ? options : undefined}\n      </OperationDropdown>\n    );\n  }, [otherOperations, onSelectAll, onSelectNone, onInvertSelection]);\n\n  // don't render anything if there are no buttons or operations\n  if (buttons.length === 0 && !moreDropdown) {\n    return null;\n  }\n\n  return (\n    <>\n      <ButtonGroup>\n        {operationButtons}\n        {moreDropdown}\n      </ButtonGroup>\n    </>\n  );\n};\n\nexport const ListOperations: React.FC<{\n  items: number;\n  hasSelection?: boolean;\n  operations?: IListFilterOperation[];\n  onEdit?: () => void;\n  onDelete?: () => void;\n  onPlay?: () => void;\n  operationsClassName?: string;\n  operationsMenuClassName?: string;\n}> = ({\n  items,\n  hasSelection = false,\n  operations = [],\n  onEdit,\n  onDelete,\n  onPlay,\n  operationsClassName = \"list-operations\",\n  operationsMenuClassName,\n}) => {\n  const intl = useIntl();\n\n  const dropdownOperations = useMemo(() => {\n    return operations.filter((o) => {\n      if (o.icon) {\n        return false;\n      }\n\n      if (!o.isDisplayed) {\n        return true;\n      }\n\n      return o.isDisplayed();\n    });\n  }, [operations]);\n\n  const buttons = useMemo(() => {\n    const otherButtons = (operations ?? []).filter((o) => {\n      if (!o.icon) {\n        return false;\n      }\n\n      if (!o.isDisplayed) {\n        return true;\n      }\n\n      return o.isDisplayed();\n    });\n\n    const ret: React.ReactNode[] = [];\n\n    function addButton(b: React.ReactNode | null) {\n      if (b) {\n        ret.push(b);\n      }\n    }\n\n    const playButton =\n      !!items && onPlay ? (\n        <Button\n          className=\"play-button\"\n          variant=\"secondary\"\n          onClick={() => onPlay()}\n          title={intl.formatMessage({ id: \"actions.play\" })}\n        >\n          <Icon icon={faPlay} />\n        </Button>\n      ) : null;\n\n    const editButton =\n      hasSelection && onEdit ? (\n        <Button\n          className=\"edit-existing-button\"\n          variant=\"secondary\"\n          onClick={() => onEdit()}\n        >\n          <Icon icon={faPencil} />\n        </Button>\n      ) : null;\n\n    const deleteButton =\n      hasSelection && onDelete ? (\n        <Button\n          variant=\"danger\"\n          className=\"delete-button btn-danger-minimal\"\n          onClick={() => onDelete()}\n        >\n          <Icon icon={faTrash} />\n        </Button>\n      ) : null;\n\n    addButton(playButton);\n    addButton(editButton);\n    addButton(deleteButton);\n\n    otherButtons.forEach((button) => {\n      addButton(\n        <Button\n          key={button.text}\n          variant={button.buttonVariant ?? \"secondary\"}\n          onClick={button.onClick}\n          title={button.text}\n          className={button.className}\n        >\n          <Icon icon={button.icon!} />\n        </Button>\n      );\n    });\n\n    if (ret.length === 0) {\n      return null;\n    }\n\n    return ret;\n  }, [operations, hasSelection, onDelete, onEdit, onPlay, items, intl]);\n\n  if (dropdownOperations.length === 0 && !buttons) {\n    return null;\n  }\n\n  return (\n    <div className=\"list-operations\">\n      <ButtonGroup>\n        {buttons}\n\n        {dropdownOperations.length > 0 && (\n          <OperationDropdown\n            className={operationsClassName}\n            menuClassName={operationsMenuClassName}\n            menuPortalTarget={document.body}\n          >\n            {dropdownOperations.map((o) => (\n              <OperationDropdownItem\n                key={o.text}\n                onClick={o.onClick}\n                text={o.text}\n                className={o.className}\n              />\n            ))}\n          </OperationDropdown>\n        )}\n      </ButtonGroup>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/ListProvider.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { IListSelect, useCachedQueryResult, useListSelect } from \"./util\";\nimport { isFunction } from \"lodash-es\";\nimport { IHasID } from \"src/utils/data\";\nimport { useFilter } from \"./FilterProvider\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { QueryResult } from \"@apollo/client\";\n\ninterface IListContextOptions<T extends IHasID> {\n  selectable?: boolean;\n  items: T[];\n}\n\nexport type IListContextState<T extends IHasID = IHasID> = IListSelect<T> & {\n  selectable: boolean;\n  items: T[];\n};\n\nexport const ListStateContext = React.createContext<IListContextState | null>(\n  null\n);\n\nexport const ListContext = <T extends IHasID = IHasID>(\n  props: IListContextOptions<T> & {\n    children?:\n      | ((props: IListContextState) => React.ReactNode)\n      | React.ReactNode;\n  }\n) => {\n  const { selectable = false, items, children } = props;\n\n  const listSelect = useListSelect(items);\n\n  const state: IListContextState<T> = {\n    selectable,\n    items,\n    ...listSelect,\n  };\n\n  return (\n    <ListStateContext.Provider value={state}>\n      {isFunction(children)\n        ? (children as (props: IListContextState) => React.ReactNode)(state)\n        : children}\n    </ListStateContext.Provider>\n  );\n};\n\nexport function useListContext<T extends IHasID = IHasID>() {\n  const context = React.useContext(ListStateContext);\n\n  if (context === null) {\n    throw new Error(\"useListContext must be used within a ListStateContext\");\n  }\n\n  return context as IListContextState<T>;\n}\n\nconst emptyState: IListContextState = {\n  selectable: false,\n  selectedIds: new Set(),\n  getSelected: () => [],\n  onSelectChange: () => {},\n  onSelectAll: () => {},\n  onSelectNone: () => {},\n  onInvertSelection: () => {},\n  items: [],\n  hasSelection: false,\n  selectedItems: [],\n};\n\nexport function useListContextOptional<T extends IHasID = IHasID>() {\n  const context = React.useContext(ListStateContext);\n\n  if (context === null) {\n    return emptyState as IListContextState<T>;\n  }\n\n  return context as IListContextState<T>;\n}\n\ninterface IQueryResultContextOptions<\n  T extends QueryResult,\n  E extends IHasID = IHasID,\n  M = unknown\n> {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  useResult: (filter: ListFilterModel) => T;\n  useMetadataInfo?: (filter: ListFilterModel) => M;\n  getCount: (data: T) => number;\n  getItems: (data: T) => E[];\n}\n\nexport interface IQueryResultContextState<\n  T extends QueryResult = QueryResult,\n  E extends IHasID = IHasID,\n  M = unknown\n> {\n  effectiveFilter: ListFilterModel;\n  result: T;\n  cachedResult: T;\n  metadataInfo?: M;\n  items: E[];\n  totalCount: number;\n}\n\nexport const QueryResultStateContext =\n  React.createContext<IQueryResultContextState | null>(null);\n\nexport const QueryResultContext = <\n  T extends QueryResult,\n  E extends IHasID = IHasID,\n  M = unknown\n>(\n  props: IQueryResultContextOptions<T, E, M> & {\n    children?:\n      | ((props: IQueryResultContextState<T, E, M>) => React.ReactNode)\n      | React.ReactNode;\n  }\n) => {\n  const {\n    filterHook,\n    useResult,\n    useMetadataInfo,\n    getItems,\n    getCount,\n    children,\n  } = props;\n\n  const { filter } = useFilter();\n  const effectiveFilter = useMemo(() => {\n    if (filterHook) {\n      return filterHook(filter.clone());\n    }\n    return filter;\n  }, [filter, filterHook]);\n\n  // metadata filter is the effective filter with the sort, page size and page number removed\n  const metadataFilter = useMemo(\n    () => effectiveFilter.metadataInfo(),\n    [effectiveFilter]\n  );\n\n  const result = useResult(effectiveFilter);\n  const metadataInfo = useMetadataInfo?.(metadataFilter);\n\n  // use cached query result for pagination\n  const cachedResult = useCachedQueryResult(effectiveFilter, result);\n\n  const items = useMemo(() => getItems(result), [getItems, result]);\n  const totalCount = useMemo(\n    () => getCount(cachedResult),\n    [getCount, cachedResult]\n  );\n\n  const state: IQueryResultContextState<T, E, M> = {\n    effectiveFilter,\n    result,\n    cachedResult,\n    items,\n    totalCount,\n    metadataInfo,\n  };\n\n  return (\n    <QueryResultStateContext.Provider value={state}>\n      {isFunction(children)\n        ? (children as (props: IQueryResultContextState) => React.ReactNode)(\n            state\n          )\n        : children}\n    </QueryResultStateContext.Provider>\n  );\n};\n\nexport function useQueryResultContext<\n  T extends QueryResult,\n  E extends IHasID = IHasID,\n  M = unknown\n>() {\n  const context = React.useContext(QueryResultStateContext);\n\n  if (context === null) {\n    throw new Error(\n      \"useQueryResultContext must be used within a ListStateContext\"\n    );\n  }\n\n  return context as IQueryResultContextState<T, E, M>;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/List/ListTable.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { Table, Form } from \"react-bootstrap\";\nimport { CheckBoxSelect } from \"../Shared/Select\";\nimport cx from \"classnames\";\n\nexport interface IColumn {\n  label: string;\n  value: string;\n  mandatory?: boolean;\n}\n\nexport const ColumnSelector: React.FC<{\n  selected: string[];\n  allColumns: IColumn[];\n  setSelected: (selected: string[]) => void;\n}> = ({ selected, allColumns, setSelected }) => {\n  const disableOptions = useMemo(() => {\n    return allColumns.map((col) => {\n      return {\n        ...col,\n        isDisabled: col.mandatory,\n      };\n    });\n  }, [allColumns]);\n\n  const selectedColumns = useMemo(() => {\n    return disableOptions.filter((col) => selected.includes(col.value));\n  }, [selected, disableOptions]);\n\n  return (\n    <CheckBoxSelect\n      options={disableOptions}\n      selectedOptions={selectedColumns}\n      onChange={(v) => {\n        setSelected(v.map((col) => col.value));\n      }}\n    />\n  );\n};\n\ninterface IListTableProps<T> {\n  className?: string;\n  items: T[];\n  columns: string[];\n  setColumns: (columns: string[]) => void;\n  allColumns: IColumn[];\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  renderCell: (column: IColumn, item: T, index: number) => React.ReactNode;\n}\n\nexport const ListTable = <T extends { id: string }>(\n  props: IListTableProps<T>\n) => {\n  const {\n    className,\n    items,\n    columns,\n    setColumns,\n    allColumns,\n    selectedIds,\n    onSelectChange,\n    renderCell,\n  } = props;\n\n  const visibleColumns = useMemo(() => {\n    return allColumns.filter(\n      (col) => col.mandatory || columns.includes(col.value)\n    );\n  }, [columns, allColumns]);\n\n  const renderObjectRow = (item: T, index: number) => {\n    let shiftKey = false;\n\n    return (\n      <tr key={item.id}>\n        <td className=\"select-col\">\n          <label>\n            <Form.Control\n              type=\"checkbox\"\n              checked={selectedIds.has(item.id)}\n              onChange={() =>\n                onSelectChange(item.id, !selectedIds.has(item.id), shiftKey)\n              }\n              onClick={(\n                event: React.MouseEvent<HTMLInputElement, MouseEvent>\n              ) => {\n                shiftKey = event.shiftKey;\n                event.stopPropagation();\n              }}\n            />\n          </label>\n        </td>\n\n        {visibleColumns.map((column) => (\n          <td key={column.value} className={`${column.value}-data`}>\n            {renderCell(column, item, index)}\n          </td>\n        ))}\n      </tr>\n    );\n  };\n\n  const columnHeaders = useMemo(() => {\n    return visibleColumns.map((column) => (\n      <th key={column.value} className={`${column.value}-head`}>\n        {column.label}\n      </th>\n    ));\n  }, [visibleColumns]);\n\n  return (\n    <div className={cx(\"table-list\", className)}>\n      <Table striped bordered>\n        <thead>\n          <tr>\n            <th className=\"select-col\">\n              <div\n                className=\"d-inline-block\"\n                data-toggle=\"popover\"\n                data-trigger=\"focus\"\n              >\n                <ColumnSelector\n                  allColumns={allColumns}\n                  selected={columns}\n                  setSelected={setColumns}\n                />\n              </div>\n            </th>\n\n            {columnHeaders}\n          </tr>\n          <tr>\n            <th className=\"border-row\" colSpan={100}></th>\n          </tr>\n        </thead>\n        <tbody>{items.map(renderObjectRow)}</tbody>\n      </Table>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/ListViewOptions.tsx",
    "content": "import React, { useEffect, useRef, useState } from \"react\";\nimport Mousetrap from \"mousetrap\";\nimport {\n  Button,\n  ButtonGroup,\n  Dropdown,\n  Overlay,\n  OverlayTrigger,\n  Popover,\n  Tooltip,\n} from \"react-bootstrap\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { IntlShape, useIntl } from \"react-intl\";\nimport { Icon } from \"../Shared/Icon\";\nimport {\n  faChevronDown,\n  faList,\n  faSquare,\n  faTags,\n  faThLarge,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { ZoomSelect } from \"./ZoomSlider\";\n\ninterface IListViewOptionsProps {\n  zoomIndex?: number;\n  onSetZoom?: (zoomIndex: number) => void;\n  displayMode: DisplayMode;\n  onSetDisplayMode: (m: DisplayMode) => void;\n  displayModeOptions: DisplayMode[];\n}\n\nfunction getIcon(option: DisplayMode) {\n  switch (option) {\n    case DisplayMode.Grid:\n      return faThLarge;\n    case DisplayMode.List:\n      return faList;\n    case DisplayMode.Wall:\n      return faSquare;\n    case DisplayMode.Tagger:\n      return faTags;\n  }\n}\n\nfunction getLabelId(option: DisplayMode) {\n  let displayModeId = \"unknown\";\n  switch (option) {\n    case DisplayMode.Grid:\n      displayModeId = \"grid\";\n      break;\n    case DisplayMode.List:\n      displayModeId = \"list\";\n      break;\n    case DisplayMode.Wall:\n      displayModeId = \"wall\";\n      break;\n    case DisplayMode.Tagger:\n      displayModeId = \"tagger\";\n      break;\n  }\n  return `display_mode.${displayModeId}`;\n}\n\nfunction getLabel(intl: IntlShape, option: DisplayMode) {\n  return intl.formatMessage({ id: getLabelId(option) });\n}\n\nexport const ListViewOptions: React.FC<IListViewOptionsProps> = ({\n  zoomIndex,\n  onSetZoom,\n  displayMode,\n  onSetDisplayMode,\n  displayModeOptions,\n}) => {\n  const intl = useIntl();\n\n  const overlayTarget = useRef(null);\n  const [showOptions, setShowOptions] = useState(false);\n\n  useEffect(() => {\n    Mousetrap.bind(\"v g\", () => {\n      if (displayModeOptions.includes(DisplayMode.Grid)) {\n        onSetDisplayMode(DisplayMode.Grid);\n      }\n    });\n    Mousetrap.bind(\"v l\", () => {\n      if (displayModeOptions.includes(DisplayMode.List)) {\n        onSetDisplayMode(DisplayMode.List);\n      }\n    });\n    Mousetrap.bind(\"v w\", () => {\n      if (displayModeOptions.includes(DisplayMode.Wall)) {\n        onSetDisplayMode(DisplayMode.Wall);\n      }\n    });\n    Mousetrap.bind(\"v t\", () => {\n      if (displayModeOptions.includes(DisplayMode.Tagger)) {\n        onSetDisplayMode(DisplayMode.Tagger);\n      }\n    });\n\n    return () => {\n      Mousetrap.unbind(\"v g\");\n      Mousetrap.unbind(\"v l\");\n      Mousetrap.unbind(\"v w\");\n      Mousetrap.unbind(\"v t\");\n    };\n  });\n\n  function onChangeZoom(v: number) {\n    if (onSetZoom) {\n      onSetZoom(v);\n    }\n  }\n\n  return (\n    <>\n      <Button\n        className=\"display-mode-select\"\n        ref={overlayTarget}\n        variant=\"secondary\"\n        title={intl.formatMessage(\n          { id: \"display_mode.label_current\" },\n          { current: getLabel(intl, displayMode) }\n        )}\n        onClick={() => setShowOptions(!showOptions)}\n      >\n        <Icon icon={getIcon(displayMode)} />\n        <Icon size=\"xs\" icon={faChevronDown} />\n      </Button>\n      <Overlay\n        target={overlayTarget.current}\n        show={showOptions}\n        placement=\"bottom\"\n        rootClose\n        onHide={() => setShowOptions(false)}\n      >\n        {({ placement, arrowProps, show: _show, ...props }) => (\n          <div className=\"popover\" {...props} style={{ ...props.style }}>\n            <Popover.Content className=\"display-mode-popover\">\n              <div className=\"display-mode-menu\">\n                {onSetZoom &&\n                zoomIndex !== undefined &&\n                (displayMode === DisplayMode.Grid ||\n                  displayMode === DisplayMode.Wall) ? (\n                  <div className=\"zoom-slider-container\">\n                    <ZoomSelect\n                      zoomIndex={zoomIndex}\n                      onChangeZoom={onChangeZoom}\n                    />\n                  </div>\n                ) : null}\n                {displayModeOptions.map((option) => (\n                  <Dropdown.Item\n                    key={option}\n                    active={displayMode === option}\n                    onClick={() => {\n                      setShowOptions(false);\n                      onSetDisplayMode(option);\n                    }}\n                  >\n                    <Icon icon={getIcon(option)} /> {getLabel(intl, option)}\n                  </Dropdown.Item>\n                ))}\n              </div>\n            </Popover.Content>\n          </div>\n        )}\n      </Overlay>\n    </>\n  );\n};\n\nexport const ListViewButtonGroup: React.FC<IListViewOptionsProps> = ({\n  zoomIndex,\n  onSetZoom,\n  displayMode,\n  onSetDisplayMode,\n  displayModeOptions,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <>\n      {displayModeOptions.length > 1 && (\n        <ButtonGroup>\n          {displayModeOptions.map((option) => (\n            <OverlayTrigger\n              key={option}\n              overlay={\n                <Tooltip id=\"display-mode-tooltip\">\n                  {getLabel(intl, option)}\n                </Tooltip>\n              }\n            >\n              <Button\n                variant=\"secondary\"\n                active={displayMode === option}\n                onClick={() => onSetDisplayMode(option)}\n              >\n                <Icon icon={getIcon(option)} />\n              </Button>\n            </OverlayTrigger>\n          ))}\n        </ButtonGroup>\n      )}\n      <div className=\"zoom-slider-container\">\n        {onSetZoom &&\n        zoomIndex !== undefined &&\n        (displayMode === DisplayMode.Grid ||\n          displayMode === DisplayMode.Wall) ? (\n          <ZoomSelect zoomIndex={zoomIndex} onChangeZoom={onSetZoom} />\n        ) : null}\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/ModifierSelect.tsx",
    "content": "import React from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\nimport { ModifierCriterion } from \"src/models/list-filter/criteria/criterion\";\nimport cx from \"classnames\";\nimport { useIntl } from \"react-intl\";\n\nconst defaultOptions = [\n  CriterionModifier.IsNull,\n  CriterionModifier.NotNull,\n  CriterionModifier.Equals,\n  CriterionModifier.NotEquals,\n  CriterionModifier.Includes,\n  CriterionModifier.Excludes,\n  CriterionModifier.GreaterThan,\n  CriterionModifier.LessThan,\n  CriterionModifier.Between,\n  CriterionModifier.NotBetween,\n];\n\ninterface IModifierSelect {\n  options?: CriterionModifier[];\n  value: CriterionModifier;\n  onChanged: (m: CriterionModifier) => void;\n}\n\nexport const ModifierSelectorButtons: React.FC<IModifierSelect> = ({\n  options = defaultOptions,\n  value,\n  onChanged,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <Form.Group className=\"modifier-options\">\n      {options.map((m) => (\n        <Button\n          className={cx(\"modifier-option\", {\n            selected: value === m,\n          })}\n          key={m}\n          onClick={() => onChanged(m)}\n        >\n          {ModifierCriterion.getModifierLabel(intl, m)}\n        </Button>\n      ))}\n    </Form.Group>\n  );\n};\n\nexport const ModifierSelect: React.FC<IModifierSelect> = ({\n  options = defaultOptions,\n  value,\n  onChanged,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <Form.Control\n      as=\"select\"\n      onChange={(e) => onChanged(e.target.value as CriterionModifier)}\n      value={value}\n      className=\"btn-secondary modifier-selector\"\n    >\n      {options.map((m) => (\n        <option key={m} value={m}>\n          {ModifierCriterion.getModifierLabel(intl, m)}\n        </option>\n      ))}\n    </Form.Control>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/PagedList.tsx",
    "content": "import React, { PropsWithChildren, useMemo } from \"react\";\nimport { ApolloError, QueryResult } from \"@apollo/client\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { Pagination, PaginationIndex } from \"./Pagination\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { ErrorMessage } from \"../Shared/ErrorMessage\";\nimport { FormattedMessage } from \"react-intl\";\n\nexport const LoadedContent: React.FC<\n  PropsWithChildren<{\n    loading?: boolean;\n    error?: ApolloError;\n  }>\n> = ({ loading, error, children }) => {\n  if (loading) {\n    return <LoadingIndicator />;\n  }\n  if (error) {\n    return (\n      <ErrorMessage\n        message={\n          <FormattedMessage\n            id=\"errors.loading_type\"\n            values={{ type: \"items\" }}\n          />\n        }\n        error={error.message}\n      />\n    );\n  }\n\n  return <>{children}</>;\n};\n\nexport const PagedList: React.FC<\n  PropsWithChildren<{\n    result: QueryResult;\n    cachedResult: QueryResult;\n    filter: ListFilterModel;\n    totalCount: number;\n    onChangePage: (page: number) => void;\n    metadataByline?: React.ReactNode;\n  }>\n> = ({\n  result,\n  cachedResult,\n  filter,\n  totalCount,\n  onChangePage,\n  metadataByline,\n  children,\n}) => {\n  const pages = Math.ceil(totalCount / filter.itemsPerPage);\n\n  const pagination = useMemo(() => {\n    return (\n      <Pagination\n        itemsPerPage={filter.itemsPerPage}\n        currentPage={filter.currentPage}\n        totalItems={totalCount}\n        metadataByline={metadataByline}\n        onChangePage={onChangePage}\n      />\n    );\n  }, [\n    filter.itemsPerPage,\n    filter.currentPage,\n    totalCount,\n    metadataByline,\n    onChangePage,\n  ]);\n\n  const paginationIndex = useMemo(() => {\n    if (cachedResult.loading) return;\n    return (\n      <PaginationIndex\n        itemsPerPage={filter.itemsPerPage}\n        currentPage={filter.currentPage}\n        totalItems={totalCount}\n        metadataByline={metadataByline}\n      />\n    );\n  }, [\n    cachedResult.loading,\n    filter.itemsPerPage,\n    filter.currentPage,\n    totalCount,\n    metadataByline,\n  ]);\n\n  const content = useMemo(() => {\n    return (\n      <LoadedContent loading={result.loading} error={result.error}>\n        {children}\n        {!!pages && (\n          <>\n            {paginationIndex}\n            {pagination}\n          </>\n        )}\n      </LoadedContent>\n    );\n  }, [\n    result.loading,\n    result.error,\n    pages,\n    children,\n    pagination,\n    paginationIndex,\n  ]);\n\n  return (\n    <>\n      {pagination}\n      {paginationIndex}\n      {content}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/Pagination.tsx",
    "content": "import React, { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  Button,\n  ButtonGroup,\n  Dropdown,\n  Form,\n  InputGroup,\n  Overlay,\n  Popover,\n} from \"react-bootstrap\";\nimport { FormattedMessage, FormattedNumber, useIntl } from \"react-intl\";\nimport useFocus from \"src/utils/focus\";\nimport { Icon } from \"../Shared/Icon\";\nimport { faCheck, faChevronDown } from \"@fortawesome/free-solid-svg-icons\";\nimport { useStopWheelScroll } from \"src/utils/form\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { PatchComponent } from \"src/patch\";\n\nconst PageCount: React.FC<{\n  totalPages: number;\n  currentPage: number;\n  onChangePage: (page: number) => void;\n  pagePopupPlacement?: Placement;\n}> = ({\n  totalPages,\n  currentPage,\n  onChangePage,\n  pagePopupPlacement = \"bottom\",\n}) => {\n  const intl = useIntl();\n  const currentPageCtrl = useRef(null);\n  const [pageInput, pageFocus] = useFocus();\n  const [showSelectPage, setShowSelectPage] = useState(false);\n\n  useEffect(() => {\n    if (showSelectPage) {\n      // delaying the focus to the next execution loop so that rendering takes place first and stops the page from resetting.\n      setTimeout(() => {\n        pageFocus();\n      }, 0);\n    }\n  }, [showSelectPage, pageFocus]);\n\n  useStopWheelScroll(pageInput);\n\n  const pageOptions = useMemo(() => {\n    const maxPagesToShow = 1000;\n    const min = Math.max(1, currentPage - maxPagesToShow / 2);\n    const max = Math.min(min + maxPagesToShow, totalPages);\n    const pages = [];\n    for (let i = min; i <= max; i++) {\n      pages.push(i);\n    }\n    return pages;\n  }, [totalPages, currentPage]);\n\n  function onCustomChangePage() {\n    const newPage = Number.parseInt(pageInput.current?.value ?? \"0\");\n    if (newPage) {\n      onChangePage(newPage);\n    }\n    setShowSelectPage(false);\n  }\n\n  return (\n    <div className=\"page-count-container\">\n      <ButtonGroup>\n        <Button\n          variant=\"secondary\"\n          className=\"page-count\"\n          ref={currentPageCtrl}\n          onClick={() => {\n            setShowSelectPage(true);\n            pageFocus();\n          }}\n        >\n          <FormattedMessage\n            id=\"pagination.current_total\"\n            values={{\n              current: intl.formatNumber(currentPage),\n              total: intl.formatNumber(totalPages),\n            }}\n          />\n        </Button>\n        <Dropdown>\n          <Dropdown.Toggle variant=\"secondary\" className=\"page-count-dropdown\">\n            <Icon size=\"xs\" icon={faChevronDown} />\n          </Dropdown.Toggle>\n          <Dropdown.Menu>\n            {pageOptions.map((s) => (\n              <Dropdown.Item\n                key={s}\n                active={s === currentPage}\n                onClick={() => onChangePage(s)}\n              >\n                {s}\n              </Dropdown.Item>\n            ))}\n          </Dropdown.Menu>\n        </Dropdown>\n      </ButtonGroup>\n      <Overlay\n        target={currentPageCtrl.current}\n        show={showSelectPage}\n        placement={pagePopupPlacement}\n        rootClose\n        onHide={() => setShowSelectPage(false)}\n      >\n        <Popover id=\"select_page_popover\">\n          <Form inline>\n            <InputGroup>\n              {/* can't use NumberField because of the ref */}\n              <Form.Control\n                type=\"number\"\n                min={1}\n                max={totalPages}\n                className=\"text-input\"\n                ref={pageInput}\n                defaultValue={currentPage}\n                onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {\n                  if (e.key === \"Enter\") {\n                    onCustomChangePage();\n                    e.preventDefault();\n                  }\n                }}\n                onFocus={(e: React.FocusEvent<HTMLInputElement>) =>\n                  e.target.select()\n                }\n              />\n              <InputGroup.Append>\n                <Button variant=\"primary\" onClick={() => onCustomChangePage()}>\n                  <Icon icon={faCheck} />\n                </Button>\n              </InputGroup.Append>\n            </InputGroup>\n          </Form>\n        </Popover>\n      </Overlay>\n    </div>\n  );\n};\n\ninterface IPaginationProps {\n  itemsPerPage: number;\n  currentPage: number;\n  totalItems: number;\n  metadataByline?: React.ReactNode;\n  onChangePage: (page: number) => void;\n  pagePopupPlacement?: Placement;\n}\n\ninterface IPaginationIndexProps {\n  loading?: boolean;\n  itemsPerPage: number;\n  currentPage: number;\n  totalItems: number;\n  metadataByline?: React.ReactNode;\n}\n\nconst minPagesForCompact = 4;\n\nexport const Pagination: React.FC<IPaginationProps> = PatchComponent(\n  \"Pagination\",\n  ({\n    itemsPerPage,\n    currentPage,\n    totalItems,\n    onChangePage,\n    pagePopupPlacement,\n  }) => {\n    const intl = useIntl();\n    const totalPages = useMemo(\n      () => Math.ceil(totalItems / itemsPerPage),\n      [totalItems, itemsPerPage]\n    );\n\n    const pageButtons = useMemo(() => {\n      if (totalPages >= minPagesForCompact)\n        return (\n          <PageCount\n            totalPages={totalPages}\n            currentPage={currentPage}\n            onChangePage={onChangePage}\n            pagePopupPlacement={pagePopupPlacement}\n          />\n        );\n\n      const pages = [...Array(totalPages).keys()].map((i) => i + 1);\n\n      return pages.map((page: number) => (\n        <Button\n          variant=\"secondary\"\n          key={page}\n          active={currentPage === page}\n          onClick={() => onChangePage(page)}\n        >\n          <FormattedNumber value={page} />\n        </Button>\n      ));\n    }, [totalPages, currentPage, onChangePage, pagePopupPlacement]);\n\n    if (totalPages <= 1) return <div />;\n\n    return (\n      <ButtonGroup className=\"pagination\">\n        <Button\n          variant=\"secondary\"\n          disabled={currentPage === 1}\n          onClick={() => onChangePage(1)}\n          title={intl.formatMessage({ id: \"pagination.first\" })}\n        >\n          <span>«</span>\n        </Button>\n        <Button\n          variant=\"secondary\"\n          disabled={currentPage === 1}\n          onClick={() => onChangePage(currentPage - 1)}\n          title={intl.formatMessage({ id: \"pagination.previous\" })}\n        >\n          &lt;\n        </Button>\n        {pageButtons}\n        <Button\n          variant=\"secondary\"\n          disabled={currentPage === totalPages}\n          onClick={() => onChangePage(currentPage + 1)}\n          title={intl.formatMessage({ id: \"pagination.next\" })}\n        >\n          &gt;\n        </Button>\n        <Button\n          variant=\"secondary\"\n          disabled={currentPage === totalPages}\n          onClick={() => onChangePage(totalPages)}\n          title={intl.formatMessage({ id: \"pagination.last\" })}\n        >\n          <span>»</span>\n        </Button>\n      </ButtonGroup>\n    );\n  }\n);\n\nexport const PaginationIndex: React.FC<IPaginationIndexProps> = PatchComponent(\n  \"PaginationIndex\",\n  ({ loading, itemsPerPage, currentPage, totalItems, metadataByline }) => {\n    const intl = useIntl();\n\n    if (loading) return null;\n\n    // Build the pagination index string\n    const firstItemCount: number = Math.min(\n      (currentPage - 1) * itemsPerPage + 1,\n      totalItems\n    );\n    const lastItemCount: number = Math.min(\n      firstItemCount + (itemsPerPage - 1),\n      totalItems\n    );\n    const indexText: string = `${intl.formatNumber(\n      firstItemCount\n    )}-${intl.formatNumber(lastItemCount)} of ${intl.formatNumber(totalItems)}`;\n\n    return (\n      <span className=\"filter-container text-muted paginationIndex center-text\">\n        {indexText}\n        <br />\n        {metadataByline}\n      </span>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/List/SavedFilterList.tsx",
    "content": "import React, { HTMLAttributes, useEffect, useMemo, useState } from \"react\";\nimport {\n  Button,\n  ButtonGroup,\n  Dropdown,\n  Form,\n  FormControl,\n  InputGroup,\n  Modal,\n  OverlayTrigger,\n  Tooltip,\n} from \"react-bootstrap\";\nimport {\n  useConfigureUISetting,\n  useFindSavedFilters,\n  useSavedFilterDestroy,\n  useSaveFilter,\n} from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  FilterMode,\n  SavedFilterDataFragment,\n} from \"src/core/generated-graphql\";\nimport { View } from \"./views\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Icon } from \"../Shared/Icon\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { faBookmark, faSave, faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport { AlertModal } from \"../Shared/Alert\";\nimport cx from \"classnames\";\nimport { TruncatedInlineText } from \"../Shared/TruncatedText\";\nimport { OperationButton } from \"../Shared/OperationButton\";\nimport { createPortal } from \"react-dom\";\n\nconst ExistingSavedFilterList: React.FC<{\n  name: string;\n  onSelect: (value: SavedFilterDataFragment) => void;\n  savedFilters: SavedFilterDataFragment[];\n  disabled?: boolean;\n}> = ({ name, onSelect, savedFilters: existing, disabled = false }) => {\n  const filtered = useMemo(() => {\n    if (!name) return existing;\n\n    return existing.filter((f) =>\n      f.name.toLowerCase().includes(name.toLowerCase())\n    );\n  }, [existing, name]);\n\n  return (\n    <ul className=\"existing-filter-list\">\n      {filtered.map((f) => (\n        <li key={f.id}>\n          <Button\n            className=\"minimal\"\n            variant=\"link\"\n            onClick={() => onSelect(f)}\n            disabled={disabled}\n          >\n            {f.name}\n          </Button>\n        </li>\n      ))}\n    </ul>\n  );\n};\n\nexport const SaveFilterDialog: React.FC<{\n  mode: FilterMode;\n  onClose: (name?: string, id?: string) => void;\n  isSaving?: boolean;\n}> = ({ mode, onClose, isSaving = false }) => {\n  const intl = useIntl();\n  const [filterName, setFilterName] = useState(\"\");\n\n  const { data } = useFindSavedFilters(mode);\n\n  const overwritingFilter = useMemo(() => {\n    const savedFilters = data?.findSavedFilters ?? [];\n    return savedFilters.find(\n      (f) => f.name.toLowerCase() === filterName.toLowerCase()\n    );\n  }, [data?.findSavedFilters, filterName]);\n\n  return (\n    <Modal show className=\"save-filter-dialog\">\n      <Modal.Header>\n        <FormattedMessage id=\"actions.save_filter\" />\n      </Modal.Header>\n      <Modal.Body>\n        <Form.Group>\n          <Form.Label>\n            <FormattedMessage id=\"filter_name\" />\n          </Form.Label>\n          <FormControl\n            className=\"bg-secondary text-white border-secondary\"\n            placeholder={`${intl.formatMessage({ id: \"filter_name\" })}…`}\n            value={filterName}\n            onChange={(e) => setFilterName(e.target.value)}\n            disabled={isSaving}\n          />\n        </Form.Group>\n\n        <ExistingSavedFilterList\n          name={filterName}\n          onSelect={(f) => setFilterName(f.name)}\n          savedFilters={data?.findSavedFilters ?? []}\n        />\n\n        {!!overwritingFilter && (\n          <span className=\"saved-filter-overwrite-warning\">\n            <FormattedMessage\n              id=\"dialogs.overwrite_filter_warning\"\n              values={{\n                entityName: overwritingFilter.name,\n              }}\n            />\n          </span>\n        )}\n      </Modal.Body>\n      <Modal.Footer>\n        <Button\n          variant=\"secondary\"\n          onClick={() => onClose()}\n          disabled={isSaving}\n        >\n          {intl.formatMessage({ id: \"actions.cancel\" })}\n        </Button>\n        <OperationButton\n          loading={isSaving}\n          variant=\"primary\"\n          onClick={() => onClose(filterName, overwritingFilter?.id)}\n        >\n          {intl.formatMessage({ id: \"actions.save\" })}\n        </OperationButton>\n      </Modal.Footer>\n    </Modal>\n  );\n};\n\nexport const LoadFilterDialog: React.FC<{\n  mode: FilterMode;\n  onClose: (filter?: SavedFilterDataFragment) => void;\n}> = ({ mode, onClose }) => {\n  const intl = useIntl();\n  const [filterName, setFilterName] = useState(\"\");\n\n  const { data } = useFindSavedFilters(mode);\n\n  return (\n    <Modal show className=\"load-filter-dialog\">\n      <Modal.Header>\n        <FormattedMessage id=\"actions.load_filter\" />\n      </Modal.Header>\n      <Modal.Body>\n        <Form.Group>\n          <Form.Label>\n            <FormattedMessage id=\"filter_name\" />\n          </Form.Label>\n          <FormControl\n            className=\"bg-secondary text-white border-secondary\"\n            placeholder={`${intl.formatMessage({ id: \"filter_name\" })}…`}\n            value={filterName}\n            onChange={(e) => setFilterName(e.target.value)}\n          />\n        </Form.Group>\n\n        <ExistingSavedFilterList\n          name={filterName}\n          onSelect={(f) => onClose(f)}\n          savedFilters={data?.findSavedFilters ?? []}\n        />\n      </Modal.Body>\n      <Modal.Footer>\n        <Button variant=\"secondary\" onClick={() => onClose()}>\n          {intl.formatMessage({ id: \"actions.cancel\" })}\n        </Button>\n      </Modal.Footer>\n    </Modal>\n  );\n};\n\nconst DeleteAlert: React.FC<{\n  deletingFilter: SavedFilterDataFragment | undefined;\n  onClose: (confirm?: boolean) => void;\n}> = ({ deletingFilter, onClose }) => {\n  if (!deletingFilter) {\n    return null;\n  }\n\n  return (\n    <Modal show>\n      <Modal.Body>\n        <FormattedMessage\n          id=\"dialogs.delete_confirm\"\n          values={{\n            entityName: deletingFilter.name,\n          }}\n        />\n      </Modal.Body>\n      <Modal.Footer>\n        <Button variant=\"danger\" onClick={() => onClose(true)}>\n          <FormattedMessage id=\"actions.delete\" />\n        </Button>\n        <Button variant=\"secondary\" onClick={() => onClose()}>\n          <FormattedMessage id=\"actions.cancel\" />\n        </Button>\n      </Modal.Footer>\n    </Modal>\n  );\n};\n\nconst OverwriteAlert: React.FC<{\n  overwritingFilter: SavedFilterDataFragment | undefined;\n  onClose: (confirm?: boolean) => void;\n}> = ({ overwritingFilter, onClose }) => {\n  if (!overwritingFilter) {\n    return null;\n  }\n\n  return (\n    <Modal show>\n      <Modal.Body>\n        <FormattedMessage\n          id=\"dialogs.overwrite_filter_warning\"\n          values={{\n            entityName: overwritingFilter.name,\n          }}\n        />\n      </Modal.Body>\n      <Modal.Footer>\n        <Button variant=\"primary\" onClick={() => onClose(true)}>\n          <FormattedMessage id=\"actions.overwrite\" />\n        </Button>\n        <Button variant=\"secondary\" onClick={() => onClose()}>\n          <FormattedMessage id=\"actions.cancel\" />\n        </Button>\n      </Modal.Footer>\n    </Modal>\n  );\n};\n\ninterface ISavedFilterListProps {\n  filter: ListFilterModel;\n  onSetFilter: (f: ListFilterModel) => void;\n  view?: View;\n  menuPortalTarget?: Element | DocumentFragment;\n}\n\nexport const SavedFilterList: React.FC<ISavedFilterListProps> = ({\n  filter,\n  onSetFilter,\n  view,\n}) => {\n  const Toast = useToast();\n  const intl = useIntl();\n\n  const { data, error, loading, refetch } = useFindSavedFilters(filter.mode);\n\n  const [filterName, setFilterName] = useState(\"\");\n  const [saving, setSaving] = useState(false);\n  const [deletingFilter, setDeletingFilter] = useState<\n    SavedFilterDataFragment | undefined\n  >();\n  const [overwritingFilter, setOverwritingFilter] = useState<\n    SavedFilterDataFragment | undefined\n  >();\n\n  const saveFilter = useSaveFilter();\n  const [destroyFilter] = useSavedFilterDestroy();\n  const [saveUISetting] = useConfigureUISetting();\n\n  const savedFilters = data?.findSavedFilters ?? [];\n\n  async function onSaveFilter(name: string, id?: string) {\n    const filterCopy = filter.clone();\n\n    try {\n      setSaving(true);\n      await saveFilter(filterCopy, name, id);\n\n      Toast.success(\n        intl.formatMessage(\n          {\n            id: \"toast.saved_entity\",\n          },\n          {\n            entity: intl.formatMessage({ id: \"filter\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n      setFilterName(\"\");\n      setOverwritingFilter(undefined);\n      refetch();\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setSaving(false);\n    }\n  }\n\n  async function onDeleteFilter(f: SavedFilterDataFragment) {\n    try {\n      setSaving(true);\n\n      await destroyFilter({\n        variables: {\n          input: {\n            id: f.id,\n          },\n        },\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          {\n            id: \"toast.delete_past_tense\",\n          },\n          {\n            count: 1,\n            singularEntity: intl.formatMessage({ id: \"filter\" }),\n            pluralEntity: intl.formatMessage({ id: \"filters\" }),\n          }\n        )\n      );\n      refetch();\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setSaving(false);\n      setDeletingFilter(undefined);\n    }\n  }\n\n  async function onSetDefaultFilter() {\n    if (!view) {\n      return;\n    }\n\n    const filterCopy = filter.clone();\n\n    try {\n      setSaving(true);\n\n      await saveUISetting({\n        variables: {\n          key: `defaultFilters.${view.toString()}`,\n          value: {\n            mode: filter.mode,\n            find_filter: filterCopy.makeFindFilter(),\n            object_filter: filterCopy.makeSavedFilter(),\n            ui_options: filterCopy.makeSavedUIOptions(),\n          },\n        },\n      });\n\n      Toast.success(\n        intl.formatMessage({\n          id: \"toast.default_filter_set\",\n        })\n      );\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setSaving(false);\n    }\n  }\n\n  function filterClicked(f: SavedFilterDataFragment) {\n    const newFilter = filter.clone();\n\n    newFilter.currentPage = 1;\n    // #1795 - reset search term if not present in saved filter\n    newFilter.searchTerm = \"\";\n    newFilter.configureFromSavedFilter(f);\n    // #1507 - reset random seed when loaded\n    newFilter.randomSeed = -1;\n\n    onSetFilter(newFilter);\n  }\n\n  interface ISavedFilterItem {\n    item: SavedFilterDataFragment;\n  }\n  const SavedFilterItem: React.FC<ISavedFilterItem> = ({ item }) => {\n    return (\n      <div className=\"dropdown-item-container\">\n        <Dropdown.Item onClick={() => filterClicked(item)} title={item.name}>\n          <span>{item.name}</span>\n        </Dropdown.Item>\n        <ButtonGroup>\n          <Button\n            className=\"save-button\"\n            variant=\"secondary\"\n            size=\"sm\"\n            title={intl.formatMessage({ id: \"actions.overwrite\" })}\n            onClick={(e) => {\n              setOverwritingFilter(item);\n              e.stopPropagation();\n            }}\n          >\n            <Icon icon={faSave} />\n          </Button>\n          <Button\n            className=\"delete-button\"\n            variant=\"secondary\"\n            size=\"sm\"\n            title={intl.formatMessage({ id: \"actions.delete\" })}\n            onClick={(e) => {\n              setDeletingFilter(item);\n              e.stopPropagation();\n            }}\n          >\n            <Icon icon={faTimes} />\n          </Button>\n        </ButtonGroup>\n      </div>\n    );\n  };\n\n  function renderSavedFilters() {\n    if (error) return <h6 className=\"text-center\">{error.message}</h6>;\n\n    if (loading || saving) {\n      return (\n        <div className=\"loading\">\n          <LoadingIndicator message=\"\" />\n        </div>\n      );\n    }\n\n    return (\n      <ul className=\"saved-filter-list\">\n        {savedFilters\n          .filter(\n            (f) =>\n              !filterName ||\n              f.name.toLowerCase().includes(filterName.toLowerCase())\n          )\n          .map((f) => (\n            <SavedFilterItem key={f.name} item={f} />\n          ))}\n      </ul>\n    );\n  }\n\n  function maybeRenderSetDefaultButton() {\n    if (view) {\n      return (\n        <div className=\"mt-1\">\n          <Dropdown.Item\n            as={Button}\n            title={intl.formatMessage({ id: \"actions.set_as_default\" })}\n            className=\"set-as-default-button\"\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={() => onSetDefaultFilter()}\n          >\n            {intl.formatMessage({ id: \"actions.set_as_default\" })}\n          </Dropdown.Item>\n        </div>\n      );\n    }\n  }\n\n  return (\n    <>\n      <DeleteAlert\n        deletingFilter={deletingFilter}\n        onClose={(confirm) => {\n          if (confirm) {\n            onDeleteFilter(deletingFilter!);\n          }\n          setDeletingFilter(undefined);\n        }}\n      />\n      <OverwriteAlert\n        overwritingFilter={overwritingFilter}\n        onClose={(confirm) => {\n          if (confirm) {\n            onSaveFilter(overwritingFilter!.name, overwritingFilter!.id);\n          }\n          setOverwritingFilter(undefined);\n        }}\n      />\n      <InputGroup>\n        <FormControl\n          className=\"bg-secondary text-white border-secondary\"\n          placeholder={`${intl.formatMessage({ id: \"filter_name\" })}…`}\n          value={filterName}\n          onChange={(e) => setFilterName(e.target.value)}\n        />\n        <InputGroup.Append>\n          <OverlayTrigger\n            placement=\"top\"\n            overlay={\n              <Tooltip id=\"filter-tooltip\">\n                <FormattedMessage id=\"actions.save_filter\" />\n              </Tooltip>\n            }\n          >\n            <Button\n              disabled={\n                !filterName || !!savedFilters.find((f) => f.name === filterName)\n              }\n              variant=\"secondary\"\n              onClick={() => {\n                onSaveFilter(filterName);\n              }}\n            >\n              <Icon icon={faSave} />\n            </Button>\n          </OverlayTrigger>\n        </InputGroup.Append>\n      </InputGroup>\n      {renderSavedFilters()}\n      {maybeRenderSetDefaultButton()}\n    </>\n  );\n};\n\ninterface ISavedFilterItem {\n  item: SavedFilterDataFragment;\n  onClick: () => void;\n  onDelete: () => void;\n  selected?: boolean;\n}\n\nconst SavedFilterItem: React.FC<ISavedFilterItem> = ({\n  item,\n  onClick,\n  onDelete,\n  selected = false,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <li className=\"saved-filter-item\">\n      <a onClick={onClick}>\n        <div className=\"label-group\">\n          <TruncatedInlineText\n            className={cx(\"no-icon-margin\", { selected })}\n            text={item.name}\n          />\n        </div>\n        <div>\n          <Button\n            className=\"delete-button\"\n            variant=\"minimal\"\n            size=\"sm\"\n            title={intl.formatMessage({ id: \"actions.delete\" })}\n            onClick={(e) => {\n              onDelete();\n              e.stopPropagation();\n            }}\n          >\n            <Icon fixedWidth icon={faTimes} />\n          </Button>\n        </div>\n      </a>\n    </li>\n  );\n};\n\nconst SavedFilters: React.FC<{\n  error?: string;\n  loading?: boolean;\n  saving?: boolean;\n  savedFilters: SavedFilterDataFragment[];\n  onFilterClicked: (f: SavedFilterDataFragment) => void;\n  onDeleteClicked: (f: SavedFilterDataFragment) => void;\n  currentFilterID?: string;\n}> = ({\n  error,\n  loading,\n  saving,\n  savedFilters,\n  onFilterClicked,\n  onDeleteClicked,\n  currentFilterID,\n}) => {\n  if (error) return <h6 className=\"text-center\">{error}</h6>;\n\n  if (loading || saving) {\n    return (\n      <div className=\"loading\">\n        <LoadingIndicator message=\"\" />\n      </div>\n    );\n  }\n\n  return (\n    <ul className=\"saved-filter-list\">\n      {savedFilters.map((f) => (\n        <SavedFilterItem\n          key={f.name}\n          item={f}\n          onClick={() => onFilterClicked(f)}\n          onDelete={() => onDeleteClicked(f)}\n          selected={currentFilterID === f.id}\n        />\n      ))}\n    </ul>\n  );\n};\n\nexport const SidebarSavedFilterList: React.FC<ISavedFilterListProps> = ({\n  filter,\n  onSetFilter,\n  view,\n}) => {\n  const Toast = useToast();\n  const intl = useIntl();\n\n  const [currentSavedFilter, setCurrentSavedFilter] = useState<{\n    id: string;\n    set: boolean;\n  }>();\n\n  const { data, error, loading, refetch } = useFindSavedFilters(filter.mode);\n\n  const [filterName, setFilterName] = useState(\"\");\n  const [saving, setSaving] = useState(false);\n  const [deletingFilter, setDeletingFilter] = useState<\n    SavedFilterDataFragment | undefined\n  >();\n  const [showSaveDialog, setShowSaveDialog] = useState(false);\n  const [settingDefault, setSettingDefault] = useState(false);\n\n  const saveFilter = useSaveFilter();\n  const [destroyFilter] = useSavedFilterDestroy();\n  const [saveUISetting] = useConfigureUISetting();\n\n  const filteredFilters = useMemo(() => {\n    const savedFilters = data?.findSavedFilters ?? [];\n    if (!filterName) return savedFilters;\n\n    return savedFilters.filter(\n      (f) =>\n        !filterName || f.name.toLowerCase().includes(filterName.toLowerCase())\n    );\n  }, [data?.findSavedFilters, filterName]);\n\n  // handle when filter is changed to de-select the current filter\n  useEffect(() => {\n    // HACK - first change will be from setting the filter\n    // second change is likely from somewhere else\n    setCurrentSavedFilter((v) => {\n      if (!v) return v;\n\n      if (v.set) {\n        setCurrentSavedFilter({ id: v.id, set: false });\n      } else {\n        setCurrentSavedFilter(undefined);\n      }\n    });\n  }, [filter]);\n\n  async function onSaveFilter(name: string, id?: string) {\n    try {\n      setSaving(true);\n      await saveFilter(filter, name, id);\n\n      Toast.success(\n        intl.formatMessage(\n          {\n            id: \"toast.saved_entity\",\n          },\n          {\n            entity: intl.formatMessage({ id: \"filter\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n      setFilterName(\"\");\n      setShowSaveDialog(false);\n      refetch();\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setSaving(false);\n    }\n  }\n\n  async function onDeleteFilter(f: SavedFilterDataFragment) {\n    try {\n      setSaving(true);\n\n      await destroyFilter({\n        variables: {\n          input: {\n            id: f.id,\n          },\n        },\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          {\n            id: \"toast.delete_past_tense\",\n          },\n          {\n            count: 1,\n            singularEntity: intl.formatMessage({ id: \"filter\" }),\n            pluralEntity: intl.formatMessage({ id: \"filters\" }),\n          }\n        )\n      );\n      refetch();\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setSaving(false);\n      setDeletingFilter(undefined);\n    }\n  }\n\n  async function onSetDefaultFilter() {\n    if (!view) {\n      return;\n    }\n\n    const filterCopy = filter.clone();\n\n    try {\n      setSaving(true);\n\n      await saveUISetting({\n        variables: {\n          key: `defaultFilters.${view.toString()}`,\n          value: {\n            mode: filter.mode,\n            find_filter: filterCopy.makeFindFilter(),\n            object_filter: filterCopy.makeSavedFilter(),\n            ui_options: filterCopy.makeSavedUIOptions(),\n          },\n        },\n      });\n\n      Toast.success(\n        intl.formatMessage({\n          id: \"toast.default_filter_set\",\n        })\n      );\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setSaving(false);\n      setSettingDefault(false);\n    }\n  }\n\n  function filterClicked(f: SavedFilterDataFragment) {\n    const newFilter = filter.clone();\n\n    newFilter.currentPage = 1;\n    // #1795 - reset search term if not present in saved filter\n    newFilter.searchTerm = \"\";\n    newFilter.configureFromSavedFilter(f);\n    // #1507 - reset random seed when loaded\n    newFilter.randomSeed = -1;\n\n    setCurrentSavedFilter({ id: f.id, set: true });\n    onSetFilter(newFilter);\n  }\n\n  return (\n    <div className=\"sidebar-saved-filter-list-container\">\n      <DeleteAlert\n        deletingFilter={deletingFilter}\n        onClose={(confirm) => {\n          if (confirm) {\n            onDeleteFilter(deletingFilter!);\n          }\n          setDeletingFilter(undefined);\n        }}\n      />\n      {showSaveDialog && (\n        <SaveFilterDialog\n          mode={filter.mode}\n          onClose={(name, id) => {\n            setShowSaveDialog(false);\n            if (name) {\n              onSaveFilter(name, id);\n            }\n          }}\n        />\n      )}\n      <AlertModal\n        show={!!settingDefault}\n        text={<FormattedMessage id=\"dialogs.set_default_filter_confirm\" />}\n        confirmVariant=\"primary\"\n        onConfirm={() => onSetDefaultFilter()}\n        onCancel={() => setSettingDefault(false)}\n      />\n\n      <div className=\"toolbar\">\n        <Button\n          className=\"minimal save-filter-button\"\n          size=\"sm\"\n          onClick={() => setShowSaveDialog(true)}\n        >\n          <span>\n            <FormattedMessage id=\"actions.save_filter\" />\n          </span>\n        </Button>\n        <Button\n          className=\"minimal set-as-default-button\"\n          variant=\"secondary\"\n          size=\"sm\"\n          onClick={() => setSettingDefault(true)}\n        >\n          <FormattedMessage id=\"actions.set_as_default\" />\n        </Button>\n      </div>\n\n      <FormControl\n        className=\"bg-secondary text-white border-secondary saved-filter-search-input\"\n        placeholder={`${intl.formatMessage({ id: \"filter_name\" })}…`}\n        value={filterName}\n        onChange={(e) => setFilterName(e.target.value)}\n      />\n      <SavedFilters\n        error={error?.message}\n        loading={loading}\n        saving={saving}\n        savedFilters={filteredFilters}\n        onFilterClicked={filterClicked}\n        onDeleteClicked={setDeletingFilter}\n        currentFilterID={currentSavedFilter?.id}\n      />\n    </div>\n  );\n};\n\nexport const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {\n  const SavedFilterDropdownRef = React.forwardRef<\n    HTMLDivElement,\n    HTMLAttributes<HTMLDivElement>\n  >(({ style, className }: HTMLAttributes<HTMLDivElement>, ref) => (\n    <div ref={ref} style={style} className={className}>\n      <SavedFilterList {...props} />\n    </div>\n  ));\n  SavedFilterDropdownRef.displayName = \"SavedFilterDropdown\";\n\n  const menu = (\n    <Dropdown.Menu\n      as={SavedFilterDropdownRef}\n      className=\"saved-filter-list-menu\"\n    />\n  );\n\n  return (\n    <Dropdown as={ButtonGroup} className=\"saved-filter-dropdown\">\n      <OverlayTrigger\n        placement=\"top\"\n        overlay={\n          <Tooltip id=\"filter-tooltip\">\n            <FormattedMessage id=\"search_filter.saved_filters\" />\n          </Tooltip>\n        }\n      >\n        <Dropdown.Toggle variant=\"secondary\">\n          <Icon icon={faBookmark} />\n        </Dropdown.Toggle>\n      </OverlayTrigger>\n      {props.menuPortalTarget\n        ? createPortal(menu, props.menuPortalTarget)\n        : menu}\n    </Dropdown>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/ZoomSlider.tsx",
    "content": "import React, { useEffect } from \"react\";\nimport Mousetrap from \"mousetrap\";\nimport { Form } from \"react-bootstrap\";\n\nconst minZoom = 0;\nconst maxZoom = 3;\n\nexport function useZoomKeybinds(props: {\n  zoomIndex: number | undefined;\n  onChangeZoom: (v: number) => void;\n}) {\n  const { zoomIndex, onChangeZoom } = props;\n  useEffect(() => {\n    Mousetrap.bind(\"+\", () => {\n      if (zoomIndex !== undefined && zoomIndex < maxZoom) {\n        onChangeZoom(zoomIndex + 1);\n      }\n    });\n    Mousetrap.bind(\"-\", () => {\n      if (zoomIndex !== undefined && zoomIndex > minZoom) {\n        onChangeZoom(zoomIndex - 1);\n      }\n    });\n\n    return () => {\n      Mousetrap.unbind(\"+\");\n      Mousetrap.unbind(\"-\");\n    };\n  });\n}\n\nexport interface IZoomSelectProps {\n  zoomIndex: number;\n  onChangeZoom: (v: number) => void;\n}\n\nexport const ZoomSelect: React.FC<IZoomSelectProps> = ({\n  zoomIndex,\n  onChangeZoom,\n}) => {\n  return (\n    <Form.Control\n      className=\"zoom-slider\"\n      type=\"range\"\n      min={minZoom}\n      max={maxZoom}\n      value={zoomIndex}\n      onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n        onChangeZoom(Number.parseInt(e.currentTarget.value, 10));\n        e.preventDefault();\n        e.stopPropagation();\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/List/styles.scss",
    "content": ".pagination {\n  .btn {\n    border-left: 1px solid $body-bg;\n    border-right: 1px solid $body-bg;\n    flex-grow: 0;\n    padding-left: 15px;\n    padding-right: 15px;\n    transition: none;\n\n    &.page-count {\n      padding-right: 5px;\n    }\n\n    &.page-count-dropdown {\n      padding-left: 5px;\n    }\n\n    &:first-child {\n      border-left: none;\n      border-right: none;\n    }\n\n    &:last-child {\n      border-right: none;\n    }\n  }\n\n  .page-count-container .btn {\n    border-radius: 0;\n  }\n}\n\n.center-text {\n  text-align: center;\n}\n\n.display-mode-select {\n  padding-left: 0.375rem;\n  padding-right: 0.375rem;\n  text-wrap: nowrap;\n\n  > svg:first-child {\n    margin-right: 0;\n  }\n}\n\n.display-mode-menu {\n  .dropdown-item {\n    color: #f5f8fa;\n    font-size: 1rem;\n\n    &:hover {\n      background-color: rgba(138, 155, 168, 0.15);\n      cursor: pointer;\n    }\n  }\n\n  .zoom-slider-container {\n    display: flex;\n    justify-content: center;\n    margin-bottom: 0.5rem;\n    min-height: 1rem;\n    padding-bottom: 0.5rem;\n    padding-top: 0.25rem;\n  }\n\n  .zoom-slider {\n    &::-webkit-slider-thumb {\n      background-color: $primary;\n    }\n\n    &::-webkit-slider-runnable-track {\n      background-color: $body-bg;\n    }\n\n    &:focus::-webkit-slider-runnable-track {\n      background-color: lighten($body-bg, 5%);\n    }\n\n    &::-moz-range-thumb {\n      background-color: $primary;\n    }\n\n    &::-moz-range-track {\n      background-color: $body-bg;\n    }\n\n    &:focus::-moz-range-track {\n      background-color: lighten($body-bg, 5%);\n    }\n  }\n}\n\n// hide zoom slider in xs viewport\n@include media-breakpoint-down(xs) {\n  .display-mode-menu .zoom-slider-container,\n  .zoom-slider-container {\n    display: none;\n  }\n}\n\n.display-mode-popover {\n  padding-left: 0;\n  padding-right: 0;\n}\n\ninput[type=\"range\"].zoom-slider {\n  height: 100%;\n  margin: 0;\n  max-width: 60px;\n  padding-left: 0;\n  padding-right: 0;\n\n  // width is set to 100% by default, but in a flex container, it gets a very small width\n  width: unset;\n}\n\n.query-text-field-group {\n  align-items: stretch;\n  display: flex;\n  flex-wrap: wrap;\n  position: relative;\n}\n\n.query-text-field {\n  border: 0;\n  width: 50%;\n}\n\n.query-text-field-clear {\n  background-color: $secondary;\n  color: $text-muted;\n  font-size: $btn-font-size-sm;\n  margin: $btn-padding-y $btn-padding-x;\n  padding: 0;\n  position: absolute;\n  right: 0;\n  z-index: 4;\n\n  &:hover,\n  &:focus,\n  &:active,\n  &:not(:disabled):not(.disabled):active,\n  &:not(:disabled):not(.disabled):active:focus {\n    background-color: $secondary;\n    border-color: transparent;\n    box-shadow: none;\n  }\n}\n\n.saved-filter-list-menu {\n  width: 300px;\n\n  &.dropdown-menu.show {\n    display: flex;\n    flex-direction: column;\n  }\n\n  .set-as-default-button {\n    float: right;\n    margin-right: 0.5rem;\n    padding: 0.25rem 0.5rem;\n    width: auto;\n  }\n\n  .LoadingIndicator {\n    height: auto;\n    text-align: center;\n\n    .spinner-border {\n      height: 1.5rem;\n      width: 1.5rem;\n    }\n  }\n}\n\n.saved-filter-list {\n  list-style: none;\n  margin-bottom: 0.25rem;\n  max-height: 230px;\n  overflow-y: auto;\n  padding-left: 0;\n\n  .dropdown-item-container {\n    display: flex;\n\n    .dropdown-item {\n      align-items: center;\n      color: $text-color;\n      display: inline;\n      overflow-x: hidden;\n      padding-left: 1.25rem;\n      padding-right: 0.25rem;\n      text-overflow: ellipsis;\n\n      &:focus,\n      &:hover {\n        background-color: #8a9ba826;\n        cursor: pointer;\n      }\n    }\n\n    .btn-group {\n      margin-left: auto;\n\n      .btn {\n        border-radius: 0;\n      }\n    }\n\n    .delete-button {\n      color: $danger;\n    }\n  }\n}\n\n.sidebar-saved-filter-list-container .toolbar {\n  align-items: center;\n  display: flex;\n  justify-content: space-between;\n  padding: 0.5rem;\n\n  .btn {\n    font-weight: bold;\n  }\n}\n\n.sidebar-saved-filter-list-container {\n  .label-group {\n    align-items: center;\n    display: flex;\n    overflow: hidden;\n  }\n\n  .saved-filter-item {\n    cursor: pointer;\n    margin-bottom: 0.25rem;\n    min-height: 2em;\n\n    a {\n      align-items: center;\n      display: flex;\n      justify-content: space-between;\n      min-height: 2em;\n      outline: none;\n\n      &:hover,\n      &:focus-visible {\n        background-color: rgba(138, 155, 168, 0.15);\n      }\n\n      .selected-object-label,\n      .excluded-object-label {\n        font-size: 16px;\n      }\n    }\n\n    .label-group {\n      align-items: center;\n      display: flex;\n      overflow: hidden;\n\n      .selected {\n        font-weight: bold;\n      }\n\n      .fa-icon {\n        flex-shrink: 0;\n      }\n    }\n\n    .delete-button {\n      color: $danger;\n    }\n  }\n\n  .saved-filter-search-input {\n    margin-bottom: 0.5rem;\n  }\n}\n\n.save-filter-dialog {\n  .existing-filter-list {\n    max-height: 300px;\n    overflow-y: auto;\n  }\n}\n\n.save-filter-button {\n  color: $text-color;\n}\n\n.saved-filter-overwrite-warning {\n  color: $danger;\n  font-weight: bold;\n}\n\n.edit-filter-dialog .rating-stars {\n  font-size: 1.3em;\n  margin-left: 0.25em;\n}\n\n.rating-filter .and-divider {\n  margin-left: 0.5em;\n}\n\n.edit-filter-dialog {\n  .modal-header {\n    align-items: center;\n    padding: 0.5rem 1rem;\n\n    .search-input {\n      width: auto;\n    }\n  }\n\n  .modal-body {\n    max-height: min(550px, calc(100vh - 12rem));\n    padding-left: 0;\n    padding-right: 0;\n  }\n\n  .modal-footer {\n    justify-content: space-between;\n\n    > div > :not(:first-child) {\n      margin-left: 0.25rem;\n    }\n  }\n\n  .search-term-row {\n    align-items: center;\n    display: flex;\n    gap: 0.5rem;\n    justify-content: space-between;\n    margin-bottom: 0.5rem;\n    margin-left: 1.5rem;\n    margin-right: 1rem;\n\n    .search-term-input {\n      flex-basis: 75%;\n    }\n\n    @include media-breakpoint-down(xs) {\n      flex-wrap: wrap;\n\n      > span {\n        width: 100%;\n      }\n\n      .search-term-input {\n        flex-basis: 100%;\n      }\n    }\n  }\n\n  .filter-tags {\n    border-top: 1px solid rgb(16 22 26 / 40%);\n    padding: 1rem 1rem 0 1rem;\n  }\n\n  .criterion-list {\n    flex-direction: column;\n    flex-wrap: nowrap;\n\n    .pinned-criterion-divider {\n      padding-bottom: 2.5rem;\n    }\n\n    .card {\n      border: 1px solid rgb(16 22 26 / 40%);\n      box-shadow: none;\n      margin: 0 0 -1px;\n      padding: 0;\n\n      .collapse-icon {\n        margin-left: 0;\n      }\n\n      .filter-item-header {\n        background-color: $card-cap-bg;\n        border: none;\n        border-bottom: 1px solid rgba(0, 0, 0, 0.125);\n        color: inherit;\n        cursor: pointer;\n        display: flex;\n        margin-bottom: 0;\n        padding: 0.75rem 1.25rem;\n\n        &:focus {\n          border-color: $primary;\n          border-radius: calc(0.375rem - 1px);\n          box-shadow: inset 0 0 0 0.1rem rgba(19, 124, 189);\n          outline: 0;\n        }\n      }\n    }\n\n    .filter-item-header .btn {\n      border: 0;\n      padding-bottom: 0;\n      padding-top: 0;\n    }\n\n    .pin-criterion-button {\n      color: $text_color;\n\n      &:hover svg {\n        transform: rotate(0);\n      }\n    }\n\n    .remove-criterion-button {\n      color: $danger;\n    }\n  }\n\n  .edit-filter-right {\n    display: flex;\n    flex-direction: column;\n    justify-content: space-between;\n    padding-left: 1rem;\n    padding-right: 1rem;\n    width: 100%;\n  }\n}\n\n.modifier-options {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: center;\n}\n\n.modifier-options .modifier-option {\n  background-color: $secondary;\n  border: none;\n  border-radius: 10rem;\n  cursor: pointer;\n  display: inline-block;\n  font-size: 100%;\n  font-weight: 700;\n  line-height: 1;\n  margin-bottom: 0.5rem;\n  margin-right: 0.25rem;\n  padding: 0.25em 0.6em;\n  text-align: center;\n  transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,\n    border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n  vertical-align: baseline;\n  white-space: nowrap;\n\n  &.selected {\n    background-color: $primary;\n  }\n}\n\n.filter-tags {\n  display: flex;\n  justify-content: center;\n  margin-bottom: 0.5rem;\n\n  .more-tags {\n    background-color: transparent;\n    color: #fff;\n  }\n\n  .clear-all-button {\n    color: $text-color;\n    // to match filter pills\n    line-height: 16px;\n    padding: 0;\n  }\n\n  .tag-item.unsupported {\n    background-color: $warning;\n  }\n}\n\n.filter-button {\n  position: relative;\n\n  .fa-icon {\n    margin: 0;\n  }\n\n  .badge {\n    font-size: 60%;\n    position: absolute;\n    right: 0;\n\n    // button group has a z-index of 1\n    z-index: 2;\n  }\n}\n\n.filter-visible-button {\n  padding-left: 0.3rem;\n  padding-right: 0.3rem;\n\n  &:focus:not(.active):not(:hover) {\n    background: none;\n  }\n\n  &:focus,\n  &.active:focus {\n    box-shadow: none;\n  }\n}\n\n.selectable-filter ul,\nul.selectable-list {\n  list-style-type: none;\n  margin-top: 0.5rem;\n  max-height: 300px;\n  overflow-y: auto;\n  // to prevent unnecessary vertical scrollbar\n  padding-bottom: 0.15rem;\n  padding-inline-start: 0;\n\n  .modifier-object {\n    font-style: italic;\n\n    .selected-object-label,\n    .unselected-object-label {\n      opacity: 0.6;\n    }\n  }\n\n  .unselected-object {\n    opacity: 0.8;\n  }\n\n  .selected-object,\n  .excluded-object,\n  .unselected-object {\n    cursor: pointer;\n    margin-bottom: 0.25rem;\n    min-height: 2em;\n\n    a {\n      align-items: center;\n      display: flex;\n      justify-content: space-between;\n      min-height: 2em;\n      outline: none;\n\n      &:hover,\n      &:focus-visible {\n        background-color: rgba(138, 155, 168, 0.15);\n      }\n\n      .selected-object-label,\n      .excluded-object-label {\n        font-size: 16px;\n      }\n    }\n\n    .include-button {\n      color: $success;\n    }\n\n    .exclude-icon {\n      color: $danger;\n    }\n\n    .exclude-button {\n      align-items: center;\n      display: flex;\n      margin-left: 0.25rem;\n      padding-left: 0.25rem;\n      padding-right: 0.25rem;\n\n      .exclude-button-text {\n        color: $danger;\n        display: none;\n        font-size: 12px;\n        font-weight: 600;\n      }\n\n      &:hover {\n        background-color: inherit;\n      }\n\n      &:hover .exclude-button-text,\n      &:focus .exclude-button-text {\n        display: inline;\n      }\n    }\n\n    .object-count {\n      color: $text-muted;\n      font-size: 12px;\n    }\n  }\n\n  .selected-object:hover,\n  .selected-object a:focus-visible,\n  .excluded-object:hover,\n  .excluded-object a:focus-visible {\n    .include-button,\n    .exclude-icon {\n      color: #fff;\n    }\n  }\n}\n\n// used to align list text without icons to those that do\n.sidebar .no-icon-margin {\n  // icon width is 17.5px + 5.6px margin each side\n  margin-left: 28.7px;\n}\n\n.sidebar-list-filter .clearable-input-group {\n  margin-bottom: 0.5rem;\n}\n\n.sidebar-list-filter ul,\n.folder-filter ul {\n  list-style-type: none;\n  margin-bottom: 0.25rem;\n  max-height: 300px;\n  overflow-y: auto;\n  // to prevent unnecessary vertical scrollbar\n  padding-bottom: 0.15rem;\n  padding-inline-start: 0;\n\n  .modifier-object {\n    font-style: italic;\n\n    .selected-object-label,\n    .unselected-object-label {\n      opacity: 0.6;\n    }\n  }\n\n  .unselected-object {\n    opacity: 0.8;\n  }\n\n  .selected-object,\n  .excluded-object,\n  .unselected-object {\n    cursor: pointer;\n    margin-bottom: 0.25rem;\n    min-height: 2em;\n\n    a {\n      align-items: center;\n      display: flex;\n      justify-content: space-between;\n      min-height: 2em;\n      outline: none;\n\n      &:hover,\n      &:focus-visible {\n        background-color: rgba(138, 155, 168, 0.15);\n      }\n\n      .selected-object-label,\n      .excluded-object-label {\n        font-size: 16px;\n      }\n    }\n\n    .include-button {\n      color: $success;\n\n      &.single-value {\n        visibility: hidden;\n      }\n    }\n\n    .exclude-icon {\n      color: $danger;\n    }\n\n    .exclude-button {\n      align-items: center;\n      display: flex;\n      margin-left: 0.25rem;\n      padding-left: 0.25rem;\n      padding-right: 0.25rem;\n\n      .exclude-button-text {\n        color: $danger;\n        display: none;\n        font-size: 12px;\n        font-weight: 600;\n      }\n\n      &:hover {\n        background-color: transparent;\n      }\n\n      &:hover .exclude-button-text,\n      &:focus .exclude-button-text {\n        display: inline;\n      }\n    }\n\n    .object-count {\n      color: $text-muted;\n      font-size: 12px;\n    }\n  }\n\n  .selected-object:hover,\n  .selected-object a:focus-visible,\n  .excluded-object:hover,\n  .excluded-object a:focus-visible {\n    .include-button,\n    .exclude-icon {\n      color: #fff;\n    }\n  }\n\n  .selected-object,\n  .unselected-object {\n    .label-group {\n      align-items: center;\n      display: flex;\n      overflow: hidden;\n    }\n  }\n}\n\n.sidebar-list-filter > .extra {\n  padding-top: 0.25rem;\n}\n\n.sidebar-list-filter .extra {\n  min-height: 2em;\n}\n\n.duplicate-sub-options {\n  margin-left: 2rem;\n  padding-left: 0.5rem;\n\n  .duplicate-sub-option {\n    align-items: center;\n    cursor: pointer;\n    display: flex;\n    height: 2em;\n    opacity: 0.8;\n    padding-left: 0.5rem;\n\n    &:hover {\n      background-color: rgba(138, 155, 168, 0.15);\n    }\n  }\n}\n\n.sidebar-folder-filter ul,\n.folder-filter ul,\nul.selectable-list {\n  margin-top: 0.25rem;\n\n  .btn.expand-collapse {\n    font-size: 0.8rem;\n    padding-left: 0;\n    padding-right: 0.25rem;\n    text-align: left;\n  }\n\n  .empty .btn.expand-collapse {\n    visibility: hidden;\n  }\n\n  .selected-object a .selected-object-label {\n    font-size: 0.8em;\n    overflow-wrap: break-word;\n    white-space: normal;\n  }\n}\n\n.tilted {\n  transform: rotate(45deg);\n}\n\n.table-list {\n  display: grid;\n  grid-template-columns: repeat(1, minmax(0, 1fr));\n  margin-bottom: 1rem;\n  margin-left: 0;\n  margin-right: 0;\n  max-height: 78dvh;\n  min-width: min-content;\n  overflow-x: auto;\n  position: relative;\n\n  table {\n    margin: 0;\n\n    thead {\n      background-color: #202b33;\n      position: sticky;\n      top: 0;\n      z-index: 1;\n    }\n\n    td:first-child {\n      padding: 0;\n    }\n\n    label {\n      margin: 0;\n      padding: 0.5rem;\n    }\n  }\n\n  .column-select {\n    margin: 0;\n    padding: 7px;\n  }\n\n  .select-col {\n    width: 20px;\n  }\n\n  .comma-list {\n    list-style: none;\n    margin: 0;\n    padding: 4px 2px;\n\n    li {\n      display: inline;\n    }\n\n    li::after {\n      content: \", \";\n    }\n\n    li:last-child::after {\n      content: \"\";\n    }\n  }\n\n  .newline-list {\n    list-style: none;\n    margin: 0;\n    padding: 4px 2px;\n\n    li {\n      display: inline;\n      white-space: pre-wrap;\n    }\n\n    li::after {\n      content: \"\\A\";\n    }\n\n    li:last-child::after {\n      content: \"\";\n    }\n  }\n\n  .newline-list.overflowable,\n  .comma-list.overflowable {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  .comma-list.overflowable {\n    width: 190px;\n  }\n\n  .newline-list.overflowable {\n    -webkit-line-clamp: 1;\n    width: 700px;\n  }\n\n  .newline-list.overflowable:hover,\n  .comma-list.overflowable:hover {\n    background: #28343c;\n    border: 1px solid #414c53;\n    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.28);\n    display: block;\n    height: auto;\n    margin-left: -0.4rem;\n    margin-top: -0.9rem;\n    overflow: hidden;\n    padding: 0.1rem 0.5rem;\n    position: absolute;\n    top: auto;\n    white-space: normal;\n    width: max-content;\n    z-index: 100;\n  }\n\n  .comma-list.overflowable:hover {\n    max-width: 40rem;\n  }\n\n  .newline-list.overflowable li .ellips-data:hover,\n  .comma-list.overflowable li .ellips-data:hover {\n    max-width: fit-content;\n  }\n\n  td {\n    color: hsla(0, 0%, 100%, 0.6);\n    font-weight: 500;\n    position: relative;\n    text-align: left;\n    white-space: nowrap;\n\n    .ellips-data {\n      display: block;\n      max-width: 190px;\n      overflow: hidden;\n      text-overflow: ellipsis;\n      white-space: nowrap;\n    }\n\n    .star-rating-number {\n      display: none;\n    }\n\n    a {\n      font-weight: 600;\n      white-space: nowrap;\n    }\n  }\n\n  td.select-col {\n    text-align: center;\n  }\n\n  .table thead th {\n    border: none;\n    white-space: nowrap;\n  }\n\n  tr {\n    border-collapse: collapse;\n  }\n\n  .date-head {\n    width: 97px;\n  }\n}\n\n.table-list tbody tr:hover {\n  background-color: #2d3942;\n}\n\n.table-list a {\n  color: $text-color;\n}\n\n.table-list .table-striped td,\n.table-list .table-striped th {\n  font-size: 1rem;\n  vertical-align: middle;\n\n  h5,\n  h6 {\n    font-size: 1rem;\n  }\n\n  &:first-child {\n    border-left: none;\n  }\n}\n\n.filtered-list-toolbar {\n  align-items: center;\n  background-color: $body-bg;\n  gap: 0.5rem;\n  justify-content: center;\n\n  // offset the main padding\n  margin-top: -0.5rem;\n  padding-bottom: 0.5rem;\n  padding-top: 0.5rem;\n\n  & > .btn-group {\n    flex-wrap: wrap;\n    justify-content: center;\n    row-gap: 0.5rem;\n\n    &:first-child {\n      justify-content: flex-start;\n    }\n\n    &:last-child {\n      justify-content: flex-end;\n    }\n  }\n\n  // set the width of the zoom-slider-container to prevent buttons moving when\n  // the slider appears/disappears\n  .zoom-slider-container {\n    min-width: 60px;\n  }\n}\n\n.custom-field-filter {\n  align-items: center;\n  display: flex;\n\n  > div:first-child {\n    flex-grow: 1;\n  }\n\n  .custom-field-filter-buttons {\n    display: flex;\n    flex-direction: column;\n    margin-left: 0.25rem;\n\n    .btn {\n      border-radius: 0.2rem;\n      font-size: 0.875rem;\n      line-height: 1.5;\n      padding: 0.25rem 0.5rem;\n\n      &:first-child {\n        margin-bottom: 0.25rem;\n      }\n    }\n  }\n}\n\n.item-list-container .sidebar-pane {\n  width: 100%;\n}\n\n.sidebar {\n  .sidebar-search-container {\n    display: flex;\n    margin-bottom: 0.5rem;\n  }\n\n  .search-term-input {\n    flex-grow: 1;\n    margin-right: 0;\n\n    .clearable-text-field {\n      height: 100%;\n    }\n  }\n\n  .edit-filter-button {\n    width: 100%;\n  }\n\n  .sidebar-footer {\n    background-color: $body-bg;\n    bottom: 0;\n    display: none;\n    padding: 0.5rem;\n    position: sticky;\n\n    @include media-breakpoint-down(xs) {\n      display: flex;\n      justify-content: center;\n    }\n  }\n}\n\n@include media-breakpoint-down(xs) {\n  .sidebar .sidebar-search-container {\n    margin-top: 0.25rem;\n  }\n}\n\n.pagination-footer-container {\n  background-color: transparent;\n  bottom: $navbar-height;\n  position: sticky;\n  z-index: 10;\n\n  @include media-breakpoint-up(sm) {\n    bottom: 0;\n  }\n}\n\n.pagination-footer {\n  margin: auto;\n  padding: 0.5rem 1rem 0.75rem;\n\n  width: fit-content;\n\n  .pagination.btn-group {\n    box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%);\n  }\n\n  .pagination {\n    margin-bottom: 0;\n\n    .btn:disabled {\n      color: #888;\n      opacity: 1;\n    }\n  }\n}\n\n// on very large screens, offset the margins to center the pagination controls\n@media (min-width: 1800px) {\n  .sidebar-pane:not(.hide-sidebar) {\n    .filter-tags,\n    .pagination-index-container,\n    .pagination-footer-container {\n      margin-left: -$sidebar-width;\n      margin-right: 0;\n    }\n  }\n}\n\n// hide sidebar Edit Filter button on larger screens\n@include media-breakpoint-up(md) {\n  .sidebar .edit-filter-button {\n    display: none;\n  }\n}\n\n// hide the search input field if the sidebar is open on smaller screens\n@media (min-width: 576px) and (max-width: 1400px) {\n  .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .search-term-input {\n    display: none;\n  }\n}\n\n#more-criteria-popover {\n  box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%);\n  max-width: 400px;\n  padding: 0.25rem;\n}\n\n// Duration slider styles\n.duration-slider,\n.age-slider-container {\n  padding: 0.5rem 0 1rem;\n  width: 100%;\n}\n\n.duration-label-input,\n.age-label-input {\n  background: transparent;\n  border: 1px solid transparent;\n  border-radius: 0.25rem;\n  color: $text-color;\n  font-size: 0.875rem;\n  font-weight: 500;\n  padding: 0.125rem 0.25rem;\n  width: 4rem;\n\n  &:hover {\n    border-color: $secondary;\n  }\n\n  &:focus {\n    border-color: $primary;\n    outline: none;\n  }\n}\n\n.duration-preset {\n  cursor: pointer;\n}\n\n.selected-items-info {\n  align-items: center;\n  border: 1px solid $secondary;\n  display: flex;\n  gap: 0.25rem;\n  justify-content: flex-end;\n}\n\n.scene-list-toolbar .selected-items-info,\n.gallery-list-toolbar .selected-items-info {\n  justify-content: flex-start;\n}\n\n// modify margins for toolbar within sidebar pane to accommodate toggle button\n.sidebar-pane .filtered-list-toolbar {\n  margin-left: 40px;\n  margin-right: 40px;\n}\n\n// on very large screens, offset the margins to center the toolbar\n@media (min-width: 1800px) {\n  .sidebar-pane:not(.hide-sidebar) {\n    .filtered-list-toolbar {\n      margin-left: -$sidebar-width;\n      margin-right: 0;\n    }\n  }\n}\n\n.item-list-container .filtered-list-toolbar.has-selection {\n  border-radius: 0.5rem;\n  margin-left: auto;\n  margin-right: auto;\n  padding-left: 0.5rem;\n  padding-right: 0.5rem;\n  position: sticky;\n  top: $navbar-height;\n  width: fit-content;\n  z-index: 10;\n\n  @include media-breakpoint-down(xs) {\n    top: 0;\n  }\n}\n\n.detail-body .filtered-list-toolbar.has-selection {\n  top: calc($sticky-detail-header-height + $navbar-height);\n\n  @include media-breakpoint-down(xs) {\n    top: 0;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/List/util.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport Mousetrap from \"mousetrap\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { useHistory, useLocation } from \"react-router-dom\";\nimport { isEqual, isFunction } from \"lodash-es\";\nimport { QueryResult } from \"@apollo/client\";\nimport { IHasID } from \"src/utils/data\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { View } from \"./views\";\nimport { usePrevious } from \"src/hooks/state\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { Criterion } from \"src/models/list-filter/criteria/criterion\";\n\nfunction locationEquals(\n  loc1: ReturnType<typeof useLocation> | undefined,\n  loc2: ReturnType<typeof useLocation>\n) {\n  return loc1 && loc1.pathname === loc2.pathname && loc1.search === loc2.search;\n}\n\nexport function useFilterURL(\n  filter: ListFilterModel,\n  setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>,\n  options?: {\n    defaultFilter?: ListFilterModel;\n    active?: boolean;\n  }\n) {\n  const { defaultFilter, active = true } = options ?? {};\n\n  const history = useHistory();\n  const location = useLocation();\n  const prevLocation = usePrevious(location);\n\n  // when the filter changes, update the URL\n  const updateFilter = useCallback(\n    (\n      value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel)\n    ) => {\n      const newFilter = isFunction(value) ? value(filter) : value;\n\n      if (active) {\n        const newParams = newFilter.makeQueryParameters();\n        history.replace({ ...history.location, search: newParams });\n      } else {\n        // set the filter without updating the URL\n        setFilter(newFilter);\n      }\n    },\n    [history, active, setFilter, filter]\n  );\n\n  // This hook runs on every page location change (ie navigation),\n  // and updates the filter accordingly.\n  useEffect(() => {\n    // don't apply if active is false\n    // also don't apply if location is unchanged\n    if (!active || locationEquals(prevLocation, location)) return;\n\n    // re-init to load default filter on empty new query params\n    if (!location.search) {\n      if (defaultFilter) updateFilter(defaultFilter.clone());\n      return;\n    }\n\n    // the query has changed, update filter if necessary\n    setFilter((prevFilter) => {\n      let newFilter = prevFilter.empty();\n      newFilter.configureFromQueryString(location.search);\n      if (!isEqual(newFilter, prevFilter)) {\n        // filter may have changed if random seed was set, update the URL\n        const newParams = newFilter.makeQueryParameters();\n        if (newParams !== location.search) {\n          history.replace({ ...history.location, search: newParams });\n        }\n\n        return newFilter;\n      } else {\n        return prevFilter;\n      }\n    });\n  }, [\n    active,\n    prevLocation,\n    location,\n    defaultFilter,\n    setFilter,\n    updateFilter,\n    history,\n  ]);\n\n  return { setFilter: updateFilter };\n}\n\nexport function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) {\n  const { configuration: config } = useConfigurationContext();\n\n  const defaultFilter = useMemo(() => {\n    if (view && config?.ui.defaultFilters?.[view]) {\n      const savedFilter = config.ui.defaultFilters[view]!;\n      const newFilter = emptyFilter.clone();\n\n      newFilter.currentPage = 1;\n      try {\n        newFilter.configureFromSavedFilter(savedFilter);\n      } catch (err) {\n        console.log(err);\n        // ignore\n      }\n      // #1507 - reset random seed when loaded\n      newFilter.randomSeed = -1;\n      return newFilter;\n    }\n  }, [view, config?.ui.defaultFilters, emptyFilter]);\n\n  const retFilter = defaultFilter ?? emptyFilter;\n\n  return { defaultFilter: retFilter };\n}\n\nfunction useEmptyFilter(props: {\n  filterMode: GQL.FilterMode;\n  defaultSort?: string;\n  config?: GQL.ConfigDataFragment;\n}) {\n  const { filterMode, defaultSort, config } = props;\n\n  const emptyFilter = useMemo(\n    () =>\n      new ListFilterModel(filterMode, config, {\n        defaultSortBy: defaultSort,\n      }),\n    [config, filterMode, defaultSort]\n  );\n\n  return emptyFilter;\n}\n\nexport interface IFilterStateHook {\n  filterMode: GQL.FilterMode;\n  defaultFilter?: ListFilterModel;\n  defaultSort?: string;\n  view?: View;\n  useURL?: boolean;\n}\n\nexport function useFilterState(\n  props: IFilterStateHook & {\n    config?: GQL.ConfigDataFragment;\n  }\n) {\n  const {\n    filterMode,\n    defaultSort,\n    config,\n    view,\n    useURL,\n    defaultFilter: propDefaultFilter,\n  } = props;\n\n  const [filter, setFilterState] = useState<ListFilterModel>(\n    () =>\n      new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort })\n  );\n\n  const emptyFilter = useEmptyFilter({ filterMode, defaultSort, config });\n\n  const { defaultFilter: defaultFilterFromConfig } = useDefaultFilter(\n    emptyFilter,\n    view\n  );\n\n  const { setFilter } = useFilterURL(filter, setFilterState, {\n    defaultFilter: propDefaultFilter ?? defaultFilterFromConfig,\n    active: useURL,\n  });\n\n  return { filter, setFilter };\n}\n\nexport function useFilterOperations(props: {\n  filter: ListFilterModel;\n  setFilter: (\n    value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel)\n  ) => void;\n}) {\n  const { setFilter } = props;\n\n  const setPage = useCallback(\n    (p: number) => {\n      setFilter((cv) => cv.changePage(p));\n    },\n    [setFilter]\n  );\n\n  const setDisplayMode = useCallback(\n    (displayMode: DisplayMode) => {\n      setFilter((cv) => cv.setDisplayMode(displayMode));\n    },\n    [setFilter]\n  );\n\n  const setZoom = useCallback(\n    (newZoomIndex: number) => {\n      setFilter((cv) => cv.setZoom(newZoomIndex));\n    },\n    [setFilter]\n  );\n\n  const removeCriterion = useCallback(\n    (removedCriterion: Criterion) => {\n      setFilter((cv) =>\n        cv.removeCriterion(removedCriterion.criterionOption.type)\n      );\n    },\n    [setFilter]\n  );\n\n  const clearAllCriteria = useCallback(\n    (includeSearchTerm = false) => {\n      setFilter((cv) => cv.clearCriteria(includeSearchTerm));\n    },\n    [setFilter]\n  );\n\n  return {\n    setPage,\n    setDisplayMode,\n    setZoom,\n    removeCriterion,\n    clearAllCriteria,\n  };\n}\n\nexport function useListKeyboardShortcuts(props: {\n  currentPage?: number;\n  onChangePage?: (page: number) => void;\n  showEditFilter?: () => void;\n  pages?: number;\n  onSelectAll?: () => void;\n  onSelectNone?: () => void;\n  onInvertSelection?: () => void;\n}) {\n  const {\n    currentPage,\n    onChangePage,\n    showEditFilter,\n    pages = 0,\n    onSelectAll,\n    onSelectNone,\n    onInvertSelection,\n  } = props;\n\n  // set up hotkeys\n  useEffect(() => {\n    if (showEditFilter) {\n      Mousetrap.bind(\"f\", (e) => {\n        showEditFilter();\n        // prevent default behavior of typing f in a text field\n        // otherwise the filter dialog closes, the query field is focused and\n        // f is typed.\n        e.preventDefault();\n      });\n\n      return () => {\n        Mousetrap.unbind(\"f\");\n      };\n    }\n  }, [showEditFilter]);\n\n  useEffect(() => {\n    if (!currentPage || !changePage || !pages) return;\n\n    function changePage(page: number) {\n      if (!currentPage || !onChangePage || !pages) return;\n      if (page >= 1 && page <= pages) {\n        onChangePage(page);\n      }\n    }\n\n    Mousetrap.bind(\"right\", () => {\n      changePage(currentPage + 1);\n    });\n    Mousetrap.bind(\"left\", () => {\n      changePage(currentPage - 1);\n    });\n    Mousetrap.bind(\"shift+right\", () => {\n      changePage(Math.min(pages, currentPage + 10));\n    });\n    Mousetrap.bind(\"shift+left\", () => {\n      changePage(Math.max(1, currentPage - 10));\n    });\n    Mousetrap.bind(\"ctrl+end\", () => {\n      changePage(pages);\n    });\n    Mousetrap.bind(\"ctrl+home\", () => {\n      changePage(1);\n    });\n\n    return () => {\n      Mousetrap.unbind(\"right\");\n      Mousetrap.unbind(\"left\");\n      Mousetrap.unbind(\"shift+right\");\n      Mousetrap.unbind(\"shift+left\");\n      Mousetrap.unbind(\"ctrl+end\");\n      Mousetrap.unbind(\"ctrl+home\");\n    };\n  }, [currentPage, onChangePage, pages]);\n\n  useEffect(() => {\n    Mousetrap.bind(\"s a\", () => onSelectAll?.());\n    Mousetrap.bind(\"s n\", () => onSelectNone?.());\n    Mousetrap.bind(\"s i\", () => onInvertSelection?.());\n\n    return () => {\n      Mousetrap.unbind(\"s a\");\n      Mousetrap.unbind(\"s n\");\n      Mousetrap.unbind(\"s i\");\n    };\n  }, [onSelectAll, onSelectNone, onInvertSelection]);\n}\n\nexport function useListSelect<T extends IHasID = IHasID>(items: T[]) {\n  const [itemsSelected, setItemsSelected] = useState<T[]>([]);\n  const [lastClickedId, setLastClickedId] = useState<string>();\n\n  // TODO - this doesn't get updated when items changes\n  const selectedIds = useMemo(() => {\n    const newSelectedIds = new Set<string>();\n    itemsSelected.forEach((item) => {\n      newSelectedIds.add(item.id);\n    });\n\n    return newSelectedIds;\n  }, [itemsSelected]);\n\n  // const prevItems = usePrevious(items);\n\n  // #5341 - HACK/TODO: this is a regression of previous behaviour. I don't like the idea\n  // of keeping selected items that are no longer in the list, since its not\n  // clear to the user that the item is still selected, but there is now an expectation of\n  // this behaviour.\n  // useEffect(() => {\n  //   if (prevItems === items) {\n  //     return;\n  //   }\n\n  //   // filter out any selectedIds that are no longer in the list\n  //   const newSelectedIds = new Set<string>();\n\n  //   selectedIds.forEach((id) => {\n  //     if (items.some((item) => item.id === id)) {\n  //       newSelectedIds.add(id);\n  //     }\n  //   });\n\n  //   setSelectedIds(newSelectedIds);\n  // }, [prevItems, items, selectedIds]);\n\n  function singleSelect(id: string, selected: boolean) {\n    setLastClickedId(id);\n\n    setItemsSelected((prevSelected) => {\n      if (selected) {\n        // prevent duplicates\n        if (prevSelected.some((v) => v.id === id)) {\n          return prevSelected;\n        }\n\n        const item = items.find((i) => i.id === id);\n        if (item) {\n          return [...prevSelected, item];\n        }\n        return prevSelected;\n      } else {\n        return prevSelected.filter((item) => item.id !== id);\n      }\n    });\n  }\n\n  function selectRange(startIndex: number, endIndex: number) {\n    let start = startIndex;\n    let end = endIndex;\n    if (start > end) {\n      const tmp = start;\n      start = end;\n      end = tmp;\n    }\n\n    const subset = items.slice(start, end + 1);\n\n    // prevent duplicates\n    const toAdd = subset.filter((item) => !selectedIds.has(item.id));\n\n    const newSelected = itemsSelected.concat(toAdd);\n    setItemsSelected(newSelected);\n  }\n\n  function multiSelect(id: string) {\n    let startIndex = 0;\n    let thisIndex = -1;\n\n    if (lastClickedId) {\n      startIndex = items.findIndex((item) => {\n        return item.id === lastClickedId;\n      });\n    }\n\n    thisIndex = items.findIndex((item) => {\n      return item.id === id;\n    });\n\n    selectRange(startIndex, thisIndex);\n  }\n\n  function onSelectChange(id: string, selected: boolean, shiftKey: boolean) {\n    if (shiftKey) {\n      multiSelect(id);\n    } else {\n      singleSelect(id, selected);\n    }\n  }\n\n  function onSelectAll() {\n    // #5341 - HACK/TODO: maintaining legacy behaviour of replacing selected items with\n    // all items on the current page. To be consistent with the existing behaviour, it\n    // should probably _add_ all items on the current page to the selected items.\n    setItemsSelected([...items]);\n    setLastClickedId(undefined);\n  }\n\n  function onSelectNone() {\n    setItemsSelected([]);\n    setLastClickedId(undefined);\n  }\n\n  function onInvertSelection() {\n    setItemsSelected((prevSelected) => {\n      const selectedSet = new Set(prevSelected.map((item) => item.id));\n      return items.filter((item) => !selectedSet.has(item.id));\n    });\n    setLastClickedId(undefined);\n  }\n\n  // TODO - this is for backwards compatibility\n  const getSelected = useCallback(() => itemsSelected, [itemsSelected]);\n\n  // convenience state\n  const hasSelection = itemsSelected.length > 0;\n\n  return {\n    selectedItems: itemsSelected,\n    selectedIds,\n    getSelected,\n    onSelectChange,\n    onSelectAll,\n    onSelectNone,\n    onInvertSelection,\n    hasSelection,\n  };\n}\n\nexport type IListSelect<T extends IHasID = IHasID> = ReturnType<\n  typeof useListSelect<T>\n>;\n\n// returns true if the filter has changed in a way that impacts the total count\nfunction totalCountImpacted(\n  oldFilter: ListFilterModel,\n  newFilter: ListFilterModel\n) {\n  return (\n    oldFilter.criteria.length !== newFilter.criteria.length ||\n    oldFilter.criteria.some((c) => {\n      const newCriterion = newFilter.criteria.find(\n        (nc) => nc.getId() === c.getId()\n      );\n      return !newCriterion || !isEqual(c, newCriterion);\n    })\n  );\n}\n\n// this hook caches a query result and count, and only updates it when the filter changes\n// in a way that would impact the result count\n// it is used to prevent the result count/pagination from flickering when changing pages or sorting\nexport function useCachedQueryResult<T extends QueryResult>(\n  filter: ListFilterModel,\n  result: T\n) {\n  const [cachedResult, setCachedResult] = useState(result);\n  const lastFilterRef = useRef(filter);\n\n  // if we are only changing the page or sort, don't update the result count\n  useEffect(() => {\n    if (!result.loading) {\n      setCachedResult(result);\n    } else {\n      if (totalCountImpacted(lastFilterRef.current, filter)) {\n        setCachedResult(result);\n      }\n    }\n\n    lastFilterRef.current = filter;\n  }, [filter, result]);\n\n  return cachedResult;\n}\n\nexport interface IQueryResultHook<\n  T extends QueryResult,\n  E extends IHasID = IHasID,\n  M = unknown\n> {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  useResult: (filter: ListFilterModel) => T;\n  useMetadataInfo?: (filter: ListFilterModel) => M;\n  getCount: (data: T) => number;\n  getItems: (data: T) => E[];\n}\n\nexport function useQueryResult<\n  T extends QueryResult,\n  E extends IHasID = IHasID,\n  M = unknown\n>(\n  props: IQueryResultHook<T, E, M> & {\n    filter: ListFilterModel;\n  }\n) {\n  const { filter, filterHook, useResult, useMetadataInfo, getItems, getCount } =\n    props;\n\n  const effectiveFilter = useMemo(() => {\n    if (filterHook) {\n      return filterHook(filter.clone());\n    }\n    return filter;\n  }, [filter, filterHook]);\n\n  // metadata filter is the effective filter with the sort, page size and page number removed\n  const metadataFilter = useMemo(\n    () => effectiveFilter.metadataInfo(),\n    [effectiveFilter]\n  );\n\n  const result = useResult(effectiveFilter);\n  const metadataInfo = useMetadataInfo?.(metadataFilter);\n\n  // use cached query result for pagination and metadata rendering\n  const cachedResult = useCachedQueryResult(effectiveFilter, result);\n\n  const items = useMemo(() => getItems(result), [getItems, result]);\n  const totalCount = useMemo(\n    () => getCount(cachedResult),\n    [getCount, cachedResult]\n  );\n\n  const pages = Math.ceil(totalCount / filter.itemsPerPage);\n\n  return {\n    effectiveFilter,\n    metadataInfo,\n    result,\n    cachedResult,\n    items,\n    totalCount,\n    pages,\n  };\n}\n\n// this hook collects the common logic when closing the edit/delete dialog\n// if applied is true, then the list should be refetched and selection cleared\nexport function useCloseEditDelete(props: {\n  onSelectNone: () => void;\n  closeModal: () => void;\n  result: QueryResult;\n}) {\n  const { onSelectNone, closeModal, result } = props;\n\n  const onCloseEditDelete = useCallback(\n    (applied?: boolean) => {\n      closeModal();\n      if (applied) {\n        onSelectNone();\n\n        // refetch\n        result.refetch();\n      }\n    },\n    [onSelectNone, closeModal, result]\n  );\n\n  return onCloseEditDelete;\n}\n\nexport function useScrollToTopOnPageChange(\n  currentPage: number,\n  loading: boolean\n) {\n  const prevPage = usePrevious(currentPage);\n\n  // scroll to the top of the page when the page changes\n  // only scroll to top if the page has changed and is not loading\n  useEffect(() => {\n    if (loading || currentPage === prevPage || prevPage === undefined) {\n      return;\n    }\n\n    // if the current page has a detail-header, then\n    // scroll up relative to that rather than 0, 0\n    const detailHeader = document.querySelector(\".detail-header\");\n    if (detailHeader) {\n      window.scrollTo(0, detailHeader.scrollHeight - 50);\n    } else {\n      window.scrollTo(0, 0);\n    }\n  }, [prevPage, currentPage, loading]);\n}\n\n// handle case where page is more than there are pages\nexport function useEnsureValidPage(\n  filter: ListFilterModel,\n  totalCount: number,\n  setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>\n) {\n  useEffect(() => {\n    const totalPages = Math.ceil(totalCount / filter.itemsPerPage);\n\n    if (totalPages > 0 && filter.currentPage > totalPages) {\n      setFilter((prevFilter) => prevFilter.changePage(totalPages));\n    }\n  }, [filter, totalCount, setFilter]);\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/List/views.ts",
    "content": "export enum View {\n  Galleries = \"galleries\",\n  Images = \"images\",\n  Scenes = \"scenes\",\n  Groups = \"groups\",\n  Performers = \"performers\",\n  Tags = \"tags\",\n  SceneMarkers = \"scene_markers\",\n  Studios = \"studios\",\n\n  TagMarkers = \"tag_markers\",\n  TagGalleries = \"tag_galleries\",\n  TagScenes = \"tag_scenes\",\n  TagImages = \"tag_images\",\n  TagPerformers = \"tag_performers\",\n  TagGroups = \"tag_groups\",\n\n  PerformerScenes = \"performer_scenes\",\n  PerformerGalleries = \"performer_galleries\",\n  PerformerImages = \"performer_images\",\n  PerformerGroups = \"performer_groups\",\n  PerformerAppearsWith = \"performer_appears_with\",\n\n  StudioGalleries = \"studio_galleries\",\n  StudioImages = \"studio_images\",\n\n  GalleryImages = \"gallery_images\",\n\n  StudioScenes = \"studio_scenes\",\n  StudioGroups = \"studio_groups\",\n  StudioPerformers = \"studio_performers\",\n  StudioChildren = \"studio_children\",\n\n  GroupScenes = \"group_scenes\",\n  GroupSubGroups = \"group_sub_groups\",\n  GroupPerformers = \"group_performers\",\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/MainNavbar.tsx",
    "content": "import React, {\n  useEffect,\n  useRef,\n  useState,\n  useCallback,\n  useMemo,\n} from \"react\";\nimport {\n  defineMessages,\n  FormattedMessage,\n  MessageDescriptor,\n  useIntl,\n} from \"react-intl\";\nimport { Nav, Navbar, Button } from \"react-bootstrap\";\nimport { IconDefinition } from \"@fortawesome/fontawesome-svg-core\";\nimport { LinkContainer } from \"react-router-bootstrap\";\nimport { Link, NavLink, useLocation, useHistory } from \"react-router-dom\";\nimport Mousetrap from \"mousetrap\";\n\nimport SessionUtils from \"src/utils/session\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { ManualStateContext } from \"./Help/context\";\nimport { SettingsButton } from \"./SettingsButton\";\nimport {\n  faBars,\n  faChartColumn,\n  faFilm,\n  faHeart,\n  faImage,\n  faImages,\n  faMapMarkerAlt,\n  faPlayCircle,\n  faQuestionCircle,\n  faSignOutAlt,\n  faTag,\n  faTimes,\n  faUser,\n  faVideo,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { baseURL } from \"src/core/createClient\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IMenuItem {\n  name: string;\n  message: MessageDescriptor;\n  href: string;\n  icon: IconDefinition;\n  hotkey: string;\n  userCreatable?: boolean;\n}\nconst messages = defineMessages({\n  scenes: {\n    id: \"scenes\",\n    defaultMessage: \"Scenes\",\n  },\n  images: {\n    id: \"images\",\n    defaultMessage: \"Images\",\n  },\n  groups: {\n    id: \"groups\",\n    defaultMessage: \"Groups\",\n  },\n  markers: {\n    id: \"markers\",\n    defaultMessage: \"Markers\",\n  },\n  performers: {\n    id: \"performers\",\n    defaultMessage: \"Performers\",\n  },\n  studios: {\n    id: \"studios\",\n    defaultMessage: \"Studios\",\n  },\n  tags: {\n    id: \"tags\",\n    defaultMessage: \"Tags\",\n  },\n  galleries: {\n    id: \"galleries\",\n    defaultMessage: \"Galleries\",\n  },\n  sceneTagger: {\n    id: \"sceneTagger\",\n    defaultMessage: \"Scene Tagger\",\n  },\n  donate: {\n    id: \"donate\",\n    defaultMessage: \"Donate\",\n  },\n  statistics: {\n    id: \"statistics\",\n    defaultMessage: \"Statistics\",\n  },\n});\n\nconst allMenuItems: IMenuItem[] = [\n  {\n    name: \"scenes\",\n    message: messages.scenes,\n    href: \"/scenes\",\n    icon: faPlayCircle,\n    hotkey: \"g s\",\n    userCreatable: true,\n  },\n  {\n    name: \"images\",\n    message: messages.images,\n    href: \"/images\",\n    icon: faImage,\n    hotkey: \"g i\",\n  },\n  {\n    name: \"groups\",\n    message: messages.groups,\n    href: \"/groups\",\n    icon: faFilm,\n    hotkey: \"g v\",\n    userCreatable: true,\n  },\n  {\n    name: \"markers\",\n    message: messages.markers,\n    href: \"/scenes/markers\",\n    icon: faMapMarkerAlt,\n    hotkey: \"g k\",\n  },\n  {\n    name: \"galleries\",\n    message: messages.galleries,\n    href: \"/galleries\",\n    icon: faImages,\n    hotkey: \"g l\",\n    userCreatable: true,\n  },\n  {\n    name: \"performers\",\n    message: messages.performers,\n    href: \"/performers\",\n    icon: faUser,\n    hotkey: \"g p\",\n    userCreatable: true,\n  },\n  {\n    name: \"studios\",\n    message: messages.studios,\n    href: \"/studios\",\n    icon: faVideo,\n    hotkey: \"g u\",\n    userCreatable: true,\n  },\n  {\n    name: \"tags\",\n    message: messages.tags,\n    href: \"/tags\",\n    icon: faTag,\n    hotkey: \"g t\",\n    userCreatable: true,\n  },\n];\n\nconst newPathsList = allMenuItems\n  .filter((item) => item.userCreatable)\n  .map((item) => item.href);\n\nconst MainNavbarMenuItems = PatchComponent(\n  \"MainNavBar.MenuItems\",\n  (props: React.PropsWithChildren<{}>) => {\n    return <Nav>{props.children}</Nav>;\n  }\n);\n\nconst MainNavbarUtilityItems = PatchComponent(\n  \"MainNavBar.UtilityItems\",\n  (props: React.PropsWithChildren<{}>) => {\n    return <>{props.children}</>;\n  }\n);\n\nexport const MainNavbar: React.FC = () => {\n  const history = useHistory();\n  const location = useLocation();\n  const { configuration } = useConfigurationContext();\n  const { openManual } = React.useContext(ManualStateContext);\n\n  const [expanded, setExpanded] = useState(false);\n\n  // Show all menu items by default, unless config says otherwise\n  const menuItems = useMemo(() => {\n    let cfgMenuItems = configuration?.interface.menuItems;\n    if (!cfgMenuItems) {\n      return allMenuItems;\n    }\n\n    // translate old movies menu item to groups\n    cfgMenuItems = cfgMenuItems.map((item) => {\n      if (item === \"movies\") {\n        return \"groups\";\n      }\n      return item;\n    });\n\n    return allMenuItems.filter((menuItem) =>\n      cfgMenuItems!.includes(menuItem.name)\n    );\n  }, [configuration]);\n\n  // react-bootstrap typing bug\n  const navbarRef = useRef<HTMLElement | null>(null);\n  const intl = useIntl();\n\n  const maybeCollapse = useCallback(\n    (event: Event) => {\n      if (\n        navbarRef.current &&\n        event.target instanceof Node &&\n        !navbarRef.current.contains(event.target)\n      ) {\n        setExpanded(false);\n      }\n    },\n    [setExpanded]\n  );\n\n  useEffect(() => {\n    if (expanded) {\n      document.addEventListener(\"click\", maybeCollapse);\n      document.addEventListener(\"touchstart\", maybeCollapse);\n    }\n    return () => {\n      document.removeEventListener(\"click\", maybeCollapse);\n      document.removeEventListener(\"touchstart\", maybeCollapse);\n    };\n  }, [expanded, maybeCollapse]);\n\n  const goto = useCallback(\n    (page: string) => {\n      history.push(page);\n      if (document.activeElement instanceof HTMLElement) {\n        document.activeElement.blur();\n      }\n    },\n    [history]\n  );\n\n  const pathname = location.pathname.replace(/\\/$/, \"\");\n  let newPath = newPathsList.includes(pathname) ? `${pathname}/new` : null;\n  if (newPath !== null) {\n    let queryParam = new URLSearchParams(location.search).get(\"q\");\n    if (queryParam) {\n      newPath += \"?q=\" + encodeURIComponent(queryParam);\n    }\n  }\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"?\", () => openManual());\n    Mousetrap.bind(\"g z\", () => goto(\"/settings\"));\n\n    menuItems.forEach((item) =>\n      Mousetrap.bind(item.hotkey, () => goto(item.href))\n    );\n\n    if (newPath) {\n      Mousetrap.bind(\"n\", () => history.push(String(newPath)));\n    }\n\n    return () => {\n      Mousetrap.unbind(\"?\");\n      Mousetrap.unbind(\"g z\");\n      menuItems.forEach((item) => Mousetrap.unbind(item.hotkey));\n\n      if (newPath) {\n        Mousetrap.unbind(\"n\");\n      }\n    };\n  });\n\n  function maybeRenderLogout() {\n    if (SessionUtils.isLoggedIn()) {\n      return (\n        <Button\n          className=\"minimal logout-button d-flex align-items-center\"\n          href={`${baseURL}logout`}\n          title={intl.formatMessage({ id: \"actions.logout\" })}\n        >\n          <Icon icon={faSignOutAlt} />\n        </Button>\n      );\n    }\n  }\n\n  const handleDismiss = useCallback(() => setExpanded(false), [setExpanded]);\n\n  function renderUtilityButtons() {\n    return (\n      <>\n        <Nav.Link\n          className=\"nav-utility\"\n          href=\"https://opencollective.com/stashapp\"\n          target=\"_blank\"\n          onClick={handleDismiss}\n        >\n          <Button\n            className=\"minimal donate\"\n            title={intl.formatMessage({ id: \"donate\" })}\n          >\n            <Icon icon={faHeart} />\n            <span className=\"d-none d-sm-inline\">\n              {intl.formatMessage(messages.donate)}\n            </span>\n          </Button>\n        </Nav.Link>\n        <NavLink\n          className=\"nav-utility\"\n          exact\n          to=\"/stats\"\n          onClick={handleDismiss}\n        >\n          <Button\n            className=\"minimal d-flex align-items-center h-100\"\n            title={intl.formatMessage({ id: \"statistics\" })}\n          >\n            <Icon icon={faChartColumn} />\n          </Button>\n        </NavLink>\n        <NavLink\n          className=\"nav-utility\"\n          exact\n          to=\"/settings\"\n          onClick={handleDismiss}\n        >\n          <SettingsButton />\n        </NavLink>\n        <Button\n          className=\"nav-utility minimal\"\n          onClick={() => openManual()}\n          title={intl.formatMessage({ id: \"help\" })}\n        >\n          <Icon icon={faQuestionCircle} />\n        </Button>\n        {maybeRenderLogout()}\n      </>\n    );\n  }\n\n  return (\n    <>\n      <Navbar\n        collapseOnSelect\n        fixed=\"top\"\n        variant=\"dark\"\n        bg=\"dark\"\n        className=\"top-nav\"\n        expand=\"xl\"\n        expanded={expanded}\n        onToggle={setExpanded}\n        ref={navbarRef}\n      >\n        <Navbar.Collapse className=\"bg-dark order-sm-1\">\n          <MainNavbarMenuItems>\n            {menuItems.map(({ href, icon, message }) => (\n              <Nav.Link\n                eventKey={href}\n                as=\"div\"\n                key={href}\n                className=\"col-4 col-sm-3 col-md-2 col-lg-auto\"\n              >\n                <LinkContainer activeClassName=\"active\" exact to={href}>\n                  <Button className=\"minimal p-4 p-xl-2 d-flex d-xl-inline-block flex-column justify-content-between align-items-center\">\n                    <Icon\n                      {...{ icon }}\n                      className=\"nav-menu-icon d-block d-xl-inline mb-2 mb-xl-0\"\n                    />\n                    <span>{intl.formatMessage(message)}</span>\n                  </Button>\n                </LinkContainer>\n              </Nav.Link>\n            ))}\n          </MainNavbarMenuItems>\n          <Nav>\n            <MainNavbarUtilityItems>\n              {renderUtilityButtons()}\n            </MainNavbarUtilityItems>\n          </Nav>\n        </Navbar.Collapse>\n\n        <Navbar.Brand as=\"div\" onClick={handleDismiss}>\n          <Link to=\"/\">\n            <Button className=\"minimal brand-link d-inline-block\">Stash</Button>\n          </Link>\n        </Navbar.Brand>\n\n        <Nav className=\"navbar-buttons flex-row ml-auto order-xl-2\">\n          {!!newPath && (\n            <div className=\"mr-2\">\n              <Link to={newPath}>\n                <Button variant=\"primary\">\n                  <FormattedMessage id=\"new\" defaultMessage=\"New\" />\n                </Button>\n              </Link>\n            </div>\n          )}\n          <MainNavbarUtilityItems>\n            {renderUtilityButtons()}\n          </MainNavbarUtilityItems>\n          <Navbar.Toggle className=\"nav-menu-toggle ml-sm-2\">\n            <Icon icon={expanded ? faTimes : faBars} />\n          </Navbar.Toggle>\n        </Nav>\n      </Navbar>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/PageNotFound.tsx",
    "content": "import React from \"react\";\n\nexport const PageNotFound: React.FC = () => {\n  return <h1>Page not found.</h1>;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/EditPerformersDialog.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useBulkPerformerUpdate } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { MultiSet } from \"../Shared/MultiSet\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport {\n  getAggregateInputValue,\n  getAggregateState,\n  getAggregateStateObject,\n} from \"src/utils/bulkUpdate\";\nimport {\n  genderStrings,\n  genderToString,\n  stringToGender,\n} from \"src/utils/gender\";\nimport {\n  circumcisedStrings,\n  circumcisedToString,\n  stringToCircumcised,\n} from \"src/utils/circumcised\";\nimport { IndeterminateCheckbox } from \"../Shared/IndeterminateCheckbox\";\nimport { BulkUpdateFormGroup, BulkUpdateTextInput } from \"../Shared/BulkUpdate\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { CountrySelect } from \"../Shared/CountrySelect\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport cx from \"classnames\";\nimport { BulkUpdateDateInput } from \"../Shared/DateInput\";\nimport { getDateError } from \"src/utils/yup\";\n\ninterface IListOperationProps {\n  selected: GQL.SlimPerformerDataFragment[];\n  onClose: (applied: boolean) => void;\n}\n\nconst performerFields = [\n  \"favorite\",\n  \"disambiguation\",\n  \"rating100\",\n  \"gender\",\n  \"birthdate\",\n  \"death_date\",\n  \"career_start\",\n  \"career_end\",\n  \"country\",\n  \"ethnicity\",\n  \"eye_color\",\n  // \"height\",\n  // \"weight\",\n  \"measurements\",\n  \"fake_tits\",\n  \"penis_length\",\n  \"circumcised\",\n  \"hair_color\",\n  \"tattoos\",\n  \"piercings\",\n  \"ignore_auto_tag\",\n];\n\nexport const EditPerformersDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [existingTagIds, setExistingTagIds] = useState<string[]>();\n  const [aggregateState, setAggregateState] =\n    useState<GQL.BulkPerformerUpdateInput>({});\n  // height and weight needs conversion to/from number\n  const [height, setHeight] = useState<string | undefined | null>();\n  const [weight, setWeight] = useState<string | undefined | null>();\n  const [penis_length, setPenisLength] = useState<string | undefined | null>();\n  const [updateInput, setUpdateInput] = useState<GQL.BulkPerformerUpdateInput>(\n    {}\n  );\n  const genderOptions = [\"\"].concat(genderStrings);\n  const circumcisedOptions = [\"\"].concat(circumcisedStrings);\n\n  const unsetDisabled = props.selected.length < 2;\n\n  const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput());\n\n  const [birthdateError, setBirthdateError] = useState<string | undefined>();\n  const [deathDateError, setDeathDateError] = useState<string | undefined>();\n  const [careerStartError, setCareerStartError] = useState<\n    string | undefined\n  >();\n  const [careerEndError, setCareerEndError] = useState<string | undefined>();\n\n  useEffect(() => {\n    setBirthdateError(getDateError(updateInput.birthdate ?? \"\", intl));\n  }, [updateInput.birthdate, intl]);\n\n  useEffect(() => {\n    setDeathDateError(getDateError(updateInput.death_date ?? \"\", intl));\n  }, [updateInput.death_date, intl]);\n\n  useEffect(() => {\n    setCareerStartError(getDateError(updateInput.career_start ?? \"\", intl));\n  }, [updateInput.career_start, intl]);\n\n  useEffect(() => {\n    setCareerEndError(getDateError(updateInput.career_end ?? \"\", intl));\n  }, [updateInput.career_end, intl]);\n\n  // Network state\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  function setUpdateField(input: Partial<GQL.BulkPerformerUpdateInput>) {\n    setUpdateInput({ ...updateInput, ...input });\n  }\n\n  function getPerformerInput(): GQL.BulkPerformerUpdateInput {\n    const performerInput: GQL.BulkPerformerUpdateInput = {\n      ids: props.selected.map((performer) => {\n        return performer.id;\n      }),\n      ...updateInput,\n      tag_ids: tagIds,\n    };\n\n    // we don't have unset functionality for the rating star control\n    // so need to determine if we are setting a rating or not\n    performerInput.rating100 = getAggregateInputValue(\n      updateInput.rating100,\n      aggregateState.rating100\n    );\n\n    // gender dropdown doesn't have unset functionality\n    // so need to determine what we are setting\n    performerInput.gender = getAggregateInputValue(\n      updateInput.gender,\n      aggregateState.gender\n    );\n    performerInput.circumcised = getAggregateInputValue(\n      updateInput.circumcised,\n      aggregateState.circumcised\n    );\n\n    if (height !== undefined) {\n      performerInput.height_cm = height === null ? null : parseFloat(height);\n    }\n    if (weight !== undefined) {\n      performerInput.weight = weight === null ? null : parseFloat(weight);\n    }\n    if (penis_length !== undefined) {\n      performerInput.penis_length =\n        penis_length === null ? null : parseFloat(penis_length);\n    }\n\n    return performerInput;\n  }\n\n  async function onSave() {\n    setIsUpdating(true);\n    try {\n      await updatePerformers();\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl\n              .formatMessage({ id: \"performers\" })\n              .toLocaleLowerCase(),\n          }\n        )\n      );\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsUpdating(false);\n  }\n\n  useEffect(() => {\n    const updateState: GQL.BulkPerformerUpdateInput = {};\n\n    const state = props.selected;\n    let updateTagIds: string[] = [];\n    let updateHeight: string | undefined | null = undefined;\n    let updateWeight: string | undefined | null = undefined;\n    let updatePenisLength: string | undefined | null = undefined;\n    let first = true;\n\n    state.forEach((performer: GQL.SlimPerformerDataFragment) => {\n      getAggregateStateObject(updateState, performer, performerFields, first);\n\n      const performerTagIDs = (performer.tags ?? []).map((p) => p.id).sort();\n\n      updateTagIds =\n        getAggregateState(updateTagIds, performerTagIDs, first) ?? [];\n\n      const thisHeight =\n        performer.height_cm !== undefined && performer.height_cm !== null\n          ? performer.height_cm.toString()\n          : performer.height_cm;\n      updateHeight = getAggregateState(updateHeight, thisHeight, first);\n\n      const thisWeight =\n        performer.weight !== undefined && performer.weight !== null\n          ? performer.weight.toString()\n          : performer.weight;\n      updateWeight = getAggregateState(updateWeight, thisWeight, first);\n\n      const thisPenisLength =\n        performer.penis_length !== undefined && performer.penis_length !== null\n          ? performer.penis_length.toString()\n          : performer.penis_length;\n      updatePenisLength = getAggregateState(\n        updatePenisLength,\n        thisPenisLength,\n        first\n      );\n\n      first = false;\n    });\n\n    setExistingTagIds(updateTagIds);\n    setHeight(updateHeight);\n    setWeight(updateWeight);\n    setAggregateState(updateState);\n    setUpdateInput(updateState);\n  }, [props.selected]);\n\n  function render() {\n    // sfw class needs to be set because it is outside body\n\n    return (\n      <ModalComponent\n        dialogClassName={cx(\"edit-performers-dialog\", {\n          \"sfw-content-mode\": sfwContentMode,\n        })}\n        show\n        icon={faPencilAlt}\n        header={intl.formatMessage(\n          { id: \"dialogs.edit_entity_count_title\" },\n          {\n            count: props?.selected?.length ?? 1,\n            singularEntity: intl.formatMessage({ id: \"performer\" }),\n            pluralEntity: intl.formatMessage({ id: \"performers\" }),\n          }\n        )}\n        accept={{\n          onClick: onSave,\n          text: intl.formatMessage({ id: \"actions.apply\" }),\n        }}\n        disabled={isUpdating || !!birthdateError || !!deathDateError}\n        cancel={{\n          onClick: () => props.onClose(false),\n          text: intl.formatMessage({ id: \"actions.cancel\" }),\n          variant: \"secondary\",\n        }}\n        isRunning={isUpdating}\n      >\n        <Form>\n          <BulkUpdateFormGroup name=\"rating\">\n            <RatingSystem\n              value={updateInput.rating100}\n              onSetRating={(value) =>\n                setUpdateField({ rating100: value ?? undefined })\n              }\n              disabled={isUpdating}\n            />\n          </BulkUpdateFormGroup>\n\n          <Form.Group controlId=\"favorite\">\n            <IndeterminateCheckbox\n              setChecked={(checked) => setUpdateField({ favorite: checked })}\n              checked={updateInput.favorite ?? undefined}\n              label={intl.formatMessage({ id: \"favourite\" })}\n            />\n          </Form.Group>\n\n          <BulkUpdateFormGroup name=\"gender\">\n            <Form.Control\n              as=\"select\"\n              className=\"input-control\"\n              value={genderToString(updateInput.gender)}\n              onChange={(event) =>\n                setUpdateField({\n                  gender: stringToGender(event.currentTarget.value),\n                })\n              }\n            >\n              {genderOptions.map((opt) => (\n                <option value={opt} key={opt}>\n                  {opt}\n                </option>\n              ))}\n            </Form.Control>\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"disambiguation\">\n            <BulkUpdateTextInput\n              value={updateInput.disambiguation}\n              valueChanged={(newValue) =>\n                setUpdateField({ disambiguation: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"birthdate\">\n            <BulkUpdateDateInput\n              value={updateInput.birthdate}\n              valueChanged={(newValue) =>\n                setUpdateField({ birthdate: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n              error={birthdateError}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"death_date\">\n            <BulkUpdateDateInput\n              value={updateInput.death_date}\n              valueChanged={(newValue) =>\n                setUpdateField({ death_date: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n              error={deathDateError}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"country\">\n            <CountrySelect\n              value={updateInput.country ?? \"\"}\n              onChange={(v) => setUpdateField({ country: v })}\n              showFlag\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"ethnicity\">\n            <BulkUpdateTextInput\n              value={updateInput.ethnicity}\n              valueChanged={(newValue) =>\n                setUpdateField({ ethnicity: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"hair_color\">\n            <BulkUpdateTextInput\n              value={updateInput.hair_color}\n              valueChanged={(newValue) =>\n                setUpdateField({ hair_color: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"eye_color\">\n            <BulkUpdateTextInput\n              value={updateInput.eye_color}\n              valueChanged={(newValue) =>\n                setUpdateField({ eye_color: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"height\">\n            <BulkUpdateTextInput\n              value={height}\n              valueChanged={(newValue) => setHeight(newValue)}\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"weight\">\n            <BulkUpdateTextInput\n              value={weight}\n              valueChanged={(newValue) => setWeight(newValue)}\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"measurements\">\n            <BulkUpdateTextInput\n              value={updateInput.measurements}\n              valueChanged={(newValue) =>\n                setUpdateField({ measurements: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"penis_length\">\n            <BulkUpdateTextInput\n              value={penis_length}\n              valueChanged={(newValue) => setPenisLength(newValue)}\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"circumcised\">\n            <Form.Control\n              as=\"select\"\n              className=\"input-control\"\n              value={circumcisedToString(updateInput.circumcised)}\n              onChange={(event) =>\n                setUpdateField({\n                  circumcised: stringToCircumcised(event.currentTarget.value),\n                })\n              }\n            >\n              {circumcisedOptions.map((opt) => (\n                <option value={opt} key={opt}>\n                  {opt}\n                </option>\n              ))}\n            </Form.Control>\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"fake_tits\">\n            <BulkUpdateTextInput\n              value={updateInput.fake_tits}\n              valueChanged={(newValue) =>\n                setUpdateField({ fake_tits: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"tattoos\">\n            <BulkUpdateTextInput\n              value={updateInput.tattoos}\n              valueChanged={(newValue) => setUpdateField({ tattoos: newValue })}\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"piercings\">\n            <BulkUpdateTextInput\n              value={updateInput.piercings}\n              valueChanged={(newValue) =>\n                setUpdateField({ piercings: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"career_start\">\n            <BulkUpdateDateInput\n              value={updateInput.career_start}\n              valueChanged={(newValue) =>\n                setUpdateField({ career_start: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n              error={careerStartError}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"career_end\">\n            <BulkUpdateDateInput\n              value={updateInput.career_end}\n              valueChanged={(newValue) =>\n                setUpdateField({ career_end: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n              error={careerEndError}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"tags\" inline={false}>\n            <MultiSet\n              type={\"tags\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setTagIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setTagIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={tagIds.ids ?? []}\n              existingIds={existingTagIds}\n              mode={tagIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <Form.Group controlId=\"ignore-auto-tags\">\n            <IndeterminateCheckbox\n              label={intl.formatMessage({ id: \"ignore_auto_tag\" })}\n              setChecked={(checked) =>\n                setUpdateField({ ignore_auto_tag: checked })\n              }\n              checked={updateInput.ignore_auto_tag ?? undefined}\n            />\n          </Form.Group>\n        </Form>\n      </ModalComponent>\n    );\n  }\n\n  return render();\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/GenderIcon.tsx",
    "content": "import React from \"react\";\nimport {\n  faVenus,\n  faTransgenderAlt,\n  faMars,\n  faNonBinary,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FontAwesomeIcon } from \"@fortawesome/react-fontawesome\";\nimport { useIntl } from \"react-intl\";\n\ninterface IIconProps {\n  gender?: GQL.Maybe<GQL.GenderEnum>;\n  className?: string;\n}\n\nfunction genderIcon(gender: GQL.GenderEnum) {\n  switch (gender) {\n    case GQL.GenderEnum.Male:\n      return faMars;\n    case GQL.GenderEnum.Female:\n      return faVenus;\n    case GQL.GenderEnum.NonBinary:\n      return faNonBinary;\n    default:\n      return faTransgenderAlt;\n  }\n}\n\nconst GenderIcon: React.FC<IIconProps> = ({ gender, className }) => {\n  const intl = useIntl();\n  if (gender) {\n    const icon = genderIcon(gender);\n\n    // new version of fontawesome doesn't seem to support titles on icons, so adding it\n    // to a span instead\n    return (\n      <span title={intl.formatMessage({ id: \"gender_types.\" + gender })}>\n        <FontAwesomeIcon\n          data-gender={gender}\n          className={className}\n          icon={icon}\n        />\n      </span>\n    );\n  }\n  return null;\n};\n\nexport default GenderIcon;\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerCard.tsx",
    "content": "import React from \"react\";\nimport { Link } from \"react-router-dom\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport NavUtils from \"src/utils/navigation\";\nimport TextUtils from \"src/utils/text\";\nimport { GridCard } from \"../Shared/GridCard/GridCard\";\nimport { CountryFlag } from \"../Shared/CountryFlag\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { Icon } from \"../Shared/Icon\";\nimport { TagLink } from \"../Shared/TagLink\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport {\n  ModifierCriterion,\n  CriterionValue,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { PopoverCountButton } from \"../Shared/PopoverCountButton\";\nimport GenderIcon from \"./GenderIcon\";\nimport { faLink, faTag } from \"@fortawesome/free-solid-svg-icons\";\nimport { faInstagram, faTwitter } from \"@fortawesome/free-brands-svg-icons\";\nimport { RatingBanner } from \"../Shared/RatingBanner\";\nimport { usePerformerUpdate } from \"src/core/StashService\";\nimport { ILabeledId } from \"src/models/list-filter/types\";\nimport { FavoriteIcon } from \"../Shared/FavoriteIcon\";\nimport { PatchComponent } from \"src/patch\";\nimport { ExternalLinksButton } from \"../Shared/ExternalLinksButton\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { OCounterButton } from \"../Shared/CountButton\";\n\nexport interface IPerformerCardExtraCriteria {\n  scenes?: ModifierCriterion<CriterionValue>[];\n  images?: ModifierCriterion<CriterionValue>[];\n  galleries?: ModifierCriterion<CriterionValue>[];\n  groups?: ModifierCriterion<CriterionValue>[];\n  performer?: ILabeledId;\n}\n\ninterface IPerformerCardProps {\n  performer: GQL.PerformerDataFragment;\n  cardWidth?: number;\n  ageFromDate?: string;\n  selecting?: boolean;\n  selected?: boolean;\n  zoomIndex?: number;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  extraCriteria?: IPerformerCardExtraCriteria;\n}\n\nconst PerformerCardPopovers: React.FC<IPerformerCardProps> = PatchComponent(\n  \"PerformerCard.Popovers\",\n  ({ performer, extraCriteria }) => {\n    function maybeRenderScenesPopoverButton() {\n      if (!performer.scene_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"scene-count\"\n          type=\"scene\"\n          count={performer.scene_count}\n          url={NavUtils.makePerformerScenesUrl(\n            performer,\n            extraCriteria?.performer,\n            extraCriteria?.scenes\n          )}\n        />\n      );\n    }\n\n    function maybeRenderImagesPopoverButton() {\n      if (!performer.image_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"image-count\"\n          type=\"image\"\n          count={performer.image_count}\n          url={NavUtils.makePerformerImagesUrl(\n            performer,\n            extraCriteria?.performer,\n            extraCriteria?.images\n          )}\n        />\n      );\n    }\n\n    function maybeRenderGalleriesPopoverButton() {\n      if (!performer.gallery_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"gallery-count\"\n          type=\"gallery\"\n          count={performer.gallery_count}\n          url={NavUtils.makePerformerGalleriesUrl(\n            performer,\n            extraCriteria?.performer,\n            extraCriteria?.galleries\n          )}\n        />\n      );\n    }\n\n    function maybeRenderOCounter() {\n      if (!performer.o_counter) return;\n\n      return <OCounterButton value={performer.o_counter} />;\n    }\n\n    function maybeRenderTagPopoverButton() {\n      if (performer.tags.length <= 0) return;\n\n      const popoverContent = performer.tags.map((tag) => (\n        <TagLink key={tag.id} linkType=\"performer\" tag={tag} />\n      ));\n\n      return (\n        <HoverPopover placement=\"bottom\" content={popoverContent}>\n          <Button className=\"minimal tag-count\">\n            <Icon icon={faTag} />\n            <span>{performer.tags.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderGroupsPopoverButton() {\n      if (!performer.group_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"group-count\"\n          type=\"group\"\n          count={performer.group_count}\n          url={NavUtils.makePerformerGroupsUrl(\n            performer,\n            extraCriteria?.performer,\n            extraCriteria?.groups\n          )}\n        />\n      );\n    }\n\n    if (\n      performer.scene_count ||\n      performer.image_count ||\n      performer.gallery_count ||\n      performer.tags.length > 0 ||\n      performer.o_counter ||\n      performer.group_count\n    ) {\n      return (\n        <>\n          <hr />\n          <ButtonGroup className=\"card-popovers\">\n            {maybeRenderScenesPopoverButton()}\n            {maybeRenderGroupsPopoverButton()}\n            {maybeRenderImagesPopoverButton()}\n            {maybeRenderGalleriesPopoverButton()}\n            {maybeRenderTagPopoverButton()}\n            {maybeRenderOCounter()}\n          </ButtonGroup>\n        </>\n      );\n    }\n\n    return null;\n  }\n);\n\nconst PerformerCardOverlays: React.FC<IPerformerCardProps> = PatchComponent(\n  \"PerformerCard.Overlays\",\n  ({ performer }) => {\n    const { configuration } = useConfigurationContext();\n    const uiConfig = configuration?.ui;\n    const [updatePerformer] = usePerformerUpdate();\n\n    function onToggleFavorite(v: boolean) {\n      if (performer.id) {\n        updatePerformer({\n          variables: {\n            input: {\n              id: performer.id,\n              favorite: v,\n            },\n          },\n        });\n      }\n    }\n\n    function maybeRenderRatingBanner() {\n      if (!performer.rating100) {\n        return;\n      }\n      return <RatingBanner rating={performer.rating100} />;\n    }\n\n    function maybeRenderFlag() {\n      if (performer.country) {\n        return (\n          <Link to={NavUtils.makePerformersCountryUrl(performer)}>\n            <CountryFlag\n              className=\"performer-card__country-flag\"\n              country={performer.country}\n              includeOverlay\n            />\n            <span className=\"performer-card__country-string\">\n              {performer.country}\n            </span>\n          </Link>\n        );\n      }\n    }\n\n    function maybeRenderLinks() {\n      if (!uiConfig?.showLinksOnPerformerCard) {\n        return;\n      }\n\n      if (performer.urls && performer.urls.length > 0) {\n        const twitter = performer.urls.filter((u) =>\n          u.match(/https?:\\/\\/(?:www\\.)?(?:twitter|x).com\\//)\n        );\n        const instagram = performer.urls.filter((u) =>\n          u.match(/https?:\\/\\/(?:www\\.)?instagram.com\\//)\n        );\n        const others = performer.urls.filter(\n          (u) => !twitter.includes(u) && !instagram.includes(u)\n        );\n\n        return (\n          <div\n            className=\"performer-card__links\"\n            style={{\n              position: \"absolute\",\n              left: \"0\",\n              bottom: \"0\",\n              display: \"flex\",\n              gap: \"0.5rem\",\n              flexDirection: \"column-reverse\",\n            }}\n          >\n            {twitter.length > 0 && (\n              <ExternalLinksButton\n                className=\"performer-card__link twitter\"\n                urls={twitter}\n                icon={faTwitter}\n                openIfSingle={true}\n              ></ExternalLinksButton>\n            )}\n            {instagram.length > 0 && (\n              <ExternalLinksButton\n                className=\"performer-card__link instagram\"\n                urls={instagram}\n                icon={faInstagram}\n                openIfSingle={true}\n              ></ExternalLinksButton>\n            )}\n            {others.length > 0 && (\n              <ExternalLinksButton\n                className=\"performer-card__link\"\n                icon={faLink}\n                urls={others}\n                openIfSingle={true}\n              />\n            )}\n          </div>\n        );\n      }\n    }\n\n    return (\n      <>\n        <FavoriteIcon\n          favorite={performer.favorite}\n          onToggleFavorite={onToggleFavorite}\n          size=\"2x\"\n          className=\"hide-not-favorite\"\n        />\n        {maybeRenderRatingBanner()}\n        {maybeRenderLinks()}\n        {maybeRenderFlag()}\n      </>\n    );\n  }\n);\n\nconst PerformerCardDetails: React.FC<IPerformerCardProps> = PatchComponent(\n  \"PerformerCard.Details\",\n  ({ performer, ageFromDate }) => {\n    const intl = useIntl();\n    const age = TextUtils.age(\n      performer.birthdate,\n      ageFromDate ?? performer.death_date\n    );\n    const ageL10nId = ageFromDate\n      ? \"media_info.performer_card.age_context\"\n      : \"media_info.performer_card.age\";\n    const ageL10String = intl.formatMessage({\n      id: \"years_old\",\n      defaultMessage: \"years old\",\n    });\n    const ageString = intl.formatMessage(\n      { id: ageL10nId },\n      { age, years_old: ageL10String }\n    );\n\n    return (\n      <>\n        {age !== 0 ? (\n          <div className=\"performer-card__age\">{ageString}</div>\n        ) : (\n          \"\"\n        )}\n      </>\n    );\n  }\n);\n\nconst PerformerCardImage: React.FC<IPerformerCardProps> = PatchComponent(\n  \"PerformerCard.Image\",\n  ({ performer }) => {\n    return (\n      <>\n        <img\n          loading=\"lazy\"\n          className=\"performer-card-image\"\n          alt={performer.name ?? \"\"}\n          src={performer.image_path ?? \"\"}\n        />\n      </>\n    );\n  }\n);\n\nconst PerformerCardTitle: React.FC<IPerformerCardProps> = PatchComponent(\n  \"PerformerCard.Title\",\n  ({ performer }) => {\n    return (\n      <div>\n        <span className=\"performer-name\">{performer.name}</span>\n        {performer.disambiguation && (\n          <span className=\"performer-disambiguation\">\n            {` (${performer.disambiguation})`}\n          </span>\n        )}\n      </div>\n    );\n  }\n);\n\nexport const PerformerCard: React.FC<IPerformerCardProps> = PatchComponent(\n  \"PerformerCard\",\n  (props) => {\n    const {\n      performer,\n      cardWidth,\n      selecting,\n      selected,\n      onSelectedChanged,\n      zoomIndex,\n    } = props;\n\n    return (\n      <GridCard\n        className={`performer-card zoom-${zoomIndex}`}\n        url={`/performers/${performer.id}`}\n        width={cardWidth}\n        pretitleIcon={\n          <GenderIcon className=\"gender-icon\" gender={performer.gender} />\n        }\n        title={<PerformerCardTitle {...props} />}\n        image={<PerformerCardImage {...props} />}\n        overlays={<PerformerCardOverlays {...props} />}\n        details={<PerformerCardDetails {...props} />}\n        popovers={<PerformerCardPopovers {...props} />}\n        selected={selected}\n        selecting={selecting}\n        onSelectedChanged={onSelectedChanged}\n      />\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerCardGrid.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { IPerformerCardExtraCriteria, PerformerCard } from \"./PerformerCard\";\nimport {\n  useCardWidth,\n  useContainerDimensions,\n} from \"../Shared/GridCard/GridCard\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IPerformerCardGrid {\n  performers: GQL.PerformerDataFragment[];\n  selectedIds: Set<string>;\n  zoomIndex: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  extraCriteria?: IPerformerCardExtraCriteria;\n}\n\nconst zoomWidths = [240, 300, 375, 470];\n\nexport const PerformerCardGrid: React.FC<IPerformerCardGrid> = PatchComponent(\n  \"PerformerCardGrid\",\n  ({ performers, selectedIds, zoomIndex, onSelectChange, extraCriteria }) => {\n    const [componentRef, { width: containerWidth }] = useContainerDimensions();\n    const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);\n\n    return (\n      <div className=\"row justify-content-center\" ref={componentRef}>\n        {performers.map((p) => (\n          <PerformerCard\n            key={p.id}\n            cardWidth={cardWidth}\n            performer={p}\n            zoomIndex={zoomIndex}\n            selecting={selectedIds.size > 0}\n            selected={selectedIds.has(p.id)}\n            onSelectedChanged={(selected: boolean, shiftKey: boolean) =>\n              onSelectChange(p.id, selected, shiftKey)\n            }\n            extraCriteria={extraCriteria}\n          />\n        ))}\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Button, Tabs, Tab, Col, Row } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useHistory, Redirect, RouteComponentProps } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport cx from \"classnames\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useFindPerformer,\n  usePerformerUpdate,\n  usePerformerDestroy,\n  mutateMetadataAutoTag,\n} from \"src/core/StashService\";\nimport { DetailsEditNavbar } from \"src/components/Shared/DetailsEditNavbar\";\nimport { ErrorMessage } from \"src/components/Shared/ErrorMessage\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport {\n  CompressedPerformerDetailsPanel,\n  PerformerDetailsPanel,\n} from \"./PerformerDetailsPanel\";\nimport { PerformerScenesPanel } from \"./PerformerScenesPanel\";\nimport { PerformerGalleriesPanel } from \"./PerformerGalleriesPanel\";\nimport { PerformerGroupsPanel } from \"./PerformerGroupsPanel\";\nimport { PerformerImagesPanel } from \"./PerformerImagesPanel\";\nimport { PerformerAppearsWithPanel } from \"./performerAppearsWithPanel\";\nimport { PerformerEditPanel } from \"./PerformerEditPanel\";\nimport { PerformerMergeModal } from \"../PerformerMergeDialog\";\nimport { PerformerSubmitButton } from \"./PerformerSubmitButton\";\nimport { useRatingKeybinds } from \"src/hooks/keybinds\";\nimport { DetailImage } from \"src/components/Shared/DetailImage\";\nimport { useLoadStickyHeader } from \"src/hooks/detailsPanel\";\nimport { useScrollToTopOnMount } from \"src/hooks/scrollToTop\";\nimport { ExternalLinkButtons } from \"src/components/Shared/ExternalLinksButton\";\nimport { BackgroundImage } from \"src/components/Shared/DetailsPage/BackgroundImage\";\nimport {\n  TabTitleCounter,\n  useTabKey,\n} from \"src/components/Shared/DetailsPage/Tabs\";\nimport { DetailTitle } from \"src/components/Shared/DetailsPage/DetailTitle\";\nimport { ExpandCollapseButton } from \"src/components/Shared/CollapseButton\";\nimport { FavoriteIcon } from \"src/components/Shared/FavoriteIcon\";\nimport { AliasList } from \"src/components/Shared/DetailsPage/AliasList\";\nimport { HeaderImage } from \"src/components/Shared/DetailsPage/HeaderImage\";\nimport { LightboxLink } from \"src/hooks/Lightbox/LightboxLink\";\nimport { PatchComponent } from \"src/patch\";\nimport { ILightboxImage } from \"src/hooks/Lightbox/types\";\nimport { goBackOrReplace } from \"src/utils/history\";\nimport { OCounterButton } from \"src/components/Shared/CountButton\";\n\ninterface IProps {\n  performer: GQL.PerformerDataFragment;\n  tabKey?: TabKey;\n}\n\ninterface IPerformerParams {\n  id: string;\n  tab?: string;\n}\n\nconst validTabs = [\n  \"default\",\n  \"scenes\",\n  \"galleries\",\n  \"images\",\n  \"groups\",\n  \"appearswith\",\n] as const;\ntype TabKey = (typeof validTabs)[number];\n\nfunction isTabKey(tab: string): tab is TabKey {\n  return validTabs.includes(tab as TabKey);\n}\n\nconst PerformerTabs: React.FC<{\n  tabKey?: TabKey;\n  performer: GQL.PerformerDataFragment;\n  abbreviateCounter: boolean;\n}> = ({ tabKey, performer, abbreviateCounter }) => {\n  const populatedDefaultTab = useMemo(() => {\n    let ret: TabKey = \"scenes\";\n    if (performer.scene_count == 0) {\n      if (performer.gallery_count != 0) {\n        ret = \"galleries\";\n      } else if (performer.image_count != 0) {\n        ret = \"images\";\n      } else if (performer.group_count != 0) {\n        ret = \"groups\";\n      }\n    }\n\n    return ret;\n  }, [performer]);\n\n  const { setTabKey } = useTabKey({\n    tabKey,\n    validTabs,\n    defaultTabKey: populatedDefaultTab,\n    baseURL: `/performers/${performer.id}`,\n  });\n\n  useEffect(() => {\n    Mousetrap.bind(\"c\", () => setTabKey(\"scenes\"));\n    Mousetrap.bind(\"g\", () => setTabKey(\"galleries\"));\n    Mousetrap.bind(\"m\", () => setTabKey(\"groups\"));\n\n    return () => {\n      Mousetrap.unbind(\"c\");\n      Mousetrap.unbind(\"g\");\n      Mousetrap.unbind(\"m\");\n    };\n  });\n\n  return (\n    <Tabs\n      id=\"performer-tabs\"\n      mountOnEnter\n      unmountOnExit\n      activeKey={tabKey}\n      onSelect={setTabKey}\n    >\n      <Tab\n        eventKey=\"scenes\"\n        title={\n          <TabTitleCounter\n            messageID=\"scenes\"\n            count={performer.scene_count}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <PerformerScenesPanel\n          active={tabKey === \"scenes\"}\n          performer={performer}\n        />\n      </Tab>\n\n      <Tab\n        eventKey=\"galleries\"\n        title={\n          <TabTitleCounter\n            messageID=\"galleries\"\n            count={performer.gallery_count}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <PerformerGalleriesPanel\n          active={tabKey === \"galleries\"}\n          performer={performer}\n        />\n      </Tab>\n\n      <Tab\n        eventKey=\"images\"\n        title={\n          <TabTitleCounter\n            messageID=\"images\"\n            count={performer.image_count}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <PerformerImagesPanel\n          active={tabKey === \"images\"}\n          performer={performer}\n        />\n      </Tab>\n\n      <Tab\n        eventKey=\"groups\"\n        title={\n          <TabTitleCounter\n            messageID=\"groups\"\n            count={performer.group_count}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <PerformerGroupsPanel\n          active={tabKey === \"groups\"}\n          performer={performer}\n        />\n      </Tab>\n\n      <Tab\n        eventKey=\"appearswith\"\n        title={\n          <TabTitleCounter\n            messageID=\"appears_with\"\n            count={performer.performer_count}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <PerformerAppearsWithPanel\n          active={tabKey === \"appearswith\"}\n          performer={performer}\n        />\n      </Tab>\n    </Tabs>\n  );\n};\n\ninterface IPerformerHeaderImageProps {\n  activeImage: string | null | undefined;\n  collapsed: boolean;\n  encodingImage: boolean;\n  lightboxImages: ILightboxImage[];\n  performer: GQL.PerformerDataFragment;\n}\n\nconst PerformerHeaderImage: React.FC<IPerformerHeaderImageProps> =\n  PatchComponent(\n    \"PerformerHeaderImage\",\n    ({ encodingImage, activeImage, lightboxImages, performer }) => {\n      return (\n        <HeaderImage encodingImage={encodingImage}>\n          {!!activeImage && (\n            <LightboxLink images={lightboxImages}>\n              <DetailImage\n                className=\"performer\"\n                src={activeImage}\n                alt={performer.name}\n              />\n            </LightboxLink>\n          )}\n        </HeaderImage>\n      );\n    }\n  );\n\nconst PerformerPage: React.FC<IProps> = PatchComponent(\n  \"PerformerPage\",\n  ({ performer, tabKey }) => {\n    const Toast = useToast();\n    const history = useHistory();\n    const intl = useIntl();\n\n    // Configuration settings\n    const { configuration } = useConfigurationContext();\n    const uiConfig = configuration?.ui;\n    const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;\n    const enableBackgroundImage =\n      uiConfig?.enablePerformerBackgroundImage ?? false;\n    const showAllDetails = uiConfig?.showAllDetails ?? true;\n    const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;\n\n    const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);\n    const [isEditing, setIsEditing] = useState<boolean>(false);\n    const [isMerging, setIsMerging] = useState<boolean>(false);\n    const [image, setImage] = useState<string | null>();\n    const [encodingImage, setEncodingImage] = useState<boolean>(false);\n    const loadStickyHeader = useLoadStickyHeader();\n\n    const activeImage = useMemo(() => {\n      const performerImage = performer.image_path;\n      if (isEditing) {\n        if (image === null && performerImage) {\n          const performerImageURL = new URL(performerImage);\n          performerImageURL.searchParams.set(\"default\", \"true\");\n          return performerImageURL.toString();\n        } else if (image) {\n          return image;\n        }\n      }\n      return performerImage;\n    }, [image, isEditing, performer.image_path]);\n\n    const lightboxImages = useMemo(\n      () => [{ paths: { thumbnail: activeImage, image: activeImage } }],\n      [activeImage]\n    );\n\n    const [updatePerformer] = usePerformerUpdate();\n    const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();\n\n    async function onAutoTag() {\n      try {\n        await mutateMetadataAutoTag({ performers: [performer.id] });\n        Toast.success(intl.formatMessage({ id: \"toast.started_auto_tagging\" }));\n      } catch (e) {\n        Toast.error(e);\n      }\n    }\n\n    function renderMergeButton() {\n      return (\n        <Button variant=\"secondary\" onClick={() => setIsMerging(true)}>\n          <FormattedMessage id=\"actions.merge\" />\n          ...\n        </Button>\n      );\n    }\n\n    function renderMergeDialog() {\n      if (!performer.id) return;\n      return (\n        <PerformerMergeModal\n          show={isMerging}\n          onClose={(mergedId) => {\n            setIsMerging(false);\n            if (mergedId !== undefined && mergedId !== performer.id) {\n              // By default, the merge destination is the current performer, but\n              // the user can change it, in which case we need to redirect.\n              history.replace(`/performers/${mergedId}`);\n            }\n          }}\n          performers={[performer]}\n        />\n      );\n    }\n\n    useRatingKeybinds(\n      true,\n      configuration?.ui.ratingSystemOptions?.type,\n      setRating\n    );\n\n    // set up hotkeys\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => toggleEditing());\n      Mousetrap.bind(\"f\", () => setFavorite(!performer.favorite));\n      Mousetrap.bind(\",\", () => setCollapsed(!collapsed));\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"f\");\n        Mousetrap.unbind(\",\");\n      };\n    });\n\n    async function onSave(input: GQL.PerformerCreateInput) {\n      await updatePerformer({\n        variables: {\n          input: {\n            id: performer.id,\n            ...input,\n          },\n        },\n      });\n      toggleEditing(false);\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"performer\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n    }\n\n    async function onDelete() {\n      try {\n        await deletePerformer({ variables: { id: performer.id } });\n      } catch (e) {\n        Toast.error(e);\n        return;\n      }\n\n      goBackOrReplace(history, \"/performers\");\n    }\n\n    function toggleEditing(value?: boolean) {\n      if (value !== undefined) {\n        setIsEditing(value);\n      } else {\n        setIsEditing((e) => !e);\n      }\n      setImage(undefined);\n    }\n\n    function setFavorite(v: boolean) {\n      if (performer.id) {\n        updatePerformer({\n          variables: {\n            input: {\n              id: performer.id,\n              favorite: v,\n            },\n          },\n        });\n      }\n    }\n\n    function setRating(v: number | null) {\n      if (performer.id) {\n        updatePerformer({\n          variables: {\n            input: {\n              id: performer.id,\n              rating100: v,\n            },\n          },\n        });\n      }\n    }\n\n    if (isDestroying)\n      return (\n        <LoadingIndicator\n          message={`Deleting performer ${performer.id}: ${performer.name}`}\n        />\n      );\n\n    const headerClassName = cx(\"detail-header\", {\n      edit: isEditing,\n      collapsed,\n      \"full-width\": !collapsed && !compactExpandedDetails,\n    });\n\n    return (\n      <div id=\"performer-page\" className=\"row\">\n        <Helmet>\n          <title>{performer.name}</title>\n        </Helmet>\n\n        <div className={headerClassName}>\n          <BackgroundImage\n            imagePath={activeImage ?? undefined}\n            show={enableBackgroundImage && !isEditing}\n          />\n          <div className=\"detail-container\">\n            <PerformerHeaderImage\n              activeImage={activeImage}\n              collapsed={collapsed}\n              encodingImage={encodingImage}\n              lightboxImages={lightboxImages}\n              performer={performer}\n            />\n            <div className=\"row\">\n              <div className=\"performer-head col\">\n                <DetailTitle\n                  name={performer.name}\n                  disambiguation={performer.disambiguation ?? undefined}\n                  classNamePrefix=\"performer\"\n                >\n                  {!isEditing && (\n                    <ExpandCollapseButton\n                      collapsed={collapsed}\n                      setCollapsed={(v) => setCollapsed(v)}\n                    />\n                  )}\n                  <span className=\"name-icons\">\n                    <FavoriteIcon\n                      favorite={performer.favorite}\n                      onToggleFavorite={(v) => setFavorite(v)}\n                    />\n                    <ExternalLinkButtons urls={performer.urls ?? undefined} />\n                  </span>\n                </DetailTitle>\n                <AliasList aliases={performer.alias_list} />\n                <div className=\"quality-group\">\n                  <RatingSystem\n                    value={performer.rating100}\n                    onSetRating={(value) => setRating(value)}\n                    clickToRate\n                    withoutContext\n                  />\n                  {!!performer.o_counter && (\n                    <OCounterButton value={performer.o_counter} />\n                  )}\n                </div>\n                {!isEditing && (\n                  <PerformerDetailsPanel\n                    performer={performer}\n                    collapsed={collapsed}\n                    fullWidth={!collapsed && !compactExpandedDetails}\n                  />\n                )}\n                {isEditing ? (\n                  <PerformerEditPanel\n                    performer={performer}\n                    isVisible={isEditing}\n                    onSubmit={onSave}\n                    onCancel={() => toggleEditing()}\n                    setImage={setImage}\n                    setEncodingImage={setEncodingImage}\n                  />\n                ) : (\n                  <Col>\n                    <Row xs={8}>\n                      <DetailsEditNavbar\n                        objectName={\n                          performer?.name ??\n                          intl.formatMessage({ id: \"performer\" })\n                        }\n                        onToggleEdit={() => toggleEditing()}\n                        onDelete={onDelete}\n                        onAutoTag={onAutoTag}\n                        autoTagDisabled={performer.ignore_auto_tag}\n                        isNew={false}\n                        isEditing={false}\n                        onSave={() => {}}\n                        onImageChange={() => {}}\n                        classNames=\"mb-2\"\n                        customButtons={\n                          <>\n                            {renderMergeButton()}\n                            <div>\n                              <PerformerSubmitButton performer={performer} />\n                            </div>\n                          </>\n                        }\n                      ></DetailsEditNavbar>\n                    </Row>\n                  </Col>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {!isEditing && loadStickyHeader && (\n          <CompressedPerformerDetailsPanel performer={performer} />\n        )}\n\n        <div className=\"detail-body\">\n          <div className=\"performer-body\">\n            <div className=\"performer-tabs\">\n              {!isEditing && (\n                <PerformerTabs\n                  tabKey={tabKey}\n                  performer={performer}\n                  abbreviateCounter={abbreviateCounter}\n                />\n              )}\n            </div>\n          </div>\n        </div>\n        {renderMergeDialog()}\n      </div>\n    );\n  }\n);\n\nconst PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({\n  location,\n  match,\n}) => {\n  const { id, tab } = match.params;\n  const { data, loading, error } = useFindPerformer(id);\n\n  useScrollToTopOnMount();\n\n  if (loading) return <LoadingIndicator />;\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findPerformer)\n    return <ErrorMessage error={`No performer found with id ${id}.`} />;\n\n  if (tab && !isTabKey(tab)) {\n    return (\n      <Redirect\n        to={{\n          ...location,\n          pathname: `/performers/${id}`,\n        }}\n      />\n    );\n  }\n\n  return (\n    <PerformerPage\n      performer={data.findPerformer}\n      tabKey={tab as TabKey | undefined}\n    />\n  );\n};\n\nexport default PerformerLoader;\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { PerformerEditPanel } from \"./PerformerEditPanel\";\nimport { useHistory, useLocation } from \"react-router-dom\";\nimport { useToast } from \"src/hooks/Toast\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { usePerformerCreate } from \"src/core/StashService\";\n\nconst PerformerCreate: React.FC = () => {\n  const Toast = useToast();\n  const history = useHistory();\n  const intl = useIntl();\n\n  const [image, setImage] = useState<string | null>();\n  const [encodingImage, setEncodingImage] = useState<boolean>(false);\n\n  const location = useLocation();\n  const query = useMemo(() => new URLSearchParams(location.search), [location]);\n  const performer = {\n    name: query.get(\"q\") ?? undefined,\n  };\n\n  const [createPerformer] = usePerformerCreate();\n\n  async function onSave(input: GQL.PerformerCreateInput, andNew?: boolean) {\n    const result = await createPerformer({\n      variables: { input },\n    });\n    if (result.data?.performerCreate) {\n      if (!andNew) {\n        history.push(`/performers/${result.data.performerCreate.id}`);\n      }\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"performer\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n    }\n  }\n\n  function renderPerformerImage() {\n    if (encodingImage) {\n      return (\n        <LoadingIndicator\n          message={intl.formatMessage({ id: \"actions.encoding_image\" })}\n        />\n      );\n    }\n    if (image) {\n      return (\n        <img\n          className=\"performer\"\n          src={image}\n          alt={intl.formatMessage({ id: \"performer\" })}\n        />\n      );\n    }\n  }\n\n  return (\n    <div className=\"row new-view\" id=\"performer-page\">\n      <div className=\"performer-image-container col-md-4 text-center\">\n        {renderPerformerImage()}\n      </div>\n      <div className=\"col-md-8\">\n        <h2>\n          <FormattedMessage\n            id=\"actions.create_entity\"\n            values={{ entityType: intl.formatMessage({ id: \"performer\" }) }}\n          />\n        </h2>\n        <PerformerEditPanel\n          performer={performer}\n          isVisible\n          onSubmit={onSave}\n          setImage={setImage}\n          setEncodingImage={setEncodingImage}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default PerformerCreate;\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx",
    "content": "import React, { PropsWithChildren } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { TagLink } from \"src/components/Shared/TagLink\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport { DetailItem } from \"src/components/Shared/DetailItem\";\nimport { CountryFlag } from \"src/components/Shared/CountryFlag\";\nimport { StashIDPill } from \"src/components/Shared/StashID\";\nimport {\n  FormatAge,\n  FormatCircumcised,\n  FormatHeight,\n  FormatPenisLength,\n  FormatWeight,\n  formatYearRange,\n} from \"../PerformerList\";\nimport { PatchComponent } from \"src/patch\";\nimport { CustomFields } from \"src/components/Shared/CustomFields\";\n\ninterface IPerformerDetails {\n  performer: GQL.PerformerDataFragment;\n  collapsed?: boolean;\n  fullWidth?: boolean;\n}\n\nconst PerformerDetailGroup: React.FC<PropsWithChildren<IPerformerDetails>> =\n  PatchComponent(\"PerformerDetailsPanel.DetailGroup\", ({ children }) => {\n    return <div className=\"detail-group\">{children}</div>;\n  });\n\nexport const PerformerDetailsPanel: React.FC<IPerformerDetails> =\n  PatchComponent(\"PerformerDetailsPanel\", (props) => {\n    const { performer, fullWidth, collapsed } = props;\n\n    // Network state\n    const intl = useIntl();\n\n    function renderTagsField() {\n      if (!performer.tags.length) {\n        return;\n      }\n      return (\n        <ul className=\"pl-0\">\n          {(performer.tags ?? []).map((tag) => (\n            <TagLink key={tag.id} linkType=\"performer\" tag={tag} />\n          ))}\n        </ul>\n      );\n    }\n\n    function renderStashIDs() {\n      if (!performer.stash_ids.length) {\n        return;\n      }\n\n      return (\n        <ul className=\"pl-0\">\n          {performer.stash_ids.map((stashID) => (\n            <li key={stashID.stash_id} className=\"row no-gutters\">\n              <StashIDPill stashID={stashID} linkType=\"performers\" />\n            </li>\n          ))}\n        </ul>\n      );\n    }\n\n    let details = performer?.details\n      ?.replace(/\\[((?:http|www\\.)[^\\n\\]]+)\\]/gm, \"\")\n      .trim();\n\n    return (\n      <PerformerDetailGroup {...props}>\n        {performer.gender ? (\n          <DetailItem\n            id=\"gender\"\n            value={intl.formatMessage({\n              id: \"gender_types.\" + performer.gender,\n            })}\n            fullWidth={fullWidth}\n          />\n        ) : (\n          \"\"\n        )}\n        <DetailItem\n          id=\"age\"\n          value={\n            !fullWidth\n              ? TextUtils.age(performer.birthdate, performer.death_date)\n              : FormatAge(performer.birthdate, performer.death_date)\n          }\n          title={\n            !fullWidth\n              ? TextUtils.formatFuzzyDate(\n                  intl,\n                  performer.birthdate ?? undefined\n                )\n              : \"\"\n          }\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"death_date\"\n          value={performer.death_date}\n          fullWidth={fullWidth}\n        />\n        {performer.country ? (\n          <DetailItem\n            id=\"country\"\n            value={\n              <CountryFlag\n                country={performer.country}\n                className=\"mr-2\"\n                includeName={true}\n              />\n            }\n            fullWidth={fullWidth}\n          />\n        ) : (\n          \"\"\n        )}\n        <DetailItem\n          id=\"ethnicity\"\n          value={performer?.ethnicity}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"hair_color\"\n          value={performer?.hair_color}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"eye_color\"\n          value={performer?.eye_color}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"height\"\n          value={FormatHeight(performer.height_cm)}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"weight\"\n          value={FormatWeight(performer.weight)}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"penis_length\"\n          value={FormatPenisLength(performer.penis_length)}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"circumcised\"\n          value={FormatCircumcised(performer.circumcised)}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"measurements\"\n          value={performer?.measurements}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"fake_tits\"\n          value={performer?.fake_tits}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"tattoos\"\n          value={performer?.tattoos}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"piercings\"\n          value={performer?.piercings}\n          fullWidth={fullWidth}\n        />\n        <DetailItem\n          id=\"career_length\"\n          value={formatYearRange(\n            performer?.career_start,\n            performer?.career_end\n          )}\n          fullWidth={fullWidth}\n        />\n        <DetailItem id=\"details\" value={details} fullWidth={fullWidth} />\n        <DetailItem id=\"tags\" value={renderTagsField()} fullWidth={fullWidth} />\n        <DetailItem\n          id=\"stash_ids\"\n          value={renderStashIDs()}\n          fullWidth={fullWidth}\n        />\n        {(fullWidth || !collapsed) && (\n          <CustomFields values={performer.custom_fields} />\n        )}\n      </PerformerDetailGroup>\n    );\n  });\n\nexport const CompressedPerformerDetailsPanel: React.FC<IPerformerDetails> =\n  PatchComponent(\"CompressedPerformerDetailsPanel\", ({ performer }) => {\n    // Network state\n    const intl = useIntl();\n\n    function scrollToTop() {\n      window.scrollTo({ top: 0, behavior: \"smooth\" });\n    }\n\n    return (\n      <div className=\"sticky detail-header\">\n        <div className=\"sticky detail-header-group\">\n          <a className=\"performer-name\" onClick={() => scrollToTop()}>\n            {performer.name}\n          </a>\n          {performer.gender ? (\n            <>\n              <span className=\"detail-divider\">/</span>\n              <span className=\"performer-gender\">\n                {intl.formatMessage({ id: \"gender_types.\" + performer.gender })}\n              </span>\n            </>\n          ) : (\n            \"\"\n          )}\n          {performer.birthdate ? (\n            <>\n              <span className=\"detail-divider\">/</span>\n              <span\n                className=\"performer-age\"\n                title={TextUtils.formatFuzzyDate(\n                  intl,\n                  performer.birthdate ?? undefined\n                )}\n              >\n                {TextUtils.age(performer.birthdate, performer.death_date)}\n              </span>\n            </>\n          ) : (\n            \"\"\n          )}\n          {performer.country ? (\n            <>\n              <span className=\"detail-divider\">/</span>\n              <span className=\"performer-country\">\n                <CountryFlag\n                  country={performer.country}\n                  className=\"mr-2\"\n                  includeName={true}\n                />\n              </span>\n            </>\n          ) : (\n            \"\"\n          )}\n        </div>\n      </div>\n    );\n  });\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Button, Form, Dropdown, SplitButton } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport * as yup from \"yup\";\nimport {\n  useListPerformerScrapers,\n  queryScrapePerformer,\n  mutateReloadScrapers,\n  queryScrapePerformerURL,\n} from \"src/core/StashService\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { ImageInput } from \"src/components/Shared/ImageInput\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { CountrySelect } from \"src/components/Shared/CountrySelect\";\nimport ImageUtils from \"src/utils/image\";\nimport { addUpdateStashID, getStashIDs } from \"src/utils/stashIds\";\nimport { stashboxDisplayName } from \"src/utils/stashbox\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { Prompt } from \"react-router-dom\";\nimport { useFormik } from \"formik\";\nimport {\n  genderToString,\n  stringGenderMap,\n  stringToGender,\n} from \"src/utils/gender\";\nimport {\n  circumcisedToString,\n  stringCircumMap,\n  stringToCircumcised,\n} from \"src/utils/circumcised\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { PerformerScrapeDialog } from \"./PerformerScrapeDialog\";\nimport PerformerScrapeModal from \"./PerformerScrapeModal\";\nimport PerformerStashBoxModal, { IStashBox } from \"./PerformerStashBoxModal\";\nimport StashBoxIDSearchModal from \"src/components/Shared/StashBoxIDSearchModal\";\nimport cx from \"classnames\";\nimport { faSyncAlt, faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport isEqual from \"lodash-es/isEqual\";\nimport { formikUtils } from \"src/utils/form\";\nimport {\n  yupFormikValidate,\n  yupInputNumber,\n  yupInputEnum,\n  yupDateString,\n  yupRequiredStringArray,\n  yupUniqueStringList,\n} from \"src/utils/yup\";\nimport { useTagsEdit } from \"src/hooks/tagsEdit\";\nimport {\n  CustomFieldsInput,\n  formatCustomFieldInput,\n} from \"src/components/Shared/CustomFields\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\n\nconst isScraper = (\n  scraper: GQL.Scraper | GQL.StashBox\n): scraper is GQL.Scraper => (scraper as GQL.Scraper).id !== undefined;\n\ninterface IPerformerDetails {\n  performer: Partial<GQL.PerformerDataFragment>;\n  isVisible: boolean;\n  onSubmit: (\n    performer: GQL.PerformerCreateInput,\n    andNew?: boolean\n  ) => Promise<void>;\n  onCancel?: () => void;\n  setImage: (image?: string | null) => void;\n  setEncodingImage: (loading: boolean) => void;\n}\n\nexport const PerformerEditPanel: React.FC<IPerformerDetails> = ({\n  performer,\n  isVisible,\n  onSubmit,\n  onCancel,\n  setImage,\n  setEncodingImage,\n}) => {\n  const Toast = useToast();\n\n  const isNew = performer.id === undefined;\n\n  // Editing state\n  const [scraper, setScraper] = useState<GQL.Scraper | IStashBox>();\n  const [isScraperModalOpen, setIsScraperModalOpen] = useState<boolean>(false);\n  const [isStashIDSearchOpen, setIsStashIDSearchOpen] =\n    useState<boolean>(false);\n\n  // Network state\n  const [isLoading, setIsLoading] = useState(false);\n\n  const Scrapers = useListPerformerScrapers();\n  const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);\n\n  const [scrapedPerformer, setScrapedPerformer] =\n    useState<GQL.ScrapedPerformer>();\n  const { configuration: stashConfig } = useConfigurationContext();\n\n  const intl = useIntl();\n\n  const schema = yup.object({\n    name: yup.string().required(),\n    disambiguation: yup.string().ensure(),\n    alias_list: yupRequiredStringArray(intl).defined(),\n    gender: yupInputEnum(GQL.GenderEnum).nullable().defined(),\n    birthdate: yupDateString(intl),\n    death_date: yupDateString(intl),\n    country: yup.string().ensure(),\n    ethnicity: yup.string().ensure(),\n    hair_color: yup.string().ensure(),\n    eye_color: yup.string().ensure(),\n    height_cm: yupInputNumber().positive().truncate().nullable().defined(),\n    weight: yupInputNumber().positive().truncate().nullable().defined(),\n    measurements: yup.string().ensure(),\n    fake_tits: yup.string().ensure(),\n    penis_length: yupInputNumber().positive().nullable().defined(),\n    circumcised: yupInputEnum(GQL.CircumcisedEnum).nullable().defined(),\n    tattoos: yup.string().ensure(),\n    piercings: yup.string().ensure(),\n    career_start: yupDateString(intl),\n    career_end: yupDateString(intl),\n    urls: yupUniqueStringList(intl),\n    details: yup.string().ensure(),\n    tag_ids: yup.array(yup.string().required()).defined(),\n    ignore_auto_tag: yup.boolean().defined(),\n    stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),\n    image: yup.string().nullable().optional(),\n    custom_fields: yup.object().required().defined(),\n  });\n\n  const initialValues = {\n    name: performer.name ?? \"\",\n    disambiguation: performer.disambiguation ?? \"\",\n    alias_list: performer.alias_list ?? [],\n    gender: performer.gender ?? null,\n    birthdate: performer.birthdate ?? \"\",\n    death_date: performer.death_date ?? \"\",\n    country: performer.country ?? \"\",\n    ethnicity: performer.ethnicity ?? \"\",\n    hair_color: performer.hair_color ?? \"\",\n    eye_color: performer.eye_color ?? \"\",\n    height_cm: performer.height_cm ?? null,\n    weight: performer.weight ?? null,\n    measurements: performer.measurements ?? \"\",\n    fake_tits: performer.fake_tits ?? \"\",\n    penis_length: performer.penis_length ?? null,\n    circumcised: performer.circumcised ?? null,\n    tattoos: performer.tattoos ?? \"\",\n    piercings: performer.piercings ?? \"\",\n    career_start: performer.career_start ?? \"\",\n    career_end: performer.career_end ?? \"\",\n    urls: performer.urls ?? [],\n    details: performer.details ?? \"\",\n    tag_ids: (performer.tags ?? []).map((t) => t.id),\n    ignore_auto_tag: performer.ignore_auto_tag ?? false,\n    stash_ids: getStashIDs(performer.stash_ids),\n    custom_fields: cloneDeep(performer.custom_fields ?? {}),\n  };\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const [customFieldsError, setCustomFieldsError] = useState<string>();\n\n  function submit(values: InputValues) {\n    const input = {\n      ...schema.cast(values),\n      custom_fields: formatCustomFieldInput(isNew, values.custom_fields),\n    };\n    onSave(input);\n  }\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: submit,\n  });\n\n  const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(\n    performer.tags,\n    (ids) => formik.setFieldValue(\"tag_ids\", ids)\n  );\n\n  function translateScrapedGender(scrapedGender?: string) {\n    if (!scrapedGender) {\n      return;\n    }\n\n    // try to translate from enum values first\n    const upperGender = scrapedGender.toUpperCase();\n    const asEnum = genderToString(upperGender);\n    if (asEnum) {\n      return stringToGender(asEnum);\n    } else {\n      // try to match against gender strings\n      const caseInsensitive = true;\n      return stringToGender(scrapedGender, caseInsensitive);\n    }\n  }\n\n  function translateScrapedCircumcised(scrapedCircumcised?: string) {\n    if (!scrapedCircumcised) {\n      return;\n    }\n\n    const upperCircumcised = scrapedCircumcised.toUpperCase();\n    const asEnum = circumcisedToString(upperCircumcised);\n    if (asEnum) {\n      return stringToCircumcised(asEnum);\n    } else {\n      const caseInsensitive = true;\n      return stringToCircumcised(scrapedCircumcised, caseInsensitive);\n    }\n  }\n\n  function updatePerformerEditStateFromScraper(\n    state: Partial<GQL.ScrapedPerformerDataFragment>\n  ) {\n    if (state.name) {\n      formik.setFieldValue(\"name\", state.name);\n    }\n    if (state.disambiguation) {\n      formik.setFieldValue(\"disambiguation\", state.disambiguation);\n    }\n    if (state.aliases) {\n      formik.setFieldValue(\n        \"alias_list\",\n        state.aliases.split(\",\").map((a) => a.trim())\n      );\n    }\n    if (state.birthdate) {\n      formik.setFieldValue(\"birthdate\", state.birthdate);\n    }\n    if (state.ethnicity) {\n      formik.setFieldValue(\"ethnicity\", state.ethnicity);\n    }\n    if (state.country) {\n      formik.setFieldValue(\"country\", state.country);\n    }\n    if (state.eye_color) {\n      formik.setFieldValue(\"eye_color\", state.eye_color);\n    }\n    if (state.height) {\n      formik.setFieldValue(\"height_cm\", parseInt(state.height, 10));\n    }\n    if (state.measurements) {\n      formik.setFieldValue(\"measurements\", state.measurements);\n    }\n    if (state.fake_tits) {\n      formik.setFieldValue(\"fake_tits\", state.fake_tits);\n    }\n    if (state.career_start) {\n      formik.setFieldValue(\"career_start\", state.career_start);\n    }\n    if (state.career_end) {\n      formik.setFieldValue(\"career_end\", state.career_end);\n    }\n    if (state.tattoos) {\n      formik.setFieldValue(\"tattoos\", state.tattoos);\n    }\n    if (state.piercings) {\n      formik.setFieldValue(\"piercings\", state.piercings);\n    }\n    if (state.urls) {\n      formik.setFieldValue(\"urls\", state.urls);\n    }\n    if (state.gender) {\n      // gender is a string in the scraper data\n      const newGender = translateScrapedGender(state.gender);\n      if (newGender) {\n        formik.setFieldValue(\"gender\", newGender);\n      }\n    }\n    if (state.circumcised) {\n      // circumcised is a string in the scraper data\n      const newCircumcised = translateScrapedCircumcised(state.circumcised);\n      if (newCircumcised) {\n        formik.setFieldValue(\"circumcised\", newCircumcised);\n      }\n    }\n    updateTagsStateFromScraper(state.tags ?? undefined);\n\n    // image is a base64 string\n    // #404: don't overwrite image if it has been modified by the user\n    // overwrite if not new since it came from a dialog\n    // overwrite if image is unset\n    if (\n      (!isNew || !formik.values.image) &&\n      state.images &&\n      state.images.length > 0\n    ) {\n      const imageStr = state.images[0];\n      formik.setFieldValue(\"image\", imageStr);\n    }\n    if (state.details) {\n      formik.setFieldValue(\"details\", state.details);\n    }\n    if (state.death_date) {\n      formik.setFieldValue(\"death_date\", state.death_date);\n    }\n    if (state.hair_color) {\n      formik.setFieldValue(\"hair_color\", state.hair_color);\n    }\n    if (state.weight) {\n      formik.setFieldValue(\"weight\", state.weight);\n    }\n    if (state.penis_length) {\n      formik.setFieldValue(\"penis_length\", state.penis_length);\n    }\n\n    updateStashIDs(state.remote_site_id);\n  }\n\n  function updateStashIDs(remoteSiteID: string | null | undefined) {\n    if (remoteSiteID && (scraper as IStashBox).endpoint) {\n      const newIDs =\n        formik.values.stash_ids?.filter(\n          (s) => s.endpoint !== (scraper as IStashBox).endpoint\n        ) ?? [];\n      newIDs?.push({\n        endpoint: (scraper as IStashBox).endpoint,\n        stash_id: remoteSiteID,\n        updated_at: new Date().toISOString(),\n      });\n      formik.setFieldValue(\"stash_ids\", newIDs);\n    }\n  }\n\n  const encodingImage = ImageUtils.usePasteImage(onImageLoad);\n\n  useEffect(() => {\n    setImage(formik.values.image);\n  }, [formik.values.image, setImage]);\n\n  useEffect(() => {\n    setEncodingImage(encodingImage);\n  }, [setEncodingImage, encodingImage]);\n\n  function onImageLoad(imageData: string | null) {\n    formik.setFieldValue(\"image\", imageData);\n  }\n\n  function onImageChange(event: React.FormEvent<HTMLInputElement>) {\n    ImageUtils.onImageChange(event, onImageLoad);\n  }\n\n  async function onSave(input: InputValues, andNew?: boolean) {\n    setIsLoading(true);\n    try {\n      await onSubmit(input, andNew);\n      formik.resetForm();\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsLoading(false);\n  }\n\n  async function onSaveAndNewClick() {\n    const { values } = formik;\n    const input = {\n      ...schema.cast(values),\n      custom_fields: formatCustomFieldInput(isNew, values.custom_fields),\n    };\n    onSave(input, true);\n  }\n\n  // set up hotkeys\n  useEffect(() => {\n    if (isVisible) {\n      Mousetrap.bind(\"s s\", () => {\n        if (formik.dirty) {\n          formik.submitForm();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"s s\");\n\n        if (!isNew) {\n          Mousetrap.unbind(\"d d\");\n        }\n      };\n    }\n  });\n\n  useEffect(() => {\n    const newQueryableScrapers = (Scrapers?.data?.listScrapers ?? []).filter(\n      (s) => s.performer?.supported_scrapes.includes(GQL.ScrapeType.Name)\n    );\n\n    setQueryableScrapers(newQueryableScrapers);\n  }, [Scrapers]);\n\n  if (isLoading) return <LoadingIndicator />;\n\n  async function onReloadScrapers() {\n    setIsLoading(true);\n    try {\n      await mutateReloadScrapers();\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  async function onScrapePerformer(\n    selectedPerformer: GQL.ScrapedPerformerDataFragment,\n    selectedScraper: GQL.Scraper\n  ) {\n    setIsScraperModalOpen(false);\n    try {\n      if (!scraper) return;\n      setIsLoading(true);\n\n      const {\n        __typename,\n        images: _image,\n        tags: _tags,\n        ...ret\n      } = selectedPerformer;\n\n      const result = await queryScrapePerformer(selectedScraper.id, ret);\n      if (!result?.data?.scrapeSinglePerformer?.length) return;\n\n      // assume one result\n      // if this is a new performer, just dump the data\n      if (isNew) {\n        updatePerformerEditStateFromScraper(\n          result.data.scrapeSinglePerformer[0]\n        );\n        setScraper(undefined);\n      } else {\n        setScrapedPerformer(result.data.scrapeSinglePerformer[0]);\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  async function onScrapePerformerURL(url: string) {\n    if (!url) return;\n    setIsLoading(true);\n    try {\n      const result = await queryScrapePerformerURL(url);\n      if (!result.data || !result.data.scrapePerformerURL) {\n        return;\n      }\n\n      // if this is a new performer, just dump the data\n      if (isNew) {\n        updatePerformerEditStateFromScraper(result.data.scrapePerformerURL);\n      } else {\n        setScrapedPerformer(result.data.scrapePerformerURL);\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  async function onScrapeStashBox(performerResult: GQL.ScrapedPerformer) {\n    setIsScraperModalOpen(false);\n\n    const result: GQL.ScrapedPerformerDataFragment = {\n      ...performerResult,\n      images: performerResult.images ?? undefined,\n      __typename: \"ScrapedPerformer\",\n    };\n\n    // if this is a new performer, just dump the data\n    if (isNew) {\n      updatePerformerEditStateFromScraper(result);\n      setScraper(undefined);\n    } else {\n      setScrapedPerformer(result);\n    }\n  }\n\n  function onScraperSelected(s: GQL.Scraper | IStashBox | undefined) {\n    setScraper(s);\n    setIsScraperModalOpen(true);\n  }\n\n  function renderScraperMenu() {\n    if (!performer) {\n      return;\n    }\n    const stashBoxes = stashConfig?.general.stashBoxes ?? [];\n\n    const popover = (\n      <Dropdown.Menu id=\"performer-scraper-popover\">\n        {stashBoxes.map((s, index) => (\n          <Dropdown.Item\n            as={Button}\n            key={s.endpoint}\n            className=\"minimal\"\n            onClick={() => onScraperSelected({ ...s, index })}\n          >\n            {stashboxDisplayName(s.name, index)}\n          </Dropdown.Item>\n        ))}\n        {queryableScrapers\n          ? queryableScrapers.map((s) => (\n              <Dropdown.Item\n                as={Button}\n                key={s.name}\n                className=\"minimal\"\n                onClick={() => onScraperSelected(s)}\n              >\n                {s.name}\n              </Dropdown.Item>\n            ))\n          : \"\"}\n        <Dropdown.Item\n          as={Button}\n          className=\"minimal\"\n          onClick={() => onReloadScrapers()}\n        >\n          <span className=\"fa-icon\">\n            <Icon icon={faSyncAlt} />\n          </span>\n          <span>\n            <FormattedMessage id=\"actions.reload_scrapers\" />\n          </span>\n        </Dropdown.Item>\n      </Dropdown.Menu>\n    );\n\n    return (\n      <Dropdown className=\"d-inline-block\">\n        <Dropdown.Toggle variant=\"secondary\" className=\"mr-2\">\n          <FormattedMessage id=\"actions.scrape_with\" />\n        </Dropdown.Toggle>\n        {popover}\n      </Dropdown>\n    );\n  }\n\n  function urlScrapable(scrapedUrl?: string) {\n    return (\n      !!scrapedUrl &&\n      (Scrapers?.data?.listScrapers ?? []).some((s) =>\n        (s?.performer?.urls ?? []).some((u) => scrapedUrl.includes(u))\n      )\n    );\n  }\n\n  function maybeRenderScrapeDialog() {\n    if (!scrapedPerformer) {\n      return;\n    }\n\n    const currentPerformer = {\n      ...formik.values,\n      image: formik.values.image ?? performer.image_path,\n    };\n\n    return (\n      <PerformerScrapeDialog\n        performer={currentPerformer}\n        performerTags={tags}\n        scraped={scrapedPerformer}\n        scraper={scraper}\n        onClose={(p) => {\n          onScrapeDialogClosed(p);\n        }}\n      />\n    );\n  }\n\n  function onScrapeDialogClosed(p?: GQL.ScrapedPerformerDataFragment) {\n    if (p) {\n      updatePerformerEditStateFromScraper(p);\n    }\n    setScrapedPerformer(undefined);\n    setScraper(undefined);\n  }\n\n  function onStashIDSelected(item?: GQL.StashIdInput) {\n    if (!item) return;\n    formik.setFieldValue(\n      \"stash_ids\",\n      addUpdateStashID(formik.values.stash_ids, item)\n    );\n  }\n\n  function renderButtons(classNames: string) {\n    return (\n      <div className={cx(\"details-edit\", \"col-xl-9\", classNames)}>\n        {!isNew && onCancel ? (\n          <Button className=\"mr-2\" variant=\"primary\" onClick={onCancel}>\n            <FormattedMessage id=\"actions.cancel\" />\n          </Button>\n        ) : null}\n        {renderScraperMenu()}\n        <ImageInput\n          isEditing\n          onImageChange={onImageChange}\n          onImageURL={onImageLoad}\n        />\n        <div>\n          <Button\n            className=\"mr-2\"\n            variant=\"danger\"\n            onClick={() => formik.setFieldValue(\"image\", null)}\n          >\n            <FormattedMessage id=\"actions.clear_image\" />\n          </Button>\n        </div>\n        {isNew ? (\n          <SplitButton\n            id=\"save-split-button\"\n            variant=\"success\"\n            disabled={\n              !isEqual(formik.errors, {}) || customFieldsError !== undefined\n            }\n            title={intl.formatMessage({ id: \"actions.save\" })}\n            onClick={() => formik.submitForm()}\n          >\n            <Dropdown.Item onClick={() => onSaveAndNewClick()}>\n              <FormattedMessage id=\"actions.save_and_new\" />\n            </Dropdown.Item>\n          </SplitButton>\n        ) : (\n          <Button\n            variant=\"success\"\n            disabled={\n              (!isNew && !formik.dirty) ||\n              !isEqual(formik.errors, {}) ||\n              customFieldsError !== undefined\n            }\n            onClick={() => formik.submitForm()}\n          >\n            <FormattedMessage id=\"actions.save\" />\n          </Button>\n        )}\n      </div>\n    );\n  }\n\n  const renderScrapeModal = () => {\n    if (!isScraperModalOpen) return;\n\n    return scraper !== undefined && isScraper(scraper) ? (\n      <PerformerScrapeModal\n        scraper={scraper}\n        onHide={() => setScraper(undefined)}\n        onSelectPerformer={onScrapePerformer}\n        name={formik.values.name || \"\"}\n      />\n    ) : scraper !== undefined && !isScraper(scraper) ? (\n      <PerformerStashBoxModal\n        instance={scraper}\n        onHide={() => setScraper(undefined)}\n        onSelectPerformer={onScrapeStashBox}\n        name={formik.values.name || \"\"}\n      />\n    ) : undefined;\n  };\n\n  const {\n    renderField,\n    renderInputField,\n    renderSelectField,\n    renderDateField,\n    renderStringListField,\n    renderStashIDsField,\n    renderURLListField,\n  } = formikUtils(intl, formik);\n\n  function renderCountryField() {\n    const title = intl.formatMessage({ id: \"country\" });\n    const control = (\n      <CountrySelect\n        value={formik.values.country}\n        onChange={(v) => formik.setFieldValue(\"country\", v)}\n      />\n    );\n\n    return renderField(\"country\", title, control);\n  }\n\n  function renderTagsField() {\n    const title = intl.formatMessage({ id: \"tags\" });\n\n    return renderField(\"tag_ids\", title, tagsControl());\n  }\n\n  return (\n    <>\n      {renderScrapeModal()}\n      {maybeRenderScrapeDialog()}\n      {isStashIDSearchOpen && (\n        <StashBoxIDSearchModal\n          entityType=\"performer\"\n          stashBoxes={stashConfig?.general.stashBoxes ?? []}\n          excludedStashBoxEndpoints={formik.values.stash_ids.map(\n            (s) => s.endpoint\n          )}\n          onSelectItem={(item) => {\n            onStashIDSelected(item);\n            setIsStashIDSearchOpen(false);\n          }}\n          initialQuery={performer.name ?? \"\"}\n        />\n      )}\n\n      <Prompt\n        when={formik.dirty}\n        message={intl.formatMessage({ id: \"dialogs.unsaved_changes\" })}\n      />\n      {renderButtons(\"mb-3\")}\n\n      <Form noValidate onSubmit={formik.handleSubmit} id=\"performer-edit\">\n        {renderInputField(\"name\")}\n        {renderInputField(\"disambiguation\")}\n\n        {renderStringListField(\"alias_list\", \"aliases\", { orderable: false })}\n\n        {renderSelectField(\"gender\", stringGenderMap)}\n\n        {renderDateField(\"birthdate\")}\n        {renderDateField(\"death_date\")}\n\n        {renderCountryField()}\n\n        {renderInputField(\"ethnicity\")}\n        {renderInputField(\"hair_color\")}\n        {renderInputField(\"eye_color\")}\n        {renderInputField(\"height_cm\", \"number\")}\n        {renderInputField(\"weight\", \"number\", \"weight_kg\")}\n        {renderInputField(\"penis_length\", \"number\", \"penis_length_cm\")}\n\n        {renderSelectField(\"circumcised\", stringCircumMap)}\n\n        {renderInputField(\"measurements\")}\n        {renderInputField(\"fake_tits\")}\n\n        {renderInputField(\"tattoos\", \"textarea\")}\n        {renderInputField(\"piercings\", \"textarea\")}\n\n        {renderDateField(\"career_start\")}\n        {renderDateField(\"career_end\")}\n\n        {renderURLListField(\"urls\", onScrapePerformerURL, urlScrapable)}\n\n        {renderInputField(\"details\", \"textarea\")}\n        {renderTagsField()}\n\n        {renderStashIDsField(\n          \"stash_ids\",\n          \"performers\",\n          \"stash_ids\",\n          undefined,\n          <Button\n            variant=\"success\"\n            className=\"mr-2 py-0\"\n            onClick={() => setIsStashIDSearchOpen(true)}\n            disabled={!stashConfig?.general.stashBoxes?.length}\n            title={intl.formatMessage({ id: \"actions.add_stash_id\" })}\n          >\n            <Icon icon={faPlus} />\n          </Button>\n        )}\n\n        <hr />\n\n        {renderInputField(\"ignore_auto_tag\", \"checkbox\")}\n\n        <hr />\n\n        <CustomFieldsInput\n          values={formik.values.custom_fields}\n          onChange={(v) => formik.setFieldValue(\"custom_fields\", v)}\n          error={customFieldsError}\n          setError={(e) => setCustomFieldsError(e)}\n        />\n\n        {renderButtons(\"mt-3\")}\n      </Form>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredGalleryList } from \"src/components/Galleries/GalleryList\";\nimport { usePerformerFilterHook } from \"src/core/performers\";\nimport { View } from \"src/components/List/views\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IPerformerDetailsProps {\n  active: boolean;\n  performer: GQL.PerformerDataFragment;\n}\n\nexport const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> =\n  PatchComponent(\"PerformerGalleriesPanel\", ({ active, performer }) => {\n    const filterHook = usePerformerFilterHook(performer);\n    return (\n      <FilteredGalleryList\n        filterHook={filterHook}\n        alterQuery={active}\n        view={View.PerformerGalleries}\n      />\n    );\n  });\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredGroupList } from \"src/components/Groups/GroupList\";\nimport { usePerformerFilterHook } from \"src/core/performers\";\nimport { View } from \"src/components/List/views\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IPerformerDetailsProps {\n  active: boolean;\n  performer: GQL.PerformerDataFragment;\n}\n\nexport const PerformerGroupsPanel: React.FC<IPerformerDetailsProps> =\n  PatchComponent(\"PerformerGroupsPanel\", ({ active, performer }) => {\n    const filterHook = usePerformerFilterHook(performer);\n    return (\n      <FilteredGroupList\n        filterHook={filterHook}\n        alterQuery={active}\n        view={View.PerformerGroups}\n      />\n    );\n  });\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredImageList } from \"src/components/Images/ImageList\";\nimport { usePerformerFilterHook } from \"src/core/performers\";\nimport { View } from \"src/components/List/views\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IPerformerImagesPanel {\n  active: boolean;\n  performer: GQL.PerformerDataFragment;\n}\n\nexport const PerformerImagesPanel: React.FC<IPerformerImagesPanel> =\n  PatchComponent(\"PerformerImagesPanel\", ({ active, performer }) => {\n    const filterHook = usePerformerFilterHook(performer);\n    return (\n      <FilteredImageList\n        filterHook={filterHook}\n        alterQuery={active}\n        view={View.PerformerImages}\n      />\n    );\n  });\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredSceneList } from \"src/components/Scenes/SceneList\";\nimport { usePerformerFilterHook } from \"src/core/performers\";\nimport { View } from \"src/components/List/views\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IPerformerDetailsProps {\n  active: boolean;\n  performer: GQL.PerformerDataFragment;\n}\n\nexport const PerformerScenesPanel: React.FC<IPerformerDetailsProps> =\n  PatchComponent(\"PerformerScenesPanel\", ({ active, performer }) => {\n    const filterHook = usePerformerFilterHook(performer);\n    return (\n      <FilteredSceneList\n        filterHook={filterHook}\n        alterQuery={active}\n        view={View.PerformerScenes}\n      />\n    );\n  });\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  ScrapedInputGroupRow,\n  ScrapedImagesRow,\n  ScrapeDialogRow,\n  ScrapedTextAreaRow,\n  ScrapedCountryRow,\n  ScrapedStringListRow,\n} from \"src/components/Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { ScrapeDialog } from \"src/components/Shared/ScrapeDialog/ScrapeDialog\";\nimport { Form } from \"react-bootstrap\";\nimport {\n  genderStrings,\n  genderToString,\n  stringToGender,\n} from \"src/utils/gender\";\nimport {\n  circumcisedStrings,\n  circumcisedToString,\n  stringToCircumcised,\n} from \"src/utils/circumcised\";\nimport { IStashBox } from \"./PerformerStashBoxModal\";\nimport { ScrapeResult } from \"src/components/Shared/ScrapeDialog/scrapeResult\";\nimport { Tag } from \"src/components/Tags/TagSelect\";\nimport { uniq } from \"lodash-es\";\nimport { useScrapedTags } from \"src/components/Shared/ScrapeDialog/scrapedTags\";\n\nfunction renderScrapedGender(\n  result: ScrapeResult<string>,\n  isNew?: boolean,\n  onChange?: (value: string) => void\n) {\n  const selectOptions = [\"\"].concat(genderStrings);\n\n  return (\n    <Form.Control\n      as=\"select\"\n      className=\"input-control\"\n      disabled={!isNew}\n      plaintext={!isNew}\n      value={isNew ? result.newValue : result.originalValue}\n      onChange={(e) => {\n        if (isNew && onChange) {\n          onChange(e.currentTarget.value);\n        }\n      }}\n    >\n      {selectOptions.map((opt) => (\n        <option value={opt} key={opt}>\n          {opt}\n        </option>\n      ))}\n    </Form.Control>\n  );\n}\n\nexport function renderScrapedGenderRow(\n  title: string,\n  result: ScrapeResult<string>,\n  onChange: (value: ScrapeResult<string>) => void\n) {\n  return (\n    <ScrapeDialogRow\n      field=\"gender\"\n      title={title}\n      result={result}\n      originalField={renderScrapedGender(result)}\n      newField={renderScrapedGender(result, true, (value) =>\n        onChange(result.cloneWithValue(value))\n      )}\n      onChange={onChange}\n    />\n  );\n}\n\nfunction renderScrapedCircumcised(\n  result: ScrapeResult<string>,\n  isNew?: boolean,\n  onChange?: (value: string) => void\n) {\n  const selectOptions = [\"\"].concat(circumcisedStrings);\n\n  return (\n    <Form.Control\n      as=\"select\"\n      className=\"input-control\"\n      disabled={!isNew}\n      plaintext={!isNew}\n      value={isNew ? result.newValue : result.originalValue}\n      onChange={(e) => {\n        if (isNew && onChange) {\n          onChange(e.currentTarget.value);\n        }\n      }}\n    >\n      {selectOptions.map((opt) => (\n        <option value={opt} key={opt}>\n          {opt}\n        </option>\n      ))}\n    </Form.Control>\n  );\n}\n\nexport function renderScrapedCircumcisedRow(\n  title: string,\n  result: ScrapeResult<string>,\n  onChange: (value: ScrapeResult<string>) => void\n) {\n  return (\n    <ScrapeDialogRow\n      title={title}\n      field=\"circumcised\"\n      result={result}\n      originalField={renderScrapedCircumcised(result)}\n      newField={renderScrapedCircumcised(result, true, (value) =>\n        onChange(result.cloneWithValue(value))\n      )}\n      onChange={onChange}\n    />\n  );\n}\n\ninterface IPerformerScrapeDialogProps {\n  performer: Partial<GQL.PerformerUpdateInput>;\n  performerTags: Tag[];\n  scraped: GQL.ScrapedPerformer;\n  scraper?: GQL.Scraper | IStashBox;\n\n  onClose: (scrapedPerformer?: GQL.ScrapedPerformer) => void;\n}\n\nexport const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (\n  props: IPerformerScrapeDialogProps\n) => {\n  const intl = useIntl();\n\n  const endpoint = (props.scraper as IStashBox)?.endpoint ?? undefined;\n\n  function getCurrentRemoteSiteID() {\n    if (!endpoint) {\n      return;\n    }\n\n    // #6257 - it is possible (though unsupported) to have multiple stash IDs for the same\n    // endpoint; in that case, we should prefer the one matching the scraped remote site ID\n    // if it exists\n    const stashIDs = (props.performer.stash_ids ?? []).filter(\n      (s) => s.endpoint === endpoint\n    );\n    if (stashIDs.length > 1 && props.scraped.remote_site_id) {\n      const matchingID = stashIDs.find(\n        (s) => s.stash_id === props.scraped.remote_site_id\n      );\n      if (matchingID) {\n        return matchingID.stash_id;\n      }\n    }\n\n    // otherwise, return the first stash ID for the endpoint\n    return props.performer.stash_ids?.find((s) => s.endpoint === endpoint)\n      ?.stash_id;\n  }\n\n  function translateScrapedGender(scrapedGender?: string | null) {\n    if (!scrapedGender) {\n      return;\n    }\n\n    let retEnum: GQL.GenderEnum | undefined;\n\n    // try to translate from enum values first\n    const upperGender = scrapedGender.toUpperCase();\n    const asEnum = genderToString(upperGender);\n    if (asEnum) {\n      retEnum = stringToGender(asEnum);\n    } else {\n      // try to match against gender strings\n      const caseInsensitive = true;\n      retEnum = stringToGender(scrapedGender, caseInsensitive);\n    }\n\n    return genderToString(retEnum);\n  }\n\n  function translateScrapedCircumcised(scrapedCircumcised?: string | null) {\n    if (!scrapedCircumcised) {\n      return;\n    }\n\n    let retEnum: GQL.CircumcisedEnum | undefined;\n\n    // try to translate from enum values first\n    const upperCircumcised = scrapedCircumcised.toUpperCase();\n    const asEnum = circumcisedToString(upperCircumcised);\n    if (asEnum) {\n      retEnum = stringToCircumcised(asEnum);\n    } else {\n      // try to match against circumcised strings\n      const caseInsensitive = true;\n      retEnum = stringToCircumcised(scrapedCircumcised, caseInsensitive);\n    }\n\n    return circumcisedToString(retEnum);\n  }\n\n  const [name, setName] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.name, props.scraped.name)\n  );\n  const [disambiguation, setDisambiguation] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.disambiguation,\n      props.scraped.disambiguation\n    )\n  );\n  const [aliases, setAliases] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.alias_list?.join(\", \"),\n      props.scraped.aliases\n    )\n  );\n  const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.birthdate, props.scraped.birthdate)\n  );\n  const [deathDate, setDeathDate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.death_date,\n      props.scraped.death_date\n    )\n  );\n  const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.ethnicity, props.scraped.ethnicity)\n  );\n  const [country, setCountry] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.country, props.scraped.country)\n  );\n  const [hairColor, setHairColor] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.hair_color,\n      props.scraped.hair_color\n    )\n  );\n  const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.eye_color, props.scraped.eye_color)\n  );\n  const [height, setHeight] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.height_cm?.toString(),\n      props.scraped.height\n    )\n  );\n  const [weight, setWeight] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.weight?.toString(),\n      props.scraped.weight\n    )\n  );\n  const [penisLength, setPenisLength] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.penis_length?.toString(),\n      props.scraped.penis_length\n    )\n  );\n  const [measurements, setMeasurements] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.measurements,\n      props.scraped.measurements\n    )\n  );\n  const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.fake_tits, props.scraped.fake_tits)\n  );\n  const [careerStart, setCareerStart] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.career_start,\n      props.scraped.career_start\n    )\n  );\n  const [careerEnd, setCareerEnd] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.career_end,\n      props.scraped.career_end\n    )\n  );\n  const [tattoos, setTattoos] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.tattoos, props.scraped.tattoos)\n  );\n  const [piercings, setPiercings] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.piercings, props.scraped.piercings)\n  );\n  const [urls, setURLs] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(\n      props.performer.urls,\n      props.scraped.urls\n        ? uniq((props.performer.urls ?? []).concat(props.scraped.urls ?? []))\n        : undefined\n    )\n  );\n  const [gender, setGender] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      genderToString(props.performer.gender),\n      translateScrapedGender(props.scraped.gender)\n    )\n  );\n  const [circumcised, setCircumcised] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      circumcisedToString(props.performer.circumcised),\n      translateScrapedCircumcised(props.scraped.circumcised)\n    )\n  );\n  const [details, setDetails] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(props.performer.details, props.scraped.details)\n  );\n  const [remoteSiteID, setRemoteSiteID] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      getCurrentRemoteSiteID(),\n      props.scraped.remote_site_id\n    )\n  );\n\n  const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(\n    props.performerTags,\n    props.scraped.tags,\n    endpoint\n  );\n\n  const [image, setImage] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(\n      props.performer.image,\n      props.scraped.images && props.scraped.images.length > 0\n        ? props.scraped.images[0]\n        : undefined\n    )\n  );\n\n  const images =\n    props.scraped.images && props.scraped.images.length > 0\n      ? props.scraped.images\n      : [];\n\n  const allFields = [\n    name,\n    disambiguation,\n    aliases,\n    birthdate,\n    ethnicity,\n    country,\n    eyeColor,\n    height,\n    measurements,\n    fakeTits,\n    penisLength,\n    circumcised,\n    careerStart,\n    careerEnd,\n    tattoos,\n    piercings,\n    urls,\n    gender,\n    image,\n    tags,\n    details,\n    deathDate,\n    hairColor,\n    weight,\n    remoteSiteID,\n  ];\n  // don't show the dialog if nothing was scraped\n  if (allFields.every((r) => !r.scraped) && newTags.length === 0) {\n    props.onClose();\n    return <></>;\n  }\n\n  function makeNewScrapedItem(): GQL.ScrapedPerformer {\n    const newImage = image.getNewValue();\n    return {\n      name: name.getNewValue() ?? \"\",\n      disambiguation: disambiguation.getNewValue(),\n      aliases: aliases.getNewValue(),\n      birthdate: birthdate.getNewValue(),\n      ethnicity: ethnicity.getNewValue(),\n      country: country.getNewValue(),\n      eye_color: eyeColor.getNewValue(),\n      height: height.getNewValue(),\n      measurements: measurements.getNewValue(),\n      fake_tits: fakeTits.getNewValue(),\n      career_start: careerStart.getNewValue(),\n      career_end: careerEnd.getNewValue(),\n      tattoos: tattoos.getNewValue(),\n      piercings: piercings.getNewValue(),\n      urls: urls.getNewValue(),\n      gender: gender.getNewValue(),\n      tags: tags.getNewValue(),\n      images: newImage ? [newImage] : undefined,\n      details: details.getNewValue(),\n      death_date: deathDate.getNewValue(),\n      hair_color: hairColor.getNewValue(),\n      weight: weight.getNewValue(),\n      penis_length: penisLength.getNewValue(),\n      circumcised: circumcised.getNewValue(),\n      remote_site_id: remoteSiteID.getNewValue(),\n    };\n  }\n\n  function renderScrapeRows() {\n    return (\n      <>\n        <ScrapedInputGroupRow\n          field=\"name\"\n          title={intl.formatMessage({ id: \"name\" })}\n          result={name}\n          onChange={(value) => setName(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"disambiguation\"\n          title={intl.formatMessage({ id: \"disambiguation\" })}\n          result={disambiguation}\n          onChange={(value) => setDisambiguation(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"aliases\"\n          title={intl.formatMessage({ id: \"aliases\" })}\n          result={aliases}\n          onChange={(value) => setAliases(value)}\n        />\n        {renderScrapedGenderRow(\n          intl.formatMessage({ id: \"gender\" }),\n          gender,\n          (value) => setGender(value)\n        )}\n        <ScrapedInputGroupRow\n          field=\"birthdate\"\n          title={intl.formatMessage({ id: \"birthdate\" })}\n          result={birthdate}\n          onChange={(value) => setBirthdate(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"death_date\"\n          title={intl.formatMessage({ id: \"death_date\" })}\n          result={deathDate}\n          onChange={(value) => setDeathDate(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"ethnicity\"\n          title={intl.formatMessage({ id: \"ethnicity\" })}\n          result={ethnicity}\n          onChange={(value) => setEthnicity(value)}\n        />\n        <ScrapedCountryRow\n          field=\"country\"\n          title={intl.formatMessage({ id: \"country\" })}\n          result={country}\n          onChange={(value) => setCountry(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"hair_color\"\n          title={intl.formatMessage({ id: \"hair_color\" })}\n          result={hairColor}\n          onChange={(value) => setHairColor(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"eye_color\"\n          title={intl.formatMessage({ id: \"eye_color\" })}\n          result={eyeColor}\n          onChange={(value) => setEyeColor(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"weight\"\n          title={intl.formatMessage({ id: \"weight\" })}\n          result={weight}\n          onChange={(value) => setWeight(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"height\"\n          title={intl.formatMessage({ id: \"height\" })}\n          result={height}\n          onChange={(value) => setHeight(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"penis_length\"\n          title={intl.formatMessage({ id: \"penis_length\" })}\n          result={penisLength}\n          onChange={(value) => setPenisLength(value)}\n        />\n        {renderScrapedCircumcisedRow(\n          intl.formatMessage({ id: \"circumcised\" }),\n          circumcised,\n          (value) => setCircumcised(value)\n        )}\n        <ScrapedInputGroupRow\n          field=\"measurements\"\n          title={intl.formatMessage({ id: \"measurements\" })}\n          result={measurements}\n          onChange={(value) => setMeasurements(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"fake_tits\"\n          title={intl.formatMessage({ id: \"fake_tits\" })}\n          result={fakeTits}\n          onChange={(value) => setFakeTits(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"career_start\"\n          title={intl.formatMessage({ id: \"career_start\" })}\n          result={careerStart}\n          onChange={(value) => setCareerStart(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"career_end\"\n          title={intl.formatMessage({ id: \"career_end\" })}\n          result={careerEnd}\n          onChange={(value) => setCareerEnd(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"tattoos\"\n          title={intl.formatMessage({ id: \"tattoos\" })}\n          result={tattoos}\n          onChange={(value) => setTattoos(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"piercings\"\n          title={intl.formatMessage({ id: \"piercings\" })}\n          result={piercings}\n          onChange={(value) => setPiercings(value)}\n        />\n        <ScrapedStringListRow\n          field=\"urls\"\n          title={intl.formatMessage({ id: \"urls\" })}\n          result={urls}\n          onChange={(value) => setURLs(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"details\"\n          title={intl.formatMessage({ id: \"details\" })}\n          result={details}\n          onChange={(value) => setDetails(value)}\n        />\n        {scrapedTagsRow}\n        <ScrapedImagesRow\n          field=\"image\"\n          title={intl.formatMessage({ id: \"performer_image\" })}\n          className=\"performer-image\"\n          result={image}\n          images={images}\n          onChange={(value) => setImage(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"remote_site_id\"\n          title={intl.formatMessage({ id: \"stash_id\" })}\n          result={remoteSiteID}\n          locked\n          onChange={(value) => setRemoteSiteID(value)}\n        />\n      </>\n    );\n  }\n\n  if (linkDialog) {\n    return linkDialog;\n  }\n\n  return (\n    <ScrapeDialog\n      title={intl.formatMessage(\n        { id: \"dialogs.scrape_entity_title\" },\n        { entity_type: intl.formatMessage({ id: \"performer\" }) }\n      )}\n      onClose={(apply) => {\n        props.onClose(apply ? makeNewScrapedItem() : undefined);\n      }}\n    >\n      {renderScrapeRows()}\n    </ScrapeDialog>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx",
    "content": "import React, { useEffect, useRef, useState } from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useScrapePerformerList } from \"src/core/StashService\";\nimport { useDebounce } from \"src/hooks/debounce\";\n\nconst CLASSNAME = \"PerformerScrapeModal\";\nconst CLASSNAME_LIST = `${CLASSNAME}-list`;\n\ninterface IProps {\n  scraper: GQL.Scraper;\n  onHide: () => void;\n  onSelectPerformer: (\n    performer: GQL.ScrapedPerformerDataFragment,\n    scraper: GQL.Scraper\n  ) => void;\n  name?: string;\n}\nconst PerformerScrapeModal: React.FC<IProps> = ({\n  scraper,\n  name,\n  onHide,\n  onSelectPerformer,\n}) => {\n  const intl = useIntl();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [query, setQuery] = useState<string>(name ?? \"\");\n  const { data, loading } = useScrapePerformerList(scraper.id, query);\n\n  const performers = data?.scrapeSinglePerformer ?? [];\n\n  const onInputChange = useDebounce(setQuery, 500);\n\n  useEffect(() => inputRef.current?.focus(), []);\n\n  return (\n    <ModalComponent\n      show\n      onHide={onHide}\n      header={`Scrape performer from ${scraper.name}`}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        onClick: onHide,\n        variant: \"secondary\",\n      }}\n    >\n      <div className={CLASSNAME}>\n        <Form.Control\n          onChange={(e) => onInputChange(e.currentTarget.value)}\n          defaultValue={name ?? \"\"}\n          placeholder=\"Performer name...\"\n          className=\"text-input mb-4\"\n          ref={inputRef}\n        />\n        {loading ? (\n          <div className=\"m-4 text-center\">\n            <LoadingIndicator inline />\n          </div>\n        ) : (\n          <ul className={CLASSNAME_LIST}>\n            {performers.map((p, i) => (\n              <li key={i}>\n                <Button\n                  variant=\"link\"\n                  onClick={() => onSelectPerformer(p, scraper)}\n                >\n                  {p.name}\n                  {p.disambiguation && ` (${p.disambiguation})`}\n                </Button>\n              </li>\n            ))}\n          </ul>\n        )}\n      </div>\n    </ModalComponent>\n  );\n};\n\nexport default PerformerScrapeModal;\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx",
    "content": "import React, { useEffect, useRef, useState } from \"react\";\nimport { Form, Row, Col, Badge } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { stashboxDisplayName } from \"src/utils/stashbox\";\nimport { useDebounce } from \"src/hooks/debounce\";\n\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { stringToGender } from \"src/utils/gender\";\nimport TextUtils from \"src/utils/text\";\nimport GenderIcon from \"src/components/Performers/GenderIcon\";\nimport { CountryFlag } from \"src/components/Shared/CountryFlag\";\n\nconst CLASSNAME = \"PerformerScrapeModal\";\nconst CLASSNAME_LIST = `${CLASSNAME}-list`;\nconst CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`;\n\ninterface IPerformerSearchResultDetailsProps {\n  performer: GQL.ScrapedPerformerDataFragment;\n}\n\nconst PerformerSearchResultDetails: React.FC<\n  IPerformerSearchResultDetailsProps\n> = ({ performer }) => {\n  function renderImage() {\n    if (performer.images && performer.images.length > 0) {\n      return (\n        <div className=\"scene-image-container\">\n          <img\n            src={performer.images[0]}\n            alt=\"\"\n            className=\"align-self-center scene-image\"\n          />\n        </div>\n      );\n    }\n  }\n\n  function calculateAge() {\n    if (performer?.birthdate) {\n      // calculate the age from birthdate. In future, this should probably be\n      // provided by the server\n      return TextUtils.age(performer.birthdate, performer.death_date);\n    }\n  }\n\n  function renderTags() {\n    if (performer.tags) {\n      return (\n        <Row>\n          <Col>\n            {performer.tags?.map((tag) => (\n              <Badge\n                className=\"tag-item\"\n                variant=\"secondary\"\n                key={tag.stored_id}\n              >\n                {tag.name}\n              </Badge>\n            ))}\n          </Col>\n        </Row>\n      );\n    }\n  }\n\n  function renderCountry() {\n    if (performer.country) {\n      return (\n        <span>\n          <CountryFlag\n            className=\"performer-result__country-flag\"\n            country={performer.country}\n          />\n        </span>\n      );\n    }\n  }\n\n  let age = calculateAge();\n\n  return (\n    <div className=\"performer-result\">\n      <Row>\n        {renderImage()}\n        <div className=\"col flex-column\">\n          <h4 className=\"performer-name\">\n            <span>{performer.name}</span>\n            {performer.disambiguation && (\n              <span className=\"performer-disambiguation\">\n                {` (${performer.disambiguation})`}\n              </span>\n            )}\n          </h4>\n          <h5 className=\"performer-details\">\n            {performer.gender && (\n              <span>\n                <GenderIcon\n                  className=\"gender-icon\"\n                  gender={stringToGender(performer.gender, true)}\n                />\n              </span>\n            )}\n            {age && (\n              <span>\n                {`${age} `}\n                <FormattedMessage id=\"years_old\" />\n              </span>\n            )}\n          </h5>\n          {renderCountry()}\n        </div>\n      </Row>\n      <Row>\n        <Col>\n          <TruncatedText text={performer.details ?? \"\"} lineCount={3} />\n        </Col>\n      </Row>\n      {renderTags()}\n    </div>\n  );\n};\n\nexport interface IPerformerSearchResult {\n  performer: GQL.ScrapedPerformerDataFragment;\n}\n\nexport const PerformerSearchResult: React.FC<IPerformerSearchResult> = ({\n  performer,\n}) => {\n  return (\n    <div className=\"mt-3 search-item\">\n      <PerformerSearchResultDetails performer={performer} />\n    </div>\n  );\n};\n\nexport interface IStashBox extends GQL.StashBox {\n  index: number;\n}\n\ninterface IProps {\n  instance: IStashBox;\n  onHide: () => void;\n  onSelectPerformer: (performer: GQL.ScrapedPerformer) => void;\n  name?: string;\n}\nconst PerformerStashBoxModal: React.FC<IProps> = ({\n  instance,\n  name,\n  onHide,\n  onSelectPerformer,\n}) => {\n  const intl = useIntl();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [query, setQuery] = useState<string>(name ?? \"\");\n  const { data, loading } = GQL.useScrapeSinglePerformerQuery({\n    variables: {\n      source: {\n        stash_box_endpoint: instance.endpoint,\n      },\n      input: {\n        query,\n      },\n    },\n    skip: query === \"\",\n  });\n\n  const performers = data?.scrapeSinglePerformer ?? [];\n\n  const onInputChange = useDebounce(setQuery, 500);\n\n  useEffect(() => inputRef.current?.focus(), []);\n\n  function renderResults() {\n    if (!performers) {\n      return;\n    }\n\n    return (\n      <div className={CLASSNAME_LIST_CONTAINER}>\n        <div className=\"mt-1\">\n          <FormattedMessage\n            id=\"dialogs.performers_found\"\n            values={{ count: performers.length }}\n          />\n        </div>\n        <ul className={CLASSNAME_LIST}>\n          {performers.map((p, i) => (\n            // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key\n            <li key={i} onClick={() => onSelectPerformer(p)}>\n              <PerformerSearchResult performer={p} />\n            </li>\n          ))}\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <ModalComponent\n      show\n      onHide={onHide}\n      header={`Scrape performer from ${stashboxDisplayName(\n        instance.name,\n        instance.index\n      )}`}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        onClick: onHide,\n        variant: \"secondary\",\n      }}\n    >\n      <div className={CLASSNAME}>\n        <Form.Control\n          onChange={(e) => onInputChange(e.currentTarget.value)}\n          defaultValue={name ?? \"\"}\n          placeholder=\"Performer name...\"\n          className=\"text-input mb-4\"\n          ref={inputRef}\n        />\n        {loading ? (\n          <div className=\"m-4 text-center\">\n            <LoadingIndicator inline />\n          </div>\n        ) : performers.length > 0 ? (\n          renderResults()\n        ) : (\n          query !== \"\" && <h5 className=\"text-center\">No results found.</h5>\n        )}\n      </div>\n    </ModalComponent>\n  );\n};\n\nexport default PerformerStashBoxModal;\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/PerformerSubmitButton.tsx",
    "content": "import { Button } from \"react-bootstrap\";\nimport React, { useState } from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SubmitStashBoxDraft } from \"src/components/Dialogs/SubmitDraft\";\n\ninterface IPerformerOperationsProps {\n  performer: GQL.PerformerDataFragment;\n}\n\nexport const PerformerSubmitButton: React.FC<IPerformerOperationsProps> = ({\n  performer,\n}) => {\n  const [showDraftModal, setShowDraftModal] = useState(false);\n\n  const { data } = GQL.useConfigurationQuery();\n  const boxes = data?.configuration?.general?.stashBoxes ?? [];\n\n  if (boxes.length === 0) return null;\n\n  return (\n    <>\n      <Button onClick={() => setShowDraftModal(true)}>\n        <FormattedMessage id=\"actions.submit_stash_box\" />\n      </Button>\n      <SubmitStashBoxDraft\n        type=\"performer\"\n        boxes={boxes}\n        entity={performer}\n        show={showDraftModal}\n        onHide={() => setShowDraftModal(false)}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredPerformerList } from \"src/components/Performers/PerformerList\";\nimport { usePerformerFilterHook } from \"src/core/performers\";\nimport { View } from \"src/components/List/views\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IPerformerDetailsProps {\n  active: boolean;\n  performer: GQL.PerformerDataFragment;\n}\n\nexport const PerformerAppearsWithPanel: React.FC<IPerformerDetailsProps> =\n  PatchComponent(\"PerformerAppearsWithPanel\", ({ active, performer }) => {\n    const performerValue = {\n      id: performer.id,\n      label: performer.name ?? `Performer ${performer.id}`,\n    };\n\n    const extraCriteria = {\n      performer: performerValue,\n    };\n\n    const filterHook = usePerformerFilterHook(performer);\n\n    return (\n      <FilteredPerformerList\n        filterHook={filterHook}\n        extraCriteria={extraCriteria}\n        alterQuery={active}\n        view={View.PerformerAppearsWith}\n      />\n    );\n  });\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerList.tsx",
    "content": "import cloneDeep from \"lodash-es/cloneDeep\";\nimport React, { useCallback, useEffect } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useHistory } from \"react-router-dom\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindPerformers,\n  useFindPerformers,\n  usePerformersDestroy,\n} from \"src/core/StashService\";\nimport { useFilteredItemList } from \"../List/ItemList\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { PerformerTagger } from \"../Tagger/performers/PerformerTagger\";\nimport { ExportDialog } from \"../Shared/ExportDialog\";\nimport { DeleteEntityDialog } from \"../Shared/DeleteEntityDialog\";\nimport { IPerformerCardExtraCriteria } from \"./PerformerCard\";\nimport { PerformerListTable } from \"./PerformerListTable\";\nimport { EditPerformersDialog } from \"./EditPerformersDialog\";\nimport { cmToImperial, cmToInches, kgToLbs } from \"src/utils/units\";\nimport TextUtils from \"src/utils/text\";\nimport { PerformerCardGrid } from \"./PerformerCardGrid\";\nimport { PerformerMergeModal } from \"./PerformerMergeDialog\";\nimport { View } from \"../List/views\";\nimport {\n  FilteredListToolbar,\n  IItemListOperation,\n} from \"../List/FilteredListToolbar\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport useFocus from \"src/utils/focus\";\nimport {\n  Sidebar,\n  SidebarPane,\n  SidebarPaneContent,\n  SidebarStateContext,\n  useSidebarState,\n} from \"../Shared/Sidebar\";\nimport { useCloseEditDelete, useFilterOperations } from \"../List/util\";\nimport {\n  FilteredSidebarHeader,\n  useFilteredSidebarKeybinds,\n} from \"../List/Filters/FilterSidebar\";\nimport {\n  IListFilterOperation,\n  ListOperations,\n} from \"../List/ListOperationButtons\";\nimport { FilterTags } from \"../List/FilterTags\";\nimport { Pagination, PaginationIndex } from \"../List/Pagination\";\nimport { LoadedContent } from \"../List/PagedList\";\nimport { SidebarTagsFilter } from \"../List/Filters/TagsFilter\";\nimport { SidebarRatingFilter } from \"../List/Filters/RatingFilter\";\nimport { SidebarAgeFilter } from \"../List/Filters/SidebarAgeFilter\";\nimport { PerformerListFilterOptions } from \"src/models/list-filter/performers\";\nimport { Button } from \"react-bootstrap\";\nimport cx from \"classnames\";\nimport { FavoritePerformerCriterionOption } from \"src/models/list-filter/criteria/favorite\";\nimport { SidebarBooleanFilter } from \"../List/Filters/BooleanFilter\";\nimport { SidebarOptionFilter } from \"../List/Filters/OptionFilter\";\nimport { GenderCriterionOption } from \"src/models/list-filter/criteria/gender\";\n\nexport const FormatHeight = (height?: number | null) => {\n  const intl = useIntl();\n  if (!height) {\n    return \"\";\n  }\n\n  const [feet, inches] = cmToImperial(height);\n\n  return (\n    <span className=\"performer-height\">\n      <span className=\"height-metric\">\n        {intl.formatNumber(height, {\n          style: \"unit\",\n          unit: \"centimeter\",\n          unitDisplay: \"short\",\n        })}\n      </span>\n      <span className=\"height-imperial\">\n        {intl.formatNumber(feet, {\n          style: \"unit\",\n          unit: \"foot\",\n          unitDisplay: \"narrow\",\n        })}\n        {intl.formatNumber(inches, {\n          style: \"unit\",\n          unit: \"inch\",\n          unitDisplay: \"narrow\",\n        })}\n      </span>\n    </span>\n  );\n};\n\nexport const FormatAge = (\n  birthdate?: string | null,\n  deathdate?: string | null\n) => {\n  if (!birthdate) {\n    return \"\";\n  }\n  const age = TextUtils.age(birthdate, deathdate);\n\n  return (\n    <span className=\"performer-age\">\n      <span className=\"age\">{age}</span>\n      <span className=\"birthdate\"> ({birthdate})</span>\n    </span>\n  );\n};\n\nexport const FormatWeight = (weight?: number | null) => {\n  const intl = useIntl();\n  if (!weight) {\n    return \"\";\n  }\n\n  const lbs = kgToLbs(weight);\n\n  return (\n    <span className=\"performer-weight\">\n      <span className=\"weight-metric\">\n        {intl.formatNumber(weight, {\n          style: \"unit\",\n          unit: \"kilogram\",\n          unitDisplay: \"short\",\n        })}\n      </span>\n      <span className=\"weight-imperial\">\n        {intl.formatNumber(lbs, {\n          style: \"unit\",\n          unit: \"pound\",\n          unitDisplay: \"short\",\n        })}\n      </span>\n    </span>\n  );\n};\n\nexport function formatYearRange(\n  start?: string | null,\n  end?: string | null\n): string | undefined {\n  if (!start && !end) return undefined;\n\n  return `${start ?? \"\"} - ${end ?? \"\"}`;\n}\n\nexport const FormatCircumcised = (circumcised?: GQL.CircumcisedEnum | null) => {\n  const intl = useIntl();\n  if (!circumcised) {\n    return \"\";\n  }\n\n  return (\n    <span className=\"penis-circumcised\">\n      {intl.formatMessage({\n        id: \"circumcised_types.\" + circumcised,\n      })}\n    </span>\n  );\n};\n\nexport const FormatPenisLength = (penis_length?: number | null) => {\n  const intl = useIntl();\n  if (!penis_length) {\n    return \"\";\n  }\n\n  const inches = cmToInches(penis_length);\n\n  return (\n    <span className=\"performer-penis-length\">\n      <span className=\"penis-length-metric\">\n        {intl.formatNumber(penis_length, {\n          style: \"unit\",\n          unit: \"centimeter\",\n          unitDisplay: \"short\",\n          maximumFractionDigits: 2,\n        })}\n      </span>\n      <span className=\"penis-length-imperial\">\n        {intl.formatNumber(inches, {\n          style: \"unit\",\n          unit: \"inch\",\n          unitDisplay: \"narrow\",\n          maximumFractionDigits: 2,\n        })}\n      </span>\n    </span>\n  );\n};\n\ninterface IPerformerList {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  alterQuery?: boolean;\n  extraCriteria?: IPerformerCardExtraCriteria;\n  extraOperations?: IItemListOperation<GQL.FindPerformersQueryResult>[];\n}\n\nconst PerformerList: React.FC<{\n  performers: GQL.PerformerDataFragment[];\n  filter: ListFilterModel;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  extraCriteria?: IPerformerCardExtraCriteria;\n}> = PatchComponent(\n  \"PerformerList\",\n  ({ performers, filter, selectedIds, onSelectChange, extraCriteria }) => {\n    if (performers.length === 0 && filter.displayMode !== DisplayMode.Tagger) {\n      return null;\n    }\n\n    if (filter.displayMode === DisplayMode.Grid) {\n      return (\n        <PerformerCardGrid\n          performers={performers}\n          zoomIndex={filter.zoomIndex}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n          extraCriteria={extraCriteria}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.List) {\n      return (\n        <PerformerListTable\n          performers={performers}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.Tagger) {\n      return <PerformerTagger performers={performers} />;\n    }\n\n    return null;\n  }\n);\n\nconst PerformerFilterSidebarSections = PatchContainerComponent(\n  \"FilteredPerformerList.SidebarSections\"\n);\n\nconst SidebarContent: React.FC<{\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  sidebarOpen: boolean;\n  onClose?: () => void;\n  showEditFilter: (editingCriterion?: string) => void;\n  count?: number;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  filter,\n  setFilter,\n  filterHook,\n  view,\n  showEditFilter,\n  sidebarOpen,\n  onClose,\n  count,\n  focus,\n}) => {\n  const showResultsId =\n    count !== undefined ? \"actions.show_count_results\" : \"actions.show_results\";\n\n  const AgeCriterionOption = PerformerListFilterOptions.criterionOptions.find(\n    (c) => c.type === \"age\"\n  );\n\n  return (\n    <>\n      <FilteredSidebarHeader\n        sidebarOpen={sidebarOpen}\n        showEditFilter={showEditFilter}\n        filter={filter}\n        setFilter={setFilter}\n        view={view}\n        focus={focus}\n      />\n\n      <PerformerFilterSidebarSections>\n        <SidebarTagsFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarRatingFilter filter={filter} setFilter={setFilter} />\n        <SidebarBooleanFilter\n          title={<FormattedMessage id=\"favourite\" />}\n          data-type={FavoritePerformerCriterionOption.type}\n          option={FavoritePerformerCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"favourite\"\n        />\n        <SidebarOptionFilter\n          title={<FormattedMessage id=\"gender\" />}\n          option={GenderCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"gender\"\n        />\n        <SidebarAgeFilter\n          title={<FormattedMessage id=\"age\" />}\n          option={AgeCriterionOption!}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"age\"\n        />\n      </PerformerFilterSidebarSections>\n\n      <div className=\"sidebar-footer\">\n        <Button className=\"sidebar-close-button\" onClick={onClose}>\n          <FormattedMessage id={showResultsId} values={{ count }} />\n        </Button>\n      </div>\n    </>\n  );\n};\n\nfunction useViewRandom(filter: ListFilterModel, count: number) {\n  const history = useHistory();\n\n  const viewRandom = useCallback(async () => {\n    // query for a random performer\n    if (count === 0) {\n      return;\n    }\n\n    const index = Math.floor(Math.random() * count);\n    const filterCopy = cloneDeep(filter);\n    filterCopy.itemsPerPage = 1;\n    filterCopy.currentPage = index + 1;\n    const singleResult = await queryFindPerformers(filterCopy);\n    if (singleResult.data.findPerformers.performers.length === 1) {\n      const { id } = singleResult.data.findPerformers.performers[0];\n      // navigate to the image player page\n      history.push(`/performers/${id}`);\n    }\n  }, [history, filter, count]);\n\n  return viewRandom;\n}\n\nfunction useAddKeybinds(filter: ListFilterModel, count: number) {\n  const viewRandom = useViewRandom(filter, count);\n\n  useEffect(() => {\n    Mousetrap.bind(\"p r\", () => {\n      viewRandom();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"p r\");\n    };\n  }, [viewRandom]);\n}\n\nexport const FilteredPerformerList = PatchComponent(\n  \"FilteredPerformerList\",\n  (props: IPerformerList) => {\n    const intl = useIntl();\n    const history = useHistory();\n\n    const searchFocus = useFocus();\n\n    const {\n      filterHook,\n      view,\n      alterQuery,\n      extraCriteria,\n      extraOperations = [],\n    } = props;\n\n    // States\n    const {\n      showSidebar,\n      setShowSidebar,\n      sectionOpen,\n      setSectionOpen,\n      loading: sidebarStateLoading,\n    } = useSidebarState(view);\n\n    const { filterState, queryResult, modalState, listSelect, showEditFilter } =\n      useFilteredItemList({\n        filterStateProps: {\n          filterMode: GQL.FilterMode.Performers,\n          view,\n          useURL: alterQuery,\n        },\n        queryResultProps: {\n          useResult: useFindPerformers,\n          getCount: (r) => r.data?.findPerformers.count ?? 0,\n          getItems: (r) => r.data?.findPerformers.performers ?? [],\n          filterHook,\n        },\n      });\n\n    const { filter, setFilter } = filterState;\n\n    const { effectiveFilter, result, cachedResult, items, totalCount } =\n      queryResult;\n\n    const {\n      selectedIds,\n      selectedItems,\n      onSelectChange,\n      onSelectAll,\n      onSelectNone,\n      onInvertSelection,\n      hasSelection,\n    } = listSelect;\n\n    const { modal, showModal, closeModal } = modalState;\n\n    // Utility hooks\n    const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({\n      filter,\n      setFilter,\n    });\n\n    useAddKeybinds(effectiveFilter, totalCount);\n    useFilteredSidebarKeybinds({\n      showSidebar,\n      setShowSidebar,\n    });\n\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => {\n        if (hasSelection) {\n          onEdit?.();\n        }\n      });\n\n      Mousetrap.bind(\"d d\", () => {\n        if (hasSelection) {\n          onDelete?.();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"d d\");\n      };\n    });\n\n    const onCloseEditDelete = useCloseEditDelete({\n      closeModal,\n      onSelectNone,\n      result,\n    });\n\n    const viewRandom = useViewRandom(effectiveFilter, totalCount);\n\n    function onExport(all: boolean) {\n      showModal(\n        <ExportDialog\n          exportInput={{\n            performers: {\n              ids: Array.from(selectedIds.values()),\n              all,\n            },\n          }}\n          onClose={() => closeModal()}\n        />\n      );\n    }\n\n    function onEdit() {\n      showModal(\n        <EditPerformersDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }\n\n    function onDelete() {\n      showModal(\n        <DeleteEntityDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n          singularEntity={intl.formatMessage({ id: \"performer\" })}\n          pluralEntity={intl.formatMessage({ id: \"performers\" })}\n          destroyMutation={usePerformersDestroy}\n        />\n      );\n    }\n\n    function onMerge() {\n      showModal(\n        <PerformerMergeModal\n          performers={selectedItems}\n          onClose={(mergedId?: string) => {\n            closeModal();\n            if (mergedId) {\n              history.push(`/performers/${mergedId}`);\n            }\n          }}\n          show\n        />\n      );\n    }\n\n    const convertedExtraOperations: IListFilterOperation[] =\n      extraOperations.map((o) => ({\n        ...o,\n        isDisplayed: o.isDisplayed\n          ? () => o.isDisplayed!(result, filter, selectedIds)\n          : undefined,\n        onClick: () => {\n          o.onClick(result, filter, selectedIds);\n        },\n      }));\n\n    const otherOperations: IListFilterOperation[] = [\n      ...convertedExtraOperations,\n      {\n        text: intl.formatMessage({ id: \"actions.select_all\" }),\n        onClick: () => onSelectAll(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_none\" }),\n        onClick: () => onSelectNone(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.invert_selection\" }),\n        onClick: () => onInvertSelection(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.open_random\" }),\n        onClick: viewRandom,\n      },\n      {\n        text: `${intl.formatMessage({ id: \"actions.merge\" })}…`,\n        onClick: onMerge,\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export\" }),\n        onClick: () => onExport(false),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export_all\" }),\n        onClick: () => onExport(true),\n      },\n    ];\n\n    // render\n    if (sidebarStateLoading) return null;\n\n    const operations = (\n      <ListOperations\n        items={items.length}\n        hasSelection={hasSelection}\n        operations={otherOperations}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        operationsMenuClassName=\"gallery-list-operations-dropdown\"\n      />\n    );\n\n    return (\n      <div\n        className={cx(\"item-list-container gallery-list\", {\n          \"hide-sidebar\": !showSidebar,\n        })}\n      >\n        {modal}\n\n        <SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>\n          <SidebarPane hideSidebar={!showSidebar}>\n            <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>\n              <SidebarContent\n                filter={filter}\n                setFilter={setFilter}\n                filterHook={filterHook}\n                showEditFilter={showEditFilter}\n                view={view}\n                sidebarOpen={showSidebar}\n                onClose={() => setShowSidebar(false)}\n                count={cachedResult.loading ? undefined : totalCount}\n                focus={searchFocus}\n              />\n            </Sidebar>\n            <SidebarPaneContent\n              onSidebarToggle={() => setShowSidebar(!showSidebar)}\n            >\n              <FilteredListToolbar\n                filter={filter}\n                listSelect={listSelect}\n                setFilter={setFilter}\n                showEditFilter={showEditFilter}\n                onDelete={onDelete}\n                onEdit={onEdit}\n                operationComponent={operations}\n                view={view}\n                zoomable\n              />\n\n              <FilterTags\n                criteria={filter.criteria}\n                onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}\n                onRemoveCriterion={removeCriterion}\n                onRemoveAll={clearAllCriteria}\n              />\n\n              <div className=\"pagination-index-container\">\n                <Pagination\n                  currentPage={filter.currentPage}\n                  itemsPerPage={filter.itemsPerPage}\n                  totalItems={totalCount}\n                  onChangePage={(page) => setFilter(filter.changePage(page))}\n                />\n                <PaginationIndex\n                  loading={cachedResult.loading}\n                  itemsPerPage={filter.itemsPerPage}\n                  currentPage={filter.currentPage}\n                  totalItems={totalCount}\n                />\n              </div>\n\n              <LoadedContent loading={result.loading} error={result.error}>\n                <PerformerList\n                  filter={effectiveFilter}\n                  performers={items}\n                  selectedIds={selectedIds}\n                  onSelectChange={onSelectChange}\n                  extraCriteria={extraCriteria}\n                />\n              </LoadedContent>\n\n              {totalCount > filter.itemsPerPage && (\n                <div className=\"pagination-footer-container\">\n                  <div className=\"pagination-footer\">\n                    <Pagination\n                      itemsPerPage={filter.itemsPerPage}\n                      currentPage={filter.currentPage}\n                      totalItems={totalCount}\n                      onChangePage={setPage}\n                      pagePopupPlacement=\"top\"\n                    />\n                  </div>\n                </div>\n              )}\n            </SidebarPaneContent>\n          </SidebarPane>\n        </SidebarStateContext.Provider>\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerListTable.tsx",
    "content": "/* eslint-disable jsx-a11y/control-has-associated-label */\n\nimport React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { Button } from \"react-bootstrap\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"../Shared/Icon\";\nimport NavUtils from \"src/utils/navigation\";\nimport { faHeart } from \"@fortawesome/free-solid-svg-icons\";\nimport { usePerformerUpdate } from \"src/core/StashService\";\nimport { useTableColumns } from \"src/hooks/useTableColumns\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport cx from \"classnames\";\nimport {\n  FormatCircumcised,\n  FormatHeight,\n  FormatPenisLength,\n  FormatWeight,\n  formatYearRange,\n} from \"./PerformerList\";\nimport TextUtils from \"src/utils/text\";\nimport { getCountryByISO } from \"src/utils/country\";\nimport { IColumn, ListTable } from \"../List/ListTable\";\n\ninterface IPerformerListTableProps {\n  performers: GQL.PerformerDataFragment[];\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nconst TABLE_NAME = \"performers\";\n\nexport const PerformerListTable: React.FC<IPerformerListTableProps> = (\n  props: IPerformerListTableProps\n) => {\n  const intl = useIntl();\n\n  const [updatePerformer] = usePerformerUpdate();\n\n  function setRating(v: number | null, performerId: string) {\n    if (performerId) {\n      updatePerformer({\n        variables: {\n          input: {\n            id: performerId,\n            rating100: v,\n          },\n        },\n      });\n    }\n  }\n\n  function setFavorite(v: boolean, performerId: string) {\n    if (performerId) {\n      updatePerformer({\n        variables: {\n          input: {\n            id: performerId,\n            favorite: v,\n          },\n        },\n      });\n    }\n  }\n\n  const ImageCell = (performer: GQL.PerformerDataFragment) => (\n    <Link to={`/performers/${performer.id}`}>\n      <img\n        loading=\"lazy\"\n        className=\"image-thumbnail\"\n        alt={performer.name ?? \"\"}\n        src={performer.image_path ?? \"\"}\n      />\n    </Link>\n  );\n\n  const NameCell = (performer: GQL.PerformerDataFragment) => (\n    <Link to={`/performers/${performer.id}`}>\n      <div className=\"ellips-data\" title={performer.name}>\n        {performer.name}\n        {performer.disambiguation && (\n          <span className=\"performer-disambiguation\">\n            {` (${performer.disambiguation})`}\n          </span>\n        )}\n      </div>\n    </Link>\n  );\n\n  const AliasesCell = (performer: GQL.PerformerDataFragment) => {\n    let aliases = performer.alias_list ? performer.alias_list.join(\", \") : \"\";\n    return (\n      <span className=\"ellips-data\" title={aliases}>\n        {aliases}\n      </span>\n    );\n  };\n\n  const GenderCell = (performer: GQL.PerformerDataFragment) => (\n    <>\n      {performer.gender\n        ? intl.formatMessage({ id: \"gender_types.\" + performer.gender })\n        : \"\"}\n    </>\n  );\n\n  const RatingCell = (performer: GQL.PerformerDataFragment) => (\n    <RatingSystem\n      value={performer.rating100}\n      onSetRating={(value) => setRating(value, performer.id)}\n      clickToRate\n    />\n  );\n\n  const AgeCell = (performer: GQL.PerformerDataFragment) => (\n    <span\n      title={\n        performer.birthdate\n          ? TextUtils.formatFuzzyDate(intl, performer.birthdate ?? undefined)\n          : \"\"\n      }\n    >\n      {performer.birthdate\n        ? TextUtils.age(performer.birthdate, performer.death_date)\n        : \"\"}\n    </span>\n  );\n\n  const DeathdateCell = (performer: GQL.PerformerDataFragment) => (\n    <>{performer.death_date}</>\n  );\n\n  const FavoriteCell = (performer: GQL.PerformerDataFragment) => (\n    <Button\n      className={cx(\n        \"minimal\",\n        performer.favorite ? \"favorite\" : \"not-favorite\"\n      )}\n      onClick={() => setFavorite(!performer.favorite, performer.id)}\n    >\n      <Icon icon={faHeart} />\n    </Button>\n  );\n\n  const CountryCell = (performer: GQL.PerformerDataFragment) => {\n    const { locale } = useIntl();\n    return (\n      <span className=\"ellips-data\">\n        {getCountryByISO(performer.country, locale)}\n      </span>\n    );\n  };\n\n  const EthnicityCell = (performer: GQL.PerformerDataFragment) => (\n    <>{performer.ethnicity}</>\n  );\n\n  const MeasurementsCell = (performer: GQL.PerformerDataFragment) => (\n    <span className=\"ellips-data\">{performer.measurements}</span>\n  );\n\n  const FakeTitsCell = (performer: GQL.PerformerDataFragment) => (\n    <>{performer.fake_tits}</>\n  );\n\n  const PenisLengthCell = (performer: GQL.PerformerDataFragment) => (\n    <>{FormatPenisLength(performer.penis_length)}</>\n  );\n\n  const CircumcisedCell = (performer: GQL.PerformerDataFragment) => (\n    <>{FormatCircumcised(performer.circumcised)}</>\n  );\n\n  const HairColorCell = (performer: GQL.PerformerDataFragment) => (\n    <span className=\"ellips-data\">{performer.hair_color}</span>\n  );\n\n  const EyeColorCell = (performer: GQL.PerformerDataFragment) => (\n    <>{performer.eye_color}</>\n  );\n\n  const HeightCell = (performer: GQL.PerformerDataFragment) => (\n    <>{FormatHeight(performer.height_cm)}</>\n  );\n\n  const WeightCell = (performer: GQL.PerformerDataFragment) => (\n    <>{FormatWeight(performer.weight)}</>\n  );\n\n  const CareerLengthCell = (performer: GQL.PerformerDataFragment) => (\n    <>{formatYearRange(performer.career_start, performer.career_end) ?? \"\"}</>\n  );\n\n  const SceneCountCell = (performer: GQL.PerformerDataFragment) => (\n    <Link to={NavUtils.makePerformerScenesUrl(performer)}>\n      <span>{performer.scene_count}</span>\n    </Link>\n  );\n\n  const GalleryCountCell = (performer: GQL.PerformerDataFragment) => (\n    <Link to={NavUtils.makePerformerGalleriesUrl(performer)}>\n      <span>{performer.gallery_count}</span>\n    </Link>\n  );\n\n  const ImageCountCell = (performer: GQL.PerformerDataFragment) => (\n    <Link to={NavUtils.makePerformerImagesUrl(performer)}>\n      <span>{performer.image_count}</span>\n    </Link>\n  );\n\n  const OCounterCell = (performer: GQL.PerformerDataFragment) => (\n    <>{performer.o_counter}</>\n  );\n\n  interface IColumnSpec {\n    value: string;\n    label: string;\n    defaultShow?: boolean;\n    mandatory?: boolean;\n    render?: (\n      scene: GQL.PerformerDataFragment,\n      index: number\n    ) => React.ReactNode;\n  }\n\n  const allColumns: IColumnSpec[] = [\n    {\n      value: \"image\",\n      label: intl.formatMessage({ id: \"image\" }),\n      defaultShow: true,\n      render: ImageCell,\n    },\n    {\n      value: \"name\",\n      label: intl.formatMessage({ id: \"name\" }),\n      mandatory: true,\n      defaultShow: true,\n      render: NameCell,\n    },\n    {\n      value: \"aliases\",\n      label: intl.formatMessage({ id: \"aliases\" }),\n      defaultShow: true,\n      render: AliasesCell,\n    },\n    {\n      value: \"gender\",\n      label: intl.formatMessage({ id: \"gender\" }),\n      defaultShow: true,\n      render: GenderCell,\n    },\n    {\n      value: \"rating\",\n      label: intl.formatMessage({ id: \"rating\" }),\n      defaultShow: true,\n      render: RatingCell,\n    },\n    {\n      value: \"age\",\n      label: intl.formatMessage({ id: \"age\" }),\n      defaultShow: true,\n      render: AgeCell,\n    },\n    {\n      value: \"death_date\",\n      label: intl.formatMessage({ id: \"death_date\" }),\n      render: DeathdateCell,\n    },\n    {\n      value: \"favourite\",\n      label: intl.formatMessage({ id: \"favourite\" }),\n      defaultShow: true,\n      render: FavoriteCell,\n    },\n    {\n      value: \"country\",\n      label: intl.formatMessage({ id: \"country\" }),\n      defaultShow: true,\n      render: CountryCell,\n    },\n    {\n      value: \"ethnicity\",\n      label: intl.formatMessage({ id: \"ethnicity\" }),\n      defaultShow: true,\n      render: EthnicityCell,\n    },\n    {\n      value: \"hair_color\",\n      label: intl.formatMessage({ id: \"hair_color\" }),\n      render: HairColorCell,\n    },\n    {\n      value: \"eye_color\",\n      label: intl.formatMessage({ id: \"eye_color\" }),\n      render: EyeColorCell,\n    },\n    {\n      value: \"height_cm\",\n      label: intl.formatMessage({ id: \"height_cm\" }),\n      render: HeightCell,\n    },\n    {\n      value: \"weight_kg\",\n      label: intl.formatMessage({ id: \"weight_kg\" }),\n      render: WeightCell,\n    },\n    {\n      value: \"penis_length_cm\",\n      label: intl.formatMessage({ id: \"penis_length_cm\" }),\n      render: PenisLengthCell,\n    },\n    {\n      value: \"circumcised\",\n      label: intl.formatMessage({ id: \"circumcised\" }),\n      render: CircumcisedCell,\n    },\n    {\n      value: \"measurements\",\n      label: intl.formatMessage({ id: \"measurements\" }),\n      render: MeasurementsCell,\n    },\n    {\n      value: \"fake_tits\",\n      label: intl.formatMessage({ id: \"fake_tits\" }),\n      render: FakeTitsCell,\n    },\n    {\n      value: \"career_length\",\n      label: intl.formatMessage({ id: \"career_length\" }),\n      defaultShow: true,\n      render: CareerLengthCell,\n    },\n    {\n      value: \"scene_count\",\n      label: intl.formatMessage({ id: \"scenes\" }),\n      defaultShow: true,\n      render: SceneCountCell,\n    },\n    {\n      value: \"gallery_count\",\n      label: intl.formatMessage({ id: \"galleries\" }),\n      defaultShow: true,\n      render: GalleryCountCell,\n    },\n    {\n      value: \"image_count\",\n      label: intl.formatMessage({ id: \"images\" }),\n      defaultShow: true,\n      render: ImageCountCell,\n    },\n    {\n      value: \"o_counter\",\n      label: intl.formatMessage({ id: \"o_count\" }),\n      defaultShow: true,\n      render: OCounterCell,\n    },\n  ];\n\n  const defaultColumns = allColumns\n    .filter((col) => col.defaultShow)\n    .map((col) => col.value);\n\n  const { selectedColumns, saveColumns } = useTableColumns(\n    TABLE_NAME,\n    defaultColumns\n  );\n\n  const columnRenderFuncs: Record<\n    string,\n    (scene: GQL.PerformerDataFragment, index: number) => React.ReactNode\n  > = {};\n  allColumns.forEach((col) => {\n    if (col.render) {\n      columnRenderFuncs[col.value] = col.render;\n    }\n  });\n\n  function renderCell(\n    column: IColumn,\n    performer: GQL.PerformerDataFragment,\n    index: number\n  ) {\n    const render = columnRenderFuncs[column.value];\n\n    if (render) return render(performer, index);\n  }\n\n  return (\n    <ListTable\n      className=\"performer-table\"\n      items={props.performers}\n      allColumns={allColumns}\n      columns={selectedColumns}\n      setColumns={(c) => saveColumns(c)}\n      selectedIds={props.selectedIds}\n      onSelectChange={props.onSelectChange}\n      renderCell={renderCell}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx",
    "content": "import { Form, Col, Row, Button } from \"react-bootstrap\";\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"../Shared/Icon\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport {\n  circumcisedToString,\n  stringToCircumcised,\n} from \"src/utils/circumcised\";\nimport * as FormUtils from \"src/utils/form\";\nimport { genderToString, stringToGender } from \"src/utils/gender\";\nimport ImageUtils from \"src/utils/image\";\nimport {\n  mutatePerformerMerge,\n  queryFindPerformersByID,\n} from \"src/core/StashService\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { faExchangeAlt, faSignInAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { ScrapeDialog } from \"../Shared/ScrapeDialog/ScrapeDialog\";\nimport {\n  ScrapedCustomFieldRows,\n  ScrapeDialogRow,\n  ScrapedImageRow,\n  ScrapedInputGroupRow,\n  ScrapedStringListRow,\n  ScrapedTextAreaRow,\n} from \"../Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { sortStoredIdObjects, uniqIDStoredIDs } from \"src/utils/data\";\nimport {\n  CustomFieldScrapeResults,\n  ObjectListScrapeResult,\n  ScrapeResult,\n  hasScrapedValues,\n} from \"../Shared/ScrapeDialog/scrapeResult\";\nimport { ScrapedTagsRow } from \"../Shared/ScrapeDialog/ScrapedObjectsRow\";\nimport {\n  renderScrapedGenderRow,\n  renderScrapedCircumcisedRow,\n} from \"./PerformerDetails/PerformerScrapeDialog\";\nimport { PerformerSelect } from \"./PerformerSelect\";\nimport { uniq } from \"lodash-es\";\nimport { StashIDsField } from \"../Shared/StashID\";\n\ntype MergeOptions = {\n  values: GQL.PerformerUpdateInput;\n};\n\ninterface IPerformerMergeDetailsProps {\n  sources: GQL.PerformerDataFragment[];\n  dest: GQL.PerformerDataFragment;\n  onClose: (options?: MergeOptions) => void;\n}\n\nconst PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({\n  sources,\n  dest,\n  onClose,\n}) => {\n  const intl = useIntl();\n\n  const [loading, setLoading] = useState(true);\n\n  const [name, setName] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.name)\n  );\n  const [disambiguation, setDisambiguation] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.disambiguation)\n  );\n  const [aliases, setAliases] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(dest.alias_list)\n  );\n  const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.birthdate)\n  );\n  const [deathDate, setDeathDate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.death_date)\n  );\n  const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.ethnicity)\n  );\n  const [country, setCountry] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.country)\n  );\n  const [hairColor, setHairColor] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.hair_color)\n  );\n  const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.eye_color)\n  );\n  const [height, setHeight] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.height_cm?.toString())\n  );\n  const [weight, setWeight] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.weight?.toString())\n  );\n  const [penisLength, setPenisLength] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.penis_length?.toString())\n  );\n  const [measurements, setMeasurements] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.measurements)\n  );\n  const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.fake_tits)\n  );\n  const [careerStart, setCareerStart] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.career_start?.toString())\n  );\n  const [careerEnd, setCareerEnd] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.career_end?.toString())\n  );\n  const [tattoos, setTattoos] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.tattoos)\n  );\n  const [piercings, setPiercings] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.piercings)\n  );\n  const [urls, setURLs] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(dest.urls)\n  );\n  const [gender, setGender] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(genderToString(dest.gender))\n  );\n  const [circumcised, setCircumcised] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(circumcisedToString(dest.circumcised))\n  );\n  const [details, setDetails] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.details)\n  );\n  const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(\n    new ObjectListScrapeResult<GQL.ScrapedTag>(\n      sortStoredIdObjects(dest.tags.map(idToStoredID))\n    )\n  );\n\n  const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));\n\n  const [image, setImage] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.image_path)\n  );\n\n  const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(\n    new Map()\n  );\n\n  function idToStoredID(o: { id: string; name: string }) {\n    return {\n      stored_id: o.id,\n      name: o.name,\n    };\n  }\n\n  // calculate the values for everything\n  // uses the first set value for single value fields, and combines all\n  useEffect(() => {\n    async function loadImages() {\n      const src = sources.find((s) => s.image_path);\n      if (!dest.image_path || !src) return;\n\n      setLoading(true);\n\n      const destData = await ImageUtils.imageToDataURL(dest.image_path);\n      const srcData = await ImageUtils.imageToDataURL(src.image_path!);\n\n      // keep destination image by default\n      const useNewValue = false;\n      setImage(new ScrapeResult(destData, srcData, useNewValue));\n\n      setLoading(false);\n    }\n\n    // append dest to all so that if dest has stash_ids with the same\n    // endpoint, then it will be excluded first\n    const all = sources.concat(dest);\n\n    setName(\n      new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)\n    );\n    setDisambiguation(\n      new ScrapeResult(\n        dest.disambiguation,\n        sources.find((s) => s.disambiguation)?.disambiguation,\n        !dest.disambiguation\n      )\n    );\n\n    // default alias list should be the existing aliases, plus the names of all sources,\n    // plus all source aliases, deduplicated\n    const allAliases = uniq(\n      dest.alias_list.concat(\n        sources.map((s) => s.name),\n        sources.flatMap((s) => s.alias_list)\n      )\n    );\n\n    setAliases(\n      new ScrapeResult(dest.alias_list, allAliases, !!allAliases.length)\n    );\n    setBirthdate(\n      new ScrapeResult(\n        dest.birthdate,\n        sources.find((s) => s.birthdate)?.birthdate,\n        !dest.birthdate\n      )\n    );\n    setDeathDate(\n      new ScrapeResult(\n        dest.death_date,\n        sources.find((s) => s.death_date)?.death_date,\n        !dest.death_date\n      )\n    );\n    setEthnicity(\n      new ScrapeResult(\n        dest.ethnicity,\n        sources.find((s) => s.ethnicity)?.ethnicity,\n        !dest.ethnicity\n      )\n    );\n    setCountry(\n      new ScrapeResult(\n        dest.country,\n        sources.find((s) => s.country)?.country,\n        !dest.country\n      )\n    );\n    setHairColor(\n      new ScrapeResult(\n        dest.hair_color,\n        sources.find((s) => s.hair_color)?.hair_color,\n        !dest.hair_color\n      )\n    );\n    setEyeColor(\n      new ScrapeResult(\n        dest.eye_color,\n        sources.find((s) => s.eye_color)?.eye_color,\n        !dest.eye_color\n      )\n    );\n    setHeight(\n      new ScrapeResult(\n        dest.height_cm?.toString(),\n        sources.find((s) => s.height_cm)?.height_cm?.toString(),\n        !dest.height_cm\n      )\n    );\n    setWeight(\n      new ScrapeResult(\n        dest.weight?.toString(),\n        sources.find((s) => s.weight)?.weight?.toString(),\n        !dest.weight\n      )\n    );\n\n    setPenisLength(\n      new ScrapeResult(\n        dest.penis_length?.toString(),\n        sources.find((s) => s.penis_length)?.penis_length?.toString(),\n        !dest.penis_length\n      )\n    );\n    setMeasurements(\n      new ScrapeResult(\n        dest.measurements,\n        sources.find((s) => s.measurements)?.measurements,\n        !dest.measurements\n      )\n    );\n    setFakeTits(\n      new ScrapeResult(\n        dest.fake_tits,\n        sources.find((s) => s.fake_tits)?.fake_tits,\n        !dest.fake_tits\n      )\n    );\n    setCareerStart(\n      new ScrapeResult(\n        dest.career_start?.toString(),\n        sources.find((s) => s.career_start)?.career_start?.toString(),\n        !dest.career_start\n      )\n    );\n    setCareerEnd(\n      new ScrapeResult(\n        dest.career_end?.toString(),\n        sources.find((s) => s.career_end)?.career_end?.toString(),\n        !dest.career_end\n      )\n    );\n    setTattoos(\n      new ScrapeResult(\n        dest.tattoos,\n        sources.find((s) => s.tattoos)?.tattoos,\n        !dest.tattoos\n      )\n    );\n    setPiercings(\n      new ScrapeResult(\n        dest.piercings,\n        sources.find((s) => s.piercings)?.piercings,\n        !dest.piercings\n      )\n    );\n    setURLs(\n      new ScrapeResult(\n        dest.urls ?? [],\n        uniq(all.map((s) => s.urls ?? []).flat())\n      )\n    );\n    setGender(\n      new ScrapeResult(\n        genderToString(dest.gender),\n        sources.find((s) => s.gender)?.gender\n          ? genderToString(sources.find((s) => s.gender)?.gender)\n          : undefined,\n        !dest.gender\n      )\n    );\n    setCircumcised(\n      new ScrapeResult(\n        circumcisedToString(dest.circumcised),\n        sources.find((s) => s.circumcised)?.circumcised\n          ? circumcisedToString(sources.find((s) => s.circumcised)?.circumcised)\n          : undefined,\n        !dest.circumcised\n      )\n    );\n    setDetails(\n      new ScrapeResult(\n        dest.details,\n        sources.find((s) => s.details)?.details,\n        !dest.details\n      )\n    );\n    setTags(\n      new ObjectListScrapeResult<GQL.ScrapedTag>(\n        sortStoredIdObjects(dest.tags.map(idToStoredID)),\n        uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat())\n      )\n    );\n    setStashIDs(\n      new ScrapeResult(\n        dest.stash_ids,\n        all\n          .map((s) => s.stash_ids)\n          .flat()\n          .filter((s, index, a) => {\n            // remove entries with duplicate endpoints\n            return index === a.findIndex((ss) => ss.endpoint === s.endpoint);\n          })\n      )\n    );\n\n    setImage(\n      new ScrapeResult(\n        dest.image_path,\n        sources.find((s) => s.image_path)?.image_path,\n        !dest.image_path\n      )\n    );\n\n    const customFieldNames = new Set<string>(Object.keys(dest.custom_fields));\n\n    for (const s of sources) {\n      for (const n of Object.keys(s.custom_fields)) {\n        customFieldNames.add(n);\n      }\n    }\n\n    setCustomFields(\n      new Map(\n        Array.from(customFieldNames)\n          .sort()\n          .map((field) => {\n            return [\n              field,\n              new ScrapeResult(\n                dest.custom_fields?.[field],\n                sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[\n                  field\n                ],\n                dest.custom_fields?.[field] === undefined\n              ),\n            ];\n          })\n      )\n    );\n\n    loadImages();\n  }, [sources, dest]);\n\n  const hasCustomFieldValues = useMemo(() => {\n    return hasScrapedValues(Array.from(customFields.values()));\n  }, [customFields]);\n\n  // ensure this is updated if fields are changed\n  const hasValues = useMemo(() => {\n    return (\n      hasCustomFieldValues ||\n      hasScrapedValues([\n        name,\n        disambiguation,\n        aliases,\n        birthdate,\n        deathDate,\n        ethnicity,\n        country,\n        hairColor,\n        eyeColor,\n        height,\n        weight,\n        penisLength,\n        measurements,\n        fakeTits,\n        careerStart,\n        careerEnd,\n        tattoos,\n        piercings,\n        urls,\n        gender,\n        circumcised,\n        details,\n        tags,\n        image,\n      ])\n    );\n  }, [\n    name,\n    disambiguation,\n    aliases,\n    birthdate,\n    deathDate,\n    ethnicity,\n    country,\n    hairColor,\n    eyeColor,\n    height,\n    weight,\n    penisLength,\n    measurements,\n    fakeTits,\n    careerStart,\n    careerEnd,\n    tattoos,\n    piercings,\n    urls,\n    gender,\n    circumcised,\n    details,\n    tags,\n    image,\n    hasCustomFieldValues,\n  ]);\n\n  function renderScrapeRows() {\n    if (loading) {\n      return (\n        <div>\n          <LoadingIndicator />\n        </div>\n      );\n    }\n\n    if (!hasValues) {\n      return (\n        <div>\n          <FormattedMessage id=\"dialogs.merge.empty_results\" />\n        </div>\n      );\n    }\n\n    return (\n      <>\n        <ScrapedInputGroupRow\n          field=\"name\"\n          title={intl.formatMessage({ id: \"name\" })}\n          result={name}\n          onChange={(value) => setName(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"disambiguation\"\n          title={intl.formatMessage({ id: \"disambiguation\" })}\n          result={disambiguation}\n          onChange={(value) => setDisambiguation(value)}\n        />\n        <ScrapedStringListRow\n          field=\"aliases\"\n          title={intl.formatMessage({ id: \"aliases\" })}\n          result={aliases}\n          onChange={(value) => setAliases(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"birthdate\"\n          title={intl.formatMessage({ id: \"birthdate\" })}\n          result={birthdate}\n          onChange={(value) => setBirthdate(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"death_date\"\n          title={intl.formatMessage({ id: \"death_date\" })}\n          result={deathDate}\n          onChange={(value) => setDeathDate(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"ethnicity\"\n          title={intl.formatMessage({ id: \"ethnicity\" })}\n          result={ethnicity}\n          onChange={(value) => setEthnicity(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"country\"\n          title={intl.formatMessage({ id: \"country\" })}\n          result={country}\n          onChange={(value) => setCountry(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"hair_color\"\n          title={intl.formatMessage({ id: \"hair_color\" })}\n          result={hairColor}\n          onChange={(value) => setHairColor(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"eye_color\"\n          title={intl.formatMessage({ id: \"eye_color\" })}\n          result={eyeColor}\n          onChange={(value) => setEyeColor(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"height\"\n          title={intl.formatMessage({ id: \"height\" })}\n          result={height}\n          onChange={(value) => setHeight(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"weight\"\n          title={intl.formatMessage({ id: \"weight\" })}\n          result={weight}\n          onChange={(value) => setWeight(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"penis_length\"\n          title={intl.formatMessage({ id: \"penis_length\" })}\n          result={penisLength}\n          onChange={(value) => setPenisLength(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"measurements\"\n          title={intl.formatMessage({ id: \"measurements\" })}\n          result={measurements}\n          onChange={(value) => setMeasurements(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"fake_tits\"\n          title={intl.formatMessage({ id: \"fake_tits\" })}\n          result={fakeTits}\n          onChange={(value) => setFakeTits(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"career_start\"\n          title={intl.formatMessage({ id: \"career_start\" })}\n          result={careerStart}\n          onChange={(value) => setCareerStart(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"career_end\"\n          title={intl.formatMessage({ id: \"career_end\" })}\n          result={careerEnd}\n          onChange={(value) => setCareerEnd(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"tattoos\"\n          title={intl.formatMessage({ id: \"tattoos\" })}\n          result={tattoos}\n          onChange={(value) => setTattoos(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"piercings\"\n          title={intl.formatMessage({ id: \"piercings\" })}\n          result={piercings}\n          onChange={(value) => setPiercings(value)}\n        />\n        <ScrapedStringListRow\n          field=\"urls\"\n          title={intl.formatMessage({ id: \"urls\" })}\n          result={urls}\n          onChange={(value) => setURLs(value)}\n        />\n        {renderScrapedGenderRow(\n          intl.formatMessage({ id: \"gender\" }),\n          gender,\n          (value) => setGender(value)\n        )}\n        {renderScrapedCircumcisedRow(\n          intl.formatMessage({ id: \"circumcised\" }),\n          circumcised,\n          (value) => setCircumcised(value)\n        )}\n        <ScrapedTagsRow\n          field=\"tags\"\n          title={intl.formatMessage({ id: \"tags\" })}\n          result={tags}\n          onChange={(value) => setTags(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"details\"\n          title={intl.formatMessage({ id: \"details\" })}\n          result={details}\n          onChange={(value) => setDetails(value)}\n        />\n        <ScrapeDialogRow\n          field=\"stash_ids\"\n          title={intl.formatMessage({ id: \"stash_id\" })}\n          result={stashIDs}\n          originalField={\n            <StashIDsField values={stashIDs?.originalValue ?? []} />\n          }\n          newField={<StashIDsField values={stashIDs?.newValue ?? []} />}\n          onChange={(value) => setStashIDs(value)}\n          alwaysShow={\n            !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length\n          }\n        />\n        <ScrapedImageRow\n          field=\"image\"\n          title={intl.formatMessage({ id: \"performer_image\" })}\n          className=\"performer-image\"\n          result={image}\n          onChange={(value) => setImage(value)}\n        />\n        {hasCustomFieldValues && (\n          <ScrapedCustomFieldRows\n            results={customFields}\n            onChange={(newCustomFields) => setCustomFields(newCustomFields)}\n          />\n        )}\n      </>\n    );\n  }\n\n  function createValues(): MergeOptions {\n    // only set the cover image if it's different from the existing cover image\n    const coverImage = image.useNewValue ? image.getNewValue() : undefined;\n\n    return {\n      values: {\n        id: dest.id,\n        name: name.getNewValue(),\n        disambiguation: disambiguation.getNewValue(),\n        alias_list: aliases\n          .getNewValue()\n          ?.map((s) => s.trim())\n          .filter((s) => s.length > 0),\n        birthdate: birthdate.getNewValue(),\n        death_date: deathDate.getNewValue(),\n        ethnicity: ethnicity.getNewValue(),\n        country: country.getNewValue(),\n        hair_color: hairColor.getNewValue(),\n        eye_color: eyeColor.getNewValue(),\n        height_cm: height.getNewValue()\n          ? parseFloat(height.getNewValue()!)\n          : undefined,\n        weight: weight.getNewValue()\n          ? parseFloat(weight.getNewValue()!)\n          : undefined,\n        penis_length: penisLength.getNewValue()\n          ? parseFloat(penisLength.getNewValue()!)\n          : undefined,\n        measurements: measurements.getNewValue(),\n        fake_tits: fakeTits.getNewValue(),\n        career_start: careerStart.getNewValue(),\n        career_end: careerEnd.getNewValue(),\n        tattoos: tattoos.getNewValue(),\n        piercings: piercings.getNewValue(),\n        urls: urls.getNewValue(),\n        gender: stringToGender(gender.getNewValue()),\n        circumcised: stringToCircumcised(circumcised.getNewValue()),\n        tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),\n        details: details.getNewValue(),\n        stash_ids: stashIDs.getNewValue(),\n        image: coverImage,\n        custom_fields: {\n          partial: Object.fromEntries(\n            Array.from(customFields.entries()).flatMap(([field, v]) =>\n              v.useNewValue ? [[field, v.getNewValue()]] : []\n            )\n          ),\n        },\n      },\n    };\n  }\n\n  const dialogTitle = intl.formatMessage({\n    id: \"actions.merge\",\n  });\n\n  const destinationLabel = !hasValues\n    ? \"\"\n    : intl.formatMessage({ id: \"dialogs.merge.destination\" });\n  const sourceLabel = !hasValues\n    ? \"\"\n    : intl.formatMessage({ id: \"dialogs.merge.combined\" });\n\n  return (\n    <ScrapeDialog\n      className=\"performer-merge-dialog\"\n      title={dialogTitle}\n      existingLabel={destinationLabel}\n      scrapedLabel={sourceLabel}\n      onClose={(apply) => {\n        if (!apply) {\n          onClose();\n        } else {\n          onClose(createValues());\n        }\n      }}\n    >\n      {renderScrapeRows()}\n    </ScrapeDialog>\n  );\n};\n\ninterface IPerformerMergeModalProps {\n  show: boolean;\n  onClose: (mergedId?: string) => void;\n  performers: GQL.SelectPerformerDataFragment[];\n}\n\nexport const PerformerMergeModal: React.FC<IPerformerMergeModalProps> = ({\n  show,\n  onClose,\n  performers,\n}) => {\n  const [sourcePerformers, setSourcePerformers] = useState<\n    GQL.SelectPerformerDataFragment[]\n  >([]);\n  const [destPerformer, setDestPerformer] = useState<\n    GQL.SelectPerformerDataFragment[]\n  >([]);\n\n  const [loadedSources, setLoadedSources] = useState<\n    GQL.PerformerDataFragment[]\n  >([]);\n  const [loadedDest, setLoadedDest] = useState<GQL.PerformerDataFragment>();\n\n  const [running, setRunning] = useState(false);\n  const [secondStep, setSecondStep] = useState(false);\n\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const title = intl.formatMessage({\n    id: \"actions.merge\",\n  });\n\n  useEffect(() => {\n    if (performers.length > 0) {\n      // set the first performer as the destination, others as source\n      setDestPerformer([performers[0]]);\n\n      if (performers.length > 1) {\n        setSourcePerformers(performers.slice(1));\n      }\n    }\n  }, [performers]);\n\n  async function loadPerformers() {\n    const performerIDs = sourcePerformers.map((s) => parseInt(s.id));\n    performerIDs.push(parseInt(destPerformer[0].id));\n    const query = await queryFindPerformersByID(performerIDs);\n    const { performers: loadedPerformers } = query.data.findPerformers;\n\n    setLoadedDest(loadedPerformers.find((s) => s.id === destPerformer[0].id));\n    setLoadedSources(\n      loadedPerformers.filter((s) => s.id !== destPerformer[0].id)\n    );\n    setSecondStep(true);\n  }\n\n  async function onMerge(options: MergeOptions) {\n    const { values } = options;\n    try {\n      setRunning(true);\n      const result = await mutatePerformerMerge(\n        destPerformer[0].id,\n        sourcePerformers.map((s) => s.id),\n        values\n      );\n      if (result.data?.performerMerge) {\n        Toast.success(intl.formatMessage({ id: \"toast.merged_performers\" }));\n        onClose(destPerformer[0].id);\n      }\n      onClose();\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setRunning(false);\n    }\n  }\n\n  function canMerge() {\n    return sourcePerformers.length > 0 && destPerformer.length !== 0;\n  }\n\n  function switchPerformers() {\n    if (sourcePerformers.length && destPerformer.length) {\n      const newDest = sourcePerformers[0];\n      setSourcePerformers([...sourcePerformers.slice(1), destPerformer[0]]);\n      setDestPerformer([newDest]);\n    }\n  }\n\n  if (secondStep && destPerformer.length > 0) {\n    return (\n      <PerformerMergeDetails\n        sources={loadedSources}\n        dest={loadedDest!}\n        onClose={(values) => {\n          setSecondStep(false);\n          if (values) {\n            onMerge(values);\n          } else {\n            onClose();\n          }\n        }}\n      />\n    );\n  }\n\n  return (\n    <ModalComponent\n      dialogClassName=\"performer-merge-dialog\"\n      show={show}\n      header={title}\n      icon={faSignInAlt}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.next_action\" }),\n        onClick: () => loadPerformers(),\n      }}\n      disabled={!canMerge()}\n      cancel={{\n        variant: \"secondary\",\n        onClick: () => onClose(),\n      }}\n      isRunning={running}\n    >\n      <div className=\"form-container row px-3\">\n        <div className=\"col-12 col-lg-6 col-xl-12\">\n          <Form.Group controlId=\"source\" as={Row}>\n            {FormUtils.renderLabel({\n              title: intl.formatMessage({ id: \"dialogs.merge.source\" }),\n              labelProps: {\n                column: true,\n                sm: 3,\n                xl: 12,\n              },\n            })}\n            <Col sm={9} xl={12}>\n              <PerformerSelect\n                isMulti\n                onSelect={(items) => setSourcePerformers(items)}\n                values={sourcePerformers}\n                menuPortalTarget={document.body}\n              />\n            </Col>\n          </Form.Group>\n          <Form.Group\n            controlId=\"switch\"\n            as={Row}\n            className=\"justify-content-center\"\n          >\n            <Button\n              variant=\"secondary\"\n              onClick={() => switchPerformers()}\n              disabled={!sourcePerformers.length || !destPerformer.length}\n              title={intl.formatMessage({ id: \"actions.swap\" })}\n            >\n              <Icon className=\"fa-fw\" icon={faExchangeAlt} />\n            </Button>\n          </Form.Group>\n          <Form.Group controlId=\"destination\" as={Row}>\n            {FormUtils.renderLabel({\n              title: intl.formatMessage({\n                id: \"dialogs.merge.destination\",\n              }),\n              labelProps: {\n                column: true,\n                sm: 3,\n                xl: 12,\n              },\n            })}\n            <Col sm={9} xl={12}>\n              <PerformerSelect\n                onSelect={(items) => setDestPerformer(items)}\n                values={destPerformer}\n                menuPortalTarget={document.body}\n              />\n            </Col>\n          </Form.Group>\n        </div>\n      </div>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerPopover.tsx",
    "content": "import React from \"react\";\nimport { ErrorMessage } from \"../Shared/ErrorMessage\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { useFindPerformer } from \"../../core/StashService\";\nimport { PerformerCard } from \"./PerformerCard\";\nimport { useConfigurationContext } from \"../../hooks/Config\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\n\ninterface IPeromerPopoverCardProps {\n  id: string;\n}\n\nexport const PerformerPopoverCard: React.FC<IPeromerPopoverCardProps> = ({\n  id,\n}) => {\n  const { data, loading, error } = useFindPerformer(id);\n\n  if (loading)\n    return (\n      <div className=\"tag-popover-card-placeholder\">\n        <LoadingIndicator card={true} message={\"\"} />\n      </div>\n    );\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findPerformer)\n    return <ErrorMessage error={`No tag found with id ${id}.`} />;\n\n  const performer = data.findPerformer;\n\n  return (\n    <div className=\"tag-popover-card\">\n      <PerformerCard performer={performer} zoomIndex={0} />\n    </div>\n  );\n};\n\ninterface IPeroformerPopoverProps {\n  id: string;\n  hide?: boolean;\n  placement?: Placement;\n  target?: React.RefObject<HTMLElement>;\n}\n\nexport const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({\n  id,\n  hide,\n  children,\n  placement = \"top\",\n  target,\n}) => {\n  const { configuration: config } = useConfigurationContext();\n\n  const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true;\n\n  if (hide || !showPerformerCardOnHover) {\n    return <>{children}</>;\n  }\n\n  return (\n    <HoverPopover\n      target={target}\n      placement={placement}\n      enterDelay={500}\n      leaveDelay={100}\n      content={<PerformerPopoverCard id={id} />}\n    >\n      {children}\n    </HoverPopover>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx",
    "content": "import React from \"react\";\nimport { useFindPerformers } from \"src/core/StashService\";\nimport { PerformerCard } from \"./PerformerCard\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { PatchComponent } from \"src/patch\";\nimport { FilteredRecommendationRow } from \"../FrontPage/FilteredRecommendationRow\";\n\ninterface IProps {\n  isTouch: boolean;\n  filter: ListFilterModel;\n  header: string;\n}\n\nexport const PerformerRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"PerformerRecommendationRow\",\n  (props) => {\n    const result = useFindPerformers(props.filter);\n    const count = result.data?.findPerformers.count ?? 0;\n\n    return (\n      <FilteredRecommendationRow\n        className=\"performer-recommendations\"\n        heading={props.header}\n        url={`/performers?${props.filter.makeQueryParameters()}`}\n        count={count}\n        loading={result.loading}\n        isTouch={props.isTouch}\n        filter={props.filter}\n      >\n        {result.loading\n          ? [...Array(props.filter.itemsPerPage)].map((i) => (\n              <div\n                key={`_${i}`}\n                className=\"performer-skeleton skeleton-card\"\n              ></div>\n            ))\n          : result.data?.findPerformers.performers.map((p) => (\n              <PerformerCard key={p.id} performer={p} />\n            ))}\n      </FilteredRecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/PerformerSelect.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport {\n  OptionProps,\n  components as reactSelectComponents,\n  MultiValueGenericProps,\n  SingleValueProps,\n} from \"react-select\";\nimport cx from \"classnames\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  usePerformerCreate,\n  queryFindPerformersByIDForSelect,\n  queryFindPerformersForSelect,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useIntl } from \"react-intl\";\nimport { defaultMaxOptionsShown } from \"src/core/config\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  FilterSelectComponent,\n  IFilterIDProps,\n  IFilterProps,\n  IFilterValueProps,\n  Option as SelectOption,\n  toOption,\n} from \"../Shared/FilterSelect\";\nimport { useCompare } from \"src/hooks/state\";\nimport { Link } from \"react-router-dom\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { PatchComponent, PatchFunction } from \"src/patch\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport TextUtils from \"src/utils/text\";\nimport { PerformerPopover } from \"./PerformerPopover\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { isUUID } from \"src/utils/stashIds\";\nimport { filterByStashID } from \"src/models/list-filter/utils\";\n\nexport type SelectObject = {\n  id: string;\n  name?: string | null;\n  title?: string | null;\n};\n\nexport type Performer = Pick<\n  GQL.Performer,\n  | \"id\"\n  | \"name\"\n  | \"alias_list\"\n  | \"disambiguation\"\n  | \"image_path\"\n  | \"birthdate\"\n  | \"death_date\"\n>;\ntype Option = SelectOption<Performer>;\n\ntype FindPerformersResult = Awaited<\n  ReturnType<typeof queryFindPerformersForSelect>\n>[\"data\"][\"findPerformers\"][\"performers\"];\n\nfunction sortPerformersByRelevance(\n  input: string,\n  performers: FindPerformersResult\n) {\n  return sortByRelevance(\n    input,\n    performers,\n    (p) => p.name,\n    (p) => p.alias_list\n  );\n}\n\nconst performerSelectSort = PatchFunction(\n  \"PerformerSelect.sort\",\n  sortPerformersByRelevance\n);\n\nconst _PerformerSelect: React.FC<\n  IFilterProps &\n    IFilterValueProps<Performer> & {\n      ageFromDate?: string | null;\n      hoverPlacementLabel?: Placement;\n      hoverPlacementOptions?: Placement;\n    }\n> = (props) => {\n  const [createPerformer] = usePerformerCreate();\n\n  const { configuration } = useConfigurationContext();\n  const intl = useIntl();\n  const maxOptionsShown =\n    configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown;\n  const defaultCreatable =\n    !configuration?.interface.disableDropdownCreate.performer;\n\n  async function loadPerformers(input: string): Promise<Option[]> {\n    const filter = new ListFilterModel(GQL.FilterMode.Performers);\n    filter.currentPage = 1;\n    filter.itemsPerPage = maxOptionsShown;\n    filter.sortBy = \"name\";\n    filter.sortDirection = GQL.SortDirectionEnum.Asc;\n\n    // If the input looks like a GUID, search for stash_id first and return match immediately\n    if (isUUID(input)) {\n      filterByStashID(filter, input);\n\n      const query = await queryFindPerformersForSelect(filter);\n      const matches = query.data.findPerformers.performers.slice();\n      if (matches.length > 0) {\n        // Matches found, return them immediately.\n        return matches.map(toOption);\n      }\n      // If no stash_id matches found, continue with standard name/alias search.\n      filter.criteria = []; // Clear stash_id criterion to search by name/alias below.\n    }\n\n    filter.searchTerm = input;\n\n    const query = await queryFindPerformersForSelect(filter);\n    return performerSelectSort(\n      input,\n      query.data.findPerformers.performers.slice()\n    ).map(toOption);\n  }\n\n  const PerformerOption: React.FC<OptionProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    let { name } = object;\n\n    // if name does not match the input value but an alias does, show the alias\n    const { inputValue } = optionProps.selectProps;\n    let alias: string | undefined = \"\";\n    if (!name.toLowerCase().includes(inputValue.toLowerCase())) {\n      alias = object.alias_list?.find((a) =>\n        a.toLowerCase().includes(inputValue.toLowerCase())\n      );\n    }\n\n    const sceneAge = TextUtils.age(object.birthdate, props.ageFromDate);\n\n    const age =\n      sceneAge < 18\n        ? TextUtils.age(object.birthdate, object.death_date)\n        : sceneAge;\n\n    const ageL10nId =\n      !props.ageFromDate || sceneAge < 18\n        ? \"media_info.performer_card.age\"\n        : \"age_on_date\";\n\n    const ageL10String = intl.formatMessage({\n      id: \"years_old\",\n      defaultMessage: \"years old\",\n    });\n    const ageString = intl.formatMessage(\n      { id: ageL10nId },\n      { age, years_old: ageL10String }\n    );\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <span className=\"performer-select-option\">\n          <span className=\"performer-select-row\">\n            <Link\n              to={`/performers/${object.id}`}\n              target=\"_blank\"\n              className=\"performer-select-image-link\"\n            >\n              <img\n                className=\"performer-select-image\"\n                src={object.image_path ?? \"\"}\n                loading=\"lazy\"\n              />\n            </Link>\n            <span className=\"performer-select-details\">\n              <TruncatedText\n                className=\"performer-select-name\"\n                text={\n                  <span>\n                    {name}\n                    {alias && (\n                      <span className=\"performer-select-alias\">\n                        &nbsp;({alias})\n                      </span>\n                    )}\n                  </span>\n                }\n                lineCount={1}\n              />\n\n              {object.disambiguation && (\n                <span className=\"performer-select-disambiguation\">\n                  {object.disambiguation}\n                </span>\n              )}\n\n              {object.birthdate && (\n                <span className=\"performer-select-birthdate\">\n                  {object.birthdate}\n                  <span className=\"performer-select-age\">{` (${ageString})`}</span>\n                </span>\n              )}\n            </span>\n          </span>\n        </span>\n      ),\n    };\n\n    return <reactSelectComponents.Option {...thisOptionProps} />;\n  };\n\n  const PerformerMultiValueLabel: React.FC<\n    MultiValueGenericProps<Option, boolean>\n  > = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <PerformerPopover\n          id={object.id}\n          placement={props.hoverPlacementLabel ?? \"top\"}\n        >\n          <span className=\"performer-select-value\">\n            <span>{object.name}</span>\n            {object.disambiguation && (\n              <span className=\"performer-disambiguation\">{` (${object.disambiguation})`}</span>\n            )}\n          </span>\n        </PerformerPopover>\n      ),\n    };\n\n    return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;\n  };\n\n  const PerformerValueLabel: React.FC<SingleValueProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <span className=\"performer-select-value\">\n          {object.name}\n          {object.disambiguation && (\n            <span className=\"performer-disambiguation\">{` (${object.disambiguation})`}</span>\n          )}\n        </span>\n      ),\n    };\n\n    return <reactSelectComponents.SingleValue {...thisOptionProps} />;\n  };\n\n  const onCreate = async (name: string) => {\n    const result = await createPerformer({\n      variables: { input: { name } },\n    });\n    return {\n      value: result.data!.performerCreate!.id,\n      item: result.data!.performerCreate!,\n      message: \"Created performer\",\n    };\n  };\n\n  const getNamedObject = (id: string, name: string) => {\n    return {\n      id,\n      name,\n      alias_list: [],\n    };\n  };\n\n  const isValidNewOption = (inputValue: string, options: Performer[]) => {\n    if (!inputValue) {\n      return false;\n    }\n\n    if (\n      options.some((o) => {\n        return (\n          o.name.toLowerCase() === inputValue.toLowerCase() ||\n          o.alias_list?.some(\n            (a) => a.toLowerCase() === inputValue.toLowerCase()\n          )\n        );\n      })\n    ) {\n      return false;\n    }\n\n    return true;\n  };\n\n  return (\n    <FilterSelectComponent<Performer, boolean>\n      {...props}\n      className={cx(\n        \"performer-select\",\n        {\n          \"performer-select-active\": props.active,\n        },\n        props.className\n      )}\n      loadOptions={loadPerformers}\n      getNamedObject={getNamedObject}\n      isValidNewOption={isValidNewOption}\n      components={{\n        Option: PerformerOption,\n        MultiValueLabel: PerformerMultiValueLabel,\n        SingleValue: PerformerValueLabel,\n      }}\n      isMulti={props.isMulti ?? false}\n      creatable={props.creatable ?? defaultCreatable}\n      onCreate={onCreate}\n      placeholder={\n        props.noSelectionString ??\n        intl.formatMessage(\n          { id: \"actions.select_entity\" },\n          {\n            entityType: intl.formatMessage({\n              id: props.isMulti ? \"performers\" : \"performer\",\n            }),\n          }\n        )\n      }\n    />\n  );\n};\n\nexport const PerformerSelect = PatchComponent(\n  \"PerformerSelect\",\n  _PerformerSelect\n);\n\nconst _PerformerIDSelect: React.FC<IFilterProps & IFilterIDProps<Performer>> = (\n  props\n) => {\n  const { ids, onSelect: onSelectValues } = props;\n\n  const [values, setValues] = useState<Performer[]>([]);\n  const idsChanged = useCompare(ids);\n\n  function onSelect(items: Performer[]) {\n    setValues(items);\n    onSelectValues?.(items);\n  }\n\n  async function loadObjectsByID(idsToLoad: string[]): Promise<Performer[]> {\n    const query = await queryFindPerformersByIDForSelect(idsToLoad);\n    const { performers: loadedPerformers } = query.data.findPerformers;\n\n    return loadedPerformers;\n  }\n\n  useEffect(() => {\n    if (!idsChanged) {\n      return;\n    }\n\n    if (!ids || ids?.length === 0) {\n      setValues([]);\n      return;\n    }\n\n    // load the values if we have ids and they haven't been loaded yet\n    const filteredValues = values.filter((v) => ids.includes(v.id.toString()));\n    if (filteredValues.length === ids.length) {\n      return;\n    }\n\n    const load = async () => {\n      const items = await loadObjectsByID(ids);\n      setValues(items);\n    };\n\n    load();\n  }, [ids, idsChanged, values]);\n\n  return <PerformerSelect {...props} values={values} onSelect={onSelect} />;\n};\n\nexport const PerformerIDSelect = PatchComponent(\n  \"PerformerIDSelect\",\n  _PerformerIDSelect\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/Performers.tsx",
    "content": "import React from \"react\";\nimport { Route, Switch } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport { useTitleProps } from \"src/hooks/title\";\nimport Performer from \"./PerformerDetails/Performer\";\nimport PerformerCreate from \"./PerformerDetails/PerformerCreate\";\nimport { FilteredPerformerList } from \"./PerformerList\";\nimport { View } from \"../List/views\";\n\nconst Performers: React.FC = () => {\n  return <FilteredPerformerList view={View.Performers} />;\n};\n\nconst PerformerRoutes: React.FC = () => {\n  const titleProps = useTitleProps({ id: \"performers\" });\n  return (\n    <>\n      <Helmet {...titleProps} />\n      <Switch>\n        <Route exact path=\"/performers\" component={Performers} />\n        <Route path=\"/performers/new\" component={PerformerCreate} />\n        <Route path=\"/performers/:id/:tab?\" component={Performer} />\n      </Switch>\n    </>\n  );\n};\n\nexport default PerformerRoutes;\n"
  },
  {
    "path": "ui/v2.5/src/components/Performers/styles.scss",
    "content": "#performer-page {\n  .performer-image-container {\n    .btn {\n      box-shadow: none;\n    }\n\n    .performer {\n      max-height: calc(100vh - 6rem);\n      max-width: 100%;\n    }\n  }\n\n  .content-container {\n    padding-bottom: 10px;\n  }\n\n  .performer-head {\n    display: inline-block;\n    vertical-align: top;\n\n    .name-icons {\n      .not-favorite {\n        color: rgba(191, 204, 214, 0.5);\n      }\n\n      .favorite {\n        color: #ff7373;\n      }\n\n      .instagram {\n        color: pink;\n      }\n    }\n\n    .rating-number .form-control {\n      width: inherit;\n    }\n\n    // The following min-width declarations prevent\n    // the performer's O-Count from moving around\n    // when hovering over rating stars\n    .rating-stars-precision-full .star-rating-number {\n      min-width: 0.75rem;\n    }\n\n    .rating-stars-precision-half .star-rating-number,\n    .rating-stars-precision-tenth .star-rating-number {\n      min-width: 1.45rem;\n    }\n\n    .rating-stars-precision-quarter .star-rating-number {\n      min-width: 2rem;\n    }\n  }\n\n  .alias {\n    font-weight: bold;\n  }\n\n  .quality-group {\n    display: inline-flex;\n    margin-top: 0.25rem;\n  }\n\n  // the detail element ids are the same as field type name\n  // which don't follow the correct convention\n  /* stylelint-disable selector-class-pattern */\n  .collapsed {\n    .detail-item.tattoos,\n    .detail-item.piercings,\n    .detail-item.career_start,\n    .detail-item.career_end,\n    .detail-item.details,\n    .detail-item.tags,\n    .detail-item.stash_ids {\n      display: none;\n    }\n  }\n\n  .detail-group .custom-fields .collapse-button {\n    display: table-cell;\n    font-weight: 700;\n    padding-left: 0;\n  }\n  /* stylelint-enable selector-class-pattern */\n}\n\n.new-view {\n  margin-bottom: 2rem;\n\n  .photo {\n    padding: 1rem 1rem 1rem 2rem;\n    width: 100%;\n  }\n}\n\n.performer-card {\n  width: 20rem;\n\n  @media (max-width: 576px) {\n    width: 100%;\n  }\n\n  .thumbnail-section {\n    position: relative;\n\n    .instagram {\n      color: pink;\n    }\n  }\n\n  &-image {\n    aspect-ratio: 2/3;\n    min-width: 11.25rem;\n    object-fit: cover;\n    object-position: top;\n    width: 100%;\n  }\n\n  .fi {\n    bottom: 1rem;\n    filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));\n    height: 2rem;\n    position: absolute;\n    right: 1rem;\n    width: 3rem;\n  }\n\n  button.btn.favorite-button {\n    padding: 0;\n    position: absolute;\n    right: 5px;\n    top: 10px;\n\n    svg.fa-icon {\n      margin-left: 0.4rem;\n      margin-right: 0.4rem;\n    }\n  }\n\n  &:hover button.btn.favorite-button.not-favorite {\n    opacity: 1;\n  }\n\n  &__age {\n    color: $muted-gray;\n  }\n\n  // allow country string to be shown, but disable by default\n  &__country-string {\n    display: none;\n  }\n}\n\n.card {\n  &.performer-card {\n    padding: 0 0 1rem 0;\n  }\n}\n\n.scrape-dialog .performer-image {\n  display: block;\n  margin-bottom: 10px;\n  margin-top: 10px;\n  max-width: 100%;\n}\n\n#performer-scraper-popover {\n  z-index: 1;\n}\n\n.PerformerScrapeModal {\n  &-list {\n    list-style: none;\n    max-height: 50vh;\n    overflow-x: hidden;\n    overflow-y: auto;\n    padding-inline-start: 0;\n\n    li {\n      cursor: pointer;\n    }\n  }\n}\n\n.flex-aligned {\n  align-items: center;\n  column-gap: 0.5rem;\n  display: flex;\n}\n\n.gender-icon {\n  &[data-gender=\"FEMALE\"],\n  &[data-gender=\"TRANSGENDER_FEMALE\"] {\n    color: #f38cac;\n  }\n\n  &[data-gender=\"MALE\"],\n  &[data-gender=\"TRANSGENDER_MALE\"] {\n    color: #89cff0;\n  }\n\n  &[data-gender=\"NON_BINARY\"],\n  &[data-gender=\"INTERSEX\"] {\n    color: #c8a2c8;\n  }\n}\n\n.performer-height .height-imperial,\n.performer-weight .weight-imperial,\n.performer-penis-length .penis-length-imperial {\n  &::before {\n    content: \" (\";\n  }\n\n  &::after {\n    content: \")\";\n  }\n}\n\n.penis-circumcised {\n  &::before {\n    content: \" \";\n  }\n}\n\n.favourite-data .favorite {\n  color: #ff7373;\n}\n\n.performer-table .height-imperial,\n.performer-table .weight-imperial,\n.performer-table .penis-length-imperial,\n.performer-disambiguation {\n  color: $text-muted;\n  font-size: 0.875em;\n}\n\n.performer-table .age-data span {\n  border-bottom: 1px dotted #f5f8fa;\n}\n\n.performer-result .performer-details > span {\n  &::after {\n    content: \" • \";\n  }\n\n  &:last-child::after {\n    content: \"\";\n  }\n}\n\n.performer-select-value .performer-disambiguation {\n  color: initial;\n}\n\n.performer-select-option {\n  .performer-select-row {\n    align-items: center;\n    display: flex;\n    width: 100%;\n\n    .performer-select-image {\n      margin-right: 0.4em;\n      max-height: 50px;\n      max-width: 50px;\n    }\n\n    .performer-select-details {\n      display: flex;\n      flex-direction: column;\n      justify-content: flex-start;\n      max-height: 4.1rem;\n      overflow: hidden;\n\n      .performer-select-name {\n        flex-shrink: 0;\n        white-space: pre-wrap;\n        word-break: break-all;\n\n        .performer-select-alias {\n          font-size: 0.8rem;\n          font-weight: bold;\n        }\n      }\n\n      .performer-select-disambiguation,\n      .performer-select-birthdate {\n        color: $text-muted;\n        flex-shrink: 0;\n        font-size: 0.9rem;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n      }\n    }\n  }\n}\n\n.edit-performers-dialog .modal-body {\n  max-height: calc(100vh - 12rem);\n  overflow-y: auto;\n  padding-right: 1.5rem;\n}\n\n.performer-merge-dialog .custom-field {\n  // ensure we don't catch the destination/source labels\n  & > .form-label,\n  .form-control {\n    font-family: \"Courier New\", Courier, monospace;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport {\n  Button,\n  ButtonGroup,\n  Card,\n  Col,\n  Dropdown,\n  Form,\n  OverlayTrigger,\n  Row,\n  Table,\n  Tooltip,\n} from \"react-bootstrap\";\nimport { Link, useHistory } from \"react-router-dom\";\nimport { FormattedMessage, FormattedNumber, useIntl } from \"react-intl\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { ErrorMessage } from \"../Shared/ErrorMessage\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { Icon } from \"../Shared/Icon\";\nimport {\n  GalleryLink,\n  GroupLink,\n  SceneMarkerLink,\n  TagLink,\n} from \"../Shared/TagLink\";\nimport { SweatDrops } from \"../Shared/SweatDrops\";\nimport { Pagination } from \"src/components/List/Pagination\";\nimport TextUtils from \"src/utils/text\";\nimport { DeleteScenesDialog } from \"src/components/Scenes/DeleteScenesDialog\";\nimport { EditScenesDialog } from \"../Scenes/EditScenesDialog\";\nimport { PerformerPopoverButton } from \"../Shared/PerformerPopoverButton\";\nimport {\n  faBox,\n  faExclamationTriangle,\n  faFileAlt,\n  faFilm,\n  faImages,\n  faMapMarkerAlt,\n  faPencilAlt,\n  faTag,\n  faTrash,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { SceneMergeModal } from \"../Scenes/SceneMergeDialog\";\nimport { objectTitle } from \"src/core/files\";\nimport { FileSize } from \"../Shared/FileSize\";\n\nconst CLASSNAME = \"duplicate-checker\";\n\nconst defaultDurationDiff = \"1\";\n\nexport const SceneDuplicateChecker: React.FC = () => {\n  const intl = useIntl();\n  const history = useHistory();\n  const query = new URLSearchParams(history.location.search);\n  const currentPage = Number.parseInt(query.get(\"page\") ?? \"1\", 10);\n  const pageSize = Number.parseInt(query.get(\"size\") ?? \"20\", 10);\n  const hashDistance = Number.parseInt(query.get(\"distance\") ?? \"0\", 10);\n  const durationDiff = Number.parseFloat(\n    query.get(\"durationDiff\") ?? defaultDurationDiff\n  );\n\n  const [currentPageSize, setCurrentPageSize] = useState(pageSize);\n  const [isMultiDelete, setIsMultiDelete] = useState(false);\n  const [deletingScenes, setDeletingScenes] = useState(false);\n  const [editingScenes, setEditingScenes] = useState(false);\n  const [chkSafeSelect, setChkSafeSelect] = useState(true);\n\n  const [checkedScenes, setCheckedScenes] = useState<Record<string, boolean>>(\n    {}\n  );\n\n  const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({\n    fetchPolicy: \"no-cache\",\n    variables: {\n      distance: hashDistance,\n      duration_diff: durationDiff,\n    },\n  });\n\n  const getGroupTotalSize = (group: GQL.SlimSceneDataFragment[]) => {\n    // Sum all file sizes across all scenes in the group\n    return group.reduce((groupTotal, scene) => {\n      const sceneTotal = scene.files.reduce(\n        (fileTotal, file) => fileTotal + file.size,\n        0\n      );\n      return groupTotal + sceneTotal;\n    }, 0);\n  };\n\n  const scenes = useMemo(() => {\n    const groups = data?.findDuplicateScenes ?? [];\n    // Sort by total file size descending (largest groups first)\n    return [...groups].sort((a, b) => {\n      return getGroupTotalSize(b) - getGroupTotalSize(a);\n    });\n  }, [data?.findDuplicateScenes]);\n\n  const { data: missingPhash } = GQL.useFindScenesQuery({\n    variables: {\n      filter: {\n        per_page: 0,\n      },\n      scene_filter: {\n        is_missing: \"phash\",\n        file_count: {\n          modifier: GQL.CriterionModifier.GreaterThan,\n          value: 0,\n        },\n      },\n    },\n  });\n\n  const [selectedScenes, setSelectedScenes] = useState<\n    GQL.SlimSceneDataFragment[] | null\n  >(null);\n\n  const [mergeScenes, setMergeScenes] =\n    useState<{ id: string; title: string }[]>();\n\n  const pageOptions = useMemo(() => {\n    const pageSizes = [\n      10, 20, 30, 40, 50, 100, 150, 200, 250, 500, 750, 1000, 1250, 1500,\n    ];\n\n    const filteredSizes = pageSizes.filter((s, i) => {\n      return scenes.length > s || i == 0 || scenes.length > pageSizes[i - 1];\n    });\n\n    return filteredSizes.map((size) => {\n      return (\n        <option key={size} value={size}>\n          {size}\n        </option>\n      );\n    });\n  }, [scenes.length]);\n\n  if (loading) return <LoadingIndicator />;\n  if (!data) return <ErrorMessage error=\"Error searching for duplicates.\" />;\n\n  const filteredScenes = scenes.slice(\n    (currentPage - 1) * pageSize,\n    currentPage * pageSize\n  );\n  const checkCount = Object.keys(checkedScenes).filter(\n    (id) => checkedScenes[id]\n  ).length;\n\n  const setQuery = (q: Record<string, string | number | undefined>) => {\n    const newQuery = new URLSearchParams(query);\n    for (const key of Object.keys(q)) {\n      const value = q[key];\n      if (value !== undefined) {\n        newQuery.set(key, String(value));\n      } else {\n        newQuery.delete(key);\n      }\n    }\n    history.push({ search: newQuery.toString() });\n  };\n\n  const resetCheckboxSelection = () => {\n    const updatedScenes: Record<string, boolean> = {};\n\n    Object.keys(checkedScenes).forEach((sceneKey) => {\n      updatedScenes[sceneKey] = false;\n    });\n\n    setCheckedScenes(updatedScenes);\n  };\n\n  function onDeleteDialogClosed(deleted: boolean) {\n    setDeletingScenes(false);\n    if (deleted) {\n      setSelectedScenes(null);\n      refetch();\n      if (isMultiDelete) setCheckedScenes({});\n    }\n    resetCheckboxSelection();\n  }\n\n  const findLargestScene = (group: GQL.SlimSceneDataFragment[]) => {\n    // Get maximum file size of a scene\n    const totalSize = (scene: GQL.SlimSceneDataFragment) => {\n      return scene.files.reduce((prev: number, f) => Math.max(prev, f.size), 0);\n    };\n    // Find scene object with maximum total size\n    return group.reduce((largest, scene) => {\n      const largestSize = totalSize(largest);\n      const currentSize = totalSize(scene);\n      return currentSize > largestSize ? scene : largest;\n    });\n  };\n\n  const findLargestResolutionScene = (group: GQL.SlimSceneDataFragment[]) => {\n    // Get maximum resolution of a scene\n    const sceneResolution = (scene: GQL.SlimSceneDataFragment) => {\n      return scene.files.reduce(\n        (prev: number, f) => Math.max(prev, f.height * f.width),\n        0\n      );\n    };\n    // Find scene object with maximum resolution\n    return group.reduce((largest, scene) => {\n      const largestSize = sceneResolution(largest);\n      const currentSize = sceneResolution(scene);\n      return currentSize > largestSize ? scene : largest;\n    });\n  };\n\n  // Helper to get file date\n\n  const findFirstFileByAge = (\n    oldest: boolean,\n    compareScenes: GQL.SlimSceneDataFragment[]\n  ) => {\n    let selectedFile: GQL.VideoFileDataFragment;\n    let oldestTimestamp: Date | undefined = undefined;\n\n    // Loop through all files\n    for (const file of compareScenes.flatMap((s) => s.files)) {\n      // Get timestamp\n      const timestamp: Date = new Date(file.mod_time);\n\n      // Check if current file is oldest\n      if (oldest) {\n        if (oldestTimestamp === undefined || timestamp < oldestTimestamp) {\n          oldestTimestamp = timestamp;\n          selectedFile = file;\n        }\n      } else {\n        if (oldestTimestamp === undefined || timestamp > oldestTimestamp) {\n          oldestTimestamp = timestamp;\n          selectedFile = file;\n        }\n      }\n    }\n\n    // Find scene with oldest file\n    return compareScenes.find((s) =>\n      s.files.some((f) => f.id === selectedFile.id)\n    );\n  };\n\n  function checkSameCodec(codecGroup: GQL.SlimSceneDataFragment[]) {\n    const codecs = codecGroup.map((s) => s.files[0]?.video_codec);\n    return new Set(codecs).size === 1;\n  }\n\n  function checkSameResolution(dataGroup: GQL.SlimSceneDataFragment[]) {\n    const resolutions = dataGroup.map(\n      (s) => s.files[0]?.width * s.files[0]?.height\n    );\n    return new Set(resolutions).size === 1;\n  }\n\n  const onSelectLargestClick = () => {\n    setSelectedScenes([]);\n    const checkedArray: Record<string, boolean> = {};\n\n    filteredScenes.forEach((group) => {\n      if (chkSafeSelect && !checkSameCodec(group)) {\n        return;\n      }\n      // Find largest scene in group a\n      const largest = findLargestScene(group);\n      group.forEach((scene) => {\n        if (scene !== largest) {\n          checkedArray[scene.id] = true;\n        }\n      });\n    });\n\n    setCheckedScenes(checkedArray);\n  };\n\n  const onSelectLargestResolutionClick = () => {\n    setSelectedScenes([]);\n    const checkedArray: Record<string, boolean> = {};\n\n    filteredScenes.forEach((group) => {\n      if (chkSafeSelect && !checkSameCodec(group)) {\n        return;\n      }\n      // Don't select scenes where resolution is identical.\n      if (checkSameResolution(group)) {\n        return;\n      }\n      // Find the highest resolution scene in group.\n      const highest = findLargestResolutionScene(group);\n      group.forEach((scene) => {\n        if (scene !== highest) {\n          checkedArray[scene.id] = true;\n        }\n      });\n    });\n\n    setCheckedScenes(checkedArray);\n  };\n\n  const onSelectByAge = (oldest: boolean) => {\n    setSelectedScenes([]);\n\n    const checkedArray: Record<string, boolean> = {};\n\n    filteredScenes.forEach((group) => {\n      if (chkSafeSelect && !checkSameCodec(group)) {\n        return;\n      }\n\n      const oldestScene = findFirstFileByAge(oldest, group);\n      group.forEach((scene) => {\n        if (scene !== oldestScene) {\n          checkedArray[scene.id] = true;\n        }\n      });\n    });\n\n    setCheckedScenes(checkedArray);\n  };\n\n  const handleCheck = (checked: boolean, sceneID: string) => {\n    setCheckedScenes({ ...checkedScenes, [sceneID]: checked });\n  };\n\n  const handleDeleteChecked = () => {\n    setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id]));\n    setDeletingScenes(true);\n    setIsMultiDelete(true);\n  };\n\n  const handleDeleteScene = (scene: GQL.SlimSceneDataFragment) => {\n    setSelectedScenes([scene]);\n    setDeletingScenes(true);\n    setIsMultiDelete(false);\n  };\n\n  function onEdit() {\n    setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id]));\n    setEditingScenes(true);\n    resetCheckboxSelection();\n  }\n\n  function maybeRenderMissingPhashWarning() {\n    const missingPhashes = missingPhash?.findScenes.count ?? 0;\n    if (missingPhashes > 0) {\n      return (\n        <p className=\"lead\">\n          <Icon icon={faExclamationTriangle} className=\"text-warning\" />\n          Missing phashes for {missingPhashes} scenes. Please run the phash\n          generation task.\n        </p>\n      );\n    }\n  }\n\n  function maybeRenderEdit() {\n    if (editingScenes && selectedScenes) {\n      return (\n        <EditScenesDialog\n          selected={selectedScenes}\n          onClose={() => setEditingScenes(false)}\n        />\n      );\n    }\n  }\n\n  function maybeRenderTagPopoverButton(scene: GQL.SlimSceneDataFragment) {\n    if (scene.tags.length <= 0) return;\n\n    const popoverContent = scene.tags.map((tag) => (\n      <TagLink key={tag.id} tag={tag} />\n    ));\n\n    return (\n      <HoverPopover placement=\"bottom\" content={popoverContent}>\n        <Button className=\"minimal\">\n          <Icon icon={faTag} />\n          <span>{scene.tags.length}</span>\n        </Button>\n      </HoverPopover>\n    );\n  }\n\n  function maybeRenderPerformerPopoverButton(scene: GQL.SlimSceneDataFragment) {\n    if (scene.performers.length <= 0) return;\n\n    return <PerformerPopoverButton performers={scene.performers} />;\n  }\n\n  function maybeRenderGroupPopoverButton(scene: GQL.SlimSceneDataFragment) {\n    if (scene.groups.length <= 0) return;\n\n    const popoverContent = scene.groups.map((sceneGroup) => (\n      <div className=\"group-tag-container row\" key={sceneGroup.group.id}>\n        <Link\n          to={`/groups/${sceneGroup.group.id}`}\n          className=\"group-tag col m-auto zoom-2\"\n        >\n          <img\n            className=\"image-thumbnail\"\n            alt={sceneGroup.group.name ?? \"\"}\n            src={sceneGroup.group.front_image_path ?? \"\"}\n          />\n        </Link>\n        <GroupLink\n          key={sceneGroup.group.id}\n          group={sceneGroup.group}\n          className=\"d-block\"\n        />\n      </div>\n    ));\n\n    return (\n      <HoverPopover\n        placement=\"bottom\"\n        content={popoverContent}\n        className=\"tag-tooltip\"\n      >\n        <Button className=\"minimal\">\n          <Icon icon={faFilm} />\n          <span>{scene.groups.length}</span>\n        </Button>\n      </HoverPopover>\n    );\n  }\n\n  function maybeRenderSceneMarkerPopoverButton(\n    scene: GQL.SlimSceneDataFragment\n  ) {\n    if (scene.scene_markers.length <= 0) return;\n\n    const popoverContent = scene.scene_markers.map((marker) => {\n      const markerWithScene = { ...marker, scene: { id: scene.id } };\n      return <SceneMarkerLink key={marker.id} marker={markerWithScene} />;\n    });\n\n    return (\n      <HoverPopover placement=\"bottom\" content={popoverContent}>\n        <Button className=\"minimal\">\n          <Icon icon={faMapMarkerAlt} />\n          <span>{scene.scene_markers.length}</span>\n        </Button>\n      </HoverPopover>\n    );\n  }\n\n  function maybeRenderOCounter(scene: GQL.SlimSceneDataFragment) {\n    if (scene.o_counter) {\n      return (\n        <div>\n          <Button className=\"minimal\">\n            <span className=\"fa-icon\">\n              <SweatDrops />\n            </span>\n            <span>{scene.o_counter}</span>\n          </Button>\n        </div>\n      );\n    }\n  }\n\n  function maybeRenderGallery(scene: GQL.SlimSceneDataFragment) {\n    if (scene.galleries.length <= 0) return;\n\n    const popoverContent = scene.galleries.map((gallery) => (\n      <GalleryLink key={gallery.id} gallery={gallery} />\n    ));\n\n    return (\n      <HoverPopover placement=\"bottom\" content={popoverContent}>\n        <Button className=\"minimal\">\n          <Icon icon={faImages} />\n          <span>{scene.galleries.length}</span>\n        </Button>\n      </HoverPopover>\n    );\n  }\n\n  function maybeRenderFileCount(scene: GQL.SlimSceneDataFragment) {\n    if (scene.files.length <= 1) return;\n\n    const popoverContent = (\n      <FormattedMessage\n        id=\"files_amount\"\n        values={{ value: intl.formatNumber(scene.files.length ?? 0) }}\n      />\n    );\n\n    return (\n      <HoverPopover placement=\"bottom\" content={popoverContent}>\n        <Button className=\"minimal\">\n          <Icon icon={faFileAlt} />\n          <span>{scene.files.length}</span>\n        </Button>\n      </HoverPopover>\n    );\n  }\n\n  function maybeRenderOrganized(scene: GQL.SlimSceneDataFragment) {\n    if (scene.organized) {\n      return (\n        <div>\n          <Button className=\"minimal\">\n            <Icon icon={faBox} />\n          </Button>\n        </div>\n      );\n    }\n  }\n\n  function maybeRenderPopoverButtonGroup(scene: GQL.SlimSceneDataFragment) {\n    if (\n      scene.tags.length > 0 ||\n      scene.performers.length > 0 ||\n      scene.groups.length > 0 ||\n      scene.scene_markers.length > 0 ||\n      scene?.o_counter ||\n      scene.galleries.length > 0 ||\n      scene.files.length > 1 ||\n      scene.organized\n    ) {\n      return (\n        <>\n          <ButtonGroup className=\"flex-wrap\">\n            {maybeRenderTagPopoverButton(scene)}\n            {maybeRenderPerformerPopoverButton(scene)}\n            {maybeRenderGroupPopoverButton(scene)}\n            {maybeRenderSceneMarkerPopoverButton(scene)}\n            {maybeRenderOCounter(scene)}\n            {maybeRenderGallery(scene)}\n            {maybeRenderFileCount(scene)}\n            {maybeRenderOrganized(scene)}\n          </ButtonGroup>\n        </>\n      );\n    }\n  }\n\n  function renderPagination() {\n    return (\n      <div className=\"d-flex mt-2 mb-2\">\n        <h6 className=\"mr-auto align-self-center\">\n          <FormattedMessage\n            id=\"dupe_check.found_sets\"\n            values={{ setCount: scenes.length }}\n          />\n        </h6>\n        {checkCount > 0 && (\n          <ButtonGroup>\n            <OverlayTrigger\n              overlay={\n                <Tooltip id=\"edit\">\n                  {intl.formatMessage({ id: \"actions.edit\" })}\n                </Tooltip>\n              }\n            >\n              <Button variant=\"secondary\" onClick={onEdit}>\n                <Icon icon={faPencilAlt} />\n              </Button>\n            </OverlayTrigger>\n            <OverlayTrigger\n              overlay={\n                <Tooltip id=\"delete\">\n                  {intl.formatMessage({ id: \"actions.delete\" })}\n                </Tooltip>\n              }\n            >\n              <Button variant=\"danger\" onClick={handleDeleteChecked}>\n                <Icon icon={faTrash} />\n              </Button>\n            </OverlayTrigger>\n          </ButtonGroup>\n        )}\n        <Pagination\n          itemsPerPage={pageSize}\n          currentPage={currentPage}\n          totalItems={scenes.length}\n          metadataByline={[]}\n          onChangePage={(newPage) => {\n            setQuery({ page: newPage === 1 ? undefined : newPage });\n            resetCheckboxSelection();\n          }}\n        />\n        <Form.Control\n          as=\"select\"\n          className=\"w-auto ml-2 btn-secondary\"\n          defaultValue={pageSize}\n          value={currentPageSize}\n          onChange={(e) => {\n            setCurrentPageSize(parseInt(e.currentTarget.value, 10));\n            setQuery({\n              size:\n                e.currentTarget.value === \"20\"\n                  ? undefined\n                  : e.currentTarget.value,\n            });\n            resetCheckboxSelection();\n          }}\n        >\n          {pageOptions}\n        </Form.Control>\n      </div>\n    );\n  }\n\n  function renderMergeDialog() {\n    if (mergeScenes) {\n      return (\n        <SceneMergeModal\n          scenes={mergeScenes}\n          onClose={(mergedID?: string) => {\n            setMergeScenes(undefined);\n            if (mergedID) {\n              // refresh\n              refetch();\n            }\n          }}\n          show\n        />\n      );\n    }\n  }\n\n  function onMergeClicked(\n    sceneGroup: GQL.SlimSceneDataFragment[],\n    scene: GQL.SlimSceneDataFragment\n  ) {\n    const selected = scenes.flat().filter((s) => checkedScenes[s.id]);\n\n    // if scenes in this group other than this scene are selected, then only\n    // the selected scenes will be selected as source. Otherwise all other\n    // scenes will be source\n    let srcScenes =\n      selected.filter((s) => {\n        if (s === scene) return false;\n        return sceneGroup.includes(s);\n      }) ?? [];\n\n    if (!srcScenes.length) {\n      srcScenes = sceneGroup.filter((s) => s !== scene);\n    }\n\n    // insert subject scene to the front so that it is considered the destination\n    srcScenes.unshift(scene);\n\n    setMergeScenes(\n      srcScenes.map((s) => {\n        return {\n          id: s.id,\n          title: objectTitle(s),\n        };\n      })\n    );\n  }\n\n  return (\n    <Card id=\"scene-duplicate-checker\" className=\"col col-xl-12 mx-auto\">\n      <div className={CLASSNAME}>\n        {deletingScenes && selectedScenes && (\n          <DeleteScenesDialog\n            selected={selectedScenes}\n            onClose={onDeleteDialogClosed}\n          />\n        )}\n        {renderMergeDialog()}\n        {maybeRenderEdit()}\n        <h4>\n          <FormattedMessage id=\"dupe_check.title\" />\n        </h4>\n        <Form>\n          <Form.Group>\n            <Row noGutters>\n              <Form.Label>\n                <FormattedMessage id=\"dupe_check.search_accuracy_label\" />\n              </Form.Label>\n              <Col xs=\"auto\">\n                <Form.Control\n                  as=\"select\"\n                  onChange={(e) =>\n                    setQuery({\n                      distance:\n                        e.currentTarget.value === \"0\"\n                          ? undefined\n                          : e.currentTarget.value,\n                      page: undefined,\n                    })\n                  }\n                  defaultValue={hashDistance}\n                  className=\"input-control ml-4\"\n                >\n                  <option value={0}>\n                    {intl.formatMessage({ id: \"dupe_check.options.exact\" })}\n                  </option>\n                  <option value={4}>\n                    {intl.formatMessage({ id: \"dupe_check.options.high\" })}\n                  </option>\n                  <option value={8}>\n                    {intl.formatMessage({ id: \"dupe_check.options.medium\" })}\n                  </option>\n                  <option value={10}>\n                    {intl.formatMessage({ id: \"dupe_check.options.low\" })}\n                  </option>\n                </Form.Control>\n              </Col>\n            </Row>\n            <Form.Text>\n              <FormattedMessage id=\"dupe_check.description\" />\n            </Form.Text>\n          </Form.Group>\n\n          <Form.Group>\n            <Row noGutters>\n              <Form.Label>\n                <FormattedMessage id=\"dupe_check.duration_diff\" />\n              </Form.Label>\n              <Col xs=\"auto\">\n                <Form.Control\n                  as=\"select\"\n                  onChange={(e) =>\n                    setQuery({\n                      durationDiff:\n                        e.currentTarget.value === defaultDurationDiff\n                          ? undefined\n                          : e.currentTarget.value,\n                      page: undefined,\n                    })\n                  }\n                  defaultValue={durationDiff}\n                  className=\"input-control ml-4\"\n                >\n                  <option value={-1}>\n                    {intl.formatMessage({\n                      id: \"dupe_check.duration_options.any\",\n                    })}\n                  </option>\n                  <option value={0}>\n                    {intl.formatMessage({\n                      id: \"dupe_check.duration_options.equal\",\n                    })}\n                  </option>\n                  <option value={1}>\n                    1 {intl.formatMessage({ id: \"second\" })}\n                  </option>\n                  <option value={5}>\n                    5 {intl.formatMessage({ id: \"seconds\" })}\n                  </option>\n                  <option value={10}>\n                    10 {intl.formatMessage({ id: \"seconds\" })}\n                  </option>\n                </Form.Control>\n              </Col>\n            </Row>\n          </Form.Group>\n          <Form.Group>\n            <Row noGutters>\n              <Col xs=\"12\">\n                <Dropdown className=\"\">\n                  <Dropdown.Toggle variant=\"secondary\">\n                    <FormattedMessage id=\"dupe_check.select_options\" />\n                  </Dropdown.Toggle>\n                  <Dropdown.Menu className=\"bg-secondary text-white\">\n                    <Dropdown.Item onClick={() => resetCheckboxSelection()}>\n                      {intl.formatMessage({ id: \"dupe_check.select_none\" })}\n                    </Dropdown.Item>\n\n                    <Dropdown.Item\n                      onClick={() => onSelectLargestResolutionClick()}\n                    >\n                      {intl.formatMessage({\n                        id: \"dupe_check.select_all_but_largest_resolution\",\n                      })}\n                    </Dropdown.Item>\n\n                    <Dropdown.Item onClick={() => onSelectLargestClick()}>\n                      {intl.formatMessage({\n                        id: \"dupe_check.select_all_but_largest_file\",\n                      })}\n                    </Dropdown.Item>\n\n                    <Dropdown.Item onClick={() => onSelectByAge(true)}>\n                      {intl.formatMessage({\n                        id: \"dupe_check.select_oldest\",\n                      })}\n                    </Dropdown.Item>\n\n                    <Dropdown.Item onClick={() => onSelectByAge(false)}>\n                      {intl.formatMessage({\n                        id: \"dupe_check.select_youngest\",\n                      })}\n                    </Dropdown.Item>\n                  </Dropdown.Menu>\n                </Dropdown>\n              </Col>\n            </Row>\n            <Row noGutters>\n              <Form.Check\n                type=\"checkbox\"\n                id=\"chkSafeSelect\"\n                label={intl.formatMessage({\n                  id: \"dupe_check.only_select_matching_codecs\",\n                })}\n                checked={chkSafeSelect}\n                onChange={(e) => {\n                  setChkSafeSelect(e.target.checked);\n                  resetCheckboxSelection();\n                }}\n              />\n            </Row>\n          </Form.Group>\n        </Form>\n\n        {maybeRenderMissingPhashWarning()}\n        {renderPagination()}\n\n        <Table responsive striped className={`${CLASSNAME}-table`}>\n          <colgroup>\n            <col className={`${CLASSNAME}-checkbox`} />\n            <col className={`${CLASSNAME}-sprite`} />\n            <col className={`${CLASSNAME}-title`} />\n            <col className={`${CLASSNAME}-details`} />\n            <col className={`${CLASSNAME}-duration`} />\n            <col className={`${CLASSNAME}-filesize`} />\n            <col className={`${CLASSNAME}-resolution`} />\n            <col className={`${CLASSNAME}-bitrate`} />\n            <col className={`${CLASSNAME}-codec`} />\n            <col className={`${CLASSNAME}-operations`} />\n          </colgroup>\n          <thead>\n            <tr>\n              <th> </th>\n              <th> </th>\n              <th>{intl.formatMessage({ id: \"details\" })}</th>\n              <th> </th>\n              <th>{intl.formatMessage({ id: \"duration\" })}</th>\n              <th>{intl.formatMessage({ id: \"filesize\" })}</th>\n              <th>{intl.formatMessage({ id: \"resolution\" })}</th>\n              <th>{intl.formatMessage({ id: \"bitrate\" })}</th>\n              <th>{intl.formatMessage({ id: \"media_info.video_codec\" })}</th>\n              <th>{intl.formatMessage({ id: \"actions.delete\" })}</th>\n            </tr>\n          </thead>\n          <tbody>\n            {filteredScenes.map((group, groupIndex) =>\n              group.map((scene, i) => {\n                const file =\n                  scene.files.length > 0 ? scene.files[0] : undefined;\n\n                return (\n                  <>\n                    {i === 0 && groupIndex !== 0 ? (\n                      <tr className=\"separator\" />\n                    ) : undefined}\n                    <tr\n                      className={i === 0 ? \"duplicate-group\" : \"\"}\n                      key={scene.id}\n                    >\n                      <td>\n                        <Form.Check\n                          checked={checkedScenes[scene.id]}\n                          onChange={(e) =>\n                            handleCheck(e.currentTarget.checked, scene.id)\n                          }\n                        />\n                      </td>\n                      <td>\n                        <HoverPopover\n                          content={\n                            <img\n                              src={scene.paths.sprite ?? \"\"}\n                              alt=\"\"\n                              width={600}\n                            />\n                          }\n                          placement=\"right\"\n                        >\n                          <img\n                            src={scene.paths.sprite ?? \"\"}\n                            alt=\"\"\n                            width={100}\n                            style={{\n                              border: checkedScenes[scene.id]\n                                ? \"2px solid red\"\n                                : \"\",\n                            }}\n                          />\n                        </HoverPopover>\n                      </td>\n                      <td className=\"text-left\">\n                        <p>\n                          <Link\n                            to={`/scenes/${scene.id}`}\n                            style={{\n                              fontWeight: checkedScenes[scene.id]\n                                ? \"bold\"\n                                : \"inherit\",\n                              textDecoration: checkedScenes[scene.id]\n                                ? \"line-through 3px\"\n                                : \"inherit\",\n                              textDecorationColor: checkedScenes[scene.id]\n                                ? \"red\"\n                                : \"inherit\",\n                            }}\n                          >\n                            {\" \"}\n                            {scene.title\n                              ? scene.title\n                              : TextUtils.fileNameFromPath(\n                                  file?.path ?? \"\"\n                                )}{\" \"}\n                          </Link>\n                        </p>\n                        <p className=\"scene-path\">{file?.path ?? \"\"}</p>\n                      </td>\n                      <td className=\"scene-details\">\n                        {maybeRenderPopoverButtonGroup(scene)}\n                      </td>\n                      <td>\n                        {file?.duration &&\n                          TextUtils.secondsToTimestamp(file.duration)}\n                      </td>\n                      <td>\n                        <FileSize size={file?.size ?? 0} />\n                      </td>\n                      <td>{`${file?.width ?? 0}x${file?.height ?? 0}`}</td>\n                      <td>\n                        <FormattedNumber\n                          value={(file?.bit_rate ?? 0) / 1000000}\n                          maximumFractionDigits={2}\n                        />\n                        &nbsp;mbps\n                      </td>\n                      <td>{file?.video_codec ?? \"\"}</td>\n                      <td>\n                        <Button\n                          className=\"edit-button\"\n                          variant=\"danger\"\n                          onClick={() => handleDeleteScene(scene)}\n                        >\n                          <FormattedMessage id=\"actions.delete\" />\n                        </Button>\n                        <Button\n                          className=\"edit-button\"\n                          onClick={() => onMergeClicked(group, scene)}\n                        >\n                          <FormattedMessage id=\"actions.merge\" />\n                        </Button>\n                      </td>\n                    </tr>\n                  </>\n                );\n              })\n            )}\n          </tbody>\n        </Table>\n        {scenes.length === 0 && (\n          <h4 className=\"text-center mt-4\">No duplicates found.</h4>\n        )}\n        {renderPagination()}\n      </div>\n    </Card>\n  );\n};\n\nexport default SceneDuplicateChecker;\n"
  },
  {
    "path": "ui/v2.5/src/components/SceneDuplicateChecker/styles.scss",
    "content": "#scene-duplicate-checker {\n  .scene-path {\n    font-size: 0.88em;\n  }\n\n  .filter-container {\n    margin: 0;\n  }\n\n  .separator {\n    border-top: 1px solid white;\n    height: 10px;\n  }\n\n  .form-group .row {\n    align-items: center;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/SceneFilenameParser/ParserField.ts",
    "content": "export class ParserField {\n  public field: string;\n  public helperText?: string;\n\n  constructor(field: string, helperText?: string) {\n    this.field = field;\n    this.helperText = helperText;\n  }\n\n  public getFieldPattern() {\n    return `{${this.field}}`;\n  }\n\n  static Title = new ParserField(\"title\");\n  static Ext = new ParserField(\"ext\", \"File extension\");\n  static Rating = new ParserField(\"rating100\");\n\n  static I = new ParserField(\"i\", \"Matches any ignored word\");\n  static D = new ParserField(\"d\", \"Matches any delimiter (.-_)\");\n\n  static Performer = new ParserField(\"performer\");\n  static Studio = new ParserField(\"studio\");\n  static Tag = new ParserField(\"tag\");\n\n  // date fields\n  static Date = new ParserField(\"date\", \"YYYY-MM-DD\");\n  static YYYY = new ParserField(\"yyyy\", \"Year\");\n  static YY = new ParserField(\"yy\", \"Year (20YY)\");\n  static MM = new ParserField(\"mm\", \"Two digit month\");\n  static MMM = new ParserField(\"mmm\", \"Three letter month (eg Jan)\");\n  static DD = new ParserField(\"dd\", \"Two digit date\");\n  static YYYYMMDD = new ParserField(\"yyyymmdd\");\n  static YYMMDD = new ParserField(\"yymmdd\");\n  static DDMMYYYY = new ParserField(\"ddmmyyyy\");\n  static DDMMYY = new ParserField(\"ddmmyy\");\n  static MMDDYYYY = new ParserField(\"mmddyyyy\");\n  static MMDDYY = new ParserField(\"mmddyy\");\n\n  static validFields = [\n    ParserField.Title,\n    ParserField.Ext,\n    ParserField.D,\n    ParserField.I,\n    ParserField.Rating,\n    ParserField.Performer,\n    ParserField.Studio,\n    ParserField.Tag,\n    ParserField.Date,\n    ParserField.YYYY,\n    ParserField.YY,\n    ParserField.MM,\n    ParserField.MMM,\n    ParserField.DD,\n    ParserField.YYYYMMDD,\n    ParserField.YYMMDD,\n    ParserField.DDMMYYYY,\n    ParserField.DDMMYY,\n    ParserField.MMDDYYYY,\n    ParserField.MMDDYY,\n  ];\n\n  static fullDateFields = [\n    ParserField.YYYYMMDD,\n    ParserField.YYMMDD,\n    ParserField.DDMMYYYY,\n    ParserField.DDMMYY,\n    ParserField.MMDDYYYY,\n    ParserField.MMDDYY,\n  ];\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/SceneFilenameParser/ParserInput.tsx",
    "content": "import React, { useState } from \"react\";\nimport {\n  Button,\n  Dropdown,\n  DropdownButton,\n  Form,\n  InputGroup,\n} from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { ParserField } from \"./ParserField\";\nimport { ShowFields } from \"./ShowFields\";\n\nconst builtInRecipes = [\n  {\n    pattern: \"{title}\",\n    ignoreWords: [],\n    whitespaceCharacters: \"\",\n    capitalizeTitle: false,\n    description: \"Filename\",\n  },\n  {\n    pattern: \"{title}.{ext}\",\n    ignoreWords: [],\n    whitespaceCharacters: \"\",\n    capitalizeTitle: false,\n    description: \"Without extension\",\n  },\n  {\n    pattern: \"{}.{yy}.{mm}.{dd}.{title}.XXX.{}.{ext}\",\n    ignoreWords: [],\n    whitespaceCharacters: \".\",\n    capitalizeTitle: true,\n    description: \"\",\n  },\n  {\n    pattern: \"{}.{yy}.{mm}.{dd}.{title}.{ext}\",\n    ignoreWords: [],\n    whitespaceCharacters: \".\",\n    capitalizeTitle: true,\n    description: \"\",\n  },\n  {\n    pattern: \"{title}.XXX.{}.{ext}\",\n    ignoreWords: [],\n    whitespaceCharacters: \".\",\n    capitalizeTitle: true,\n    description: \"\",\n  },\n  {\n    pattern: \"{}.{yy}.{mm}.{dd}.{title}.{i}.{ext}\",\n    ignoreWords: [\"cz\", \"fr\"],\n    whitespaceCharacters: \".\",\n    capitalizeTitle: true,\n    description: \"Foreign language\",\n  },\n];\n\nexport interface IParserInput {\n  pattern: string;\n  ignoreWords: string[];\n  whitespaceCharacters: string;\n  capitalizeTitle: boolean;\n  page: number;\n  pageSize: number;\n  findClicked: boolean;\n  ignoreOrganized: boolean;\n}\n\ninterface IParserRecipe {\n  pattern: string;\n  ignoreWords: string[];\n  whitespaceCharacters: string;\n  capitalizeTitle: boolean;\n  description: string;\n}\n\ninterface IParserInputProps {\n  input: IParserInput;\n  onFind: (input: IParserInput) => void;\n  onPageSizeChanged: (newSize: number) => void;\n  showFields: Map<string, boolean>;\n  setShowFields: (fields: Map<string, boolean>) => void;\n}\n\nexport const ParserInput: React.FC<IParserInputProps> = (\n  props: IParserInputProps\n) => {\n  const intl = useIntl();\n  const [pattern, setPattern] = useState<string>(props.input.pattern);\n  const [ignoreWords, setIgnoreWords] = useState<string>(\n    props.input.ignoreWords.join(\" \")\n  );\n  const [whitespaceCharacters, setWhitespaceCharacters] = useState<string>(\n    props.input.whitespaceCharacters\n  );\n  const [capitalizeTitle, setCapitalizeTitle] = useState<boolean>(\n    props.input.capitalizeTitle\n  );\n  const [ignoreOrganized, setIgnoreOrganized] = useState<boolean>(\n    props.input.ignoreOrganized\n  );\n\n  function onFind() {\n    props.onFind({\n      pattern,\n      ignoreWords: ignoreWords.split(\" \"),\n      whitespaceCharacters,\n      capitalizeTitle,\n      page: 1,\n      pageSize: props.input.pageSize,\n      findClicked: props.input.findClicked,\n      ignoreOrganized,\n    });\n  }\n\n  function setParserRecipe(recipe: IParserRecipe) {\n    setPattern(recipe.pattern);\n    setIgnoreWords(recipe.ignoreWords.join(\" \"));\n    setWhitespaceCharacters(recipe.whitespaceCharacters);\n    setCapitalizeTitle(recipe.capitalizeTitle);\n  }\n\n  const validFields = [new ParserField(\"\", \"Wildcard\")].concat(\n    ParserField.validFields\n  );\n\n  function addParserField(field: ParserField) {\n    setPattern(pattern + field.getFieldPattern());\n  }\n\n  const PAGE_SIZE_OPTIONS = [\"20\", \"40\", \"60\", \"120\", \"250\", \"500\", \"1000\"];\n\n  return (\n    <Form.Group>\n      <Form.Group className=\"row\">\n        <Form.Label htmlFor=\"filename-pattern\" className=\"col-2\">\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.filename_pattern\",\n          })}\n        </Form.Label>\n        <InputGroup className=\"col-8\">\n          <Form.Control\n            className=\"text-input\"\n            id=\"filename-pattern\"\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n              setPattern(e.currentTarget.value)\n            }\n            value={pattern}\n          />\n          <InputGroup.Append>\n            <DropdownButton\n              id=\"parser-field-select\"\n              title={intl.formatMessage({\n                id: \"config.tools.scene_filename_parser.add_field\",\n              })}\n            >\n              {validFields.map((item) => (\n                <Dropdown.Item\n                  key={item.field}\n                  onSelect={() => addParserField(item)}\n                >\n                  <span className=\"mr-2\">{item.field || \"{}\"}</span>\n                  <span className=\"ml-auto text-muted\">{item.helperText}</span>\n                </Dropdown.Item>\n              ))}\n            </DropdownButton>\n          </InputGroup.Append>\n        </InputGroup>\n        <Form.Text className=\"text-muted row col-10 offset-2\">\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.escape_chars\",\n          })}\n        </Form.Text>\n      </Form.Group>\n\n      <Form.Group className=\"row\" controlId=\"ignored-words\">\n        <Form.Label className=\"col-2\">\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.ignored_words\",\n          })}\n        </Form.Label>\n        <InputGroup className=\"col-8\">\n          <Form.Control\n            className=\"text-input\"\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n              setIgnoreWords(e.currentTarget.value)\n            }\n            value={ignoreWords}\n          />\n        </InputGroup>\n        <Form.Text className=\"text-muted col-10 offset-2\">\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.matches_with\",\n          })}\n        </Form.Text>\n      </Form.Group>\n\n      <h5>{intl.formatMessage({ id: \"title\" })}</h5>\n      <Form.Group className=\"row\">\n        <Form.Label htmlFor=\"whitespace-characters\" className=\"col-2\">\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.whitespace_chars\",\n          })}\n        </Form.Label>\n        <InputGroup className=\"col-8\">\n          <Form.Control\n            className=\"text-input\"\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n              setWhitespaceCharacters(e.currentTarget.value)\n            }\n            value={whitespaceCharacters}\n          />\n        </InputGroup>\n        <Form.Text className=\"text-muted col-10 offset-2\">\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.whitespace_chars_desc\",\n          })}\n        </Form.Text>\n      </Form.Group>\n      <Form.Group>\n        <Form.Check\n          inline\n          className=\"m-0\"\n          id=\"capitalize-title\"\n          checked={capitalizeTitle}\n          onChange={() => setCapitalizeTitle(!capitalizeTitle)}\n        />\n        <Form.Label htmlFor=\"capitalize-title\">\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.capitalize_title\",\n          })}\n        </Form.Label>\n      </Form.Group>\n      <Form.Group>\n        <Form.Check\n          inline\n          className=\"m-0\"\n          id=\"ignore-organized\"\n          checked={ignoreOrganized}\n          onChange={() => setIgnoreOrganized(!ignoreOrganized)}\n        />\n        <Form.Label htmlFor=\"ignore-organized\">\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.ignore_organized\",\n          })}\n        </Form.Label>\n      </Form.Group>\n\n      {/* TODO - mapping stuff will go here */}\n\n      <Form.Group>\n        <DropdownButton\n          variant=\"secondary\"\n          id=\"recipe-select\"\n          title={intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.select_parser_recipe\",\n          })}\n          drop=\"up\"\n        >\n          {builtInRecipes.map((item) => (\n            <Dropdown.Item\n              key={item.pattern}\n              onSelect={() => setParserRecipe(item)}\n            >\n              <span>{item.pattern}</span>\n              <span className=\"ml-auto text-muted\">{item.description}</span>\n            </Dropdown.Item>\n          ))}\n        </DropdownButton>\n      </Form.Group>\n\n      <Form.Group>\n        <ShowFields\n          fields={props.showFields}\n          onShowFieldsChanged={(fields) => props.setShowFields(fields)}\n        />\n      </Form.Group>\n\n      <Form.Group className=\"row\">\n        <Button variant=\"secondary\" className=\"ml-3 col-1\" onClick={onFind}>\n          {intl.formatMessage({ id: \"actions.find\" })}\n        </Button>\n        <Form.Control\n          as=\"select\"\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            props.onPageSizeChanged(parseInt(e.currentTarget.value, 10))\n          }\n          defaultValue={props.input.pageSize}\n          className=\"col-1 input-control filter-item\"\n        >\n          {PAGE_SIZE_OPTIONS.map((val) => (\n            <option key={val} value={val}>\n              {val}\n            </option>\n          ))}\n        </Form.Control>\n      </Form.Group>\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx",
    "content": "/* eslint-disable no-param-reassign, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */\n\nimport React, { useEffect, useState, useCallback, useRef } from \"react\";\nimport { Button, Card, Form, Table } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport clone from \"lodash-es/clone\";\nimport {\n  queryParseSceneFilenames,\n  useScenesUpdate,\n} from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { Pagination } from \"src/components/List/Pagination\";\nimport { IParserInput, ParserInput } from \"./ParserInput\";\nimport { ParserField } from \"./ParserField\";\nimport { SceneParserResult, SceneParserRow } from \"./SceneParserRow\";\n\nconst initialParserInput = {\n  pattern: \"{title}.{ext}\",\n  ignoreWords: [],\n  whitespaceCharacters: \"._\",\n  capitalizeTitle: true,\n  page: 1,\n  pageSize: 20,\n  findClicked: false,\n  ignoreOrganized: true,\n};\n\nconst initialShowFieldsState = new Map<string, boolean>([\n  [\"Title\", true],\n  [\"Date\", true],\n  [\"Rating\", true],\n  [\"Performers\", true],\n  [\"Tags\", true],\n  [\"Studio\", true],\n]);\n\nexport const SceneFilenameParser: React.FC = () => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const [parserResult, setParserResult] = useState<SceneParserResult[]>([]);\n  const [parserInput, setParserInput] =\n    useState<IParserInput>(initialParserInput);\n  const prevParserInputRef = useRef<IParserInput>();\n  const prevParserInput = prevParserInputRef.current;\n\n  const [allTitleSet, setAllTitleSet] = useState<boolean>(false);\n  const [allDateSet, setAllDateSet] = useState<boolean>(false);\n  const [allRatingSet, setAllRatingSet] = useState<boolean>(false);\n  const [allPerformerSet, setAllPerformerSet] = useState<boolean>(false);\n  const [allTagSet, setAllTagSet] = useState<boolean>(false);\n  const [allStudioSet, setAllStudioSet] = useState<boolean>(false);\n\n  const [showFields, setShowFields] = useState<Map<string, boolean>>(\n    initialShowFieldsState\n  );\n\n  const [totalItems, setTotalItems] = useState<number>(0);\n\n  // Network state\n  const [isLoading, setIsLoading] = useState(false);\n\n  const [updateScenes] = useScenesUpdate(getScenesUpdateData());\n\n  useEffect(() => {\n    prevParserInputRef.current = parserInput;\n  }, [parserInput]);\n\n  const determineFieldsToHide = useCallback(() => {\n    const { pattern } = parserInput;\n    const titleSet = pattern.includes(\"{title}\");\n    const dateSet =\n      pattern.includes(\"{date}\") ||\n      pattern.includes(\"{dd}\") || // don't worry about other partial date fields since this should be implied\n      ParserField.fullDateFields.some((f) => {\n        return pattern.includes(`{${f.field}}`);\n      });\n    const ratingSet = pattern.includes(\"{rating100}\");\n    const performerSet = pattern.includes(\"{performer}\");\n    const tagSet = pattern.includes(\"{tag}\");\n    const studioSet = pattern.includes(\"{studio}\");\n\n    const newShowFields = new Map<string, boolean>([\n      [\"Title\", titleSet],\n      [\"Date\", dateSet],\n      [\"Rating\", ratingSet],\n      [\"Performers\", performerSet],\n      [\"Tags\", tagSet],\n      [\"Studio\", studioSet],\n    ]);\n\n    setShowFields(newShowFields);\n  }, [parserInput]);\n\n  const parseResults = useCallback(\n    (\n      results: GQL.ParseSceneFilenamesQuery[\"parseSceneFilenames\"][\"results\"]\n    ) => {\n      if (results) {\n        const result = results\n          .map((r) => {\n            return new SceneParserResult(r);\n          })\n          .filter((r) => !!r) as SceneParserResult[];\n\n        setParserResult(result);\n        determineFieldsToHide();\n      }\n    },\n    [determineFieldsToHide]\n  );\n\n  const parseSceneFilenames = useCallback(() => {\n    setParserResult([]);\n    setIsLoading(true);\n\n    const parserFilter = {\n      q: parserInput.pattern,\n      page: parserInput.page,\n      per_page: parserInput.pageSize,\n      sort: \"path\",\n      direction: GQL.SortDirectionEnum.Asc,\n    };\n\n    const parserInputData = {\n      ignoreWords: parserInput.ignoreWords,\n      whitespaceCharacters: parserInput.whitespaceCharacters,\n      capitalizeTitle: parserInput.capitalizeTitle,\n      ignoreOrganized: parserInput.ignoreOrganized,\n    };\n\n    queryParseSceneFilenames(parserFilter, parserInputData)\n      .then((response) => {\n        const result = response?.data?.parseSceneFilenames;\n        if (result) {\n          parseResults(result.results);\n          setTotalItems(result.count);\n        }\n      })\n      .catch((err) => Toast.error(err))\n      .finally(() => setIsLoading(false));\n  }, [parserInput, parseResults, Toast]);\n\n  useEffect(() => {\n    // only refresh if parserInput actually changed\n    if (prevParserInput === parserInput) {\n      return;\n    }\n\n    if (parserInput.findClicked) {\n      parseSceneFilenames();\n    }\n  }, [parserInput, parseSceneFilenames, prevParserInput]);\n\n  function onPageSizeChanged(newSize: number) {\n    const newInput = clone(parserInput);\n    newInput.page = 1;\n    newInput.pageSize = newSize;\n    setParserInput(newInput);\n  }\n\n  function onPageChanged(newPage: number) {\n    if (newPage !== parserInput.page) {\n      const newInput = clone(parserInput);\n      newInput.page = newPage;\n      setParserInput(newInput);\n    }\n  }\n\n  function onFindClicked(input: IParserInput) {\n    const newInput = clone(input);\n    newInput.page = 1;\n    newInput.findClicked = true;\n    setParserInput(newInput);\n    setTotalItems(0);\n  }\n\n  function getScenesUpdateData() {\n    return parserResult\n      .filter((result) => result.isChanged())\n      .map((result) => result.toSceneUpdateInput());\n  }\n\n  async function onApply() {\n    setIsLoading(true);\n\n    try {\n      await updateScenes();\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          { entity: intl.formatMessage({ id: \"scenes\" }).toLocaleLowerCase() }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n\n    setIsLoading(false);\n\n    // trigger a refresh of the results\n    onFindClicked(parserInput);\n  }\n\n  useEffect(() => {\n    const newAllTitleSet = !parserResult.some((r) => {\n      return !r.title.isSet;\n    });\n    const newAllDateSet = !parserResult.some((r) => {\n      return !r.date.isSet;\n    });\n    const newAllRatingSet = !parserResult.some((r) => {\n      return !r.rating.isSet;\n    });\n    const newAllPerformerSet = !parserResult.some((r) => {\n      return !r.performers.isSet;\n    });\n    const newAllTagSet = !parserResult.some((r) => {\n      return !r.tags.isSet;\n    });\n    const newAllStudioSet = !parserResult.some((r) => {\n      return !r.studio.isSet;\n    });\n\n    setAllTitleSet(newAllTitleSet);\n    setAllDateSet(newAllDateSet);\n    setAllRatingSet(newAllRatingSet);\n    setAllTagSet(newAllPerformerSet);\n    setAllTagSet(newAllTagSet);\n    setAllStudioSet(newAllStudioSet);\n  }, [parserResult]);\n\n  function onSelectAllTitleSet(selected: boolean) {\n    const newResult = [...parserResult];\n\n    newResult.forEach((r) => {\n      r.title.isSet = selected;\n    });\n\n    setParserResult(newResult);\n    setAllTitleSet(selected);\n  }\n\n  function onSelectAllDateSet(selected: boolean) {\n    const newResult = [...parserResult];\n\n    newResult.forEach((r) => {\n      r.date.isSet = selected;\n    });\n\n    setParserResult(newResult);\n    setAllDateSet(selected);\n  }\n\n  function onSelectAllRatingSet(selected: boolean) {\n    const newResult = [...parserResult];\n\n    newResult.forEach((r) => {\n      r.rating.isSet = selected;\n    });\n\n    setParserResult(newResult);\n    setAllRatingSet(selected);\n  }\n\n  function onSelectAllPerformerSet(selected: boolean) {\n    const newResult = [...parserResult];\n\n    newResult.forEach((r) => {\n      r.performers.isSet = selected;\n    });\n\n    setParserResult(newResult);\n    setAllPerformerSet(selected);\n  }\n\n  function onSelectAllTagSet(selected: boolean) {\n    const newResult = [...parserResult];\n\n    newResult.forEach((r) => {\n      r.tags.isSet = selected;\n    });\n\n    setParserResult(newResult);\n    setAllTagSet(selected);\n  }\n\n  function onSelectAllStudioSet(selected: boolean) {\n    const newResult = [...parserResult];\n\n    newResult.forEach((r) => {\n      r.studio.isSet = selected;\n    });\n\n    setParserResult(newResult);\n    setAllStudioSet(selected);\n  }\n\n  function onChange(scene: SceneParserResult, changedScene: SceneParserResult) {\n    const newResult = [...parserResult];\n\n    const index = newResult.indexOf(scene);\n    newResult[index] = changedScene;\n\n    setParserResult(newResult);\n  }\n\n  function renderHeader(\n    fieldName: string,\n    allSet: boolean,\n    onAllSet: (set: boolean) => void\n  ) {\n    if (!showFields.get(fieldName)) {\n      return null;\n    }\n\n    return (\n      <>\n        <th className=\"w-15\">\n          <Form.Check\n            checked={allSet}\n            onChange={() => {\n              onAllSet(!allSet);\n            }}\n          />\n        </th>\n        <th>{fieldName}</th>\n      </>\n    );\n  }\n\n  function renderTable() {\n    if (parserResult.length === 0) {\n      return undefined;\n    }\n\n    return (\n      <>\n        <div className=\"scene-parser-results\">\n          <Table>\n            <thead>\n              <tr className=\"scene-parser-row\">\n                <th className=\"parser-field-filename\">\n                  {intl.formatMessage({\n                    id: \"config.tools.scene_filename_parser.filename\",\n                  })}\n                </th>\n                {renderHeader(\n                  intl.formatMessage({ id: \"title\" }),\n                  allTitleSet,\n                  onSelectAllTitleSet\n                )}\n                {renderHeader(\n                  intl.formatMessage({ id: \"date\" }),\n                  allDateSet,\n                  onSelectAllDateSet\n                )}\n                {renderHeader(\n                  intl.formatMessage({ id: \"rating\" }),\n                  allRatingSet,\n                  onSelectAllRatingSet\n                )}\n                {renderHeader(\n                  intl.formatMessage({ id: \"performers\" }),\n                  allPerformerSet,\n                  onSelectAllPerformerSet\n                )}\n                {renderHeader(\n                  intl.formatMessage({ id: \"tags\" }),\n                  allTagSet,\n                  onSelectAllTagSet\n                )}\n                {renderHeader(\n                  intl.formatMessage({ id: \"studio\" }),\n                  allStudioSet,\n                  onSelectAllStudioSet\n                )}\n              </tr>\n            </thead>\n            <tbody>\n              {parserResult.map((scene) => (\n                <SceneParserRow\n                  scene={scene}\n                  key={scene.id}\n                  onChange={(changedScene) => onChange(scene, changedScene)}\n                  showFields={showFields}\n                />\n              ))}\n            </tbody>\n          </Table>\n        </div>\n        <Pagination\n          currentPage={parserInput.page}\n          itemsPerPage={parserInput.pageSize}\n          totalItems={totalItems}\n          metadataByline={[]}\n          onChangePage={(page) => onPageChanged(page)}\n        />\n        <Button variant=\"primary\" onClick={onApply}>\n          <FormattedMessage id=\"actions.apply\" />\n        </Button>\n      </>\n    );\n  }\n\n  return (\n    <Card id=\"parser-container\" className=\"col col-sm-9 mx-auto\">\n      <h4>\n        {intl.formatMessage({ id: \"config.tools.scene_filename_parser.title\" })}\n      </h4>\n      <ParserInput\n        input={parserInput}\n        onFind={(input) => onFindClicked(input)}\n        onPageSizeChanged={onPageSizeChanged}\n        showFields={showFields}\n        setShowFields={setShowFields}\n      />\n\n      {isLoading && <LoadingIndicator />}\n      {renderTable()}\n    </Card>\n  );\n};\n\nexport default SceneFilenameParser;\n"
  },
  {
    "path": "ui/v2.5/src/components/SceneFilenameParser/SceneParserRow.tsx",
    "content": "import React from \"react\";\nimport isEqual from \"lodash-es/isEqual\";\nimport clone from \"lodash-es/clone\";\nimport { Form } from \"react-bootstrap\";\nimport {\n  ParseSceneFilenamesQuery,\n  SlimSceneDataFragment,\n} from \"src/core/generated-graphql\";\nimport {\n  PerformerSelect,\n  TagSelect,\n  StudioSelect,\n} from \"src/components/Shared/Select\";\nimport cx from \"classnames\";\nimport { objectTitle } from \"src/core/files\";\n\nclass ParserResult<T> {\n  public value?: T;\n  public originalValue?: T;\n  public isSet: boolean = false;\n\n  public setOriginalValue(value?: T) {\n    this.originalValue = value;\n    this.value = value;\n  }\n\n  public setValue(value?: T) {\n    if (value) {\n      this.value = value;\n      this.isSet = !isEqual(this.value, this.originalValue);\n    }\n  }\n}\n\nexport class SceneParserResult {\n  public id: string;\n  public filename: string;\n  public title: ParserResult<string> = new ParserResult<string>();\n  public date: ParserResult<string> = new ParserResult<string>();\n  public rating: ParserResult<number> = new ParserResult<number>();\n\n  public studio: ParserResult<string> = new ParserResult<string>();\n  public tags: ParserResult<string[]> = new ParserResult<string[]>();\n  public performers: ParserResult<string[]> = new ParserResult<string[]>();\n\n  public scene: SlimSceneDataFragment;\n\n  constructor(\n    result: ParseSceneFilenamesQuery[\"parseSceneFilenames\"][\"results\"][0]\n  ) {\n    this.scene = result.scene;\n\n    this.id = this.scene.id;\n    this.filename = objectTitle(this.scene);\n    this.title.setOriginalValue(this.scene.title ?? undefined);\n    this.date.setOriginalValue(this.scene.date ?? undefined);\n    this.rating.setOriginalValue(this.scene.rating100 ?? undefined);\n    this.performers.setOriginalValue(this.scene.performers.map((p) => p.id));\n    this.tags.setOriginalValue(this.scene.tags.map((t) => t.id));\n    this.studio.setOriginalValue(this.scene.studio?.id);\n\n    this.title.setValue(result.title ?? undefined);\n    this.date.setValue(result.date ?? undefined);\n    this.rating.setValue(result.rating ?? undefined);\n\n    this.performers.setValue(result.performer_ids ?? undefined);\n    this.tags.setValue(result.tag_ids ?? undefined);\n    this.studio.setValue(result.studio_id ?? undefined);\n  }\n\n  // returns true if any of its fields have set == true\n  public isChanged() {\n    return (\n      this.title.isSet ||\n      this.date.isSet ||\n      this.rating.isSet ||\n      this.performers.isSet ||\n      this.studio.isSet ||\n      this.tags.isSet\n    );\n  }\n\n  public toSceneUpdateInput() {\n    return {\n      id: this.id,\n      rating: this.rating.isSet ? this.rating.value : undefined,\n      title: this.title.isSet ? this.title.value : undefined,\n      date: this.date.isSet ? this.date.value : undefined,\n      studio_id: this.studio.isSet ? this.studio.value : undefined,\n      performer_ids: this.performers.isSet ? this.performers.value : undefined,\n      tag_ids: this.tags.isSet ? this.tags.value : undefined,\n    };\n  }\n}\n\ninterface ISceneParserFieldProps<T> {\n  parserResult: ParserResult<T>;\n  className?: string;\n  onSetChanged: (isSet: boolean) => void;\n  onValueChanged: (value: T) => void;\n  originalParserResult?: ParserResult<T>;\n}\n\nfunction SceneParserStringField(props: ISceneParserFieldProps<string>) {\n  function maybeValueChanged(value: string) {\n    if (value !== props.parserResult.value) {\n      props.onValueChanged(value);\n    }\n  }\n\n  const result = props.originalParserResult || props.parserResult;\n\n  return (\n    <>\n      <td>\n        <Form.Check\n          checked={props.parserResult.isSet}\n          onChange={() => {\n            props.onSetChanged(!props.parserResult.isSet);\n          }}\n        />\n      </td>\n      <td>\n        <Form.Group>\n          <Form.Control\n            disabled\n            className={props.className}\n            defaultValue={result.originalValue || \"\"}\n          />\n          <Form.Control\n            readOnly={!props.parserResult.isSet}\n            className={props.className}\n            value={props.parserResult.value || \"\"}\n            onChange={(event: React.ChangeEvent<HTMLInputElement>) =>\n              maybeValueChanged(event.currentTarget.value)\n            }\n          />\n        </Form.Group>\n      </td>\n    </>\n  );\n}\n\nfunction SceneParserRatingField(\n  props: ISceneParserFieldProps<number | undefined>\n) {\n  function maybeValueChanged(value?: number) {\n    if (value !== props.parserResult.value) {\n      props.onValueChanged(value);\n    }\n  }\n\n  const result = props.originalParserResult || props.parserResult;\n  const options = [\"\", 1, 2, 3, 4, 5];\n\n  return (\n    <>\n      <td>\n        <Form.Check\n          checked={props.parserResult.isSet}\n          onChange={() => {\n            props.onSetChanged(!props.parserResult.isSet);\n          }}\n        />\n      </td>\n      <td>\n        <Form.Group>\n          <Form.Control\n            disabled\n            className={cx(\"input-control text-input\", props.className)}\n            defaultValue={result.originalValue || \"\"}\n          />\n          <Form.Control\n            as=\"select\"\n            className={cx(\"input-control\", props.className)}\n            disabled={!props.parserResult.isSet}\n            value={props.parserResult.value?.toString()}\n            onChange={(event: React.ChangeEvent<HTMLSelectElement>) =>\n              maybeValueChanged(\n                event.currentTarget.value === \"\"\n                  ? undefined\n                  : Number.parseInt(event.currentTarget.value, 10)\n              )\n            }\n          >\n            {options.map((opt) => (\n              <option value={opt} key={opt}>\n                {opt}\n              </option>\n            ))}\n          </Form.Control>\n        </Form.Group>\n      </td>\n    </>\n  );\n}\n\nfunction SceneParserPerformerField(props: ISceneParserFieldProps<string[]>) {\n  function maybeValueChanged(value: string[]) {\n    if (value !== props.parserResult.value) {\n      props.onValueChanged(value);\n    }\n  }\n\n  const originalPerformers = (props.originalParserResult?.originalValue ??\n    []) as string[];\n  const newPerformers = props.parserResult.value ?? [];\n\n  return (\n    <>\n      <td>\n        <Form.Check\n          checked={props.parserResult.isSet}\n          onChange={() => {\n            props.onSetChanged(!props.parserResult.isSet);\n          }}\n        />\n      </td>\n      <td>\n        <Form.Group className={props.className}>\n          <PerformerSelect\n            isDisabled\n            isMulti\n            ids={originalPerformers}\n            className=\"parser-field-performers-select\"\n          />\n          <PerformerSelect\n            className=\"parser-field-performers-select\"\n            isMulti\n            isDisabled={!props.parserResult.isSet}\n            onSelect={(items) => {\n              maybeValueChanged(items.map((i) => i.id));\n            }}\n            ids={newPerformers}\n          />\n        </Form.Group>\n      </td>\n    </>\n  );\n}\n\nfunction SceneParserTagField(props: ISceneParserFieldProps<string[]>) {\n  function maybeValueChanged(value: string[]) {\n    if (value !== props.parserResult.value) {\n      props.onValueChanged(value);\n    }\n  }\n\n  const originalTags = props.originalParserResult?.originalValue ?? [];\n  const newTags = props.parserResult.value ?? [];\n\n  return (\n    <>\n      <td>\n        <Form.Check\n          checked={props.parserResult.isSet}\n          onChange={() => {\n            props.onSetChanged(!props.parserResult.isSet);\n          }}\n        />\n      </td>\n      <td>\n        <Form.Group className={props.className}>\n          <TagSelect\n            isDisabled\n            isMulti\n            ids={originalTags}\n            className=\"parser-field-tags-select\"\n          />\n          <TagSelect\n            className=\"parser-field-tags-select\"\n            isMulti\n            isDisabled={!props.parserResult.isSet}\n            onSelect={(items) => {\n              maybeValueChanged(items.map((i) => i.id));\n            }}\n            ids={newTags}\n          />\n        </Form.Group>\n      </td>\n    </>\n  );\n}\n\nfunction SceneParserStudioField(props: ISceneParserFieldProps<string>) {\n  function maybeValueChanged(value: string) {\n    if (value !== props.parserResult.value) {\n      props.onValueChanged(value);\n    }\n  }\n\n  const originalStudio = props.originalParserResult?.originalValue\n    ? [props.originalParserResult?.originalValue]\n    : [];\n  const newStudio = props.parserResult.value ? [props.parserResult.value] : [];\n\n  return (\n    <>\n      <td>\n        <Form.Check\n          checked={props.parserResult.isSet}\n          onChange={() => {\n            props.onSetChanged(!props.parserResult.isSet);\n          }}\n        />\n      </td>\n      <td>\n        <Form.Group className={props.className}>\n          <StudioSelect\n            isDisabled\n            ids={originalStudio}\n            className=\"parser-field-studio-select\"\n          />\n          <StudioSelect\n            className=\"parser-field-studio-select\"\n            isDisabled={!props.parserResult.isSet}\n            onSelect={(items) => {\n              maybeValueChanged(items[0].id);\n            }}\n            ids={newStudio}\n          />\n        </Form.Group>\n      </td>\n    </>\n  );\n}\n\ninterface ISceneParserRowProps {\n  scene: SceneParserResult;\n  onChange: (changedScene: SceneParserResult) => void;\n  showFields: Map<string, boolean>;\n}\n\nexport const SceneParserRow = (props: ISceneParserRowProps) => {\n  function changeParser<T>(result: ParserResult<T>, isSet: boolean, value?: T) {\n    const newParser = clone(result);\n    newParser.isSet = isSet;\n    newParser.value = value;\n    return newParser;\n  }\n\n  function onTitleChanged(set: boolean, value: string) {\n    const newResult = clone(props.scene);\n    newResult.title = changeParser(newResult.title, set, value);\n    props.onChange(newResult);\n  }\n\n  function onDateChanged(set: boolean, value: string) {\n    const newResult = clone(props.scene);\n    newResult.date = changeParser(newResult.date, set, value);\n    props.onChange(newResult);\n  }\n\n  function onRatingChanged(set: boolean, value?: number) {\n    const newResult = clone(props.scene);\n    newResult.rating = changeParser(newResult.rating, set, value);\n    props.onChange(newResult);\n  }\n\n  function onPerformerIdsChanged(set: boolean, value: string[]) {\n    const newResult = clone(props.scene);\n    newResult.performers = changeParser(newResult.performers, set, value);\n    props.onChange(newResult);\n  }\n\n  function onTagIdsChanged(set: boolean, value: string[]) {\n    const newResult = clone(props.scene);\n    newResult.tags = changeParser(newResult.tags, set, value);\n    props.onChange(newResult);\n  }\n\n  function onStudioIdChanged(set: boolean, value: string) {\n    const newResult = clone(props.scene);\n    newResult.studio = changeParser(newResult.studio, set, value);\n    props.onChange(newResult);\n  }\n\n  return (\n    <tr className=\"scene-parser-row\">\n      <td className=\"text-left parser-field-filename\">\n        {props.scene.filename}\n      </td>\n      {props.showFields.get(\"Title\") && (\n        <SceneParserStringField\n          key=\"title\"\n          className=\"parser-field-title input-control text-input\"\n          parserResult={props.scene.title}\n          onSetChanged={(isSet) =>\n            onTitleChanged(isSet, props.scene.title.value ?? \"\")\n          }\n          onValueChanged={(value) =>\n            onTitleChanged(props.scene.title.isSet, value)\n          }\n        />\n      )}\n      {props.showFields.get(\"Date\") && (\n        <SceneParserStringField\n          key=\"date\"\n          className=\"parser-field-date input-control text-input\"\n          parserResult={props.scene.date}\n          onSetChanged={(isSet) =>\n            onDateChanged(isSet, props.scene.date.value ?? \"\")\n          }\n          onValueChanged={(value) =>\n            onDateChanged(props.scene.date.isSet, value)\n          }\n        />\n      )}\n      {props.showFields.get(\"Rating\") && (\n        <SceneParserRatingField\n          key=\"rating\"\n          className=\"parser-field-rating\"\n          parserResult={props.scene.rating}\n          onSetChanged={(isSet) =>\n            onRatingChanged(isSet, props.scene.rating.value ?? undefined)\n          }\n          onValueChanged={(value) =>\n            onRatingChanged(props.scene.rating.isSet, value)\n          }\n        />\n      )}\n      {props.showFields.get(\"Performers\") && (\n        <SceneParserPerformerField\n          key=\"performers\"\n          className=\"parser-field-performers\"\n          parserResult={props.scene.performers}\n          originalParserResult={props.scene.performers}\n          onSetChanged={(set) =>\n            onPerformerIdsChanged(set, props.scene.performers.value ?? [])\n          }\n          onValueChanged={(value) =>\n            onPerformerIdsChanged(props.scene.performers.isSet, value)\n          }\n        />\n      )}\n      {props.showFields.get(\"Tags\") && (\n        <SceneParserTagField\n          key=\"tags\"\n          className=\"parser-field-tags\"\n          parserResult={props.scene.tags}\n          originalParserResult={props.scene.tags}\n          onSetChanged={(isSet) =>\n            onTagIdsChanged(isSet, props.scene.tags.value ?? [])\n          }\n          onValueChanged={(value) =>\n            onTagIdsChanged(props.scene.tags.isSet, value)\n          }\n        />\n      )}\n      {props.showFields.get(\"Studio\") && (\n        <SceneParserStudioField\n          key=\"studio\"\n          className=\"parser-field-studio\"\n          parserResult={props.scene.studio}\n          originalParserResult={props.scene.studio}\n          onSetChanged={(set) =>\n            onStudioIdChanged(set, props.scene.studio.value ?? \"\")\n          }\n          onValueChanged={(value) =>\n            onStudioIdChanged(props.scene.studio.isSet, value)\n          }\n        />\n      )}\n    </tr>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/SceneFilenameParser/ShowFields.tsx",
    "content": "import {\n  faCheck,\n  faChevronDown,\n  faChevronRight,\n  faTimes,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState } from \"react\";\nimport { Button, Collapse } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\n\ninterface IShowFieldsProps {\n  fields: Map<string, boolean>;\n  onShowFieldsChanged: (fields: Map<string, boolean>) => void;\n}\n\nexport const ShowFields: React.FC<IShowFieldsProps> = (props) => {\n  const intl = useIntl();\n  const [open, setOpen] = useState(false);\n\n  function handleClick(label: string) {\n    const copy = new Map<string, boolean>(props.fields);\n    copy.set(label, !props.fields.get(label));\n    props.onShowFieldsChanged(copy);\n  }\n\n  const fieldRows = [...props.fields.entries()].map(([label, enabled]) => (\n    <Button\n      className=\"minimal d-block\"\n      key={label}\n      onClick={() => {\n        handleClick(label);\n      }}\n    >\n      <Icon icon={enabled ? faCheck : faTimes} />\n      <span>{label}</span>\n    </Button>\n  ));\n\n  return (\n    <div>\n      <Button onClick={() => setOpen(!open)} className=\"minimal\">\n        <Icon icon={open ? faChevronDown : faChevronRight} />\n        <span>\n          {intl.formatMessage({\n            id: \"config.tools.scene_filename_parser.display_fields\",\n          })}\n        </span>\n      </Button>\n      <Collapse in={open}>\n        <div>{fieldRows}</div>\n      </Collapse>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/SceneFilenameParser/styles.scss",
    "content": "#recipe-select::after {\n  content: none;\n}\n\n.scene-parser-results {\n  margin-left: 31ch;\n  overflow-x: auto;\n}\n\n.scene-parser-row {\n  .parser-field-filename {\n    left: 1ch;\n    position: absolute;\n    width: 30ch;\n  }\n\n  .parser-field-title {\n    width: 40ch;\n  }\n\n  .parser-field-date {\n    width: 13ch;\n  }\n\n  .parser-field-performers {\n    width: 30ch;\n  }\n\n  .parser-field-performers-select,\n  .parser-field-tags-select,\n  .parser-field-studio-select {\n    margin-bottom: 0.5rem;\n  }\n\n  .parser-field-tags {\n    width: 30ch;\n  }\n\n  .parser-field-studio {\n    width: 20ch;\n  }\n\n  .form-control {\n    min-width: 10ch;\n  }\n\n  .form-control + .form-control {\n    margin-top: 0.5rem;\n  }\n\n  .badge-items {\n    background-color: #e9ecef;\n    margin-bottom: 0.25rem;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport videojs, { VideoJsPlayer } from \"video.js\";\n\ninterface ControlOptions extends videojs.ComponentOptions {\n  direction: \"forward\" | \"back\";\n  parent: SkipButtonPlugin;\n}\n\nclass SkipButtonPlugin extends videojs.getPlugin(\"plugin\") {\n  onNext?: () => void;\n  onPrevious?: () => void;\n\n  constructor(player: VideoJsPlayer) {\n    super(player);\n    player.ready(() => {\n      this.ready();\n    });\n  }\n\n  public setForwardHandler(handler?: () => void) {\n    this.onNext = handler;\n    if (handler !== undefined) this.player.addClass(\"vjs-skip-buttons-next\");\n    else this.player.removeClass(\"vjs-skip-buttons-next\");\n  }\n\n  public setBackwardHandler(handler?: () => void) {\n    this.onPrevious = handler;\n    if (handler !== undefined) this.player.addClass(\"vjs-skip-buttons-prev\");\n    else this.player.removeClass(\"vjs-skip-buttons-prev\");\n  }\n\n  handleForward() {\n    this.onNext?.();\n  }\n\n  handleBackward() {\n    this.onPrevious?.();\n  }\n\n  ready() {\n    this.player.addClass(\"vjs-skip-buttons\");\n\n    this.player.controlBar.addChild(\n      \"skipButton\",\n      {\n        direction: \"forward\",\n        parent: this,\n      },\n      1\n    );\n\n    this.player.controlBar.addChild(\n      \"skipButton\",\n      {\n        direction: \"back\",\n        parent: this,\n      },\n      0\n    );\n  }\n}\n\nclass SkipButton extends videojs.getComponent(\"button\") {\n  private parentPlugin: SkipButtonPlugin;\n  private direction: \"forward\" | \"back\";\n\n  constructor(player: VideoJsPlayer, options: ControlOptions) {\n    super(player, options);\n    this.parentPlugin = options.parent;\n    this.direction = options.direction;\n    if (options.direction === \"forward\") {\n      this.controlText(this.localize(\"Skip to next video\"));\n      this.addClass(`vjs-icon-next-item`);\n    } else if (options.direction === \"back\") {\n      this.controlText(this.localize(\"Skip to previous video\"));\n      this.addClass(`vjs-icon-previous-item`);\n    }\n  }\n\n  /**\n   * Return button class names\n   */\n  buildCSSClass() {\n    return `vjs-skip-button ${super.buildCSSClass()}`;\n  }\n\n  /**\n   * Seek with the button's configured offset\n   */\n  handleClick() {\n    if (this.direction === \"forward\") this.parentPlugin.handleForward();\n    else this.parentPlugin.handleBackward();\n  }\n}\n\nvideojs.registerComponent(\"SkipButton\", SkipButton);\nvideojs.registerPlugin(\"skipButtons\", SkipButtonPlugin);\n\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    skipButtons: () => SkipButtonPlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    skipButtons?: {};\n  }\n}\n\nexport default SkipButtonPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx",
    "content": "import React, {\n  KeyboardEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport videojs, { VideoJsPlayer, VideoJsPlayerOptions } from \"video.js\";\nimport useScript from \"src/hooks/useScript\";\nimport \"videojs-contrib-dash\";\nimport \"videojs-mobile-ui\";\nimport \"videojs-seek-buttons\";\nimport { UAParser } from \"ua-parser-js\";\nimport \"./live\";\nimport \"./PlaylistButtons\";\nimport \"./source-selector\";\nimport \"./persist-volume\";\nimport \"./autostart-button\";\nimport MarkersPlugin, { type IMarker } from \"./markers\";\nvoid MarkersPlugin;\nimport \"./vtt-thumbnails\";\nimport \"./big-buttons\";\nimport \"./track-activity\";\nimport \"./vrmode\";\nimport \"./media-session\";\nimport \"./wake-sentinel\";\nimport cx from \"classnames\";\nimport {\n  useSceneSaveActivity,\n  useSceneIncrementPlayCount,\n  useConfigureInterface,\n} from \"src/core/StashService\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ScenePlayerScrubber } from \"./ScenePlayerScrubber\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport {\n  ConnectionState,\n  InteractiveContext,\n} from \"src/hooks/Interactive/context\";\nimport { SceneInteractiveStatus } from \"src/hooks/Interactive/status\";\nimport { languageMap } from \"src/utils/caption\";\nimport { VIDEO_PLAYER_ID } from \"./util\";\n\n// @ts-ignore\nimport airplay from \"@silvermine/videojs-airplay\";\n// @ts-ignore\nimport chromecast from \"@silvermine/videojs-chromecast\";\nimport abLoopPlugin from \"videojs-abloop\";\nimport ScreenUtils from \"src/utils/screen\";\nimport { PatchComponent } from \"src/patch\";\n\n// register videojs plugins\nairplay(videojs);\nchromecast(videojs);\nabLoopPlugin(window, videojs);\n\nfunction handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) {\n  function seekStep(step: number) {\n    const time = player.currentTime() + step;\n    const duration = player.duration();\n    if (time < 0) {\n      player.currentTime(0);\n    } else if (time < duration) {\n      player.currentTime(time);\n    } else {\n      player.currentTime(duration);\n    }\n  }\n\n  function seekPercent(percent: number) {\n    const duration = player.duration();\n    const time = duration * percent;\n    player.currentTime(time);\n  }\n\n  function seekPercentRelative(percent: number) {\n    const duration = player.duration();\n    const currentTime = player.currentTime();\n    const time = currentTime + duration * percent;\n    if (time > duration) return;\n    player.currentTime(time);\n  }\n\n  function toggleABLooping() {\n    const opts = player.abLoopPlugin.getOptions();\n    if (!opts.start) {\n      opts.start = player.currentTime();\n    } else if (!opts.end) {\n      opts.end = player.currentTime();\n      opts.enabled = true;\n    } else {\n      opts.start = 0;\n      opts.end = 0;\n      opts.enabled = false;\n    }\n    player.abLoopPlugin.setOptions(opts);\n  }\n\n  let seekFactor = 10;\n  if (event.shiftKey) {\n    seekFactor = 5;\n  } else if (event.ctrlKey || event.altKey) {\n    seekFactor = 60;\n  }\n  switch (event.which) {\n    case 39: // right arrow\n      seekStep(seekFactor);\n      break;\n    case 37: // left arrow\n      seekStep(-seekFactor);\n      break;\n  }\n\n  // toggle player looping with shift+l\n  if (event.shiftKey && event.which === 76) {\n    player.loop(!player.loop());\n    return;\n  }\n\n  if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {\n    return;\n  }\n\n  const skipButtons = player.skipButtons();\n  if (skipButtons) {\n    // handle multimedia keys\n    switch (event.key) {\n      case \"MediaTrackNext\":\n        if (!skipButtons.onNext) return;\n        skipButtons.onNext();\n        break;\n      case \"MediaTrackPrevious\":\n        if (!skipButtons.onPrevious) return;\n        skipButtons.onPrevious();\n        break;\n      // MediaPlayPause handled by videojs\n    }\n  }\n\n  switch (event.which) {\n    case 32: // space\n    case 13: // enter\n      if (player.paused()) player.play();\n      else player.pause();\n      break;\n    case 77: // m\n      player.muted(!player.muted());\n      break;\n    case 70: // f\n      if (player.isFullscreen()) player.exitFullscreen();\n      else player.requestFullscreen();\n      break;\n    case 76: // l\n      toggleABLooping();\n      break;\n    case 38: // up arrow\n      player.volume(player.volume() + 0.1);\n      break;\n    case 40: // down arrow\n      player.volume(player.volume() - 0.1);\n      break;\n    case 48: // 0\n      player.currentTime(0);\n      break;\n    case 49: // 1\n      seekPercent(0.1);\n      break;\n    case 50: // 2\n      seekPercent(0.2);\n      break;\n    case 51: // 3\n      seekPercent(0.3);\n      break;\n    case 52: // 4\n      seekPercent(0.4);\n      break;\n    case 53: // 5\n      seekPercent(0.5);\n      break;\n    case 54: // 6\n      seekPercent(0.6);\n      break;\n    case 55: // 7\n      seekPercent(0.7);\n      break;\n    case 56: // 8\n      seekPercent(0.8);\n      break;\n    case 57: // 9\n      seekPercent(0.9);\n      break;\n    case 221: // ]\n      seekPercentRelative(0.1);\n      break;\n    case 219: // [\n      seekPercentRelative(-0.1);\n      break;\n  }\n}\n\ntype MarkerFragment = Pick<GQL.SceneMarker, \"title\" | \"seconds\"> & {\n  primary_tag: Pick<GQL.Tag, \"name\">;\n  tags: Array<Pick<GQL.Tag, \"name\">>;\n};\n\nfunction getMarkerTitle(marker: MarkerFragment) {\n  if (marker.title) {\n    return marker.title;\n  }\n\n  let ret = marker.primary_tag.name;\n  if (marker.tags.length) {\n    ret += `, ${marker.tags.map((t) => t.name).join(\", \")}`;\n  }\n\n  return ret;\n}\n\ninterface IScenePlayerProps {\n  scene: GQL.SceneDataFragment;\n  hideScrubberOverride: boolean;\n  autoplay?: boolean;\n  permitLoop?: boolean;\n  initialTimestamp: number;\n  sendSetTimestamp: (setTimestamp: (value: number) => void) => void;\n  onComplete: () => void;\n  onNext: () => void;\n  onPrevious: () => void;\n}\n\nexport const ScenePlayer: React.FC<IScenePlayerProps> = PatchComponent(\n  \"ScenePlayer\",\n  ({\n    scene,\n    hideScrubberOverride,\n    autoplay,\n    permitLoop = true,\n    initialTimestamp: _initialTimestamp,\n    sendSetTimestamp,\n    onComplete,\n    onNext,\n    onPrevious,\n  }) => {\n    const { configuration } = useConfigurationContext();\n    const interfaceConfig = configuration?.interface;\n    const uiConfig = configuration?.ui;\n    const videoRef = useRef<HTMLDivElement>(null);\n    const [_player, setPlayer] = useState<VideoJsPlayer>();\n    const sceneId = useRef<string>();\n    const [sceneSaveActivity] = useSceneSaveActivity();\n    const [sceneIncrementPlayCount] = useSceneIncrementPlayCount();\n    const [updateInterfaceConfig] = useConfigureInterface();\n\n    const [time, setTime] = useState(0);\n    const [ready, setReady] = useState(false);\n\n    const {\n      interactive: interactiveClient,\n      uploadScript,\n      currentScript,\n      initialised: interactiveInitialised,\n      state: interactiveState,\n    } = React.useContext(InteractiveContext);\n\n    const [fullscreen, setFullscreen] = useState(false);\n    const [showScrubber, setShowScrubber] = useState(false);\n\n    const started = useRef(false);\n    const auto = useRef(false);\n    const interactiveReady = useRef(false);\n    const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0;\n    const trackActivity = uiConfig?.trackActivity ?? true;\n    const vrTag = uiConfig?.vrTag ?? undefined;\n\n    useScript(\n      \"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1\",\n      uiConfig?.enableChromecast\n    );\n\n    const file = useMemo(\n      () => (scene.files.length > 0 ? scene.files[0] : undefined),\n      [scene]\n    );\n\n    const maxLoopDuration = interfaceConfig?.maximumLoopDuration ?? 0;\n    const looping = useMemo(\n      () =>\n        !!file?.duration &&\n        permitLoop &&\n        maxLoopDuration !== 0 &&\n        file.duration < maxLoopDuration,\n      [file, permitLoop, maxLoopDuration]\n    );\n\n    const getPlayer = useCallback(() => {\n      if (!_player) return null;\n      if (_player.isDisposed()) return null;\n      return _player;\n    }, [_player]);\n\n    useEffect(() => {\n      if (hideScrubberOverride || fullscreen) {\n        setShowScrubber(false);\n        return;\n      }\n\n      const onResize = () => {\n        const show = window.innerHeight >= 450 && !ScreenUtils.isMobile();\n        setShowScrubber(show);\n      };\n      onResize();\n\n      window.addEventListener(\"resize\", onResize);\n\n      return () => window.removeEventListener(\"resize\", onResize);\n    }, [hideScrubberOverride, fullscreen]);\n\n    useEffect(() => {\n      sendSetTimestamp((value: number) => {\n        const player = getPlayer();\n        if (player && value >= 0) {\n          if (player.hasStarted() && player.paused()) {\n            player.currentTime(value);\n          } else {\n            player.play()?.then(() => {\n              player.currentTime(value);\n            });\n          }\n        }\n      });\n    }, [sendSetTimestamp, getPlayer]);\n\n    // Initialize VideoJS player\n    useEffect(() => {\n      const options: VideoJsPlayerOptions = {\n        id: VIDEO_PLAYER_ID,\n        controls: true,\n        controlBar: {\n          pictureInPictureToggle: false,\n          volumePanel: {\n            inline: false,\n          },\n          chaptersButton: false,\n        },\n        html5: {\n          dash: {\n            updateSettings: [\n              {\n                streaming: {\n                  buffer: {\n                    bufferTimeAtTopQuality: 30,\n                    bufferTimeAtTopQualityLongForm: 30,\n                  },\n                  gaps: {\n                    jumpGaps: false,\n                    jumpLargeGaps: false,\n                  },\n                },\n              },\n            ],\n          },\n        },\n        nativeControlsForTouch: false,\n        playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],\n        inactivityTimeout: 700,\n        preload: \"none\",\n        playsinline: true,\n        techOrder: [\"chromecast\", \"html5\"],\n        userActions: {\n          hotkeys: function (this: VideoJsPlayer, event) {\n            handleHotkeys(this, event);\n          },\n        },\n        plugins: {\n          airPlay: {\n            addButtonToControlBar: uiConfig?.enableChromecast ?? false,\n          },\n          chromecast: {},\n          vttThumbnails: {\n            showTimestamp: true,\n          },\n          markers: {},\n          sourceSelector: {},\n          persistVolume: {},\n          bigButtons: {},\n          seekButtons: {\n            forward: 10,\n            back: 10,\n          },\n          skipButtons: {},\n          trackActivity: {},\n          vrMenu: {},\n          autostartButton: {\n            enabled: interfaceConfig?.autostartVideo ?? false,\n          },\n          abLoopPlugin: {\n            start: 0,\n            end: false,\n            enabled: false,\n            loopIfBeforeStart: true,\n            loopIfAfterEnd: true,\n            pauseAfterLooping: false,\n            pauseBeforeLooping: false,\n            createButtons: uiConfig?.showAbLoopControls ?? false,\n          },\n          mediaSession: {},\n          wakeSentinel: {},\n        },\n      };\n\n      const videoEl = document.createElement(\"video-js\");\n      videoEl.setAttribute(\"data-vjs-player\", \"true\");\n      videoEl.setAttribute(\"crossorigin\", \"anonymous\");\n      videoEl.classList.add(\"vjs-big-play-centered\");\n      videoRef.current!.appendChild(videoEl);\n\n      const vjs = videojs(videoEl, options);\n\n      /* eslint-disable-next-line @typescript-eslint/no-explicit-any */\n      const settings = (vjs as any).textTrackSettings;\n      settings.setValues({\n        backgroundColor: \"#000\",\n        backgroundOpacity: \"0.5\",\n      });\n      settings.updateDisplay();\n\n      vjs.focus();\n      setPlayer(vjs);\n\n      // Video player destructor\n      return () => {\n        vjs.dispose();\n        videoEl.remove();\n        setPlayer(undefined);\n\n        // reset sceneId to force reload sources\n        sceneId.current = undefined;\n      };\n      // empty deps - only init once\n      // showAbLoopControls is necessary to re-init the player when the config changes\n      // Note: interfaceConfig?.autostartVideo is intentionally excluded to prevent\n      // player re-initialization when toggling autostart (which would interrupt playback)\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [uiConfig?.showAbLoopControls, uiConfig?.enableChromecast]);\n\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n      const skipButtons = player.skipButtons();\n      skipButtons.setForwardHandler(onNext);\n      skipButtons.setBackwardHandler(onPrevious);\n    }, [getPlayer, onNext, onPrevious]);\n\n    useEffect(() => {\n      if (scene.interactive && interactiveInitialised) {\n        interactiveReady.current = false;\n        uploadScript(scene.paths.funscript || \"\").then(() => {\n          interactiveReady.current = true;\n        });\n      }\n    }, [\n      uploadScript,\n      interactiveInitialised,\n      scene.interactive,\n      scene.paths.funscript,\n    ]);\n\n    // play the script if video started before script upload finished\n    useEffect(() => {\n      if (interactiveState !== ConnectionState.Ready) return;\n      const player = getPlayer();\n      if (!player || player.paused()) return;\n      interactiveClient.ensurePlaying(player.currentTime());\n    }, [interactiveState, getPlayer, interactiveClient]);\n\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      const vrMenu = player.vrMenu();\n\n      let showButton = false;\n\n      if (vrTag) {\n        showButton = scene.tags.some((tag) => vrTag === tag.name);\n      }\n\n      vrMenu.setShowButton(showButton);\n    }, [getPlayer, scene, vrTag]);\n\n    // Player event handlers\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      function canplay(this: VideoJsPlayer) {\n        // if we're seeking before starting, don't set the initial timestamp\n        // when starting from the beginning, there is a small delay before the event\n        // is triggered, so we can't just check if the time is 0\n        if (this.currentTime() >= 0.1) {\n          return;\n        }\n      }\n\n      function playing(this: VideoJsPlayer) {\n        // This still runs even if autoplay failed on Safari,\n        // only set flag if actually playing\n        if (!started.current && !this.paused()) {\n          started.current = true;\n        }\n      }\n\n      function loadstart(this: VideoJsPlayer) {\n        setReady(true);\n      }\n\n      function fullscreenchange(this: VideoJsPlayer) {\n        setFullscreen(this.isFullscreen());\n      }\n\n      player.on(\"canplay\", canplay);\n      player.on(\"playing\", playing);\n      player.on(\"loadstart\", loadstart);\n      player.on(\"fullscreenchange\", fullscreenchange);\n\n      return () => {\n        player.off(\"canplay\", canplay);\n        player.off(\"playing\", playing);\n        player.off(\"loadstart\", loadstart);\n        player.off(\"fullscreenchange\", fullscreenchange);\n      };\n    }, [getPlayer]);\n\n    // delay before second play event after a play event to adjust for video player issues\n    const DELAY_FOR_SECOND_PLAY_MS = 1000;\n    const playingTimer = useRef<number>();\n\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      function playing(this: VideoJsPlayer) {\n        if (scene.interactive && interactiveReady.current) {\n          interactiveClient.play(this.currentTime());\n          // trigger a second script play event to adjust for video player issues\n          clearTimeout(playingTimer.current);\n          playingTimer.current = setTimeout(() => {\n            if (this.paused()) return;\n            interactiveClient.play(this.currentTime());\n          }, DELAY_FOR_SECOND_PLAY_MS);\n        }\n      }\n\n      function pause(this: VideoJsPlayer) {\n        interactiveClient.pause();\n      }\n\n      function timeupdate(this: VideoJsPlayer) {\n        if (this.paused()) return;\n        setTime(this.currentTime());\n      }\n\n      player.on(\"playing\", playing);\n      player.on(\"pause\", pause);\n      player.on(\"timeupdate\", timeupdate);\n\n      return () => {\n        player.off(\"playing\", playing);\n        player.off(\"pause\", pause);\n        player.off(\"timeupdate\", timeupdate);\n        clearTimeout(playingTimer.current);\n      };\n    }, [getPlayer, interactiveClient, scene]);\n\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      // don't re-initialise the player unless the scene has changed\n      if (!file || scene.id === sceneId.current) return;\n\n      sceneId.current = scene.id;\n\n      setReady(false);\n\n      // reset on new scene\n      player.trackActivity().reset();\n\n      // always stop the interactive client on initialisation\n      interactiveClient.pause();\n\n      const isSafari = UAParser().browser.name?.includes(\"Safari\");\n      const isLandscape = file.height && file.width && file.width > file.height;\n      const mobileUiOptions = {\n        fullscreen: {\n          enterOnRotate: true,\n          exitOnRotate: true,\n          lockOnRotate: true,\n          lockToLandscapeOnEnter: uiConfig?.disableMobileMediaAutoRotateEnabled\n            ? false\n            : isLandscape,\n        },\n        touchControls: {\n          disabled: true,\n        },\n      };\n      if (!isSafari) {\n        player.mobileUi(mobileUiOptions);\n      }\n\n      function isDirect(src: URL) {\n        return (\n          src.pathname.endsWith(\"/stream\") ||\n          src.pathname.endsWith(\"/stream.mpd\") ||\n          src.pathname.endsWith(\"/stream.m3u8\")\n        );\n      }\n\n      const { duration } = file;\n      const sourceSelector = player.sourceSelector();\n      sourceSelector.setSources(\n        scene.sceneStreams\n          .filter((stream) => {\n            const src = new URL(stream.url);\n            const isFileTranscode = !isDirect(src);\n\n            return !(isFileTranscode && isSafari);\n          })\n          .map((stream) => {\n            const src = new URL(stream.url);\n\n            return {\n              src: stream.url,\n              type: stream.mime_type ?? undefined,\n              label: stream.label ?? undefined,\n              offset: !isDirect(src),\n              duration,\n            };\n          })\n      );\n\n      function getDefaultLanguageCode() {\n        let languageCode = window.navigator.language;\n\n        if (languageCode.indexOf(\"-\") !== -1) {\n          languageCode = languageCode.split(\"-\")[0];\n        }\n\n        if (languageCode.indexOf(\"_\") !== -1) {\n          languageCode = languageCode.split(\"_\")[0];\n        }\n\n        return languageCode;\n      }\n\n      if (scene.captions && scene.captions.length > 0) {\n        const languageCode = getDefaultLanguageCode();\n        let hasDefault = false;\n\n        for (let caption of scene.captions) {\n          const lang = caption.language_code;\n          let label = lang;\n          if (languageMap.has(lang)) {\n            label = languageMap.get(lang)!;\n          }\n\n          label = label + \" (\" + caption.caption_type + \")\";\n          const setAsDefault = !hasDefault && languageCode == lang;\n          if (setAsDefault) {\n            hasDefault = true;\n          }\n          sourceSelector.addTextTrack(\n            {\n              src: `${scene.paths.caption}?lang=${lang}&type=${caption.caption_type}`,\n              kind: \"captions\",\n              srclang: lang,\n              label: label,\n              default: setAsDefault,\n            },\n            false\n          );\n        }\n      }\n\n      const alwaysStartFromBeginning =\n        uiConfig?.alwaysStartFromBeginning ?? false;\n      const resumeTime = scene.resume_time ?? 0;\n\n      let startPosition = _initialTimestamp;\n      if (\n        !startPosition &&\n        !alwaysStartFromBeginning &&\n        file.duration > resumeTime\n      ) {\n        startPosition = resumeTime;\n      }\n\n      setTime(startPosition);\n\n      player.load();\n      player.focus();\n\n      // Check the autostart button plugin for user preference\n      const autostartButton = player.autostartButton();\n      const buttonEnabled = autostartButton.getEnabled();\n      auto.current =\n        autoplay ||\n        buttonEnabled ||\n        (interfaceConfig?.autostartVideo ?? false) ||\n        _initialTimestamp > 0;\n\n      player.ready(() => {\n        player.vttThumbnails().src(scene.paths.vtt ?? null);\n\n        if (startPosition) {\n          player.currentTime(startPosition);\n        }\n      });\n\n      started.current = false;\n    }, [\n      getPlayer,\n      file,\n      scene,\n      interactiveClient,\n      autoplay,\n      interfaceConfig?.autostartVideo,\n      uiConfig?.alwaysStartFromBeginning,\n      uiConfig?.disableMobileMediaAutoRotateEnabled,\n      _initialTimestamp,\n    ]);\n\n    useEffect(() => {\n      return () => {\n        // stop the interactive client on unmount\n        interactiveClient.pause();\n      };\n    }, [interactiveClient]);\n\n    const loadMarkers = useCallback(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      const markerData = scene.scene_markers.map((marker) => ({\n        title: getMarkerTitle(marker),\n        seconds: marker.seconds,\n        end_seconds: marker.end_seconds ?? null,\n        primaryTag: marker.primary_tag,\n      }));\n\n      const markers = player!.markers();\n\n      const uniqueTagNames = markerData\n        .map((marker) => marker.primaryTag.name)\n        .filter((value, index, self) => self.indexOf(value) === index);\n\n      // Wait for colors\n      markers.findColors(uniqueTagNames);\n\n      const showRangeTags =\n        !ScreenUtils.isMobile() && (uiConfig?.showRangeMarkers ?? true);\n      const timestampMarkers: IMarker[] = [];\n      const rangeMarkers: IMarker[] = [];\n\n      if (!showRangeTags) {\n        for (const marker of markerData) {\n          timestampMarkers.push(marker);\n        }\n      } else {\n        for (const marker of markerData) {\n          if (marker.end_seconds === null) {\n            timestampMarkers.push(marker);\n          } else {\n            rangeMarkers.push(marker);\n          }\n        }\n      }\n\n      requestAnimationFrame(() => {\n        markers.addDotMarkers(timestampMarkers);\n        markers.addRangeMarkers(rangeMarkers);\n      });\n    }, [getPlayer, scene, uiConfig]);\n\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      if (scene.paths.screenshot) {\n        player.poster(scene.paths.screenshot);\n      } else {\n        player.poster(\"\");\n      }\n\n      // Define the event handler outside the useEffect\n      const handleLoadMetadata = () => {\n        loadMarkers();\n      };\n\n      // Ensure markers are added after player is fully ready and sources are loaded\n      if (player.readyState() >= 1) {\n        loadMarkers();\n      } else {\n        player.on(\"loadedmetadata\", handleLoadMetadata);\n      }\n\n      return () => {\n        player.off(\"loadedmetadata\", handleLoadMetadata);\n        const markers = player!.markers();\n        markers.clearMarkers();\n      };\n    }, [getPlayer, scene, loadMarkers]);\n\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      async function saveActivity(resumeTime: number, playDuration: number) {\n        if (!scene.id) return;\n\n        await sceneSaveActivity({\n          variables: {\n            id: scene.id,\n            playDuration,\n            resume_time: resumeTime,\n          },\n        });\n      }\n\n      async function incrementPlayCount() {\n        if (!scene.id) return;\n\n        await sceneIncrementPlayCount({\n          variables: {\n            id: scene.id,\n          },\n        });\n      }\n\n      const activity = player.trackActivity();\n      activity.saveActivity = saveActivity;\n      activity.incrementPlayCount = incrementPlayCount;\n      activity.minimumPlayPercent = minimumPlayPercent;\n      activity.setEnabled(trackActivity);\n    }, [\n      getPlayer,\n      scene,\n      vrTag,\n      trackActivity,\n      minimumPlayPercent,\n      sceneIncrementPlayCount,\n      sceneSaveActivity,\n    ]);\n\n    // Sync autostart button with config changes\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      async function updateAutoStart(enabled: boolean) {\n        await updateInterfaceConfig({\n          variables: {\n            input: {\n              autostartVideo: enabled,\n            },\n          },\n        });\n      }\n\n      const autostartButton = player.autostartButton();\n      if (autostartButton) {\n        autostartButton.syncWithConfig(\n          interfaceConfig?.autostartVideo ?? false\n        );\n        autostartButton.updateAutoStart = updateAutoStart;\n      }\n    }, [getPlayer, updateInterfaceConfig, interfaceConfig?.autostartVideo]);\n\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      player.loop(looping);\n      interactiveClient.setLooping(looping);\n    }, [getPlayer, interactiveClient, looping]);\n\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player || !ready || !auto.current) {\n        return;\n      }\n\n      // check if we're waiting for the interactive client\n      if (\n        scene.interactive &&\n        interactiveClient.handyKey &&\n        currentScript !== scene.paths.funscript\n      ) {\n        return;\n      }\n\n      player.play();\n      auto.current = false;\n    }, [getPlayer, scene, ready, interactiveClient, currentScript]);\n\n    // Attach handler for onComplete event\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      player.on(\"ended\", onComplete);\n\n      return () => player.off(\"ended\");\n    }, [getPlayer, onComplete]);\n\n    // set up mediaSession plugin\n    useEffect(() => {\n      const player = getPlayer();\n      if (!player) return;\n\n      // set up mediasession plugin\n      // get performer names as array\n      const performers = scene?.performers.map((p) => p.name).join(\", \");\n      player\n        .mediaSession()\n        .setMetadata(\n          scene?.title ?? \"Stash\",\n          scene?.studio?.name ?? performers ?? \"Stash\",\n          scene.paths.screenshot || \"\"\n        );\n    }, [getPlayer, scene]);\n\n    const pausedBeforeScrubber = useRef(true);\n\n    function onScrubberScroll() {\n      const player = getPlayer();\n      if (started.current && player) {\n        pausedBeforeScrubber.current = player.paused();\n        player.pause();\n      }\n    }\n\n    function onScrubberSeek(seconds: number) {\n      const player = getPlayer();\n      if (started.current && player) {\n        player.currentTime(seconds);\n        if (!pausedBeforeScrubber.current) {\n          player.play();\n        }\n      } else {\n        setTime(seconds);\n      }\n    }\n\n    // Override spacebar to always pause/play\n    function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) {\n      const player = getPlayer();\n      if (!player) return;\n\n      if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {\n        return;\n      }\n      if (event.key == \" \") {\n        event.preventDefault();\n        event.stopPropagation();\n        if (player.paused()) {\n          player.play();\n        } else {\n          player.pause();\n        }\n      }\n    }\n\n    const isPortrait =\n      file && file.height && file.width && file.height > file.width;\n\n    return (\n      <div\n        className={cx(\"VideoPlayer\", {\n          portrait: isPortrait,\n          \"no-file\": !file,\n        })}\n        onKeyDownCapture={onKeyDown}\n      >\n        <div className=\"video-wrapper\" ref={videoRef} />\n        {scene.interactive &&\n          (interactiveState !== ConnectionState.Ready ||\n            getPlayer()?.paused()) && <SceneInteractiveStatus />}\n        {file && showScrubber && (\n          <ScenePlayerScrubber\n            file={file}\n            scene={scene}\n            time={time}\n            onSeek={onScrubberSeek}\n            onScroll={onScrubberScroll}\n          />\n        )}\n      </div>\n    );\n  }\n);\n\nexport default ScenePlayer;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx",
    "content": "import React, {\n  CSSProperties,\n  useEffect,\n  useRef,\n  useState,\n  useCallback,\n} from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport {\n  faChevronRight,\n  faChevronLeft,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { useSpriteInfo } from \"src/hooks/sprite\";\n\ninterface IScenePlayerScrubberProps {\n  file: GQL.VideoFileDataFragment;\n  scene: GQL.SceneDataFragment;\n  time: number;\n  onSeek: (seconds: number) => void;\n  onScroll: () => void;\n}\n\ninterface ISceneSpriteItem {\n  style: CSSProperties;\n  time: string;\n}\n\nconst scrubberViewportHeight = 120;\nconst scrubberTagsHeight = 30;\nconst scrubberSpriteHeight = scrubberViewportHeight - scrubberTagsHeight;\n\nexport const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = ({\n  file,\n  scene,\n  time,\n  onSeek,\n  onScroll,\n}) => {\n  const contentEl = useRef<HTMLDivElement>(null);\n  const indicatorEl = useRef<HTMLDivElement>(null);\n  const sliderEl = useRef<HTMLDivElement>(null);\n  const mouseDown = useRef(false);\n  const lastMouseEvent = useRef<MouseEvent | null>(null);\n  const startMouseEvent = useRef<MouseEvent | null>(null);\n  const velocity = useRef(0);\n\n  const prevTime = useRef(NaN);\n  const _width = useRef(0);\n  const [width, setWidth] = useState(0);\n  const [scrubWidth, setScrubWidth] = useState(0);\n  const position = useRef(0);\n  const setPosition = useCallback(\n    (value: number, seek: boolean) => {\n      if (!scrubWidth) return;\n\n      const slider = sliderEl.current!;\n      const indicator = indicatorEl.current!;\n\n      const midpointOffset = slider.clientWidth / 2;\n\n      let newPosition: number;\n      let percentage: number;\n      if (value >= midpointOffset) {\n        percentage = 0;\n        newPosition = midpointOffset;\n      } else if (value <= midpointOffset - scrubWidth) {\n        percentage = 1;\n        newPosition = midpointOffset - scrubWidth;\n      } else {\n        percentage = (midpointOffset - value) / scrubWidth;\n        newPosition = value;\n      }\n\n      slider.style.transform = `translateX(${newPosition}px)`;\n      indicator.style.transform = `translateX(${percentage * 100}%)`;\n\n      position.current = newPosition;\n\n      if (seek) {\n        onSeek(percentage * (file.duration || 0));\n      }\n    },\n    [onSeek, file.duration, scrubWidth]\n  );\n\n  const spriteInfo = useSpriteInfo(scene.paths.vtt ?? undefined);\n  const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>();\n\n  useEffect(() => {\n    if (!spriteInfo || spriteInfo.length === 0) return;\n    let totalWidth = 0;\n\n    // calculate total width/height of scrubber image so we can scale it\n    const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w));\n    const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h));\n    const spriteWidth = spriteInfo[0].w;\n    const spriteHeight = spriteInfo[0].h;\n    const scale = scrubberSpriteHeight / spriteHeight;\n\n    const w = spriteWidth * scale;\n    const h = scrubberSpriteHeight;\n\n    const sizeX = maxX * scale;\n    const sizeY = maxY * scale;\n\n    // scale sprite dimensions to fit scrubber height, and calculate background position for each sprite\n    const newSprites = spriteInfo?.map((sprite, index) => {\n      totalWidth += w;\n      const left = w * index;\n\n      const spriteX = sprite.x * scale;\n      const spriteY = sprite.y * scale;\n\n      const style = {\n        width: `${w}px`,\n        height: `${h}px`,\n        backgroundPosition: `${-spriteX}px ${-spriteY}px`,\n        backgroundImage: `url(${sprite.url})`,\n        backgroundSize: `${sizeX}px ${sizeY}px`,\n        left: `${left}px`,\n      };\n      const start = TextUtils.secondsToTimestamp(sprite.start);\n      const end = TextUtils.secondsToTimestamp(sprite.end);\n      return {\n        style,\n        time: `${start} - ${end}`,\n      };\n    });\n    setScrubWidth(totalWidth);\n    setSpriteItems(newSprites);\n  }, [spriteInfo]);\n\n  useEffect(() => {\n    const onResize = (entries: ResizeObserverEntry[]) => {\n      const newWidth = entries[0].target.clientWidth;\n      if (_width.current != newWidth) {\n        // set prevTime to NaN to not use a transition when updating the slider position\n        prevTime.current = NaN;\n        _width.current = newWidth;\n        setWidth(newWidth);\n      }\n    };\n\n    const content = contentEl.current!;\n    const resizeObserver = new ResizeObserver(onResize);\n    resizeObserver.observe(content);\n\n    return () => {\n      resizeObserver.unobserve(content);\n    };\n  }, []);\n\n  function setLinearTransition() {\n    const slider = sliderEl.current!;\n    slider.style.transition = \"500ms linear\";\n  }\n\n  function setEaseOutTransition() {\n    const slider = sliderEl.current!;\n    slider.style.transition = \"333ms ease-out\";\n  }\n\n  function clearTransition() {\n    const slider = sliderEl.current!;\n    slider.style.transition = \"\";\n  }\n\n  // Update slider position when player time changes\n  useEffect(() => {\n    if (!scrubWidth || !width) return;\n\n    const duration = Number(file.duration);\n    const percentage = time / duration;\n    const newPosition = width / 2 - percentage * scrubWidth;\n\n    // Ignore position changes of < 1px\n    if (Math.abs(newPosition - position.current) < 1) return;\n\n    const delta = Math.abs(time - prevTime.current);\n    if (isNaN(delta)) {\n      // Don't use a transition on initial time change or after resize\n      clearTransition();\n    } else if (delta <= 1) {\n      // If time changed by < 1s, use linear transition instead of ease-out\n      setLinearTransition();\n    } else {\n      setEaseOutTransition();\n    }\n    prevTime.current = time;\n\n    setPosition(newPosition, false);\n  }, [file.duration, setPosition, time, width, scrubWidth]);\n\n  const onMouseUp = useCallback(\n    (event: MouseEvent) => {\n      if (!mouseDown.current) return;\n      const slider = sliderEl.current!;\n\n      mouseDown.current = false;\n\n      contentEl.current!.classList.remove(\"dragging\");\n\n      let newPosition = position.current;\n      const midpointOffset = slider.clientWidth / 2;\n      const delta = Math.abs(event.clientX - startMouseEvent.current!.clientX);\n      if (delta < 1 && event.target instanceof HTMLDivElement) {\n        const { target } = event;\n\n        if (target.hasAttribute(\"data-sprite-item-id\")) {\n          newPosition = midpointOffset - (target.offsetLeft + event.offsetX);\n        }\n\n        if (target.hasAttribute(\"data-marker-id\")) {\n          newPosition = midpointOffset - target.offsetLeft;\n        }\n      }\n      if (Math.abs(velocity.current) > 25) {\n        newPosition = position.current + velocity.current * 10;\n        velocity.current = 0;\n      }\n\n      setEaseOutTransition();\n      setPosition(newPosition, true);\n    },\n    [setPosition]\n  );\n\n  const onMouseDown = useCallback((event: MouseEvent) => {\n    // Only if left mouse button pressed\n    if (event.button !== 0) return;\n\n    event.preventDefault();\n\n    mouseDown.current = true;\n    lastMouseEvent.current = event;\n    startMouseEvent.current = event;\n    velocity.current = 0;\n  }, []);\n\n  const onMouseMove = useCallback(\n    (event: MouseEvent) => {\n      if (!mouseDown.current) return;\n\n      // negative dragging right (past), positive left (future)\n      const delta = event.clientX - lastMouseEvent.current!.clientX;\n\n      if (lastMouseEvent.current === startMouseEvent.current) {\n        // this is the first mousemove event after mousedown\n\n        // #4295: a mousemove with delta 0 can be sent when just clicking\n        // ignore such an event to prevent pausing the player\n        if (delta === 0) return;\n\n        onScroll();\n      }\n\n      contentEl.current!.classList.add(\"dragging\");\n\n      const movement = event.movementX;\n      velocity.current = movement;\n\n      clearTransition();\n      setPosition(position.current + delta, false);\n      lastMouseEvent.current = event;\n    },\n    [onScroll, setPosition]\n  );\n\n  useEffect(() => {\n    const content = contentEl.current!;\n\n    content.addEventListener(\"mousedown\", onMouseDown, false);\n    content.addEventListener(\"mousemove\", onMouseMove, false);\n    window.addEventListener(\"mouseup\", onMouseUp, false);\n\n    return () => {\n      content.removeEventListener(\"mousedown\", onMouseDown);\n      content.removeEventListener(\"mousemove\", onMouseMove);\n      window.removeEventListener(\"mouseup\", onMouseUp);\n    };\n  }, [onMouseDown, onMouseMove, onMouseUp]);\n\n  function goBack() {\n    const slider = sliderEl.current!;\n    const newPosition = position.current + slider.clientWidth;\n    setEaseOutTransition();\n    setPosition(newPosition, true);\n  }\n\n  function goForward() {\n    const slider = sliderEl.current!;\n    const newPosition = position.current - slider.clientWidth;\n    setEaseOutTransition();\n    setPosition(newPosition, true);\n  }\n\n  function renderTags() {\n    if (!spriteItems) return;\n\n    return scene.scene_markers.map((marker, index) => {\n      const { duration } = file;\n      const left = (scrubWidth * marker.seconds) / duration;\n      const style = { left: `${left}px` };\n\n      return (\n        <div\n          key={index}\n          className=\"scrubber-tag\"\n          style={style}\n          data-marker-id={index}\n        >\n          {marker.title || marker.primary_tag.name}\n        </div>\n      );\n    });\n  }\n\n  function renderSprites() {\n    if (!scene.paths.vtt) return;\n\n    return spriteItems?.map((sprite, index) => {\n      return (\n        <div\n          key={index}\n          className=\"scrubber-item\"\n          style={sprite.style}\n          data-sprite-item-id={index}\n        >\n          <span className=\"scrubber-item-time\">{sprite.time}</span>\n        </div>\n      );\n    });\n  }\n\n  return (\n    <div className=\"scrubber-wrapper\">\n      <Button\n        className=\"scrubber-button\"\n        id=\"scrubber-back\"\n        onClick={() => goBack()}\n      >\n        <Icon className=\"fa-fw\" icon={faChevronLeft} />\n      </Button>\n      <div ref={contentEl} className=\"scrubber-content\">\n        <div className=\"scrubber-tags-background\" />\n        <div\n          className=\"scrubber-heatmap\"\n          style={{\n            backgroundImage: scene.paths.interactive_heatmap\n              ? `url(${scene.paths.interactive_heatmap})`\n              : undefined,\n          }}\n        />\n        <div ref={indicatorEl} id=\"scrubber-position-indicator\" />\n        <div id=\"scrubber-current-position\" />\n        <div className=\"scrubber-viewport\">\n          <div ref={sliderEl} className=\"scrubber-slider\">\n            <div className=\"scrubber-tags\">{renderTags()}</div>\n            {renderSprites()}\n          </div>\n        </div>\n      </div>\n      <Button\n        className=\"scrubber-button\"\n        id=\"scrubber-forward\"\n        onClick={() => goForward()}\n      >\n        <Icon className=\"fa-fw\" icon={faChevronRight} />\n      </Button>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/autostart-button.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport videojs, { VideoJsPlayer } from \"video.js\";\n\ninterface IAutostartButtonOptions {\n  enabled?: boolean;\n}\n\ninterface AutostartButtonOptions extends videojs.ComponentOptions {\n  autostartEnabled: boolean;\n}\n\nclass AutostartButton extends videojs.getComponent(\"Button\") {\n  private autostartEnabled: boolean;\n\n  constructor(player: VideoJsPlayer, options: AutostartButtonOptions) {\n    super(player, options);\n    this.autostartEnabled = options.autostartEnabled;\n    this.updateIcon();\n  }\n\n  buildCSSClass() {\n    return `vjs-autostart-button ${super.buildCSSClass()}`;\n  }\n\n  private updateIcon() {\n    this.removeClass(\"vjs-icon-play-circle\");\n    this.removeClass(\"vjs-icon-cancel\");\n\n    if (this.autostartEnabled) {\n      this.addClass(\"vjs-icon-play-circle\");\n      this.controlText(this.localize(\"Auto-start enabled (click to disable)\"));\n    } else {\n      this.addClass(\"vjs-icon-cancel\");\n      this.controlText(this.localize(\"Auto-start disabled (click to enable)\"));\n    }\n  }\n\n  handleClick(event: Event) {\n    // Prevent the click from bubbling up and affecting the video player\n    event.stopPropagation();\n\n    this.autostartEnabled = !this.autostartEnabled;\n    this.updateIcon();\n    this.trigger(\"autostartchanged\", { enabled: this.autostartEnabled });\n  }\n\n  public setEnabled(enabled: boolean) {\n    this.autostartEnabled = enabled;\n    this.updateIcon();\n  }\n}\n\nclass AutostartButtonPlugin extends videojs.getPlugin(\"plugin\") {\n  private button: AutostartButton;\n  private autostartEnabled: boolean;\n  updateAutoStart: (enabled: boolean) => Promise<void> = () => {\n    return Promise.resolve();\n  };\n\n  constructor(player: VideoJsPlayer, options?: IAutostartButtonOptions) {\n    super(player, options);\n\n    this.autostartEnabled = options?.enabled ?? false;\n\n    this.button = new AutostartButton(player, {\n      autostartEnabled: this.autostartEnabled,\n    });\n\n    player.ready(() => {\n      this.ready();\n    });\n  }\n\n  private ready() {\n    // Add button to control bar, before the fullscreen button\n    const { controlBar } = this.player;\n    const fullscreenToggle = controlBar.getChild(\"fullscreenToggle\");\n    if (fullscreenToggle) {\n      controlBar.addChild(this.button);\n      controlBar.el().insertBefore(this.button.el(), fullscreenToggle.el());\n    } else {\n      controlBar.addChild(this.button);\n    }\n\n    // Listen for changes\n    this.button.on(\"autostartchanged\", (_, data: { enabled: boolean }) => {\n      this.autostartEnabled = data.enabled;\n      this.updateAutoStart(this.autostartEnabled);\n    });\n  }\n\n  public isEnabled(): boolean {\n    return this.autostartEnabled;\n  }\n\n  public getEnabled(): boolean {\n    return this.autostartEnabled;\n  }\n\n  public setEnabled(enabled: boolean) {\n    this.autostartEnabled = enabled;\n    this.button.setEnabled(enabled);\n  }\n\n  public syncWithConfig(configEnabled: boolean) {\n    // Sync button state with external config changes\n    if (this.autostartEnabled !== configEnabled) {\n      this.setEnabled(configEnabled);\n    }\n  }\n}\n\n// Register the plugin with video.js.\nvideojs.registerComponent(\"AutostartButton\", AutostartButton);\nvideojs.registerPlugin(\"autostartButton\", AutostartButtonPlugin);\n\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    autostartButton: () => AutostartButtonPlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    autostartButton?: IAutostartButtonOptions;\n  }\n}\n\nexport default AutostartButtonPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/big-buttons.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\n\n// prettier-ignore\nconst BigPlayButton = videojs.getComponent(\"BigPlayButton\") as unknown as typeof videojs.BigPlayButton;\n\nclass BigPlayPauseButton extends BigPlayButton {\n  handleClick(event: videojs.EventTarget.Event) {\n    if (this.player().paused()) {\n      super.handleClick(event);\n    } else {\n      this.player().pause();\n    }\n  }\n\n  buildCSSClass() {\n    return \"vjs-control vjs-button vjs-big-play-pause-button\";\n  }\n}\n\nclass BigButtonGroup extends videojs.getComponent(\"Component\") {\n  constructor(player: VideoJsPlayer) {\n    super(player);\n\n    this.addChild(\"seekButton\", {\n      direction: \"back\",\n      seconds: 10,\n    });\n\n    this.addChild(\"BigPlayPauseButton\");\n\n    this.addChild(\"seekButton\", {\n      direction: \"forward\",\n      seconds: 10,\n    });\n  }\n\n  createEl() {\n    return super.createEl(\"div\", {\n      className: \"vjs-big-button-group\",\n    });\n  }\n}\n\nclass BigButtonsPlugin extends videojs.getPlugin(\"plugin\") {\n  constructor(player: VideoJsPlayer) {\n    super(player);\n\n    player.ready(() => {\n      player.addChild(\"BigButtonGroup\");\n    });\n  }\n}\n\n// Register the plugin with video.js.\nvideojs.registerComponent(\"BigButtonGroup\", BigButtonGroup);\nvideojs.registerComponent(\"BigPlayPauseButton\", BigPlayPauseButton);\nvideojs.registerPlugin(\"bigButtons\", BigButtonsPlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    bigButtons: () => BigButtonsPlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    bigButtons?: {};\n  }\n}\n\nexport default BigButtonsPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/live.ts",
    "content": "import { debounce } from \"lodash-es\";\nimport videojs, { VideoJsPlayer } from \"video.js\";\n\nexport interface ISource extends videojs.Tech.SourceObject {\n  offset?: boolean;\n  duration?: number;\n}\n\ninterface ICue extends TextTrackCue {\n  _startTime?: number;\n  _endTime?: number;\n}\n\n// delay before loading new source after setting currentTime\nconst loadDelay = 200;\n\nfunction offsetMiddleware(player: VideoJsPlayer) {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow access to private tech methods\n  let tech: any;\n  let source: ISource;\n  let offsetStart: number | undefined;\n  let seeking = 0;\n\n  function initCues(cues: TextTrackCueList) {\n    const offset = offsetStart ?? 0;\n    for (let j = 0; j < cues.length; j++) {\n      const cue = cues[j] as ICue;\n      cue._startTime = cue.startTime;\n      cue.startTime = cue._startTime - offset;\n      cue._endTime = cue.endTime;\n      cue.endTime = cue._endTime - offset;\n    }\n  }\n\n  function updateOffsetStart(offset: number | undefined) {\n    offsetStart = offset;\n\n    if (!tech) return;\n    offset = offset ?? 0;\n\n    const tracks = tech.remoteTextTracks();\n    for (let i = 0; i < tracks.length; i++) {\n      const { cues } = tracks[i];\n      if (cues) {\n        for (let j = 0; j < cues.length; j++) {\n          const cue = cues[j] as ICue;\n          if (cue._startTime === undefined || cue._endTime === undefined) {\n            continue;\n          }\n          cue.startTime = cue._startTime - offset;\n          cue.endTime = cue._endTime - offset;\n        }\n      }\n    }\n  }\n\n  const loadSource = debounce(\n    (seconds: number) => {\n      const srcUrl = new URL(source.src);\n      srcUrl.searchParams.set(\"start\", seconds.toString());\n      source.src = srcUrl.toString();\n\n      const poster = player.poster();\n      const playbackRate = tech.playbackRate();\n      seeking = tech.paused() ? 1 : 2;\n      player.poster(\"\");\n      tech.setSource(source);\n      tech.setPlaybackRate(playbackRate);\n      tech.one(\"canplay\", () => {\n        player.poster(poster);\n        if (seeking === 1 || tech.scrubbing()) {\n          tech.pause();\n        }\n        seeking = 0;\n      });\n      tech.trigger(\"timeupdate\");\n      tech.trigger(\"pause\");\n      tech.trigger(\"seeking\");\n      tech.play();\n    },\n    loadDelay,\n    { leading: true }\n  );\n\n  return {\n    setTech(newTech: videojs.Tech) {\n      tech = newTech;\n\n      const _addRemoteTextTrack = tech.addRemoteTextTrack.bind(tech);\n      function addRemoteTextTrack(\n        this: VideoJsPlayer,\n        options: videojs.TextTrackOptions,\n        manualCleanup: boolean\n      ) {\n        const textTrack = _addRemoteTextTrack(options, manualCleanup);\n        textTrack.addEventListener(\"load\", () => {\n          const { cues } = textTrack.track;\n          if (cues) {\n            initCues(cues);\n          }\n        });\n\n        return textTrack;\n      }\n      tech.addRemoteTextTrack = addRemoteTextTrack;\n\n      const trackEls: HTMLTrackElement[] = tech.remoteTextTrackEls();\n      for (let i = 0; i < trackEls.length; i++) {\n        const trackEl = trackEls[i];\n        const { track } = trackEl;\n        if (track.cues) {\n          initCues(track.cues);\n        } else {\n          trackEl.addEventListener(\"load\", () => {\n            if (track.cues) {\n              initCues(track.cues);\n            }\n          });\n        }\n      }\n    },\n    setSource(\n      srcObj: ISource,\n      next: (err: unknown, src: videojs.Tech.SourceObject) => void\n    ) {\n      if (srcObj.offset && srcObj.duration) {\n        updateOffsetStart(0);\n      } else {\n        updateOffsetStart(undefined);\n      }\n      source = srcObj;\n      next(null, srcObj);\n    },\n    duration(seconds: number) {\n      if (source.duration) {\n        return source.duration;\n      } else {\n        return seconds;\n      }\n    },\n    buffered(buffers: TimeRanges) {\n      if (offsetStart === undefined) {\n        return buffers;\n      }\n\n      const timeRanges: number[][] = [];\n      for (let i = 0; i < buffers.length; i++) {\n        const start = buffers.start(i) + offsetStart;\n        const end = buffers.end(i) + offsetStart;\n\n        timeRanges.push([start, end]);\n      }\n\n      // types for createTimeRanges are incorrect, should be number[][] not TimeRange[]\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      return videojs.createTimeRanges(timeRanges as any);\n    },\n    currentTime(seconds: number) {\n      return (offsetStart ?? 0) + seconds;\n    },\n    setCurrentTime(seconds: number) {\n      if (offsetStart === undefined) {\n        return seconds;\n      }\n\n      const offsetSeconds = seconds - offsetStart;\n      const buffers = tech.buffered() as TimeRanges;\n      for (let i = 0; i < buffers.length; i++) {\n        const start = buffers.start(i);\n        const end = buffers.end(i);\n        // seek point is in buffer, just seek normally\n        if (start <= offsetSeconds && offsetSeconds <= end) {\n          return offsetSeconds;\n        }\n      }\n\n      updateOffsetStart(seconds);\n\n      loadSource(seconds);\n\n      return 0;\n    },\n    callPlay() {\n      if (seeking) {\n        seeking = 2;\n        return videojs.middleware.TERMINATOR;\n      }\n    },\n  };\n}\n\nvideojs.use(\"*\", offsetMiddleware);\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/markers.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\nimport CryptoJS from \"crypto-js\";\n\nexport interface IMarker {\n  title: string;\n  seconds: number;\n  end_seconds?: number | null;\n  primaryTag: { name: string };\n}\n\ninterface IMarkersOptions {\n  markers?: IMarker[];\n}\n\nclass MarkersPlugin extends videojs.getPlugin(\"plugin\") {\n  private markers: IMarker[] = [];\n  private markerDivs: {\n    dot?: HTMLDivElement;\n    range?: HTMLDivElement;\n    containedRanges?: HTMLDivElement[];\n  }[] = [];\n  private markerTooltip: HTMLElement | null = null;\n  private defaultTooltip: HTMLElement | null = null;\n\n  private layerHeight: number = 9;\n\n  private tagColors: { [tag: string]: string } = {};\n\n  constructor(player: VideoJsPlayer) {\n    super(player);\n    player.ready(() => {\n      const tooltip = videojs.dom.createEl(\"div\") as HTMLElement;\n      tooltip.className = \"vjs-marker-tooltip\";\n      tooltip.style.visibility = \"hidden\";\n\n      const parent = player\n        .el()\n        .querySelector(\".vjs-progress-holder .vjs-mouse-display\");\n      if (parent) parent.appendChild(tooltip);\n      this.markerTooltip = tooltip;\n\n      this.defaultTooltip = player\n        .el()\n        .querySelector<HTMLElement>(\n          \".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip\"\n        );\n    });\n  }\n\n  private showMarkerTooltip(title: string, layer: number = 0) {\n    if (!this.markerTooltip) return;\n    this.markerTooltip.innerText = title;\n    this.markerTooltip.style.right = `${-this.markerTooltip.clientWidth / 2}px`;\n    this.markerTooltip.style.top = `-${this.layerHeight * layer + 50}px`;\n    this.markerTooltip.style.visibility = \"visible\";\n    if (this.defaultTooltip) this.defaultTooltip.style.visibility = \"hidden\";\n  }\n\n  private hideMarkerTooltip() {\n    if (this.markerTooltip) this.markerTooltip.style.visibility = \"hidden\";\n    if (this.defaultTooltip) this.defaultTooltip.style.visibility = \"visible\";\n  }\n\n  addDotMarker(marker: IMarker) {\n    const duration = this.player.duration();\n    const markerSet: {\n      dot?: HTMLDivElement;\n      range?: HTMLDivElement;\n    } = {};\n    const seekBar = this.player.el().querySelector(\".vjs-progress-holder\");\n\n    markerSet.dot = videojs.dom.createEl(\"div\") as HTMLDivElement;\n    markerSet.dot.className = \"vjs-marker\";\n    if (duration) {\n      // marker is 6px wide - adjust by 3px to align to center not left side\n      markerSet.dot.style.left = `calc(${\n        (marker.seconds / duration) * 100\n      }% - 3px)`;\n      markerSet.dot.style.visibility = \"visible\";\n    }\n\n    // Add event listeners to dot\n    markerSet.dot.addEventListener(\"click\", () =>\n      this.player.currentTime(marker.seconds)\n    );\n    markerSet.dot.toggleAttribute(\"marker-tooltip-shown\", true);\n\n    // Set background color based on tag (if available)\n    if (\n      marker.primaryTag &&\n      marker.primaryTag.name &&\n      this.tagColors[marker.primaryTag.name]\n    ) {\n      markerSet.dot.style.backgroundColor =\n        this.tagColors[marker.primaryTag.name];\n    }\n    markerSet.dot.addEventListener(\"mouseenter\", () => {\n      this.showMarkerTooltip(marker.title);\n      markerSet.dot?.toggleAttribute(\"marker-tooltip-shown\", true);\n    });\n\n    markerSet.dot.addEventListener(\"mouseout\", () => {\n      this.hideMarkerTooltip();\n      markerSet.dot?.toggleAttribute(\"marker-tooltip-shown\", false);\n    });\n\n    if (seekBar) {\n      seekBar.appendChild(markerSet.dot);\n    }\n    this.markers.push(marker);\n    this.markerDivs.push(markerSet);\n  }\n\n  addDotMarkers(markers: IMarker[]) {\n    markers.forEach(this.addDotMarker, this);\n  }\n\n  private renderRangeMarkers(markers: IMarker[], layer: number) {\n    const duration = this.player.duration();\n    const parent = this.player.el().querySelector(\".vjs-progress-control\");\n    const seekBar = this.player.el().querySelector(\".vjs-progress-holder\");\n    if (!seekBar || !parent || !duration) return;\n\n    markers.forEach((marker) => {\n      this.renderRangeMarker(marker, layer, duration, seekBar, parent);\n    });\n  }\n\n  private renderRangeMarker(\n    marker: IMarker,\n    layer: number,\n    duration: number,\n    seekBar: Element,\n    parent: Element\n  ) {\n    if (!marker.end_seconds) return;\n\n    const markerSet: {\n      dot?: HTMLDivElement;\n      range?: HTMLDivElement;\n    } = {};\n    const rangeDiv = videojs.dom.createEl(\"div\") as HTMLDivElement;\n    rangeDiv.className = \"vjs-marker-range\";\n\n    // Use percentage-based positioning for proper scaling in fullscreen mode\n    // The range marker is inside vjs-progress-control, but needs to align with\n    // vjs-progress-holder which has 15px margins on each side.\n    // We use calc() to combine percentage positioning with the fixed margin offset.\n    const startPercent = (marker.seconds / duration) * 100;\n    const widthPercent =\n      ((marker.end_seconds - marker.seconds) / duration) * 100;\n\n    // left: 15px margin + percentage of the progress holder width\n    // Since progress-holder has margin: 0 15px, we need calc(15px + X% of remaining width)\n    // The progress-holder width is (100% - 30px), so the actual left position is:\n    // 15px + startPercent% * (100% - 30px) = 15px + startPercent% * 100% - startPercent% * 30px\n    rangeDiv.style.left = `calc(15px + ${startPercent}% - ${\n      startPercent * 0.3\n    }px)`;\n\n    rangeDiv.style.width = `calc(${widthPercent}% - ${widthPercent * 0.3}px)`;\n    rangeDiv.style.bottom = `${layer * this.layerHeight}px`; // Adjust height based on layer\n    rangeDiv.style.display = \"none\"; // Initially hidden\n\n    // Set background color based on tag (if available)\n    if (\n      marker.primaryTag &&\n      marker.primaryTag.name &&\n      this.tagColors[marker.primaryTag.name]\n    ) {\n      rangeDiv.style.backgroundColor = this.tagColors[marker.primaryTag.name];\n    }\n\n    markerSet.range = rangeDiv;\n    markerSet.range.style.display = \"block\";\n    markerSet.range.addEventListener(\"pointermove\", (e) => {\n      e.stopPropagation();\n    });\n    markerSet.range.addEventListener(\"pointerover\", (e) => {\n      e.stopPropagation();\n    });\n    markerSet.range.addEventListener(\"pointerout\", (e) => {\n      e.stopPropagation();\n    });\n    markerSet.range.addEventListener(\"mouseenter\", () => {\n      this.showMarkerTooltip(marker.title, layer);\n      markerSet.range?.toggleAttribute(\"marker-tooltip-shown\", true);\n    });\n\n    markerSet.range.addEventListener(\"mouseout\", () => {\n      this.hideMarkerTooltip();\n      markerSet.range?.toggleAttribute(\"marker-tooltip-shown\", false);\n    });\n    parent.appendChild(rangeDiv);\n    this.markers.push(marker);\n    this.markerDivs.push(markerSet);\n  }\n\n  addRangeMarkers(markers: IMarker[]) {\n    let remainingMarkers = [...markers];\n    let layerNum = 0;\n\n    while (remainingMarkers.length > 0) {\n      // Get the set of markers that currently have the highest total duration that don't overlap. We do this layer by layer to prioritize filling\n      // the lower layers when possible\n      const mwis = this.findMWIS(remainingMarkers);\n      if (!mwis.length) break;\n\n      this.renderRangeMarkers(mwis, layerNum);\n      remainingMarkers = remainingMarkers.filter(\n        (marker) => !mwis.includes(marker)\n      );\n      layerNum++;\n    }\n  }\n\n  // Use dynamic programming to find maximum weight independent set (ie the set of markers that have the highest total duration that don't overlap)\n  private findMWIS(markers: IMarker[]): IMarker[] {\n    if (!markers.length) return [];\n\n    // Sort markers by end time\n    markers = markers\n      .slice()\n      .sort((a, b) => (a.end_seconds || 0) - (b.end_seconds || 0));\n    const n = markers.length;\n\n    // Compute p(j) for each marker. This is the index of the marker that has the highest end time that doesn't overlap with marker j\n    const p: number[] = new Array(n).fill(-1);\n    for (let j = 0; j < n; j++) {\n      for (let i = j - 1; i >= 0; i--) {\n        if ((markers[i].end_seconds || 0) <= markers[j].seconds) {\n          p[j] = i;\n          break;\n        }\n      }\n    }\n\n    // Initialize M[j]\n    // Compute M[j] for each marker. This is the maximum total duration of markers that don't overlap with marker j\n    const M: number[] = new Array(n).fill(0);\n    for (let j = 0; j < n; j++) {\n      const include =\n        (markers[j].end_seconds || 0) - markers[j].seconds + (M[p[j]] || 0);\n      const exclude = j > 0 ? M[j - 1] : 0;\n      M[j] = Math.max(include, exclude);\n    }\n\n    // Reconstruct optimal solution\n    const findSolution = (j: number): IMarker[] => {\n      if (j < 0) return [];\n      const include =\n        (markers[j].end_seconds || 0) - markers[j].seconds + (M[p[j]] || 0);\n      const exclude = j > 0 ? M[j - 1] : 0;\n      if (include >= exclude) {\n        return [...findSolution(p[j]), markers[j]];\n      } else {\n        return findSolution(j - 1);\n      }\n    };\n\n    return findSolution(n - 1);\n  }\n\n  removeMarker(marker: IMarker) {\n    const i = this.markers.indexOf(marker);\n    if (i === -1) return;\n\n    this.markers.splice(i, 1);\n    const markerSet = this.markerDivs.splice(i, 1)[0];\n\n    if (markerSet.dot?.hasAttribute(\"marker-tooltip-shown\")) {\n      this.hideMarkerTooltip();\n    }\n\n    markerSet.dot?.remove();\n    if (markerSet.range) markerSet.range.remove();\n  }\n\n  removeMarkers(markers: IMarker[]) {\n    markers.forEach(this.removeMarker, this);\n  }\n\n  clearMarkers() {\n    for (const markerSet of this.markerDivs) {\n      if (markerSet.dot?.hasAttribute(\"marker-tooltip-shown\")) {\n        this.hideMarkerTooltip();\n      }\n\n      markerSet.dot?.remove();\n      if (markerSet.range) markerSet.range.remove();\n    }\n    this.markers = [];\n    this.markerDivs = [];\n  }\n\n  // Implementing the findColors method\n  findColors(tagNames: string[]) {\n    // Compute base hues for each tag\n    const baseHues: { [tag: string]: number } = {};\n    for (const tag of tagNames) {\n      baseHues[tag] = this.computeBaseHue(tag);\n    }\n\n    // Adjust hues to avoid similar colors\n    const adjustedHues = this.adjustHues(baseHues);\n\n    // Convert adjusted hues to colors and store in tagColors dictionary\n    for (const tag of tagNames) {\n      this.tagColors[tag] = this.hueToColor(adjustedHues[tag]);\n    }\n  }\n\n  // Helper methods translated from Python\n\n  // Compute base hue from tag name\n  private computeBaseHue(tag: string): number {\n    const hash = CryptoJS.SHA256(tag);\n    const hashHex = hash.toString(CryptoJS.enc.Hex);\n    const hashInt = BigInt(`0x${hashHex}`);\n    const baseHue = Number(hashInt % BigInt(360)); // Map to [0, 360)\n    return baseHue;\n  }\n\n  // Calculate minimum acceptable hue difference based on number of tags\n  private calculateDeltaMin(N: number): number {\n    const maxDeltaNeeded = 35;\n    let scalingFactor: number;\n\n    if (N <= 4) {\n      scalingFactor = 0.8;\n    } else if (N <= 10) {\n      scalingFactor = 0.6;\n    } else {\n      scalingFactor = 0.4;\n    }\n\n    const deltaMin = Math.min((360 / N) * scalingFactor, maxDeltaNeeded);\n    return deltaMin;\n  }\n\n  // Adjust hues to ensure minimum difference\n  private adjustHues(baseHues: { [tag: string]: number }): {\n    [tag: string]: number;\n  } {\n    const adjustedHues: { [tag: string]: number } = {};\n    const tags = Object.keys(baseHues);\n    const N = tags.length;\n    const deltaMin = this.calculateDeltaMin(N);\n\n    // Sort the tags by base hue\n    const sortedTags = tags.sort((a, b) => baseHues[a] - baseHues[b]);\n    // Get sorted base hues\n    const baseHuesSorted = sortedTags.map((tag) => baseHues[tag]);\n\n    // Unwrap hues to handle circular nature\n    const unwrappedHues = [...baseHuesSorted];\n    for (let i = 1; i < N; i++) {\n      if (unwrappedHues[i] <= unwrappedHues[i - 1]) {\n        unwrappedHues[i] += 360; // Unwrap by adding 360 degrees\n      }\n    }\n\n    // Adjust hues to ensure minimum difference\n    for (let i = 1; i < N; i++) {\n      const requiredHue = unwrappedHues[i - 1] + deltaMin;\n      if (unwrappedHues[i] < requiredHue) {\n        unwrappedHues[i] = requiredHue; // Adjust hue minimally\n      }\n    }\n\n    // Handle wrap-around difference\n    const endGap = unwrappedHues[0] + 360 - unwrappedHues[N - 1];\n    if (endGap < deltaMin) {\n      // Adjust first and last hues minimally to increase end gap\n      const adjustmentNeeded = (deltaMin - endGap) / 2;\n      // Adjust the first hue backward, ensure it doesn't go below other hues\n      unwrappedHues[0] = Math.max(\n        unwrappedHues[0] - adjustmentNeeded,\n        unwrappedHues[1] - 360 + deltaMin\n      );\n      // Adjust the last hue forward\n      unwrappedHues[N - 1] += adjustmentNeeded;\n    }\n\n    // Wrap adjusted hues back to [0, 360)\n    const adjustedHuesList = unwrappedHues.map((hue) => hue % 360);\n\n    // Map adjusted hues back to tags\n    for (let i = 0; i < N; i++) {\n      adjustedHues[sortedTags[i]] = adjustedHuesList[i];\n    }\n\n    return adjustedHues;\n  }\n\n  // Convert hue to RGB color in hex format\n  private hueToColor(hue: number): string {\n    // Convert hue from degrees to [0, 1)\n    const hueNormalized = hue / 360.0;\n    const saturation = 0.65;\n    const value = 0.95;\n    const rgb = this.hsvToRgb(hueNormalized, saturation, value);\n    const alpha = 0.6; // Set the desired alpha value here\n    const rgbColor = `#${this.toHex(rgb[0])}${this.toHex(rgb[1])}${this.toHex(\n      rgb[2]\n    )}${this.toHex(Math.round(alpha * 255))}`;\n    return rgbColor;\n  }\n\n  // Convert HSV to RGB\n  private hsvToRgb(h: number, s: number, v: number): [number, number, number] {\n    const i = Math.floor(h * 6);\n    const f = h * 6 - i;\n    const p = v * (1 - s);\n    const q = v * (1 - f * s);\n    const t = v * (1 - (1 - f) * s);\n\n    let r, g, b;\n    switch (i % 6) {\n      case 0:\n        r = v;\n        g = t;\n        b = p;\n        break;\n      case 1:\n        r = q;\n        g = v;\n        b = p;\n        break;\n      case 2:\n        r = p;\n        g = v;\n        b = t;\n        break;\n      case 3:\n        r = p;\n        g = q;\n        b = v;\n        break;\n      case 4:\n        r = t;\n        g = p;\n        b = v;\n        break;\n      case 5:\n        r = v;\n        g = p;\n        b = q;\n        break;\n      default:\n        r = v;\n        g = t;\n        b = p;\n        break;\n    }\n\n    return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];\n  }\n\n  // Convert a number to two-digit hex string\n  private toHex(value: number): string {\n    return value.toString(16).padStart(2, \"0\");\n  }\n}\n\nvideojs.registerPlugin(\"markers\", MarkersPlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    markers: () => MarkersPlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    markers?: IMarkersOptions;\n  }\n}\n\nexport default MarkersPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/media-session.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\n\nclass MediaSessionPlugin extends videojs.getPlugin(\"plugin\") {\n  constructor(player: VideoJsPlayer) {\n    super(player);\n\n    player.ready(() => {\n      player.addClass(\"vjs-media-session\");\n      this.setActionHandlers();\n    });\n\n    player.on(\"play\", () => {\n      this.updatePlaybackState();\n    });\n\n    player.on(\"pause\", () => {\n      this.updatePlaybackState();\n    });\n    this.updatePlaybackState();\n  }\n\n  // manually set poster since it's only set on useEffect\n  public setMetadata(title: string, artist: string, poster: string): void {\n    if (\"mediaSession\" in navigator) {\n      navigator.mediaSession.metadata = new MediaMetadata({\n        title,\n        artist,\n        artwork: [\n          {\n            src: poster || this.player.poster() || \"\",\n            type: \"image/jpeg\",\n          },\n        ],\n      });\n    }\n  }\n\n  private updatePlaybackState(): void {\n    if (\"mediaSession\" in navigator) {\n      const playbackState = this.player.paused() ? \"paused\" : \"playing\";\n      navigator.mediaSession.playbackState = playbackState;\n    }\n  }\n\n  private setActionHandlers(): void {\n    // method initialization\n    navigator.mediaSession.setActionHandler(\"play\", () => {\n      this.player.play();\n    });\n    navigator.mediaSession.setActionHandler(\"pause\", () => {\n      this.player.pause();\n    });\n    navigator.mediaSession.setActionHandler(\"nexttrack\", () => {\n      this.player.skipButtons()?.handleForward();\n    });\n    navigator.mediaSession.setActionHandler(\"previoustrack\", () => {\n      this.player.skipButtons()?.handleBackward();\n    });\n  }\n}\n\nvideojs.registerPlugin(\"mediaSession\", MediaSessionPlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    mediaSession: () => MediaSessionPlugin;\n  }\n}\n\nexport default MediaSessionPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/persist-volume.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\nimport localForage from \"localforage\";\n\nconst levelKey = \"volume-level\";\nconst mutedKey = \"volume-muted\";\n\ninterface IPersistVolumeOptions {\n  enabled?: boolean;\n}\n\nclass PersistVolumePlugin extends videojs.getPlugin(\"plugin\") {\n  enabled: boolean;\n\n  constructor(player: VideoJsPlayer, options?: IPersistVolumeOptions) {\n    super(player, options);\n\n    this.enabled = options?.enabled ?? true;\n\n    player.on(\"volumechange\", () => {\n      if (this.enabled) {\n        localForage.setItem(levelKey, player.volume());\n        localForage.setItem(mutedKey, player.muted());\n      }\n    });\n\n    player.ready(() => {\n      this.ready();\n    });\n  }\n\n  private ready() {\n    localForage.getItem<number>(levelKey).then((value) => {\n      if (value !== null) {\n        this.player.volume(value);\n      }\n    });\n\n    localForage.getItem<boolean>(mutedKey).then((value) => {\n      if (value !== null) {\n        this.player.muted(value);\n      }\n    });\n  }\n}\n\n// Register the plugin with video.js.\nvideojs.registerPlugin(\"persistVolume\", PersistVolumePlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    persistVolume: () => PersistVolumePlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    persistVolume?: IPersistVolumeOptions;\n  }\n}\n\nexport default PersistVolumePlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/source-selector.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\n\nexport interface ISource extends videojs.Tech.SourceObject {\n  label?: string;\n  errored?: boolean;\n}\n\nclass SourceMenuItem extends videojs.getComponent(\"MenuItem\") {\n  public source: ISource;\n  public isSelected = false;\n\n  constructor(parent: SourceMenuButton, source: ISource) {\n    const options = {} as videojs.MenuItemOptions;\n    options.selectable = true;\n    options.multiSelectable = false;\n    options.label = source.label || source.type;\n\n    super(parent.player(), options);\n\n    this.source = source;\n\n    this.addClass(\"vjs-source-menu-item\");\n  }\n\n  selected(selected: boolean): void {\n    super.selected(selected);\n    this.isSelected = selected;\n  }\n\n  handleClick() {\n    if (this.isSelected) return;\n\n    this.trigger(\"selected\");\n  }\n}\n\nclass SourceMenuButton extends videojs.getComponent(\"MenuButton\") {\n  private items: SourceMenuItem[] = [];\n  private selectedSource: ISource | null = null;\n\n  constructor(player: VideoJsPlayer) {\n    super(player);\n\n    player.on(\"loadstart\", () => {\n      this.update();\n    });\n  }\n\n  public setSources(sources: ISource[]) {\n    this.selectedSource = null;\n\n    this.items = sources.map((source, i) => {\n      if (i === 0) {\n        this.selectedSource = source;\n      }\n\n      const item = new SourceMenuItem(this, source);\n\n      item.on(\"selected\", () => {\n        this.selectedSource = source;\n\n        this.trigger(\"sourceselected\", source);\n      });\n\n      return item;\n    });\n  }\n\n  createEl() {\n    return videojs.dom.createEl(\"div\", {\n      className:\n        \"vjs-source-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button\",\n    });\n  }\n\n  createItems() {\n    if (this.items === undefined) return [];\n\n    for (const item of this.items) {\n      item.selected(item.source === this.selectedSource);\n    }\n\n    return this.items;\n  }\n\n  setSelectedSource(source: ISource) {\n    this.selectedSource = source;\n    if (this.items === undefined) return;\n\n    for (const item of this.items) {\n      item.selected(item.source === this.selectedSource);\n    }\n  }\n\n  markSourceErrored(source: ISource) {\n    const item = this.items.find((i) => i.source.src === source.src);\n    if (item === undefined) return;\n\n    item.addClass(\"vjs-source-menu-item-error\");\n  }\n}\n\nclass SourceSelectorPlugin extends videojs.getPlugin(\"plugin\") {\n  private menu: SourceMenuButton;\n  private sources: ISource[] = [];\n  private selectedIndex = -1;\n  private cleanupTextTracks: HTMLTrackElement[] = [];\n  private manualTextTracks: HTMLTrackElement[] = [];\n\n  // don't auto play next source if user manually selected a source\n  private manuallySelected = false;\n\n  constructor(player: VideoJsPlayer) {\n    super(player);\n\n    this.menu = new SourceMenuButton(player);\n\n    this.menu.on(\"sourceselected\", (_, source: ISource) => {\n      this.selectedIndex = this.sources.findIndex((src) => src === source);\n      if (this.selectedIndex === -1) return;\n\n      this.manuallySelected = true;\n\n      const loadSrc = this.sources[this.selectedIndex];\n\n      const currentTime = player.currentTime();\n      const paused = player.paused();\n\n      player.src(loadSrc);\n      player.one(\"canplay\", () => {\n        if (paused) {\n          player.pause();\n        }\n        player.currentTime(currentTime);\n      });\n      player.play();\n    });\n\n    player.on(\"ready\", () => {\n      const { controlBar } = player;\n      const fullscreenToggle = controlBar.getChild(\"fullscreenToggle\")!.el();\n      controlBar.addChild(this.menu);\n      controlBar.el().insertBefore(this.menu.el(), fullscreenToggle);\n    });\n\n    player.on(\"loadedmetadata\", () => {\n      if (!player.videoWidth() && !player.videoHeight()) {\n        // Occurs during preload when videos with supported audio/unsupported video are preloaded.\n        // Treat this as a decoding error and try the next source without playing.\n        // However on Safari we get an media event when m3u8 or mpd is loaded which needs to be ignored.\n        if (player.error() !== null) return;\n\n        const currentSrc = player.currentSrc();\n        if (currentSrc === null) return;\n\n        if (currentSrc.includes(\".m3u8\") || currentSrc.includes(\".mpd\")) {\n          player.play();\n        } else {\n          player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);\n          return;\n        }\n      }\n    });\n\n    player.on(\"error\", () => {\n      const error = player.error();\n      if (!error) return;\n\n      // Only try next source if media was unsupported\n      if (\n        error.code !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED &&\n        error.code !== MediaError.MEDIA_ERR_DECODE\n      )\n        return;\n\n      const currentSource = player.currentSource() as ISource;\n      console.log(`Source '${currentSource.label}' is unsupported`);\n\n      // mark current source as errored\n      currentSource.errored = true;\n      this.menu.markSourceErrored(currentSource);\n\n      // don't auto play next source if user manually selected a source\n      if (this.manuallySelected) {\n        return;\n      }\n\n      // TODO - make auto play next source configurable\n      // try the next source in the list\n      if (\n        this.selectedIndex !== -1 &&\n        this.selectedIndex + 1 < this.sources.length\n      ) {\n        this.selectedIndex += 1;\n        const newSource = this.sources[this.selectedIndex];\n        console.log(`Trying next source in playlist: '${newSource.label}'`);\n        this.menu.setSelectedSource(newSource);\n\n        const currentTime = player.currentTime();\n        player.src(newSource);\n        player.load();\n        player.one(\"canplay\", () => {\n          player.currentTime(currentTime);\n        });\n        player.play();\n      } else {\n        console.log(\"No more sources in playlist\");\n      }\n    });\n  }\n\n  setSources(sources: ISource[]) {\n    const cleanupTracks = this.cleanupTextTracks.splice(0);\n    for (const track of cleanupTracks) {\n      this.player.removeRemoteTextTrack(track);\n    }\n\n    this.menu.setSources(sources);\n    if (sources.length !== 0) {\n      this.selectedIndex = 0;\n    } else {\n      this.selectedIndex = -1;\n    }\n\n    this.sources = sources;\n    this.player.src(sources[0]);\n  }\n\n  get textTracks(): HTMLTrackElement[] {\n    return [...this.cleanupTextTracks, ...this.manualTextTracks];\n  }\n\n  addTextTrack(options: videojs.TextTrackOptions, manualCleanup: boolean) {\n    const track = this.player.addRemoteTextTrack(options, true);\n    if (manualCleanup) {\n      this.manualTextTracks.push(track);\n    } else {\n      this.cleanupTextTracks.push(track);\n    }\n    return track;\n  }\n\n  removeTextTrack(track: HTMLTrackElement) {\n    this.player.removeRemoteTextTrack(track);\n    let index = this.manualTextTracks.indexOf(track);\n    if (index != -1) {\n      this.manualTextTracks.splice(index, 1);\n    }\n    index = this.cleanupTextTracks.indexOf(track);\n    if (index != -1) {\n      this.cleanupTextTracks.splice(index, 1);\n    }\n  }\n}\n\n// Register the plugin with video.js.\nvideojs.registerComponent(\"SourceMenuButton\", SourceMenuButton);\nvideojs.registerPlugin(\"sourceSelector\", SourceSelectorPlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    sourceSelector: () => SourceSelectorPlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    sourceSelector?: {};\n  }\n}\n\nexport default SourceSelectorPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/styles.scss",
    "content": "@import \"video.js/dist/video-js.css\";\n@import \"videojs-mobile-ui/dist/videojs-mobile-ui.css\";\n@import \"videojs-seek-buttons/dist/videojs-seek-buttons.css\";\n@import \"@silvermine/videojs-chromecast/dist/silvermine-videojs-chromecast.css\";\n@import \"@silvermine/videojs-airplay/dist/silvermine-videojs-airplay.css\";\n\n$scrubberHeight: 120px;\n$menuHeight: 4rem;\n$sceneTabWidth: 450px;\n\n.VideoPlayer {\n  display: flex;\n  flex-direction: column;\n  max-height: calc(100vh - #{$menuHeight});\n  padding-bottom: 0.25rem;\n\n  @media (min-width: 1200px) {\n    height: 100vh;\n  }\n\n  &.portrait .video-wrapper {\n    height: 177.78vw;\n  }\n}\n\n.video-wrapper {\n  height: 56.25vw;\n  overflow: hidden;\n  position: relative;\n  width: 100%;\n\n  @media (min-width: 1200px) {\n    height: 100%;\n  }\n}\n\n.VideoPlayer.no-file .video-js {\n  .vjs-big-play-button,\n  .vjs-control-bar {\n    display: none;\n  }\n}\n\n.video-js {\n  height: 100%;\n  position: absolute;\n  width: 100%;\n\n  &:not(.vjs-has-started) .vjs-control-bar {\n    display: flex;\n  }\n\n  // show controls even when an error is displayed\n  /* stylelint-disable declaration-no-important */\n  &.vjs-error .vjs-control-bar {\n    display: flex !important;\n  }\n  /* stylelint-enable declaration-no-important */\n\n  // allow interaction with the controls when error is displayed\n  .vjs-error-display,\n  .vjs-error-display .vjs-modal-dialog-content {\n    position: static;\n  }\n\n  // hide spinner when error is displayed\n  &.vjs-error .vjs-loading-spinner {\n    display: none;\n  }\n\n  .vjs-button {\n    outline: none;\n  }\n\n  .vjs-big-button-group {\n    display: none;\n    height: 80px;\n    justify-content: space-around;\n    opacity: 0;\n    position: absolute;\n    top: calc(50% - 40px);\n    width: 100%;\n    z-index: 1;\n\n    .vjs-button {\n      font-size: 4em;\n      height: 100%;\n      width: 80px;\n\n      .vjs-icon-placeholder::before {\n        height: 100%;\n        line-height: 80px;\n      }\n    }\n  }\n\n  .vjs-airplay-button .vjs-icon-placeholder,\n  .vjs-chromecast-button .vjs-icon-placeholder {\n    height: 1.6em;\n    width: 1.6em;\n  }\n\n  .vjs-autostart-button {\n    cursor: pointer;\n\n    &.vjs-icon-play-circle::before {\n      align-items: center;\n      background-color: rgba(255, 255, 255, 0.9);\n      border-radius: 50%;\n      color: rgba(80, 80, 80, 0.9);\n      content: \"\\f101\";\n      font-size: 1em;\n      line-height: 1;\n      margin-left: 1rem;\n      padding: 0.3em;\n      position: relative;\n      z-index: 2;\n    }\n\n    &.vjs-icon-cancel::before {\n      align-items: center;\n      background-color: rgba(80, 80, 80, 0.9);\n      border-radius: 50%;\n      color: #fff;\n      content: \"\\f103\";\n      font-size: 1em;\n      line-height: 1;\n      margin-right: 1rem;\n      padding: 0.3em;\n      position: relative;\n      z-index: 2;\n    }\n\n    &.vjs-icon-play-circle::after,\n    &.vjs-icon-cancel::after {\n      background-color: rgb(255 255 255 / 70%);\n      border-radius: 8px;\n      content: \"\";\n      height: 2.5rem;\n      left: 50%;\n      opacity: 0.7;\n      position: absolute;\n      top: 50%;\n      transform: translate(-50%, -50%) rotate(90deg);\n      width: 1rem;\n      z-index: 1;\n    }\n\n    &:hover {\n      text-shadow: 0 0 1em rgba(255, 255, 255, 0.75);\n    }\n  }\n\n  .vjs-touch-overlay .vjs-play-control {\n    z-index: 1;\n  }\n\n  .vjs-control-bar {\n    background: none;\n\n    /* Scales control size */\n    font-size: 15px;\n\n    &::before {\n      background: linear-gradient(\n        0deg,\n        rgba(0, 0, 0, 0.4) 0%,\n        rgba(0, 0, 0, 0) 100%\n      );\n      bottom: 0;\n      content: \"\";\n      height: 10rem;\n      pointer-events: none;\n      position: absolute;\n      width: 100%;\n    }\n  }\n\n  .vjs-time-control {\n    align-items: center;\n    display: flex;\n    justify-content: center;\n    min-width: 0;\n    padding: 0 4px;\n    pointer-events: none;\n\n    .vjs-control-text {\n      display: none;\n    }\n  }\n\n  .vjs-duration {\n    margin-right: auto;\n  }\n\n  .vjs-remaining-time {\n    display: none;\n  }\n\n  .vjs-progress-control {\n    bottom: 2.5em;\n    height: 3em;\n    position: absolute;\n    width: 100%;\n\n    .vjs-progress-holder {\n      margin: 0 15px;\n    }\n  }\n\n  /* stylelint-disable declaration-no-important */\n  .vjs-play-progress .vjs-time-tooltip {\n    display: none !important;\n  }\n  /* stylelint-enable declaration-no-important */\n\n  .vjs-volume-control {\n    z-index: 1;\n  }\n\n  /* stylelint-disable declaration-no-important */\n  .vjs-slider {\n    box-shadow: none !important;\n    text-shadow: none !important;\n  }\n  /* stylelint-enable declaration-no-important */\n\n  .vjs-vtt-thumbnail-display {\n    border: 2px solid white;\n    border-radius: 2px;\n    bottom: 6em;\n    box-shadow: 0 0 7px rgba(0, 0, 0, 0.6);\n    opacity: 0;\n    pointer-events: none;\n    position: absolute;\n    transition: opacity 0.2s;\n    z-index: 100;\n  }\n\n  .vjs-big-play-button,\n  .vjs-big-play-button:hover,\n  .vjs-big-play-button:focus,\n  &:hover .vjs-big-play-button {\n    background: none;\n    border: none;\n    font-size: 10em;\n  }\n\n  .vjs-skip-button {\n    &::before {\n      font-size: 1.8em;\n      line-height: 1.67;\n    }\n  }\n\n  &.vjs-skip-buttons {\n    .vjs-icon-next-item,\n    .vjs-icon-previous-item {\n      display: none;\n    }\n\n    &-prev .vjs-icon-previous-item,\n    &-next .vjs-icon-next-item {\n      display: inline-block;\n    }\n  }\n\n  .vjs-source-selector {\n    &.vjs-hover .vjs-menu {\n      display: none;\n    }\n\n    .vjs-menu li {\n      font-size: 0.8em;\n    }\n\n    .vjs-button > .vjs-icon-placeholder::before {\n      content: \"\\f110\";\n      font-family: VideoJS;\n    }\n\n    .vjs-menu-item.vjs-source-menu-item-error:not(.vjs-selected) {\n      color: $text-muted;\n    }\n\n    .vjs-menu-item.vjs-source-menu-item-error {\n      font-style: italic;\n    }\n  }\n\n  .vjs-vr-selector {\n    .vjs-menu li {\n      font-size: 0.8em;\n    }\n\n    .vjs-button {\n      background: url(\"/vr.svg\") center center no-repeat;\n      width: 50%;\n    }\n  }\n\n  .vjs-marker {\n    background-color: rgba(33, 33, 33, 0.8);\n    bottom: 0;\n    height: 100%;\n    left: 0;\n    opacity: 1;\n    position: absolute;\n    transition: opacity 0.2s ease;\n    visibility: hidden;\n    width: 6px;\n    z-index: 100;\n\n    &:hover {\n      cursor: pointer;\n      transform: scale(1.3, 1.3);\n    }\n  }\n\n  .vjs-marker-range {\n    background-color: rgba(255, 255, 255, 0.4);\n    border-radius: 2px;\n    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n    height: 8px;\n    min-width: 8px;\n    position: absolute;\n    transform: translateY(-28px);\n    transition: none;\n  }\n\n  .vjs-marker-tooltip {\n    background-color: #fff;\n    background-color: rgba(255, 255, 255, 0.8);\n    border-radius: 0.3em;\n    color: #000;\n    float: right;\n    font-family: Arial, Helvetica, sans-serif;\n    font-size: 0.6em;\n    padding: 6px 8px 8px 8px;\n    pointer-events: none;\n    position: absolute;\n    top: -3.4em;\n    visibility: hidden;\n    white-space: nowrap;\n    z-index: 1;\n  }\n\n  .vjs-text-track-settings select {\n    background: #fff;\n  }\n\n  .vjs-seek-button.skip-back span.vjs-icon-placeholder::before {\n    -ms-transform: none;\n    -webkit-transform: none;\n    transform: none;\n  }\n\n  .vjs-seek-button.skip-forward span.vjs-icon-placeholder::before {\n    -ms-transform: scale(-1, 1);\n    -webkit-transform: scale(-1, 1);\n    transform: scale(-1, 1);\n  }\n\n  @media (pointer: coarse) {\n    &.vjs-touch-enabled {\n      &.vjs-has-started .vjs-big-button-group {\n        display: flex;\n        opacity: 1;\n        visibility: visible;\n      }\n\n      &.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-big-button-group {\n        opacity: 0;\n        pointer-events: none;\n        transition: visibility 1s, opacity 1s;\n        visibility: visible;\n      }\n\n      .vjs-big-play-pause-button .vjs-icon-placeholder::before {\n        content: \"\\f101\";\n        font-family: VideoJS;\n      }\n\n      &.vjs-playing .vjs-big-play-pause-button .vjs-icon-placeholder::before {\n        content: \"\\f103\";\n      }\n\n      .vjs-vtt-thumbnail-display {\n        bottom: 2.8em;\n      }\n\n      // hide the regular seek buttons on touch screens\n      .vjs-control-bar .vjs-seek-button {\n        display: none;\n      }\n    }\n  }\n  @media (max-width: 576px) {\n    .vjs-control-bar {\n      .vjs-autostart-button {\n        display: none;\n      }\n    }\n  }\n\n  // make controls a little more compact on smaller screens\n  @media (max-width: 768px) {\n    .vjs-control-bar {\n      .vjs-control {\n        width: 2.5em;\n      }\n\n      .vjs-progress-control {\n        height: 2em;\n        width: 100%;\n      }\n\n      .vjs-playback-rate {\n        width: 3em;\n      }\n\n      .vjs-button > .vjs-icon-placeholder::before,\n      .vjs-skip-button::before {\n        font-size: 1.5em;\n        line-height: 2;\n      }\n\n      .vjs-airplay-button .vjs-icon-placeholder,\n      .vjs-chromecast-button .vjs-icon-placeholder {\n        height: 1.4em;\n        width: 1.4em;\n      }\n\n      .vjs-source-selector .vjs-menu {\n        z-index: 9999;\n      }\n    }\n\n    .vjs-menu-button-popup .vjs-menu {\n      width: 8em;\n\n      .vjs-menu-content {\n        max-height: 10em;\n      }\n    }\n\n    .vjs-playback-rate .vjs-playback-rate-value {\n      font-size: 1em;\n      line-height: 2.97;\n    }\n\n    .vjs-source-selector {\n      .vjs-menu li {\n        font-size: 10px;\n      }\n    }\n\n    .vjs-time-control {\n      font-size: 12px;\n    }\n\n    .vjs-big-button-group .vjs-button {\n      font-size: 2em;\n      width: 50px;\n    }\n\n    .vjs-current-time {\n      margin-left: 1em;\n    }\n  }\n}\n\n.scene-tabs,\n.scene-player-container {\n  padding-left: 15px;\n  position: relative;\n  width: 100%;\n}\n\n.scene-player-container {\n  padding-right: 15px;\n}\n\n.scrubber-wrapper {\n  display: flex;\n  flex-shrink: 0;\n  margin: 5px 0;\n  overflow: hidden;\n  position: relative;\n}\n\n#scrubber-back {\n  float: left;\n}\n\n#scrubber-forward {\n  float: right;\n}\n\n.scrubber-button {\n  background-color: transparent;\n  border: 1px solid #555;\n  color: $link-color;\n  cursor: pointer;\n  font-size: 1.1rem;\n  font-weight: 800;\n  height: 100%;\n  line-height: $scrubberHeight;\n  padding: 0;\n  text-align: center;\n  width: 1.8rem;\n}\n\n.scrubber-content {\n  cursor: pointer;\n  display: inline-block;\n  flex-grow: 1;\n  height: $scrubberHeight;\n  margin: 0 7px;\n  overflow: hidden;\n  -webkit-overflow-scrolling: touch;\n  position: relative;\n  -webkit-user-select: none;\n  user-select: none;\n\n  &.dragging {\n    cursor: grabbing;\n  }\n}\n\n#scrubber-position-indicator {\n  background-color: rgba(255, 255, 255, 0.7);\n  height: 20px;\n  left: -100%;\n  position: absolute;\n  width: 100%;\n  z-index: 0;\n}\n\n#scrubber-current-position {\n  background-color: #fff;\n  height: 30px;\n  left: 50%;\n  position: absolute;\n  width: 2px;\n  z-index: 1;\n}\n\n.scrubber-viewport {\n  height: 100%;\n  overflow: hidden;\n  position: static;\n}\n\n.scrubber-slider {\n  height: 100%;\n  left: 0;\n  position: absolute;\n  width: 100%;\n}\n\n.scrubber-tags {\n  height: 20px;\n  margin-bottom: 10px;\n  position: relative;\n\n  &-background {\n    background-color: #555;\n    height: 20px;\n    left: 0;\n    position: absolute;\n    right: 0;\n  }\n}\n\n.scrubber-heatmap {\n  background-size: 100% 100%;\n  height: 20px;\n  left: 0;\n  position: absolute;\n  right: 0;\n}\n\n.scrubber-tag {\n  background-color: #000;\n  cursor: pointer;\n  font-size: 10px;\n  height: 20px;\n  padding: 0 10px;\n  position: absolute;\n  transform: translateX(-50%);\n  white-space: nowrap;\n\n  &:hover {\n    background-color: #444;\n    z-index: 1;\n  }\n\n  &:hover::after {\n    border-top: solid 5px #444;\n    z-index: 1;\n  }\n\n  &::after {\n    border-left: solid 5px transparent;\n    border-right: solid 5px transparent;\n    border-top: solid 5px #000;\n    bottom: -5px;\n    content: \"\";\n    left: 50%;\n    margin-left: -5px;\n    position: absolute;\n  }\n}\n\n.scrubber-item {\n  color: white;\n  display: flex;\n  font-size: 10px;\n  margin: 0 auto;\n  position: absolute;\n  text-align: center;\n  text-shadow: 1px 1px black;\n\n  &-time {\n    align-self: flex-end;\n    display: inline-block;\n    width: 100%;\n  }\n}\n\n@media (max-width: 1199px) {\n  .scene-tabs {\n    padding-right: 15px;\n  }\n\n  .scene-player-container {\n    padding-left: 0;\n    padding-right: 0;\n  }\n\n  .scrubber-wrapper {\n    margin-left: 5px;\n    margin-right: 5px;\n  }\n}\n@media (min-width: 1200px) {\n  .scene-tabs {\n    flex: 0 0 $sceneTabWidth;\n    max-width: $sceneTabWidth;\n    overflow: auto;\n\n    &.collapsed {\n      display: none;\n    }\n\n    .tab-content {\n      flex: 1 1 auto;\n      min-height: 15rem;\n      overflow-x: hidden;\n      overflow-y: auto;\n    }\n  }\n\n  .scene-divider {\n    flex: 0 0 15px;\n    max-width: 15px;\n\n    button {\n      background-color: transparent;\n      border: 0;\n      color: $link-color;\n      cursor: pointer;\n      font-size: 10px;\n      font-weight: 800;\n      height: 100%;\n      line-height: 100%;\n      padding: 0;\n      text-align: center;\n      width: 100%;\n\n      &:active:not(:hover),\n      &:focus:not(:hover) {\n        background-color: transparent;\n        border: 0;\n        box-shadow: none;\n      }\n    }\n  }\n\n  .scene-player-container {\n    flex: 0 0 calc(100% - #{$sceneTabWidth} - 15px);\n    max-width: calc(100% - #{$sceneTabWidth} - 15px);\n    padding-left: 0;\n\n    &.expanded {\n      flex: 0 0 calc(100% - 15px);\n      max-width: calc(100% - 15px);\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/track-activity.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\n\nconst intervalSeconds = 1; // check every second\nconst sendInterval = 10; // send every 10 seconds\n\nclass TrackActivityPlugin extends videojs.getPlugin(\"plugin\") {\n  totalPlayDuration = 0;\n  currentPlayDuration = 0;\n  minimumPlayPercent = 0;\n  incrementPlayCount: () => Promise<void> = () => {\n    return Promise.resolve();\n  };\n  saveActivity: (resumeTime: number, playDuration: number) => Promise<void> =\n    () => {\n      return Promise.resolve();\n    };\n\n  private enabled = false;\n  private playCountIncremented = false;\n  private intervalID: number | undefined;\n\n  private lastResumeTime = 0;\n  private lastDuration = 0;\n\n  constructor(player: VideoJsPlayer) {\n    super(player);\n\n    player.on(\"playing\", () => {\n      this.start();\n    });\n\n    player.on(\"waiting\", () => {\n      this.stop();\n    });\n\n    player.on(\"stalled\", () => {\n      this.stop();\n    });\n\n    player.on(\"pause\", () => {\n      this.stop();\n    });\n\n    player.on(\"dispose\", () => {\n      this.stop();\n    });\n\n    player.on(\"ended\", () => {\n      this.stop();\n    });\n  }\n\n  private start() {\n    if (this.enabled && !this.intervalID) {\n      this.intervalID = window.setInterval(() => {\n        this.intervalHandler();\n      }, intervalSeconds * 1000);\n      this.lastResumeTime = this.player.currentTime();\n      this.lastDuration = this.player.duration();\n    }\n  }\n\n  private stop() {\n    if (this.intervalID) {\n      window.clearInterval(this.intervalID);\n      this.intervalID = undefined;\n      this.sendActivity();\n    }\n  }\n\n  reset() {\n    this.stop();\n    this.totalPlayDuration = 0;\n    this.currentPlayDuration = 0;\n    this.playCountIncremented = false;\n  }\n\n  setEnabled(enabled: boolean) {\n    this.enabled = enabled;\n    if (!enabled) {\n      this.stop();\n    } else if (!this.player.paused()) {\n      this.start();\n    }\n  }\n\n  private intervalHandler() {\n    if (!this.enabled || !this.player) return;\n\n    this.lastResumeTime = this.player.currentTime();\n    this.lastDuration = this.player.duration();\n\n    this.totalPlayDuration += intervalSeconds;\n    this.currentPlayDuration += intervalSeconds;\n    if (this.totalPlayDuration % sendInterval === 0) {\n      this.sendActivity();\n    }\n  }\n\n  private sendActivity() {\n    if (!this.enabled) return;\n\n    if (this.totalPlayDuration > 0) {\n      let resumeTime = this.player?.currentTime() ?? this.lastResumeTime;\n      const videoDuration = this.player?.duration() ?? this.lastDuration;\n      const percentCompleted = (100 / videoDuration) * resumeTime;\n      const percentPlayed = (100 / videoDuration) * this.totalPlayDuration;\n\n      if (\n        !this.playCountIncremented &&\n        percentPlayed >= this.minimumPlayPercent\n      ) {\n        this.incrementPlayCount();\n        this.playCountIncremented = true;\n      }\n\n      // if video is 98% or more complete then reset resume_time\n      if (percentCompleted >= 98) {\n        resumeTime = 0;\n      }\n\n      this.saveActivity(resumeTime, this.currentPlayDuration);\n      this.currentPlayDuration = 0;\n    }\n  }\n}\n\n// Register the plugin with video.js.\nvideojs.registerPlugin(\"trackActivity\", TrackActivityPlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    trackActivity: () => TrackActivityPlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    trackActivity?: {};\n  }\n}\n\nexport default TrackActivityPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/util.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\n\nexport const VIDEO_PLAYER_ID = \"VideoJsPlayer\";\n\nexport const getPlayer = () => videojs.getPlayer(VIDEO_PLAYER_ID);\n\nexport const getPlayerPosition = () => getPlayer()?.currentTime();\n\nexport type AbLoopOptions = {\n  start: number;\n  end: number | false;\n  enabled?: boolean;\n};\n\nexport type AbLoopPluginApi = {\n  getOptions: () => AbLoopOptions;\n  setOptions: (options: AbLoopOptions) => void;\n};\n\nexport const getAbLoopPlugin = () => {\n  const player = getPlayer();\n  if (!player) return null;\n  const { abLoopPlugin } = player as VideoJsPlayer & {\n    abLoopPlugin?: AbLoopPluginApi;\n  };\n  return abLoopPlugin ?? null;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/vrmode.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport videojs, { VideoJsPlayer } from \"video.js\";\nimport \"videojs-vr\";\n// separate type import, otherwise typescript elides the above import\n// and the plugin does not get initialized\nimport type { ProjectionType, Plugin as VideoJsVRPlugin } from \"videojs-vr\";\n\nexport interface VRMenuOptions {\n  /**\n   * Whether to show the vr button.\n   * @default false\n   */\n  showButton?: boolean;\n}\n\nenum VRType {\n  LR180 = \"180 LR\",\n  TB360 = \"360 TB\",\n  Mono360 = \"360 Mono\",\n  Off = \"Off\",\n}\n\nconst vrTypeProjection: Record<VRType, ProjectionType> = {\n  [VRType.LR180]: \"180_LR\",\n  [VRType.TB360]: \"360_TB\",\n  [VRType.Mono360]: \"360\",\n  [VRType.Off]: \"NONE\",\n};\n\nfunction isVrDevice() {\n  return navigator.userAgent.match(/oculusbrowser|\\svr\\s/i);\n}\n\nclass VRMenuItem extends videojs.getComponent(\"MenuItem\") {\n  public type: VRType;\n  public isSelected = false;\n\n  constructor(parent: VRMenuButton, type: VRType) {\n    const options: videojs.MenuItemOptions = {};\n    options.selectable = true;\n    options.multiSelectable = false;\n    options.label = type;\n\n    super(parent.player(), options);\n\n    this.type = type;\n\n    this.addClass(\"vjs-source-menu-item\");\n  }\n\n  selected(selected: boolean): void {\n    super.selected(selected);\n    this.isSelected = selected;\n  }\n\n  handleClick() {\n    if (this.isSelected) return;\n\n    this.trigger(\"selected\");\n  }\n}\n\nclass VRMenuButton extends videojs.getComponent(\"MenuButton\") {\n  private items: VRMenuItem[] = [];\n  private selectedType: VRType = VRType.Off;\n\n  constructor(player: VideoJsPlayer) {\n    super(player);\n    this.setTypes();\n  }\n\n  private onSelected(item: VRMenuItem) {\n    this.selectedType = item.type;\n\n    this.items.forEach((i) => {\n      i.selected(i.type === this.selectedType);\n    });\n\n    this.trigger(\"typeselected\", item.type);\n  }\n\n  public setTypes() {\n    this.items = Object.values(VRType).map((type) => {\n      const item = new VRMenuItem(this, type);\n\n      item.on(\"selected\", () => {\n        this.onSelected(item);\n      });\n\n      return item;\n    });\n    this.update();\n  }\n\n  createEl() {\n    return videojs.dom.createEl(\"div\", {\n      className:\n        \"vjs-vr-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button\",\n    });\n  }\n\n  createItems() {\n    if (this.items === undefined) return [];\n\n    for (const item of this.items) {\n      item.selected(item.type === this.selectedType);\n    }\n\n    return this.items;\n  }\n}\n\nclass VRMenuPlugin extends videojs.getPlugin(\"plugin\") {\n  private menu: VRMenuButton;\n  private showButton: boolean;\n  private vr?: VideoJsVRPlugin;\n\n  constructor(player: VideoJsPlayer, options: VRMenuOptions) {\n    super(player);\n\n    this.menu = new VRMenuButton(player);\n    this.showButton = options.showButton ?? false;\n\n    if (isVrDevice()) return;\n\n    this.vr = this.player.vr();\n\n    this.menu.on(\"typeselected\", (_, type: VRType) => {\n      this.loadVR(type);\n    });\n\n    player.on(\"ready\", () => {\n      if (this.showButton) {\n        this.addButton();\n      }\n    });\n  }\n\n  private loadVR(type: VRType) {\n    const projection = vrTypeProjection[type];\n    this.vr?.setProjection(projection);\n    this.vr?.init();\n  }\n\n  private addButton() {\n    const { controlBar } = this.player;\n    const fullscreenToggle = controlBar.getChild(\"fullscreenToggle\")!.el();\n    controlBar.addChild(this.menu);\n    controlBar.el().insertBefore(this.menu.el(), fullscreenToggle);\n  }\n\n  private removeButton() {\n    const { controlBar } = this.player;\n    controlBar.removeChild(this.menu);\n  }\n\n  public setShowButton(showButton: boolean) {\n    if (isVrDevice()) return;\n\n    if (showButton === this.showButton) return;\n\n    this.showButton = showButton;\n    if (showButton) {\n      this.addButton();\n    } else {\n      this.removeButton();\n      this.loadVR(VRType.Off);\n    }\n  }\n}\n\n// Register the plugin with video.js.\nvideojs.registerComponent(\"VRMenuButton\", VRMenuButton);\nvideojs.registerPlugin(\"vrMenu\", VRMenuPlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    vrMenu: () => VRMenuPlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    vrMenu?: VRMenuOptions;\n  }\n}\n\nexport default VRMenuPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\nimport { WebVTT } from \"videojs-vtt.js\";\n\nexport interface IVTTThumbnailsOptions {\n  /**\n   * Source URL to use for thumbnails.\n   */\n  src?: string;\n  /**\n   * Whether to show the timestamp on hover.\n   * @default false\n   */\n  showTimestamp?: boolean;\n}\n\ninterface IVTTData {\n  start: number;\n  end: number;\n  style: IVTTStyle | null;\n}\n\ninterface IVTTStyle {\n  background: string;\n  width: string;\n  height: string;\n}\n\nclass VTTThumbnailsPlugin extends videojs.getPlugin(\"plugin\") {\n  private source: string | null;\n  private showTimestamp: boolean;\n\n  private progressBar?: HTMLElement;\n  private thumbnailHolder?: HTMLDivElement;\n\n  private showing = false;\n\n  private vttData?: IVTTData[];\n  private lastStyle?: IVTTStyle;\n\n  constructor(player: VideoJsPlayer, options: IVTTThumbnailsOptions) {\n    super(player, options);\n    this.source = options.src ?? null;\n    this.showTimestamp = options.showTimestamp ?? false;\n\n    player.ready(() => {\n      player.addClass(\"vjs-vtt-thumbnails\");\n      this.initializeThumbnails();\n    });\n  }\n\n  src(source: string | null): void {\n    this.resetPlugin();\n    this.source = source;\n    this.initializeThumbnails();\n  }\n\n  detach(): void {\n    this.resetPlugin();\n  }\n\n  private resetPlugin() {\n    this.showing = false;\n\n    if (this.thumbnailHolder) {\n      this.thumbnailHolder.remove();\n      delete this.thumbnailHolder;\n    }\n\n    if (this.progressBar) {\n      this.progressBar.removeEventListener(\n        \"pointerenter\",\n        this.onBarPointerEnter\n      );\n      this.progressBar.removeEventListener(\n        \"pointermove\",\n        this.onBarPointerMove\n      );\n      this.progressBar.removeEventListener(\n        \"pointerleave\",\n        this.onBarPointerLeave\n      );\n\n      delete this.progressBar;\n    }\n\n    delete this.vttData;\n    delete this.lastStyle;\n  }\n\n  /**\n   * Bootstrap the plugin.\n   */\n  private initializeThumbnails() {\n    if (!this.source) {\n      return;\n    }\n\n    const baseUrl = this.getBaseUrl();\n    const url = this.getFullyQualifiedUrl(this.source, baseUrl);\n\n    this.getVttFile(url).then((data) => {\n      this.vttData = this.processVtt(data);\n      this.setupThumbnailElement();\n    });\n  }\n\n  /**\n   * Builds a base URL should we require one.\n   */\n  private getBaseUrl() {\n    return [\n      window.location.protocol,\n      \"//\",\n      window.location.hostname,\n      window.location.port ? \":\" + window.location.port : \"\",\n      window.location.pathname,\n    ]\n      .join(\"\")\n      .split(/([^\\/]*)$/gi)[0];\n  }\n\n  /**\n   * Grabs the contents of the VTT file.\n   */\n  private getVttFile(url: string): Promise<string> {\n    return new Promise((resolve, reject) => {\n      const req = new XMLHttpRequest();\n\n      req.addEventListener(\"load\", () => {\n        resolve(req.responseText);\n      });\n      req.addEventListener(\"error\", (e) => {\n        reject(e);\n      });\n      req.open(\"GET\", url);\n      req.send();\n    });\n  }\n\n  private setupThumbnailElement() {\n    const progressBar = this.player.$(\".vjs-progress-control\") as HTMLElement;\n    if (!progressBar) return;\n    this.progressBar = progressBar;\n\n    const thumbHolder = document.createElement(\"div\");\n    thumbHolder.setAttribute(\"class\", \"vjs-vtt-thumbnail-display\");\n    progressBar.appendChild(thumbHolder);\n    this.thumbnailHolder = thumbHolder;\n\n    if (!this.showTimestamp) {\n      this.player.$(\".vjs-mouse-display\")?.classList.add(\"vjs-hidden\");\n    }\n\n    progressBar.addEventListener(\"pointerover\", this.onBarPointerEnter);\n    progressBar.addEventListener(\"pointerout\", this.onBarPointerLeave);\n  }\n\n  private onBarPointerEnter = () => {\n    this.showThumbnailHolder();\n    this.progressBar?.addEventListener(\"pointermove\", this.onBarPointerMove);\n  };\n\n  private onBarPointerMove = (e: Event) => {\n    const { progressBar } = this;\n    if (!progressBar) return;\n\n    this.showThumbnailHolder();\n    this.updateThumbnailStyle(\n      videojs.dom.getPointerPosition(progressBar, e).x,\n      progressBar.offsetWidth\n    );\n  };\n\n  private onBarPointerLeave = () => {\n    this.hideThumbnailHolder();\n    this.progressBar?.removeEventListener(\"pointermove\", this.onBarPointerMove);\n  };\n\n  private getStyleForTime(time: number) {\n    if (!this.vttData) return null;\n\n    for (const element of this.vttData) {\n      const item = element;\n\n      if (time >= item.start && time < item.end) {\n        return item.style;\n      }\n    }\n\n    return null;\n  }\n\n  private showThumbnailHolder() {\n    if (this.thumbnailHolder && !this.showing) {\n      this.showing = true;\n      this.thumbnailHolder.style.opacity = \"1\";\n    }\n  }\n\n  private hideThumbnailHolder() {\n    if (this.thumbnailHolder && this.showing) {\n      this.showing = false;\n      this.thumbnailHolder.style.opacity = \"0\";\n    }\n  }\n\n  private updateThumbnailStyle(percent: number, width: number) {\n    if (!this.thumbnailHolder) return;\n\n    const duration = this.player.duration();\n    const time = percent * duration;\n    const currentStyle = this.getStyleForTime(time);\n\n    if (!currentStyle) {\n      this.hideThumbnailHolder();\n      return;\n    }\n\n    const xPos = percent * width;\n    const thumbnailWidth = parseInt(currentStyle.width, 10);\n    const halfThumbnailWidth = thumbnailWidth >> 1;\n    const marginRight = width - (xPos + halfThumbnailWidth);\n    const marginLeft = xPos - halfThumbnailWidth;\n\n    if (marginLeft > 0 && marginRight > 0) {\n      this.thumbnailHolder.style.transform =\n        \"translateX(\" + (xPos - halfThumbnailWidth) + \"px)\";\n    } else if (marginLeft <= 0) {\n      this.thumbnailHolder.style.transform = \"translateX(\" + 0 + \"px)\";\n    } else if (marginRight <= 0) {\n      this.thumbnailHolder.style.transform =\n        \"translateX(\" + (width - thumbnailWidth) + \"px)\";\n    }\n\n    if (this.lastStyle && this.lastStyle === currentStyle) {\n      return;\n    }\n\n    this.lastStyle = currentStyle;\n\n    Object.assign(this.thumbnailHolder.style, currentStyle);\n  }\n\n  private processVtt(data: string) {\n    const processedVtts: IVTTData[] = [];\n\n    const parser = new WebVTT.Parser(window, WebVTT.StringDecoder());\n    parser.oncue = (cue: VTTCue) => {\n      processedVtts.push({\n        start: cue.startTime,\n        end: cue.endTime,\n        style: this.getVttStyle(cue.text),\n      });\n    };\n    parser.parse(data);\n    parser.flush();\n\n    return processedVtts;\n  }\n\n  private getFullyQualifiedUrl(path: string, base: string) {\n    if (path.indexOf(\"//\") >= 0) {\n      // We have a fully qualified path.\n      return path;\n    }\n\n    if (base.indexOf(\"//\") === 0) {\n      // We don't have a fully qualified path, but need to\n      // be careful with trimming.\n      return [base.replace(/\\/$/gi, \"\"), this.trim(path, \"/\")].join(\"/\");\n    }\n\n    if (base.indexOf(\"//\") > 0) {\n      // We don't have a fully qualified path, and should\n      // trim both sides of base and path.\n      return [this.trim(base, \"/\"), this.trim(path, \"/\")].join(\"/\");\n    }\n\n    // If all else fails.\n    return path;\n  }\n\n  private getPropsFromDef(def: string) {\n    const match = def.match(/^([^#]*)#xywh=(\\d+),(\\d+),(\\d+),(\\d+)$/i);\n    if (!match) return null;\n\n    return {\n      image: match[1],\n      x: match[2],\n      y: match[3],\n      w: match[4],\n      h: match[5],\n    };\n  }\n\n  private getVttStyle(vttImageDef: string) {\n    // If there isn't a protocol, use the VTT source URL.\n    let baseSplit: string;\n\n    if (this.source === null) {\n      baseSplit = this.getBaseUrl();\n    } else if (this.source.indexOf(\"//\") >= 0) {\n      baseSplit = this.source.split(/([^\\/]*)$/gi)[0];\n    } else {\n      baseSplit = this.getBaseUrl() + this.source.split(/([^\\/]*)$/gi)[0];\n    }\n\n    vttImageDef = this.getFullyQualifiedUrl(vttImageDef, baseSplit);\n\n    const imageProps = this.getPropsFromDef(vttImageDef);\n    if (!imageProps) return null;\n\n    return {\n      background:\n        'url(\"' +\n        imageProps.image +\n        '\") no-repeat -' +\n        imageProps.x +\n        \"px -\" +\n        imageProps.y +\n        \"px\",\n      width: imageProps.w + \"px\",\n      height: imageProps.h + \"px\",\n    };\n  }\n\n  /**\n   * trim\n   *\n   * @param  str      source string\n   * @param  charlist characters to trim from text\n   * @return          trimmed string\n   */\n  private trim(str: string, charlist: string) {\n    let whitespace = [\n      \" \",\n      \"\\n\",\n      \"\\r\",\n      \"\\t\",\n      \"\\f\",\n      \"\\x0b\",\n      \"\\xa0\",\n      \"\\u2000\",\n      \"\\u2001\",\n      \"\\u2002\",\n      \"\\u2003\",\n      \"\\u2004\",\n      \"\\u2005\",\n      \"\\u2006\",\n      \"\\u2007\",\n      \"\\u2008\",\n      \"\\u2009\",\n      \"\\u200a\",\n      \"\\u200b\",\n      \"\\u2028\",\n      \"\\u2029\",\n      \"\\u3000\",\n    ].join(\"\");\n    let l = 0;\n\n    str += \"\";\n    if (charlist) {\n      whitespace = (charlist + \"\").replace(/([[\\]().?/*{}+$^:])/g, \"$1\");\n    }\n\n    l = str.length;\n    for (let i = 0; i < l; i++) {\n      if (whitespace.indexOf(str.charAt(i)) === -1) {\n        str = str.substring(i);\n        break;\n      }\n    }\n\n    l = str.length;\n    for (let i = l - 1; i >= 0; i--) {\n      if (whitespace.indexOf(str.charAt(i)) === -1) {\n        str = str.substring(0, i + 1);\n        break;\n      }\n    }\n    return whitespace.indexOf(str.charAt(0)) === -1 ? str : \"\";\n  }\n}\n\n// Register the plugin with video.js.\nvideojs.registerPlugin(\"vttThumbnails\", VTTThumbnailsPlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    vttThumbnails: () => VTTThumbnailsPlugin;\n  }\n  interface VideoJsPlayerPluginOptions {\n    vttThumbnails?: IVTTThumbnailsOptions;\n  }\n}\n\nexport default VTTThumbnailsPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/ScenePlayer/wake-sentinel.ts",
    "content": "import videojs, { VideoJsPlayer } from \"video.js\";\n\nclass WakeSentinelPlugin extends videojs.getPlugin(\"plugin\") {\n  public wakeLock: WakeLockSentinel | null = null;\n  public wakeLockFail: boolean = false;\n  constructor(player: VideoJsPlayer) {\n    super(player);\n\n    // listen for visibility change events\n    document.addEventListener(\"visibilitychange\", async () => {\n      if (document.visibilityState === \"visible\") {\n        // reacquire the wake lock when the page becomes visible\n        await this.acquireWakeLock();\n      }\n    });\n\n    // acquire wake lock on ready and play\n    player.ready(async () => {\n      player.addClass(\"vjs-wake-sentinel\");\n      await this.acquireWakeLock(true);\n    });\n    player.on(\"play\", () => this.acquireWakeLock());\n\n    // release wake lock on pause, dispose and end\n    player.on(\"pause\", () => this.releaseWakeLock());\n    player.on(\"dispose\", () => this.releaseWakeLock());\n    player.on(\"ended\", () => this.releaseWakeLock());\n  }\n\n  private async releaseWakeLock(): Promise<void> {\n    this.wakeLock?.release().then(() => (this.wakeLock = null));\n  }\n\n  private async acquireWakeLock(log = false): Promise<void> {\n    // if wake lock failed, don't even try\n    if (this.wakeLockFail) return;\n    // check for wake lock on startup\n    if (\"wakeLock\" in navigator) {\n      try {\n        this.wakeLock = await navigator.wakeLock.request(\"screen\");\n      } catch (err) {\n        if (log) console.error(\"Failed to obtain Screen Wake Lock:\", err);\n        this.wakeLockFail = true;\n      }\n    } else {\n      if (log) {\n        console.warn(\n          \"Screen Wake Lock API not supported. Secure context (https or localhost) and modern browser required.\"\n        );\n      }\n      this.wakeLockFail = true;\n    }\n  }\n}\n\nvideojs.registerPlugin(\"wakeSentinel\", WakeSentinelPlugin);\n\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare module \"video.js\" {\n  interface VideoJsPlayer {\n    wakeSentinel: () => WakeSentinelPlugin;\n  }\n}\n\nexport default WakeSentinelPlugin;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/DeleteSceneMarkersDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { useSceneMarkersDestroy } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useIntl } from \"react-intl\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IDeleteSceneMarkersDialogProps {\n  selected: GQL.SceneMarkerDataFragment[];\n  onClose: (confirmed: boolean) => void;\n}\n\nexport const DeleteSceneMarkersDialog: React.FC<\n  IDeleteSceneMarkersDialogProps\n> = (props: IDeleteSceneMarkersDialogProps) => {\n  const intl = useIntl();\n  const singularEntity = intl.formatMessage({ id: \"marker\" });\n  const pluralEntity = intl.formatMessage({ id: \"markers\" });\n\n  const header = intl.formatMessage(\n    { id: \"dialogs.delete_object_title\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const toastMessage = intl.formatMessage(\n    { id: \"toast.delete_past_tense\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const message = intl.formatMessage(\n    { id: \"dialogs.delete_object_desc\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n\n  const Toast = useToast();\n  const [deleteSceneMarkers] = useSceneMarkersDestroy(\n    getSceneMarkersDeleteInput()\n  );\n\n  // Network state\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  function getSceneMarkersDeleteInput(): GQL.SceneMarkersDestroyMutationVariables {\n    return {\n      ids: props.selected.map((marker) => marker.id),\n    };\n  }\n\n  async function onDelete() {\n    setIsDeleting(true);\n    try {\n      await deleteSceneMarkers();\n      Toast.success(toastMessage);\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n      props.onClose(false);\n    }\n    setIsDeleting(false);\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faTrashAlt}\n      header={header}\n      accept={{\n        variant: \"danger\",\n        onClick: onDelete,\n        text: intl.formatMessage({ id: \"actions.delete\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(false),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isDeleting}\n    >\n      <p>{message}</p>\n    </ModalComponent>\n  );\n};\n\nexport default DeleteSceneMarkersDialog;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useScenesDestroy } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { objectPath } from \"src/core/files\";\n\ninterface IDeleteSceneDialogProps {\n  selected: GQL.SlimSceneDataFragment[];\n  onClose: (confirmed: boolean) => void;\n}\n\nexport const DeleteScenesDialog: React.FC<IDeleteSceneDialogProps> = (\n  props: IDeleteSceneDialogProps\n) => {\n  const intl = useIntl();\n  const singularEntity = intl.formatMessage({ id: \"scene\" });\n  const pluralEntity = intl.formatMessage({ id: \"scenes\" });\n\n  const header = intl.formatMessage(\n    { id: \"dialogs.delete_entity_title\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const toastMessage = intl.formatMessage(\n    { id: \"toast.delete_past_tense\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const message = intl.formatMessage(\n    { id: \"dialogs.delete_entity_desc\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n\n  const { configuration: config } = useConfigurationContext();\n\n  const [deleteFile, setDeleteFile] = useState<boolean>(\n    config?.defaults.deleteFile ?? false\n  );\n  const [deleteGenerated, setDeleteGenerated] = useState<boolean>(\n    config?.defaults.deleteGenerated ?? true\n  );\n\n  const Toast = useToast();\n  const [deleteScene] = useScenesDestroy(getScenesDeleteInput());\n\n  // Network state\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  function getScenesDeleteInput(): GQL.ScenesDestroyInput {\n    return {\n      ids: props.selected.map((scene) => scene.id),\n      delete_file: deleteFile,\n      delete_generated: deleteGenerated,\n    };\n  }\n\n  async function onDelete() {\n    setIsDeleting(true);\n    try {\n      await deleteScene();\n      Toast.success(toastMessage);\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n      props.onClose(false);\n    }\n    setIsDeleting(false);\n  }\n\n  function funscriptPath(sp: string) {\n    const extIndex = sp.lastIndexOf(\".\");\n    if (extIndex !== -1) {\n      return sp.substring(0, extIndex + 1) + \"funscript\";\n    }\n\n    return sp;\n  }\n\n  function maybeRenderDeleteFileAlert() {\n    if (!deleteFile) {\n      return;\n    }\n\n    const deletedFiles: string[] = [];\n\n    props.selected.forEach((s) => {\n      const paths = s.files.map((f) => f.path);\n      deletedFiles.push(...paths);\n      if (s.interactive && s.files.length) {\n        deletedFiles.push(funscriptPath(objectPath(s)));\n      }\n    });\n\n    const deleteTrashPath = config?.general.deleteTrashPath;\n    const deleteAlertId = deleteTrashPath\n      ? \"dialogs.delete_alert_to_trash\"\n      : \"dialogs.delete_alert\";\n\n    return (\n      <div className=\"delete-dialog alert alert-danger text-break\">\n        <p className=\"font-weight-bold\">\n          <FormattedMessage\n            values={{\n              count: deletedFiles.length,\n              singularEntity: intl.formatMessage({ id: \"file\" }),\n              pluralEntity: intl.formatMessage({ id: \"files\" }),\n            }}\n            id={deleteAlertId}\n          />\n        </p>\n        <ul>\n          {deletedFiles.slice(0, 5).map((s) => (\n            <li key={s}>{s}</li>\n          ))}\n          {deletedFiles.length > 5 && (\n            <FormattedMessage\n              values={{\n                count: deletedFiles.length - 5,\n                singularEntity: intl.formatMessage({ id: \"file\" }),\n                pluralEntity: intl.formatMessage({ id: \"files\" }),\n              }}\n              id=\"dialogs.delete_object_overflow\"\n            />\n          )}\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faTrashAlt}\n      header={header}\n      accept={{\n        variant: \"danger\",\n        onClick: onDelete,\n        text: intl.formatMessage({ id: \"actions.delete\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(false),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isDeleting}\n    >\n      <p>{message}</p>\n      {maybeRenderDeleteFileAlert()}\n      <Form>\n        <Form.Check\n          id=\"delete-file\"\n          checked={deleteFile}\n          label={intl.formatMessage({\n            id: \"actions.delete_file_and_funscript\",\n          })}\n          onChange={() => setDeleteFile(!deleteFile)}\n        />\n        <Form.Check\n          id=\"delete-generated\"\n          checked={deleteGenerated}\n          label={intl.formatMessage({\n            id: \"actions.delete_generated_supporting_files\",\n          })}\n          onChange={() => setDeleteGenerated(!deleteGenerated)}\n        />\n      </Form>\n    </ModalComponent>\n  );\n};\n\nexport default DeleteScenesDialog;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useBulkSceneMarkerUpdate } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { MultiSet } from \"../Shared/MultiSet\";\nimport {\n  getAggregateState,\n  getAggregateStateObject,\n} from \"src/utils/bulkUpdate\";\nimport { BulkUpdateFormGroup, BulkUpdateTextInput } from \"../Shared/BulkUpdate\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { TagSelect } from \"../Shared/Select\";\n\ninterface IListOperationProps {\n  selected: GQL.SceneMarkerDataFragment[];\n  onClose: (applied: boolean) => void;\n}\n\nconst scenemarkerFields = [\"title\"];\n\nexport const EditSceneMarkersDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [updateInput, setUpdateInput] =\n    useState<GQL.BulkSceneMarkerUpdateInput>({\n      ids: props.selected.map((scenemarker) => {\n        return scenemarker.id;\n      }),\n    });\n\n  const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n\n  const unsetDisabled = props.selected.length < 2;\n\n  const [updateSceneMarkers] = useBulkSceneMarkerUpdate();\n\n  // Network state\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const aggregateState = useMemo(() => {\n    const updateState: Partial<GQL.BulkSceneMarkerUpdateInput> = {};\n    const state = props.selected;\n    let updateTagIds: string[] = [];\n    let first = true;\n\n    state.forEach((scenemarker: GQL.SceneMarkerDataFragment) => {\n      getAggregateStateObject(\n        updateState,\n        scenemarker,\n        scenemarkerFields,\n        first\n      );\n\n      // sceneMarker data fragment doesn't have primary_tag_id, so handle separately\n      updateState.primary_tag_id = getAggregateState(\n        updateState.primary_tag_id,\n        scenemarker.primary_tag.id,\n        first\n      );\n\n      const thisTagIDs = (scenemarker.tags ?? []).map((p) => p.id).sort();\n\n      updateTagIds = getAggregateState(updateTagIds, thisTagIDs, first) ?? [];\n\n      first = false;\n    });\n\n    return { state: updateState, tagIds: updateTagIds };\n  }, [props.selected]);\n\n  // update initial state from aggregate\n  useEffect(() => {\n    setUpdateInput((current) => ({ ...current, ...aggregateState.state }));\n  }, [aggregateState]);\n\n  function setUpdateField(input: Partial<GQL.BulkSceneMarkerUpdateInput>) {\n    setUpdateInput((current) => ({ ...current, ...input }));\n  }\n\n  function getSceneMarkerInput(): GQL.BulkSceneMarkerUpdateInput {\n    const sceneMarkerInput: GQL.BulkSceneMarkerUpdateInput = {\n      ...updateInput,\n      tag_ids: tagIds,\n    };\n\n    return sceneMarkerInput;\n  }\n\n  async function onSave() {\n    setIsUpdating(true);\n    try {\n      await updateSceneMarkers({\n        variables: {\n          input: getSceneMarkerInput(),\n        },\n      });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"markers\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsUpdating(false);\n  }\n\n  function render() {\n    return (\n      <ModalComponent\n        dialogClassName=\"edit-scenemarkers-dialog\"\n        show\n        icon={faPencilAlt}\n        header={intl.formatMessage(\n          { id: \"dialogs.edit_entity_count_title\" },\n          {\n            count: props?.selected?.length ?? 1,\n            singularEntity: intl.formatMessage({ id: \"marker\" }),\n            pluralEntity: intl.formatMessage({ id: \"markers\" }),\n          }\n        )}\n        accept={{\n          onClick: onSave,\n          text: intl.formatMessage({ id: \"actions.apply\" }),\n        }}\n        cancel={{\n          onClick: () => props.onClose(false),\n          text: intl.formatMessage({ id: \"actions.cancel\" }),\n          variant: \"secondary\",\n        }}\n        isRunning={isUpdating}\n      >\n        <Form>\n          <BulkUpdateFormGroup name=\"title\">\n            <BulkUpdateTextInput\n              value={updateInput.title}\n              valueChanged={(newValue) => setUpdateField({ title: newValue })}\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"primary-tag\" messageId=\"primary_tag\">\n            <TagSelect\n              onSelect={(t) => setUpdateField({ primary_tag_id: t[0]?.id })}\n              ids={\n                updateInput.primary_tag_id ? [updateInput.primary_tag_id] : []\n              }\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"tags\" inline={false}>\n            <MultiSet\n              type={\"tags\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setTagIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setTagIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={tagIds.ids ?? []}\n              existingIds={aggregateState.tagIds ?? []}\n              mode={tagIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n        </Form>\n      </ModalComponent>\n    );\n  }\n\n  return render();\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/EditScenesDialog.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useBulkSceneUpdate } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { StudioSelect } from \"../Shared/Select\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { MultiSet } from \"../Shared/MultiSet\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport {\n  getAggregateInputValue,\n  getAggregateGroupIds,\n  getAggregatePerformerIds,\n  getAggregateStateObject,\n  getAggregateTagIds,\n  getAggregateStudioId,\n} from \"src/utils/bulkUpdate\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { IndeterminateCheckbox } from \"../Shared/IndeterminateCheckbox\";\nimport { BulkUpdateFormGroup, BulkUpdateTextInput } from \"../Shared/BulkUpdate\";\nimport { BulkUpdateDateInput } from \"../Shared/DateInput\";\nimport { getDateError } from \"src/utils/yup\";\n\ninterface IListOperationProps {\n  selected: GQL.SlimSceneDataFragment[];\n  onClose: (applied: boolean) => void;\n}\n\nconst sceneFields = [\n  \"code\",\n  \"rating100\",\n  \"details\",\n  \"organized\",\n  \"director\",\n  \"date\",\n];\n\nexport const EditScenesDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [updateInput, setUpdateInput] = useState<GQL.BulkSceneUpdateInput>({\n    ids: props.selected.map((scene) => {\n      return scene.id;\n    }),\n  });\n\n  const [dateError, setDateError] = useState<string | undefined>();\n\n  const [performerIds, setPerformerIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [groupIds, setGroupIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n\n  const unsetDisabled = props.selected.length < 2;\n\n  const [updateScenes] = useBulkSceneUpdate();\n\n  // Network state\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const aggregateState = useMemo(() => {\n    const updateState: Partial<GQL.BulkSceneUpdateInput> = {};\n    const state = props.selected;\n    updateState.studio_id = getAggregateStudioId(props.selected);\n    const updateTagIds = getAggregateTagIds(props.selected);\n    const updatePerformerIds = getAggregatePerformerIds(props.selected);\n    const updateGroupIds = getAggregateGroupIds(props.selected);\n    let first = true;\n\n    state.forEach((scene: GQL.SlimSceneDataFragment) => {\n      getAggregateStateObject(updateState, scene, sceneFields, first);\n      first = false;\n    });\n\n    return {\n      state: updateState,\n      tagIds: updateTagIds,\n      performerIds: updatePerformerIds,\n      groupIds: updateGroupIds,\n    };\n  }, [props.selected]);\n\n  // update initial state from aggregate\n  useEffect(() => {\n    setUpdateInput((current) => ({ ...current, ...aggregateState.state }));\n  }, [aggregateState]);\n\n  useEffect(() => {\n    setDateError(getDateError(updateInput.date ?? \"\", intl));\n  }, [updateInput.date, intl]);\n\n  function setUpdateField(input: Partial<GQL.BulkSceneUpdateInput>) {\n    setUpdateInput((current) => ({ ...current, ...input }));\n  }\n\n  function getSceneInput(): GQL.BulkSceneUpdateInput {\n    const sceneInput: GQL.BulkSceneUpdateInput = {\n      ...updateInput,\n      tag_ids: tagIds,\n      performer_ids: performerIds,\n      group_ids: groupIds,\n    };\n\n    // we don't have unset functionality for the rating star control\n    // so need to determine if we are setting a rating or not\n    sceneInput.rating100 = getAggregateInputValue(\n      updateInput.rating100,\n      aggregateState.state.rating100\n    );\n\n    return sceneInput;\n  }\n\n  async function onSave() {\n    setIsUpdating(true);\n    try {\n      await updateScenes({ variables: { input: getSceneInput() } });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          { entity: intl.formatMessage({ id: \"scenes\" }).toLocaleLowerCase() }\n        )\n      );\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsUpdating(false);\n  }\n\n  function render() {\n    return (\n      <ModalComponent\n        show\n        icon={faPencilAlt}\n        header={intl.formatMessage(\n          { id: \"dialogs.edit_entity_count_title\" },\n          {\n            count: props?.selected?.length ?? 1,\n            singularEntity: intl.formatMessage({ id: \"scene\" }),\n            pluralEntity: intl.formatMessage({ id: \"scenes\" }),\n          }\n        )}\n        accept={{\n          onClick: onSave,\n          text: intl.formatMessage({ id: \"actions.apply\" }),\n        }}\n        disabled={isUpdating || !!dateError}\n        cancel={{\n          onClick: () => props.onClose(false),\n          text: intl.formatMessage({ id: \"actions.cancel\" }),\n          variant: \"secondary\",\n        }}\n        isRunning={isUpdating}\n      >\n        <Form>\n          <BulkUpdateFormGroup name=\"rating\">\n            <RatingSystem\n              value={updateInput.rating100}\n              onSetRating={(value) =>\n                setUpdateField({ rating100: value ?? undefined })\n              }\n              disabled={isUpdating}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"scene_code\">\n            <BulkUpdateTextInput\n              value={updateInput.code}\n              valueChanged={(newValue) => setUpdateField({ code: newValue })}\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"date\">\n            <BulkUpdateDateInput\n              value={updateInput.date}\n              valueChanged={(newValue) => setUpdateField({ date: newValue })}\n              unsetDisabled={unsetDisabled}\n              error={dateError}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"director\">\n            <BulkUpdateTextInput\n              value={updateInput.director}\n              valueChanged={(newValue) =>\n                setUpdateField({ director: newValue })\n              }\n              unsetDisabled={unsetDisabled}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"studio\">\n            <StudioSelect\n              onSelect={(items) =>\n                setUpdateField({\n                  studio_id: items.length > 0 ? items[0]?.id : undefined,\n                })\n              }\n              ids={updateInput.studio_id ? [updateInput.studio_id] : []}\n              isDisabled={isUpdating}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"performers\" inline={false}>\n            <MultiSet\n              type={\"performers\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setPerformerIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setPerformerIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={performerIds.ids ?? []}\n              existingIds={aggregateState.performerIds}\n              mode={performerIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"groups\" inline={false}>\n            <MultiSet\n              type={\"groups\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setGroupIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setGroupIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={groupIds.ids ?? []}\n              existingIds={aggregateState.groupIds}\n              mode={groupIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"tags\" inline={false}>\n            <MultiSet\n              type={\"tags\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setTagIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setTagIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={tagIds.ids ?? []}\n              existingIds={aggregateState.tagIds}\n              mode={tagIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"details\" inline={false}>\n            <BulkUpdateTextInput\n              value={updateInput.details}\n              valueChanged={(newValue) => setUpdateField({ details: newValue })}\n              unsetDisabled={unsetDisabled}\n              as=\"textarea\"\n            />\n          </BulkUpdateFormGroup>\n\n          <Form.Group controlId=\"organized\">\n            <IndeterminateCheckbox\n              label={intl.formatMessage({ id: \"organized\" })}\n              setChecked={(checked) => setUpdateField({ organized: checked })}\n              checked={updateInput.organized ?? undefined}\n            />\n          </Form.Group>\n        </Form>\n      </ModalComponent>\n    );\n  }\n\n  return render();\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/PreviewScrubber.tsx",
    "content": "import React, {\n  useRef,\n  useMemo,\n  useState,\n  useLayoutEffect,\n  useEffect,\n} from \"react\";\nimport { useSpriteInfo } from \"src/hooks/sprite\";\nimport { useThrottle } from \"src/hooks/throttle\";\nimport TextUtils from \"src/utils/text\";\nimport { HoverScrubber } from \"../Shared/HoverScrubber\";\n\ninterface IScenePreviewProps {\n  vttPath: string | undefined;\n  onClick?: (timestamp: number) => void;\n  disabled?: boolean;\n}\n\nfunction scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) {\n  const rw = bounds.width / dimensions.w;\n  const rh = bounds.height / dimensions.h;\n\n  // for consistency, use max by default and min for portrait\n  if (dimensions.w > dimensions.h) {\n    return Math.max(rw, rh);\n  }\n\n  return Math.min(rw, rh);\n}\n\nconst defaultSprites = 81; // 9x9 grid by default\n\nexport const PreviewScrubber: React.FC<IScenePreviewProps> = ({\n  vttPath,\n  onClick,\n  disabled,\n}) => {\n  const imageParentRef = useRef<HTMLDivElement>(null);\n  const [style, setStyle] = useState({});\n\n  const [activeIndex, setActiveIndex] = useState<number>();\n\n  const debounceSetActiveIndex = useThrottle(setActiveIndex, 50);\n\n  // hold off on loading vtt until first mouse over\n  const [hasLoaded, setHasLoaded] = useState(false);\n  const spriteInfo = useSpriteInfo(hasLoaded ? vttPath : undefined);\n\n  const spriteSheetSize = useMemo(() => {\n    if (!spriteInfo) {\n      return { x: 0, y: 0 };\n    }\n\n    // calculate total width/height of scrubber image so we can scale it\n    const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w));\n    const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h));\n\n    return { x: maxX, y: maxY };\n  }, [spriteInfo]);\n\n  const sprite = useMemo(() => {\n    if (!spriteInfo || activeIndex === undefined) {\n      return undefined;\n    }\n    return spriteInfo[activeIndex];\n  }, [activeIndex, spriteInfo]);\n\n  // mark as loaded on the first hover\n  useEffect(() => {\n    if (activeIndex !== undefined) {\n      setHasLoaded(true);\n    }\n  }, [activeIndex]);\n\n  useLayoutEffect(() => {\n    const imageParent = imageParentRef.current;\n\n    if (!sprite || !imageParent) {\n      return setStyle({});\n    }\n\n    const clientRect = imageParent.getBoundingClientRect();\n    const scale = scaleToFit(sprite, clientRect);\n\n    setStyle({\n      backgroundPosition: `${-sprite.x * scale}px ${-sprite.y * scale}px`,\n      backgroundImage: `url(${sprite.url})`,\n      backgroundSize: `${spriteSheetSize.x * scale}px ${\n        spriteSheetSize.y * scale\n      }px`,\n      width: `${sprite.w * scale}px`,\n      height: `${sprite.h * scale}px`,\n    });\n  }, [sprite, spriteSheetSize]);\n\n  const currentTime = useMemo(() => {\n    if (!sprite) return undefined;\n\n    const start = TextUtils.secondsToTimestamp(sprite.start);\n\n    return start;\n  }, [sprite]);\n\n  function onScrubberClick(index: number) {\n    if (!onClick || !spriteInfo) {\n      return;\n    }\n\n    const s = spriteInfo[index];\n    onClick(s.start);\n  }\n\n  if (spriteInfo === null || !vttPath) return null;\n\n  return (\n    <div className=\"preview-scrubber\">\n      {sprite && (\n        <div className=\"scene-card-preview-image\" ref={imageParentRef}>\n          <div className=\"scrubber-image\" style={style}></div>\n          {currentTime !== undefined && (\n            <div className=\"scrubber-timestamp\">{currentTime}</div>\n          )}\n        </div>\n      )}\n      <HoverScrubber\n        totalSprites={spriteInfo?.length ?? defaultSprites}\n        activeIndex={activeIndex}\n        setActiveIndex={(i) => debounceSetActiveIndex(i)}\n        onClick={onScrubberClick}\n        disabled={disabled}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneCard.tsx",
    "content": "import React, { useEffect, useMemo, useRef } from \"react\";\nimport { Button, ButtonGroup, OverlayTrigger, Tooltip } from \"react-bootstrap\";\nimport { useHistory } from \"react-router-dom\";\nimport cx from \"classnames\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"../Shared/Icon\";\nimport { GalleryLink, TagLink, SceneMarkerLink } from \"../Shared/TagLink\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport NavUtils from \"src/utils/navigation\";\nimport TextUtils from \"src/utils/text\";\nimport { SceneQueue } from \"src/models/sceneQueue\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { PerformerPopoverButton } from \"../Shared/PerformerPopoverButton\";\nimport { GridCard } from \"../Shared/GridCard/GridCard\";\nimport { RatingBanner } from \"../Shared/RatingBanner\";\nimport { FormattedMessage } from \"react-intl\";\nimport {\n  faBox,\n  faCopy,\n  faFilm,\n  faImages,\n  faMapMarkerAlt,\n  faTag,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { objectPath, objectTitle } from \"src/core/files\";\nimport { PreviewScrubber } from \"./PreviewScrubber\";\nimport { PatchComponent } from \"src/patch\";\nimport { StudioOverlay } from \"../Shared/GridCard/StudioOverlay\";\nimport { GroupTag } from \"../Groups/GroupTag\";\nimport { FileSize } from \"../Shared/FileSize\";\nimport { OCounterButton } from \"../Shared/CountButton\";\nimport { defaultPreviewVolume } from \"src/core/config\";\n\ninterface IScenePreviewProps {\n  isPortrait: boolean;\n  image?: string;\n  video?: string;\n  soundActive: boolean;\n  volume?: number;\n  vttPath?: string;\n  onScrubberClick?: (timestamp: number) => void;\n  disabled?: boolean;\n}\n\nexport const ScenePreview: React.FC<IScenePreviewProps> = ({\n  image,\n  video,\n  isPortrait,\n  soundActive,\n  vttPath,\n  onScrubberClick,\n  disabled,\n  volume,\n}) => {\n  const videoEl = useRef<HTMLVideoElement>(null);\n\n  useEffect(() => {\n    const observer = new IntersectionObserver((entries) => {\n      entries.forEach((entry) => {\n        if (entry.intersectionRatio > 0)\n          // Catch is necessary due to DOMException if user hovers before clicking on page\n          videoEl.current?.play()?.catch(() => {});\n        else videoEl.current?.pause();\n      });\n    });\n\n    if (videoEl.current) observer.observe(videoEl.current);\n  });\n\n  useEffect(() => {\n    if (videoEl?.current?.volume)\n      videoEl.current.volume = soundActive ? (volume ?? 0) / 100 : 0;\n  }, [volume, soundActive]);\n\n  return (\n    <div className={cx(\"scene-card-preview\", { portrait: isPortrait })}>\n      <img\n        className=\"scene-card-preview-image\"\n        loading=\"lazy\"\n        src={image}\n        alt=\"\"\n      />\n      <video\n        disableRemotePlayback\n        playsInline\n        muted={!soundActive}\n        className=\"scene-card-preview-video\"\n        loop\n        preload=\"none\"\n        ref={videoEl}\n        src={video}\n      />\n      <PreviewScrubber\n        vttPath={vttPath}\n        onClick={onScrubberClick}\n        disabled={disabled}\n      />\n    </div>\n  );\n};\n\ninterface ISceneCardProps {\n  scene: GQL.SlimSceneDataFragment;\n  width?: number;\n  previewHeight?: number;\n  index?: number;\n  queue?: SceneQueue;\n  compact?: boolean;\n  selecting?: boolean;\n  selected?: boolean | undefined;\n  zoomIndex?: number;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  fromGroupId?: string;\n}\n\nconst Description: React.FC<{\n  sceneNumber?: number;\n}> = ({ sceneNumber }) => {\n  if (!sceneNumber) return null;\n\n  return (\n    <>\n      <hr />\n      {sceneNumber !== undefined && (\n        <span className=\"scene-group-scene-number\">\n          <FormattedMessage id=\"scene\" /> #{sceneNumber}\n        </span>\n      )}\n    </>\n  );\n};\n\nconst SceneCardPopovers = PatchComponent(\n  \"SceneCard.Popovers\",\n  (props: ISceneCardProps) => {\n    const file = useMemo(\n      () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),\n      [props.scene]\n    );\n\n    const sceneNumber = useMemo(() => {\n      if (!props.fromGroupId) {\n        return undefined;\n      }\n\n      const group = props.scene.groups.find(\n        (g) => g.group.id === props.fromGroupId\n      );\n      return group?.scene_index ?? undefined;\n    }, [props.fromGroupId, props.scene.groups]);\n\n    function maybeRenderTagPopoverButton() {\n      if (props.scene.tags.length <= 0) return;\n\n      const popoverContent = props.scene.tags.map((tag) => (\n        <TagLink key={tag.id} tag={tag} />\n      ));\n\n      return (\n        <HoverPopover\n          className=\"tag-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faTag} />\n            <span>{props.scene.tags.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderPerformerPopoverButton() {\n      if (props.scene.performers.length <= 0) return;\n\n      return (\n        <PerformerPopoverButton\n          performers={props.scene.performers}\n          linkType=\"scene\"\n        />\n      );\n    }\n\n    function maybeRenderGroupPopoverButton() {\n      if (props.scene.groups.length <= 0) return;\n\n      const popoverContent = props.scene.groups.map((sceneGroup) => (\n        <GroupTag key={sceneGroup.group.id} group={sceneGroup.group} />\n      ));\n\n      return (\n        <HoverPopover\n          placement=\"bottom\"\n          content={popoverContent}\n          className=\"group-count tag-tooltip\"\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faFilm} />\n            <span>{props.scene.groups.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderSceneMarkerPopoverButton() {\n      if (props.scene.scene_markers.length <= 0) return;\n\n      const popoverContent = props.scene.scene_markers.map((marker) => {\n        const markerWithScene = { ...marker, scene: { id: props.scene.id } };\n        return <SceneMarkerLink key={marker.id} marker={markerWithScene} />;\n      });\n\n      return (\n        <HoverPopover\n          className=\"marker-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faMapMarkerAlt} />\n            <span>{props.scene.scene_markers.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderOCounter() {\n      if (props.scene.o_counter) {\n        return <OCounterButton value={props.scene.o_counter} />;\n      }\n    }\n\n    function maybeRenderGallery() {\n      if (props.scene.galleries.length <= 0) return;\n\n      const popoverContent = props.scene.galleries.map((gallery) => (\n        <GalleryLink key={gallery.id} gallery={gallery} />\n      ));\n\n      return (\n        <HoverPopover\n          className=\"gallery-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faImages} />\n            <span>{props.scene.galleries.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderOrganized() {\n      if (props.scene.organized) {\n        return (\n          <OverlayTrigger\n            overlay={<Tooltip id=\"organised-tooltip\">{\"Organized\"}</Tooltip>}\n            placement=\"bottom\"\n          >\n            <div className=\"organized\">\n              <Button className=\"minimal\">\n                <Icon icon={faBox} />\n              </Button>\n            </div>\n          </OverlayTrigger>\n        );\n      }\n    }\n\n    function maybeRenderDupeCopies() {\n      const phash = file\n        ? file.fingerprints.find((fp) => fp.type === \"phash\")\n        : undefined;\n\n      if (phash) {\n        return (\n          <div className=\"other-copies extra-scene-info\">\n            <Button\n              href={NavUtils.makeScenesPHashMatchUrl(phash.value)}\n              className=\"minimal\"\n            >\n              <Icon icon={faCopy} />\n            </Button>\n          </div>\n        );\n      }\n    }\n\n    function maybeRenderPopoverButtonGroup() {\n      if (\n        !props.compact &&\n        (props.scene.tags.length > 0 ||\n          props.scene.performers.length > 0 ||\n          props.scene.groups.length > 0 ||\n          props.scene.scene_markers.length > 0 ||\n          props.scene?.o_counter ||\n          props.scene.galleries.length > 0 ||\n          props.scene.organized ||\n          sceneNumber !== undefined)\n      ) {\n        return (\n          <>\n            <Description sceneNumber={sceneNumber} />\n            <hr />\n            <ButtonGroup className=\"card-popovers\">\n              {maybeRenderTagPopoverButton()}\n              {maybeRenderPerformerPopoverButton()}\n              {maybeRenderGroupPopoverButton()}\n              {maybeRenderSceneMarkerPopoverButton()}\n              {maybeRenderOCounter()}\n              {maybeRenderGallery()}\n              {maybeRenderOrganized()}\n              {maybeRenderDupeCopies()}\n            </ButtonGroup>\n          </>\n        );\n      }\n    }\n\n    return <>{maybeRenderPopoverButtonGroup()}</>;\n  }\n);\n\nconst SceneCardDetails = PatchComponent(\n  \"SceneCard.Details\",\n  (props: ISceneCardProps) => {\n    return (\n      <div className=\"scene-card__details\">\n        <span className=\"scene-card__date\">{props.scene.date}</span>\n        <span className=\"file-path extra-scene-info\">\n          {objectPath(props.scene)}\n        </span>\n        <TruncatedText\n          className=\"scene-card__description\"\n          text={props.scene.details}\n          lineCount={3}\n        />\n      </div>\n    );\n  }\n);\n\nconst SceneCardOverlays = PatchComponent(\n  \"SceneCard.Overlays\",\n  (props: ISceneCardProps) => {\n    const ret = useMemo(() => {\n      return (\n        <StudioOverlay studio={props.scene.studio} disabled={props.selecting} />\n      );\n    }, [props.scene.studio, props.selecting]);\n\n    return ret;\n  }\n);\n\ninterface ISceneSpecsOverlay {\n  scene: GQL.SlimSceneDataFragment;\n}\n\nexport const SceneSpecsOverlay: React.FC<ISceneSpecsOverlay> = PatchComponent(\n  \"SceneCard.SceneSpecs\",\n  ({ scene }) => {\n    const file = scene.files?.[0];\n    if (!file) return null;\n    return (\n      <div className=\"scene-specs-overlay\">\n        <span className=\"overlay-filesize extra-scene-info\">\n          <FileSize size={file.size} />\n        </span>\n        {file.width && file.height ? (\n          <span className=\"overlay-resolution\">\n            {TextUtils.resolution(file.width, file.height)}\n          </span>\n        ) : (\n          \"\"\n        )}\n        {file.duration > 0 ? (\n          <span className=\"overlay-duration\">\n            {TextUtils.secondsToTimestamp(file.duration)}\n          </span>\n        ) : (\n          \"\"\n        )}\n      </div>\n    );\n  }\n);\n\nconst SceneCardImage = PatchComponent(\n  \"SceneCard.Image\",\n  (props: ISceneCardProps) => {\n    const history = useHistory();\n    const { configuration } = useConfigurationContext();\n    const cont = configuration?.interface.continuePlaylistDefault ?? false;\n\n    const file = useMemo(\n      () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),\n      [props.scene]\n    );\n\n    function maybeRenderInteractiveSpeedOverlay() {\n      return (\n        <div className=\"scene-interactive-speed-overlay\">\n          {props.scene.interactive_speed ?? \"\"}\n        </div>\n      );\n    }\n\n    function onScrubberClick(timestamp: number) {\n      if (props.selecting) return;\n      const link = props.queue\n        ? props.queue.makeLink(props.scene.id, {\n            sceneIndex: props.index,\n            continue: cont,\n            start: timestamp,\n          })\n        : `/scenes/${props.scene.id}?t=${timestamp}`;\n\n      history.push(link);\n    }\n\n    function isPortrait() {\n      const width = file?.width ? file.width : 0;\n      const height = file?.height ? file.height : 0;\n      return height > width;\n    }\n\n    return (\n      <>\n        <ScenePreview\n          image={props.scene.paths.screenshot ?? undefined}\n          video={props.scene.paths.preview ?? undefined}\n          isPortrait={isPortrait()}\n          soundActive={configuration?.interface?.soundOnPreview ?? false}\n          volume={configuration?.ui.previewVolume ?? defaultPreviewVolume}\n          vttPath={props.scene.paths.vtt ?? undefined}\n          onScrubberClick={onScrubberClick}\n          disabled={props.selecting}\n        />\n        <RatingBanner rating={props.scene.rating100} />\n        <SceneSpecsOverlay scene={props.scene} />\n        {maybeRenderInteractiveSpeedOverlay()}\n      </>\n    );\n  }\n);\n\nexport const SceneCard = PatchComponent(\n  \"SceneCard\",\n  (props: ISceneCardProps) => {\n    const { configuration } = useConfigurationContext();\n\n    const file = useMemo(\n      () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),\n      [props.scene]\n    );\n\n    function zoomIndex() {\n      if (!props.compact && props.zoomIndex !== undefined) {\n        return `zoom-${props.zoomIndex}`;\n      }\n\n      return \"\";\n    }\n\n    function filelessClass() {\n      if (!props.scene.files.length) {\n        return \"fileless\";\n      }\n\n      return \"\";\n    }\n\n    const cont = configuration?.interface.continuePlaylistDefault ?? false;\n\n    const sceneLink = props.queue\n      ? props.queue.makeLink(props.scene.id, {\n          sceneIndex: props.index,\n          continue: cont,\n        })\n      : `/scenes/${props.scene.id}`;\n\n    return (\n      <GridCard\n        className={`scene-card ${zoomIndex()} ${filelessClass()}`}\n        url={sceneLink}\n        title={objectTitle(props.scene)}\n        width={props.width}\n        linkClassName=\"scene-card-link\"\n        thumbnailSectionClassName=\"video-section\"\n        resumeTime={props.scene.resume_time ?? undefined}\n        duration={file?.duration ?? undefined}\n        interactiveHeatmap={\n          props.scene.interactive_speed\n            ? props.scene.paths.interactive_heatmap ?? undefined\n            : undefined\n        }\n        image={<SceneCardImage {...props} />}\n        overlays={<SceneCardOverlays {...props} />}\n        details={<SceneCardDetails {...props} />}\n        popovers={<SceneCardPopovers {...props} />}\n        selected={props.selected}\n        selecting={props.selecting}\n        onSelectedChanged={props.onSelectedChanged}\n      />\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneCardGrid.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SceneQueue } from \"src/models/sceneQueue\";\nimport { SceneCard } from \"./SceneCard\";\nimport {\n  useCardWidth,\n  useContainerDimensions,\n} from \"../Shared/GridCard/GridCard\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface ISceneCardGrid {\n  scenes: GQL.SlimSceneDataFragment[];\n  queue?: SceneQueue;\n  selectedIds: Set<string>;\n  zoomIndex: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  fromGroupId?: string;\n}\n\nconst zoomWidths = [280, 340, 480, 640];\n\nexport const SceneCardGrid: React.FC<ISceneCardGrid> = PatchComponent(\n  \"SceneCardGrid\",\n  ({ scenes, queue, selectedIds, zoomIndex, onSelectChange, fromGroupId }) => {\n    const [componentRef, { width: containerWidth }] = useContainerDimensions();\n\n    const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);\n\n    return (\n      <div className=\"row justify-content-center\" ref={componentRef}>\n        {scenes.map((scene, index) => (\n          <SceneCard\n            key={scene.id}\n            width={cardWidth}\n            scene={scene}\n            queue={queue}\n            index={index}\n            zoomIndex={zoomIndex}\n            selecting={selectedIds.size > 0}\n            selected={selectedIds.has(scene.id)}\n            onSelectedChanged={(selected: boolean, shiftKey: boolean) =>\n              onSelectChange(scene.id, selected, shiftKey)\n            }\n            fromGroupId={fromGroupId}\n          />\n        ))}\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/ExternalPlayerButton.tsx",
    "content": "import { faExternalLinkAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport React from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { objectTitle } from \"src/core/files\";\nimport { SceneDataFragment } from \"src/core/generated-graphql\";\n\nexport interface IExternalPlayerButtonProps {\n  scene: SceneDataFragment;\n}\n\nexport const ExternalPlayerButton: React.FC<IExternalPlayerButtonProps> = ({\n  scene,\n}) => {\n  const isAndroid = /(android)/i.test(navigator.userAgent);\n  const isAppleDevice = /(ipod|iphone|ipad)/i.test(navigator.userAgent);\n  const intl = useIntl();\n\n  const { paths } = scene;\n\n  if (!paths || !paths.stream || (!isAndroid && !isAppleDevice))\n    return <span />;\n\n  const { stream } = paths;\n  const title = objectTitle(scene);\n\n  let url;\n  const streamURL = new URL(stream);\n  if (isAndroid) {\n    const scheme = streamURL.protocol.slice(0, -1);\n    streamURL.hash = `Intent;action=android.intent.action.VIEW;scheme=${scheme};type=video/mp4;S.title=${encodeURIComponent(\n      title\n    )};end`;\n\n    // #4401 - not allowed to set the protocol from a \"special\" protocol to a non-special protocol\n    url = streamURL\n      .toString()\n      .replace(new RegExp(`^${streamURL.protocol}`), \"intent:\");\n  } else if (isAppleDevice) {\n    streamURL.host = \"x-callback-url\";\n    streamURL.port = \"\";\n    streamURL.pathname = \"stream\";\n    streamURL.search = `url=${encodeURIComponent(stream)}`;\n\n    // #4401 - not allowed to set the protocol from a \"special\" protocol to a non-special protocol\n    url = streamURL\n      .toString()\n      .replace(new RegExp(`^${streamURL.protocol}`), \"vlc-x-callback:\");\n  }\n\n  return (\n    <Button\n      className=\"minimal px-0 px-sm-2 pt-2\"\n      variant=\"secondary\"\n      title={intl.formatMessage({ id: \"actions.open_in_external_player\" })}\n    >\n      <a href={url}>\n        <Icon icon={faExternalLinkAlt} color=\"white\" />\n      </a>\n    </Button>\n  );\n};\n\nexport default ExternalPlayerButton;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/OCounterButton.tsx",
    "content": "import { faBan, faMinus, faThumbsUp } from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState } from \"react\";\nimport { Button, ButtonGroup, Dropdown, DropdownButton } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { SweatDrops } from \"src/components/Shared/SweatDrops\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\nexport interface IOCounterButtonProps {\n  value: number;\n  onIncrement: () => Promise<void>;\n  onDecrement: () => Promise<void>;\n  onReset: () => Promise<void>;\n}\n\nexport const OCounterButton: React.FC<IOCounterButtonProps> = (\n  props: IOCounterButtonProps\n) => {\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const icon = !sfwContentMode ? <SweatDrops /> : <Icon icon={faThumbsUp} />;\n  const messageID = !sfwContentMode ? \"o_count\" : \"o_count_sfw\";\n\n  const [loading, setLoading] = useState(false);\n\n  async function increment() {\n    setLoading(true);\n    await props.onIncrement();\n    setLoading(false);\n  }\n\n  async function decrement() {\n    setLoading(true);\n    await props.onDecrement();\n    setLoading(false);\n  }\n\n  async function reset() {\n    setLoading(true);\n    await props.onReset();\n    setLoading(false);\n  }\n\n  if (loading) return <LoadingIndicator message=\"\" inline small />;\n\n  const renderButton = () => (\n    <Button\n      className=\"minimal pr-1\"\n      onClick={increment}\n      variant=\"secondary\"\n      title={intl.formatMessage({ id: messageID })}\n    >\n      {icon}\n      <span className=\"ml-2\">{props.value}</span>\n    </Button>\n  );\n\n  const maybeRenderDropdown = () => {\n    if (props.value) {\n      return (\n        <DropdownButton\n          as={ButtonGroup}\n          title=\" \"\n          variant=\"secondary\"\n          className=\"pl-0 show-carat\"\n        >\n          <Dropdown.Item onClick={decrement}>\n            <Icon icon={faMinus} />\n            <span>Decrement</span>\n          </Dropdown.Item>\n          <Dropdown.Item onClick={reset}>\n            <Icon icon={faBan} />\n            <span>Reset</span>\n          </Dropdown.Item>\n        </DropdownButton>\n      );\n    }\n  };\n\n  return (\n    <ButtonGroup className=\"o-counter\">\n      {renderButton()}\n      {maybeRenderDropdown()}\n    </ButtonGroup>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/OrganizedButton.tsx",
    "content": "import React from \"react\";\nimport cx from \"classnames\";\nimport { Button, Spinner } from \"react-bootstrap\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { defineMessages, useIntl } from \"react-intl\";\nimport { faBox } from \"@fortawesome/free-solid-svg-icons\";\n\nexport interface IOrganizedButtonProps {\n  loading: boolean;\n  organized: boolean;\n  onClick: () => void;\n}\n\nexport const OrganizedButton: React.FC<IOrganizedButtonProps> = (\n  props: IOrganizedButtonProps\n) => {\n  const intl = useIntl();\n  const messages = defineMessages({\n    organized: {\n      id: \"organized\",\n      defaultMessage: \"Organized\",\n    },\n  });\n\n  if (props.loading) return <Spinner animation=\"border\" role=\"status\" />;\n\n  return (\n    <Button\n      variant=\"secondary\"\n      title={intl.formatMessage(messages.organized)}\n      className={cx(\n        \"minimal\",\n        \"organized-button\",\n        props.organized ? \"organized\" : \"not-organized\"\n      )}\n      onClick={props.onClick}\n    >\n      <Icon icon={faBox} />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx",
    "content": "import React from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Button, Badge, Card } from \"react-bootstrap\";\nimport TextUtils from \"src/utils/text\";\nimport { markerTitle } from \"src/core/markers\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\ninterface IPrimaryTags {\n  sceneMarkers: GQL.SceneMarkerDataFragment[];\n  onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;\n  onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void;\n  onEdit: (marker: GQL.SceneMarkerDataFragment) => void;\n}\n\nexport const PrimaryTags: React.FC<IPrimaryTags> = ({\n  sceneMarkers,\n  onClickMarker,\n  onLoopMarker,\n  onEdit,\n}) => {\n  const { configuration } = useConfigurationContext();\n  const showAbLoopControls = configuration?.ui?.showAbLoopControls;\n\n  if (!sceneMarkers?.length) return <div />;\n\n  const primaryTagNames: Record<string, string> = {};\n  const markersByTag: Record<string, GQL.SceneMarkerDataFragment[]> = {};\n  sceneMarkers.forEach((m) => {\n    if (primaryTagNames[m.primary_tag.id]) {\n      markersByTag[m.primary_tag.id].push(m);\n    } else {\n      primaryTagNames[m.primary_tag.id] = m.primary_tag.name;\n      markersByTag[m.primary_tag.id] = [m];\n    }\n  });\n\n  const primaryCards = Object.keys(markersByTag).map((id) => {\n    const markers = markersByTag[id].map((marker) => {\n      const tags = marker.tags.map((tag) => (\n        <Badge key={tag.id} variant=\"secondary\" className=\"tag-item\">\n          {tag.name}\n        </Badge>\n      ));\n\n      return (\n        <div key={marker.id}>\n          <hr />\n          <div className=\"row\">\n            <Button variant=\"link\" onClick={() => onClickMarker(marker)}>\n              {markerTitle(marker)}\n            </Button>\n            <Button\n              variant=\"link\"\n              className=\"ml-auto\"\n              onClick={() => onEdit(marker)}\n            >\n              <FormattedMessage id=\"actions.edit\" />\n            </Button>\n          </div>\n          <div className=\"d-flex align-items-center\">\n            <div>\n              {TextUtils.formatTimestampRange(\n                marker.seconds,\n                marker.end_seconds ?? undefined\n              )}\n            </div>\n            {showAbLoopControls && marker.end_seconds != null && (\n              <Button\n                variant=\"link\"\n                className=\"ml-2 p-0\"\n                onClick={() => onLoopMarker(marker)}\n              >\n                Loop\n              </Button>\n            )}\n          </div>\n          <div className=\"card-section centered\">{tags}</div>\n        </div>\n      );\n    });\n\n    return (\n      <Card className=\"primary-card col-12 col-sm-6 col-xl-6\" key={id}>\n        <h3>{primaryTagNames[id]}</h3>\n        <Card.Body className=\"primary-card-body\">{markers}</Card.Body>\n      </Card>\n    );\n  });\n\n  return <div className=\"primary-tag row\">{primaryCards}</div>;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Link } from \"react-router-dom\";\nimport cx from \"classnames\";\nimport { Button, Form, Spinner } from \"react-bootstrap\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { useIntl } from \"react-intl\";\nimport {\n  faChevronDown,\n  faChevronUp,\n  faRandom,\n  faStepBackward,\n  faStepForward,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { objectTitle } from \"src/core/files\";\nimport { QueuedScene } from \"src/models/sceneQueue\";\n\nexport interface IPlaylistViewer {\n  scenes: QueuedScene[];\n  currentID?: string;\n  start?: number;\n  continue?: boolean;\n  hasMoreScenes: boolean;\n  setContinue: (v: boolean) => void;\n  onSceneClicked: (id: string) => void;\n  onNext: () => void;\n  onPrevious: () => void;\n  onRandom: () => void;\n  onMoreScenes: () => void;\n  onLessScenes: () => void;\n}\n\nexport const QueueViewer: React.FC<IPlaylistViewer> = ({\n  scenes,\n  currentID,\n  start = 0,\n  continue: continuePlaylist = false,\n  hasMoreScenes,\n  setContinue,\n  onNext,\n  onPrevious,\n  onRandom,\n  onSceneClicked,\n  onMoreScenes,\n  onLessScenes,\n}) => {\n  const intl = useIntl();\n  const [lessLoading, setLessLoading] = useState(false);\n  const [moreLoading, setMoreLoading] = useState(false);\n\n  const currentIndex = scenes.findIndex((s) => s.id === currentID);\n\n  useEffect(() => {\n    setLessLoading(false);\n    setMoreLoading(false);\n  }, [scenes]);\n\n  function isCurrentScene(scene: QueuedScene) {\n    return scene.id === currentID;\n  }\n\n  function handleSceneClick(\n    event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,\n    id: string\n  ) {\n    onSceneClicked(id);\n    event.preventDefault();\n  }\n\n  function lessClicked() {\n    setLessLoading(true);\n    onLessScenes();\n  }\n\n  function moreClicked() {\n    setMoreLoading(true);\n    onMoreScenes();\n  }\n\n  function renderPlaylistEntry(scene: QueuedScene) {\n    return (\n      <li\n        className={cx(\"my-2\", { current: isCurrentScene(scene) })}\n        key={scene.id}\n      >\n        <Link\n          to={`/scenes/${scene.id}`}\n          onClick={(e) => handleSceneClick(e, scene.id)}\n        >\n          <div className=\"ml-1 d-flex align-items-center\">\n            <div className=\"thumbnail-container\">\n              <img\n                loading=\"lazy\"\n                alt={scene.title ?? \"\"}\n                src={scene.paths.screenshot ?? \"\"}\n              />\n            </div>\n            <div className=\"queue-scene-details\">\n              <span className=\"queue-scene-title\">{objectTitle(scene)}</span>\n              <span className=\"queue-scene-studio\">{scene?.studio?.name}</span>\n              <span className=\"queue-scene-performers\">\n                {scene?.performers\n                  ?.map(function (performer) {\n                    return performer.name;\n                  })\n                  .join(\", \")}\n              </span>\n              <span className=\"queue-scene-date\">{scene?.date}</span>\n            </div>\n          </div>\n        </Link>\n      </li>\n    );\n  }\n\n  return (\n    <div id=\"queue-viewer\">\n      <div className=\"queue-controls\">\n        <div>\n          <Form.Check\n            id=\"continue-checkbox\"\n            checked={continuePlaylist}\n            label={intl.formatMessage({ id: \"actions.continue\" })}\n            onChange={() => {\n              setContinue(!continuePlaylist);\n            }}\n          />\n        </div>\n        <div>\n          {currentIndex > 0 || start > 1 ? (\n            <Button\n              className=\"minimal\"\n              variant=\"secondary\"\n              onClick={() => onPrevious()}\n            >\n              <Icon icon={faStepBackward} />\n            </Button>\n          ) : (\n            \"\"\n          )}\n          {currentIndex < scenes.length - 1 || hasMoreScenes ? (\n            <Button\n              className=\"minimal\"\n              variant=\"secondary\"\n              onClick={() => onNext()}\n            >\n              <Icon icon={faStepForward} />\n            </Button>\n          ) : (\n            \"\"\n          )}\n          <Button\n            className=\"minimal\"\n            variant=\"secondary\"\n            onClick={() => onRandom()}\n          >\n            <Icon icon={faRandom} />\n          </Button>\n        </div>\n      </div>\n      <div id=\"queue-content\">\n        {start > 1 ? (\n          <div className=\"d-flex justify-content-center\">\n            <Button onClick={() => lessClicked()} disabled={lessLoading}>\n              {!lessLoading ? (\n                <Icon icon={faChevronUp} />\n              ) : (\n                <Spinner animation=\"border\" role=\"status\" />\n              )}\n            </Button>\n          </div>\n        ) : undefined}\n        <ol start={start}>{scenes.map(renderPlaylistEntry)}</ol>\n        {hasMoreScenes ? (\n          <div className=\"d-flex justify-content-center\">\n            <Button onClick={() => moreClicked()} disabled={moreLoading}>\n              {!moreLoading ? (\n                <Icon icon={faChevronDown} />\n              ) : (\n                <Spinner animation=\"border\" role=\"status\" />\n              )}\n            </Button>\n          </div>\n        ) : undefined}\n      </div>\n    </div>\n  );\n};\n\nexport default QueueViewer;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx",
    "content": "import { Tab, Nav, Dropdown, Button } from \"react-bootstrap\";\nimport React, {\n  useEffect,\n  useState,\n  useMemo,\n  useRef,\n  useLayoutEffect,\n} from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useHistory, Link, RouteComponentProps } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  mutateMetadataScan,\n  useFindScene,\n  useSceneIncrementO,\n  useSceneGenerateScreenshot,\n  useSceneUpdate,\n  queryFindScenes,\n  queryFindScenesByID,\n  useSceneIncrementPlayCount,\n} from \"src/core/StashService\";\n\nimport { SceneEditPanel } from \"./SceneEditPanel\";\nimport { ErrorMessage } from \"src/components/Shared/ErrorMessage\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { Counter } from \"src/components/Shared/Counter\";\nimport { useToast } from \"src/hooks/Toast\";\nimport SceneQueue, { QueuedScene } from \"src/models/sceneQueue\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport Mousetrap from \"mousetrap\";\nimport { OrganizedButton } from \"./OrganizedButton\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport {\n  getAbLoopPlugin,\n  getPlayerPosition,\n} from \"src/components/ScenePlayer/util\";\nimport {\n  faEllipsisV,\n  faChevronRight,\n  faChevronLeft,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { objectPath, objectTitle } from \"src/core/files\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport TextUtils from \"src/utils/text\";\nimport {\n  OCounterButton,\n  ViewCountButton,\n} from \"src/components/Shared/CountButton\";\nimport { useRatingKeybinds } from \"src/hooks/keybinds\";\nimport { lazyComponent } from \"src/utils/lazyComponent\";\nimport cx from \"classnames\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport { SceneMergeModal } from \"../SceneMergeDialog\";\nimport { goBackOrReplace } from \"src/utils/history\";\nimport { FormattedDate } from \"src/components/Shared/Date\";\n\nconst SubmitStashBoxDraft = lazyComponent(\n  () => import(\"src/components/Dialogs/SubmitDraft\")\n);\nconst ScenePlayer = lazyComponent(\n  () => import(\"src/components/ScenePlayer/ScenePlayer\")\n);\n\nconst GalleryViewer = lazyComponent(\n  () => import(\"src/components/Galleries/GalleryViewer\")\n);\nconst ExternalPlayerButton = lazyComponent(\n  () => import(\"./ExternalPlayerButton\")\n);\n\nconst QueueViewer = lazyComponent(() => import(\"./QueueViewer\"));\nconst SceneMarkersPanel = lazyComponent(() => import(\"./SceneMarkersPanel\"));\nconst SceneFileInfoPanel = lazyComponent(() => import(\"./SceneFileInfoPanel\"));\nconst SceneDetailPanel = lazyComponent(() => import(\"./SceneDetailPanel\"));\nconst SceneHistoryPanel = lazyComponent(() => import(\"./SceneHistoryPanel\"));\nconst SceneGroupPanel = lazyComponent(() => import(\"./SceneGroupPanel\"));\nconst SceneGalleriesPanel = lazyComponent(\n  () => import(\"./SceneGalleriesPanel\")\n);\nconst DeleteScenesDialog = lazyComponent(() => import(\"../DeleteScenesDialog\"));\nconst GenerateDialog = lazyComponent(\n  () => import(\"../../Dialogs/GenerateDialog\")\n);\nconst SceneVideoFilterPanel = lazyComponent(\n  () => import(\"./SceneVideoFilterPanel\")\n);\n\nconst VideoFrameRateResolution: React.FC<{\n  width?: number;\n  height?: number;\n  frameRate?: number;\n}> = ({ width, height, frameRate }) => {\n  const intl = useIntl();\n\n  const resolution = useMemo(() => {\n    if (width && height) {\n      const r = TextUtils.resolution(width, height);\n      return (\n        <span className=\"resolution\" data-value={r}>\n          {r}\n        </span>\n      );\n    }\n    return undefined;\n  }, [width, height]);\n\n  const frameRateDisplay = useMemo(() => {\n    if (frameRate) {\n      return (\n        <span className=\"frame-rate\" data-value={frameRate}>\n          <FormattedMessage\n            id=\"frames_per_second\"\n            values={{ value: intl.formatNumber(frameRate ?? 0) }}\n          />\n        </span>\n      );\n    }\n    return undefined;\n  }, [intl, frameRate]);\n\n  const divider = useMemo(() => {\n    return resolution && frameRateDisplay ? (\n      <span className=\"divider\"> | </span>\n    ) : undefined;\n  }, [resolution, frameRateDisplay]);\n\n  return (\n    <span>\n      {frameRateDisplay}\n      {divider}\n      {resolution}\n    </span>\n  );\n};\n\ninterface IProps {\n  scene: GQL.SceneDataFragment;\n  setTimestamp: (num: number) => void;\n  queueScenes: QueuedScene[];\n  onQueueNext: () => void;\n  onQueuePrevious: () => void;\n  onQueueRandom: () => void;\n  onQueueSceneClicked: (sceneID: string) => void;\n  onDelete: () => void;\n  continuePlaylist: boolean;\n  queueHasMoreScenes: boolean;\n  onQueueMoreScenes: () => void;\n  onQueueLessScenes: () => void;\n  queueStart: number;\n  collapsed: boolean;\n  setCollapsed: (state: boolean) => void;\n  setContinuePlaylist: (value: boolean) => void;\n}\n\ninterface ISceneParams {\n  id: string;\n}\n\nconst ScenePageTabs = PatchContainerComponent<IProps>(\"ScenePage.Tabs\");\nconst ScenePageTabContent = PatchContainerComponent<IProps>(\n  \"ScenePage.TabContent\"\n);\n\nconst ScenePage: React.FC<IProps> = PatchComponent(\"ScenePage\", (props) => {\n  const {\n    scene,\n    setTimestamp,\n    queueScenes,\n    onQueueNext,\n    onQueuePrevious,\n    onQueueRandom,\n    onQueueSceneClicked,\n    onDelete,\n    continuePlaylist,\n    queueHasMoreScenes,\n    onQueueMoreScenes,\n    onQueueLessScenes,\n    queueStart,\n    collapsed,\n    setCollapsed,\n    setContinuePlaylist,\n  } = props;\n\n  const Toast = useToast();\n  const intl = useIntl();\n  const history = useHistory();\n  const [updateScene] = useSceneUpdate();\n  const [generateScreenshot] = useSceneGenerateScreenshot();\n  const { configuration } = useConfigurationContext();\n\n  const [showDraftModal, setShowDraftModal] = useState(false);\n  const boxes = configuration?.general?.stashBoxes ?? [];\n\n  const [incrementO] = useSceneIncrementO(scene.id);\n\n  const [incrementPlay] = useSceneIncrementPlayCount();\n\n  function incrementPlayCount() {\n    incrementPlay({\n      variables: {\n        id: scene.id,\n      },\n    });\n  }\n\n  const [organizedLoading, setOrganizedLoading] = useState(false);\n\n  const [activeTabKey, setActiveTabKey] = useState(\"scene-details-panel\");\n\n  const [isMerging, setIsMerging] = useState(false);\n  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);\n  const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);\n\n  const onIncrementOClick = async () => {\n    try {\n      await incrementO();\n    } catch (e) {\n      Toast.error(e);\n    }\n  };\n\n  function setRating(v: number | null) {\n    updateScene({\n      variables: {\n        input: {\n          id: scene.id,\n          rating100: v,\n        },\n      },\n    });\n  }\n\n  useRatingKeybinds(\n    true,\n    configuration?.ui.ratingSystemOptions?.type,\n    setRating\n  );\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"a\", () => setActiveTabKey(\"scene-details-panel\"));\n    Mousetrap.bind(\"q\", () => setActiveTabKey(\"scene-queue-panel\"));\n    Mousetrap.bind(\"e\", () => setActiveTabKey(\"scene-edit-panel\"));\n    Mousetrap.bind(\"k\", () => setActiveTabKey(\"scene-markers-panel\"));\n    Mousetrap.bind(\"i\", () => setActiveTabKey(\"scene-file-info-panel\"));\n    Mousetrap.bind(\"h\", () => setActiveTabKey(\"scene-history-panel\"));\n    Mousetrap.bind(\"o\", () => {\n      onIncrementOClick();\n    });\n    Mousetrap.bind(\"p n\", () => onQueueNext());\n    Mousetrap.bind(\"p p\", () => onQueuePrevious());\n    Mousetrap.bind(\"p r\", () => onQueueRandom());\n    Mousetrap.bind(\",\", () => setCollapsed(!collapsed));\n    Mousetrap.bind(\"c c\", () => {\n      onGenerateScreenshot(getPlayerPosition());\n    });\n    Mousetrap.bind(\"c d\", () => {\n      onGenerateScreenshot();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"a\");\n      Mousetrap.unbind(\"q\");\n      Mousetrap.unbind(\"e\");\n      Mousetrap.unbind(\"k\");\n      Mousetrap.unbind(\"i\");\n      Mousetrap.unbind(\"h\");\n      Mousetrap.unbind(\"o\");\n      Mousetrap.unbind(\"p n\");\n      Mousetrap.unbind(\"p p\");\n      Mousetrap.unbind(\"p r\");\n      Mousetrap.unbind(\",\");\n      Mousetrap.unbind(\"c c\");\n      Mousetrap.unbind(\"c d\");\n    };\n  });\n\n  async function onSave(input: GQL.SceneCreateInput) {\n    await updateScene({\n      variables: {\n        input: {\n          id: scene.id,\n          ...input,\n        },\n      },\n    });\n    Toast.success(\n      intl.formatMessage(\n        { id: \"toast.updated_entity\" },\n        { entity: intl.formatMessage({ id: \"scene\" }).toLocaleLowerCase() }\n      )\n    );\n  }\n\n  const onOrganizedClick = async () => {\n    try {\n      setOrganizedLoading(true);\n      await updateScene({\n        variables: {\n          input: {\n            id: scene.id,\n            organized: !scene.organized,\n          },\n        },\n      });\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setOrganizedLoading(false);\n    }\n  };\n\n  function onClickMarker(marker: GQL.SceneMarkerDataFragment) {\n    const abLoopPlugin = getAbLoopPlugin();\n    const opts = abLoopPlugin?.getOptions();\n    const start = opts?.start;\n    const end = opts?.end;\n\n    const hasLoopRange =\n      opts?.enabled &&\n      typeof start === \"number\" &&\n      typeof end === \"number\" &&\n      Number.isFinite(start) &&\n      Number.isFinite(end);\n\n    if (\n      abLoopPlugin &&\n      opts &&\n      hasLoopRange &&\n      (marker.seconds < Math.min(start as number, end as number) ||\n        marker.seconds > Math.max(start as number, end as number))\n    ) {\n      abLoopPlugin.setOptions({\n        ...opts,\n        enabled: false,\n      });\n    }\n\n    setTimestamp(marker.seconds);\n  }\n\n  function onLoopMarker(marker: GQL.SceneMarkerDataFragment) {\n    if (marker.end_seconds == null) return;\n\n    setTimestamp(marker.seconds);\n    const start = Math.min(marker.seconds, marker.end_seconds);\n    const end = Math.max(marker.seconds, marker.end_seconds);\n    const abLoopPlugin = getAbLoopPlugin();\n    const opts = abLoopPlugin?.getOptions();\n\n    if (opts && abLoopPlugin) {\n      abLoopPlugin.setOptions({\n        ...opts,\n        start,\n        end,\n        enabled: true,\n      });\n    }\n  }\n\n  async function onRescan() {\n    await mutateMetadataScan({\n      paths: [objectPath(scene)],\n      rescan: true,\n    });\n\n    Toast.success(\n      intl.formatMessage(\n        { id: \"toast.rescanning_entity\" },\n        {\n          count: 1,\n          singularEntity: intl\n            .formatMessage({ id: \"scene\" })\n            .toLocaleLowerCase(),\n        }\n      )\n    );\n  }\n\n  async function onGenerateScreenshot(at?: number) {\n    await generateScreenshot({\n      variables: {\n        id: scene.id,\n        at,\n      },\n    });\n    Toast.success(intl.formatMessage({ id: \"toast.generating_screenshot\" }));\n  }\n\n  function onDeleteDialogClosed(deleted: boolean) {\n    setIsDeleteAlertOpen(false);\n    if (deleted) {\n      onDelete();\n    }\n  }\n\n  function maybeRenderMergeDialog() {\n    if (!scene.id) return;\n    return (\n      <SceneMergeModal\n        show={isMerging}\n        onClose={(mergedId) => {\n          setIsMerging(false);\n          if (mergedId !== undefined && mergedId !== scene.id) {\n            // By default, the merge destination is the current scene, but\n            // the user can change it, in which case we need to redirect.\n            history.replace(`/scenes/${mergedId}`);\n          }\n        }}\n        scenes={[{ id: scene.id, title: objectTitle(scene) }]}\n      />\n    );\n  }\n\n  function maybeRenderDeleteDialog() {\n    if (isDeleteAlertOpen) {\n      return (\n        <DeleteScenesDialog selected={[scene]} onClose={onDeleteDialogClosed} />\n      );\n    }\n  }\n\n  function maybeRenderSceneGenerateDialog() {\n    if (isGenerateDialogOpen) {\n      return (\n        <GenerateDialog\n          selectedIds={[scene.id]}\n          onClose={() => {\n            setIsGenerateDialogOpen(false);\n          }}\n          type=\"scene\"\n        />\n      );\n    }\n  }\n\n  const renderOperations = () => (\n    <Dropdown>\n      <Dropdown.Toggle\n        variant=\"secondary\"\n        id=\"operation-menu\"\n        className=\"minimal\"\n        title={intl.formatMessage({ id: \"operations\" })}\n      >\n        <Icon icon={faEllipsisV} />\n      </Dropdown.Toggle>\n      <Dropdown.Menu className=\"bg-secondary text-white\">\n        {!!scene.files.length && (\n          <Dropdown.Item\n            key=\"rescan\"\n            className=\"bg-secondary text-white\"\n            onClick={() => onRescan()}\n          >\n            <FormattedMessage id=\"actions.rescan\" />\n          </Dropdown.Item>\n        )}\n        <Dropdown.Item\n          key=\"generate\"\n          className=\"bg-secondary text-white\"\n          onClick={() => setIsGenerateDialogOpen(true)}\n        >\n          <FormattedMessage id=\"actions.generate\" />…\n        </Dropdown.Item>\n        <Dropdown.Item\n          key=\"generate-screenshot\"\n          className=\"bg-secondary text-white\"\n          onClick={() => onGenerateScreenshot(getPlayerPosition())}\n        >\n          <FormattedMessage id=\"actions.generate_thumb_from_current\" />\n        </Dropdown.Item>\n        <Dropdown.Item\n          key=\"generate-default\"\n          className=\"bg-secondary text-white\"\n          onClick={() => onGenerateScreenshot()}\n        >\n          <FormattedMessage id=\"actions.generate_thumb_default\" />\n        </Dropdown.Item>\n        {boxes.length > 0 && (\n          <Dropdown.Item\n            key=\"submit\"\n            className=\"bg-secondary text-white\"\n            onClick={() => setShowDraftModal(true)}\n          >\n            <FormattedMessage id=\"actions.submit_stash_box\" />\n          </Dropdown.Item>\n        )}\n        <Dropdown.Item\n          key=\"merge-scene\"\n          className=\"bg-secondary text-white\"\n          onClick={() => setIsMerging(true)}\n        >\n          <FormattedMessage id=\"actions.merge\" />\n          ...\n        </Dropdown.Item>\n        <Dropdown.Item\n          key=\"delete-scene\"\n          className=\"bg-secondary text-white\"\n          onClick={() => setIsDeleteAlertOpen(true)}\n        >\n          <FormattedMessage\n            id=\"actions.delete\"\n            values={{ entityType: intl.formatMessage({ id: \"scene\" }) }}\n          />\n        </Dropdown.Item>\n      </Dropdown.Menu>\n    </Dropdown>\n  );\n\n  const renderTabs = () => (\n    <Tab.Container\n      activeKey={activeTabKey}\n      onSelect={(k) => k && setActiveTabKey(k)}\n    >\n      <div>\n        <Nav variant=\"tabs\" className=\"mr-auto\">\n          <ScenePageTabs {...props}>\n            <Nav.Item>\n              <Nav.Link eventKey=\"scene-details-panel\">\n                <FormattedMessage id=\"details\" />\n              </Nav.Link>\n            </Nav.Item>\n            {queueScenes.length > 0 ? (\n              <Nav.Item>\n                <Nav.Link eventKey=\"scene-queue-panel\">\n                  <FormattedMessage id=\"queue\" />\n                </Nav.Link>\n              </Nav.Item>\n            ) : (\n              \"\"\n            )}\n            <Nav.Item>\n              <Nav.Link eventKey=\"scene-markers-panel\">\n                <FormattedMessage id=\"markers\" />\n              </Nav.Link>\n            </Nav.Item>\n            {scene.groups.length > 0 ? (\n              <Nav.Item>\n                <Nav.Link eventKey=\"scene-group-panel\">\n                  <FormattedMessage\n                    id=\"countables.groups\"\n                    values={{ count: scene.groups.length }}\n                  />\n                </Nav.Link>\n              </Nav.Item>\n            ) : (\n              \"\"\n            )}\n            {scene.galleries.length >= 1 ? (\n              <Nav.Item>\n                <Nav.Link eventKey=\"scene-galleries-panel\">\n                  <FormattedMessage\n                    id=\"countables.galleries\"\n                    values={{ count: scene.galleries.length }}\n                  />\n                </Nav.Link>\n              </Nav.Item>\n            ) : undefined}\n            <Nav.Item>\n              <Nav.Link eventKey=\"scene-video-filter-panel\">\n                <FormattedMessage id=\"effect_filters.name\" />\n              </Nav.Link>\n            </Nav.Item>\n            <Nav.Item>\n              <Nav.Link eventKey=\"scene-file-info-panel\">\n                <FormattedMessage id=\"file_info\" />\n                <Counter count={scene.files.length} hideZero hideOne />\n              </Nav.Link>\n            </Nav.Item>\n            <Nav.Item>\n              <Nav.Link eventKey=\"scene-history-panel\">\n                <FormattedMessage id=\"history\" />\n              </Nav.Link>\n            </Nav.Item>\n            <Nav.Item>\n              <Nav.Link eventKey=\"scene-edit-panel\">\n                <FormattedMessage id=\"actions.edit\" />\n              </Nav.Link>\n            </Nav.Item>\n          </ScenePageTabs>\n        </Nav>\n      </div>\n\n      <Tab.Content>\n        <ScenePageTabContent {...props}>\n          <Tab.Pane eventKey=\"scene-details-panel\">\n            <SceneDetailPanel scene={scene} />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"scene-queue-panel\">\n            <QueueViewer\n              scenes={queueScenes}\n              currentID={scene.id}\n              continue={continuePlaylist}\n              setContinue={setContinuePlaylist}\n              onSceneClicked={onQueueSceneClicked}\n              onNext={onQueueNext}\n              onPrevious={onQueuePrevious}\n              onRandom={onQueueRandom}\n              start={queueStart}\n              hasMoreScenes={queueHasMoreScenes}\n              onLessScenes={onQueueLessScenes}\n              onMoreScenes={onQueueMoreScenes}\n            />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"scene-markers-panel\">\n            <SceneMarkersPanel\n              sceneId={scene.id}\n              onClickMarker={onClickMarker}\n              onLoopMarker={onLoopMarker}\n              isVisible={activeTabKey === \"scene-markers-panel\"}\n            />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"scene-group-panel\">\n            <SceneGroupPanel scene={scene} />\n          </Tab.Pane>\n          {scene.galleries.length >= 1 && (\n            <Tab.Pane eventKey=\"scene-galleries-panel\">\n              <SceneGalleriesPanel galleries={scene.galleries} />\n              {scene.galleries.length === 1 && (\n                <GalleryViewer galleryId={scene.galleries[0].id} />\n              )}\n            </Tab.Pane>\n          )}\n          <Tab.Pane eventKey=\"scene-video-filter-panel\">\n            <SceneVideoFilterPanel scene={scene} />\n          </Tab.Pane>\n          <Tab.Pane\n            className=\"file-info-panel\"\n            eventKey=\"scene-file-info-panel\"\n          >\n            <SceneFileInfoPanel scene={scene} />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"scene-edit-panel\" mountOnEnter>\n            <SceneEditPanel\n              isVisible={activeTabKey === \"scene-edit-panel\"}\n              scene={scene}\n              onSubmit={onSave}\n              onDelete={() => setIsDeleteAlertOpen(true)}\n            />\n          </Tab.Pane>\n          <Tab.Pane eventKey=\"scene-history-panel\">\n            <SceneHistoryPanel scene={scene} />\n          </Tab.Pane>\n        </ScenePageTabContent>\n      </Tab.Content>\n    </Tab.Container>\n  );\n\n  function getCollapseButtonIcon() {\n    return collapsed ? faChevronRight : faChevronLeft;\n  }\n\n  const title = objectTitle(scene);\n\n  const file = useMemo(\n    () => (scene.files.length > 0 ? scene.files[0] : undefined),\n    [scene]\n  );\n\n  return (\n    <>\n      <Helmet>\n        <title>{title}</title>\n      </Helmet>\n      {maybeRenderSceneGenerateDialog()}\n      {maybeRenderMergeDialog()}\n      {maybeRenderDeleteDialog()}\n      <div\n        className={`scene-tabs order-xl-first order-last ${\n          collapsed ? \"collapsed\" : \"\"\n        }`}\n      >\n        <div>\n          <div className=\"scene-header-container\">\n            {scene.studio && (\n              <h1 className=\"text-center scene-studio-image\">\n                <Link to={`/studios/${scene.studio.id}`}>\n                  <img\n                    src={scene.studio.image_path ?? \"\"}\n                    alt={`${scene.studio.name} logo`}\n                    className=\"studio-logo\"\n                  />\n                </Link>\n              </h1>\n            )}\n            <h3 className={cx(\"scene-header\", { \"no-studio\": !scene.studio })}>\n              <TruncatedText lineCount={2} text={title} />\n            </h3>\n          </div>\n\n          <div className=\"scene-subheader\">\n            <span className=\"date\" data-value={scene.date}>\n              {!!scene.date && <FormattedDate value={scene.date} />}\n            </span>\n            <VideoFrameRateResolution\n              width={file?.width}\n              height={file?.height}\n              frameRate={file?.frame_rate}\n            />\n          </div>\n\n          <div className=\"scene-toolbar\">\n            <span className=\"scene-toolbar-group\">\n              <RatingSystem\n                value={scene.rating100}\n                onSetRating={setRating}\n                clickToRate\n                withoutContext\n              />\n            </span>\n            <span className=\"scene-toolbar-group\">\n              <span>\n                <ExternalPlayerButton scene={scene} />\n              </span>\n              <span>\n                <ViewCountButton\n                  value={scene.play_count ?? 0}\n                  onIncrement={() => incrementPlayCount()}\n                />\n              </span>\n              <span>\n                <OCounterButton\n                  value={scene.o_counter ?? 0}\n                  onIncrement={() => onIncrementOClick()}\n                />\n              </span>\n              <span>\n                <OrganizedButton\n                  loading={organizedLoading}\n                  organized={scene.organized}\n                  onClick={onOrganizedClick}\n                />\n              </span>\n              <span>{renderOperations()}</span>\n            </span>\n          </div>\n        </div>\n        {renderTabs()}\n      </div>\n      <div className=\"scene-divider d-none d-xl-block\">\n        <Button onClick={() => setCollapsed(!collapsed)}>\n          <Icon className=\"fa-fw\" icon={getCollapseButtonIcon()} />\n        </Button>\n      </div>\n      <SubmitStashBoxDraft\n        type=\"scene\"\n        boxes={boxes}\n        entity={scene}\n        show={showDraftModal}\n        onHide={() => setShowDraftModal(false)}\n      />\n    </>\n  );\n});\n\nconst SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({\n  location,\n  history,\n  match,\n}) => {\n  const { id } = match.params;\n  const { configuration } = useConfigurationContext();\n  const { data, loading, error } = useFindScene(id);\n\n  const [scene, setScene] = useState<GQL.SceneDataFragment>();\n\n  // useLayoutEffect to update before paint\n  useLayoutEffect(() => {\n    // only update scene when loading is done\n    if (!loading) {\n      setScene(data?.findScene ?? undefined);\n    }\n  }, [data, loading]);\n\n  const queryParams = useMemo(\n    () => new URLSearchParams(location.search),\n    [location.search]\n  );\n  const sceneQueue = useMemo(\n    () => SceneQueue.fromQueryParameters(queryParams),\n    [queryParams]\n  );\n  const queryContinue = useMemo(() => {\n    let cont = queryParams.get(\"continue\");\n    if (cont) {\n      return cont === \"true\";\n    } else {\n      return !!configuration?.interface.continuePlaylistDefault;\n    }\n  }, [configuration?.interface.continuePlaylistDefault, queryParams]);\n\n  const [queueScenes, setQueueScenes] = useState<QueuedScene[]>([]);\n\n  const [collapsed, setCollapsed] = useState(false);\n  const [continuePlaylist, setContinuePlaylist] = useState(queryContinue);\n  const [hideScrubber, setHideScrubber] = useState(\n    !(configuration?.interface.showScrubber ?? true)\n  );\n\n  const _setTimestamp = useRef<(value: number) => void>();\n  const initialTimestamp = useMemo(() => {\n    const t = queryParams.get(\"t\");\n    if (!t) return 0;\n\n    const n = Number(t);\n    if (Number.isNaN(n)) return 0;\n    return n;\n  }, [queryParams]);\n\n  const [queueTotal, setQueueTotal] = useState(0);\n  const [queueStart, setQueueStart] = useState(1);\n\n  const autoplay = queryParams.get(\"autoplay\") === \"true\";\n  const autoPlayOnSelected =\n    configuration?.interface.autostartVideoOnPlaySelected ?? false;\n\n  const currentQueueIndex = useMemo(\n    () => queueScenes.findIndex((s) => s.id === id),\n    [queueScenes, id]\n  );\n\n  function getSetTimestamp(fn: (value: number) => void) {\n    _setTimestamp.current = fn;\n  }\n\n  function setTimestamp(value: number) {\n    if (_setTimestamp.current) {\n      _setTimestamp.current(value);\n    }\n  }\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\".\", () => setHideScrubber((value) => !value));\n\n    return () => {\n      Mousetrap.unbind(\".\");\n    };\n  }, []);\n\n  async function getQueueFilterScenes(filter: ListFilterModel) {\n    const query = await queryFindScenes(filter);\n    const { scenes, count } = query.data.findScenes;\n    setQueueScenes(scenes);\n    setQueueTotal(count);\n    setQueueStart((filter.currentPage - 1) * filter.itemsPerPage + 1);\n  }\n\n  async function getQueueScenes(sceneIDs: number[]) {\n    const query = await queryFindScenesByID(sceneIDs);\n    const { scenes, count } = query.data.findScenes;\n    setQueueScenes(scenes);\n    setQueueTotal(count);\n    setQueueStart(1);\n  }\n\n  useEffect(() => {\n    if (sceneQueue.query) {\n      getQueueFilterScenes(sceneQueue.query);\n    } else if (sceneQueue.sceneIDs) {\n      getQueueScenes(sceneQueue.sceneIDs);\n    }\n  }, [sceneQueue]);\n\n  async function onQueueLessScenes() {\n    if (!sceneQueue.query || queueStart <= 1) {\n      return;\n    }\n\n    const filterCopy = sceneQueue.query.clone();\n    const newStart = queueStart - filterCopy.itemsPerPage;\n    filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage);\n    const query = await queryFindScenes(filterCopy);\n    const { scenes } = query.data.findScenes;\n\n    // prepend scenes to scene list\n    const newScenes = (scenes as QueuedScene[]).concat(queueScenes);\n    setQueueScenes(newScenes);\n    setQueueStart(newStart);\n\n    return scenes;\n  }\n\n  const queueHasMoreScenes = useMemo(() => {\n    return queueStart + queueScenes.length - 1 < queueTotal;\n  }, [queueStart, queueScenes, queueTotal]);\n\n  async function onQueueMoreScenes() {\n    if (!sceneQueue.query || !queueHasMoreScenes) {\n      return;\n    }\n\n    const filterCopy = sceneQueue.query.clone();\n    const newStart = queueStart + queueScenes.length;\n    filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage);\n    const query = await queryFindScenes(filterCopy);\n    const { scenes } = query.data.findScenes;\n\n    // append scenes to scene list\n    const newScenes = queueScenes.concat(scenes);\n    setQueueScenes(newScenes);\n    // don't change queue start\n    return scenes;\n  }\n\n  function loadScene(sceneID: string, autoPlay?: boolean, newPage?: number) {\n    const sceneLink = sceneQueue.makeLink(sceneID, {\n      newPage,\n      autoPlay,\n      continue: continuePlaylist,\n    });\n    history.replace(sceneLink);\n  }\n\n  async function queueNext(autoPlay: boolean) {\n    if (currentQueueIndex === -1) return;\n\n    if (currentQueueIndex < queueScenes.length - 1) {\n      loadScene(queueScenes[currentQueueIndex + 1].id, autoPlay);\n    } else {\n      // if we're at the end of the queue, load more scenes\n      if (currentQueueIndex === queueScenes.length - 1 && queueHasMoreScenes) {\n        const loadedScenes = await onQueueMoreScenes();\n        if (loadedScenes && loadedScenes.length > 0) {\n          // set the page to the next page\n          const newPage = (sceneQueue.query?.currentPage ?? 0) + 1;\n          loadScene(loadedScenes[0].id, autoPlay, newPage);\n        }\n      }\n    }\n  }\n\n  async function queuePrevious(autoPlay: boolean) {\n    if (currentQueueIndex === -1) return;\n\n    if (currentQueueIndex > 0) {\n      loadScene(queueScenes[currentQueueIndex - 1].id, autoPlay);\n    } else {\n      // if we're at the beginning of the queue, load the previous page\n      if (queueStart > 1) {\n        const loadedScenes = await onQueueLessScenes();\n        if (loadedScenes && loadedScenes.length > 0) {\n          const newPage = (sceneQueue.query?.currentPage ?? 0) - 1;\n          loadScene(\n            loadedScenes[loadedScenes.length - 1].id,\n            autoPlay,\n            newPage\n          );\n        }\n      }\n    }\n  }\n\n  async function queueRandom(autoPlay: boolean) {\n    if (sceneQueue.query) {\n      const { query } = sceneQueue;\n      const pages = Math.ceil(queueTotal / query.itemsPerPage);\n      const page = Math.floor(Math.random() * pages) + 1;\n      const index = Math.floor(\n        Math.random() * Math.min(query.itemsPerPage, queueTotal)\n      );\n      const filterCopy = sceneQueue.query.clone();\n      filterCopy.currentPage = page;\n      const queryResults = await queryFindScenes(filterCopy);\n      if (queryResults.data.findScenes.scenes.length > index) {\n        const { id: sceneID } = queryResults.data.findScenes.scenes[index];\n        // navigate to the image player page\n        loadScene(sceneID, autoPlay, page);\n      }\n    } else if (queueTotal !== 0) {\n      const index = Math.floor(Math.random() * queueTotal);\n      loadScene(queueScenes[index].id, autoPlay);\n    }\n  }\n\n  function onComplete() {\n    // load the next scene if we're continuing\n    if (continuePlaylist) {\n      queueNext(true);\n    }\n  }\n\n  function onDelete() {\n    if (\n      continuePlaylist &&\n      currentQueueIndex >= 0 &&\n      currentQueueIndex < queueScenes.length - 1\n    ) {\n      loadScene(queueScenes[currentQueueIndex + 1].id);\n    } else {\n      goBackOrReplace(history, \"/scenes\");\n    }\n  }\n\n  function getScenePage(sceneID: string) {\n    if (!sceneQueue.query) return;\n\n    // find the page that the scene is on\n    const index = queueScenes.findIndex((s) => s.id === sceneID);\n\n    if (index === -1) return;\n\n    const perPage = sceneQueue.query.itemsPerPage;\n    return Math.floor((index + queueStart - 1) / perPage) + 1;\n  }\n\n  function onQueueSceneClicked(sceneID: string) {\n    loadScene(sceneID, autoPlayOnSelected, getScenePage(sceneID));\n  }\n\n  if (!scene) {\n    if (loading) return <LoadingIndicator />;\n    if (error) return <ErrorMessage error={error.message} />;\n    return <ErrorMessage error={`No scene found with id ${id}.`} />;\n  }\n\n  return (\n    <div className=\"row\">\n      <ScenePage\n        scene={scene}\n        setTimestamp={setTimestamp}\n        queueScenes={queueScenes}\n        queueStart={queueStart}\n        onDelete={onDelete}\n        onQueueNext={() => queueNext(autoPlayOnSelected)}\n        onQueuePrevious={() => queuePrevious(autoPlayOnSelected)}\n        onQueueRandom={() => queueRandom(autoPlayOnSelected)}\n        onQueueSceneClicked={onQueueSceneClicked}\n        continuePlaylist={continuePlaylist}\n        queueHasMoreScenes={queueHasMoreScenes}\n        onQueueLessScenes={onQueueLessScenes}\n        onQueueMoreScenes={onQueueMoreScenes}\n        collapsed={collapsed}\n        setCollapsed={setCollapsed}\n        setContinuePlaylist={setContinuePlaylist}\n      />\n      <div className={`scene-player-container ${collapsed ? \"expanded\" : \"\"}`}>\n        <ScenePlayer\n          key=\"ScenePlayer\"\n          scene={scene}\n          hideScrubberOverride={hideScrubber}\n          autoplay={autoplay}\n          permitLoop={!continuePlaylist}\n          initialTimestamp={initialTimestamp}\n          sendSetTimestamp={getSetTimestamp}\n          onComplete={onComplete}\n          onNext={() => queueNext(true)}\n          onPrevious={() => queuePrevious(true)}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default SceneLoader;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useHistory, useLocation } from \"react-router-dom\";\nimport { SceneEditPanel } from \"./SceneEditPanel\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { mutateCreateScene, useFindScene } from \"src/core/StashService\";\nimport ImageUtils from \"src/utils/image\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\n\nconst SceneCreate: React.FC = () => {\n  const history = useHistory();\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const location = useLocation();\n  const query = useMemo(() => new URLSearchParams(location.search), [location]);\n\n  // create scene from provided scene id if applicable\n  const { data, loading } = useFindScene(query.get(\"from_scene_id\") ?? \"new\");\n  const [loadingCoverImage, setLoadingCoverImage] = useState(false);\n  const [coverImage, setCoverImage] = useState<string>();\n\n  const scene = useMemo(() => {\n    if (data?.findScene) {\n      return {\n        ...data.findScene,\n        paths: undefined,\n        id: undefined,\n      };\n    }\n\n    return {\n      title: query.get(\"q\") ?? undefined,\n    };\n  }, [data?.findScene, query]);\n\n  useEffect(() => {\n    async function fetchCoverImage() {\n      const srcScene = data?.findScene;\n      if (srcScene?.paths.screenshot) {\n        setLoadingCoverImage(true);\n        const imageData = await ImageUtils.imageToDataURL(\n          srcScene.paths.screenshot\n        );\n        setCoverImage(imageData);\n        setLoadingCoverImage(false);\n      } else {\n        setCoverImage(undefined);\n      }\n    }\n\n    fetchCoverImage();\n  }, [data?.findScene]);\n\n  if (loading || loadingCoverImage) {\n    return <LoadingIndicator />;\n  }\n\n  async function onSave(input: GQL.SceneCreateInput, andNew?: boolean) {\n    const fileID = query.get(\"file_id\") ?? undefined;\n    const result = await mutateCreateScene({\n      ...input,\n      file_ids: fileID ? [fileID] : undefined,\n    });\n    if (result.data?.sceneCreate?.id) {\n      if (!andNew) {\n        history.push(`/scenes/${result.data.sceneCreate.id}`);\n      }\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          { entity: intl.formatMessage({ id: \"scene\" }).toLocaleLowerCase() }\n        )\n      );\n    }\n  }\n\n  return (\n    <div className=\"row new-view justify-content-center\" id=\"create-scene-page\">\n      <div className=\"col-md-8\">\n        <h2>\n          <FormattedMessage\n            id=\"actions.create_entity\"\n            values={{ entityType: intl.formatMessage({ id: \"scene\" }) }}\n          />\n        </h2>\n        <SceneEditPanel\n          scene={scene}\n          initialCoverImage={coverImage}\n          isVisible\n          isNew\n          onSubmit={onSave}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default SceneCreate;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx",
    "content": "import React from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport { TagLink } from \"src/components/Shared/TagLink\";\nimport { PerformerCard } from \"src/components/Performers/PerformerCard\";\nimport { sortPerformers } from \"src/core/performers\";\nimport { DirectorLink } from \"src/components/Shared/Link\";\nimport { CustomFields } from \"src/components/Shared/CustomFields\";\n\ninterface ISceneDetailProps {\n  scene: GQL.SceneDataFragment;\n}\n\nexport const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {\n  const intl = useIntl();\n\n  function renderDetails() {\n    if (!props.scene.details || props.scene.details === \"\") return;\n    return (\n      <>\n        <h6>\n          <FormattedMessage id=\"details\" />:{\" \"}\n        </h6>\n        <p className=\"pre\">{props.scene.details}</p>\n      </>\n    );\n  }\n\n  function renderTags() {\n    if (props.scene.tags.length === 0) return;\n    const tags = props.scene.tags.map((tag) => (\n      <TagLink key={tag.id} tag={tag} />\n    ));\n    return (\n      <>\n        <h6>\n          <FormattedMessage\n            id=\"countables.tags\"\n            values={{ count: props.scene.tags.length }}\n          />\n        </h6>\n        {tags}\n      </>\n    );\n  }\n\n  function renderPerformers() {\n    if (props.scene.performers.length === 0) return;\n    const performers = sortPerformers(props.scene.performers);\n    const cards = performers.map((performer) => (\n      <PerformerCard\n        key={performer.id}\n        performer={performer}\n        ageFromDate={props.scene.date ?? undefined}\n      />\n    ));\n\n    return (\n      <>\n        <h6>\n          <FormattedMessage\n            id=\"countables.performers\"\n            values={{ count: props.scene.performers.length }}\n          />\n        </h6>\n        <div className=\"row justify-content-center scene-performers\">\n          {cards}\n        </div>\n      </>\n    );\n  }\n\n  // filename should use entire row if there is no studio\n  const sceneDetailsWidth = props.scene.studio ? \"col-9\" : \"col-12\";\n\n  return (\n    <>\n      <div className=\"row\">\n        <div className={`${sceneDetailsWidth} col-12 scene-details`}>\n          <h6>\n            <FormattedMessage id=\"created_at\" />:{\" \"}\n            {TextUtils.formatDateTime(intl, props.scene.created_at)}{\" \"}\n          </h6>\n          <h6>\n            <FormattedMessage id=\"updated_at\" />:{\" \"}\n            {TextUtils.formatDateTime(intl, props.scene.updated_at)}{\" \"}\n          </h6>\n          {props.scene.code && (\n            <h6>\n              <FormattedMessage id=\"scene_code\" />: {props.scene.code}{\" \"}\n            </h6>\n          )}\n          {props.scene.director && (\n            <h6>\n              <FormattedMessage id=\"director\" />:{\" \"}\n              <DirectorLink director={props.scene.director} linkType=\"scene\" />\n            </h6>\n          )}\n        </div>\n      </div>\n      <div className=\"row\">\n        <div className=\"col-12\">\n          {renderDetails()}\n          {renderTags()}\n          {renderPerformers()}\n          <CustomFields values={props.scene.custom_fields} fullWidth />\n        </div>\n      </div>\n    </>\n  );\n};\n\nexport default SceneDetailPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx",
    "content": "import React, { useEffect, useState, useMemo } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport {\n  Button,\n  Dropdown,\n  Form,\n  Col,\n  Row,\n  ButtonGroup,\n  SplitButton,\n} from \"react-bootstrap\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport * as yup from \"yup\";\nimport {\n  queryScrapeScene,\n  queryScrapeSceneURL,\n  useListSceneScrapers,\n  mutateReloadScrapers,\n  queryScrapeSceneQueryFragment,\n} from \"src/core/StashService\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { ImageInput } from \"src/components/Shared/ImageInput\";\nimport { useToast } from \"src/hooks/Toast\";\nimport ImageUtils from \"src/utils/image\";\nimport { addUpdateStashID, getStashIDs } from \"src/utils/stashIds\";\nimport { useFormik } from \"formik\";\nimport { Prompt } from \"react-router-dom\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { IGroupEntry, SceneGroupTable } from \"./SceneGroupTable\";\nimport { faSearch, faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport { objectTitle } from \"src/core/files\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { lazyComponent } from \"src/utils/lazyComponent\";\nimport isEqual from \"lodash-es/isEqual\";\nimport {\n  yupDateString,\n  yupFormikValidate,\n  yupUniqueStringList,\n} from \"src/utils/yup\";\nimport {\n  Performer,\n  PerformerSelect,\n} from \"src/components/Performers/PerformerSelect\";\nimport { formikUtils } from \"src/utils/form\";\nimport { Studio, StudioSelect } from \"src/components/Studios/StudioSelect\";\nimport { Gallery, GallerySelect } from \"src/components/Galleries/GallerySelect\";\nimport { Group } from \"src/components/Groups/GroupSelect\";\nimport { useTagsEdit } from \"src/hooks/tagsEdit\";\nimport { ScraperMenu } from \"src/components/Shared/ScraperMenu\";\nimport StashBoxIDSearchModal from \"src/components/Shared/StashBoxIDSearchModal\";\nimport {\n  CustomFieldsInput,\n  formatCustomFieldInput,\n} from \"src/components/Shared/CustomFields\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\n\nconst SceneScrapeDialog = lazyComponent(() => import(\"./SceneScrapeDialog\"));\nconst SceneQueryModal = lazyComponent(() => import(\"./SceneQueryModal\"));\n\ninterface IProps {\n  scene: Partial<GQL.SceneDataFragment>;\n  initialCoverImage?: string;\n  isNew?: boolean;\n  isVisible: boolean;\n  onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise<void>;\n  onDelete?: () => void;\n}\n\nexport const SceneEditPanel: React.FC<IProps> = ({\n  scene,\n  initialCoverImage,\n  isNew = false,\n  isVisible,\n  onSubmit,\n  onDelete,\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [galleries, setGalleries] = useState<Gallery[]>([]);\n  const [performers, setPerformers] = useState<Performer[]>([]);\n  const [groups, setGroups] = useState<Group[]>([]);\n  const [studio, setStudio] = useState<Studio | null>(null);\n\n  const Scrapers = useListSceneScrapers();\n  const [fragmentScrapers, setFragmentScrapers] = useState<GQL.Scraper[]>([]);\n  const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);\n\n  const [scraper, setScraper] = useState<GQL.ScraperSourceInput>();\n  const [isScraperQueryModalOpen, setIsScraperQueryModalOpen] =\n    useState<boolean>(false);\n  const [isStashIDSearchOpen, setIsStashIDSearchOpen] =\n    useState<boolean>(false);\n  const [scrapedScene, setScrapedScene] = useState<GQL.ScrapedScene | null>();\n  const [endpoint, setEndpoint] = useState<string>();\n\n  useEffect(() => {\n    setGalleries(\n      scene.galleries?.map((g) => ({\n        id: g.id,\n        title: galleryTitle(g),\n        files: g.files,\n        folder: g.folder,\n      })) ?? []\n    );\n  }, [scene.galleries]);\n\n  useEffect(() => {\n    setPerformers(scene.performers ?? []);\n  }, [scene.performers]);\n\n  useEffect(() => {\n    setGroups(scene.groups?.map((m) => m.group) ?? []);\n  }, [scene.groups]);\n\n  useEffect(() => {\n    setStudio(scene.studio ?? null);\n  }, [scene.studio]);\n\n  const { configuration: stashConfig } = useConfigurationContext();\n\n  // Network state\n  const [isLoading, setIsLoading] = useState(false);\n\n  const schema = yup.object({\n    title: yup.string().ensure(),\n    code: yup.string().ensure(),\n    urls: yupUniqueStringList(intl),\n    date: yupDateString(intl),\n    director: yup.string().ensure(),\n    gallery_ids: yup.array(yup.string().required()).defined(),\n    studio_id: yup.string().required().nullable(),\n    performer_ids: yup.array(yup.string().required()).defined(),\n    groups: yup\n      .array(\n        yup.object({\n          group_id: yup.string().required(),\n          scene_index: yup.number().integer().nullable().defined(),\n        })\n      )\n      .defined(),\n    tag_ids: yup.array(yup.string().required()).defined(),\n    stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),\n    details: yup.string().ensure(),\n    cover_image: yup.string().nullable().optional(),\n    custom_fields: yup.object().required().defined(),\n  });\n\n  const initialValues = useMemo(\n    () => ({\n      title: scene.title ?? \"\",\n      code: scene.code ?? \"\",\n      urls: scene.urls ?? [],\n      date: scene.date ?? \"\",\n      director: scene.director ?? \"\",\n      gallery_ids: (scene.galleries ?? []).map((g) => g.id),\n      studio_id: scene.studio?.id ?? null,\n      performer_ids: (scene.performers ?? []).map((p) => p.id),\n      groups: (scene.groups ?? []).map((m) => {\n        return { group_id: m.group.id, scene_index: m.scene_index ?? null };\n      }),\n      tag_ids: (scene.tags ?? []).map((t) => t.id),\n      stash_ids: getStashIDs(scene.stash_ids),\n      details: scene.details ?? \"\",\n      cover_image: initialCoverImage,\n      custom_fields: cloneDeep(scene.custom_fields ?? {}),\n    }),\n    [scene, initialCoverImage]\n  );\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const [customFieldsError, setCustomFieldsError] = useState<string>();\n\n  function submit(values: InputValues) {\n    const input = {\n      ...schema.cast(values),\n      custom_fields: formatCustomFieldInput(isNew, values.custom_fields),\n    };\n    onSave(input);\n  }\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: submit,\n  });\n\n  const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(\n    scene.tags,\n    (ids) => formik.setFieldValue(\"tag_ids\", ids)\n  );\n\n  const coverImagePreview = useMemo(() => {\n    const sceneImage = scene.paths?.screenshot;\n    const formImage = formik.values.cover_image;\n    if (formImage === null && sceneImage) {\n      const sceneImageURL = new URL(sceneImage);\n      sceneImageURL.searchParams.set(\"default\", \"true\");\n      return sceneImageURL.toString();\n    } else if (formImage) {\n      return formImage;\n    }\n    return sceneImage;\n  }, [formik.values.cover_image, scene.paths?.screenshot]);\n\n  const groupEntries = useMemo(() => {\n    return formik.values.groups\n      .map((m) => {\n        return {\n          group: groups.find((mm) => mm.id === m.group_id),\n          scene_index: m.scene_index,\n        };\n      })\n      .filter((m) => m.group !== undefined) as IGroupEntry[];\n  }, [formik.values.groups, groups]);\n\n  function onSetGalleries(items: Gallery[]) {\n    setGalleries(items);\n    formik.setFieldValue(\n      \"gallery_ids\",\n      items.map((i) => i.id)\n    );\n  }\n\n  function onSetPerformers(items: Performer[]) {\n    setPerformers(items);\n    formik.setFieldValue(\n      \"performer_ids\",\n      items.map((item) => item.id)\n    );\n  }\n\n  function onSetStudio(item: Studio | null) {\n    setStudio(item);\n    formik.setFieldValue(\"studio_id\", item ? item.id : null);\n  }\n\n  useEffect(() => {\n    if (isVisible) {\n      Mousetrap.bind(\"s s\", () => {\n        if (formik.dirty) {\n          formik.submitForm();\n        }\n      });\n      Mousetrap.bind(\"d d\", () => {\n        if (onDelete) {\n          onDelete();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"s s\");\n        Mousetrap.unbind(\"d d\");\n      };\n    }\n  });\n\n  useEffect(() => {\n    const toFilter = Scrapers?.data?.listScrapers ?? [];\n\n    const newFragmentScrapers = toFilter.filter((s) =>\n      s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment)\n    );\n    const newQueryableScrapers = toFilter.filter((s) =>\n      s.scene?.supported_scrapes.includes(GQL.ScrapeType.Name)\n    );\n\n    setFragmentScrapers(newFragmentScrapers);\n    setQueryableScrapers(newQueryableScrapers);\n  }, [Scrapers, stashConfig]);\n\n  function onSetGroups(items: Group[]) {\n    setGroups(items);\n\n    const existingGroups = formik.values.groups;\n\n    const newGroups = items.map((m) => {\n      const existing = existingGroups.find((mm) => mm.group_id === m.id);\n      if (existing) {\n        return existing;\n      }\n\n      return {\n        group_id: m.id,\n        scene_index: null,\n      };\n    });\n\n    formik.setFieldValue(\"groups\", newGroups);\n  }\n\n  async function onSave(input: InputValues, andNew?: boolean) {\n    setIsLoading(true);\n    try {\n      await onSubmit(input, andNew);\n      formik.resetForm();\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsLoading(false);\n  }\n\n  async function onSaveAndNewClick() {\n    const input = {\n      ...schema.cast(formik.values),\n      custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),\n    };\n    onSave(input, true);\n  }\n\n  const encodingImage = ImageUtils.usePasteImage(onImageLoad);\n\n  function onImageLoad(imageData: string) {\n    formik.setFieldValue(\"cover_image\", imageData);\n  }\n\n  function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {\n    ImageUtils.onImageChange(event, onImageLoad);\n  }\n\n  function onResetCover() {\n    formik.setFieldValue(\"cover_image\", null);\n  }\n\n  async function onScrapeClicked(s: GQL.ScraperSourceInput) {\n    setIsLoading(true);\n    try {\n      const result = await queryScrapeScene(s, scene.id!);\n      if (!result.data || !result.data.scrapeSingleScene?.length) {\n        Toast.success(\"No scenes found\");\n        return;\n      }\n      // assume one returned scene\n      setScrapedScene(result.data.scrapeSingleScene[0]);\n      setEndpoint(s.stash_box_endpoint ?? undefined);\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  async function scrapeFromQuery(\n    s: GQL.ScraperSourceInput,\n    fragment: GQL.ScrapedSceneDataFragment\n  ) {\n    setIsLoading(true);\n    try {\n      const input: GQL.ScrapedSceneInput = {\n        date: fragment.date,\n        code: fragment.code,\n        details: fragment.details,\n        director: fragment.director,\n        remote_site_id: fragment.remote_site_id,\n        title: fragment.title,\n        urls: fragment.urls,\n      };\n\n      const result = await queryScrapeSceneQueryFragment(s, input);\n      if (!result.data || !result.data.scrapeSingleScene?.length) {\n        Toast.success(\"No scenes found\");\n        return;\n      }\n      // assume one returned scene\n      setScrapedScene(result.data.scrapeSingleScene[0]);\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  function onScrapeQueryClicked(s: GQL.ScraperSourceInput) {\n    setScraper(s);\n    setEndpoint(s.stash_box_endpoint ?? undefined);\n    setIsScraperQueryModalOpen(true);\n  }\n\n  async function onReloadScrapers() {\n    setIsLoading(true);\n    try {\n      await mutateReloadScrapers();\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  function onScrapeDialogClosed(sceneData?: GQL.ScrapedSceneDataFragment) {\n    if (sceneData) {\n      updateSceneFromScrapedScene(sceneData);\n    }\n    setScrapedScene(undefined);\n  }\n\n  function maybeRenderScrapeDialog() {\n    if (!scrapedScene) {\n      return;\n    }\n\n    const currentScene = {\n      id: scene.id!,\n      ...formik.values,\n    };\n\n    if (!currentScene.cover_image) {\n      currentScene.cover_image = scene.paths?.screenshot;\n    }\n\n    return (\n      <SceneScrapeDialog\n        scene={currentScene}\n        sceneStudio={studio}\n        sceneTags={tags}\n        scenePerformers={performers}\n        sceneGroups={groups}\n        scraped={scrapedScene}\n        endpoint={endpoint}\n        onClose={(s) => onScrapeDialogClosed(s)}\n      />\n    );\n  }\n\n  function onSceneSelected(s: GQL.ScrapedSceneDataFragment) {\n    if (!scraper) return;\n\n    if (scraper?.stash_box_endpoint !== undefined) {\n      // must be stash-box - assume full scene\n      setScrapedScene(s);\n    } else {\n      // must be scraper\n      scrapeFromQuery(scraper, s);\n    }\n  }\n\n  const renderScrapeQueryModal = () => {\n    if (!isScraperQueryModalOpen || !scraper) return;\n\n    return (\n      <SceneQueryModal\n        scraper={scraper}\n        onHide={() => setScraper(undefined)}\n        onSelectScene={(s) => {\n          setIsScraperQueryModalOpen(false);\n          setScraper(undefined);\n          onSceneSelected(s);\n        }}\n        name={formik.values.title || objectTitle(scene) || \"\"}\n      />\n    );\n  };\n\n  function urlScrapable(scrapedUrl: string): boolean {\n    return (Scrapers?.data?.listScrapers ?? []).some((s) =>\n      (s?.scene?.urls ?? []).some((u) => scrapedUrl.includes(u))\n    );\n  }\n\n  function updateSceneFromScrapedScene(\n    updatedScene: GQL.ScrapedSceneDataFragment\n  ) {\n    if (updatedScene.title) {\n      formik.setFieldValue(\"title\", updatedScene.title);\n    }\n\n    if (updatedScene.code) {\n      formik.setFieldValue(\"code\", updatedScene.code);\n    }\n\n    if (updatedScene.details) {\n      formik.setFieldValue(\"details\", updatedScene.details);\n    }\n\n    if (updatedScene.director) {\n      formik.setFieldValue(\"director\", updatedScene.director);\n    }\n\n    if (updatedScene.date) {\n      formik.setFieldValue(\"date\", updatedScene.date);\n    }\n\n    if (updatedScene.urls) {\n      formik.setFieldValue(\"urls\", updatedScene.urls);\n    }\n\n    if (updatedScene.studio && updatedScene.studio.stored_id) {\n      onSetStudio({\n        id: updatedScene.studio.stored_id,\n        name: updatedScene.studio.name ?? \"\",\n        aliases: [],\n      });\n    }\n\n    if (updatedScene.performers && updatedScene.performers.length > 0) {\n      const idPerfs = updatedScene.performers.filter((p) => {\n        return p.stored_id !== undefined && p.stored_id !== null;\n      });\n\n      if (idPerfs.length > 0) {\n        onSetPerformers(\n          idPerfs.map((p) => {\n            return {\n              id: p.stored_id!,\n              name: p.name ?? \"\",\n              alias_list: [],\n            };\n          })\n        );\n      }\n    }\n\n    if (updatedScene.groups && updatedScene.groups.length > 0) {\n      const idMovis = updatedScene.groups.filter((p) => {\n        return p.stored_id !== undefined && p.stored_id !== null;\n      });\n\n      if (idMovis.length > 0) {\n        onSetGroups(\n          idMovis.map((p) => {\n            return {\n              id: p.stored_id!,\n              name: p.name ?? \"\",\n            };\n          })\n        );\n      }\n    }\n\n    updateTagsStateFromScraper(updatedScene.tags ?? undefined);\n\n    if (updatedScene.image) {\n      // image is a base64 string\n      formik.setFieldValue(\"cover_image\", updatedScene.image);\n    }\n\n    if (updatedScene.remote_site_id && endpoint) {\n      let found = false;\n      formik.setFieldValue(\n        \"stash_ids\",\n        formik.values.stash_ids.map((s) => {\n          if (s.endpoint === endpoint) {\n            found = true;\n            return {\n              endpoint,\n              stash_id: updatedScene.remote_site_id,\n              updated_at: new Date().toISOString(),\n            };\n          }\n\n          return s;\n        })\n      );\n\n      if (!found) {\n        formik.setFieldValue(\n          \"stash_ids\",\n          formik.values.stash_ids.concat({\n            endpoint,\n            stash_id: updatedScene.remote_site_id,\n            updated_at: new Date().toISOString(),\n          })\n        );\n      }\n    }\n  }\n\n  async function onScrapeSceneURL(url: string) {\n    if (!url) {\n      return;\n    }\n    setIsLoading(true);\n    try {\n      const result = await queryScrapeSceneURL(url);\n      if (!result.data || !result.data.scrapeSceneURL) {\n        return;\n      }\n      setScrapedScene(result.data.scrapeSceneURL);\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsLoading(false);\n    }\n  }\n\n  function onStashIDSelected(item?: GQL.StashIdInput) {\n    if (!item) return;\n    formik.setFieldValue(\n      \"stash_ids\",\n      addUpdateStashID(formik.values.stash_ids, item)\n    );\n  }\n\n  const image = useMemo(() => {\n    if (encodingImage) {\n      return (\n        <LoadingIndicator\n          message={intl.formatMessage({ id: \"actions.encoding_image\" })}\n        />\n      );\n    }\n\n    if (coverImagePreview) {\n      return (\n        <img\n          className=\"scene-cover\"\n          src={coverImagePreview}\n          alt={intl.formatMessage({ id: \"cover_image\" })}\n        />\n      );\n    }\n\n    return <div></div>;\n  }, [encodingImage, coverImagePreview, intl]);\n\n  if (isLoading) return <LoadingIndicator />;\n\n  const splitProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n    },\n    fieldProps: {\n      sm: 9,\n    },\n  };\n  const fullWidthProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n      xl: 12,\n    },\n    fieldProps: {\n      sm: 9,\n      xl: 12,\n    },\n  };\n  const urlProps = isNew\n    ? splitProps\n    : {\n        labelProps: {\n          column: true,\n          md: 3,\n          lg: 12,\n        },\n        fieldProps: {\n          md: 9,\n          lg: 12,\n        },\n      };\n  const {\n    renderField,\n    renderInputField,\n    renderDateField,\n    renderURLListField,\n    renderStashIDsField,\n  } = formikUtils(intl, formik, splitProps);\n\n  function renderGalleriesField() {\n    const title = intl.formatMessage({ id: \"galleries\" });\n    const control = (\n      <GallerySelect\n        values={galleries}\n        onSelect={(items) => onSetGalleries(items)}\n        isMulti\n      />\n    );\n\n    return renderField(\"gallery_ids\", title, control);\n  }\n\n  function renderStudioField() {\n    const title = intl.formatMessage({ id: \"studio\" });\n    const control = (\n      <StudioSelect\n        onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)}\n        values={studio ? [studio] : []}\n      />\n    );\n\n    return renderField(\"studio_id\", title, control);\n  }\n\n  function renderPerformersField() {\n    const date = (() => {\n      try {\n        return schema.validateSyncAt(\"date\", formik.values);\n      } catch (e) {\n        return undefined;\n      }\n    })();\n\n    const title = intl.formatMessage({ id: \"performers\" });\n    const control = (\n      <PerformerSelect\n        isMulti\n        onSelect={onSetPerformers}\n        values={performers}\n        ageFromDate={date}\n      />\n    );\n\n    return renderField(\"performer_ids\", title, control, fullWidthProps);\n  }\n\n  function onSetGroupEntries(input: IGroupEntry[]) {\n    setGroups(input.map((m) => m.group));\n\n    const newGroups = input.map((m) => ({\n      group_id: m.group.id,\n      scene_index: m.scene_index,\n    }));\n\n    formik.setFieldValue(\"groups\", newGroups);\n  }\n\n  function renderGroupsField() {\n    const title = intl.formatMessage({ id: \"groups\" });\n    const control = (\n      <SceneGroupTable value={groupEntries} onUpdate={onSetGroupEntries} />\n    );\n\n    return renderField(\"groups\", title, control, fullWidthProps);\n  }\n\n  function renderTagsField() {\n    const title = intl.formatMessage({ id: \"tags\" });\n    return renderField(\"tag_ids\", title, tagsControl(), fullWidthProps);\n  }\n\n  function renderDetailsField() {\n    const props = {\n      labelProps: {\n        column: true,\n        sm: 3,\n        lg: 12,\n      },\n      fieldProps: {\n        sm: 9,\n        lg: 12,\n      },\n    };\n\n    return renderInputField(\"details\", \"textarea\", \"details\", props);\n  }\n\n  return (\n    <div id=\"scene-edit-details\">\n      <Prompt\n        when={formik.dirty}\n        message={intl.formatMessage({ id: \"dialogs.unsaved_changes\" })}\n      />\n\n      {renderScrapeQueryModal()}\n      {maybeRenderScrapeDialog()}\n      {isStashIDSearchOpen && (\n        <StashBoxIDSearchModal\n          entityType=\"scene\"\n          stashBoxes={stashConfig?.general.stashBoxes ?? []}\n          excludedStashBoxEndpoints={formik.values.stash_ids.map(\n            (s) => s.endpoint\n          )}\n          onSelectItem={(item) => {\n            onStashIDSelected(item);\n            setIsStashIDSearchOpen(false);\n          }}\n          initialQuery={scene.title ?? \"\"}\n        />\n      )}\n      <Form noValidate onSubmit={formik.handleSubmit}>\n        <Row className=\"form-container edit-buttons-container px-3 pt-3\">\n          <div className=\"edit-buttons mb-3 pl-0\">\n            {isNew ? (\n              <SplitButton\n                id=\"scene-save-split-button\"\n                className=\"edit-button\"\n                variant=\"primary\"\n                disabled={\n                  !isEqual(formik.errors, {}) || customFieldsError !== undefined\n                }\n                title={intl.formatMessage({ id: \"actions.save\" })}\n                onClick={() => formik.submitForm()}\n              >\n                <Dropdown.Item onClick={() => onSaveAndNewClick()}>\n                  <FormattedMessage id=\"actions.save_and_new\" />\n                </Dropdown.Item>\n              </SplitButton>\n            ) : (\n              <Button\n                className=\"edit-button\"\n                variant=\"primary\"\n                disabled={\n                  (!isNew && !formik.dirty) ||\n                  !isEqual(formik.errors, {}) ||\n                  customFieldsError !== undefined\n                }\n                onClick={() => formik.submitForm()}\n              >\n                <FormattedMessage id=\"actions.save\" />\n              </Button>\n            )}\n            {onDelete && (\n              <Button\n                className=\"edit-button\"\n                variant=\"danger\"\n                onClick={() => onDelete()}\n              >\n                <FormattedMessage id=\"actions.delete\" />\n              </Button>\n            )}\n          </div>\n          {!isNew && (\n            <div className=\"ml-auto text-right d-flex\">\n              <ButtonGroup className=\"scraper-group\">\n                <ScraperMenu\n                  toggle={intl.formatMessage({ id: \"actions.scrape_with\" })}\n                  stashBoxes={stashConfig?.general.stashBoxes ?? []}\n                  scrapers={fragmentScrapers}\n                  onScraperClicked={onScrapeClicked}\n                  onReloadScrapers={onReloadScrapers}\n                />\n                <ScraperMenu\n                  variant=\"secondary\"\n                  toggle={<Icon icon={faSearch} />}\n                  stashBoxes={stashConfig?.general.stashBoxes ?? []}\n                  scrapers={queryableScrapers}\n                  onScraperClicked={onScrapeQueryClicked}\n                  onReloadScrapers={onReloadScrapers}\n                />\n              </ButtonGroup>\n            </div>\n          )}\n        </Row>\n        <Row className=\"form-container px-3\">\n          <Col lg={7} xl={12}>\n            {renderInputField(\"title\")}\n            {renderInputField(\"code\", \"text\", \"scene_code\")}\n\n            {renderURLListField(\n              \"urls\",\n              onScrapeSceneURL,\n              urlScrapable,\n              \"urls\",\n              urlProps\n            )}\n\n            {renderDateField(\"date\")}\n            {renderInputField(\"director\")}\n\n            {renderGalleriesField()}\n            {renderStudioField()}\n            {renderPerformersField()}\n            {renderGroupsField()}\n            {renderTagsField()}\n\n            {renderStashIDsField(\n              \"stash_ids\",\n              \"scenes\",\n              \"stash_ids\",\n              fullWidthProps,\n              <Button\n                variant=\"success\"\n                className=\"mr-2 py-0\"\n                onClick={() => setIsStashIDSearchOpen(true)}\n                disabled={!stashConfig?.general.stashBoxes?.length}\n                title={intl.formatMessage({ id: \"actions.add_stash_id\" })}\n              >\n                <Icon icon={faPlus} />\n              </Button>\n            )}\n          </Col>\n          <Col lg={5} xl={12}>\n            {renderDetailsField()}\n            <Form.Group controlId=\"cover_image\">\n              <Form.Label>\n                <FormattedMessage id=\"cover_image\" />\n              </Form.Label>\n              {image}\n              <ImageInput\n                isEditing\n                onImageChange={onCoverImageChange}\n                onImageURL={onImageLoad}\n                onReset={scene.id ? onResetCover : undefined}\n              />\n            </Form.Group>\n\n            <CustomFieldsInput\n              values={formik.values.custom_fields}\n              onChange={(v) => formik.setFieldValue(\"custom_fields\", v)}\n              error={customFieldsError}\n              setError={(e) => setCustomFieldsError(e)}\n            />\n          </Col>\n        </Row>\n      </Form>\n    </div>\n  );\n};\n\nexport default SceneEditPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport { Accordion, Button, Card } from \"react-bootstrap\";\nimport {\n  FormattedMessage,\n  FormattedNumber,\n  FormattedTime,\n  useIntl,\n} from \"react-intl\";\nimport { useHistory } from \"react-router-dom\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { DeleteFilesDialog } from \"src/components/Shared/DeleteFilesDialog\";\nimport { RevealInFilesystemButton } from \"src/components/Shared/RevealInFilesystemButton\";\nimport { ReassignFilesDialog } from \"src/components/Shared/ReassignFilesDialog\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { mutateSceneSetPrimaryFile } from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport NavUtils from \"src/utils/navigation\";\nimport TextUtils from \"src/utils/text\";\nimport { TextField, URLField, URLsField } from \"src/utils/field\";\nimport { StashIDPill } from \"src/components/Shared/StashID\";\nimport { PatchComponent } from \"../../../patch\";\nimport { FileSize } from \"src/components/Shared/FileSize\";\n\ninterface IFileInfoPanelProps {\n  sceneID: string;\n  file: GQL.VideoFileDataFragment;\n  primary?: boolean;\n  ofMany?: boolean;\n  onSetPrimaryFile?: () => void;\n  onDeleteFile?: () => void;\n  onReassign?: () => void;\n  loading?: boolean;\n}\n\nconst FileInfoPanel: React.FC<IFileInfoPanelProps> = (\n  props: IFileInfoPanelProps\n) => {\n  const intl = useIntl();\n  const history = useHistory();\n\n  // TODO - generalise fingerprints\n  const oshash = props.file.fingerprints.find((f) => f.type === \"oshash\");\n  const phash = props.file.fingerprints.find((f) => f.type === \"phash\");\n  const checksum = props.file.fingerprints.find((f) => f.type === \"md5\");\n\n  function onSplit() {\n    history.push(\n      `/scenes/new?from_scene_id=${props.sceneID}&file_id=${props.file.id}`\n    );\n  }\n\n  return (\n    <div>\n      <dl className=\"container scene-file-info details-list\">\n        {props.primary && (\n          <>\n            <dt></dt>\n            <dd className=\"primary-file\">\n              <FormattedMessage id=\"primary_file\" />\n            </dd>\n          </>\n        )}\n        <TextField\n          id=\"media_info.oshash\"\n          abbr={intl.formatMessage({ id: \"media_info.oshash_meaning\" })}\n          value={oshash?.value}\n          truncate\n        />\n        <TextField id=\"media_info.md5\" value={checksum?.value} truncate />\n        <URLField\n          id=\"media_info.phash\"\n          abbr={intl.formatMessage({ id: \"media_info.phash_meaning\" })}\n          value={phash?.value}\n          url={NavUtils.makeScenesPHashMatchUrl(phash?.value)}\n          target=\"_self\"\n          truncate\n          internal\n        />\n        <TextField id=\"path\">\n          <span className=\"d-flex align-items-center\">\n            <TruncatedText text={props.file.path} />\n            <RevealInFilesystemButton fileId={props.file.id} />\n          </span>\n        </TextField>\n        <TextField id=\"filesize\">\n          <span className=\"text-truncate\">\n            <FileSize size={props.file.size} />\n          </span>\n        </TextField>\n        <TextField id=\"file_mod_time\">\n          <FormattedTime\n            dateStyle=\"medium\"\n            timeStyle=\"medium\"\n            value={props.file.mod_time ?? 0}\n          />\n        </TextField>\n        <TextField\n          id=\"duration\"\n          value={TextUtils.secondsToTimestamp(props.file.duration ?? 0)}\n          truncate\n        />\n        <TextField\n          id=\"dimensions\"\n          value={`${props.file.width} x ${props.file.height}`}\n          truncate\n        />\n        <TextField id=\"framerate\">\n          <FormattedMessage\n            id=\"frames_per_second\"\n            values={{ value: intl.formatNumber(props.file.frame_rate ?? 0) }}\n          />\n        </TextField>\n        <TextField id=\"bitrate\">\n          <FormattedMessage\n            id=\"megabits_per_second\"\n            values={{\n              value: intl.formatNumber((props.file.bit_rate ?? 0) / 1000000, {\n                maximumFractionDigits: 2,\n              }),\n            }}\n          />\n        </TextField>\n        <TextField\n          id=\"media_info.video_codec\"\n          value={props.file.video_codec ?? \"\"}\n          truncate\n        />\n        <TextField\n          id=\"media_info.audio_codec\"\n          value={props.file.audio_codec ?? \"\"}\n          truncate\n        />\n      </dl>\n      {props.ofMany && props.onSetPrimaryFile && !props.primary && (\n        <div>\n          <Button\n            className=\"edit-button\"\n            disabled={props.loading}\n            onClick={props.onSetPrimaryFile}\n          >\n            <FormattedMessage id=\"actions.make_primary\" />\n          </Button>\n          <Button\n            className=\"edit-button\"\n            disabled={props.loading}\n            onClick={props.onReassign}\n          >\n            <FormattedMessage id=\"actions.reassign\" />\n          </Button>\n          <Button className=\"edit-button\" onClick={onSplit}>\n            <FormattedMessage id=\"actions.split\" />\n          </Button>\n          <Button\n            variant=\"danger\"\n            disabled={props.loading}\n            onClick={props.onDeleteFile}\n          >\n            <FormattedMessage id=\"actions.delete_file\" />\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n};\n\ninterface ISceneFileInfoPanelProps {\n  scene: GQL.SceneDataFragment;\n}\n\nconst _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (\n  props: ISceneFileInfoPanelProps\n) => {\n  const Toast = useToast();\n\n  const [loading, setLoading] = useState(false);\n  const [deletingFile, setDeletingFile] = useState<GQL.VideoFileDataFragment>();\n  const [reassigningFile, setReassigningFile] =\n    useState<GQL.VideoFileDataFragment>();\n\n  function renderStashIDs() {\n    if (!props.scene.stash_ids.length) {\n      return;\n    }\n\n    return (\n      <>\n        <dt>\n          <FormattedMessage id=\"stash_ids\" />\n        </dt>\n        <dd>\n          <dl>\n            {props.scene.stash_ids.map((stashID) => {\n              return (\n                <dd key={stashID.stash_id} className=\"row no-gutters\">\n                  <StashIDPill stashID={stashID} linkType=\"scenes\" />\n                </dd>\n              );\n            })}\n          </dl>\n        </dd>\n      </>\n    );\n  }\n\n  function renderFunscript() {\n    if (props.scene.interactive) {\n      return (\n        <URLField\n          name=\"Funscript\"\n          url={props.scene.paths.funscript}\n          value={props.scene.paths.funscript}\n          truncate\n        />\n      );\n    }\n  }\n\n  function renderInteractiveSpeed() {\n    if (props.scene.interactive_speed) {\n      return (\n        <TextField id=\"media_info.interactive_speed\">\n          <FormattedNumber value={props.scene.interactive_speed} />\n        </TextField>\n      );\n    }\n  }\n\n  const filesPanel = useMemo(() => {\n    if (props.scene.files.length === 0) {\n      return;\n    }\n\n    if (props.scene.files.length === 1) {\n      return (\n        <FileInfoPanel sceneID={props.scene.id} file={props.scene.files[0]} />\n      );\n    }\n\n    async function onSetPrimaryFile(fileID: string) {\n      try {\n        setLoading(true);\n        await mutateSceneSetPrimaryFile(props.scene.id, fileID);\n      } catch (e) {\n        Toast.error(e);\n      } finally {\n        setLoading(false);\n      }\n    }\n\n    return (\n      <Accordion defaultActiveKey={props.scene.files[0].id}>\n        {deletingFile && (\n          <DeleteFilesDialog\n            onClose={() => setDeletingFile(undefined)}\n            selected={[deletingFile]}\n          />\n        )}\n        {reassigningFile && (\n          <ReassignFilesDialog\n            onClose={() => setReassigningFile(undefined)}\n            selected={reassigningFile}\n          />\n        )}\n        {props.scene.files.map((file, index) => (\n          <Card key={file.id} className=\"scene-file-card\">\n            <Accordion.Toggle as={Card.Header} eventKey={file.id}>\n              <TruncatedText text={TextUtils.fileNameFromPath(file.path)} />\n            </Accordion.Toggle>\n            <Accordion.Collapse eventKey={file.id}>\n              <Card.Body>\n                <FileInfoPanel\n                  sceneID={props.scene.id}\n                  file={file}\n                  primary={index === 0}\n                  ofMany\n                  onSetPrimaryFile={() => onSetPrimaryFile(file.id)}\n                  onDeleteFile={() => setDeletingFile(file)}\n                  onReassign={() => setReassigningFile(file)}\n                  loading={loading}\n                />\n              </Card.Body>\n            </Accordion.Collapse>\n          </Card>\n        ))}\n      </Accordion>\n    );\n  }, [props.scene, loading, Toast, deletingFile, reassigningFile]);\n\n  return (\n    <>\n      <dl className=\"container scene-file-info details-list\">\n        {props.scene.files.length > 0 && (\n          <URLField\n            id=\"media_info.stream\"\n            url={props.scene.paths.stream}\n            value={props.scene.paths.stream}\n            truncate\n          />\n        )}\n        {renderFunscript()}\n        {renderInteractiveSpeed()}\n        <URLsField id=\"urls\" urls={props.scene.urls} truncate />\n        {renderStashIDs()}\n      </dl>\n\n      {filesPanel}\n    </>\n  );\n};\n\nexport const SceneFileInfoPanel = PatchComponent(\n  \"SceneFileInfoPanel\",\n  _SceneFileInfoPanel\n);\nexport default SceneFileInfoPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneGalleriesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { GalleryCard } from \"src/components/Galleries/GalleryCard\";\n\ninterface ISceneGalleriesPanelProps {\n  galleries: GQL.SlimGalleryDataFragment[];\n}\n\nexport const SceneGalleriesPanel: React.FC<ISceneGalleriesPanelProps> = ({\n  galleries,\n}) => {\n  const cards = galleries.map((gallery) => (\n    <GalleryCard\n      key={gallery.id}\n      gallery={gallery}\n      selecting={false}\n      zoomIndex={2}\n    />\n  ));\n\n  return <div className=\"container scene-galleries\">{cards}</div>;\n};\n\nexport default SceneGalleriesPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { GroupCard } from \"src/components/Groups/GroupCard\";\n\ninterface ISceneGroupPanelProps {\n  scene: GQL.SceneDataFragment;\n}\n\nexport const SceneGroupPanel: React.FC<ISceneGroupPanelProps> = (\n  props: ISceneGroupPanelProps\n) => {\n  const cards = props.scene.groups.map((sceneGroup) => (\n    <GroupCard\n      key={sceneGroup.group.id}\n      group={sceneGroup.group}\n      sceneNumber={sceneGroup.scene_index ?? undefined}\n    />\n  ));\n\n  return (\n    <>\n      <div className=\"row justify-content-center\">{cards}</div>\n    </>\n  );\n};\n\nexport default SceneGroupPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Form, Row, Col } from \"react-bootstrap\";\nimport { Group, GroupSelect } from \"src/components/Groups/GroupSelect\";\nimport cx from \"classnames\";\nimport { NumberField } from \"src/utils/form\";\n\nexport type GroupSceneIndexMap = Map<string, number | undefined>;\n\nexport interface IGroupEntry {\n  group: Group;\n  scene_index?: GQL.InputMaybe<number> | undefined;\n}\n\nexport interface IProps {\n  value: IGroupEntry[];\n  onUpdate: (input: IGroupEntry[]) => void;\n}\n\nexport const SceneGroupTable: React.FC<IProps> = (props) => {\n  const { value, onUpdate } = props;\n\n  const intl = useIntl();\n\n  const groupIDs = useMemo(() => value.map((m) => m.group.id), [value]);\n\n  const updateFieldChanged = (index: number, sceneIndex: number | null) => {\n    const newValues = value.map((existing, i) => {\n      if (i === index) {\n        return {\n          ...existing,\n          scene_index: sceneIndex,\n        };\n      }\n      return existing;\n    });\n\n    onUpdate(newValues);\n  };\n\n  function onGroupSet(index: number, groups: Group[]) {\n    if (!groups.length) {\n      // remove this entry\n      const newValues = value.filter((_, i) => i !== index);\n      onUpdate(newValues);\n      return;\n    }\n\n    const group = groups[0];\n\n    const newValues = value.map((existing, i) => {\n      if (i === index) {\n        return {\n          ...existing,\n          group: group,\n        };\n      }\n      return existing;\n    });\n\n    onUpdate(newValues);\n  }\n\n  function onNewGroupSet(groups: Group[]) {\n    if (!groups.length) {\n      return;\n    }\n\n    const group = groups[0];\n\n    const newValues = [\n      ...value,\n      {\n        group: group,\n        scene_index: null,\n      },\n    ];\n\n    onUpdate(newValues);\n  }\n\n  function renderTableData() {\n    return (\n      <>\n        {value.map((m, i) => (\n          <Row key={m.group.id} className=\"group-row\">\n            <Col xs={9}>\n              <GroupSelect\n                onSelect={(items) => onGroupSet(i, items)}\n                values={[m.group!]}\n                excludeIds={groupIDs}\n              />\n            </Col>\n            <Col xs={3}>\n              <NumberField\n                className=\"text-input\"\n                value={m.scene_index ?? \"\"}\n                onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n                  updateFieldChanged(\n                    i,\n                    e.currentTarget.value === \"\"\n                      ? null\n                      : Number.parseInt(e.currentTarget.value, 10)\n                  );\n                }}\n              />\n            </Col>\n          </Row>\n        ))}\n        <Row className=\"group-row\">\n          <Col xs={12}>\n            <GroupSelect\n              onSelect={(items) => onNewGroupSet(items)}\n              values={[]}\n              excludeIds={groupIDs}\n            />\n          </Col>\n        </Row>\n      </>\n    );\n  }\n\n  return (\n    <div className={cx(\"group-table\", { \"no-groups\": !value.length })}>\n      <Row className=\"group-table-header\">\n        <Col xs={9}></Col>\n        <Form.Label column xs={3} className=\"group-scene-number-header\">\n          {intl.formatMessage({ id: \"group_scene_number\" })}\n        </Form.Label>\n      </Row>\n      {renderTableData()}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx",
    "content": "import {\n  faEllipsisV,\n  faPlus,\n  faTrash,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React from \"react\";\nimport { Button, Dropdown } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { AlertModal } from \"src/components/Shared/Alert\";\nimport { Counter } from \"src/components/Shared/Counter\";\nimport { DateInput } from \"src/components/Shared/DateInput\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport {\n  useSceneDecrementO,\n  useSceneDecrementPlayCount,\n  useSceneIncrementO,\n  useSceneIncrementPlayCount,\n  useSceneResetO,\n  useSceneResetPlayCount,\n  useSceneResetActivity,\n} from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { TextField } from \"src/utils/field\";\nimport TextUtils from \"src/utils/text\";\n\nconst History: React.FC<{\n  className?: string;\n  history: string[];\n  unknownDate?: string;\n  onRemove: (date: string) => void;\n  noneID: string;\n}> = ({ className, history, unknownDate, noneID, onRemove }) => {\n  const intl = useIntl();\n\n  if (history.length === 0) {\n    return (\n      <div>\n        <FormattedMessage id={noneID} />\n      </div>\n    );\n  }\n\n  function renderDate(date: string) {\n    if (date === unknownDate) {\n      return intl.formatMessage({ id: \"unknown_date\" });\n    }\n\n    return TextUtils.formatDateTime(intl, date);\n  }\n\n  return (\n    <div className=\"scene-history\">\n      <ul className={className}>\n        {history.map((playdate, index) => (\n          <li key={index}>\n            <span>{renderDate(playdate)}</span>\n            <Button\n              className=\"remove-date-button\"\n              size=\"sm\"\n              variant=\"minimal\"\n              onClick={() => onRemove(playdate)}\n              title={intl.formatMessage({ id: \"actions.remove_date\" })}\n            >\n              <Icon icon={faTrash} />\n            </Button>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n};\n\nconst HistoryMenu: React.FC<{\n  hasHistory: boolean;\n  showResetResumeDuration: boolean;\n  onAddDate: () => void;\n  onClearDates: () => void;\n  resetResume: () => void;\n  resetDuration: () => void;\n}> = ({\n  hasHistory,\n  showResetResumeDuration,\n  onAddDate,\n  onClearDates,\n  resetResume,\n  resetDuration,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <Dropdown className=\"history-operations-dropdown\">\n      <Dropdown.Toggle\n        variant=\"secondary\"\n        className=\"minimal\"\n        title={intl.formatMessage({ id: \"operations\" })}\n      >\n        <Icon icon={faEllipsisV} />\n      </Dropdown.Toggle>\n      <Dropdown.Menu className=\"bg-secondary text-white\">\n        <Dropdown.Item\n          className=\"bg-secondary text-white\"\n          onClick={() => onAddDate()}\n        >\n          <FormattedMessage id=\"actions.add_manual_date\" />\n        </Dropdown.Item>\n        {hasHistory && (\n          <Dropdown.Item\n            className=\"bg-secondary text-white\"\n            onClick={() => onClearDates()}\n          >\n            <FormattedMessage id=\"actions.clear_date_data\" />\n          </Dropdown.Item>\n        )}\n        {showResetResumeDuration && (\n          <Dropdown.Item\n            className=\"bg-secondary text-white\"\n            onClick={() => resetResume()}\n          >\n            <FormattedMessage id=\"actions.reset_resume_time\" />\n          </Dropdown.Item>\n        )}\n        {showResetResumeDuration && (\n          <Dropdown.Item\n            className=\"bg-secondary text-white\"\n            onClick={() => resetDuration()}\n          >\n            <FormattedMessage id=\"actions.reset_play_duration\" />\n          </Dropdown.Item>\n        )}\n      </Dropdown.Menu>\n    </Dropdown>\n  );\n};\n\nconst DatePickerModal: React.FC<{\n  show: boolean;\n  onClose: (t?: string) => void;\n}> = ({ show, onClose }) => {\n  const intl = useIntl();\n  const [date, setDate] = React.useState<string>(\n    TextUtils.dateTimeToString(new Date())\n  );\n\n  return (\n    <ModalComponent\n      show={show}\n      header={<FormattedMessage id=\"actions.choose_date\" />}\n      accept={{\n        onClick: () => onClose(date),\n        text: intl.formatMessage({ id: \"actions.confirm\" }),\n      }}\n      cancel={{\n        variant: \"secondary\",\n        onClick: () => onClose(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n      }}\n    >\n      <div>\n        <DateInput value={date} onValueChange={(d) => setDate(d)} isTime />\n      </div>\n    </ModalComponent>\n  );\n};\n\ninterface ISceneHistoryProps {\n  scene: GQL.SceneDataFragment;\n}\n\nexport const SceneHistoryPanel: React.FC<ISceneHistoryProps> = ({ scene }) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const [dialogs, setDialogs] = React.useState({\n    playHistory: false,\n    oHistory: false,\n    addPlay: false,\n    addO: false,\n  });\n\n  function setDialogPartial(partial: Partial<typeof dialogs>) {\n    setDialogs({ ...dialogs, ...partial });\n  }\n\n  const [incrementPlayCount] = useSceneIncrementPlayCount();\n  const [decrementPlayCount] = useSceneDecrementPlayCount();\n  const [clearPlayCount] = useSceneResetPlayCount();\n  const [incrementOCount] = useSceneIncrementO(scene.id);\n  const [decrementOCount] = useSceneDecrementO(scene.id);\n  const [resetO] = useSceneResetO(scene.id);\n  const [resetResume] = useSceneResetActivity(scene.id, true, false);\n  const [resetDuration] = useSceneResetActivity(scene.id, false, true);\n\n  function dateStringToISOString(time: string) {\n    const date = TextUtils.stringToFuzzyDateTime(time);\n    if (!date) return null;\n    return date.toISOString();\n  }\n\n  function handleAddPlayDate(time?: string) {\n    incrementPlayCount({\n      variables: {\n        id: scene.id,\n        times: time ? [time] : undefined,\n      },\n    });\n  }\n\n  function handleDeletePlayDate(time: string) {\n    decrementPlayCount({\n      variables: {\n        id: scene.id,\n        times: time ? [time] : undefined,\n      },\n    });\n  }\n\n  function handleClearPlayDates() {\n    setDialogPartial({ playHistory: false });\n    clearPlayCount({\n      variables: {\n        id: scene.id,\n      },\n    });\n  }\n\n  function handleAddODate(time?: string) {\n    incrementOCount({\n      variables: {\n        id: scene.id,\n        times: time ? [time] : undefined,\n      },\n    });\n  }\n\n  function handleDeleteODate(time: string) {\n    decrementOCount({\n      variables: {\n        id: scene.id,\n        times: time ? [time] : undefined,\n      },\n    });\n  }\n\n  function handleClearODates() {\n    setDialogPartial({ oHistory: false });\n    resetO({\n      variables: {\n        id: scene.id,\n      },\n    });\n  }\n\n  async function handleResetResume() {\n    try {\n      await resetResume({\n        variables: {\n          id: scene.id,\n          reset_resume: true,\n          reset_duration: false,\n        },\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"scene\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function handleResetDuration() {\n    try {\n      await resetDuration({\n        variables: {\n          id: scene.id,\n          reset_resume: false,\n          reset_duration: true,\n        },\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"scene\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  function maybeRenderDialogs() {\n    const clearHistoryMessageID = sfwContentMode\n      ? \"dialogs.clear_o_history_confirm_sfw\"\n      : \"dialogs.clear_play_history_confirm\";\n    return (\n      <>\n        <AlertModal\n          show={dialogs.playHistory}\n          text={intl.formatMessage({\n            id: \"dialogs.clear_play_history_confirm\",\n          })}\n          confirmButtonText={intl.formatMessage({ id: \"actions.clear\" })}\n          onConfirm={() => handleClearPlayDates()}\n          onCancel={() => setDialogPartial({ playHistory: false })}\n        />\n        <AlertModal\n          show={dialogs.oHistory}\n          text={intl.formatMessage({ id: clearHistoryMessageID })}\n          confirmButtonText={intl.formatMessage({ id: \"actions.clear\" })}\n          onConfirm={() => handleClearODates()}\n          onCancel={() => setDialogPartial({ oHistory: false })}\n        />\n        {/* add conditions here so that date is generated correctly */}\n        {dialogs.addPlay && (\n          <DatePickerModal\n            show\n            onClose={(t) => {\n              const tt = t ? dateStringToISOString(t) : null;\n              if (tt) {\n                handleAddPlayDate(tt);\n              }\n              setDialogPartial({ addPlay: false });\n            }}\n          />\n        )}\n        {dialogs.addO && (\n          <DatePickerModal\n            show\n            onClose={(t) => {\n              const tt = t ? dateStringToISOString(t) : null;\n              if (tt) {\n                handleAddODate(tt);\n              }\n              setDialogPartial({ addO: false });\n            }}\n          />\n        )}\n      </>\n    );\n  }\n\n  const playHistory = (scene.play_history ?? []).filter(\n    (h) => h != null\n  ) as string[];\n  const oHistory = (scene.o_history ?? []).filter((h) => h != null) as string[];\n\n  const oHistoryMessageID = sfwContentMode ? \"o_history_sfw\" : \"o_history\";\n  const noneMessageID = sfwContentMode\n    ? \"odate_recorded_no_sfw\"\n    : \"odate_recorded_no\";\n\n  return (\n    <div>\n      {maybeRenderDialogs()}\n      <div className=\"play-history\">\n        <div className=\"history-header\">\n          <h5>\n            <span>\n              <FormattedMessage id=\"play_history\" />\n              <Counter count={playHistory.length} hideZero />\n            </span>\n            <span>\n              <Button\n                size=\"sm\"\n                variant=\"minimal\"\n                className=\"add-date-button\"\n                title={intl.formatMessage({ id: \"actions.add_play\" })}\n                onClick={() => handleAddPlayDate()}\n              >\n                <Icon icon={faPlus} />\n              </Button>\n              <HistoryMenu\n                hasHistory={playHistory.length > 0}\n                showResetResumeDuration={true}\n                onAddDate={() => setDialogPartial({ addPlay: true })}\n                onClearDates={() => setDialogPartial({ playHistory: true })}\n                resetResume={() => handleResetResume()}\n                resetDuration={() => handleResetDuration()}\n              />\n            </span>\n          </h5>\n        </div>\n\n        <History\n          history={playHistory ?? []}\n          noneID=\"playdate_recorded_no\"\n          unknownDate={scene.created_at}\n          onRemove={(t) => handleDeletePlayDate(t)}\n        />\n        <dl className=\"details-list\">\n          <TextField\n            id=\"media_info.play_duration\"\n            value={TextUtils.secondsToTimestamp(scene.play_duration ?? 0)}\n          />\n        </dl>\n      </div>\n\n      <div className=\"o-history\">\n        <div className=\"history-header\">\n          <h5>\n            <span>\n              <FormattedMessage id={oHistoryMessageID} />\n              <Counter count={oHistory.length} hideZero />\n            </span>\n            <span>\n              <Button\n                size=\"sm\"\n                variant=\"minimal\"\n                className=\"add-date-button\"\n                title={intl.formatMessage({ id: \"actions.add_o\" })}\n                onClick={() => handleAddODate()}\n              >\n                <Icon icon={faPlus} />\n              </Button>\n              <HistoryMenu\n                hasHistory={oHistory.length > 0}\n                showResetResumeDuration={false}\n                onAddDate={() => setDialogPartial({ addO: true })}\n                onClearDates={() => setDialogPartial({ oHistory: true })}\n                resetResume={() => handleResetResume()}\n                resetDuration={() => handleResetDuration()}\n              />\n            </span>\n          </h5>\n        </div>\n        <History\n          history={oHistory}\n          noneID={noneMessageID}\n          unknownDate={scene.created_at}\n          onRemove={(t) => handleDeleteODate(t)}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default SceneHistoryPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useFormik } from \"formik\";\nimport * as yup from \"yup\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useSceneMarkerCreate,\n  useSceneMarkerUpdate,\n  useSceneMarkerDestroy,\n} from \"src/core/StashService\";\nimport { DurationInput } from \"src/components/Shared/DurationInput\";\nimport { MarkerTitleSuggest } from \"src/components/Shared/Select\";\nimport {\n  getAbLoopPlugin,\n  getPlayerPosition,\n} from \"src/components/ScenePlayer/util\";\nimport { useToast } from \"src/hooks/Toast\";\nimport isEqual from \"lodash-es/isEqual\";\nimport { formikUtils } from \"src/utils/form\";\nimport { yupFormikValidate } from \"src/utils/yup\";\nimport { Tag, TagSelect } from \"src/components/Tags/TagSelect\";\n\ninterface ISceneMarkerForm {\n  sceneID: string;\n  marker?: GQL.SceneMarkerDataFragment;\n  onClose: () => void;\n}\n\nexport const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({\n  sceneID,\n  marker,\n  onClose,\n}) => {\n  const intl = useIntl();\n\n  const [sceneMarkerCreate] = useSceneMarkerCreate();\n  const [sceneMarkerUpdate] = useSceneMarkerUpdate();\n  const [sceneMarkerDestroy] = useSceneMarkerDestroy();\n  const Toast = useToast();\n\n  const [primaryTag, setPrimaryTag] = useState<Tag>();\n  const [tags, setTags] = useState<Tag[]>([]);\n\n  const isNew = marker === undefined;\n\n  const schema = yup.object({\n    title: yup.string().ensure(),\n    seconds: yup.number().min(0).required(),\n    end_seconds: yup\n      .number()\n      .min(0)\n      .nullable()\n      .defined()\n      .test(\n        \"is-greater-than-seconds\",\n        intl.formatMessage({ id: \"validation.end_time_before_start_time\" }),\n        function (value) {\n          return value === null || value >= this.parent.seconds;\n        }\n      ),\n    primary_tag_id: yup.string().required(),\n    tag_ids: yup.array(yup.string().required()).defined(),\n  });\n\n  // useMemo to only run getPlayerPosition when the input marker actually changes\n  const initialValues = useMemo(() => {\n    if (!marker) {\n      const abLoopPlugin = getAbLoopPlugin();\n      const opts = abLoopPlugin?.getOptions();\n      const start = opts?.start;\n      const end = opts?.end;\n      const hasAbLoop = Number.isFinite(start);\n\n      if (opts?.enabled && hasAbLoop) {\n        const current = Math.round(getPlayerPosition() ?? 0);\n        const rawEnd =\n          Number.isFinite(end) && (end as number) > 0 ? (end as number) : null;\n        const endSeconds =\n          rawEnd !== null ? rawEnd : Math.max(start as number, current);\n\n        return {\n          title: \"\",\n          seconds: start as number,\n          end_seconds: endSeconds,\n          primary_tag_id: \"\",\n          tag_ids: [],\n        };\n      }\n    }\n\n    return {\n      title: marker?.title ?? \"\",\n      seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0),\n      end_seconds: marker?.end_seconds ?? null,\n      primary_tag_id: marker?.primary_tag.id ?? \"\",\n      tag_ids: marker?.tags.map((tag) => tag.id) ?? [],\n    };\n  }, [marker]);\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: (values) => onSave(schema.cast(values)),\n  });\n\n  function onSetPrimaryTag(item: Tag) {\n    setPrimaryTag(item);\n    formik.setFieldValue(\"primary_tag_id\", item.id);\n  }\n\n  function onSetTags(items: Tag[]) {\n    setTags(items);\n    formik.setFieldValue(\n      \"tag_ids\",\n      items.map((item) => item.id)\n    );\n  }\n\n  useEffect(() => {\n    setPrimaryTag(\n      marker?.primary_tag\n        ? { ...marker.primary_tag, aliases: [], stash_ids: [] }\n        : undefined\n    );\n  }, [marker?.primary_tag]);\n\n  useEffect(() => {\n    setTags(\n      marker?.tags.map((t) => ({\n        ...t,\n        aliases: [],\n        stash_ids: [],\n      })) ?? []\n    );\n  }, [marker?.tags]);\n\n  async function onSave(input: InputValues) {\n    try {\n      if (isNew) {\n        await sceneMarkerCreate({\n          variables: {\n            scene_id: sceneID,\n            ...input,\n            // undefined means setting to null, not omitting the field\n            end_seconds: input.end_seconds ?? null,\n          },\n        });\n      } else {\n        await sceneMarkerUpdate({\n          variables: {\n            id: marker.id,\n            scene_id: sceneID,\n            ...input,\n            // undefined means setting to null, not omitting the field\n            end_seconds: input.end_seconds ?? null,\n          },\n        });\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      onClose();\n    }\n  }\n\n  async function onDelete() {\n    if (isNew) return;\n\n    try {\n      await sceneMarkerDestroy({ variables: { id: marker.id } });\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      onClose();\n    }\n  }\n\n  const splitProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n    },\n    fieldProps: {\n      sm: 9,\n    },\n  };\n  const fullWidthProps = {\n    labelProps: {\n      column: true,\n      sm: 3,\n      xl: 12,\n    },\n    fieldProps: {\n      sm: 9,\n      xl: 12,\n    },\n  };\n  const { renderField } = formikUtils(intl, formik, splitProps);\n\n  function renderTitleField() {\n    const title = intl.formatMessage({ id: \"title\" });\n    const control = (\n      <MarkerTitleSuggest\n        initialMarkerTitle={formik.values.title}\n        onChange={(v) => formik.setFieldValue(\"title\", v)}\n      />\n    );\n\n    return renderField(\"title\", title, control);\n  }\n\n  function renderPrimaryTagField() {\n    const title = intl.formatMessage({ id: \"primary_tag\" });\n    const control = (\n      <>\n        <TagSelect\n          onSelect={(t) => onSetPrimaryTag(t[0])}\n          values={primaryTag ? [primaryTag] : []}\n          hoverPlacement=\"right\"\n        />\n        {formik.touched.primary_tag_id && (\n          <Form.Control.Feedback type=\"invalid\">\n            {formik.errors.primary_tag_id}\n          </Form.Control.Feedback>\n        )}\n      </>\n    );\n\n    return renderField(\"primary_tag_id\", title, control);\n  }\n\n  function renderTimeField() {\n    const { error } = formik.getFieldMeta(\"seconds\");\n\n    const title = intl.formatMessage({ id: \"time\" });\n    const control = (\n      <DurationInput\n        value={formik.values.seconds}\n        setValue={(v) => formik.setFieldValue(\"seconds\", v)}\n        onReset={() =>\n          formik.setFieldValue(\"seconds\", getPlayerPosition() ?? 0)\n        }\n        error={error}\n      />\n    );\n\n    return renderField(\"seconds\", title, control);\n  }\n\n  function renderEndTimeField() {\n    const { error } = formik.getFieldMeta(\"end_seconds\");\n\n    const title = intl.formatMessage({ id: \"time_end\" });\n    const control = (\n      <>\n        <DurationInput\n          value={formik.values.end_seconds}\n          setValue={(v) => formik.setFieldValue(\"end_seconds\", v ?? null)}\n          onReset={() =>\n            formik.setFieldValue(\"end_seconds\", getPlayerPosition() ?? 0)\n          }\n          error={error}\n        />\n        {formik.touched.end_seconds && formik.errors.end_seconds && (\n          <Form.Control.Feedback type=\"invalid\">\n            {formik.errors.end_seconds}\n          </Form.Control.Feedback>\n        )}\n      </>\n    );\n\n    return renderField(\"end_seconds\", title, control);\n  }\n\n  function renderTagsField() {\n    const title = intl.formatMessage({ id: \"tags\" });\n    const control = (\n      <TagSelect\n        isMulti\n        onSelect={onSetTags}\n        values={tags}\n        hoverPlacement=\"right\"\n      />\n    );\n\n    return renderField(\"tag_ids\", title, control, fullWidthProps);\n  }\n\n  return (\n    <Form noValidate onSubmit={formik.handleSubmit}>\n      <div className=\"form-container px-3\">\n        {renderTitleField()}\n        {renderPrimaryTagField()}\n        {renderTimeField()}\n        {renderEndTimeField()}\n        {renderTagsField()}\n      </div>\n      <div className=\"buttons-container px-3\">\n        <div className=\"d-flex\">\n          <Button\n            variant=\"primary\"\n            disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}\n            onClick={() => formik.submitForm()}\n          >\n            <FormattedMessage id=\"actions.save\" />\n          </Button>\n          <Button\n            variant=\"secondary\"\n            type=\"button\"\n            onClick={onClose}\n            className=\"ml-2\"\n          >\n            <FormattedMessage id=\"actions.cancel\" />\n          </Button>\n          {!isNew && (\n            <Button\n              variant=\"danger\"\n              className=\"ml-auto\"\n              onClick={() => onDelete()}\n            >\n              <FormattedMessage id=\"actions.delete\" />\n            </Button>\n          )}\n        </div>\n      </div>\n    </Form>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { MarkerWallPanel } from \"src/components/Wall/WallPanel\";\nimport { PrimaryTags } from \"./PrimaryTags\";\nimport { SceneMarkerForm } from \"./SceneMarkerForm\";\n\ninterface ISceneMarkersPanelProps {\n  sceneId: string;\n  isVisible: boolean;\n  onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;\n  onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void;\n}\n\nexport const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = ({\n  sceneId,\n  isVisible,\n  onClickMarker,\n  onLoopMarker,\n}) => {\n  const { data, loading } = GQL.useFindSceneMarkerTagsQuery({\n    variables: { id: sceneId },\n  });\n  const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);\n  const [editingMarker, setEditingMarker] =\n    useState<GQL.SceneMarkerDataFragment>();\n\n  // set up hotkeys\n  useEffect(() => {\n    if (!isVisible) return;\n\n    Mousetrap.bind(\"n\", () => onOpenEditor());\n\n    return () => {\n      Mousetrap.unbind(\"n\");\n    };\n  });\n\n  if (loading) return null;\n\n  function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) {\n    setIsEditorOpen(true);\n    setEditingMarker(marker ?? undefined);\n  }\n\n  const closeEditor = () => {\n    setEditingMarker(undefined);\n    setIsEditorOpen(false);\n  };\n\n  if (isEditorOpen)\n    return (\n      <SceneMarkerForm\n        sceneID={sceneId}\n        marker={editingMarker}\n        onClose={closeEditor}\n      />\n    );\n\n  const sceneMarkers = (\n    data?.sceneMarkerTags.map((tag) => tag.scene_markers) ?? []\n  ).reduce((prev, current) => [...prev, ...current], []);\n\n  return (\n    <div className=\"scene-markers-panel\">\n      <Button onClick={() => onOpenEditor()}>\n        <FormattedMessage id=\"actions.create_marker\" />\n      </Button>\n      <div className=\"container\">\n        <PrimaryTags\n          sceneMarkers={sceneMarkers}\n          onClickMarker={onClickMarker}\n          onLoopMarker={onLoopMarker}\n          onEdit={onOpenEditor}\n        />\n      </div>\n      <MarkerWallPanel\n        markers={sceneMarkers}\n        clickHandler={(e, marker) => {\n          e.preventDefault();\n          window.scrollTo(0, 0);\n          onClickMarker(marker);\n        }}\n      />\n    </div>\n  );\n};\n\nexport default SceneMarkersPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneQueryModal.tsx",
    "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Badge, Button, Col, Form, InputGroup, Row } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { queryScrapeSceneQuery } from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { faSearch } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface ISceneSearchResultDetailsProps {\n  scene: GQL.ScrapedSceneDataFragment;\n}\n\nconst SceneSearchResultDetails: React.FC<ISceneSearchResultDetailsProps> = ({\n  scene,\n}) => {\n  function renderPerformers() {\n    if (scene.performers) {\n      return (\n        <Row>\n          <Col>\n            {scene.performers?.map((performer) => (\n              <Badge\n                className=\"tag-item\"\n                variant=\"secondary\"\n                key={performer.name}\n              >\n                {performer.name}\n              </Badge>\n            ))}\n          </Col>\n        </Row>\n      );\n    }\n  }\n\n  function renderTags() {\n    if (scene.tags) {\n      return (\n        <Row>\n          <Col>\n            {scene.tags?.map((tag) => (\n              <Badge className=\"tag-item\" variant=\"secondary\" key={tag.name}>\n                {tag.name}\n              </Badge>\n            ))}\n          </Col>\n        </Row>\n      );\n    }\n  }\n\n  function renderImage() {\n    if (scene.image) {\n      return (\n        <div className=\"scene-image-container\">\n          <img\n            src={scene.image}\n            alt=\"\"\n            className=\"align-self-center scene-image\"\n          />\n        </div>\n      );\n    }\n  }\n\n  return (\n    <div className=\"scene-details\">\n      <Row>\n        {renderImage()}\n        <div className=\"col flex-column\">\n          <h4>{scene.title}</h4>\n          <h5>\n            {scene.studio?.name}\n            {scene.studio?.name && scene.date && ` • `}\n            {scene.date}\n          </h5>\n        </div>\n      </Row>\n      <Row>\n        <Col>\n          <TruncatedText text={scene.details ?? \"\"} lineCount={3} />\n        </Col>\n      </Row>\n      {renderPerformers()}\n      {renderTags()}\n    </div>\n  );\n};\n\nexport interface ISceneSearchResult {\n  scene: GQL.ScrapedSceneDataFragment;\n}\n\nexport const SceneSearchResult: React.FC<ISceneSearchResult> = ({ scene }) => {\n  return (\n    <div className=\"mt-3 search-item\">\n      <div className=\"row\">\n        <SceneSearchResultDetails scene={scene} />\n      </div>\n    </div>\n  );\n};\n\ninterface IProps {\n  scraper: GQL.ScraperSourceInput;\n  onHide: () => void;\n  onSelectScene: (scene: GQL.ScrapedSceneDataFragment) => void;\n  name?: string;\n}\nexport const SceneQueryModal: React.FC<IProps> = ({\n  scraper,\n  name,\n  onHide,\n  onSelectScene,\n}) => {\n  const CLASSNAME = \"SceneScrapeModal\";\n  const CLASSNAME_LIST = `${CLASSNAME}-list`;\n  const CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`;\n\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [loading, setLoading] = useState<boolean>(false);\n  const [scenes, setScenes] = useState<GQL.ScrapedScene[] | undefined>();\n  const [error, setError] = useState<Error | undefined>();\n\n  const doQuery = useCallback(\n    async (input: string) => {\n      if (!input) return;\n\n      setLoading(true);\n      try {\n        const r = await queryScrapeSceneQuery(scraper, input);\n        setScenes(r.data.scrapeSingleScene);\n      } catch (err) {\n        if (err instanceof Error) setError(err);\n      } finally {\n        setLoading(false);\n      }\n    },\n    [scraper]\n  );\n\n  useEffect(() => inputRef.current?.focus(), []);\n  useEffect(() => {\n    if (error) {\n      Toast.error(error);\n      setError(undefined);\n    }\n  }, [error, Toast]);\n\n  function renderResults() {\n    if (!scenes) {\n      return;\n    }\n\n    return (\n      <div className={CLASSNAME_LIST_CONTAINER}>\n        <div className=\"mt-1\">\n          <FormattedMessage\n            id=\"dialogs.scenes_found\"\n            values={{ count: scenes.length }}\n          />\n        </div>\n        <ul className={CLASSNAME_LIST}>\n          {scenes.map((s, i) => (\n            // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key\n            <li key={i} onClick={() => onSelectScene(s)}>\n              <SceneSearchResult scene={s} />\n            </li>\n          ))}\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <ModalComponent\n      show\n      onHide={onHide}\n      modalProps={{ size: \"lg\", dialogClassName: \"scrape-query-dialog\" }}\n      header={intl.formatMessage(\n        { id: \"dialogs.scrape_entity_query\" },\n        { entity_type: intl.formatMessage({ id: \"scene\" }) }\n      )}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        onClick: onHide,\n        variant: \"secondary\",\n      }}\n    >\n      <div className={CLASSNAME}>\n        <InputGroup>\n          <Form.Control\n            defaultValue={name ?? \"\"}\n            placeholder={`${intl.formatMessage({ id: \"name\" })}...`}\n            className=\"text-input\"\n            ref={inputRef}\n            onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>\n              e.key === \"Enter\" && doQuery(inputRef.current?.value ?? \"\")\n            }\n          />\n          <InputGroup.Append>\n            <Button\n              onClick={() => {\n                doQuery(inputRef.current?.value ?? \"\");\n              }}\n              variant=\"primary\"\n              title={intl.formatMessage({ id: \"actions.search\" })}\n            >\n              <Icon icon={faSearch} />\n            </Button>\n          </InputGroup.Append>\n        </InputGroup>\n\n        {loading ? (\n          <div className=\"m-4 text-center\">\n            <LoadingIndicator inline />\n          </div>\n        ) : (\n          renderResults()\n        )}\n      </div>\n    </ModalComponent>\n  );\n};\n\nexport default SceneQueryModal;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  ScrapedInputGroupRow,\n  ScrapedTextAreaRow,\n  ScrapedImageRow,\n  ScrapedStringListRow,\n} from \"src/components/Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { ScrapeDialog } from \"src/components/Shared/ScrapeDialog/ScrapeDialog\";\nimport { useIntl } from \"react-intl\";\nimport { uniq } from \"lodash-es\";\nimport { Performer } from \"src/components/Performers/PerformerSelect\";\nimport { sortStoredIdObjects } from \"src/utils/data\";\nimport {\n  ObjectListScrapeResult,\n  ObjectScrapeResult,\n  ScrapeResult,\n} from \"src/components/Shared/ScrapeDialog/scrapeResult\";\nimport {\n  ScrapedGroupsRow,\n  ScrapedPerformersRow,\n  ScrapedStudioRow,\n} from \"src/components/Shared/ScrapeDialog/ScrapedObjectsRow\";\nimport {\n  useCreateScrapedGroup,\n  useCreateScrapedPerformer,\n  useCreateScrapedStudio,\n} from \"src/components/Shared/ScrapeDialog/createObjects\";\nimport { Tag } from \"src/components/Tags/TagSelect\";\nimport { Studio } from \"src/components/Studios/StudioSelect\";\nimport { Group } from \"src/components/Groups/GroupSelect\";\nimport { useScrapedTags } from \"src/components/Shared/ScrapeDialog/scrapedTags\";\n\ninterface ISceneScrapeDialogProps {\n  scene: Partial<GQL.SceneUpdateInput>;\n  sceneStudio: Studio | null;\n  scenePerformers: Performer[];\n  sceneTags: Tag[];\n  sceneGroups: Group[];\n  scraped: GQL.ScrapedScene;\n  endpoint?: string;\n\n  onClose: (scrapedScene?: GQL.ScrapedScene) => void;\n}\n\nexport const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({\n  scene,\n  sceneStudio,\n  scenePerformers,\n  sceneTags,\n  sceneGroups,\n  scraped,\n  onClose,\n  endpoint,\n}) => {\n  const [title, setTitle] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(scene.title, scraped.title)\n  );\n  const [code, setCode] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(scene.code, scraped.code)\n  );\n\n  const [urls, setURLs] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(\n      scene.urls,\n      scraped.urls\n        ? uniq((scene.urls ?? []).concat(scraped.urls ?? []))\n        : undefined\n    )\n  );\n\n  const [date, setDate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(scene.date, scraped.date)\n  );\n  const [director, setDirector] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(scene.director, scraped.director)\n  );\n  const [studio, setStudio] = useState<ObjectScrapeResult<GQL.ScrapedStudio>>(\n    new ObjectScrapeResult<GQL.ScrapedStudio>(\n      sceneStudio\n        ? {\n            stored_id: sceneStudio.id,\n            name: sceneStudio.name,\n          }\n        : undefined,\n      scraped.studio?.stored_id ? scraped.studio : undefined\n    )\n  );\n  const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(\n    scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined\n  );\n\n  const [stashID, setStashID] = useState(\n    new ScrapeResult<string>(\n      scene.stash_ids?.find((s) => s.endpoint === endpoint)?.stash_id,\n      scraped.remote_site_id\n    )\n  );\n\n  const [performers, setPerformers] = useState<\n    ObjectListScrapeResult<GQL.ScrapedPerformer>\n  >(\n    new ObjectListScrapeResult<GQL.ScrapedPerformer>(\n      sortStoredIdObjects(\n        scenePerformers.map((p) => ({\n          stored_id: p.id,\n          name: p.name,\n        }))\n      ),\n      sortStoredIdObjects(scraped.performers ?? undefined)\n    )\n  );\n  const [newPerformers, setNewPerformers] = useState<GQL.ScrapedPerformer[]>(\n    scraped.performers?.filter((t) => !t.stored_id) ?? []\n  );\n\n  const [groups, setGroups] = useState<\n    ObjectListScrapeResult<GQL.ScrapedGroup>\n  >(\n    new ObjectListScrapeResult<GQL.ScrapedGroup>(\n      sortStoredIdObjects(\n        sceneGroups.map((p) => ({\n          stored_id: p.id,\n          name: p.name,\n        }))\n      ),\n      sortStoredIdObjects(scraped.groups ?? undefined)\n    )\n  );\n  const [newGroups, setNewGroups] = useState<GQL.ScrapedGroup[]>(\n    scraped.groups?.filter((t) => !t.stored_id) ?? []\n  );\n\n  const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(\n    sceneTags,\n    scraped.tags,\n    endpoint\n  );\n\n  const [details, setDetails] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(scene.details, scraped.details)\n  );\n\n  const [image, setImage] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(scene.cover_image, scraped.image)\n  );\n\n  const createNewStudio = useCreateScrapedStudio({\n    scrapeResult: studio,\n    setScrapeResult: setStudio,\n    setNewObject: setNewStudio,\n    endpoint,\n  });\n\n  const createNewPerformer = useCreateScrapedPerformer({\n    scrapeResult: performers,\n    setScrapeResult: setPerformers,\n    newObjects: newPerformers,\n    setNewObjects: setNewPerformers,\n    endpoint,\n  });\n\n  const createNewGroup = useCreateScrapedGroup({\n    scrapeResult: groups,\n    setScrapeResult: setGroups,\n    newObjects: newGroups,\n    setNewObjects: setNewGroups,\n    endpoint,\n  });\n\n  const intl = useIntl();\n\n  // don't show the dialog if nothing was scraped\n  if (\n    [\n      title,\n      code,\n      urls,\n      date,\n      director,\n      studio,\n      performers,\n      groups,\n      tags,\n      details,\n      image,\n      stashID,\n    ].every((r) => !r.scraped) &&\n    newTags.length === 0 &&\n    newPerformers.length === 0 &&\n    newGroups.length === 0 &&\n    !newStudio\n  ) {\n    onClose();\n    return <></>;\n  }\n\n  function makeNewScrapedItem(): GQL.ScrapedSceneDataFragment {\n    const newStudioValue = studio.getNewValue();\n\n    return {\n      title: title.getNewValue(),\n      code: code.getNewValue(),\n      urls: urls.getNewValue(),\n      date: date.getNewValue(),\n      director: director.getNewValue(),\n      studio: newStudioValue,\n      performers: performers.getNewValue(),\n      groups: groups.getNewValue(),\n      tags: tags.getNewValue(),\n      details: details.getNewValue(),\n      image: image.getNewValue(),\n      remote_site_id: stashID.getNewValue(),\n    };\n  }\n\n  function renderScrapeRows() {\n    return (\n      <>\n        <ScrapedInputGroupRow\n          field=\"title\"\n          title={intl.formatMessage({ id: \"title\" })}\n          result={title}\n          onChange={(value) => setTitle(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"code\"\n          title={intl.formatMessage({ id: \"scene_code\" })}\n          result={code}\n          onChange={(value) => setCode(value)}\n        />\n        <ScrapedStringListRow\n          field=\"urls\"\n          title={intl.formatMessage({ id: \"urls\" })}\n          result={urls}\n          onChange={(value) => setURLs(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"date\"\n          title={intl.formatMessage({ id: \"date\" })}\n          placeholder=\"YYYY-MM-DD\"\n          result={date}\n          onChange={(value) => setDate(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"director\"\n          title={intl.formatMessage({ id: \"director\" })}\n          result={director}\n          onChange={(value) => setDirector(value)}\n        />\n        <ScrapedStudioRow\n          field=\"studio\"\n          title={intl.formatMessage({ id: \"studios\" })}\n          result={studio}\n          onChange={(value) => setStudio(value)}\n          newStudio={newStudio}\n          onCreateNew={createNewStudio}\n        />\n        <ScrapedPerformersRow\n          field=\"performers\"\n          title={intl.formatMessage({ id: \"performers\" })}\n          result={performers}\n          onChange={(value) => setPerformers(value)}\n          newObjects={newPerformers}\n          onCreateNew={createNewPerformer}\n          ageFromDate={date.useNewValue ? date.newValue : date.originalValue}\n        />\n        <ScrapedGroupsRow\n          field=\"groups\"\n          title={intl.formatMessage({ id: \"groups\" })}\n          result={groups}\n          onChange={(value) => setGroups(value)}\n          newObjects={newGroups}\n          onCreateNew={createNewGroup}\n        />\n        {scrapedTagsRow}\n        <ScrapedTextAreaRow\n          field=\"details\"\n          title={intl.formatMessage({ id: \"details\" })}\n          result={details}\n          onChange={(value) => setDetails(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"stash_ids\"\n          title={intl.formatMessage({ id: \"stash_id\" })}\n          result={stashID}\n          locked\n          onChange={(value) => setStashID(value)}\n        />\n        <ScrapedImageRow\n          field=\"cover_image\"\n          title={intl.formatMessage({ id: \"cover_image\" })}\n          className=\"scene-cover\"\n          result={image}\n          onChange={(value) => setImage(value)}\n        />\n      </>\n    );\n  }\n\n  if (linkDialog) {\n    return linkDialog;\n  }\n\n  return (\n    <ScrapeDialog\n      title={intl.formatMessage(\n        { id: \"dialogs.scrape_entity_title\" },\n        { entity_type: intl.formatMessage({ id: \"scene\" }) }\n      )}\n      onClose={(apply) => {\n        onClose(apply ? makeNewScrapedItem() : undefined);\n      }}\n    >\n      {renderScrapeRows()}\n    </ScrapeDialog>\n  );\n};\n\nexport default SceneScrapeDialog;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx",
    "content": "import React, { useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { VIDEO_PLAYER_ID } from \"src/components/ScenePlayer/util\";\nimport * as GQL from \"src/core/generated-graphql\";\n\ninterface ISceneVideoFilterPanelProps {\n  scene: GQL.SceneDataFragment;\n}\n\n// References\n// https://yoksel.github.io/svg-filters/#/\n// https://codepen.io/chriscoyier/pen/zbakI\n// http://xahlee.info/js/js_scritping_svg_basics.html#:~:text=Just%20use%20JavaScript%20to%20script,%2C%20path%2C%20%E2%80%A6.).\n\ntype SliderRange = {\n  min: number;\n  default: number;\n  max: number;\n  divider: number;\n};\n\nfunction getMatrixValue(value: number, range: SliderRange) {\n  return (value - range.default) / range.divider;\n}\n\ninterface ISliderProps {\n  title: string;\n  className?: string;\n  range: SliderRange;\n  value: number;\n  setValue: (value: React.SetStateAction<number>) => void;\n  displayValue: string;\n}\n\nconst Slider: React.FC<ISliderProps> = (sliderProps: ISliderProps) => {\n  return (\n    <div className=\"row form-group\">\n      <span className=\"col-sm-3\">{sliderProps.title}</span>\n      <span className=\"col-sm-7\">\n        <Form.Control\n          className={`filter-slider d-inline-flex ml-sm-3 ${sliderProps.className}`}\n          type=\"range\"\n          min={sliderProps.range.min}\n          max={sliderProps.range.max}\n          value={sliderProps.value}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            sliderProps.setValue(Number.parseInt(e.currentTarget.value, 10))\n          }\n        />\n      </span>\n      <span\n        className=\"col-sm-2 filter-slider-value\"\n        role=\"presentation\"\n        onClick={() => sliderProps.setValue(sliderProps.range.default)}\n        onKeyPress={() => sliderProps.setValue(sliderProps.range.default)}\n      >\n        <TruncatedText text={sliderProps.displayValue} />\n      </span>\n    </div>\n  );\n};\n\nexport const SceneVideoFilterPanel: React.FC<ISceneVideoFilterPanelProps> = (\n  props: ISceneVideoFilterPanelProps\n) => {\n  const contrastRange: SliderRange = {\n    min: 0,\n    default: 100,\n    max: 200,\n    divider: 1,\n  };\n  const brightnessRange: SliderRange = {\n    min: 0,\n    default: 100,\n    max: 200,\n    divider: 1,\n  };\n  const gammaRange: SliderRange = {\n    min: 0,\n    default: 100,\n    max: 200,\n    divider: 200,\n  };\n  const saturateRange: SliderRange = {\n    min: 0,\n    default: 100,\n    max: 200,\n    divider: 1,\n  };\n  const hueRotateRange: SliderRange = {\n    min: 0,\n    default: 0,\n    max: 360,\n    divider: 1,\n  };\n  const whiteBalanceRange: SliderRange = {\n    min: 0,\n    default: 100,\n    max: 200,\n    divider: 200,\n  };\n  const colourRange: SliderRange = {\n    min: 0,\n    default: 100,\n    max: 200,\n    divider: 100,\n  };\n  const blurRange: SliderRange = { min: 0, default: 0, max: 250, divider: 10 };\n  const rotateRange: SliderRange = {\n    min: 0,\n    default: 2,\n    max: 4,\n    divider: 1 / 90,\n  };\n  const scaleRange: SliderRange = {\n    min: 0,\n    default: 100,\n    max: 200,\n    divider: 1,\n  };\n  const aspectRatioRange: SliderRange = {\n    min: 0,\n    default: 150,\n    max: 300,\n    divider: 100,\n  };\n\n  const intl = useIntl();\n\n  const [contrastValue, setContrastValue] = useState(contrastRange.default);\n  const [brightnessValue, setBrightnessValue] = useState(\n    brightnessRange.default\n  );\n  const [gammaValue, setGammaValue] = useState(gammaRange.default);\n  const [saturateValue, setSaturateValue] = useState(saturateRange.default);\n  const [hueRotateValue, setHueRotateValue] = useState(hueRotateRange.default);\n  const [whiteBalanceValue, setWhiteBalanceValue] = useState(\n    whiteBalanceRange.default\n  );\n  const [redValue, setRedValue] = useState(colourRange.default);\n  const [greenValue, setGreenValue] = useState(colourRange.default);\n  const [blueValue, setBlueValue] = useState(colourRange.default);\n  const [blurValue, setBlurValue] = useState(blurRange.default);\n  const [rotateValue, setRotateValue] = useState(rotateRange.default);\n  const [scaleValue, setScaleValue] = useState(scaleRange.default);\n  const [aspectRatioValue, setAspectRatioValue] = useState(\n    aspectRatioRange.default\n  );\n\n  // eslint-disable-next-line\n  function getVideoElement(playerVideoContainer: any) {\n    let videoElements = playerVideoContainer.getElementsByTagName(\"canvas\");\n\n    if (videoElements.length == 0) {\n      videoElements = playerVideoContainer.getElementsByTagName(\"video\");\n    }\n\n    if (videoElements.length > 0) {\n      return videoElements[0];\n    }\n  }\n\n  function updateVideoStyle() {\n    const playerVideoContainer = document.getElementById(VIDEO_PLAYER_ID)!;\n    if (!playerVideoContainer) {\n      return;\n    }\n\n    const playerVideoElement = getVideoElement(playerVideoContainer);\n    if (playerVideoElement != null) {\n      let styleString = \"filter:\";\n      let style = playerVideoElement.attributes.getNamedItem(\"style\");\n\n      if (style == null) {\n        style = document.createAttribute(\"style\");\n        playerVideoElement.attributes.setNamedItem(style);\n      }\n\n      if (\n        whiteBalanceValue !== whiteBalanceRange.default ||\n        redValue !== colourRange.default ||\n        greenValue !== colourRange.default ||\n        blueValue !== colourRange.default ||\n        gammaValue !== gammaRange.default\n      ) {\n        styleString += \" url(#videoFilter)\";\n      }\n\n      if (contrastValue !== contrastRange.default) {\n        styleString += ` contrast(${contrastValue}%)`;\n      }\n\n      if (brightnessValue !== brightnessRange.default) {\n        styleString += ` brightness(${brightnessValue}%)`;\n      }\n\n      if (saturateValue !== saturateRange.default) {\n        styleString += ` saturate(${saturateValue}%)`;\n      }\n\n      if (hueRotateValue !== hueRotateRange.default) {\n        styleString += ` hue-rotate(${hueRotateValue}deg)`;\n      }\n\n      if (blurValue > blurRange.default) {\n        styleString += ` blur(${blurValue / blurRange.divider}px)`;\n      }\n\n      styleString += \"; transform:\";\n\n      if (rotateValue !== rotateRange.default) {\n        styleString += ` rotate(${\n          (rotateValue - rotateRange.default) / rotateRange.divider\n        }deg)`;\n      }\n\n      if (\n        scaleValue !== scaleRange.default ||\n        aspectRatioValue !== aspectRatioRange.default\n      ) {\n        let xScale = scaleValue / scaleRange.divider / 100.0;\n        let yScale = scaleValue / scaleRange.divider / 100.0;\n\n        if (aspectRatioValue > aspectRatioRange.default) {\n          xScale *=\n            (aspectRatioRange.divider +\n              aspectRatioValue -\n              aspectRatioRange.default) /\n            aspectRatioRange.divider;\n        } else if (aspectRatioValue < aspectRatioRange.default) {\n          yScale *=\n            (aspectRatioRange.divider +\n              aspectRatioRange.default -\n              aspectRatioValue) /\n            aspectRatioRange.divider;\n        }\n\n        styleString += ` scale(${xScale},${yScale})`;\n      }\n\n      if (playerVideoElement.tagName == \"CANVAS\") {\n        styleString += \"; width: 100%; height: 100%; position: absolute; top:0\";\n      }\n\n      style.value = `${styleString};`;\n    }\n  }\n\n  function updateVideoFilters() {\n    const filterContainer = document.getElementById(\"video-filter-container\");\n\n    if (filterContainer == null) {\n      return;\n    }\n\n    const svg1 = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n    const videoFilter = document.createElementNS(\n      \"http://www.w3.org/2000/svg\",\n      \"filter\"\n    );\n    videoFilter.setAttribute(\"id\", \"videoFilter\");\n\n    if (\n      whiteBalanceValue !== whiteBalanceRange.default ||\n      redValue !== colourRange.default ||\n      greenValue !== colourRange.default ||\n      blueValue !== colourRange.default\n    ) {\n      const feColorMatrix = document.createElementNS(\n        \"http://www.w3.org/2000/svg\",\n        \"feColorMatrix\"\n      );\n\n      const wbMatrixValue = getMatrixValue(\n        whiteBalanceValue,\n        whiteBalanceRange\n      );\n\n      feColorMatrix.setAttribute(\n        \"values\",\n        `${\n          1 + wbMatrixValue + getMatrixValue(redValue, colourRange)\n        } 0 0 0 0   0 ${\n          1.0 + getMatrixValue(greenValue, colourRange)\n        } 0 0 0   0 0 ${\n          1 - wbMatrixValue + getMatrixValue(blueValue, colourRange)\n        } 0 0   0 0 0 1.0 0`\n      );\n      videoFilter.appendChild(feColorMatrix);\n    }\n\n    if (gammaValue !== gammaRange.default) {\n      const feComponentTransfer = document.createElementNS(\n        \"http://www.w3.org/2000/svg\",\n        \"feComponentTransfer\"\n      );\n\n      const feFuncR = document.createElementNS(\n        \"http://www.w3.org/2000/svg\",\n        \"feFuncR\"\n      );\n      feFuncR.setAttribute(\"type\", \"gamma\");\n      feFuncR.setAttribute(\"amplitude\", \"1.0\");\n      feFuncR.setAttribute(\n        \"exponent\",\n        `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}`\n      );\n      feFuncR.setAttribute(\"offset\", \"0.0\");\n      feComponentTransfer.appendChild(feFuncR);\n\n      const feFuncG = document.createElementNS(\n        \"http://www.w3.org/2000/svg\",\n        \"feFuncG\"\n      );\n      feFuncG.setAttribute(\"type\", \"gamma\");\n      feFuncG.setAttribute(\"amplitude\", \"1.0\");\n      feFuncG.setAttribute(\n        \"exponent\",\n        `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}`\n      );\n      feFuncG.setAttribute(\"offset\", \"0.0\");\n      feComponentTransfer.appendChild(feFuncG);\n\n      const feFuncB = document.createElementNS(\n        \"http://www.w3.org/2000/svg\",\n        \"feFuncB\"\n      );\n      feFuncB.setAttribute(\"type\", \"gamma\");\n      feFuncB.setAttribute(\"amplitude\", \"1.0\");\n      feFuncB.setAttribute(\n        \"exponent\",\n        `${1 + (gammaRange.default - gammaValue) / gammaRange.divider}`\n      );\n      feFuncB.setAttribute(\"offset\", \"0.0\");\n      feComponentTransfer.appendChild(feFuncB);\n\n      const feFuncA = document.createElementNS(\n        \"http://www.w3.org/2000/svg\",\n        \"feFuncA\"\n      );\n      feFuncA.setAttribute(\"type\", \"gamma\");\n      feFuncA.setAttribute(\"amplitude\", \"1.0\");\n      feFuncA.setAttribute(\"exponent\", \"1.0\");\n      feFuncA.setAttribute(\"offset\", \"0.0\");\n      feComponentTransfer.appendChild(feFuncA);\n\n      videoFilter.appendChild(feComponentTransfer);\n    }\n\n    svg1.appendChild(videoFilter);\n\n    // Add or Replace existing svg\n    const filterContainerSvgs = filterContainer.getElementsByTagNameNS(\n      \"http://www.w3.org/2000/svg\",\n      \"svg\"\n    );\n    if (filterContainerSvgs.length === 0) {\n      // attach container to document\n      filterContainer.appendChild(svg1);\n    } else {\n      // assume only one svg... maybe issue\n      filterContainer.replaceChild(svg1, filterContainerSvgs[0]);\n    }\n  }\n\n  function onRotateAndScale(direction: number) {\n    if (direction === 0) {\n      // Left -90\n      setRotateValue(1);\n    } else {\n      // Right +90\n      setRotateValue(3);\n    }\n\n    const file =\n      props.scene.files.length > 0 ? props.scene.files[0] : undefined;\n\n    // Calculate Required Scaling.\n    const sceneWidth = file?.width ?? 1;\n    const sceneHeight = file?.height ?? 1;\n    const sceneAspectRatio = sceneWidth / sceneHeight;\n    const sceneNewAspectRatio = sceneHeight / sceneWidth;\n\n    const playerVideoElement = document.getElementById(VIDEO_PLAYER_ID);\n    const playerWidth = playerVideoElement?.clientWidth ?? 1;\n    const playerHeight = playerVideoElement?.clientHeight ?? 1;\n    const playerAspectRation = playerWidth / playerHeight;\n\n    // rs > ri ? (wi * hs/hi, hs) : (ws, hi * ws/wi)\n    // Determine if video is currently constrained by player height or width.\n    let scaledVideoHeight = 0;\n    let scaledVideoWidth = 0;\n    if (playerAspectRation > sceneAspectRatio) {\n      // Video has it's width scaled\n      // Video is constrained by it's height\n      scaledVideoHeight = playerHeight;\n      scaledVideoWidth = (playerHeight / sceneHeight) * sceneWidth;\n    } else {\n      // Video has it's height scaled\n      // Video is constrained by it's width\n      scaledVideoWidth = playerWidth;\n      scaledVideoHeight = (playerWidth / sceneWidth) * sceneHeight;\n    }\n\n    // but now the video is rotated\n    let scaleFactor = 1;\n    if (playerAspectRation > sceneNewAspectRatio) {\n      // Rotated video will be constrained by it's height\n      // so we need to scaledVideoWidth to match the player height\n      scaleFactor = playerHeight / scaledVideoWidth;\n    } else {\n      // Rotated video will be constrained by it's width\n      // so we need to scaledVideoHeight to match the player width\n      scaleFactor = playerWidth / scaledVideoHeight;\n    }\n\n    setScaleValue(scaleFactor * 100);\n  }\n\n  function renderRotateAndScale() {\n    return (\n      <div className=\"row form-group\">\n        <span className=\"col-6\">\n          <Button\n            id=\"rotateAndScaleLeft\"\n            variant=\"primary\"\n            type=\"button\"\n            onClick={() => onRotateAndScale(0)}\n          >\n            <FormattedMessage id=\"effect_filters.rotate_left_and_scale\" />\n          </Button>\n        </span>\n        <span className=\"col-6\">\n          <Button\n            id=\"rotateAndScaleRight\"\n            variant=\"primary\"\n            type=\"button\"\n            onClick={() => onRotateAndScale(1)}\n          >\n            <FormattedMessage id=\"effect_filters.rotate_right_and_scale\" />\n          </Button>\n        </span>\n      </div>\n    );\n  }\n\n  function onResetFilters() {\n    setContrastValue(contrastRange.default);\n    setBrightnessValue(brightnessRange.default);\n    setGammaValue(gammaRange.default);\n    setSaturateValue(saturateRange.default);\n    setHueRotateValue(hueRotateRange.default);\n    setWhiteBalanceValue(whiteBalanceRange.default);\n    setRedValue(colourRange.default);\n    setGreenValue(colourRange.default);\n    setBlueValue(colourRange.default);\n    setBlurValue(blurRange.default);\n  }\n\n  function onResetTransforms() {\n    setScaleValue(scaleRange.default);\n    setRotateValue(rotateRange.default);\n    setAspectRatioValue(aspectRatioRange.default);\n  }\n\n  function renderResetButton() {\n    return (\n      <div className=\"row form-group\">\n        <span className=\"col-6\">\n          <Button\n            id=\"resetFilters\"\n            variant=\"primary\"\n            type=\"button\"\n            onClick={() => onResetFilters()}\n          >\n            <FormattedMessage id=\"effect_filters.reset_filters\" />\n          </Button>\n        </span>\n        <span className=\"col-6\">\n          <Button\n            id=\"resetTransforms\"\n            variant=\"secondary\"\n            type=\"button\"\n            onClick={() => onResetTransforms()}\n          >\n            <FormattedMessage id=\"effect_filters.reset_transforms\" />\n          </Button>\n        </span>\n      </div>\n    );\n  }\n\n  function renderFilterContainer() {\n    return <div id=\"video-filter-container\" />;\n  }\n\n  // On render update video style.\n  updateVideoFilters();\n  updateVideoStyle();\n\n  return (\n    <div className=\"container scene-video-filter\">\n      <div className=\"row form-group\">\n        <span className=\"col-12\">\n          <h5>\n            <FormattedMessage id=\"effect_filters.name\" />\n          </h5>\n        </span>\n      </div>\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.brightness\" })}\n        className=\"brightness-slider\"\n        range={brightnessRange}\n        value={brightnessValue}\n        setValue={setBrightnessValue}\n        displayValue={`${brightnessValue / brightnessRange.divider}%`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.contrast\" })}\n        className=\"contrast-slider\"\n        range={contrastRange}\n        value={contrastValue}\n        setValue={setContrastValue}\n        displayValue={`${contrastValue / brightnessRange.divider}%`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.gamma\" })}\n        className=\"gamma-slider\"\n        range={gammaRange}\n        value={gammaValue}\n        setValue={setGammaValue}\n        displayValue={`${\n          (gammaValue - gammaRange.default) / gammaRange.divider\n        }`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.saturation\" })}\n        className=\"saturation-slider\"\n        range={saturateRange}\n        value={saturateValue}\n        setValue={setSaturateValue}\n        displayValue={`${saturateValue / saturateRange.divider}%`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.hue\" })}\n        className=\"hue-rotate-slider\"\n        range={hueRotateRange}\n        value={hueRotateValue}\n        setValue={setHueRotateValue}\n        displayValue={`${hueRotateValue / hueRotateRange.divider}\\xB0`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.warmth\" })}\n        className=\"white-balance-slider\"\n        range={whiteBalanceRange}\n        value={whiteBalanceValue}\n        setValue={setWhiteBalanceValue}\n        displayValue={`${\n          (whiteBalanceValue - whiteBalanceRange.default) /\n          whiteBalanceRange.divider\n        }`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.red\" })}\n        className=\"red-slider\"\n        range={colourRange}\n        value={redValue}\n        setValue={setRedValue}\n        displayValue={`${redValue}%`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.green\" })}\n        className=\"green-slider\"\n        range={colourRange}\n        value={greenValue}\n        setValue={setGreenValue}\n        displayValue={`${greenValue}%`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.blue\" })}\n        className=\"blue-slider\"\n        range={colourRange}\n        value={blueValue}\n        setValue={setBlueValue}\n        displayValue={`${blueValue}%`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.blur\" })}\n        range={blurRange}\n        value={blurValue}\n        setValue={setBlurValue}\n        displayValue={`${blurValue / blurRange.divider}px`}\n      />\n\n      <div className=\"row form-group\">\n        <span className=\"col-12\">\n          <h5>\n            <FormattedMessage id=\"effect_filters.name_transforms\" />\n          </h5>\n        </span>\n      </div>\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.rotate\" })}\n        range={rotateRange}\n        value={rotateValue}\n        setValue={setRotateValue}\n        displayValue={`${\n          (rotateValue - rotateRange.default) / rotateRange.divider\n        }\\xB0`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.scale\" })}\n        range={scaleRange}\n        value={scaleValue}\n        setValue={setScaleValue}\n        displayValue={`${scaleValue / scaleRange.divider}%`}\n      />\n      <Slider\n        title={intl.formatMessage({ id: \"effect_filters.aspect\" })}\n        range={aspectRatioRange}\n        value={aspectRatioValue}\n        setValue={setAspectRatioValue}\n        displayValue={`${\n          (aspectRatioValue - aspectRatioRange.default) /\n          aspectRatioRange.divider\n        }`}\n      />\n      <div className=\"row form-group\">\n        <span className=\"col-12\">\n          <h5>\n            <FormattedMessage id=\"actions_name\" />\n          </h5>\n        </span>\n      </div>\n      {renderRotateAndScale()}\n      {renderResetButton()}\n      {renderFilterContainer()}\n    </div>\n  );\n};\n\nexport default SceneVideoFilterPanel;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneList.tsx",
    "content": "import React, { useCallback, useEffect, useMemo } from \"react\";\nimport cloneDeep from \"lodash-es/cloneDeep\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useHistory, useLocation } from \"react-router-dom\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { queryFindScenes, useFindScenes } from \"src/core/StashService\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { Tagger } from \"../Tagger/scenes/SceneTagger\";\nimport { IPlaySceneOptions, SceneQueue } from \"src/models/sceneQueue\";\nimport { SceneWallPanel } from \"./SceneWallPanel\";\nimport { SceneListTable } from \"./SceneListTable\";\nimport { EditScenesDialog } from \"./EditScenesDialog\";\nimport { DeleteScenesDialog } from \"./DeleteScenesDialog\";\nimport { GenerateDialog } from \"../Dialogs/GenerateDialog\";\nimport { ExportDialog } from \"../Shared/ExportDialog\";\nimport { SceneCardGrid } from \"./SceneCardGrid\";\nimport { TaggerContext } from \"../Tagger/context\";\nimport { IdentifyDialog } from \"../Dialogs/IdentifyDialog/IdentifyDialog\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { SceneMergeModal } from \"./SceneMergeDialog\";\nimport { objectTitle } from \"src/core/files\";\nimport TextUtils from \"src/utils/text\";\nimport { View } from \"../List/views\";\nimport { FileSize } from \"../Shared/FileSize\";\nimport { LoadedContent } from \"../List/PagedList\";\nimport { useCloseEditDelete, useFilterOperations } from \"../List/util\";\nimport { ListOperations } from \"../List/ListOperationButtons\";\nimport { useFilteredItemList } from \"../List/ItemList\";\nimport {\n  Sidebar,\n  SidebarPane,\n  SidebarPaneContent,\n  SidebarStateContext,\n  useSidebarState,\n} from \"../Shared/Sidebar\";\nimport { SidebarPerformersFilter } from \"../List/Filters/PerformersFilter\";\nimport { SidebarStudiosFilter } from \"../List/Filters/StudiosFilter\";\nimport { SidebarTagsFilter } from \"../List/Filters/TagsFilter\";\nimport cx from \"classnames\";\nimport { SidebarRatingFilter } from \"../List/Filters/RatingFilter\";\nimport { OrganizedCriterionOption } from \"src/models/list-filter/criteria/organized\";\nimport { HasMarkersCriterionOption } from \"src/models/list-filter/criteria/has-markers\";\nimport { SidebarBooleanFilter } from \"../List/Filters/BooleanFilter\";\nimport { PerformerAgeCriterionOption } from \"src/models/list-filter/scenes\";\nimport { SidebarDuplicateFilter } from \"../List/Filters/DuplicateFilter\";\nimport { SidebarAgeFilter } from \"../List/Filters/SidebarAgeFilter\";\nimport { SidebarDurationFilter } from \"../List/Filters/SidebarDurationFilter\";\nimport {\n  FilteredSidebarHeader,\n  useFilteredSidebarKeybinds,\n} from \"../List/Filters/FilterSidebar\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport { Pagination, PaginationIndex } from \"../List/Pagination\";\nimport { Button } from \"react-bootstrap\";\nimport useFocus from \"src/utils/focus\";\nimport { useZoomKeybinds } from \"../List/ZoomSlider\";\nimport { FilteredListToolbar } from \"../List/FilteredListToolbar\";\nimport { FilterTags } from \"../List/FilterTags\";\nimport { SidebarFolderFilter } from \"../List/Filters/FolderFilter\";\n\nfunction renderMetadataByline(result: GQL.FindScenesQueryResult) {\n  const duration = result?.data?.findScenes?.duration;\n  const size = result?.data?.findScenes?.filesize;\n\n  if (!duration && !size) {\n    return;\n  }\n\n  const separator = duration && size ? \" - \" : \"\";\n\n  return (\n    <span className=\"scenes-stats\">\n      &nbsp;(\n      {duration ? (\n        <span className=\"scenes-duration\">\n          {TextUtils.secondsAsTimeString(duration, 3)}\n        </span>\n      ) : undefined}\n      {separator}\n      {size ? (\n        <span className=\"scenes-size\">\n          <FileSize size={size} />\n        </span>\n      ) : undefined}\n      )\n    </span>\n  );\n}\n\nfunction usePlayScene() {\n  const history = useHistory();\n\n  const { configuration: config } = useConfigurationContext();\n  const cont = config?.interface.continuePlaylistDefault ?? false;\n  const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;\n\n  const playScene = useCallback(\n    (queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => {\n      history.push(\n        queue.makeLink(sceneID, { autoPlay, continue: cont, ...options })\n      );\n    },\n    [history, cont, autoPlay]\n  );\n\n  return playScene;\n}\n\nfunction usePlaySelected(selectedIds: Set<string>) {\n  const playScene = usePlayScene();\n\n  const playSelected = useCallback(() => {\n    // populate queue and go to first scene\n    const sceneIDs = Array.from(selectedIds.values());\n    const queue = SceneQueue.fromSceneIDList(sceneIDs);\n\n    playScene(queue, sceneIDs[0]);\n  }, [selectedIds, playScene]);\n\n  return playSelected;\n}\n\nfunction usePlayFirst() {\n  const playScene = usePlayScene();\n\n  const playFirst = useCallback(\n    (queue: SceneQueue, sceneID: string, index: number) => {\n      // populate queue and go to first scene\n      playScene(queue, sceneID, { sceneIndex: index });\n    },\n    [playScene]\n  );\n\n  return playFirst;\n}\n\nfunction usePlayRandom(filter: ListFilterModel, count: number) {\n  const playScene = usePlayScene();\n\n  const playRandom = useCallback(async () => {\n    // query for a random scene\n    if (count === 0) {\n      return;\n    }\n\n    const pages = Math.ceil(count / filter.itemsPerPage);\n    const page = Math.floor(Math.random() * pages) + 1;\n\n    const indexMax = Math.min(filter.itemsPerPage, count);\n    const index = Math.floor(Math.random() * indexMax);\n    const filterCopy = cloneDeep(filter);\n    filterCopy.currentPage = page;\n    filterCopy.sortBy = \"random\";\n    const queryResults = await queryFindScenes(filterCopy);\n    const scene = queryResults.data.findScenes.scenes[index];\n    if (scene) {\n      // navigate to the image player page\n      const queue = SceneQueue.fromListFilterModel(filterCopy);\n      playScene(queue, scene.id, { sceneIndex: index });\n    }\n  }, [filter, count, playScene]);\n\n  return playRandom;\n}\n\nfunction useAddKeybinds(filter: ListFilterModel, count: number) {\n  const playRandom = usePlayRandom(filter, count);\n\n  useEffect(() => {\n    Mousetrap.bind(\"p r\", () => {\n      playRandom();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"p r\");\n    };\n  }, [playRandom]);\n}\n\nconst SceneList: React.FC<{\n  scenes: GQL.SlimSceneDataFragment[];\n  filter: ListFilterModel;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  fromGroupId?: string;\n}> = PatchComponent(\n  \"SceneList\",\n  ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => {\n    const queue = useMemo(\n      () => SceneQueue.fromListFilterModel(filter),\n      [filter]\n    );\n\n    if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) {\n      return null;\n    }\n\n    if (filter.displayMode === DisplayMode.Grid) {\n      return (\n        <SceneCardGrid\n          scenes={scenes}\n          queue={queue}\n          zoomIndex={filter.zoomIndex}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n          fromGroupId={fromGroupId}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.List) {\n      return (\n        <SceneListTable\n          scenes={scenes}\n          queue={queue}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.Wall) {\n      return (\n        <SceneWallPanel\n          scenes={scenes}\n          sceneQueue={queue}\n          zoomIndex={filter.zoomIndex}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.Tagger) {\n      return (\n        <Tagger\n          scenes={scenes}\n          queue={queue}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n\n    return null;\n  }\n);\n\nconst ScenesFilterSidebarSections = PatchContainerComponent(\n  \"FilteredSceneList.SidebarSections\"\n);\n\nconst SidebarContent: React.FC<{\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  sidebarOpen: boolean;\n  onClose?: () => void;\n  showEditFilter: (editingCriterion?: string) => void;\n  count?: number;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  filter,\n  setFilter,\n  filterHook,\n  view,\n  showEditFilter,\n  sidebarOpen,\n  onClose,\n  count,\n  focus,\n}) => {\n  const showResultsId =\n    count !== undefined ? \"actions.show_count_results\" : \"actions.show_results\";\n\n  const hideStudios = view === View.StudioScenes;\n\n  return (\n    <>\n      <FilteredSidebarHeader\n        sidebarOpen={sidebarOpen}\n        showEditFilter={showEditFilter}\n        filter={filter}\n        setFilter={setFilter}\n        view={view}\n        focus={focus}\n      />\n\n      <ScenesFilterSidebarSections>\n        {!hideStudios && (\n          <SidebarStudiosFilter\n            filter={filter}\n            setFilter={setFilter}\n            filterHook={filterHook}\n          />\n        )}\n        <SidebarPerformersFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarTagsFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarRatingFilter filter={filter} setFilter={setFilter} />\n        <SidebarDurationFilter filter={filter} setFilter={setFilter} />\n        <SidebarFolderFilter\n          text={<FormattedMessage id=\"folder\" />}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"folder\"\n        />\n        <SidebarBooleanFilter\n          title={<FormattedMessage id=\"hasMarkers\" />}\n          data-type={HasMarkersCriterionOption.type}\n          option={HasMarkersCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"hasMarkers\"\n        />\n        <SidebarBooleanFilter\n          title={<FormattedMessage id=\"organized\" />}\n          data-type={OrganizedCriterionOption.type}\n          option={OrganizedCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"organized\"\n        />\n        <SidebarDuplicateFilter\n          title={<FormattedMessage id=\"duplicated\" />}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"duplicated\"\n        />\n        <SidebarAgeFilter\n          title={<FormattedMessage id=\"performer_age\" />}\n          option={PerformerAgeCriterionOption}\n          filter={filter}\n          setFilter={setFilter}\n          sectionID=\"performer_age\"\n        />\n      </ScenesFilterSidebarSections>\n\n      <div className=\"sidebar-footer\">\n        <Button className=\"sidebar-close-button\" onClick={onClose}>\n          <FormattedMessage id={showResultsId} values={{ count }} />\n        </Button>\n      </div>\n    </>\n  );\n};\n\ninterface IFilteredScenes {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  defaultSort?: string;\n  view?: View;\n  alterQuery?: boolean;\n  fromGroupId?: string;\n}\n\nexport const FilteredSceneList = PatchComponent(\n  \"FilteredSceneList\",\n  (props: IFilteredScenes) => {\n    const intl = useIntl();\n    const history = useHistory();\n    const location = useLocation();\n\n    const searchFocus = useFocus();\n\n    const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;\n\n    // States\n    const {\n      showSidebar,\n      setShowSidebar,\n      loading: sidebarStateLoading,\n      sectionOpen,\n      setSectionOpen,\n    } = useSidebarState(view);\n\n    const { filterState, queryResult, modalState, listSelect, showEditFilter } =\n      useFilteredItemList({\n        filterStateProps: {\n          filterMode: GQL.FilterMode.Scenes,\n          defaultSort,\n          view,\n          useURL: alterQuery,\n        },\n        queryResultProps: {\n          useResult: useFindScenes,\n          getCount: (r) => r.data?.findScenes.count ?? 0,\n          getItems: (r) => r.data?.findScenes.scenes ?? [],\n          filterHook,\n        },\n      });\n\n    const { filter, setFilter } = filterState;\n\n    const { effectiveFilter, result, cachedResult, items, totalCount } =\n      queryResult;\n\n    const {\n      selectedIds,\n      selectedItems,\n      onSelectChange,\n      onSelectAll,\n      onSelectNone,\n      onInvertSelection,\n      hasSelection,\n    } = listSelect;\n\n    const { modal, showModal, closeModal } = modalState;\n\n    // Utility hooks\n    const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({\n      filter,\n      setFilter,\n    });\n\n    useAddKeybinds(effectiveFilter, totalCount);\n    useFilteredSidebarKeybinds({\n      showSidebar,\n      setShowSidebar,\n    });\n\n    const onCloseEditDelete = useCloseEditDelete({\n      closeModal,\n      onSelectNone,\n      result,\n    });\n\n    const onEdit = useCallback(() => {\n      showModal(\n        <EditScenesDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }, [showModal, selectedItems, onCloseEditDelete]);\n\n    const onDelete = useCallback(() => {\n      showModal(\n        <DeleteScenesDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }, [showModal, selectedItems, onCloseEditDelete]);\n\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => {\n        if (hasSelection) {\n          onEdit?.();\n        }\n      });\n\n      Mousetrap.bind(\"d d\", () => {\n        if (hasSelection) {\n          onDelete?.();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"d d\");\n      };\n    }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]);\n    useZoomKeybinds({\n      zoomIndex: filter.zoomIndex,\n      onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),\n    });\n\n    const metadataByline = useMemo(() => {\n      if (cachedResult.loading) return null;\n\n      return renderMetadataByline(cachedResult) ?? null;\n    }, [cachedResult]);\n\n    const queue = useMemo(\n      () => SceneQueue.fromListFilterModel(filter),\n      [filter]\n    );\n\n    const playRandom = usePlayRandom(effectiveFilter, totalCount);\n    const playSelected = usePlaySelected(selectedIds);\n    const playFirst = usePlayFirst();\n\n    function onCreateNew() {\n      let queryParam = new URLSearchParams(location.search).get(\"q\");\n      let newPath = \"/scenes/new\";\n      if (queryParam) {\n        newPath += \"?q=\" + encodeURIComponent(queryParam);\n      }\n      history.push(newPath);\n    }\n\n    function onPlay() {\n      if (items.length === 0) {\n        return;\n      }\n\n      // if there are selected items, play those\n      if (hasSelection) {\n        playSelected();\n        return;\n      }\n\n      // otherwise, play the first item in the list\n      const sceneID = items[0].id;\n      playFirst(queue, sceneID, 0);\n    }\n\n    function onExport(all: boolean) {\n      showModal(\n        <ExportDialog\n          exportInput={{\n            scenes: {\n              ids: Array.from(selectedIds.values()),\n              all: all,\n            },\n          }}\n          onClose={() => closeModal()}\n        />\n      );\n    }\n\n    function onMerge() {\n      const selected =\n        selectedItems.map((s) => {\n          return {\n            id: s.id,\n            title: objectTitle(s),\n          };\n        }) ?? [];\n      showModal(\n        <SceneMergeModal\n          scenes={selected}\n          onClose={(mergedID?: string) => {\n            closeModal();\n            if (mergedID) {\n              history.push(`/scenes/${mergedID}`);\n            }\n          }}\n          show\n        />\n      );\n    }\n\n    const otherOperations = [\n      {\n        text: intl.formatMessage({ id: \"actions.play\" }),\n        onClick: () => onPlay(),\n        isDisplayed: () => items.length > 0,\n        className: \"play-item\",\n      },\n      {\n        text: intl.formatMessage(\n          { id: \"actions.create_entity\" },\n          { entityType: intl.formatMessage({ id: \"scene\" }) }\n        ),\n        onClick: () => onCreateNew(),\n        isDisplayed: () => !hasSelection,\n        className: \"create-new-item\",\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_all\" }),\n        onClick: () => onSelectAll(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_none\" }),\n        onClick: () => onSelectNone(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.invert_selection\" }),\n        onClick: () => onInvertSelection(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.play_random\" }),\n        onClick: playRandom,\n        isDisplayed: () => totalCount > 1,\n      },\n      {\n        text: `${intl.formatMessage({ id: \"actions.generate\" })}…`,\n        onClick: () =>\n          showModal(\n            <GenerateDialog\n              type=\"scene\"\n              selectedIds={Array.from(selectedIds.values())}\n              onClose={() => closeModal()}\n            />\n          ),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: `${intl.formatMessage({ id: \"actions.identify\" })}…`,\n        onClick: () =>\n          showModal(\n            <IdentifyDialog\n              selectedIds={Array.from(selectedIds.values())}\n              onClose={() => closeModal()}\n            />\n          ),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: `${intl.formatMessage({ id: \"actions.merge\" })}…`,\n        onClick: () => onMerge(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export\" }),\n        onClick: () => onExport(false),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export_all\" }),\n        onClick: () => onExport(true),\n      },\n    ];\n\n    // render\n    if (sidebarStateLoading) return null;\n\n    const operations = (\n      <ListOperations\n        items={items.length}\n        hasSelection={hasSelection}\n        operations={otherOperations}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        onPlay={onPlay}\n        operationsMenuClassName=\"scene-list-operations-dropdown\"\n      />\n    );\n\n    return (\n      <TaggerContext>\n        <div\n          className={cx(\"item-list-container scene-list\", {\n            \"hide-sidebar\": !showSidebar,\n          })}\n        >\n          {modal}\n\n          <SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>\n            <SidebarPane hideSidebar={!showSidebar}>\n              <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>\n                <SidebarContent\n                  filter={filter}\n                  setFilter={setFilter}\n                  filterHook={filterHook}\n                  showEditFilter={showEditFilter}\n                  view={view}\n                  sidebarOpen={showSidebar}\n                  onClose={() => setShowSidebar(false)}\n                  count={cachedResult.loading ? undefined : totalCount}\n                  focus={searchFocus}\n                />\n              </Sidebar>\n              <SidebarPaneContent\n                onSidebarToggle={() => setShowSidebar(!showSidebar)}\n              >\n                <FilteredListToolbar\n                  filter={filter}\n                  listSelect={listSelect}\n                  setFilter={setFilter}\n                  showEditFilter={showEditFilter}\n                  onDelete={onDelete}\n                  onEdit={onEdit}\n                  operationComponent={operations}\n                  view={view}\n                  zoomable\n                />\n\n                <FilterTags\n                  criteria={filter.criteria}\n                  onEditCriterion={(c) =>\n                    showEditFilter(c.criterionOption.type)\n                  }\n                  onRemoveCriterion={removeCriterion}\n                  onRemoveAll={clearAllCriteria}\n                />\n\n                <div className=\"pagination-index-container\">\n                  <Pagination\n                    currentPage={filter.currentPage}\n                    itemsPerPage={filter.itemsPerPage}\n                    totalItems={totalCount}\n                    onChangePage={(page) => setFilter(filter.changePage(page))}\n                  />\n                  <PaginationIndex\n                    loading={cachedResult.loading}\n                    itemsPerPage={filter.itemsPerPage}\n                    currentPage={filter.currentPage}\n                    totalItems={totalCount}\n                    metadataByline={metadataByline}\n                  />\n                </div>\n\n                <LoadedContent loading={result.loading} error={result.error}>\n                  <SceneList\n                    filter={effectiveFilter}\n                    scenes={items}\n                    selectedIds={selectedIds}\n                    onSelectChange={onSelectChange}\n                    fromGroupId={fromGroupId}\n                  />\n                </LoadedContent>\n\n                {totalCount > filter.itemsPerPage && (\n                  <div className=\"pagination-footer-container\">\n                    <div className=\"pagination-footer\">\n                      <Pagination\n                        itemsPerPage={filter.itemsPerPage}\n                        currentPage={filter.currentPage}\n                        totalItems={totalCount}\n                        metadataByline={metadataByline}\n                        onChangePage={setPage}\n                        pagePopupPlacement=\"top\"\n                      />\n                    </div>\n                  </div>\n                )}\n              </SidebarPaneContent>\n            </SidebarPane>\n          </SidebarStateContext.Provider>\n        </div>\n      </TaggerContext>\n    );\n  }\n);\n\nexport default FilteredSceneList;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneListTable.tsx",
    "content": "import React from \"react\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport NavUtils from \"src/utils/navigation\";\nimport TextUtils from \"src/utils/text\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { objectTitle } from \"src/core/files\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport SceneQueue from \"src/models/sceneQueue\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport { useSceneUpdate } from \"src/core/StashService\";\nimport { IColumn, ListTable } from \"../List/ListTable\";\nimport { useTableColumns } from \"src/hooks/useTableColumns\";\nimport { FileSize } from \"../Shared/FileSize\";\n\ninterface ISceneListTableProps {\n  scenes: GQL.SlimSceneDataFragment[];\n  queue?: SceneQueue;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nconst TABLE_NAME = \"scenes\";\n\nexport const SceneListTable: React.FC<ISceneListTableProps> = (\n  props: ISceneListTableProps\n) => {\n  const intl = useIntl();\n\n  const [updateScene] = useSceneUpdate();\n\n  function setRating(v: number | null, sceneId: string) {\n    if (sceneId) {\n      updateScene({\n        variables: {\n          input: {\n            id: sceneId,\n            rating100: v,\n          },\n        },\n      });\n    }\n  }\n\n  const CoverImageCell = (scene: GQL.SlimSceneDataFragment, index: number) => {\n    const title = objectTitle(scene);\n    const sceneLink = props.queue\n      ? props.queue.makeLink(scene.id, { sceneIndex: index })\n      : `/scenes/${scene.id}`;\n\n    return (\n      <Link to={sceneLink}>\n        <img\n          loading=\"lazy\"\n          className=\"image-thumbnail\"\n          alt={title}\n          src={scene.paths.screenshot ?? \"\"}\n        />\n      </Link>\n    );\n  };\n\n  const TitleCell = (scene: GQL.SlimSceneDataFragment, index: number) => {\n    const title = objectTitle(scene);\n    const sceneLink = props.queue\n      ? props.queue.makeLink(scene.id, { sceneIndex: index })\n      : `/scenes/${scene.id}`;\n\n    return (\n      <Link to={sceneLink} title={title}>\n        <span className=\"ellips-data\">{title}</span>\n      </Link>\n    );\n  };\n\n  const DateCell = (scene: GQL.SlimSceneDataFragment) => <>{scene.date}</>;\n\n  const RatingCell = (scene: GQL.SlimSceneDataFragment) => (\n    <RatingSystem\n      value={scene.rating100}\n      onSetRating={(value) => setRating(value, scene.id)}\n      clickToRate\n    />\n  );\n\n  const DurationCell = (scene: GQL.SlimSceneDataFragment) => {\n    const file = scene.files.length > 0 ? scene.files[0] : undefined;\n    return file?.duration && TextUtils.secondsToTimestamp(file.duration);\n  };\n\n  const TagCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list overflowable\">\n      {scene.tags.map((tag) => (\n        <li key={tag.id}>\n          <Link to={NavUtils.makeTagScenesUrl(tag)}>\n            <span>{tag.name}</span>\n          </Link>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const PerformersCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list overflowable\">\n      {scene.performers.map((performer) => (\n        <li key={performer.id}>\n          <Link to={NavUtils.makePerformerScenesUrl(performer)}>\n            <span>{performer.name}</span>\n          </Link>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const StudioCell = (scene: GQL.SlimSceneDataFragment) => {\n    if (scene.studio) {\n      return (\n        <Link\n          to={NavUtils.makeStudioScenesUrl(scene.studio)}\n          title={scene.studio.name}\n        >\n          <span className=\"ellips-data\">{scene.studio.name}</span>\n        </Link>\n      );\n    }\n  };\n\n  const GroupCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list overflowable\">\n      {scene.groups.map((sceneGroup) => (\n        <li key={sceneGroup.group.id}>\n          <Link to={NavUtils.makeGroupScenesUrl(sceneGroup.group)}>\n            <span className=\"ellips-data\">{sceneGroup.group.name}</span>\n          </Link>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const GalleriesCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list overflowable\">\n      {scene.galleries.map((gallery) => (\n        <li key={gallery.id}>\n          <Link to={`/galleries/${gallery.id}`}>\n            <span>{galleryTitle(gallery)}</span>\n          </Link>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const PlayCountCell = (scene: GQL.SlimSceneDataFragment) => (\n    <FormattedMessage\n      id=\"plays\"\n      values={{ value: intl.formatNumber(scene.play_count ?? 0) }}\n    />\n  );\n\n  const PlayDurationCell = (scene: GQL.SlimSceneDataFragment) => (\n    <>{TextUtils.secondsToTimestamp(scene.play_duration ?? 0)}</>\n  );\n\n  const ResolutionCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list\">\n      {scene.files.map((file) => (\n        <li key={file.id}>\n          <span> {TextUtils.resolution(file?.width, file?.height)}</span>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const FileSizeCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list\">\n      {scene.files.map((file) => (\n        <li key={file.id}>\n          <FileSize size={file.size} />\n        </li>\n      ))}\n    </ul>\n  );\n\n  const FrameRateCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list\">\n      {scene.files.map((file) => (\n        <li key={file.id}>\n          <span>\n            <FormattedMessage\n              id=\"frames_per_second\"\n              values={{ value: intl.formatNumber(file.frame_rate ?? 0) }}\n            />\n          </span>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const BitRateCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list\">\n      {scene.files.map((file) => (\n        <li key={file.id}>\n          <span>\n            <FormattedMessage\n              id=\"megabits_per_second\"\n              values={{\n                value: intl.formatNumber((file.bit_rate ?? 0) / 1000000, {\n                  maximumFractionDigits: 2,\n                }),\n              }}\n            />\n          </span>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const AudioCodecCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list over\">\n      {scene.files.map((file) => (\n        <li key={file.id}>\n          <span>{file.audio_codec}</span>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const VideoCodecCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"comma-list\">\n      {scene.files.map((file) => (\n        <li key={file.id}>\n          <span>{file.video_codec}</span>\n        </li>\n      ))}\n    </ul>\n  );\n\n  const PathCell = (scene: GQL.SlimSceneDataFragment) => (\n    <ul className=\"newline-list overflowable TruncatedText\">\n      {scene.files.map((file) => (\n        <li key={file.id}>\n          <span>{file.path}</span>\n        </li>\n      ))}\n    </ul>\n  );\n\n  interface IColumnSpec {\n    value: string;\n    label: string;\n    defaultShow?: boolean;\n    mandatory?: boolean;\n    render?: (\n      scene: GQL.SlimSceneDataFragment,\n      index: number\n    ) => React.ReactNode;\n  }\n\n  const allColumns: IColumnSpec[] = [\n    {\n      value: \"cover_image\",\n      label: intl.formatMessage({ id: \"cover_image\" }),\n      defaultShow: true,\n      render: CoverImageCell,\n    },\n    {\n      value: \"title\",\n      label: intl.formatMessage({ id: \"title\" }),\n      defaultShow: true,\n      mandatory: true,\n      render: TitleCell,\n    },\n    {\n      value: \"date\",\n      label: intl.formatMessage({ id: \"date\" }),\n      defaultShow: true,\n      render: DateCell,\n    },\n    {\n      value: \"rating\",\n      label: intl.formatMessage({ id: \"rating\" }),\n      defaultShow: true,\n      render: RatingCell,\n    },\n    {\n      value: \"scene_code\",\n      label: intl.formatMessage({ id: \"scene_code\" }),\n      render: (s) => <>{s.code}</>,\n    },\n    {\n      value: \"duration\",\n      label: intl.formatMessage({ id: \"duration\" }),\n      defaultShow: true,\n      render: DurationCell,\n    },\n    {\n      value: \"studio\",\n      label: intl.formatMessage({ id: \"studio\" }),\n      defaultShow: true,\n      render: StudioCell,\n    },\n    {\n      value: \"performers\",\n      label: intl.formatMessage({ id: \"performers\" }),\n      defaultShow: true,\n      render: PerformersCell,\n    },\n    {\n      value: \"tags\",\n      label: intl.formatMessage({ id: \"tags\" }),\n      defaultShow: true,\n      render: TagCell,\n    },\n    {\n      value: \"groups\",\n      label: intl.formatMessage({ id: \"groups\" }),\n      defaultShow: true,\n      render: GroupCell,\n    },\n    {\n      value: \"galleries\",\n      label: intl.formatMessage({ id: \"galleries\" }),\n      defaultShow: true,\n      render: GalleriesCell,\n    },\n    {\n      value: \"play_count\",\n      label: intl.formatMessage({ id: \"play_count\" }),\n      render: PlayCountCell,\n    },\n    {\n      value: \"play_duration\",\n      label: intl.formatMessage({ id: \"play_duration\" }),\n      render: PlayDurationCell,\n    },\n    {\n      value: \"o_counter\",\n      label: intl.formatMessage({ id: \"o_count\" }),\n      render: (s) => <>{s.o_counter}</>,\n    },\n    {\n      value: \"resolution\",\n      label: intl.formatMessage({ id: \"resolution\" }),\n      render: ResolutionCell,\n    },\n    {\n      value: \"path\",\n      label: intl.formatMessage({ id: \"path\" }),\n      render: PathCell,\n    },\n    {\n      value: \"filesize\",\n      label: intl.formatMessage({ id: \"filesize\" }),\n      render: FileSizeCell,\n    },\n    {\n      value: \"framerate\",\n      label: intl.formatMessage({ id: \"framerate\" }),\n      render: FrameRateCell,\n    },\n    {\n      value: \"bitrate\",\n      label: intl.formatMessage({ id: \"bitrate\" }),\n      render: BitRateCell,\n    },\n    {\n      value: \"video_codec\",\n      label: intl.formatMessage({ id: \"video_codec\" }),\n      render: VideoCodecCell,\n    },\n    {\n      value: \"audio_codec\",\n      label: intl.formatMessage({ id: \"audio_codec\" }),\n      render: AudioCodecCell,\n    },\n  ];\n\n  const defaultColumns = allColumns\n    .filter((col) => col.defaultShow)\n    .map((col) => col.value);\n\n  const { selectedColumns, saveColumns } = useTableColumns(\n    TABLE_NAME,\n    defaultColumns\n  );\n\n  const columnRenderFuncs: Record<\n    string,\n    (scene: GQL.SlimSceneDataFragment, index: number) => React.ReactNode\n  > = {};\n  allColumns.forEach((col) => {\n    if (col.render) {\n      columnRenderFuncs[col.value] = col.render;\n    }\n  });\n\n  function renderCell(\n    column: IColumn,\n    scene: GQL.SlimSceneDataFragment,\n    index: number\n  ) {\n    const render = columnRenderFuncs[column.value];\n\n    if (render) return render(scene, index);\n  }\n\n  return (\n    <ListTable\n      className=\"scene-table\"\n      items={props.scenes}\n      allColumns={allColumns}\n      columns={selectedColumns}\n      setColumns={(c) => saveColumns(c)}\n      selectedIds={props.selectedIds}\n      onSelectChange={props.onSelectChange}\n      renderCell={renderCell}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx",
    "content": "import { useMemo } from \"react\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"../Shared/Icon\";\nimport { TagLink } from \"../Shared/TagLink\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport NavUtils from \"src/utils/navigation\";\nimport TextUtils from \"src/utils/text\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { GridCard } from \"../Shared/GridCard/GridCard\";\nimport { faTag } from \"@fortawesome/free-solid-svg-icons\";\nimport { markerTitle } from \"src/core/markers\";\nimport { Link } from \"react-router-dom\";\nimport { objectTitle } from \"src/core/files\";\nimport { PatchComponent } from \"src/patch\";\nimport { PerformerPopoverButton } from \"../Shared/PerformerPopoverButton\";\nimport { ScenePreview } from \"./SceneCard\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\n\ninterface ISceneMarkerCardProps {\n  marker: GQL.SceneMarkerDataFragment;\n  cardWidth?: number;\n  previewHeight?: number;\n  index?: number;\n  compact?: boolean;\n  selecting?: boolean;\n  selected?: boolean | undefined;\n  zoomIndex?: number;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n}\n\nconst SceneMarkerCardPopovers = PatchComponent(\n  \"SceneMarkerCard.Popovers\",\n  (props: ISceneMarkerCardProps) => {\n    function maybeRenderPerformerPopoverButton() {\n      if (props.marker.scene.performers.length <= 0) return;\n\n      return (\n        <PerformerPopoverButton\n          performers={props.marker.scene.performers}\n          linkType=\"scene_marker\"\n        />\n      );\n    }\n\n    function renderTagPopoverButton() {\n      const popoverContent = [\n        <TagLink\n          key={props.marker.primary_tag.id}\n          tag={props.marker.primary_tag}\n          linkType=\"scene_marker\"\n        />,\n      ];\n\n      props.marker.tags.map((tag) =>\n        popoverContent.push(\n          <TagLink key={tag.id} tag={tag} linkType=\"scene_marker\" />\n        )\n      );\n\n      return (\n        <HoverPopover\n          className=\"tag-count\"\n          placement=\"bottom\"\n          content={popoverContent}\n        >\n          <Button className=\"minimal\">\n            <Icon icon={faTag} />\n            <span>{popoverContent.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function renderPopoverButtonGroup() {\n      if (!props.compact) {\n        return (\n          <>\n            <hr />\n            <ButtonGroup className=\"card-popovers\">\n              {maybeRenderPerformerPopoverButton()}\n              {renderTagPopoverButton()}\n            </ButtonGroup>\n          </>\n        );\n      }\n    }\n\n    return <>{renderPopoverButtonGroup()}</>;\n  }\n);\n\nconst SceneMarkerCardDetails = PatchComponent(\n  \"SceneMarkerCard.Details\",\n  (props: ISceneMarkerCardProps) => {\n    return (\n      <div className=\"scene-marker-card__details\">\n        <span className=\"scene-marker-card__time\">\n          {TextUtils.formatTimestampRange(\n            props.marker.seconds,\n            props.marker.end_seconds ?? undefined\n          )}\n        </span>\n        <TruncatedText\n          className=\"scene-marker-card__scene\"\n          lineCount={3}\n          text={\n            <Link to={NavUtils.makeSceneMarkersSceneUrl(props.marker.scene)}>\n              {objectTitle(props.marker.scene)}\n            </Link>\n          }\n        />\n      </div>\n    );\n  }\n);\n\nconst SceneMarkerCardImage = PatchComponent(\n  \"SceneMarkerCard.Image\",\n  (props: ISceneMarkerCardProps) => {\n    const { configuration } = useConfigurationContext();\n\n    const file = useMemo(\n      () =>\n        props.marker.scene.files.length > 0\n          ? props.marker.scene.files[0]\n          : undefined,\n      [props.marker.scene]\n    );\n\n    function isPortrait() {\n      const width = file?.width ? file.width : 0;\n      const height = file?.height ? file.height : 0;\n      return height > width;\n    }\n\n    function maybeRenderSceneSpecsOverlay() {\n      return (\n        <div className=\"scene-specs-overlay\">\n          {props.marker.end_seconds && (\n            <span className=\"overlay-duration\">\n              {TextUtils.secondsToTimestamp(\n                props.marker.end_seconds - props.marker.seconds\n              )}\n            </span>\n          )}\n        </div>\n      );\n    }\n\n    return (\n      <>\n        <ScenePreview\n          image={props.marker.screenshot ?? undefined}\n          video={props.marker.stream ?? undefined}\n          soundActive={configuration?.interface?.soundOnPreview ?? false}\n          isPortrait={isPortrait()}\n        />\n        {maybeRenderSceneSpecsOverlay()}\n      </>\n    );\n  }\n);\n\nexport const SceneMarkerCard = PatchComponent(\n  \"SceneMarkerCard\",\n  (props: ISceneMarkerCardProps) => {\n    function zoomIndex() {\n      if (!props.compact && props.zoomIndex !== undefined) {\n        return `zoom-${props.zoomIndex}`;\n      }\n\n      return \"\";\n    }\n\n    return (\n      <GridCard\n        className={`scene-marker-card ${zoomIndex()}`}\n        url={NavUtils.makeSceneMarkerUrl(props.marker)}\n        title={markerTitle(props.marker)}\n        width={props.cardWidth}\n        linkClassName=\"scene-marker-card-link\"\n        thumbnailSectionClassName=\"video-section\"\n        resumeTime={props.marker.seconds}\n        image={<SceneMarkerCardImage {...props} />}\n        details={<SceneMarkerCardDetails {...props} />}\n        popovers={<SceneMarkerCardPopovers {...props} />}\n        selected={props.selected}\n        selecting={props.selecting}\n        onSelectedChanged={props.onSelectedChanged}\n      />\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneMarkerCardGrid.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SceneMarkerCard } from \"./SceneMarkerCard\";\nimport {\n  useCardWidth,\n  useContainerDimensions,\n} from \"../Shared/GridCard/GridCard\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface ISceneMarkerCardGrid {\n  markers: GQL.SceneMarkerDataFragment[];\n  selectedIds: Set<string>;\n  zoomIndex: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nconst zoomWidths = [240, 340, 480, 640];\n\nexport const SceneMarkerCardGrid: React.FC<ISceneMarkerCardGrid> =\n  PatchComponent(\n    \"SceneMarkerCardGrid\",\n    ({ markers, selectedIds, zoomIndex, onSelectChange }) => {\n      const [componentRef, { width: containerWidth }] =\n        useContainerDimensions();\n      const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);\n\n      return (\n        <div className=\"row justify-content-center\" ref={componentRef}>\n          {markers.map((marker, index) => (\n            <SceneMarkerCard\n              key={marker.id}\n              cardWidth={cardWidth}\n              marker={marker}\n              index={index}\n              zoomIndex={zoomIndex}\n              selecting={selectedIds.size > 0}\n              selected={selectedIds.has(marker.id)}\n              onSelectedChanged={(selected: boolean, shiftKey: boolean) =>\n                onSelectChange(marker.id, selected, shiftKey)\n              }\n            />\n          ))}\n        </div>\n      );\n    }\n  );\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneMarkerList.tsx",
    "content": "import cloneDeep from \"lodash-es/cloneDeep\";\nimport React, { useCallback, useEffect } from \"react\";\nimport { useHistory } from \"react-router-dom\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindSceneMarkers,\n  useFindSceneMarkers,\n} from \"src/core/StashService\";\nimport NavUtils from \"src/utils/navigation\";\nimport { useFilteredItemList } from \"../List/ItemList\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { MarkerWallPanel } from \"./SceneMarkerWallPanel\";\nimport { View } from \"../List/views\";\nimport { SceneMarkerCardGrid } from \"./SceneMarkerCardGrid\";\nimport { DeleteSceneMarkersDialog } from \"./DeleteSceneMarkersDialog\";\nimport { EditSceneMarkersDialog } from \"./EditSceneMarkersDialog\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport {\n  FilteredListToolbar,\n  IItemListOperation,\n} from \"../List/FilteredListToolbar\";\nimport {\n  Sidebar,\n  SidebarPane,\n  SidebarPaneContent,\n  SidebarStateContext,\n  useSidebarState,\n} from \"../Shared/Sidebar\";\nimport { useCloseEditDelete, useFilterOperations } from \"../List/util\";\nimport {\n  FilteredSidebarHeader,\n  useFilteredSidebarKeybinds,\n} from \"../List/Filters/FilterSidebar\";\nimport { useZoomKeybinds } from \"../List/ZoomSlider\";\nimport {\n  IListFilterOperation,\n  ListOperations,\n} from \"../List/ListOperationButtons\";\nimport cx from \"classnames\";\nimport { FilterTags } from \"../List/FilterTags\";\nimport { Pagination, PaginationIndex } from \"../List/Pagination\";\nimport { LoadedContent } from \"../List/PagedList\";\nimport useFocus from \"src/utils/focus\";\nimport { SidebarPerformersFilter } from \"../List/Filters/PerformersFilter\";\nimport { SidebarTagsFilter } from \"../List/Filters/TagsFilter\";\nimport { Button } from \"react-bootstrap\";\n\nconst SceneMarkerList: React.FC<{\n  markers: GQL.SceneMarkerDataFragment[];\n  filter: ListFilterModel;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}> = PatchComponent(\n  \"SceneMarkerList\",\n  ({ markers, filter, selectedIds, onSelectChange }) => {\n    if (markers.length === 0) {\n      return null;\n    }\n\n    if (filter.displayMode === DisplayMode.Wall) {\n      return (\n        <MarkerWallPanel\n          markers={markers}\n          zoomIndex={filter.zoomIndex}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n\n    if (filter.displayMode === DisplayMode.Grid) {\n      return (\n        <SceneMarkerCardGrid\n          markers={markers}\n          zoomIndex={filter.zoomIndex}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n\n    return null;\n  }\n);\n\nfunction usePlayRandom(filter: ListFilterModel, count: number) {\n  const history = useHistory();\n\n  const playRandom = useCallback(async () => {\n    // query for a random scene\n    if (count === 0) {\n      return;\n    }\n\n    const pages = Math.ceil(count / filter.itemsPerPage);\n    const page = Math.floor(Math.random() * pages) + 1;\n\n    const indexMax = Math.min(filter.itemsPerPage, count);\n    const index = Math.floor(Math.random() * indexMax);\n    const filterCopy = cloneDeep(filter);\n    filterCopy.currentPage = page;\n    filterCopy.sortBy = \"random\";\n    const queryResults = await queryFindSceneMarkers(filterCopy);\n    const marker = queryResults.data.findSceneMarkers.scene_markers[index];\n    if (marker) {\n      // navigate to the scene player page\n      const url = NavUtils.makeSceneMarkerUrl(marker);\n      history.push(url);\n    }\n  }, [filter, count, history]);\n\n  return playRandom;\n}\n\nfunction useAddKeybinds(filter: ListFilterModel, count: number) {\n  const playRandom = usePlayRandom(filter, count);\n\n  useEffect(() => {\n    Mousetrap.bind(\"p r\", () => {\n      playRandom();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"p r\");\n    };\n  }, [playRandom]);\n}\n\nconst ScenesFilterSidebarSections = PatchContainerComponent(\n  \"FilteredSceneMarkerList.SidebarSections\"\n);\n\nconst SidebarContent: React.FC<{\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  sidebarOpen: boolean;\n  onClose?: () => void;\n  showEditFilter: (editingCriterion?: string) => void;\n  count?: number;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  filter,\n  setFilter,\n  filterHook,\n  view,\n  showEditFilter,\n  sidebarOpen,\n  onClose,\n  count,\n  focus,\n}) => {\n  const showResultsId =\n    count !== undefined ? \"actions.show_count_results\" : \"actions.show_results\";\n\n  return (\n    <>\n      <FilteredSidebarHeader\n        sidebarOpen={sidebarOpen}\n        showEditFilter={showEditFilter}\n        filter={filter}\n        setFilter={setFilter}\n        view={view}\n        focus={focus}\n      />\n\n      <ScenesFilterSidebarSections>\n        <SidebarPerformersFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarTagsFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n      </ScenesFilterSidebarSections>\n\n      <div className=\"sidebar-footer\">\n        <Button className=\"sidebar-close-button\" onClick={onClose}>\n          <FormattedMessage id={showResultsId} values={{ count }} />\n        </Button>\n      </div>\n    </>\n  );\n};\n\ninterface ISceneMarkerList {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  alterQuery?: boolean;\n  defaultSort?: string;\n  extraOperations?: IItemListOperation<GQL.FindSceneMarkersQueryResult>[];\n}\n\nexport const FilteredSceneMarkerList = PatchComponent(\n  \"FilteredSceneMarkerList\",\n  (props: ISceneMarkerList) => {\n    const intl = useIntl();\n\n    const searchFocus = useFocus();\n\n    const {\n      filterHook,\n      defaultSort,\n      view,\n      alterQuery,\n      extraOperations = [],\n    } = props;\n\n    // States\n    const {\n      showSidebar,\n      setShowSidebar,\n      loading: sidebarStateLoading,\n      sectionOpen,\n      setSectionOpen,\n    } = useSidebarState(view);\n\n    const { filterState, queryResult, modalState, listSelect, showEditFilter } =\n      useFilteredItemList({\n        filterStateProps: {\n          filterMode: GQL.FilterMode.SceneMarkers,\n          defaultSort,\n          view,\n          useURL: alterQuery,\n        },\n        queryResultProps: {\n          useResult: useFindSceneMarkers,\n          getCount: (r) => r.data?.findSceneMarkers.count ?? 0,\n          getItems: (r) => r.data?.findSceneMarkers.scene_markers ?? [],\n          filterHook,\n        },\n      });\n\n    const { filter, setFilter } = filterState;\n\n    const { effectiveFilter, result, cachedResult, items, totalCount } =\n      queryResult;\n\n    const {\n      selectedIds,\n      selectedItems,\n      onSelectChange,\n      onSelectAll,\n      onSelectNone,\n      onInvertSelection,\n      hasSelection,\n    } = listSelect;\n\n    const { modal, showModal, closeModal } = modalState;\n\n    // Utility hooks\n    const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({\n      filter,\n      setFilter,\n    });\n\n    useAddKeybinds(filter, totalCount);\n    useFilteredSidebarKeybinds({\n      showSidebar,\n      setShowSidebar,\n    });\n\n    const onCloseEditDelete = useCloseEditDelete({\n      closeModal,\n      onSelectNone,\n      result,\n    });\n\n    const onEdit = useCallback(() => {\n      showModal(\n        <EditSceneMarkersDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }, [showModal, selectedItems, onCloseEditDelete]);\n\n    const onDelete = useCallback(() => {\n      showModal(\n        <DeleteSceneMarkersDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }, [showModal, selectedItems, onCloseEditDelete]);\n\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => {\n        if (hasSelection) {\n          onEdit?.();\n        }\n      });\n\n      Mousetrap.bind(\"d d\", () => {\n        if (hasSelection) {\n          onDelete?.();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"d d\");\n      };\n    }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]);\n\n    useZoomKeybinds({\n      zoomIndex: filter.zoomIndex,\n      onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),\n    });\n\n    const playRandom = usePlayRandom(effectiveFilter, totalCount);\n\n    const convertedExtraOperations: IListFilterOperation[] =\n      extraOperations.map((o) => ({\n        ...o,\n        isDisplayed: o.isDisplayed\n          ? () => o.isDisplayed!(result, filter, selectedIds)\n          : undefined,\n        onClick: () => {\n          o.onClick(result, filter, selectedIds);\n        },\n      }));\n\n    const otherOperations = [\n      ...convertedExtraOperations,\n      {\n        text: intl.formatMessage({ id: \"actions.select_all\" }),\n        onClick: () => onSelectAll(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_none\" }),\n        onClick: () => onSelectNone(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.invert_selection\" }),\n        onClick: () => onInvertSelection(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.play_random\" }),\n        onClick: playRandom,\n        isDisplayed: () => totalCount > 1,\n      },\n      // {\n      //   text: `${intl.formatMessage({ id: \"actions.generate\" })}…`,\n      //   onClick: () =>\n      //     showModal(\n      //       <GenerateDialog\n      //         type=\"scene\"\n      //         selectedIds={Array.from(selectedIds.values())}\n      //         onClose={() => closeModal()}\n      //       />\n      //     ),\n      //   isDisplayed: () => hasSelection,\n      // },\n    ];\n\n    // render\n    if (sidebarStateLoading) return null;\n\n    const operations = (\n      <ListOperations\n        items={items.length}\n        hasSelection={hasSelection}\n        operations={otherOperations}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        operationsMenuClassName=\"scene-marker-list-operations-dropdown\"\n      />\n    );\n\n    return (\n      <div\n        className={cx(\"item-list-container scene-list\", {\n          \"hide-sidebar\": !showSidebar,\n        })}\n      >\n        {modal}\n\n        <SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>\n          <SidebarPane hideSidebar={!showSidebar}>\n            <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>\n              <SidebarContent\n                filter={filter}\n                setFilter={setFilter}\n                filterHook={filterHook}\n                showEditFilter={showEditFilter}\n                view={view}\n                sidebarOpen={showSidebar}\n                onClose={() => setShowSidebar(false)}\n                count={cachedResult.loading ? undefined : totalCount}\n                focus={searchFocus}\n              />\n            </Sidebar>\n            <SidebarPaneContent\n              onSidebarToggle={() => setShowSidebar(!showSidebar)}\n            >\n              <FilteredListToolbar\n                filter={filter}\n                listSelect={listSelect}\n                setFilter={setFilter}\n                showEditFilter={showEditFilter}\n                onDelete={onDelete}\n                onEdit={onEdit}\n                operationComponent={operations}\n                view={view}\n                zoomable\n              />\n\n              <FilterTags\n                criteria={filter.criteria}\n                onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}\n                onRemoveCriterion={removeCriterion}\n                onRemoveAll={clearAllCriteria}\n              />\n\n              <div className=\"pagination-index-container\">\n                <Pagination\n                  currentPage={filter.currentPage}\n                  itemsPerPage={filter.itemsPerPage}\n                  totalItems={totalCount}\n                  onChangePage={(page) => setFilter(filter.changePage(page))}\n                />\n                <PaginationIndex\n                  loading={cachedResult.loading}\n                  itemsPerPage={filter.itemsPerPage}\n                  currentPage={filter.currentPage}\n                  totalItems={totalCount}\n                />\n              </div>\n\n              <LoadedContent loading={result.loading} error={result.error}>\n                <SceneMarkerList\n                  filter={effectiveFilter}\n                  markers={items}\n                  selectedIds={selectedIds}\n                  onSelectChange={onSelectChange}\n                />\n              </LoadedContent>\n\n              {totalCount > filter.itemsPerPage && (\n                <div className=\"pagination-footer-container\">\n                  <div className=\"pagination-footer\">\n                    <Pagination\n                      itemsPerPage={filter.itemsPerPage}\n                      currentPage={filter.currentPage}\n                      totalItems={totalCount}\n                      onChangePage={setPage}\n                      pagePopupPlacement=\"top\"\n                    />\n                  </div>\n                </div>\n              )}\n            </SidebarPaneContent>\n          </SidebarPane>\n        </SidebarStateContext.Provider>\n      </div>\n    );\n  }\n);\n\nexport default FilteredSceneMarkerList;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx",
    "content": "import React from \"react\";\nimport { useFindSceneMarkers } from \"src/core/StashService\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { SceneMarkerCard } from \"./SceneMarkerCard\";\nimport { PatchComponent } from \"src/patch\";\nimport { FilteredRecommendationRow } from \"../FrontPage/FilteredRecommendationRow\";\n\ninterface IProps {\n  isTouch: boolean;\n  filter: ListFilterModel;\n  header: string;\n}\n\nexport const SceneMarkerRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"SceneMarkerRecommendationRow\",\n  (props) => {\n    const result = useFindSceneMarkers(props.filter);\n    const count = result.data?.findSceneMarkers.count ?? 0;\n\n    return (\n      <FilteredRecommendationRow\n        className=\"scene-marker-recommendations\"\n        heading={props.header}\n        url={`/scenes/markers?${props.filter.makeQueryParameters()}`}\n        count={count}\n        loading={result.loading}\n        isTouch={props.isTouch}\n        filter={props.filter}\n      >\n        {result.loading\n          ? [...Array(props.filter.itemsPerPage)].map((i) => (\n              <div\n                key={`_${i}`}\n                className=\"scene-marker-skeleton skeleton-card\"\n              ></div>\n            ))\n          : result.data?.findSceneMarkers.scene_markers.map((marker, index) => (\n              <SceneMarkerCard\n                key={marker.id}\n                marker={marker}\n                index={index}\n                zoomIndex={1}\n              />\n            ))}\n      </FilteredRecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx",
    "content": "import React, { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport Gallery, {\n  GalleryI,\n  PhotoProps,\n  RenderImageProps,\n} from \"react-photo-gallery\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { objectTitle } from \"src/core/files\";\nimport { Link, useHistory } from \"react-router-dom\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport TextUtils from \"src/utils/text\";\nimport { useDragMoveSelect } from \"../Shared/GridCard/dragMoveSelect\";\nimport cx from \"classnames\";\nimport NavUtils from \"src/utils/navigation\";\nimport { markerTitle } from \"src/core/markers\";\n\nfunction wallItemTitle(sceneMarker: GQL.SceneMarkerDataFragment) {\n  const newTitle = markerTitle(sceneMarker);\n  const seconds = TextUtils.formatTimestampRange(\n    sceneMarker.seconds,\n    sceneMarker.end_seconds ?? undefined\n  );\n  if (newTitle) {\n    return `${newTitle} - ${seconds}`;\n  } else {\n    return seconds;\n  }\n}\n\ninterface IMarkerPhoto {\n  marker: GQL.SceneMarkerDataFragment;\n  link: string;\n  onError?: (photo: PhotoProps<IMarkerPhoto>) => void;\n}\n\ninterface IExtraProps {\n  maxHeight: number;\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  selecting?: boolean;\n}\n\nexport const MarkerWallItem: React.FC<\n  RenderImageProps<IMarkerPhoto> & IExtraProps\n> = (props: RenderImageProps<IMarkerPhoto> & IExtraProps) => {\n  const { dragProps } = useDragMoveSelect({\n    selecting: props.selecting || false,\n    selected: props.selected || false,\n    onSelectedChanged: props.onSelectedChanged,\n  });\n\n  const { configuration } = useConfigurationContext();\n  const playSound = configuration?.interface.soundOnPreview ?? false;\n  const showTitle = configuration?.interface.wallShowTitle ?? false;\n\n  const [active, setActive] = useState(false);\n\n  const height = Math.min(props.maxHeight, props.photo.height);\n  const zoomFactor = height / props.photo.height;\n  const width = props.photo.width * zoomFactor;\n\n  type style = Record<string, string | number | undefined>;\n  var divStyle: style = {\n    margin: props.margin,\n    display: \"block\",\n  };\n\n  if (props.direction === \"column\") {\n    divStyle.position = \"absolute\";\n    divStyle.left = props.left;\n    divStyle.top = props.top;\n  }\n\n  var handleClick = function handleClick(event: React.MouseEvent) {\n    if (props.selecting && props.onSelectedChanged) {\n      props.onSelectedChanged(!props.selected, event.shiftKey);\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    if (props.onClick) {\n      props.onClick(event, { index: props.index });\n    }\n  };\n\n  const video = props.photo.src.includes(\"stream\");\n  const ImagePreview = video ? \"video\" : \"img\";\n\n  const { marker } = props.photo;\n  const title = wallItemTitle(marker);\n  const tagNames = marker.tags.map((p) => p.name);\n\n  let shiftKey = false;\n\n  return (\n    <div\n      className={cx(\"wall-item\", { \"show-title\": showTitle })}\n      role=\"button\"\n      onClick={handleClick}\n      {...dragProps}\n      style={{\n        ...divStyle,\n        width,\n        height,\n      }}\n    >\n      {props.onSelectedChanged && (\n        <Form.Control\n          type=\"checkbox\"\n          className=\"wall-item-check mousetrap\"\n          checked={props.selected}\n          onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}\n          onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {\n            shiftKey = event.shiftKey;\n            event.stopPropagation();\n          }}\n        />\n      )}\n      <ImagePreview\n        loading=\"lazy\"\n        loop={video}\n        muted={!video || !playSound || !active}\n        autoPlay={video}\n        playsInline={video}\n        key={props.photo.key}\n        src={props.photo.src}\n        width={width}\n        height={height}\n        alt={props.photo.alt}\n        onMouseEnter={() => setActive(true)}\n        onMouseLeave={() => setActive(false)}\n        onClick={handleClick}\n        onError={() => {\n          props.photo.onError?.(props.photo);\n        }}\n      />\n      <div className=\"lineargradient\">\n        <footer className=\"wall-item-footer\">\n          <Link to={props.photo.link} onClick={(e) => e.stopPropagation()}>\n            {title && (\n              <TruncatedText\n                text={title}\n                lineCount={1}\n                className=\"wall-item-title\"\n              />\n            )}\n            <TruncatedText text={tagNames.join(\", \")} />\n          </Link>\n        </footer>\n      </div>\n    </div>\n  );\n};\n\ninterface IMarkerWallProps {\n  markers: GQL.SceneMarkerDataFragment[];\n  zoomIndex: number;\n  selectedIds?: Set<string>;\n  onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;\n  selecting?: boolean;\n}\n\n// HACK: typescript doesn't allow Gallery to accept a parameter for some reason\nconst MarkerGallery = Gallery as unknown as GalleryI<IMarkerPhoto>;\n\nfunction getFirstValidSrc(srcSet: string[], invalidSrcSet: string[]) {\n  if (!srcSet.length) {\n    return \"\";\n  }\n\n  return (\n    srcSet.find((src) => !invalidSrcSet.includes(src)) ??\n    ([...srcSet].pop() as string)\n  );\n}\n\ninterface IFile {\n  width: number;\n  height: number;\n}\n\nfunction getDimensions(file?: IFile) {\n  const defaults = { width: 1280, height: 720 };\n\n  if (!file) return defaults;\n\n  return {\n    width: file.width || defaults.width,\n    height: file.height || defaults.height,\n  };\n}\n\nconst breakpointZoomHeights = [\n  { minWidth: 576, heights: [100, 120, 240, 360] },\n  { minWidth: 768, heights: [120, 160, 240, 480] },\n  { minWidth: 1200, heights: [120, 160, 240, 300] },\n  { minWidth: 1400, heights: [160, 240, 300, 480] },\n];\n\nconst MarkerWall: React.FC<IMarkerWallProps> = ({\n  markers,\n  zoomIndex,\n  selectedIds,\n  onSelectChange,\n  selecting,\n}) => {\n  const history = useHistory();\n\n  const containerRef = React.useRef<HTMLDivElement>(null);\n\n  const margin = 3;\n  const direction = \"row\";\n\n  const [erroredImgs, setErroredImgs] = useState<string[]>([]);\n\n  const handleError = useCallback((photo: PhotoProps<IMarkerPhoto>) => {\n    setErroredImgs((prev) => [...prev, photo.src]);\n  }, []);\n\n  useEffect(() => {\n    setErroredImgs([]);\n  }, [markers]);\n\n  const photos: PhotoProps<IMarkerPhoto>[] = useMemo(() => {\n    return markers.map((m, index) => {\n      const { width = 1280, height = 720 } = getDimensions(m.scene.files[0]);\n\n      return {\n        marker: m,\n        src: getFirstValidSrc([m.stream, m.preview, m.screenshot], erroredImgs),\n        link: NavUtils.makeSceneMarkerUrl(m),\n        width,\n        height,\n        tabIndex: index,\n        key: m.id,\n        loading: \"lazy\",\n        alt: objectTitle(m),\n        onError: handleError,\n      };\n    });\n  }, [markers, erroredImgs, handleError]);\n\n  const onClick = useCallback(\n    (event, { index }) => {\n      history.push(photos[index].link);\n    },\n    [history, photos]\n  );\n\n  function columns(containerWidth: number) {\n    let preferredSize = 300;\n    let columnCount = containerWidth / preferredSize;\n    return Math.round(columnCount);\n  }\n\n  const targetRowHeight = useCallback(\n    (containerWidth: number) => {\n      let zoomHeight = 280;\n      breakpointZoomHeights.forEach((e) => {\n        if (containerWidth >= e.minWidth) {\n          zoomHeight = e.heights[zoomIndex];\n        }\n      });\n      return zoomHeight;\n    },\n    [zoomIndex]\n  );\n\n  // set the max height as a factor of the targetRowHeight\n  // this allows some images to be taller than the target row height\n  // but prevents images from becoming too tall when there is a small number of items\n  const maxHeightFactor = 1.3;\n\n  const renderImage = useCallback(\n    (props: RenderImageProps<IMarkerPhoto>) => {\n      const markerId = props.photo.marker.id;\n      return (\n        <MarkerWallItem\n          {...props}\n          maxHeight={\n            targetRowHeight(containerRef.current?.offsetWidth ?? 0) *\n            maxHeightFactor\n          }\n          selected={selectedIds?.has(markerId)}\n          onSelectedChanged={\n            onSelectChange\n              ? (selected, shiftKey) =>\n                  onSelectChange(markerId, selected, shiftKey)\n              : undefined\n          }\n          selecting={selecting}\n        />\n      );\n    },\n    [targetRowHeight, selectedIds, onSelectChange, selecting]\n  );\n\n  return (\n    <div className=\"marker-wall\" ref={containerRef}>\n      {photos.length ? (\n        <MarkerGallery\n          photos={photos}\n          renderImage={renderImage}\n          onClick={onClick}\n          margin={margin}\n          direction={direction}\n          columns={columns}\n          targetRowHeight={targetRowHeight}\n        />\n      ) : null}\n    </div>\n  );\n};\n\ninterface IMarkerWallPanelProps {\n  markers: GQL.SceneMarkerDataFragment[];\n  zoomIndex: number;\n  selectedIds?: Set<string>;\n  onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nexport const MarkerWallPanel: React.FC<IMarkerWallPanelProps> = ({\n  markers,\n  zoomIndex,\n  selectedIds,\n  onSelectChange,\n}) => {\n  const selecting = !!selectedIds && selectedIds.size > 0;\n  return (\n    <MarkerWall\n      markers={markers}\n      zoomIndex={zoomIndex}\n      selectedIds={selectedIds}\n      onSelectChange={onSelectChange}\n      selecting={selecting}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx",
    "content": "import { Form, Col, Row, Button, FormControl } from \"react-bootstrap\";\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"../Shared/Icon\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { GallerySelect } from \"../Shared/Select\";\nimport * as FormUtils from \"src/utils/form\";\nimport ImageUtils from \"src/utils/image\";\nimport TextUtils from \"src/utils/text\";\nimport {\n  mutateSceneMerge,\n  queryFindFullScenesByID,\n} from \"src/core/StashService\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { faExchangeAlt, faSignInAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport {\n  ScrapeDialogRow,\n  ScrapedCustomFieldRows,\n  ScrapedImageRow,\n  ScrapedInputGroupRow,\n  ScrapedStringListRow,\n  ScrapedTextAreaRow,\n} from \"../Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { ScrapeDialog } from \"../Shared/ScrapeDialog/ScrapeDialog\";\nimport { clone, uniq } from \"lodash-es\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { sortStoredIdObjects, uniqIDStoredIDs } from \"src/utils/data\";\nimport {\n  CustomFieldScrapeResults,\n  ObjectListScrapeResult,\n  ScrapeResult,\n  ZeroableScrapeResult,\n  hasScrapedValues,\n} from \"../Shared/ScrapeDialog/scrapeResult\";\nimport {\n  ScrapedGroupsRow,\n  ScrapedPerformersRow,\n  ScrapedStudioRow,\n  ScrapedTagsRow,\n} from \"../Shared/ScrapeDialog/ScrapedObjectsRow\";\nimport { Scene, SceneSelect } from \"src/components/Scenes/SceneSelect\";\nimport { StashIDsField } from \"../Shared/StashID\";\n\ntype MergeOptions = {\n  values: GQL.SceneUpdateInput;\n  includeViewHistory: boolean;\n  includeOHistory: boolean;\n};\n\ninterface ISceneMergeDetailsProps {\n  sources: GQL.SceneDataFragment[];\n  dest: GQL.SceneDataFragment;\n  onClose: (options?: MergeOptions) => void;\n}\n\nconst SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({\n  sources,\n  dest,\n  onClose,\n}) => {\n  const intl = useIntl();\n\n  const [loading, setLoading] = useState(true);\n\n  const [title, setTitle] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.title)\n  );\n  const [code, setCode] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.code)\n  );\n  const [url, setURL] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(dest.urls)\n  );\n  const [date, setDate] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.date)\n  );\n\n  const [rating, setRating] = useState(\n    new ZeroableScrapeResult<number>(dest.rating100)\n  );\n  // zero values can be treated as missing for these fields\n  const [oCounter, setOCounter] = useState(\n    new ScrapeResult<number>(dest.o_counter)\n  );\n  const [playCount, setPlayCount] = useState(\n    new ScrapeResult<number>(dest.play_count)\n  );\n  const [playDuration, setPlayDuration] = useState(\n    new ScrapeResult<number>(dest.play_duration)\n  );\n\n  function idToStoredID(o: { id: string; name: string }) {\n    return {\n      stored_id: o.id,\n      name: o.name,\n    };\n  }\n\n  function groupToStoredID(o: { group: { id: string; name: string } }) {\n    return {\n      stored_id: o.group.id,\n      name: o.group.name,\n    };\n  }\n\n  const [studio, setStudio] = useState<ScrapeResult<GQL.ScrapedStudio>>(\n    new ScrapeResult<GQL.ScrapedStudio>(\n      dest.studio ? idToStoredID(dest.studio) : undefined\n    )\n  );\n\n  function sortIdList(idList?: string[] | null) {\n    if (!idList) {\n      return;\n    }\n\n    const ret = clone(idList);\n    // sort by id numerically\n    ret.sort((a, b) => {\n      return parseInt(a, 10) - parseInt(b, 10);\n    });\n\n    return ret;\n  }\n\n  const [performers, setPerformers] = useState<\n    ObjectListScrapeResult<GQL.ScrapedPerformer>\n  >(\n    new ObjectListScrapeResult<GQL.ScrapedPerformer>(\n      sortStoredIdObjects(dest.performers.map(idToStoredID))\n    )\n  );\n\n  const [groups, setGroups] = useState<\n    ObjectListScrapeResult<GQL.ScrapedGroup>\n  >(\n    new ObjectListScrapeResult<GQL.ScrapedGroup>(\n      sortStoredIdObjects(dest.groups.map(groupToStoredID))\n    )\n  );\n\n  const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(\n    new ObjectListScrapeResult<GQL.ScrapedTag>(\n      sortStoredIdObjects(dest.tags.map(idToStoredID))\n    )\n  );\n\n  const [details, setDetails] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.details)\n  );\n\n  const [galleries, setGalleries] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(sortIdList(dest.galleries.map((p) => p.id)))\n  );\n\n  const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));\n\n  const [organized, setOrganized] = useState(\n    new ZeroableScrapeResult<boolean>(dest.organized)\n  );\n\n  const [image, setImage] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.paths.screenshot)\n  );\n\n  const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(\n    new Map()\n  );\n\n  // calculate the values for everything\n  // uses the first set value for single value fields, and combines all\n  useEffect(() => {\n    async function loadImages() {\n      const src = sources.find((s) => s.paths.screenshot);\n      if (!dest.paths.screenshot || !src) return;\n\n      setLoading(true);\n\n      const destData = await ImageUtils.imageToDataURL(dest.paths.screenshot);\n      const srcData = await ImageUtils.imageToDataURL(src.paths.screenshot!);\n\n      // keep destination image by default\n      const useNewValue = false;\n      setImage(new ScrapeResult(destData, srcData, useNewValue));\n\n      setLoading(false);\n    }\n\n    // append dest to all so that if dest has stash_ids with the same\n    // endpoint, then it will be excluded first\n    const all = sources.concat(dest);\n\n    setTitle(\n      new ScrapeResult(\n        dest.title,\n        sources.find((s) => s.title)?.title,\n        !dest.title\n      )\n    );\n    setCode(\n      new ScrapeResult(dest.code, sources.find((s) => s.code)?.code, !dest.code)\n    );\n    setURL(new ScrapeResult(dest.urls, uniq(all.map((s) => s.urls).flat())));\n    setDate(\n      new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date)\n    );\n\n    const foundStudio = sources.find((s) => s.studio)?.studio;\n\n    setStudio(\n      new ScrapeResult<GQL.ScrapedStudio>(\n        dest.studio ? idToStoredID(dest.studio) : undefined,\n        foundStudio\n          ? {\n              stored_id: foundStudio.id,\n              name: foundStudio.name,\n            }\n          : undefined,\n        !dest.studio\n      )\n    );\n\n    setPerformers(\n      new ObjectListScrapeResult<GQL.ScrapedPerformer>(\n        sortStoredIdObjects(dest.performers.map(idToStoredID)),\n        uniqIDStoredIDs(all.map((s) => s.performers.map(idToStoredID)).flat())\n      )\n    );\n    setTags(\n      new ObjectListScrapeResult<GQL.ScrapedTag>(\n        sortStoredIdObjects(dest.tags.map(idToStoredID)),\n        uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat())\n      )\n    );\n    setDetails(\n      new ScrapeResult(\n        dest.details,\n        sources.find((s) => s.details)?.details,\n        !dest.details\n      )\n    );\n\n    setGroups(\n      new ObjectListScrapeResult<GQL.ScrapedGroup>(\n        sortStoredIdObjects(dest.groups.map(groupToStoredID)),\n        uniqIDStoredIDs(all.map((s) => s.groups.map(groupToStoredID)).flat())\n      )\n    );\n\n    setGalleries(\n      new ScrapeResult(\n        dest.galleries.map((p) => p.id),\n        uniq(all.map((s) => s.galleries.map((p) => p.id)).flat())\n      )\n    );\n\n    setRating(\n      new ScrapeResult(\n        dest.rating100,\n        sources.find((s) => s.rating100)?.rating100,\n        !dest.rating100\n      )\n    );\n\n    setOCounter(\n      new ScrapeResult(\n        dest.o_counter ?? 0,\n        all.map((s) => s.o_counter ?? 0).reduce((pv, cv) => pv + cv, 0)\n      )\n    );\n\n    setPlayCount(\n      new ScrapeResult(\n        dest.play_count ?? 0,\n        all.map((s) => s.play_count ?? 0).reduce((pv, cv) => pv + cv, 0)\n      )\n    );\n\n    setPlayDuration(\n      new ScrapeResult(\n        dest.play_duration ?? 0,\n        all.map((s) => s.play_duration ?? 0).reduce((pv, cv) => pv + cv, 0)\n      )\n    );\n\n    setOrganized(\n      new ScrapeResult(\n        dest.organized ?? false,\n        sources.every((s) => s.organized)\n      )\n    );\n\n    setStashIDs(\n      new ScrapeResult(\n        dest.stash_ids,\n        all\n          .map((s) => s.stash_ids)\n          .flat()\n          .filter((s, index, a) => {\n            // remove entries with duplicate endpoints\n            return index === a.findIndex((ss) => ss.endpoint === s.endpoint);\n          })\n      )\n    );\n\n    const customFieldNames = new Set<string>(\n      Object.keys(dest.custom_fields ?? {})\n    );\n\n    for (const s of sources) {\n      for (const n of Object.keys(s.custom_fields ?? {})) {\n        customFieldNames.add(n);\n      }\n    }\n\n    setCustomFields(\n      new Map(\n        Array.from(customFieldNames)\n          .sort()\n          .map((field) => {\n            return [\n              field,\n              new ScrapeResult(\n                dest.custom_fields?.[field],\n                sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[\n                  field\n                ],\n                dest.custom_fields?.[field] === undefined\n              ),\n            ];\n          })\n      )\n    );\n\n    loadImages();\n  }, [sources, dest]);\n\n  const hasCustomFieldValues = useMemo(() => {\n    return hasScrapedValues(Array.from(customFields.values()));\n  }, [customFields]);\n\n  // ensure this is updated if fields are changed\n  const hasValues = useMemo(() => {\n    return (\n      hasCustomFieldValues ||\n      hasScrapedValues([\n        title,\n        code,\n        url,\n        date,\n        rating,\n        oCounter,\n        galleries,\n        studio,\n        performers,\n        groups,\n        tags,\n        details,\n        organized,\n        stashIDs,\n        image,\n      ])\n    );\n  }, [\n    title,\n    code,\n    url,\n    date,\n    rating,\n    oCounter,\n    galleries,\n    studio,\n    performers,\n    groups,\n    tags,\n    details,\n    organized,\n    stashIDs,\n    image,\n    hasCustomFieldValues,\n  ]);\n\n  function renderScrapeRows() {\n    if (loading) {\n      return (\n        <div>\n          <LoadingIndicator />\n        </div>\n      );\n    }\n\n    if (!hasValues) {\n      return (\n        <div>\n          <FormattedMessage id=\"dialogs.merge.empty_results\" />\n        </div>\n      );\n    }\n\n    const trueString = intl.formatMessage({ id: \"true\" });\n    const falseString = intl.formatMessage({ id: \"false\" });\n\n    return (\n      <>\n        <ScrapedInputGroupRow\n          field=\"title\"\n          title={intl.formatMessage({ id: \"title\" })}\n          result={title}\n          onChange={(value) => setTitle(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"code\"\n          title={intl.formatMessage({ id: \"scene_code\" })}\n          result={code}\n          onChange={(value) => setCode(value)}\n        />\n        <ScrapedStringListRow\n          field=\"urls\"\n          title={intl.formatMessage({ id: \"urls\" })}\n          result={url}\n          onChange={(value) => setURL(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"date\"\n          title={intl.formatMessage({ id: \"date\" })}\n          placeholder=\"YYYY-MM-DD\"\n          result={date}\n          onChange={(value) => setDate(value)}\n        />\n        <ScrapeDialogRow\n          field=\"rating\"\n          title={intl.formatMessage({ id: \"rating\" })}\n          result={rating}\n          originalField={<RatingSystem value={rating.originalValue} disabled />}\n          newField={<RatingSystem value={rating.newValue} disabled />}\n          onChange={(value) => setRating(value)}\n        />\n        <ScrapeDialogRow\n          field=\"o_count\"\n          title={intl.formatMessage({ id: \"o_count\" })}\n          result={oCounter}\n          originalField={\n            <FormControl\n              value={oCounter.originalValue ?? 0}\n              readOnly\n              onChange={() => {}}\n              className=\"bg-secondary text-white border-secondary\"\n            />\n          }\n          newField={\n            <FormControl\n              value={oCounter.newValue ?? 0}\n              readOnly\n              onChange={() => {}}\n              className=\"bg-secondary text-white border-secondary\"\n            />\n          }\n          onChange={(value) => setOCounter(value)}\n        />\n        <ScrapeDialogRow\n          field=\"play_count\"\n          title={intl.formatMessage({ id: \"play_count\" })}\n          result={playCount}\n          originalField={\n            <FormControl\n              value={playCount.originalValue ?? 0}\n              readOnly\n              onChange={() => {}}\n              className=\"bg-secondary text-white border-secondary\"\n            />\n          }\n          newField={\n            <FormControl\n              value={playCount.newValue ?? 0}\n              readOnly\n              onChange={() => {}}\n              className=\"bg-secondary text-white border-secondary\"\n            />\n          }\n          onChange={(value) => setPlayCount(value)}\n        />\n        <ScrapeDialogRow\n          field=\"play_duration\"\n          title={intl.formatMessage({ id: \"play_duration\" })}\n          result={playDuration}\n          originalField={\n            <FormControl\n              value={TextUtils.secondsToTimestamp(\n                playDuration.originalValue ?? 0\n              )}\n              readOnly\n              onChange={() => {}}\n              className=\"bg-secondary text-white border-secondary\"\n            />\n          }\n          newField={\n            <FormControl\n              value={TextUtils.secondsToTimestamp(playDuration.newValue ?? 0)}\n              readOnly\n              onChange={() => {}}\n              className=\"bg-secondary text-white border-secondary\"\n            />\n          }\n          onChange={(value) => setPlayDuration(value)}\n        />\n        <ScrapeDialogRow\n          field=\"galleries\"\n          title={intl.formatMessage({ id: \"galleries\" })}\n          result={galleries}\n          originalField={\n            <GallerySelect\n              className=\"form-control react-select\"\n              ids={galleries.originalValue ?? []}\n              onSelect={() => {}}\n              isMulti\n              isDisabled\n            />\n          }\n          newField={\n            <GallerySelect\n              className=\"form-control react-select\"\n              ids={galleries.newValue ?? []}\n              onSelect={() => {}}\n              isMulti\n              isDisabled\n            />\n          }\n          onChange={(value) => setGalleries(value)}\n        />\n        <ScrapedStudioRow\n          field=\"studio\"\n          title={intl.formatMessage({ id: \"studios\" })}\n          result={studio}\n          onChange={(value) => setStudio(value)}\n        />\n        <ScrapedPerformersRow\n          field=\"performers\"\n          title={intl.formatMessage({ id: \"performers\" })}\n          result={performers}\n          onChange={(value) => setPerformers(value)}\n          ageFromDate={date.useNewValue ? date.newValue : date.originalValue}\n        />\n        <ScrapedGroupsRow\n          field=\"groups\"\n          title={intl.formatMessage({ id: \"groups\" })}\n          result={groups}\n          onChange={(value) => setGroups(value)}\n        />\n        <ScrapedTagsRow\n          field=\"tags\"\n          title={intl.formatMessage({ id: \"tags\" })}\n          result={tags}\n          onChange={(value) => setTags(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"details\"\n          title={intl.formatMessage({ id: \"details\" })}\n          result={details}\n          onChange={(value) => setDetails(value)}\n        />\n        <ScrapeDialogRow\n          field=\"organized\"\n          title={intl.formatMessage({ id: \"organized\" })}\n          result={organized}\n          originalField={\n            <FormControl\n              value={organized.originalValue ? trueString : falseString}\n              readOnly\n              onChange={() => {}}\n              className=\"bg-secondary text-white border-secondary\"\n            />\n          }\n          newField={\n            <FormControl\n              value={organized.newValue ? trueString : falseString}\n              readOnly\n              onChange={() => {}}\n              className=\"bg-secondary text-white border-secondary\"\n            />\n          }\n          onChange={(value) => setOrganized(value)}\n        />\n        <ScrapeDialogRow\n          field=\"stash_ids\"\n          title={intl.formatMessage({ id: \"stash_id\" })}\n          result={stashIDs}\n          originalField={\n            <StashIDsField values={stashIDs?.originalValue ?? []} />\n          }\n          newField={<StashIDsField values={stashIDs?.newValue ?? []} />}\n          onChange={(value) => setStashIDs(value)}\n          alwaysShow={\n            !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length\n          }\n        />\n        <ScrapedImageRow\n          field=\"cover_image\"\n          title={intl.formatMessage({ id: \"cover_image\" })}\n          className=\"scene-cover\"\n          result={image}\n          onChange={(value) => setImage(value)}\n        />\n        {hasCustomFieldValues && (\n          <ScrapedCustomFieldRows\n            results={customFields}\n            onChange={(newCustomFields) => setCustomFields(newCustomFields)}\n          />\n        )}\n      </>\n    );\n  }\n\n  function createValues(): MergeOptions {\n    const all = [dest, ...sources];\n\n    // only set the cover image if it's different from the existing cover image\n    const coverImage = image.useNewValue ? image.getNewValue() : undefined;\n\n    return {\n      values: {\n        id: dest.id,\n        title: title.getNewValue(),\n        code: code.getNewValue(),\n        urls: url.getNewValue(),\n        date: date.getNewValue(),\n        rating100: rating.getNewValue(),\n        o_counter: oCounter.getNewValue(),\n        play_count: playCount.getNewValue(),\n        play_duration: playDuration.getNewValue(),\n        gallery_ids: galleries.getNewValue(),\n        studio_id: studio.getNewValue()?.stored_id,\n        performer_ids: performers.getNewValue()?.map((p) => p.stored_id!),\n        groups: groups.getNewValue()?.map((m) => {\n          // find the equivalent group in the original scenes\n          const found = all\n            .map((s) => s.groups)\n            .flat()\n            .find((mm) => mm.group.id === m.stored_id);\n          return {\n            group_id: m.stored_id!,\n            scene_index: found!.scene_index,\n          };\n        }),\n        tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),\n        details: details.getNewValue(),\n        organized: organized.getNewValue(),\n        stash_ids: stashIDs.getNewValue(),\n        cover_image: coverImage,\n        custom_fields: {\n          partial: Object.fromEntries(\n            Array.from(customFields.entries()).flatMap(([field, v]) =>\n              v.useNewValue ? [[field, v.getNewValue()]] : []\n            )\n          ),\n        },\n      },\n      includeViewHistory: playCount.getNewValue() !== undefined,\n      includeOHistory: oCounter.getNewValue() !== undefined,\n    };\n  }\n\n  const dialogTitle = intl.formatMessage({\n    id: \"actions.merge\",\n  });\n\n  const destinationLabel = !hasValues\n    ? \"\"\n    : intl.formatMessage({ id: \"dialogs.merge.destination\" });\n  const sourceLabel = !hasValues\n    ? \"\"\n    : intl.formatMessage({ id: \"dialogs.merge.combined\" });\n\n  return (\n    <ScrapeDialog\n      title={dialogTitle}\n      existingLabel={destinationLabel}\n      scrapedLabel={sourceLabel}\n      onClose={(apply) => {\n        if (!apply) {\n          onClose();\n        } else {\n          onClose(createValues());\n        }\n      }}\n    >\n      {renderScrapeRows()}\n    </ScrapeDialog>\n  );\n};\n\ninterface ISceneMergeModalProps {\n  show: boolean;\n  onClose: (mergedID?: string) => void;\n  scenes: { id: string; title: string }[];\n}\n\nexport const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({\n  show,\n  onClose,\n  scenes,\n}) => {\n  const [sourceScenes, setSourceScenes] = useState<Scene[]>([]);\n  const [destScene, setDestScene] = useState<Scene[]>([]);\n\n  const [loadedSources, setLoadedSources] = useState<GQL.SceneDataFragment[]>(\n    []\n  );\n  const [loadedDest, setLoadedDest] = useState<GQL.SceneDataFragment>();\n\n  const [running, setRunning] = useState(false);\n  const [secondStep, setSecondStep] = useState(false);\n\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const title = intl.formatMessage({\n    id: \"actions.merge\",\n  });\n\n  useEffect(() => {\n    if (scenes.length > 0) {\n      // set the first scene as the destination, others as source\n      setDestScene([scenes[0]]);\n\n      if (scenes.length > 1) {\n        setSourceScenes(scenes.slice(1));\n      }\n    }\n  }, [scenes]);\n\n  async function loadScenes() {\n    const sceneIDs = sourceScenes.map((s) => parseInt(s.id));\n    sceneIDs.push(parseInt(destScene[0].id));\n    const query = await queryFindFullScenesByID(sceneIDs);\n    const { scenes: loadedScenes } = query.data.findScenes;\n\n    setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id));\n    setLoadedSources(loadedScenes.filter((s) => s.id !== destScene[0].id));\n    setSecondStep(true);\n  }\n\n  async function onMerge(options: MergeOptions) {\n    const { values, includeViewHistory, includeOHistory } = options;\n    try {\n      setRunning(true);\n      const result = await mutateSceneMerge(\n        destScene[0].id,\n        sourceScenes.map((s) => s.id),\n        values,\n        includeViewHistory,\n        includeOHistory\n      );\n      if (result.data?.sceneMerge) {\n        Toast.success(intl.formatMessage({ id: \"toast.merged_scenes\" }));\n        onClose(destScene[0].id);\n      }\n      onClose();\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setRunning(false);\n    }\n  }\n\n  function canMerge() {\n    return sourceScenes.length > 0 && destScene.length !== 0;\n  }\n\n  function switchScenes() {\n    if (sourceScenes.length && destScene.length) {\n      const newDest = sourceScenes[0];\n      setSourceScenes([...sourceScenes.slice(1), destScene[0]]);\n      setDestScene([newDest]);\n    }\n  }\n\n  if (secondStep && destScene.length > 0) {\n    return (\n      <SceneMergeDetails\n        sources={loadedSources}\n        dest={loadedDest!}\n        onClose={(values) => {\n          setSecondStep(false);\n          if (values) {\n            onMerge(values);\n          } else {\n            onClose();\n          }\n        }}\n      />\n    );\n  }\n\n  return (\n    <ModalComponent\n      show={show}\n      header={title}\n      icon={faSignInAlt}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.next_action\" }),\n        onClick: () => loadScenes(),\n      }}\n      disabled={!canMerge()}\n      cancel={{\n        variant: \"secondary\",\n        onClick: () => onClose(),\n      }}\n      isRunning={running}\n    >\n      <div className=\"form-container row px-3\">\n        <div className=\"col-12 col-lg-6 col-xl-12\">\n          <Form.Group controlId=\"source\" as={Row}>\n            {FormUtils.renderLabel({\n              title: intl.formatMessage({ id: \"dialogs.merge.source\" }),\n              labelProps: {\n                column: true,\n                sm: 3,\n                xl: 12,\n              },\n            })}\n            <Col sm={9} xl={12}>\n              <SceneSelect\n                isMulti\n                onSelect={(items) => setSourceScenes(items)}\n                values={sourceScenes}\n                menuPortalTarget={document.body}\n              />\n            </Col>\n          </Form.Group>\n          <Form.Group\n            controlId=\"switch\"\n            as={Row}\n            className=\"justify-content-center\"\n          >\n            <Button\n              variant=\"secondary\"\n              onClick={() => switchScenes()}\n              disabled={!sourceScenes.length || !destScene.length}\n              title={intl.formatMessage({ id: \"actions.swap\" })}\n            >\n              <Icon className=\"fa-fw\" icon={faExchangeAlt} />\n            </Button>\n          </Form.Group>\n          <Form.Group controlId=\"destination\" as={Row}>\n            {FormUtils.renderLabel({\n              title: intl.formatMessage({\n                id: \"dialogs.merge.destination\",\n              }),\n              labelProps: {\n                column: true,\n                sm: 3,\n                xl: 12,\n              },\n            })}\n            <Col sm={9} xl={12}>\n              <SceneSelect\n                onSelect={(items) => setDestScene(items)}\n                values={destScene}\n                menuPortalTarget={document.body}\n              />\n            </Col>\n          </Form.Group>\n        </div>\n      </div>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { useFindScenes } from \"src/core/StashService\";\nimport { SceneCard } from \"./SceneCard\";\nimport { SceneQueue } from \"src/models/sceneQueue\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { PatchComponent } from \"src/patch\";\nimport { FilteredRecommendationRow } from \"../FrontPage/FilteredRecommendationRow\";\n\ninterface IProps {\n  isTouch: boolean;\n  filter: ListFilterModel;\n  header: string;\n}\n\nexport const SceneRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"SceneRecommendationRow\",\n  (props) => {\n    const result = useFindScenes(props.filter);\n    const count = result.data?.findScenes.count ?? 0;\n\n    const queue = useMemo(() => {\n      return SceneQueue.fromListFilterModel(props.filter);\n    }, [props.filter]);\n\n    return (\n      <FilteredRecommendationRow\n        className=\"scene-recommendations\"\n        heading={props.header}\n        url={`/scenes?${props.filter.makeQueryParameters()}`}\n        count={count}\n        loading={result.loading}\n        isTouch={props.isTouch}\n        filter={props.filter}\n      >\n        {result.loading\n          ? [...Array(props.filter.itemsPerPage)].map((i) => (\n              <div key={`_${i}`} className=\"scene-skeleton skeleton-card\"></div>\n            ))\n          : result.data?.findScenes.scenes.map((scene, index) => (\n              <SceneCard\n                key={scene.id}\n                scene={scene}\n                queue={queue}\n                index={index}\n                zoomIndex={1}\n              />\n            ))}\n      </FilteredRecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneSelect.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport {\n  OptionProps,\n  components as reactSelectComponents,\n  MultiValueGenericProps,\n  SingleValueProps,\n} from \"react-select\";\nimport cx from \"classnames\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindScenesForSelect,\n  queryFindScenesByIDForSelect,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useIntl } from \"react-intl\";\nimport { defaultMaxOptionsShown } from \"src/core/config\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  FilterSelectComponent,\n  IFilterIDProps,\n  IFilterProps,\n  IFilterValueProps,\n  Option as SelectOption,\n  toOption,\n} from \"../Shared/FilterSelect\";\nimport { useCompare } from \"src/hooks/state\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { objectTitle } from \"src/core/files\";\nimport { PatchComponent, PatchFunction } from \"src/patch\";\nimport {\n  ModifierCriterion,\n  CriterionValue,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport { isUUID } from \"src/utils/stashIds\";\nimport { filterByStashID } from \"src/models/list-filter/utils\";\n\nexport type Scene = Pick<GQL.Scene, \"id\" | \"title\" | \"date\" | \"code\"> & {\n  studio?: Pick<GQL.Studio, \"name\"> | null;\n  files?: Pick<GQL.VideoFile, \"path\">[];\n  paths?: Pick<GQL.ScenePathsType, \"screenshot\">;\n};\n\ntype Option = SelectOption<Scene>;\n\ntype ExtraSceneProps = {\n  hoverPlacement?: Placement;\n  excludeIds?: string[];\n  extraCriteria?: Array<ModifierCriterion<CriterionValue>>;\n};\n\ntype FindScenesResult = Awaited<\n  ReturnType<typeof queryFindScenesForSelect>\n>[\"data\"][\"findScenes\"][\"scenes\"];\n\nfunction sortScenesByRelevance(input: string, scenes: FindScenesResult) {\n  return sortByRelevance(input, scenes, objectTitle, (s) => {\n    return s.files.map((f) => f.path);\n  });\n}\n\nconst sceneSelectSort = PatchFunction(\n  \"SceneSelect.sort\",\n  sortScenesByRelevance\n);\n\nconst _SceneSelect: React.FC<\n  IFilterProps & IFilterValueProps<Scene> & ExtraSceneProps\n> = (props) => {\n  const { configuration } = useConfigurationContext();\n  const intl = useIntl();\n  const maxOptionsShown =\n    configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown;\n\n  const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);\n\n  function filterExcluded(scene: Scene) {\n    // HACK - we should probably exclude these in the backend query, but\n    // this will do in the short-term\n    return !exclude.includes(scene.id.toString());\n  }\n\n  async function loadScenes(input: string): Promise<Option[]> {\n    const filter = new ListFilterModel(GQL.FilterMode.Scenes);\n    filter.currentPage = 1;\n    filter.itemsPerPage = maxOptionsShown;\n    filter.sortBy = \"title\";\n    filter.sortDirection = GQL.SortDirectionEnum.Asc;\n\n    filter.criteria = [...(props.extraCriteria ?? [])];\n\n    if (isUUID(input)) {\n      const oldCriteria = filter.criteria;\n\n      filterByStashID(filter, input);\n\n      const query = await queryFindScenesForSelect(filter);\n      const matches = query.data.findScenes.scenes.filter(filterExcluded);\n\n      if (matches.length > 0) {\n        // Matches found, return them immediately.\n        return matches.map(toOption);\n      }\n\n      // If no stash_id matches found, continue with standard name/alias search.\n      filter.criteria = oldCriteria; // Clear stash_id criterion to search by name/alias below.\n    }\n\n    filter.searchTerm = input;\n\n    const query = await queryFindScenesForSelect(filter);\n    const ret = query.data.findScenes.scenes.filter(filterExcluded);\n\n    return sceneSelectSort(input, ret).map(toOption);\n  }\n\n  const SceneOption: React.FC<OptionProps<Option, boolean>> = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    const title = objectTitle(object);\n\n    // if title does not match the input value but the path does, show the path\n    const { inputValue } = optionProps.selectProps;\n    let matchedPath: string | undefined = \"\";\n    if (!title.toLowerCase().includes(inputValue.toLowerCase())) {\n      matchedPath = object.files?.find((a) =>\n        a.path.toLowerCase().includes(inputValue.toLowerCase())\n      )?.path;\n    }\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <span className=\"scene-select-option\">\n          <span className=\"scene-select-row\">\n            {object.paths?.screenshot && (\n              <img\n                className=\"scene-select-image\"\n                src={object.paths.screenshot}\n                loading=\"lazy\"\n              />\n            )}\n\n            <span className=\"scene-select-details\">\n              <TruncatedText\n                className=\"scene-select-title\"\n                text={title}\n                lineCount={1}\n              />\n\n              {object.studio?.name && (\n                <span className=\"scene-select-studio\">\n                  {object.studio?.name}\n                </span>\n              )}\n\n              {object.date && (\n                <span className=\"scene-select-date\">{object.date}</span>\n              )}\n\n              {object.code && (\n                <span className=\"scene-select-code\">{object.code}</span>\n              )}\n            </span>\n          </span>\n\n          {matchedPath && (\n            <span className=\"scene-select-alias\">{`(${matchedPath})`}</span>\n          )}\n        </span>\n      ),\n    };\n\n    return <reactSelectComponents.Option {...thisOptionProps} />;\n  };\n\n  const SceneMultiValueLabel: React.FC<\n    MultiValueGenericProps<Option, boolean>\n  > = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: objectTitle(object),\n    };\n\n    return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;\n  };\n\n  const SceneValueLabel: React.FC<SingleValueProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: <>{objectTitle(object)}</>,\n    };\n\n    return <reactSelectComponents.SingleValue {...thisOptionProps} />;\n  };\n\n  return (\n    <FilterSelectComponent<Scene, boolean>\n      {...props}\n      className={cx(\n        \"scene-select\",\n        {\n          \"scene-select-active\": props.active,\n        },\n        props.className\n      )}\n      loadOptions={loadScenes}\n      components={{\n        Option: SceneOption,\n        MultiValueLabel: SceneMultiValueLabel,\n        SingleValue: SceneValueLabel,\n      }}\n      isMulti={props.isMulti ?? false}\n      placeholder={\n        props.noSelectionString ??\n        intl.formatMessage(\n          { id: \"actions.select_entity\" },\n          {\n            entityType: intl.formatMessage({\n              id: props.isMulti ? \"scenes\" : \"scene\",\n            }),\n          }\n        )\n      }\n      closeMenuOnSelect={!props.isMulti}\n    />\n  );\n};\n\nexport const SceneSelect = PatchComponent(\"SceneSelect\", _SceneSelect);\n\nconst _SceneIDSelect: React.FC<\n  IFilterProps & IFilterIDProps<Scene> & ExtraSceneProps\n> = (props) => {\n  const { ids, onSelect: onSelectValues } = props;\n\n  const [values, setValues] = useState<Scene[]>([]);\n  const idsChanged = useCompare(ids);\n\n  function onSelect(items: Scene[]) {\n    setValues(items);\n    onSelectValues?.(items);\n  }\n\n  async function loadObjectsByID(idsToLoad: string[]): Promise<Scene[]> {\n    const query = await queryFindScenesByIDForSelect(idsToLoad);\n    const { scenes: loadedScenes } = query.data.findScenes;\n\n    return loadedScenes;\n  }\n\n  useEffect(() => {\n    if (!idsChanged) {\n      return;\n    }\n\n    if (!ids || ids?.length === 0) {\n      setValues([]);\n      return;\n    }\n\n    // load the values if we have ids and they haven't been loaded yet\n    const filteredValues = values.filter((v) => ids.includes(v.id.toString()));\n    if (filteredValues.length === ids.length) {\n      return;\n    }\n\n    const load = async () => {\n      const items = await loadObjectsByID(ids);\n      setValues(items);\n    };\n\n    load();\n  }, [ids, idsChanged, values]);\n\n  return <SceneSelect {...props} values={values} onSelect={onSelect} />;\n};\n\nexport const SceneIDSelect = PatchComponent(\"SceneIDSelect\", _SceneIDSelect);\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/SceneWallPanel.tsx",
    "content": "import React, {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SceneQueue } from \"src/models/sceneQueue\";\nimport Gallery, {\n  GalleryI,\n  PhotoProps,\n  RenderImageProps,\n} from \"react-photo-gallery\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { objectTitle } from \"src/core/files\";\nimport { Link, useHistory } from \"react-router-dom\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport TextUtils from \"src/utils/text\";\nimport { useIntl } from \"react-intl\";\nimport { useDragMoveSelect } from \"../Shared/GridCard/dragMoveSelect\";\nimport cx from \"classnames\";\nimport { defaultPreviewVolume } from \"src/core/config\";\n\ninterface IScenePhoto {\n  scene: GQL.SlimSceneDataFragment;\n  link: string;\n  onError?: (photo: PhotoProps<IScenePhoto>) => void;\n}\n\ninterface IExtraProps {\n  maxHeight: number;\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  selecting?: boolean;\n}\n\nexport const SceneWallItem: React.FC<\n  RenderImageProps<IScenePhoto> & IExtraProps\n> = (props: RenderImageProps<IScenePhoto> & IExtraProps) => {\n  const intl = useIntl();\n\n  const { dragProps } = useDragMoveSelect({\n    selecting: props.selecting || false,\n    selected: props.selected || false,\n    onSelectedChanged: props.onSelectedChanged,\n  });\n\n  const { configuration } = useConfigurationContext();\n  const playSound = configuration?.interface.soundOnPreview ?? false;\n  const volume = configuration?.ui.previewVolume ?? defaultPreviewVolume;\n  const showTitle = configuration?.interface.wallShowTitle ?? false;\n\n  const height = Math.min(props.maxHeight, props.photo.height);\n  const zoomFactor = height / props.photo.height;\n  const width = props.photo.width * zoomFactor;\n\n  const [active, setActive] = useState(false);\n\n  type style = Record<string, string | number | undefined>;\n  var divStyle: style = {\n    margin: props.margin,\n    display: \"block\",\n  };\n\n  if (props.direction === \"column\") {\n    divStyle.position = \"absolute\";\n    divStyle.left = props.left;\n    divStyle.top = props.top;\n  }\n\n  var handleClick = function handleClick(event: React.MouseEvent) {\n    if (props.selecting && props.onSelectedChanged) {\n      props.onSelectedChanged(!props.selected, event.shiftKey);\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    if (props.onClick) {\n      props.onClick(event, { index: props.index });\n    }\n  };\n\n  const video = props.photo.src.includes(\"preview\");\n  const previewProps = {\n    loading: \"lazy\",\n    loop: video,\n    muted: !video || !playSound || !active,\n    autoPlay: video,\n    playsInline: video,\n    key: props.photo.key,\n    src: props.photo.src,\n    width,\n    height,\n    alt: props.photo.alt,\n    onMouseEnter: () => setActive(true),\n    onMouseLeave: () => setActive(false),\n    onClick: handleClick,\n    onError: () => {\n      props.photo.onError?.(props.photo);\n    },\n  };\n\n  const videoEl = useRef<HTMLVideoElement>(null);\n\n  useEffect(() => {\n    if (video && videoEl?.current?.volume)\n      videoEl.current.volume = playSound ? volume / 100 : 0;\n  }, [video, playSound, volume]);\n\n  const { scene } = props.photo;\n  const title = objectTitle(scene);\n  const performerNames = scene.performers.map((p) => p.name);\n  const performers =\n    performerNames.length >= 2\n      ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(\" & \")]\n      : performerNames;\n\n  let shiftKey = false;\n\n  return (\n    <div\n      className={cx(\"wall-item\", { \"show-title\": showTitle })}\n      role=\"button\"\n      onClick={handleClick}\n      {...dragProps}\n      style={{\n        ...divStyle,\n        width,\n        height,\n      }}\n    >\n      {props.onSelectedChanged && (\n        <Form.Control\n          type=\"checkbox\"\n          className=\"wall-item-check mousetrap\"\n          checked={props.selected}\n          onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}\n          onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {\n            shiftKey = event.shiftKey;\n            event.stopPropagation();\n          }}\n        />\n      )}\n      {video ? (\n        <video {...previewProps} ref={videoEl} />\n      ) : (\n        <img {...previewProps} loading=\"lazy\" />\n      )}\n      <div className=\"lineargradient\">\n        <footer className=\"wall-item-footer\">\n          <Link\n            to={props.photo.link}\n            onClick={(e) => {\n              if (props.selecting) {\n                e.preventDefault();\n                handleClick(e);\n              }\n              e.stopPropagation();\n            }}\n          >\n            {title && (\n              <TruncatedText\n                text={title}\n                lineCount={1}\n                className=\"wall-item-title\"\n              />\n            )}\n            <TruncatedText text={performers.join(\", \")} />\n            <div>\n              {scene.date && TextUtils.formatFuzzyDate(intl, scene.date)}\n            </div>\n          </Link>\n        </footer>\n      </div>\n    </div>\n  );\n};\n\nfunction getDimensions(s: GQL.SlimSceneDataFragment) {\n  const defaults = { width: 1280, height: 720 };\n\n  if (!s.files.length) return defaults;\n\n  return {\n    width: s.files[0].width || defaults.width,\n    height: s.files[0].height || defaults.height,\n  };\n}\n\ninterface ISceneWallProps {\n  scenes: GQL.SlimSceneDataFragment[];\n  sceneQueue?: SceneQueue;\n  zoomIndex: number;\n  selectedIds?: Set<string>;\n  onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;\n  selecting?: boolean;\n}\n\n// HACK: typescript doesn't allow Gallery to accept a parameter for some reason\nconst SceneGallery = Gallery as unknown as GalleryI<IScenePhoto>;\n\nconst breakpointZoomHeights = [\n  { minWidth: 576, heights: [100, 120, 240, 360] },\n  { minWidth: 768, heights: [120, 160, 240, 480] },\n  { minWidth: 1200, heights: [120, 160, 240, 300] },\n  { minWidth: 1400, heights: [160, 240, 300, 480] },\n];\n\nconst SceneWall: React.FC<ISceneWallProps> = ({\n  scenes,\n  sceneQueue,\n  zoomIndex,\n  selectedIds,\n  onSelectChange,\n  selecting,\n}) => {\n  const history = useHistory();\n\n  const containerRef = React.useRef<HTMLDivElement>(null);\n\n  const margin = 3;\n  const direction = \"row\";\n\n  const [erroredImgs, setErroredImgs] = useState<string[]>([]);\n\n  const handleError = useCallback((photo: PhotoProps<IScenePhoto>) => {\n    setErroredImgs((prev) => [...prev, photo.src]);\n  }, []);\n\n  useEffect(() => {\n    setErroredImgs([]);\n  }, [scenes]);\n\n  const photos: PhotoProps<IScenePhoto>[] = useMemo(() => {\n    return scenes.map((s, index) => {\n      const { width, height } = getDimensions(s);\n\n      return {\n        scene: s,\n        src:\n          s.paths.preview && !erroredImgs.includes(s.paths.preview)\n            ? s.paths.preview!\n            : s.paths.screenshot!,\n        link: sceneQueue\n          ? sceneQueue.makeLink(s.id, { sceneIndex: index })\n          : `/scenes/${s.id}`,\n        width,\n        height,\n        tabIndex: index,\n        key: s.id,\n        loading: \"lazy\",\n        alt: objectTitle(s),\n        onError: handleError,\n      };\n    });\n  }, [scenes, sceneQueue, erroredImgs, handleError]);\n\n  const onClick = useCallback(\n    (event, { index }) => {\n      history.push(photos[index].link);\n    },\n    [history, photos]\n  );\n\n  function columns(containerWidth: number) {\n    let preferredSize = 300;\n    let columnCount = containerWidth / preferredSize;\n    return Math.round(columnCount);\n  }\n\n  const targetRowHeight = useCallback(\n    (containerWidth: number) => {\n      let zoomHeight = 280;\n      breakpointZoomHeights.forEach((e) => {\n        if (containerWidth >= e.minWidth) {\n          zoomHeight = e.heights[zoomIndex];\n        }\n      });\n      return zoomHeight;\n    },\n    [zoomIndex]\n  );\n\n  // set the max height as a factor of the targetRowHeight\n  // this allows some images to be taller than the target row height\n  // but prevents images from becoming too tall when there is a small number of items\n  const maxHeightFactor = 1.3;\n\n  const renderImage = useCallback(\n    (props: RenderImageProps<IScenePhoto>) => {\n      const sceneId = props.photo.scene.id;\n      return (\n        <SceneWallItem\n          {...props}\n          maxHeight={\n            targetRowHeight(containerRef.current?.offsetWidth ?? 0) *\n            maxHeightFactor\n          }\n          selected={selectedIds?.has(sceneId)}\n          onSelectedChanged={\n            onSelectChange\n              ? (selected, shiftKey) =>\n                  onSelectChange(sceneId, selected, shiftKey)\n              : undefined\n          }\n          selecting={selecting}\n        />\n      );\n    },\n    [targetRowHeight, selectedIds, onSelectChange, selecting]\n  );\n\n  return (\n    <div className={`scene-wall`} ref={containerRef}>\n      {photos.length ? (\n        <SceneGallery\n          photos={photos}\n          renderImage={renderImage}\n          onClick={onClick}\n          margin={margin}\n          direction={direction}\n          columns={columns}\n          targetRowHeight={targetRowHeight}\n        />\n      ) : null}\n    </div>\n  );\n};\n\ninterface ISceneWallPanelProps {\n  scenes: GQL.SlimSceneDataFragment[];\n  sceneQueue?: SceneQueue;\n  zoomIndex: number;\n  selectedIds?: Set<string>;\n  onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nexport const SceneWallPanel: React.FC<ISceneWallPanelProps> = ({\n  scenes,\n  sceneQueue,\n  zoomIndex,\n  selectedIds,\n  onSelectChange,\n}) => {\n  const selecting = !!selectedIds && selectedIds.size > 0;\n  return (\n    <SceneWall\n      scenes={scenes}\n      sceneQueue={sceneQueue}\n      zoomIndex={zoomIndex}\n      selectedIds={selectedIds}\n      onSelectChange={onSelectChange}\n      selecting={selecting}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/Scenes.tsx",
    "content": "import React from \"react\";\nimport { Route, Switch } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport { useTitleProps } from \"src/hooks/title\";\nimport { lazyComponent } from \"src/utils/lazyComponent\";\nimport { View } from \"../List/views\";\n\nconst SceneList = lazyComponent(() => import(\"./SceneList\"));\nconst SceneMarkerList = lazyComponent(() => import(\"./SceneMarkerList\"));\nconst Scene = lazyComponent(() => import(\"./SceneDetails/Scene\"));\nconst SceneCreate = lazyComponent(() => import(\"./SceneDetails/SceneCreate\"));\n\nconst Scenes: React.FC = () => {\n  return <SceneList view={View.Scenes} />;\n};\n\nconst SceneMarkers: React.FC = () => {\n  const titleProps = useTitleProps({ id: \"markers\" });\n  return (\n    <>\n      <Helmet {...titleProps} />\n      <SceneMarkerList view={View.SceneMarkers} />\n    </>\n  );\n};\n\nconst SceneRoutes: React.FC = () => {\n  const titleProps = useTitleProps({ id: \"scenes\" });\n  return (\n    <>\n      <Helmet {...titleProps} />\n      <Switch>\n        <Route exact path=\"/scenes\" component={Scenes} />\n        <Route exact path=\"/scenes/markers\" component={SceneMarkers} />\n        <Route exact path=\"/scenes/new\" component={SceneCreate} />\n        <Route path=\"/scenes/:id\" component={Scene} />\n      </Switch>\n    </>\n  );\n};\n\nexport default SceneRoutes;\n"
  },
  {
    "path": "ui/v2.5/src/components/Scenes/styles.scss",
    "content": ".card-popovers {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: center;\n  margin-bottom: 10px;\n\n  .btn {\n    padding-bottom: 3px;\n    padding-top: 3px;\n  }\n\n  .fa-icon {\n    margin-right: 7px;\n  }\n}\n\n.video-section {\n  position: relative;\n}\n\n.card-section {\n  margin-bottom: 0;\n  padding: 0.5rem 1rem 0 1rem;\n}\n\n.performer-tag-container,\n.group-tag-container {\n  display: inline-block;\n  margin: 5px;\n}\n\n.performer-tag-container .performer-disambiguation {\n  color: initial;\n}\n\n.performer-tag.image,\n.group-tag.image {\n  background-position: center;\n  background-repeat: no-repeat;\n  background-size: cover;\n  height: 150px;\n  margin: 0 auto;\n  width: 100%;\n}\n\n.operation-container {\n  .operation-item {\n    min-width: 240px;\n  }\n\n  .rating-operation {\n    min-width: 20px;\n  }\n\n  .apply-operation {\n    margin-top: 2rem;\n  }\n}\n\n.studio-logo {\n  margin-top: 1rem;\n  max-height: 8rem;\n  max-width: 100%;\n}\n\n@include media-breakpoint-only(lg) {\n  .scene-header-container {\n    align-items: center;\n    display: flex;\n    justify-content: space-between;\n\n    .scene-header {\n      flex: 0 0 75%;\n      order: 1;\n    }\n\n    .scene-studio-image {\n      flex: 0 0 25%;\n      order: 2;\n    }\n  }\n}\n\n.scene-header {\n  flex-basis: auto;\n  font-size: 1.5rem;\n  margin-top: 30px;\n\n  @include media-breakpoint-down(xl) {\n    font-size: 1.75rem;\n  }\n}\n\n.scene-subheader {\n  display: flex;\n  justify-content: space-between;\n  margin-top: 0.5rem;\n\n  .date {\n    color: $text-muted;\n  }\n\n  .resolution {\n    font-weight: bold;\n  }\n}\n\n.scene-toolbar {\n  align-items: center;\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 0.25rem;\n  margin-top: 0.5rem;\n  padding-bottom: 0.25rem;\n  width: 100%;\n\n  .scene-toolbar-group {\n    align-items: center;\n    column-gap: 0.25rem;\n    display: flex;\n    width: 100%;\n\n    &:last-child {\n      justify-content: flex-end;\n    }\n  }\n}\n\n#scene-details-container {\n  .tab-content {\n    min-height: 15rem;\n  }\n}\n\ntextarea.scene-description {\n  min-height: 150px;\n}\n\n.primary-card {\n  margin: 1rem 0;\n\n  &-body {\n    max-height: 15rem;\n    overflow-y: auto;\n  }\n}\n\n.justify-content-center .studio-card .studio-card-image {\n  width: 100%;\n}\n\n.studio-card {\n  padding: 0.5rem;\n\n  @media (max-width: 576px) {\n    width: 100%;\n  }\n\n  &-header {\n    height: 150px;\n    line-height: 150px;\n    text-align: center;\n  }\n\n  &-image {\n    max-height: 150px;\n    object-fit: contain;\n    vertical-align: middle;\n    width: 320px;\n\n    @media (max-width: 576px) {\n      width: 100%;\n    }\n  }\n}\n\n.scene-specs-overlay,\n.scene-interactive-speed-overlay {\n  bottom: 1rem;\n  color: $text-color;\n  display: block;\n  font-weight: 400;\n  letter-spacing: -0.03rem;\n  position: absolute;\n  right: 0.7rem;\n  text-shadow: 0 0 3px #000;\n}\n\n.scene-specs-overlay {\n  right: 0.7rem;\n}\n\n.scene-specs-overlay > span:not(:last-child) {\n  margin-right: 0.3rem;\n}\n\n.scene-interactive-speed-overlay {\n  left: 0.7rem;\n}\n\n.extra-scene-info {\n  display: none;\n}\n\n.overlay-resolution {\n  font-weight: 900;\n  text-transform: uppercase;\n}\n\n.scene-card {\n  &-preview {\n    aspect-ratio: 16/9;\n  }\n\n  .scene-group-scene-number {\n    text-align: center;\n  }\n}\n\n.scene-card,\n.scene-marker-card,\n.gallery-card {\n  .scene-specs-overlay {\n    transition: opacity 0.5s;\n  }\n\n  &-preview {\n    display: flex;\n    justify-content: center;\n    margin-bottom: 5px;\n    position: relative;\n\n    &-image,\n    &-video {\n      height: 100%;\n      object-fit: cover;\n      object-position: top;\n      width: 100%;\n    }\n\n    &-video {\n      position: absolute;\n      top: -9999px;\n      transition: top 0s;\n      transition-delay: 0s;\n    }\n\n    &.portrait {\n      .scene-card-preview-image,\n      .scene-card-preview-video {\n        object-fit: contain;\n      }\n    }\n  }\n\n  &__details {\n    margin-bottom: 1rem;\n  }\n\n  &:hover,\n  &:active {\n    .scene-specs-overlay {\n      opacity: 0;\n      transition: opacity 0.5s;\n    }\n\n    .scene-card-check {\n      opacity: 0.75;\n      transition: opacity 0.5s;\n    }\n\n    .scene-card-preview-video {\n      top: 0;\n      transition-delay: 0.2s;\n    }\n  }\n}\n\n.scene-card.card,\n.scene-marker-card.card {\n  overflow: hidden;\n  padding: 0;\n\n  @media (max-width: 576px) {\n    width: 100%;\n  }\n\n  &.fileless {\n    background-color: darken($card-bg, 5%);\n  }\n}\n\n.scene-cover {\n  display: block;\n  margin-bottom: 10px;\n  margin-top: 10px;\n  max-width: 100%;\n}\n\n.group-image {\n  max-width: 100%;\n}\n\n.group-table {\n  width: 100%;\n\n  .group-row {\n    align-items: center;\n    margin-bottom: 0.25rem;\n  }\n\n  .group-scene-number-header {\n    color: $text-muted;\n    font-size: 0.8em;\n    padding-bottom: 0;\n    padding-top: 0;\n  }\n}\n\n.group-table.no-groups .group-table-header {\n  display: none;\n}\n\n.scene-tabs {\n  display: flex;\n  flex-direction: column;\n  max-height: calc(100vh - 4rem);\n  overflow-wrap: break-word;\n  word-wrap: break-word;\n\n  > div {\n    flex: 0 1 auto;\n  }\n}\n\n/* stylelint-disable selector-class-pattern */\n.table .cover_image-head,\n.table .cover_image-data {\n  text-align: center;\n}\n\ninput[type=\"range\"].filter-slider {\n  height: 100%;\n  margin: 0;\n  padding-left: 0;\n  padding-right: 0;\n}\n\n.filter-slider-value {\n  cursor: pointer;\n}\n\n@mixin contrast-slider() {\n  background: rgb(255, 255, 255);\n  background: linear-gradient(\n      -1deg,\n      rgba(255, 255, 255, 1) 0%,\n      rgba(255, 255, 255, 1) 40%,\n      rgba(0, 0, 0, 1) 60%,\n      rgba(0, 0, 0, 1) 100%\n    ),\n    linear-gradient(90deg, rgba(61, 61, 61, 1) 0%, rgba(255, 255, 255, 0) 100%);\n  background-blend-mode: color;\n}\n\ninput[type=\"range\"].contrast-slider {\n  &::-webkit-slider-runnable-track {\n    @include contrast-slider;\n  }\n\n  &::-moz-range-track {\n    @include contrast-slider;\n  }\n\n  &::-ms-track {\n    @include contrast-slider;\n  }\n}\n\n@mixin brightness-slider() {\n  background: rgb(41, 41, 41);\n  background: linear-gradient(\n    90deg,\n    rgba(41, 41, 41, 1) 0%,\n    rgba(255, 255, 255, 1) 100%\n  );\n}\n\ninput[type=\"range\"].brightness-slider {\n  &::-webkit-slider-runnable-track {\n    @include brightness-slider;\n  }\n\n  &::-moz-range-track {\n    @include brightness-slider;\n  }\n\n  &::-ms-track {\n    @include brightness-slider;\n  }\n}\n\n@mixin saturation-slider() {\n  background: rgb(198, 198, 199);\n  background: linear-gradient(\n    90deg,\n    rgba(198, 198, 199, 1) 0%,\n    rgba(255, 71, 71, 1) 100%\n  );\n}\n\ninput[type=\"range\"].saturation-slider {\n  &::-webkit-slider-runnable-track {\n    @include saturation-slider;\n  }\n\n  &::-moz-range-track {\n    @include saturation-slider;\n  }\n\n  &::-ms-track {\n    @include saturation-slider;\n  }\n}\n\n@mixin hue-rotate-slider() {\n  background: rgb(198, 198, 199);\n  background: linear-gradient(\n    to right,\n    orange,\n    yellow,\n    green,\n    cyan,\n    blue,\n    violet\n  );\n}\n\ninput[type=\"range\"].hue-rotate-slider {\n  &::-webkit-slider-runnable-track {\n    @include hue-rotate-slider;\n  }\n\n  &::-moz-range-track {\n    @include hue-rotate-slider;\n  }\n\n  &::-ms-track {\n    @include hue-rotate-slider;\n  }\n}\n\n@mixin white-balance-slider() {\n  background: rgb(90, 138, 210);\n  background: linear-gradient(\n    90deg,\n    rgba(90, 138, 210, 1) 0%,\n    rgba(83, 72, 72, 1) 50%,\n    rgba(252, 186, 8, 1) 100%\n  );\n}\n\ninput[type=\"range\"].white-balance-slider {\n  &::-webkit-slider-runnable-track {\n    @include white-balance-slider;\n  }\n\n  &::-moz-range-track {\n    @include white-balance-slider;\n  }\n\n  &::-ms-track {\n    @include white-balance-slider;\n  }\n}\n\n@mixin red-slider() {\n  background: rgb(255, 0, 0);\n}\n\ninput[type=\"range\"].red-slider {\n  &::-webkit-slider-runnable-track {\n    @include red-slider;\n  }\n\n  &::-moz-range-track {\n    @include red-slider;\n  }\n\n  &::-ms-track {\n    @include red-slider;\n  }\n}\n\n@mixin green-slider() {\n  background: rgb(0, 255, 0);\n}\n\ninput[type=\"range\"].green-slider {\n  &::-webkit-slider-runnable-track {\n    @include green-slider;\n  }\n\n  &::-moz-range-track {\n    @include green-slider;\n  }\n\n  &::-ms-track {\n    @include green-slider;\n  }\n}\n\n@mixin blue-slider() {\n  background: rgb(0, 0, 255);\n}\n\ninput[type=\"range\"].blue-slider {\n  &::-webkit-slider-runnable-track {\n    @include blue-slider;\n  }\n\n  &::-moz-range-track {\n    @include blue-slider;\n  }\n\n  &::-ms-track {\n    @include blue-slider;\n  }\n}\n\n@media (min-width: 1200px), (max-width: 575px) {\n  .performer-card .fi {\n    height: 1.33rem;\n    width: 2rem;\n  }\n\n  .scene-performers {\n    .performer-card {\n      width: 15rem;\n\n      &-image {\n        height: 22.5rem;\n      }\n    }\n  }\n}\n\n#scene-edit-details {\n  .rating-stars {\n    font-size: 1.3em;\n    height: calc(1.5em + 0.75rem + 2px);\n  }\n\n  .edit-buttons-container {\n    background-color: #202b33;\n    position: sticky;\n    top: 0;\n    z-index: 3;\n\n    @media (min-width: 575px) and (max-width: 1199px) {\n      top: 3rem;\n    }\n  }\n\n  .form-group[data-field=\"urls\"] .string-list-input input.form-control {\n    font-size: 0.85em;\n  }\n\n  @include media-breakpoint-up(xl) {\n    .custom-fields-input {\n      .custom-fields-field {\n        flex: 0 0 25%;\n        max-width: 25%;\n      }\n\n      .custom-fields-value {\n        flex: 0 0 75%;\n        max-width: 75%;\n      }\n    }\n  }\n}\n\n.scene-markers-panel {\n  .wall .wall-item {\n    height: inherit;\n    min-height: 14rem;\n    width: calc(100% - 2rem);\n\n    &-missing {\n      font-size: 1.5rem;\n    }\n\n    &::before {\n      display: none;\n    }\n\n    &:hover {\n      .wall-item-container {\n        transform: scale(1);\n      }\n    }\n  }\n}\n\n.organized-button {\n  &.not-organized {\n    color: rgba(191, 204, 214, 0.5);\n  }\n\n  &.organized {\n    color: #664c3f;\n  }\n}\n\n.o-counter .dropdown-toggle {\n  background-color: rgba(0, 0, 0, 0);\n  border: none;\n  padding-left: 0;\n  padding-right: 0.25rem;\n}\n\n@media (min-width: 1200px) {\n  #queue-viewer {\n    .queue-scene-details {\n      width: 245px;\n    }\n\n    .queue-scene-title,\n    .queue-scene-studio,\n    .queue-scene-performers,\n    .queue-scene-date {\n      margin-right: auto;\n      min-width: 245px;\n      overflow: hidden;\n      position: relative;\n      transform: translateX(0);\n      transition: 2s;\n      white-space: nowrap;\n    }\n\n    .queue-scene-title:hover,\n    .queue-scene-studio:hover,\n    .queue-scene-performers:hover,\n    .queue-scene-date:hover {\n      transform: translateX(calc(245px - 100%));\n    }\n  }\n}\n\n#queue-viewer {\n  .queue-controls {\n    align-items: center;\n    background-color: $body-bg;\n    display: flex;\n    flex: 0 1 auto;\n    height: 30px;\n    justify-content: space-between;\n    position: sticky;\n    top: 0;\n    z-index: 100;\n  }\n\n  .queue-scene-details {\n    display: grid;\n    overflow: hidden;\n    position: relative;\n  }\n\n  .queue-scene-title {\n    font-size: 1.2rem;\n\n    @media (max-width: 576px) {\n      font-size: 1rem;\n    }\n  }\n\n  .queue-scene-studio {\n    color: #d3d0d0;\n    font-weight: 600;\n  }\n\n  .queue-scene-performers,\n  .queue-scene-date {\n    color: #d3d0d0;\n    font-size: 0.9rem;\n    font-weight: 400;\n\n    @media (max-width: 576px) {\n      font-size: 0.8rem;\n    }\n  }\n\n  .thumbnail-container {\n    height: 80px;\n    margin-bottom: 5px;\n    margin-right: 0.75rem;\n    margin-top: 5px;\n    min-width: 142px;\n    width: 142px;\n  }\n\n  ol {\n    padding-left: 20px;\n  }\n\n  img {\n    height: 100%;\n    object-fit: contain;\n    object-position: center;\n    width: 100%;\n  }\n\n  a {\n    color: $text-color;\n    font-weight: 500;\n    text-decoration: none;\n  }\n\n  .current {\n    background-color: $secondary;\n  }\n}\n\n.scrape-query-dialog {\n  max-height: calc(100vh - 10rem);\n}\n\n.scraper-group {\n  & > .dropdown:not(:last-child) .btn {\n    border-bottom-right-radius: 0;\n    border-top-right-radius: 0;\n  }\n\n  & > .dropdown:not(:first-child) .btn {\n    border-bottom-left-radius: 0;\n    border-top-left-radius: 0;\n  }\n}\n\n.SceneScrapeModal-list {\n  list-style: none;\n  max-height: 50vh;\n  overflow-x: hidden;\n  overflow-y: auto;\n  padding-inline-start: 0;\n\n  li {\n    cursor: pointer;\n  }\n}\n\n.scene-file-card.card {\n  margin: 0;\n  padding: 0;\n\n  .card-header {\n    cursor: pointer;\n  }\n\n  dl {\n    margin-bottom: 0;\n  }\n}\n\n.scrape-dialog .rating-number.disabled {\n  padding-left: 0.5em;\n}\n\n.preview-scrubber {\n  height: 100%;\n  position: absolute;\n  width: 100%;\n\n  .scene-card-preview-image {\n    align-items: center;\n    display: flex;\n    justify-content: center;\n    overflow: hidden;\n  }\n\n  .scrubber-image {\n    height: 100%;\n    width: 100%;\n  }\n\n  .scrubber-timestamp {\n    bottom: calc(20px + 0.25rem);\n    font-weight: 400;\n    opacity: 0.75;\n    position: absolute;\n    right: 0.7rem;\n    text-shadow: 0 0 3px #000;\n  }\n}\n\n.hover-scrubber {\n  bottom: 0;\n  height: 20px;\n  overflow: hidden;\n  position: absolute;\n  width: 100%;\n\n  .hover-scrubber-area {\n    cursor: col-resize;\n    height: 100%;\n    position: absolute;\n    width: 100%;\n    z-index: 1;\n  }\n\n  &.hover-scrubber-inactive {\n    .hover-scrubber-area {\n      cursor: inherit;\n    }\n\n    .hover-scrubber-indicator {\n      background-color: inherit;\n    }\n  }\n\n  .hover-scrubber-indicator {\n    background-color: rgba(255, 255, 255, 0.1);\n    bottom: -100%;\n    height: 100%;\n    position: absolute;\n    transition: bottom 0.2s ease-in-out;\n    width: 100%;\n\n    .hover-scrubber-indicator-marker {\n      background-color: rgba(255, 0, 0, 0.5);\n      bottom: 0;\n      height: 5px;\n      position: absolute;\n    }\n  }\n\n  &:hover .hover-scrubber-indicator {\n    bottom: 0;\n  }\n}\n\n.play-history dl {\n  margin-top: 0.5rem;\n}\n\n.play-history,\n.o-history {\n  .history-header h5 {\n    align-items: center;\n    display: flex;\n    justify-content: space-between;\n  }\n\n  .history-operations-dropdown {\n    display: inline-block;\n  }\n\n  .add-date-button {\n    color: $success;\n  }\n\n  .remove-date-button {\n    color: $danger;\n  }\n\n  ul {\n    padding-inline-start: 1rem;\n\n    li {\n      display: flex;\n      justify-content: space-between;\n    }\n  }\n}\n\n.scene-select-option {\n  .scene-select-row {\n    align-items: center;\n    display: flex;\n    width: 100%;\n\n    .scene-select-image {\n      background-color: $body-bg;\n      margin-right: 0.4em;\n      max-height: 50px;\n      max-width: 89px;\n      object-fit: contain;\n      object-position: center;\n    }\n\n    .scene-select-details {\n      display: flex;\n      flex-direction: column;\n      justify-content: flex-start;\n      max-height: 4.1rem;\n      overflow: hidden;\n\n      .scene-select-title {\n        flex-shrink: 0;\n        white-space: pre-wrap;\n        word-break: break-all;\n      }\n\n      .scene-select-date,\n      .scene-select-studio,\n      .scene-select-code {\n        color: $text-muted;\n        flex-shrink: 0;\n        font-size: 0.9rem;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n      }\n    }\n  }\n\n  .scene-select-alias {\n    font-size: 0.8rem;\n    font-weight: bold;\n    width: 100%;\n    word-break: break-all;\n  }\n}\n\n.scene-wall,\n.marker-wall {\n  .wall-item {\n    position: relative;\n\n    .lineargradient {\n      background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));\n      bottom: 0;\n      height: 100px;\n      position: absolute;\n      width: 100%;\n    }\n\n    &-title {\n      font-weight: bold;\n    }\n\n    &-footer {\n      bottom: 20px;\n      padding: 0 1rem;\n      position: absolute;\n      text-shadow: 1px 1px 3px black;\n      transition: 0s opacity;\n      width: 100%;\n      z-index: 2;\n\n      @media (min-width: 768px) {\n        opacity: 0;\n      }\n\n      &:hover {\n        .wall-item-title {\n          text-decoration: underline;\n        }\n      }\n\n      a {\n        color: white;\n      }\n    }\n\n    &:hover .wall-item-footer {\n      opacity: 1;\n      transition: 1s opacity;\n      transition-delay: 500ms;\n\n      a {\n        text-decoration: none;\n      }\n    }\n\n    &.show-title .wall-item-footer {\n      opacity: 1;\n    }\n  }\n}\n\n.table-list.scene-table {\n  // Set max height to viewport height minus estimated header/footer height\n  // TODO - this will need to be rolled out to other tables\n  max-height: calc(100dvh - 210px);\n}\n\n.scene-list .filtered-list-toolbar {\n  // hide play and create new buttons on xs screens\n  // show these in the drop down menu instead\n  @include media-breakpoint-down(xs) {\n    .play-button,\n    .create-new-button {\n      display: none;\n    }\n  }\n}\n\n// hide drop down menu items for play and create new\n// when the buttons are visible\n@include media-breakpoint-up(sm) {\n  .scene-list-operations-dropdown {\n    .dropdown-item.play-item,\n    .dropdown-item.create-new-item {\n      display: none;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx",
    "content": "import React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { Form } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { NumberField } from \"src/utils/form\";\n\nexport type VideoPreviewSettingsInput = Pick<\n  GQL.ConfigGeneralInput,\n  | \"previewSegments\"\n  | \"previewSegmentDuration\"\n  | \"previewExcludeStart\"\n  | \"previewExcludeEnd\"\n>;\n\ninterface IVideoPreviewInput {\n  value: VideoPreviewSettingsInput;\n  setValue: (v: VideoPreviewSettingsInput) => void;\n}\n\nexport const VideoPreviewInput: React.FC<IVideoPreviewInput> = ({\n  value,\n  setValue,\n}) => {\n  const intl = useIntl();\n\n  function set(v: Partial<VideoPreviewSettingsInput>) {\n    setValue({\n      ...value,\n      ...v,\n    });\n  }\n\n  const {\n    previewSegments,\n    previewSegmentDuration,\n    previewExcludeStart,\n    previewExcludeEnd,\n  } = value;\n\n  return (\n    <div>\n      <Form.Group id=\"preview-segments\">\n        <h6>\n          {intl.formatMessage({\n            id: \"dialogs.scene_gen.preview_seg_count_head\",\n          })}\n        </h6>\n        <NumberField\n          className=\"text-input\"\n          value={previewSegments?.toString() ?? 1}\n          min={1}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            set({\n              previewSegments: Number.parseInt(\n                e.currentTarget.value || \"1\",\n                10\n              ),\n            })\n          }\n        />\n        <Form.Text className=\"text-muted\">\n          {intl.formatMessage({\n            id: \"dialogs.scene_gen.preview_seg_count_desc\",\n          })}\n        </Form.Text>\n      </Form.Group>\n\n      <Form.Group id=\"preview-segment-duration\">\n        <h6>\n          {intl.formatMessage({\n            id: \"dialogs.scene_gen.preview_seg_duration_head\",\n          })}\n        </h6>\n        <NumberField\n          className=\"text-input\"\n          value={previewSegmentDuration?.toString() ?? 0}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            set({\n              previewSegmentDuration: Number.parseFloat(\n                e.currentTarget.value || \"0\"\n              ),\n            })\n          }\n        />\n        <Form.Text className=\"text-muted\">\n          {intl.formatMessage({\n            id: \"dialogs.scene_gen.preview_seg_duration_desc\",\n          })}\n        </Form.Text>\n      </Form.Group>\n\n      <Form.Group id=\"preview-exclude-start\">\n        <h6>\n          {intl.formatMessage({\n            id: \"dialogs.scene_gen.preview_exclude_start_time_head\",\n          })}\n        </h6>\n        <Form.Control\n          className=\"text-input\"\n          value={previewExcludeStart ?? \"\"}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            set({ previewExcludeStart: e.currentTarget.value })\n          }\n        />\n        <Form.Text className=\"text-muted\">\n          {intl.formatMessage({\n            id: \"dialogs.scene_gen.preview_exclude_start_time_desc\",\n          })}\n        </Form.Text>\n      </Form.Group>\n\n      <Form.Group id=\"preview-exclude-start\">\n        <h6>\n          {intl.formatMessage({\n            id: \"dialogs.scene_gen.preview_exclude_end_time_head\",\n          })}\n        </h6>\n        <Form.Control\n          className=\"text-input\"\n          value={previewExcludeEnd ?? \"\"}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            set({ previewExcludeEnd: e.currentTarget.value })\n          }\n        />\n        <Form.Text className=\"text-muted\">\n          {intl.formatMessage({\n            id: \"dialogs.scene_gen.preview_exclude_end_time_desc\",\n          })}\n        </Form.Text>\n      </Form.Group>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Inputs.tsx",
    "content": "import { faChevronDown, faChevronUp } from \"@fortawesome/free-solid-svg-icons\";\nimport React, { PropsWithChildren, useState } from \"react\";\nimport { Button, Collapse, Form, Modal, ModalProps } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Icon } from \"../Shared/Icon\";\nimport { StringListInput } from \"../Shared/StringListInput\";\nimport { PatchComponent } from \"src/patch\";\nimport { useSettings, useSettingsOptional } from \"./context\";\nimport { NumberField } from \"src/utils/form\";\n\ninterface ISetting {\n  id?: string;\n  advanced?: boolean;\n  className?: string;\n  heading?: React.ReactNode;\n  headingID?: string;\n  subHeadingID?: string;\n  subHeading?: React.ReactNode;\n  tooltipID?: string;\n  onClick?: React.MouseEventHandler<HTMLDivElement>;\n  disabled?: boolean;\n}\n\nexport const Setting: React.FC<PropsWithChildren<ISetting>> = PatchComponent(\n  \"Setting\",\n  (props: PropsWithChildren<ISetting>) => {\n    const {\n      id,\n      className,\n      heading,\n      headingID,\n      subHeadingID,\n      subHeading,\n      children,\n      tooltipID,\n      onClick,\n      disabled,\n      advanced,\n    } = props;\n\n    // these components can be used in the setup wizard, where advanced mode is not available\n    const { advancedMode } = useSettingsOptional();\n\n    const intl = useIntl();\n\n    function renderHeading() {\n      if (headingID) {\n        return intl.formatMessage({ id: headingID });\n      }\n      return heading;\n    }\n\n    function renderSubHeading() {\n      if (subHeadingID) {\n        return (\n          <div className=\"sub-heading\">\n            {intl.formatMessage({ id: subHeadingID })}\n          </div>\n        );\n      }\n      if (subHeading) {\n        return <div className=\"sub-heading\">{subHeading}</div>;\n      }\n    }\n\n    const tooltip = tooltipID\n      ? intl.formatMessage({ id: tooltipID })\n      : undefined;\n    const disabledClassName = disabled ? \"disabled\" : \"\";\n\n    if (advanced && !advancedMode) return null;\n\n    return (\n      <div\n        className={`setting ${className ?? \"\"} ${disabledClassName}`}\n        id={id}\n        onClick={onClick}\n      >\n        <div>\n          <h3 title={tooltip}>{renderHeading()}</h3>\n          {renderSubHeading()}\n        </div>\n        <div>{children}</div>\n      </div>\n    );\n  }\n) as React.FC<PropsWithChildren<ISetting>>;\n\ninterface ISettingGroup {\n  settingProps?: ISetting;\n  topLevel?: JSX.Element;\n  collapsible?: boolean;\n  collapsedDefault?: boolean;\n}\n\nexport const SettingGroup: React.FC<PropsWithChildren<ISettingGroup>> =\n  PatchComponent(\n    \"SettingGroup\",\n    ({ settingProps, topLevel, collapsible, collapsedDefault, children }) => {\n      const [open, setOpen] = useState(!collapsedDefault);\n\n      function renderCollapseButton() {\n        if (!collapsible) return;\n\n        return (\n          <Button\n            className=\"setting-group-collapse-button\"\n            variant=\"minimal\"\n            onClick={() => setOpen(!open)}\n          >\n            <Icon className=\"fa-fw\" icon={open ? faChevronUp : faChevronDown} />\n          </Button>\n        );\n      }\n\n      function onDivClick(e: React.MouseEvent<HTMLDivElement>) {\n        if (!collapsible) return;\n\n        // ensure button was not clicked\n        let target: HTMLElement | null = e.target as HTMLElement;\n        while (target && target !== e.currentTarget) {\n          if (\n            target.nodeName.toLowerCase() === \"button\" ||\n            target.nodeName.toLowerCase() === \"a\"\n          ) {\n            // button clicked, swallow event\n            return;\n          }\n          target = target.parentElement;\n        }\n\n        setOpen(!open);\n      }\n\n      return (\n        <div className={`setting-group ${collapsible ? \"collapsible\" : \"\"}`}>\n          <Setting {...settingProps} onClick={onDivClick}>\n            {topLevel}\n            {renderCollapseButton()}\n          </Setting>\n          <Collapse in={open}>\n            <div className=\"collapsible-section\">{children}</div>\n          </Collapse>\n        </div>\n      );\n    }\n  );\n\ninterface IBooleanSetting extends ISetting {\n  id: string;\n  checked?: boolean;\n  onChange: (v: boolean) => void;\n}\n\nexport const BooleanSetting: React.FC<IBooleanSetting> = PatchComponent(\n  \"BooleanSetting\",\n  (props) => {\n    const { id, disabled, checked, onChange, ...settingProps } = props;\n\n    return (\n      <Setting {...settingProps} disabled={disabled}>\n        <Form.Switch\n          id={id}\n          disabled={disabled}\n          checked={checked ?? false}\n          onChange={() => onChange(!checked)}\n        />\n      </Setting>\n    );\n  }\n);\n\ninterface ISelectSetting extends ISetting {\n  value?: string | number | string[];\n  onChange: (v: string) => void;\n}\n\nexport const SelectSetting: React.FC<PropsWithChildren<ISelectSetting>> =\n  PatchComponent(\n    \"SelectSetting\",\n    ({ id, headingID, subHeadingID, value, children, onChange, advanced }) => {\n      return (\n        <Setting\n          advanced={advanced}\n          headingID={headingID}\n          subHeadingID={subHeadingID}\n          id={id}\n        >\n          <Form.Control\n            className=\"input-control\"\n            as=\"select\"\n            value={value ?? \"\"}\n            onChange={(e) => onChange(e.currentTarget.value)}\n          >\n            {children}\n          </Form.Control>\n        </Setting>\n      );\n    }\n  );\n\ninterface IDialogSetting<T> extends ISetting {\n  buttonText?: string;\n  buttonTextID?: string;\n  value?: T;\n  renderValue?: (v: T | undefined) => JSX.Element;\n  onChange: () => void;\n}\nconst _ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {\n  const {\n    id,\n    className,\n    headingID,\n    heading,\n    tooltipID,\n    subHeadingID,\n    subHeading,\n    value,\n    onChange,\n    renderValue,\n    buttonText,\n    buttonTextID,\n    disabled,\n  } = props;\n  const intl = useIntl();\n\n  const tooltip = tooltipID ? intl.formatMessage({ id: tooltipID }) : undefined;\n  const disabledClassName = disabled ? \"disabled\" : \"\";\n\n  return (\n    <div className={`setting ${className ?? \"\"} ${disabledClassName}`} id={id}>\n      <div>\n        <h3 title={tooltip}>\n          {headingID\n            ? intl.formatMessage({ id: headingID })\n            : heading\n            ? heading\n            : undefined}\n        </h3>\n\n        <div className=\"value\">\n          {renderValue ? renderValue(value) : undefined}\n        </div>\n\n        {subHeadingID ? (\n          <div className=\"sub-heading\">\n            {intl.formatMessage({ id: subHeadingID })}\n          </div>\n        ) : subHeading ? (\n          <div className=\"sub-heading\">{subHeading}</div>\n        ) : undefined}\n      </div>\n      <div>\n        <Button onClick={() => onChange()} disabled={disabled}>\n          {buttonText ? (\n            buttonText\n          ) : (\n            <FormattedMessage id={buttonTextID ?? \"actions.edit\"} />\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n};\n\nexport const ChangeButtonSetting = PatchComponent(\n  \"ChangeButtonSetting\",\n  _ChangeButtonSetting\n) as typeof _ChangeButtonSetting;\n\nexport interface ISettingModal<T> {\n  heading?: React.ReactNode;\n  headingID?: string;\n  subHeadingID?: string;\n  subHeading?: React.ReactNode;\n  value: T | undefined;\n  close: (v?: T) => void;\n  renderField: (\n    value: T | undefined,\n    setValue: (v?: T) => void,\n    error?: string\n  ) => JSX.Element;\n  modalProps?: ModalProps;\n  validate?: (v: T) => boolean | undefined;\n  error?: string | undefined;\n}\n\nconst _SettingModal = <T extends {}>(props: ISettingModal<T>) => {\n  const {\n    heading,\n    headingID,\n    subHeading,\n    subHeadingID,\n    value,\n    close,\n    renderField,\n    modalProps,\n    validate,\n    error,\n  } = props;\n\n  const intl = useIntl();\n  const [currentValue, setCurrentValue] = useState<T | undefined>(value);\n\n  return (\n    <Modal show onHide={() => close()} id=\"setting-dialog\" {...modalProps}>\n      <Form\n        onSubmit={(e) => {\n          close(currentValue);\n          e.preventDefault();\n        }}\n      >\n        <Modal.Header closeButton>\n          {headingID ? <FormattedMessage id={headingID} /> : heading}\n        </Modal.Header>\n        <Modal.Body>\n          {renderField(currentValue, setCurrentValue, error)}\n          {subHeadingID ? (\n            <div className=\"sub-heading\">\n              {intl.formatMessage({ id: subHeadingID })}\n            </div>\n          ) : subHeading ? (\n            <div className=\"sub-heading\">{subHeading}</div>\n          ) : undefined}\n        </Modal.Body>\n        <Modal.Footer>\n          <Button variant=\"secondary\" onClick={() => close()}>\n            <FormattedMessage id=\"actions.cancel\" />\n          </Button>\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            onClick={() => close(currentValue)}\n            disabled={\n              currentValue === undefined ||\n              (validate && !validate(currentValue))\n            }\n          >\n            <FormattedMessage id=\"actions.confirm\" />\n          </Button>\n        </Modal.Footer>\n      </Form>\n    </Modal>\n  );\n};\n\nexport const SettingModal = PatchComponent(\n  \"SettingModal\",\n  _SettingModal\n) as typeof _SettingModal;\n\ninterface IModalSetting<T> extends ISetting {\n  value: T | undefined;\n  buttonText?: string;\n  buttonTextID?: string;\n  onChange: (v: T) => void;\n  renderField: (\n    value: T | undefined,\n    setValue: (v?: T) => void,\n    error?: string\n  ) => JSX.Element;\n  renderValue?: (v: T | undefined) => JSX.Element;\n  modalProps?: ModalProps;\n  validateChange?: (v: T) => void | undefined;\n}\n\nexport const _ModalSetting = <T extends {}>(props: IModalSetting<T>) => {\n  const {\n    id,\n    className,\n    value,\n    headingID,\n    heading,\n    subHeadingID,\n    subHeading,\n    onChange,\n    renderField,\n    renderValue,\n    tooltipID,\n    buttonText,\n    buttonTextID,\n    modalProps,\n    disabled,\n    advanced,\n    validateChange,\n  } = props;\n  const [showModal, setShowModal] = useState(false);\n  const [error, setError] = useState<string>();\n  const { advancedMode } = useSettings();\n\n  function onClose(v: T | undefined) {\n    setError(undefined);\n    if (v !== undefined) {\n      if (validateChange) {\n        try {\n          validateChange(v);\n        } catch (e) {\n          setError((e as Error).message);\n          return;\n        }\n      }\n\n      onChange(v);\n    }\n    setShowModal(false);\n  }\n\n  if (advanced && !advancedMode) return null;\n\n  return (\n    <>\n      {showModal ? (\n        <SettingModal<T>\n          headingID={headingID}\n          subHeadingID={subHeadingID}\n          heading={heading}\n          subHeading={subHeading}\n          value={value}\n          renderField={renderField}\n          close={onClose}\n          error={error}\n          {...modalProps}\n        />\n      ) : undefined}\n\n      <ChangeButtonSetting<T>\n        id={id}\n        className={className}\n        disabled={disabled}\n        buttonText={buttonText}\n        buttonTextID={buttonTextID}\n        headingID={headingID}\n        heading={heading}\n        tooltipID={tooltipID}\n        subHeadingID={subHeadingID}\n        subHeading={subHeading}\n        value={value}\n        onChange={() => setShowModal(true)}\n        renderValue={renderValue}\n      />\n    </>\n  );\n};\n\nexport const ModalSetting = PatchComponent(\n  \"ModalSetting\",\n  _ModalSetting\n) as typeof _ModalSetting;\n\ninterface IStringSetting extends ISetting {\n  value: string | undefined;\n  onChange: (v: string) => void;\n}\n\nexport const StringSetting: React.FC<IStringSetting> = PatchComponent(\n  \"StringSetting\",\n  (props) => {\n    return (\n      <ModalSetting<string>\n        {...props}\n        renderField={(value, setValue) => (\n          <Form.Control\n            className=\"text-input\"\n            value={value ?? \"\"}\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n              setValue(e.currentTarget.value)\n            }\n          />\n        )}\n        renderValue={(value) => <span>{value}</span>}\n      />\n    );\n  }\n);\n\ninterface INumberSetting extends ISetting {\n  value: number | undefined;\n  onChange: (v: number) => void;\n}\n\nexport const NumberSetting: React.FC<INumberSetting> = PatchComponent(\n  \"NumberSetting\",\n  (props) => {\n    return (\n      <ModalSetting<number>\n        {...props}\n        renderField={(value, setValue) => (\n          <NumberField\n            className=\"text-input\"\n            value={value ?? 0}\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n              setValue(Number.parseInt(e.currentTarget.value || \"0\", 10))\n            }\n          />\n        )}\n        renderValue={(value) => <span>{value}</span>}\n      />\n    );\n  }\n);\n\ninterface IStringListSetting extends ISetting {\n  value: string[] | undefined;\n  defaultNewValue?: string;\n  onChange: (v: string[]) => void;\n}\n\nexport const StringListSetting: React.FC<IStringListSetting> = PatchComponent(\n  \"StringListSetting\",\n  (props) => {\n    return (\n      <ModalSetting<string[]>\n        {...props}\n        renderField={(value, setValue) => (\n          <StringListInput\n            value={value ?? []}\n            setValue={setValue}\n            placeholder={props.defaultNewValue}\n          />\n        )}\n        renderValue={(value) => (\n          <div>\n            {value?.map((v, i) => (\n              // eslint-disable-next-line react/no-array-index-key\n              <div key={i}>{v}</div>\n            ))}\n          </div>\n        )}\n      />\n    );\n  }\n);\n\ninterface IConstantSetting<T> extends ISetting {\n  value?: T;\n  renderValue?: (v: T | undefined) => JSX.Element;\n}\n\nexport const _ConstantSetting = <T extends {}>(props: IConstantSetting<T>) => {\n  const { id, headingID, subHeading, subHeadingID, renderValue, value } = props;\n  const intl = useIntl();\n\n  return (\n    <div className=\"setting\" id={id}>\n      <div>\n        <h3>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h3>\n\n        <div className=\"value\">{renderValue ? renderValue(value) : value}</div>\n\n        {subHeadingID ? (\n          <div className=\"sub-heading\">\n            {intl.formatMessage({ id: subHeadingID })}\n          </div>\n        ) : subHeading ? (\n          <div className=\"sub-heading\">{subHeading}</div>\n        ) : undefined}\n      </div>\n      <div />\n    </div>\n  );\n};\n\nexport const ConstantSetting = PatchComponent(\n  \"ConstantSetting\",\n  _ConstantSetting\n) as typeof _ConstantSetting;\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/PluginPackageManager.tsx",
    "content": "import React, { useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  evictQueries,\n  getClient,\n  queryAvailablePluginPackages,\n  useInstalledPluginPackages,\n  mutateInstallPluginPackages,\n  mutateUninstallPluginPackages,\n  mutateUpdatePluginPackages,\n  pluginMutationImpactedQueries,\n  isLoading,\n} from \"src/core/StashService\";\nimport { useMonitorJob } from \"src/utils/job\";\nimport {\n  AvailablePackages,\n  InstalledPackages,\n  RemotePackage,\n} from \"../Shared/PackageManager/PackageManager\";\nimport { useSettings } from \"./context\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { SettingSection } from \"./SettingSection\";\n\nexport const InstalledPluginPackages: React.FC = () => {\n  const [loadUpgrades, setLoadUpgrades] = useState(false);\n  const [jobID, setJobID] = useState<string>();\n  const { job } = useMonitorJob(jobID, () => onPackageChanges());\n\n  const { data, previousData, refetch, networkStatus, error } =\n    useInstalledPluginPackages(loadUpgrades);\n\n  const loading = isLoading(networkStatus);\n\n  async function onUpdatePackages(packages: GQL.PackageSpecInput[]) {\n    const r = await mutateUpdatePluginPackages(packages);\n\n    setJobID(r.data?.updatePackages);\n  }\n\n  async function onUninstallPackages(packages: GQL.PackageSpecInput[]) {\n    const r = await mutateUninstallPluginPackages(packages);\n\n    setJobID(r.data?.uninstallPackages);\n  }\n\n  function onPackageChanges() {\n    // job is complete, refresh all local data\n    const ac = getClient();\n    evictQueries(ac.cache, pluginMutationImpactedQueries);\n  }\n\n  function onCheckForUpdates() {\n    if (!loadUpgrades) {\n      setLoadUpgrades(true);\n    } else {\n      refetch();\n    }\n  }\n\n  // when loadUpgrades changes from false to true, data is set to undefined while the request is loading\n  // so use previousData as a fallback, which will be the result when loadUpgrades was false,\n  // to prevent displaying a \"No packages found\" message\n  const installedPackages =\n    data?.installedPackages ?? previousData?.installedPackages ?? [];\n\n  return (\n    <SettingSection headingID=\"config.plugins.installed_plugins\">\n      <div className=\"package-manager\">\n        <InstalledPackages\n          loading={!!job || loading}\n          error={error?.message}\n          packages={installedPackages}\n          onCheckForUpdates={onCheckForUpdates}\n          onUpdatePackages={(packages) =>\n            onUpdatePackages(\n              packages.map((p) => ({\n                id: p.package_id,\n                sourceURL: p.sourceURL,\n              }))\n            )\n          }\n          onUninstallPackages={(packages) =>\n            onUninstallPackages(\n              packages.map((p) => ({\n                id: p.package_id,\n                sourceURL: p.sourceURL,\n              }))\n            )\n          }\n          updatesLoaded={loadUpgrades && !loading}\n        />\n      </div>\n    </SettingSection>\n  );\n};\n\nexport const AvailablePluginPackages: React.FC = () => {\n  const { general, loading: configLoading, error, saveGeneral } = useSettings();\n\n  const [jobID, setJobID] = useState<string>();\n  const { job } = useMonitorJob(jobID, () => onPackageChanges());\n\n  // Get installed packages to filter them out from available list\n  const { data: installedData } = useInstalledPluginPackages(false);\n  const installedPackageIds = new Set(\n    installedData?.installedPackages?.map((p) => p.package_id) ?? []\n  );\n\n  async function onInstallPackages(packages: GQL.PackageSpecInput[]) {\n    const r = await mutateInstallPluginPackages(packages);\n\n    setJobID(r.data?.installPackages);\n  }\n\n  function onPackageChanges() {\n    // job is complete, refresh all local data\n    const ac = getClient();\n    evictQueries(ac.cache, pluginMutationImpactedQueries);\n  }\n\n  async function loadSource(source: string): Promise<RemotePackage[]> {\n    const { data } = await queryAvailablePluginPackages(source);\n    // Filter out already installed packages\n    return data.availablePackages.filter(\n      (pkg) => !installedPackageIds.has(pkg.package_id)\n    );\n  }\n\n  function addSource(source: GQL.PackageSource) {\n    saveGeneral({\n      pluginPackageSources: [...(general.pluginPackageSources ?? []), source],\n    });\n  }\n\n  function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) {\n    saveGeneral({\n      pluginPackageSources: general.pluginPackageSources?.map((s) =>\n        s.url === existing.url ? changed : s\n      ),\n    });\n  }\n\n  function deleteSource(source: GQL.PackageSource) {\n    saveGeneral({\n      pluginPackageSources: general.pluginPackageSources?.filter(\n        (s) => s.url !== source.url\n      ),\n    });\n  }\n\n  function renderDescription(pkg: RemotePackage) {\n    if (pkg.metadata.description) {\n      return pkg.metadata.description;\n    }\n  }\n\n  if (error) return <h1>{error.message}</h1>;\n  if (configLoading) return <LoadingIndicator />;\n\n  const loading = !!job;\n\n  const sources = general?.pluginPackageSources ?? [];\n\n  return (\n    <SettingSection headingID=\"config.plugins.available_plugins\">\n      <div className=\"package-manager\">\n        <AvailablePackages\n          loading={loading}\n          onInstallPackages={onInstallPackages}\n          renderDescription={renderDescription}\n          loadSource={(source) => loadSource(source)}\n          sources={sources}\n          addSource={addSource}\n          editSource={editSource}\n          deleteSource={deleteSource}\n        />\n      </div>\n    </SettingSection>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/ScraperPackageManager.tsx",
    "content": "import React, { useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  evictQueries,\n  getClient,\n  queryAvailableScraperPackages,\n  useInstalledScraperPackages,\n  mutateUpdateScraperPackages,\n  mutateUninstallScraperPackages,\n  mutateInstallScraperPackages,\n  scraperMutationImpactedQueries,\n  isLoading,\n} from \"src/core/StashService\";\nimport { useMonitorJob } from \"src/utils/job\";\nimport {\n  AvailablePackages,\n  InstalledPackages,\n  RemotePackage,\n} from \"../Shared/PackageManager/PackageManager\";\nimport { useSettings } from \"./context\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { SettingSection } from \"./SettingSection\";\n\nexport const InstalledScraperPackages: React.FC = () => {\n  const [loadUpgrades, setLoadUpgrades] = useState(false);\n  const [jobID, setJobID] = useState<string>();\n  const { job } = useMonitorJob(jobID, () => onPackageChanges());\n\n  const { data, previousData, refetch, networkStatus, error } =\n    useInstalledScraperPackages(loadUpgrades);\n\n  const loading = isLoading(networkStatus);\n\n  async function onUpdatePackages(packages: GQL.PackageSpecInput[]) {\n    const r = await mutateUpdateScraperPackages(packages);\n\n    setJobID(r.data?.updatePackages);\n  }\n\n  async function onUninstallPackages(packages: GQL.PackageSpecInput[]) {\n    const r = await mutateUninstallScraperPackages(packages);\n\n    setJobID(r.data?.uninstallPackages);\n  }\n\n  function onPackageChanges() {\n    // job is complete, refresh all local data\n    const ac = getClient();\n    evictQueries(ac.cache, scraperMutationImpactedQueries);\n  }\n\n  function onCheckForUpdates() {\n    if (!loadUpgrades) {\n      setLoadUpgrades(true);\n    } else {\n      refetch();\n    }\n  }\n\n  // when loadUpgrades changes from false to true, data is set to undefined while the request is loading\n  // so use previousData as a fallback, which will be the result when loadUpgrades was false,\n  // to prevent displaying a \"No packages found\" message\n  const installedPackages =\n    data?.installedPackages ?? previousData?.installedPackages ?? [];\n\n  return (\n    <SettingSection headingID=\"config.scraping.installed_scrapers\">\n      <div className=\"package-manager\">\n        <InstalledPackages\n          loading={!!job || loading}\n          error={error?.message}\n          packages={installedPackages}\n          onCheckForUpdates={onCheckForUpdates}\n          onUpdatePackages={(packages) =>\n            onUpdatePackages(\n              packages.map((p) => ({\n                id: p.package_id,\n                sourceURL: p.sourceURL,\n              }))\n            )\n          }\n          onUninstallPackages={(packages) =>\n            onUninstallPackages(\n              packages.map((p) => ({\n                id: p.package_id,\n                sourceURL: p.sourceURL,\n              }))\n            )\n          }\n          updatesLoaded={loadUpgrades && !loading}\n        />\n      </div>\n    </SettingSection>\n  );\n};\n\nexport const AvailableScraperPackages: React.FC = () => {\n  const { general, loading: configLoading, error, saveGeneral } = useSettings();\n\n  const [jobID, setJobID] = useState<string>();\n  const { job } = useMonitorJob(jobID, () => onPackageChanges());\n\n  // Get installed packages to filter them out from available list\n  const { data: installedData } = useInstalledScraperPackages(false);\n  const installedPackageIds = new Set(\n    installedData?.installedPackages?.map((p) => p.package_id) ?? []\n  );\n\n  async function onInstallPackages(packages: GQL.PackageSpecInput[]) {\n    const r = await mutateInstallScraperPackages(packages);\n\n    setJobID(r.data?.installPackages);\n  }\n\n  function onPackageChanges() {\n    // job is complete, refresh all local data\n    const ac = getClient();\n    evictQueries(ac.cache, scraperMutationImpactedQueries);\n  }\n\n  async function loadSource(source: string): Promise<RemotePackage[]> {\n    const { data } = await queryAvailableScraperPackages(source);\n    // Filter out already installed packages\n    return data.availablePackages.filter(\n      (pkg) => !installedPackageIds.has(pkg.package_id)\n    );\n  }\n\n  function addSource(source: GQL.PackageSource) {\n    saveGeneral({\n      scraperPackageSources: [...(general.scraperPackageSources ?? []), source],\n    });\n  }\n\n  function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) {\n    saveGeneral({\n      scraperPackageSources: general.scraperPackageSources?.map((s) =>\n        s.url === existing.url ? changed : s\n      ),\n    });\n  }\n\n  function deleteSource(source: GQL.PackageSource) {\n    saveGeneral({\n      scraperPackageSources: general.scraperPackageSources?.filter(\n        (s) => s.url !== source.url\n      ),\n    });\n  }\n\n  function renderDescription(pkg: RemotePackage) {\n    if (pkg.metadata.description) {\n      return pkg.metadata.description;\n    }\n  }\n\n  if (error) return <h1>{error.message}</h1>;\n  if (configLoading) return <LoadingIndicator />;\n\n  const loading = !!job;\n\n  const sources = general?.scraperPackageSources ?? [];\n\n  return (\n    <SettingSection headingID=\"config.scraping.available_scrapers\">\n      <div className=\"package-manager\">\n        <AvailablePackages\n          loading={loading}\n          onInstallPackages={onInstallPackages}\n          renderDescription={renderDescription}\n          loadSource={(source) => loadSource(source)}\n          sources={sources}\n          addSource={addSource}\n          editSource={editSource}\n          deleteSource={deleteSource}\n          allowSelectAll\n        />\n      </div>\n    </SettingSection>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingSection.tsx",
    "content": "import React, { PropsWithChildren } from \"react\";\nimport { Card } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useSettings } from \"./context\";\n\ninterface ISettingGroup {\n  id?: string;\n  headingID?: string;\n  subHeadingID?: string;\n  advanced?: boolean;\n}\n\nexport const SettingSection: React.FC<PropsWithChildren<ISettingGroup>> = ({\n  id,\n  children,\n  headingID,\n  subHeadingID,\n  advanced,\n}) => {\n  const intl = useIntl();\n  const { advancedMode } = useSettings();\n\n  if (advanced && !advancedMode) return null;\n\n  return (\n    <div className=\"setting-section\" id={id}>\n      <h1>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h1>\n      {subHeadingID ? (\n        <div className=\"sub-heading\">\n          {intl.formatMessage({ id: subHeadingID })}\n        </div>\n      ) : undefined}\n      <Card>{children}</Card>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Settings.tsx",
    "content": "import React from \"react\";\nimport { Tab, Nav, Row, Col, Form } from \"react-bootstrap\";\nimport { Redirect, useLocation } from \"react-router-dom\";\nimport { LinkContainer } from \"react-router-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { Helmet } from \"react-helmet\";\nimport { useTitleProps } from \"src/hooks/title\";\nimport { SettingsAboutPanel } from \"./SettingsAboutPanel\";\nimport { SettingsConfigurationPanel } from \"./SettingsSystemPanel\";\nimport { SettingsInterfacePanel } from \"./SettingsInterfacePanel/SettingsInterfacePanel\";\nimport { SettingsLogsPanel } from \"./SettingsLogsPanel\";\nimport { SettingsTasksPanel } from \"./Tasks/SettingsTasksPanel\";\nimport { SettingsPluginsPanel } from \"./SettingsPluginsPanel\";\nimport { SettingsScrapingPanel } from \"./SettingsScrapingPanel\";\nimport { SettingsToolsPanel } from \"./SettingsToolsPanel\";\nimport { SettingsServicesPanel } from \"./SettingsServicesPanel\";\nimport { SettingsContext, useSettings } from \"./context\";\nimport { SettingsLibraryPanel } from \"./SettingsLibraryPanel\";\nimport { SettingsSecurityPanel } from \"./SettingsSecurityPanel\";\nimport Changelog from \"../Changelog/Changelog\";\nimport { TroubleshootingModeButton } from \"../TroubleshootingMode/TroubleshootingModeButton\";\nimport { useTroubleshootingMode } from \"../TroubleshootingMode/useTroubleshootingMode\";\n\nconst validTabs = [\n  \"tasks\",\n  \"library\",\n  \"interface\",\n  \"security\",\n  \"metadata-providers\",\n  \"services\",\n  \"system\",\n  \"plugins\",\n  \"logs\",\n  \"tools\",\n  \"changelog\",\n  \"about\",\n] as const;\ntype TabKey = (typeof validTabs)[number];\n\nconst defaultTab: TabKey = \"tasks\";\n\nfunction isTabKey(tab: string | null): tab is TabKey {\n  return validTabs.includes(tab as TabKey);\n}\n\nconst SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => {\n  const { advancedMode, setAdvancedMode } = useSettings();\n  const { isActive: troubleshootingModeActive } = useTroubleshootingMode();\n\n  const titleProps = useTitleProps({ id: \"settings\" });\n\n  return (\n    <Tab.Container activeKey={tab} id=\"configuration-tabs\">\n      <Helmet {...titleProps} />\n      <Row>\n        <Col id=\"settings-menu-container\" sm={3} md={3} xl={2}>\n          <Nav variant=\"pills\" className=\"flex-column\">\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=tasks\">\n                <Nav.Link eventKey=\"tasks\">\n                  <FormattedMessage id=\"config.categories.tasks\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=library\">\n                <Nav.Link eventKey=\"library\">\n                  <FormattedMessage id=\"library\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=interface\">\n                <Nav.Link eventKey=\"interface\">\n                  <FormattedMessage id=\"config.categories.interface\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=security\">\n                <Nav.Link eventKey=\"security\">\n                  <FormattedMessage id=\"config.categories.security\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=metadata-providers\">\n                <Nav.Link eventKey=\"metadata-providers\">\n                  <FormattedMessage id=\"config.categories.metadata_providers\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=services\">\n                <Nav.Link eventKey=\"services\">\n                  <FormattedMessage id=\"config.categories.services\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=system\">\n                <Nav.Link eventKey=\"system\">\n                  <FormattedMessage id=\"config.categories.system\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=plugins\">\n                <Nav.Link eventKey=\"plugins\">\n                  <FormattedMessage id=\"config.categories.plugins\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=logs\">\n                <Nav.Link eventKey=\"logs\">\n                  <FormattedMessage id=\"config.categories.logs\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=tools\">\n                <Nav.Link eventKey=\"tools\">\n                  <FormattedMessage id=\"config.categories.tools\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=changelog\">\n                <Nav.Link eventKey=\"changelog\">\n                  <FormattedMessage id=\"config.categories.changelog\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <LinkContainer to=\"/settings?tab=about\">\n                <Nav.Link eventKey=\"about\">\n                  <FormattedMessage id=\"config.categories.about\" />\n                </Nav.Link>\n              </LinkContainer>\n            </Nav.Item>\n            <Nav.Item>\n              <div className=\"advanced-switch\">\n                <Form.Label htmlFor=\"advanced-settings\">\n                  <FormattedMessage id=\"config.advanced_mode\" />\n                </Form.Label>\n                <Form.Switch\n                  id=\"advanced-settings\"\n                  checked={advancedMode}\n                  onChange={() => setAdvancedMode(!advancedMode)}\n                />\n              </div>\n            </Nav.Item>\n            {!troubleshootingModeActive && <TroubleshootingModeButton />}\n            <hr className=\"d-sm-none\" />\n          </Nav>\n        </Col>\n        <Col\n          id=\"settings-container\"\n          sm={{ offset: 3 }}\n          md={{ offset: 3 }}\n          xl={{ offset: 2 }}\n        >\n          <Tab.Content className=\"mx-auto\">\n            <Tab.Pane eventKey=\"library\">\n              <SettingsLibraryPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"interface\">\n              <SettingsInterfacePanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"security\">\n              <SettingsSecurityPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"tasks\">\n              <SettingsTasksPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"services\" unmountOnExit>\n              <SettingsServicesPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"tools\" unmountOnExit>\n              <SettingsToolsPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"metadata-providers\" unmountOnExit>\n              <SettingsScrapingPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"system\">\n              <SettingsConfigurationPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"plugins\" unmountOnExit>\n              <SettingsPluginsPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"logs\" unmountOnExit>\n              <SettingsLogsPanel />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"changelog\" unmountOnExit>\n              <Changelog />\n            </Tab.Pane>\n            <Tab.Pane eventKey=\"about\" unmountOnExit>\n              <SettingsAboutPanel />\n            </Tab.Pane>\n          </Tab.Content>\n        </Col>\n      </Row>\n    </Tab.Container>\n  );\n};\n\nexport const Settings: React.FC = () => {\n  const location = useLocation();\n  const tab = new URLSearchParams(location.search).get(\"tab\");\n\n  if (!isTabKey(tab)) {\n    return (\n      <Redirect\n        to={{\n          ...location,\n          search: `tab=${defaultTab}`,\n        }}\n      />\n    );\n  }\n\n  return (\n    <SettingsContext>\n      <SettingTabs tab={tab} />\n    </SettingsContext>\n  );\n};\n\nexport default Settings;\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx",
    "content": "import React from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useLatestVersion } from \"src/core/StashService\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\nimport { ConstantSetting, SettingGroup } from \"./Inputs\";\nimport { SettingSection } from \"./SettingSection\";\n\nexport const SettingsAboutPanel: React.FC = () => {\n  const gitHash = import.meta.env.VITE_APP_GITHASH;\n  const stashVersion = import.meta.env.VITE_APP_STASH_VERSION;\n  const buildTime = import.meta.env.VITE_APP_DATE;\n\n  const intl = useIntl();\n\n  const {\n    data: dataLatest,\n    error: errorLatest,\n    loading: loadingLatest,\n    refetch,\n    networkStatus,\n  } = useLatestVersion();\n\n  function renderLatestVersion() {\n    if (errorLatest) {\n      return (\n        <SettingGroup\n          settingProps={{\n            heading: errorLatest.message,\n          }}\n        />\n      );\n    } else if (!dataLatest || loadingLatest || networkStatus === 4) {\n      return (\n        <SettingGroup\n          settingProps={{\n            headingID: \"loading.generic\",\n          }}\n        />\n      );\n    } else {\n      let heading = dataLatest.latestversion.version;\n      const hashString = dataLatest.latestversion.shorthash;\n      if (gitHash !== hashString) {\n        heading +=\n          \" \" +\n          intl.formatMessage({\n            id: \"config.about.new_version_notice\",\n          });\n      }\n      return (\n        <SettingGroup\n          settingProps={{\n            heading,\n          }}\n        >\n          <div className=\"setting\">\n            <div>\n              <h3>\n                {intl.formatMessage({\n                  id: \"config.about.build_hash\",\n                })}\n              </h3>\n              <div className=\"value\">{hashString}</div>\n            </div>\n            <div>\n              <a href={dataLatest.latestversion.url}>\n                <Button>\n                  {intl.formatMessage({ id: \"actions.download\" })}\n                </Button>\n              </a>\n              <Button onClick={() => refetch()}>\n                {intl.formatMessage({\n                  id: \"config.about.check_for_new_version\",\n                })}\n              </Button>\n            </div>\n          </div>\n          <ConstantSetting\n            headingID=\"config.about.release_date\"\n            value={dataLatest.latestversion.release_date}\n          />\n        </SettingGroup>\n      );\n    }\n  }\n\n  return (\n    <>\n      <SettingSection headingID=\"config.about.version\">\n        <SettingGroup\n          settingProps={{\n            heading: stashVersion,\n          }}\n        >\n          <ConstantSetting\n            headingID=\"config.about.build_hash\"\n            value={gitHash}\n          />\n          <ConstantSetting\n            headingID=\"config.about.build_time\"\n            value={buildTime}\n          />\n        </SettingGroup>\n      </SettingSection>\n\n      <SettingSection headingID=\"config.about.latest_version\">\n        {renderLatestVersion()}\n      </SettingSection>\n\n      <SettingSection headingID=\"config.categories.about\">\n        <div className=\"setting\">\n          <div>\n            <p>\n              {intl.formatMessage(\n                { id: \"config.about.stash_home\" },\n                {\n                  url: (\n                    <ExternalLink href=\"https://github.com/stashapp/stash\">\n                      GitHub\n                    </ExternalLink>\n                  ),\n                }\n              )}\n            </p>\n            <p>\n              {intl.formatMessage(\n                { id: \"config.about.stash_wiki\" },\n                {\n                  url: (\n                    <ExternalLink href=\"https://docs.stashapp.cc\">\n                      Documentation\n                    </ExternalLink>\n                  ),\n                }\n              )}\n            </p>\n            <p>\n              {intl.formatMessage(\n                { id: \"config.about.stash_discord\" },\n                {\n                  url: (\n                    <ExternalLink href=\"https://discord.gg/2TsNFKt\">\n                      Discord\n                    </ExternalLink>\n                  ),\n                }\n              )}\n            </p>\n            <p>\n              {intl.formatMessage(\n                { id: \"config.about.stash_open_collective\" },\n                {\n                  url: (\n                    <ExternalLink href=\"https://opencollective.com/stashapp\">\n                      Open Collective\n                    </ExternalLink>\n                  ),\n                }\n              )}\n            </p>\n          </div>\n          <div />\n        </div>\n      </SettingSection>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx",
    "content": "import React from \"react\";\nimport { BooleanSetting } from \"../Inputs\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IItem {\n  id: string;\n  headingID: string;\n}\n\ninterface ICheckboxGroupProps {\n  groupId: string;\n  items: IItem[];\n  checkedIds?: string[];\n  onChange?: (ids: string[]) => void;\n}\n\nexport const CheckboxGroup: React.FC<ICheckboxGroupProps> = PatchComponent(\n  \"CheckboxGroup\",\n  ({ groupId, items, checkedIds = [], onChange }) => {\n    function generateId(itemId: string) {\n      return `${groupId}-${itemId}`;\n    }\n\n    return (\n      <>\n        {items.map(({ id, headingID }) => (\n          <BooleanSetting\n            key={id}\n            id={generateId(id)}\n            headingID={headingID}\n            checked={checkedIds.includes(id)}\n            onChange={(v) => {\n              if (v) {\n                onChange?.(\n                  items\n                    .map((item) => item.id)\n                    .filter(\n                      (itemId) =>\n                        generateId(itemId) === generateId(id) ||\n                        checkedIds.includes(itemId)\n                    )\n                );\n              } else {\n                onChange?.(\n                  items\n                    .map((item) => item.id)\n                    .filter(\n                      (itemId) =>\n                        generateId(itemId) !== generateId(id) &&\n                        checkedIds.includes(itemId)\n                    )\n                );\n              }\n            }}\n          />\n        ))}\n      </>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx",
    "content": "import React, { useCallback, useMemo } from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { DurationInput } from \"src/components/Shared/DurationInput\";\nimport { PercentInput } from \"src/components/Shared/PercentInput\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { CheckboxGroup } from \"./CheckboxGroup\";\nimport { SettingSection } from \"../SettingSection\";\nimport {\n  BooleanSetting,\n  ModalSetting,\n  NumberSetting,\n  SelectSetting,\n  StringSetting,\n} from \"../Inputs\";\nimport { useSettings } from \"../context\";\nimport TextUtils from \"src/utils/text\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  imageLightboxDisplayModeIntlMap,\n  imageLightboxScrollModeIntlMap,\n} from \"src/core/enums\";\nimport { useInterfaceLocalForage } from \"src/hooks/LocalForage\";\nimport {\n  ConnectionState,\n  connectionStateLabel,\n  InteractiveContext,\n} from \"src/hooks/Interactive/context\";\nimport {\n  defaultRatingStarPrecision,\n  defaultRatingSystemOptions,\n  defaultRatingSystemType,\n  RatingStarPrecision,\n  ratingStarPrecisionIntlMap,\n  ratingSystemIntlMap,\n  RatingSystemType,\n} from \"src/utils/rating\";\nimport {\n  imageWallDirectionIntlMap,\n  ImageWallDirection,\n  defaultImageWallOptions,\n  defaultImageWallDirection,\n  defaultImageWallMargin,\n} from \"src/utils/imageWall\";\nimport { defaultMaxOptionsShown, defaultPreviewVolume } from \"src/core/config\";\nimport { PatchComponent } from \"src/patch\";\n\nconst allMenuItems = [\n  { id: \"scenes\", headingID: \"scenes\" },\n  { id: \"images\", headingID: \"images\" },\n  { id: \"groups\", headingID: \"groups\" },\n  { id: \"markers\", headingID: \"markers\" },\n  { id: \"galleries\", headingID: \"galleries\" },\n  { id: \"performers\", headingID: \"performers\" },\n  { id: \"studios\", headingID: \"studios\" },\n  { id: \"tags\", headingID: \"tags\" },\n];\n\nexport const SettingsInterfacePanel: React.FC = PatchComponent(\n  \"SettingsInterfacePanel\",\n  function SettingsInterfacePanel() {\n    const intl = useIntl();\n\n    const {\n      interface: iface,\n      saveInterface,\n      ui,\n      saveUI,\n      loading,\n      error,\n    } = useSettings();\n    // convert old movies menu item to groups\n    const massageMenuItems = useCallback((menuItems: string[]) => {\n      return menuItems.map((item) => {\n        if (item === \"movies\") {\n          return \"groups\";\n        }\n        return item;\n      });\n    }, []);\n\n    const massagedMenuItems = useMemo(() => {\n      if (!iface.menuItems) return iface.menuItems;\n\n      return massageMenuItems(iface.menuItems);\n    }, [iface.menuItems, massageMenuItems]);\n\n    const {\n      interactive,\n      state: interactiveState,\n      error: interactiveError,\n      serverOffset: interactiveServerOffset,\n      initialised: interactiveInitialised,\n      initialise: initialiseInteractive,\n      sync: interactiveSync,\n    } = React.useContext(InteractiveContext);\n\n    const [, setInterfaceLocalForage] = useInterfaceLocalForage();\n\n    function saveLightboxSettings(v: Partial<GQL.ConfigImageLightboxInput>) {\n      // save in local forage as well for consistency\n      setInterfaceLocalForage((prev) => ({\n        ...prev,\n        imageLightbox: {\n          ...prev.imageLightbox,\n          ...v,\n        },\n      }));\n\n      saveInterface({\n        imageLightbox: {\n          ...iface.imageLightbox,\n          ...v,\n        },\n      });\n    }\n\n    function saveImageWallMargin(m: number) {\n      saveUI({\n        imageWallOptions: {\n          ...(ui.imageWallOptions ?? defaultImageWallOptions),\n          margin: m,\n        },\n      });\n    }\n\n    function saveImageWallDirection(d: ImageWallDirection) {\n      saveUI({\n        imageWallOptions: {\n          ...(ui.imageWallOptions ?? defaultImageWallOptions),\n          direction: d,\n        },\n      });\n    }\n\n    function saveRatingSystemType(t: RatingSystemType) {\n      saveUI({\n        ratingSystemOptions: {\n          ...ui.ratingSystemOptions,\n          type: t,\n        },\n      });\n    }\n\n    function saveRatingSystemStarPrecision(p: RatingStarPrecision) {\n      saveUI({\n        ratingSystemOptions: {\n          ...(ui.ratingSystemOptions ?? defaultRatingSystemOptions),\n          starPrecision: p,\n        },\n      });\n    }\n\n    function validateLocaleString(v: string) {\n      if (!v) return;\n      try {\n        JSON.parse(v);\n      } catch (e) {\n        throw new Error(\n          intl.formatMessage(\n            { id: \"errors.invalid_json_string\" },\n            {\n              error: (e as SyntaxError).message,\n            }\n          )\n        );\n      }\n    }\n\n    function validateJavascriptString(v: string) {\n      if (!v) return;\n      try {\n        // creates a function from the string to validate it but does not execute it\n        // eslint-disable-next-line @typescript-eslint/no-implied-eval\n        new Function(v);\n      } catch (e) {\n        throw new Error(\n          intl.formatMessage(\n            { id: \"errors.invalid_javascript_string\" },\n            {\n              error: (e as SyntaxError).message,\n            }\n          )\n        );\n      }\n    }\n\n    if (error) return <h1>{error.message}</h1>;\n    if (loading) return <LoadingIndicator />;\n\n    // https://en.wikipedia.org/wiki/List_of_language_names\n\n    return (\n      <>\n        <SettingSection headingID=\"config.ui.basic_settings\">\n          <SelectSetting\n            id=\"language\"\n            headingID=\"config.ui.language.heading\"\n            value={iface.language ?? undefined}\n            onChange={(v) => saveInterface({ language: v })}\n          >\n            <option value=\"af-ZA\">Afrikaans (Preview)</option>\n            <option value=\"ar\">Arabic (Preview)</option>\n            <option value=\"bg-BG\">Bulgarian (Preview)</option>\n            <option value=\"bn-BD\">বাংলা (বাংলাদেশ) (Preview)</option>\n            <option value=\"ca-ES\">Catalan (Preview)</option>\n            <option value=\"cs-CZ\">Čeština (Česko)</option>\n            <option value=\"da-DK\">Dansk (Danmark)</option>\n            <option value=\"de-DE\">Deutsch (Deutschland)</option>\n            <option value=\"en-GB\">English (United Kingdom)</option>\n            <option value=\"en-US\">English (United States)</option>\n            <option value=\"et-EE\">Eesti</option>\n            <option value=\"fa-IR\">فارسی (ایران) (Preview)</option>\n            <option value=\"fi-FI\">Suomi</option>\n            <option value=\"fr-FR\">Français (France)</option>\n            <option value=\"hi-IN\">हिन्दी (Preview)</option>\n            <option value=\"hr-HR\">Hrvatski (Preview)</option>\n            <option value=\"id-ID\">Indonesian (Preview)</option>\n            <option value=\"hu-HU\">Magyar (Preview)</option>\n            <option value=\"it-IT\">Italiano</option>\n            <option value=\"ja-JP\">日本語 (日本)</option>\n            <option value=\"ko-KR\">한국어 (대한민국)</option>\n            <option value=\"lv-LV\">Latviešu (Preview)</option>\n            <option value=\"lt-LT\">Lithuanian (Preview)</option>\n            <option value=\"nb-NO\">Norsk bokmål</option>\n            <option value=\"nn-NO\">Nynorsk (Preview)</option>\n            <option value=\"nl-NL\">Nederlands (Nederland)</option>\n            <option value=\"pl-PL\">Polski</option>\n            <option value=\"pt-BR\">Português (Brasil)</option>\n            <option value=\"ro-RO\">Română (Preview)</option>\n            <option value=\"ru-RU\">Русский (Россия)</option>\n            <option value=\"es-ES\">Español (España)</option>\n            <option value=\"sk-SK\">Slovenčina (Preview)</option>\n            <option value=\"sv-SE\">Svenska</option>\n            <option value=\"tr-TR\">Türkçe (Türkiye)</option>\n            <option value=\"th-TH\">ภาษาไทย (ไทย)</option>\n            <option value=\"uk-UA\">Ukrainian (Україна)</option>\n            <option value=\"ur-PK\">Urdu (Preview)</option>\n            <option value=\"vi-VN\">Tiếng Việt (Preview)</option>\n            <option value=\"zh-TW\">繁體中文 (台灣)</option>\n            <option value=\"zh-CN\">简体中文 (中国)</option>\n          </SelectSetting>\n\n          <BooleanSetting\n            id=\"sfw-content-mode\"\n            headingID=\"config.ui.sfw_mode.heading\"\n            subHeadingID=\"config.ui.sfw_mode.description\"\n            checked={iface.sfwContentMode ?? undefined}\n            onChange={(v) => saveInterface({ sfwContentMode: v })}\n          />\n\n          <StringSetting\n            id=\"custom-title\"\n            headingID=\"config.ui.custom_title.heading\"\n            subHeadingID=\"config.ui.custom_title.description\"\n            value={ui.title ?? \"\"}\n            onChange={(v) => saveUI({ title: v })}\n          />\n\n          <div className=\"setting-group\">\n            <div className=\"setting\">\n              <div>\n                <h3>\n                  {intl.formatMessage({\n                    id: \"config.ui.menu_items.heading\",\n                  })}\n                </h3>\n                <div className=\"sub-heading\">\n                  {intl.formatMessage({\n                    id: \"config.ui.menu_items.description\",\n                  })}\n                </div>\n              </div>\n              <div />\n            </div>\n            <CheckboxGroup\n              groupId=\"menu-items\"\n              items={allMenuItems}\n              checkedIds={massagedMenuItems ?? undefined}\n              onChange={(v) =>\n                saveInterface({ menuItems: massageMenuItems(v) })\n              }\n            />\n          </div>\n\n          <BooleanSetting\n            id=\"abbreviate-counters\"\n            headingID=\"config.ui.abbreviate_counters.heading\"\n            subHeadingID=\"config.ui.abbreviate_counters.description\"\n            checked={ui.abbreviateCounters ?? undefined}\n            onChange={(v) => saveUI({ abbreviateCounters: v })}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.desktop_integration.desktop_integration\">\n          <BooleanSetting\n            id=\"skip-browser\"\n            headingID=\"config.ui.desktop_integration.skip_opening_browser\"\n            subHeadingID=\"config.ui.desktop_integration.skip_opening_browser_on_startup\"\n            checked={iface.noBrowser ?? undefined}\n            onChange={(v) => saveInterface({ noBrowser: v })}\n          />\n          <BooleanSetting\n            id=\"notifications-enabled\"\n            headingID=\"config.ui.desktop_integration.notifications_enabled\"\n            subHeadingID=\"config.ui.desktop_integration.send_desktop_notifications_for_events\"\n            checked={iface.notificationsEnabled ?? undefined}\n            onChange={(v) => saveInterface({ notificationsEnabled: v })}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.scene_view.heading\">\n          <BooleanSetting\n            id=\"sound-on-hover\"\n            headingID=\"config.ui.scene_wall.options.toggle_sound\"\n            checked={iface.soundOnPreview ?? undefined}\n            onChange={(v) => saveInterface({ soundOnPreview: v })}\n          />\n          <ModalSetting<number>\n            id=\"preview-volume\"\n            headingID=\"config.ui.scene_view.options.preview_volume.heading\"\n            subHeadingID=\"config.ui.scene_view.options.preview_volume.description\"\n            value={ui.previewVolume ?? defaultPreviewVolume}\n            onChange={(v) => saveUI({ previewVolume: v })}\n            disabled={!iface.soundOnPreview}\n            renderField={(value, setValue) => (\n              <PercentInput\n                numericValue={value}\n                onValueChange={(v) => setValue(v ?? 0)}\n              />\n            )}\n            renderValue={(v) => {\n              return <span>{v}%</span>;\n            }}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.scene_wall.heading\">\n          <BooleanSetting\n            id=\"wall-show-title\"\n            headingID=\"config.ui.scene_wall.options.display_title\"\n            checked={iface.wallShowTitle ?? undefined}\n            onChange={(v) => saveInterface({ wallShowTitle: v })}\n          />\n          <SelectSetting\n            advanced\n            id=\"wall-preview\"\n            headingID=\"config.ui.preview_type.heading\"\n            subHeadingID=\"config.ui.preview_type.description\"\n            value={iface.wallPlayback ?? undefined}\n            onChange={(v) => saveInterface({ wallPlayback: v })}\n          >\n            <option value=\"video\">\n              {intl.formatMessage({\n                id: \"config.ui.preview_type.options.video\",\n              })}\n            </option>\n            <option value=\"animation\">\n              {intl.formatMessage({\n                id: \"config.ui.preview_type.options.animated\",\n              })}\n            </option>\n            <option value=\"image\">\n              {intl.formatMessage({\n                id: \"config.ui.preview_type.options.static\",\n              })}\n            </option>\n          </SelectSetting>\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.scene_list.heading\">\n          <BooleanSetting\n            id=\"show-text-studios\"\n            headingID=\"config.ui.scene_list.options.show_studio_as_text\"\n            checked={iface.showStudioAsText ?? undefined}\n            onChange={(v) => saveInterface({ showStudioAsText: v })}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.scene_player.heading\">\n          <BooleanSetting\n            id=\"enable-chromecast\"\n            headingID=\"config.ui.scene_player.options.enable_chromecast\"\n            checked={ui.enableChromecast ?? undefined}\n            onChange={(v) => saveUI({ enableChromecast: v })}\n          />\n          <BooleanSetting\n            id=\"disable-mobile-media-auto-rotate\"\n            headingID=\"config.ui.scene_player.options.disable_mobile_media_auto_rotate\"\n            checked={ui.disableMobileMediaAutoRotateEnabled ?? undefined}\n            onChange={(v) => saveUI({ disableMobileMediaAutoRotateEnabled: v })}\n          />\n          <BooleanSetting\n            id=\"show-scrubber\"\n            headingID=\"config.ui.scene_player.options.show_scrubber\"\n            checked={iface.showScrubber ?? undefined}\n            onChange={(v) => saveInterface({ showScrubber: v })}\n          />\n          <BooleanSetting\n            id=\"show-range-markers\"\n            headingID=\"config.ui.scene_player.options.show_range_markers\"\n            checked={ui.showRangeMarkers ?? undefined}\n            onChange={(v) => saveUI({ showRangeMarkers: v })}\n          />\n          <BooleanSetting\n            id=\"always-start-from-beginning\"\n            headingID=\"config.ui.scene_player.options.always_start_from_beginning\"\n            checked={ui.alwaysStartFromBeginning ?? undefined}\n            onChange={(v) => saveUI({ alwaysStartFromBeginning: v })}\n          />\n          <BooleanSetting\n            id=\"track-activity\"\n            headingID=\"config.ui.scene_player.options.track_activity\"\n            checked={ui.trackActivity ?? true}\n            onChange={(v) => saveUI({ trackActivity: v })}\n          />\n          <StringSetting\n            id=\"vr-tag\"\n            headingID=\"config.ui.scene_player.options.vr_tag.heading\"\n            subHeadingID=\"config.ui.scene_player.options.vr_tag.description\"\n            value={ui.vrTag ?? undefined}\n            onChange={(v) => saveUI({ vrTag: v })}\n          />\n          <ModalSetting<number>\n            id=\"ignore-interval\"\n            headingID=\"config.ui.minimum_play_percent.heading\"\n            subHeadingID=\"config.ui.minimum_play_percent.description\"\n            value={ui.minimumPlayPercent ?? 0}\n            onChange={(v) => saveUI({ minimumPlayPercent: v })}\n            disabled={!ui.trackActivity}\n            renderField={(value, setValue) => (\n              <PercentInput\n                numericValue={value}\n                onValueChange={(interval) => setValue(interval ?? 0)}\n              />\n            )}\n            renderValue={(v) => {\n              return <span>{v}%</span>;\n            }}\n          />\n          <NumberSetting\n            headingID=\"config.ui.slideshow_delay.heading\"\n            subHeadingID=\"config.ui.slideshow_delay.description\"\n            value={iface.imageLightbox?.slideshowDelay ?? undefined}\n            onChange={(v) => saveLightboxSettings({ slideshowDelay: v })}\n          />\n          <BooleanSetting\n            id=\"auto-start-video\"\n            headingID=\"config.ui.scene_player.options.auto_start_video\"\n            checked={iface.autostartVideo ?? undefined}\n            onChange={(v) => saveInterface({ autostartVideo: v })}\n          />\n          <BooleanSetting\n            id=\"auto-start-video-on-play-selected\"\n            headingID=\"config.ui.scene_player.options.auto_start_video_on_play_selected.heading\"\n            subHeadingID=\"config.ui.scene_player.options.auto_start_video_on_play_selected.description\"\n            checked={iface.autostartVideoOnPlaySelected ?? undefined}\n            onChange={(v) => saveInterface({ autostartVideoOnPlaySelected: v })}\n          />\n\n          <BooleanSetting\n            id=\"continue-playlist-default\"\n            headingID=\"config.ui.scene_player.options.continue_playlist_default.heading\"\n            subHeadingID=\"config.ui.scene_player.options.continue_playlist_default.description\"\n            checked={iface.continuePlaylistDefault ?? undefined}\n            onChange={(v) => saveInterface({ continuePlaylistDefault: v })}\n          />\n\n          <ModalSetting<number>\n            id=\"max-loop-duration\"\n            headingID=\"config.ui.max_loop_duration.heading\"\n            subHeadingID=\"config.ui.max_loop_duration.description\"\n            value={iface.maximumLoopDuration ?? undefined}\n            onChange={(v) => saveInterface({ maximumLoopDuration: v })}\n            renderField={(value, setValue) => (\n              <DurationInput\n                value={value}\n                setValue={(duration) => setValue(duration ?? 0)}\n              />\n            )}\n            renderValue={(v) => {\n              return <span>{TextUtils.secondsToTimestamp(v ?? 0)}</span>;\n            }}\n          />\n\n          <BooleanSetting\n            id=\"show-ab-loop\"\n            headingID=\"config.ui.scene_player.options.show_ab_loop_controls\"\n            checked={ui.showAbLoopControls ?? undefined}\n            onChange={(v) => saveUI({ showAbLoopControls: v })}\n          />\n        </SettingSection>\n        <SettingSection headingID=\"config.ui.tag_panel.heading\">\n          <BooleanSetting\n            id=\"show-tag-card-on-hover\"\n            headingID=\"config.ui.show_tag_card_on_hover.heading\"\n            subHeadingID=\"config.ui.show_tag_card_on_hover.description\"\n            checked={ui.showTagCardOnHover ?? true}\n            onChange={(v) => saveUI({ showTagCardOnHover: v })}\n          />\n          <BooleanSetting\n            id=\"show-child-tagged-content\"\n            headingID=\"config.ui.tag_panel.options.show_child_tagged_content.heading\"\n            subHeadingID=\"config.ui.tag_panel.options.show_child_tagged_content.description\"\n            checked={ui.showChildTagContent ?? undefined}\n            onChange={(v) => saveUI({ showChildTagContent: v })}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.studio_panel.heading\">\n          <BooleanSetting\n            id=\"show-child-studio-content\"\n            headingID=\"config.ui.studio_panel.options.show_child_studio_content.heading\"\n            subHeadingID=\"config.ui.studio_panel.options.show_child_studio_content.description\"\n            checked={ui.showChildStudioContent ?? undefined}\n            onChange={(v) => saveUI({ showChildStudioContent: v })}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.performer_list.heading\">\n          <BooleanSetting\n            id=\"show-links-on-grid-card\"\n            headingID=\"config.ui.performer_list.options.show_links_on_grid_card.heading\"\n            checked={ui.showLinksOnPerformerCard ?? undefined}\n            onChange={(v) => saveUI({ showLinksOnPerformerCard: v })}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.image_wall.heading\">\n          <NumberSetting\n            headingID=\"config.ui.image_wall.margin\"\n            subHeadingID=\"dialogs.imagewall.margin_desc\"\n            value={ui.imageWallOptions?.margin ?? defaultImageWallMargin}\n            onChange={(v) => saveImageWallMargin(v)}\n          />\n\n          <SelectSetting\n            id=\"image_wall_direction\"\n            headingID=\"config.ui.image_wall.direction\"\n            subHeadingID=\"dialogs.imagewall.direction.description\"\n            value={ui.imageWallOptions?.direction ?? defaultImageWallDirection}\n            onChange={(v) => saveImageWallDirection(v as ImageWallDirection)}\n          >\n            {Array.from(imageWallDirectionIntlMap.entries()).map((v) => (\n              <option key={v[0]} value={v[0]}>\n                {intl.formatMessage({\n                  id: v[1],\n                })}\n              </option>\n            ))}\n          </SelectSetting>\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.image_lightbox.heading\">\n          <NumberSetting\n            headingID=\"config.ui.slideshow_delay.heading\"\n            subHeadingID=\"config.ui.slideshow_delay.description\"\n            value={iface.imageLightbox?.slideshowDelay ?? undefined}\n            onChange={(v) => saveLightboxSettings({ slideshowDelay: v })}\n          />\n\n          <SelectSetting\n            id=\"lightbox_display_mode\"\n            headingID=\"dialogs.lightbox.display_mode.label\"\n            value={\n              iface.imageLightbox?.displayMode ??\n              GQL.ImageLightboxDisplayMode.FitXy\n            }\n            onChange={(v) =>\n              saveLightboxSettings({\n                displayMode: v as GQL.ImageLightboxDisplayMode,\n              })\n            }\n          >\n            {Array.from(imageLightboxDisplayModeIntlMap.entries()).map((v) => (\n              <option key={v[0]} value={v[0]}>\n                {intl.formatMessage({\n                  id: v[1],\n                })}\n              </option>\n            ))}\n          </SelectSetting>\n\n          <BooleanSetting\n            id=\"lightbox_scale_up\"\n            headingID=\"dialogs.lightbox.scale_up.label\"\n            subHeadingID=\"dialogs.lightbox.scale_up.description\"\n            checked={iface.imageLightbox?.scaleUp ?? false}\n            onChange={(v) => saveLightboxSettings({ scaleUp: v })}\n          />\n\n          <BooleanSetting\n            id=\"lightbox_reset_zoom_on_nav\"\n            headingID=\"dialogs.lightbox.reset_zoom_on_nav\"\n            checked={iface.imageLightbox?.resetZoomOnNav ?? false}\n            onChange={(v) => saveLightboxSettings({ resetZoomOnNav: v })}\n          />\n\n          <SelectSetting\n            id=\"lightbox_scroll_mode\"\n            headingID=\"dialogs.lightbox.scroll_mode.label\"\n            subHeadingID=\"dialogs.lightbox.scroll_mode.description\"\n            value={\n              iface.imageLightbox?.scrollMode ??\n              GQL.ImageLightboxScrollMode.Zoom\n            }\n            onChange={(v) =>\n              saveLightboxSettings({\n                scrollMode: v as GQL.ImageLightboxScrollMode,\n              })\n            }\n          >\n            {Array.from(imageLightboxScrollModeIntlMap.entries()).map((v) => (\n              <option key={v[0]} value={v[0]}>\n                {intl.formatMessage({\n                  id: v[1],\n                })}\n              </option>\n            ))}\n          </SelectSetting>\n\n          <NumberSetting\n            headingID=\"config.ui.scroll_attempts_before_change.heading\"\n            subHeadingID=\"config.ui.scroll_attempts_before_change.description\"\n            value={iface.imageLightbox?.scrollAttemptsBeforeChange ?? 0}\n            onChange={(v) =>\n              saveLightboxSettings({ scrollAttemptsBeforeChange: v })\n            }\n          />\n\n          <BooleanSetting\n            id=\"lightbox_disable_animation\"\n            headingID=\"dialogs.lightbox.disable_animation\"\n            checked={iface.imageLightbox?.disableAnimation ?? false}\n            onChange={(v) => saveLightboxSettings({ disableAnimation: v })}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.detail.heading\">\n          <div className=\"setting-group\">\n            <div className=\"setting\">\n              <div>\n                <h3>\n                  {intl.formatMessage({\n                    id: \"config.ui.detail.enable_background_image.heading\",\n                  })}\n                </h3>\n                <div className=\"sub-heading\">\n                  {intl.formatMessage({\n                    id: \"config.ui.detail.enable_background_image.description\",\n                  })}\n                </div>\n              </div>\n              <div />\n            </div>\n            <BooleanSetting\n              id=\"enableMovieBackgroundImage\"\n              headingID=\"group\"\n              checked={ui.enableMovieBackgroundImage ?? undefined}\n              onChange={(v) => saveUI({ enableMovieBackgroundImage: v })}\n            />\n            <BooleanSetting\n              id=\"enablePerformerBackgroundImage\"\n              headingID=\"performer\"\n              checked={ui.enablePerformerBackgroundImage ?? undefined}\n              onChange={(v) => saveUI({ enablePerformerBackgroundImage: v })}\n            />\n            <BooleanSetting\n              id=\"enableStudioBackgroundImage\"\n              headingID=\"studio\"\n              checked={ui.enableStudioBackgroundImage ?? undefined}\n              onChange={(v) => saveUI({ enableStudioBackgroundImage: v })}\n            />\n            <BooleanSetting\n              id=\"enableTagBackgroundImage\"\n              headingID=\"tag\"\n              checked={ui.enableTagBackgroundImage ?? undefined}\n              onChange={(v) => saveUI({ enableTagBackgroundImage: v })}\n            />\n          </div>\n          <BooleanSetting\n            id=\"show_all_details\"\n            headingID=\"config.ui.detail.show_all_details.heading\"\n            subHeadingID=\"config.ui.detail.show_all_details.description\"\n            checked={ui.showAllDetails ?? true}\n            onChange={(v) => saveUI({ showAllDetails: v })}\n          />\n          <BooleanSetting\n            id=\"compact_expanded_details\"\n            headingID=\"config.ui.detail.compact_expanded_details.heading\"\n            subHeadingID=\"config.ui.detail.compact_expanded_details.description\"\n            checked={ui.compactExpandedDetails ?? undefined}\n            onChange={(v) => saveUI({ compactExpandedDetails: v })}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.editing.heading\">\n          <div className=\"setting-group\">\n            <div className=\"setting\">\n              <div>\n                <h3>\n                  {intl.formatMessage({\n                    id: \"config.ui.editing.disable_dropdown_create.heading\",\n                  })}\n                </h3>\n                <div className=\"sub-heading\">\n                  {intl.formatMessage({\n                    id: \"config.ui.editing.disable_dropdown_create.description\",\n                  })}\n                </div>\n              </div>\n              <div />\n            </div>\n            <BooleanSetting\n              id=\"disableDropdownCreate_performer\"\n              headingID=\"performer\"\n              checked={iface.disableDropdownCreate?.performer ?? undefined}\n              onChange={(v) =>\n                saveInterface({\n                  disableDropdownCreate: {\n                    ...iface.disableDropdownCreate,\n                    performer: v,\n                  },\n                })\n              }\n            />\n            <BooleanSetting\n              id=\"disableDropdownCreate_studio\"\n              headingID=\"studio\"\n              checked={iface.disableDropdownCreate?.studio ?? undefined}\n              onChange={(v) =>\n                saveInterface({\n                  disableDropdownCreate: {\n                    ...iface.disableDropdownCreate,\n                    studio: v,\n                  },\n                })\n              }\n            />\n            <BooleanSetting\n              id=\"disableDropdownCreate_tag\"\n              headingID=\"tag\"\n              checked={iface.disableDropdownCreate?.tag ?? undefined}\n              onChange={(v) =>\n                saveInterface({\n                  disableDropdownCreate: {\n                    ...iface.disableDropdownCreate,\n                    tag: v,\n                  },\n                })\n              }\n            />\n            <BooleanSetting\n              id=\"disableDropdownCreate_group\"\n              headingID=\"group\"\n              checked={iface.disableDropdownCreate?.movie ?? undefined}\n              onChange={(v) =>\n                saveInterface({\n                  disableDropdownCreate: {\n                    ...iface.disableDropdownCreate,\n                    movie: v,\n                  },\n                })\n              }\n            />\n            <BooleanSetting\n              id=\"disableDropdownCreate_gallery\"\n              headingID=\"gallery\"\n              checked={iface.disableDropdownCreate?.gallery ?? undefined}\n              onChange={(v) =>\n                saveInterface({\n                  disableDropdownCreate: {\n                    ...iface.disableDropdownCreate,\n                    gallery: v,\n                  },\n                })\n              }\n            />\n          </div>\n          <NumberSetting\n            id=\"max_options_shown\"\n            headingID=\"config.ui.editing.max_options_shown.label\"\n            value={ui.maxOptionsShown ?? defaultMaxOptionsShown}\n            onChange={(v) => saveUI({ maxOptionsShown: v })}\n          />\n          <SelectSetting\n            id=\"rating_system\"\n            headingID=\"config.ui.editing.rating_system.type.label\"\n            value={ui.ratingSystemOptions?.type ?? defaultRatingSystemType}\n            onChange={(v) => saveRatingSystemType(v as RatingSystemType)}\n          >\n            {Array.from(ratingSystemIntlMap.entries()).map((v) => (\n              <option key={v[0]} value={v[0]}>\n                {intl.formatMessage({\n                  id: v[1],\n                })}\n              </option>\n            ))}\n          </SelectSetting>\n          {(ui.ratingSystemOptions?.type ?? defaultRatingSystemType) ===\n            RatingSystemType.Stars && (\n            <SelectSetting\n              id=\"rating_system_star_precision\"\n              headingID=\"config.ui.editing.rating_system.star_precision.label\"\n              value={\n                ui.ratingSystemOptions?.starPrecision ??\n                defaultRatingStarPrecision\n              }\n              onChange={(v) =>\n                saveRatingSystemStarPrecision(v as RatingStarPrecision)\n              }\n            >\n              {Array.from(ratingStarPrecisionIntlMap.entries()).map((v) => (\n                <option key={v[0]} value={v[0]}>\n                  {intl.formatMessage({\n                    id: v[1],\n                  })}\n                </option>\n              ))}\n            </SelectSetting>\n          )}\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.custom_css.heading\">\n          <BooleanSetting\n            id=\"custom-css-enabled\"\n            headingID=\"config.ui.custom_css.option_label\"\n            checked={iface.cssEnabled ?? undefined}\n            onChange={(v) => saveInterface({ cssEnabled: v })}\n          />\n\n          <ModalSetting<string>\n            id=\"custom-css\"\n            headingID=\"config.ui.custom_css.heading\"\n            subHeadingID=\"config.ui.custom_css.description\"\n            value={iface.css ?? undefined}\n            onChange={(v) => saveInterface({ css: v })}\n            renderField={(value, setValue) => (\n              <Form.Control\n                as=\"textarea\"\n                value={value}\n                onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>\n                  setValue(e.currentTarget.value)\n                }\n                rows={16}\n                className=\"text-input code\"\n              />\n            )}\n            renderValue={() => {\n              return <></>;\n            }}\n          />\n        </SettingSection>\n        <SettingSection headingID=\"config.ui.custom_javascript.heading\">\n          <BooleanSetting\n            id=\"custom-javascript-enabled\"\n            headingID=\"config.ui.custom_javascript.option_label\"\n            checked={iface.javascriptEnabled ?? undefined}\n            onChange={(v) => saveInterface({ javascriptEnabled: v })}\n          />\n\n          <ModalSetting<string>\n            id=\"custom-javascript\"\n            headingID=\"config.ui.custom_javascript.heading\"\n            subHeadingID=\"config.ui.custom_javascript.description\"\n            value={iface.javascript ?? undefined}\n            onChange={(v) => saveInterface({ javascript: v })}\n            validateChange={validateJavascriptString}\n            renderField={(value, setValue, err) => (\n              <>\n                <Form.Control\n                  as=\"textarea\"\n                  value={value}\n                  onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>\n                    setValue(e.currentTarget.value)\n                  }\n                  rows={16}\n                  className=\"text-input code\"\n                  isInvalid={!!err}\n                />\n                <Form.Control.Feedback type=\"invalid\">\n                  {err}\n                </Form.Control.Feedback>\n              </>\n            )}\n            renderValue={() => {\n              return <></>;\n            }}\n          />\n        </SettingSection>\n        <SettingSection headingID=\"config.ui.custom_locales.heading\">\n          <BooleanSetting\n            id=\"custom-locales-enabled\"\n            headingID=\"config.ui.custom_locales.option_label\"\n            checked={iface.customLocalesEnabled ?? undefined}\n            onChange={(v) => saveInterface({ customLocalesEnabled: v })}\n          />\n\n          <ModalSetting<string>\n            id=\"custom-locales\"\n            headingID=\"config.ui.custom_locales.heading\"\n            subHeadingID=\"config.ui.custom_locales.description\"\n            value={iface.customLocales ?? undefined}\n            onChange={(v) => saveInterface({ customLocales: v })}\n            validateChange={validateLocaleString}\n            renderField={(value, setValue, err) => (\n              <>\n                <Form.Control\n                  as=\"textarea\"\n                  value={value}\n                  onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>\n                    setValue(e.currentTarget.value)\n                  }\n                  rows={16}\n                  className=\"text-input code\"\n                  isInvalid={!!err}\n                />\n                <Form.Control.Feedback type=\"invalid\">\n                  {err}\n                </Form.Control.Feedback>\n              </>\n            )}\n            renderValue={() => {\n              return <></>;\n            }}\n          />\n        </SettingSection>\n\n        <SettingSection headingID=\"config.ui.interactive_options\">\n          <StringSetting\n            headingID=\"config.ui.handy_connection_key.heading\"\n            subHeadingID=\"config.ui.handy_connection_key.description\"\n            value={iface.handyKey ?? undefined}\n            onChange={(v) => saveInterface({ handyKey: v })}\n          />\n          {interactive.handyKey && (\n            <>\n              <div className=\"setting\" id=\"handy-status\">\n                <div>\n                  <h3>\n                    {intl.formatMessage({\n                      id: \"config.ui.handy_connection.status.heading\",\n                    })}\n                  </h3>\n\n                  <div className=\"value\">\n                    <FormattedMessage\n                      id={connectionStateLabel(interactiveState)}\n                    />\n                    {interactiveError && <span>: {interactiveError}</span>}\n                  </div>\n                </div>\n                <div>\n                  {!interactiveInitialised && (\n                    <Button\n                      disabled={\n                        interactiveState === ConnectionState.Connecting ||\n                        interactiveState === ConnectionState.Syncing\n                      }\n                      onClick={() => initialiseInteractive()}\n                    >\n                      {intl.formatMessage({\n                        id: \"config.ui.handy_connection.connect\",\n                      })}\n                    </Button>\n                  )}\n                </div>\n              </div>\n              <div className=\"setting\" id=\"handy-server-offset\">\n                <div>\n                  <h3>\n                    {intl.formatMessage({\n                      id: \"config.ui.handy_connection.server_offset.heading\",\n                    })}\n                  </h3>\n\n                  <div className=\"value\">\n                    {interactiveServerOffset.toFixed()}ms\n                  </div>\n                </div>\n                <div>\n                  {interactiveInitialised && (\n                    <Button\n                      disabled={\n                        !interactiveInitialised ||\n                        interactiveState === ConnectionState.Syncing\n                      }\n                      onClick={() => interactiveSync()}\n                    >\n                      {intl.formatMessage({\n                        id: \"config.ui.handy_connection.sync\",\n                      })}\n                    </Button>\n                  )}\n                </div>\n              </div>\n            </>\n          )}\n\n          <NumberSetting\n            headingID=\"config.ui.funscript_offset.heading\"\n            subHeadingID=\"config.ui.funscript_offset.description\"\n            value={iface.funscriptOffset ?? undefined}\n            onChange={(v) => saveInterface({ funscriptOffset: v })}\n          />\n\n          <BooleanSetting\n            id=\"use-stash-hosted-funscript\"\n            headingID=\"config.ui.use_stash_hosted_funscript.heading\"\n            subHeadingID=\"config.ui.use_stash_hosted_funscript.description\"\n            checked={iface.useStashHostedFunscript ?? false}\n            onChange={(v) => saveInterface({ useStashHostedFunscript: v })}\n          />\n        </SettingSection>\n      </>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx",
    "content": "import React from \"react\";\nimport { Icon } from \"../Shared/Icon\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { StashSetting } from \"./StashConfiguration\";\nimport { SettingSection } from \"./SettingSection\";\nimport { BooleanSetting, StringListSetting, StringSetting } from \"./Inputs\";\nimport { useSettings } from \"./context\";\nimport { useIntl } from \"react-intl\";\nimport { faQuestionCircle } from \"@fortawesome/free-solid-svg-icons\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\n\nexport const SettingsLibraryPanel: React.FC = () => {\n  const intl = useIntl();\n  const { general, loading, error, saveGeneral, defaults, saveDefaults } =\n    useSettings();\n\n  function commaDelimitedToList(value: string | undefined) {\n    if (value) {\n      return value.split(\",\").map((s) => s.trim());\n    }\n  }\n\n  function listToCommaDelimited(value: string[] | undefined) {\n    if (value) {\n      return value.join(\", \");\n    }\n  }\n\n  if (error) return <h1>{error.message}</h1>;\n  if (loading) return <LoadingIndicator />;\n\n  return (\n    <>\n      <StashSetting\n        value={general.stashes ?? []}\n        onChange={(v) => saveGeneral({ stashes: v })}\n      />\n\n      <SettingSection headingID=\"config.library.media_content_extensions\">\n        <StringSetting\n          id=\"video-extensions\"\n          headingID=\"config.general.video_ext_head\"\n          subHeadingID=\"config.general.video_ext_desc\"\n          value={listToCommaDelimited(general.videoExtensions ?? undefined)}\n          onChange={(v) =>\n            saveGeneral({ videoExtensions: commaDelimitedToList(v) })\n          }\n        />\n\n        <StringSetting\n          id=\"image-extensions\"\n          headingID=\"config.general.image_ext_head\"\n          subHeadingID=\"config.general.image_ext_desc\"\n          value={listToCommaDelimited(general.imageExtensions ?? undefined)}\n          onChange={(v) =>\n            saveGeneral({ imageExtensions: commaDelimitedToList(v) })\n          }\n        />\n\n        <StringSetting\n          id=\"gallery-extensions\"\n          headingID=\"config.general.gallery_ext_head\"\n          subHeadingID=\"config.general.gallery_ext_desc\"\n          value={listToCommaDelimited(general.galleryExtensions ?? undefined)}\n          onChange={(v) =>\n            saveGeneral({ galleryExtensions: commaDelimitedToList(v) })\n          }\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.library.exclusions\">\n        <StringListSetting\n          id=\"excluded-video-patterns\"\n          headingID=\"config.general.excluded_video_patterns_head\"\n          subHeading={\n            <span>\n              {intl.formatMessage({\n                id: \"config.general.excluded_video_patterns_desc\",\n              })}\n              <ExternalLink href=\"https://docs.stashapp.cc/beginner-guides/exclude-file-configuration\">\n                <Icon icon={faQuestionCircle} />\n              </ExternalLink>\n            </span>\n          }\n          value={general.excludes ?? undefined}\n          onChange={(v) => saveGeneral({ excludes: v })}\n          defaultNewValue=\"sample\\.mp4$\"\n        />\n\n        <StringListSetting\n          id=\"excluded-image-gallery-patterns\"\n          headingID=\"config.general.excluded_image_gallery_patterns_head\"\n          subHeading={\n            <span>\n              {intl.formatMessage({\n                id: \"config.general.excluded_image_gallery_patterns_desc\",\n              })}\n              <ExternalLink href=\"https://docs.stashapp.cc/beginner-guides/exclude-file-configuration\">\n                <Icon icon={faQuestionCircle} />\n              </ExternalLink>\n            </span>\n          }\n          value={general.imageExcludes ?? undefined}\n          onChange={(v) => saveGeneral({ imageExcludes: v })}\n          defaultNewValue=\"sample\\.jpg$\"\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.library.gallery_and_image_options\">\n        <BooleanSetting\n          id=\"create-galleries-from-folders\"\n          headingID=\"config.general.create_galleries_from_folders_label\"\n          subHeadingID=\"config.general.create_galleries_from_folders_desc\"\n          checked={general.createGalleriesFromFolders ?? false}\n          onChange={(v) => saveGeneral({ createGalleriesFromFolders: v })}\n        />\n\n        <BooleanSetting\n          id=\"write-image-thumbnails\"\n          headingID=\"config.ui.images.options.write_image_thumbnails.heading\"\n          subHeadingID=\"config.ui.images.options.write_image_thumbnails.description\"\n          checked={general.writeImageThumbnails ?? false}\n          onChange={(v) => saveGeneral({ writeImageThumbnails: v })}\n        />\n\n        <BooleanSetting\n          id=\"create-image-clips-from-videos\"\n          headingID=\"config.ui.images.options.create_image_clips_from_videos.heading\"\n          subHeadingID=\"config.ui.images.options.create_image_clips_from_videos.description\"\n          checked={general.createImageClipsFromVideos ?? false}\n          onChange={(v) => saveGeneral({ createImageClipsFromVideos: v })}\n        />\n\n        <StringSetting\n          id=\"gallery-cover-regex\"\n          headingID=\"config.general.gallery_cover_regex_label\"\n          subHeadingID=\"config.general.gallery_cover_regex_desc\"\n          value={general.galleryCoverRegex ?? \"\"}\n          onChange={(v) => saveGeneral({ galleryCoverRegex: v })}\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.ui.delete_options.heading\">\n        <BooleanSetting\n          id=\"delete-file-default\"\n          headingID=\"config.ui.delete_options.options.delete_file\"\n          checked={defaults.deleteFile ?? undefined}\n          onChange={(v) => {\n            saveDefaults({ deleteFile: v });\n          }}\n        />\n        <BooleanSetting\n          id=\"delete-generated-default\"\n          headingID=\"config.ui.delete_options.options.delete_generated_supporting_files\"\n          subHeadingID=\"config.ui.delete_options.description\"\n          checked={defaults.deleteGenerated ?? undefined}\n          onChange={(v) => {\n            saveDefaults({ deleteGenerated: v });\n          }}\n        />\n      </SettingSection>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useLoggingSubscribe, queryLogs } from \"src/core/StashService\";\nimport { SelectSetting } from \"./Inputs\";\nimport { SettingSection } from \"./SettingSection\";\nimport { JobTable } from \"./Tasks/JobTable\";\n\nfunction convertTime(logEntry: GQL.LogEntryDataFragment) {\n  function pad(val: number) {\n    let ret = val.toString();\n    if (val <= 9) {\n      ret = `0${ret}`;\n    }\n\n    return ret;\n  }\n\n  const date = new Date(logEntry.time);\n  const month = date.getMonth() + 1;\n  const day = date.getDate();\n  let dateStr = `${date.getFullYear()}-${pad(month)}-${pad(day)}`;\n  dateStr += ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(\n    date.getSeconds()\n  )}`;\n\n  return dateStr;\n}\n\nfunction levelClass(level: string) {\n  return level.toLowerCase().trim();\n}\n\ninterface ILogElementProps {\n  logEntry: LogEntry;\n}\n\nconst LogElement: React.FC<ILogElementProps> = ({ logEntry }) => {\n  // pad to maximum length of level enum\n  const level = logEntry.level.padEnd(GQL.LogLevel.Progress.length);\n\n  return (\n    <div className=\"row\">\n      <span className=\"log-time\">{logEntry.time}</span>\n      <span className={`${levelClass(logEntry.level)}`}>{level}</span>\n      <span className=\"col col-sm-9\">{logEntry.message}</span>\n    </div>\n  );\n};\n\nclass LogEntry {\n  public time: string;\n  public level: string;\n  public message: string;\n  public id: string;\n\n  private static nextId: number = 0;\n\n  public constructor(logEntry: GQL.LogEntryDataFragment) {\n    this.time = convertTime(logEntry);\n    this.level = logEntry.level;\n    this.message = logEntry.message;\n\n    const id = LogEntry.nextId++;\n    this.id = id.toString();\n  }\n}\n\n// maximum number of log entries to keep - entries are discarded oldest-first\nconst MAX_LOG_ENTRIES = 50000;\n// maximum number of log entries to display\nconst MAX_DISPLAY_LOG_ENTRIES = 1000;\nconst logLevels = [\"Trace\", \"Debug\", \"Info\", \"Warning\", \"Error\"];\n\nexport const SettingsLogsPanel: React.FC = () => {\n  const [entries, setEntries] = useState<LogEntry[]>([]);\n  const { data, error } = useLoggingSubscribe();\n  const [logLevel, setLogLevel] = useState<string>(\"Info\");\n  const intl = useIntl();\n\n  useEffect(() => {\n    async function getInitialLogs() {\n      const logQuery = await queryLogs();\n      if (logQuery.error) return;\n\n      const initEntries = logQuery.data.logs.map((e) => new LogEntry(e));\n      if (initEntries.length !== 0) {\n        setEntries((prev) => {\n          return [...prev, ...initEntries].slice(0, MAX_LOG_ENTRIES);\n        });\n      }\n    }\n\n    getInitialLogs();\n  }, []);\n\n  useEffect(() => {\n    if (!data) return;\n\n    const newEntries = data.loggingSubscribe.map((e) => new LogEntry(e));\n    newEntries.reverse();\n    setEntries((prev) => {\n      return [...newEntries, ...prev].slice(0, MAX_LOG_ENTRIES);\n    });\n  }, [data]);\n\n  const displayEntries = entries\n    .filter(filterByLogLevel)\n    .slice(0, MAX_DISPLAY_LOG_ENTRIES);\n\n  function maybeRenderError() {\n    if (error) {\n      return (\n        <div className=\"error\">\n          Error connecting to log server: {error.message}\n        </div>\n      );\n    }\n  }\n\n  function filterByLogLevel(logEntry: LogEntry) {\n    if (logLevel === \"Trace\") return true;\n\n    const logLevelIndex = logLevels.indexOf(logLevel);\n    const levelIndex = logLevels.indexOf(logEntry.level);\n\n    return levelIndex >= logLevelIndex;\n  }\n\n  return (\n    <>\n      <h2>{intl.formatMessage({ id: \"config.tasks.job_queue\" })}</h2>\n      <JobTable />\n      <SettingSection headingID=\"config.categories.logs\">\n        <SelectSetting\n          id=\"log-level\"\n          headingID=\"config.logs.log_level\"\n          value={logLevel}\n          onChange={(v) => setLogLevel(v)}\n        >\n          {logLevels.map((level) => (\n            <option key={level} value={level}>\n              {level}\n            </option>\n          ))}\n        </SelectSetting>\n      </SettingSection>\n\n      <div className=\"logs\">\n        {maybeRenderError()}\n        {displayEntries.map((logEntry) => (\n          <LogElement logEntry={logEntry} key={logEntry.id} />\n        ))}\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  mutateReloadPlugins,\n  mutateSetPluginsEnabled,\n  usePlugins,\n} from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport TextUtils from \"src/utils/text\";\nimport { CollapseButton } from \"../Shared/CollapseButton\";\nimport { Icon } from \"../Shared/Icon\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { SettingSection } from \"./SettingSection\";\nimport {\n  BooleanSetting,\n  NumberSetting,\n  Setting,\n  SettingGroup,\n  StringSetting,\n} from \"./Inputs\";\nimport { faLink, faSyncAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { useSettings } from \"./context\";\nimport {\n  AvailablePluginPackages,\n  InstalledPluginPackages,\n} from \"./PluginPackageManager\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IPluginSettingProps {\n  pluginID: string;\n  setting: GQL.PluginSetting;\n  value: unknown;\n  onChange: (value: unknown) => void;\n}\n\nconst PluginSetting: React.FC<IPluginSettingProps> = ({\n  pluginID,\n  setting,\n  value,\n  onChange,\n}) => {\n  const commonProps = {\n    heading: setting.display_name ? setting.display_name : setting.name,\n    id: `plugin-${pluginID}-${setting.name}`,\n    subHeading: setting.description ?? undefined,\n  };\n\n  switch (setting.type) {\n    case GQL.PluginSettingTypeEnum.Boolean:\n      return (\n        <BooleanSetting\n          {...commonProps}\n          checked={(value as boolean) ?? false}\n          onChange={() => onChange(!value)}\n        />\n      );\n    case GQL.PluginSettingTypeEnum.String:\n      return (\n        <StringSetting\n          {...commonProps}\n          value={(value as string) ?? \"\"}\n          onChange={(v) => onChange(v)}\n        />\n      );\n    case GQL.PluginSettingTypeEnum.Number:\n      return (\n        <NumberSetting\n          {...commonProps}\n          value={(value as number) ?? 0}\n          onChange={(v) => onChange(v)}\n        />\n      );\n  }\n};\n\nconst PluginSettings: React.FC<{\n  pluginID: string;\n  settings: GQL.PluginSetting[];\n}> = PatchComponent(\"PluginSettings\", ({ pluginID, settings }) => {\n  const { plugins, savePluginSettings } = useSettings();\n  const pluginSettings = plugins[pluginID] ?? {};\n\n  return (\n    <div className=\"plugin-settings\">\n      {settings.map((setting) => (\n        <PluginSetting\n          key={setting.name}\n          pluginID={pluginID}\n          setting={setting}\n          value={pluginSettings[setting.name]}\n          onChange={(v) =>\n            savePluginSettings(pluginID, {\n              ...pluginSettings,\n              [setting.name]: v,\n            })\n          }\n        />\n      ))}\n    </div>\n  );\n});\n\nexport const SettingsPluginsPanel: React.FC = () => {\n  const Toast = useToast();\n  const intl = useIntl();\n\n  const { loading: configLoading } = useSettings();\n  const { data, loading } = usePlugins();\n\n  const [changedPluginID, setChangedPluginID] = React.useState<\n    string | undefined\n  >();\n\n  async function onReloadPlugins() {\n    try {\n      await mutateReloadPlugins();\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  const pluginElements = useMemo(() => {\n    function renderLink(url?: string) {\n      if (url) {\n        return (\n          <Button\n            as={ExternalLink}\n            href={TextUtils.sanitiseURL(url)}\n            className=\"minimal link\"\n          >\n            <Icon icon={faLink} />\n          </Button>\n        );\n      }\n    }\n\n    function renderEnableButton(pluginID: string, enabled: boolean) {\n      async function onClick() {\n        try {\n          await mutateSetPluginsEnabled({ [pluginID]: !enabled });\n        } catch (e) {\n          Toast.error(e);\n        }\n\n        setChangedPluginID(pluginID);\n      }\n\n      return (\n        <Button size=\"sm\" onClick={onClick}>\n          <FormattedMessage\n            id={enabled ? \"actions.disable\" : \"actions.enable\"}\n          />\n        </Button>\n      );\n    }\n\n    function onReloadUI() {\n      window.location.reload();\n    }\n\n    function maybeRenderReloadUI(pluginID: string) {\n      if (pluginID === changedPluginID) {\n        return (\n          <Button size=\"sm\" onClick={() => onReloadUI()}>\n            Reload UI\n          </Button>\n        );\n      }\n    }\n\n    function renderPlugins() {\n      const elements = (data?.plugins ?? []).map((plugin) => (\n        <SettingGroup\n          key={plugin.id}\n          settingProps={{\n            heading: `${plugin.name} ${\n              plugin.version ? `(${plugin.version})` : undefined\n            }`,\n            className: !plugin.enabled ? \"disabled\" : undefined,\n            subHeading: plugin.description,\n          }}\n          topLevel={\n            <>\n              {renderLink(plugin.url ?? undefined)}\n              {maybeRenderReloadUI(plugin.id)}\n              {renderEnableButton(plugin.id, plugin.enabled)}\n            </>\n          }\n        >\n          {renderPluginHooks(plugin.hooks ?? undefined)}\n          <PluginSettings\n            pluginID={plugin.id}\n            settings={plugin.settings ?? []}\n          />\n        </SettingGroup>\n      ));\n\n      return <div>{elements}</div>;\n    }\n\n    function renderPluginHooks(\n      hooks?: Pick<GQL.PluginHook, \"name\" | \"description\" | \"hooks\">[]\n    ) {\n      if (!hooks || hooks.length === 0) {\n        return;\n      }\n\n      return (\n        <div className=\"setting\">\n          <div>\n            <h5>\n              <FormattedMessage id=\"config.plugins.hooks\" />\n            </h5>\n            {hooks.map((h) => (\n              <div key={`${h.name}`}>\n                <h6>{h.name}</h6>\n                <CollapseButton\n                  text={intl.formatMessage({\n                    id: \"config.plugins.triggers_on\",\n                  })}\n                >\n                  <ul>\n                    {h.hooks?.map((hh) => (\n                      <li key={hh}>\n                        <code>{hh}</code>\n                      </li>\n                    ))}\n                  </ul>\n                </CollapseButton>\n                <small className=\"text-muted\">{h.description}</small>\n              </div>\n            ))}\n          </div>\n          <div />\n        </div>\n      );\n    }\n\n    return renderPlugins();\n  }, [data?.plugins, intl, Toast, changedPluginID]);\n\n  if (loading || configLoading) return <LoadingIndicator />;\n\n  return (\n    <>\n      <InstalledPluginPackages />\n      <AvailablePluginPackages />\n\n      <SettingSection headingID=\"config.categories.plugins\">\n        <Setting headingID=\"actions.reload_plugins\">\n          <Button onClick={() => onReloadPlugins()}>\n            <span className=\"fa-icon\">\n              <Icon icon={faSyncAlt} />\n            </span>\n            <span>\n              <FormattedMessage id=\"actions.reload_plugins\" />\n            </span>\n          </Button>\n        </Setting>\n        {pluginElements}\n      </SettingSection>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx",
    "content": "import React, { PropsWithChildren, useMemo, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Button } from \"react-bootstrap\";\nimport {\n  mutateReloadScrapers,\n  useListGroupScrapers,\n  useListPerformerScrapers,\n  useListSceneScrapers,\n  useListGalleryScrapers,\n  useListImageScrapers,\n} from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport TextUtils from \"src/utils/text\";\nimport { CollapseButton } from \"../Shared/CollapseButton\";\nimport { Icon } from \"../Shared/Icon\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { ScrapeType } from \"src/core/generated-graphql\";\nimport { SettingSection } from \"./SettingSection\";\nimport { BooleanSetting, StringListSetting, StringSetting } from \"./Inputs\";\nimport { useSettings } from \"./context\";\nimport { StashBoxSetting } from \"./StashBoxConfiguration\";\nimport { faSyncAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport {\n  AvailableScraperPackages,\n  InstalledScraperPackages,\n} from \"./ScraperPackageManager\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\nimport { ClearableInput } from \"../Shared/ClearableInput\";\nimport { Counter } from \"../Shared/Counter\";\n\nconst ScraperTable: React.FC<\n  PropsWithChildren<{\n    entityType: string;\n    count?: number;\n  }>\n> = ({ entityType, count, children }) => {\n  const intl = useIntl();\n\n  const titleEl = useMemo(() => {\n    const title = intl.formatMessage(\n      { id: \"config.scraping.entity_scrapers\" },\n      { entityType: intl.formatMessage({ id: entityType }) }\n    );\n\n    if (count) {\n      return (\n        <span>\n          {title} <Counter count={count} />\n        </span>\n      );\n    }\n\n    return title;\n  }, [count, entityType, intl]);\n\n  return (\n    <CollapseButton text={titleEl}>\n      <table className=\"scraper-table\">\n        <thead>\n          <tr>\n            <th>\n              <FormattedMessage id=\"name\" />\n            </th>\n            <th>\n              <FormattedMessage id=\"config.scraping.supported_types\" />\n            </th>\n            <th>\n              <FormattedMessage id=\"config.scraping.supported_urls\" />\n            </th>\n          </tr>\n        </thead>\n        <tbody>{children}</tbody>\n      </table>\n    </CollapseButton>\n  );\n};\n\nconst ScrapeTypeList: React.FC<{\n  types: ScrapeType[];\n  entityType: string;\n}> = ({ types, entityType }) => {\n  const intl = useIntl();\n\n  const typeStrings = useMemo(\n    () =>\n      types.map((t) => {\n        switch (t) {\n          case ScrapeType.Fragment:\n            return intl.formatMessage(\n              { id: \"config.scraping.entity_metadata\" },\n              { entityType: intl.formatMessage({ id: entityType }) }\n            );\n          default:\n            return t;\n        }\n      }),\n    [types, entityType, intl]\n  );\n\n  return (\n    <ul>\n      {typeStrings.map((t) => (\n        <li key={t}>{t}</li>\n      ))}\n    </ul>\n  );\n};\n\ninterface IURLList {\n  urls: string[];\n}\n\nconst URLList: React.FC<IURLList> = ({ urls }) => {\n  const items = useMemo(() => {\n    function linkSite(url: string) {\n      const u = new URL(url);\n      return `${u.protocol}//${u.host}`;\n    }\n\n    const ret = urls\n      .slice()\n      .sort()\n      .map((u) => {\n        const sanitised = TextUtils.sanitiseURL(u);\n        const siteURL = linkSite(sanitised!);\n\n        return (\n          <li key={u}>\n            <ExternalLink href={siteURL}>{sanitised}</ExternalLink>\n          </li>\n        );\n      });\n\n    return ret;\n  }, [urls]);\n\n  return <ul>{items}</ul>;\n};\n\nconst ScraperTableRow: React.FC<{\n  name: string;\n  entityType: string;\n  supportedScrapes: ScrapeType[];\n  urls: string[];\n}> = ({ name, entityType, supportedScrapes, urls }) => {\n  return (\n    <tr>\n      <td>{name}</td>\n      <td>\n        <ScrapeTypeList types={supportedScrapes} entityType={entityType} />\n      </td>\n      <td>\n        <URLList urls={urls} />\n      </td>\n    </tr>\n  );\n};\n\nfunction filterScraper(filter: string) {\n  return (name: string, urls: string[] | undefined | null) => {\n    if (!filter) return true;\n\n    return (\n      name.toLowerCase().includes(filter) ||\n      urls?.some((url) => url.toLowerCase().includes(filter))\n    );\n  };\n}\n\nconst ScrapersSection: React.FC = () => {\n  const Toast = useToast();\n  const intl = useIntl();\n\n  const [filter, setFilter] = useState(\"\");\n\n  const { data: performerScrapers, loading: loadingPerformers } =\n    useListPerformerScrapers();\n  const { data: sceneScrapers, loading: loadingScenes } =\n    useListSceneScrapers();\n  const { data: galleryScrapers, loading: loadingGalleries } =\n    useListGalleryScrapers();\n  const { data: imageScrapers, loading: loadingImages } =\n    useListImageScrapers();\n  const { data: groupScrapers, loading: loadingGroups } =\n    useListGroupScrapers();\n\n  const filteredScrapers = useMemo(() => {\n    const filterFn = filterScraper(filter.toLowerCase());\n    return {\n      performers: performerScrapers?.listScrapers.filter((s) =>\n        filterFn(s.name, s.performer?.urls)\n      ),\n      scenes: sceneScrapers?.listScrapers.filter((s) =>\n        filterFn(s.name, s.scene?.urls)\n      ),\n      galleries: galleryScrapers?.listScrapers.filter((s) =>\n        filterFn(s.name, s.gallery?.urls)\n      ),\n      images: imageScrapers?.listScrapers.filter((s) =>\n        filterFn(s.name, s.image?.urls)\n      ),\n      groups: groupScrapers?.listScrapers.filter((s) =>\n        filterFn(s.name, s.group?.urls)\n      ),\n    };\n  }, [\n    performerScrapers,\n    sceneScrapers,\n    galleryScrapers,\n    imageScrapers,\n    groupScrapers,\n    filter,\n  ]);\n\n  async function onReloadScrapers() {\n    try {\n      await mutateReloadScrapers();\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  if (\n    loadingScenes ||\n    loadingGalleries ||\n    loadingPerformers ||\n    loadingGroups ||\n    loadingImages\n  )\n    return (\n      <SettingSection headingID=\"config.scraping.scrapers\">\n        <LoadingIndicator />\n      </SettingSection>\n    );\n\n  return (\n    <SettingSection headingID=\"config.scraping.scrapers\">\n      <div className=\"content scraper-toolbar\">\n        <ClearableInput\n          placeholder={`${intl.formatMessage({ id: \"filter\" })}...`}\n          value={filter}\n          setValue={(v) => setFilter(v)}\n        />\n\n        <Button onClick={() => onReloadScrapers()}>\n          <span className=\"fa-icon\">\n            <Icon icon={faSyncAlt} />\n          </span>\n          <span>\n            <FormattedMessage id=\"actions.reload_scrapers\" />\n          </span>\n        </Button>\n      </div>\n\n      <div className=\"content\">\n        {!!filteredScrapers.scenes?.length && (\n          <ScraperTable\n            entityType=\"scene\"\n            count={filteredScrapers.scenes?.length}\n          >\n            {filteredScrapers.scenes?.map((scraper) => (\n              <ScraperTableRow\n                key={scraper.id}\n                name={scraper.name}\n                entityType=\"scene\"\n                supportedScrapes={scraper.scene?.supported_scrapes ?? []}\n                urls={scraper.scene?.urls ?? []}\n              />\n            ))}\n          </ScraperTable>\n        )}\n\n        {!!filteredScrapers.galleries?.length && (\n          <ScraperTable\n            entityType=\"gallery\"\n            count={filteredScrapers.galleries?.length}\n          >\n            {filteredScrapers.galleries?.map((scraper) => (\n              <ScraperTableRow\n                key={scraper.id}\n                name={scraper.name}\n                entityType=\"gallery\"\n                supportedScrapes={scraper.gallery?.supported_scrapes ?? []}\n                urls={scraper.gallery?.urls ?? []}\n              />\n            ))}\n          </ScraperTable>\n        )}\n\n        {!!filteredScrapers.images?.length && (\n          <ScraperTable\n            entityType=\"image\"\n            count={filteredScrapers.images?.length}\n          >\n            {filteredScrapers.images?.map((scraper) => (\n              <ScraperTableRow\n                key={scraper.id}\n                name={scraper.name}\n                entityType=\"image\"\n                supportedScrapes={scraper.image?.supported_scrapes ?? []}\n                urls={scraper.image?.urls ?? []}\n              />\n            ))}\n          </ScraperTable>\n        )}\n\n        {!!filteredScrapers.performers?.length && (\n          <ScraperTable\n            entityType=\"performer\"\n            count={filteredScrapers.performers?.length}\n          >\n            {filteredScrapers.performers?.map((scraper) => (\n              <ScraperTableRow\n                key={scraper.id}\n                name={scraper.name}\n                entityType=\"performer\"\n                supportedScrapes={scraper.performer?.supported_scrapes ?? []}\n                urls={scraper.performer?.urls ?? []}\n              />\n            ))}\n          </ScraperTable>\n        )}\n\n        {!!filteredScrapers.groups?.length && (\n          <ScraperTable\n            entityType=\"group\"\n            count={filteredScrapers.groups?.length}\n          >\n            {filteredScrapers.groups?.map((scraper) => (\n              <ScraperTableRow\n                key={scraper.id}\n                name={scraper.name}\n                entityType=\"group\"\n                supportedScrapes={scraper.group?.supported_scrapes ?? []}\n                urls={scraper.group?.urls ?? []}\n              />\n            ))}\n          </ScraperTable>\n        )}\n      </div>\n    </SettingSection>\n  );\n};\n\nexport const SettingsScrapingPanel: React.FC = () => {\n  const { general, scraping, loading, error, saveGeneral, saveScraping } =\n    useSettings();\n\n  if (error) return <h1>{error.message}</h1>;\n  if (loading) return <LoadingIndicator />;\n\n  return (\n    <>\n      <StashBoxSetting\n        value={general.stashBoxes ?? []}\n        onChange={(v) => saveGeneral({ stashBoxes: v })}\n      />\n\n      <SettingSection headingID=\"config.general.scraping\">\n        <StringSetting\n          id=\"scraperUserAgent\"\n          headingID=\"config.general.scraper_user_agent\"\n          subHeadingID=\"config.general.scraper_user_agent_desc\"\n          value={scraping.scraperUserAgent ?? undefined}\n          onChange={(v) => saveScraping({ scraperUserAgent: v })}\n        />\n\n        <StringSetting\n          id=\"scraperCDPPath\"\n          headingID=\"config.general.chrome_cdp_path\"\n          subHeadingID=\"config.general.chrome_cdp_path_desc\"\n          value={scraping.scraperCDPPath ?? undefined}\n          onChange={(v) => saveScraping({ scraperCDPPath: v })}\n        />\n\n        <BooleanSetting\n          id=\"scraper-cert-check\"\n          headingID=\"config.general.check_for_insecure_certificates\"\n          subHeadingID=\"config.general.check_for_insecure_certificates_desc\"\n          checked={scraping.scraperCertCheck ?? undefined}\n          onChange={(v) => saveScraping({ scraperCertCheck: v })}\n        />\n\n        <StringListSetting\n          id=\"excluded-tag-patterns\"\n          headingID=\"config.scraping.excluded_tag_patterns_head\"\n          subHeadingID=\"config.scraping.excluded_tag_patterns_desc\"\n          value={scraping.excludeTagPatterns ?? undefined}\n          onChange={(v) => saveScraping({ excludeTagPatterns: v })}\n        />\n      </SettingSection>\n\n      <InstalledScraperPackages />\n      <AvailableScraperPackages />\n\n      <ScrapersSection />\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx",
    "content": "import React from \"react\";\nimport { ModalSetting, NumberSetting } from \"./Inputs\";\nimport { SettingSection } from \"./SettingSection\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useSettings } from \"./context\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useGenerateAPIKey } from \"src/core/StashService\";\n\ntype AuthenticationSettingsInput = Pick<\n  GQL.ConfigGeneralInput,\n  \"username\" | \"password\"\n>;\n\ninterface IAuthenticationInput {\n  value: AuthenticationSettingsInput;\n  setValue: (v: AuthenticationSettingsInput) => void;\n}\n\nconst AuthenticationInput: React.FC<IAuthenticationInput> = ({\n  value,\n  setValue,\n}) => {\n  const intl = useIntl();\n\n  function set(v: Partial<AuthenticationSettingsInput>) {\n    setValue({\n      ...value,\n      ...v,\n    });\n  }\n\n  const { username, password } = value;\n\n  return (\n    <div>\n      <Form.Group id=\"username\">\n        <h6>{intl.formatMessage({ id: \"config.general.auth.username\" })}</h6>\n        <Form.Control\n          className=\"text-input\"\n          value={username ?? \"\"}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            set({ username: e.currentTarget.value })\n          }\n        />\n        <Form.Text className=\"text-muted\">\n          {intl.formatMessage({ id: \"config.general.auth.username_desc\" })}\n        </Form.Text>\n      </Form.Group>\n      <Form.Group id=\"password\">\n        <h6>{intl.formatMessage({ id: \"config.general.auth.password\" })}</h6>\n        <Form.Control\n          className=\"text-input\"\n          type=\"password\"\n          value={password ?? \"\"}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            set({ password: e.currentTarget.value })\n          }\n        />\n        <Form.Text className=\"text-muted\">\n          {intl.formatMessage({ id: \"config.general.auth.password_desc\" })}\n        </Form.Text>\n      </Form.Group>\n    </div>\n  );\n};\n\nexport const SettingsSecurityPanel: React.FC = () => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const { general, apiKey, loading, error, saveGeneral, refetch } =\n    useSettings();\n\n  const [generateAPIKey] = useGenerateAPIKey();\n\n  async function onGenerateAPIKey() {\n    try {\n      await generateAPIKey({\n        variables: {\n          input: {},\n        },\n      });\n      refetch();\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function onClearAPIKey() {\n    try {\n      await generateAPIKey({\n        variables: {\n          input: {\n            clear: true,\n          },\n        },\n      });\n      refetch();\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  if (error) return <h1>{error.message}</h1>;\n  if (loading) return <LoadingIndicator />;\n\n  return (\n    <>\n      <SettingSection headingID=\"config.general.auth.authentication\">\n        <ModalSetting<AuthenticationSettingsInput>\n          id=\"authentication-settings\"\n          headingID=\"config.general.auth.credentials.heading\"\n          subHeadingID=\"config.general.auth.credentials.description\"\n          value={{\n            username: general.username,\n            password: general.password,\n          }}\n          onChange={(v) => saveGeneral(v)}\n          renderField={(value, setValue) => (\n            <AuthenticationInput value={value ?? {}} setValue={setValue} />\n          )}\n          renderValue={(v) => {\n            if (v?.username && v?.password)\n              return <span>{v?.username ?? \"\"}</span>;\n            return <></>;\n          }}\n        />\n\n        <div className=\"setting\" id=\"apikey\">\n          <div>\n            <h3>{intl.formatMessage({ id: \"config.general.auth.api_key\" })}</h3>\n\n            <div className=\"value text-break\">{apiKey}</div>\n\n            <div className=\"sub-heading\">\n              {intl.formatMessage({ id: \"config.general.auth.api_key_desc\" })}\n            </div>\n          </div>\n          <div>\n            <Button\n              disabled={!general.username || !general.password}\n              onClick={() => onGenerateAPIKey()}\n            >\n              {intl.formatMessage({\n                id: \"config.general.auth.generate_api_key\",\n              })}\n            </Button>\n            <Button variant=\"danger\" onClick={() => onClearAPIKey()}>\n              {intl.formatMessage({\n                id: \"config.general.auth.clear_api_key\",\n              })}\n            </Button>\n          </div>\n        </div>\n\n        <NumberSetting\n          id=\"maxSessionAge\"\n          headingID=\"config.general.auth.maximum_session_age\"\n          subHeadingID=\"config.general.auth.maximum_session_age_desc\"\n          value={general.maxSessionAge ?? undefined}\n          onChange={(v) => saveGeneral({ maxSessionAge: v })}\n        />\n      </SettingSection>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport {\n  useDisableDLNA,\n  useDLNAStatus,\n  useEnableDLNA,\n  useAddTempDLNAIP,\n  useRemoveTempDLNAIP,\n} from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { DurationInput } from \"../Shared/DurationInput\";\nimport { Icon } from \"../Shared/Icon\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { SettingSection } from \"./SettingSection\";\nimport {\n  BooleanSetting,\n  StringListSetting,\n  StringSetting,\n  SelectSetting,\n  NumberSetting,\n} from \"./Inputs\";\nimport { useSettings } from \"./context\";\nimport {\n  videoSortOrderIntlMap,\n  defaultVideoSort,\n} from \"src/utils/dlnaVideoSort\";\nimport {\n  faClock,\n  faTimes,\n  faUserClock,\n} from \"@fortawesome/free-solid-svg-icons\";\n\nconst defaultDLNAPort = 1338;\n\nexport const SettingsServicesPanel: React.FC = () => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const { dlna, loading: configLoading, error, saveDLNA } = useSettings();\n\n  // undefined to hide dialog, true for enable, false for disable\n  const [enableDisable, setEnableDisable] = useState<boolean>();\n\n  const [enableUntilRestart, setEnableUntilRestart] = useState<boolean>(false);\n  const [enableDuration, setEnableDuration] = useState<number>(0);\n\n  const [ipEntry, setIPEntry] = useState<string>(\"\");\n  const [tempIP, setTempIP] = useState<string>();\n\n  const { data: statusData, loading, refetch: statusRefetch } = useDLNAStatus();\n\n  const [enableDLNA] = useEnableDLNA();\n  const [disableDLNA] = useDisableDLNA();\n  const [addTempDLANIP] = useAddTempDLNAIP();\n  const [removeTempDLNAIP] = useRemoveTempDLNAIP();\n\n  if (error) return <h1>{error.message}</h1>;\n  if (loading || configLoading) return <LoadingIndicator />;\n\n  async function onTempEnable() {\n    const input = {\n      variables: {\n        input: {\n          duration: enableUntilRestart ? undefined : enableDuration,\n        },\n      },\n    };\n\n    try {\n      if (enableDisable) {\n        await enableDLNA(input);\n        Toast.success(\n          intl.formatMessage({\n            id: \"config.dlna.enabled_dlna_temporarily\",\n          })\n        );\n      } else {\n        await disableDLNA(input);\n        Toast.success(\n          intl.formatMessage({\n            id: \"config.dlna.disabled_dlna_temporarily\",\n          })\n        );\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setEnableDisable(undefined);\n      statusRefetch();\n    }\n  }\n\n  async function onAllowTempIP() {\n    if (!tempIP) {\n      return;\n    }\n\n    const input = {\n      variables: {\n        input: {\n          duration: enableUntilRestart ? undefined : enableDuration,\n          address: tempIP,\n        },\n      },\n    };\n\n    try {\n      await addTempDLANIP(input);\n      Toast.success(\n        intl.formatMessage({\n          id: \"config.dlna.allowed_ip_temporarily\",\n        })\n      );\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setTempIP(undefined);\n      statusRefetch();\n    }\n  }\n\n  async function onDisallowTempIP(address: string) {\n    const input = {\n      variables: {\n        input: {\n          address,\n        },\n      },\n    };\n\n    try {\n      await removeTempDLNAIP(input);\n      Toast.success(intl.formatMessage({ id: \"config.dlna.disallowed_ip\" }));\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      statusRefetch();\n    }\n  }\n\n  function renderDeadline(until?: string | null) {\n    if (until) {\n      const deadline = new Date(until);\n      return `until ${intl.formatDate(deadline)}`;\n    }\n\n    return \"\";\n  }\n\n  function renderStatus() {\n    if (!statusData) {\n      return \"\";\n    }\n\n    const { dlnaStatus } = statusData;\n    const runningText = intl.formatMessage({\n      id: dlnaStatus.running ? \"actions.running\" : \"actions.not_running\",\n    });\n\n    return `${runningText} ${renderDeadline(dlnaStatus.until)}`;\n  }\n\n  function renderEnableButton() {\n    // if enabled by default, then show the disable temporarily\n    // if disabled by default, then show enable temporarily\n    if (dlna.enabled) {\n      return (\n        <Button onClick={() => setEnableDisable(false)} className=\"mr-1\">\n          <FormattedMessage id=\"actions.temp_disable\" />\n        </Button>\n      );\n    }\n\n    return (\n      <Button onClick={() => setEnableDisable(true)} className=\"mr-1\">\n        <FormattedMessage id=\"actions.temp_enable\" />\n      </Button>\n    );\n  }\n\n  function canCancel() {\n    if (!statusData || !dlna) {\n      return false;\n    }\n\n    const { dlnaStatus } = statusData;\n    const { enabled } = dlna;\n\n    return dlnaStatus.until || dlnaStatus.running !== enabled;\n  }\n\n  async function cancelTempBehaviour() {\n    if (!canCancel()) {\n      return;\n    }\n\n    const running = statusData?.dlnaStatus.running;\n\n    const input = {\n      variables: {\n        input: {},\n      },\n    };\n\n    try {\n      if (!running) {\n        await enableDLNA(input);\n      } else {\n        await disableDLNA(input);\n      }\n      Toast.success(\n        intl.formatMessage({\n          id: \"config.dlna.successfully_cancelled_temporary_behaviour\",\n        })\n      );\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setEnableDisable(undefined);\n      statusRefetch();\n    }\n  }\n\n  function renderTempCancelButton() {\n    if (!canCancel()) {\n      return;\n    }\n\n    return (\n      <Button onClick={() => cancelTempBehaviour()} variant=\"danger\">\n        Cancel temporary behaviour\n      </Button>\n    );\n  }\n\n  function renderTempEnableDialog() {\n    const text: string = enableDisable ? \"enable\" : \"disable\";\n    const capitalised = `${text[0].toUpperCase()}${text.slice(1)}`;\n\n    return (\n      <ModalComponent\n        show={enableDisable !== undefined}\n        header={capitalised}\n        icon={faClock}\n        accept={{\n          text: capitalised,\n          variant: \"primary\",\n          onClick: onTempEnable,\n        }}\n        cancel={{\n          onClick: () => setEnableDisable(undefined),\n          variant: \"secondary\",\n        }}\n      >\n        <h4>{capitalised} temporarily</h4>\n        <Form.Group>\n          <Form.Check\n            checked={enableUntilRestart}\n            label={intl.formatMessage({ id: \"config.dlna.until_restart\" })}\n            onChange={() => setEnableUntilRestart(!enableUntilRestart)}\n          />\n        </Form.Group>\n\n        <Form.Group id=\"temp-enable-duration\">\n          <DurationInput\n            value={enableDuration}\n            setValue={(v) => setEnableDuration(v ?? 0)}\n            disabled={enableUntilRestart}\n          />\n          <Form.Text className=\"text-muted\">\n            Duration to {text} for - in minutes.\n          </Form.Text>\n        </Form.Group>\n      </ModalComponent>\n    );\n  }\n\n  function renderTempWhitelistDialog() {\n    return (\n      <ModalComponent\n        show={tempIP !== undefined}\n        header={intl.formatMessage(\n          { id: \"config.dlna.allow_temp_ip\" },\n          { tempIP }\n        )}\n        icon={faClock}\n        accept={{\n          text: intl.formatMessage({ id: \"actions.allow\" }),\n          variant: \"primary\",\n          onClick: onAllowTempIP,\n        }}\n        cancel={{\n          onClick: () => setTempIP(undefined),\n          variant: \"secondary\",\n        }}\n      >\n        <h4>{`Allow ${tempIP} temporarily`}</h4>\n        <Form.Group>\n          <Form.Check\n            checked={enableUntilRestart}\n            label={intl.formatMessage({ id: \"config.dlna.until_restart\" })}\n            onChange={() => setEnableUntilRestart(!enableUntilRestart)}\n          />\n        </Form.Group>\n\n        <Form.Group id=\"temp-enable-duration\">\n          <DurationInput\n            value={enableDuration}\n            setValue={(v) => setEnableDuration(v ?? 0)}\n            disabled={enableUntilRestart}\n          />\n          <Form.Text className=\"text-muted\">\n            Duration to allow for - in minutes.\n          </Form.Text>\n        </Form.Group>\n      </ModalComponent>\n    );\n  }\n\n  function renderAllowedIPs() {\n    if (!statusData || statusData.dlnaStatus.allowedIPAddresses.length === 0) {\n      return;\n    }\n\n    const { allowedIPAddresses } = statusData.dlnaStatus;\n    return (\n      <Form.Group className=\"content\">\n        <h6>\n          {intl.formatMessage({ id: \"config.dlna.allowed_ip_addresses\" })}\n        </h6>\n\n        <ul className=\"addresses\">\n          {allowedIPAddresses.map((a) => (\n            <li key={a.ipAddress}>\n              <div className=\"address\">\n                <code>{a.ipAddress}</code>\n                <br />\n                <span className=\"deadline\">{renderDeadline(a.until)}</span>\n              </div>\n\n              <div className=\"buttons\">\n                <Button\n                  size=\"sm\"\n                  title={intl.formatMessage({ id: \"actions.disallow\" })}\n                  variant=\"danger\"\n                  onClick={() => onDisallowTempIP(a.ipAddress)}\n                >\n                  <Icon icon={faTimes} />\n                </Button>\n              </div>\n            </li>\n          ))}\n        </ul>\n      </Form.Group>\n    );\n  }\n\n  function renderRecentIPs() {\n    if (!statusData) {\n      return;\n    }\n\n    const { recentIPAddresses } = statusData.dlnaStatus;\n    return (\n      <ul className=\"addresses\">\n        {recentIPAddresses.map((a) => (\n          <li key={a}>\n            <div className=\"address\">\n              <code>{a}</code>\n            </div>\n            <div>\n              <Button\n                size=\"sm\"\n                title={intl.formatMessage({ id: \"actions.allow_temporarily\" })}\n                onClick={() => setTempIP(a)}\n              >\n                <Icon icon={faUserClock} />\n              </Button>\n            </div>\n          </li>\n        ))}\n        <li>\n          <div className=\"address\">\n            <Form.Control\n              className=\"text-input\"\n              value={ipEntry}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                setIPEntry(e.currentTarget.value)\n              }\n            />\n          </div>\n          <div className=\"buttons\">\n            <Button\n              size=\"sm\"\n              title={intl.formatMessage({ id: \"actions.allow_temporarily\" })}\n              onClick={() => setTempIP(ipEntry)}\n              disabled={!ipEntry}\n            >\n              <Icon icon={faUserClock} />\n            </Button>\n          </div>\n        </li>\n      </ul>\n    );\n  }\n\n  const DLNASettingsForm: React.FC = () => {\n    return (\n      <>\n        <SettingSection headingID=\"settings\">\n          <StringSetting\n            headingID=\"config.dlna.server_display_name\"\n            subHeading={intl.formatMessage(\n              { id: \"config.dlna.server_display_name_desc\" },\n              { server_name: <code>stash</code> }\n            )}\n            value={dlna.serverName ?? undefined}\n            onChange={(v) => saveDLNA({ serverName: v })}\n          />\n\n          <NumberSetting\n            headingID=\"config.dlna.server_port\"\n            subHeading={intl.formatMessage({\n              id: \"config.dlna.server_port_desc\",\n            })}\n            value={dlna.port ?? undefined}\n            onChange={(v) => saveDLNA({ port: v ? v : defaultDLNAPort })}\n          />\n\n          <BooleanSetting\n            id=\"dlna-enabled-by-default\"\n            headingID=\"config.dlna.enabled_by_default\"\n            checked={dlna.enabled ?? undefined}\n            onChange={(v) => saveDLNA({ enabled: v })}\n          />\n\n          <StringListSetting\n            id=\"dlna-network-interfaces\"\n            headingID=\"config.dlna.network_interfaces\"\n            subHeadingID=\"config.dlna.network_interfaces_desc\"\n            value={dlna.interfaces ?? undefined}\n            onChange={(v) => saveDLNA({ interfaces: v })}\n          />\n\n          <StringListSetting\n            id=\"dlna-default-ip-whitelist\"\n            headingID=\"config.dlna.default_ip_whitelist\"\n            subHeading={intl.formatMessage(\n              { id: \"config.dlna.default_ip_whitelist_desc\" },\n              { wildcard: <code>*</code> }\n            )}\n            defaultNewValue=\"*\"\n            value={dlna.whitelistedIPs ?? undefined}\n            onChange={(v) => saveDLNA({ whitelistedIPs: v })}\n          />\n\n          <SelectSetting\n            id=\"video-sort-order\"\n            headingID=\"config.dlna.video_sort_order\"\n            subHeadingID=\"config.dlna.video_sort_order_desc\"\n            value={dlna.videoSortOrder ?? defaultVideoSort}\n            onChange={(v) => saveDLNA({ videoSortOrder: v })}\n          >\n            {Array.from(videoSortOrderIntlMap.entries()).map((v) => (\n              <option key={v[0]} value={v[0]}>\n                {intl.formatMessage({\n                  id: v[1],\n                })}\n              </option>\n            ))}\n          </SelectSetting>\n        </SettingSection>\n      </>\n    );\n  };\n\n  return (\n    <div id=\"settings-dlna\">\n      {renderTempEnableDialog()}\n      {renderTempWhitelistDialog()}\n\n      <h4>DLNA</h4>\n\n      <Form.Group>\n        <h5>\n          {intl.formatMessage({ id: \"status\" }, { statusText: renderStatus() })}\n        </h5>\n      </Form.Group>\n\n      <SettingSection headingID=\"actions_name\">\n        <Form.Group className=\"content\">\n          {renderEnableButton()}\n          {renderTempCancelButton()}\n        </Form.Group>\n\n        {renderAllowedIPs()}\n\n        <Form.Group className=\"content\">\n          <h6>\n            {intl.formatMessage({ id: \"config.dlna.recent_ip_addresses\" })}\n          </h6>\n          <Form.Group>{renderRecentIPs()}</Form.Group>\n          <Form.Group>\n            <Button onClick={() => statusRefetch()}>\n              <FormattedMessage id=\"actions.refresh\" />\n            </Button>\n          </Form.Group>\n        </Form.Group>\n      </SettingSection>\n\n      <DLNASettingsForm />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { SettingSection } from \"./SettingSection\";\nimport {\n  BooleanSetting,\n  ModalSetting,\n  NumberSetting,\n  SelectSetting,\n  Setting,\n  StringListSetting,\n  StringSetting,\n} from \"./Inputs\";\nimport { useSettings } from \"./context\";\nimport {\n  VideoPreviewInput,\n  VideoPreviewSettingsInput,\n} from \"./GeneratePreviewOptions\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Button } from \"react-bootstrap\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useHistory } from \"react-router-dom\";\n\nexport const SettingsConfigurationPanel: React.FC = () => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const history = useHistory();\n\n  const { general, loading, error, saveGeneral } = useSettings();\n  const [mutateDownloadFFMpeg] = GQL.useDownloadFfMpegMutation();\n\n  const transcodeQualities = [\n    GQL.StreamingResolutionEnum.Low,\n    GQL.StreamingResolutionEnum.Standard,\n    GQL.StreamingResolutionEnum.StandardHd,\n    GQL.StreamingResolutionEnum.FullHd,\n    GQL.StreamingResolutionEnum.FourK,\n    GQL.StreamingResolutionEnum.Original,\n  ].map(resolutionToString);\n\n  function resolutionToString(r: GQL.StreamingResolutionEnum | undefined) {\n    switch (r) {\n      case GQL.StreamingResolutionEnum.Low:\n        return \"240p\";\n      case GQL.StreamingResolutionEnum.Standard:\n        return \"480p\";\n      case GQL.StreamingResolutionEnum.StandardHd:\n        return \"720p\";\n      case GQL.StreamingResolutionEnum.FullHd:\n        return \"1080p\";\n      case GQL.StreamingResolutionEnum.FourK:\n        return \"4k\";\n      case GQL.StreamingResolutionEnum.Original:\n        return \"Original\";\n    }\n\n    return \"Original\";\n  }\n\n  function translateQuality(quality: string) {\n    switch (quality) {\n      case \"240p\":\n        return GQL.StreamingResolutionEnum.Low;\n      case \"480p\":\n        return GQL.StreamingResolutionEnum.Standard;\n      case \"720p\":\n        return GQL.StreamingResolutionEnum.StandardHd;\n      case \"1080p\":\n        return GQL.StreamingResolutionEnum.FullHd;\n      case \"4k\":\n        return GQL.StreamingResolutionEnum.FourK;\n      case \"Original\":\n        return GQL.StreamingResolutionEnum.Original;\n    }\n\n    return GQL.StreamingResolutionEnum.Original;\n  }\n\n  const namingHashAlgorithms = [\n    GQL.HashAlgorithm.Md5,\n    GQL.HashAlgorithm.Oshash,\n  ].map(namingHashToString);\n\n  function namingHashToString(value: GQL.HashAlgorithm | undefined) {\n    switch (value) {\n      case GQL.HashAlgorithm.Oshash:\n        return \"oshash\";\n      case GQL.HashAlgorithm.Md5:\n        return \"MD5\";\n    }\n\n    return \"MD5\";\n  }\n\n  function translateNamingHash(value: string) {\n    switch (value) {\n      case \"oshash\":\n        return GQL.HashAlgorithm.Oshash;\n      case \"MD5\":\n        return GQL.HashAlgorithm.Md5;\n    }\n\n    return GQL.HashAlgorithm.Md5;\n  }\n\n  function blobStorageTypeToID(value: GQL.BlobsStorageType | undefined) {\n    switch (value) {\n      case GQL.BlobsStorageType.Database:\n        return \"blobs_storage_type.database\";\n      case GQL.BlobsStorageType.Filesystem:\n        return \"blobs_storage_type.filesystem\";\n    }\n\n    return \"blobs_storage_type.database\";\n  }\n\n  async function onDownloadFFMpeg() {\n    try {\n      await mutateDownloadFFMpeg();\n      // navigate to tasks page to see the progress\n      history.push(\"/settings?tab=tasks\");\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  if (error) return <h1>{error.message}</h1>;\n  if (loading) return <LoadingIndicator />;\n\n  return (\n    <>\n      <SettingSection headingID=\"config.application_paths.heading\">\n        <StringSetting\n          id=\"generated-path\"\n          headingID=\"config.general.generated_path_head\"\n          subHeadingID=\"config.general.generated_files_location\"\n          value={general.generatedPath ?? undefined}\n          onChange={(v) => saveGeneral({ generatedPath: v })}\n        />\n\n        <StringSetting\n          id=\"cache-path\"\n          headingID=\"config.general.cache_path_head\"\n          subHeadingID=\"config.general.cache_location\"\n          value={general.cachePath ?? undefined}\n          onChange={(v) => saveGeneral({ cachePath: v })}\n        />\n\n        <StringSetting\n          id=\"scrapers-path\"\n          headingID=\"config.general.scrapers_path.heading\"\n          subHeadingID=\"config.general.scrapers_path.description\"\n          value={general.scrapersPath ?? undefined}\n          onChange={(v) => saveGeneral({ scrapersPath: v })}\n        />\n\n        <StringSetting\n          id=\"plugins-path\"\n          headingID=\"config.general.plugins_path.heading\"\n          subHeadingID=\"config.general.plugins_path.description\"\n          value={general.pluginsPath ?? undefined}\n          onChange={(v) => saveGeneral({ pluginsPath: v })}\n        />\n\n        <StringSetting\n          id=\"metadata-path\"\n          headingID=\"config.general.metadata_path.heading\"\n          subHeadingID=\"config.general.metadata_path.description\"\n          value={general.metadataPath ?? undefined}\n          onChange={(v) => saveGeneral({ metadataPath: v })}\n        />\n\n        <StringSetting\n          id=\"custom-performer-image-location\"\n          headingID=\"config.ui.performers.options.image_location.heading\"\n          subHeadingID=\"config.ui.performers.options.image_location.description\"\n          value={general.customPerformerImageLocation ?? undefined}\n          onChange={(v) => saveGeneral({ customPerformerImageLocation: v })}\n        />\n\n        <StringSetting\n          id=\"ffmpeg-path\"\n          headingID=\"config.general.ffmpeg.ffmpeg_path.heading\"\n          subHeadingID=\"config.general.ffmpeg.ffmpeg_path.description\"\n          value={general.ffmpegPath ?? undefined}\n          onChange={(v) => saveGeneral({ ffmpegPath: v })}\n        />\n\n        <StringSetting\n          id=\"ffprobe-path\"\n          headingID=\"config.general.ffmpeg.ffprobe_path.heading\"\n          subHeadingID=\"config.general.ffmpeg.ffprobe_path.description\"\n          value={general.ffprobePath ?? undefined}\n          onChange={(v) => saveGeneral({ ffprobePath: v })}\n        />\n\n        <Setting\n          heading={\n            <>\n              <FormattedMessage id=\"config.general.ffmpeg.download_ffmpeg.heading\" />\n            </>\n          }\n          subHeadingID=\"config.general.ffmpeg.download_ffmpeg.description\"\n        >\n          <Button variant=\"secondary\" onClick={() => onDownloadFFMpeg()}>\n            <FormattedMessage id=\"config.general.ffmpeg.download_ffmpeg.heading\" />\n          </Button>\n        </Setting>\n\n        <StringSetting\n          id=\"python-path\"\n          headingID=\"config.general.python_path.heading\"\n          subHeadingID=\"config.general.python_path.description\"\n          value={general.pythonPath ?? undefined}\n          onChange={(v) => saveGeneral({ pythonPath: v })}\n        />\n\n        <StringSetting\n          id=\"backup-directory-path\"\n          headingID=\"config.general.backup_directory_path.heading\"\n          subHeadingID=\"config.general.backup_directory_path.description\"\n          value={general.backupDirectoryPath ?? undefined}\n          onChange={(v) => saveGeneral({ backupDirectoryPath: v })}\n        />\n\n        <StringSetting\n          id=\"delete-trash-path\"\n          headingID=\"config.general.delete_trash_path.heading\"\n          subHeadingID=\"config.general.delete_trash_path.description\"\n          value={general.deleteTrashPath ?? undefined}\n          onChange={(v) => saveGeneral({ deleteTrashPath: v })}\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.general.database\">\n        <StringSetting\n          id=\"database-path\"\n          headingID=\"config.general.db_path_head\"\n          subHeadingID=\"config.general.sqlite_location\"\n          value={general.databasePath ?? undefined}\n          onChange={(v) => saveGeneral({ databasePath: v })}\n        />\n        <SelectSetting\n          id=\"blobs-storage\"\n          headingID=\"config.general.blobs_storage.heading\"\n          subHeadingID=\"config.general.blobs_storage.description\"\n          value={general.blobsStorage ?? GQL.BlobsStorageType.Database}\n          onChange={(v) =>\n            saveGeneral({ blobsStorage: v as GQL.BlobsStorageType })\n          }\n        >\n          {Object.values(GQL.BlobsStorageType).map((q) => (\n            <option key={q} value={q}>\n              {intl.formatMessage({ id: blobStorageTypeToID(q) })}\n            </option>\n          ))}\n        </SelectSetting>\n        <StringSetting\n          id=\"blobs-path\"\n          headingID=\"config.general.blobs_path.heading\"\n          subHeadingID=\"config.general.blobs_path.description\"\n          value={general.blobsPath ?? \"\"}\n          onChange={(v) => saveGeneral({ blobsPath: v })}\n        />\n      </SettingSection>\n\n      <SettingSection advanced headingID=\"config.general.hashing\">\n        <BooleanSetting\n          id=\"calculate-md5-and-ohash\"\n          headingID=\"config.general.calculate_md5_and_ohash_label\"\n          subHeadingID=\"config.general.calculate_md5_and_ohash_desc\"\n          checked={general.calculateMD5 ?? false}\n          onChange={(v) => saveGeneral({ calculateMD5: v })}\n        />\n\n        <SelectSetting\n          id=\"generated_file_naming_hash\"\n          headingID=\"config.general.generated_file_naming_hash_head\"\n          subHeadingID=\"config.general.generated_file_naming_hash_desc\"\n          value={namingHashToString(\n            general.videoFileNamingAlgorithm ?? undefined\n          )}\n          onChange={(v) =>\n            saveGeneral({ videoFileNamingAlgorithm: translateNamingHash(v) })\n          }\n        >\n          {namingHashAlgorithms.map((q) => (\n            <option key={q} value={q}>\n              {q}\n            </option>\n          ))}\n        </SelectSetting>\n      </SettingSection>\n\n      <SettingSection headingID=\"config.system.transcoding\">\n        <SelectSetting\n          advanced\n          id=\"transcode-size\"\n          headingID=\"config.general.maximum_transcode_size_head\"\n          subHeadingID=\"config.general.maximum_transcode_size_desc\"\n          onChange={(v) =>\n            saveGeneral({ maxTranscodeSize: translateQuality(v) })\n          }\n          value={resolutionToString(general.maxTranscodeSize ?? undefined)}\n        >\n          {transcodeQualities.map((q) => (\n            <option key={q} value={q}>\n              {q}\n            </option>\n          ))}\n        </SelectSetting>\n\n        <SelectSetting\n          id=\"streaming-transcode-size\"\n          headingID=\"config.general.maximum_streaming_transcode_size_head\"\n          subHeadingID=\"config.general.maximum_streaming_transcode_size_desc\"\n          onChange={(v) =>\n            saveGeneral({ maxStreamingTranscodeSize: translateQuality(v) })\n          }\n          value={resolutionToString(\n            general.maxStreamingTranscodeSize ?? undefined\n          )}\n        >\n          {transcodeQualities.map((q) => (\n            <option key={q} value={q}>\n              {q}\n            </option>\n          ))}\n        </SelectSetting>\n\n        <BooleanSetting\n          id=\"hardware-encoding\"\n          headingID=\"config.general.ffmpeg.hardware_acceleration.heading\"\n          subHeadingID=\"config.general.ffmpeg.hardware_acceleration.desc\"\n          checked={general.transcodeHardwareAcceleration ?? false}\n          onChange={(v) => saveGeneral({ transcodeHardwareAcceleration: v })}\n        />\n\n        <StringListSetting\n          advanced\n          id=\"transcode-input-args\"\n          headingID=\"config.general.ffmpeg.transcode.input_args.heading\"\n          subHeadingID=\"config.general.ffmpeg.transcode.input_args.desc\"\n          onChange={(v) => saveGeneral({ transcodeInputArgs: v })}\n          value={general.transcodeInputArgs ?? []}\n        />\n        <StringListSetting\n          advanced\n          id=\"transcode-output-args\"\n          headingID=\"config.general.ffmpeg.transcode.output_args.heading\"\n          subHeadingID=\"config.general.ffmpeg.transcode.output_args.desc\"\n          onChange={(v) => saveGeneral({ transcodeOutputArgs: v })}\n          value={general.transcodeOutputArgs ?? []}\n        />\n\n        <StringListSetting\n          advanced\n          id=\"live-transcode-input-args\"\n          headingID=\"config.general.ffmpeg.live_transcode.input_args.heading\"\n          subHeadingID=\"config.general.ffmpeg.live_transcode.input_args.desc\"\n          onChange={(v) => saveGeneral({ liveTranscodeInputArgs: v })}\n          value={general.liveTranscodeInputArgs ?? []}\n        />\n        <StringListSetting\n          advanced\n          id=\"live-transcode-output-args\"\n          headingID=\"config.general.ffmpeg.live_transcode.output_args.heading\"\n          subHeadingID=\"config.general.ffmpeg.live_transcode.output_args.desc\"\n          onChange={(v) => saveGeneral({ liveTranscodeOutputArgs: v })}\n          value={general.liveTranscodeOutputArgs ?? []}\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.general.parallel_scan_head\">\n        <NumberSetting\n          id=\"parallel-tasks\"\n          headingID=\"config.general.number_of_parallel_task_for_scan_generation_head\"\n          subHeadingID=\"config.general.number_of_parallel_task_for_scan_generation_desc\"\n          value={general.parallelTasks ?? undefined}\n          onChange={(v) => saveGeneral({ parallelTasks: v })}\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.general.preview_generation\">\n        <SelectSetting\n          id=\"scene-gen-preview-preset\"\n          headingID=\"dialogs.scene_gen.preview_preset_head\"\n          subHeadingID=\"dialogs.scene_gen.preview_preset_desc\"\n          value={general.previewPreset ?? undefined}\n          onChange={(v) =>\n            saveGeneral({\n              previewPreset: (v as GQL.PreviewPreset) ?? undefined,\n            })\n          }\n        >\n          {Object.keys(GQL.PreviewPreset).map((p) => (\n            <option value={p.toLowerCase()} key={p}>\n              {p}\n            </option>\n          ))}\n        </SelectSetting>\n\n        <BooleanSetting\n          id=\"preview-include-audio\"\n          headingID=\"config.general.include_audio_head\"\n          subHeadingID=\"config.general.include_audio_desc\"\n          checked={general.previewAudio ?? false}\n          onChange={(v) => saveGeneral({ previewAudio: v })}\n        />\n\n        <ModalSetting<VideoPreviewSettingsInput>\n          id=\"video-preview-settings\"\n          headingID=\"dialogs.scene_gen.preview_generation_options\"\n          value={{\n            previewExcludeEnd: general.previewExcludeEnd,\n            previewExcludeStart: general.previewExcludeStart,\n            previewSegmentDuration: general.previewSegmentDuration,\n            previewSegments: general.previewSegments,\n          }}\n          onChange={(v) => saveGeneral(v)}\n          renderField={(value, setValue) => (\n            <VideoPreviewInput value={value ?? {}} setValue={setValue} />\n          )}\n          renderValue={() => {\n            return <></>;\n          }}\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.general.sprite_generation_head\">\n        <NumberSetting\n          id=\"sprite-screenshot-size\"\n          headingID=\"config.general.sprite_screenshot_size_head\"\n          subHeadingID=\"config.general.sprite_screenshot_size_desc\"\n          value={general.spriteScreenshotSize ?? 160}\n          onChange={(v) => saveGeneral({ spriteScreenshotSize: v })}\n        />\n        <BooleanSetting\n          id=\"use-custom-sprite-interval\"\n          headingID=\"config.general.use_custom_sprite_interval_head\"\n          subHeadingID=\"config.general.use_custom_sprite_interval_desc\"\n          checked={general.useCustomSpriteInterval ?? false}\n          onChange={(v) => saveGeneral({ useCustomSpriteInterval: v })}\n        />\n        <NumberSetting\n          id=\"sprite-interval\"\n          headingID=\"config.general.sprite_interval_head\"\n          subHeadingID=\"config.general.sprite_interval_desc\"\n          value={general.spriteInterval ?? 0}\n          onChange={(v) => saveGeneral({ spriteInterval: v })}\n        />\n        <NumberSetting\n          id=\"minimum-sprites\"\n          headingID=\"config.general.sprite_minimum_head\"\n          subHeadingID=\"config.general.sprite_minimum_desc\"\n          value={general.minimumSprites ?? 10}\n          onChange={(v) => saveGeneral({ minimumSprites: v })}\n        />\n        <NumberSetting\n          id=\"maximum-sprites\"\n          headingID=\"config.general.sprite_maximum_head\"\n          subHeadingID=\"config.general.sprite_maximum_desc\"\n          value={general.maximumSprites ?? 10}\n          onChange={(v) => saveGeneral({ maximumSprites: v })}\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.general.heatmap_generation\">\n        <BooleanSetting\n          id=\"heatmap-draw-range\"\n          headingID=\"config.general.funscript_heatmap_draw_range\"\n          subHeadingID=\"config.general.funscript_heatmap_draw_range_desc\"\n          checked={general.drawFunscriptHeatmapRange ?? true}\n          onChange={(v) => saveGeneral({ drawFunscriptHeatmapRange: v })}\n        />\n      </SettingSection>\n\n      <SettingSection headingID=\"config.general.logging\">\n        <StringSetting\n          headingID=\"config.general.auth.log_file\"\n          subHeadingID=\"config.general.auth.log_file_desc\"\n          value={general.logFile ?? undefined}\n          onChange={(v) => saveGeneral({ logFile: v })}\n        />\n\n        <BooleanSetting\n          id=\"log-terminal\"\n          headingID=\"config.general.auth.log_to_terminal\"\n          subHeadingID=\"config.general.auth.log_to_terminal_desc\"\n          checked={general.logOut ?? false}\n          onChange={(v) => saveGeneral({ logOut: v })}\n        />\n\n        <SelectSetting\n          id=\"log-level\"\n          headingID=\"config.logs.log_level\"\n          onChange={(v) => saveGeneral({ logLevel: v })}\n          value={general.logLevel ?? undefined}\n        >\n          {[\"Trace\", \"Debug\", \"Info\", \"Warning\", \"Error\"].map((o) => (\n            <option key={o} value={o}>\n              {o}\n            </option>\n          ))}\n        </SelectSetting>\n\n        <BooleanSetting\n          id=\"log-http\"\n          headingID=\"config.general.auth.log_http\"\n          subHeadingID=\"config.general.auth.log_http_desc\"\n          checked={general.logAccess ?? false}\n          onChange={(v) => saveGeneral({ logAccess: v })}\n        />\n\n        <NumberSetting\n          id=\"log-file-max-size\"\n          headingID=\"config.general.auth.log_file_max_size\"\n          subHeadingID=\"config.general.auth.log_file_max_size_desc\"\n          value={general.logFileMaxSize ?? 10}\n          onChange={(v) => saveGeneral({ logFileMaxSize: v })}\n        />\n      </SettingSection>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx",
    "content": "import React from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { Link } from \"react-router-dom\";\nimport { Setting } from \"./Inputs\";\nimport { SettingSection } from \"./SettingSection\";\nimport { PatchContainerComponent } from \"src/patch\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\n\nconst SettingsToolsSection = PatchContainerComponent(\"SettingsToolsSection\");\n\nexport const SettingsToolsPanel: React.FC = () => {\n  return (\n    <>\n      <SettingSection headingID=\"config.tools.heading\">\n        <SettingsToolsSection>\n          <Setting\n            heading={\n              <ExternalLink href=\"/playground\">\n                <Button>\n                  <FormattedMessage id=\"config.tools.graphql_playground\" />\n                </Button>\n              </ExternalLink>\n            }\n          />\n        </SettingsToolsSection>\n      </SettingSection>\n      <SettingSection headingID=\"config.tools.scene_tools\">\n        <SettingsToolsSection>\n          <Setting\n            heading={\n              <Link to=\"/sceneFilenameParser\">\n                <Button>\n                  <FormattedMessage id=\"config.tools.scene_filename_parser.title\" />\n                </Button>\n              </Link>\n            }\n          />\n\n          <Setting\n            heading={\n              <Link to=\"/sceneDuplicateChecker\">\n                <Button>\n                  <FormattedMessage id=\"config.tools.scene_duplicate_checker\" />\n                </Button>\n              </Link>\n            }\n          />\n        </SettingsToolsSection>\n      </SettingSection>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/StashBoxConfiguration.tsx",
    "content": "import React, { useRef, useState } from \"react\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { SettingSection } from \"./SettingSection\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SettingModal } from \"./Inputs\";\n\nexport interface IStashBoxModal {\n  value: GQL.StashBoxInput;\n  close: (v?: GQL.StashBoxInput) => void;\n}\n\nconst defaultMaxRequestsPerMinute = 240;\n\nexport const StashBoxModal: React.FC<IStashBoxModal> = ({ value, close }) => {\n  const intl = useIntl();\n  const endpoint = useRef<HTMLInputElement | null>(null);\n  const apiKey = useRef<HTMLInputElement | null>(null);\n\n  const [validate, { data, loading }] = GQL.useValidateStashBoxLazyQuery({\n    fetchPolicy: \"network-only\",\n  });\n\n  const handleValidate = () => {\n    validate({\n      variables: {\n        input: {\n          endpoint: endpoint.current?.value ?? \"\",\n          api_key: apiKey.current?.value ?? \"\",\n          name: \"test\",\n        },\n      },\n    });\n  };\n\n  return (\n    <SettingModal<GQL.StashBoxInput>\n      headingID=\"config.stashbox.title\"\n      value={value}\n      renderField={(v, setValue) => (\n        <>\n          <Form.Group id=\"stashbox-name\">\n            <h6>\n              {intl.formatMessage({\n                id: \"config.stashbox.name\",\n              })}\n            </h6>\n            <Form.Control\n              placeholder={intl.formatMessage({ id: \"config.stashbox.name\" })}\n              className=\"text-input stash-box-name\"\n              value={v?.name}\n              isValid={(v?.name?.length ?? 0) > 0}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                setValue({ ...v!, name: e.currentTarget.value })\n              }\n            />\n          </Form.Group>\n\n          <Form.Group id=\"stashbox-name\">\n            <h6>\n              {intl.formatMessage({\n                id: \"config.stashbox.graphql_endpoint\",\n              })}\n            </h6>\n            <Form.Control\n              placeholder={intl.formatMessage({\n                id: \"config.stashbox.graphql_endpoint\",\n              })}\n              className=\"text-input stash-box-endpoint\"\n              value={v?.endpoint}\n              isValid={(v?.endpoint?.length ?? 0) > 0}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                setValue({ ...v!, endpoint: e.currentTarget.value.trim() })\n              }\n              ref={endpoint}\n            />\n          </Form.Group>\n\n          <Form.Group id=\"stashbox-name\">\n            <h6>\n              {intl.formatMessage({\n                id: \"config.stashbox.api_key\",\n              })}\n            </h6>\n            <Form.Control\n              placeholder={intl.formatMessage({\n                id: \"config.stashbox.api_key\",\n              })}\n              className=\"text-input stash-box-apikey\"\n              value={v?.api_key}\n              isValid={(v?.api_key?.length ?? 0) > 0}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                setValue({ ...v!, api_key: e.currentTarget.value.trim() })\n              }\n              ref={apiKey}\n            />\n          </Form.Group>\n\n          <Form.Group>\n            <Button\n              disabled={loading}\n              onClick={handleValidate}\n              className=\"mr-3\"\n            >\n              Test Credentials\n            </Button>\n            {data && (\n              <b\n                className={\n                  data.validateStashBoxCredentials?.valid\n                    ? \"text-success\"\n                    : \"text-danger\"\n                }\n              >\n                {data.validateStashBoxCredentials?.status}\n              </b>\n            )}\n          </Form.Group>\n\n          <Form.Group id=\"stashbox-max-requests-per-minute\">\n            <h6>\n              {intl.formatMessage({\n                id: \"config.stashbox.max_requests_per_minute\",\n              })}\n            </h6>\n            <Form.Control\n              placeholder={intl.formatMessage({\n                id: \"config.stashbox.max_requests_per_minute\",\n              })}\n              className=\"text-input\"\n              value={v?.max_requests_per_minute ?? defaultMaxRequestsPerMinute}\n              isValid={\n                (v?.max_requests_per_minute ?? defaultMaxRequestsPerMinute) >= 0\n              }\n              type=\"number\"\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                setValue({\n                  ...v!,\n                  max_requests_per_minute: parseInt(e.currentTarget.value),\n                })\n              }\n            />\n            <div className=\"sub-heading\">\n              <FormattedMessage\n                id=\"config.stashbox.max_requests_per_minute_description\"\n                values={{ defaultValue: defaultMaxRequestsPerMinute }}\n              />\n            </div>\n          </Form.Group>\n        </>\n      )}\n      close={close}\n    />\n  );\n};\n\ninterface IStashBoxSetting {\n  value: GQL.StashBoxInput[];\n  onChange: (v: GQL.StashBoxInput[]) => void;\n}\n\nexport const StashBoxSetting: React.FC<IStashBoxSetting> = ({\n  value,\n  onChange,\n}) => {\n  const [isCreating, setIsCreating] = useState(false);\n  const [editingIndex, setEditingIndex] = useState<number | undefined>();\n\n  function onEdit(index: number) {\n    setEditingIndex(index);\n  }\n\n  function onDelete(index: number) {\n    onChange(value.filter((v, i) => i !== index));\n  }\n\n  function onNew() {\n    setIsCreating(true);\n  }\n\n  return (\n    <SettingSection\n      id=\"stash-boxes\"\n      headingID=\"config.stashbox.title\"\n      subHeadingID=\"config.stashbox.description\"\n    >\n      {isCreating ? (\n        <StashBoxModal\n          value={{\n            endpoint: \"\",\n            api_key: \"\",\n            name: \"\",\n          }}\n          close={(v) => {\n            if (v) onChange([...value, v]);\n            setIsCreating(false);\n          }}\n        />\n      ) : undefined}\n\n      {editingIndex !== undefined ? (\n        <StashBoxModal\n          value={value[editingIndex]}\n          close={(v) => {\n            if (v)\n              onChange(\n                value.map((vv, index) => {\n                  if (index === editingIndex) {\n                    return v;\n                  }\n                  return vv;\n                })\n              );\n            setEditingIndex(undefined);\n          }}\n        />\n      ) : undefined}\n\n      {value.map((b, index) => (\n        // eslint-disable-next-line react/no-array-index-key\n        <div key={index} className=\"setting\">\n          <div>\n            <h3>{b.name ?? `#${index}`}</h3>\n            <div className=\"value\">{b.endpoint ?? \"\"}</div>\n          </div>\n          <div>\n            <Button onClick={() => onEdit(index)}>\n              <FormattedMessage id=\"actions.edit\" />\n            </Button>\n            <Button variant=\"danger\" onClick={() => onDelete(index)}>\n              <FormattedMessage id=\"actions.delete\" />\n            </Button>\n          </div>\n        </div>\n      ))}\n      <div className=\"setting\">\n        <div />\n        <div>\n          <Button onClick={() => onNew()}>\n            <FormattedMessage id=\"actions.add\" />\n          </Button>\n        </div>\n      </div>\n    </SettingSection>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/StashConfiguration.tsx",
    "content": "import { faEllipsisV } from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState } from \"react\";\nimport { Button, Form, Row, Col, Dropdown } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FolderSelectDialog } from \"../Shared/FolderSelect/FolderSelectDialog\";\nimport { BooleanSetting } from \"./Inputs\";\nimport { SettingSection } from \"./SettingSection\";\n\ninterface IStashProps {\n  index: number;\n  stash: GQL.StashConfig;\n  onSave: (instance: GQL.StashConfig) => void;\n  onEdit: () => void;\n  onDelete: () => void;\n}\n\nconst Stash: React.FC<IStashProps> = ({\n  index,\n  stash,\n  onSave,\n  onEdit,\n  onDelete,\n}) => {\n  // eslint-disable-next-line\n  const handleInput = (key: string, value: any) => {\n    const newObj = {\n      ...stash,\n      [key]: value,\n    };\n    onSave(newObj);\n  };\n\n  const classAdd = index % 2 === 1 ? \"bg-dark\" : \"\";\n\n  return (\n    <Row className={`stash-row align-items-center ${classAdd}`}>\n      <Form.Label column md={7}>\n        {stash.path}\n      </Form.Label>\n      <Col md={2} xs={4} className=\"col form-label\">\n        {/* NOTE - language is opposite to meaning:\n        internally exclude flags, displayed as include */}\n        <div>\n          <h6 className=\"d-md-none\">\n            <FormattedMessage id=\"videos\" />\n          </h6>\n          <BooleanSetting\n            id={`stash-exclude-video-${index}`}\n            checked={!stash.excludeVideo}\n            onChange={(v) => handleInput(\"excludeVideo\", !v)}\n          />\n        </div>\n      </Col>\n\n      <Col md={2} xs={4} className=\"col-form-label\">\n        <div>\n          <h6 className=\"d-md-none\">\n            <FormattedMessage id=\"images\" />\n          </h6>\n          <BooleanSetting\n            id={`stash-exclude-image-${index}`}\n            checked={!stash.excludeImage}\n            onChange={(v) => handleInput(\"excludeImage\", !v)}\n          />\n        </div>\n      </Col>\n      <Col className=\"justify-content-end\" xs={4} md={1}>\n        <Dropdown className=\"text-right\">\n          <Dropdown.Toggle\n            variant=\"minimal\"\n            id={`stash-menu-${index}`}\n            className=\"minimal\"\n          >\n            <Icon icon={faEllipsisV} />\n          </Dropdown.Toggle>\n          <Dropdown.Menu className=\"bg-secondary text-white\">\n            <Dropdown.Item onClick={() => onEdit()}>\n              <FormattedMessage id=\"actions.edit\" />\n            </Dropdown.Item>\n            <Dropdown.Item onClick={() => onDelete()}>\n              <FormattedMessage id=\"actions.delete\" />\n            </Dropdown.Item>\n          </Dropdown.Menu>\n        </Dropdown>\n      </Col>\n    </Row>\n  );\n};\n\ninterface IStashConfigurationProps {\n  stashes: GQL.StashConfig[];\n  setStashes: (v: GQL.StashConfig[]) => void;\n}\n\nconst StashConfiguration: React.FC<IStashConfigurationProps> = ({\n  stashes,\n  setStashes,\n}) => {\n  const [isCreating, setIsCreating] = useState(false);\n  const [editingIndex, setEditingIndex] = useState<number | undefined>();\n\n  function onEdit(index: number) {\n    setEditingIndex(index);\n  }\n\n  function onDelete(index: number) {\n    setStashes(stashes.filter((v, i) => i !== index));\n  }\n\n  function onNew() {\n    setIsCreating(true);\n  }\n\n  const handleSave = (index: number, stash: GQL.StashConfig) =>\n    setStashes(stashes.map((s, i) => (i === index ? stash : s)));\n\n  return (\n    <>\n      {isCreating ? (\n        <FolderSelectDialog\n          onClose={(v) => {\n            if (v)\n              setStashes([\n                ...stashes,\n                {\n                  path: v,\n                  excludeVideo: false,\n                  excludeImage: false,\n                },\n              ]);\n            setIsCreating(false);\n          }}\n        />\n      ) : undefined}\n\n      {editingIndex !== undefined ? (\n        <FolderSelectDialog\n          defaultValue={stashes[editingIndex].path}\n          onClose={(v) => {\n            if (v)\n              setStashes(\n                stashes.map((vv, index) => {\n                  if (index === editingIndex) {\n                    return {\n                      ...vv,\n                      path: v,\n                    };\n                  }\n                  return vv;\n                })\n              );\n            setEditingIndex(undefined);\n          }}\n        />\n      ) : undefined}\n\n      <div className=\"content\" id=\"stash-table\">\n        {stashes.length > 0 && (\n          <Row className=\"d-none d-md-flex\">\n            <h6 className=\"col-md-7\">\n              <FormattedMessage id=\"path\" />\n            </h6>\n            <h6 className=\"col-md-2 col-4\">\n              <FormattedMessage id=\"videos\" />\n            </h6>\n            <h6 className=\"col-md-2 col-4\">\n              <FormattedMessage id=\"images\" />\n            </h6>\n          </Row>\n        )}\n        {stashes.map((stash, index) => (\n          <Stash\n            index={index}\n            stash={stash}\n            onSave={(s) => handleSave(index, s)}\n            onEdit={() => onEdit(index)}\n            onDelete={() => onDelete(index)}\n            key={stash.path}\n          />\n        ))}\n        <Button className=\"mt-2\" variant=\"secondary\" onClick={() => onNew()}>\n          <FormattedMessage id=\"actions.add_directory\" />\n        </Button>\n      </div>\n    </>\n  );\n};\n\ninterface IStashSetting {\n  value: GQL.StashConfigInput[];\n  onChange: (v: GQL.StashConfigInput[]) => void;\n}\n\nexport const StashSetting: React.FC<IStashSetting> = ({ value, onChange }) => {\n  return (\n    <SettingSection\n      id=\"stashes\"\n      headingID=\"library\"\n      subHeadingID=\"config.general.directory_locations_to_your_content\"\n    >\n      <StashConfiguration stashes={value} setStashes={(v) => onChange(v)} />\n    </SettingSection>\n  );\n};\n\nexport default StashConfiguration;\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/CleanGeneratedDialog.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { BooleanSetting } from \"../Inputs\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { SettingSection } from \"../SettingSection\";\nimport { useSettings } from \"../context\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\n\nconst CleanGeneratedOptions: React.FC<{\n  options: GQL.CleanGeneratedInput;\n  setOptions: (s: GQL.CleanGeneratedInput) => void;\n}> = ({ options, setOptions: setOptionsState }) => {\n  function setOptions(input: Partial<GQL.CleanGeneratedInput>) {\n    setOptionsState({ ...options, ...input });\n  }\n\n  return (\n    <>\n      <BooleanSetting\n        id=\"clean-generated-blob-files\"\n        checked={options.blobFiles ?? false}\n        headingID=\"config.tasks.clean_generated.blob_files\"\n        onChange={(v) => setOptions({ blobFiles: v })}\n      />\n      <BooleanSetting\n        id=\"clean-generated-screenshots\"\n        checked={options.screenshots ?? false}\n        headingID=\"config.tasks.clean_generated.previews\"\n        subHeadingID=\"config.tasks.clean_generated.previews_desc\"\n        onChange={(v) => setOptions({ screenshots: v })}\n      />\n      <BooleanSetting\n        id=\"clean-generated-sprites\"\n        checked={options.sprites ?? false}\n        headingID=\"config.tasks.clean_generated.sprites\"\n        onChange={(v) => setOptions({ sprites: v })}\n      />\n      <BooleanSetting\n        id=\"clean-generated-transcodes\"\n        checked={options.transcodes ?? false}\n        headingID=\"config.tasks.clean_generated.transcodes\"\n        onChange={(v) => setOptions({ transcodes: v })}\n      />\n      <BooleanSetting\n        id=\"clean-generated-markers\"\n        checked={options.markers ?? false}\n        headingID=\"config.tasks.clean_generated.markers\"\n        onChange={(v) => setOptions({ markers: v })}\n      />\n      <BooleanSetting\n        id=\"clean-generated-image-thumbnails\"\n        checked={options.imageThumbnails ?? false}\n        headingID=\"config.tasks.clean_generated.image_thumbnails\"\n        subHeadingID=\"config.tasks.clean_generated.image_thumbnails_desc\"\n        onChange={(v) => setOptions({ imageThumbnails: v })}\n      />\n      <BooleanSetting\n        id=\"clean-generated-dryrun\"\n        checked={options.dryRun ?? false}\n        headingID=\"config.tasks.only_dry_run\"\n        onChange={(v) => setOptions({ dryRun: v })}\n      />\n    </>\n  );\n};\n\nexport const CleanGeneratedDialog: React.FC<{\n  onClose: (input?: GQL.CleanGeneratedInput) => void;\n}> = ({ onClose }) => {\n  const intl = useIntl();\n\n  const { ui, saveUI, loading } = useSettings();\n\n  const [options, setOptions] = useState<GQL.CleanGeneratedInput>({\n    blobFiles: true,\n    imageThumbnails: true,\n    markers: true,\n    screenshots: true,\n    sprites: true,\n    transcodes: true,\n    dryRun: false,\n  });\n\n  useEffect(() => {\n    const defaults = ui.taskDefaults?.cleanGenerated;\n    if (defaults) {\n      setOptions(defaults);\n    }\n  }, [ui?.taskDefaults?.cleanGenerated]);\n\n  function confirm() {\n    saveUI({\n      taskDefaults: {\n        ...ui.taskDefaults,\n        cleanGenerated: options,\n      },\n    });\n    onClose(options);\n  }\n\n  if (loading) return <LoadingIndicator />;\n\n  return (\n    <ModalComponent\n      show\n      header={<FormattedMessage id=\"actions.clean_generated\" />}\n      icon={faTrashAlt}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.clean_generated\" }),\n        variant: \"danger\",\n        onClick: () => confirm(),\n      }}\n      cancel={{ onClick: () => onClose() }}\n    >\n      <div className=\"dialog-container\">\n        <p>\n          <FormattedMessage id=\"config.tasks.clean_generated.description\" />\n        </p>\n        <SettingSection>\n          <CleanGeneratedOptions options={options} setOptions={setOptions} />\n        </SettingSection>\n        {options.dryRun && (\n          <p>\n            <FormattedMessage id=\"actions.tasks.dry_mode_selected\" />\n          </p>\n        )}\n      </div>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx",
    "content": "import React, { useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Button, Col, Form, Row } from \"react-bootstrap\";\nimport {\n  mutateMigrateHashNaming,\n  mutateMetadataExport,\n  mutateBackupDatabase,\n  mutateMetadataImport,\n  mutateMetadataClean,\n  mutateAnonymiseDatabase,\n  mutateMigrateSceneScreenshots,\n  mutateMigrateBlobs,\n  mutateOptimiseDatabase,\n  mutateCleanGenerated,\n} from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport downloadFile from \"src/utils/download\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { ImportDialog } from \"./ImportDialog\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SettingSection } from \"../SettingSection\";\nimport { BooleanSetting, Setting } from \"../Inputs\";\nimport { ManualLink } from \"src/components/Help/context\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { FolderSelect } from \"src/components/Shared/FolderSelect/FolderSelect\";\nimport {\n  faBoxArchive,\n  faExclamationTriangle,\n  faMinus,\n  faPlus,\n  faQuestionCircle,\n  faTrashAlt,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { CleanGeneratedDialog } from \"./CleanGeneratedDialog\";\n\ninterface ICleanDialog {\n  pathSelection?: boolean;\n  dryRun: boolean;\n  onClose: (paths?: string[]) => void;\n}\n\nconst CleanDialog: React.FC<ICleanDialog> = ({\n  pathSelection = false,\n  dryRun,\n  onClose,\n}) => {\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n\n  const libraryPaths = configuration?.general.stashes.map((s) => s.path);\n\n  const [paths, setPaths] = useState<string[]>([]);\n  const [currentDirectory, setCurrentDirectory] = useState<string>(\"\");\n\n  function removePath(p: string) {\n    setPaths(paths.filter((path) => path !== p));\n  }\n\n  function addPath(p: string) {\n    if (p && !paths.includes(p)) {\n      setPaths(paths.concat(p));\n    }\n  }\n\n  let msg;\n  if (dryRun) {\n    msg = (\n      <p>{intl.formatMessage({ id: \"actions.tasks.dry_mode_selected\" })}</p>\n    );\n  } else {\n    msg = (\n      <p>{intl.formatMessage({ id: \"actions.tasks.clean_confirm_message\" })}</p>\n    );\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faTrashAlt}\n      disabled={pathSelection && paths.length === 0}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.clean\" }),\n        variant: \"danger\",\n        onClick: () => onClose(paths),\n      }}\n      cancel={{ onClick: () => onClose() }}\n    >\n      <div className=\"dialog-container\">\n        <div className=\"mb-3\">\n          {paths.map((p) => (\n            <Row className=\"align-items-center mb-1\" key={p}>\n              <Form.Label column xs={10}>\n                {p}\n              </Form.Label>\n              <Col xs={2} className=\"d-flex justify-content-end\">\n                <Button\n                  className=\"ml-auto\"\n                  size=\"sm\"\n                  variant=\"danger\"\n                  title={intl.formatMessage({ id: \"actions.delete\" })}\n                  onClick={() => removePath(p)}\n                >\n                  <Icon icon={faMinus} />\n                </Button>\n              </Col>\n            </Row>\n          ))}\n\n          {pathSelection ? (\n            <FolderSelect\n              currentDirectory={currentDirectory}\n              onChangeDirectory={setCurrentDirectory}\n              defaultDirectories={libraryPaths}\n              appendButton={\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => addPath(currentDirectory)}\n                >\n                  <Icon icon={faPlus} />\n                </Button>\n              }\n            />\n          ) : undefined}\n        </div>\n\n        {msg}\n      </div>\n    </ModalComponent>\n  );\n};\n\ninterface ICleanOptions {\n  options: GQL.CleanMetadataInput;\n  setOptions: (s: GQL.CleanMetadataInput) => void;\n}\n\nconst CleanOptions: React.FC<ICleanOptions> = ({\n  options,\n  setOptions: setOptionsState,\n}) => {\n  function setOptions(input: Partial<GQL.CleanMetadataInput>) {\n    setOptionsState({ ...options, ...input });\n  }\n\n  return (\n    <>\n      <BooleanSetting\n        id=\"clean-ignore-zip-contents\"\n        checked={options.ignoreZipFileContents ?? false}\n        headingID=\"config.tasks.clean_ignore_zip_contents\"\n        subHeadingID=\"config.tasks.clean_ignore_zip_contents_desc\"\n        onChange={(v) => setOptions({ ignoreZipFileContents: v })}\n      />\n      <BooleanSetting\n        id=\"clean-dryrun\"\n        checked={options.dryRun}\n        headingID=\"config.tasks.only_dry_run\"\n        onChange={(v) => setOptions({ dryRun: v })}\n      />\n    </>\n  );\n};\n\nconst BackupDialog: React.FC<{\n  onClose: (\n    confirmed?: boolean,\n    download?: boolean,\n    includeBlobs?: boolean\n  ) => void;\n}> = ({ onClose }) => {\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n\n  const includeBlobsDefault =\n    configuration?.general.blobsStorage === GQL.BlobsStorageType.Filesystem;\n  const backupDir =\n    configuration.general.backupDirectoryPath ||\n    `<${intl.formatMessage({\n      id: \"config.general.backup_directory_path.heading\",\n    })}>`;\n\n  const [download, setDownload] = useState(false);\n  const [includeBlobs, setIncludeBlobs] = useState(includeBlobsDefault);\n\n  let msg;\n  if (!includeBlobs) {\n    msg = intl.formatMessage(\n      { id: \"config.tasks.backup_database.sqlite\" },\n      {\n        filename_format: (\n          <code>[origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]</code>\n        ),\n      }\n    );\n  } else {\n    msg = intl.formatMessage(\n      { id: \"config.tasks.backup_database.zip\" },\n      {\n        filename_format: (\n          <code>\n            [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS].zip\n          </code>\n        ),\n      }\n    );\n  }\n\n  const warning =\n    includeBlobs !== includeBlobsDefault ? (\n      <p className=\"lead\">\n        <Icon icon={faExclamationTriangle} className=\"text-warning\" />\n        <FormattedMessage id=\"config.tasks.backup_database.warning_blobs\" />\n      </p>\n    ) : null;\n\n  const acceptID = download\n    ? \"config.tasks.backup_database.download\"\n    : \"actions.backup\";\n\n  return (\n    <ModalComponent\n      show\n      icon={faBoxArchive}\n      accept={{\n        text: intl.formatMessage({ id: acceptID }),\n        onClick: () => onClose(true, download, includeBlobs),\n      }}\n      cancel={{\n        onClick: () => onClose(),\n        variant: \"secondary\",\n      }}\n    >\n      <div className=\"dialog-container\">\n        <Form.Group>\n          <h5>\n            <FormattedMessage id=\"config.tasks.backup_database.destination\" />\n          </h5>\n          <Form.Check\n            type=\"radio\"\n            id=\"backup-directory\"\n            checked={!download}\n            onChange={() => setDownload(false)}\n            label={intl.formatMessage(\n              {\n                id: \"config.tasks.backup_database.to_directory\",\n              },\n              {\n                directory: <code>{backupDir}</code>,\n              }\n            )}\n          />\n\n          <Form.Check\n            type=\"radio\"\n            id=\"backup-download\"\n            checked={download}\n            onChange={() => setDownload(true)}\n            label={intl.formatMessage({\n              id: \"config.tasks.backup_database.download\",\n            })}\n          />\n        </Form.Group>\n\n        <SettingSection>\n          <BooleanSetting\n            id=\"backup-include-blobs\"\n            // if includeBlobsDefault is false, then blobs are in the database, so we check the box and disable it\n            checked={includeBlobs || !includeBlobsDefault}\n            headingID=\"config.tasks.backup_database.include_blobs\"\n            onChange={(v) => setIncludeBlobs(v)}\n            // if includeBlobsDefault is false, then blobs are in the database\n            disabled={!includeBlobsDefault}\n          />\n        </SettingSection>\n\n        <p>{msg}</p>\n        {warning}\n      </div>\n    </ModalComponent>\n  );\n};\n\ninterface IDataManagementTasks {\n  setIsBackupRunning: (v: boolean) => void;\n  setIsAnonymiseRunning: (v: boolean) => void;\n}\n\nexport const DataManagementTasks: React.FC<IDataManagementTasks> = ({\n  setIsBackupRunning,\n  setIsAnonymiseRunning,\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const [dialogOpen, setDialogOpenState] = useState({\n    importAlert: false,\n    import: false,\n    backup: false,\n    clean: false,\n    cleanAlert: false,\n    cleanGenerated: false,\n  });\n\n  const [cleanOptions, setCleanOptions] = useState<GQL.CleanMetadataInput>({\n    dryRun: false,\n  });\n\n  const [migrateBlobsOptions, setMigrateBlobsOptions] =\n    useState<GQL.MigrateBlobsInput>({\n      deleteOld: true,\n    });\n\n  const [migrateSceneScreenshotsOptions, setMigrateSceneScreenshotsOptions] =\n    useState<GQL.MigrateSceneScreenshotsInput>({\n      deleteFiles: false,\n      overwriteExisting: false,\n    });\n\n  type DialogOpenState = typeof dialogOpen;\n\n  function setDialogOpen(s: Partial<DialogOpenState>) {\n    setDialogOpenState((v) => {\n      return { ...v, ...s };\n    });\n  }\n\n  async function onImport() {\n    setDialogOpen({ importAlert: false });\n    try {\n      await mutateMetadataImport();\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.import\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  function renderImportAlert() {\n    return (\n      <ModalComponent\n        show={dialogOpen.importAlert}\n        icon={faTrashAlt}\n        accept={{\n          text: intl.formatMessage({ id: \"actions.import\" }),\n          variant: \"danger\",\n          onClick: onImport,\n        }}\n        cancel={{ onClick: () => setDialogOpen({ importAlert: false }) }}\n      >\n        <p>{intl.formatMessage({ id: \"actions.tasks.import_warning\" })}</p>\n      </ModalComponent>\n    );\n  }\n\n  function renderImportDialog() {\n    if (!dialogOpen.import) {\n      return;\n    }\n\n    return <ImportDialog onClose={() => setDialogOpen({ import: false })} />;\n  }\n\n  async function onClean(paths?: string[]) {\n    try {\n      await mutateMetadataClean({\n        ...cleanOptions,\n        paths,\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.clean\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setDialogOpen({ clean: false });\n    }\n  }\n\n  async function onCleanGenerated(options: GQL.CleanGeneratedInput) {\n    try {\n      await mutateCleanGenerated({\n        ...options,\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          {\n            operation_name: intl.formatMessage({\n              id: \"actions.clean_generated\",\n            }),\n          }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function onMigrateHashNaming() {\n    try {\n      await mutateMigrateHashNaming();\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          {\n            operation_name: intl.formatMessage({\n              id: \"actions.hash_migration\",\n            }),\n          }\n        )\n      );\n    } catch (err) {\n      Toast.error(err);\n    }\n  }\n\n  async function onMigrateSceneScreenshots() {\n    try {\n      await mutateMigrateSceneScreenshots(migrateSceneScreenshotsOptions);\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          {\n            operation_name: intl.formatMessage({\n              id: \"actions.migrate_scene_screenshots\",\n            }),\n          }\n        )\n      );\n    } catch (err) {\n      Toast.error(err);\n    }\n  }\n\n  async function onMigrateBlobs() {\n    try {\n      await mutateMigrateBlobs(migrateBlobsOptions);\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          {\n            operation_name: intl.formatMessage({\n              id: \"actions.migrate_blobs\",\n            }),\n          }\n        )\n      );\n    } catch (err) {\n      Toast.error(err);\n    }\n  }\n\n  async function onExport() {\n    try {\n      await mutateMetadataExport();\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.export\" }) }\n        )\n      );\n    } catch (err) {\n      Toast.error(err);\n    }\n  }\n\n  async function onBackup(download?: boolean, includeBlobs?: boolean) {\n    try {\n      setIsBackupRunning(true);\n      const ret = await mutateBackupDatabase({\n        download,\n        includeBlobs,\n      });\n\n      // download the result\n      if (download && ret.data && ret.data.backupDatabase) {\n        const link = ret.data.backupDatabase;\n        downloadFile(link);\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsBackupRunning(false);\n    }\n  }\n\n  async function onOptimiseDatabase() {\n    try {\n      await mutateOptimiseDatabase();\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          {\n            operation_name: intl.formatMessage({\n              id: \"actions.optimise_database\",\n            }),\n          }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function onAnonymise(download?: boolean) {\n    try {\n      setIsAnonymiseRunning(true);\n      const ret = await mutateAnonymiseDatabase({\n        download,\n      });\n\n      // download the result\n      if (download && ret.data && ret.data.anonymiseDatabase) {\n        const link = ret.data.anonymiseDatabase;\n        downloadFile(link);\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsAnonymiseRunning(false);\n    }\n  }\n\n  return (\n    <Form.Group>\n      {renderImportAlert()}\n      {renderImportDialog()}\n      {dialogOpen.cleanAlert || dialogOpen.clean ? (\n        <CleanDialog\n          dryRun={cleanOptions.dryRun}\n          pathSelection={dialogOpen.clean}\n          onClose={(p) => {\n            // undefined means cancelled\n            if (p !== undefined) {\n              if (dialogOpen.cleanAlert) {\n                // don't provide paths\n                onClean();\n              } else {\n                onClean(p);\n              }\n            }\n\n            setDialogOpen({\n              clean: false,\n              cleanAlert: false,\n            });\n          }}\n        />\n      ) : (\n        dialogOpen.clean\n      )}\n      {dialogOpen.cleanGenerated && (\n        <CleanGeneratedDialog\n          onClose={(options) => {\n            if (options) {\n              onCleanGenerated(options);\n            }\n\n            setDialogOpen({ cleanGenerated: false });\n          }}\n        />\n      )}\n      {dialogOpen.backup && (\n        <BackupDialog\n          onClose={(confirmed, download, includeBlobs) => {\n            if (confirmed) {\n              onBackup(download, includeBlobs);\n            }\n\n            setDialogOpen({ backup: false });\n          }}\n        />\n      )}\n\n      <SettingSection headingID=\"config.tasks.maintenance\">\n        <div className=\"setting-group\">\n          <Setting\n            heading={\n              <>\n                <FormattedMessage id=\"actions.clean\" />\n                <ManualLink tab=\"Tasks\">\n                  <Icon icon={faQuestionCircle} />\n                </ManualLink>\n              </>\n            }\n            subHeadingID=\"config.tasks.cleanup_desc\"\n          >\n            <Button\n              variant=\"danger\"\n              type=\"submit\"\n              onClick={() => setDialogOpen({ cleanAlert: true })}\n            >\n              <FormattedMessage id=\"actions.clean\" />…\n            </Button>\n            <Button\n              variant=\"danger\"\n              type=\"submit\"\n              onClick={() => setDialogOpen({ clean: true })}\n            >\n              <FormattedMessage id=\"actions.selective_clean\" />…\n            </Button>\n          </Setting>\n          <CleanOptions\n            options={cleanOptions}\n            setOptions={(o) => setCleanOptions(o)}\n          />\n        </div>\n\n        <div className=\"setting-group\">\n          <Setting\n            heading={<FormattedMessage id=\"actions.clean_generated\" />}\n            subHeadingID=\"config.tasks.clean_generated.description\"\n          >\n            <Button\n              variant=\"danger\"\n              type=\"submit\"\n              onClick={() => setDialogOpen({ cleanGenerated: true })}\n            >\n              <FormattedMessage id=\"actions.clean_generated\" />…\n            </Button>\n          </Setting>\n        </div>\n\n        <Setting\n          headingID=\"actions.optimise_database\"\n          subHeading={\n            <>\n              <FormattedMessage id=\"config.tasks.optimise_database\" />\n              <br />\n              <FormattedMessage id=\"config.tasks.optimise_database_warning\" />\n            </>\n          }\n        >\n          <Button\n            id=\"optimiseDatabase\"\n            variant=\"danger\"\n            onClick={() => onOptimiseDatabase()}\n          >\n            <FormattedMessage id=\"actions.optimise_database\" />\n          </Button>\n        </Setting>\n      </SettingSection>\n\n      <SettingSection headingID=\"metadata\">\n        <Setting\n          headingID=\"actions.full_export\"\n          subHeadingID=\"config.tasks.export_to_json\"\n        >\n          <Button\n            id=\"export\"\n            variant=\"secondary\"\n            type=\"submit\"\n            onClick={() => onExport()}\n          >\n            <FormattedMessage id=\"actions.full_export\" />\n          </Button>\n        </Setting>\n\n        <Setting\n          headingID=\"actions.full_import\"\n          subHeadingID=\"config.tasks.import_from_exported_json\"\n        >\n          <Button\n            id=\"import\"\n            variant=\"danger\"\n            type=\"submit\"\n            onClick={() => setDialogOpen({ importAlert: true })}\n          >\n            <FormattedMessage id=\"actions.full_import\" />\n          </Button>\n        </Setting>\n\n        <Setting\n          headingID=\"actions.import_from_file\"\n          subHeadingID=\"config.tasks.incremental_import\"\n        >\n          <Button\n            id=\"partial-import\"\n            variant=\"danger\"\n            type=\"submit\"\n            onClick={() => setDialogOpen({ import: true })}\n          >\n            <FormattedMessage id=\"actions.import_from_file\" />\n          </Button>\n        </Setting>\n      </SettingSection>\n\n      <SettingSection headingID=\"actions.backup\">\n        <Setting\n          heading={\n            <>\n              <FormattedMessage id=\"actions.backup\" />\n              <ManualLink tab=\"Tasks\">\n                <Icon icon={faQuestionCircle} />\n              </ManualLink>\n            </>\n          }\n          subHeading={intl.formatMessage({\n            id: \"config.tasks.backup_database.description\",\n          })}\n        >\n          <Button\n            id=\"backup\"\n            variant=\"secondary\"\n            type=\"submit\"\n            onClick={() => setDialogOpen({ backup: true })}\n          >\n            <FormattedMessage id=\"actions.backup\" />…\n          </Button>\n        </Setting>\n      </SettingSection>\n\n      <SettingSection headingID=\"actions.anonymise\">\n        <Setting\n          headingID=\"actions.anonymise\"\n          subHeading={intl.formatMessage(\n            { id: \"config.tasks.anonymise_database\" },\n            {\n              filename_format: (\n                <code>\n                  [origFilename].anonymous.sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]\n                </code>\n              ),\n            }\n          )}\n        >\n          <Button\n            id=\"anonymise\"\n            variant=\"secondary\"\n            type=\"submit\"\n            onClick={() => onAnonymise()}\n          >\n            <FormattedMessage id=\"actions.anonymise\" />\n          </Button>\n        </Setting>\n\n        <Setting\n          headingID=\"actions.download_anonymised\"\n          subHeadingID=\"config.tasks.anonymise_and_download\"\n        >\n          <Button\n            id=\"anonymiseDownload\"\n            variant=\"secondary\"\n            type=\"submit\"\n            onClick={() => onAnonymise(true)}\n          >\n            <FormattedMessage id=\"actions.download_anonymised\" />\n          </Button>\n        </Setting>\n      </SettingSection>\n\n      <SettingSection headingID=\"config.tasks.migrations\">\n        <Setting\n          advanced\n          headingID=\"actions.rename_gen_files\"\n          subHeadingID=\"config.tasks.migrate_hash_files\"\n        >\n          <Button\n            id=\"migrateHashNaming\"\n            variant=\"danger\"\n            onClick={() => onMigrateHashNaming()}\n          >\n            <FormattedMessage id=\"actions.rename_gen_files\" />\n          </Button>\n        </Setting>\n\n        <div className=\"setting-group\">\n          <Setting\n            headingID=\"actions.migrate_blobs\"\n            subHeadingID=\"config.tasks.migrate_blobs.description\"\n          >\n            <Button\n              id=\"migrateBlobs\"\n              variant=\"danger\"\n              onClick={() => onMigrateBlobs()}\n            >\n              <FormattedMessage id=\"actions.migrate_blobs\" />\n            </Button>\n          </Setting>\n\n          <BooleanSetting\n            id=\"migrate-blobs-delete-old\"\n            checked={migrateBlobsOptions.deleteOld ?? false}\n            headingID=\"config.tasks.migrate_blobs.delete_old\"\n            onChange={(v) =>\n              setMigrateBlobsOptions({ ...migrateBlobsOptions, deleteOld: v })\n            }\n          />\n        </div>\n\n        <div className=\"setting-group\">\n          <Setting\n            headingID=\"actions.migrate_scene_screenshots\"\n            subHeadingID=\"config.tasks.migrate_scene_screenshots.description\"\n          >\n            <Button\n              id=\"migrateSceneScreenshots\"\n              variant=\"danger\"\n              onClick={() => onMigrateSceneScreenshots()}\n            >\n              <FormattedMessage id=\"actions.migrate_scene_screenshots\" />\n            </Button>\n          </Setting>\n\n          <BooleanSetting\n            id=\"migrate-scene-screenshots-overwrite-existing\"\n            checked={migrateSceneScreenshotsOptions.overwriteExisting ?? false}\n            headingID=\"config.tasks.migrate_scene_screenshots.overwrite_existing\"\n            onChange={(v) =>\n              setMigrateSceneScreenshotsOptions({\n                ...migrateSceneScreenshotsOptions,\n                overwriteExisting: v,\n              })\n            }\n          />\n\n          <BooleanSetting\n            id=\"migrate-scene-screenshots-delete-files\"\n            checked={migrateSceneScreenshotsOptions.deleteFiles ?? false}\n            headingID=\"config.tasks.migrate_scene_screenshots.delete_files\"\n            onChange={(v) =>\n              setMigrateSceneScreenshotsOptions({\n                ...migrateSceneScreenshotsOptions,\n                deleteFiles: v,\n              })\n            }\n          />\n        </div>\n      </SettingSection>\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/DirectorySelectionDialog.tsx",
    "content": "import {\n  faMinus,\n  faPencilAlt,\n  faPlus,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState } from \"react\";\nimport { Button, Col, Form, Row } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { FolderSelect } from \"src/components/Shared/FolderSelect/FolderSelect\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\ninterface IDirectorySelectionDialogProps {\n  animation?: boolean;\n  initialPaths?: string[];\n  allowEmpty?: boolean;\n  onClose: (paths?: string[]) => void;\n}\n\nexport const DirectorySelectionDialog: React.FC<\n  IDirectorySelectionDialogProps\n> = ({ animation, allowEmpty = false, initialPaths = [], onClose }) => {\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n\n  const libraryPaths = configuration?.general.stashes.map((s) => s.path);\n\n  const [paths, setPaths] = useState<string[]>(initialPaths);\n  const [currentDirectory, setCurrentDirectory] = useState<string>(\"\");\n\n  function removePath(p: string) {\n    setPaths(paths.filter((path) => path !== p));\n  }\n\n  function addPath(p: string) {\n    if (p && !paths.includes(p)) {\n      setPaths(paths.concat(p));\n    }\n  }\n\n  return (\n    <ModalComponent\n      show\n      modalProps={{ animation }}\n      disabled={!allowEmpty && paths.length === 0}\n      icon={faPencilAlt}\n      header={intl.formatMessage({ id: \"actions.select_folders\" })}\n      accept={{\n        onClick: () => {\n          onClose(paths);\n        },\n        text: intl.formatMessage({ id: \"actions.confirm\" }),\n      }}\n      cancel={{\n        onClick: () => onClose(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n    >\n      <div className=\"dialog-container\">\n        {paths.map((p) => (\n          <Row className=\"align-items-center mb-1\" key={p}>\n            <Form.Label column xs={10}>\n              {p}\n            </Form.Label>\n            <Col xs={2} className=\"d-flex justify-content-end\">\n              <Button\n                className=\"ml-auto\"\n                size=\"sm\"\n                variant=\"danger\"\n                title={intl.formatMessage({ id: \"actions.delete\" })}\n                onClick={() => removePath(p)}\n              >\n                <Icon icon={faMinus} />\n              </Button>\n            </Col>\n          </Row>\n        ))}\n\n        <FolderSelect\n          currentDirectory={currentDirectory}\n          onChangeDirectory={setCurrentDirectory}\n          defaultDirectories={libraryPaths}\n          appendButton={\n            <Button\n              variant=\"secondary\"\n              onClick={() => addPath(currentDirectory)}\n            >\n              <Icon icon={faPlus} />\n            </Button>\n          }\n        />\n      </div>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { BooleanSetting, ModalSetting } from \"../Inputs\";\nimport {\n  VideoPreviewInput,\n  VideoPreviewSettingsInput,\n} from \"../GeneratePreviewOptions\";\n\ninterface IGenerateOptions {\n  type?: \"scene\" | \"image\" | \"gallery\";\n  selection?: boolean;\n  options: GQL.GenerateMetadataInput;\n  setOptions: (s: GQL.GenerateMetadataInput) => void;\n}\n\nexport const GenerateOptions: React.FC<IGenerateOptions> = ({\n  type,\n  selection,\n  options,\n  setOptions: setOptionsState,\n}) => {\n  const previewOptions: GQL.GeneratePreviewOptionsInput =\n    options.previewOptions ?? {};\n\n  function setOptions(input: Partial<GQL.GenerateMetadataInput>) {\n    setOptionsState({ ...options, ...input });\n  }\n\n  const showSceneOptions = !type || type === \"scene\";\n  const showImageOptions = !type || type === \"image\" || type === \"gallery\";\n\n  return (\n    <>\n      {showSceneOptions && (\n        <>\n          <BooleanSetting\n            id=\"covers-task\"\n            headingID=\"dialogs.scene_gen.covers\"\n            checked={options.covers ?? false}\n            onChange={(v) => setOptions({ covers: v })}\n          />\n          <BooleanSetting\n            id=\"preview-task\"\n            checked={options.previews ?? false}\n            headingID=\"dialogs.scene_gen.video_previews\"\n            tooltipID=\"dialogs.scene_gen.video_previews_tooltip\"\n            onChange={(v) => setOptions({ previews: v })}\n          />\n          <BooleanSetting\n            advanced\n            className=\"sub-setting\"\n            id=\"image-preview-task\"\n            checked={options.imagePreviews ?? false}\n            disabled={!options.previews}\n            headingID=\"dialogs.scene_gen.image_previews\"\n            tooltipID=\"dialogs.scene_gen.image_previews_tooltip\"\n            onChange={(v) => setOptions({ imagePreviews: v })}\n          />\n\n          {/* #2251 - only allow preview generation options to be overridden when generating from a selection */}\n          {selection ? (\n            <ModalSetting<VideoPreviewSettingsInput>\n              id=\"video-preview-settings\"\n              className=\"sub-setting\"\n              disabled={!options.previews}\n              headingID=\"dialogs.scene_gen.override_preview_generation_options\"\n              tooltipID=\"dialogs.scene_gen.override_preview_generation_options_desc\"\n              value={{\n                previewExcludeEnd: previewOptions.previewExcludeEnd,\n                previewExcludeStart: previewOptions.previewExcludeStart,\n                previewSegmentDuration: previewOptions.previewSegmentDuration,\n                previewSegments: previewOptions.previewSegments,\n              }}\n              onChange={(v) => setOptions({ previewOptions: v })}\n              renderField={(value, setValue) => (\n                <VideoPreviewInput value={value ?? {}} setValue={setValue} />\n              )}\n              renderValue={() => {\n                return <></>;\n              }}\n            />\n          ) : undefined}\n\n          <BooleanSetting\n            id=\"sprite-task\"\n            checked={options.sprites ?? false}\n            headingID=\"dialogs.scene_gen.sprites\"\n            tooltipID=\"dialogs.scene_gen.sprites_tooltip\"\n            onChange={(v) => setOptions({ sprites: v })}\n          />\n          <BooleanSetting\n            id=\"marker-task\"\n            checked={options.markers ?? false}\n            headingID=\"dialogs.scene_gen.markers\"\n            tooltipID=\"dialogs.scene_gen.markers_tooltip\"\n            onChange={(v) => setOptions({ markers: v })}\n          />\n          <BooleanSetting\n            advanced\n            id=\"marker-image-preview-task\"\n            className=\"sub-setting\"\n            checked={options.markerImagePreviews ?? false}\n            headingID=\"dialogs.scene_gen.marker_image_previews\"\n            tooltipID=\"dialogs.scene_gen.marker_image_previews_tooltip\"\n            onChange={(v) =>\n              setOptions({\n                markerImagePreviews: v,\n              })\n            }\n          />\n          <BooleanSetting\n            id=\"marker-screenshot-task\"\n            checked={options.markerScreenshots ?? false}\n            headingID=\"dialogs.scene_gen.marker_screenshots\"\n            tooltipID=\"dialogs.scene_gen.marker_screenshots_tooltip\"\n            onChange={(v) => setOptions({ markerScreenshots: v })}\n          />\n\n          <BooleanSetting\n            advanced\n            id=\"transcode-task\"\n            checked={options.transcodes ?? false}\n            headingID=\"dialogs.scene_gen.transcodes\"\n            tooltipID=\"dialogs.scene_gen.transcodes_tooltip\"\n            onChange={(v) => setOptions({ transcodes: v })}\n          />\n          {selection ? (\n            <BooleanSetting\n              advanced\n              id=\"force-transcode\"\n              className=\"sub-setting\"\n              checked={options.forceTranscodes ?? false}\n              disabled={!options.transcodes}\n              headingID=\"dialogs.scene_gen.force_transcodes\"\n              tooltipID=\"dialogs.scene_gen.force_transcodes_tooltip\"\n              onChange={(v) => setOptions({ forceTranscodes: v })}\n            />\n          ) : undefined}\n\n          <BooleanSetting\n            id=\"phash-task\"\n            checked={options.phashes ?? false}\n            headingID=\"dialogs.scene_gen.phash\"\n            tooltipID=\"dialogs.scene_gen.phash_tooltip\"\n            onChange={(v) => setOptions({ phashes: v })}\n          />\n\n          <BooleanSetting\n            id=\"interactive-heatmap-speed-task\"\n            checked={options.interactiveHeatmapsSpeeds ?? false}\n            headingID=\"dialogs.scene_gen.interactive_heatmap_speed\"\n            onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })}\n          />\n        </>\n      )}\n      {showImageOptions && (\n        <>\n          <BooleanSetting\n            id=\"clip-previews\"\n            checked={options.clipPreviews ?? false}\n            headingID=\"dialogs.scene_gen.clip_previews\"\n            onChange={(v) => setOptions({ clipPreviews: v })}\n          />\n          <BooleanSetting\n            id=\"image-thumbnails\"\n            checked={options.imageThumbnails ?? false}\n            headingID=\"dialogs.scene_gen.image_thumbnails\"\n            onChange={(v) => setOptions({ imageThumbnails: v })}\n          />\n          <BooleanSetting\n            id=\"image-phash-task\"\n            checked={options.imagePhashes ?? false}\n            headingID=\"dialogs.scene_gen.image_phash\"\n            tooltipID=\"dialogs.scene_gen.image_phash_tooltip\"\n            onChange={(v) => setOptions({ imagePhashes: v })}\n          />\n        </>\n      )}\n      <BooleanSetting\n        id=\"overwrite\"\n        checked={options.overwrite ?? false}\n        headingID=\"dialogs.scene_gen.overwrite\"\n        onChange={(v) => setOptions({ overwrite: v })}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/ImportDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { mutateImportObjects } from \"src/core/StashService\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useIntl } from \"react-intl\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IImportDialogProps {\n  onClose: () => void;\n}\n\nexport const ImportDialog: React.FC<IImportDialogProps> = (\n  props: IImportDialogProps\n) => {\n  const [duplicateBehaviour, setDuplicateBehaviour] = useState<string>(\n    duplicateHandlingToString(GQL.ImportDuplicateEnum.Ignore)\n  );\n\n  const [missingRefBehaviour, setMissingRefBehaviour] = useState<string>(\n    missingRefHandlingToString(GQL.ImportMissingRefEnum.Fail)\n  );\n\n  const [file, setFile] = useState<File | undefined>();\n\n  // Network state\n  const [isRunning, setIsRunning] = useState(false);\n\n  const intl = useIntl();\n  const Toast = useToast();\n\n  function duplicateHandlingToString(\n    value: GQL.ImportDuplicateEnum | undefined\n  ) {\n    switch (value) {\n      case GQL.ImportDuplicateEnum.Fail:\n        return \"Fail\";\n      case GQL.ImportDuplicateEnum.Ignore:\n        return \"Ignore\";\n      case GQL.ImportDuplicateEnum.Overwrite:\n        return \"Overwrite\";\n    }\n    return \"Ignore\";\n  }\n\n  function translateDuplicateHandling(value: string) {\n    switch (value) {\n      case \"Fail\":\n        return GQL.ImportDuplicateEnum.Fail;\n      case \"Ignore\":\n        return GQL.ImportDuplicateEnum.Ignore;\n      case \"Overwrite\":\n        return GQL.ImportDuplicateEnum.Overwrite;\n    }\n\n    return GQL.ImportDuplicateEnum.Ignore;\n  }\n\n  function missingRefHandlingToString(\n    value: GQL.ImportMissingRefEnum | undefined\n  ) {\n    switch (value) {\n      case GQL.ImportMissingRefEnum.Fail:\n        return \"Fail\";\n      case GQL.ImportMissingRefEnum.Ignore:\n        return \"Ignore\";\n      case GQL.ImportMissingRefEnum.Create:\n        return \"Create\";\n    }\n    return \"Fail\";\n  }\n\n  function translateMissingRefHandling(value: string) {\n    switch (value) {\n      case \"Fail\":\n        return GQL.ImportMissingRefEnum.Fail;\n      case \"Ignore\":\n        return GQL.ImportMissingRefEnum.Ignore;\n      case \"Create\":\n        return GQL.ImportMissingRefEnum.Create;\n    }\n\n    return GQL.ImportMissingRefEnum.Fail;\n  }\n\n  function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {\n    if (\n      event.target.validity.valid &&\n      event.target.files &&\n      event.target.files.length > 0\n    ) {\n      setFile(event.target.files[0]);\n    }\n  }\n\n  async function onImport() {\n    if (!file) return;\n\n    try {\n      setIsRunning(true);\n      await mutateImportObjects({\n        duplicateBehaviour: translateDuplicateHandling(duplicateBehaviour),\n        missingRefBehaviour: translateMissingRefHandling(missingRefBehaviour),\n        file,\n      });\n      setIsRunning(false);\n      Toast.success(intl.formatMessage({ id: \"toast.started_importing\" }));\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      props.onClose();\n    }\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faPencilAlt}\n      header={intl.formatMessage({ id: \"actions.import\" })}\n      accept={{\n        onClick: () => {\n          onImport();\n        },\n        text: intl.formatMessage({ id: \"actions.import\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      disabled={!file}\n      isRunning={isRunning}\n    >\n      <div className=\"dialog-container\">\n        <Form>\n          <Form.Group id=\"import-file\">\n            <h6>Import zip file</h6>\n            <Form.File onChange={onFileChange} accept=\".zip\" />\n          </Form.Group>\n          <Form.Group id=\"duplicate-handling\">\n            <h6>Duplicate object handling</h6>\n            <Form.Control\n              className=\"w-auto input-control\"\n              as=\"select\"\n              value={duplicateBehaviour}\n              onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>\n                setDuplicateBehaviour(e.currentTarget.value)\n              }\n            >\n              {Object.values(GQL.ImportDuplicateEnum).map((p) => (\n                <option key={p}>{duplicateHandlingToString(p)}</option>\n              ))}\n            </Form.Control>\n          </Form.Group>\n\n          <Form.Group id=\"missing-ref-handling\">\n            <h6>Missing reference handling</h6>\n            <Form.Control\n              className=\"w-auto input-control\"\n              as=\"select\"\n              value={missingRefBehaviour}\n              onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>\n                setMissingRefBehaviour(e.currentTarget.value)\n              }\n            >\n              {Object.values(GQL.ImportMissingRefEnum).map((p) => (\n                <option key={p}>{missingRefHandlingToString(p)}</option>\n              ))}\n            </Form.Control>\n          </Form.Group>\n        </Form>\n      </div>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/JobTable.tsx",
    "content": "import {\n  faBan,\n  faCheck,\n  faCircle,\n  faCircleExclamation,\n  faCog,\n  faHourglassStart,\n  faTimes,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport moment from \"moment/min/moment-with-locales\";\nimport React, { useEffect, useState } from \"react\";\nimport { Button, Card, ProgressBar } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport {\n  mutateStopJob,\n  useJobQueue,\n  useJobsSubscribe,\n} from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\n\ntype JobFragment = Pick<\n  GQL.Job,\n  | \"id\"\n  | \"status\"\n  | \"subTasks\"\n  | \"description\"\n  | \"progress\"\n  | \"error\"\n  | \"startTime\"\n>;\n\ninterface IJob {\n  job: JobFragment;\n}\n\nconst Task: React.FC<IJob> = ({ job }) => {\n  const [stopping, setStopping] = useState(false);\n  const [className, setClassName] = useState(\"\");\n\n  useEffect(() => {\n    setTimeout(() => setClassName(\"fade-in\"));\n  }, []);\n\n  useEffect(() => {\n    if (\n      job.status === GQL.JobStatus.Cancelled ||\n      job.status === GQL.JobStatus.Failed ||\n      job.status === GQL.JobStatus.Finished\n    ) {\n      // fade out around 10 seconds\n      setTimeout(() => {\n        setClassName(\"fade-out\");\n      }, 9800);\n    }\n  }, [job]);\n\n  async function stopJob() {\n    setStopping(true);\n    await mutateStopJob(job.id);\n  }\n\n  function canStop() {\n    return (\n      !stopping &&\n      (job.status === GQL.JobStatus.Ready ||\n        job.status === GQL.JobStatus.Running)\n    );\n  }\n\n  function getStatusClass() {\n    switch (job.status) {\n      case GQL.JobStatus.Ready:\n        return \"ready\";\n      case GQL.JobStatus.Running:\n        return \"running\";\n      case GQL.JobStatus.Stopping:\n        return \"stopping\";\n      case GQL.JobStatus.Finished:\n        return \"finished\";\n      case GQL.JobStatus.Cancelled:\n        return \"cancelled\";\n      case GQL.JobStatus.Failed:\n        return \"failed\";\n    }\n  }\n\n  function getStatusIcon() {\n    let icon = faCircle;\n    let iconClass = \"\";\n    switch (job.status) {\n      case GQL.JobStatus.Ready:\n        icon = faHourglassStart;\n        break;\n      case GQL.JobStatus.Running:\n        icon = faCog;\n        iconClass = \"fa-spin\";\n        break;\n      case GQL.JobStatus.Stopping:\n        icon = faCog;\n        iconClass = \"fa-spin\";\n        break;\n      case GQL.JobStatus.Finished:\n        icon = faCheck;\n        break;\n      case GQL.JobStatus.Cancelled:\n        icon = faBan;\n        break;\n      case GQL.JobStatus.Failed:\n        icon = faCircleExclamation;\n        break;\n    }\n\n    return <Icon icon={icon} className={`fa-fw ${iconClass}`} />;\n  }\n\n  function maybeRenderProgress() {\n    if (\n      job.status === GQL.JobStatus.Running &&\n      job.progress !== undefined &&\n      job.progress !== null\n    ) {\n      const progress = job.progress * 100;\n      return (\n        <ProgressBar\n          animated\n          now={progress}\n          label={`${progress.toFixed(0)}%`}\n        />\n      );\n    }\n  }\n\n  function maybeRenderETA() {\n    if (\n      job.status === GQL.JobStatus.Running &&\n      job.startTime !== null &&\n      job.startTime !== undefined &&\n      job.progress !== null &&\n      job.progress !== undefined &&\n      job.progress > 0\n    ) {\n      const now = new Date();\n      const start = new Date(job.startTime);\n      const nowMS = now.valueOf();\n      const startMS = start.valueOf();\n      const estimatedLength = (nowMS - startMS) / job.progress;\n      const estLenStr = moment.duration(estimatedLength).humanize();\n      return (\n        <span className=\"job-eta\">\n          <FormattedMessage id=\"eta\" />: {estLenStr}\n        </span>\n      );\n    }\n  }\n\n  function maybeRenderSubTasks() {\n    if (\n      job.status === GQL.JobStatus.Running ||\n      job.status === GQL.JobStatus.Stopping\n    ) {\n      return (\n        <div>\n          {/* eslint-disable react/no-array-index-key */}\n          {(job.subTasks ?? []).map((t, i) => (\n            <div className=\"job-subtask\" key={i}>\n              {t}\n            </div>\n          ))}\n          {/* eslint-enable react/no-array-index-key */}\n        </div>\n      );\n    }\n\n    if (job.status === GQL.JobStatus.Failed && job.error) {\n      return <div className=\"job-error\">{job.error}</div>;\n    }\n  }\n\n  return (\n    <li className={`job ${className}`}>\n      <div>\n        <Button\n          className=\"minimal stop\"\n          size=\"sm\"\n          onClick={() => stopJob()}\n          disabled={!canStop()}\n        >\n          <Icon icon={faTimes} />\n        </Button>\n        <div className={`job-status ${getStatusClass()}`}>\n          <div className=\"job-description\">\n            <div>\n              {getStatusIcon()}\n              <span>{job.description}</span>\n            </div>\n            {maybeRenderETA()}\n          </div>\n          <div>{maybeRenderProgress()}</div>\n          {maybeRenderSubTasks()}\n        </div>\n      </div>\n    </li>\n  );\n};\n\nexport const JobTable: React.FC = () => {\n  const intl = useIntl();\n  const jobStatus = useJobQueue();\n  const jobsSubscribe = useJobsSubscribe();\n\n  const [queue, setQueue] = useState<JobFragment[]>([]);\n\n  useEffect(() => {\n    setQueue(jobStatus.data?.jobQueue ?? []);\n  }, [jobStatus]);\n\n  useEffect(() => {\n    if (!jobsSubscribe.data) {\n      return;\n    }\n\n    const event = jobsSubscribe.data.jobsSubscribe;\n\n    function updateJob() {\n      setQueue((q) =>\n        q.map((j) => {\n          if (j.id === event.job.id) {\n            return event.job;\n          }\n\n          return j;\n        })\n      );\n    }\n\n    switch (event.type) {\n      case GQL.JobStatusUpdateType.Add:\n        // add to the end of the queue\n        setQueue((q) => q.concat([event.job]));\n        break;\n      case GQL.JobStatusUpdateType.Remove:\n        // update the job then remove after a timeout\n        updateJob();\n        setTimeout(() => {\n          setQueue((q) => q.filter((j) => j.id !== event.job.id));\n        }, 10000);\n        break;\n      case GQL.JobStatusUpdateType.Update:\n        updateJob();\n        break;\n    }\n  }, [jobsSubscribe.data]);\n\n  return (\n    <Card className=\"job-table\">\n      <ul>\n        {!queue?.length ? (\n          <span className=\"empty-queue-message\">\n            {intl.formatMessage({ id: \"config.tasks.empty_queue\" })}\n          </span>\n        ) : undefined}\n        {(queue ?? []).map((j) => (\n          <Task job={j} key={j.id} />\n        ))}\n      </ul>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Button, Form } from \"react-bootstrap\";\nimport {\n  mutateMetadataScan,\n  mutateMetadataAutoTag,\n  mutateMetadataGenerate,\n} from \"src/core/StashService\";\nimport { withoutTypename } from \"src/utils/data\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { IdentifyDialog } from \"../../Dialogs/IdentifyDialog/IdentifyDialog\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { DirectorySelectionDialog } from \"./DirectorySelectionDialog\";\nimport { ScanOptions } from \"./ScanOptions\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { GenerateOptions } from \"./GenerateOptions\";\nimport { SettingSection } from \"../SettingSection\";\nimport { BooleanSetting, Setting, SettingGroup } from \"../Inputs\";\nimport { ManualLink } from \"src/components/Help/context\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { faQuestionCircle } from \"@fortawesome/free-solid-svg-icons\";\nimport { useSettings } from \"../context\";\n\ninterface IAutoTagOptions {\n  options: GQL.AutoTagMetadataInput;\n  setOptions: (s: GQL.AutoTagMetadataInput) => void;\n}\n\nconst AutoTagOptions: React.FC<IAutoTagOptions> = ({\n  options,\n  setOptions: setOptionsState,\n}) => {\n  const { performers, studios, tags } = options;\n  const wildcard = [\"*\"];\n\n  function set(v?: boolean) {\n    if (v) {\n      return wildcard;\n    }\n    return [];\n  }\n\n  function setOptions(input: Partial<GQL.AutoTagMetadataInput>) {\n    setOptionsState({ ...options, ...input });\n  }\n\n  return (\n    <>\n      <BooleanSetting\n        id=\"autotag-performers\"\n        checked={!!performers?.length}\n        headingID=\"performers\"\n        onChange={(v) => setOptions({ performers: set(v) })}\n      />\n      <BooleanSetting\n        id=\"autotag-studios\"\n        checked={!!studios?.length}\n        headingID=\"studios\"\n        onChange={(v) => setOptions({ studios: set(v) })}\n      />\n      <BooleanSetting\n        id=\"autotag-tags\"\n        checked={!!tags?.length}\n        headingID=\"tags\"\n        onChange={(v) => setOptions({ tags: set(v) })}\n      />\n    </>\n  );\n};\n\nexport const LibraryTasks: React.FC = () => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const { ui, saveUI, loading } = useSettings();\n\n  const { taskDefaults } = ui;\n\n  const [dialogOpen, setDialogOpenState] = useState({\n    scan: false,\n    autoTag: false,\n    identify: false,\n    generate: false,\n  });\n\n  function getDefaultScanOptions(): GQL.ScanMetadataInput {\n    return {\n      scanGenerateCovers: true,\n      scanGeneratePreviews: false,\n      scanGenerateImagePreviews: false,\n      scanGenerateSprites: false,\n      scanGeneratePhashes: false,\n      scanGenerateThumbnails: false,\n      scanGenerateClipPreviews: false,\n    };\n  }\n\n  const [scanOptions, setScanOptions] = useState<GQL.ScanMetadataInput>(\n    getDefaultScanOptions()\n  );\n  const [autoTagOptions, setAutoTagOptions] =\n    useState<GQL.AutoTagMetadataInput>({\n      performers: [\"*\"],\n      studios: [\"*\"],\n      tags: [\"*\"],\n    });\n\n  function getDefaultGenerateOptions(): GQL.GenerateMetadataInput {\n    return {\n      covers: true,\n      sprites: true,\n      phashes: true,\n      previews: true,\n      markers: true,\n      previewOptions: {\n        previewSegments: 0,\n        previewSegmentDuration: 0,\n        previewPreset: GQL.PreviewPreset.Slow,\n      },\n    };\n  }\n\n  const [generateOptions, setGenerateOptions] =\n    useState<GQL.GenerateMetadataInput>(getDefaultGenerateOptions());\n\n  type DialogOpenState = typeof dialogOpen;\n\n  const { configuration } = useConfigurationContext();\n  const [configRead, setConfigRead] = useState(false);\n\n  useEffect(() => {\n    if (!configuration?.defaults || loading) {\n      return;\n    }\n\n    const { scan, autoTag } = configuration.defaults;\n\n    // prefer UI defaults over system defaults\n    // other defaults should be deprecated\n    if (taskDefaults?.scan) {\n      setScanOptions(taskDefaults.scan);\n    } else if (scan) {\n      setScanOptions(withoutTypename(scan));\n    }\n\n    if (taskDefaults?.autoTag) {\n      setAutoTagOptions(taskDefaults.autoTag);\n    } else if (autoTag) {\n      setAutoTagOptions(withoutTypename(autoTag));\n    }\n\n    if (taskDefaults?.generate) {\n      setGenerateOptions(taskDefaults.generate);\n    }\n\n    // combine the defaults with the system preview generation settings\n    // only do this once\n    // don't do this if UI had a default\n    if (!configRead && !taskDefaults?.generate) {\n      if (configuration?.defaults.generate) {\n        const { generate } = configuration.defaults;\n        setGenerateOptions(withoutTypename(generate));\n      }\n\n      setConfigRead(true);\n    }\n  }, [configuration, configRead, taskDefaults, loading]);\n\n  function configureDefaults(partial: Record<string, {}>) {\n    saveUI({ taskDefaults: { ...partial } });\n  }\n\n  function onSetScanOptions(s: GQL.ScanMetadataInput) {\n    configureDefaults({ scan: s });\n    setScanOptions(s);\n  }\n\n  function onSetGenerateOptions(s: GQL.GenerateMetadataInput) {\n    configureDefaults({ generate: s });\n    setGenerateOptions(s);\n  }\n\n  function onSetAutoTagOptions(s: GQL.AutoTagMetadataInput) {\n    configureDefaults({ autoTag: s });\n    setAutoTagOptions(s);\n  }\n\n  function setDialogOpen(s: Partial<DialogOpenState>) {\n    setDialogOpenState((v) => {\n      return { ...v, ...s };\n    });\n  }\n\n  function renderScanDialog() {\n    if (!dialogOpen.scan) {\n      return;\n    }\n\n    return <DirectorySelectionDialog onClose={onScanDialogClosed} />;\n  }\n\n  function onScanDialogClosed(paths?: string[]) {\n    if (paths) {\n      runScan(paths);\n    }\n\n    setDialogOpen({ scan: false });\n  }\n\n  async function runScan(paths?: string[]) {\n    try {\n      await mutateMetadataScan({\n        ...scanOptions,\n        paths,\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.scan\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  function renderAutoTagDialog() {\n    if (!dialogOpen.autoTag) {\n      return;\n    }\n\n    return <DirectorySelectionDialog onClose={onAutoTagDialogClosed} />;\n  }\n\n  function onAutoTagDialogClosed(paths?: string[]) {\n    if (paths) {\n      runAutoTag(paths);\n    }\n\n    setDialogOpen({ autoTag: false });\n  }\n\n  async function runAutoTag(paths?: string[]) {\n    try {\n      await mutateMetadataAutoTag({\n        ...autoTagOptions,\n        paths,\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.auto_tag\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  function maybeRenderIdentifyDialog() {\n    if (!dialogOpen.identify) return;\n\n    return (\n      <IdentifyDialog onClose={() => setDialogOpen({ identify: false })} />\n    );\n  }\n\n  function renderGenerateDialog() {\n    if (!dialogOpen.generate) {\n      return;\n    }\n\n    return <DirectorySelectionDialog onClose={onGenerateDialogClosed} />;\n  }\n\n  function onGenerateDialogClosed(paths?: string[]) {\n    if (paths) {\n      runGenerate(paths);\n    }\n\n    setDialogOpen({ generate: false });\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  async function runGenerate(paths?: string[]) {\n    try {\n      await mutateMetadataGenerate({\n        ...generateOptions,\n        paths,\n      });\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.generate\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function onGenerateClicked() {\n    try {\n      // insert preview options here instead of loading them\n      const general = configuration?.general;\n\n      await mutateMetadataGenerate({\n        ...generateOptions,\n        previewOptions: {\n          ...generateOptions.previewOptions,\n          previewSegments:\n            general?.previewSegments ??\n            generateOptions.previewOptions?.previewSegments,\n          previewSegmentDuration:\n            general?.previewSegmentDuration ??\n            generateOptions.previewOptions?.previewSegmentDuration,\n          previewExcludeStart:\n            general?.previewExcludeStart ??\n            generateOptions.previewOptions?.previewExcludeStart,\n          previewExcludeEnd:\n            general?.previewExcludeEnd ??\n            generateOptions.previewOptions?.previewExcludeEnd,\n          previewPreset:\n            general?.previewPreset ??\n            generateOptions.previewOptions?.previewPreset,\n        },\n      });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"config.tasks.added_job_to_queue\" },\n          { operation_name: intl.formatMessage({ id: \"actions.generate\" }) }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  return (\n    <Form.Group>\n      {renderScanDialog()}\n      {renderAutoTagDialog()}\n      {maybeRenderIdentifyDialog()}\n      {renderGenerateDialog()}\n\n      <SettingSection headingID=\"library\">\n        <SettingGroup\n          settingProps={{\n            heading: (\n              <>\n                <FormattedMessage id=\"actions.scan\" />\n                <ManualLink tab=\"Tasks\">\n                  <Icon icon={faQuestionCircle} />\n                </ManualLink>\n              </>\n            ),\n            subHeadingID: \"config.tasks.scan_for_content_desc\",\n          }}\n          topLevel={\n            <>\n              <Button\n                variant=\"secondary\"\n                type=\"submit\"\n                className=\"mr-2\"\n                onClick={() => runScan()}\n              >\n                <FormattedMessage id=\"actions.scan\" />\n              </Button>\n\n              <Button\n                variant=\"secondary\"\n                type=\"submit\"\n                className=\"mr-2\"\n                onClick={() => setDialogOpen({ scan: true })}\n              >\n                <FormattedMessage id=\"actions.selective_scan\" />…\n              </Button>\n            </>\n          }\n          collapsible\n        >\n          <ScanOptions options={scanOptions} setOptions={onSetScanOptions} />\n        </SettingGroup>\n      </SettingSection>\n\n      <SettingSection advanced>\n        <Setting\n          heading={\n            <>\n              <FormattedMessage id=\"config.tasks.identify.heading\" />\n              <ManualLink tab=\"Identify\">\n                <Icon icon={faQuestionCircle} />\n              </ManualLink>\n            </>\n          }\n          subHeadingID=\"config.tasks.identify.description\"\n        >\n          <Button\n            variant=\"secondary\"\n            type=\"submit\"\n            onClick={() => setDialogOpen({ identify: true })}\n          >\n            <FormattedMessage id=\"actions.identify\" />…\n          </Button>\n        </Setting>\n      </SettingSection>\n\n      <SettingSection advanced>\n        <SettingGroup\n          settingProps={{\n            heading: (\n              <>\n                <FormattedMessage id=\"actions.auto_tag\" />\n                <ManualLink tab=\"AutoTagging\">\n                  <Icon icon={faQuestionCircle} />\n                </ManualLink>\n              </>\n            ),\n            subHeadingID: \"config.tasks.auto_tag_based_on_filenames\",\n          }}\n          topLevel={\n            <>\n              <Button\n                variant=\"secondary\"\n                type=\"submit\"\n                className=\"mr-2\"\n                onClick={() => runAutoTag()}\n              >\n                <FormattedMessage id=\"actions.auto_tag\" />\n              </Button>\n              <Button\n                variant=\"secondary\"\n                type=\"submit\"\n                onClick={() => setDialogOpen({ autoTag: true })}\n              >\n                <FormattedMessage id=\"actions.selective_auto_tag\" />…\n              </Button>\n            </>\n          }\n          collapsible\n        >\n          <AutoTagOptions\n            options={autoTagOptions}\n            setOptions={onSetAutoTagOptions}\n          />\n        </SettingGroup>\n      </SettingSection>\n\n      <SettingSection headingID=\"config.tasks.generated_content\">\n        <SettingGroup\n          settingProps={{\n            heading: (\n              <>\n                <FormattedMessage id=\"actions.generate\" />\n                <ManualLink tab=\"Tasks\">\n                  <Icon icon={faQuestionCircle} />\n                </ManualLink>\n              </>\n            ),\n            subHeadingID: \"config.tasks.generate_desc\",\n          }}\n          topLevel={\n            <>\n              <Button\n                variant=\"secondary\"\n                type=\"submit\"\n                onClick={() => onGenerateClicked()}\n              >\n                <FormattedMessage id=\"actions.generate\" />\n              </Button>\n              <Button\n                variant=\"secondary\"\n                type=\"submit\"\n                className=\"mr-2\"\n                onClick={() => setDialogOpen({ generate: true })}\n              >\n                <FormattedMessage id=\"actions.selective_generate\" />…\n              </Button>\n            </>\n          }\n          collapsible\n        >\n          <GenerateOptions\n            options={generateOptions}\n            setOptions={onSetGenerateOptions}\n          />\n        </SettingGroup>\n      </SettingSection>\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx",
    "content": "import React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { mutateRunPluginTask, usePlugins } from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SettingSection } from \"../SettingSection\";\nimport { Setting, SettingGroup } from \"../Inputs\";\n\ntype Plugin = Pick<GQL.Plugin, \"id\">;\ntype PluginTask = Pick<GQL.PluginTask, \"name\" | \"description\">;\n\nexport const PluginTasks: React.FC = () => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const plugins = usePlugins();\n\n  function renderPluginTasks(plugin: Plugin, pluginTasks: PluginTask[]) {\n    return pluginTasks.map((o) => {\n      return (\n        <Setting heading={o.name} subHeading={o.description} key={o.name}>\n          <Button\n            onClick={() => onPluginTaskClicked(plugin, o)}\n            variant=\"secondary\"\n            size=\"sm\"\n          >\n            {o.name}\n          </Button>\n        </Setting>\n      );\n    });\n  }\n\n  async function onPluginTaskClicked(plugin: Plugin, operation: PluginTask) {\n    await mutateRunPluginTask(plugin.id, operation.name);\n    Toast.success(\n      intl.formatMessage(\n        { id: \"config.tasks.added_job_to_queue\" },\n        { operation_name: operation.name }\n      )\n    );\n  }\n\n  if (!plugins.data?.plugins) {\n    return null;\n  }\n\n  const taskPlugins = plugins.data.plugins.filter(\n    (p) => p.enabled && p.tasks && p.tasks.length > 0\n  );\n\n  if (!taskPlugins.length) {\n    return null;\n  }\n\n  return (\n    <Form.Group>\n      <SettingSection headingID=\"config.tasks.plugin_tasks\">\n        {taskPlugins.map((o) => {\n          return (\n            <SettingGroup\n              key={o.id}\n              settingProps={{\n                heading: o.name,\n              }}\n              collapsible\n            >\n              {renderPluginTasks(o, o.tasks!)}\n            </SettingGroup>\n          );\n        })}\n      </SettingSection>\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { BooleanSetting } from \"../Inputs\";\n\ninterface IScanOptions {\n  options: GQL.ScanMetadataInput;\n  setOptions: (s: GQL.ScanMetadataInput) => void;\n}\n\nexport const ScanOptions: React.FC<IScanOptions> = ({\n  options,\n  setOptions: setOptionsState,\n}) => {\n  const {\n    scanGenerateCovers,\n    scanGeneratePreviews,\n    scanGenerateImagePreviews,\n    scanGenerateSprites,\n    scanGeneratePhashes,\n    scanGenerateThumbnails,\n    scanGenerateImagePhashes,\n    scanGenerateClipPreviews,\n    rescan,\n  } = options;\n\n  function setOptions(input: Partial<GQL.ScanMetadataInput>) {\n    setOptionsState({ ...options, ...input });\n  }\n\n  return (\n    <>\n      <BooleanSetting\n        id=\"scan-generate-covers\"\n        headingID=\"config.tasks.generate_video_covers_during_scan\"\n        checked={scanGenerateCovers ?? true}\n        onChange={(v) => setOptions({ scanGenerateCovers: v })}\n      />\n      <BooleanSetting\n        id=\"scan-generate-previews\"\n        headingID=\"config.tasks.generate_video_previews_during_scan\"\n        tooltipID=\"config.tasks.generate_video_previews_during_scan_tooltip\"\n        checked={scanGeneratePreviews ?? false}\n        onChange={(v) => setOptions({ scanGeneratePreviews: v })}\n      />\n      <BooleanSetting\n        advanced\n        id=\"scan-generate-image-previews\"\n        className=\"sub-setting\"\n        headingID=\"config.tasks.generate_previews_during_scan\"\n        tooltipID=\"config.tasks.generate_previews_during_scan_tooltip\"\n        checked={scanGenerateImagePreviews ?? false}\n        disabled={!scanGeneratePreviews}\n        onChange={(v) => setOptions({ scanGenerateImagePreviews: v })}\n      />\n\n      <BooleanSetting\n        id=\"scan-generate-sprites\"\n        headingID=\"config.tasks.generate_sprites_during_scan\"\n        tooltipID=\"config.tasks.generate_sprites_during_scan_tooltip\"\n        checked={scanGenerateSprites ?? false}\n        onChange={(v) => setOptions({ scanGenerateSprites: v })}\n      />\n      <BooleanSetting\n        id=\"scan-generate-phashes\"\n        checked={scanGeneratePhashes ?? false}\n        headingID=\"config.tasks.generate_phashes_during_scan\"\n        tooltipID=\"config.tasks.generate_phashes_during_scan_tooltip\"\n        onChange={(v) => setOptions({ scanGeneratePhashes: v })}\n      />\n      <BooleanSetting\n        id=\"scan-generate-thumbnails\"\n        checked={scanGenerateThumbnails ?? false}\n        headingID=\"config.tasks.generate_thumbnails_during_scan\"\n        onChange={(v) => setOptions({ scanGenerateThumbnails: v })}\n      />\n      <BooleanSetting\n        id=\"scan-generate-image-phashes\"\n        checked={scanGenerateImagePhashes ?? false}\n        headingID=\"config.tasks.generate_image_phashes_during_scan\"\n        tooltipID=\"config.tasks.generate_image_phashes_during_scan_tooltip\"\n        onChange={(v) => setOptions({ scanGenerateImagePhashes: v })}\n      />\n      <BooleanSetting\n        id=\"scan-generate-clip-previews\"\n        checked={scanGenerateClipPreviews ?? false}\n        headingID=\"config.tasks.generate_clip_previews_during_scan\"\n        onChange={(v) => setOptions({ scanGenerateClipPreviews: v })}\n      />\n      <BooleanSetting\n        id=\"force-rescan\"\n        headingID=\"config.tasks.rescan\"\n        tooltipID=\"config.tasks.rescan_tooltip\"\n        checked={rescan ?? false}\n        onChange={(v) => setOptions({ rescan: v })}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/Tasks/SettingsTasksPanel.tsx",
    "content": "import React, { useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { LibraryTasks } from \"./LibraryTasks\";\nimport { DataManagementTasks } from \"./DataManagementTasks\";\nimport { PluginTasks } from \"./PluginTasks\";\nimport { JobTable } from \"./JobTable\";\n\nexport const SettingsTasksPanel: React.FC = () => {\n  const intl = useIntl();\n  const [isBackupRunning, setIsBackupRunning] = useState<boolean>(false);\n  const [isAnonymiseRunning, setIsAnonymiseRunning] = useState<boolean>(false);\n\n  if (isBackupRunning) {\n    return (\n      <LoadingIndicator\n        message={intl.formatMessage({ id: \"config.tasks.backing_up_database\" })}\n      />\n    );\n  }\n\n  if (isAnonymiseRunning) {\n    return (\n      <LoadingIndicator\n        message={intl.formatMessage({\n          id: \"config.tasks.anonymising_database\",\n        })}\n      />\n    );\n  }\n\n  return (\n    <div id=\"tasks-panel\">\n      <div className=\"tasks-panel-queue\">\n        <h1>{intl.formatMessage({ id: \"config.tasks.job_queue\" })}</h1>\n        <JobTable />\n      </div>\n\n      <div className=\"tasks-panel-tasks\">\n        <LibraryTasks />\n        <hr />\n        <DataManagementTasks\n          setIsBackupRunning={setIsBackupRunning}\n          setIsAnonymiseRunning={setIsAnonymiseRunning}\n        />\n        <hr />\n        <PluginTasks />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/context.tsx",
    "content": "import { ApolloError } from \"@apollo/client/errors\";\nimport {\n  faCheckCircle,\n  faTimesCircle,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState, useEffect, useCallback, useRef } from \"react\";\nimport { Spinner } from \"react-bootstrap\";\nimport { IUIConfig } from \"src/core/config\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useConfiguration,\n  useConfigureDefaults,\n  useConfigureDLNA,\n  useConfigureGeneral,\n  useConfigureInterface,\n  useConfigurePlugin,\n  useConfigureScraping,\n  useConfigureUI,\n} from \"src/core/StashService\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { withoutTypename } from \"src/utils/data\";\nimport { Icon } from \"../Shared/Icon\";\n\ntype PluginConfigs = Record<string, Record<string, unknown>>;\n\nexport interface ISettingsContextState {\n  loading: boolean;\n  error: ApolloError | undefined;\n  general: GQL.ConfigGeneralInput;\n  interface: GQL.ConfigInterfaceInput;\n  defaults: GQL.ConfigDefaultSettingsInput;\n  scraping: GQL.ConfigScrapingInput;\n  dlna: GQL.ConfigDlnaInput;\n  ui: IUIConfig;\n  plugins: PluginConfigs;\n\n  advancedMode: boolean;\n\n  // apikey isn't directly settable, so expose it here\n  apiKey: string;\n\n  saveGeneral: (input: Partial<GQL.ConfigGeneralInput>) => void;\n  saveInterface: (input: Partial<GQL.ConfigInterfaceInput>) => void;\n  saveDefaults: (input: Partial<GQL.ConfigDefaultSettingsInput>) => void;\n  saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void;\n  saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void;\n  saveUI: (input: Partial<IUIConfig>) => void;\n  savePluginSettings: (pluginID: string, input: {}) => void;\n  setAdvancedMode: (value: boolean) => void;\n\n  refetch: () => void;\n}\n\nfunction noop() {}\n\nconst emptyState: ISettingsContextState = {\n  loading: false,\n  error: undefined,\n  general: {},\n  interface: {},\n  defaults: {},\n  scraping: {},\n  dlna: {},\n  ui: {},\n  plugins: {},\n\n  advancedMode: false,\n\n  apiKey: \"\",\n\n  saveGeneral: noop,\n  saveInterface: noop,\n  saveDefaults: noop,\n  saveScraping: noop,\n  saveDLNA: noop,\n  saveUI: noop,\n  savePluginSettings: noop,\n  setAdvancedMode: noop,\n\n  refetch: noop,\n};\n\nexport const SettingStateContext =\n  React.createContext<ISettingsContextState | null>(null);\n\nexport const useSettings = () => {\n  const context = React.useContext(SettingStateContext);\n\n  if (context === null) {\n    throw new Error(\"useSettings must be used within a SettingsContext\");\n  }\n\n  return context;\n};\n\nexport function useSettingsOptional(): ISettingsContextState {\n  const context = React.useContext(SettingStateContext);\n\n  if (context === null) {\n    return emptyState;\n  }\n\n  return context;\n}\n\nexport const SettingsContext: React.FC = ({ children }) => {\n  const Toast = useToast();\n\n  const { data, error, loading, refetch } = useConfiguration();\n  const initialRef = useRef(false);\n\n  const [general, setGeneral] = useState<GQL.ConfigGeneralInput>({});\n  const [pendingGeneral, setPendingGeneral] =\n    useState<GQL.ConfigGeneralInput>();\n  const [updateGeneralConfig] = useConfigureGeneral();\n\n  const [iface, setIface] = useState<GQL.ConfigInterfaceInput>({});\n  const [pendingInterface, setPendingInterface] =\n    useState<GQL.ConfigInterfaceInput>();\n  const [updateInterfaceConfig] = useConfigureInterface();\n\n  const [defaults, setDefaults] = useState<GQL.ConfigDefaultSettingsInput>({});\n  const [pendingDefaults, setPendingDefaults] =\n    useState<GQL.ConfigDefaultSettingsInput>();\n  const [updateDefaultsConfig] = useConfigureDefaults();\n\n  const [scraping, setScraping] = useState<GQL.ConfigScrapingInput>({});\n  const [pendingScraping, setPendingScraping] =\n    useState<GQL.ConfigScrapingInput>();\n  const [updateScrapingConfig] = useConfigureScraping();\n\n  const [dlna, setDLNA] = useState<GQL.ConfigDlnaInput>({});\n  const [pendingDLNA, setPendingDLNA] = useState<GQL.ConfigDlnaInput>();\n  const [updateDLNAConfig] = useConfigureDLNA();\n\n  const [ui, setUI] = useState<IUIConfig>({});\n  const [pendingUI, setPendingUI] = useState<{}>();\n  const [updateUIConfig] = useConfigureUI();\n\n  const [plugins, setPlugins] = useState<PluginConfigs>({});\n  const [pendingPlugins, setPendingPlugins] = useState<PluginConfigs>();\n  const [updatePluginConfig] = useConfigurePlugin();\n\n  const [updateSuccess, setUpdateSuccess] = useState<boolean>();\n\n  const [apiKey, setApiKey] = useState(\"\");\n\n  useEffect(() => {\n    if (!data?.configuration || error) return;\n\n    // always set api key\n    setApiKey(data.configuration.general.apiKey);\n\n    // only initialise once - assume we have control over these settings and\n    // they aren't modified elsewhere\n    if (initialRef.current) return;\n    initialRef.current = true;\n\n    setGeneral({ ...withoutTypename(data.configuration.general) });\n    setIface({ ...withoutTypename(data.configuration.interface) });\n    setDefaults({ ...withoutTypename(data.configuration.defaults) });\n    setScraping({ ...withoutTypename(data.configuration.scraping) });\n    setDLNA({ ...withoutTypename(data.configuration.dlna) });\n    setUI(data.configuration.ui);\n    setPlugins(data.configuration.plugins);\n  }, [data, error]);\n\n  const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000);\n\n  const onSuccess = useCallback(() => {\n    setUpdateSuccess(true);\n    resetSuccess();\n  }, [resetSuccess]);\n\n  const onError = useCallback(\n    (err) => {\n      Toast.error(err);\n      setUpdateSuccess(false);\n    },\n    [Toast]\n  );\n\n  // saves the configuration if no further changes are made after a half second\n  const saveGeneralConfig = useDebounce(\n    async (input: GQL.ConfigGeneralInput) => {\n      try {\n        setUpdateSuccess(undefined);\n        await updateGeneralConfig({\n          variables: {\n            input,\n          },\n        });\n\n        setPendingGeneral(undefined);\n        onSuccess();\n      } catch (e) {\n        onError(e);\n      }\n    },\n    500\n  );\n\n  useEffect(() => {\n    if (!pendingGeneral) {\n      return;\n    }\n\n    saveGeneralConfig(pendingGeneral);\n  }, [pendingGeneral, saveGeneralConfig]);\n\n  function saveGeneral(input: Partial<GQL.ConfigGeneralInput>) {\n    if (!general) {\n      return;\n    }\n\n    setGeneral({\n      ...general,\n      ...input,\n    });\n\n    setPendingGeneral((current) => {\n      if (!current) {\n        return input;\n      }\n      return {\n        ...current,\n        ...input,\n      };\n    });\n  }\n\n  // saves the configuration if no further changes are made after a half second\n  const saveInterfaceConfig = useDebounce(\n    async (input: GQL.ConfigInterfaceInput) => {\n      try {\n        setUpdateSuccess(undefined);\n        await updateInterfaceConfig({\n          variables: {\n            input,\n          },\n        });\n\n        setPendingInterface(undefined);\n        onSuccess();\n      } catch (e) {\n        onError(e);\n      }\n    },\n    500\n  );\n\n  useEffect(() => {\n    if (!pendingInterface) {\n      return;\n    }\n\n    saveInterfaceConfig(pendingInterface);\n  }, [pendingInterface, saveInterfaceConfig]);\n\n  function saveInterface(input: Partial<GQL.ConfigInterfaceInput>) {\n    if (!iface) {\n      return;\n    }\n\n    setIface({\n      ...iface,\n      ...input,\n    });\n\n    setPendingInterface((current) => {\n      if (!current) {\n        return input;\n      }\n      return {\n        ...current,\n        ...input,\n      };\n    });\n  }\n\n  // saves the configuration if no further changes are made after a half second\n  const saveDefaultsConfig = useDebounce(\n    async (input: GQL.ConfigDefaultSettingsInput) => {\n      try {\n        setUpdateSuccess(undefined);\n        await updateDefaultsConfig({\n          variables: {\n            input,\n          },\n        });\n\n        setPendingDefaults(undefined);\n        onSuccess();\n      } catch (e) {\n        onError(e);\n      }\n    },\n    500\n  );\n\n  useEffect(() => {\n    if (!pendingDefaults) {\n      return;\n    }\n\n    saveDefaultsConfig(pendingDefaults);\n  }, [pendingDefaults, saveDefaultsConfig]);\n\n  function saveDefaults(input: Partial<GQL.ConfigDefaultSettingsInput>) {\n    if (!defaults) {\n      return;\n    }\n\n    setDefaults({\n      ...defaults,\n      ...input,\n    });\n\n    setPendingDefaults((current) => {\n      if (!current) {\n        return input;\n      }\n      return {\n        ...current,\n        ...input,\n      };\n    });\n  }\n\n  // saves the configuration if no further changes are made after a half second\n  const saveScrapingConfig = useDebounce(\n    async (input: GQL.ConfigScrapingInput) => {\n      try {\n        setUpdateSuccess(undefined);\n        await updateScrapingConfig({\n          variables: {\n            input,\n          },\n        });\n\n        setPendingScraping(undefined);\n        onSuccess();\n      } catch (e) {\n        onError(e);\n      }\n    },\n    500\n  );\n\n  useEffect(() => {\n    if (!pendingScraping) {\n      return;\n    }\n\n    saveScrapingConfig(pendingScraping);\n  }, [pendingScraping, saveScrapingConfig]);\n\n  function saveScraping(input: Partial<GQL.ConfigScrapingInput>) {\n    if (!scraping) {\n      return;\n    }\n\n    setScraping({\n      ...scraping,\n      ...input,\n    });\n\n    setPendingScraping((current) => {\n      if (!current) {\n        return input;\n      }\n      return {\n        ...current,\n        ...input,\n      };\n    });\n  }\n\n  // saves the configuration if no further changes are made after a half second\n  const saveDLNAConfig = useDebounce(async (input: GQL.ConfigDlnaInput) => {\n    try {\n      setUpdateSuccess(undefined);\n      await updateDLNAConfig({\n        variables: {\n          input,\n        },\n      });\n\n      setPendingDLNA(undefined);\n      onSuccess();\n    } catch (e) {\n      onError(e);\n    }\n  }, 500);\n\n  useEffect(() => {\n    if (!pendingDLNA) {\n      return;\n    }\n\n    saveDLNAConfig(pendingDLNA);\n  }, [pendingDLNA, saveDLNAConfig]);\n\n  function saveDLNA(input: Partial<GQL.ConfigDlnaInput>) {\n    if (!dlna) {\n      return;\n    }\n\n    setDLNA({\n      ...dlna,\n      ...input,\n    });\n\n    setPendingDLNA((current) => {\n      if (!current) {\n        return input;\n      }\n      return {\n        ...current,\n        ...input,\n      };\n    });\n  }\n\n  type UIConfigInput = GQL.Scalars[\"Map\"][\"input\"];\n\n  // saves the configuration if no further changes are made after a half second\n  const saveUIConfig = useDebounce(async (input: Partial<IUIConfig>) => {\n    try {\n      setUpdateSuccess(undefined);\n      await updateUIConfig({\n        variables: {\n          partial: input as UIConfigInput,\n        },\n      });\n\n      setPendingUI(undefined);\n      onSuccess();\n    } catch (e) {\n      onError(e);\n    }\n  }, 500);\n\n  useEffect(() => {\n    if (!pendingUI) {\n      return;\n    }\n\n    saveUIConfig(pendingUI);\n  }, [pendingUI, saveUIConfig]);\n\n  function saveUI(input: IUIConfig) {\n    if (!ui) {\n      return;\n    }\n\n    setUI({\n      ...ui,\n      ...input,\n    });\n\n    setPendingUI((current) => {\n      return {\n        ...current,\n        ...input,\n      };\n    });\n  }\n\n  function setAdvancedMode(value: boolean) {\n    saveUI({\n      advancedMode: value,\n    });\n  }\n\n  // saves the configuration if no further changes are made after a half second\n  const savePluginConfig = useDebounce(async (input: PluginConfigs) => {\n    try {\n      setUpdateSuccess(undefined);\n\n      for (const pluginID in input) {\n        await updatePluginConfig({\n          variables: {\n            plugin_id: pluginID,\n            input: input[pluginID],\n          },\n        });\n      }\n\n      setPendingPlugins(undefined);\n      onSuccess();\n    } catch (e) {\n      onError(e);\n    }\n  }, 500);\n\n  useEffect(() => {\n    if (!pendingPlugins) {\n      return;\n    }\n\n    savePluginConfig(pendingPlugins);\n  }, [pendingPlugins, savePluginConfig]);\n\n  function savePluginSettings(\n    pluginID: string,\n    input: Record<string, unknown>\n  ) {\n    if (!plugins) {\n      return;\n    }\n\n    setPlugins({\n      ...plugins,\n      [pluginID]: input,\n    });\n\n    setPendingPlugins((current) => {\n      if (!current) {\n        // use full UI object to ensure nothing is wiped\n        return {\n          ...plugins,\n          [pluginID]: input,\n        };\n      }\n      return {\n        ...current,\n        [pluginID]: input,\n      };\n    });\n  }\n\n  function maybeRenderLoadingIndicator() {\n    if (updateSuccess === false) {\n      return (\n        <div className=\"loading-indicator failed\">\n          <Icon icon={faTimesCircle} className=\"fa-fw\" />\n        </div>\n      );\n    }\n\n    if (\n      pendingGeneral ||\n      pendingInterface ||\n      pendingDefaults ||\n      pendingScraping ||\n      pendingDLNA ||\n      pendingUI ||\n      pendingPlugins\n    ) {\n      return (\n        <div className=\"loading-indicator\">\n          <Spinner animation=\"border\" role=\"status\">\n            <span className=\"sr-only\">Loading...</span>\n          </Spinner>\n        </div>\n      );\n    }\n\n    if (updateSuccess) {\n      return (\n        <div className=\"loading-indicator success\">\n          <Icon icon={faCheckCircle} className=\"fa-fw\" />\n        </div>\n      );\n    }\n  }\n\n  return (\n    <SettingStateContext.Provider\n      value={{\n        loading,\n        error,\n        apiKey,\n        general,\n        interface: iface,\n        defaults,\n        scraping,\n        dlna,\n        ui,\n        plugins,\n        advancedMode: ui.advancedMode ?? false,\n        saveGeneral,\n        saveInterface,\n        saveDefaults,\n        saveScraping,\n        saveDLNA,\n        saveUI,\n        refetch,\n        savePluginSettings,\n        setAdvancedMode,\n      }}\n    >\n      {maybeRenderLoadingIndicator()}\n      {children}\n    </SettingStateContext.Provider>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Settings/styles.scss",
    "content": "@include media-breakpoint-up(sm) {\n  #settings-menu-container {\n    position: fixed;\n  }\n}\n\n#settings-container .tab-content {\n  max-width: 780px;\n}\n\n.setting-section {\n  &:not(:first-child) {\n    margin-top: 1.5em;\n  }\n\n  .card {\n    padding: 0;\n  }\n\n  h1 {\n    font-size: 2rem;\n  }\n\n  .sub-heading {\n    font-size: 0.8rem;\n    margin-top: 0.5rem;\n  }\n\n  .content {\n    padding: 15px;\n    width: 100%;\n  }\n\n  .setting {\n    align-items: center;\n    display: flex;\n    justify-content: space-between;\n    padding: 15px;\n    width: 100%;\n\n    &.sub-setting {\n      padding-left: 2rem;\n    }\n\n    h3 {\n      font-size: 1.25rem;\n      margin-bottom: 0;\n\n      &[title] {\n        cursor: help;\n        text-decoration: underline dotted;\n      }\n    }\n\n    &.disabled {\n      .custom-switch,\n      h3 {\n        opacity: 0.5;\n      }\n    }\n\n    > div:first-child {\n      flex-grow: 0;\n    }\n\n    > div:last-child {\n      min-width: 100px;\n      text-align: right;\n\n      .btn {\n        margin: 0.25rem;\n      }\n    }\n\n    &:not(:last-child) {\n      border-bottom: 1px solid #000;\n    }\n\n    .value {\n      font-family: \"Courier New\", Courier, monospace;\n      margin-bottom: 0.5rem;\n      margin-top: 0.5rem;\n      overflow-wrap: anywhere;\n\n      pre {\n        max-height: 250px;\n        width: 100%;\n      }\n    }\n  }\n\n  .setting-group {\n    &.collapsible > .setting {\n      cursor: pointer;\n    }\n\n    padding-bottom: 15px;\n    width: 100%;\n\n    .setting-group-collapse-button {\n      color: $text-muted;\n      font-size: 1.5rem;\n      padding: 0;\n    }\n\n    &:not(:last-child) {\n      border-bottom: 1px solid #000;\n    }\n\n    > .setting:first-child {\n      border-bottom: none;\n      padding-bottom: 0;\n    }\n\n    > .setting:not(:first-child),\n    .collapsible-section .setting {\n      margin-left: 2.5rem;\n      margin-right: 1.5rem;\n      padding-bottom: 10px;\n      padding-left: 0;\n      padding-top: 10px;\n\n      h3 {\n        font-size: 1rem;\n      }\n\n      &.sub-setting {\n        padding-left: 2rem;\n      }\n    }\n\n    .setting {\n      flex-wrap: wrap;\n      width: auto;\n\n      & > div:last-child {\n        margin-left: auto;\n      }\n    }\n  }\n}\n\n#stashes .card {\n  // override overflow so that menu shows correctly\n  overflow: visible;\n}\n\n#stash-table {\n  @include media-breakpoint-down(sm) {\n    padding-top: 0;\n  }\n\n  .setting {\n    justify-content: start;\n    padding: 0;\n  }\n\n  .stash-row .setting > div:last-child {\n    text-align: left;\n  }\n}\n\n#tasks-panel {\n  @media (min-width: 576px) and (min-height: 600px) {\n    .tasks-panel-queue {\n      background-color: #202b33;\n      margin-top: -1rem;\n      padding-bottom: 0.25rem;\n      padding-top: 1rem;\n      position: sticky;\n      top: 3rem;\n      z-index: 2;\n    }\n  }\n\n  h1 {\n    font-size: 2rem;\n  }\n}\n\n#setting-dialog .sub-heading {\n  font-size: 0.8rem;\n  margin-top: 0.5rem;\n}\n\n.logs {\n  font-family: source-code-pro, Menlo, Monaco, Consolas, \"Courier New\",\n    monospace;\n  font-size: smaller;\n  max-height: 100vh;\n  overflow-x: hidden;\n  overflow-y: auto;\n  padding-top: 1rem;\n  white-space: pre-wrap;\n\n  .debug {\n    color: lightgreen;\n    font-weight: bold;\n  }\n\n  .info {\n    color: white;\n    font-weight: bold;\n  }\n\n  .warning {\n    color: orange;\n    font-weight: bold;\n  }\n\n  .error {\n    color: red;\n    font-weight: bold;\n  }\n}\n\n.log-time {\n  margin-right: 1rem;\n}\n\n#configuration-tabs-tabpane-about .table {\n  width: initial;\n}\n\n#configuration-tabs-tabpane-tasks h5 {\n  margin-bottom: 1em;\n}\n\n.scraper-table {\n  display: block;\n  margin-bottom: 16px;\n  max-height: 300px;\n  overflow: auto;\n  width: 100%;\n\n  tr {\n    border-top: 1px solid #181513;\n\n    &:nth-child(2n) {\n      background-color: #2c3b47;\n    }\n  }\n\n  th,\n  td {\n    border: 1px solid #181513;\n    padding: 6px 13px;\n  }\n\n  ul {\n    margin-bottom: 0;\n    max-height: 100px;\n    overflow: auto;\n    padding-left: 0;\n  }\n\n  li {\n    list-style: none;\n  }\n}\n\n.scraper-toolbar {\n  display: flex;\n  justify-content: space-between;\n}\n\n.job-table.card {\n  background-color: $card-bg;\n  height: 10em;\n  margin-bottom: 30px;\n  overflow-y: auto;\n  padding: 0.5rem 15px;\n\n  ul {\n    list-style: none;\n    padding-inline-start: 0;\n  }\n\n  li {\n    opacity: 0;\n    transition: opacity 0.25s;\n\n    &.fade-in {\n      opacity: 1;\n    }\n\n    > div {\n      align-items: flex-start;\n      display: flex;\n    }\n  }\n\n  .job-status {\n    width: 100%;\n  }\n\n  .job-description {\n    display: flex;\n    justify-content: space-between;\n  }\n\n  .stop:not(:disabled),\n  .stopping .fa-icon,\n  .cancelled .fa-icon {\n    color: $danger;\n  }\n\n  .running .fa-icon,\n  .finished .fa-icon {\n    color: $success;\n  }\n\n  .failed .fa-icon {\n    color: $danger;\n  }\n\n  .ready .fa-icon {\n    color: $warning;\n  }\n\n  .cancelled,\n  .finished {\n    color: $text-muted;\n  }\n\n  .job-error {\n    color: $danger;\n  }\n}\n\n#temp-enable-duration .duration-control:disabled {\n  opacity: 0.5;\n}\n\n#settings-dlna {\n  .ip-whitelist-input,\n  .interfaces-input {\n    width: 12em;\n  }\n\n  .server-name {\n    width: 24em;\n  }\n\n  .addresses {\n    list-style-type: none;\n    padding-inline-start: 0;\n\n    li {\n      display: flex;\n      margin-bottom: 0.5rem;\n\n      .address {\n        display: inline-block;\n        width: 12em;\n      }\n\n      .buttons {\n        align-items: center;\n        display: flex;\n      }\n\n      .deadline {\n        font-size: 0.8em;\n      }\n\n      code {\n        display: inline-block;\n      }\n    }\n  }\n}\n\n.task-group {\n  padding-top: 0.5rem;\n\n  &:not(:last-child) {\n    padding-bottom: 0.5rem;\n  }\n\n  .task {\n    &:not(:first-child) {\n      padding-top: 0.5rem;\n    }\n\n    &:not(:last-child) {\n      padding-bottom: 1rem;\n    }\n  }\n}\n\n.loading-indicator {\n  opacity: 50%;\n  position: fixed;\n  right: 30px;\n  z-index: 1051;\n\n  @include media-breakpoint-down(xs) {\n    top: 30px;\n  }\n  @include media-breakpoint-up(sm) {\n    bottom: 30px;\n  }\n\n  .fa-icon {\n    color: $success;\n    height: 2rem;\n    margin: 0;\n    width: 2rem;\n  }\n\n  &.success .fa-icon {\n    animation: fadeOut 2s forwards;\n    animation-delay: 2s;\n  }\n\n  &.failed .fa-icon {\n    color: $danger;\n  }\n}\n\n@keyframes fadeOut {\n  from {\n    opacity: 1;\n  }\n\n  to {\n    opacity: 0;\n  }\n}\n\n.empty-queue-message {\n  color: $text-muted;\n}\n\n.advanced-switch {\n  display: flex;\n  justify-content: space-between;\n  padding: 0.5rem 1rem;\n\n  .form-label {\n    color: $text-muted;\n    margin-right: 0.5rem;\n  }\n\n  .custom-switch {\n    display: inline-block;\n  }\n}\n\n.troubleshooting-mode-button {\n  bottom: 1rem;\n  left: 1rem;\n  position: fixed;\n  z-index: 100;\n\n  @include media-breakpoint-down(xs) {\n    padding-left: 0.5rem;\n    position: static;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/SettingsButton.tsx",
    "content": "import { FontAwesomeIcon } from \"@fortawesome/react-fontawesome\";\nimport React, { useEffect, useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { useJobQueue, useJobsSubscribe } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useIntl } from \"react-intl\";\nimport { faCog } from \"@fortawesome/free-solid-svg-icons\";\n\ntype JobFragment = Pick<\n  GQL.Job,\n  \"id\" | \"status\" | \"subTasks\" | \"description\" | \"progress\"\n>;\n\nexport const SettingsButton: React.FC = () => {\n  const intl = useIntl();\n  const jobStatus = useJobQueue();\n  const jobsSubscribe = useJobsSubscribe();\n\n  const [queue, setQueue] = useState<JobFragment[]>([]);\n\n  useEffect(() => {\n    setQueue(jobStatus.data?.jobQueue ?? []);\n  }, [jobStatus]);\n\n  useEffect(() => {\n    if (!jobsSubscribe.data) {\n      return;\n    }\n\n    const event = jobsSubscribe.data.jobsSubscribe;\n\n    function updateJob() {\n      setQueue((q) =>\n        q.map((j) => {\n          if (j.id === event.job.id) {\n            return event.job;\n          }\n\n          return j;\n        })\n      );\n    }\n\n    switch (event.type) {\n      case GQL.JobStatusUpdateType.Add:\n        // add to the end of the queue\n        setQueue((q) => q.concat([event.job]));\n        break;\n      case GQL.JobStatusUpdateType.Remove:\n        setQueue((q) => q.filter((j) => j.id !== event.job.id));\n        break;\n      case GQL.JobStatusUpdateType.Update:\n        updateJob();\n        break;\n    }\n  }, [jobsSubscribe.data]);\n\n  return (\n    <Button\n      className=\"minimal d-flex align-items-center h-100\"\n      title={intl.formatMessage({ id: \"settings\" })}\n    >\n      <FontAwesomeIcon icon={faCog} spin={queue.length > 0} />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Setup/Migrate.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Button, Card, Container, Form, ProgressBar } from \"react-bootstrap\";\nimport { useIntl, FormattedMessage } from \"react-intl\";\nimport { useHistory } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useSystemStatus,\n  mutateMigrate,\n  postMigrate,\n  refetchSystemStatus,\n} from \"src/core/StashService\";\nimport { migrationNotes } from \"src/docs/en/MigrationNotes\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { MarkdownPage } from \"../Shared/MarkdownPage\";\nimport { JobFragment, useMonitorJob } from \"src/utils/job\";\n\nexport const Migrate: React.FC = () => {\n  const intl = useIntl();\n  const history = useHistory();\n\n  const { data: systemStatus, loading } = useSystemStatus();\n\n  const [backupPath, setBackupPath] = useState<string | undefined>();\n  const [migrateLoading, setMigrateLoading] = useState(false);\n  const [migrateError, setMigrateError] = useState(\"\");\n\n  const [jobID, setJobID] = useState<string | undefined>();\n\n  function onJobFinished(finishedJob?: JobFragment) {\n    setJobID(undefined);\n    setMigrateLoading(false);\n\n    if (finishedJob?.error) {\n      setMigrateError(finishedJob.error);\n    } else {\n      postMigrate();\n      // refetch the system status so that the we get redirected\n      refetchSystemStatus();\n    }\n  }\n\n  const { job } = useMonitorJob(jobID, onJobFinished);\n\n  // if database path includes path separators, then this is passed through\n  // to the migration path. Extract the base name of the database file.\n  const databasePath = systemStatus\n    ? systemStatus?.systemStatus.databasePath?.split(/[\\\\/]/).pop()\n    : \"\";\n\n  // make suffix based on current time\n  const now = new Date()\n    .toISOString()\n    .replace(/T/g, \"_\")\n    .replace(/-/g, \"\")\n    .replace(/:/g, \"\")\n    .replace(/\\..*/, \"\");\n  const defaultBackupPath = systemStatus\n    ? `${databasePath}.${systemStatus.systemStatus.databaseSchema}.${now}`\n    : \"\";\n\n  const discordLink = (\n    <ExternalLink href=\"https://discord.gg/2TsNFKt\">Discord</ExternalLink>\n  );\n  const githubLink = (\n    <ExternalLink href=\"https://github.com/stashapp/stash/issues\">\n      <FormattedMessage id=\"setup.github_repository\" />\n    </ExternalLink>\n  );\n\n  useEffect(() => {\n    if (backupPath === undefined && defaultBackupPath) {\n      setBackupPath(defaultBackupPath);\n    }\n  }, [defaultBackupPath, backupPath]);\n\n  const status = systemStatus?.systemStatus;\n\n  const maybeMigrationNotes = useMemo(() => {\n    if (\n      !status ||\n      status.databaseSchema === undefined ||\n      status.databaseSchema === null ||\n      status.appSchema === undefined ||\n      status.appSchema === null\n    )\n      return;\n\n    const notes = [];\n    for (let i = status.databaseSchema + 1; i <= status.appSchema; ++i) {\n      const note = migrationNotes[i];\n      if (note) {\n        notes.push(note);\n      }\n    }\n\n    if (notes.length === 0) return;\n\n    return (\n      <div className=\"migration-notes\">\n        <h2>\n          <FormattedMessage id=\"setup.migrate.migration_notes\" />\n        </h2>\n        <div>\n          {notes.map((n, i) => (\n            <div key={i}>\n              <MarkdownPage page={n} />\n            </div>\n          ))}\n        </div>\n      </div>\n    );\n  }, [status]);\n\n  // only display setup wizard if system is not setup\n  if (loading || !systemStatus || !status) {\n    return <LoadingIndicator />;\n  }\n\n  if (migrateLoading) {\n    const progress =\n      job && job.progress !== undefined && job.progress !== null\n        ? job.progress * 100\n        : undefined;\n\n    return (\n      <div className=\"migrate-loading-status\">\n        <h4>\n          <LoadingIndicator inline small message=\"\" />\n          <span>\n            <FormattedMessage id=\"setup.migrate.migrating_database\" />\n          </span>\n        </h4>\n        {progress !== undefined && (\n          <ProgressBar\n            animated\n            now={progress}\n            label={`${progress.toFixed(0)}%`}\n          />\n        )}\n        {job?.subTasks?.map((subTask, i) => (\n          <div key={i}>\n            <p>{subTask}</p>\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  if (\n    systemStatus.systemStatus.status !== GQL.SystemStatusEnum.NeedsMigration\n  ) {\n    // redirect to main page\n    history.replace(\"/\");\n    return <LoadingIndicator />;\n  }\n\n  async function onMigrate() {\n    try {\n      setMigrateLoading(true);\n      setMigrateError(\"\");\n\n      // migrate now uses the job manager\n      const ret = await mutateMigrate({\n        backupPath: backupPath ?? \"\",\n      });\n\n      setJobID(ret.data?.migrate);\n    } catch (e) {\n      if (e instanceof Error) setMigrateError(e.message ?? e.toString());\n      setMigrateLoading(false);\n    }\n  }\n\n  function maybeRenderError() {\n    if (!migrateError) {\n      return;\n    }\n\n    return (\n      <section>\n        <h2 className=\"text-danger\">\n          <FormattedMessage id=\"setup.migrate.migration_failed\" />\n        </h2>\n\n        <p>\n          <FormattedMessage id=\"setup.migrate.migration_failed_error\" />\n        </p>\n\n        <Card>\n          <pre>{migrateError}</pre>\n        </Card>\n\n        <p>\n          <FormattedMessage\n            id=\"setup.migrate.migration_failed_help\"\n            values={{ discordLink, githubLink }}\n          />\n        </p>\n      </section>\n    );\n  }\n\n  return (\n    <Container>\n      <h1 className=\"text-center mb-3\">\n        <FormattedMessage id=\"setup.migrate.migration_required\" />\n      </h1>\n      <Card>\n        <section>\n          <p>\n            <FormattedMessage\n              id=\"setup.migrate.schema_too_old\"\n              values={{\n                databaseSchema: <strong>{status.databaseSchema}</strong>,\n                appSchema: <strong>{status.appSchema}</strong>,\n                strong: (chunks: string) => <strong>{chunks}</strong>,\n                code: (chunks: string) => <code>{chunks}</code>,\n              }}\n            />\n          </p>\n\n          <p className=\"lead text-center my-5\">\n            <FormattedMessage id=\"setup.migrate.migration_irreversible_warning\" />\n          </p>\n\n          <p>\n            <FormattedMessage\n              id=\"setup.migrate.backup_recommended\"\n              values={{\n                defaultBackupPath,\n                code: (chunks: string) => <code>{chunks}</code>,\n              }}\n            />\n          </p>\n        </section>\n\n        {maybeMigrationNotes}\n\n        <section>\n          <Form.Group id=\"migrate\">\n            <Form.Label>\n              <FormattedMessage id=\"setup.migrate.backup_database_path_leave_empty_to_disable_backup\" />\n            </Form.Label>\n            <Form.Control\n              className=\"text-input\"\n              name=\"backupPath\"\n              defaultValue={backupPath}\n              placeholder={intl.formatMessage({\n                id: \"setup.paths.database_filename_empty_for_default\",\n              })}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                setBackupPath(e.currentTarget.value)\n              }\n            />\n          </Form.Group>\n        </section>\n\n        <section>\n          <div className=\"d-flex justify-content-center\">\n            <Button variant=\"primary mx-2 p-5\" onClick={() => onMigrate()}>\n              <FormattedMessage id=\"setup.migrate.perform_schema_migration\" />\n            </Button>\n          </div>\n        </section>\n\n        {maybeRenderError()}\n      </Card>\n    </Container>\n  );\n};\n\nexport default Migrate;\n"
  },
  {
    "path": "ui/v2.5/src/components/Setup/Setup.tsx",
    "content": "import React, { useState, useCallback } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport {\n  Alert,\n  Button,\n  Card,\n  Container,\n  Form,\n  InputGroup,\n} from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  mutateSetup,\n  useConfigureUI,\n  useSystemStatus,\n} from \"src/core/StashService\";\nimport { useHistory } from \"react-router-dom\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport StashConfiguration from \"../Settings/StashConfiguration\";\nimport { Icon } from \"../Shared/Icon\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { FolderSelectDialog } from \"../Shared/FolderSelect/FolderSelectDialog\";\nimport {\n  faEllipsisH,\n  faExclamationTriangle,\n  faQuestionCircle,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { releaseNotes } from \"src/docs/en/ReleaseNotes\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\n\ninterface ISetupContextState {\n  configuration: GQL.ConfigDataFragment;\n  systemStatus: GQL.SystemStatusQuery;\n\n  setupState: Partial<GQL.SetupInput>;\n  setupError: string | undefined;\n\n  pathJoin: (...paths: string[]) => string;\n  pathDir(path: string): string;\n\n  homeDir: string;\n  windows: boolean;\n  macApp: boolean;\n  homeDirPath: string;\n  pwd: string;\n  workingDir: string;\n}\n\nconst SetupStateContext = React.createContext<ISetupContextState | null>(null);\n\nconst useSetupContext = () => {\n  const context = React.useContext(SetupStateContext);\n\n  if (context === null) {\n    throw new Error(\"useSettings must be used within a SettingsContext\");\n  }\n\n  return context;\n};\n\nconst SetupContext: React.FC<{\n  setupState: Partial<GQL.SetupInput>;\n  setupError: string | undefined;\n  systemStatus: GQL.SystemStatusQuery;\n  configuration: GQL.ConfigDataFragment;\n}> = ({ setupState, setupError, systemStatus, configuration, children }) => {\n  const status = systemStatus?.systemStatus;\n\n  const windows = status?.os === \"windows\";\n  const pathSep = windows ? \"\\\\\" : \"/\";\n  const homeDir = windows ? \"%USERPROFILE%\" : \"$HOME\";\n  const pwd = windows ? \"%CD%\" : \"$PWD\";\n\n  const pathJoin = useCallback(\n    (...paths: string[]) => {\n      return paths.join(pathSep);\n    },\n    [pathSep]\n  );\n\n  // simply returns everything preceding the last path separator\n  function pathDir(path: string) {\n    const lastSep = path.lastIndexOf(pathSep);\n    if (lastSep === -1) return \"\";\n    return path.slice(0, lastSep);\n  }\n\n  const workingDir = status?.workingDir ?? \".\";\n\n  // When running Stash.app, the working directory is (usually) set to /.\n  // Assume that the user doesn't want to set up in / (it's usually mounted read-only anyway),\n  // so in this situation disallow setting up in the working directory.\n  const macApp = status?.os === \"darwin\" && workingDir === \"/\";\n\n  const homeDirPath = pathJoin(status?.homeDir ?? homeDir, \".stash\");\n\n  const state: ISetupContextState = {\n    systemStatus,\n    configuration,\n    windows,\n    macApp,\n    pathJoin,\n    pathDir,\n    homeDir,\n    homeDirPath,\n    pwd,\n    workingDir,\n    setupState,\n    setupError,\n  };\n\n  return (\n    <SetupStateContext.Provider value={state}>\n      {children}\n    </SetupStateContext.Provider>\n  );\n};\n\ninterface IWizardStep {\n  next: (input?: Partial<GQL.SetupInput>) => void;\n  goBack: () => void;\n}\n\nconst WelcomeSpecificConfig: React.FC<IWizardStep> = ({ next }) => {\n  const { systemStatus } = useSetupContext();\n  const status = systemStatus?.systemStatus;\n  const overrideConfig = status?.configPath;\n\n  function onNext() {\n    next({ configLocation: overrideConfig! });\n  }\n\n  return (\n    <>\n      <section>\n        <h2 className=\"mb-5\">\n          <FormattedMessage id=\"setup.welcome_to_stash\" />\n        </h2>\n        <p className=\"lead text-center\">\n          <FormattedMessage id=\"setup.welcome_specific_config.unable_to_locate_specified_config\" />\n        </p>\n        <p>\n          <FormattedMessage\n            id=\"setup.welcome_specific_config.config_path\"\n            values={{\n              path: overrideConfig,\n              code: (chunks: string) => <code>{chunks}</code>,\n            }}\n          />\n        </p>\n        <p>\n          <FormattedMessage id=\"setup.welcome_specific_config.next_step\" />\n        </p>\n      </section>\n\n      <section className=\"mt-5\">\n        <div className=\"d-flex justify-content-center\">\n          <Button variant=\"primary mx-2 p-5\" onClick={() => onNext()}>\n            <FormattedMessage id=\"actions.next_action\" />\n          </Button>\n        </div>\n      </section>\n    </>\n  );\n};\n\nconst DefaultWelcomeStep: React.FC<IWizardStep> = ({ next }) => {\n  const { pathJoin, homeDir, macApp, homeDirPath, pwd, workingDir } =\n    useSetupContext();\n\n  const fallbackStashDir = pathJoin(homeDir, \".stash\");\n  const fallbackConfigPath = pathJoin(fallbackStashDir, \"config.yml\");\n\n  function onConfigLocationChosen(inWorkingDir: boolean) {\n    const configLocation = inWorkingDir ? \"config.yml\" : \"\";\n    next({ configLocation });\n  }\n\n  return (\n    <>\n      <section>\n        <h2 className=\"mb-5\">\n          <FormattedMessage id=\"setup.welcome_to_stash\" />\n        </h2>\n        <p className=\"lead text-center\">\n          <FormattedMessage id=\"setup.welcome.unable_to_locate_config\" />\n        </p>\n        <p>\n          <FormattedMessage\n            id=\"setup.welcome.config_path_logic_explained\"\n            values={{\n              code: (chunks: string) => <code>{chunks}</code>,\n              fallback_path: fallbackConfigPath,\n            }}\n          />\n        </p>\n        <Alert variant=\"info text-center\">\n          <FormattedMessage\n            id=\"setup.welcome.unexpected_explained\"\n            values={{\n              code: (chunks: string) => <code>{chunks}</code>,\n            }}\n          />\n        </Alert>\n        <p>\n          <FormattedMessage id=\"setup.welcome.next_step\" />\n        </p>\n      </section>\n\n      <section className=\"mt-5\">\n        <h3 className=\"text-center mb-5\">\n          <FormattedMessage id=\"setup.welcome.store_stash_config\" />\n        </h3>\n\n        <div className=\"d-flex justify-content-center\">\n          <Button\n            variant=\"secondary mx-2 p-5\"\n            onClick={() => onConfigLocationChosen(false)}\n          >\n            <FormattedMessage\n              id=\"setup.welcome.in_current_stash_directory\"\n              values={{\n                code: (chunks: string) => <code>{chunks}</code>,\n                path: fallbackStashDir,\n              }}\n            />\n            <br />\n            <code>{homeDirPath}</code>\n          </Button>\n          <Button\n            variant=\"secondary mx-2 p-5\"\n            onClick={() => onConfigLocationChosen(true)}\n            disabled={macApp}\n          >\n            {macApp ? (\n              <>\n                <FormattedMessage\n                  id=\"setup.welcome.in_the_current_working_directory_disabled\"\n                  values={{\n                    code: (chunks: string) => <code>{chunks}</code>,\n                    path: pwd,\n                  }}\n                />\n                <br />\n                <b>\n                  <FormattedMessage\n                    id=\"setup.welcome.in_the_current_working_directory_disabled_macos\"\n                    values={{\n                      code: (chunks: string) => <code>{chunks}</code>,\n                      br: () => <br />,\n                    }}\n                  />\n                </b>\n              </>\n            ) : (\n              <>\n                <FormattedMessage\n                  id=\"setup.welcome.in_the_current_working_directory\"\n                  values={{\n                    code: (chunks: string) => <code>{chunks}</code>,\n                    path: pwd,\n                  }}\n                />\n                <br />\n                <code>{workingDir}</code>\n              </>\n            )}\n          </Button>\n        </div>\n      </section>\n    </>\n  );\n};\n\nconst WelcomeStep: React.FC<IWizardStep> = (props) => {\n  const { systemStatus } = useSetupContext();\n  const status = systemStatus?.systemStatus;\n  const overrideConfig = status?.configPath;\n\n  return overrideConfig ? (\n    <WelcomeSpecificConfig {...props} />\n  ) : (\n    <DefaultWelcomeStep {...props} />\n  );\n};\n\nconst StashAlert: React.FC<{ close: (confirm: boolean) => void }> = ({\n  close,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <ModalComponent\n      show\n      icon={faExclamationTriangle}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.confirm\" }),\n        variant: \"danger\",\n        onClick: () => close(true),\n      }}\n      cancel={{ onClick: () => close(false) }}\n    >\n      <p>\n        <FormattedMessage id=\"setup.paths.stash_alert\" />\n      </p>\n    </ModalComponent>\n  );\n};\n\nconst DatabaseSection: React.FC<{\n  databaseFile: string;\n  setDatabaseFile: React.Dispatch<React.SetStateAction<string>>;\n}> = ({ databaseFile, setDatabaseFile }) => {\n  const intl = useIntl();\n\n  return (\n    <Form.Group id=\"database\">\n      <h3>\n        <FormattedMessage id=\"setup.paths.where_can_stash_store_its_database\" />\n      </h3>\n      <p>\n        <FormattedMessage\n          id=\"setup.paths.where_can_stash_store_its_database_description\"\n          values={{\n            code: (chunks: string) => <code>{chunks}</code>,\n          }}\n        />\n        <br />\n        <FormattedMessage\n          id=\"setup.paths.where_can_stash_store_its_database_warning\"\n          values={{\n            strong: (chunks: string) => <strong>{chunks}</strong>,\n          }}\n        />\n      </p>\n      <Form.Control\n        className=\"text-input\"\n        defaultValue={databaseFile}\n        placeholder={intl.formatMessage({\n          id: \"setup.paths.database_filename_empty_for_default\",\n        })}\n        onChange={(e) => setDatabaseFile(e.currentTarget.value)}\n      />\n    </Form.Group>\n  );\n};\n\nconst DirectorySelector: React.FC<{\n  value: string;\n  setValue: React.Dispatch<React.SetStateAction<string>>;\n  placeholder: string;\n  disabled?: boolean;\n}> = ({ value, setValue, placeholder, disabled = false }) => {\n  const [showSelectDialog, setShowSelectDialog] = useState(false);\n\n  function onSelectClosed(dir?: string) {\n    if (dir) {\n      setValue(dir);\n    }\n    setShowSelectDialog(false);\n  }\n\n  return (\n    <>\n      {showSelectDialog ? (\n        <FolderSelectDialog onClose={onSelectClosed} />\n      ) : null}\n      <InputGroup>\n        <Form.Control\n          className=\"text-input\"\n          value={disabled ? \"\" : value}\n          placeholder={placeholder}\n          onChange={(e) => setValue(e.currentTarget.value)}\n          disabled={disabled}\n        />\n        <InputGroup.Append>\n          <Button\n            variant=\"secondary\"\n            className=\"text-input\"\n            onClick={() => setShowSelectDialog(true)}\n            disabled={disabled}\n          >\n            <Icon icon={faEllipsisH} />\n          </Button>\n        </InputGroup.Append>\n      </InputGroup>\n    </>\n  );\n};\n\nconst GeneratedSection: React.FC<{\n  generatedLocation: string;\n  setGeneratedLocation: React.Dispatch<React.SetStateAction<string>>;\n}> = ({ generatedLocation, setGeneratedLocation }) => {\n  const intl = useIntl();\n\n  return (\n    <Form.Group id=\"generated\">\n      <h3>\n        <FormattedMessage id=\"setup.paths.where_can_stash_store_its_generated_content\" />\n      </h3>\n      <p>\n        <FormattedMessage\n          id=\"setup.paths.where_can_stash_store_its_generated_content_description\"\n          values={{\n            code: (chunks: string) => <code>{chunks}</code>,\n          }}\n        />\n      </p>\n      <DirectorySelector\n        value={generatedLocation}\n        setValue={setGeneratedLocation}\n        placeholder={intl.formatMessage({\n          id: \"setup.paths.path_to_generated_directory_empty_for_default\",\n        })}\n      />\n    </Form.Group>\n  );\n};\n\nconst CacheSection: React.FC<{\n  cacheLocation: string;\n  setCacheLocation: React.Dispatch<React.SetStateAction<string>>;\n}> = ({ cacheLocation, setCacheLocation }) => {\n  const intl = useIntl();\n\n  return (\n    <Form.Group id=\"cache\">\n      <h3>\n        <FormattedMessage id=\"setup.paths.where_can_stash_store_cache_files\" />\n      </h3>\n      <p>\n        <FormattedMessage\n          id=\"setup.paths.where_can_stash_store_cache_files_description\"\n          values={{\n            code: (chunks: string) => <code>{chunks}</code>,\n          }}\n        />\n      </p>\n      <DirectorySelector\n        value={cacheLocation}\n        setValue={setCacheLocation}\n        placeholder={intl.formatMessage({\n          id: \"setup.paths.path_to_cache_directory_empty_for_default\",\n        })}\n      />\n    </Form.Group>\n  );\n};\n\nconst BlobsSection: React.FC<{\n  blobsLocation: string;\n  setBlobsLocation: React.Dispatch<React.SetStateAction<string>>;\n  storeBlobsInDatabase: boolean;\n  setStoreBlobsInDatabase: React.Dispatch<React.SetStateAction<boolean>>;\n}> = ({\n  blobsLocation,\n  setBlobsLocation,\n  storeBlobsInDatabase,\n  setStoreBlobsInDatabase,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <Form.Group id=\"blobs\">\n      <h3>\n        <FormattedMessage id=\"setup.paths.where_can_stash_store_blobs\" />\n      </h3>\n      <p>\n        <FormattedMessage\n          id=\"setup.paths.where_can_stash_store_blobs_description\"\n          values={{\n            code: (chunks: string) => <code>{chunks}</code>,\n          }}\n        />\n      </p>\n      <p>\n        <FormattedMessage\n          id=\"setup.paths.where_can_stash_store_blobs_description_addendum\"\n          values={{\n            code: (chunks: string) => <code>{chunks}</code>,\n            strong: (chunks: string) => <strong>{chunks}</strong>,\n          }}\n        />\n      </p>\n\n      <div>\n        <Form.Check\n          id=\"store-blobs-in-database\"\n          checked={storeBlobsInDatabase}\n          label={intl.formatMessage({\n            id: \"setup.paths.store_blobs_in_database\",\n          })}\n          onChange={() => setStoreBlobsInDatabase(!storeBlobsInDatabase)}\n        />\n      </div>\n\n      <div>\n        <DirectorySelector\n          value={blobsLocation}\n          setValue={setBlobsLocation}\n          placeholder={intl.formatMessage({\n            id: \"setup.paths.path_to_blobs_directory_empty_for_default\",\n          })}\n          disabled={storeBlobsInDatabase}\n        />\n      </div>\n    </Form.Group>\n  );\n};\n\nconst SetPathsStep: React.FC<IWizardStep> = ({ goBack, next }) => {\n  const { configuration, setupState } = useSetupContext();\n\n  const [showStashAlert, setShowStashAlert] = useState(false);\n\n  const [stashes, setStashes] = useState<GQL.StashConfig[]>(\n    setupState.stashes ?? []\n  );\n  const [sfwContentMode, setSfwContentMode] = useState(\n    setupState.sfwContentMode ?? false\n  );\n\n  const [databaseFile, setDatabaseFile] = useState(\n    setupState.databaseFile ?? \"\"\n  );\n  const [generatedLocation, setGeneratedLocation] = useState(\n    setupState.generatedLocation ?? \"\"\n  );\n  const [cacheLocation, setCacheLocation] = useState(\n    setupState.cacheLocation ?? \"\"\n  );\n  const [storeBlobsInDatabase, setStoreBlobsInDatabase] = useState(\n    setupState.storeBlobsInDatabase ?? false\n  );\n  const [blobsLocation, setBlobsLocation] = useState(\n    setupState.blobsLocation ?? \"\"\n  );\n\n  const overrideDatabase = configuration?.general.databasePath;\n  const overrideGenerated = configuration?.general.generatedPath;\n  const overrideCache = configuration?.general.cachePath;\n  const overrideBlobs = configuration?.general.blobsPath;\n\n  function preNext() {\n    if (stashes.length === 0) {\n      setShowStashAlert(true);\n    } else {\n      onNext();\n    }\n  }\n\n  function onNext() {\n    const input: Partial<GQL.SetupInput> = {\n      stashes,\n      databaseFile,\n      generatedLocation,\n      cacheLocation,\n      blobsLocation: storeBlobsInDatabase ? \"\" : blobsLocation,\n      storeBlobsInDatabase,\n      sfwContentMode,\n    };\n    next(input);\n  }\n\n  return (\n    <>\n      {showStashAlert ? (\n        <StashAlert\n          close={(confirm) => {\n            setShowStashAlert(false);\n            if (confirm) {\n              onNext();\n            }\n          }}\n        />\n      ) : null}\n      <section>\n        <h2 className=\"mb-3\">\n          <FormattedMessage id=\"setup.paths.set_up_your_paths\" />\n        </h2>\n        <p>\n          <FormattedMessage id=\"setup.paths.description\" />\n        </p>\n      </section>\n      <section>\n        <Form.Group id=\"stashes\">\n          <h3>\n            <FormattedMessage id=\"setup.paths.where_is_your_porn_located\" />\n          </h3>\n          <p>\n            <FormattedMessage id=\"setup.paths.where_is_your_porn_located_description\" />\n          </p>\n          <Card>\n            <StashConfiguration\n              stashes={stashes}\n              setStashes={(s) => setStashes(s)}\n            />\n          </Card>\n        </Form.Group>\n        <Form.Group id=\"sfw_content\">\n          <h3>\n            <FormattedMessage id=\"setup.paths.sfw_content_settings\" />\n          </h3>\n          <p>\n            <FormattedMessage id=\"setup.paths.sfw_content_settings_description\" />\n          </p>\n          <Card>\n            <Form.Check\n              id=\"use-sfw-content-mode\"\n              checked={sfwContentMode}\n              label={<FormattedMessage id=\"setup.paths.use_sfw_content_mode\" />}\n              onChange={() => setSfwContentMode(!sfwContentMode)}\n            />\n          </Card>\n        </Form.Group>\n        {overrideDatabase ? null : (\n          <DatabaseSection\n            databaseFile={databaseFile}\n            setDatabaseFile={setDatabaseFile}\n          />\n        )}\n        {overrideGenerated ? null : (\n          <GeneratedSection\n            generatedLocation={generatedLocation}\n            setGeneratedLocation={setGeneratedLocation}\n          />\n        )}\n        {overrideCache ? null : (\n          <CacheSection\n            cacheLocation={cacheLocation}\n            setCacheLocation={setCacheLocation}\n          />\n        )}\n        {overrideBlobs ? null : (\n          <BlobsSection\n            blobsLocation={blobsLocation}\n            setBlobsLocation={setBlobsLocation}\n            storeBlobsInDatabase={storeBlobsInDatabase}\n            setStoreBlobsInDatabase={setStoreBlobsInDatabase}\n          />\n        )}\n      </section>\n      <section className=\"mt-5\">\n        <div className=\"d-flex justify-content-center\">\n          <Button variant=\"secondary mx-2 p-5\" onClick={() => goBack()}>\n            <FormattedMessage id=\"actions.previous_action\" />\n          </Button>\n          <Button variant=\"primary mx-2 p-5\" onClick={() => preNext()}>\n            <FormattedMessage id=\"actions.next_action\" />\n          </Button>\n        </div>\n      </section>\n    </>\n  );\n};\n\nconst StashExclusions: React.FC<{ stash: GQL.StashConfig }> = ({ stash }) => {\n  if (!stash.excludeImage && !stash.excludeVideo) {\n    return null;\n  }\n\n  const excludes = [];\n  if (stash.excludeVideo) {\n    excludes.push(\"videos\");\n  }\n  if (stash.excludeImage) {\n    excludes.push(\"images\");\n  }\n\n  return <span>{`(excludes ${excludes.join(\" and \")})`}</span>;\n};\n\nconst ConfirmStep: React.FC<IWizardStep> = ({ goBack, next }) => {\n  const {\n    configuration,\n    pathDir,\n    pathJoin,\n    setupState,\n    homeDirPath,\n    workingDir,\n  } = useSetupContext();\n\n  // if unset, means use homeDirPath\n  const cfgFile = setupState.configLocation\n    ? pathJoin(workingDir, setupState.configLocation)\n    : pathJoin(homeDirPath, \"config.yml\");\n  const cfgDir = pathDir(cfgFile);\n  const stashes = setupState.stashes ?? [];\n  const {\n    databaseFile,\n    generatedLocation,\n    cacheLocation,\n    blobsLocation,\n    storeBlobsInDatabase,\n  } = setupState;\n\n  const overrideDatabase = configuration?.general.databasePath;\n  const overrideGenerated = configuration?.general.generatedPath;\n  const overrideCache = configuration?.general.cachePath;\n  const overrideBlobs = configuration?.general.blobsPath;\n\n  function joinCfgDir(path: string) {\n    if (cfgDir) {\n      return pathJoin(cfgDir, path);\n    } else {\n      return path;\n    }\n  }\n\n  return (\n    <>\n      <section>\n        <h2 className=\"mb-3\">\n          <FormattedMessage id=\"setup.confirm.nearly_there\" />\n        </h2>\n        <p>\n          <FormattedMessage id=\"setup.confirm.almost_ready\" />\n        </p>\n        <dl>\n          <dt>\n            <FormattedMessage id=\"setup.confirm.configuration_file_location\" />\n          </dt>\n          <dd>\n            <code>{cfgFile}</code>\n          </dd>\n        </dl>\n        <dl>\n          <dt>\n            <FormattedMessage id=\"setup.confirm.stash_library_directories\" />\n          </dt>\n          <dd>\n            <ul>\n              {stashes.map((s) => (\n                <li key={s.path}>\n                  <code>{s.path} </code>\n                  <StashExclusions stash={s} />\n                </li>\n              ))}\n            </ul>\n          </dd>\n        </dl>\n        {!overrideDatabase && (\n          <dl>\n            <dt>\n              <FormattedMessage id=\"setup.confirm.database_file_path\" />\n            </dt>\n            <dd>\n              <code>{databaseFile || joinCfgDir(\"stash-go.sqlite\")}</code>\n            </dd>\n          </dl>\n        )}\n        {!overrideGenerated && (\n          <dl>\n            <dt>\n              <FormattedMessage id=\"setup.confirm.generated_directory\" />\n            </dt>\n            <dd>\n              <code>{generatedLocation || joinCfgDir(\"generated\")}</code>\n            </dd>\n          </dl>\n        )}\n        {!overrideCache && (\n          <dl>\n            <dt>\n              <FormattedMessage id=\"setup.confirm.cache_directory\" />\n            </dt>\n            <dd>\n              <code>{cacheLocation || joinCfgDir(\"cache\")}</code>\n            </dd>\n          </dl>\n        )}\n        {!overrideBlobs && (\n          <dl>\n            <dt>\n              <FormattedMessage id=\"setup.confirm.blobs_directory\" />\n            </dt>\n            <dd>\n              <code>\n                {storeBlobsInDatabase ? (\n                  <FormattedMessage id=\"setup.confirm.blobs_use_database\" />\n                ) : (\n                  blobsLocation || joinCfgDir(\"blobs\")\n                )}\n              </code>\n            </dd>\n          </dl>\n        )}\n      </section>\n      <section className=\"mt-5\">\n        <div className=\"d-flex justify-content-center\">\n          <Button variant=\"secondary mx-2 p-5\" onClick={() => goBack()}>\n            <FormattedMessage id=\"actions.previous_action\" />\n          </Button>\n          <Button variant=\"success mx-2 p-5\" onClick={() => next()}>\n            <FormattedMessage id=\"actions.confirm\" />\n          </Button>\n        </div>\n      </section>\n    </>\n  );\n};\n\nconst DiscordLink = (\n  <ExternalLink href=\"https://discord.gg/2TsNFKt\">Discord</ExternalLink>\n);\nconst GithubLink = (\n  <ExternalLink href=\"https://github.com/stashapp/stash/issues\">\n    <FormattedMessage id=\"setup.github_repository\" />\n  </ExternalLink>\n);\n\nconst ErrorStep: React.FC<{ error: string; goBack: () => void }> = ({\n  error,\n  goBack,\n}) => {\n  return (\n    <>\n      <section>\n        <h2>\n          <FormattedMessage id=\"setup.errors.something_went_wrong\" />\n        </h2>\n        <p>\n          <FormattedMessage\n            id=\"setup.errors.something_went_wrong_while_setting_up_your_system\"\n            values={{ error: <pre>{error}</pre> }}\n          />\n        </p>\n        <p>\n          <FormattedMessage\n            id=\"setup.errors.something_went_wrong_description\"\n            values={{ githubLink: GithubLink, discordLink: DiscordLink }}\n          />\n        </p>\n      </section>\n      <section className=\"mt-5\">\n        <div className=\"d-flex justify-content-center\">\n          <Button variant=\"secondary mx-2 p-5\" onClick={goBack}>\n            <FormattedMessage id=\"actions.previous_action\" />\n          </Button>\n        </div>\n      </section>\n    </>\n  );\n};\n\nconst SuccessStep: React.FC<{}> = () => {\n  const intl = useIntl();\n  const history = useHistory();\n\n  const [mutateDownloadFFMpeg] = GQL.useDownloadFfMpegMutation();\n\n  const [downloadFFmpeg, setDownloadFFmpeg] = useState(true);\n\n  const { systemStatus } = useSetupContext();\n  const status = systemStatus?.systemStatus;\n\n  function onFinishClick() {\n    if ((!status?.ffmpegPath || !status?.ffprobePath) && downloadFFmpeg) {\n      mutateDownloadFFMpeg();\n    }\n\n    history.push(\"/settings?tab=library\");\n  }\n\n  return (\n    <>\n      <section>\n        <h2>\n          <FormattedMessage id=\"setup.success.your_system_has_been_created\" />\n        </h2>\n        <p>\n          <FormattedMessage id=\"setup.success.next_config_step_one\" />\n        </p>\n        <p>\n          <FormattedMessage\n            id=\"setup.success.next_config_step_two\"\n            values={{\n              code: (chunks: string) => <code>{chunks}</code>,\n              localized_task: intl.formatMessage({\n                id: \"config.categories.tasks\",\n              }),\n              localized_scan: intl.formatMessage({ id: \"actions.scan\" }),\n            }}\n          />\n        </p>\n        {!status?.ffmpegPath || !status?.ffprobePath ? (\n          <>\n            <Alert variant=\"warning text-center\">\n              <FormattedMessage\n                id=\"setup.success.missing_ffmpeg\"\n                values={{\n                  code: (chunks: string) => <code>{chunks}</code>,\n                }}\n              />\n            </Alert>\n            <p>\n              <Form.Check\n                id=\"download-ffmpeg\"\n                checked={downloadFFmpeg}\n                label={intl.formatMessage({\n                  id: \"setup.success.download_ffmpeg\",\n                })}\n                onChange={() => setDownloadFFmpeg(!downloadFFmpeg)}\n              />\n            </p>\n          </>\n        ) : null}\n      </section>\n      <section>\n        <h3>\n          <FormattedMessage id=\"setup.success.getting_help\" />\n        </h3>\n        <p>\n          <FormattedMessage\n            id=\"setup.success.in_app_manual_explained\"\n            values={{ icon: <Icon icon={faQuestionCircle} /> }}\n          />\n        </p>\n        <p>\n          <FormattedMessage\n            id=\"setup.success.help_links\"\n            values={{ discordLink: DiscordLink, githubLink: GithubLink }}\n          />\n        </p>\n      </section>\n      <section>\n        <h3>\n          <FormattedMessage id=\"setup.success.support_us\" />\n        </h3>\n        <p>\n          <FormattedMessage\n            id=\"setup.success.open_collective\"\n            values={{\n              open_collective_link: (\n                <ExternalLink href=\"https://opencollective.com/stashapp\">\n                  Open Collective\n                </ExternalLink>\n              ),\n            }}\n          />\n        </p>\n        <p>\n          <FormattedMessage id=\"setup.success.welcome_contrib\" />\n        </p>\n      </section>\n      <section>\n        <p className=\"lead text-center\">\n          <FormattedMessage id=\"setup.success.thanks_for_trying_stash\" />\n        </p>\n      </section>\n      <section className=\"mt-5\">\n        <div className=\"d-flex justify-content-center\">\n          <Button variant=\"success mx-2 p-5\" onClick={() => onFinishClick()}>\n            <FormattedMessage id=\"actions.finish\" />\n          </Button>\n        </div>\n      </section>\n    </>\n  );\n};\n\nconst FinishStep: React.FC<IWizardStep> = ({ goBack }) => {\n  const { setupError } = useSetupContext();\n\n  if (setupError !== undefined) {\n    return <ErrorStep error={setupError} goBack={goBack} />;\n  }\n\n  return <SuccessStep />;\n};\n\nexport const Setup: React.FC = () => {\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n\n  const [saveUI] = useConfigureUI();\n\n  const {\n    data: systemStatus,\n    loading: statusLoading,\n    error: statusError,\n  } = useSystemStatus();\n\n  const [step, setStep] = useState(0);\n  const [setupInput, setSetupInput] = useState<Partial<GQL.SetupInput>>({});\n  const [creating, setCreating] = useState(false);\n  const [setupError, setSetupError] = useState<string | undefined>(undefined);\n\n  const history = useHistory();\n\n  const steps: React.FC<IWizardStep>[] = [\n    WelcomeStep,\n    SetPathsStep,\n    ConfirmStep,\n    FinishStep,\n  ];\n  const Step = steps[step];\n\n  async function createSystem() {\n    try {\n      setCreating(true);\n      setSetupError(undefined);\n      await mutateSetup(setupInput as GQL.SetupInput);\n      // Set lastNoteSeen to hide release notes dialog\n      await saveUI({\n        variables: {\n          input: {\n            ...configuration?.ui,\n            lastNoteSeen: releaseNotes[0].date,\n          },\n        },\n      });\n    } catch (e) {\n      if (e instanceof Error && e.message) {\n        setSetupError(e.message);\n      } else {\n        setSetupError(String(e));\n      }\n    } finally {\n      setCreating(false);\n      setStep(step + 1);\n    }\n  }\n\n  function next(input?: Partial<GQL.SetupInput>) {\n    setSetupInput({ ...setupInput, ...input });\n\n    if (Step === ConfirmStep) {\n      // create the system\n      createSystem();\n    } else {\n      setStep(step + 1);\n    }\n  }\n\n  function goBack() {\n    if (Step === FinishStep) {\n      // go back to the step before ConfirmStep\n      setStep(step - 2);\n    } else {\n      setStep(step - 1);\n    }\n  }\n\n  if (statusLoading) {\n    return <LoadingIndicator />;\n  }\n\n  if (\n    step === 0 &&\n    systemStatus &&\n    systemStatus.systemStatus.status !== GQL.SystemStatusEnum.Setup\n  ) {\n    // redirect to main page\n    history.push(\"/\");\n    return <LoadingIndicator />;\n  }\n\n  if (statusError) {\n    return (\n      <Container>\n        <Alert variant=\"danger\">\n          <FormattedMessage\n            id=\"setup.errors.unable_to_retrieve_system_status\"\n            values={{ error: statusError.message }}\n          />\n        </Alert>\n      </Container>\n    );\n  }\n\n  if (!configuration || !systemStatus) {\n    return (\n      <Container>\n        <Alert variant=\"danger\">\n          <FormattedMessage\n            id=\"setup.errors.unable_to_retrieve_configuration\"\n            values={{ error: \"configuration or systemStatus === undefined\" }}\n          />\n        </Alert>\n      </Container>\n    );\n  }\n\n  return (\n    <SetupContext\n      setupState={setupInput}\n      setupError={setupError}\n      configuration={configuration}\n      systemStatus={systemStatus}\n    >\n      <Container className=\"setup-wizard\">\n        <h1 className=\"text-center\">\n          <FormattedMessage id=\"setup.stash_setup_wizard\" />\n        </h1>\n        <Card>\n          {creating ? (\n            <LoadingIndicator\n              message={intl.formatMessage({\n                id: \"setup.creating.creating_your_system\",\n              })}\n            />\n          ) : (\n            <Step next={next} goBack={goBack} />\n          )}\n        </Card>\n      </Container>\n    </SetupContext>\n  );\n};\n\nexport default Setup;\n"
  },
  {
    "path": "ui/v2.5/src/components/Setup/styles.scss",
    "content": ".migration-notes {\n  margin: 1rem;\n\n  > div {\n    background-color: darken($color: $card-bg, $amount: 3);\n    border-radius: 3px;\n    padding: 16px;\n  }\n}\n\n.migrate-loading-status {\n  align-items: center;\n  display: flex;\n  flex-direction: column;\n  height: 70vh;\n  justify-content: center;\n  width: 100%;\n\n  .progress {\n    width: 60%;\n  }\n\n  h4 span {\n    margin-left: 0.5rem;\n  }\n}\n\n.setup-wizard {\n  #blobs > div {\n    margin-bottom: 1rem;\n    margin-top: 0;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Alert.tsx",
    "content": "import { Button, Modal } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { PatchComponent } from \"src/patch\";\n\nexport interface IAlertModalProps {\n  text: JSX.Element | string;\n  confirmVariant?: string;\n  show?: boolean;\n  confirmButtonText?: string;\n  onConfirm: () => void;\n  onCancel: () => void;\n}\n\nexport const AlertModal: React.FC<IAlertModalProps> = PatchComponent(\n  \"AlertModal\",\n  ({\n    text,\n    show,\n    confirmVariant = \"danger\",\n    confirmButtonText,\n    onConfirm,\n    onCancel,\n  }) => {\n    return (\n      <Modal show={show}>\n        <Modal.Body>{text}</Modal.Body>\n        <Modal.Footer>\n          <Button variant={confirmVariant} onClick={() => onConfirm()}>\n            {confirmButtonText ?? <FormattedMessage id=\"actions.confirm\" />}\n          </Button>\n          <Button variant=\"secondary\" onClick={() => onCancel()}>\n            <FormattedMessage id=\"actions.cancel\" />\n          </Button>\n        </Modal.Footer>\n      </Modal>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/BatchModals.tsx",
    "content": "import React, { useMemo, useRef, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\n\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { faStar, faTags } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IEntityWithStashIDs {\n  stash_ids: { endpoint: string }[];\n}\n\ninterface IBatchUpdateModalProps {\n  entities: IEntityWithStashIDs[];\n  isIdle: boolean;\n  selectedEndpoint: { endpoint: string; index: number };\n  allCount: number | undefined;\n  onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;\n  onRefreshChange?: (refresh: boolean) => void;\n  batchAddParents: boolean;\n  setBatchAddParents: (addParents: boolean) => void;\n  close: () => void;\n  localePrefix: string;\n  entityName: string;\n  countVariableName: string;\n}\n\nexport const BatchUpdateModal: React.FC<IBatchUpdateModalProps> = ({\n  entities,\n  isIdle,\n  selectedEndpoint,\n  allCount,\n  onBatchUpdate,\n  onRefreshChange,\n  batchAddParents,\n  setBatchAddParents,\n  close,\n  localePrefix,\n  entityName,\n  countVariableName,\n}) => {\n  const intl = useIntl();\n\n  const [queryAll, setQueryAll] = useState(false);\n  const [refresh, setRefreshState] = useState(false);\n\n  const setRefresh = (value: boolean) => {\n    setRefreshState(value);\n    onRefreshChange?.(value);\n  };\n\n  const entityCount = useMemo(() => {\n    const filteredStashIDs = entities.map((e) =>\n      e.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)\n    );\n\n    return queryAll\n      ? allCount\n      : filteredStashIDs.filter((s) =>\n          refresh ? s.length > 0 : s.length === 0\n        ).length;\n  }, [queryAll, refresh, entities, allCount, selectedEndpoint.endpoint]);\n\n  return (\n    <ModalComponent\n      show\n      icon={faTags}\n      header={intl.formatMessage({\n        id: `${localePrefix}.update_${entityName}s`,\n      })}\n      accept={{\n        text: intl.formatMessage({\n          id: `${localePrefix}.update_${entityName}s`,\n        }),\n        onClick: () => onBatchUpdate(queryAll, refresh),\n      }}\n      cancel={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"danger\",\n        onClick: () => close(),\n      }}\n      disabled={!isIdle}\n    >\n      <Form.Group>\n        <Form.Label>\n          <h6>\n            <FormattedMessage id={`${localePrefix}.${entityName}_selection`} />\n          </h6>\n        </Form.Label>\n        <Form.Check\n          id=\"query-page\"\n          type=\"radio\"\n          name={`${entityName}-query`}\n          label={<FormattedMessage id={`${localePrefix}.current_page`} />}\n          checked={!queryAll}\n          onChange={() => setQueryAll(false)}\n        />\n        <Form.Check\n          id=\"query-all\"\n          type=\"radio\"\n          name={`${entityName}-query`}\n          label={intl.formatMessage({\n            id: `${localePrefix}.query_all_${entityName}s_in_the_database`,\n          })}\n          checked={queryAll}\n          onChange={() => setQueryAll(true)}\n        />\n      </Form.Group>\n      <Form.Group>\n        <Form.Label>\n          <h6>\n            <FormattedMessage id={`${localePrefix}.tag_status`} />\n          </h6>\n        </Form.Label>\n        <Form.Check\n          id={`untagged-${entityName}s`}\n          type=\"radio\"\n          name={`${entityName}-refresh`}\n          label={intl.formatMessage({\n            id: `${localePrefix}.untagged_${entityName}s`,\n          })}\n          checked={!refresh}\n          onChange={() => setRefresh(false)}\n        />\n        <Form.Text>\n          <FormattedMessage\n            id={`${localePrefix}.updating_untagged_${entityName}s_description`}\n          />\n        </Form.Text>\n        <Form.Check\n          id={`tagged-${entityName}s`}\n          type=\"radio\"\n          name={`${entityName}-refresh`}\n          label={intl.formatMessage({\n            id: `${localePrefix}.refresh_tagged_${entityName}s`,\n          })}\n          checked={refresh}\n          onChange={() => setRefresh(true)}\n        />\n        <Form.Text>\n          <FormattedMessage\n            id={`${localePrefix}.refreshing_will_update_the_data`}\n          />\n        </Form.Text>\n      </Form.Group>\n      <div className=\"mt-4\">\n        <Form.Check\n          id=\"add-parent\"\n          checked={batchAddParents}\n          label={intl.formatMessage({\n            id: `${localePrefix}.create_or_tag_parent_${entityName}s`,\n          })}\n          onChange={() => setBatchAddParents(!batchAddParents)}\n        />\n      </div>\n      <b>\n        <FormattedMessage\n          id={`${localePrefix}.number_of_${entityName}s_will_be_processed`}\n          values={{\n            [countVariableName]: entityCount,\n          }}\n        />\n      </b>\n    </ModalComponent>\n  );\n};\n\ninterface IBatchAddModalProps {\n  isIdle: boolean;\n  onBatchAdd: (input: string) => void;\n  batchAddParents: boolean;\n  setBatchAddParents: (addParents: boolean) => void;\n  close: () => void;\n  localePrefix: string;\n  entityName: string;\n}\n\nexport const BatchAddModal: React.FC<IBatchAddModalProps> = ({\n  isIdle,\n  onBatchAdd,\n  batchAddParents,\n  setBatchAddParents,\n  close,\n  localePrefix,\n  entityName,\n}) => {\n  const intl = useIntl();\n\n  const inputRef = useRef<HTMLTextAreaElement | null>(null);\n\n  return (\n    <ModalComponent\n      show\n      icon={faStar}\n      header={intl.formatMessage({\n        id: `${localePrefix}.add_new_${entityName}s`,\n      })}\n      accept={{\n        text: intl.formatMessage({\n          id: `${localePrefix}.add_new_${entityName}s`,\n        }),\n        onClick: () => {\n          if (inputRef.current) {\n            onBatchAdd(inputRef.current.value);\n          } else {\n            close();\n          }\n        },\n      }}\n      cancel={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"danger\",\n        onClick: () => close(),\n      }}\n      disabled={!isIdle}\n    >\n      <Form.Control\n        className=\"text-input\"\n        as=\"textarea\"\n        ref={inputRef}\n        placeholder={intl.formatMessage({\n          id: `${localePrefix}.${entityName}_names_or_stashids_separated_by_comma`,\n        })}\n        rows={6}\n      />\n      <Form.Text>\n        <FormattedMessage\n          id={`${localePrefix}.any_names_entered_will_be_queried`}\n        />\n      </Form.Text>\n      <div className=\"mt-2\">\n        <Form.Check\n          id=\"add-parent\"\n          checked={batchAddParents}\n          label={intl.formatMessage({\n            id: `${localePrefix}.create_or_tag_parent_${entityName}s`,\n          })}\n          onChange={() => setBatchAddParents(!batchAddParents)}\n        />\n      </div>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/BulkUpdate.tsx",
    "content": "import { faBan } from \"@fortawesome/free-solid-svg-icons\";\nimport React from \"react\";\nimport {\n  Button,\n  Col,\n  Form,\n  FormControlProps,\n  InputGroup,\n  Row,\n} from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Icon } from \"./Icon\";\nimport * as FormUtils from \"src/utils/form\";\n\ninterface IBulkUpdateTextInputProps extends Omit<FormControlProps, \"value\"> {\n  valueChanged: (value: string | null | undefined) => void;\n  value: string | null | undefined;\n  unsetDisabled?: boolean;\n  as?: React.ElementType;\n}\n\nexport const BulkUpdateTextInput: React.FC<IBulkUpdateTextInputProps> = ({\n  valueChanged,\n  unsetDisabled,\n  ...props\n}) => {\n  const intl = useIntl();\n\n  const value = props.value === null ? \"\" : props.value ?? undefined;\n  const unset = value === undefined;\n\n  const placeholderValue = unset\n    ? `<${intl.formatMessage({ id: \"existing_value\" })}>`\n    : value === \"\"\n    ? `<${intl.formatMessage({ id: \"empty_value\" })}>`\n    : undefined;\n\n  return (\n    <InputGroup className=\"bulk-update-text-input\">\n      <Form.Control\n        {...props}\n        className=\"text-input\"\n        type=\"text\"\n        as={props.as}\n        value={value ?? \"\"}\n        placeholder={placeholderValue}\n        onChange={(event) => valueChanged(event.currentTarget.value)}\n      />\n      <InputGroup.Append>\n        {!unsetDisabled ? (\n          <Button\n            variant=\"secondary\"\n            onClick={() => valueChanged(undefined)}\n            title={intl.formatMessage({ id: \"actions.unset\" })}\n            disabled={unset}\n          >\n            <Icon icon={faBan} />\n          </Button>\n        ) : undefined}\n      </InputGroup.Append>\n    </InputGroup>\n  );\n};\n\nexport const BulkUpdateFormGroup: React.FC<{\n  name: string;\n  messageId?: string;\n  inline?: boolean;\n}> = ({ name, messageId = name, inline = true, children }) => {\n  if (inline) {\n    return (\n      <Form.Group controlId={name} data-field={name} as={Row}>\n        {FormUtils.renderLabel({\n          title: <FormattedMessage id={messageId} />,\n        })}\n        <Col xs={9}>{children}</Col>\n      </Form.Group>\n    );\n  }\n\n  return (\n    <Form.Group controlId={name} data-field={name}>\n      <Form.Label>\n        <FormattedMessage id={messageId} />\n      </Form.Label>\n      {children}\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ClearableInput.tsx",
    "content": "import React from \"react\";\nimport { Button, FormControl } from \"react-bootstrap\";\nimport { faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"./Icon\";\nimport useFocus from \"src/utils/focus\";\nimport cx from \"classnames\";\n\ninterface IClearableInput {\n  className?: string;\n  value: string;\n  setValue: (value: string) => void;\n  onEnter?: () => void;\n  focus?: ReturnType<typeof useFocus>;\n  placeholder?: string;\n}\n\nexport const ClearableInput: React.FC<IClearableInput> = ({\n  className,\n  value,\n  setValue,\n  onEnter,\n  focus,\n  placeholder,\n}) => {\n  const intl = useIntl();\n\n  const [defaultQueryRef, setQueryFocusDefault] = useFocus();\n  const [queryRef, setQueryFocus] = focus || [\n    defaultQueryRef,\n    setQueryFocusDefault,\n  ];\n  const queryClearShowing = !!value;\n\n  function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {\n    setValue(event.currentTarget.value);\n  }\n\n  function onClearQuery() {\n    setValue(\"\");\n    setQueryFocus();\n  }\n\n  function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {\n    if (e.key === \"Escape\") {\n      queryRef.current?.blur();\n    }\n    if (e.key === \"Enter\" && onEnter) {\n      onEnter();\n    }\n  }\n\n  return (\n    <div className={cx(\"clearable-input-group\", className)}>\n      <FormControl\n        ref={queryRef}\n        placeholder={placeholder}\n        value={value}\n        onInput={onChangeQuery}\n        onKeyDown={onInputKeyDown}\n        className=\"clearable-text-field\"\n      />\n      {queryClearShowing && (\n        <Button\n          variant=\"secondary\"\n          onClick={onClearQuery}\n          title={intl.formatMessage({ id: \"actions.clear\" })}\n          className=\"clearable-text-field-clear\"\n        >\n          <Icon icon={faTimes} />\n        </Button>\n      )}\n    </div>\n  );\n};\n\nexport default ClearableInput;\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/CollapseButton.tsx",
    "content": "import {\n  faChevronDown,\n  faChevronRight,\n  faChevronUp,\n  IconDefinition,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useEffect, useState } from \"react\";\nimport { Button, Collapse, CollapseProps } from \"react-bootstrap\";\nimport { Icon } from \"./Icon\";\n\ninterface IProps {\n  className?: string;\n  text: React.ReactNode;\n  collapseProps?: Partial<CollapseProps>;\n  outsideCollapse?: React.ReactNode;\n  onOpenChanged?: (o: boolean) => void;\n  open?: boolean;\n}\n\nexport const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (\n  props: React.PropsWithChildren<IProps>\n) => {\n  const [open, setOpen] = useState(props.open ?? false);\n\n  function toggleOpen() {\n    const nv = !open;\n    setOpen(nv);\n    props.onOpenChanged?.(nv);\n  }\n\n  useEffect(() => {\n    if (props.open !== undefined) {\n      setOpen(props.open);\n    }\n  }, [props.open]);\n\n  return (\n    <div className={props.className}>\n      <div className=\"collapse-header\">\n        <Button\n          onClick={() => toggleOpen()}\n          className=\"minimal collapse-button\"\n        >\n          <Icon icon={open ? faChevronDown : faChevronRight} fixedWidth />\n          <span>{props.text}</span>\n        </Button>\n      </div>\n      {props.outsideCollapse}\n      <Collapse in={open} {...props.collapseProps}>\n        <div>{props.children}</div>\n      </Collapse>\n    </div>\n  );\n};\n\nexport const ExpandCollapseButton: React.FC<{\n  collapsed: boolean;\n  setCollapsed: (collapsed: boolean) => void;\n  collapsedIcon?: IconDefinition;\n  notCollapsedIcon?: IconDefinition;\n}> = ({ collapsedIcon, notCollapsedIcon, collapsed, setCollapsed }) => {\n  const buttonIcon = collapsed\n    ? collapsedIcon ?? faChevronDown\n    : notCollapsedIcon ?? faChevronUp;\n\n  return (\n    <span className=\"detail-expand-collapse\">\n      <Button\n        className=\"minimal expand-collapse\"\n        onClick={(e) => {\n          setCollapsed(!collapsed);\n          e.stopPropagation();\n        }}\n      >\n        <Icon icon={buttonIcon} fixedWidth />\n      </Button>\n    </span>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/CountButton.tsx",
    "content": "import { faEye, faThumbsUp } from \"@fortawesome/free-solid-svg-icons\";\nimport React from \"react\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { SweatDrops } from \"./SweatDrops\";\nimport cx from \"classnames\";\nimport { useIntl } from \"react-intl\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\ninterface ICountButtonProps {\n  value: number;\n  icon: React.ReactNode;\n  onIncrement?: () => void;\n  onValueClicked?: () => void;\n  title?: string;\n  countTitle?: string;\n}\n\nexport const CountButton: React.FC<ICountButtonProps> = ({\n  value,\n  icon,\n  onIncrement,\n  onValueClicked,\n  title,\n  countTitle,\n}) => {\n  return (\n    <ButtonGroup\n      className={cx(\"count-button\", { \"increment-only\": !onValueClicked })}\n    >\n      <Button\n        className=\"minimal count-icon\"\n        variant=\"secondary\"\n        onClick={() => onIncrement?.()}\n        title={title}\n      >\n        {icon}\n      </Button>\n      <Button\n        className=\"minimal count-value\"\n        variant=\"secondary\"\n        onClick={() => (onValueClicked ?? onIncrement)?.()}\n        title={!!onValueClicked ? countTitle : undefined}\n      >\n        <span>{value}</span>\n      </Button>\n    </ButtonGroup>\n  );\n};\n\ntype CountButtonPropsNoIcon = Omit<ICountButtonProps, \"icon\">;\n\nexport const ViewCountButton: React.FC<CountButtonPropsNoIcon> = (props) => {\n  const intl = useIntl();\n  return (\n    <CountButton\n      {...props}\n      icon={<Icon icon={faEye} />}\n      title={intl.formatMessage({ id: \"media_info.play_count\" })}\n      countTitle={intl.formatMessage({ id: \"actions.view_history\" })}\n    />\n  );\n};\n\nexport const OCounterButton: React.FC<CountButtonPropsNoIcon> = (props) => {\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const icon = !sfwContentMode ? <SweatDrops /> : <Icon icon={faThumbsUp} />;\n  const messageID = !sfwContentMode ? \"o_count\" : \"o_count_sfw\";\n\n  return (\n    <CountButton\n      {...props}\n      icon={icon}\n      title={intl.formatMessage({ id: messageID })}\n      countTitle={intl.formatMessage({ id: \"actions.view_history\" })}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Counter.tsx",
    "content": "import React from \"react\";\nimport { Badge } from \"react-bootstrap\";\nimport { FormattedNumber, useIntl } from \"react-intl\";\nimport TextUtils from \"src/utils/text\";\n\ninterface IProps {\n  abbreviateCounter?: boolean;\n  count: number;\n  hideZero?: boolean;\n  hideOne?: boolean;\n}\n\nexport const Counter: React.FC<IProps> = ({\n  abbreviateCounter = false,\n  count,\n  hideZero = false,\n  hideOne = false,\n}) => {\n  const intl = useIntl();\n\n  if (hideZero && count === 0) return null;\n  if (hideOne && count === 1) return null;\n\n  if (abbreviateCounter) {\n    const formatted = TextUtils.abbreviateCounter(count);\n    return (\n      <Badge\n        className=\"left-spacing\"\n        pill\n        variant=\"secondary\"\n        data-value={intl.formatNumber(count)}\n      >\n        <FormattedNumber\n          value={formatted.size}\n          maximumFractionDigits={formatted.digits}\n        />\n        {formatted.unit}\n      </Badge>\n    );\n  } else {\n    return (\n      <Badge\n        className=\"left-spacing\"\n        pill\n        variant=\"secondary\"\n        data-value={intl.formatNumber(count)}\n      >\n        {intl.formatNumber(count)}\n      </Badge>\n    );\n  }\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/CountryFlag.tsx",
    "content": "import React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { getCountryByISO } from \"src/utils/country\";\nimport { OverlayTrigger, Tooltip } from \"react-bootstrap\";\n\ninterface ICountryFlag {\n  country?: string | null;\n  className?: string;\n  includeName?: boolean;\n  includeOverlay?: boolean;\n}\n\nexport const CountryFlag: React.FC<ICountryFlag> = ({\n  className,\n  country: isoCountry,\n  includeName,\n  includeOverlay,\n}) => {\n  const { locale } = useIntl();\n\n  const country = getCountryByISO(isoCountry, locale);\n\n  if (!isoCountry || !country) return <></>;\n\n  return (\n    <>\n      {includeName ? country : \"\"}\n      {includeOverlay ? (\n        <OverlayTrigger\n          overlay={<Tooltip id=\"{country}-tooltip\">{country}</Tooltip>}\n        >\n          <span\n            className={`${className ?? \"\"} fi fi-${isoCountry.toLowerCase()}`}\n          />\n        </OverlayTrigger>\n      ) : (\n        <span\n          className={`${className ?? \"\"} fi fi-${isoCountry.toLowerCase()}`}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/CountryLabel.tsx",
    "content": "import React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { CountryFlag } from \"./CountryFlag\";\nimport { getCountryByISO } from \"src/utils/country\";\n\ninterface IProps {\n  country: string | undefined;\n  showFlag?: boolean;\n}\n\nexport const CountryLabel: React.FC<IProps> = ({\n  country,\n  showFlag = true,\n}) => {\n  const { locale } = useIntl();\n\n  // #3063 - use alpha2 values only\n  const fromISO =\n    country?.length === 2 ? getCountryByISO(country, locale) : undefined;\n\n  return (\n    <div>\n      {showFlag && <CountryFlag className=\"mr-2\" country={country} />}\n      <span>{fromISO ?? country}</span>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/CountrySelect.tsx",
    "content": "import React from \"react\";\nimport Creatable from \"react-select/creatable\";\nimport { useIntl } from \"react-intl\";\nimport { getCountries } from \"src/utils/country\";\nimport { CountryLabel } from \"./CountryLabel\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IProps {\n  value?: string;\n  onChange?: (value: string) => void;\n  disabled?: boolean;\n  className?: string;\n  showFlag?: boolean;\n  isClearable?: boolean;\n  menuPortalTarget?: HTMLElement | null;\n}\n\nconst _CountrySelect: React.FC<IProps> = ({\n  value,\n  onChange,\n  disabled = false,\n  isClearable = true,\n  showFlag,\n  className,\n  menuPortalTarget,\n}) => {\n  const { locale } = useIntl();\n  const options = getCountries(locale);\n  const selected = options.find((opt) => opt.value === value) ?? {\n    label: value,\n    value,\n  };\n\n  return (\n    <Creatable\n      classNamePrefix=\"react-select\"\n      value={selected}\n      isClearable={isClearable}\n      formatOptionLabel={(option) => (\n        <CountryLabel country={option.value} showFlag={showFlag} />\n      )}\n      placeholder=\"Country\"\n      options={options}\n      onChange={(selectedOption) => onChange?.(selectedOption?.value ?? \"\")}\n      isDisabled={disabled || !onChange}\n      components={{\n        IndicatorSeparator: null,\n      }}\n      className={`CountrySelect ${className}`}\n      menuPortalTarget={menuPortalTarget}\n    />\n  );\n};\n\nexport const CountrySelect = PatchComponent(\"CountrySelect\", _CountrySelect);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/CustomFields.tsx",
    "content": "import React, { useEffect, useMemo, useRef, useState } from \"react\";\nimport { CollapseButton } from \"./CollapseButton\";\nimport { DetailItem } from \"./DetailItem\";\nimport { Button, Col, Form, FormGroup, InputGroup, Row } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\nimport { Icon } from \"./Icon\";\nimport { faMinus, faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport cx from \"classnames\";\nimport { PatchComponent } from \"src/patch\";\nimport { TruncatedText } from \"./TruncatedText\";\n\nconst maxFieldNameLength = 64;\n\nexport type CustomFieldMap = {\n  [key: string]: unknown;\n};\n\ninterface ICustomFields {\n  values: CustomFieldMap;\n  fullWidth?: boolean;\n}\n\nfunction convertValue(value: unknown): string {\n  if (typeof value === \"string\") {\n    return value;\n  } else if (typeof value === \"number\") {\n    return value.toString();\n  } else if (typeof value === \"boolean\") {\n    return value ? \"true\" : \"false\";\n  } else if (Array.isArray(value)) {\n    return value.join(\", \");\n  } else {\n    return JSON.stringify(value);\n  }\n}\n\nconst CustomField: React.FC<{ field: string; value: unknown }> = ({\n  field,\n  value,\n}) => {\n  const valueStr = convertValue(value);\n\n  // replace spaces with hyphen characters for css id\n  const id = `custom-field-${field.toLowerCase().replace(/ /g, \"-\")}`;\n\n  return (\n    <DetailItem\n      id={id}\n      label={field}\n      labelTitle={field}\n      value={<TruncatedText lineCount={5} text={<>{valueStr}</>} />}\n      fullWidth={true}\n      showEmpty\n    />\n  );\n};\n\nexport const CustomFields: React.FC<ICustomFields> = PatchComponent(\n  \"CustomFields\",\n  ({ values, fullWidth }) => {\n    const intl = useIntl();\n    if (Object.keys(values).length === 0) {\n      return null;\n    }\n\n    return (\n      // according to linter rule CSS classes shouldn't use underscores\n      <div className={cx(\"custom-fields\", { \"full-width\": fullWidth })}>\n        <CollapseButton\n          text={intl.formatMessage({ id: \"custom_fields.title\" })}\n        >\n          {Object.entries(values).map(([key, value]) => (\n            <CustomField key={key} field={key} value={value} />\n          ))}\n        </CollapseButton>\n      </div>\n    );\n  }\n);\n\nfunction isNumeric(v: string) {\n  return /^-?(?:0|(?:[1-9][0-9]*))(?:\\.[0-9]+)?$/.test(v);\n}\n\nfunction convertCustomValue(v: string) {\n  // if the value is numeric, convert it to a number\n  if (isNumeric(v)) {\n    return Number(v);\n  } else {\n    return v;\n  }\n}\n\nconst CustomFieldInput: React.FC<{\n  field: string;\n  value: unknown;\n  onChange: (field: string, value: unknown) => void;\n  isNew?: boolean;\n  error?: string;\n}> = PatchComponent(\n  \"CustomFieldInput\",\n  ({ field, value, onChange, isNew = false, error }) => {\n    const intl = useIntl();\n    const [currentField, setCurrentField] = useState(field);\n    const [currentValue, setCurrentValue] = useState(value as string);\n\n    const fieldRef = useRef<HTMLInputElement>(null);\n    const valueRef = useRef<HTMLInputElement>(null);\n\n    useEffect(() => {\n      setCurrentField(field);\n      setCurrentValue(value as string);\n    }, [field, value]);\n\n    function onBlur() {\n      onChange(currentField, convertCustomValue(currentValue));\n    }\n\n    function onDelete() {\n      onChange(\"\", \"\");\n    }\n\n    return (\n      <FormGroup>\n        <Row\n          className={cx(\"custom-fields-row\", { \"custom-fields-new\": isNew })}\n        >\n          <Col className=\"custom-fields-field\">\n            {isNew ? (\n              <>\n                <Form.Control\n                  ref={fieldRef}\n                  className=\"input-control\"\n                  type=\"text\"\n                  value={currentField ?? \"\"}\n                  placeholder={intl.formatMessage({\n                    id: \"custom_fields.field\",\n                  })}\n                  onChange={(event) =>\n                    setCurrentField(event.currentTarget.value)\n                  }\n                  onBlur={onBlur}\n                />\n              </>\n            ) : (\n              <Form.Label title={currentField}>{currentField}</Form.Label>\n            )}\n          </Col>\n          <Col className=\"custom-fields-value\">\n            <InputGroup>\n              <Form.Control\n                ref={valueRef}\n                className=\"input-control\"\n                type=\"text\"\n                value={(currentValue as string) ?? \"\"}\n                placeholder={currentField}\n                onChange={(event) => setCurrentValue(event.currentTarget.value)}\n                onBlur={onBlur}\n              />\n              <InputGroup.Append>\n                {!isNew && (\n                  <Button\n                    className=\"custom-fields-remove\"\n                    variant=\"danger\"\n                    onClick={() => onDelete()}\n                  >\n                    <Icon icon={faMinus} />\n                  </Button>\n                )}\n              </InputGroup.Append>\n            </InputGroup>\n          </Col>\n        </Row>\n        <Form.Control.Feedback type=\"invalid\">{error}</Form.Control.Feedback>\n      </FormGroup>\n    );\n  }\n);\n\ninterface ICustomField {\n  field: string;\n  value: unknown;\n}\n\ninterface ICustomFieldsInput {\n  values: CustomFieldMap;\n  error?: string;\n  onChange: (values: CustomFieldMap) => void;\n  setError: (error?: string) => void;\n}\n\nexport function formatCustomFieldInput(isNew: boolean, input: {}) {\n  if (isNew) {\n    return input;\n  } else {\n    return {\n      full: input,\n    };\n  }\n}\n\nexport const CustomFieldsInput: React.FC<ICustomFieldsInput> = PatchComponent(\n  \"CustomFieldsInput\",\n  ({ values, error, onChange, setError }) => {\n    const intl = useIntl();\n\n    const [newCustomField, setNewCustomField] = useState<ICustomField>({\n      field: \"\",\n      value: \"\",\n    });\n\n    const fields = useMemo(() => {\n      const valueCopy = cloneDeep(values);\n      if (newCustomField.field !== \"\" && error === undefined) {\n        delete valueCopy[newCustomField.field];\n      }\n\n      const ret = Object.keys(valueCopy);\n      ret.sort();\n      return ret;\n    }, [values, newCustomField, error]);\n\n    function onSetNewField(v: ICustomField) {\n      // validate the field name\n      let newError = undefined;\n      if (v.field.length > maxFieldNameLength) {\n        newError = intl.formatMessage({\n          id: \"errors.custom_fields.field_name_length\",\n        });\n      }\n      if (v.field.trim() === \"\" && v.value !== \"\") {\n        newError = intl.formatMessage({\n          id: \"errors.custom_fields.field_name_required\",\n        });\n      }\n      if (v.field.trim() !== v.field) {\n        newError = intl.formatMessage({\n          id: \"errors.custom_fields.field_name_whitespace\",\n        });\n      }\n      if (fields.includes(v.field)) {\n        newError = intl.formatMessage({\n          id: \"errors.custom_fields.duplicate_field\",\n        });\n      }\n\n      const oldField = newCustomField;\n\n      setNewCustomField(v);\n\n      const valuesCopy = cloneDeep(values);\n      if (oldField.field !== \"\" && error === undefined) {\n        delete valuesCopy[oldField.field];\n      }\n\n      // if valid, pass up\n      if (!newError && v.field !== \"\") {\n        valuesCopy[v.field] = v.value;\n      }\n\n      onChange(valuesCopy);\n      setError(newError);\n    }\n\n    function onAdd() {\n      const newValues = {\n        ...values,\n        [newCustomField.field]: newCustomField.value,\n      };\n      setNewCustomField({ field: \"\", value: \"\" });\n      onChange(newValues);\n    }\n\n    function fieldChanged(\n      currentField: string,\n      newField: string,\n      value: unknown\n    ) {\n      let newValues = cloneDeep(values);\n      delete newValues[currentField];\n      if (newField !== \"\") {\n        newValues[newField] = value;\n      }\n      onChange(newValues);\n    }\n\n    return (\n      <CollapseButton\n        className=\"custom-fields-input\"\n        text={intl.formatMessage({ id: \"custom_fields.title\" })}\n      >\n        <Row>\n          <Col xl={12}>\n            <Row className=\"custom-fields-input-header\">\n              <Form.Label column className=\"custom-fields-field\">\n                <FormattedMessage id=\"custom_fields.field\" />\n              </Form.Label>\n              <Form.Label column className=\"custom-fields-value\">\n                <FormattedMessage id=\"custom_fields.value\" />\n              </Form.Label>\n            </Row>\n            {fields.map((field) => (\n              <CustomFieldInput\n                key={field}\n                field={field}\n                value={values[field]}\n                onChange={(newField, newValue) =>\n                  fieldChanged(field, newField, newValue)\n                }\n              />\n            ))}\n            <CustomFieldInput\n              field={newCustomField.field}\n              value={newCustomField.value}\n              error={error}\n              onChange={(field, value) => onSetNewField({ field, value })}\n              isNew\n            />\n          </Col>\n        </Row>\n        <Button\n          className=\"custom-fields-add\"\n          variant=\"success\"\n          onClick={() => onAdd()}\n          disabled={newCustomField.field === \"\" || error !== undefined}\n        >\n          <Icon icon={faPlus} />\n        </Button>\n      </CollapseButton>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Date.tsx",
    "content": "import React from \"react\";\nimport { FormattedDate as IntlDate } from \"react-intl\";\nimport { PatchComponent } from \"src/patch\";\n\n// wraps FormattedDate to handle year or year/month dates\nexport const FormattedDate: React.FC<{\n  value: string | number | Date | undefined;\n}> = PatchComponent(\"Date\", ({ value }) => {\n  if (typeof value === \"string\") {\n    // try parsing as year or year/month\n    const yearMatch = value.match(/^(\\d{4})$/);\n    if (yearMatch) {\n      const year = parseInt(yearMatch[1], 10);\n      return (\n        <IntlDate value={Date.UTC(year, 0)} year=\"numeric\" timeZone=\"utc\" />\n      );\n    }\n\n    const yearMonthMatch = value.match(/^(\\d{4})-(\\d{2})$/);\n    if (yearMonthMatch) {\n      const year = parseInt(yearMonthMatch[1], 10);\n      const month = parseInt(yearMonthMatch[2], 10) - 1;\n\n      return (\n        <IntlDate\n          value={Date.UTC(year, month)}\n          year=\"numeric\"\n          month=\"long\"\n          timeZone=\"utc\"\n        />\n      );\n    }\n  }\n\n  return <IntlDate value={value} format=\"long\" timeZone=\"utc\" />;\n});\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DateInput.tsx",
    "content": "import { faCalendar } from \"@fortawesome/free-regular-svg-icons\";\nimport React, { forwardRef, useMemo } from \"react\";\nimport { Button, InputGroup, Form } from \"react-bootstrap\";\nimport ReactDatePicker from \"react-datepicker\";\nimport TextUtils from \"src/utils/text\";\nimport { Icon } from \"./Icon\";\n\nimport \"react-datepicker/dist/react-datepicker.css\";\nimport { useIntl } from \"react-intl\";\nimport { PatchComponent } from \"src/patch\";\nimport { faBan, faTimes } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IProps {\n  groupClassName?: string;\n  className?: string;\n  disabled?: boolean;\n  value: string;\n  isTime?: boolean;\n  onValueChange(value: string): void;\n  placeholder?: string;\n  placeholderOverride?: string;\n  error?: string;\n  appendBefore?: React.ReactNode;\n  appendAfter?: React.ReactNode;\n}\n\nconst ShowPickerButton = forwardRef<\n  HTMLButtonElement,\n  {\n    onClick: (event: React.MouseEvent) => void;\n  }\n>(({ onClick }, ref) => (\n  <Button variant=\"secondary\" onClick={onClick} ref={ref}>\n    <Icon icon={faCalendar} />\n  </Button>\n));\n\nconst _DateInput: React.FC<IProps> = (props: IProps) => {\n  const intl = useIntl();\n\n  const {\n    groupClassName = \"date-input-group\",\n    className = \"date-input text-input\",\n  } = props;\n\n  const date = useMemo(() => {\n    const toDate = props.isTime\n      ? TextUtils.stringToFuzzyDateTime\n      : TextUtils.stringToFuzzyDate;\n    if (props.value) {\n      const ret = toDate(props.value);\n      if (ret && !Number.isNaN(ret.getTime())) {\n        return ret;\n      }\n    }\n  }, [props.value, props.isTime]);\n\n  function maybeRenderButton() {\n    if (!props.disabled) {\n      const dateToString = props.isTime\n        ? TextUtils.dateTimeToString\n        : TextUtils.dateToString;\n\n      return (\n        <ReactDatePicker\n          selected={date}\n          onChange={(v) => {\n            props.onValueChange(v ? dateToString(v) : \"\");\n          }}\n          customInput={<ShowPickerButton onClick={() => {}} />}\n          showMonthDropdown\n          showYearDropdown\n          scrollableMonthYearDropdown\n          scrollableYearDropdown\n          maxDate={new Date()}\n          yearDropdownItemNumber={100}\n          portalId=\"date-picker-portal\"\n          showTimeSelect={props.isTime}\n        />\n      );\n    }\n  }\n\n  const formatHint = intl.formatMessage({\n    id: props.isTime ? \"datetime_format\" : \"date_format\",\n  });\n\n  const placeholderText = props.placeholder\n    ? `${props.placeholder} (${formatHint})`\n    : formatHint;\n\n  return (\n    <InputGroup hasValidation className={groupClassName}>\n      <Form.Control\n        className={className}\n        disabled={props.disabled}\n        value={props.value}\n        onChange={(e) => props.onValueChange(e.currentTarget.value)}\n        placeholder={\n          !props.disabled\n            ? props.placeholderOverride ?? placeholderText\n            : undefined\n        }\n        isInvalid={!!props.error}\n      />\n      <InputGroup.Append>\n        {props.appendBefore}\n        {maybeRenderButton()}\n        {props.appendAfter}\n      </InputGroup.Append>\n      <Form.Control.Feedback type=\"invalid\">\n        {props.error}\n      </Form.Control.Feedback>\n    </InputGroup>\n  );\n};\n\nexport const DateInput = PatchComponent(\"DateInput\", _DateInput);\n\ninterface IBulkUpdateDateInputProps\n  extends Omit<IProps, \"onValueChange\" | \"value\"> {\n  value: string | null | undefined;\n  valueChanged: (value: string | null | undefined) => void;\n  unsetDisabled?: boolean;\n  as?: React.ElementType;\n  error?: string;\n}\n\nexport const BulkUpdateDateInput: React.FC<IBulkUpdateDateInputProps> = ({\n  valueChanged,\n  unsetDisabled,\n  ...props\n}) => {\n  const intl = useIntl();\n\n  const unset = props.value === undefined;\n\n  const unsetButton = !unsetDisabled ? (\n    <Button\n      variant=\"secondary\"\n      onClick={() => valueChanged(undefined)}\n      title={intl.formatMessage({ id: \"actions.unset\" })}\n      disabled={unset}\n    >\n      <Icon icon={faBan} />\n    </Button>\n  ) : undefined;\n\n  const clearButton =\n    props.value !== null ? (\n      <Button\n        className=\"minimal\"\n        variant=\"secondary\"\n        onClick={() => valueChanged(null)}\n        title={intl.formatMessage({ id: \"actions.clear\" })}\n      >\n        <Icon icon={faTimes} />\n      </Button>\n    ) : undefined;\n\n  const placeholderValue =\n    props.value === null\n      ? `<${intl.formatMessage({ id: \"empty_value\" })}>`\n      : props.value === undefined\n      ? `<${intl.formatMessage({ id: \"existing_value\" })}>`\n      : undefined;\n\n  function outValue(v: string | undefined) {\n    if (v === \"\") {\n      return null;\n    }\n\n    return v;\n  }\n\n  return (\n    <DateInput\n      {...props}\n      value={props.value ?? \"\"}\n      placeholderOverride={placeholderValue}\n      onValueChange={(v) => valueChanged(outValue(v))}\n      groupClassName=\"bulk-update-date-input\"\n      className=\"date-input text-input\"\n      appendBefore={clearButton}\n      appendAfter={unsetButton}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DeleteEntityDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { defineMessages, FormattedMessage, useIntl } from \"react-intl\";\nimport { FetchResult } from \"@apollo/client\";\n\nimport { ModalComponent } from \"./Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IDeletionEntity {\n  id: string;\n  name?: string | null;\n}\n\ntype DestroyMutation = (input: {\n  ids: string[];\n}) => [() => Promise<FetchResult>, {}];\n\ninterface IDeleteEntityDialogProps {\n  selected: IDeletionEntity[];\n  onClose: (confirmed: boolean) => void;\n  singularEntity: string;\n  pluralEntity: string;\n  destroyMutation: DestroyMutation;\n  onDeleted?: () => void;\n}\n\nconst messages = defineMessages({\n  deleteHeader: {\n    id: \"dialogs.delete_object_title\",\n  },\n  deleteToast: {\n    id: \"toast.delete_past_tense\",\n  },\n  deleteMessage: {\n    id: \"dialogs.delete_object_desc\",\n  },\n  overflowMessage: {\n    id: \"dialogs.delete_object_overflow\",\n  },\n});\n\nexport const DeleteEntityDialog: React.FC<IDeleteEntityDialogProps> = ({\n  selected,\n  onClose,\n  singularEntity,\n  pluralEntity,\n  destroyMutation,\n  onDeleted,\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const [deleteEntities] = destroyMutation({ ids: selected.map((p) => p.id) });\n  const count = selected.length;\n\n  // Network state\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  async function onDelete() {\n    setIsDeleting(true);\n    try {\n      await deleteEntities();\n      if (onDeleted) {\n        onDeleted();\n      }\n      Toast.success(\n        intl.formatMessage(messages.deleteToast, {\n          count,\n          singularEntity,\n          pluralEntity,\n        })\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsDeleting(false);\n    onClose(true);\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faTrashAlt}\n      header={intl.formatMessage(messages.deleteHeader, {\n        count,\n        singularEntity,\n        pluralEntity,\n      })}\n      accept={{\n        variant: \"danger\",\n        onClick: onDelete,\n        text: intl.formatMessage({ id: \"actions.delete\" }),\n      }}\n      cancel={{\n        onClick: () => onClose(false),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isDeleting}\n    >\n      <p>\n        <FormattedMessage\n          values={{ count, singularEntity, pluralEntity }}\n          {...messages.deleteMessage}\n        />\n      </p>\n      <ul>\n        {selected.slice(0, 10).map((s) => (\n          <li key={s.name}>{s.name}</li>\n        ))}\n        {selected.length > 10 && (\n          <FormattedMessage\n            values={{\n              count: selected.length - 10,\n              singularEntity,\n              pluralEntity,\n            }}\n            {...messages.overflowMessage}\n          />\n        )}\n      </ul>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { mutateDeleteFiles } from \"src/core/StashService\";\nimport { ModalComponent } from \"./Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { ConfigurationContext } from \"src/hooks/Config\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IFile {\n  id: string;\n  path: string;\n}\n\ninterface IDeleteSceneDialogProps {\n  selected: IFile[];\n  onClose: (confirmed: boolean) => void;\n}\n\nexport const DeleteFilesDialog: React.FC<IDeleteSceneDialogProps> = (\n  props: IDeleteSceneDialogProps\n) => {\n  const intl = useIntl();\n  const singularEntity = intl.formatMessage({ id: \"file\" });\n  const pluralEntity = intl.formatMessage({ id: \"files\" });\n\n  const header = intl.formatMessage(\n    { id: \"dialogs.delete_entity_title\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const toastMessage = intl.formatMessage(\n    { id: \"toast.delete_past_tense\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n  const message = intl.formatMessage(\n    { id: \"dialogs.delete_entity_simple_desc\" },\n    { count: props.selected.length, singularEntity, pluralEntity }\n  );\n\n  const Toast = useToast();\n\n  // Network state\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  const context = React.useContext(ConfigurationContext);\n  const config = context?.configuration;\n\n  async function onDelete() {\n    setIsDeleting(true);\n    try {\n      await mutateDeleteFiles(props.selected.map((f) => f.id));\n      Toast.success(toastMessage);\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n      props.onClose(false);\n    }\n    setIsDeleting(false);\n  }\n\n  function renderDeleteFileAlert() {\n    const deletedFiles = props.selected.map((f) => f.path);\n\n    const deleteTrashPath = config?.general.deleteTrashPath;\n    const deleteAlertId = deleteTrashPath\n      ? \"dialogs.delete_alert_to_trash\"\n      : \"dialogs.delete_alert\";\n\n    return (\n      <div className=\"delete-dialog alert alert-danger text-break\">\n        <p className=\"font-weight-bold\">\n          <FormattedMessage\n            values={{\n              count: props.selected.length,\n              singularEntity: intl.formatMessage({ id: \"file\" }),\n              pluralEntity: intl.formatMessage({ id: \"files\" }),\n            }}\n            id={deleteAlertId}\n          />\n        </p>\n        <ul>\n          {deletedFiles.slice(0, 5).map((s) => (\n            <li key={s}>{s}</li>\n          ))}\n          {deletedFiles.length > 5 && (\n            <FormattedMessage\n              values={{\n                count: deletedFiles.length - 5,\n                singularEntity: intl.formatMessage({ id: \"file\" }),\n                pluralEntity: intl.formatMessage({ id: \"files\" }),\n              }}\n              id=\"dialogs.delete_object_overflow\"\n            />\n          )}\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faTrashAlt}\n      header={header}\n      accept={{\n        variant: \"danger\",\n        onClick: onDelete,\n        text: intl.formatMessage({ id: \"actions.delete\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(false),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isDeleting}\n    >\n      <p>{message}</p>\n      {renderDeleteFileAlert()}\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DetailImage.tsx",
    "content": "import { useLayoutEffect, useRef } from \"react\";\nimport { PatchComponent } from \"src/patch\";\nimport { remToPx } from \"src/utils/units\";\n\nconst DEFAULT_WIDTH = Math.round(remToPx(30));\n\n// Props used by the <img> element\ntype IDetailImageProps = JSX.IntrinsicElements[\"img\"];\n\nexport const DetailImage = PatchComponent(\n  \"DetailImage\",\n  (props: IDetailImageProps) => {\n    const imgRef = useRef<HTMLImageElement>(null);\n\n    function fixWidth() {\n      const img = imgRef.current;\n      if (!img) return;\n\n      // prevent SVG's w/o intrinsic size from rendering as 0x0\n      if (img.naturalWidth === 0) {\n        // If the naturalWidth is zero, it means the image either hasn't loaded yet\n        // or we're on Firefox and it is an SVG w/o an intrinsic size.\n        // So set the width to our fallback width.\n        img.setAttribute(\"width\", String(DEFAULT_WIDTH));\n      } else {\n        // If we have a `naturalWidth`, this could either be the actual intrinsic width\n        // of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari,\n        // which seem to return a size calculated in some browser-specific way.\n        // Worse yet, once rendered, Safari will then return the value of `img.width` as `img.naturalWidth`,\n        // so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone,\n        // in order to always return the same `naturalWidth` for a given src.\n        const i = img.cloneNode() as HTMLImageElement;\n        img.setAttribute(\"width\", String(i.naturalWidth || DEFAULT_WIDTH));\n      }\n    }\n\n    useLayoutEffect(() => {\n      fixWidth();\n    }, [props.src]);\n\n    return <img ref={imgRef} onLoad={() => fixWidth()} {...props} />;\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DetailItem.tsx",
    "content": "import React from \"react\";\nimport { FormattedMessage } from \"react-intl\";\n\ninterface IDetailItem {\n  id?: string | null;\n  className?: string;\n  label?: React.ReactNode;\n  value?: React.ReactNode;\n  labelTitle?: string;\n  title?: string;\n  fullWidth?: boolean;\n  showEmpty?: boolean;\n}\n\nexport const DetailItem: React.FC<IDetailItem> = ({\n  id,\n  className = \"\",\n  label,\n  value,\n  labelTitle,\n  title,\n  fullWidth,\n  showEmpty = false,\n}) => {\n  if (!id || (!showEmpty && (!value || value === \"Na\"))) {\n    return <></>;\n  }\n\n  const message = label ?? <FormattedMessage id={id} />;\n\n  // according to linter rule CSS classes shouldn't use underscores\n  const sanitisedID = id.replace(/_/g, \"-\");\n\n  return (\n    <div className={`detail-item ${id} ${className}`}>\n      <span className={`detail-item-title ${sanitisedID}`} title={labelTitle}>\n        {message}\n        {fullWidth ? \":\" : \"\"}\n      </span>\n      <span className={`detail-item-value ${sanitisedID}`} title={title}>\n        {value}\n      </span>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx",
    "content": "import { Button, Dropdown, Modal, SplitButton } from \"react-bootstrap\";\nimport React, { useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { ImageInput } from \"./ImageInput\";\nimport cx from \"classnames\";\n\ninterface IProps {\n  objectName?: string;\n  isNew: boolean;\n  isEditing: boolean;\n  onToggleEdit: () => void;\n  onSave: () => void;\n  onSaveAndNew?: () => void;\n  saveDisabled?: boolean;\n  onDelete: () => void;\n  onAutoTag?: () => void;\n  autoTagDisabled?: boolean;\n  onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;\n  onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;\n  onImageChangeURL?: (url: string) => void;\n  onBackImageChangeURL?: (url: string) => void;\n  onClearImage?: () => void;\n  onClearBackImage?: () => void;\n  acceptSVG?: boolean;\n  customButtons?: JSX.Element;\n  classNames?: string;\n  children?: JSX.Element | JSX.Element[];\n}\n\nexport const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {\n  const intl = useIntl();\n  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);\n\n  function renderEditButton() {\n    if (props.isNew) return;\n    return (\n      <Button\n        variant=\"primary\"\n        className=\"edit\"\n        onClick={() => props.onToggleEdit()}\n      >\n        {props.isEditing\n          ? intl.formatMessage({ id: \"actions.cancel\" })\n          : intl.formatMessage({ id: \"actions.edit\" })}\n      </Button>\n    );\n  }\n\n  function renderSaveButton() {\n    if (!props.isEditing) return;\n\n    if (props.isNew && props.onSaveAndNew) {\n      return (\n        <SplitButton\n          id=\"save-split-button\"\n          variant=\"success\"\n          className=\"save\"\n          disabled={props.saveDisabled}\n          title={intl.formatMessage({ id: \"actions.save\" })}\n          onClick={() => props.onSave()}\n        >\n          <Dropdown.Item onClick={() => props.onSaveAndNew!()}>\n            <FormattedMessage id=\"actions.save_and_new\" />\n          </Dropdown.Item>\n        </SplitButton>\n      );\n    }\n\n    return (\n      <Button\n        variant=\"success\"\n        className=\"save\"\n        disabled={props.saveDisabled}\n        onClick={() => props.onSave()}\n      >\n        <FormattedMessage id=\"actions.save\" />\n      </Button>\n    );\n  }\n\n  function renderDeleteButton() {\n    if (props.isNew || props.isEditing) return;\n    return (\n      <Button\n        variant=\"danger\"\n        className=\"delete\"\n        onClick={() => setIsDeleteAlertOpen(true)}\n      >\n        <FormattedMessage id=\"actions.delete\" />\n      </Button>\n    );\n  }\n\n  function renderBackImageInput() {\n    if (!props.isEditing || !props.onBackImageChange) {\n      return;\n    }\n    return (\n      <ImageInput\n        isEditing={props.isEditing}\n        text={intl.formatMessage({ id: \"actions.set_back_image\" })}\n        onImageChange={props.onBackImageChange}\n        onImageURL={props.onBackImageChangeURL}\n      />\n    );\n  }\n\n  function renderAutoTagButton() {\n    if (props.isNew || props.isEditing) return;\n\n    if (props.onAutoTag) {\n      return (\n        <div>\n          <Button\n            variant=\"secondary\"\n            disabled={props.autoTagDisabled}\n            onClick={() => {\n              if (props.onAutoTag) {\n                props.onAutoTag();\n              }\n            }}\n          >\n            <FormattedMessage id=\"actions.auto_tag\" />\n          </Button>\n        </div>\n      );\n    }\n  }\n\n  function renderDeleteAlert() {\n    return (\n      <Modal show={isDeleteAlertOpen}>\n        <Modal.Body>\n          <FormattedMessage\n            id=\"dialogs.delete_confirm\"\n            values={{ entityName: props.objectName }}\n          />\n        </Modal.Body>\n        <Modal.Footer>\n          <Button variant=\"danger\" onClick={props.onDelete}>\n            <FormattedMessage id=\"actions.delete\" />\n          </Button>\n          <Button\n            variant=\"secondary\"\n            onClick={() => setIsDeleteAlertOpen(false)}\n          >\n            <FormattedMessage id=\"actions.cancel\" />\n          </Button>\n        </Modal.Footer>\n      </Modal>\n    );\n  }\n\n  return (\n    <div className={cx(\"details-edit\", props.classNames)}>\n      {renderEditButton()}\n      <ImageInput\n        isEditing={props.isEditing}\n        text={\n          props.onBackImageChange\n            ? intl.formatMessage({ id: \"actions.set_front_image\" })\n            : undefined\n        }\n        onImageChange={props.onImageChange}\n        onImageURL={props.onImageChangeURL}\n        acceptSVG={props.acceptSVG ?? false}\n      />\n      {props.isEditing && props.onClearImage ? (\n        <div>\n          <Button\n            className=\"mr-2\"\n            variant=\"danger\"\n            onClick={() => props.onClearImage!()}\n          >\n            {props.onClearBackImage\n              ? intl.formatMessage({ id: \"actions.clear_front_image\" })\n              : intl.formatMessage({ id: \"actions.clear_image\" })}\n          </Button>\n        </div>\n      ) : null}\n      {renderBackImageInput()}\n      {props.isEditing && props.onClearBackImage ? (\n        <div>\n          <Button\n            className=\"mr-2\"\n            variant=\"danger\"\n            onClick={() => props.onClearBackImage!()}\n          >\n            {intl.formatMessage({ id: \"actions.clear_back_image\" })}\n          </Button>\n        </div>\n      ) : null}\n      {renderAutoTagButton()}\n      {props.customButtons}\n      {renderSaveButton()}\n      {renderDeleteButton()}\n      {renderDeleteAlert()}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DetailsPage/AliasList.tsx",
    "content": "export const AliasList: React.FC<{ aliases: string[] | undefined }> = ({\n  aliases,\n}) => {\n  if (!aliases?.length) {\n    return null;\n  }\n\n  return (\n    <div>\n      <span className=\"alias-head\">{aliases.join(\", \")}</span>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DetailsPage/BackgroundImage.tsx",
    "content": "import React from \"react\";\nimport { PatchComponent } from \"src/patch\";\n\nexport const BackgroundImage: React.FC<{\n  imagePath: string | undefined;\n  show: boolean;\n  alt?: string;\n}> = PatchComponent(\"BackgroundImage\", ({ imagePath, show, alt }) => {\n  if (imagePath && show) {\n    const imageURL = new URL(imagePath);\n    let isDefaultImage = imageURL.searchParams.get(\"default\");\n    if (!isDefaultImage) {\n      return (\n        <div className=\"background-image-container\">\n          <picture>\n            <source src={imagePath} />\n            <img className=\"background-image\" src={imagePath} alt={alt} />\n          </picture>\n        </div>\n      );\n    }\n  }\n\n  return null;\n});\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DetailsPage/DetailTitle.tsx",
    "content": "import React, { PropsWithChildren } from \"react\";\n\nexport const DetailTitle: React.FC<\n  PropsWithChildren<{\n    name: string;\n    disambiguation?: string;\n    classNamePrefix: string;\n  }>\n> = ({ name, disambiguation, classNamePrefix, children }) => {\n  return (\n    <h2>\n      <span className={`${classNamePrefix}-name`}>{name}</span>\n      {disambiguation && (\n        <span className={`${classNamePrefix}-disambiguation`}>\n          {` (${disambiguation})`}\n        </span>\n      )}\n      {children}\n    </h2>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DetailsPage/HeaderImage.tsx",
    "content": "import { PropsWithChildren } from \"react\";\nimport { LoadingIndicator } from \"../LoadingIndicator\";\nimport { FormattedMessage } from \"react-intl\";\nimport { PatchComponent } from \"src/patch\";\n\nexport const HeaderImage: React.FC<\n  PropsWithChildren<{\n    encodingImage: boolean;\n  }>\n> = PatchComponent(\"HeaderImage\", ({ encodingImage, children }) => {\n  return (\n    <div className=\"detail-header-image\">\n      {encodingImage ? (\n        <LoadingIndicator\n          message={<FormattedMessage id=\"actions.encoding_image\" />}\n        />\n      ) : (\n        children\n      )}\n    </div>\n  );\n});\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx",
    "content": "import { FormattedMessage } from \"react-intl\";\nimport { Counter } from \"../Counter\";\nimport { useCallback, useEffect } from \"react\";\nimport { useHistory } from \"react-router-dom\";\nimport { PatchComponent } from \"src/patch\";\n\nexport const TabTitleCounter: React.FC<{\n  messageID: string;\n  count: number;\n  abbreviateCounter: boolean;\n}> = PatchComponent(\n  \"TabTitleCounter\",\n  ({ messageID, count, abbreviateCounter }) => {\n    return (\n      <>\n        <FormattedMessage id={messageID} />\n        <Counter count={count} abbreviateCounter={abbreviateCounter} hideZero />\n      </>\n    );\n  }\n);\n\nexport function useTabKey(props: {\n  tabKey: string | undefined;\n  validTabs: readonly string[];\n  defaultTabKey: string;\n  baseURL: string;\n}) {\n  const { tabKey, validTabs, defaultTabKey, baseURL } = props;\n\n  const history = useHistory();\n\n  const setTabKey = useCallback(\n    (newTabKey: string | null) => {\n      if (!newTabKey) newTabKey = defaultTabKey;\n      if (newTabKey === tabKey) return;\n\n      if (validTabs.includes(newTabKey)) {\n        history.replace(`${baseURL}/${newTabKey}`);\n      }\n    },\n    [defaultTabKey, validTabs, tabKey, history, baseURL]\n  );\n\n  useEffect(() => {\n    if (!tabKey) {\n      setTabKey(defaultTabKey);\n    }\n  }, [setTabKey, defaultTabKey, tabKey]);\n\n  return { setTabKey };\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DoubleRangeInput.tsx",
    "content": "import React from \"react\";\n\nexport const DoubleRangeInput: React.FC<{\n  className?: string;\n  minInput: React.ReactNode;\n  maxInput: React.ReactNode;\n  min?: number;\n  max: number;\n  value: [number, number];\n  onChange(value: [number, number]): void;\n}> = ({\n  className = \"\",\n  minInput,\n  maxInput,\n  min = 0,\n  max,\n  value,\n  onChange,\n}) => {\n  const minValue = value[0];\n  const maxValue = value[1];\n\n  return (\n    <div className={`double-range-input ${className}`}>\n      <div className=\"double-range-input-labels\">\n        {minInput}\n        {maxInput}\n      </div>\n      <div className=\"double-range-sliders\">\n        <input\n          type=\"range\"\n          min={min}\n          max={max}\n          step={1}\n          value={minValue}\n          onChange={(e) => {\n            const rawValue = parseInt(e.target.value);\n            if (rawValue < maxValue) {\n              onChange([rawValue, maxValue]);\n            }\n          }}\n          className=\"double-range-slider double-range-slider-min\"\n        />\n        <input\n          type=\"range\"\n          min={min}\n          max={max}\n          step={1}\n          value={maxValue}\n          onChange={(e) => {\n            const rawValue = parseInt(e.target.value);\n            if (rawValue > minValue) {\n              onChange([minValue, rawValue]);\n            }\n          }}\n          className=\"double-range-slider double-range-slider-max\"\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/DurationInput.tsx",
    "content": "import {\n  faChevronDown,\n  faChevronUp,\n  faClock,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useMemo, useState } from \"react\";\nimport { Button, ButtonGroup, InputGroup, Form } from \"react-bootstrap\";\nimport { Icon } from \"./Icon\";\nimport TextUtils from \"src/utils/text\";\n\ninterface IProps {\n  disabled?: boolean;\n  value: number | null | undefined;\n  setValue(value: number | null): void;\n  onReset?(): void;\n  className?: string;\n  placeholder?: string;\n  error?: string;\n  allowNegative?: boolean;\n}\n\nconst includeMS = true;\n\nexport const DurationInput: React.FC<IProps> = ({\n  disabled,\n  value,\n  setValue,\n  onReset,\n  className,\n  placeholder,\n  error,\n  allowNegative = false,\n}) => {\n  const [tmpValue, setTmpValue] = useState<string>();\n\n  function onChange(e: React.ChangeEvent<HTMLInputElement>) {\n    setTmpValue(e.currentTarget.value);\n  }\n\n  function onBlur() {\n    if (tmpValue !== undefined) {\n      updateValue(TextUtils.timestampToSeconds(tmpValue));\n      setTmpValue(undefined);\n    }\n  }\n\n  function updateValue(v: number | null) {\n    if (v !== null && !allowNegative && v < 0) {\n      v = null;\n    }\n    setValue(v);\n  }\n\n  function increment() {\n    setTmpValue(undefined);\n    updateValue((value ?? 0) + 1);\n  }\n\n  function decrement() {\n    setTmpValue(undefined);\n    if (allowNegative) {\n      updateValue((value ?? 0) - 1);\n    } else {\n      updateValue(value ? value - 1 : 0);\n    }\n  }\n\n  function renderButtons() {\n    if (!disabled) {\n      return (\n        <ButtonGroup vertical>\n          <Button\n            variant=\"secondary\"\n            className=\"duration-button\"\n            onClick={() => increment()}\n          >\n            <Icon icon={faChevronUp} />\n          </Button>\n          <Button\n            variant=\"secondary\"\n            className=\"duration-button\"\n            onClick={() => decrement()}\n          >\n            <Icon icon={faChevronDown} />\n          </Button>\n        </ButtonGroup>\n      );\n    }\n  }\n\n  function maybeRenderReset() {\n    if (onReset) {\n      return (\n        <Button variant=\"secondary\" onClick={() => onReset()}>\n          <Icon icon={faClock} />\n        </Button>\n      );\n    }\n  }\n\n  const inputValue = useMemo(() => {\n    if (tmpValue !== undefined) {\n      return tmpValue;\n    } else if (value !== null && value !== undefined) {\n      return TextUtils.secondsToTimestamp(value, includeMS);\n    }\n  }, [value, tmpValue]);\n\n  const format = \"hh:mm:ss.ms\";\n\n  if (placeholder) {\n    placeholder = `${placeholder} (${format})`;\n  } else {\n    placeholder = format;\n  }\n\n  return (\n    <div className={`duration-input ${className}`}>\n      <InputGroup>\n        <Form.Control\n          className=\"duration-control text-input\"\n          disabled={disabled}\n          value={inputValue}\n          onChange={onChange}\n          onBlur={onBlur}\n          placeholder={placeholder}\n        />\n        <InputGroup.Append>\n          {maybeRenderReset()}\n          {renderButtons()}\n        </InputGroup.Append>\n        <Form.Control.Feedback type=\"invalid\">{error}</Form.Control.Feedback>\n      </InputGroup>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ErrorMessage.tsx",
    "content": "import { faWarning } from \"@fortawesome/free-solid-svg-icons\";\nimport React, { ReactNode } from \"react\";\nimport { Alert } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { Icon } from \"./Icon\";\n\ninterface IProps {\n  message?: React.ReactNode;\n  error: string | ReactNode;\n}\n\nexport const ErrorMessage: React.FC<IProps> = (props) => {\n  const { error, message = <FormattedMessage id=\"errors.header\" /> } = props;\n\n  return (\n    <div className=\"ErrorMessage-container\">\n      <Alert variant=\"danger\" className=\"ErrorMessage\">\n        <Alert.Heading className=\"ErrorMessage-header\">\n          <Icon icon={faWarning} />\n          {message}\n        </Alert.Heading>\n        <div className=\"ErrorMessage-content code\">{error}</div>\n      </Alert>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ExportDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { mutateExportObjects } from \"src/core/StashService\";\nimport { ModalComponent } from \"./Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport downloadFile from \"src/utils/download\";\nimport { ExportObjectsInput } from \"src/core/generated-graphql\";\nimport { useIntl } from \"react-intl\";\nimport { faCogs } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IExportDialogProps {\n  exportInput: ExportObjectsInput;\n  onClose: () => void;\n}\n\nexport const ExportDialog: React.FC<IExportDialogProps> = (\n  props: IExportDialogProps\n) => {\n  const [includeDependencies, setIncludeDependencies] = useState(true);\n\n  // Network state\n  const [isRunning, setIsRunning] = useState(false);\n\n  const intl = useIntl();\n  const Toast = useToast();\n\n  async function onExport() {\n    try {\n      setIsRunning(true);\n      const ret = await mutateExportObjects({\n        ...props.exportInput,\n        includeDependencies,\n      });\n\n      // download the result\n      if (ret.data && ret.data.exportObjects) {\n        const link = ret.data.exportObjects;\n        downloadFile(link);\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setIsRunning(false);\n      props.onClose();\n    }\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faCogs}\n      header={intl.formatMessage({ id: \"dialogs.export_title\" })}\n      accept={{\n        onClick: onExport,\n        text: intl.formatMessage({ id: \"actions.export\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isRunning}\n    >\n      <Form>\n        <Form.Group>\n          <Form.Check\n            id=\"include-dependencies\"\n            checked={includeDependencies}\n            label={intl.formatMessage({\n              id: \"dialogs.export_include_related_objects\",\n            })}\n            onChange={() => setIncludeDependencies(!includeDependencies)}\n          />\n        </Form.Group>\n      </Form>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ExternalLink.tsx",
    "content": "type IExternalLinkProps = JSX.IntrinsicElements[\"a\"];\n\nexport const ExternalLink: React.FC<IExternalLinkProps> = (props) => {\n  return <a target=\"_blank\" rel=\"noopener noreferrer\" {...props} />;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ExternalLinksButton.tsx",
    "content": "import { Button, Dropdown } from \"react-bootstrap\";\nimport { ExternalLink } from \"./ExternalLink\";\nimport TextUtils from \"src/utils/text\";\nimport { Icon } from \"./Icon\";\nimport { IconDefinition, faLink } from \"@fortawesome/free-solid-svg-icons\";\nimport { useMemo } from \"react\";\nimport { faInstagram, faTwitter } from \"@fortawesome/free-brands-svg-icons\";\nimport ReactDOM from \"react-dom\";\nimport { PatchComponent } from \"src/patch\";\n\nexport const ExternalLinksButton: React.FC<{\n  icon?: IconDefinition;\n  urls: string[];\n  className?: string;\n  openIfSingle?: boolean;\n}> = PatchComponent(\n  \"ExternalLinksButton\",\n  ({ urls, icon = faLink, className = \"\", openIfSingle = false }) => {\n    if (!urls.length) {\n      return null;\n    }\n\n    const Menu = () =>\n      ReactDOM.createPortal(\n        <Dropdown.Menu>\n          {urls.map((url) => (\n            <Dropdown.Item\n              key={url}\n              as={ExternalLink}\n              href={TextUtils.sanitiseURL(url)}\n              title={url}\n            >\n              {url}\n            </Dropdown.Item>\n          ))}\n        </Dropdown.Menu>,\n        document.body\n      );\n\n    if (openIfSingle && urls.length === 1) {\n      return (\n        <ExternalLink\n          className={`external-links-button-link minimal btn link ${className}`}\n          href={TextUtils.sanitiseURL(urls[0])}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n        >\n          <Icon icon={icon} />\n        </ExternalLink>\n      );\n    } else {\n      return (\n        <Dropdown className=\"external-links-button\">\n          <Dropdown.Toggle as={Button} className={`minimal link ${className}`}>\n            <Icon icon={icon} />\n          </Dropdown.Toggle>\n          <Menu />\n        </Dropdown>\n      );\n    }\n  }\n);\n\nexport const ExternalLinkButtons: React.FC<{ urls: string[] | undefined }> =\n  PatchComponent(\"ExternalLinkButtons\", ({ urls }) => {\n    const urlSpecs = useMemo(() => {\n      if (!urls?.length) {\n        return [];\n      }\n\n      const twitter = urls.filter((u) =>\n        u.match(/https?:\\/\\/(?:www\\.)?(?:twitter|x).com\\//)\n      );\n      const instagram = urls.filter((u) =>\n        u.match(/https?:\\/\\/(?:www\\.)?instagram.com\\//)\n      );\n      const others = urls.filter(\n        (u) => !twitter.includes(u) && !instagram.includes(u)\n      );\n\n      return [\n        { icon: faLink, className: \"\", urls: others },\n        { icon: faTwitter, className: \"twitter\", urls: twitter },\n        { icon: faInstagram, className: \"instagram\", urls: instagram },\n      ];\n    }, [urls]);\n\n    return (\n      <>\n        {urlSpecs.map((spec, i) => (\n          <ExternalLinksButton key={i} {...spec} />\n        ))}\n      </>\n    );\n  });\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/FavoriteIcon.tsx",
    "content": "import React from \"react\";\nimport { Icon } from \"../Shared/Icon\";\nimport { Button } from \"react-bootstrap\";\nimport { faHeart } from \"@fortawesome/free-solid-svg-icons\";\nimport cx from \"classnames\";\nimport { SizeProp } from \"@fortawesome/fontawesome-svg-core\";\n\nexport const FavoriteIcon: React.FC<{\n  favorite: boolean;\n  onToggleFavorite: (v: boolean) => void;\n  size?: SizeProp;\n  className?: string;\n}> = ({ favorite, onToggleFavorite, size, className }) => {\n  return (\n    <Button\n      className={cx(\n        \"minimal\",\n        \"mousetrap\",\n        \"favorite-button\",\n        className,\n        favorite ? \"favorite\" : \"not-favorite\"\n      )}\n      onClick={() => onToggleFavorite!(!favorite)}\n    >\n      <Icon icon={faHeart} size={size} />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/FileSize.tsx",
    "content": "import React from \"react\";\nimport { FormattedNumber } from \"react-intl\";\nimport TextUtils from \"src/utils/text\";\n\nexport const FileSize: React.FC<{ size: number }> = ({ size: fileSize }) => {\n  const { size, unit } = TextUtils.fileSize(fileSize);\n\n  return (\n    <>\n      <FormattedNumber\n        value={size}\n        maximumFractionDigits={TextUtils.fileSizeFractionalDigits(unit)}\n      />\n      {` ${TextUtils.formatFileSizeUnit(unit)}`}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/FilterSelect.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport {\n  OnChangeValue,\n  StylesConfig,\n  GroupBase,\n  OptionsOrGroups,\n  Options,\n} from \"react-select\";\nimport AsyncSelect from \"react-select/async\";\nimport AsyncCreatableSelect, {\n  AsyncCreatableProps,\n} from \"react-select/async-creatable\";\nimport cx from \"classnames\";\n\nimport { useToast } from \"src/hooks/Toast\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport { IHasID } from \"src/utils/data\";\n\nexport type Option<T> = { value: string; object: T };\n\ninterface ISelectProps<T, IsMulti extends boolean>\n  extends AsyncCreatableProps<Option<T>, IsMulti, GroupBase<Option<T>>> {\n  selectedOptions?: OnChangeValue<Option<T>, IsMulti>;\n  creatable?: boolean;\n  isLoading?: boolean;\n  isDisabled?: boolean;\n  placeholder?: string;\n  showDropdown?: boolean;\n  groupHeader?: string;\n  noOptionsMessageText?: string | null;\n}\n\ninterface IFilterSelectProps<T, IsMulti extends boolean>\n  extends Pick<\n    ISelectProps<T, IsMulti>,\n    | \"selectedOptions\"\n    | \"isLoading\"\n    | \"isMulti\"\n    | \"components\"\n    | \"placeholder\"\n    | \"closeMenuOnSelect\"\n  > {}\n\nconst getSelectedItems = <T,>(\n  selectedItems: OnChangeValue<Option<T>, boolean>\n) => {\n  if (Array.isArray(selectedItems)) {\n    return selectedItems;\n  } else if (selectedItems) {\n    return [selectedItems];\n  } else {\n    return [];\n  }\n};\n\nconst SelectComponent = <T, IsMulti extends boolean>(\n  props: ISelectProps<T, IsMulti>\n) => {\n  const {\n    selectedOptions,\n    isDisabled = false,\n    creatable = false,\n    components,\n    placeholder,\n    showDropdown = true,\n    noOptionsMessageText: noOptionsMessage = \"None\",\n  } = props;\n\n  const styles: StylesConfig<Option<T>, IsMulti> = {\n    option: (base) => ({\n      ...base,\n      color: \"#000\",\n    }),\n    container: (base, state) => ({\n      ...base,\n      zIndex: state.isFocused ? 10 : base.zIndex,\n    }),\n    multiValueRemove: (base, state) => ({\n      ...base,\n      color: state.isFocused ? base.color : \"#333333\",\n    }),\n  };\n\n  const componentProps = {\n    ...props,\n    styles,\n    defaultOptions: true,\n    isClearable: true,\n    value: selectedOptions ?? null,\n    className: cx(\"react-select\", props.className),\n    classNamePrefix: \"react-select\",\n    noOptionsMessage: () => noOptionsMessage,\n    placeholder: isDisabled ? \"\" : placeholder,\n    components: {\n      ...components,\n      IndicatorSeparator: () => null,\n      ...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),\n      ...(isDisabled && { MultiValueRemove: () => null }),\n    },\n  };\n\n  return creatable ? (\n    <AsyncCreatableSelect {...componentProps} isDisabled={isDisabled} />\n  ) : (\n    <AsyncSelect {...componentProps} />\n  );\n};\n\nexport interface IFilterValueProps<T> {\n  values?: T[];\n  onSelect?: (item: T[]) => void;\n}\n\nexport interface IFilterProps {\n  noSelectionString?: string;\n  className?: string;\n  active?: boolean;\n  isMulti?: boolean;\n  isClearable?: boolean;\n  isDisabled?: boolean;\n  creatable?: boolean;\n  menuPortalTarget?: HTMLElement | null;\n}\n\nexport interface IFilterComponentProps<T> extends IFilterProps {\n  loadOptions: (inputValue: string) => Promise<Option<T>[]>;\n  onCreate?: (\n    name: string\n  ) => Promise<{ value: string; item: T; message: string }>;\n  getNamedObject?: (id: string, name: string) => T;\n  isValidNewOption?: (inputValue: string, options: T[]) => boolean;\n}\n\nexport const FilterSelectComponent = <\n  T extends IHasID,\n  IsMulti extends boolean\n>(\n  props: IFilterValueProps<T> &\n    IFilterComponentProps<T> &\n    IFilterSelectProps<T, IsMulti>\n) => {\n  const {\n    values,\n    isMulti,\n    onSelect,\n    creatable = false,\n    isValidNewOption,\n    getNamedObject,\n    loadOptions,\n  } = props;\n  const [loading, setLoading] = useState(false);\n  const Toast = useToast();\n\n  const selectedOptions = useMemo(() => {\n    if (isMulti && values) {\n      return values.map(\n        (value) =>\n          ({\n            object: value,\n            value: value.id,\n          } as Option<T>)\n      ) as unknown as OnChangeValue<Option<T>, IsMulti>;\n    }\n\n    if (values?.length) {\n      return {\n        object: values[0],\n        value: values[0].id,\n      } as OnChangeValue<Option<T>, IsMulti>;\n    }\n  }, [values, isMulti]);\n\n  const onChange = (selectedItems: OnChangeValue<Option<T>, boolean>) => {\n    const selected = getSelectedItems(selectedItems);\n\n    onSelect?.(selected.map((item) => item.object));\n  };\n\n  const onCreate =\n    creatable && props.onCreate\n      ? async (name: string) => {\n          try {\n            setLoading(true);\n            const {\n              value,\n              item: newItem,\n              message,\n            } = await props.onCreate!(name);\n            const newItemOption = {\n              object: newItem,\n              value,\n            } as Option<T>;\n            if (!isMulti) {\n              onChange(newItemOption);\n            } else {\n              const o = (selectedOptions ?? []) as Option<T>[];\n              onChange([...o, newItemOption]);\n            }\n\n            setLoading(false);\n            Toast.success(\n              <span>\n                {message}: <b>{name}</b>\n              </span>\n            );\n          } catch (e) {\n            Toast.error(e);\n          }\n        }\n      : undefined;\n\n  const getNewOptionData =\n    creatable && getNamedObject\n      ? (inputValue: string, optionLabel: React.ReactNode) => {\n          return {\n            value: \"\",\n            object: getNamedObject(\"\", optionLabel as string),\n          };\n        }\n      : undefined;\n\n  const validNewOption =\n    creatable && isValidNewOption\n      ? (\n          inputValue: string,\n          value: Options<Option<T>>,\n          options: OptionsOrGroups<Option<T>, GroupBase<Option<T>>>\n        ) => {\n          return isValidNewOption(\n            inputValue,\n            (options as Options<Option<T>>).map((o) => o.object)\n          );\n        }\n      : undefined;\n\n  const debounceDelay = 100;\n  const debounceLoadOptions = useDebounce((inputValue, callback) => {\n    loadOptions(inputValue).then(callback);\n  }, debounceDelay);\n\n  return (\n    <SelectComponent<T, IsMulti>\n      {...props}\n      loadOptions={debounceLoadOptions}\n      isLoading={props.isLoading || loading}\n      onChange={onChange}\n      selectedOptions={selectedOptions}\n      onCreateOption={onCreate}\n      getNewOptionData={getNewOptionData}\n      isValidNewOption={validNewOption}\n    />\n  );\n};\n\nexport interface IFilterIDProps<T> {\n  ids?: string[];\n  onSelect?: (item: T[]) => void;\n}\n\nexport function toOption<T extends IHasID>(item: T): Option<T> {\n  return {\n    value: item.id,\n    object: item,\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx",
    "content": "import React, { useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Button, InputGroup, Form, Collapse } from \"react-bootstrap\";\nimport { Icon } from \"../Icon\";\nimport { LoadingIndicator } from \"../LoadingIndicator\";\nimport { faEllipsis, faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport TextUtils from \"src/utils/text\";\nimport { useDirectoryPaths } from \"./useDirectoryPaths\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IProps {\n  currentDirectory: string;\n  onChangeDirectory: (value: string) => void;\n  defaultDirectories?: string[];\n  appendButton?: JSX.Element;\n  collapsible?: boolean;\n  quotePath?: boolean;\n  hideError?: boolean;\n}\n\nconst _FolderSelect: React.FC<IProps> = ({\n  currentDirectory,\n  onChangeDirectory,\n  defaultDirectories = [],\n  appendButton,\n  collapsible = false,\n  quotePath = false,\n  hideError = false,\n}) => {\n  const intl = useIntl();\n  const [showBrowser, setShowBrowser] = useState(false);\n  const [path, setPath] = useState(currentDirectory);\n\n  const normalizedPath = quotePath ? TextUtils.stripQuotes(path) : path;\n  const { directories, parent, error, loading } = useDirectoryPaths(\n    normalizedPath,\n    hideError\n  );\n\n  const selectableDirectories =\n    (currentDirectory ? directories : defaultDirectories) ?? defaultDirectories;\n\n  const debouncedSetDirectory = useDebounce(setPath, 250);\n\n  function setInstant(value: string) {\n    const normalizedValue =\n      quotePath && value.includes(\" \") ? TextUtils.addQuotes(value) : value;\n    onChangeDirectory(normalizedValue);\n    setPath(normalizedValue);\n  }\n\n  function setDebounced(value: string) {\n    onChangeDirectory(value);\n    debouncedSetDirectory(value);\n  }\n\n  function goUp() {\n    if (defaultDirectories?.includes(currentDirectory)) {\n      setInstant(\"\");\n    } else if (parent) {\n      setInstant(parent);\n    }\n  }\n\n  const topDirectory = currentDirectory && parent && (\n    <li className=\"folder-list-parent folder-list-item\">\n      <Button variant=\"link\" onClick={() => goUp()} disabled={loading}>\n        <span>\n          <FormattedMessage id=\"setup.folder.up_dir\" />\n        </span>\n      </Button>\n    </li>\n  );\n\n  return (\n    <>\n      <InputGroup>\n        <Form.Control\n          className=\"btn-secondary\"\n          placeholder={intl.formatMessage({ id: \"setup.folder.file_path\" })}\n          onChange={(e) => {\n            setDebounced(e.currentTarget.value);\n          }}\n          value={currentDirectory}\n          spellCheck={false}\n        />\n\n        {appendButton && <InputGroup.Append>{appendButton}</InputGroup.Append>}\n\n        {collapsible && (\n          <InputGroup.Append>\n            <Button\n              variant=\"secondary\"\n              onClick={() => setShowBrowser(!showBrowser)}\n            >\n              <Icon icon={faEllipsis} />\n            </Button>\n          </InputGroup.Append>\n        )}\n\n        {(loading || error) && (\n          <InputGroup.Append className=\"align-self-center\">\n            {loading ? (\n              <LoadingIndicator inline small message=\"\" />\n            ) : (\n              !hideError && <Icon icon={faTimes} color=\"red\" className=\"ml-3\" />\n            )}\n          </InputGroup.Append>\n        )}\n      </InputGroup>\n\n      {!hideError && error !== undefined && (\n        <h5 className=\"mt-4 text-break\">Error: {error.message}</h5>\n      )}\n\n      <Collapse in={!collapsible || showBrowser}>\n        <ul className=\"folder-list\">\n          {topDirectory}\n          {selectableDirectories.map((dir) => (\n            <li key={dir} className=\"folder-list-item\">\n              <Button\n                variant=\"link\"\n                onClick={() => setInstant(dir)}\n                disabled={loading}\n              >\n                <span>{dir}</span>\n              </Button>\n            </li>\n          ))}\n        </ul>\n      </Collapse>\n    </>\n  );\n};\n\nexport const FolderSelect = PatchComponent(\"FolderSelect\", _FolderSelect);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/FolderSelect/FolderSelectDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport { Button, Modal } from \"react-bootstrap\";\nimport { FolderSelect } from \"./FolderSelect\";\n\ninterface IProps {\n  defaultValue?: string;\n  onClose: (directory?: string) => void;\n}\n\nexport const FolderSelectDialog: React.FC<IProps> = ({\n  defaultValue: currentValue,\n  onClose,\n}) => {\n  const [currentDirectory, setCurrentDirectory] = useState<string>(\n    currentValue ?? \"\"\n  );\n\n  return (\n    <Modal show onHide={() => onClose()} title=\"\">\n      <Modal.Header>\n        <FormattedMessage id=\"actions.select_directory\" />\n      </Modal.Header>\n      <Modal.Body>\n        <div className=\"dialog-content\">\n          <FolderSelect\n            currentDirectory={currentDirectory}\n            onChangeDirectory={setCurrentDirectory}\n          />\n        </div>\n      </Modal.Body>\n      <Modal.Footer>\n        <Button variant=\"secondary\" onClick={() => onClose()}>\n          <FormattedMessage id=\"actions.cancel\" />\n        </Button>\n        <Button variant=\"success\" onClick={() => onClose(currentDirectory)}>\n          <FormattedMessage id=\"actions.confirm\" />\n        </Button>\n      </Modal.Footer>\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/FolderSelect/useDirectoryPaths.ts",
    "content": "import { useRef } from \"react\";\nimport { useDirectory } from \"src/core/StashService\";\n\nexport const useDirectoryPaths = (path: string, hideError: boolean) => {\n  const { data, loading, error } = useDirectory(path);\n  const prevData = useRef<typeof data | undefined>(undefined);\n\n  if (!loading) prevData.current = data;\n\n  const currentData = loading ? prevData.current : data;\n  const directories =\n    error && hideError ? [] : currentData?.directory.directories;\n  const parent = currentData?.directory.parent;\n\n  return { directories, parent, loading, error };\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/GridCard/GridCard.tsx",
    "content": "import React, {\n  MutableRefObject,\n  PropsWithChildren,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Card, Form } from \"react-bootstrap\";\nimport { Link } from \"react-router-dom\";\nimport cx from \"classnames\";\nimport { TruncatedText } from \"../TruncatedText\";\nimport ScreenUtils from \"src/utils/screen\";\nimport useResizeObserver from \"@react-hook/resize-observer\";\nimport { Icon } from \"../Icon\";\nimport { faGripLines } from \"@fortawesome/free-solid-svg-icons\";\nimport { DragSide, useDragMoveSelect } from \"./dragMoveSelect\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface ICardProps {\n  className?: string;\n  linkClassName?: string;\n  thumbnailSectionClassName?: string;\n  width?: number;\n  url: string;\n  pretitleIcon?: JSX.Element;\n  title: JSX.Element | string;\n  image: JSX.Element;\n  details?: JSX.Element;\n  overlays?: JSX.Element;\n  popovers?: JSX.Element;\n  selecting?: boolean;\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  resumeTime?: number;\n  duration?: number;\n  interactiveHeatmap?: string;\n\n  // move logic - both of the following are required to enable move dragging\n  objectId?: string; // required for move dragging\n  onMove?: (srcIds: string[], targetId: string, after: boolean) => void;\n}\n\nexport const calculateCardWidth = (\n  containerWidth: number,\n  preferredWidth: number\n) => {\n  const containerPadding = 30;\n  const cardMargin = 10;\n  let maxUsableWidth = containerWidth - containerPadding;\n  let maxElementsOnRow = Math.ceil(maxUsableWidth / preferredWidth);\n  return maxUsableWidth / maxElementsOnRow - cardMargin;\n};\n\ninterface IDimension {\n  width: number;\n  height: number;\n}\n\nexport const useContainerDimensions = <T extends HTMLElement = HTMLDivElement>(\n  sensitivityThreshold = 20\n): [MutableRefObject<T | null>, IDimension] => {\n  const target = useRef<T | null>(null);\n  const [dimension, setDimension] = useState<IDimension>({\n    width: 0,\n    height: 0,\n  });\n\n  const debouncedSetDimension = useDebounce((entry: ResizeObserverEntry) => {\n    const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0];\n    let difference = Math.abs(dimension.width - width);\n    // Only adjust when width changed by a significant margin. This addresses the cornercase that sees\n    // the dimensions toggle back and forward when the window is adjusted perfectly such that overflow\n    // is trigger then immediable disabled because of a resize event then continues this loop endlessly.\n    // the scrollbar size varies between platforms. Windows is apparently around 17 pixels.\n    if (difference > sensitivityThreshold) {\n      setDimension({ width, height });\n    }\n  }, 50);\n\n  useResizeObserver(target, debouncedSetDimension);\n\n  return [target, dimension];\n};\n\nexport function useCardWidth(\n  containerWidth: number,\n  zoomIndex: number,\n  zoomWidths: number[]\n) {\n  return useMemo(() => {\n    if (ScreenUtils.isMobile()) {\n      return;\n    }\n\n    if (\n      zoomIndex === undefined ||\n      zoomIndex < 0 ||\n      zoomIndex >= zoomWidths.length\n    )\n      return;\n\n    // use a default card width if we don't have the container width yet\n    if (!containerWidth) {\n      return zoomWidths[zoomIndex];\n    }\n\n    let zoomValue = zoomIndex;\n    const preferredCardWidth = zoomWidths[zoomValue];\n    let fittedCardWidth = calculateCardWidth(\n      containerWidth,\n      preferredCardWidth!\n    );\n    return fittedCardWidth;\n  }, [containerWidth, zoomIndex, zoomWidths]);\n}\n\nconst Checkbox: React.FC<{\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n}> = ({ selected = false, onSelectedChanged }) => {\n  let shiftKey = false;\n\n  return (\n    <Form.Control\n      type=\"checkbox\"\n      // #2750 - add mousetrap class to ensure keyboard shortcuts work\n      className=\"card-check mousetrap\"\n      checked={selected}\n      onChange={() => onSelectedChanged!(!selected, shiftKey)}\n      onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {\n        shiftKey = event.shiftKey;\n        event.stopPropagation();\n      }}\n    />\n  );\n};\n\nconst DragHandle: React.FC<{\n  setInHandle: (inHandle: boolean) => void;\n}> = ({ setInHandle }) => {\n  function onMouseEnter() {\n    setInHandle(true);\n  }\n\n  function onMouseLeave() {\n    setInHandle(false);\n  }\n\n  return (\n    <span onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>\n      <Icon className=\"card-drag-handle\" icon={faGripLines} />\n    </span>\n  );\n};\n\nconst Controls: React.FC<PropsWithChildren<{}>> = ({ children }) => {\n  return <div className=\"card-controls\">{children}</div>;\n};\n\nconst MoveTarget: React.FC<{ dragSide: DragSide }> = ({ dragSide }) => {\n  if (dragSide === undefined) {\n    return null;\n  }\n\n  return (\n    <div\n      className={`move-target move-target-${\n        dragSide === DragSide.BEFORE ? \"before\" : \"after\"\n      }`}\n    ></div>\n  );\n};\n\nexport const GridCard: React.FC<ICardProps> = PatchComponent(\n  \"GridCard\",\n  (props: ICardProps) => {\n    const { setInHandle, moveTarget, dragProps } = useDragMoveSelect({\n      selecting: props.selecting || false,\n      selected: props.selected || false,\n      onSelectedChanged: props.onSelectedChanged,\n      objectId: props.objectId,\n      onMove: props.onMove,\n    });\n\n    function handleImageClick(\n      event: React.MouseEvent<HTMLElement, MouseEvent>\n    ) {\n      const { shiftKey } = event;\n\n      if (!props.onSelectedChanged) {\n        return;\n      }\n\n      if (props.selecting) {\n        props.onSelectedChanged(!props.selected, shiftKey);\n        event.preventDefault();\n        event.stopPropagation();\n      }\n    }\n\n    function maybeRenderInteractiveHeatmap() {\n      if (props.interactiveHeatmap) {\n        return (\n          <img\n            loading=\"lazy\"\n            src={props.interactiveHeatmap}\n            alt=\"interactive heatmap\"\n            className=\"interactive-heatmap\"\n          />\n        );\n      }\n    }\n\n    function maybeRenderProgressBar() {\n      if (\n        props.resumeTime &&\n        props.duration &&\n        props.duration > props.resumeTime\n      ) {\n        const percentValue = (100 / props.duration) * props.resumeTime;\n        const percentStr = percentValue + \"%\";\n        return (\n          <div title={Math.round(percentValue) + \"%\"} className=\"progress-bar\">\n            <div style={{ width: percentStr }} className=\"progress-indicator\" />\n          </div>\n        );\n      }\n    }\n\n    return (\n      <Card\n        className={cx(props.className, \"grid-card\")}\n        onClick={handleImageClick}\n        {...dragProps}\n        style={\n          props.width && !ScreenUtils.isMobile()\n            ? { width: `${props.width}px` }\n            : {}\n        }\n      >\n        {moveTarget !== undefined && <MoveTarget dragSide={moveTarget} />}\n        <Controls>\n          {props.onSelectedChanged && (\n            <Checkbox\n              selected={props.selected}\n              onSelectedChanged={props.onSelectedChanged}\n            />\n          )}\n\n          {!!props.objectId && props.onMove && (\n            <DragHandle setInHandle={setInHandle} />\n          )}\n        </Controls>\n\n        <div\n          className={cx(props.thumbnailSectionClassName, \"thumbnail-section\")}\n        >\n          <Link\n            to={props.url}\n            className={props.linkClassName}\n            onClick={handleImageClick}\n          >\n            {props.image}\n          </Link>\n          {props.overlays}\n          {maybeRenderProgressBar()}\n        </div>\n        {maybeRenderInteractiveHeatmap()}\n        <div className=\"card-section\">\n          <Link to={props.url} onClick={handleImageClick}>\n            <h5 className=\"card-section-title flex-aligned\">\n              {props.pretitleIcon}\n              <TruncatedText text={props.title} lineCount={2} />\n            </h5>\n          </Link>\n          {props.details}\n        </div>\n\n        {props.popovers}\n      </Card>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { Link } from \"react-router-dom\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\ninterface IStudio {\n  id: string;\n  name: string;\n  image_path?: string | null;\n}\n\nexport const StudioOverlay: React.FC<{\n  studio: IStudio | null | undefined;\n  disabled?: boolean;\n}> = ({ studio, disabled }) => {\n  const { configuration } = useConfigurationContext();\n\n  const configValue = configuration?.interface.showStudioAsText;\n\n  const showStudioAsText = useMemo(() => {\n    if (configValue || !studio?.image_path) {\n      return true;\n    }\n\n    // If the studio has a default image, show the studio name as text\n    const studioImageURL = new URL(studio.image_path);\n    if (studioImageURL.searchParams.get(\"default\") === \"true\") {\n      return true;\n    }\n\n    return false;\n  }, [configValue, studio?.image_path]);\n\n  function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {\n    if (disabled) {\n      e.preventDefault();\n    }\n  }\n\n  if (!studio) return <></>;\n\n  return (\n    // this class name is incorrect\n    <div className=\"studio-overlay\">\n      <Link to={`/studios/${studio.id}`} onClick={onClick}>\n        {showStudioAsText ? (\n          studio.name\n        ) : (\n          <img\n            className=\"image-thumbnail\"\n            loading=\"lazy\"\n            alt={studio.name}\n            src={studio.image_path ?? \"\"}\n          />\n        )}\n      </Link>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts",
    "content": "import { useState } from \"react\";\nimport { useListContextOptional } from \"src/components/List/ListProvider\";\n\n// Enum representing the possible sides for a drag operation.\nexport enum DragSide {\n  BEFORE,\n  AFTER,\n}\n\n/**\n * Hook to manage drag and move selection functionality.\n * Dragging while selecting will allow the user to select multiple items.\n * Dragging from the drag handle will allow the user to move the item or selected items.\n *\n * @param props - The properties for the hook.\n * @param props.selecting - Whether the one or more items have been selected.\n * @param props.selected - Whether this item is currently selected.\n * @param props.onSelectedChanged - Callback when the selected state changes.\n * @param props.objectId - The ID of this object.\n * @param props.onMove - Callback when a move operation occurs.\n *\n * @returns An object containing the drag event handlers and state.\n */\nexport function useDragMoveSelect(props: {\n  selecting: boolean;\n  selected: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n  objectId?: string;\n  onMove?: (srcIds: string[], targetId: string, after: boolean) => void;\n}) {\n  const { selectedIds } = useListContextOptional();\n\n  // true if the mouse is over the drag handle\n  const [inHandle, setInHandle] = useState(false);\n\n  // true if this is the source of a move operation\n  const [moveSrc, setMoveSrc] = useState(false);\n  // the target side for a move operation\n  const [moveTarget, setMoveTarget] = useState<DragSide | undefined>();\n\n  const canSelect = props.onSelectedChanged && props.selecting;\n  const canMove = !!props.objectId && props.onMove && inHandle;\n  const draggable = canSelect || canMove;\n\n  function onDragStart(event: React.DragEvent<HTMLElement>) {\n    if (!draggable) {\n      event.preventDefault();\n      return;\n    }\n\n    if (!inHandle && props.selecting) {\n      event.dataTransfer.setData(\"text/plain\", \"\");\n      // event.dataTransfer.setDragImage(new Image(), 0, 0);\n      event.dataTransfer.effectAllowed = \"copy\";\n      event.stopPropagation();\n    } else if (inHandle && props.objectId) {\n      if (selectedIds.size > 1 && selectedIds.has(props.objectId)) {\n        // moving all selected\n        const movingIds = Array.from(selectedIds.values()).join(\",\");\n        event.dataTransfer.setData(\"text/plain\", movingIds);\n      } else {\n        // moving single\n        setMoveSrc(true);\n        event.dataTransfer.setData(\"text/plain\", props.objectId);\n      }\n      event.dataTransfer.effectAllowed = \"move\";\n      event.stopPropagation();\n    }\n  }\n\n  function doSetMoveTarget(event: React.DragEvent<HTMLElement>) {\n    const isBefore =\n      event.nativeEvent.offsetX < event.currentTarget.clientWidth / 2;\n    if (isBefore && moveTarget !== DragSide.BEFORE) {\n      setMoveTarget(DragSide.BEFORE);\n    } else if (!isBefore && moveTarget !== DragSide.AFTER) {\n      setMoveTarget(DragSide.AFTER);\n    }\n  }\n\n  function onDragEnter(event: React.DragEvent<HTMLElement>) {\n    const ev = event;\n    const shiftKey = false;\n\n    if (ev.dataTransfer.effectAllowed === \"copy\") {\n      if (!props.onSelectedChanged) {\n        return;\n      }\n\n      if (props.selecting && !props.selected) {\n        props.onSelectedChanged(true, shiftKey);\n      }\n\n      ev.dataTransfer.dropEffect = \"copy\";\n      ev.preventDefault();\n    } else if (ev.dataTransfer.effectAllowed === \"move\" && !moveSrc) {\n      // don't allow move on self\n      doSetMoveTarget(event);\n      ev.dataTransfer.dropEffect = \"move\";\n      ev.preventDefault();\n    } else {\n      ev.dataTransfer.dropEffect = \"none\";\n    }\n  }\n\n  function onDragLeave(event: React.DragEvent<HTMLElement>) {\n    if (event.currentTarget.contains(event.relatedTarget as Node)) {\n      return;\n    }\n\n    setMoveTarget(undefined);\n  }\n\n  function onDragOver(event: React.DragEvent<HTMLElement>) {\n    // only set move target if move is allowed, or if this is not the source of the move\n    if (event.dataTransfer.effectAllowed !== \"move\" || moveSrc) {\n      return;\n    }\n\n    doSetMoveTarget(event);\n\n    event.preventDefault();\n  }\n\n  function onDragEnd() {\n    setMoveTarget(undefined);\n    setMoveSrc(false);\n  }\n\n  function onDrop(event: React.DragEvent<HTMLElement>) {\n    const ev = event;\n\n    if (\n      ev.dataTransfer.effectAllowed === \"copy\" ||\n      !props.onMove ||\n      !props.objectId\n    ) {\n      return;\n    }\n\n    const srcIds = ev.dataTransfer.getData(\"text/plain\").split(\",\");\n    const targetId = props.objectId;\n    const after = moveTarget === DragSide.AFTER;\n\n    props.onMove(srcIds, targetId, after);\n\n    onDragEnd();\n  }\n\n  return {\n    inHandle,\n    setInHandle,\n    moveTarget,\n    dragProps: {\n      draggable: draggable || undefined,\n      onDragStart,\n      onDragEnter,\n      onDragLeave,\n      onDragOver,\n      onDragEnd,\n      onDrop,\n    },\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/GridCard/styles.scss",
    "content": ".grid-card {\n  a {\n    color: $text-color;\n    text-decoration: none;\n  }\n\n  .rating-banner {\n    transition: opacity 0.5s;\n  }\n\n  &:hover,\n  &:active {\n    .rating-banner,\n    .studio-overlay {\n      opacity: 0;\n      transition: opacity 0.5s;\n    }\n\n    .studio-overlay:hover,\n    .studio-overlay:active {\n      opacity: 0.75;\n      transition: opacity 0.5s;\n    }\n  }\n}\n\n.studio-overlay {\n  display: block;\n  font-weight: 900;\n  height: 10%;\n  max-width: 40%;\n  opacity: 0.75;\n  position: absolute;\n  right: 0.7rem;\n  top: 0.7rem;\n  transition: opacity 0.5s;\n  z-index: 8;\n\n  .image-thumbnail {\n    height: 50px;\n    object-fit: contain;\n    width: 100%;\n  }\n\n  a {\n    color: $text-color;\n    display: inline-block;\n    letter-spacing: -0.03rem;\n    text-align: right;\n    text-decoration: none;\n    text-shadow: 0 0 3px #000;\n  }\n\n  &:hover,\n  &:active {\n    opacity: 0.75;\n    transition: opacity 0.5s;\n  }\n}\n\n.move-target {\n  align-items: center;\n  background-color: $primary;\n  color: $secondary;\n  display: flex;\n  height: 100%;\n  justify-content: center;\n  opacity: 0.5;\n  pointer-events: none;\n  position: absolute;\n  width: 10%;\n\n  &.move-target-before {\n    left: 0;\n  }\n\n  &.move-target-after {\n    right: 0;\n  }\n}\n\n.card-drag-handle {\n  filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.7));\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/HoverPopover.tsx",
    "content": "import React, { useState, useCallback, useEffect, useRef } from \"react\";\nimport { Overlay, Popover, OverlayProps } from \"react-bootstrap\";\nimport { PatchComponent } from \"src/patch\";\nimport { Icon } from \"./Icon\";\nimport { faExclamationTriangle } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface IHoverPopover {\n  enterDelay?: number;\n  leaveDelay?: number;\n  content: JSX.Element[] | JSX.Element | string;\n  className?: string;\n  placement?: OverlayProps[\"placement\"];\n  onOpen?: () => void;\n  onClose?: () => void;\n  target?: React.RefObject<HTMLElement>;\n}\n\nexport const HoverPopover: React.FC<IHoverPopover> = PatchComponent(\n  \"HoverPopover\",\n  ({\n    enterDelay = 200,\n    leaveDelay = 200,\n    content,\n    children,\n    className,\n    placement = \"top\",\n    onOpen,\n    onClose,\n    target,\n  }) => {\n    const [show, setShow] = useState(false);\n    const triggerRef = useRef<HTMLDivElement>(null);\n    const enterTimer = useRef<number>();\n    const leaveTimer = useRef<number>();\n\n    const handleMouseEnter = useCallback(() => {\n      window.clearTimeout(leaveTimer.current);\n      enterTimer.current = window.setTimeout(() => {\n        setShow(true);\n        onOpen?.();\n      }, enterDelay);\n    }, [enterDelay, onOpen]);\n\n    const handleMouseLeave = useCallback(() => {\n      window.clearTimeout(enterTimer.current);\n      leaveTimer.current = window.setTimeout(() => {\n        setShow(false);\n        onClose?.();\n      }, leaveDelay);\n    }, [leaveDelay, onClose]);\n\n    useEffect(\n      () => () => {\n        window.clearTimeout(enterTimer.current);\n        window.clearTimeout(leaveTimer.current);\n      },\n      []\n    );\n\n    return (\n      <>\n        <div\n          className={className}\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n          ref={triggerRef}\n        >\n          {children}\n        </div>\n        {triggerRef.current && (\n          <Overlay\n            show={show}\n            placement={placement}\n            target={target?.current ?? triggerRef.current}\n          >\n            <Popover\n              onMouseEnter={handleMouseEnter}\n              onMouseLeave={handleMouseLeave}\n              id=\"popover\"\n              className=\"hover-popover-content\"\n            >\n              {content}\n            </Popover>\n          </Overlay>\n        )}\n      </>\n    );\n  }\n);\n\n// convenience component to set the padding on popover content\nexport const PopoverCard: React.FC<{ className?: string }> = ({\n  className,\n  children,\n}) => {\n  return <div className={`popover-card ${className}`}>{children}</div>;\n};\n\nexport const WarningHoverPopover: React.FC<IHoverPopover> = PatchComponent(\n  \"WarningHoverPopover\",\n  ({ children, ...props }) => (\n    <HoverPopover {...props} className=\"warning-hover-popover\">\n      <Icon icon={faExclamationTriangle} />\n    </HoverPopover>\n  )\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/HoverScrubber.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport cx from \"classnames\";\n\n// #5231: TouchEvent is not defined on all browsers\nconst touchEventDefined = window.TouchEvent !== undefined;\n\ninterface IHoverScrubber {\n  totalSprites: number;\n  activeIndex: number | undefined;\n  setActiveIndex: (index: number | undefined) => void;\n  onClick?: (index: number) => void;\n  disabled?: boolean;\n}\n\nexport const HoverScrubber: React.FC<IHoverScrubber> = ({\n  totalSprites,\n  activeIndex,\n  setActiveIndex,\n  onClick,\n  disabled,\n}) => {\n  function getActiveIndex(\n    e:\n      | React.MouseEvent<HTMLDivElement, MouseEvent>\n      | React.TouchEvent<HTMLDivElement>\n  ) {\n    const { width } = e.currentTarget.getBoundingClientRect();\n\n    let x = 0;\n    if (e.nativeEvent instanceof MouseEvent) {\n      x = e.nativeEvent.offsetX;\n    } else if (touchEventDefined && e.nativeEvent instanceof TouchEvent) {\n      x =\n        e.nativeEvent.touches[0].clientX -\n        e.currentTarget.getBoundingClientRect().x;\n    }\n\n    const i = Math.round((x / width) * (totalSprites - 1));\n\n    // clamp to [0, totalSprites)\n    if (i < 0) return 0;\n    if (i >= totalSprites) return totalSprites - 1;\n    return i;\n  }\n\n  function onMove(\n    e:\n      | React.MouseEvent<HTMLDivElement, MouseEvent>\n      | React.TouchEvent<HTMLDivElement>\n  ) {\n    const relatedTarget = e.currentTarget;\n\n    if (\n      (e instanceof MouseEvent && relatedTarget !== e.target) ||\n      (touchEventDefined &&\n        e instanceof TouchEvent &&\n        document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY))\n    )\n      return;\n\n    setActiveIndex(getActiveIndex(e));\n  }\n\n  function onLeave() {\n    setActiveIndex(undefined);\n  }\n\n  function onScrubberClick(\n    e:\n      | React.MouseEvent<HTMLDivElement, MouseEvent>\n      | React.TouchEvent<HTMLDivElement>\n  ) {\n    if (!onClick) return;\n    if (disabled) {\n      // allow propagation up so that selection still works\n      e.preventDefault();\n      return;\n    }\n\n    const relatedTarget = e.currentTarget;\n\n    if (\n      (e instanceof MouseEvent && relatedTarget !== e.target) ||\n      (touchEventDefined &&\n        e instanceof TouchEvent &&\n        document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY))\n    )\n      return;\n\n    e.preventDefault();\n    e.stopPropagation();\n\n    const i = getActiveIndex(e);\n    if (i === undefined) return;\n    onClick(i);\n  }\n\n  const indicatorStyle = useMemo(() => {\n    if (activeIndex === undefined || !totalSprites) return {};\n\n    const width = ((activeIndex + 1) / totalSprites) * 100;\n\n    return {\n      width: `${width}%`,\n    };\n  }, [activeIndex, totalSprites]);\n\n  return (\n    <div\n      className={cx(\"hover-scrubber\", {\n        \"hover-scrubber-inactive\": !totalSprites,\n      })}\n    >\n      <div\n        className=\"hover-scrubber-area\"\n        onMouseMove={onMove}\n        onTouchMove={onMove}\n        onMouseLeave={onLeave}\n        onTouchEnd={onLeave}\n        onTouchCancel={onLeave}\n        onClick={onScrubberClick}\n      />\n      <div className=\"hover-scrubber-indicator\">\n        {activeIndex !== undefined && (\n          <div\n            className=\"hover-scrubber-indicator-marker\"\n            style={indicatorStyle}\n          ></div>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Icon.tsx",
    "content": "import React from \"react\";\nimport {\n  FontAwesomeIcon,\n  FontAwesomeIconProps,\n} from \"@fortawesome/react-fontawesome\";\nimport { PatchComponent } from \"src/patch\";\n\nexport const Icon: React.FC<FontAwesomeIconProps> = PatchComponent(\n  \"Icon\",\n  (props) => (\n    <FontAwesomeIcon\n      {...props}\n      className={`fa-icon ${props.className ?? \"\"}`}\n    />\n  )\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ImageInput.tsx",
    "content": "import React, { useState } from \"react\";\nimport {\n  Button,\n  Col,\n  Form,\n  OverlayTrigger,\n  Popover,\n  Row,\n} from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { ModalComponent } from \"./Modal\";\nimport { Icon } from \"./Icon\";\nimport { faClipboard, faFile, faLink } from \"@fortawesome/free-solid-svg-icons\";\nimport { PatchComponent } from \"src/patch\";\nimport ImageUtils from \"src/utils/image\";\nimport { useToast } from \"src/hooks/Toast\";\n\ninterface IImageInput {\n  isEditing: boolean;\n  text?: string;\n  onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;\n  onImageURL?: (url: string) => void;\n  onReset?: () => void;\n  acceptSVG?: boolean;\n}\n\nfunction acceptExtensions(acceptSVG: boolean = false) {\n  return `.jpg,.jpeg,.png,.webp,.gif${acceptSVG ? \",.svg\" : \"\"}`;\n}\n\nexport const ImageInput: React.FC<IImageInput> = PatchComponent(\n  \"ImageInput\",\n  ({\n    isEditing,\n    text,\n    onImageChange,\n    onImageURL,\n    onReset,\n    acceptSVG = false,\n  }) => {\n    const [isShowDialog, setIsShowDialog] = useState(false);\n    const [url, setURL] = useState(\"\");\n    const intl = useIntl();\n    const Toast = useToast();\n\n    if (!isEditing) return <div />;\n\n    if (!onImageURL) {\n      // just return the file input\n      return (\n        <Form.Label className=\"image-input\">\n          <Button variant=\"secondary\">\n            {text ?? intl.formatMessage({ id: \"actions.browse_for_image\" })}\n          </Button>\n          <Form.Control\n            type=\"file\"\n            onChange={onImageChange}\n            accept={acceptExtensions(acceptSVG)}\n          />\n        </Form.Label>\n      );\n    }\n\n    async function onPasteClipboard() {\n      try {\n        const data = await ImageUtils.readClipboardImage();\n        if (data && onImageURL) {\n          onImageURL(data);\n          Toast.success(\n            intl.formatMessage({ id: \"toast.clipboard_image_pasted\" })\n          );\n        } else {\n          Toast.error(intl.formatMessage({ id: \"toast.clipboard_no_image\" }));\n        }\n      } catch (e) {\n        if (e instanceof DOMException && e.name === \"NotAllowedError\") {\n          Toast.error(\n            intl.formatMessage({ id: \"toast.clipboard_access_denied\" })\n          );\n        } else {\n          Toast.error(e);\n        }\n      }\n    }\n\n    function showDialog() {\n      setURL(\"\");\n      setIsShowDialog(true);\n    }\n\n    function onConfirmURL() {\n      if (!onImageURL) {\n        return;\n      }\n\n      setIsShowDialog(false);\n      onImageURL(url);\n    }\n\n    function renderDialog() {\n      return (\n        <ModalComponent\n          show={!!isShowDialog}\n          onHide={() => setIsShowDialog(false)}\n          header={intl.formatMessage({ id: \"dialogs.set_image_url_title\" })}\n          accept={{\n            onClick: onConfirmURL,\n            text: intl.formatMessage({ id: \"actions.confirm\" }),\n          }}\n        >\n          <div className=\"dialog-content\">\n            <Form.Group controlId=\"url\" as={Row}>\n              <Form.Label column xs={3}>\n                {intl.formatMessage({ id: \"url\" })}\n              </Form.Label>\n              <Col xs={9}>\n                <Form.Control\n                  className=\"text-input\"\n                  onChange={(event: React.ChangeEvent<HTMLInputElement>) =>\n                    setURL(event.currentTarget.value)\n                  }\n                  value={url}\n                  placeholder={intl.formatMessage({ id: \"url\" })}\n                />\n              </Col>\n            </Form.Group>\n          </div>\n        </ModalComponent>\n      );\n    }\n\n    const popover = (\n      <Popover id=\"set-image-popover\">\n        <Popover.Content>\n          <>\n            <div>\n              <Form.Label className=\"image-input\">\n                <Button variant=\"secondary\">\n                  <Icon icon={faFile} className=\"fa-fw\" />\n                  <span>{intl.formatMessage({ id: \"actions.from_file\" })}</span>\n                </Button>\n                <Form.Control\n                  type=\"file\"\n                  onChange={onImageChange}\n                  accept={acceptExtensions(acceptSVG)}\n                />\n              </Form.Label>\n            </div>\n            <div>\n              <Button className=\"minimal\" onClick={showDialog}>\n                <Icon icon={faLink} className=\"fa-fw\" />\n                <span>{intl.formatMessage({ id: \"actions.from_url\" })}</span>\n              </Button>\n            </div>\n            {window.isSecureContext && (\n              <div>\n                <Button className=\"minimal\" onClick={onPasteClipboard}>\n                  <Icon icon={faClipboard} className=\"fa-fw\" />\n                  <span>\n                    {intl.formatMessage({ id: \"actions.from_clipboard\" })}\n                  </span>\n                </Button>\n              </div>\n            )}\n          </>\n        </Popover.Content>\n      </Popover>\n    );\n\n    return (\n      <>\n        {renderDialog()}\n        <OverlayTrigger\n          trigger=\"click\"\n          placement=\"top\"\n          overlay={popover}\n          rootClose\n        >\n          <Button variant=\"secondary\" className=\"mr-2\">\n            {text ?? intl.formatMessage({ id: \"actions.set_image\" })}\n          </Button>\n        </OverlayTrigger>\n        {onReset && (\n          <Button variant=\"danger\" className=\"mr-2\" onClick={onReset}>\n            {intl.formatMessage({ id: \"actions.clear_image\" })}\n          </Button>\n        )}\n      </>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ImageSelector.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport cx from \"classnames\";\nimport { LoadingIndicator } from \"./LoadingIndicator\";\nimport { Button } from \"react-bootstrap\";\nimport { faArrowLeft, faArrowRight } from \"@fortawesome/free-solid-svg-icons\";\nimport { Icon } from \"./Icon\";\nimport { FormattedMessage } from \"react-intl\";\n\ninterface IImageSelectorProps {\n  imageClassName?: string;\n  images: string[];\n  imageIndex: number;\n  setImageIndex: (index: number) => void;\n}\n\nexport const ImageSelector: React.FC<IImageSelectorProps> = ({\n  imageClassName,\n  images,\n  imageIndex,\n  setImageIndex,\n}) => {\n  const [imageState, setImageState] = useState<\n    \"loading\" | \"error\" | \"loaded\" | \"empty\"\n  >(\"empty\");\n  const [loadDict, setLoadDict] = useState<Record<number, boolean>>({});\n  const [currentImage, setCurrentImage] = useState<string>(\"\");\n\n  useEffect(() => {\n    if (imageState !== \"loading\") {\n      setCurrentImage(images[imageIndex]);\n    }\n  }, [imageState, imageIndex, images]);\n\n  const changeImage = (index: number) => {\n    setImageIndex(index);\n    if (!loadDict[index]) setImageState(\"loading\");\n  };\n\n  const setPrev = () =>\n    changeImage(imageIndex === 0 ? images.length - 1 : imageIndex - 1);\n  const setNext = () =>\n    changeImage(imageIndex === images.length - 1 ? 0 : imageIndex + 1);\n\n  const handleLoad = (index: number) => {\n    setLoadDict({\n      ...loadDict,\n      [index]: true,\n    });\n    setImageState(\"loaded\");\n  };\n  const handleError = () => setImageState(\"error\");\n\n  return (\n    <div className=\"image-selection\">\n      {images.length > 1 && (\n        <div className=\"select-buttons\">\n          <Button onClick={setPrev} disabled={images.length === 1}>\n            <Icon icon={faArrowLeft} />\n          </Button>\n          <h5 className=\"image-index\">\n            <FormattedMessage\n              id=\"index_of_total\"\n              values={{\n                index: imageIndex + 1,\n                total: images.length,\n              }}\n            />\n          </h5>\n          <Button onClick={setNext} disabled={images.length === 1}>\n            <Icon icon={faArrowRight} />\n          </Button>\n        </div>\n      )}\n\n      <div className=\"performer-image\">\n        {/* hidden image to handle loading */}\n        <img\n          src={images[imageIndex]}\n          className=\"d-none\"\n          onLoad={() => handleLoad(imageIndex)}\n          onError={handleError}\n        />\n        <img\n          src={currentImage}\n          className={cx(imageClassName, { loading: imageState === \"loading\" })}\n          alt=\"\"\n        />\n        {imageState === \"loading\" && <LoadingIndicator />}\n        {imageState === \"error\" && (\n          <div className=\"h-100 d-flex justify-content-center align-items-center\">\n            <b>\n              <FormattedMessage\n                id=\"errors.loading_type\"\n                values={{ type: \"image\" }}\n              />\n            </b>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/IndeterminateCheckbox.tsx",
    "content": "import React, { useEffect } from \"react\";\nimport { Form, FormCheckProps } from \"react-bootstrap\";\n\nconst useIndeterminate = (\n  ref: React.RefObject<HTMLInputElement>,\n  value: boolean | undefined\n) => {\n  useEffect(() => {\n    if (ref.current) {\n      // eslint-disable-next-line no-param-reassign\n      ref.current.indeterminate = value === undefined;\n    }\n  }, [ref, value]);\n};\n\ninterface IIndeterminateCheckbox extends FormCheckProps {\n  setChecked: (v: boolean | undefined) => void;\n  allowIndeterminate?: boolean;\n  indeterminateClassname?: string;\n}\n\nexport const IndeterminateCheckbox: React.FC<IIndeterminateCheckbox> = ({\n  checked,\n  setChecked,\n  allowIndeterminate,\n  indeterminateClassname,\n  ...props\n}) => {\n  const ref = React.createRef<HTMLInputElement>();\n\n  useIndeterminate(ref, checked);\n\n  function cycleState() {\n    const undefAllowed = allowIndeterminate ?? true;\n    if (undefAllowed && checked) {\n      return undefined;\n    }\n    if ((!undefAllowed && checked) || checked === undefined) {\n      return false;\n    }\n    return true;\n  }\n\n  return (\n    <Form.Check\n      {...props}\n      className={`${props.className ?? \"\"} ${\n        checked === undefined ? indeterminateClassname : \"\"\n      }`}\n      ref={ref}\n      checked={checked ?? false}\n      onChange={() => setChecked(cycleState())}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Link.tsx",
    "content": "import { useMemo } from \"react\";\nimport { Link } from \"react-router-dom\";\nimport NavUtils from \"src/utils/navigation\";\n\n// common link components\n\nexport const DirectorLink: React.FC<{\n  director: string;\n  linkType: \"scene\" | \"group\";\n}> = ({ director: director, linkType = \"scene\" }) => {\n  const link = useMemo(() => {\n    switch (linkType) {\n      case \"scene\":\n        return NavUtils.makeDirectorScenesUrl(director);\n      case \"group\":\n        return NavUtils.makeDirectorGroupsUrl(director);\n    }\n  }, [director, linkType]);\n\n  return <Link to={link}>{director}</Link>;\n};\n\nexport const PhotographerLink: React.FC<{\n  photographer: string;\n  linkType: \"gallery\" | \"image\";\n}> = ({ photographer, linkType = \"image\" }) => {\n  const link = useMemo(() => {\n    switch (linkType) {\n      case \"gallery\":\n        return NavUtils.makePhotographerGalleriesUrl(photographer);\n      case \"image\":\n        return NavUtils.makePhotographerImagesUrl(photographer);\n    }\n  }, [photographer, linkType]);\n\n  return <Link to={link}>{photographer}</Link>;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/LoadingIndicator.tsx",
    "content": "import React from \"react\";\nimport { Spinner } from \"react-bootstrap\";\nimport cx from \"classnames\";\nimport { useIntl } from \"react-intl\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface ILoadingProps {\n  message?: JSX.Element | string;\n  inline?: boolean;\n  small?: boolean;\n  card?: boolean;\n}\n\nconst CLASSNAME = \"LoadingIndicator\";\nconst CLASSNAME_MESSAGE = `${CLASSNAME}-message`;\n\nexport const LoadingIndicator: React.FC<ILoadingProps> = PatchComponent(\n  \"LoadingIndicator\",\n  ({ message, inline = false, small = false, card = false }) => {\n    const intl = useIntl();\n\n    const text = intl.formatMessage({ id: \"loading.generic\" });\n\n    return (\n      <div className={cx(CLASSNAME, { inline, small, \"card-based\": card })}>\n        <Spinner\n          animation=\"border\"\n          role=\"status\"\n          size={small ? \"sm\" : undefined}\n        >\n          <span className=\"sr-only\">{text}</span>\n        </Spinner>\n        {message !== \"\" && (\n          <h4 className={CLASSNAME_MESSAGE}>{message ?? text}</h4>\n        )}\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/MarkdownPage.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Remark } from \"react-remark\";\nimport remarkGfm from \"remark-gfm\";\n\ninterface IPageProps {\n  // page is a markdown module\n  page: string;\n}\n\nexport const MarkdownPage: React.FC<IPageProps> = ({ page }) => {\n  const [markdown, setMarkdown] = useState(\"\");\n\n  useEffect(() => {\n    if (!markdown) {\n      fetch(page)\n        .then((res) => res.text())\n        .then((text) => setMarkdown(text));\n    }\n  }, [page, markdown]);\n\n  return (\n    <div className=\"markdown\">\n      <Remark remarkPlugins={[remarkGfm]}>{markdown}</Remark>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Modal.tsx",
    "content": "import React from \"react\";\nimport { Button, Modal, Spinner, ModalProps } from \"react-bootstrap\";\nimport { ButtonVariant } from \"react-bootstrap/types\";\nimport { Icon } from \"./Icon\";\nimport { IconDefinition } from \"@fortawesome/fontawesome-svg-core\";\nimport { FormattedMessage } from \"react-intl\";\n\ninterface IButton {\n  text?: string;\n  variant?: ButtonVariant;\n  onClick?: () => void;\n}\n\ninterface IModal {\n  show: boolean;\n  onHide?: () => void;\n  header?: JSX.Element | string;\n  icon?: IconDefinition;\n  cancel?: IButton;\n  accept?: IButton;\n  isRunning?: boolean;\n  disabled?: boolean;\n  modalProps?: ModalProps;\n  dialogClassName?: string;\n  footerButtons?: React.ReactNode;\n  leftFooterButtons?: React.ReactNode;\n}\n\nconst defaultOnHide = () => {};\n\nexport const ModalComponent: React.FC<IModal> = ({\n  children,\n  show,\n  icon,\n  header,\n  cancel,\n  accept,\n  onHide,\n  isRunning,\n  disabled,\n  modalProps,\n  dialogClassName,\n  footerButtons,\n  leftFooterButtons,\n}) => (\n  <Modal\n    className=\"ModalComponent\"\n    keyboard={false}\n    onHide={onHide ?? defaultOnHide}\n    show={show}\n    dialogClassName={dialogClassName}\n    {...modalProps}\n  >\n    <Modal.Header>\n      {icon ? <Icon icon={icon} /> : \"\"}\n      <span>{header ?? \"\"}</span>\n    </Modal.Header>\n    <Modal.Body>{children}</Modal.Body>\n    <Modal.Footer className=\"ModalFooter\">\n      <div>{leftFooterButtons}</div>\n      <div>\n        {footerButtons}\n        {cancel ? (\n          <Button\n            disabled={isRunning}\n            variant={cancel.variant ?? \"primary\"}\n            onClick={cancel.onClick}\n            className=\"ml-2\"\n          >\n            {cancel.text ?? (\n              <FormattedMessage\n                id=\"actions.cancel\"\n                defaultMessage=\"Cancel\"\n                description=\"Cancels the current action and dismisses the modal.\"\n              />\n            )}\n          </Button>\n        ) : (\n          \"\"\n        )}\n        <Button\n          disabled={isRunning || disabled}\n          variant={accept?.variant ?? \"primary\"}\n          onClick={accept?.onClick}\n          className=\"ml-2\"\n        >\n          {isRunning ? (\n            <Spinner animation=\"border\" role=\"status\" size=\"sm\" />\n          ) : (\n            accept?.text ?? (\n              <FormattedMessage\n                id=\"actions.close\"\n                defaultMessage=\"Close\"\n                description=\"Closes the current modal.\"\n              />\n            )\n          )}\n        </Button>\n      </div>\n    </Modal.Footer>\n  </Modal>\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/MultiSet.tsx",
    "content": "import React from \"react\";\nimport { IntlShape, useIntl } from \"react-intl\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport { FilterSelect, SelectObject } from \"./Select\";\nimport {\n  GalleryIDSelect,\n  excludeFileBasedGalleries,\n} from \"../Galleries/GallerySelect\";\nimport { PerformerIDSelect } from \"../Performers/PerformerSelect\";\nimport { StudioIDSelect } from \"../Studios/StudioSelect\";\nimport { TagIDSelect } from \"../Tags/TagSelect\";\nimport { GroupIDSelect } from \"../Groups/GroupSelect\";\nimport { SceneIDSelect } from \"../Scenes/SceneSelect\";\n\ninterface IMultiSetProps {\n  type: \"performers\" | \"studios\" | \"tags\" | \"groups\" | \"galleries\" | \"scenes\";\n  existingIds?: string[];\n  ids?: string[];\n  mode: GQL.BulkUpdateIdMode;\n  disabled?: boolean;\n  onUpdate: (ids: string[]) => void;\n  onSetMode: (mode: GQL.BulkUpdateIdMode) => void;\n  menuPortalTarget?: HTMLElement | null;\n}\n\nconst Select: React.FC<IMultiSetProps> = (props) => {\n  const { type, disabled } = props;\n\n  function onUpdate(items: SelectObject[]) {\n    props.onUpdate(items.map((i) => i.id));\n  }\n\n  switch (type) {\n    case \"performers\":\n      return (\n        <PerformerIDSelect\n          isDisabled={disabled}\n          isMulti\n          isClearable={false}\n          onSelect={onUpdate}\n          ids={props.ids ?? []}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      );\n    case \"studios\":\n      return (\n        <StudioIDSelect\n          isDisabled={disabled}\n          isMulti\n          isClearable={false}\n          onSelect={onUpdate}\n          ids={props.ids ?? []}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      );\n    case \"tags\":\n      return (\n        <TagIDSelect\n          isDisabled={disabled}\n          isMulti\n          isClearable={false}\n          onSelect={onUpdate}\n          ids={props.ids ?? []}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      );\n    case \"groups\":\n      return (\n        <GroupIDSelect\n          isDisabled={disabled}\n          isMulti\n          isClearable={false}\n          onSelect={onUpdate}\n          ids={props.ids ?? []}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      );\n    case \"galleries\":\n      return (\n        <GalleryIDSelect\n          isDisabled={disabled}\n          isMulti\n          isClearable={false}\n          onSelect={onUpdate}\n          ids={props.ids ?? []}\n          // exclude file-based galleries when setting galleries\n          extraCriteria={excludeFileBasedGalleries}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      );\n    case \"scenes\":\n      return (\n        <SceneIDSelect\n          isDisabled={disabled}\n          isMulti\n          isClearable={false}\n          onSelect={onUpdate}\n          ids={props.ids ?? []}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      );\n    default:\n      return (\n        <FilterSelect\n          type={type}\n          isDisabled={disabled}\n          isMulti\n          isClearable={false}\n          onSelect={onUpdate}\n          ids={props.ids ?? []}\n          menuPortalTarget={props.menuPortalTarget}\n        />\n      );\n  }\n};\n\nfunction getModeText(intl: IntlShape, mode: GQL.BulkUpdateIdMode) {\n  switch (mode) {\n    case GQL.BulkUpdateIdMode.Set:\n      return intl.formatMessage({\n        id: \"actions.overwrite\",\n        defaultMessage: \"Overwrite\",\n      });\n    case GQL.BulkUpdateIdMode.Add:\n      return intl.formatMessage({ id: \"actions.add\", defaultMessage: \"Add\" });\n    case GQL.BulkUpdateIdMode.Remove:\n      return intl.formatMessage({\n        id: \"actions.remove\",\n        defaultMessage: \"Remove\",\n      });\n  }\n}\n\nexport const MultiSetModeButton: React.FC<{\n  mode: GQL.BulkUpdateIdMode;\n  active: boolean;\n  onClick: () => void;\n  disabled?: boolean;\n}> = ({ mode, active, onClick, disabled }) => {\n  const intl = useIntl();\n\n  return (\n    <Button\n      key={mode}\n      variant=\"primary\"\n      active={active}\n      size=\"sm\"\n      onClick={onClick}\n      disabled={disabled}\n    >\n      {getModeText(intl, mode)}\n    </Button>\n  );\n};\n\nconst modes = [\n  GQL.BulkUpdateIdMode.Set,\n  GQL.BulkUpdateIdMode.Add,\n  GQL.BulkUpdateIdMode.Remove,\n];\n\nexport const MultiSetModeButtons: React.FC<{\n  mode: GQL.BulkUpdateIdMode;\n  onSetMode: (mode: GQL.BulkUpdateIdMode) => void;\n  disabled?: boolean;\n}> = ({ mode, onSetMode, disabled }) => {\n  return (\n    <ButtonGroup className=\"button-group-above\">\n      {modes.map((m) => (\n        <MultiSetModeButton\n          key={m}\n          mode={m}\n          active={mode === m}\n          onClick={() => onSetMode(m)}\n          disabled={disabled}\n        />\n      ))}\n    </ButtonGroup>\n  );\n};\n\nexport const MultiSet: React.FC<IMultiSetProps> = (props) => {\n  const { mode, onUpdate, existingIds } = props;\n\n  function onSetMode(m: GQL.BulkUpdateIdMode) {\n    if (m === mode) {\n      return;\n    }\n\n    // if going to Set, set the existing ids\n    if (m === GQL.BulkUpdateIdMode.Set && existingIds) {\n      onUpdate(existingIds);\n      // if going from Set, wipe the ids\n    } else if (\n      m !== GQL.BulkUpdateIdMode.Set &&\n      mode === GQL.BulkUpdateIdMode.Set\n    ) {\n      onUpdate([]);\n    }\n\n    props.onSetMode(m);\n  }\n\n  return (\n    <div className=\"multi-set\">\n      <MultiSetModeButtons mode={mode} onSetMode={onSetMode} />\n      <Select {...props} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/OperationButton.tsx",
    "content": "import React, { useState, useRef, useEffect } from \"react\";\nimport { Button, ButtonProps } from \"react-bootstrap\";\nimport { LoadingIndicator } from \"./LoadingIndicator\";\n\ninterface IOperationButton extends ButtonProps {\n  operation?: () => Promise<void>;\n  loading?: boolean;\n  hideChildrenWhenLoading?: boolean;\n  setLoading?: (v: boolean) => void;\n}\n\nexport const OperationButton: React.FC<IOperationButton> = (props) => {\n  const [internalLoading, setInternalLoading] = useState(false);\n  const mounted = useRef(false);\n\n  const {\n    operation,\n    loading: externalLoading,\n    hideChildrenWhenLoading = false,\n    setLoading: setExternalLoading,\n    ...withoutExtras\n  } = props;\n\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n\n  const setLoading = setExternalLoading || setInternalLoading;\n  const loading =\n    externalLoading !== undefined ? externalLoading : internalLoading;\n\n  async function handleClick() {\n    if (operation && !loading) {\n      setLoading(true);\n      await operation();\n\n      if (mounted.current) {\n        setLoading(false);\n      }\n    }\n  }\n\n  return (\n    <Button onClick={handleClick} {...withoutExtras}>\n      {loading && (\n        <span className=\"mr-2\">\n          <LoadingIndicator message=\"\" inline small />\n        </span>\n      )}\n      {(!loading || !hideChildrenWhenLoading) && props.children}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/PackageManager/PackageManager.tsx",
    "content": "import { Button, Form, Table } from \"react-bootstrap\";\nimport React, { useState, useMemo, useEffect } from \"react\";\nimport { FormattedMessage, IntlShape, useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"../Icon\";\nimport cx from \"classnames\";\nimport {\n  faAnglesUp,\n  faChevronDown,\n  faChevronRight,\n  faRotate,\n  faWarning,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { SettingModal } from \"src/components/Settings/Inputs\";\nimport * as yup from \"yup\";\nimport { FormikErrors, yupToFormErrors } from \"formik\";\nimport { AlertModal } from \"../Alert\";\nimport { LoadingIndicator } from \"../LoadingIndicator\";\nimport { ApolloError } from \"@apollo/client\";\nimport { ClearableInput } from \"../ClearableInput\";\n\nfunction packageKey(\n  pkg: Pick<GQL.Package, \"package_id\" | \"sourceURL\">\n): string {\n  return `${pkg.sourceURL}-${pkg.package_id}`;\n}\n\nfunction displayVersion(intl: IntlShape, version: string | undefined | null) {\n  if (!version) return intl.formatMessage({ id: \"package_manager.unknown\" });\n\n  return version;\n}\n\nfunction displayDate(intl: IntlShape, date: string | undefined | null) {\n  if (!date) return;\n\n  const d = new Date(date);\n\n  return `${intl.formatDate(d, {\n    timeZone: \"utc\",\n  })} ${intl.formatTime(d, {\n    timeZone: \"utc\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n    second: \"numeric\",\n  })}`;\n}\n\ninterface IPackage {\n  package_id: string;\n  name: string;\n}\n\nfunction filterPackages<T extends IPackage>(packages: T[], filter: string) {\n  if (!filter) return packages;\n\n  return packages.filter((pkg) => {\n    return (\n      pkg.name.toLowerCase().includes(filter.toLowerCase()) ||\n      pkg.package_id.toLowerCase().includes(filter.toLowerCase())\n    );\n  });\n}\n\nexport type InstalledPackage = Omit<GQL.Package, \"requires\">;\n\nfunction hasUpgrade(pkg: InstalledPackage) {\n  if (!pkg.date || !pkg.source_package?.date) return false;\n\n  const pkgDate = new Date(pkg.date);\n  const upgradeDate = new Date(pkg.source_package.date);\n  return upgradeDate > pkgDate;\n}\n\nconst InstalledPackageRow: React.FC<{\n  loading?: boolean;\n  pkg: InstalledPackage;\n  selected: boolean;\n  togglePackage: () => void;\n  updatesLoaded: boolean;\n}> = ({ loading, pkg, selected, togglePackage, updatesLoaded }) => {\n  const intl = useIntl();\n\n  const updateAvailable = useMemo(() => {\n    if (!updatesLoaded) return false;\n    return hasUpgrade(pkg);\n  }, [updatesLoaded, pkg]);\n\n  return (\n    <tr className={cx({ \"package-update-available\": updateAvailable })}>\n      <td>\n        <Form.Check\n          checked={selected}\n          disabled={loading}\n          onChange={() => togglePackage()}\n        />\n      </td>\n      <td>\n        <span className=\"package-name\">{pkg.name}</span>\n        <span className=\"package-id\">{pkg.package_id}</span>\n      </td>\n      <td>\n        <span className=\"package-version\">\n          {displayVersion(intl, pkg.version)}\n        </span>\n        <span className=\"package-date\">{displayDate(intl, pkg.date)}</span>\n      </td>\n      {updatesLoaded && pkg.source_package && (\n        <td>\n          <span className=\"package-latest-version\">\n            {displayVersion(intl, pkg.source_package.version)}\n            {updateAvailable && <Icon icon={faAnglesUp} />}\n          </span>\n          <span className=\"package-latest-date\">\n            {displayDate(intl, pkg.source_package.date)}\n          </span>\n        </td>\n      )}\n    </tr>\n  );\n};\n\nconst InstalledPackagesList: React.FC<{\n  filter: string;\n  loading?: boolean;\n  error?: string;\n  updatesLoaded: boolean;\n  packages: InstalledPackage[];\n  checkedPackages: InstalledPackage[];\n  setCheckedPackages: React.Dispatch<React.SetStateAction<InstalledPackage[]>>;\n  upgradableOnly: boolean;\n}> = ({\n  filter,\n  packages,\n  checkedPackages,\n  setCheckedPackages,\n  updatesLoaded,\n  loading,\n  error,\n  upgradableOnly,\n}) => {\n  const checkedMap = useMemo(() => {\n    const map: Record<string, boolean> = {};\n    for (const pkg of checkedPackages) {\n      map[packageKey(pkg)] = true;\n    }\n    return map;\n  }, [checkedPackages]);\n\n  const allChecked = useMemo(() => {\n    return packages.length > 0 && checkedPackages.length === packages.length;\n  }, [checkedPackages, packages]);\n\n  const filteredPackages = useMemo(() => {\n    return filterPackages(packages, filter).filter((pkg) => {\n      return !updatesLoaded || !upgradableOnly || hasUpgrade(pkg);\n    });\n  }, [packages, filter, updatesLoaded, upgradableOnly]);\n\n  function toggleAllChecked() {\n    setCheckedPackages(allChecked ? [] : packages.slice());\n  }\n\n  function togglePackage(pkg: InstalledPackage) {\n    if (loading) return;\n\n    setCheckedPackages((prev) => {\n      if (prev.includes(pkg)) {\n        return prev.filter((n) => packageKey(n) !== packageKey(pkg));\n      } else {\n        return [...prev, pkg];\n      }\n    });\n  }\n\n  function renderBody() {\n    if (error) {\n      return (\n        <tr>\n          <td />\n          <td colSpan={1000} className=\"source-error\">\n            <Icon icon={faWarning} />\n            <span>{error}</span>\n          </td>\n        </tr>\n      );\n    }\n\n    if (filteredPackages.length === 0) {\n      const id = upgradableOnly\n        ? \"package_manager.no_upgradable\"\n        : \"package_manager.no_packages\";\n      return (\n        <tr className=\"package-manager-no-results\">\n          <td colSpan={1000}>\n            <FormattedMessage id={id} />\n          </td>\n        </tr>\n      );\n    }\n\n    return filteredPackages.map((pkg) => (\n      <InstalledPackageRow\n        key={packageKey(pkg)}\n        loading={loading}\n        pkg={pkg}\n        selected={checkedMap[packageKey(pkg)] ?? false}\n        togglePackage={() => togglePackage(pkg)}\n        updatesLoaded={updatesLoaded}\n      />\n    ));\n  }\n\n  return (\n    <div className=\"package-manager-table-container\">\n      <Table>\n        <thead>\n          <tr>\n            <th className=\"check-cell\">\n              <Form.Check\n                checked={allChecked ?? false}\n                onChange={toggleAllChecked}\n                disabled={loading && packages.length > 0}\n              />\n            </th>\n            <th>\n              <FormattedMessage id=\"package_manager.package\" />\n            </th>\n            <th>\n              <FormattedMessage id=\"package_manager.installed_version\" />\n            </th>\n            {updatesLoaded ? (\n              <th>\n                <FormattedMessage id=\"package_manager.latest_version\" />\n              </th>\n            ) : undefined}\n          </tr>\n          <tr>\n            <th className=\"border-row\" colSpan={100}></th>\n          </tr>\n        </thead>\n        <tbody>{renderBody()}</tbody>\n      </Table>\n    </div>\n  );\n};\n\nconst InstalledPackagesToolbar: React.FC<{\n  loading?: boolean;\n  filter: string;\n  setFilter: (s: string) => void;\n  checkedPackages: InstalledPackage[];\n  onCheckForUpdates: () => void;\n  onUpdatePackages: () => void;\n  onUninstallPackages: () => void;\n\n  upgradableOnly: boolean;\n  setUpgradableOnly: (v: boolean) => void;\n}> = ({\n  loading,\n  checkedPackages,\n  onCheckForUpdates,\n  onUpdatePackages,\n  onUninstallPackages,\n  filter,\n  setFilter,\n  upgradableOnly,\n  setUpgradableOnly,\n}) => {\n  const intl = useIntl();\n\n  return (\n    <div className=\"package-manager-toolbar\">\n      <ClearableInput\n        placeholder={`${intl.formatMessage({ id: \"filter\" })}...`}\n        value={filter}\n        setValue={(v) => setFilter(v)}\n      />\n      {upgradableOnly && (\n        <Button\n          size=\"sm\"\n          variant=\"primary\"\n          onClick={() => setUpgradableOnly(!upgradableOnly)}\n        >\n          <FormattedMessage id=\"package_manager.show_all\" />\n        </Button>\n      )}\n      <div className=\"flex-grow-1\" />\n      <Button\n        variant=\"primary\"\n        onClick={() => onCheckForUpdates()}\n        disabled={loading}\n      >\n        <FormattedMessage id=\"package_manager.check_for_updates\" />\n      </Button>\n      <Button\n        variant=\"primary\"\n        disabled={!checkedPackages.length || loading}\n        onClick={() => onUpdatePackages()}\n      >\n        <FormattedMessage id=\"package_manager.update\" />\n      </Button>\n      <Button\n        variant=\"danger\"\n        disabled={!checkedPackages.length || loading}\n        onClick={() => onUninstallPackages()}\n      >\n        <FormattedMessage id=\"package_manager.uninstall\" />\n      </Button>\n    </div>\n  );\n};\n\nexport const InstalledPackages: React.FC<{\n  loading?: boolean;\n  error?: string;\n  packages: InstalledPackage[];\n  updatesLoaded: boolean;\n  onCheckForUpdates: () => void;\n  onUpdatePackages: (packages: InstalledPackage[]) => void;\n  onUninstallPackages: (packages: InstalledPackage[]) => void;\n}> = ({\n  packages,\n  onCheckForUpdates,\n  updatesLoaded,\n  onUpdatePackages,\n  onUninstallPackages,\n  loading,\n  error,\n}) => {\n  const [checkedPackages, setCheckedPackages] = useState<InstalledPackage[]>(\n    []\n  );\n  const [filter, setFilter] = useState(\"\");\n  const [upgradableOnly, setUpgradableOnly] = useState(true);\n  const [uninstalling, setUninstalling] = useState(false);\n\n  // sort packages so that those with updates are at the top\n  const sortedPackages = useMemo(() => {\n    return packages.slice().sort((a, b) => {\n      const aHasUpdate = hasUpgrade(a);\n      const bHasUpdate = hasUpgrade(b);\n\n      if (aHasUpdate && !bHasUpdate) return -1;\n      if (!aHasUpdate && bHasUpdate) return 1;\n\n      // sort by name\n      return a.package_id.localeCompare(b.package_id);\n    });\n  }, [packages]);\n\n  const filteredPackages = useMemo(() => {\n    return filterPackages(checkedPackages, filter).filter((pkg) => {\n      return !updatesLoaded || !upgradableOnly || hasUpgrade(pkg);\n    });\n  }, [checkedPackages, filter, updatesLoaded, upgradableOnly]);\n\n  useEffect(() => {\n    setCheckedPackages((prev) => {\n      const newVal = prev.filter((pkg) =>\n        packages.find((p) => packageKey(p) === packageKey(pkg))\n      );\n      if (newVal.length !== prev.length) {\n        return newVal;\n      }\n\n      return prev;\n    });\n  }, [checkedPackages, packages]);\n\n  function confirmUninstall() {\n    onUninstallPackages(filteredPackages);\n    setUninstalling(false);\n  }\n\n  function checkForUpdates() {\n    // reset to only show upgradable packages\n    setUpgradableOnly(true);\n    onCheckForUpdates();\n  }\n\n  return (\n    <>\n      <AlertModal\n        show={!!uninstalling}\n        text={\n          <FormattedMessage\n            id=\"package_manager.confirm_uninstall\"\n            values={{ number: filteredPackages.length }}\n          />\n        }\n        onConfirm={() => confirmUninstall()}\n        onCancel={() => setUninstalling(false)}\n      />\n      <div className=\"installed-packages\">\n        <InstalledPackagesToolbar\n          filter={filter}\n          setFilter={(f) => setFilter(f)}\n          loading={loading}\n          checkedPackages={filteredPackages}\n          onCheckForUpdates={() => checkForUpdates()}\n          onUpdatePackages={() => onUpdatePackages(filteredPackages)}\n          onUninstallPackages={() => setUninstalling(true)}\n          upgradableOnly={updatesLoaded && upgradableOnly}\n          setUpgradableOnly={(v) => setUpgradableOnly(v)}\n        />\n        <InstalledPackagesList\n          filter={filter}\n          loading={loading}\n          error={error}\n          packages={sortedPackages}\n          // use original checked packages so that check boxes are not affected by filter\n          checkedPackages={checkedPackages}\n          setCheckedPackages={setCheckedPackages}\n          updatesLoaded={updatesLoaded}\n          upgradableOnly={upgradableOnly}\n        />\n      </div>\n    </>\n  );\n};\n\nconst AvailablePackagesToolbar: React.FC<{\n  filter: string;\n  setFilter: (s: string) => void;\n  loading?: boolean;\n  hasSelectedPackages: boolean;\n  onInstallPackages: () => void;\n\n  selectedOnly: boolean;\n  setSelectedOnly: (v: boolean) => void;\n}> = ({\n  hasSelectedPackages,\n  onInstallPackages,\n  loading,\n  filter,\n  setFilter,\n  selectedOnly,\n  setSelectedOnly,\n}) => {\n  const intl = useIntl();\n\n  const selectedOnlyId = !selectedOnly\n    ? \"package_manager.hide_unselected\"\n    : \"package_manager.show_all\";\n\n  return (\n    <div className=\"package-manager-toolbar\">\n      <ClearableInput\n        placeholder={`${intl.formatMessage({ id: \"filter\" })}...`}\n        value={filter}\n        setValue={(v) => setFilter(v)}\n      />\n      {hasSelectedPackages && (\n        <Button\n          size=\"sm\"\n          variant=\"primary\"\n          onClick={() => setSelectedOnly(!selectedOnly)}\n        >\n          <FormattedMessage id={selectedOnlyId} />\n        </Button>\n      )}\n      <div className=\"flex-grow-1\" />\n      <Button\n        variant=\"primary\"\n        disabled={!hasSelectedPackages || loading}\n        onClick={() => onInstallPackages()}\n      >\n        <FormattedMessage id=\"package_manager.install\" />\n      </Button>\n    </div>\n  );\n};\n\nconst EditSourceModal: React.FC<{\n  sources: GQL.PackageSource[];\n  existing?: GQL.PackageSource;\n  onClose: (source?: GQL.PackageSource) => void;\n}> = ({ existing, sources, onClose }) => {\n  const intl = useIntl();\n\n  const schema = yup.object({\n    name: yup\n      .string()\n      .required()\n      .test({\n        name: \"name\",\n        test: (value) => {\n          if (!value) return true;\n          const found = sources.find((s) => s.name === value);\n          return !found || found === existing;\n        },\n        message: intl.formatMessage({ id: \"validation.unique\" }),\n      }),\n    url: yup\n      .string()\n      .required()\n      .test({\n        name: \"url\",\n        test: (value) => {\n          if (!value) return true;\n          const found = sources.find((s) => s.url === value);\n          return !found || found === existing;\n        },\n        message: intl.formatMessage({ id: \"validation.unique\" }),\n      }),\n    local_path: yup.string().nullable(),\n  });\n\n  type InputValues = yup.InferType<typeof schema>;\n  function validate(\n    v: GQL.PackageSource | undefined\n  ): FormikErrors<InputValues> | undefined {\n    try {\n      schema.validateSync(v, { abortEarly: false });\n    } catch (e) {\n      return yupToFormErrors(e);\n    }\n  }\n\n  const headerID = !!existing\n    ? \"package_manager.edit_source\"\n    : \"package_manager.add_source\";\n\n  function renderField(\n    v: GQL.PackageSource | undefined,\n    setValue: (v: GQL.PackageSource | undefined) => void\n  ) {\n    const errors = validate(v);\n\n    return (\n      <>\n        <Form.Group id=\"package-source-name\">\n          <h6>\n            <FormattedMessage id=\"package_manager.source.name\" />\n          </h6>\n          <Form.Control\n            placeholder={intl.formatMessage({\n              id: \"package_manager.source.name\",\n            })}\n            className=\"text-input\"\n            value={v?.name ?? \"\"}\n            isInvalid={!!errors?.name}\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n              setValue({ ...v!, name: e.currentTarget.value })\n            }\n          />\n          <Form.Control.Feedback type=\"invalid\">\n            {errors?.name}\n          </Form.Control.Feedback>\n        </Form.Group>\n\n        <Form.Group id=\"package-source-url\">\n          <h6>\n            <FormattedMessage id=\"package_manager.source.url\" />\n          </h6>\n          <Form.Control\n            placeholder={intl.formatMessage({\n              id: \"package_manager.source.url\",\n            })}\n            className=\"text-input\"\n            value={v?.url}\n            isInvalid={!!errors?.url}\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n              setValue({ ...v!, url: e.currentTarget.value.trim() })\n            }\n          />\n          <Form.Control.Feedback type=\"invalid\">\n            {errors?.url}\n          </Form.Control.Feedback>\n        </Form.Group>\n\n        <Form.Group id=\"package-source-name\">\n          <h6>\n            <FormattedMessage id=\"package_manager.source.local_path.heading\" />\n          </h6>\n          <Form.Control\n            placeholder={intl.formatMessage({\n              id: \"package_manager.source.local_path.heading\",\n            })}\n            className=\"text-input\"\n            value={v?.local_path ?? \"\"}\n            isInvalid={!!errors?.local_path}\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n              setValue({ ...v!, local_path: e.currentTarget.value })\n            }\n          />\n          <div className=\"sub-heading\">\n            <FormattedMessage id=\"package_manager.source.local_path.description\" />\n          </div>\n          <Form.Control.Feedback type=\"invalid\">\n            {errors?.local_path}\n          </Form.Control.Feedback>\n        </Form.Group>\n      </>\n    );\n  }\n\n  return (\n    <SettingModal<GQL.PackageSource>\n      headingID={headerID}\n      value={existing ?? { url: \"\", name: \"\" }}\n      validate={(v) => validate(v) === undefined}\n      renderField={renderField}\n      close={onClose}\n    />\n  );\n};\n\nexport type RemotePackage = Omit<GQL.Package, \"requires\"> & {\n  requires: { package_id: string }[];\n};\n\nconst AvailablePackageRow: React.FC<{\n  disabled?: boolean;\n  pkg: RemotePackage;\n  requiredBy: RemotePackage[];\n  selected: boolean;\n  togglePackage: () => void;\n  renderDescription?: (pkg: RemotePackage) => React.ReactNode;\n}> = ({\n  disabled,\n  pkg,\n  requiredBy,\n  selected,\n  togglePackage,\n  renderDescription = () => undefined,\n}) => {\n  const intl = useIntl();\n\n  function renderRequiredBy() {\n    if (!requiredBy.length) return;\n\n    return (\n      <div className=\"package-required-by\">\n        <FormattedMessage\n          id=\"package_manager.required_by\"\n          values={{ packages: requiredBy.map((p) => p.name).join(\", \") }}\n        />\n      </div>\n    );\n  }\n\n  return (\n    <tr>\n      <td colSpan={2}>\n        <Form.Check\n          checked={selected ?? false}\n          onChange={() => togglePackage()}\n          disabled={disabled}\n        />\n      </td>\n      <td className=\"package-cell\" onClick={() => togglePackage()}>\n        <span className=\"package-name\">{pkg.name}</span>\n        <span className=\"package-id\">{pkg.package_id}</span>\n      </td>\n      <td>\n        <span className=\"package-version\">\n          {displayVersion(intl, pkg.version)}\n        </span>\n        <span className=\"package-date\">{displayDate(intl, pkg.date)}</span>\n      </td>\n      <td>\n        {renderRequiredBy()}\n        <div>{renderDescription(pkg)}</div>\n      </td>\n    </tr>\n  );\n};\n\nconst SourcePackagesList: React.FC<{\n  filter: string;\n  disabled?: boolean;\n  source: GQL.PackageSource;\n  loadSource: () => Promise<RemotePackage[]>;\n  selectedOnly: boolean;\n  selectedPackages: RemotePackage[];\n  allowSelectAll?: boolean;\n  setSelectedPackages: React.Dispatch<React.SetStateAction<RemotePackage[]>>;\n  renderDescription?: (pkg: RemotePackage) => React.ReactNode;\n  editSource: () => void;\n  deleteSource: () => void;\n}> = ({\n  source,\n  loadSource,\n  allowSelectAll,\n  selectedOnly,\n  selectedPackages,\n  setSelectedPackages,\n  disabled,\n  filter,\n  renderDescription,\n  editSource,\n  deleteSource,\n}) => {\n  const intl = useIntl();\n  const [packages, setPackages] = useState<RemotePackage[]>();\n  const [sourceOpen, setSourceOpen] = useState(false);\n  const [loading, setLoading] = useState(false);\n  const [loadError, setLoadError] = useState<string>();\n\n  const checkedMap = useMemo(() => {\n    const map: Record<string, boolean> = {};\n\n    selectedPackages.forEach((pkg) => {\n      map[pkg.package_id] = true;\n    });\n    return map;\n  }, [selectedPackages]);\n\n  const sourceChecked = useMemo(() => {\n    return packages && Object.keys(checkedMap).length === packages.length;\n  }, [checkedMap, packages]);\n\n  const filteredPackages = useMemo(() => {\n    if (!packages) return [];\n\n    let ret = filterPackages(packages, filter);\n\n    if (selectedOnly) {\n      ret = ret.filter((pkg) => checkedMap[pkg.package_id]);\n    }\n\n    return ret;\n  }, [filter, packages, selectedOnly, checkedMap]);\n\n  function toggleSource() {\n    if (disabled || packages === undefined) return;\n\n    if (sourceChecked) {\n      setSelectedPackages([]);\n    } else {\n      setSelectedPackages(packages.slice());\n    }\n  }\n\n  async function loadPackages() {\n    // need to load\n    setLoading(true);\n    setLoadError(undefined);\n    try {\n      const loaded = await loadSource();\n      setPackages(loaded);\n    } catch (e) {\n      setLoadError((e as ApolloError).message);\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  function toggleSourceOpen() {\n    if (sourceOpen) {\n      setLoadError(undefined);\n      setSourceOpen(false);\n    } else {\n      if (packages === undefined) {\n        loadPackages();\n      }\n      setSourceOpen(true);\n    }\n  }\n\n  function renderContents() {\n    if (loading) {\n      return (\n        <tr>\n          <td colSpan={2}></td>\n          <td colSpan={3}>\n            <LoadingIndicator inline small />\n          </td>\n        </tr>\n      );\n    }\n\n    if (loadError) {\n      return (\n        <tr>\n          <td colSpan={2}></td>\n          <td colSpan={3} className=\"source-error\">\n            <Icon icon={faWarning} />\n            <span>{loadError}</span>\n            <Button\n              size=\"sm\"\n              variant=\"secondary\"\n              onClick={() => loadPackages()}\n              title={intl.formatMessage({ id: \"actions.reload\" })}\n            >\n              <Icon icon={faRotate} />\n            </Button>\n          </td>\n        </tr>\n      );\n    }\n\n    if (!sourceOpen) {\n      return null;\n    }\n\n    function getRequiredPackages(pkg: RemotePackage) {\n      const ret: RemotePackage[] = [];\n      for (const r of pkg.requires) {\n        const found = packages?.find((p) => p.package_id === r.package_id);\n        if (found && !ret.includes(found)) {\n          ret.push(found);\n          ret.push(...getRequiredPackages(found));\n        }\n      }\n      return ret;\n    }\n\n    function togglePackage(pkg: RemotePackage) {\n      if (disabled || !packages) return;\n\n      setSelectedPackages((prev) => {\n        const selected = prev.find((p) => p.package_id === pkg.package_id);\n\n        if (selected) {\n          return prev.filter((n) => n.package_id !== pkg.package_id);\n        } else {\n          // also include required packages\n          return [...prev, pkg, ...getRequiredPackages(pkg)];\n        }\n      });\n    }\n\n    return filteredPackages.map((pkg) => (\n      <AvailablePackageRow\n        key={pkg.package_id}\n        disabled={disabled}\n        pkg={pkg}\n        requiredBy={selectedPackages.filter((p) =>\n          p.requires.some((r) => r.package_id === pkg.package_id)\n        )}\n        selected={checkedMap[pkg.package_id] ?? false}\n        togglePackage={() => togglePackage(pkg)}\n        renderDescription={renderDescription}\n      />\n    ));\n  }\n\n  return (\n    <>\n      <tr className=\"package-source\">\n        <td>\n          {allowSelectAll && packages !== undefined ? (\n            <Form.Check\n              checked={sourceChecked ?? false}\n              onChange={() => toggleSource()}\n              disabled={disabled}\n            />\n          ) : undefined}\n        </td>\n        <td className=\"source-collapse\">\n          <Button\n            variant=\"minimal\"\n            size=\"sm\"\n            onClick={() => toggleSourceOpen()}\n          >\n            <Icon icon={sourceOpen ? faChevronDown : faChevronRight} />\n          </Button>\n        </td>\n        <td\n          className=\"source-name\"\n          colSpan={2}\n          onClick={() => toggleSourceOpen()}\n        >\n          <span>{source.name ?? source.url}</span>\n        </td>\n        <td className=\"source-controls\">\n          <Button\n            size=\"sm\"\n            variant=\"primary\"\n            title={intl.formatMessage({ id: \"actions.edit\" })}\n            onClick={() => editSource()}\n          >\n            <FormattedMessage id=\"actions.edit\" />\n          </Button>\n          <Button\n            size=\"sm\"\n            variant=\"danger\"\n            title={intl.formatMessage({ id: \"actions.delete\" })}\n            onClick={() => deleteSource()}\n          >\n            <FormattedMessage id=\"actions.delete\" />\n          </Button>\n        </td>\n      </tr>\n      {renderContents()}\n    </>\n  );\n};\n\nconst AvailablePackagesList: React.FC<{\n  filter: string;\n  loading?: boolean;\n  sources: GQL.PackageSource[];\n  renderDescription?: (pkg: RemotePackage) => React.ReactNode;\n  loadSource: (source: string) => Promise<RemotePackage[]>;\n  selectedPackages: Record<string, RemotePackage[]>; // map of source url to selected packages\n  setSelectedPackages: React.Dispatch<\n    React.SetStateAction<Record<string, RemotePackage[]>>\n  >;\n  selectedOnly: boolean;\n  allowSourceSelectAll?: boolean;\n  addSource: (src: GQL.PackageSource) => void;\n  editSource: (existing: GQL.PackageSource, changed: GQL.PackageSource) => void;\n  deleteSource: (source: GQL.PackageSource) => void;\n}> = ({\n  sources,\n  loadSource,\n  selectedPackages,\n  setSelectedPackages,\n  loading,\n  filter,\n  renderDescription,\n  selectedOnly,\n  addSource,\n  editSource,\n  deleteSource,\n  allowSourceSelectAll,\n}) => {\n  const [deletingSource, setDeletingSource] = useState<GQL.PackageSource>();\n  const [editingSource, setEditingSource] = useState<GQL.PackageSource>();\n  const [addingSource, setAddingSource] = useState(false);\n\n  function onDeleteSource() {\n    if (!deletingSource) return;\n\n    deleteSource(deletingSource);\n    setDeletingSource(undefined);\n  }\n\n  function setSelectedSourcePackages(\n    src: GQL.PackageSource,\n    v: RemotePackage[] | ((prevState: RemotePackage[]) => RemotePackage[])\n  ) {\n    setSelectedPackages((prev) => {\n      const existing = prev[src.url] ?? [];\n      const next = typeof v === \"function\" ? v(existing) : v;\n\n      return {\n        ...prev,\n        [src.url]: next,\n      };\n    });\n  }\n\n  function renderBody() {\n    if (sources.length === 0) {\n      return (\n        <tr className=\"package-manager-no-results\">\n          <td colSpan={5}>\n            <FormattedMessage id=\"package_manager.no_sources\" />\n            <br />\n            <Button\n              size=\"sm\"\n              variant=\"success\"\n              onClick={() => setAddingSource(true)}\n            >\n              <FormattedMessage id=\"package_manager.add_source\" />\n            </Button>\n          </td>\n        </tr>\n      );\n    }\n\n    return (\n      <>\n        {sources.map((src) => (\n          <SourcePackagesList\n            key={src.url}\n            filter={filter}\n            disabled={loading}\n            source={src}\n            renderDescription={renderDescription}\n            loadSource={() => loadSource(src.url)}\n            selectedOnly={selectedOnly}\n            selectedPackages={selectedPackages[src.url] ?? []}\n            setSelectedPackages={(v) => setSelectedSourcePackages(src, v)}\n            editSource={() => setEditingSource(src)}\n            deleteSource={() => setDeletingSource(src)}\n            allowSelectAll={allowSourceSelectAll}\n          />\n        ))}\n        <tr className=\"add-package-source\">\n          <td colSpan={2}></td>\n          <td colSpan={3}>\n            <Button\n              size=\"sm\"\n              variant=\"success\"\n              onClick={() => setAddingSource(true)}\n            >\n              <FormattedMessage id=\"package_manager.add_source\" />\n            </Button>\n          </td>\n        </tr>\n      </>\n    );\n  }\n\n  return (\n    <>\n      <AlertModal\n        show={!!deletingSource}\n        text={\n          <FormattedMessage\n            id=\"package_manager.confirm_delete_source\"\n            values={{ name: deletingSource?.name, url: deletingSource?.url }}\n          />\n        }\n        onConfirm={() => onDeleteSource()}\n        onCancel={() => setDeletingSource(undefined)}\n      />\n\n      {editingSource || addingSource ? (\n        <EditSourceModal\n          sources={sources}\n          existing={editingSource}\n          onClose={(v) => {\n            if (v) {\n              if (addingSource) addSource(v);\n              else if (editingSource) editSource(editingSource, v);\n            }\n            setEditingSource(undefined);\n            setAddingSource(false);\n          }}\n        />\n      ) : undefined}\n\n      <div className=\"package-manager-table-container\">\n        <Table>\n          <thead>\n            <tr>\n              <th className=\"check-cell\"></th>\n              <th className=\"collapse-cell\"></th>\n              <th>\n                <FormattedMessage id=\"package_manager.package\" />\n              </th>\n              <th>\n                <FormattedMessage id=\"package_manager.version\" />\n              </th>\n              <th>\n                <FormattedMessage id=\"package_manager.description\" />\n              </th>\n            </tr>\n            <tr>\n              <th className=\"border-row\" colSpan={100}></th>\n            </tr>\n          </thead>\n          <tbody>{renderBody()}</tbody>\n        </Table>\n      </div>\n    </>\n  );\n};\n\nexport const AvailablePackages: React.FC<{\n  loading?: boolean;\n  sources: GQL.PackageSource[];\n  renderDescription?: (pkg: RemotePackage) => React.ReactNode;\n  loadSource: (source: string) => Promise<RemotePackage[]>;\n  onInstallPackages: (packages: GQL.PackageSpecInput[]) => void;\n  addSource: (src: GQL.PackageSource) => void;\n  editSource: (existing: GQL.PackageSource, changed: GQL.PackageSource) => void;\n  deleteSource: (source: GQL.PackageSource) => void;\n  allowSelectAll?: boolean;\n}> = ({\n  sources,\n  loadSource,\n  onInstallPackages,\n  loading,\n  renderDescription,\n  addSource,\n  editSource,\n  deleteSource,\n  allowSelectAll,\n}) => {\n  const [checkedPackages, setCheckedPackages] = useState<\n    Record<string, RemotePackage[]>\n  >({});\n  const [filter, setFilter] = useState(\"\");\n  const [selectedOnly, setSelectedOnly] = useState(false);\n\n  const hasPackagesSelected = useMemo(() => {\n    return Object.values(checkedPackages).some((s) => s.length > 0);\n  }, [checkedPackages]);\n\n  // if no packages are selected, set selected only to false\n  useEffect(() => {\n    if (!hasPackagesSelected) {\n      setSelectedOnly(false);\n    }\n  }, [hasPackagesSelected]);\n\n  function toPackageSpecInput(): GQL.PackageSpecInput[] {\n    const ret: GQL.PackageSpecInput[] = [];\n    Object.keys(checkedPackages).forEach((sourceURL) => {\n      checkedPackages[sourceURL].forEach((pkg) => {\n        ret.push({ id: pkg.package_id, sourceURL });\n      });\n    });\n    return ret;\n  }\n\n  return (\n    <div className=\"available-packages\">\n      <AvailablePackagesToolbar\n        filter={filter}\n        setFilter={(f) => setFilter(f)}\n        loading={loading}\n        hasSelectedPackages={hasPackagesSelected}\n        onInstallPackages={() => onInstallPackages(toPackageSpecInput())}\n        selectedOnly={selectedOnly}\n        setSelectedOnly={(v) => setSelectedOnly(v)}\n      />\n      <AvailablePackagesList\n        filter={filter}\n        loading={loading}\n        sources={sources}\n        renderDescription={renderDescription}\n        loadSource={loadSource}\n        selectedOnly={selectedOnly}\n        selectedPackages={checkedPackages}\n        setSelectedPackages={setCheckedPackages}\n        addSource={addSource}\n        editSource={editSource}\n        deleteSource={deleteSource}\n        allowSourceSelectAll={allowSelectAll}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/PackageManager/styles.scss",
    "content": ".package-manager {\n  padding: 1em;\n\n  .package-source {\n    font-weight: bold;\n\n    .source-collapse {\n      padding-left: 0;\n      padding-right: 0;\n\n      .btn {\n        color: $text-color;\n      }\n    }\n\n    .source-controls {\n      align-items: center;\n      display: flex;\n      gap: 0.5rem;\n      justify-content: end;\n    }\n  }\n\n  .package-cell,\n  .package-source {\n    cursor: pointer;\n  }\n\n  .package-manager-table-container {\n    max-height: 300px;\n    overflow-y: auto;\n\n    th {\n      border: none;\n    }\n  }\n\n  table thead {\n    background-color: $card-bg;\n    position: sticky;\n    top: 0;\n    z-index: 1;\n\n    .check-cell {\n      width: 40px;\n    }\n\n    .collapse-cell {\n      width: 30px;\n    }\n  }\n\n  table td {\n    vertical-align: middle;\n  }\n\n  .package-name,\n  .package-id,\n  .package-version,\n  .package-date,\n  .package-latest-version,\n  .package-latest-date {\n    display: block;\n  }\n\n  .package-id,\n  .package-date,\n  .package-latest-date {\n    color: $muted-gray;\n    font-size: 0.8rem;\n  }\n\n  .package-id,\n  .package-version,\n  .package-date,\n  .package-latest-version,\n  .package-latest-date {\n    white-space: nowrap;\n  }\n\n  .package-update-available {\n    .package-latest-version,\n    .package-latest-date {\n      font-weight: 700;\n    }\n  }\n\n  .package-manager-toolbar {\n    display: flex;\n    gap: 0.5rem;\n    padding-bottom: 0.25rem;\n  }\n\n  .package-required-by {\n    color: $warning;\n    font-size: 0.8rem;\n  }\n\n  .LoadingIndicator-message {\n    display: inline-block;\n    font-size: 1rem;\n    margin-left: 0.5em;\n    margin-top: 0;\n  }\n\n  .source-error {\n    & > .fa-icon {\n      color: $warning;\n    }\n\n    .btn {\n      margin-left: 0.5em;\n    }\n  }\n}\n\n.package-manager-no-results {\n  color: $text-muted;\n  padding: 1em;\n  text-align: center;\n\n  .btn {\n    margin-top: 0.5em;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/PercentInput.tsx",
    "content": "import {\n  faChevronDown,\n  faChevronUp,\n  faClock,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState, useEffect } from \"react\";\nimport { Button, ButtonGroup, InputGroup, Form } from \"react-bootstrap\";\nimport { Icon } from \"./Icon\";\nimport PercentUtils from \"src/utils/percent\";\n\ninterface IProps {\n  disabled?: boolean;\n  numericValue: number | undefined;\n  mandatory?: boolean;\n  onValueChange(\n    valueAsNumber: number | undefined,\n    valueAsString?: string\n  ): void;\n  onReset?(): void;\n  className?: string;\n  placeholder?: string;\n}\n\nexport const PercentInput: React.FC<IProps> = (props: IProps) => {\n  const [value, setValue] = useState<string | undefined>(\n    props.numericValue !== undefined\n      ? PercentUtils.numberToString(props.numericValue)\n      : undefined\n  );\n\n  useEffect(() => {\n    if (props.numericValue !== undefined || props.mandatory) {\n      setValue(PercentUtils.numberToString(props.numericValue ?? 0));\n    } else {\n      setValue(undefined);\n    }\n  }, [props.numericValue, props.mandatory]);\n\n  function increment() {\n    if (value === undefined) {\n      return;\n    }\n\n    let percent = PercentUtils.stringToNumber(value);\n    if (percent >= 100) {\n      percent = 0;\n    } else {\n      percent += 1;\n    }\n    props.onValueChange(percent, PercentUtils.numberToString(percent));\n  }\n\n  function decrement() {\n    if (value === undefined) {\n      return;\n    }\n\n    let percent = PercentUtils.stringToNumber(value);\n    if (percent <= 0) {\n      percent = 100;\n    } else {\n      percent -= 1;\n    }\n    props.onValueChange(percent, PercentUtils.numberToString(percent));\n  }\n\n  function renderButtons() {\n    if (!props.disabled) {\n      return (\n        <ButtonGroup vertical>\n          <Button\n            variant=\"secondary\"\n            className=\"percent-button\"\n            disabled={props.disabled}\n            onClick={() => increment()}\n          >\n            <Icon icon={faChevronUp} />\n          </Button>\n          <Button\n            variant=\"secondary\"\n            className=\"percent-button\"\n            disabled={props.disabled}\n            onClick={() => decrement()}\n          >\n            <Icon icon={faChevronDown} />\n          </Button>\n        </ButtonGroup>\n      );\n    }\n  }\n\n  function onReset() {\n    if (props.onReset) {\n      props.onReset();\n    }\n  }\n\n  function maybeRenderReset() {\n    if (props.onReset) {\n      return (\n        <Button variant=\"secondary\" onClick={onReset}>\n          <Icon icon={faClock} />\n        </Button>\n      );\n    }\n  }\n\n  return (\n    <div className={`percent-input ${props.className}`}>\n      <InputGroup>\n        <Form.Control\n          className=\"percent-control text-input\"\n          disabled={props.disabled}\n          value={value ?? 0}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n            setValue(e.currentTarget.value)\n          }\n          onBlur={() => {\n            if (props.mandatory || (value !== undefined && value !== \"\")) {\n              props.onValueChange(PercentUtils.stringToNumber(value), value);\n            } else {\n              props.onValueChange(undefined);\n            }\n          }}\n          placeholder={\n            !props.disabled\n              ? props.placeholder\n                ? `${props.placeholder} (%)`\n                : \"%\"\n              : undefined\n          }\n        />\n        <InputGroup.Append>\n          {maybeRenderReset()}\n          {renderButtons()}\n        </InputGroup.Append>\n      </InputGroup>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx",
    "content": "import { faUser } from \"@fortawesome/free-solid-svg-icons\";\nimport React from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { sortPerformers } from \"src/core/performers\";\nimport { HoverPopover } from \"./HoverPopover\";\nimport { Icon } from \"./Icon\";\nimport { PerformerLink, PerformerLinkType } from \"./TagLink\";\n\ninterface IProps {\n  performers: Pick<\n    GQL.Performer,\n    \"id\" | \"name\" | \"image_path\" | \"disambiguation\" | \"gender\"\n  >[];\n  linkType?: PerformerLinkType;\n}\n\nexport const PerformerPopoverButton: React.FC<IProps> = ({\n  performers,\n  linkType,\n}) => {\n  const sorted = sortPerformers(performers);\n  const popoverContent = sorted.map((performer) => (\n    <div className=\"performer-tag-container row\" key={performer.id}>\n      <Link\n        to={`/performers/${performer.id}`}\n        className=\"performer-tag col m-auto zoom-2\"\n      >\n        <img\n          className=\"image-thumbnail\"\n          alt={performer.name ?? \"\"}\n          src={performer.image_path ?? \"\"}\n        />\n      </Link>\n      <PerformerLink\n        key={performer.id}\n        performer={performer}\n        className=\"d-block\"\n        linkType={linkType}\n      />\n    </div>\n  ));\n\n  return (\n    <HoverPopover\n      className=\"performer-count\"\n      placement=\"bottom\"\n      content={popoverContent}\n    >\n      <Button className=\"minimal\">\n        <Icon icon={faUser} />\n        <span>{performers.length}</span>\n      </Button>\n    </HoverPopover>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/PopoverCountButton.tsx",
    "content": "import {\n  faFilm,\n  faImage,\n  faImages,\n  faPlayCircle,\n  faUser,\n  faVideo,\n  faMapMarkerAlt,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React from \"react\";\nimport { Button, OverlayTrigger, Tooltip } from \"react-bootstrap\";\nimport { FormattedNumber, useIntl } from \"react-intl\";\nimport { Link } from \"react-router-dom\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport TextUtils from \"src/utils/text\";\nimport { Icon } from \"./Icon\";\n\nexport const Count: React.FC<{\n  count: number;\n}> = ({ count }) => {\n  const { configuration } = useConfigurationContext();\n  const abbreviateCounter = configuration?.ui.abbreviateCounters ?? false;\n\n  if (!abbreviateCounter) {\n    return <span>{count}</span>;\n  }\n\n  const formatted = TextUtils.abbreviateCounter(count);\n\n  return (\n    <span>\n      <FormattedNumber\n        value={formatted.size}\n        maximumFractionDigits={formatted.digits}\n      />\n      {formatted.unit}\n    </span>\n  );\n};\n\ntype PopoverLinkType =\n  | \"scene\"\n  | \"image\"\n  | \"gallery\"\n  | \"marker\"\n  | \"group\"\n  | \"sub_group\"\n  | \"performer\"\n  | \"studio\";\n\ninterface IProps {\n  className?: string;\n  url: string;\n  type: PopoverLinkType;\n  count: number;\n  showZero?: boolean;\n}\n\nexport const PopoverCountButton: React.FC<IProps> = ({\n  className,\n  url,\n  type,\n  count,\n  showZero = true,\n}) => {\n  const intl = useIntl();\n\n  if (!showZero && count === 0) {\n    return null;\n  }\n\n  // TODO - refactor - create SceneIcon, ImageIcon etc components\n  function getIcon() {\n    switch (type) {\n      case \"scene\":\n        return faPlayCircle;\n      case \"image\":\n        return faImage;\n      case \"gallery\":\n        return faImages;\n      case \"marker\":\n        return faMapMarkerAlt;\n      case \"group\":\n      case \"sub_group\":\n        return faFilm;\n      case \"performer\":\n        return faUser;\n      case \"studio\":\n        return faVideo;\n    }\n  }\n\n  function getPluralOptions() {\n    switch (type) {\n      case \"scene\":\n        return {\n          one: \"scene\",\n          other: \"scenes\",\n        };\n      case \"image\":\n        return {\n          one: \"image\",\n          other: \"images\",\n        };\n      case \"gallery\":\n        return {\n          one: \"gallery\",\n          other: \"galleries\",\n        };\n      case \"marker\":\n        return {\n          one: \"marker\",\n          other: \"markers\",\n        };\n      case \"group\":\n        return {\n          one: \"group\",\n          other: \"groups\",\n        };\n      case \"sub_group\":\n        return {\n          one: \"sub_group\",\n          other: \"sub_groups\",\n        };\n      case \"performer\":\n        return {\n          one: \"performer\",\n          other: \"performers\",\n        };\n      case \"studio\":\n        return {\n          one: \"studio\",\n          other: \"studios\",\n        };\n    }\n  }\n\n  function getTitle() {\n    const pluralCategory = intl.formatPlural(count);\n    const options = getPluralOptions();\n    const plural = intl.formatMessage({\n      id: options[pluralCategory as \"one\"] || options.other,\n    });\n    return `${count} ${plural}`;\n  }\n\n  return (\n    <>\n      <OverlayTrigger\n        overlay={<Tooltip id={`${type}-count-tooltip`}>{getTitle()}</Tooltip>}\n        placement=\"bottom\"\n      >\n        <Link className={className} to={url}>\n          <Button className=\"minimal\">\n            <Icon icon={getIcon()} />\n            <Count count={count} />\n          </Button>\n        </Link>\n      </OverlayTrigger>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx",
    "content": "import React, { useEffect, useRef, useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { Icon } from \"../Icon\";\nimport { faPencil, faStar } from \"@fortawesome/free-solid-svg-icons\";\nimport { useFocusOnce } from \"src/utils/focus\";\nimport { useStopWheelScroll } from \"src/utils/form\";\nimport { PatchComponent } from \"src/patch\";\n\nexport interface IRatingNumberProps {\n  value: number | null;\n  onSetRating?: (value: number | null) => void;\n  disabled?: boolean;\n  clickToRate?: boolean;\n  // true if we should indicate that this is a rating\n  withoutContext?: boolean;\n}\n\nexport const RatingNumber = PatchComponent(\n  \"RatingNumber\",\n  (props: IRatingNumberProps) => {\n    const [editing, setEditing] = useState(false);\n    const [valueStage, setValueStage] = useState<number | null>(props.value);\n\n    useEffect(() => {\n      setValueStage(props.value);\n    }, [props.value]);\n\n    const showTextField = !props.disabled && (editing || !props.clickToRate);\n\n    const [ratingRef] = useFocusOnce(editing, true);\n    useStopWheelScroll(ratingRef);\n\n    const effectiveValue = editing ? valueStage : props.value;\n\n    const text = ((effectiveValue ?? 0) / 10).toFixed(1);\n    const useValidation = useRef(true);\n\n    function stepChange() {\n      useValidation.current = false;\n    }\n\n    function nonStepChange() {\n      useValidation.current = true;\n    }\n\n    function setCursorPosition(\n      target: HTMLInputElement,\n      pos: number,\n      endPos?: number\n    ) {\n      // This is a workaround to a missing feature where you can't set cursor position in input numbers.\n      // See https://stackoverflow.com/questions/33406169/failed-to-execute-setselectionrange-on-htmlinputelement-the-input-elements\n      target.type = \"text\";\n\n      target.setSelectionRange(pos, endPos ?? pos);\n      target.type = \"number\";\n    }\n\n    function handleChange(e: React.ChangeEvent<HTMLInputElement>) {\n      if (!props.onSetRating) {\n        return;\n      }\n\n      const setRating = editing ? setValueStage : props.onSetRating;\n\n      let val = e.target.value;\n      if (!useValidation.current) {\n        e.target.value = Number(val).toFixed(1);\n        const tempVal = Number(val) * 10;\n        setRating(tempVal || null);\n        useValidation.current = true;\n        return;\n      }\n\n      const match = /(\\d?)(\\d?)(.?)((\\d)?)/g.exec(val);\n      const matchOld = /(\\d?)(\\d?)(.?)((\\d{0,2})?)/g.exec(text ?? \"\");\n\n      if (match == null) {\n        return;\n      }\n\n      if (match[2] && !(match[2] == \"0\" && match[1] == \"1\")) {\n        match[2] = \"\";\n      }\n      if (match[4] == null || match[4] == \"\") {\n        match[4] = \"0\";\n      }\n\n      let value = match[1] + match[2] + \".\" + match[4];\n      e.target.value = value;\n\n      if (val.length > 0) {\n        if (Number(value) > 10) {\n          value = \"10.0\";\n        }\n        e.target.value = Number(value).toFixed(1);\n        let tempVal = Number(value) * 10;\n        setRating(tempVal || null);\n\n        let cursorPosition = 0;\n        if (match[2] && !match[4]) {\n          cursorPosition = 3;\n        } else if (matchOld != null && match[1] !== matchOld[1]) {\n          cursorPosition = 2;\n        } else if (\n          matchOld != null &&\n          match[1] === matchOld[1] &&\n          match[2] === matchOld[2] &&\n          match[4] === matchOld[4]\n        ) {\n          cursorPosition = 2;\n        }\n\n        setCursorPosition(e.target, cursorPosition);\n      }\n    }\n\n    function onBlur() {\n      if (editing) {\n        setEditing(false);\n        if (props.onSetRating && valueStage !== props.value) {\n          props.onSetRating(valueStage);\n        }\n      }\n    }\n\n    if (!showTextField) {\n      return (\n        <div className=\"rating-number disabled\">\n          {props.withoutContext && <Icon icon={faStar} />}\n          <span>{Number((effectiveValue ?? 0) / 10).toFixed(1)}</span>\n          {!props.disabled && props.clickToRate && (\n            <Button\n              variant=\"minimal\"\n              size=\"sm\"\n              className=\"edit-rating-button\"\n              onClick={() => setEditing(true)}\n            >\n              <Icon className=\"text-primary\" icon={faPencil} />\n            </Button>\n          )}\n        </div>\n      );\n    } else {\n      return (\n        <div className=\"rating-number\">\n          <input\n            ref={ratingRef}\n            className=\"text-input form-control\"\n            name=\"ratingnumber\"\n            type=\"number\"\n            onMouseDown={stepChange}\n            onKeyDown={nonStepChange}\n            onChange={handleChange}\n            onBlur={onBlur}\n            value={text}\n            min=\"0.0\"\n            step=\"0.1\"\n            max=\"10\"\n            placeholder=\"0.0\"\n          />\n        </div>\n      );\n    }\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Rating/RatingStars.tsx",
    "content": "import { useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { Icon } from \"../Icon\";\nimport { faStar as fasStar } from \"@fortawesome/free-solid-svg-icons\";\nimport { faStar as farStar } from \"@fortawesome/free-regular-svg-icons\";\nimport {\n  convertFromRatingFormat,\n  convertToRatingFormat,\n  getRatingPrecision,\n  RatingStarPrecision,\n  RatingSystemType,\n} from \"src/utils/rating\";\nimport { useIntl } from \"react-intl\";\nimport { PatchComponent } from \"src/patch\";\n\nexport interface IRatingStarsProps {\n  value: number | null;\n  onSetRating?: (value: number | null) => void;\n  disabled?: boolean;\n  precision: RatingStarPrecision;\n  valueRequired?: boolean;\n  orMore?: boolean;\n}\n\nexport const RatingStars = PatchComponent(\n  \"RatingStars\",\n  (props: IRatingStarsProps) => {\n    const intl = useIntl();\n    const [hoverRating, setHoverRating] = useState<number | undefined>();\n    const disabled = props.disabled || !props.onSetRating;\n\n    const rating = convertToRatingFormat(props.value, {\n      type: RatingSystemType.Stars,\n      starPrecision: props.precision,\n    });\n    const stars = rating ? Math.floor(rating) : 0;\n    // the upscaling was necesary to fix rounding issue present with tenth place precision\n    const fraction = rating ? ((rating * 10) % 10) / 10 : 0;\n\n    const max = 5;\n    const precision = getRatingPrecision(props.precision);\n\n    function newToggleFraction() {\n      if (precision !== 1) {\n        if (fraction !== precision) {\n          if (fraction == 0) {\n            return 1 - precision;\n          }\n\n          return fraction - precision;\n        }\n      }\n    }\n\n    function setRating(thisStar: number) {\n      if (!props.onSetRating) {\n        return;\n      }\n\n      let newRating: number | undefined = thisStar;\n\n      // toggle rating fraction if we're clicking on the current rating\n      if (\n        (stars === thisStar && !fraction) ||\n        (stars + 1 === thisStar && fraction)\n      ) {\n        const f = newToggleFraction();\n        if (!f) {\n          if (props.valueRequired) {\n            if (fraction) {\n              newRating = stars + 1;\n            } else {\n              newRating = stars;\n            }\n          } else {\n            newRating = undefined;\n          }\n        } else if (fraction) {\n          // we're toggling from an existing fraction so use the stars value\n          newRating = stars + f;\n        } else {\n          // we're toggling from a whole value, so decrement from current rating\n          newRating = stars - 1 + f;\n        }\n      }\n\n      // set the hover rating to undefined so that it doesn't immediately clear\n      // the stars\n      setHoverRating(undefined);\n\n      if (!newRating) {\n        props.onSetRating(null);\n        return;\n      }\n\n      props.onSetRating(\n        convertFromRatingFormat(newRating, RatingSystemType.Stars)\n      );\n    }\n\n    function onMouseOver(thisStar: number) {\n      if (!disabled) {\n        setHoverRating(thisStar);\n      }\n    }\n\n    function onMouseOut(thisStar: number) {\n      if (!disabled && hoverRating === thisStar) {\n        setHoverRating(undefined);\n      }\n    }\n\n    function getClassName(thisStar: number) {\n      if (hoverRating && hoverRating >= thisStar) {\n        if (hoverRating === stars) {\n          return \"unsetting\";\n        }\n\n        return \"setting\";\n      }\n\n      if (stars && stars >= thisStar) {\n        return \"set\";\n      }\n\n      return \"unset\";\n    }\n\n    function getTooltip(thisStar: number, current: RatingFraction | undefined) {\n      if (disabled) {\n        if (rating) {\n          // always return current rating for disabled control\n          return rating.toString();\n        }\n\n        return undefined;\n      }\n\n      // adjust tooltip to use fractions\n      if (!current) {\n        return intl.formatMessage({ id: \"actions.unset\" });\n      }\n\n      return (current.rating + current.fraction).toString();\n    }\n\n    type RatingFraction = {\n      rating: number;\n      fraction: number;\n    };\n\n    function getCurrentSelectedRating(): RatingFraction | undefined {\n      let r: number = hoverRating ? hoverRating : stars;\n      let f: number | undefined = fraction;\n\n      if (hoverRating) {\n        if (hoverRating === stars && precision === 1) {\n          if (props.valueRequired) {\n            return { rating: r, fraction: 0 };\n          }\n\n          // unsetting\n          return undefined;\n        }\n        if (hoverRating === stars + 1 && fraction && fraction === precision) {\n          if (props.valueRequired) {\n            return { rating: r, fraction: 0 };\n          }\n          // unsetting\n          return undefined;\n        }\n\n        if (f && hoverRating === stars + 1) {\n          f = newToggleFraction();\n          r--;\n        } else if (!f && hoverRating === stars) {\n          f = newToggleFraction();\n          r--;\n        } else {\n          f = 0;\n        }\n      }\n\n      return { rating: r, fraction: f ?? 0 };\n    }\n\n    function getButtonClassName(\n      thisStar: number,\n      current: RatingFraction | undefined\n    ) {\n      if (!current || thisStar > current.rating + 1) {\n        return \"star-fill-0\";\n      }\n\n      if (thisStar <= current.rating) {\n        return \"star-fill-100\";\n      }\n\n      let w = current.fraction * 100;\n      return `star-fill-${w}`;\n    }\n\n    const suffix = props.orMore ? \"+\" : \"\";\n\n    const renderRatingButton = (thisStar: number) => {\n      const ratingFraction = getCurrentSelectedRating();\n\n      return (\n        <Button\n          disabled={disabled}\n          className={`minimal ${getButtonClassName(thisStar, ratingFraction)}`}\n          onClick={() => setRating(thisStar)}\n          variant=\"secondary\"\n          onMouseEnter={() => onMouseOver(thisStar)}\n          onMouseLeave={() => onMouseOut(thisStar)}\n          onFocus={() => onMouseOver(thisStar)}\n          onBlur={() => onMouseOut(thisStar)}\n          title={getTooltip(thisStar, ratingFraction)}\n          key={`star-${thisStar}`}\n        >\n          <div className=\"filled-star\">\n            <Icon icon={fasStar} className=\"set\" />\n          </div>\n          <div className=\"unfilled-star\">\n            <Icon icon={farStar} className={getClassName(thisStar)} />\n          </div>\n        </Button>\n      );\n    };\n\n    const maybeGetStarRatingNumber = () => {\n      const ratingFraction = getCurrentSelectedRating();\n      if (\n        !ratingFraction ||\n        (ratingFraction.rating == 0 && ratingFraction.fraction == 0)\n      ) {\n        return \"\";\n      }\n\n      return ratingFraction.rating + ratingFraction.fraction + suffix;\n    };\n\n    const precisionClassName = `rating-stars-precision-${props.precision}`;\n\n    return (\n      <div className={`rating-stars ${precisionClassName}`}>\n        {Array.from(Array(max)).map((value, index) =>\n          renderRatingButton(index + 1)\n        )}\n        <span className=\"star-rating-number\">{maybeGetStarRatingNumber()}</span>\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx",
    "content": "import { useConfigurationContext } from \"src/hooks/Config\";\nimport {\n  defaultRatingStarPrecision,\n  defaultRatingSystemOptions,\n  RatingSystemType,\n} from \"src/utils/rating\";\nimport { RatingNumber } from \"./RatingNumber\";\nimport { RatingStars } from \"./RatingStars\";\nimport { PatchComponent } from \"src/patch\";\n\nexport interface IRatingSystemProps {\n  value: number | null | undefined;\n  onSetRating?: (value: number | null) => void;\n  disabled?: boolean;\n  valueRequired?: boolean;\n  // if true, requires a click first to edit the rating\n  clickToRate?: boolean;\n  // true if we should indicate that this is a rating\n  withoutContext?: boolean;\n}\n\nexport const RatingSystem = PatchComponent(\n  \"RatingSystem\",\n  (props: IRatingSystemProps) => {\n    const { configuration: config } = useConfigurationContext();\n    const ratingSystemOptions =\n      config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;\n\n    if (ratingSystemOptions.type === RatingSystemType.Stars) {\n      return (\n        <RatingStars\n          value={props.value ?? null}\n          onSetRating={props.onSetRating}\n          disabled={props.disabled}\n          precision={\n            ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision\n          }\n          valueRequired={props.valueRequired}\n        />\n      );\n    } else {\n      return (\n        <RatingNumber\n          value={props.value ?? null}\n          onSetRating={props.onSetRating}\n          disabled={props.disabled}\n          clickToRate={props.clickToRate}\n          withoutContext={props.withoutContext}\n        />\n      );\n    }\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Rating/styles.scss",
    "content": ".rating-stars {\n  display: inline-flex;\n  vertical-align: middle;\n\n  button {\n    font-size: inherit;\n    margin-right: 1px;\n    padding: 0;\n    position: relative;\n\n    &:hover {\n      background-color: inherit;\n    }\n\n    &:disabled {\n      background-color: inherit;\n      opacity: inherit;\n    }\n\n    &.star-fill-0 .filled-star {\n      width: 0;\n    }\n\n    &.star-fill-10 .filled-star {\n      width: 10%;\n    }\n\n    &.star-fill-20 .filled-star {\n      width: 20%;\n    }\n\n    &.star-fill-25 .filled-star {\n      width: 35%;\n    }\n\n    &.star-fill-30 .filled-star {\n      width: 30%;\n    }\n\n    &.star-fill-40 .filled-star {\n      width: 40%;\n    }\n\n    &.star-fill-50 .filled-star {\n      width: 50%;\n    }\n\n    &.star-fill-60 .filled-star {\n      width: 60%;\n    }\n\n    &.star-fill-75 .filled-star {\n      width: 65%;\n    }\n\n    &.star-fill-70 .filled-star {\n      width: 70%;\n    }\n\n    &.star-fill-80 .filled-star {\n      width: 80%;\n    }\n\n    &.star-fill-90 .filled-star {\n      width: 90%;\n    }\n\n    &.star-fill-100 .filled-star {\n      width: 100%;\n    }\n\n    .filled-star {\n      overflow: hidden;\n      position: absolute;\n    }\n  }\n\n  .unsetting {\n    color: gold;\n  }\n\n  .setting {\n    color: gold;\n  }\n\n  .set {\n    color: gold;\n  }\n}\n\n.star-rating-number {\n  font-size: 1rem;\n  margin: auto 0.5rem;\n}\n\n.rating-number {\n  .fa-icon {\n    color: gold;\n    margin-left: 0;\n  }\n\n  .edit-rating-button {\n    font-size: 0.75rem;\n  }\n\n  &.disabled {\n    align-items: center;\n    display: inline-flex;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/RatingBanner.tsx",
    "content": "import React from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport {\n  convertToRatingFormat,\n  defaultRatingSystemOptions,\n  RatingStarPrecision,\n  RatingSystemType,\n} from \"src/utils/rating\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\ninterface IProps {\n  rating?: number | null;\n}\n\nexport const RatingBanner: React.FC<IProps> = ({ rating }) => {\n  const { configuration: config } = useConfigurationContext();\n  const ratingSystemOptions =\n    config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;\n  const isLegacy =\n    ratingSystemOptions.type === RatingSystemType.Stars &&\n    ratingSystemOptions.starPrecision === RatingStarPrecision.Full;\n\n  const convertedRating = convertToRatingFormat(\n    rating ?? undefined,\n    ratingSystemOptions\n  );\n\n  return rating ? (\n    <div\n      className={\n        isLegacy\n          ? `rating-banner rating-${convertedRating}`\n          : `rating-banner rating-100-${Math.trunc(rating / 5)}`\n      }\n    >\n      <FormattedMessage id=\"rating\" />: {convertedRating}\n    </div>\n  ) : (\n    <></>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport { ModalComponent } from \"./Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useIntl } from \"react-intl\";\nimport { faSignOutAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { Col, Form, Row } from \"react-bootstrap\";\nimport * as FormUtils from \"src/utils/form\";\nimport { mutateSceneAssignFile } from \"src/core/StashService\";\nimport { Scene, SceneSelect } from \"src/components/Scenes/SceneSelect\";\n\ninterface IFile {\n  id: string;\n  path: string;\n}\n\ninterface IReassignFilesDialogProps {\n  selected: IFile;\n  onClose: () => void;\n}\n\nexport const ReassignFilesDialog: React.FC<IReassignFilesDialogProps> = (\n  props: IReassignFilesDialogProps\n) => {\n  const [scenes, setScenes] = useState<Scene[]>([]);\n\n  const intl = useIntl();\n  const singularEntity = intl.formatMessage({ id: \"file\" });\n  const pluralEntity = intl.formatMessage({ id: \"files\" });\n\n  const header = intl.formatMessage(\n    { id: \"dialogs.reassign_entity_title\" },\n    { count: 1, singularEntity, pluralEntity }\n  );\n\n  const toastMessage = intl.formatMessage(\n    { id: \"toast.reassign_past_tense\" },\n    { count: 1, singularEntity, pluralEntity }\n  );\n\n  const Toast = useToast();\n\n  // Network state\n  const [reassigning, setReassigning] = useState(false);\n\n  async function onAccept() {\n    if (!scenes.length) {\n      return;\n    }\n\n    setReassigning(true);\n    try {\n      await mutateSceneAssignFile(scenes[0].id, props.selected.id);\n      Toast.success(toastMessage);\n      props.onClose();\n    } catch (e) {\n      Toast.error(e);\n      props.onClose();\n    }\n    setReassigning(false);\n  }\n\n  return (\n    <ModalComponent\n      show\n      icon={faSignOutAlt}\n      header={header}\n      accept={{\n        onClick: onAccept,\n        text: intl.formatMessage({ id: \"actions.reassign\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={reassigning}\n    >\n      <Form>\n        <Form.Group controlId=\"dest\" as={Row}>\n          {FormUtils.renderLabel({\n            title: intl.formatMessage({\n              id: \"dialogs.reassign_files.destination\",\n            }),\n            labelProps: {\n              column: true,\n              sm: 3,\n              xl: 12,\n            },\n          })}\n          <Col sm={9} xl={12}>\n            <SceneSelect\n              values={scenes}\n              onSelect={(items) => setScenes(items)}\n            />\n          </Col>\n        </Form.Group>\n      </Form>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx",
    "content": "import React from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { faFolderOpen } from \"@fortawesome/free-solid-svg-icons\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"./Icon\";\nimport {\n  mutateRevealFileInFileManager,\n  mutateRevealFolderInFileManager,\n} from \"src/core/StashService\";\nimport { getPlatformURL } from \"src/core/createClient\";\n\ninterface IRevealInFilesystemButtonProps {\n  fileId?: string;\n  folderId?: string;\n}\n\nfunction isLocalhost(): boolean {\n  const { hostname } = getPlatformURL();\n  return (\n    hostname === \"localhost\" || hostname === \"127.0.0.1\" || hostname === \"::1\"\n  );\n}\n\nexport const RevealInFilesystemButton: React.FC<\n  IRevealInFilesystemButtonProps\n> = ({ fileId, folderId }) => {\n  const intl = useIntl();\n\n  if (!isLocalhost()) return null;\n\n  function onClick() {\n    if (folderId) {\n      mutateRevealFolderInFileManager(folderId);\n    } else if (fileId) {\n      mutateRevealFileInFileManager(fileId);\n    }\n  }\n\n  return (\n    <Button\n      className=\"minimal reveal-in-filesystem-button\"\n      title={intl.formatMessage({ id: \"actions.reveal_in_file_manager\" })}\n      onClick={onClick}\n    >\n      <Icon icon={faFolderOpen} />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ScrapeDialog/CreateLinkTagDialog.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { faLink } from \"@fortawesome/free-solid-svg-icons\";\nimport { Form } from \"react-bootstrap\";\nimport { Tag, TagSelect } from \"../../Tags/TagSelect\";\n\nexport const CreateLinkTagDialog: React.FC<{\n  tag: GQL.ScrapedTag;\n  onClose: (result: {\n    create?: GQL.TagCreateInput;\n    update?: GQL.TagUpdateInput;\n  }) => void;\n  endpoint?: string;\n}> = ({ tag, onClose, endpoint }) => {\n  const intl = useIntl();\n\n  const [createNew, setCreateNew] = useState(false);\n  const [name, setName] = useState(tag.name);\n  const [existingTag, setExistingTag] = useState<Tag | null>(null);\n  const [addAsAlias, setAddAsAlias] = useState(false);\n\n  const canAddAlias = (createNew && name !== tag.name) || !createNew;\n\n  useEffect(() => {\n    setAddAsAlias(canAddAlias);\n  }, [canAddAlias]);\n\n  function handleTagSave() {\n    if (createNew) {\n      const createInput: GQL.TagCreateInput = {\n        name: name,\n        aliases: addAsAlias ? [tag.name] : [],\n        stash_ids:\n          endpoint && tag.remote_site_id\n            ? [{ endpoint: endpoint!, stash_id: tag.remote_site_id }]\n            : undefined,\n      };\n      onClose({ create: createInput });\n    } else if (existingTag) {\n      const updateInput: GQL.TagUpdateInput = {\n        id: existingTag.id,\n        aliases: addAsAlias\n          ? [...(existingTag.aliases || []), tag.name]\n          : undefined,\n        // add stash id if applicable\n        stash_ids:\n          endpoint && tag.remote_site_id\n            ? [\n                ...(existingTag.stash_ids || []),\n                { endpoint: endpoint!, stash_id: tag.remote_site_id },\n              ]\n            : undefined,\n      };\n      onClose({ update: updateInput });\n    }\n  }\n\n  return (\n    <ModalComponent\n      show={true}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.save\" }),\n        onClick: () => handleTagSave(),\n      }}\n      disabled={createNew ? name.trim() === \"\" : existingTag === null}\n      cancel={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        onClick: () => {\n          onClose({});\n        },\n      }}\n      dialogClassName=\"create-link-tag-modal\"\n      icon={faLink}\n      header={intl.formatMessage({ id: \"component_tagger.verb_match_tag\" })}\n    >\n      <Form>\n        <Form.Check\n          type=\"radio\"\n          id=\"create-new\"\n          label={intl.formatMessage({ id: \"actions.create_new\" })}\n          checked={createNew}\n          onChange={() => setCreateNew(true)}\n        />\n\n        <Form.Group className=\"ml-3 mt-2\">\n          <Form.Label>\n            <FormattedMessage id=\"name\" />\n          </Form.Label>\n          <Form.Control\n            className=\"input-control\"\n            type=\"text\"\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            disabled={!createNew}\n          />\n        </Form.Group>\n\n        <Form.Check\n          type=\"radio\"\n          id=\"link-existing\"\n          label={intl.formatMessage({\n            id: \"component_tagger.verb_link_existing\",\n          })}\n          checked={!createNew}\n          onChange={() => setCreateNew(false)}\n        />\n\n        <Form.Group className=\"ml-3 mt-2\">\n          <TagSelect\n            isMulti={false}\n            values={existingTag ? [existingTag] : []}\n            onSelect={(t) => setExistingTag(t.length > 0 ? t[0] : null)}\n            isDisabled={createNew}\n            menuPortalTarget={document.body}\n          />\n        </Form.Group>\n\n        <Form.Group className=\"mt-3\">\n          <Form.Check\n            type=\"checkbox\"\n            id=\"add-as-alias\"\n            label={intl.formatMessage({\n              id: \"component_tagger.verb_add_as_alias\",\n            })}\n            checked={addAsAlias}\n            onChange={() => setAddAsAlias(!addAsAlias)}\n            disabled={!canAddAlias}\n          />\n        </Form.Group>\n      </Form>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { Form, Col, Row } from \"react-bootstrap\";\nimport { ModalComponent } from \"../Modal\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\nexport interface IScrapeDialogContextState {\n  existingLabel?: React.ReactNode;\n  scrapedLabel?: React.ReactNode;\n}\n\nexport const ScrapeDialogContext =\n  React.createContext<IScrapeDialogContextState>({});\n\ninterface IScrapeDialogProps {\n  className?: string;\n  title: string;\n  existingLabel?: React.ReactNode;\n  scrapedLabel?: React.ReactNode;\n  onClose: (apply?: boolean) => void;\n}\n\nexport const ScrapeDialog: React.FC<\n  React.PropsWithChildren<IScrapeDialogProps>\n> = (props: React.PropsWithChildren<IScrapeDialogProps>) => {\n  const intl = useIntl();\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const existingLabel = useMemo(\n    () =>\n      props.existingLabel ?? (\n        <FormattedMessage id=\"dialogs.scrape_results_existing\" />\n      ),\n    [props.existingLabel]\n  );\n  const scrapedLabel = useMemo(\n    () =>\n      props.scrapedLabel ?? (\n        <FormattedMessage id=\"dialogs.scrape_results_scraped\" />\n      ),\n    [props.scrapedLabel]\n  );\n\n  const contextState = useMemo(\n    () => ({\n      existingLabel: existingLabel,\n      scrapedLabel: scrapedLabel,\n    }),\n    [existingLabel, scrapedLabel]\n  );\n\n  return (\n    <ModalComponent\n      show\n      icon={faPencilAlt}\n      header={props.title}\n      accept={{\n        onClick: () => {\n          props.onClose(true);\n        },\n        text: intl.formatMessage({ id: \"actions.apply\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      modalProps={{\n        size: \"lg\",\n        dialogClassName: `${props.className ?? \"\"} scrape-dialog ${\n          sfwContentMode ? \"sfw-mode\" : \"\"\n        }`,\n      }}\n    >\n      <div className=\"dialog-container\">\n        <ScrapeDialogContext.Provider value={contextState}>\n          <Form>\n            <Row className=\"px-3 pt-3\">\n              <Col lg={{ span: 9, offset: 3 }}>\n                <Row>\n                  <Form.Label\n                    column\n                    lg=\"6\"\n                    className=\"d-lg-block d-none column-label\"\n                  >\n                    {existingLabel}\n                  </Form.Label>\n                  <Form.Label\n                    column\n                    lg=\"6\"\n                    className=\"d-lg-block d-none column-label\"\n                  >\n                    {scrapedLabel}\n                  </Form.Label>\n                </Row>\n              </Col>\n            </Row>\n\n            {props.children}\n          </Form>\n        </ScrapeDialogContext.Provider>\n      </div>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx",
    "content": "import React, { useContext, useState } from \"react\";\nimport {\n  Form,\n  Col,\n  Row,\n  InputGroup,\n  Button,\n  FormControl,\n} from \"react-bootstrap\";\nimport { Icon } from \"../Icon\";\nimport clone from \"lodash-es/clone\";\nimport { faCheck, faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport { getCountryByISO } from \"src/utils/country\";\nimport { CountrySelect } from \"../CountrySelect\";\nimport { StringListInput } from \"../StringListInput\";\nimport { ImageSelector } from \"../ImageSelector\";\nimport { CustomFieldScrapeResults, ScrapeResult } from \"./scrapeResult\";\nimport { ScrapeDialogContext } from \"./ScrapeDialog\";\n\nfunction renderButtonIcon(selected: boolean) {\n  const className = selected ? \"text-success\" : \"text-muted\";\n\n  return (\n    <Icon\n      className={`fa-fw ${className}`}\n      icon={selected ? faCheck : faTimes}\n    />\n  );\n}\n\ninterface IScrapedFieldProps<T> {\n  result: ScrapeResult<T>;\n}\n\ninterface IScrapedRowProps<T> extends IScrapedFieldProps<T> {\n  className?: string;\n  field: string;\n  title: string;\n  originalField: React.ReactNode;\n  newField: React.ReactNode;\n  onChange: (value: ScrapeResult<T>) => void;\n  newValues?: React.ReactNode;\n  alwaysShow?: boolean;\n}\n\nexport const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {\n  const { existingLabel, scrapedLabel } = useContext(ScrapeDialogContext);\n\n  function handleSelectClick(isNew: boolean) {\n    const ret = clone(props.result);\n    ret.useNewValue = isNew;\n    props.onChange(ret);\n  }\n\n  if (!props.result.scraped && !props.newValues && !props.alwaysShow) {\n    return <></>;\n  }\n\n  return (\n    <Row\n      className={`px-3 pt-3 ${props.className ?? \"\"}`}\n      data-field={props.field}\n    >\n      <Form.Label column lg=\"3\">\n        {props.title}\n      </Form.Label>\n\n      <Col lg=\"9\">\n        <Row>\n          <Form.Label column className=\"d-lg-none column-label\">\n            {existingLabel}\n          </Form.Label>\n          <Col lg=\"6\">\n            <InputGroup>\n              <InputGroup.Prepend className=\"bg-secondary text-white border-secondary\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => handleSelectClick(false)}\n                >\n                  {renderButtonIcon(!props.result.useNewValue)}\n                </Button>\n              </InputGroup.Prepend>\n              {props.originalField}\n            </InputGroup>\n          </Col>\n\n          <Form.Label column className=\"d-lg-none column-label\">\n            {scrapedLabel}\n          </Form.Label>\n          <Col lg=\"6\">\n            <InputGroup>\n              <InputGroup.Prepend>\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => handleSelectClick(true)}\n                >\n                  {renderButtonIcon(props.result.useNewValue)}\n                </Button>\n              </InputGroup.Prepend>\n              {props.newField}\n            </InputGroup>\n            {props.newValues}\n          </Col>\n        </Row>\n      </Col>\n    </Row>\n  );\n};\n\ninterface IScrapedInputGroupProps {\n  isNew?: boolean;\n  placeholder?: string;\n  locked?: boolean;\n  result: ScrapeResult<string>;\n  onChange?: (value: string) => void;\n}\n\nconst ScrapedInputGroup: React.FC<IScrapedInputGroupProps> = (props) => {\n  return (\n    <FormControl\n      placeholder={props.placeholder}\n      value={props.isNew ? props.result.newValue : props.result.originalValue}\n      readOnly={!props.isNew || props.locked}\n      onChange={(e) => {\n        if (props.isNew && props.onChange) {\n          props.onChange(e.target.value);\n        }\n      }}\n      className=\"bg-secondary text-white border-secondary\"\n    />\n  );\n};\n\ninterface IScrapedInputGroupRowProps {\n  title: string;\n  field: string;\n  className?: string;\n  placeholder?: string;\n  result: ScrapeResult<string>;\n  locked?: boolean;\n  onChange: (value: ScrapeResult<string>) => void;\n}\n\nexport const ScrapedInputGroupRow: React.FC<IScrapedInputGroupRowProps> = (\n  props\n) => {\n  return (\n    <ScrapeDialogRow\n      title={props.title}\n      field={props.field}\n      className={props.className}\n      result={props.result}\n      originalField={\n        <ScrapedInputGroup\n          placeholder={props.placeholder || props.title}\n          result={props.result}\n        />\n      }\n      newField={\n        <ScrapedInputGroup\n          placeholder={props.placeholder || props.title}\n          result={props.result}\n          isNew\n          locked={props.locked}\n          onChange={(value) =>\n            props.onChange(props.result.cloneWithValue(value))\n          }\n        />\n      }\n      onChange={props.onChange}\n    />\n  );\n};\n\ninterface IScrapedNumberInputProps {\n  isNew?: boolean;\n  placeholder?: string;\n  locked?: boolean;\n  result: ScrapeResult<number>;\n  onChange?: (value: number) => void;\n}\n\nconst ScrapedNumberInput: React.FC<IScrapedNumberInputProps> = (props) => {\n  return (\n    <FormControl\n      placeholder={props.placeholder}\n      value={props.isNew ? props.result.newValue : props.result.originalValue}\n      readOnly={!props.isNew || props.locked}\n      onChange={(e) => {\n        if (props.isNew && props.onChange) {\n          props.onChange(Number(e.target.value));\n        }\n      }}\n      className=\"bg-secondary text-white border-secondary\"\n      type=\"number\"\n    />\n  );\n};\n\ninterface IScrapedNumberRowProps {\n  title: string;\n  field: string;\n  className?: string;\n  placeholder?: string;\n  result: ScrapeResult<number>;\n  locked?: boolean;\n  onChange: (value: ScrapeResult<number>) => void;\n}\n\nexport const ScrapedNumberRow: React.FC<IScrapedNumberRowProps> = (props) => {\n  return (\n    <ScrapeDialogRow\n      title={props.title}\n      field={props.field}\n      className={props.className}\n      result={props.result}\n      originalField={\n        <ScrapedNumberInput\n          placeholder={props.placeholder || props.title}\n          result={props.result}\n        />\n      }\n      newField={\n        <ScrapedNumberInput\n          placeholder={props.placeholder || props.title}\n          result={props.result}\n          isNew\n          locked={props.locked}\n          onChange={(value) =>\n            props.onChange(props.result.cloneWithValue(value))\n          }\n        />\n      }\n      onChange={props.onChange}\n    />\n  );\n};\n\ninterface IScrapedStringListProps {\n  isNew?: boolean;\n  placeholder?: string;\n  locked?: boolean;\n  result: ScrapeResult<string[]>;\n  onChange?: (value: string[]) => void;\n}\n\nconst ScrapedStringList: React.FC<IScrapedStringListProps> = (props) => {\n  const value = props.isNew\n    ? props.result.newValue\n    : props.result.originalValue;\n\n  return (\n    <StringListInput\n      value={value ?? []}\n      setValue={(v) => {\n        if (props.isNew && props.onChange) {\n          props.onChange(v);\n        }\n      }}\n      placeholder={props.placeholder}\n      readOnly={!props.isNew || props.locked}\n    />\n  );\n};\n\ninterface IScrapedStringListRowProps {\n  title: string;\n  field: string;\n  placeholder?: string;\n  result: ScrapeResult<string[]>;\n  locked?: boolean;\n  onChange: (value: ScrapeResult<string[]>) => void;\n}\n\nexport const ScrapedStringListRow: React.FC<IScrapedStringListRowProps> = (\n  props\n) => {\n  return (\n    <ScrapeDialogRow\n      className=\"string-list-row\"\n      title={props.title}\n      field={props.field}\n      result={props.result}\n      originalField={\n        <ScrapedStringList\n          placeholder={props.placeholder || props.title}\n          result={props.result}\n        />\n      }\n      newField={\n        <ScrapedStringList\n          placeholder={props.placeholder || props.title}\n          result={props.result}\n          isNew\n          locked={props.locked}\n          onChange={(value) =>\n            props.onChange(props.result.cloneWithValue(value))\n          }\n        />\n      }\n      onChange={props.onChange}\n    />\n  );\n};\n\nconst ScrapedTextArea: React.FC<IScrapedInputGroupProps> = (props) => {\n  return (\n    <FormControl\n      as=\"textarea\"\n      placeholder={props.placeholder}\n      value={props.isNew ? props.result.newValue : props.result.originalValue}\n      readOnly={!props.isNew}\n      onChange={(e) => {\n        if (props.isNew && props.onChange) {\n          props.onChange(e.target.value);\n        }\n      }}\n      className=\"bg-secondary text-white border-secondary scene-description\"\n    />\n  );\n};\n\nexport const ScrapedTextAreaRow: React.FC<IScrapedInputGroupRowProps> = (\n  props\n) => {\n  return (\n    <ScrapeDialogRow\n      title={props.title}\n      field={props.field}\n      result={props.result}\n      originalField={\n        <ScrapedTextArea\n          placeholder={props.placeholder || props.title}\n          result={props.result}\n        />\n      }\n      newField={\n        <ScrapedTextArea\n          placeholder={props.placeholder || props.title}\n          result={props.result}\n          isNew\n          onChange={(value) =>\n            props.onChange(props.result.cloneWithValue(value))\n          }\n        />\n      }\n      onChange={props.onChange}\n    />\n  );\n};\n\ninterface IScrapedImageProps {\n  isNew?: boolean;\n  className?: string;\n  placeholder?: string;\n  result: ScrapeResult<string>;\n}\n\nconst ScrapedImage: React.FC<IScrapedImageProps> = (props) => {\n  const value = props.isNew\n    ? props.result.newValue\n    : props.result.originalValue;\n\n  if (!value) {\n    return <></>;\n  }\n\n  return (\n    <img className={props.className} src={value} alt={props.placeholder} />\n  );\n};\n\ninterface IScrapedImageRowProps {\n  title: string;\n  field: string;\n  className?: string;\n  result: ScrapeResult<string>;\n  onChange: (value: ScrapeResult<string>) => void;\n}\n\nexport const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {\n  return (\n    <ScrapeDialogRow\n      title={props.title}\n      field={props.field}\n      result={props.result}\n      originalField={\n        <ScrapedImage\n          result={props.result}\n          className={props.className}\n          placeholder={props.title}\n        />\n      }\n      newField={\n        <ScrapedImage\n          result={props.result}\n          className={props.className}\n          placeholder={props.title}\n          isNew\n        />\n      }\n      onChange={props.onChange}\n    />\n  );\n};\n\ninterface IScrapedImagesRowProps {\n  title: string;\n  field: string;\n  className?: string;\n  result: ScrapeResult<string>;\n  images: string[];\n  onChange: (value: ScrapeResult<string>) => void;\n}\n\nexport const ScrapedImagesRow: React.FC<IScrapedImagesRowProps> = (props) => {\n  const [imageIndex, setImageIndex] = useState(0);\n\n  function onSetImageIndex(newIdx: number) {\n    const ret = props.result.cloneWithValue(props.images[newIdx]);\n    props.onChange(ret);\n    setImageIndex(newIdx);\n  }\n\n  return (\n    <ScrapeDialogRow\n      title={props.title}\n      field={props.field}\n      result={props.result}\n      originalField={\n        <ScrapedImage\n          result={props.result}\n          className={props.className}\n          placeholder={props.title}\n        />\n      }\n      newField={\n        <div className=\"image-selection-parent\">\n          <ImageSelector\n            imageClassName={props.className}\n            images={props.images}\n            imageIndex={imageIndex}\n            setImageIndex={onSetImageIndex}\n          />\n        </div>\n      }\n      onChange={props.onChange}\n    />\n  );\n};\n\ninterface IScrapedCountryRowProps {\n  title: string;\n  field: string;\n  result: ScrapeResult<string>;\n  onChange: (value: ScrapeResult<string>) => void;\n  locked?: boolean;\n  locale?: string;\n}\n\nexport const ScrapedCountryRow: React.FC<IScrapedCountryRowProps> = ({\n  title,\n  field,\n  result,\n  onChange,\n  locked,\n  locale,\n}) => (\n  <ScrapeDialogRow\n    title={title}\n    field={field}\n    result={result}\n    originalField={\n      <FormControl\n        value={\n          getCountryByISO(result.originalValue, locale) ?? result.originalValue\n        }\n        readOnly\n        className=\"bg-secondary text-white border-secondary\"\n      />\n    }\n    newField={\n      <CountrySelect\n        value={result.newValue}\n        disabled={locked}\n        onChange={(value) => {\n          if (onChange) {\n            onChange(result.cloneWithValue(value));\n          }\n        }}\n        showFlag={false}\n        isClearable={false}\n        className=\"flex-grow-1\"\n      />\n    }\n    onChange={onChange}\n  />\n);\n\nexport const ScrapedCustomFieldRows: React.FC<{\n  results: CustomFieldScrapeResults;\n  onChange: (newCustomFields: CustomFieldScrapeResults) => void;\n}> = ({ results, onChange }) => {\n  return (\n    <>\n      {Array.from(results.entries()).map(([field, result]) => {\n        const fieldName = `custom_${field}`;\n        return (\n          <ScrapedInputGroupRow\n            className=\"custom-field\"\n            title={field}\n            field={fieldName}\n            key={fieldName}\n            result={result}\n            onChange={(newResult) => {\n              const newResults = new Map(results);\n              newResults.set(field, newResult);\n              onChange(newResults);\n            }}\n          />\n        );\n      })}\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ScrapeDialogRow } from \"src/components/Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { PerformerSelect } from \"src/components/Performers/PerformerSelect\";\nimport {\n  ObjectScrapeResult,\n  ScrapeResult,\n} from \"src/components/Shared/ScrapeDialog/scrapeResult\";\nimport { TagIDSelect } from \"src/components/Tags/TagSelect\";\nimport { StudioSelect } from \"src/components/Studios/StudioSelect\";\nimport { GroupSelect } from \"src/components/Groups/GroupSelect\";\nimport { uniq } from \"lodash-es\";\nimport { CollapseButton } from \"../CollapseButton\";\nimport { Badge, Button } from \"react-bootstrap\";\nimport { Icon } from \"../Icon\";\nimport { faLink, faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport { useIntl } from \"react-intl\";\n\ninterface INewScrapedObjects<T> {\n  newValues: T[];\n  onCreateNew: (value: T) => void;\n  onLinkExisting?: (value: T) => void;\n  getName: (value: T) => string;\n}\n\nexport const NewScrapedObjects = <T,>(props: INewScrapedObjects<T>) => {\n  const intl = useIntl();\n\n  if (props.newValues.length === 0) {\n    return null;\n  }\n\n  const ret = (\n    <>\n      {props.newValues.map((t) => (\n        <Badge\n          className=\"tag-item\"\n          variant=\"secondary\"\n          key={props.getName(t)}\n          onClick={() => props.onCreateNew(t)}\n        >\n          {props.getName(t)}\n          <Button className=\"minimal ml-2\">\n            <Icon className=\"fa-fw\" icon={faPlus} />\n          </Button>\n          {props.onLinkExisting ? (\n            <Button\n              className=\"minimal\"\n              onClick={(e) => {\n                props.onLinkExisting?.(t);\n                e.stopPropagation();\n              }}\n            >\n              <Icon className=\"fa-fw\" icon={faLink} />\n            </Button>\n          ) : null}\n        </Badge>\n      ))}\n    </>\n  );\n\n  const minCollapseLength = 10;\n\n  if (props.newValues!.length >= minCollapseLength) {\n    const missingText = intl.formatMessage({\n      id: \"dialogs.scrape_results_missing\",\n    });\n    return (\n      <CollapseButton text={`${missingText} (${props.newValues!.length})`}>\n        {ret}\n      </CollapseButton>\n    );\n  }\n\n  return ret;\n};\n\ninterface IScrapedStudioRow {\n  title: string;\n  field: string;\n  result: ObjectScrapeResult<GQL.ScrapedStudio>;\n  onChange: (value: ObjectScrapeResult<GQL.ScrapedStudio>) => void;\n  newStudio?: GQL.ScrapedStudio;\n  onCreateNew?: (value: GQL.ScrapedStudio) => void;\n  onLinkExisting?: (value: GQL.ScrapedStudio) => void;\n}\n\nfunction getObjectName<T extends { name: string }>(value: T) {\n  return value.name;\n}\n\nexport const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({\n  title,\n  field,\n  result,\n  onChange,\n  newStudio,\n  onCreateNew,\n  onLinkExisting,\n}) => {\n  function renderScrapedStudio(\n    scrapeResult: ObjectScrapeResult<GQL.ScrapedStudio>,\n    isNew?: boolean,\n    onChangeFn?: (value: GQL.ScrapedStudio) => void\n  ) {\n    const resultValue = isNew\n      ? scrapeResult.newValue\n      : scrapeResult.originalValue;\n    const value = resultValue ? [resultValue] : [];\n\n    const selectValue = value.map((p) => {\n      const aliases: string[] = p.aliases\n        ? p.aliases.split(\",\").map((a) => a.trim())\n        : [];\n      return {\n        id: p.stored_id ?? \"\",\n        name: p.name ?? \"\",\n        aliases,\n      };\n    });\n\n    return (\n      <StudioSelect\n        className=\"form-control react-select\"\n        isDisabled={!isNew}\n        onSelect={(items) => {\n          if (onChangeFn) {\n            const { id, aliases, ...data } = items[0];\n            onChangeFn({\n              ...data,\n              stored_id: id,\n              aliases: aliases?.join(\", \"),\n            });\n          }\n        }}\n        values={selectValue}\n      />\n    );\n  }\n\n  return (\n    <ScrapeDialogRow\n      title={title}\n      field={field}\n      result={result}\n      originalField={renderScrapedStudio(result)}\n      newField={renderScrapedStudio(result, true, (value) =>\n        onChange(result.cloneWithValue(value))\n      )}\n      onChange={onChange}\n      newValues={\n        newStudio && onCreateNew ? (\n          <NewScrapedObjects\n            newValues={[newStudio]}\n            onCreateNew={onCreateNew}\n            getName={getObjectName}\n            onLinkExisting={onLinkExisting}\n          />\n        ) : undefined\n      }\n    />\n  );\n};\n\ninterface IScrapedObjectsRow<T> {\n  title: string;\n  field: string;\n  result: ScrapeResult<T[]>;\n  onChange: (value: ScrapeResult<T[]>) => void;\n  newObjects?: T[];\n  onCreateNew?: (value: T) => void;\n  onLinkExisting?: (value: T) => void;\n  renderObjects: (\n    result: ScrapeResult<T[]>,\n    isNew?: boolean,\n    onChange?: (value: T[]) => void\n  ) => JSX.Element;\n  getName: (value: T) => string;\n}\n\nexport const ScrapedObjectsRow = <T,>(props: IScrapedObjectsRow<T>) => {\n  const {\n    title,\n    field,\n    result,\n    onChange,\n    newObjects = [],\n    onCreateNew,\n    onLinkExisting,\n    renderObjects,\n    getName,\n  } = props;\n\n  return (\n    <ScrapeDialogRow\n      title={title}\n      field={field}\n      result={result}\n      originalField={renderObjects(result)}\n      newField={renderObjects(result, true, (value) =>\n        onChange(result.cloneWithValue(value))\n      )}\n      onChange={onChange}\n      newValues={\n        onCreateNew && newObjects.length > 0 ? (\n          <NewScrapedObjects\n            newValues={newObjects ?? []}\n            onCreateNew={onCreateNew}\n            onLinkExisting={onLinkExisting}\n            getName={getName}\n          />\n        ) : undefined\n      }\n    />\n  );\n};\n\ntype IScrapedObjectRowImpl<T> = Omit<\n  IScrapedObjectsRow<T>,\n  \"renderObjects\" | \"getName\"\n>;\n\nexport const ScrapedPerformersRow: React.FC<\n  IScrapedObjectRowImpl<GQL.ScrapedPerformer> & { ageFromDate?: string | null }\n> = ({\n  title,\n  field,\n  result,\n  onChange,\n  newObjects,\n  onCreateNew,\n  ageFromDate,\n  onLinkExisting,\n}) => {\n  const performersCopy = useMemo(() => {\n    return (\n      newObjects?.map((p) => {\n        const name: string = p.name ?? \"\";\n        return { ...p, name };\n      }) ?? []\n    );\n  }, [newObjects]);\n\n  function renderScrapedPerformers(\n    scrapeResult: ScrapeResult<GQL.ScrapedPerformer[]>,\n    isNew?: boolean,\n    onChangeFn?: (value: GQL.ScrapedPerformer[]) => void\n  ) {\n    const resultValue = isNew\n      ? scrapeResult.newValue\n      : scrapeResult.originalValue;\n    const value = resultValue ?? [];\n\n    const selectValue = value.map((p) => {\n      const alias_list: string[] = [];\n      return {\n        id: p.stored_id ?? \"\",\n        name: p.name ?? \"\",\n        alias_list,\n      };\n    });\n\n    return (\n      <PerformerSelect\n        isMulti\n        className=\"form-control\"\n        isDisabled={!isNew}\n        onSelect={(items) => {\n          if (onChangeFn) {\n            // map the id back to stored_id\n            onChangeFn(items.map((p) => ({ ...p, stored_id: p.id })));\n          }\n        }}\n        values={selectValue}\n        ageFromDate={ageFromDate}\n      />\n    );\n  }\n\n  return (\n    <ScrapedObjectsRow<GQL.ScrapedPerformer>\n      title={title}\n      field={field}\n      result={result}\n      renderObjects={renderScrapedPerformers}\n      onChange={onChange}\n      newObjects={performersCopy}\n      onCreateNew={onCreateNew}\n      getName={(value) => value.name ?? \"\"}\n      onLinkExisting={onLinkExisting}\n    />\n  );\n};\n\nexport const ScrapedGroupsRow: React.FC<\n  IScrapedObjectRowImpl<GQL.ScrapedGroup>\n> = ({\n  title,\n  field,\n  result,\n  onChange,\n  newObjects,\n  onCreateNew,\n  onLinkExisting,\n}) => {\n  const groupsCopy = useMemo(() => {\n    return (\n      newObjects?.map((p) => {\n        const name: string = p.name ?? \"\";\n        return { ...p, name };\n      }) ?? []\n    );\n  }, [newObjects]);\n\n  function renderScrapedGroups(\n    scrapeResult: ScrapeResult<GQL.ScrapedGroup[]>,\n    isNew?: boolean,\n    onChangeFn?: (value: GQL.ScrapedGroup[]) => void\n  ) {\n    const resultValue = isNew\n      ? scrapeResult.newValue\n      : scrapeResult.originalValue;\n    const value = resultValue ?? [];\n\n    const selectValue = value.map((p) => {\n      const aliases: string = \"\";\n      return {\n        id: p.stored_id ?? \"\",\n        name: p.name ?? \"\",\n        aliases,\n      };\n    });\n\n    return (\n      <GroupSelect\n        isMulti\n        className=\"form-control react-select\"\n        isDisabled={!isNew}\n        onSelect={(items) => {\n          if (onChangeFn) {\n            // map the id back to stored_id\n            onChangeFn(items.map((p) => ({ ...p, stored_id: p.id })));\n          }\n        }}\n        values={selectValue}\n      />\n    );\n  }\n\n  return (\n    <ScrapedObjectsRow<GQL.ScrapedGroup>\n      title={title}\n      field={field}\n      result={result}\n      renderObjects={renderScrapedGroups}\n      onChange={onChange}\n      newObjects={groupsCopy}\n      onCreateNew={onCreateNew}\n      getName={(value) => value.name ?? \"\"}\n      onLinkExisting={onLinkExisting}\n    />\n  );\n};\n\nexport const ScrapedTagsRow: React.FC<\n  IScrapedObjectRowImpl<GQL.ScrapedTag>\n> = ({\n  title,\n  field,\n  result,\n  onChange,\n  newObjects,\n  onCreateNew,\n  onLinkExisting,\n}) => {\n  function renderScrapedTags(\n    scrapeResult: ScrapeResult<GQL.ScrapedTag[]>,\n    isNew?: boolean,\n    onChangeFn?: (value: GQL.ScrapedTag[]) => void\n  ) {\n    const resultValue = isNew\n      ? scrapeResult.newValue\n      : scrapeResult.originalValue;\n    const value = resultValue ?? [];\n\n    const selectValue = uniq(value.map((p) => p.stored_id ?? \"\"));\n\n    // we need to use TagIDSelect here because we want to use the local name\n    // of the tag instead of the name from the source\n    return (\n      <TagIDSelect\n        isMulti\n        className=\"form-control\"\n        isDisabled={!isNew}\n        onSelect={(items) => {\n          if (onChangeFn) {\n            // map the id back to stored_id\n            onChangeFn(\n              items.map((p) => ({\n                ...p,\n                stored_id: p.id,\n                alias_list: p.aliases,\n              }))\n            );\n          }\n        }}\n        ids={selectValue}\n      />\n    );\n  }\n\n  return (\n    <ScrapedObjectsRow<GQL.ScrapedTag>\n      title={title}\n      field={field}\n      result={result}\n      renderObjects={renderScrapedTags}\n      onChange={onChange}\n      newObjects={newObjects}\n      onCreateNew={onCreateNew}\n      onLinkExisting={onLinkExisting}\n      getName={getObjectName}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts",
    "content": "import { useToast } from \"src/hooks/Toast\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useGroupCreate,\n  usePerformerCreate,\n  useStudioCreate,\n  useTagCreate,\n} from \"src/core/StashService\";\nimport { ObjectScrapeResult, ScrapeResult } from \"./scrapeResult\";\nimport { useIntl } from \"react-intl\";\nimport { scrapedPerformerToCreateInput } from \"src/core/performers\";\nimport { scrapedGroupToCreateInput } from \"src/core/groups\";\n\nfunction useCreateObject<T>(\n  entityTypeID: string,\n  createFunc: (o: T) => Promise<void>\n) {\n  const Toast = useToast();\n  const intl = useIntl();\n\n  async function createNewObject(o: T) {\n    try {\n      await createFunc(o);\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          {\n            entity: intl\n              .formatMessage({ id: entityTypeID })\n              .toLocaleLowerCase(),\n          }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  return createNewObject;\n}\n\ninterface IUseCreateNewStudioProps {\n  scrapeResult: ObjectScrapeResult<GQL.ScrapedStudio>;\n  setScrapeResult: (\n    scrapeResult: ObjectScrapeResult<GQL.ScrapedStudio>\n  ) => void;\n  setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void;\n  endpoint?: string;\n}\n\nexport function useCreateScrapedStudio(props: IUseCreateNewStudioProps) {\n  const [createStudio] = useStudioCreate();\n\n  const { scrapeResult, setScrapeResult, setNewObject } = props;\n\n  async function createNewStudio(toCreate: GQL.ScrapedStudio) {\n    const input: GQL.StudioCreateInput = {\n      name: toCreate.name,\n      urls: toCreate.urls,\n      aliases:\n        toCreate.aliases\n          ?.split(\",\")\n          .map((a) => a.trim())\n          .filter((a) => a) || [],\n      details: toCreate.details,\n      image: toCreate.image,\n      tag_ids: (toCreate.tags ?? [])\n        .filter((t) => t.stored_id)\n        .map((t) => t.stored_id!),\n    };\n\n    if (props.endpoint && toCreate.remote_site_id) {\n      input.stash_ids = [\n        {\n          endpoint: props.endpoint,\n          stash_id: toCreate.remote_site_id,\n        },\n      ];\n    }\n\n    const result = await createStudio({\n      variables: {\n        input,\n      },\n    });\n\n    // set the new studio as the value\n    setScrapeResult(\n      scrapeResult.cloneWithValue({\n        stored_id: result.data!.studioCreate!.id,\n        name: toCreate.name,\n      })\n    );\n    setNewObject(undefined);\n  }\n\n  return useCreateObject(\"studio\", createNewStudio);\n}\n\ninterface IUseCreateNewObjectProps<T> {\n  scrapeResult: ScrapeResult<T[]>;\n  setScrapeResult: (scrapeResult: ScrapeResult<T[]>) => void;\n  newObjects: T[];\n  setNewObjects: (newObject: T[]) => void;\n  endpoint?: string;\n}\n\nexport function useCreateScrapedPerformer(\n  props: IUseCreateNewObjectProps<GQL.ScrapedPerformer>\n) {\n  const [createPerformer] = usePerformerCreate();\n\n  const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props;\n\n  async function createNewPerformer(toCreate: GQL.ScrapedPerformer) {\n    const input = scrapedPerformerToCreateInput(toCreate, props.endpoint);\n\n    const result = await createPerformer({\n      variables: { input },\n    });\n\n    const newValue = [...(scrapeResult.newValue ?? [])];\n    if (result.data?.performerCreate)\n      newValue.push({\n        stored_id: result.data.performerCreate.id,\n        name: result.data.performerCreate.name,\n      });\n\n    // add the new performer to the new performers value\n    const performerClone = scrapeResult.cloneWithValue(newValue);\n    setScrapeResult(performerClone);\n\n    // remove the performer from the list\n    const newPerformersClone = newObjects.concat();\n    const pIndex = newPerformersClone.findIndex(\n      (p) => p.name === toCreate.name\n    );\n    if (pIndex === -1) throw new Error(\"Could not find performer to remove\");\n\n    newPerformersClone.splice(pIndex, 1);\n\n    setNewObjects(newPerformersClone);\n  }\n\n  return useCreateObject(\"performer\", createNewPerformer);\n}\n\nexport function useCreateScrapedGroup(\n  props: IUseCreateNewObjectProps<GQL.ScrapedGroup>\n) {\n  const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props;\n  const [createGroup] = useGroupCreate();\n\n  async function createNewGroup(toCreate: GQL.ScrapedGroup) {\n    const input = scrapedGroupToCreateInput(toCreate);\n\n    const result = await createGroup({\n      variables: { input: input },\n    });\n\n    const newValue = [...(scrapeResult.newValue ?? [])];\n    if (result.data?.groupCreate)\n      newValue.push({\n        stored_id: result.data.groupCreate.id,\n        name: result.data.groupCreate.name,\n      });\n\n    // add the new object to the new object value\n    const resultClone = scrapeResult.cloneWithValue(newValue);\n    setScrapeResult(resultClone);\n\n    // remove the object from the list\n    const newObjectsClone = newObjects.concat();\n    const pIndex = newObjectsClone.findIndex((p) => p.name === toCreate.name);\n    if (pIndex === -1) throw new Error(\"Could not find group to remove\");\n\n    newObjectsClone.splice(pIndex, 1);\n\n    setNewObjects(newObjectsClone);\n  }\n\n  return useCreateObject(\"group\", createNewGroup);\n}\n\nexport function useLinkScrapedTag(\n  props: IUseCreateNewObjectProps<GQL.ScrapedTag>\n) {\n  const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props;\n\n  function linkTag(id: string, matchedName: string, scrapedName: string) {\n    const newValue = [...(scrapeResult.newValue ?? [])];\n    newValue.push({\n      stored_id: id,\n      name: matchedName,\n    });\n\n    // add the new tag to the new tags value\n    const tagClone = scrapeResult.cloneWithValue(newValue);\n    setScrapeResult(tagClone);\n\n    // remove the tag from the list\n    const newTagsClone = newObjects.concat();\n    const pIndex = newTagsClone.findIndex((p) => p.name === scrapedName);\n    if (pIndex === -1) throw new Error(\"Could not find tag to remove\");\n\n    newTagsClone.splice(pIndex, 1);\n\n    setNewObjects(newTagsClone);\n  }\n\n  return linkTag;\n}\n\nexport function useCreateScrapedTag(\n  props: IUseCreateNewObjectProps<GQL.ScrapedTag>\n) {\n  const [createTag] = useTagCreate();\n  const linkTag = useLinkScrapedTag(props);\n\n  async function createNewTag(toCreate: GQL.ScrapedTag) {\n    const input: GQL.TagCreateInput = {\n      name: toCreate.name ?? \"\",\n    };\n\n    if (props.endpoint && toCreate.remote_site_id) {\n      input.stash_ids = [\n        {\n          endpoint: props.endpoint,\n          stash_id: toCreate.remote_site_id,\n        },\n      ];\n    }\n\n    const result = await createTag({\n      variables: { input },\n    });\n\n    if (result.data?.tagCreate)\n      linkTag(\n        result.data.tagCreate.id,\n        result.data.tagCreate.name,\n        toCreate.name ?? \"\"\n      );\n  }\n\n  return useCreateObject(\"tag\", createNewTag);\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts",
    "content": "import lodashIsEqual from \"lodash-es/isEqual\";\nimport clone from \"lodash-es/clone\";\nimport { IHasStoredID } from \"src/utils/data\";\n\n/* eslint-disable-next-line @typescript-eslint/no-explicit-any */\nexport type CustomFieldScrapeResults = Map<string, ZeroableScrapeResult<any>>;\n\nexport class ScrapeResult<T> {\n  public newValue?: T;\n  public originalValue?: T;\n  public scraped: boolean = false;\n  public useNewValue: boolean = false;\n  private isEqual: (\n    v1: T | undefined | null,\n    v2: T | undefined | null\n  ) => boolean;\n\n  public constructor(\n    originalValue?: T | null,\n    newValue?: T | null,\n    useNewValue?: boolean,\n    isEqual: (\n      v1: T | undefined | null,\n      v2: T | undefined | null\n    ) => boolean = lodashIsEqual\n  ) {\n    this.originalValue = originalValue ?? undefined;\n    this.newValue = newValue ?? undefined;\n    this.isEqual = isEqual;\n\n    // NOTE: this means that zero values are treated as null\n    // this is incorrect for numbers and booleans, but correct for strings\n    const hasNewValue = !!this.newValue;\n\n    const valuesEqual = isEqual(originalValue, newValue);\n    this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual);\n    this.scraped = hasNewValue && !valuesEqual;\n  }\n\n  public setOriginalValue(value?: T) {\n    this.originalValue = value;\n    this.newValue = value;\n  }\n\n  public cloneWithValue(value?: T) {\n    const ret = clone(this);\n\n    ret.newValue = value;\n    ret.useNewValue = !this.isEqual(ret.newValue, ret.originalValue);\n\n    // #2691 - if we're setting the value, assume it should be treated as\n    // scraped\n    ret.scraped = true;\n\n    return ret;\n  }\n\n  public getNewValue() {\n    if (this.useNewValue) {\n      return this.newValue;\n    }\n  }\n}\n\n// for types where !!value is a valid value (boolean and number)\nexport class ZeroableScrapeResult<T> extends ScrapeResult<T> {\n  public constructor(\n    originalValue?: T | null,\n    newValue?: T | null,\n    useNewValue?: boolean,\n    isEqual: (\n      v1: T | undefined | null,\n      v2: T | undefined | null\n    ) => boolean = lodashIsEqual\n  ) {\n    super(originalValue, newValue, useNewValue, isEqual);\n\n    const hasNewValue = this.newValue !== undefined;\n\n    const valuesEqual = isEqual(originalValue, newValue);\n    this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual);\n    this.scraped = hasNewValue && !valuesEqual;\n  }\n}\n\nfunction storedIDsEqual<T extends IHasStoredID>(\n  o1: T[] | undefined | null,\n  o2: T[] | undefined | null\n) {\n  return (\n    !!o1 &&\n    !!o2 &&\n    o1.length === o2.length &&\n    o1.every((o) => {\n      return o2.find((oo) => o.stored_id === oo.stored_id);\n    })\n  );\n}\n\nexport class ObjectListScrapeResult<\n  T extends IHasStoredID\n> extends ScrapeResult<T[]> {\n  public constructor(\n    originalValue?: T[] | null,\n    newValue?: T[] | null,\n    useNewValue?: boolean\n  ) {\n    super(originalValue, newValue, useNewValue, storedIDsEqual);\n  }\n}\n\nexport class ObjectScrapeResult<\n  T extends IHasStoredID\n> extends ScrapeResult<T> {\n  public constructor(\n    originalValue?: T | null,\n    newValue?: T | null,\n    useNewValue?: boolean\n  ) {\n    super(\n      originalValue,\n      newValue,\n      useNewValue,\n      (o1, o2) => o1?.stored_id === o2?.stored_id\n    );\n  }\n}\n\nexport function hasScrapedValues(values: { scraped: boolean }[]): boolean {\n  return values.some((r) => r.scraped);\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx",
    "content": "import { useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ObjectListScrapeResult } from \"./scrapeResult\";\nimport { sortStoredIdObjects } from \"src/utils/data\";\nimport { Tag } from \"src/components/Tags/TagSelect\";\nimport { useCreateScrapedTag, useLinkScrapedTag } from \"./createObjects\";\nimport { ScrapedTagsRow } from \"./ScrapedObjectsRow\";\nimport { CreateLinkTagDialog } from \"src/components/Shared/ScrapeDialog/CreateLinkTagDialog\";\nimport { useTagCreate, useTagUpdate } from \"src/core/StashService\";\nimport { toastOperation, useToast } from \"src/hooks/Toast\";\n\nexport function useScrapedTags(\n  existingTags: Tag[],\n  scrapedTags?: GQL.Maybe<GQL.ScrapedTag[]>,\n  endpoint?: string\n) {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(\n    new ObjectListScrapeResult<GQL.ScrapedTag>(\n      sortStoredIdObjects(\n        existingTags.map((t) => ({\n          stored_id: t.id,\n          name: t.name,\n        }))\n      ),\n      sortStoredIdObjects(scrapedTags ?? undefined)\n    )\n  );\n\n  const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(\n    scrapedTags?.filter((t) => !t.stored_id) ?? []\n  );\n  const [linkedTag, setLinkedTag] = useState<GQL.ScrapedTag | null>(null);\n\n  const createNewTag = useCreateScrapedTag({\n    scrapeResult: tags,\n    setScrapeResult: setTags,\n    newObjects: newTags,\n    setNewObjects: setNewTags,\n    endpoint,\n  });\n\n  const [createTag] = useTagCreate();\n  const [updateTag] = useTagUpdate();\n\n  const linkScrapedTag = useLinkScrapedTag({\n    scrapeResult: tags,\n    setScrapeResult: setTags,\n    newObjects: newTags,\n    setNewObjects: setNewTags,\n  });\n\n  async function handleLinkTagResult(tag: {\n    create?: GQL.TagCreateInput;\n    update?: GQL.TagUpdateInput;\n  }) {\n    if (tag.create) {\n      await toastOperation(\n        Toast,\n        async () => {\n          // create the new tag\n          const result = await createTag({ variables: { input: tag.create! } });\n\n          // adjust scrape result\n          if (result.data?.tagCreate) {\n            linkScrapedTag(\n              result.data.tagCreate.id,\n              result.data.tagCreate.name,\n              linkedTag?.name ?? \"\"\n            );\n          }\n        },\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"tag\" }).toLocaleLowerCase(),\n          }\n        )\n      )();\n    } else if (tag.update) {\n      // link existing tag\n      await toastOperation(\n        Toast,\n        async () => {\n          const result = await updateTag({ variables: { input: tag.update! } });\n\n          // adjust scrape result\n          if (result.data?.tagUpdate) {\n            linkScrapedTag(\n              result.data.tagUpdate.id,\n              result.data.tagUpdate.name,\n              linkedTag?.name ?? \"\"\n            );\n          }\n        },\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"tag\" }).toLocaleLowerCase(),\n          }\n        )\n      )();\n    }\n\n    setLinkedTag(null);\n  }\n\n  const linkDialog = linkedTag ? (\n    <CreateLinkTagDialog\n      tag={linkedTag}\n      onClose={handleLinkTagResult}\n      endpoint={endpoint}\n    />\n  ) : null;\n\n  const scrapedTagsRow = (\n    <ScrapedTagsRow\n      field=\"tags\"\n      title={intl.formatMessage({ id: \"tags\" })}\n      result={tags}\n      onChange={(value) => setTags(value)}\n      newObjects={newTags}\n      onCreateNew={createNewTag}\n      onLinkExisting={(l) => setLinkedTag(l)}\n    />\n  );\n\n  return {\n    tags,\n    newTags,\n    linkDialog,\n    scrapedTagsRow,\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ScraperMenu.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport { Dropdown, Button } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"./Icon\";\nimport { stashboxDisplayName } from \"src/utils/stashbox\";\nimport { ScraperSourceInput, StashBox } from \"src/core/generated-graphql\";\nimport { faSyncAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { ClearableInput } from \"./ClearableInput\";\nimport useFocus from \"src/utils/focus\";\nimport ScreenUtils from \"src/utils/screen\";\n\nexport const ScraperMenu: React.FC<{\n  toggle: React.ReactNode;\n  variant?: string;\n  stashBoxes?: StashBox[];\n  scrapers: { id: string; name: string }[];\n  onScraperClicked: (s: ScraperSourceInput) => void;\n  onReloadScrapers: () => void;\n}> = ({\n  toggle,\n  variant,\n  stashBoxes,\n  scrapers,\n  onScraperClicked,\n  onReloadScrapers,\n}) => {\n  const intl = useIntl();\n  const [filter, setFilter] = useState(\"\");\n\n  const focusOnOpen = !ScreenUtils.isTouch();\n  const focusRef = useFocus();\n  const [, setFocus] = focusRef;\n\n  const filteredStashboxes = useMemo(() => {\n    if (!stashBoxes) return [];\n    if (!filter) return stashBoxes;\n\n    return stashBoxes.filter((s) =>\n      s.name.toLowerCase().includes(filter.toLowerCase())\n    );\n  }, [stashBoxes, filter]);\n\n  const filteredScrapers = useMemo(() => {\n    if (!filter) return scrapers;\n\n    return scrapers.filter(\n      (s) =>\n        s.name.toLowerCase().includes(filter.toLowerCase()) ||\n        s.id.toLowerCase().includes(filter.toLowerCase())\n    );\n  }, [scrapers, filter]);\n\n  return (\n    <Dropdown\n      className=\"scraper-menu\"\n      title={intl.formatMessage({ id: \"actions.scrape_query\" })}\n      onToggle={(v) => {\n        if (focusOnOpen && v) setTimeout(() => setFocus(true), 0);\n      }}\n    >\n      <Dropdown.Toggle variant={variant}>{toggle}</Dropdown.Toggle>\n\n      <Dropdown.Menu>\n        <div className=\"scraper-filter-container\">\n          <ClearableInput\n            placeholder={`${intl.formatMessage({ id: \"filter\" })}...`}\n            value={filter}\n            setValue={setFilter}\n            focus={focusRef}\n          />\n          <Button\n            onClick={onReloadScrapers}\n            className=\"reload-button\"\n            title={intl.formatMessage({ id: \"actions.reload_scrapers\" })}\n          >\n            <Icon icon={faSyncAlt} />\n          </Button>\n        </div>\n\n        {filteredStashboxes.map((s, index) => (\n          <Dropdown.Item\n            key={s.endpoint}\n            onClick={() =>\n              onScraperClicked({\n                stash_box_endpoint: s.endpoint,\n              })\n            }\n          >\n            {stashboxDisplayName(s.name, index)}\n          </Dropdown.Item>\n        ))}\n\n        {filteredStashboxes.length > 0 && filteredScrapers.length > 0 && (\n          <Dropdown.Divider />\n        )}\n\n        {filteredScrapers.map((s) => (\n          <Dropdown.Item\n            key={s.name}\n            onClick={() => onScraperClicked({ scraper_id: s.id })}\n          >\n            {s.name}\n          </Dropdown.Item>\n        ))}\n      </Dropdown.Menu>\n    </Dropdown>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Select.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport Select, {\n  OnChangeValue,\n  StylesConfig,\n  OptionProps,\n  components as reactSelectComponents,\n  Options,\n  MenuListProps,\n  GroupBase,\n  OptionsOrGroups,\n  DropdownIndicatorProps,\n} from \"react-select\";\nimport CreatableSelect from \"react-select/creatable\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useMarkerStrings } from \"src/core/StashService\";\nimport { SelectComponents } from \"react-select/dist/declarations/src/components\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { objectTitle } from \"src/core/files\";\nimport { defaultMaxOptionsShown } from \"src/core/config\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { PerformerIDSelect } from \"../Performers/PerformerSelect\";\nimport { Icon } from \"./Icon\";\nimport { faTableColumns } from \"@fortawesome/free-solid-svg-icons\";\nimport { TagIDSelect } from \"../Tags/TagSelect\";\nimport { StudioIDSelect } from \"../Studios/StudioSelect\";\nimport { GalleryIDSelect } from \"../Galleries/GallerySelect\";\nimport { GroupIDSelect } from \"../Groups/GroupSelect\";\nimport { SceneIDSelect } from \"../Scenes/SceneSelect\";\n\nexport type SelectObject = {\n  id: string;\n  name?: string | null;\n  title?: string | null;\n};\ntype Option = { value: string; label: string };\n\ninterface ITypeProps {\n  type?:\n    | \"performers\"\n    | \"studios\"\n    | \"tags\"\n    | \"scene_tags\"\n    | \"performer_tags\"\n    | \"scenes\"\n    | \"groups\"\n    | \"galleries\";\n}\ninterface IFilterProps {\n  ids?: string[];\n  initialIds?: string[];\n  onSelect?: (item: SelectObject[]) => void;\n  noSelectionString?: string;\n  className?: string;\n  isMulti?: boolean;\n  isClearable?: boolean;\n  isDisabled?: boolean;\n  creatable?: boolean;\n  menuPortalTarget?: HTMLElement | null;\n}\ninterface ISelectProps<T extends boolean> {\n  className?: string;\n  items: Option[];\n  selectedOptions?: OnChangeValue<Option, T>;\n  creatable?: boolean;\n  onCreateOption?: (value: string) => void;\n  isLoading: boolean;\n  isDisabled?: boolean;\n  onChange: (item: OnChangeValue<Option, T>) => void;\n  initialIds?: string[];\n  isMulti: T;\n  isClearable?: boolean;\n  onInputChange?: (input: string) => void;\n  components?: Partial<SelectComponents<Option, T, GroupBase<Option>>>;\n  filterOption?: (option: Option, rawInput: string) => boolean;\n  isValidNewOption?: (\n    inputValue: string,\n    value: Options<Option>,\n    options: OptionsOrGroups<Option, GroupBase<Option>>\n  ) => boolean;\n  placeholder?: string;\n  showDropdown?: boolean;\n  groupHeader?: string;\n  menuPortalTarget?: HTMLElement | null;\n  closeMenuOnSelect?: boolean;\n  noOptionsMessage?: string | null;\n}\ntype TitledObject = { id: string; title: string };\ninterface ITitledSelect {\n  className?: string;\n  selected: TitledObject[];\n  onSelect: (items: TitledObject[]) => void;\n  isMulti?: boolean;\n  disabled?: boolean;\n}\n\nconst getSelectedItems = (selectedItems: OnChangeValue<Option, boolean>) => {\n  if (Array.isArray(selectedItems)) {\n    return selectedItems;\n  } else if (selectedItems) {\n    return [selectedItems];\n  } else {\n    return [];\n  }\n};\n\nconst LimitedSelectMenu = <T extends boolean>(\n  props: MenuListProps<Option, T, GroupBase<Option>>\n) => {\n  const { configuration } = useConfigurationContext();\n  const maxOptionsShown =\n    configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown;\n\n  const [hiddenCount, setHiddenCount] = useState<number>(0);\n  const hiddenCountStyle = {\n    padding: \"8px 12px\",\n    opacity: \"50%\",\n  };\n  const menuChildren = useMemo(() => {\n    if (Array.isArray(props.children)) {\n      // limit the number of select options showing in the select dropdowns\n      // always showing the 'Create \"...\"' option when it exists\n      let creationOptionIndex = (props.children as React.ReactNode[]).findIndex(\n        (child: React.ReactNode) => {\n          let maybeCreatableOption = child as React.ReactElement<\n            OptionProps<\n              Option & { __isNew__: boolean },\n              T,\n              GroupBase<Option & { __isNew__: boolean }>\n            >,\n            \"\"\n          >;\n          return maybeCreatableOption?.props?.data?.__isNew__;\n        }\n      );\n      if (creationOptionIndex >= maxOptionsShown) {\n        setHiddenCount(props.children.length - maxOptionsShown - 1);\n        return props.children\n          .slice(0, maxOptionsShown - 1)\n          .concat([props.children[creationOptionIndex]]);\n      } else {\n        setHiddenCount(Math.max(props.children.length - maxOptionsShown, 0));\n        return props.children.slice(0, maxOptionsShown);\n      }\n    }\n    setHiddenCount(0);\n    return props.children;\n  }, [props.children, maxOptionsShown]);\n  return (\n    <reactSelectComponents.MenuList {...props}>\n      {menuChildren}\n      {hiddenCount > 0 && (\n        <div style={hiddenCountStyle}>{hiddenCount} Options Hidden</div>\n      )}\n    </reactSelectComponents.MenuList>\n  );\n};\n\nconst SelectComponent = <T extends boolean>({\n  type,\n  initialIds,\n  onChange,\n  className,\n  items,\n  selectedOptions,\n  isLoading,\n  isDisabled = false,\n  onCreateOption,\n  isClearable = true,\n  creatable = false,\n  isMulti,\n  onInputChange,\n  filterOption,\n  isValidNewOption,\n  components,\n  placeholder,\n  showDropdown = true,\n  groupHeader,\n  menuPortalTarget,\n  closeMenuOnSelect = true,\n  noOptionsMessage = type !== \"tags\" ? \"None\" : null,\n}: ISelectProps<T> & ITypeProps) => {\n  const values = items.filter((item) => initialIds?.indexOf(item.value) !== -1);\n  const defaultValue = (isMulti ? values : values[0] ?? null) as OnChangeValue<\n    Option,\n    T\n  >;\n\n  const options = groupHeader\n    ? [\n        {\n          label: groupHeader,\n          options: items,\n        },\n      ]\n    : items;\n\n  const styles: StylesConfig<Option, T> = {\n    option: (base) => ({\n      ...base,\n      color: \"#000\",\n    }),\n    container: (base, state) => ({\n      ...base,\n      zIndex: state.isFocused ? 10 : base.zIndex,\n    }),\n    multiValueRemove: (base, state) => ({\n      ...base,\n      color: state.isFocused ? base.color : \"#333333\",\n    }),\n  };\n\n  const props = {\n    options,\n    value: selectedOptions,\n    className,\n    classNamePrefix: \"react-select\",\n    onChange,\n    isMulti,\n    isClearable,\n    defaultValue: defaultValue ?? undefined,\n    noOptionsMessage: () => noOptionsMessage,\n    placeholder: isDisabled ? \"\" : placeholder,\n    onInputChange,\n    filterOption,\n    isValidNewOption,\n    isDisabled,\n    isLoading,\n    styles,\n    closeMenuOnSelect,\n    menuPortalTarget,\n    components: {\n      ...components,\n      MenuList: LimitedSelectMenu,\n      IndicatorSeparator: () => null,\n      ...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),\n      ...(isDisabled && { MultiValueRemove: () => null }),\n    },\n  };\n\n  return creatable ? (\n    <CreatableSelect\n      {...props}\n      isDisabled={isLoading || isDisabled}\n      onCreateOption={onCreateOption}\n    />\n  ) : (\n    <Select {...props} />\n  );\n};\n\nexport const GallerySelect: React.FC<\n  IFilterProps & { excludeIds?: string[] }\n> = (props) => {\n  return <GalleryIDSelect {...props} />;\n};\n\nexport const SceneSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (\n  props\n) => {\n  return <SceneIDSelect {...props} />;\n};\n\nexport const ImageSelect: React.FC<ITitledSelect> = (props) => {\n  const [query, setQuery] = useState<string>(\"\");\n  const { data, loading } = GQL.useFindImagesQuery({\n    skip: query === \"\",\n    variables: {\n      filter: {\n        q: query,\n      },\n    },\n  });\n\n  const images = data?.findImages.images ?? [];\n  const items = images.map((s) => ({\n    label: objectTitle(s),\n    value: s.id,\n  }));\n\n  const onInputChange = useDebounce(setQuery, 500);\n\n  const onChange = (selectedItems: OnChangeValue<Option, boolean>) => {\n    const selected = getSelectedItems(selectedItems);\n    props.onSelect(\n      (selected ?? []).map((s) => ({\n        id: s.value,\n        title: s.label,\n      }))\n    );\n  };\n\n  const options = props.selected.map((s) => ({\n    value: s.id,\n    label: s.title,\n  }));\n\n  return (\n    <SelectComponent\n      onChange={onChange}\n      onInputChange={onInputChange}\n      isLoading={loading}\n      items={items}\n      selectedOptions={options}\n      isMulti={props.isMulti ?? false}\n      placeholder=\"Search for image...\"\n      noOptionsMessage={query === \"\" ? null : \"No images found.\"}\n      showDropdown={false}\n      isDisabled={props.disabled}\n    />\n  );\n};\n\ninterface IMarkerSuggestProps {\n  initialMarkerTitle?: string;\n  onChange: (title: string) => void;\n}\nexport const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = (props) => {\n  const { data, loading } = useMarkerStrings();\n  const suggestions = data?.markerStrings ?? [];\n\n  const onChange = (selectedItem: OnChangeValue<Option, false>) =>\n    props.onChange(selectedItem?.value ?? \"\");\n\n  const items = suggestions.map((item) => ({\n    label: item?.title ?? \"\",\n    value: item?.title ?? \"\",\n  }));\n  const initialIds = props.initialMarkerTitle ? [props.initialMarkerTitle] : [];\n\n  // add initial value to items if still loading, to ensure existing value\n  // is populated\n  if (loading && initialIds.length > 0) {\n    items.push({\n      label: initialIds[0],\n      value: initialIds[0],\n    });\n  }\n\n  return (\n    <SelectComponent\n      isMulti={false}\n      creatable\n      onChange={onChange}\n      isLoading={loading}\n      items={items}\n      initialIds={initialIds}\n      placeholder=\"Marker title...\"\n      className=\"select-suggest\"\n      showDropdown={false}\n      groupHeader=\"Previously used titles...\"\n    />\n  );\n};\n\nexport const PerformerSelect: React.FC<IFilterProps> = (props) => {\n  return <PerformerIDSelect {...props} />;\n};\n\nexport const StudioSelect: React.FC<\n  IFilterProps & { excludeIds?: string[] }\n> = (props) => {\n  return <StudioIDSelect {...props} />;\n};\n\nexport const GroupSelect: React.FC<IFilterProps> = (props) => {\n  return <GroupIDSelect {...props} />;\n};\n\nexport const TagSelect: React.FC<\n  IFilterProps & { excludeIds?: string[]; hoverPlacement?: Placement }\n> = (props) => {\n  return <TagIDSelect {...props} />;\n};\n\nexport const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => {\n  switch (props.type) {\n    case \"performers\":\n      return <PerformerSelect {...props} creatable={false} />;\n    case \"studios\":\n      return <StudioSelect {...props} creatable={false} />;\n    case \"scenes\":\n      return <SceneSelect {...props} creatable={false} />;\n    case \"groups\":\n      return <GroupSelect {...props} creatable={false} />;\n    case \"galleries\":\n      return <GallerySelect {...props} creatable={false} />;\n    default:\n      return <TagSelect {...props} creatable={false} />;\n  }\n};\n\ninterface IStringListSelect {\n  options?: string[];\n  value: string[];\n}\n\nexport const StringListSelect: React.FC<IStringListSelect> = ({\n  options = [],\n  value,\n}) => {\n  const translatedOptions = useMemo(() => {\n    return options.map((o) => {\n      return { label: o, value: o };\n    });\n  }, [options]);\n  const translatedValue = useMemo(() => {\n    return value.map((o) => {\n      return { label: o, value: o };\n    });\n  }, [value]);\n\n  const styles: StylesConfig<Option, true> = {\n    option: (base) => ({\n      ...base,\n      color: \"#000\",\n    }),\n    container: (base, state) => ({\n      ...base,\n      zIndex: state.isFocused ? 10 : base.zIndex,\n    }),\n    multiValueRemove: (base, state) => ({\n      ...base,\n      color: state.isFocused ? base.color : \"#333333\",\n    }),\n  };\n\n  return (\n    <Select\n      classNamePrefix=\"react-select\"\n      className=\"form-control react-select\"\n      options={translatedOptions}\n      value={translatedValue}\n      isMulti\n      isDisabled\n      styles={styles}\n      components={{\n        IndicatorSeparator: () => null,\n        ...{ DropdownIndicator: () => null },\n        ...{ MultiValueRemove: () => null },\n      }}\n    />\n  );\n};\n\ninterface IListSelect<T> {\n  options?: T[];\n  value: T[];\n  toOptionType: (v: T) => { label: string; value: string };\n  fromOptionType?: (o: { label: string; value: string }) => T;\n}\n\nexport const ListSelect = <T extends {}>(props: IListSelect<T>) => {\n  const { options = [], value, toOptionType } = props;\n\n  const translatedOptions = useMemo(() => {\n    return options.map(toOptionType);\n  }, [options, toOptionType]);\n  const translatedValue = useMemo(() => {\n    return value.map(toOptionType);\n  }, [value, toOptionType]);\n\n  const styles: StylesConfig<Option, true> = {\n    option: (base) => ({\n      ...base,\n      color: \"#000\",\n    }),\n    container: (base, state) => ({\n      ...base,\n      zIndex: state.isFocused ? 10 : base.zIndex,\n    }),\n    multiValueRemove: (base, state) => ({\n      ...base,\n      color: state.isFocused ? base.color : \"#333333\",\n    }),\n  };\n\n  return (\n    <Select\n      classNamePrefix=\"react-select\"\n      className=\"form-control react-select\"\n      options={translatedOptions}\n      value={translatedValue}\n      isMulti\n      isDisabled\n      styles={styles}\n      components={{\n        IndicatorSeparator: () => null,\n        ...{ DropdownIndicator: () => null },\n        ...{ MultiValueRemove: () => null },\n      }}\n    />\n  );\n};\n\ntype DisableOption = Option & {\n  isDisabled?: boolean;\n  className?: string;\n};\n\ninterface ICheckBoxSelectProps {\n  options: DisableOption[];\n  selectedOptions?: DisableOption[];\n  onChange: (item: OnChangeValue<DisableOption, true>) => void;\n}\n\nexport const CheckBoxSelect: React.FC<ICheckBoxSelectProps> = ({\n  options,\n  selectedOptions,\n  onChange,\n}) => {\n  const Option = (props: OptionProps<DisableOption, true>) => (\n    <reactSelectComponents.Option\n      {...props}\n      className={`${props.className || \"\"} ${props.data.className || \"\"}`}\n      // data values don't seem to be included in props.innerProps by default\n      innerProps={\n        {\n          ...props.innerProps,\n          \"data-value\": props.data.value,\n        } as React.DetailedHTMLProps<\n          React.HTMLAttributes<HTMLDivElement>,\n          HTMLDivElement\n        >\n      }\n    >\n      <input\n        type=\"checkbox\"\n        disabled={props.isDisabled}\n        checked={props.isSelected}\n        onChange={() => null}\n        className=\"mr-1\"\n      />\n      <label>{props.label}</label>\n    </reactSelectComponents.Option>\n  );\n\n  const DropdownIndicator = (\n    props: DropdownIndicatorProps<DisableOption, true>\n  ) => (\n    <reactSelectComponents.DropdownIndicator {...props}>\n      <Icon icon={faTableColumns} className=\"column-select\" />\n    </reactSelectComponents.DropdownIndicator>\n  );\n\n  return (\n    <Select\n      className=\"CheckBoxSelect\"\n      options={options}\n      value={selectedOptions}\n      isMulti\n      closeMenuOnSelect={false}\n      hideSelectedOptions={false}\n      isSearchable={false}\n      isClearable={false}\n      components={{\n        DropdownIndicator,\n        Option,\n        ValueContainer: () => null,\n        IndicatorSeparator: () => null,\n      }}\n      onChange={onChange}\n      styles={{\n        control: (base) => ({\n          ...base,\n          height: \"25px\",\n          width: \"25px\",\n          backgroundColor: \"none\",\n          border: \"none\",\n          transition: \"none\",\n          cursor: \"pointer\",\n        }),\n        dropdownIndicator: (base) => ({\n          ...base,\n          color: \"rgb(255, 255, 255)\",\n          padding: \"0\",\n        }),\n        menu: (base) => ({\n          ...base,\n          backgroundColor: \"rgb(57, 75, 89)\",\n        }),\n        option: (base, fprops) => ({\n          ...base,\n          backgroundColor: fprops.isFocused\n            ? \"rgb(37, 49, 58)\"\n            : \"rgb(57, 75, 89)\",\n          padding: \"0px 12px\",\n        }),\n        menuList: (base) => ({\n          ...base,\n          position: \"fixed\",\n        }),\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/Sidebar.tsx",
    "content": "import React, {\n  PropsWithChildren,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { CollapseButton } from \"./CollapseButton\";\nimport { useOnOutsideClick } from \"src/hooks/OutsideClick\";\nimport ScreenUtils, { useMediaQuery } from \"src/utils/screen\";\nimport { IViewConfig, useInterfaceLocalForage } from \"src/hooks/LocalForage\";\nimport { View } from \"../List/views\";\nimport cx from \"classnames\";\nimport { Button, CollapseProps } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { Icon } from \"./Icon\";\nimport { faSliders } from \"@fortawesome/free-solid-svg-icons\";\nimport { useHistory } from \"react-router-dom\";\n\nexport type SidebarSectionStates = Record<string, boolean>;\n\n// this needs to correspond to the CSS media query that overlaps the sidebar over content\nconst fixedSidebarMediaQuery = \"only screen and (max-width: 767px)\";\n\nexport const Sidebar: React.FC<\n  PropsWithChildren<{\n    hide?: boolean;\n    onHide?: () => void;\n  }>\n> = ({ hide, onHide, children }) => {\n  const ref = React.useRef<HTMLDivElement>(null);\n\n  const closeOnOutsideClick = useMediaQuery(fixedSidebarMediaQuery) && !hide;\n\n  useOnOutsideClick(\n    ref,\n    !closeOnOutsideClick ? undefined : onHide,\n    \"ignore-sidebar-outside-click\"\n  );\n\n  return (\n    <div ref={ref} className=\"sidebar\">\n      {children}\n    </div>\n  );\n};\n\n// SidebarPane is a container for a Sidebar and content.\n// It is expected that the children will be two elements:\n// a Sidebar and a content element.\nexport const SidebarPane: React.FC<\n  PropsWithChildren<{\n    hideSidebar?: boolean;\n  }>\n> = ({ hideSidebar = false, children }) => {\n  return (\n    <div className={cx(\"sidebar-pane\", { \"hide-sidebar\": hideSidebar })}>\n      {children}\n    </div>\n  );\n};\n\nexport const SidebarToggleButton: React.FC<{\n  onClick: () => void;\n}> = ({ onClick }) => {\n  const intl = useIntl();\n  return (\n    <div className=\"sidebar-toggle-button-container\">\n      <Button\n        className=\"sidebar-toggle-button ignore-sidebar-outside-click minimal\"\n        variant=\"secondary\"\n        onClick={onClick}\n        title={intl.formatMessage({ id: \"actions.sidebar.toggle\" })}\n      >\n        <Icon icon={faSliders} />\n      </Button>\n    </div>\n  );\n};\n\nexport const SidebarPaneContent: React.FC<{ onSidebarToggle: () => void }> = ({\n  onSidebarToggle,\n  children,\n}) => {\n  return (\n    <div className=\"sidebar-pane-content\">\n      <SidebarToggleButton onClick={onSidebarToggle} />\n      {children}\n    </div>\n  );\n};\n\ninterface IContext {\n  sectionOpen: SidebarSectionStates;\n  setSectionOpen: (section: string, open: boolean) => void;\n}\n\nexport const SidebarStateContext = React.createContext<IContext | null>(null);\n\nexport interface ISidebarSectionProps {\n  text: React.ReactNode;\n  className?: string;\n  outsideCollapse?: React.ReactNode;\n  onOpen?: () => void;\n  // used to store open/closed state in SidebarStateContext\n  sectionID?: string;\n}\n\nexport const SidebarSection: React.FC<\n  PropsWithChildren<ISidebarSectionProps>\n> = ({\n  className = \"\",\n  text,\n  outsideCollapse,\n  onOpen,\n  sectionID = \"\",\n  children,\n}) => {\n  // this is optional\n  const contextState = React.useContext(SidebarStateContext);\n  const openState =\n    !contextState || !sectionID\n      ? undefined\n      : contextState.sectionOpen[sectionID] ?? undefined;\n\n  function onOpenInternal(open: boolean) {\n    if (contextState && sectionID) {\n      contextState.setSectionOpen(sectionID, open);\n    }\n  }\n\n  useEffect(() => {\n    if (openState && onOpen) {\n      onOpen();\n    }\n  }, [openState, onOpen]);\n\n  const collapseProps: Partial<CollapseProps> = {\n    mountOnEnter: true,\n    unmountOnExit: true,\n  };\n  return (\n    <CollapseButton\n      className={`sidebar-section ${className}`}\n      collapseProps={collapseProps}\n      text={text}\n      outsideCollapse={outsideCollapse}\n      onOpenChanged={onOpenInternal}\n      open={openState}\n    >\n      {children}\n    </CollapseButton>\n  );\n};\n\n// show sidebar by default if not on mobile\nexport function defaultShowSidebar() {\n  return !ScreenUtils.matchesMediaQuery(fixedSidebarMediaQuery);\n}\n\nexport function useSidebarState(view?: View) {\n  const [interfaceLocalForage, setInterfaceLocalForage] =\n    useInterfaceLocalForage();\n  const history = useHistory();\n\n  const { data: interfaceLocalForageData, loading } = interfaceLocalForage;\n\n  const viewConfig: IViewConfig = useMemo(() => {\n    return view ? interfaceLocalForageData?.viewConfig?.[view] || {} : {};\n  }, [view, interfaceLocalForageData]);\n\n  const [showSidebar, setShowSidebar] = useState<boolean>();\n  const [sectionOpen, setSectionOpen] = useState<SidebarSectionStates>();\n\n  // set initial state once loading is done\n  useEffect(() => {\n    if (showSidebar !== undefined) return;\n\n    if (!view) {\n      setShowSidebar(defaultShowSidebar());\n      return;\n    }\n\n    if (loading) return;\n\n    // only show sidebar by default on large screens\n    setShowSidebar(!!viewConfig.showSidebar && defaultShowSidebar());\n    setSectionOpen(\n      (history.location.state as { sectionOpen?: SidebarSectionStates })\n        ?.sectionOpen || {}\n    );\n  }, [\n    view,\n    loading,\n    showSidebar,\n    viewConfig.showSidebar,\n    history.location.state,\n  ]);\n\n  const onSetShowSidebar = useCallback(\n    (show: boolean | ((prevState: boolean | undefined) => boolean)) => {\n      const nv = typeof show === \"function\" ? show(showSidebar) : show;\n      setShowSidebar(nv);\n      if (view === undefined) return;\n\n      setInterfaceLocalForage((prev) => ({\n        ...prev,\n        viewConfig: {\n          ...prev.viewConfig,\n          [view]: {\n            ...viewConfig,\n            showSidebar: nv,\n          },\n        },\n      }));\n    },\n    [showSidebar, setInterfaceLocalForage, view, viewConfig]\n  );\n\n  const onSetSectionOpen = useCallback(\n    (section: string, open: boolean) => {\n      const newSectionOpen = { ...sectionOpen, [section]: open };\n      setSectionOpen(newSectionOpen);\n      if (view === undefined) return;\n\n      history.replace({\n        ...history.location,\n        state: {\n          ...(history.location.state as {}),\n          sectionOpen: newSectionOpen,\n        },\n      });\n    },\n    [sectionOpen, view, history]\n  );\n\n  return {\n    showSidebar: showSidebar ?? defaultShowSidebar(),\n    sectionOpen: sectionOpen || {},\n    setShowSidebar: onSetShowSidebar,\n    setSectionOpen: onSetSectionOpen,\n    loading: showSidebar === undefined,\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx",
    "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Form, Button, Row, Col, Badge, InputGroup } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { faSearch } from \"@fortawesome/free-solid-svg-icons\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { stashboxDisplayName } from \"src/utils/stashbox\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport TextUtils from \"src/utils/text\";\nimport GenderIcon from \"src/components/Performers/GenderIcon\";\nimport { CountryFlag } from \"src/components/Shared/CountryFlag\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport {\n  stashBoxPerformerQuery,\n  stashBoxSceneQuery,\n  stashBoxStudioQuery,\n  stashBoxTagQuery,\n} from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { stringToGender } from \"src/utils/gender\";\n\ntype SearchResultItem =\n  | GQL.ScrapedPerformerDataFragment\n  | GQL.ScrapedSceneDataFragment\n  | GQL.ScrapedStudioDataFragment\n  | GQL.ScrapedSceneTagDataFragment;\n\nexport type StashBoxEntityType = \"performer\" | \"scene\" | \"studio\" | \"tag\";\n\ninterface IProps {\n  entityType: StashBoxEntityType;\n  stashBoxes: GQL.StashBox[];\n  excludedStashBoxEndpoints?: string[];\n  onSelectItem: (item?: GQL.StashIdInput) => void;\n  initialQuery?: string;\n}\n\nconst CLASSNAME = \"StashBoxIDSearchModal\";\nconst CLASSNAME_LIST = `${CLASSNAME}-list`;\nconst CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`;\n\ninterface IHasRemoteSiteID {\n  remote_site_id?: string | null;\n}\n\n// Shared component for rendering images\nconst SearchResultImage: React.FC<{ imageUrl?: string | null }> = ({\n  imageUrl,\n}) => {\n  if (!imageUrl) return null;\n\n  return (\n    <div className=\"scene-image-container\">\n      <img src={imageUrl} alt=\"\" className=\"align-self-center scene-image\" />\n    </div>\n  );\n};\n\n// Shared component for rendering tags\nconst SearchResultTags: React.FC<{\n  tags?: GQL.ScrapedTag[] | null;\n}> = ({ tags }) => {\n  if (!tags || tags.length === 0) return null;\n\n  return (\n    <Row>\n      <Col>\n        {tags.map((tag) => (\n          <Badge className=\"tag-item\" variant=\"secondary\" key={tag.stored_id}>\n            {tag.name}\n          </Badge>\n        ))}\n      </Col>\n    </Row>\n  );\n};\n\n// Performer Result Component\ninterface IPerformerResultProps {\n  performer: GQL.ScrapedPerformerDataFragment;\n}\n\nconst PerformerSearchResultDetails: React.FC<IPerformerResultProps> = ({\n  performer,\n}) => {\n  const age = performer?.birthdate\n    ? TextUtils.age(performer.birthdate, performer.death_date)\n    : undefined;\n\n  return (\n    <div className=\"performer-result\">\n      <Row>\n        <SearchResultImage imageUrl={performer.images?.[0]} />\n        <div className=\"col flex-column\">\n          <h4 className=\"performer-name\">\n            <span>{performer.name}</span>\n            {performer.disambiguation && (\n              <span className=\"performer-disambiguation\">\n                {` (${performer.disambiguation})`}\n              </span>\n            )}\n          </h4>\n          <h5 className=\"performer-details\">\n            {performer.gender && (\n              <span>\n                <GenderIcon\n                  className=\"gender-icon\"\n                  gender={stringToGender(performer.gender, true)}\n                />\n              </span>\n            )}\n            {age && (\n              <span>\n                {`${age} `}\n                <FormattedMessage id=\"years_old\" />\n              </span>\n            )}\n          </h5>\n          {performer.country && (\n            <span>\n              <CountryFlag\n                className=\"performer-result__country-flag\"\n                country={performer.country}\n              />\n            </span>\n          )}\n        </div>\n      </Row>\n      <Row>\n        <Col>\n          <TruncatedText text={performer.details ?? \"\"} lineCount={3} />\n        </Col>\n      </Row>\n      <SearchResultTags tags={performer.tags} />\n    </div>\n  );\n};\n\nexport const PerformerSearchResult: React.FC<IPerformerResultProps> = ({\n  performer,\n}) => {\n  return (\n    <div className=\"mt-3 search-item\" style={{ cursor: \"pointer\" }}>\n      <PerformerSearchResultDetails performer={performer} />\n    </div>\n  );\n};\n\n// Scene Result Component\ninterface ISceneResultProps {\n  scene: GQL.ScrapedSceneDataFragment;\n}\n\nconst SceneSearchResultDetails: React.FC<ISceneResultProps> = ({ scene }) => {\n  return (\n    <div className=\"scene-result\">\n      <Row>\n        <SearchResultImage imageUrl={scene.image} />\n        <div className=\"col flex-column\">\n          <h4 className=\"scene-title\">\n            <span>{scene.title}</span>\n            {scene.code && (\n              <span className=\"scene-code\">{` (${scene.code})`}</span>\n            )}\n          </h4>\n          <h5 className=\"scene-details\">\n            {scene.studio?.name && <span>{scene.studio.name}</span>}\n            {scene.date && (\n              <span className=\"scene-date\">{` • ${scene.date}`}</span>\n            )}\n          </h5>\n          {scene.performers && scene.performers.length > 0 && (\n            <div className=\"scene-performers\">\n              {scene.performers.map((p) => p.name).join(\", \")}\n            </div>\n          )}\n        </div>\n      </Row>\n      <Row>\n        <Col>\n          <TruncatedText text={scene.details ?? \"\"} lineCount={3} />\n        </Col>\n      </Row>\n      <SearchResultTags tags={scene.tags} />\n    </div>\n  );\n};\n\nexport const SceneSearchResult: React.FC<ISceneResultProps> = ({ scene }) => {\n  return (\n    <div className=\"mt-3 search-item\" style={{ cursor: \"pointer\" }}>\n      <SceneSearchResultDetails scene={scene} />\n    </div>\n  );\n};\n\n// Studio Result Component\ninterface IStudioResultProps {\n  studio: GQL.ScrapedStudioDataFragment;\n}\n\nconst StudioSearchResultDetails: React.FC<IStudioResultProps> = ({\n  studio,\n}) => {\n  return (\n    <div className=\"studio-result\">\n      <Row>\n        <SearchResultImage imageUrl={studio.image} />\n        <div className=\"col flex-column\">\n          <h4 className=\"studio-name\">\n            <span>{studio.name}</span>\n          </h4>\n          {studio.parent?.name && (\n            <h5 className=\"studio-parent\">\n              <span>{studio.parent.name}</span>\n            </h5>\n          )}\n          {studio.urls && studio.urls.length > 0 && (\n            <div className=\"studio-url text-muted small\">{studio.urls[0]}</div>\n          )}\n        </div>\n      </Row>\n    </div>\n  );\n};\n\nexport const StudioSearchResult: React.FC<IStudioResultProps> = ({\n  studio,\n}) => {\n  return (\n    <div className=\"mt-3 search-item\" style={{ cursor: \"pointer\" }}>\n      <StudioSearchResultDetails studio={studio} />\n    </div>\n  );\n};\n\n// Tag Result Component\ninterface ITagResultProps {\n  tag: GQL.ScrapedSceneTagDataFragment;\n}\n\nexport const TagSearchResult: React.FC<ITagResultProps> = ({ tag }) => {\n  return (\n    <div className=\"mt-3 search-item\" style={{ cursor: \"pointer\" }}>\n      <div className=\"tag-result\">\n        <Row>\n          <div className=\"col flex-column\">\n            <h4 className=\"tag-name\">\n              <span>{tag.name}</span>\n            </h4>\n          </div>\n        </Row>\n      </div>\n    </div>\n  );\n};\n\n// Helper to get entity type message id for i18n\nfunction getEntityTypeMessageId(entityType: StashBoxEntityType): string {\n  switch (entityType) {\n    case \"performer\":\n      return \"performer\";\n    case \"scene\":\n      return \"scene\";\n    case \"studio\":\n      return \"studio\";\n    case \"tag\":\n      return \"tag\";\n  }\n}\n\n// Helper to get the \"found\" message id based on entity type\nfunction getFoundMessageId(entityType: StashBoxEntityType): string {\n  switch (entityType) {\n    case \"performer\":\n      return \"dialogs.performers_found\";\n    case \"scene\":\n      return \"dialogs.scenes_found\";\n    case \"studio\":\n      return \"dialogs.studios_found\";\n    case \"tag\":\n      return \"dialogs.tags_found\";\n  }\n}\n\n// Main Modal Component\nexport const StashBoxIDSearchModal: React.FC<IProps> = ({\n  entityType,\n  stashBoxes,\n  excludedStashBoxEndpoints = [],\n  onSelectItem,\n  initialQuery = \"\",\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const [selectedStashBox, setSelectedStashBox] = useState<GQL.StashBox | null>(\n    null\n  );\n  const [query, setQuery] = useState<string>(initialQuery);\n  const [results, setResults] = useState<SearchResultItem[] | undefined>(\n    undefined\n  );\n  const [loading, setLoading] = useState(false);\n\n  useEffect(() => {\n    if (stashBoxes.length > 0) {\n      setSelectedStashBox(stashBoxes[0]);\n    }\n  }, [stashBoxes]);\n\n  useEffect(() => inputRef.current?.focus(), []);\n\n  const doSearch = useCallback(async () => {\n    if (!selectedStashBox || !query) {\n      return;\n    }\n\n    setLoading(true);\n    setResults([]);\n\n    try {\n      switch (entityType) {\n        case \"performer\": {\n          const queryData = await stashBoxPerformerQuery(\n            query,\n            selectedStashBox.endpoint\n          );\n          setResults(queryData.data?.scrapeSinglePerformer ?? []);\n          break;\n        }\n        case \"scene\": {\n          const queryData = await stashBoxSceneQuery(\n            query,\n            selectedStashBox.endpoint\n          );\n          setResults(queryData.data?.scrapeSingleScene ?? []);\n          break;\n        }\n        case \"studio\": {\n          const queryData = await stashBoxStudioQuery(\n            query,\n            selectedStashBox.endpoint\n          );\n          setResults(queryData.data?.scrapeSingleStudio ?? []);\n          break;\n        }\n        case \"tag\": {\n          const queryData = await stashBoxTagQuery(\n            query,\n            selectedStashBox.endpoint\n          );\n          setResults(queryData.data?.scrapeSingleTag ?? []);\n          break;\n        }\n      }\n    } catch (error) {\n      Toast.error(error);\n    } finally {\n      setLoading(false);\n    }\n  }, [query, selectedStashBox, Toast, entityType]);\n\n  function handleItemClick(item: IHasRemoteSiteID) {\n    if (selectedStashBox && item.remote_site_id) {\n      onSelectItem({\n        endpoint: selectedStashBox.endpoint,\n        stash_id: item.remote_site_id,\n      });\n    } else {\n      onSelectItem(undefined);\n    }\n  }\n\n  function handleClose() {\n    onSelectItem(undefined);\n  }\n\n  function renderResultItem(item: SearchResultItem) {\n    switch (entityType) {\n      case \"performer\":\n        return (\n          <PerformerSearchResult\n            performer={item as GQL.ScrapedPerformerDataFragment}\n          />\n        );\n      case \"scene\":\n        return (\n          <SceneSearchResult scene={item as GQL.ScrapedSceneDataFragment} />\n        );\n      case \"studio\":\n        return (\n          <StudioSearchResult studio={item as GQL.ScrapedStudioDataFragment} />\n        );\n      case \"tag\":\n        return (\n          <TagSearchResult tag={item as GQL.ScrapedSceneTagDataFragment} />\n        );\n    }\n  }\n\n  function renderResults() {\n    if (!results || results.length === 0) {\n      return null;\n    }\n\n    return (\n      <div className={CLASSNAME_LIST_CONTAINER}>\n        <div className=\"mt-1 mb-2\">\n          <FormattedMessage\n            id={getFoundMessageId(entityType)}\n            values={{ count: results.length }}\n          />\n        </div>\n        <ul className={CLASSNAME_LIST} style={{ listStyleType: \"none\" }}>\n          {results.map((item, i) => (\n            <li key={i} onClick={() => handleItemClick(item)}>\n              {renderResultItem(item)}\n            </li>\n          ))}\n        </ul>\n      </div>\n    );\n  }\n\n  const entityTypeDisplayName = intl.formatMessage({\n    id: getEntityTypeMessageId(entityType),\n  });\n\n  return (\n    <ModalComponent\n      show\n      onHide={handleClose}\n      header={intl.formatMessage(\n        { id: \"stashbox_search.header\" },\n        { entityType: entityTypeDisplayName }\n      )}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        onClick: handleClose,\n        variant: \"secondary\",\n      }}\n    >\n      <div className={CLASSNAME}>\n        <Form.Group className=\"d-flex align-items-center mb-3\">\n          <Form.Label className=\"mb-0 mr-2\" style={{ flexShrink: 0 }}>\n            <FormattedMessage id=\"stashbox.source\" />\n          </Form.Label>\n          <Form.Control\n            as=\"select\"\n            className=\"input-control\"\n            style={{ flex: \"0 1 auto\" }}\n            value={selectedStashBox?.endpoint ?? \"\"}\n            onChange={(e) => {\n              const box = stashBoxes.find(\n                (b) => b.endpoint === e.currentTarget.value\n              );\n              if (box) {\n                setSelectedStashBox(box);\n              }\n            }}\n          >\n            {stashBoxes.map((box, index) => (\n              <option key={box.endpoint} value={box.endpoint}>\n                {stashboxDisplayName(box.name, index)}\n              </option>\n            ))}\n          </Form.Control>\n        </Form.Group>\n\n        {selectedStashBox &&\n          excludedStashBoxEndpoints.includes(selectedStashBox.endpoint) && (\n            <span className=\"saved-filter-overwrite-warning mb-3 d-block\">\n              <FormattedMessage id=\"dialogs.stashid_exists_warning\" />\n            </span>\n          )}\n\n        <InputGroup>\n          <Form.Control\n            onChange={(e) => setQuery(e.currentTarget.value)}\n            value={query}\n            placeholder={intl.formatMessage(\n              { id: \"stashbox_search.placeholder_name_or_id\" },\n              { entityType: entityTypeDisplayName }\n            )}\n            className=\"text-input\"\n            ref={inputRef}\n            onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>\n              e.key === \"Enter\" && doSearch()\n            }\n          />\n          <InputGroup.Append>\n            <Button\n              onClick={doSearch}\n              variant=\"primary\"\n              disabled={!selectedStashBox}\n              title={intl.formatMessage({ id: \"actions.search\" })}\n            >\n              <Icon icon={faSearch} />\n            </Button>\n          </InputGroup.Append>\n        </InputGroup>\n\n        {loading ? (\n          <div className=\"m-4 text-center\">\n            <LoadingIndicator inline />\n          </div>\n        ) : results && results.length > 0 ? (\n          renderResults()\n        ) : (\n          results !== undefined &&\n          results.length === 0 && (\n            <h5 className=\"text-center mt-4\">\n              <FormattedMessage id=\"stashbox_search.no_results\" />\n            </h5>\n          )\n        )}\n      </div>\n    </ModalComponent>\n  );\n};\n\nexport default StashBoxIDSearchModal;\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/StashID.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { StashId } from \"src/core/generated-graphql\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { getStashboxBase } from \"src/utils/stashbox\";\nimport { ExternalLink } from \"./ExternalLink\";\n\nexport type LinkType = \"performers\" | \"scenes\" | \"studios\" | \"tags\";\n\nexport const StashIDPill: React.FC<{\n  stashID: Pick<StashId, \"endpoint\" | \"stash_id\">;\n  linkType: LinkType;\n}> = ({ stashID, linkType }) => {\n  const { configuration } = useConfigurationContext();\n\n  const { endpoint, stash_id } = stashID;\n\n  const endpointName = useMemo(() => {\n    return (\n      configuration?.general.stashBoxes.find((sb) => sb.endpoint === endpoint)\n        ?.name ?? endpoint\n    );\n  }, [configuration?.general.stashBoxes, endpoint]);\n\n  const base = getStashboxBase(endpoint);\n  const link = `${base}${linkType}/${stash_id}`;\n\n  return (\n    <span className=\"stash-id-pill\" data-endpoint={endpointName}>\n      <span>{endpointName}</span>\n      <ExternalLink href={link}>{stash_id}</ExternalLink>\n    </span>\n  );\n};\n\ninterface IStashIDsField {\n  values: StashId[];\n}\n\nexport const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {\n  if (!values.length) return null;\n\n  return (\n    <ul className=\"pl-0 mw-100\">\n      {values.map((v) => (\n        <li key={v.stash_id} className=\"row no-gutters\">\n          <StashIDPill linkType=\"scenes\" stashID={v} />\n        </li>\n      ))}\n    </ul>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/StringListInput.tsx",
    "content": "import { faGripVertical, faMinus } from \"@fortawesome/free-solid-svg-icons\";\nimport React, { ComponentType, useState } from \"react\";\nimport { Button, Form, InputGroup } from \"react-bootstrap\";\nimport { Icon } from \"./Icon\";\n\ninterface IListInputComponentProps {\n  value: string;\n  setValue: (value: string) => void;\n  placeholder?: string;\n  className?: string;\n  readOnly?: boolean;\n}\n\ninterface IListInputAppendProps {\n  value: string;\n}\n\nexport interface IStringListInputProps {\n  value: string[];\n  setValue: (value: string[]) => void;\n  inputComponent?: ComponentType<IListInputComponentProps>;\n  appendComponent?: ComponentType<IListInputAppendProps>;\n  placeholder?: string;\n  className?: string;\n  errors?: string;\n  errorIdx?: number[];\n  readOnly?: boolean;\n  // defaults to true if not set\n  orderable?: boolean;\n}\n\nexport const StringInput: React.FC<IListInputComponentProps> = ({\n  className,\n  placeholder,\n  value,\n  setValue,\n  readOnly = false,\n}) => {\n  return (\n    <Form.Control\n      className={`text-input ${className ?? \"\"}`}\n      value={value}\n      onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n        setValue(e.currentTarget.value)\n      }\n      placeholder={placeholder}\n      readOnly={readOnly}\n    />\n  );\n};\n\nexport const StringListInput: React.FC<IStringListInputProps> = (props) => {\n  const Input = props.inputComponent ?? StringInput;\n  const AppendComponent = props.appendComponent;\n  const values = props.value.concat(\"\");\n  const [draggedIdx, setDraggedIdx] = useState<number | null>(null);\n\n  const { orderable = true } = props;\n\n  function valueChanged(idx: number, value: string) {\n    const newValues = props.value.slice();\n    newValues[idx] = value;\n\n    // if we cleared the last string, delete it from the array entirely\n    if (!value && idx === newValues.length - 1) {\n      newValues.splice(newValues.length - 1);\n    }\n\n    props.setValue(newValues);\n  }\n\n  function removeValue(idx: number) {\n    const newValues = props.value.filter((_v, i) => i !== idx);\n\n    props.setValue(newValues);\n  }\n\n  function handleDragStart(event: React.DragEvent<HTMLElement>, idx: number) {\n    event.dataTransfer.dropEffect = \"move\";\n    setDraggedIdx(idx);\n  }\n\n  function handleDragOver(e: React.DragEvent, idx: number) {\n    e.dataTransfer.dropEffect = \"move\";\n    e.preventDefault();\n\n    if (\n      draggedIdx === null ||\n      draggedIdx === idx ||\n      idx === values.length - 1\n    ) {\n      return;\n    }\n\n    const newValues = [...props.value];\n    const draggedValue = newValues[draggedIdx];\n    newValues.splice(draggedIdx, 1);\n    newValues.splice(idx, 0, draggedValue);\n\n    props.setValue(newValues);\n    setDraggedIdx(idx);\n  }\n\n  function handleDragEnd() {\n    setDraggedIdx(null);\n  }\n\n  return (\n    <>\n      <div className={`string-list-input ${props.errors ? \"is-invalid\" : \"\"}`}>\n        <Form.Group>\n          {values.map((v, i) => (\n            <InputGroup\n              className={props.className}\n              key={i}\n              onDragOver={(e) => handleDragOver(e, i)}\n            >\n              <Input\n                value={v}\n                setValue={(value) => valueChanged(i, value)}\n                placeholder={props.placeholder}\n                className={props.errorIdx?.includes(i) ? \"is-invalid\" : \"\"}\n                readOnly={props.readOnly}\n              />\n              <InputGroup.Append>\n                {AppendComponent && <AppendComponent value={v} />}\n                {!props.readOnly && values.length > 2 && orderable && (\n                  <Button\n                    variant=\"secondary\"\n                    className=\"drag-handle minimal\"\n                    draggable={i !== values.length - 1}\n                    disabled={i === values.length - 1}\n                    onDragStart={(e) => handleDragStart(e, i)}\n                    onDragEnd={handleDragEnd}\n                  >\n                    <Icon icon={faGripVertical} />\n                  </Button>\n                )}\n                {!props.readOnly && (\n                  <Button\n                    variant=\"danger\"\n                    onClick={() => removeValue(i)}\n                    disabled={i === values.length - 1}\n                    size=\"sm\"\n                  >\n                    <Icon icon={faMinus} />\n                  </Button>\n                )}\n              </InputGroup.Append>\n            </InputGroup>\n          ))}\n        </Form.Group>\n      </div>\n      <div className=\"invalid-feedback mt-n2\">{props.errors}</div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/SuccessIcon.tsx",
    "content": "import { faCheckCircle } from \"@fortawesome/free-regular-svg-icons\";\nimport React from \"react\";\nimport { Icon } from \"./Icon\";\n\ninterface ISuccessIconProps {\n  className?: string;\n}\n\nexport const SuccessIcon: React.FC<ISuccessIconProps> = ({ className }) => (\n  <Icon icon={faCheckCircle} className={className} color=\"#0f9960\" />\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/SweatDrops.tsx",
    "content": "import React from \"react\";\nimport { PatchComponent } from \"src/patch\";\n\nexport const SweatDrops: React.FC = PatchComponent(\"SweatDrops\", () => (\n  <span>\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      xmlnsXlink=\"http://www.w3.org/1999/xlink\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      width=\"1em\"\n      height=\"1em\"\n      style={{ transform: \"rotate(360deg)\" }}\n      preserveAspectRatio=\"xMidYMid meet\"\n      viewBox=\"0 0 36 36\"\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"M22.855.758L7.875 7.024l12.537 9.733c2.633 2.224 6.377 2.937 9.77 1.518c4.826-2.018 7.096-7.576 5.072-12.413C33.232 1.024 27.68-1.261 22.855.758zm-9.962 17.924L2.05 10.284L.137 23.529a7.993 7.993 0 0 0 2.958 7.803a8.001 8.001 0 0 0 9.798-12.65zm15.339 7.015l-8.156-4.69l-.033 9.223c-.088 2 .904 3.98 2.75 5.041a5.462 5.462 0 0 0 7.479-2.051c1.499-2.644.589-6.013-2.04-7.523z\"\n      />\n      <rect x=\"0\" y=\"0\" width=\"36\" height=\"36\" fill=\"rgba(0, 0, 0, 0)\" />\n    </svg>\n  </span>\n));\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/TagLink.tsx",
    "content": "import { Badge, OverlayTrigger, Tooltip } from \"react-bootstrap\";\nimport React, { useMemo } from \"react\";\nimport { Link } from \"react-router-dom\";\nimport cx from \"classnames\";\nimport NavUtils, { INamedObject } from \"src/utils/navigation\";\nimport TextUtils from \"src/utils/text\";\nimport { IFile, IObjectWithTitleFiles, objectTitle } from \"src/core/files\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { TagPopover } from \"../Tags/TagPopover\";\nimport { markerTitle } from \"src/core/markers\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { faFolderTree } from \"@fortawesome/free-solid-svg-icons\";\nimport { Icon } from \"../Shared/Icon\";\nimport { FormattedMessage } from \"react-intl\";\nimport { PatchComponent } from \"src/patch\";\n\ntype SceneMarkerFragment = Pick<GQL.SceneMarker, \"id\" | \"title\" | \"seconds\"> & {\n  scene: Pick<GQL.Scene, \"id\">;\n  primary_tag: Pick<GQL.Tag, \"id\" | \"name\">;\n};\n\ninterface ISortNameLinkProps {\n  link: string;\n  className?: string;\n  sortName?: string;\n}\n\nconst SortNameLinkComponent: React.FC<ISortNameLinkProps> = ({\n  link,\n  sortName,\n  className,\n  children,\n}) => {\n  return (\n    <Badge\n      data-name={className}\n      data-sort-name={sortName}\n      className={cx(\"tag-item tag-link\", className)}\n      variant=\"secondary\"\n    >\n      <Link to={link}>{children}</Link>\n    </Badge>\n  );\n};\n\ninterface ICommonLinkProps {\n  link: string;\n  className?: string;\n}\n\nconst CommonLinkComponent: React.FC<ICommonLinkProps> = ({\n  link,\n  className,\n  children,\n}) => {\n  return (\n    <Badge className={cx(\"tag-item tag-link\", className)} variant=\"secondary\">\n      <Link to={link}>{children}</Link>\n    </Badge>\n  );\n};\n\ninterface IPerformerLinkProps {\n  performer: INamedObject & { disambiguation?: string | null };\n  linkType?: \"scene\" | \"gallery\" | \"image\" | \"scene_marker\";\n  className?: string;\n}\n\nexport type PerformerLinkType = IPerformerLinkProps[\"linkType\"];\n\nexport const PerformerLink: React.FC<IPerformerLinkProps> = ({\n  performer,\n  linkType = \"scene\",\n  className,\n}) => {\n  const link = useMemo(() => {\n    switch (linkType) {\n      case \"gallery\":\n        return NavUtils.makePerformerGalleriesUrl(performer);\n      case \"image\":\n        return NavUtils.makePerformerImagesUrl(performer);\n      case \"scene_marker\":\n        return NavUtils.makePerformerSceneMarkersUrl(performer);\n      case \"scene\":\n      default:\n        return NavUtils.makePerformerScenesUrl(performer);\n    }\n  }, [performer, linkType]);\n\n  const title = performer.name || \"\";\n\n  return (\n    <CommonLinkComponent link={link} className={className}>\n      <span>{title}</span>\n      {performer.disambiguation && (\n        <span className=\"performer-disambiguation\">{` (${performer.disambiguation})`}</span>\n      )}\n    </CommonLinkComponent>\n  );\n};\n\ninterface IGroupLinkProps {\n  group: INamedObject;\n  description?: string;\n  linkType?: \"scene\" | \"sub_group\" | \"details\";\n  className?: string;\n}\n\nexport const GroupLink: React.FC<IGroupLinkProps> = ({\n  group,\n  description,\n  linkType = \"scene\",\n  className,\n}) => {\n  const link = useMemo(() => {\n    switch (linkType) {\n      case \"scene\":\n        return NavUtils.makeGroupScenesUrl(group);\n      case \"sub_group\":\n        return NavUtils.makeSubGroupsUrl(group);\n      case \"details\":\n        return NavUtils.makeGroupUrl(group.id ?? \"\");\n    }\n  }, [group, linkType]);\n\n  const title = group.name || \"\";\n\n  return (\n    <CommonLinkComponent link={link} className={className}>\n      {title}{\" \"}\n      {description && (\n        <span className=\"group-description\">({description})</span>\n      )}\n    </CommonLinkComponent>\n  );\n};\n\ninterface ISceneMarkerLinkProps {\n  marker: SceneMarkerFragment;\n  linkType?: \"scene\";\n  className?: string;\n}\n\nexport const SceneMarkerLink: React.FC<ISceneMarkerLinkProps> = ({\n  marker,\n  linkType = \"scene\",\n  className,\n}) => {\n  const link = useMemo(() => {\n    switch (linkType) {\n      case \"scene\":\n        return NavUtils.makeSceneMarkerUrl(marker);\n    }\n  }, [marker, linkType]);\n\n  const title = `${markerTitle(marker)} - ${TextUtils.secondsToTimestamp(\n    marker.seconds || 0\n  )}`;\n\n  return (\n    <CommonLinkComponent link={link} className={className}>\n      {title}\n    </CommonLinkComponent>\n  );\n};\n\ninterface IObjectWithIDTitleFiles extends IObjectWithTitleFiles {\n  id: string;\n}\n\ninterface ISceneLinkProps {\n  scene: IObjectWithIDTitleFiles;\n  linkType?: \"details\";\n  className?: string;\n}\n\nexport const SceneLink: React.FC<ISceneLinkProps> = ({\n  scene,\n  linkType = \"details\",\n  className,\n}) => {\n  const link = useMemo(() => {\n    switch (linkType) {\n      case \"details\":\n        return `/scenes/${scene.id}`;\n    }\n  }, [scene, linkType]);\n\n  const title = objectTitle(scene);\n\n  return (\n    <CommonLinkComponent link={link} className={className}>\n      {title}\n    </CommonLinkComponent>\n  );\n};\n\ninterface IGallery extends IObjectWithIDTitleFiles {\n  folder?: GQL.Maybe<IFile>;\n}\n\ninterface IGalleryLinkProps {\n  gallery: IGallery;\n  linkType?: \"details\";\n  className?: string;\n}\n\nexport const GalleryLink: React.FC<IGalleryLinkProps> = ({\n  gallery,\n  linkType = \"details\",\n  className,\n}) => {\n  const link = useMemo(() => {\n    switch (linkType) {\n      case \"details\":\n        return `/galleries/${gallery.id}`;\n    }\n  }, [gallery, linkType]);\n\n  const title = galleryTitle(gallery);\n\n  return (\n    <CommonLinkComponent link={link} className={className}>\n      {title}\n    </CommonLinkComponent>\n  );\n};\n\ninterface ITagLinkProps {\n  tag: INamedObject;\n  linkType?:\n    | \"scene\"\n    | \"gallery\"\n    | \"image\"\n    | \"details\"\n    | \"performer\"\n    | \"group\"\n    | \"studio\"\n    | \"scene_marker\";\n  className?: string;\n  hoverPlacement?: Placement;\n  showHierarchyIcon?: boolean;\n  hierarchyTooltipID?: string;\n}\n\nexport const TagLink: React.FC<ITagLinkProps> = PatchComponent(\n  \"TagLink\",\n  ({\n    tag,\n    linkType = \"scene\",\n    className,\n    hoverPlacement,\n    showHierarchyIcon = false,\n    hierarchyTooltipID,\n  }) => {\n    const link = useMemo(() => {\n      switch (linkType) {\n        case \"scene\":\n          return NavUtils.makeTagScenesUrl(tag);\n        case \"performer\":\n          return NavUtils.makeTagPerformersUrl(tag);\n        case \"studio\":\n          return NavUtils.makeTagStudiosUrl(tag);\n        case \"gallery\":\n          return NavUtils.makeTagGalleriesUrl(tag);\n        case \"image\":\n          return NavUtils.makeTagImagesUrl(tag);\n        case \"group\":\n          return NavUtils.makeTagGroupsUrl(tag);\n        case \"scene_marker\":\n          return NavUtils.makeTagSceneMarkersUrl(tag);\n        case \"details\":\n          return NavUtils.makeTagUrl(tag.id ?? \"\");\n      }\n    }, [tag, linkType]);\n\n    const title = tag.name || \"\";\n\n    const tooltip = useMemo(() => {\n      if (!hierarchyTooltipID) {\n        return <></>;\n      }\n\n      return (\n        <Tooltip id=\"tag-hierarchy-tooltip\">\n          <FormattedMessage id={hierarchyTooltipID} />\n        </Tooltip>\n      );\n    }, [hierarchyTooltipID]);\n\n    return (\n      <SortNameLinkComponent\n        sortName={tag.sort_name || title}\n        link={link}\n        className={className}\n      >\n        <TagPopover id={tag.id ?? \"\"} placement={hoverPlacement}>\n          {title}\n          {showHierarchyIcon && (\n            <OverlayTrigger placement=\"top\" overlay={tooltip}>\n              <span className=\"icon-wrapper\">\n                <span className=\"vertical-line\">|</span>\n                <Icon icon={faFolderTree} className=\"tag-icon\" />\n              </span>\n            </OverlayTrigger>\n          )}\n        </TagPopover>\n      </SortNameLinkComponent>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/ThreeStateCheckbox.tsx",
    "content": "import { faCheck, faMinus, faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport React from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { Icon } from \"./Icon\";\n\ninterface IThreeStateCheckbox {\n  value: boolean | undefined;\n  setValue: (v: boolean | undefined) => void;\n  allowUndefined?: boolean;\n  label?: React.ReactNode;\n  disabled?: boolean;\n}\n\nexport const ThreeStateCheckbox: React.FC<IThreeStateCheckbox> = ({\n  value,\n  setValue,\n  allowUndefined,\n  label,\n  disabled = false,\n}) => {\n  function cycleState() {\n    const undefAllowed = allowUndefined ?? true;\n    if (undefAllowed && value) {\n      return undefined;\n    }\n    if ((!undefAllowed && value) || value === undefined) {\n      return false;\n    }\n    return true;\n  }\n\n  const icon = value === undefined ? faMinus : value ? faCheck : faTimes;\n  const labelClassName =\n    value === undefined ? \"unset\" : value ? \"checked\" : \"not-checked\";\n\n  return (\n    <span className={`three-state-checkbox ${labelClassName}`}>\n      <Button\n        onClick={() => setValue(cycleState())}\n        className=\"minimal\"\n        disabled={disabled}\n      >\n        <Icon icon={icon} className=\"fa-fw\" />\n      </Button>\n      <span className=\"label\">{label}</span>\n    </span>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/TruncatedText.tsx",
    "content": "import React, { useRef, useState } from \"react\";\nimport { Overlay, Tooltip } from \"react-bootstrap\";\nimport { Placement } from \"react-bootstrap/Overlay\";\nimport cx from \"classnames\";\nimport { useDebounce } from \"src/hooks/debounce\";\nimport { PatchComponent } from \"src/patch\";\n\nconst CLASSNAME = \"TruncatedText\";\nconst CLASSNAME_TOOLTIP = `${CLASSNAME}-tooltip`;\n\ninterface ITruncatedTextProps {\n  text?: JSX.Element | string | null;\n  lineCount?: number;\n  placement?: Placement;\n  delay?: number;\n  className?: string;\n}\n\nexport const TruncatedText: React.FC<ITruncatedTextProps> = PatchComponent(\n  \"TruncatedText\",\n  ({ text, className, lineCount = 1, placement = \"bottom\", delay = 1000 }) => {\n    const [showTooltip, setShowTooltip] = useState(false);\n    const target = useRef(null);\n\n    const startShowingTooltip = useDebounce(() => setShowTooltip(true), delay);\n\n    if (!text) return <></>;\n\n    const handleFocus = (element: HTMLElement) => {\n      // Check if visible size is smaller than the content size\n      if (\n        element.offsetWidth < element.scrollWidth ||\n        element.offsetHeight + 10 < element.scrollHeight\n      )\n        startShowingTooltip();\n    };\n\n    const handleBlur = () => {\n      startShowingTooltip.cancel();\n      setShowTooltip(false);\n    };\n\n    const overlay = (\n      <Overlay target={target.current} show={showTooltip} placement={placement}>\n        <Tooltip id={CLASSNAME} className={CLASSNAME_TOOLTIP}>\n          {text}\n        </Tooltip>\n      </Overlay>\n    );\n\n    return (\n      <div\n        className={cx(CLASSNAME, className)}\n        style={{ WebkitLineClamp: lineCount }}\n        ref={target}\n        onMouseEnter={(e) => handleFocus(e.currentTarget)}\n        onFocus={(e) => handleFocus(e.currentTarget)}\n        onMouseLeave={handleBlur}\n        onBlur={handleBlur}\n      >\n        {text}\n        {overlay}\n      </div>\n    );\n  }\n);\n\nexport const TruncatedInlineText: React.FC<ITruncatedTextProps> = ({\n  text,\n  className,\n  placement = \"bottom\",\n  delay = 1000,\n}) => {\n  const [showTooltip, setShowTooltip] = useState(false);\n  const target = useRef(null);\n\n  const startShowingTooltip = useDebounce(() => setShowTooltip(true), delay);\n\n  if (!text) return <></>;\n\n  const handleFocus = (element: HTMLElement) => {\n    // Check if visible size is smaller than the content size\n    if (\n      element.offsetWidth < element.scrollWidth ||\n      element.offsetHeight + 10 < element.scrollHeight\n    )\n      startShowingTooltip();\n  };\n\n  const handleBlur = () => {\n    startShowingTooltip.cancel();\n    setShowTooltip(false);\n  };\n\n  const overlay = (\n    <Overlay target={target.current} show={showTooltip} placement={placement}>\n      <Tooltip id={CLASSNAME} className={CLASSNAME_TOOLTIP}>\n        {text}\n      </Tooltip>\n    </Overlay>\n  );\n\n  return (\n    <span\n      className={cx(CLASSNAME, \"inline\", className)}\n      ref={target}\n      onMouseEnter={(e) => handleFocus(e.currentTarget)}\n      onFocus={(e) => handleFocus(e.currentTarget)}\n      onMouseLeave={handleBlur}\n      onBlur={handleBlur}\n    >\n      {text}\n      {overlay}\n    </span>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/URLField.tsx",
    "content": "import React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { Button, InputGroup, Form } from \"react-bootstrap\";\nimport { Icon } from \"./Icon\";\nimport { FormikHandlers } from \"formik\";\nimport { faFileDownload } from \"@fortawesome/free-solid-svg-icons\";\nimport {\n  IStringListInputProps,\n  StringInput,\n  StringListInput,\n} from \"./StringListInput\";\n\ninterface IProps {\n  value: string;\n  name: string;\n  onChange: FormikHandlers[\"handleChange\"];\n  onBlur: FormikHandlers[\"handleBlur\"];\n  onScrapeClick(): void;\n  urlScrapable(url: string): boolean;\n  isInvalid?: boolean;\n}\n\nexport const URLField: React.FC<IProps> = (props: IProps) => {\n  const intl = useIntl();\n\n  return (\n    <InputGroup className=\"mr-2 flex-grow-1\">\n      <Form.Control\n        className=\"text-input\"\n        placeholder={intl.formatMessage({ id: \"url\" })}\n        value={props.value}\n        name={props.name}\n        onChange={props.onChange}\n        onBlur={props.onBlur}\n        isInvalid={props.isInvalid}\n      />\n      <InputGroup.Append>\n        <Button\n          className=\"scrape-url-button text-input\"\n          variant=\"secondary\"\n          onClick={props.onScrapeClick}\n          disabled={!props.value || !props.urlScrapable(props.value)}\n          title={intl.formatMessage({ id: \"actions.scrape\" })}\n        >\n          <Icon icon={faFileDownload} />\n        </Button>\n      </InputGroup.Append>\n    </InputGroup>\n  );\n};\n\ninterface IURLListProps extends IStringListInputProps {\n  onScrapeClick?: (url: string) => void;\n  urlScrapable?: (url: string) => boolean;\n}\n\nexport const URLListInput: React.FC<IURLListProps> = (\n  listProps: IURLListProps\n) => {\n  const intl = useIntl();\n  const { onScrapeClick, urlScrapable } = listProps;\n  return (\n    <StringListInput\n      {...listProps}\n      placeholder={intl.formatMessage({ id: \"url\" })}\n      inputComponent={StringInput}\n      appendComponent={(props) => {\n        if (!onScrapeClick || !urlScrapable) {\n          return <></>;\n        }\n\n        return (\n          <Button\n            className=\"scrape-url-button text-input\"\n            variant=\"secondary\"\n            onClick={() => onScrapeClick(props.value)}\n            disabled={!props.value || !urlScrapable(props.value)}\n            title={intl.formatMessage({ id: \"actions.scrape\" })}\n          >\n            <Icon icon={faFileDownload} />\n          </Button>\n        );\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Shared/styles.scss",
    "content": ".LoadingIndicator {\n  // fade in animation - delay showing\n  animation: fadeInAnimation ease 200ms;\n  animation-delay: 200ms;\n  animation-fill-mode: forwards;\n  animation-iteration-count: 1;\n\n  opacity: 0;\n}\n\n@keyframes fadeInAnimation {\n  0% {\n    opacity: 0;\n  }\n\n  100% {\n    opacity: 1;\n  }\n}\n\n.LoadingIndicator {\n  align-items: center;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  width: 100%;\n\n  &:not(.card-based) {\n    padding-top: 2rem;\n  }\n\n  &-message {\n    margin-top: 1rem;\n  }\n\n  .spinner-border {\n    height: 3rem;\n    width: 3rem;\n  }\n\n  &.inline {\n    display: inline;\n    height: auto;\n    margin-left: 0.5rem;\n  }\n\n  &.small .spinner-border {\n    height: 1rem;\n    width: 1rem;\n  }\n}\n\n.details-edit {\n  /*\n    The penultimate button should be wrapped in an unstyled div.\n    This allows the div to expand, to right-justify the last (save / delete) button.\n  */\n\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: left;\n  padding: 0;\n  row-gap: 0.5rem;\n\n  > .btn {\n    margin-right: 0.5rem;\n    white-space: nowrap;\n  }\n\n  > .btn-group {\n    margin-right: 0.5rem;\n\n    .btn {\n      margin-right: 0;\n    }\n\n    // Show caret on split button dropdown toggle\n    .dropdown-toggle-split::after {\n      content: \"\";\n    }\n  }\n}\n\n.col-md-8 .details-edit div:nth-last-child(2),\n.detail-header.edit .details-edit div:nth-last-child(2) {\n  flex: 1;\n  max-width: 100%;\n}\n\n.select-suggest {\n  &:hover {\n    cursor: text;\n  }\n}\n\n.duration-input,\n.percent-input {\n  .duration-control,\n  .percent-control {\n    min-width: 3rem;\n  }\n\n  .duration-button,\n  .percent-button {\n    border-bottom-left-radius: 0;\n    border-top-left-radius: 0;\n    line-height: 10px;\n    padding: 1px 7px;\n  }\n\n  .btn + .btn {\n    margin-left: 0;\n  }\n}\n\n// z-index gets set on button groups for some reason\n.multi-set .btn-group > button.btn {\n  z-index: auto;\n}\n\n.folder-item {\n  button {\n    padding: 0;\n  }\n}\n\n.folder-list {\n  list-style-type: none;\n  margin: 0;\n  max-height: 30vw;\n  overflow-x: auto;\n  padding-bottom: 0.5rem;\n  padding-top: 1rem;\n\n  &-item {\n    white-space: nowrap;\n\n    .btn {\n      border: none;\n      color: white;\n      font-weight: 400;\n      padding: 0;\n      text-align: left;\n      width: 100%;\n    }\n\n    &:last-child .btn span::before {\n      content: \"└ \\1F4C1\";\n    }\n\n    .btn span::before {\n      content: \"├ \\1F4C1\";\n      display: inline-block;\n      padding-right: 1rem;\n      transform: scale(1.5);\n    }\n  }\n\n  &-parent {\n    .btn span::before {\n      visibility: hidden;\n    }\n\n    .btn-link {\n      font-weight: 500;\n    }\n  }\n}\n\n.scrape-dialog {\n  .column-label {\n    color: $muted-gray;\n    font-size: 0.85em;\n  }\n\n  .string-list-input {\n    width: 100%;\n  }\n\n  .modal-content .dialog-container {\n    max-height: calc(100vh - 14rem);\n    overflow-y: auto;\n    padding-right: 15px;\n  }\n\n  .image-selection-parent {\n    min-width: 100%;\n  }\n\n  .image-selection {\n    .select-buttons {\n      align-items: center;\n      display: flex;\n      justify-content: space-between;\n      margin-top: 1rem;\n\n      .image-index {\n        flex-grow: 1;\n        text-align: center;\n      }\n    }\n\n    .loading {\n      opacity: 0.5;\n    }\n\n    .LoadingIndicator {\n      height: 100%;\n      position: absolute;\n      top: 0;\n    }\n  }\n}\n\nbutton.collapse-button.btn-primary:not(:disabled):not(.disabled):hover,\nbutton.collapse-button.btn-primary:not(:disabled):not(.disabled):focus,\nbutton.collapse-button.btn-primary:not(:disabled):not(.disabled):active {\n  background: none;\n  border: none;\n  box-shadow: none;\n  color: #f5f8fa;\n  text-align: left;\n}\n\nbutton.collapse-button {\n  .fa-icon {\n    margin-left: 0;\n  }\n\n  padding-left: 0;\n}\n\n.hover-popover-content {\n  max-width: 32rem;\n  text-align: center;\n\n  .popover-card {\n    padding: 0.5rem;\n  }\n}\n\n.warning-hover-popover {\n  display: inline-flex;\n  margin: 0 0.25rem;\n\n  .fa-icon {\n    color: $warning;\n  }\n}\n\n.ErrorMessage-container {\n  display: flex;\n  justify-content: center;\n  width: 100%;\n}\n\n.ErrorMessage {\n  .fa-icon {\n    color: $warning;\n    font-size: 1.5em;\n    margin-right: 0.3em;\n    vertical-align: middle;\n  }\n\n  background-color: initial;\n  border-color: $danger;\n  color: $text-color;\n  margin: 1rem;\n  text-align: left;\n  width: 500px;\n\n  @include media-breakpoint-down(xs) {\n    width: 100%;\n  }\n}\n\n.grid-card {\n  a .card-section-title {\n    color: $text-color;\n    text-decoration: none;\n  }\n\n  .progress-bar {\n    background-color: #73859f80;\n    bottom: 5px;\n    display: block;\n    height: 5px;\n    position: absolute;\n    width: 100%;\n  }\n\n  .progress-indicator {\n    background-color: #137cbd;\n    height: 5px;\n  }\n\n  .card-controls {\n    align-items: center;\n    display: flex;\n    left: 0.5rem;\n\n    position: absolute;\n    top: 0.7rem;\n    z-index: 1;\n  }\n\n  .card-check,\n  .card-drag-handle {\n    height: 1.2rem;\n    opacity: 0;\n    width: 1.2rem;\n\n    &:checked {\n      opacity: 0.75;\n    }\n\n    @media (hover: none), (pointer: coarse) {\n      // always show card controls when hovering not supported\n      opacity: 0.25;\n    }\n  }\n\n  .card-drag-handle {\n    cursor: move;\n  }\n\n  .card-check {\n    padding-left: 15px;\n\n    @media (hover: none), (pointer: coarse) {\n      // and make it bigger when hovering not supported\n      width: 1.5rem;\n    }\n  }\n\n  &:hover .card-check,\n  &:hover .card-drag-handle {\n    opacity: 0.75;\n    transition: opacity 0.5s;\n  }\n}\n\n.search-item-check,\n.wall-item-check {\n  height: 1.2rem;\n  width: 1.2rem;\n}\n\n// Wall item checkbox styles\n.wall-item-check {\n  left: 0.5rem;\n  opacity: 0;\n  position: absolute;\n  top: 0.5rem;\n  z-index: 10;\n\n  &:checked {\n    opacity: 0.75;\n  }\n\n  @media (hover: none) {\n    opacity: 0.25;\n  }\n}\n\n.wall-item:hover .wall-item-check {\n  opacity: 0.75;\n  transition: opacity 0.5s;\n}\n\n.TruncatedText {\n  -webkit-box-orient: vertical;\n  display: -webkit-box;\n  overflow: hidden;\n  white-space: pre-line;\n\n  &-tooltip .tooltip-inner {\n    max-width: 300px;\n    white-space: pre-line;\n  }\n\n  .file-info-panel a > & {\n    word-break: break-all;\n  }\n\n  &.inline {\n    display: inline;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n}\n\n.RatingStars {\n  &-unfilled {\n    path {\n      fill: white;\n    }\n  }\n\n  &-filled {\n    path {\n      fill: gold;\n    }\n  }\n}\n\n.three-state-checkbox {\n  align-items: center;\n  display: flex;\n\n  button.btn {\n    font-size: 12.67px;\n    margin-left: -0.2em;\n    margin-right: 0.25rem;\n    padding: 0;\n\n    &:not(:disabled):active,\n    &:not(:disabled):active:focus,\n    &:not(:disabled):hover,\n    &:not(:disabled):not(:hover) {\n      background-color: initial;\n      box-shadow: none;\n    }\n  }\n\n  &.unset {\n    .label {\n      color: #bfccd6;\n      text-decoration: line-through;\n    }\n  }\n\n  &.checked svg {\n    color: #0f9960;\n  }\n\n  &.not-checked svg {\n    color: #db3737;\n  }\n}\n\n.input-group-prepend {\n  .btn {\n    border-bottom-right-radius: 0;\n    border-top-right-radius: 0;\n  }\n}\n\n.input-group-append {\n  .btn {\n    border-bottom-left-radius: 0;\n    border-top-left-radius: 0;\n  }\n}\n\n.ModalComponent .modal-footer {\n  justify-content: space-between;\n}\n\n.scrape-url-button:disabled {\n  opacity: 0.5;\n}\n\n.string-list-input {\n  .form-group {\n    margin-bottom: 0;\n  }\n\n  .input-group {\n    margin-bottom: 0.35rem;\n\n    &:last-child {\n      margin-bottom: 0;\n    }\n  }\n\n  .btn.drag-handle {\n    display: inline-block;\n    margin: -0.25em 0.25em -0.25em -0.25em;\n    padding: 0.25em 0.5em 0.25em;\n\n    &:not(:disabled):not(.disabled) {\n      cursor: move;\n    }\n\n    &:hover,\n    &:active,\n    &:focus,\n    &:focus:active {\n      background-color: initial;\n      border-color: initial;\n      box-shadow: initial;\n    }\n  }\n}\n\n.bulk-update-date-input {\n  .react-datepicker-wrapper .btn {\n    border-bottom-right-radius: 0;\n    border-top-right-radius: 0;\n  }\n}\n\n.date-input.form-control:focus {\n  // z-index gets set to 3 in input groups\n  z-index: inherit;\n}\n\n/* stylelint-disable */\ndiv.react-datepicker {\n  background-color: $body-bg;\n  border-color: $card-bg;\n  color: $text-color;\n\n  .react-datepicker__header,\n  .react-datepicker-time__header {\n    background-color: $secondary;\n    color: $text-color;\n    padding-top: 0.4rem;\n  }\n\n  .react-datepicker__navigation {\n    top: 0.4rem;\n  }\n\n  .react-datepicker__day {\n    color: $text-color;\n\n    &.react-datepicker__day--disabled {\n      color: $text-muted;\n    }\n\n    &:hover {\n      background: rgba(138, 155, 168, 0.15);\n    }\n  }\n\n  div.react-datepicker__time-container div.react-datepicker__time {\n    background-color: $body-bg;\n    color: $text-color;\n\n    ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover {\n      background-color: rgba(138, 155, 168, 0.15);\n    }\n  }\n\n  .react-datepicker__day-name {\n    color: $text-color;\n  }\n\n  // replace the current month with the dropdowns\n  .react-datepicker__current-month {\n    display: none;\n  }\n\n  .react-datepicker__triangle {\n    display: none;\n  }\n\n  .react-datepicker__month-dropdown-container {\n    margin-left: 0;\n    margin-right: 0.1rem;\n  }\n\n  .react-datepicker__year-dropdown-container {\n    margin-left: 0.1rem;\n    margin-right: 0;\n  }\n\n  .react-datepicker__month-dropdown-container\n    .react-datepicker__month-read-view,\n  .react-datepicker__year-dropdown-container .react-datepicker__year-read-view {\n    font-weight: bold;\n    font-size: 0.944rem;\n\n    // react-datepicker hides these fields when the dropdown is shown\n    visibility: visible !important;\n  }\n\n  // hide the dropdown arrows\n  .react-datepicker__month-dropdown-container\n    .react-datepicker__month-read-view--down-arrow,\n  .react-datepicker__year-dropdown-container\n    .react-datepicker__year-read-view--down-arrow {\n    display: none;\n  }\n\n  .react-datepicker__year-dropdown,\n  .react-datepicker__month-dropdown {\n    background-color: $body-bg;\n\n    .react-datepicker__year-option:hover,\n    .react-datepicker__month-option:hover {\n      background-color: #8a9ba826;\n    }\n  }\n}\n\n/* stylelint-enable */\n\n#date-picker-portal .react-datepicker-popper {\n  z-index: 1600;\n}\n\n.clearable-input-group {\n  align-items: stretch;\n  display: flex;\n  flex-wrap: wrap;\n  position: relative;\n}\n\n.clearable-text-field,\n.clearable-text-field:active,\n.clearable-text-field:focus {\n  background-color: $secondary;\n  border: 0;\n  border-color: $secondary;\n  color: #fff;\n}\n\n.clearable-text-field-clear {\n  background-color: $secondary;\n  bottom: 0;\n  color: $muted-gray;\n  font-size: 0.875rem;\n  margin: 0.375rem 0.75rem;\n  padding: 0;\n  position: absolute;\n  right: 0;\n  top: 0;\n  z-index: 4;\n\n  &:hover,\n  &:focus,\n  &:active,\n  &:not(:disabled):not(.disabled):active,\n  &:not(:disabled):not(.disabled):active:focus {\n    background-color: $secondary;\n    border-color: transparent;\n    box-shadow: none;\n  }\n}\n\n.string-list-row .input-group {\n  flex-wrap: nowrap;\n}\n\n.stash-id-pill {\n  display: inline-flex;\n  font-size: 90%;\n  font-weight: 700;\n  line-height: 1;\n  max-width: 100%;\n  padding-bottom: 0.25em;\n  padding-top: 0.25em;\n  text-align: center;\n  vertical-align: baseline;\n  white-space: nowrap;\n\n  span,\n  a {\n    display: inline-block;\n    padding: 0.25em 0.6em;\n  }\n\n  span {\n    background-color: $primary;\n    border-radius: 0.25rem 0 0 0.25rem;\n    flex-shrink: 0;\n    min-width: 5em;\n  }\n\n  a {\n    background-color: $secondary;\n    border-radius: 0 0.25rem 0.25rem 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n}\n\n.react-select-image-option {\n  align-items: baseline;\n  display: flex;\n}\n\nbutton.btn.favorite-button {\n  opacity: 1;\n  transition: opacity 0.5s;\n\n  &.not-favorite {\n    color: rgba(191, 204, 214, 0.5);\n    filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));\n\n    &.hide-not-favorite {\n      opacity: 0;\n    }\n  }\n\n  &.favorite {\n    color: #ff7373;\n    filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));\n  }\n\n  &:hover,\n  &:active,\n  &:focus,\n  &:active:focus {\n    background: none;\n    box-shadow: none;\n  }\n}\n\n.count-button {\n  border-radius: 5px;\n\n  &:hover {\n    background: rgba(138, 155, 168, 0.15);\n    color: #f5f8fa;\n  }\n\n  .count-icon {\n    padding-left: 0.5rem;\n    padding-right: 0.25rem;\n  }\n\n  .count-value {\n    padding-left: 0.25rem;\n    padding-right: 0.5rem;\n  }\n\n  button.count-icon,\n  &.increment-only button.count-value {\n    &:hover {\n      background: none;\n      color: #f5f8fa;\n    }\n  }\n\n  button.btn-secondary.count-icon,\n  button.btn-secondary.count-value {\n    &:focus {\n      border: none;\n      box-shadow: none;\n      color: #f5f8fa;\n\n      &:not(:hover) {\n        background: none;\n      }\n    }\n  }\n}\n\n.external-links-button {\n  display: inline-block;\n}\n\n.scraper-menu .dropdown-menu {\n  min-width: 250px;\n  padding-top: 0;\n\n  .dropdown-divider {\n    border-top-color: $textfield-bg;\n    margin: 0;\n  }\n\n  .scraper-filter-container {\n    background-color: $secondary;\n    border-bottom: solid 1px $textfield-bg;\n    display: flex;\n    padding: 5px;\n    position: sticky;\n    top: 0;\n    z-index: 1;\n\n    .clearable-input-group {\n      flex-grow: 1;\n    }\n\n    .clearable-text-field {\n      background-color: $textfield-bg;\n    }\n\n    .clearable-text-field-clear {\n      background-color: unset;\n      border: unset;\n    }\n\n    .reload-button.btn {\n      border-bottom-right-radius: 0.25rem;\n      border-top-right-radius: 0.25rem;\n    }\n  }\n}\n\n.custom-fields {\n  width: 100%;\n\n  .detail-item {\n    max-width: 100%;\n  }\n\n  .detail-item-title,\n  .detail-item-value {\n    font-family: \"Courier New\", Courier, monospace;\n  }\n}\n\n.custom-fields .detail-item .detail-item-title {\n  max-width: 130px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.custom-fields .detail-item .detail-item-value {\n  word-break: break-word;\n\n  .TruncatedText {\n    white-space: pre-line;\n  }\n}\n\n.custom-fields-input > .collapse-button {\n  font-weight: 700;\n}\n\n.custom-fields-input {\n  .custom-fields-field {\n    flex: 0 0 100%;\n    max-width: 100%;\n\n    @include media-breakpoint-up(sm) {\n      flex: 0 0 25%;\n      max-width: 25%;\n    }\n    @include media-breakpoint-up(xl) {\n      flex: 0 0 16.667%;\n      max-width: 16.667%;\n    }\n  }\n\n  .custom-fields-value {\n    flex: 0 0 100%;\n    max-width: 100%;\n\n    @include media-breakpoint-up(sm) {\n      flex: 0 0 75%;\n      max-width: 75%;\n    }\n    @include media-breakpoint-up(xl) {\n      flex: 0 0 58.33%;\n      max-width: 58.33%;\n    }\n  }\n}\n\n.custom-fields-row {\n  align-items: center;\n  font-family: \"Courier New\", Courier, monospace;\n  font-size: 0.875rem;\n\n  .form-label {\n    margin-bottom: 0;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    vertical-align: middle;\n    white-space: nowrap;\n  }\n\n  // labels with titles are styled with help cursor and dotted underline elsewhere\n  div.custom-fields-field label.form-label {\n    cursor: inherit;\n    text-decoration: inherit;\n  }\n\n  .form-control,\n  .btn {\n    font-size: 0.875rem;\n  }\n\n  &.custom-fields-new > div:not(:last-child) {\n    padding-right: 0;\n  }\n}\n\n.sidebar-pane {\n  display: flex;\n\n  .sidebar {\n    // TODO - use different colours for sidebar and toolbar\n    background-color: $body-bg;\n    border-right: 1px solid $secondary;\n    flex: $sidebar-width;\n    flex-grow: 0;\n    flex-shrink: 0;\n    padding-left: 15px;\n    transition: margin-left 0.1s;\n  }\n\n  .sidebar {\n    bottom: 0;\n    left: 0;\n    margin-top: $navbar-height;\n    overflow-y: auto;\n    padding-top: 0.5rem;\n    position: fixed;\n    scrollbar-gutter: stable;\n    top: 0;\n    width: $sidebar-width;\n    z-index: 100;\n  }\n\n  &.hide-sidebar .sidebar {\n    margin-left: -$sidebar-width;\n  }\n\n  &.hide-sidebar .sidebar + div {\n    width: 100%;\n  }\n\n  &:not(.hide-sidebar) .sidebar + div {\n    width: calc(100% - $sidebar-width);\n  }\n\n  > :nth-child(2) {\n    flex-grow: 1;\n    padding-left: 0.5rem;\n  }\n\n  &.hide-sidebar {\n    > :nth-child(2) {\n      padding-left: 0;\n    }\n  }\n\n  @include media-breakpoint-up(md) {\n    transition: margin-left 0.1s;\n\n    &:not(.hide-sidebar) {\n      > :nth-child(2) {\n        margin-left: calc($sidebar-width - 15px);\n      }\n    }\n  }\n  @include media-breakpoint-down(xs) {\n    .sidebar {\n      margin-top: 0;\n    }\n  }\n}\n\n.sidebar-toggle-button-container {\n  height: 100%;\n  position: absolute;\n\n  .sidebar-toggle-button {\n    border-bottom: 1px solid $secondary;\n    border-bottom-left-radius: 0;\n    border-bottom-right-radius: 10px;\n    border-right: 1px solid $secondary;\n    border-top: 1px solid $secondary;\n    border-top-left-radius: 0;\n    border-top-right-radius: 10px;\n    margin-left: -15px;\n    opacity: 0.5;\n    position: sticky;\n    top: calc($navbar-height + 0.5rem);\n    z-index: 10;\n\n    @include media-breakpoint-down(sm) {\n      top: 0.5rem;\n    }\n  }\n}\n\n.sidebar-pane:not(.hide-sidebar) .sidebar-toggle-button-container {\n  .sidebar-toggle-button {\n    margin-left: -0.5rem;\n  }\n}\n\n.sidebar-toolbar {\n  // TODO - use different colours for sidebar and toolbar\n  background-color: $body-bg;\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 0;\n  padding-bottom: 1rem;\n  position: sticky;\n  top: 0;\n  z-index: 101;\n}\n\n@include media-breakpoint-down(xs) {\n  .sidebar-toolbar {\n    padding-top: 1rem;\n  }\n}\n\n.sidebar-section {\n  border-bottom: 1px solid $secondary;\n\n  .collapse-header {\n    // background-color: $secondary;\n\n    padding: 0.25rem;\n\n    .collapse-button {\n      font-weight: bold;\n      text-align: left;\n      width: 100%;\n    }\n  }\n\n  .collapse,\n  // include collapsing to allow for the transition\n  .collapsing {\n    padding-top: 0.25rem;\n  }\n}\n\n.sidebar-section:first-child .collapse-header {\n  border-top: 1px solid $secondary;\n}\n\n$sticky-header-height: calc(50px + 3.3rem);\n\n// special case for sidebar in details view\n.detail-body .sidebar-toggle-button-container .sidebar-toggle-button {\n  top: calc($sticky-header-height + 0.5rem);\n\n  @include media-breakpoint-down(sm) {\n    top: 0.5rem;\n  }\n}\n\n.detail-body {\n  .sidebar-pane {\n    position: sticky;\n    top: calc($sticky-detail-header-height + $navbar-height);\n  }\n\n  .sidebar {\n    // required for sticky to work\n    align-self: flex-start;\n\n    // take a further 15px padding to match the detail body\n    margin-top: -15px;\n    max-height: calc(100vh - $sticky-header-height - 15px);\n    overflow-y: auto;\n    padding-left: 0;\n    position: sticky;\n\n    top: calc($sticky-detail-header-height + $navbar-height);\n\n    .sidebar-toolbar {\n      padding-top: 15px;\n    }\n  }\n\n  .sidebar-pane:not(.hide-sidebar) .sidebar {\n    height: calc(100vh - $sticky-header-height - 15px);\n  }\n\n  .sidebar-pane.hide-sidebar .sidebar {\n    left: -$sidebar-width;\n    margin-left: calc(-15px - $sidebar-width);\n  }\n\n  // on smaller viewports we want the sidebar to overlap content\n  @include media-breakpoint-down(sm) {\n    .sidebar-pane:not(.hide-sidebar) .sidebar {\n      margin-right: -$sidebar-width;\n    }\n\n    .sidebar-pane > .sidebar-pane-content {\n      transition: none;\n    }\n  }\n  @include media-breakpoint-down(xs) {\n    .sidebar-pane {\n      top: 0;\n    }\n\n    .sidebar {\n      // flex: 100% 0 0;\n      height: calc(100vh - $navbar-height);\n      max-height: calc(100vh - $navbar-height);\n      top: 0;\n    }\n  }\n  @include media-breakpoint-up(md) {\n    .sidebar-pane:not(.hide-sidebar) {\n      > :nth-child(2) {\n        margin-left: 0;\n      }\n    }\n  }\n\n  .sidebar-pane.hide-sidebar {\n    > :nth-child(2) {\n      padding-left: 15px;\n    }\n  }\n}\n\n// Duration slider styles\n.duration-slider-container {\n  padding: 0.5rem 0 1rem;\n  width: 100%;\n}\n\n.double-range-input-labels {\n  color: $text-color;\n  display: flex;\n  font-size: 0.875rem;\n  font-weight: 500;\n  justify-content: space-between;\n  margin-bottom: 0.5rem;\n  padding: 0 0.25rem;\n\n  input[type=\"text\"] {\n    &:first-child {\n      text-align: left;\n    }\n\n    &:last-child {\n      text-align: right;\n    }\n  }\n}\n\n.double-range-sliders {\n  height: 22px;\n  position: relative;\n}\n\n.double-range-slider {\n  pointer-events: none;\n  position: absolute;\n  width: 100%;\n\n  &::-webkit-slider-thumb {\n    appearance: none;\n    background-color: $primary;\n    border: 2px solid $primary;\n    border-radius: 50%;\n    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n    cursor: pointer;\n    height: 18px;\n    pointer-events: all;\n    position: relative;\n    width: 18px;\n  }\n\n  &::-moz-range-thumb {\n    appearance: none;\n    background-color: $primary;\n    border: 2px solid $primary;\n    border-radius: 50%;\n    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n    cursor: pointer;\n    height: 18px;\n    pointer-events: all;\n    position: relative;\n    width: 18px;\n  }\n\n  &::-ms-thumb {\n    appearance: none;\n    background-color: $primary;\n    border: 2px solid $primary;\n    border-radius: 50%;\n    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n    cursor: pointer;\n    height: 18px;\n    pointer-events: all;\n    position: relative;\n    width: 18px;\n  }\n}\n\n.double-range-slider-min {\n  z-index: 1;\n}\n\ninput[type=\"range\"].double-range-slider-max {\n  z-index: 2;\n\n  // combining these into one rule doesn't work for some reason\n  &::-webkit-slider-runnable-track {\n    background: transparent;\n  }\n\n  &::-moz-range-track {\n    background: transparent;\n  }\n\n  &::-ms-track {\n    background: transparent;\n  }\n}\n\n// Label offset for buttons that need to align with form fields\n.ml-label {\n  @include media-breakpoint-up(sm) {\n    // sm: label is 3 of 12 columns = 25%, plus partial gutter\n    margin-left: calc(25% + 7.5px);\n  }\n  @include media-breakpoint-up(xl) {\n    // xl: label is 2 of 12 columns = 16.667%, plus partial gutter\n    margin-left: calc(16.667% + 7.5px);\n  }\n}\n\n// StashBox Search Modal\n.StashBoxSearchModal {\n  &-list {\n    list-style: none;\n    padding: 0;\n\n    li {\n      border-radius: 0.25rem;\n      cursor: pointer;\n      margin-bottom: 0.5rem;\n      padding: 0.5rem;\n      transition: background-color 0.2s;\n\n      &:hover {\n        background-color: rgba(138, 155, 168, 0.1);\n      }\n\n      &.selected {\n        background-color: #e7f3ff;\n      }\n    }\n  }\n\n  &-list-container {\n    max-height: 60vh;\n    overflow-y: auto;\n  }\n}\n\n.reveal-in-filesystem-button {\n  margin-left: 0.25rem;\n  padding: 0 0.25rem;\n}\n\n// general styling for appended minimal button to input group\n.text-input + .input-group-append .btn.minimal {\n  background-color: $textfield-bg;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Stats.tsx",
    "content": "import React from \"react\";\nimport { useStats } from \"src/core/StashService\";\nimport { FormattedMessage, FormattedNumber } from \"react-intl\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport TextUtils from \"src/utils/text\";\nimport { FileSize } from \"./Shared/FileSize\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\nexport const Stats: React.FC = () => {\n  const { configuration } = useConfigurationContext();\n  const { sfwContentMode } = configuration.interface;\n\n  const oCountID = sfwContentMode\n    ? \"stats.total_o_count_sfw\"\n    : \"stats.total_o_count\";\n\n  const { data, error, loading } = useStats();\n\n  if (error) return <span>{error.message}</span>;\n  if (loading || !data) return <LoadingIndicator />;\n\n  const scenesDuration = TextUtils.secondsAsTimeString(\n    data.stats.scenes_duration,\n    3\n  );\n\n  const totalPlayDuration = TextUtils.secondsAsTimeString(\n    data.stats.total_play_duration,\n    3\n  );\n\n  return (\n    <div className=\"mt-5\">\n      <div className=\"col col-sm-8 m-sm-auto row stats\">\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FileSize size={data.stats.scenes_size} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"stats.scenes_size\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.scene_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"scenes\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.group_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"groups\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">{scenesDuration || \"-\"}</p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"stats.scenes_duration\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.performer_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"performers\" />\n          </p>\n        </div>\n      </div>\n      <div className=\"col col-sm-8 m-sm-auto row stats\">\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FileSize size={data.stats.images_size} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"stats.image_size\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.gallery_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"galleries\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.image_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"images\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.studio_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"studios\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.tag_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"tags\" />\n          </p>\n        </div>\n      </div>\n      <div className=\"col col-sm-8 m-sm-auto row stats\">\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.total_o_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id={oCountID} />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.total_play_count} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"stats.total_play_count\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">\n            <FormattedNumber value={data.stats.scenes_played} />\n          </p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"stats.scenes_played\" />\n          </p>\n        </div>\n        <div className=\"stats-element\">\n          <p className=\"title\">{totalPlayDuration || \"-\"}</p>\n          <p className=\"heading\">\n            <FormattedMessage id=\"stats.total_play_duration\" />\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Stats;\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/EditStudiosDialog.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\nimport { useBulkStudioUpdate } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { MultiSet } from \"../Shared/MultiSet\";\nimport { RatingSystem } from \"../Shared/Rating/RatingSystem\";\nimport {\n  getAggregateInputValue,\n  getAggregateState,\n  getAggregateStateObject,\n} from \"src/utils/bulkUpdate\";\nimport { IndeterminateCheckbox } from \"../Shared/IndeterminateCheckbox\";\nimport { BulkUpdateFormGroup, BulkUpdateTextInput } from \"../Shared/BulkUpdate\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { StudioSelect } from \"../Shared/Select\";\n\ninterface IListOperationProps {\n  selected: GQL.SlimStudioDataFragment[];\n  onClose: (applied: boolean) => void;\n}\n\nconst studioFields = [\n  \"favorite\",\n  \"rating100\",\n  \"details\",\n  \"ignore_auto_tag\",\n  \"organized\",\n];\n\nexport const EditStudiosDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [updateInput, setUpdateInput] = useState<GQL.BulkStudioUpdateInput>({\n    ids: props.selected.map((studio) => {\n      return studio.id;\n    }),\n  });\n\n  const [tagIds, setTagIds] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n\n  const unsetDisabled = props.selected.length < 2;\n\n  const [updateStudios] = useBulkStudioUpdate();\n\n  // Network state\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const aggregateState = useMemo(() => {\n    const updateState: Partial<GQL.BulkStudioUpdateInput> = {};\n    const state = props.selected;\n    let updateTagIds: string[] = [];\n    let first = true;\n\n    state.forEach((studio: GQL.SlimStudioDataFragment) => {\n      getAggregateStateObject(updateState, studio, studioFields, first);\n\n      // studio data fragment doesn't have parent_id, so handle separately\n      updateState.parent_id = getAggregateState(\n        updateState.parent_id,\n        studio.parent_studio?.id,\n        first\n      );\n\n      const studioTagIDs = (studio.tags ?? []).map((p) => p.id).sort();\n\n      updateTagIds = getAggregateState(updateTagIds, studioTagIDs, first) ?? [];\n\n      first = false;\n    });\n\n    return { state: updateState, tagIds: updateTagIds };\n  }, [props.selected]);\n\n  // update initial state from aggregate\n  useEffect(() => {\n    setUpdateInput((current) => ({ ...current, ...aggregateState.state }));\n  }, [aggregateState]);\n\n  function setUpdateField(input: Partial<GQL.BulkStudioUpdateInput>) {\n    setUpdateInput((current) => ({ ...current, ...input }));\n  }\n\n  function getStudioInput(): GQL.BulkStudioUpdateInput {\n    const studioInput: GQL.BulkStudioUpdateInput = {\n      ...updateInput,\n      tag_ids: tagIds,\n    };\n\n    // we don't have unset functionality for the rating star control\n    // so need to determine if we are setting a rating or not\n    studioInput.rating100 = getAggregateInputValue(\n      updateInput.rating100,\n      aggregateState.state.rating100\n    );\n\n    return studioInput;\n  }\n\n  async function onSave() {\n    setIsUpdating(true);\n    try {\n      await updateStudios({\n        variables: {\n          input: getStudioInput(),\n        },\n      });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"studios\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsUpdating(false);\n  }\n\n  function render() {\n    return (\n      <ModalComponent\n        dialogClassName=\"edit-studios-dialog\"\n        show\n        icon={faPencilAlt}\n        header={intl.formatMessage(\n          { id: \"dialogs.edit_entity_count_title\" },\n          {\n            count: props?.selected?.length ?? 1,\n            singularEntity: intl.formatMessage({ id: \"studio\" }),\n            pluralEntity: intl.formatMessage({ id: \"studios\" }),\n          }\n        )}\n        accept={{\n          onClick: onSave,\n          text: intl.formatMessage({ id: \"actions.apply\" }),\n        }}\n        cancel={{\n          onClick: () => props.onClose(false),\n          text: intl.formatMessage({ id: \"actions.cancel\" }),\n          variant: \"secondary\",\n        }}\n        isRunning={isUpdating}\n      >\n        <Form>\n          <BulkUpdateFormGroup name=\"parent-studio\" messageId=\"parent_studio\">\n            <StudioSelect\n              onSelect={(items) =>\n                setUpdateField({\n                  parent_id: items.length > 0 ? items[0]?.id : undefined,\n                })\n              }\n              ids={updateInput.parent_id ? [updateInput.parent_id] : []}\n              isDisabled={isUpdating}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n          <BulkUpdateFormGroup name=\"rating\">\n            <RatingSystem\n              value={updateInput.rating100}\n              onSetRating={(value) =>\n                setUpdateField({ rating100: value ?? undefined })\n              }\n              disabled={isUpdating}\n            />\n          </BulkUpdateFormGroup>\n\n          <Form.Group controlId=\"favorite\">\n            <IndeterminateCheckbox\n              setChecked={(checked) => setUpdateField({ favorite: checked })}\n              checked={updateInput.favorite ?? undefined}\n              label={intl.formatMessage({ id: \"favourite\" })}\n            />\n          </Form.Group>\n\n          <BulkUpdateFormGroup name=\"tags\" inline={false}>\n            <MultiSet\n              type={\"tags\"}\n              disabled={isUpdating}\n              onUpdate={(itemIDs) => {\n                setTagIds((c) => ({ ...c, ids: itemIDs }));\n              }}\n              onSetMode={(newMode) => {\n                setTagIds((c) => ({ ...c, mode: newMode }));\n              }}\n              ids={tagIds.ids ?? []}\n              existingIds={aggregateState.tagIds}\n              mode={tagIds.mode}\n              menuPortalTarget={document.body}\n            />\n          </BulkUpdateFormGroup>\n\n          <BulkUpdateFormGroup name=\"details\" inline={false}>\n            <BulkUpdateTextInput\n              value={updateInput.details}\n              valueChanged={(newValue) => setUpdateField({ details: newValue })}\n              unsetDisabled={unsetDisabled}\n              as=\"textarea\"\n            />\n          </BulkUpdateFormGroup>\n\n          <Form.Group controlId=\"ignore-auto-tags\">\n            <IndeterminateCheckbox\n              label={intl.formatMessage({ id: \"ignore_auto_tag\" })}\n              setChecked={(checked) =>\n                setUpdateField({ ignore_auto_tag: checked })\n              }\n              checked={updateInput.ignore_auto_tag ?? undefined}\n            />\n          </Form.Group>\n\n          <Form.Group controlId=\"organized\">\n            <IndeterminateCheckbox\n              label={intl.formatMessage({ id: \"organized\" })}\n              setChecked={(checked) => setUpdateField({ organized: checked })}\n              checked={updateInput.organized ?? undefined}\n            />\n          </Form.Group>\n        </Form>\n      </ModalComponent>\n    );\n  }\n\n  return render();\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioCard.tsx",
    "content": "import React from \"react\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport NavUtils from \"src/utils/navigation\";\nimport { GridCard } from \"src/components/Shared/GridCard/GridCard\";\nimport { PatchComponent } from \"src/patch\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { Icon } from \"../Shared/Icon\";\nimport { TagLink } from \"../Shared/TagLink\";\nimport { Button, ButtonGroup, OverlayTrigger, Tooltip } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { PopoverCountButton } from \"../Shared/PopoverCountButton\";\nimport { RatingBanner } from \"../Shared/RatingBanner\";\nimport { FavoriteIcon } from \"../Shared/FavoriteIcon\";\nimport { useStudioUpdate } from \"src/core/StashService\";\nimport { faTag, faBox } from \"@fortawesome/free-solid-svg-icons\";\nimport { OCounterButton } from \"../Shared/CountButton\";\n\ninterface IProps {\n  studio: GQL.StudioDataFragment;\n  cardWidth?: number;\n  hideParent?: boolean;\n  selecting?: boolean;\n  selected?: boolean;\n  zoomIndex?: number;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n}\n\nfunction maybeRenderParent(\n  studio: GQL.StudioDataFragment,\n  hideParent?: boolean\n) {\n  if (!hideParent && studio.parent_studio) {\n    return (\n      <div className=\"studio-parent-studios\">\n        <FormattedMessage\n          id=\"part_of\"\n          values={{\n            parent: (\n              <Link to={`/studios/${studio.parent_studio.id}`}>\n                {studio.parent_studio.name}\n              </Link>\n            ),\n          }}\n        />\n      </div>\n    );\n  }\n}\n\nfunction maybeRenderChildren(studio: GQL.StudioDataFragment) {\n  if (studio.child_studios.length > 0) {\n    return (\n      <div className=\"studio-child-studios\">\n        <FormattedMessage\n          id=\"parent_of\"\n          values={{\n            children: (\n              <Link to={NavUtils.makeChildStudiosUrl(studio)}>\n                {studio.child_studios.length}&nbsp;\n                <FormattedMessage\n                  id=\"countables.studios\"\n                  values={{ count: studio.child_studios.length }}\n                />\n              </Link>\n            ),\n          }}\n        />\n      </div>\n    );\n  }\n}\n\nexport const StudioCard: React.FC<IProps> = PatchComponent(\n  \"StudioCard\",\n  ({\n    studio,\n    cardWidth,\n    hideParent,\n    selecting,\n    selected,\n    zoomIndex,\n    onSelectedChanged,\n  }) => {\n    const [updateStudio] = useStudioUpdate();\n\n    function onToggleFavorite(v: boolean) {\n      if (studio.id) {\n        updateStudio({\n          variables: {\n            input: {\n              id: studio.id,\n              favorite: v,\n            },\n          },\n        });\n      }\n    }\n\n    function maybeRenderScenesPopoverButton() {\n      if (!studio.scene_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"scene-count\"\n          type=\"scene\"\n          count={studio.scene_count}\n          url={NavUtils.makeStudioScenesUrl(studio)}\n        />\n      );\n    }\n\n    function maybeRenderImagesPopoverButton() {\n      if (!studio.image_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"image-count\"\n          type=\"image\"\n          count={studio.image_count}\n          url={NavUtils.makeStudioImagesUrl(studio)}\n        />\n      );\n    }\n\n    function maybeRenderGalleriesPopoverButton() {\n      if (!studio.gallery_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"gallery-count\"\n          type=\"gallery\"\n          count={studio.gallery_count}\n          url={NavUtils.makeStudioGalleriesUrl(studio)}\n        />\n      );\n    }\n\n    function maybeRenderGroupsPopoverButton() {\n      if (!studio.group_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"group-count\"\n          type=\"group\"\n          count={studio.group_count}\n          url={NavUtils.makeStudioGroupsUrl(studio)}\n        />\n      );\n    }\n\n    function maybeRenderPerformersPopoverButton() {\n      if (!studio.performer_count) return;\n\n      return (\n        <PopoverCountButton\n          className=\"performer-count\"\n          type=\"performer\"\n          count={studio.performer_count}\n          url={NavUtils.makeStudioPerformersUrl(studio)}\n        />\n      );\n    }\n\n    function maybeRenderTagPopoverButton() {\n      if (studio.tags.length <= 0) return;\n\n      const popoverContent = studio.tags.map((tag) => (\n        <TagLink key={tag.id} linkType=\"studio\" tag={tag} />\n      ));\n\n      return (\n        <HoverPopover placement=\"bottom\" content={popoverContent}>\n          <Button className=\"minimal tag-count\">\n            <Icon icon={faTag} />\n            <span>{studio.tags.length}</span>\n          </Button>\n        </HoverPopover>\n      );\n    }\n\n    function maybeRenderOCounter() {\n      if (!studio.o_counter) return;\n\n      return <OCounterButton value={studio.o_counter} />;\n    }\n\n    function maybeRenderOrganized() {\n      if (studio.organized) {\n        return (\n          <OverlayTrigger\n            overlay={\n              <Tooltip id=\"organized-tooltip\">\n                <FormattedMessage id=\"organized\" />\n              </Tooltip>\n            }\n            placement=\"bottom\"\n          >\n            <div className=\"organized\">\n              <Button className=\"minimal\">\n                <Icon icon={faBox} />\n              </Button>\n            </div>\n          </OverlayTrigger>\n        );\n      }\n    }\n\n    function maybeRenderPopoverButtonGroup() {\n      if (\n        studio.scene_count ||\n        studio.image_count ||\n        studio.gallery_count ||\n        studio.group_count ||\n        studio.performer_count ||\n        studio.o_counter ||\n        studio.tags.length > 0 ||\n        studio.organized\n      ) {\n        return (\n          <>\n            <hr />\n            <ButtonGroup className=\"card-popovers\">\n              {maybeRenderScenesPopoverButton()}\n              {maybeRenderGroupsPopoverButton()}\n              {maybeRenderImagesPopoverButton()}\n              {maybeRenderGalleriesPopoverButton()}\n              {maybeRenderPerformersPopoverButton()}\n              {maybeRenderTagPopoverButton()}\n              {maybeRenderOCounter()}\n              {maybeRenderOrganized()}\n            </ButtonGroup>\n          </>\n        );\n      }\n    }\n\n    return (\n      <GridCard\n        className={`studio-card zoom-${zoomIndex}`}\n        url={`/studios/${studio.id}`}\n        width={cardWidth}\n        title={studio.name}\n        linkClassName=\"studio-card-header\"\n        image={\n          <img\n            loading=\"lazy\"\n            className=\"studio-card-image\"\n            alt={studio.name}\n            src={studio.image_path ?? \"\"}\n          />\n        }\n        details={\n          <div className=\"studio-card__details\">\n            {maybeRenderParent(studio, hideParent)}\n            {maybeRenderChildren(studio)}\n            <RatingBanner rating={studio.rating100} />\n          </div>\n        }\n        overlays={\n          <FavoriteIcon\n            favorite={studio.favorite}\n            onToggleFavorite={(v) => onToggleFavorite(v)}\n            size=\"2x\"\n            className=\"hide-not-favorite\"\n          />\n        }\n        popovers={maybeRenderPopoverButtonGroup()}\n        selected={selected}\n        selecting={selecting}\n        onSelectedChanged={onSelectedChanged}\n      />\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioCardGrid.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useCardWidth,\n  useContainerDimensions,\n} from \"../Shared/GridCard/GridCard\";\nimport { StudioCard } from \"./StudioCard\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface IStudioCardGrid {\n  studios: GQL.StudioDataFragment[];\n  fromParent: boolean | undefined;\n  selectedIds: Set<string>;\n  zoomIndex: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nconst zoomWidths = [280, 340, 420, 560];\n\nexport const StudioCardGrid: React.FC<IStudioCardGrid> = PatchComponent(\n  \"StudioCardGrid\",\n  ({ studios, fromParent, selectedIds, zoomIndex, onSelectChange }) => {\n    const [componentRef, { width: containerWidth }] = useContainerDimensions();\n    const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);\n\n    return (\n      <div className=\"row justify-content-center\" ref={componentRef}>\n        {studios.map((studio) => (\n          <StudioCard\n            key={studio.id}\n            cardWidth={cardWidth}\n            studio={studio}\n            zoomIndex={zoomIndex}\n            hideParent={fromParent}\n            selecting={selectedIds.size > 0}\n            selected={selectedIds.has(studio.id)}\n            onSelectedChanged={(selected: boolean, shiftKey: boolean) =>\n              onSelectChange(studio.id, selected, shiftKey)\n            }\n          />\n        ))}\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx",
    "content": "import { Tabs, Tab, Form } from \"react-bootstrap\";\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport { useHistory, Redirect, RouteComponentProps } from \"react-router-dom\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Helmet } from \"react-helmet\";\nimport cx from \"classnames\";\nimport Mousetrap from \"mousetrap\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useFindStudio,\n  useStudioUpdate,\n  useStudioDestroy,\n  mutateMetadataAutoTag,\n} from \"src/core/StashService\";\nimport { DetailsEditNavbar } from \"src/components/Shared/DetailsEditNavbar\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { ErrorMessage } from \"src/components/Shared/ErrorMessage\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { StudioScenesPanel } from \"./StudioScenesPanel\";\nimport { StudioGalleriesPanel } from \"./StudioGalleriesPanel\";\nimport { StudioImagesPanel } from \"./StudioImagesPanel\";\nimport { StudioChildrenPanel } from \"./StudioChildrenPanel\";\nimport { StudioPerformersPanel } from \"./StudioPerformersPanel\";\nimport { StudioEditPanel } from \"./StudioEditPanel\";\nimport {\n  CompressedStudioDetailsPanel,\n  StudioDetailsPanel,\n} from \"./StudioDetailsPanel\";\nimport { StudioGroupsPanel } from \"./StudioGroupsPanel\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport { DetailImage } from \"src/components/Shared/DetailImage\";\nimport { useRatingKeybinds } from \"src/hooks/keybinds\";\nimport { useLoadStickyHeader } from \"src/hooks/detailsPanel\";\nimport { useScrollToTopOnMount } from \"src/hooks/scrollToTop\";\nimport { BackgroundImage } from \"src/components/Shared/DetailsPage/BackgroundImage\";\nimport {\n  TabTitleCounter,\n  useTabKey,\n} from \"src/components/Shared/DetailsPage/Tabs\";\nimport { DetailTitle } from \"src/components/Shared/DetailsPage/DetailTitle\";\nimport { ExpandCollapseButton } from \"src/components/Shared/CollapseButton\";\nimport { FavoriteIcon } from \"src/components/Shared/FavoriteIcon\";\nimport { ExternalLinkButtons } from \"src/components/Shared/ExternalLinksButton\";\nimport { AliasList } from \"src/components/Shared/DetailsPage/AliasList\";\nimport { HeaderImage } from \"src/components/Shared/DetailsPage/HeaderImage\";\nimport { goBackOrReplace } from \"src/utils/history\";\nimport { OCounterButton } from \"src/components/Shared/CountButton\";\nimport { OrganizedButton } from \"src/components/Scenes/SceneDetails/OrganizedButton\";\n\ninterface IProps {\n  studio: GQL.StudioDataFragment;\n  tabKey?: TabKey;\n}\n\ninterface IStudioParams {\n  id: string;\n  tab?: string;\n}\n\nconst validTabs = [\n  \"default\",\n  \"scenes\",\n  \"galleries\",\n  \"images\",\n  \"performers\",\n  \"groups\",\n  \"childstudios\",\n] as const;\ntype TabKey = (typeof validTabs)[number];\n\nfunction isTabKey(tab: string): tab is TabKey {\n  return validTabs.includes(tab as TabKey);\n}\n\nconst StudioTabs: React.FC<{\n  tabKey?: TabKey;\n  studio: GQL.StudioDataFragment;\n  abbreviateCounter: boolean;\n  showAllCounts?: boolean;\n}> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => {\n  const [showAllDetails, setShowAllDetails] = useState<boolean>(\n    showAllCounts && studio.child_studios.length > 0\n  );\n\n  const sceneCount =\n    (showAllDetails ? studio.scene_count_all : studio.scene_count) ?? 0;\n  const galleryCount =\n    (showAllDetails ? studio.gallery_count_all : studio.gallery_count) ?? 0;\n  const imageCount =\n    (showAllDetails ? studio.image_count_all : studio.image_count) ?? 0;\n  const performerCount =\n    (showAllDetails ? studio.performer_count_all : studio.performer_count) ?? 0;\n  const groupCount =\n    (showAllDetails ? studio.group_count_all : studio.group_count) ?? 0;\n\n  const populatedDefaultTab = useMemo(() => {\n    let ret: TabKey = \"scenes\";\n    if (sceneCount == 0) {\n      if (galleryCount != 0) {\n        ret = \"galleries\";\n      } else if (imageCount != 0) {\n        ret = \"images\";\n      } else if (performerCount != 0) {\n        ret = \"performers\";\n      } else if (groupCount != 0) {\n        ret = \"groups\";\n      } else if (studio.child_studios.length != 0) {\n        ret = \"childstudios\";\n      }\n    }\n\n    return ret;\n  }, [\n    sceneCount,\n    galleryCount,\n    imageCount,\n    performerCount,\n    groupCount,\n    studio,\n  ]);\n\n  const { setTabKey } = useTabKey({\n    tabKey,\n    validTabs,\n    defaultTabKey: populatedDefaultTab,\n    baseURL: `/studios/${studio.id}`,\n  });\n\n  const contentSwitch = useMemo(() => {\n    if (!studio.child_studios.length) {\n      return null;\n    }\n\n    return (\n      <div className=\"item-list-header\">\n        <Form.Check\n          id=\"showSubContent\"\n          checked={showAllDetails}\n          onChange={() => setShowAllDetails(!showAllDetails)}\n          type=\"switch\"\n          label={<FormattedMessage id=\"include_sub_studio_content\" />}\n        />\n      </div>\n    );\n  }, [showAllDetails, studio.child_studios.length]);\n\n  return (\n    <Tabs\n      id=\"studio-tabs\"\n      mountOnEnter\n      unmountOnExit\n      activeKey={tabKey}\n      onSelect={setTabKey}\n    >\n      <Tab\n        eventKey=\"scenes\"\n        title={\n          <TabTitleCounter\n            messageID=\"scenes\"\n            count={sceneCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <StudioScenesPanel\n          active={tabKey === \"scenes\"}\n          studio={studio}\n          showChildStudioContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"galleries\"\n        title={\n          <TabTitleCounter\n            messageID=\"galleries\"\n            count={galleryCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <StudioGalleriesPanel\n          active={tabKey === \"galleries\"}\n          studio={studio}\n          showChildStudioContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"images\"\n        title={\n          <TabTitleCounter\n            messageID=\"images\"\n            count={imageCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <StudioImagesPanel\n          active={tabKey === \"images\"}\n          studio={studio}\n          showChildStudioContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"performers\"\n        title={\n          <TabTitleCounter\n            messageID=\"performers\"\n            count={performerCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <StudioPerformersPanel\n          active={tabKey === \"performers\"}\n          studio={studio}\n          showChildStudioContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"groups\"\n        title={\n          <TabTitleCounter\n            messageID=\"groups\"\n            count={groupCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <StudioGroupsPanel\n          active={tabKey === \"groups\"}\n          studio={studio}\n          showChildStudioContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"childstudios\"\n        title={\n          <TabTitleCounter\n            messageID=\"subsidiary_studios\"\n            count={studio.child_studios.length}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        <StudioChildrenPanel\n          active={tabKey === \"childstudios\"}\n          studio={studio}\n        />\n      </Tab>\n    </Tabs>\n  );\n};\n\nconst StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {\n  const history = useHistory();\n  const Toast = useToast();\n  const intl = useIntl();\n\n  // Configuration settings\n  const { configuration } = useConfigurationContext();\n  const uiConfig = configuration?.ui;\n  const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;\n  const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false;\n  const showAllDetails = uiConfig?.showAllDetails ?? true;\n  const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;\n\n  const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);\n  const loadStickyHeader = useLoadStickyHeader();\n\n  // Editing state\n  const [isEditing, setIsEditing] = useState<boolean>(false);\n  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);\n\n  // Editing studio state\n  const [image, setImage] = useState<string | null>();\n  const [encodingImage, setEncodingImage] = useState<boolean>(false);\n\n  const [updateStudio] = useStudioUpdate();\n  const [deleteStudio] = useStudioDestroy({ id: studio.id });\n\n  const showAllCounts = uiConfig?.showChildStudioContent;\n\n  const studioImage = useMemo(() => {\n    const existingPath = studio.image_path;\n    if (isEditing) {\n      if (image === null && existingPath) {\n        const studioImageURL = new URL(existingPath);\n        studioImageURL.searchParams.set(\"default\", \"true\");\n        return studioImageURL.toString();\n      } else if (image) {\n        return image;\n      }\n    }\n\n    return existingPath;\n  }, [isEditing, image, studio.image_path]);\n\n  function setFavorite(v: boolean) {\n    if (studio.id) {\n      updateStudio({\n        variables: {\n          input: {\n            id: studio.id,\n            favorite: v,\n          },\n        },\n      });\n    }\n  }\n\n  const [organizedLoading, setOrganizedLoading] = useState(false);\n\n  async function onOrganizedClick() {\n    if (!studio.id) return;\n\n    setOrganizedLoading(true);\n    try {\n      await updateStudio({\n        variables: {\n          input: {\n            id: studio.id,\n            organized: !studio.organized,\n          },\n        },\n      });\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setOrganizedLoading(false);\n    }\n  }\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"e\", () => toggleEditing());\n    Mousetrap.bind(\"d d\", () => {\n      setIsDeleteAlertOpen(true);\n    });\n    Mousetrap.bind(\",\", () => setCollapsed(!collapsed));\n    Mousetrap.bind(\"f\", () => setFavorite(!studio.favorite));\n\n    return () => {\n      Mousetrap.unbind(\"e\");\n      Mousetrap.unbind(\"d d\");\n      Mousetrap.unbind(\",\");\n      Mousetrap.unbind(\"f\");\n    };\n  });\n\n  useRatingKeybinds(\n    true,\n    configuration?.ui.ratingSystemOptions?.type,\n    setRating\n  );\n\n  async function onSave(input: GQL.StudioCreateInput) {\n    await updateStudio({\n      variables: {\n        input: {\n          id: studio.id,\n          ...input,\n        },\n      },\n    });\n    toggleEditing(false);\n    Toast.success(\n      intl.formatMessage(\n        { id: \"toast.updated_entity\" },\n        { entity: intl.formatMessage({ id: \"studio\" }).toLocaleLowerCase() }\n      )\n    );\n  }\n\n  async function onAutoTag() {\n    if (!studio.id) return;\n    try {\n      await mutateMetadataAutoTag({ studios: [studio.id] });\n      Toast.success(intl.formatMessage({ id: \"toast.started_auto_tagging\" }));\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function onDelete() {\n    try {\n      await deleteStudio();\n    } catch (e) {\n      Toast.error(e);\n      return;\n    }\n\n    goBackOrReplace(history, \"/studios\");\n  }\n\n  function renderDeleteAlert() {\n    return (\n      <ModalComponent\n        show={isDeleteAlertOpen}\n        icon={faTrashAlt}\n        accept={{\n          text: intl.formatMessage({ id: \"actions.delete\" }),\n          variant: \"danger\",\n          onClick: onDelete,\n        }}\n        cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}\n      >\n        <p>\n          <FormattedMessage\n            id=\"dialogs.delete_confirm\"\n            values={{\n              entityName:\n                studio.name ??\n                intl.formatMessage({ id: \"studio\" }).toLocaleLowerCase(),\n            }}\n          />\n        </p>\n      </ModalComponent>\n    );\n  }\n\n  function toggleEditing(value?: boolean) {\n    if (value !== undefined) {\n      setIsEditing(value);\n    } else {\n      setIsEditing((e) => !e);\n    }\n    setImage(undefined);\n  }\n\n  function setRating(v: number | null) {\n    if (studio.id) {\n      updateStudio({\n        variables: {\n          input: {\n            id: studio.id,\n            rating100: v,\n          },\n        },\n      });\n    }\n  }\n\n  const headerClassName = cx(\"detail-header\", {\n    edit: isEditing,\n    collapsed,\n    \"full-width\": !collapsed && !compactExpandedDetails,\n  });\n\n  return (\n    <div id=\"studio-page\" className=\"row\">\n      <Helmet>\n        <title>{studio.name ?? intl.formatMessage({ id: \"studio\" })}</title>\n      </Helmet>\n\n      <div className={headerClassName}>\n        <BackgroundImage\n          imagePath={studio.image_path ?? undefined}\n          show={enableBackgroundImage && !isEditing}\n        />\n        <div className=\"detail-container\">\n          <HeaderImage encodingImage={encodingImage}>\n            {studioImage && (\n              <DetailImage\n                className=\"logo\"\n                alt={studio.name}\n                src={studioImage}\n              />\n            )}\n          </HeaderImage>\n          <div className=\"row\">\n            <div className=\"studio-head col\">\n              <DetailTitle name={studio.name ?? \"\"} classNamePrefix=\"studio\">\n                {!isEditing && (\n                  <ExpandCollapseButton\n                    collapsed={collapsed}\n                    setCollapsed={(v) => setCollapsed(v)}\n                  />\n                )}\n                <span className=\"name-icons\">\n                  <FavoriteIcon\n                    favorite={studio.favorite}\n                    onToggleFavorite={(v) => setFavorite(v)}\n                  />\n                  <OrganizedButton\n                    loading={organizedLoading}\n                    organized={studio.organized}\n                    onClick={onOrganizedClick}\n                  />\n                  <ExternalLinkButtons urls={studio.urls} />\n                </span>\n              </DetailTitle>\n\n              <AliasList aliases={studio.aliases} />\n              <div className=\"quality-group\">\n                <RatingSystem\n                  value={studio.rating100}\n                  onSetRating={(value) => setRating(value)}\n                  clickToRate\n                  withoutContext\n                />\n                {!!studio.o_counter && (\n                  <OCounterButton value={studio.o_counter} />\n                )}\n              </div>\n              {!isEditing && (\n                <StudioDetailsPanel\n                  studio={studio}\n                  collapsed={collapsed}\n                  fullWidth={!collapsed && !compactExpandedDetails}\n                />\n              )}\n              {isEditing ? (\n                <StudioEditPanel\n                  studio={studio}\n                  onSubmit={onSave}\n                  onCancel={() => toggleEditing()}\n                  onDelete={onDelete}\n                  setImage={setImage}\n                  setEncodingImage={setEncodingImage}\n                />\n              ) : (\n                <DetailsEditNavbar\n                  objectName={\n                    studio.name ?? intl.formatMessage({ id: \"studio\" })\n                  }\n                  isNew={false}\n                  isEditing={isEditing}\n                  onToggleEdit={() => toggleEditing()}\n                  onSave={() => {}}\n                  onImageChange={() => {}}\n                  onClearImage={() => {}}\n                  onAutoTag={onAutoTag}\n                  autoTagDisabled={studio.ignore_auto_tag}\n                  onDelete={onDelete}\n                />\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {!isEditing && loadStickyHeader && (\n        <CompressedStudioDetailsPanel studio={studio} />\n      )}\n\n      <div className=\"detail-body\">\n        <div className=\"studio-body\">\n          <div className=\"studio-tabs\">\n            {!isEditing && (\n              <StudioTabs\n                studio={studio}\n                tabKey={tabKey}\n                abbreviateCounter={abbreviateCounter}\n                showAllCounts={showAllCounts}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n      {renderDeleteAlert()}\n    </div>\n  );\n};\n\nconst StudioLoader: React.FC<RouteComponentProps<IStudioParams>> = ({\n  location,\n  match,\n}) => {\n  const { id, tab } = match.params;\n  const { data, loading, error } = useFindStudio(id);\n\n  useScrollToTopOnMount();\n\n  if (loading) return <LoadingIndicator />;\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findStudio)\n    return <ErrorMessage error={`No studio found with id ${id}.`} />;\n\n  if (tab && !isTabKey(tab)) {\n    return (\n      <Redirect\n        to={{\n          ...location,\n          pathname: `/studios/${id}`,\n        }}\n      />\n    );\n  }\n\n  return (\n    <StudioPage studio={data.findStudio} tabKey={tab as TabKey | undefined} />\n  );\n};\n\nexport default StudioLoader;\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ParentStudiosCriterion } from \"src/models/list-filter/criteria/studios\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { FilteredStudioList } from \"../StudioList\";\nimport { View } from \"src/components/List/views\";\n\nfunction useFilterHook(studio: GQL.StudioDataFragment) {\n  return (filter: ListFilterModel) => {\n    const studioValue = { id: studio.id!, label: studio.name! };\n    // if studio is already present, then we modify it, otherwise add\n    let parentStudioCriterion = filter.criteria.find((c) => {\n      return c.criterionOption.type === \"parents\";\n    }) as ParentStudiosCriterion | undefined;\n\n    if (\n      parentStudioCriterion &&\n      (parentStudioCriterion.modifier === GQL.CriterionModifier.IncludesAll ||\n        parentStudioCriterion.modifier === GQL.CriterionModifier.Includes)\n    ) {\n      // add the studio if not present\n      if (\n        !parentStudioCriterion.value.find((p) => {\n          return p.id === studio.id;\n        })\n      ) {\n        parentStudioCriterion.value.push(studioValue);\n      }\n\n      parentStudioCriterion.modifier = GQL.CriterionModifier.IncludesAll;\n    } else {\n      // overwrite\n      parentStudioCriterion = new ParentStudiosCriterion();\n      parentStudioCriterion.value = [studioValue];\n      filter.criteria.push(parentStudioCriterion);\n    }\n\n    return filter;\n  };\n}\n\ninterface IStudioChildrenPanel {\n  active: boolean;\n  studio: GQL.StudioDataFragment;\n}\n\nexport const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({\n  active,\n  studio,\n}) => {\n  const filterHook = useFilterHook(studio);\n\n  return (\n    <FilteredStudioList\n      fromParent\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.StudioChildren}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport { useHistory, useLocation } from \"react-router-dom\";\nimport { useIntl } from \"react-intl\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useStudioCreate } from \"src/core/StashService\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { StudioEditPanel } from \"./StudioEditPanel\";\n\nconst StudioCreate: React.FC = () => {\n  const history = useHistory();\n  const location = useLocation();\n  const Toast = useToast();\n\n  const query = useMemo(() => new URLSearchParams(location.search), [location]);\n  const studio = {\n    name: query.get(\"q\") ?? undefined,\n  };\n\n  const intl = useIntl();\n\n  // Editing studio state\n  const [image, setImage] = useState<string | null>();\n  const [encodingImage, setEncodingImage] = useState<boolean>(false);\n\n  const [createStudio] = useStudioCreate();\n\n  async function onSave(input: GQL.StudioCreateInput, andNew?: boolean) {\n    const result = await createStudio({\n      variables: { input },\n    });\n    if (result.data?.studioCreate?.id) {\n      if (!andNew) {\n        history.push(`/studios/${result.data.studioCreate.id}`);\n      }\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          { entity: intl.formatMessage({ id: \"studio\" }).toLocaleLowerCase() }\n        )\n      );\n    }\n  }\n\n  function renderImage() {\n    if (image) {\n      return <img className=\"logo\" alt=\"\" src={image} />;\n    }\n  }\n\n  return (\n    <div className=\"row\">\n      <div className=\"studio-details col-md-8\">\n        <h2>\n          {intl.formatMessage(\n            { id: \"actions.add_entity\" },\n            { entityType: intl.formatMessage({ id: \"studio\" }) }\n          )}\n        </h2>\n        <div className=\"text-center\">\n          {encodingImage ? (\n            <LoadingIndicator\n              message={intl.formatMessage({ id: \"actions.encoding_image\" })}\n            />\n          ) : (\n            renderImage()\n          )}\n        </div>\n        <StudioEditPanel\n          studio={studio}\n          onSubmit={onSave}\n          onCancel={() => history.push(\"/studios\")}\n          onDelete={() => {}}\n          setImage={setImage}\n          setEncodingImage={setEncodingImage}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default StudioCreate;\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx",
    "content": "import React from \"react\";\nimport { TagLink } from \"src/components/Shared/TagLink\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { DetailItem } from \"src/components/Shared/DetailItem\";\nimport { StashIDPill } from \"src/components/Shared/StashID\";\nimport { PatchComponent } from \"src/patch\";\nimport { CustomFields } from \"src/components/Shared/CustomFields\";\nimport { Link } from \"react-router-dom\";\n\ninterface IStudioDetailsPanel {\n  studio: GQL.StudioDataFragment;\n  collapsed?: boolean;\n  fullWidth?: boolean;\n}\n\nexport const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = PatchComponent(\n  \"StudioDetailsPanel\",\n  ({ studio, fullWidth }) => {\n    function renderTagsField() {\n      if (!studio.tags.length) {\n        return;\n      }\n      return (\n        <ul className=\"pl-0\">\n          {(studio.tags ?? []).map((tag) => (\n            <TagLink key={tag.id} linkType=\"studio\" tag={tag} />\n          ))}\n        </ul>\n      );\n    }\n\n    function renderStashIDs() {\n      if (!studio.stash_ids?.length) {\n        return;\n      }\n\n      return (\n        <ul className=\"pl-0\">\n          {studio.stash_ids.map((stashID) => {\n            return (\n              <li key={stashID.stash_id} className=\"row no-gutters\">\n                <StashIDPill stashID={stashID} linkType=\"studios\" />\n              </li>\n            );\n          })}\n        </ul>\n      );\n    }\n\n    function renderURLs() {\n      if (!studio.urls?.length) {\n        return;\n      }\n\n      return (\n        <ul className=\"pl-0\">\n          {studio.urls.map((url) => (\n            <li key={url}>\n              <a href={url} target=\"_blank\" rel=\"noreferrer\">\n                {url}\n              </a>\n            </li>\n          ))}\n        </ul>\n      );\n    }\n\n    return (\n      <div className=\"detail-group\">\n        <DetailItem id=\"details\" value={studio.details} fullWidth={fullWidth} />\n        <DetailItem id=\"urls\" value={renderURLs()} fullWidth={fullWidth} />\n        <DetailItem\n          id=\"parent_studios\"\n          value={\n            studio.parent_studio?.name ? (\n              <Link to={`/studios/${studio.parent_studio?.id}`}>\n                {studio.parent_studio.name}\n              </Link>\n            ) : (\n              \"\"\n            )\n          }\n          fullWidth={fullWidth}\n        />\n        <DetailItem id=\"tags\" value={renderTagsField()} fullWidth={fullWidth} />\n        <DetailItem\n          id=\"stash_ids\"\n          value={renderStashIDs()}\n          fullWidth={fullWidth}\n        />\n        <CustomFields values={studio.custom_fields} />\n      </div>\n    );\n  }\n);\n\nexport const CompressedStudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({\n  studio,\n}) => {\n  function scrollToTop() {\n    window.scrollTo({ top: 0, behavior: \"smooth\" });\n  }\n\n  return (\n    <div className=\"sticky detail-header\">\n      <div className=\"sticky detail-header-group\">\n        <a className=\"studio-name\" onClick={() => scrollToTop()}>\n          {studio.name}\n        </a>\n        {studio?.parent_studio?.name ? (\n          <>\n            <span className=\"detail-divider\">/</span>\n            <span className=\"studio-parent\">{studio?.parent_studio?.name}</span>\n          </>\n        ) : (\n          \"\"\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport * as yup from \"yup\";\nimport Mousetrap from \"mousetrap\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { DetailsEditNavbar } from \"src/components/Shared/DetailsEditNavbar\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport ImageUtils from \"src/utils/image\";\nimport { addUpdateStashID, getStashIDs } from \"src/utils/stashIds\";\nimport { useFormik } from \"formik\";\nimport { Prompt } from \"react-router-dom\";\nimport isEqual from \"lodash-es/isEqual\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { handleUnsavedChanges } from \"src/utils/navigation\";\nimport { formikUtils } from \"src/utils/form\";\nimport { yupFormikValidate, yupRequiredStringArray } from \"src/utils/yup\";\nimport { Studio, StudioSelect } from \"../StudioSelect\";\nimport { useTagsEdit } from \"src/hooks/tagsEdit\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport StashBoxIDSearchModal from \"src/components/Shared/StashBoxIDSearchModal\";\nimport {\n  CustomFieldsInput,\n  formatCustomFieldInput,\n} from \"src/components/Shared/CustomFields\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\n\ninterface IStudioEditPanel {\n  studio: Partial<GQL.StudioDataFragment>;\n  onSubmit: (studio: GQL.StudioCreateInput, andNew?: boolean) => Promise<void>;\n  onCancel: () => void;\n  onDelete: () => void;\n  setImage: (image?: string | null) => void;\n  setEncodingImage: (loading: boolean) => void;\n}\n\nexport const StudioEditPanel: React.FC<IStudioEditPanel> = ({\n  studio,\n  onSubmit,\n  onCancel,\n  onDelete,\n  setImage,\n  setEncodingImage,\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const { configuration: stashConfig } = useConfigurationContext();\n\n  const isNew = studio.id === undefined;\n\n  // Editing state\n  const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false);\n\n  // Network state\n  const [isLoading, setIsLoading] = useState(false);\n\n  const [parentStudio, setParentStudio] = useState<Studio | null>(null);\n\n  const schema = yup.object({\n    name: yup.string().required(),\n    urls: yup.array(yup.string().required()).defined(),\n    details: yup.string().ensure(),\n    parent_id: yup.string().required().nullable(),\n    aliases: yupRequiredStringArray(intl).defined(),\n    tag_ids: yup.array(yup.string().required()).defined(),\n    ignore_auto_tag: yup.boolean().defined(),\n    stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),\n    image: yup.string().nullable().optional(),\n    custom_fields: yup.object().required().defined(),\n  });\n\n  const initialValues = {\n    id: studio.id,\n    name: studio.name ?? \"\",\n    urls: studio.urls ?? [],\n    details: studio.details ?? \"\",\n    parent_id: studio.parent_studio?.id ?? null,\n    aliases: studio.aliases ?? [],\n    tag_ids: (studio.tags ?? []).map((t) => t.id),\n    ignore_auto_tag: studio.ignore_auto_tag ?? false,\n    stash_ids: getStashIDs(studio.stash_ids),\n    custom_fields: cloneDeep(studio.custom_fields ?? {}),\n  };\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const [customFieldsError, setCustomFieldsError] = useState<string>();\n\n  function submit(values: InputValues) {\n    const input = {\n      ...schema.cast(values),\n      custom_fields: formatCustomFieldInput(isNew, values.custom_fields),\n    };\n    onSave(input);\n  }\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: submit,\n  });\n\n  const { tagsControl } = useTagsEdit(studio.tags, (ids) =>\n    formik.setFieldValue(\"tag_ids\", ids)\n  );\n\n  function onSetParentStudio(item: Studio | null) {\n    setParentStudio(item);\n    formik.setFieldValue(\"parent_id\", item ? item.id : null);\n  }\n\n  const encodingImage = ImageUtils.usePasteImage((imageData) =>\n    formik.setFieldValue(\"image\", imageData)\n  );\n\n  useEffect(() => {\n    setParentStudio(\n      studio.parent_studio\n        ? {\n            id: studio.parent_studio.id,\n            name: studio.parent_studio.name,\n            aliases: [],\n          }\n        : null\n    );\n  }, [studio.parent_studio]);\n\n  useEffect(() => {\n    setImage(formik.values.image);\n  }, [formik.values.image, setImage]);\n\n  useEffect(() => {\n    setEncodingImage(encodingImage);\n  }, [setEncodingImage, encodingImage]);\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"s s\", () => {\n      if (formik.dirty) {\n        formik.submitForm();\n      }\n    });\n\n    return () => {\n      Mousetrap.unbind(\"s s\");\n    };\n  });\n\n  async function onSave(input: InputValues, andNew?: boolean) {\n    setIsLoading(true);\n    try {\n      await onSubmit(input, andNew);\n      formik.resetForm();\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsLoading(false);\n  }\n\n  async function onSaveAndNewClick() {\n    const input = {\n      ...schema.cast(formik.values),\n      custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),\n    };\n    onSave(input, true);\n  }\n\n  function onImageLoad(imageData: string | null) {\n    formik.setFieldValue(\"image\", imageData);\n  }\n\n  function onImageChange(event: React.FormEvent<HTMLInputElement>) {\n    ImageUtils.onImageChange(event, onImageLoad);\n  }\n\n  function onStashIDSelected(item?: GQL.StashIdInput) {\n    if (!item) return;\n    formik.setFieldValue(\n      \"stash_ids\",\n      addUpdateStashID(formik.values.stash_ids, item)\n    );\n  }\n\n  const {\n    renderField,\n    renderInputField,\n    renderStringListField,\n    renderStashIDsField,\n  } = formikUtils(intl, formik);\n\n  function renderParentStudioField() {\n    const title = intl.formatMessage({ id: \"parent_studio\" });\n    const control = (\n      <StudioSelect\n        onSelect={(items) =>\n          onSetParentStudio(items.length > 0 ? items[0] : null)\n        }\n        values={parentStudio ? [parentStudio] : []}\n      />\n    );\n\n    return renderField(\"parent_id\", title, control);\n  }\n\n  function renderTagsField() {\n    const title = intl.formatMessage({ id: \"tags\" });\n    return renderField(\"tag_ids\", title, tagsControl());\n  }\n\n  if (isLoading) return <LoadingIndicator />;\n\n  return (\n    <>\n      {isStashIDSearchOpen && (\n        <StashBoxIDSearchModal\n          entityType=\"studio\"\n          stashBoxes={stashConfig?.general.stashBoxes ?? []}\n          excludedStashBoxEndpoints={formik.values.stash_ids.map(\n            (s) => s.endpoint\n          )}\n          onSelectItem={(item) => {\n            onStashIDSelected(item);\n            setIsStashIDSearchOpen(false);\n          }}\n          initialQuery={studio.name ?? \"\"}\n        />\n      )}\n\n      <Prompt\n        when={formik.dirty}\n        message={(location, action) => {\n          // Check if it's a redirect after studio creation\n          if (action === \"PUSH\" && location.pathname.startsWith(\"/studios/\"))\n            return true;\n\n          return handleUnsavedChanges(intl, \"studios\", studio.id)(location);\n        }}\n      />\n\n      <Form noValidate onSubmit={formik.handleSubmit} id=\"studio-edit\">\n        {renderInputField(\"name\")}\n        {renderStringListField(\"aliases\")}\n        {renderStringListField(\"urls\")}\n        {renderInputField(\"details\", \"textarea\")}\n        {renderParentStudioField()}\n        {renderTagsField()}\n        {renderStashIDsField(\n          \"stash_ids\",\n          \"studios\",\n          \"stash_ids\",\n          undefined,\n          <Button\n            variant=\"success\"\n            className=\"mr-2 py-0\"\n            onClick={() => setIsStashIDSearchOpen(true)}\n            disabled={!stashConfig?.general.stashBoxes?.length}\n            title={intl.formatMessage({ id: \"actions.add_stash_id\" })}\n          >\n            <Icon icon={faPlus} />\n          </Button>\n        )}\n\n        <CustomFieldsInput\n          values={formik.values.custom_fields}\n          onChange={(v) => formik.setFieldValue(\"custom_fields\", v)}\n          error={customFieldsError}\n          setError={(e) => setCustomFieldsError(e)}\n        />\n\n        <hr />\n        {renderInputField(\"ignore_auto_tag\", \"checkbox\")}\n      </Form>\n\n      <DetailsEditNavbar\n        objectName={studio?.name ?? intl.formatMessage({ id: \"studio\" })}\n        classNames=\"col-xl-9 mt-3\"\n        isNew={isNew}\n        isEditing\n        onToggleEdit={onCancel}\n        onSave={formik.handleSubmit}\n        onSaveAndNew={isNew ? onSaveAndNewClick : undefined}\n        saveDisabled={\n          (!isNew && !formik.dirty) ||\n          !isEqual(formik.errors, {}) ||\n          customFieldsError !== undefined\n        }\n        onImageChange={onImageChange}\n        onImageChangeURL={onImageLoad}\n        onClearImage={() => onImageLoad(null)}\n        onDelete={onDelete}\n        acceptSVG\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredGalleryList } from \"src/components/Galleries/GalleryList\";\nimport { useStudioFilterHook } from \"src/core/studios\";\nimport { View } from \"src/components/List/views\";\n\ninterface IStudioGalleriesPanel {\n  active: boolean;\n  studio: GQL.StudioDataFragment;\n  showChildStudioContent?: boolean;\n}\n\nexport const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({\n  active,\n  studio,\n  showChildStudioContent,\n}) => {\n  const filterHook = useStudioFilterHook(studio, showChildStudioContent);\n  return (\n    <FilteredGalleryList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.StudioGalleries}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredGroupList } from \"src/components/Groups/GroupList\";\nimport { useStudioFilterHook } from \"src/core/studios\";\nimport { View } from \"src/components/List/views\";\n\ninterface IStudioGroupsPanel {\n  active: boolean;\n  studio: GQL.StudioDataFragment;\n  showChildStudioContent?: boolean;\n}\n\nexport const StudioGroupsPanel: React.FC<IStudioGroupsPanel> = ({\n  active,\n  studio,\n  showChildStudioContent,\n}) => {\n  const filterHook = useStudioFilterHook(studio, showChildStudioContent);\n  return (\n    <FilteredGroupList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.StudioGroups}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useStudioFilterHook } from \"src/core/studios\";\nimport { FilteredImageList } from \"src/components/Images/ImageList\";\nimport { View } from \"src/components/List/views\";\n\ninterface IStudioImagesPanel {\n  active: boolean;\n  studio: GQL.StudioDataFragment;\n  showChildStudioContent?: boolean;\n}\n\nexport const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({\n  active,\n  studio,\n  showChildStudioContent,\n}) => {\n  const filterHook = useStudioFilterHook(studio, showChildStudioContent);\n  return (\n    <FilteredImageList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.StudioImages}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useStudioFilterHook } from \"src/core/studios\";\nimport { FilteredPerformerList } from \"src/components/Performers/PerformerList\";\nimport { StudiosCriterion } from \"src/models/list-filter/criteria/studios\";\nimport { View } from \"src/components/List/views\";\n\ninterface IStudioPerformersPanel {\n  active: boolean;\n  studio: GQL.StudioDataFragment;\n  showChildStudioContent?: boolean;\n}\n\nexport const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({\n  active,\n  studio,\n  showChildStudioContent,\n}) => {\n  const studioCriterion = new StudiosCriterion();\n  studioCriterion.value = {\n    items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }],\n    excluded: [],\n    depth: 0,\n  };\n\n  const extraCriteria = {\n    scenes: [studioCriterion],\n    images: [studioCriterion],\n    galleries: [studioCriterion],\n    groups: [studioCriterion],\n  };\n\n  const filterHook = useStudioFilterHook(studio, showChildStudioContent);\n\n  return (\n    <FilteredPerformerList\n      filterHook={filterHook}\n      extraCriteria={extraCriteria}\n      alterQuery={active}\n      view={View.StudioPerformers}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredSceneList } from \"src/components/Scenes/SceneList\";\nimport { useStudioFilterHook } from \"src/core/studios\";\nimport { View } from \"src/components/List/views\";\n\ninterface IStudioScenesPanel {\n  active: boolean;\n  studio: GQL.StudioDataFragment;\n  showChildStudioContent?: boolean;\n}\n\nexport const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({\n  active,\n  studio,\n  showChildStudioContent,\n}) => {\n  const filterHook = useStudioFilterHook(studio, showChildStudioContent);\n  return (\n    <FilteredSceneList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.StudioScenes}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioList.tsx",
    "content": "import React, { useCallback, useEffect } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport cloneDeep from \"lodash-es/cloneDeep\";\nimport { useHistory } from \"react-router-dom\";\nimport Mousetrap from \"mousetrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindStudios,\n  useFindStudios,\n  useStudiosDestroy,\n} from \"src/core/StashService\";\nimport { useFilteredItemList } from \"../List/ItemList\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { ExportDialog } from \"../Shared/ExportDialog\";\nimport { DeleteEntityDialog } from \"../Shared/DeleteEntityDialog\";\nimport { StudioTagger } from \"../Tagger/studios/StudioTagger\";\nimport { StudioCardGrid } from \"./StudioCardGrid\";\nimport { View } from \"../List/views\";\nimport { EditStudiosDialog } from \"./EditStudiosDialog\";\nimport {\n  FilteredListToolbar,\n  IItemListOperation,\n} from \"../List/FilteredListToolbar\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport { useCloseEditDelete, useFilterOperations } from \"../List/util\";\nimport { ListOperations } from \"../List/ListOperationButtons\";\nimport {\n  Sidebar,\n  SidebarPane,\n  SidebarPaneContent,\n  SidebarStateContext,\n  useSidebarState,\n} from \"../Shared/Sidebar\";\nimport useFocus from \"src/utils/focus\";\nimport {\n  FilteredSidebarHeader,\n  useFilteredSidebarKeybinds,\n} from \"../List/Filters/FilterSidebar\";\nimport { FilterTags } from \"../List/FilterTags\";\nimport { Pagination, PaginationIndex } from \"../List/Pagination\";\nimport { LoadedContent } from \"../List/PagedList\";\nimport { SidebarTagsFilter } from \"../List/Filters/TagsFilter\";\nimport { SidebarRatingFilter } from \"../List/Filters/RatingFilter\";\nimport { SidebarBooleanFilter } from \"../List/Filters/BooleanFilter\";\nimport { FavoriteStudioCriterionOption } from \"src/models/list-filter/criteria/favorite\";\nimport { Button } from \"react-bootstrap\";\nimport cx from \"classnames\";\n\nconst StudioList: React.FC<{\n  studios: GQL.StudioDataFragment[];\n  filter: ListFilterModel;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n  fromParent?: boolean;\n}> = PatchComponent(\n  \"StudioList\",\n  ({ studios, filter, selectedIds, onSelectChange, fromParent }) => {\n    if (studios.length === 0 && filter.displayMode !== DisplayMode.Tagger) {\n      return null;\n    }\n\n    if (filter.displayMode === DisplayMode.Grid) {\n      return (\n        <StudioCardGrid\n          studios={studios}\n          zoomIndex={filter.zoomIndex}\n          fromParent={fromParent}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.List) {\n      return <h1>TODO</h1>;\n    }\n    if (filter.displayMode === DisplayMode.Wall) {\n      return <h1>TODO</h1>;\n    }\n    if (filter.displayMode === DisplayMode.Tagger) {\n      return <StudioTagger studios={studios} />;\n    }\n\n    return null;\n  }\n);\n\nconst StudioFilterSidebarSections = PatchContainerComponent(\n  \"FilteredStudioList.SidebarSections\"\n);\n\nconst SidebarContent: React.FC<{\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  sidebarOpen: boolean;\n  onClose?: () => void;\n  showEditFilter: (editingCriterion?: string) => void;\n  count?: number;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  filter,\n  setFilter,\n  filterHook,\n  view,\n  showEditFilter,\n  sidebarOpen,\n  onClose,\n  count,\n  focus,\n}) => {\n  const showResultsId =\n    count !== undefined ? \"actions.show_count_results\" : \"actions.show_results\";\n\n  return (\n    <>\n      <FilteredSidebarHeader\n        sidebarOpen={sidebarOpen}\n        showEditFilter={showEditFilter}\n        filter={filter}\n        setFilter={setFilter}\n        view={view}\n        focus={focus}\n      />\n\n      <StudioFilterSidebarSections>\n        <SidebarTagsFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        />\n        <SidebarRatingFilter filter={filter} setFilter={setFilter} />\n        <SidebarBooleanFilter\n          title={<FormattedMessage id=\"favourite\" />}\n          filter={filter}\n          setFilter={setFilter}\n          option={FavoriteStudioCriterionOption}\n          sectionID=\"favourite\"\n        />\n      </StudioFilterSidebarSections>\n\n      <div className=\"sidebar-footer\">\n        <Button className=\"sidebar-close-button\" onClick={onClose}>\n          <FormattedMessage id={showResultsId} values={{ count }} />\n        </Button>\n      </div>\n    </>\n  );\n};\n\ninterface IStudioList {\n  fromParent?: boolean;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  alterQuery?: boolean;\n  extraOperations?: IItemListOperation<GQL.FindStudiosQueryResult>[];\n}\n\nfunction useViewRandom(filter: ListFilterModel, count: number) {\n  const history = useHistory();\n\n  const viewRandom = useCallback(async () => {\n    // query for a random studio\n    if (count === 0) {\n      return;\n    }\n\n    const index = Math.floor(Math.random() * count);\n    const filterCopy = cloneDeep(filter);\n    filterCopy.itemsPerPage = 1;\n    filterCopy.currentPage = index + 1;\n    const singleResult = await queryFindStudios(filterCopy);\n    if (singleResult.data.findStudios.studios.length === 1) {\n      const { id } = singleResult.data.findStudios.studios[0];\n      // navigate to the studio page\n      history.push(`/studios/${id}`);\n    }\n  }, [history, filter, count]);\n\n  return viewRandom;\n}\n\nfunction useAddKeybinds(filter: ListFilterModel, count: number) {\n  const viewRandom = useViewRandom(filter, count);\n\n  useEffect(() => {\n    Mousetrap.bind(\"p r\", () => {\n      viewRandom();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"p r\");\n    };\n  }, [viewRandom]);\n}\n\nexport const FilteredStudioList = PatchComponent(\n  \"FilteredStudioList\",\n  (props: IStudioList) => {\n    const intl = useIntl();\n\n    const searchFocus = useFocus();\n\n    const { filterHook, view, alterQuery, extraOperations = [] } = props;\n\n    // States\n    const {\n      showSidebar,\n      setShowSidebar,\n      sectionOpen,\n      setSectionOpen,\n      loading: sidebarStateLoading,\n    } = useSidebarState(view);\n\n    const { filterState, queryResult, modalState, listSelect, showEditFilter } =\n      useFilteredItemList({\n        filterStateProps: {\n          filterMode: GQL.FilterMode.Studios,\n          view,\n          useURL: alterQuery,\n        },\n        queryResultProps: {\n          useResult: useFindStudios,\n          getCount: (r) => r.data?.findStudios.count ?? 0,\n          getItems: (r) => r.data?.findStudios.studios ?? [],\n          filterHook,\n        },\n      });\n\n    const { filter, setFilter } = filterState;\n\n    const { effectiveFilter, result, cachedResult, items, totalCount } =\n      queryResult;\n\n    const {\n      selectedIds,\n      selectedItems,\n      onSelectChange,\n      onSelectAll,\n      onSelectNone,\n      onInvertSelection,\n      hasSelection,\n    } = listSelect;\n\n    const { modal, showModal, closeModal } = modalState;\n\n    // Utility hooks\n    const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({\n      filter,\n      setFilter,\n    });\n\n    useAddKeybinds(effectiveFilter, totalCount);\n    useFilteredSidebarKeybinds({\n      showSidebar,\n      setShowSidebar,\n    });\n\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => {\n        if (hasSelection) {\n          onEdit?.();\n        }\n      });\n\n      Mousetrap.bind(\"d d\", () => {\n        if (hasSelection) {\n          onDelete?.();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"d d\");\n      };\n    });\n\n    const onCloseEditDelete = useCloseEditDelete({\n      closeModal,\n      onSelectNone,\n      result,\n    });\n\n    const viewRandom = useViewRandom(effectiveFilter, totalCount);\n\n    function onExport(all: boolean) {\n      showModal(\n        <ExportDialog\n          exportInput={{\n            studios: {\n              ids: Array.from(selectedIds.values()),\n              all: all,\n            },\n          }}\n          onClose={() => closeModal()}\n        />\n      );\n    }\n\n    function onEdit() {\n      showModal(\n        <EditStudiosDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n        />\n      );\n    }\n\n    function onDelete() {\n      showModal(\n        <DeleteEntityDialog\n          selected={selectedItems}\n          onClose={onCloseEditDelete}\n          singularEntity={intl.formatMessage({ id: \"studio\" })}\n          pluralEntity={intl.formatMessage({ id: \"studios\" })}\n          destroyMutation={useStudiosDestroy}\n        />\n      );\n    }\n\n    const convertedExtraOperations = extraOperations.map((op) => ({\n      text: op.text,\n      onClick: () => op.onClick(result, filter, selectedIds),\n      isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true,\n    }));\n\n    const otherOperations = [\n      ...convertedExtraOperations,\n      {\n        text: intl.formatMessage({ id: \"actions.select_all\" }),\n        onClick: () => onSelectAll(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_none\" }),\n        onClick: () => onSelectNone(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.invert_selection\" }),\n        onClick: () => onInvertSelection(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.view_random\" }),\n        onClick: viewRandom,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export\" }),\n        onClick: () => onExport(false),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export_all\" }),\n        onClick: () => onExport(true),\n      },\n    ];\n\n    // render\n    if (sidebarStateLoading) return null;\n\n    const operations = (\n      <ListOperations\n        items={items.length}\n        hasSelection={hasSelection}\n        operations={otherOperations}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        operationsMenuClassName=\"studio-list-operations-dropdown\"\n      />\n    );\n\n    return (\n      <div\n        className={cx(\"item-list-container studio-list\", {\n          \"hide-sidebar\": !showSidebar,\n        })}\n      >\n        {modal}\n\n        <SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>\n          <SidebarPane hideSidebar={!showSidebar}>\n            <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>\n              <SidebarContent\n                filter={filter}\n                setFilter={setFilter}\n                filterHook={filterHook}\n                showEditFilter={showEditFilter}\n                view={view}\n                sidebarOpen={showSidebar}\n                onClose={() => setShowSidebar(false)}\n                count={cachedResult.loading ? undefined : totalCount}\n                focus={searchFocus}\n              />\n            </Sidebar>\n            <SidebarPaneContent\n              onSidebarToggle={() => setShowSidebar(!showSidebar)}\n            >\n              <FilteredListToolbar\n                filter={filter}\n                listSelect={listSelect}\n                setFilter={setFilter}\n                showEditFilter={showEditFilter}\n                onDelete={onDelete}\n                onEdit={onEdit}\n                operationComponent={operations}\n                view={view}\n                zoomable\n              />\n\n              <FilterTags\n                criteria={filter.criteria}\n                onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}\n                onRemoveCriterion={removeCriterion}\n                onRemoveAll={clearAllCriteria}\n              />\n\n              <div className=\"pagination-index-container\">\n                <Pagination\n                  currentPage={filter.currentPage}\n                  itemsPerPage={filter.itemsPerPage}\n                  totalItems={totalCount}\n                  onChangePage={(page) => setFilter(filter.changePage(page))}\n                />\n                <PaginationIndex\n                  loading={cachedResult.loading}\n                  itemsPerPage={filter.itemsPerPage}\n                  currentPage={filter.currentPage}\n                  totalItems={totalCount}\n                />\n              </div>\n\n              <LoadedContent loading={result.loading} error={result.error}>\n                <StudioList\n                  filter={effectiveFilter}\n                  studios={items}\n                  selectedIds={selectedIds}\n                  onSelectChange={onSelectChange}\n                />\n              </LoadedContent>\n\n              {totalCount > filter.itemsPerPage && (\n                <div className=\"pagination-footer-container\">\n                  <div className=\"pagination-footer\">\n                    <Pagination\n                      itemsPerPage={filter.itemsPerPage}\n                      currentPage={filter.currentPage}\n                      totalItems={totalCount}\n                      onChangePage={setPage}\n                      pagePopupPlacement=\"top\"\n                    />\n                  </div>\n                </div>\n              )}\n            </SidebarPaneContent>\n          </SidebarPane>\n        </SidebarStateContext.Provider>\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx",
    "content": "import React from \"react\";\nimport { useFindStudios } from \"src/core/StashService\";\nimport { StudioCard } from \"./StudioCard\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { PatchComponent } from \"src/patch\";\nimport { FilteredRecommendationRow } from \"../FrontPage/FilteredRecommendationRow\";\n\ninterface IProps {\n  isTouch: boolean;\n  filter: ListFilterModel;\n  header: string;\n}\n\nexport const StudioRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"StudioRecommendationRow\",\n  (props) => {\n    const result = useFindStudios(props.filter);\n    const count = result.data?.findStudios.count ?? 0;\n\n    return (\n      <FilteredRecommendationRow\n        className=\"studio-recommendations\"\n        heading={props.header}\n        url={`/studios?${props.filter.makeQueryParameters()}`}\n        count={count}\n        loading={result.loading}\n        isTouch={props.isTouch}\n        filter={props.filter}\n      >\n        {result.loading\n          ? [...Array(props.filter.itemsPerPage)].map((i) => (\n              <div\n                key={`_${i}`}\n                className=\"studio-skeleton skeleton-card\"\n              ></div>\n            ))\n          : result.data?.findStudios.studios.map((s) => (\n              <StudioCard key={s.id} studio={s} hideParent={true} />\n            ))}\n      </FilteredRecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/StudioSelect.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport {\n  OptionProps,\n  components as reactSelectComponents,\n  MultiValueGenericProps,\n  SingleValueProps,\n} from \"react-select\";\nimport cx from \"classnames\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useStudioCreate,\n  queryFindStudiosByIDForSelect,\n  queryFindStudiosForSelect,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useIntl } from \"react-intl\";\nimport { defaultMaxOptionsShown, IUIConfig } from \"src/core/config\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  FilterSelectComponent,\n  IFilterIDProps,\n  IFilterProps,\n  IFilterValueProps,\n  Option as SelectOption,\n  toOption,\n} from \"../Shared/FilterSelect\";\nimport { useCompare } from \"src/hooks/state\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { PatchComponent, PatchFunction } from \"src/patch\";\nimport { isUUID } from \"src/utils/stashIds\";\nimport { filterByStashID } from \"src/models/list-filter/utils\";\n\nexport type SelectObject = {\n  id: string;\n  name?: string | null;\n  title?: string | null;\n};\n\nexport type Studio = Pick<GQL.Studio, \"id\" | \"name\" | \"aliases\" | \"image_path\">;\ntype Option = SelectOption<Studio>;\n\ntype FindStudiosResult = Awaited<\n  ReturnType<typeof queryFindStudiosForSelect>\n>[\"data\"][\"findStudios\"][\"studios\"];\n\nfunction sortStudiosByRelevance(input: string, studios: FindStudiosResult) {\n  return sortByRelevance(\n    input,\n    studios,\n    (s) => s.name,\n    (s) => s.aliases\n  );\n}\n\nconst studioSelectSort = PatchFunction(\n  \"StudioSelect.sort\",\n  sortStudiosByRelevance\n);\n\nconst _StudioSelect: React.FC<\n  IFilterProps &\n    IFilterValueProps<Studio> & {\n      hoverPlacement?: Placement;\n      excludeIds?: string[];\n    }\n> = (props) => {\n  const [createStudio] = useStudioCreate();\n\n  const { configuration } = useConfigurationContext();\n  const intl = useIntl();\n  const maxOptionsShown =\n    (configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown;\n  const defaultCreatable =\n    !configuration?.interface.disableDropdownCreate.studio;\n\n  const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);\n\n  function filterExcluded(studio: Studio) {\n    // HACK - we should probably exclude these in the backend query, but\n    // this will do in the short-term\n    return !exclude.includes(studio.id.toString());\n  }\n\n  async function loadStudios(input: string): Promise<Option[]> {\n    const filter = new ListFilterModel(GQL.FilterMode.Studios);\n    filter.currentPage = 1;\n    filter.itemsPerPage = maxOptionsShown;\n    filter.sortBy = \"name\";\n    filter.sortDirection = GQL.SortDirectionEnum.Asc;\n\n    if (isUUID(input)) {\n      filterByStashID(filter, input);\n\n      const query = await queryFindStudiosForSelect(filter);\n      const matches = query.data.findStudios.studios.filter(filterExcluded);\n\n      if (matches.length > 0) {\n        // Matches found, return them immediately.\n        return matches.map(toOption);\n      }\n\n      // If no stash_id matches found, continue with standard name/alias search.\n      filter.criteria = []; // Clear stash_id criterion to search by name/alias below.\n    }\n\n    filter.searchTerm = input;\n\n    const query = await queryFindStudiosForSelect(filter);\n    const ret = query.data.findStudios.studios.filter(filterExcluded);\n\n    return studioSelectSort(input, ret).map(toOption);\n  }\n\n  const StudioOption: React.FC<OptionProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    let { name } = object;\n\n    // if name does not match the input value but an alias does, show the alias\n    const { inputValue } = optionProps.selectProps;\n    let alias: string | undefined = \"\";\n    if (!name.toLowerCase().includes(inputValue.toLowerCase())) {\n      alias = object.aliases?.find((a) =>\n        a.toLowerCase().includes(inputValue.toLowerCase())\n      );\n    }\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <span className=\"react-select-image-option\">\n          <span>{name}</span>\n          {alias && <span className=\"alias\">&nbsp;({alias})</span>}\n        </span>\n      ),\n    };\n\n    return <reactSelectComponents.Option {...thisOptionProps} />;\n  };\n\n  const StudioMultiValueLabel: React.FC<\n    MultiValueGenericProps<Option, boolean>\n  > = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: object.name,\n    };\n\n    return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;\n  };\n\n  const StudioValueLabel: React.FC<SingleValueProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: <>{object.name}</>,\n    };\n\n    return <reactSelectComponents.SingleValue {...thisOptionProps} />;\n  };\n\n  const onCreate = async (name: string) => {\n    const result = await createStudio({\n      variables: { input: { name } },\n    });\n    return {\n      value: result.data!.studioCreate!.id,\n      item: result.data!.studioCreate!,\n      message: \"Created studio\",\n    };\n  };\n\n  const getNamedObject = (id: string, name: string) => {\n    return {\n      id,\n      name,\n      aliases: [],\n    };\n  };\n\n  const isValidNewOption = (inputValue: string, options: Studio[]) => {\n    if (!inputValue) {\n      return false;\n    }\n\n    if (\n      options.some((o) => {\n        return (\n          o.name.toLowerCase() === inputValue.toLowerCase() ||\n          o.aliases?.some((a) => a.toLowerCase() === inputValue.toLowerCase())\n        );\n      })\n    ) {\n      return false;\n    }\n\n    return true;\n  };\n\n  return (\n    <FilterSelectComponent<Studio, boolean>\n      {...props}\n      className={cx(\n        \"studio-select\",\n        {\n          \"studio-select-active\": props.active,\n        },\n        props.className\n      )}\n      loadOptions={loadStudios}\n      getNamedObject={getNamedObject}\n      isValidNewOption={isValidNewOption}\n      components={{\n        Option: StudioOption,\n        MultiValueLabel: StudioMultiValueLabel,\n        SingleValue: StudioValueLabel,\n      }}\n      isMulti={props.isMulti ?? false}\n      creatable={props.creatable ?? defaultCreatable}\n      onCreate={onCreate}\n      placeholder={\n        props.noSelectionString ??\n        intl.formatMessage(\n          { id: \"actions.select_entity\" },\n          {\n            entityType: intl.formatMessage({\n              id: props.isMulti ? \"studios\" : \"studio\",\n            }),\n          }\n        )\n      }\n      closeMenuOnSelect={!props.isMulti}\n    />\n  );\n};\n\nexport const StudioSelect = PatchComponent(\"StudioSelect\", _StudioSelect);\n\nconst _StudioIDSelect: React.FC<IFilterProps & IFilterIDProps<Studio>> = (\n  props\n) => {\n  const { ids, onSelect: onSelectValues } = props;\n\n  const [values, setValues] = useState<Studio[]>([]);\n  const idsChanged = useCompare(ids);\n\n  function onSelect(items: Studio[]) {\n    setValues(items);\n    onSelectValues?.(items);\n  }\n\n  async function loadObjectsByID(idsToLoad: string[]): Promise<Studio[]> {\n    const query = await queryFindStudiosByIDForSelect(idsToLoad);\n    const { studios: loadedStudios } = query.data.findStudios;\n\n    return loadedStudios;\n  }\n\n  useEffect(() => {\n    if (!idsChanged) {\n      return;\n    }\n\n    if (!ids || ids?.length === 0) {\n      setValues([]);\n      return;\n    }\n\n    // load the values if we have ids and they haven't been loaded yet\n    const filteredValues = values.filter((v) => ids.includes(v.id.toString()));\n    if (filteredValues.length === ids.length) {\n      return;\n    }\n\n    const load = async () => {\n      const items = await loadObjectsByID(ids);\n      setValues(items);\n    };\n\n    load();\n  }, [ids, idsChanged, values]);\n\n  return <StudioSelect {...props} values={values} onSelect={onSelect} />;\n};\n\nexport const StudioIDSelect = PatchComponent(\"StudioIDSelect\", _StudioIDSelect);\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/Studios.tsx",
    "content": "import React from \"react\";\nimport { Route, Switch } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport { useTitleProps } from \"src/hooks/title\";\nimport Studio from \"./StudioDetails/Studio\";\nimport StudioCreate from \"./StudioDetails/StudioCreate\";\nimport { FilteredStudioList } from \"./StudioList\";\nimport { View } from \"../List/views\";\n\nconst Studios: React.FC = () => {\n  return <FilteredStudioList view={View.Studios} />;\n};\n\nconst StudioRoutes: React.FC = () => {\n  const titleProps = useTitleProps({ id: \"studios\" });\n  return (\n    <>\n      <Helmet {...titleProps} />\n      <Switch>\n        <Route exact path=\"/studios\" component={Studios} />\n        <Route exact path=\"/studios/new\" component={StudioCreate} />\n        <Route path=\"/studios/:id/:tab?\" component={Studio} />\n      </Switch>\n    </>\n  );\n};\n\nexport default StudioRoutes;\n"
  },
  {
    "path": "ui/v2.5/src/components/Studios/styles.scss",
    "content": ".studio-details {\n  .logo {\n    margin-bottom: 4rem;\n    max-height: 50vh;\n    max-width: 100%;\n  }\n}\n\n.studio-card {\n  button.btn.favorite-button {\n    padding: 0;\n    position: absolute;\n    right: 5px;\n    top: 10px;\n\n    svg.fa-icon {\n      margin-left: 0.4rem;\n      margin-right: 0.4rem;\n    }\n  }\n\n  &:hover button.btn.favorite-button.not-favorite {\n    opacity: 1;\n  }\n}\n\n#studio-page {\n  .studio-head {\n    .name-icons {\n      .not-favorite {\n        color: rgba(191, 204, 214, 0.5);\n      }\n\n      .favorite {\n        color: #ff7373;\n      }\n    }\n  }\n\n  .rating-number .text-input {\n    width: auto;\n  }\n\n  .quality-group {\n    display: inline-flex;\n    margin-top: 0.25rem;\n  }\n\n  // The following min-width declarations prevent\n  // the O-Count from moving around\n  // when hovering over rating stars\n  .rating-stars-precision-full .star-rating-number {\n    min-width: 0.75rem;\n  }\n\n  .rating-stars-precision-half .star-rating-number,\n  .rating-stars-precision-tenth .star-rating-number {\n    min-width: 1.45rem;\n  }\n\n  .rating-stars-precision-quarter .star-rating-number {\n    min-width: 2rem;\n  }\n\n  // the detail element ids are the same as field type name\n  // which don't follow the correct convention\n  /* stylelint-disable selector-class-pattern */\n  .collapsed {\n    .detail-item.stash_ids {\n      display: none;\n    }\n  }\n\n  .detail-item.urls ul {\n    list-style-type: none;\n  }\n  /* stylelint-enable selector-class-pattern */\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/FieldSelector.tsx",
    "content": "import { faCheck, faList, faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState } from \"react\";\nimport { Button, Row, Col } from \"react-bootstrap\";\nimport { useIntl } from \"react-intl\";\n\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { Icon } from \"../Shared/Icon\";\n\ninterface IProps {\n  show: boolean;\n  fields: string[];\n  excludedFields: string[];\n  onSelect: (fields: string[]) => void;\n}\n\nconst FieldSelector: React.FC<IProps> = ({\n  show,\n  fields,\n  excludedFields,\n  onSelect,\n}) => {\n  const intl = useIntl();\n  const [excluded, setExcluded] = useState<Record<string, boolean>>(\n    excludedFields\n      .filter((field) => fields.includes(field))\n      .reduce((dict, field) => ({ ...dict, [field]: true }), {})\n  );\n\n  const toggleField = (field: string) =>\n    setExcluded({\n      ...excluded,\n      [field]: !excluded[field],\n    });\n\n  const renderField = (field: string) => (\n    <Col xs={6} className=\"mb-1\" key={field}>\n      <Button\n        onClick={() => toggleField(field)}\n        variant=\"secondary\"\n        className={excluded[field] ? \"text-muted\" : \"text-success\"}\n      >\n        <Icon icon={excluded[field] ? faTimes : faCheck} />\n      </Button>\n      <span className=\"ml-3\">{intl.formatMessage({ id: field })}</span>\n    </Col>\n  );\n\n  return (\n    <ModalComponent\n      show={show}\n      icon={faList}\n      dialogClassName=\"FieldSelect\"\n      accept={{\n        text: intl.formatMessage({ id: \"actions.save\" }),\n        onClick: () =>\n          onSelect(Object.keys(excluded).filter((f) => excluded[f])),\n      }}\n    >\n      <h4>Select tagged fields</h4>\n      <div className=\"mb-2\">\n        These fields will be tagged by default. Click the button to toggle.\n      </div>\n      <Row>{fields.map((f) => renderField(f))}</Row>\n    </ModalComponent>\n  );\n};\n\nexport default FieldSelector;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/IncludeButton.tsx",
    "content": "import { faCheck, faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport React from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { Icon } from \"../Shared/Icon\";\n\ninterface IIncludeExcludeButton {\n  exclude: boolean;\n  disabled?: boolean;\n  setExclude: (v: boolean) => void;\n}\n\nexport const IncludeExcludeButton: React.FC<IIncludeExcludeButton> = ({\n  exclude,\n  disabled,\n  setExclude,\n}) => (\n  <Button\n    onClick={() => setExclude(!exclude)}\n    disabled={disabled}\n    variant=\"minimal\"\n    className={`${\n      exclude ? \"text-danger\" : \"text-success\"\n    } include-exclude-button`}\n  >\n    <Icon className=\"fa-fw\" icon={exclude ? faTimes : faCheck} />\n  </Button>\n);\n\ninterface IOptionalField {\n  exclude: boolean;\n  title?: string;\n  disabled?: boolean;\n  setExclude: (v: boolean) => void;\n}\n\nexport const OptionalField: React.FC<IOptionalField> = ({\n  exclude,\n  setExclude,\n  children,\n  title,\n}) => {\n  return (\n    <div className={`optional-field ${!exclude ? \"included\" : \"excluded\"}`}>\n      <IncludeExcludeButton exclude={exclude} setExclude={setExclude} />\n      {title && <span className=\"optional-field-title\">{title}</span>}\n      <div className=\"optional-field-content\">{children}</div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/LinkButton.tsx",
    "content": "import React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { faLink } from \"@fortawesome/free-solid-svg-icons\";\n\nimport { OperationButton } from \"../Shared/OperationButton\";\nimport { Icon } from \"../Shared/Icon\";\n\nexport const LinkButton: React.FC<{\n  disabled: boolean;\n  onLink: () => Promise<void>;\n}> = ({ disabled, onLink }) => {\n  const intl = useIntl();\n\n  return (\n    <OperationButton\n      variant=\"secondary\"\n      disabled={disabled}\n      operation={onLink}\n      hideChildrenWhenLoading\n      title={intl.formatMessage({ id: \"component_tagger.verb_link_existing\" })}\n    >\n      <Icon icon={faLink} />\n    </OperationButton>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/PerformerModal.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport cx from \"classnames\";\nimport { IconDefinition } from \"@fortawesome/fontawesome-svg-core\";\n\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { Icon } from \"../Shared/Icon\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { stringToGender } from \"src/utils/gender\";\nimport { getCountryByISO } from \"src/utils/country\";\nimport {\n  faArrowLeft,\n  faArrowRight,\n  faCheck,\n  faTimes,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { ExternalLink } from \"../Shared/ExternalLink\";\nimport { StashIDPill } from \"../Shared/StashID\";\n\ninterface IPerformerModalProps {\n  performer: GQL.ScrapedScenePerformerDataFragment;\n  modalVisible: boolean;\n  closeModal: () => void;\n  onSave: (input: GQL.PerformerCreateInput) => void;\n  excludedPerformerFields?: string[];\n  header: string;\n  icon: IconDefinition;\n  create?: boolean;\n  endpoint?: string;\n}\n\nconst PerformerModal: React.FC<IPerformerModalProps> = ({\n  modalVisible,\n  performer,\n  onSave,\n  closeModal,\n  excludedPerformerFields = [],\n  header,\n  icon,\n  create = false,\n  endpoint,\n}) => {\n  const intl = useIntl();\n\n  const [imageIndex, setImageIndex] = useState(0);\n  const [imageState, setImageState] = useState<\n    \"loading\" | \"error\" | \"loaded\" | \"empty\"\n  >(\"empty\");\n  const [loadDict, setLoadDict] = useState<Record<number, boolean>>({});\n  const [excluded, setExcluded] = useState<Record<string, boolean>>(\n    excludedPerformerFields.reduce(\n      (dict, field) => ({ ...dict, [field]: true }),\n      {}\n    )\n  );\n\n  const images = performer.images ?? [];\n\n  const changeImage = (index: number) => {\n    setImageIndex(index);\n    if (!loadDict[index]) setImageState(\"loading\");\n  };\n  const setPrev = () =>\n    changeImage(imageIndex === 0 ? images.length - 1 : imageIndex - 1);\n  const setNext = () =>\n    changeImage(imageIndex === images.length - 1 ? 0 : imageIndex + 1);\n\n  const handleLoad = (index: number) => {\n    setLoadDict({\n      ...loadDict,\n      [index]: true,\n    });\n    setImageState(\"loaded\");\n  };\n  const handleError = () => setImageState(\"error\");\n\n  const toggleField = (name: string) =>\n    setExcluded({\n      ...excluded,\n      [name]: !excluded[name],\n    });\n\n  function maybeRenderField(\n    name: string,\n    text: string | null | undefined,\n    truncate: boolean = true\n  ) {\n    if (!text) return;\n\n    return (\n      <div className=\"row no-gutters\">\n        <div className=\"col-5 performer-create-modal-field\" key={name}>\n          {!create && (\n            <Button\n              onClick={() => toggleField(name)}\n              variant=\"secondary\"\n              className={excluded[name] ? \"text-muted\" : \"text-success\"}\n            >\n              <Icon icon={excluded[name] ? faTimes : faCheck} />\n            </Button>\n          )}\n          <strong>\n            <FormattedMessage id={name} />:\n          </strong>\n        </div>\n        {truncate ? (\n          <div className=\"col-7 performer-create-modal-value\">\n            <TruncatedText text={text} />\n          </div>\n        ) : (\n          <span className=\"col-7 performer-create-modal-value\">{text}</span>\n        )}\n      </div>\n    );\n  }\n\n  function maybeRenderURLListField(\n    name: string,\n    text: string[] | null | undefined,\n    truncate: boolean = true\n  ) {\n    if (!text) return;\n\n    return (\n      <div className=\"row no-gutters\">\n        <div className=\"col-5 performer-create-modal-field\" key={name}>\n          {!create && (\n            <Button\n              onClick={() => toggleField(name)}\n              variant=\"secondary\"\n              className={excluded[name] ? \"text-muted\" : \"text-success\"}\n            >\n              <Icon icon={excluded[name] ? faTimes : faCheck} />\n            </Button>\n          )}\n          <strong>\n            <FormattedMessage id={name} />:\n          </strong>\n        </div>\n        <div className=\"col-7 performer-create-modal-value\">\n          <ul>\n            {text.map((t, i) => (\n              <li key={i}>\n                <ExternalLink href={t}>\n                  {truncate ? <TruncatedText text={t} /> : t}\n                </ExternalLink>\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n    );\n  }\n\n  function maybeRenderImage() {\n    if (!images.length) return;\n\n    return (\n      <div className=\"col-5 image-selection\">\n        <div className=\"performer-image\">\n          {!create && (\n            <Button\n              onClick={() => toggleField(\"image\")}\n              variant=\"secondary\"\n              className={cx(\n                \"performer-image-exclude\",\n                excluded.image ? \"text-muted\" : \"text-success\"\n              )}\n            >\n              <Icon icon={excluded.image ? faTimes : faCheck} />\n            </Button>\n          )}\n          <img\n            src={images[imageIndex]}\n            className={cx({ \"d-none\": imageState !== \"loaded\" })}\n            alt=\"\"\n            onLoad={() => handleLoad(imageIndex)}\n            onError={handleError}\n          />\n          {imageState === \"loading\" && (\n            <LoadingIndicator message=\"Loading image...\" />\n          )}\n          {imageState === \"error\" && (\n            <div className=\"h-100 d-flex justify-content-center align-items-center\">\n              <b>Error loading image.</b>\n            </div>\n          )}\n        </div>\n        <div className=\"d-flex mt-3\">\n          <Button onClick={setPrev} disabled={images.length === 1}>\n            <Icon icon={faArrowLeft} />\n          </Button>\n          <h5 className=\"flex-grow-1\">\n            Select performer image\n            <br />\n            {imageIndex + 1} of {images.length}\n          </h5>\n          <Button onClick={setNext} disabled={images.length === 1}>\n            <Icon icon={faArrowRight} />\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  function maybeRenderStashBoxLink() {\n    const base = endpoint?.match(/https?:\\/\\/.*?\\//)?.[0];\n    if (!base || !performer.remote_site_id) return;\n\n    return (\n      <StashIDPill\n        linkType=\"performers\"\n        stashID={{ endpoint: endpoint, stash_id: performer.remote_site_id }}\n      />\n    );\n  }\n\n  function onSaveClicked() {\n    if (!performer.name) {\n      throw new Error(\"performer name must set\");\n    }\n\n    const performerData: GQL.PerformerCreateInput & {\n      [index: string]: unknown;\n    } = {\n      name: performer.name ?? \"\",\n      disambiguation: performer.disambiguation ?? \"\",\n      alias_list:\n        performer.aliases?.split(\",\").map((a) => a.trim()) ?? undefined,\n      gender: stringToGender(performer.gender ?? undefined, true),\n      birthdate: performer.birthdate,\n      ethnicity: performer.ethnicity,\n      eye_color: performer.eye_color,\n      country: performer.country,\n      height_cm: Number.parseFloat(performer.height ?? \"\") ?? undefined,\n      measurements: performer.measurements,\n      fake_tits: performer.fake_tits,\n      career_start: performer.career_start,\n      career_end: performer.career_end,\n      tattoos: performer.tattoos,\n      piercings: performer.piercings,\n      urls: performer.urls,\n      image: images.length > imageIndex ? images[imageIndex] : undefined,\n      details: performer.details,\n      death_date: performer.death_date,\n      hair_color: performer.hair_color,\n      weight: Number.parseFloat(performer.weight ?? \"\") ?? undefined,\n    };\n\n    if (Number.isNaN(performerData.weight ?? 0)) {\n      performerData.weight = undefined;\n    }\n\n    if (Number.isNaN(performerData.height ?? 0)) {\n      performerData.height = undefined;\n    }\n\n    if (performer.tags) {\n      performerData.tag_ids = performer.tags\n        .map((t) => t.stored_id)\n        .filter((t) => t) as string[];\n    }\n\n    // stashid handling code\n    const remoteSiteID = performer.remote_site_id;\n    if (remoteSiteID && endpoint) {\n      performerData.stash_ids = [\n        {\n          endpoint,\n          stash_id: remoteSiteID,\n          updated_at: new Date().toISOString(),\n        },\n      ];\n    }\n\n    // handle exclusions\n    Object.keys(performerData).forEach((k) => {\n      if (excluded[k] || !performerData[k]) {\n        performerData[k] = undefined;\n      }\n      // #5565 - special case aliases as the names differ\n      if (k == \"alias_list\" && excluded.aliases) {\n        performerData.alias_list = undefined;\n      }\n    });\n\n    onSave(performerData);\n  }\n\n  return (\n    <ModalComponent\n      show={modalVisible}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.save\" }),\n        onClick: onSaveClicked,\n      }}\n      cancel={{ onClick: () => closeModal(), variant: \"secondary\" }}\n      onHide={() => closeModal()}\n      dialogClassName=\"performer-create-modal\"\n      icon={icon}\n      header={header}\n    >\n      <div className=\"row\">\n        <div className=\"col-7\">\n          {maybeRenderField(\"name\", performer.name)}\n          {maybeRenderField(\"disambiguation\", performer.disambiguation)}\n          {maybeRenderField(\"aliases\", performer.aliases)}\n          {maybeRenderField(\n            \"gender\",\n            performer.gender\n              ? intl.formatMessage({ id: \"gender_types.\" + performer.gender })\n              : \"\"\n          )}\n          {maybeRenderField(\"birthdate\", performer.birthdate)}\n          {maybeRenderField(\"death_date\", performer.death_date)}\n          {maybeRenderField(\"ethnicity\", performer.ethnicity)}\n          {maybeRenderField(\"country\", getCountryByISO(performer.country))}\n          {maybeRenderField(\"hair_color\", performer.hair_color)}\n          {maybeRenderField(\"eye_color\", performer.eye_color)}\n          {maybeRenderField(\"height\", performer.height)}\n          {maybeRenderField(\"weight\", performer.weight)}\n          {maybeRenderField(\"measurements\", performer.measurements)}\n          {performer?.gender !== GQL.GenderEnum.Male &&\n            maybeRenderField(\"fake_tits\", performer.fake_tits)}\n          {maybeRenderField(\"career_start\", performer.career_start?.toString())}\n          {maybeRenderField(\"career_end\", performer.career_end?.toString())}\n          {maybeRenderField(\"tattoos\", performer.tattoos, false)}\n          {maybeRenderField(\"piercings\", performer.piercings, false)}\n          {maybeRenderField(\"weight\", performer.weight, false)}\n          {maybeRenderField(\"details\", performer.details)}\n          {maybeRenderURLListField(\"urls\", performer.urls)}\n          {maybeRenderStashBoxLink()}\n        </div>\n        {maybeRenderImage()}\n      </div>\n    </ModalComponent>\n  );\n};\n\nexport default PerformerModal;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/StashBoxSelector.tsx",
    "content": "import { Form } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { StashBox } from \"src/core/generated-graphql\";\n\ninterface IStashBoxSelectorProps {\n  stashBoxes: StashBox[];\n  selectedEndpoint: string;\n  onEndpointChange: (endpoint: string) => void;\n}\n\nexport const StashBoxSelector: React.FC<IStashBoxSelectorProps> = ({\n  stashBoxes,\n  selectedEndpoint,\n  onEndpointChange,\n}) => {\n  return (\n    <Form.Control\n      as=\"select\"\n      value={selectedEndpoint}\n      className=\"input-control\"\n      disabled={stashBoxes.length < 2}\n      onChange={(e) => onEndpointChange(e.target.value)}\n    >\n      {!stashBoxes.length && (\n        <option>\n          <FormattedMessage id=\"tagger.config.no_instances_found\" />\n        </option>\n      )}\n      {stashBoxes.map((i) => (\n        <option value={i.endpoint} key={i.endpoint}>\n          {i.endpoint}\n        </option>\n      ))}\n    </Form.Control>\n  );\n};\n\nexport const StashBoxSelectorField: React.FC<IStashBoxSelectorProps> = (\n  props\n) => {\n  return (\n    <Form.Group controlId=\"scraper\">\n      <Form.Label>\n        <FormattedMessage id=\"component_tagger.config.source\" />\n      </Form.Label>\n      <div>\n        <StashBoxSelector {...props} />\n      </div>\n    </Form.Group>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/TaggerConfig.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Badge, Button, Card, Collapse, Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport FieldSelector from \"./FieldSelector\";\nimport { Icon } from \"../Shared/Icon\";\nimport { faCog } from \"@fortawesome/free-solid-svg-icons\";\n\ninterface ITaggerConfigProps {\n  show: boolean;\n  excludedFields: string[];\n  onFieldsChange: (fields: string[]) => void;\n  fields: string[];\n  entityName: string;\n  extraConfig?: React.ReactNode;\n}\n\nconst TaggerConfig: React.FC<ITaggerConfigProps> = ({\n  show,\n  excludedFields,\n  onFieldsChange,\n  fields,\n  entityName,\n  extraConfig,\n}) => {\n  const [showExclusionModal, setShowExclusionModal] = useState(false);\n\n  const handleFieldSelect = (selectedFields: string[]) => {\n    onFieldsChange(selectedFields);\n    setShowExclusionModal(false);\n  };\n\n  return (\n    <>\n      <Collapse in={show}>\n        <Card>\n          <div className=\"row\">\n            <h4 className=\"col-12\">\n              <FormattedMessage id=\"configuration\" />\n            </h4>\n            <hr className=\"w-100\" />\n            <div className=\"col-md-6\">\n              {extraConfig}\n              <Form.Group controlId=\"excluded-fields\">\n                <h6>\n                  <FormattedMessage id=\"tagger.config.excluded_fields\" />\n                </h6>\n                <span>\n                  {excludedFields.length > 0 ? (\n                    excludedFields.map((f) => (\n                      <Badge variant=\"secondary\" className=\"tag-item\" key={f}>\n                        <FormattedMessage id={f} />\n                      </Badge>\n                    ))\n                  ) : (\n                    <FormattedMessage id=\"tagger.config.no_fields_are_excluded\" />\n                  )}\n                </span>\n                <Form.Text>\n                  <FormattedMessage\n                    id=\"tagger.config.fields_will_not_be_changed\"\n                    values={{ entity: entityName }}\n                  />\n                </Form.Text>\n                <Button\n                  onClick={() => setShowExclusionModal(true)}\n                  className=\"mt-2\"\n                >\n                  <FormattedMessage id=\"tagger.config.edit_excluded_fields\" />\n                </Button>\n              </Form.Group>\n            </div>\n          </div>\n        </Card>\n      </Collapse>\n      <FieldSelector\n        show={showExclusionModal}\n        fields={fields}\n        onSelect={handleFieldSelect}\n        excludedFields={excludedFields}\n      />\n    </>\n  );\n};\n\nexport default TaggerConfig;\n\nexport const ConfigButton: React.FC<{\n  onClick: () => void;\n  showConfig: boolean;\n}> = ({ onClick, showConfig }) => {\n  const intl = useIntl();\n\n  const showHideConfigId = showConfig\n    ? \"actions.hide_configuration\"\n    : \"actions.show_configuration\";\n\n  return (\n    <Button\n      onClick={onClick}\n      title={intl.formatMessage({ id: showHideConfigId })}\n    >\n      <Icon className=\"fa-fw\" icon={faCog} />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/config.ts",
    "content": "import { useCallback } from \"react\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { initialConfig, ITaggerConfig } from \"./constants\";\nimport { useConfigureUISetting } from \"src/core/StashService\";\n\nexport function useTaggerConfig() {\n  const { configuration: stashConfig } = useConfigurationContext();\n  const [saveUISetting] = useConfigureUISetting();\n\n  const config = stashConfig?.ui.taggerConfig ?? initialConfig;\n\n  const setConfig = useCallback(\n    (c: ITaggerConfig) => {\n      saveUISetting({ variables: { key: \"taggerConfig\", value: c } });\n    },\n    [saveUISetting]\n  );\n\n  return { config, setConfig };\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/constants.ts",
    "content": "import { GenderEnum, ScraperSourceInput } from \"src/core/generated-graphql\";\n\nexport const STASH_BOX_PREFIX = \"stashbox:\";\nexport const SCRAPER_PREFIX = \"scraper:\";\n\nexport interface ITaggerSource {\n  id: string;\n  sourceInput: ScraperSourceInput;\n  displayName: string;\n  supportSceneQuery?: boolean;\n  supportSceneFragment?: boolean;\n}\n\nexport const DEFAULT_BLACKLIST = [\n  \"\\\\sXXX\\\\s\",\n  \"1080p\",\n  \"720p\",\n  \"2160p\",\n  \"KTR\",\n  \"RARBG\",\n  \"\\\\scom\\\\s\",\n  \"\\\\[\",\n  \"\\\\]\",\n];\nexport const DEFAULT_EXCLUDED_PERFORMER_FIELDS = [\"name\"];\nexport const DEFAULT_EXCLUDED_STUDIO_FIELDS = [\"name\"];\nexport const DEFAULT_EXCLUDED_TAG_FIELDS = [\"name\"];\n\nexport const initialConfig: ITaggerConfig = {\n  blacklist: DEFAULT_BLACKLIST,\n  mode: \"auto\",\n  setCoverImage: true,\n  setTags: true,\n  tagOperation: \"merge\",\n  fingerprintQueue: {},\n  excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS,\n  markSceneAsOrganizedOnSave: false,\n  excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS,\n  excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS,\n  createParentStudios: true,\n  createParentTags: true,\n};\n\nexport type ParseMode = \"auto\" | \"filename\" | \"dir\" | \"path\" | \"metadata\";\nexport type TagOperation = \"merge\" | \"overwrite\";\nexport interface ITaggerConfig {\n  blacklist: string[];\n  performerGenders?: GenderEnum[];\n  mode: ParseMode;\n  setCoverImage: boolean;\n  setTags: boolean;\n  tagOperation: TagOperation;\n  selectedEndpoint?: string;\n  fingerprintQueue: Record<string, string[]>;\n  excludedPerformerFields?: string[];\n  markSceneAsOrganizedOnSave?: boolean;\n  excludedStudioFields?: string[];\n  excludedTagFields?: string[];\n  createParentStudios: boolean;\n  createParentTags: boolean;\n}\n\nexport const PERFORMER_FIELDS = [\n  \"name\",\n  \"image\",\n  \"disambiguation\",\n  \"aliases\",\n  \"gender\",\n  \"birthdate\",\n  \"death_date\",\n  \"country\",\n  \"ethnicity\",\n  \"hair_color\",\n  \"eye_color\",\n  \"height\",\n  \"weight\",\n  \"penis_length\",\n  \"circumcised\",\n  \"measurements\",\n  \"fake_tits\",\n  \"tattoos\",\n  \"piercings\",\n  \"career_start\",\n  \"career_end\",\n  \"urls\",\n  \"details\",\n];\n\nexport const STUDIO_FIELDS = [\"name\", \"image\", \"url\", \"parent_studio\"];\nexport const TAG_FIELDS = [\"name\", \"description\", \"aliases\", \"parent_tags\"];\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/context.tsx",
    "content": "import React, { useState, useEffect, useRef } from \"react\";\nimport { initialConfig, ITaggerConfig } from \"src/components/Tagger/constants\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindPerformer,\n  queryFindStudio,\n  queryScrapeScene,\n  queryScrapeSceneQuery,\n  queryScrapeSceneQueryFragment,\n  stashBoxSceneBatchQuery,\n  useListSceneScrapers,\n  usePerformerCreate,\n  usePerformerUpdate,\n  useSceneUpdate,\n  useStudioCreate,\n  useStudioUpdate,\n  useTagCreate,\n  useTagUpdate,\n} from \"src/core/StashService\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { ITaggerSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from \"./constants\";\nimport { errorToString } from \"src/utils\";\nimport { mergeStudioStashIDs } from \"./utils\";\nimport { useTaggerConfig } from \"./config\";\n\nexport interface ITaggerContextState {\n  config: ITaggerConfig;\n  setConfig: (c: ITaggerConfig) => void;\n  loading: boolean;\n  loadingMulti?: boolean;\n  multiError?: string;\n  sources: ITaggerSource[];\n  currentSource?: ITaggerSource;\n  searchResults: Record<string, ISceneQueryResult>;\n  setCurrentSource: (src?: ITaggerSource) => void;\n  doSceneQuery: (sceneID: string, searchStr: string) => Promise<void>;\n  doSceneFragmentScrape: (sceneID: string) => Promise<void>;\n  doMultiSceneFragmentScrape: (sceneIDs: string[]) => Promise<void>;\n  stopMultiScrape: () => void;\n  createNewTag: (\n    tag: GQL.ScrapedTag,\n    toCreate: GQL.TagCreateInput\n  ) => Promise<string | undefined>;\n  createNewPerformer: (\n    performer: GQL.ScrapedPerformer,\n    toCreate: GQL.PerformerCreateInput\n  ) => Promise<string | undefined>;\n  linkPerformer: (\n    performer: GQL.ScrapedPerformer,\n    performerID: string\n  ) => Promise<void>;\n  createNewStudio: (\n    studio: GQL.ScrapedStudio,\n    toCreate: GQL.StudioCreateInput\n  ) => Promise<string | undefined>;\n  updateStudio: (studio: GQL.StudioUpdateInput) => Promise<void>;\n  linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise<void>;\n  updateTag: (\n    tag: GQL.ScrapedTag,\n    updateInput: GQL.TagUpdateInput\n  ) => Promise<void>;\n  resolveScene: (\n    sceneID: string,\n    index: number,\n    scene: IScrapedScene\n  ) => Promise<void>;\n  submitFingerprints: () => Promise<void>;\n  pendingFingerprints: string[];\n  saveScene: (\n    sceneCreateInput: GQL.SceneUpdateInput,\n    queueFingerprint: boolean\n  ) => Promise<void>;\n}\n\nconst dummyFn = () => {\n  return Promise.resolve();\n};\nconst dummyValFn = () => {\n  return Promise.resolve(undefined);\n};\n\nexport const TaggerStateContext = React.createContext<ITaggerContextState>({\n  config: initialConfig,\n  setConfig: () => {},\n  loading: false,\n  sources: [],\n  searchResults: {},\n  setCurrentSource: () => {},\n  doSceneQuery: dummyFn,\n  doSceneFragmentScrape: dummyFn,\n  doMultiSceneFragmentScrape: dummyFn,\n  stopMultiScrape: () => {},\n  createNewTag: dummyValFn,\n  createNewPerformer: dummyValFn,\n  linkPerformer: dummyFn,\n  createNewStudio: dummyValFn,\n  updateStudio: dummyFn,\n  linkStudio: dummyFn,\n  updateTag: dummyFn,\n  resolveScene: dummyFn,\n  submitFingerprints: dummyFn,\n  pendingFingerprints: [],\n  saveScene: dummyFn,\n});\n\nexport type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean };\n\nexport interface ISceneQueryResult {\n  results?: IScrapedScene[];\n  error?: string;\n}\n\nexport const TaggerContext: React.FC = ({ children }) => {\n  const [loading, setLoading] = useState(false);\n  const [loadingMulti, setLoadingMulti] = useState(false);\n  const [sources, setSources] = useState<ITaggerSource[]>([]);\n  const [currentSource, setCurrentSource] = useState<ITaggerSource>();\n  const [multiError, setMultiError] = useState<string | undefined>();\n  const [searchResults, setSearchResults] = useState<\n    Record<string, ISceneQueryResult>\n  >({});\n\n  const stopping = useRef(false);\n\n  const { configuration: stashConfig } = useConfigurationContext();\n  const { config, setConfig } = useTaggerConfig();\n\n  const Scrapers = useListSceneScrapers();\n\n  const Toast = useToast();\n  const [createTag] = useTagCreate();\n  const [createPerformer] = usePerformerCreate();\n  const [updatePerformer] = usePerformerUpdate();\n  const [createStudio] = useStudioCreate();\n  const [updateStudio] = useStudioUpdate();\n  const [updateScene] = useSceneUpdate();\n  const [updateTag] = useTagUpdate();\n\n  useEffect(() => {\n    if (!stashConfig || !Scrapers.data) {\n      return;\n    }\n\n    const { stashBoxes } = stashConfig.general;\n    const scrapers = Scrapers.data.listScrapers;\n\n    const stashboxSources: ITaggerSource[] = stashBoxes.map((s, i) => ({\n      id: `${STASH_BOX_PREFIX}${s.endpoint}`,\n      sourceInput: {\n        stash_box_endpoint: s.endpoint,\n      },\n      displayName: `stash-box: ${s.name || `#${i + 1}`}`,\n      supportSceneFragment: true,\n      supportSceneQuery: true,\n    }));\n\n    // filter scraper sources such that only those that can query scrape or\n    // scrape via fragment are added\n    const scraperSources: ITaggerSource[] = scrapers\n      .filter((s) =>\n        s.scene?.supported_scrapes.some(\n          (t) => t === GQL.ScrapeType.Name || t === GQL.ScrapeType.Fragment\n        )\n      )\n      .map((s) => ({\n        id: `${SCRAPER_PREFIX}${s.id}`,\n        sourceInput: {\n          scraper_id: s.id,\n        },\n        displayName: s.name,\n        supportSceneQuery: s.scene?.supported_scrapes.includes(\n          GQL.ScrapeType.Name\n        ),\n        supportSceneFragment: s.scene?.supported_scrapes.includes(\n          GQL.ScrapeType.Fragment\n        ),\n      }));\n\n    setSources(stashboxSources.concat(scraperSources));\n  }, [Scrapers.data, stashConfig]);\n\n  // set the current source on load\n  useEffect(() => {\n    if (!sources.length || currentSource) {\n      return;\n    }\n    // First, see if we have a saved endpoint.\n    if (config.selectedEndpoint) {\n      let source = sources.find(\n        (s) => s.sourceInput.stash_box_endpoint == config.selectedEndpoint\n      );\n      if (source) {\n        setCurrentSource(source);\n        return;\n      }\n    }\n    // Otherwise, just use the first source.\n    setCurrentSource(sources[0]);\n  }, [sources, currentSource, config]);\n\n  // clear the search results when the source changes\n  useEffect(() => {\n    setSearchResults({});\n  }, [currentSource]);\n\n  // keep selected endpoint in config in sync with current source\n  useEffect(() => {\n    const selectedEndpoint = currentSource?.sourceInput.stash_box_endpoint;\n    if (selectedEndpoint && selectedEndpoint !== config.selectedEndpoint) {\n      setConfig({\n        ...config,\n        selectedEndpoint,\n      });\n    }\n  }, [currentSource, config, setConfig]);\n\n  function getPendingFingerprints() {\n    const endpoint = currentSource?.sourceInput.stash_box_endpoint;\n    if (!config || !endpoint) return [];\n\n    return config.fingerprintQueue[endpoint] ?? [];\n  }\n\n  function clearSubmissionQueue() {\n    const endpoint = currentSource?.sourceInput.stash_box_endpoint;\n    if (!config || !endpoint) return;\n\n    setConfig({\n      ...config,\n      fingerprintQueue: {\n        ...config.fingerprintQueue,\n        [endpoint]: [],\n      },\n    });\n  }\n\n  const [submitFingerprintsMutation] =\n    GQL.useSubmitStashBoxFingerprintsMutation();\n\n  async function submitFingerprints() {\n    const endpoint = currentSource?.sourceInput.stash_box_endpoint;\n\n    if (!config || !endpoint) return;\n\n    try {\n      setLoading(true);\n      await submitFingerprintsMutation({\n        variables: {\n          input: {\n            stash_box_endpoint: endpoint,\n            scene_ids: config.fingerprintQueue[endpoint],\n          },\n        },\n      });\n\n      clearSubmissionQueue();\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  function queueFingerprintSubmission(sceneId: string) {\n    const endpoint = currentSource?.sourceInput.stash_box_endpoint;\n    if (!config || !endpoint) return;\n\n    setConfig({\n      ...config,\n      fingerprintQueue: {\n        ...config.fingerprintQueue,\n        [endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId],\n      },\n    });\n  }\n\n  function clearSearchResults(sceneID: string) {\n    setSearchResults((current) => {\n      const newSearchResults = { ...current };\n      delete newSearchResults[sceneID];\n      return newSearchResults;\n    });\n  }\n\n  async function doSceneQuery(sceneID: string, searchVal: string) {\n    if (!currentSource) {\n      return;\n    }\n\n    try {\n      setLoading(true);\n      clearSearchResults(sceneID);\n\n      const results = await queryScrapeSceneQuery(\n        currentSource.sourceInput,\n        searchVal\n      );\n      let newResult: ISceneQueryResult;\n      // scenes are already resolved if they come from stash-box\n      const resolved =\n        currentSource.sourceInput.stash_box_endpoint !== undefined;\n\n      if (results.error) {\n        newResult = { error: results.error.message };\n      } else if (results.errors) {\n        newResult = { error: results.errors.toString() };\n      } else {\n        newResult = {\n          results: results.data.scrapeSingleScene.map((r) => ({\n            ...r,\n            resolved,\n          })),\n        };\n      }\n\n      setSearchResults({ ...searchResults, [sceneID]: newResult });\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  async function sceneFragmentScrape(sceneID: string) {\n    if (!currentSource) {\n      return;\n    }\n\n    clearSearchResults(sceneID);\n\n    let newResult: ISceneQueryResult;\n\n    try {\n      const results = await queryScrapeScene(\n        currentSource.sourceInput,\n        sceneID\n      );\n\n      if (results.error) {\n        newResult = { error: results.error.message };\n      } else if (results.errors) {\n        newResult = { error: results.errors.toString() };\n      } else {\n        newResult = {\n          results: results.data.scrapeSingleScene.map((r) => ({\n            ...r,\n            // scenes are already resolved if they are scraped via fragment\n            resolved: true,\n          })),\n        };\n      }\n    } catch (err: unknown) {\n      newResult = { error: errorToString(err) };\n    }\n\n    setSearchResults((current) => {\n      return { ...current, [sceneID]: newResult };\n    });\n  }\n\n  async function doSceneFragmentScrape(sceneID: string) {\n    if (!currentSource) {\n      return;\n    }\n\n    clearSearchResults(sceneID);\n\n    try {\n      setLoading(true);\n      await sceneFragmentScrape(sceneID);\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  async function doMultiSceneFragmentScrape(sceneIDs: string[]) {\n    if (!currentSource) {\n      return;\n    }\n\n    setSearchResults({});\n\n    try {\n      stopping.current = false;\n      setLoading(true);\n      setMultiError(undefined);\n\n      const stashBoxEndpoint =\n        currentSource.sourceInput.stash_box_endpoint ?? undefined;\n\n      // if current source is stash-box, we can use the multi-scene\n      // interface\n      if (stashBoxEndpoint !== undefined) {\n        const results = await stashBoxSceneBatchQuery(\n          sceneIDs,\n          stashBoxEndpoint\n        );\n\n        if (results.error) {\n          setMultiError(results.error.message);\n        } else if (results.errors) {\n          setMultiError(results.errors.toString());\n        } else {\n          const newSearchResults = { ...searchResults };\n          sceneIDs.forEach((sceneID, index) => {\n            const newResults = results.data.scrapeMultiScenes[index].map(\n              (r) => ({\n                ...r,\n                resolved: true,\n              })\n            );\n\n            newSearchResults[sceneID] = {\n              results: newResults,\n            };\n          });\n\n          setSearchResults(newSearchResults);\n        }\n      } else {\n        setLoadingMulti(true);\n\n        // do singular calls\n        await sceneIDs.reduce(async (promise, id) => {\n          await promise;\n          if (!stopping.current) {\n            await sceneFragmentScrape(id);\n          }\n        }, Promise.resolve());\n      }\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setLoading(false);\n      setLoadingMulti(false);\n    }\n  }\n\n  function stopMultiScrape() {\n    stopping.current = true;\n  }\n\n  async function resolveScene(\n    sceneID: string,\n    index: number,\n    scene: IScrapedScene\n  ) {\n    if (!currentSource || scene.resolved || !searchResults[sceneID].results) {\n      return Promise.resolve();\n    }\n\n    try {\n      const sceneInput: GQL.ScrapedSceneInput = {\n        date: scene.date,\n        details: scene.details,\n        remote_site_id: scene.remote_site_id,\n        title: scene.title,\n        urls: scene.urls,\n      };\n\n      const result = await queryScrapeSceneQueryFragment(\n        currentSource.sourceInput,\n        sceneInput\n      );\n\n      if (result.data.scrapeSingleScene.length) {\n        const resolvedScene = result.data.scrapeSingleScene[0];\n\n        // set the scene in the results and mark as resolved\n        const newResult = [...searchResults[sceneID].results!];\n        newResult[index] = { ...resolvedScene, resolved: true };\n        setSearchResults({\n          ...searchResults,\n          [sceneID]: { ...searchResults[sceneID], results: newResult },\n        });\n      }\n    } catch (err) {\n      Toast.error(err);\n\n      const newResult = [...searchResults[sceneID].results!];\n      newResult[index] = { ...newResult[index], resolved: true };\n      setSearchResults({\n        ...searchResults,\n        [sceneID]: { ...searchResults[sceneID], results: newResult },\n      });\n    }\n  }\n\n  async function saveScene(\n    sceneCreateInput: GQL.SceneUpdateInput,\n    queueFingerprint: boolean\n  ) {\n    try {\n      await updateScene({\n        variables: {\n          input: {\n            ...sceneCreateInput,\n            // only set organized if it is enabled in the config\n            organized: config?.markSceneAsOrganizedOnSave || undefined,\n          },\n        },\n      });\n\n      if (queueFingerprint) {\n        queueFingerprintSubmission(sceneCreateInput.id);\n      }\n      clearSearchResults(sceneCreateInput.id);\n    } catch (err) {\n      Toast.error(err);\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  function mapResults(fn: (r: IScrapedScene) => IScrapedScene) {\n    const newSearchResults = { ...searchResults };\n\n    Object.keys(newSearchResults).forEach((k) => {\n      const searchResult = searchResults[k];\n      if (!searchResult.results) {\n        return;\n      }\n\n      newSearchResults[k].results = searchResult.results.map(fn);\n    });\n\n    return newSearchResults;\n  }\n\n  async function createNewTag(\n    tag: GQL.ScrapedTag,\n    toCreate: GQL.TagCreateInput\n  ) {\n    try {\n      const result = await createTag({\n        variables: {\n          input: toCreate,\n        },\n      });\n\n      const tagID = result.data?.tagCreate?.id;\n      if (tagID === undefined) return undefined;\n\n      const newSearchResults = mapResults((r) => {\n        if (!r.tags) {\n          return r;\n        }\n\n        return {\n          ...r,\n          tags: r.tags.map((t) => {\n            if (t.name === tag.name) {\n              return {\n                ...t,\n                stored_id: tagID,\n              };\n            }\n\n            return t;\n          }),\n        };\n      });\n\n      setSearchResults(newSearchResults);\n\n      Toast.success(\n        <span>\n          Created tag: <b>{toCreate.name}</b>\n        </span>\n      );\n\n      return tagID;\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function createNewPerformer(\n    performer: GQL.ScrapedPerformer,\n    toCreate: GQL.PerformerCreateInput\n  ) {\n    try {\n      const result = await createPerformer({\n        variables: {\n          input: toCreate,\n        },\n      });\n\n      const performerID = result.data?.performerCreate?.id;\n      if (performerID === undefined) return undefined;\n\n      const newSearchResults = mapResults((r) => {\n        if (!r.performers) {\n          return r;\n        }\n\n        return {\n          ...r,\n          performers: r.performers.map((p) => {\n            // Match by remote_site_id if available, otherwise fall back to name\n            const matches = performer.remote_site_id\n              ? p.remote_site_id === performer.remote_site_id\n              : p.name === performer.name;\n\n            if (matches) {\n              return {\n                ...p,\n                stored_id: performerID,\n              };\n            }\n\n            return p;\n          }),\n        };\n      });\n\n      setSearchResults(newSearchResults);\n\n      Toast.success(\n        <span>\n          Created performer: <b>{toCreate.name}</b>\n        </span>\n      );\n\n      return performerID;\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function linkPerformer(\n    performer: GQL.ScrapedPerformer,\n    performerID: string\n  ) {\n    if (\n      !performer.remote_site_id ||\n      !currentSource?.sourceInput.stash_box_endpoint\n    )\n      return;\n\n    try {\n      const queryResult = await queryFindPerformer(performerID);\n      if (queryResult.data.findPerformer) {\n        const target = queryResult.data.findPerformer;\n\n        const stashIDs: GQL.StashIdInput[] = target.stash_ids.map((e) => {\n          return {\n            endpoint: e.endpoint,\n            stash_id: e.stash_id,\n            updated_at: e.updated_at,\n          };\n        });\n\n        stashIDs.push({\n          stash_id: performer.remote_site_id,\n          endpoint: currentSource?.sourceInput.stash_box_endpoint,\n          updated_at: new Date().toISOString(),\n        });\n\n        await updatePerformer({\n          variables: {\n            input: {\n              id: performerID,\n              stash_ids: stashIDs,\n            },\n          },\n        });\n\n        const newSearchResults = mapResults((r) => {\n          if (!r.performers) {\n            return r;\n          }\n\n          return {\n            ...r,\n            performers: r.performers.map((p) => {\n              if (p.remote_site_id === performer.remote_site_id) {\n                return {\n                  ...p,\n                  stored_id: performerID,\n                };\n              }\n\n              return p;\n            }),\n          };\n        });\n\n        setSearchResults(newSearchResults);\n\n        Toast.success(<span>Added stash-id to performer</span>);\n      }\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function createNewStudio(\n    studio: GQL.ScrapedStudio,\n    toCreate: GQL.StudioCreateInput\n  ) {\n    try {\n      const result = await createStudio({\n        variables: {\n          input: toCreate,\n        },\n      });\n\n      const studioID = result.data?.studioCreate?.id;\n      if (studioID === undefined) return undefined;\n\n      const newSearchResults = mapResults((r) => {\n        if (!r.studio) {\n          return r;\n        }\n\n        let resultStudio = r.studio;\n        if (resultStudio.name === studio.name) {\n          resultStudio = {\n            ...resultStudio,\n            stored_id: studioID,\n          };\n        }\n\n        // #5821 - set the stored_id of the parent studio if it matches too\n        if (resultStudio.parent?.name === studio.name) {\n          resultStudio = {\n            ...resultStudio,\n            parent: {\n              ...resultStudio.parent,\n              stored_id: studioID,\n            },\n          };\n        }\n\n        return {\n          ...r,\n          studio: resultStudio,\n        };\n      });\n\n      setSearchResults(newSearchResults);\n\n      Toast.success(\n        <span>\n          Created studio: <b>{toCreate.name}</b>\n        </span>\n      );\n\n      return studioID;\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function updateExistingStudio(input: GQL.StudioUpdateInput) {\n    try {\n      const inputCopy = { ...input };\n      inputCopy.stash_ids = await mergeStudioStashIDs(\n        input.id,\n        input.stash_ids ?? []\n      );\n      const result = await updateStudio({\n        variables: {\n          input: input,\n        },\n      });\n\n      const studioID = result.data?.studioUpdate?.id;\n\n      const stashID = input.stash_ids?.find((e) => {\n        return e.endpoint === currentSource?.sourceInput.stash_box_endpoint;\n      })?.stash_id;\n\n      if (stashID) {\n        const newSearchResults = mapResults((r) => {\n          if (!r.studio) {\n            return r;\n          }\n\n          return {\n            ...r,\n            studio:\n              r.remote_site_id === stashID\n                ? {\n                    ...r.studio,\n                    stored_id: studioID,\n                  }\n                : r.studio,\n          };\n        });\n\n        setSearchResults(newSearchResults);\n      }\n\n      Toast.success(\n        <span>\n          Created studio: <b>{input.name}</b>\n        </span>\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function linkStudio(studio: GQL.ScrapedStudio, studioID: string) {\n    if (\n      !studio.remote_site_id ||\n      !currentSource?.sourceInput.stash_box_endpoint\n    )\n      return;\n\n    try {\n      const queryResult = await queryFindStudio(studioID);\n      if (queryResult.data.findStudio) {\n        const target = queryResult.data.findStudio;\n\n        const stashIDs: GQL.StashIdInput[] = target.stash_ids.map((e) => {\n          return {\n            endpoint: e.endpoint,\n            stash_id: e.stash_id,\n            updated_at: e.updated_at,\n          };\n        });\n\n        stashIDs.push({\n          stash_id: studio.remote_site_id,\n          endpoint: currentSource?.sourceInput.stash_box_endpoint,\n          updated_at: new Date().toISOString(),\n        });\n\n        await updateStudio({\n          variables: {\n            input: {\n              id: studioID,\n              stash_ids: stashIDs,\n            },\n          },\n        });\n\n        const newSearchResults = mapResults((r) => {\n          if (!r.studio) {\n            return r;\n          }\n\n          return {\n            ...r,\n            studio:\n              r.studio.remote_site_id === studio.remote_site_id\n                ? {\n                    ...r.studio,\n                    stored_id: studioID,\n                  }\n                : r.studio,\n          };\n        });\n\n        setSearchResults(newSearchResults);\n\n        Toast.success(<span>Added stash-id to studio</span>);\n      }\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function updateExistingTag(\n    tag: GQL.ScrapedTag,\n    updateInput: GQL.TagUpdateInput\n  ) {\n    const hasRemoteID = !!tag.remote_site_id;\n\n    try {\n      await updateTag({\n        variables: {\n          input: updateInput,\n        },\n      });\n\n      const newSearchResults = mapResults((r) => {\n        if (!r.tags) {\n          return r;\n        }\n\n        return {\n          ...r,\n          tags: r.tags.map((t) => {\n            if (\n              (hasRemoteID && t.remote_site_id === tag.remote_site_id) ||\n              (!hasRemoteID && t.name === tag.name)\n            ) {\n              return {\n                ...t,\n                stored_id: updateInput.id,\n              };\n            }\n\n            return t;\n          }),\n        };\n      });\n\n      setSearchResults(newSearchResults);\n\n      Toast.success(<span>Updated tag</span>);\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  return (\n    <TaggerStateContext.Provider\n      value={{\n        config: config ?? initialConfig,\n        setConfig,\n        loading: loading || loadingMulti,\n        loadingMulti,\n        multiError,\n        sources,\n        currentSource,\n        searchResults,\n        setCurrentSource: (src) => {\n          setCurrentSource(src);\n        },\n        doSceneQuery,\n        doSceneFragmentScrape,\n        doMultiSceneFragmentScrape,\n        stopMultiScrape,\n        createNewTag,\n        createNewPerformer,\n        linkPerformer,\n        createNewStudio,\n        updateStudio: updateExistingStudio,\n        linkStudio,\n        updateTag: updateExistingTag,\n        resolveScene,\n        saveScene,\n        submitFingerprints,\n        pendingFingerprints: getPendingFingerprints(),\n      }}\n    >\n      {children}\n    </TaggerStateContext.Provider>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx",
    "content": "import React, { useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, Card, Form, InputGroup, ProgressBar } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Link } from \"react-router-dom\";\nimport { HashLink } from \"react-router-hash-link\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport {\n  stashBoxPerformerQuery,\n  useJobsSubscribe,\n  mutateStashBoxBatchPerformerTag,\n  getClient,\n  evictQueries,\n  performerMutationImpactedQueries,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\nimport StashSearchResult from \"./StashSearchResult\";\nimport TaggerConfig, { ConfigButton } from \"../TaggerConfig\";\nimport { ITaggerConfig, PERFORMER_FIELDS } from \"../constants\";\nimport PerformerModal from \"../PerformerModal\";\nimport { useUpdatePerformer } from \"../queries\";\nimport { faStar, faTags } from \"@fortawesome/free-solid-svg-icons\";\nimport { mergeStashIDs } from \"src/utils/stashbox\";\nimport { separateNamesAndStashIds } from \"src/utils/stashIds\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\nimport { useTaggerConfig } from \"../config\";\nimport { StashBoxSelectorField } from \"../StashBoxSelector\";\n\ntype JobFragment = Pick<\n  GQL.Job,\n  \"id\" | \"status\" | \"subTasks\" | \"description\" | \"progress\"\n>;\n\nconst CLASSNAME = \"PerformerTagger\";\n\ninterface IPerformerBatchUpdateModal {\n  performers: GQL.PerformerDataFragment[];\n  isIdle: boolean;\n  selectedEndpoint: { endpoint: string; index: number };\n  onBatchUpdate: (queryAll: boolean, refresh: boolean) => void;\n  close: () => void;\n}\n\nconst PerformerBatchUpdateModal: React.FC<IPerformerBatchUpdateModal> = ({\n  performers,\n  isIdle,\n  selectedEndpoint,\n  onBatchUpdate,\n  close,\n}) => {\n  const intl = useIntl();\n\n  const [queryAll, setQueryAll] = useState(false);\n\n  const [refresh, setRefresh] = useState(false);\n  const { data: allPerformers } = GQL.useFindPerformersQuery({\n    variables: {\n      performer_filter: {\n        stash_id_endpoint: {\n          endpoint: selectedEndpoint.endpoint,\n          modifier: refresh\n            ? GQL.CriterionModifier.NotNull\n            : GQL.CriterionModifier.IsNull,\n        },\n      },\n      filter: {\n        per_page: 0,\n      },\n    },\n  });\n\n  const performerCount = useMemo(() => {\n    // get all stash ids for the selected endpoint\n    const filteredStashIDs = performers.map((p) =>\n      p.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint)\n    );\n\n    return queryAll\n      ? allPerformers?.findPerformers.count\n      : filteredStashIDs.filter((s) =>\n          // if refresh, then we filter out the performers without a stash id\n          // otherwise, we want untagged performers, filtering out those with a stash id\n          refresh ? s.length > 0 : s.length === 0\n        ).length;\n  }, [queryAll, refresh, performers, allPerformers, selectedEndpoint.endpoint]);\n\n  return (\n    <ModalComponent\n      show\n      icon={faTags}\n      header={intl.formatMessage({\n        id: \"performer_tagger.update_performers\",\n      })}\n      accept={{\n        text: intl.formatMessage({\n          id: \"performer_tagger.update_performers\",\n        }),\n        onClick: () => onBatchUpdate(queryAll, refresh),\n      }}\n      cancel={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"danger\",\n        onClick: () => close(),\n      }}\n      disabled={!isIdle}\n    >\n      <Form.Group>\n        <Form.Label>\n          <h6>\n            <FormattedMessage id=\"performer_tagger.performer_selection\" />\n          </h6>\n        </Form.Label>\n        <Form.Check\n          id=\"query-page\"\n          type=\"radio\"\n          name=\"performer-query\"\n          label={<FormattedMessage id=\"performer_tagger.current_page\" />}\n          checked={!queryAll}\n          onChange={() => setQueryAll(false)}\n        />\n        <Form.Check\n          id=\"query-all\"\n          type=\"radio\"\n          name=\"performer-query\"\n          label={intl.formatMessage({\n            id: \"performer_tagger.query_all_performers_in_the_database\",\n          })}\n          checked={queryAll}\n          onChange={() => setQueryAll(true)}\n        />\n      </Form.Group>\n      <Form.Group>\n        <Form.Label>\n          <h6>\n            <FormattedMessage id=\"performer_tagger.tag_status\" />\n          </h6>\n        </Form.Label>\n        <Form.Check\n          id=\"untagged-performers\"\n          type=\"radio\"\n          name=\"performer-refresh\"\n          label={intl.formatMessage({\n            id: \"performer_tagger.untagged_performers\",\n          })}\n          checked={!refresh}\n          onChange={() => setRefresh(false)}\n        />\n        <Form.Text>\n          <FormattedMessage id=\"performer_tagger.updating_untagged_performers_description\" />\n        </Form.Text>\n        <Form.Check\n          id=\"tagged-performers\"\n          type=\"radio\"\n          name=\"performer-refresh\"\n          label={intl.formatMessage({\n            id: \"performer_tagger.refresh_tagged_performers\",\n          })}\n          checked={refresh}\n          onChange={() => setRefresh(true)}\n        />\n        <Form.Text>\n          <FormattedMessage id=\"performer_tagger.refreshing_will_update_the_data\" />\n        </Form.Text>\n      </Form.Group>\n      <b>\n        <FormattedMessage\n          id=\"performer_tagger.number_of_performers_will_be_processed\"\n          values={{\n            performer_count: performerCount,\n          }}\n        />\n      </b>\n    </ModalComponent>\n  );\n};\n\ninterface IPerformerBatchAddModal {\n  isIdle: boolean;\n  onBatchAdd: (input: string) => void;\n  close: () => void;\n}\n\nconst PerformerBatchAddModal: React.FC<IPerformerBatchAddModal> = ({\n  isIdle,\n  onBatchAdd,\n  close,\n}) => {\n  const intl = useIntl();\n\n  const performerInput = useRef<HTMLTextAreaElement | null>(null);\n\n  return (\n    <ModalComponent\n      show\n      icon={faStar}\n      header={intl.formatMessage({\n        id: \"performer_tagger.add_new_performers\",\n      })}\n      accept={{\n        text: intl.formatMessage({\n          id: \"performer_tagger.add_new_performers\",\n        }),\n        onClick: () => {\n          if (performerInput.current) {\n            onBatchAdd(performerInput.current.value);\n          } else {\n            close();\n          }\n        },\n      }}\n      cancel={{\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"danger\",\n        onClick: () => close(),\n      }}\n      disabled={!isIdle}\n    >\n      <Form.Control\n        className=\"text-input\"\n        as=\"textarea\"\n        ref={performerInput}\n        placeholder={intl.formatMessage({\n          id: \"performer_tagger.performer_names_or_stashids_separated_by_comma\",\n        })}\n        rows={6}\n      />\n      <Form.Text>\n        <FormattedMessage id=\"performer_tagger.any_names_entered_will_be_queried\" />\n      </Form.Text>\n    </ModalComponent>\n  );\n};\n\ninterface IPerformerTaggerListProps {\n  performers: GQL.PerformerDataFragment[];\n  selectedEndpoint: { endpoint: string; index: number };\n  isIdle: boolean;\n  config: ITaggerConfig;\n  onBatchAdd: (performerInput: string) => void;\n  onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void;\n}\n\nconst PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({\n  performers,\n  selectedEndpoint,\n  isIdle,\n  config,\n  onBatchAdd,\n  onBatchUpdate,\n}) => {\n  const intl = useIntl();\n  const [loading, setLoading] = useState(false);\n  const [searchResults, setSearchResults] = useState<\n    Record<string, GQL.ScrapedPerformerDataFragment[]>\n  >({});\n  const [searchErrors, setSearchErrors] = useState<\n    Record<string, string | undefined>\n  >({});\n  const [taggedPerformers, setTaggedPerformers] = useState<\n    Record<string, Partial<GQL.SlimPerformerDataFragment>>\n  >({});\n  const [queries, setQueries] = useState<Record<string, string>>({});\n\n  const [showBatchAdd, setShowBatchAdd] = useState(false);\n  const [showBatchUpdate, setShowBatchUpdate] = useState(false);\n\n  const [error, setError] = useState<\n    Record<string, { message?: string; details?: string } | undefined>\n  >({});\n  const [loadingUpdate, setLoadingUpdate] = useState<string | undefined>();\n  const [modalPerformer, setModalPerformer] = useState<\n    GQL.ScrapedPerformerDataFragment | undefined\n  >();\n\n  const doBoxSearch = (performerID: string, searchVal: string) => {\n    stashBoxPerformerQuery(searchVal, selectedEndpoint.endpoint)\n      .then((queryData) => {\n        const s = queryData.data?.scrapeSinglePerformer ?? [];\n        setSearchResults({\n          ...searchResults,\n          [performerID]: s,\n        });\n        setSearchErrors({\n          ...searchErrors,\n          [performerID]: undefined,\n        });\n        setLoading(false);\n      })\n      .catch(() => {\n        setLoading(false);\n        // Destructure to remove existing result\n        const { [performerID]: unassign, ...results } = searchResults;\n        setSearchResults(results);\n        setSearchErrors({\n          ...searchErrors,\n          [performerID]: intl.formatMessage({\n            id: \"performer_tagger.network_error\",\n          }),\n        });\n      });\n\n    setLoading(true);\n  };\n\n  const doBoxUpdate = (\n    performerID: string,\n    stashID: string,\n    endpoint: string\n  ) => {\n    setLoadingUpdate(stashID);\n    setError({\n      ...error,\n      [performerID]: undefined,\n    });\n    stashBoxPerformerQuery(stashID, endpoint)\n      .then((queryData) => {\n        const data = queryData.data?.scrapeSinglePerformer ?? [];\n        if (data.length > 0) {\n          setModalPerformer({\n            ...data[0],\n            stored_id: performerID,\n          });\n        }\n      })\n      .finally(() => setLoadingUpdate(undefined));\n  };\n\n  async function handleBatchAdd(input: string) {\n    onBatchAdd(input);\n    setShowBatchAdd(false);\n  }\n\n  const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => {\n    onBatchUpdate(!queryAll ? performers.map((p) => p.id) : undefined, refresh);\n    setShowBatchUpdate(false);\n  };\n\n  const handleTaggedPerformer = (\n    performer: Pick<GQL.SlimPerformerDataFragment, \"id\"> &\n      Partial<Omit<GQL.SlimPerformerDataFragment, \"id\">>\n  ) => {\n    setTaggedPerformers({\n      ...taggedPerformers,\n      [performer.id]: performer,\n    });\n  };\n\n  const updatePerformer = useUpdatePerformer();\n\n  function handleSaveError(performerID: string, name: string, message: string) {\n    setError({\n      ...error,\n      [performerID]: {\n        message: intl.formatMessage(\n          { id: \"performer_tagger.failed_to_save_performer\" },\n          { performer: modalPerformer?.name }\n        ),\n        details:\n          message === \"UNIQUE constraint failed: performers.name\"\n            ? intl.formatMessage({\n                id: \"performer_tagger.name_already_exists\",\n              })\n            : message,\n      },\n    });\n  }\n\n  const handlePerformerUpdate = async (\n    existing: GQL.PerformerDataFragment,\n    input: GQL.PerformerCreateInput\n  ) => {\n    setModalPerformer(undefined);\n    const performerID = modalPerformer?.stored_id;\n    if (performerID) {\n      // handle stash ids - we want to add, not set them\n      if (input.stash_ids?.length) {\n        input.stash_ids = mergeStashIDs(existing.stash_ids, input.stash_ids);\n      }\n\n      const updateData: GQL.PerformerUpdateInput = {\n        ...input,\n        id: performerID,\n      };\n\n      const res = await updatePerformer(updateData);\n      if (!res.data?.performerUpdate)\n        handleSaveError(\n          performerID,\n          modalPerformer?.name ?? \"\",\n          res?.errors?.[0]?.message ?? \"\"\n        );\n    }\n  };\n\n  const renderPerformers = () =>\n    performers.map((performer) => {\n      const isTagged = taggedPerformers[performer.id];\n\n      const stashID = performer.stash_ids.find((s) => {\n        return s.endpoint === selectedEndpoint.endpoint;\n      });\n\n      let mainContent;\n      if (!isTagged && stashID !== undefined) {\n        mainContent = (\n          <div className=\"text-left\">\n            <h5 className=\"text-bold\">\n              <FormattedMessage id=\"performer_tagger.performer_already_tagged\" />\n            </h5>\n          </div>\n        );\n      } else if (!isTagged && !stashID) {\n        mainContent = (\n          <InputGroup>\n            <Form.Control\n              className=\"text-input\"\n              defaultValue={performer.name ?? \"\"}\n              onChange={(e) =>\n                setQueries({\n                  ...queries,\n                  [performer.id]: e.currentTarget.value,\n                })\n              }\n              onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>\n                e.key === \"Enter\" &&\n                doBoxSearch(\n                  performer.id,\n                  queries[performer.id] ?? performer.name ?? \"\"\n                )\n              }\n            />\n            <InputGroup.Append>\n              <Button\n                disabled={loading}\n                onClick={() =>\n                  doBoxSearch(\n                    performer.id,\n                    queries[performer.id] ?? performer.name ?? \"\"\n                  )\n                }\n              >\n                <FormattedMessage id=\"actions.search\" />\n              </Button>\n            </InputGroup.Append>\n          </InputGroup>\n        );\n      } else if (isTagged) {\n        mainContent = (\n          <div className=\"d-flex flex-column text-left\">\n            <h5>\n              <FormattedMessage id=\"performer_tagger.performer_successfully_tagged\" />\n            </h5>\n            <h6>\n              <Link className=\"bold\" to={`/performers/${performer.id}`}>\n                {taggedPerformers[performer.id].name}\n              </Link>\n            </h6>\n          </div>\n        );\n      }\n\n      let subContent;\n      if (stashID !== undefined) {\n        const base = stashID.endpoint.match(/https?:\\/\\/.*?\\//)?.[0];\n        const link = base ? (\n          <ExternalLink\n            className=\"small d-block\"\n            href={`${base}performers/${stashID.stash_id}`}\n          >\n            {stashID.stash_id}\n          </ExternalLink>\n        ) : (\n          <div className=\"small\">{stashID.stash_id}</div>\n        );\n\n        subContent = (\n          <div key={performer.id}>\n            <InputGroup className=\"PerformerTagger-box-link\">\n              <InputGroup.Text>{link}</InputGroup.Text>\n              <InputGroup.Append>\n                <Button\n                  onClick={() =>\n                    doBoxUpdate(\n                      performer.id,\n                      stashID.stash_id,\n                      stashID.endpoint\n                    )\n                  }\n                  disabled={!!loadingUpdate}\n                >\n                  {loadingUpdate === stashID.stash_id ? (\n                    <LoadingIndicator inline small message=\"\" />\n                  ) : (\n                    <FormattedMessage id=\"actions.refresh\" />\n                  )}\n                </Button>\n              </InputGroup.Append>\n            </InputGroup>\n            {error[performer.id] && (\n              <div className=\"text-danger mt-1\">\n                <strong>\n                  <span className=\"mr-2\">Error:</span>\n                  {error[performer.id]?.message}\n                </strong>\n                <div>{error[performer.id]?.details}</div>\n              </div>\n            )}\n          </div>\n        );\n      } else if (searchErrors[performer.id]) {\n        subContent = (\n          <div className=\"text-danger font-weight-bold\">\n            {searchErrors[performer.id]}\n          </div>\n        );\n      } else if (searchResults[performer.id]?.length === 0) {\n        subContent = (\n          <div className=\"text-danger font-weight-bold\">\n            <FormattedMessage id=\"performer_tagger.no_results_found\" />\n          </div>\n        );\n      }\n\n      let searchResult;\n      if (searchResults[performer.id]?.length > 0 && !isTagged) {\n        searchResult = (\n          <StashSearchResult\n            key={performer.id}\n            stashboxPerformers={searchResults[performer.id]}\n            performer={performer}\n            endpoint={selectedEndpoint.endpoint}\n            onPerformerTagged={handleTaggedPerformer}\n            excludedPerformerFields={config.excludedPerformerFields ?? []}\n          />\n        );\n      }\n\n      return (\n        <div key={performer.id} className={`${CLASSNAME}-performer`}>\n          {modalPerformer && (\n            <PerformerModal\n              closeModal={() => setModalPerformer(undefined)}\n              modalVisible={modalPerformer.stored_id === performer.id}\n              performer={modalPerformer}\n              onSave={(input) => {\n                handlePerformerUpdate(performer, input);\n              }}\n              excludedPerformerFields={config.excludedPerformerFields}\n              icon={faTags}\n              header={intl.formatMessage({\n                id: \"performer_tagger.update_performer\",\n              })}\n              endpoint={selectedEndpoint.endpoint}\n            />\n          )}\n          <Card className=\"performer-card p-0 m-0\">\n            <img src={performer.image_path ?? \"\"} alt=\"\" />\n          </Card>\n          <div className={`${CLASSNAME}-details`}>\n            <Link\n              to={`/performers/${performer.id}`}\n              className={`${CLASSNAME}-header`}\n            >\n              <h2>\n                {performer.name}\n                {performer.disambiguation && (\n                  <span className=\"performer-disambiguation\">\n                    {` (${performer.disambiguation})`}\n                  </span>\n                )}\n              </h2>\n            </Link>\n            {mainContent}\n            <div className=\"sub-content text-left\">{subContent}</div>\n            {searchResult}\n          </div>\n        </div>\n      );\n    });\n\n  return (\n    <Card>\n      {showBatchUpdate && (\n        <PerformerBatchUpdateModal\n          close={() => setShowBatchUpdate(false)}\n          isIdle={isIdle}\n          selectedEndpoint={selectedEndpoint}\n          performers={performers}\n          onBatchUpdate={handleBatchUpdate}\n        />\n      )}\n\n      {showBatchAdd && (\n        <PerformerBatchAddModal\n          close={() => setShowBatchAdd(false)}\n          isIdle={isIdle}\n          onBatchAdd={handleBatchAdd}\n        />\n      )}\n\n      <div className=\"ml-auto mb-3\">\n        <Button onClick={() => setShowBatchAdd(true)}>\n          <FormattedMessage id=\"performer_tagger.batch_add_performers\" />\n        </Button>\n        <Button className=\"ml-3\" onClick={() => setShowBatchUpdate(true)}>\n          <FormattedMessage id=\"performer_tagger.batch_update_performers\" />\n        </Button>\n      </div>\n      <div className={CLASSNAME}>{renderPerformers()}</div>\n    </Card>\n  );\n};\n\ninterface ITaggerProps {\n  performers: GQL.PerformerDataFragment[];\n}\n\nexport const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {\n  const jobsSubscribe = useJobsSubscribe();\n  const { configuration: stashConfig } = useConfigurationContext();\n  const { config, setConfig } = useTaggerConfig();\n  const [showConfig, setShowConfig] = useState(false);\n\n  const [batchJobID, setBatchJobID] = useState<string | undefined | null>();\n  const [batchJob, setBatchJob] = useState<JobFragment | undefined>();\n\n  // monitor batch operation\n  useEffect(() => {\n    if (!jobsSubscribe.data) {\n      return;\n    }\n\n    const event = jobsSubscribe.data.jobsSubscribe;\n    if (event.job.id !== batchJobID) {\n      return;\n    }\n\n    if (event.type !== GQL.JobStatusUpdateType.Remove) {\n      setBatchJob(event.job);\n    } else {\n      setBatchJob(undefined);\n      setBatchJobID(undefined);\n\n      // Once the performer batch is complete, refresh all local performer data\n      const ac = getClient();\n      evictQueries(ac.cache, performerMutationImpactedQueries);\n    }\n  }, [jobsSubscribe, batchJobID]);\n\n  if (!config) return <LoadingIndicator />;\n\n  const savedEndpointIndex =\n    stashConfig?.general.stashBoxes.findIndex(\n      (s) => s.endpoint === config.selectedEndpoint\n    ) ?? -1;\n  const selectedEndpointIndex =\n    savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length\n      ? 0\n      : savedEndpointIndex;\n  const selectedEndpoint =\n    stashConfig?.general.stashBoxes[selectedEndpointIndex];\n\n  async function batchAdd(performerInput: string) {\n    if (performerInput && selectedEndpoint) {\n      const inputs = performerInput\n        .split(\",\")\n        .map((n) => n.trim())\n        .filter((n) => n.length > 0);\n\n      const { names, stashIds } = separateNamesAndStashIds(inputs);\n\n      if (names.length > 0 || stashIds.length > 0) {\n        const ret = await mutateStashBoxBatchPerformerTag({\n          names: names.length > 0 ? names : undefined,\n          stash_ids: stashIds.length > 0 ? stashIds : undefined,\n          endpoint: selectedEndpointIndex,\n          refresh: false,\n          createParent: false,\n        });\n\n        setBatchJobID(ret.data?.stashBoxBatchPerformerTag);\n      }\n    }\n  }\n\n  async function batchUpdate(ids: string[] | undefined, refresh: boolean) {\n    if (config && selectedEndpoint) {\n      const ret = await mutateStashBoxBatchPerformerTag({\n        ids: ids,\n        endpoint: selectedEndpointIndex,\n        refresh,\n        exclude_fields: config.excludedPerformerFields ?? [],\n        createParent: false,\n      });\n\n      setBatchJobID(ret.data?.stashBoxBatchPerformerTag);\n    }\n  }\n\n  // const progress =\n  //   jobStatus.data?.metadataUpdate.status ===\n  //     \"Stash-Box Performer Batch Operation\" &&\n  //   jobStatus.data.metadataUpdate.progress >= 0\n  //     ? jobStatus.data.metadataUpdate.progress * 100\n  //     : null;\n\n  function renderStatus() {\n    if (batchJob) {\n      const progress =\n        batchJob.progress !== undefined && batchJob.progress !== null\n          ? batchJob.progress * 100\n          : undefined;\n      return (\n        <Form.Group className=\"px-4\">\n          <h5>\n            <FormattedMessage id=\"performer_tagger.status_tagging_performers\" />\n          </h5>\n          {progress !== undefined && (\n            <ProgressBar\n              animated\n              now={progress}\n              label={`${progress.toFixed(0)}%`}\n            />\n          )}\n        </Form.Group>\n      );\n    }\n\n    if (batchJobID !== undefined) {\n      return (\n        <Form.Group className=\"px-4\">\n          <h5>\n            <FormattedMessage id=\"performer_tagger.status_tagging_job_queued\" />\n          </h5>\n        </Form.Group>\n      );\n    }\n  }\n\n  if (selectedEndpointIndex === -1 || !selectedEndpoint) {\n    return (\n      <div className=\"my-4\">\n        <h3 className=\"text-center mt-4\">\n          <FormattedMessage id=\"performer_tagger.to_use_the_performer_tagger\" />\n        </h3>\n        <h5 className=\"text-center\">\n          <FormattedMessage\n            id=\"refer_to\"\n            values={{\n              link: (\n                <HashLink\n                  to=\"/settings?tab=metadata-providers#stash-boxes\"\n                  scroll={(el) =>\n                    el.scrollIntoView({ behavior: \"smooth\", block: \"center\" })\n                  }\n                >\n                  <FormattedMessage id=\"config.stashbox.title\" />\n                </HashLink>\n              ),\n            }}\n          />\n        </h5>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {renderStatus()}\n      <div className=\"tagger-container mx-md-auto\">\n        <div className=\"tagger-container-header\">\n          <div className=\"d-flex justify-content-between align-items-center flex-wrap\">\n            <div className=\"w-auto\">\n              <StashBoxSelectorField\n                stashBoxes={stashConfig?.general.stashBoxes ?? []}\n                selectedEndpoint={selectedEndpoint.endpoint}\n                onEndpointChange={(endpoint) =>\n                  setConfig({ ...config, selectedEndpoint: endpoint })\n                }\n              />\n            </div>\n            <div className=\"d-flex\">\n              <div className=\"ml-2\">\n                <ConfigButton\n                  showConfig={showConfig}\n                  onClick={() => setShowConfig(!showConfig)}\n                />\n              </div>\n            </div>\n          </div>\n\n          <TaggerConfig\n            show={showConfig}\n            excludedFields={config.excludedPerformerFields ?? []}\n            onFieldsChange={(fields) =>\n              setConfig({ ...config, excludedPerformerFields: fields })\n            }\n            fields={PERFORMER_FIELDS}\n            entityName=\"performers\"\n          />\n        </div>\n\n        <PerformerTaggerList\n          performers={performers}\n          selectedEndpoint={{\n            endpoint: selectedEndpoint.endpoint,\n            index: selectedEndpointIndex,\n          }}\n          isIdle={batchJobID === undefined}\n          config={config}\n          onBatchAdd={batchAdd}\n          onBatchUpdate={batchUpdate}\n        />\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useUpdatePerformer } from \"../queries\";\nimport PerformerModal from \"../PerformerModal\";\nimport { faTags } from \"@fortawesome/free-solid-svg-icons\";\nimport { mergeStashIDs } from \"src/utils/stashbox\";\n\ninterface IStashSearchResultProps {\n  performer: GQL.SlimPerformerDataFragment;\n  stashboxPerformers: GQL.ScrapedPerformerDataFragment[];\n  endpoint: string;\n  onPerformerTagged: (\n    performer: Pick<GQL.SlimPerformerDataFragment, \"id\"> &\n      Partial<Omit<GQL.SlimPerformerDataFragment, \"id\">>\n  ) => void;\n  excludedPerformerFields: string[];\n}\n\n// #4596 - remove any duplicate aliases or aliases that are the same as the performer's name\nfunction cleanAliases(currentName: string, aliases: string[]) {\n  const ret: string[] = [];\n  aliases.forEach((alias) => {\n    if (\n      alias.toLowerCase() !== currentName.toLowerCase() &&\n      !ret.find((r) => r.toLowerCase() === alias.toLowerCase())\n    ) {\n      ret.push(alias);\n    }\n  });\n\n  return ret;\n}\n\nconst StashSearchResult: React.FC<IStashSearchResultProps> = ({\n  performer,\n  stashboxPerformers,\n  onPerformerTagged,\n  excludedPerformerFields,\n  endpoint,\n}) => {\n  const [modalPerformer, setModalPerformer] =\n    useState<GQL.ScrapedPerformerDataFragment>();\n  const [saveState, setSaveState] = useState<string>(\"\");\n  const [error, setError] = useState<{ message?: string; details?: string }>(\n    {}\n  );\n\n  const updatePerformer = useUpdatePerformer();\n\n  const handleSave = async (input: GQL.PerformerCreateInput) => {\n    setError({});\n    setSaveState(\"Saving performer\");\n    setModalPerformer(undefined);\n\n    if (input.stash_ids?.length) {\n      input.stash_ids = mergeStashIDs(performer.stash_ids, input.stash_ids);\n    }\n\n    if (input.alias_list) {\n      input.alias_list = cleanAliases(performer.name, input.alias_list);\n    }\n\n    const updateData: GQL.PerformerUpdateInput = {\n      ...input,\n      id: performer.id,\n    };\n\n    const res = await updatePerformer(updateData);\n\n    if (!res?.data?.performerUpdate)\n      setError({\n        message: `Failed to save performer \"${performer.name}\"`,\n        details:\n          res?.errors?.[0].message ===\n          \"UNIQUE constraint failed: performers.name\"\n            ? \"Name already exists\"\n            : res?.errors?.[0].message,\n      });\n    else onPerformerTagged(performer);\n    setSaveState(\"\");\n  };\n\n  const performers = stashboxPerformers.map((p) => (\n    <Button\n      className=\"PerformerTagger-performer-search-item minimal col-6\"\n      variant=\"link\"\n      key={p.remote_site_id}\n      onClick={() => setModalPerformer(p)}\n    >\n      <img src={(p.images ?? [])[0]} alt=\"\" className=\"PerformerTagger-thumb\" />\n      <span>\n        {p.name}\n        {p.disambiguation && ` (${p.disambiguation})`}\n      </span>\n    </Button>\n  ));\n\n  return (\n    <>\n      {modalPerformer && (\n        <PerformerModal\n          closeModal={() => setModalPerformer(undefined)}\n          modalVisible={modalPerformer !== undefined}\n          performer={modalPerformer}\n          onSave={handleSave}\n          icon={faTags}\n          header=\"Update Performer\"\n          excludedPerformerFields={excludedPerformerFields}\n          endpoint={endpoint}\n        />\n      )}\n      <div className=\"PerformerTagger-performer-search\">{performers}</div>\n      <div className=\"row no-gutters mt-2 align-items-center justify-content-end\">\n        {error.message && (\n          <div className=\"text-right text-danger mt-1\">\n            <strong>\n              <span className=\"mr-2\">Error:</span>\n              {error.message}\n            </strong>\n            <div>{error.details}</div>\n          </div>\n        )}\n        {saveState && (\n          <strong className=\"col-4 mt-1 mr-2 text-right\">{saveState}</strong>\n        )}\n      </div>\n    </>\n  );\n};\n\nexport default StashSearchResult;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/queries.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\nimport {\n  evictQueries,\n  getClient,\n  studioMutationImpactedQueries,\n} from \"src/core/StashService\";\n\nexport const useUpdatePerformer = () => {\n  const [updatePerformer] = GQL.usePerformerUpdateMutation({\n    onError: (errors) => errors,\n    errorPolicy: \"all\",\n  });\n\n  const updatePerformerHandler = (input: GQL.PerformerUpdateInput) =>\n    updatePerformer({\n      variables: {\n        input,\n      },\n      update: (store, updatedPerformer) => {\n        if (!updatedPerformer.data?.performerUpdate) return;\n\n        updatedPerformer.data.performerUpdate.stash_ids.forEach((id) => {\n          store.writeQuery<\n            GQL.FindPerformersQuery,\n            GQL.FindPerformersQueryVariables\n          >({\n            query: GQL.FindPerformersDocument,\n            variables: {\n              performer_filter: {\n                stash_id_endpoint: {\n                  stash_id: id.stash_id,\n                  endpoint: id.endpoint,\n                  modifier: GQL.CriterionModifier.Equals,\n                },\n              },\n            },\n            data: {\n              findPerformers: {\n                count: 1,\n                performers: [updatedPerformer.data!.performerUpdate!],\n                __typename: \"FindPerformersResultType\",\n              },\n            },\n          });\n        });\n      },\n    });\n\n  return updatePerformerHandler;\n};\n\nexport const useUpdateStudio = () => {\n  const [updateStudio] = GQL.useStudioUpdateMutation({\n    onError: (errors) => errors,\n    errorPolicy: \"all\",\n  });\n\n  const updateStudioHandler = (input: GQL.StudioUpdateInput) =>\n    updateStudio({\n      variables: {\n        input,\n      },\n      update: (store, updatedStudio) => {\n        if (!updatedStudio.data?.studioUpdate) return;\n\n        if (updatedStudio.data?.studioUpdate?.parent_studio) {\n          const ac = getClient();\n          evictQueries(ac.cache, studioMutationImpactedQueries);\n        } else {\n          updatedStudio.data.studioUpdate.stash_ids.forEach((id) => {\n            store.writeQuery<\n              GQL.FindStudiosQuery,\n              GQL.FindStudiosQueryVariables\n            >({\n              query: GQL.FindStudiosDocument,\n              variables: {\n                studio_filter: {\n                  stash_id_endpoint: {\n                    stash_id: id.stash_id,\n                    endpoint: id.endpoint,\n                    modifier: GQL.CriterionModifier.Equals,\n                  },\n                },\n              },\n              data: {\n                findStudios: {\n                  count: 1,\n                  studios: [updatedStudio.data!.studioUpdate!],\n                  __typename: \"FindStudiosResultType\",\n                },\n              },\n            });\n          });\n        }\n      },\n    });\n\n  return updateStudioHandler;\n};\n\nexport const useUpdateTag = () => {\n  const [updateTag] = GQL.useTagUpdateMutation({\n    onError: (errors) => errors,\n    errorPolicy: \"all\",\n  });\n\n  const updateTagHandler = (input: GQL.TagUpdateInput) =>\n    updateTag({\n      variables: {\n        input,\n      },\n      update: (store, updatedTag) => {\n        if (!updatedTag.data?.tagUpdate) return;\n\n        updatedTag.data.tagUpdate.stash_ids.forEach((id) => {\n          store.writeQuery<GQL.FindTagsQuery, GQL.FindTagsQueryVariables>({\n            query: GQL.FindTagsDocument,\n            variables: {\n              tag_filter: {\n                stash_id_endpoint: {\n                  stash_id: id.stash_id,\n                  endpoint: id.endpoint,\n                  modifier: GQL.CriterionModifier.Equals,\n                },\n              },\n            },\n            data: {\n              findTags: {\n                count: 1,\n                tags: [updatedTag.data!.tagUpdate!],\n                __typename: \"FindTagsResultType\",\n              },\n            },\n          });\n        });\n      },\n    });\n\n  return updateTagHandler;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/Config.tsx",
    "content": "import { faTimes } from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useContext, useState } from \"react\";\nimport {\n  Badge,\n  Button,\n  Card,\n  Collapse,\n  Form,\n  InputGroup,\n} from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\n\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { ParseMode, TagOperation } from \"../constants\";\nimport { TaggerStateContext } from \"../context\";\nimport { GenderEnum } from \"src/core/generated-graphql\";\nimport { genderList } from \"src/utils/gender\";\n\nconst Blacklist: React.FC<{\n  list: string[];\n  setList: (blacklist: string[]) => void;\n}> = ({ list, setList }) => {\n  const intl = useIntl();\n\n  const [currentValue, setCurrentValue] = useState(\"\");\n  const [error, setError] = useState<string>();\n\n  function addBlacklistItem() {\n    if (!currentValue) return;\n\n    // don't add duplicate items\n    if (list.includes(currentValue)) {\n      setError(\n        intl.formatMessage({\n          id: \"component_tagger.config.errors.blacklist_duplicate\",\n        })\n      );\n      return;\n    }\n\n    // validate regex\n    try {\n      new RegExp(currentValue);\n    } catch (e) {\n      setError((e as SyntaxError).message);\n      return;\n    }\n\n    setList([...list, currentValue]);\n\n    setCurrentValue(\"\");\n  }\n\n  function removeBlacklistItem(index: number) {\n    const newBlacklist = [...list];\n    newBlacklist.splice(index, 1);\n    setList(newBlacklist);\n  }\n\n  return (\n    <div>\n      <h5>\n        <FormattedMessage id=\"component_tagger.config.blacklist_label\" />\n      </h5>\n      <Form.Group>\n        <InputGroup hasValidation>\n          <Form.Control\n            className=\"text-input\"\n            value={currentValue}\n            onChange={(e) => {\n              setCurrentValue(e.currentTarget.value);\n              setError(undefined);\n            }}\n            onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {\n              if (e.key === \"Enter\") {\n                addBlacklistItem();\n                e.preventDefault();\n              }\n            }}\n            isInvalid={!!error}\n          />\n          <InputGroup.Append>\n            <Button onClick={() => addBlacklistItem()}>\n              <FormattedMessage id=\"actions.add\" />\n            </Button>\n          </InputGroup.Append>\n          <Form.Control.Feedback type=\"invalid\">{error}</Form.Control.Feedback>\n        </InputGroup>\n      </Form.Group>\n      <div>\n        {intl.formatMessage(\n          { id: \"component_tagger.config.blacklist_desc\" },\n          { chars_require_escape: <code>[\\^$.|?*+()</code> }\n        )}\n      </div>\n      {list.map((item, index) => (\n        <Badge\n          className=\"tag-item d-inline-block\"\n          variant=\"secondary\"\n          key={item}\n        >\n          {item.toString()}\n          <Button\n            className=\"minimal ml-2\"\n            onClick={() => removeBlacklistItem(index)}\n          >\n            <Icon icon={faTimes} />\n          </Button>\n        </Badge>\n      ))}\n    </div>\n  );\n};\n\ninterface IConfigProps {\n  show: boolean;\n}\n\nconst Config: React.FC<IConfigProps> = ({ show }) => {\n  const { config, setConfig } = useContext(TaggerStateContext);\n  const intl = useIntl();\n\n  function renderGenderCheckbox(gender: GenderEnum) {\n    const performerGenders = config.performerGenders || genderList.slice();\n    return (\n      <Form.Check\n        id={`gender-${gender}`}\n        key={gender}\n        label={<FormattedMessage id={`gender_types.${gender}`} />}\n        checked={performerGenders.includes(gender)}\n        onChange={(e) => {\n          const isChecked = e.currentTarget.checked;\n          setConfig({\n            ...config,\n            performerGenders: isChecked\n              ? [...performerGenders, gender]\n              : performerGenders.filter((g) => g !== gender),\n          });\n        }}\n      />\n    );\n  }\n\n  return (\n    <Collapse in={show}>\n      <Card>\n        <div className=\"row\">\n          <h4 className=\"col-12\">\n            <FormattedMessage id=\"configuration\" />\n          </h4>\n          <hr className=\"w-100\" />\n          <Form className=\"col-md-6\">\n            <Form.Group\n              controlId=\"performer-genders\"\n              className=\"align-items-center\"\n            >\n              <Form.Label>\n                <FormattedMessage id=\"component_tagger.config.performer_genders.heading\" />\n              </Form.Label>\n              {genderList.map(renderGenderCheckbox)}\n              <Form.Text>\n                <FormattedMessage id=\"component_tagger.config.performer_genders.description\" />\n              </Form.Text>\n            </Form.Group>\n            <Form.Group controlId=\"set-cover\" className=\"align-items-center\">\n              <Form.Check\n                label={\n                  <FormattedMessage id=\"component_tagger.config.set_cover_label\" />\n                }\n                checked={config.setCoverImage}\n                onChange={(e) =>\n                  setConfig({\n                    ...config,\n                    setCoverImage: e.currentTarget.checked,\n                  })\n                }\n              />\n              <Form.Text>\n                <FormattedMessage id=\"component_tagger.config.set_cover_desc\" />\n              </Form.Text>\n            </Form.Group>\n            <Form.Group className=\"align-items-center\">\n              <div className=\"d-flex align-items-center\">\n                <Form.Check\n                  id=\"tag-mode\"\n                  label={\n                    <FormattedMessage id=\"component_tagger.config.set_tag_label\" />\n                  }\n                  className=\"mr-4\"\n                  checked={config.setTags}\n                  onChange={(e) =>\n                    setConfig({ ...config, setTags: e.currentTarget.checked })\n                  }\n                />\n                <Form.Control\n                  id=\"tag-operation\"\n                  className=\"col-md-2 col-3 input-control\"\n                  as=\"select\"\n                  value={config.tagOperation}\n                  onChange={(e) =>\n                    setConfig({\n                      ...config,\n                      tagOperation: e.currentTarget.value as TagOperation,\n                    })\n                  }\n                  disabled={!config.setTags}\n                >\n                  <option value=\"merge\">\n                    {intl.formatMessage({ id: \"actions.merge\" })}\n                  </option>\n                  <option value=\"overwrite\">\n                    {intl.formatMessage({ id: \"actions.overwrite\" })}\n                  </option>\n                </Form.Control>\n              </div>\n              <Form.Text>\n                <FormattedMessage id=\"component_tagger.config.set_tag_desc\" />\n              </Form.Text>\n            </Form.Group>\n\n            <Form.Group controlId=\"mode-select\">\n              <div className=\"row no-gutters\">\n                <Form.Label className=\"mr-4 mt-1\">\n                  <FormattedMessage id=\"component_tagger.config.query_mode_label\" />\n                  :\n                </Form.Label>\n                <Form.Control\n                  as=\"select\"\n                  className=\"col-md-2 col-3 input-control\"\n                  value={config.mode}\n                  onChange={(e) =>\n                    setConfig({\n                      ...config,\n                      mode: e.currentTarget.value as ParseMode,\n                    })\n                  }\n                >\n                  <option value=\"auto\">\n                    {intl.formatMessage({\n                      id: \"component_tagger.config.query_mode_auto\",\n                    })}\n                  </option>\n                  <option value=\"filename\">\n                    {intl.formatMessage({\n                      id: \"component_tagger.config.query_mode_filename\",\n                    })}\n                  </option>\n                  <option value=\"dir\">\n                    {intl.formatMessage({\n                      id: \"component_tagger.config.query_mode_dir\",\n                    })}\n                  </option>\n                  <option value=\"path\">\n                    {intl.formatMessage({\n                      id: \"component_tagger.config.query_mode_path\",\n                    })}\n                  </option>\n                  <option value=\"metadata\">\n                    {intl.formatMessage({\n                      id: \"component_tagger.config.query_mode_metadata\",\n                    })}\n                  </option>\n                </Form.Control>\n              </div>\n              <Form.Text>\n                {intl.formatMessage({\n                  id: `component_tagger.config.query_mode_${config.mode}_desc`,\n                  defaultMessage: \"Unknown query mode\",\n                })}\n              </Form.Text>\n            </Form.Group>\n            <Form.Group controlId=\"toggle-organized\">\n              <Form.Check\n                label={\n                  <FormattedMessage id=\"component_tagger.config.mark_organized_label\" />\n                }\n                checked={config.markSceneAsOrganizedOnSave}\n                onChange={(e) =>\n                  setConfig({\n                    ...config,\n                    markSceneAsOrganizedOnSave: e.currentTarget.checked,\n                  })\n                }\n              />\n              <Form.Text>\n                <FormattedMessage id=\"component_tagger.config.mark_organized_desc\" />\n              </Form.Text>\n            </Form.Group>\n          </Form>\n          <div className=\"col-md-6\">\n            <Blacklist\n              list={config.blacklist}\n              setList={(blacklist) => setConfig({ ...config, blacklist })}\n            />\n          </div>\n        </div>\n      </Card>\n    </Collapse>\n  );\n};\n\nexport default Config;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { OptionalField } from \"../IncludeButton\";\nimport {\n  Performer,\n  PerformerSelect,\n} from \"src/components/Performers/PerformerSelect\";\nimport { getStashboxBase } from \"src/utils/stashbox\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\nimport { Link } from \"react-router-dom\";\nimport { LinkButton } from \"../LinkButton\";\n\nconst PerformerLink: React.FC<{\n  performer: GQL.ScrapedPerformer | Performer;\n  url: string | undefined;\n  internal?: boolean;\n}> = ({ performer, url, internal = false }) => {\n  const name = useMemo(() => {\n    if (!url) return performer.name;\n\n    return internal ? (\n      <Link to={url} target=\"_blank\">\n        {performer.name}\n      </Link>\n    ) : (\n      <ExternalLink href={url}>{performer.name}</ExternalLink>\n    );\n  }, [url, performer.name, internal]);\n\n  return (\n    <>\n      <span>{name}</span>\n      {performer.disambiguation && (\n        <span className=\"performer-disambiguation\">\n          {` (${performer.disambiguation})`}\n        </span>\n      )}\n    </>\n  );\n};\n\ninterface IPerformerResultProps {\n  performer: GQL.ScrapedPerformer;\n  selectedID: string | undefined;\n  setSelectedID: (id: string | undefined) => void;\n  onCreate: () => void;\n  onLink?: () => Promise<void>;\n  endpoint?: string;\n  ageFromDate?: string | null;\n}\n\nconst PerformerResult: React.FC<IPerformerResultProps> = ({\n  performer,\n  selectedID,\n  setSelectedID,\n  onCreate,\n  onLink,\n  endpoint,\n  ageFromDate,\n}) => {\n  const { data: performerData, loading: stashLoading } =\n    GQL.useFindPerformerQuery({\n      variables: { id: performer.stored_id ?? \"\" },\n      skip: !performer.stored_id,\n    });\n\n  const matchedPerformer = performerData?.findPerformer;\n  const matchedStashID = matchedPerformer?.stash_ids.some(\n    (stashID) =>\n      stashID.endpoint === endpoint &&\n      stashID.stash_id === performer.remote_site_id\n  );\n\n  const [selectedPerformer, setSelectedPerformer] = useState<Performer>();\n\n  const stashboxPerformerPrefix = endpoint\n    ? `${getStashboxBase(endpoint)}performers/`\n    : undefined;\n  const performerURLPrefix = \"/performers/\";\n\n  function selectPerformer(selected: Performer | undefined) {\n    setSelectedPerformer(selected);\n    setSelectedID(selected?.id);\n  }\n\n  useEffect(() => {\n    if (\n      performerData?.findPerformer &&\n      selectedID === performerData?.findPerformer?.id\n    ) {\n      setSelectedPerformer(performerData.findPerformer);\n    }\n  }, [performerData?.findPerformer, selectedID]);\n\n  const handleSelect = (performers: Performer[]) => {\n    if (performers.length) {\n      selectPerformer(performers[0]);\n    } else {\n      selectPerformer(undefined);\n    }\n  };\n\n  const handleSkip = () => {\n    selectPerformer(undefined);\n  };\n\n  if (stashLoading) return <div>Loading performer</div>;\n\n  if (matchedPerformer && matchedStashID) {\n    return (\n      <div className=\"row no-gutters my-2\">\n        <div className=\"entity-name\">\n          <FormattedMessage id=\"countables.performers\" values={{ count: 1 }} />:\n          <b className=\"ml-2\">\n            <PerformerLink\n              performer={performer}\n              url={`${stashboxPerformerPrefix}${performer.remote_site_id}`}\n            />\n          </b>\n        </div>\n        <span className=\"ml-auto\">\n          <OptionalField\n            exclude={selectedID === undefined}\n            setExclude={(v) =>\n              v ? handleSkip() : setSelectedID(matchedPerformer.id)\n            }\n          >\n            <div>\n              <span className=\"mr-2\">\n                <FormattedMessage id=\"component_tagger.verb_matched\" />:\n              </span>\n              <b className=\"col-3 text-right\">\n                <PerformerLink\n                  performer={matchedPerformer}\n                  url={`${performerURLPrefix}${matchedPerformer.id}`}\n                  internal\n                />\n              </b>\n            </div>\n          </OptionalField>\n        </span>\n      </div>\n    );\n  }\n\n  const selectedSource = !selectedID ? \"skip\" : \"existing\";\n\n  const safeBuildPerformerScraperLink = (id: string | null | undefined) => {\n    return stashboxPerformerPrefix && id\n      ? `${stashboxPerformerPrefix}${id}`\n      : undefined;\n  };\n\n  return (\n    <div className=\"row no-gutters align-items-center mt-2\">\n      <div className=\"entity-name\">\n        <FormattedMessage id=\"countables.performers\" values={{ count: 1 }} />:\n        <b className=\"ml-2\">\n          <PerformerLink\n            performer={performer}\n            url={safeBuildPerformerScraperLink(performer.remote_site_id)}\n          />\n        </b>\n      </div>\n      <ButtonGroup>\n        <Button variant=\"secondary\" onClick={() => onCreate()}>\n          <FormattedMessage id=\"actions.create\" />\n        </Button>\n        <Button\n          variant={selectedSource === \"skip\" ? \"primary\" : \"secondary\"}\n          onClick={() => handleSkip()}\n        >\n          <FormattedMessage id=\"actions.skip\" />\n        </Button>\n        <PerformerSelect\n          values={selectedPerformer ? [selectedPerformer] : []}\n          onSelect={handleSelect}\n          active={selectedSource === \"existing\"}\n          isClearable={false}\n          ageFromDate={ageFromDate}\n        />\n        {endpoint && onLink && (\n          <LinkButton disabled={selectedID === undefined} onLink={onLink} />\n        )}\n      </ButtonGroup>\n    </div>\n  );\n};\n\nexport default PerformerResult;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx",
    "content": "import React, { useContext, useMemo, useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SceneQueue } from \"src/models/sceneQueue\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\n\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { OperationButton } from \"src/components/Shared/OperationButton\";\nimport { ISceneQueryResult, TaggerStateContext } from \"../context\";\nimport Config from \"./Config\";\nimport { TaggerScene } from \"./TaggerScene\";\nimport { SceneTaggerModals } from \"./sceneTaggerModals\";\nimport { SceneSearchResults } from \"./StashSearchResult\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useLightbox } from \"src/hooks/Lightbox/hooks\";\nimport { ConfigButton } from \"../TaggerConfig\";\n\nconst Scene: React.FC<{\n  scene: GQL.SlimSceneDataFragment;\n  searchResult?: ISceneQueryResult;\n  queue?: SceneQueue;\n  index: number;\n  showLightboxImage: (imagePath: string) => void;\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n}> = ({\n  scene,\n  searchResult,\n  queue,\n  index,\n  showLightboxImage,\n  selected,\n  onSelectedChanged,\n}) => {\n  const intl = useIntl();\n  const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } =\n    useContext(TaggerStateContext);\n  const { configuration } = useConfigurationContext();\n\n  const cont = configuration?.interface.continuePlaylistDefault ?? false;\n\n  const sceneLink = useMemo(\n    () =>\n      queue\n        ? queue.makeLink(scene.id, { sceneIndex: index, continue: cont })\n        : `/scenes/${scene.id}`,\n    [queue, scene.id, index, cont]\n  );\n\n  const errorMessage = useMemo(() => {\n    if (searchResult?.error) {\n      return searchResult.error;\n    } else if (searchResult && searchResult.results?.length === 0) {\n      return intl.formatMessage({\n        id: \"component_tagger.results.match_failed_no_result\",\n      });\n    }\n  }, [intl, searchResult]);\n\n  return (\n    <TaggerScene\n      loading={loading}\n      scene={scene}\n      url={sceneLink}\n      errorMessage={errorMessage}\n      doSceneQuery={\n        currentSource?.supportSceneQuery\n          ? async (v) => {\n              await doSceneQuery(scene.id, v);\n            }\n          : undefined\n      }\n      scrapeSceneFragment={\n        currentSource?.supportSceneFragment\n          ? async () => {\n              await doSceneFragmentScrape(scene.id);\n            }\n          : undefined\n      }\n      showLightboxImage={showLightboxImage}\n      queue={queue}\n      index={index}\n      selected={selected}\n      onSelectedChanged={onSelectedChanged}\n    >\n      {searchResult && searchResult.results?.length ? (\n        <SceneSearchResults scenes={searchResult.results} target={scene} />\n      ) : undefined}\n    </TaggerScene>\n  );\n};\n\ninterface ITaggerProps {\n  scenes: GQL.SlimSceneDataFragment[];\n  queue?: SceneQueue;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nexport const Tagger: React.FC<ITaggerProps> = ({\n  scenes,\n  queue,\n  selectedIds,\n  onSelectChange,\n}) => {\n  const {\n    sources,\n    setCurrentSource,\n    currentSource,\n    doMultiSceneFragmentScrape,\n    stopMultiScrape,\n    searchResults,\n    loading,\n    loadingMulti,\n    multiError,\n    submitFingerprints,\n    pendingFingerprints,\n  } = useContext(TaggerStateContext);\n  const [showConfig, setShowConfig] = useState(false);\n  const [hideUnmatched, setHideUnmatched] = useState(false);\n\n  const intl = useIntl();\n\n  const hasSelection = selectedIds.size > 0;\n\n  function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {\n    setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value));\n  }\n\n  function renderSourceSelector() {\n    return (\n      <Form.Group controlId=\"scraper\">\n        <Form.Label>\n          <FormattedMessage id=\"component_tagger.config.source\" />\n        </Form.Label>\n        <div>\n          <Form.Control\n            as=\"select\"\n            value={currentSource?.id}\n            className=\"input-control\"\n            disabled={loading || !sources.length}\n            onChange={handleSourceSelect}\n          >\n            {!sources.length && <option>No scraper sources</option>}\n            {sources.map((i) => (\n              <option value={i.id} key={i.id}>\n                {i.displayName}\n              </option>\n            ))}\n          </Form.Control>\n        </div>\n      </Form.Group>\n    );\n  }\n\n  const [spriteImage, setSpriteImage] = useState<string | null>(null);\n  const lightboxImage = useMemo(\n    () => [{ paths: { thumbnail: spriteImage, image: spriteImage } }],\n    [spriteImage]\n  );\n  const showLightbox = useLightbox({\n    images: lightboxImage,\n  });\n  function showLightboxImage(imagePath: string) {\n    setSpriteImage(imagePath);\n    showLightbox({ images: lightboxImage });\n  }\n\n  const filteredScenes = useMemo(\n    () =>\n      !hideUnmatched\n        ? scenes\n        : scenes.filter((s) => searchResults[s.id]?.results?.length),\n    [scenes, searchResults, hideUnmatched]\n  );\n\n  const toggleHideUnmatchedScenes = () => {\n    setHideUnmatched(!hideUnmatched);\n  };\n\n  function maybeRenderShowHideUnmatchedButton() {\n    if (Object.keys(searchResults).length) {\n      return (\n        <Button onClick={toggleHideUnmatchedScenes}>\n          <FormattedMessage\n            id=\"component_tagger.verb_toggle_unmatched\"\n            values={{\n              toggle: (\n                <FormattedMessage\n                  id={`actions.${!hideUnmatched ? \"hide\" : \"show\"}`}\n                />\n              ),\n            }}\n          />\n        </Button>\n      );\n    }\n  }\n\n  function maybeRenderSubmitFingerprintsButton() {\n    if (pendingFingerprints.length) {\n      return (\n        <OperationButton\n          className=\"ml-1\"\n          operation={submitFingerprints}\n          disabled={loading || loadingMulti}\n        >\n          <span>\n            <FormattedMessage\n              id=\"component_tagger.verb_submit_fp\"\n              values={{ fpCount: pendingFingerprints.length }}\n            />\n          </span>\n        </OperationButton>\n      );\n    }\n  }\n\n  function renderFragmentScrapeButton() {\n    if (!currentSource?.supportSceneFragment) {\n      return;\n    }\n\n    // Use selected scenes if any, otherwise all scenes\n    const scenesToScrape = hasSelection\n      ? scenes.filter((s) => selectedIds.has(s.id))\n      : scenes;\n\n    if (scenesToScrape.length === 0) {\n      return;\n    }\n\n    if (loadingMulti) {\n      return (\n        <Button\n          className=\"ml-1\"\n          variant=\"danger\"\n          onClick={() => {\n            stopMultiScrape();\n          }}\n        >\n          <LoadingIndicator message=\"\" inline small />\n          <span className=\"ml-2\">\n            {intl.formatMessage({ id: \"actions.stop\" })}\n          </span>\n        </Button>\n      );\n    }\n\n    // Change button text based on selection state\n    const buttonTextId = hasSelection\n      ? \"component_tagger.verb_scrape_selected\"\n      : \"component_tagger.verb_scrape_all\";\n\n    return (\n      <div className=\"ml-1\">\n        <OperationButton\n          disabled={loading}\n          operation={async () => {\n            await doMultiSceneFragmentScrape(scenesToScrape.map((s) => s.id));\n          }}\n        >\n          {intl.formatMessage({ id: buttonTextId })}\n        </OperationButton>\n        {multiError && (\n          <>\n            <br />\n            <b className=\"text-danger\">{multiError}</b>\n          </>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <SceneTaggerModals>\n      <div className=\"tagger-container mx-md-auto\">\n        <div className=\"tagger-container-header\">\n          <div className=\"d-flex justify-content-between align-items-center flex-wrap\">\n            <div className=\"w-auto\">{renderSourceSelector()}</div>\n            <div className=\"d-flex\">\n              {maybeRenderShowHideUnmatchedButton()}\n              {maybeRenderSubmitFingerprintsButton()}\n              {renderFragmentScrapeButton()}\n              <div className=\"ml-2\">\n                <ConfigButton\n                  showConfig={showConfig}\n                  onClick={() => setShowConfig(!showConfig)}\n                />\n              </div>\n            </div>\n          </div>\n          <Config show={showConfig} />\n        </div>\n        <div>\n          {filteredScenes.map((s, i) => (\n            <Scene\n              key={s.id}\n              scene={s}\n              searchResult={searchResults[s.id]}\n              index={i}\n              showLightboxImage={showLightboxImage}\n              queue={queue}\n              selected={selectedIds.has(s.id)}\n              onSelectedChanged={(selected, shiftKey) =>\n                onSelectChange(s.id, selected, shiftKey)\n              }\n            />\n          ))}\n        </div>\n      </div>\n    </SceneTaggerModals>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx",
    "content": "import React, { useState, useEffect, useCallback, useMemo } from \"react\";\nimport cx from \"classnames\";\nimport { Badge, Button, Col, Form, Row } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport uniq from \"lodash-es/uniq\";\nimport { blobToBase64 } from \"base64-blob\";\nimport { distance } from \"src/utils/hamming\";\nimport { faCheckCircle } from \"@fortawesome/free-regular-svg-icons\";\nimport {\n  faLink,\n  faPlus,\n  faTriangleExclamation,\n  faXmark,\n} from \"@fortawesome/free-solid-svg-icons\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { HoverPopover } from \"src/components/Shared/HoverPopover\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { SuccessIcon } from \"src/components/Shared/SuccessIcon\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { TagSelect } from \"src/components/Shared/Select\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { OperationButton } from \"src/components/Shared/OperationButton\";\nimport * as FormUtils from \"src/utils/form\";\nimport { genderList, stringToGender } from \"src/utils/gender\";\nimport { IScrapedScene, TaggerStateContext } from \"../context\";\nimport { OptionalField } from \"../IncludeButton\";\nimport { SceneTaggerModalsState } from \"./sceneTaggerModals\";\nimport PerformerResult from \"./PerformerResult\";\nimport StudioResult from \"./StudioResult\";\nimport { useInitialState } from \"src/hooks/state\";\nimport { getStashboxBase } from \"src/utils/stashbox\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\nimport { compareScenesForSort } from \"./utils\";\n\nconst getDurationIcon = (matchPercentage: number) => {\n  if (matchPercentage > 65)\n    return (\n      <Icon className=\"SceneTaggerIcon text-success\" icon={faCheckCircle} />\n    );\n  if (matchPercentage > 35)\n    return (\n      <Icon\n        className=\"SceneTaggerIcon text-warning\"\n        icon={faTriangleExclamation}\n      />\n    );\n\n  return <Icon className=\"SceneTaggerIcon text-danger\" icon={faXmark} />;\n};\n\nconst getDurationStatus = (\n  scene: IScrapedScene,\n  stashDuration: number | undefined | null\n) => {\n  if (!stashDuration) return \"\";\n\n  const durations =\n    scene.fingerprints\n      ?.map((f) => f.duration)\n      .map((d) => Math.abs(d - stashDuration)) ?? [];\n\n  if (!scene.duration && durations.length === 0) return \"\";\n\n  const matchCount = durations.filter((duration) => duration <= 5).length;\n\n  let match;\n  if (matchCount > 0)\n    match = (\n      <FormattedMessage\n        id=\"component_tagger.results.fp_matches_multi\"\n        values={{ matchCount, durationsLength: durations.length }}\n      />\n    );\n  else if (scene.duration && Math.abs(scene.duration - stashDuration) < 5)\n    match = <FormattedMessage id=\"component_tagger.results.fp_matches\" />;\n\n  const matchPercentage = (matchCount / durations.length) * 100;\n\n  if (match)\n    return (\n      <div className=\"font-weight-bold\">\n        {getDurationIcon(matchPercentage)}\n        {match}\n      </div>\n    );\n\n  let minDiff = Math.min(...durations);\n  if (scene.duration) {\n    minDiff = Math.min(minDiff, Math.abs(scene.duration - stashDuration));\n  }\n\n  return (\n    <FormattedMessage\n      id=\"component_tagger.results.duration_off\"\n      values={{ number: Math.floor(minDiff) }}\n    />\n  );\n};\n\nfunction matchPhashes(\n  scenePhashes: Pick<GQL.Fingerprint, \"type\" | \"value\">[],\n  fingerprints: GQL.StashBoxFingerprint[]\n) {\n  const phashes = fingerprints.filter((f) => f.algorithm === \"PHASH\");\n\n  const matches: { [key: string]: number } = {};\n  phashes.forEach((p) => {\n    let bestMatch = -1;\n    scenePhashes.forEach((fp) => {\n      const d = distance(p.hash, fp.value);\n\n      if (d <= 8 && (bestMatch === -1 || d < bestMatch)) {\n        bestMatch = d;\n      }\n    });\n\n    if (bestMatch !== -1) {\n      matches[p.hash] = bestMatch;\n    }\n  });\n\n  // convert to tuple and sort by distance descending\n  const entries = Object.entries(matches);\n  entries.sort((a, b) => {\n    return a[1] - b[1];\n  });\n\n  return entries;\n}\n\nconst getFingerprintStatus = (\n  scene: IScrapedScene,\n  stashScene: GQL.SlimSceneDataFragment\n) => {\n  const checksumMatch = scene.fingerprints?.some((f) =>\n    stashScene.files.some((ff) =>\n      ff.fingerprints.some(\n        (fp) =>\n          fp.value === f.hash && (fp.type === \"oshash\" || fp.type === \"md5\")\n      )\n    )\n  );\n\n  const allPhashes = stashScene.files.reduce(\n    (pv: Pick<GQL.Fingerprint, \"type\" | \"value\">[], cv) => {\n      return [...pv, ...cv.fingerprints.filter((f) => f.type === \"phash\")];\n    },\n    []\n  );\n\n  const phashMatches = matchPhashes(allPhashes, scene.fingerprints ?? []);\n\n  const phashList = (\n    <div className=\"m-2\">\n      {phashMatches.map((fp: [string, number]) => {\n        const hash = fp[0];\n        const d = fp[1];\n        return (\n          <div key={hash}>\n            <b>{hash}</b>\n            {d === 0 ? \", Exact match\" : `, distance ${d}`}\n          </div>\n        );\n      })}\n    </div>\n  );\n\n  if (checksumMatch || phashMatches.length > 0)\n    return (\n      <div>\n        {phashMatches.length > 0 && (\n          <div className=\"font-weight-bold\">\n            <SuccessIcon className=\"SceneTaggerIcon\" />\n            <HoverPopover\n              placement=\"bottom\"\n              content={phashList}\n              className=\"PHashPopover\"\n            >\n              {phashMatches.length > 1 ? (\n                <FormattedMessage\n                  id=\"component_tagger.results.phash_matches\"\n                  values={{\n                    count: phashMatches.length,\n                  }}\n                />\n              ) : (\n                <FormattedMessage\n                  id=\"component_tagger.results.hash_matches\"\n                  values={{\n                    hash_type: <FormattedMessage id=\"media_info.phash\" />,\n                  }}\n                />\n              )}\n            </HoverPopover>\n          </div>\n        )}\n        {checksumMatch && (\n          <div className=\"font-weight-bold\">\n            <SuccessIcon className=\"mr-2\" />\n            <FormattedMessage\n              id=\"component_tagger.results.hash_matches\"\n              values={{\n                hash_type: <FormattedMessage id=\"media_info.md5\" />,\n              }}\n            />\n          </div>\n        )}\n      </div>\n    );\n};\n\ninterface IStashSearchResultProps {\n  scene: IScrapedScene;\n  stashScene: GQL.SlimSceneDataFragment;\n  index: number;\n  isActive: boolean;\n}\n\nconst StashSearchResult: React.FC<IStashSearchResultProps> = ({\n  scene,\n  stashScene,\n  index,\n  isActive,\n}) => {\n  const intl = useIntl();\n\n  const {\n    config,\n    createNewTag,\n    createNewPerformer,\n    linkPerformer,\n    createNewStudio,\n    updateStudio,\n    linkStudio,\n    updateTag,\n    resolveScene,\n    currentSource,\n    saveScene,\n  } = React.useContext(TaggerStateContext);\n\n  const performerGenders = config.performerGenders || genderList;\n\n  const performers = useMemo(\n    () =>\n      scene.performers?.filter((p) => {\n        const gender = p.gender ? stringToGender(p.gender, true) : undefined;\n        return !gender || performerGenders.includes(gender);\n      }) ?? [],\n    [scene, performerGenders]\n  );\n\n  const { createPerformerModal, createStudioModal, createTagModal } =\n    React.useContext(SceneTaggerModalsState);\n\n  const getInitialTags = useCallback(() => {\n    const stashSceneTags = stashScene.tags.map((t) => t.id);\n    if (!config.setTags) {\n      return stashSceneTags;\n    }\n\n    const { tagOperation } = config;\n\n    const newTags =\n      scene.tags?.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? [];\n\n    if (tagOperation === \"overwrite\") {\n      return newTags;\n    }\n    if (tagOperation === \"merge\") {\n      return uniq(stashSceneTags.concat(newTags));\n    }\n\n    throw new Error(\"unexpected tagOperation\");\n  }, [stashScene, scene, config]);\n\n  const getInitialPerformers = useCallback(() => {\n    return performers.map((p) => p.stored_id ?? undefined);\n  }, [performers]);\n\n  const getInitialStudio = useCallback(() => {\n    return scene.studio?.stored_id ?? stashScene.studio?.id;\n  }, [stashScene, scene]);\n\n  const [loading, setLoading] = useState(false);\n  const [excludedFields, setExcludedFields] = useState<Record<string, boolean>>(\n    {}\n  );\n  const [tagIDs, setTagIDs, setInitialTagIDs] = useInitialState<string[]>(\n    getInitialTags()\n  );\n\n  // map of original performer to id\n  const [performerIDs, setPerformerIDs, setInitialPerformerIDs] =\n    useInitialState<(string | undefined)[]>(getInitialPerformers());\n\n  const [studioID, setStudioID, setInitialStudioID] = useInitialState<\n    string | undefined\n  >(getInitialStudio());\n\n  useEffect(() => {\n    setInitialTagIDs(getInitialTags());\n  }, [getInitialTags, setInitialTagIDs]);\n\n  useEffect(() => {\n    setInitialPerformerIDs(getInitialPerformers());\n  }, [getInitialPerformers, setInitialPerformerIDs]);\n\n  useEffect(() => {\n    setInitialStudioID(getInitialStudio());\n  }, [getInitialStudio, setInitialStudioID]);\n\n  useEffect(() => {\n    async function doResolveScene() {\n      try {\n        setLoading(true);\n        await resolveScene(stashScene.id, index, scene);\n      } finally {\n        setLoading(false);\n      }\n    }\n\n    if (isActive && !loading && !scene.resolved) {\n      doResolveScene();\n    }\n  }, [isActive, loading, stashScene, index, resolveScene, scene]);\n\n  const stashBoxBaseURL = currentSource?.sourceInput.stash_box_endpoint\n    ? getStashboxBase(currentSource.sourceInput.stash_box_endpoint)\n    : undefined;\n  const stashBoxURL = useMemo(() => {\n    if (stashBoxBaseURL) {\n      return `${stashBoxBaseURL}scenes/${scene.remote_site_id}`;\n    }\n  }, [scene, stashBoxBaseURL]);\n\n  const setExcludedField = (name: string, value: boolean) =>\n    setExcludedFields({\n      ...excludedFields,\n      [name]: value,\n    });\n\n  async function handleSave() {\n    const excludedFieldList = Object.keys(excludedFields).filter(\n      (f) => excludedFields[f]\n    );\n\n    function resolveField<T>(field: string, stashField: T, remoteField: T) {\n      // #2452 - don't overwrite fields that are already set if the remote field is empty\n      const remoteFieldIsNull =\n        remoteField === null || remoteField === undefined;\n      if (excludedFieldList.includes(field) || remoteFieldIsNull) {\n        return stashField;\n      }\n\n      return remoteField;\n    }\n\n    let imgData;\n    if (!excludedFields.cover_image && config.setCoverImage) {\n      const imgurl = scene.image;\n      if (imgurl) {\n        const img = await fetch(imgurl, {\n          mode: \"cors\",\n          cache: \"no-store\",\n        });\n        if (img.status === 200) {\n          const blob = await img.blob();\n          // Sanity check on image size since bad images will fail\n          if (blob.size > 10000) imgData = await blobToBase64(blob);\n        }\n      }\n    }\n\n    const filteredPerformerIDs = performerIDs.filter(\n      (id) => id !== undefined\n    ) as string[];\n\n    const sceneCreateInput: GQL.SceneUpdateInput = {\n      id: stashScene.id ?? \"\",\n      title: resolveField(\"title\", stashScene.title, scene.title),\n      details: resolveField(\"details\", stashScene.details, scene.details),\n      date: resolveField(\"date\", stashScene.date, scene.date),\n      performer_ids: uniq(\n        stashScene.performers.map((p) => p.id).concat(filteredPerformerIDs)\n      ),\n      studio_id: studioID,\n      cover_image: resolveField(\"cover_image\", undefined, imgData),\n      tag_ids: tagIDs,\n      stash_ids: stashScene.stash_ids ?? [],\n      code: resolveField(\"code\", stashScene.code, scene.code),\n      director: resolveField(\"director\", stashScene.director, scene.director),\n    };\n\n    const includeUrl = !excludedFieldList.includes(\"url\");\n    if (includeUrl && scene.urls) {\n      sceneCreateInput.urls = uniq(stashScene.urls.concat(scene.urls));\n    } else {\n      sceneCreateInput.urls = stashScene.urls;\n    }\n\n    const includeStashID = !excludedFieldList.includes(\"stash_ids\");\n    if (\n      includeStashID &&\n      currentSource?.sourceInput.stash_box_endpoint &&\n      scene.remote_site_id\n    ) {\n      sceneCreateInput.stash_ids = [\n        ...(stashScene?.stash_ids\n          .map((s) => {\n            return {\n              endpoint: s.endpoint,\n              stash_id: s.stash_id,\n              updated_at: s.updated_at,\n            };\n          })\n          .filter(\n            (s) => s.endpoint !== currentSource.sourceInput.stash_box_endpoint\n          ) ?? []),\n        {\n          endpoint: currentSource.sourceInput.stash_box_endpoint,\n          stash_id: scene.remote_site_id,\n          updated_at: new Date().toISOString(),\n        },\n      ];\n    } else {\n      // #2348 - don't include stash_ids if we're not setting them\n      delete sceneCreateInput.stash_ids;\n    }\n\n    await saveScene(sceneCreateInput, includeStashID);\n  }\n\n  function showPerformerModal(t: GQL.ScrapedPerformer) {\n    createPerformerModal(t, (toCreate) => {\n      if (toCreate) {\n        createNewPerformer(t, toCreate);\n      }\n    });\n  }\n\n  async function onCreateTag(\n    t: GQL.ScrapedTag,\n    createInput?: GQL.TagCreateInput\n  ) {\n    const toCreate: GQL.TagCreateInput = createInput ?? { name: t.name };\n\n    // If the tag has a remote_site_id and we have an endpoint, include the stash_id\n    const endpoint = currentSource?.sourceInput.stash_box_endpoint;\n    if (!createInput && t.remote_site_id && endpoint) {\n      toCreate.stash_ids = [\n        {\n          endpoint: endpoint,\n          stash_id: t.remote_site_id,\n        },\n      ];\n    }\n\n    const newTagID = await createNewTag(t, toCreate);\n    if (newTagID !== undefined) {\n      setTagIDs([...tagIDs, newTagID]);\n    }\n  }\n\n  async function onUpdateTag(\n    t: GQL.ScrapedTag,\n    updateInput: GQL.TagUpdateInput\n  ) {\n    await updateTag(t, updateInput);\n    setTagIDs(uniq([...tagIDs, updateInput.id]));\n  }\n\n  function showTagModal(t: GQL.ScrapedTag) {\n    createTagModal(t, (result) => {\n      if (result.create) {\n        onCreateTag(t, result.create);\n      } else if (result.update) {\n        onUpdateTag(t, result.update);\n      }\n    });\n  }\n\n  async function studioModalCallback(\n    studio: GQL.ScrapedStudio,\n    toCreate?: GQL.StudioCreateInput,\n    parentInput?: GQL.StudioCreateInput\n  ) {\n    if (toCreate) {\n      if (parentInput && studio.parent) {\n        if (toCreate.parent_id) {\n          const parentUpdateData: GQL.StudioUpdateInput = {\n            ...parentInput,\n            id: toCreate.parent_id,\n          };\n          await updateStudio(parentUpdateData);\n        } else {\n          const parentID = await createNewStudio(studio.parent, parentInput);\n          toCreate.parent_id = parentID;\n        }\n      }\n\n      createNewStudio(studio, toCreate);\n    }\n  }\n\n  function showStudioModal(t: GQL.ScrapedStudio) {\n    createStudioModal(t, (toCreate, parentInput) => {\n      studioModalCallback(t, toCreate, parentInput);\n    });\n  }\n\n  // constants to get around dot-notation eslint rule\n  const fields = {\n    cover_image: \"cover_image\",\n    title: \"title\",\n    date: \"date\",\n    url: \"url\",\n    details: \"details\",\n    studio: \"studio\",\n    stash_ids: \"stash_ids\",\n    code: \"code\",\n    director: \"director\",\n  };\n\n  const maybeRenderCoverImage = () => {\n    if (scene.image) {\n      return (\n        <div className=\"scene-image-container\">\n          <OptionalField\n            disabled={!config.setCoverImage}\n            exclude={\n              excludedFields[fields.cover_image] || !config.setCoverImage\n            }\n            setExclude={(v) => setExcludedField(fields.cover_image, v)}\n          >\n            <img\n              src={scene.image}\n              alt=\"\"\n              className=\"align-self-center scene-image\"\n            />\n          </OptionalField>\n        </div>\n      );\n    }\n  };\n\n  const renderTitle = () => {\n    if (!scene.title) {\n      return (\n        <h4 className=\"text-muted\">\n          <FormattedMessage id=\"component_tagger.results.unnamed\" />\n        </h4>\n      );\n    }\n\n    const url = scene.urls?.length ? scene.urls[0] : null;\n\n    const sceneTitleEl = url ? (\n      <ExternalLink className=\"scene-link\" href={url}>\n        <TruncatedText text={scene.title} />\n      </ExternalLink>\n    ) : (\n      <TruncatedText text={scene.title} />\n    );\n\n    return (\n      <h4>\n        <OptionalField\n          exclude={excludedFields[fields.title]}\n          setExclude={(v) => setExcludedField(fields.title, v)}\n        >\n          {sceneTitleEl}\n        </OptionalField>\n      </h4>\n    );\n  };\n\n  function renderStudioDate() {\n    const text =\n      scene.studio && scene.date\n        ? `${scene.studio.name} • ${scene.date}`\n        : `${scene.studio?.name ?? scene.date ?? \"\"}`;\n\n    if (text) {\n      return <h5>{text}</h5>;\n    }\n  }\n\n  const renderPerformerList = () => {\n    if (scene.performers?.length) {\n      return (\n        <div>\n          {intl.formatMessage(\n            { id: \"countables.performers\" },\n            { count: scene?.performers?.length }\n          )}\n          : {scene?.performers?.map((p) => p.name).join(\", \")}\n        </div>\n      );\n    }\n  };\n\n  const maybeRenderStudioCode = () => {\n    if (isActive && scene.code) {\n      return (\n        <h5>\n          <OptionalField\n            exclude={excludedFields[fields.code]}\n            setExclude={(v) => setExcludedField(fields.code, v)}\n          >\n            {scene.code}\n          </OptionalField>\n        </h5>\n      );\n    }\n  };\n\n  const maybeRenderDateField = () => {\n    if (isActive && scene.date) {\n      return (\n        <h5>\n          <OptionalField\n            exclude={excludedFields[fields.date]}\n            setExclude={(v) => setExcludedField(fields.date, v)}\n          >\n            {scene.date}\n          </OptionalField>\n        </h5>\n      );\n    }\n  };\n\n  const maybeRenderDirector = () => {\n    if (scene.director) {\n      return (\n        <h5>\n          <OptionalField\n            exclude={excludedFields[fields.director]}\n            setExclude={(v) => setExcludedField(fields.director, v)}\n          >\n            <FormattedMessage id=\"director\" />: {scene.director}\n          </OptionalField>\n        </h5>\n      );\n    }\n  };\n\n  const maybeRenderURL = () => {\n    if (scene.urls) {\n      return (\n        <div className=\"scene-details\">\n          <OptionalField\n            exclude={excludedFields[fields.url]}\n            setExclude={(v) => setExcludedField(fields.url, v)}\n          >\n            {scene.urls.map((url) => (\n              <div key={url}>\n                <ExternalLink href={url}>{url}</ExternalLink>\n              </div>\n            ))}\n          </OptionalField>\n        </div>\n      );\n    }\n  };\n\n  const maybeRenderDetails = () => {\n    if (scene.details) {\n      return (\n        <div className=\"scene-details\">\n          <OptionalField\n            exclude={excludedFields[fields.details]}\n            setExclude={(v) => setExcludedField(fields.details, v)}\n          >\n            <TruncatedText text={scene.details ?? \"\"} lineCount={3} />\n          </OptionalField>\n        </div>\n      );\n    }\n  };\n\n  const maybeRenderStashBoxID = () => {\n    if (scene.remote_site_id && stashBoxURL) {\n      return (\n        <div className=\"scene-details\">\n          <OptionalField\n            exclude={excludedFields[fields.stash_ids]}\n            setExclude={(v) => setExcludedField(fields.stash_ids, v)}\n          >\n            <ExternalLink href={stashBoxURL}>\n              {scene.remote_site_id}\n            </ExternalLink>\n          </OptionalField>\n        </div>\n      );\n    }\n  };\n\n  const maybeRenderStudioField = () => {\n    if (scene.studio) {\n      return (\n        <div className=\"mt-2\">\n          <StudioResult\n            studio={scene.studio}\n            selectedID={studioID}\n            setSelectedID={(id) => setStudioID(id)}\n            onCreate={() => showStudioModal(scene.studio!)}\n            endpoint={\n              currentSource?.sourceInput.stash_box_endpoint ?? undefined\n            }\n            onLink={async () => {\n              await linkStudio(scene.studio!, studioID!);\n            }}\n          />\n        </div>\n      );\n    }\n  };\n\n  function setPerformerID(performerIndex: number, id: string | undefined) {\n    const newPerformerIDs = [...performerIDs];\n    newPerformerIDs[performerIndex] = id;\n    setPerformerIDs(newPerformerIDs);\n  }\n\n  const renderPerformerField = () => (\n    <div className=\"mt-2\">\n      <div>\n        <Form.Group controlId=\"performers\">\n          {performers.map((performer, performerIndex) => (\n            <PerformerResult\n              performer={performer}\n              selectedID={performerIDs[performerIndex]}\n              setSelectedID={(id) => setPerformerID(performerIndex, id)}\n              onCreate={() => showPerformerModal(performer)}\n              onLink={async () => {\n                await linkPerformer(performer, performerIDs[performerIndex]!);\n              }}\n              endpoint={\n                currentSource?.sourceInput.stash_box_endpoint ?? undefined\n              }\n              key={`${performer.name ?? performer.remote_site_id ?? \"\"}`}\n              ageFromDate={\n                !scene.date || excludedFields.date\n                  ? stashScene.date\n                  : scene.date\n              }\n            />\n          ))}\n        </Form.Group>\n      </div>\n    </div>\n  );\n\n  function maybeRenderTagsField() {\n    if (!config.setTags) return;\n\n    const createTags = scene.tags?.filter((t) => !t.stored_id);\n\n    return (\n      <div className=\"mt-2\">\n        <div>\n          <Form.Group controlId=\"tags\" as={Row}>\n            {FormUtils.renderLabel({\n              title: `${intl.formatMessage({ id: \"tags\" })}:`,\n            })}\n            <Col sm={9} xl={12}>\n              <TagSelect\n                isMulti\n                onSelect={(items) => {\n                  setTagIDs(items.map((i) => i.id));\n                }}\n                ids={tagIDs}\n              />\n            </Col>\n          </Form.Group>\n        </div>\n        {createTags?.map((t) => (\n          <Badge\n            className=\"tag-item\"\n            variant=\"secondary\"\n            key={t.name}\n            onClick={() => {\n              onCreateTag(t);\n            }}\n          >\n            {t.name}\n            <Button\n              className=\"minimal ml-2\"\n              title={intl.formatMessage({ id: \"actions.create\" })}\n            >\n              <Icon className=\"fa-fw\" icon={faPlus} />\n            </Button>\n            <Button\n              className=\"minimal\"\n              onClick={(e) => {\n                showTagModal(t);\n                e.stopPropagation();\n              }}\n              title={intl.formatMessage({\n                id: \"component_tagger.verb_link_existing\",\n              })}\n            >\n              <Icon className=\"fa-fw\" icon={faLink} />\n            </Button>\n          </Badge>\n        ))}\n      </div>\n    );\n  }\n\n  if (loading) {\n    return <LoadingIndicator card />;\n  }\n\n  const stashSceneFile =\n    stashScene.files.length > 0 ? stashScene.files[0] : undefined;\n\n  return (\n    <>\n      <div className={isActive ? \"col-lg-6\" : \"\"}>\n        <div className=\"row mx-0\">\n          {maybeRenderCoverImage()}\n          <div className=\"d-flex flex-column justify-content-center scene-metadata\">\n            {renderTitle()}\n\n            {!isActive && (\n              <>\n                {renderStudioDate()}\n                {renderPerformerList()}\n              </>\n            )}\n\n            {maybeRenderStudioCode()}\n            {maybeRenderDateField()}\n            {getDurationStatus(scene, stashSceneFile?.duration)}\n            {getFingerprintStatus(scene, stashScene)}\n          </div>\n        </div>\n        {isActive && (\n          <div className=\"d-flex flex-column\">\n            {maybeRenderStashBoxID()}\n            {maybeRenderDirector()}\n            {maybeRenderURL()}\n            {maybeRenderDetails()}\n          </div>\n        )}\n      </div>\n      {isActive && (\n        <div className=\"col-lg-6\">\n          {maybeRenderStudioField()}\n          {renderPerformerField()}\n          {maybeRenderTagsField()}\n\n          <div className=\"row no-gutters mt-2 align-items-center justify-content-end\">\n            <OperationButton operation={handleSave}>\n              <FormattedMessage id=\"actions.save\" />\n            </OperationButton>\n          </div>\n        </div>\n      )}\n    </>\n  );\n};\n\nexport interface ISceneSearchResults {\n  target: GQL.SlimSceneDataFragment;\n  scenes: IScrapedScene[];\n}\n\nexport const SceneSearchResults: React.FC<ISceneSearchResults> = ({\n  target,\n  scenes: unsortedScenes,\n}) => {\n  const [selectedResult, setSelectedResult] = useState<number | undefined>();\n\n  const scenes = useMemo(\n    () =>\n      unsortedScenes\n        .slice()\n        .sort((scrapedSceneA, scrapedSceneB) =>\n          compareScenesForSort(target, scrapedSceneA, scrapedSceneB)\n        ),\n    [unsortedScenes, target]\n  );\n\n  useEffect(() => {\n    // #3198 - if the selected result is no longer in the list, reset it\n    if (!selectedResult || scenes?.length <= selectedResult) {\n      if (!scenes) {\n        setSelectedResult(undefined);\n      } else if (scenes.length > 0 && scenes[0].resolved) {\n        setSelectedResult(0);\n      }\n    }\n  }, [scenes, selectedResult]);\n\n  function getClassName(i: number) {\n    return cx(\"row mx-0 mt-2 search-result\", {\n      \"selected-result active\": i === selectedResult,\n    });\n  }\n\n  return (\n    <ul className=\"pl-0 mt-3 mb-0\">\n      {scenes.map((s, i) => (\n        // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key\n        <li\n          // eslint-disable-next-line react/no-array-index-key\n          key={i}\n          onClick={() => setSelectedResult(i)}\n          className={getClassName(i)}\n        >\n          <StashSearchResult\n            index={i}\n            isActive={i === selectedResult}\n            scene={s}\n            stashScene={target}\n          />\n        </li>\n      ))}\n    </ul>\n  );\n};\n\nexport default StashSearchResult;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx",
    "content": "import React, { useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport cx from \"classnames\";\nimport { IconDefinition } from \"@fortawesome/fontawesome-svg-core\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useFindStudio } from \"src/core/StashService\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport {\n  faCheck,\n  faExternalLinkAlt,\n  faTimes,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { excludeFields } from \"src/utils/data\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\n\ninterface IStudioDetailsProps {\n  studio: GQL.ScrapedSceneStudioDataFragment;\n  link?: string;\n  excluded: Record<string, boolean>;\n  toggleField: (field: string) => void;\n  isNew?: boolean;\n}\n\nconst StudioDetails: React.FC<IStudioDetailsProps> = ({\n  studio,\n  link,\n  excluded,\n  toggleField,\n  isNew = false,\n}) => {\n  function maybeRenderImage() {\n    if (!studio.image) return;\n\n    return (\n      <div className=\"row\">\n        <div className=\"col-12 image-selection\">\n          <div className=\"studio-image\">\n            <Button\n              onClick={() => toggleField(\"image\")}\n              variant=\"secondary\"\n              className={cx(\n                \"studio-image-exclude\",\n                excluded.image ? \"text-muted\" : \"text-success\"\n              )}\n            >\n              <Icon icon={excluded.image ? faTimes : faCheck} />\n            </Button>\n            <img src={studio.image} alt=\"\" />\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  function maybeRenderField(\n    id: string,\n    text: string | null | undefined,\n    isSelectable: boolean = true\n  ) {\n    if (!text) return;\n\n    return (\n      <div className=\"row no-gutters\">\n        <div className=\"col-5 studio-create-modal-field\" key={id}>\n          {isSelectable && (\n            <Button\n              onClick={() => toggleField(id)}\n              variant=\"secondary\"\n              className={excluded[id] ? \"text-muted\" : \"text-success\"}\n            >\n              <Icon icon={excluded[id] ? faTimes : faCheck} />\n            </Button>\n          )}\n          <strong>\n            <FormattedMessage id={id} />:\n          </strong>\n        </div>\n        <TruncatedText className=\"col-7\" text={text} />\n      </div>\n    );\n  }\n\n  function maybeRenderURLListField(\n    name: string,\n    text: string[] | null | undefined,\n    truncate: boolean = true\n  ) {\n    if (!text) return;\n\n    return (\n      <div className=\"row no-gutters\">\n        <div className=\"col-5 studio-create-modal-field\" key={name}>\n          {!isNew && (\n            <Button\n              onClick={() => toggleField(name)}\n              variant=\"secondary\"\n              className={excluded[name] ? \"text-muted\" : \"text-success\"}\n            >\n              <Icon icon={excluded[name] ? faTimes : faCheck} />\n            </Button>\n          )}\n          <strong>\n            <FormattedMessage id={name} />:\n          </strong>\n        </div>\n        <div className=\"col-7 studio-create-modal-value\">\n          <ul>\n            {text.map((t, i) => (\n              <li key={i}>\n                <ExternalLink href={t}>\n                  {truncate ? <TruncatedText text={t} /> : t}\n                </ExternalLink>\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n    );\n  }\n\n  function maybeRenderStashBoxLink() {\n    if (!link) return;\n\n    return (\n      <h6 className=\"mt-2\">\n        <ExternalLink href={link}>\n          <FormattedMessage id=\"stashbox.source\" />\n          <Icon icon={faExternalLinkAlt} className=\"ml-2\" />\n        </ExternalLink>\n      </h6>\n    );\n  }\n\n  return (\n    <div>\n      {maybeRenderImage()}\n      <div className=\"row\">\n        <div className=\"col-12\">\n          {maybeRenderField(\"name\", studio.name, !isNew)}\n          {maybeRenderURLListField(\"urls\", studio.urls)}\n          {maybeRenderField(\"details\", studio.details)}\n          {maybeRenderField(\"aliases\", studio.aliases)}\n          {maybeRenderField(\"tags\", studio.tags?.map((t) => t.name).join(\", \"))}\n          {maybeRenderField(\"parent_studio\", studio.parent?.name, false)}\n          {maybeRenderStashBoxLink()}\n        </div>\n      </div>\n    </div>\n  );\n};\n\ninterface IStudioModalProps {\n  studio: GQL.ScrapedSceneStudioDataFragment;\n  modalVisible: boolean;\n  closeModal: () => void;\n  handleStudioCreate: (\n    input: GQL.StudioCreateInput,\n    parent?: GQL.StudioCreateInput\n  ) => void;\n  excludedStudioFields?: string[];\n  header: string;\n  icon: IconDefinition;\n  endpoint?: string;\n}\n\nconst StudioModal: React.FC<IStudioModalProps> = ({\n  modalVisible,\n  studio,\n  handleStudioCreate,\n  closeModal,\n  excludedStudioFields = [],\n  header,\n  icon,\n  endpoint,\n}) => {\n  const intl = useIntl();\n\n  const [excluded, setExcluded] = useState<Record<string, boolean>>(\n    excludedStudioFields.reduce(\n      (dict, field) => ({ ...dict, [field]: true }),\n      {}\n    )\n  );\n  const toggleField = (name: string) =>\n    setExcluded({\n      ...excluded,\n      [name]: !excluded[name],\n    });\n\n  const [parentExcluded, setParentExcluded] = useState<Record<string, boolean>>(\n    excludedStudioFields.reduce(\n      (dict, field) => ({ ...dict, [field]: true }),\n      {}\n    )\n  );\n  const toggleParentField = (name: string) =>\n    setParentExcluded({\n      ...parentExcluded,\n      [name]: !parentExcluded[name],\n    });\n\n  const [createParentStudio, setCreateParentStudio] = useState<boolean>(\n    !!studio.parent\n  );\n\n  let sendParentStudio = true;\n  // The parent studio exists, need to check if it has a Stash ID.\n  const queryResult = useFindStudio(studio.parent?.stored_id ?? \"\");\n  if (\n    queryResult.data?.findStudio?.stash_ids?.length &&\n    queryResult.data?.findStudio?.stash_ids?.length > 0\n  ) {\n    // It already has a Stash ID, so we can skip worrying about it\n    sendParentStudio = false;\n  }\n\n  const parentStudioCreateText = () => {\n    if (studio.parent && studio.parent.stored_id) {\n      return \"actions.assign_stashid_to_parent_studio\";\n    }\n    return \"actions.create_parent_studio\";\n  };\n\n  function onSave() {\n    if (!studio.name) {\n      throw new Error(\"studio name must set\");\n    }\n\n    const studioData: GQL.StudioCreateInput = {\n      name: studio.name,\n      urls: studio.urls,\n      image: studio.image,\n      parent_id: studio.parent?.stored_id,\n      details: studio.details,\n      aliases: studio.aliases\n        ?.split(\",\")\n        .map((a) => a.trim())\n        .filter((a) => a),\n      tag_ids: studio.tags?.map((t) => t.stored_id).filter((id) => id) as\n        | string[]\n        | undefined,\n    };\n\n    // stashid handling code\n    const remoteSiteID = studio.remote_site_id;\n    const timeNow = new Date().toISOString();\n    if (remoteSiteID && endpoint) {\n      studioData.stash_ids = [\n        {\n          endpoint,\n          stash_id: remoteSiteID,\n          updated_at: timeNow,\n        },\n      ];\n    }\n\n    // handle exclusions\n    excludeFields(studioData, excluded);\n\n    let parentData: GQL.StudioCreateInput | undefined = undefined;\n\n    if (createParentStudio && sendParentStudio) {\n      if (!studio.parent?.name) {\n        throw new Error(\"parent studio name must set\");\n      }\n\n      parentData = {\n        name: studio.parent?.name,\n        urls: studio.parent?.urls,\n        image: studio.parent?.image,\n        details: studio.parent?.details,\n        aliases: studio.parent?.aliases\n          ?.split(\",\")\n          .map((a) => a.trim())\n          .filter((a) => a),\n        tag_ids: studio.parent?.tags\n          ?.map((t) => t.stored_id)\n          .filter((id) => id) as string[] | undefined,\n      };\n\n      // stashid handling code\n      const parentRemoteSiteID = studio.parent?.remote_site_id;\n      if (parentRemoteSiteID && endpoint) {\n        parentData.stash_ids = [\n          {\n            endpoint,\n            stash_id: parentRemoteSiteID,\n            updated_at: timeNow,\n          },\n        ];\n      }\n\n      // handle exclusions\n      // Can't exclude parent studio name when creating a new one\n      parentExcluded.name = false;\n      excludeFields(parentData, parentExcluded);\n    }\n\n    handleStudioCreate(studioData, parentData);\n  }\n\n  const base = endpoint?.match(/https?:\\/\\/.*?\\//)?.[0];\n  const link = base ? `${base}studios/${studio.remote_site_id}` : undefined;\n  const parentLink = base\n    ? `${base}studios/${studio.parent?.remote_site_id}`\n    : undefined;\n\n  function maybeRenderParentStudio() {\n    // There is no parent studio or it already has a Stash ID\n    if (!studio.parent || !sendParentStudio) {\n      return;\n    }\n\n    return (\n      <div>\n        <div className=\"mb-4 mt-4\">\n          <Form.Check\n            id=\"create-parent\"\n            checked={createParentStudio}\n            label={intl.formatMessage({\n              id: parentStudioCreateText(),\n            })}\n            onChange={() => setCreateParentStudio(!createParentStudio)}\n          />\n        </div>\n        {maybeRenderParentStudioDetails()}\n      </div>\n    );\n  }\n\n  function maybeRenderParentStudioDetails() {\n    if (!createParentStudio || !studio.parent) {\n      return;\n    }\n\n    return (\n      <StudioDetails\n        studio={studio.parent}\n        excluded={parentExcluded}\n        toggleField={(field) => toggleParentField(field)}\n        link={parentLink}\n        isNew\n      />\n    );\n  }\n\n  return (\n    <ModalComponent\n      show={modalVisible}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.save\" }),\n        onClick: onSave,\n      }}\n      cancel={{ onClick: () => closeModal(), variant: \"secondary\" }}\n      onHide={() => closeModal()}\n      dialogClassName=\"studio-create-modal\"\n      icon={icon}\n      header={header}\n    >\n      <StudioDetails\n        studio={studio}\n        excluded={excluded}\n        toggleField={(field) => toggleField(field)}\n        link={link}\n      />\n\n      {maybeRenderParentStudio()}\n    </ModalComponent>\n  );\n};\n\nexport default StudioModal;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport cx from \"classnames\";\n\nimport { StudioSelect, SelectObject } from \"src/components/Shared/Select\";\nimport * as GQL from \"src/core/generated-graphql\";\n\nimport { OptionalField } from \"../IncludeButton\";\nimport { getStashboxBase } from \"src/utils/stashbox\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\nimport { Link } from \"react-router-dom\";\nimport { LinkButton } from \"../LinkButton\";\n\nconst StudioLink: React.FC<{\n  studio: GQL.ScrapedStudio | GQL.SlimStudioDataFragment;\n  url: string | undefined;\n  internal?: boolean;\n}> = ({ studio, url, internal = false }) => {\n  const name = useMemo(() => {\n    if (!url) return studio.name;\n\n    return internal ? (\n      <Link to={url} target=\"_blank\">\n        {studio.name}\n      </Link>\n    ) : (\n      <ExternalLink href={url}>{studio.name}</ExternalLink>\n    );\n  }, [url, studio.name, internal]);\n\n  return <span>{name}</span>;\n};\n\ninterface IStudioResultProps {\n  studio: GQL.ScrapedStudio;\n  selectedID: string | undefined;\n  setSelectedID: (id: string | undefined) => void;\n  onCreate: () => void;\n  onLink?: () => Promise<void>;\n  endpoint?: string;\n}\n\nconst StudioResult: React.FC<IStudioResultProps> = ({\n  studio,\n  selectedID,\n  setSelectedID,\n  onCreate,\n  onLink,\n  endpoint,\n}) => {\n  const { data: studioData, loading: stashLoading } = GQL.useFindStudioQuery({\n    variables: { id: studio.stored_id ?? \"\" },\n    skip: !studio.stored_id,\n  });\n\n  const matchedStudio = studioData?.findStudio;\n  const matchedStashID = matchedStudio?.stash_ids.some(\n    (stashID) => stashID.endpoint === endpoint && stashID.stash_id\n  );\n\n  const stashboxStudioPrefix = endpoint\n    ? `${getStashboxBase(endpoint)}studios/`\n    : undefined;\n  const studioURLPrefix = \"/studios/\";\n\n  const handleSelect = (studios: SelectObject[]) => {\n    if (studios.length) {\n      setSelectedID(studios[0].id);\n    } else {\n      setSelectedID(undefined);\n    }\n  };\n\n  const handleSkip = () => {\n    setSelectedID(undefined);\n  };\n\n  if (stashLoading) return <div>Loading studio</div>;\n\n  if (matchedStudio && matchedStashID) {\n    return (\n      <div className=\"row no-gutters my-2\">\n        <div className=\"entity-name\">\n          <FormattedMessage id=\"countables.studios\" values={{ count: 1 }} />:\n          <b className=\"ml-2\">\n            <StudioLink\n              studio={studio}\n              url={`${stashboxStudioPrefix}${studio.remote_site_id}`}\n            />\n          </b>\n        </div>\n        <span className=\"ml-auto\">\n          <OptionalField\n            exclude={selectedID === undefined}\n            setExclude={(v) =>\n              v ? handleSkip() : setSelectedID(matchedStudio.id)\n            }\n          >\n            <div>\n              <span className=\"mr-2\">\n                <FormattedMessage id=\"component_tagger.verb_matched\" />:\n              </span>\n              <b className=\"col-3 text-right\">\n                <StudioLink\n                  studio={matchedStudio}\n                  url={`${studioURLPrefix}${matchedStudio.id}`}\n                  internal\n                />\n              </b>\n            </div>\n          </OptionalField>\n        </span>\n      </div>\n    );\n  }\n\n  const selectedSource = !selectedID ? \"skip\" : \"existing\";\n\n  const safeBuildStudioScraperLink = (id: string | null | undefined) => {\n    return stashboxStudioPrefix && id\n      ? `${stashboxStudioPrefix}${id}`\n      : undefined;\n  };\n\n  return (\n    <div className=\"row no-gutters align-items-center mt-2\">\n      <div className=\"entity-name\">\n        <FormattedMessage id=\"countables.studios\" values={{ count: 1 }} />:\n        <b className=\"ml-2\">\n          <StudioLink\n            studio={studio}\n            url={safeBuildStudioScraperLink(studio.remote_site_id)}\n          />\n        </b>\n      </div>\n      <ButtonGroup>\n        <Button variant=\"secondary\" onClick={() => onCreate()}>\n          <FormattedMessage id=\"actions.create\" />\n        </Button>\n        <Button\n          variant={selectedSource === \"skip\" ? \"primary\" : \"secondary\"}\n          onClick={() => handleSkip()}\n        >\n          <FormattedMessage id=\"actions.skip\" />\n        </Button>\n        <StudioSelect\n          ids={selectedID ? [selectedID] : []}\n          onSelect={handleSelect}\n          className={cx(\"studio-select\", {\n            \"studio-select-active\": selectedSource === \"existing\",\n          })}\n          isClearable={false}\n        />\n        {endpoint && onLink && (\n          <LinkButton disabled={selectedID === undefined} onLink={onLink} />\n        )}\n      </ButtonGroup>\n    </div>\n  );\n};\n\nexport default StudioResult;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx",
    "content": "import React, { useState, useContext, PropsWithChildren, useMemo } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Link, useHistory } from \"react-router-dom\";\nimport { Button, Collapse, Form, InputGroup } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\n\nimport { sortPerformers } from \"src/core/performers\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { OperationButton } from \"src/components/Shared/OperationButton\";\nimport { StashIDPill } from \"src/components/Shared/StashID\";\nimport { PerformerLink, TagLink } from \"src/components/Shared/TagLink\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { parsePath, prepareQueryString } from \"src/components/Tagger/utils\";\nimport {\n  ScenePreview,\n  SceneSpecsOverlay,\n} from \"src/components/Scenes/SceneCard\";\nimport { TaggerStateContext } from \"../context\";\nimport {\n  faChevronDown,\n  faChevronUp,\n  faImage,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { objectPath, objectTitle } from \"src/core/files\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { SceneQueue } from \"src/models/sceneQueue\";\n\ninterface ITaggerSceneDetails {\n  scene: GQL.SlimSceneDataFragment;\n}\n\nconst TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {\n  const [open, setOpen] = useState(false);\n  const sorted = sortPerformers(scene.performers);\n\n  return (\n    <div className=\"original-scene-details\">\n      <Collapse in={open}>\n        <div className=\"row\">\n          <div className=\"col col-lg-6\">\n            <h4>{objectTitle(scene)}</h4>\n            <h5>\n              {scene.studio?.name}\n              {scene.studio?.name && scene.date && ` • `}\n              {scene.date}\n            </h5>\n            <TruncatedText text={scene.details ?? \"\"} lineCount={3} />\n          </div>\n          <div className=\"col col-lg-6\">\n            <div>\n              {sorted.map((performer) => (\n                <div className=\"performer-tag-container row\" key={performer.id}>\n                  <Link\n                    to={`/performers/${performer.id}`}\n                    className=\"performer-tag col m-auto zoom-2\"\n                  >\n                    <img\n                      loading=\"lazy\"\n                      className=\"image-thumbnail\"\n                      alt={performer.name ?? \"\"}\n                      src={performer.image_path ?? \"\"}\n                    />\n                  </Link>\n                  <PerformerLink\n                    key={performer.id}\n                    performer={performer}\n                    className=\"d-block\"\n                  />\n                </div>\n              ))}\n            </div>\n            <div>\n              {scene.tags.map((tag) => (\n                <TagLink key={tag.id} tag={tag} />\n              ))}\n            </div>\n          </div>\n        </div>\n      </Collapse>\n      <Button\n        onClick={() => setOpen(!open)}\n        className=\"minimal collapse-button\"\n        size=\"lg\"\n      >\n        <Icon icon={open ? faChevronUp : faChevronDown} />\n      </Button>\n    </div>\n  );\n};\n\ntype StashID = Pick<GQL.StashId, \"endpoint\" | \"stash_id\">;\n\nconst StashIDs: React.FC<{ stashIDs: StashID[] }> = ({ stashIDs }) => {\n  if (!stashIDs.length) {\n    return null;\n  }\n\n  const stashLinks = stashIDs.map((stashID) => {\n    const base = stashID.endpoint.match(/https?:\\/\\/.*?\\//)?.[0];\n    const link = base ? (\n      <StashIDPill stashID={stashID} linkType=\"scenes\" />\n    ) : (\n      <span className=\"small\">{stashID.stash_id}</span>\n    );\n\n    return <div key={stashID.stash_id}>{link}</div>;\n  });\n\n  return <div className=\"mt-2 sub-content text-right\">{stashLinks}</div>;\n};\n\ninterface ITaggerScene {\n  scene: GQL.SlimSceneDataFragment;\n  url: string;\n  errorMessage?: string;\n  doSceneQuery?: (queryString: string) => void;\n  scrapeSceneFragment?: (scene: GQL.SlimSceneDataFragment) => void;\n  loading?: boolean;\n  showLightboxImage: (imagePath: string) => void;\n  queue?: SceneQueue;\n  index?: number;\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n}\n\nexport const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({\n  scene,\n  url,\n  loading,\n  doSceneQuery,\n  scrapeSceneFragment,\n  errorMessage,\n  children,\n  showLightboxImage,\n  queue,\n  index,\n  selected,\n  onSelectedChanged,\n}) => {\n  const { config } = useContext(TaggerStateContext);\n  const [queryString, setQueryString] = useState<string>(\"\");\n  const [queryLoading, setQueryLoading] = useState(false);\n\n  const { paths, file: basename } = parsePath(objectPath(scene));\n  const defaultQueryString = prepareQueryString(\n    scene,\n    paths,\n    basename,\n    config.mode,\n    config.blacklist\n  );\n\n  const file = useMemo(\n    () => (scene.files.length > 0 ? scene.files[0] : undefined),\n    [scene]\n  );\n\n  const width = file?.width ? file.width : 0;\n  const height = file?.height ? file.height : 0;\n  const isPortrait = height > width;\n\n  const history = useHistory();\n\n  const { configuration } = useConfigurationContext();\n  const cont = configuration?.interface.continuePlaylistDefault ?? false;\n\n  async function query() {\n    if (!doSceneQuery) return;\n\n    try {\n      setQueryLoading(true);\n      await doSceneQuery(queryString || defaultQueryString);\n    } finally {\n      setQueryLoading(false);\n    }\n  }\n\n  function renderQueryForm() {\n    if (!doSceneQuery) return;\n\n    return (\n      <InputGroup>\n        <InputGroup.Prepend>\n          <InputGroup.Text>\n            <FormattedMessage id=\"component_tagger.noun_query\" />\n          </InputGroup.Text>\n        </InputGroup.Prepend>\n        <Form.Control\n          className=\"text-input\"\n          value={queryString || defaultQueryString}\n          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n            setQueryString(e.currentTarget.value);\n          }}\n          onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>\n            e.key === \"Enter\" && query()\n          }\n        />\n        <InputGroup.Append>\n          <OperationButton\n            disabled={loading}\n            operation={query}\n            loading={queryLoading}\n            setLoading={setQueryLoading}\n          >\n            <FormattedMessage id=\"actions.search\" />\n          </OperationButton>\n        </InputGroup.Append>\n      </InputGroup>\n    );\n  }\n\n  function onSpriteClick(ev: React.MouseEvent<HTMLElement>) {\n    ev.preventDefault();\n    showLightboxImage(scene.paths.sprite ?? \"\");\n  }\n\n  function maybeRenderSpriteIcon() {\n    // If a scene doesn't have any files, or doesn't have a sprite generated, the\n    // path will be http://localhost:9999/scene/_sprite.jpg\n    if (scene.files.length > 0) {\n      return (\n        <Button\n          className=\"sprite-button\"\n          variant=\"link\"\n          onClick={onSpriteClick}\n        >\n          <Icon icon={faImage} />\n        </Button>\n      );\n    }\n  }\n\n  function onScrubberClick(timestamp: number) {\n    const link = queue\n      ? queue.makeLink(scene.id, {\n          sceneIndex: index,\n          continue: cont,\n          start: timestamp,\n        })\n      : `/scenes/${scene.id}?t=${timestamp}`;\n\n    history.push(link);\n  }\n\n  let shiftKey = false;\n\n  return (\n    <div key={scene.id} className=\"mt-3 search-item\">\n      <div className=\"row\">\n        {onSelectedChanged && (\n          <div className=\"col-auto d-flex align-items-start pt-2 pr-2\">\n            <Form.Control\n              type=\"checkbox\"\n              className=\"search-item-check mousetrap\"\n              checked={selected}\n              onChange={() => onSelectedChanged(!selected, shiftKey)}\n              onClick={(\n                event: React.MouseEvent<HTMLInputElement, MouseEvent>\n              ) => {\n                shiftKey = event.shiftKey;\n                event.stopPropagation();\n              }}\n            />\n          </div>\n        )}\n        <div className=\"col-12 col-lg overflow-hidden align-items-center d-flex flex-column flex-sm-row\">\n          <div className=\"scene-card mr-3\">\n            <Link to={url}>\n              <ScenePreview\n                image={scene.paths.screenshot ?? undefined}\n                video={scene.paths.preview ?? undefined}\n                isPortrait={isPortrait}\n                soundActive={false}\n                vttPath={scene.paths.vtt ?? undefined}\n                onScrubberClick={onScrubberClick}\n              />\n              <SceneSpecsOverlay scene={scene} />\n              {maybeRenderSpriteIcon()}\n            </Link>\n          </div>\n          <Link to={url} className=\"scene-link overflow-hidden\">\n            <TruncatedText text={objectTitle(scene)} lineCount={2} />\n          </Link>\n        </div>\n        <div className=\"col-12 col-lg my-1\">\n          <div>\n            {renderQueryForm()}\n            {scrapeSceneFragment ? (\n              <div className=\"mt-2 text-right\">\n                <OperationButton\n                  disabled={loading}\n                  operation={async () => {\n                    await scrapeSceneFragment(scene);\n                  }}\n                >\n                  <FormattedMessage id=\"actions.scrape_scene_fragment\" />\n                </OperationButton>\n              </div>\n            ) : undefined}\n          </div>\n          {errorMessage ? (\n            <div className=\"text-danger font-weight-bold\">{errorMessage}</div>\n          ) : undefined}\n          <StashIDs stashIDs={scene.stash_ids} />\n        </div>\n        <TaggerSceneDetails scene={scene} />\n      </div>\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx",
    "content": "import React, { useState, useContext } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\n\nimport StudioModal from \"./StudioModal\";\nimport PerformerModal from \"../PerformerModal\";\nimport { TaggerStateContext } from \"../context\";\nimport { useIntl } from \"react-intl\";\nimport { faTags } from \"@fortawesome/free-solid-svg-icons\";\nimport { CreateLinkTagDialog } from \"src/components/Shared/ScrapeDialog/CreateLinkTagDialog\";\n\ntype PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void;\ntype StudioModalCallback = (\n  toCreate?: GQL.StudioCreateInput,\n  parentInput?: GQL.StudioCreateInput\n) => void;\ntype TagModalCallback = (result: {\n  create?: GQL.TagCreateInput;\n  update?: GQL.TagUpdateInput;\n}) => void;\n\nexport interface ISceneTaggerModalsContextState {\n  createPerformerModal: (\n    performer: GQL.ScrapedPerformerDataFragment,\n    callback: PerformerModalCallback\n  ) => void;\n  createStudioModal: (\n    studio: GQL.ScrapedSceneStudioDataFragment,\n    callback: StudioModalCallback\n  ) => void;\n  createTagModal: (tag: GQL.ScrapedTag, callback: TagModalCallback) => void;\n}\n\nexport const SceneTaggerModalsState =\n  React.createContext<ISceneTaggerModalsContextState>({\n    createPerformerModal: () => {},\n    createStudioModal: () => {},\n    createTagModal: () => {},\n  });\n\nexport const SceneTaggerModals: React.FC = ({ children }) => {\n  const { currentSource } = useContext(TaggerStateContext);\n\n  const [performerToCreate, setPerformerToCreate] = useState<\n    GQL.ScrapedPerformerDataFragment | undefined\n  >();\n  const [performerCallback, setPerformerCallback] = useState<\n    PerformerModalCallback | undefined\n  >();\n\n  const [studioToCreate, setStudioToCreate] = useState<\n    GQL.ScrapedSceneStudioDataFragment | undefined\n  >();\n  const [studioCallback, setStudioCallback] = useState<\n    StudioModalCallback | undefined\n  >();\n\n  const [tagToCreate, setTagToCreate] = useState<GQL.ScrapedTag | undefined>();\n  const [tagCallback, setTagCallback] = useState<\n    | ((result: {\n        create?: GQL.TagCreateInput;\n        update?: GQL.TagUpdateInput;\n      }) => void)\n    | undefined\n  >();\n\n  const intl = useIntl();\n\n  function handlePerformerSave(toCreate: GQL.PerformerCreateInput) {\n    if (performerCallback) {\n      performerCallback(toCreate);\n    }\n\n    setPerformerToCreate(undefined);\n    setPerformerCallback(undefined);\n  }\n\n  function handlePerformerCancel() {\n    if (performerCallback) {\n      performerCallback();\n    }\n\n    setPerformerToCreate(undefined);\n    setPerformerCallback(undefined);\n  }\n\n  function createPerformerModal(\n    performer: GQL.ScrapedPerformerDataFragment,\n    callback: PerformerModalCallback\n  ) {\n    setPerformerToCreate(performer);\n    // can't set the function directly - needs to be via a wrapping function\n    setPerformerCallback(() => callback);\n  }\n\n  function handleStudioSave(\n    toCreate: GQL.StudioCreateInput,\n    parentInput?: GQL.StudioCreateInput\n  ) {\n    if (studioCallback) {\n      studioCallback(toCreate, parentInput);\n    }\n\n    setStudioToCreate(undefined);\n    setStudioCallback(undefined);\n  }\n\n  function handleStudioCancel() {\n    if (studioCallback) {\n      studioCallback();\n    }\n\n    setStudioToCreate(undefined);\n    setStudioCallback(undefined);\n  }\n\n  function createStudioModal(\n    studio: GQL.ScrapedSceneStudioDataFragment,\n    callback: StudioModalCallback\n  ) {\n    setStudioToCreate(studio);\n    // can't set the function directly - needs to be via a wrapping function\n    setStudioCallback(() => callback);\n  }\n\n  function handleTagSave(result: {\n    create?: GQL.TagCreateInput;\n    update?: GQL.TagUpdateInput;\n  }) {\n    if (tagCallback) {\n      tagCallback(result);\n    }\n\n    setTagToCreate(undefined);\n    setTagCallback(undefined);\n  }\n\n  function createTagModal(tag: GQL.ScrapedTag, callback: TagModalCallback) {\n    setTagToCreate(tag);\n    setTagCallback(() => callback);\n  }\n\n  const endpoint = currentSource?.sourceInput.stash_box_endpoint ?? undefined;\n\n  return (\n    <SceneTaggerModalsState.Provider\n      value={{ createPerformerModal, createStudioModal, createTagModal }}\n    >\n      {performerToCreate && (\n        <PerformerModal\n          closeModal={handlePerformerCancel}\n          modalVisible\n          performer={performerToCreate}\n          onSave={handlePerformerSave}\n          icon={faTags}\n          header={intl.formatMessage(\n            { id: \"actions.create_entity\" },\n            { entityType: intl.formatMessage({ id: \"performer\" }) }\n          )}\n          endpoint={endpoint}\n          create\n        />\n      )}\n      {studioToCreate && (\n        <StudioModal\n          closeModal={handleStudioCancel}\n          modalVisible\n          studio={studioToCreate}\n          handleStudioCreate={handleStudioSave}\n          icon={faTags}\n          header={intl.formatMessage(\n            { id: \"actions.create_entity\" },\n            { entityType: intl.formatMessage({ id: \"studio\" }) }\n          )}\n          endpoint={endpoint}\n        />\n      )}\n      {tagToCreate && (\n        <CreateLinkTagDialog\n          tag={tagToCreate}\n          onClose={handleTagSave}\n          endpoint={endpoint}\n        />\n      )}\n      {children}\n    </SceneTaggerModalsState.Provider>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/scenes/utils.ts",
    "content": "import { SlimSceneDataFragment } from \"src/core/generated-graphql\";\nimport { IScrapedScene } from \"../context\";\nimport { distance } from \"src/utils/hamming\";\n\nexport function minDistance(hash: string, stashScene: SlimSceneDataFragment) {\n  let ret = 9999;\n  stashScene.files.forEach((cv) => {\n    if (ret === 0) return;\n\n    const stashHash = cv.fingerprints.find((fp) => fp.type === \"phash\");\n    if (!stashHash) {\n      return;\n    }\n\n    const d = distance(hash, stashHash.value);\n    if (d < ret) {\n      ret = d;\n    }\n  });\n\n  return ret;\n}\n\nexport function calculatePhashComparisonScore(\n  stashScene: SlimSceneDataFragment,\n  scrapedScene: IScrapedScene\n) {\n  const phashFingerprints =\n    scrapedScene.fingerprints?.filter((f) => f.algorithm === \"PHASH\") ?? [];\n  const filteredFingerprints = phashFingerprints.filter(\n    (f) => minDistance(f.hash, stashScene) <= 8\n  );\n\n  if (phashFingerprints.length == 0) return [0, 0];\n\n  return [\n    filteredFingerprints.length,\n    filteredFingerprints.length / phashFingerprints.length,\n  ];\n}\n\nexport function minDurationDiff(\n  stashScene: SlimSceneDataFragment,\n  duration: number\n) {\n  let ret = 9999;\n  stashScene.files.forEach((cv) => {\n    if (ret === 0) return;\n\n    const d = Math.abs(duration - cv.duration);\n    if (d < ret) {\n      ret = d;\n    }\n  });\n\n  return ret;\n}\n\nexport function calculateDurationComparisonScore(\n  stashScene: SlimSceneDataFragment,\n  scrapedScene: IScrapedScene\n) {\n  if (scrapedScene.fingerprints && scrapedScene.fingerprints.length > 0) {\n    const durations = scrapedScene.fingerprints.map((f) => f.duration);\n    const diffs = durations.map((d) => minDurationDiff(stashScene, d));\n    const filteredDurations = diffs.filter((duration) => duration <= 5);\n\n    const minDiff = Math.min(...diffs);\n\n    return [\n      filteredDurations.length,\n      filteredDurations.length / durations.length,\n      minDiff,\n    ];\n  }\n  return [0, 0, 0];\n}\n\nexport function compareScenesForSort(\n  stashScene: SlimSceneDataFragment,\n  sceneA: IScrapedScene,\n  sceneB: IScrapedScene\n) {\n  // Compare sceneA and sceneB to each other for sorting based on similarity to stashScene\n  // Order of priority is: nb. phash match > nb. duration match > ratio duration match > ratio phash match\n\n  // scenes without any fingerprints should be sorted to the end\n  if (!sceneA.fingerprints?.length && sceneB.fingerprints?.length) {\n    return 1;\n  }\n  if (!sceneB.fingerprints?.length && sceneA.fingerprints?.length) {\n    return -1;\n  }\n\n  const [nbPhashMatchSceneA, ratioPhashMatchSceneA] =\n    calculatePhashComparisonScore(stashScene, sceneA);\n  const [nbPhashMatchSceneB, ratioPhashMatchSceneB] =\n    calculatePhashComparisonScore(stashScene, sceneB);\n\n  // If only one scene has matching phash, prefer that scene\n  if (\n    (nbPhashMatchSceneA != nbPhashMatchSceneB && nbPhashMatchSceneA === 0) ||\n    nbPhashMatchSceneB === 0\n  ) {\n    return nbPhashMatchSceneB - nbPhashMatchSceneA;\n  }\n\n  // Prefer scene with highest ratio of phash matches\n  if (ratioPhashMatchSceneA !== ratioPhashMatchSceneB) {\n    return ratioPhashMatchSceneB - ratioPhashMatchSceneA;\n  }\n\n  // Same ratio of phash matches, check duration\n  const [\n    nbDurationMatchSceneA,\n    ratioDurationMatchSceneA,\n    minDurationDiffSceneA,\n  ] = calculateDurationComparisonScore(stashScene, sceneA);\n  const [\n    nbDurationMatchSceneB,\n    ratioDurationMatchSceneB,\n    minDurationDiffSceneB,\n  ] = calculateDurationComparisonScore(stashScene, sceneB);\n\n  if (nbDurationMatchSceneA != nbDurationMatchSceneB) {\n    return nbDurationMatchSceneB - nbDurationMatchSceneA;\n  }\n\n  // Same number of phash & duration, check duration ratio\n  if (ratioDurationMatchSceneA != ratioDurationMatchSceneB) {\n    return ratioDurationMatchSceneB - ratioDurationMatchSceneA;\n  }\n\n  // fall back to duration difference - less is better\n  return minDurationDiffSceneA - minDurationDiffSceneB;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/studios/StashSearchResult.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useUpdateStudio } from \"../queries\";\nimport StudioModal from \"../scenes/StudioModal\";\nimport { faTags } from \"@fortawesome/free-solid-svg-icons\";\nimport { useStudioCreate } from \"src/core/StashService\";\nimport { useIntl } from \"react-intl\";\nimport { apolloError } from \"src/utils\";\nimport { mergeStudioStashIDs } from \"../utils\";\n\ninterface IStashSearchResultProps {\n  studio: GQL.SlimStudioDataFragment;\n  stashboxStudios: GQL.ScrapedStudioDataFragment[];\n  endpoint: string;\n  onStudioTagged: (\n    studio: Pick<GQL.SlimStudioDataFragment, \"id\"> &\n      Partial<Omit<GQL.SlimStudioDataFragment, \"id\">>\n  ) => void;\n  excludedStudioFields: string[];\n}\n\nconst StashSearchResult: React.FC<IStashSearchResultProps> = ({\n  studio,\n  stashboxStudios,\n  onStudioTagged,\n  excludedStudioFields,\n  endpoint,\n}) => {\n  const intl = useIntl();\n\n  const [modalStudio, setModalStudio] =\n    useState<GQL.ScrapedStudioDataFragment>();\n  const [saveState, setSaveState] = useState<string>(\"\");\n  const [error, setError] = useState<{ message?: string; details?: string }>(\n    {}\n  );\n\n  const [createStudio] = useStudioCreate();\n  const updateStudio = useUpdateStudio();\n\n  function handleSaveError(name: string, message: string) {\n    setError({\n      message: intl.formatMessage(\n        { id: \"studio_tagger.failed_to_save_studio\" },\n        { studio: name }\n      ),\n      details:\n        message === \"UNIQUE constraint failed: studios.name\"\n          ? \"Name already exists\"\n          : message,\n    });\n  }\n\n  const handleSave = async (\n    input: GQL.StudioCreateInput,\n    parentInput?: GQL.StudioCreateInput\n  ) => {\n    setError({});\n    setModalStudio(undefined);\n\n    if (parentInput) {\n      setSaveState(\"Saving parent studio\");\n\n      try {\n        // if parent id is set, then update the existing studio\n        if (input.parent_id) {\n          const parentUpdateData: GQL.StudioUpdateInput = {\n            ...parentInput,\n            id: input.parent_id,\n          };\n\n          parentUpdateData.stash_ids = await mergeStudioStashIDs(\n            input.parent_id,\n            parentInput.stash_ids ?? []\n          );\n\n          await updateStudio(parentUpdateData);\n        } else {\n          const parentRes = await createStudio({\n            variables: { input: parentInput },\n          });\n          input.parent_id = parentRes.data?.studioCreate?.id;\n        }\n      } catch (e) {\n        handleSaveError(parentInput.name, apolloError(e));\n      }\n    }\n\n    setSaveState(\"Saving studio\");\n    const updateData: GQL.StudioUpdateInput = {\n      ...input,\n      id: studio.id,\n    };\n\n    updateData.stash_ids = await mergeStudioStashIDs(\n      studio.id,\n      input.stash_ids ?? []\n    );\n\n    const res = await updateStudio(updateData);\n\n    if (!res?.data?.studioUpdate)\n      handleSaveError(studio.name, res?.errors?.[0]?.message ?? \"\");\n    else onStudioTagged(studio);\n    setSaveState(\"\");\n  };\n\n  const studios = stashboxStudios.map((p) => (\n    <Button\n      className=\"StudioTagger-studio-search-item minimal col-6\"\n      variant=\"link\"\n      key={p.remote_site_id}\n      onClick={() => setModalStudio(p)}\n    >\n      <img\n        loading=\"lazy\"\n        src={(p.image ?? [])[0]}\n        alt=\"\"\n        className=\"StudioTagger-thumb\"\n      />\n      <span>{p.name}</span>\n    </Button>\n  ));\n\n  return (\n    <>\n      {modalStudio && (\n        <StudioModal\n          closeModal={() => setModalStudio(undefined)}\n          modalVisible={modalStudio !== undefined}\n          studio={modalStudio}\n          handleStudioCreate={handleSave}\n          icon={faTags}\n          header=\"Update Studio\"\n          excludedStudioFields={excludedStudioFields}\n          endpoint={endpoint}\n        />\n      )}\n      <div className=\"StudioTagger-studio-search\">{studios}</div>\n      <div className=\"row no-gutters mt-2 align-items-center justify-content-end\">\n        {error.message && (\n          <div className=\"text-right text-danger mt-1\">\n            <strong>\n              <span className=\"mr-2\">Error:</span>\n              {error.message}\n            </strong>\n            <div>{error.details}</div>\n          </div>\n        )}\n        {saveState && (\n          <strong className=\"col-4 mt-1 mr-2 text-right\">{saveState}</strong>\n        )}\n      </div>\n    </>\n  );\n};\n\nexport default StashSearchResult;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Button, Card, Form, InputGroup, ProgressBar } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Link } from \"react-router-dom\";\nimport { HashLink } from \"react-router-hash-link\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport {\n  stashBoxStudioQuery,\n  useJobsSubscribe,\n  mutateStashBoxBatchStudioTag,\n  getClient,\n  studioMutationImpactedQueries,\n  useStudioCreate,\n  evictQueries,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\nimport StashSearchResult from \"./StashSearchResult\";\nimport TaggerConfig, { ConfigButton } from \"../TaggerConfig\";\nimport { ITaggerConfig, STUDIO_FIELDS } from \"../constants\";\nimport StudioModal from \"../scenes/StudioModal\";\nimport { useUpdateStudio } from \"../queries\";\nimport { apolloError } from \"src/utils\";\nimport { faTags } from \"@fortawesome/free-solid-svg-icons\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\nimport { mergeStudioStashIDs } from \"../utils\";\nimport { separateNamesAndStashIds } from \"src/utils/stashIds\";\nimport { useTaggerConfig } from \"../config\";\nimport {\n  BatchUpdateModal,\n  BatchAddModal,\n} from \"src/components/Shared/BatchModals\";\nimport { StashBoxSelectorField } from \"../StashBoxSelector\";\n\ntype JobFragment = Pick<\n  GQL.Job,\n  \"id\" | \"status\" | \"subTasks\" | \"description\" | \"progress\"\n>;\n\nconst CLASSNAME = \"StudioTagger\";\n\ninterface IStudioTaggerListProps {\n  studios: GQL.StudioDataFragment[];\n  selectedEndpoint: { endpoint: string; index: number };\n  isIdle: boolean;\n  config: ITaggerConfig;\n  onBatchAdd: (studioInput: string, createParent: boolean) => void;\n  onBatchUpdate: (\n    ids: string[] | undefined,\n    refresh: boolean,\n    createParent: boolean\n  ) => void;\n}\n\nconst StudioTaggerList: React.FC<IStudioTaggerListProps> = ({\n  studios,\n  selectedEndpoint,\n  isIdle,\n  config,\n  onBatchAdd,\n  onBatchUpdate,\n}) => {\n  const intl = useIntl();\n\n  const [loading, setLoading] = useState(false);\n  const [searchResults, setSearchResults] = useState<\n    Record<string, GQL.ScrapedStudioDataFragment[]>\n  >({});\n  const [searchErrors, setSearchErrors] = useState<\n    Record<string, string | undefined>\n  >({});\n  const [taggedStudios, setTaggedStudios] = useState<\n    Record<string, Partial<GQL.SlimStudioDataFragment>>\n  >({});\n  const [queries, setQueries] = useState<Record<string, string>>({});\n\n  const [showBatchAdd, setShowBatchAdd] = useState(false);\n  const [showBatchUpdate, setShowBatchUpdate] = useState(false);\n  const [batchAddParents, setBatchAddParents] = useState(\n    config.createParentStudios || false\n  );\n\n  const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false);\n  const { data: allStudios } = GQL.useFindStudiosQuery({\n    skip: !showBatchUpdate,\n    variables: {\n      studio_filter: {\n        stash_id_endpoint: {\n          endpoint: selectedEndpoint.endpoint,\n          modifier: batchUpdateRefresh\n            ? GQL.CriterionModifier.NotNull\n            : GQL.CriterionModifier.IsNull,\n        },\n      },\n      filter: {\n        per_page: 0,\n      },\n    },\n  });\n\n  const [error, setError] = useState<\n    Record<string, { message?: string; details?: string } | undefined>\n  >({});\n  const [loadingUpdate, setLoadingUpdate] = useState<string | undefined>();\n  const [modalStudio, setModalStudio] = useState<\n    GQL.ScrapedStudioDataFragment | undefined\n  >();\n\n  const doBoxSearch = (studioID: string, searchVal: string) => {\n    stashBoxStudioQuery(searchVal, selectedEndpoint.endpoint)\n      .then((queryData) => {\n        const s = queryData.data?.scrapeSingleStudio ?? [];\n        setSearchResults({\n          ...searchResults,\n          [studioID]: s,\n        });\n        setSearchErrors({\n          ...searchErrors,\n          [studioID]: undefined,\n        });\n        setLoading(false);\n      })\n      .catch(() => {\n        setLoading(false);\n        // Destructure to remove existing result\n        const { [studioID]: unassign, ...results } = searchResults;\n        setSearchResults(results);\n        setSearchErrors({\n          ...searchErrors,\n          [studioID]: intl.formatMessage({\n            id: \"studio_tagger.network_error\",\n          }),\n        });\n      });\n\n    setLoading(true);\n  };\n\n  const doBoxUpdate = (studioID: string, stashID: string, endpoint: string) => {\n    setLoadingUpdate(stashID);\n    setError({\n      ...error,\n      [studioID]: undefined,\n    });\n    stashBoxStudioQuery(stashID, endpoint)\n      .then((queryData) => {\n        const data = queryData.data?.scrapeSingleStudio ?? [];\n        if (data.length > 0) {\n          setModalStudio({\n            ...data[0],\n            stored_id: studioID,\n          });\n        }\n      })\n      .finally(() => setLoadingUpdate(undefined));\n  };\n\n  async function handleBatchAdd(input: string) {\n    onBatchAdd(input, batchAddParents);\n    setShowBatchAdd(false);\n  }\n\n  const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => {\n    onBatchUpdate(\n      !queryAll ? studios.map((p) => p.id) : undefined,\n      refresh,\n      batchAddParents\n    );\n    setShowBatchUpdate(false);\n  };\n\n  const handleTaggedStudio = (\n    studio: Pick<GQL.SlimStudioDataFragment, \"id\"> &\n      Partial<Omit<GQL.SlimStudioDataFragment, \"id\">>\n  ) => {\n    setTaggedStudios({\n      ...taggedStudios,\n      [studio.id]: studio,\n    });\n  };\n\n  const [createStudio] = useStudioCreate();\n  const updateStudio = useUpdateStudio();\n\n  function handleSaveError(studioID: string, name: string, message: string) {\n    setError({\n      ...error,\n      [studioID]: {\n        message: intl.formatMessage(\n          { id: \"studio_tagger.failed_to_save_studio\" },\n          { studio: modalStudio?.name }\n        ),\n        details:\n          message === \"UNIQUE constraint failed: studios.name\"\n            ? intl.formatMessage({\n                id: \"studio_tagger.name_already_exists\",\n              })\n            : message,\n      },\n    });\n  }\n\n  const handleStudioUpdate = async (\n    input: GQL.StudioCreateInput,\n    parentInput?: GQL.StudioCreateInput\n  ) => {\n    setModalStudio(undefined);\n    const studioID = modalStudio?.stored_id;\n    if (studioID) {\n      if (parentInput) {\n        try {\n          // if parent id is set, then update the existing studio\n          if (input.parent_id) {\n            const parentUpdateData: GQL.StudioUpdateInput = {\n              ...parentInput,\n              id: input.parent_id,\n            };\n            parentUpdateData.stash_ids = await mergeStudioStashIDs(\n              input.parent_id,\n              parentInput.stash_ids ?? []\n            );\n            await updateStudio(parentUpdateData);\n          } else {\n            const parentRes = await createStudio({\n              variables: { input: parentInput },\n            });\n            input.parent_id = parentRes.data?.studioCreate?.id;\n          }\n        } catch (e) {\n          handleSaveError(studioID, parentInput.name, apolloError(e));\n        }\n      }\n\n      const updateData: GQL.StudioUpdateInput = {\n        ...input,\n        id: studioID,\n      };\n      updateData.stash_ids = await mergeStudioStashIDs(\n        studioID,\n        input.stash_ids ?? []\n      );\n\n      const res = await updateStudio(updateData);\n      if (!res.data?.studioUpdate)\n        handleSaveError(\n          studioID,\n          modalStudio?.name ?? \"\",\n          res?.errors?.[0]?.message ?? \"\"\n        );\n    }\n  };\n\n  const renderStudios = () =>\n    studios.map((studio) => {\n      const isTagged = taggedStudios[studio.id];\n\n      const stashID = studio.stash_ids.find((s) => {\n        return s.endpoint === selectedEndpoint.endpoint;\n      });\n\n      let mainContent;\n      if (!isTagged && stashID !== undefined) {\n        mainContent = (\n          <div className=\"text-left\">\n            <h5 className=\"text-bold\">\n              <FormattedMessage id=\"studio_tagger.studio_already_tagged\" />\n            </h5>\n          </div>\n        );\n      } else if (!isTagged && !stashID) {\n        mainContent = (\n          <InputGroup>\n            <Form.Control\n              className=\"text-input\"\n              defaultValue={studio.name ?? \"\"}\n              onChange={(e) =>\n                setQueries({\n                  ...queries,\n                  [studio.id]: e.currentTarget.value,\n                })\n              }\n              onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>\n                e.key === \"Enter\" &&\n                doBoxSearch(studio.id, queries[studio.id] ?? studio.name ?? \"\")\n              }\n            />\n            <InputGroup.Append>\n              <Button\n                disabled={loading}\n                onClick={() =>\n                  doBoxSearch(\n                    studio.id,\n                    queries[studio.id] ?? studio.name ?? \"\"\n                  )\n                }\n              >\n                <FormattedMessage id=\"actions.search\" />\n              </Button>\n            </InputGroup.Append>\n          </InputGroup>\n        );\n      } else if (isTagged) {\n        mainContent = (\n          <div className=\"d-flex flex-column text-left\">\n            <h5>\n              <FormattedMessage id=\"studio_tagger.studio_successfully_tagged\" />\n            </h5>\n          </div>\n        );\n      }\n\n      let subContent;\n      if (stashID !== undefined) {\n        const base = stashID.endpoint.match(/https?:\\/\\/.*?\\//)?.[0];\n        const link = base ? (\n          <ExternalLink\n            className=\"small d-block\"\n            href={`${base}studios/${stashID.stash_id}`}\n          >\n            {stashID.stash_id}\n          </ExternalLink>\n        ) : (\n          <div className=\"small\">{stashID.stash_id}</div>\n        );\n\n        subContent = (\n          <div key={studio.id}>\n            <InputGroup className=\"StudioTagger-box-link\">\n              <InputGroup.Text>{link}</InputGroup.Text>\n              <InputGroup.Append>\n                <Button\n                  onClick={() =>\n                    doBoxUpdate(studio.id, stashID.stash_id, stashID.endpoint)\n                  }\n                  disabled={!!loadingUpdate}\n                >\n                  {loadingUpdate === stashID.stash_id ? (\n                    <LoadingIndicator inline small message=\"\" />\n                  ) : (\n                    <FormattedMessage id=\"actions.refresh\" />\n                  )}\n                </Button>\n              </InputGroup.Append>\n            </InputGroup>\n            {error[studio.id] && (\n              <div className=\"text-danger mt-1\">\n                <strong>\n                  <span className=\"mr-2\">Error:</span>\n                  {error[studio.id]?.message}\n                </strong>\n                <div>{error[studio.id]?.details}</div>\n              </div>\n            )}\n          </div>\n        );\n      } else if (searchErrors[studio.id]) {\n        subContent = (\n          <div className=\"text-danger font-weight-bold\">\n            {searchErrors[studio.id]}\n          </div>\n        );\n      } else if (searchResults[studio.id]?.length === 0) {\n        subContent = (\n          <div className=\"text-danger font-weight-bold\">\n            <FormattedMessage id=\"studio_tagger.no_results_found\" />\n          </div>\n        );\n      }\n\n      let searchResult;\n      if (searchResults[studio.id]?.length > 0 && !isTagged) {\n        searchResult = (\n          <StashSearchResult\n            key={studio.id}\n            stashboxStudios={searchResults[studio.id]}\n            studio={studio}\n            endpoint={selectedEndpoint.endpoint}\n            onStudioTagged={handleTaggedStudio}\n            excludedStudioFields={config.excludedStudioFields ?? []}\n          />\n        );\n      }\n\n      return (\n        <div key={studio.id} className={`${CLASSNAME}-studio`}>\n          {modalStudio && (\n            <StudioModal\n              closeModal={() => setModalStudio(undefined)}\n              modalVisible={modalStudio.stored_id === studio.id}\n              studio={modalStudio}\n              handleStudioCreate={handleStudioUpdate}\n              excludedStudioFields={config.excludedStudioFields}\n              icon={faTags}\n              header={intl.formatMessage({\n                id: \"studio_tagger.update_studio\",\n              })}\n              endpoint={selectedEndpoint.endpoint}\n            />\n          )}\n          <div className={`${CLASSNAME}-details`}>\n            <div></div>\n            <div>\n              <Card className=\"studio-card\">\n                <img loading=\"lazy\" src={studio.image_path ?? \"\"} alt=\"\" />\n              </Card>\n            </div>\n            <div className={`${CLASSNAME}-details-text`}>\n              <Link\n                to={`/studios/${studio.id}`}\n                className={`${CLASSNAME}-header`}\n              >\n                <h2>{studio.name}</h2>\n              </Link>\n              {mainContent}\n              <div className=\"sub-content text-left\">{subContent}</div>\n              {searchResult}\n            </div>\n          </div>\n        </div>\n      );\n    });\n\n  return (\n    <Card>\n      {showBatchUpdate && (\n        <BatchUpdateModal\n          close={() => setShowBatchUpdate(false)}\n          isIdle={isIdle}\n          selectedEndpoint={selectedEndpoint}\n          entities={studios}\n          allCount={allStudios?.findStudios.count}\n          onBatchUpdate={handleBatchUpdate}\n          onRefreshChange={setBatchUpdateRefresh}\n          batchAddParents={batchAddParents}\n          setBatchAddParents={setBatchAddParents}\n          localePrefix=\"studio_tagger\"\n          entityName=\"studio\"\n          countVariableName=\"studio_count\"\n        />\n      )}\n\n      {showBatchAdd && (\n        <BatchAddModal\n          close={() => setShowBatchAdd(false)}\n          isIdle={isIdle}\n          onBatchAdd={handleBatchAdd}\n          batchAddParents={batchAddParents}\n          setBatchAddParents={setBatchAddParents}\n          localePrefix=\"studio_tagger\"\n          entityName=\"studio\"\n        />\n      )}\n      <div className=\"ml-auto mb-3\">\n        <Button onClick={() => setShowBatchAdd(true)}>\n          <FormattedMessage id=\"studio_tagger.batch_add_studios\" />\n        </Button>\n        <Button className=\"ml-3\" onClick={() => setShowBatchUpdate(true)}>\n          <FormattedMessage id=\"studio_tagger.batch_update_studios\" />\n        </Button>\n      </div>\n      <div className={CLASSNAME}>{renderStudios()}</div>\n    </Card>\n  );\n};\n\ninterface ITaggerProps {\n  studios: GQL.StudioDataFragment[];\n}\n\nexport const StudioTagger: React.FC<ITaggerProps> = ({ studios }) => {\n  const jobsSubscribe = useJobsSubscribe();\n  const { configuration: stashConfig } = useConfigurationContext();\n  const { config, setConfig } = useTaggerConfig();\n  const [showConfig, setShowConfig] = useState(false);\n\n  const [batchJobID, setBatchJobID] = useState<string | undefined | null>();\n  const [batchJob, setBatchJob] = useState<JobFragment | undefined>();\n\n  // monitor batch operation\n  useEffect(() => {\n    if (!jobsSubscribe.data) {\n      return;\n    }\n\n    const event = jobsSubscribe.data.jobsSubscribe;\n    if (event.job.id !== batchJobID) {\n      return;\n    }\n\n    if (event.type !== GQL.JobStatusUpdateType.Remove) {\n      setBatchJob(event.job);\n    } else {\n      setBatchJob(undefined);\n      setBatchJobID(undefined);\n\n      // Once the studio batch is complete, refresh all local studio data\n      const ac = getClient();\n      evictQueries(ac.cache, studioMutationImpactedQueries);\n    }\n  }, [jobsSubscribe, batchJobID]);\n\n  if (!config) return <LoadingIndicator />;\n\n  const savedEndpointIndex =\n    stashConfig?.general.stashBoxes.findIndex(\n      (s) => s.endpoint === config.selectedEndpoint\n    ) ?? -1;\n  const selectedEndpointIndex =\n    savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length\n      ? 0\n      : savedEndpointIndex;\n  const selectedEndpoint =\n    stashConfig?.general.stashBoxes[selectedEndpointIndex];\n\n  async function batchAdd(studioInput: string, createParent: boolean) {\n    if (studioInput && selectedEndpoint) {\n      const inputs = studioInput\n        .split(\",\")\n        .map((n) => n.trim())\n        .filter((n) => n.length > 0);\n\n      const { names, stashIds } = separateNamesAndStashIds(inputs);\n\n      if (names.length > 0 || stashIds.length > 0) {\n        const ret = await mutateStashBoxBatchStudioTag({\n          names: names.length > 0 ? names : undefined,\n          stash_ids: stashIds.length > 0 ? stashIds : undefined,\n          endpoint: selectedEndpointIndex,\n          refresh: false,\n          exclude_fields: config?.excludedStudioFields ?? [],\n          createParent: createParent,\n        });\n\n        setBatchJobID(ret.data?.stashBoxBatchStudioTag);\n      }\n    }\n  }\n\n  async function batchUpdate(\n    ids: string[] | undefined,\n    refresh: boolean,\n    createParent: boolean\n  ) {\n    if (selectedEndpoint) {\n      const ret = await mutateStashBoxBatchStudioTag({\n        ids: ids,\n        endpoint: selectedEndpointIndex,\n        refresh,\n        exclude_fields: config?.excludedStudioFields ?? [],\n        createParent: createParent,\n      });\n\n      setBatchJobID(ret.data?.stashBoxBatchStudioTag);\n    }\n  }\n\n  // const progress =\n  //   jobStatus.data?.metadataUpdate.status ===\n  //     \"Stash-Box Studio Batch Operation\" &&\n  //   jobStatus.data.metadataUpdate.progress >= 0\n  //     ? jobStatus.data.metadataUpdate.progress * 100\n  //     : null;\n\n  function renderStatus() {\n    if (batchJob) {\n      const progress =\n        batchJob.progress !== undefined && batchJob.progress !== null\n          ? batchJob.progress * 100\n          : undefined;\n      return (\n        <Form.Group className=\"px-4\">\n          <h5>\n            <FormattedMessage id=\"studio_tagger.status_tagging_studios\" />\n          </h5>\n          {progress !== undefined && (\n            <ProgressBar\n              animated\n              now={progress}\n              label={`${progress.toFixed(0)}%`}\n            />\n          )}\n        </Form.Group>\n      );\n    }\n\n    if (batchJobID !== undefined) {\n      return (\n        <Form.Group className=\"px-4\">\n          <h5>\n            <FormattedMessage id=\"studio_tagger.status_tagging_job_queued\" />\n          </h5>\n        </Form.Group>\n      );\n    }\n  }\n\n  if (selectedEndpointIndex === -1 || !selectedEndpoint) {\n    return (\n      <div className=\"my-4\">\n        <h3 className=\"text-center mt-4\">\n          <FormattedMessage id=\"studio_tagger.to_use_the_studio_tagger\" />\n        </h3>\n        <h5 className=\"text-center\">\n          <FormattedMessage\n            id=\"refer_to\"\n            values={{\n              link: (\n                <HashLink\n                  to=\"/settings?tab=metadata-providers#stash-boxes\"\n                  scroll={(el) =>\n                    el.scrollIntoView({ behavior: \"smooth\", block: \"center\" })\n                  }\n                >\n                  <FormattedMessage id=\"config.stashbox.title\" />\n                </HashLink>\n              ),\n            }}\n          />\n        </h5>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {renderStatus()}\n      <div className=\"tagger-container mx-md-auto\">\n        <div className=\"tagger-container-header\">\n          <div className=\"d-flex justify-content-between align-items-center flex-wrap\">\n            <div className=\"w-auto\">\n              <StashBoxSelectorField\n                stashBoxes={stashConfig?.general.stashBoxes ?? []}\n                selectedEndpoint={selectedEndpoint.endpoint}\n                onEndpointChange={(endpoint) =>\n                  setConfig({ ...config, selectedEndpoint: endpoint })\n                }\n              />\n            </div>\n            <div className=\"d-flex\">\n              <div className=\"ml-2\">\n                <ConfigButton\n                  showConfig={showConfig}\n                  onClick={() => setShowConfig(!showConfig)}\n                />\n              </div>\n            </div>\n          </div>\n\n          <TaggerConfig\n            show={showConfig}\n            excludedFields={config.excludedStudioFields ?? []}\n            onFieldsChange={(fields) =>\n              setConfig({ ...config, excludedStudioFields: fields })\n            }\n            fields={STUDIO_FIELDS}\n            entityName=\"studios\"\n            extraConfig={\n              <Form.Group\n                controlId=\"create-parent\"\n                className=\"align-items-center\"\n              >\n                <Form.Check\n                  label={\n                    <FormattedMessage id=\"studio_tagger.config.create_parent_label\" />\n                  }\n                  checked={config.createParentStudios}\n                  onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                    setConfig({\n                      ...config,\n                      createParentStudios: e.currentTarget.checked,\n                    })\n                  }\n                />\n                <Form.Text>\n                  <FormattedMessage id=\"studio_tagger.config.create_parent_desc\" />\n                </Form.Text>\n              </Form.Group>\n            }\n          />\n        </div>\n\n        <StudioTaggerList\n          studios={studios}\n          selectedEndpoint={{\n            endpoint: selectedEndpoint.endpoint,\n            index: selectedEndpointIndex,\n          }}\n          isIdle={batchJobID === undefined}\n          config={config}\n          onBatchAdd={batchAdd}\n          onBatchUpdate={batchUpdate}\n        />\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/styles.scss",
    "content": ".tagger-container {\n  max-width: 1600px;\n\n  .tagger-container-header {\n    background-color: rgba(0, 0, 0, 0);\n    padding-bottom: 0;\n  }\n\n  .scene-card {\n    position: relative;\n\n    .scene-specs-overlay {\n      bottom: 5px;\n      right: 5px;\n    }\n  }\n\n  .scene-card-preview {\n    border-radius: 3px;\n    color: $text-color;\n    height: 100px;\n    margin-bottom: 0;\n    overflow: hidden;\n    width: auto;\n  }\n\n  .sprite-button {\n    filter: drop-shadow(1px 1px 1px #222);\n    padding: 0;\n    position: absolute;\n    right: 5px;\n    top: 5px;\n  }\n\n  .sub-content {\n    min-height: 1.5rem;\n  }\n}\n\n.tagger-table {\n  overflow: visible;\n}\n\n.search-item {\n  background-color: #495b68;\n  border-radius: 3px;\n  padding: 1rem;\n\n  .scene-details {\n    display: flex;\n    flex-direction: column;\n    overflow-wrap: anywhere;\n    width: 100%;\n  }\n\n  .original-scene-details {\n    align-items: center;\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n  }\n}\n\n.search-item-check {\n  cursor: pointer;\n}\n\n.search-result {\n  background-color: rgba(61, 80, 92, 0.3);\n  padding: 1rem 0;\n\n  &:hover {\n    background-color: hsl(204, 20%, 30%);\n    cursor: pointer;\n  }\n\n  .performer-select,\n  .studio-select {\n    width: 18rem;\n\n    // stylelint-disable-next-line selector-class-pattern\n    &-active .react-select__control {\n      background-color: #137cbd;\n    }\n  }\n\n  .SceneTaggerIcon {\n    margin-left: 0.25em;\n    margin-right: 10px;\n    width: var(--fa-fw-width, 1.25em);\n  }\n}\n\n.selected-result {\n  background-color: hsl(204, 20%, 30%);\n  border-radius: 3px;\n\n  &:hover {\n    cursor: default;\n  }\n}\n\n.scene-select {\n  &:hover {\n    cursor: pointer;\n  }\n}\n\n.scene-image {\n  max-height: 10rem;\n  max-width: 14rem;\n  min-width: 168px;\n  object-fit: contain;\n}\n\n.scene-metadata {\n  margin-left: 1rem;\n}\n\n.select-existing {\n  width: 2rem;\n}\n\n.entity-name {\n  flex: 1;\n  margin-right: auto;\n}\n\n.scene-link {\n  color: $text-color;\n  font-weight: 500;\n}\n\n.performer-create-modal {\n  font-size: 1.2rem;\n  max-width: 800px;\n\n  .image-selection {\n    height: 450px;\n    text-align: center;\n\n    .performer-image {\n      height: 85%;\n      position: relative;\n\n      &-exclude {\n        position: absolute;\n        right: 20px;\n        top: 10px;\n      }\n    }\n\n    img {\n      max-height: 100%;\n      max-width: 100%;\n    }\n  }\n\n  .LoadingIndicator {\n    height: 100%;\n  }\n\n  &-field {\n    margin-bottom: 5px;\n\n    .btn {\n      margin-right: 5px;\n    }\n\n    .fa-icon {\n      width: 12px;\n    }\n  }\n\n  &-value ul {\n    font-size: 0.8em;\n    list-style-type: none;\n    padding-inline-start: 0;\n  }\n}\n\n.PerformerTagger {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: center;\n  max-width: 1600px;\n\n  &-header {\n    color: white;\n\n    &:hover {\n      color: white;\n    }\n  }\n\n  &-performer {\n    background-color: #495b68;\n    border-radius: 3px;\n    display: flex;\n    margin: 1rem;\n    max-width: 100%;\n    padding: 1rem;\n\n    .performer-card {\n      flex-shrink: 0;\n      width: 12rem;\n\n      img {\n        height: 100%;\n        max-height: 18rem;\n        object-fit: cover;\n        object-position: top;\n      }\n    }\n  }\n\n  &-details {\n    flex-grow: 1;\n    margin-left: 1rem;\n    width: 24rem;\n  }\n\n  &-performer-search {\n    display: flex;\n    flex-wrap: wrap;\n\n    &-item {\n      align-items: center;\n      display: flex;\n      overflow: hidden;\n      text-align: left;\n    }\n  }\n\n  &-thumb {\n    height: 40px;\n    margin-right: 10px;\n  }\n\n  &-box-link {\n    margin-bottom: 5px;\n\n    .input-group-text {\n      font-family: monospace;\n    }\n  }\n}\n\n.studio-create-modal {\n  font-size: 1.2rem;\n  max-width: 800px;\n\n  .image-selection {\n    text-align: center;\n\n    .studio-image {\n      height: 85%;\n      position: relative;\n\n      &-exclude {\n        position: absolute;\n        right: 20px;\n        top: 10px;\n      }\n    }\n\n    img {\n      max-height: 100%;\n      max-width: 100%;\n    }\n  }\n\n  .LoadingIndicator {\n    height: 100%;\n  }\n\n  &-field {\n    margin-bottom: 5px;\n\n    .btn {\n      margin-right: 5px;\n    }\n\n    .fa-icon {\n      width: 12px;\n    }\n  }\n}\n\n.StudioTagger,\n.TagTagger {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: center;\n  max-width: 1600px;\n\n  &-header {\n    color: white;\n\n    &:hover {\n      color: white;\n    }\n  }\n\n  &-studio {\n    background-color: #495b68;\n    border-radius: 3px;\n    display: flex;\n    margin: 1rem;\n    max-width: 100%;\n    padding: 1rem;\n\n    .studio-card {\n      box-shadow: none;\n      flex-shrink: 0;\n      margin: 0;\n      padding: 0;\n\n      img {\n        background-color: #495b68;\n        max-height: 150px;\n        object-fit: contain;\n        vertical-align: middle;\n        width: 100%;\n      }\n    }\n  }\n\n  &-details {\n    //flex-grow: 1;\n    display: flex;\n    flex-direction: column;\n    justify-content: space-between;\n    margin: 0.5rem;\n    width: 24rem;\n  }\n\n  &-details-image {\n    vertical-align: bottom;\n  }\n\n  &-details-text {\n    vertical-align: bottom;\n  }\n\n  &-studio-search,\n  &-tag-search {\n    display: flex;\n    flex-wrap: wrap;\n\n    &-item {\n      align-items: center;\n      display: flex;\n      overflow: hidden;\n      text-align: left;\n    }\n  }\n\n  &-thumb {\n    height: 40px;\n    margin-right: 10px;\n  }\n\n  &-box-link {\n    margin-bottom: 5px;\n\n    .input-group-text {\n      font-family: monospace;\n    }\n  }\n}\n\n.FieldSelect {\n  .fa-icon {\n    width: 12px;\n  }\n}\n\n.include-exclude-button {\n  display: inline-block;\n  margin-right: 0.38em;\n  padding: 0.2em;\n}\n\nli:not(.active) {\n  .include-exclude-button {\n    // visibility: hidden;\n    display: none;\n  }\n\n  .scene-image {\n    padding-left: 1rem;\n  }\n}\n\n.optional-field {\n  align-items: center;\n  display: inline-flex;\n  flex-direction: row;\n}\n\nli.active .optional-field.missing .optional-field-content {\n  color: #bfccd6;\n}\n\nli.active .optional-field.excluded .optional-field-content,\nli.active .optional-field.excluded .scene-link {\n  color: #bfccd6;\n  text-decoration: line-through;\n\n  img {\n    opacity: 0.5;\n  }\n}\n\n// li.active .scene-image-container {\n//   margin-left: 1rem;\n// }\n\n.tagger-container {\n  .scene-details,\n  .original-scene-details {\n    margin-top: 0.5rem;\n\n    > .row {\n      width: 100%;\n    }\n  }\n}\n\n.PHashPopover {\n  display: inline-block;\n  text-decoration: underline dotted;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useUpdateTag } from \"../queries\";\nimport TagModal from \"./TagModal\";\nimport { faTags } from \"@fortawesome/free-solid-svg-icons\";\nimport { useIntl } from \"react-intl\";\nimport { mergeTagStashIDs } from \"../utils\";\nimport { useTagCreate } from \"src/core/StashService\";\nimport { apolloError } from \"src/utils\";\n\ninterface IStashSearchResultProps {\n  tag: GQL.TagListDataFragment;\n  stashboxTags: GQL.ScrapedSceneTagDataFragment[];\n  endpoint: string;\n  onTagTagged: (\n    tag: Pick<GQL.TagListDataFragment, \"id\"> &\n      Partial<Omit<GQL.TagListDataFragment, \"id\">>\n  ) => void;\n  excludedTagFields: string[];\n}\n\nconst StashSearchResult: React.FC<IStashSearchResultProps> = ({\n  tag,\n  stashboxTags,\n  onTagTagged,\n  excludedTagFields,\n  endpoint,\n}) => {\n  const intl = useIntl();\n\n  const [modalTag, setModalTag] = useState<GQL.ScrapedSceneTagDataFragment>();\n  const [saveState, setSaveState] = useState<string>(\"\");\n  const [error, setError] = useState<{ message?: string; details?: string }>(\n    {}\n  );\n\n  const [createTag] = useTagCreate();\n  const updateTag = useUpdateTag();\n\n  function handleSaveError(name: string, message: string) {\n    setError({\n      message: intl.formatMessage(\n        { id: \"tag_tagger.failed_to_save_tag\" },\n        { tag: name }\n      ),\n      details:\n        message === \"UNIQUE constraint failed: tags.name\"\n          ? intl.formatMessage({\n              id: \"tag_tagger.name_already_exists\",\n            })\n          : message,\n    });\n  }\n\n  const handleSave = async (\n    input: GQL.TagCreateInput,\n    parentInput?: GQL.TagCreateInput\n  ) => {\n    setError({});\n    setModalTag(undefined);\n\n    if (parentInput) {\n      setSaveState(\"Saving parent tag\");\n\n      try {\n        const parentRes = await createTag({\n          variables: { input: parentInput },\n        });\n        input.parent_ids = [parentRes.data?.tagCreate?.id].filter(\n          Boolean\n        ) as string[];\n      } catch (e) {\n        handleSaveError(parentInput.name, apolloError(e));\n        setSaveState(\"\");\n        return;\n      }\n    }\n\n    setSaveState(\"Saving tag\");\n    const updateData: GQL.TagUpdateInput = {\n      ...input,\n      id: tag.id,\n    };\n\n    updateData.stash_ids = await mergeTagStashIDs(\n      tag.id,\n      input.stash_ids ?? []\n    );\n\n    const res = await updateTag(updateData);\n\n    if (!res?.data?.tagUpdate) {\n      handleSaveError(input.name ?? tag.name, res?.errors?.[0]?.message ?? \"\");\n    } else {\n      onTagTagged(tag);\n    }\n    setSaveState(\"\");\n  };\n\n  const tags = stashboxTags.map((p) => (\n    <Button\n      className=\"TagTagger-tag-search-item minimal col-6\"\n      variant=\"link\"\n      key={p.remote_site_id}\n      onClick={() => setModalTag(p)}\n    >\n      <span>{p.name}</span>\n    </Button>\n  ));\n\n  return (\n    <>\n      {modalTag && (\n        <TagModal\n          closeModal={() => setModalTag(undefined)}\n          modalVisible={modalTag !== undefined}\n          tag={modalTag}\n          onSave={handleSave}\n          icon={faTags}\n          header=\"Update Tag\"\n          excludedTagFields={excludedTagFields}\n          endpoint={endpoint}\n        />\n      )}\n      <div className=\"TagTagger-tag-search\">{tags}</div>\n      <div className=\"row no-gutters mt-2 align-items-center justify-content-end\">\n        {error.message && (\n          <div className=\"text-right text-danger mt-1\">\n            <strong>\n              <span className=\"mr-2\">Error:</span>\n              {error.message}\n            </strong>\n            <div>{error.details}</div>\n          </div>\n        )}\n        {saveState && (\n          <strong className=\"col-4 mt-1 mr-2 text-right\">{saveState}</strong>\n        )}\n      </div>\n    </>\n  );\n};\n\nexport default StashSearchResult;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/tags/TagModal.tsx",
    "content": "import React, { useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { IconDefinition } from \"@fortawesome/fontawesome-svg-core\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport {\n  faCheck,\n  faExternalLinkAlt,\n  faTimes,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\nimport { excludeFields } from \"src/utils/data\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\n\ninterface ITagModalProps {\n  tag: GQL.ScrapedSceneTagDataFragment;\n  modalVisible: boolean;\n  closeModal: () => void;\n  onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void;\n  excludedTagFields?: string[];\n  header: string;\n  icon: IconDefinition;\n  endpoint?: string;\n}\n\nconst TagModal: React.FC<ITagModalProps> = ({\n  modalVisible,\n  tag,\n  onSave,\n  closeModal,\n  excludedTagFields = [],\n  header,\n  icon,\n  endpoint,\n}) => {\n  const intl = useIntl();\n\n  const [excluded, setExcluded] = useState<Record<string, boolean>>(\n    excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})\n  );\n  const toggleField = (name: string) =>\n    setExcluded({\n      ...excluded,\n      [name]: !excluded[name],\n    });\n\n  const [createParentTag, setCreateParentTag] = useState<boolean>(\n    !!tag.parent && !tag.parent.stored_id\n  );\n\n  // Check if a tag with the parent name already exists locally.\n  // Categories don't have stash IDs, so stored_id may be null even when the\n  // parent tag has already been created (e.g. by tagging a sibling tag first).\n  const parentNameQuery = GQL.useFindTagsQuery({\n    skip: !tag.parent || !!tag.parent.stored_id,\n    variables: {\n      tag_filter: {\n        name: {\n          value: tag.parent?.name ?? \"\",\n          modifier: GQL.CriterionModifier.Equals,\n        },\n      },\n      filter: { per_page: 1 },\n    },\n  });\n  const existingParentId = parentNameQuery.data?.findTags.tags[0]?.id;\n\n  // If the parent already exists locally, don't offer to create it\n  const sendParentTag = !existingParentId;\n\n  const [parentExcluded, setParentExcluded] = useState<Record<string, boolean>>(\n    excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})\n  );\n  const toggleParentField = (name: string) =>\n    setParentExcluded({\n      ...parentExcluded,\n      [name]: !parentExcluded[name],\n    });\n\n  function maybeRenderField(\n    id: string,\n    text: string | null | undefined,\n    isSelectable: boolean = true\n  ) {\n    if (!text) return;\n\n    return (\n      <div className=\"row no-gutters\">\n        <div className=\"col-5 studio-create-modal-field\" key={id}>\n          {isSelectable && (\n            <Button\n              onClick={() => toggleField(id)}\n              variant=\"secondary\"\n              className={excluded[id] ? \"text-muted\" : \"text-success\"}\n            >\n              <Icon icon={excluded[id] ? faTimes : faCheck} />\n            </Button>\n          )}\n          <strong>\n            <FormattedMessage id={id} />:\n          </strong>\n        </div>\n        <TruncatedText className=\"col-7\" text={text} />\n      </div>\n    );\n  }\n\n  function maybeRenderStashBoxLink() {\n    const base = endpoint?.match(/https?:\\/\\/.*?\\//)?.[0];\n    const link = base ? `${base}tags/${tag.remote_site_id}` : undefined;\n\n    if (!link) return;\n\n    return (\n      <h6 className=\"mt-2\">\n        <ExternalLink href={link}>\n          <FormattedMessage id=\"stashbox.source\" />\n          <Icon icon={faExternalLinkAlt} className=\"ml-2\" />\n        </ExternalLink>\n      </h6>\n    );\n  }\n\n  function maybeRenderParentField(\n    id: string,\n    text: string | null | undefined,\n    isSelectable: boolean = true\n  ) {\n    if (!text) return;\n\n    return (\n      <div className=\"row no-gutters\">\n        <div className=\"col-5 studio-create-modal-field\" key={id}>\n          {isSelectable && (\n            <Button\n              onClick={() => toggleParentField(id)}\n              variant=\"secondary\"\n              className={parentExcluded[id] ? \"text-muted\" : \"text-success\"}\n            >\n              <Icon icon={parentExcluded[id] ? faTimes : faCheck} />\n            </Button>\n          )}\n          <strong>\n            <FormattedMessage id={id} />:\n          </strong>\n        </div>\n        <TruncatedText className=\"col-7\" text={text} />\n      </div>\n    );\n  }\n\n  function maybeRenderParentTagDetails() {\n    if (!createParentTag || !tag.parent) {\n      return;\n    }\n\n    return (\n      <div>\n        {maybeRenderParentField(\"name\", tag.parent.name, false)}\n        {maybeRenderParentField(\"description\", tag.parent.description)}\n      </div>\n    );\n  }\n\n  function maybeRenderParentTag() {\n    // No parent tag, or parent already exists locally\n    if (!tag.parent || tag.parent.stored_id || !sendParentTag) {\n      return;\n    }\n\n    return (\n      <div>\n        <div className=\"mb-4 mt-4\">\n          <Form.Check\n            id=\"create-parent\"\n            checked={createParentTag}\n            label={intl.formatMessage({\n              id: \"actions.create_parent_tag\",\n            })}\n            onChange={() => setCreateParentTag(!createParentTag)}\n          />\n        </div>\n        {maybeRenderParentTagDetails()}\n      </div>\n    );\n  }\n\n  function handleSave() {\n    if (!tag.name) {\n      throw new Error(\"tag name must be set\");\n    }\n\n    const parentId = tag.parent?.stored_id ?? existingParentId;\n\n    const tagData: GQL.TagCreateInput = {\n      name: tag.name,\n      description: tag.description ?? undefined,\n      aliases: tag.alias_list?.filter((a) => a) ?? undefined,\n      parent_ids: parentId ? [parentId] : undefined,\n    };\n\n    // stashid handling code\n    const remoteSiteID = tag.remote_site_id;\n    if (remoteSiteID && endpoint) {\n      tagData.stash_ids = [\n        {\n          endpoint,\n          stash_id: remoteSiteID,\n          updated_at: new Date().toISOString(),\n        },\n      ];\n    }\n\n    // handle exclusions\n    excludeFields(tagData, excluded);\n\n    let parentData: GQL.TagCreateInput | undefined = undefined;\n\n    // Categories don't have stash IDs, so we only create new parent tags\n    if (\n      createParentTag &&\n      sendParentTag &&\n      tag.parent &&\n      !tag.parent.stored_id\n    ) {\n      parentData = {\n        name: tag.parent.name,\n        description: tag.parent.description ?? undefined,\n      };\n\n      // handle exclusions\n      // Can't exclude parent tag name when creating a new one\n      parentExcluded.name = false;\n      excludeFields(parentData, parentExcluded);\n    }\n\n    onSave(tagData, parentData);\n  }\n\n  return (\n    <ModalComponent\n      show={modalVisible}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.save\" }),\n        onClick: handleSave,\n      }}\n      cancel={{ onClick: () => closeModal(), variant: \"secondary\" }}\n      onHide={() => closeModal()}\n      dialogClassName=\"studio-create-modal\"\n      icon={icon}\n      header={header}\n    >\n      <div>\n        <div className=\"row\">\n          <div className=\"col-12\">\n            {maybeRenderField(\"name\", tag.name)}\n            {maybeRenderField(\"description\", tag.description)}\n            {maybeRenderField(\"aliases\", tag.alias_list?.join(\", \"))}\n            {maybeRenderField(\"parent_tags\", tag.parent?.name, false)}\n            {maybeRenderStashBoxLink()}\n          </div>\n        </div>\n      </div>\n      {maybeRenderParentTag()}\n    </ModalComponent>\n  );\n};\n\nexport default TagModal;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/tags/TagTagger.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Button, Card, Form, InputGroup, ProgressBar } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Link } from \"react-router-dom\";\nimport { HashLink } from \"react-router-hash-link\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport {\n  stashBoxTagQuery,\n  useJobsSubscribe,\n  mutateStashBoxBatchTagTag,\n  getClient,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\n\nimport StashSearchResult from \"./StashSearchResult\";\nimport TaggerConfig, { ConfigButton } from \"../TaggerConfig\";\nimport { ITaggerConfig, TAG_FIELDS } from \"../constants\";\nimport { useUpdateTag } from \"../queries\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\nimport { mergeTagStashIDs } from \"../utils\";\nimport { separateNamesAndStashIds } from \"src/utils/stashIds\";\nimport { useTaggerConfig } from \"../config\";\nimport {\n  BatchUpdateModal,\n  BatchAddModal,\n} from \"src/components/Shared/BatchModals\";\nimport { StashBoxSelectorField } from \"../StashBoxSelector\";\n\ntype JobFragment = Pick<\n  GQL.Job,\n  \"id\" | \"status\" | \"subTasks\" | \"description\" | \"progress\"\n>;\n\nconst CLASSNAME = \"TagTagger\";\n\ninterface ITagTaggerListProps {\n  tags: GQL.TagListDataFragment[];\n  selectedEndpoint: { endpoint: string; index: number };\n  isIdle: boolean;\n  config: ITaggerConfig;\n  onBatchAdd: (tagInput: string, createParent: boolean) => void;\n  onBatchUpdate: (\n    ids: string[] | undefined,\n    refresh: boolean,\n    createParent: boolean\n  ) => void;\n}\n\nconst TagTaggerList: React.FC<ITagTaggerListProps> = ({\n  tags,\n  selectedEndpoint,\n  isIdle,\n  config,\n  onBatchAdd,\n  onBatchUpdate,\n}) => {\n  const intl = useIntl();\n\n  const [loading, setLoading] = useState(false);\n  const [searchResults, setSearchResults] = useState<\n    Record<string, GQL.ScrapedSceneTagDataFragment[]>\n  >({});\n  const [searchErrors, setSearchErrors] = useState<\n    Record<string, string | undefined>\n  >({});\n  const [taggedTags, setTaggedTags] = useState<\n    Record<string, Partial<GQL.TagListDataFragment>>\n  >({});\n  const [queries, setQueries] = useState<Record<string, string>>({});\n\n  const [showBatchAdd, setShowBatchAdd] = useState(false);\n  const [showBatchUpdate, setShowBatchUpdate] = useState(false);\n  const [batchAddParents, setBatchAddParents] = useState(\n    config.createParentTags || false\n  );\n\n  const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false);\n  const { data: allTags } = GQL.useFindTagsQuery({\n    skip: !showBatchUpdate,\n    variables: {\n      tag_filter: {\n        stash_id_endpoint: {\n          endpoint: selectedEndpoint.endpoint,\n          modifier: batchUpdateRefresh\n            ? GQL.CriterionModifier.NotNull\n            : GQL.CriterionModifier.IsNull,\n        },\n      },\n      filter: {\n        per_page: 0,\n      },\n    },\n  });\n\n  const [error, setError] = useState<\n    Record<string, { message?: string; details?: string } | undefined>\n  >({});\n  const [loadingUpdate, setLoadingUpdate] = useState<string | undefined>();\n\n  const doBoxSearch = (tagID: string, searchVal: string) => {\n    stashBoxTagQuery(searchVal, selectedEndpoint.endpoint)\n      .then((queryData) => {\n        const s = queryData.data?.scrapeSingleTag ?? [];\n        setSearchResults({\n          ...searchResults,\n          [tagID]: s,\n        });\n        setSearchErrors({\n          ...searchErrors,\n          [tagID]: undefined,\n        });\n        setLoading(false);\n      })\n      .catch(() => {\n        setLoading(false);\n        const { [tagID]: unassign, ...results } = searchResults;\n        setSearchResults(results);\n        setSearchErrors({\n          ...searchErrors,\n          [tagID]: intl.formatMessage({\n            id: \"tag_tagger.network_error\",\n          }),\n        });\n      });\n\n    setLoading(true);\n  };\n\n  const updateTag = useUpdateTag();\n\n  const doBoxUpdate = (tagID: string, stashID: string, endpoint: string) => {\n    setLoadingUpdate(stashID);\n    setError({\n      ...error,\n      [tagID]: undefined,\n    });\n    stashBoxTagQuery(stashID, endpoint)\n      .then(async (queryData) => {\n        const data = queryData.data?.scrapeSingleTag ?? [];\n        if (data.length > 0) {\n          const stashboxTag = data[0];\n          const updateData: GQL.TagUpdateInput = {\n            id: tagID,\n          };\n\n          if (\n            !(config.excludedTagFields ?? []).includes(\"name\") &&\n            stashboxTag.name\n          ) {\n            updateData.name = stashboxTag.name;\n          }\n\n          if (\n            stashboxTag.description &&\n            !(config.excludedTagFields ?? []).includes(\"description\")\n          ) {\n            updateData.description = stashboxTag.description;\n          }\n\n          if (\n            stashboxTag.alias_list &&\n            stashboxTag.alias_list.length > 0 &&\n            !(config.excludedTagFields ?? []).includes(\"aliases\")\n          ) {\n            updateData.aliases = stashboxTag.alias_list;\n          }\n\n          if (stashboxTag.remote_site_id) {\n            updateData.stash_ids = await mergeTagStashIDs(tagID, [\n              {\n                endpoint,\n                stash_id: stashboxTag.remote_site_id,\n              },\n            ]);\n          }\n\n          const res = await updateTag(updateData);\n          if (!res?.data?.tagUpdate) {\n            setError({\n              ...error,\n              [tagID]: {\n                message: `Failed to update tag`,\n                details: res?.errors?.[0]?.message ?? \"\",\n              },\n            });\n          }\n        }\n      })\n      .finally(() => setLoadingUpdate(undefined));\n  };\n\n  async function handleBatchAdd(input: string) {\n    onBatchAdd(input, batchAddParents);\n    setShowBatchAdd(false);\n  }\n\n  const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => {\n    onBatchUpdate(\n      !queryAll ? tags.map((t) => t.id) : undefined,\n      refresh,\n      batchAddParents\n    );\n    setShowBatchUpdate(false);\n  };\n\n  const handleTaggedTag = (\n    tag: Pick<GQL.TagListDataFragment, \"id\"> &\n      Partial<Omit<GQL.TagListDataFragment, \"id\">>\n  ) => {\n    setTaggedTags({\n      ...taggedTags,\n      [tag.id]: tag,\n    });\n  };\n\n  const renderTags = () =>\n    tags.map((tag) => {\n      const isTagged = taggedTags[tag.id];\n\n      const stashID = tag.stash_ids.find((s) => {\n        return s.endpoint === selectedEndpoint.endpoint;\n      });\n\n      let mainContent;\n      if (!isTagged && stashID !== undefined) {\n        mainContent = (\n          <div className=\"text-left\">\n            <h5 className=\"text-bold\">\n              <FormattedMessage id=\"tag_tagger.tag_already_tagged\" />\n            </h5>\n          </div>\n        );\n      } else if (!isTagged && !stashID) {\n        mainContent = (\n          <InputGroup>\n            <Form.Control\n              className=\"text-input\"\n              defaultValue={tag.name ?? \"\"}\n              onChange={(e) =>\n                setQueries({\n                  ...queries,\n                  [tag.id]: e.currentTarget.value,\n                })\n              }\n              onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>\n                e.key === \"Enter\" &&\n                doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? \"\")\n              }\n            />\n            <InputGroup.Append>\n              <Button\n                disabled={loading}\n                onClick={() =>\n                  doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? \"\")\n                }\n              >\n                <FormattedMessage id=\"actions.search\" />\n              </Button>\n            </InputGroup.Append>\n          </InputGroup>\n        );\n      } else if (isTagged) {\n        mainContent = (\n          <div className=\"d-flex flex-column text-left\">\n            <h5>\n              <FormattedMessage id=\"tag_tagger.tag_successfully_tagged\" />\n            </h5>\n          </div>\n        );\n      }\n\n      let subContent;\n      if (stashID !== undefined) {\n        const base = stashID.endpoint.match(/https?:\\/\\/.*?\\//)?.[0];\n        const link = base ? (\n          <ExternalLink\n            className=\"small d-block\"\n            href={`${base}tags/${stashID.stash_id}`}\n          >\n            {stashID.stash_id}\n          </ExternalLink>\n        ) : (\n          <div className=\"small\">{stashID.stash_id}</div>\n        );\n\n        subContent = (\n          <div key={tag.id}>\n            <InputGroup className=\"TagTagger-box-link\">\n              <InputGroup.Text>{link}</InputGroup.Text>\n              <InputGroup.Append>\n                <Button\n                  onClick={() =>\n                    doBoxUpdate(tag.id, stashID.stash_id, stashID.endpoint)\n                  }\n                  disabled={!!loadingUpdate}\n                >\n                  {loadingUpdate === stashID.stash_id ? (\n                    <LoadingIndicator inline small message=\"\" />\n                  ) : (\n                    <FormattedMessage id=\"actions.refresh\" />\n                  )}\n                </Button>\n              </InputGroup.Append>\n            </InputGroup>\n            {error[tag.id] && (\n              <div className=\"text-danger mt-1\">\n                <strong>\n                  <span className=\"mr-2\">Error:</span>\n                  {error[tag.id]?.message}\n                </strong>\n                <div>{error[tag.id]?.details}</div>\n              </div>\n            )}\n          </div>\n        );\n      } else if (searchErrors[tag.id]) {\n        subContent = (\n          <div className=\"text-danger font-weight-bold\">\n            {searchErrors[tag.id]}\n          </div>\n        );\n      } else if (searchResults[tag.id]?.length === 0) {\n        subContent = (\n          <div className=\"text-danger font-weight-bold\">\n            <FormattedMessage id=\"tag_tagger.no_results_found\" />\n          </div>\n        );\n      }\n\n      let searchResult;\n      if (searchResults[tag.id]?.length > 0 && !isTagged) {\n        searchResult = (\n          <StashSearchResult\n            key={tag.id}\n            stashboxTags={searchResults[tag.id]}\n            tag={tag}\n            endpoint={selectedEndpoint.endpoint}\n            onTagTagged={handleTaggedTag}\n            excludedTagFields={config.excludedTagFields ?? []}\n          />\n        );\n      }\n\n      return (\n        <div key={tag.id} className={`${CLASSNAME}-studio`}>\n          <div className={`${CLASSNAME}-details`}>\n            <div></div>\n            <div>\n              <Card className=\"studio-card\">\n                <img loading=\"lazy\" src={tag.image_path ?? \"\"} alt=\"\" />\n              </Card>\n            </div>\n            <div className={`${CLASSNAME}-details-text`}>\n              <Link to={`/tags/${tag.id}`} className={`${CLASSNAME}-header`}>\n                <h2>{tag.name}</h2>\n              </Link>\n              {mainContent}\n              <div className=\"sub-content text-left\">{subContent}</div>\n              {searchResult}\n            </div>\n          </div>\n        </div>\n      );\n    });\n\n  return (\n    <Card>\n      {showBatchUpdate && (\n        <BatchUpdateModal\n          close={() => setShowBatchUpdate(false)}\n          isIdle={isIdle}\n          selectedEndpoint={selectedEndpoint}\n          entities={tags}\n          allCount={allTags?.findTags.count}\n          onBatchUpdate={handleBatchUpdate}\n          onRefreshChange={setBatchUpdateRefresh}\n          batchAddParents={batchAddParents}\n          setBatchAddParents={setBatchAddParents}\n          localePrefix=\"tag_tagger\"\n          entityName=\"tag\"\n          countVariableName=\"tag_count\"\n        />\n      )}\n\n      {showBatchAdd && (\n        <BatchAddModal\n          close={() => setShowBatchAdd(false)}\n          isIdle={isIdle}\n          onBatchAdd={handleBatchAdd}\n          batchAddParents={batchAddParents}\n          setBatchAddParents={setBatchAddParents}\n          localePrefix=\"tag_tagger\"\n          entityName=\"tag\"\n        />\n      )}\n      <div className=\"ml-auto mb-3\">\n        <Button onClick={() => setShowBatchAdd(true)}>\n          <FormattedMessage id=\"tag_tagger.batch_add_tags\" />\n        </Button>\n        <Button className=\"ml-3\" onClick={() => setShowBatchUpdate(true)}>\n          <FormattedMessage id=\"tag_tagger.batch_update_tags\" />\n        </Button>\n      </div>\n      <div className={CLASSNAME}>{renderTags()}</div>\n    </Card>\n  );\n};\n\ninterface ITaggerProps {\n  tags: GQL.TagListDataFragment[];\n}\n\nexport const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {\n  const jobsSubscribe = useJobsSubscribe();\n  const { configuration: stashConfig } = useConfigurationContext();\n  const { config, setConfig } = useTaggerConfig();\n  const [showConfig, setShowConfig] = useState(false);\n\n  const [batchJobID, setBatchJobID] = useState<string | undefined | null>();\n  const [batchJob, setBatchJob] = useState<JobFragment | undefined>();\n\n  useEffect(() => {\n    if (!jobsSubscribe.data) {\n      return;\n    }\n\n    const event = jobsSubscribe.data.jobsSubscribe;\n    if (event.job.id !== batchJobID) {\n      return;\n    }\n\n    if (event.type !== GQL.JobStatusUpdateType.Remove) {\n      setBatchJob(event.job);\n    } else {\n      setBatchJob(undefined);\n      setBatchJobID(undefined);\n\n      const ac = getClient();\n      ac.cache.evict({ fieldName: \"findTags\" });\n      ac.cache.gc();\n    }\n  }, [jobsSubscribe, batchJobID]);\n\n  if (!config) return <LoadingIndicator />;\n\n  const savedEndpointIndex =\n    stashConfig?.general.stashBoxes.findIndex(\n      (s) => s.endpoint === config.selectedEndpoint\n    ) ?? -1;\n  const selectedEndpointIndex =\n    savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length\n      ? 0\n      : savedEndpointIndex;\n  const selectedEndpoint =\n    stashConfig?.general.stashBoxes[selectedEndpointIndex];\n\n  async function batchAdd(tagInput: string, createParent: boolean) {\n    if (tagInput && selectedEndpoint) {\n      const inputs = tagInput\n        .split(\",\")\n        .map((n) => n.trim())\n        .filter((n) => n.length > 0);\n\n      const { names, stashIds } = separateNamesAndStashIds(inputs);\n\n      if (names.length > 0 || stashIds.length > 0) {\n        const ret = await mutateStashBoxBatchTagTag({\n          names: names.length > 0 ? names : undefined,\n          stash_ids: stashIds.length > 0 ? stashIds : undefined,\n          endpoint: selectedEndpointIndex,\n          refresh: false,\n          createParent: createParent,\n          exclude_fields: config?.excludedTagFields ?? [],\n        });\n\n        setBatchJobID(ret.data?.stashBoxBatchTagTag);\n      }\n    }\n  }\n\n  async function batchUpdate(\n    ids: string[] | undefined,\n    refresh: boolean,\n    createParent: boolean\n  ) {\n    if (selectedEndpoint) {\n      const ret = await mutateStashBoxBatchTagTag({\n        ids: ids,\n        endpoint: selectedEndpointIndex,\n        refresh,\n        createParent: createParent,\n        exclude_fields: config?.excludedTagFields ?? [],\n      });\n\n      setBatchJobID(ret.data?.stashBoxBatchTagTag);\n    }\n  }\n\n  function renderStatus() {\n    if (batchJob) {\n      const progress =\n        batchJob.progress !== undefined && batchJob.progress !== null\n          ? batchJob.progress * 100\n          : undefined;\n      return (\n        <Form.Group className=\"px-4\">\n          <h5>\n            <FormattedMessage id=\"tag_tagger.status_tagging_tags\" />\n          </h5>\n          {progress !== undefined && (\n            <ProgressBar\n              animated\n              now={progress}\n              label={`${progress.toFixed(0)}%`}\n            />\n          )}\n        </Form.Group>\n      );\n    }\n\n    if (batchJobID !== undefined) {\n      return (\n        <Form.Group className=\"px-4\">\n          <h5>\n            <FormattedMessage id=\"tag_tagger.status_tagging_job_queued\" />\n          </h5>\n        </Form.Group>\n      );\n    }\n  }\n\n  if (selectedEndpointIndex === -1 || !selectedEndpoint) {\n    return (\n      <div className=\"my-4\">\n        <h3 className=\"text-center mt-4\">\n          <FormattedMessage id=\"tag_tagger.to_use_the_tag_tagger\" />\n        </h3>\n        <h5 className=\"text-center\">\n          <FormattedMessage\n            id=\"refer_to\"\n            values={{\n              link: (\n                <HashLink\n                  to=\"/settings?tab=metadata-providers#stash-boxes\"\n                  scroll={(el) =>\n                    el.scrollIntoView({ behavior: \"smooth\", block: \"center\" })\n                  }\n                >\n                  <FormattedMessage id=\"config.stashbox.title\" />\n                </HashLink>\n              ),\n            }}\n          />\n        </h5>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {renderStatus()}\n      <div className=\"tagger-container mx-md-auto\">\n        <div className=\"tagger-container-header\">\n          <div className=\"d-flex justify-content-between align-items-center flex-wrap\">\n            <div className=\"w-auto\">\n              <StashBoxSelectorField\n                stashBoxes={stashConfig?.general.stashBoxes ?? []}\n                selectedEndpoint={selectedEndpoint.endpoint}\n                onEndpointChange={(endpoint) =>\n                  setConfig({ ...config, selectedEndpoint: endpoint })\n                }\n              />\n            </div>\n            <div className=\"d-flex\">\n              <div className=\"ml-2\">\n                <ConfigButton\n                  showConfig={showConfig}\n                  onClick={() => setShowConfig(!showConfig)}\n                />\n              </div>\n            </div>\n          </div>\n\n          <TaggerConfig\n            show={showConfig}\n            excludedFields={config.excludedTagFields ?? []}\n            onFieldsChange={(fields) =>\n              setConfig({ ...config, excludedTagFields: fields })\n            }\n            fields={TAG_FIELDS}\n            entityName=\"tags\"\n            extraConfig={\n              <Form.Group\n                controlId=\"create-parent\"\n                className=\"align-items-center\"\n              >\n                <Form.Check\n                  label={\n                    <FormattedMessage id=\"tag_tagger.config.create_parent_label\" />\n                  }\n                  checked={config.createParentTags}\n                  onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                    setConfig({\n                      ...config,\n                      createParentTags: e.currentTarget.checked,\n                    })\n                  }\n                />\n                <Form.Text>\n                  <FormattedMessage id=\"tag_tagger.config.create_parent_desc\" />\n                </Form.Text>\n              </Form.Group>\n            }\n          />\n        </div>\n\n        <TagTaggerList\n          tags={tags}\n          selectedEndpoint={{\n            endpoint: selectedEndpoint.endpoint,\n            index: selectedEndpointIndex,\n          }}\n          isIdle={batchJobID === undefined}\n          config={config}\n          onBatchAdd={batchAdd}\n          onBatchUpdate={batchUpdate}\n        />\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tagger/utils.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\nimport { ParseMode } from \"./constants\";\nimport { queryFindStudio, queryFindTag } from \"src/core/StashService\";\nimport { mergeStashIDs } from \"src/utils/stashbox\";\n\nconst months = [\n  \"jan\",\n  \"feb\",\n  \"mar\",\n  \"apr\",\n  \"may\",\n  \"jun\",\n  \"jul\",\n  \"aug\",\n  \"sep\",\n  \"oct\",\n  \"nov\",\n  \"dec\",\n];\n\nconst ddmmyyRegex = /\\.(\\d\\d)\\.(\\d\\d)\\.(\\d\\d)\\./;\nconst yyyymmddRegex = /(\\d{4})[-.](\\d{2})[-.](\\d{2})/;\nconst mmddyyRegex = /(\\d{2})[-.](\\d{2})[-.](\\d{4})/;\nconst ddMMyyRegex = new RegExp(\n  `(\\\\d{1,2}).(${months.join(\"|\")})\\\\.?.(\\\\d{4})`,\n  \"i\"\n);\nconst MMddyyRegex = new RegExp(\n  `(${months.join(\"|\")})\\\\.?.(\\\\d{1,2}),?.(\\\\d{4})`,\n  \"i\"\n);\nconst javcodeRegex = /([a-zA-Z|tT28|tT38]+-\\d+[zZeE]?)/;\n\nconst handleSpecialStrings = (input: string): string => {\n  let output = input;\n  const ddmmyy = output.match(ddmmyyRegex);\n  if (ddmmyy) {\n    output = output.replace(\n      ddmmyy[0],\n      ` 20${ddmmyy[1]}-${ddmmyy[2]}-${ddmmyy[3]} `\n    );\n  }\n  const mmddyy = output.match(mmddyyRegex);\n  if (mmddyy) {\n    output = output.replace(\n      mmddyy[0],\n      ` ${mmddyy[1]}-${mmddyy[2]}-${mmddyy[3]} `\n    );\n  }\n  const ddMMyy = output.match(ddMMyyRegex);\n  if (ddMMyy) {\n    const month = (months.indexOf(ddMMyy[2].toLowerCase()) + 1)\n      .toString()\n      .padStart(2, \"0\");\n    output = output.replace(\n      ddMMyy[0],\n      ` ${ddMMyy[3]}-${month}-${ddMMyy[1].padStart(2, \"0\")} `\n    );\n  }\n  const MMddyy = output.match(MMddyyRegex);\n  if (MMddyy) {\n    const month = (months.indexOf(MMddyy[1].toLowerCase()) + 1)\n      .toString()\n      .padStart(2, \"0\");\n    output = output.replace(\n      MMddyy[0],\n      ` ${MMddyy[3]}-${month}-${MMddyy[2].padStart(2, \"0\")} `\n    );\n  }\n\n  const yyyymmdd = output.search(yyyymmddRegex);\n  // if we find a date, then replace hyphens with spaces outside of the date\n  // replace dots with hyphens in the date\n  if (yyyymmdd !== -1)\n    return (\n      output.slice(0, yyyymmdd).replace(/-/g, \" \") +\n      output.slice(yyyymmdd, yyyymmdd + 10).replace(/\\./g, \"-\") +\n      output.slice(yyyymmdd + 10).replace(/-/g, \" \")\n    );\n\n  const javcodeIndex = output.search(javcodeRegex);\n  // if we find a javcode, then replace hyphens with spaces outside of the javcode\n  if (javcodeIndex !== -1) {\n    const javcodeLength = output.match(javcodeRegex)![1].length;\n    return (\n      output.slice(0, javcodeIndex).replace(/-/g, \" \") +\n      output.slice(javcodeIndex, javcodeIndex + javcodeLength) +\n      output.slice(javcodeIndex + javcodeLength).replace(/-/g, \" \")\n    );\n  }\n  // otherwise just replace hyphens with spaces\n  return output.replace(/-/g, \" \");\n};\n\nexport function prepareQueryString(\n  scene: Partial<GQL.SlimSceneDataFragment>,\n  paths: string[],\n  filename: string,\n  mode: ParseMode,\n  blacklist: string[]\n) {\n  const regexs = blacklist\n    .map((b) => {\n      try {\n        return new RegExp(b, \"gi\");\n      } catch {\n        // ignore\n        return null;\n      }\n    })\n    .filter((r) => r !== null) as RegExp[];\n\n  if ((mode === \"auto\" && scene.date && scene.studio) || mode === \"metadata\") {\n    let str = [\n      scene.date,\n      scene.studio?.name ?? \"\",\n      (scene?.performers ?? []).map((p) => p.name).join(\" \"),\n      scene?.title ? scene.title.replace(/[^a-zA-Z0-9 ]+/g, \"\") : \"\",\n    ]\n      .filter((s) => s !== \"\")\n      .join(\" \");\n    regexs.forEach((re) => {\n      str = str.replace(re, \" \");\n    });\n    return str;\n  }\n  let s = \"\";\n\n  if (mode === \"auto\" || mode === \"filename\") {\n    s = filename;\n  } else if (mode === \"path\") {\n    s = [...paths, filename].join(\" \");\n  } else if (mode === \"dir\" && paths.length) {\n    s = paths[paths.length - 1];\n  }\n\n  regexs.forEach((re) => {\n    s = s.replace(re, \" \");\n  });\n  s = handleSpecialStrings(s);\n  return s.replace(/\\./g, \" \").replace(/ +/g, \" \");\n}\n\nexport const parsePath = (filePath: string) => {\n  if (!filePath) {\n    return {\n      paths: [],\n      file: \"\",\n      ext: \"\",\n    };\n  }\n\n  const path = filePath.toLowerCase();\n  // Absolute paths on Windows start with a drive letter (e.g. C:\\)\n  // Alternatively, they may start with a UNC path (e.g. \\\\server\\share)\n  // Remove the drive letter/UNC and replace backslashes with forward slashes\n  const normalizedPath = path.replace(/^[a-z]:|\\\\\\\\/, \"\").replace(/\\\\/g, \"/\");\n  const pathComponents = normalizedPath\n    .split(\"/\")\n    .filter((component) => component.trim().length > 0);\n  const fileName = pathComponents[pathComponents.length - 1];\n\n  const ext = fileName.match(/\\.[a-z0-9]*$/)?.[0] ?? \"\";\n  const file = fileName.slice(0, ext.length * -1);\n\n  // remove any .. or . paths\n  const paths = (\n    pathComponents.length >= 1\n      ? pathComponents.slice(0, pathComponents.length - 1)\n      : []\n  ).filter((p) => p !== \"..\" && p !== \".\");\n\n  return { paths, file, ext };\n};\n\nasync function mergeEntityStashIDs(\n  fetchExisting: (id: string) => Promise<GQL.StashIdInput[] | undefined>,\n  id: string,\n  newStashIDs: GQL.StashIdInput[]\n) {\n  const existing = await fetchExisting(id);\n  if (existing) {\n    return mergeStashIDs(existing, newStashIDs);\n  }\n  return newStashIDs;\n}\n\nexport const mergeStudioStashIDs = (\n  id: string,\n  newStashIDs: GQL.StashIdInput[]\n) =>\n  mergeEntityStashIDs(\n    async (studioId) =>\n      (await queryFindStudio(studioId))?.data?.findStudio?.stash_ids,\n    id,\n    newStashIDs\n  );\n\nexport const mergeTagStashIDs = (id: string, newStashIDs: GQL.StashIdInput[]) =>\n  mergeEntityStashIDs(\n    async (tagId) => (await queryFindTag(tagId))?.data?.findTag?.stash_ids,\n    id,\n    newStashIDs\n  );\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/EditTagsDialog.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Form } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useBulkTagUpdate } from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ModalComponent } from \"../Shared/Modal\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { MultiSet } from \"../Shared/MultiSet\";\nimport {\n  getAggregateState,\n  getAggregateStateObject,\n} from \"src/utils/bulkUpdate\";\nimport { IndeterminateCheckbox } from \"../Shared/IndeterminateCheckbox\";\nimport { BulkUpdateFormGroup, BulkUpdateTextInput } from \"../Shared/BulkUpdate\";\nimport { faPencilAlt } from \"@fortawesome/free-solid-svg-icons\";\n\nfunction Tags(props: {\n  isUpdating: boolean;\n  controlId: string;\n  messageId: string;\n  existingTagIds: string[] | undefined;\n  tagIDs: GQL.BulkUpdateIds;\n  setTagIDs: (value: React.SetStateAction<GQL.BulkUpdateIds>) => void;\n}) {\n  const {\n    isUpdating,\n    controlId,\n    messageId,\n    existingTagIds,\n    tagIDs,\n    setTagIDs,\n  } = props;\n\n  return (\n    <Form.Group controlId={controlId}>\n      <Form.Label>\n        <FormattedMessage id={messageId} />\n      </Form.Label>\n      <MultiSet\n        type=\"tags\"\n        disabled={isUpdating}\n        onUpdate={(itemIDs) =>\n          setTagIDs((existing) => ({ ...existing, ids: itemIDs }))\n        }\n        onSetMode={(newMode) =>\n          setTagIDs((existing) => ({ ...existing, mode: newMode }))\n        }\n        existingIds={existingTagIds ?? []}\n        ids={tagIDs.ids ?? []}\n        mode={tagIDs.mode}\n        menuPortalTarget={document.body}\n      />\n    </Form.Group>\n  );\n}\n\ninterface IListOperationProps {\n  selected: (GQL.TagDataFragment | GQL.TagListDataFragment)[];\n  onClose: (applied: boolean) => void;\n}\n\nconst tagFields = [\"favorite\", \"description\", \"ignore_auto_tag\"];\n\nexport const EditTagsDialog: React.FC<IListOperationProps> = (\n  props: IListOperationProps\n) => {\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const [parentTagIDs, setParentTagIDs_] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n\n  function setParentTagIDs(value: React.SetStateAction<GQL.BulkUpdateIds>) {\n    console.log(value);\n    setParentTagIDs_(value);\n  }\n\n  const [existingParentTagIds, setExistingParentTagIds] = useState<string[]>();\n\n  const [childTagIDs, setChildTagIDs] = useState<GQL.BulkUpdateIds>({\n    mode: GQL.BulkUpdateIdMode.Add,\n  });\n  const [existingChildTagIds, setExistingChildTagIds] = useState<string[]>();\n\n  const [updateInput, setUpdateInput] = useState<GQL.BulkTagUpdateInput>({});\n\n  const unsetDisabled = props.selected.length < 2;\n\n  const [updateTags] = useBulkTagUpdate(getTagInput());\n\n  // Network state\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  function setUpdateField(input: Partial<GQL.BulkTagUpdateInput>) {\n    setUpdateInput({ ...updateInput, ...input });\n  }\n\n  function getTagInput(): GQL.BulkTagUpdateInput {\n    const tagInput: GQL.BulkTagUpdateInput = {\n      ids: props.selected.map((tag) => {\n        return tag.id;\n      }),\n      ...updateInput,\n      parent_ids: parentTagIDs,\n      child_ids: childTagIDs,\n    };\n\n    return tagInput;\n  }\n\n  async function onSave() {\n    setIsUpdating(true);\n    try {\n      await updateTags();\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"tags\" }).toLocaleLowerCase(),\n          }\n        )\n      );\n      props.onClose(true);\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsUpdating(false);\n  }\n\n  useEffect(() => {\n    const updateState: GQL.BulkTagUpdateInput = {};\n\n    const state = props.selected;\n    let updateParentTagIds: string[] = [];\n    let updateChildTagIds: string[] = [];\n    let first = true;\n\n    state.forEach((tag: GQL.TagDataFragment | GQL.TagListDataFragment) => {\n      getAggregateStateObject(updateState, tag, tagFields, first);\n\n      const thisParents = (tag.parents ?? []).map((t) => t.id).sort();\n      updateParentTagIds =\n        getAggregateState(updateParentTagIds, thisParents, first) ?? [];\n\n      const thisChildren = (tag.children ?? []).map((t) => t.id).sort();\n      updateChildTagIds =\n        getAggregateState(updateChildTagIds, thisChildren, first) ?? [];\n\n      first = false;\n    });\n\n    setExistingParentTagIds(updateParentTagIds);\n    setExistingChildTagIds(updateChildTagIds);\n    setUpdateInput(updateState);\n  }, [props.selected]);\n\n  return (\n    <ModalComponent\n      dialogClassName=\"edit-tags-dialog\"\n      show\n      icon={faPencilAlt}\n      header={intl.formatMessage(\n        { id: \"dialogs.edit_entity_count_title\" },\n        {\n          count: props?.selected?.length ?? 1,\n          singularEntity: intl.formatMessage({ id: \"tag\" }),\n          pluralEntity: intl.formatMessage({ id: \"tags\" }),\n        }\n      )}\n      accept={{\n        onClick: onSave,\n        text: intl.formatMessage({ id: \"actions.apply\" }),\n      }}\n      cancel={{\n        onClick: () => props.onClose(false),\n        text: intl.formatMessage({ id: \"actions.cancel\" }),\n        variant: \"secondary\",\n      }}\n      isRunning={isUpdating}\n    >\n      <Form>\n        <Form.Group controlId=\"favorite\">\n          <IndeterminateCheckbox\n            setChecked={(checked) => setUpdateField({ favorite: checked })}\n            checked={updateInput.favorite ?? undefined}\n            label={intl.formatMessage({ id: \"favourite\" })}\n          />\n        </Form.Group>\n\n        <BulkUpdateFormGroup name=\"description\" inline={false}>\n          <BulkUpdateTextInput\n            value={updateInput.description}\n            valueChanged={(newValue) =>\n              setUpdateField({ description: newValue })\n            }\n            unsetDisabled={unsetDisabled}\n            as=\"textarea\"\n          />\n        </BulkUpdateFormGroup>\n\n        <Tags\n          isUpdating={isUpdating}\n          controlId=\"parent-tags\"\n          messageId=\"parent_tags\"\n          existingTagIds={existingParentTagIds}\n          tagIDs={parentTagIDs}\n          setTagIDs={setParentTagIDs}\n        />\n\n        <Tags\n          isUpdating={isUpdating}\n          controlId=\"sub-tags\"\n          messageId=\"sub_tags\"\n          existingTagIds={existingChildTagIds}\n          tagIDs={childTagIDs}\n          setTagIDs={setChildTagIDs}\n        />\n\n        <Form.Group controlId=\"ignore-auto-tags\">\n          <IndeterminateCheckbox\n            label={intl.formatMessage({ id: \"ignore_auto_tag\" })}\n            setChecked={(checked) =>\n              setUpdateField({ ignore_auto_tag: checked })\n            }\n            checked={updateInput.ignore_auto_tag ?? undefined}\n          />\n        </Form.Group>\n      </Form>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagCard.tsx",
    "content": "import { PatchComponent } from \"src/patch\";\nimport { Button, ButtonGroup } from \"react-bootstrap\";\nimport React from \"react\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport NavUtils from \"src/utils/navigation\";\nimport { FormattedMessage } from \"react-intl\";\nimport { TruncatedText } from \"../Shared/TruncatedText\";\nimport { GridCard } from \"../Shared/GridCard/GridCard\";\nimport { PopoverCountButton } from \"../Shared/PopoverCountButton\";\nimport { Icon } from \"../Shared/Icon\";\nimport { faHeart } from \"@fortawesome/free-solid-svg-icons\";\nimport cx from \"classnames\";\nimport { useTagUpdate } from \"src/core/StashService\";\n\ninterface IProps {\n  tag: GQL.TagDataFragment | GQL.TagListDataFragment;\n  cardWidth?: number;\n  zoomIndex: number;\n  selecting?: boolean;\n  selected?: boolean;\n  onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n}\n\nconst TagCardPopovers: React.FC<IProps> = PatchComponent(\n  \"TagCard.Popovers\",\n  ({ tag }) => {\n    return (\n      <>\n        <hr />\n        <ButtonGroup className=\"card-popovers\">\n          <PopoverCountButton\n            className=\"scene-count\"\n            type=\"scene\"\n            count={tag.scene_count}\n            url={NavUtils.makeTagScenesUrl(tag)}\n            showZero={false}\n          />\n          <PopoverCountButton\n            className=\"image-count\"\n            type=\"image\"\n            count={tag.image_count}\n            url={NavUtils.makeTagImagesUrl(tag)}\n            showZero={false}\n          />\n          <PopoverCountButton\n            className=\"gallery-count\"\n            type=\"gallery\"\n            count={tag.gallery_count}\n            url={NavUtils.makeTagGalleriesUrl(tag)}\n            showZero={false}\n          />\n          <PopoverCountButton\n            className=\"group-count\"\n            type=\"group\"\n            count={tag.group_count}\n            url={NavUtils.makeTagGroupsUrl(tag)}\n            showZero={false}\n          />\n          <PopoverCountButton\n            className=\"marker-count\"\n            type=\"marker\"\n            count={tag.scene_marker_count}\n            url={NavUtils.makeTagSceneMarkersUrl(tag)}\n            showZero={false}\n          />\n          <PopoverCountButton\n            className=\"performer-count\"\n            type=\"performer\"\n            count={tag.performer_count}\n            url={NavUtils.makeTagPerformersUrl(tag)}\n            showZero={false}\n          />\n          <PopoverCountButton\n            className=\"studio-count\"\n            type=\"studio\"\n            count={tag.studio_count}\n            url={NavUtils.makeTagStudiosUrl(tag)}\n            showZero={false}\n          />\n        </ButtonGroup>\n      </>\n    );\n  }\n);\n\nconst TagCardOverlays: React.FC<IProps> = PatchComponent(\n  \"TagCard.Overlays\",\n  ({ tag }) => {\n    const [updateTag] = useTagUpdate();\n\n    function renderFavoriteIcon() {\n      return (\n        <Link to=\"\" onClick={(e) => e.preventDefault()}>\n          <Button\n            className={cx(\n              \"minimal\",\n              \"mousetrap\",\n              \"favorite-button\",\n              tag.favorite ? \"favorite\" : \"not-favorite\"\n            )}\n            onClick={() => onToggleFavorite!(!tag.favorite)}\n          >\n            <Icon icon={faHeart} size=\"2x\" />\n          </Button>\n        </Link>\n      );\n    }\n\n    function onToggleFavorite(v: boolean) {\n      if (tag.id) {\n        updateTag({\n          variables: {\n            input: {\n              id: tag.id,\n              favorite: v,\n            },\n          },\n        });\n      }\n    }\n\n    return <>{renderFavoriteIcon()}</>;\n  }\n);\n\nconst TagCardDetails: React.FC<IProps> = PatchComponent(\n  \"TagCard.Details\",\n  ({ tag }) => {\n    function maybeRenderDescription() {\n      if (tag.description) {\n        return (\n          <TruncatedText\n            className=\"tag-description\"\n            text={tag.description}\n            lineCount={3}\n          />\n        );\n      }\n    }\n\n    function maybeRenderParents() {\n      if (tag.parents.length === 1) {\n        const parent = tag.parents[0];\n        return (\n          <div className=\"tag-parent-tags\">\n            <FormattedMessage\n              id=\"sub_tag_of\"\n              values={{\n                parent: <Link to={`/tags/${parent.id}`}>{parent.name}</Link>,\n              }}\n            />\n          </div>\n        );\n      }\n\n      if (tag.parents.length > 1) {\n        return (\n          <div className=\"tag-parent-tags\">\n            <FormattedMessage\n              id=\"sub_tag_of\"\n              values={{\n                parent: (\n                  <Link to={NavUtils.makeParentTagsUrl(tag)}>\n                    {tag.parents.length}&nbsp;\n                    <FormattedMessage\n                      id=\"countables.tags\"\n                      values={{ count: tag.parents.length }}\n                    />\n                  </Link>\n                ),\n              }}\n            />\n          </div>\n        );\n      }\n    }\n\n    function maybeRenderChildren() {\n      if (tag.children.length > 0) {\n        return (\n          <div className=\"tag-sub-tags\">\n            <FormattedMessage\n              id=\"parent_of\"\n              values={{\n                children: (\n                  <Link to={NavUtils.makeChildTagsUrl(tag)}>\n                    {tag.children.length}&nbsp;\n                    <FormattedMessage\n                      id=\"countables.tags\"\n                      values={{ count: tag.children.length }}\n                    />\n                  </Link>\n                ),\n              }}\n            />\n          </div>\n        );\n      }\n    }\n\n    return (\n      <>\n        {maybeRenderDescription()}\n        {maybeRenderParents()}\n        {maybeRenderChildren()}\n      </>\n    );\n  }\n);\n\nconst TagCardImage: React.FC<IProps> = PatchComponent(\n  \"TagCard.Image\",\n  ({ tag }) => {\n    return (\n      <>\n        <img\n          loading=\"lazy\"\n          className=\"tag-card-image\"\n          alt={tag.name}\n          src={tag.image_path ?? \"\"}\n        />\n      </>\n    );\n  }\n);\n\nconst TagCardTitle: React.FC<IProps> = PatchComponent(\n  \"TagCard.Title\",\n  ({ tag }) => {\n    return <>{tag.name ?? \"\"}</>;\n  }\n);\n\nexport const TagCard: React.FC<IProps> = PatchComponent(\"TagCard\", (props) => {\n  const { tag, cardWidth, zoomIndex, selecting, selected, onSelectedChanged } =\n    props;\n\n  return (\n    <GridCard\n      className={`tag-card zoom-${zoomIndex}`}\n      url={`/tags/${tag.id}`}\n      width={cardWidth}\n      title={<TagCardTitle {...props} />}\n      linkClassName=\"tag-card-header\"\n      image={<TagCardImage {...props} />}\n      details={<TagCardDetails {...props} />}\n      overlays={<TagCardOverlays {...props} />}\n      popovers={<TagCardPopovers {...props} />}\n      selected={selected}\n      selecting={selecting}\n      onSelectedChanged={onSelectedChanged}\n    />\n  );\n});\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagCardGrid.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useCardWidth,\n  useContainerDimensions,\n} from \"../Shared/GridCard/GridCard\";\nimport { TagCard } from \"./TagCard\";\nimport { PatchComponent } from \"src/patch\";\n\ninterface ITagCardGrid {\n  tags: (GQL.TagDataFragment | GQL.TagListDataFragment)[];\n  selectedIds: Set<string>;\n  zoomIndex: number;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nconst zoomWidths = [280, 340, 480, 640];\n\nexport const TagCardGrid: React.FC<ITagCardGrid> = PatchComponent(\n  \"TagCardGrid\",\n  ({ tags, selectedIds, zoomIndex, onSelectChange }) => {\n    const [componentRef, { width: containerWidth }] = useContainerDimensions();\n    const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);\n\n    return (\n      <div className=\"row justify-content-center\" ref={componentRef}>\n        {tags.map((tag) => (\n          <TagCard\n            key={tag.id}\n            cardWidth={cardWidth}\n            tag={tag}\n            zoomIndex={zoomIndex}\n            selecting={selectedIds.size > 0}\n            selected={selectedIds.has(tag.id)}\n            onSelectedChanged={(selected: boolean, shiftKey: boolean) =>\n              onSelectChange(tag.id, selected, shiftKey)\n            }\n          />\n        ))}\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/Tag.tsx",
    "content": "import { Button, Tabs, Tab, Form } from \"react-bootstrap\";\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport { useHistory, Redirect, RouteComponentProps } from \"react-router-dom\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { Helmet } from \"react-helmet\";\nimport cx from \"classnames\";\nimport Mousetrap from \"mousetrap\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useFindTag,\n  useTagUpdate,\n  useTagDestroy,\n  mutateMetadataAutoTag,\n} from \"src/core/StashService\";\nimport { DetailsEditNavbar } from \"src/components/Shared/DetailsEditNavbar\";\nimport { ErrorMessage } from \"src/components/Shared/ErrorMessage\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { tagRelationHook } from \"src/core/tags\";\nimport { TagScenesPanel } from \"./TagScenesPanel\";\nimport { TagMarkersPanel } from \"./TagMarkersPanel\";\nimport { TagImagesPanel } from \"./TagImagesPanel\";\nimport { TagPerformersPanel } from \"./TagPerformersPanel\";\nimport { TagStudiosPanel } from \"./TagStudiosPanel\";\nimport { TagGalleriesPanel } from \"./TagGalleriesPanel\";\nimport { CompressedTagDetailsPanel, TagDetailsPanel } from \"./TagDetailsPanel\";\nimport { TagEditPanel } from \"./TagEditPanel\";\nimport { TagMergeModal } from \"../TagMergeDialog\";\nimport { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { DetailImage } from \"src/components/Shared/DetailImage\";\nimport { useLoadStickyHeader } from \"src/hooks/detailsPanel\";\nimport { useScrollToTopOnMount } from \"src/hooks/scrollToTop\";\nimport { TagGroupsPanel } from \"./TagGroupsPanel\";\nimport { BackgroundImage } from \"src/components/Shared/DetailsPage/BackgroundImage\";\nimport {\n  TabTitleCounter,\n  useTabKey,\n} from \"src/components/Shared/DetailsPage/Tabs\";\nimport { DetailTitle } from \"src/components/Shared/DetailsPage/DetailTitle\";\nimport { ExpandCollapseButton } from \"src/components/Shared/CollapseButton\";\nimport { FavoriteIcon } from \"src/components/Shared/FavoriteIcon\";\nimport { AliasList } from \"src/components/Shared/DetailsPage/AliasList\";\nimport { HeaderImage } from \"src/components/Shared/DetailsPage/HeaderImage\";\nimport { goBackOrReplace } from \"src/utils/history\";\n\ninterface IProps {\n  tag: GQL.TagDataFragment;\n  tabKey?: TabKey;\n}\n\ninterface ITagParams {\n  id: string;\n  tab?: string;\n}\n\nconst validTabs = [\n  \"default\",\n  \"scenes\",\n  \"images\",\n  \"galleries\",\n  \"groups\",\n  \"markers\",\n  \"performers\",\n  \"studios\",\n] as const;\ntype TabKey = (typeof validTabs)[number];\n\nfunction isTabKey(tab: string): tab is TabKey {\n  return validTabs.includes(tab as TabKey);\n}\n\nconst TagTabs: React.FC<{\n  tabKey?: TabKey;\n  tag: GQL.TagDataFragment;\n  abbreviateCounter: boolean;\n  showAllCounts?: boolean;\n}> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => {\n  const [showAllDetails, setShowAllDetails] = useState<boolean>(\n    showAllCounts && tag.children.length > 0\n  );\n\n  const sceneCount =\n    (showAllDetails ? tag.scene_count_all : tag.scene_count) ?? 0;\n  const imageCount =\n    (showAllDetails ? tag.image_count_all : tag.image_count) ?? 0;\n  const galleryCount =\n    (showAllDetails ? tag.gallery_count_all : tag.gallery_count) ?? 0;\n  const groupCount =\n    (showAllDetails ? tag.group_count_all : tag.group_count) ?? 0;\n  const sceneMarkerCount =\n    (showAllDetails ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0;\n  const performerCount =\n    (showAllDetails ? tag.performer_count_all : tag.performer_count) ?? 0;\n  const studioCount =\n    (showAllDetails ? tag.studio_count_all : tag.studio_count) ?? 0;\n\n  const populatedDefaultTab = useMemo(() => {\n    let ret: TabKey = \"scenes\";\n    if (sceneCount == 0) {\n      if (imageCount != 0) {\n        ret = \"images\";\n      } else if (galleryCount != 0) {\n        ret = \"galleries\";\n      } else if (groupCount != 0) {\n        ret = \"groups\";\n      } else if (sceneMarkerCount != 0) {\n        ret = \"markers\";\n      } else if (performerCount != 0) {\n        ret = \"performers\";\n      } else if (studioCount != 0) {\n        ret = \"studios\";\n      }\n    }\n\n    return ret;\n  }, [\n    sceneCount,\n    imageCount,\n    galleryCount,\n    sceneMarkerCount,\n    performerCount,\n    studioCount,\n    groupCount,\n  ]);\n\n  const { setTabKey } = useTabKey({\n    tabKey,\n    validTabs,\n    defaultTabKey: populatedDefaultTab,\n    baseURL: `/tags/${tag.id}`,\n  });\n\n  const contentSwitch = useMemo(() => {\n    if (tag.children.length === 0) {\n      return null;\n    }\n\n    return (\n      <div className=\"item-list-header\">\n        <Form.Check\n          id=\"showSubContent\"\n          checked={showAllDetails}\n          onChange={() => setShowAllDetails(!showAllDetails)}\n          type=\"switch\"\n          label={<FormattedMessage id=\"include_sub_tag_content\" />}\n        />\n      </div>\n    );\n  }, [showAllDetails, tag.children.length]);\n\n  return (\n    <Tabs\n      id=\"tag-tabs\"\n      mountOnEnter\n      unmountOnExit\n      activeKey={tabKey}\n      onSelect={setTabKey}\n    >\n      <Tab\n        eventKey=\"scenes\"\n        title={\n          <TabTitleCounter\n            messageID=\"scenes\"\n            count={sceneCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <TagScenesPanel\n          active={tabKey === \"scenes\"}\n          tag={tag}\n          showSubTagContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"images\"\n        title={\n          <TabTitleCounter\n            messageID=\"images\"\n            count={imageCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <TagImagesPanel\n          active={tabKey === \"images\"}\n          tag={tag}\n          showSubTagContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"galleries\"\n        title={\n          <TabTitleCounter\n            messageID=\"galleries\"\n            count={galleryCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <TagGalleriesPanel\n          active={tabKey === \"galleries\"}\n          tag={tag}\n          showSubTagContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"groups\"\n        title={\n          <TabTitleCounter\n            messageID=\"groups\"\n            count={groupCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <TagGroupsPanel\n          active={tabKey === \"groups\"}\n          tag={tag}\n          showSubTagContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"markers\"\n        title={\n          <TabTitleCounter\n            messageID=\"markers\"\n            count={sceneMarkerCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <TagMarkersPanel\n          active={tabKey === \"markers\"}\n          tag={tag}\n          showSubTagContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"performers\"\n        title={\n          <TabTitleCounter\n            messageID=\"performers\"\n            count={performerCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <TagPerformersPanel\n          active={tabKey === \"performers\"}\n          tag={tag}\n          showSubTagContent={showAllDetails}\n        />\n      </Tab>\n      <Tab\n        eventKey=\"studios\"\n        title={\n          <TabTitleCounter\n            messageID=\"studios\"\n            count={studioCount}\n            abbreviateCounter={abbreviateCounter}\n          />\n        }\n      >\n        {contentSwitch}\n        <TagStudiosPanel\n          active={tabKey === \"studios\"}\n          tag={tag}\n          showSubTagContent={showAllDetails}\n        />\n      </Tab>\n    </Tabs>\n  );\n};\n\nconst TagPage: React.FC<IProps> = ({ tag, tabKey }) => {\n  const history = useHistory();\n  const Toast = useToast();\n  const intl = useIntl();\n\n  // Configuration settings\n  const { configuration } = useConfigurationContext();\n  const uiConfig = configuration?.ui;\n  const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;\n  const enableBackgroundImage = uiConfig?.enableTagBackgroundImage ?? false;\n  const showAllDetails = uiConfig?.showAllDetails ?? true;\n  const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;\n\n  const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);\n  const loadStickyHeader = useLoadStickyHeader();\n\n  // Editing state\n  const [isEditing, setIsEditing] = useState<boolean>(false);\n  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);\n  const [isMerging, setIsMerging] = useState<boolean>(false);\n\n  // Editing tag state\n  const [image, setImage] = useState<string | null>();\n  const [encodingImage, setEncodingImage] = useState<boolean>(false);\n\n  const [updateTag] = useTagUpdate();\n  const [deleteTag] = useTagDestroy({ id: tag.id });\n\n  const showAllCounts = uiConfig?.showChildTagContent;\n\n  const tagImage = useMemo(() => {\n    let existingImage = tag.image_path;\n    if (isEditing) {\n      if (image === null && existingImage) {\n        const tagImageURL = new URL(existingImage);\n        tagImageURL.searchParams.set(\"default\", \"true\");\n        return tagImageURL.toString();\n      } else if (image) {\n        return image;\n      }\n    }\n\n    return existingImage;\n  }, [isEditing, tag.image_path, image]);\n\n  function setFavorite(v: boolean) {\n    if (tag.id) {\n      updateTag({\n        variables: {\n          input: {\n            id: tag.id,\n            favorite: v,\n          },\n        },\n      });\n    }\n  }\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"e\", () => toggleEditing());\n    Mousetrap.bind(\"d d\", () => {\n      setIsDeleteAlertOpen(true);\n    });\n    Mousetrap.bind(\",\", () => setCollapsed(!collapsed));\n    Mousetrap.bind(\"f\", () => setFavorite(!tag.favorite));\n\n    return () => {\n      if (isEditing) {\n        Mousetrap.unbind(\"s s\");\n      }\n\n      Mousetrap.unbind(\"e\");\n      Mousetrap.unbind(\"d d\");\n      Mousetrap.unbind(\",\");\n      Mousetrap.unbind(\"f\");\n    };\n  });\n\n  async function onSave(input: GQL.TagCreateInput) {\n    const oldRelations = {\n      parents: tag.parents ?? [],\n      children: tag.children ?? [],\n    };\n    const result = await updateTag({\n      variables: {\n        input: {\n          id: tag.id,\n          ...input,\n        },\n      },\n    });\n    if (result.data?.tagUpdate) {\n      toggleEditing(false);\n      const updated = result.data.tagUpdate;\n      tagRelationHook(updated, oldRelations, {\n        parents: updated.parents,\n        children: updated.children,\n      });\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.updated_entity\" },\n          { entity: intl.formatMessage({ id: \"tag\" }).toLocaleLowerCase() }\n        )\n      );\n    }\n  }\n\n  async function onAutoTag() {\n    if (!tag.id) return;\n    try {\n      await mutateMetadataAutoTag({ tags: [tag.id] });\n      Toast.success(intl.formatMessage({ id: \"toast.started_auto_tagging\" }));\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  async function onDelete() {\n    try {\n      const oldRelations = {\n        parents: tag.parents ?? [],\n        children: tag.children ?? [],\n      };\n      await deleteTag();\n      tagRelationHook(tag as GQL.TagDataFragment, oldRelations, {\n        parents: [],\n        children: [],\n      });\n    } catch (e) {\n      Toast.error(e);\n      return;\n    }\n\n    goBackOrReplace(history, \"/tags\");\n  }\n\n  function renderDeleteAlert() {\n    return (\n      <ModalComponent\n        show={isDeleteAlertOpen}\n        icon={faTrashAlt}\n        accept={{\n          text: intl.formatMessage({ id: \"actions.delete\" }),\n          variant: \"danger\",\n          onClick: onDelete,\n        }}\n        cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}\n      >\n        <p>\n          <FormattedMessage\n            id=\"dialogs.delete_confirm\"\n            values={{\n              entityName:\n                tag.name ??\n                intl.formatMessage({ id: \"tag\" }).toLocaleLowerCase(),\n            }}\n          />\n        </p>\n      </ModalComponent>\n    );\n  }\n\n  function toggleEditing(value?: boolean) {\n    if (value !== undefined) {\n      setIsEditing(value);\n    } else {\n      setIsEditing((e) => !e);\n    }\n    setImage(undefined);\n  }\n\n  function renderMergeButton() {\n    return (\n      <Button variant=\"secondary\" onClick={() => setIsMerging(true)}>\n        <FormattedMessage id=\"actions.merge\" />\n        ...\n      </Button>\n    );\n  }\n\n  function renderMergeDialog() {\n    if (!tag.id) return;\n    return (\n      <TagMergeModal\n        show={isMerging}\n        onClose={(mergedId) => {\n          setIsMerging(false);\n          if (mergedId !== undefined && mergedId !== tag.id) {\n            // By default, the merge destination is the current tag, but\n            // the user can change it, in which case we need to redirect.\n            history.replace(`/tags/${mergedId}`);\n          }\n        }}\n        tags={[tag]}\n      />\n    );\n  }\n\n  const headerClassName = cx(\"detail-header\", {\n    edit: isEditing,\n    collapsed,\n    \"full-width\": !collapsed && !compactExpandedDetails,\n  });\n\n  return (\n    <div id=\"tag-page\" className=\"row\">\n      <Helmet>\n        <title>{tag.name}</title>\n      </Helmet>\n\n      <div className={headerClassName}>\n        <BackgroundImage\n          imagePath={tag.image_path ?? undefined}\n          show={enableBackgroundImage && !isEditing}\n        />\n        <div className=\"detail-container\">\n          <HeaderImage encodingImage={encodingImage}>\n            {tagImage && (\n              <DetailImage className=\"logo\" alt={tag.name} src={tagImage} />\n            )}\n          </HeaderImage>\n          <div className=\"row\">\n            <div className=\"tag-head col\">\n              <DetailTitle name={tag.name} classNamePrefix=\"tag\">\n                {!isEditing && (\n                  <ExpandCollapseButton\n                    collapsed={collapsed}\n                    setCollapsed={(v) => setCollapsed(v)}\n                  />\n                )}\n                <span className=\"name-icons\">\n                  <FavoriteIcon\n                    favorite={tag.favorite}\n                    onToggleFavorite={(v) => setFavorite(v)}\n                  />\n                </span>\n              </DetailTitle>\n\n              <AliasList aliases={tag.aliases} />\n              {!isEditing && (\n                <TagDetailsPanel\n                  tag={tag}\n                  fullWidth={!collapsed && !compactExpandedDetails}\n                />\n              )}\n              {isEditing ? (\n                <TagEditPanel\n                  tag={tag}\n                  onSubmit={onSave}\n                  onCancel={() => toggleEditing()}\n                  onDelete={onDelete}\n                  setImage={setImage}\n                  setEncodingImage={setEncodingImage}\n                />\n              ) : (\n                <DetailsEditNavbar\n                  objectName={tag.name}\n                  isNew={false}\n                  isEditing={isEditing}\n                  onToggleEdit={() => toggleEditing()}\n                  onSave={() => {}}\n                  onImageChange={() => {}}\n                  onClearImage={() => {}}\n                  onAutoTag={onAutoTag}\n                  autoTagDisabled={tag.ignore_auto_tag}\n                  onDelete={onDelete}\n                  classNames=\"mb-2\"\n                  customButtons={renderMergeButton()}\n                />\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {!isEditing && loadStickyHeader && (\n        <CompressedTagDetailsPanel tag={tag} />\n      )}\n\n      <div className=\"detail-body\">\n        <div className=\"tag-body\">\n          <div className=\"tag-tabs\">\n            {!isEditing && (\n              <TagTabs\n                tabKey={tabKey}\n                tag={tag}\n                abbreviateCounter={abbreviateCounter}\n                showAllCounts={showAllCounts}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n      {renderDeleteAlert()}\n      {renderMergeDialog()}\n    </div>\n  );\n};\n\nconst TagLoader: React.FC<RouteComponentProps<ITagParams>> = ({\n  location,\n  match,\n}) => {\n  const { id, tab } = match.params;\n  const { data, loading, error } = useFindTag(id);\n\n  useScrollToTopOnMount();\n\n  if (loading) return <LoadingIndicator />;\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findTag)\n    return <ErrorMessage error={`No tag found with id ${id}.`} />;\n\n  if (tab && !isTabKey(tab)) {\n    return (\n      <Redirect\n        to={{\n          ...location,\n          pathname: `/tags/${id}`,\n        }}\n      />\n    );\n  }\n\n  return <TagPage tag={data.findTag} tabKey={tab as TabKey | undefined} />;\n};\n\nexport default TagLoader;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx",
    "content": "import React, { useMemo, useState } from \"react\";\nimport { useHistory, useLocation } from \"react-router-dom\";\nimport { useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useTagCreate } from \"src/core/StashService\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { tagRelationHook } from \"src/core/tags\";\nimport { TagEditPanel } from \"./TagEditPanel\";\n\nconst TagCreate: React.FC = () => {\n  const intl = useIntl();\n  const history = useHistory();\n  const Toast = useToast();\n\n  const location = useLocation();\n  const query = useMemo(() => new URLSearchParams(location.search), [location]);\n  const tag = {\n    name: query.get(\"q\") ?? undefined,\n  };\n\n  // Editing tag state\n  const [image, setImage] = useState<string | null>();\n  const [encodingImage, setEncodingImage] = useState<boolean>(false);\n\n  const [createTag] = useTagCreate();\n\n  async function onSave(input: GQL.TagCreateInput, andNew?: boolean) {\n    const oldRelations = {\n      parents: [],\n      children: [],\n    };\n    const result = await createTag({\n      variables: { input },\n    });\n    if (result.data?.tagCreate?.id) {\n      const created = result.data.tagCreate;\n      tagRelationHook(created, oldRelations, {\n        parents: created.parents,\n        children: created.children,\n      });\n      if (!andNew) {\n        history.push(`/tags/${created.id}`);\n      }\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          { entity: intl.formatMessage({ id: \"tag\" }).toLocaleLowerCase() }\n        )\n      );\n    }\n  }\n\n  function renderImage() {\n    if (image) {\n      return <img className=\"logo\" alt=\"\" src={image} />;\n    }\n  }\n\n  return (\n    <div className=\"row\">\n      <div className=\"tag-details col-md-8\">\n        <div className=\"text-center logo-container\">\n          {encodingImage ? (\n            <LoadingIndicator\n              message={intl.formatMessage({ id: \"actions.encoding_image\" })}\n            />\n          ) : (\n            renderImage()\n          )}\n        </div>\n        <TagEditPanel\n          tag={tag}\n          onSubmit={onSave}\n          onCancel={() => history.push(\"/tags\")}\n          onDelete={() => {}}\n          setImage={setImage}\n          setEncodingImage={setEncodingImage}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport default TagCreate;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx",
    "content": "import React from \"react\";\nimport { TagLink } from \"src/components/Shared/TagLink\";\nimport { DetailItem } from \"src/components/Shared/DetailItem\";\nimport { StashIDPill } from \"src/components/Shared/StashID\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { CustomFields } from \"src/components/Shared/CustomFields\";\n\ninterface ITagDetails {\n  tag: GQL.TagDataFragment;\n  fullWidth?: boolean;\n}\n\nexport const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {\n  function renderParentsField() {\n    if (!tag.parents?.length) {\n      return;\n    }\n\n    return (\n      <>\n        {tag.parents.map((p) => (\n          <TagLink\n            key={p.id}\n            tag={p}\n            hoverPlacement=\"bottom\"\n            linkType=\"details\"\n            showHierarchyIcon={p.parent_count !== 0}\n            hierarchyTooltipID=\"tag_parent_tooltip\"\n          />\n        ))}\n      </>\n    );\n  }\n\n  function renderChildrenField() {\n    if (!tag.children?.length) {\n      return;\n    }\n\n    return (\n      <>\n        {tag.children.map((c) => (\n          <TagLink\n            key={c.id}\n            tag={c}\n            hoverPlacement=\"bottom\"\n            linkType=\"details\"\n            showHierarchyIcon={c.child_count !== 0}\n            hierarchyTooltipID=\"tag_sub_tag_tooltip\"\n          />\n        ))}\n      </>\n    );\n  }\n\n  function renderStashIDs() {\n    if (!tag.stash_ids?.length) {\n      return;\n    }\n\n    return (\n      <ul className=\"pl-0\">\n        {tag.stash_ids.map((stashID) => (\n          <li key={stashID.stash_id} className=\"row no-gutters\">\n            <StashIDPill stashID={stashID} linkType=\"tags\" />\n          </li>\n        ))}\n      </ul>\n    );\n  }\n\n  return (\n    <div className=\"detail-group\">\n      <DetailItem\n        id=\"description\"\n        value={tag.description}\n        fullWidth={fullWidth}\n      />\n      <DetailItem\n        id=\"parent_tags\"\n        value={renderParentsField()}\n        fullWidth={fullWidth}\n      />\n      <DetailItem\n        id=\"sub_tags\"\n        value={renderChildrenField()}\n        fullWidth={fullWidth}\n      />\n      <DetailItem\n        id=\"stash_ids\"\n        value={renderStashIDs()}\n        fullWidth={fullWidth}\n      />\n      <CustomFields values={tag.custom_fields} />\n    </div>\n  );\n};\n\nexport const CompressedTagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {\n  function scrollToTop() {\n    window.scrollTo({ top: 0, behavior: \"smooth\" });\n  }\n\n  return (\n    <div className=\"sticky detail-header\">\n      <div className=\"sticky detail-header-group\">\n        <a className=\"tag-name\" onClick={() => scrollToTop()}>\n          {tag.name}\n        </a>\n        {tag.description ? (\n          <>\n            <span className=\"detail-divider\">/</span>\n            <span className=\"tag-desc\">{tag.description}</span>\n          </>\n        ) : (\n          \"\"\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport * as yup from \"yup\";\nimport { DetailsEditNavbar } from \"src/components/Shared/DetailsEditNavbar\";\nimport { Button, Form } from \"react-bootstrap\";\nimport { faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport ImageUtils from \"src/utils/image\";\nimport { useFormik } from \"formik\";\nimport { Prompt } from \"react-router-dom\";\nimport Mousetrap from \"mousetrap\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport isEqual from \"lodash-es/isEqual\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { handleUnsavedChanges } from \"src/utils/navigation\";\nimport { formikUtils } from \"src/utils/form\";\nimport { yupFormikValidate, yupRequiredStringArray } from \"src/utils/yup\";\nimport { addUpdateStashID, getStashIDs } from \"src/utils/stashIds\";\nimport { Tag, TagSelect } from \"../TagSelect\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport StashBoxIDSearchModal from \"src/components/Shared/StashBoxIDSearchModal\";\nimport {\n  CustomFieldsInput,\n  formatCustomFieldInput,\n} from \"src/components/Shared/CustomFields\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\n\ninterface ITagEditPanel {\n  tag: Partial<GQL.TagDataFragment>;\n  onSubmit: (tag: GQL.TagCreateInput, andNew?: boolean) => Promise<void>;\n  onCancel: () => void;\n  onDelete: () => void;\n  setImage: (image?: string | null) => void;\n  setEncodingImage: (loading: boolean) => void;\n}\n\nexport const TagEditPanel: React.FC<ITagEditPanel> = ({\n  tag,\n  onSubmit,\n  onCancel,\n  onDelete,\n  setImage,\n  setEncodingImage,\n}) => {\n  const intl = useIntl();\n  const Toast = useToast();\n  const { configuration: stashConfig } = useConfigurationContext();\n\n  const isNew = tag.id === undefined;\n\n  // Editing state\n  const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false);\n\n  // Network state\n  const [isLoading, setIsLoading] = useState(false);\n\n  const [childTags, setChildTags] = useState<Tag[]>([]);\n  const [parentTags, setParentTags] = useState<Tag[]>([]);\n\n  const schema = yup.object({\n    name: yup.string().required(),\n    sort_name: yup.string().ensure(),\n    aliases: yupRequiredStringArray(intl).defined(),\n    description: yup.string().ensure(),\n    parent_ids: yup.array(yup.string().required()).defined(),\n    child_ids: yup.array(yup.string().required()).defined(),\n    ignore_auto_tag: yup.boolean().defined(),\n    stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),\n    image: yup.string().nullable().optional(),\n    custom_fields: yup.object().required().defined(),\n  });\n\n  const initialValues = {\n    name: tag?.name ?? \"\",\n    sort_name: tag?.sort_name ?? \"\",\n    aliases: tag?.aliases ?? [],\n    description: tag?.description ?? \"\",\n    parent_ids: (tag?.parents ?? []).map((t) => t.id),\n    child_ids: (tag?.children ?? []).map((t) => t.id),\n    ignore_auto_tag: tag?.ignore_auto_tag ?? false,\n    stash_ids: getStashIDs(tag?.stash_ids),\n    custom_fields: cloneDeep(tag?.custom_fields ?? {}),\n  };\n\n  type InputValues = yup.InferType<typeof schema>;\n\n  const [customFieldsError, setCustomFieldsError] = useState<string>();\n\n  function submit(values: InputValues) {\n    const input = {\n      ...schema.cast(values),\n      custom_fields: formatCustomFieldInput(isNew, values.custom_fields),\n    };\n    onSave(input);\n  }\n\n  const formik = useFormik<InputValues>({\n    initialValues,\n    enableReinitialize: true,\n    validate: yupFormikValidate(schema),\n    onSubmit: submit,\n  });\n\n  function onSetParentTags(items: Tag[]) {\n    setParentTags(items);\n    formik.setFieldValue(\n      \"parent_ids\",\n      items.map((item) => item.id)\n    );\n  }\n\n  function onSetChildTags(items: Tag[]) {\n    setChildTags(items);\n    formik.setFieldValue(\n      \"child_ids\",\n      items.map((item) => item.id)\n    );\n  }\n\n  useEffect(() => {\n    setParentTags(tag.parents ?? []);\n  }, [tag.parents]);\n\n  useEffect(() => {\n    setChildTags(tag.children ?? []);\n  }, [tag.children]);\n\n  // set up hotkeys\n  useEffect(() => {\n    Mousetrap.bind(\"s s\", () => {\n      if (formik.dirty) {\n        formik.submitForm();\n      }\n    });\n\n    return () => {\n      Mousetrap.unbind(\"s s\");\n    };\n  });\n\n  async function onSave(input: InputValues, andNew?: boolean) {\n    setIsLoading(true);\n    try {\n      await onSubmit(input, andNew);\n      formik.resetForm();\n    } catch (e) {\n      Toast.error(e);\n    }\n    setIsLoading(false);\n  }\n\n  async function onSaveAndNewClick() {\n    const input = {\n      ...schema.cast(formik.values),\n      custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields),\n    };\n    onSave(input, true);\n  }\n\n  const encodingImage = ImageUtils.usePasteImage(onImageLoad);\n\n  useEffect(() => {\n    setImage(formik.values.image);\n  }, [formik.values.image, setImage]);\n\n  useEffect(() => {\n    setEncodingImage(encodingImage);\n  }, [setEncodingImage, encodingImage]);\n\n  function onImageLoad(imageData: string | null) {\n    formik.setFieldValue(\"image\", imageData);\n  }\n\n  function onImageChange(event: React.FormEvent<HTMLInputElement>) {\n    ImageUtils.onImageChange(event, onImageLoad);\n  }\n\n  function onStashIDSelected(item?: GQL.StashIdInput) {\n    if (!item) return;\n    const allowMultiple = true;\n    formik.setFieldValue(\n      \"stash_ids\",\n      addUpdateStashID(formik.values.stash_ids, item, allowMultiple)\n    );\n  }\n\n  const {\n    renderField,\n    renderInputField,\n    renderStringListField,\n    renderStashIDsField,\n  } = formikUtils(intl, formik);\n\n  function renderParentTagsField() {\n    const title = intl.formatMessage({ id: \"parent_tags\" });\n    const control = (\n      <TagSelect\n        isMulti\n        onSelect={onSetParentTags}\n        values={parentTags}\n        excludeIds={[...(tag?.id ? [tag.id] : []), ...formik.values.child_ids]}\n        creatable={false}\n        hoverPlacement=\"right\"\n      />\n    );\n\n    return renderField(\"parent_ids\", title, control);\n  }\n\n  function renderSubTagsField() {\n    const title = intl.formatMessage({ id: \"sub_tags\" });\n    const control = (\n      <TagSelect\n        isMulti\n        onSelect={onSetChildTags}\n        values={childTags}\n        excludeIds={[...(tag?.id ? [tag.id] : []), ...formik.values.parent_ids]}\n        creatable={false}\n        hoverPlacement=\"right\"\n      />\n    );\n\n    return renderField(\"child_ids\", title, control);\n  }\n\n  if (isLoading) return <LoadingIndicator />;\n\n  // TODO: CSS class\n  return (\n    <>\n      {/* allow many stash-ids from the same stash box */}\n      {isStashIDSearchOpen && (\n        <StashBoxIDSearchModal\n          entityType=\"tag\"\n          stashBoxes={stashConfig?.general.stashBoxes ?? []}\n          onSelectItem={(item) => {\n            onStashIDSelected(item);\n            setIsStashIDSearchOpen(false);\n          }}\n          initialQuery={tag?.name ?? \"\"}\n        />\n      )}\n\n      <div>\n        {isNew && (\n          <h2>\n            <FormattedMessage\n              id=\"actions.add_entity\"\n              values={{ entityType: intl.formatMessage({ id: \"tag\" }) }}\n            />\n          </h2>\n        )}\n\n        <Prompt\n          when={formik.dirty}\n          message={(location, action) => {\n            // Check if it's a redirect after tag creation\n            if (action === \"PUSH\" && location.pathname.startsWith(\"/tags/\")) {\n              return true;\n            }\n\n            return handleUnsavedChanges(intl, \"tags\", tag.id)(location);\n          }}\n        />\n\n        <Form noValidate onSubmit={formik.handleSubmit} id=\"tag-edit\">\n          {renderInputField(\"name\")}\n          {renderInputField(\"sort_name\", \"text\")}\n          {renderStringListField(\"aliases\", \"aliases\", { orderable: false })}\n          {renderInputField(\"description\", \"textarea\")}\n          {renderParentTagsField()}\n          {renderSubTagsField()}\n          {renderStashIDsField(\n            \"stash_ids\",\n            \"tags\",\n            \"stash_ids\",\n            undefined,\n            <Button\n              variant=\"success\"\n              className=\"mr-2 py-0\"\n              onClick={() => setIsStashIDSearchOpen(true)}\n              disabled={!stashConfig?.general.stashBoxes?.length}\n              title={intl.formatMessage({ id: \"actions.add_stash_id\" })}\n            >\n              <Icon icon={faPlus} />\n            </Button>\n          )}\n\n          <CustomFieldsInput\n            values={formik.values.custom_fields}\n            onChange={(v) => formik.setFieldValue(\"custom_fields\", v)}\n            error={customFieldsError}\n            setError={(e) => setCustomFieldsError(e)}\n          />\n\n          <hr />\n          {renderInputField(\"ignore_auto_tag\", \"checkbox\")}\n        </Form>\n\n        <DetailsEditNavbar\n          objectName={tag?.name ?? intl.formatMessage({ id: \"tag\" })}\n          classNames=\"col-xl-9 mt-3\"\n          isNew={isNew}\n          isEditing\n          onToggleEdit={onCancel}\n          onSave={formik.handleSubmit}\n          onSaveAndNew={isNew ? onSaveAndNewClick : undefined}\n          saveDisabled={\n            (!isNew && !formik.dirty) ||\n            !isEqual(formik.errors, {}) ||\n            customFieldsError !== undefined\n          }\n          onImageChange={onImageChange}\n          onImageChangeURL={onImageLoad}\n          onClearImage={() => onImageLoad(null)}\n          onDelete={onDelete}\n          acceptSVG\n        />\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useTagFilterHook } from \"src/core/tags\";\nimport { FilteredGalleryList } from \"src/components/Galleries/GalleryList\";\nimport { View } from \"src/components/List/views\";\n\ninterface ITagGalleriesPanel {\n  active: boolean;\n  tag: GQL.TagDataFragment;\n  showSubTagContent?: boolean;\n}\n\nexport const TagGalleriesPanel: React.FC<ITagGalleriesPanel> = ({\n  active,\n  tag,\n  showSubTagContent,\n}) => {\n  const filterHook = useTagFilterHook(tag, showSubTagContent);\n  return (\n    <FilteredGalleryList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.TagGalleries}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useTagFilterHook } from \"src/core/tags\";\nimport { FilteredGroupList } from \"src/components/Groups/GroupList\";\nimport { View } from \"src/components/List/views\";\n\nexport const TagGroupsPanel: React.FC<{\n  active: boolean;\n  tag: GQL.TagDataFragment;\n  showSubTagContent?: boolean;\n}> = ({ active, tag, showSubTagContent }) => {\n  const filterHook = useTagFilterHook(tag, showSubTagContent);\n  return (\n    <FilteredGroupList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.TagGroups}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useTagFilterHook } from \"src/core/tags\";\nimport { FilteredImageList } from \"src/components/Images/ImageList\";\nimport { View } from \"src/components/List/views\";\n\ninterface ITagImagesPanel {\n  active: boolean;\n  tag: GQL.TagDataFragment;\n  showSubTagContent?: boolean;\n}\n\nexport const TagImagesPanel: React.FC<ITagImagesPanel> = ({\n  active,\n  tag,\n  showSubTagContent,\n}) => {\n  const filterHook = useTagFilterHook(tag, showSubTagContent);\n  return (\n    <FilteredImageList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.TagImages}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  TagsCriterion,\n  TagsCriterionOption,\n} from \"src/models/list-filter/criteria/tags\";\nimport { FilteredSceneMarkerList } from \"src/components/Scenes/SceneMarkerList\";\nimport { View } from \"src/components/List/views\";\n\nfunction useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) {\n  return (filter: ListFilterModel) => {\n    const tagValue = { id: tag.id, label: tag.name };\n    // if tag is already present, then we modify it, otherwise add\n    let tagCriterion = filter.criteria.find((c) => {\n      return c.criterionOption.type === \"tags\";\n    }) as TagsCriterion | undefined;\n\n    if (\n      tagCriterion &&\n      (tagCriterion.modifier === GQL.CriterionModifier.IncludesAll ||\n        tagCriterion.modifier === GQL.CriterionModifier.Includes)\n    ) {\n      // add the tag if not present\n      if (\n        !tagCriterion.value.items.find((p) => {\n          return p.id === tag.id;\n        })\n      ) {\n        tagCriterion.value.items.push(tagValue);\n      }\n\n      tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;\n    } else {\n      // overwrite\n      tagCriterion = new TagsCriterion(TagsCriterionOption);\n      tagCriterion.value = {\n        items: [tagValue],\n        excluded: [],\n        depth: showSubTagContent ? -1 : 0,\n      };\n      filter.criteria.push(tagCriterion);\n    }\n\n    return filter;\n  };\n}\n\ninterface ITagMarkersPanel {\n  active: boolean;\n  tag: GQL.TagDataFragment;\n  showSubTagContent?: boolean;\n}\n\nexport const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({\n  active,\n  tag,\n  showSubTagContent,\n}) => {\n  const filterHook = useFilterHook(tag, showSubTagContent);\n\n  return (\n    <FilteredSceneMarkerList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.TagMarkers}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useTagFilterHook } from \"src/core/tags\";\nimport { FilteredPerformerList } from \"src/components/Performers/PerformerList\";\nimport { View } from \"src/components/List/views\";\n\ninterface ITagPerformersPanel {\n  active: boolean;\n  tag: GQL.TagDataFragment;\n  showSubTagContent?: boolean;\n}\n\nexport const TagPerformersPanel: React.FC<ITagPerformersPanel> = ({\n  active,\n  tag,\n  showSubTagContent,\n}) => {\n  const filterHook = useTagFilterHook(tag, showSubTagContent);\n  return (\n    <FilteredPerformerList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.TagPerformers}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FilteredSceneList } from \"src/components/Scenes/SceneList\";\nimport { useTagFilterHook } from \"src/core/tags\";\nimport { View } from \"src/components/List/views\";\n\ninterface ITagScenesPanel {\n  active: boolean;\n  tag: GQL.TagDataFragment;\n  showSubTagContent?: boolean;\n}\n\nexport const TagScenesPanel: React.FC<ITagScenesPanel> = ({\n  active,\n  tag,\n  showSubTagContent,\n}) => {\n  const filterHook = useTagFilterHook(tag, showSubTagContent);\n  return (\n    <FilteredSceneList\n      filterHook={filterHook}\n      alterQuery={active}\n      view={View.TagScenes}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useTagFilterHook } from \"src/core/tags\";\nimport { FilteredStudioList } from \"src/components/Studios/StudioList\";\n\ninterface ITagStudiosPanel {\n  active: boolean;\n  tag: GQL.TagDataFragment;\n  showSubTagContent?: boolean;\n}\n\nexport const TagStudiosPanel: React.FC<ITagStudiosPanel> = ({\n  active,\n  tag,\n  showSubTagContent,\n}) => {\n  const filterHook = useTagFilterHook(tag, showSubTagContent);\n  return <FilteredStudioList filterHook={filterHook} alterQuery={active} />;\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagList.tsx",
    "content": "import React, { useCallback, useEffect } from \"react\";\nimport cloneDeep from \"lodash-es/cloneDeep\";\nimport Mousetrap from \"mousetrap\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { DisplayMode } from \"src/models/list-filter/types\";\nimport { useFilteredItemList } from \"../List/ItemList\";\nimport { Button } from \"react-bootstrap\";\nimport { useHistory } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  queryFindTagsForList,\n  useFindTagsForList,\n  useTagsDestroy,\n} from \"src/core/StashService\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { DeleteEntityDialog } from \"../Shared/DeleteEntityDialog\";\nimport { ExportDialog } from \"../Shared/ExportDialog\";\nimport { tagRelationHook } from \"../../core/tags\";\nimport { TagMergeModal } from \"./TagMergeDialog\";\nimport { TagCardGrid } from \"./TagCardGrid\";\nimport { EditTagsDialog } from \"./EditTagsDialog\";\nimport { View } from \"../List/views\";\nimport {\n  FilteredListToolbar,\n  IItemListOperation,\n} from \"../List/FilteredListToolbar\";\nimport { PatchComponent, PatchContainerComponent } from \"src/patch\";\nimport { TagTagger } from \"../Tagger/tags/TagTagger\";\nimport useFocus from \"src/utils/focus\";\nimport {\n  Sidebar,\n  SidebarPane,\n  SidebarPaneContent,\n  SidebarStateContext,\n  useSidebarState,\n} from \"../Shared/Sidebar\";\nimport { useCloseEditDelete, useFilterOperations } from \"../List/util\";\nimport {\n  FilteredSidebarHeader,\n  useFilteredSidebarKeybinds,\n} from \"../List/Filters/FilterSidebar\";\nimport { ListOperations } from \"../List/ListOperationButtons\";\nimport cx from \"classnames\";\nimport { FilterTags } from \"../List/FilterTags\";\nimport { Pagination, PaginationIndex } from \"../List/Pagination\";\nimport { LoadedContent } from \"../List/PagedList\";\nimport { SidebarBooleanFilter } from \"../List/Filters/BooleanFilter\";\nimport { FavoriteTagCriterionOption } from \"src/models/list-filter/criteria/favorite\";\nimport { TagListTable } from \"./TagListTable\";\n\nconst TagList: React.FC<{\n  tags: GQL.TagListDataFragment[];\n  filter: ListFilterModel;\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}> = PatchComponent(\n  \"TagList\",\n  ({ tags, filter, selectedIds, onSelectChange }) => {\n    if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) {\n      return null;\n    }\n\n    if (filter.displayMode === DisplayMode.Grid) {\n      return (\n        <TagCardGrid\n          tags={tags}\n          zoomIndex={filter.zoomIndex}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.List) {\n      return (\n        <TagListTable\n          tags={tags}\n          selectedIds={selectedIds}\n          onSelectChange={onSelectChange}\n        />\n      );\n    }\n    if (filter.displayMode === DisplayMode.Tagger) {\n      return <TagTagger tags={tags} />;\n    }\n\n    return null;\n  }\n);\n\nconst TagFilterSidebarSections = PatchContainerComponent(\n  \"FilteredTagList.SidebarSections\"\n);\n\nconst SidebarContent: React.FC<{\n  filter: ListFilterModel;\n  setFilter: (filter: ListFilterModel) => void;\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  view?: View;\n  sidebarOpen: boolean;\n  onClose?: () => void;\n  showEditFilter: (editingCriterion?: string) => void;\n  count?: number;\n  focus?: ReturnType<typeof useFocus>;\n}> = ({\n  filter,\n  setFilter,\n  // filterHook,\n  view,\n  showEditFilter,\n  sidebarOpen,\n  onClose,\n  count,\n  focus,\n}) => {\n  const showResultsId =\n    count !== undefined ? \"actions.show_count_results\" : \"actions.show_results\";\n\n  return (\n    <>\n      <FilteredSidebarHeader\n        sidebarOpen={sidebarOpen}\n        showEditFilter={showEditFilter}\n        filter={filter}\n        setFilter={setFilter}\n        view={view}\n        focus={focus}\n      />\n\n      <TagFilterSidebarSections>\n        {/* <SidebarTagsFilter\n          filter={filter}\n          setFilter={setFilter}\n          filterHook={filterHook}\n        /> */}\n        <SidebarBooleanFilter\n          title={<FormattedMessage id=\"favourite\" />}\n          filter={filter}\n          setFilter={setFilter}\n          option={FavoriteTagCriterionOption}\n          sectionID=\"favourite\"\n        />\n      </TagFilterSidebarSections>\n\n      <div className=\"sidebar-footer\">\n        <Button className=\"sidebar-close-button\" onClick={onClose}>\n          <FormattedMessage id={showResultsId} values={{ count }} />\n        </Button>\n      </div>\n    </>\n  );\n};\n\nfunction useViewRandom(filter: ListFilterModel, count: number) {\n  const history = useHistory();\n\n  const viewRandom = useCallback(async () => {\n    // query for a random tag\n    if (count === 0) {\n      return;\n    }\n\n    const index = Math.floor(Math.random() * count);\n    const filterCopy = cloneDeep(filter);\n    filterCopy.itemsPerPage = 1;\n    filterCopy.currentPage = index + 1;\n    const singleResult = await queryFindTagsForList(filterCopy);\n    if (singleResult.data.findTags.tags.length === 1) {\n      const { id } = singleResult.data.findTags.tags[0];\n      // navigate to the tag page\n      history.push(`/tags/${id}`);\n    }\n  }, [history, filter, count]);\n\n  return viewRandom;\n}\n\nfunction useAddKeybinds(filter: ListFilterModel, count: number) {\n  const viewRandom = useViewRandom(filter, count);\n\n  useEffect(() => {\n    Mousetrap.bind(\"p r\", () => {\n      viewRandom();\n    });\n\n    return () => {\n      Mousetrap.unbind(\"p r\");\n    };\n  }, [viewRandom]);\n}\n\ninterface ITagList {\n  filterHook?: (filter: ListFilterModel) => ListFilterModel;\n  alterQuery?: boolean;\n  extraOperations?: IItemListOperation<GQL.FindTagsForListQueryResult>[];\n}\n\nexport const FilteredTagList = PatchComponent(\n  \"FilteredTagList\",\n  (props: ITagList) => {\n    const intl = useIntl();\n    const history = useHistory();\n\n    const searchFocus = useFocus();\n\n    const { filterHook, alterQuery, extraOperations = [] } = props;\n\n    const view = View.Tags;\n\n    // States\n    const {\n      showSidebar,\n      setShowSidebar,\n      sectionOpen,\n      setSectionOpen,\n      loading: sidebarStateLoading,\n    } = useSidebarState(view);\n\n    const { filterState, queryResult, modalState, listSelect, showEditFilter } =\n      useFilteredItemList({\n        filterStateProps: {\n          filterMode: GQL.FilterMode.Tags,\n          view,\n          useURL: alterQuery,\n        },\n        queryResultProps: {\n          useResult: useFindTagsForList,\n          getCount: (r) => r.data?.findTags.count ?? 0,\n          getItems: (r) => r.data?.findTags.tags ?? [],\n          filterHook,\n        },\n      });\n\n    const { filter, setFilter } = filterState;\n\n    const { effectiveFilter, result, cachedResult, items, totalCount } =\n      queryResult;\n\n    const {\n      selectedIds,\n      selectedItems,\n      onSelectChange,\n      onSelectAll,\n      onSelectNone,\n      onInvertSelection,\n      hasSelection,\n    } = listSelect;\n\n    const { modal, showModal, closeModal } = modalState;\n\n    // Utility hooks\n    const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({\n      filter,\n      setFilter,\n    });\n\n    useAddKeybinds(effectiveFilter, totalCount);\n    useFilteredSidebarKeybinds({\n      showSidebar,\n      setShowSidebar,\n    });\n\n    useEffect(() => {\n      Mousetrap.bind(\"e\", () => {\n        if (hasSelection) {\n          onEdit?.();\n        }\n      });\n\n      Mousetrap.bind(\"d d\", () => {\n        if (hasSelection) {\n          onDelete?.();\n        }\n      });\n\n      return () => {\n        Mousetrap.unbind(\"e\");\n        Mousetrap.unbind(\"d d\");\n      };\n    });\n\n    const onCloseEditDelete = useCloseEditDelete({\n      closeModal,\n      onSelectNone,\n      result,\n    });\n\n    const viewRandom = useViewRandom(effectiveFilter, totalCount);\n\n    function onExport(all: boolean) {\n      showModal(\n        <ExportDialog\n          exportInput={{\n            studios: {\n              ids: Array.from(selectedIds.values()),\n              all: all,\n            },\n          }}\n          onClose={() => closeModal()}\n        />\n      );\n    }\n\n    function onEdit() {\n      showModal(\n        <EditTagsDialog selected={selectedItems} onClose={onCloseEditDelete} />\n      );\n    }\n\n    function onDelete(tag?: GQL.TagListDataFragment) {\n      const itemsToDelete = tag ? [tag] : selectedItems;\n\n      showModal(\n        <DeleteEntityDialog\n          selected={itemsToDelete}\n          onClose={onCloseEditDelete}\n          singularEntity={intl.formatMessage({ id: \"tag\" })}\n          pluralEntity={intl.formatMessage({ id: \"tags\" })}\n          destroyMutation={useTagsDestroy}\n          onDeleted={() => {\n            itemsToDelete.forEach((t) =>\n              tagRelationHook(\n                t,\n                { parents: t.parents ?? [], children: t.children ?? [] },\n                { parents: [], children: [] }\n              )\n            );\n          }}\n        />\n      );\n    }\n\n    function onMerge() {\n      showModal(\n        <TagMergeModal\n          tags={selectedItems}\n          onClose={(mergedId?: string) => {\n            onCloseEditDelete();\n            if (mergedId) {\n              history.push(`/tags/${mergedId}`);\n            }\n          }}\n          show\n        />\n      );\n    }\n\n    const convertedExtraOperations = extraOperations.map((op) => ({\n      text: op.text,\n      onClick: () => op.onClick(result, filter, selectedIds),\n      isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true,\n    }));\n\n    const otherOperations = [\n      ...convertedExtraOperations,\n      {\n        text: intl.formatMessage({ id: \"actions.select_all\" }),\n        onClick: () => onSelectAll(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.select_none\" }),\n        onClick: () => onSelectNone(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.invert_selection\" }),\n        onClick: () => onInvertSelection(),\n        isDisplayed: () => totalCount > 0,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.view_random\" }),\n        onClick: viewRandom,\n      },\n      {\n        text: `${intl.formatMessage({ id: \"actions.merge\" })}…`,\n        onClick: () => onMerge(),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export\" }),\n        onClick: () => onExport(false),\n        isDisplayed: () => hasSelection,\n      },\n      {\n        text: intl.formatMessage({ id: \"actions.export_all\" }),\n        onClick: () => onExport(true),\n      },\n    ];\n\n    // render\n    if (sidebarStateLoading) return null;\n\n    const operations = (\n      <ListOperations\n        items={items.length}\n        hasSelection={hasSelection}\n        operations={otherOperations}\n        onEdit={onEdit}\n        onDelete={onDelete}\n        operationsMenuClassName=\"tag-list-operations-dropdown\"\n      />\n    );\n\n    return (\n      <div\n        className={cx(\"item-list-container tag-list\", {\n          \"hide-sidebar\": !showSidebar,\n        })}\n      >\n        {modal}\n\n        <SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>\n          <SidebarPane hideSidebar={!showSidebar}>\n            <Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>\n              <SidebarContent\n                filter={filter}\n                setFilter={setFilter}\n                filterHook={filterHook}\n                showEditFilter={showEditFilter}\n                view={view}\n                sidebarOpen={showSidebar}\n                onClose={() => setShowSidebar(false)}\n                count={cachedResult.loading ? undefined : totalCount}\n                focus={searchFocus}\n              />\n            </Sidebar>\n            <SidebarPaneContent\n              onSidebarToggle={() => setShowSidebar(!showSidebar)}\n            >\n              <FilteredListToolbar\n                filter={filter}\n                listSelect={listSelect}\n                setFilter={setFilter}\n                showEditFilter={showEditFilter}\n                onDelete={onDelete}\n                onEdit={onEdit}\n                operationComponent={operations}\n                view={view}\n                zoomable\n              />\n\n              <FilterTags\n                criteria={filter.criteria}\n                onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}\n                onRemoveCriterion={removeCriterion}\n                onRemoveAll={clearAllCriteria}\n              />\n\n              <div className=\"pagination-index-container\">\n                <Pagination\n                  currentPage={filter.currentPage}\n                  itemsPerPage={filter.itemsPerPage}\n                  totalItems={totalCount}\n                  onChangePage={(page) => setFilter(filter.changePage(page))}\n                />\n                <PaginationIndex\n                  loading={cachedResult.loading}\n                  itemsPerPage={filter.itemsPerPage}\n                  currentPage={filter.currentPage}\n                  totalItems={totalCount}\n                />\n              </div>\n\n              <LoadedContent loading={result.loading} error={result.error}>\n                <TagList\n                  filter={effectiveFilter}\n                  tags={items}\n                  selectedIds={selectedIds}\n                  onSelectChange={onSelectChange}\n                />\n              </LoadedContent>\n\n              {totalCount > filter.itemsPerPage && (\n                <div className=\"pagination-footer-container\">\n                  <div className=\"pagination-footer\">\n                    <Pagination\n                      itemsPerPage={filter.itemsPerPage}\n                      currentPage={filter.currentPage}\n                      totalItems={totalCount}\n                      onChangePage={setPage}\n                      pagePopupPlacement=\"top\"\n                    />\n                  </div>\n                </div>\n              )}\n            </SidebarPaneContent>\n          </SidebarPane>\n        </SidebarStateContext.Provider>\n      </div>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagListTable.tsx",
    "content": "/* eslint-disable jsx-a11y/control-has-associated-label */\n\nimport React from \"react\";\nimport { useIntl } from \"react-intl\";\nimport { Button } from \"react-bootstrap\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { Icon } from \"../Shared/Icon\";\nimport NavUtils from \"src/utils/navigation\";\nimport { faHeart } from \"@fortawesome/free-solid-svg-icons\";\nimport { useTagUpdate } from \"src/core/StashService\";\nimport { useTableColumns } from \"src/hooks/useTableColumns\";\nimport cx from \"classnames\";\nimport { IColumn, ListTable } from \"../List/ListTable\";\n\ninterface ITagListTableProps {\n  tags: GQL.TagListDataFragment[];\n  selectedIds: Set<string>;\n  onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;\n}\n\nconst TABLE_NAME = \"tags\";\n\nexport const TagListTable: React.FC<ITagListTableProps> = (\n  props: ITagListTableProps\n) => {\n  const intl = useIntl();\n\n  const [updateTag] = useTagUpdate();\n\n  function setFavorite(v: boolean, tagId: string) {\n    if (tagId) {\n      updateTag({\n        variables: {\n          input: {\n            id: tagId,\n            favorite: v,\n          },\n        },\n      });\n    }\n  }\n\n  const ImageCell = (tag: GQL.TagListDataFragment) => (\n    <Link to={`/tags/${tag.id}`}>\n      <img\n        loading=\"lazy\"\n        className=\"image-thumbnail\"\n        alt={tag.name ?? \"\"}\n        src={tag.image_path ?? \"\"}\n      />\n    </Link>\n  );\n\n  const NameCell = (tag: GQL.TagListDataFragment) => (\n    <Link to={`/tags/${tag.id}`}>\n      <div className=\"ellips-data\" title={tag.name}>\n        {tag.name}\n      </div>\n    </Link>\n  );\n\n  const AliasesCell = (tag: GQL.TagListDataFragment) => {\n    let aliases = tag.aliases ? tag.aliases.join(\", \") : \"\";\n    return (\n      <span className=\"ellips-data\" title={aliases}>\n        {aliases}\n      </span>\n    );\n  };\n\n  const FavoriteCell = (tag: GQL.TagListDataFragment) => (\n    <Button\n      className={cx(\"minimal\", tag.favorite ? \"favorite\" : \"not-favorite\")}\n      onClick={() => setFavorite(!tag.favorite, tag.id)}\n    >\n      <Icon icon={faHeart} />\n    </Button>\n  );\n\n  const SceneCountCell = (tag: GQL.TagListDataFragment) => (\n    <Link to={NavUtils.makeTagScenesUrl(tag)}>\n      <span>{tag.scene_count}</span>\n    </Link>\n  );\n\n  const GalleryCountCell = (tag: GQL.TagListDataFragment) => (\n    <Link to={NavUtils.makeTagGalleriesUrl(tag)}>\n      <span>{tag.gallery_count}</span>\n    </Link>\n  );\n\n  const ImageCountCell = (tag: GQL.TagListDataFragment) => (\n    <Link to={NavUtils.makeTagImagesUrl(tag)}>\n      <span>{tag.image_count}</span>\n    </Link>\n  );\n\n  const GroupCountCell = (tag: GQL.TagListDataFragment) => (\n    <Link to={NavUtils.makeTagGroupsUrl(tag)}>\n      <span>{tag.group_count}</span>\n    </Link>\n  );\n\n  const StudioCountCell = (tag: GQL.TagListDataFragment) => (\n    <Link to={NavUtils.makeTagStudiosUrl(tag)}>\n      <span>{tag.studio_count}</span>\n    </Link>\n  );\n\n  const PerformerCountCell = (tag: GQL.TagListDataFragment) => (\n    <Link to={NavUtils.makeTagPerformersUrl(tag)}>\n      <span>{tag.performer_count}</span>\n    </Link>\n  );\n\n  interface IColumnSpec {\n    value: string;\n    label: string;\n    defaultShow?: boolean;\n    mandatory?: boolean;\n    render?: (tag: GQL.TagListDataFragment, index: number) => React.ReactNode;\n  }\n\n  const allColumns: IColumnSpec[] = [\n    {\n      value: \"image\",\n      label: intl.formatMessage({ id: \"image\" }),\n      defaultShow: true,\n      render: ImageCell,\n    },\n    {\n      value: \"name\",\n      label: intl.formatMessage({ id: \"name\" }),\n      mandatory: true,\n      defaultShow: true,\n      render: NameCell,\n    },\n    {\n      value: \"aliases\",\n      label: intl.formatMessage({ id: \"aliases\" }),\n      defaultShow: true,\n      render: AliasesCell,\n    },\n    {\n      value: \"favourite\",\n      label: intl.formatMessage({ id: \"favourite\" }),\n      defaultShow: true,\n      render: FavoriteCell,\n    },\n    {\n      value: \"scene_count\",\n      label: intl.formatMessage({ id: \"scenes\" }),\n      defaultShow: true,\n      render: SceneCountCell,\n    },\n    {\n      value: \"gallery_count\",\n      label: intl.formatMessage({ id: \"galleries\" }),\n      defaultShow: true,\n      render: GalleryCountCell,\n    },\n    {\n      value: \"image_count\",\n      label: intl.formatMessage({ id: \"images\" }),\n      defaultShow: true,\n      render: ImageCountCell,\n    },\n    {\n      value: \"group_count\",\n      label: intl.formatMessage({ id: \"groups\" }),\n      defaultShow: true,\n      render: GroupCountCell,\n    },\n    {\n      value: \"performer_count\",\n      label: intl.formatMessage({ id: \"performers\" }),\n      defaultShow: true,\n      render: PerformerCountCell,\n    },\n    {\n      value: \"studio_count\",\n      label: intl.formatMessage({ id: \"studios\" }),\n      defaultShow: true,\n      render: StudioCountCell,\n    },\n  ];\n\n  const defaultColumns = allColumns\n    .filter((col) => col.defaultShow)\n    .map((col) => col.value);\n\n  const { selectedColumns, saveColumns } = useTableColumns(\n    TABLE_NAME,\n    defaultColumns\n  );\n\n  const columnRenderFuncs: Record<\n    string,\n    (tag: GQL.TagListDataFragment, index: number) => React.ReactNode\n  > = {};\n  allColumns.forEach((col) => {\n    if (col.render) {\n      columnRenderFuncs[col.value] = col.render;\n    }\n  });\n\n  function renderCell(\n    column: IColumn,\n    tag: GQL.TagListDataFragment,\n    index: number\n  ) {\n    const render = columnRenderFuncs[column.value];\n\n    if (render) return render(tag, index);\n  }\n\n  return (\n    <ListTable\n      className=\"tag-table\"\n      items={props.tags}\n      allColumns={allColumns}\n      columns={selectedColumns}\n      setColumns={(c) => saveColumns(c)}\n      selectedIds={props.selectedIds}\n      onSelectChange={props.onSelectChange}\n      renderCell={renderCell}\n    />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagMergeDialog.tsx",
    "content": "import { Button, Form, Col, Row } from \"react-bootstrap\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport React, { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { Icon } from \"../Shared/Icon\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport * as FormUtils from \"src/utils/form\";\nimport { queryFindTagsByID, useTagsMerge } from \"src/core/StashService\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { faExchangeAlt, faSignInAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { Tag, TagSelect } from \"./TagSelect\";\nimport {\n  CustomFieldScrapeResults,\n  hasScrapedValues,\n  ObjectListScrapeResult,\n  ScrapeResult,\n} from \"../Shared/ScrapeDialog/scrapeResult\";\nimport { sortStoredIdObjects } from \"src/utils/data\";\nimport ImageUtils from \"src/utils/image\";\nimport { uniq } from \"lodash-es\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport {\n  ScrapedCustomFieldRows,\n  ScrapeDialogRow,\n  ScrapedImageRow,\n  ScrapedInputGroupRow,\n  ScrapedStringListRow,\n  ScrapedTextAreaRow,\n} from \"../Shared/ScrapeDialog/ScrapeDialogRow\";\nimport { ScrapedTagsRow } from \"../Shared/ScrapeDialog/ScrapedObjectsRow\";\nimport { ScrapeDialog } from \"../Shared/ScrapeDialog/ScrapeDialog\";\nimport { StashIDsField } from \"../Shared/StashID\";\n\ninterface ITagMergeDetailsProps {\n  sources: GQL.TagDataFragment[];\n  dest: GQL.TagDataFragment;\n  onClose: (values?: GQL.TagUpdateInput) => void;\n}\n\nconst TagMergeDetails: React.FC<ITagMergeDetailsProps> = ({\n  sources,\n  dest,\n  onClose,\n}) => {\n  const intl = useIntl();\n\n  const [loading, setLoading] = useState(true);\n\n  const filterCandidates = useCallback(\n    (t: { stored_id: string }) =>\n      t.stored_id !== dest.id && sources.every((s) => s.id !== t.stored_id),\n    [dest.id, sources]\n  );\n\n  const [name, setName] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.name)\n  );\n  const [sortName, setSortName] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.sort_name)\n  );\n  const [aliases, setAliases] = useState<ScrapeResult<string[]>>(\n    new ScrapeResult<string[]>(dest.aliases)\n  );\n  const [description, setDescription] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.description)\n  );\n  const [parentTags, setParentTags] = useState<\n    ObjectListScrapeResult<GQL.ScrapedTag>\n  >(\n    new ObjectListScrapeResult<GQL.ScrapedTag>(\n      sortStoredIdObjects(\n        dest.parents.map(idToStoredID).filter(filterCandidates)\n      )\n    )\n  );\n  const [childTags, setChildTags] = useState<\n    ObjectListScrapeResult<GQL.ScrapedTag>\n  >(\n    new ObjectListScrapeResult<GQL.ScrapedTag>(\n      sortStoredIdObjects(\n        dest.children.map(idToStoredID).filter(filterCandidates)\n      )\n    )\n  );\n\n  const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));\n\n  const [image, setImage] = useState<ScrapeResult<string>>(\n    new ScrapeResult<string>(dest.image_path)\n  );\n\n  const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(\n    new Map()\n  );\n\n  function idToStoredID(o: { id: string; name: string }) {\n    return {\n      stored_id: o.id,\n      name: o.name,\n    };\n  }\n\n  // calculate the values for everything\n  // uses the first set value for single value fields, and combines all\n  useEffect(() => {\n    async function loadImages() {\n      const src = sources.find((s) => s.image_path);\n      if (!dest.image_path || !src) return;\n\n      setLoading(true);\n\n      const destData = await ImageUtils.imageToDataURL(dest.image_path);\n      const srcData = await ImageUtils.imageToDataURL(src.image_path!);\n\n      // keep destination image by default\n      const useNewValue = false;\n      setImage(new ScrapeResult(destData, srcData, useNewValue));\n\n      setLoading(false);\n    }\n\n    // append dest to all so that if dest has stash_ids with the same\n    // endpoint, then it will be excluded first\n    const all = sources.concat(dest);\n\n    setName(\n      new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)\n    );\n    setSortName(\n      new ScrapeResult(\n        dest.sort_name,\n        sources.find((s) => s.sort_name)?.sort_name,\n        !dest.sort_name\n      )\n    );\n\n    setDescription(\n      new ScrapeResult(\n        dest.description,\n        sources.find((s) => s.description)?.description,\n        !dest.description\n      )\n    );\n\n    // default alias list should be the existing aliases, plus the names of all sources,\n    // plus all source aliases, deduplicated\n    const allAliases = uniq(\n      dest.aliases.concat(\n        sources.map((s) => s.name),\n        sources.flatMap((s) => s.aliases)\n      )\n    );\n    setAliases(new ScrapeResult(dest.aliases, allAliases, !!allAliases.length));\n\n    // default parent/child tags should be the existing tags, plus all source parent/child tags, deduplicated\n    const allParentTags = uniq(all.flatMap((s) => s.parents))\n      .map(idToStoredID)\n      .filter(filterCandidates); // exclude self and sources\n\n    setParentTags(\n      new ObjectListScrapeResult<GQL.ScrapedTag>(\n        sortStoredIdObjects(dest.parents.map(idToStoredID)),\n        sortStoredIdObjects(allParentTags),\n        !!allParentTags.length\n      )\n    );\n\n    const allChildTags = uniq(all.flatMap((s) => s.children))\n      .map(idToStoredID)\n      .filter(filterCandidates); // exclude self and sources\n\n    setChildTags(\n      new ObjectListScrapeResult<GQL.ScrapedTag>(\n        sortStoredIdObjects(\n          dest.children.map(idToStoredID).filter(filterCandidates)\n        ),\n        sortStoredIdObjects(allChildTags),\n        !!allChildTags.length\n      )\n    );\n\n    setStashIDs(\n      new ScrapeResult(\n        dest.stash_ids,\n        all\n          .map((s) => s.stash_ids)\n          .flat()\n          .filter((s, index, a) => {\n            // remove entries with duplicate endpoints\n            return index === a.findIndex((ss) => ss.endpoint === s.endpoint);\n          })\n      )\n    );\n\n    setImage(\n      new ScrapeResult(\n        dest.image_path,\n        sources.find((s) => s.image_path)?.image_path,\n        !dest.image_path\n      )\n    );\n\n    const customFieldNames = new Set<string>(Object.keys(dest.custom_fields));\n\n    for (const s of sources) {\n      for (const n of Object.keys(s.custom_fields)) {\n        customFieldNames.add(n);\n      }\n    }\n\n    setCustomFields(\n      new Map(\n        Array.from(customFieldNames)\n          .sort()\n          .map((field) => {\n            return [\n              field,\n              new ScrapeResult(\n                dest.custom_fields?.[field],\n                sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[\n                  field\n                ],\n                dest.custom_fields?.[field] === undefined\n              ),\n            ];\n          })\n      )\n    );\n\n    loadImages();\n  }, [sources, dest, filterCandidates]);\n\n  const hasCustomFieldValues = useMemo(() => {\n    return hasScrapedValues(Array.from(customFields.values()));\n  }, [customFields]);\n\n  // ensure this is updated if fields are changed\n  const hasValues = useMemo(() => {\n    return (\n      hasCustomFieldValues ||\n      hasScrapedValues([\n        name,\n        sortName,\n        aliases,\n        description,\n        parentTags,\n        childTags,\n        stashIDs,\n        image,\n      ])\n    );\n  }, [\n    name,\n    sortName,\n    aliases,\n    description,\n    parentTags,\n    childTags,\n    stashIDs,\n    image,\n    hasCustomFieldValues,\n  ]);\n\n  function renderScrapeRows() {\n    if (loading) {\n      return (\n        <div>\n          <LoadingIndicator />\n        </div>\n      );\n    }\n\n    if (!hasValues) {\n      return (\n        <div>\n          <FormattedMessage id=\"dialogs.merge.empty_results\" />\n        </div>\n      );\n    }\n\n    return (\n      <>\n        <ScrapedInputGroupRow\n          field=\"name\"\n          title={intl.formatMessage({ id: \"name\" })}\n          result={name}\n          onChange={(value) => setName(value)}\n        />\n        <ScrapedInputGroupRow\n          field=\"sort_name\"\n          title={intl.formatMessage({ id: \"sort_name\" })}\n          result={sortName}\n          onChange={(value) => setSortName(value)}\n        />\n        <ScrapedStringListRow\n          field=\"aliases\"\n          title={intl.formatMessage({ id: \"aliases\" })}\n          result={aliases}\n          onChange={(value) => setAliases(value)}\n        />\n        <ScrapedTagsRow\n          field=\"parent_tags\"\n          title={intl.formatMessage({ id: \"parent_tags\" })}\n          result={parentTags}\n          onChange={(value) => setParentTags(value)}\n        />\n        <ScrapedTagsRow\n          field=\"child_tags\"\n          title={intl.formatMessage({ id: \"sub_tags\" })}\n          result={childTags}\n          onChange={(value) => setChildTags(value)}\n        />\n        <ScrapedTextAreaRow\n          field=\"description\"\n          title={intl.formatMessage({ id: \"description\" })}\n          result={description}\n          onChange={(value) => setDescription(value)}\n        />\n        <ScrapeDialogRow\n          field=\"stash_ids\"\n          title={intl.formatMessage({ id: \"stash_id\" })}\n          result={stashIDs}\n          originalField={\n            <StashIDsField values={stashIDs?.originalValue ?? []} />\n          }\n          newField={<StashIDsField values={stashIDs?.newValue ?? []} />}\n          onChange={(value) => setStashIDs(value)}\n          alwaysShow={\n            !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length\n          }\n        />\n        <ScrapedImageRow\n          field=\"image\"\n          title={intl.formatMessage({ id: \"tag_image\" })}\n          className=\"performer-image\"\n          result={image}\n          onChange={(value) => setImage(value)}\n        />\n        {hasCustomFieldValues && (\n          <ScrapedCustomFieldRows\n            results={customFields}\n            onChange={(newCustomFields) => setCustomFields(newCustomFields)}\n          />\n        )}\n      </>\n    );\n  }\n\n  function createValues(): GQL.TagUpdateInput {\n    // only set the cover image if it's different from the existing cover image\n    const coverImage = image.useNewValue ? image.getNewValue() : undefined;\n\n    return {\n      id: dest.id,\n      name: name.getNewValue(),\n      sort_name: sortName.getNewValue(),\n      aliases: aliases\n        .getNewValue()\n        ?.map((s) => s.trim())\n        .filter((s) => s.length > 0),\n      parent_ids: parentTags.getNewValue()?.map((t) => t.stored_id!),\n      child_ids: childTags.getNewValue()?.map((t) => t.stored_id!),\n      description: description.getNewValue(),\n      stash_ids: stashIDs.getNewValue(),\n      image: coverImage,\n      custom_fields: {\n        partial: Object.fromEntries(\n          Array.from(customFields.entries()).flatMap(([field, v]) =>\n            v.useNewValue ? [[field, v.getNewValue()]] : []\n          )\n        ),\n      },\n    };\n  }\n\n  const dialogTitle = intl.formatMessage({\n    id: \"actions.merge\",\n  });\n\n  const destinationLabel = !hasValues\n    ? \"\"\n    : intl.formatMessage({ id: \"dialogs.merge.destination\" });\n  const sourceLabel = !hasValues\n    ? \"\"\n    : intl.formatMessage({ id: \"dialogs.merge.combined\" });\n\n  return (\n    <ScrapeDialog\n      className=\"tag-merge-dialog\"\n      title={dialogTitle}\n      existingLabel={destinationLabel}\n      scrapedLabel={sourceLabel}\n      onClose={(apply) => {\n        if (!apply) {\n          onClose();\n        } else {\n          onClose(createValues());\n        }\n      }}\n    >\n      {renderScrapeRows()}\n    </ScrapeDialog>\n  );\n};\n\ninterface ITagMergeModalProps {\n  show: boolean;\n  onClose: (mergedID?: string) => void;\n  tags: Tag[];\n}\n\nexport const TagMergeModal: React.FC<ITagMergeModalProps> = ({\n  show,\n  onClose,\n  tags,\n}) => {\n  const [src, setSrc] = useState<Tag[]>([]);\n  const [dest, setDest] = useState<Tag | null>(null);\n\n  const [loadedSources, setLoadedSources] = useState<GQL.TagDataFragment[]>([]);\n  const [loadedDest, setLoadedDest] = useState<GQL.TagDataFragment>();\n\n  const [secondStep, setSecondStep] = useState(false);\n\n  const [running, setRunning] = useState(false);\n\n  const [mergeTags] = useTagsMerge();\n\n  const intl = useIntl();\n  const Toast = useToast();\n\n  const title = intl.formatMessage({\n    id: \"actions.merge\",\n  });\n\n  useEffect(() => {\n    if (tags.length > 0) {\n      setDest(tags[0]);\n      setSrc(tags.slice(1));\n    }\n  }, [tags]);\n\n  async function loadTags() {\n    try {\n      const tagIDs = src.map((s) => s.id);\n      tagIDs.push(dest!.id);\n      const query = await queryFindTagsByID(tagIDs);\n      const { tags: loadedTags } = query.data.findTags;\n\n      setLoadedDest(loadedTags.find((s) => s.id === dest!.id));\n      setLoadedSources(loadedTags.filter((s) => s.id !== dest!.id));\n      setSecondStep(true);\n    } catch (e) {\n      Toast.error(e);\n      return;\n    }\n  }\n\n  async function onMerge(values: GQL.TagUpdateInput) {\n    if (!dest) return;\n\n    const source = src.map((s) => s.id);\n    const destination = dest.id;\n\n    try {\n      setRunning(true);\n      const result = await mergeTags({\n        variables: {\n          source,\n          destination,\n          values,\n        },\n      });\n      if (result.data?.tagsMerge) {\n        Toast.success(intl.formatMessage({ id: \"toast.merged_tags\" }));\n        onClose(dest.id);\n      }\n    } catch (e) {\n      Toast.error(e);\n    } finally {\n      setRunning(false);\n    }\n  }\n\n  function canMerge() {\n    return src.length > 0 && dest !== null;\n  }\n\n  function switchTags() {\n    if (src.length && dest !== null) {\n      const newDest = src[0];\n      setSrc([...src.slice(1), dest]);\n      setDest(newDest);\n    }\n  }\n\n  if (secondStep && dest) {\n    return (\n      <TagMergeDetails\n        sources={loadedSources}\n        dest={loadedDest!}\n        onClose={(values) => {\n          setSecondStep(false);\n          if (values) {\n            onMerge(values);\n          } else {\n            onClose();\n          }\n        }}\n      />\n    );\n  }\n\n  return (\n    <ModalComponent\n      show={show}\n      header={title}\n      icon={faSignInAlt}\n      accept={{\n        text: intl.formatMessage({ id: \"actions.merge\" }),\n        onClick: () => loadTags(),\n      }}\n      disabled={!canMerge()}\n      cancel={{\n        variant: \"secondary\",\n        onClick: () => onClose(),\n      }}\n      isRunning={running}\n    >\n      <div className=\"form-container row px-3\">\n        <div className=\"col-12 col-lg-6 col-xl-12\">\n          <Form.Group controlId=\"source\" as={Row}>\n            {FormUtils.renderLabel({\n              title: intl.formatMessage({ id: \"dialogs.merge.source\" }),\n              labelProps: {\n                column: true,\n                sm: 3,\n                xl: 12,\n              },\n            })}\n            <Col sm={9} xl={12}>\n              <TagSelect\n                isMulti\n                creatable={false}\n                onSelect={(items) => setSrc(items)}\n                values={src}\n                menuPortalTarget={document.body}\n              />\n            </Col>\n          </Form.Group>\n          <Form.Group\n            controlId=\"switch\"\n            as={Row}\n            className=\"justify-content-center\"\n          >\n            <Button\n              variant=\"secondary\"\n              onClick={() => switchTags()}\n              disabled={!src.length || !dest}\n              title={intl.formatMessage({ id: \"actions.swap\" })}\n            >\n              <Icon className=\"fa-fw\" icon={faExchangeAlt} />\n            </Button>\n          </Form.Group>\n          <Form.Group controlId=\"destination\" as={Row}>\n            {FormUtils.renderLabel({\n              title: intl.formatMessage({\n                id: \"dialogs.merge.destination\",\n              }),\n              labelProps: {\n                column: true,\n                sm: 3,\n                xl: 12,\n              },\n            })}\n            <Col sm={9} xl={12}>\n              <TagSelect\n                isMulti={false}\n                creatable={false}\n                onSelect={(items) => setDest(items[0])}\n                values={dest ? [dest] : undefined}\n                menuPortalTarget={document.body}\n              />\n            </Col>\n          </Form.Group>\n        </div>\n      </div>\n    </ModalComponent>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagPopover.tsx",
    "content": "import React from \"react\";\nimport { ErrorMessage } from \"../Shared/ErrorMessage\";\nimport { LoadingIndicator } from \"../Shared/LoadingIndicator\";\nimport { HoverPopover } from \"../Shared/HoverPopover\";\nimport { useFindTag } from \"../../core/StashService\";\nimport { TagCard } from \"./TagCard\";\nimport { useConfigurationContext } from \"../../hooks/Config\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\n\ninterface ITagPopoverCardProps {\n  id: string;\n}\n\nexport const TagPopoverCard: React.FC<ITagPopoverCardProps> = ({ id }) => {\n  const { data, loading, error } = useFindTag(id);\n\n  if (loading)\n    return (\n      <div className=\"tag-popover-card-placeholder\">\n        <LoadingIndicator card={true} message={\"\"} />\n      </div>\n    );\n  if (error) return <ErrorMessage error={error.message} />;\n  if (!data?.findTag)\n    return <ErrorMessage error={`No tag found with id ${id}.`} />;\n\n  const tag = data.findTag;\n\n  return (\n    <div className=\"tag-popover-card\">\n      <TagCard tag={tag} zoomIndex={0} />\n    </div>\n  );\n};\n\ninterface ITagPopoverProps {\n  id: string;\n  hide?: boolean;\n  placement?: Placement;\n  target?: React.RefObject<HTMLElement>;\n}\n\nexport const TagPopover: React.FC<ITagPopoverProps> = ({\n  id,\n  hide,\n  children,\n  placement = \"top\",\n  target,\n}) => {\n  const { configuration: config } = useConfigurationContext();\n\n  const showTagCardOnHover = config?.ui.showTagCardOnHover ?? true;\n\n  if (hide || !showTagCardOnHover) {\n    return <>{children}</>;\n  }\n\n  return (\n    <HoverPopover\n      target={target}\n      placement={placement}\n      enterDelay={500}\n      leaveDelay={100}\n      content={<TagPopoverCard id={id} />}\n    >\n      {children}\n    </HoverPopover>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagRecommendationRow.tsx",
    "content": "import React from \"react\";\nimport { useFindTags } from \"src/core/StashService\";\nimport { TagCard } from \"./TagCard\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { PatchComponent } from \"src/patch\";\nimport { FilteredRecommendationRow } from \"../FrontPage/FilteredRecommendationRow\";\n\ninterface IProps {\n  isTouch: boolean;\n  filter: ListFilterModel;\n  header: string;\n}\n\nexport const TagRecommendationRow: React.FC<IProps> = PatchComponent(\n  \"TagRecommendationRow\",\n  (props) => {\n    const result = useFindTags(props.filter);\n    const count = result.data?.findTags.count ?? 0;\n\n    return (\n      <FilteredRecommendationRow\n        className=\"tag-recommendations\"\n        heading={props.header}\n        url={`/tags?${props.filter.makeQueryParameters()}`}\n        count={count}\n        loading={result.loading}\n        isTouch={props.isTouch}\n        filter={props.filter}\n      >\n        {result.loading\n          ? [...Array(props.filter.itemsPerPage)].map((i) => (\n              <div key={`_${i}`} className=\"tag-skeleton skeleton-card\"></div>\n            ))\n          : result.data?.findTags.tags.map((p) => (\n              <TagCard key={p.id} tag={p} zoomIndex={0} />\n            ))}\n      </FilteredRecommendationRow>\n    );\n  }\n);\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/TagSelect.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\nimport {\n  OptionProps,\n  components as reactSelectComponents,\n  MultiValueGenericProps,\n  SingleValueProps,\n} from \"react-select\";\nimport cx from \"classnames\";\n\nimport * as GQL from \"src/core/generated-graphql\";\nimport {\n  useTagCreate,\n  queryFindTagsByIDForSelect,\n  queryFindTagsForSelect,\n} from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useIntl } from \"react-intl\";\nimport { defaultMaxOptionsShown } from \"src/core/config\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  FilterSelectComponent,\n  IFilterIDProps,\n  IFilterProps,\n  IFilterValueProps,\n  Option as SelectOption,\n  toOption,\n} from \"../Shared/FilterSelect\";\nimport { useCompare } from \"src/hooks/state\";\nimport { TagPopover } from \"./TagPopover\";\nimport { Placement } from \"react-bootstrap/esm/Overlay\";\nimport { sortByRelevance } from \"src/utils/query\";\nimport { PatchComponent, PatchFunction } from \"src/patch\";\nimport { isUUID } from \"src/utils/stashIds\";\nimport { filterByStashID } from \"src/models/list-filter/utils\";\n\nexport type SelectObject = {\n  id: string;\n  name?: string | null;\n  title?: string | null;\n};\n\nexport type Tag = Pick<\n  GQL.Tag,\n  \"id\" | \"name\" | \"sort_name\" | \"aliases\" | \"image_path\" | \"stash_ids\"\n>;\ntype Option = SelectOption<Tag>;\n\ntype FindTagsResult = Awaited<\n  ReturnType<typeof queryFindTagsForSelect>\n>[\"data\"][\"findTags\"][\"tags\"];\n\nfunction sortTagsByRelevance(input: string, tags: FindTagsResult) {\n  return sortByRelevance(\n    input,\n    tags,\n    (t) => t.name,\n    (t) => t.aliases\n  );\n}\n\nconst tagSelectSort = PatchFunction(\"TagSelect.sort\", sortTagsByRelevance);\n\nexport type TagSelectProps = IFilterProps &\n  IFilterValueProps<Tag> & {\n    hoverPlacement?: Placement;\n    hoverPlacementLabel?: Placement;\n    excludeIds?: string[];\n  };\n\nconst _TagSelect: React.FC<TagSelectProps> = (props) => {\n  const [createTag] = useTagCreate();\n\n  const { configuration } = useConfigurationContext();\n  const intl = useIntl();\n  const maxOptionsShown =\n    configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown;\n  const defaultCreatable = !configuration?.interface.disableDropdownCreate.tag;\n\n  const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);\n\n  function filterExcluded(tag: Tag) {\n    // HACK - we should probably exclude these in the backend query, but\n    // this will do in the short-term\n    return !exclude.includes(tag.id.toString());\n  }\n\n  async function loadTags(input: string): Promise<Option[]> {\n    const filter = new ListFilterModel(GQL.FilterMode.Tags);\n    filter.currentPage = 1;\n    filter.itemsPerPage = maxOptionsShown;\n    filter.sortBy = \"name\";\n    filter.sortDirection = GQL.SortDirectionEnum.Asc;\n\n    if (isUUID(input)) {\n      filterByStashID(filter, input);\n\n      const query = await queryFindTagsForSelect(filter);\n      const matches = query.data.findTags.tags.filter(filterExcluded);\n\n      if (matches.length > 0) {\n        // Matches found, return them immediately.\n        return matches.map(toOption);\n      }\n\n      // If no stash_id matches found, continue with standard name/alias search.\n      filter.criteria = []; // Clear stash_id criterion to search by name/alias below.\n    }\n\n    filter.searchTerm = input;\n\n    const query = await queryFindTagsForSelect(filter);\n    const ret = query.data.findTags.tags.filter(filterExcluded);\n\n    return tagSelectSort(input, ret).map(toOption);\n  }\n\n  const TagOption: React.FC<OptionProps<Option, boolean>> = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    let { name } = object;\n\n    // if name does not match the input value but an alias does, show the alias\n    const { inputValue } = optionProps.selectProps;\n    let alias: string | undefined = \"\";\n    if (!name.toLowerCase().includes(inputValue.toLowerCase())) {\n      alias = object.aliases?.find((a) =>\n        a.toLowerCase().includes(inputValue.toLowerCase())\n      );\n    }\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <TagPopover id={object.id} placement={props.hoverPlacement ?? \"right\"}>\n          <span className=\"react-select-image-option\">\n            {/* the following code causes re-rendering issues when selecting tags */}\n            {/* <TagPopover\n              id={object.id}\n              placement={props.hoverPlacement}\n              target={targetRef}\n            >\n              <a\n                href={`/tags/${object.id}`}\n                target=\"_blank\"\n                rel=\"noreferrer\"\n                className=\"tag-select-image-link\"\n              >\n                <img\n                  className=\"tag-select-image\"\n                  src={object.image_path ?? \"\"}\n                  loading=\"lazy\"\n                />\n              </a>\n            </TagPopover> */}\n            <span>{name}</span>\n            {alias && <span className=\"alias\">&nbsp;({alias})</span>}\n          </span>\n        </TagPopover>\n      ),\n    };\n\n    return <reactSelectComponents.Option {...thisOptionProps} />;\n  };\n\n  const TagMultiValueLabel: React.FC<\n    MultiValueGenericProps<Option, boolean>\n  > = (optionProps) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: (\n        <TagPopover\n          id={object.id}\n          placement={props.hoverPlacementLabel ?? \"top\"}\n        >\n          <span>{object.name}</span>\n        </TagPopover>\n      ),\n    };\n\n    return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;\n  };\n\n  const TagValueLabel: React.FC<SingleValueProps<Option, boolean>> = (\n    optionProps\n  ) => {\n    let thisOptionProps = optionProps;\n\n    const { object } = optionProps.data;\n\n    thisOptionProps = {\n      ...optionProps,\n      children: <>{object.name}</>,\n    };\n\n    return <reactSelectComponents.SingleValue {...thisOptionProps} />;\n  };\n\n  const onCreate = async (name: string) => {\n    const result = await createTag({\n      variables: { input: { name } },\n    });\n    return {\n      value: result.data!.tagCreate!.id,\n      item: result.data!.tagCreate!,\n      message: \"Created tag\",\n    };\n  };\n\n  const getNamedObject = (id: string, name: string) => {\n    return {\n      id,\n      name,\n      aliases: [],\n      stash_ids: [],\n    };\n  };\n\n  const isValidNewOption = (inputValue: string, options: Tag[]) => {\n    if (!inputValue) {\n      return false;\n    }\n\n    if (\n      options.some((o) => {\n        return (\n          o.name.toLowerCase() === inputValue.toLowerCase() ||\n          o.aliases?.some((a) => a.toLowerCase() === inputValue.toLowerCase())\n        );\n      })\n    ) {\n      return false;\n    }\n\n    return true;\n  };\n\n  return (\n    <FilterSelectComponent<Tag, boolean>\n      {...props}\n      className={cx(\n        \"tag-select\",\n        {\n          \"tag-select-active\": props.active,\n        },\n        props.className\n      )}\n      loadOptions={loadTags}\n      getNamedObject={getNamedObject}\n      isValidNewOption={isValidNewOption}\n      components={{\n        Option: TagOption,\n        MultiValueLabel: TagMultiValueLabel,\n        SingleValue: TagValueLabel,\n      }}\n      isMulti={props.isMulti ?? false}\n      creatable={props.creatable ?? defaultCreatable}\n      onCreate={onCreate}\n      placeholder={\n        props.noSelectionString ??\n        intl.formatMessage(\n          { id: \"actions.select_entity\" },\n          {\n            entityType: intl.formatMessage({\n              id: props.isMulti ? \"tags\" : \"tag\",\n            }),\n          }\n        )\n      }\n      closeMenuOnSelect={!props.isMulti}\n    />\n  );\n};\n\nexport const TagSelect = PatchComponent(\"TagSelect\", _TagSelect);\n\nconst _TagIDSelect: React.FC<IFilterProps & IFilterIDProps<Tag>> = (props) => {\n  const { ids, onSelect: onSelectValues } = props;\n\n  const [values, setValues] = useState<Tag[]>([]);\n  const idsChanged = useCompare(ids);\n\n  function onSelect(items: Tag[]) {\n    setValues(items);\n    onSelectValues?.(items);\n  }\n\n  async function loadObjectsByID(idsToLoad: string[]): Promise<Tag[]> {\n    const query = await queryFindTagsByIDForSelect(idsToLoad);\n    const { tags: loadedTags } = query.data.findTags;\n\n    return loadedTags;\n  }\n\n  useEffect(() => {\n    if (!idsChanged) {\n      return;\n    }\n\n    if (!ids || ids?.length === 0) {\n      setValues([]);\n      return;\n    }\n\n    // load the values if we have ids and they haven't been loaded yet\n    const filteredValues = values.filter((v) => ids.includes(v.id.toString()));\n    if (filteredValues.length === ids.length) {\n      return;\n    }\n\n    const load = async () => {\n      const items = await loadObjectsByID(ids);\n\n      // #4684 - sort items by sort name/name\n      const sortedItems = [...items];\n      sortedItems.sort((a, b) => {\n        const aName = a.sort_name || a.name;\n        const bName = b.sort_name || b.name;\n\n        if (aName && bName) {\n          return aName.localeCompare(bName);\n        }\n        return 0;\n      });\n\n      setValues(sortedItems);\n    };\n\n    load();\n  }, [ids, idsChanged, values]);\n\n  return <TagSelect {...props} values={values} onSelect={onSelect} />;\n};\n\nexport const TagIDSelect = PatchComponent(\"TagIDSelect\", _TagIDSelect);\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/Tags.tsx",
    "content": "import React from \"react\";\nimport { Route, Switch } from \"react-router-dom\";\nimport { Helmet } from \"react-helmet\";\nimport { useTitleProps } from \"src/hooks/title\";\nimport Tag from \"./TagDetails/Tag\";\nimport TagCreate from \"./TagDetails/TagCreate\";\nimport { FilteredTagList } from \"./TagList\";\n\nconst Tags: React.FC = () => {\n  return <FilteredTagList />;\n};\n\nconst TagRoutes: React.FC = () => {\n  const titleProps = useTitleProps({ id: \"tags\" });\n  return (\n    <>\n      <Helmet {...titleProps} />\n      <Switch>\n        <Route exact path=\"/tags\" component={Tags} />\n        <Route exact path=\"/tags/new\" component={TagCreate} />\n        <Route path=\"/tags/:id/:tab?\" component={Tag} />\n      </Switch>\n    </>\n  );\n};\n\nexport default TagRoutes;\n"
  },
  {
    "path": "ui/v2.5/src/components/Tags/styles.scss",
    "content": ".tag-list {\n  &-row {\n    margin: 0.5rem 0;\n  }\n\n  &-button {\n    margin: 0 0.5rem;\n    width: 8rem;\n  }\n\n  &-anchor {\n    color: $text-color;\n  }\n\n  &-count {\n    display: inline-block;\n    margin: 0 0.5rem;\n    min-width: 6rem;\n  }\n}\n\n.tag-card {\n  padding: 0.5rem;\n\n  @media (max-width: 576px) {\n    width: 100%;\n  }\n\n  &-image {\n    display: block;\n    margin: 0 auto;\n    object-fit: contain;\n  }\n\n  button.btn.favorite-button {\n    opacity: 1;\n    padding: 0;\n    position: absolute;\n    right: 5px;\n    top: 10px;\n    transition: opacity 0.5s;\n\n    svg.fa-icon {\n      margin-left: 0.4rem;\n      margin-right: 0.4rem;\n    }\n\n    &.not-favorite {\n      color: rgba(191, 204, 214, 0.5);\n      filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));\n      opacity: 0;\n    }\n\n    &.favorite {\n      color: #ff7373;\n      filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));\n    }\n\n    &:hover,\n    &:active,\n    &:focus,\n    &:active:focus {\n      background: none;\n      box-shadow: none;\n    }\n  }\n\n  &:hover button.btn.favorite-button.not-favorite {\n    opacity: 1;\n  }\n}\n\n#tag-page {\n  .tag-head {\n    .name-icons {\n      .not-favorite {\n        color: rgba(191, 204, 214, 0.5);\n      }\n\n      .favorite {\n        color: #ff7373;\n      }\n    }\n  }\n}\n\n.tag-details {\n  .logo {\n    max-height: 50vh;\n    max-width: 100%;\n  }\n\n  .logo-container {\n    margin-bottom: 4rem;\n  }\n}\n\n#tag-merge-menu .dropdown-item {\n  align-items: center;\n}\n\n.tag-card {\n  .tag-description + div {\n    margin-top: 1rem;\n  }\n}\n\n.tag-popover-card-placeholder {\n  display: flex;\n  max-width: 240px;\n  min-height: 314px;\n  width: calc(100vw - 2rem);\n}\n\n.tag-popover-card {\n  padding: 0.5rem;\n  text-align: left;\n\n  .card {\n    background: transparent;\n    box-shadow: none;\n    max-width: calc(100vw - 2rem);\n    padding: 0;\n    width: 240px;\n  }\n}\n\n.tag-item {\n  .icon-wrapper {\n    color: #202b33;\n    opacity: 0.5;\n    padding-left: 6px;\n  }\n}\n\n.tag-item {\n  .tag-icon {\n    color: #202b33;\n    margin: 0;\n    opacity: 0.5;\n    padding-left: 3px;\n    transform: scale(0.7);\n  }\n}\n\n.tag-select {\n  .alias {\n    font-weight: bold;\n    white-space: pre;\n  }\n}\n\n.tag-select-image {\n  height: 25px;\n  margin-right: 0.5em;\n  width: 25px;\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { faBug } from \"@fortawesome/free-solid-svg-icons\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { useTroubleshootingMode } from \"./useTroubleshootingMode\";\n\nconst DIALOG_ITEMS = [\n  \"config.ui.troubleshooting_mode.dialog_item_plugins\",\n  \"config.ui.troubleshooting_mode.dialog_item_css\",\n  \"config.ui.troubleshooting_mode.dialog_item_js\",\n  \"config.ui.troubleshooting_mode.dialog_item_locales\",\n] as const;\n\nexport const TroubleshootingModeButton: React.FC = () => {\n  const intl = useIntl();\n  const [showDialog, setShowDialog] = useState(false);\n  const { enable, isLoading } = useTroubleshootingMode();\n\n  return (\n    <>\n      <div className=\"troubleshooting-mode-button\">\n        <Button variant=\"primary\" size=\"sm\" onClick={() => setShowDialog(true)}>\n          <FormattedMessage id=\"config.ui.troubleshooting_mode.button\" />\n        </Button>\n      </div>\n\n      <ModalComponent\n        show={showDialog}\n        onHide={() => setShowDialog(false)}\n        header={intl.formatMessage({\n          id: \"config.ui.troubleshooting_mode.dialog_title\",\n        })}\n        icon={faBug}\n        accept={{\n          text: intl.formatMessage({\n            id: \"config.ui.troubleshooting_mode.enable\",\n          }),\n          variant: \"primary\",\n          onClick: enable,\n        }}\n        cancel={{\n          onClick: () => setShowDialog(false),\n          variant: \"secondary\",\n        }}\n        isRunning={isLoading}\n      >\n        <p>\n          <FormattedMessage id=\"config.ui.troubleshooting_mode.dialog_description\" />\n        </p>\n        <ul>\n          {DIALOG_ITEMS.map((id) => (\n            <li key={id}>\n              <FormattedMessage id={id} />\n            </li>\n          ))}\n        </ul>\n        <p>\n          <FormattedMessage id=\"config.ui.troubleshooting_mode.dialog_log_level\" />\n        </p>\n        <p className=\"text-muted\">\n          <FormattedMessage id=\"config.ui.troubleshooting_mode.dialog_reload_note\" />\n        </p>\n      </ModalComponent>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx",
    "content": "import React from \"react\";\nimport { Button } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { faBug } from \"@fortawesome/free-solid-svg-icons\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { useTroubleshootingMode } from \"./useTroubleshootingMode\";\n\nexport const TroubleshootingModeOverlay: React.FC = () => {\n  const { isActive, isLoading, disable } = useTroubleshootingMode();\n\n  if (!isActive) {\n    return null;\n  }\n\n  return (\n    <div className=\"troubleshooting-mode-overlay\">\n      <div className=\"troubleshooting-mode-alert\">\n        <span>\n          <Icon icon={faBug} className=\"mr-2\" />\n          <FormattedMessage id=\"config.ui.troubleshooting_mode.overlay_message\" />\n        </span>\n        <Button variant=\"link\" onClick={disable} disabled={isLoading}>\n          <FormattedMessage id=\"config.ui.troubleshooting_mode.exit\" />\n        </Button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts",
    "content": "import { useState, useRef, useEffect } from \"react\";\nimport {\n  useConfigureInterface,\n  useConfigureGeneral,\n  useConfiguration,\n} from \"src/core/StashService\";\n\nconst ORIGINAL_LOG_LEVEL_KEY = \"troubleshootingMode_originalLogLevel\";\n\nexport function useTroubleshootingMode() {\n  const [isLoading, setIsLoading] = useState(false);\n  const isMounted = useRef(true);\n\n  const { data: config } = useConfiguration();\n  const [configureInterface] = useConfigureInterface();\n  const [configureGeneral] = useConfigureGeneral();\n\n  const isActive =\n    config?.configuration?.interface?.disableCustomizations ?? false;\n  const currentLogLevel = config?.configuration?.general?.logLevel || \"Info\";\n\n  useEffect(() => {\n    return () => {\n      isMounted.current = false;\n    };\n  }, []);\n\n  async function enable() {\n    setIsLoading(true);\n    try {\n      // Store original log level for restoration later\n      localStorage.setItem(ORIGINAL_LOG_LEVEL_KEY, currentLogLevel);\n\n      // Enable troubleshooting mode and set log level to Debug\n      await Promise.all([\n        configureInterface({\n          variables: { input: { disableCustomizations: true } },\n        }),\n        configureGeneral({\n          variables: { input: { logLevel: \"Debug\" } },\n        }),\n      ]);\n\n      window.location.reload();\n    } catch (e) {\n      if (isMounted.current) {\n        setIsLoading(false);\n      }\n      throw e;\n    }\n  }\n\n  async function disable() {\n    setIsLoading(true);\n    try {\n      // Restore original log level\n      const originalLogLevel =\n        localStorage.getItem(ORIGINAL_LOG_LEVEL_KEY) || \"Info\";\n\n      // Disable troubleshooting mode and restore log level\n      await Promise.all([\n        configureInterface({\n          variables: { input: { disableCustomizations: false } },\n        }),\n        configureGeneral({\n          variables: { input: { logLevel: originalLogLevel } },\n        }),\n      ]);\n\n      // Clean up localStorage\n      localStorage.removeItem(ORIGINAL_LOG_LEVEL_KEY);\n\n      window.location.reload();\n    } catch (e) {\n      if (isMounted.current) {\n        setIsLoading(false);\n      }\n      throw e;\n    }\n  }\n\n  return { isActive, isLoading, enable, disable };\n}\n"
  },
  {
    "path": "ui/v2.5/src/components/Wall/WallItem.tsx",
    "content": "import React, {\n  useRef,\n  useState,\n  useEffect,\n  useCallback,\n  MouseEvent,\n  useMemo,\n} from \"react\";\nimport { Link } from \"react-router-dom\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport NavUtils from \"src/utils/navigation\";\nimport cx from \"classnames\";\nimport { SceneQueue } from \"src/models/sceneQueue\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { markerTitle } from \"src/core/markers\";\nimport { objectTitle } from \"src/core/files\";\n\nexport type WallItemType = keyof WallItemData;\n\nexport type WallItemData = {\n  scene: GQL.SlimSceneDataFragment;\n  sceneMarker: GQL.SceneMarkerDataFragment;\n  image: GQL.SlimImageDataFragment;\n};\n\ninterface IWallItemProps<T extends WallItemType> {\n  type: T;\n  index?: number;\n  data: WallItemData[T];\n  sceneQueue?: SceneQueue;\n  clickHandler?: (e: MouseEvent, item: WallItemData[T]) => void;\n  className: string;\n}\n\ninterface IPreviews {\n  video?: string;\n  animation?: string;\n  image?: string;\n}\n\nconst Preview: React.FC<{\n  previews: IPreviews;\n  config?: GQL.ConfigDataFragment;\n  active: boolean;\n}> = ({ previews, config, active }) => {\n  const videoEl = useRef<HTMLVideoElement>(null);\n  const [isMissing, setIsMissing] = useState(false);\n\n  const previewType = config?.interface?.wallPlayback;\n  const soundOnPreview = config?.interface?.soundOnPreview ?? false;\n\n  useEffect(() => {\n    const video = videoEl.current;\n    if (!video) return;\n\n    video.muted = !(soundOnPreview && active);\n    if (previewType !== \"video\") {\n      if (active) {\n        video.play();\n      } else {\n        video.pause();\n      }\n    }\n  }, [previewType, soundOnPreview, active]);\n\n  const image = (\n    <img\n      loading=\"lazy\"\n      alt=\"\"\n      className=\"wall-item-media\"\n      src={\n        (previewType === \"animation\" && previews.animation) || previews.image\n      }\n    />\n  );\n  const video = (\n    <video\n      disableRemotePlayback\n      playsInline\n      src={previews.video}\n      poster={previews.image}\n      autoPlay={previewType === \"video\"}\n      loop\n      muted\n      className={cx(\"wall-item-media\", {\n        \"wall-item-preview\": previewType !== \"video\",\n      })}\n      onError={(error: React.SyntheticEvent<HTMLVideoElement>) => {\n        // Error code 4 indicates media not found or unsupported\n        setIsMissing(error.currentTarget.error?.code === 4);\n      }}\n      ref={videoEl}\n    />\n  );\n\n  if (isMissing) {\n    // show the image if the video preview is unavailable\n    if (previews.image) {\n      return image;\n    }\n\n    return (\n      <div className=\"wall-item-media wall-item-missing\">\n        Pending preview generation\n      </div>\n    );\n  }\n\n  if (previewType === \"video\") {\n    return video;\n  }\n  return (\n    <>\n      {image}\n      {video}\n    </>\n  );\n};\n\nexport const WallItem = <T extends WallItemType>({\n  type,\n  index,\n  data,\n  sceneQueue,\n  clickHandler,\n  className,\n}: IWallItemProps<T>) => {\n  const [active, setActive] = useState(false);\n  const itemEl = useRef<HTMLDivElement>(null);\n  const { configuration: config } = useConfigurationContext();\n\n  const showTextContainer = config?.interface.wallShowTitle ?? true;\n\n  const previews = useMemo(() => {\n    switch (type) {\n      case \"scene\":\n        const scene = data as GQL.SlimSceneDataFragment;\n        return {\n          video: scene.paths.preview ?? undefined,\n          animation: scene.paths.webp ?? undefined,\n          image: scene.paths.screenshot ?? undefined,\n        };\n      case \"sceneMarker\":\n        const sceneMarker = data as GQL.SceneMarkerDataFragment;\n        return {\n          video: sceneMarker.stream,\n          animation: sceneMarker.preview,\n          image: sceneMarker.screenshot,\n        };\n      case \"image\":\n        const image = data as GQL.SlimImageDataFragment;\n        return {\n          image: image.paths.thumbnail ?? undefined,\n        };\n      default:\n        // this is unreachable, inference fails for some reason\n        return type as never;\n    }\n  }, [type, data]);\n  const linkSrc = useMemo(() => {\n    switch (type) {\n      case \"scene\":\n        const scene = data as GQL.SlimSceneDataFragment;\n        return sceneQueue\n          ? sceneQueue.makeLink(scene.id, { sceneIndex: index })\n          : `/scenes/${scene.id}`;\n      case \"sceneMarker\":\n        const sceneMarker = data as GQL.SceneMarkerDataFragment;\n        return NavUtils.makeSceneMarkerUrl(sceneMarker);\n      case \"image\":\n        const image = data as GQL.SlimImageDataFragment;\n        return `/images/${image.id}`;\n      default:\n        return type;\n    }\n  }, [type, data, sceneQueue, index]);\n  const title = useMemo(() => {\n    switch (type) {\n      case \"scene\":\n        const scene = data as GQL.SlimSceneDataFragment;\n        return objectTitle(scene);\n      case \"sceneMarker\":\n        const sceneMarker = data as GQL.SceneMarkerDataFragment;\n        const newTitle = markerTitle(sceneMarker);\n        const seconds = TextUtils.formatTimestampRange(\n          sceneMarker.seconds,\n          sceneMarker.end_seconds ?? undefined\n        );\n        if (newTitle) {\n          return `${newTitle} - ${seconds}`;\n        } else {\n          return seconds;\n        }\n      case \"image\":\n        return \"\";\n      default:\n        return type;\n    }\n  }, [type, data]);\n  const tags = useMemo(() => {\n    if (type === \"sceneMarker\") {\n      const sceneMarker = data as GQL.SceneMarkerDataFragment;\n      return [sceneMarker.primary_tag, ...sceneMarker.tags];\n    }\n  }, [type, data]);\n\n  const setInactive = () => setActive(false);\n  const toggleActive = useCallback((e: TransitionEvent) => {\n    if (e.propertyName === \"transform\" && e.elapsedTime === 0) {\n      // Get the current scale of the wall-item. If it's smaller than 1.1 the item is being scaled up, otherwise down.\n      const matrixScale = getComputedStyle(itemEl.current!).transform.match(\n        /-?\\d+\\.?\\d+|\\d+/g\n      )?.[0];\n      const scale = Number.parseFloat(matrixScale ?? \"2\") || 2;\n      setActive((value) => scale <= 1.1 && !value);\n    }\n  }, []);\n\n  useEffect(() => {\n    const item = itemEl.current!;\n    item.addEventListener(\"transitioncancel\", setInactive);\n    item.addEventListener(\"transitionstart\", toggleActive);\n    return () => {\n      item.removeEventListener(\"transitioncancel\", setInactive);\n      item.removeEventListener(\"transitionstart\", toggleActive);\n    };\n  }, [toggleActive]);\n\n  const onClick = (e: MouseEvent) => {\n    clickHandler?.(e, data);\n  };\n\n  const renderText = () => {\n    if (!showTextContainer) return;\n\n    return (\n      <div className=\"wall-item-text\">\n        <div>{title}</div>\n        {tags?.map((tag) => (\n          <span key={tag.id} className=\"wall-tag\">\n            {tag.name}\n          </span>\n        ))}\n      </div>\n    );\n  };\n\n  return (\n    <div className=\"wall-item\">\n      <div className={`wall-item-container ${className}`} ref={itemEl}>\n        <Link onClick={onClick} to={linkSrc} className=\"wall-item-anchor\">\n          <Preview previews={previews} config={config} active={active} />\n          {renderText()}\n        </Link>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Wall/WallPanel.tsx",
    "content": "import React, { MouseEvent } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { SceneQueue } from \"src/models/sceneQueue\";\nimport { WallItem, WallItemData, WallItemType } from \"./WallItem\";\n\ninterface IWallPanelProps<T extends WallItemType> {\n  type: T;\n  data: WallItemData[T][];\n  sceneQueue?: SceneQueue;\n  clickHandler?: (e: MouseEvent, item: WallItemData[T]) => void;\n}\n\nconst calculateClass = (index: number, count: number) => {\n  // First position and more than one row\n  if (index === 0 && count > 5) return \"transform-origin-top-left\";\n  // Fifth position and more than one row\n  if (index === 4 && count > 5) return \"transform-origin-top-right\";\n  // Top row\n  if (index < 5) return \"transform-origin-top\";\n  // Two or more rows, with full last row and index is last\n  if (count > 9 && count % 5 === 0 && index + 1 === count)\n    return \"transform-origin-bottom-right\";\n  // Two or more rows, with full last row and index is fifth to last\n  if (count > 9 && count % 5 === 0 && index + 5 === count)\n    return \"transform-origin-bottom-left\";\n  // Multiple of five minus one\n  if (index % 5 === 4) return \"transform-origin-right\";\n  // Multiple of five\n  if (index % 5 === 0) return \"transform-origin-left\";\n  // Position is equal or larger than first position in last row\n  if (count - (count % 5 || 5) <= index + 1) return \"transform-origin-bottom\";\n  // Default\n  return \"transform-origin-center\";\n};\n\nconst WallPanel = <T extends WallItemType>({\n  type,\n  data,\n  sceneQueue,\n  clickHandler,\n}: IWallPanelProps<T>) => {\n  function renderItems() {\n    return data.map((item, index, arr) => (\n      <WallItem\n        type={type}\n        key={item.id}\n        index={index}\n        data={item}\n        sceneQueue={sceneQueue}\n        clickHandler={clickHandler}\n        className={calculateClass(index, arr.length)}\n      />\n    ));\n  }\n\n  return (\n    <div className=\"row\">\n      <div className=\"wall w-100 row justify-content-center\">\n        {renderItems()}\n      </div>\n    </div>\n  );\n};\n\ninterface IMarkerWallPanelProps {\n  markers: GQL.SceneMarkerDataFragment[];\n  clickHandler?: (e: MouseEvent, item: GQL.SceneMarkerDataFragment) => void;\n}\n\nexport const MarkerWallPanel: React.FC<IMarkerWallPanelProps> = ({\n  markers,\n  clickHandler,\n}) => {\n  return (\n    <WallPanel type=\"sceneMarker\" data={markers} clickHandler={clickHandler} />\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/components/Wall/styles.scss",
    "content": ".wall {\n  margin: 0 auto;\n  max-width: 2250px;\n\n  .wall-item {\n    height: 11.25vw;\n    line-height: 0;\n    max-height: 253px;\n    max-width: 450px;\n    overflow: visible;\n    padding: 0;\n    transition: z-index 0.5s 0.5s;\n    width: 20%;\n    z-index: 0;\n\n    @media (max-width: 576px) {\n      height: inherit;\n      max-width: 100%;\n      min-height: 210px;\n      width: 100%;\n    }\n\n    &-anchor:hover {\n      text-decoration: none;\n    }\n\n    &-media {\n      background-color: black;\n      height: 100%;\n      object-fit: contain;\n      transition: z-index 0s 0s;\n      width: 100%;\n      z-index: 0;\n    }\n\n    &-missing {\n      align-items: center;\n      color: $text-color;\n      display: flex;\n      font-size: 1vw;\n      justify-content: center;\n      text-align: center;\n\n      @media (max-width: 576px) {\n        font-size: 6vw;\n      }\n    }\n\n    &-preview {\n      left: 0;\n      position: absolute;\n      top: 0;\n      transition: z-index 0s 0s;\n      z-index: -1;\n    }\n\n    &-text {\n      background: linear-gradient(\n        rgba(255, 255, 255, 0.25),\n        rgba(255, 255, 255, 0.65)\n      );\n      bottom: 0;\n      color: #444;\n      font-weight: 700;\n      left: 0;\n      line-height: 1;\n      overflow: hidden;\n      padding: 5px;\n      position: absolute;\n      text-align: center;\n      width: 100%;\n      z-index: 2000000;\n\n      .wall-tag {\n        font-size: 10px;\n        font-weight: 400;\n        line-height: 1;\n        margin: 0 3px;\n      }\n    }\n\n    &-container {\n      background-color: black;\n      display: flex;\n      height: 100%;\n      justify-content: center;\n      position: relative;\n      transition: all 0.5s 0s;\n      width: 100%;\n      z-index: 0;\n    }\n\n    &-container.transform-origin-top-left {\n      transform-origin: top left;\n    }\n\n    &-container.transform-origin-top-right {\n      transform-origin: top right;\n    }\n\n    &-container.transform-origin-bottom-left {\n      transform-origin: bottom left;\n    }\n\n    &-container.transform-origin-bottom-right {\n      transform-origin: bottom right;\n    }\n\n    &-container.transform-origin-left {\n      transform-origin: left;\n    }\n\n    &-container.transform-origin-right {\n      transform-origin: right;\n    }\n\n    &-container.transform-origin-top {\n      transform-origin: top;\n    }\n\n    &-container.transform-origin-bottom {\n      transform-origin: bottom;\n    }\n\n    &-container.transform-origin-center {\n      transform-origin: center;\n    }\n\n    &::before {\n      background-color: black;\n      bottom: 0;\n      content: \"\";\n      left: 0;\n      opacity: 0;\n      pointer-events: none;\n      position: fixed;\n      right: 0;\n      top: 0;\n      transition: opacity 0.5s 0s ease-in-out;\n      z-index: -1;\n    }\n\n    @media (min-width: 576px) {\n      &:hover {\n        z-index: 2;\n\n        .wall-item-media {\n          transition-delay: 0.5s;\n          transition-duration: 0.5s;\n          z-index: 10;\n        }\n\n        &::before {\n          opacity: 0.8;\n          transition-delay: 0.5s;\n        }\n\n        .wall-item-container {\n          background-color: black;\n          position: relative;\n          transform: scale(2);\n          transition-delay: 0.5s;\n          z-index: 10;\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/core/StashService.ts",
    "content": "import {\n  ApolloCache,\n  DocumentNode,\n  FetchResult,\n  NetworkStatus,\n  useQuery,\n} from \"@apollo/client\";\nimport { Modifiers } from \"@apollo/client/cache\";\nimport {\n  isField,\n  getQueryDefinition,\n  StoreObject,\n} from \"@apollo/client/utilities\";\nimport { ListFilterModel } from \"../models/list-filter/filter\";\nimport * as GQL from \"./generated-graphql\";\n\nimport { createClient } from \"./createClient\";\nimport { Client } from \"graphql-ws\";\nimport { useEffect, useState } from \"react\";\n\nconst { client, wsClient, cache: clientCache } = createClient();\n\nexport const getClient = () => client;\nexport const getWSClient = () => wsClient;\n\nexport function useWSState(ws: Client) {\n  const [state, setState] = useState<\"connecting\" | \"connected\" | \"error\">(\n    \"connecting\"\n  );\n\n  useEffect(() => {\n    const disposeConnected = ws.on(\"connected\", () => {\n      setState(\"connected\");\n    });\n\n    const disposeError = ws.on(\"error\", () => {\n      setState(\"error\");\n    });\n\n    return () => {\n      disposeConnected();\n      disposeError();\n    };\n  }, [ws]);\n\n  return { state };\n}\n\n// Evicts cached results for the given queries.\n// Will also call a cache GC afterwards.\nexport function evictQueries(\n  cache: ApolloCache<unknown>,\n  queries: DocumentNode[]\n) {\n  const fields: Modifiers = {};\n  for (const query of queries) {\n    const { selections } = getQueryDefinition(query).selectionSet;\n    for (const field of selections) {\n      if (!isField(field)) continue;\n      const keyName = field.name.value;\n      fields[keyName] = (_value, { DELETE }) => DELETE;\n    }\n  }\n\n  cache.modify({ fields });\n\n  // evictQueries is usually called at the end of\n  // an update function - so call a GC here\n  cache.gc();\n}\n\n/**\n * Evicts fields from all objects of a given type.\n *\n * @param input   a map from typename -> list of field names to evict\n * @param ignore  optionally specify a cache id to ignore and not modify\n */\nfunction evictTypeFields(\n  cache: ApolloCache<Record<string, StoreObject>>,\n  input: Record<string, string[]>,\n  ignore?: string\n) {\n  const data = cache.extract();\n  for (const key in data) {\n    if (ignore?.includes(key)) continue;\n\n    const obj = data[key];\n    const typename = obj.__typename;\n\n    if (typename && input[typename]) {\n      const modifiers: Modifiers = {};\n      for (const field of input[typename]) {\n        modifiers[field] = (_value, { DELETE }) => DELETE;\n      }\n      cache.modify({\n        id: key,\n        fields: modifiers,\n      });\n    }\n  }\n}\n\n// Deletes obj from the cache, and sets the\n// cached result of the given query to null.\n// Use with \"Destroy\" mutations.\nfunction deleteObject(\n  cache: ApolloCache<unknown>,\n  obj: StoreObject,\n  query: DocumentNode\n) {\n  const field = getQueryDefinition(query).selectionSet.selections[0];\n  if (!isField(field)) return;\n  const keyName = field.name.value;\n\n  cache.writeQuery({\n    query,\n    variables: { id: obj.id },\n    data: { [keyName]: null },\n  });\n  cache.evict({ id: cache.identify(obj) });\n}\n\nexport function isLoading(networkStatus: NetworkStatus) {\n  // useQuery hook loading field only returns true when initially loading the query\n  // and not during subsequent fetches\n  return (\n    networkStatus === NetworkStatus.loading ||\n    networkStatus === NetworkStatus.fetchMore ||\n    networkStatus === NetworkStatus.refetch\n  );\n}\n\n/// Object queries\n\nexport const useFindScene = (id: string) => {\n  const skip = id === \"new\" || id === \"\";\n  return GQL.useFindSceneQuery({ variables: { id }, skip });\n};\n\nexport const useSceneStreams = (id: string) =>\n  GQL.useSceneStreamsQuery({ variables: { id } });\n\nexport const useFindScenes = (filter?: ListFilterModel) =>\n  GQL.useFindScenesQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      scene_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const queryFindScenes = (filter: ListFilterModel) =>\n  client.query<GQL.FindScenesQuery>({\n    query: GQL.FindScenesDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      scene_filter: filter.makeFilter(),\n    },\n  });\n\nexport const queryFindScenesByID = (sceneIDs: number[]) =>\n  client.query<GQL.FindScenesQuery>({\n    query: GQL.FindScenesDocument,\n    variables: {\n      scene_ids: sceneIDs,\n    },\n  });\n\nexport const queryFindFullScenesByID = (sceneIDs: number[]) =>\n  client.query<GQL.FindFullScenesQuery>({\n    query: GQL.FindFullScenesDocument,\n    variables: {\n      ids: sceneIDs,\n    },\n  });\n\nexport const queryFindScenesForSelect = (filter: ListFilterModel) =>\n  client.query<GQL.FindScenesForSelectQuery>({\n    query: GQL.FindScenesForSelectDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      scene_filter: filter.makeFilter(),\n    },\n  });\n\nexport const queryFindScenesByIDForSelect = (sceneIDs: string[]) =>\n  client.query<GQL.FindScenesForSelectQuery>({\n    query: GQL.FindScenesForSelectDocument,\n    variables: {\n      ids: sceneIDs,\n    },\n  });\n\nexport const querySceneByPathRegex = (filter: GQL.FindFilterType) =>\n  client.query<GQL.FindScenesByPathRegexQuery>({\n    query: GQL.FindScenesByPathRegexDocument,\n    variables: { filter },\n  });\n\nexport const useFindImage = (id: string) =>\n  GQL.useFindImageQuery({ variables: { id } });\n\nexport const useFindImages = (filter?: ListFilterModel) =>\n  GQL.useFindImagesQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      image_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const useFindImagesMetadata = (filter?: ListFilterModel) =>\n  GQL.useFindImagesMetadataQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      image_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const queryFindImages = (filter: ListFilterModel) =>\n  client.query<GQL.FindImagesQuery>({\n    query: GQL.FindImagesDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      image_filter: filter.makeFilter(),\n    },\n  });\n\nexport const useFindGroup = (id: string) => {\n  const skip = id === \"new\" || id === \"\";\n  return GQL.useFindGroupQuery({ variables: { id }, skip });\n};\n\nexport const useFindGroups = (filter?: ListFilterModel) =>\n  GQL.useFindGroupsQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      group_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const queryFindGroups = (filter: ListFilterModel) =>\n  client.query<GQL.FindGroupsQuery>({\n    query: GQL.FindGroupsDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      group_filter: filter.makeFilter(),\n    },\n  });\n\nexport const queryFindGroupsByIDForSelect = (groupIDs: string[]) =>\n  client.query<GQL.FindGroupsForSelectQuery>({\n    query: GQL.FindGroupsForSelectDocument,\n    variables: {\n      ids: groupIDs,\n    },\n  });\n\nexport const queryFindGroupsForSelect = (filter: ListFilterModel) =>\n  client.query<GQL.FindGroupsForSelectQuery>({\n    query: GQL.FindGroupsForSelectDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      group_filter: filter.makeFilter(),\n    },\n  });\n\nexport const useFindSceneMarkers = (filter?: ListFilterModel) =>\n  GQL.useFindSceneMarkersQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      scene_marker_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const queryFindSceneMarkers = (filter: ListFilterModel) =>\n  client.query<GQL.FindSceneMarkersQuery>({\n    query: GQL.FindSceneMarkersDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      scene_marker_filter: filter.makeFilter(),\n    },\n  });\n\nexport const useMarkerStrings = () => GQL.useMarkerStringsQuery();\n\nexport const useFindGallery = (id: string) => {\n  const skip = id === \"new\" || id === \"\";\n  return GQL.useFindGalleryQuery({ variables: { id }, skip });\n};\n\nexport const useFindGalleryImageID = (id: string, index: number) => {\n  return GQL.useFindGalleryImageIdQuery({ variables: { id, index } });\n};\n\nexport const useFindGalleries = (filter?: ListFilterModel) =>\n  GQL.useFindGalleriesQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      gallery_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const queryFindGalleries = (filter: ListFilterModel) =>\n  client.query<GQL.FindGalleriesQuery>({\n    query: GQL.FindGalleriesDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      gallery_filter: filter.makeFilter(),\n    },\n  });\n\nexport const queryFindGalleriesForSelect = (filter: ListFilterModel) =>\n  client.query<GQL.FindGalleriesForSelectQuery>({\n    query: GQL.FindGalleriesForSelectDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      gallery_filter: filter.makeFilter(),\n    },\n  });\n\nexport const queryFindGalleriesByIDForSelect = (galleryIDs: string[]) =>\n  client.query<GQL.FindGalleriesForSelectQuery>({\n    query: GQL.FindGalleriesForSelectDocument,\n    variables: {\n      ids: galleryIDs,\n    },\n  });\n\nexport const useFindPerformer = (id: string) => {\n  const skip = id === \"new\" || id === \"\";\n  return GQL.useFindPerformerQuery({ variables: { id }, skip });\n};\n\nexport const queryFindPerformer = (id: string) =>\n  client.query<GQL.FindPerformerQuery>({\n    query: GQL.FindPerformerDocument,\n    variables: { id },\n  });\n\nexport const useFindPerformers = (filter?: ListFilterModel) =>\n  GQL.useFindPerformersQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      performer_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const queryFindPerformers = (filter: ListFilterModel) =>\n  client.query<GQL.FindPerformersQuery>({\n    query: GQL.FindPerformersDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      performer_filter: filter.makeFilter(),\n    },\n  });\n\nexport const queryFindPerformersByID = (performerIDs: number[]) =>\n  client.query<GQL.FindPerformersQuery>({\n    query: GQL.FindPerformersDocument,\n    variables: {\n      performer_ids: performerIDs,\n    },\n  });\n\nexport const queryFindPerformersByIDForSelect = (performerIDs: string[]) =>\n  client.query<GQL.FindPerformersForSelectQuery>({\n    query: GQL.FindPerformersForSelectDocument,\n    variables: {\n      ids: performerIDs,\n    },\n  });\n\nexport const queryFindPerformersForSelect = (filter: ListFilterModel) =>\n  client.query<GQL.FindPerformersForSelectQuery>({\n    query: GQL.FindPerformersForSelectDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      performer_filter: filter.makeFilter(),\n    },\n  });\n\nexport const useFindStudio = (id: string) => {\n  const skip = id === \"new\" || id === \"\";\n  return GQL.useFindStudioQuery({ variables: { id }, skip });\n};\n\nexport const queryFindStudio = (id: string) =>\n  client.query<GQL.FindStudioQuery>({\n    query: GQL.FindStudioDocument,\n    variables: { id },\n  });\n\nexport const useFindStudios = (filter?: ListFilterModel) =>\n  GQL.useFindStudiosQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      studio_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const queryFindStudios = (filter: ListFilterModel) =>\n  client.query<GQL.FindStudiosQuery>({\n    query: GQL.FindStudiosDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      studio_filter: filter.makeFilter(),\n    },\n  });\n\nexport const queryFindStudiosByIDForSelect = (studioIDs: string[]) =>\n  client.query<GQL.FindStudiosForSelectQuery>({\n    query: GQL.FindStudiosForSelectDocument,\n    variables: {\n      ids: studioIDs,\n    },\n  });\n\nexport const queryFindStudiosForSelect = (filter: ListFilterModel) =>\n  client.query<GQL.FindStudiosForSelectQuery>({\n    query: GQL.FindStudiosForSelectDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      studio_filter: filter.makeFilter(),\n    },\n  });\n\nexport const useFindTag = (id: string) => {\n  const skip = id === \"new\" || id === \"\";\n  return GQL.useFindTagQuery({ variables: { id }, skip });\n};\n\nexport const queryFindTag = (id: string) =>\n  client.query<GQL.FindTagQuery>({\n    query: GQL.FindTagDocument,\n    variables: { id },\n  });\n\nexport const useFindTags = (filter?: ListFilterModel) =>\n  GQL.useFindTagsQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      tag_filter: filter?.makeFilter(),\n    },\n  });\n\n// Optimized query for tag list page - excludes expensive recursive *_count_all fields\nexport const useFindTagsForList = (filter?: ListFilterModel) =>\n  GQL.useFindTagsForListQuery({\n    skip: filter === undefined,\n    variables: {\n      filter: filter?.makeFindFilter(),\n      tag_filter: filter?.makeFilter(),\n    },\n  });\n\nexport const queryFindTags = (filter: ListFilterModel) =>\n  client.query<GQL.FindTagsQuery>({\n    query: GQL.FindTagsDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      tag_filter: filter.makeFilter(),\n    },\n  });\n\n// Optimized query for tag list page\nexport const queryFindTagsForList = (filter: ListFilterModel) =>\n  client.query<GQL.FindTagsForListQuery>({\n    query: GQL.FindTagsForListDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      tag_filter: filter.makeFilter(),\n    },\n  });\n\nexport const queryFindTagsByID = (tagIDs: string[]) =>\n  client.query<GQL.FindTagsQuery>({\n    query: GQL.FindTagsDocument,\n    variables: {\n      ids: tagIDs,\n    },\n  });\n\nexport const queryFindTagsByIDForSelect = (tagIDs: string[]) =>\n  client.query<GQL.FindTagsForSelectQuery>({\n    query: GQL.FindTagsForSelectDocument,\n    variables: {\n      ids: tagIDs,\n    },\n  });\n\nexport const queryFindTagsForSelect = (filter: ListFilterModel) =>\n  client.query<GQL.FindTagsForSelectQuery>({\n    query: GQL.FindTagsForSelectDocument,\n    variables: {\n      filter: filter.makeFindFilter(),\n      tag_filter: filter.makeFilter(),\n    },\n  });\n\nexport const useFindSavedFilter = (id: string) =>\n  GQL.useFindSavedFilterQuery({\n    variables: { id },\n  });\n\nexport const useFindSavedFilters = (mode?: GQL.FilterMode) =>\n  GQL.useFindSavedFiltersQuery({\n    variables: { mode },\n  });\n\nexport const queryFindSubFolders = (id: string) =>\n  client.query<GQL.FindFoldersForQueryQuery>({\n    query: GQL.FindFoldersForQueryDocument,\n    variables: {\n      folder_filter: {\n        parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals },\n      },\n      filter: {\n        per_page: -1,\n        sort: \"basename\",\n        direction: GQL.SortDirectionEnum.Asc,\n      },\n    },\n  });\n\n/// Object Mutations\n\n// Increases/decreases the given field of the Stats query by diff\nfunction updateStats(cache: ApolloCache<unknown>, field: string, diff: number) {\n  cache.modify({\n    fields: {\n      stats(value) {\n        return {\n          ...value,\n          [field]: value[field] + diff,\n        };\n      },\n    },\n  });\n}\n\nfunction updateO(\n  cache: ApolloCache<unknown>,\n  typename: string,\n  id: string,\n  updatedOCount: number\n) {\n  cache.modify({\n    id: cache.identify({ __typename: typename, id }),\n    fields: {\n      o_counter() {\n        return updatedOCount;\n      },\n    },\n  });\n}\n\nconst sceneMutationImpactedTypeFields = {\n  Group: [\"scenes\", \"scene_count\"],\n  Gallery: [\"scenes\"],\n  Performer: [\n    \"scenes\",\n    \"scene_count\",\n    \"groups\",\n    \"group_count\",\n    \"performer_count\",\n  ],\n  Studio: [\"scene_count\", \"performer_count\"],\n  Tag: [\"scene_count\"],\n};\n\nconst sceneMutationImpactedQueries = [\n  GQL.FindScenesDocument, // various filters\n  GQL.FindGroupsDocument, // is missing scenes\n  GQL.FindGalleriesDocument, // is missing scenes\n  GQL.FindPerformersDocument, // filter by scene count\n  GQL.FindStudiosDocument, // filter by scene count\n  GQL.FindTagsDocument, // filter by scene count\n];\n\nexport const mutateCreateScene = (input: GQL.SceneCreateInput) =>\n  client.mutate<GQL.SceneCreateMutation>({\n    mutation: GQL.SceneCreateDocument,\n    variables: { input },\n    update(cache, result) {\n      const scene = result.data?.sceneCreate;\n      if (!scene) return;\n\n      // update stats\n      updateStats(cache, \"scene_count\", 1);\n\n      // if we're reassigning files, refetch files from other scenes\n      if (input.file_ids?.length) {\n        const obj = { __typename: \"Scene\", id: scene.id };\n        evictTypeFields(\n          cache,\n          {\n            ...sceneMutationImpactedTypeFields,\n            Scene: [\"files\"],\n          },\n          cache.identify(obj) // don't evict this scene\n        );\n      } else {\n        evictTypeFields(cache, sceneMutationImpactedTypeFields);\n      }\n\n      evictQueries(cache, sceneMutationImpactedQueries);\n    },\n  });\n\nexport const useSceneUpdate = () =>\n  GQL.useSceneUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.sceneUpdate) return;\n\n      evictTypeFields(cache, sceneMutationImpactedTypeFields);\n      evictQueries(cache, sceneMutationImpactedQueries);\n    },\n  });\n\nexport const useBulkSceneUpdate = () =>\n  GQL.useBulkSceneUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.bulkSceneUpdate) return;\n\n      evictTypeFields(cache, sceneMutationImpactedTypeFields);\n      evictQueries(cache, sceneMutationImpactedQueries);\n    },\n  });\n\nexport const useScenesUpdate = (input: GQL.SceneUpdateInput[]) =>\n  GQL.useScenesUpdateMutation({\n    variables: { input },\n    update(cache, result) {\n      if (!result.data?.scenesUpdate) return;\n\n      evictTypeFields(cache, sceneMutationImpactedTypeFields);\n      evictQueries(cache, sceneMutationImpactedQueries);\n    },\n  });\n\nexport const useSceneDestroy = (input: GQL.SceneDestroyInput) =>\n  GQL.useSceneDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.sceneDestroy) return;\n\n      const obj = { __typename: \"Scene\", id: input.id };\n      deleteObject(cache, obj, GQL.FindSceneDocument);\n\n      evictTypeFields(cache, sceneMutationImpactedTypeFields);\n      evictQueries(cache, [\n        ...sceneMutationImpactedQueries,\n        GQL.FindSceneMarkersDocument, // filter by scene tags\n        GQL.StatsDocument, // scenes size, scene count, etc\n      ]);\n    },\n  });\n\nexport const useScenesDestroy = (input: GQL.ScenesDestroyInput) =>\n  GQL.useScenesDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.scenesDestroy) return;\n\n      for (const id of input.ids) {\n        const obj = { __typename: \"Scene\", id };\n        deleteObject(cache, obj, GQL.FindSceneDocument);\n      }\n\n      evictTypeFields(cache, sceneMutationImpactedTypeFields);\n      evictQueries(cache, [\n        ...sceneMutationImpactedQueries,\n        GQL.FindSceneMarkersDocument, // filter by scene tags\n        GQL.StatsDocument, // scenes size, scene count, etc\n      ]);\n    },\n  });\n\nexport const useSceneIncrementO = (id: string) =>\n  GQL.useSceneAddOMutation({\n    variables: { id },\n    update(cache, result, { variables }) {\n      // this is not perfectly accurate, the time is set server-side\n      // it isn't even displayed anywhere in the UI anyway\n      const at = new Date().toISOString();\n\n      const mutationResult = result.data?.sceneAddO;\n      if (!mutationResult || !variables) return;\n\n      const { history } = mutationResult;\n      const { times } = variables;\n      const timeArray = !times ? [at] : Array.isArray(times) ? times : [times];\n\n      const scene = cache.readFragment<GQL.SlimSceneDataFragment>({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fragment: GQL.SlimSceneDataFragmentDoc,\n        fragmentName: \"SlimSceneData\",\n      });\n\n      if (scene) {\n        // if we have the scene, update performer o_counters manually\n        for (const performer of scene.performers) {\n          cache.modify({\n            id: cache.identify(performer),\n            fields: {\n              o_counter(value) {\n                return value + timeArray.length;\n              },\n            },\n          });\n        }\n      } else {\n        // else refresh all performer o_counters\n        evictTypeFields(cache, {\n          Performer: [\"o_counter\"],\n        });\n      }\n\n      updateStats(cache, \"total_o_count\", timeArray.length);\n\n      cache.modify({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fields: {\n          o_history() {\n            return history;\n          },\n        },\n      });\n\n      updateO(cache, \"Scene\", id, history.length);\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by o_counter\n        GQL.FindPerformersDocument, // filter by o_counter\n      ]);\n    },\n  });\n\nexport const useSceneDecrementO = (id: string) =>\n  GQL.useSceneDeleteOMutation({\n    variables: { id },\n    update(cache, result, { variables }) {\n      const mutationResult = result.data?.sceneDeleteO;\n      if (!mutationResult || !variables) return;\n\n      const { history } = mutationResult;\n      const { times } = variables;\n      const timeArray = !times ? null : Array.isArray(times) ? times : [times];\n\n      const scene = cache.readFragment<GQL.SlimSceneDataFragment>({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fragment: GQL.SlimSceneDataFragmentDoc,\n        fragmentName: \"SlimSceneData\",\n      });\n\n      if (scene) {\n        // if we have the scene, update performer o_counters manually\n        for (const performer of scene.performers) {\n          cache.modify({\n            id: cache.identify(performer),\n            fields: {\n              o_counter(value) {\n                return value - (timeArray?.length ?? 1);\n              },\n            },\n          });\n        }\n      } else {\n        // else refresh all performer o_counters\n        evictTypeFields(cache, {\n          Performer: [\"o_counter\"],\n        });\n      }\n\n      updateStats(cache, \"total_o_count\", -(timeArray?.length ?? 1));\n\n      cache.modify({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fields: {\n          o_history() {\n            return history;\n          },\n        },\n      });\n\n      updateO(cache, \"Scene\", id, history.length);\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by o_counter\n        GQL.FindPerformersDocument, // filter by o_counter\n      ]);\n    },\n  });\n\nexport const useSceneResetO = (id: string) =>\n  GQL.useSceneResetOMutation({\n    variables: { id },\n    update(cache, result) {\n      const updatedOCount = result.data?.sceneResetO;\n      if (updatedOCount === undefined) return;\n\n      const scene = cache.readFragment<GQL.SlimSceneDataFragment>({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fragment: GQL.SlimSceneDataFragmentDoc,\n        fragmentName: \"SlimSceneData\",\n      });\n\n      if (scene) {\n        // if we have the scene, update performer o_counters manually\n        const old_count = scene.o_counter ?? 0;\n        for (const performer of scene.performers) {\n          cache.modify({\n            id: cache.identify(performer),\n            fields: {\n              o_counter(value) {\n                return value - old_count;\n              },\n            },\n          });\n        }\n        updateStats(cache, \"total_o_count\", -old_count);\n      } else {\n        // else refresh all performer o_counters\n        evictTypeFields(cache, {\n          Performer: [\"o_counter\"],\n        });\n        // also refresh stats total_o_count\n        cache.modify({\n          fields: {\n            stats: (value) => ({\n              ...value,\n              total_o_count: undefined,\n            }),\n          },\n        });\n      }\n\n      cache.modify({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fields: {\n          o_history() {\n            const ret: string[] = [];\n            return ret;\n          },\n        },\n      });\n\n      updateO(cache, \"Scene\", id, updatedOCount);\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by o_counter\n        GQL.FindPerformersDocument, // filter by o_counter\n      ]);\n    },\n  });\n\nexport const useSceneResetActivity = (\n  id: string,\n  reset_resume: boolean,\n  reset_duration: boolean\n) =>\n  GQL.useSceneResetActivityMutation({\n    variables: { id, reset_resume, reset_duration },\n    update(cache, result) {\n      if (!result.data?.sceneResetActivity) return;\n\n      evictTypeFields(cache, sceneMutationImpactedTypeFields);\n      evictQueries(cache, sceneMutationImpactedQueries);\n    },\n  });\n\nexport const useSceneGenerateScreenshot = () =>\n  GQL.useSceneGenerateScreenshotMutation();\n\nexport const mutateSceneSetPrimaryFile = (id: string, fileID: string) =>\n  client.mutate<GQL.SceneUpdateMutation>({\n    mutation: GQL.SceneUpdateDocument,\n    variables: {\n      input: {\n        id,\n        primary_file_id: fileID,\n      },\n    },\n    update(cache, result) {\n      if (!result.data?.sceneUpdate) return;\n\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // sort by primary basename when missing title\n      ]);\n    },\n  });\n\nexport const mutateSceneAssignFile = (sceneID: string, fileID: string) =>\n  client.mutate<GQL.SceneAssignFileMutation>({\n    mutation: GQL.SceneAssignFileDocument,\n    variables: {\n      input: {\n        scene_id: sceneID,\n        file_id: fileID,\n      },\n    },\n    update(cache, result) {\n      if (!result.data?.sceneAssignFile) return;\n\n      // refetch target scene\n      cache.evict({\n        id: cache.identify({ __typename: \"Scene\", id: sceneID }),\n      });\n\n      // refetch files of the scene the file was previously assigned to\n      evictTypeFields(cache, { Scene: [\"files\"] });\n\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by file count\n      ]);\n    },\n  });\n\nexport const mutateSceneMerge = (\n  destination: string,\n  source: string[],\n  values: GQL.SceneUpdateInput,\n  includeViewHistory: boolean,\n  includeOHistory: boolean\n) =>\n  client.mutate<GQL.SceneMergeMutation>({\n    mutation: GQL.SceneMergeDocument,\n    variables: {\n      input: {\n        source,\n        destination,\n        values,\n        play_history: includeViewHistory,\n        o_history: includeOHistory,\n      },\n    },\n    update(cache, result) {\n      if (!result.data?.sceneMerge) return;\n\n      for (const id of source) {\n        const obj = { __typename: \"Scene\", id };\n        deleteObject(cache, obj, GQL.FindSceneDocument);\n      }\n\n      cache.evict({\n        id: cache.identify({ __typename: \"Scene\", id: destination }),\n      });\n\n      evictTypeFields(cache, sceneMutationImpactedTypeFields);\n      evictQueries(cache, [\n        ...sceneMutationImpactedQueries,\n        GQL.StatsDocument, // scenes size, scene count, etc\n      ]);\n    },\n  });\n\nexport const useSceneSaveActivity = () =>\n  GQL.useSceneSaveActivityMutation({\n    update(cache, result, { variables }) {\n      if (!result.data?.sceneSaveActivity || !variables) return;\n\n      const { id, playDuration, resume_time: resumeTime } = variables;\n\n      cache.modify({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fields: {\n          resume_time() {\n            return resumeTime ?? null;\n          },\n          play_duration(value) {\n            return value + playDuration;\n          },\n        },\n      });\n\n      if (playDuration) {\n        updateStats(cache, \"total_play_duration\", playDuration);\n      }\n\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by play duration\n      ]);\n    },\n  });\n\nexport const useSceneIncrementPlayCount = () =>\n  GQL.useSceneAddPlayMutation({\n    update(cache, result, { variables }) {\n      const mutationResult = result.data?.sceneAddPlay;\n\n      if (!mutationResult || !variables) return;\n\n      const { history } = mutationResult;\n      const { id } = variables;\n\n      let lastPlayCount = 0;\n      const playCount = history.length;\n\n      cache.modify({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fields: {\n          play_count(value) {\n            lastPlayCount = value;\n            return history.length;\n          },\n          last_played_at() {\n            // assume only one entry - or the first is the most recent\n            return history[0];\n          },\n          play_history() {\n            return history;\n          },\n        },\n      });\n\n      updateStats(cache, \"total_play_count\", playCount - lastPlayCount);\n      if (lastPlayCount === 0) {\n        updateStats(cache, \"scenes_played\", 1);\n      }\n\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by play count\n      ]);\n    },\n  });\n\nexport const useSceneDecrementPlayCount = () =>\n  GQL.useSceneDeletePlayMutation({\n    update(cache, result, { variables }) {\n      const mutationResult = result.data?.sceneDeletePlay;\n\n      if (!mutationResult || !variables) return;\n\n      const { history } = mutationResult;\n      const { id, times } = variables;\n      const timeArray = !times ? null : Array.isArray(times) ? times : [times];\n      const nRemoved = timeArray?.length ?? 1;\n\n      let lastPlayCount = 0;\n      let lastPlayedAt: string | null = null;\n      const playCount = history.length;\n\n      cache.modify({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fields: {\n          play_count(value) {\n            lastPlayCount = value;\n            return playCount;\n          },\n          play_history() {\n            if (history.length > 0) {\n              lastPlayedAt = history[0];\n            }\n            return history;\n          },\n        },\n      });\n\n      cache.modify({\n        id: cache.identify({ __typename: \"Scene\", id }),\n        fields: {\n          last_played_at() {\n            return lastPlayedAt;\n          },\n        },\n      });\n\n      if (lastPlayCount > 0) {\n        updateStats(\n          cache,\n          \"total_play_count\",\n          nRemoved > lastPlayCount ? -lastPlayCount : -nRemoved\n        );\n      }\n      if (lastPlayCount - nRemoved <= 0) {\n        updateStats(cache, \"scenes_played\", -1);\n      }\n\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by play count\n      ]);\n    },\n  });\n\nexport const useSceneResetPlayCount = () =>\n  GQL.useSceneResetPlayCountMutation({\n    update(cache, result, { variables }) {\n      if (!variables) return;\n\n      let lastPlayCount = 0;\n      cache.modify({\n        id: cache.identify({ __typename: \"Scene\", id: variables.id }),\n        fields: {\n          play_count(value) {\n            lastPlayCount = value;\n            return 0;\n          },\n          play_history() {\n            const ret: string[] = [];\n            return ret;\n          },\n          last_played_at() {\n            return null;\n          },\n        },\n      });\n\n      if (lastPlayCount > 0) {\n        updateStats(cache, \"total_play_count\", -lastPlayCount);\n      }\n      if (lastPlayCount > 0) {\n        updateStats(cache, \"scenes_played\", -1);\n      }\n\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by play count\n      ]);\n    },\n  });\n\nconst imageMutationImpactedTypeFields = {\n  Gallery: [\"images\", \"image_count\"],\n  Performer: [\"image_count\", \"performer_count\"],\n  Studio: [\"image_count\", \"performer_count\"],\n  Tag: [\"image_count\"],\n};\n\nconst imageMutationImpactedQueries = [\n  GQL.FindImagesDocument, // various filters\n  GQL.FindGalleriesDocument, // filter by image count\n  GQL.FindPerformersDocument, // filter by image count\n  GQL.FindStudiosDocument, // filter by image count\n  GQL.FindTagsDocument, // filter by image count\n];\n\nexport const useImageUpdate = () =>\n  GQL.useImageUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.imageUpdate) return;\n\n      evictTypeFields(cache, imageMutationImpactedTypeFields);\n      evictQueries(cache, imageMutationImpactedQueries);\n    },\n  });\n\nexport const useBulkImageUpdate = () =>\n  GQL.useBulkImageUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.bulkImageUpdate) return;\n\n      evictTypeFields(cache, imageMutationImpactedTypeFields);\n      evictQueries(cache, imageMutationImpactedQueries);\n    },\n  });\n\nexport const useImagesDestroy = (input: GQL.ImagesDestroyInput) =>\n  GQL.useImagesDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.imagesDestroy) return;\n\n      for (const id of input.ids) {\n        const obj = { __typename: \"Image\", id };\n        deleteObject(cache, obj, GQL.FindImageDocument);\n      }\n\n      evictTypeFields(cache, imageMutationImpactedTypeFields);\n      evictQueries(cache, [\n        ...imageMutationImpactedQueries,\n        GQL.StatsDocument, // images size, images count\n      ]);\n    },\n  });\n\nfunction updateImageIncrementO(id: string) {\n  return (\n    cache: ApolloCache<Record<string, StoreObject>>,\n    result: FetchResult<GQL.ImageIncrementOMutation>\n  ) => {\n    const updatedOCount = result.data?.imageIncrementO;\n    if (updatedOCount === undefined) return;\n\n    const image = cache.readFragment<GQL.SlimImageDataFragment>({\n      id: cache.identify({ __typename: \"Image\", id }),\n      fragment: GQL.SlimImageDataFragmentDoc,\n      fragmentName: \"SlimImageData\",\n    });\n\n    if (image) {\n      // if we have the image, update performer o_counters manually\n      for (const performer of image.performers) {\n        cache.modify({\n          id: cache.identify(performer),\n          fields: {\n            o_counter(value) {\n              return value + 1;\n            },\n          },\n        });\n      }\n    } else {\n      // else refresh all performer o_counters\n      evictTypeFields(cache, {\n        Performer: [\"o_counter\"],\n      });\n    }\n\n    updateStats(cache, \"total_o_count\", 1);\n    updateO(cache, \"Image\", id, updatedOCount);\n    evictQueries(cache, [\n      GQL.FindImagesDocument, // filter by o_counter\n      GQL.FindPerformersDocument, // filter by o_counter\n    ]);\n  };\n}\nexport const useImageIncrementO = (id: string) =>\n  GQL.useImageIncrementOMutation({\n    variables: { id },\n    update: updateImageIncrementO(id),\n  });\n\nexport const mutateImageIncrementO = (id: string) =>\n  client.mutate<GQL.ImageIncrementOMutation>({\n    mutation: GQL.ImageIncrementODocument,\n    variables: { id },\n    update: updateImageIncrementO(id),\n  });\n\nfunction updateImageDecrementO(id: string) {\n  return (\n    cache: ApolloCache<Record<string, StoreObject>>,\n    result: FetchResult<GQL.ImageDecrementOMutation>\n  ) => {\n    const updatedOCount = result.data?.imageDecrementO;\n    if (updatedOCount === undefined) return;\n\n    const image = cache.readFragment<GQL.SlimImageDataFragment>({\n      id: cache.identify({ __typename: \"Image\", id }),\n      fragment: GQL.SlimImageDataFragmentDoc,\n      fragmentName: \"SlimImageData\",\n    });\n\n    if (image) {\n      // if we have the image, update performer o_counters manually\n      for (const performer of image.performers) {\n        cache.modify({\n          id: cache.identify(performer),\n          fields: {\n            o_counter(value) {\n              return value - 1;\n            },\n          },\n        });\n      }\n    } else {\n      // else refresh all performer o_counters\n      evictTypeFields(cache, {\n        Performer: [\"o_counter\"],\n      });\n    }\n\n    updateStats(cache, \"total_o_count\", -1);\n    updateO(cache, \"Image\", id, updatedOCount);\n    evictQueries(cache, [\n      GQL.FindImagesDocument, // filter by o_counter\n      GQL.FindPerformersDocument, // filter by o_counter\n    ]);\n  };\n}\n\nexport const useImageDecrementO = (id: string) =>\n  GQL.useImageDecrementOMutation({\n    variables: { id },\n    update: updateImageDecrementO(id),\n  });\n\nexport const mutateImageDecrementO = (id: string) =>\n  client.mutate<GQL.ImageDecrementOMutation>({\n    mutation: GQL.ImageDecrementODocument,\n    variables: { id },\n    update: updateImageDecrementO(id),\n  });\n\nfunction updateImageResetO(id: string) {\n  return (\n    cache: ApolloCache<Record<string, StoreObject>>,\n    result: FetchResult<GQL.ImageResetOMutation>\n  ) => {\n    const updatedOCount = result.data?.imageResetO;\n    if (updatedOCount === undefined) return;\n\n    const image = cache.readFragment<GQL.SlimImageDataFragment>({\n      id: cache.identify({ __typename: \"Image\", id }),\n      fragment: GQL.SlimImageDataFragmentDoc,\n      fragmentName: \"SlimImageData\",\n    });\n\n    if (image) {\n      // if we have the image, update performer o_counters manually\n      const old_count = image.o_counter ?? 0;\n      for (const performer of image.performers) {\n        cache.modify({\n          id: cache.identify(performer),\n          fields: {\n            o_counter(value) {\n              return value - old_count;\n            },\n          },\n        });\n      }\n      updateStats(cache, \"total_o_count\", -old_count);\n    } else {\n      // else refresh all performer o_counters\n      evictTypeFields(cache, {\n        Performer: [\"o_counter\"],\n      });\n      // also refresh stats total_o_count\n      cache.modify({\n        fields: {\n          stats: (value) => ({\n            ...value,\n            total_o_count: undefined,\n          }),\n        },\n      });\n    }\n\n    updateO(cache, \"Image\", id, updatedOCount);\n    evictQueries(cache, [\n      GQL.FindImagesDocument, // filter by o_counter\n      GQL.FindPerformersDocument, // filter by o_counter\n    ]);\n  };\n}\n\nexport const useImageResetO = (id: string) =>\n  GQL.useImageResetOMutation({\n    variables: { id },\n    update: updateImageResetO(id),\n  });\n\nexport const mutateImageResetO = (id: string) =>\n  client.mutate<GQL.ImageResetOMutation>({\n    mutation: GQL.ImageResetODocument,\n    variables: { id },\n    update: updateImageResetO(id),\n  });\n\nexport const mutateImageSetPrimaryFile = (id: string, fileID: string) =>\n  client.mutate<GQL.ImageUpdateMutation>({\n    mutation: GQL.ImageUpdateDocument,\n    variables: {\n      input: {\n        id,\n        primary_file_id: fileID,\n      },\n    },\n    update(cache, result) {\n      if (!result.data?.imageUpdate) return;\n\n      evictQueries(cache, [\n        GQL.FindImagesDocument, // sort by primary basename when missing title\n      ]);\n    },\n  });\n\nconst groupMutationImpactedTypeFields = {\n  Performer: [\"group_count\"],\n  Studio: [\"group_count\"],\n};\n\nconst groupMutationImpactedQueries = [\n  GQL.FindGroupsDocument, // various filters\n];\n\nexport const useGroupCreate = () =>\n  GQL.useGroupCreateMutation({\n    update(cache, result) {\n      const group = result.data?.groupCreate;\n      if (!group) return;\n\n      // update stats\n      updateStats(cache, \"group_count\", 1);\n\n      evictTypeFields(cache, groupMutationImpactedTypeFields);\n      evictQueries(cache, groupMutationImpactedQueries);\n    },\n  });\n\nexport const useGroupUpdate = () =>\n  GQL.useGroupUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.groupUpdate) return;\n\n      evictTypeFields(cache, groupMutationImpactedTypeFields);\n      evictQueries(cache, groupMutationImpactedQueries);\n    },\n  });\n\nexport const useBulkGroupUpdate = () =>\n  GQL.useBulkGroupUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.bulkGroupUpdate) return;\n\n      evictTypeFields(cache, groupMutationImpactedTypeFields);\n      evictQueries(cache, groupMutationImpactedQueries);\n    },\n  });\n\nexport const useGroupDestroy = (input: GQL.GroupDestroyInput) =>\n  GQL.useGroupDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.groupDestroy) return;\n\n      const obj = { __typename: \"Group\", id: input.id };\n      deleteObject(cache, obj, GQL.FindGroupDocument);\n\n      // update stats\n      updateStats(cache, \"group_count\", -1);\n\n      evictTypeFields(cache, {\n        Scene: [\"groups\"],\n        Performer: [\"group_count\"],\n        Studio: [\"group_count\"],\n      });\n      evictQueries(cache, [\n        ...groupMutationImpactedQueries,\n        GQL.FindScenesDocument, // filter by group\n      ]);\n    },\n  });\n\nexport const useGroupsDestroy = (input: GQL.GroupsDestroyMutationVariables) =>\n  GQL.useGroupsDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.groupsDestroy) return;\n\n      const { ids } = input;\n\n      for (const id of ids) {\n        const obj = { __typename: \"Group\", id };\n        deleteObject(cache, obj, GQL.FindGroupDocument);\n      }\n\n      // update stats\n      updateStats(cache, \"group_count\", -ids.length);\n\n      evictTypeFields(cache, {\n        Scene: [\"groups\"],\n        Performer: [\"group_count\"],\n        Studio: [\"group_count\"],\n      });\n      evictQueries(cache, [\n        ...groupMutationImpactedQueries,\n        GQL.FindScenesDocument, // filter by group\n      ]);\n    },\n  });\n\nexport function useReorderSubGroupsMutation() {\n  return GQL.useReorderSubGroupsMutation({\n    update(cache) {\n      evictQueries(cache, [\n        GQL.FindGroupsDocument, // various filters\n      ]);\n    },\n  });\n}\n\nexport const useAddSubGroups = () => {\n  const [addSubGroups] = GQL.useAddGroupSubGroupsMutation({\n    update(cache, result) {\n      if (!result.data?.addGroupSubGroups) return;\n\n      evictTypeFields(cache, groupMutationImpactedTypeFields);\n      evictQueries(cache, groupMutationImpactedQueries);\n    },\n  });\n\n  return (containingGroupId: string, toAdd: GQL.GroupDescriptionInput[]) => {\n    return addSubGroups({\n      variables: {\n        input: {\n          containing_group_id: containingGroupId,\n          sub_groups: toAdd,\n        },\n      },\n    });\n  };\n};\n\nexport const useRemoveSubGroups = () => {\n  const [removeSubGroups] = GQL.useRemoveGroupSubGroupsMutation({\n    update(cache, result) {\n      if (!result.data?.removeGroupSubGroups) return;\n\n      evictTypeFields(cache, groupMutationImpactedTypeFields);\n      evictQueries(cache, groupMutationImpactedQueries);\n    },\n  });\n\n  return (containingGroupId: string, removeIds: string[]) => {\n    return removeSubGroups({\n      variables: {\n        input: {\n          containing_group_id: containingGroupId,\n          sub_group_ids: removeIds,\n        },\n      },\n    });\n  };\n};\n\nconst sceneMarkerMutationImpactedTypeFields = {\n  Tag: [\"scene_marker_count\"],\n};\n\nconst sceneMarkerMutationImpactedQueries = [\n  GQL.FindScenesDocument, // has marker filter\n  GQL.FindSceneMarkersDocument, // various filters\n  GQL.MarkerStringsDocument, // marker list\n  GQL.FindSceneMarkerTagsDocument, // marker tag list\n  GQL.FindTagsDocument, // filter by marker count\n];\n\nexport const useSceneMarkerCreate = () =>\n  GQL.useSceneMarkerCreateMutation({\n    update(cache, result, { variables }) {\n      if (!result.data?.sceneMarkerCreate || !variables) return;\n\n      // refetch linked scene's marker list\n      cache.evict({\n        id: cache.identify({ __typename: \"Scene\", id: variables.scene_id }),\n        fieldName: \"scene_markers\",\n      });\n\n      evictTypeFields(cache, sceneMarkerMutationImpactedTypeFields);\n      evictQueries(cache, sceneMarkerMutationImpactedQueries);\n    },\n  });\n\nexport const useSceneMarkerUpdate = () =>\n  GQL.useSceneMarkerUpdateMutation({\n    update(cache, result, { variables }) {\n      if (!result.data?.sceneMarkerUpdate || !variables) return;\n\n      // refetch linked scene's marker list\n      cache.evict({\n        id: cache.identify({ __typename: \"Scene\", id: variables.scene_id }),\n        fieldName: \"scene_markers\",\n      });\n\n      evictTypeFields(cache, sceneMarkerMutationImpactedTypeFields);\n      evictQueries(cache, sceneMarkerMutationImpactedQueries);\n    },\n  });\n\nexport const useBulkSceneMarkerUpdate = () =>\n  GQL.useBulkSceneMarkerUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.bulkSceneMarkerUpdate) return;\n\n      evictTypeFields(cache, sceneMarkerMutationImpactedTypeFields);\n      evictQueries(cache, sceneMarkerMutationImpactedQueries);\n    },\n  });\n\nexport const useSceneMarkerDestroy = () =>\n  GQL.useSceneMarkerDestroyMutation({\n    update(cache, result, { variables }) {\n      if (!result.data?.sceneMarkerDestroy || !variables) return;\n\n      const obj = { __typename: \"SceneMarker\", id: variables.id };\n      cache.evict({ id: cache.identify(obj) });\n\n      evictTypeFields(cache, sceneMarkerMutationImpactedTypeFields);\n      evictQueries(cache, sceneMarkerMutationImpactedQueries);\n    },\n  });\n\nexport const useSceneMarkersDestroy = (\n  input: GQL.SceneMarkersDestroyMutationVariables\n) =>\n  GQL.useSceneMarkersDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.sceneMarkersDestroy) return;\n\n      for (const id of input.ids) {\n        const obj = { __typename: \"SceneMarker\", id };\n        cache.evict({ id: cache.identify(obj) });\n      }\n\n      evictTypeFields(cache, sceneMarkerMutationImpactedTypeFields);\n      evictQueries(cache, sceneMarkerMutationImpactedQueries);\n    },\n  });\n\nconst galleryMutationImpactedTypeFields = {\n  Scene: [\"galleries\"],\n  Performer: [\"gallery_count\", \"performer_count\"],\n  Studio: [\"gallery_count\", \"performer_count\"],\n  Tag: [\"gallery_count\"],\n};\n\nconst galleryMutationImpactedQueries = [\n  GQL.FindScenesDocument, // is missing galleries\n  GQL.FindGalleriesDocument, // various filters\n  GQL.FindPerformersDocument, // filter by gallery count\n  GQL.FindStudiosDocument, // filter by gallery count\n  GQL.FindTagsDocument, // filter by gallery count\n];\n\nexport const useGalleryCreate = () =>\n  GQL.useGalleryCreateMutation({\n    update(cache, result) {\n      if (!result.data?.galleryCreate) return;\n\n      // update stats\n      updateStats(cache, \"gallery_count\", 1);\n\n      evictTypeFields(cache, galleryMutationImpactedTypeFields);\n      evictQueries(cache, galleryMutationImpactedQueries);\n    },\n  });\n\nexport const useGalleryUpdate = () =>\n  GQL.useGalleryUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.galleryUpdate) return;\n\n      evictTypeFields(cache, galleryMutationImpactedTypeFields);\n      evictQueries(cache, galleryMutationImpactedQueries);\n    },\n  });\n\nexport const useBulkGalleryUpdate = () =>\n  GQL.useBulkGalleryUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.bulkGalleryUpdate) return;\n\n      evictTypeFields(cache, galleryMutationImpactedTypeFields);\n      evictQueries(cache, galleryMutationImpactedQueries);\n    },\n  });\n\nexport const useGalleryDestroy = (input: GQL.GalleryDestroyInput) =>\n  GQL.useGalleryDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.galleryDestroy) return;\n\n      for (const id of input.ids) {\n        const obj = { __typename: \"Gallery\", id };\n        deleteObject(cache, obj, GQL.FindGalleryDocument);\n      }\n\n      evictTypeFields(cache, galleryMutationImpactedTypeFields);\n      evictQueries(cache, [\n        ...galleryMutationImpactedQueries,\n        GQL.FindImagesDocument, // filter by gallery\n        GQL.StatsDocument, // images size, gallery count, etc\n      ]);\n    },\n  });\n\nexport const mutateAddGalleryImages = (input: GQL.GalleryAddInput) =>\n  client.mutate<GQL.AddGalleryImagesMutation>({\n    mutation: GQL.AddGalleryImagesDocument,\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.addGalleryImages) return;\n\n      // refetch gallery image_count\n      cache.evict({\n        id: cache.identify({ __typename: \"Gallery\", id: input.gallery_id }),\n        fieldName: \"image_count\",\n      });\n\n      // refetch images galleries field\n      for (const id of input.image_ids) {\n        cache.evict({\n          id: cache.identify({ __typename: \"Image\", id }),\n          fieldName: \"galleries\",\n        });\n      }\n\n      evictQueries(cache, [\n        GQL.FindGalleriesDocument, // filter by image count\n        GQL.FindImagesDocument, // filter by gallery\n      ]);\n    },\n  });\n\nfunction evictCover(cache: ApolloCache<GQL.Gallery>, gallery_id: string) {\n  const fields: Partial<Pick<Modifiers<GQL.Gallery>, \"paths\" | \"cover\">> = {};\n  fields.paths = (paths) => {\n    if (!(\"cover\" in paths)) {\n      return paths;\n    }\n    const coverUrl = new URL(paths.cover);\n    coverUrl.search = \"?t=\" + Math.floor(Date.now() / 1000);\n    return { ...paths, cover: coverUrl.toString() };\n  };\n  fields.cover = (_value, { DELETE }) => DELETE;\n  cache.modify({\n    id: cache.identify({ __typename: \"Gallery\", id: gallery_id }),\n    fields,\n  });\n}\n\nexport const mutateSetGalleryCover = (input: GQL.GallerySetCoverInput) =>\n  client.mutate<GQL.SetGalleryCoverMutation>({\n    mutation: GQL.SetGalleryCoverDocument,\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.setGalleryCover) return;\n      evictCover(cache, input.gallery_id);\n    },\n  });\n\nexport const mutateResetGalleryCover = (input: GQL.GalleryResetCoverInput) =>\n  client.mutate<GQL.ResetGalleryCoverMutation>({\n    mutation: GQL.ResetGalleryCoverDocument,\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.resetGalleryCover) return;\n      evictCover(cache, input.gallery_id);\n    },\n  });\n\nexport const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) =>\n  client.mutate<GQL.RemoveGalleryImagesMutation>({\n    mutation: GQL.RemoveGalleryImagesDocument,\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.removeGalleryImages) return;\n\n      // refetch gallery image_count\n      cache.evict({\n        id: cache.identify({ __typename: \"Gallery\", id: input.gallery_id }),\n        fieldName: \"image_count\",\n      });\n\n      // refetch images galleries field\n      for (const id of input.image_ids) {\n        cache.evict({\n          id: cache.identify({ __typename: \"Image\", id }),\n          fieldName: \"galleries\",\n        });\n      }\n\n      evictQueries(cache, [\n        GQL.FindGalleriesDocument, // filter by image count\n        GQL.FindImagesDocument, // filter by gallery\n      ]);\n    },\n  });\n\nexport const mutateGallerySetPrimaryFile = (id: string, fileID: string) =>\n  client.mutate<GQL.GalleryUpdateMutation>({\n    mutation: GQL.GalleryUpdateDocument,\n    variables: {\n      input: {\n        id,\n        primary_file_id: fileID,\n      },\n    },\n    update(cache, result) {\n      if (!result.data?.galleryUpdate) return;\n\n      evictQueries(cache, [\n        GQL.FindGalleriesDocument, // sort by primary basename when missing title\n      ]);\n    },\n  });\n\nconst galleryChapterMutationImpactedTypeFields = {\n  Gallery: [\"chapters\"],\n};\n\nconst galleryChapterMutationImpactedQueries = [\n  GQL.FindGalleriesDocument, // filter by has chapters\n];\n\nexport const useGalleryChapterCreate = () =>\n  GQL.useGalleryChapterCreateMutation({\n    update(cache, result) {\n      if (!result.data?.galleryChapterCreate) return;\n\n      evictTypeFields(cache, galleryChapterMutationImpactedTypeFields);\n      evictQueries(cache, galleryChapterMutationImpactedQueries);\n    },\n  });\n\nexport const useGalleryChapterUpdate = () =>\n  GQL.useGalleryChapterUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.galleryChapterUpdate) return;\n\n      evictTypeFields(cache, galleryChapterMutationImpactedTypeFields);\n      evictQueries(cache, galleryChapterMutationImpactedQueries);\n    },\n  });\n\nexport const useGalleryChapterDestroy = () =>\n  GQL.useGalleryChapterDestroyMutation({\n    update(cache, result, { variables }) {\n      if (!result.data?.galleryChapterDestroy || !variables) return;\n\n      const obj = { __typename: \"GalleryChapter\", id: variables.id };\n      cache.evict({ id: cache.identify(obj) });\n\n      evictTypeFields(cache, galleryChapterMutationImpactedTypeFields);\n      evictQueries(cache, galleryChapterMutationImpactedQueries);\n    },\n  });\n\nconst performerMutationImpactedTypeFields = {\n  Tag: [\"performer_count\"],\n};\n\nexport const performerMutationImpactedQueries = [\n  GQL.FindScenesDocument, // filter by performer tags\n  GQL.FindImagesDocument, // filter by performer tags\n  GQL.FindGalleriesDocument, // filter by performer tags\n  GQL.FindPerformersDocument, // various filters\n  GQL.FindTagsDocument, // filter by performer count\n];\n\nexport const usePerformerCreate = () =>\n  GQL.usePerformerCreateMutation({\n    update(cache, result) {\n      const performer = result.data?.performerCreate;\n      if (!performer) return;\n\n      // update stats\n      updateStats(cache, \"performer_count\", 1);\n\n      evictTypeFields(cache, performerMutationImpactedTypeFields);\n      evictQueries(cache, [\n        GQL.FindPerformersDocument, // various filters\n        GQL.FindTagsDocument, // filter by performer count\n      ]);\n    },\n  });\n\nexport const usePerformerUpdate = () =>\n  GQL.usePerformerUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.performerUpdate) return;\n\n      evictTypeFields(cache, performerMutationImpactedTypeFields);\n      evictQueries(cache, performerMutationImpactedQueries);\n    },\n  });\n\nexport const useBulkPerformerUpdate = (input: GQL.BulkPerformerUpdateInput) =>\n  GQL.useBulkPerformerUpdateMutation({\n    variables: { input },\n    update(cache, result) {\n      if (!result.data?.bulkPerformerUpdate) return;\n\n      evictTypeFields(cache, performerMutationImpactedTypeFields);\n      evictQueries(cache, performerMutationImpactedQueries);\n    },\n  });\n\nexport const usePerformerDestroy = () =>\n  GQL.usePerformerDestroyMutation({\n    update(cache, result, { variables }) {\n      if (!result.data?.performerDestroy || !variables) return;\n\n      const obj = { __typename: \"Performer\", id: variables.id };\n      deleteObject(cache, obj, GQL.FindPerformerDocument);\n\n      // update stats\n      updateStats(cache, \"performer_count\", -1);\n\n      evictTypeFields(cache, {\n        ...performerMutationImpactedTypeFields,\n        Performer: [\"performer_count\"],\n        Studio: [\"performer_count\"],\n      });\n      evictQueries(cache, [\n        ...performerMutationImpactedQueries,\n        GQL.FindGroupsDocument, // filter by performers\n        GQL.FindSceneMarkersDocument, // filter by performers\n      ]);\n    },\n  });\n\nexport const usePerformersDestroy = (\n  input: GQL.PerformersDestroyMutationVariables\n) =>\n  GQL.usePerformersDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.performersDestroy) return;\n\n      const { ids } = input;\n\n      let count: number;\n      if (Array.isArray(ids)) {\n        for (const id of ids) {\n          const obj = { __typename: \"Performer\", id };\n          deleteObject(cache, obj, GQL.FindPerformerDocument);\n        }\n        count = ids.length;\n      } else {\n        const obj = { __typename: \"Performer\", id: ids };\n        deleteObject(cache, obj, GQL.FindPerformerDocument);\n        count = 1;\n      }\n\n      // update stats\n      updateStats(cache, \"performer_count\", -count);\n\n      evictTypeFields(cache, {\n        ...performerMutationImpactedTypeFields,\n        Performer: [\"performer_count\"],\n        Studio: [\"performer_count\"],\n      });\n      evictQueries(cache, [\n        ...performerMutationImpactedQueries,\n        GQL.FindGroupsDocument, // filter by performers\n        GQL.FindSceneMarkersDocument, // filter by performers\n      ]);\n    },\n  });\n\nexport const mutatePerformerMerge = (\n  destination: string,\n  source: string[],\n  values: GQL.PerformerUpdateInput\n) =>\n  client.mutate<GQL.PerformerMergeMutation>({\n    mutation: GQL.PerformerMergeDocument,\n    variables: {\n      input: {\n        source,\n        destination,\n        values,\n      },\n    },\n    update(cache, result) {\n      if (!result.data?.performerMerge) return;\n\n      for (const id of source) {\n        const obj = { __typename: \"Performer\", id };\n        deleteObject(cache, obj, GQL.FindPerformerDocument);\n      }\n\n      cache.evict({\n        id: cache.identify({ __typename: \"Performer\", id: destination }),\n      });\n\n      evictTypeFields(cache, performerMutationImpactedTypeFields);\n      evictQueries(cache, [\n        ...performerMutationImpactedQueries,\n        GQL.FindGroupsDocument, // filter by performers\n        GQL.FindSceneMarkersDocument, // filter by performers\n        GQL.StatsDocument, // performer count\n      ]);\n    },\n  });\n\nconst studioMutationImpactedTypeFields = {\n  Studio: [\"child_studios\"],\n};\n\nexport const studioMutationImpactedQueries = [\n  GQL.FindScenesDocument, // filter by studio\n  GQL.FindImagesDocument, // filter by studio\n  GQL.FindGroupsDocument, // filter by studio\n  GQL.FindGalleriesDocument, // filter by studio\n  GQL.FindPerformersDocument, // filter by studio\n  GQL.FindStudiosDocument, // various filters\n];\n\nexport const useStudioCreate = () =>\n  GQL.useStudioCreateMutation({\n    update(cache, result, { variables }) {\n      const studio = result.data?.studioCreate;\n      if (!studio || !variables) return;\n\n      // update stats\n      updateStats(cache, \"studio_count\", 1);\n\n      // if new scene has a parent studio,\n      // refetch the parent's list of child studios\n      const { parent_id } = variables.input;\n      if (parent_id !== undefined) {\n        cache.evict({\n          id: cache.identify({ __typename: \"Studio\", id: parent_id }),\n          fieldName: \"child_studios\",\n        });\n      }\n\n      evictQueries(cache, [\n        GQL.FindStudiosDocument, // various filters\n      ]);\n    },\n  });\n\nexport const useStudioUpdate = () =>\n  GQL.useStudioUpdateMutation({\n    update(cache, result) {\n      const studio = result.data?.studioUpdate;\n      if (!studio) return;\n\n      const obj = { __typename: \"Studio\", id: studio.id };\n      evictTypeFields(\n        cache,\n        studioMutationImpactedTypeFields,\n        cache.identify(obj) // don't evict this studio\n      );\n\n      evictQueries(cache, studioMutationImpactedQueries);\n    },\n  });\n\nexport const useBulkStudioUpdate = () =>\n  GQL.useBulkStudioUpdateMutation({\n    update(cache, result) {\n      if (!result.data?.bulkStudioUpdate) return;\n\n      evictTypeFields(cache, studioMutationImpactedTypeFields);\n      evictQueries(cache, studioMutationImpactedQueries);\n    },\n  });\n\nexport const useStudioDestroy = (input: GQL.StudioDestroyInput) =>\n  GQL.useStudioDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.studioDestroy) return;\n\n      const obj = { __typename: \"Studio\", id: input.id };\n      deleteObject(cache, obj, GQL.FindStudioDocument);\n\n      // update stats\n      updateStats(cache, \"studio_count\", -1);\n\n      evictTypeFields(cache, studioMutationImpactedTypeFields);\n      evictQueries(cache, studioMutationImpactedQueries);\n    },\n  });\n\nexport const useStudiosDestroy = (input: GQL.StudiosDestroyMutationVariables) =>\n  GQL.useStudiosDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.studiosDestroy) return;\n\n      const { ids } = input;\n\n      for (const id of ids) {\n        const obj = { __typename: \"Studio\", id };\n        deleteObject(cache, obj, GQL.FindStudioDocument);\n      }\n\n      // update stats\n      updateStats(cache, \"studio_count\", -ids.length);\n\n      evictTypeFields(cache, studioMutationImpactedTypeFields);\n      evictQueries(cache, studioMutationImpactedQueries);\n    },\n  });\n\nconst tagMutationImpactedTypeFields = {\n  Tag: [\"parents\", \"children\"],\n};\n\nconst tagMutationImpactedQueries = [\n  GQL.FindGroupsDocument, // filter by tags\n  GQL.FindSceneMarkersDocument, // filter by tags\n  GQL.FindScenesDocument, // filter by tags\n  GQL.FindImagesDocument, // filter by tags\n  GQL.FindGalleriesDocument, // filter by tags\n  GQL.FindPerformersDocument, // filter by tags\n  GQL.FindTagsDocument, // various filters\n];\n\nexport const useTagCreate = () =>\n  GQL.useTagCreateMutation({\n    update(cache, result) {\n      const tag = result.data?.tagCreate;\n      if (!tag) return;\n\n      // update stats\n      updateStats(cache, \"tag_count\", 1);\n\n      const obj = { __typename: \"Tag\", id: tag.id };\n      evictTypeFields(\n        cache,\n        tagMutationImpactedTypeFields,\n        cache.identify(obj) // don't evict this tag\n      );\n\n      evictQueries(cache, [\n        GQL.FindTagsDocument, // various filters\n      ]);\n    },\n  });\n\nexport const useTagUpdate = () =>\n  GQL.useTagUpdateMutation({\n    update(cache, result) {\n      const tag = result.data?.tagUpdate;\n      if (!tag) return;\n\n      const obj = { __typename: \"Tag\", id: tag.id };\n      evictTypeFields(\n        cache,\n        tagMutationImpactedTypeFields,\n        cache.identify(obj) // don't evict this tag\n      );\n\n      evictQueries(cache, tagMutationImpactedQueries);\n    },\n  });\n\nexport const useBulkTagUpdate = (input: GQL.BulkTagUpdateInput) =>\n  GQL.useBulkTagUpdateMutation({\n    variables: { input },\n    update(cache, result) {\n      if (!result.data?.bulkTagUpdate) return;\n\n      evictTypeFields(cache, tagMutationImpactedTypeFields);\n      evictQueries(cache, tagMutationImpactedQueries);\n    },\n  });\n\nexport const useTagDestroy = (input: GQL.TagDestroyInput) =>\n  GQL.useTagDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.tagDestroy) return;\n\n      const obj = { __typename: \"Tag\", id: input.id };\n      deleteObject(cache, obj, GQL.FindTagDocument);\n\n      // update stats\n      updateStats(cache, \"tag_count\", -1);\n\n      evictTypeFields(cache, tagMutationImpactedTypeFields);\n      evictQueries(cache, tagMutationImpactedQueries);\n    },\n  });\n\nexport const useTagsDestroy = (input: GQL.TagsDestroyMutationVariables) =>\n  GQL.useTagsDestroyMutation({\n    variables: input,\n    update(cache, result) {\n      if (!result.data?.tagsDestroy) return;\n\n      const { ids } = input;\n\n      for (const id of ids) {\n        const obj = { __typename: \"Tag\", id };\n        deleteObject(cache, obj, GQL.FindTagDocument);\n      }\n\n      // update stats\n      updateStats(cache, \"tag_count\", -ids.length);\n\n      evictTypeFields(cache, tagMutationImpactedTypeFields);\n      evictQueries(cache, tagMutationImpactedQueries);\n    },\n  });\n\nexport const useTagsMerge = () =>\n  GQL.useTagsMergeMutation({\n    update(cache, result, { variables }) {\n      if (!result.data?.tagsMerge || !variables) return;\n\n      const { source, destination } = variables;\n\n      for (const id of source) {\n        const obj = { __typename: \"Tag\", id };\n        deleteObject(cache, obj, GQL.FindTagDocument);\n      }\n\n      cache.evict({\n        id: cache.identify({ __typename: \"Tag\", id: destination }),\n      });\n\n      evictQueries(cache, [\n        ...tagMutationImpactedQueries,\n        GQL.StatsDocument, // tag count\n      ]);\n    },\n  });\n\nexport const useSaveFilter = () => {\n  const [saveFilterMutation] = GQL.useSaveFilterMutation({\n    update(cache, result) {\n      if (!result.data?.saveFilter) return;\n\n      evictQueries(cache, [GQL.FindSavedFiltersDocument]);\n    },\n  });\n\n  function saveFilter(filter: ListFilterModel, name: string, id?: string) {\n    const filterCopy = filter.clone();\n\n    return saveFilterMutation({\n      variables: {\n        input: {\n          id,\n          mode: filter.mode,\n          name,\n          find_filter: filterCopy.makeFindFilter(),\n          object_filter: filterCopy.makeSavedFilter(),\n          ui_options: filterCopy.makeSavedUIOptions(),\n        },\n      },\n    });\n  }\n\n  return saveFilter;\n};\n\nexport const useSavedFilterDestroy = () =>\n  GQL.useDestroySavedFilterMutation({\n    update(cache, result, { variables }) {\n      if (!result.data?.destroySavedFilter || !variables) return;\n\n      const obj = { __typename: \"SavedFilter\", id: variables.input.id };\n      deleteObject(cache, obj, GQL.FindSavedFilterDocument);\n    },\n  });\n\nexport const mutateDeleteFiles = (ids: string[]) =>\n  client.mutate<GQL.DeleteFilesMutation>({\n    mutation: GQL.DeleteFilesDocument,\n    variables: { ids },\n    update(cache, result) {\n      if (!result.data?.deleteFiles) return;\n\n      // we don't know which type the files are,\n      // so evict all of them\n      for (const id of ids) {\n        cache.evict({\n          id: cache.identify({ __typename: \"VideoFile\", id }),\n        });\n        cache.evict({\n          id: cache.identify({ __typename: \"ImageFile\", id }),\n        });\n        cache.evict({\n          id: cache.identify({ __typename: \"GalleryFile\", id }),\n        });\n      }\n\n      evictQueries(cache, [\n        GQL.FindScenesDocument, // filter by file count\n        GQL.FindImagesDocument, // filter by file count\n        GQL.FindGalleriesDocument, // filter by file count\n        GQL.StatsDocument, // scenes size, images size\n      ]);\n    },\n  });\n\nexport const mutateRevealFileInFileManager = (id: string) =>\n  client.mutate<GQL.RevealFileInFileManagerMutation>({\n    mutation: GQL.RevealFileInFileManagerDocument,\n    variables: { id },\n  });\n\nexport const mutateRevealFolderInFileManager = (id: string) =>\n  client.mutate<GQL.RevealFolderInFileManagerMutation>({\n    mutation: GQL.RevealFolderInFileManagerDocument,\n    variables: { id },\n  });\n\n/// Scrapers\n\nexport const useListSceneScrapers = () => GQL.useListSceneScrapersQuery();\n\nexport const queryScrapeScene = (\n  source: GQL.ScraperSourceInput,\n  sceneId: string\n) =>\n  client.query<GQL.ScrapeSingleSceneQuery>({\n    query: GQL.ScrapeSingleSceneDocument,\n    variables: {\n      source,\n      input: {\n        scene_id: sceneId,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const queryScrapeSceneQuery = (\n  source: GQL.ScraperSourceInput,\n  q: string\n) =>\n  client.query<GQL.ScrapeSingleSceneQuery>({\n    query: GQL.ScrapeSingleSceneDocument,\n    variables: {\n      source,\n      input: {\n        query: q,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const queryScrapeSceneURL = (url: string) =>\n  client.query<GQL.ScrapeSceneUrlQuery>({\n    query: GQL.ScrapeSceneUrlDocument,\n    variables: { url },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const queryScrapeSceneQueryFragment = (\n  source: GQL.ScraperSourceInput,\n  input: GQL.ScrapedSceneInput\n) =>\n  client.query<GQL.ScrapeSingleSceneQuery>({\n    query: GQL.ScrapeSingleSceneDocument,\n    variables: {\n      source,\n      input: {\n        scene_input: input,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const stashBoxSceneBatchQuery = (\n  sceneIds: string[],\n  stashBoxEndpoint: string\n) =>\n  client.query<GQL.ScrapeMultiScenesQuery, GQL.ScrapeMultiScenesQueryVariables>(\n    {\n      query: GQL.ScrapeMultiScenesDocument,\n      variables: {\n        source: {\n          stash_box_endpoint: stashBoxEndpoint,\n        },\n        input: {\n          scene_ids: sceneIds,\n        },\n      },\n    }\n  );\n\nexport const useListPerformerScrapers = () =>\n  GQL.useListPerformerScrapersQuery();\n\nexport const useScrapePerformerList = (scraperId: string, q: string) =>\n  GQL.useScrapeSinglePerformerQuery({\n    variables: {\n      source: {\n        scraper_id: scraperId,\n      },\n      input: {\n        query: q,\n      },\n    },\n    skip: q === \"\",\n  });\n\nexport const queryScrapePerformer = (\n  scraperId: string,\n  scrapedPerformer: GQL.ScrapedPerformerInput\n) =>\n  client.query<GQL.ScrapeSinglePerformerQuery>({\n    query: GQL.ScrapeSinglePerformerDocument,\n    variables: {\n      source: {\n        scraper_id: scraperId,\n      },\n      input: {\n        performer_input: scrapedPerformer,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const queryScrapePerformerURL = (url: string) =>\n  client.query<GQL.ScrapePerformerUrlQuery>({\n    query: GQL.ScrapePerformerUrlDocument,\n    variables: { url },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const stashBoxPerformerQuery = (\n  searchVal: string,\n  stashBoxEndpoint: string\n) =>\n  client.query<\n    GQL.ScrapeSinglePerformerQuery,\n    GQL.ScrapeSinglePerformerQueryVariables\n  >({\n    query: GQL.ScrapeSinglePerformerDocument,\n    variables: {\n      source: {\n        stash_box_endpoint: stashBoxEndpoint,\n      },\n      input: {\n        query: searchVal,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const stashBoxStudioQuery = (\n  query: string | null,\n  stashBoxEndpoint: string\n) =>\n  client.query<\n    GQL.ScrapeSingleStudioQuery,\n    GQL.ScrapeSingleStudioQueryVariables\n  >({\n    query: GQL.ScrapeSingleStudioDocument,\n    variables: {\n      source: {\n        stash_box_endpoint: stashBoxEndpoint,\n      },\n      input: {\n        query: query,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const stashBoxSceneQuery = (query: string, stashBoxEndpoint: string) =>\n  client.query<GQL.ScrapeSingleSceneQuery, GQL.ScrapeSingleSceneQueryVariables>(\n    {\n      query: GQL.ScrapeSingleSceneDocument,\n      variables: {\n        source: {\n          stash_box_endpoint: stashBoxEndpoint,\n        },\n        input: {\n          query: query,\n        },\n      },\n      fetchPolicy: \"network-only\",\n    }\n  );\n\nexport const stashBoxTagQuery = (\n  query: string | null,\n  stashBoxEndpoint: string\n) =>\n  client.query<GQL.ScrapeSingleTagQuery, GQL.ScrapeSingleTagQueryVariables>({\n    query: GQL.ScrapeSingleTagDocument,\n    variables: {\n      source: {\n        stash_box_endpoint: stashBoxEndpoint,\n      },\n      input: {\n        query: query,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const mutateStashBoxBatchPerformerTag = (\n  input: GQL.StashBoxBatchTagInput\n) =>\n  client.mutate<GQL.StashBoxBatchPerformerTagMutation>({\n    mutation: GQL.StashBoxBatchPerformerTagDocument,\n    variables: { input },\n  });\n\nexport const mutateStashBoxBatchStudioTag = (\n  input: GQL.StashBoxBatchTagInput\n) =>\n  client.mutate<GQL.StashBoxBatchStudioTagMutation>({\n    mutation: GQL.StashBoxBatchStudioTagDocument,\n    variables: { input },\n  });\n\nexport const mutateStashBoxBatchTagTag = (input: GQL.StashBoxBatchTagInput) =>\n  client.mutate<GQL.StashBoxBatchTagTagMutation>({\n    mutation: GQL.StashBoxBatchTagTagDocument,\n    variables: { input },\n  });\n\nexport const useListGroupScrapers = () => GQL.useListGroupScrapersQuery();\n\nexport const queryScrapeGroupURL = (url: string) =>\n  client.query<GQL.ScrapeGroupUrlQuery>({\n    query: GQL.ScrapeGroupUrlDocument,\n    variables: { url },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const useListGalleryScrapers = () => GQL.useListGalleryScrapersQuery();\n\nexport const useListImageScrapers = () => GQL.useListImageScrapersQuery();\n\nexport const queryScrapeGallery = (scraperId: string, galleryId: string) =>\n  client.query<GQL.ScrapeSingleGalleryQuery>({\n    query: GQL.ScrapeSingleGalleryDocument,\n    variables: {\n      source: {\n        scraper_id: scraperId,\n      },\n      input: {\n        gallery_id: galleryId,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const queryScrapeGalleryURL = (url: string) =>\n  client.query<GQL.ScrapeGalleryUrlQuery>({\n    query: GQL.ScrapeGalleryUrlDocument,\n    variables: { url },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const queryScrapeImage = (scraperId: string, imageId: string) =>\n  client.query<GQL.ScrapeSingleImageQuery>({\n    query: GQL.ScrapeSingleImageDocument,\n    variables: {\n      source: {\n        scraper_id: scraperId,\n      },\n      input: {\n        image_id: imageId,\n      },\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const queryScrapeImageURL = (url: string) =>\n  client.query<GQL.ScrapeImageUrlQuery>({\n    query: GQL.ScrapeImageUrlDocument,\n    variables: { url },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const mutateSubmitStashBoxSceneDraft = (\n  input: GQL.StashBoxDraftSubmissionInput\n) =>\n  client.mutate<GQL.SubmitStashBoxSceneDraftMutation>({\n    mutation: GQL.SubmitStashBoxSceneDraftDocument,\n    variables: { input },\n  });\n\nexport const mutateSubmitStashBoxPerformerDraft = (\n  input: GQL.StashBoxDraftSubmissionInput\n) =>\n  client.mutate<GQL.SubmitStashBoxPerformerDraftMutation>({\n    mutation: GQL.SubmitStashBoxPerformerDraftDocument,\n    variables: { input },\n  });\n\n/// Configuration\n\nexport const useConfiguration = () => GQL.useConfigurationQuery();\n\nexport const usePlugins = () => GQL.usePluginsQuery();\n\nexport const usePluginTasks = () => GQL.usePluginTasksQuery();\n\nexport const useStats = () => GQL.useStatsQuery();\n\nexport const useVersion = () => GQL.useVersionQuery();\n\nexport const useLatestVersion = () =>\n  GQL.useLatestVersionQuery({\n    notifyOnNetworkStatusChange: true,\n    errorPolicy: \"ignore\",\n  });\n\nexport const useDLNAStatus = () =>\n  GQL.useDlnaStatusQuery({\n    fetchPolicy: \"no-cache\",\n  });\n\nexport const useJobQueue = () =>\n  GQL.useJobQueueQuery({\n    fetchPolicy: \"no-cache\",\n  });\n\nexport const useLogs = () =>\n  GQL.useLogsQuery({\n    fetchPolicy: \"no-cache\",\n  });\n\nexport const queryLogs = () =>\n  client.query<GQL.LogsQuery>({\n    query: GQL.LogsDocument,\n    fetchPolicy: \"no-cache\",\n  });\n\nexport const useSystemStatus = () => GQL.useSystemStatusQuery();\nexport const refetchSystemStatus = () => {\n  client.refetchQueries({\n    include: [GQL.SystemStatusDocument],\n  });\n};\n\nexport const useJobsSubscribe = () => GQL.useJobsSubscribeSubscription();\n\nexport const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription();\n\n// all scraper-related queries\nexport const scraperMutationImpactedQueries = [\n  GQL.ListGroupScrapersDocument,\n  GQL.ListPerformerScrapersDocument,\n  GQL.ListSceneScrapersDocument,\n  GQL.ListImageScrapersDocument,\n  GQL.InstalledScraperPackagesDocument,\n  GQL.InstalledScraperPackagesStatusDocument,\n];\n\nexport const mutateReloadScrapers = () =>\n  client.mutate<GQL.ReloadScrapersMutation>({\n    mutation: GQL.ReloadScrapersDocument,\n    update(cache, result) {\n      if (!result.data?.reloadScrapers) return;\n\n      evictQueries(cache, scraperMutationImpactedQueries);\n    },\n  });\n\n// all plugin-related queries\nexport const pluginMutationImpactedQueries = [\n  GQL.PluginsDocument,\n  GQL.PluginTasksDocument,\n  GQL.InstalledPluginPackagesDocument,\n  GQL.InstalledPluginPackagesStatusDocument,\n];\n\nexport const mutateReloadPlugins = () =>\n  client.mutate<GQL.ReloadPluginsMutation>({\n    mutation: GQL.ReloadPluginsDocument,\n    update(cache, result) {\n      if (!result.data?.reloadPlugins) return;\n\n      evictQueries(cache, pluginMutationImpactedQueries);\n    },\n  });\n\ntype BoolMap = { [key: string]: boolean };\n\nexport const mutateSetPluginsEnabled = (enabledMap: BoolMap) =>\n  client.mutate<GQL.SetPluginsEnabledMutation>({\n    mutation: GQL.SetPluginsEnabledDocument,\n    variables: { enabledMap },\n    update(cache, result) {\n      if (!result.data?.setPluginsEnabled) return;\n\n      for (const id in enabledMap) {\n        cache.modify({\n          id: cache.identify({ __typename: \"Plugin\", id }),\n          fields: {\n            enabled() {\n              return enabledMap[id];\n            },\n          },\n        });\n      }\n    },\n  });\n\nfunction updateConfiguration(cache: ApolloCache<unknown>, result: FetchResult) {\n  if (!result.data) return;\n\n  evictQueries(cache, [GQL.ConfigurationDocument]);\n}\n\nexport const useConfigureGeneral = () =>\n  GQL.useConfigureGeneralMutation({\n    update(cache, result) {\n      if (!result.data?.configureGeneral) return;\n\n      evictQueries(cache, [\n        GQL.ConfigurationDocument,\n        ...scraperMutationImpactedQueries,\n        ...pluginMutationImpactedQueries,\n      ]);\n    },\n  });\n\nexport const useConfigureInterface = () =>\n  GQL.useConfigureInterfaceMutation({\n    update: updateConfiguration,\n  });\n\nexport const useGenerateAPIKey = () =>\n  GQL.useGenerateApiKeyMutation({\n    update: updateConfiguration,\n  });\n\nexport const useConfigureDefaults = () =>\n  GQL.useConfigureDefaultsMutation({\n    update: updateConfiguration,\n  });\n\nfunction updateUIConfig(\n  cache: ApolloCache<Record<string, StoreObject>>,\n  result: GQL.ConfigureUiMutation[\"configureUI\"] | undefined\n) {\n  if (!result) return;\n\n  const existing = cache.readQuery<GQL.ConfigurationQuery>({\n    query: GQL.ConfigurationDocument,\n  });\n\n  cache.writeQuery({\n    query: GQL.ConfigurationDocument,\n    data: {\n      configuration: {\n        ...existing?.configuration,\n        ui: result,\n      },\n    },\n  });\n}\n\nexport const useConfigureUI = () =>\n  GQL.useConfigureUiMutation({\n    update: (cache, result) => updateUIConfig(cache, result.data?.configureUI),\n  });\n\nexport const useConfigureUISetting = () =>\n  GQL.useConfigureUiSettingMutation({\n    update: (cache, result) =>\n      updateUIConfig(cache, result.data?.configureUISetting),\n  });\n\nexport const useConfigureScraping = () =>\n  GQL.useConfigureScrapingMutation({\n    update: updateConfiguration,\n  });\n\nexport const useConfigureDLNA = () =>\n  GQL.useConfigureDlnaMutation({\n    update: updateConfiguration,\n  });\n\nexport const useConfigurePlugin = () =>\n  GQL.useConfigurePluginMutation({\n    update: updateConfiguration,\n  });\n\nexport const useEnableDLNA = () => GQL.useEnableDlnaMutation();\n\nexport const useDisableDLNA = () => GQL.useDisableDlnaMutation();\n\nexport const useAddTempDLNAIP = () => GQL.useAddTempDlnaipMutation();\n\nexport const useRemoveTempDLNAIP = () => GQL.useRemoveTempDlnaipMutation();\n\nexport const mutateStopJob = (jobID: string) =>\n  client.mutate<GQL.StopJobMutation>({\n    mutation: GQL.StopJobDocument,\n    variables: { job_id: jobID },\n  });\n\nconst setupMutationImpactedQueries = [\n  GQL.ConfigurationDocument,\n  GQL.SystemStatusDocument,\n];\n\nexport const mutateSetup = (input: GQL.SetupInput) =>\n  client.mutate<GQL.SetupMutation>({\n    mutation: GQL.SetupDocument,\n    variables: { input },\n    update(cache, result) {\n      if (!result.data?.setup) return;\n\n      evictQueries(cache, setupMutationImpactedQueries);\n    },\n  });\n\nexport const mutateMigrate = (input: GQL.MigrateInput) =>\n  client.mutate<GQL.MigrateMutation>({\n    mutation: GQL.MigrateDocument,\n    variables: { input },\n  });\n\n// migrate now runs asynchronously, so we need to evict queries\n// once it successfully completes\nexport function postMigrate() {\n  evictQueries(clientCache, setupMutationImpactedQueries);\n}\n\n/// Packages\n\n// Acts like GQL.useInstalledScraperPackagesStatusQuery if loadUpgrades is true,\n// and GQL.useInstalledScraperPackagesQuery if it is false\nexport const useInstalledScraperPackages = <T extends boolean>(\n  loadUpgrades: T\n) => {\n  const query = loadUpgrades\n    ? GQL.InstalledScraperPackagesStatusDocument\n    : GQL.InstalledScraperPackagesDocument;\n\n  type TData = T extends true\n    ? GQL.InstalledScraperPackagesStatusQuery\n    : GQL.InstalledScraperPackagesQuery;\n  type TVariables = T extends true\n    ? GQL.InstalledScraperPackagesStatusQueryVariables\n    : GQL.InstalledScraperPackagesQueryVariables;\n\n  return useQuery<TData, TVariables>(query);\n};\n\nexport const queryAvailableScraperPackages = (source: string) =>\n  client.query<GQL.AvailableScraperPackagesQuery>({\n    query: GQL.AvailableScraperPackagesDocument,\n    variables: {\n      source,\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const mutateInstallScraperPackages = (\n  packages: GQL.PackageSpecInput[]\n) =>\n  client.mutate<GQL.InstallScraperPackagesMutation>({\n    mutation: GQL.InstallScraperPackagesDocument,\n    variables: {\n      packages,\n    },\n  });\n\nexport const mutateUpdateScraperPackages = (packages: GQL.PackageSpecInput[]) =>\n  client.mutate<GQL.UpdateScraperPackagesMutation>({\n    mutation: GQL.UpdateScraperPackagesDocument,\n    variables: {\n      packages,\n    },\n  });\n\nexport const mutateUninstallScraperPackages = (\n  packages: GQL.PackageSpecInput[]\n) =>\n  client.mutate<GQL.UninstallScraperPackagesMutation>({\n    mutation: GQL.UninstallScraperPackagesDocument,\n    variables: {\n      packages,\n    },\n  });\n\n// Acts like GQL.useInstalledPluginPackagesStatusQuery if loadUpgrades is true,\n// and GQL.useInstalledPluginPackagesQuery if it is false\nexport const useInstalledPluginPackages = <T extends boolean>(\n  loadUpgrades: T\n) => {\n  const query = loadUpgrades\n    ? GQL.InstalledPluginPackagesStatusDocument\n    : GQL.InstalledPluginPackagesDocument;\n\n  type TData = T extends true\n    ? GQL.InstalledPluginPackagesStatusQuery\n    : GQL.InstalledPluginPackagesQuery;\n  type TVariables = T extends true\n    ? GQL.InstalledPluginPackagesStatusQueryVariables\n    : GQL.InstalledPluginPackagesQueryVariables;\n\n  return useQuery<TData, TVariables>(query);\n};\n\nexport const queryAvailablePluginPackages = (source: string) =>\n  client.query<GQL.AvailablePluginPackagesQuery>({\n    query: GQL.AvailablePluginPackagesDocument,\n    variables: {\n      source,\n    },\n    fetchPolicy: \"network-only\",\n  });\n\nexport const mutateInstallPluginPackages = (packages: GQL.PackageSpecInput[]) =>\n  client.mutate<GQL.InstallPluginPackagesMutation>({\n    mutation: GQL.InstallPluginPackagesDocument,\n    variables: {\n      packages,\n    },\n  });\n\nexport const mutateUpdatePluginPackages = (packages: GQL.PackageSpecInput[]) =>\n  client.mutate<GQL.UpdatePluginPackagesMutation>({\n    mutation: GQL.UpdatePluginPackagesDocument,\n    variables: {\n      packages,\n    },\n  });\n\nexport const mutateUninstallPluginPackages = (\n  packages: GQL.PackageSpecInput[]\n) =>\n  client.mutate<GQL.UninstallPluginPackagesMutation>({\n    mutation: GQL.UninstallPluginPackagesDocument,\n    variables: {\n      packages,\n    },\n  });\n\n/// Tasks\n\nexport const mutateMetadataScan = (input: GQL.ScanMetadataInput) =>\n  client.mutate<GQL.MetadataScanMutation>({\n    mutation: GQL.MetadataScanDocument,\n    variables: { input },\n  });\n\nexport const mutateMetadataIdentify = (input: GQL.IdentifyMetadataInput) =>\n  client.mutate<GQL.MetadataIdentifyMutation>({\n    mutation: GQL.MetadataIdentifyDocument,\n    variables: { input },\n  });\n\nexport const mutateMetadataAutoTag = (input: GQL.AutoTagMetadataInput) =>\n  client.mutate<GQL.MetadataAutoTagMutation>({\n    mutation: GQL.MetadataAutoTagDocument,\n    variables: { input },\n  });\n\nexport const mutateMetadataGenerate = (input: GQL.GenerateMetadataInput) =>\n  client.mutate<GQL.MetadataGenerateMutation>({\n    mutation: GQL.MetadataGenerateDocument,\n    variables: { input },\n  });\n\nexport const mutateMetadataClean = (input: GQL.CleanMetadataInput) =>\n  client.mutate<GQL.MetadataCleanMutation>({\n    mutation: GQL.MetadataCleanDocument,\n    variables: { input },\n  });\n\nexport const mutateCleanGenerated = (input: GQL.CleanGeneratedInput) =>\n  client.mutate<GQL.MetadataCleanGeneratedMutation>({\n    mutation: GQL.MetadataCleanGeneratedDocument,\n    variables: { input },\n  });\n\nexport const mutateRunPluginTask = (\n  pluginId: string,\n  taskName: string,\n  args?: GQL.Scalars[\"Map\"][\"input\"]\n) =>\n  client.mutate<GQL.RunPluginTaskMutation>({\n    mutation: GQL.RunPluginTaskDocument,\n    variables: { plugin_id: pluginId, task_name: taskName, args },\n  });\n\nexport const mutateMetadataExport = () =>\n  client.mutate<GQL.MetadataExportMutation>({\n    mutation: GQL.MetadataExportDocument,\n  });\n\nexport const mutateExportObjects = (input: GQL.ExportObjectsInput) =>\n  client.mutate<GQL.ExportObjectsMutation>({\n    mutation: GQL.ExportObjectsDocument,\n    variables: { input },\n  });\n\nexport const mutateMetadataImport = () =>\n  client.mutate<GQL.MetadataImportMutation>({\n    mutation: GQL.MetadataImportDocument,\n  });\n\nexport const mutateImportObjects = (input: GQL.ImportObjectsInput) =>\n  client.mutate<GQL.ImportObjectsMutation>({\n    mutation: GQL.ImportObjectsDocument,\n    variables: { input },\n  });\n\nexport const mutateBackupDatabase = (input: GQL.BackupDatabaseInput) =>\n  client.mutate<GQL.BackupDatabaseMutation>({\n    mutation: GQL.BackupDatabaseDocument,\n    variables: { input },\n  });\n\nexport const mutateAnonymiseDatabase = (input: GQL.AnonymiseDatabaseInput) =>\n  client.mutate<GQL.AnonymiseDatabaseMutation>({\n    mutation: GQL.AnonymiseDatabaseDocument,\n    variables: { input },\n  });\n\nexport const mutateOptimiseDatabase = () =>\n  client.mutate<GQL.OptimiseDatabaseMutation>({\n    mutation: GQL.OptimiseDatabaseDocument,\n  });\n\nexport const mutateMigrateHashNaming = () =>\n  client.mutate<GQL.MigrateHashNamingMutation>({\n    mutation: GQL.MigrateHashNamingDocument,\n  });\n\nexport const mutateMigrateSceneScreenshots = (\n  input: GQL.MigrateSceneScreenshotsInput\n) =>\n  client.mutate<GQL.MigrateSceneScreenshotsMutation>({\n    mutation: GQL.MigrateSceneScreenshotsDocument,\n    variables: { input },\n  });\n\nexport const mutateMigrateBlobs = (input: GQL.MigrateBlobsInput) =>\n  client.mutate<GQL.MigrateBlobsMutation>({\n    mutation: GQL.MigrateBlobsDocument,\n    variables: { input },\n  });\n\n/// Misc\n\nexport const useDirectory = (path?: string) =>\n  GQL.useDirectoryQuery({ variables: { path } });\n\nexport const queryParseSceneFilenames = (\n  filter: GQL.FindFilterType,\n  config: GQL.SceneParserInput\n) =>\n  client.query<GQL.ParseSceneFilenamesQuery>({\n    query: GQL.ParseSceneFilenamesDocument,\n    variables: { filter, config },\n    fetchPolicy: \"network-only\",\n  });\n"
  },
  {
    "path": "ui/v2.5/src/core/config.ts",
    "content": "import { IntlShape } from \"react-intl\";\nimport { ITypename } from \"src/utils/data\";\nimport { ImageWallOptions } from \"src/utils/imageWall\";\nimport { RatingSystemOptions } from \"src/utils/rating\";\nimport {\n  FilterMode,\n  SavedFilterDataFragment,\n  SortDirectionEnum,\n} from \"./generated-graphql\";\nimport { View } from \"src/components/List/views\";\nimport { ITaggerConfig } from \"src/components/Tagger/constants\";\n\n// NOTE: double capitals aren't converted correctly in the backend\n\nexport interface ISavedFilterRow extends ITypename {\n  __typename: \"SavedFilter\";\n  savedFilterId: number;\n}\n\nexport interface IMessage {\n  id: string;\n  values: { [key: string]: string };\n}\n\nexport interface ICustomFilter extends ITypename {\n  __typename: \"CustomFilter\";\n  message?: IMessage;\n  title?: string;\n  mode: FilterMode;\n  sortBy: string;\n  direction: SortDirectionEnum;\n}\n\nexport type DefaultFilters = {\n  [P in View]?: SavedFilterDataFragment;\n};\n\nexport type FrontPageContent = ISavedFilterRow | ICustomFilter;\n\nexport const defaultMaxOptionsShown = 200;\nexport const defaultPreviewVolume = 25;\n\nexport interface IUIConfig {\n  // unknown to prevent direct access - use getFrontPageContent\n  frontPageContent?: unknown;\n\n  showChildTagContent?: boolean;\n  showChildStudioContent?: boolean;\n  showLinksOnPerformerCard?: boolean;\n  showTagCardOnHover?: boolean;\n\n  previewVolume?: number;\n\n  abbreviateCounters?: boolean;\n\n  ratingSystemOptions?: RatingSystemOptions;\n\n  // if true a background image will be display on header\n  enableMovieBackgroundImage?: boolean;\n  // if true a background image will be display on header\n  enablePerformerBackgroundImage?: boolean;\n  // if true a background image will be display on header\n  enableStudioBackgroundImage?: boolean;\n  // if true a background image will be display on header\n  enableTagBackgroundImage?: boolean;\n  // if true view expanded details compact\n  compactExpandedDetails?: boolean;\n  // if true show all content details by default\n  showAllDetails?: boolean;\n\n  // if true the chromecast option will enabled\n  enableChromecast?: boolean;\n\n  // if true the fullscreen mobile media auto-rotate option will be disabled\n  disableMobileMediaAutoRotateEnabled?: boolean;\n\n  // if true markers with end times will display with a horizontal bar in the scene player\n  showRangeMarkers?: boolean;\n  // if true continue scene will always play from the beginning\n  alwaysStartFromBeginning?: boolean;\n  // if true enable activity tracking\n  trackActivity?: boolean;\n  // the minimum percentage of scene duration which a scene must be played\n  // before the play count is incremented\n  minimumPlayPercent?: number;\n\n  showAbLoopControls?: boolean;\n\n  // maximum number of items to shown in the dropdown list - defaults to 200\n  // upper limit of 1000\n  maxOptionsShown?: number;\n\n  imageWallOptions?: ImageWallOptions;\n\n  lastNoteSeen?: number;\n\n  vrTag?: string;\n\n  pinnedFilters?: Record<string, string[]>;\n  tableColumns?: Record<string, string[]>;\n\n  advancedMode?: boolean;\n\n  taskDefaults?: Record<string, {}>;\n\n  defaultFilters?: DefaultFilters;\n\n  taggerConfig?: ITaggerConfig;\n\n  title?: string;\n}\n\nexport function getFrontPageContent(\n  ui: IUIConfig | undefined\n): FrontPageContent[] | undefined {\n  return ui?.frontPageContent as FrontPageContent[] | undefined;\n}\n\nfunction recentlyReleased(\n  intl: IntlShape,\n  mode: FilterMode,\n  objectsID: string\n): ICustomFilter {\n  return {\n    __typename: \"CustomFilter\",\n    message: {\n      id: \"recently_released_objects\",\n      values: { objects: intl.formatMessage({ id: objectsID }) },\n    },\n    mode,\n    sortBy: \"date\",\n    direction: SortDirectionEnum.Desc,\n  };\n}\n\nfunction recentlyAdded(\n  intl: IntlShape,\n  mode: FilterMode,\n  objectsID: string\n): ICustomFilter {\n  return {\n    __typename: \"CustomFilter\",\n    message: {\n      id: \"recently_added_objects\",\n      values: { objects: intl.formatMessage({ id: objectsID }) },\n    },\n    mode,\n    sortBy: \"created_at\",\n    direction: SortDirectionEnum.Desc,\n  };\n}\n\nexport function generateDefaultFrontPageContent(intl: IntlShape) {\n  return [\n    recentlyReleased(intl, FilterMode.Scenes, \"scenes\"),\n    recentlyAdded(intl, FilterMode.Studios, \"studios\"),\n    recentlyReleased(intl, FilterMode.Groups, \"groups\"),\n    recentlyAdded(intl, FilterMode.Performers, \"performers\"),\n    recentlyReleased(intl, FilterMode.Galleries, \"galleries\"),\n  ];\n}\n\nexport function generatePremadeFrontPageContent(intl: IntlShape) {\n  return [\n    recentlyReleased(intl, FilterMode.Scenes, \"scenes\"),\n    recentlyAdded(intl, FilterMode.Scenes, \"scenes\"),\n    recentlyReleased(intl, FilterMode.Galleries, \"galleries\"),\n    recentlyAdded(intl, FilterMode.Galleries, \"galleries\"),\n    recentlyAdded(intl, FilterMode.Images, \"images\"),\n    recentlyReleased(intl, FilterMode.Groups, \"groups\"),\n    recentlyAdded(intl, FilterMode.Groups, \"groups\"),\n    recentlyAdded(intl, FilterMode.Studios, \"studios\"),\n    recentlyAdded(intl, FilterMode.Performers, \"performers\"),\n    recentlyAdded(intl, FilterMode.SceneMarkers, \"markers\"),\n  ];\n}\n"
  },
  {
    "path": "ui/v2.5/src/core/createClient.ts",
    "content": "import {\n  ApolloClient,\n  InMemoryCache,\n  split,\n  from,\n  ServerError,\n  TypePolicies,\n} from \"@apollo/client\";\nimport { GraphQLWsLink } from \"@apollo/client/link/subscriptions\";\nimport { createClient as createWSClient } from \"graphql-ws\";\nimport { onError } from \"@apollo/client/link/error\";\nimport { getMainDefinition } from \"@apollo/client/utilities\";\nimport createUploadLink from \"apollo-upload-client/createUploadLink.mjs\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { FieldReadFunction } from \"@apollo/client/cache\";\n\n// A read function that returns a cache reference with the given\n// typename if no valid reference is available.\n// Allows to return a cached object rather than fetching.\nconst readReference = (typename: string): FieldReadFunction => {\n  return (existing, { args, canRead, toReference }) =>\n    canRead(existing)\n      ? existing\n      : toReference({\n          __typename: typename,\n          id: args?.id,\n        });\n};\n\n// A read function that returns null if a cached reference is invalid.\n// Means that a dangling reference implies the object was deleted.\nconst readDanglingNull: FieldReadFunction = (existing, { canRead }) => {\n  if (existing === undefined) return undefined;\n  return canRead(existing) ? existing : null;\n};\n\nconst typePolicies: TypePolicies = {\n  Query: {\n    fields: {\n      findImage: {\n        read: readReference(\"Image\"),\n      },\n      findPerformer: {\n        read: readReference(\"Performer\"),\n      },\n      findStudio: {\n        read: readReference(\"Studio\"),\n      },\n      findGroup: {\n        read: readReference(\"Group\"),\n      },\n      findGallery: {\n        read: readReference(\"Gallery\"),\n      },\n      findScene: {\n        read: readReference(\"Scene\"),\n      },\n      findTag: {\n        read: readReference(\"Tag\"),\n      },\n      findSavedFilter: {\n        read: readReference(\"SavedFilter\"),\n      },\n    },\n  },\n  Scene: {\n    fields: {\n      studio: {\n        read: readDanglingNull,\n      },\n    },\n  },\n  Image: {\n    fields: {\n      studio: {\n        read: readDanglingNull,\n      },\n      paths: {\n        merge: false,\n      },\n    },\n  },\n  Group: {\n    fields: {\n      studio: {\n        read: readDanglingNull,\n      },\n    },\n  },\n  Gallery: {\n    fields: {\n      studio: {\n        read: readDanglingNull,\n      },\n    },\n  },\n  Studio: {\n    fields: {\n      parent_studio: {\n        read: readDanglingNull,\n      },\n    },\n  },\n};\n\nconst possibleTypes = {\n  BaseFile: [\"VideoFile\", \"ImageFile\", \"GalleryFile\"],\n  VisualFile: [\"VideoFile\", \"ImageFile\"],\n};\n\nexport const baseURL =\n  document.querySelector(\"base\")?.getAttribute(\"href\") ?? \"/\";\n\nexport const getPlatformURL = (path?: string) => {\n  let url = new URL(window.location.origin + baseURL);\n\n  if (import.meta.env.DEV) {\n    if (import.meta.env.VITE_APP_PLATFORM_URL) {\n      url = new URL(import.meta.env.VITE_APP_PLATFORM_URL);\n    } else {\n      url.port = import.meta.env.VITE_APP_PLATFORM_PORT ?? \"9999\";\n    }\n  }\n\n  if (path) {\n    url.pathname += path;\n  }\n\n  return url;\n};\n\nexport const createClient = () => {\n  const url = getPlatformURL(\"graphql\");\n\n  const wsUrl = getPlatformURL(\"graphql\");\n  if (wsUrl.protocol === \"https:\") {\n    wsUrl.protocol = \"wss:\";\n  } else {\n    wsUrl.protocol = \"ws:\";\n  }\n\n  const httpLink = createUploadLink({ uri: url.toString() });\n\n  const wsClient = createWSClient({\n    url: wsUrl.toString(),\n    retryAttempts: Infinity,\n    shouldRetry() {\n      return true;\n    },\n  });\n\n  const wsLink = new GraphQLWsLink(wsClient);\n\n  const errorLink = onError(({ networkError }) => {\n    // handle graphql unauthorized error\n    if (networkError && (networkError as ServerError).statusCode === 401) {\n      if (import.meta.env.DEV) {\n        alert(`\\\nGraphQL server error: 401 Unauthorized\nAuthentication cannot be used with the dev server, since the session authorization cookie cannot be sent cross-origin.\nPlease disable it on the server and refresh the page.`);\n        return;\n      }\n      // redirect to login page\n      const newURL = new URL(\n        getPlatformURL(\"login\"),\n        window.location.toString()\n      );\n      newURL.searchParams.append(\"returnURL\", window.location.href);\n      window.location.href = newURL.toString();\n    }\n  });\n\n  const splitLink = split(\n    ({ query }) => {\n      const definition = getMainDefinition(query);\n      return (\n        definition.kind === \"OperationDefinition\" &&\n        definition.operation === \"subscription\"\n      );\n    },\n    wsLink,\n    httpLink\n  );\n\n  const link = from([errorLink, splitLink]);\n\n  const cache = new InMemoryCache({\n    typePolicies,\n    possibleTypes: possibleTypes,\n  });\n  const client = new ApolloClient({\n    link,\n    cache,\n  });\n\n  // Watch for scan/clean tasks and reset cache when they complete\n  client\n    .subscribe<GQL.ScanCompleteSubscribeSubscription>({\n      query: GQL.ScanCompleteSubscribeDocument,\n    })\n    .subscribe({\n      next: () => {\n        client.resetStore();\n      },\n    });\n\n  return {\n    cache,\n    client,\n    wsClient,\n  };\n};\n"
  },
  {
    "path": "ui/v2.5/src/core/enums.ts",
    "content": "import {\n  ImageLightboxDisplayMode,\n  ImageLightboxScrollMode,\n} from \"../core/generated-graphql\";\n\nexport const imageLightboxDisplayModeIntlMap = new Map<\n  ImageLightboxDisplayMode,\n  string\n>([\n  [ImageLightboxDisplayMode.Original, \"dialogs.lightbox.display_mode.original\"],\n  [\n    ImageLightboxDisplayMode.FitXy,\n    \"dialogs.lightbox.display_mode.fit_to_screen\",\n  ],\n  [\n    ImageLightboxDisplayMode.FitX,\n    \"dialogs.lightbox.display_mode.fit_horizontally\",\n  ],\n]);\n\nexport const imageLightboxScrollModeIntlMap = new Map<\n  ImageLightboxScrollMode,\n  string\n>([\n  [ImageLightboxScrollMode.Zoom, \"dialogs.lightbox.scroll_mode.zoom\"],\n  [ImageLightboxScrollMode.PanY, \"dialogs.lightbox.scroll_mode.pan_y\"],\n]);\n"
  },
  {
    "path": "ui/v2.5/src/core/files.ts",
    "content": "import TextUtils from \"src/utils/text\";\nimport * as GQL from \"src/core/generated-graphql\";\n\nexport interface IFile {\n  path: string;\n}\n\ninterface IObjectWithFiles {\n  files?: GQL.Maybe<IFile[]>;\n}\n\nexport interface IObjectWithTitleFiles extends IObjectWithFiles {\n  title?: GQL.Maybe<string>;\n}\n\nexport function objectTitle(s: Partial<IObjectWithTitleFiles>) {\n  if (s.title) {\n    return s.title;\n  }\n  if (s.files && s.files.length > 0) {\n    return TextUtils.fileNameFromPath(s.files[0].path);\n  }\n  return \"\";\n}\n\nexport function objectPath(s: IObjectWithFiles) {\n  if (s.files && s.files.length > 0) {\n    return s.files[0].path;\n  }\n  return \"\";\n}\n\ninterface IObjectWithVisualFiles {\n  visual_files?: IFile[];\n}\n\nexport interface IObjectWithTitleVisualFiles extends IObjectWithVisualFiles {\n  title?: GQL.Maybe<string>;\n}\n\nexport function imageTitle(s: Partial<IObjectWithTitleVisualFiles>) {\n  if (s.title) {\n    return s.title;\n  }\n  if (s.visual_files && s.visual_files.length > 0) {\n    return TextUtils.fileNameFromPath(s.visual_files[0].path);\n  }\n  return \"\";\n}\n\nexport function imagePath(s: IObjectWithVisualFiles) {\n  if (s.visual_files && s.visual_files.length > 0) {\n    return s.visual_files[0].path;\n  }\n  return \"\";\n}\n"
  },
  {
    "path": "ui/v2.5/src/core/galleries.ts",
    "content": "import TextUtils from \"src/utils/text\";\nimport * as GQL from \"src/core/generated-graphql\";\n\ninterface IFile {\n  path: string;\n}\n\ninterface IGallery {\n  files: GQL.Maybe<IFile[]>;\n  folder?: GQL.Maybe<IFile>;\n}\n\ninterface IGalleryWithTitle extends IGallery {\n  title: GQL.Maybe<string>;\n}\n\nexport function galleryTitle(s: Partial<IGalleryWithTitle>) {\n  if (s.title) {\n    return s.title;\n  }\n  if (s.files && s.files.length > 0) {\n    return TextUtils.fileNameFromPath(s.files[0].path);\n  }\n  if (s.folder) {\n    return TextUtils.fileNameFromPath(s.folder.path);\n  }\n  return \"\";\n}\n\nexport function galleryPath(s: IGallery) {\n  if (s.files && s.files.length > 0) {\n    return s.files[0].path;\n  }\n  if (s.folder) {\n    return s.folder.path;\n  }\n  return \"\";\n}\n"
  },
  {
    "path": "ui/v2.5/src/core/groups.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport {\n  GroupsCriterion,\n  GroupsCriterionOption,\n} from \"src/models/list-filter/criteria/groups\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\n\nexport const useGroupFilterHook = (\n  group: GQL.GroupDataFragment,\n  showChildGroupContent?: boolean\n) => {\n  return (filter: ListFilterModel) => {\n    const groupValue = { id: group.id, label: group.name };\n    // if group is already present, then we modify it, otherwise add\n    let groupCriterion = filter.criteria.find((c) => {\n      return c.criterionOption.type === \"groups\";\n    }) as GroupsCriterion | undefined;\n\n    if (groupCriterion) {\n      // we should be showing group only. Remove other values\n      groupCriterion.value.items = [groupValue];\n      groupCriterion.modifier = GQL.CriterionModifier.Includes;\n    } else {\n      groupCriterion = new GroupsCriterion(GroupsCriterionOption);\n      groupCriterion.value = {\n        items: [groupValue],\n        excluded: [],\n        depth: showChildGroupContent ? -1 : 0,\n      };\n      groupCriterion.modifier = GQL.CriterionModifier.Includes;\n      filter.criteria.push(groupCriterion);\n    }\n\n    return filter;\n  };\n};\n\nexport const scrapedGroupToCreateInput = (toCreate: GQL.ScrapedGroup) => {\n  const input: GQL.GroupCreateInput = {\n    name: toCreate.name ?? \"\",\n    urls: toCreate.urls,\n    aliases: toCreate.aliases,\n    front_image: toCreate.front_image,\n    back_image: toCreate.back_image,\n    synopsis: toCreate.synopsis,\n    date: toCreate.date,\n    director: toCreate.director,\n    // #788 - convert duration and rating to the correct type\n    duration: TextUtils.timestampToSeconds(toCreate.duration),\n    studio_id: toCreate.studio?.stored_id,\n    rating100: parseInt(toCreate.rating ?? \"0\", 10) * 20,\n  };\n\n  if (!input.duration) {\n    input.duration = undefined;\n  }\n\n  if (!input.rating100 || Number.isNaN(input.rating100)) {\n    input.rating100 = undefined;\n  }\n\n  return input;\n};\n"
  },
  {
    "path": "ui/v2.5/src/core/markers.ts",
    "content": "import { SceneMarker, Tag } from \"./generated-graphql\";\n\ntype SceneMarkerFragment = Pick<SceneMarker, \"id\" | \"title\"> & {\n  primary_tag: Pick<Tag, \"id\" | \"name\">;\n};\n\nexport function markerTitle(s: SceneMarkerFragment) {\n  if (s.title) {\n    return s.title;\n  }\n\n  if (s.primary_tag?.name) {\n    return s.primary_tag?.name;\n  }\n\n  return \"\";\n}\n"
  },
  {
    "path": "ui/v2.5/src/core/performers.ts",
    "content": "import { PerformersCriterion } from \"src/models/list-filter/criteria/performers\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport { stringToGender } from \"src/utils/gender\";\nimport { filterData } from \"src/utils/data\";\nimport { stringToCircumcised } from \"src/utils/circumcised\";\n\nexport const usePerformerFilterHook = (\n  performer: GQL.PerformerDataFragment\n) => {\n  return (filter: ListFilterModel) => {\n    const performerValue = {\n      id: performer.id,\n      label: performer.name ?? `Performer ${performer.id}`,\n    };\n    // if performers is already present, then we modify it, otherwise add\n    let performerCriterion = filter.criteria.find((c) => {\n      return c.criterionOption.type === \"performers\";\n    }) as PerformersCriterion | undefined;\n\n    if (performerCriterion) {\n      if (\n        performerCriterion.modifier === GQL.CriterionModifier.IncludesAll ||\n        performerCriterion.modifier === GQL.CriterionModifier.Includes\n      ) {\n        // add the performer if not present\n        if (\n          !performerCriterion.value.items.find((p) => {\n            return p.id === performer.id;\n          })\n        ) {\n          performerCriterion.value.items.push(performerValue);\n        }\n      } else {\n        // overwrite\n        performerCriterion.value.items = [performerValue];\n      }\n\n      performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;\n    } else {\n      performerCriterion = new PerformersCriterion();\n      performerCriterion.value.items = [performerValue];\n      performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;\n      filter.criteria.push(performerCriterion);\n    }\n\n    return filter;\n  };\n};\n\ninterface IPerformerFragment {\n  name?: GQL.Maybe<string>;\n  gender?: GQL.Maybe<GQL.GenderEnum>;\n}\n\nexport function sortPerformers<T extends IPerformerFragment>(performers: T[]) {\n  const ret = performers.slice();\n  ret.sort((a, b) => {\n    if (a.gender === b.gender) {\n      // sort by name\n      return (a.name ?? \"\").localeCompare(b.name ?? \"\");\n    }\n\n    // TODO - may want to customise gender order\n    const genderOrder = [\n      GQL.GenderEnum.Female,\n      GQL.GenderEnum.TransgenderFemale,\n      GQL.GenderEnum.Male,\n      GQL.GenderEnum.TransgenderMale,\n      GQL.GenderEnum.Intersex,\n      GQL.GenderEnum.NonBinary,\n    ];\n\n    const aIndex = a.gender\n      ? genderOrder.indexOf(a.gender)\n      : genderOrder.length;\n    const bIndex = b.gender\n      ? genderOrder.indexOf(b.gender)\n      : genderOrder.length;\n    return aIndex - bIndex;\n  });\n\n  return ret;\n}\n\nexport const scrapedPerformerToCreateInput = (\n  toCreate: GQL.ScrapedPerformer,\n  endpoint?: string\n) => {\n  const aliases =\n    toCreate.aliases\n      ?.split(\",\")\n      .map((a) => a.trim())\n      .filter((a) => a) || [];\n\n  const input: GQL.PerformerCreateInput = {\n    name: toCreate.name ?? \"\",\n    gender: stringToGender(toCreate.gender),\n    birthdate: toCreate.birthdate,\n    disambiguation: toCreate.disambiguation,\n    ethnicity: toCreate.ethnicity,\n    country: toCreate.country,\n    eye_color: toCreate.eye_color,\n    height_cm: toCreate.height ? Number(toCreate.height) : undefined,\n    measurements: toCreate.measurements,\n    fake_tits: toCreate.fake_tits,\n    career_start: toCreate.career_start,\n    career_end: toCreate.career_end,\n    tattoos: toCreate.tattoos,\n    piercings: toCreate.piercings,\n    alias_list: aliases,\n    urls: toCreate.urls,\n    tag_ids: filterData((toCreate.tags ?? []).map((t) => t.stored_id)),\n    image:\n      (toCreate.images ?? []).length > 0\n        ? (toCreate.images ?? [])[0]\n        : undefined,\n    details: toCreate.details,\n    death_date: toCreate.death_date,\n    hair_color: toCreate.hair_color,\n    weight: toCreate.weight ? Number(toCreate.weight) : undefined,\n    penis_length: toCreate.penis_length\n      ? Number(toCreate.penis_length)\n      : undefined,\n    circumcised: stringToCircumcised(toCreate.circumcised),\n  };\n\n  if (endpoint && toCreate.remote_site_id) {\n    input.stash_ids = [\n      {\n        endpoint,\n        stash_id: toCreate.remote_site_id,\n      },\n    ];\n  }\n\n  return input;\n};\n"
  },
  {
    "path": "ui/v2.5/src/core/recommendations.ts",
    "content": "function determineSlidesToScroll(\n  cardCount: number,\n  prefered: number,\n  isTouch: boolean\n) {\n  if (isTouch) {\n    return 1;\n  } else if (cardCount! > prefered) {\n    return prefered;\n  } else {\n    return cardCount;\n  }\n}\n\nexport function getSlickSliderSettings(cardCount: number, isTouch: boolean) {\n  return {\n    dots: !isTouch,\n    arrows: !isTouch,\n    infinite: !isTouch && cardCount > 5,\n    speed: 300,\n    variableWidth: true,\n    swipeToSlide: true,\n    slidesToShow: cardCount! > 5 ? 5 : cardCount,\n    slidesToScroll: determineSlidesToScroll(cardCount!, 5, isTouch),\n    responsive: [\n      {\n        breakpoint: 1909,\n        settings: {\n          infinite: !isTouch && cardCount > 4,\n          slidesToShow: cardCount! > 4 ? 4 : cardCount,\n          slidesToScroll: determineSlidesToScroll(cardCount!, 4, isTouch),\n        },\n      },\n      {\n        breakpoint: 1542,\n        settings: {\n          infinite: !isTouch && cardCount > 3,\n          slidesToShow: cardCount! > 3 ? 3 : cardCount,\n          slidesToScroll: determineSlidesToScroll(cardCount!, 3, isTouch),\n        },\n      },\n      {\n        breakpoint: 1170,\n        settings: {\n          infinite: !isTouch && cardCount > 2,\n          slidesToShow: cardCount! > 2 ? 2 : cardCount,\n          slidesToScroll: determineSlidesToScroll(cardCount!, 2, isTouch),\n        },\n      },\n      {\n        breakpoint: 801,\n        settings: {\n          infinite: !isTouch && cardCount > 1,\n          slidesToShow: 1,\n          slidesToScroll: 1,\n          dots: cardCount < 6,\n        },\n      },\n    ],\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/src/core/studios.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\nimport { StudiosCriterion } from \"src/models/list-filter/criteria/studios\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\n\nexport const useStudioFilterHook = (\n  studio: GQL.StudioDataFragment,\n  showChildStudioContent?: boolean\n) => {\n  return (filter: ListFilterModel) => {\n    const studioValue = { id: studio.id, label: studio.name };\n    // if studio is already present, then we modify it, otherwise add\n    let studioCriterion = filter.criteria.find((c) => {\n      return c.criterionOption.type === \"studios\";\n    }) as StudiosCriterion | undefined;\n\n    if (studioCriterion) {\n      // we should be showing studio only. Remove other values\n      studioCriterion.value.items = [studioValue];\n      studioCriterion.modifier = GQL.CriterionModifier.Includes;\n    } else {\n      studioCriterion = new StudiosCriterion();\n      studioCriterion.value = {\n        items: [studioValue],\n        excluded: [],\n        depth: showChildStudioContent ? -1 : 0,\n      };\n      studioCriterion.modifier = GQL.CriterionModifier.Includes;\n      filter.criteria.push(studioCriterion);\n    }\n\n    return filter;\n  };\n};\n"
  },
  {
    "path": "ui/v2.5/src/core/tags.ts",
    "content": "import { gql } from \"@apollo/client\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { getClient } from \"src/core/StashService\";\nimport {\n  TagsCriterion,\n  TagsCriterionOption,\n} from \"src/models/list-filter/criteria/tags\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\n\nexport const useTagFilterHook = (\n  tag: GQL.TagDataFragment,\n  showSubTagContent?: boolean\n) => {\n  return (filter: ListFilterModel) => {\n    const tagValue = { id: tag.id, label: tag.name };\n    // if tag is already present, then we modify it, otherwise add\n    let tagCriterion = filter.criteria.find((c) => {\n      return c.criterionOption.type === \"tags\";\n    }) as TagsCriterion | undefined;\n\n    if (tagCriterion) {\n      if (\n        tagCriterion.modifier === GQL.CriterionModifier.IncludesAll ||\n        tagCriterion.modifier === GQL.CriterionModifier.Includes\n      ) {\n        // add the tag if not present\n        if (\n          !tagCriterion.value.items.find((p) => {\n            return p.id === tag.id;\n          })\n        ) {\n          tagCriterion.value.items.push(tagValue);\n        }\n      } else {\n        // overwrite\n        tagCriterion.value.items = [tagValue];\n      }\n\n      tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;\n    } else {\n      tagCriterion = new TagsCriterion(TagsCriterionOption);\n      tagCriterion.value = {\n        items: [tagValue],\n        excluded: [],\n        depth: showSubTagContent ? -1 : 0,\n      };\n      tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;\n      filter.criteria.push(tagCriterion);\n    }\n\n    return filter;\n  };\n};\n\ninterface ITagRelationTuple {\n  parents: GQL.SlimTagDataFragment[];\n  children: GQL.SlimTagDataFragment[];\n}\n\nexport const tagRelationHook = (\n  tag: GQL.SlimTagDataFragment | GQL.TagDataFragment | GQL.TagListDataFragment,\n  old: ITagRelationTuple,\n  updated: ITagRelationTuple\n) => {\n  const { cache } = getClient();\n\n  const tagRef = cache.writeFragment({\n    data: tag,\n    fragment: gql`\n      fragment Tag on Tag {\n        id\n      }\n    `,\n  });\n\n  function updater(\n    property: \"parents\" | \"children\",\n    oldTags: GQL.SlimTagDataFragment[],\n    updatedTags: GQL.SlimTagDataFragment[]\n  ) {\n    oldTags.forEach((o) => {\n      if (!updatedTags.some((u) => u.id === o.id)) {\n        cache.modify({\n          id: cache.identify(o),\n          fields: {\n            [property](value, { readField }) {\n              return (value as GQL.SlimTagDataFragment[]).filter(\n                (t) => readField(\"id\", t) !== tag.id\n              );\n            },\n          },\n        });\n      }\n    });\n\n    updatedTags.forEach((u) => {\n      if (!oldTags.some((o) => o.id === u.id)) {\n        cache.modify({\n          id: cache.identify(u),\n          fields: {\n            [property](value) {\n              return [...(value as unknown[]), tagRef];\n            },\n          },\n        });\n      }\n    });\n  }\n\n  updater(\"children\", old.parents, updated.parents);\n  updater(\"parents\", old.children, updated.children);\n};\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v010.md",
    "content": "### ✨ New Features\n\n* Configurable custom performer scrapers\n* Support looping of short videos.\n* Optionally auto-start videos.\n* Add scene auto-tagging from filename\n* Add Play random button to scenes and scene markers page\n* Allow uploading of custom scene covers\n* Configurable custom scene metadata scrapers\n* Add \"Open Random\" to performer list\n* Add scenes tab to performer page\n* Add version check\n* Add \"O-\" (or \"splooge-\") counter\n* Add external_host option\n\n### 🎨 Improvements\n\n* Improve scene wall layout\n* Read config from current working directory before user profile directory\n* Upload pull request builds to transfer.sh\n* Save interface options\n* Change marker time input to mm:ss\n* Allow pasting image into performer/studio\n* Scene UI improvements\n* Update JWPlayer to 8.11.5\n* Beautify scene list table\n* Add responsive menu\n* Make scene metadata from file metadata optional\n* Add transcode seeking support to JWPlayer and remove video.js\n* Allow exclusion patterns for scanning\n* Support scraping from other stash instances\n* Display both server address and listening address in log\n* Add scene duration filter\n* Add useful links to about page\n* Generate a new order when selecting random sorting\n* Maintain filter parameters in session\n* Change thumbnail default size and resize algorithm\n* Improve caching of static files and performer images\n* Improve position and cropping of performer images\n* Improve stats page\n\n### 🐛 Bug fixes\n\n* Fix importing on Windows\n* Fix previews sometimes taking a long time to generate\n* Fix input fields losing focus when switching between windows\n* Fix VTT for chapter display in scene players\n* Fix usage of Box.Bytes causing depreciation message\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0100.md",
    "content": "#### 💥 Note: Please check your logs after migrating to this release. A log warning will be generated on startup if duplicate image checksums exist in your system. Search for the images using the logged checksums, and remove the unwanted ones.\n\n#### 💥 Note: The system will now stop serving requests if authentication is not configured and it detects a connection from public internet. See [this link](https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet) for details.\n\n### ✨ New Features\n* Added support for Tag hierarchies. ([#1519](https://github.com/stashapp/stash/pull/1519))\n* Revamped image lightbox to support zoom, pan and various display modes. ([#1708](https://github.com/stashapp/stash/pull/1708))\n* Added support for Studio aliases. ([#1660](https://github.com/stashapp/stash/pull/1660))\n* Added support for querying scene scrapers using keywords. ([#1712](https://github.com/stashapp/stash/pull/1712))\n* Added native support for Apple Silicon / M1 Macs. ([#1646](https://github.com/stashapp/stash/pull/1646))\n* Support subpaths when serving stash via reverse proxy. ([#1719](https://github.com/stashapp/stash/pull/1719))\n* Disallow access from public internet addresses when authentication is not configured. ([#1761](https://github.com/stashapp/stash/pull/1761))\n* Added options to generate webp and static preview files for markers. ([#1604](https://github.com/stashapp/stash/pull/1604))\n* Added sort by option for gallery rating. ([#1720](https://github.com/stashapp/stash/pull/1720))\n* Added Movies to Scene bulk edit dialog. ([#1676](https://github.com/stashapp/stash/pull/1676))\n* Added Movies tab to Studio and Performer pages. ([#1675](https://github.com/stashapp/stash/pull/1675))\n* Support filtering Movies by Performers. ([#1675](https://github.com/stashapp/stash/pull/1675))\n\n### 🎨 Improvements\n* Optimised image thumbnail generation (optionally using `libvips`) and made optional. ([#1655](https://github.com/stashapp/stash/pull/1655))\n* Improved image query performance. ([#1740](https://github.com/stashapp/stash/pull/1740), [#1750](https://github.com/stashapp/stash/pull/1750))\n* Support setting metadata import/export directory from UI. ([#1782](https://github.com/stashapp/stash/pull/1782))\n* Added movie count to performer and studio cards. ([#1760](https://github.com/stashapp/stash/pull/1760))\n* Added date and details to Movie card, and move scene count to icon. ([#1758](https://github.com/stashapp/stash/pull/1758))\n* Added date and details to Gallery card, and move image count to icon. ([#1763](https://github.com/stashapp/stash/pull/1763))\n* Support scraper script logging to specific log levels. ([#1648](https://github.com/stashapp/stash/pull/1648))\n* Added sv-SE language option. ([#1691](https://github.com/stashapp/stash/pull/1691))\n\n### 🐛 Bug fixes\n* Disabled float-on-scroll player on mobile devices. ([#1721](https://github.com/stashapp/stash/pull/1721))\n* Fix video transcoding process starting before video is played. ([#1780](https://github.com/stashapp/stash/pull/1780))\n* Fix Scene Edit Panel form layout for mobile and desktop. ([#1737](https://github.com/stashapp/stash/pull/1737))\n* Don't scan zero-length files. ([#1779](https://github.com/stashapp/stash/pull/1779))\n* Accept svg files in file selector for tag images. ([#1778](https://github.com/stashapp/stash/pull/1778))\n* Optimised exclude filter queries. ([#1815](https://github.com/stashapp/stash/pull/1815))\n* Fix video player aspect ratio shifting sometimes when clicking scene tabs. ([#1764](https://github.com/stashapp/stash/pull/1764))\n* Fix criteria being incorrectly applied when clicking back button. ([#1765](https://github.com/stashapp/stash/pull/1765))\n* Show first page and fix order direction not being maintained when clicking on card popover button. ([#1765](https://github.com/stashapp/stash/pull/1765))\n* Fix panic in autotagger when backslash character present in tag/performer/studio name. ([#1753](https://github.com/stashapp/stash/pull/1753))\n* Fix Scene Player CLS issue ([#1739](https://github.com/stashapp/stash/pull/1739))\n* Fix Gallery create plugin hook not being invoked when creating Gallery from folder. ([#1731](https://github.com/stashapp/stash/pull/1731)) \n* Fix tag aliases not being matched when autotagging from the tasks page. ([#1713](https://github.com/stashapp/stash/pull/1713))\n* Fix Create Marker form on small devices. ([#1718](https://github.com/stashapp/stash/pull/1718))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v011.md",
    "content": "### 🐛 Bug fixes\n* Fix version checking.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0110.md",
    "content": "### 💫 [Help Shape the Future of Stash!](https://forms.gle/x5nZa1zrVTJpgMHx8)\nThe Stash developers would greatly appreciate if you take a [short, anonymous survey](https://forms.gle/x5nZa1zrVTJpgMHx8). It would help us out a great deal to make yourself heard, let us know how you use Stash, and tell us what you'd like to see in the future.\n\n### ✨ New Features\n* Added Identify task to automatically identify scenes from stash-box/scraper sources. See manual entry for details. ([#1839](https://github.com/stashapp/stash/pull/1839))\n* Added support for matching scenes using perceptual hashes when querying stash-box. ([#1858](https://github.com/stashapp/stash/pull/1858))\n* Generalised Tagger view to support tagging using supported scene scrapers. ([#1812](https://github.com/stashapp/stash/pull/1812))\n* Added built-in `Auto Tag` scene scraper to match performers, studio and tags from filename - using AutoTag logic. ([#1817](https://github.com/stashapp/stash/pull/1817))\n* Add options to auto-start videos when playing from selection and continue to scene playlists. ([#1921](https://github.com/stashapp/stash/pull/1921))\n* Support is (not) null for multi-relational filter criteria. ([#1785](https://github.com/stashapp/stash/pull/1785))\n* Optionally open browser on startup (enabled by default for new systems). ([#1832](https://github.com/stashapp/stash/pull/1832))\n* Support setting defaults for Delete File and Delete Generated Files in the Interface Settings. ([#1852](https://github.com/stashapp/stash/pull/1852))\n* Added interface options to disable creating performers/studios/tags from dropdown selectors. ([#1814](https://github.com/stashapp/stash/pull/1814))\n\n### 🎨 Improvements\n* Added Italian 🇮🇹, French 🇫🇷, and Spanish 🇪🇸 translations ([#1875](https://github.com/stashapp/stash/pull/1875), [#1967](https://github.com/stashapp/stash/pull/1967), [#1886](https://github.com/stashapp/stash/pull/1886))\n* Added stash-id to scene scrape dialog. ([#1955](https://github.com/stashapp/stash/pull/1955))\n* Reworked main navbar and positioned at bottom for mobile devices. ([#1769](https://github.com/stashapp/stash/pull/1769))\n* Show files being deleted in the Delete dialogs. ([#1852](https://github.com/stashapp/stash/pull/1852))\n* Added specific page titles. ([#1831](https://github.com/stashapp/stash/pull/1831))\n* Show pagination at top and bottom of page. ([#1776](https://github.com/stashapp/stash/pull/1776))\n* Include total duration/megapixels and filesize information on Scenes and Images pages. ([#1776](https://github.com/stashapp/stash/pull/1776))\n* Optimised generate process. ([#1871](https://github.com/stashapp/stash/pull/1871))\n* Added clear button to query text field. ([#1845](https://github.com/stashapp/stash/pull/1845))\n* Moved Performer rating stars from details/edit tabs to heading section of performer page. ([#1844](https://github.com/stashapp/stash/pull/1844))\n* Optimised scanning process. ([#1816](https://github.com/stashapp/stash/pull/1816))\n\n### 🐛 Bug fixes\n* Fix tag hierarchy not being validated during tag creation. ([#1926](https://github.com/stashapp/stash/pull/1926))\n* Fix tag hierarchy validation incorrectly failing for some hierarchies. ([#1926](https://github.com/stashapp/stash/pull/1926))\n* Fix exclusion pattern fields losing focus on keypress. ([#1952](https://github.com/stashapp/stash/pull/1952))\n* Include stash ids in import/export. ([#1916](https://github.com/stashapp/stash/pull/1916))\n* Fix tiny menu items in scrape menu when a stash-box instance has no name. ([#1889](https://github.com/stashapp/stash/pull/1889))\n* Fix creating missing entities removing the incorrect entry from the missing list in the scrape dialog. ([#1890](https://github.com/stashapp/stash/pull/1890))\n* Allow creating missing Studio during movie scrape. ([#1899](https://github.com/stashapp/stash/pull/1899))\n* Fix image files in folder galleries not being deleting when delete file option is checked. ([#1872](https://github.com/stashapp/stash/pull/1872))\n* Fix marker generation task reading video files unnecessarily. ([#1871](https://github.com/stashapp/stash/pull/1871))\n* Fix accessing Stash via IPv6 link local address causing security tripwire to be activated. ([#1841](https://github.com/stashapp/stash/pull/1841))\n* Fix Twitter value defaulting to freeones in built-in Freeones scraper. ([#1853](https://github.com/stashapp/stash/pull/1853))\n* Fix colour codes not outputting correctly when logging to file on Windows. ([#1846](https://github.com/stashapp/stash/pull/1846))\n* Sort directory listings using case sensitive collation. ([#1823](https://github.com/stashapp/stash/pull/1823))\n* Fix auto-tag logic for names which have single-letter words. ([#1817](https://github.com/stashapp/stash/pull/1817))\n* Fix huge memory usage spike during clean task. ([#1805](https://github.com/stashapp/stash/pull/1805))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0120.md",
    "content": "### ✨ New Features\n* Changed query string parsing behaviour to require all words by default, with the option to `or` keywords and exclude keywords. See the `Browsing` section of the manual for details. ([#1982](https://github.com/stashapp/stash/pull/1982))\n* Show heatmaps and median stroke speed for interactive scenes on the scenes page. ([#2096](https://github.com/stashapp/stash/pull/2096))\n* Added selective clean task. ([#2125](https://github.com/stashapp/stash/pull/2125))\n* Added option to force generation of transcodes for selected scenes. ([#2126](https://github.com/stashapp/stash/pull/2126))\n* Save task options when scanning, generating and auto-tagging. ([#1949](https://github.com/stashapp/stash/pull/1949), [#2061](https://github.com/stashapp/stash/pull/2061))\n* Added forward jump 10 second button to video player. ([#1973](https://github.com/stashapp/stash/pull/1973))\n\n### 🎨 Improvements\n* Overhauled, restructured and added auto-save to the settings pages. ([#2086](https://github.com/stashapp/stash/pull/2086))\n* Added keyboard shortcuts to hide scene page sidebar and scene scrubber. ([#2099](https://github.com/stashapp/stash/pull/2099))\n* Added support for setting scrapers path in the settings page. ([#2124](https://github.com/stashapp/stash/pull/2124))\n* Made scene phash field in File Info tab a link to show duplicate scenes. ([#2154](https://github.com/stashapp/stash/pull/2154))\n* Rollback operation if files fail to be deleted. ([#1954](https://github.com/stashapp/stash/pull/1954))\n* Prefer right-most Studio match in the file path when autotagging. ([#2057](https://github.com/stashapp/stash/pull/2057))\n* Show Created/Updated dates in scene/image/gallery details pages. ([#2145](https://github.com/stashapp/stash/pull/2145))\n* Include path and hashes in destroy scene/image/gallery post hook input. ([#2102](https://github.com/stashapp/stash/pull/2102/files))\n* Added plugin hook for Tag merge operation. ([#2010](https://github.com/stashapp/stash/pull/2010))\n\n### 🐛 Bug fixes\n* Don't include audio in marker previews if Include Audio option is unchecked. ([#2101](https://github.com/stashapp/stash/pull/2101))\n* Include performer aliases when scraping from stash-box. ([#2091](https://github.com/stashapp/stash/pull/2091/files))\n* Remove empty folder-based galleries during clean. ([#1954](https://github.com/stashapp/stash/pull/1954))\n* Select first scene result in scene tagger where possible. ([#2051](https://github.com/stashapp/stash/pull/2051))\n* Reject dates with invalid format. ([#2052](https://github.com/stashapp/stash/pull/2052))\n* Fix Autostart Video on Play Selected and Continue Playlist default settings not working. ([#2050](https://github.com/stashapp/stash/pull/2050))\n* Fix \"Custom Performer Images\" feature picking up non-image files. ([#2017](https://github.com/stashapp/stash/pull/2017))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0130.md",
    "content": "### ✨ New Features\n* Added title, rating and o-counter in image lightbox. ([#2274](https://github.com/stashapp/stash/pull/2274))\n* Added option to hide scene scrubber by default. ([#2325](https://github.com/stashapp/stash/pull/2325))\n* Added support for bulk-editing movies. ([#2283](https://github.com/stashapp/stash/pull/2283))\n* Added support for filtering scenes, images and galleries featuring favourite performers and performer age at time of production. ([#2257](https://github.com/stashapp/stash/pull/2257))\n* Added support for filtering scenes with (or without) phash duplicates. ([#2257](https://github.com/stashapp/stash/pull/2257))\n* Added support for sorting scenes by phash. ([#2257](https://github.com/stashapp/stash/pull/2257))\n* Open stash in system tray on Windows/MacOS when not running via terminal. ([#2073](https://github.com/stashapp/stash/pull/2073))\n* Optionally send desktop notifications when a task completes. ([#2073](https://github.com/stashapp/stash/pull/2073))\n* Added button to image card to view image in Lightbox. ([#2275](https://github.com/stashapp/stash/pull/2275))\n* Added support for submitting performer/scene drafts to stash-box. ([#2234](https://github.com/stashapp/stash/pull/2234))\n\n### 🎨 Improvements\n* Removed generate options from Tasks -> Generate. These should be set in System -> Preview Generation instead. ([#2342](https://github.com/stashapp/stash/pull/2342))\n* Added gallery icon on Image cards. ([#2324](https://github.com/stashapp/stash/pull/2324))\n* Made Performer page consistent with Studio and Tag pages. ([#2200](https://github.com/stashapp/stash/pull/2200))\n* Added gender icons to performers. ([#2179](https://github.com/stashapp/stash/pull/2179))\n* Added button to test credentials when adding/editing stash-box endpoints. ([#2173](https://github.com/stashapp/stash/pull/2173))\n* Show counts on list tabs in Performer, Studio and Tag pages. ([#2169](https://github.com/stashapp/stash/pull/2169))\n\n### 🐛 Bug fixes\n* Fix Scrape All button not returning phash distance-matched results from stash-box. ([#2355](https://github.com/stashapp/stash/pull/2355))\n* Fix performer checksum not being updated when name updated via batch stash-box tag. ([#2345](https://github.com/stashapp/stash/pull/2345))\n* Fix studios/performers/tags with unicode characters not being auto-tagged. ([#2336](https://github.com/stashapp/stash/pull/2336))\n* Preview Generation now uses defaults defined in System settings unless overridden in the Generate options. ([#2328](https://github.com/stashapp/stash/pull/2328)) \n* Fix scraped performer tags being incorrectly applied to scene tags. ([#2339](https://github.com/stashapp/stash/pull/2339))\n* Fix performer tattoos incorrectly being applied to Twitter URL during batch performer tag. ([#2332](https://github.com/stashapp/stash/pull/2332))\n* Fix performer country not expanding from code when tagging from stash-box. ([#2323](https://github.com/stashapp/stash/pull/2323))\n* Fix image exclude regex not being honoured when scanning in zips. ([#2317](https://github.com/stashapp/stash/pull/2317))\n* Delete funscripts when deleting scene files. ([#2265](https://github.com/stashapp/stash/pull/2265))\n* Fix regex queries incorrectly being converted to lowercase. ([#2314](https://github.com/stashapp/stash/pull/2314))\n* Fix saved filters with URL encoded characters being incorrectly converted. ([#2301](https://github.com/stashapp/stash/pull/2301))\n* Removed trusted proxies setting. ([#2229](https://github.com/stashapp/stash/pull/2229))\n* Fix preview videos causing background media to stop on Android. ([#2254](https://github.com/stashapp/stash/pull/2254))\n* Allow Stash to be iframed. ([#2217](https://github.com/stashapp/stash/pull/2217))\n* Resolve CDP hostname if necessary. ([#2174](https://github.com/stashapp/stash/pull/2174))\n* Generate sprites for short video files. ([#2167](https://github.com/stashapp/stash/pull/2167))\n* Fix stash-box scraping including underscores in ethnicity. ([#2191](https://github.com/stashapp/stash/pull/2191))\n* Fix stash-box batch performer task not setting birthdate. ([#2189](https://github.com/stashapp/stash/pull/2189))\n* Fix error when scanning symlinks. ([#2196](https://github.com/stashapp/stash/issues/2196))\n* Fix timezone issue with Created/Updated dates in scene/image/gallery details pages. ([#2190](https://github.com/stashapp/stash/pull/2190))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0131.md",
    "content": "### 🐛 Bug fixes\n* Fix auto-tag not using case-insensitive matching. ([#2378](https://github.com/stashapp/stash/pull/2378))"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0140.md",
    "content": "##### 💥 Note: Image Slideshow Delay (in Interface Settings) is now in seconds rather than milliseconds and has not been converted. Please adjust your settings as needed.\n\n### ✨ New Features\n* Add Ignore Auto Tag flag to Performers, Studios and Tags. ([#2439](https://github.com/stashapp/stash/pull/2439))\n* Add python location in System Settings for script scrapers and plugins. ([#2409](https://github.com/stashapp/stash/pull/2409))\n\n### 🎨 Improvements\n* Added support for Handy APIv2. ([#2193](https://github.com/stashapp/stash/pull/2193))\n* Added support for bulk editing most performer fields. ([#2467](https://github.com/stashapp/stash/pull/2467))\n* Changed video player to videojs. ([#2100](https://github.com/stashapp/stash/pull/2100))\n* Maintain lightbox settings and add lightbox settings to Interface settings page. ([#2406](https://github.com/stashapp/stash/pull/2406))\n* Image lightbox now transitions to next/previous image when scrolling in pan-Y mode. ([#2403](https://github.com/stashapp/stash/pull/2403))\n* Allow customisation of UI theme color using `theme_color` property in `config.yml` ([#2365](https://github.com/stashapp/stash/pull/2365))\n* Improved autotag performance. ([#2368](https://github.com/stashapp/stash/pull/2368))\n\n### 🐛 Bug fixes\n* Fix existing performers being lost when setting performers in the scene tagger. ([#2478](https://github.com/stashapp/stash/issues/2478))\n* Fix scene fields being overwritten with empty values when saving from the scene tagger. ([#2461](https://github.com/stashapp/stash/pull/2461))\n* Fix Is Missing Date filter not including null date values. ([#2434](https://github.com/stashapp/stash/pull/2434))\n* Fix Open Stash systray menu item not opening stash when Skip Opening Browser was enabled. ([#2418](https://github.com/stashapp/stash/pull/2418))\n* Fix error saving a scene from the tagger when the scene has stash ids. ([#2408](https://github.com/stashapp/stash/pull/2408))\n* Perform tag pattern exclusion on stash-box sources. ([#2391](https://github.com/stashapp/stash/pull/2391))\n* Don't generate jpg thumbnails for animated webp files. ([#2388](https://github.com/stashapp/stash/pull/2388))\n* Removed warnings and incorrect error message in json scrapers. ([#2375](https://github.com/stashapp/stash/pull/2375))\n* Ensure identify continues using other scrapers if a scrape returns no results. ([#2375](https://github.com/stashapp/stash/pull/2375))\n* Continue trying to identify scene if scraper fails. ([#2375](https://github.com/stashapp/stash/pull/2375))"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0150.md",
    "content": "### ✨ New Features\n* Show Handy status on scene player where applicable. ([#2555](https://github.com/stashapp/stash/pull/2555))\n* Added recommendations to home page. ([#2571](https://github.com/stashapp/stash/pull/2571))\n* Add support for VTT and SRT captions for scenes. ([#2462](https://github.com/stashapp/stash/pull/2462))\n* Added option to require a number of scroll attempts before navigating to next/previous image in Lightbox. ([#2544](https://github.com/stashapp/stash/pull/2544))\n\n### 🎨 Improvements\n* Added Handy server sync button to Interface settings page. ([#2555](https://github.com/stashapp/stash/pull/2555))\n* Changed playback rate options to be the same as those provided by YouTube. ([#2550](https://github.com/stashapp/stash/pull/2550))\n* Display error message on fatal error when running stash with double-click in Windows. ([#2543](https://github.com/stashapp/stash/pull/2543))\n\n### 🐛 Bug fixes\n* Fix gallery zip files being rescanned unnecessarily. ([#2594](https://github.com/stashapp/stash/pull/2594))\n* Fix long Handy initialisation delay. ([#2555](https://github.com/stashapp/stash/pull/2555))\n* Fix lightbox autoplaying while offscreen. ([#2563](https://github.com/stashapp/stash/pull/2563))\n* Fix playback rate resetting when seeking. ([#2550](https://github.com/stashapp/stash/pull/2550))\n* Fix video not starting when clicking scene scrubber. ([#2546](https://github.com/stashapp/stash/pull/2546))\n* Update vtt files when scene hash changes. ([#2554](https://github.com/stashapp/stash/pulls?q=is%3Apr+is%3Aclosed))\n* Don't break up preview video into segments for shorter scenes. ([#2553](https://github.com/stashapp/stash/pull/2553))\n* Fix parsing query URLs when query string contains special characters. ([#2552](https://github.com/stashapp/stash/pull/2552))\n* Fix crash when cancelling pending tasks. ([#2527](https://github.com/stashapp/stash/pull/2527))\n* Fix markers not refreshing after creating new marker. ([#2502](https://github.com/stashapp/stash/pull/2502))\n* Fix error when submitting scene draft to stash-box without performers. ([#2515](https://github.com/stashapp/stash/pull/2515))\n* Fix incorrect video player positioning on touch-enabled devices. ([#2501](https://github.com/stashapp/stash/issues/2501))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0160.md",
    "content": "### ✨ New Features\n* Added hotkeys to scrub scene by 10% duration. ([#2678](https://github.com/stashapp/stash/pull/2678))\n* Added support for customizing recommendations on home page. ([#2592](https://github.com/stashapp/stash/pull/2592))\n* Support submitting stash-box scene updates for scenes with stash ids. ([#2577](https://github.com/stashapp/stash/pull/2577))\n\n### 🎨 Improvements\n* Moved Filter and Saved Filters buttons out of the query input field. ([#2668](https://github.com/stashapp/stash/pull/2668))\n\n### 🐛 Bug fixes\n* Fix fields disappearing after creating missing objects in the scrape dialog. ([#2702](https://github.com/stashapp/stash/pull/2702))\n* Fix saved filters with uppercase characters not appearing in filtered results. ([#2698](https://github.com/stashapp/stash/pull/2698))\n* Fix query results not updating when clearing search query field. ([#2686](https://github.com/stashapp/stash/pull/2686))\n* Fix incorrect field name in movie export json. ([#2664](https://github.com/stashapp/stash/pull/2664))\n* Fix ffprobe showing window on some systems. ([#2685](https://github.com/stashapp/stash/pull/2685))\n* Fix portrait videos orienting incorrectly in full-screen mode. ([#2665](https://github.com/stashapp/stash/pull/2665)) \n* Fix scene scrubber stopping scrolling after editing scene or marker. ([#2600](https://github.com/stashapp/stash/pull/2600))\n* Fix folder-based galleries not auto-tagging correctly if folder name contains `.` characters. ([#2658](https://github.com/stashapp/stash/pull/2658))\n* Fix scene cover in scene edit panel not being updated when changing scenes. ([#2657](https://github.com/stashapp/stash/pull/2657))\n* Fix moved gallery zip files not being rescanned. ([#2611](https://github.com/stashapp/stash/pull/2611))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0161.md",
    "content": "### 🐛 Bug fixes\n* Fix New button not being localised correctly. ([#2772](https://github.com/stashapp/stash/pull/2772))\n* Fix scene player losing focus when playing next/previous scene. ([#2758](https://github.com/stashapp/stash/pull/2758))\n* Fix UI crash when % character used in tag names. ([#2757](https://github.com/stashapp/stash/pull/2757))\n* Fix keyboard shortcuts not working after selecting an object. ([#2750](https://github.com/stashapp/stash/pull/2750))\n* Fix UI crash on session timeout. ([#2755](https://github.com/stashapp/stash/pull/2755))\n* Fix incorrect scene metadata being set when video has cover art. ([#2752](https://github.com/stashapp/stash/pull/2752))\n* Fix incorrect image being displayed when first previewing image. ([#2754](https://github.com/stashapp/stash/pull/2754))\n* Fix issues with multi-edit behaviour. ([#2754](https://github.com/stashapp/stash/pull/2724))\n* Fix UI crash after upgrading with pending fingerprints. ([#2754](https://github.com/stashapp/stash/pull/2724))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0170.md",
    "content": "After migrating, please run a scan on your entire library to populate missing data, and to ingest identical files which were previously ignored.\n\n### 💥 Known issues and other changes\n* Import/export schema has changed and is incompatible with the previous version.\n* `Set name, date, details from embedded file metadata` scan flag is no longer supported. This functionality may be implemented as a built-in scraper in the future.\n\n### ✨ New Features\n* Added support for identical files. Identical files are assigned to the same scene/gallery/image and can be viewed in File Info. ([#2676](https://github.com/stashapp/stash/pull/2676))\n* Added support for setting primary file for scenes, images and galleries. ([#2790](https://github.com/stashapp/stash/pull/2790))\n* Added support for deleting secondary files from scenes, images and galleries. ([#2790](https://github.com/stashapp/stash/pull/2790))\n* Added support for filtering and sorting by file count. ([#2744](https://github.com/stashapp/stash/pull/2744))\n* Added description field to Tags. ([#2708](https://github.com/stashapp/stash/pull/2708))\n* Added Interface option to abbreviate counts on cards and details pages. ([#2781](https://github.com/stashapp/stash/pull/2781))\n* Added Interface options to include sub-studio/sub-tag content in Studio/Tag pages. ([#2832](https://github.com/stashapp/stash/pull/2832))\n* Populate name from query field when creating new performer/studio/tag/gallery. ([#2701](https://github.com/stashapp/stash/pull/2701))\n* Added backup location configuration setting. ([#2953](https://github.com/stashapp/stash/pull/2953))\n* Allow overriding UI localisation strings. ([#2837](https://github.com/stashapp/stash/pull/2837))\n* Added release notes dialog. ([#2726](https://github.com/stashapp/stash/pull/2726))\n\n### 🎨 Improvements\n* **[0.17.1]** Added Ukrainian language option.\n* Object titles are now displayed as the file basename if the title is not explicitly set. The `Don't include file extension as part of the title` scan flag is no longer supported.\n* Optionally show Tag card when hovering over tag badge. ([#2708](https://github.com/stashapp/stash/pull/2708))\n* Show default thumbnails for scenes and images where the actual image is not found. ([#2949](https://github.com/stashapp/stash/pull/2949))\n* Added unix timestamp parsing in the `parseDate` scraper post processor. ([#2817](https://github.com/stashapp/stash/pull/2817))\n* Improve matching scene order in the tagger to prioritise matching phashes and durations. ([#2840](https://github.com/stashapp/stash/pull/2840)) \n* Encode reserved characters in query URLs. ([#2899](https://github.com/stashapp/stash/pull/2899))\n* Moved Changelogs to Settings page. ([#2726](https://github.com/stashapp/stash/pull/2726))\n\n### 🐛 Bug fixes\n* **[0.17.2]** Fix file rename detection on case-insensitive file systems. ([#3047](https://github.com/stashapp/stash/pull/3047))\n* **[0.17.2]** Fix size calculation for symlinks. ([#3046](https://github.com/stashapp/stash/pull/3046))\n* **[0.17.2]** Use file base name as title if title is empty in scraper operations. ([#3040](https://github.com/stashapp/stash/pull/3040))\n* **[0.17.2]** Fix error when submitting fingerprints from deleted scene. ([#3039](https://github.com/stashapp/stash/pull/3039))\n* **[0.17.2]** Fix moved zip file creating duplicate galleries. ([#3036](https://github.com/stashapp/stash/pull/3036))\n* **[0.17.1]** Fix Windows exporting incompatible zip files. ([#3022](https://github.com/stashapp/stash/pull/3022))\n* **[0.17.1]** Fix migration error handling various NULL values. ([#3021](https://github.com/stashapp/stash/pull/3021))\n* **[0.17.1]** Updated translations missed during release.\n* Fix live transcoded videos hanging at end. ([#2996](https://github.com/stashapp/stash/pull/2996))\n* Fix display of scene markers when title is empty. ([#2994](https://github.com/stashapp/stash/pull/2994))\n* Fix tag marker count sorting. ([#2993](https://github.com/stashapp/stash/pull/2993))\n* Fix studio/tag alias and caption null filtering. ([#2990](https://github.com/stashapp/stash/pull/2990))\n* Fix generated file naming algorithm being set incorrectly in certain circumstances. ([#2496](https://github.com/stashapp/stash/pull/2946))\n* Fix continue queue checkbox value not persisting. ([#2895](https://github.com/stashapp/stash/pull/2895))\n* Fix `autostartVideoOnPlaySelected` option not applying when navigating from scene queue. ([#2896](https://github.com/stashapp/stash/pull/2896))\n* Fix incorrect gallery value in Scene edit tab after navigating from scene queue. ([#2897](https://github.com/stashapp/stash/pull/2897))\n* Fix https schema not being used over some https connections. ([#2900](https://github.com/stashapp/stash/pull/2900))\n* Fix scene files not deleting correctly when streaming over https. ([#2900](https://github.com/stashapp/stash/pull/2900))\n* Fix panic when custom performer image location is invalid. ([#2894](https://github.com/stashapp/stash/pull/2894))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0180.md",
    "content": "### 💥 Known issues\n* Performer autotagging does not currently match on performer aliases. This will be addressed when finer control over the matching is implemented.\n\n### ✨ New Features\n* Added disambiguation field to Performers, to differentiate between performers with the same name. ([#3113](https://github.com/stashapp/stash/pull/3113))\n* Added ability to track play count and duration for scenes. ([#3055](https://github.com/stashapp/stash/pull/3055))\n* Scenes now optionally show the last point watched, and can be resumed from that point. ([#3055](https://github.com/stashapp/stash/pull/3055))\n* Added support for filtering stash ids by endpoint. ([#3005](https://github.com/stashapp/stash/pull/3005))\n* Added ability to select rating system in the Interface settings, allowing 5 stars with full-, half- or quarter-stars, or numeric score out of 10 with one decimal point. ([#2830](https://github.com/stashapp/stash/pull/2830))\n* Support creation of scenes without files. ([#3006](https://github.com/stashapp/stash/pull/3006))\n* Added ability to reassign files to other scenes. ([#3006](https://github.com/stashapp/stash/pull/3006))\n* Added ability to split and merge scenes. ([#3006](https://github.com/stashapp/stash/pull/3006))\n* Added Director and Studio Code fields to scenes. ([#3051](https://github.com/stashapp/stash/pull/3051))\n* Added custom javascript option. ([#3132](https://github.com/stashapp/stash/pull/3132))\n* Added filter criteria for Birthdate, Death Date, Date, Created At and Updated At fields. ([#2834](https://github.com/stashapp/stash/pull/2834))\n* Added selector for Country field. ([#1922](https://github.com/stashapp/stash/pull/1922))\n* Added tag description filter criterion. ([#3011](https://github.com/stashapp/stash/pull/3011))\n\n### 🎨 Improvements\n* Changed performer aliases to be a list, rather than a string field. ([#3113](https://github.com/stashapp/stash/pull/3113))\n* Jump back/forward buttons on mobile have been replaced with double-tap gestures on mobile. ([#3120](https://github.com/stashapp/stash/pull/3120))\n* Added shift- and ctrl-keybinds for seeking for shorter and longer intervals, respectively. ([#3120](https://github.com/stashapp/stash/pull/3120))\n* Limit number of items in selector drop-downs to 200. ([#3062](https://github.com/stashapp/stash/pull/3062))\n* Changed Performer height to be numeric, and changed filtering accordingly. ([#3060](https://github.com/stashapp/stash/pull/3060))\n* Improved performance viewing galleries with many images. ([#3183](https://github.com/stashapp/stash/pull/3183))\n* Generated heatmaps now only show ranges within the duration of the scene. ([#3182](https://github.com/stashapp/stash/pull/3182))\n* Added File Modification Time to File Info panels. ([#3054](https://github.com/stashapp/stash/pull/3054))\n* Added counter to File Info tabs for objects with multiple files. ([#3054](https://github.com/stashapp/stash/pull/3054))\n* Added file count in Scene Duplicate Checker for scenes with multiple files. ([#3054](https://github.com/stashapp/stash/pull/3054))\n* Also show imperial units for performer height and weight. ([#3097](https://github.com/stashapp/stash/pull/3097))\n* Added Estonian and Russian Language translations. Added in-progress Languages for Persian, Ukrainian, Bengali, Thai, Romainian, Hungarian, and Czech.([#3024] (https://github.com/stashapp/stash/pull/3024))\n\n### 🐛 Bug fixes\n* Fixed `database is locked` errors when performing operations while running a scan. ([#3153](https://github.com/stashapp/stash/pull/3153))\n* Fixed hang when deleting scene when video has started playing in Firefox. ([#3169](https://github.com/stashapp/stash/pull/3169))\n* Fixed database backup in incorrect directory during migration when database location is an absolute path. ([#3140](https://github.com/stashapp/stash/pull/3140))\n* Fixed autotag error when tagging a large amount of objects. ([#3106](https://github.com/stashapp/stash/pull/3106))\n* Scene Player no longer always resumes playing when seeking. ([#3020](https://github.com/stashapp/stash/pull/3020))\n* Fixed error when editing paths when metadata directory is overridden. ([#3212](https://github.com/stashapp/stash/pull/3212))\n* Fixed sort direction sometimes not being set when selecting a saved filter. ([#3206](https://github.com/stashapp/stash/pull/3206))\n* Fixed gallery create post hook not being fired during gallery creation. ([#3134](https://github.com/stashapp/stash/pull/3134))\n* Fixed Gallery title being incorrectly marked as mandatory for file- and folder-based galleries. ([#3110](https://github.com/stashapp/stash/pull/3110))\n* Fixed Saved Filters not ordered by name. ([#3101](https://github.com/stashapp/stash/pull/3101))\n* Fixed space bar sometimes no playing/pausing the scene player. ([#3020](https://github.com/stashapp/stash/pull/3020))\n* Fixed scrubber thumbnails not disappearing when seeking on mobile. ([#3020](https://github.com/stashapp/stash/pull/3020))\n* Fixed path filter behaviour to be consistent with previous behaviour. ([#3041](https://github.com/stashapp/stash/pull/3041))\n* Fixed `index.html` not correctly served from custom mapped folders. ([#3168](https://github.com/stashapp/stash/pull/3168))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0190.md",
    "content": "### 💥 Known issues\n* Performer autotagging does not currently match on performer aliases. This will be addressed when finer control over the matching is implemented.\n\n### ✨ New Features\n* Added support for specifying the use of a proxy for network requests. ([#3284](https://github.com/stashapp/stash/pull/3284))\n* Added support for injecting arguments into `ffmpeg` during generation and live-transcoding. ([#3216](https://github.com/stashapp/stash/pull/3216))\n* Added URL and Date fields to Images. ([#3015](https://github.com/stashapp/stash/pull/3015))\n* Added support for plugins to add injected CSS and Javascript to the UI. ([#3195](https://github.com/stashapp/stash/pull/3195))\n* Added disambiguation field to Performers, to differentiate between performers with the same name. ([#3113](https://github.com/stashapp/stash/pull/3113))\n* Added Anonymise task to generate an anonymised version of the database. ([#3186](https://github.com/stashapp/stash/pull/3186))\n\n### 🎨 Improvements\n* Added `r x x` keyboard shortcuts to set decimal ratings. ([#3226](https://github.com/stashapp/stash/pull/3226))\n* Changed performer aliases to be a list, rather than a string field. ([#3113](https://github.com/stashapp/stash/pull/3113))\n\n### 🐛 Bug fixes\n* **[0.19.1]** Fixed performance issues with Scene Tagger view. ([#3444](https://github.com/stashapp/stash/pull/3444), [#3452](https://github.com/stashapp/stash/pull/3452))\n* **[0.19.1]** Fixed panic when batch adding performers from the Tagger view. ([#3456](https://github.com/stashapp/stash/pull/3456))\n* Fixed folder symlinks not being handled correctly during clean. ([#3415](https://github.com/stashapp/stash/pull/3415))\n* Fixed error when clicking Scrape All when a file-less scene is in the scene list. ([#3414](https://github.com/stashapp/stash/pull/3414))\n* Fixed clicking popover pills not clearing search term. ([#3408](https://github.com/stashapp/stash/pull/3408))\n* Fixed URL not being preserved when redirected to login. ([#3305](https://github.com/stashapp/stash/pull/3305))\n* Fixed scene previews not being overwritten when Overwrite option is selected. ([#3256](https://github.com/stashapp/stash/pull/3256))\n* Fixed objects without titles not being sorted correctly with objects with titles. ([#3244](https://github.com/stashapp/stash/pull/3244))\n* Fixed incorrect new Performer pill being removed when creating Performer from scrape dialog. ([#3251](https://github.com/stashapp/stash/pull/3251))\n* Fixed date fields not being nulled correctly when cleared. ([#3243](https://github.com/stashapp/stash/pull/3243))\n* Fixed scene wall items to show file base name where scene has no title set. ([#3242](https://github.com/stashapp/stash/pull/3242))\n* Fixed image exclusion pattern being applied to all files. ([#3241](https://github.com/stashapp/stash/pull/3241))\n* Fixed missing captions not being removed during scan. ([#3240](https://github.com/stashapp/stash/pull/3240))"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v020.md",
    "content": "#### 💥 **Note: After upgrading performance will be degraded until a full [scan](/settings?tab=tasks) has been completed.**\n#### 💥 **Note: [Language](/settings?tab=interface) has been set to \\`English (United States)\\` by default, which affects number and date formatting.**\n\n&nbsp;\n### ✨ New Features\n*  Movies are now supported.\n*  Responsive layout for mobile phones.\n*  Add support for image scraping.\n*  Allow user to regenerate scene cover based on timestamp.\n*  Autoassociate galleries to scenes when scanning.\n*  Configurable scraper user agent string.\n*  Backup database if a migration is needed.\n*  Add modes for performer/tag for bulk scene editing.\n*  Add gender support for performer.\n*  Add SVG studio image support, and studio image caching.\n*  Enable sorting for galleries.\n*  Add scene rating to scene filename parser.\n*  Replace basic auth with cookie authentication.\n*  Add detection of container/video_codec/audio_codec compatibility for live file streaming or transcoding.\n*  Move image with cover.jpg in name to first place in Galleries.\n*  Add \"reshuffle button\" when sortby is random.\n*  Implement clean for missing galleries.\n*  Add parser support for 3-letter month.\n*  Add is-missing tags filter.\n\n### 🎨 Improvements\n*  Performance improvements and improved video support.\n*  Support for localized text, dates and numbers.\n*  Replace Blueprint with react-bootstrap.\n*  Add image count to gallery list.\n*  Add library size to main stats page.\n*  Add slim endpoints for entities to speed up filters.\n*  Export performance optimization.\n*  Add random male performer image.\n*  Added various missing filters to performer page.\n*  Add index/total count to end of pagination buttons.\n*  Add flags for performer countries.\n*  Overhaul look and feel of folder select.\n*  Add cache for gallery thumbnails.\n*  Add changelog to start page.\n*  Include subdirectories when searching for scraper configurations.\n*  Add debug logging for xpath scraping to assist scraper development.\n*  Encode pasted images to jpeg.\n*  Allow selection of wall preview type: video, animated image and static image.\n*  Localize dates and numbers.\n\n### 🐛 Bug fixes\n*  Update performer image in UI when it's replaced.\n*  Fix performer height filter.\n*  Fix error when viewing scenes related to objects with illegal characters in name.\n*  Make ethnicity freetext and fix freeones ethnicity panic.\n*  Delete marker preview on marker change or delete.\n*  Include scene o-counter in import/export.\n*  Make image extension check in zip files case insensitive.\n*  Fix incorrect stash directory setting when directory has spaces.\n*  Update built-in Freeones scraper for new API.\n*  Fix redirect loops in login, migrate and setup pages.\n*  Make studio, movies, tag, performers scrape/parser matching case insensitive.\n*  Fix files with special characters in filename not being scanned.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0200.md",
    "content": "##### 💥 Note: The cache directory is now required if using HLS/DASH streaming. Please set the cache directory in the System Settings page.\n\n##### 💥 Note: The image data subsystem has been reworked in this release. Existing systems will have their storage system set to `Database`, which stores all image data in the database. This can be changed in the System Settings page. \n\nA migration is required to change the storage system, and can be accessed from the Tasks page.\n\nThe `Database` storage system is not recommended for large libraries, as it can cause performance issues. The `Filesystem` storage system is recommended for large libraries, and is the default for new systems.\n\n##### 💥 Note: the `generated/screenshots` jpg files are now considered legacy. These files can be migrated into the blob storage system by running the `Migrate Screenshots` task from the Tasks page. \n\nOnce migrated, these files can be deleted. The files can be optionally deleted during the migration.\n\n### ✨ New Features\n* Added `Is Missing Cover` scene filter criterion. ([#3187](https://github.com/stashapp/stash/pull/3187))\n* Added Chapters to Galleries. ([#3289](https://github.com/stashapp/stash/pull/3289))\n* Added button to tagger scene cards to view scene sprite. ([#3536](https://github.com/stashapp/stash/pull/3536))\n* Added hardware acceleration support (for a limited number of encoders) for transcoding. ([#3419](https://github.com/stashapp/stash/pull/3419))\n* Added support for DASH streaming. ([#3275](https://github.com/stashapp/stash/pull/3275))\n* Added configuration option for the maximum number of items in selector drop-downs. ([#3277](https://github.com/stashapp/stash/pull/3277))\n* Added configuration option to perform generation operations sequentially after scanning a new video file. ([#3378](https://github.com/stashapp/stash/pull/3378))\n* Optionally show range in generated funscript heatmaps. ([#3373](https://github.com/stashapp/stash/pull/3373))\n* Show funscript heatmaps in scene player scrubber. ([#3374](https://github.com/stashapp/stash/pull/3374))\n* Support customising the filename regex used for determining the gallery cover image. ([#3391](https://github.com/stashapp/stash/pull/3391))\n* Added tenth-place rating precision option. ([#3343](https://github.com/stashapp/stash/pull/3343))\n* Added toggleable favorite button to Performer cards. ([#3369](https://github.com/stashapp/stash/pull/3369))\n\n### 🎨 Improvements\n* Added date/time pickers for date and timestamp fields. ([#3572](https://github.com/stashapp/stash/pull/3572))\n* Added folder browser to path filter UI. ([#3570](https://github.com/stashapp/stash/pull/3570))\n* Include Organized flag in merge dialog. ([#3565](https://github.com/stashapp/stash/pull/3565))\n* Scene cover generation is now optional during scanning, and can be generated using the Generate task. ([#3187](https://github.com/stashapp/stash/pull/3187))\n* Overhauled the image blob storage system and added filesystem-based blob storage. ([#3187](https://github.com/stashapp/stash/pull/3187))\n* Overhauled filtering interface to allow setting filter criteria from a single dialog. ([#3515](https://github.com/stashapp/stash/pull/3515))\n* Removed upper limit on page size. ([#3544](https://github.com/stashapp/stash/pull/3544))\n* Anonymise task now obfuscates Marker titles. ([#3542](https://github.com/stashapp/stash/pull/3542))\n* Improved Images wall view layout and added Interface settings to adjust the layout. ([#3511](https://github.com/stashapp/stash/pull/3511))\n* Added collapsible divider to Gallery, Performer, Studio and Tag pages. ([#3508](https://github.com/stashapp/stash/pull/3508), [#3514](https://github.com/stashapp/stash/pull/3514))\n* Overhauled and improved HLS streaming. ([#3274](https://github.com/stashapp/stash/pull/3274))\n\n### 🐛 Bug fixes\n* **[0.20.2]** Fixed empty strings being preferred in scrape dialog. ([#3647](https://github.com/stashapp/stash/pull/3647))\n* **[0.20.2]** Fixed scene covers being regenerated when video file was moved. ([#3646](https://github.com/stashapp/stash/pull/3646))\n* **[0.20.1]** Fixed null values being preferred in scrape dialog. ([#3621](https://github.com/stashapp/stash/pull/3621))\n* Fixed login screen not working correctly from the logout screen. ([#3555](https://github.com/stashapp/stash/pull/3555))\n* Fixed incorrect stash ID being overwritten when updating performer with multiple stash-box endpoints. ([#3543](https://github.com/stashapp/stash/pull/3543)\n* Fixed batch performer update overwriting incorrect stash IDs when multiple endpoints are configured. ([#3548](https://github.com/stashapp/stash/pull/3548))\n* Fixed `/stream` endpoint serving directory list. ([#3541](https://github.com/stashapp/stash/pull/3541))\n* Fixed error when querying with a large or unlimited page size. ([#3544](https://github.com/stashapp/stash/pull/3544))\n* Fixed sprites not being displayed for scenes with numeric-only hashes. ([#3513](https://github.com/stashapp/stash/pull/3513))\n* Fixed Save button being disabled when setting Tag image. ([#3509](https://github.com/stashapp/stash/pull/3509))\n* Fixed incorrect performer with identical name being matched when scraping from stash-box. ([#3488](https://github.com/stashapp/stash/pull/3488))\n* Fixed scene cover not being included when submitting file-less scenes to stash-box. ([#3465](https://github.com/stashapp/stash/pull/3465))\n* Fixed URL not being during stash-box scrape if the Studio URL is not set. ([#3439](https://github.com/stashapp/stash/pull/3439))\n* Fixed generating previews for variable frame rate videos. ([#3376](https://github.com/stashapp/stash/pull/3376))\n* Fixed errors reading zip files with non-UTF8 encoding. ([#3389](https://github.com/stashapp/stash/pull/3389))\n* Fixed plugins not able to access API during zip scan operations on systems with authentication enabled. ([#3433](https://github.com/stashapp/stash/pull/3433))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v021.md",
    "content": "### 🐛 Bug fixes\n*  Fix max loop duration not working.\n*  Fix URL sanitization on non-Chrome browsers.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0210.md",
    "content": "### ✨ New Features\n* Added VR button to the scene player when the scene tag includes a configurable tag. ([#3636](https://github.com/stashapp/stash/pull/3636))\n* Added ability to include and exclude performers, studios and tags in the same filter. ([#3619](https://github.com/stashapp/stash/pull/3619))\n* Added penis length and circumcision status for Performers. ([#3627](https://github.com/stashapp/stash/pull/3627))\n* Added text field to search criteria in the edit filter dialog. ([#3740](https://github.com/stashapp/stash/pull/3740))\n* Added ability to add (short) video files as images. ([#3583](https://github.com/stashapp/stash/pull/3583))\n* Added ability to force gallery creation by adding `.forcegallery` to directory. ([#3715](https://github.com/stashapp/stash/pull/3715))\n* Added ability to ignore gallery creation by adding `.nogallery` to directory. ([#3715](https://github.com/stashapp/stash/pull/3715))\n* Added Maximum Duration Difference option to the Duplicate Scene Checker. ([#3663](https://github.com/stashapp/stash/pull/3663))\n* Added ability to configure the default sort order for videos served by DLNA. ([#3645](https://github.com/stashapp/stash/pull/3645))\n* Support pinning filter criteria to the top of the edit filter page. ([#3675](https://github.com/stashapp/stash/pull/3675))\n* Added Appears With tab to Performer page showing other performers that appear in the same scenes. ([#3563](https://github.com/stashapp/stash/pull/3563))\n* Added derived Performer O-Counter field. ([#3588](https://github.com/stashapp/stash/pull/3588))\n* Added distance parameter to phash filter. ([#3596](https://github.com/stashapp/stash/pull/3596))\n\n### 🎨 Improvements\n* Gallery Updated At timestamp is now updated when its contents are changed. ([#3771](https://github.com/stashapp/stash/pull/3771))\n* Added male performer images that are consistent with the other performer images. ([#3770](https://github.com/stashapp/stash/pull/3770))\n* Improved the UX when navigating the edit filter dialog using keyboard. ([#3739](https://github.com/stashapp/stash/pull/3739))\n* Changed modifier selector to a set of clickable pills. ([#3598](https://github.com/stashapp/stash/pull/3598))\n* Movie covers can now be shown in the Lightbox when clicking on them. ([#3705](https://github.com/stashapp/stash/pull/3705))\n* Scrapers are now sorted by name in the Scraper UI. ([#3691](https://github.com/stashapp/stash/pull/3691))\n* Changed source selector menu to require click instead of mouseover. ([#3578](https://github.com/stashapp/stash/pull/3578))\n* Updated default studio icon to be consistent with other icons. ([#3577](https://github.com/stashapp/stash/pull/3577))\n* Make cards use up the full width of the screen on mobile. ([#3576](https://github.com/stashapp/stash/pull/3576))\n* Log errors when a graphql request fails. ([#3562](https://github.com/stashapp/stash/pull/3562))\n* Use case insensitive sorting for text based sorting. ([#3560](https://github.com/stashapp/stash/pull/3560))\n* Default date sorting in descending order. ([#3560](https://github.com/stashapp/stash/pull/3560))\n\n### 🐛 Bug fixes\n* Fixed captions not appearing on iOS devices. ([#3729](https://github.com/stashapp/stash/pull/3729))\n* Fixed folder selector appearing for name criterion. ([#3788](https://github.com/stashapp/stash/pull/3788))\n* Fixed generation of interactive heatmaps to match scene duration. ([#3758](https://github.com/stashapp/stash/pull/3758))\n* Fixed incorrect plugin hook being triggered during bulk performer update. ([#3754](https://github.com/stashapp/stash/pull/3754))\n* Fixed error when removing file over network on Windows. ([#3714](https://github.com/stashapp/stash/pull/3714))\n* Fixed scene cards being sized incorrectly on the front page. ([#3724](https://github.com/stashapp/stash/pull/3724))\n* Fixed hair colour not being populated during Batch Update Performers. ([#3718](https://github.com/stashapp/stash/pull/3718)) \n* Fixed Create Missing checkbox not appearing in the Identify dialog. ([#3260](https://github.com/stashapp/stash/issues/3260))\n* Fixed override option not being honoured when generating scene covers. ([#3661](https://github.com/stashapp/stash/pull/3661))\n* Fixed error when creating a movie in the scrape scene dialog. ([#3633](https://github.com/stashapp/stash/pull/3633))\n* Fixed issues when scanning a renamed zip file. ([#3610](https://github.com/stashapp/stash/pull/3579))\n* Fixed incorrect Twitter/Instagram URLs sent to stash-box. ([#3579](https://github.com/stashapp/stash/pull/3579))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0220.md",
    "content": "### ✨ New Features\n* Added Studio Tagger. ([#3510](https://github.com/stashapp/stash/pull/3510))\n* Added options to skip multiple results and single name performers during Identify. ([#3707](https://github.com/stashapp/stash/pull/3707))\n* Added folder move detection during scan. ([#3817](https://github.com/stashapp/stash/pull/3817))\n* Changed Scene to accept multiple URLs. ([#3852](https://github.com/stashapp/stash/pull/3852))\n* Added ability to choose from multiple images in the performer scrape dialog. ([#3965](https://github.com/stashapp/stash/pull/3965))\n* Added AirPlay and Chromecast support. ([#3907](https://github.com/stashapp/stash/pull/3907))\n* Added support for creating Movies from the Movie select dropdown. ([#3928](https://github.com/stashapp/stash/pull/3928))\n* Added Optimise Database task. ([#3929](https://github.com/stashapp/stash/pull/3929))\n* Added support for serving interactive CSVs directly to Handy. ([#3756](https://github.com/stashapp/stash/pull/3756))\n* Added video and audio codec filters for scene queries. ([#3843](https://github.com/stashapp/stash/pull/3843))\n\n### 🎨 Improvements\n* Significantly redesigned the Movie, Performer, Studio, and Tag detail pages. ([#3946](https://github.com/stashapp/stash/pull/3946))\n* Added age, gender, country and image to Performer stash-box results. ([#3964](https://github.com/stashapp/stash/pull/3964))\n* Refactored graphql client cache invalidation to improve performance when creating objects. ([#3912](https://github.com/stashapp/stash/pull/3912))\n* Added Gallery card to the Gallery tab on the Scene page. ([#3927](https://github.com/stashapp/stash/pull/3927))\n* Added logging when deleting files. ([#4004](https://github.com/stashapp/stash/pull/4004))\n* Added more stats to the stats page. ([#3812](https://github.com/stashapp/stash/pull/3812))\n* Added support for `-v/--version` command line flag. ([#3883](https://github.com/stashapp/stash/pull/3883))\n\n### 🐛 Bug fixes\n* **[0.22.1]** Fixed Batch Update Performers not working correctly. ([#4024](https://github.com/stashapp/stash/pull/4024))\n* **[0.22.1]** Fixed panic when creating Studios during Identify task. ([#4024](https://github.com/stashapp/stash/pull/4024))\n* **[0.22.1]** Added explicit option to store blobs in database at setup, and fixed default blobs path. ([#4038](https://github.com/stashapp/stash/pull/4038))\n* **[0.22.1]** Fixed dropdown appearing beneath other controls on the Performer and Tag pages. ([#4039](https://github.com/stashapp/stash/pull/4039))\n* **[0.22.1]** Fixed buttons moving around when setting marker time when creating a new marker. ([#4040](https://github.com/stashapp/stash/pull/4040))\n* Fixing sorting of performer tags. ([#4018](https://github.com/stashapp/stash/pull/4018))\n* Fixed scene URLs being cleared when merging scenes. ([#4005](https://github.com/stashapp/stash/pull/4005))\n* Fixed setting the Create Missing flag in the Identify dialog not working. ([#4008](https://github.com/stashapp/stash/pull/4008))\n* Fixed scene marker previews not being renamed when a file hash is changed. ([#3988](https://github.com/stashapp/stash/pull/3988))\n* Fixed parent/child tags not showing popover. ([#3968](https://github.com/stashapp/stash/pull/3968))\n* Fixed scrape not returning any results if only relationship fields were set. ([#3954](https://github.com/stashapp/stash/pull/3954))\n* Fixed rotating in iOS causing scene player to exit fullscreen. ([#3919](https://github.com/stashapp/stash/pull/3919))\n* Repositioned the tag popover to the right on drop-down tag select control. ([#3939](https://github.com/stashapp/stash/pull/3939))\n* Fixed on-screen controls not showing correctly on iPad in Safari. ([#3882](https://github.com/stashapp/stash/pull/3882))\n* Fixed marker tag exclude filtering. ([#3846](https://github.com/stashapp/stash/pull/3846))\n* Fixed error after deleting studio or tag. ([#3835](https://github.com/stashapp/stash/pull/3835))\n* Fixed scene queue show more button appending scenes incorrectly. ([#3851](https://github.com/stashapp/stash/pull/3851))\n* Fixed path sorting ordering numbers before special characters. ([#3829](https://github.com/stashapp/stash/pull/3829))\n* Fixed scene player not staying in full screen when loading a new scene. ([#3828](https://github.com/stashapp/stash/pull/3828))\n* Fixed badge counter value for sub-tags/studios. ([#3816](https://github.com/stashapp/stash/pull/3816))\n* Adjusted dimensions for resolution display and filtering. ([#3798](https://github.com/stashapp/stash/pull/3798))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0230.md",
    "content": "### ✨ New Features\n* Added hoverable control at the bottom edge of the scene preview to scrub through the scene. ([#4022](https://github.com/stashapp/stash/pull/4022))\n* Added support for multiple URLs for Images and Galleries. ([#4000](https://github.com/stashapp/stash/pull/4000)/[#4114](https://github.com/stashapp/stash/pull/4114))\n* Added option to mark scene as Organized when saving a scene in the Tagger view. ([#4031](https://github.com/stashapp/stash/pull/4031))\n* Added A/B looping support to the scene player. ([#3904](https://github.com/stashapp/stash/pull/3904))\n* Added new selection options to the Duplicate Checker. ([#4006](https://github.com/stashapp/stash/pull/4006))\n\n### 🎨 Improvements\n* Movies scraped from the scene scrape dialog can now create full movies. ([#4147](https://github.com/stashapp/stash/pull/4147))\n* Improved the lightbox behaviour when using a touchpad or mouse with a smooth wheel. ([#3894](https://github.com/stashapp/stash/pull/3894))\n* Refactored Performer select control to be more performant and to show relevant aliases. ([#4013](https://github.com/stashapp/stash/pull/4013))\n* Made x button on filter badges easier to click. ([#4029](https://github.com/stashapp/stash/pull/4029))\n* Details pages now show the first populated content tab when loaded. ([#4032](https://github.com/stashapp/stash/pull/4032))\n* Refactored the Saved Filter format. ([#4054](https://github.com/stashapp/stash/pull/4054))\n\n### 🐛 Bug fixes\n* **[0.23.1]** Fixed Performers not being set correctly from the Scene scrape dialog. ([#4199](https://github.com/stashapp/stash/pull/4199))\n* **[0.23.1]** Fixed Gallery URLs not being set correctly from the scrape dialog. ([#4187](https://github.com/stashapp/stash/pull/4187))\n* **[0.23.1]** Fixed default slideshow delay value. ([#4186](https://github.com/stashapp/stash/pull/4186))\n* Fixed data corruption that occurred when stash detected a folder had been moved. ([#4169](https://github.com/stashapp/stash/pull/4169))\n* Convert movie duration from seconds during scrape if provided as a number. ([#4144](https://github.com/stashapp/stash/pull/4144))\n* Fixed image clip videos not autoplaying when a page is first loaded. ([#4131](https://github.com/stashapp/stash/pull/4131))\n* Fixed display of cards on the front page on mobile devices. ([#4057](https://github.com/stashapp/stash/pull/4057))\n* Fixed nil pointer dereference when merging scenes. ([#4119](https://github.com/stashapp/stash/pull/4119))\n* Fixed nil pointer dereference when identifying scenes. ([#4171](https://github.com/stashapp/stash/pull/4171))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0240.md",
    "content": "### ✨ New Features\n* Added manager for installing, updating and uninstalling scrapers and plugins. ([#4242](https://github.com/stashapp/stash/pull/4242))\n* Added support for enabling and disabling plugins in the UI. ([#4141](https://github.com/stashapp/stash/pull/4141))\n* Added support for plugin settings. ([#4143](https://github.com/stashapp/stash/pull/4143))\n* Added support for plugin assets, external scripts and CSP overrides. ([#4260](https://github.com/stashapp/stash/pull/4260))\n* Added UI plugin API to better support UI-based plugins. ([#4256](https://github.com/stashapp/stash/pull/4256))\n* Added Studio Code and Photographer to Galleries. ([#4195](https://github.com/stashapp/stash/pull/4195))\n* Added Details, Studio Code and Photographer to Images. ([#4217](https://github.com/stashapp/stash/pull/4217))\n* Added scene framerate filter. ([#4161](https://github.com/stashapp/stash/pull/4161))\n* Added option to Duplicate Checker to select all files except the highest resolution. ([#4286](https://github.com/stashapp/stash/pull/4286))\n\n### 🎨 Improvements\n* **[0.24.2]** Hide Tags input in Tagger when Set Tags is disabled. ([#4440](https://github.com/stashapp/stash/pull/4440))\n* Show Performer image in Performer select list. ([#4227](https://github.com/stashapp/stash/pull/4227))\n* Match Performers by alias during scraping and tagging if no Performer is found with the exact name (only if a single performer is found with the alias). ([#4182](https://github.com/stashapp/stash/pull/4182))\n* Show Performer disambiguation and add stash-box links to Studio in tagger results. ([#4180](https://github.com/stashapp/stash/pull/4180))\n* Show endpoints with stash ids. ([#4216](https://github.com/stashapp/stash/pull/4216))\n* Load card thumbnails and similar images lazily when a component comes into view, instead of all at once. ([#4228](https://github.com/stashapp/stash/pull/4228))\n* Made the random sorting more random. ([#4246](https://github.com/stashapp/stash/pull/4246))\n* Added more VR projection modes. ([#3799](https://github.com/stashapp/stash/pull/3799))\n* Improved the filtering behaviour when selecting a folder. ([#4277](https://github.com/stashapp/stash/pull/4277))\n* Added support for setting plugins path from the UI. ([#4382](https://github.com/stashapp/stash/pull/4382))\n\n### 🐛 Bug fixes\n* **[0.24.3]** Fixed error when editing case of existing studio name. ([#4447](https://github.com/stashapp/stash/pull/4447))\n* **[0.24.3]** Fixed videos muting after auto-play fails. ([#4450](https://github.com/stashapp/stash/pull/4450))\n* **[0.24.2]** Fixed error when renaming marker files during scene merge operation ([#4446](https://github.com/stashapp/stash/pull/4446))\n* **[0.24.2]** Fixed error when creating/updating a Performer where an alias is the same as the Performer name. ([#4443](https://github.com/stashapp/stash/pull/4443))\n* **[0.24.2]** Errors during the tagger Scrape All operation now output to the scene card and no longer stop the operation. ([#4442](https://github.com/stashapp/stash/pull/4442))\n* **[0.24.2]** Fixed studio image sizing on details pages. ([#4441](https://github.com/stashapp/stash/pull/4441))\n* **[0.24.2]** Fixed URL not being overwritten when specified during Identify ([#4412](https://github.com/stashapp/stash/pull/4412))\n* **[0.24.2]** Fixed plugin settings to be sorted alphabetically, instead of being displayed in a random order. ([#4435](https://github.com/stashapp/stash/pull/4435))\n* **[0.24.2]** Fixed scene queue not respecting the Auto-start video setting. ([#4428](https://github.com/stashapp/stash/pull/4428))\n* **[0.24.2]** Fixed performers incorrectly being matched by alias during scraping. ([#4432](https://github.com/stashapp/stash/pull/4432))\n* **[0.24.2]** Fixed error when filtering on Scene interactive speed. ([#4414](https://github.com/stashapp/stash/pull/4414))\n* **[0.24.2]** Fixed plugin CSP not being enacted. ([#4424](https://github.com/stashapp/stash/pull/4424))\n* **[0.24.1]** Fixed external player button not working correctly. ([#4403](https://github.com/stashapp/stash/pull/4403))\n* **[0.24.1]** Fixed image thumbnail generation on arm devices. ([#4402](https://github.com/stashapp/stash/pull/4402))\n* **[0.24.1]** Reverted change to modal button order. ([#4400](https://github.com/stashapp/stash/pull/4400))\n* Fixed submitting to stash-box not working after switching to another scene using the queue. ([#4354](https://github.com/stashapp/stash/pull/4354))\n* Fixed UI crash when clearing a value from a URL or alias list. ([#4344](https://github.com/stashapp/stash/pull/4344))\n* Fixed panic when exporting galleries. ([#4311](https://github.com/stashapp/stash/pull/4311))\n* Fixed error when setting performer height with decimals. ([#4283](https://github.com/stashapp/stash/pull/4283))\n* Fixed Performer stash ids being overwritten instead of merged when saving a Performer in the Performer tagger. ([#4215](https://github.com/stashapp/stash/pull/4215))\n* Fixed organized being set to false if `Mark as Organized on save` is false when saving a scene in the tagger. ([#4213](https://github.com/stashapp/stash/pull/4213))\n* Fixed URLs not populating correctly when scraping galleries. ([#4206](https://github.com/stashapp/stash/pull/4206))\n* Fixed not being able to click next/previous scene if the applicable scenes had not been loaded by the queue. ([#4325](https://github.com/stashapp/stash/pull/4325))\n* Fixed confirmation dialog not appearing on some screens when using the delete keyboard shortcut. ([#4387](https://github.com/stashapp/stash/pull/4387))\n* Fixed desktop notifications not appearing on macos. ([#4153](https://github.com/stashapp/stash/pull/4153))\n* Fixed video sometimes pausing when clicking on the scene scrubber. ([#4295](https://github.com/stashapp/stash/pull/4295))\n* Fixed file paths not being shown when deleting image clips. ([#4323](https://github.com/stashapp/stash/pull/4323))\n* Fixed image o-counts not being included in stats page. ([#4386](https://github.com/stashapp/stash/pull/4323))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0250.md",
    "content": "##### 💥 Note: A number of settings and tasks are now only available when `Advanced Mode` is set to true in the settings, including the `Auto Tag` and `Identify` tasks.\n\n### ✨ New Features\n* Added Scene play and o-counter history tracking, view and editing. ([#4532](https://github.com/stashapp/stash/pull/4532))\n* Added Advanced settings flag. ([#4378](https://github.com/stashapp/stash/pull/4378))\n* Added support for setting galleries in Image edit panel and Bulk Edit Image dialog. ([#4573](https://github.com/stashapp/stash/pull/4573)/[#4608](https://github.com/stashapp/stash/pull/4608))\n* Added option to generate image thumbnails during generate. ([#4602](https://github.com/stashapp/stash/pull/4602))\n* Added Clean Generated Files task. ([#4607](https://github.com/stashapp/stash/pull/4607))\n* Added more options to Performer gender filter. ([#4419](https://github.com/stashapp/stash/pull/4419))\n* Added image orientation filtering. ([#4404](https://github.com/stashapp/stash/pull/4404))\n* Added filtering and sorting of Studios on subsidiary Studio count. ([#4479](https://github.com/stashapp/stash/pull/4479))\n* Added image performer age filter. ([#4601](https://github.com/stashapp/stash/pull/4601))\n\n### 🎨 Improvements\n* Overhauled the list view for scenes, galleries and performers. ([#4368](https://github.com/stashapp/stash/pull/4368))\n* Made grid card fit cards properly within their containers. ([#4514](https://github.com/stashapp/stash/pull/4514))\n* Improved the presentation of the toast notifications. ([#4584](https://github.com/stashapp/stash/pull/4584))\n* Improved Tag, Studio, Gallery and Movie select controls. ([#4478](https://github.com/stashapp/stash/pull/4478)/[#4493](https://github.com/stashapp/stash/pull/4493)/[#4535](https://github.com/stashapp/stash/pull/4535)/[#4563](https://github.com/stashapp/stash/pull/4563))\n* Improve sorting of results when entering text in select fields. ([#4528](https://github.com/stashapp/stash/pull/4528))\n* Add disambiguation to performer link and performer select values. ([#4541](https://github.com/stashapp/stash/pull/4541))\n* Show upgradable packages only when checking for updates in the package managers. ([#4599](https://github.com/stashapp/stash/pull/4599))\n* Include primary tag name in Scene Marker search and sort. ([#4606](https://github.com/stashapp/stash/pull/4606))\n* Improved presentation of scene queue. ([#4448](https://github.com/stashapp/stash/pull/4448))\n* Improved zip file move detection. ([#4374](https://github.com/stashapp/stash/pull/4374))\n* Saving images will now name them based on the original filename. ([#4616](https://github.com/stashapp/stash/pull/4616))\n* Improved scene tagger matching prioritisation. ([#4618](https://github.com/stashapp/stash/pull/4618))\n* Added support for disabling mobile media-viewer's fullscreen auto-rotate. ([#4416](https://github.com/stashapp/stash/pull/4416))\n* Defer loading edit panel data until needed. ([#4564](https://github.com/stashapp/stash/pull/4564))\n* Performer stash-box draft now includes the Disambiguation field. ([#4122](https://github.com/stashapp/stash/pull/4122))\n\n### 🐛 Bug fixes\n* **[0.25.1]** Fixed captions with embedded timestamps rendering incorrectly. ([#4682](https://github.com/stashapp/stash/pull/4682))\n* **[0.25.1]** Fixed buffering time included in scene play time. ([#4670](https://github.com/stashapp/stash/pull/4670))\n* **[0.25.1]** Fixed medium fingerprint match colour. ([#4662](https://github.com/stashapp/stash/pull/4662))\n* **[0.25.1]** Fixed image clip webm files not being cleaned by Clean Generate task. ([#4657](https://github.com/stashapp/stash/pull/4657))\n* **[0.25.1]** Fixed ffmpeg error when transcoding files where max transcode size is set. ([#4660](https://github.com/stashapp/stash/pull/4660))\n* Fixed invalid share causing error during cleaning. ([#4570](https://github.com/stashapp/stash/pull/4570))\n* Fixed stash ids being removed when tagging Studio using the Studio Tagger. ([#4572](https://github.com/stashapp/stash/pull/4572))\n* Fixed Plugin manager failing to get any updates if any installed sources are not found. ([#4591](https://github.com/stashapp/stash/pull/4591))\n* Fixed `.forcegallery` file not being honoured when re-scanning after adding the file. ([#4627](https://github.com/stashapp/stash/pull/4627))\n* Fixed Gallery Image filtering. ([#4535](https://github.com/stashapp/stash/pull/4535))\n* Fixed Studio overlay not being shown on Image cards, and Studio text not being shown on Gallery cards. ([#4540](https://github.com/stashapp/stash/pull/4540))\n* Wrap grid card popovers. ([#4539](https://github.com/stashapp/stash/pull/4539))\n* Fix merge scene not deleting generated files. ([#4567](https://github.com/stashapp/stash/pull/4567))\n* Fixed auto tag from object not honouring the ignore autotag flag. ([#4610](https://github.com/stashapp/stash/pull/4610))\n* Fixed moved files causing re-generation of phashes. ([#4598](https://github.com/stashapp/stash/pull/4598))\n* Fixed Movie scene sorting in Movie view. ([#4588](https://github.com/stashapp/stash/pull/4588))\n* Fixed `baseURL` not being applied to some links. ([#4501](https://github.com/stashapp/stash/pull/4501))\n* Fixed country selector in bulk performer edit dialog. ([#4565](https://github.com/stashapp/stash/pull/4565))\n* Fixed image clips not upscaling in lightbox. ([#4569](https://github.com/stashapp/stash/pull/4569))\n* Fixed `bmp` files being treated as video files in the lightbox. ([#4653](https://github.com/stashapp/stash/pull/4653))\n* Fixed performer penis length being truncated to integer. ([#4630](https://github.com/stashapp/stash/pull/4630))\n* Fixed heatmap generating repeated segments where there is no action. ([#4557](https://github.com/stashapp/stash/pull/4557))\n* Fixed media decode error not switching to next streaming format. ([#4506](https://github.com/stashapp/stash/pull/4506))\n* Fixed image scraping not using proxy. ([#4637](https://github.com/stashapp/stash/pull/4637))\n* Fixed broken favicon after logging in using Firefox. ([#4498](https://github.com/stashapp/stash/pull/4498))\n* Moved tag hover popover to the right. ([#4593](https://github.com/stashapp/stash/pull/4593))\n* Fixed weird 404 behaviour in plugin assets handler. ([#4597](https://github.com/stashapp/stash/pull/4597))\n\n### Plugin API changes\n* Added `Mousetrap` and `MousetrapPause` to `PluginApi.libraries`. ([#4489](https://github.com/stashapp/stash/pull/4489))\n* Added `useToast` to `PluginApi.hooks`. ([#4546](https://github.com/stashapp/stash/pull/4546))\n* Exposed Studio, Performer, Tag and Gallery selects, and date, country and folder inputs in `PluginApi.components`. ([#4546](https://github.com/stashapp/stash/pull/4546))\n* Made `task_name` parameter optional, added an optional `description` parameter and deprecated `args` for a generic map parameter `args_map` in `runPluginTask`. ([#4603](https://github.com/stashapp/stash/pull/4603))\n* Added `runPluginOperation` to run synchronous plugin operations with a return value, without using the task manager. ([#4603](https://github.com/stashapp/stash/pull/4603))\n* Added `PluginApi.Event.addEventListener` and `stash:location` event dispatching.\n* Relaxed plugin cyclic loop detection to allow up to ten loops. ([#4625](https://github.com/stashapp/stash/pull/4625))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0260.md",
    "content": "##### 💥 Note: The `Enable Scene Play History` setting has been set to true for existing systems. This setting enables play counts and resuming scenes from where they were previously played. If you do not want this enabled, please disable it explicitly in `Settings -> Interface -> Scene Player -> Enable Scene Play History`.\n\n### ✨ New Features\n* Added support for favorite Tags and Studios. ([#4728](https://github.com/stashapp/stash/pull/4728), [#4675](https://github.com/stashapp/stash/pull/4675))\n* Added filtering of scenes by galleries, bitrate and last played time. ([#4632](https://github.com/stashapp/stash/pull/4632), [#4713](https://github.com/stashapp/stash/pull/4713), [#4829](https://github.com/stashapp/stash/pull/4829))\n* Added support for sorting Performers by Last O At, Last Played At, and Play Count. ([#4649](https://github.com/stashapp/stash/pull/4649))\n* Added filtering of Performers by Play Count. ([#4649](https://github.com/stashapp/stash/pull/4649))\n* Added support for configuring the DLNA port. ([#4836](https://github.com/stashapp/stash/pull/4836))\n* Add support for filtering galleries by related scenes. ([#4840](https://github.com/stashapp/stash/pull/4840))\n* FFMpeg and FFProbe paths can now be set in `Settings -> System`, and can also be downloaded from this screen. ([#4688](https://github.com/stashapp/stash/pull/4688))\n\n### 🎨 Improvements\n* Scan, Generate and Auto Tag options are now saved as they are changed. ([#4591](https://github.com/stashapp/stash/pull/4591))\n* Made migration an asynchronous task. No more time outs during the migration process. ([#4666](https://github.com/stashapp/stash/pull/4666))\n* FFMpeg is now downloaded using a task rather than automatically during setup. ([#4688](https://github.com/stashapp/stash/pull/4688))\n* Moved the details toolbar in Scene, Image and Gallery pages to above the tabs, moved rating control to the toolbar, and added other details. ([#4714](https://github.com/stashapp/stash/pull/4714))\n* Improved Scene, Movie and Gallery select controls. ([#4832](https://github.com/stashapp/stash/pull/4832), [#4851](https://github.com/stashapp/stash/pull/4851))\n* When enabled, full hardware transcoding is used where possible. ([#4765](https://github.com/stashapp/stash/pull/4765))\n* Made directors and photographers clickable links in detail view ([#4621](https://github.com/stashapp/stash/pull/4621))\n* Redundant filter pills are no longer shown on filter sub-views. ([#4705](https://github.com/stashapp/stash/pull/4705))\n* Enforce whitelist for sort values (CVE-2024-32231). ([#4865](https://github.com/stashapp/stash/pull/4865))\n* Changed umask when creating config file to exclude user write (CVE-2024-32233) ([#4866](https://github.com/stashapp/stash/pull/4866))\n\n### 🐛 Bug fixes\n* **[0.26.2]** Fixed issue where performer could not be created without disambiguation if a performer with the same name and populated disambiguation exists. ([#5019](https://github.com/stashapp/stash/pull/5019))\n* **[0.26.2]** Fix resize loop in grid views. ([#5004](https://github.com/stashapp/stash/pull/5004))\n* **[0.26.2]** Fix query field values duplicating in tagger view when scene list is updated. ([#5000](https://github.com/stashapp/stash/pull/5000))\n* **[0.26.2]** Fix identify clearing parent studio when merging studio field. ([#4993](https://github.com/stashapp/stash/pull/4993))\n* **[0.26.2]** Fix manually selected studio not being applied during scrape. ([#4953](https://github.com/stashapp/stash/pull/4953))\n* **[0.26.1]** Fixed identify task defaults not displaying correctly. ([#4931](https://github.com/stashapp/stash/pull/4931))\n* **[0.26.1]** Fixed issue where full hardware transcoding did not work where a filter was not required. ([#4934](https://github.com/stashapp/stash/pull/4934))\n* **[0.26.1]** Fixed new performer tags not displaying correctly in the performer scrape dialog. ([#4943](https://github.com/stashapp/stash/pull/4943))\n* **[0.26.1]** Added missing `console` object in javascript runtime environment. ([#4944](https://github.com/stashapp/stash/pull/4944))\n* Fix selected tagger search result being lost when creating objects. ([#4715](https://github.com/stashapp/stash/pull/4715))\n* Fixed error when adding performer with duplicate aliases using the performer tagger. ([#4801](https://github.com/stashapp/stash/pull/4801))\n* Fixed interactive speed being lost when file is moved. ([#4799](https://github.com/stashapp/stash/pull/4799))\n* Fixed missing studio selector in movie scrape dialog. ([#4692](https://github.com/stashapp/stash/pull/4692))\n* Fixed values being reset when changing mode in bulk edit dialog. ([#4854](https://github.com/stashapp/stash/pull/4854))\n* Fixed python not being resolved correctly if not in path. ([#4864](https://github.com/stashapp/stash/pull/4864))\n* Fixed scraped tag exclusions not applying to galleries and performers ([#4872](https://github.com/stashapp/stash/pull/4872))\n* Fixed UI not loading on Safari 12. ([#4874](https://github.com/stashapp/stash/pull/4874))\n* Include director field when adding movies from the scene scrape dialog. ([#4757](https://github.com/stashapp/stash/pull/4757))\n* Fixed detail items not wrapping correctly. ([#4730](https://github.com/stashapp/stash/pull/4730))\n* Fixed duplicate scene checker selection logic. ([#4800](https://github.com/stashapp/stash/pull/4800))\n* Fixed video streams being reencoded unnecessarily. ([#4783](https://github.com/stashapp/stash/pull/4783))\n* Improved support for Samsung SmartTV for built-in DLNA server ([#4784](https://github.com/stashapp/stash/pull/4784))\n* Fixed incorrect status code for `ErrUnauthorized` errors. ([#4842](https://github.com/stashapp/stash/pull/4842))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0270.md",
    "content": "##### 💥 Note: The Movie concept has been renamed to Group.\n##### 💥 Note: Tagger settings have been reset, but are now persisted between browser sessions. `Show male performers` and `Set Tags` are now defaulted to true. Please verify your settings before using the Tagger.\n\n### ✨ New Features\n* Movies have been renamed to `Groups` and now may contain orderable sub-groups with descriptions. ([#5105](https://github.com/stashapp/stash/pull/5105))\n* Added support for multiple URLs for Performers. ([#4958](https://github.com/stashapp/stash/pull/4958))\n* Added ability to set tags on Studios. ([#4858](https://github.com/stashapp/stash/pull/4858))\n* Added support for multiple URLs for Groups. ([#4900](https://github.com/stashapp/stash/pull/4900))\n* Added ability to set tags on Groups. ([#4969](https://github.com/stashapp/stash/pull/4969))\n* Added ability to set a specific image as a Gallery Cover. ([#5182](https://github.com/stashapp/stash/pull/5182))\n* Added support for setting default filter for all views. ([#4962](https://github.com/stashapp/stash/pull/4962))\n* Added preview scrubber to Gallery cards. ([#5133](https://github.com/stashapp/stash/pull/5133))\n* Added support for bulk-editing Tags. ([#4925](https://github.com/stashapp/stash/pull/4925))\n* Added filter to Scrapers menu. ([#5041](https://github.com/stashapp/stash/pull/5041))\n* Added ability to set the location of ssl certificate files. ([#4910](https://github.com/stashapp/stash/pull/4910))\n* Added option to rescan all files in the Scan task. ([#5254](https://github.com/stashapp/stash/pull/5254))\n\n### 🎨 Improvements\n* **[0.27.2]** Scene player now shows the starting position when resume time is set. ([#5379](https://github.com/stashapp/stash/pull/5379))\n* **[0.27.1]** Live transcode requests are now debounced to spawn fewer `ffmpeg` instances while scrubbing. ([#5340](https://github.com/stashapp/stash/pull/5340))\n* **[0.27.1]** Blobs location may now be set using environment variable `STASH_BLOBS`. ([#5345](https://github.com/stashapp/stash/pull/5345))\n* Added button to view sub-studio/sub-tag content on Studio/Tag details pages. ([#5080](https://github.com/stashapp/stash/pull/5080))\n* Made tagger settings persistent. ([#5165](https://github.com/stashapp/stash/pull/5165))\n* Added birthdate and age to Performer select. ([#5076](https://github.com/stashapp/stash/pull/5076))\n* Made pagination control more compact. ([#4882](https://github.com/stashapp/stash/pull/4882))\n* Added filter and count badge to Scraper lists in the `Metadata Providers` page, and improved presentation. ([#5040](https://github.com/stashapp/stash/pull/5040))\n* Clicking `Rescan` on the details pages will now properly recalculate file details. ([#5043](https://github.com/stashapp/stash/pull/5043))\n* Added performer sorting options for `career length`, `measurements` and `weight`. ([#5129](https://github.com/stashapp/stash/pull/5129))\n* Added `path` column option to scene and gallery list tables. ([#5005](https://github.com/stashapp/stash/pull/5005))\n* Moved `Reload scrapers` option to top of Scrapers menus. ([#5142](https://github.com/stashapp/stash/pull/5142))\n* Added `scene` filter criterion for Scene Marker queries. ([#5097](https://github.com/stashapp/stash/pull/5097))\n* Scene Player now allows interacting with the controls before playing video, and errors no longer prevent interacting with the Scene Player. ([#5145](https://github.com/stashapp/stash/pull/5145))\n\n### 🐛 Bug fixes\n* **[0.27.2]** Fixed items being selected twice when selecting items in the Grid list. ([#5377](https://github.com/stashapp/stash/pull/5377))\n* **[0.27.2]** Fixed 62 migration error for some users. ([#5363](https://github.com/stashapp/stash/pull/5363))\n* **[0.27.2]** Fixed scenes incorrectly autoplaying on queue selection. ([#5379](https://github.com/stashapp/stash/pull/5379))\n* **[0.27.2]** Videos no longer begin playing when seeking before video has started. ([#5379](https://github.com/stashapp/stash/pull/5379))\n* **[0.27.2]** Videos will now resume from the correct time when switching sources due to error. ([#5379](https://github.com/stashapp/stash/pull/5379))\n* **[0.27.1]** Fixed UI infinite loop when sorting by random without a seed in the URL. ([#5319](https://github.com/stashapp/stash/pull/5319))\n* **[0.27.1]** Fixed dropdowns not displaying correctly in the merge dialogs. ([#5299](https://github.com/stashapp/stash/pull/5299))\n* **[0.27.1]** For single URLs, link icon now shows the dropdown menu instead of navigating to the URL. ([#5310](https://github.com/stashapp/stash/pull/5310))\n* **[0.27.1]** Fixed redirection when page > total pages to the last page instead of the first. ([#5321](https://github.com/stashapp/stash/pull/5321))\n* **[0.27.1]** Fixed display of rating criterion when using decimal rating system. ([#5334](https://github.com/stashapp/stash/pull/5334))\n* **[0.27.1]** Fixed parent/child Tags not showing in alphabetical order. ([#5320](https://github.com/stashapp/stash/pull/5320))\n* **[0.27.1]** Fixed performance issue when viewing studios where system has many images with no studios. ([#5335](https://github.com/stashapp/stash/pull/5335))\n* **[0.27.1]** Clicking on the video player timeline before video is started now plays the video from that point instead of playing from the beginning. ([#5340](https://github.com/stashapp/stash/pull/5340))\n* **[0.27.1]** Fixed UI crash when front page has filters using legacy `movies` scene filter. ([#5348](https://github.com/stashapp/stash/pull/5348))\n* **[0.27.1]** Restored legacy behaviour where selection is persisted when paging or changing filter. ([#5349](https://github.com/stashapp/stash/pull/5349))\n* **[0.27.1]** Fixed UI crash when navigating to image without files. ([#5325](https://github.com/stashapp/stash/pull/5325))\n* **[0.27.1]** Fixed panic when deleting image without files. ([#5328](https://github.com/stashapp/stash/pull/5328))\n* **[0.27.1]** Fixed matched performer and studio links not including base URL in Tagger. ([#5337](https://github.com/stashapp/stash/pull/5337))\n* Fixed videos and images having incorrect dimensions when the orientation flag is set to a non-default value during scan. ([#5188](https://github.com/stashapp/stash/pull/5188), [#5189](https://github.com/stashapp/stash/pull/5189))\n* Fixed mp4 videos being incorrectly transcoded when the file has opus audio codec. ([#5030](https://github.com/stashapp/stash/pull/5030))\n* Fixed o-history being imported as view-history when importing from JSON. ([#5127](https://github.com/stashapp/stash/pull/5127))\n* Deleting a zip-based or folder-based Gallery and deleting the file/folder now removes files from the existing image if the image has multiple files, instead of removing the image. ([#5213](https://github.com/stashapp/stash/pull/5213))\n* Fixed Intel Quicksync hardware encoding support. ([#5069](https://github.com/stashapp/stash/pull/5069))\n* Fixed hardware transcoding not working correctly on macOS devices. ([#4945](https://github.com/stashapp/stash/pull/4945))\n* Fixed ffmpeg version detection for `n`- prefixed version numbers. ([#5102](https://github.com/stashapp/stash/pull/5102))\n* Anonymise now truncates o- and view history data. ([#5166](https://github.com/stashapp/stash/pull/5166))\n* Fixed issue where using mouse wheel on numeric input fields would scroll the window in addition to changing the value. ([#5199](https://github.com/stashapp/stash/pull/5199))\n* Fixed issue where some o-dates could not be deleted. ([#4971](https://github.com/stashapp/stash/pull/4971))\n* Fixed handling of symlink zip files. ([#5249](https://github.com/stashapp/stash/pull/5249))\n* Fixed default database backup directory being set to the config file directory instead of the database directory. ([#5250](https://github.com/stashapp/stash/pull/5250))\n* Added API key to DASH and HLS manifests. ([#5061](https://github.com/stashapp/stash/pull/5061))\n* Query field no longer focused when selecting items in the filter list on touch devices. ([#5204](https://github.com/stashapp/stash/pull/5204))\n* Fixed weird scrolling behaviour on Gallery detail page on smaller viewports ([#5205](https://github.com/stashapp/stash/pull/5205))\n* Performer popover links now correctly link to the applicable scenes/image/gallery query page instead of always going to scenes. ([#5195](https://github.com/stashapp/stash/pull/5195))\n* Fixed scene player source selector appearing behind the player controls. ([#5229](https://github.com/stashapp/stash/pull/5229))\n* Fixed red/green/blue slider values in the Scene Filter panel. ([#5221](https://github.com/stashapp/stash/pull/5221))\n* Play button no longer appears on file-less Scenes. ([#5141](https://github.com/stashapp/stash/pull/5141))\n* Fixed transgender icon colouring. ([#5090](https://github.com/stashapp/stash/pull/5090))\n* Refreshed built in freeones scraper. ([#5171](https://github.com/stashapp/stash/pull/5171))\n\n### Plugin API changes\n* `PluginAPI.patch.instead` now allows for multiple plugins to hook into a single function. ([#5125](https://github.com/stashapp/stash/pull/5125))\n\n\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0280.md",
    "content": "### ✨ New Features\n\n* Markers now have an optional end time ([#5311](https://github.com/stashapp/stash/pull/5311), [#5633](https://github.com/stashapp/stash/pull/5633))\n* Marker times now have sub-second precision ([#5431](https://github.com/stashapp/stash/pull/5431))\n* Added Grid view for Markers. ([#5443](https://github.com/stashapp/stash/pull/5443))\n* Scene markers can now be filtered and sorted by their duration. ([#5472](https://github.com/stashapp/stash/pull/5472))\n* Added custom fields for Performers. ([#5487](https://github.com/stashapp/stash/pull/5487), [#5632](https://github.com/stashapp/stash/pull/5632))\n* Added Sort Name to Tags. ([#5531](https://github.com/stashapp/stash/pull/5531))\n* Added Image scraping. ([#5562](https://github.com/stashapp/stash/pull/5562))\n* It is now possible to configure an API key for a stash scraper source. ([#5474](https://github.com/stashapp/stash/pull/5474))\n\n### 🎨 Improvements\n\n* Changed modifier buttons to be selectable options in object filter selectors. ([#5203](https://github.com/stashapp/stash/pull/5203))\n* Changed Group Details images to be a flippable front/back rather than showing both at once. ([#5367](https://github.com/stashapp/stash/pull/5367))\n* Performer select now shows the performer age based on the date field. ([#5110](https://github.com/stashapp/stash/pull/5110))\n* Stash IDs now have an Updated At field. ([#5259](https://github.com/stashapp/stash/pull/5259))\n* Performer Death Date is now fetched from stash-box. ([#5653](https://github.com/stashapp/stash/pull/5653))\n* Batch Performer Update now handles Performers merged on stash-box. ([#5664](https://github.com/stashapp/stash/pull/5664))\n* ETA is now shown for tasks. ([#5535](https://github.com/stashapp/stash/pull/5535))\n* Scene Updated At field is now updated when Interactive Heatmap is generated. ([#5401](https://github.com/stashapp/stash/pull/5401))\n* Handy now resyncs automatically. ([#5581](https://github.com/stashapp/stash/pull/5581))\n* It is now possible to query by scene name in a stash scraper. ([#5722](https://github.com/stashapp/stash/pull/5722))\n* Added Scene Code sort by option. ([#5708](https://github.com/stashapp/stash/pull/5708))\n\n### 🐛 Bug fixes\n\n* **[0.28.1]** Fixed scene not playing from sub-second marker position when navigating from markers page. ([#5744](https://github.com/stashapp/stash/pull/5744))\n* **[0.28.1]** Fixed URL not being excluded correctly in Studio tagger. ([#5743](https://github.com/stashapp/stash/pull/5743))\n* **[0.28.1]** Fixed UI crash when loading saved filter with timestamp criteria. ([#5742](https://github.com/stashapp/stash/pull/5742))\n* Fixed errors when scraping stash-box performers with null birthdates. ([#5428](https://github.com/stashapp/stash/pull/5248))\n* Fixed video files with identical phashes being merged during scan. ([#5461](https://github.com/stashapp/stash/pull/5461))\n* Fixed scraped tags showing the scraped tag name rather than the matched tag name. ([#5462](https://github.com/stashapp/stash/pull/5462))\n* Fixed unmatched scraped tags appearing in the Tag field when scraping groups. ([#5522](https://github.com/stashapp/stash/pull/5522))\n* Fixed issue where creating a new tag from the Tag selector would not update the tags field. ([#5522](https://github.com/stashapp/stash/pull/5522))\n* Invalid tagger blacklist entries now show an error message instead of crashing the UI. ([#5497](https://github.com/stashapp/stash/pull/5497))\n* Fixed Performer aliases not being excluded when updating from tagger. ([#5566](https://github.com/stashapp/stash/pull/5566)\n* Fixed scene scrubber not working correctly in Tagger view. ([#5507](https://github.com/stashapp/stash/pull/5507))\n* Fixed Handy script not playing after revisiting scene. ([#5578](https://github.com/stashapp/stash/pull/5578))\n* Fixed various Handy playback issues. ([#5576](https://github.com/stashapp/stash/pull/5576))\n* Fixed incorrect image being shown in the lightbox when clicking on Group or Performer images in the applicable detail pages. ([#5659](https://github.com/stashapp/stash/pull/5659))\n* Saved Filters are now included in full export/import. ([#5465](https://github.com/stashapp/stash/pull/5465))\n* Fixed issue where entering text into the setup input fields would defocus the fields. ([#5459](https://github.com/stashapp/stash/pull/5459))\n* Fixed race condition when registering plugin custom routes. ([#5523](https://github.com/stashapp/stash/pull/5523))\n* Fixed scraping multiple URLs using the mapped scrapers. ([#5677](https://github.com/stashapp/stash/pull/5677))\n* Fixed excluded tags not being excluded when identifying scenes. ([#5686](https://github.com/stashapp/stash/pull/5686))\n* Fixed database locked error messages after migrating. ([#5723](https://github.com/stashapp/stash/pull/5723))\n* Fixed issue where scraped tags that resolve to the same tag would result in no scraped tags being shown. ([#5733](https://github.com/stashapp/stash/pull/5733))\n* Fixed Image Wall Margin setting not working correctly. ([#5496](https://github.com/stashapp/stash/pull/5496))\n* Fixed scraper errors when scraping from a stash instance. ([#5474](https://github.com/stashapp/stash/pull/5474))\n* Fixed duplicate Groups Scene filter criterion option. ([#5504](https://github.com/stashapp/stash/pull/5504))\n* Fixed back button returning to non-existing tag after merging. ([#5712](https://github.com/stashapp/stash/pull/5712))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0290.md",
    "content": "### ✨ New Features\n* Redesigned the scenes page with filter sidebar. ([#5714](https://github.com/stashapp/stash/pull/5714))\n* Added Performers tab to Group details page. ([#5895](https://github.com/stashapp/stash/pull/5895))\n* Added configurable rate limit to stash-box connection options. ([#5764](https://github.com/stashapp/stash/pull/5764))\n\n\n### 🎨 Improvements\n* **[0.29.2]** Returned saved filters button to the top toolbar in the Scene list. ([#6215](https://github.com/stashapp/stash/pull/6215))\n* **[0.29.2]** Top pagination can now be optionally shown in the scene list with [custom css](https://github.com/stashapp/stash/pull/6234#issue-3593190476). ([#6234](https://github.com/stashapp/stash/pull/6234))\n* **[0.29.2]** Restyled the scene list toolbar based on user feedback. ([#6215](https://github.com/stashapp/stash/pull/6215))\n* **[0.29.2]** Sidebar section collapsed state is now saved in the browser history. ([#6217](https://github.com/stashapp/stash/pull/6217))\n* **[0.29.2]** Increased the number of pages in pagination dropdown to 1000. ([#6207](https://github.com/stashapp/stash/pull/6207))\n* Revamped the scene and marker wall views. ([#5816](https://github.com/stashapp/stash/pull/5816))\n* Added zoom functionality to wall views. ([#6011](https://github.com/stashapp/stash/pull/6011))\n* Added search term field to the Edit Filter dialog. ([#6082](https://github.com/stashapp/stash/pull/6082))\n* Added load and save filter buttons to the Edit Filter dialog. ([#6092](https://github.com/stashapp/stash/pull/6092))\n* Restyled UI error messages. ([#5813](https://github.com/stashapp/stash/pull/5813))\n* Changed default modifier of `path` criterion to `includes` instead of `equals`. ([#5968](https://github.com/stashapp/stash/pull/5968))\n* Added internationalisation to login page. ([#5765](https://github.com/stashapp/stash/pull/5765))\n* Added Performer and Tag popovers to scene edit page. ([#5739](https://github.com/stashapp/stash/pull/5739))\n* Tags are now sorted by name in scrape and merge dialogs. ([#5752](https://github.com/stashapp/stash/pull/5752))\n* Related stash-box is now shown with IDs in tagger view. ([#5879](https://github.com/stashapp/stash/pull/5879))\n* UI now navigates to previous page when deleting an item. ([#5818](https://github.com/stashapp/stash/pull/5818))\n* All URLs will now be submitted when submitting a draft to stash-box. ([#5894](https://github.com/stashapp/stash/pull/5894))\n* Made funscript parsing more fault tolerant. ([#5978](https://github.com/stashapp/stash/pull/5978))\n* Added link to gallery in image lightbox. ([#6012](https://github.com/stashapp/stash/pull/6012))\n* Provide correct filename when downloading scene video. ([#6119](https://github.com/stashapp/stash/pull/6119))\n* Support hardware next/previous keys for scene navigation. ([#5553](https://github.com/stashapp/stash/pull/5553))\n* Duplicate checker now sorts largest file groups first. ([#6133](https://github.com/stashapp/stash/pull/6133))\n* Show gallery cover in Gallery edit panel. ([#5935](https://github.com/stashapp/stash/pull/5935))\n* Backups will now be created in the same directory as the database, then moved to the configured backup directory. This avoids potential corruption when backing up over a network share. ([#6137](https://github.com/stashapp/stash/pull/6137))\n* Added graphql playground link to tools panel. ([#5807](https://github.com/stashapp/stash/pull/5807))\n* Include IP address in login errors in log. ([#5760](https://github.com/stashapp/stash/pull/5760))\n\n### 🐛 Bug fixes\n* **[0.29.3]** Fixed sidebar filter contents not loading. ([#6240](https://github.com/stashapp/stash/pull/6240))\n* **[0.29.2]** Fixed Play Random not playing from the current filtered scenes on scene list sub-pages. ([#6202](https://github.com/stashapp/stash/pull/6202))\n* **[0.29.2]** Fixed infinite loop in Group Sub-Groups panel. ([#6212](https://github.com/stashapp/stash/pull/6212))\n* **[0.29.2]** Page no longer scrolls when selecting criterion for the first time in the Edit Filter dialog. ([#6205](https://github.com/stashapp/stash/pull/6205))\n* **[0.29.2]** Zoom slider is no longer shown on mobile devices. ([#6206](https://github.com/stashapp/stash/pull/6206)) \n* **[0.29.2]** Fixed trailing space sometimes being trimmed from query string when querying. ([#6211](https://github.com/stashapp/stash/pull/6211))\n* **[0.29.2]** Page now redirects to list page when deleting an object in a new browser tab. ([#6203](https://github.com/stashapp/stash/pull/6203))\n* **[0.29.2]** Related groups can now be scraped when scraping a scene. ([#6228](https://github.com/stashapp/stash/pull/6228))\n* **[0.29.2]** Fixed panic when a scraper configuration contains an unknown field. ([#6220](https://github.com/stashapp/stash/pull/6220))\n* **[0.29.2]** Fixed panic when using `stash_box_index` input in scrape API calls. ([#6201](https://github.com/stashapp/stash/pull/6201))\n* **[0.29.1]** Fixed password with special characters not allowing login. ([#6163](https://github.com/stashapp/stash/pull/6163))\n* **[0.29.1]** Fixed layout issues using column direction for image wall. ([#6168](https://github.com/stashapp/stash/pull/6168))\n* **[0.29.1]** Fixed layout issues for scene list table. ([#6169](https://github.com/stashapp/stash/pull/6169))\n* **[0.29.1]** Fixed UI loop when sorting by random without seed using URL. ([#6167](https://github.com/stashapp/stash/pull/6167))\n* Fixed ordering studios by tag count returning error. ([#5776](https://github.com/stashapp/stash/pull/5776))\n* Fixed error when submitting fingerprints for scenes that have been deleted. ([#5799](https://github.com/stashapp/stash/pull/5799))\n* Fixed errors when scraping groups. ([#5793](https://github.com/stashapp/stash/pull/5793), [#5974](https://github.com/stashapp/stash/pull/5974))\n* Fixed UI crash when viewing a gallery in the Performer details page. ([#5824](https://github.com/stashapp/stash/pull/5824))\n* Fixed scraped performer stash ID being saved when cancelling scrape operation. ([#5839](https://github.com/stashapp/stash/pull/5839))\n* Fixed groups not transferring when merging tags. ([#6127](https://github.com/stashapp/stash/pull/6127))\n* Fixed URLs and stash IDs not transferring during scene merge operation. ([#6151](https://github.com/stashapp/stash/pull/6151), [#6152](https://github.com/stashapp/stash/pull/6152))\n* Fixed empty exclusion patterns being applied when scanning and cleaning. ([#6023](https://github.com/stashapp/stash/pull/6023))\n* Fixed login page being included in browser history. ([#5747](https://github.com/stashapp/stash/pull/5747))\n* Fixed gallery card resizing while scrubbing. ([#5844](https://github.com/stashapp/stash/pull/5844))\n* Fixed incorrectly positioned scene markers in the scene player timeline. ([#5801](https://github.com/stashapp/stash/pull/5801), [#5804](https://github.com/stashapp/stash/pull/5804))\n* Fixed incorrect marker colours in the scene player timeline. ([#6141](https://github.com/stashapp/stash/pull/6141))\n* Fixed custom fields not being displayed in Performer page with `Compact Expanded Details` enabled. ([#5833](https://github.com/stashapp/stash/pull/5833))\n* Fixed issue in tagger where creating a parent studio would not map it to the other results. ([#5810](https://github.com/stashapp/stash/pull/5810), [#5996](https://github.com/stashapp/stash/pull/5996))\n* Fixed generation options not being respected when generating using the Tasks page. ([#6139](https://github.com/stashapp/stash/pull/6139))\n* Related tags are now ordered by name. ([#5945](https://github.com/stashapp/stash/pull/5945))\n* Fixed error message not being displayed when failing at startup. ([#5798](https://github.com/stashapp/stash/pull/5798))\n* Fixed incorrect paths in confirm step of the setup wizard. ([#6138](https://github.com/stashapp/stash/pull/6138))\n* Fixed values being lost when navigating back from the confirmation step of the setup wizard. ([#6138](https://github.com/stashapp/stash/pull/6138))\n* Fixed incorrect paths generated in HLS when using a reverse proxy prefix. ([#5791](https://github.com/stashapp/stash/pull/5791))\n* Fixed marker preview being deleted when modifying a marker with a duration. ([#5800](https://github.com/stashapp/stash/pull/5800))\n* Fixed marker end seconds not being included in import/export. ([#5777](https://github.com/stashapp/stash/pull/5777))\n* Fixed parent tags missing in export if including dependencies. ([#5780](https://github.com/stashapp/stash/pull/5780))\n* Add short hash of basename when generating export file names to prevent the same filename being generated. ([#5780](https://github.com/stashapp/stash/pull/5780))\n* Fixed invalid studio and performer links in the tagger view. ([#5876](https://github.com/stashapp/stash/pull/5876))\n* Fixed clickable area for tag links. ([#6129](https://github.com/stashapp/stash/pull/6129))\n* ffmpeg hardware encoding checks now timeout after 1 second to prevent startup hangs. ([#6154](https://github.com/stashapp/stash/pull/6154))"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v030.md",
    "content": "#### 💥 **Note: After upgrading, the next scan will populate all scenes with oshash hashes. MD5 calculation can be disabled after populating the oshash for all scenes. See \\`Hashing Algorithms\\` in the \\`Configuration\\` section of the manual for details. **\n\n### ✨ New Features\n*  Show and allow creation of unknown performers/tags/studios/movies in the scraper dialog.\n*  Add support for scraping movie details.\n*  Add support for JSON scrapers.\n*  Add support for plugin tasks.\n*  Add oshash algorithm for hashing scene video files. Enabled by default on new systems.\n*  Support (re-)generation of generated content for specific scenes.\n*  Add tag thumbnails, tags grid view and tag page.\n*  Add post-scrape dialog.\n*  Add various keyboard shortcuts (see manual).\n*  Support deleting multiple scenes.\n*  Add in-app help manual.\n*  Add support for custom served folders.\n*  Add support for parent/child studios.\n\n### 🎨 Improvements\n*  Support cbz galleries.\n*  Improve sprite generation performance.\n*  Make preview generation more fault-tolerant.\n*  Allow clearing of images and querying on missing images.\n*  Allow free-editing of scene movie number.\n*  Allow adding performers and studios from selectors.\n*  Add support for chrome dp in xpath scrapers.\n*  Allow customisation of preview video generation.\n*  Add support for live transcoding in Safari.\n*  Add mapped and fixed post-processing scraping options.\n*  Add random sorting for performers.\n*  Search for files which have low or upper case supported filename extensions.\n*  Add dialog when pasting movie images.\n*  Allow click and click-drag selection after selecting scene.\n*  Added multi-scene edit dialog.\n*  Moved images to separate tables, increasing performance.\n*  Add gallery grid view.\n*  Add is-missing scene filter for gallery query.\n*  Don't import galleries with no images, and delete galleries with no images during clean.\n*  Show pagination at top as well as bottom of the page.\n*  Add split xpath post-processing action.\n*  Improved the layout of the scene page.\n*  Show rating as stars in scene page.\n*  Add reload scrapers button.\n\n### 🐛 Bug fixes\n*  Fix directories with video name extensions being detected as files to be scanned.\n*  Fix issues moving generated files between file systems.\n*  Fix formatted dates using incorrect timezone.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0300.md",
    "content": "### ✨ New Features\n* Added SFW content mode option to settings and setup wizard. ([#6262](https://github.com/stashapp/stash/pull/6262))\n* Added stash-ids to Tags. ([#6255](https://github.com/stashapp/stash/pull/6255))\n* Added support for manually adding stash-ids to scenes, performers, studio and tags. ([#6374](https://github.com/stashapp/stash/pull/6374))\n* Added option to link a scraped tag to an existing tag in the tagger and scrape dialog. ([#6389](https://github.com/stashapp/stash/pull/6389))\n* Partial dates (year only or month/year) are now supported for all date fields. ([#6359](https://github.com/stashapp/stash/pull/6359))\n* Added support for specifying a trash location where deleted files will be moved instead of being permanently deleted. ([#6237](https://github.com/stashapp/stash/pull/6237))\n* Logs can now be compressed after reaching a configurable size. ([#5696](https://github.com/stashapp/stash/pull/5696))\n* Added ability to edit multiple studios at once. ([#6238](https://github.com/stashapp/stash/pull/6238))\n* Added ability to edit multiple scene markers at once. ([#6239](https://github.com/stashapp/stash/pull/6239))\n* Added support for multiple Studio URLs. ([#6223](https://github.com/stashapp/stash/pull/6223))\n* Added option to add markers to front page. ([#6065](https://github.com/stashapp/stash/pull/6065))\n* Added option for instant transitions in the image lightbox. ([#6354](https://github.com/stashapp/stash/pull/6354))\n* Added duration filter to scene list sidebar. ([#6264](https://github.com/stashapp/stash/pull/6264))\n* Added support for avif images. ([#6288](https://github.com/stashapp/stash/pull/6288), [#6337](https://github.com/stashapp/stash/pull/6337))\n* Added experimental support for JPEG XL images. ([#6184](https://github.com/stashapp/stash/pull/6184))\n\n### 🎨 Improvements\n* Reverted scene toolbar to previous layout, consistent with other query pages. ([#6322](https://github.com/stashapp/stash/pull/6322))\n* Restored display mode button group to list pages. ([#6317](https://github.com/stashapp/stash/pull/6317))\n* Added sticky selection toolbar to all list views. ([#6320](https://github.com/stashapp/stash/pull/6320))\n* Added performer age slider to scene filter sidebar. ([#6267](https://github.com/stashapp/stash/pull/6267))\n* Added markers option to scene filter sidebar. ([#6270](https://github.com/stashapp/stash/pull/6270))\n* Selected stash-box is now remembered in the scene tagger view. ([#6192](https://github.com/stashapp/stash/pull/6192))\n* Replaced `Show male performers` tagger option with a list of genders to include. ([#6321](https://github.com/stashapp/stash/pull/6321))\n* Galleries can now be created using the gallery select control. ([#6376](https://github.com/stashapp/stash/pull/6376))\n* String list inputs can now be re-ordered. ([#6397](https://github.com/stashapp/stash/pull/6397))\n* Added auto-start button to scene player. ([#6368](https://github.com/stashapp/stash/pull/6368))\n* Bulk add tasks now accept stash ids in addition to names. ([#6310](https://github.com/stashapp/stash/pull/6310))\n* Image query metadata (total file size and megapixels) is now performed as a separate query to the main query to improve performance. ([#6370](https://github.com/stashapp/stash/pull/6370))\n* Removed some unused fields in the tag list query to improve performance. ([#6398](https://github.com/stashapp/stash/pull/6398))\n* Added hardware encoding support for Rockchip RKMPP devices. ([#6182](https://github.com/stashapp/stash/pull/6182))\n* stash now uses the Media Session API when playing scenes. ([#6298](https://github.com/stashapp/stash/pull/6298))\n* Screen sleeping is now prevented when playing scenes (only in secure contexts: `localhost` or https). ([#6331](https://github.com/stashapp/stash/pull/6331))\n* Whitespace is now trimmed from the start and end of text fields. ([#6226](https://github.com/stashapp/stash/pull/6226))\n* Added `inputURL` and `inputHostname` fields to scraper specs. ([#6250](https://github.com/stashapp/stash/pull/6250))\n* Added extra studio fields to scraper specs. ([#6249](https://github.com/stashapp/stash/pull/6249))\n* Added o-count to studio cards and details page. ([#5982](https://github.com/stashapp/stash/pull/5982))\n* Added o-count to group cards. ([#6122](https://github.com/stashapp/stash/pull/6122))\n* Added options to filter and sort groups by o-count. ([#6122](https://github.com/stashapp/stash/pull/6122))\n* Added o-count to performer details page. ([#6171](https://github.com/stashapp/stash/pull/6171))\n* Added option to sort by total scene direction for performers, studios and tags. ([#6172](https://github.com/stashapp/stash/pull/6172))\n* Added option to sort scenes by Performer age. ([#6009](https://github.com/stashapp/stash/pull/6009))\n* Added option to sort scenes by Studio. ([#6155](https://github.com/stashapp/stash/pull/6155))\n* Added option to show external links on Performer cards. ([#6153](https://github.com/stashapp/stash/pull/6153))\n* Improved dimension detection for webp files. ([#6342](https://github.com/stashapp/stash/pull/6342))\n* Added keyboard shortcuts to generate scene screenshot at current time (`c c`) and to regenerate default screenshot (`c d`). ([#5984](https://github.com/stashapp/stash/pull/5984))\n* Added keyboard shortcut for tagger view (`v t`). ([#6261](https://github.com/stashapp/stash/pull/6261))\n* Custom field values are now displayed truncated to 5 lines. ([#6361](https://github.com/stashapp/stash/pull/6361))\n\n### 🐛 Bug fixes\n* **[0.30.1]** fixed hardware encode tests preventing desktop features from working correctly. ([#6417](https://github.com/stashapp/stash/pull/6417))\n* **[0.30.1]** fixed Handy integration not functioning correctly. ([#6425](https://github.com/stashapp/stash/pull/6425))\n* **[0.30.1]** fixed gallery create graphql interface not setting organised flag. ([#6418](https://github.com/stashapp/stash/pull/6418))\n* stash-ids are now set when creating new objects from the scrape dialog. ([#6269](https://github.com/stashapp/stash/pull/6269))\n* partial dates are now correctly handled when scraping scenes. ([#6305](https://github.com/stashapp/stash/pull/6305))\n* Fixed zoom keyboard shortcuts not working. ([#6317](https://github.com/stashapp/stash/pull/6317))\n* Fixed inline videos showing as full-screen on iPhone devices. ([#6259](https://github.com/stashapp/stash/pull/6259))\n* Fixed external player not loading on Android when a scene title has special characters. ([#6297](https://github.com/stashapp/stash/pull/6297))\n* Play activity will now be recorded correctly when reaching the end of a video. ([#6334](https://github.com/stashapp/stash/pull/6334))\n* Fixed markers appearing in the wrong location when player is in fullscreen mode. ([#6323](https://github.com/stashapp/stash/pull/6323))\n* Fixed selected studio/performer being reset when saving a scene in the tagger view. ([#6391](https://github.com/stashapp/stash/pull/6391), [#6409](https://github.com/stashapp/stash/pull/6409))\n* Fixed performer becoming unmatched when creating a new performer with the same name is created. ([#6308](https://github.com/stashapp/stash/pull/6308))\n* Fixed tagger options and buttons not being visible when there are no scenes in the result list. ([#6316](https://github.com/stashapp/stash/pull/6316))\n* Fixed error when scraping a studio if the alias field was empty. ([#6313](https://github.com/stashapp/stash/pull/6313))\n* Fixed existing match stash ID sometimes not being displayed in the performer scrape dialog. ([#6257](https://github.com/stashapp/stash/pull/6257))\n* Fixed download backup function not working when generated directory is on a different filesystem. ([#6244](https://github.com/stashapp/stash/pull/6244))\n* Fixed issue where duplicate file entries would be created if a file was modified and renamed with a different case on case-insensitive filesystems. ([#6327](https://github.com/stashapp/stash/pull/6327))\n* Hardware encoding tests are now performed concurrently at startup to reduce startup time. ([#6414](https://github.com/stashapp/stash/pull/6414))\n* Fixed scraper and plugin locations being converted to absolute paths during setup. ([#6373](https://github.com/stashapp/stash/pull/6373))\n* Fixed Macos version check pointing to incorrect location. ([#6289](https://github.com/stashapp/stash/pull/6289))\n* stash will no longer try to generate marker previews where a marker start is set after the end of a scene's duration. ([#6290](https://github.com/stashapp/stash/pull/6290))\n* Fixed panic when scraping a performer with no measurement value. ([#6367](https://github.com/stashapp/stash/pull/6367))\n\n### Api Changes\n\n* added `remove` field to `CustomFieldsInput` to allow removing specific custom fields when updating objects. ([#6362](https://github.com/stashapp/stash/pull/6362))"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v0310.md",
    "content": "### ✨ New Features\n\n* Added support for image phash generation and filtering. ([#6497](https://github.com/stashapp/stash/pull/6497))\n* Added minimum/maximum number of sprites and sprite size options to support customised scene sprite generation. ([#6588](https://github.com/stashapp/stash/pull/6588))\n* Added support for merging performers. ([#5910](https://github.com/stashapp/stash/pull/5910))\n* Added `Reveal in file manager` button to file info panel when running locally. ([#6587](https://github.com/stashapp/stash/pull/6587))\n* Added `.stashignore` support for gitignore-style scan exclusions. ([#6485](https://github.com/stashapp/stash/pull/6485))\n* Added Selective generate option. ([#6621](https://github.com/stashapp/stash/pull/6621))\n* Added `From Clipboard` option to Set Image dropdown button (on secure connections). ([#6637](https://github.com/stashapp/stash/pull/6637))\n* Added Tags tagger view. ([#6559](https://github.com/stashapp/stash/pull/6559), [#6620](https://github.com/stashapp/stash/pull/6620))\n* Added loop option for markers. ([#6510](https://github.com/stashapp/stash/pull/6510))\n* Added support for custom favicon and title. ([#6366](https://github.com/stashapp/stash/pull/6366))\n* Added Troubleshooting Mode to help identify and resolve common issues. ([#6343](https://github.com/stashapp/stash/pull/6343))\n\n### 🎨 Improvements\n\n* Sidebars are now used for lists of galleries ([#6157](https://github.com/stashapp/stash/pull/6157)), images ([#6607](https://github.com/stashapp/stash/pull/6607)), groups ([#6573](https://github.com/stashapp/stash/pull/6573)), performers ([#6547](https://github.com/stashapp/stash/pull/6547)), studios ([#6549](https://github.com/stashapp/stash/pull/6549)), tags ([#6610](https://github.com/stashapp/stash/pull/6610)), and scene markers ([#6603](https://github.com/stashapp/stash/pull/6603)).\n* Added folder sidebar criterion option for scenes, images and galleries. ([#6636](https://github.com/stashapp/stash/pull/6636))\n* Custom field support has been added to scenes ([#6584](https://github.com/stashapp/stash/pull/6584)), galleries ([#6592](https://github.com/stashapp/stash/pull/6592)), images ([#6598](https://github.com/stashapp/stash/pull/6598)), groups ([#6596](https://github.com/stashapp/stash/pull/6596)) studios ([#6156](https://github.com/stashapp/stash/pull/6156)) and tags ([#6546](https://github.com/stashapp/stash/pull/6546)). \n* Bulk edit dialogs have been refactored to include more fields. ([#6647](https://github.com/stashapp/stash/pull/6647))\n* Extended duplicate criterion to filter by duplicated titles and stash IDs. ([#6344](https://github.com/stashapp/stash/pull/6344))\n* Extended missing criterion to add full coverage of fields. ([#6565](https://github.com/stashapp/stash/pull/6565))\n* Identify settings now allows for selecting included genders. ([#6557](https://github.com/stashapp/stash/pull/6557))\n* Added option to ignore files in zip files while cleaning. ([#6700](https://github.com/stashapp/stash/pull/6700))\n* Backup now provides an option to include blobs in a backup zip. ([#6586](https://github.com/stashapp/stash/pull/6586))\n* Added checkbox selection on wall and tagger views. ([#6476](https://github.com/stashapp/stash/pull/6476))\n* Performer career length field has been replaced with career start and end fields. ([#6449](https://github.com/stashapp/stash/pull/6449))\n* Added organised flag to studios. ([#6303](https://github.com/stashapp/stash/pull/6303))\n* Merging tags now shows a dialog to edit the merged tag's details. ([#6552](https://github.com/stashapp/stash/pull/6552))\n* New object pages now support for saving and creating another object. ([#6438](https://github.com/stashapp/stash/pull/6438))\n* Default performer images have been updated to be consistent with other card images. ([#6566](https://github.com/stashapp/stash/pull/6566))\n* Unsupported filter criteria are now indicated in the UI. ([#6604](https://github.com/stashapp/stash/pull/6604))\n* Marker screenshots can now be generated independently of marker previews. ([#6433](https://github.com/stashapp/stash/pull/6433))\n* Added invert selection option to list menus. ([#6491](https://github.com/stashapp/stash/pull/6491))\n* Added Generate task option for galleries. ([#6442](https://github.com/stashapp/stash/pull/6442))\n* Scene resolution and duration is now shown in the tagger view. ([#6663](https://github.com/stashapp/stash/pull/6663))\n* Added button to delete scene cover. ([#6444](https://github.com/stashapp/stash/pull/6444))\n* Duplicate aliases are now silently removed. ([#6514](https://github.com/stashapp/stash/pull/6514))\n* Image query now includes image details field. ([#6673](https://github.com/stashapp/stash/pull/6673))\n* Select scene/performer/studio/tag dropdowns now accept stash-ids as input. ([#6709](https://github.com/stashapp/stash/pull/6709)) \n* Volume when hovering over a scene preview is now configurable. ([#6712](https://github.com/stashapp/stash/pull/6712))\n* Added non-binary gender icon. ([#6489](https://github.com/stashapp/stash/pull/6489))\n* Transgender icons are now coloured by their presented gender. ([#6489](https://github.com/stashapp/stash/pull/6489))\n* It is now possible to add a library path to a non-existing directory (useful for disconnected network paths). ([#6644](https://github.com/stashapp/stash/pull/6644))\n* Added activity tracking for DLNA resume/view counts. ([#6407](https://github.com/stashapp/stash/pull/6407), [#6483](https://github.com/stashapp/stash/pull/6483))\n* SFW Mode now shows performer ages. ([#6450](https://github.com/stashapp/stash/pull/6450))\n* Added support for sorting scenes and images by resolution. ([#6441](https://github.com/stashapp/stash/pull/6441))\n* Added support for sorting performers and studios by latest scene. ([#6501](https://github.com/stashapp/stash/pull/6501))\n* Added support for sorting performers, studios and tags by total scene file size. ([#6642](https://github.com/stashapp/stash/pull/6642))\n* Added support for filtering by stash ID count. ([#6437](https://github.com/stashapp/stash/pull/6437))\n* Added support for filtering group by scene count. ([#6593](https://github.com/stashapp/stash/pull/6593))\n* Updated Tag list view to be consistent with other list views. ([#6703](https://github.com/stashapp/stash/pull/6703))\n* Installed plugins/scrapers no longer show in the available list. ([#6443](https://github.com/stashapp/stash/pull/6443))\n* Name is now populated when searching by stash-box. ([#6447](https://github.com/stashapp/stash/pull/6447))\n* Improved performance of group queries on large systems. ([#6478](https://github.com/stashapp/stash/pull/6478))\n* Search input is now focused when opening the scraper menu. ([#6704](https://github.com/stashapp/stash/pull/6704))\n* Added support for `{phash}` in `queryURL` scraper field. ([#6701](https://github.com/stashapp/stash/pull/6701))\n* Systray notification now shows the port stash is running on. ([#6448](https://github.com/stashapp/stash/pull/6448))\n\n### 🐛 Bug fixes\n\n* Fixed certain unicode characters in library path causing panic in scan task. ([#6431](https://github.com/stashapp/stash/pull/6431), [#6589](https://github.com/stashapp/stash/pull/6589), [#6635](https://github.com/stashapp/stash/pull/6635))\n* Fixed bad network path error preventing rename detection during scanning. ([#6680](https://github.com/stashapp/stash/pull/6680))\n* Fixed duplicate files in zips being incorrectly reported as renames. ([#6493](https://github.com/stashapp/stash/pull/6493))\n* Fixed merging scene causing cover to be lost. ([#6542](https://github.com/stashapp/stash/pull/6542))\n* Improved scanning algorithm to prevent creation of orphaned folders and handle missing parent folders. ([#6608](https://github.com/stashapp/stash/pull/6608))\n* Scanning no longer scans zip contents when the zip file is unchanged. ([#6633](https://github.com/stashapp/stash/pull/6633))\n* Captions are now correctly detected in a single scan. ([#6634](https://github.com/stashapp/stash/pull/6634))\n* Fixed galleries not being linked to scenes when scanning a matching file. ([#6705](https://github.com/stashapp/stash/pull/6705))\n* Fixed mis-clicks on cards navigating to new page when selecting items. ([#6599](https://github.com/stashapp/stash/pull/6599), [#6649](https://github.com/stashapp/stash/pull/6649))\n* Select dropdown now retains focus after creating a new option. ([#6697](https://github.com/stashapp/stash/pull/6697))\n* Fixed custom field filtering not working correctly when query value was provided. ([#6614](https://github.com/stashapp/stash/pull/6614))\n* Fixed stale thumbnails after file content is changed. ([#6622](https://github.com/stashapp/stash/pull/6622))\n* Clicking on the scrubber in the scene player no longer pauses the video. ([#6336](https://github.com/stashapp/stash/pull/6336))\n* Fixed string-based hash filtering not functioning correctly. ([#6654](https://github.com/stashapp/stash/pull/6654))\n* Fixed hardware decoding detection for 10-bit videos on rkmpp. ([#6420](https://github.com/stashapp/stash/pull/6420))\n\n### Api Changes\n\n* Many new components are now patchable. ([#6468](https://github.com/stashapp/stash/pull/6468), [#6463](https://github.com/stashapp/stash/pull/6463), [#6482](https://github.com/stashapp/stash/pull/6482), [#6492](https://github.com/stashapp/stash/pull/6492), [#6470](https://github.com/stashapp/stash/pull/6470))\n* Added access to `ReactFontAwesome` in the plugin API. ([#6487](https://github.com/stashapp/stash/pull/6487))\n* Added `destroyFiles` mutation to delete file entries from the database. ([#6437](https://github.com/stashapp/stash/pull/6437))\n* Added `destroy_file_entry` flag to destroy inputs to destroy file entries when destroying scenes, images, and galleries. ([#6437](https://github.com/stashapp/stash/pull/6437))\n* Added `basename` and `parent_folders` fields to the `folder` type. ([#6494](https://github.com/stashapp/stash/pull/6494))\n* Added `basename` filter field to `FolderFilterType`. ([#6494](https://github.com/stashapp/stash/pull/6494))\n* Added `parent_folder` filter field to `GalleryFilterType`. ([#6636](https://github.com/stashapp/stash/pull/6636))\n* Performer `career_length` field is deprecated in favour of `career_start` and `career_end`.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v040.md",
    "content": "#### 💥 **Note: After upgrading, please [verify your stash library settings](/settings?tab=configuration) and perform a [scan](/settings?tab=tasks) to populate gallery images and the file modification times in the database. **\n\n### ✨ New Features\n* Add selective scan.\n* Add selective export of all objects.\n* Add stash-box tagger to scenes page.\n* Add filters tab in scene page.\n* Add selectable streaming quality profiles in the scene player.\n* Add gallery metadata scraping.\n* Add scrapers list setting page.\n* Add support for individual images and manual creation of galleries.\n* Add various fields to galleries.\n* Add partial import from zip file.\n\n### 🎨 Improvements\n* Add equals/not equals string criteria.\n* Increase page size limit to 1000 and add new page size options.\n* Add support for query URL parameter regex replacement when scraping by query URL.\n* Include empty fields in isMissing filter\n* Show static image on scene wall if preview video is missing.\n* Add path filter to scene and gallery query.\n* Add button to hide left panel on scene page.\n* Add link to parent studio in studio page.\n* Add missing scenes movie filter.\n* Add gallery icon to scene cards.\n* Add country query link to performer flag.\n* Improved gallery layout.\n* Add hover delay before scene preview is played.\n* Re-show preview thumbnail when mousing away from scene card.\n\n### 🐛 Bug fixes\n* Changed startup behaviour to only set libraries from `STASH_STASH` environment variable if not already set.\n* Don't set default studio image during studio creation.\n* Update Freeones scraper for website update.\n* Fix invalid date tag preventing video file from being scanned.\n* Fix error when creating movie from scene scrape dialog.\n* Fix incorrect date timezone.\n* Fix search filters not persisting for studios, markers and galleries.\n* Fix pending thumbnail on wall items on mobile platforms.\n* Fix downloading and permissions for ffmpeg/ffprobe.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v050.md",
    "content": "#### 💥 Note: After upgrading, all scene file sizes will be 0B until a new [scan](/settings?tab=tasks) is run.\n\n### ✨ New Features\n* Add support for multiple galleries per scene, and vice-versa.\n* Add backup database functionality to Settings/Tasks.\n* Add gallery wall view.\n* Add organized flag for scenes, galleries and images.\n* Allow configuration of visible navbar items.\n\n### 🎨 Improvements\n* Added Donate button to top navbar.\n* Add directory selection to auto-tag task.\n* Add string matches/not matches regex filter criteria.\n* Added configuration option for import file size limit and increased default to 1GB.\n* Add dry-run option for Clean task.\n* Refresh UI when changing custom CSS options.\n* Add batch deleting of performers, tags, studios, and movies.\n* Reset cache after scan/clean to ensure scenes are updated.\n* Add more video/image resolution tags.\n* Add option to strip file extension from scene title when populating from scanning task.\n* Pagination support and general improvements for image lightbox.\n* Add mouse click support for CDP scrapers.\n* Add gallery tabs to performer and studio pages.\n* Add gallery scrapers to scraper page.\n* Add support for setting cookies in scrapers.\n* Truncate long text and show on hover.\n* Show scene studio as text where image is missing.\n* Use natural sort for titles and movie names.\n* Support optional preview and sprite generation during scanning.\n* Support configurable number of threads for scanning and generation.\n\n### 🐛 Bug fixes\n* Fix error when unsetting image studio.\n* Fix input fields being wiped when an error occurs creating a performer.\n* Fix edit data being lost when clicking the O-Counter, Organized or Favorite buttons.\n* Exclude media in `generated` directory from the library.\n* Prevent cover image from being incorrectly regenerated when a scene file's hash changes.\n* Fix version check sometimes giving incorrect results.\n* Fix stash potentially deleting `downloads` directory when first run.\n* Fix sprite generation when generated path has special characters.\n* Prevent studio from being set as its own parent\n* Fixed performer scraper select overlapping search results\n* Fix tag/studio images not being changed after update.\n* Fixed resolution tags and querying for portrait videos and images.\n* Corrected file sizes on 32bit platforms\n* Fixed login redirect to remember the current page.\n* Fixed scene tagger config saving\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v060.md",
    "content": "### ✨ New Features\n* Added Performer tags.\n\n### 🎨 Improvements\n* Improve performer scraper search modal.\n* Add galleries tab to Tag details page.\n* Allow scene/performer/studio image upload via URL.\n* Add button to hide unmatched scenes in Tagger view.\n* Hide create option in dropdowns when searching in filters.\n* Add scrape gallery from fragment to UI\n* Improved performer details and edit UI pages.\n* Resolve python executable to `python3` or `python` for python script scrapers.\n* Add `url` field to `URLReplace`, and make `queryURLReplace` available when scraping by URL.\n* Make logging format consistent across platforms and include full timestamp.\n* Remember gallery images view mode.\n* Add option to skip checking of insecure SSL certificates when scraping.\n* Auto-play video previews on mobile devices.\n* Replace hover menu with dropdown menu for O-Counter.\n* Support random strings for scraper cookie values.\n* Added Rescan button to scene, image, gallery details overflow button.\n\n### 🐛 Bug fixes\n* Fix SQL error when filtering nullable string fields with regex.\n* Fix incorrect folders being excluded during scanning.\n* Filter out streaming resolution options that are over the maximum streaming resolution.\n* Fix `cover.jpg` not being detected as cover image when in sub-directory.\n* Fix scan re-associating galleries to the same scene.\n* Fix SQL error when filtering galleries excluding performers or tags.\n* Fix version checking for armv7 and arm64.\n* Change \"Is NULL\" filter to include empty string values.\n* Prevent scene card previews playing in full-screen on iOS devices.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v070.md",
    "content": "### ✨ New Features\n* Added stash-box performer tagger.\n* Auto-tagger now tags images and galleries.\n* Added rating field to performers and studios.\n* Support serving UI from specific directory location.\n* Added details, death date, hair color, and weight to Performers.\n* Added `lbToKg` post-process action for performer scrapers.\n* Added details to Studios.\n* Added [perceptual dupe checker](/sceneDuplicateChecker).\n* Add various `count` filter criteria and sort options.\n* Add URL filter criteria for scenes, galleries, movies, performers and studios.\n* Add HTTP endpoint for health checking at `/healthz`.\n* Add random sorting option for galleries, studios, movies and tags.\n* Support access to system without logging in via API key.\n* Added scene queue.\n\n### 🎨 Improvements\n* Improve sprite generation performance when using network storage.\n* Remove duplicate values when scraping lists of elements.\n* Improved performance of the auto-tagger.\n* Clean generation artifacts after generating each scene.\n* Log message at startup when cleaning the `tmp` and `downloads` generated folders takes more than one second.\n* Sort movie scenes by scene number by default.\n* Support http request headers in scrapers.\n* Sort performers by gender in scene/image/gallery cards and details.\n* Add popover buttons for scenes/images/galleries on performer/studio/tag cards.\n* Add slideshow to image wall view.\n* Support API key via URL query parameter, and added API key to stream link in Scene File Info.\n* Revamped setup wizard and migration UI.\n* Scroll to top when changing page number.\n* Support `today` and `yesterday` for `parseDate` in scrapers.\n* Disable sounds on scene/marker wall previews by default.\n* Improve Movie UI.\n* Change performer text query to search by name and alias only.\n\n### 🐛 Bug fixes\n* Fix image/gallery title not being set during scan.\n* Reverted video previews always playing on small devices.\n* Fix performer/studio being cleared when skipped in scene tagger.\n* Fixed error when auto-tagging for performers/studios/tags with regex characters in the name.\n* Fix scraped performer image not updating after clearing the current image when creating a new performer.\n* Fix error preventing adding a new library path when an existing library path is missing.\n* Fix whitespace in query string returning all objects. \n* Fix hang on Login page when not connected to internet.\n* Fix `Clear Image` button not updating image preview.\n* Fix processing some webp files.\n* Fix incorrect performer age calculation in UI.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v080.md",
    "content": "### ✨ New Features\n* Added filter criteria for name, details and hash related fields. ([#1505](https://github.com/stashapp/stash/pull/1505))\n* Added button to open scene in external player on handheld devices. ([#679](https://github.com/stashapp/stash/pull/679))\n* Added support for saved and default filters. ([#1474](https://github.com/stashapp/stash/pull/1474))\n* Added merge tags functionality. ([#1481](https://github.com/stashapp/stash/pull/1481))\n* Added support for triggering plugin tasks during operations. ([#1452](https://github.com/stashapp/stash/pull/1452))\n* Support Studio filter including child studios. ([#1397](https://github.com/stashapp/stash/pull/1397))\n* Added support for tag aliases. ([#1412](https://github.com/stashapp/stash/pull/1412))\n* Support embedded Javascript plugins. ([#1393](https://github.com/stashapp/stash/pull/1393))\n* Revamped job management: tasks can now be queued. ([#1379](https://github.com/stashapp/stash/pull/1379))\n* Added Handy/Funscript support. ([#1377](https://github.com/stashapp/stash/pull/1377))\n* Added Performers tab to Studio page. ([#1405](https://github.com/stashapp/stash/pull/1405))\n* Added [DLNA server](/settings?tab=dlna). ([#1364](https://github.com/stashapp/stash/pull/1364))\n\n### 🎨 Improvements\n* Allow navigation to previous/next image in carousel by clicking left/right side of image. ([#1516](https://github.com/stashapp/stash/pull/1516))\n* Include `Host` in input to plugins. ([#1514](https://github.com/stashapp/stash/pull/1514))\n* Added internationalisation for all UI pages and added zh-TW language option. ([#1471](https://github.com/stashapp/stash/pull/1471))\n* Add option to disable audio for generated previews. ([#1454](https://github.com/stashapp/stash/pull/1454))\n* Prompt when leaving scene edit page with unsaved changes. ([#1429](https://github.com/stashapp/stash/pull/1429))\n* Make multi-set mode buttons more obvious in multi-edit dialog. ([#1435](https://github.com/stashapp/stash/pull/1435))\n* Filter modifiers and sort by options are now sorted alphabetically. ([#1406](https://github.com/stashapp/stash/pull/1406))\n* Add `CreatedAt` and `UpdatedAt` (and `FileModTime` where applicable) to API objects. ([#1421](https://github.com/stashapp/stash/pull/1421))\n* Add Studios Performer filter criterion. ([#1405](https://github.com/stashapp/stash/pull/1405))\n* Add `subtractDays` post-process scraper action. ([#1399](https://github.com/stashapp/stash/pull/1399))\n* Skip scanning directories if path matches image and video exclude patterns. ([#1382](https://github.com/stashapp/stash/pull/1382))\n* Add button to remove studio stash ID. ([#1378](https://github.com/stashapp/stash/pull/1378))\n\n### 🐛 Bug fixes\n* Fix scene query not being cached correctly when navigating using back. ([#1533](https://github.com/stashapp/stash/pull/1533))\n* Fix query with multiple table joins causing invalid query SQL. ([#1510](https://github.com/stashapp/stash/pull/1510))\n* Fix file move detection when case of filename is changed on case-insensitive file systems. ([#1426](https://github.com/stashapp/stash/issues/1426))\n* Fix auto-tagger not tagging scenes with no whitespace in name. ([#1488](https://github.com/stashapp/stash/pull/1488))\n* Fix click/drag to select scenes. ([#1476](https://github.com/stashapp/stash/pull/1476))\n* Fix clearing Performer and Movie ratings not working. ([#1429](https://github.com/stashapp/stash/pull/1429))\n* Fix scraper date parser failing when parsing time. ([#1431](https://github.com/stashapp/stash/pull/1431))\n* Fix quotes in filter labels causing UI errors. ([#1425](https://github.com/stashapp/stash/pull/1425))\n* Fix post-processing not running when scraping by performer fragment. ([#1387](https://github.com/stashapp/stash/pull/1387))\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Changelog/v090.md",
    "content": "### ✨ New Features\n* Support setting a fixed funscript offset/delay. ([#1573](https://github.com/stashapp/stash/pull/1573))\n* Added sort by options for image and gallery count for performers. ([#1671](https://github.com/stashapp/stash/pull/1671))\n* Added sort by options for date, duration and rating for movies. ([#1663](https://github.com/stashapp/stash/pull/1663))\n* Allow saving query page zoom level in saved and default filters. ([#1636](https://github.com/stashapp/stash/pull/1636))\n* Support custom page sizes in the query page size dropdown. ([#1636](https://github.com/stashapp/stash/pull/1636))\n* Added between/not between modifiers for number criteria. ([#1559](https://github.com/stashapp/stash/pull/1559))\n* Support excluding tag patterns when scraping. ([#1617](https://github.com/stashapp/stash/pull/1617))\n* Support setting a custom directory for default performer images. ([#1489](https://github.com/stashapp/stash/pull/1489))\n* Added filtering and sorting on scene marker count for tags. ([#1603](https://github.com/stashapp/stash/pull/1603))\n* Support excluding fields and editing tags when saving from scene tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605))\n* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))\n\n### 🎨 Improvements\n* Added support for loading TLS/SSL configuration files from the configuration directory. ([#1678](https://github.com/stashapp/stash/pull/1678))\n* Added total scenes duration to Stats page. ([#1626](https://github.com/stashapp/stash/pull/1626))\n* Move Play Selected Scenes, and Add/Remove Gallery Image buttons to button toolbar. ([#1673](https://github.com/stashapp/stash/pull/1673))\n* Added image and gallery counts to tag list view. ([#1672](https://github.com/stashapp/stash/pull/1672))\n* Prompt when leaving gallery and image edit pages with unsaved changes. ([#1654](https://github.com/stashapp/stash/pull/1654), [#1669](https://github.com/stashapp/stash/pull/1669))\n* Show largest duplicates first in scene duplicate checker. ([#1639](https://github.com/stashapp/stash/pull/1639))\n* Added checkboxes to scene list view. ([#1642](https://github.com/stashapp/stash/pull/1642))\n* Added keyboard shortcuts for scene queue navigation. ([#1635](https://github.com/stashapp/stash/pull/1635))\n* Made performer scrape menu scrollable. ([#1634](https://github.com/stashapp/stash/pull/1634))\n* Improve Studio UI. ([#1629](https://github.com/stashapp/stash/pull/1629))\n* Improve link styling and ensure links open in a new tab. ([#1622](https://github.com/stashapp/stash/pull/1622))\n* Added zh-CN language option. ([#1620](https://github.com/stashapp/stash/pull/1620))\n* Moved scraping settings into the Scraping settings page. ([#1548](https://github.com/stashapp/stash/pull/1548))\n* Show current scene details in tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605))\n* Removed stripes and added background colour to default performer images (old images can be downloaded from the PR link). ([#1609](https://github.com/stashapp/stash/pull/1609))\n* Added pt-BR language option. ([#1587](https://github.com/stashapp/stash/pull/1587))\n* Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578))\n\n### 🐛 Bug fixes\n* Fix SQL error when filtering for Performers missing stash IDs. ([#1681](https://github.com/stashapp/stash/pull/1681))\n* Fix Play Selected scene UI error when one scene is selected. ([#1674](https://github.com/stashapp/stash/pull/1674))\n* Fix race condition panic when reading and writing config concurrently. ([#1645](https://github.com/stashapp/stash/issues/1343))\n* Fix performance issue on Studios page getting studio image count. ([#1643](https://github.com/stashapp/stash/pull/1643))\n* Regenerate scene phash if overwrite flag is set. ([#1633](https://github.com/stashapp/stash/pull/1633))\n* Create .stash directory in $HOME only if required. ([#1623](https://github.com/stashapp/stash/pull/1623))\n* Include stash id when scraping performer from stash-box. ([#1608](https://github.com/stashapp/stash/pull/1608))\n* Fix infinity framerate values causing resolver error. ([#1607](https://github.com/stashapp/stash/pull/1607))\n* Fix unsetting performer gender not working correctly. ([#1606](https://github.com/stashapp/stash/pull/1606))\n* Fix is missing date scene criterion causing invalid SQL. ([#1577](https://github.com/stashapp/stash/pull/1577))\n* Fix rendering of carousel images on Apple devices. ([#1562](https://github.com/stashapp/stash/pull/1562))\n* Show New and Delete buttons in mobile view. ([#1539](https://github.com/stashapp/stash/pull/1539))"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/AutoTagging.md",
    "content": "# Auto Tag\n\nAuto tag automatically assigns Performers, Studios, and Tags to your media based on their names found in file paths or filenames. This task works for scenes, images, and galleries.\n\nThis task is part of the advanced settings mode.\n\n## Rules\n\n> **⚠️ Important:** Auto tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags.\n\n - Multi-word names are matched when words appear in order and are separated by any of these characters: `.`, `-`, `_`, or whitespace. These separators are treated as word boundaries.\n - Matching is case-insensitive but requires complete words within word boundaries. Partial words or misspelled words will not match.\n - Auto tag does not match performer aliases. Aliases will not be considered during matching.\n\n### Examples (performer \"Jane Doe\")\n\n**Matches:**\n\n| Example | Explanation |\n|---|---|\n| `Jane.Doe.1.mp4` | Dot as separator. |\n| `Jane_Doe.2.mp4` | Underscore as separator. |\n| `Jane-Doe.3.mp4` | Hyphen as separator. |\n| `Jane Doe.4.mp4` | Whitespace as separator. |\n| `Mary-Jane-Doe` | Extra characters around word boundaries are allowed. |\n| `Jane-Doe_n` | Extra characters around word boundaries are allowed. |\n| `[OF]jane doe` | Extra characters around word boundaries are allowed. |\n\n**Does not match:**\n\n| Example | Explanation |\n|---|---|\n| `Maryjane-Doe` | Combined words without separator. |\n| `Jane-Doen` | Spelling mismatch. |\n\n### Organized flag\n\nScenes, images, and galleries that have the Organized flag added to them will not be modified by Auto tag. You can also use Organized flag status as a filter.\n\nStudios also support the Organized flag, however it is purely informational. It serves as a front-end indicator for the user to mark that a studio's collection is complete and does not affect Auto tag behavior. The Ignore Auto tag flag should be used to exclude a studio from Auto tag.\n\n### Ignore Auto tag flag\n\nPerformers or Tags that have Ignore Auto tag flag added to them will be skipped by the Auto tag task.\n\n## Running task\n\n- **Auto tag:** You can run the Auto tag task on your entire library from the Tasks page.\n- **Selective auto tag:** You can run the Auto tag task on specific directories from the Tasks page.\n- **Individual pages:** You can run Auto tag tasks for specific Performers, Studios, and Tags from their respective pages.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Browsing.md",
    "content": "# Browsing\n\n## Querying and Filtering\n\n### Keyword searching\n\nThe text field allows you to search using keywords. Keyword searching matches on different fields depending on the object type:\n\n| Type | Fields searched |\n|------|-----------------|\n| Scene | Title, Details, Path, OSHash, Checksum, Marker titles |\n| Image | Title, Path, Checksum |\n| Group | Title |\n| Marker | Title, Scene title |\n| Gallery | Title, Path, Checksum |\n| Performer | Name, Aliases |\n| Studio | Name, Aliases |\n| Tag | Name, Aliases |\n\nKeyword matching uses the following rules:\n\n* all words are required in the matching field. For example, `foo bar` matches scenes with both `foo` and `bar` in the title.\n* the `or` keyword or symbol (`|`) is used to match either fields. For example, `foo or bar` (or `foo | bar`) matches scenes with `foo` or `bar` in the title. Or sets can be combined. For example, `foo or bar or baz xyz or zyx` matches scenes with one of `foo`, `bar` and `baz`, *and* `xyz` or `zyx`.\n* the not symbol (`-`) is used to exclude terms. For example, `foo -bar` matches scenes with `foo` and excludes those with `bar`. The not symbol cannot be combined with an or operand. That is, `-foo or bar` will be interpreted to match `-foo` or `bar`. On the other hand, `foo or bar -baz` will match `foo` or `bar` and exclude `baz`.\n* surrounding a phrase in quotes (`\"`) matches on that exact phrase. For example, `\"foo bar\"` matches scenes with `foo bar` in the title. Quotes may also be used to escape the keywords and symbols. For example, `foo \"-bar\"` will match scenes with `foo` and `-bar`.\n* quoted phrases may be used with the or and not operators. For example, `\"foo bar\" or baz -\"xyz zyx\"` will match scenes with `foo bar` *or* `baz`, and exclude those with `xyz zyx`.\n* `or` keywords or symbols at the start or end of a line will be treated literally. That is, `or foo` will match scenes with `or` and `foo`.\n* all keyword matching is case-insensitive\n\n### Filters\n\nFilters can be accessed by clicking the filter button on the right side of the query text field. \n\nNote that only one filter criterion per criterion type may be assigned.\n\n#### Regex modifiers\n\nSome filters have regex modifier as an option. Regex modifiers are case-sensitive by default.\n\n### Sorting and page size\n\nThe current sorting field is shown next to the query text field, indicating the current sort field and order. The page size dropdown allows selecting from a standard set of objects per page, and allows setting a custom page size.\n\n### Saved filters\n\nSaved filters can be accessed with the bookmark button on the left of the query text field. The current filter can be saved by entering a filter name and clicking on the save button. Existing saved filters may be overwritten with the current filter by clicking on the save button next to the filter name. Saved filters may also be deleted by pressing the delete button next to the filter name.\n\nSaved filters are sorted alphabetically by title with capitalized titles sorted first.\n\n### Default filter\n\nThe default filter for the top-level pages may be set to the current filter by clicking the `Set as default` button in the saved filter menu.\n\n## Reveal file in file manager\n\nThe `Reveal in file manager` action is available for file-based scenes, galleries and images in the `File Info` tab. This action will open the file manager to the location of the file on disk. The file will be selected if supported by the file manager.\n\nThis button will only be available when accessing stash from a local loopback address (e.g. `localhost` or `127.0.0.1`), and will not be shown when accessing stash from a remote address."
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Captions.md",
    "content": "# Captions\n\nStash supports captioning with SRT and VTT files.\n\nCaptions will only be detected if they are located in the same folder as the corresponding scene file.\n\nEnsure the caption files follow these naming conventions:\n\n## Scene\n\n- {scene_file_name}.{language_code}.ext\n- {scene_file_name}.ext\n\nWhere `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `ext` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine.\n\nScenes with captions can be filtered with the `captions` criterion.\n\n> **⚠️ Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective scan task for it to show up.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Configuration.md",
    "content": "# Configuration\n\n## Library\n\nThis section enables you to add or remove directories that will be discoverable by Stash. The directories you add will be utilized for scanning new files and for updating their locations in Stash database.\n\nYou can configure these directories to apply specifically to:\n\n- **Videos**\n- **Images**\n- **Both**\n\n> **⚠️ Note:** Don't forget to click `Save` after updating these directories!\n\n## Excluded patterns\n\nGiven a valid [regex](https://github.com/google/re2/wiki/Syntax), files that match even partially are excluded during the Scan process and are not entered in the database. Also during the Clean task if these files exist in the DB they are removed from it and their generated files get deleted.  \nPrior to matching both the filenames and patterns are converted to lower case so the match is case insensitive.\n\nRegex patterns can be added in the config file or from the UI.  \nIf you add manually to the config file a restart is needed while from the UI you just need to click the Save button.  \nWhen added through the config file directly special care must be given to double escape the `\\` character.\n\nThere are 2 separate exclusion settings. One is for videos, another is for images/galleries.\n\nSome examples:\n\n- `\"sample\\.mp4$\"` will exclude all files ending in `sample.mp4`. \n- `\"/\\.[[:word:]]+/\"` will exclude all hidden directories like `/.directoryname/`.\n- `\"c:\\\\stash\\\\videos\\\\exclude\"` will exclude specific Windows directory `c:\\stash\\videos\\exclude`.\n- `\"^/stash/videos/exclude/\"` will exclude all directories that match `/stash/videos/exclude/` pattern.\n- `\"\\\\\\\\stash\\\\network\\\\share\\\\excl\\\\\"` will exclude specific Windows network path `\\\\stash\\network\\share\\excl\\`.\n\n> **⚠️ Note:** If a directory is excluded for images and videos, then the directory will be excluded from scans completely.\n\n_There is a useful [regex101](https://regex101.com/) site that can help test and experiment with regexps._\n\n## Gallery creation from folders\n\nIn the Library section you can find an option to create a gallery from each folder containing images. This will be applied on all libraries when activated, including the base folder of a library. \n\nIf you wish to apply this on a per folder basis, you can create a file called **.nogallery** or **.forcegallery** in a folder that should act different than this global setting.\n\nThis will either exclude the folder from becoming a gallery even if the setting is set, or create a gallery from the folder even if the setting is not set. \n\nThe file will only be recognized if written in lower case letters.\n\nFiles with a dot in front are handled as hidden in the Linux OS and Mac OS, so you will not see those files after creation on your system without setting your file manager accordingly.\n\n## Hashing algorithms\n\nStash identifies video files by calculating a hash of the file. There are two algorithms available for hashing: `oshash` and `MD5`. `MD5` requires reading the entire file, and can therefore be slow, particularly when reading files over a network. `oshash` (which uses OpenSubtitle's hashing algorithm) only reads 64k from each end of the file.\n\nThe hash is used to name the generated files such as preview images and videos, and sprite images.\n\nBy default, new systems have MD5 calculation disabled for optimal performance. Existing systems that are upgraded will have the oshash populated for each scene on the next scan.\n\n### Changing the hashing algorithm\n\nTo change the file naming hash to oshash, all scenes must have their oshash values populated. oshash population is done automatically when scanning.\n\nTo change the file naming hash to `MD5`, the MD5 must be populated for all scenes. To do this, `Calculate MD5` for videos must be enabled and the library must be rescanned.\n\nMD5 calculation may only be disabled if the file naming hash is set to `oshash`.\n\nAfter changing the file naming hash, any existing generated files will now be named incorrectly. This means that stash will not find them and may regenerate them if the `Generate task` is used. To remedy this, run the `Rename generated files` task, which will rename existing generated files to their correct names.\n\n#### Step-by-step instructions to migrate to oshash for existing users\n\nThese instructions are for existing users whose systems will be defaulted to use and calculate MD5 checksums. Once completed, MD5 checksums will no longer be calculated when scanning, and oshash will be used for generated file naming. Existing calculated MD5 checksums will remain on scenes, but checksums will not be calculated for new scenes.\n\n1. Scan the library (to populate oshash for all existing scenes).\n2. In Settings -> System page, untick `Calculate MD5` and select `oshash` as file naming hash. Save the configuration.\n3. In Settings -> Tasks page, click on the `Rename generated files` migration button.\n\n\n## Parallel scan/generation\n\n#### Number of parallel task for scan/generation\n\nThis setting controls how many sub-tasks will be run in parallel during scanning and generation tasks. (See Tasks)\n\nAuto-detection can be enabled by setting this to zero. This will calculate the number of parallel tasks to be logical cores/4 + 1.\n\nThis setting can be used to increase/decrease overall CPU utilisation in two scenarios:\n\n1. High performance 4+ core cpus.\n2. Media files stored on remote/cloud filesystem.\n\n> **⚠️ Note:** If this is set too high it will decrease overall performance and causes failures (out of memory).\n\n## Sprite generation\n\n### Sprite size\n\nFixed size of a generated sprite, being the longest dimension in pixels. \nSetting this to `0` will fallback to the default of `160`.\nAlthought it is possible to set this value to anything bigger than `0` it is recommended to set it to `160` at least.\n\n### Use custom sprite generation\n\nIf this setting is disabled, the settings below will be ignored and the default sprite generation settings are used.\n\n### Sprite interval\n\nThis represents the time in seconds between each sprite to be generated. This value will be adjusted if necessary to fit within the bounds of the `Minimum Sprites` and `Maximum Sprites` settings.\n\nSetting this to `0` means that the sprite interval will be calculated based on the value of the `Minimum Sprites` field.\n\n### Minimum sprites\n\nThe minimal number of distinct sprites that will be generated for a scene. `Sprite interval` will be adjusted if necessary.\nSetting this to `0` will fallback to the default of `10`\n\n### Maximum sprites\n\nThe maximum number of distinct sprites that will be generated for a scene. `Sprite interval` will be adjusted if necessary.\nSetting this to `0` indicates there is no maximum.\n\n> **⚠️ Note:** The number of generated sprites is adjusted upwards to the next perfect square to ensure the sprite image is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns). This means that if you set a minimum of 10 sprites, 16 will actually be generated, and if you set a maximum of 15 sprites, 16 will actually be generated.\n\n## Hardware accelerated live transcoding\n\nHardware accelerated live transcoding can be enabled by setting the `FFmpeg hardware encoding` setting. Stash outputs the supported hardware encoders to the log file on startup at the Info log level. If a given hardware encoder is not supported, it's error message is logged to the Debug log level for debugging purposes.\n\n## HLS/DASH streaming\n\nTo stream using HLS (such as on Apple devices) or DASH, the Cache path must be set. This directory is used to store temporary files during the live-transcoding process. The Cache path can be set in the System settings page. \n\n## ffmpeg arguments\n\nAdditional arguments can be injected into ffmpeg when generating previews and sprites, and when live-transcoding videos. \n\nThe ffmpeg arguments configuration is split into `Input` and `Output` arguments. Input arguments are injected before the input file argument, and output arguments are injected before the output file argument.\n\nArguments are accepted as a list of strings. Each string is a separate argument. For example, a single argument of `-foo bar` would be treated as a single argument `\"-foo bar\"`. The correct way to pass this argument would be to split it into two separate arguments: `\"-foo\", \"bar\"`.\n\n## Scraping\n\n### User Agent string\n\nSome websites require a legitimate User-Agent string when receiving requests, or they will be rejected. If entered, this string will be applied as the `User-Agent` header value in http scrape requests.\n\n### Chrome CDP path\n\nSome scrapers require a Chrome instance to function correctly. If left empty, stash will attempt to find the Chrome executable in the path environment, and will fail if it cannot find one.\n\n`Chrome CDP path` can be set to a path to the chrome executable, or an http(s) address to remote chrome instance (for example: `http://localhost:9222/json/version`).\n\n> **⚠️ Important:** As of Chrome 136 you need to specify `--user-data-dir` alongside `--remote-debugging-port`. Read more on their [official post](https://developer.chrome.com/blog/remote-debugging-port). \n\n## Authentication\n\nBy default, stash is not configured with any sort of password protection. To enable password protection, both `Username` and `Password` must be populated. Note that when entering a new username and password where none was set previously, the system will immediately request these credentials to log you in.\n\n## API key\n\nIf password protection is enabled, you may also generate an API key. An API key is used by external systems to access your stash system without needing to login first.\n\nExternal systems using the API key must set the `ApiKey` header value to the configured API key in order to bypass the login requirement.\n\n### Logging out\n\nThe logout button is situated in the upper-right part of the screen when you are logged in.\n\n### Recovering from a forgotten username or password\n\nStash saves login credentials in the config.yml file. You must reset both login and password if you have forgotten your password by doing the following:\n\n* Close your Stash process\n* Open the `config.yml` file found in your Stash directory with a text editor\n* Delete the `username` and `password` lines from the file and save\n\nStash authentication should now be reset with no authentication credentials.\n\n## Advanced configuration options\n\nThese options are typically not exposed in the UI and must be changed manually in the `config.yml` file.\n\n| Field | Remarks |\n|-------|---------|\n| `custom_served_folders` | A map of URLs to file system folders. See below. |\n| `custom_ui_location` | The file system folder where the UI files will be served from, instead of using the embedded UI. Empty to disable. Stash must be restarted to take effect. |\n| `developer_options.extra_blob_paths` | A list of alternative blob paths. These paths will be read for blob files. Blobs will not be written or deleted from these paths. Intended for developer use only. |\n| `max_upload_size` | Maximum file upload size for import files. Defaults to 1GB. |\n| `theme_color` | Sets the `theme-color` property in the UI. |\n| `gallery_cover_regex` | The regex responsible for selecting images as gallery covers |\n| `proxy` | The url of a HTTP(S) proxy to be used when stash makes calls to online services. Example: https://user:password@my.proxy:8080. Note: SOCKS5 proxies are unsupported. |\n| `no_proxy` | A list of domains for which the proxy must not be used. Default is all local LAN: localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 |\n| `sequential_scanning` | Modifies behaviour of the scanning functionality to generate support files (previews/sprites/phash) at the same time as fingerprinting/screenshotting. Useful when scanning cached remote files. |\n\nThe following environment variables are also supported:\n\n| Environment variable | Remarks |\n|----------------------|---------|\n| `STASH_SQLITE_CACHE_SIZE` | Sets the SQLite cache size. See https://www.sqlite.org/pragma.html#pragma_cache_size. Default is `-2000` which is 2MB. |\n\n### Custom favicon\n\nYou can provide a custom favicon by placing a `favicon.ico` file in the configuration directory. The configuration directory is located alongside the `config.yml` file.\n\nWhen a custom favicon is provided, it will be served instead of the default embedded favicon.\n\n### Custom served folders\n\nCustom served folders are served when the server handles a request with the `/custom` URL prefix. The following is an example configuration:\n\n```\ncustom_served_folders:\n  /: D:\\stash\\static\n  /foo: D:\\bar\n```\n\nWith the above configuration, a request for `/custom/foo/bar.png` would serve `D:\\bar\\bar.png`. \n\nThe `/` entry matches anything that is not otherwise mapped by the other entries. For example, `/custom/baz/xyz.png` would serve `D:\\stash\\static\\baz\\xyz.png`.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Contributing.md",
    "content": "# Ways to contribute\n\n## Financial\n\nFinancial contributions are welcomed and are accepted using [Open Collective](https://opencollective.com/stashapp).\n\n## Development-related\n\nThe Stash backend is written in Go, using a SQLite database. The UI is written in Typescript, using React. Bug fixes, improvements and new features are welcomed. Please see the [DEVELOPMENT.md](https://github.com/stashapp/stash/blob/develop/docs/DEVELOPMENT.md) file for details on how to get started. Assistance is available via our [Discord](https://discord.gg/2TsNFKt).\n\n## Documentation\n\nEfforts to improve documentation in Stash helps new users and reduces the number of questions we have to field in Discord. Contributions to documentation are welcomed. While submitting documentation changes via GitHub pull requests is ideal, we will gladly accept submissions via [GitHub issues](https://github.com/stashapp/stash/issues) or on [Discord](https://discord.gg/2TsNFKt).\n\nFor those with web page experience, we also welcome contributions to our [website](https://stashapp.cc/) (which as of writing is very undeveloped).\n\n## Testing features, improvements and bug fixes\n\nTesting is currently covered by a very small group, so new testers are welcomed. Being able to build Stash locally is ideal, but binaries for pull requests are also available.\n\nFirst, you will need to be signed in to GitHub. Find and open the relevant pull request, and then click on the `Checks` tab. On the right, there should be a button titled `Artifacts` - click that, and you should get a dropdown with links to download binaries built from that pull request for Linux, Windows and macOS.\n\n## Submitting and contributing to bug reports, improvements and new features\n\nWe welcome ideas for future improvements and features, and bug reports help everyone. These can all be found on [GitHub](https://github.com/stashapp/stash/issues).\n\n## Providing support\n\nOffering support for new users on our [Community forum](https://discourse.stashapp.cc/) and [Discord](https://discord.gg/2TsNFKt) is also welcomed.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Deduplication.md",
    "content": "# Dupe Checker\n\n[The dupe checker](/sceneDuplicateChecker) searches your collection for scenes that are perceptually similar. This means that the files don't need to be identical, and will be identified even with different bitrates, resolutions, and intros/outros.\n\nTo achieve this stash needs to generate what's called a phash, or perceptual hash. Similar to sprite generation stash will generate a set of 25 images from fixed points in the scene. These images will be stitched together, and then hashed using the phash algorithm. The phash can then be used to find scenes that are the same or similar to others in the database. Phash generation can be run during scan, or as a separate task. \n\n> **⚠️ Note:** Generation can take a while due to the work involved with extracting screenshots.\n\nThe dupe checker can be run with four different levels of accuracy. `Exact` looks for scenes that have exactly the same phash. This is a fast and accurate operation that should not yield any false positives except in very rare cases. The other accuracy levels look for duplicate files within a set distance of each other. This means the scenes don't have exactly the same phash, but are very similar. `High` and `Medium` should still yield very good results with few or no false positives. `Low` is likely to produce some false positives, but might still be useful for finding dupes.\n\n> **⚠️ Note:** To generate a pHash Stash requires an uncorrupted file. If any errors are encountered during sprite generation the pHash will not be generated. This is to prevent false positives."
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md",
    "content": "# Embedded Plugin Tasks\n\nEmbedded plugin tasks are executed within the stash process using a scripting system.\n\n## Supported script languages\n\nStash currently supports Javascript embedded plugin tasks using [goja](https://github.com/dop251/goja).\n\n## Javascript plugins\n\n### Plugin input\n\nThe input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. \n\n> **⚠️ Note:** `server_connection` field should not be necessary in most embedded plugins.\n\n### Plugin output\n\nThe output of a Javascript plugin task is derived from the evaluated value of the script. The output should conform to the structure provided in the `Plugin output` section of the [Plugins](/help/Plugins.md) page.\n\nThere are a number of ways to return the plugin output:\n\n#### Example #1\n```\n(function() {\n    return {\n        Output: \"ok\"\n    };\n})();\n```\n\n#### Example #2\n```\nfunction main() {\n    return {\n        Output: \"ok\"\n    };\n}\n\nmain();\n```\n\n#### Example #3\n```\nvar output = {\n    Output: \"ok\"\n};\n\noutput;\n```\n\n## Logging\n\nSee the `Javascript API` section below on how to log with Javascript plugins.\n\n## Plugin configuration file format\n\n### exec\n\nFor embedded plugins, the `exec` field is a list with the first element being the path to the Javascript file that will be executed. It is expected that the path to the Javascript file is relative to the directory of the plugin configuration file.\n\n### interface\n\nFor embedded plugins, the `interface` field must be set to one of the following values:\n* `js`\n\n## Javascript API\n\n### Logging\n\nStash provides the following API for logging in Javascript plugins:\n\n| Method | Description |\n|--------|-------------|\n| `log.Trace(<string>)` | Log with the `trace` log level. |\n| `log.Debug(<string>)` | Log with the `debug` log level. |\n| `log.Info(<string>)` | Log with the `info` log level. |\n| `log.Warn(<string>)` | Log with the `warn` log level. |\n| `log.Error(<string>)` | Log with the `error` log level. |\n| `log.Progress(<float between 0 and 1>)` | Sets the progress of the plugin task, as a float, where `0` represents 0% and `1` represents 100%. |\n\n### GQL\n\nStash provides the following API for communicating with stash using the graphql interface:\n\n| Method | Description |\n|--------|-------------|\n| `gql.Do(<query/mutation string>, <variables object>)` | Executes a graphql query/mutation on the stash server. Returns an object in the same way as a graphql query does. |\n\n#### Example\n\n```\n// creates a tag\nvar mutation = \"\\\nmutation tagCreate($input: TagCreateInput!) {\\\n  tagCreate(input: $input) {\\\n    id\\\n  }\\\n}\";\n\nvar variables = {\n    input: {\n        'name': tagName\n    }\n};\n\nresult = gql.Do(mutation, variables);\nlog.Info(\"tag id = \" + result.tagCreate.id);\n```\n\n## Utility functions\n\nStash provides the following API for utility functions:\n\n| Method | Description |\n|--------|-------------|\n| `util.Sleep(<milliseconds>)` | Suspends the current thread for the specified duration. |\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/ExternalPlugins.md",
    "content": "# External Plugin Tasks\n\nExternal plugin tasks are executed by running an external binary.\n\n## Plugin interfaces\n\nStash communicates with external plugin tasks using an interface. Stash currently supports RPC and raw interface types.\n\n### RPC interface\n\nThe RPC interface uses JSON-RPC to communicate with the plugin process. A golang plugin utilising the RPC interface is available in the stash source code under `pkg/plugin/examples/gorpc`. RPC plugins are expected to provide an interface that fulfils the `RPCRunner` interface in `pkg/plugin/common`.\n\nRPC plugins are expected to accept requests asynchronously.\n\nWhen stopping an RPC plugin task, the stash server sends a stop request to the plugin and relies on the plugin to stop itself.\n\n### Raw interface\n\nRaw interface plugins are not required to conform to any particular interface. The stash server will send the plugin input to the plugin process via its stdin stream, encoded as JSON. Raw interface plugins are not required to read the input.\n\nThe stash server reads stdout for the plugin's output. If the output can be decoded as a JSON representation of the plugin output data structure then it will do so. If not, it will treat the entire stdout string as the plugin's output.\n\nWhen stopping a raw plugin task, the stash server kills the spawned process without warning or signals.\n\n## Logging\n\nExternal plugins may log to the stash server by writing to stderr. By default, data written to stderr will be logged by stash at the `error` level. This default behaviour can be changed by setting the `errLog` field in the plugin configuration file.\n\nPlugins can log for specific levels or log progress by prefixing the output string with special control characters. See `pkg/plugin/common/log` for how this is done in go.\n\n## Plugin configuration file format\n\n### exec\n\nFor external plugin tasks, the `exec` field is a list with the first element being the binary that will be executed, and the subsequent elements are the arguments passed. The execution process will search the path for the binary, then will attempt to find the program in the same directory as the plugin configuration file. The `exe` extension is not necessary on Windows systems. \n\n> **⚠️ Note:** The plugin execution process sets the current working directory to that of the stash process.\n\nArguments can include the plugin's directory with the special string `{pluginDir}`. \n\nFor example, if the plugin executable `my_plugin` is placed in the `plugins` subdirectory and requires arguments `foo` and `bar`, then the `exec` part of the configuration would look like the following:\n\n```\nexec:\n  - my_plugin\n  - foo\n  - bar\n```\n\nAnother example might use a python script to execute the plugin. Assuming the python script `foo.py` is placed in the same directory as the plugin config file, the `exec` fragment would look like the following:\n\n```\nexec:\n  - python\n  - {pluginDir}/foo.py\n```\n\n### interface\n\nFor external plugin tasks, the `interface` field must be set to one of the following values:\n* `rpc`\n* `raw`\n\nSee the `Plugin interfaces` section above for details on these interface types.\n\nThe `interface` field defaults to `raw` if not provided.\n\n### errLog\n\nThe `errLog` field tells stash what the default log level should be when the plugin outputs to stderr without encoding a log level. It defaults to the `error` level if no provided. This field is not necessary if the plugin outputs logging with the appropriate encoding. See the `Logging` section above for details.\n\n## Task configuration\n\nIn addition to the standard task configuration, external tasks may be configured with an optional `execArgs` field to add extra parameters to the execution arguments for the task.\n\nFor example:\n\n```\ntasks:\n  - name: <operation name>\n    description: <optional description>\n    execArgs:\n      - <arg to add to the exec line>\n```\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Help.md",
    "content": "# Where to get further help\n\nJoin our [Community forum](https://discourse.stashapp.cc/).\n\nJoin our [Discord](https://discord.gg/2TsNFKt).\n\nThe [Stash-Docs](https://docs.stashapp.cc) covers some areas not covered in the in-app help.\n\nRaise a [GitHub issue](https://github.com/stashapp/stash/issues).\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Identify.md",
    "content": "# Identify\n\nThe Identify task iterates through your Scenes and attempts to identify them using a selection of scraping sources. If a result is found in a source, the Scene is updated, and no further sources are checked for that scene.\n\nThis task is part of the advanced settings mode.\n\n## Rules\n\n- The task accepts one or more scraper sources, including stash-box instances and scene scrapers that support scraping via Scene Fragment. The order of the sources can be rearranged.\n- The task iterates through the scraper sources in the provided order.\n- If a result is found in a source, the Scene is updated, and further sources are not checked for that scene.\n\n### Organized flag\n\nScenes that have the Organized flag added to them will not be modified by Identify. You can also use Organized flag status as a filter.\n\n## Options\n\nThe following options can be configured:\n\n| Option | Description |\n|--------|-------------|\n| Performer genders | Filter which performer genders are included during identification. If no genders are selected, all performers are included regardless of gender. |\n| Set cover images | If false, scene cover images will not be modified. |\n| Set organized flag | If true, the organized flag is set to true when a scene is organized. |\n| Skip matches that have more than one result | If this is not enabled and more than one result is returned, one will be randomly chosen to match |\n| Tag skipped matches with | If the above option is set and a scene is skipped, this will add the tag so that you can filter for it in the Scene Tagger view and choose the correct match by hand |\n| Skip single name performers with no disambiguation | If this is not enabled, performers that are often generic like Samantha or Olga will be matched |\n| Tag skipped performers with | If the above option is set and a performer is skipped, this will add the tag so that you can filter for it in the Scene Tagger view and choose how you want to handle those performers |\n\n### Field specific options\n\nEach field may have a strategy. The behavior for each strategy is as follows:\n\n| Strategy | Description |\n|----------|-------------|\n| Ignore | The field is not set. |\n| Overwrite | Existing values are overwritten. |\n| Merge (*default*) | For multi-value fields, adds to existing values. For single-value fields, only sets if not already set. |\n\nFor Studio, Performers, and Tags, an option is available to **Create Missing Objects**. This is enabled by default. When true, if a Studio/Performer/Tag is included during the identification process and does not exist in the system, it will be created.\n\n## Running task\n\n- **Identify...:** Run the Identify task on your entire library from the Tasks page.\n- **Selective Identify:** Configure and run the Identify task on specific directories from Tasks > Identify... page. At the top of the page click folder icon to select directories.\n\n## Logs\n\nThe result of the identification process for each scene is output to the log.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Images.md",
    "content": "# Images and Galleries\n\nImages are the parts which make up galleries, but you can also have them be scanned independently. To declare an image part of a gallery, there are four ways:\n\n1. Group them in a folder together and activate the **Create galleries from folders containing images** option in the library section of your settings. The gallery will get the name of the folder.\n2. Group them in a folder together and create a file in the folder called .forcegallery. The gallery will get the name of the folder.\n3. Group them into a zip archive together. The gallery will get the name of the archive.\n4. You can simply create a gallery in stash itself by clicking on **New** in the Galleries tab. \n\nYou can add images to every gallery manually in the gallery detail page. Deleting can be done by selecting the according images in the same view and clicking on the minus next to the edit button.\n\nFor best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance.\n\n> **⚠️ Note:** AVIF files in ZIP archives are currently unsupported.\n\nIf a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected.\n\nYou can also manually select any image from a gallery as its cover. On the gallery details page, select the desired cover image, and then select **Set as Cover** in the ⋯ menu.\n\n## Image clips/gifs\n\nImages can also be clips/gifs. These are meant to be short video loops. Right now they are not possible in zipfiles. To declare video files to be images, there are two ways:\n\n1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan video extensions as image clips** option in the library section of your settings. \n2. Make sure none of the file endings used by your clips/gifs are present in the **Video extensions** and add them to the **Image extensions** in the library section of your settings.\n\nA clip/gif will be a stillframe in the wall and grid view by default. To view the loop, you can go into the Lightbox Carousel (e.g. by clicking on an image in the wall view) or the image detail page.\n\nIf you want the loop to be used as a preview on the wall and grid view, you will have to generate them. \nYou can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image clip previews** and clicking generate. This takes a while, as the files are transcoded.\n\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Interactive.md",
    "content": "# Interactivity\n\nStash currently supports syncing with The Handy devices, using funscript files.\n\nIn order for stash to connect to your Handy device, the Handy connection key must be entered in Settings -> Interface.\n\nFunscript files must be in the same directory as the matching video file and must have the same base name. For example, a funscript file for `video.mp4` must be named `video.funscript`. A scan must be run to update scenes with matching funscript files.\n\nScenes with funscript files can be filtered with the `interactive` criterion.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Interface.md",
    "content": "# Interface Options\n\n## Language\n\nSetting the language affects the formatting of numbers and dates.\n\n## SFW content mode\n\nSFW content mode is used to indicate that the content being managed is _not_ adult content. \n\nWhen SFW content mode is enabled, the following changes are made to the UI:\n- default performer images are changed to less adult-oriented images\n- certain adult-specific metadata fields are hidden (e.g. performer genital fields)\n- `O`-Counter is replaced with `Like`-counter\n\n## Scene/Marker Wall Preview type\n\nThe Scene Wall and Marker pages display scene preview videos (mp4) by default. This can be changed to animated image (webp) or static image. \n\n> **⚠️ Note:** scene/marker preview videos must be generated to see them in the applicable wall page if Video preview type is selected. Likewise, if Animated image is selected, then Image Previews must be generated.\n\n## Show Studios as text\n\nBy default, a scene's studio will be shown as an image overlay. Checking this option changes this to display studios as a text name instead.\n\n## Scene Player options\n\nBy default, scene videos do not automatically start when navigating to the scenes page. Checking the \"Auto-start video\" option changes this to auto play scene videos.\n\nThe maximum loop duration option allows looping of shorter videos. Set this value to the maximum scene duration that scene videos should loop. Setting this to 0 disables this functionality.\n\n### Activity tracking\n\nThe \"Track Activity\" option allows tracking of scene play count and duration, and sets the resume point when a scene video is not finished.\n\nThe \"Minimum play percent\" gives the minimum proportion of a video that must be played before the play count of the scene is incremented.\n\nBy default, when a scene has a resume point, the scene player will automatically seek to this point when the scene is played. Setting \"Always start video from beginning\" to true disables this behaviour.\n\n## Custom CSS\n\nThe stash UI can be customised using custom CSS. See [here](https://discourse.stashapp.cc/t/custom-css-snippets/4043) for a community-curated set of CSS snippets to customise your UI. \n\nThere is also a [collection of community-created themes](https://discourse.stashapp.cc/tags/c/plugins/18/all/theme) available.\n\n## Custom JavaScript\n\nStash supports the injection of custom JavaScript to assist with theming or adding additional functionality. Be aware that bad JavaScript could break the UI or worse.\n\n## Custom Locales\n\nThe localisation strings can be customised. The master list of default (en-GB) locale strings can be found [here](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json). The custom locale format is the same as this json file.\n\nFor example, to override the `actions.add_directory` label (which is `Add directory` by default), you would have the following in the custom locale:\n\n```\n{\n  \"actions\": {\n    \"add_directory\": \"Some other description\"\n  }\n}\n```\n\n## Custom served folders\n\nIt is possible to expose specific folders to the UI. This configuration is performed manually in the `config.yml` file only.\n\nCustom served content is exposed via the `/custom` URL path prefix.\n\nFor example, in the `config.yml` file:\n```\ncustom_served_folders:\n  /: D:\\stash\\static\n  /foo: D:\\bar\n```\n\nWith the above configuration, a request for `/custom/foo/bar.png` would return `D:\\bar\\bar.png`. The `/` entry matches anything that is not otherwise mapped by the other entries. For example, `/custom/baz/xyz.png` would return `D:\\stash\\static\\baz\\xyz.png`.\n\nApplications for this include using static images in custom css, like the Plex theme. For example, using the following config:\n```yml\ncustom_served_folders:\n  /: <stash folder>\\custom\n```\n\nThe `background.png` and `noise.png` files can be placed in the `custom` folder, then in the custom css, the `./background.png` and `./noise.png` strings can be replaced with `/custom/background.png` and `/custom/noise.png` respectively.\n\nOther applications are to add custom UIs to stash, accessible via `/custom`.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Introduction.md",
    "content": "# Introduction\n\nStash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and stash will begin scanning and importing your media into its library.\n\nFor the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). \n\n> **⚠️ Note:** Currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete.\n\nOnce your media is imported, you are ready to begin creating Performers, Studios and Tags, and curating your content!"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/JSONSpec.md",
    "content": "# Import/Export JSON Specification\n\nThe metadata given to Stash can be exported into the JSON format. This structure can be modified, or replicated by other means. The resulting data can then be imported again, giving the possibility for automatic scraping of all kinds. The format of this metadata bulk is a folder structure, containing the following folders:\n  \n* `files`\n* `galleries`\n* `images`\n* `performers`\n* `scenes`\n* `studios`\n* `groups`\n\n## File naming\n\nWhen exported, files are named with different formats depending on the object type:\n\n| Type | Format |\n|------|--------|\n| Files/Folders | `<path depth in hex, two character width>.<basename>.<hash>.json` |\n| Galleries | `<first zip filename>.<path hash>.json` or `<folder basename>.<path hash>.json` or `<title>.json` |\n| Images | `<title or first file basename>.<hash>.json` |\n| Performers | `<name>.json` |\n| Scenes | `<title or first file basename>.<hash>.json` |\n| Studios | `<name>.json` |\n| Groups | `<name>.json` |\n\n> **⚠️ Note:** The file naming is not significant when importing. All json files will be read from the subdirectories.\n  \n## Content of the json files\n\nIn the following, the values of the according jsons will be shown. If the value should be a number, it is written with after comma values (like `29.98` or `50.0`), but still as a string. The meaning from most of them should be obvious due to the previous explanation or from the possible values stash offers when editing, otherwise a short comment will be added.\n\nThe json values are given as strings, if not stated otherwise. Every new line will stand for a new value in the json. If the value is a list of objects, the values of that object will be shown indented.  \n\nIf a value is empty in any file, it can be left out of the file entirely. \nMany files have an `created_at` and `updated_at`, both are kept in the following format:\n```  \nYYYY-MM-DDThh:mm:ssTZD  \n```\nExample:  \n```\n\"created_at\": \"2019-05-03T21:36:58+01:00\"\n```\n\n### Performer\n```\nname  \nurl  \ntwitter  \ninstagram  \nbirthdate  \ndeath_date  \nethnicity  \ncountry  \nhair_color  \neye_color  \nheight  \nweight  \nmeasurements  \nfake_tits  \ncareer_length  \ntattoos  \npiercings  \nimage (base64 encoding of the image file)  \ncreated_at  \nupdated_at\nrating (integer)\ndetails\n```\n\n### Studio\n```\nname  \nurl  \nimage (base64 encoding of the image file)  \ncreated_at  \nupdated_at\nrating (integer)  \ndetails  \n```\n\n### Scene\n```\ntitle  \nstudio  \nurl  \ndate  \nrating (integer)  \ndetails  \nperformers (list of strings, performers name)  \ntags (list of strings)  \nmarkers     \n  title  \n  seconds  \n  primary_tag  \n  tags (list of strings)  \n  created_at  \n  updated_at  \nfile (not a list, but a single object)  \n  size (in bytes, no after comma values)  \n  duration (in seconds)  \n  video_codec (example value: h264)  \n  audio_codec (example value: aac)  \n  width (integer, in pixel)  \n  height (integer, in pixel)  \n  framerate  \n  bitrate (integer, in Bit)  \ncreated_at  \nupdated_at  \n```\n\n\n### Image\n```\ntitle  \nstudio  \nrating (integer)  \nperformers (list of strings, performers name)  \ntags (list of strings)  \nfiles (list of path strings)\ngalleries\n  zip_files (list of path strings)\n  folder_path\n  title (for user-created gallery)\ncreated_at  \nupdated_at  \n```\n\n### Gallery\n```\ntitle  \nstudio  \nurl  \ndate  \nrating (integer)  \ndetails  \nperformers (list of strings, performers name)  \ntags (list of strings)  \nzip_files (list of path strings)\nfolder_path   \ncreated_at  \nupdated_at  \n```\n\n## Files\n\n### Folder\n```\nzip_file (path to containing zip file)\nmod_time\ntype (= folder)\npath\ncreated_at\nupdated_at\n```\n\n### Video file\n```\nzip_file (path to containing zip file)\nmod_time\ntype (= video)\npath\nfingerprints\n  type\n  fingerprint\nsize\nformat\nwidth\nheight\nduration\nvideo_codec\naudio_codec\nframe\nbitrate\ninteractive (bool)\ninteractive_speed (integer)\ncreated_at\nupdated_at\n```\n\n### Image file\n```\nzip_file (path to containing zip file)\nmod_time\ntype (= image)\npath\nfingerprints\n  type\n  fingerprint\nsize\nformat\nwidth\nheight\ncreated_at\nupdated_at\n```\n\n### Other files\n```\nzip_file (path to containing zip file)\nmod_time\ntype (= file)\npath\nfingerprints\n  type\n  fingerprint\nsize\ncreated_at\nupdated_at\n```\n\n## In JSON format\n\nFor those preferring the json-format, defined [here](https://json-schema.org/), the following format may be more interesting:\n\n### performer.json\n\n``` json\n{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"https://docs.stashapp.cc/in-app-manual/tasks/jsonspec#performerjson\",\n  \"title\": \"performer\",\n  \"description\": \"A json file representing a performer. The file is named by a MD5 Code.\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"name\": {\n      \"description\": \"Name of the performer\",\n      \"type\": \"string\"\n    },\n    \"url\": {\n      \"description\": \"URL to website of the performer\",\n      \"type\": \"string\"\n    },\n    \"twitter\": {\n      \"description\": \"Twitter name of the performer\",\n      \"type\": \"string\"\n    },\n    \"instagram\": {\n      \"description\": \"Instagram name of the performer\",\n      \"type\": \"string\"\n    },\n    \"birthdate\": {\n      \"description\": \"Birthdate of the performer. Format is YYYY-MM-DD\",\n      \"type\": \"string\"\n    },\n    \"death_date\": {\n      \"description\": \"Death date of the performer. Format is YYYY-MM-DD\",\n      \"type\": \"string\"\n    },\n    \"ethnicity\": {\n      \"description\": \"Ethnicity of the Performer. Possible values are black, white, asian or hispanic\",\n      \"type\": \"string\"\n    },\n    \"country\": {\n      \"description\": \"Country of the performer\",\n      \"type\": \"string\"\n    },\n    \"hair_color\": {\n      \"description\": \"Hair color of the performer\",\n      \"type\": \"string\"\n    },\n    \"eye_color\": {\n      \"description\": \"Eye color of the performer\",\n      \"type\": \"string\"\n    },\n    \"height\": {\n      \"description\": \"Height of the performer in centimeters\",\n      \"type\": \"string\"\n    },\n    \"weight\": {\n      \"description\": \"Weight of the performer in kilograms\",\n      \"type\": \"string\"\n    },\n    \"measurements\": {\n      \"description\": \"Measurements of the performer\",\n      \"type\": \"string\"\n    },\n    \"fake_tits\": {\n      \"description\": \"Whether performer has fake tits. Possible are Yes or No\",\n      \"type\": \"string\"\n    },\n    \"career_length\": {\n      \"description\": \"The time the performer has been in business. In the format YYYY-YYYY\",\n      \"type\": \"string\"\n    },\n    \"tattoos\": {\n      \"description\": \"Giving a description of Tattoos of the performer if any\",\n      \"type\": \"string\"\n    },\n    \"piercings\": {\n      \"description\": \"Giving a description of Piercings of the performer if any\",\n      \"type\": \"string\"\n    },\n    \"image\": {\n      \"description\": \"Image of the performer, parsed into base64\",\n      \"type\": \"string\"\n    },\n    \"created_at\": {\n      \"description\": \"The time this performers data was added to the database. Format is YYYY-MM-DDThh:mm:ssTZD\",\n      \"type\": \"string\"\n    },\n    \"updated_at\": {\n      \"description\": \"The time this performers data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD\",\n      \"type\": \"string\"\n    },\n    \"details\": {\n      \"description\": \"Description of the performer\",\n      \"type\": \"string\"\n    }\n  },\n  \"required\": [\"name\", \"ethnicity\", \"image\", \"created_at\", \"updated_at\"]\n}\n\n```\n\n### studio.json\n\n``` json\n{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"https://docs.stashapp.cc/in-app-manual/tasks/jsonspec#studiojson\",\n  \"title\": \"studio\",\n  \"description\": \"A json file representing a studio. The file is named by a MD5 Code.\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"name\": {\n      \"description\": \"Name of the studio\",\n      \"type\": \"string\"\n    },\n    \"url\": {\n      \"description\": \"URL to the studios websites\",\n      \"type\": \"string\"\n    },\n    \"image\": {\n      \"description\": \"Logo of the studio, parsed into base64\",\n      \"type\": \"string\"\n    },\n    \"created_at\": {\n      \"description\": \"The time this studios data was added to the database. Format is YYYY-MM-DDThh:mm:ssTZD\",\n      \"type\": \"string\"\n    },\n    \"updated_at\": {\n      \"description\": \"The time this studios data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD\",\n      \"type\": \"string\"\n    },\n    \"details\": {\n      \"description\": \"Description of the studio\",\n      \"type\": \"string\"\n    }\n  },\n  \"required\": [\"name\", \"image\", \"created_at\", \"updated_at\"]\n}\n```\n\n### scene.json\n\n```json\n{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"https://docs.stashapp.cc/in-app-manual/tasks/jsonspec#scenejson\",\n  \"title\": \"scene\",\n  \"description\": \"A json file representing a scene. The file is named by the MD5 Code of the file its data is referring to.\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"title\": {\n      \"description\": \"Title of the scene\",\n      \"type\": \"string\"\n    },\n    \"studio\": {\n      \"description\": \"The name of the studio that produced that scene\",\n      \"type\": \"string\"\n    },\n    \"url\": {\n      \"description\": \"The url to the scenes original source\",\n      \"type\": \"string\"\n    },\n    \"date\": {\n      \"description\": \"The release date of the scene. Its given in the format YYYY-MM-DD\",\n      \"type\": \"string\"\n    },\n    \"rating\": {\n      \"description\": \"The scenes Rating. Its given in stars, from 1 to 5\",\n      \"type\": \"integer\"\n    },\n    \"details\": {\n      \"description\": \"A description of the scene, containing things like the story arc\",\n      \"type\": \"string\"\n    },\n    \"performers\": {\n      \"description\": \"A list of names of the performers in this gallery\",\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"minItems\": 1,\n      \"uniqueItems\": true\n    },\n    \"tags\": {\n      \"description\": \"A list of the tags associated with this scene\",\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"minItems\": 1,\n      \"uniqueItems\": true\n    },\n    \"markers\": {\n      \"description\": \"Markers mark certain events in the scene, most often the change of the position. They are attributed with their own tags.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"title\": {\n            \"description\": \"Searchable name of the marker\",\n            \"type\": \"string\"\n          },\n          \"seconds\": {\n            \"description\": \"At what second the marker is set. It is given with after comma values, such as 10.0 or 17.5\",\n            \"type\": \"string\"\n          },\n          \"primary_tag\": {\n            \"description\": \"A tag identifying this marker. Multiple markers from the same scene with the same primary tag are concatenated, showing them as similar in nature\",\n            \"type\": \"string\"\n          },\n          \"tags\": {\n            \"description\": \"A list of the tags associated with this marker\",\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            },\n            \"minItems\": 1,\n            \"uniqueItems\": true\n          },\n          \"created_at\": {\n            \"description\": \"The time this marker was added to the database. Format is YYYY-MM-DDThh:mm:ssTZD\",\n            \"type\": \"string\"\n          },\n          \"updated_at\": {\n            \"description\": \"The time this marker was updated the last time. Format is YYYY-MM-DDThh:mm:ssTZD\",\n            \"type\": \"string\"\n          }\n\n        },\n        \"required\": [\"seconds\", \"primary_tag\", \"created_at\", \"updated_at\"]\n      },\n      \"minItems\": 1,\n      \"uniqueItems\": true\n    },\n    \"files\": {\n      \"description\": \"A list of paths of the files for this scene\",\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"minItems\": 1,\n      \"uniqueItems\": true\n    },\n    \"created_at\": {\n      \"description\": \"The time this studios data was added to the database. Format is YYYY-MM-DDThh:mm:ssTZD\",\n      \"type\": \"string\"\n    },\n    \"updated_at\": {\n      \"description\": \"The time this studios data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD\",\n      \"type\": \"string\"\n    }\n  },\n  \"required\": [\"files\", \"created_at\", \"updated_at\"]\n}\n```\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md",
    "content": "# Keyboard Shortcuts\n\n## Global shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `?` | Display manual |\n\n### Global Navigation\n\n| Keyboard sequence | Target page |\n|-------------------|--------|\n| `g s` | Scenes |\n| `g i` | Images |\n| `g v` | Groups |\n| `g k` | Markers |\n| `g l` | Galleries |\n| `g p` | Performers |\n| `g u` | Studios |\n| `g t` | Tags |\n| `g z` | Settings |\n\n## Query page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `/` | Focus search field / focus query field in filter dialog |\n| `f` | Show Add Filter dialog |\n| `r` | Reshuffle if sorted by random |\n| `v g` | Set view to grid |\n| `v l` | Set view to list |\n| `v w` | Set view to wall |\n| `v t` | Set view to tagger |\n| `+` | Increase zoom slider |\n| `-` | Decrease zoom slider |\n| `←` | Previous page of results |\n| `→` | Next page of results |\n| `Shift + ←` | Go to current results page -10 |\n| `Shift + →` | Go to current results page +10 |\n| `Ctrl + Home` | Go to first page of results |\n| `Ctrl + End` | Go to last page of results |\n| `s a` | Select all on page |\n| `s n` | Unselect all |\n| `s i` | Invert selection |\n| `e` | Edit selected |\n| `d d` | Delete selected |\n\n## Scenes page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `p r` | Play random scene |\n\n## Scene page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `a` | Details tab |\n| `q` | Queue tab |\n| `k` | Markers tab |\n| `i` | File info tab |\n| `e` | Edit tab |\n| `h` | History tab |\n| `,` | Hide/Show sidebar |\n| `.` | Hide/Show scene scrubber |\n| `o` | Increment O-Counter |\n| Ratings ||\n| `r {1-5}` | Set rating (stars) |\n| `r 0` | Unset rating (stars) |\n| `r {0-9} {0-9}` | Set rating (decimal - `00` for `10.0`) |\n| ``r ` `` | Unset rating (decimal) |\n| Cover generation ||\n| `c c` | Generate screenshot at current time |\n| `c d` | Generate default screenshot |\n| Playback ||\n| `p n` | Play next scene in queue |\n| `p p` | Play previous scene in queue |\n| `p r` | Play random scene in queue |\n| `Space` | Play/pause player |\n| `Enter` | Play/pause player |\n| `←` | Seek backwards by 10 seconds |\n| `→` | Seek forwards by 10 seconds |\n| `Shift + ←` | Seek backwards by 5 seconds |\n| `Shift + →` | Seek forwards by 5 seconds |\n| `Ctrl/Alt + ←` | Seek backwards by 1 minute |\n| `Ctrl/Alt + →` | Seek forwards by 1 minute |\n| `{1-9}` | Seek to 10-90% duration |\n| `[` | Scrub backwards 10% duration |\n| `]` | Scrub forwards 10% duration |\n| `↑` | Increase volume 10% |\n| `↓` | Decrease volume 10% |\n| `m` | Toggle mute |\n| `l` | A/B looping toggle. Press once to set start point. Press again to set end point. Press again to disable loop. |\n| `Shift + l` | Toggle looping of scene when it's over |\n\n### Scene Markers tab shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `n` | Display Create Markers dialog |\n\n### Scene Edit tab shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `s s` | Save Scene |\n| `d d` | Delete Scene |\n| `Ctrl + v` | Paste Scene cover |\n\n[//]: # \"Commented until implementation is dealt with\"\n[//]: # \"(| `l` | Focus Gallery selector |)\"\n[//]: # \"(| `u` | Focus Studio selector |)\"\n[//]: # \"(| `p` | Focus Performers selector |)\"\n[//]: # \"(| `v` | Focus Groups selector |)\"\n[//]: # \"(| `t` | Focus Tags selector |)\"\n\n## Image Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `e` | Edit tab |\n| `o` | Increment O-Counter |\n| Ratings ||\n| `r {1-5}` | Set rating (stars) |\n| `r 0` | Unset rating (stars) |\n| `r {0-9} {0-9}` | Set rating (decimal - `00` for `10.0`) |\n| ``r ` `` | Unset rating (decimal) |\n\n### Image Edit tab shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `s s` | Save Scene |\n| `d d` | Delete Scene |\n\n## Groups Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `n` | New Group |\n\n## Group Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `e` | Edit Group |\n| `s s` | Save Group |\n| `d d` | Delete Group |\n| ``r ` `` | [Edit mode] Unset rating (decimal) |\n| `,` | Expand/Collapse Details |\n| `Ctrl + v` | Paste Group image |\n| Ratings ||\n| `r {1-5}` | [Edit mode] Set rating (stars) |\n| `r 0` | [Edit mode] Unset rating (stars) |\n| `r {0-9} {0-9}` | [Edit mode] Set rating (decimal - `r 0 0` for `10.0`) |\n\n[//]: # \"Commented until implementation is dealt with\"\n[//]: # \"(| `u` | Focus Studio selector (in edit mode) |)\"\n\n## Markers Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `p r` | Play random marker |\n\n## Performers Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `n` | New Performer |\n| `p r` | Open random Performer |\n\n## Performer Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `c` | Scenes tab |\n| `e` | Edit tab |\n| `o` | Operations tab |\n| `f` | Toggle favourite |\n| `,` | Expand/Collapse Details |\n\n### Performer Edit tab shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `s s` | Save Performer |\n| `d d` | Delete Performer |\n| `Ctrl + v` | Paste Performer image |\n\n## Studios Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `n` | New Studio |\n\n## Studio Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `e` | Edit Studio |\n| `s s` | Save Studio |\n| `d d` | Delete Studio |\n| `,` | Expand/Collapse Details |\n| `Ctrl + v` | Paste Studio image |\n\n## Tags Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `n` | New Tag |\n\n## Tag Page shortcuts\n\n| Keyboard sequence | Action |\n|-------------------|--------|\n| `e` | Edit Tag |\n| `s s` | Save Tag |\n| `d d` | Delete Tag |\n| `,` | Expand/Collapse Details |\n| `Ctrl + v` | Paste Tag image |\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Plugins.md",
    "content": "# Plugins\n\nStash supports plugins that can do the following:\n\n- perform custom tasks when triggered by the user from the Tasks page\n- perform custom tasks when triggered from specific events\n- add custom CSS to the UI\n- add custom JavaScript to the UI\n\nPlugin tasks can be implemented using embedded Javascript, or by calling an external binary.\n\n> **⚠️ Note:** Plugin support is still experimental and is likely to change.\n\n## Managing Plugins\n\nPlugins can be installed and managed from the `Settings > Plugins` page. \n\nPlugins are installed using the `Available Plugins` section. This section allows configuring sources from which to install plugins. The `Community (stable)` source is configured by default. This source contains plugins for the current _stable_ version of stash.\n\nThese are the plugin sources maintained by the stashapp organisation:\n\n| Name | Source URL | Recommended Local Path | Notes |\n|------|-----------|------------------------|-------|\n| Community (stable) | `https://stashapp.github.io/CommunityScripts/stable/index.yml` | `stable` | For the current stable version of stash. |\n| Community (develop) | `https://stashapp.github.io/CommunityScripts/develop/index.yml` | `develop` | For the develop version of stash. |\n\nInstalled plugins can be updated or uninstalled from the `Installed Plugins` section.\n\n### Source URLs\n\nThe source URL must return a yaml file containing all the available packages for the source. An example source yaml file looks like the following:\n\n```\n- id: <package id>\n  name: <package name>\n  version: <version>\n  date: <date>\n  requires:\n  - <ids of packages required by this package (optional)>\n  - ...\n  path: <path to package zip file>\n  sha256: <sha256 of zip>\n  metadata:\n    <optional key/value pairs for extra information>\n- ...\n```\n\nPath can be a relative path to the zip file or an external URL.\n\n## Adding plugins manually\n\nBy default, Stash looks for plugin configurations in the `plugins` sub-directory of the directory where the stash `config.yml` is read. This will either be the `$HOME/.stash` directory or the current working directory.\n\nPlugins are added by adding configuration yaml files (format: `pluginName.yml`) to the `plugins` directory.\n\nLoaded plugins can be viewed in the Plugins page of the Settings. After plugins are added, removed or edited while stash is running, they can be reloaded by clicking `Reload Plugins` button.\n\n## Using plugins\n\nPlugins provide tasks which can be run from the Tasks page. \n\n## Creating plugins\n\n### Plugin configuration file format\n\nThe basic structure of a plugin configuration file is as follows:\n\n```yaml\nname: <plugin name> \n# optional list of dependencies to be included\n# \"#\" is is part of the config - do not remove\n# requires: <plugin ID>\ndescription: <optional description of the plugin>\nversion: <optional version tag>\nurl: <optional url>\n\nui:\n  # optional list of css files to include in the UI\n  css:\n    - <path to css file>\n\n  # optional list of js files to include in the UI\n  javascript:\n    - <path to javascript file>\n\n  # optional list of plugin IDs to load prior to this plugin\n  requires:\n    - <plugin ID>\n\n  # optional list of assets \n  assets:\n    urlPrefix: fsLocation\n    ...\n\n  # content-security policy overrides\n  csp:\n    script-src:\n      - http://alloweddomain.com\n    \n    style-src:\n      - http://alloweddomain.com\n    \n    connect-src:\n      - http://alloweddomain.com\n\n# map of setting names to be displayed in the plugins page in the UI\nsettings:\n  # internal name\n  foo:\n  # name to display in the UI\n  displayName: Foo\n  # type of the attribute to show in the UI\n  # can be BOOLEAN, NUMBER, or STRING\n  type: BOOLEAN\n\n# the following are used for plugin tasks only\nexec:\n  - ...\ninterface: [interface type]\nerrLog: [one of none trace, debug, info, warning, error]\ntasks:\n  - ...\n```\n\nThe `name`, `description`, `version` and `url` fields are displayed on the plugins page.\n\n`# requires` will make the plugin manager select plugins matching the specified IDs to be automatically installed as dependencies. Only works with plugins within the same index.\n\nThe `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins with tasks.\n\nThe `settings` field is used to display plugin settings on the plugins page. Plugin settings can also be set using the graphql mutation `configurePlugin` - the settings set this way do _not_ need to be specified in the `settings` field unless they are to be displayed in the stock plugin settings UI.\n\n### UI Configuration\n\nThe `css` and `javascript` field values may be relative paths to the plugin configuration file, or\nmay be full external URLs.\n\nThe `requires` field is a list of plugin IDs which must have their javascript/css files loaded\nbefore this plugins javascript/css files.\n\nThe `assets` field is a map of URL prefixes to filesystem paths relative to the plugin configuration file.\nAssets are mounted to the `/plugin/{pluginID}/assets` path. \n\nAs an example, for a plugin with id `foo` with the following `assets` value:\n```\nassets:\n  foo: bar\n  /: .\n```\nThe following URLs will be mapped to these locations:\n`/plugin/foo/assets/foo/file.txt` -> `{pluginDir}/bar/file.txt`\n`/plugin/foo/assets/file.txt` -> `{pluginDir}/file.txt`\n`/plugin/foo/assets/bar/file.txt` -> `{pluginDir}/bar/file.txt` (via the `/` entry)\n\nMappings that try to go outside of the directory containing the plugin configuration file will be\nignored.\n\nThe `csp` field contains overrides to the content security policies. The URLs in `script-src`,\n`style-src` and `connect-src` will be added to the applicable content security policy.\n\nSee [External Plugins](/help/ExternalPlugins.md) for details for making plugins with external tasks.\n\nSee [Embedded Plugins](/help/EmbeddedPlugins.md) for details for making plugins with embedded tasks.\n\n### Plugin task input\n\nPlugin tasks may accept an input from the stash server. This input is encoded according to the interface, and has the following structure (presented here in JSON format):\n```\n{\n    \"server_connection\": {\n        \"Scheme\": \"http\",\n        \"Port\": 9999,\n        \"SessionCookie\": {\n            \"Name\":\"session\",\n            \"Value\":\"cookie-value\",\n            \"Path\":\"\",\n            \"Domain\":\"\",\n            \"Expires\":\"0001-01-01T00:00:00Z\",\n            \"RawExpires\":\"\",\n            \"MaxAge\":0,\n            \"Secure\":false,\n            \"HttpOnly\":false,\n            \"SameSite\":0,\n            \"Raw\":\"\",\n            \"Unparsed\":null\n        },\n        \"Dir\": <path to stash config directory>,\n        \"PluginDir\": <path to plugin config directory>,\n    },\n    \"args\": {\n        \"argKey\": \"argValue\"\n    }\n}\n```\n\nThe `server_connection` field contains all the information needed for a plugin to access the parent stash server, if necessary.\n\n### Plugin task output\n\nPlugin task output is expected in the following structure (presented here as JSON format):\n\n```\n{\n    \"error\": <optional error string>\n    \"output\": <anything>\n}\n```\n\nThe `error` field is logged in stash at the `error` log level if present. The `output` is written at the `debug` log level.\n\n### Task configuration\n\nTasks are configured using the following structure:\n\n```\ntasks:\n  - name: <operation name>\n    description: <optional description>\n    defaultArgs:\n      argKey: argValue\n```\n\nA plugin configuration may contain multiple tasks. \n\nThe `defaultArgs` field is used to add inputs to the plugin input sent to the plugin.\n\n### Hook configuration\n\nStash supports executing plugin operations via triggering of a hook during a stash operation.\n\nHooks are configured using a similar structure to tasks:\n\n```\nhooks:\n  - name: <operation name>\n    description: <optional description>\n    triggeredBy:\n      - <trigger types>...\n    defaultArgs:\n      argKey: argValue\n```\n\n**⚠️ Note:** It is possible for hooks to trigger eachother or themselves if they perform mutations. For safety, hooks will not be triggered if they have already been triggered in the context of the operation. Stash uses cookies to track this context, so it's important for plugins to send cookies when performing operations.\n\n#### Trigger types\n\nTrigger types use the following format: `<object type>.<operation>.<hook type>`\n\nFor example, a post-hook on a scene create operation will be `Scene.Create.Post`.\n\nThe following object types are supported:\n\n* `Scene`\n* `SceneMarker`\n* `Image`\n* `Gallery`\n* `Group`\n* `Performer`\n* `Studio`\n* `Tag`\n\nThe following operations are supported:\n\n* `Create`\n* `Update`\n* `Destroy`\n* `Merge` (for `Tag` only)\n\nCurrently, only `Post` hook types are supported. These are executed after the operation has completed and the transaction is committed.\n\n#### Hook input\n\nPlugin tasks triggered by a hook include an argument named `hookContext` in the `args` object structure. The `hookContext` is structured as follows:\n\n```\n{\n    \"id\": <object id>,\n    \"type\": <trigger type>,\n    \"input\": <operation input>,\n    \"inputFields\": <fields included in input>\n}\n```\n\nThe `input` field contains the JSON graphql input passed to the original operation. This will differ between operations. For hooks triggered by operations in a scan or clean, the input will be nil. `inputFields` is populated in update operations to indicate which fields were passed to the operation, to differentiate between missing and empty fields.\n\nFor example, here is the `args` values for a Scene update operation:\n\n```\n{\n    \"hookContext\": {\n        \"type\":\"Scene.Update.Post\",\n        \"id\":45,\n        \"input\":{\n            \"clientMutationId\":null,\n            \"id\":\"45\",\n            \"title\":null,\n            \"details\":null,\n            \"url\":null,\n            \"date\":null,\n            \"rating\":null,\n            \"organized\":null,\n            \"studio_id\":null,\n            \"gallery_ids\":null,\n            \"performer_ids\":null,\n            \"groups\":null,\n            \"tag_ids\":[\"21\"],\n            \"cover_image\":null,\n            \"stash_ids\":null\n        },\n        \"inputFields\":[\n            \"tag_ids\",\n            \"id\"\n        ]\n    }\n}\n```\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/SceneFilenameParser.md",
    "content": "# Scene Filename Parser\n\n[This tool](/sceneFilenameParser) parses the scene filenames in your library and allows setting the metadata from those filenames.\n\n## Parser Options\n\nTo use this tool, a filename pattern must be entered. The pattern accepts the following fields:\n\n| Field | Remark |\n|-------|--------|\n| `title` | Text captured within is set as the title of the scene. |\n|`ext`|Matches the end of the filename. It is not captured. Does not include the last `.` character.|\n|`d`|Matches delimiter characters (`-_.`). Not captured.|\n|`i`|Matches any ignored word entered in the `Ignored words` field. Ignored words are entered as space-delimited words. Not captured. Use this to match release artifacts like `DVDRip` or release groups.|\n|`date`|Matches `yyyy-mm-dd` and sets the date of the scene.|\n|`rating`|Matches a single digit and sets the rating of the scene.|\n|`performer`| Sets the scene performer, based on the text captured.|\n|`tag`| Sets the scene tag, based on the text captured.|\n|`studio`| Sets the studio performer, based on the text captured.|\n|`{}`|Matches any characters. Not captured.|\n\n> **⚠️ Note:** `performer`, `tag` and `studio` fields will only match against Performers/Tags/Studios that already exist in the system.\n\nThe `performer`/`tag`/`studio` fields will remove any delimiter characters (`.-_`) before querying. Name matching is case-insensitive.\n\nThe following partial date fields are also supported. The date will only be set on the scene if a date string can be built using the partial date components:\n\n| Field | Remark |\n|-------|--------|\n|`yyyy`|Four digit year|\n|`yy`|Two digit year. Assumes the first two digits are `20`|\n|`mm`|Two digit month|\n|`mmm`|Three letter month, such as `Jan` (case-insensitive)|\n|`dd`|Two digit date|\n\nThe following full date fields are supported, using the same partial date rules as above:\n\n* `yyyymmdd`\n* `yymmdd`\n* `ddmmyyyy`\n* `ddmmyy`\n* `mmddyyyy`\n* `mmddyy`\n\nAll of these fields are available from the `Add Field` button.\n\nTitle generation also has the following options:\n\n| Option | Remark |\n|--------|--------|\n|Whitespace characters| These characters are replaced with whitespace (defaults to `._`, to handle filenames like `three.word.title.avi`|\n|Capitalize title| capitalises the first letter of each word|\n\nThe fields to display can be customised with the `Display Fields` drop-down section. By default, any field with new/different values will be displayed.\n\n## Applying the results\n\nOnce the options are correct, click on the `Find` button. The system will search for scenes that have filenames that match the given pattern.\n\nThe results are presented in a table showing the existing and generated values of the discovered fields, along with a checkbox to determine whether or not the field will be set on each scene. These fields can also be edited manually.\n\nThe `Apply` button updates the scenes based on the set fields.\n\n> **⚠️ Note:** results are paged and the `Apply` button only applies to scenes on the current page.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md",
    "content": "# Contributing Scrapers \n\nScrapers can be contributed to the community by creating a PR in [this repository](https://github.com/stashapp/CommunityScrapers/pulls).\n\n## XPath scraper templates\n\nThe most basic XPath scraper templates are available on [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers/tree/master/templates).\n\n## Scraper configuration file format\n\n```yaml\nname: <site>\nperformerByName:\n  <single scraper config>\nperformerByFragment:\n  <single scraper config>\nperformerByURL:\n  <multiple scraper URL configs>\nsceneByName:\n  <single scraper config>\nsceneByQueryFragment:\n  <single scraper config>\nsceneByFragment:\n  <single scraper config>\nsceneByURL:\n  <multiple scraper URL configs>\ngroupByURL:\n  <multiple scraper URL configs>\ngalleryByFragment:\n  <single scraper config>\ngalleryByURL:\n  <multiple scraper URL configs>\nimageByFragment:\n  <single scraper config>\nimageByURL:\n  <multiple scraper URL configs>\n<other configurations>\n```\n\n`name` is mandatory, all other top-level fields are optional. The inclusion of each top-level field determines what capabilities the scraper has.\n\nA scraper configuration in any of the top-level fields must at least have an `action` field. The other fields are required based on the value of the `action` field.\n\nThe scraping types and their required fields are outlined in the following table:\n\n| Behavior | Required configuration |\n|-----------|------------------------|\n| Scraper in `Scrape...` dropdown button in Performer Edit page | Valid `performerByName` and `performerByFragment` configurations. |\n| Scrape performer from URL | Valid `performerByURL` configuration with matching URL. |\n| Scraper in query dropdown button in Scene Edit page | Valid `sceneByName` and `sceneByQueryFragment` configurations. |\n| Scraper in `Scrape...` dropdown button in Scene Edit page | Valid `sceneByFragment` configuration. |\n| Scrape scene from URL | Valid `sceneByURL` configuration with matching URL. |\n| Scrape group from URL | Valid `groupByURL` configuration with matching URL. **Note:** `movieByURL` is also supported but is deprecated. |\n| Scraper in `Scrape...` dropdown button in Gallery Edit page | Valid `galleryByFragment` configuration. |\n| Scrape gallery from URL | Valid `galleryByURL` configuration with matching URL. |\n\nURL-based scraping accepts multiple scrape configurations, and each configuration requires a `url` field. stash iterates through these configurations, attempting to match the entered URL against the `url` fields in the configuration. It executes the first scraping configuration where the entered URL contains the value of the `url` field. \n\n    \n## Actions\n\n### Script\n\nExecutes a script to perform the scrape. The `script` field is required for this action and accepts a list of string arguments. For example:\n\n```yaml\naction: script\nscript:\n  - python\n  - iafdScrape.py\n  - query\n```\n\nIf the script specifies the python executable, Stash will find the correct python executable for your system, either `python` or `python3`. So for example. this configuration could execute `python iafdScrape.py query` or `python3 iafdScrape.py query`.\n`python3` will be looked for first and if it's not found, we'll check for `python`. In the case neither are found, you will get an error.\n\nStash sends data to the script process's `stdin` stream and expects the output to be streamed to the `stdout` stream. Any errors and progress messages should be output to `stderr`.\n\nThe script is sent input and expects output based on the scraping type, as detailed in the following table:\n\n| Scrape type | Input | Output |\n|-------------|-------|--------|\n| `performerByName` | `{\"name\": \"<performer query string>\"}` | Array of JSON-encoded performer fragments (including at least `name`) |\n| `performerByFragment` | JSON-encoded performer fragment | JSON-encoded performer fragment |\n| `performerByURL` | `{\"url\": \"<url>\"}` | JSON-encoded performer fragment |\n| `sceneByName` | `{\"name\": \"<scene query string>\"}` | Array of JSON-encoded scene fragments |\n| `sceneByQueryFragment`, `sceneByFragment` | JSON-encoded scene fragment | JSON-encoded scene fragment |\n| `sceneByURL` | `{\"url\": \"<url>\"}` | JSON-encoded scene fragment |\n| `groupByURL` | `{\"url\": \"<url>\"}` | JSON-encoded group fragment |\n| `galleryByFragment` | JSON-encoded gallery fragment | JSON-encoded gallery fragment |\n| `galleryByURL` | `{\"url\": \"<url>\"}` | JSON-encoded gallery fragment |\n| `imageByFragment` | JSON-encoded image fragment | JSON-encoded image fragment |\n| `imageByURL` | `{\"url\": \"<url>\"}` | JSON-encoded image fragment |\n\nFor `performerByName`, only `name` is required in the returned performer fragments. One entire object is sent back to `performerByFragment` to scrape a specific performer, so the other fields may be included to assist in scraping a performer. For example, the `url` field may be filled in for the specific performer page, then `performerByFragment` can extract by using its value.\n  \nPython example of a performer Scraper:\n  \n```python\nimport json\nimport sys\nimport string\n\ndef readJSONInput():\n\tinput = sys.stdin.read()\n\treturn json.loads(input)\n\n\ndef searchPerformer(name):\n    # perform scraping here - using name for the query\n\n    # fill in the output\n    ret = []\n    \n    # example shown for a single found performer \n    p = {}\n    p['name'] = \"some name\"\n    p['url'] = \"performer url\"\n    ret.append(p)\n    \n    return ret\n\ndef scrapePerformer(input):\n    ret = []\n    # get the url from the input\n    url = input['url']\n    return scrapePerformerURL(url)\n\ndef debugPrint(t):\n    sys.stderr.write(t + \"\\n\")\n\ndef scrapePerformerURL(url):\n    debugPrint(\"Reading url...\")\n    debugPrint(\"Parsing html...\")\n    \n    # parse html\n\n    # fill in performer details - single object\n    ret = {}\n\n    ret['name'] = \"fred\"\n    ret['aliases'] = \"freddy\"\n    ret['ethnicity'] = \"\"\n    # and so on\n\n    return ret\n\n# read the input \ni = readJSONInput()\n\nif sys.argv[1] == \"query\":\n    ret = searchPerformer(i['name'])\n    print(json.dumps(ret))\nelif sys.argv[1] == \"scrape\":\n    ret = scrapePerformer(i)\n    print(json.dumps(ret))\nelif sys.argv[1] == \"scrapeURL\":\n    ret = scrapePerformerURL(i['url'])\n    print(json.dumps(ret))\n```\n\n### scrapeXPath\n\nThis action scrapes a web page using an xpath configuration to parse. This action is **not valid** for `performerByFragment`.\n\nThis action requires that the top-level `xPathScrapers` configuration is populated. The `scraper` field is required and must match the name of a scraper name configured in `xPathScrapers`. For example:\n\n```yaml\nsceneByURL:\n- action: scrapeXPath\n  url: \n    - pornhub.com/view_video.php\n  scraper: sceneScraper\n```\n\nThe above configuration requires that `sceneScraper` exists in the `xPathScrapers` configuration.\n\nXPath scraping configurations specify the mapping between object fields and an xpath selector. The xpath scraper scrapes the applicable URL and uses xpath to populate the object fields.\n\n### scrapeJson\n\nThis action works in the same way as `scrapeXPath`, but uses a mapped json configuration to parse. It uses the top-level `jsonScrapers` configuration. This action is **not valid** for `performerByFragment`.\n\nJSON scraping configurations specify the mapping between object fields and a GJSON selector. The JSON scraper scrapes the applicable URL and uses [GJSON](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) to parse the returned JSON object and populate the object fields.\n\n\n### scrapeXPath and scrapeJson use with `performerByName`\n\nFor `performerByName`, the `queryURL` field must be present also. This field is used to perform a search query URL for performer names. The placeholder string sequence `{}` is replaced with the performer name search string. For the subsequent performer scrape to work, the `URL` field must be filled in with the URL of the performer page that matches a URL given in a `performerByURL` scraping configuration. For example:\n\n```yaml\nname: Boobpedia\nperformerByName:\n  action: scrapeXPath\n  queryURL: http://www.boobpedia.com/wiki/index.php?title=Special%3ASearch&search={}&fulltext=Search\n  scraper: performerSearch\nperformerByURL:\n  - action: scrapeXPath\n    url: \n      - boobpedia.com/boobs/\n    scraper: performerScraper\nxPathScrapers:\n  performerSearch:\n    performer:\n      Name: # name element\n      URL: # URL element that matches the boobpedia.com/boobs/ URL above\n  performerScraper:\n    # ... performer scraper details ...\n```\n\n### scrapeXPath and scrapeJson use with `sceneByFragment` and `sceneByQueryFragment`\n\nFor `sceneByFragment` and `sceneByQueryFragment`, the `queryURL` field must also be present. This field is used to build a query URL for scenes. For `sceneByFragment`, the `queryURL` field supports the following placeholder fields:\n\n* `{checksum}` - the MD5 checksum of the scene\n* `{oshash}` - the oshash of the scene\n* `{phash}` - the phash of the scene\n* `{filename}` - the base filename of the scene\n* `{title}` - the title of the scene\n* `{url}` - the url of the scene\n\nThese placeholder field values may be manipulated with regex replacements by adding a `queryURLReplace` section, containing a map of placeholder field to regex configuration which uses the same format as the `replace` post-process action covered below.\n\nFor example:\n\n```yaml\nsceneByFragment:\n  action: scrapeJson\n  scraper: sceneQueryScraper\n  queryURL: https://metadataapi.net/api/scenes?parse={filename}&limit=1\n  queryURLReplace:\n    filename:\n      - regex: <some regex>\n        with: <replacement>\n```\n\nThe above configuration would scrape from the value of `queryURL`, replacing `{filename}` with the base filename of the scene, after it has been manipulated by the regex replacements.\n\n### scrapeXPath and scrapeJson use with `<scene|performer|gallery|group>ByURL`\n\nFor `sceneByURL`, `performerByURL`, `galleryByURL` the `queryURL` can also be present if we want to use `queryURLReplace`. The functionality is the same as `sceneByFragment`, the only placeholder field available though is the `url`:\n\n* `{url}` - the url of the scene/performer/gallery\n\n```yaml\nsceneByURL:\n  - action: scrapeJson\n    url:\n      - metartnetwork.com\n    scraper: sceneScraper\n    queryURL: \"{url}\"\n    queryURLReplace:\n      url:\n        - regex: '^(?:.+\\.)?([^.]+)\\.com/.+movie/(\\d+)/(\\w+)/?$'\n          with: https://www.$1.com/api/movie?name=$3&date=$2\n```\n\n### Stash\n\nA different stash server can be configured as a scraping source. This action applies only to `performerByName`, `performerByFragment`, `sceneByName`, `sceneByQueryFragment` and `sceneByFragment`, types. This action requires that the top-level `stashServer` field is configured.\n\n- `stashServer` contains a single `url` field for the remote stash server. \n- The username and password can be embedded in this string using `username:password@host`. \n- Alternatively, the `apiKey` field can be used to authenticate with the remote stash server.\n\nAn example stash scrape configuration is below:\n\n```yaml\nname: stash\nperformerByName:\n  action: stash\nperformerByFragment:\n  action: stash\nsceneByFragment:\n  action: stash\nsceneByName:\n  action: stash\nsceneByQueryFragment:\n  action: stash\nstashServer:\n  apiKey: <api key>\n  url: http://stashserver.com:9999\n```\n  \n## Xpath and JSON scrapers configuration\n\nThe top-level `xPathScrapers` field contains xpath scraping configurations, freely named. These are referenced in the `scraper` field for `scrapeXPath` scrapers. \n\nLikewise, the top-level `jsonScrapers` field contains json scraping configurations.\n\nCollectively, these configurations are known as mapped scraping configurations. \n\nA mapped scraping configuration may contain a `common` field, and must contain `performer`, `scene`, `group` or `gallery` depending on the scraping type it is configured for. \n\nWithin the `performer`/`scene`/`group`/`gallery` field are key/value pairs corresponding to the [golang fields](/help/ScraperDevelopment.md#object-fields) on the performer/scene object. These fields are case-sensitive. \n\nThe values of these may be either a simple selector value, which tells the system where to get the value of the field from, or a more advanced configuration (see below). For example, for an xpath configuration:\n\n```yaml\nperformer:\n  Name: //h1[@itemprop=\"name\"]\n```\n\nThis will set the `Name` attribute of the returned performer to the text content of the element that matches `<h1 itemprop=\"name\">...`.\n\nFor a json configuration:\n\n```yaml\nperformer:\n  Name: data.name\n```\n\nThe value may also be a sub-object. If it is a sub-object, then the selector must be set to the `selector` key of the sub-object. For example, using the same xpath as above:\n\n```yaml\nperformer:\n  Name: \n    selector: //h1[@itemprop=\"name\"]\n    postProcess:\n      # post-processing config values\n```\n\n### Fixed attribute values\n\nAlternatively, an attribute value may be set to a fixed value, rather than scraping it from the webpage. This can be done by replacing `selector` with `fixed`. For example:\n\n```yaml\nperformer:\n  Gender:\n    fixed: Female\n```\n\n### Input URL placeholders\n\nThe `{inputURL}` and `{inputHostname}` placeholders can be used in both `fixed` values and `selector` expressions to access information about the original URL that was used to scrape the content.\n\n#### {inputURL}\n\nThe `{inputURL}` placeholder provides access to the full URL. This is useful when you want to return or reference the source URL as part of the scraped data.\n\nFor example:\n\n```yaml\nscene:\n  URL:\n    fixed: \"{inputURL}\"\n  Title:\n    selector: //h1[@class=\"title\"]\n```\n\nWhen scraping from `https://example.com/scene/12345`, the `{inputURL}` placeholder will be replaced with `https://example.com/scene/12345`.\n\n#### {inputHostname}\n\nThe `{inputHostname}` placeholder extracts just the hostname from the URL. This is useful when you need to reference the domain without manually parsing the URL.\n\nFor example:\n\n```yaml\nscene:\n  Studio:\n    fixed: \"{inputHostname}\"\n  Details:\n    selector: //div[@data-domain=\"{inputHostname}\"]//p[@class=\"description\"]\n```\n\nWhen scraping from `https://example.com/scene/12345`, the `{inputHostname}` placeholder will be replaced with `example.com`.\n\nThese placeholders can also be used within selectors for more advanced use cases:\n\n```yaml\nscene:\n  Details:\n    selector: //div[@data-url=\"{inputURL}\"]//p[@class=\"description\"]\n  Site:\n    selector: //div[@data-host=\"{inputHostname}\"]//span[@class=\"site-name\"]\n```\n\n> **⚠️ Note:** These placeholders represent the actual URL used to fetch the content, after any URL replacements have been applied.\n\n### Common fragments\n\nThe `common` field is used to configure selector fragments that can be referenced in the selector strings. These are key-value pairs where the key is the string to reference the fragment, and the value is the string that the fragment will be replaced with. For example:\n\n```yaml\ncommon:\n  $infoPiece: //div[@class=\"infoPiece\"]/span\nperformer:\n  Measurements: $infoPiece[text() = 'Measurements:']/../span[@class=\"smallInfo\"]\n```\n\nThe `Measurements` xpath string will replace `$infoPiece` with `//div[@class=\"infoPiece\"]/span`, resulting in: `//div[@class=\"infoPiece\"]/span[text() = 'Measurements:']/../span[@class=\"smallInfo\"]`.\n\n> **⚠️ Note:** Recursive common fragments are **not** supported.  \n\nReferencing a common fragment within another common fragment will cause an error. For example:\n```yaml\ncommon:\n  $info: //div[@class=\"info\"]\n  # Referencing $info in $models will cause an error\n  $models: $info/a[@class=\"model\"]\nscene:\n  Title: $info/h1\n  Performers:\n    Name: $models\n    URL: $models/@href\n```\n\n### Post-processing options\n\nPost-processing operations are contained in the `postProcess` key. Post-processing operations are performed in the order they are specified. The following post-processing operations are available:\n\n* `javascript`: accepts a javascript code block, that must return a string value. The input string is declared in the `value` variable. If an error occurs while compiling or running the script, then the original value is returned.\nExample:\n```yaml\nperformer:\n  Name:\n    selector: //div[@class=\"example element\"]\n    postProcess:\n      - javascript: |\n          // capitalise the first letter\n          if (value && value.length) {\n            return value[0].toUpperCase() + value.substring(1)\n          }\n```\n\nWe use [`goja` javascript engine](https://github.com/dop251/goja) which is missing a few built-in methods and may not be consistent with other modern javascript implementations.\n\n* `feetToCm`: converts a string containing feet and inches numbers into centimeters. Looks for up to two separate integers and interprets the first as the number of feet, and the second as the number of inches. The numbers can be separated by any non-numeric character including the `.` character. It does not handle decimal numbers. For example `6.3` and `6ft3.3` would both be interpreted as 6 feet, 3 inches before converting into centimeters.\n* `lbToKg`: converts a string containing lbs to kg.\n* `map`: contains a map of input values to output values. Where a value matches one of the input values, it is replaced with the matching output value. If no value is matched, then value is unmodified.\nExample:\n```yaml\nperformer:\n  Gender:\n    selector: //div[@class=\"example element\"]\n    postProcess:\n      - map:\n          F: Female\n          M: Male\n  Height:\n    selector: //span[@id=\"height\"]\n    postProcess:\n      - feetToCm: true\n  Weight:\n    selector: //span[@id=\"weight\"]\n    postProcess:\n      - lbToKg: true\n```\nGets the contents of the selected div element, and sets the returned value to:\n    - `Female` if the scraped value is `F`;\n    - `Male` if the scraped value is `M`.\n\n    Height and weight are extracted from the selected spans and converted to `cm` and `kg`.\n\n* `parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). Strings \"Today\", \"Yesterday\" are matched (case insensitive) and converted by the scraper so you don't need to edit/replace them. \nUnix timestamps (example: 1660169451) can also be parsed by selecting `unix` as the date format.\nExample:\n```yaml\nDate:\n  selector: //div[@class=\"value epoch\"]/text()\n  postProcess:\n    - parseDate: unix\n```\n\n* `subtractDays`: if set to `true` it subtracts the value in days from the current date and returns the resulting date in stash's date format.\nExample:\n```yaml\nDate:\n  selector: //strong[contains(text(),\"Added:\")]/following-sibling::text()\n  postProcess:\n    - replace:\n        - regex: (\\d+)\\sdays\\sago.+\n          with: $1\n    - subtractDays: true\n```\n\n* `replace`: contains an array of sub-objects. Each sub-object must have a `regex` and `with` field. The `regex` field is the regex pattern to replace, and `with` is the string to replace it with. `$` is used to reference capture groups - `$1` is the first capture group, `$2` the second and so on. Replacements are performed in order of the array.\nExample:\n```yaml\nCareerLength: \n  selector: $infoPiece[text() = 'Career Start and End:']/../span[@class=\"smallInfo\"]\n    postProcess:\n      - replace:\n          - regex: \\s+to\\s+\n            with: \"-\"\n```\nReplaces `2001 to 2003` with `2001-2003`.\n\n* `subScraper`: if present, the sub-scraper will be executed after all other post-processes are complete and before parseDate. It then takes the value and performs an http request, using the value as the URL. Within the `subScraper` config is a nested scraping configuration. This allows you to traverse to other webpages to get the attribute value you are after. For more info and examples have a look at [#370](https://github.com/stashapp/stash/pull/370), [#606](https://github.com/stashapp/stash/pull/606)\n\nAdditionally, there are a number of fixed post-processing fields that are specified at the attribute level (not in `postProcess`) that are performed after the `postProcess` operations:\n\n* `concat`: if an xpath matches multiple elements, and `concat` is present, then all of the elements will be concatenated together\n* `split`: the inverse of `concat`. Splits a string to more elements using the separator given. For more info and examples have a look at PR [#579](https://github.com/stashapp/stash/pull/579)\nExample:\n```yaml\nTags:\n  Name:\n    selector: //span[@class=\"list_attributes\"]\n    split: \",\"\n```\nSplits a comma separated list of tags located in the span and returns the tags.\n\n\nFor backwards compatibility, `replace`, `subscraper` and `parseDate` are also allowed as keys for the attribute.\n\nPost-processing on attribute post-process is done in the following order: `concat`, `replace`, `subscraper`, `parseDate` and then `split`.\n\n### XPath resources:\n\n- Test XPaths in Firefox: https://addons.mozilla.org/en-US/firefox/addon/try-xpath/\n- XPath cheatsheet: https://devhints.io/xpath\n\n### GJSON resources:\n\n- GJSON Path Syntax: https://github.com/tidwall/gjson/blob/master/SYNTAX.md\n\n### Debugging support\nTo print the received html/json from a scraper request to the log file, add the following to your scraper yml file:\n```yaml\ndebug:\n  printHTML: true\n```\n\n### CDP support\n\nSome websites deliver content that cannot be scraped using the raw html file alone. These websites use javascript to dynamically load the content. As such, direct xpath scraping will not work on these websites. There is an option to use Chrome DevTools Protocol to load the webpage using an instance of Chrome, then scrape the result.\n\nChrome CDP support can be enabled for a specific scraping configuration by adding the following to the root of the yml configuration:\n```yaml\ndriver:\n  useCDP: true\n```\n\nOptionally, you can add a `sleep` value under the `driver` section. This specifies the amount of time (in seconds) that the scraper should wait after loading the website to perform the scrape. This is needed as some sites need more time for loading scripts to finish. If unset, this value defaults to 2 seconds.\n\nWhen `useCDP` is set to true, stash will execute or connect to an instance of Chrome. The behavior is dictated by the `Chrome CDP path` setting in the user configuration. If left empty, stash will attempt to find the Chrome executable in the path environment, and will fail if it cannot find one. \n\n`Chrome CDP path` can be set to a path to the chrome executable, or an http(s) address to remote chrome instance (for example: `http://localhost:9222/json/version`). As remote instance a docker container can also be used with the `chromedp/headless-shell` image being highly recommended.\n\n### CDP Click support\n\nWhen using CDP you can use  the `clicks` part of the `driver` section to do Mouse Clicks on elements you need to collapse or toggle. Each click element has an `xpath` value that holds the XPath for the button/element you need to click and an optional `sleep` value that is the time in seconds to wait for after clicking.\nIf the `sleep` value is not set it defaults to `2` seconds.\n\nA demo scraper using `clicks` follows.\n\n```yaml\nname: clickDemo # demo only for a single URL\nsceneByURL:\n  - action: scrapeXPath\n    url:\n      - https://getbootstrap.com/docs/4.3/components/collapse/\n    scraper: sceneScraper\n\nxPathScrapers:\n  sceneScraper:\n    scene:\n      Title: //head/title\n      Details: # shows the id/s of the visible div/s for the Multiple targets example of the page\n        selector: //div[@class=\"bd-example\"]//div[@class=\"multi-collapse collapse show\"]/@id\n        concat: \"\\n\\n\"\n\ndriver:\n  useCDP: true\n  sleep: 1\n  clicks: # demo usage toggle on off multiple times\n    - xpath: //a[@href=\"#multiCollapseExample1\"] # toggle on first element\n    - xpath: //button[@data-target=\"#multiCollapseExample2\"] # toggle on second element\n      sleep: 4\n    - xpath: //a[@href=\"#multiCollapseExample1\"] # toggle off fist element\n      sleep: 1\n    - xpath: //button[@data-target=\"#multiCollapseExample2\"] # toggle off second element\n    - xpath: //button[@data-target=\"#multiCollapseExample2\"] # toggle on second element\n```\n\n> **⚠️ Note:** each `click` adds an extra delay of `clicks sleep` seconds, so the above adds `2+4+1+2+2=11` seconds to the loading time of the page.\n\n### Cookie support\n\nIn some websites the use of cookies is needed to bypass a welcoming message or some other kind of protection. Stash supports the setting of cookies for the direct xpath scraper and the CDP based one. Due to implementation issues the usage varies a bit.\n\nTo use the cookie functionality a `cookies` sub section needs to be added to the `driver` section.\nEach cookie element can consist of a `CookieURL` and a number of `Cookies`.\n\n* `CookieURL` is only needed if you are using the direct / native scraper method. It is the request url that we expect from the site we scrape. It must be in the same domain as the cookies we try to set otherwise all cookies in the same group will fail to set. If the `CookieURL` is not a valid URL then again the cookies of that group will fail.\n\n* `Cookies` are the actual cookies we set. When using CDP that's the only part required. They have  `Name`, `Value`, `Domain`, `Path` values.\n\nIn the following example we use cookies for a site using the direct / native xpath scraper. We expect requests to come from `https://www.example.com` and `https://api.somewhere.com` that look for a `_warning` and a `_warn` cookie. A `_test2` cookie is also set just as a demo.\n\n```yaml\ndriver:\n  cookies:\n    - CookieURL: \"https://www.example.com\"\n      Cookies:\n        - Name: \"_warning\"\n          Domain: \".example.com\"\n          Value: \"true\"\n          Path: \"/\"\n        - Name: \"_test2\"\n          Value: \"123412\"\n          Domain: \".example.com\"\n          Path: \"/\"\n    - CookieURL: \"https://api.somewhere.com\"\n      Cookies:\n        - Name: \"_warn\"\n          Value: \"123\"\n          Domain: \".somewhere.com\"\n```\n\nThe same functionality when using CDP would look like this:\n\n```yaml\ndriver:\n  useCDP: true\n  cookies:\n    - Cookies:\n        - Name: \"_warning\"\n          Domain: \".example.com\"\n          Value: \"true\"\n          Path: \"/\"\n        - Name: \"_test2\"\n          Value: \"123412\"\n          Domain: \".example.com\"\n          Path: \"/\"\n    - Cookies:\n        - Name: \"_warn\"\n          Value: \"123\"\n          Domain: \".somewhere.com\"\n```\n\nFor some sites, the value of the cookie itself doesn't actually matter. In these cases, we can use the `ValueRandom`\nproperty instead of `Value`. Unlike `Value`, `ValueRandom` requires an integer value greater than `0` where the value\nindicates how long the cookie string should be.\n\nIn the following example, we will adapt the previous cookies to use `ValueRandom` instead. We set the `_test2` cookie\nto randomly generate a value with a length of 6 characters and the `_warn` cookie to a length of 3.\n\n```yaml\ndriver:\n  cookies:\n    - CookieURL: \"https://www.example.com\"\n      Cookies:\n        - Name: \"_warning\"\n          Domain: \".example.com\"\n          Value: \"true\"\n          Path: \"/\"\n        - Name: \"_test2\"\n          ValueRandom: 6\n          Domain: \".example.com\"\n          Path: \"/\"\n    - CookieURL: \"https://api.somewhere.com\"\n      Cookies:\n        - Name: \"_warn\"\n          ValueRandom: 3\n          Domain: \".somewhere.com\"\n```\n\nWhen developing a scraper you can have a look at the cookies set by a site by adding\n\n* a `CookieURL` if you use the direct xpath scraper\n\n* a `Domain` if you use the CDP scraper\n\nand having a look at the log / console in debug mode.\n\n### Headers\n\nSending request headers is possible when using a scraper.\nHeaders can be set in the `driver` section and are supported for plain, CDP enabled and JSON scrapers.\nThey consist of a Key and a Value. If the Key is empty or not defined then the header is ignored.\n\n```yaml\ndriver:\n  headers:\n    - Key: User-Agent\n      Value: My Stash Scraper\n    - Key: Authorization\n      Value: Bearer ds3sdfcFdfY17p4qBkTVF03zscUU2glSjWF17bZyoe8\n```\n\n* headers are set after stash's `User-Agent` configuration option is applied.\nThis means setting a `User-Agent` header from the scraper overrides the one in the configuration settings.\n\n### XPath scraper example\n\nA performer and scene xpath scraper is shown as an example below:\n\n```yaml\nname: Pornhub\nperformerByURL:\n  - action: scrapeXPath\n    url: \n      - pornhub.com\n    scraper: performerScraper\nsceneByURL:\n  - action: scrapeXPath\n    url: \n      - pornhub.com/view_video.php\n    scraper: sceneScraper\nxPathScrapers:\n  performerScraper:\n    common:\n      $infoPiece: //div[@class=\"infoPiece\"]/span\n    performer:\n      Name: //h1[@itemprop=\"name\"]\n      Birthdate: \n        selector: //span[@itemprop=\"birthDate\"]\n        parseDate: Jan 2, 2006\n      Twitter: //span[text() = 'Twitter']/../@href\n      Instagram: //span[text() = 'Instagram']/../@href\n      Measurements: $infoPiece[text() = 'Measurements:']/../span[@class=\"smallInfo\"]\n      Height: \n        selector: $infoPiece[text() = 'Height:']/../span[@class=\"smallInfo\"]\n        postProcess:\n          - replace: \n              - regex: .*\\((\\d+) cm\\)\n                with: $1\n      Ethnicity: $infoPiece[text() = 'Ethnicity:']/../span[@class=\"smallInfo\"]\n      FakeTits: $infoPiece[text() = 'Fake Boobs:']/../span[@class=\"smallInfo\"]\n      Piercings: $infoPiece[text() = 'Piercings:']/../span[@class=\"smallInfo\"]\n      Tattoos: $infoPiece[text() = 'Tattoos:']/../span[@class=\"smallInfo\"]\n      CareerLength: \n        selector: $infoPiece[text() = 'Career Start and End:']/../span[@class=\"smallInfo\"]\n        postProcess:\n          - replace:\n            - regex: \\s+to\\s+\n              with: \"-\"\n  sceneScraper:\n    common:\n      $performer: //div[@class=\"pornstarsWrapper\"]/a[@data-mxptype=\"Pornstar\"]\n      $studio: //div[@data-type=\"channel\"]/a\n    scene:\n      Title: //div[@id=\"main-container\"]/@data-video-title\n      Tags: \n        Name: //div[@class=\"categoriesWrapper\"]//a[not(@class=\"add-btn-small \")]\n      Performers:\n        Name: $performer/@data-mxptext\n        URL: $performer/@href\n      Studio:\n        Name: $studio\n        URL: $studio/@href\n        Details: //div[@class=\"studioDescription\"]\n        Aliases: //div[@class=\"studioAliases\"]/span\n        Tags:\n          Name: //div[@class=\"studioTags\"]/a    \n```\n\nSee also [#333](https://github.com/stashapp/stash/pull/333) for more examples.\n\n### JSON scraper example\n\nA performer and scene scraper for ThePornDB is shown below:\n\n```yaml\nname: ThePornDB\nperformerByName:\n  action: scrapeJson\n  queryURL: https://api.metadataapi.net/performers?q={}\n  scraper: performerSearch\nperformerByURL:\n  - action: scrapeJson\n    url:\n      - https://api.metadataapi.net/performers/\n    scraper: performerScraper\nsceneByURL:\n  - action: scrapeJson\n    url:\n      - https://api.metadataapi.net/scenes/\n    scraper: sceneScraper\nsceneByFragment:\n  action: scrapeJson\n  queryURL: https://api.metadataapi.net/scenes?parse={filename}&hash={oshash}&limit=1\n  scraper: sceneQueryScraper\n  queryURLReplace:\n    filename:\n      - regex: \"[^a-zA-Z\\\\d\\\\-._~]\" # clean filename so that it can construct a valid url\n        with: \".\" # \"%20\"\n      - regex: HEVC\n        with:\n      - regex: x265\n        with:\n      - regex: \\.+\n        with: \".\"\njsonScrapers:\n  performerSearch:\n    performer:\n      Name: data.#.name\n      URL:\n        selector: data.#.id\n        postProcess:\n          - replace:\n              - regex: ^\n                with: https://api.metadataapi.net/performers/\n\n  performerScraper:\n    common:\n      $extras: data.extras\n    performer:\n      Name: data.name\n      Gender: $extras.gender\n      Birthdate: $extras.birthday\n      Ethnicity: $extras.ethnicity\n      Height:\n        selector: $extras.height\n        postProcess:\n          - replace:\n              - regex: cm\n                with:\n      Measurements: $extras.measurements\n      Tattoos: $extras.tattoos\n      Piercings: $extras.piercings\n      Aliases: data.aliases\n      Image: data.image\n\n  sceneScraper:\n    common:\n      $performers: data.performers\n    scene:\n      Title: data.title\n      Details: data.description\n      Date: data.date\n      URL: data.url\n      Image: data.background.small\n      Performers:\n        Name: data.performers.#.name\n      Studio:\n        Name: data.site.name\n        URL: data.site.url\n        Details: data.site.description\n        Aliases: data.site.aliases\n        Tags:\n          Name: data.site.tags.#.name\n      Tags:\n        Name: data.tags.#.tag\n\n  sceneQueryScraper:\n    common:\n      $data: data.0\n      $performers: data.0.performers\n    scene:\n      Title: $data.title\n      Details: $data.description\n      Date: $data.date\n      URL: $data.url\n      Image: $data.background.small\n      Performers:\n        Name: $data.performers.#.name\n      Studio:\n        Name: $data.site.name\n        URL: $data.site.url\n        Details: $data.site.description\n        Aliases: $data.site.aliases\n        Tags:\n          Name: $data.site.tags.#.name\n      Tags:\n        Name: $data.tags.#.tag\ndriver:\n  headers:\n    - Key: User-Agent\n      Value: Stash JSON Scraper\n    - Key: Authorization\n      Value: Bearer lPdwFdfY17p4qBkTVF03zscUU2glSjdf17bZyoe  # use an actual API Key here\n# Last Updated April 7, 2021\n```\n\n## Object fields\n\n### Gallery\n\n```\nCode\nDate\nDetails\nPerformers (see Performer fields)\nPhotographer\nRating\nStudio (see Studio Fields)\nTags (see Tag fields)\nTitle\nURLs\n```\n\n> **⚠️ Important:** `Title` field is required. \n\n### Group\n\n```\nAliases\nBackImage\nDate\nDirector\nDuration\nFrontImage\nName\nRating\nStudio (see Studio Fields)\nSynopsis\nTags (see Tag fields)\nURLs\n```\n\n> **⚠️ Important:** `Name` field is required. \n\n### Image\n\n```\nCode\nDate\nDetails\nPerformers (see Performer fields)\nPhotographer\nRating\nStudio (see Studio Fields)\nTags (see Tag fields)\nTitle\nURLs\n```\n\n### Performer\n\n```\nAliases\nBirthdate\nCareerLength\nCircumcised\nCountry\nDeathDate\nDetails\nDisambiguation\nEthnicity\nEyeColor\nFakeTits\nGender\nHairColor\nHeight\nMeasurements\nName\nPenisLength\nPiercings\nTags (see Tag fields)\nTattoos\nURLs\nWeight\n```\n\n> **⚠️ Important:** `Name` field is required. \n\n> **⚠️ Note:** `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive).\n\n### Scene\n\n```\nCode\nDate\nDetails\nDirector\nGroups (see Group Fields)\nImage\nPerformers (see Performer fields)\nStudio (see Studio Fields)\nTags (see Tag fields)\nTitle\nURLs\n```\n\n> **⚠️ Important:** `Title` field is required only if fileless.\n\n### Studio\n\n```\nAliases\nDetails\nName\nTags (see Tag fields)\nURL\n```\n\n> **⚠️ Important:** `Name` field is required. \n\n### Tag\n\n```\nName\n```\n\n> **⚠️ Important:** `Name` field is required. \n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Scraping.md",
    "content": "# Metadata Scraping\n\nStash supports scraping of metadata from various external sources.\n\n## Scraper Types\n\n| Type | Description |\n|---|:---|\n| Fragment | Uses existing metadata for an Item and match it to a result from a metadata source. |\n| Search/By Name | Uses a provided query string to search a metadata source for a list of matches for the user to pick from. |\n| URL | Extracts metadata from a given URL. |\n\n## Supported Scrapers\n\n|   | Fragment | Search | URL |\n|---|:---:|:---:|:---:|\n| gallery | ✔️ | | ✔️ |\n| image | ✔️ | | ✔️ |\n| group | | | ✔️ |\n| performer | | ✔️ | ✔️ |\n| scene | ✔️  | ✔️ | ✔️ |\n\n## Included Scrapers\n\nStash provides the following built-in scrapers:\n\n| Scraper | Description |\n|---|--|\n| Freeones | `search` Performer scraper for freeones.xxx. |\n| Auto Tag | Scene `fragment` scraper that matches existing performers, studio and tags using the filename. |\n\n## Managing Scrapers\n\nScrapers can be installed and managed from the `Settings > Metadata Providers` page. \n\nScrapers are installed using the `Available Scrapers` section. This section allows configuring sources from which to install scrapers. The `Community (stable)` source is configured by default. This source contains scrapers for the current _stable_ version of stash.\n\nThese are the scraper sources maintained by the stashapp organisation:\n\n| Name | Source URL | Recommended Local Path | Notes |\n|------|-----------|------------------------|-------|\n| Community (stable) | `https://stashapp.github.io/CommunityScrapers/stable/index.yml` | `stable` | For the current stable version of stash. |\n| Community (develop) | `https://stashapp.github.io/CommunityScrapers/develop/index.yml` | `develop` | For the develop version of stash. |\n\nInstalled scrapers can be updated or uninstalled from the `Installed Scrapers` section.\n\n### Source URLs\n\nThe source URL must return a yaml file containing all the available packages for the source. An example source yaml file looks like the following:\n\n```\n- id: <package id>\n  name: <package name>\n  version: <version>\n  date: <date>\n  requires:\n  - <ids of packages required by this package (optional)>\n  - ...\n  path: <path to package zip file>\n  sha256: <sha256 of zip>\n  metadata:\n    <optional key/value pairs for extra information>\n- ...\n```\n\nPath can be a relative path to the zip file or an external URL.\n\n## Adding Scrapers manually\n\nBy default, Stash looks for scraper configurations in the `scrapers` sub-directory of the directory where the stash `config.yml` is read. This will either be the `$HOME/.stash` directory or the current working directory.\n\nScrapers are added manually by placing yaml configuration files (format: `scrapername.yml`) in the `scrapers` directory.\n\n> **⚠️ Note:** Some scrapers may require more than just the yaml file, consult the individual scraper documentation\n\nAfter the yaml files are added, removed or edited while stash is running, they can be reloaded going to `Settings > Metadata Providers > Scrapers` and clicking `Reload Scrapers`.\n  \n## Using Scrapers\n\n#### Fragment Scraper\nClick on the `Scrape With...` button in the `edit` tab of an item, then select the scraper you wish to use.\n\n#### Search Scraper\nClick on the 🔍 button in the `edit` tab of an item. You will be presented with a search dialog with a pre-populated query to search for, after searching you will be presented with a list of results to pick from\n\n#### URL Scraper\nEnter the URL in the `edit` tab of an Item. If a scraper is installed that supports that url, then a button will appear to scrape the metadata.\n\n## Tagger View\n\nThe Tagger view is accessed from the scenes page. It allows the user to run scrapers on all items on the current page. The Tagger presents the user with potential matches for an item from a selected stash-box instance or metadata source if supported. The user needs to select the correct metadata information to save. \n\nWhen used in combination with stash-box, the user can optionally submit scene fingerprints to contribute to a stash-box instance. A scene fingerprint consists of any generated hashes (`phash`, `oshash`, `md5`) and the scene duration. Fingerprint submissions are associated with your stash-box account. Submitting fingerprints assists others in matching their files, because stash-box returns a count of matching user submitted fingerprints with every potential match.\n\n| | Has Tagger | Source Selection |\n|---|:---:|:---:|\n| gallery | | |\n| group | | |\n| performer | ✔️ | |\n| scene | ✔️ | ✔️ |\n\n\n## Identify Task\n\nThis task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources. This task can be found under `Settings -> Tasks -> \"Identify...\" (Button)`. For more information see the [Tasks > Identify](/help/Identify.md) page.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Tagger.md",
    "content": "# Scene Tagger\n\nStash can be integrated with stash-box which acts as a centralized metadata database. This is in the early stages of development but can be used for fingerprint/keyword lookups and automated tagging of performers and scenes. The batch tagging interface can be accessed from the [scene view](/scenes?disp=3). For more information join our [Discord](https://discord.gg/2TsNFKt).\n\n## Searching \n\nThe fingerprint search matches your current selection of files against the remote stash-box instance. Any scenes with a matching fingerprint will be returned, although there is currently no validation of fingerprints so it's recommended to double-check the validity before saving.\n\nIf no fingerprint match is found it's possible to search by keywords. The search works by matching the query against a scene's _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config.\n\nAn important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `\"A Trip to the Mall\"` with the performer `\"Jane Doe\"`, a search for `\"Trip to the Mall 1080p\"` will *not* match, however `\"trip mall doe\"` would. Usually a few pieces of info is enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query.\n\n## Saving\nWhen a scene is matched stash will try to match the studio and performers against your local studios and performers. If you have previously matched them, they will automatically be selected. If not you either have to select the correct performer/studio from the dropdown, choose create to create a new entity, or skip to ignore it.\n\nOnce a scene is saved the scene and the matched studio/performers will have the `stash_id` saved which will then be used for future tagging.\n\nBy default male performers are not shown, this can be enabled in the tagger config. Likewise scene tags are by default not saved. They can be set to either merge with existing tags on the scene, or overwrite them. It is not recommended to set tags currently since they are hard to deduplicate and can litter your data.\n\n## Submitting fingerprints\nAfter a scene is saved you will prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical copy who will then be able to match via the fingerprint search. No other information than the `stash_id` and file fingerprint is submitted.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/Tasks.md",
    "content": "# Tasks\n\nThis page allows you to direct the stash server to perform a variety of tasks.\n\n## Scanning\n\nThe scan function walks through the stash directories you have configured for new and moved files. \n\nStash currently identifies files by performing a quick file hash. This means that if the file is renamed for moved elsewhere within your configured stash directories, then the scan will detect this and update its database accordingly.\n\nStash currently ignores duplicate files. If two files contain identical content, only the first one it comes across is used.\n\n### Ignoring Files with .stashignore\n\nYou can create `.stashignore` files to exclude specific files or directories from being scanned. These files use gitignore-style pattern matching syntax.\n\nPlace a `.stashignore` file in any directory within your library. The patterns in that file will apply to all files and subdirectories within that directory. You can have multiple `.stashignore` files at different levels of your directory hierarchy - patterns from parent directories cascade down to child directories.\n\n`.stashignore` files are not read inside zip files.\n\n**Supported patterns:**\n\n| Pattern | Description |\n|---------|-------------|\n| `filename.mp4` | Ignore a specific file. |\n| `*.tmp` | Ignore all files with a specific extension. |\n| `temp/` | Ignore a directory and all its contents. |\n| `**/cache/` | Ignore directories named \"cache\" at any level. |\n| `!important.mp4` | Negation - do not ignore this file even if it matches a previous pattern. |\n| `# comment` | Lines starting with # are comments. |\n| `\\#filename` | Use backslash to match a literal # character. |\n\n**Example .stashignore file:**\n\n```\n# Ignore temporary files\n*.tmp\n*.log\n\n# Ignore specific directories\ntemp/\n.thumbnails/\n\n# But keep this specific file\n!important.tmp\n```\n\nThe scan task accepts the following options:\n\n| Option | Description |\n|--------|-------------|\n| Generate scene covers | Generates scene covers for video files. |\n| Generate previews | Generates video previews (mp4) which play when hovering over a scene. |\n| Generate animated image previews | *Accessible in Advanced mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.|\n| Generate scrubber sprites | The set of images displayed below the video player for easy navigation. |\n| Generate video perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |\n| Generate thumbnails for images | Generates thumbnails for image files. | \n| Generate image perceptual hashes | Generates perceptual hashes for image deduplication and identification. |\n| Generate previews for image clips | Generates a gif/looping video as thumbnail for image clips/gifs. |\n| Rescan | By default, Stash will only rescan existing files if the file's modified date has been updated since its previous scan. Stash will rescan files in the path when this option is enabled, regardless of the file modification time. Only required if Stash needs to recalculate video/image metadata, or to rescan gallery zips. |\n\n## Auto Tagging\nSee the [Auto Tagging](/help/AutoTagging.md) page.\n\n## Scene Filename Parser\nSee the [Scene Filename Parser](/help/SceneFilenameParser.md) page.\n\n## Generated Content\n\nThe generated content provides the following:\n\n* Scene covers - screenshot of the scene used as the cover image\n* Video or image previews that are played when mousing over the scene card\n* Video Perceptual hashes - helps match against StashDB, and feeds the duplicate finder\n* Sprites (scene stills for parts of each scene) that are shown in the scene scrubber\n* Marker video previews that are shown in the markers page\n* Transcoded versions of scenes. See below\n* Image thumbnails of galleries\n* Image Perceptual hashes - can be used for identification and deduplication\n\nThe generate task accepts the following options:\n\n| Option | Description |\n|--------|-------------|\n| Scene covers | Generates scene covers for video files. |\n| Previews | Generates video previews (mp4) which play when hovering over a scene. |\n| Animated image previews | *Accessible in Advanced mode* - Generates animated previews (webp). Only required if the Preview type is set to Animated image. Requires Generate previews to be enabled. |\n| Scene scrubber sprites | The set of images displayed below the video player for easy navigation. |\n| Marker previews | Generates 20 second video previews (mp4) which begin at the marker timecode. |\n| Marker animated image previews | *Accessible in Advanced mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files. |\n| Marker screenshots | Generates static JPG images for markers. Only required if Preview type is set to Static image. Requires marker previews to be enabled. | \n| Transcodes | *Accessible in Advanced mode* - MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. |\n| Video Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. |\n| Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. |\n| Image clip previews | Generates a gif/looping video as thumbnail for image clips/gifs. |\n| Image thumbnails | Generates thumbnails for image files. |\n| Image Perceptual hashes (for deduplication) | Generates perceptual hashes for image deduplication and identification. |\n| Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. |\n\n### Transcodes\n\nWeb browsers support a limited number of video and audio codecs and containers. Stash will directly stream video files where the browser supports the codecs and container. Originally, stash did not support viewing scene videos where the browser did not support the codecs/container, and generating transcodes was a way of viewing these files.\n\nStash has since implemented live transcoding, so transcodes are essentially unnecessary now. Further, transcodes use up a significant amount of disk space and are not guaranteed to be lossless.\n\n### Image gallery thumbnails\n\nThese are generated when the gallery is first viewed, so generating them beforehand is not necessary.\n\n## Cleaning\n\nThis task will walk through your configured media directories and remove any scene from the database that can no longer be found. It will also remove generated files for scenes that subsequently no longer exist.\n\nCare should be taken with this task, especially where the configured media directories may be inaccessible due to network issues.\n\n## Exporting and Importing\n\nThe import and export tasks read and write JSON files to the configured metadata directory. Import from file will merge your database with a file.\n\n> **⚠️ Note:** The full import task wipes the current database completely before importing.\n\nSee the [JSON Specification](/help/JSONSpec.md) page for details on the exported JSON format.\n\n## Backing up\n\nThe backup task creates a backup of the stash database and (optionally) blob files. The backup can either be downloaded or output into the backup directory (under `Settings > Paths`) or the database directory if the backup directory is not configured.\n\nFor a full backup, the database file and all blob files must be copied. The backup is stored as a zip file, with the database file at the root of the zip and the blob files in a `blobs` directory.\n\n> **⚠️ Note:** generated files are not included in the backup, so these will need to be regenerated when restoring with an empty system from backup.\n\nFor database-only backups, only the database file is copied into the destination. This is useful for quick backups before performing risky operations, or for users who do not use filesystem blob storage.\n\n## Restoring from backup\n\nRestoring from backup is currently a manual process. The database backup zip file must be unzipped, and the database file and blob files (if applicable) copied into the database and blob directories respectively. Stash should then be restarted to load the restored database.\n\n> **⚠️ Note:** the filename for a database-only backup is not the same as the original database file, so the database file from the backup must be renamed to match the original database filename before copying it into the database directory. The original database filename can be found in `Settings > Paths > Database path`."
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md",
    "content": "# Troubleshooting Mode\n\nTroubleshooting mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue.\n\nTroubleshooting mode is enabled from the Settings page, by clicking the `Troubleshooting mode` button at the bottom left of the page.\n\nWhen Troubleshooting mode is enabled, a red border and a banner will be displayed to remind you that you are in Troubleshooting mode. To exit Troubleshooting mode, click the `Exit` button in the banner."
  },
  {
    "path": "ui/v2.5/src/docs/en/Manual/UIPluginApi.md",
    "content": "# UI Plugin API\n\nThe `PluginApi` object is a global object in the `window` object.\n\n`PluginApi` is considered experimental and is subject to change without notice. This documentation covers only the plugin-specific API. It does not necessarily cover the core UI API. Information on these methods should be referenced in the UI source code.\n\nAn example using various aspects of `PluginApi` may be found in the source code under `pkg/plugin/examples/react-component`.\n\n## Properties\n\n### `React`\n\nAn instance of the React library.\n\n### `ReactDOM`\n\nAn instance of the ReactDOM library.\n\n### `GQL`\n\nThis namespace contains the generated graphql client interface. This is a low-level interface. In many cases, `StashService` should be used instead.\n\n### `libraries`\n\n`libraries` provides access to the following UI libraries:\n\n- `ReactRouterDOM`\n- `Bootstrap`\n- `Apollo`\n- `Intl`\n- `FontAwesomeRegular`\n- `FontAwesomeSolid`\n- `FontAwesomeBrands`\n- `Mousetrap`\n- `MousetrapPause`\n- `ReactFontAwesome`\n- `ReactSelect`\n\n### `register`\n\nThis namespace contains methods used to register page routes and components.\n\n#### `PluginApi.register.route`\n\nRegisters a route in the React Router.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `path` | `string` | The path to register. This should generally use the `/plugin/` prefix. |\n| `component` | `React.FC` | A React function component that will be rendered when the route is loaded. |\n\nReturns `void`.\n\n#### `PluginApi.register.component`\n\nRegisters a component to be used by plugins. The component will be available in the `components` namespace.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `name` | `string` | The name of the component to register. This should be unique and should ideally be prefixed with `plugin-`. |\n| `component` | `React.FC` | A React function component. |\n\nReturns `void`.\n\n### `components`\n\nThis namespace contains all of the components available to plugins. These include a selection of core components and components registered using `PluginApi.register.component`.\n\n### `utils`\n\nThis namespace provides access to the `NavUtils` , `StashService` and `InteractiveUtils` namespaces. It also provides access to the `loadComponents` method.\n\n#### `PluginApi.utils.loadComponents`\n\nDue to code splitting, some components may not be loaded and available when a plugin page is rendered. `loadComponents` loads all of the components that a plugin page may require.\n\nIn general, `PluginApi.hooks.useLoadComponents` hook should be used instead.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `components` | `Promise[]` | The list of components to load. These values should come from the `PluginApi.loadableComponents` namespace. |\n\nReturns a `Promise<void>` that resolves when all of the components have been loaded.\n\n#### `PluginApi.utils.InteractiveUtils`\nThis namespace provides access to `interactiveClientProvider` and `getPlayer`\n - `getPlayer` returns the current `videojs` player object\n - `interactiveClientProvider` takes `IInteractiveClientProvider` which allows a developer to hook into the lifecycle of funscripts.\n```ts\n  export interface IDeviceSettings {\n  connectionKey: string;\n  scriptOffset: number;\n  estimatedServerTimeOffset?: number;\n  useStashHostedFunscript?: boolean;\n  [key: string]: unknown;\n}\n\nexport interface IInteractiveClientProviderOptions {\n  handyKey: string;\n  scriptOffset: number;\n  defaultClientProvider?: IInteractiveClientProvider;\n  stashConfig?: GQL.ConfigDataFragment;\n}\nexport interface IInteractiveClientProvider {\n  (options: IInteractiveClientProviderOptions): IInteractiveClient;\n}\n\n/**\n * Interface that is used for InteractiveProvider\n */\nexport interface IInteractiveClient {\n  connect(): Promise<void>;\n  handyKey: string;\n  uploadScript: (funscriptPath: string, apiKey?: string) => Promise<void>;\n  sync(): Promise<number>;\n  configure(config: Partial<IDeviceSettings>): Promise<void>;\n  play(position: number): Promise<void>;\n  pause(): Promise<void>;\n  ensurePlaying(position: number): Promise<void>;\n  setLooping(looping: boolean): Promise<void>;\n  readonly connected: boolean;\n  readonly playing: boolean;\n}\n\n```\n##### Example\nFor instance say I wanted to add extra logging when `IInteractiveClient.connect()` is called.\nIn my plugin you would install your own client provider as seen below\n\n```ts\nInteractiveUtils.interactiveClientProvider = (\n  opts\n) => {\n  if (!opts.defaultClientProvider) {\n    throw new Error('invalid setup');\n  }\n\n  const client = opts.defaultClientProvider(opts);\n  const connect = client.connect;\n  client.connect = async () => {\n      console.log('patching connect method');\n      return connect.call(client);\n    };\n   \n  return client;\n};\n\n```\n\n\n### `hooks`\n\nThis namespace provides access to the following core utility hooks:\n\n- `useGalleryLightbox`\n- `useLightbox`\n- `useSpriteInfo`\n- `useToast`\n\nIt also provides plugin-specific hooks.\n\n#### `PluginApi.hooks.useLoadComponents`\n\nThis is a hook used to load components, using the `PluginApi.utils.loadComponents` method.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `components` | `Promise[]` | The list of components to load. These values should come from the `PluginApi.loadableComponents` namespace. |\n\nReturns a `boolean` which will be `true` if the components are loading.\n\n### `loadableComponents`\n\nThis namespace contains all of the components that may need to be loaded using the `loadComponents` method. Components are added to this namespace as needed. Please make a development request if a required component is not in this namespace.\n\nThis component also includes coarse-grained entries for every lazily loaded import in the stock UI. If a component is not available in `components` when the page loads, it can be loaded using the coarse-grained entry. For example, `PerformerCard` can be loaded using `loadableComponents.Performers`.\n\n### `patch`\n\nThis namespace provides methods to patch components to change their behaviour.\n\n#### `PluginApi.patch.before`\n\nRegisters a before function. A before function is called prior to calling a component's render function. It accepts the same parameters as the component's render function, and is expected to return a list of new arguments that will be passed to the render.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `component` | `string` | The name of the component to patch. |\n| `fn` | `Function` | The before function. It accepts the same arguments as the component render function and is expected to return a list of arguments to pass to the render function. |\n\nReturns `void`.\n\n#### `PluginApi.patch.instead`\n\nRegisters a replacement function for a component. The provided function will be called with the arguments passed to the original render function, plus the next render function as the last argument. Replacement functions will be called in the order that they are registered. If a replacement function does not call the next render function then the following replacement functions will not be called or applied.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `component` | `string` | The name of the component to patch. |\n| `fn` | `Function` | The replacement function. It accepts the same arguments as the original render function, plus the next render function, and is expected to return the replacement component. |\n\nReturns `void`.\n\n#### `PluginApi.patch.after`\n\nRegisters an after function. An after function is called after the render function of the component. It accepts the arguments passed to the original render function, plus the result of the original render function. It is expected to return the rendered component.\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `component` | `string` | The name of the component to patch. |\n| `fn` | `Function` | The after function. It accepts the same arguments as the original render function, plus the result of the original render function, and is expected to return the rendered component. |\n\nReturns `void`.\n\n#### Patchable components and functions\n\n- `AlertModal`\n- `App`\n- `BackgroundImage`\n- `BooleanSetting`\n- `ChangeButtonSetting`\n- `CompressedPerformerDetailsPanel`\n- `ConstantSetting`\n- `CountrySelect`\n- `CustomFieldInput`\n- `CustomFields`\n- `CustomFieldsInput`\n- `DateInput`\n- `DetailImage`\n- `ExternalLinkButtons`\n- `ExternalLinksButton`\n- `FilteredGalleryList`\n- `FilteredGroupList`\n- `FilteredImageList`\n- `FilteredPerformerList`\n- `FilteredSceneList`\n- `FilteredSceneMarkerList`\n- `FilteredStudioList`\n- `FilteredTagList`\n- `FolderSelect`\n- `FrontPage`\n- `GalleryCard`\n- `GalleryCard.Details`\n- `GalleryCard.Image`\n- `GalleryCard.Overlays`\n- `GalleryCard.Popovers`\n- `GalleryCardGrid`\n- `GalleryIDSelect`\n- `GalleryList`\n- `GalleryRecommendationRow`\n- `GallerySelect`\n- `GallerySelect.sort`\n- `GridCard`\n- `GroupCard`\n- `GroupCardGrid`\n- `GroupIDSelect`\n- `GroupList`\n- `GroupRecommendationRow`\n- `GroupSelect`\n- `GroupSelect.sort`\n- `HeaderImage`\n- `HoverPopover`\n- `Icon`\n- `ImageCard`\n- `ImageCard.Details`\n- `ImageCard.Image`\n- `ImageCard.Overlays`\n- `ImageCard.Popovers`\n- `ImageDetailPanel`\n- `ImageGridCard`\n- `ImageInput`\n- `ImageList`\n- `ImageRecommendationRow`\n- `LightboxLink`\n- `LoadingIndicator`\n- `MainNavBar.MenuItems`\n- `MainNavBar.UtilityItems`\n- `ModalSetting`\n- `NumberSetting`\n- `Pagination`\n- `PaginationIndex`\n- `PerformerAppearsWithPanel`\n- `PerformerCard`\n- `PerformerCard.Details`\n- `PerformerCard.Image`\n- `PerformerCard.Overlays`\n- `PerformerCard.Popovers`\n- `PerformerCard.Title`\n- `PerformerCardGrid`\n- `PerformerDetailsPanel`\n- `PerformerDetailsPanel.DetailGroup`\n- `PerformerGalleriesPanel`\n- `PerformerGroupsPanel`\n- `PerformerHeaderImage`\n- `PerformerIDSelect`\n- `PerformerImagesPanel`\n- `PerformerList`\n- `PerformerPage`\n- `PerformerRecommendationRow`\n- `PerformerScenesPanel`\n- `PerformerSelect`\n- `PerformerSelect.sort`\n- `PluginRoutes`\n- `PluginSettings`\n- `RatingNumber`\n- `RatingStars`\n- `RatingSystem`\n- `RecommendationRow`\n- `SceneCard`\n- `SceneCard.Details`\n- `SceneCard.Image`\n- `SceneCard.Overlays`\n- `SceneCard.Popovers`\n- `SceneCard.SceneSpecs`\n- `SceneCardsGrid`\n- `SceneFileInfoPanel`\n- `SceneIDSelect`\n- `SceneMarkerCard`\n- `SceneMarkerCard.Details`\n- `SceneMarkerCard.Image`\n- `SceneMarkerCard.Popovers`\n- `SceneMarkerCardsGrid`\n- `SceneMarkerList`\n- `SceneMarkerRecommendationRow`\n- `SceneList`\n- `ScenePage`\n- `ScenePage.TabContent`\n- `ScenePage.Tabs`\n- `ScenePlayer`\n- `SceneRecommendationRow`\n- `SceneSelect`\n- `SceneSelect.sort`\n- `SelectSetting`\n- `Setting`\n- `SettingGroup`\n- `SettingModal`\n- `StringListSetting`\n- `StringSetting`\n- `StudioCard`\n- `StudioCardGrid`\n- `StudioDetailsPanel`\n- `StudioIDSelect`\n- `StudioList`\n- `StudioRecommendationRow`\n- `StudioSelect`\n- `StudioSelect.sort`\n- `SweatDrops`\n- `TabTitleCounter`\n- `TagCard`\n- `TagCard.Details`\n- `TagCard.Image`\n- `TagCard.Overlays`\n- `TagCard.Popovers`\n- `TagCard.Title`\n- `TagCardGrid`\n- `TagIDSelect`\n- `TagLink`\n- `TagList`\n- `TagRecommendationRow`\n- `TagSelect`\n- `TagSelect.sort`\n- `TruncatedText`\n\n### `PluginApi.Event`\n\nAllows plugins to listen for Stash's events.\n\n```js\nPluginApi.Event.addEventListener(\"stash:location\", (e) => console.log(\"Page Changed\", e.detail.data.location.pathname))\n```"
  },
  {
    "path": "ui/v2.5/src/docs/en/MigrationNotes/32.md",
    "content": "**For best results, ensure that zip-based gallery paths are correct by performing a scan and clean of your library using v0.16.1 prior to running this migration.**\n\nThis migration significantly changes the way that stash stores information about your files. This migration is not reversible.\n\nTo prevent timeout errors, please run this migration with a direct connection and not via reverse proxy.\n\nThe migration can take a long time on larger systems. Please view the log file for progress updates. \n\nAfter migrating, please run a scan on your entire library to populate missing data, and to ingest identical files which were previously ignored.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/MigrationNotes/39.md",
    "content": "This migration changes performer height values from strings to numbers. The migration converts the _first number in the string_ to an integer value. Height values that cannot be converted this way **will be erased during this migration**."
  },
  {
    "path": "ui/v2.5/src/docs/en/MigrationNotes/48.md",
    "content": "This migration removes the unused `scraped_items` table from the database, which was only used in very old versions of Stash. For the vast majority of users, it should be empty, but if not, the migration will fail and restore the old database. If this happens, please manually edit the database and remove the table yourself, after making a copy of any contained data you'd like to keep. If you are not confident on how to do this, feel free to ask for assistance on the Discord server.\n\nThis migration also enforces studio name uniqueness at the database level. Although no longer possible in recent versions, older versions of Stash allowed for different studios to have identical names. If your database has such duplicate names, the duplicates will have `\" (1)\"`, `\" (2)\"`, etc. appended to their names after this migration.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/MigrationNotes/58.md",
    "content": "This migration corrects the plugin and UI settings in `config.yml` to use `camelCase` instead of `snake_case`. As a result, the migrated file will not be compatible with previous versions of stash. A backup of the current `config.yml` will be created in the same directory with the name `config.yml.57.<date and time>`. The exact filename is written to the log."
  },
  {
    "path": "ui/v2.5/src/docs/en/MigrationNotes/60.md",
    "content": "This migration moves default filters from the database into the configuration file. A backup of the current `config.yml` will be created in the same directory with the name `config.yml.59.<date and time>`. The exact filename is written to the log."
  },
  {
    "path": "ui/v2.5/src/docs/en/MigrationNotes/index.ts",
    "content": "import migration32 from \"./32.md\";\nimport migration39 from \"./39.md\";\nimport migration48 from \"./48.md\";\nimport migration58 from \"./58.md\";\nimport migration60 from \"./60.md\";\n\nexport const migrationNotes: Record<number, string> = {\n  32: migration32,\n  39: migration39,\n  48: migration48,\n  58: migration58,\n  60: migration60,\n};\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/ReleaseNotes/index.ts",
    "content": "import v0170 from \"./v0170.md\";\nimport v0200 from \"./v0200.md\";\nimport v0240 from \"./v0240.md\";\nimport v0250 from \"./v0250.md\";\nimport v0260 from \"./v0260.md\";\nimport v0270 from \"./v0270.md\";\nimport v0290 from \"./v0290.md\";\n\nexport interface IReleaseNotes {\n  // handle should be in the form of YYYYMMDD\n  date: number;\n  version: string;\n  content: string;\n}\n\nexport const releaseNotes: IReleaseNotes[] = [\n  {\n    date: 20251026,\n    version: \"v0.29.0\",\n    content: v0290,\n  },\n  {\n    date: 20240826,\n    version: \"v0.27.0\",\n    content: v0270,\n  },\n  {\n    date: 20240510,\n    version: \"v0.26.0\",\n    content: v0260,\n  },\n  {\n    date: 20240228,\n    version: \"v0.25.0\",\n    content: v0250,\n  },\n  {\n    date: 20231212,\n    version: \"v0.24.0\",\n    content: v0240,\n  },\n  {\n    date: 20230301,\n    version: \"v0.20.0\",\n    content: v0200,\n  },\n  {\n    date: 20220906,\n    version: \"v0.17.0\",\n    content: v0170,\n  },\n];\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/ReleaseNotes/v0170.md",
    "content": "After migrating, please run a scan on your entire library to populate missing data, and to ingest identical files which were previously ignored.\n\n##### Other changes:\n* Import/export schema has changed and is incompatible with the previous version.\n* Changelog has been moved from the stats page to a section in the Settings page.\n* Object titles are now displayed as the file basename if the title is not explicitly set. The `Don't include file extension as part of the title` scan flag is no longer supported.\n* `Set name, date, details from embedded file metadata` scan flag is no longer supported. This functionality may be implemented as a built-in scraper in the future.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/ReleaseNotes/v0200.md",
    "content": "The image data subsystem has been reworked in this release. Existing systems will have their storage system set to `Database`, which stores all image data in the database. This can be changed in the System Settings page. **Note:** a migration is required to change the storage system, and can be accessed from the Tasks page.\n\nThe `Database` storage system is not recommended for large libraries, as it can cause performance issues. The `Filesystem` storage system is recommended for large libraries, and is the default for new systems.\n\n**Important:** the `generated/screenshots` jpg files are considered legacy. These files can be migrated into the blob storage system by running the `Migrate Screenshots` task from the Tasks page. Once migrated, these files can be deleted. The files can be optionally deleted during the migration.\n\nThe cache directory is now required if using HLS/DASH streaming. Please set the cache directory in the System Settings page.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/ReleaseNotes/v0240.md",
    "content": "This release introduces scraper and plugin management interfaces. This allows installing, updating and removing scrapers and plugins from the WebUI. \n\nDefault package sources have been automatically configured to point at the _stable_ branches of the `CommunityScrapers` and `CommunityScripts` repositories. These branches will correspond to the current stable version of stash. \n\n**Note:** existing scrapers and plugins will _not_ be able to be managed using the management interface. It is recommended that any existing scrapers and plugins that are available from the community repositories are backed up and removed from the applicable `scrapers` or `plugins` directory, and reinstalled using the management UI.\n"
  },
  {
    "path": "ui/v2.5/src/docs/en/ReleaseNotes/v0250.md",
    "content": "A number of settings and tasks are now only available when `Advanced Mode` is set to true in the settings, including the `Auto Tag` and `Identify` tasks."
  },
  {
    "path": "ui/v2.5/src/docs/en/ReleaseNotes/v0260.md",
    "content": "The `Enable Scene Play History` setting has been set to true for existing systems. This setting enables play counts and resuming scenes from where they were previously played. If you do not want this enabled, please disable it explicitly in `Settings -> Interface -> Scene Player -> Enable Scene Play History`."
  },
  {
    "path": "ui/v2.5/src/docs/en/ReleaseNotes/v0270.md",
    "content": "Tagger settings have been reset, but are now persisted between browser sessions. `Show male performers` and `Set Tags` are now defaulted to true. Please verify your settings before using the Tagger."
  },
  {
    "path": "ui/v2.5/src/docs/en/ReleaseNotes/v0290.md",
    "content": "The Scenes page and related scene list views have been updated with a filter sidebar and a toolbar for filtering and other actions. This design is intended to be applied to other query pages in the following release. The design will be refined based on user feedback. \n\nYou can help steer the direction of this design by providing feedback in the [forum thread](https://discourse.stashapp.cc/t/query-page-redesign-feedback-thread-0-29/3935).\n\nOld userscripts and plugins that intercept GraphQL with content-type `application/json` will stop working, as gqlenc uses the updated content-type `application/graphql-response+json`"
  },
  {
    "path": "ui/v2.5/src/globals.d.ts",
    "content": "declare module \"intersection-observer\";\n\ndeclare module \"*.md\" {\n  const src: string;\n  export default src;\n}\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\ninterface ImportMetaEnv {\n  readonly VITE_APP_GITHASH?: string;\n  readonly VITE_APP_STASH_VERSION?: string;\n  readonly VITE_APP_DATE?: string;\n  readonly VITE_APP_PLATFORM_URL?: string;\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Config.tsx",
    "content": "import React from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\n\nexport interface IContext {\n  configuration: GQL.ConfigDataFragment;\n}\n\nexport const ConfigurationContext = React.createContext<IContext | null>(null);\n\nexport const useConfigurationContext = () => {\n  const context = React.useContext(ConfigurationContext);\n\n  if (context === null) {\n    throw new Error(\n      \"useConfigurationContext must be used within a ConfigurationProvider\"\n    );\n  }\n\n  return context;\n};\n\nexport const useConfigurationContextOptional = () => {\n  return React.useContext(ConfigurationContext);\n};\n\nexport const ConfigurationProvider: React.FC<IContext> = ({\n  configuration,\n  children,\n}) => {\n  return (\n    <ConfigurationContext.Provider\n      value={{\n        configuration,\n      }}\n    >\n      {children}\n    </ConfigurationContext.Provider>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Interactive/context.tsx",
    "content": "import React, { useCallback, useContext, useEffect, useState } from \"react\";\nimport { useConfigurationContext } from \"../Config\";\nimport { useLocalForage } from \"../LocalForage\";\nimport { Interactive as InteractiveAPI } from \"./interactive\";\nimport InteractiveUtils, {\n  IInteractiveClient,\n  IInteractiveClientProvider,\n} from \"./utils\";\n\nexport enum ConnectionState {\n  Missing,\n  Disconnected,\n  Error,\n  Connecting,\n  Syncing,\n  Uploading,\n  Ready,\n}\n\nexport function connectionStateLabel(s: ConnectionState) {\n  const prefix = \"handy_connection_status\";\n  switch (s) {\n    case ConnectionState.Missing:\n      return `${prefix}.missing`;\n    case ConnectionState.Connecting:\n      return `${prefix}.connecting`;\n    case ConnectionState.Disconnected:\n      return `${prefix}.disconnected`;\n    case ConnectionState.Error:\n      return `${prefix}.error`;\n    case ConnectionState.Syncing:\n      return `${prefix}.syncing`;\n    case ConnectionState.Uploading:\n      return `${prefix}.uploading`;\n    case ConnectionState.Ready:\n      return `${prefix}.ready`;\n  }\n}\n\nexport interface IState {\n  interactive: IInteractiveClient;\n  state: ConnectionState;\n  serverOffset: number;\n  initialised: boolean;\n  currentScript?: string;\n  error?: string;\n  initialise: () => Promise<void>;\n  uploadScript: (funscriptPath: string) => Promise<void>;\n  sync: () => Promise<void>;\n}\n\nexport const InteractiveContext = React.createContext<IState>({\n  interactive: new InteractiveAPI(\"\", 0),\n  state: ConnectionState.Missing,\n  serverOffset: 0,\n  initialised: false,\n  initialise: () => {\n    return Promise.resolve();\n  },\n  uploadScript: () => {\n    return Promise.resolve();\n  },\n  sync: () => {\n    return Promise.resolve();\n  },\n});\n\nconst LOCAL_FORAGE_KEY = \"interactive\";\nconst TIME_BETWEEN_SYNCS = 60 * 60 * 1000; // 1 hour\n\ninterface IInteractiveState {\n  serverOffset: number;\n  lastSyncTime: number;\n}\n\nexport const defaultInteractiveClientProvider: IInteractiveClientProvider = ({\n  handyKey,\n  scriptOffset,\n}): IInteractiveClient => {\n  return new InteractiveAPI(handyKey, scriptOffset);\n};\n\nexport const InteractiveProvider: React.FC = ({ children }) => {\n  const [{ data: config }, setConfig] = useLocalForage<IInteractiveState>(\n    LOCAL_FORAGE_KEY,\n    { serverOffset: 0, lastSyncTime: 0 }\n  );\n\n  const { configuration: stashConfig } = useConfigurationContext();\n\n  const [state, setState] = useState<ConnectionState>(ConnectionState.Missing);\n  const [handyKey, setHandyKey] = useState<string | undefined>(undefined);\n  const [currentScript, setCurrentScript] = useState<string | undefined>(\n    undefined\n  );\n  const [scriptOffset, setScriptOffset] = useState<number>(0);\n  const [useStashHostedFunscript, setUseStashHostedFunscript] =\n    useState<boolean>(false);\n\n  const resolveInteractiveClient = useCallback(() => {\n    const interactiveClientProvider =\n      InteractiveUtils.interactiveClientProvider ??\n      defaultInteractiveClientProvider;\n\n    return interactiveClientProvider({\n      handyKey: \"\",\n      scriptOffset: 0,\n      defaultClientProvider: defaultInteractiveClientProvider,\n      stashConfig,\n    });\n  }, [stashConfig]);\n\n  // fetch client provider from PluginApi if not found use default provider\n  const [interactive] = useState(resolveInteractiveClient);\n\n  const [initialised, setInitialised] = useState(false);\n  const [error, setError] = useState<string | undefined>();\n\n  const initialise = useCallback(async () => {\n    setError(undefined);\n\n    const shouldResync =\n      !config?.lastSyncTime ||\n      Date.now() - config?.lastSyncTime > TIME_BETWEEN_SYNCS;\n\n    if (!config?.serverOffset || shouldResync) {\n      setState(ConnectionState.Syncing);\n      const offset = await interactive.sync();\n      setConfig({ serverOffset: offset, lastSyncTime: Date.now() });\n    }\n\n    if (config?.serverOffset) {\n      await interactive.configure({\n        estimatedServerTimeOffset: config.serverOffset,\n      });\n      setState(ConnectionState.Connecting);\n      try {\n        await interactive.connect();\n        setState(ConnectionState.Ready);\n        setInitialised(true);\n      } catch (e) {\n        if (e instanceof Error) {\n          setError(e.message ?? e.toString());\n          setState(ConnectionState.Error);\n        }\n      }\n    }\n  }, [config, interactive, setConfig]);\n\n  useEffect(() => {\n    if (!stashConfig) {\n      return;\n    }\n\n    setHandyKey(stashConfig.interface.handyKey ?? undefined);\n    setScriptOffset(stashConfig.interface.funscriptOffset ?? 0);\n    setUseStashHostedFunscript(\n      stashConfig.interface.useStashHostedFunscript ?? false\n    );\n  }, [stashConfig]);\n\n  useEffect(() => {\n    if (!config) {\n      return;\n    }\n\n    const oldKey = interactive.handyKey;\n\n    interactive\n      .configure({\n        connectionKey: handyKey ?? \"\",\n        offset: scriptOffset,\n        useStashHostedFunscript,\n      })\n      .then(() => {\n        if (oldKey !== interactive.handyKey && interactive.handyKey) {\n          initialise();\n        }\n      });\n  }, [\n    handyKey,\n    scriptOffset,\n    useStashHostedFunscript,\n    config,\n    interactive,\n    initialise,\n  ]);\n\n  const sync = useCallback(async () => {\n    if (\n      !interactive.handyKey ||\n      state === ConnectionState.Syncing ||\n      !initialised\n    ) {\n      return;\n    }\n\n    setState(ConnectionState.Syncing);\n    const offset = await interactive.sync();\n    setConfig({ serverOffset: offset, lastSyncTime: Date.now() });\n    setState(ConnectionState.Ready);\n  }, [interactive, state, setConfig, initialised]);\n\n  const uploadScript = useCallback(\n    async (funscriptPath: string) => {\n      await interactive.pause();\n      if (\n        !interactive.handyKey ||\n        !funscriptPath ||\n        funscriptPath === currentScript\n      ) {\n        return Promise.resolve();\n      }\n\n      setState(ConnectionState.Uploading);\n      try {\n        await interactive.uploadScript(\n          funscriptPath,\n          stashConfig?.general?.apiKey\n        );\n        setCurrentScript(funscriptPath);\n        setState(ConnectionState.Ready);\n      } catch (e) {\n        setState(ConnectionState.Error);\n      }\n    },\n    [interactive, currentScript, stashConfig]\n  );\n\n  return (\n    <InteractiveContext.Provider\n      value={{\n        interactive,\n        state,\n        error,\n        currentScript,\n        serverOffset: config?.serverOffset ?? 0,\n        initialised,\n        initialise,\n        uploadScript,\n        sync,\n      }}\n    >\n      {children}\n    </InteractiveContext.Provider>\n  );\n};\n\nexport const useInteractive = () => {\n  return useContext(InteractiveContext);\n};\nexport default InteractiveProvider;\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Interactive/interactive.scss",
    "content": "div.scene-interactive-status {\n  opacity: 0.75;\n  padding: 0.75rem;\n  position: absolute;\n\n  &.interactive-status-disconnected,\n  &.interactive-status-error svg {\n    color: $danger;\n  }\n\n  &.interactive-status-connecting svg,\n  &.interactive-status-syncing svg,\n  &.interactive-status-uploading svg {\n    animation: 1s ease 0s infinite alternate fadepulse;\n    color: $warning;\n  }\n\n  &.interactive-status-ready svg {\n    color: $success;\n  }\n\n  .status-text {\n    margin-left: 0.5rem;\n  }\n}\n\n@keyframes fadepulse {\n  0% {\n    opacity: 0.4;\n  }\n\n  100% {\n    opacity: 1;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Interactive/interactive.ts",
    "content": "import Handy from \"thehandy\";\nimport {\n  HandyMode,\n  HsspSetupResult,\n  CsvUploadResponse,\n  HandyFirmwareStatus,\n} from \"thehandy/lib/types\";\nimport { IDeviceSettings } from \"./utils\";\n\ninterface IFunscript {\n  actions: Array<IAction>;\n  inverted: boolean;\n  range: number;\n}\n\ninterface IAction {\n  at: number;\n  pos: number;\n}\n\n// Utility function to convert one range of values to another\nfunction convertRange(\n  value: number,\n  fromLow: number,\n  fromHigh: number,\n  toLow: number,\n  toHigh: number\n) {\n  return ((value - fromLow) * (toHigh - toLow)) / (fromHigh - fromLow) + toLow;\n}\n\n// Converting to CSV first instead of uploading Funscripts is required\n// Reference for Funscript format:\n// https://pkg.go.dev/github.com/funjack/launchcontrol/protocol/funscript\nfunction convertFunscriptToCSV(funscript: IFunscript) {\n  const lineTerminator = \"\\r\\n\";\n  if (funscript?.actions?.length > 0) {\n    return funscript.actions.reduce((prev: string, curr: IAction) => {\n      var { pos } = curr;\n      // If it's inverted in the Funscript, we flip it because\n      // the Handy doesn't have inverted support\n      if (funscript.inverted === true) {\n        pos = convertRange(curr.pos, 0, 100, 100, 0);\n      }\n      // in APIv2; the Handy maintains it's own slide range\n      // (ref: https://staging.handyfeeling.com/api/handy/v2/docs/#/SLIDE )\n      // so if a range is specified in the Funscript, we convert it to the\n      // full range and let the Handy's settings take precedence\n      if (funscript.range) {\n        pos = convertRange(curr.pos, 0, funscript.range, 0, 100);\n      }\n      return `${prev}${curr.at},${pos}${lineTerminator}`;\n    }, `#Created by stash.app ${new Date().toUTCString()}\\n`);\n  }\n  throw new Error(\"Not a valid funscript\");\n}\n\n// copied from https://github.com/defucilis/thehandy/blob/main/src/HandyUtils.ts\n// since HandyUtils is not exported.\n// License is listed as MIT. No copyright notice is provided in original.\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// 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 FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n// THE SOFTWARE.\nasync function uploadCsv(\n  csv: File,\n  filename?: string\n): Promise<CsvUploadResponse> {\n  const url = \"https://www.handyfeeling.com/api/sync/upload?local=true\";\n  if (!filename) filename = \"script_\" + new Date().valueOf() + \".csv\";\n  const formData = new FormData();\n  formData.append(\"syncFile\", csv, filename);\n  const response = await fetch(url, {\n    method: \"post\",\n    body: formData,\n  });\n  const newUrl = await response.json();\n  return newUrl;\n}\n\n// Interactive currently uses the Handy API, but could be expanded to use buttplug.io\n// via buttplugio/buttplug-rs-ffi's WASM module.\nexport class Interactive {\n  _connected: boolean;\n  _playing: boolean;\n  _scriptOffset: number;\n  _handy: Handy;\n  _useStashHostedFunscript: boolean;\n\n  constructor(handyKey: string, scriptOffset: number) {\n    this._handy = new Handy();\n    this._handy.connectionKey = handyKey;\n    this._scriptOffset = scriptOffset;\n    this._useStashHostedFunscript = false;\n    this._connected = false;\n    this._playing = false;\n  }\n\n  get connected() {\n    return this._connected;\n  }\n  get playing() {\n    return this._playing;\n  }\n\n  async connect() {\n    const connected = await this._handy.getConnected();\n    if (!connected) {\n      throw new Error(\"Handy not connected\");\n    }\n\n    // check the firmware and make sure it's compatible\n    const info = await this._handy.getInfo();\n    if (info.fwStatus === HandyFirmwareStatus.updateRequired) {\n      throw new Error(\"Handy firmware update required\");\n    }\n  }\n\n  set handyKey(key: string) {\n    this._handy.connectionKey = key;\n  }\n\n  get handyKey(): string {\n    return this._handy.connectionKey;\n  }\n\n  set useStashHostedFunscript(useStashHostedFunscript: boolean) {\n    this._useStashHostedFunscript = useStashHostedFunscript;\n  }\n\n  get useStashHostedFunscript(): boolean {\n    return this._useStashHostedFunscript;\n  }\n\n  set scriptOffset(offset: number) {\n    this._scriptOffset = offset;\n  }\n\n  async uploadScript(funscriptPath: string, apiKey?: string) {\n    if (!(this._handy.connectionKey && funscriptPath)) {\n      return;\n    }\n\n    var funscriptUrl;\n\n    if (this._useStashHostedFunscript) {\n      funscriptUrl = funscriptPath.replace(\"/funscript\", \"/interactive_csv\");\n      if (typeof apiKey !== \"undefined\" && apiKey !== \"\") {\n        var url = new URL(funscriptUrl);\n        url.searchParams.append(\"apikey\", apiKey);\n        funscriptUrl = url.toString();\n      }\n    } else {\n      const csv = await fetch(funscriptPath)\n        .then((response) => response.json())\n        .then((json) => convertFunscriptToCSV(json));\n      const fileName = `${Math.round(Math.random() * 100000000)}.csv`;\n      const csvFile = new File([csv], fileName);\n\n      funscriptUrl = await uploadCsv(csvFile).then((response) => response.url);\n    }\n\n    await this._handy.setMode(HandyMode.hssp);\n\n    this._connected = await this._handy\n      .setHsspSetup(funscriptUrl)\n      .then((result) => result === HsspSetupResult.downloaded);\n\n    // for some reason we need to call getStatus after setup to ensure proper state\n    // see https://github.com/defucilis/thehandy/issues/3\n    await this._handy.getStatus();\n  }\n\n  async sync() {\n    return this._handy.getServerTimeOffset();\n  }\n\n  setServerTimeOffset(offset: number) {\n    this._handy.estimatedServerTimeOffset = offset;\n  }\n\n  async configure(config: Partial<IDeviceSettings>) {\n    this._scriptOffset = config.scriptOffset ?? this._scriptOffset;\n    this.handyKey = config.connectionKey ?? this.handyKey;\n    this._handy.estimatedServerTimeOffset =\n      config.estimatedServerTimeOffset ?? this._handy.estimatedServerTimeOffset;\n    this.useStashHostedFunscript =\n      config.useStashHostedFunscript ?? this.useStashHostedFunscript;\n  }\n\n  async play(position: number) {\n    if (!this._connected) {\n      return;\n    }\n\n    this._playing = await this._handy\n      .setHsspPlay(\n        Math.round(position * 1000 + this._scriptOffset),\n        this._handy.estimatedServerTimeOffset + Date.now() // our guess of the Handy server's UNIX epoch time\n      )\n      .then(() => true);\n  }\n\n  async pause() {\n    if (!this._connected) {\n      return;\n    }\n    this._playing = await this._handy.setHsspStop().then(() => false);\n  }\n\n  async ensurePlaying(position: number) {\n    if (this._playing) {\n      return;\n    }\n    await this.play(position);\n  }\n\n  async setLooping(looping: boolean) {\n    if (!this._connected) {\n      return;\n    }\n    this._handy.setHsspLoop(looping);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Interactive/status.tsx",
    "content": "import { faCircle } from \"@fortawesome/free-solid-svg-icons\";\nimport { FontAwesomeIcon } from \"@fortawesome/react-fontawesome\";\nimport React from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport {\n  ConnectionState,\n  connectionStateLabel,\n  InteractiveContext,\n} from \"./context\";\n\nexport const SceneInteractiveStatus: React.FC = ({}) => {\n  const { state, error } = React.useContext(InteractiveContext);\n\n  function getStateClass() {\n    switch (state) {\n      case ConnectionState.Connecting:\n        return \"interactive-status-connecting\";\n      case ConnectionState.Disconnected:\n        return \"interactive-status-disconnected\";\n      case ConnectionState.Error:\n        return \"interactive-status-error\";\n      case ConnectionState.Syncing:\n        return \"interactive-status-uploading\";\n      case ConnectionState.Uploading:\n        return \"interactive-status-syncing\";\n      case ConnectionState.Ready:\n        return \"interactive-status-ready\";\n    }\n\n    return \"\";\n  }\n\n  if (state === ConnectionState.Missing) {\n    return <></>;\n  }\n\n  return (\n    <div className={`scene-interactive-status ${getStateClass()}`}>\n      <FontAwesomeIcon pulse icon={faCircle} size=\"xs\" />\n      <span className=\"status-text\">\n        <FormattedMessage id={connectionStateLabel(state)} />\n        {error && <span>: {error}</span>}\n      </span>\n    </div>\n  );\n};\n\nexport default SceneInteractiveStatus;\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Interactive/utils.ts",
    "content": "import { getPlayer } from \"src/components/ScenePlayer/util\";\nimport type { VideoJsPlayer } from \"video.js\";\nimport * as GQL from \"src/core/generated-graphql\";\n\nexport interface IDeviceSettings {\n  connectionKey: string;\n  scriptOffset: number;\n  estimatedServerTimeOffset?: number;\n  useStashHostedFunscript?: boolean;\n  [key: string]: unknown;\n}\n\nexport interface IInteractiveClientProviderOptions {\n  handyKey: string;\n  scriptOffset: number;\n  defaultClientProvider?: IInteractiveClientProvider;\n  stashConfig?: GQL.ConfigDataFragment;\n}\nexport interface IInteractiveClientProvider {\n  (options: IInteractiveClientProviderOptions): IInteractiveClient;\n}\n\n/**\n * Interface that is used for InteractiveProvider\n */\nexport interface IInteractiveClient {\n  connect(): Promise<void>;\n  handyKey: string;\n  uploadScript: (funscriptPath: string, apiKey?: string) => Promise<void>;\n  sync(): Promise<number>;\n  configure(config: Partial<IDeviceSettings>): Promise<void>;\n  play(position: number): Promise<void>;\n  pause(): Promise<void>;\n  ensurePlaying(position: number): Promise<void>;\n  setLooping(looping: boolean): Promise<void>;\n  readonly connected: boolean;\n  readonly playing: boolean;\n}\n\nexport interface IInteractiveUtils {\n  getPlayer: () => VideoJsPlayer | undefined;\n  interactiveClientProvider: IInteractiveClientProvider | undefined;\n}\nconst InteractiveUtils = {\n  // hook to allow to customize the interactive client\n  interactiveClientProvider: undefined,\n  // returns the active player\n  getPlayer,\n};\n\nexport default InteractiveUtils;\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Interval.ts",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nconst MIN_VALID_INTERVAL = 1000;\n\nfunction noop() {}\n\nconst useInterval = (\n  callback: () => void,\n  delay: number | null = 5000\n): (() => void)[] => {\n  const savedCallback = useRef<() => void>();\n  const savedIntervalId = useRef<number>();\n  const [savedDelay, setSavedDelay] = useState<number | null>(delay);\n\n  useEffect(() => {\n    savedCallback.current = callback;\n  }, [callback]);\n\n  useEffect(() => {\n    let validDelay;\n    if (delay !== null) {\n      validDelay = delay >= MIN_VALID_INTERVAL ? delay : MIN_VALID_INTERVAL;\n    } else {\n      validDelay = delay;\n    }\n\n    setSavedDelay(validDelay);\n  }, [delay]);\n\n  const cancel = () => {\n    const intervalId = savedIntervalId.current;\n    if (intervalId) {\n      savedIntervalId.current = undefined;\n      clearInterval(intervalId);\n    }\n  };\n\n  const reset = () => {\n    cancel();\n\n    const tick = () => {\n      if (savedCallback.current) savedCallback.current();\n    };\n\n    if (savedDelay !== null) {\n      savedIntervalId.current = setInterval(tick, savedDelay);\n    }\n  };\n\n  useEffect(() => {\n    cancel();\n\n    const tick = () => {\n      if (savedCallback.current) savedCallback.current();\n    };\n\n    if (savedDelay !== null) {\n      savedIntervalId.current = setInterval(tick, savedDelay);\n      return cancel;\n    }\n  }, [callback, savedDelay]);\n\n  return delay ? [cancel, reset] : [noop, noop];\n};\n\nexport default useInterval;\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Lightbox/Lightbox.tsx",
    "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n  Button,\n  Col,\n  InputGroup,\n  Overlay,\n  Popover,\n  Form,\n  Row,\n  Dropdown,\n} from \"react-bootstrap\";\nimport cx from \"classnames\";\nimport Mousetrap from \"mousetrap\";\n\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { LoadingIndicator } from \"src/components/Shared/LoadingIndicator\";\nimport useInterval from \"../Interval\";\nimport usePageVisibility from \"../PageVisibility\";\nimport { useToast } from \"../Toast\";\nimport { FormattedMessage, useIntl } from \"react-intl\";\nimport { LightboxImage } from \"./LightboxImage\";\nimport { useConfigurationContext } from \"../Config\";\nimport { Link } from \"react-router-dom\";\nimport { OCounterButton } from \"src/components/Scenes/SceneDetails/OCounterButton\";\nimport {\n  mutateImageIncrementO,\n  mutateImageDecrementO,\n  mutateImageResetO,\n  useImageUpdate,\n} from \"src/core/StashService\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { useInterfaceLocalForage } from \"../LocalForage\";\nimport { imageLightboxDisplayModeIntlMap } from \"src/core/enums\";\nimport { ILightboxImage, IChapter } from \"./types\";\nimport {\n  faArrowLeft,\n  faArrowRight,\n  faChevronLeft,\n  faChevronRight,\n  faCog,\n  faExpand,\n  faPause,\n  faPlay,\n  faSearchMinus,\n  faTimes,\n  faBars,\n  faImages,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport { useDebounce } from \"../debounce\";\nimport { isVideo } from \"src/utils/visualFile\";\nimport { imageTitle } from \"src/core/files\";\nimport { galleryTitle } from \"src/core/galleries\";\n\nconst CLASSNAME = \"Lightbox\";\nconst CLASSNAME_HEADER = `${CLASSNAME}-header`;\nconst CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`;\nconst CLASSNAME_CHAPTERS = `${CLASSNAME_HEADER}-chapters`;\nconst CLASSNAME_CHAPTER_BUTTON = `${CLASSNAME_HEADER}-chapter-button`;\nconst CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;\nconst CLASSNAME_OPTIONS = `${CLASSNAME_HEADER}-options`;\nconst CLASSNAME_OPTIONS_ICON = `${CLASSNAME_OPTIONS}-icon`;\nconst CLASSNAME_OPTIONS_INLINE = `${CLASSNAME_OPTIONS}-inline`;\nconst CLASSNAME_RIGHT = `${CLASSNAME_HEADER}-right`;\nconst CLASSNAME_FOOTER = `${CLASSNAME}-footer`;\nconst CLASSNAME_FOOTER_LEFT = `${CLASSNAME_FOOTER}-left`;\nconst CLASSNAME_FOOTER_CENTER = `${CLASSNAME_FOOTER}-center`;\nconst CLASSNAME_FOOTER_RIGHT = `${CLASSNAME_FOOTER}-right`;\nconst CLASSNAME_DISPLAY = `${CLASSNAME}-display`;\nconst CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;\nconst CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;\nconst CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;\nconst CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;\nconst CLASSNAME_NAV = `${CLASSNAME}-nav`;\nconst CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;\nconst CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;\n\nconst DEFAULT_SLIDESHOW_DELAY = 5000;\nconst SECONDS_TO_MS = 1000;\nconst MIN_VALID_INTERVAL_SECONDS = 1;\nconst MIN_ZOOM = 0.1;\nconst SCROLL_ZOOM_TIMEOUT = 250;\nconst ZOOM_NONE_EPSILON = 0.015;\n\ninterface IProps {\n  images: ILightboxImage[];\n  isVisible: boolean;\n  isLoading: boolean;\n  initialIndex?: number;\n  showNavigation: boolean;\n  slideshowEnabled?: boolean;\n  page?: number;\n  pages?: number;\n  pageSize?: number;\n  pageCallback?: (props: { direction?: number; page?: number }) => void;\n  chapters?: IChapter[];\n  hide: () => void;\n}\n\nexport const LightboxComponent: React.FC<IProps> = ({\n  images,\n  isVisible,\n  isLoading,\n  initialIndex = 0,\n  showNavigation,\n  slideshowEnabled = false,\n  page,\n  pages,\n  pageSize: pageSize = 40,\n  pageCallback,\n  chapters = [],\n  hide,\n}) => {\n  const [updateImage] = useImageUpdate();\n\n  // zero-based\n  const [index, setIndex] = useState<number | null>(null);\n  const [movingLeft, setMovingLeft] = useState(false);\n  const oldIndex = useRef<number | null>(null);\n  const [instantTransition, setInstantTransition] = useState(false);\n  const [isSwitchingPage, setIsSwitchingPage] = useState(true);\n  const [isFullscreen, setFullscreen] = useState(false);\n  const [showOptions, setShowOptions] = useState(false);\n  const [showChapters, setShowChapters] = useState(false);\n  const [imagesLoaded, setImagesLoaded] = useState(0);\n  const [navOffset, setNavOffset] = useState<React.CSSProperties | undefined>();\n\n  const oldImages = useRef<ILightboxImage[]>([]);\n\n  const [zoom, setZoom] = useState(1);\n\n  function updateZoom(v: number) {\n    if (v < MIN_ZOOM) {\n      setZoom(MIN_ZOOM);\n    } else if (Math.abs(v - 1) < ZOOM_NONE_EPSILON) {\n      // \"snap to 1\" effect: if new zoom is close to 1, set to 1\n      setZoom(1);\n    } else {\n      setZoom(v);\n    }\n  }\n\n  const [resetPosition, setResetPosition] = useState(false);\n\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const overlayTarget = useRef<HTMLButtonElement | null>(null);\n  const carouselRef = useRef<HTMLDivElement | null>(null);\n  const indicatorRef = useRef<HTMLDivElement | null>(null);\n  const navRef = useRef<HTMLDivElement | null>(null);\n  const clearIntervalCallback = useRef<() => void>();\n  const resetIntervalCallback = useRef<() => void>();\n\n  const allowNavigation = images.length > 1 || pageCallback;\n\n  const Toast = useToast();\n  const intl = useIntl();\n  const { configuration: config } = useConfigurationContext();\n  const [interfaceLocalForage, setInterfaceLocalForage] =\n    useInterfaceLocalForage();\n\n  const lightboxSettings = interfaceLocalForage.data?.imageLightbox;\n\n  function setLightboxSettings(v: Partial<GQL.ConfigImageLightboxInput>) {\n    setInterfaceLocalForage((prev) => {\n      return {\n        ...prev,\n        imageLightbox: {\n          ...prev.imageLightbox,\n          ...v,\n        },\n      };\n    });\n  }\n\n  function setScaleUp(value: boolean) {\n    setLightboxSettings({ scaleUp: value });\n  }\n\n  function setResetZoomOnNav(v: boolean) {\n    setLightboxSettings({ resetZoomOnNav: v });\n  }\n\n  function setScrollMode(v: GQL.ImageLightboxScrollMode) {\n    setLightboxSettings({ scrollMode: v });\n  }\n\n  const configuredDelay = config?.interface.imageLightbox.slideshowDelay\n    ? config.interface.imageLightbox.slideshowDelay * SECONDS_TO_MS\n    : undefined;\n\n  const savedDelay = lightboxSettings?.slideshowDelay\n    ? lightboxSettings.slideshowDelay * SECONDS_TO_MS\n    : undefined;\n\n  const slideshowDelay =\n    savedDelay ?? configuredDelay ?? DEFAULT_SLIDESHOW_DELAY;\n\n  const scrollAttemptsBeforeChange = Math.max(\n    0,\n    config?.interface.imageLightbox.scrollAttemptsBeforeChange ?? 0\n  );\n\n  const disableAnimation = config?.interface.imageLightbox.disableAnimation;\n\n  function setSlideshowDelay(v: number) {\n    setLightboxSettings({ slideshowDelay: v });\n  }\n\n  const displayMode =\n    lightboxSettings?.displayMode ?? GQL.ImageLightboxDisplayMode.FitXy;\n  const oldDisplayMode = useRef(displayMode);\n\n  function setDisplayMode(v: GQL.ImageLightboxDisplayMode) {\n    setLightboxSettings({ displayMode: v });\n  }\n\n  // slideshowInterval is used for controlling the logic\n  // displaySlideshowInterval is for display purposes only\n  // keeping them separate and independant allows us to handle the logic however we want\n  // while still displaying something that makes sense to the user\n  const [slideshowInterval, setSlideshowInterval] = useState<number | null>(\n    null\n  );\n\n  const [displayedSlideshowInterval, setDisplayedSlideshowInterval] =\n    useState<string>((slideshowDelay / SECONDS_TO_MS).toString());\n\n  useEffect(() => {\n    if (images !== oldImages.current && isSwitchingPage) {\n      if (index === -1) setIndex(images.length - 1);\n      setIsSwitchingPage(false);\n    }\n  }, [isSwitchingPage, images, index]);\n\n  const disableInstantTransition = useDebounce(\n    () => setInstantTransition(false),\n    400\n  );\n\n  const setInstant = useCallback(() => {\n    setInstantTransition(true);\n    disableInstantTransition();\n  }, [disableInstantTransition]);\n\n  useEffect(() => {\n    if (images.length < 2) return;\n    if (index === oldIndex.current) return;\n    if (index === null) return;\n\n    // reset zoom status\n    // setResetZoom((r) => !r);\n    // setZoomed(false);\n    if (lightboxSettings?.resetZoomOnNav) {\n      setZoom(1);\n    }\n    setResetPosition((r) => !r);\n\n    oldIndex.current = index;\n  }, [index, images.length, lightboxSettings?.resetZoomOnNav]);\n\n  const getNavOffset = useCallback(() => {\n    if (images.length < 2) return;\n    if (index === undefined || index === null) return;\n\n    if (navRef.current) {\n      const currentThumb = navRef.current.children[index + 1];\n      if (currentThumb instanceof HTMLImageElement) {\n        const offset =\n          -1 *\n          (currentThumb.offsetLeft - document.documentElement.clientWidth / 2);\n\n        return { left: `${offset}px` };\n      }\n    }\n  }, [index, images.length]);\n\n  useEffect(() => {\n    // reset images loaded counter for new images\n    setImagesLoaded(0);\n  }, [images]);\n\n  useEffect(() => {\n    setNavOffset(getNavOffset() ?? undefined);\n  }, [getNavOffset]);\n\n  useEffect(() => {\n    if (displayMode !== oldDisplayMode.current) {\n      // reset zoom status\n      // setResetZoom((r) => !r);\n      // setZoomed(false);\n      if (lightboxSettings?.resetZoomOnNav) {\n        setZoom(1);\n      }\n      setResetPosition((r) => !r);\n    }\n    oldDisplayMode.current = displayMode;\n  }, [displayMode, lightboxSettings?.resetZoomOnNav]);\n\n  const selectIndex = (e: React.MouseEvent, i: number) => {\n    setIndex(i);\n    e.stopPropagation();\n  };\n\n  useEffect(() => {\n    if (isVisible) {\n      if (index === null) setIndex(initialIndex);\n      document.body.style.overflow = \"hidden\";\n      Mousetrap.pause();\n    }\n  }, [initialIndex, isVisible, setIndex, index]);\n\n  const toggleSlideshow = useCallback(() => {\n    if (slideshowInterval) {\n      setSlideshowInterval(null);\n    } else {\n      setSlideshowInterval(slideshowDelay);\n    }\n  }, [slideshowInterval, slideshowDelay]);\n\n  // stop slideshow when the page is hidden\n  usePageVisibility((hidden: boolean) => {\n    if (hidden) {\n      setSlideshowInterval(null);\n    }\n  });\n\n  const close = useCallback(() => {\n    if (isFullscreen) document.exitFullscreen();\n\n    hide();\n    document.body.style.overflow = \"auto\";\n    Mousetrap.unpause();\n  }, [isFullscreen, hide]);\n\n  const handleClose = (e: React.MouseEvent<HTMLDivElement>) => {\n    const { className } = e.target as Element;\n    if (className && className.includes && className.includes(CLASSNAME_IMAGE))\n      close();\n  };\n\n  const handleLeft = useCallback(\n    (isUserAction = true) => {\n      if (isSwitchingPage || index === -1) return;\n\n      if (disableAnimation) {\n        setInstant();\n      }\n\n      setShowChapters(false);\n      setMovingLeft(true);\n\n      if (index === 0) {\n        // go to next page, or loop back if no callback is set\n        if (pageCallback) {\n          pageCallback({ direction: -1 });\n          setIndex(-1);\n          oldImages.current = images;\n          setIsSwitchingPage(true);\n        } else setIndex(images.length - 1);\n      } else setIndex((index ?? 0) - 1);\n\n      if (isUserAction && resetIntervalCallback.current) {\n        resetIntervalCallback.current();\n      }\n    },\n    [\n      images,\n      pageCallback,\n      isSwitchingPage,\n      resetIntervalCallback,\n      index,\n      disableAnimation,\n      setInstant,\n    ]\n  );\n\n  const handleRight = useCallback(\n    (isUserAction = true) => {\n      if (isSwitchingPage) return;\n\n      if (disableAnimation) {\n        setInstant();\n      }\n\n      setMovingLeft(false);\n      setShowChapters(false);\n\n      if (index === images.length - 1) {\n        // go to preview page, or loop back if no callback is set\n        if (pageCallback) {\n          pageCallback({ direction: 1 });\n          oldImages.current = images;\n          setIsSwitchingPage(true);\n          setIndex(0);\n        } else setIndex(0);\n      } else setIndex((index ?? 0) + 1);\n\n      if (isUserAction && resetIntervalCallback.current) {\n        resetIntervalCallback.current();\n      }\n    },\n    [\n      images,\n      setIndex,\n      pageCallback,\n      isSwitchingPage,\n      resetIntervalCallback,\n      index,\n      disableAnimation,\n      setInstant,\n    ]\n  );\n\n  const firstScroll = useRef<number | null>(null);\n  const inScrollGroup = useRef(false);\n\n  const debouncedScrollReset = useDebounce(() => {\n    firstScroll.current = null;\n    inScrollGroup.current = false;\n  }, SCROLL_ZOOM_TIMEOUT);\n\n  const handleKey = useCallback(\n    (e: KeyboardEvent) => {\n      if (e.repeat && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\"))\n        setInstant();\n      if (e.key === \"ArrowLeft\") handleLeft();\n      else if (e.key === \"ArrowRight\") handleRight();\n      else if (e.key === \"Escape\") close();\n    },\n    [setInstant, handleLeft, handleRight, close]\n  );\n  const handleFullScreenChange = () => {\n    if (clearIntervalCallback.current) {\n      clearIntervalCallback.current();\n    }\n    setFullscreen(document.fullscreenElement !== null);\n  };\n\n  const [clearCallback, resetCallback] = useInterval(\n    () => {\n      handleRight(false);\n    },\n    slideshowEnabled ? slideshowInterval : null\n  );\n\n  resetIntervalCallback.current = resetCallback;\n  clearIntervalCallback.current = clearCallback;\n\n  useEffect(() => {\n    if (isVisible) {\n      document.addEventListener(\"keydown\", handleKey);\n      document.addEventListener(\"fullscreenchange\", handleFullScreenChange);\n    }\n    return () => {\n      document.removeEventListener(\"keydown\", handleKey);\n      document.removeEventListener(\"fullscreenchange\", handleFullScreenChange);\n    };\n  }, [isVisible, handleKey]);\n\n  const toggleFullscreen = useCallback(() => {\n    if (!isFullscreen) containerRef.current?.requestFullscreen();\n    else document.exitFullscreen();\n  }, [isFullscreen]);\n\n  function imageLoaded() {\n    setImagesLoaded((loaded) => loaded + 1);\n\n    if (imagesLoaded === images.length - 1) {\n      // all images are loaded - update the nav offset\n      setNavOffset(getNavOffset() ?? undefined);\n    }\n  }\n\n  const navItems = images.map((image, i) =>\n    React.createElement(image.paths.preview != \"\" ? \"video\" : \"img\", {\n      loop: image.paths.preview != \"\",\n      autoPlay: image.paths.preview != \"\",\n      playsInline: image.paths.preview != \"\",\n      src:\n        image.paths.preview != \"\"\n          ? image.paths.preview ?? \"\"\n          : image.paths.thumbnail ?? \"\",\n      alt: \"\",\n      className: cx(CLASSNAME_NAVIMAGE, {\n        [CLASSNAME_NAVSELECTED]: i === index,\n      }),\n      onClick: (e: React.MouseEvent) => selectIndex(e, i),\n      role: \"presentation\",\n      loading: \"lazy\",\n      key: image.paths.thumbnail,\n      onLoad: imageLoaded,\n    })\n  );\n\n  const onDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    let numberValue = Number.parseInt(e.currentTarget.value, 10);\n    setDisplayedSlideshowInterval(e.currentTarget.value);\n\n    // Without this exception, the blocking of updates for invalid values is even weirder\n    if (e.currentTarget.value === \"-\" || e.currentTarget.value === \"\") {\n      return;\n    }\n\n    numberValue =\n      numberValue >= MIN_VALID_INTERVAL_SECONDS\n        ? numberValue\n        : MIN_VALID_INTERVAL_SECONDS;\n\n    setSlideshowDelay(numberValue);\n\n    if (slideshowInterval !== null) {\n      setSlideshowInterval(numberValue * SECONDS_TO_MS);\n    }\n  };\n\n  const currentIndex = index === null ? initialIndex : index;\n\n  function gotoPage(imageIndex: number) {\n    const indexInPage = (imageIndex - 1) % pageSize;\n    if (pageCallback) {\n      let jumppage = Math.floor((imageIndex - 1) / pageSize) + 1;\n      if (page !== jumppage) {\n        pageCallback({ page: jumppage });\n        oldImages.current = images;\n        setIsSwitchingPage(true);\n      }\n    }\n\n    setIndex(indexInPage);\n    setShowChapters(false);\n  }\n\n  function chapterHeader() {\n    const imageNumber = (index ?? 0) + 1;\n    const globalIndex = page\n      ? (page - 1) * pageSize + imageNumber\n      : imageNumber;\n\n    let chapterTitle = \"\";\n    chapters.forEach(function (chapter) {\n      if (chapter.image_index > globalIndex) {\n        return;\n      }\n      chapterTitle = chapter.title;\n    });\n\n    return chapterTitle ?? \"\";\n  }\n\n  const renderChapterMenu = () => {\n    if (chapters.length <= 0) return;\n\n    const popoverContent = chapters.map(({ id, title, image_index }) => (\n      <Dropdown.Item key={id} onClick={() => gotoPage(image_index)}>\n        {\" \"}\n        {title}\n        {title.length > 0 ? \" - #\" : \"#\"}\n        {image_index}\n      </Dropdown.Item>\n    ));\n\n    return (\n      <Dropdown\n        show={showChapters}\n        onToggle={() => setShowChapters(!showChapters)}\n      >\n        <Dropdown.Toggle className={`minimal ${CLASSNAME_CHAPTER_BUTTON}`}>\n          <Icon icon={showChapters ? faTimes : faBars} />\n        </Dropdown.Toggle>\n        <Dropdown.Menu className={`${CLASSNAME_CHAPTERS}`}>\n          {popoverContent}\n        </Dropdown.Menu>\n      </Dropdown>\n    );\n  };\n\n  // #2451: making OptionsForm an inline component means it\n  // get re-rendered each time. This makes the text\n  // field lose focus on input. Use function instead.\n  function renderOptionsForm() {\n    return (\n      <>\n        {slideshowEnabled ? (\n          <Form.Group controlId=\"delay\" as={Row} className=\"form-container\">\n            <Col xs={4}>\n              <Form.Label className=\"col-form-label\">\n                <FormattedMessage id=\"dialogs.lightbox.delay\" />\n              </Form.Label>\n            </Col>\n            <Col xs={8}>\n              <Form.Control\n                type=\"number\"\n                className=\"text-input\"\n                min={1}\n                value={displayedSlideshowInterval ?? 0}\n                onChange={onDelayChange}\n                size=\"sm\"\n              />\n            </Col>\n          </Form.Group>\n        ) : undefined}\n\n        <Form.Group controlId=\"displayMode\" as={Row}>\n          <Col xs={4}>\n            <Form.Label className=\"col-form-label\">\n              <FormattedMessage id=\"dialogs.lightbox.display_mode.label\" />\n            </Form.Label>\n          </Col>\n          <Col xs={8}>\n            <Form.Control\n              as=\"select\"\n              onChange={(e) =>\n                setDisplayMode(e.target.value as GQL.ImageLightboxDisplayMode)\n              }\n              value={displayMode}\n              className=\"btn-secondary mx-1 mb-1\"\n            >\n              {Array.from(imageLightboxDisplayModeIntlMap.entries()).map(\n                (v) => (\n                  <option key={v[0]} value={v[0]}>\n                    {intl.formatMessage({\n                      id: v[1],\n                    })}\n                  </option>\n                )\n              )}\n            </Form.Control>\n          </Col>\n        </Form.Group>\n        <Form.Group>\n          <Form.Group controlId=\"scaleUp\" as={Row} className=\"mb-1\">\n            <Col>\n              <Form.Check\n                type=\"checkbox\"\n                label={intl.formatMessage({\n                  id: \"dialogs.lightbox.scale_up.label\",\n                })}\n                checked={lightboxSettings?.scaleUp ?? false}\n                disabled={displayMode === GQL.ImageLightboxDisplayMode.Original}\n                onChange={(v) => setScaleUp(v.currentTarget.checked)}\n              />\n            </Col>\n          </Form.Group>\n          <Form.Text className=\"text-muted\">\n            {intl.formatMessage({\n              id: \"dialogs.lightbox.scale_up.description\",\n            })}\n          </Form.Text>\n        </Form.Group>\n        <Form.Group>\n          <Form.Group controlId=\"resetZoomOnNav\" as={Row} className=\"mb-1\">\n            <Col>\n              <Form.Check\n                type=\"checkbox\"\n                label={intl.formatMessage({\n                  id: \"dialogs.lightbox.reset_zoom_on_nav\",\n                })}\n                checked={lightboxSettings?.resetZoomOnNav ?? false}\n                onChange={(v) => setResetZoomOnNav(v.currentTarget.checked)}\n              />\n            </Col>\n          </Form.Group>\n        </Form.Group>\n        <Form.Group controlId=\"scrollMode\">\n          <Form.Group as={Row} className=\"mb-1\">\n            <Col xs={4}>\n              <Form.Label className=\"col-form-label\">\n                <FormattedMessage id=\"dialogs.lightbox.scroll_mode.label\" />\n              </Form.Label>\n            </Col>\n            <Col xs={8}>\n              <Form.Control\n                as=\"select\"\n                onChange={(e) =>\n                  setScrollMode(e.target.value as GQL.ImageLightboxScrollMode)\n                }\n                value={\n                  lightboxSettings?.scrollMode ??\n                  GQL.ImageLightboxScrollMode.Zoom\n                }\n                className=\"btn-secondary mx-1 mb-1\"\n              >\n                <option\n                  value={GQL.ImageLightboxScrollMode.Zoom}\n                  key={GQL.ImageLightboxScrollMode.Zoom}\n                >\n                  {intl.formatMessage({\n                    id: \"dialogs.lightbox.scroll_mode.zoom\",\n                  })}\n                </option>\n                <option\n                  value={GQL.ImageLightboxScrollMode.PanY}\n                  key={GQL.ImageLightboxScrollMode.PanY}\n                >\n                  {intl.formatMessage({\n                    id: \"dialogs.lightbox.scroll_mode.pan_y\",\n                  })}\n                </option>\n              </Form.Control>\n            </Col>\n          </Form.Group>\n          <Form.Text className=\"text-muted\">\n            {intl.formatMessage({\n              id: \"dialogs.lightbox.scroll_mode.description\",\n            })}\n          </Form.Text>\n        </Form.Group>\n      </>\n    );\n  }\n\n  function renderBody() {\n    if (images.length === 0 || isLoading || isSwitchingPage) {\n      return <LoadingIndicator />;\n    }\n\n    const currentImage: ILightboxImage | undefined = images[currentIndex];\n    const title = currentImage ? imageTitle(currentImage) : undefined;\n\n    function setRating(v: number | null) {\n      if (currentImage?.id) {\n        updateImage({\n          variables: {\n            input: {\n              id: currentImage.id,\n              rating100: v,\n            },\n          },\n        });\n      }\n    }\n\n    async function onIncrementClick() {\n      if (currentImage?.id === undefined) return;\n      try {\n        await mutateImageIncrementO(currentImage.id);\n      } catch (e) {\n        Toast.error(e);\n      }\n    }\n\n    async function onDecrementClick() {\n      if (currentImage?.id === undefined) return;\n      try {\n        await mutateImageDecrementO(currentImage.id);\n      } catch (e) {\n        Toast.error(e);\n      }\n    }\n\n    async function onResetClick() {\n      if (currentImage?.id === undefined) return;\n      try {\n        await mutateImageResetO(currentImage?.id);\n      } catch (e) {\n        Toast.error(e);\n      }\n    }\n\n    const pageHeader =\n      page && pages\n        ? intl.formatMessage(\n            { id: \"dialogs.lightbox.page_header\" },\n            { page, total: pages }\n          )\n        : \"\";\n\n    return (\n      <>\n        <div className={CLASSNAME_HEADER}>\n          <div className={CLASSNAME_LEFT_SPACER}>{renderChapterMenu()}</div>\n          <div className={CLASSNAME_INDICATOR}>\n            <span>\n              {chapterHeader()} {pageHeader}\n            </span>\n            {images.length > 1 ? (\n              <b ref={indicatorRef}>{`${currentIndex + 1} / ${\n                images.length\n              }`}</b>\n            ) : undefined}\n          </div>\n          <div className={CLASSNAME_RIGHT}>\n            <div className={CLASSNAME_OPTIONS}>\n              <div className={CLASSNAME_OPTIONS_ICON}>\n                <Button\n                  ref={overlayTarget}\n                  variant=\"link\"\n                  title={intl.formatMessage({\n                    id: \"dialogs.lightbox.options\",\n                  })}\n                  onClick={() => setShowOptions(!showOptions)}\n                >\n                  <Icon icon={faCog} />\n                </Button>\n                <Overlay\n                  target={overlayTarget.current}\n                  show={showOptions}\n                  placement=\"bottom\"\n                  container={containerRef}\n                  rootClose\n                  onHide={() => setShowOptions(false)}\n                >\n                  {({ placement, arrowProps, show: _show, ...props }) => (\n                    <div\n                      className=\"popover\"\n                      {...props}\n                      style={{ ...props.style }}\n                    >\n                      <Popover.Title>\n                        {intl.formatMessage({\n                          id: \"dialogs.lightbox.options\",\n                        })}\n                      </Popover.Title>\n                      <Popover.Content>{renderOptionsForm()}</Popover.Content>\n                    </div>\n                  )}\n                </Overlay>\n              </div>\n              <InputGroup className={CLASSNAME_OPTIONS_INLINE}>\n                {renderOptionsForm()}\n              </InputGroup>\n            </div>\n            {slideshowEnabled && (\n              <Button\n                variant=\"link\"\n                onClick={toggleSlideshow}\n                title=\"Toggle Slideshow\"\n              >\n                <Icon icon={slideshowInterval !== null ? faPause : faPlay} />\n              </Button>\n            )}\n            {zoom !== 1 && (\n              <Button\n                variant=\"link\"\n                onClick={() => {\n                  setResetPosition(!resetPosition);\n                  setZoom(1);\n                }}\n                title=\"Reset zoom\"\n              >\n                <Icon icon={faSearchMinus} />\n              </Button>\n            )}\n            {document.fullscreenEnabled && (\n              <Button\n                variant=\"link\"\n                onClick={toggleFullscreen}\n                title=\"Toggle Fullscreen\"\n              >\n                <Icon icon={faExpand} />\n              </Button>\n            )}\n            <Button\n              variant=\"link\"\n              onClick={() => close()}\n              title=\"Close Lightbox\"\n            >\n              <Icon icon={faTimes} />\n            </Button>\n          </div>\n        </div>\n        <div className={CLASSNAME_DISPLAY}>\n          {allowNavigation && (\n            <Button\n              variant=\"link\"\n              onClick={handleLeft}\n              className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}\n            >\n              <Icon icon={faChevronLeft} />\n            </Button>\n          )}\n\n          <div\n            className={cx(CLASSNAME_CAROUSEL, {\n              [CLASSNAME_INSTANT]: instantTransition,\n            })}\n            style={{ left: `${currentIndex * -100}vw` }}\n            ref={carouselRef}\n          >\n            {images.map((image, i) => (\n              <div className={`${CLASSNAME_IMAGE}`} key={image.paths.image}>\n                {i >= currentIndex - 1 && i <= currentIndex + 1 ? (\n                  <LightboxImage\n                    src={image.paths.image ?? \"\"}\n                    width={image.visual_files?.[0]?.width ?? 0}\n                    height={image.visual_files?.[0]?.height ?? 0}\n                    displayMode={displayMode}\n                    scaleUp={lightboxSettings?.scaleUp ?? false}\n                    scrollMode={\n                      lightboxSettings?.scrollMode ??\n                      GQL.ImageLightboxScrollMode.Zoom\n                    }\n                    resetPosition={resetPosition}\n                    zoom={i === currentIndex ? zoom : 1}\n                    scrollAttemptsBeforeChange={scrollAttemptsBeforeChange}\n                    firstScroll={firstScroll}\n                    inScrollGroup={inScrollGroup}\n                    current={i === currentIndex}\n                    alignBottom={movingLeft}\n                    setZoom={updateZoom}\n                    debouncedScrollReset={debouncedScrollReset}\n                    onLeft={handleLeft}\n                    onRight={handleRight}\n                    isVideo={isVideo(image.visual_files?.[0] ?? {})}\n                  />\n                ) : undefined}\n              </div>\n            ))}\n          </div>\n\n          {allowNavigation && (\n            <Button\n              variant=\"link\"\n              onClick={handleRight}\n              className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}\n            >\n              <Icon icon={faChevronRight} />\n            </Button>\n          )}\n        </div>\n        {showNavigation && !isFullscreen && images.length > 1 && (\n          <div className={CLASSNAME_NAV} style={navOffset} ref={navRef}>\n            <Button\n              variant=\"link\"\n              onClick={() => setIndex(images.length - 1)}\n              className={CLASSNAME_NAVBUTTON}\n            >\n              <Icon icon={faArrowLeft} className=\"mr-4\" />\n            </Button>\n            {navItems}\n            <Button\n              variant=\"link\"\n              onClick={() => setIndex(0)}\n              className={CLASSNAME_NAVBUTTON}\n            >\n              <Icon icon={faArrowRight} className=\"ml-4\" />\n            </Button>\n          </div>\n        )}\n        <div className={CLASSNAME_FOOTER}>\n          <div className={CLASSNAME_FOOTER_LEFT}>\n            {currentImage?.id !== undefined && (\n              <>\n                <div>\n                  <OCounterButton\n                    onDecrement={onDecrementClick}\n                    onIncrement={onIncrementClick}\n                    onReset={onResetClick}\n                    value={currentImage?.o_counter ?? 0}\n                  />\n                </div>\n                <RatingSystem\n                  value={currentImage?.rating100}\n                  onSetRating={(v) => setRating(v)}\n                  clickToRate\n                  withoutContext\n                />\n              </>\n            )}\n          </div>\n          <div className={CLASSNAME_FOOTER_CENTER}>\n            {currentImage && (\n              <>\n                <Link\n                  className=\"image-link\"\n                  to={`/images/${currentImage.id}`}\n                  onClick={() => close()}\n                >\n                  {title ?? \"\"}\n                </Link>\n                {currentImage.galleries?.length ? (\n                  <Link\n                    className=\"image-gallery-link\"\n                    to={`/galleries/${currentImage.galleries[0].id}`}\n                    onClick={() => close()}\n                  >\n                    <Icon icon={faImages} />\n                    {galleryTitle(currentImage.galleries[0])}\n                  </Link>\n                ) : null}\n              </>\n            )}\n          </div>\n          <div className={CLASSNAME_FOOTER_RIGHT}></div>\n        </div>\n      </>\n    );\n  }\n\n  if (!isVisible) {\n    return <></>;\n  }\n\n  return (\n    <div\n      className={CLASSNAME}\n      role=\"presentation\"\n      ref={containerRef}\n      onClick={handleClose}\n    >\n      {renderBody()}\n    </div>\n  );\n};\n\nexport default LightboxComponent;\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx",
    "content": "import React, { useEffect, useRef, useState, useCallback } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\n\nconst ZOOM_STEP = 1.1;\nconst ZOOM_FACTOR = 700;\nconst SCROLL_GROUP_THRESHOLD = 8;\nconst SCROLL_GROUP_EXIT_THRESHOLD = 4;\nconst SCROLL_INFINITE_THRESHOLD = 10;\nconst SCROLL_PAN_STEP = 75;\nconst SCROLL_PAN_FACTOR = 2;\nconst CLASSNAME = \"Lightbox\";\nconst CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;\nconst CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;\n\nfunction calculateDefaultZoom(\n  width: number,\n  height: number,\n  boundWidth: number,\n  boundHeight: number,\n  displayMode: GQL.ImageLightboxDisplayMode,\n  scaleUp: boolean\n) {\n  // set initial zoom level based on options\n  let xZoom: number;\n  let yZoom: number;\n  let newZoom = 1;\n  switch (displayMode) {\n    case GQL.ImageLightboxDisplayMode.FitXy:\n      xZoom = boundWidth / width;\n      yZoom = boundHeight / height;\n\n      if (!scaleUp) {\n        xZoom = Math.min(xZoom, 1);\n        yZoom = Math.min(yZoom, 1);\n      }\n      newZoom = Math.min(xZoom, yZoom);\n      break;\n    case GQL.ImageLightboxDisplayMode.FitX:\n      newZoom = boundWidth / width;\n\n      if (!scaleUp) {\n        newZoom = Math.min(newZoom, 1);\n      }\n      break;\n    case GQL.ImageLightboxDisplayMode.Original:\n      newZoom = 1;\n      break;\n  }\n\n  return newZoom;\n}\n\ninterface IProps {\n  src: string;\n  width: number;\n  height: number;\n  displayMode: GQL.ImageLightboxDisplayMode;\n  scaleUp: boolean;\n  scrollMode: GQL.ImageLightboxScrollMode;\n  resetPosition?: boolean;\n  zoom: number;\n  scrollAttemptsBeforeChange: number;\n  // these refs must be outside of LightboxImage,\n  // since they need to be shared between all LightboxImages\n  firstScroll: React.MutableRefObject<number | null>;\n  inScrollGroup: React.MutableRefObject<boolean>;\n  current: boolean;\n  // set to true to align image with bottom instead of top\n  alignBottom?: boolean;\n  setZoom: (v: number) => void;\n  debouncedScrollReset: () => void;\n  onLeft: () => void;\n  onRight: () => void;\n  isVideo: boolean;\n}\n\nexport const LightboxImage: React.FC<IProps> = ({\n  src,\n  width,\n  height,\n  displayMode,\n  scaleUp,\n  scrollMode,\n  resetPosition,\n  zoom,\n  scrollAttemptsBeforeChange,\n  firstScroll,\n  inScrollGroup,\n  current,\n  alignBottom,\n  setZoom,\n  debouncedScrollReset,\n  onLeft,\n  onRight,\n  isVideo,\n}) => {\n  const [defaultZoom, setDefaultZoom] = useState(1);\n  const [moving, setMoving] = useState(false);\n  const [positionX, setPositionX] = useState(0);\n  const [positionY, setPositionY] = useState(0);\n  const [imageWidth, setImageWidth] = useState(width);\n  const [imageHeight, setImageHeight] = useState(height);\n  const [boxWidth, setBoxWidth] = useState(0);\n  const [boxHeight, setBoxHeight] = useState(0);\n  const dimensionsProvided = width > 0 && height > 0;\n\n  const mouseDownEvent = useRef<MouseEvent>();\n  const resetPositionRef = useRef(resetPosition);\n\n  const container = React.createRef<HTMLDivElement>();\n  const startPoints = useRef<number[]>([0, 0]);\n  const pointerCache = useRef<React.PointerEvent[]>([]);\n  const prevDiff = useRef<number | undefined>();\n\n  const scrollAttempts = useRef(0);\n\n  useEffect(() => {\n    const box = container.current;\n    if (box) {\n      setBoxWidth(box.offsetWidth);\n      setBoxHeight(box.offsetHeight);\n    }\n\n    function toggleVideoPlay() {\n      if (container.current) {\n        let openVideo = container.current.getElementsByTagName(\"video\");\n        if (openVideo.length > 0) {\n          let rect = openVideo[0].getBoundingClientRect();\n          if (Math.abs(rect.x) < document.body.clientWidth / 2) {\n            openVideo[0].play();\n          } else {\n            openVideo[0].pause();\n          }\n        }\n      }\n    }\n\n    setTimeout(() => {\n      toggleVideoPlay();\n    }, 250);\n  }, [container]);\n\n  useEffect(() => {\n    if (dimensionsProvided) {\n      return;\n    }\n    let mounted = true;\n    const img = new Image();\n    function onLoad() {\n      if (mounted) {\n        setImageWidth(img.width);\n        setImageHeight(img.height);\n      }\n    }\n\n    img.onload = onLoad;\n    img.src = src;\n\n    return () => {\n      mounted = false;\n    };\n  }, [src, dimensionsProvided]);\n\n  const minMaxY = useCallback(\n    (appliedZoom: number) => {\n      let minY, maxY: number;\n      const inBounds = appliedZoom * imageHeight <= boxHeight;\n\n      // NOTE: I don't even know how these work, but they do\n      if (!inBounds) {\n        if (imageHeight > boxHeight) {\n          minY =\n            (appliedZoom * imageHeight - imageHeight) / 2 -\n            appliedZoom * imageHeight +\n            boxHeight;\n          maxY = (appliedZoom * imageHeight - imageHeight) / 2;\n        } else {\n          minY = (boxHeight - appliedZoom * imageHeight) / 2;\n          maxY = (appliedZoom * imageHeight - boxHeight) / 2;\n        }\n      } else {\n        minY = Math.min((boxHeight - imageHeight) / 2, 0);\n        maxY = minY;\n      }\n\n      return [minY, maxY];\n    },\n    [imageHeight, boxHeight]\n  );\n\n  const calculateInitialPosition = useCallback(\n    (appliedZoom: number) => {\n      // Center image from container's center\n      const newPositionX = Math.min((boxWidth - imageWidth) / 2, 0);\n      let newPositionY: number;\n\n      if (displayMode === GQL.ImageLightboxDisplayMode.FitXy) {\n        newPositionY = Math.min((boxHeight - imageHeight) / 2, 0);\n      } else {\n        // otherwise, align image with container\n        const [minY, maxY] = minMaxY(appliedZoom);\n        if (!alignBottom) {\n          newPositionY = maxY;\n        } else {\n          newPositionY = minY;\n        }\n      }\n\n      return [newPositionX, newPositionY];\n    },\n    [\n      displayMode,\n      boxWidth,\n      imageWidth,\n      boxHeight,\n      imageHeight,\n      alignBottom,\n      minMaxY,\n    ]\n  );\n\n  useEffect(() => {\n    // don't set anything until we have the dimensions\n    if (!imageWidth || !imageHeight || !boxWidth || !boxHeight) {\n      return;\n    }\n\n    if (!scaleUp && imageWidth < boxWidth && imageHeight < boxHeight) {\n      setDefaultZoom(1);\n      setPositionX(0);\n      setPositionY(0);\n      return;\n    }\n\n    // set initial zoom level based on options\n    const newZoom = calculateDefaultZoom(\n      imageWidth,\n      imageHeight,\n      boxWidth,\n      boxHeight,\n      displayMode,\n      scaleUp\n    );\n\n    setDefaultZoom(newZoom);\n\n    const [newPositionX, newPositionY] = calculateInitialPosition(newZoom * 1);\n\n    setPositionX(newPositionX);\n    setPositionY(newPositionY);\n\n    if (alignBottom) {\n      scrollAttempts.current = scrollAttemptsBeforeChange;\n    } else {\n      scrollAttempts.current = -scrollAttemptsBeforeChange;\n    }\n  }, [\n    imageWidth,\n    imageHeight,\n    boxWidth,\n    boxHeight,\n    displayMode,\n    scaleUp,\n    alignBottom,\n    calculateInitialPosition,\n    scrollAttemptsBeforeChange,\n  ]);\n\n  useEffect(() => {\n    if (resetPosition !== resetPositionRef.current) {\n      resetPositionRef.current = resetPosition;\n\n      const [x, y] = calculateInitialPosition(zoom * defaultZoom);\n      setPositionX(x);\n      setPositionY(y);\n    }\n  }, [\n    zoom,\n    defaultZoom,\n    resetPosition,\n    resetPositionRef,\n    calculateInitialPosition,\n  ]);\n\n  function getScrollMode(ev: React.WheelEvent) {\n    if (ev.shiftKey) {\n      switch (scrollMode) {\n        case GQL.ImageLightboxScrollMode.Zoom:\n          return GQL.ImageLightboxScrollMode.PanY;\n        case GQL.ImageLightboxScrollMode.PanY:\n          return GQL.ImageLightboxScrollMode.Zoom;\n      }\n    }\n\n    return scrollMode;\n  }\n\n  function onContainerScroll(ev: React.WheelEvent) {\n    // don't zoom if mouse isn't over image\n    if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) {\n      onImageScroll(ev);\n    }\n  }\n\n  function onLeftScroll(\n    ev: React.WheelEvent,\n    scrollable: boolean,\n    infinite: boolean\n  ) {\n    if (infinite) {\n      // for infinite scrolls, only change once per scroll \"group\"\n      if (ev.deltaY <= -SCROLL_GROUP_THRESHOLD) {\n        if (!inScrollGroup.current) {\n          onLeft();\n        }\n      }\n    } else {\n      // #2535 - require additional scrolls before changing page\n      if (\n        !scrollable ||\n        scrollAttempts.current <= -scrollAttemptsBeforeChange\n      ) {\n        scrollAttempts.current = 0;\n        onLeft();\n      } else {\n        scrollAttempts.current--;\n      }\n    }\n  }\n\n  function onRightScroll(\n    ev: React.WheelEvent,\n    scrollable: boolean,\n    infinite: boolean\n  ) {\n    if (infinite) {\n      // for infinite scrolls, only change once per scroll \"group\"\n      if (ev.deltaY >= SCROLL_GROUP_THRESHOLD) {\n        if (!inScrollGroup.current) {\n          onRight();\n        }\n      }\n    } else {\n      // #2535 - require additional scrolls before changing page\n      if (!scrollable || scrollAttempts.current >= scrollAttemptsBeforeChange) {\n        scrollAttempts.current = 0;\n        onRight();\n      } else {\n        scrollAttempts.current++;\n      }\n    }\n  }\n\n  function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) {\n    if (!current) return;\n\n    const [minY, maxY] = minMaxY(zoom * defaultZoom);\n\n    const scrollable = positionY !== maxY || positionY !== minY;\n\n    let newPositionY: number;\n    if (infinite) {\n      newPositionY = positionY - ev.deltaY / SCROLL_PAN_FACTOR;\n    } else {\n      newPositionY =\n        positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP);\n    }\n\n    // #2389 - if scroll up and at top, then go to previous image\n    // if scroll down and at bottom, then go to next image\n    if (newPositionY > maxY && positionY === maxY) {\n      onLeftScroll(ev, scrollable, infinite);\n    } else if (newPositionY < minY && positionY === minY) {\n      onRightScroll(ev, scrollable, infinite);\n    } else {\n      scrollAttempts.current = 0;\n\n      // ensure image doesn't go offscreen\n      newPositionY = Math.max(newPositionY, minY);\n      newPositionY = Math.min(newPositionY, maxY);\n\n      setPositionY(newPositionY);\n    }\n\n    ev.stopPropagation();\n  }\n\n  function onImageScroll(ev: React.WheelEvent) {\n    const absDeltaY = Math.abs(ev.deltaY);\n    const firstDeltaY = firstScroll.current;\n    // detect infinite scrolling (mousepad, mouse with infinite scrollwheel)\n    const infinite =\n      // scrolling is infinite if deltaY is small\n      absDeltaY < SCROLL_INFINITE_THRESHOLD ||\n      // or if scroll events come quickly and the first one was small\n      (firstDeltaY !== null &&\n        Math.abs(firstDeltaY) < SCROLL_INFINITE_THRESHOLD);\n\n    switch (getScrollMode(ev)) {\n      case GQL.ImageLightboxScrollMode.Zoom:\n        let percent: number;\n        if (infinite) {\n          percent = 1 - ev.deltaY / ZOOM_FACTOR;\n        } else {\n          percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;\n        }\n        setZoom(zoom * percent);\n        break;\n      case GQL.ImageLightboxScrollMode.PanY:\n        onImageScrollPanY(ev, infinite);\n        break;\n    }\n    if (firstDeltaY === null) {\n      firstScroll.current = ev.deltaY;\n    }\n    if (absDeltaY >= SCROLL_GROUP_THRESHOLD) {\n      inScrollGroup.current = true;\n    } else if (absDeltaY <= SCROLL_GROUP_EXIT_THRESHOLD) {\n      // only \"exit\" the scroll group if speed has slowed considerably\n      inScrollGroup.current = false;\n    }\n    debouncedScrollReset();\n  }\n\n  function onImageMouseOver(ev: React.MouseEvent) {\n    if (!moving) return;\n\n    if (!ev.buttons) {\n      setMoving(false);\n      return;\n    }\n\n    const posX = ev.pageX - startPoints.current[0];\n    const posY = ev.pageY - startPoints.current[1];\n    startPoints.current = [ev.pageX, ev.pageY];\n\n    setPositionX(positionX + posX);\n    setPositionY(positionY + posY);\n  }\n\n  function onImageMouseDown(ev: React.MouseEvent) {\n    startPoints.current = [ev.pageX, ev.pageY];\n    setMoving(true);\n\n    mouseDownEvent.current = ev.nativeEvent;\n  }\n\n  function onImageMouseUp(ev: React.MouseEvent) {\n    if (ev.button !== 0) return;\n\n    if (\n      !mouseDownEvent.current ||\n      ev.timeStamp - mouseDownEvent.current.timeStamp > 200\n    ) {\n      // not a click - ignore\n      return;\n    }\n\n    // must be a click\n    if (\n      ev.pageX !== startPoints.current[0] ||\n      ev.pageY !== startPoints.current[1]\n    ) {\n      return;\n    }\n\n    if (ev.nativeEvent.offsetX >= (ev.target as HTMLElement).offsetWidth / 2) {\n      onRight();\n    } else {\n      onLeft();\n    }\n  }\n\n  function onTouchStart(ev: React.TouchEvent) {\n    ev.preventDefault();\n    if (ev.touches.length === 1) {\n      startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];\n      setMoving(true);\n    }\n  }\n\n  function onTouchMove(ev: React.TouchEvent) {\n    if (!moving) return;\n\n    if (ev.touches.length === 1) {\n      const posX = ev.touches[0].pageX - startPoints.current[0];\n      const posY = ev.touches[0].pageY - startPoints.current[1];\n      startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];\n\n      setPositionX(positionX + posX);\n      setPositionY(positionY + posY);\n    }\n  }\n\n  function onPointerDown(ev: React.PointerEvent) {\n    // replace pointer event with the same id, if applicable\n    pointerCache.current = pointerCache.current.filter(\n      (e) => e.pointerId !== ev.pointerId\n    );\n\n    pointerCache.current.push(ev);\n    prevDiff.current = undefined;\n  }\n\n  function onPointerUp(ev: React.PointerEvent) {\n    for (let i = 0; i < pointerCache.current.length; i++) {\n      if (pointerCache.current[i].pointerId === ev.pointerId) {\n        pointerCache.current.splice(i, 1);\n        break;\n      }\n    }\n  }\n\n  function onPointerMove(ev: React.PointerEvent) {\n    // find the event in the cache\n    const cachedIndex = pointerCache.current.findIndex(\n      (c) => c.pointerId === ev.pointerId\n    );\n    if (cachedIndex !== -1) {\n      pointerCache.current[cachedIndex] = ev;\n    }\n\n    // compare the difference between the two pointers\n    if (pointerCache.current.length === 2) {\n      const ev1 = pointerCache.current[0];\n      const ev2 = pointerCache.current[1];\n      const diffX = Math.abs(ev1.clientX - ev2.clientX);\n      const diffY = Math.abs(ev1.clientY - ev2.clientY);\n      const diff = Math.sqrt(diffX ** 2 + diffY ** 2);\n\n      if (prevDiff.current !== undefined) {\n        const diffDiff = diff - prevDiff.current;\n        const factor = (Math.abs(diffDiff) / 20) * 0.1 + 1;\n\n        if (diffDiff > 0) {\n          setZoom(zoom * factor);\n        } else if (diffDiff < 0) {\n          setZoom((zoom * 1) / factor);\n        }\n      }\n\n      prevDiff.current = diff;\n    }\n  }\n\n  const ImageView = isVideo ? \"video\" : \"img\";\n\n  return (\n    <div\n      ref={container}\n      className={`${CLASSNAME_IMAGE}`}\n      onWheel={(e) => onContainerScroll(e)}\n    >\n      {defaultZoom ? (\n        <picture\n          style={{\n            transform: `translate(${positionX}px, ${positionY}px) scale(${\n              defaultZoom * zoom\n            })`,\n          }}\n        >\n          <source srcSet={src} media=\"(min-width: 800px)\" />\n          {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}\n          <ImageView\n            loop={isVideo}\n            src={src}\n            alt=\"\"\n            draggable={false}\n            style={{ touchAction: \"none\" }}\n            onWheel={current ? (e) => onImageScroll(e) : undefined}\n            onMouseDown={onImageMouseDown}\n            onMouseUp={onImageMouseUp}\n            onMouseMove={onImageMouseOver}\n            onTouchStart={onTouchStart}\n            onTouchMove={onTouchMove}\n            onPointerDown={onPointerDown}\n            onPointerUp={onPointerUp}\n            onPointerMove={onPointerMove}\n          />\n        </picture>\n      ) : undefined}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Lightbox/LightboxLink.tsx",
    "content": "import { PropsWithChildren } from \"react\";\nimport { useLightbox } from \"./hooks\";\nimport { ILightboxImage } from \"./types\";\nimport { Button } from \"react-bootstrap\";\nimport { PatchComponent } from \"src/patch\";\n\nexport const LightboxLink: React.FC<\n  PropsWithChildren<{ images?: ILightboxImage[] | undefined; index?: number }>\n> = PatchComponent(\"LightboxLink\", ({ images, index, children }) => {\n  const showLightbox = useLightbox();\n\n  if (!images || images.length === 0) {\n    return <>{children}</>;\n  }\n\n  return (\n    <Button\n      variant=\"link\"\n      onClick={() => showLightbox({ images, initialIndex: index })}\n    >\n      {children}\n    </Button>\n  );\n});\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Lightbox/context.tsx",
    "content": "import React, { Suspense, useCallback, useState } from \"react\";\nimport { lazyComponent } from \"src/utils/lazyComponent\";\nimport { ILightboxImage, IChapter } from \"./types\";\n\nconst LightboxComponent = lazyComponent(() => import(\"./Lightbox\"));\n\nexport interface IState {\n  images: ILightboxImage[];\n  isVisible: boolean;\n  isLoading: boolean;\n  showNavigation: boolean;\n  initialIndex?: number;\n  pageCallback?: (props: { direction?: number; page?: number }) => void;\n  chapters?: IChapter[];\n  page?: number;\n  pages?: number;\n  pageSize?: number;\n  slideshowEnabled: boolean;\n  onClose?: () => void;\n}\ninterface IContext {\n  lightboxState: IState;\n  setLightboxState: (state: Partial<IState>) => void;\n}\n\nexport const LightboxContext = React.createContext<IContext | null>(null);\n\nexport function useLightboxContext() {\n  const context = React.useContext(LightboxContext);\n  if (!context) {\n    throw new Error(\n      \"useLightboxContext must be used within a LightboxProvider\"\n    );\n  }\n  return context;\n}\n\nexport const LightboxProvider: React.FC = ({ children }) => {\n  const [lightboxState, setLightboxState] = useState<IState>({\n    images: [],\n    isVisible: false,\n    isLoading: false,\n    showNavigation: true,\n    slideshowEnabled: false,\n  });\n\n  const setPartialState = useCallback(\n    (state: Partial<IState>) => {\n      setLightboxState((currentState: IState) => ({\n        ...currentState,\n        ...state,\n      }));\n    },\n    [setLightboxState]\n  );\n\n  const onHide = () => {\n    setLightboxState({ ...lightboxState, isVisible: false });\n    if (lightboxState.onClose) {\n      lightboxState.onClose();\n    }\n  };\n\n  return (\n    <LightboxContext.Provider\n      value={{ lightboxState, setLightboxState: setPartialState }}\n    >\n      {children}\n      <Suspense fallback={<></>}>\n        {lightboxState.isVisible && (\n          <LightboxComponent {...lightboxState} hide={onHide} />\n        )}\n      </Suspense>\n    </LightboxContext.Provider>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Lightbox/hooks.ts",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport { IState, useLightboxContext } from \"./context\";\nimport { IChapter } from \"./types\";\n\nexport const useLightbox = (\n  state: Partial<Omit<IState, \"isVisible\">> = {},\n  chapters: IChapter[] = []\n) => {\n  const { setLightboxState } = useLightboxContext();\n\n  useEffect(() => {\n    setLightboxState({\n      images: state.images,\n      showNavigation: state.showNavigation,\n      pageCallback: state.pageCallback,\n      page: state.page,\n      pages: state.pages,\n      pageSize: state.pageSize,\n      slideshowEnabled: state.slideshowEnabled,\n      onClose: state.onClose,\n    });\n  }, [\n    setLightboxState,\n    state.images,\n    state.showNavigation,\n    state.pageCallback,\n    state.page,\n    state.pages,\n    state.pageSize,\n    state.slideshowEnabled,\n    state.onClose,\n  ]);\n\n  const show = useCallback(\n    (props: Partial<IState>) => {\n      setLightboxState({\n        ...props,\n        isVisible: true,\n        page: props.page ?? state.page,\n        pages: props.pages ?? state.pages,\n        pageSize: props.pageSize ?? state.pageSize,\n        chapters: chapters,\n      });\n    },\n    [setLightboxState, state.page, state.pages, state.pageSize, chapters]\n  );\n  return show;\n};\n\nexport const useGalleryLightbox = (id: string, chapters: IChapter[] = []) => {\n  const { setLightboxState } = useLightboxContext();\n\n  const pageSize = 40;\n  const [page, setPage] = useState(1);\n\n  const currentFilter = useMemo(() => {\n    return {\n      page,\n      per_page: pageSize,\n      sort: \"path\",\n    };\n  }, [page]);\n\n  const [fetchGallery, { data }] = GQL.useFindImagesLazyQuery({\n    variables: {\n      filter: currentFilter,\n      image_filter: {\n        galleries: {\n          modifier: GQL.CriterionModifier.Includes,\n          value: [id],\n        },\n      },\n    },\n  });\n\n  const pages = useMemo(() => {\n    const totalCount = data?.findImages.count ?? 0;\n    return Math.ceil(totalCount / pageSize);\n  }, [data?.findImages.count]);\n\n  const handleLightBoxPage = useCallback(\n    (props: { direction?: number; page?: number }) => {\n      const { direction, page: newPage } = props;\n\n      if (direction !== undefined) {\n        if (direction < 0) {\n          if (page === 1) {\n            setPage(pages);\n          } else {\n            setPage(page + direction);\n          }\n        } else if (direction > 0) {\n          if (page === pages) {\n            // return to the first page\n            setPage(1);\n          } else {\n            setPage(page + direction);\n          }\n        }\n      } else if (newPage !== undefined) {\n        setPage(newPage);\n      }\n    },\n    [page, pages]\n  );\n\n  useEffect(() => {\n    if (data)\n      setLightboxState({\n        isLoading: false,\n        isVisible: true,\n        images: data.findImages?.images ?? [],\n        pageCallback: pages > 1 ? handleLightBoxPage : undefined,\n        page,\n        pages,\n      });\n  }, [setLightboxState, data, handleLightBoxPage, page, pages]);\n\n  const show = (index: number = 0) => {\n    if (index > pageSize) {\n      setPage(Math.floor(index / pageSize) + 1);\n      index = index % pageSize;\n    } else {\n      setPage(1);\n    }\n    if (data)\n      setLightboxState({\n        isLoading: false,\n        isVisible: true,\n        initialIndex: index,\n        images: data.findImages?.images ?? [],\n        pageCallback: pages > 1 ? handleLightBoxPage : undefined,\n        page,\n        pages,\n        pageSize,\n        chapters: chapters,\n      });\n    else {\n      setLightboxState({\n        images: [],\n        isLoading: true,\n        isVisible: true,\n        initialIndex: index,\n        pageCallback: undefined,\n        page: undefined,\n        pageSize,\n        chapters: chapters,\n      });\n      fetchGallery();\n    }\n  };\n\n  return show;\n};\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Lightbox/lightbox.scss",
    "content": ".Lightbox {\n  background-color: rgba(20, 20, 20, 0.8);\n  bottom: 0;\n  display: flex;\n  flex-direction: column;\n  left: 0;\n  position: fixed;\n  right: 0;\n  top: 0;\n  z-index: 1040;\n\n  &-header {\n    align-items: center;\n    display: flex;\n    flex-shrink: 0;\n    height: 4rem;\n\n    .fa-icon {\n      path {\n        fill: white;\n      }\n      opacity: 0.4;\n\n      &:hover {\n        opacity: 1;\n      }\n    }\n\n    &-left-spacer {\n      display: flex;\n      flex: 1;\n      justify-content: center;\n    }\n\n    &-chapters {\n      max-height: 90%;\n      overflow: auto;\n    }\n\n    &-indicator {\n      display: flex;\n      flex: 1;\n      flex-direction: column;\n      margin-right: auto;\n      text-align: center;\n    }\n\n    &-options {\n      display: flex;\n      flex-direction: column;\n      margin-left: 100px;\n      text-align: left;\n\n      &-icon {\n        display: inline-block;\n      }\n\n      &-inline {\n        display: none;\n      }\n    }\n\n    &-right {\n      display: flex;\n      flex: 1;\n      justify-content: flex-end;\n    }\n\n    .fa-icon {\n      height: 1.5rem;\n      opacity: 1;\n      width: 1.5rem;\n    }\n  }\n\n  &-footer {\n    align-items: center;\n    display: flex;\n    flex-shrink: 0;\n    height: 4.5rem;\n\n    & > div {\n      flex: 1;\n\n      &:nth-child(2) {\n        overflow-wrap: anywhere;\n        text-align: center;\n      }\n    }\n\n    .rating-stars {\n      display: flex;\n      flex-wrap: nowrap;\n      padding-left: 0.38rem;\n    }\n\n    .rating-number .text-input {\n      width: auto;\n    }\n\n    &-left {\n      display: flex;\n      flex-direction: column;\n      justify-content: start;\n      padding-left: 1em;\n    }\n\n    &-center {\n      display: flex;\n      flex-direction: column;\n      justify-content: center;\n      padding-left: 1em;\n      text-align: center;\n    }\n\n    a {\n      color: $text-color;\n      text-decoration: none;\n\n      .fa-icon {\n        margin-right: 0.5rem;\n      }\n\n      &.image-link {\n        font-weight: bold;\n      }\n    }\n  }\n\n  &-display {\n    display: flex;\n    height: 100%;\n    justify-content: space-between;\n    position: relative;\n  }\n\n  &-carousel {\n    display: flex;\n    height: 100%;\n    position: absolute;\n    transition: left 400ms;\n\n    &-instant {\n      transition-duration: 0ms;\n    }\n\n    &-image {\n      content-visibility: auto;\n      display: flex;\n      width: 100vw;\n\n      picture {\n        display: flex;\n        margin: auto;\n        position: relative;\n\n        > div {\n          display: flex;\n          height: 100%;\n          position: absolute;\n          width: 100%;\n        }\n      }\n\n      img {\n        cursor: pointer;\n        object-fit: contain;\n      }\n    }\n  }\n\n  &-navzone {\n    cursor: pointer;\n    width: 50%;\n  }\n\n  &-navbutton {\n    z-index: 1045;\n\n    .fa-icon {\n      height: 4rem;\n      opacity: 0.4;\n      width: 4rem;\n\n      path {\n        fill: white;\n      }\n\n      &:hover {\n        opacity: 1;\n      }\n    }\n\n    &:focus {\n      box-shadow: none;\n    }\n\n    &:hover {\n      filter: drop-shadow(2px 2px 2px black);\n    }\n  }\n\n  &-nav {\n    display: flex;\n    flex-direction: row;\n    flex-shrink: 0;\n    height: 10rem;\n    margin: 0 auto 2rem 0;\n    padding: 0 10rem;\n    position: relative;\n    transition: left 400ms;\n\n    @media (max-height: 800px) {\n      display: none;\n    }\n\n    &-selected {\n      box-shadow: 0 0 0 6px white;\n    }\n\n    &-image {\n      cursor: pointer;\n      height: 100%;\n      margin-right: 1rem;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Lightbox/types.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\n\ninterface IImagePaths {\n  image?: GQL.Maybe<string>;\n  thumbnail?: GQL.Maybe<string>;\n  preview?: GQL.Maybe<string>;\n}\n\ninterface IFiles {\n  __typename?: string;\n  path: string;\n  width: number;\n  height: number;\n  video_codec?: GQL.Maybe<string>;\n}\n\ninterface IWithPath {\n  path: string;\n}\n\nexport interface IGallery {\n  id: string;\n  title?: GQL.Maybe<string>;\n  files?: GQL.Maybe<IWithPath[]>;\n  folder?: GQL.Maybe<IWithPath>;\n}\n\nexport interface ILightboxImage {\n  id?: string;\n  title?: GQL.Maybe<string>;\n  rating100?: GQL.Maybe<number>;\n  o_counter?: GQL.Maybe<number>;\n  paths: IImagePaths;\n  visual_files?: IFiles[];\n  galleries?: GQL.Maybe<IGallery[]>;\n}\n\nexport interface IChapter {\n  id: string;\n  title: string;\n  image_index: number;\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/LocalForage.ts",
    "content": "import localForage from \"localforage\";\nimport isEqual from \"lodash-es/isEqual\";\nimport React, { Dispatch, SetStateAction, useEffect } from \"react\";\nimport { View } from \"src/components/List/views\";\nimport { ConfigImageLightboxInput } from \"src/core/generated-graphql\";\n\ninterface IInterfaceQueryConfig {\n  filter: string;\n  itemsPerPage: number;\n  currentPage: number;\n}\n\nexport interface IViewConfig {\n  showSidebar?: boolean;\n}\n\ntype IQueryConfig = Record<string, IInterfaceQueryConfig>;\n\ninterface IInterfaceConfig {\n  queryConfig: IQueryConfig;\n  imageLightbox: ConfigImageLightboxInput;\n  // Partial is required because using View makes the key mandatory\n  viewConfig: Partial<Record<View, IViewConfig>>;\n}\n\nexport interface IChangelogConfig {\n  versions: Record<string, boolean>;\n}\n\ninterface ILocalForage<T> {\n  data?: T;\n  error: Error | null;\n  loading: boolean;\n}\n\nconst Loading: Record<string, boolean> = {};\nconst Cache: Record<string, {}> = {};\n\nexport function useLocalForage<T extends {}>(\n  key: string,\n  defaultValue: T = {} as T\n): [ILocalForage<T>, Dispatch<SetStateAction<T>>] {\n  const [error, setError] = React.useState<Error | null>(null);\n  const [data, setData] = React.useState<T>(Cache[key] as T);\n  const [loading, setLoading] = React.useState(Loading[key]);\n\n  useEffect(() => {\n    async function runAsync() {\n      try {\n        let parsed = await localForage.getItem<T>(key);\n        if (typeof parsed === \"string\") {\n          parsed = JSON.parse(parsed ?? \"null\");\n        }\n        if (parsed !== null) {\n          setData(parsed);\n          Cache[key] = parsed;\n        } else {\n          setData(defaultValue);\n          Cache[key] = defaultValue;\n        }\n        setError(null);\n      } catch (err) {\n        if (err instanceof Error) setError(err);\n        Cache[key] = defaultValue;\n      } finally {\n        Loading[key] = false;\n        setLoading(false);\n      }\n    }\n\n    if (!loading && !Cache[key]) {\n      Loading[key] = true;\n      setLoading(true);\n      runAsync();\n    }\n  }, [loading, key, defaultValue]);\n\n  useEffect(() => {\n    if (!isEqual(Cache[key], data)) {\n      Cache[key] = {\n        ...Cache[key],\n        ...data,\n      };\n      localForage.setItem(key, Cache[key]);\n    }\n  });\n\n  const isLoading = loading || loading === undefined;\n\n  return [{ data, error, loading: isLoading }, setData];\n}\n\nexport const useInterfaceLocalForage = () =>\n  useLocalForage<IInterfaceConfig>(\"interface\");\n\nexport const useChangelogStorage = () =>\n  useLocalForage<IChangelogConfig>(\"changelog\");\n"
  },
  {
    "path": "ui/v2.5/src/hooks/OutsideClick.tsx",
    "content": "import React, { useEffect } from \"react\";\n\nexport const useOnOutsideClick = (\n  ref: React.RefObject<HTMLElement>,\n  callback?: () => void,\n  excludeClassName?: string\n) => {\n  useEffect(() => {\n    if (!callback) return;\n\n    /**\n     * Alert if clicked on outside of element\n     */\n    function handleClickOutside(event: MouseEvent) {\n      if (\n        ref.current &&\n        event.target instanceof Node &&\n        !ref.current.contains(event.target) &&\n        !(\n          excludeClassName &&\n          (event.target as HTMLElement).closest(`.${excludeClassName}`)\n        )\n      ) {\n        callback?.();\n      }\n    }\n    // Bind the event listener\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => {\n      // Unbind the event listener on clean up\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, [ref, callback, excludeClassName]);\n};\n"
  },
  {
    "path": "ui/v2.5/src/hooks/PageVisibility.ts",
    "content": "import { useEffect } from \"react\";\n\nconst usePageVisibility = (\n  visibilityChangeCallback: (hidden: boolean) => void\n): void => {\n  useEffect(() => {\n    const callback = () => visibilityChangeCallback(document.hidden);\n    document.addEventListener(\"visibilitychange\", callback);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", callback);\n    };\n  }, [visibilityChangeCallback]);\n};\n\nexport default usePageVisibility;\n"
  },
  {
    "path": "ui/v2.5/src/hooks/Toast.tsx",
    "content": "import {\n  faArrowUpRightFromSquare,\n  faTriangleExclamation,\n} from \"@fortawesome/free-solid-svg-icons\";\nimport React, { useState, useContext, createContext, useMemo } from \"react\";\nimport { Button, Toast } from \"react-bootstrap\";\nimport { FormattedMessage } from \"react-intl\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { ModalComponent } from \"src/components/Shared/Modal\";\nimport { errorToString } from \"src/utils\";\nimport cx from \"classnames\";\n\nexport interface IToast {\n  content: JSX.Element | string;\n  delay?: number;\n  variant?: \"success\" | \"danger\" | \"warning\";\n  priority?: number; // higher is more important\n}\n\ninterface IActiveToast extends IToast {\n  id: number;\n}\n\n// errors are always more important than regular toasts\nconst errorPriority = 100;\n// errors should stay on screen longer\nconst errorDelay = 5000;\n\nlet toastID = 0;\n\ntype ToastFn = (item: IToast) => void;\n\nconst ToastContext = createContext<ToastFn | null>(null);\n\nexport const ToastProvider: React.FC = ({ children }) => {\n  const [toast, setToast] = useState<IActiveToast>();\n  const [hiding, setHiding] = useState(false);\n  const [expanded, setExpanded] = useState(false);\n\n  function expand() {\n    setExpanded(true);\n  }\n\n  const toastItem = useMemo(() => {\n    if (!toast || expanded) return null;\n\n    return (\n      <Toast\n        autohide\n        key={toast.id}\n        onClose={() => setHiding(true)}\n        className={toast.variant ?? \"success\"}\n        delay={toast.delay ?? 3000}\n      >\n        <Toast.Header>\n          <span className=\"mr-auto\" onClick={() => expand()}>\n            {toast.content}\n          </span>\n          {toast.variant === \"danger\" && (\n            <Button\n              variant=\"minimal\"\n              className=\"expand-error-button\"\n              onClick={() => expand()}\n            >\n              <Icon icon={faArrowUpRightFromSquare} />\n            </Button>\n          )}\n        </Toast.Header>\n      </Toast>\n    );\n  }, [toast, expanded]);\n\n  function addToast(item: IToast) {\n    if (hiding || !toast || (item.priority ?? 0) >= (toast.priority ?? 0)) {\n      setHiding(false);\n      setToast({ ...item, id: toastID++ });\n    }\n  }\n\n  function copyToClipboard() {\n    const { content } = toast ?? {};\n\n    if (!!content && typeof content === \"string\" && navigator.clipboard) {\n      navigator.clipboard.writeText(content);\n    }\n  }\n\n  return (\n    <ToastContext.Provider value={addToast}>\n      {children}\n      {expanded && (\n        <ModalComponent\n          dialogClassName=\"toast-expanded-dialog\"\n          show={expanded}\n          accept={{\n            onClick: () => {\n              setToast(undefined);\n              setExpanded(false);\n            },\n          }}\n          header={<FormattedMessage id=\"errors.header\" />}\n          icon={faTriangleExclamation}\n          footerButtons={\n            <>\n              {!!navigator.clipboard && (\n                <Button variant=\"secondary\" onClick={() => copyToClipboard()}>\n                  <FormattedMessage id=\"actions.copy_to_clipboard\" />\n                </Button>\n              )}\n            </>\n          }\n        >\n          {toast?.content}\n        </ModalComponent>\n      )}\n      <div className={cx(\"toast-container row\", { hidden: !toast || hiding })}>\n        {toastItem}\n      </div>\n    </ToastContext.Provider>\n  );\n};\n\nexport const useToast = () => {\n  const addToast = useContext(ToastContext);\n\n  if (!addToast) {\n    throw new Error(\"useToast must be used within a ToastProvider\");\n  }\n\n  return useMemo(\n    () => ({\n      toast: addToast,\n      success(message: JSX.Element | string) {\n        addToast({\n          content: message,\n        });\n      },\n      error(error: unknown) {\n        const message = errorToString(error);\n\n        console.error(error);\n        addToast({\n          variant: \"danger\",\n          content: message,\n          priority: errorPriority,\n          delay: errorDelay,\n        });\n      },\n    }),\n    [addToast]\n  );\n};\n\nexport function toastOperation(\n  toast: ReturnType<typeof useToast>,\n  o: () => Promise<void>,\n  successMessage: string\n) {\n  async function operation() {\n    try {\n      await o();\n\n      toast.success(successMessage);\n    } catch (e) {\n      toast.error(e);\n    }\n  }\n\n  return operation;\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/data.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nexport interface ILoadResults<T> {\n  results: T;\n  loading: boolean;\n}\n\nexport function useCacheResults<T>(data: ILoadResults<T>) {\n  const [results, setResults] = useState<T | undefined>(\n    !data.loading ? data.results : undefined\n  );\n\n  useEffect(() => {\n    if (!data.loading) {\n      setResults(data.results);\n    }\n  }, [data.loading, data.results]);\n\n  return { loading: data.loading, results };\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/debounce.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable react-hooks/exhaustive-deps */\nimport { debounce, DebouncedFunc, DebounceSettings } from \"lodash-es\";\nimport { useCallback, useRef, useState } from \"react\";\n\nexport function useDebounce<T extends (...args: any) => any>(\n  fn: T,\n  wait?: number,\n  options?: DebounceSettings\n): DebouncedFunc<T> {\n  const func = useRef<T>(fn);\n  func.current = fn;\n  return useCallback(\n    debounce(\n      function (this: any) {\n        return func.current.apply(this, arguments as any);\n      },\n      wait,\n      options\n    ),\n    [wait, options?.leading, options?.trailing, options?.maxWait]\n  );\n}\n\nexport function useDebouncedState<T>(\n  initialValue: T,\n  setValue: (v: T) => void,\n  wait?: number\n): [T, (v: T) => void, (v: T) => void] {\n  const [displayedState, setDisplayedState] = useState(initialValue);\n\n  const debouncedSetValue = useDebounce(setValue, wait);\n  const onChange = useCallback(\n    (input: T) => {\n      setDisplayedState(input);\n      debouncedSetValue(input);\n    },\n    [debouncedSetValue, setDisplayedState]\n  );\n\n  const setInstant = useCallback(\n    (v: T) => {\n      setDisplayedState(v);\n      setValue(v);\n    },\n    [setValue]\n  );\n\n  return [displayedState, onChange, setInstant];\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/detailsPanel.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nfunction shouldLoadStickyHeader() {\n  return document.documentElement.scrollTop > 50;\n}\n\nexport function useLoadStickyHeader() {\n  const [load, setLoad] = useState(shouldLoadStickyHeader());\n\n  useEffect(() => {\n    const onScroll = () => {\n      setLoad(shouldLoadStickyHeader());\n    };\n\n    window.addEventListener(\"scroll\", onScroll);\n    return () => {\n      window.removeEventListener(\"scroll\", onScroll);\n    };\n  }, []);\n\n  return load;\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/event.ts",
    "content": "class StashEvent extends EventTarget {\n  dispatch(event: string, id?: string, data?: object) {\n    event = `stash:${event}${id ? `:${id}` : \"\"}`;\n\n    this.dispatchEvent(\n      new CustomEvent(event, {\n        detail: {\n          event: event,\n          ...(id ? { id } : {}),\n          ...(data ? { data } : {}),\n        },\n      })\n    );\n  }\n}\n\nconst Event = new StashEvent();\n\nexport default Event;\n"
  },
  {
    "path": "ui/v2.5/src/hooks/keybinds.ts",
    "content": "import Mousetrap from \"mousetrap\";\nimport { useEffect, useRef } from \"react\";\nimport { RatingSystemType } from \"src/utils/rating\";\n\nexport function useRatingKeybinds(\n  isVisible: boolean,\n  ratingSystem: RatingSystemType | undefined,\n  setRating: (v: number) => void\n) {\n  const firstChar = useRef<string | undefined>(undefined);\n\n  const starRatingShortcuts: { [char: string]: number } = {\n    \"0\": NaN,\n    \"1\": 20,\n    \"2\": 40,\n    \"3\": 60,\n    \"4\": 80,\n    \"5\": 100,\n  };\n\n  function handleStarRatingKeybinds() {\n    for (const key in starRatingShortcuts) {\n      Mousetrap.bind(key, () => setRating(starRatingShortcuts[key]));\n    }\n\n    setTimeout(() => {\n      for (const key in starRatingShortcuts) {\n        Mousetrap.unbind(key);\n      }\n    }, 1000);\n  }\n\n  function handleDecimalKeybinds() {\n    Mousetrap.bind(\"`\", () => {\n      setRating(NaN);\n    });\n\n    for (let i = 0; i <= 9; ++i) {\n      Mousetrap.bind(i.toString(), () => {\n        if (firstChar.current !== undefined) {\n          let combined = parseInt(firstChar.current + i.toString());\n          if (combined === 0) {\n            combined = 100;\n          }\n\n          setRating(combined);\n          firstChar.current = undefined;\n        } else {\n          firstChar.current = i.toString();\n        }\n      });\n    }\n\n    setTimeout(() => {\n      firstChar.current = undefined;\n\n      Mousetrap.unbind(\"`\");\n      for (let i = 0; i <= 9; ++i) {\n        Mousetrap.unbind(i.toString());\n      }\n    }, 1000);\n  }\n\n  useEffect(() => {\n    if (!isVisible) return;\n\n    Mousetrap.bind(\"r\", () => {\n      // numeric keypresses get caught by jwplayer, so blur the element\n      // if the rating sequence is started\n      if (document.activeElement instanceof HTMLElement) {\n        document.activeElement.blur();\n      }\n\n      if (!ratingSystem || ratingSystem === RatingSystemType.Stars) {\n        return handleStarRatingKeybinds();\n      } else {\n        return handleDecimalKeybinds();\n      }\n    });\n\n    return () => {\n      Mousetrap.unbind(\"r\");\n    };\n  });\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/modal.ts",
    "content": "import React from \"react\";\n\nexport function useModal() {\n  const [modal, setModal] = React.useState<React.ReactNode>();\n\n  const closeModal = () => setModal(undefined);\n  const showModal = (m: React.ReactNode) => setModal(m);\n\n  return { modal, closeModal, showModal };\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/scrollToTop.ts",
    "content": "import { useEffect } from \"react\";\n\nexport function useScrollToTopOnMount() {\n  useEffect(() => {\n    window.scrollTo(0, 0);\n  }, []);\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/sprite.ts",
    "content": "import { useEffect, useState } from \"react\";\nimport { WebVTT } from \"videojs-vtt.js\";\n\nexport interface ISceneSpriteInfo {\n  url: string;\n  start: number;\n  end: number;\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\nfunction getSpriteInfo(vttPath: string, response: string) {\n  const sprites: ISceneSpriteInfo[] = [];\n\n  const parser = new WebVTT.Parser(window, WebVTT.StringDecoder());\n  parser.oncue = (cue: VTTCue) => {\n    const match = cue.text.match(/^([^#]*)#xywh=(\\d+),(\\d+),(\\d+),(\\d+)$/i);\n    if (!match) return;\n\n    sprites.push({\n      url: new URL(match[1], vttPath).href,\n      start: cue.startTime,\n      end: cue.endTime,\n      x: Number(match[2]),\n      y: Number(match[3]),\n      w: Number(match[4]),\n      h: Number(match[5]),\n    });\n  };\n  parser.parse(response);\n  parser.flush();\n\n  return sprites;\n}\n\n// useSpriteInfo is a hook that fetches a VTT file and parses it for sprite information.\n// If the vttPath is undefined, the hook will return undefined.\n// If the response is not ok, the hook will return null. This usually indicates missing sprite.\nexport function useSpriteInfo(vttPath: string | undefined) {\n  const [spriteInfo, setSpriteInfo] = useState<\n    ISceneSpriteInfo[] | undefined | null\n  >();\n\n  useEffect(() => {\n    if (!vttPath) {\n      setSpriteInfo(undefined);\n      return;\n    }\n\n    fetch(vttPath).then((response) => {\n      if (!response.ok) {\n        setSpriteInfo(null);\n        return;\n      }\n\n      response.text().then((text) => {\n        setSpriteInfo(getSpriteInfo(vttPath, text));\n      });\n    });\n  }, [vttPath]);\n\n  return spriteInfo;\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/state.ts",
    "content": "import React, { useCallback, Dispatch, SetStateAction } from \"react\";\n\n// useInitialState is an extension of the useState hook.\n// It maintains a state, but additionally exposes a setInitialState function.\n// When setInitialState is called, the current state is only updated if the current\n// state is unchanged from the initial state. This means that the current state will\n// only be updated if explicitly called, or if the initial state is changed and the current\n// state is not dirty.\nexport function useInitialState<T>(\n  initialValue: T\n): [T, Dispatch<SetStateAction<T>>, Dispatch<T>] {\n  const [, setInitialValueInternal] = React.useState<T>(initialValue);\n  const [value, setValue] = React.useState<T>(initialValue);\n\n  const setInitialValue = useCallback((v: T) => {\n    setInitialValueInternal((currentInitial) => {\n      if (v === currentInitial) {\n        return currentInitial;\n      }\n\n      setValue((currentValue) => {\n        if (currentInitial === currentValue) {\n          return v;\n        }\n\n        return currentValue;\n      });\n\n      return v;\n    });\n  }, []);\n\n  return [value, setValue, setInitialValue];\n}\n\n// useMemoOnce is a hook that returns a value once the ready flag is set to true.\n// The value is only set once, and will not be updated once it has been set.\n/* eslint-disable react-hooks/exhaustive-deps */\nexport function useMemoOnce<T>(\n  fn: () => [T, boolean],\n  deps: React.DependencyList\n) {\n  const [storedValue, setStoredValue] = React.useState<T>();\n  const isFirst = React.useRef(true);\n\n  React.useEffect(() => {\n    if (isFirst.current) {\n      const [v, ready] = fn();\n      if (ready) {\n        setStoredValue(v);\n        isFirst.current = false;\n      }\n    }\n  }, deps);\n\n  return storedValue;\n}\n/* eslint-enable react-hooks/exhaustive-deps */\n\n// useCompare is a hook that returns true if the value has changed since the last render.\nexport function useCompare<T>(val: T) {\n  const prevVal = usePrevious(val);\n  return prevVal !== val;\n}\n\n// usePrevious is a hook that returns the previous value of a variable.\nexport function usePrevious<T>(value: T) {\n  const ref = React.useRef<T>();\n  React.useEffect(() => {\n    ref.current = value;\n  }, [value]);\n  return ref.current;\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/tagsEdit.tsx",
    "content": "import * as GQL from \"src/core/generated-graphql\";\nimport { useTagCreate } from \"src/core/StashService\";\nimport { useEffect, useState } from \"react\";\nimport { Tag, TagSelect, TagSelectProps } from \"src/components/Tags/TagSelect\";\nimport { useToast } from \"src/hooks/Toast\";\nimport { useIntl } from \"react-intl\";\nimport { Badge, Button } from \"react-bootstrap\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { faPlus } from \"@fortawesome/free-solid-svg-icons\";\nimport { CollapseButton } from \"src/components/Shared/CollapseButton\";\n\nexport function useTagsEdit(\n  srcTags: Tag[] | undefined,\n  setFieldValue: (ids: string[]) => void\n) {\n  const intl = useIntl();\n  const Toast = useToast();\n  const [createTag] = useTagCreate();\n\n  const [tags, setTags] = useState<Tag[]>([]);\n  const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>();\n\n  function onSetTags(items: Tag[]) {\n    setTags(items);\n    setFieldValue(items.map((item) => item.id));\n  }\n\n  useEffect(() => {\n    setTags(srcTags ?? []);\n  }, [srcTags]);\n\n  async function createNewTag(toCreate: GQL.ScrapedTag) {\n    const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? \"\" };\n    try {\n      const result = await createTag({\n        variables: {\n          input: tagInput,\n        },\n      });\n\n      if (!result.data?.tagCreate) {\n        Toast.error(new Error(\"Failed to create tag\"));\n        return;\n      }\n\n      // add the new tag to the new tags value\n      onSetTags(\n        tags.concat([\n          {\n            id: result.data.tagCreate.id,\n            name: toCreate.name ?? \"\",\n            aliases: [],\n            stash_ids: result.data.tagCreate.stash_ids,\n          },\n        ])\n      );\n\n      // remove the tag from the list\n      const newTagsClone = newTags!.concat();\n      const pIndex = newTagsClone.indexOf(toCreate);\n      newTagsClone.splice(pIndex, 1);\n\n      setNewTags(newTagsClone);\n\n      Toast.success(\n        intl.formatMessage(\n          { id: \"toast.created_entity\" },\n          {\n            entity: intl.formatMessage({ id: \"tag\" }).toLocaleLowerCase(),\n            entity_name: toCreate.name,\n          }\n        )\n      );\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  function updateTagsStateFromScraper(\n    scrapedTags?: Pick<GQL.ScrapedTag, \"name\" | \"stored_id\">[]\n  ) {\n    if (!scrapedTags) {\n      return;\n    }\n\n    // map tags to their ids and filter out those not found\n    const idTags = scrapedTags.filter(\n      (t) => t.stored_id !== undefined && t.stored_id !== null\n    );\n    const newNewTags = scrapedTags.filter((t) => !t.stored_id);\n    onSetTags(\n      idTags.map((p) => {\n        return {\n          id: p.stored_id!,\n          name: p.name ?? \"\",\n          aliases: [],\n          stash_ids: [],\n        };\n      })\n    );\n\n    setNewTags(newNewTags);\n  }\n\n  function renderNewTags() {\n    if (!newTags || newTags.length === 0) {\n      return;\n    }\n\n    const ret = (\n      <>\n        {newTags.map((t) => (\n          <Badge\n            className=\"tag-item\"\n            variant=\"secondary\"\n            key={t.name}\n            onClick={() => createNewTag(t)}\n          >\n            {t.name}\n            <Button className=\"minimal ml-2\">\n              <Icon className=\"fa-fw\" icon={faPlus} />\n            </Button>\n          </Badge>\n        ))}\n      </>\n    );\n\n    const minCollapseLength = 10;\n\n    if (newTags.length >= minCollapseLength) {\n      return (\n        <CollapseButton text={`Missing (${newTags.length})`}>\n          {ret}\n        </CollapseButton>\n      );\n    }\n\n    return ret;\n  }\n\n  function tagsControl(props?: TagSelectProps) {\n    return (\n      <>\n        <TagSelect isMulti onSelect={onSetTags} values={tags} {...props} />\n        {renderNewTags()}\n      </>\n    );\n  }\n\n  return {\n    tags,\n    onSetTags,\n    tagsControl,\n    updateTagsStateFromScraper,\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/throttle.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable react-hooks/exhaustive-deps */\nimport { DebouncedFunc, DebounceSettings, throttle } from \"lodash-es\";\nimport { useCallback, useRef } from \"react\";\n\nexport function useThrottle<T extends (...args: any) => any>(\n  fn: T,\n  wait?: number,\n  options?: DebounceSettings\n): DebouncedFunc<T> {\n  const func = useRef<T>(fn);\n  func.current = fn;\n  return useCallback(\n    throttle(\n      function (this: any) {\n        return func.current.apply(this, arguments as any);\n      },\n      wait,\n      options\n    ),\n    [wait, options?.leading, options?.trailing, options?.maxWait]\n  );\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/title.ts",
    "content": "import { MessageDescriptor, useIntl } from \"react-intl\";\nimport { useConfigurationContext } from \"./Config\";\n\nexport const TITLE = \"Stash\";\nexport const TITLE_SEPARATOR = \" | \";\n\nexport function useTitleProps(...messages: (string | MessageDescriptor)[]) {\n  const intl = useIntl();\n  const config = useConfigurationContext();\n  const title = config.configuration.ui.title || TITLE;\n\n  const parts = messages.map((msg) => {\n    if (typeof msg === \"object\") {\n      return intl.formatMessage(msg);\n    } else {\n      return msg;\n    }\n  });\n\n  return makeTitleProps(title, ...parts);\n}\n\nexport function makeTitleProps(title: string, ...parts: string[]) {\n  const fullTitle = [...parts, title].join(TITLE_SEPARATOR);\n  return {\n    titleTemplate: `%s | ${fullTitle}`,\n    defaultTitle: fullTitle,\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/src/hooks/useScript.tsx",
    "content": "import { useEffect, useMemo, useState } from \"react\";\n\nconst useScript = (urls: string | string[], condition: boolean = true) => {\n  // array of booleans to track the loading state of each script\n  const [loadStates, setLoadStates] = useState<boolean[]>();\n\n  const urlArray = useMemo(() => {\n    if (!Array.isArray(urls)) {\n      return [urls];\n    }\n\n    return urls;\n  }, [urls]);\n\n  useEffect(() => {\n    if (condition) {\n      setLoadStates(urlArray.map(() => false));\n    }\n\n    const scripts = urlArray.map((url) => {\n      const script = document.createElement(\"script\");\n\n      script.src = url;\n      script.async = false;\n      script.defer = true;\n\n      function onLoad() {\n        setLoadStates((prev) =>\n          prev!.map((state, i) => (i === urlArray.indexOf(url) ? true : state))\n        );\n      }\n      script.addEventListener(\"load\", onLoad);\n      script.addEventListener(\"error\", onLoad); // handle error as well\n\n      return script;\n    });\n\n    if (condition) {\n      scripts.forEach((script) => {\n        document.head.appendChild(script);\n      });\n    }\n\n    return () => {\n      if (condition) {\n        scripts.forEach((script) => {\n          document.head.removeChild(script);\n        });\n      }\n    };\n  }, [urlArray, condition]);\n\n  return (\n    condition &&\n    loadStates &&\n    (loadStates.length === 0 || loadStates.every((state) => state))\n  );\n};\n\nexport const useCSS = (urls: string | string[], condition?: boolean) => {\n  const urlArray = useMemo(() => {\n    if (!Array.isArray(urls)) {\n      return [urls];\n    }\n\n    return urls;\n  }, [urls]);\n\n  useEffect(() => {\n    const links = urlArray.map((url) => {\n      const link = document.createElement(\"link\");\n\n      link.href = url;\n      link.rel = \"stylesheet\";\n      link.type = \"text/css\";\n      return link;\n    });\n\n    if (condition) {\n      links.forEach((link) => {\n        document.head.appendChild(link);\n      });\n    }\n\n    return () => {\n      if (condition) {\n        links.forEach((link) => {\n          document.head.removeChild(link);\n        });\n      }\n    };\n  }, [urlArray, condition]);\n};\n\nexport default useScript;\n"
  },
  {
    "path": "ui/v2.5/src/hooks/useTableColumns.ts",
    "content": "import { useConfigureUI } from \"src/core/StashService\";\nimport { useConfigurationContext } from \"src/hooks/Config\";\nimport { useToast } from \"./Toast\";\n\nexport const useTableColumns = (\n  tableName: string,\n  defaultColumns: string[]\n) => {\n  const Toast = useToast();\n\n  const { configuration } = useConfigurationContext();\n  const [saveUI] = useConfigureUI();\n\n  const ui = configuration?.ui;\n\n  const selectedColumns = ui?.tableColumns?.[tableName] ?? defaultColumns;\n\n  async function saveColumns(updatedColumns: string[]) {\n    try {\n      await saveUI({\n        variables: {\n          input: {\n            ...ui,\n            tableColumns: {\n              ...ui?.tableColumns,\n              [tableName]: updatedColumns,\n            },\n          },\n        },\n      });\n    } catch (e) {\n      Toast.error(e);\n    }\n  }\n\n  return { selectedColumns, saveColumns };\n};\n"
  },
  {
    "path": "ui/v2.5/src/index.scss",
    "content": "// variables required by other scss files\n\n// this is calculated from the existing height\n$navbar-height: 48.75px;\n\n$sticky-detail-header-height: 50px;\n\n$sidebar-width: 250px;\n\n@import \"styles/theme\";\n@import \"styles/range\";\n@import \"styles/scrollbars\";\n@import \"sfw-mode.scss\";\n@import \"src/components/Changelog/styles.scss\";\n@import \"src/components/Galleries/styles.scss\";\n@import \"src/components/Help/styles.scss\";\n@import \"src/components/Images/styles.scss\";\n@import \"src/components/List/styles.scss\";\n@import \"src/components/Groups/styles.scss\";\n@import \"src/components/Performers/styles.scss\";\n@import \"src/components/FrontPage/styles.scss\";\n@import \"src/components/Scenes/styles.scss\";\n@import \"src/components/SceneDuplicateChecker/styles.scss\";\n@import \"src/components/SceneFilenameParser/styles.scss\";\n@import \"src/components/ScenePlayer/styles.scss\";\n@import \"src/components/Settings/styles.scss\";\n@import \"src/components/Setup/styles.scss\";\n@import \"src/components/Studios/styles.scss\";\n@import \"src/components/Shared/styles.scss\";\n@import \"src/components/Shared/GridCard/styles.scss\";\n@import \"src/components/Shared/Rating/styles.scss\";\n@import \"src/components/Shared/PackageManager/styles.scss\";\n@import \"src/components/Tags/styles.scss\";\n@import \"src/components/Wall/styles.scss\";\n@import \"src/components/Tagger/styles.scss\";\n@import \"src/hooks/Lightbox/lightbox.scss\";\n@import \"src/hooks/Interactive/interactive.scss\";\n@import \"src/components/Dialogs/IdentifyDialog/styles.scss\";\n@import \"src/components/Dialogs/styles.scss\";\n@import \"flag-icons/css/flag-icons.min.css\";\n\n/* stylelint-disable */\n#root {\n  position: relative !important;\n}\n/* stylelint-enable */\n\nhtml {\n  font-size: 14px;\n}\n\nbody {\n  color: $text-color;\n  -moz-osx-font-smoothing: grayscale;\n  -webkit-font-smoothing: antialiased;\n  margin: 0;\n  overflow-x: hidden;\n  padding: $navbar-height 0 0 0;\n\n  @include media-breakpoint-down(xs) {\n    @media (orientation: portrait) {\n      padding: 0.5rem 0 $navbar-height;\n    }\n  }\n}\n\n.main {\n  @include media-breakpoint-up(sm) {\n    padding-top: 0.5rem;\n  }\n}\n\n#group-page,\n#performer-page,\n#studio-page,\n#tag-page {\n  margin-top: -0.5rem;\n}\n\ncode,\n.code {\n  font-family: source-code-pro, Menlo, Monaco, Consolas, \"Courier New\",\n    monospace;\n}\n\ndd {\n  overflow: hidden;\n  white-space: pre-line;\n}\n\n.sticky.detail-header {\n  display: block;\n  min-height: $sticky-detail-header-height;\n  padding: unset;\n  position: fixed;\n  top: $navbar-height;\n  z-index: 10;\n\n  @media (max-width: 576px) {\n    display: none;\n  }\n\n  .group-name,\n  .performer-name,\n  .studio-name,\n  .tag-name {\n    font-weight: 800;\n  }\n\n  .sticky.detail-header-group {\n    padding: 1rem 2.5rem;\n\n    a.group-name,\n    a.performer-name,\n    a.studio-name,\n    a.tag-name {\n      color: #f5f8fa;\n      cursor: pointer;\n      font-weight: 800;\n    }\n\n    a,\n    span {\n      color: #d7d9db;\n      font-weight: 600;\n      padding-right: 0.5rem;\n    }\n\n    .detail-divider {\n      font-size: 1rem;\n      font-weight: 400;\n      opacity: 0.6;\n    }\n  }\n}\n\n.detail-expand-collapse {\n  .btn-primary:focus,\n  .btn-primary.focus,\n  .btn-primary:not(:disabled):not(.disabled):active,\n  .btn-primary:not(:disabled):not(.disabled).active,\n  .show > .btn-primary.dropdown-toggle,\n  .btn-primary:hover {\n    background: rgba(138, 155, 168, 0.15);\n    background-color: rgba(138, 155, 168, 0.15);\n    border-color: rgba(138, 155, 168, 0.15);\n    box-shadow: unset;\n    color: #f5f8fa;\n  }\n}\n\n.detail-header {\n  background-color: #192127;\n  min-height: 15rem;\n  overflow: hidden;\n  padding: 1rem;\n  position: relative;\n  transition: 0.3s;\n  width: 100%;\n  z-index: 11;\n\n  .detail-group,\n  .col {\n    transition: 0.2s;\n\n    @media (max-width: 576px) {\n      padding-top: 0.5rem;\n    }\n  }\n\n  .background-image-container {\n    bottom: -0.2rem;\n    left: 0;\n    opacity: 0.2;\n    position: absolute;\n    right: 0;\n    top: -0.2rem;\n    z-index: auto;\n\n    .background-image {\n      filter: blur(16px);\n      height: 100%;\n      object-fit: cover;\n      object-position: 50% 30%;\n      width: 100%;\n    }\n  }\n\n  .detail-container {\n    height: 100%;\n    position: relative;\n    z-index: 20;\n\n    .detail-item-value.age {\n      border-bottom: 1px dotted #f5f8fa;\n      margin-right: auto;\n    }\n\n    .performer-disambiguation {\n      letter-spacing: -0.04rem;\n      opacity: 0.65;\n    }\n  }\n\n  h2 {\n    margin-bottom: 0;\n  }\n\n  .country,\n  .performer-country {\n    .mr-2.fi {\n      margin-left: 0.5rem;\n    }\n  }\n\n  .alias-head {\n    color: #868791;\n  }\n\n  .detail-expand-collapse,\n  .name-icons {\n    margin-left: 10px;\n  }\n}\n\n.btn.link {\n  color: $link-color;\n\n  &:hover:not(:disabled),\n  &:active:not(:disabled) {\n    color: $link-color;\n  }\n}\n\n.detail-header.edit {\n  background-color: unset;\n  overflow: visible;\n\n  form {\n    padding-top: 0.5rem;\n  }\n\n  .details-edit {\n    padding-top: 1rem;\n  }\n\n  .detail-header-image {\n    height: auto;\n  }\n\n  /* StashID alignment fix */\n  .form-group.row .row.no-gutters {\n    padding-top: calc(0.375rem + 1px);\n  }\n}\n\n.detail-header.collapsed {\n  .detail-header-image img {\n    max-width: 11rem;\n    transition: 0.5s;\n  }\n}\n\n.detail-body {\n  margin-left: 15px;\n  margin-right: 15px;\n  width: 100%;\n\n  nav {\n    align-content: center;\n    border-bottom: solid 2px #192127;\n    display: flex;\n    justify-content: center;\n    margin: 0;\n    padding: 5px 0;\n  }\n\n  .tab-content {\n    padding-bottom: 0;\n  }\n\n  .item-list-header {\n    align-content: center;\n    // border-bottom: solid 2px #192127;\n    display: flex;\n    justify-content: center;\n    margin: 0;\n    padding: 5px 0 0 0;\n  }\n\n  .item-list-container {\n    padding-top: 15px;\n\n    // this breaks sticky sidebar - need to work out why this is here\n    // @media (max-width: 576px) {\n    //   overflow-x: hidden;\n    // }\n  }\n}\n\n.collapsed .detail-item-value {\n  -webkit-box-orient: vertical;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  overflow: hidden;\n}\n\n.full-width {\n  .detail-header-image {\n    height: auto;\n\n    img {\n      max-width: 22rem;\n    }\n  }\n\n  .detail-item {\n    display: table;\n    padding-right: 0;\n    width: 100%;\n\n    .detail-item-title {\n      display: table-cell;\n      width: 130px;\n    }\n\n    .detail-item-value.age {\n      border-bottom: unset;\n      width: fit-content;\n    }\n  }\n\n  .detail-item-title.tags,\n  .detail-item-title.parent-tags,\n  .detail-item-title.sub-tags {\n    padding-top: 4px;\n  }\n}\n\n.detail-header-image {\n  display: flex;\n  float: left;\n  height: 100%;\n  justify-content: center;\n  padding: 0 1rem;\n\n  .group-images {\n    height: 100%;\n  }\n\n  @media (max-width: 576px) {\n    float: unset;\n    height: auto;\n    padding: 0;\n\n    .group-images {\n      .img {\n        max-width: 100%;\n      }\n    }\n  }\n\n  img {\n    margin: auto;\n    max-width: 14rem;\n    transition: 0.5s;\n  }\n\n  .group-images img {\n    @media (max-width: 576px) {\n      max-width: 100%;\n    }\n  }\n}\n\n#group-page .detail-header-image .group-images img {\n  max-width: 13rem;\n}\n\n#group-page .detail-header-image img,\n#performer-page .detail-header-image img,\n#tag-page .detail-header-image img {\n  border-radius: 0.5rem;\n}\n\n#tag-page {\n  .full-width .detail-header-image img {\n    max-width: 22rem;\n  }\n\n  .detail-header.collapsed .detail-header-image img {\n    max-width: 18rem;\n  }\n\n  .detail-header-image img {\n    max-width: 20rem;\n\n    @media (max-width: 576px) {\n      max-width: 100%;\n    }\n  }\n}\n\n.detail-item.tags .pl-0 {\n  margin-bottom: 0;\n}\n\n.detail-group {\n  display: flex;\n  flex-direction: row;\n  flex-wrap: wrap;\n  padding: 1rem 0;\n}\n\n.detail-item {\n  display: inline-flex;\n  flex-direction: column;\n  padding-bottom: 0.5rem;\n  padding-right: 4rem;\n\n  @media (max-width: 576px) {\n    padding-right: 2rem;\n  }\n}\n\n/* the .apple class denotes areas where rendering on some apple platforms has been inconsistent with other platforms\n   these rules aim to address those inconsistences */\n.apple {\n  @media (min-width: 576px) {\n    .detail-header {\n      .detail-container {\n        display: flex;\n      }\n    }\n\n    .detail-header.edit .row {\n      flex: 1;\n    }\n\n    .detail-header.full-width .detail-header-image,\n    .detail-header.edit .detail-header-image {\n      display: unset;\n    }\n  }\n}\n\n.detail-item-title {\n  color: #868791;\n  font-weight: 700;\n}\n\n.detail-item-value {\n  align-items: center;\n  display: flex;\n  flex-direction: row;\n  flex-wrap: wrap;\n  white-space: pre-line;\n\n  .birthdate,\n  .height-imperial,\n  .penis-length-imperial,\n  .weight-imperial {\n    opacity: 0.65;\n  }\n}\n\n.input-control,\n.text-input {\n  border: 0;\n  box-shadow: 0 0 0 0 rgba(19, 124, 189, 0), 0 0 0 0 rgba(19, 124, 189, 0),\n    0 0 0 0 rgba(19, 124, 189, 0), inset 0 0 0 1px rgba(16, 22, 26, 0.3),\n    inset 0 1px 1px rgba(16, 22, 26, 0.4);\n  color: $text-color;\n\n  &:focus {\n    border: 0;\n    box-shadow: 0 0 0 1px $primary, 0 0 0 1px $primary,\n      0 0 0 3px rgba(19, 124, 189, 0.3), inset 0 0 0 1px rgba(16, 22, 26, 0.3),\n      inset 0 1px 1px rgba(16, 22, 26, 0.4);\n    color: $text-color;\n  }\n}\n\n.input-control,\n.input-control:focus,\n.input-control:disabled {\n  background-color: $secondary;\n}\n\n.input-control:disabled {\n  opacity: 0.8;\n}\n\n.text-input,\n.text-input:focus,\n.text-input[readonly],\n.text-input:disabled {\n  background-color: $textfield-bg;\n}\n\ntextarea.text-input {\n  line-height: 2.5ex;\n  min-height: 12ex;\n  overflow-y: scroll;\n}\n\n/* stylelint-disable declaration-no-important */\n.border-row {\n  background-color: #414c53;\n  height: 1px;\n  padding: 0 !important;\n}\n/* stylelint-enable declaration-no-important */\n\n@media (max-width: 576px) {\n  .row.justify-content-center {\n    margin-left: 0;\n    margin-right: 0;\n  }\n}\n\n.zoom-0 {\n  .gallery-card-image,\n  .tag-card-image {\n    height: 180px;\n  }\n}\n\n.zoom-1 {\n  .gallery-card-image,\n  .tag-card-image {\n    height: 240px;\n  }\n\n  .image-card-preview {\n    height: 240px;\n  }\n}\n\n.zoom-2 {\n  .gallery-card-image,\n  .tag-card-image {\n    height: 360px;\n  }\n\n  .image-card-preview {\n    height: 360px;\n  }\n}\n\n.zoom-3 {\n  .tag-card-image,\n  .gallery-card-image {\n    height: 480px;\n  }\n\n  .image-card-preview {\n    height: 480px;\n  }\n}\n\n.scene-card-preview,\n.gallery-card-image,\n.tag-card-image,\n.image-card-preview {\n  height: auto;\n  width: 100%;\n}\n\n.preview-button {\n  align-items: center;\n  display: flex;\n  height: 100%;\n  justify-content: center;\n  position: absolute;\n  text-align: center;\n  width: 100%;\n\n  button.btn,\n  button.btn:not(:disabled):not(.disabled):hover,\n  button.btn:not(:disabled):not(.disabled):focus,\n  button.btn:not(:disabled):not(.disabled):active {\n    background: none;\n    border: none;\n    box-shadow: none;\n  }\n\n  .fa-icon {\n    color: $text-color;\n    filter: drop-shadow(2px 4px 6px black);\n    height: 5em;\n    opacity: 0;\n    transition: opacity 0.5s;\n    width: 5em;\n\n    &:hover {\n      opacity: 0.8;\n    }\n  }\n\n  @media (hover: none), (pointer: coarse) {\n    // always show preview button when hovering not supported\n    align-items: flex-end;\n    justify-content: right;\n\n    .fa-icon {\n      height: 3em;\n      opacity: 0.8;\n      width: 3em;\n    }\n  }\n}\n\n/* this is a bit of a hack, because we can't supply direct class names\n   to the react-select controls */\n/* stylelint-disable selector-class-pattern */\n\ndiv.react-select__control {\n  background-color: $secondary;\n  border-color: $secondary;\n  color: $text-color;\n  cursor: pointer;\n  white-space: nowrap;\n\n  .react-select__single-value,\n  .react-select__input-container {\n    color: $text-color;\n  }\n\n  .react-select__multi-value {\n    background-color: $muted-gray;\n    color: $text-color;\n  }\n}\n\ndiv.react-select__menu-portal {\n  z-index: 1600;\n}\n\ndiv.react-select__menu,\ndiv.dropdown-menu {\n  background-color: $secondary;\n  color: $text-color;\n\n  .react-select__option,\n  .dropdown-item {\n    color: $text-color;\n  }\n\n  .react-select__option--is-focused,\n  .dropdown-item:focus,\n  .dropdown-item:hover {\n    background-color: #8a9ba826;\n    cursor: pointer;\n  }\n}\n\ndiv.dropdown-menu {\n  max-height: 300px;\n  overflow-y: auto;\n\n  .dropdown-item {\n    display: flex;\n  }\n}\n\n/* fix for react-select in input-group */\n.input-group .react-select {\n  border: 0;\n  height: 100%;\n  padding: 0;\n\n  .react-select__control {\n    border-radius: 0;\n  }\n}\n\n/* stylelint-enable selector-class-pattern */\n\n.image-thumbnail {\n  height: 100px;\n  min-width: 50px;\n  object-fit: cover;\n  object-position: top;\n}\n\n.edit-button {\n  margin-right: 10px;\n\n  // Show caret on split button dropdown toggle\n  &.btn-group .dropdown-toggle-split::after {\n    content: \"\";\n  }\n}\n\n.wrap-tags {\n  column-gap: 10px;\n  flex-wrap: wrap;\n  row-gap: 10px;\n\n  .badge {\n    margin: unset;\n    white-space: normal;\n  }\n}\n\n.tag-item {\n  align-items: center;\n  background-color: $muted-gray;\n  color: $dark-text;\n  display: inline-flex;\n  font-size: 12px;\n  font-weight: 400;\n  line-height: 16px;\n  margin: 5px;\n  padding: 2px 6px;\n\n  // if link, move padding to link to make full tag clickable\n  &.tag-link {\n    padding: 0;\n\n    a {\n      padding: 2px 6px;\n    }\n  }\n\n  &:hover {\n    cursor: pointer;\n  }\n\n  .search-term svg {\n    margin-left: 0;\n  }\n\n  .btn {\n    background: none;\n    border: none;\n    bottom: 2px;\n    color: $dark-text;\n    font-size: 12px;\n    line-height: 16px;\n    margin-right: -0.5rem;\n    opacity: 0.5;\n    padding: 0 0.5rem;\n\n    &:active,\n    &:hover {\n      opacity: 1;\n    }\n  }\n\n  a {\n    color: unset;\n\n    &:hover {\n      color: unset;\n      text-decoration: none;\n    }\n  }\n}\n\n.filter-container,\n.operation-container,\n.pagination {\n  align-items: center;\n  display: flex;\n  justify-content: center;\n  margin: 0 auto 10px;\n}\n\n.filter-item,\n.operation-item {\n  margin: 0 10px;\n}\n\n.rating-100-20 {\n  background: #f00;\n}\n\n.rating-100-19 {\n  background: #ff2409;\n}\n\n.rating-100-18 {\n  background: #ff4812;\n}\n\n.rating-100-17 {\n  background: #ff6a07;\n}\n\n.rating-100-16 {\n  background: #ff8000;\n}\n\n.rating-100-15 {\n  background: #fa8804;\n}\n\n.rating-100-14 {\n  background: #f39409;\n}\n\n.rating-100-13 {\n  background: #eca00e;\n}\n\n.rating-100-12 {\n  background: #e7a811;\n}\n\n.rating-100-11 {\n  background: #dfb617;\n}\n\n.rating-100-10 {\n  background: #d2ca20;\n}\n\n.rating-100-9 {\n  background: #cbb526;\n}\n\n.rating-100-8 {\n  background: #c39f2b;\n}\n\n.rating-100-7 {\n  background: #bd8e2f;\n}\n\n.rating-100-6 {\n  background: #b47435;\n}\n\n.rating-100-5 {\n  background: #af7944;\n}\n\n.rating-100-4 {\n  background: #a7805b;\n}\n\n.rating-100-3 {\n  background: #a48363;\n}\n\n.rating-100-2 {\n  background: #9e8974;\n}\n\n.rating-100-1 {\n  background: #9b8c7d;\n}\n\n.rating-100-0 {\n  background: #939393;\n}\n\n.rating-5 {\n  background: #ff2f39;\n}\n\n.rating-4 {\n  background: $red1;\n}\n\n.rating-3 {\n  background: $orange1;\n}\n\n.rating-2 {\n  background: $sepia1;\n}\n\n.rating-1 {\n  background: $dark-gray5;\n}\n\n.rating-banner {\n  color: #fff;\n  display: block;\n  font-size: 1rem;\n  font-weight: bold;\n  left: -48px;\n  letter-spacing: 1px;\n  line-height: 1.6rem;\n  padding: 6px 45px;\n  position: absolute;\n  text-align: center;\n  text-size-adjust: none;\n  text-transform: uppercase;\n  top: 14px;\n  transform: rotate(-36deg);\n}\n\n.card {\n  background-color: $card-bg;\n  border-radius: 3px;\n  box-shadow: 0 0 0 1px rgba(16, 22, 26, 0.4), 0 0 0 rgba(16, 22, 26, 0),\n    0 0 0 rgba(16, 22, 26, 0);\n  padding: 20px;\n}\n\n.toast-container {\n  max-width: 350px;\n  opacity: 0.9;\n  position: fixed;\n  right: 2rem;\n  top: 4rem;\n  transition: right 0.5s;\n  z-index: 1051;\n\n  &.hidden {\n    right: -350px;\n  }\n\n  .success {\n    background-color: $success;\n  }\n\n  .danger {\n    background-color: $danger;\n  }\n\n  .warning {\n    background-color: $warning;\n  }\n\n  .toast {\n    width: 350px;\n  }\n\n  .toast-header {\n    background-color: transparent;\n    border: none;\n    color: $text-color;\n    padding: 1rem;\n    white-space: pre-wrap;\n\n    .close,\n    .expand-error-button {\n      color: $text-color;\n      text-shadow: none;\n    }\n\n    .expand-error-button {\n      opacity: 0.5;\n      padding-left: 0.25rem;\n      padding-right: 0.25rem;\n\n      &:hover {\n        opacity: 0.75;\n      }\n    }\n  }\n\n  .toast.danger .toast-header > span {\n    cursor: pointer;\n  }\n}\n\n.toast-expanded-dialog {\n  .modal-header {\n    align-items: center;\n    justify-content: start;\n\n    .fa-icon {\n      color: $danger;\n      margin-right: 0.5rem;\n    }\n  }\n}\n\n@include media-breakpoint-down(xs) {\n  .toast-container {\n    bottom: 4rem;\n    left: 50%;\n    margin-left: -175px;\n    right: unset;\n    top: unset;\n\n    transition: left 0.5s;\n\n    &.hidden {\n      left: -350px;\n    }\n  }\n}\n\n.image-input {\n  margin-bottom: 0;\n  position: relative;\n\n  input[type=\"file\"], /* FF, IE7+, chrome (except button) */\n  input[type=\"file\"]::-webkit-file-upload-button {\n    /* chromes and blink button */\n    cursor: pointer;\n  }\n\n  [type=\"file\"] {\n    display: block;\n    filter: alpha(opacity=0);\n    min-height: 100%;\n    min-width: 100%;\n    opacity: 0;\n    position: absolute;\n    right: 0;\n    text-align: right;\n    top: 0;\n  }\n}\n\n.fa-icon {\n  margin: 0 0.4rem;\n}\n\n.btn .fa-icon {\n  &:last-child:first-child {\n    margin: 0;\n  }\n}\n\n.popover-body .btn .fa-icon,\n.dropdown-item .fa-icon {\n  margin-left: 0;\n}\n\n.brand-icon {\n  padding: 3px 6px;\n\n  img {\n    height: 1.5rem;\n  }\n}\n\n.top-nav {\n  justify-content: start;\n  padding: 0.25rem 1rem;\n\n  @include media-breakpoint-down(xs) {\n    padding: 0.25rem 2rem;\n\n    @media (orientation: portrait) {\n      bottom: 0;\n      top: auto;\n    }\n  }\n  @include media-breakpoint-up(xl) {\n    height: $navbar-height;\n  }\n\n  .navbar-toggler {\n    padding: 0.5em 0;\n    text-align: center;\n    width: 3em;\n\n    svg {\n      margin: auto;\n    }\n  }\n\n  .navbar-collapse {\n    justify-content: space-between;\n    max-height: calc(100vh - 4rem);\n\n    @include media-breakpoint-down(xs) {\n      @media (orientation: landscape) {\n        overflow-y: scroll;\n      }\n    }\n\n    .navbar-nav {\n      flex-direction: row;\n      flex-wrap: wrap;\n      justify-content: center;\n      padding-bottom: 0.5rem;\n\n      @include media-breakpoint-up(xl) {\n        flex-wrap: nowrap;\n        padding-bottom: 0;\n      }\n\n      &:last-child {\n        display: none;\n      }\n\n      @include media-breakpoint-down(xs) {\n        &:last-child {\n          display: flex;\n          min-height: 3rem;\n        }\n      }\n    }\n  }\n\n  .navbar-buttons .btn {\n    align-items: center;\n    display: flex;\n  }\n\n  @include media-breakpoint-down(xs) {\n    .navbar-buttons .nav-utility {\n      display: none;\n    }\n  }\n\n  .nav-link {\n    padding: 0;\n  }\n\n  .fa-icon {\n    @include media-breakpoint-down(xs) {\n      margin: 0;\n    }\n\n    &.nav-menu-icon {\n      @include media-breakpoint-down(lg) {\n        height: 100%;\n        max-height: min(10vw, 55px);\n        width: 100%;\n      }\n    }\n  }\n\n  .btn {\n    white-space: nowrap;\n  }\n\n  @include media-breakpoint-down(lg) {\n    .navbar-brand {\n      margin-left: -8px;\n    }\n\n    .navbar-buttons {\n      margin: 0 -8px;\n    }\n\n    .btn {\n      padding: 6px 12px;\n    }\n  }\n}\n\n.donate {\n  align-items: center;\n  display: flex;\n  height: 100%;\n\n  svg {\n    color: #ff7373;\n\n    @include media-breakpoint-down(xs) {\n      margin: 0;\n    }\n  }\n}\n\n.error-message {\n  white-space: pre-wrap;\n}\n\n.btn-toolbar .form-control {\n  width: inherit;\n}\n\n.stats {\n  &-element {\n    flex-grow: 1;\n    margin: auto 0.5rem;\n  }\n\n  .title {\n    font-size: 3vw;\n    text-align: center;\n\n    @media (max-width: 576px) {\n      font-size: 16px;\n    }\n  }\n\n  .heading {\n    text-align: center;\n    text-transform: uppercase;\n  }\n}\n\n$detailTabWidth: calc(100% / 3);\n\n.content-container,\n.details-tab {\n  padding-left: 15px;\n  padding-right: 15px;\n  position: relative;\n  width: 100%;\n}\n\n@media (min-width: 768px) {\n  .details-tab {\n    flex: 0 0 $detailTabWidth;\n    max-width: $detailTabWidth;\n  }\n\n  .content-container {\n    flex: 0 0 calc(100% - #{$detailTabWidth});\n    max-width: calc(100% - #{$detailTabWidth});\n  }\n}\n@media (min-width: 1200px) {\n  .details-tab {\n    flex: 0 0 $detailTabWidth;\n    max-height: calc(100vh - 4rem);\n    max-width: $detailTabWidth;\n    overflow: auto;\n\n    &.collapsed {\n      display: none;\n    }\n  }\n\n  .details-divider {\n    flex: 0 0 15px;\n    height: calc(100vh - 4rem);\n    max-width: 15px;\n\n    button {\n      background-color: transparent;\n      border: 0;\n      color: $link-color;\n      cursor: pointer;\n      font-size: 10px;\n      font-weight: 800;\n      height: 100%;\n      line-height: 100%;\n      padding: 0;\n      text-align: center;\n      width: 100%;\n\n      &:active:not(:hover),\n      &:focus:not(:hover) {\n        background-color: transparent;\n        border: 0;\n        box-shadow: none;\n      }\n    }\n  }\n\n  .content-container {\n    flex: 0 0 calc(100% - #{$detailTabWidth} - 15px);\n    max-height: calc(100vh - 4rem);\n    max-width: calc(100% - #{$detailTabWidth} - 15px);\n    overflow: auto;\n\n    &.expanded {\n      flex: 0 0 calc(100% - 15px);\n      max-width: calc(100% - 15px);\n    }\n  }\n}\n\n.pre {\n  white-space: pre-line;\n}\n\n.markdown {\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6 {\n    margin-bottom: 16px;\n    margin-top: 24px;\n  }\n\n  & > h1:first-child,\n  & > h2:first-child,\n  & > h3:first-child,\n  & > h4:first-child,\n  & > h5:first-child,\n  & > h6:first-child {\n    margin-top: 0;\n  }\n\n  h1,\n  h2 {\n    padding-bottom: 0.3em;\n  }\n\n  h1 {\n    font-size: 2rem;\n  }\n\n  h2 {\n    font-size: 1.83rem;\n  }\n\n  h3 {\n    font-size: 1.67rem;\n  }\n\n  h4 {\n    font-size: 1.5rem;\n  }\n\n  h5 {\n    font-size: 1.33rem;\n  }\n\n  code {\n    background-color: darken($color: $card-bg, $amount: 3);\n    color: $text-color;\n    padding: 0.2em 0.4em;\n  }\n\n  blockquote {\n    p {\n      margin-bottom: 0;\n      vertical-align: middle;\n    }\n  }\n\n  blockquote,\n  pre {\n    code {\n      padding: 0;\n    }\n\n    background-color: darken($color: $card-bg, $amount: 3);\n    border-radius: 3px;\n    padding: 16px;\n  }\n\n  pre {\n    font-size: 85%;\n    line-height: 1.45;\n    overflow: auto;\n  }\n\n  table {\n    display: block;\n    margin-bottom: 16px;\n    overflow: auto;\n    width: 100%;\n\n    tr {\n      border-top: 1px solid darken($color: #201d1a, $amount: 3);\n    }\n\n    tr:nth-child(2n) {\n      background-color: darken($color: $card-bg, $amount: 2);\n    }\n\n    td,\n    th {\n      border: 1px solid darken($color: #201d1a, $amount: 3);\n      padding: 6px 13px;\n    }\n  }\n}\n\n.no-focus:focus {\n  background-color: inherit;\n  border-color: inherit;\n  box-shadow: inherit;\n}\n\n.button-group-above {\n  .btn:first-child {\n    border-bottom-left-radius: 0;\n  }\n\n  .btn:last-child {\n    border-bottom-right-radius: 0;\n  }\n}\n\n// workaround for dropdown button in button group\n.btn-group > .dropdown:not(:last-child) > .btn {\n  border-bottom-right-radius: 0;\n  border-top-right-radius: 0;\n}\n\n.btn-group > .dropdown:not(:first-child) > .btn {\n  border-bottom-left-radius: 0;\n  border-top-left-radius: 0;\n}\n\ndl.details-list {\n  display: grid;\n  grid-column-gap: 10px;\n  grid-template-columns: minmax(16.67%, auto) 1fr;\n}\n\n// middle align checkboxes\n.form-group .form-check .form-check-input {\n  vertical-align: middle;\n}\n\n.invalid-feedback {\n  display: block;\n  white-space: pre-wrap;\n\n  &:empty {\n    display: none;\n  }\n}\n\n// Fix Safari styling on dropdowns\nselect {\n  -webkit-appearance: none;\n  appearance: none;\n  background: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4.95 10' fill='%23fff'><polygon points='1.41 4.67 2.48 3.18 3.54 4.67 1.41 4.67'/><polygon points='3.54 5.33 2.48 6.82 1.41 5.33 3.54 5.33'/></svg>\")\n    no-repeat right 2px center;\n}\n\n.left-spacing {\n  margin-left: 0.5em;\n}\n\n.primary-file {\n  font-weight: bold;\n}\n\n// ensure rating number editing doesn't resize column\n.table-list .rating-number {\n  width: 6rem;\n}\n\n.modal-body {\n  max-height: calc(100vh - 12rem);\n  overflow-y: auto;\n  padding-right: 1.5rem;\n}\n\n// Fix descenders clipping in line-height #6047\nh3 .TruncatedText {\n  line-height: 1.5;\n}\n\n// Troubleshooting Mode overlay banner\n.troubleshooting-mode-overlay {\n  border: 5px solid $danger;\n  bottom: 0;\n  left: 0;\n  opacity: 0.75;\n  pointer-events: none;\n  position: fixed;\n  right: 0;\n  top: 0;\n  z-index: 1040;\n\n  .troubleshooting-mode-alert {\n    align-items: baseline;\n    border-radius: 0;\n    bottom: 0.5rem;\n    display: inline-flex;\n    margin: 0;\n    position: fixed;\n    right: 0.5rem;\n\n    @include media-breakpoint-down(xs) {\n      @media (orientation: portrait) {\n        bottom: $navbar-height;\n\n        & > span {\n          font-size: 0.75rem;\n        }\n      }\n    }\n  }\n\n  .btn {\n    pointer-events: auto;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/index.tsx",
    "content": "import { ApolloProvider } from \"@apollo/client\";\nimport ReactDOM from \"react-dom\";\nimport { BrowserRouter } from \"react-router-dom\";\nimport { App } from \"./App\";\nimport { getClient } from \"./core/StashService\";\nimport { baseURL, getPlatformURL } from \"./core/createClient\";\nimport \"./index.scss\";\nimport * as serviceWorker from \"./serviceWorker\";\n\nReactDOM.render(\n  <>\n    <link\n      rel=\"stylesheet\"\n      type=\"text/css\"\n      href={getPlatformURL(\"css\").toString()}\n    />\n    <BrowserRouter basename={baseURL}>\n      <ApolloProvider client={getClient()}>\n        <App />\n      </ApolloProvider>\n    </BrowserRouter>\n  </>,\n  document.getElementById(\"root\")\n);\n\nconst script = document.createElement(\"script\");\nscript.src = getPlatformURL(\"javascript\").toString();\ndocument.body.appendChild(script);\n\n// If you want your app to work offline and load faster, you can change\n// unregister() to register() below. Note this comes with some pitfalls.\n// Learn more about service workers: http://bit.ly/CRA-PWA\nserviceWorker.unregister();\n"
  },
  {
    "path": "ui/v2.5/src/locales/README.md",
    "content": "Use `en-GB.json` by default. This should have _all_ message IDs in it. Only add to other json files if the value is different to `en-GB` since it will fall back to use it if the message ID is not found in the chosen language.\n\nTry to keep message IDs in alphabetical order for ease of reference.\n\n# Merging translations from Codeberg Weblate\n\n1. (**first time only**) Add remote for the Codeberg Weblate repository:\n```bash\ngit remote add weblate_codeberg https://translate.codeberg.org/git/stash/stash/\n```\n2. (optional) Lock the Weblate repository.\n3. Fetch the Weblate repository:\n```bash\ngit fetch weblate_codeberg develop\n```\n4. Create and/or checkout a branch to hold the Weblate translations:\n```bash\ngit checkout -b codeberg_weblate\n```\n5. Reset the branch to the Weblate repository's `develop` branch:\n```bash\ngit reset --hard weblate_codeberg/develop\n```\n6. Push the branch to your github account:\n```bash\ngit push origin codeberg_weblate\n```\n7. Create a pull request to merge the Weblate translations into the main repository.\n"
  },
  {
    "path": "ui/v2.5/src/locales/af-ZA.json",
    "content": "{\n    \"actions\": {\n        \"anonymise\": \"Anonimiseer\",\n        \"apply\": \"Pas Toe\",\n        \"assign_stashid_to_parent_studio\": \"Ken Stash ID aan bestaande ouerateljee toe en opdateer metadata\",\n        \"browse_for_image\": \"Blaai vir beelde…\",\n        \"choose_date\": \"Kies 'n datum\",\n        \"clean\": \"Maak Skoon\",\n        \"clean_generated\": \"Maak gegenereerde lêers skoon\",\n        \"clear_back_image\": \"Verwyder agterste beeld\",\n        \"clear_front_image\": \"Verwyder voorste beeld\",\n        \"clear_image\": \"Verwyder beeld\",\n        \"close\": \"Maak toe\",\n        \"confirm\": \"Bevestig\",\n        \"continue\": \"Gaan voort\",\n        \"copy_to_clipboard\": \"Kopieer na knipbord\",\n        \"create\": \"Skep\",\n        \"create_chapters\": \"Skep hoofstuk\",\n        \"create_entity\": \"Skep {entityType}\",\n        \"create_parent_studio\": \"Skep ouerateljee\",\n        \"delete\": \"Verwyder\",\n        \"delete_entity\": \"Verwyder {entityType}\",\n        \"delete_file_and_funscript\": \"Verwyder lêer (en funscript)\",\n        \"delete_generated_supporting_files\": \"Verwyder gegenereerde ondersteunende lêers\",\n        \"disable\": \"Deaktiveer\",\n        \"disallow\": \"Verbied\",\n        \"download\": \"Laai af\",\n        \"download_anonymised\": \"Laai anoniem af\",\n        \"edit\": \"Redigeer\",\n        \"edit_entity\": \"Redigeer {entityType}\",\n        \"export\": \"Voer uit\",\n        \"export_all\": \"Voer alles uit…\",\n        \"find\": \"Vind\",\n        \"generate\": \"Genereer\",\n        \"allow\": \"Laat Toe\",\n        \"allow_temporarily\": \"Laat tydelik toe\",\n        \"clear_date_data\": \"Verwyder datum data\",\n        \"cancel\": \"Kanselleer\",\n        \"create_marker\": \"Skep Merker\",\n        \"delete_file\": \"Verwyder lêer\",\n        \"enable\": \"Aktiveer\",\n        \"from_url\": \"Van URL…\",\n        \"full_export\": \"Volle uitvoer\",\n        \"full_import\": \"Volle Invoer\"\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/ar.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"إضافة\",\n        \"add_directory\": \"إضافة مسار\",\n        \"add_entity\": \"إضافة {entityType}\",\n        \"add_manual_date\": \"إضافة تاريخ يدوي\",\n        \"add_sub_groups\": \"إضافة مجموعة فرعية\",\n        \"add_o\": \"إضافة فتح\",\n        \"add_play\": \"إضافة تشغيل\",\n        \"add_stash_id\": \"إضافة معرف ستاش\",\n        \"add_to_entity\": \"إضافة إلى {entityType}\",\n        \"allow\": \"سماح\",\n        \"allow_temporarily\": \"سماح مؤقت\",\n        \"anonymise\": \"إخفاء الهوية\",\n        \"apply\": \"تطبيق\",\n        \"assign_stashid_to_parent_studio\": \"إضافة معرف ستاش إلى ستوديو رئيسي موجود و تحديث البيانات\",\n        \"auto_tag\": \"وسم تلقاني\",\n        \"backup\": \"نسخة احتياطية\",\n        \"browse_for_image\": \"استعرض صورة…\",\n        \"cancel\": \"إلغاء\",\n        \"choose_date\": \"اختر تاريخ\",\n        \"clean\": \"تنظيف\",\n        \"clean_generated\": \"تنظيف الملفات التي تم انشاؤها\",\n        \"clear\": \"مسح\",\n        \"clear_back_image\": \"مسح الصورة الخلفية\",\n        \"clear_date_data\": \"مسح بيانات التاريخ\",\n        \"clear_front_image\": \"مسح الصورة الامامية\",\n        \"clear_image\": \"مسح الصورة\",\n        \"close\": \"إغلاق\",\n        \"confirm\": \"تأكيد\",\n        \"continue\": \"متابعة\",\n        \"copy_to_clipboard\": \"نسخ إلى الحافظة\",\n        \"create\": \"إنشاء\",\n        \"create_chapters\": \"إنشاء فصل\",\n        \"create_entity\": \"إنشاء {entityType}\",\n        \"create_marker\": \"إنشاء علامة\",\n        \"create_new\": \"إنشاء جديد\",\n        \"create_parent_studio\": \"إنشاء ستوديو رئيسي\",\n        \"created_entity\": \"إنشاء {entity_type}: {entity_name}\",\n        \"customise\": \"تخصيص\",\n        \"delete\": \"حذف\",\n        \"delete_entity\": \"حذف {entityType}\",\n        \"delete_file\": \"حذف الملف\",\n        \"delete_file_and_funscript\": \"حذف الملف (و funscript)\",\n        \"delete_generated_supporting_files\": \"حذف الملفات الداعمة المنشأة\",\n        \"disable\": \"تعطيل\",\n        \"disallow\": \"إلغاء السماح\",\n        \"download\": \"تحميل\",\n        \"download_anonymised\": \"تحميل خفي\",\n        \"download_backup\": \"تحميل نسخة احتياطية\",\n        \"edit\": \"تعديل\",\n        \"edit_entity\": \"تعديل {entityType}\",\n        \"enable\": \"تفعيل\",\n        \"encoding_image\": \"جارِ ترميز صورة…\",\n        \"exclude_lowercase\": \"استبعاد\",\n        \"export\": \"تصدير\",\n        \"export_all\": \"تصدير الكل…\",\n        \"find\": \"عثور\",\n        \"finish\": \"انتهاء\",\n        \"from_clipboard\": \"من الحافظة\",\n        \"from_file\": \"من ملف…\",\n        \"from_url\": \"من رابط…\",\n        \"full_export\": \"تصدير كامل\",\n        \"full_import\": \"استيراد كامل\",\n        \"generate\": \"توليد\",\n        \"generate_thumb_default\": \"توليد صورة مصغرة افتراضية\",\n        \"generate_thumb_from_current\": \"توليد صورة مصغرة افتراضية من اللقطة الحالية\",\n        \"hash_migration\": \"دمج الترميز\",\n        \"hide\": \"إخفاء\",\n        \"hide_configuration\": \"إخفاء الإعدادات\",\n        \"identify\": \"تعرف\",\n        \"ignore\": \"تجاهل\",\n        \"import\": \"استيراد…\",\n        \"import_from_file\": \"استيراد من ملف\",\n        \"load\": \"حمل\",\n        \"load_filter\": \"تحميل فلتر\",\n        \"logout\": \"تسجيل الخروج\",\n        \"make_primary\": \"اجعلها أساسية\",\n        \"merge\": \"دمج\",\n        \"migrate_blobs\": \"نقل الكتل\",\n        \"migrate_scene_screenshots\": \"نقل لقطات الشاشة من المشهد\",\n        \"next_action\": \"التالي\",\n        \"not_running\": \"لا يعمل\",\n        \"open_in_external_player\": \"فتح في مشغل خارجي\",\n        \"open_random\": \"فتح عشوائي\",\n        \"optimise_database\": \"تحسين قاعدة البيانات\",\n        \"overwrite\": \"استبدال\",\n        \"play\": \"تشغيل\",\n        \"play_random\": \"تشغيل عشوائي\",\n        \"play_selected\": \"تشغيل المحدد\",\n        \"preview\": \"معاينة\",\n        \"previous_action\": \"رجوع\",\n        \"reassign\": \"إعادة تعيين\",\n        \"refresh\": \"تحديث\",\n        \"reload\": \"اعادة تحميل\",\n        \"reload_plugins\": \"إعادة تحميل الإضافات\",\n        \"reload_scrapers\": \"إعادة تحميل مستخرجات بيانات المشهد\",\n        \"remove\": \"إزالة\",\n        \"remove_date\": \"إزالة التاريخ\",\n        \"remove_from_containing_group\": \"إزالة من المجموعة\",\n        \"remove_from_gallery\": \"إزالة من المعرض\",\n        \"rename_gen_files\": \"إعادة تسمية الملفات التي تم إنشاؤها\",\n        \"reveal_in_file_manager\": \"إظهار في مدير الملفات\",\n        \"rescan\": \"إعادة المسح\",\n        \"reset_play_duration\": \"إعادة ضبط مدة التشغيل\",\n        \"reset_resume_time\": \"إعادة ضبط وقت الاستئناف\",\n        \"reset_cover\": \"استعادة الغطاء الافتراضي\",\n        \"reshuffle\": \"إعادة ترتيب\",\n        \"running\": \"جارِ\",\n        \"save\": \"حفظ\",\n        \"save_and_new\": \"حفظ وجديد\",\n        \"save_delete_settings\": \"استخدام هذه الخيارات افتراضيًا عند الحذف\",\n        \"save_filter\": \"حفظ الفلتر\",\n        \"scan\": \"تفحص\",\n        \"scrape\": \"استخرج البيانات\",\n        \"scrape_query\": \"استعلام استخراج البيانات\",\n        \"scrape_scene_fragment\": \"استراج بيانات بشكل جزئي\",\n        \"scrape_with\": \"استخراج البيانات باستخدام…\",\n        \"search\": \"بحث\",\n        \"select_all\": \"تحديد الكل\",\n        \"select_directory\": \"تحديد الدليل\",\n        \"select_entity\": \"حدد {entityType}\",\n        \"select_folders\": \"تحديد المجلدات\",\n        \"select_none\": \"اختيار لا شيء\",\n        \"invert_selection\": \"عكس التحديد\",\n        \"selective_auto_tag\": \"علامة تلقائية انتقائية\",\n        \"selective_clean\": \"التنظيف الانتقائي\",\n        \"selective_generate\": \"توليد انتقائي\",\n        \"selective_scan\": \"المسح الانتقائي\",\n        \"set_as_default\": \"تعيين كافتراضي\",\n        \"set_back_image\": \"صورة خلفية…\",\n        \"set_cover\": \"تعيين كغلاف\",\n        \"set_front_image\": \"صورة أمامية…\",\n        \"set_image\": \"تعيين الصورة…\",\n        \"show\": \"إظهار\",\n        \"show_configuration\": \"إظهار الاعدادات\",\n        \"show_results\": \"عرض النتائج\",\n        \"show_count_results\": \"إظهار {count} النتائج\",\n        \"sidebar\": {\n            \"close\": \"إغلاق الشريط الجانبي\",\n            \"open\": \"فتح الشريط الجانبي\",\n            \"toggle\": \"تبديل الشريط الجانبي\"\n        },\n        \"skip\": \"تخطى\",\n        \"split\": \"انقسم\",\n        \"stop\": \"إيقاف\",\n        \"submit\": \"إرسال\",\n        \"submit_stash_box\": \"ارسال إلى Stash-Box\",\n        \"submit_update\": \"إرسال التحديث\",\n        \"swap\": \"تبديل\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"هل أنت متأكد من رغبتك في التنظيف؟ سيؤدي هذا إلى حذف معلومات قاعدة البيانات والمحتوى المُنشأ لجميع المشاهد والمعارض التي لم تعد موجودة في نظام الملفات.\",\n            \"dry_mode_selected\": \"تم اختيار الوضع الجاف. لن يتم حذف أي بيانات فعلياً، بل سيتم تسجيلها فقط.\",\n            \"import_warning\": \"هل أنت متأكد من رغبتك في الاستيراد؟ سيؤدي هذا إلى حذف قاعدة البيانات وإعادة الاستيراد من بياناتك الوصفية المصدرة.\"\n        },\n        \"temp_disable\": \"تعطيل مؤقت…\",\n        \"temp_enable\": \"تفعيل مؤقتًا…\",\n        \"unset\": \"غير محدد\",\n        \"use_default\": \"استخدم الإعدادات الافتراضية\",\n        \"view_history\": \"عرض السجل\",\n        \"view_random\": \"عرض عشوائي\",\n        \"create_parent_tag\": \"إنشاء علامة رئيسية\"\n    },\n    \"actions_name\": \"الإجراءات\",\n    \"age\": \"العمر\",\n    \"age_on_date\": \"{age} في مرحلة الإنتاج\",\n    \"aliases\": \"الأسماء المستعارة\",\n    \"all\": \"الكل\",\n    \"also_known_as\": \"يُعرف أيضًا باسم\",\n    \"appears_with\": \"يظهر مع\",\n    \"ascending\": \"تصاعدي\",\n    \"audio_codec\": \"ترميز الصوت\",\n    \"average_resolution\": \"متوسط الدقة\",\n    \"between_and\": \"أيضا\",\n    \"birth_year\": \"سنة الميلاد\",\n    \"birthdate\": \"تاريخ الميلاد\",\n    \"bitrate\": \"معدل البت\",\n    \"blobs_storage_type\": {\n        \"database\": \"قاعدة البيانات\",\n        \"filesystem\": \"نظام الملفات\"\n    },\n    \"captions\": \"التعليقات التوضيحية\",\n    \"career_end\": \"نهاية المسيرة المهنية\",\n    \"career_length\": \"طول المسيرة المهنية\",\n    \"career_start\": \"بداية المسيرة المهنية\",\n    \"chapters\": \"الفصول\",\n    \"circumcised\": \"مختون\",\n    \"circumcised_types\": {\n        \"CUT\": \"قص مختون\",\n        \"UNCUT\": \"غير مختون\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"مثال stash-box فعال:\",\n            \"blacklist_desc\": \"تُستثنى عناصر القائمة السوداء من الاستعلامات. لاحظ أنها تعبيرات نمطية غير حساسة لحالة الأحرف. يجب وضع علامة (\\\\) قبل بعض الأحرف: {chars_require_escape}\",\n            \"blacklist_label\": \"القائمة السوداء\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"عنصر مكرر في القائمة السوداء\"\n            },\n            \"mark_organized_desc\": \"تحديد المشهد على أنه منظم فور النقر على زر الحفظ.\",\n            \"mark_organized_label\": \"وضع علامة \\\"منظم\\\" عند الحفظ\",\n            \"performer_genders\": {\n                \"heading\": \"جنس الفنان\",\n                \"description\": \"سيتم عرض الفنانين من هذه الأجناس عند تصنيف المشاهد.\"\n            },\n            \"query_mode_auto\": \"تلقائي\",\n            \"query_mode_auto_desc\": \"يستخدم البيانات الوصفية إن وجدت، أو اسم الملف\",\n            \"query_mode_dir\": \"الدليل\",\n            \"query_mode_dir_desc\": \"يستخدم فقط الدليل الرئيسي لملف الفيديو\",\n            \"query_mode_filename\": \"اسم الملف\",\n            \"query_mode_filename_desc\": \"يستخدم اسم الملف فقط\",\n            \"query_mode_label\": \"وضع الاستعلام\",\n            \"query_mode_metadata\": \"البيانات الوصفية\",\n            \"query_mode_metadata_desc\": \"يستخدم البيانات الوصفية فقط\",\n            \"query_mode_path\": \"مسار\",\n            \"query_mode_path_desc\": \"يستخدم مسار الملف بالكامل\",\n            \"set_cover_desc\": \"استبدل غلاف المشهد إذا تم العثور على واحد.\",\n            \"set_cover_label\": \"صورة غلاف المشهد المحدد\",\n            \"set_tag_desc\": \"إرفاق العلامات بالمشهد، إما عن طريق الكتابة فوق العلامات الموجودة في المشهد أو دمجها معها.\",\n            \"set_tag_label\": \"تعيين العلامات\",\n            \"source\": \"المصدر\"\n        },\n        \"noun_query\": \"استفسار\",\n        \"results\": {\n            \"duration_off\": \"مدة الخطأ لا تقل عن {number} ث\",\n            \"duration_unknown\": \"المدة غير معروفة\",\n            \"fp_found\": \"{fpCount, plural, =0 {No new fingerprint matches found} other {# new fingerprint matches found}}\",\n            \"fp_matches\": \"المدة متطابقة\",\n            \"fp_matches_multi\": \"مدة مطابقة {matchCount}/{durationsLength} بصمات\",\n            \"hash_matches\": \"{hash_type} متطابق\",\n            \"match_failed_already_tagged\": \"تم وضع علامة على المشهد بالفعل\",\n            \"match_failed_no_result\": \"لم يتم العثور على نتائج\",\n            \"match_success\": \"تم وضع علامة على المشهد بنجاح\",\n            \"phash_matches\": \"{count} تطابق التجزئات\",\n            \"unnamed\": \"غير مسمى\"\n        },\n        \"verb_add_as_alias\": \"أضف الاسم المستخرج كاسم مستعار\",\n        \"verb_link_existing\": \"ربط إلى الموجود\",\n        \"verb_match_fp\": \"مطابقة بصمات\",\n        \"verb_match_tag\": \"علامة المطابقة\",\n        \"verb_matched\": \"متطابق\",\n        \"verb_scrape_all\": \"استخرج بيانات الكل\",\n        \"verb_scrape_selected\": \"استخرج بيانات المحدد\",\n        \"verb_submit_fp\": \"ارسال {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}\",\n        \"verb_toggle_unmatched\": \"{toggle} مشاهد لا مثيل لها\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"بناء التجزئة:\",\n            \"build_time\": \"وقت البناء:\",\n            \"check_for_new_version\": \"تحقق من وجود إصدار جديد\",\n            \"latest_version\": \"أحدث إصدار\",\n            \"latest_version_build_hash\": \"أحدث إصدار من رقم البناء:\",\n            \"release_date\": \"تاريخ الاصدار:\",\n            \"stash_discord\": \"انضم إلى {url} قناتنا\",\n            \"stash_home\": \"صفحة ستاش {url}\",\n            \"stash_open_collective\": \"ادعمونا من خلال {url}\",\n            \"stash_wiki\": \"موقع ستاش {url}\",\n            \"version\": \"إصدار\",\n            \"new_version_notice\": \"[NEW]\"\n        },\n        \"advanced_mode\": \"الوضع المتقدم\",\n        \"application_paths\": {\n            \"heading\": \"مسارات التطبيق\"\n        },\n        \"categories\": {\n            \"about\": \"حول\",\n            \"changelog\": \"سجل التغييرات\",\n            \"interface\": \"واجهة المستخدم\",\n            \"logs\": \"سجلات\",\n            \"metadata_providers\": \"موفرو البيانات الوصفية\",\n            \"plugins\": \"الإضافات\",\n            \"scraping\": \"استخراج البيانات\",\n            \"security\": \"حماية\",\n            \"services\": \"خدمات\",\n            \"system\": \"نظام\",\n            \"tasks\": \"المهام\",\n            \"tools\": \"أدوات\"\n        },\n        \"changelog\": {\n            \"header\": \"سجل التغييرات\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"سماح {tempIP}\",\n            \"allowed_ip_addresses\": \"عناوين IP المسموح بها\",\n            \"allowed_ip_temporarily\": \"عنوان IP مسموح به مؤقتًا\",\n            \"default_ip_whitelist\": \"قائمة عناوين IP المسموح بها الافتراضية\",\n            \"default_ip_whitelist_desc\": \"تسمح عناوين IP الافتراضية بالوصول إلى DLNA. استخدم {wildcard} للسماح بجميع عناوين IP.\",\n            \"disabled_dlna_temporarily\": \"تعطيل DLNA مؤقتًا\",\n            \"disallowed_ip\": \"عنوان IP غير مسموح به\",\n            \"enabled_by_default\": \"مُفعّل افتراضياً\",\n            \"enabled_dlna_temporarily\": \"تفعيل DLNA مؤقتًا\",\n            \"network_interfaces\": \"واجهات\",\n            \"network_interfaces_desc\": \"واجهات لعرض خادم DLNA عليها. القائمة الفارغة تؤدي إلى تشغيله على جميع الواجهات. يتطلب الأمر إعادة تشغيل DLNA بعد التغيير.\",\n            \"recent_ip_addresses\": \"عناوين IP الحديثة\",\n            \"server_display_name\": \"اسم عرض الخادم\",\n            \"server_display_name_desc\": \"اسم العرض لخادم DLNA. الوضع الافتراضي هو {server_name} إذا كان فارغًا.\",\n            \"server_port\": \"منفذ الخادم\",\n            \"server_port_desc\": \"المنفذ الذي سيتم تشغيل خادم DLNA عليه. يتطلب إعادة تشغيل DLNA بعد التغيير.\",\n            \"successfully_cancelled_temporary_behaviour\": \"تم إلغاء السلوك المؤقت بنجاح\",\n            \"until_restart\": \"حتى إعادة التشغيل\",\n            \"video_sort_order\": \"ترتيب فرز الفيديو الافتراضي\",\n            \"video_sort_order_desc\": \"ترتيب الفيديوهات افتراضياً.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"مفتاح API\",\n                \"api_key_desc\": \"مفتاح API للأنظمة الخارجية. مطلوب فقط عند إعداد اسم المستخدم/كلمة المرور. يجب حفظ اسم المستخدم قبل إنشاء مفتاح API.\",\n                \"authentication\": \"المصادقة\",\n                \"clear_api_key\": \"ازالة مفتاح API\",\n                \"credentials\": {\n                    \"description\": \"بيانات اعتماد لتقييد الوصول إلى ستاش.\",\n                    \"heading\": \"الاعتمادات\"\n                },\n                \"generate_api_key\": \"توليد مفتاح API\",\n                \"log_file\": \"ملف السجل\",\n                \"log_file_desc\": \"مسار الملف المراد إخراج سجلات النظام إليه. اتركه فارغًا لتعطيل تسجيل الملفات. يتطلب إعادة التشغيل.\",\n                \"log_http\": \"تسجيل الوصول إلى HTTP\",\n                \"log_http_desc\": \"تسجيل الوصول إلى الطرفية عبر بروتوكول HTTP. يتطلب إعادة التشغيل.\",\n                \"log_to_terminal\": \"سجل الدخول إلى الطرفية\",\n                \"log_to_terminal_desc\": \"يُسجّل في الطرفية بالإضافة إلى ملف. هذا صحيح دائمًا إذا كان تسجيل الملفات مُعطّلاً. يتطلب إعادة التشغيل.\",\n                \"log_file_max_size\": \"أقصى حجم للسجل\",\n                \"log_file_max_size_desc\": \"الحد الأقصى لحجم ملف السجل بالميغابايت قبل ضغطه. 0 ميغابايت معطل. يتطلب إعادة التشغيل.\",\n                \"maximum_session_age\": \"الحد الأقصى لمدة الجلسة\",\n                \"maximum_session_age_desc\": \"أقصى مدة خمول قبل انتهاء صلاحية جلسة تسجيل الدخول، بالثواني. يتطلب إعادة التشغيل.\",\n                \"password\": \"كلمة المرور\",\n                \"password_desc\": \"كلمة المرور للوصول إلى ستاش. اترك هذا الحقل فارغًا لتعطيل مصادقة المستخدم\",\n                \"stash-box_integration\": \"تكامل Stash-box\",\n                \"username\": \"اسم المستخدم\",\n                \"username_desc\": \"اسم المستخدم للوصول إلى Stash. اتركه فارغًا لتعطيل مصادقة المستخدم\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"موقع المجلد لنسخ ملفات قاعدة بيانات SQLite الاحتياطية.\",\n                \"heading\": \"مسار دليل النسخ الاحتياطي\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"المسار الذي ستُنقل إليه الملفات المحذوفة بدلاً من حذفها نهائياً. اتركه فارغاً لحذف الملفات نهائياً.\",\n                \"heading\": \"مسار المهملات\"\n            },\n            \"blobs_path\": {\n                \"description\": \"مكان تخزين البيانات الثنائية في نظام الملفات. ينطبق هذا فقط عند استخدام نوع تخزين الكائنات الثنائية الكبيرة (blob) في نظام الملفات. تحذير: يتطلب تغيير هذا نقل البيانات الموجودة يدويًا.\",\n                \"heading\": \"مسار نظام ملفات البيانات الثنائية\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"أين يتم تخزين البيانات الثنائية مثل صور أغلفة المشاهد، وصور الفنانين، وصور الاستوديو، وصور الوسوم؟ بعد تغيير هذه القيمة، يجب نقل البيانات الموجودة باستخدام مهام نقل البيانات الثنائية. راجع صفحة المهام للاطلاع على تفاصيل النقل.\",\n                \"heading\": \"نوع تخزين البيانات الثنائية\"\n            },\n            \"cache_location\": \"موقع مجلد ذاكرة التخزين المؤقت. مطلوب في حالة البث باستخدام HLS (كما هو الحال على أجهزة Apple) أو DASH.\",\n            \"cache_path_head\": \"مسار التخزين المؤقت\",\n            \"calculate_md5_and_ohash_desc\": \"احسب مجموع التحقق MD5 بالإضافة إلى oshash. سيؤدي التفعيل إلى إبطاء عمليات المسح الأولية. يجب ضبط تجزئة اسم الملف على oshash لتعطيل حساب MD5.\",\n            \"calculate_md5_and_ohash_label\": \"احسب قيمة MD5 للفيديوهات\",\n            \"check_for_insecure_certificates\": \"تحقق من وجود شهادات غير آمنة\",\n            \"check_for_insecure_certificates_desc\": \"تستخدم بعض المواقع شهادات SSL غير آمنة. عند إلغاء تحديد هذا الخيار، يتجاوز برنامج استخراج البيانات فحص الشهادات غير الآمنة ويسمح باستخراج البيانات من تلك المواقع. إذا واجهت خطأً متعلقًا بالشهادة أثناء استخراج البيانات، فقم بإلغاء تحديد هذا الخيار.\",\n            \"chrome_cdp_path\": \"مسار CDP لمتصفح Chrome\",\n            \"chrome_cdp_path_desc\": \"مسار الملف إلى ملف Chrome القابل للتنفيذ، أو عنوان بعيد (يبدأ بـ http:// أو https://، على سبيل المثال http://localhost:9222/json/version) إلى مثيل Chrome.\",\n            \"create_galleries_from_folders_desc\": \"إذا كان هذا الخيار صحيحًا، فسيتم إنشاء معارض الصور من المجلدات التي تحتوي على الصور افتراضيًا. أنشئ ملفًا باسم .forcegallery أو .nogallery في مجلد لتجاوز هذا الإعداد.\",\n            \"create_galleries_from_folders_label\": \"أنشئ معارض من مجلدات تحتوي على صور\",\n            \"database\": \"قاعدة البيانات\",\n            \"db_path_head\": \"مسار قاعدة البيانات\",\n            \"directory_locations_to_your_content\": \"مواقع الدليل لمحتواك\",\n            \"excluded_image_gallery_patterns_desc\": \"التعبيرات النمطية لملفات/مسارات الصور والمعرض المراد استبعادها من الفحص وإضافتها إلى مهام التنظيف.\",\n            \"excluded_image_gallery_patterns_head\": \"أنماط الصور/المعارض المستبعدة\",\n            \"excluded_video_patterns_desc\": \"التعبيرات النمطية لملفات/مسارات الفيديو المراد استبعادها من الفحص وإضافتها إلى مهام التنظيف.\",\n            \"excluded_video_patterns_head\": \"أنماط الفيديو المستبعدة\",\n            \"ffmpeg\": {\n                \"download_ffmpeg\": {\n                    \"description\": \"تنزيل FFmpeg إلى دليل التكوين ومسح مسارات ffmpeg و ffprobe التي يجب حلها من دليل التكوين.\",\n                    \"heading\": \"تنزيل FFmpeg\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"مسار ملف ffmpeg التنفيذي (وليس المجلد فقط). إذا كان فارغًا، فسيتم تحديد مسار ffmpeg من بيئة النظام عبر متغير البيئة $PATH، أو من دليل الإعدادات، أو من $HOME/.stash.\",\n                    \"heading\": \"مسار ملف FFmpeg القابل للتنفيذ\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"مسار ملف ffprobe القابل للتنفيذ (وليس المجلد فقط). إذا كان فارغًا، فسيتم تحديد مسار ffprobe من بيئة النظام عبر متغير البيئة $PATH، أو من دليل الإعدادات، أو من $HOME/.stash.\",\n                    \"heading\": \"مسار الملف التنفيذي FFprobe\"\n                },\n                \"hardware_acceleration\": {\n                    \"desc\": \"يستخدم الأجهزة المتاحة لترميز الفيديو من أجل التحويل المباشر.\",\n                    \"heading\": \"ترميز الأجهزة FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"متقدم: وسائط إضافية لتمريرها إلى ffmpeg قبل حقل الإدخال عند تحويل ترميز الفيديو المباشر.\",\n                        \"heading\": \"وسائط إدخال التحويل المباشر لـ FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"متقدم: وسائط إضافية لتمريرها إلى ffmpeg قبل حقل الإخراج عند تحويل ترميز الفيديو المباشر.\",\n                        \"heading\": \"وسائط إخراج التحويل المباشر لـ FFmpeg\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"متقدم: وسائط إضافية لتمريرها إلى ffmpeg قبل حقل الإدخال عند إنشاء الفيديو.\",\n                        \"heading\": \"وسائط إدخال تحويل ترميز FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"متقدم: وسائط إضافية لتمريرها إلى ffmpeg قبل حقل الإخراج عند إنشاء الفيديو.\",\n                        \"heading\": \"وسائط إخراج تحويل ترميز FFmpeg\"\n                    }\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"قم بتضمين النطاق في الخرائط الحرارية المُنشأة\",\n            \"funscript_heatmap_draw_range_desc\": \"ارسم نطاق الحركة على المحور الصادي للخريطة الحرارية المُنشأة. ستحتاج الخرائط الحرارية الموجودة إلى إعادة إنشائها بعد التغيير.\",\n            \"gallery_cover_regex_desc\": \"تُستخدم التعابير النمطية لتحديد صورة كغلاف لمعرض الصور.\",\n            \"gallery_cover_regex_label\": \"نمط غلاف المعرض\",\n            \"gallery_ext_desc\": \"قائمة مفصولة بفواصل لامتدادات الملفات التي سيتم التعرف عليها كملفات ZIP خاصة بالمعرض.\",\n            \"gallery_ext_head\": \"امتداد ملفات ZIP للمعرض\",\n            \"generated_file_naming_hash_desc\": \"استخدم MD5 أو oshash لتسمية الملفات المُنشأة. يتطلب تغيير هذا أن تحتوي جميع المشاهد على MD5 المناسب.\",\n            \"generated_file_naming_hash_head\": \"تم إنشاء تجزئة تسمية الملف\",\n            \"generated_files_location\": \"موقع الدليل للملفات التي تم إنشاؤها (علامات المشهد، معاينات المشهد، الصور المتحركة، إلخ).\",\n            \"generated_path_head\": \"المسار المُنشأ\",\n            \"hashing\": \"التجزئة\",\n            \"heatmap_generation\": \"إنشاء خريطة حرارية باستخدام Funscript\",\n            \"image_ext_desc\": \"قائمة مفصولة بفواصل لامتدادات الملفات التي سيتم التعرف عليها كصور.\",\n            \"image_ext_head\": \"امتدادات الصور\",\n            \"include_audio_desc\": \"يتضمن ذلك بث الصوت عند إنشاء المعاينات.\",\n            \"include_audio_head\": \"تضمين الصوت\",\n            \"logging\": \"تسجيل الأحداث\",\n            \"maximum_streaming_transcode_size_desc\": \"الحد الأقصى لحجم التدفقات المُعاد ترميزها.\",\n            \"maximum_streaming_transcode_size_head\": \"الحد الأقصى لحجم عملية ترميز البث\",\n            \"maximum_transcode_size_desc\": \"الحد الأقصى لحجم عمليات التحويل البرمجي المُنشأة.\",\n            \"maximum_transcode_size_head\": \"الحد الأقصى لحجم عملية الترميز\",\n            \"metadata_path\": {\n                \"description\": \"موقع الدليل المستخدم عند إجراء عملية تصدير أو استيراد كاملة.\",\n                \"heading\": \"مسار البيانات الوصفية\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"اضبط القيمة على 0 للكشف التلقائي. تحذير: تشغيل مهام أكثر من اللازم لتحقيق استخدام كامل لوحدة المعالجة المركزية سيؤدي إلى انخفاض الأداء وقد يتسبب في مشاكل أخرى.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"عدد المهام المتوازية للمسح/التوليد\",\n            \"parallel_scan_head\": \"المسح/التوليد المتوازي\",\n            \"plugins_path\": {\n                \"description\": \"موقع مجلد ملفات تكوين الملحق.\",\n                \"heading\": \"مسار الإضافات\"\n            },\n            \"preview_generation\": \"توليد معاينة\",\n            \"python_path\": {\n                \"description\": \"مسار ملف بايثون التنفيذي (وليس المجلد فقط). يُستخدم هذا المسار لبرامج استخراج البيانات والإضافات. في حال تركه فارغًا، سيتم تحديد مسار بايثون من بيئة النظام.\",\n                \"heading\": \"مسار ملف بايثون القابل للتنفيذ\"\n            },\n            \"scraper_user_agent\": \"وكيل مستخدم برنامج استخراج البيانات\",\n            \"scraper_user_agent_desc\": \"سلسلة User-Agent المستخدمة أثناء طلبات HTTP الخاصة بالاستخراج.\",\n            \"scrapers_path\": {\n                \"description\": \"موقع مجلد ملفات تهيئة برنامج استخراج البيانات.\",\n                \"heading\": \"مسار البيانات المستخرجة\"\n            },\n            \"scraping\": \"جار استخراج البيانات\",\n            \"sprite_generation_head\": \"توليد الصور المتحركة\",\n            \"sprite_interval_desc\": \"الوقت بين كل صورة متحركة يتم إنشاؤها بالثواني.\",\n            \"sprite_interval_head\": \"فاصل زمني للصورة متحركة\",\n            \"sprite_maximum_desc\": \"الحد الأقصى لعدد الصور المتحركة التي سيتم إنشاؤها لمشهد واحد. اضبطه على 0 لإلغاء الحد.\",\n            \"sprite_maximum_head\": \"أقصى عدد من الصور المتحركة\",\n            \"sprite_minimum_desc\": \"الحد الأدنى لعدد الصور المتحركة التي يجب إنشاؤها لمشهد واحد\",\n            \"sprite_minimum_head\": \"الحد الأدنى من الصور المتحركة\",\n            \"sprite_screenshot_size_desc\": \"الحجم المطلوب لكل صورة متحركة بالبكسل.\",\n            \"sprite_screenshot_size_head\": \"حجم الصورة المتحركة\",\n            \"sqlite_location\": \"موقع ملف قاعدة بيانات SQLite (يتطلب إعادة التشغيل). تحذير: تخزين قاعدة البيانات على نظام مختلف عن النظام الذي يُشغّل منه خادم Stash (أي عبر الشبكة) غير مدعوم!\",\n            \"use_custom_sprite_interval_head\": \"استخدم فاصل زمني مخصص للصور المتحركة\",\n            \"use_custom_sprite_interval_desc\": \"قم بتمكين فاصل الصور المتحركة المخصص وفقًا للإعدادات أدناه.\",\n            \"video_ext_desc\": \"قائمة مفصولة بفواصل لامتدادات الملفات التي سيتم التعرف عليها كملفات فيديو.\",\n            \"video_ext_head\": \"امتدادات الفيديو\",\n            \"video_head\": \"فيديو\"\n        },\n        \"library\": {\n            \"exclusions\": \"الاستثناءات\",\n            \"gallery_and_image_options\": \"خيارات المعرض والصور\",\n            \"media_content_extensions\": \"امتدادات محتوى الوسائط\"\n        },\n        \"logs\": {\n            \"log_level\": \"مستوى السجل\"\n        },\n        \"plugins\": {\n            \"available_plugins\": \"الإضافات المتاحة\",\n            \"hooks\": \"خطافات\",\n            \"installed_plugins\": \"الإضافات المثبتة\",\n            \"triggers_on\": \"تشغيل المحفزات\"\n        },\n        \"scraping\": {\n            \"available_scrapers\": \"مستخرجات البيانات المتاحة\",\n            \"entity_metadata\": \"{entityType} البيانات الوصفية\",\n            \"entity_scrapers\": \"{entityType} مستخرجات البيانات\",\n            \"excluded_tag_patterns_desc\": \"التعبيرات النمطية لأسماء العلامات المراد استبعادها من نتائج استخراج البيانات.\",\n            \"excluded_tag_patterns_head\": \"أنماط العلامات المستبعدة\",\n            \"installed_scrapers\": \"مستخرجات البيانات المثبته\",\n            \"scraper\": \"مستخرج البيانات\",\n            \"scrapers\": \"مستخرجات البيانات\",\n            \"search_by_name\": \"البحث بالاسم\",\n            \"supported_types\": \"الأنواع المدعومة\",\n            \"supported_urls\": \"عناوين URL\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"أضف مثيل stash-box\",\n            \"api_key\": \"مفتاح API\",\n            \"description\": \"يُسهّل هذا البرنامج عملية الوسم الآلي للمشاهد والمؤدين بناءً على بصمات وأسماء الملفات من Stash-box.\\nيمكنك العثور على نقطة النهاية ومفتاح واجهة برمجة التطبيقات (API) في صفحة حسابك على منصة stash-box. يُشترط إدخال الأسماء عند إضافة أكثر من منصة.\",\n            \"endpoint\": \"نقطة النهاية\",\n            \"graphql_endpoint\": \"نقطة نهاية GraphQL\",\n            \"max_requests_per_minute\": \"الحد الأقصى للطلبات في الدقيقة\",\n            \"max_requests_per_minute_description\": \"يستخدم القيمة الافتراضية لـ {defaultValue} إذا تم ضبطه على 0\",\n            \"name\": \"اسم\",\n            \"title\": \"نقاط نهاية Stash-box\"\n        },\n        \"system\": {\n            \"transcoding\": \"تحويل الترميز\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"تمت إضافة {operation_name} إلى قائمة انتظار المهام\",\n            \"anonymise_and_download\": \"يقوم بإنشاء نسخة مجهولة المصدر من قاعدة البيانات وتنزيل الملف الناتج.\",\n            \"anonymise_database\": \"ينشئ نسخة من قاعدة البيانات في دليل النسخ الاحتياطية، مع إخفاء هوية جميع البيانات الحساسة. ويمكن بعد ذلك تقديم هذه النسخة للآخرين لأغراض استكشاف الأخطاء وإصلاحها وتصحيحها. ولا يتم إجراء أي تعديل على قاعدة البيانات الأصلية. وتستخدم قاعدة البيانات التي تم إخفاء هويتها تنسيق اسم الملف التالي: {filename_format}.\",\n            \"anonymising_database\": \"قاعدة بيانات إخفاء الهوية\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"وضع علامات تلقائية على جميع المسارات\",\n                \"auto_tagging_paths\": \"وضع علامات تلقائية على المسارات التالية\"\n            },\n            \"auto_tag_based_on_filenames\": \"يتم وضع علامات تلقائية على المحتوى بناءً على مسارات الملفات.\",\n            \"auto_tagging\": \"الوسم التلقائي\",\n            \"backing_up_database\": \"نسخ قاعدة البيانات احتياطياً\",\n            \"backup_and_download\": \"يقوم بنسخ قاعدة البيانات احتياطياً وتنزيل الملف الناتج.\",\n            \"backup_database\": {\n                \"description\": \"يقوم بإجراء نسخ احتياطي لقاعدة البيانات وملفات الكائنات الثنائية الكبيرة (blob).\",\n                \"destination\": \"وجهة\",\n                \"download\": \"تنزيل النسخة الاحتياطية\",\n                \"include_blobs\": \"تضمين الكائنات الثنائية الكبيرة في النسخ الاحتياطي\",\n                \"include_blobs_desc\": \"قم بتعطيل النسخ الاحتياطي لملف قاعدة بيانات SQLite فقط.\",\n                \"sqlite\": \"سيكون ملف النسخ الاحتياطي نسخة من ملف قاعدة بيانات SQLite، باسم الملف {filename_format}\",\n                \"to_directory\": \"إلى {directory}\",\n                \"warning_blobs\": \"لن يتم تضمين ملفات الكائنات الثنائية الكبيرة (Blob) في النسخة الاحتياطية. هذا يعني أنه لكي تتم عملية الاستعادة بنجاح من النسخة الاحتياطية، يجب أن تكون ملفات الكائنات الثنائية الكبيرة موجودة في موقع تخزين الكائنات الثنائية الكبيرة.\",\n                \"zip\": \"سيتم ضغط ملفات قاعدة بيانات SQLite وملفات الكائنات الثنائية الكبيرة في ملف واحد، باسم الملف {filename_format}\"\n            },\n            \"cleanup_desc\": \"تحقق من وجود ملفات مفقودة وقم بإزالتها من قاعدة البيانات. هذا إجراء ضار.\",\n            \"clean_generated\": {\n                \"blob_files\": \"ملفات Blob\",\n                \"description\": \"يزيل الملفات التي تم إنشاؤها بدون وجود مدخل مطابق لها في قاعدة البيانات.\",\n                \"image_thumbnails\": \"صور مصغرة\",\n                \"image_thumbnails_desc\": \"صور مصغرة ومقاطع فيديو\",\n                \"markers\": \"معاينات العلامات\",\n                \"previews\": \"معاينات المشاهد\",\n                \"previews_desc\": \"معاينات المشاهد والصور المصغرة\",\n                \"sprites\": \"صور المشهد\",\n                \"transcodes\": \"تحويل المشهد\"\n            },\n            \"data_management\": \"إدارة البيانات\",\n            \"defaults_set\": \"تم تعيين الإعدادات الافتراضية وسيتم استخدامها عند النقر على زر {action} في صفحة المهام.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"لا تقم بتضمين امتداد الملف كجزء من العنوان\",\n            \"empty_queue\": \"لا توجد مهام قيد التشغيل حاليًا.\",\n            \"export_to_json\": \"يقوم بتصدير محتوى قاعدة البيانات إلى تنسيق JSON في دليل البيانات الوصفية.\",\n            \"generate\": {\n                \"generating_from_paths\": \"إنشاء مشاهد من المسارات التالية\",\n                \"generating_scenes\": \"جارٍ إنشاء {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"إنشاء معاينات لمقاطع الصور\",\n            \"generate_desc\": \"إنشاء ملفات الصور والرسوم المتحركة والفيديوهات وملفات الفيديو الافتراضية وغيرها من الملفات الداعمة.\",\n            \"generate_image_phashes_during_scan\": \"توليد تجزئات إدراكية للصور\",\n            \"generate_image_phashes_during_scan_tooltip\": \"لإزالة التكرارات والتعرف على البيانات.\",\n            \"generate_phashes_during_scan\": \"توليد تجزئات إدراكية للفيديو\",\n            \"generate_phashes_during_scan_tooltip\": \"لإزالة التكرارات وتحديد المشهد.\",\n            \"generate_previews_during_scan\": \"إنشاء معاينات صور متحركة\",\n            \"generate_previews_during_scan_tooltip\": \"كما يُمكن إنشاء معاينات متحركة (webp)، وهي مطلوبة فقط عند ضبط نوع معاينة المشهد/جدار العلامات على صورة متحركة. تستهلك هذه المعاينات موارد أقل من وحدة المعالجة المركزية مقارنةً بمعاينات الفيديو أثناء التصفح، ولكنها تُنشأ بالإضافة إليها وتكون ملفاتها أكبر حجمًا.\",\n            \"generate_sprites_during_scan\": \"إنشاء صور متحركة\",\n            \"generate_sprites_during_scan_tooltip\": \"مجموعة الصور المعروضة أسفل مشغل الفيديو لتسهيل التصفح.\",\n            \"generate_thumbnails_during_scan\": \"إنشاء صور مصغرة للصور\",\n            \"generate_video_covers_during_scan\": \"إنشاء أغلفة المشاهد\",\n            \"generate_video_previews_during_scan\": \"إنشاء معاينات\",\n            \"generate_video_previews_during_scan_tooltip\": \"إنشاء معاينات فيديو يتم تشغيلها عند تمرير المؤشر فوق مشهد ما\",\n            \"generated_content\": \"المحتوى المُنشأ\",\n            \"identify\": {\n                \"and_create_missing\": \"وخلق حالات مفقودة\",\n                \"create_missing\": \"إنشاء مفقود\",\n                \"default_options\": \"الخيارات الافتراضية\",\n                \"description\": \"قم بتعيين بيانات المشهد الوصفية تلقائيًا باستخدام مصادر stash-box و scraper.\",\n                \"explicit_set_description\": \"سيتم استخدام الخيارات التالية في حال عدم تجاوزها في الخيارات الخاصة بالمصدر.\",\n                \"field\": \"حقل\",\n                \"field_options\": \"خيارات الحقل\",\n                \"heading\": \"تعرف\",\n                \"identifying_from_paths\": \"تحديد المشاهد من المسارات التالية\",\n                \"identifying_scenes\": \"تعرف على {num} {scene}\",\n                \"include_male_performers\": \"يشمل ذلك فنانين ذكور\",\n                \"performer_genders\": \"جنس الفنانين\",\n                \"performer_genders_desc\": \"سيتم إدراج الفنانين من أجناس محددة أثناء عملية تحديد الهوية.\",\n                \"set_cover_images\": \"ضبط صوة غلاف\",\n                \"set_organized\": \"تعيين علامة منظمة\",\n                \"skip_multiple_matches\": \"تخطّي المتطابقات التي لها أكثر من نتيجة واحدة\",\n                \"skip_multiple_matches_tooltip\": \"إذا لم يتم تفعيل هذا الخيار وتم إرجاع أكثر من نتيجة، فسيتم اختيار واحدة منها عشوائيًا للمطابقة\",\n                \"skip_single_name_performers\": \"تخطّي الفنانين الذين يحملون اسماً واحداً دون أي لبس\",\n                \"skip_single_name_performers_tooltip\": \"إذا لم يتم تفعيل هذا الخيار، فسيتم اختيار فنانين ذوي نمطية عالية مثل سامانثا أو أولغا\",\n                \"source\": \"مصدر\",\n                \"source_options\": \"{source} خيارات\",\n                \"sources\": \"مصادر\",\n                \"strategy\": \"استراتيجية\",\n                \"tag_skipped_matches\": \"تم تخطي علامة المتطابقة مع\",\n                \"tag_skipped_matches_tooltip\": \"أنشئ علامة مثل \\\"تحديد: تطابقات متعددة\\\" يمكنك استخدامها للتصفية في عرض مُصنِّف المشهد، ثم اختر التطابق الصحيح يدويًا\",\n                \"tag_skipped_performer_tooltip\": \"أنشئ علامة مثل \\\"تحديد: اسم فنان واحد\\\" يمكنك استخدامها للتصفية في عرض مُصنِّف المشهد، ثم اختر الطريقة التي تريد التعامل بها مع هؤلاء الفنانين\",\n                \"tag_skipped_performers\": \"تخطت علامة الأداء بعض الفنانين الذين\",\n                \"field_behaviour\": \"{strategy} {field}\"\n            },\n            \"import_from_exported_json\": \"يستورد من ملف JSON مُصدّر في دليل البيانات الوصفية. يؤدي ذلك إلى مسح قاعدة البيانات الموجودة.\",\n            \"incremental_import\": \"استيراد تدريجي من ملف مضغوط للتصدير.\",\n            \"job_queue\": \"قائمة المهام\",\n            \"maintenance\": \"صيانة\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"حذف البيانات القديمة\",\n                \"description\": \"قم بنقل البيانات الثنائية الكبيرة (blobs) إلى نظام تخزين البيانات الثنائية الكبيرة الحالي. يجب تنفيذ عملية النقل هذه بعد تغيير نظام تخزين البيانات الثنائية الكبيرة. يمكنك حذف البيانات القديمة بعد النقل.\"\n            },\n            \"migrate_hash_files\": \"يُستخدم بعد تغيير تجزئة تسمية الملفات المُنشأة لإعادة تسمية الملفات المُنشأة الموجودة إلى تنسيق التجزئة الجديد.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"حذف ملفات لقطات الشاشة\",\n                \"description\": \"انقل لقطات الشاشة الخاصة بالمشاهد إلى نظام تخزين الكائنات الثنائية الكبيرة الجديد. يجب تنفيذ عملية النقل هذه بعد ترقية النظام الحالي إلى الإصدار 0.20. يمكنك حذف لقطات الشاشة القديمة بعد النقل.\",\n                \"overwrite_existing\": \"استبدل البيانات الموجودة ببيانات لقطات الشاشة\"\n            },\n            \"migrations\": \"اندماجات\",\n            \"only_dry_run\": \"قم بإجراء تجربة جافة فقط. لا تقم بإزالة أي شيء\",\n            \"optimise_database\": \"حاول تحسين الأداء من خلال تحليل ملف قاعدة البيانات بالكامل ثم إعادة بنائه.\",\n            \"optimise_database_warning\": \"تحذير: أثناء تشغيل هذه المهمة، ستفشل أي عمليات لتعديل قاعدة البيانات، وقد يستغرق إكمالها عدة دقائق، اعتمادًا على حجم قاعدة البيانات الخاصة بك. كما يتطلب أيضًا على الأقل مساحة خالية على القرص بقدر ما تكون قاعدة بياناتك كبيرة، ولكن يوصى بـ 1.5x.\",\n            \"plugin_tasks\": \"مهام الملحقات\",\n            \"rescan\": \"إعادة فحص الملفات\",\n            \"rescan_tooltip\": \"أعد فحص جميع الملفات في المسار. يُستخدم هذا الخيار لفرض تحديث بيانات تعريف الملفات وإعادة فحص ملفات zip.\",\n            \"scan\": {\n                \"scanning_all_paths\": \"مسح جميع المسارات\",\n                \"scanning_paths\": \"مسح المسارات التالية\"\n            },\n            \"scan_for_content_desc\": \"قم بمسح المحتوى الجديد وإضافته إلى قاعدة البيانات.\",\n            \"set_name_date_details_from_metadata_if_present\": \"قم بتعيين الاسم والتاريخ والتفاصيل من بيانات تعريف الملف المضمنة\"\n        },\n        \"tools\": {\n            \"graphql_playground\": \"بيئة اختبار GraphQL\",\n            \"heading\": \"أدوات\",\n            \"scene_duplicate_checker\": \"مدقق تكرار المشاهد\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"إضافة حقل\",\n                \"capitalize_title\": \"العنوان بحروف كبيرة\",\n                \"display_fields\": \"حقول العرض\",\n                \"escape_chars\": \"استخدم \\\\ للهروب من الأحرف الحرفية\",\n                \"filename\": \"اسم الملف\",\n                \"filename_pattern\": \"نمط اسم الملف\",\n                \"ignore_organized\": \"تجاهل المشاهد المنظمة\",\n                \"ignored_words\": \"الكلمات المتجاهلة\",\n                \"matches_with\": \"يتطابق مع {i}\",\n                \"select_parser_recipe\": \"حدد وصفة المحلل اللغوي\",\n                \"title\": \"محلل أسماء ملفات المشاهد\",\n                \"whitespace_chars\": \"أحرف المسافة البيضاء\",\n                \"whitespace_chars_desc\": \"سيتم استبدال هذه الأحرف بمسافات بيضاء في العنوان\"\n            },\n            \"scene_tools\": \"أدوات المشهد\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"قم بتقصير العدادات في صفحات عرض البطاقات والتفاصيل، على سبيل المثال سيتم تنسيق \\\"1831\\\" إلى \\\"1.8K\\\".\",\n                \"heading\": \"عدادات مختصرة\"\n            },\n            \"basic_settings\": \"الإعدادات الأساسية\",\n            \"custom_css\": {\n                \"description\": \"يجب إعادة تحميل الصفحة لتطبيق التغييرات. لا يوجد ضمان للتوافق بين ملفات CSS المخصصة والإصدارات المستقبلية من ستاش.\",\n                \"heading\": \"CSS مخصص\",\n                \"option_label\": \"تم تفعيل CSS المخصص\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"وضع استكشاف الأخطاء وإصلاحها\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/bg-BG.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Добави\",\n        \"add_directory\": \"Добави Директория\",\n        \"add_entity\": \"Добави {entityType}\",\n        \"add_manual_date\": \"Добави дата ръчно\",\n        \"add_sub_groups\": \"Добави Подгрупа\",\n        \"add_o\": \"Добави О\",\n        \"add_play\": \"Добави Гледане\",\n        \"add_to_entity\": \"Добави към {entityType}\",\n        \"allow\": \"Позволи\",\n        \"allow_temporarily\": \"Позволи временно\",\n        \"anonymise\": \"Анонимизирай\",\n        \"apply\": \"Приложи\",\n        \"auto_tag\": \"Автоматично Тагване\",\n        \"backup\": \"Резервно копие\",\n        \"browse_for_image\": \"Преглеждане за картина…\",\n        \"cancel\": \"Отмяна\",\n        \"choose_date\": \"Избери дата\",\n        \"clean\": \"Изчисти\",\n        \"clean_generated\": \"Изчисти генерирани файлове\",\n        \"clear\": \"Изчисти\",\n        \"clear_back_image\": \"Изчисти задна картина\",\n        \"clear_date_data\": \"Изчисти дата на данни\",\n        \"clear_front_image\": \"Изчисти предна картина\",\n        \"clear_image\": \"Изичисти картина\",\n        \"close\": \"Затвори\",\n        \"confirm\": \"Потвърди\",\n        \"continue\": \"Продължи\",\n        \"copy_to_clipboard\": \"Копиране в буфера\",\n        \"create\": \"Създай\",\n        \"create_chapters\": \"Създай Глава\",\n        \"create_entity\": \"Създай {entityType}\",\n        \"create_marker\": \"Създай Маркер\",\n        \"create_parent_studio\": \"Създай родителско студио\",\n        \"created_entity\": \"Създаден {entity_type}: {entity_name}\",\n        \"customise\": \"Персонализирай\",\n        \"delete\": \"Изтрий\",\n        \"delete_entity\": \"Изтрий {entityType}\",\n        \"delete_file\": \"Изтрий файла\",\n        \"delete_file_and_funscript\": \"Изтрий файл (и funscript)\",\n        \"delete_generated_supporting_files\": \"Изтрий генерирани поддържащи файлове\",\n        \"disable\": \"Изключи\",\n        \"disallow\": \"Забрани\",\n        \"download\": \"Свали\",\n        \"download_anonymised\": \"Свали анонимизирана\",\n        \"download_backup\": \"Свали резевно копие\",\n        \"edit\": \"Редакция\",\n        \"edit_entity\": \"Редакция {entityType}\",\n        \"enable\": \"Активирай\",\n        \"encoding_image\": \"Кодиране на картина…\",\n        \"export\": \"Експортиране\",\n        \"export_all\": \"Експортирай всичко…\",\n        \"reshuffle\": \"Пренареди\",\n        \"assign_stashid_to_parent_studio\": \"Присвояване на Stash ID към съществуващо родителско студио и актуализиране на метаданните\",\n        \"find\": \"Намери\",\n        \"finish\": \"Приключи\",\n        \"from_file\": \"От файл…\",\n        \"from_url\": \"От URL…\",\n        \"full_export\": \"Пълен експорт\",\n        \"full_import\": \"Пълен Импорт\",\n        \"generate\": \"Генерирай\",\n        \"generate_thumb_default\": \"Генерирай тъмбнайл по подразбиране\",\n        \"generate_thumb_from_current\": \"Генерирай тъмбнайл от сегашният кадър\",\n        \"hash_migration\": \"миграция на хеш\",\n        \"hide\": \"Скрий\",\n        \"hide_configuration\": \"Скрий конфигурация\",\n        \"identify\": \"Индентифицирай\",\n        \"ignore\": \"Игнорирай\",\n        \"import\": \"Импорт…\",\n        \"import_from_file\": \"Импорт от файл\",\n        \"load\": \"Зареди\",\n        \"load_filter\": \"Зареди филтър\",\n        \"logout\": \"Изход\",\n        \"make_primary\": \"Направи Основен\",\n        \"merge\": \"Слей\",\n        \"migrate_blobs\": \"Мигрирай Блобове\",\n        \"migrate_scene_screenshots\": \"Мигрирай Скрийншоти от Сцени\",\n        \"next_action\": \"Следващ\",\n        \"not_running\": \"не върви\",\n        \"open_in_external_player\": \"Отвори в външет плейер\",\n        \"open_random\": \"Отвори Случайно\",\n        \"optimise_database\": \"Оптимизирай База Данни\",\n        \"overwrite\": \"Презапиши\",\n        \"play\": \"Пусни\",\n        \"play_random\": \"Пусни Случайно\",\n        \"play_selected\": \"Пусни избраните\",\n        \"preview\": \"Преглеждане\",\n        \"previous_action\": \"Назад\",\n        \"reassign\": \"Презадай\",\n        \"refresh\": \"Опресни\",\n        \"reload\": \"Презареди\",\n        \"reload_plugins\": \"Презареди плъгините\",\n        \"reload_scrapers\": \"Презареди търкачите\",\n        \"remove\": \"Премахни\",\n        \"remove_date\": \"Премахни дата\",\n        \"remove_from_containing_group\": \"Премахни от Група\",\n        \"remove_from_gallery\": \"Премахни от Галерия\",\n        \"rename_gen_files\": \"Преименувай генериран файл\",\n        \"rescan\": \"Пресканирай\",\n        \"reset_play_duration\": \"Нулирай период на пускане\",\n        \"reset_resume_time\": \"Нулирай преме за възтановяване\",\n        \"reset_cover\": \"Възтанови Първоначална Корица\",\n        \"running\": \"върви\",\n        \"save\": \"Запази\",\n        \"save_delete_settings\": \"Изполвай тези настройки по подразвиране при изтриване\",\n        \"save_filter\": \"Запази филтър\",\n        \"scan\": \"Сканирай\",\n        \"scrape\": \"Изтъркай\",\n        \"scrape_query\": \"Изтъркай със заявка\",\n        \"scrape_scene_fragment\": \"Изтъркай по частица\",\n        \"scrape_with\": \"Изтъркай чрез…\",\n        \"search\": \"Търси\",\n        \"select_all\": \"Избери Всичко\",\n        \"select_entity\": \"Избери {entityType}\",\n        \"select_folders\": \"Избери папки\",\n        \"select_none\": \"Избери Нищо\",\n        \"selective_auto_tag\": \"Избирателно Автоматично Тагване\",\n        \"selective_clean\": \"Избирателно Почистване\",\n        \"selective_scan\": \"Избирателно Сканиране\",\n        \"set_as_default\": \"Сложи по подразбиране\",\n        \"set_back_image\": \"Задна картина…\",\n        \"set_cover\": \"Сложи Като Корица\",\n        \"set_front_image\": \"Предна Картина…\",\n        \"set_image\": \"Сложи картина…\",\n        \"show\": \"Покажи\",\n        \"show_configuration\": \"Покажи Конфигурация\",\n        \"show_results\": \"Покажи резултат\",\n        \"show_count_results\": \"Покажи {count} резултати\",\n        \"sidebar\": {\n            \"close\": \"Затвори странично меню\",\n            \"open\": \"Отвори странично меню\",\n            \"toggle\": \"Превключи странично меню\"\n        },\n        \"skip\": \"Пропусни\",\n        \"split\": \"Раздели\",\n        \"stop\": \"Спри\",\n        \"submit\": \"Подай\",\n        \"submit_stash_box\": \"Подай към Stash-Box\",\n        \"submit_update\": \"Подай обноваване\",\n        \"swap\": \"Подмени\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Сигурен ли си че искаш да Изчистиш? Това ще изтрие иформацията от база данни и генерирано съдържание за всички сцени и галерий който не мога да бъдат намерени на файловата система.\",\n            \"dry_mode_selected\": \"Избрано е Сухо Пускане. Нищо няма да бъде изтрито на истина, са ще бъде записано в логовете.\",\n            \"import_warning\": \"Сигурен ли си че искаш да импортираш? Това ще изтрие базата данни и че вкара на ново твоите експортирани метаданни.\"\n        },\n        \"temp_disable\": \"Спри временно…\",\n        \"temp_enable\": \"Включи временно…\",\n        \"unset\": \"Премахни\",\n        \"use_default\": \"Използвай на стойностите по подразбиране\",\n        \"view_history\": \"Виж история\",\n        \"view_random\": \"Виж Случайно\"\n    },\n    \"actions_name\": \"Действия\",\n    \"age\": \"Години\",\n    \"age_on_date\": \"{age} по време на продукция\",\n    \"aliases\": \"Псевдоними\",\n    \"all\": \"всички\",\n    \"also_known_as\": \"Също така познат/а като\",\n    \"appears_with\": \"Има Участия Със\",\n    \"ascending\": \"Възходящ\",\n    \"audio_codec\": \"Аудио Кодек\",\n    \"average_resolution\": \"Средностатистическа Резолюция\",\n    \"between_and\": \"и\",\n    \"birth_year\": \"Година на раждане\",\n    \"birthdate\": \"Дата на раждане\",\n    \"bitrate\": \"Бит Рейт\",\n    \"blobs_storage_type\": {\n        \"database\": \"База Данни\",\n        \"filesystem\": \"Файлова Система\"\n    },\n    \"captions\": \"Субтитри\",\n    \"career_length\": \"Подължителност на Кариера\",\n    \"chapters\": \"Глави\",\n    \"circumcised\": \"Образан\",\n    \"circumcised_types\": {\n        \"CUT\": \"Обрязан\",\n        \"UNCUT\": \"Необрязан\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Активни инстанции на stash-box:\",\n            \"blacklist_desc\": \"Предмети от черният списък са изключени от заяки. Забележи че те са regular expressions и не гледа главни и малки букви. Някой символи трябва да пъдат escape-нати със наклонка: {chars_require_escape}\",\n            \"blacklist_label\": \"Черен списък\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Дубликирани предмети от черния списък\"\n            },\n            \"mark_organized_desc\": \"Веднага отбележи сцена като Организирана след като се натисне бутон Запази.\",\n            \"mark_organized_label\": \"Отбележи като Организирана на запазване\",\n            \"query_mode_auto\": \"Автоматично\",\n            \"query_mode_auto_desc\": \"Използа метаданни ако съществиват, или име на файл\",\n            \"query_mode_dir\": \"Папка\",\n            \"query_mode_dir_desc\": \"Използва само папката която съдържа видео файла\",\n            \"query_mode_filename\": \"Име на файл\",\n            \"query_mode_filename_desc\": \"Използва само име на файл\",\n            \"query_mode_label\": \"Мод на Заявки\",\n            \"query_mode_metadata\": \"Мета данни\",\n            \"query_mode_metadata_desc\": \"Използва само мета данни\",\n            \"query_mode_path\": \"Път\",\n            \"query_mode_path_desc\": \"Използва целият път на файла\",\n            \"set_cover_desc\": \"Замени корицата на сцената ако се намери такава.\",\n            \"set_cover_label\": \"Заложи картина за корица на сцена\",\n            \"set_tag_desc\": \"Закачи тагове към сцената, или чрез презаписване или чрез сливане със съществуващите тагове на сцената.\",\n            \"set_tag_label\": \"Задай тагове\",\n            \"source\": \"Източник\"\n        },\n        \"noun_query\": \"Заявка\",\n        \"results\": {\n            \"duration_off\": \"Продължителност не съвпада с поне {number} сек.\",\n            \"duration_unknown\": \"Неизвестна продължителност\",\n            \"fp_matches\": \"Продължителността съвпада\",\n            \"fp_matches_multi\": \"Продължителността съвпада {matchCount}/{durationsLength} отпечатъци\",\n            \"hash_matches\": \"{hash_type} съвпада\",\n            \"match_failed_already_tagged\": \"Сцената вече има тагове\",\n            \"match_failed_no_result\": \"Няма намерени резултати\",\n            \"match_success\": \"Счената е успешно тагната\",\n            \"phash_matches\": \"{count} PHashes съвпадат\",\n            \"unnamed\": \"Неименуван\"\n        },\n        \"verb_match_fp\": \"Сравни Отпечатъци\",\n        \"verb_matched\": \"Сравнени\",\n        \"verb_scrape_all\": \"Изтъркай Всичко\",\n        \"verb_submit_fp\": \"Подай {fpCount, plural, one{# Отпечатък} other{# Отпечатъци}}\",\n        \"verb_toggle_unmatched\": \"{toggle} несъвпадаци сцени\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Хеш на билда:\",\n            \"build_time\": \"Време на билда:\",\n            \"check_for_new_version\": \"Проверка за нова версия\",\n            \"latest_version\": \"Последна Версия\",\n            \"latest_version_build_hash\": \"Хеш на билда на Последна Версия:\",\n            \"new_version_notice\": \"[НОВО]\",\n            \"release_date\": \"Дана за издаване:\",\n            \"stash_discord\": \"Присъедини се към нашият {url} канал\",\n            \"stash_home\": \"Stash home на {url}\",\n            \"stash_open_collective\": \"Подкрепи ни чрез {url}\",\n            \"stash_wiki\": \"Stash {url} страница\",\n            \"version\": \"Версия\"\n        },\n        \"advanced_mode\": \"Мод за Напреднали\",\n        \"application_paths\": {\n            \"heading\": \"Пътища на Апликация\"\n        },\n        \"categories\": {\n            \"about\": \"Относно\",\n            \"changelog\": \"Списък на промените\",\n            \"interface\": \"Интерфейс\",\n            \"logs\": \"Дневници\",\n            \"metadata_providers\": \"Доставчици на Мета данни\",\n            \"plugins\": \"Приставки\",\n            \"scraping\": \"Търкане\",\n            \"security\": \"Сигурност\",\n            \"services\": \"Услуги\",\n            \"system\": \"Система\",\n            \"tasks\": \"Задачи\",\n            \"tools\": \"Иструменти\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Позволи {tempIP}\",\n            \"allowed_ip_addresses\": \"Позволени IP адреси\",\n            \"allowed_ip_temporarily\": \"Позволени IP временно\",\n            \"default_ip_whitelist\": \"Основен IP Бъл списък\",\n            \"default_ip_whitelist_desc\": \"Основени IP адреси позволени да достигат DLNA. Изполвай {wildcard} за да позволиш всички IP адреси.\",\n            \"disabled_dlna_temporarily\": \"Временно изключено DLNA\",\n            \"disallowed_ip\": \"Непозволени IP\",\n            \"enabled_by_default\": \"Включено по подразвиране\",\n            \"enabled_dlna_temporarily\": \"Временно включено DLNA\",\n            \"network_interfaces\": \"Интерфейси\",\n            \"network_interfaces_desc\": \"Интерфейси да се достъпн DLNA сървъра. Празен лист ще ползва всички интерфейси. Изисква DLNA рестрат след промяна.\",\n            \"recent_ip_addresses\": \"Скорошни IP адреси\",\n            \"server_display_name\": \"Име за Покаване на Сървъра\",\n            \"server_display_name_desc\": \"Име за покаване на DLNA сървъра. По подразбиране {server_name} ако е празно.\",\n            \"server_port\": \"Порт на Сървъра\",\n            \"server_port_desc\": \"Порт на който да върви DLNA сървъра. Изисква рестарт на DLNA след промяна.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Успешно отказано временно поведение\",\n            \"until_restart\": \"до рестартиране\",\n            \"video_sort_order\": \"Ред на Видеа по подразбиране\",\n            \"video_sort_order_desc\": \"Ред по който да реди видеа по подразбиране.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API ключ\",\n                \"api_key_desc\": \"API ключ за външни системи. Нужен само когато име/парола са настроени. Името трябва да бъде запазене преди генерация на API ключ.\",\n                \"authentication\": \"Идентификация\",\n                \"clear_api_key\": \"Изчисти API ключ\",\n                \"credentials\": {\n                    \"description\": \"Удостоверителни данни за контрол на достъпа до stash.\",\n                    \"heading\": \"Удостоверителни данни\"\n                },\n                \"generate_api_key\": \"Генерирай API ключ\",\n                \"log_file\": \"Лог файл\",\n                \"log_file_desc\": \"Път към файла за извод на лог. Празен за да спре извод на лог. Изисква рестарт.\",\n                \"log_http\": \"Логвай http достъп\",\n                \"log_http_desc\": \"Логва http достъп на терминала. Изисква рестарт.\",\n                \"log_to_terminal\": \"Лог към терминал\",\n                \"log_to_terminal_desc\": \"Логва към терминал заедно с към файл. Винаги истина ако логване към файл е изключено. Изискава рестарт.\",\n                \"maximum_session_age\": \"Максимална Продължителност на Сесия\",\n                \"maximum_session_age_desc\": \"Максимално време на бездействие, преди сесията за вход да изтече, в секунди. Изисква рестартиране.\",\n                \"password\": \"Парола\",\n                \"password_desc\": \"Парола за достъп до Stash. Остави празно за да изключи достъп чрез потребител\",\n                \"stash-box_integration\": \"Stash-box интеграция\",\n                \"username\": \"Потребителско име\",\n                \"username_desc\": \"Потребителско име за достъп до Stash. Остави празно за да изключи достъп чрез потребител\",\n                \"log_file_max_size\": \"Максимален размер на файла\",\n                \"log_file_max_size_desc\": \"Максимален размер в мегабайти на лог файла преди компресиране. 0 MB означава деактивирано. Изисква рестартиране.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Местоположение на папка за резрвни SQLite бази данни\",\n                \"heading\": \"Път към Папка за Резервни Данни\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Къде във файловата система да се пазят бинарни данни. Позва се само ако се ползва Файлова система за блоб пазене. ВНИМАНИЕ: промяната ще изисква ръчно местене на съществуващи данни.\",\n                \"heading\": \"Път до файловата система за двоични данни\"\n            }\n        },\n        \"ui\": {\n            \"custom_locales\": {\n                \"option_label\": \"Персонализирана локализация е активирана\"\n            },\n            \"delete_options\": {\n                \"description\": \"Настройки по подразбиране при триене на картини, галерий и сцени.\",\n                \"heading\": \"Изтриване на настройки\",\n                \"options\": {\n                    \"delete_file\": \"Изтриване на файлове по подразбиране\",\n                    \"delete_generated_supporting_files\": \"Изтриване на генерираните поддържащи файлове по подразбиране\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Десктоп Интеграция\",\n                \"notifications_enabled\": \"Включване на известяването\",\n                \"send_desktop_notifications_for_events\": \"Изпащане на десктоп известявания за събития\",\n                \"skip_opening_browser\": \"Пропускане на отваряне на браузер\",\n                \"skip_opening_browser_on_startup\": \"Пропускане на автоматично отваряне на броузер по време на стартиране\"\n            },\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"description\": \"Когато е включена, тази настройка ще предосвати разширени детайли запавайки компактна презентация\",\n                    \"heading\": \"Компактни разширени детайли\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Покажи фонова картина на станицата с детайли.\",\n                    \"heading\": \"Включи фонова картина\"\n                },\n                \"heading\": \"Станица с детайли\",\n                \"show_all_details\": {\n                    \"description\": \"Когато е включена, всичкото съдържание ще бъде показано по подразбиране и всеки детайл ще бъде в собствена колона\",\n                    \"heading\": \"Покажи всички детайли\"\n                }\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Премахни възможноста да се създават нови обекти от падащият селектор\",\n                    \"heading\": \"Изключи падащо създаване\"\n                },\n                \"heading\": \"Редактиране\",\n                \"max_options_shown\": {\n                    \"label\": \"Максимален брой неща който се показват в падащ селектор\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Точност на звездния рейтинг\",\n                        \"options\": {\n                            \"full\": \"Цели\",\n                            \"half\": \"Половинки\",\n                            \"quarter\": \"Четвъртинки\",\n                            \"tenth\": \"Десетици\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Тип система за рейтинг\",\n                        \"options\": {\n                            \"decimal\": \"Десетична\",\n                            \"stars\": \"Звезди\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Време за разминаване в милисекунди за пускане на интерактивни скриптове.\",\n                \"heading\": \"Funscript разминаване (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Свързване\",\n                \"server_offset\": {\n                    \"heading\": \"Сървърно разминаване\"\n                },\n                \"status\": {\n                    \"heading\": \"Статус на връзка с Handy\"\n                },\n                \"sync\": \"Синхронизиране\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Handy connection key за ползване със интерактивни сцени. Слагането на този ключ ще позволи на Stash да сподели иформация за текущата сцена със handyfeeling.com\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/bn-BD.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"যুক্ত\",\n        \"add_directory\": \"ডিরেক্টরি যুক্ত করুন\",\n        \"add_entity\": \"যুক্ত{entityType}\",\n        \"add_to_entity\": \"{entityType} এ যুক্ত করুন\",\n        \"allow\": \"সম্মত আছেন\",\n        \"allow_temporarily\": \"অস্থায়ীভাবে সম্মত আছেন\",\n        \"apply\": \"প্রয়োগ করুন\",\n        \"auto_tag\": \"স্বয়ংক্রিয়\",\n        \"backup\": \"জমানো\",\n        \"browse_for_image\": \"ইমেজ ব্রাউজের জন্যে…\",\n        \"cancel\": \"বাতিল\",\n        \"clean\": \"পরিষ্কার\",\n        \"clear\": \"পরিষ্কার\",\n        \"clear_back_image\": \"আগের ইমেজ পরিষ্কার করুন\",\n        \"clear_front_image\": \"সামনের ইমেজ পরিষ্কার করুন\",\n        \"clear_image\": \"ইমেজ পরিষ্কার করুন\",\n        \"close\": \"বন্ধ করুন\",\n        \"confirm\": \"নিশ্চিত\",\n        \"continue\": \"এগিয়ে চলুন\",\n        \"create\": \"তৈরি করুন\",\n        \"create_entity\": \"{entityType} তৈরি করুন\",\n        \"create_marker\": \"মার্কার তৈরি করুন\",\n        \"created_entity\": \"তৈরি হয়েছে {entity_type}: {entity_name}\",\n        \"customise\": \"সাজাও\",\n        \"delete\": \"মুছুন\",\n        \"delete_entity\": \"{entityType} মুছুন\",\n        \"delete_file\": \"ফাইল মুছুন\",\n        \"delete_file_and_funscript\": \"ফাইল মুছুন (সাথে funscript ও)\",\n        \"delete_generated_supporting_files\": \"জেনারেট হওয়া সমর্থিত ফাইলসমূহ মুছুন\",\n        \"disallow\": \"অনুমতি নাকচ করুন\",\n        \"download\": \"ডাউনলোড\",\n        \"download_backup\": \"জনানোগুলো ডাউনলোড করুন\",\n        \"edit\": \"সম্পাদন করুন\",\n        \"edit_entity\": \"{entityType} সম্পাদন করুন\",\n        \"export\": \"রপ্তানি\",\n        \"export_all\": \"সব রপ্তানি করুন…\",\n        \"find\": \"খোঁজ করুন\",\n        \"finish\": \"শেষ করুন\",\n        \"from_file\": \"ফাইল থেকে…\",\n        \"from_url\": \"ইউআরএল থেকে…\",\n        \"full_export\": \"পুরো রপ্তানি\",\n        \"full_import\": \"পুরো রপ্তানি\",\n        \"generate\": \"জেনারেট করুন\",\n        \"generate_thumb_default\": \"ডিফল্ট পূর্বচিত্র উৎপাদন করুন\",\n        \"generate_thumb_from_current\": \"বর্তমানের থেকে পূর্বচিত্র উৎপাদন করুন\",\n        \"hash_migration\": \"হ্যাশ স্থানান্তর\",\n        \"hide\": \"লুকান\",\n        \"hide_configuration\": \"কনফিগারেশন লুকান\",\n        \"identify\": \"চেনা নিশ্চিত করুন\",\n        \"ignore\": \"অগ্রাহ্য করুন\",\n        \"import\": \"আমদানি…\",\n        \"import_from_file\": \"ফাইল থেকে আমদানি করুন\",\n        \"logout\": \"লগ ছাড়ুন\",\n        \"make_primary\": \"প্রাথমিক বানান\",\n        \"merge\": \"ছাটুন\",\n        \"next_action\": \"পরবর্তী\",\n        \"not_running\": \"চলছে না\",\n        \"open_in_external_player\": \"বাইরের প্লেয়ার এ খুলুন\",\n        \"open_random\": \"অনির্ধারিত খুলুন\",\n        \"overwrite\": \"পূনরায় লিখুন\",\n        \"play_random\": \"অনির্ধারিত খেলুন\",\n        \"play_selected\": \"খেলা নির্বাচিত\",\n        \"preview\": \"পূর্বরূপ\",\n        \"previous_action\": \"পেছনে\",\n        \"refresh\": \"রিফ্রেশ\",\n        \"reload_plugins\": \"প্লাগিনগুলো পুনরায় লোড করুন\",\n        \"reload_scrapers\": \"বর্শিগুলো পুনরায় লোড করুন\",\n        \"remove\": \"সরান\",\n        \"remove_from_gallery\": \"গ্যালারি থেকে সরান\",\n        \"rename_gen_files\": \"উৎপাদিত ফাইলগুলোকে পুনরায় নামকরণ\",\n        \"rescan\": \"পুনরায় ছাকুন\",\n        \"reshuffle\": \"পুনরায় এলোমেলো করুন\",\n        \"running\": \"চলছে\",\n        \"save\": \"সংরক্ষণ\",\n        \"save_delete_settings\": \"ডিলিট করার সময় এই পছন্দগুলো ব্যাবহার করুন\",\n        \"save_filter\": \"ফিল্টার সংরক্ষণ করুন\",\n        \"scan\": \"ছাকুন\",\n        \"scrape\": \"গাথুন\",\n        \"scrape_query\": \"কুয়েরি গাথুন\",\n        \"scrape_scene_fragment\": \"ফ্রেগমেন্ট দিয়ে গাথুন\",\n        \"scrape_with\": \"দিয়ে গাথুন…\",\n        \"search\": \"অনুসন্ধান\",\n        \"select_all\": \"সব নির্বাচিত করুন\",\n        \"select_entity\": \"{entityType} নির্বাচিত করুন\",\n        \"select_folders\": \"ফোল্ডার নির্বাচন করুন\",\n        \"select_none\": \"কোনোকিছু না নির্বাচন করুন\",\n        \"selective_auto_tag\": \"নির্বাচনী স্বক্রিয় ট্যাগ\",\n        \"selective_clean\": \"নির্বাচনী পরিষ্কার\",\n        \"selective_scan\": \"নির্বাচনী ছাকা\",\n        \"set_as_default\": \"ডিফল্ট করুন\",\n        \"set_back_image\": \"পেছনের ইমেজ…\",\n        \"set_front_image\": \"সমানের ইমেজ…\",\n        \"set_image\": \"ইমেজ লাগান…\",\n        \"show\": \"দেখান\",\n        \"show_configuration\": \"কনফিগারেশন দেখান\",\n        \"skip\": \"এড়িয়ে যান\",\n        \"stop\": \"থামান\",\n        \"submit\": \"জমা করুন\",\n        \"submit_stash_box\": \"স্টাস-বক্স এ জমা করুন\",\n        \"submit_update\": \"আপডেট জমা করুন\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"আপনি কি নিশ্চিত পরিষ্কার করবেন?\\nএটা ডেটাবেইজের তথ্য মুছে দেবে সাথে সকল দৃশ্য ও গ্যালারির জন্যে উৎপাদিত কনেন্ট আর ফাইল সিস্টেমে পাওয়া যাবেনা।\",\n            \"dry_mode_selected\": \"শুষ্ক মোড নির্বাচিত। কোনো সত্যিকার মুছন হবেনা, শুধু লগ।\",\n            \"import_warning\": \"নিশ্চিত যে আমদানি করবেন? এটা ডাটাবেজ মুছবে এবং আপনার রপ্তানি করা মেটাডাটা থেকে পুনরায় আমদানি করবে।\"\n        },\n        \"temp_disable\": \"সাময়িক অকার্য করুন…\",\n        \"temp_enable\": \"সাময়িক কার্যকর করুন…\",\n        \"unset\": \"আনসেট\",\n        \"use_default\": \"ডিফল্ট ব্যাবহার করুন\",\n        \"view_random\": \"অনির্ধারিত দেখুন\",\n        \"optimise_database\": \"ডাটাবেস অপ্টিমাইজ করুন\",\n        \"reassign\": \"পুনরায় বরাদ্দ করুন\",\n        \"split\": \"বিভক্ত করুন\",\n        \"swap\": \"অদলবদল\",\n        \"choose_date\": \"তারিখ নির্ধারন করুন\",\n        \"add_manual_date\": \"ম্যানুয়াল তারিখ যোগ করুন\",\n        \"reload\": \"পুনরায় লোড করুন\",\n        \"add_play\": \"প্লে যুক্ত করুন\",\n        \"clear_date_data\": \"তারিখ ডেটা পরিষ্কার করুন\",\n        \"copy_to_clipboard\": \"ক্লিপবোর্ডে কপি করুন\",\n        \"create_chapters\": \"অধ্যায় তৈরি করুন\",\n        \"clean_generated\": \"তৈরি ফাইলগুলি পরিষ্কার করুন\",\n        \"create_parent_studio\": \"প্যারেন্ট স্টুডিও তৈরি করুন\",\n        \"download_anonymised\": \"বেনামে ডাউনলোড করুন\",\n        \"enable\": \"চালু করুন\",\n        \"encoding_image\": \"ছবি এনকোডিং…\",\n        \"disable\": \"নিষ্ক্রিয় করুন\",\n        \"remove_date\": \"তারিখ বাতিল করুন\"\n    },\n    \"actions_name\": \"ক্রিয়াসমুহ\",\n    \"age\": \"বয়স\",\n    \"aliases\": \"সন্ধিবন্ধরা\",\n    \"all\": \"সব\",\n    \"also_known_as\": \"আরো নামে জ্ঞাত\",\n    \"ascending\": \"আগানো\",\n    \"average_resolution\": \"মধ্যম রেসুলিউশন\",\n    \"birth_year\": \"জন্মের সন\",\n    \"birthdate\": \"জন্মতারিখ\",\n    \"bitrate\": \"বিটের হার\",\n    \"captions\": \"ক্যাপশনসমুহ\",\n    \"career_length\": \"বহকের দৈর্ঘ্য\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"সচল স্ট্যাস-বক্স প্রতিনিধি:\",\n            \"blacklist_desc\": \"কালো তালিকার আইটেমগুলো কুয়েরির বাইরে রাখা হয়েছে। মনে রাখুন তারা সাধারণ প্রতিক্রিয়া এবং একইভাবে কেস সেনসিটিভ। কিছু অক্ষরকে অবশ্যই ব্যাকস্ল্যাশ দিয়ে ছেড়ে রাখা যাবে: {chars_require_escape}\",\n            \"blacklist_label\": \"কালো তালিকা\",\n            \"query_mode_auto\": \"স্বয়ং\",\n            \"query_mode_auto_desc\": \"মেটাডাটা থাকলে ব্যাবহার করুন , অথবা ফাইলের নাম\",\n            \"query_mode_dir\": \"ডির\",\n            \"query_mode_dir_desc\": \"শুধু ভিডিও ফাইলের আত্মীয় ডিরেক্টরি ব্যাবহার করে\",\n            \"query_mode_filename\": \"ফাইলের নাম\",\n            \"query_mode_filename_desc\": \"শুধু ফাইলের নাম ব্যাবহার করে\",\n            \"query_mode_label\": \"কুয়েরি মোড\",\n            \"query_mode_metadata\": \"মেটাডাটা\",\n            \"query_mode_metadata_desc\": \"শুধু মেটাডাটা ব্যাবহার করে\",\n            \"query_mode_path\": \"পথ\",\n            \"query_mode_path_desc\": \"সম্পূর্ণ ফাইলের পথ ব্যাবহার করে\",\n            \"set_cover_desc\": \"যদি কোনোটা পাওয়া যায় তবে দৃশ্যের কভার পুনঃস্থাপন করুন।\",\n            \"set_cover_label\": \"দৃশ্যের কভার ইমেজ সেট করুন\",\n            \"set_tag_desc\": \"দৃশ্যে ট্যাগ লাগান, হয় পুনরায় লিখে নয় দৃশ্যে থাকা ট্যাগ ছেটে।\",\n            \"set_tag_label\": \"ট্যাগ সেট করুন\",\n            \"source\": \"উৎস\"\n        },\n        \"noun_query\": \"কুয়েরি\",\n        \"results\": {\n            \"duration_off\": \"{নাম্বারে} হলেও চলনকাল বন্ধ\",\n            \"duration_unknown\": \"চলনকাল অজানা\",\n            \"fp_found\": \"{fpCount, plural, =0 {কোনো নতুন আঙ্গুলেরছাপ পাওয়া যায়নি } অন্যান্য {নতুন অঙ্গুলেরছাপের মিল পাওয়া গেছে}}\",\n            \"fp_matches\": \"চলনকাল একটি মিল\",\n            \"fp_matches_multi\": \"চলনকাল মেলে {matchCount}/{durationLength}আঙ্গুলেরছাপ(গুলো)\",\n            \"hash_matches\": \"{হ্যাশ_ধরন} হলো একটা মিল\",\n            \"match_failed_already_tagged\": \"দৃশ্য আগে থেকেই ট্যাগকৃত\",\n            \"match_failed_no_result\": \"কোনো ফলাফল পাওয়া যায়নি\",\n            \"match_success\": \"দৃশ্য সফলভাবে ট্যাগ করা হয়েছে\",\n            \"phash_matches\": \"{গণনা} পিহ্যাশগুলোর মিল\",\n            \"unnamed\": \"বেনামী\"\n        },\n        \"verb_match_fp\": \"আঙ্গুলেরছাপ মিল করুন\",\n        \"verb_matched\": \"মিলিত\",\n        \"verb_scrape_all\": \"সবগুলোকে উঠান\",\n        \"verb_submit_fp\": \"জমান {এফপিগণনা,বহু,একক{# আঙ্গুলেরছাপ } অন্য { # আঙ্গুলেরছাপ }\",\n        \"verb_toggle_config\": \"{ নাড়ান} { কনফিগারেশন }\",\n        \"verb_toggle_unmatched\": \"{ নাড়ান } অমিলিত দৃশ্যগুলো\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"হ্যাশ নির্মাণ:\",\n            \"build_time\": \"নির্মাণের সময়:\",\n            \"check_for_new_version\": \"নতুন সংস্করণ যাচাই করুন\",\n            \"latest_version\": \"সর্বশেষ সংস্করণ\",\n            \"latest_version_build_hash\": \"সর্বশেষ সংস্করণের হ্যাশ নির্মাণ:\",\n            \"new_version_notice\": \"{ নতুন }\",\n            \"stash_discord\": \"আমাদের {url} চ্যানেলে যুক্ত থাকুন\",\n            \"stash_home\": \"স্টাস এর হোম {url} তে\",\n            \"stash_open_collective\": \"{url} এর মাধ্যমে আমাদের সাহায্য করুন\",\n            \"stash_wiki\": \"স্টাস {url} এর পাতা\",\n            \"version\": \"সংস্করণ\",\n            \"release_date\": \"মুক্তির তারিখ:\"\n        },\n        \"application_paths\": {\n            \"heading\": \"অ্যাপ্লিকেশন এর পথ\"\n        },\n        \"categories\": {\n            \"about\": \"সমন্ধে\",\n            \"changelog\": \"পরিবর্তনের লগ\",\n            \"interface\": \"ইন্টারফেস\",\n            \"logs\": \"লগগুলো\",\n            \"metadata_providers\": \"মেটাতথ্য প্রদানকারীরা\",\n            \"plugins\": \"সংযোজকসমূহ\",\n            \"scraping\": \"উঠান\",\n            \"security\": \"নিরাপত্তা\",\n            \"services\": \"সেবাসমূহ\",\n            \"system\": \"সিস্টেম\",\n            \"tasks\": \"কাজ\",\n            \"tools\": \"সরঞ্জামগুলো\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"অনুমোদন {tempIP}\",\n            \"allowed_ip_addresses\": \"আইপি ঠিকানা অনুমোদিত\",\n            \"allowed_ip_temporarily\": \"আইপি অস্থায়ীভাবে অনুমোদিত\",\n            \"default_ip_whitelist\": \"ডিফল্ট আইপি সাদাতালিকা\",\n            \"default_ip_whitelist_desc\": \"ডিফল্ট আইপি ঠিকানাগুলো DLNA একসেস করার অনুমতি দিন। সব আইপি থিকানাগুলোকে অনুমোদন করতে {wildcard} ব্যাবহার করুন।\",\n            \"disabled_dlna_temporarily\": \"DLNA অস্থায়ীভাবে অচল করুন\",\n            \"disallowed_ip\": \"আইপি অননুমোদিত\",\n            \"enabled_by_default\": \"প্রাথমিকভাবে সচলকৃত\",\n            \"enabled_dlna_temporarily\": \"DLNA অস্থায়ীভাবে সচল\",\n            \"network_interfaces\": \"ইন্টারফেসগুলো\",\n            \"network_interfaces_desc\": \"DLNA সার্ভার অন ফাঁস করে দেওয়ার ইন্টারফেস। একটা খালি তালিকা সবগুলো ইন্টারফেসে চলন ঘটায়। পরিবর্তন করতে পর DLNA পুনরায় শুরুর দরকার হয়।\",\n            \"recent_ip_addresses\": \"সাম্প্রতিক আইপি ঠিকানাগুলো\",\n            \"server_display_name\": \"সার্ভার প্রদর্শনের নাম\",\n            \"server_display_name_desc\": \"DLNA সার্ভারটার প্রদর্শিত নাম। প্রাথমিকভাবে {server_name} যদি ফাঁকা থাকে।\",\n            \"successfully_cancelled_temporary_behaviour\": \"অস্থায়ী আচার সফলভাবে বাতিল করা হয়েছে\",\n            \"until_restart\": \"পুনরায় শুরুর করা পর্যন্ত\",\n            \"video_sort_order\": \"ডিফল্ট ভিডিও সাজানোর অর্ডার\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"এপিআই চাবি\",\n                \"api_key_desc\": \"বাইরের সিস্টেমের জন্যে এপিআই চাবি। শুধুমাত্র যখন বাভারকারিনাম/পাসশব্দ কনফিগারকৃত তখনই প্রয়োজন। ব্যাবহারকারীনাম অবশ্যই এপিআই চাবি উৎপাদনের আগে সংরক্ষিত হতে হবে।\",\n                \"authentication\": \"অ্যাথনিটিকেশন\",\n                \"clear_api_key\": \"এপিআই চাবি পরিষ্কার করুন\",\n                \"credentials\": {\n                    \"description\": \"স্টাস থেকে বরখাস্ত করতে দস্তাবেজসমূহ।\",\n                    \"heading\": \"দস্তাবেজসমূহ\"\n                },\n                \"generate_api_key\": \"এপিআই চাবি উৎপাদন\",\n                \"log_file\": \"লগ ফাইল\",\n                \"log_file_desc\": \"যেখানে আউটপুট লগ হয় সেখানের ফাইল পথ। ফাইল লগ করতে খালি। পুনরায় শুরুর প্রয়োজন।\",\n                \"log_http\": \"http অ্যাকসেস লগ করুন\",\n                \"log_http_desc\": \"টার্মিনাল এ http অ্যাকসেস লগ করুন। পুনরায় শুরুর প্রয়োজন।\",\n                \"log_to_terminal\": \"টার্মিনালে লগ করুন\",\n                \"log_to_terminal_desc\": \"একটা ফাইলে সংযোজন হিসেবে টার্মিনালে লগ করে। সবসময় সত্য যদি ফাইল লগ করা অচল থাকে। পুনরায় শুরুর প্রয়োজন।\",\n                \"maximum_session_age\": \"সর্বোচ্চ সেশন এর বয়স\",\n                \"maximum_session_age_desc\": \"লগ ইন সেশনের মেয়াদ শেষ হওয়ার আগের সর্বোচ্চ অলস সময়, সেকেন্ড এ।\",\n                \"password\": \"পাসশব্দ\",\n                \"password_desc\": \"স্ট্যাস অ্যাকসেস করার পাসশব্দ। ব্যাবহারকারী  অথনিটিকেশিন অচল করতে খালি রাখুন\",\n                \"stash-box_integration\": \"স্ট্যাস-বক্স ইন্টিগ্রেশন\",\n                \"username\": \"ব্যাবহারকারীনাম\",\n                \"username_desc\": \"স্ট্যাস অ্যাকসেস করার ব্যাবহারকারীনাম। ব্যাবহাকারী অথনিটিকেশন অচল করতে খালি রাখুন\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"SQLite ডেটাবেস ফাইল জমানোর ডিরেক্টরির অবস্থান\",\n                \"heading\": \"জমা রাখা ডিরেক্টরির পথ\"\n            },\n            \"cache_location\": \"ক্যাশে ডিরেক্টরির অবস্থান\",\n            \"cache_path_head\": \"ক্যাশের পথ\"\n        }\n    },\n    \"true\": \"সত্য\",\n    \"view_all\": \"সবগুলো দেখুন\",\n    \"appears_with\": \"উপস্থিত হয়\",\n    \"between_and\": \"এবং\",\n    \"blobs_storage_type\": {\n        \"database\": \"ডেটাবেস\"\n    },\n    \"chapters\": \"অধ্যায়\",\n    \"years_old\": \"বছর পুরান\",\n    \"weight_kg\": \"ওজন (কেজি)\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} অবশ্যই YYYY-MM-DD এ হতে হবে\",\n        \"blank\": \"${path} শুন্য হওয়া যাবে না\",\n        \"required\": \"${path} অবশ্য পুরনীয়\",\n        \"unique\": \"${path} অনন্য হতে হবে\"\n    },\n    \"type\": \"ধরন\",\n    \"total\": \"মোট\",\n    \"unknown_date\": \"আজানা তারিখ\",\n    \"weight\": \"ওজন\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/ca-ES.json",
    "content": "{\n    \"actions\": {\n        \"allow\": \"Permet\",\n        \"allow_temporarily\": \"Permet temporalment\",\n        \"add_manual_date\": \"Afegeix una data manual\",\n        \"anonymise\": \"Anonimitzar\",\n        \"apply\": \"Aplica\",\n        \"auto_tag\": \"Etiqueta automàtica\",\n        \"backup\": \"Còpia de seguretat\",\n        \"browse_for_image\": \"Navega per la imatge…\",\n        \"cancel\": \"Cancel·la\",\n        \"choose_date\": \"Tria una data\",\n        \"clean\": \"Neteja\",\n        \"clean_generated\": \"Neteja els fitxers generats\",\n        \"clear\": \"Neteja\",\n        \"clear_back_image\": \"Neteja la imatge de fons\",\n        \"clear_date_data\": \"Neteja les dades de temps\",\n        \"clear_front_image\": \"Neteja la imatge frontal\",\n        \"clear_image\": \"Neteja la imatge\",\n        \"confirm\": \"Confirma\",\n        \"continue\": \"Continua\",\n        \"copy_to_clipboard\": \"Copia al porta-retalls\",\n        \"create_chapters\": \"Crea un capítol\",\n        \"create_marker\": \"Crea un marcador\",\n        \"create_parent_studio\": \"Crea un estudi pare\",\n        \"created_entity\": \"Creat {entity_type}: {entity_name}\",\n        \"customise\": \"Personalitza\",\n        \"delete_entity\": \"Suprimeix {entityType}\",\n        \"delete_file\": \"Suprimeix el fitxer\",\n        \"delete_file_and_funscript\": \"Suprimeix el fitxer (i funscript)\",\n        \"delete_generated_supporting_files\": \"Suprimeix els fitxers de suport generats\",\n        \"disable\": \"Desactiva\",\n        \"disallow\": \"No permetre\",\n        \"download_anonymised\": \"Baixa anònimament\",\n        \"download\": \"Baixa\",\n        \"download_backup\": \"Baixa una còpia de seguretat\",\n        \"edit_entity\": \"Editar {entityType}\",\n        \"enable\": \"Activa\",\n        \"export\": \"Exporta\",\n        \"export_all\": \"Exporta-ho tot…\",\n        \"find\": \"Cerca\",\n        \"from_file\": \"Des d'un fitxer…\",\n        \"full_export\": \"Exportació completa\",\n        \"full_import\": \"Importació completa\",\n        \"generate\": \"Genera\",\n        \"generate_thumb_from_current\": \"Genera la miniatura des de l'actual\",\n        \"hash_migration\": \"migració de hash\",\n        \"hide\": \"Amaga\",\n        \"hide_configuration\": \"Oculta la configuració\",\n        \"identify\": \"Identifica\",\n        \"ignore\": \"Ignora\",\n        \"import\": \"Importa…\",\n        \"logout\": \"Tanca la sessió\",\n        \"make_primary\": \"Fes primari\",\n        \"merge\": \"Fusiona\",\n        \"migrate_blobs\": \"Migra els Blobs\",\n        \"migrate_scene_screenshots\": \"Migra les captures de pantalla de l'escena\",\n        \"not_running\": \"sense executar\",\n        \"open_in_external_player\": \"Obre en un reproductor extern\",\n        \"open_random\": \"Obre aleatòriament\",\n        \"optimise_database\": \"Optimitza la base de dades\",\n        \"overwrite\": \"Sobreescriu\",\n        \"play_random\": \"Reprodueix aleatòriament\",\n        \"play_selected\": \"Reprodueix els seleccionats\",\n        \"preview\": \"Previsualització\",\n        \"previous_action\": \"Enrere\",\n        \"reassign\": \"Reassigna\",\n        \"refresh\": \"Actualitza\",\n        \"reload\": \"Torna a carregar\",\n        \"remove\": \"Suprimeix\",\n        \"remove_date\": \"Suprimeix la data\",\n        \"remove_from_gallery\": \"Suprimeix de la galeria\",\n        \"rename_gen_files\": \"Canvia el nom dels fitxers generats\",\n        \"rescan\": \"Torna a explorar\",\n        \"reshuffle\": \"Torna a barrejar\",\n        \"running\": \"s'està executant\",\n        \"save\": \"Desa\",\n        \"save_delete_settings\": \"Usa aquestes opcions per defecte en suprimir\",\n        \"save_filter\": \"Desa el filtre\",\n        \"scan\": \"Escaneja\",\n        \"scrape\": \"Rastrejar\",\n        \"scrape_query\": \"Consulta de rastreig\",\n        \"scrape_scene_fragment\": \"Rastreja per fragment\",\n        \"search\": \"Cerca\",\n        \"select_all\": \"Selecciona-ho tot\",\n        \"select_entity\": \"Selecciona {entityType}\",\n        \"select_none\": \"No seleccionis cap\",\n        \"selective_auto_tag\": \"Etiqueta automàtica selectiva\",\n        \"selective_clean\": \"Neteja selectiva\",\n        \"selective_scan\": \"Escaneig selectiu\",\n        \"set_as_default\": \"Estableix com a predeterminat\",\n        \"set_front_image\": \"Portada…\",\n        \"set_back_image\": \"Contraportada…\",\n        \"add\": \"Afegir\",\n        \"close\": \"Tanca\",\n        \"create\": \"Crea\",\n        \"reload_plugins\": \"Torna a carregar els plugins\",\n        \"reload_scrapers\": \"Torna a carregar els scrapers\",\n        \"delete\": \"Suprimeix\",\n        \"add_directory\": \"Afegeix un directori\",\n        \"assign_stashid_to_parent_studio\": \"Assigna l'identificador de la memòria a l'estudi pare existent i actualitza les metadades\",\n        \"edit\": \"Editar\",\n        \"encoding_image\": \"S'està codificant la imatge…\",\n        \"finish\": \"Finalitza\",\n        \"from_url\": \"Des de l'URL…\",\n        \"generate_thumb_default\": \"Genera la miniatura predeterminada\",\n        \"import_from_file\": \"Importa des d'un fitxer\",\n        \"next_action\": \"Següent\",\n        \"scrape_with\": \"Rastreja amb…\",\n        \"select_folders\": \"Selecciona les carpetes\",\n        \"view_random\": \"Visualitza aleatòriament\",\n        \"set_image\": \"Estableix la imatge…\",\n        \"show\": \"Mostra\",\n        \"show_configuration\": \"Mostra la configuració\",\n        \"skip\": \"Omet\",\n        \"split\": \"Divideix\",\n        \"stop\": \"Atura\",\n        \"submit\": \"Envia\",\n        \"submit_stash_box\": \"Envia a Stash-Box\",\n        \"submit_update\": \"Envia l'actualització\",\n        \"swap\": \"Intercanvia\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Estàs segur que vols netejar? Això suprimirà la informació de la base de dades i el contingut generat per a totes les escenes i galeries que ja no es troben al sistema de fitxers.\",\n            \"dry_mode_selected\": \"S'ha seleccionat el mode sec. No es produirà cap eliminació real, només es registrarà.\",\n            \"import_warning\": \"Esteu segur que voleu importar? Això suprimirà la base de dades i tornarà a importar de les metadades exportades.\"\n        },\n        \"temp_disable\": \"Desactiva temporalment…\",\n        \"temp_enable\": \"Activa temporalment…\",\n        \"unset\": \"Desactivat\",\n        \"use_default\": \"Utilitza el valor per defecte\",\n        \"view_history\": \"Mostra l'historial\",\n        \"add_entity\": \"Afegeix {entityType}\",\n        \"add_o\": \"Afageix O\",\n        \"add_play\": \"Agregar reproduir\",\n        \"add_to_entity\": \"Afegir a {entityType}\",\n        \"create_entity\": \"Crear {entityType}\",\n        \"add_sub_groups\": \"Afegeix subgrups\",\n        \"remove_from_containing_group\": \"Suprimeix del grup\"\n    },\n    \"appears_with\": \"Apareix amb\",\n    \"career_length\": \"Durada de la carrera\",\n    \"component_tagger\": {\n        \"config\": {\n            \"blacklist_desc\": \"Els elements de la llista negra estan exclosos de les consultes. Tingueu en compte que són expressions regulars i també insensibles a majúscules i minúscules. Alguns caràcters s'han d'escapar amb una barra inversa: {chars_require_escape}\",\n            \"set_cover_desc\": \"Reemplaça la portada de l'escena si se'n troba una.\",\n            \"set_tag_label\": \"Estableix les etiquetes\",\n            \"active_instance\": \"Instància activa de Stash-Box:\",\n            \"blacklist_label\": \"Llista negra\",\n            \"mark_organized_desc\": \"Marqueu immediatament l'escena com a Organitzada després de fer clic al botó Desa.\",\n            \"mark_organized_label\": \"Marca com a organitzat en desar\",\n            \"query_mode_auto\": \"Automàtic\",\n            \"query_mode_auto_desc\": \"Utilitza metadades si hi és present, o nom de fitxer\",\n            \"query_mode_dir\": \"Directori\",\n            \"query_mode_dir_desc\": \"Només usa el directori pare del fitxer de vídeo\",\n            \"query_mode_filename\": \"Nom del fitxer\",\n            \"query_mode_filename_desc\": \"Només usa el nom del fitxer\",\n            \"query_mode_label\": \"Mode de consulta\",\n            \"query_mode_metadata\": \"Metadades\",\n            \"query_mode_metadata_desc\": \"Només usa metadades\",\n            \"query_mode_path\": \"Camí\",\n            \"query_mode_path_desc\": \"Utilitza el camí complet del fitxer\",\n            \"set_cover_label\": \"Estableix la imatge de la portada de l'escena\",\n            \"set_tag_desc\": \"Adjunta etiquetes a l'escena, ja sigui sobreescrivint o fusionant amb etiquetes existents a l'escena.\",\n            \"source\": \"Font\"\n        },\n        \"results\": {\n            \"fp_found\": \"{fpCount, plural, =0 {No s'han trobat coincidències d'empremta digital noves} other {# s'han trobat coincidències d'empremta digital noves}}\",\n            \"match_failed_already_tagged\": \"Escena ja etiquetada\",\n            \"match_success\": \"Escena etiquetada correctament\",\n            \"phash_matches\": \"{count} PHashes coincideixen\",\n            \"unnamed\": \"Sense nom\",\n            \"duration_off\": \"Durada de sortida com a mínim {number}s\",\n            \"duration_unknown\": \"Durada desconeguda\",\n            \"fp_matches\": \"La duració coincideix\",\n            \"fp_matches_multi\": \"Coincidencia en la durada {matchCount}/{durationsLength} fingerprints\",\n            \"hash_matches\": \"{hash_type} coincideix\",\n            \"match_failed_no_result\": \"No s'han trobat resultats\"\n        },\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} escenes no coincidents\",\n        \"noun_query\": \"Consulta\",\n        \"verb_match_fp\": \"Coincidència d'empremtes\",\n        \"verb_matched\": \"Coincidència\",\n        \"verb_scrape_all\": \"Traça-ho tot\",\n        \"verb_submit_fp\": \"Enviar {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}\"\n    },\n    \"config\": {\n        \"categories\": {\n            \"metadata_providers\": \"Proveïdors de metadades\",\n            \"system\": \"Sistema\",\n            \"about\": \"Quant a\",\n            \"changelog\": \"Registre de canvis\",\n            \"interface\": \"Interfície\",\n            \"logs\": \"Registres\",\n            \"plugins\": \"Plugins\",\n            \"scraping\": \"Rastreig\",\n            \"security\": \"Seguretat\",\n            \"services\": \"Serveis\",\n            \"tasks\": \"Tasques\",\n            \"tools\": \"Eines\"\n        },\n        \"dlna\": {\n            \"default_ip_whitelist_desc\": \"Les adreces IP predeterminades permeten accedir a DLNA. Utilitzeu {wildcard} per permetre totes les adreces IP.\",\n            \"network_interfaces_desc\": \"Interfícies on exposar el servidor DLNA. Una llista buida resulta en executar-se a totes les interfícies. Requereix reiniciar el DLNA després de canviar.\",\n            \"video_sort_order\": \"Ordre predeterminat de l'ordenació del vídeo\",\n            \"allow_temp_ip\": \"Permetre {tempIP}\",\n            \"allowed_ip_addresses\": \"Adreces IP permeses\",\n            \"allowed_ip_temporarily\": \"IP permesa temporalment\",\n            \"default_ip_whitelist\": \"Llista blanca d'IP per defecte\",\n            \"disabled_dlna_temporarily\": \"DLNA desactivat temporalment\",\n            \"disallowed_ip\": \"IP no permesa\",\n            \"enabled_by_default\": \"Activat per defecte\",\n            \"enabled_dlna_temporarily\": \"Activa temporalment el DLNA\",\n            \"network_interfaces\": \"Interfícies\",\n            \"recent_ip_addresses\": \"Adreces IP recents\",\n            \"server_display_name\": \"Nom de visualització del servidor\",\n            \"server_display_name_desc\": \"Mostra el nom del servidor DLNA. Per defecte a {server_name} si està buit.\",\n            \"successfully_cancelled_temporary_behaviour\": \"S'ha cancel·lat correctament el comportament temporal\",\n            \"until_restart\": \"fins que es reiniciï\",\n            \"video_sort_order_desc\": \"Ordena els vídeos per defecte.\",\n            \"server_port\": \"Port del servidor\",\n            \"server_port_desc\": \"Port on executar el servidor DLNA. Requereix reiniciar el DLNA després de canviar.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"generate_api_key\": \"Genera la clau API\",\n                \"log_to_terminal\": \"Inicia la sessió al terminal\",\n                \"maximum_session_age_desc\": \"Temps màxim d'inactivitat abans que expiri una sessió d'inici de sessió, en segons. Requereix reinici.\",\n                \"username_desc\": \"Usuario para acceder a Stash. Dejar en blanco para deshabilitar la exigencia de identificación para accedir a la aplicación\",\n                \"api_key\": \"Clau API\",\n                \"api_key_desc\": \"Clau API per a sistemes externs. Només es requereix quan es configura el nom d'usuari/contrasenya. El nom d'usuari s'ha de desar abans de generar la clau de l'API.\",\n                \"authentication\": \"Autenticació\",\n                \"clear_api_key\": \"Neteja la clau API\",\n                \"credentials\": {\n                    \"description\": \"Credencials per restringir l'accés a stash.\",\n                    \"heading\": \"Credencials\"\n                },\n                \"log_file\": \"Fitxer de registre\",\n                \"log_file_desc\": \"Camí al fitxer al qual s'ha de registrar la sortida. En blanc per a desactivar el registre de fitxers. Requereix reiniciar.\",\n                \"log_http\": \"Registre d'accés http\",\n                \"log_http_desc\": \"Registra l'accés http al terminal. Requereix reiniciar.\",\n                \"log_to_terminal_desc\": \"Registres al terminal a més d'un fitxer. Sempre és cert si el registre de fitxers està desactivat. Requereix reiniciar.\",\n                \"maximum_session_age\": \"Temps màxim de sessió\",\n                \"password\": \"Contrasenya\",\n                \"password_desc\": \"Contrasenya per a accedir a Stash. Deixar en blanc per a deshabilitar l'exigència d'identificació per a accedir a l'aplicació\",\n                \"stash-box_integration\": \"Integració Stash-box\",\n                \"username\": \"Usuari\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Ubicació del directori per a copies de seguretat d'arxius de bases de dades SQLite\",\n                \"heading\": \"Ruta del directori de la còpia de seguretat\"\n            },\n            \"generated_file_naming_hash_desc\": \"Usa MD5 o oshash per al nom de fitxer generat. Canviar això requereix que totes les escenes tinguin el valor MD5/oshash aplicable poblat. Després de canviar aquest valor, els fitxers generats existents hauran de ser migrats o regenerats. Vegeu la pàgina Tasques per a la migració.\",\n            \"blobs_storage\": {\n                \"description\": \"On emmagatzemar informació binària com per exemple imatges d'escenes, actors, estudis i etiquetes. Després de canviar aquest valor, les dades existents han de ser migrats usant la tasca \\\"Migrar blobs\\\". Veure la pàgina \\\"Tasques\\\" per a la migració.\",\n                \"heading\": \"Emmagatzematge de dades binari\"\n            },\n            \"check_for_insecure_certificates_desc\": \"Alguns llocs utilitzen certificats ssl insegurs. Quan es desmarca, el raspador salta la comprovació dels certificats insegurs i permet fer el raspatge d'aquests llocs. Si obteniu un error de certificat en desfer-ho.\",\n            \"excluded_image_gallery_patterns_desc\": \"Expressions regulars d'arxius/rutes d'imatges que seran exclosos de l'escaneig i que seran afegits a la tasca de depuració/neteja\",\n            \"ffmpeg\": {\n                \"ffprobe_path\": {\n                    \"description\": \"Ruta a l'executable ffprobe (no sols la carpeta). Si està buida, ffprobe es resoldrà des de l'entorn a través de $PATH, el directori de configuració o $HOME/.stash\",\n                    \"heading\": \"Ruta de l'executable FFprobe\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avançat: Arguments addicionals per a ffmpeg abans del camp d'entrada al transcodificador de vídeos en temps real.\",\n                        \"heading\": \"Arguments entrada transcodificación FFmpeg en temps real\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avançat: Arguments addicionals per a ffmpeg abans del camp de sortida al transcodificador de vídeos en temps real.\",\n                        \"heading\": \"Arguments sortida transcodificación FFmpeg en temps real\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"Descàrrega FFmpeg en el directori de configuració i esborra les rutes de ffmpeg i ffprobe per a resoldre-les des del directori de configuració.\",\n                    \"heading\": \"Baixa FFmpeg\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"Ruta a l'executable ffmpeg (no sols la carpeta). Si està buida, ffmpeg es resoldrà des de l'entorn a través de $PATH, el directori de configuració o $HOME/.stash\",\n                    \"heading\": \"Ruta de l'executable FFmpeg\"\n                },\n                \"hardware_acceleration\": {\n                    \"desc\": \"Utilitza el maquinari disponible per a encodificar el vídeo en temps real.\",\n                    \"heading\": \"Encodificació maquinari FFmpeg\"\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avançat: Arguments addicionals per a ffmpeg abans del camp d'entrada en generar vídeos.\",\n                        \"heading\": \"Arguments entrada transcodificació FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avançat: Arguments addicionals per a ffmpeg abans del camp de sortida en generar vídeos.\",\n                        \"heading\": \"Arguments sortida transcodificació FFmpeg\"\n                    }\n                }\n            },\n            \"funscript_heatmap_draw_range_desc\": \"Dibuixar rang de moviment en l'eix \\\"i\\\" del mapa de calor generada. Els mapes de calor existents hauran de ser generats de nou després del canvi.\",\n            \"include_audio_head\": \"Incloure àudio\",\n            \"maximum_transcode_size_desc\": \"Mida màxima per als transcodificats generats\",\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Estableix a 0 per a la detecció automàtica. L'avís d'executar més tasques de les necessàries per aconseguir una utilització del 100% de la CPU disminuirà el rendiment i potencialment causarà altres problemes.\",\n            \"parallel_scan_head\": \"Escaneig/Generació en paral·lel\",\n            \"python_path\": {\n                \"description\": \"Ruta a l'executable python (no sols la carpeta). Usat per a script scrapers i plugins. Si està en blanc, python es resoldrà des de l'entorn\",\n                \"heading\": \"Ruta de l'executable Python\"\n            },\n            \"scraper_user_agent\": \"Identificador de l'agent d'usuari del navegador per a rastreig\",\n            \"scraper_user_agent_desc\": \"Identificador de l'agent d'usuari del navegador web (USER-AGENT) usat durant el rastreig mitjançant peticions http\",\n            \"scrapers_path\": {\n                \"description\": \"Ruta del directori de rastrejadors\",\n                \"heading\": \"Ruta de Rastrejadors\"\n            },\n            \"blobs_path\": {\n                \"description\": \"On emmagatzemar fitxers binaris. Només aplicable quan el sistema d'arxius és del tipus \\\"blob\\\". AVÍS: Canviar aquest paràmetre requereix moure manualment els fitxers.\",\n                \"heading\": \"Ruta de sistema d'arxius binari\"\n            },\n            \"cache_location\": \"Ruta de la cache. Requerit per a utilitzar streaming mitjançant HLS (per exemple dispositius Apple) o DASH.\",\n            \"cache_path_head\": \"Camí de la cache\",\n            \"calculate_md5_and_ohash_desc\": \"Calcula la suma de verificació MD5 a més de l'oshash. L'activació farà que les exploracions inicials siguin més lentes. El hash de nomenclatura de fitxers s'ha d'establir a oshash per desactivar el càlcul MD5.\",\n            \"calculate_md5_and_ohash_label\": \"Calcula MD5 per als vídeos\",\n            \"check_for_insecure_certificates\": \"Comprova si hi ha certificats insegurs\",\n            \"chrome_cdp_path\": \"Camí Chrome CDP\",\n            \"chrome_cdp_path_desc\": \"Ruta de l'arxiu de l'executable Chrome o una adreça remota (començant per http:// o https://, per exemple, http://localhost:9222/json/version) per a una instància Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Si aquesta opció està marcada es crearan automàticament galeries d'aquells directoris que contenen imatges.\",\n            \"create_galleries_from_folders_label\": \"Crear galeries des de directoris amb imatges\",\n            \"database\": \"Base de dades\",\n            \"db_path_head\": \"Camí de la base de dades\",\n            \"directory_locations_to_your_content\": \"Ubicacions dels directoris al vostre contingut\",\n            \"excluded_image_gallery_patterns_head\": \"Patrons d'imatges/galeries exclosos\",\n            \"excluded_video_patterns_desc\": \"Expressions regulars d'arxius/rutes de vídeos que seran exclosos de l'escaneig i que seran afegits a la tasca de depuració/neteja\",\n            \"excluded_video_patterns_head\": \"Patrons de vídeo exclosos\",\n            \"funscript_heatmap_draw_range\": \"Incloure rang en els mapes de calor generats\",\n            \"gallery_cover_regex_desc\": \"Expressió regular utilitzada per a identificar una imatge com a caràtula d'una galeria\",\n            \"gallery_cover_regex_label\": \"Patró de la portada de la galeria\",\n            \"gallery_ext_desc\": \"Llista delimitada per comes d'extensions de fitxers que s'identificaran com a fitxers zip de galeria.\",\n            \"gallery_ext_head\": \"Extensions zip de la galeria\",\n            \"generated_file_naming_hash_head\": \"Hash de nom d'arxiu generat\",\n            \"generated_files_location\": \"Ruta relativa del directori per als fitxers generats (marcadors d'escena, vistes prèvies d'escena, conjunts d'imatges o “sprites”, etc)\",\n            \"generated_path_head\": \"Ruta relativa per al directori d'arxius generats\",\n            \"hashing\": \"Hashing\",\n            \"heatmap_generation\": \"Generació mapa de calor Funscript\",\n            \"image_ext_desc\": \"Llista delimitada per comes de les extensions d'arxiu que seran identificades com a imatges.\",\n            \"image_ext_head\": \"Extensions d'imatge\",\n            \"include_audio_desc\": \"Inclou flux d'àudio quan es generin vistes prèvies.\",\n            \"logging\": \"Registre\",\n            \"maximum_streaming_transcode_size_desc\": \"Grandària màxima per a la transcodificació d'arxius de vídeo en directe\",\n            \"maximum_streaming_transcode_size_head\": \"Mida màxima de transcodificació de transmissió\",\n            \"maximum_transcode_size_head\": \"Mida màxima de transcodificació\",\n            \"metadata_path\": {\n                \"description\": \"Ubicació del directori utilitzada en realitzar una exportació o importació completa\",\n                \"heading\": \"Camí de les metadades\"\n            },\n            \"number_of_parallel_task_for_scan_generation_head\": \"Nombre de tasques paral·leles per a l'escaneig/generació\",\n            \"plugins_path\": {\n                \"description\": \"Ubicació del directori dels fitxers de configuració dels plugins\",\n                \"heading\": \"Camí dels plugins\"\n            },\n            \"preview_generation\": \"Previsualitzar generació de fitxers\",\n            \"scraping\": \"Rastreig\",\n            \"sqlite_location\": \"Ruta relativa per a la base de dades SQLite (requereix reinici). AVÍS: Emmagatzemar la base de dades en un sistema diferent al servidor Stash (per exemple a través de la xarxa) no està suportat!\",\n            \"video_ext_desc\": \"Llista delimitada per comes de les extensions d'arxiu que seran identificades com a vídeos.\",\n            \"video_ext_head\": \"Extensions de vídeo\",\n            \"video_head\": \"Vídeo\"\n        },\n        \"about\": {\n            \"build_hash\": \"Build hash:\",\n            \"build_time\": \"Temps de construcció:\",\n            \"check_for_new_version\": \"Comprova si hi ha una versió nova\",\n            \"latest_version\": \"Última versió\",\n            \"latest_version_build_hash\": \"Hash de l'última versió:\",\n            \"new_version_notice\": \"[NOVA]\",\n            \"release_date\": \"Data de llançament:\",\n            \"stash_discord\": \"Uneix-te al nostre canal {url}\",\n            \"stash_home\": \"Pàgina principal del projecte en {url}\",\n            \"stash_open_collective\": \"Donacions al projecte a través de {url}\",\n            \"stash_wiki\": \"Pàgina web {url}\",\n            \"version\": \"Versió\"\n        },\n        \"advanced_mode\": \"Mode avançat\",\n        \"application_paths\": {\n            \"heading\": \"Rutes de l'Aplicació\"\n        },\n        \"tasks\": {\n            \"dont_include_file_extension_as_part_of_the_title\": \"No incloure l'extensió de l'arxiu com a part del títol\",\n            \"generate_phashes_during_scan\": \"Generar hashes de percepció (Phashes)\",\n            \"generate_desc\": \"Generar imatge de suport, conjunts d'imatges, vídeo, vtt i resta d'arxius.\",\n            \"generate_phashes_during_scan_tooltip\": \"Per a cerca de duplicats i identificació d'escenes.\",\n            \"generate_previews_during_scan\": \"Generar imatges prèvies animades\",\n            \"generate_previews_during_scan_tooltip\": \"També generi vistes prèvies animades (webp), que només són necessàries quan el Tipus de vista prèvia de paret d'escena/marcador està configurat en Imatge animada. En navegar utilitzen menys CPU que les vistes prèvies de vídeo, però es generen a més d'ells i són arxius de major grandària.\",\n            \"identify\": {\n                \"and_create_missing\": \"i crear no existents\",\n                \"explicit_set_description\": \"Aquestes opcions s'usaran predeterminadament si no se sobreescriuen en les opcions específiques per a cada font.\",\n                \"identifying_scenes\": \"Identificant {num} {scene}\",\n                \"sources\": \"Fonts\",\n                \"tag_skipped_matches\": \"Etiquetar coincidències omeses amb\",\n                \"strategy\": \"Estratègia\",\n                \"tag_skipped_matches_tooltip\": \"Cree una etiqueta como 'Identificar: coincidencias múltiples' que pueda filtrar en la vista Etiquetador de escenas y elija la coincidencia correcta manualmente\",\n                \"tag_skipped_performer_tooltip\": \"Crea una etiqueta com \\\"Identificar: Artista amb un nom\\\" que pots filtrar en l'etiquetador d'escenes i triar com vols tractar a aquests artistes\",\n                \"tag_skipped_performers\": \"Etiqueta als intèrprets omesos amb\",\n                \"create_missing\": \"Crear no existents\",\n                \"default_options\": \"Opcions per defecte\",\n                \"description\": \"Obtenir automàticament metadades per a les escenes utilitzant una instància stash-box i els rastrejadors instal·lats.\",\n                \"field\": \"Camp\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Opcions de camp\",\n                \"heading\": \"Identificar\",\n                \"identifying_from_paths\": \"Identificació de les escenes que es trobin en les següents rutes\",\n                \"include_male_performers\": \"Incloure actors (homes)\",\n                \"set_cover_images\": \"Selecció automàtica de caràtula d'escena\",\n                \"set_organized\": \"Marcar escena com \\\"classificada\\\"\",\n                \"skip_multiple_matches\": \"Ometre coincidències que tinguin més d'un resultat\",\n                \"skip_multiple_matches_tooltip\": \"Si està desactivat i s'obté més d'un resultat, es triarà una coincidència a l'atzar\",\n                \"skip_single_name_performers\": \"Saltar intèrprets amb un sol nom sense desambiguació\",\n                \"skip_single_name_performers_tooltip\": \"Si això no està habilitat, s'aparellaran artistes que sovint són genèrics com Samantha o Olga\",\n                \"source\": \"Font\",\n                \"source_options\": \"Opcions de {source}\"\n            },\n            \"generate_sprites_during_scan\": \"Generar miniatures d'imatge del reproductor de vídeo\",\n            \"migrate_blobs\": {\n                \"description\": \"Migrar blobs al sistema d'emmagatzematge de blobs actual. Aquesta migració hauria d'executar-se després de canviar el sistema d'emmagatzematge de blobs. Opcionalment es poden esborrar les dades antigues després de la migració.\",\n                \"delete_old\": \"Esborrar dades antigues\"\n            },\n            \"incremental_import\": \"Importació gradual o progressiva des d'un arxiu zip d'exportació aportat per l'usuari.\",\n            \"maintenance\": \"Manteniment\",\n            \"migrate_scene_screenshots\": {\n                \"description\": \"Migrar captures de pantalla d'escenes al nou sistema d'emmagatzematge blob. Aquesta migració hauria d'executar-se migrar un sistema existent a la versió 0.20. Opcionalment es poden esborrar les captures de pantalla antigues després de la migració.\",\n                \"delete_files\": \"Esborrar fitxers de captures de pantalla\",\n                \"overwrite_existing\": \"Sobreescriure blobs existents amb dades de captures de pantalla\"\n            },\n            \"migrations\": \"Migracions\",\n            \"added_job_to_queue\": \"Afegit/a {operation_name} a la cua de treball\",\n            \"anonymise_and_download\": \"Realitza una còpia anònima de la base de dades i descàrrega el fitxer resultant.\",\n            \"anonymise_database\": \"Fa una còpia de la base de dades al directori de còpies de seguretat anonimitzant tota la informació sensible. Aquesta còpia es pot proveir a tercers per a solucionar i depurar problemes. La base de dades original no és modificada. La base de dades anonimitzada s'emmagatzema amb el format {filename_format}.\",\n            \"anonymising_database\": \"Anonimitzar la base de dades\",\n            \"auto_tag\": {\n                \"auto_tagging_paths\": \"Etiquetar automàticament les següents rutes\",\n                \"auto_tagging_all_paths\": \"Etiquetar automàticament totes les rutes\"\n            },\n            \"auto_tag_based_on_filenames\": \"Etiquetatge automatitzat del contingut basat en noms d'arxiu.\",\n            \"auto_tagging\": \"Auto-etiquetat\",\n            \"backing_up_database\": \"Guardant suport de la base de dades\",\n            \"backup_and_download\": \"Duu a terme una còpia de seguretat de la base de dades i la guarda en un fitxer de suport.\",\n            \"cleanup_desc\": \"Buscar fitxers eliminats del sistema d'arxius i eliminar-los de la base de dades. PRECAUCIÓ: aquesta és una acció destructiva.\",\n            \"clean_generated\": {\n                \"blob_files\": \"Arxius Blob\",\n                \"description\": \"Elimina els arxius generats sense una entrada corresponent en la base de dades.\",\n                \"image_thumbnails\": \"Miniatures d'imatges\",\n                \"image_thumbnails_desc\": \"Miniatures d'imatges i clips\",\n                \"markers\": \"Vista prèvia de marcadors\",\n                \"previews\": \"Vista prèvia d'escenes\",\n                \"previews_desc\": \"Vista prèvia d'escenes i miniatures\",\n                \"sprites\": \"Sprites d'escenes\",\n                \"transcodes\": \"Transcodificacions d'escenes\"\n            },\n            \"data_management\": \"Gestió de dades\",\n            \"defaults_set\": \"Les opcions per defecte s'han guardat i seran usades cada vegada que premis el botó de {action} en la pàgina de tasques.\",\n            \"empty_queue\": \"Actualment no hi ha tasques en execució.\",\n            \"export_to_json\": \"Exporta el contingut de la base de dades en format JSON en el directori de metadades.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Generant multimèdia de suport per a les escenes en les següents rutes\",\n                \"generating_scenes\": \"Generant multimèdia de suport per a {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"Generar vistes prèvies per a clips d'imatges\",\n            \"generate_sprites_during_scan_tooltip\": \"El conjunt d'imatges que es mostra sota el reproductor de vídeo per a facilitar la navegació.\",\n            \"generate_thumbnails_during_scan\": \"Generar miniatures de les imatges\",\n            \"generate_video_covers_during_scan\": \"Generar caràtules d'escenes\",\n            \"generate_video_previews_during_scan\": \"Generar vistes prèvies\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generar vistes prèvies en vídeo que es reprodueixen en passar el ratolí per sobre d'una escena\",\n            \"generated_content\": \"Contingut generat\",\n            \"import_from_exported_json\": \"Importar des del fitxer JSON exportat en el directori de metadades. Esborrarà la base de dades existent.\",\n            \"job_queue\": \"Cua de tasques\",\n            \"migrate_hash_files\": \"S'executarà després de fer un canvi de tipus de hash per a canviar de nom els fitxers generats al nou format hash.\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Hooks\",\n            \"available_plugins\": \"Connectors disponibles\",\n            \"installed_plugins\": \"Connectors instal·lats\",\n            \"triggers_on\": \"Es duu a terme durant\"\n        },\n        \"scraping\": {\n            \"entity_scrapers\": \"Rastrejadors de {entityType}\",\n            \"excluded_tag_patterns_head\": \"Patrons d'etiquetes exclosos\",\n            \"scraper\": \"Rastrejador\",\n            \"available_scrapers\": \"Rastrejadors disponibles\",\n            \"entity_metadata\": \"Metadades de {entityType}\",\n            \"excluded_tag_patterns_desc\": \"Expressions regulars de noms d'etiquetes per a excloure dels resultats del rastreig\",\n            \"installed_scrapers\": \"Rastrejadors instal·lats\",\n            \"scrapers\": \"Rastrejadors\",\n            \"search_by_name\": \"Cerca per nom\",\n            \"supported_types\": \"Tipus suportats\",\n            \"supported_urls\": \"Adreces web\"\n        },\n        \"stashbox\": {\n            \"endpoint\": \"Terminal de xarxa\",\n            \"add_instance\": \"Afegir instància stash-box\",\n            \"api_key\": \"Clau API\",\n            \"description\": \"Stash-box permet automatitzar l'etiquetatge d'escenes, actors i actrius, basat en empremtes dactilars i noms d'arxiu.\\nEl terminal de xarxa i la clau API poden ser trobats en la teva pàgina d'usuari de la instància stash-box. Els noms són requerits quan s'agregui més d'una instància stash-box.\",\n            \"graphql_endpoint\": \"Terminal de xarxa GraphQL\",\n            \"name\": \"Nom\",\n            \"title\": \"Terminal de xarxa Stash-box\"\n        },\n        \"library\": {\n            \"media_content_extensions\": \"Extensions de contingut multimèdia\",\n            \"exclusions\": \"Exclusions\",\n            \"gallery_and_image_options\": \"Opcions de Galeria i Imatge\"\n        },\n        \"logs\": {\n            \"log_level\": \"Nivell de registre\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transcodificació\"\n        }\n    },\n    \"actions_name\": \"Accions\",\n    \"age\": \"Edat\",\n    \"aliases\": \"Àlies\",\n    \"all\": \"tots\",\n    \"also_known_as\": \"També conegut com a\",\n    \"ascending\": \"Ascendent\",\n    \"audio_codec\": \"Còdec d'àudio\",\n    \"average_resolution\": \"Resolució mitjana\",\n    \"between_and\": \"i\",\n    \"birth_year\": \"Any de naixement\",\n    \"birthdate\": \"Data de naixement\",\n    \"bitrate\": \"Taxa de bits\",\n    \"blobs_storage_type\": {\n        \"database\": \"Base de dades\",\n        \"filesystem\": \"Sistema de fitxers\"\n    },\n    \"captions\": \"Subtítols\",\n    \"chapters\": \"Capítols\",\n    \"circumcised\": \"Circumcidat\",\n    \"circumcised_types\": {\n        \"CUT\": \"Tallat\",\n        \"UNCUT\": \"Sense tallar\"\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/countryNames/zh-TW.json",
    "content": "{\n  \"locale\": \"tw\",\n  \"countries\": {\n    \"AD\": \"安道爾\",\n    \"AE\": \"阿聯酋\",\n    \"AF\": \"阿富汗\",\n    \"AG\": \"安地卡及巴布達\",\n    \"AI\": \"安圭拉\",\n    \"AL\": \"阿爾巴尼亞\",\n    \"AM\": \"亞美尼亞\",\n    \"AO\": \"安哥拉\",\n    \"AQ\": \"南極洲\",\n    \"AR\": \"阿根廷\",\n    \"AS\": \"美屬薩摩亞\",\n    \"AT\": \"奧地利\",\n    \"AU\": \"澳大利亞\",\n    \"AW\": \"阿魯巴\",\n    \"AX\": \"奧蘭\",\n    \"AZ\": \"阿塞拜疆\",\n    \"BA\": \"波斯尼亞和黑塞哥維那\",\n    \"BB\": \"巴巴多斯\",\n    \"BD\": \"孟加拉國\",\n    \"BE\": \"比利時\",\n    \"BF\": \"布吉納法索\",\n    \"BG\": \"保加利亞\",\n    \"BH\": \"巴林\",\n    \"BI\": \"布隆迪\",\n    \"BJ\": \"貝寧\",\n    \"BL\": \"聖巴泰勒米\",\n    \"BM\": \"百慕大\",\n    \"BN\": \"文萊\",\n    \"BO\": \"玻利維亞\",\n    \"BQ\": \"加勒比荷蘭\",\n    \"BR\": \"巴西\",\n    \"BS\": \"巴哈馬\",\n    \"BT\": \"不丹\",\n    \"BV\": \"布韋島\",\n    \"BW\": \"博茨瓦納\",\n    \"BY\": \"白俄羅斯\",\n    \"BZ\": \"伯利茲\",\n    \"CA\": \"加拿大\",\n    \"CC\": \"科科斯（基林）群島\",\n    \"CD\": \"剛果（金)\",\n    \"CF\": \"中非\",\n    \"CG\": \"剛果（布)\",\n    \"CH\": \"瑞士\",\n    \"CI\": \"科特迪瓦\",\n    \"CK\": \"庫克群島\",\n    \"CL\": \"智利\",\n    \"CM\": \"喀麥隆\",\n    \"CN\": \"中國\",\n    \"CO\": \"哥倫比亞\",\n    \"CR\": \"哥斯達黎加\",\n    \"CU\": \"古巴\",\n    \"CV\": \"佛得角\",\n    \"CW\": \"庫拉索\",\n    \"CX\": \"聖誕島\",\n    \"CY\": \"賽普勒斯\",\n    \"CZ\": \"捷克\",\n    \"DE\": \"德國\",\n    \"DJ\": \"吉布提\",\n    \"DK\": \"丹麥\",\n    \"DM\": \"多米尼克\",\n    \"DO\": \"多米尼加\",\n    \"DZ\": \"阿爾及利亞\",\n    \"EC\": \"厄瓜多爾\",\n    \"EE\": \"愛沙尼亞\",\n    \"EG\": \"埃及\",\n    \"EH\": \"阿拉伯撒哈拉民主共和國\",\n    \"ER\": \"厄立特里亞\",\n    \"ES\": \"西班牙\",\n    \"ET\": \"衣索比亞\",\n    \"FI\": \"芬蘭\",\n    \"FJ\": \"斐濟\",\n    \"FK\": \"福克蘭群島\",\n    \"FM\": \"密克羅尼西亞聯邦\",\n    \"FO\": \"法羅群島\",\n    \"FR\": \"法國\",\n    \"GA\": \"加彭\",\n    \"GB\": \"英國\",\n    \"GD\": \"格瑞那達\",\n    \"GE\": \"格魯吉亞\",\n    \"GF\": \"法屬圭亞那\",\n    \"GG\": \"根西\",\n    \"GH\": \"加納\",\n    \"GI\": \"直布羅陀\",\n    \"GL\": \"格陵蘭\",\n    \"GM\": \"岡比亞\",\n    \"GN\": \"幾內亞\",\n    \"GP\": \"瓜德羅普\",\n    \"GQ\": \"赤道幾內亞\",\n    \"GR\": \"希臘\",\n    \"GS\": \"南喬治亞和南桑威奇群島\",\n    \"GT\": \"危地馬拉\",\n    \"GU\": \"關島\",\n    \"GW\": \"幾內亞比紹\",\n    \"GY\": \"圭亞那\",\n    \"HK\": \"香港\",\n    \"HM\": \"赫德島和麥克唐納群島\",\n    \"HN\": \"宏都拉斯\",\n    \"HR\": \"克羅地亞\",\n    \"HT\": \"海地\",\n    \"HU\": \"匈牙利\",\n    \"ID\": \"印尼\",\n    \"IE\": \"愛爾蘭\",\n    \"IL\": \"以色列\",\n    \"IM\": \"馬恩島\",\n    \"IN\": \"印度\",\n    \"IO\": \"英屬印度洋領地\",\n    \"IQ\": \"伊拉克\",\n    \"IR\": \"伊朗\",\n    \"IS\": \"冰島\",\n    \"IT\": \"意大利\",\n    \"JE\": \"澤西\",\n    \"JM\": \"牙買加\",\n    \"JO\": \"約旦\",\n    \"JP\": \"日本\",\n    \"KE\": \"肯尼亞\",\n    \"KG\": \"吉爾吉斯斯坦\",\n    \"KH\": \"柬埔寨\",\n    \"KI\": \"基里巴斯\",\n    \"KM\": \"科摩羅\",\n    \"KN\": \"聖基茨和尼維斯\",\n    \"KP\": \"朝鮮\",\n    \"KR\": \"韓國\",\n    \"KW\": \"科威特\",\n    \"KY\": \"開曼群島\",\n    \"KZ\": \"哈薩克斯坦\",\n    \"LA\": \"老撾\",\n    \"LB\": \"黎巴嫩\",\n    \"LC\": \"聖盧西亞\",\n    \"LI\": \"列支敦斯登\",\n    \"LK\": \"斯里蘭卡\",\n    \"LR\": \"利比里亞\",\n    \"LS\": \"賴索托\",\n    \"LT\": \"立陶宛\",\n    \"LU\": \"盧森堡\",\n    \"LV\": \"拉脫維亞\",\n    \"LY\": \"利比亞\",\n    \"MA\": \"摩洛哥\",\n    \"MC\": \"摩納哥\",\n    \"MD\": \"摩爾多瓦\",\n    \"ME\": \"蒙特內哥羅\",\n    \"MF\": \"法屬聖馬丁\",\n    \"MG\": \"馬達加斯加\",\n    \"MH\": \"馬紹爾群島\",\n    \"MK\": \"馬其頓\",\n    \"ML\": \"馬里\",\n    \"MM\": \"緬甸\",\n    \"MN\": \"蒙古\",\n    \"MO\": \"澳門\",\n    \"MP\": \"北馬里亞納群島\",\n    \"MQ\": \"馬提尼克\",\n    \"MR\": \"毛里塔尼亞\",\n    \"MS\": \"蒙特塞拉特\",\n    \"MT\": \"馬爾他\",\n    \"MU\": \"模里西斯\",\n    \"MV\": \"馬爾地夫\",\n    \"MW\": \"馬拉維\",\n    \"MX\": \"墨西哥\",\n    \"MY\": \"馬來西亞\",\n    \"MZ\": \"莫桑比克\",\n    \"NA\": \"納米比亞\",\n    \"NC\": \"新喀裡多尼亞\",\n    \"NE\": \"尼日爾\",\n    \"NF\": \"諾福克島\",\n    \"NG\": \"奈及利亞\",\n    \"NI\": \"尼加拉瓜\",\n    \"NL\": \"荷蘭\",\n    \"NO\": \"挪威\",\n    \"NP\": \"尼泊爾\",\n    \"NR\": \"瑙魯\",\n    \"NU\": \"紐埃\",\n    \"NZ\": \"新西蘭\",\n    \"OM\": \"阿曼\",\n    \"PA\": \"巴拿馬\",\n    \"PE\": \"秘魯\",\n    \"PF\": \"法屬玻里尼西亞\",\n    \"PG\": \"巴布亞新幾內亞\",\n    \"PH\": \"菲律賓\",\n    \"PK\": \"巴基斯坦\",\n    \"PL\": \"波蘭\",\n    \"PM\": \"聖皮埃爾和密克隆\",\n    \"PN\": \"皮特凱恩群島\",\n    \"PR\": \"波多黎各\",\n    \"PS\": \"巴勒斯坦\",\n    \"PT\": \"葡萄牙\",\n    \"PW\": \"帛琉\",\n    \"PY\": \"巴拉圭\",\n    \"QA\": \"卡塔爾\",\n    \"RE\": \"留尼汪\",\n    \"RO\": \"羅馬尼亞\",\n    \"RS\": \"塞爾維亞\",\n    \"RU\": \"俄羅斯\",\n    \"RW\": \"盧旺達\",\n    \"SA\": \"沙烏地阿拉伯\",\n    \"SB\": \"所羅門群島\",\n    \"SC\": \"塞舌爾\",\n    \"SD\": \"蘇丹\",\n    \"SE\": \"瑞典\",\n    \"SG\": \"新加坡\",\n    \"SH\": \"聖赫勒拿\",\n    \"SI\": \"斯洛維尼亞\",\n    \"SJ\": \"斯瓦爾巴群島和揚馬延島\",\n    \"SK\": \"斯洛伐克\",\n    \"SL\": \"塞拉利昂\",\n    \"SM\": \"聖馬力諾\",\n    \"SN\": \"塞內加爾\",\n    \"SO\": \"索馬利亞\",\n    \"SR\": \"蘇里南\",\n    \"SS\": \"南蘇丹\",\n    \"ST\": \"聖多美和普林西比\",\n    \"SV\": \"薩爾瓦多\",\n    \"SX\": \"荷屬聖馬丁\",\n    \"SY\": \"敘利亞\",\n    \"SZ\": \"斯威士蘭\",\n    \"TC\": \"特克斯和凱科斯群島\",\n    \"TD\": \"乍得\",\n    \"TF\": \"法屬南部領地\",\n    \"TG\": \"多哥\",\n    \"TH\": \"泰國\",\n    \"TJ\": \"塔吉克斯坦\",\n    \"TK\": \"托克勞\",\n    \"TL\": \"東帝汶\",\n    \"TM\": \"土庫曼斯坦\",\n    \"TN\": \"突尼西亞\",\n    \"TO\": \"湯加\",\n    \"TR\": \"土耳其\",\n    \"TT\": \"千里達及托巴哥\",\n    \"TV\": \"圖瓦盧\",\n    \"TW\": \"臺灣\",\n    \"TZ\": \"坦桑尼亞\",\n    \"UA\": \"烏克蘭\",\n    \"UG\": \"烏干達\",\n    \"UM\": \"美國本土外小島嶼\",\n    \"US\": \"美國\",\n    \"UY\": \"烏拉圭\",\n    \"UZ\": \"烏茲別克斯坦\",\n    \"VA\": \"梵蒂岡\",\n    \"VC\": \"聖文森及格瑞那丁\",\n    \"VE\": \"委內瑞拉\",\n    \"VG\": \"英屬維爾京群島\",\n    \"VI\": \"美屬維爾京群島\",\n    \"VN\": \"越南\",\n    \"VU\": \"瓦努阿圖\",\n    \"WF\": \"瓦利斯和富圖納\",\n    \"WS\": \"薩摩亞\",\n    \"YE\": \"葉門\",\n    \"YT\": \"馬約特\",\n    \"ZA\": \"南非\",\n    \"ZM\": \"尚比亞\",\n    \"ZW\": \"辛巴威\",\n    \"XK\": \"科索沃\"\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/cs-CZ.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Přidat\",\n        \"add_directory\": \"Přidat adresář\",\n        \"add_entity\": \"Přidat {entityType}\",\n        \"add_to_entity\": \"Přidat do {entityType}\",\n        \"allow\": \"Povolit\",\n        \"allow_temporarily\": \"Povolit dočasně\",\n        \"apply\": \"Použít\",\n        \"auto_tag\": \"Auto Tag\",\n        \"backup\": \"Záloha\",\n        \"browse_for_image\": \"Vybrat obrázek…\",\n        \"cancel\": \"Zrušit\",\n        \"clean\": \"Vyčistit\",\n        \"clear\": \"Vyčistit\",\n        \"clear_back_image\": \"Vymazat obrázek zadní strany\",\n        \"clear_front_image\": \"Vymazat obrázek přední strany\",\n        \"clear_image\": \"Vymazat obrázek\",\n        \"close\": \"Zavřít\",\n        \"confirm\": \"Potvrdit\",\n        \"continue\": \"Pokračovat\",\n        \"create\": \"Vytvořit\",\n        \"create_entity\": \"Vytvořit {entityType}\",\n        \"create_marker\": \"Vytvořit značku\",\n        \"created_entity\": \"Vytvořen {entity_type}: {entity_name}\",\n        \"customise\": \"Customizovat\",\n        \"delete\": \"Odstranit\",\n        \"delete_entity\": \"Odstranit {entityType}\",\n        \"delete_file\": \"Odstranit soubor\",\n        \"delete_file_and_funscript\": \"Odstranit soubor (a funscript)\",\n        \"delete_generated_supporting_files\": \"Odstranit generované podpůrné soubory\",\n        \"disallow\": \"Zakázat\",\n        \"download\": \"Stáhnout\",\n        \"download_backup\": \"Stáhnout zálohu\",\n        \"edit\": \"Upravit\",\n        \"edit_entity\": \"Upravit {entityType}\",\n        \"export\": \"Exportovat\",\n        \"export_all\": \"Exportovat vše…\",\n        \"find\": \"Hledat\",\n        \"finish\": \"Dokončit\",\n        \"from_file\": \"Ze souboru…\",\n        \"from_url\": \"Z URL…\",\n        \"full_export\": \"Úplný export\",\n        \"full_import\": \"Úplný import\",\n        \"generate\": \"Generovat\",\n        \"generate_thumb_default\": \"Vygenerovat výchozí miniaturu\",\n        \"generate_thumb_from_current\": \"Vygenerovat miniaturu z aktuální\",\n        \"hash_migration\": \"Hash migrace\",\n        \"hide\": \"Skrýt\",\n        \"hide_configuration\": \"Skrýt konfiguraci\",\n        \"identify\": \"Identifikovat\",\n        \"ignore\": \"Ignorovat\",\n        \"import\": \"Importovat…\",\n        \"import_from_file\": \"Importovat ze souboru\",\n        \"logout\": \"Odhlásit se\",\n        \"make_primary\": \"Natavit jako primární\",\n        \"merge\": \"Sloučit\",\n        \"next_action\": \"Další\",\n        \"not_running\": \"neběží\",\n        \"open_in_external_player\": \"Otevřít v externím přehrávači\",\n        \"open_random\": \"Otevřít náhodný\",\n        \"overwrite\": \"Přepsat\",\n        \"play_random\": \"Přehrát náhodný\",\n        \"play_selected\": \"Přehrát vybrané\",\n        \"preview\": \"Náhled\",\n        \"previous_action\": \"Zpět\",\n        \"reassign\": \"Přeřadit\",\n        \"refresh\": \"Obnovit\",\n        \"reload_plugins\": \"Znovu načíst pluginy\",\n        \"reload_scrapers\": \"Znovu načíst scrapery\",\n        \"remove\": \"Odebrat\",\n        \"remove_from_gallery\": \"Odebrat z galerie\",\n        \"rename_gen_files\": \"Přejmenovat generované soubory\",\n        \"rescan\": \"Skenovat znovu\",\n        \"reshuffle\": \"Promíchat\",\n        \"running\": \"běží\",\n        \"save\": \"Uložit\",\n        \"save_delete_settings\": \"Použít tyto možnosti defaultně při mazání\",\n        \"save_filter\": \"Uložit filtr\",\n        \"scan\": \"Skenovat\",\n        \"scrape\": \"Stáhnout informace\",\n        \"scrape_query\": \"Scrape dotaz\",\n        \"scrape_scene_fragment\": \"Scrape dle fragmentu\",\n        \"scrape_with\": \"Scrape pomocí…\",\n        \"search\": \"Hledat\",\n        \"select_all\": \"Vybrat vše\",\n        \"select_entity\": \"Vybrat {entityType}\",\n        \"select_folders\": \"Vybrat složky\",\n        \"select_none\": \"Vybrat nic\",\n        \"selective_auto_tag\": \"Selektivní Auto Tag\",\n        \"selective_clean\": \"Selektivní čištění\",\n        \"selective_scan\": \"Selektivní Scan\",\n        \"set_as_default\": \"Nastavit jako výchozí\",\n        \"set_back_image\": \"Zadní obrázek…\",\n        \"set_front_image\": \"Přední obrázek…\",\n        \"set_image\": \"Nastavit obrázek…\",\n        \"show\": \"Zobrazit\",\n        \"show_configuration\": \"Zobrazit konfiguraci\",\n        \"skip\": \"Přeskočit\",\n        \"split\": \"Rozdělit\",\n        \"stop\": \"Zastavit\",\n        \"submit\": \"Publikovat\",\n        \"submit_stash_box\": \"Publikovat do Stash-Box\",\n        \"submit_update\": \"Publikovat aktualizaci\",\n        \"swap\": \"Prohodit\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Chcete doopravdy provést vyčištění databáze? Tato operace vymaže informace z databáze a generovaný obsah pro všechny scény a galerie, které se již nenacházejí v souborovém systému.\",\n            \"dry_mode_selected\": \"Vybrán \\\"Dry Mode\\\". Nic nebude smazáno, pouze logováno.\",\n            \"import_warning\": \"Chcete doopravdy provést import? Tato operace smaže databázi a znovu naimportuje Vaše exportovaná metadata.\"\n        },\n        \"temp_disable\": \"Zakázat dočasně…\",\n        \"temp_enable\": \"Povolit dočasně…\",\n        \"unset\": \"Odnastavit\",\n        \"use_default\": \"Použít výchozí\",\n        \"view_random\": \"Zobrazit náhodný\",\n        \"create_parent_studio\": \"Vytvořit nadřazené studio\",\n        \"encoding_image\": \"Enkóduji obrázek…\",\n        \"optimise_database\": \"Optimalizovat databázi\",\n        \"copy_to_clipboard\": \"Zkopírovat do schránky\",\n        \"disable\": \"Zakázat\",\n        \"download_anonymised\": \"Stáhnout anonymizovaně\",\n        \"enable\": \"Povolit\",\n        \"migrate_blobs\": \"Zmigrovat bloby\",\n        \"migrate_scene_screenshots\": \"Migrace snímků obrazovky scén\",\n        \"add_o\": \"Přidat O\",\n        \"anonymise\": \"Anonymizovat\",\n        \"assign_stashid_to_parent_studio\": \"Přiřaď Stash ID k existujícímu nadřazenému studiu a aktualizuj metadata\",\n        \"choose_date\": \"Vyberte datum\",\n        \"create_chapters\": \"Vytvořit kapitolu\",\n        \"clear_date_data\": \"Vymazat informace o datumech\",\n        \"reload\": \"Načíst znovu\",\n        \"clean_generated\": \"Vyčistit vygenerované soubory\",\n        \"remove_date\": \"Odstranit datum\",\n        \"add_manual_date\": \"Přidat datum ručně\",\n        \"add_play\": \"Přidat přehrávání\",\n        \"view_history\": \"Zobrazit historii\",\n        \"reset_cover\": \"Obnovit výchozí obal\",\n        \"add_sub_groups\": \"Přidat podskupinu\",\n        \"set_cover\": \"Nastavit jako obal\",\n        \"remove_from_containing_group\": \"Odstranit ze skupiny\",\n        \"reset_resume_time\": \"Obnovit čas pokračování\",\n        \"reset_play_duration\": \"Obnovit dobu přehrávání\",\n        \"sidebar\": {\n            \"close\": \"Zavřít postranní panel\",\n            \"open\": \"Zobrazit postranní panel\",\n            \"toggle\": \"Boční panel\"\n        },\n        \"play\": \"Přehrát\",\n        \"show_results\": \"Zobrazit výsledky\",\n        \"show_count_results\": \"Zobrazit {count} výsledků\",\n        \"load\": \"Načíst\",\n        \"load_filter\": \"Načíst filtr\",\n        \"add_stash_id\": \"Přidat Stash ID\",\n        \"create_new\": \"Vytvořit nový\",\n        \"save_and_new\": \"Ulož & Nový\",\n        \"invert_selection\": \"Obrátit výběr\"\n    },\n    \"actions_name\": \"Akce\",\n    \"age\": \"Věk\",\n    \"aliases\": \"Aliasy\",\n    \"all\": \"vše\",\n    \"also_known_as\": \"Též známá jako\",\n    \"ascending\": \"Vzestupně\",\n    \"average_resolution\": \"Střední rozlišení\",\n    \"between_and\": \"a\",\n    \"birth_year\": \"Rok narození\",\n    \"birthdate\": \"Datum narození\",\n    \"bitrate\": \"Bitová rychlost\",\n    \"captions\": \"Titulky\",\n    \"career_length\": \"Délka kariéry\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktivní Stash-Box instance:\",\n            \"blacklist_desc\": \"Položky na blacklistu jsou vyňaty z dotazů. Poznámka: Jedná se o regulární výrazy, nerozlišující malá a velká písmena. Některém znakům, pro jejich správnou interpretaci, musí předcházet znak zpětného lomítka: {chars_require_escape}\",\n            \"blacklist_label\": \"Blacklist\",\n            \"query_mode_auto\": \"Automaticky\",\n            \"query_mode_auto_desc\": \"Používá metadata, pokud jsou k dispozici nebo název souboru\",\n            \"query_mode_dir\": \"Složka\",\n            \"query_mode_dir_desc\": \"Používá pouze nadřazený adresář video souboru\",\n            \"query_mode_filename\": \"Název souboru\",\n            \"query_mode_filename_desc\": \"Používá pouze název souboru\",\n            \"query_mode_label\": \"Režim dotazu\",\n            \"query_mode_metadata\": \"Metadata\",\n            \"query_mode_metadata_desc\": \"Používá pouze metadata\",\n            \"query_mode_path\": \"Cesta\",\n            \"query_mode_path_desc\": \"Používá úplnou cestu k souboru\",\n            \"set_cover_desc\": \"Nahradit obal scény, pokud je nějaký nalezen.\",\n            \"set_cover_label\": \"Nastavit obrázek obalu scény\",\n            \"set_tag_desc\": \"Přidat tagy ke scéně, buď přepsáním nebo sloučením existujících tagů na scéně.\",\n            \"set_tag_label\": \"Nastavit tagy\",\n            \"source\": \"Zdroj\",\n            \"mark_organized_desc\": \"Po kliknutí na tlačítko Uložit okamžitě označte scénu jako Uspořádanou.\",\n            \"mark_organized_label\": \"Označit jako Uspořádané při uložení\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Duplikovat položku blacklistu\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Pohlaví účinkujicích\",\n                \"description\": \"Účinkující s těmito pohlavími budou zobrazeni při tagování scén.\"\n            }\n        },\n        \"noun_query\": \"Dotaz\",\n        \"results\": {\n            \"duration_off\": \"Doba trvání se liší minálně o {number}s\",\n            \"duration_unknown\": \"Neznámá délka\",\n            \"fp_found\": \"{fpCount, plural, =0 {Žádné nové otisky nebyly nalezeny} other {# nových otisků bylo nalezeno}}\",\n            \"fp_matches\": \"Délka videa je shodná\",\n            \"fp_matches_multi\": \"Délka videa se shoduje u {matchCount}/{durationsLength} otisku(ů)\",\n            \"hash_matches\": \"{hash_type} se shodují\",\n            \"match_failed_already_tagged\": \"Scéna byla již otagována\",\n            \"match_failed_no_result\": \"Nebyly nalezeny žádné výsledky\",\n            \"match_success\": \"Scéna úspěšně otagována\",\n            \"phash_matches\": \"{count} PHash(ů) se shoduje\",\n            \"unnamed\": \"Bezejmený\"\n        },\n        \"verb_match_fp\": \"Identifikovat otisky\",\n        \"verb_matched\": \"Identifikováno\",\n        \"verb_scrape_all\": \"Scrape vše\",\n        \"verb_submit_fp\": \"Publikovat {fpCount, plural, one{# otisk} other{# otisky}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} neidentifikované scény\",\n        \"verb_add_as_alias\": \"Přidat scrapované jméno jako alias\",\n        \"verb_link_existing\": \"Odkaz na existujicí\",\n        \"verb_match_tag\": \"Odpovídajicí tag\",\n        \"verb_scrape_selected\": \"Scrapovat Vybrané\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Vytvořit hash:\",\n            \"build_time\": \"Čas sestavení:\",\n            \"check_for_new_version\": \"Zkontrolovat existenci nové verze\",\n            \"latest_version\": \"Nejnovější verze\",\n            \"latest_version_build_hash\": \"Hash sestavení poslední verze:\",\n            \"new_version_notice\": \"[NOVÝ]\",\n            \"stash_discord\": \"Připojte se na náš {url} kanál\",\n            \"stash_home\": \"Stash domovská stránka na {url}\",\n            \"stash_open_collective\": \"Podpořte nás skrze {url}\",\n            \"stash_wiki\": \"Stash {url} stránka\",\n            \"version\": \"Verze\",\n            \"release_date\": \"Datum vydání:\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Cesta k aplikaci\"\n        },\n        \"categories\": {\n            \"about\": \"O programu\",\n            \"changelog\": \"Seznam změn\",\n            \"interface\": \"Rozhraní\",\n            \"logs\": \"Logy\",\n            \"metadata_providers\": \"Poskytovatelé Metadat\",\n            \"plugins\": \"Pluginy\",\n            \"scraping\": \"Scrapování\",\n            \"security\": \"Zabezpečení\",\n            \"services\": \"Služby\",\n            \"system\": \"Systém\",\n            \"tasks\": \"Úlohy\",\n            \"tools\": \"Nástroje\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Povolit IP {tempIP}\",\n            \"allowed_ip_addresses\": \"Povolené IP adresy\",\n            \"allowed_ip_temporarily\": \"IP adresa dočasně povolena\",\n            \"default_ip_whitelist\": \"Seznam povolených výchozích IP adres\",\n            \"default_ip_whitelist_desc\": \"Výchozí IP adresy umožňují přístup k DLNA. Použijte {wildcard} k povolení všech IP adres.\",\n            \"disabled_dlna_temporarily\": \"Dočasně zakázáno DLNA\",\n            \"disallowed_ip\": \"Nepovolená IP adresa\",\n            \"enabled_by_default\": \"Povoleno ve výchozím nastavení\",\n            \"enabled_dlna_temporarily\": \"DLNA dočasně povoleno\",\n            \"network_interfaces\": \"Rozhraní\",\n            \"network_interfaces_desc\": \"Rozhraní ke spuštění DLNA serveru aktivní. Prázdný seznam má za následek spuštění na všech rozhraních. Po změně je nezbytné restartovat DLNA.\",\n            \"recent_ip_addresses\": \"Nedávné IP adresy\",\n            \"server_display_name\": \"Jméno serveru\",\n            \"server_display_name_desc\": \"Název DLNA serveru. Výchozí hodnota {} pokud je pole prázdné.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Úspěšně zrušeno dočasné chování\",\n            \"until_restart\": \"pouze do restartu\",\n            \"video_sort_order\": \"Výchozí řazení videa\",\n            \"video_sort_order_desc\": \"Nastav řazení videí na výchozí.\",\n            \"server_port\": \"Port serveru\",\n            \"server_port_desc\": \"Port, na kterém poběží DLNA server. Po změně, vyžaduje DLNA restart.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API klíč\",\n                \"api_key_desc\": \"API klíč pro externí systémy. Vyžadován pouze tehdy, pokud jsou nastavené přihlašující údaje. Přihlašovací jméno musí být uloženo před generováním API klíče.\",\n                \"authentication\": \"Autentifikace\",\n                \"clear_api_key\": \"Vymazat API klíč\",\n                \"credentials\": {\n                    \"description\": \"Přihlašovací údaje pro omezení přístupu do aplikace.\",\n                    \"heading\": \"Přihlašovací údaje\"\n                },\n                \"generate_api_key\": \"Generovat API klíč\",\n                \"log_file\": \"Log soubor\",\n                \"log_file_desc\": \"Ceska k log souboru. Nechte prázdné pro zakázání logování. Vyžaduje restart.\",\n                \"log_http\": \"Zaznamenávat http přístup\",\n                \"log_http_desc\": \"Zaznamenávat http přístup do příkazové řádky. Vyžaduje restart.\",\n                \"log_to_terminal\": \"Logovat do příkazové řádky\",\n                \"log_to_terminal_desc\": \"Logovat do příkazové řádky kromě logování do terminálu. Vždy zapnuto pokud logování do souboru je zakázané. Vyžaduje restart.\",\n                \"maximum_session_age\": \"Maximální věk relace\",\n                \"maximum_session_age_desc\": \"Maximální doba nečinnosti před vypršením relace přihlášení, v sekundách. Vyžaduje restart.\",\n                \"password\": \"Heslo\",\n                \"password_desc\": \"Heslo pro přístup do aplikace. Ponechte prázdné pro vypnutí autentizace\",\n                \"stash-box_integration\": \"Stash-box integrace\",\n                \"username\": \"Přihlašovací jméno\",\n                \"username_desc\": \"Přihlašovací jméno pro přístup do aplikace. Ponechte prázdné pro vypnutí autentizace\",\n                \"log_file_max_size\": \"Maximální velikost logu\",\n                \"log_file_max_size_desc\": \"Maximální velikost logu v megabytech před kompresí. 0MB pro deaktivaci. Vyžaduje restart.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Adresář umístění záloh databáze SQLite\",\n                \"heading\": \"Cesta k adresáři záloh\"\n            },\n            \"cache_location\": \"Umístění adresáře mezipaměti. Vyžaduje se při streamování pomocí HLS (například na zařízeních Apple) nebo DASH.\",\n            \"cache_path_head\": \"Cesta k mezipaměti\",\n            \"calculate_md5_and_ohash_desc\": \"Spočítat MD5 kontrolní součet kromě oshashe. Zapnutí zpomalí následující skenování. Hash dle názvu souboru musí být nastaven na oshash pro zrušení výpočtu MD5.\",\n            \"calculate_md5_and_ohash_label\": \"Spočíst MD5 pro videa\",\n            \"check_for_insecure_certificates\": \"Zkontrolujte nezabezpečené certifikáty\",\n            \"check_for_insecure_certificates_desc\": \"Některé weby používají nezabezpečené ssl certifikáty. Pokud není zaškrtnuto, scraper přeskočí kontrolu nezabezpečených certifikátů a povolí scraping těchto stránek. Pokud je při scrapingu zobrazena chyba certifikátu, zrušte zaškrtnutí.\",\n            \"chrome_cdp_path\": \"Cesta k Chrome CDP\",\n            \"chrome_cdp_path_desc\": \"Cesta k exe souboru aplikace Chrome nebo vzdálená adresa (začínající http:// nebo https://, například http://localhost:9222/json/version) k instanci Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Je-li vybráno, budou vytvořeny galerie ze složek obsahující obrázky.\",\n            \"create_galleries_from_folders_label\": \"Vytvářet galerie ze složek obsahující obrázky\",\n            \"db_path_head\": \"Cesta k databázi\",\n            \"directory_locations_to_your_content\": \"Adresa k Vašemu obsahu\",\n            \"excluded_image_gallery_patterns_desc\": \"Regulární výrazy obrázků a galerií (souborů / cest) pro výjimku ze skenování a přidání k čištění\",\n            \"excluded_image_gallery_patterns_head\": \"Vyloučené vzory obrázků/galerií\",\n            \"excluded_video_patterns_desc\": \"Regulární výrazy video (souborů / cest) pro výjimku ze skenování a přidání k čištění\",\n            \"excluded_video_patterns_head\": \"Vyloučené video patterny\",\n            \"gallery_ext_desc\": \"Seznam souborových koncovek, oddělených čárkou, definující zip soubory galerií.\",\n            \"gallery_ext_head\": \"Koncovky zip souborů galerií\",\n            \"generated_file_naming_hash_desc\": \"Použít MD5 nebo oshash pro pojmenování generovaných souborů. Úprava tohoto nastavení vyžaduje aby všechny scény měly vyplněnou hodnotu MD5/oshash. Po úpravě této hodnoty je nezbytné přemigrovat nebo přegenerovat existující generované soubory. Navštivte stránku Úlohy pro migraci.\",\n            \"generated_file_naming_hash_head\": \"Typ hashe používaný pro pojmenování generovaných souborů\",\n            \"generated_files_location\": \"Umístění adresáře pro generované soubory (značky scén, náhledy scén, sprajty atd.)\",\n            \"generated_path_head\": \"Generovaná cesta\",\n            \"hashing\": \"Hashování\",\n            \"image_ext_desc\": \"Seznam koncovek souborů, oddělený čárkou, které budou identifikovány jako obrázky.\",\n            \"image_ext_head\": \"Koncovky souborů obrázků\",\n            \"include_audio_desc\": \"Zahrnout zvuk při generování náhledů.\",\n            \"include_audio_head\": \"Zahrnout zvuk\",\n            \"logging\": \"Logování\",\n            \"maximum_streaming_transcode_size_desc\": \"Maximální velikost překódovaných streamů\",\n            \"maximum_streaming_transcode_size_head\": \"Maximální velikost streamovaného překódovaného souboru\",\n            \"maximum_transcode_size_desc\": \"Maximální velikost pro generované překódované soubory\",\n            \"maximum_transcode_size_head\": \"Maximální velikost překódovaného souboru\",\n            \"metadata_path\": {\n                \"description\": \"Složka použita při kompletním exportu / importu\",\n                \"heading\": \"Cesta k metadatům\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Nastavte na 0 pro autodetekci. Upozornění: běh vícero úloh než je vyžadováno pro dosažení 100% využítí CPU snižuje výkon a může potenciálně způsobit další problémy.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Počet paralelních úloh pro skenování / generování\",\n            \"parallel_scan_head\": \"Paralelní skenování / generování\",\n            \"preview_generation\": \"Generování náhledů\",\n            \"python_path\": {\n                \"description\": \"Cesta ke spustitelnému souboru pythonu (nejen ke složce). Používá se pro script scrappery a pluginy. Pokud je prázdné, python bude vyřešen z prostředí\",\n                \"heading\": \"Cesta k Pythonu\"\n            },\n            \"scraper_user_agent\": \"User Agent Scraperu\",\n            \"scraper_user_agent_desc\": \"User-Agent řetězec používaný při scrapování http požadavků\",\n            \"scrapers_path\": {\n                \"description\": \"Adresář konfiguračních souborů scraperů\",\n                \"heading\": \"Cesta ke scraperům\"\n            },\n            \"scraping\": \"Scrapování\",\n            \"sqlite_location\": \"Umístění souboru pro databázi SQLite (vyžaduje restart). VAROVÁNÍ: Uložení databáze na jiný systém, než ze kterého je spuštěn Stash server (tj. přes síť), není podporováno!\",\n            \"video_ext_desc\": \"Seznam koncovek souborů, oddělený čárkou, které budou identifikovány jako videa.\",\n            \"video_ext_head\": \"Koncovky video souborů\",\n            \"video_head\": \"Video\",\n            \"database\": \"Databáze\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"heading\": \"Hardwarové kódování FFmpeg\",\n                    \"desc\": \"Používá dostupný hardware k enkódování videa pro živé transkódování.\"\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"heading\": \"Vstupní argumenty ffmpeg pro transkódování\",\n                        \"desc\": \"Pokročilé: Další argumenty, které jsou předány ffmpeg před vstupní pole při generování videa.\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Advanced: Další argumenty, které jsou předány ffmpeg před výstupní pole při generování videa.\",\n                        \"heading\": \"Výstupní argumenty FFmpeg pro transkódování\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"Stáhne FFmpeg do konfiguračního adresáře a vymaže cesty ffmpeg a ffprobe k vyřešení z konfiguračního adresáře.\",\n                    \"heading\": \"Stáhni FFmpeg\"\n                },\n                \"ffprobe_path\": {\n                    \"heading\": \"Cesta ke spustitelnému souboru FFprobe\",\n                    \"description\": \"Cesta ke spustitelnému souboru ffprobe (nejen ke složce). Pokud je prázdný, ffprobe bude použit ze složky přes $PATH, konfigurační adresář nebo z $HOME/.stash\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"heading\": \"Vstupní argumenty FFmpeg pro živé transkódování\",\n                        \"desc\": \"Pokročilé: Další argumenty, které se mají předat ffmpeg před vstupní pole při živém transkódování videa.\"\n                    },\n                    \"output_args\": {\n                        \"heading\": \"Výstupní argumenty FFmpeg pro živé transkódování\",\n                        \"desc\": \"Pokročilé: Další argumenty, které jsou předány ffmpeg před výstupní pole při živém transkódováním videa.\"\n                    }\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"Cesta ke spustitelnému souboru ffmpeg (nejen ke složce). Pokud je prázdný, ffmpeg bude použit ze složky přes $PATH, konfiguračního adresáře nebo z $HOME/.stash\",\n                    \"heading\": \"Cesta ke spustitelnému souboru FFmpeg\"\n                }\n            },\n            \"gallery_cover_regex_desc\": \"Regexp používaný k identifikaci obrázku jako obalu galerie\",\n            \"plugins_path\": {\n                \"heading\": \"Cesty pluginů\",\n                \"description\": \"Umístění adresáře konfiguračních souborů pluginu\"\n            },\n            \"funscript_heatmap_draw_range\": \"Zahrnout rozsah do vygenerovaných heatmap\",\n            \"funscript_heatmap_draw_range_desc\": \"Nakreslete rozsah pohybu na ose y vygenerované heatmapy. Stávající heatmapy bude nutné po změně zregenerovat.\",\n            \"gallery_cover_regex_label\": \"Vzor obalu galerií\",\n            \"heatmap_generation\": \"Generace heatmapy funscriptu\",\n            \"blobs_storage\": {\n                \"description\": \"Kde ukládat binární data, jako jsou obaly scén, účinkující, studia a obrázky tagů. Po změně této hodnoty musí být stávající data zmigrována pomocí úlohy Zmigrování blobů. Podívej se na stránku Úloh pro migraci.\",\n                \"heading\": \"Binární typ úložení dat\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Kde v souborovém systému ukládat binární data. Použitelné pouze při použití uložiště typu souborového systému blobů. UPOZORNĚNÍ: převrácení těchto údajů vyžaduje ruční přesun existujících dat.\",\n                \"heading\": \"Cesta binárních dat souborového systému\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"Cesta, kam budou smazané soubory přesunuty, místo aby byly trvale smazány. Pro trvalé smazání souborů ponechte pole prázdné.\",\n                \"heading\": \"Cesta koše\"\n            }\n        },\n        \"library\": {\n            \"exclusions\": \"Výjimky\",\n            \"gallery_and_image_options\": \"Možnosti galerie a obrázků\",\n            \"media_content_extensions\": \"Koncovky mediálních souborů\"\n        },\n        \"logs\": {\n            \"log_level\": \"Úroveň logování\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Triggery\",\n            \"triggers_on\": \"Spuštěno při\",\n            \"installed_plugins\": \"Nainstalované Pluginy\",\n            \"available_plugins\": \"Dostupné pluginy\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metadata\",\n            \"entity_scrapers\": \"{entityType} scrapery\",\n            \"excluded_tag_patterns_desc\": \"Regulární výrazy názvu tagů vyjmutých z výsledků scrapování\",\n            \"excluded_tag_patterns_head\": \"Vyjmuté tag patterny\",\n            \"scraper\": \"Scraper\",\n            \"scrapers\": \"Scrapery\",\n            \"search_by_name\": \"Hledat dle názvu\",\n            \"supported_types\": \"Podporované typy\",\n            \"supported_urls\": \"URL odkazy\",\n            \"installed_scrapers\": \"Nainstalované Scrappery\",\n            \"available_scrapers\": \"Dostupné Scrappery\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Přidat stash-box instanci\",\n            \"api_key\": \"API klíč\",\n            \"description\": \"Stash-box zjednodušuje automatizované tagování scén a účinkujicích na základě otisků a názvu souborů.\\nEndpoint a API klíč naleznete na stránce Vašeho účtu stash-box instance. Pojmenování je požadováno, pokud je přidáno vícero instancí.\",\n            \"endpoint\": \"Koncový bod\",\n            \"graphql_endpoint\": \"Koncový bod GraphQL\",\n            \"name\": \"Název\",\n            \"title\": \"Stash-box Endpointy\",\n            \"max_requests_per_minute\": \"Maximální počet dotazů za minutu\",\n            \"max_requests_per_minute_description\": \"Používá základní hodnotu {defaultValue} pokud je nastaven na 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Překódování\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Operace {operation_name} byla přidána do fronty úloh\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Autotagování všech cest\",\n                \"auto_tagging_paths\": \"Autotagování následujících cest\"\n            },\n            \"auto_tag_based_on_filenames\": \"Autotagování obsahu dle názvu souborů.\",\n            \"auto_tagging\": \"Autotagování\",\n            \"backing_up_database\": \"Zálohovat databázi\",\n            \"backup_and_download\": \"Provede zálohu databáze a stáhne výsledný soubor.\",\n            \"cleanup_desc\": \"Zkontrolovat chybějící soubory a odstranit je z databáze. Toto je destruktivní akce.\",\n            \"data_management\": \"Správa dat\",\n            \"defaults_set\": \"Výchozí hodnoty byly nastaveny a budou použity při kliknutí na tlačítko {action} na stránce Úloh.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Nezahrnovat příponu souboru jako součást názvu\",\n            \"empty_queue\": \"Momentálně nejsou spuštěny žádné úlohy.\",\n            \"export_to_json\": \"Exportuje obsah databáze do formátu JSON do složky metadata.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Generovat pro scény z následujících cest\",\n                \"generating_scenes\": \"Generováno pro {num} {scene}\"\n            },\n            \"generate_desc\": \"Generovat podpůrné obrázky, sprite, video, vtt a další soubory.\",\n            \"generate_phashes_during_scan\": \"Generovat percepční hashe (phash) videa\",\n            \"generate_phashes_during_scan_tooltip\": \"Pro deduplikaci a identifikaci scén.\",\n            \"generate_previews_during_scan\": \"Generovat animované náhledy\",\n            \"generate_previews_during_scan_tooltip\": \"Vytvářet také animované (webp) náhledy, které jsou vyžadovány pouze v případě, že je Typ náhledu scény/značky nastaven na Animovaný obrázek. Při procházení nevytěžují tolik procesor než náhledy videí, ale jsou generovány navíc a jsou to větší soubory.\",\n            \"generate_sprites_during_scan\": \"Generovat sprity scrubberu\",\n            \"generate_thumbnails_during_scan\": \"Generovat náhledy obrázků\",\n            \"generate_video_previews_during_scan\": \"Generovat náhledy videí\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generovat náhledy videí, které se přehrají při najetí myší na scénu\",\n            \"generated_content\": \"Generovaný obsah\",\n            \"identify\": {\n                \"and_create_missing\": \"a vytvořit chybějící\",\n                \"create_missing\": \"Vytvořit chybějící\",\n                \"default_options\": \"Výchozí možnosti\",\n                \"description\": \"Automaticky nastavit metadata scény s použitím stash-boxu a scraper zdrojů.\",\n                \"explicit_set_description\": \"Následující možnosti budou použity tam, kde nejsou nastaveny rozdílné pro konkrétní zdroje.\",\n                \"field\": \"Pole\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Možnosti pole\",\n                \"heading\": \"Identifikovat\",\n                \"identifying_from_paths\": \"Identifikovat scény z následujících cest\",\n                \"identifying_scenes\": \"Identifikuji {num} {scene}\",\n                \"include_male_performers\": \"Zahrnout mužské účinkující\",\n                \"set_cover_images\": \"Nastav obrázky obalů\",\n                \"set_organized\": \"Nastavit příznak \\\"organizovaný\\\"\",\n                \"source\": \"Zdroj\",\n                \"source_options\": \"Možnosti {source}\",\n                \"sources\": \"Zdroje\",\n                \"strategy\": \"Strategie\",\n                \"skip_multiple_matches\": \"Přeskočte shody, které mají více než jeden výsledek\",\n                \"skip_single_name_performers\": \"Přeskočte účinkující s jedním jménem bez jednoznačného označení\",\n                \"tag_skipped_performers\": \"Označte přeskočené umělce/umělkyně pomocí\",\n                \"tag_skipped_performer_tooltip\": \"Vytvořte štítek jako „Identifikuj: Umělec/umělkyně s jedním jménem“, který můžete filtrovat v taggeru scén a zvolit, jak chcete s těmito účinkujícími zacházet\",\n                \"skip_multiple_matches_tooltip\": \"Pokud toto není povoleno a vrátí se více než jeden výsledek, bude náhodně jeden vybrán ke shodě\",\n                \"tag_skipped_matches\": \"Označte přeskočené shody pomocí\",\n                \"skip_single_name_performers_tooltip\": \"Pokud toto není povoleno, budou přiřazeni účinkující, kteří jsou často generičtí jako Samantha nebo Olga\",\n                \"tag_skipped_matches_tooltip\": \"Vytvořte značku jako „Identifikuj: Více shod“, kterou můžete filtrovat taggeru scén a ručně vybrat správnou shodu\"\n            },\n            \"import_from_exported_json\": \"Importovat z exportovaného JSONu z metadata složky. Přemaže existující databázi.\",\n            \"incremental_import\": \"Inkrementální import z dodaného exportního zip souboru.\",\n            \"job_queue\": \"Fronta úloh\",\n            \"maintenance\": \"Údržba\",\n            \"migrate_hash_files\": \"Použijte po změně konvence pojmenovaní generovaných souborů pomocí hashe za účelem přejmenování generovaných souborů na nový hash formát.\",\n            \"migrations\": \"Migrace\",\n            \"only_dry_run\": \"Provést pouze \\\"dry run\\\". Nic nebude smazáno\",\n            \"plugin_tasks\": \"Úlohy pluginů\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Skenovat všechny cesty\",\n                \"scanning_paths\": \"Skenovat následující cesty\"\n            },\n            \"scan_for_content_desc\": \"Skenovat na nový obsah a přidat ho do databáze.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Nastavit jméno, datum a detaily z metadat souboru\",\n            \"anonymise_and_download\": \"Vytvoří anonymizovanou kopii databáze a stáhne výsledný soubor.\",\n            \"anonymising_database\": \"Anonymizování databáze\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Vymaž stará data\",\n                \"description\": \"Migrujte bloby do aktuálního systému úložiště blobů. Tato migrace by měla být spuštěna po změně systému úložiště blobů. Po migraci lze volitelně odstranit stará data.\"\n            },\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Odstraňte soubory snímků obrazovky\",\n                \"description\": \"Migrujte snímky obrazovky scén do nového systému úložiště blobů. Tato migrace by měla být spuštěna po migraci stávajícího systému na verzi 0.20. Po migraci lze volitelně odstranit staré snímky obrazovky.\",\n                \"overwrite_existing\": \"Přepište existující bloby daty snímku obrazovky\"\n            },\n            \"clean_generated\": {\n                \"blob_files\": \"Soubory Blobů\",\n                \"description\": \"Odstraní vygenerované soubory bez odpovídající položky databáze.\",\n                \"image_thumbnails\": \"Náhledy obrázků\",\n                \"image_thumbnails_desc\": \"Náhledy obrázků a klipy\",\n                \"markers\": \"Náhledy Značek\",\n                \"previews\": \"Náhledy Scén\",\n                \"previews_desc\": \"Náhledy scén a miniatury\",\n                \"sprites\": \"Sprity scén\",\n                \"transcodes\": \"Transkódy scén\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"Sada obrázků zobrazená pod videopřehrávačem pro snadnou navigaci.\",\n            \"generate_video_covers_during_scan\": \"Generovat obaly scén\",\n            \"anonymise_database\": \"Vytvoří kopii databáze do adresáře záloh, anonymizuje všechna citlivá data. To pak může být poskytnuto ostatním pro účely odstraňování problémů a debuggování. Původní databáze se nemění. Anonymizovaná databáze používá formát názvu souboru {filename_format}.\",\n            \"optimise_database\": \"Pokusit se zlepšit výkon analyzováním a opětovným sestavením celého databázového souboru.\",\n            \"generate_clip_previews_during_scan\": \"Generování náhledů pro obrázkové klipy\",\n            \"optimise_database_warning\": \"Varování: Pokud je tato úloha spuštěna, všechny operace, které upravují databázi, selžou a v závislosti na velikosti databáze může její dokončení trvat několik minut. Vyžaduje také minimálně tolik volného místa na disku, jak je vaše databáze velká, ale doporučuje se 1,5x.\",\n            \"rescan\": \"Znovu skenovat soubory\",\n            \"rescan_tooltip\": \"Znovu skenovat každý soubor v cestě. Používá se k vynucení aktualizace metadat souboru a opětovnému skenování zip souborů.\",\n            \"generate_image_phashes_during_scan\": \"Generovat percepční hashe (phash) obrazu\",\n            \"generate_image_phashes_during_scan_tooltip\": \"Pro deduplikaci a identifikaci.\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Detektor duplicitních scén\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Přidat pole\",\n                \"capitalize_title\": \"Převést titul na kapitálky\",\n                \"display_fields\": \"Zobrazit pole\",\n                \"escape_chars\": \"Použijte \\\\ pro escapování doslovných znaků\",\n                \"filename\": \"Název souboru\",\n                \"filename_pattern\": \"Pattern názvu souboru\",\n                \"ignore_organized\": \"Ignorovat organizované scény\",\n                \"ignored_words\": \"Ignorovaná slova\",\n                \"matches_with\": \"Shoduje se s {i}\",\n                \"select_parser_recipe\": \"Vyberte parsovací formuli\",\n                \"title\": \"Parser dle názvu souboru scény\",\n                \"whitespace_chars\": \"Whitespace znaky\",\n                \"whitespace_chars_desc\": \"Tyto znaky v názvu budou nahrazeny prázdným znakem (mezerou)\"\n            },\n            \"scene_tools\": \"Nástroje pro scény\",\n            \"graphql_playground\": \"GraphQL hřiště\",\n            \"heading\": \"Nástroje\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Zkrátit počty na kartách a stránkách zobrazení podrobností, například „1831“ bude naformátováno na „1,8K“.\",\n                \"heading\": \"Zkrátit počítadla\"\n            },\n            \"basic_settings\": \"Základní nastavení\",\n            \"custom_css\": {\n                \"description\": \"Stránka musí být znovu načtena, aby byly změny viditelné.\",\n                \"heading\": \"Vlastní CSS\",\n                \"option_label\": \"Vlastní CSS aktivováno\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Stránka musí být znovu načtena, aby byly viditelné změny.\",\n                \"heading\": \"Vlastní Javascript\",\n                \"option_label\": \"Vlastní Javascript povolen\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Přepsat individuální jazykovou lokalizaci. Navštivte https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json pro zobrazení seznamu. Stránka musí být obnovena, aby byly změny viditelné.\",\n                \"heading\": \"Vlastní jazyková lokalizace\",\n                \"option_label\": \"Vlastní jazyková lokalizace povolena\"\n            },\n            \"delete_options\": {\n                \"description\": \"Výchozí nastavení pro mazání obrázků, galerií a scén.\",\n                \"heading\": \"Možnosti mazání\",\n                \"options\": {\n                    \"delete_file\": \"Výchozí nastavení - Smazat soubor\",\n                    \"delete_generated_supporting_files\": \"Výchozí nastavení - Smazat podpůrné generované soubory\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Integrace na plochu\",\n                \"notifications_enabled\": \"Aktivovat upozornění / notifikace\",\n                \"send_desktop_notifications_for_events\": \"Zasílat notifikace na plochu pro události\",\n                \"skip_opening_browser\": \"Přeskočit otevření prohlížeče\",\n                \"skip_opening_browser_on_startup\": \"Přeskočit automatické otevření prohlížeče při spuštění\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Zakázat funkčnost tvorby nových objektů z rozbalovacích seznamů\",\n                    \"heading\": \"Zakázat vytvoření z rozbalovacího seznamu\"\n                },\n                \"heading\": \"Editace\",\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Přesnost hvězd hodnocení\",\n                        \"options\": {\n                            \"full\": \"Plné\",\n                            \"half\": \"Poloviční\",\n                            \"quarter\": \"Čtvrtinové\",\n                            \"tenth\": \"Desetiné\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Typ hodnocení\",\n                        \"options\": {\n                            \"decimal\": \"Desetinné\",\n                            \"stars\": \"Hvězdičky\"\n                        }\n                    }\n                },\n                \"max_options_shown\": {\n                    \"label\": \"Maximální počet položek k zobrazení ve vybíracích rozbalovacích seznamech\"\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Offset v milisekundách pro přehrávání interaktivních skriptů.\",\n                \"heading\": \"Offset funscriptu (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Připojit\",\n                \"server_offset\": {\n                    \"heading\": \"Offset serveru\"\n                },\n                \"status\": {\n                    \"heading\": \"Status připojení Handy\"\n                },\n                \"sync\": \"Synchronizovat\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Connection Key k Handy pro použití v interaktivních scénách. Nastavení tohoto klíče umožní Stash ke sdílení aktuální scény s handyfeeling.com\",\n                \"heading\": \"Handy Connection klíč\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Lightbox pro obrázek\"\n            },\n            \"images\": {\n                \"heading\": \"Obrázky\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Zapsat miniatury obrázků na disk, když jsou generovány za běhu\",\n                        \"heading\": \"Zapsat miniatury obrázků\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"Když má knihovna vypnutá videa, soubory videa (soubory končící s video příponou) budou naskenovány jako obrázkový klip.\",\n                        \"heading\": \"Skenujte rozšíření videa jako obrázkový klip\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Možnosti Interaktivní\",\n            \"language\": {\n                \"heading\": \"Jazyk\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Maximální doba trvání scény, při které bude přehrávač scén opakovat video – 0 pro deaktivaci\",\n                \"heading\": \"Maximální doba trvání smyčky\"\n            },\n            \"menu_items\": {\n                \"description\": \"Zobrazit nebo skrýt různé typy obsahu na navigační liště\",\n                \"heading\": \"Menu položky\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Procento času scény, po kterém bude počet přehrání zvýšen.\",\n                \"heading\": \"Minimální procento počtu přehrání\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Vlastní cesta pro výchozí obrázky účinkujících. Chcete-li použít vestavěné výchozí hodnoty, ponechte prázdné\",\n                        \"heading\": \"Cesta pro výchozí obrázky účinkujících\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Výchozí možností jsou náhledy videa (mp4). Pro menší využití procesoru při procházení můžete použít náhledy animovaných obrázků (webp). Musí však být generovány navíc k náhledům videa a jedná se o větší soubory.\",\n                \"heading\": \"Typ náhledu\",\n                \"options\": {\n                    \"animated\": \"Animovaný obrázek\",\n                    \"static\": \"Statický obrázek\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Zobrazení v mřížce\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Zobrazit studiové překrytí jako text\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Přehrávač scén\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Vždy spustit video od začátku\",\n                    \"auto_start_video\": \"Automaticky spustit video\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Automaticky spustit video pokud je přehráváno vybrané nebo náhodné video ze stránky scén\",\n                        \"heading\": \"Automaticky spustit video při přehrávání vybraného\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Přehrát následující scénu na seznamu při skončení videa\",\n                        \"heading\": \"(Výchozí nastavení) Pokračovat v playlistu\"\n                    },\n                    \"show_scrubber\": \"Zobrazit Scrubber\",\n                    \"track_activity\": \"Povolit historii přehrávání scén\",\n                    \"disable_mobile_media_auto_rotate\": \"Zakázat automatické otáčení médií na celou obrazovku v mobilu\",\n                    \"enable_chromecast\": \"Povolit Chromecast\",\n                    \"show_ab_loop_controls\": \"Zobrazit ovládací prvky pluginu AB Loop\",\n                    \"vr_tag\": {\n                        \"description\": \"Tlačítko VR se zobrazí pouze u scén s tímto tagem.\",\n                        \"heading\": \"VR Tag\"\n                    },\n                    \"show_range_markers\": \"Zobrazit značky s rozsahem\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Scéna / Marker zeď\",\n                \"options\": {\n                    \"display_title\": \"Zobrazit titul a tagy\",\n                    \"toggle_sound\": \"Povolit zvuk\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Počet pokusů o posunutí před přechodem na další/předchozí položku. Platí pouze pro režim posouvání Pan Y.\",\n                \"heading\": \"Počet pokusů o rolování před přechodem\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Zobrazit přehledku tagu při přejetí přes štítek tagu\",\n                \"heading\": \"Popisky tagových štítků\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Slideshow je k dispozici v galeriích v režimu zobrazení stěny\",\n                \"heading\": \"Zpoždění prezentace (sekundy)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Pohled studií\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"heading\": \"Zobrazit obsah dílčích studií\",\n                        \"description\": \"V zobrazení studia zobrazte také obsah z dílčích studií\"\n                    }\n                }\n            },\n            \"title\": \"Uživatelské rozhraní\",\n            \"detail\": {\n                \"enable_background_image\": {\n                    \"description\": \"Zobrazit obrázek na pozadí na stránce s podrobnostmi.\",\n                    \"heading\": \"Povolí obrázek na pozadí\"\n                },\n                \"heading\": \"Stránka s podrobnostmi\",\n                \"compact_expanded_details\": {\n                    \"heading\": \"Rozšířené kompaktní detaily\",\n                    \"description\": \"Pokud je tato možnost povolena, zobrazí rozšířené detaily a přitom zachovává kompaktní prezentaci\"\n                },\n                \"show_all_details\": {\n                    \"description\": \"Když je tato možnost povolena, všechny detaily obsahu budou zobrazeny s výchozím nastavením a detail každé položky bude pod jedním sloupcem\",\n                    \"heading\": \"Zobrazit všechny detaily\"\n                }\n            },\n            \"image_wall\": {\n                \"direction\": \"Směr\",\n                \"heading\": \"Stěna obrázků\",\n                \"margin\": \"Okraj (v pixelech)\"\n            },\n            \"tag_panel\": {\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"heading\": \"Zobrazit obsah dílčích tagů\",\n                        \"description\": \"V zobrazení tagů, zobraz obsah také obsah z dílčích tagů\"\n                    }\n                },\n                \"heading\": \"Zobrazení tagů\"\n            },\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"Je-li povoleno, budou funscripty poskytovány přímo ze Stash do vašeho zařízení Handy bez použití serveru Handy třetí strany. Vyžaduje, aby byl Stash dostupný z vašeho zařízení Handy a vygenerovaný API klíč, pokud má stash nakonfigurované údaje.\",\n                \"heading\": \"Funscripty podávejte přímo\"\n            },\n            \"sfw_mode\": {\n                \"description\": \"Zapněte zda použáváte stash k ukládání SFW obsahu. Schová nebo změní některé aspekty uživatelského rozhraní související s obsahem pro dospělé.\",\n                \"heading\": \"Režim obsahu SFW\"\n            },\n            \"performer_list\": {\n                \"heading\": \"List účinkujicích\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"Zobrazit odkazy na mřížce karet účinkujicích\"\n                    }\n                }\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"Režim řešení problémů\",\n                \"dialog_title\": \"Povolit režim řešení problémů\",\n                \"dialog_description\": \"Tím se dočasně zakážou všechny customizace, která pomohou diagnostikovat problémy:\",\n                \"dialog_item_plugins\": \"Všechna rozšíření\",\n                \"dialog_item_css\": \"Vlastní CSS\",\n                \"dialog_item_js\": \"Vlastní JavaScript\",\n                \"dialog_item_locales\": \"Vlastní locales\",\n                \"dialog_log_level\": \"Log level bude nastaven na Debug pro detailní diagnostiku.\",\n                \"dialog_reload_note\": \"Stránka se automaticky znovu načte.\",\n                \"enable\": \"Povolit a znovu načíst\",\n                \"overlay_message\": \"Režim řešení problémů je aktivní – všechny customizace jsou zakázány\",\n                \"exit\": \"Odejít\"\n            },\n            \"custom_title\": {\n                \"description\": \"Vlastní text, který se přidá k názvu stránky. Pokud je prázdný, výchozí nastavení je 'Stash'.\",\n                \"heading\": \"Vlastní název\"\n            }\n        },\n        \"advanced_mode\": \"Pokročilý mód\"\n    },\n    \"configuration\": \"Konfigurace\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Soubor} other {Soubory}}\",\n        \"galleries\": \"{count, plural, one {Galerie} other {Galerie}}\",\n        \"images\": \"{count, plural, one {Obrázek} other {Obrázky}}\",\n        \"markers\": \"{count, plural, one {Marker} other {Markery}}\",\n        \"performers\": \"{count, plural, one {Účinkující} other {Účinkující}}\",\n        \"scenes\": \"{count, plural, one {Scéna} other {Scény}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Studia}}\",\n        \"tags\": \"{count, plural, one {Tag} other {Tagy}}\",\n        \"groups\": \"{count, plural, one {Skupina} other {Skupiny}}\"\n    },\n    \"country\": \"Země\",\n    \"cover_image\": \"Obrázek obalu\",\n    \"created_at\": \"Vytvořeno\",\n    \"criterion\": {\n        \"greater_than\": \"Větší než\",\n        \"less_than\": \"Méně než\",\n        \"value\": \"Hodnota\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"mezi\",\n        \"equals\": \"je\",\n        \"excludes\": \"vylučuje\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"Je větší než\",\n        \"includes\": \"zahrnuje\",\n        \"includes_all\": \"zahrnuje všechny\",\n        \"is_null\": \"je null\",\n        \"less_than\": \"je menší než\",\n        \"matches_regex\": \"vyhovuje regulárnímu výrazu\",\n        \"not_between\": \"není mezi\",\n        \"not_equals\": \"není\",\n        \"not_matches_regex\": \"nevyhovuje regulárnímu výrazu\",\n        \"not_null\": \"není null\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (vylučuje {excludedString}) (+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (vyřazeno: {excludedString})\"\n    },\n    \"custom\": \"Vlastní\",\n    \"date\": \"Datum\",\n    \"death_date\": \"Datum úmrtí\",\n    \"death_year\": \"Rok úmrtí\",\n    \"descending\": \"Sestupně\",\n    \"description\": \"Popis\",\n    \"detail\": \"Detail\",\n    \"details\": \"Detaily\",\n    \"developmentVersion\": \"Vývojářská verze\",\n    \"dialogs\": {\n        \"delete_alert\": \"Následující {count, plural, one {{singularEntity}} other {{pluralEntity}}} budou permanentně smazány:\",\n        \"delete_confirm\": \"Chcete doopravdy smazat {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Doopravdy chcete smazat {singularEntity}? Pokud nebude smazán i soubor, {singularEntity} bude znovu přidán při příštím skenování.} other {Doopravdy chcete smazat tyto {pluralEntity}? Pokud nebudou smazány i soubory, tyto {pluralEntity} budou znovu přidány při příštím skenování.}}\",\n        \"delete_entity_title\": \"{count, plural, one {Smazat {singularEntity}} other {Smazat {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"…navíc všechny obrazové soubory, které nejsou připojeny k žádné jiné galerii.\",\n        \"delete_gallery_files\": \"Smazat složku galerie/zip soubor a jakékoliv obrázky nenapojené na jinou galerii.\",\n        \"delete_object_desc\": \"Doopravdy chcete smazat {count, plural, one {{singularEntity}} other {tyto {pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…a {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Smazat {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Skrýt do příští aktualizace\",\n        \"edit_entity_title\": \"Upravit {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Zahrnout související objekty do exportu\",\n        \"export_title\": \"Exportovat\",\n        \"lightbox\": {\n            \"delay\": \"Zpoždění (sekundy)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Seřídit horizontálně\",\n                \"fit_to_screen\": \"Seřídit na velikost obrazovky\",\n                \"label\": \"Mód zobrazení\",\n                \"original\": \"Originál\"\n            },\n            \"options\": \"Možnosti\",\n            \"reset_zoom_on_nav\": \"Resetovat zoom po změně obrázku\",\n            \"scale_up\": {\n                \"description\": \"Škálovat menší obrázky tak, aby vyplnily obrazovku\",\n                \"label\": \"Zvětšit, aby se vešly\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Držet shift pro dočasné použití druhého módu.\",\n                \"label\": \"Rolovací mód\",\n                \"pan_y\": \"Náklon Y\",\n                \"zoom\": \"Zvětšit\"\n            },\n            \"page_header\": \"Stránka {page} / {total}\",\n            \"disable_animation\": \"Zakázat animaci přechodu mezi obrázky\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Vynutit generování Transkódu\",\n            \"force_transcodes_tooltip\": \"Ve výchozím nastavení, transkódy jsou generovány pouze tehdy, když video soubor není podporován prohlížečem. V případě aktivování, transkódy budou generovány i v ostatních případech.\",\n            \"image_previews\": \"Animované náhledy obrázků\",\n            \"image_previews_tooltip\": \"Vytvářejte také animované (webp) náhledy, které jsou vyžadovány pouze v případě, že je Typ náhledu scény/značky nastaven na Animovaný obrázek. Při procházení využívají zatěžují procesor méně než náhledy videí, ale jsou generovány navíc a jsou to větší soubory.\",\n            \"interactive_heatmap_speed\": \"Generovat heatmapy a rychlosti pro interaktivní scény\",\n            \"marker_image_previews\": \"Animované náhledy markerů\",\n            \"marker_image_previews_tooltip\": \"Vytvářejte také animované (webp) náhledy, které jsou vyžadovány pouze v případě, že je Typ náhledu scény/značky nastaven na Animovaný obrázek. Při procházení využívají zatěžují procesor méně než náhledy videí, ale jsou generovány navíc a jsou to větší soubory.\",\n            \"marker_screenshots\": \"Screenshoty markerů\",\n            \"preview_exclude_start_time_desc\": \"Vyloučit prvních x sekund z náhledů scén. Může to být hodnota v sekundách nebo procentech (např. 2 %) z celkové doby trvání scény.\",\n            \"preview_exclude_start_time_head\": \"Vyloučit čas zahájení\",\n            \"preview_generation_options\": \"Možnosti generování náhledů\",\n            \"phash\": \"Percepční hashe (phash) videa\",\n            \"preview_preset_head\": \"Enkódovací předvolba náhledů\",\n            \"preview_seg_count_desc\": \"Počet segmentů v souborech náhledů.\",\n            \"preview_seg_duration_desc\": \"Doba trvání každého segmentu náhledu v sekundách.\",\n            \"sprites_tooltip\": \"Sada obrázků zobrazených pod přehrávačem videa pro snadnou navigaci.\",\n            \"transcodes_tooltip\": \"Pro veškerý obsah budou předem vygenerovány transkódy MP4; užitečné pro pomalé procesory, ale vyžaduje to mnohem více místa na disku\",\n            \"video_previews\": \"Náhledy\",\n            \"video_previews_tooltip\": \"Náhledy videa, které se přehrají při najetí myší na scénu\",\n            \"phash_tooltip\": \"Pro deduplikaci a identifikace scén\",\n            \"preview_exclude_end_time_desc\": \"Vyloučit posledních x sekund z náhledů scén. Může to být hodnota v sekundách nebo procentech (např. 2 %) z celkové doby trvání scény.\",\n            \"preview_exclude_end_time_head\": \"Vyloučit čas ukončení\",\n            \"preview_options\": \"Nastavení náhledů\",\n            \"preview_preset_desc\": \"Předvolba reguluje velikost, kvalitu a čas enkódování generování náhledu. Předvolby větší než „pomalý“ mají snižjicí se účinek a nedoporučují se.\",\n            \"preview_seg_count_head\": \"Počet segmentů v náhledech\",\n            \"preview_seg_duration_head\": \"Doba trvání segmentu náhledu\",\n            \"covers\": \"Obaly scén\",\n            \"sprites\": \"Sprity scrubberu scén\",\n            \"markers\": \"Náhledy značek\",\n            \"markers_tooltip\": \"20sekundová videa, která začínají v daném časovém kódu.\",\n            \"transcodes\": \"Transkódované\",\n            \"image_thumbnails\": \"Miniatury obrázků\",\n            \"override_preview_generation_options\": \"Přepsat možnosti generování náhledu\",\n            \"override_preview_generation_options_desc\": \"Přepsat možnosti generování náhledu pro tuto operaci. Výchozí nastavení se nastavuje: Systém -> Generování náhledu.\",\n            \"clip_previews\": \"Náhledy obrázkových klipů\",\n            \"marker_screenshots_tooltip\": \"Statické JPG obrázky značek\",\n            \"overwrite\": \"Přepsat existující soubory\",\n            \"image_phash\": \"Percepční hashování (phash) obrazu\",\n            \"image_phash_tooltip\": \"Pro deduplikaci a identifikaci\"\n        },\n        \"unsaved_changes\": \"Neuložené změny. Opravdu chcete odejít?\",\n        \"scenes_found\": \"{count} nalezené scény\",\n        \"scrape_entity_query\": \"Scrapovací dotaz {entity_type}\",\n        \"scrape_entity_title\": \"{entity_type} Scrapovací výsledky\",\n        \"scrape_results_existing\": \"Existujicí\",\n        \"scrape_results_scraped\": \"Scrapováno\",\n        \"set_image_url_title\": \"URL obrázku\",\n        \"merge\": {\n            \"destination\": \"Destinace\",\n            \"source\": \"Zdroj\",\n            \"empty_results\": \"Hodnoty cílového pole zůstanou nezměněny.\"\n        },\n        \"create_new_entity\": \"Vytvořit novou {entity}\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Sloupec\",\n                \"description\": \"Rozvržení založené na sloupcích nebo řádcích.\",\n                \"row\": \"Řádek\"\n            },\n            \"margin_desc\": \"Počet pixelů kolem každého celého obrázku.\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {Změnit přiřazení {singularEntity}} other {Změnit přiřazení {pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Změnit přiřazení na\"\n        },\n        \"clear_o_history_confirm\": \"Opravdu chcete vymazat historii O?\",\n        \"clear_play_history_confirm\": \"Opravdu chcete vymazat historii přehrávání?\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Opravdu chcete smazat tuto {singularEntity}?} other {Opravdu chcete smazat tyto {pluralEntity}?}}\",\n        \"performers_found\": \"{count} nalezených účinkujících\",\n        \"overwrite_filter_warning\": \"Uložený filtr \\\"{entityName}\\\" bude přepsán.\",\n        \"set_default_filter_confirm\": \"Chcete doopravdy nastavit tento filtr jako výchozí?\",\n        \"clear_o_history_confirm_sfw\": \"Opravdu chcete vymazat historii lajků?\",\n        \"delete_alert_to_trash\": \"Následující {count, plural, one {{singularEntity}} other {{pluralEntity}}} budou přesunuty do koše:\",\n        \"stashid_exists_warning\": \"Stávající stash id pro tento stash-box bude nahrazeno.\",\n        \"studios_found\": \"{count} studií nalezeno\",\n        \"tags_found\": \"{count} tagů nalezeno\",\n        \"scrape_results_missing\": \"Chybějící\"\n    },\n    \"chapters\": \"Kapitoly\",\n    \"circumcised\": \"Obřezán\",\n    \"tag_count\": \"Počet tagů\",\n    \"tag\": \"Tag\",\n    \"toast\": {\n        \"updated_entity\": \"Aktualizováno {entity}\",\n        \"merged_scenes\": \"Sloučené scény\",\n        \"generating_screenshot\": \"Generuji snímek obrazovky…\",\n        \"default_filter_set\": \"Výchozí set filtrů\",\n        \"merged_tags\": \"Sloučené tagy\",\n        \"created_entity\": \"Vytvořeno {entity}\",\n        \"added_generation_job_to_queue\": \"Do fronty přidána generační úloha\",\n        \"started_auto_tagging\": \"Zahájeno automatické tagování\",\n        \"saved_entity\": \"Uloženo {entity}\",\n        \"started_importing\": \"Zahájeno importování\",\n        \"started_generating\": \"Zahájeno generování\",\n        \"added_entity\": \"Přidáno {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"delete_past_tense\": \"Odstraněno {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"rescanning_entity\": \"Přeskenování {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"removed_entity\": \"Odebráno {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"image_index_too_large\": \"Chyba: Index obrázku je větší než počet obrázků v galerii\",\n        \"reassign_past_tense\": \"Soubor byl znovu přidělen\",\n        \"merged_performers\": \"Sloučení účinkující\"\n    },\n    \"urls\": \"Odkazy\",\n    \"url\": \"Odkaz\",\n    \"audio_codec\": \"Audio kodek\",\n    \"blobs_storage_type\": {\n        \"database\": \"Databáze\",\n        \"filesystem\": \"Souborový systém\"\n    },\n    \"circumcised_types\": {\n        \"CUT\": \"Obřezaný\",\n        \"UNCUT\": \"Neobřezaný\"\n    },\n    \"title\": \"Název\",\n    \"time\": \"Čas\",\n    \"tattoos\": \"Tetování\",\n    \"tags\": \"Tagy\",\n    \"view_all\": \"Zobrazit všechny\",\n    \"validation\": {\n        \"required\": \"${path} je vyžadované pole\",\n        \"unique\": \"${path} musí být jedinečná\",\n        \"blank\": \"${path} nesmí být prázdná\",\n        \"date_invalid_form\": \"${path} musí být ve formátu YYYY, YYYY-MM, nebo YYYY-MM-DD (Rok-Měsíc-Den)\",\n        \"end_time_before_start_time\": \"Čas ukončení musí být větší nebo roven času zahájení\"\n    },\n    \"type\": \"Typ\",\n    \"twitter\": \"Twitter\",\n    \"true\": \"Pravda\",\n    \"total\": \"Celkem\",\n    \"tag_parent_tooltip\": \"Má nadřazené tagy\",\n    \"weight_kg\": \"Váha (kg)\",\n    \"weight\": \"Váha\",\n    \"appears_with\": \"Objevuje se s\",\n    \"zip_file_count\": \"Počet souborů zip\",\n    \"videos\": \"Videa\",\n    \"video_codec\": \"Video kodeky\",\n    \"unknown_date\": \"Neznámý datum\",\n    \"director\": \"Režisér(ka)\",\n    \"display_mode\": {\n        \"list\": \"Seznam\",\n        \"grid\": \"Mřížka\",\n        \"tagger\": \"Tagger\",\n        \"wall\": \"Stěna\",\n        \"unknown\": \"Neznámý\",\n        \"label_current\": \"Mód zobrazení: {current}\"\n    },\n    \"effect_filters\": {\n        \"aspect\": \"Aspekt\",\n        \"blue\": \"Modrá\",\n        \"brightness\": \"Jas\",\n        \"contrast\": \"Kontrast\",\n        \"gamma\": \"Gamma\",\n        \"rotate\": \"Otočit\",\n        \"rotate_right_and_scale\": \"Otočit doprava a změnit měřítko\",\n        \"saturation\": \"Nasycení\",\n        \"scale\": \"Měřítko\",\n        \"warmth\": \"Teplo\",\n        \"green\": \"Zelený\",\n        \"hue\": \"Odstín\",\n        \"blur\": \"Rozmazání\",\n        \"name\": \"Filtry\",\n        \"red\": \"Červený\",\n        \"reset_filters\": \"Resetovat Filtry\",\n        \"name_transforms\": \"Transformace\",\n        \"reset_transforms\": \"Resetovat transformace\",\n        \"rotate_left_and_scale\": \"Otočit doleva a změnit měřítko\"\n    },\n    \"dupe_check\": {\n        \"select_youngest\": \"Vyberte nejnovější soubor ve skupině duplicit\",\n        \"title\": \"Duplicitní scény\",\n        \"duration_diff\": \"Maximální rozdíl trvání\",\n        \"only_select_matching_codecs\": \"Vyberte pouze v případě, že se všechny kodeky shodují v duplicitní skupině\",\n        \"options\": {\n            \"medium\": \"Střední\",\n            \"exact\": \"Přesné\",\n            \"high\": \"Vysoké\",\n            \"low\": \"Nízké\"\n        },\n        \"search_accuracy_label\": \"Přesnost vyhledávání\",\n        \"select_all_but_largest_resolution\": \"Vyber každý soubor v každé duplikované skupině kromě souboru s nejvyšším rozlišením\",\n        \"description\": \"Výpočet úrovní pod 'Přesné' může trvat déle. Falešně pozitivní mohou být vráceny také na nižších úrovních přesnosti.\",\n        \"duration_options\": {\n            \"any\": \"Jakýkoli\",\n            \"equal\": \"Rovná se\"\n        },\n        \"select_all_but_largest_file\": \"Vyber každý soubor v každé duplikované skupině kromě největšího souboru\",\n        \"select_none\": \"Nevybírat\",\n        \"select_oldest\": \"Vyber nejstarší soubor v duplicitní skupině\",\n        \"select_options\": \"Vyberte možnosti…\",\n        \"found_sets\": \"{setCount, plural, one{# nalezen duplikát.} other {# nalezena sada duplikátů.}}\"\n    },\n    \"duplicated_phash\": \"Duplicitní (phash)\",\n    \"duration\": \"Doba trvání\",\n    \"distance\": \"Vzdálenost\",\n    \"donate\": \"Darovat\",\n    \"package_manager\": {\n        \"source\": {\n            \"local_path\": {\n                \"description\": \"Relativní cesta k uložení balíčků pro tento zdroj. Uvědomte si, že tato změna vyžaduje ruční přesun balíčků.\",\n                \"heading\": \"Místní cesta\"\n            },\n            \"url\": \"Zdrojové URL\",\n            \"name\": \"Jméno\"\n        },\n        \"add_source\": \"Přidat zdroj\",\n        \"show_all\": \"Zobrazit vše\",\n        \"no_sources\": \"Nejsou nakonfigurovány žádné zdroje\",\n        \"package\": \"Balík\",\n        \"required_by\": \"Vyžadováno: {packages}\",\n        \"selected_only\": \"Pouze vybrané\",\n        \"unknown\": \"<neznámé>\",\n        \"uninstall\": \"Odinstalovat\",\n        \"update\": \"Aktualizovat\",\n        \"version\": \"Verze\",\n        \"check_for_updates\": \"Vyhledat aktualizace\",\n        \"confirm_delete_source\": \"Opravdu chcete smazat zdroj {name} ({url})?\",\n        \"confirm_uninstall\": \"Opravdu chcete odinstalovat {number} balíčky?\",\n        \"description\": \"Popis\",\n        \"edit_source\": \"Upravit zdroj\",\n        \"hide_unselected\": \"Skrýt nevybrané\",\n        \"install\": \"Nainstalovat\",\n        \"latest_version\": \"Poslední verze\",\n        \"no_packages\": \"Nebyly nalezeny žádné balíčky\",\n        \"no_upgradable\": \"Nebyly nalezeny žádné upgradovatelné balíčky\",\n        \"installed_version\": \"Nainstalovaná verze\"\n    },\n    \"performer_tagger\": {\n        \"batch_update_performers\": \"Hromadná aktualizace účinkujicích\",\n        \"performer_selection\": \"Výběr účinkujících\",\n        \"status_tagging_performers\": \"Stav: Tagování Účinkujících\",\n        \"add_new_performers\": \"Přidat nové Účinkující\",\n        \"current_page\": \"Aktuální stránka\",\n        \"no_results_found\": \"Nebyly nalezeny žádné výsledky.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} účinkujících bude zpracováno\",\n        \"performer_successfully_tagged\": \"Účinkujicí úspěšně označen:\",\n        \"query_all_performers_in_the_database\": \"Všichni účinkující v databázi\",\n        \"refresh_tagged_performers\": \"Obnovit označené učinkujicí\",\n        \"to_use_the_performer_tagger\": \"Chcete-li použít označovač účinkujicích, je třeba nakonfigurovat instanci stash-boxu.\",\n        \"tag_status\": \"Stav Tagu\",\n        \"untagged_performers\": \"Netagnutí účinkující\",\n        \"any_names_entered_will_be_queried\": \"Všechna zadaná jména budou dotázána ze vzdálené instance Stash-Box a přidána, pokud budou nalezena. Za shodu budou považovány pouze přesné shody.\",\n        \"batch_add_performers\": \"Hromadné přidání účinkujících\",\n        \"failed_to_save_performer\": \"Nepodařilo se uložit umělce „{performer}“\",\n        \"network_error\": \"Chyba sítě\",\n        \"performer_already_tagged\": \"Účinkujicí již byl označen\",\n        \"refreshing_will_update_the_data\": \"Obnovení aktualizuje data všech označených účinkujících z instance stash-boxu.\",\n        \"status_tagging_job_queued\": \"Stav: Úloha tagování ve frontě\",\n        \"name_already_exists\": \"Název již existuje\",\n        \"update_performers\": \"Aktualizovat Účinkující\",\n        \"updating_untagged_performers_description\": \"Aktualizování nepřiřazených účinkujících se pokusí nalézt všechny účinkující, kteří postrádají StashID, a aktualizovat metadata.\",\n        \"update_performer\": \"Aktualizovat Účinkující\",\n        \"performer_names_or_stashids_separated_by_comma\": \"Jména účinkujících nebo Stash ID oddělené čárkou\"\n    },\n    \"setup\": {\n        \"paths\": {\n            \"where_can_stash_store_its_generated_content_description\": \"Aby Stash mohl poskytovat miniatury, náhledy a sprity generuje Stash obrázky a videa. To zahrnuje také transkódování pro nepodporované formáty souborů. Ve výchozím nastavení Stash vytvoří <code>generated</code> adresář v adresáři obsahujícím váš konfigurační soubor. Pokud chcete změnit, kam se budou tato generovaná media ukládat, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) cestu. Stash vytvoří tento adresář, pokud ještě neexistuje.\",\n            \"where_is_your_porn_located_description\": \"Přidejte adresáře obsahující váše videa a obrázky. Stash použije tyto adresáře k vyhledání videí a obrázků během skenování.\",\n            \"where_can_stash_store_blobs_description\": \"Kam může Stash ukládat binární data jako jsou obaly scén, účinkující, studia a obrázky tagů, buď v databázi nebo v souborovém systému. Ve výchozím nastavení bude tato data ukládat do souborového systému v podadresáři <code>blobs</code> v adresáři obsahujícím váš konfigurační soubor. Pokud to chcete změnit, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) cestu. Stash vytvoří tento adresář, pokud ještě neexistuje.\",\n            \"path_to_cache_directory_empty_for_default\": \"cesta k adresáři mezipaměti (ve výchozím nastavení prázdné)\",\n            \"path_to_generated_directory_empty_for_default\": \"cesta k adresáři vygenerovaných souborů (ve výchozím nastavení prázdné)\",\n            \"set_up_your_paths\": \"Nastavte vaše cesty\",\n            \"where_can_stash_store_cache_files\": \"Kde může Stash ukládat soubory mezipaměti?\",\n            \"where_can_stash_store_its_generated_content\": \"Kam může Stash ukládat svůj vygenerovaný obsah?\",\n            \"where_can_stash_store_its_database_warning\": \"VAROVÁNÍ: Uložení databáze na jiný systém, než ze kterého se spouští Stash (např. uložení databáze na NAS při spuštění serveru Stash na jiném počítači), <strong>nepodporováno</strong>! SQLite není určen pro použití v síti a pokus o to může velmi snadno způsobit poškození celé vaší databáze.\",\n            \"database_filename_empty_for_default\": \"název souboru databáze (ve výchozím nastavení prázdné)\",\n            \"description\": \"Dále musíme určit, kde najdeme vaši sbírku obsahu a kam uložit databázi Stash, vygenerované soubory a soubory mezipaměti. Tato nastavení lze v případě potřeby později změnit.\",\n            \"path_to_blobs_directory_empty_for_default\": \"cesta k adresáři blobů (ve výchozím nastavení prázdné)\",\n            \"stash_alert\": \"Nebyla vybrána žádná cesta knihovny. Žádné médium nebude možné naskenovat do Stash. Jste si jisti?\",\n            \"where_is_your_porn_located\": \"Kde se nachází váš obsah?\",\n            \"where_can_stash_store_cache_files_description\": \"Aby některé funkce, jako je živé transkódování HLS/DASH, fungovaly, vyžaduje Stash adresář mezipaměti pro dočasné soubory. Ve výchozím nastavení Stash vytvoří adresář <code>mezipaměť</code> v adresáři obsahujícím váš konfigurační soubor. Pokud to chcete změnit, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) cestu. Stash vytvoří tento adresář, pokud ještě neexistuje.\",\n            \"where_can_stash_store_its_database\": \"Kde může Stash uložit svou databázi?\",\n            \"where_can_stash_store_blobs\": \"Kde může Stash ukládat binární data databáze?\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Případně můžete tato data uložit do databáze. <strong>Poznámka:</strong> Tím se zvětší velikost souboru databáze a prodlouží se doba migrace databáze.\",\n            \"where_can_stash_store_its_database_description\": \"Stash používá databázi SQLite k ukládání metadat obsahu. Ve výchozím nastavení bude tento soubor vytvořen jako <code>stash-go.sqlite</code> v adresáři obsahujícím váš konfigurační soubor. Pokud to chcete změnit, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) název souboru.\",\n            \"store_blobs_in_database\": \"Uložte bloby do databáze\",\n            \"sfw_content_settings\": \"Používáte stash k SFW obsahu?\",\n            \"sfw_content_settings_description\": \"Stash lze použít ke správě SFW obsahu, jako jsou fotografie, umění, komiksy a další. Povolení této možnosti upraví některé vlastnosti uživatelského rozhraní tak, aby byly vhodnější pro SFW obsah.\",\n            \"use_sfw_content_mode\": \"Použít režim obsahu SFW\"\n        },\n        \"stash_setup_wizard\": \"Průvodce nastavením Stash\",\n        \"success\": {\n            \"next_config_step_one\": \"Dále budete přesměrováni na konfigurační stránku. Tato stránka vám umožní přizpůsobit, jaké soubory zahrnout a vyloučit, nastavit uživatelské jméno a heslo pro ochranu vašeho systému a spoustu dalších možností.\",\n            \"getting_help\": \"Získání pomoci\",\n            \"open_collective\": \"Podívejte se na {open_collective_link} a zjistěte, jak můžete přispět k dalšímu rozvoji Stash.\",\n            \"help_links\": \"Pokud narazíte na problémy nebo máte nějaké dotazy či návrhy, neváhejte otevřít problém na {githubLink} nebo se zeptejte komunity na {discordLink}.\",\n            \"in_app_manual_explained\": \"Doporučujeme vám, abyste se podívali na příručku v aplikaci, kterou lze otevřít pomocí ikony v pravém horním rohu obrazovky, která vypadá takto: {icon}\",\n            \"next_config_step_two\": \"Až budete s těmito nastaveními spokojeni, můžete začít skenovat svůj obsah do Stash kliknutím na <code>{localized_task}</code> a poté na <code>{localized_scan}</code>.\",\n            \"support_us\": \"Podpoř nás\",\n            \"thanks_for_trying_stash\": \"Díky za vyzkoušení Stash!\",\n            \"your_system_has_been_created\": \"Úspěch! Váš systém byl vytvořen!\",\n            \"download_ffmpeg\": \"Stáhnout ffmpeg\",\n            \"missing_ffmpeg\": \"Chybí vám požadovaný binární soubor <code>ffmpeg</code>. Zaškrtnutím políčka níže si jej můžete automaticky stáhnout do svého konfiguračního adresáře. Případně můžete zadat cesty k binárním souborům <code>ffmpeg</code> a <code>ffprobe</code> v nastavení systému. Aby Stash fungoval, musí být tyto binární soubory přítomny.\",\n            \"welcome_contrib\": \"Vítáme také příspěvky ve formě kódu (opravy chyb, vylepšení a nových funkcí), testování, hlášení chyb, požadavků na vylepšení/funkce a uživatelskou podporu. Podrobnosti najdete v sekci Příspěveků v příručce v aplikaci.\"\n        },\n        \"errors\": {\n            \"something_went_wrong_description\": \"Pokud to vypadá na problém s vašemi vstupy, pokračujte kliknutím na tlačítko Zpět a opravte je. Jinak upozorněte na chybu na {githubLink} nebo vyhledejte pomoc na {discordLink}.\",\n            \"something_went_wrong\": \"Ach ne! Něco se pokazilo!\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Při nastavování systému se něco pokazilo. Zde je chyba, kterou jsme obdrželi: {error}\",\n            \"unable_to_retrieve_system_status\": \"Nelze načíst stav systému: {error}\",\n            \"unexpected_error\": \"Došlo k neočekávané chybě: {error}\"\n        },\n        \"folder\": {\n            \"up_dir\": \"Nahoru o adresář\",\n            \"file_path\": \"Cesta k souboru\"\n        },\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Cesta k zalohování databáze (pro deaktivaci zálohování ponechte prázdné):\",\n            \"backup_recommended\": \"Před migrací se doporučuje zálohovat stávající databázi. Můžeme to udělat za Vás vytvořením kopie vaší databáze na <code>{defaultBackupPath}</code>.\",\n            \"migration_failed_error\": \"Při migraci databáze došlo k následující chybě:\",\n            \"migration_failed\": \"Migrace selhala\",\n            \"migration_irreversible_warning\": \"Proces migrace schématu není vratný. Po provedení migrace bude vaše databáze nekompatibilní s předchozími verzemi Stash.\",\n            \"migration_notes\": \"Poznámky k migraci\",\n            \"migration_required\": \"Migrace je nutná\",\n            \"perform_schema_migration\": \"Proveď migraci schématu\",\n            \"migration_failed_help\": \"Proveďte prosím potřebné opravy a zkuste to znovu. Jinak upozorněte na chybu na {githubLink} nebo vyhledejte pomoc na {discordLink}.\",\n            \"schema_too_old\": \"Vaše aktuální Stash databáze je verze schématu <strong>{databaseSchema}</strong> a je třeba ji migrovat na verzi <strong>{appSchema}</strong>. Tato verze Stash nebude fungovat bez migrace databáze. Pokud si nepřejete migrovat, budete muset přejít na nižší verzi, která odpovídá schématu vaší databáze.\",\n            \"migrating_database\": \"Migruji databázi\"\n        },\n        \"confirm\": {\n            \"configuration_file_location\": \"Umístění konfiguračního souboru:\",\n            \"generated_directory\": \"Vygenerovaný adresář\",\n            \"nearly_there\": \"Skoro jsme tam!\",\n            \"stash_library_directories\": \"Stash adresáře knihoven\",\n            \"almost_ready\": \"Jsme téměř připraveni dokončit konfiguraci. Potvrďte prosím následující nastavení. Můžete kliknout zpět a změnit cokoliv nesprávného. Pokud vše vypadá dobře, kliknutím na Potvrdit vytvořte Váš systém.\",\n            \"blobs_directory\": \"Adresář binárních dat\",\n            \"database_file_path\": \"Cesta k souboru databáze\",\n            \"blobs_use_database\": \"<používá databázi>\",\n            \"cache_directory\": \"Adresář mezipaměti\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Vytváření vašeho systému\"\n        },\n        \"github_repository\": \"Github repozitář\",\n        \"welcome\": {\n            \"unable_to_locate_config\": \"Pokud toto čtete, Stash nemohla najít existující konfiguraci. Tento průvodce vás provede procesem nastavení nové konfigurace.\",\n            \"in_the_current_working_directory_disabled_macos\": \"Nepodporováno, když je spuštěna <code>Stash.app</code>,<br></br>spusťte <code>stash-macos</code> pro nastavení v pracovním adresáři\",\n            \"store_stash_config\": \"Kam chcete uložit konfiguraci Stash?\",\n            \"in_the_current_working_directory\": \"V <code>{cesta}</code>, pracovním adresáři, aktuálně:\",\n            \"in_the_current_working_directory_disabled\": \"V <code>{cesta}</code>, pracovní adresář:\",\n            \"next_step\": \"Se vším vysvětleným, pokud jste připraveni pokračovat v nastavení nového systému, vyberte prosím, kde byste chtěli uložit svůj konfigurační soubor.\",\n            \"unexpected_explained\": \"Pokud se tato obrazovka nečekaně zobrazuje, zkuste restartovat Stash ve správném pracovním adresáři nebo s příznakem <code>-c</code>.\",\n            \"config_path_logic_explained\": \"Stash se nejprve pokusí najít svůj konfigurační soubor (<code>config.yml</code>) z aktuálního pracovního adresáře, a pokud jej tam nenajde, vrátí se zpět do <code>{fallback_path}</code>. Stash můžete také nechat číst z konkrétního konfiguračního souboru spuštěním s <code>-c '<cesta ke konfiguračnímu souboru>'</code> nebo <code>--config '<cesta ke konfiguračnímu souboru>'</ kód> možnosti.\",\n            \"in_current_stash_directory\": \"V adresáři <code>{cesta}</code>:\"\n        },\n        \"welcome_to_stash\": \"Vítejte v Stash\",\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash použije následující cestu konfiguračního souboru: <code>{cesta}</code>\",\n            \"next_step\": \"Až budete připraveni pokračovat v nastavení nového systému, klikněte na Další.\",\n            \"unable_to_locate_specified_config\": \"Pokud toto čtete, Stash nemohla najít konfigurační soubor zadaný v příkazovém řádku nebo v prostředí. Tento průvodce vás provede procesem nastavení nové konfigurace.\"\n        }\n    },\n    \"dimensions\": \"Rozměry\",\n    \"disambiguation\": \"Jednoznačnost\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"Index obrázku musí být větší než 0\",\n        \"lazy_component_error_help\": \"Pokud jste nedávno aktualizovali Stash, znovu načtěte stránku nebo vymažte mezipaměť prohlížeče.\",\n        \"something_went_wrong\": \"Něco se pokazilo.\",\n        \"header\": \"Chyba\",\n        \"loading_type\": \"Chyba při načítání {type}\",\n        \"invalid_javascript_string\": \"Neplatný kód javascriptu : {error}\",\n        \"invalid_json_string\": \"Neplatný string JSON: {error}\",\n        \"custom_fields\": {\n            \"field_name_required\": \"Název pole je povinný\",\n            \"field_name_whitespace\": \"Název pole nesmí obsahovat mezery ani na začátku ani na konci\",\n            \"duplicate_field\": \"Název pole musí být jedinečný\",\n            \"field_name_length\": \"Název pole musí být kratší než 65 znaků\"\n        }\n    },\n    \"eye_color\": \"Barva očí\",\n    \"fake_tits\": \"Falešná prsa\",\n    \"file\": \"soubor\",\n    \"file_count\": \"Počet souborů\",\n    \"files_amount\": \"{value} soubory\",\n    \"filesize\": \"Velikost souboru\",\n    \"filter\": \"Filtr\",\n    \"filter_name\": \"Jméno filtru\",\n    \"filters\": \"Filtry\",\n    \"folder\": \"Složka\",\n    \"framerate\": \"Počet snímků za vteřinu\",\n    \"handy_connection_status\": {\n        \"error\": \"Chyba při připojování k Handy\",\n        \"missing\": \"Chybějící\",\n        \"ready\": \"Připraveno\",\n        \"connecting\": \"Připojuji\",\n        \"disconnected\": \"Odpojeno\",\n        \"syncing\": \"Probíhá synchronizace se serverem\",\n        \"uploading\": \"Nahrávání skriptu\"\n    },\n    \"hasMarkers\": \"Značky\",\n    \"height_cm\": \"Výška (cm)\",\n    \"include_sub_studios\": \"Zahrnout dceřiná studia\",\n    \"interactive\": \"Interaktivní\",\n    \"interactive_speed\": \"Interaktivní rychlost\",\n    \"isMissing\": \"Chybí\",\n    \"last_played_at\": \"Naposledy přehráno\",\n    \"loading\": {\n        \"generic\": \"Načítání…\",\n        \"plugins\": \"Znovu načíst pluginy…\"\n    },\n    \"library\": \"Knihovna\",\n    \"marker_count\": \"Počet Značek\",\n    \"markers\": \"Značky\",\n    \"measurements\": \"Míry\",\n    \"media_info\": {\n        \"audio_codec\": \"Audio Kodek\",\n        \"downloaded_from\": \"Staženo z\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} během produkce\"\n        },\n        \"play_duration\": \"Délka přehrávání\",\n        \"stream\": \"Stream\",\n        \"video_codec\": \"Video Kodek\",\n        \"interactive_speed\": \"Interaktivní rychlost\",\n        \"phash\": \"PHash\",\n        \"play_count\": \"Počet přehrání\",\n        \"o_count\": \"Počet O\"\n    },\n    \"megabits_per_second\": \"{value} mbps\",\n    \"none\": \"Žádný\",\n    \"pagination\": {\n        \"last\": \"Poslední\",\n        \"next\": \"Další\",\n        \"previous\": \"Předchozí\",\n        \"first\": \"První\",\n        \"current_total\": \"{current} z {total}\"\n    },\n    \"parent_tag_count\": \"Počet nadřazených tagů\",\n    \"parent_tags\": \"Nadřazené tagy\",\n    \"perceptual_similarity\": \"Percepční podobnost (phash)\",\n    \"performer\": \"Účinkující\",\n    \"play_duration\": \"Délka přehrávání\",\n    \"rating\": \"Hodnocení\",\n    \"recently_added_objects\": \"Nedávno přidané {objects}\",\n    \"scene_created_at\": \"Scéna vytvořena\",\n    \"search_filter\": {\n        \"saved_filters\": \"Uložené filtry\",\n        \"update_filter\": \"Aktualizovat filtr\",\n        \"edit_filter\": \"Upravit filtr\",\n        \"name\": \"Filtr\",\n        \"more_filter_criteria\": \"+{count} navíc\",\n        \"search_term\": \"Hledaný výraz\"\n    },\n    \"ethnicity\": \"Etnická příslušnost\",\n    \"existing_value\": \"Existujicí hodnota\",\n    \"false\": \"Lež\",\n    \"favourite\": \"Oblíbený/á\",\n    \"file_info\": \"Informace o souboru\",\n    \"file_mod_time\": \"Čas modifikace souboru\",\n    \"files\": \"soubory\",\n    \"frames_per_second\": \"{value} snímků za vteřinu\",\n    \"galleries\": \"Galerie\",\n    \"gender_types\": {\n        \"TRANSGENDER_MALE\": \"Transgender muž\",\n        \"FEMALE\": \"Žena\",\n        \"INTERSEX\": \"Intersex\",\n        \"MALE\": \"Muž\",\n        \"NON_BINARY\": \"Nebinární\",\n        \"TRANSGENDER_FEMALE\": \"Transgender žena\"\n    },\n    \"hair_color\": \"Barva vlasů\",\n    \"help\": \"Pomoc\",\n    \"hasChapters\": \"Kapitoly\",\n    \"height\": \"Výška\",\n    \"ignore_auto_tag\": \"Ignoruj automatické tagování\",\n    \"instagram\": \"Instragram\",\n    \"metadata\": \"Metadata\",\n    \"name\": \"Jméno\",\n    \"new\": \"Nový\",\n    \"organized\": \"Organizovaný\",\n    \"orientation\": \"Orientace\",\n    \"photographer\": \"Fotograf/ka\",\n    \"plays\": \"{value} přehrání\",\n    \"primary_file\": \"Primární soubor\",\n    \"primary_tag\": \"Primární tag\",\n    \"queue\": \"Fronta\",\n    \"random\": \"Náhodně\",\n    \"last_o_at\": \"Poslední O\",\n    \"o_history\": \"Historie O\",\n    \"odate_recorded_no\": \"Žádný Datum O Nezaznamenán\",\n    \"part_of\": \"Součást skupiny {parent}\",\n    \"penis\": \"Penis\",\n    \"penis_length\": \"Délka penisu\",\n    \"parent_of\": \"Nadřazený k {children}\",\n    \"parent_studio\": \"Nadřazené Studio\",\n    \"parent_studios\": \"Nadřazená studia\",\n    \"penis_length_cm\": \"Délka penisu (cm)\",\n    \"performer_age\": \"Věk Herce/Herečky\",\n    \"performer_count\": \"Počet Účinkujících\",\n    \"performer_favorite\": \"Oblíbení Účinkující\",\n    \"performer_image\": \"Fotka Herce/Herečky\",\n    \"piercings\": \"Piercingy\",\n    \"play_count\": \"Počet přehrání\",\n    \"play_history\": \"Historie přehrávání\",\n    \"playdate_recorded_no\": \"Nebylo zaznamenáno žádné datum přehrávání\",\n    \"recently_released_objects\": \"Nedávno vydané {objects}\",\n    \"release_notes\": \"Poznámky k vydání\",\n    \"resolution\": \"Rozlišení\",\n    \"resume_time\": \"Čas obnovení\",\n    \"scene\": \"Scéna\",\n    \"sceneTagger\": \"Tagger scén\",\n    \"scene_code\": \"Kód studia\",\n    \"scene_count\": \"Počet scén\",\n    \"scene_date\": \"Datum scény\",\n    \"scene_id\": \"ID scény\",\n    \"scene_tags\": \"Tagy scén\",\n    \"scene_updated_at\": \"Scéna aktualizována\",\n    \"scenes\": \"Scény\",\n    \"scenes_updated_at\": \"Scéna aktualizována\",\n    \"second\": \"Sekunda\",\n    \"seconds\": \"Sekund\",\n    \"settings\": \"Nastavení\",\n    \"empty_server\": \"Chcete-li na této stránce zobrazit doporučení, přidejte na svůj server nějaké scény.\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Předpřipravený filtr\",\n            \"saved_filter\": \"Uložený filtr\"\n        }\n    },\n    \"gallery\": \"Galerie\",\n    \"gallery_count\": \"Počet galerií\",\n    \"gender\": \"Pohlaví\",\n    \"history\": \"Historie\",\n    \"image\": \"Obrázek\",\n    \"image_count\": \"Počet obrázků\",\n    \"image_index\": \"Obrázek #\",\n    \"images\": \"Obrázky\",\n    \"include_parent_tags\": \"Zahrnout nadřazené tagy\",\n    \"include_sub_tags\": \"Zahrnout dílčí tagy\",\n    \"path\": \"Cesta\",\n    \"datetime_format\": \"YYYY-MM-DD HH:MM (rok-měsíc-den hodina:minuta)\",\n    \"operations\": \"Operace\",\n    \"performers\": \"Účinkujicí\",\n    \"index_of_total\": \"{index} z {total}\",\n    \"performer_tags\": \"Tagy účinkujících\",\n    \"tag_sub_tag_tooltip\": \"Má dílčí tagy\",\n    \"date_format\": \"YYYY-MM-DD (rok-měsíc-den)\",\n    \"status\": \"Status: {statusText}\",\n    \"studio_tagger\": {\n        \"no_results_found\": \"Nebyly nalezeny žádné výsledky.\",\n        \"untagged_studios\": \"Neoznačená studia\",\n        \"add_new_studios\": \"Přidat nová studia\",\n        \"config\": {\n            \"create_parent_label\": \"Vytvořit nadřazená studia\",\n            \"create_parent_desc\": \"Vytvořte chybějící nadřazená studia nebo označte a aktualizujte data/obrázeky pro stávající nadřazená studia s přesnými shodami názvů\"\n        },\n        \"number_of_studios_will_be_processed\": \"Bude zpracováno {studio_count} studií\",\n        \"to_use_the_studio_tagger\": \"Chcete-li použít studio Tagger, je třeba nakonfigurovat instanci stash-boxu.\",\n        \"update_studio\": \"Aktualizovat studio\",\n        \"update_studios\": \"Aktualizovat studia\",\n        \"any_names_entered_will_be_queried\": \"Všechna zadaná jména budou dotázána ze vzdálené instance Stash-Box a přidána, pokud budou nalezena. Za shodu budou považovány pouze přesné shody.\",\n        \"updating_untagged_studios_description\": \"Aktualizace neoznačených studií se pokusí porovnat všechna studia, která postrádají stash ID, a aktualizuje metadata.\",\n        \"create_or_tag_parent_studios\": \"Vytvořte chybějící nebo označte existujicí nadřazená studia\",\n        \"current_page\": \"Aktuální stránka\",\n        \"failed_to_save_studio\": \"Nepodařilo se uložit studio \\\"{studio}\\\"\",\n        \"name_already_exists\": \"Název již existuje\",\n        \"batch_add_studios\": \"Přidání více studií\",\n        \"batch_update_studios\": \"Aktualizovat více studií\",\n        \"query_all_studios_in_the_database\": \"Všechna studia v databázi\",\n        \"refresh_tagged_studios\": \"Obnovit označená studia\",\n        \"refreshing_will_update_the_data\": \"Aktualizace aktualizuje data všech označených studií z instance stash-boxu.\",\n        \"status_tagging_job_queued\": \"Status: Úloha označování zařazena do fronty\",\n        \"status_tagging_studios\": \"Status: Označování studií\",\n        \"studio_already_tagged\": \"Studio již označeno\",\n        \"tag_status\": \"Status označení\",\n        \"network_error\": \"Chyba sítě\",\n        \"studio_selection\": \"Výběr studia\",\n        \"studio_successfully_tagged\": \"Studio úspěšně označeno\",\n        \"studio_names_or_stashids_separated_by_comma\": \"Jména studií nebo Stash ID odělená čárkou\"\n    },\n    \"synopsis\": \"Souhrn\",\n    \"stashbox\": {\n        \"submit_update\": \"Již existuje v {endpoint_name}\",\n        \"submission_successful\": \"Odeslání úspěšné\",\n        \"submission_failed\": \"Odeslání selhalo\",\n        \"selected_stash_box\": \"Vyberte koncový bod Stash-Box\",\n        \"source\": \"Zdroj Stash-Box\",\n        \"go_review_draft\": \"Přejít do {endpoint_name} ke zkontrolování návrhu.\"\n    },\n    \"studios\": \"Studia\",\n    \"subsidiary_studio_count\": \"Počet Sub-Studií\",\n    \"subsidiary_studios\": \"Sub-Studia\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"Nelze vytvořit připojení websocket: podrobnosti naleznete v konzoly prohlížeče\",\n        \"websocket_connection_reestablished\": \"Připojení Websocket bylo obnoveno\"\n    },\n    \"o_count\": \"Počet O\",\n    \"stash_id_endpoint\": \"URL koncového bodu (endpoint) Stash ID\",\n    \"stash_ids\": \"Stash IDs\",\n    \"statistics\": \"Statistiky\",\n    \"stats\": {\n        \"image_size\": \"Velikost obrázků\",\n        \"scenes_duration\": \"Trvání scén\",\n        \"scenes_played\": \"Přehrátých scén\",\n        \"total_o_count\": \"Celkový Počet O\",\n        \"total_play_duration\": \"Celková doba přehrávání\",\n        \"scenes_size\": \"Velikost scén\",\n        \"total_play_count\": \"Celkový počet přehrátí\",\n        \"total_o_count_sfw\": \"Celkový počet lajků\"\n    },\n    \"studio\": \"Studio\",\n    \"studio_and_parent\": \"Studio & Nadřazené studio\",\n    \"studio_depth\": \"Úrovně (prázdné pro všechny)\",\n    \"sub_tag_count\": \"Počet sub-tagů\",\n    \"sub_tags\": \"Sub-Tagy\",\n    \"years_old\": \"Let\",\n    \"updated_at\": \"Aktualizováno\",\n    \"stash_id\": \"Stash ID\",\n    \"sub_tag_of\": \"Počet sub-tagů od {parent}\",\n    \"containing_group\": \"Obsahující skupina\",\n    \"containing_group_count\": \"Počet obsahujících skupin\",\n    \"include_sub_group_content\": \"Zahrnout obsah podskupiny\",\n    \"group\": \"Skupina\",\n    \"group_count\": \"Počet skupin\",\n    \"group_scene_number\": \"Číslo scény\",\n    \"studio_count\": \"Počet studií\",\n    \"studio_tags\": \"Tagy studií\",\n    \"containing_groups\": \"Obsahující skupiny\",\n    \"groups\": \"Skupiny\",\n    \"sub_group_of\": \"Podskupina {parent}\",\n    \"sub_group_order\": \"Pořadí podskupin\",\n    \"sub_groups\": \"Podskupiny\",\n    \"sub_group\": \"Podskupina\",\n    \"sub_group_count\": \"Počet podskupin\",\n    \"include_sub_studio_content\": \"Zahrnout obsah podstudií\",\n    \"include_sub_tag_content\": \"Zahrnout obsah podtagů\",\n    \"include_sub_groups\": \"Zahrnout podskupiny\",\n    \"time_end\": \"Čas ukončení\",\n    \"criterion_modifier_values\": {\n        \"any\": \"Jakýkoli\",\n        \"any_of\": \"Jakýkoli z\",\n        \"none\": \"Žádný\",\n        \"only\": \"Pouze\"\n    },\n    \"custom_fields\": {\n        \"title\": \"Uživatelská pole\",\n        \"value\": \"Hodnota\",\n        \"field\": \"Pole\",\n        \"criteria_format_string\": \"{criterion} (custom field) {modifierString} {valueString}\",\n        \"criteria_format_string_others\": \"{criterion} (custom field) {modifierString} {valueString} (+{others} další)\"\n    },\n    \"age_on_date\": \"{age} během produkce\",\n    \"sort_name\": \"Seřadit jména\",\n    \"eta\": \"Přibližný čas dokončení\",\n    \"login\": {\n        \"password\": \"Heslo\",\n        \"invalid_credentials\": \"Neplatné uživatelské jméno nebo heslo\",\n        \"login\": \"Přihlášení\",\n        \"username\": \"Uživatelské jméno\",\n        \"internal_error\": \"Neočekávaná interní chyba. Podívej se do logu pro více detailů\"\n    },\n    \"last_o_at_sfw\": \"Poslední lajk\",\n    \"o_count_sfw\": \"Lajky\",\n    \"o_history_sfw\": \"Historie lajků\",\n    \"odate_recorded_no_sfw\": \"Žádný datum lajku nebyl zaznamenán\",\n    \"scenes_duration\": \"Trvání scény\",\n    \"stashbox_search\": {\n        \"header\": \"Hledej {entityType} ve StashBoxu\",\n        \"no_results\": \"Žádné výsledky nenalezeny.\",\n        \"placeholder_name_or_id\": \"{entityType} jméno nebo StashID...\",\n        \"select_stashbox\": \"Vybrat StashBox...\"\n    },\n    \"latest_scene\": \"Nejnovější scéna\",\n    \"stash_id_count\": \"Počet Stash ID\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/da-DK.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Tilføj\",\n        \"add_directory\": \"Tilføj mappe\",\n        \"add_entity\": \"Tilføj {entityType}\",\n        \"add_to_entity\": \"Tilføj til {entityType}\",\n        \"allow\": \"Tillad\",\n        \"allow_temporarily\": \"Tillad midlertidigt\",\n        \"anonymise\": \"Anonymisér\",\n        \"apply\": \"Anvend\",\n        \"auto_tag\": \"Auto Tagge\",\n        \"backup\": \"Backup\",\n        \"browse_for_image\": \"Søg efter billede…\",\n        \"cancel\": \"Afbryd\",\n        \"clean\": \"Rens\",\n        \"clear\": \"Ryd\",\n        \"clear_back_image\": \"Ryd bagerst billede\",\n        \"clear_front_image\": \"Ryd forrest billede\",\n        \"clear_image\": \"Ryd Billede\",\n        \"close\": \"Luk\",\n        \"confirm\": \"Bekræft\",\n        \"continue\": \"Forsæt\",\n        \"create\": \"Lav\",\n        \"create_chapters\": \"Skab Kapitel\",\n        \"create_entity\": \"Lav {entityType}\",\n        \"create_marker\": \"Lav Mærke\",\n        \"created_entity\": \"Lavet {entity_type}: {entity_name}\",\n        \"customise\": \"Tilpas\",\n        \"delete\": \"Slet\",\n        \"delete_entity\": \"Slet {entityType}\",\n        \"delete_file\": \"Slet fil\",\n        \"delete_file_and_funscript\": \"Slet fil (og funscript)\",\n        \"delete_generated_supporting_files\": \"Slet genereret filer\",\n        \"disallow\": \"Tillad ikke\",\n        \"download\": \"Download\",\n        \"download_anonymised\": \"Hent anonymiseret\",\n        \"download_backup\": \"Download Backup\",\n        \"edit\": \"Ændre\",\n        \"edit_entity\": \"Ændre {entityType}\",\n        \"export\": \"Eksportere\",\n        \"export_all\": \"Exportere alt…\",\n        \"find\": \"Finde\",\n        \"finish\": \"Afslut\",\n        \"from_file\": \"Fra fil…\",\n        \"from_url\": \"Fra URL…\",\n        \"full_export\": \"Fuld Eksport\",\n        \"full_import\": \"Fuld Import\",\n        \"generate\": \"Generer\",\n        \"generate_thumb_default\": \"Generer standard thumbnail\",\n        \"generate_thumb_from_current\": \"Generer thumbnail fra nuværende\",\n        \"hash_migration\": \"hash migration\",\n        \"hide\": \"Skjul\",\n        \"hide_configuration\": \"Skjul Konfiguration\",\n        \"identify\": \"identificer\",\n        \"ignore\": \"Ignorere\",\n        \"import\": \"Importer…\",\n        \"import_from_file\": \"Importer fra fil\",\n        \"logout\": \"Log ud\",\n        \"make_primary\": \"Gør til primær\",\n        \"merge\": \"Fusioner\",\n        \"migrate_blobs\": \"Migrér Blobs\",\n        \"migrate_scene_screenshots\": \"Migrér Scene-skærmbilleder\",\n        \"next_action\": \"Næste\",\n        \"not_running\": \"kører ikke\",\n        \"open_in_external_player\": \"Åben i ekstern afspiller\",\n        \"open_random\": \"Åben Tilfældig\",\n        \"overwrite\": \"Overskriv\",\n        \"play_random\": \"Afspil tilfældig\",\n        \"play_selected\": \"Afspil valgte\",\n        \"preview\": \"Forhåndsvisning\",\n        \"previous_action\": \"Tilbage\",\n        \"reassign\": \"Gentildel\",\n        \"refresh\": \"Opdater\",\n        \"reload_plugins\": \"Genindlæs plugins\",\n        \"reload_scrapers\": \"Genindlæs scrapers\",\n        \"remove\": \"Fjern\",\n        \"remove_from_gallery\": \"Fjern fra Galleri\",\n        \"rename_gen_files\": \"Omdøb genereret filer\",\n        \"rescan\": \"Genscan\",\n        \"reshuffle\": \"Bland om\",\n        \"running\": \"kører\",\n        \"save\": \"Gem\",\n        \"save_delete_settings\": \"Brug disse muligheder som standard når der slettes\",\n        \"save_filter\": \"Gem filter\",\n        \"scan\": \"Skan\",\n        \"scrape\": \"Skrabe\",\n        \"scrape_query\": \"Skrab forespørgsel\",\n        \"scrape_scene_fragment\": \"Skrab for fragment\",\n        \"scrape_with\": \"Skrab med…\",\n        \"search\": \"Søg\",\n        \"select_all\": \"Vælg Alt\",\n        \"select_entity\": \"Vælg {entityType}\",\n        \"select_folders\": \"Vælg mappe\",\n        \"select_none\": \"Vælg Ingen\",\n        \"selective_auto_tag\": \"Selektiv Auto Tag\",\n        \"selective_clean\": \"Selektiv Ryd\",\n        \"selective_scan\": \"Selektiv Scan\",\n        \"set_as_default\": \"Vælg som standard\",\n        \"set_back_image\": \"Bagerst billede…\",\n        \"set_front_image\": \"Forrest billede…\",\n        \"set_image\": \"Vælg billede…\",\n        \"show\": \"Vis\",\n        \"show_configuration\": \"Vis Konfiguration\",\n        \"skip\": \"Spring over\",\n        \"split\": \"Opdel\",\n        \"stop\": \"Stop\",\n        \"submit\": \"Send\",\n        \"submit_stash_box\": \"Send til Stash-Box\",\n        \"submit_update\": \"Send opdatering\",\n        \"swap\": \"Ombyt\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Er du sikker på, at du vil Rense? Dette vil slette databaseinformation og genereret indhold for alle scener og gallerier, der ikke længere findes i filsystemet.\",\n            \"dry_mode_selected\": \"Tør Tilstand valgt. Ingen egentlig sletning vil finde sted, kun logning.\",\n            \"import_warning\": \"Er du sikker på, at du vil importere? Dette vil slette databasen og genimportere fra dine eksporterede metadata.\"\n        },\n        \"temp_disable\": \"Deaktiver midlertidigt…\",\n        \"temp_enable\": \"Aktiver midlertidigt…\",\n        \"unset\": \"Fravælg\",\n        \"use_default\": \"Brug standard\",\n        \"view_random\": \"Vis Tilfældig\",\n        \"optimise_database\": \"Optimer Database\",\n        \"reload\": \"Genindlæs\",\n        \"add_manual_date\": \"Tilføj manuelt dato\",\n        \"add_o\": \"Tilføj O\",\n        \"add_play\": \"Tilføj afspil\",\n        \"choose_date\": \"Vælg en dato\",\n        \"copy_to_clipboard\": \"Kopier til udklipsholder\",\n        \"create_parent_studio\": \"Lav forældre studie\",\n        \"disable\": \"Deaktiver\",\n        \"enable\": \"Aktiver\",\n        \"remove_date\": \"Fjern dato\",\n        \"assign_stashid_to_parent_studio\": \"Tildel Stash ID til eksisterende forældre studie og updater metadata\",\n        \"clean_generated\": \"Rens generede filer\",\n        \"clear_date_data\": \"Rens dato data\",\n        \"add_sub_groups\": \"Tilføj undergrupper\",\n        \"add_stash_id\": \"Tilføj Stash ID\",\n        \"create_new\": \"Skab ny\",\n        \"encoding_image\": \"Enkoder billede…\",\n        \"load\": \"Hent\",\n        \"load_filter\": \"Hent filter\",\n        \"play\": \"Afspil\",\n        \"remove_from_containing_group\": \"Fjern fra Gruppe\",\n        \"reset_play_duration\": \"Nulstil afspilningslængde\",\n        \"reset_resume_time\": \"Nulstil genoptag tidspunkt\",\n        \"reset_cover\": \"Genskab Standard Cover\",\n        \"set_cover\": \"Sæt som Cover\",\n        \"show_results\": \"Vis resultater\",\n        \"show_count_results\": \"Vis {count} resultater\",\n        \"sidebar\": {\n            \"close\": \"Luk sidepanel\",\n            \"open\": \"Åbn sidepanel\",\n            \"toggle\": \"Skift sidepanel\"\n        },\n        \"view_history\": \"Se historik\"\n    },\n    \"actions_name\": \"Handlinger\",\n    \"age\": \"Alder\",\n    \"aliases\": \"Aliaser\",\n    \"all\": \"alt\",\n    \"also_known_as\": \"Også kendt som\",\n    \"appears_with\": \"Optræder Med\",\n    \"ascending\": \"Stigende\",\n    \"average_resolution\": \"Gennemsnitlig Opløsning\",\n    \"between_and\": \"og\",\n    \"birth_year\": \"Fødselsår\",\n    \"birthdate\": \"Fødselsdato\",\n    \"bitrate\": \"Bithastighed\",\n    \"blobs_storage_type\": {\n        \"database\": \"Database\",\n        \"filesystem\": \"Filsystem\"\n    },\n    \"captions\": \"Undertekster\",\n    \"career_length\": \"Karrierer Længde\",\n    \"chapters\": \"Kapitler\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktiv stash-box instans:\",\n            \"blacklist_desc\": \"Sortlistet elementer er ekskluderet fra forespørgsler. Bemærk, at de er regulære udtryk og også ufølsomme for store og små bogstaver. Visse tegn skal escapes med en omvendt skråstreg: {chars_require_escape}\",\n            \"blacklist_label\": \"Sortlist\",\n            \"query_mode_auto\": \"Auto\",\n            \"query_mode_auto_desc\": \"Bruger metadata, hvis de er til stede, eller filnavn\",\n            \"query_mode_dir\": \"Mappe\",\n            \"query_mode_dir_desc\": \"Bruger kun overordnet mappe til videofil\",\n            \"query_mode_filename\": \"Filnavn\",\n            \"query_mode_filename_desc\": \"Bruger kun filnavn\",\n            \"query_mode_label\": \"Forespørgselstilstand\",\n            \"query_mode_metadata\": \"Metadata\",\n            \"query_mode_metadata_desc\": \"Brug kun metadata\",\n            \"query_mode_path\": \"Sti\",\n            \"query_mode_path_desc\": \"Brug fuld file sti\",\n            \"set_cover_desc\": \"Overskriv scene billedet hvis en er fundet.\",\n            \"set_cover_label\": \"Vælg scene cover billede\",\n            \"set_tag_desc\": \"Vedhæft tags til scenen, enten ved at overskrive eller flette med eksisterende tags.\",\n            \"set_tag_label\": \"Sæt tags\",\n            \"source\": \"Kilde\",\n            \"mark_organized_desc\": \"Sæt scene som Organiseret efter klik på Save knappen.\",\n            \"mark_organized_label\": \"Marker som Organiseret ved gem\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Duplikér blacklist element\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Skuespilleres køn\",\n                \"description\": \"Skuespillere med disse køn vil blive vist når scener tagges.\"\n            }\n        },\n        \"noun_query\": \"Query\",\n        \"results\": {\n            \"duration_off\": \"Varighed af med mindst {number}s\",\n            \"duration_unknown\": \"Varighed ukendt\",\n            \"fp_found\": \"{fpCount, plural, =0 {Ingen nye fingeraftryksmatches fundet} other {# nye fingeraftryksmatches fundet}}\",\n            \"fp_matches\": \"Varighed er et match\",\n            \"fp_matches_multi\": \"Varighed matcher {matchCount}/{durationsLength} fingeraftryk\",\n            \"hash_matches\": \"{hash_type} er et match\",\n            \"match_failed_already_tagged\": \"Scene er allerede tagget\",\n            \"match_failed_no_result\": \"Ingen resultater fundet\",\n            \"match_success\": \"Scene blev tagget\",\n            \"phash_matches\": \"{count} PHashes matcher\",\n            \"unnamed\": \"Unavngivet\"\n        },\n        \"verb_match_fp\": \"Match fingeraftryk\",\n        \"verb_matched\": \"Matchet\",\n        \"verb_scrape_all\": \"Scrape Alle\",\n        \"verb_submit_fp\": \"Indsend {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} ikke matchede scener\",\n        \"verb_add_as_alias\": \"Tilføj scrapede navne som alias\",\n        \"verb_link_existing\": \"Link til eksisterende\",\n        \"verb_match_tag\": \"Match Tag\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Byg hash:\",\n            \"build_time\": \"Byggetid:\",\n            \"check_for_new_version\": \"Tjek for ny version\",\n            \"latest_version\": \"Seneste Version\",\n            \"latest_version_build_hash\": \"Seneste version Byg Hash:\",\n            \"new_version_notice\": \"[NY]\",\n            \"release_date\": \"Udgivelsesdato:\",\n            \"stash_discord\": \"Tilmeld dig vores {url} kanal\",\n            \"stash_home\": \"Stash hjem på {url}\",\n            \"stash_open_collective\": \"Støt os gennem {url}\",\n            \"stash_wiki\": \"Stash {url} side\",\n            \"version\": \"Version\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Applikationsstier\"\n        },\n        \"categories\": {\n            \"about\": \"Om\",\n            \"changelog\": \"Ændringslog\",\n            \"interface\": \"Interface\",\n            \"logs\": \"Logfiler\",\n            \"metadata_providers\": \"Metadataudbydere\",\n            \"plugins\": \"Plugins\",\n            \"scraping\": \"Skrabning\",\n            \"security\": \"Sikkerhed\",\n            \"services\": \"Tjenester\",\n            \"system\": \"System\",\n            \"tasks\": \"Opgaver\",\n            \"tools\": \"Værktøjer\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Tillad {tempIP}\",\n            \"allowed_ip_addresses\": \"Tilladte IP-adresser\",\n            \"allowed_ip_temporarily\": \"Tilladt IP midlertidigt\",\n            \"default_ip_whitelist\": \"Standard IP-hvidliste\",\n            \"default_ip_whitelist_desc\": \"Standard IP-adresser giver adgang til DLNA. Brug {wildcard} for at tillade alle IP-adresser.\",\n            \"disabled_dlna_temporarily\": \"Deaktiveret DLNA midlertidigt\",\n            \"disallowed_ip\": \"Ikke tilladt IP\",\n            \"enabled_by_default\": \"Aktiveret som standard\",\n            \"enabled_dlna_temporarily\": \"Aktiveret DLNA midlertidigt\",\n            \"network_interfaces\": \"Interfacer\",\n            \"network_interfaces_desc\": \"Interfaces til at eksponere DLNA-server på. En tom liste resulterer i at køre på alle grænseflader. Kræver DLNA-genstart efter ændring.\",\n            \"recent_ip_addresses\": \"Seneste IP-adresser\",\n            \"server_display_name\": \"Server Viste Navn\",\n            \"server_display_name_desc\": \"Vist navn for DLNA-serveren. Standard er {server_name}, hvis tom.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Midlertidig adfærd blev annulleret\",\n            \"until_restart\": \"indtil genstart\",\n            \"video_sort_order\": \"Standard Video Sorteringsrækkefølge\",\n            \"video_sort_order_desc\": \"Standard rækkefølge til sortering af videoer.\",\n            \"server_port\": \"Server Port\",\n            \"server_port_desc\": \"Porten som DLNA serveren skal køre på. Kræver DLNA genstart efter ændring.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API Nøgle\",\n                \"api_key_desc\": \"API nøgle til eksterne systemer. Kun påkrævet, når brugernavn/adgangskode er konfigureret. Brugernavn skal gemmes, før API-nøgle genereres.\",\n                \"authentication\": \"Godkendelse\",\n                \"clear_api_key\": \"Ryd API nøgle\",\n                \"credentials\": {\n                    \"description\": \"Legitimationsoplysninger for at begrænse adgangen til stash.\",\n                    \"heading\": \"Legitimationsoplysninger\"\n                },\n                \"generate_api_key\": \"Generer API nøgle\",\n                \"log_file\": \"Logfil\",\n                \"log_file_desc\": \"Sti til filen, der skal udlæses logning til. Tom for at deaktivere fillogning. Kræver genstart.\",\n                \"log_http\": \"Log http-adgang\",\n                \"log_http_desc\": \"Logger http-adgang til terminalen. Kræver genstart.\",\n                \"log_to_terminal\": \"Log til terminal\",\n                \"log_to_terminal_desc\": \"Log på terminalen ud over en fil. Altid sandt, hvis fillogning er deaktiveret. Kræver genstart.\",\n                \"maximum_session_age\": \"Maksimal sessionsalder\",\n                \"maximum_session_age_desc\": \"Maksimal inaktiv tid, før en login-session udløber, i sekunder. Kræver genstart.\",\n                \"password\": \"Kodeord\",\n                \"password_desc\": \"Adgangskode for at få adgang til Stash. Lad tom for at deaktivere brugergodkendelse\",\n                \"stash-box_integration\": \"Stash-box integration\",\n                \"username\": \"Brugernavn\",\n                \"username_desc\": \"Brugernavn for at få adgang til Stash. Lad tom for at deaktivere brugergodkendelse\",\n                \"log_file_max_size\": \"Maksimal log størrelse\",\n                \"log_file_max_size_desc\": \"Den maksimale størrelse i megabytes for logfilen, før den bliver komprimeret. 0MB betyder slået fra. Kræver genstart.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Mappelokation for SQLite database backup filer\",\n                \"heading\": \"Backup mappesti\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Hvori filsystemet binær data skal lagres. Anvendes kun når blobs lagres i Filsystemet. ADVARSEL: Ændres dette, kræves manuel flytning af eksisterende data.\",\n                \"heading\": \"Binær data filsystem-sti\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Hvor binær data, som scene-forsider, performere, studie eller tag-billeder opbevares. Efter denne værdi ændres, skal den eksisterende data migreres med Migrér Blobs-opgaverne. Se Opgaver siden for migrering.\",\n                \"heading\": \"Binær data lagringstype\"\n            },\n            \"cache_location\": \"Mappe-placering af cachen. Påkrævet, hvis der streames via HLS (som på Apple-enheder) eller DASH.\",\n            \"cache_path_head\": \"Cache Sti\",\n            \"calculate_md5_and_ohash_desc\": \"Beregn MD5 kontrolsum ud over oshash. Aktivering vil medføre, at indledende scanninger bliver langsommere. Filnavnehash skal indstilles til oshash for at deaktivere MD5-beregning.\",\n            \"calculate_md5_and_ohash_label\": \"Beregn MD5 for videoer\",\n            \"check_for_insecure_certificates\": \"Tjek for usikre certifikater\",\n            \"check_for_insecure_certificates_desc\": \"Nogle websteder bruger usikre ssl-certifikater. Når afkrydsningen er afkrydset, springer skraberen kontrollen over usikre certifikater over og tillader skrabning af disse websteder. Hvis du får en certifikatfejl, når du skraber, skal du fjerne flueben her.\",\n            \"chrome_cdp_path\": \"Chrome CDP-sti\",\n            \"chrome_cdp_path_desc\": \"Filsti til den eksekverbare Chrome-fil eller en ekstern adresse (startende med http:// eller https://, for eksempel http://localhost:9222/json/version) til en Chrome-instans.\",\n            \"create_galleries_from_folders_desc\": \"Hvis sandt, opretter gallerier fra mapper, der indeholder billeder.\",\n            \"create_galleries_from_folders_label\": \"Opret gallerier fra mapper, der indeholder billeder\",\n            \"database\": \"Database\",\n            \"db_path_head\": \"Databasesti\",\n            \"directory_locations_to_your_content\": \"Adresser til dit indhold i mappen\",\n            \"excluded_image_gallery_patterns_desc\": \"Regexps af billed- og gallerifiler/stier, der skal udelukkes fra Scan og tilføje til Clean\",\n            \"excluded_image_gallery_patterns_head\": \"Udelukkede billed-/gallerimønstre\",\n            \"excluded_video_patterns_desc\": \"Regexps af videofiler/stier, der skal udelukkes fra Scan og tilføje til Clean\",\n            \"excluded_video_patterns_head\": \"Udelukkede videomønstre\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"Anvender tilgængelig hardware til live-omkodning af video.\",\n                    \"heading\": \"FFmpeg hardware-indkodning\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avanceret: Yderligere argumenter for videreførsel til ffmpeg forinden inputsfeltet, når live video omkodes.\",\n                        \"heading\": \"FFmpeg Live Omkodning Input Argumenter\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avanceret: Øvrige argumenter som overføres til ffmpeg før output feltet når videoer transkodes live.\",\n                        \"heading\": \"FFmpeg Live Trankodning Output Args\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"Downloader FFmpeg til konfigurationsmappen og nulstiller ffmpeg and ffprobe mappestier så de hentes fra konfigurationsmappen.\",\n                    \"heading\": \"Download FFmpeg\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"Stien til det eksekverbare ffmpeg program (ikke bare mappen). Hvis tom, bliver ffmpeg hentet fra enten miljøet via $PATH, konfigurationsmappen, eller fra $HOME/.stash\",\n                    \"heading\": \"FFmpeg Eksekvérbare Sti\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Stien til ffprobes eksekvérbare fil (ikke bare mappen). Hvis tom, bliver ffprobe hentet fra enten miljøet via $PATH, konfigurationsmappen, eller fra $HOME/.stash\",\n                    \"heading\": \"FFProbes Eksekvérbare Sti\"\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avanceret: Øvrige argumenter som overføres til ffmpeg før input feltet når video skabes.\",\n                        \"heading\": \"FFmpeg Transkodning Input Args\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avanceret: Øvrige argumenter som overføres til ffmpeg før output feltet når videoer skabes.\",\n                        \"heading\": \"FFMpeg Transkodning Output Args\"\n                    }\n                }\n            },\n            \"gallery_ext_desc\": \"Kommasepareret liste over filtypenavne, der vil blive identificeret som galleri-zip-filer.\",\n            \"gallery_ext_head\": \"Galleri zip-udvidelser\",\n            \"generated_file_naming_hash_desc\": \"Brug MD5 eller oshash til genereret filnavngivning. Ændring af dette kræver, at alle scener har den relevante MD5/oshash-værdi udfyldt. Efter at have ændret denne værdi, skal eksisterende genererede filer migreres eller regenereres. Se siden Opgaver for migrering.\",\n            \"generated_file_naming_hash_head\": \"Genereret hash til filnavngivning\",\n            \"generated_files_location\": \"Katalogplacering for de genererede filer (scenemarkører, sceneforhåndsvisninger, sprites osv.)\",\n            \"generated_path_head\": \"Genereret sti\",\n            \"image_ext_desc\": \"Kommasepareret liste over filtypenavne, der vil blive identificeret som billeder.\",\n            \"image_ext_head\": \"Billedudvidelser\",\n            \"include_audio_desc\": \"Inkluderer lydstream ved generering af forhåndsvisninger.\",\n            \"include_audio_head\": \"Inkluder lyd\",\n            \"logging\": \"Logning\",\n            \"maximum_streaming_transcode_size_desc\": \"Maksimal størrelse for omkodede streams\",\n            \"maximum_streaming_transcode_size_head\": \"Maksimal streaming transcode størrelse\",\n            \"maximum_transcode_size_desc\": \"Maksimal størrelse for genererede transkoder\",\n            \"maximum_transcode_size_head\": \"Maksimal omkodningsstørrelse\",\n            \"metadata_path\": {\n                \"description\": \"Katalogplacering, der bruges, når du udfører en fuld eksport eller import\",\n                \"heading\": \"Metadatasti\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Indstil til 0 for automatisk registrering. Advarsel om at køre flere opgaver, end der kræves for at opnå 100 % cpu-udnyttelse, vil reducere ydeevnen og potentielt forårsage andre problemer.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Antal parallelle opgaver til scanning/generering\",\n            \"parallel_scan_head\": \"Parallel scanning/generering\",\n            \"preview_generation\": \"Forhåndsvisningsgenerering\",\n            \"python_path\": {\n                \"description\": \"Placering af python eksekverbar (Ikke kun mappen). Bruges til scriptskrabere og plugins. Hvis tom, vil python blive løst fra miljøet\",\n                \"heading\": \"Python eksekverbar sti\"\n            },\n            \"scraper_user_agent\": \"Skraber brugeragent\",\n            \"scraper_user_agent_desc\": \"User-Agent-streng brugt under scrape http-anmodninger\",\n            \"scrapers_path\": {\n                \"description\": \"Directory placering af skraber konfigurationsfiler\",\n                \"heading\": \"Skraberstien\"\n            },\n            \"scraping\": \"Skrabning\",\n            \"sqlite_location\": \"Filplacering for SQLite-databasen (kræver genstart)\",\n            \"video_ext_desc\": \"Kommasepareret liste over filtypenavne, der vil blive identificeret som videoer.\",\n            \"video_ext_head\": \"Videoudvidelser\",\n            \"video_head\": \"Video\",\n            \"plugins_path\": {\n                \"heading\": \"Sti til Plugins\",\n                \"description\": \"Placeringen af plugin konfigurations filer\"\n            },\n            \"gallery_cover_regex_desc\": \"Regexp bruges til at identificere et billede som galleri cover\",\n            \"delete_trash_path\": {\n                \"description\": \"Stien slettede filer flyttes til, i stedet for at blive slettet permanent. Efterlad tom hvis filer skal slettes permanent.\",\n                \"heading\": \"Sti til Papirkurv\"\n            },\n            \"funscript_heatmap_draw_range\": \"Inkludér interval i genererede heatmaps\",\n            \"funscript_heatmap_draw_range_desc\": \"Tegn bevægelsesomfang på y-aksen af det genererede heatmap. Eksisterende heatmaps vil blive regenereret efter ændringen.\",\n            \"gallery_cover_regex_label\": \"Galleri cover mønster\",\n            \"hashing\": \"Hashing\",\n            \"heatmap_generation\": \"Funscript Heatmap Generering\"\n        },\n        \"library\": {\n            \"exclusions\": \"Udelukkelser\",\n            \"gallery_and_image_options\": \"Indstillinger for Galleri og Billede\",\n            \"media_content_extensions\": \"Medieindholdsudvidelser\"\n        },\n        \"logs\": {\n            \"log_level\": \"Log niveau\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Kroge\",\n            \"triggers_on\": \"Udløser på\",\n            \"available_plugins\": \"Tilgængelige Plugins\",\n            \"installed_plugins\": \"Installerede Plugins\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metadata\",\n            \"entity_scrapers\": \"{entityType} skrabere\",\n            \"excluded_tag_patterns_desc\": \"Regexps af tagnavne, der skal udelukkes fra skraberesultater\",\n            \"excluded_tag_patterns_head\": \"Udelukkede tagmønstre\",\n            \"scraper\": \"Skraber\",\n            \"scrapers\": \"Skrabere\",\n            \"search_by_name\": \"Søg på navn\",\n            \"supported_types\": \"Understøttede typer\",\n            \"supported_urls\": \"URL'er\",\n            \"available_scrapers\": \"Tilgængelige Skrabere\",\n            \"installed_scrapers\": \"Installerede Skrabere\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Tilføj stash-box-instans\",\n            \"api_key\": \"API nøgle\",\n            \"description\": \"Stash-box letter automatisk tagging af scener og performere baseret på fingeraftryk og filnavne.\\nSlutpunkt og API-nøgle kan findes på din kontoside på stash-box-forekomsten. Navne er påkrævet, når mere end én forekomst tilføjes.\",\n            \"endpoint\": \"Slutpunkt\",\n            \"graphql_endpoint\": \"GraphQL slutpunkt\",\n            \"name\": \"Navn\",\n            \"title\": \"Stash-box-endepunkter\",\n            \"max_requests_per_minute\": \"Max forespørgsler per minut\",\n            \"max_requests_per_minute_description\": \"Bruger standardværdien {defaultValue} hvis sat til 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Omkodning\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Føjede {operation_name} til jobkøen\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Automatisk tagging af alle stier\",\n                \"auto_tagging_paths\": \"Automatisk tagging af følgende stier\"\n            },\n            \"auto_tag_based_on_filenames\": \"Autotag indhold baseret på filnavne.\",\n            \"auto_tagging\": \"Auto tagging\",\n            \"backing_up_database\": \"Sikkerhedskopiering af database\",\n            \"backup_and_download\": \"Udfører en sikkerhedskopi af databasen og downloader den resulterende fil.\",\n            \"cleanup_desc\": \"Tjek for manglende filer og fjern dem fra databasen. Dette er en destruktiv handling.\",\n            \"data_management\": \"Datastyring\",\n            \"defaults_set\": \"Standarder er blevet indstillet og vil blive brugt, når du klikker på knappen {action} på siden Opgaver.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Inkluder ikke filtypenavnet som en del af titlen\",\n            \"empty_queue\": \"Ingen opgaver kører i øjeblikket.\",\n            \"export_to_json\": \"Eksporterer databaseindholdet til JSON-format i metadatabiblioteket.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Genererer til scener fra følgende stier\",\n                \"generating_scenes\": \"Genererer for {num} {scene}\"\n            },\n            \"generate_desc\": \"Generer understøttende billed-, sprite-, video-, vtt- og andre filer.\",\n            \"generate_phashes_during_scan\": \"Generer perceptuelle hashes\",\n            \"generate_phashes_during_scan_tooltip\": \"Til deduplikering og sceneidentifikation.\",\n            \"generate_previews_during_scan\": \"Generer animerede billedforhåndsvisninger\",\n            \"generate_previews_during_scan_tooltip\": \"Generer animerede WebP-forhåndsvisninger, kun påkrævet, hvis Eksempeltype er indstillet til Animeret billede.\",\n            \"generate_sprites_during_scan\": \"Generer scrubber sprites\",\n            \"generate_thumbnails_during_scan\": \"Generer thumbnails til billeder\",\n            \"generate_video_previews_during_scan\": \"Generer forhåndsvisninger\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generer videoforhåndsvisninger, som afspilles, når du svæver over en scene\",\n            \"generated_content\": \"Genereret indhold\",\n            \"identify\": {\n                \"and_create_missing\": \"og lav manglende\",\n                \"create_missing\": \"Opret manglende\",\n                \"default_options\": \"Standardindstillinger\",\n                \"description\": \"Indstil automatisk scene-metadata ved hjælp af stash-box og scraper-kilder.\",\n                \"explicit_set_description\": \"Følgende muligheder vil blive brugt, hvor de ikke tilsidesættes i de kildespecifikke muligheder.\",\n                \"field\": \"Felt\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Feltindstillinger\",\n                \"heading\": \"Identificere\",\n                \"identifying_from_paths\": \"Identifikation af scener fra følgende stier\",\n                \"identifying_scenes\": \"Identifikation af {num} {scene}\",\n                \"include_male_performers\": \"Inkluder mandlige kunstnere\",\n                \"set_cover_images\": \"Indstil forsidebilleder\",\n                \"set_organized\": \"Sæt organiseret flag\",\n                \"source\": \"Kilde\",\n                \"source_options\": \"{kilde} Valgmuligheder\",\n                \"sources\": \"Kilder\",\n                \"strategy\": \"Strategi\",\n                \"skip_multiple_matches_tooltip\": \"Hvis dette ikke er aktiveret og flere resultater bliver fundet, vil én tilfældigt blive valgt\",\n                \"skip_single_name_performers_tooltip\": \"Hvis dette ikke er aktiveret, vil kunstnere, der ofte er generiske som Samantha eller Olga, blive matchet\",\n                \"skip_multiple_matches\": \"Spring over matches der har mere end ét resultat\",\n                \"tag_skipped_performers\": \"Mærk oversprungne kunstnere med\"\n            },\n            \"import_from_exported_json\": \"Importer fra eksporteret JSON i metadatabiblioteket. Sletter den eksisterende database.\",\n            \"incremental_import\": \"Inkrementel import fra en medfølgende eksport-zip-fil.\",\n            \"job_queue\": \"Opgavekø\",\n            \"maintenance\": \"Vedligeholdelse\",\n            \"migrate_hash_files\": \"Bruges efter ændring af den Genererede filnavngivnings-hash for at omdøbe eksisterende genererede filer til det nye hash-format.\",\n            \"migrations\": \"Migrationer\",\n            \"only_dry_run\": \"Udfør kun et tørløb. Fjern ikke noget\",\n            \"plugin_tasks\": \"Plugin-opgaver\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Scanner alle stier\",\n                \"scanning_paths\": \"Scanning af følgende stier\"\n            },\n            \"scan_for_content_desc\": \"Scan efter nyt indhold og føj det til databasen.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Indstil navn, dato, detaljer fra indlejrede filmetadata\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Slet gamle data\",\n                \"description\": \"Migrér blobs til det nuværende blob lagringssystem. Denne Migrering bør køres efter ændring af blob lagringssystemet. Kan valgfrit slette de gamle data efter migrering.\"\n            },\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Slet skærmprint filer\",\n                \"overwrite_existing\": \"Overskriv eksisterende blobs med skærmprints data\",\n                \"description\": \"Migrér scene skærmprints ind i det nye blob lagringssystem. Denne migrering bør køres efter migrering til et tidligere system til 0.20. Can valgfrit slette gamle skærmprints efter migrering.\"\n            },\n            \"anonymising_database\": \"Anonymiserer databasen\",\n            \"clean_generated\": {\n                \"description\": \"Fjerner generede filer uden en tilsvarende databasepost.\",\n                \"image_thumbnails\": \"Billedminiature\",\n                \"image_thumbnails_desc\": \"Billedminiaturer og klips\",\n                \"markers\": \"Markør Forhåndsvisninger\",\n                \"previews_desc\": \"Scene forhåndsvisninger og billedminiaturer\",\n                \"blob_files\": \"Blob filer\",\n                \"previews\": \"Scene Forhåndsvisninger\"\n            },\n            \"generate_clip_previews_during_scan\": \"Generer forhåndsvisninger for billedklips\",\n            \"optimise_database\": \"Forsøg forøgelse af ydelsen ved at analysere og derefter genopbygge hele database filen.\",\n            \"optimise_database_warning\": \"Advarsel: mens denne opgave kører, hvilken som helst opgave der ændrer databasen vil fejle, og afhængigt af din databasestørrelse, kan det tage flere minutter at gennemføre. Det kræver også som minimum lige så meget ledig diskplads som din database er stor, men 1.5X mere anbefales.\",\n            \"anonymise_and_download\": \"Laver en anonymiseret kopi af databasen og downloader den endelige fil.\",\n            \"generate_sprites_during_scan_tooltip\": \"Sættet af billeder bliver vist under afspilleren for nem navigation.\",\n            \"anonymise_database\": \"Laver en kopi af databasen til backupmappen, alle følsomme data anonymiseres. Dette kan herefter gives til andre til fejlsøgnings og debugging formål. Den originale database forbliver uændret. Den anonymiserede database bruger filnavnformatet {filename_format}.\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Scene duplikatkontrol\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Tilføj felt\",\n                \"capitalize_title\": \"Sæt overskrift med stort\",\n                \"display_fields\": \"Vis felter\",\n                \"escape_chars\": \"Brug \\\\ for at undslippe bogstavelige tegn\",\n                \"filename\": \"Filnavn\",\n                \"filename_pattern\": \"Filnavn mønster\",\n                \"ignore_organized\": \"Ignorer organiserede scener\",\n                \"ignored_words\": \"Ignorerede ord\",\n                \"matches_with\": \"Matcher med {i}\",\n                \"select_parser_recipe\": \"Vælg Parser-opskrift\",\n                \"title\": \"Scene Filnavn Parser\",\n                \"whitespace_chars\": \"Blanke tegn\",\n                \"whitespace_chars_desc\": \"Disse tegn vil blive erstattet med mellemrum i titlen\"\n            },\n            \"scene_tools\": \"Sceneværktøjer\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Forkort tællere i kort- og detaljebilleder, fx bliver \\\"1831\\\" forkortet til \\\"1.8K\\\".\",\n                \"heading\": \"Forkort tællere\"\n            },\n            \"basic_settings\": \"Grundlæggende indstillinger\",\n            \"custom_css\": {\n                \"description\": \"Siden skal genindlæses for at ændringer kan træde i kraft.\",\n                \"heading\": \"Brugerdefineret CSS\",\n                \"option_label\": \"Brugerdefineret CSS aktiveret\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Siden skal genindlæses før ændringerne træder i kraft.\",\n                \"heading\": \"Brugerdefineret Javascript\",\n                \"option_label\": \"Brugerdefineret Javascript aktiveret\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Ændre individuelle lokale strenge. Se https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for master-listen. Siden skal genindlæses for at aktivere ændringerne.\",\n                \"heading\": \"Egen oversættelse\",\n                \"option_label\": \"Egen oversættelse aktiveret\"\n            },\n            \"delete_options\": {\n                \"description\": \"Standardindstillinger ved sletning af billeder, gallerier og scener.\",\n                \"heading\": \"Slet indstillinger\",\n                \"options\": {\n                    \"delete_file\": \"Slet fil som standard\",\n                    \"delete_generated_supporting_files\": \"Slet genererede understøttende filer som standard\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Desktop-integration\",\n                \"notifications_enabled\": \"Aktiver notifikationer\",\n                \"send_desktop_notifications_for_events\": \"Send skrivebordsnotifikationer for begivenheder\",\n                \"skip_opening_browser\": \"Spring over åbning af browser\",\n                \"skip_opening_browser_on_startup\": \"Spring over automatisk åbning af browser under opstart\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Fjern muligheden for at oprette nye objekter fra rullemenuerne\",\n                    \"heading\": \"Deaktiver dropdown oprettelse\"\n                },\n                \"heading\": \"Redigering\",\n                \"rating_system\": {\n                    \"type\": {\n                        \"label\": \"Type af bedømmelsessystem\",\n                        \"options\": {\n                            \"decimal\": \"Decimal\",\n                            \"stars\": \"Stjerner\"\n                        }\n                    },\n                    \"star_precision\": {\n                        \"options\": {\n                            \"quarter\": \"Kvart\",\n                            \"tenth\": \"Tiendedel\",\n                            \"full\": \"Fuld\",\n                            \"half\": \"Halv\"\n                        },\n                        \"label\": \"Stjernebedømmelses præcision\"\n                    }\n                },\n                \"max_options_shown\": {\n                    \"label\": \"Maksimalt antal elementer, der skal vises i udvalgte rullemenuer\"\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Tidsforskydning i millisekunder for interaktiv afspilning af scripts.\",\n                \"heading\": \"Funscript-forskydning (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Forbinde\",\n                \"server_offset\": {\n                    \"heading\": \"Server offset\"\n                },\n                \"status\": {\n                    \"heading\": \"Handy forbindelsesstatus\"\n                },\n                \"sync\": \"Synkronisere\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Praktisk forbindelsesnøgle til at bruge til interaktive scener. Indstilling af denne tast giver Stash mulighed for at dele dine aktuelle sceneoplysninger med handyfeeling.com\",\n                \"heading\": \"Handy forbindelsesnøgle\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Billed lysboks\"\n            },\n            \"images\": {\n                \"heading\": \"Billeder\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Skriv miniaturebilleder til disk, når de genereres på farten\",\n                        \"heading\": \"Skriv miniaturebilleder\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"Når et bibliotek har Videoer deaktiveret, Video Filer (filer der slutter med Videoudvidelse) vil blive scannet som Billed Klip.\",\n                        \"heading\": \"Skan Videoudvidelser som Billedklip\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Interaktive muligheder\",\n            \"language\": {\n                \"heading\": \"Sprog\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Maksimal scenevarighed, hvor sceneafspilleren vil sløjfe videoen - 0 for at deaktivere\",\n                \"heading\": \"Maksimal sløjfevarighed\"\n            },\n            \"menu_items\": {\n                \"description\": \"Vis eller skjul forskellige typer indhold på navigationslinjen\",\n                \"heading\": \"Menupunkter\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Brugerdefineret sti til standard kunstnerbilleder. Lad være tom for at bruge indbyggede standardindstillinger\",\n                        \"heading\": \"Brugerdefineret kunstnerbilledsti\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Konfiguration til væggenstande\",\n                \"heading\": \"Eksempeltype\",\n                \"options\": {\n                    \"animated\": \"Animeret billede\",\n                    \"static\": \"Statisk billede\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Gittervisning\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Vis studieoverlejring som tekst\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Scene afspiller\",\n                \"options\": {\n                    \"auto_start_video\": \"Autostart video\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Start automatisk scenevideoer, når du afspiller valgte eller tilfældige fra siden Scener\",\n                        \"heading\": \"Autostart video ved afspilning af valgt\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Afspil næste scene i køen, når videoen er færdig\",\n                        \"heading\": \"Fortsæt afspilningsliste som standard\"\n                    },\n                    \"show_scrubber\": \"Vis Scrubber\",\n                    \"vr_tag\": {\n                        \"heading\": \"VR Mærke\",\n                        \"description\": \"VR knappen vil kun blive vist for scener med dette mærke.\"\n                    },\n                    \"track_activity\": \"Spor Aktivitet\",\n                    \"always_start_from_beginning\": \"Start altid video fra begyndelsen\",\n                    \"disable_mobile_media_auto_rotate\": \"Deaktiver automatisk rotation af fuldskærms indhold på mobile enheder\",\n                    \"enable_chromecast\": \"Aktiver Chromecast\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Scene / Markeringsvæg\",\n                \"options\": {\n                    \"display_title\": \"Vis titel og tags\",\n                    \"toggle_sound\": \"Aktiver lyd\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Antal gange at forsøge at rulle, før du går til næste/forrige element. Gælder kun for Pan Y-rulletilstand.\",\n                \"heading\": \"Rulningsforsøg før overgang\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Diasshow er tilgængeligt i gallerier i vægvisningstilstand\",\n                \"heading\": \"Forsinkelse af diasshow (sekunder)\"\n            },\n            \"title\": \"Brugergrænseflade\",\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"description\": \"Når aktiveret, vil denne mulighed præsentere udvidede detaljer og samtidig bevare en kompakt præsentation\",\n                    \"heading\": \"Kompakt udvidet detaljer\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Vis baggrundsbillede på detalje side.\",\n                    \"heading\": \"Aktiver baggrundsbillede\"\n                },\n                \"heading\": \"Detalje Side\",\n                \"show_all_details\": {\n                    \"description\": \"Når aktiveret, alle indholds detaljer vil blive vist som standard og hver detalje vil passe under en enkelt række\",\n                    \"heading\": \"Vis alle detaljer\"\n                }\n            },\n            \"image_wall\": {\n                \"margin\": \"Margen (pixels)\",\n                \"direction\": \"Retning\",\n                \"heading\": \"Billedvæg\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Procentdelen af tiden hvoraf en scene skal være afspillet før afspilningsantallet stiger.\",\n                \"heading\": \"Minimum Afspilningsprocent\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"heading\": \"Værktøjstip til mærkekort\"\n            },\n            \"tag_panel\": {\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"Vis også indhold fra undermærkerne i mærke-visningen\",\n                        \"heading\": \"Vis undermærke indhold\"\n                    }\n                },\n                \"heading\": \"Mærke-visning\"\n            },\n            \"use_stash_hosted_funscript\": {\n                \"heading\": \"Send funscripts direkte\",\n                \"description\": \"Når aktiveret, vil funscripts blive sendt direkte fra Stash til din Handy-enhed uden brug af tredjeparts Handy-serveren. Kræver, at Stash er tilgængelig fra din Handy-enhed.\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Studie visning\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"Vis også indhold fra understudierne i studievisningen\",\n                        \"heading\": \"Vis indhold af understudier\"\n                    }\n                }\n            }\n        },\n        \"advanced_mode\": \"Advanceret Tilstand\"\n    },\n    \"configuration\": \"Konfiguration\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Fil} other {Filer}}\",\n        \"galleries\": \"{count, plural, one {Galleri} other {Gallerier}}\",\n        \"images\": \"{count, plural, one {Billede} other {Billeder}}\",\n        \"markers\": \"{count, plural, one {Markør} other {Markører}}\",\n        \"performers\": \"{count, plural, one {Kunstner} other {Kunstnere}}\",\n        \"scenes\": \"{count, plural, one {Scene} other {Scener}}\",\n        \"studios\": \"{count, plural, one {Studie} other {Studier}}\",\n        \"tags\": \"{count, plural, one {Tag} other {Tags}}\"\n    },\n    \"country\": \"Land\",\n    \"cover_image\": \"Forsidebillede\",\n    \"created_at\": \"Oprettet den\",\n    \"criterion\": {\n        \"greater_than\": \"Større end\",\n        \"less_than\": \"Mindre end\",\n        \"value\": \"Værdi\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"imellem\",\n        \"equals\": \"er\",\n        \"excludes\": \"udelukker\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"er større end\",\n        \"includes\": \"inkluderer\",\n        \"includes_all\": \"inkluderer alt\",\n        \"is_null\": \"er null\",\n        \"less_than\": \"er mindre end\",\n        \"matches_regex\": \"matcher regex\",\n        \"not_between\": \"ikke imellem\",\n        \"not_equals\": \"er ikke\",\n        \"not_matches_regex\": \"matcher ikke regex\",\n        \"not_null\": \"er ikke null\"\n    },\n    \"custom\": \"Brugerdefinerede\",\n    \"date\": \"Dato\",\n    \"death_date\": \"Dødsdato\",\n    \"death_year\": \"Dødsår\",\n    \"descending\": \"Aftagende\",\n    \"detail\": \"Detalje\",\n    \"details\": \"Detaljer\",\n    \"developmentVersion\": \"Udviklingsversion\",\n    \"dialogs\": {\n        \"delete_alert\": \"Følgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} vil blive slettet permanent:\",\n        \"delete_confirm\": \"Er du sikker på, at du vil slette {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Er du sikker på, at du vil slette denne {singularEntity}? Medmindre filen også slettes, vil denne {singularEntity} blive tilføjet igen, når scanningen udføres.} andet {Er du sikker på, at du vil slette disse {pluralEntity}? Medmindre filerne også slettes, vil disse {pluralEntity} blive tilføjet igen, når scanningen udføres.}}\",\n        \"delete_entity_title\": \"{count, plural, one {Slet {singularEntity}} anden {Slet {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"…plus eventuelle billedfiler, der ikke er knyttet til noget andet galleri.\",\n        \"delete_gallery_files\": \"Slet gallerimappe/zip-fil og alle billeder, der ikke er knyttet til noget andet galleri.\",\n        \"delete_object_desc\": \"Er du sikker på, at du vil slette {count, plural, one {this {singularEntity}} other {these {pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…og {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Slet {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"edit_entity_title\": \"Rediger {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Inkluder relaterede objekter i eksport\",\n        \"export_title\": \"Eksport\",\n        \"lightbox\": {\n            \"delay\": \"Forsinkelse (sek.)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Tilpas vandret\",\n                \"fit_to_screen\": \"Tilpas til skærmen\",\n                \"label\": \"Visningstilstand\",\n                \"original\": \"Original\"\n            },\n            \"options\": \"Muligheder\",\n            \"reset_zoom_on_nav\": \"Nulstil zoomniveau, når du skifter billede\",\n            \"scale_up\": {\n                \"description\": \"Skaler mindre billeder op for at fylde skærmen\",\n                \"label\": \"Skaler op til at passe\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Hold shift nede for midlertidigt at bruge en anden tilstand.\",\n                \"label\": \"Rulletilstand\",\n                \"pan_y\": \"Panorer Y\",\n                \"zoom\": \"Zoom\"\n            },\n            \"page_header\": \"Side {page} / {total}\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Tving omkodningsgenerering\",\n            \"force_transcodes_tooltip\": \"Som standard genereres omkoder kun, når videofilen ikke understøttes i browseren. Når det er aktiveret, genereres omkoder, selv når videofilen ser ud til at være understøttet i browseren.\",\n            \"image_previews\": \"Animerede billedforhåndsvisninger\",\n            \"image_previews_tooltip\": \"Animerede WebP-forhåndsvisninger, kun påkrævet, hvis Eksempeltype er indstillet til Animeret billede.\",\n            \"interactive_heatmap_speed\": \"Generer heatmaps og hastigheder til interaktive scener\",\n            \"marker_image_previews\": \"Markør Animerede billedforhåndsvisninger\",\n            \"marker_image_previews_tooltip\": \"Generer også animerede (webp) forhåndsvisninger, kun påkrævet, når Scene/Mærkevæg Forhåndsvisnings Type er indstillet til Animerede Billeder. Når der kigges, bruger de mindre CPU end videoeksemplerne, men genereres ud over dem og er større filer.\",\n            \"marker_screenshots\": \"Skærmbilleder af markør\",\n            \"marker_screenshots_tooltip\": \"Marker statiske JPG-billeder, kun påkrævet, hvis Preview Type er indstillet til Static Image.\",\n            \"markers\": \"Markør forhåndsvisninger\",\n            \"markers_tooltip\": \"20 sekunders videoer, som begynder på den givne tidskode.\",\n            \"override_preview_generation_options\": \"Tilsidesæt indstillinger for generering af forhåndsvisning\",\n            \"override_preview_generation_options_desc\": \"Tilsidesæt forhåndsvisningsgenereringsindstillinger for denne handling. Standarder er angivet i System -> Forhåndsvisningsgenerering.\",\n            \"overwrite\": \"Overskriv eksisterende genererede filer\",\n            \"phash\": \"Perceptuelle hashes\",\n            \"preview_exclude_end_time_desc\": \"Udelad de sidste x sekunder fra sceneforhåndsvisninger. Dette kan være en værdi i sekunder eller en procentdel (f.eks. 2 %) af den samlede scenevarighed.\",\n            \"preview_exclude_end_time_head\": \"Udelad sluttidspunkt\",\n            \"preview_exclude_start_time_desc\": \"Ekskluder de første x sekunder fra sceneforhåndsvisninger. Dette kan være en værdi i sekunder eller en procentdel (f.eks. 2 %) af den samlede scenevarighed.\",\n            \"preview_exclude_start_time_head\": \"Udelad starttidspunkt\",\n            \"preview_generation_options\": \"Forhåndsvisningsgenereringsindstillinger\",\n            \"preview_options\": \"Indstillinger for forhåndsvisning\",\n            \"preview_preset_desc\": \"Forudindstillingen regulerer størrelse, kvalitet og kodningstid for preview-generering. Forudindstillinger ud over \\\"langsom\\\" har aftagende afkast og anbefales ikke.\",\n            \"preview_preset_head\": \"Vis forhåndsindstilling af kodning\",\n            \"preview_seg_count_desc\": \"Antal segmenter i forhåndsvisningsfiler.\",\n            \"preview_seg_count_head\": \"Antal segmenter i forhåndsvisning\",\n            \"preview_seg_duration_desc\": \"Varigheden af hvert eksempelsegment i sekunder.\",\n            \"preview_seg_duration_head\": \"Forhåndsvisning af segmentvarighed\",\n            \"sprites\": \"Sceneskrubber sprites\",\n            \"sprites_tooltip\": \"Sættet af billeder, der vises under videoafspilleren, for nem navigation.\",\n            \"transcodes\": \"Omkoder\",\n            \"transcodes_tooltip\": \"MP4-konverteringer vil blive prægenereret for alt indhold; nyttig til langsomme CPU'er, men kræver meget mere diskplads\",\n            \"video_previews\": \"Forhåndsvisninger\",\n            \"video_previews_tooltip\": \"Videoforhåndsvisninger, der afspilles, når du holder musemarkøren over en scene\",\n            \"clip_previews\": \"Forhåndsvisninger af billedklip\",\n            \"image_thumbnails\": \"Billedminiaturer\",\n            \"phash_tooltip\": \"Til deduplikering og sceneidentifikation\"\n        },\n        \"scenes_found\": \"{count} scener fundet\",\n        \"scrape_entity_query\": \"{entity_type} Scrape-forespørgsel\",\n        \"scrape_entity_title\": \"{entity_type} skrabe resultater\",\n        \"scrape_results_existing\": \"Eksisterende\",\n        \"scrape_results_scraped\": \"Skrabet\",\n        \"set_image_url_title\": \"Billed-URL\",\n        \"unsaved_changes\": \"Ugemte ændringer. Er du sikker på, at du vil tage forlade?\",\n        \"imagewall\": {\n            \"direction\": {\n                \"description\": \"Kolonne- eller rækkebaseret layout.\",\n                \"row\": \"Række\",\n                \"column\": \"Kolonne\"\n            },\n            \"margin_desc\": \"Antal margenpixels omkring hvert hele billede.\"\n        },\n        \"delete_entity_simple_desc\": \"{count, plural, one {Er du sikker på du vil slette dette {singularEntity}?} andre {Er du sikker på du vil slette disse {pluralEntity}?}}\",\n        \"dont_show_until_updated\": \"Vis ikke før næste opdatering\",\n        \"performers_found\": \"{count} kunstnere fundet\",\n        \"clear_o_history_confirm\": \"Er du sikker på du vil nulstille O historikken ?\",\n        \"clear_play_history_confirm\": \"Er du sikker på du vil nulstille afspilnings historikken ?\",\n        \"create_new_entity\": \"Lav ny {entity}\",\n        \"merge\": {\n            \"source\": \"Kilde\"\n        },\n        \"reassign_files\": {\n            \"destination\": \"Gentildel til\"\n        },\n        \"delete_alert_to_trash\": \"Følgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} vil blive flyttet til papirkurv:\"\n    },\n    \"dimensions\": \"Dimensioner\",\n    \"director\": \"Direktør\",\n    \"display_mode\": {\n        \"grid\": \"Gitter\",\n        \"list\": \"Liste\",\n        \"tagger\": \"Tagger\",\n        \"unknown\": \"Ukendt\",\n        \"wall\": \"Væg\"\n    },\n    \"donate\": \"Doner\",\n    \"dupe_check\": {\n        \"description\": \"Niveauer under 'Eksakt' kan tage længere tid at beregne. Falske positiver kan også returneres ved lavere nøjagtighedsniveauer.\",\n        \"found_sets\": \"{setCount, plural, one{# sæt dubletter fundet.} other {# sæt dubletter fundet.}}\",\n        \"options\": {\n            \"exact\": \"Præcis\",\n            \"high\": \"Høj\",\n            \"low\": \"Lav\",\n            \"medium\": \"Medium\"\n        },\n        \"search_accuracy_label\": \"Søgenøjagtighed\",\n        \"title\": \"Duplikatscener\",\n        \"duration_diff\": \"Maksimal varighedsforskel\",\n        \"duration_options\": {\n            \"any\": \"Enhver\",\n            \"equal\": \"Lige\"\n        },\n        \"only_select_matching_codecs\": \"Vælg kun, hvis alle codecs matcher i dubletgruppen\",\n        \"select_all_but_largest_file\": \"Vælg hver fil i hver duplikeret gruppe, undtagen den største fil\",\n        \"select_all_but_largest_resolution\": \"Vælg hver fil i hver duplikeret gruppe, undtagen filen med højeste opløsning\",\n        \"select_oldest\": \"Vælg den ældste fil i dubletgruppen\",\n        \"select_options\": \"Valgmuligheder…\",\n        \"select_youngest\": \"Vælg den yngste fil i dubletgruppen\",\n        \"select_none\": \"Vælg Ingen\"\n    },\n    \"duplicated_phash\": \"Duplikeret (phash)\",\n    \"duration\": \"Varighed\",\n    \"effect_filters\": {\n        \"aspect\": \"Aspekt\",\n        \"blue\": \"Blå\",\n        \"blur\": \"Slør\",\n        \"brightness\": \"Lysstyrke\",\n        \"contrast\": \"Kontrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Grøn\",\n        \"hue\": \"Nuance\",\n        \"name\": \"Filtre\",\n        \"name_transforms\": \"Forvandler\",\n        \"red\": \"Rød\",\n        \"reset_filters\": \"Nulstil filtre\",\n        \"reset_transforms\": \"Nulstil transformationer\",\n        \"rotate\": \"Rotere\",\n        \"rotate_left_and_scale\": \"Roter til venstre & skaler\",\n        \"rotate_right_and_scale\": \"Roter til højre & skaler\",\n        \"saturation\": \"Mætning\",\n        \"scale\": \"Skala\",\n        \"warmth\": \"Varme\"\n    },\n    \"empty_server\": \"Tilføj nogle scener til din server for at se anbefalinger på denne side.\",\n    \"ethnicity\": \"Etnicitet\",\n    \"existing_value\": \"eksisterende værdi\",\n    \"eye_color\": \"Øjenfarve\",\n    \"fake_tits\": \"Falske Bryster\",\n    \"false\": \"Falsk\",\n    \"favourite\": \"Favorit\",\n    \"file\": \"fil\",\n    \"file_info\": \"Fil Info\",\n    \"file_mod_time\": \"Filændringstid\",\n    \"files\": \"filer\",\n    \"filesize\": \"Filstørrelse\",\n    \"filter\": \"Filter\",\n    \"filter_name\": \"Filternavn\",\n    \"filters\": \"Filtre\",\n    \"framerate\": \"Billedhastighed\",\n    \"frames_per_second\": \"{value} fps\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Forhåndslavet Filter\",\n            \"saved_filter\": \"Gemt Filter\"\n        }\n    },\n    \"galleries\": \"Galleri\",\n    \"gallery\": \"Galleri\",\n    \"gallery_count\": \"Galleri Antal\",\n    \"gender\": \"Køn\",\n    \"gender_types\": {\n        \"FEMALE\": \"Kvinde\",\n        \"INTERSEX\": \"Interkønnet\",\n        \"MALE\": \"Mand\",\n        \"NON_BINARY\": \"Ikke-binær\",\n        \"TRANSGENDER_FEMALE\": \"Transkønnet kvinde\",\n        \"TRANSGENDER_MALE\": \"Transkønnet mand\"\n    },\n    \"hair_color\": \"Hårfarve\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Opretter forbindelse\",\n        \"disconnected\": \"Afbrudt\",\n        \"error\": \"Fejl ved forbindelse til Handy\",\n        \"missing\": \"Mangler\",\n        \"ready\": \"Klar\",\n        \"syncing\": \"Synkroniser med server\",\n        \"uploading\": \"Uploader script\"\n    },\n    \"hasMarkers\": \"Har Markører\",\n    \"height\": \"Højde\",\n    \"help\": \"Hjælp\",\n    \"ignore_auto_tag\": \"Ignorer Auto Tag\",\n    \"image\": \"Billede\",\n    \"image_count\": \"Billedantal\",\n    \"images\": \"Billeder\",\n    \"include_parent_tags\": \"Inkluder overordnede tags\",\n    \"include_sub_studios\": \"Inkluder underliggende studier\",\n    \"include_sub_tags\": \"Inkluder undertags\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktiv\",\n    \"interactive_speed\": \"Interaktiv hastighed\",\n    \"isMissing\": \"Mangler\",\n    \"library\": \"Bibliotek\",\n    \"loading\": {\n        \"generic\": \"Indlæser…\"\n    },\n    \"marker_count\": \"Markørtælling\",\n    \"markers\": \"Markører\",\n    \"measurements\": \"Målinger\",\n    \"media_info\": {\n        \"audio_codec\": \"Audio Codec\",\n        \"downloaded_from\": \"Downloadet fra\",\n        \"interactive_speed\": \"Interaktiv hastighed\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} i denne scene\"\n        },\n        \"phash\": \"PHash\",\n        \"stream\": \"Stream\",\n        \"video_codec\": \"Video Codec\",\n        \"play_duration\": \"Spillevarighed\",\n        \"o_count\": \"O tælning\",\n        \"play_count\": \"Afspilninger\"\n    },\n    \"megabits_per_second\": \"{value} mbps\",\n    \"metadata\": \"Metadata\",\n    \"name\": \"Navn\",\n    \"new\": \"Ny\",\n    \"none\": \"Ingen\",\n    \"operations\": \"Operationer\",\n    \"organized\": \"Organiseret\",\n    \"pagination\": {\n        \"first\": \"Først\",\n        \"last\": \"Sidst\",\n        \"next\": \"Næste\",\n        \"previous\": \"Tidligere\"\n    },\n    \"parent_of\": \"Forælder til {children}\",\n    \"parent_studios\": \"Forældrestudier\",\n    \"parent_tag_count\": \"Overordnet Tags Antal\",\n    \"parent_tags\": \"Overordnet Tags\",\n    \"part_of\": \"En del af {parent}\",\n    \"path\": \"Sti\",\n    \"perceptual_similarity\": \"Perceptuel lighed (phash)\",\n    \"performer\": \"Kunstner\",\n    \"performer_age\": \"kunstnere Alder\",\n    \"performer_count\": \"Kunstner Antal\",\n    \"performer_favorite\": \"Foretrukken optrædende\",\n    \"performer_image\": \"kunstnere Billede\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Tilføj Nye Kunstnere\",\n        \"any_names_entered_will_be_queried\": \"Alle indtastede navne vil blive forespurgt fra den eksterne Stash-Box-instans og tilføjet, hvis de findes. Kun eksakte matches vil blive betragtet som et match.\",\n        \"batch_add_performers\": \"Batch Tilføj Kunstnere\",\n        \"batch_update_performers\": \"Batch Opdater Kunstnere\",\n        \"current_page\": \"Nuværende side\",\n        \"failed_to_save_performer\": \"Kunne ikke gemme kunstneren \\\"{performer}\\\"\",\n        \"name_already_exists\": \"Navnet findes allerede\",\n        \"network_error\": \"Netværksfejl\",\n        \"no_results_found\": \"Ingen resultater fundet.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} kunstnere vil blive behandlet\",\n        \"performer_already_tagged\": \"Kunstner allerede tagget\",\n        \"performer_selection\": \"Valg af kunstner\",\n        \"performer_successfully_tagged\": \"Kunstner tagget med succes:\",\n        \"query_all_performers_in_the_database\": \"Alle kunstnere i databasen\",\n        \"refresh_tagged_performers\": \"Opdater taggede kunstnere\",\n        \"refreshing_will_update_the_data\": \"Opdatering vil opdatere dataene for alle taggede kunstnere fra stash-box-forekomsten.\",\n        \"status_tagging_job_queued\": \"Status: Tagging job i kø\",\n        \"status_tagging_performers\": \"Status: Tagger kunstnere\",\n        \"tag_status\": \"Tag Status\",\n        \"to_use_the_performer_tagger\": \"For at bruge kunstnere-taggeren skal en stash-box-instans konfigureres.\",\n        \"untagged_performers\": \"Utaggede kunstnere\",\n        \"update_performer\": \"Opdater Kunstner\",\n        \"update_performers\": \"Opdater Kunstnere\",\n        \"updating_untagged_performers_description\": \"Opdatering af ikke-taggede kunstnere vil forsøge at matche alle kunstnere, der mangler en stashid, og opdatere metadataene.\"\n    },\n    \"performer_tags\": \"Kunstner Tags\",\n    \"performers\": \"Kunstnere\",\n    \"piercings\": \"Piercinger\",\n    \"queue\": \"Kø\",\n    \"random\": \"Tilfældig\",\n    \"rating\": \"Bedømmelse\",\n    \"recently_added_objects\": \"Senest Tilføjet {objects}\",\n    \"recently_released_objects\": \"Senest Udgivet {objects}\",\n    \"resolution\": \"Opløsning\",\n    \"scene\": \"Scene\",\n    \"sceneTagger\": \"Scenetagger\",\n    \"scene_count\": \"Scene antal\",\n    \"scene_id\": \"Scene-id\",\n    \"scene_tags\": \"Scene-etiketter\",\n    \"scenes\": \"Scener\",\n    \"scenes_updated_at\": \"Scene opdateret den\",\n    \"search_filter\": {\n        \"name\": \"Filter\",\n        \"saved_filters\": \"Gemte filtre\",\n        \"update_filter\": \"Opdater filter\",\n        \"edit_filter\": \"Rediger Filter\"\n    },\n    \"seconds\": \"Sekunder\",\n    \"settings\": \"Indstillinger\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Vi er næsten klar til at fuldføre konfigurationen. Bekræft venligst følgende indstillinger. Du kan klikke tilbage for at ændre noget forkert. Hvis alt ser godt ud, skal du klikke på Bekræft for at oprette dit system.\",\n            \"configuration_file_location\": \"Placering af konfigurationsfil:\",\n            \"database_file_path\": \"Sti til databasefil\",\n            \"generated_directory\": \"Genereret mappe\",\n            \"nearly_there\": \"Næsten færdig!\",\n            \"stash_library_directories\": \"Stash biblioteksmapper\",\n            \"blobs_use_database\": \"<bruger database>\",\n            \"blobs_directory\": \"Binær datamappe\",\n            \"cache_directory\": \"Cache bibliotek\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Oprettelse af dit system\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Åh nej! Noget gik galt!\",\n            \"something_went_wrong_description\": \"Hvis dette ligner et problem med dine input, skal du gå videre og klikke tilbage for at rette dem. Ellers, rejs en fejl på {githubLink} eller søg hjælp i {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Noget gik galt under opsætningen af dit system. Her er fejlen, vi modtog: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Filsti\",\n            \"up_dir\": \"Op en mappe\"\n        },\n        \"github_repository\": \"Github-depot\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Backup databasesti (lad tom for at deaktivere backup):\",\n            \"backup_recommended\": \"Det anbefales, at du sikkerhedskopierer din eksisterende database, før du migrerer. Vi kan gøre dette for dig ved at lave en kopi af din database til <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Migrering af database\",\n            \"migration_failed\": \"Migration mislykkedes\",\n            \"migration_failed_error\": \"Følgende fejl opstod under migrering af databasen:\",\n            \"migration_failed_help\": \"Foretag de nødvendige rettelser og prøv igen. Ellers, rejs en fejl på {githubLink} eller søg hjælp i {discordLink}.\",\n            \"migration_irreversible_warning\": \"Skemamigreringsprocessen er ikke reversibel. Når migreringen er udført, vil din database være inkompatibel med tidligere versioner af stash.\",\n            \"migration_required\": \"Migration påkrævet\",\n            \"perform_schema_migration\": \"Udfør skemamigrering\",\n            \"schema_too_old\": \"Din nuværende stash-database er skemaversion <strong>{databaseSchema}</strong> og skal migreres til version <strong>{appSchema}</strong>. Denne version af Stash fungerer ikke uden migrering af databasen.\",\n            \"migration_notes\": \"Migrationsnotater\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"database filnavn (tomt som standard)\",\n            \"description\": \"Dernæst skal vi bestemme, hvor vi kan finde din pornosamling, hvor vi skal gemme stash-databasen og de genererede filer. Disse indstillinger kan ændres senere, hvis det er nødvendigt.\",\n            \"path_to_generated_directory_empty_for_default\": \"sti til genereret mappe (tom som standard)\",\n            \"set_up_your_paths\": \"Sæt dine stier op\",\n            \"stash_alert\": \"Der er ikke valgt nogen biblioteksstier. Ingen medier vil være i stand til at blive scannet ind i Stash. Er du sikker?\",\n            \"where_can_stash_store_its_database\": \"Hvor kan Stash gemme sin database?\",\n            \"where_can_stash_store_its_database_description\": \"Stash bruger en sqlite-database til at gemme dine porno-metadata. Som standard vil dette blive oprettet som <code>stash-go.sqlite</code> i den mappe, der indeholder din konfigurationsfil. Hvis du vil ændre dette, skal du indtaste et absolut eller relativt (til den aktuelle arbejdsmappe) filnavn.\",\n            \"where_can_stash_store_its_generated_content\": \"Hvor kan Stash gemme dets genererede indhold?\",\n            \"where_can_stash_store_its_generated_content_description\": \"For at give thumbnails, forhåndsvisninger og sprites genererer Stash billeder og videoer. Dette inkluderer også omkoder for ikke-understøttede filformater. Som standard vil Stash oprette en <code>genereret</code> mappe i den mappe, der indeholder din konfigurationsfil. Hvis du vil ændre, hvor dette genererede medie vil blive gemt, skal du indtaste en absolut eller relativ (til den aktuelle arbejdsmappe) sti. Stash vil oprette denne mappe, hvis den ikke allerede eksisterer.\",\n            \"where_is_your_porn_located\": \"Hvor er din porno placeret?\",\n            \"where_is_your_porn_located_description\": \"Tilføj mapper, der indeholder dine pornovideoer og billeder. Stash vil bruge disse mapper til at finde videoer og billeder under scanning.\",\n            \"where_can_stash_store_cache_files\": \"Hvor kan Stash gemme cache-filer?\",\n            \"store_blobs_in_database\": \"Gem blobs i databasen\",\n            \"where_can_stash_store_cache_files_description\": \"For nogle funktionaliteter som HLS/DASH live-omkodning kan fungere, kræver Stash en cache-mappe til midlertidige filer. Som standard vil Stash oprette en <code>cache</code>-mappe i den mappe, der indeholder din konfigurationsfil. Hvis du vil ændre dette, skal du indtaste en absolut eller relativ (til den aktuelle mappe) sti. Stash vil oprette denne mappe, hvis den ikke allerede eksisterer.\",\n            \"path_to_blobs_directory_empty_for_default\": \"sti til blobs-bibliotek (tom for standard)\",\n            \"where_can_stash_store_blobs\": \"Hvor kan Stash gemme binære database data?\",\n            \"where_can_stash_store_blobs_description\": \"Stash kan gemme binære data såsom sceneomslag, kunstnere, studie og mærkebilleder enten i databasen eller i filsystemet. Som standard gemmer den disse data i filsystemet i undermappen <code>blobs</code> i den mappe, der indeholder din konfigurationsfil. Hvis du vil ændre dette, skal du indtaste en absolut eller relativ (til den aktuelle mappe) sti. Stash vil oprette denne mappe, hvis den ikke allerede eksisterer.\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Alternativt kan du gemme disse data i databasen. <strong>Bemærk:</strong> Dette vil øge størrelsen på din databasefil og øge databasens migreringstider.\",\n            \"where_can_stash_store_its_database_warning\": \"ADVARSEL: lagring af databasen på et andet system end det sted, hvor Stash køres fra (f.eks. lagring af databasen på en NAS, mens du kører Stash-serveren på en anden computer) er <strong>ikke-understøttet</strong>! SQLite er ikke beregnet til brug på tværs af et netværk, og forsøg på at gøre dette kan meget nemt få hele din database til at blive ødelagt.\",\n            \"path_to_cache_directory_empty_for_default\": \"sti til cache-bibliotek (tom for standard)\"\n        },\n        \"stash_setup_wizard\": \"Stash Opsætningsguide\",\n        \"success\": {\n            \"getting_help\": \"Få hjælp\",\n            \"help_links\": \"Hvis du støder på problemer eller har spørgsmål eller forslag, er du velkommen til at åbne et problem i {githubLink} eller spørg fællesskabet i {discordLink}.\",\n            \"in_app_manual_explained\": \"Du opfordres til at tjekke in-app manualen, som kan tilgås fra ikonet i øverste højre hjørne af skærmen, der ser sådan ud: {icon}\",\n            \"next_config_step_one\": \"Du vil blive ført til konfigurationssiden næste gang. Denne side giver dig mulighed for at tilpasse, hvilke filer der skal inkluderes og ekskluderes, indstille et brugernavn og en adgangskode for at beskytte dit system og en hel masse andre muligheder.\",\n            \"next_config_step_two\": \"Når du er tilfreds med disse indstillinger, kan du begynde at scanne dit indhold ind i Stash ved at klikke på <code>{localized_task}</code> og derefter <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Tjek vores {open_collective_link} for at se, hvordan du kan bidrage til den fortsatte udvikling af Stash.\",\n            \"support_us\": \"Støt os\",\n            \"thanks_for_trying_stash\": \"Tak, fordi du prøvede Stash!\",\n            \"welcome_contrib\": \"Vi glæder os også over bidrag i form af kode (fejlrettelser, forbedringer og nye funktioner), test, fejlrapporter, forbedringer og funktionsanmodninger og brugersupport. Detaljer kan findes i bidragssektionen i in-app manualen.\",\n            \"your_system_has_been_created\": \"Succes! Dit system er blevet oprettet!\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash prøver først at finde sin konfigurationsfil (<code>config.yml</code>) fra den aktuelle arbejdsmappe, og hvis den ikke finder den der, falder den tilbage til <code>$HOME/.stash/config. yml</code> (på Windows vil dette være <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>). Du kan også få Stash til at læse fra en specifik konfigurationsfil ved at køre den med <code>-c '<sti til konfigurationsfil>'</code> eller <code>--config '<sti til konfigurationsfil>'</code> muligheder.\",\n            \"in_current_stash_directory\": \"I mappen <code>$HOME/.stash</code>\",\n            \"in_the_current_working_directory\": \"I den aktuelle arbejdsmappe\",\n            \"next_step\": \"Med alt det ude af vejen, hvis du er klar til at fortsætte med at konfigurere et nyt system, skal du vælge, hvor du vil gemme din konfigurationsfil og klikke på Næste.\",\n            \"store_stash_config\": \"Hvor vil du gemme din Stash-konfiguration?\",\n            \"unable_to_locate_config\": \"Hvis du læser dette, så kunne Stash ikke finde en eksisterende konfiguration. Denne guide vil guide dig gennem processen med at konfigurere en ny konfiguration.\",\n            \"unexpected_explained\": \"Hvis du får denne skærm uventet, så prøv at genstarte Stash i den korrekte arbejdsmappe eller med <code>-c</code> flaget.\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash vil bruge følgende konfigurationsfilsti: <code>{path}</code>\",\n            \"next_step\": \"Når du er klar til at fortsætte med at konfigurere et nyt system, skal du klikke på Næste.\",\n            \"unable_to_locate_specified_config\": \"Hvis du læser dette, så kunne Stash ikke finde konfigurationsfilen angivet på kommandolinjen eller miljøet. Denne guide vil guide dig gennem processen med at konfigurere en ny konfiguration.\"\n        },\n        \"welcome_to_stash\": \"Velkommen til Stash\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_ids\": \"Stash ID'er\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Gå til {endpoint_name} for at gennemgå kladde.\",\n        \"selected_stash_box\": \"Valgt Stash-Box endpoint\",\n        \"submission_failed\": \"Indsendelse mislykkedes\",\n        \"submission_successful\": \"Indsendelse lykkedes\",\n        \"submit_update\": \"Eksisterer allerede i {endpoint_name}\"\n    },\n    \"statistics\": \"Statistikker\",\n    \"stats\": {\n        \"image_size\": \"Billedstørrelse\",\n        \"scenes_duration\": \"Videoer varighed\",\n        \"scenes_size\": \"Videoer størrelse\"\n    },\n    \"status\": \"Status: {statusText}\",\n    \"studio\": \"Studie\",\n    \"studio_depth\": \"Niveauer (tom for alle)\",\n    \"studios\": \"Studier\",\n    \"sub_tag_count\": \"Antal under-tags\",\n    \"sub_tag_of\": \"Under-tag til {parent}\",\n    \"sub_tags\": \"Under-tags\",\n    \"subsidiary_studios\": \"Underliggende Studier\",\n    \"synopsis\": \"Synopse\",\n    \"tag\": \"Tag\",\n    \"tag_count\": \"Tag Antal\",\n    \"tags\": \"Tags\",\n    \"tattoos\": \"Tatoveringer\",\n    \"title\": \"Titel\",\n    \"toast\": {\n        \"added_entity\": \"Tilføjet {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Tilføjet generationsjob til køen\",\n        \"created_entity\": \"Oprettet {entity}\",\n        \"default_filter_set\": \"Standard filtersæt\",\n        \"delete_past_tense\": \"Slettede {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Genererer skærmbillede…\",\n        \"merged_tags\": \"Sammenlagte tags\",\n        \"removed_entity\": \"Fjerned {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"rescanning_entity\": \"Genscanner {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"Gemt {entity}\",\n        \"started_auto_tagging\": \"Startede automatisk tagging\",\n        \"started_generating\": \"Begyndte at generere\",\n        \"started_importing\": \"Begyndte at importere\",\n        \"updated_entity\": \"Opdateret {entity}\"\n    },\n    \"total\": \"Total\",\n    \"true\": \"Sandt\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Type\",\n    \"updated_at\": \"Opdateret Den\",\n    \"url\": \"URL\",\n    \"videos\": \"Videoer\",\n    \"view_all\": \"Se alle\",\n    \"weight\": \"Vægt\",\n    \"years_old\": \"år gammel\",\n    \"circumcised\": \"Omskåret\",\n    \"circumcised_types\": {\n        \"CUT\": \"Omskåret\",\n        \"UNCUT\": \"Ej Omskåret\"\n    },\n    \"disambiguation\": \"Fjernelse af flertydighed\",\n    \"distance\": \"Afstand\",\n    \"audio_codec\": \"Lyd Codec\",\n    \"description\": \"Beskrivelse\",\n    \"errors\": {\n        \"lazy_component_error_help\": \"Hvis du for nylig har opgraderet Stash, skal du genindlæse siden eller rydde din browsers cache.\",\n        \"header\": \"Fejl\",\n        \"image_index_greater_than_zero\": \"Billedindeks skal være større end 0\",\n        \"loading_type\": \"Fejl ved indlæsning af {type}\",\n        \"something_went_wrong\": \"Noget gik galt.\"\n    },\n    \"file_count\": \"Filantal\",\n    \"files_amount\": \"{value} filer\",\n    \"last_played_at\": \"Sidst afspillet\",\n    \"scene_updated_at\": \"Scene opdateret kl\",\n    \"second\": \"Anden\",\n    \"folder\": \"Mappe\",\n    \"image_index\": \"Billede #\",\n    \"package_manager\": {\n        \"selected_only\": \"Kun valgte\",\n        \"show_all\": \"Vis alle\",\n        \"source\": {\n            \"local_path\": {\n                \"heading\": \"Lokal Sti\",\n                \"description\": \"Relativ sti til at gemme pakker for denne kilde. Bemærk, at ændring af dette kræver, at pakkerne flyttes manuelt.\"\n            },\n            \"name\": \"Navn\",\n            \"url\": \"Kilde URL\"\n        },\n        \"required_by\": \"Påkrævet af {packages}\",\n        \"uninstall\": \"Afinstallér\",\n        \"confirm_delete_source\": \"Er du sikker på du vil slette kilden {name} ({url})?\",\n        \"confirm_uninstall\": \"Er du sikker på du vil afinstallere {number} pakker?\",\n        \"installed_version\": \"Installeret Version\",\n        \"latest_version\": \"Nyeste version\",\n        \"add_source\": \"Tilføj Kilde\",\n        \"check_for_updates\": \"Tjek for Opdateringer\",\n        \"no_packages\": \"Ingen pakker fundet\",\n        \"no_sources\": \"Ingen kilder konfiguret\",\n        \"no_upgradable\": \"Ingen opgraderbare pakker fundet\",\n        \"package\": \"Pakke\",\n        \"unknown\": \"<ukendt>\",\n        \"update\": \"Opdater\",\n        \"version\": \"Version\",\n        \"edit_source\": \"Rediger Kilde\",\n        \"hide_unselected\": \"Skjul umarkeret\",\n        \"description\": \"Beskrivelse\",\n        \"install\": \"Installer\"\n    },\n    \"parent_studio\": \"Forældrestudie\",\n    \"penis\": \"Penis\",\n    \"release_notes\": \"Udgivelses noter\",\n    \"resume_time\": \"Genoptag tid\",\n    \"last_o_at\": \"Sidste O Var\",\n    \"o_history\": \"O Historik\",\n    \"odate_recorded_no\": \"Ingen O Dato Optaget\",\n    \"orientation\": \"Orientering\",\n    \"penis_length_cm\": \"Penis Længde (cm)\",\n    \"penis_length\": \"Penis længde\",\n    \"photographer\": \"Fotograf\",\n    \"play_count\": \"Afspilninger\",\n    \"play_duration\": \"Afspilningsvarighed\",\n    \"play_history\": \"Afspilningshistorik\",\n    \"playdate_recorded_no\": \"Ingen Afspilnings Dato Optaget\",\n    \"plays\": \"{value} afspilninger\",\n    \"scene_code\": \"Studiekode\",\n    \"scene_created_at\": \"Scene oprettet kl\",\n    \"scene_date\": \"Dato af Scene\",\n    \"hasChapters\": \"Har Kapitler\",\n    \"height_cm\": \"Højde (cm)\",\n    \"history\": \"Historie\",\n    \"index_of_total\": \"{index} af {total}\",\n    \"primary_file\": \"Primær fil\",\n    \"primary_tag\": \"Primær Mærke\",\n    \"age_on_date\": \"{age} på produktionstidpunktet\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/de-DE.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Hinzufügen\",\n        \"add_directory\": \"Ordner hinzufügen\",\n        \"add_entity\": \"Füge {entityType} hinzu\",\n        \"add_to_entity\": \"Hinzufügen zu {entityType}\",\n        \"allow\": \"Erlauben\",\n        \"allow_temporarily\": \"Vorübergehend erlauben\",\n        \"anonymise\": \"Anonymisieren\",\n        \"apply\": \"Übernehmen\",\n        \"auto_tag\": \"Auto-Tag\",\n        \"backup\": \"Backup\",\n        \"browse_for_image\": \"Nach Bild suchen…\",\n        \"cancel\": \"Abbrechen\",\n        \"clean\": \"Aufräumen\",\n        \"clear\": \"Leeren\",\n        \"clear_back_image\": \"Rückseite entfernen\",\n        \"clear_front_image\": \"Vorderseite entfernen\",\n        \"clear_image\": \"Bild entfernen\",\n        \"close\": \"Schließen\",\n        \"confirm\": \"Bestätigen\",\n        \"continue\": \"Fortsetzen\",\n        \"create\": \"Erstellen\",\n        \"create_chapters\": \"Kapitel erstellen\",\n        \"create_entity\": \"Erstelle {entityType}\",\n        \"create_marker\": \"Markierung erstellen\",\n        \"created_entity\": \"{entity_type} erstellt: {entity_name}\",\n        \"customise\": \"Anpassen\",\n        \"delete\": \"Löschen\",\n        \"delete_entity\": \"Lösche {entityType}\",\n        \"delete_file\": \"Lösche Datei\",\n        \"delete_file_and_funscript\": \"Datei löschen (inkl. funscript)\",\n        \"delete_generated_supporting_files\": \"Lösche generierte Hilfsdaten\",\n        \"disallow\": \"Nicht erlauben\",\n        \"download\": \"Herunterladen\",\n        \"download_anonymised\": \"Anonymisiert herunterladen\",\n        \"download_backup\": \"Backup herunterladen\",\n        \"edit\": \"Bearbeiten\",\n        \"edit_entity\": \"Bearbeiten {entityType}\",\n        \"export\": \"Exportieren\",\n        \"export_all\": \"Alle exportieren…\",\n        \"find\": \"Suchen\",\n        \"finish\": \"Fertig\",\n        \"from_file\": \"Aus Datei…\",\n        \"from_url\": \"Von URL…\",\n        \"full_export\": \"Vollständiger Export\",\n        \"full_import\": \"Vollständiger Import\",\n        \"generate\": \"Generieren\",\n        \"generate_thumb_default\": \"Erstelle voreingestelltes Vorschaubild\",\n        \"generate_thumb_from_current\": \"Erstelle Vorschaubild vom Gegenwärtigen\",\n        \"hash_migration\": \"Hash Umwandlung\",\n        \"hide\": \"Verstecke\",\n        \"hide_configuration\": \"Konfiguration ausblenden\",\n        \"identify\": \"Identifizieren\",\n        \"ignore\": \"Ignorieren\",\n        \"import\": \"Importieren…\",\n        \"import_from_file\": \"Importieren aus Datei\",\n        \"logout\": \"Ausloggen\",\n        \"make_primary\": \"Als Primärquelle festlegen\",\n        \"merge\": \"Zusammenführen\",\n        \"migrate_blobs\": \"Blobs migrieren\",\n        \"migrate_scene_screenshots\": \"Szenen-Screenshots migrieren\",\n        \"next_action\": \"Nächste\",\n        \"not_running\": \"wird nicht ausgeführt\",\n        \"open_in_external_player\": \"In externem Player öffnen\",\n        \"open_random\": \"Öffne Zufällig\",\n        \"overwrite\": \"Überschreiben\",\n        \"play_random\": \"Zufällige Wiedergabe\",\n        \"play_selected\": \"Spiele ausgewählte\",\n        \"preview\": \"Vorschau\",\n        \"previous_action\": \"Zurück\",\n        \"reassign\": \"Neu zuordnen\",\n        \"refresh\": \"Aktualisieren\",\n        \"reload_plugins\": \"Plugins neu laden\",\n        \"reload_scrapers\": \"Scraper neu laden\",\n        \"remove\": \"Entfernen\",\n        \"remove_from_gallery\": \"Aus Gallerie entfernen\",\n        \"rename_gen_files\": \"Hilfsdaten umbenennen\",\n        \"rescan\": \"Erneut scannen\",\n        \"reshuffle\": \"Neu mischen\",\n        \"running\": \"wird ausgeführt\",\n        \"save\": \"Speichern\",\n        \"save_delete_settings\": \"Verwende Option standardmäßig beim Löschen\",\n        \"save_filter\": \"Filter speichern\",\n        \"scan\": \"Scannen\",\n        \"scrape\": \"Scrapen\",\n        \"scrape_query\": \"Scrape Anfrage\",\n        \"scrape_scene_fragment\": \"An Fragment scrapen\",\n        \"scrape_with\": \"Scrape mit…\",\n        \"search\": \"Suchen\",\n        \"select_all\": \"Alle auswählen\",\n        \"select_entity\": \"{entityType} auswählen\",\n        \"select_folders\": \"Ordner auswählen\",\n        \"select_none\": \"Nichts auswählen\",\n        \"selective_auto_tag\": \"Automatisch selektiv taggen\",\n        \"selective_clean\": \"Selektives Aufräumen\",\n        \"selective_scan\": \"Selektiv scannen\",\n        \"set_as_default\": \"Als Voreinstellung festlegen\",\n        \"set_back_image\": \"Rückseite…\",\n        \"set_front_image\": \"Vorderseite…\",\n        \"set_image\": \"Bild festlegen…\",\n        \"show\": \"Anzeigen\",\n        \"show_configuration\": \"Konfiguration anzeigen\",\n        \"skip\": \"Überspringen\",\n        \"split\": \"Trennen\",\n        \"stop\": \"Stopp\",\n        \"submit\": \"Einreichen\",\n        \"submit_stash_box\": \"Zu Stash-Box übermitteln\",\n        \"submit_update\": \"Aktualisierung übermitteln\",\n        \"swap\": \"Tauschen\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Wollen Sie wirklich die Datenbank aufräumen? Dies wird alle Informationen und Hilfsdaten für Szenen und Galerien löschen, die nicht mehr auf dem Dateisystem vorhanden sind.\",\n            \"dry_mode_selected\": \"Trockenmodus ausgewählt. Es findet keine Löschung der Daten statt, lediglich Protokollierung.\",\n            \"import_warning\": \"Wollen Sie wirklich die Datenbank importieren? Dies wird die aktuelle Datenbank mit der importierten Datenbank überschreiben.\"\n        },\n        \"temp_disable\": \"Vorübergehend deaktivieren…\",\n        \"temp_enable\": \"Vorübergehend aktivieren…\",\n        \"unset\": \"Aufheben\",\n        \"use_default\": \"Standard verwenden\",\n        \"view_random\": \"Zufällige anzeigen\",\n        \"disable\": \"Deaktivieren\",\n        \"enable\": \"Aktivieren\",\n        \"encoding_image\": \"Bild wird encodiert…\",\n        \"optimise_database\": \"Datenbank optimieren\",\n        \"reload\": \"Neu laden\",\n        \"remove_date\": \"Datum entfernen\",\n        \"choose_date\": \"Datum auswählen\",\n        \"clean_generated\": \"Generierte Dateien aufräumen\",\n        \"create_parent_studio\": \"Übergeordnetes Studio erstellen\",\n        \"add_manual_date\": \"Datum manuell hinzufügen\",\n        \"copy_to_clipboard\": \"In die Zwischenablage kopieren\",\n        \"view_history\": \"Verlauf anzeigen\",\n        \"add_o\": \"O hinzufügen\",\n        \"add_play\": \"Abspielen hinzufügen\",\n        \"clear_date_data\": \"Datum-Eintrag löschen\",\n        \"assign_stashid_to_parent_studio\": \"Stash ID zu existierendem Übergeordnetem Studio hinzufügen und Metadaten aktualisieren\",\n        \"set_cover\": \"Als Titelbild setzen\",\n        \"reset_cover\": \"Titelbild zurücksetzen\",\n        \"remove_from_containing_group\": \"Von Gruppe entfernen\",\n        \"reset_play_duration\": \"Spieldauer zurücksetzten\",\n        \"reset_resume_time\": \"Fortschritt zurücksetzten\",\n        \"add_sub_groups\": \"Untergruppen hinzufügen\",\n        \"play\": \"Abspielen\",\n        \"sidebar\": {\n            \"toggle\": \"Seitenleiste umschalten\",\n            \"close\": \"Seitenleiste schließen\",\n            \"open\": \"Seitenleiste öffnen\"\n        },\n        \"show_count_results\": \"Zeige {count} Ergebnisse\",\n        \"show_results\": \"Ergebnisse anzeigen\",\n        \"load_filter\": \"Filter laden\",\n        \"load\": \"Laden\",\n        \"create_new\": \"Neu erstellen\",\n        \"add_stash_id\": \"Stash-ID hinzufügen\",\n        \"save_and_new\": \"Speichern & Neu\",\n        \"invert_selection\": \"Invertiere Auswahl\",\n        \"select_directory\": \"Ordner Auswählen\",\n        \"from_clipboard\": \"Aus Zwischenablage\",\n        \"reveal_in_file_manager\": \"Im Dateimanager anzeigen\",\n        \"selective_generate\": \"Selektives Generieren\"\n    },\n    \"actions_name\": \"Aktionen\",\n    \"age\": \"Alter\",\n    \"aliases\": \"Aliase\",\n    \"all\": \"Alle\",\n    \"also_known_as\": \"Auch bekannt unter\",\n    \"appears_with\": \"Tritt auf mit\",\n    \"ascending\": \"Aufsteigend\",\n    \"average_resolution\": \"Durchschnittliche Auflösung\",\n    \"between_and\": \"und\",\n    \"birth_year\": \"Geburtsjahr\",\n    \"birthdate\": \"Geburtsdatum\",\n    \"bitrate\": \"Bitrate\",\n    \"blobs_storage_type\": {\n        \"database\": \"Datenbank\",\n        \"filesystem\": \"Dateisystem\"\n    },\n    \"captions\": \"Untertitel\",\n    \"career_length\": \"Karrierelänge\",\n    \"chapters\": \"Kapitel\",\n    \"circumcised\": \"Beschnitten\",\n    \"circumcised_types\": {\n        \"CUT\": \"Beschnitten\",\n        \"UNCUT\": \"Unbeschnitten\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktive stash-box Instanz:\",\n            \"blacklist_desc\": \"Auf der Blacklist befindliche Objekte sind von Anfragen ausgenommen. Objekte sind reguläre Ausdrücke und Groß-/Kleinschreibung wird nicht beachtet. Manchen Zeichen muss ein Fluchtsymbol (Backslash) vorangestellt werden: {chars_require_escape}\",\n            \"blacklist_label\": \"Schwarze Liste\",\n            \"query_mode_auto\": \"Automatisch\",\n            \"query_mode_auto_desc\": \"Nutzt Metadaten sofern verfügbar bzw. Dateinamen\",\n            \"query_mode_dir\": \"Verzeichnis\",\n            \"query_mode_dir_desc\": \"Nutzt nur den übergeordneten Ordner\",\n            \"query_mode_filename\": \"Dateiname\",\n            \"query_mode_filename_desc\": \"Nutzt nur den Dateinamen\",\n            \"query_mode_label\": \"Suchmodus\",\n            \"query_mode_metadata\": \"Metadaten\",\n            \"query_mode_metadata_desc\": \"Nutzt nur Metadaten\",\n            \"query_mode_path\": \"Pfad\",\n            \"query_mode_path_desc\": \"Nutzt vollständigen Dateipfad\",\n            \"set_cover_desc\": \"Überschreibe Titelbild sofern verfügbar.\",\n            \"set_cover_label\": \"Setze Cover-Bild\",\n            \"set_tag_desc\": \"Hänge Tags der Szene an, entweder durch Überschreiben oder Zusammenführen mit bereits angehängten Tags.\",\n            \"set_tag_label\": \"Tags anhängen\",\n            \"source\": \"Quelle\",\n            \"mark_organized_label\": \"Beim speichern als organisiert markieren\",\n            \"mark_organized_desc\": \"Markiere die Szene nach dem klicken auf Speichern als organisiert.\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Blacklist item duplizieren\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Darsteller-Geschlechter\",\n                \"description\": \"Darsteller mit diesem Geschlecht werden angezeigt, wenn Szenen getaggt werden.\"\n            }\n        },\n        \"noun_query\": \"Anfrage\",\n        \"results\": {\n            \"duration_off\": \"Laufzeitunterschied bei mindestens {number}sek\",\n            \"duration_unknown\": \"Laufzeit unbekannt\",\n            \"fp_found\": \"{fpCount, plural, =0 {Keine neuen Fingerabdruckübereinstimmungen gefunden} other {# neue Fingerabdruckübereinstimmungen gefunden}}\",\n            \"fp_matches\": \"Übereinstimmung der Laufzeit\",\n            \"fp_matches_multi\": \"Laufzeit stimmt mit {matchCount}/{durationsLength} Fingerabdrücken überein\",\n            \"hash_matches\": \"Übereinstimmung bei {hash_type}\",\n            \"match_failed_already_tagged\": \"Szene bereits getagged\",\n            \"match_failed_no_result\": \"Keine Übereinstimmungen gefunden\",\n            \"match_success\": \"Szene erfolgreich getagged\",\n            \"phash_matches\": \"{count} PHashes übereinstimmung\",\n            \"unnamed\": \"Unbenannt\"\n        },\n        \"verb_match_fp\": \"Fingerabdrücke zuordnen\",\n        \"verb_matched\": \"zugeordnet\",\n        \"verb_scrape_all\": \"Alles Scrapen\",\n        \"verb_submit_fp\": \"Übermittele {fpCount, plural, one{# Fingerabdruck} other{# Fingerabdrücke}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} nicht zugeordnete Szenen\",\n        \"verb_add_as_alias\": \"Füge Spitzname als Alias hinzu\",\n        \"verb_link_existing\": \"Link zu bereits existierendem\",\n        \"verb_match_tag\": \"Tag zuordnen\",\n        \"verb_scrape_selected\": \"Ausgewählte \\\"scrapen\\\"\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Hash des Builds:\",\n            \"build_time\": \"Zeitpunkt des Builds:\",\n            \"check_for_new_version\": \"Suche nach Updates\",\n            \"latest_version\": \"Aktuellste Version\",\n            \"latest_version_build_hash\": \"Neuester Build Hash:\",\n            \"new_version_notice\": \"[NEU]\",\n            \"release_date\": \"Veröffentlichungsdatum:\",\n            \"stash_discord\": \"Komm in unseren {url} Kanal\",\n            \"stash_home\": \"Stash ist beheimatet auf {url}\",\n            \"stash_open_collective\": \"Unterstütze uns über {url}\",\n            \"stash_wiki\": \"Stash {url} Seite\",\n            \"version\": \"Version\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Anwendungspfade\"\n        },\n        \"categories\": {\n            \"about\": \"Über\",\n            \"changelog\": \"Änderungsprotokoll\",\n            \"interface\": \"Oberfläche\",\n            \"logs\": \"Protokoll\",\n            \"metadata_providers\": \"Metadaten-Anbieter\",\n            \"plugins\": \"Plugins\",\n            \"scraping\": \"Durchsuchen\",\n            \"security\": \"Sicherheit\",\n            \"services\": \"Dienste\",\n            \"system\": \"System\",\n            \"tasks\": \"Aufgaben\",\n            \"tools\": \"Werkzeuge\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Erlaube {tempIP}\",\n            \"allowed_ip_addresses\": \"Erlaubte IP Adressen\",\n            \"allowed_ip_temporarily\": \"Temporär erlaubte IP\",\n            \"default_ip_whitelist\": \"Standard IP Whitelist\",\n            \"default_ip_whitelist_desc\": \"Standard IP Adressen, welche DLNA nutzen dürfen. Nutze {wildcard} um alle IP Adressen zu erlauben.\",\n            \"disabled_dlna_temporarily\": \"DLNA vorübergehend deaktiviert\",\n            \"disallowed_ip\": \"Unzulässige IP\",\n            \"enabled_by_default\": \"Standardmäßig aktiviert\",\n            \"enabled_dlna_temporarily\": \"DLNA vorübergehend aktiviert\",\n            \"network_interfaces\": \"Netzwerkoberflächen\",\n            \"network_interfaces_desc\": \"Netzwerkoberflächen auf denen DLNA sichtbar ist. Eine leere Liste führt dazu, dass DLNA auf allen Oberflächen ausgeführt wird. Benötigt Neustart des DLNA nach Änderungen.\",\n            \"recent_ip_addresses\": \"Letzte IP Adressen\",\n            \"server_display_name\": \"Server Anzeigename\",\n            \"server_display_name_desc\": \"Anzeigename des DLNA-Servers. Standardmäßig {server_name} bei leerem Feld.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Erfolgreich temporäres Verhalten aufgehoben\",\n            \"until_restart\": \"bis Neustart\",\n            \"video_sort_order\": \"Standard-Videosortierreihenfolge\",\n            \"video_sort_order_desc\": \"Reihenfolge, in der Videos standardmäßig sortiert werden.\",\n            \"server_port\": \"Serverport\",\n            \"server_port_desc\": \"Port, auf dem der DLNA Server laufen soll. Benötigt nach einer Änderung einen DLNA Neustart.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API-Schlüssel\",\n                \"api_key_desc\": \"API-Schlüssel für externe Systeme. Nur nötig, falls Benutzer/Password konfiguriert. Benutzername muss vor Erzeugung des API Schlüssels gespeichert worden sein.\",\n                \"authentication\": \"Authentifizierung\",\n                \"clear_api_key\": \"API-Schlüssel löschen\",\n                \"credentials\": {\n                    \"description\": \"Anmeldedaten, um den Zugriff auf Stash einzuschränken.\",\n                    \"heading\": \"Anmeldedaten\"\n                },\n                \"generate_api_key\": \"API-Schlüssel erzeugen\",\n                \"log_file\": \"Protokolldatei\",\n                \"log_file_desc\": \"Pfad zur Protokolldatei. Feld leer lassen, um Protokollierung zu deaktivieren. Benötigt Neustart.\",\n                \"log_http\": \"Protokolliere HTTP Zugriffe\",\n                \"log_http_desc\": \"Protokolliert HTTP Zugriffe im Terminal. Benötigt Neustart.\",\n                \"log_to_terminal\": \"Protokolliere zu Terminal\",\n                \"log_to_terminal_desc\": \"Protokolliert zusätzlich zur Protokolldatei auch zum Terminal. Gilt automatisch, sofern Protokolldatei deaktiviert. Benötigt Neustart.\",\n                \"maximum_session_age\": \"Maximale Sitzungsdauer\",\n                \"maximum_session_age_desc\": \"Maximale Wartezeit bis eine Login-Sitzung ausläuft, in Sekunden.\",\n                \"password\": \"Passwort\",\n                \"password_desc\": \"Passwort für den Zugriff auf Stash. Feld leer lassen, um Benutzerauthentifizierung zu deaktivieren\",\n                \"stash-box_integration\": \"Stash-box Einbindung\",\n                \"username\": \"Benutzername\",\n                \"username_desc\": \"Benutzername für den Zugriff auf Stash. Feld leer lassen, um Benutzerauthentifizierung zu deaktivieren\",\n                \"log_file_max_size\": \"Maximale Log Größe\",\n                \"log_file_max_size_desc\": \"Maximale Größe, in Megabytes, von den Log Files bevor es komprimiert wird. 0MB ist deaktiviert. Benötigt Neustart.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Verzeichnisspeicherort für SQLite-Datenbankdateisicherungen\",\n                \"heading\": \"Backup-Verzeichnispfad\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Der Ort auf dem Dateisystem an dem die Binärdaten gespeichert werden. Wird nur angewendet, wenn der Binärdaten Speichertyp auf Dateisystem eingestellt ist. ACHTUNG: Eine Änderung des Pfades erfordert das manuelle Verschieben von bereits existierenden Daten.\",\n                \"heading\": \"Binärdaten Dateisystem-Pfad\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Der Ort an dem Binärdaten wie Szenencover, Darsteller-, Studio- und Tag-Bilder bespeichert werden. Nach einer Änderung müssen die bereits existierenden Daten mit der \\\"Blobs migrieren\\\"-Aufgabe migriert werden. Siehe Aufgaben-Seite für Migrierungen.\",\n                \"heading\": \"Binärdaten Speichertyp\"\n            },\n            \"cache_location\": \"Verzeichnis für den Cache. Notwendig falls Streaming mit HLS (wie auf Apple Geräten üblich) oder DASH erfolgt.\",\n            \"cache_path_head\": \"Cachepfad\",\n            \"calculate_md5_and_ohash_desc\": \"Berechne MD5 Prüfsumme zusätzlich zu oshash. Aktivierung führt dazu, dass erstmalige Scans mehr Zeit benötigen. Dateibenennungshash muss auf oshash gesetzt sein, um Berechnung des MD5 zu unterbinden.\",\n            \"calculate_md5_and_ohash_label\": \"Berechne MD5 für Videodateien\",\n            \"check_for_insecure_certificates\": \"Überprüfe auf unsichere Zertifikate\",\n            \"check_for_insecure_certificates_desc\": \"Manche Seiten nutzen unsichere SSL Zertifikate. Wenn diese Option nicht ausgewählt ist, überspringt der Scraper die Überprüfung und erlaubt das Scrapen dieser Seiten. Entfernen Sie das Häkchen, falls Sie Zertifikatsfehler beim Scrapen erhalten.\",\n            \"chrome_cdp_path\": \"Chrome CDP Pfad\",\n            \"chrome_cdp_path_desc\": \"Dateipfad zur Chrome Executable oder einer externen Adresse (beginnend mit http:// oder https://, bspw. http://localhost:9222/json/version) die auf eine Chrome Instanz zeigt.\",\n            \"create_galleries_from_folders_desc\": \"Wenn ausgewählt, erzeuge standardmäßig Galerien aus Verzeichnissen, welche Bilder enthalten. Erstellen Sie eine Datei .forcegallery oder .nogallery in dem Ordner, um das Verhalten für diesen zu erzwingen/unterdrücken.\",\n            \"create_galleries_from_folders_label\": \"Erzeuge Galerien aus Verzeichnissen mit Bilder darin\",\n            \"database\": \"Datenbank\",\n            \"db_path_head\": \"Datenbankpfad\",\n            \"directory_locations_to_your_content\": \"Verzeichnis zu Ihren Inhalten\",\n            \"excluded_image_gallery_patterns_desc\": \"Reguläre Ausdrücke für Dateinamen/Pfade von Bildern/Galerien, welche von Scans ausgeschlossen werden und beim Aufräumen der Datenbank berücksichtigt werden sollen.\",\n            \"excluded_image_gallery_patterns_head\": \"Schema für ausgeschlossene Bilder/Galerien\",\n            \"excluded_video_patterns_desc\": \"Reguläre Ausdrücke für Dateinamen/Pfade von Videos, welche von Scans ausgeschlossen werden und beim Aufräumen der Datenbank berücksichtigt werden sollen.\",\n            \"excluded_video_patterns_head\": \"Schema für ausgeschlossene Videos\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"Nutzt verfügbare Hardware zum Kodieren von Video für Live-Transkodierung.\",\n                    \"heading\": \"FFmpeg Hardware-Kodierung\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Erweitert: Zusätzliche Parameter für die Live-Transkodierung mit ffmpeg, welche vor dem Eingabefeld übergeben werden können.\",\n                        \"heading\": \"FFmpeg Live Transcode Eingangsparameter\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Erweitert: Zusätzliche Parameter für die Live-Transkodierung mit ffmpeg, welche vor dem Ausgabefeld übergeben werden können.\",\n                        \"heading\": \"FFmpeg Live-Transkodierung Ausgangsparameter\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Erweitert: Zusätzliche Parameter für die Video-Generierung mit ffmpeg, welche vor dem Eingabefeld übergeben werden können.\",\n                        \"heading\": \"FFmpeg Transkodierung Eingangsparameter\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Erweitert: Zusätzliche Parameter für die Videogenerierung mit ffmpeg, welche vor dem Ausgabefeld übergeben werden können.\",\n                        \"heading\": \"FFmpeg Transkodierung Ausgangsparameter\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"FFmpeg herunterladen\",\n                    \"description\": \"Lädt FFmpeg in das Konfigurationsverzeichnis herunter und löscht die Pfade ffmpeg und ffprobe, um sie aus dem Konfigurationsverzeichnis aufzulösen.\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"FFmpeg.exe Pfad\",\n                    \"description\": \"Pfad zu ffmpeg.exe (nicht nur der Ordner). Wenn nicht angegeben wird ffmpeg.exe aus der Umgebungsvariable $PATH, dem Konfigurationsverzeichnis oder aus $HOME/.stash aufgelöst\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Pfad zu ffprobe.exe (nicht nur der Ordner). Wenn nicht angegeben wird ffprobe.exe aus der Umgebungsvariable $PATH, dem Konfigurationsverzeichnis oder aus $HOME/.stash aufgelöst.\",\n                    \"heading\": \"FFprobe.exe Pfad\"\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"Reichweite in generierte Heatmaps einbeziehen\",\n            \"funscript_heatmap_draw_range_desc\": \"Zeichnet den Bewegungsbereich auf der y-Achse der erzeugten Heatmap. Vorhandene Heatmaps müssen nach der Änderung neu generiert werden.\",\n            \"gallery_cover_regex_desc\": \"Regulärer Ausdruck, der verwendet wird um ein Bild als Galerietitelbild zu identifizieren.\",\n            \"gallery_cover_regex_label\": \"Schema für Galerietitelbilder\",\n            \"gallery_ext_desc\": \"Durch Kommas getrennte Liste von Dateiformaten, welche als Galeriecontainer gelesen werden sollen.\",\n            \"gallery_ext_head\": \"Galerie-ZIP Dateiendung\",\n            \"generated_file_naming_hash_desc\": \"Verwende MD5 oder oshash für die Benennung der generierten Dateien. Um dies zu ändern, müssen für alle Szenen der entsprechende MD5/oshash berechnet werden. Nachdem dieser Wert geändert wurde, müssen vorhandene generierte Dateien migriert oder neu generiert werden. Siehe Aufgabenseite für die Migration.\",\n            \"generated_file_naming_hash_head\": \"Dateinamen-Hash für generierte Dateien\",\n            \"generated_files_location\": \"Verzeichnisspeicherort für die generierten Dateien (Markierungen, Vorschauen, Sprites usw.)\",\n            \"generated_path_head\": \"Pfad für generierte Dateien\",\n            \"hashing\": \"Hashwertberechnung\",\n            \"heatmap_generation\": \"Funscript Heatmap Erzeugung\",\n            \"image_ext_desc\": \"Durch Kommas getrennte Liste von Dateierweiterungen, die als Bilder identifiziert werden.\",\n            \"image_ext_head\": \"Bilderweiterungen\",\n            \"include_audio_desc\": \"Binde Audiostream bei der Erstellung der Videovorschau ein.\",\n            \"include_audio_head\": \"Audio einbeziehen\",\n            \"logging\": \"Protokollierung\",\n            \"maximum_streaming_transcode_size_desc\": \"Maximale Größe für transcodierte Streams\",\n            \"maximum_streaming_transcode_size_head\": \"Maximale Streaming-Transcode-Größe\",\n            \"maximum_transcode_size_desc\": \"Maximale Größe für generierte Transcodes\",\n            \"maximum_transcode_size_head\": \"Maximale Transcodierungsgröße\",\n            \"metadata_path\": {\n                \"description\": \"Verzeichnis das bei einem vollständigen Export oder Import genutzt wird\",\n                \"heading\": \"Metadatenpfad\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Für die automatische Erkennung auf 0 setzen. Warnung: Mehr Aufgaben auszuführen, als erforderlich ist, um eine CPU-Auslastung von 100 % zu erreichen, verringert die Leistung und verursacht möglicherweise andere Probleme.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Anzahl paralleler Tasks für Scan/Generierung\",\n            \"parallel_scan_head\": \"Paralleler Scan/Generierung\",\n            \"preview_generation\": \"Vorschau-Generierung\",\n            \"python_path\": {\n                \"description\": \"Ort der Python-Programmdatei. Wird für Script-Scraper und Plugins verwendet. Wenn leer, wird python aus der Umgebung aufgelöst\",\n                \"heading\": \"Python Pfad\"\n            },\n            \"scraper_user_agent\": \"Scraper-Benutzeragent\",\n            \"scraper_user_agent_desc\": \"User-Agent-String, der während Scrape-HTTP-Anfragen verwendet wird\",\n            \"scrapers_path\": {\n                \"description\": \"Verzeichnis für die Konfigurationsdateien des Scrapers\",\n                \"heading\": \"Scraper Pfad\"\n            },\n            \"scraping\": \"Durchsuchen\",\n            \"sqlite_location\": \"Dateispeicherort für die SQLite-Datenbank (erfordert Neustart). ACHTUNG: Ein Speicherort auf einem anderen System als dem Server auf dem Stash läuft (z.B. Netzwerkspeicher) wird nicht unterstützt!\",\n            \"video_ext_desc\": \"Durch Kommas getrennte Liste von Dateierweiterungen, die als Videos identifiziert werden.\",\n            \"video_ext_head\": \"Videodateiformate\",\n            \"video_head\": \"Video\",\n            \"plugins_path\": {\n                \"heading\": \"Dateipfad für Plugins\",\n                \"description\": \"Speicherort der Plugin-Konfigurationsdateien\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"Pfad in den gelöschte Dateien verschoben, anstatt dauerhaft gelöscht werden. Freilassen, um Dateien permanent zu löschen.\",\n                \"heading\": \"Papierkorbverzeichnis\"\n            }\n        },\n        \"library\": {\n            \"exclusions\": \"Ausnahmen\",\n            \"gallery_and_image_options\": \"Galerie- und Bildoptionen\",\n            \"media_content_extensions\": \"Erweiterungen für Medieninhalte\"\n        },\n        \"logs\": {\n            \"log_level\": \"Protokolllevel\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Einbindungen\",\n            \"triggers_on\": \"Auslösen bei\",\n            \"installed_plugins\": \"Installierte Plugins\",\n            \"available_plugins\": \"Verfügbare Plugins\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metadaten\",\n            \"entity_scrapers\": \"{entityType} Scraper\",\n            \"excluded_tag_patterns_desc\": \"Reguläre Audrücke von Tags zum Ausschließen von Scraping Ergebnissen\",\n            \"excluded_tag_patterns_head\": \"Tag Muster ausschließen\",\n            \"scraper\": \"Scraper\",\n            \"scrapers\": \"Scraper\",\n            \"search_by_name\": \"Suche nach Name\",\n            \"supported_types\": \"Unterstützte Typen\",\n            \"supported_urls\": \"Unterstützte Adressen\",\n            \"available_scrapers\": \"Verfügbare Scraper\",\n            \"installed_scrapers\": \"Installierte Scraper\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Stash-Box-Instanz hinzufügen\",\n            \"api_key\": \"API-Schlüssel\",\n            \"description\": \"Stash-Box erleichtert das automatisierte Tagging von Szenen und Darstellern basierend auf Fingerabdrücken und Dateinamen.\\nEndpunkt und API-Schlüssel finden Sie auf Ihrer Kontoseite in der stash-box-Instanz. Ein Name ist erforderlich, wenn mehr als eine Instanz hinzugefügt wird.\",\n            \"endpoint\": \"Endpunkt\",\n            \"graphql_endpoint\": \"GraphQL-Endpunkt\",\n            \"name\": \"Name\",\n            \"title\": \"Stash-Box-Endpunkte\",\n            \"max_requests_per_minute\": \"Max requests pro Minute\",\n            \"max_requests_per_minute_description\": \"Verwendet Standardwert {defaultValue}, wenn auf 0 gesetzt\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transcodierung\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"{operation_name} zur Auftragswarteschlange hinzugefügt\",\n            \"anonymise_and_download\": \"Erstellt eine anonymisierte Kopie der Datenbank und lädt diese im Anschluss herunter.\",\n            \"anonymise_database\": \"Erstellt eine Kopie der Datenbank in das Backup-Verzeichnis und anonymisiert alle empfindlichen Daten. Diese kann dann für zur Fehlersuche und -behebung geteilt werden. Die ursprüngliche Datenbank wird dabei nicht verändert. Die anonymisierte Datenbank verwendet das Dateiformat {filename_format}.\",\n            \"anonymising_database\": \"Anonymisiere Datenbank\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Automatisches Taggen aller Pfade\",\n                \"auto_tagging_paths\": \"Automatisches Taggen der folgenden Pfade\"\n            },\n            \"auto_tag_based_on_filenames\": \"Inhalte basierend auf Dateipfaden automatisch taggen.\",\n            \"auto_tagging\": \"Automatisches Tagging\",\n            \"backing_up_database\": \"Datenbank sichern\",\n            \"backup_and_download\": \"Führt eine Sicherung der Datenbank durch und lädt die resultierende Datei herunter.\",\n            \"cleanup_desc\": \"Suche nach fehlenden Dateien und entfernen Sie diese aus der Datenbank. Dies ist eine destruktive Aktion.\",\n            \"data_management\": \"Datenmanagement\",\n            \"defaults_set\": \"Standardeinstellungen wurden eingestellt und werden genutzt, wenn {action} Button auf der Aufgabenseite geklickt wurde.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Füge keine Dateierweiterung als Teil des Titels hinzu\",\n            \"empty_queue\": \"Derzeit laufen keine Aufgaben.\",\n            \"export_to_json\": \"Exportiert den Datenbankinhalt in JSON-Format im Metadatenverzeichnis.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Generieren der Szenen aus den folgenden Pfaden\",\n                \"generating_scenes\": \"Generieren für {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"Erstelle Previews für Bild-Clips\",\n            \"generate_desc\": \"Generiere unterstützende Bild-, Sprite-, Video-, VTT- und andere Dateien.\",\n            \"generate_phashes_during_scan\": \"Generiere Wahrnehmungshashwerte\",\n            \"generate_phashes_during_scan_tooltip\": \"Zur Deduplizierung und Szenenerkennung.\",\n            \"generate_previews_during_scan\": \"Animierte Bildvorschauen erstellen\",\n            \"generate_previews_during_scan_tooltip\": \"Generiert animierte WebP-Vorschaubilder, nur erforderlich, wenn der Vorschautyp auf Animiertes Bild eingestellt ist.\",\n            \"generate_sprites_during_scan\": \"Scrubber-Sprites generieren\",\n            \"generate_thumbnails_during_scan\": \"Generiert Miniaturansichten für Bilder\",\n            \"generate_video_covers_during_scan\": \"Erzeuge Szenen-Cover\",\n            \"generate_video_previews_during_scan\": \"Vorschaubilder generieren\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generiert Videovorschauen, die abgespielt werden, wenn man den Mauszeiger über eine Szene bewegt\",\n            \"generated_content\": \"Generierter Inhalt\",\n            \"identify\": {\n                \"and_create_missing\": \"und erstelle fehlende\",\n                \"create_missing\": \"Erstelle Fehlende\",\n                \"default_options\": \"Standardoptionen\",\n                \"description\": \"Automatisches erstellen der Szenen Metadaten durch Stash-Box und Scraper Quellen.\",\n                \"explicit_set_description\": \"Die folgenden Optionen werden genutzt, wenn sie nicht in der quellenspezifischen Konfiguration überschrieben worden sind.\",\n                \"field\": \"Feld\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Feldoptionen\",\n                \"heading\": \"Identifizieren\",\n                \"identifying_from_paths\": \"Identifiziere Szenen von folgenden Pfad\",\n                \"identifying_scenes\": \"Identifiziere {num} {scene}\",\n                \"include_male_performers\": \"Männliche Darsteller einbeziehen\",\n                \"set_cover_images\": \"Titelbild festlegen\",\n                \"set_organized\": \"Setze 'Organisiert'\",\n                \"source\": \"Quelle\",\n                \"source_options\": \"{source} Optionen\",\n                \"sources\": \"Quellen\",\n                \"strategy\": \"Strategie\",\n                \"skip_multiple_matches\": \"Überspringe Treffer, die mehr als ein Ergebnis haben\",\n                \"skip_multiple_matches_tooltip\": \"Wenn dies nicht aktiviert ist und mehr als ein Ergebnis zurückgegeben wird, wird ein passendes zufällig ausgewählt\",\n                \"skip_single_name_performers\": \"Darsteller mit nur einem - nicht eindeutigem - Namen überspringen\",\n                \"skip_single_name_performers_tooltip\": \"Wenn dies nicht aktiviert ist, werden Darsteller, die oft generisch sind, wie Samantha oder Olga, abgeglichen\",\n                \"tag_skipped_matches\": \"Übersprungenes Tag passt zu\",\n                \"tag_skipped_matches_tooltip\": \"Erstellen Sie ein Tag wie 'Identifizieren: Mehrere Übereinstimmungen“, nach denen Sie in der Scene Tagger-Ansicht filtern und die richtige Übereinstimmung von Hand auswählen können\",\n                \"tag_skipped_performers\": \"Setze folgenden Tag bei übersprungenen Darstellern\",\n                \"tag_skipped_performer_tooltip\": \"Erstelle einen Tag wie ‘zu identifizieren: Darsteller:in mit einem Namen‘ sodass du in der Scene Tagger Ansicht danach filtern und entscheiden kannst, wie mit diesen Darstellern:innen umgegangen werden soll\"\n            },\n            \"import_from_exported_json\": \"Import aus exportiertem JSON im Metadatenverzeichnis. Löscht die vorhandene Datenbank.\",\n            \"incremental_import\": \"Inkrementeller Import aus einer Export-ZIP-Datei.\",\n            \"job_queue\": \"Aufgabenwarteschlange\",\n            \"maintenance\": \"Instandhaltung\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Ältere Daten löschen\",\n                \"description\": \"Migriere Blobs auf den aktuellen Binärdaten Speichertyp. Diese Migration sollte durchgeführt werden nachdem der Binärdaten Speichertyp geändert wurde. Optional können die alten Daten nach der Migration gelöscht werden.\"\n            },\n            \"migrate_hash_files\": \"Wird nach dem Ändern des Dateinamen-Hashs für generierte Dateien verwendet, um vorhandene generierte Dateien in das neue Hash-Format umzubenennen.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Screenshot-Dateien löschen\",\n                \"description\": \"Migriere Szenen-Screenshots in den neuen Binärdaten Speichertyp. Diese Migration sollte durchgeführt werden nachdem ein System auf die Version 0.20 geupdatet wurde. Optional können ältere Screenshot-Dateien gelöscht werden.\",\n                \"overwrite_existing\": \"Überschreibe existierende Binärblobs mit Screenshot-Dateien\"\n            },\n            \"migrations\": \"Migrationen\",\n            \"only_dry_run\": \"Führt einen Probelauf durch. Es wird noch nichts entfernt\",\n            \"plugin_tasks\": \"Plugin-Aufgaben\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Scannen aller Pfade\",\n                \"scanning_paths\": \"Scannen der folgenden Pfade\"\n            },\n            \"scan_for_content_desc\": \"Suchen nach neuen Inhalten und füge sie der Datenbank hinzu.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Name, Datum und Details aus eingebetteten Metadaten festlegen\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blob Dateien\",\n                \"description\": \"Entfernt generierte Dateien ohne korrespondierenden Datenbankeintrag.\",\n                \"image_thumbnails\": \"Bild Miniaturansichten\",\n                \"image_thumbnails_desc\": \"Bild Miniaturansichten und Clips\",\n                \"markers\": \"Marker Vorschauen\",\n                \"previews\": \"Szenen Vorschauen\",\n                \"previews_desc\": \"Szenen Vorschauen und Miniaturansichten\",\n                \"transcodes\": \"Transkodierte Szenen\",\n                \"sprites\": \"Sprites der Szenen\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"Die Anzahl an Bilder, die unter dem Video Player, zur einfacheren Navigation, angezeigt werden.\",\n            \"optimise_database_warning\": \"Achtung: Während diese Aufgabe ausgeführt wird, schlagen alle Operationen, die die Datenbank verändern, fehl, und je nach Größe Ihrer Datenbank kann es mehrere Minuten dauern, bis sie abgeschlossen ist. Außerdem wird mindestens so viel freier Speicherplatz benötigt, wie Ihre Datenbank groß ist, empfohlen wird jedoch das 1,5-fache.\",\n            \"optimise_database\": \"Versucht die Performance zu verbessern, indem die Datenbank analysiert und dann neu strukturiert wird.\",\n            \"rescan\": \"Dateien erneut scannen\",\n            \"rescan_tooltip\": \"Alle Dateien im Pfad neu scannen. Erzwingt das Erneuern von Metadaten und das erneute Scannen von ZIP Archiven.\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Duplikatsprüfung für Szenen\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Feld hinzufügen\",\n                \"capitalize_title\": \"Titel groß schreiben\",\n                \"display_fields\": \"Anzeigefelder\",\n                \"escape_chars\": \"Verwenden Sie \\\\, um Literale zu maskieren\",\n                \"filename\": \"Dateiname\",\n                \"filename_pattern\": \"Dateinamenmuster\",\n                \"ignore_organized\": \"Ignoriere organizierte Szenen\",\n                \"ignored_words\": \"Ignorierte Wörter\",\n                \"matches_with\": \"Stimmt mit {i} überein\",\n                \"select_parser_recipe\": \"Parser-Rezept auswählen\",\n                \"title\": \"Szenendateinamen-Parser\",\n                \"whitespace_chars\": \"Zwischenraumzeichen\",\n                \"whitespace_chars_desc\": \"Diese Zeichen werden im Titel durch Zwischenraumzeichen ersetzt\"\n            },\n            \"scene_tools\": \"Szenen-Tools\",\n            \"heading\": \"Werkzeuge\",\n            \"graphql_playground\": \"GraphQL-Spielplatz\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Verkürze Zähler in Karten und den Detail-Ansichten ab, zum Beispiel wird \\\"1831\\\" als \\\"1.8K\\\" abgekürzt.\",\n                \"heading\": \"Zähler verkürzen\"\n            },\n            \"basic_settings\": \"Grundeinstellungen\",\n            \"custom_css\": {\n                \"description\": \"Die Seite muss neu geladen werden, damit die Änderungen wirksam werden. Es gibt keine Garantie für die Kompatibilität des benutzerdefinierten CSS und zukünftigen Versionen von Stash.\",\n                \"heading\": \"Benutzerdefinierte CSS\",\n                \"option_label\": \"Benutzerdefiniertes CSS aktiviert\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Seite muss neu geladen werden, damit die Änderungen wirksam werden. Es gibt keine Garantie für die Kompatibilität des benutzerdefinierten Javascript und zukünftigen Versionen von Stash.\",\n                \"heading\": \"Benutzerdefiniertes Javascript\",\n                \"option_label\": \"Benutzerdefiniertes Javascript aktiviert\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Überschreibe einzelne Locale-Strings. Siehe https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json für die Hauptliste. Die Seite muss neu geladen werden, damit die Änderungen wirksam werden.\",\n                \"heading\": \"Benutzerdefinierte Lokalisierung\",\n                \"option_label\": \"Benutzerdefinierte Lokalisierung aktiviert\"\n            },\n            \"delete_options\": {\n                \"description\": \"Standardeinstellungen wenn Bilder, Galerien und Szenen gelöscht werden.\",\n                \"heading\": \"Optionen löschen\",\n                \"options\": {\n                    \"delete_file\": \"Lösche standardmäßig die Dateien\",\n                    \"delete_generated_supporting_files\": \"Lösche standardmäßig die generierten Hilfsdateien\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Schreibtisch Integration\",\n                \"notifications_enabled\": \"Benachrichtigungen aktivieren\",\n                \"send_desktop_notifications_for_events\": \"Bei Neuigkeiten Benachrichtigungen auf den Desktop senden\",\n                \"skip_opening_browser\": \"Überspringe Öffnen des Browsers\",\n                \"skip_opening_browser_on_startup\": \"Überspringe automatisches Öffnen des Browsers bei Start\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Entferne die Möglichkeit der Erstellung neuer Objekte in der Dropdown-Auswahl\",\n                    \"heading\": \"Entferne Dropdown Erstellung\"\n                },\n                \"heading\": \"Editieren\",\n                \"max_options_shown\": {\n                    \"label\": \"Maximalanzahl der anzuzeigenden Objekte in Dropdown-Menüs\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Präzision der Sternebewertung\",\n                        \"options\": {\n                            \"full\": \"Voll\",\n                            \"half\": \"Halb\",\n                            \"quarter\": \"Viertel\",\n                            \"tenth\": \"Zehntel\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Art des Bewertungssystems\",\n                        \"options\": {\n                            \"decimal\": \"Dezimal\",\n                            \"stars\": \"Sterne\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Zeitversatz in Millisekunden für interaktive Skriptwiedergabe.\",\n                \"heading\": \"Funscript Zeitversatz (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Verbinden\",\n                \"server_offset\": {\n                    \"heading\": \"Server Kompensation\"\n                },\n                \"status\": {\n                    \"heading\": \"Handy Verbindungsstatus\"\n                },\n                \"sync\": \"Synchronisieren\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Handy Verbindungsschlüssel für interaktive Szenen. Wenn dieser Schlüssel gesetzt wird, kann Stash aktuellen Szeneinformationen mit handyfeeling.com teilen\",\n                \"heading\": \"Handy Verbindungsschlüssel\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Bild-Lightbox\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Richtung\",\n                \"heading\": \"Bilderwand\",\n                \"margin\": \"Marge (Pixel)\"\n            },\n            \"images\": {\n                \"heading\": \"Bilder\",\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"Wenn für die Bibliothek Videos deaktiviert wurden, können Videodateien (Dateien mit Videodateiendungen) als Bild-Clips eingescannt werden.\",\n                        \"heading\": \"Scannen von Videoerweiterungen als Bild-Clip\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Speichere eilig erstellte Bild-Thumbnails ab\",\n                        \"heading\": \"Speichere Bild-Thumbnails\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Interaktive Optionen\",\n            \"language\": {\n                \"heading\": \"Sprache\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Maximale Szenendauer, bei der das Video wiederholt wird – 0 zum Deaktivieren\",\n                \"heading\": \"Maximale Schleifendauer\"\n            },\n            \"menu_items\": {\n                \"description\": \"Anzeigen oder Ausblenden verschiedener Inhaltstypen in der Navigationsleiste\",\n                \"heading\": \"Menüpunkte\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Der prozentuale Anteil der Zeit, in der eine Szene gespielt werden muss, bevor Abspielen gezählt wird ist erhöht worden.\",\n                \"heading\": \"Mindestabspieldauer (Prozent)\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Benutzerdefinierter Pfad zu den standardmäßigen Darstellerbildern. Leer lassen, um mitgelieferte Darstellerbilder zu verwenden\",\n                        \"heading\": \"Benutzerdefinierter Pfad zu Darstellerbildern\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Die Standardoption sind Video (mp4) Vorschaubilder. Für geringeren CPU-Verbrauch beim Durchsuchen kannst du die animierten Bilder (webp) Vorschaubilder verwenden. Diese müssen jedoch zusätzlich zu den Video-Vorschaubildern erstellt werden und sind größere Dateien.\",\n                \"heading\": \"Vorschautyp\",\n                \"options\": {\n                    \"animated\": \"Animiertes Bild\",\n                    \"static\": \"Statisches Bild\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Szenenliste\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Studios als Text anzeigen\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Szenenplayer\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Video immer von Anfang an starten\",\n                    \"auto_start_video\": \"Video automatisch starten\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Automatischer Start von Videos aus der Warteschlange oder bei einer Wiedergabe von ausgewählten oder zufälligen Videos von der Szenen-Seite\",\n                        \"heading\": \"Automatische Wiedergabe von ausgewählten Videos\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Nächste Szene in der Warteschlange spielen\",\n                        \"heading\": \"Standardmäßig die Wiedergabeliste fortsetzen\"\n                    },\n                    \"show_scrubber\": \"Scrubber anzeigen\",\n                    \"track_activity\": \"Aktivität verfolgen\",\n                    \"vr_tag\": {\n                        \"description\": \"Der VR-Knopf wird nur für Szenen mit diesem Tag angezeigt.\",\n                        \"heading\": \"VR Tag\"\n                    },\n                    \"show_ab_loop_controls\": \"Die Steuerungselemente des AB-Loop-Plugins anzeigen\",\n                    \"disable_mobile_media_auto_rotate\": \"Deaktiviere das automatische Drehen von Vollbildmedien auf Mobilgeräten\",\n                    \"enable_chromecast\": \"Chromecast aktivieren\",\n                    \"show_range_markers\": \"Zeige Range der Markierungen\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Szenen-/Markierungswand\",\n                \"options\": {\n                    \"display_title\": \"Titel und Tags anzeigen\",\n                    \"toggle_sound\": \"Sound einschalten\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Anzahl der Versuche, einen Bildlauf durchzuführen, bevor zum nächsten/vorherigen Element gewechselt wird. Gilt nur für den Bildlaufmodus Schwenkung Y.\",\n                \"heading\": \"Anzahl Scroll-Versuche vor Übergang\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Tag-Karte anzeigen, wenn der Mauszeiger über Tag-Abzeichen bewegt wird\",\n                \"heading\": \"Tag-Karten-Tooltips\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Die Diashow ist in Galerien in der Wandansicht verfügbar\",\n                \"heading\": \"Verzögerung der Diashow (Sekunden)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Studioansicht\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"In der Studioansicht, zeige auch Inhalte von Unterstudios\",\n                        \"heading\": \"Zeige Inhalte von Unterstudios\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Tag-Ansicht\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"In der Tag-Ansicht, zeige auch Inhalte der Sub-Tags\",\n                        \"heading\": \"Zeige Sub-Tag Inhalte\"\n                    }\n                }\n            },\n            \"title\": \"Benutzeroberfläche\",\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"heading\": \"Erweiterte Details einklappen\",\n                    \"description\": \"Wenn aktiviert, wird diese Option mehr Details anzeigen und dabei einen kompakten beibehalten\"\n                },\n                \"enable_background_image\": {\n                    \"heading\": \"Aktiviere Hintergrundbild\",\n                    \"description\": \"Hintergrundbild auf Detailseite anzeigen.\"\n                },\n                \"heading\": \"Detailseite\",\n                \"show_all_details\": {\n                    \"description\": \"Wenn aktiviert, werden alle Inhaltsdetails standardmäßig angezeigt, und jedes Detail passt in eine einzelne Spalte\",\n                    \"heading\": \"Alle Details anzeigen\"\n                }\n            },\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"Wenn aktiviert, werden Funscripts direkt von Stash an dein Handy-Gerät gesendet, ohne Handy-Server von Drittanbietern zu verwenden. Erfordert, dass Stash von deinem Handy-Gerät aus zugänglich ist und ein API-Schlüssel generiert wurde, falls Stash mit Zugangsdaten konfiguriert ist.\",\n                \"heading\": \"Funscripts direkt bereitstellen\"\n            },\n            \"performer_list\": {\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"Zeige den Link auf der Darsteller Gitterkarte\"\n                    }\n                },\n                \"heading\": \"Darsteller Liste\"\n            },\n            \"sfw_mode\": {\n                \"description\": \"Aktivieren wenn man Stash für das speichern von SFW content benutzt. Versteckt oder Ändernt ein paar NFSW eigenschaften des UI.\",\n                \"heading\": \"SFW Content Modus\"\n            },\n            \"custom_title\": {\n                \"description\": \"Benutzerdefinierter Text, der an den Seitentitel angehängt werden soll. Falls leer, wird Standard \\\"Stash\\\" verwendet.\",\n                \"heading\": \"Benutzerdefinierter Titel\"\n            },\n            \"troubleshooting_mode\": {\n                \"exit\": \"Abbrechen\"\n            }\n        },\n        \"advanced_mode\": \"Fortgeschrittener Modus\",\n        \"changelog\": {\n            \"header\": \"Änderungsprotokoll\"\n        }\n    },\n    \"configuration\": \"Konfiguration\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Datei} other {Dateien}}\",\n        \"galleries\": \"{count, plural, one {Galerie} other {Galerien}}\",\n        \"images\": \"{count, plural, one {Bild} other {Bilder}}\",\n        \"markers\": \"{count, plural, one {Markierung} other {Markierungen}}\",\n        \"performers\": \"{count, plural, one {Darsteller} other {Darsteller}}\",\n        \"scenes\": \"{count, plural, one {Szene} other {Szenen}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Studios}}\",\n        \"tags\": \"{count, plural, one {Tag} other {Tags}}\",\n        \"groups\": \"{count, plural, one {Gruppe} other {Gruppen}}\"\n    },\n    \"country\": \"Land\",\n    \"cover_image\": \"Titelbild\",\n    \"created_at\": \"Erstellt am\",\n    \"criterion\": {\n        \"greater_than\": \"Größer als\",\n        \"less_than\": \"Weniger als\",\n        \"value\": \"Wert\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"zwischen\",\n        \"equals\": \"ist\",\n        \"excludes\": \"schließt aus\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {Alle} other {{Tiefe}}})\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (ausgenommen {excludedString})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (ausgenommen {excludedString}) (+{depth, plural, =-1 {Alle} other {{Tiefe}}})\",\n        \"greater_than\": \"ist größer als\",\n        \"includes\": \"beinhaltet\",\n        \"includes_all\": \"beinhaltet alles\",\n        \"is_null\": \"ist nichts\",\n        \"less_than\": \"ist weniger als\",\n        \"matches_regex\": \"stimmt mit Regex überein\",\n        \"not_between\": \"nicht zwischen\",\n        \"not_equals\": \"ist nicht\",\n        \"not_matches_regex\": \"stimmt nicht mit Regex überein\",\n        \"not_null\": \"ist nicht nichts\"\n    },\n    \"custom\": \"Benutzerdefiniert\",\n    \"date\": \"Datum\",\n    \"date_format\": \"YYYY-MM-DD\",\n    \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n    \"death_date\": \"Todesdatum\",\n    \"death_year\": \"Todesjahr\",\n    \"descending\": \"Absteigend\",\n    \"description\": \"Beschreibung\",\n    \"detail\": \"Detail\",\n    \"details\": \"Details\",\n    \"developmentVersion\": \"Entwicklungsversion\",\n    \"dialogs\": {\n        \"create_new_entity\": \"Neues {entity} erstellen\",\n        \"delete_alert\": \"Folgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} werden dauerhaft gelöscht:\",\n        \"delete_confirm\": \"Möchten Sie {entityName} wirklich löschen?\",\n        \"delete_entity_desc\": \"{count, plural, one {Möchten Sie {singularEntity} wirklich löschen? Sofern die Datei nicht ebenfalls gelöscht werden soll, wird diese {singularEntity} beim Scannen wieder hinzugefügt.} other {Möchten Sie {pluralEntity} wirklich löschen? Sofern die Dateien nicht ebenfalls gelöscht werden sollen, werden diese {pluralEntity} beim Scannen wieder hinzugefügt.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Möchten Sie {singularEntity} wirklich löschen?} other {Möchten Sie diese {pluralEntity} wirklich löschen?}}\",\n        \"delete_entity_title\": \"{count, plural, one {Lösche {singularEntity}} other {Lösche {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"…plus allen Bilddateien, die keiner anderen Galerie angehängt sind.\",\n        \"delete_gallery_files\": \"Lösche Galerieordner/zip Datei und alle Bilder, die keiner anderen Galerie angehängt sind.\",\n        \"delete_object_desc\": \"Möchten Sie {count, plural, one {diese {singularEntity}} other {diese {pluralEntity}}} wirklich löschen?\",\n        \"delete_object_overflow\": \"…und {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} löschen\",\n        \"dont_show_until_updated\": \"Bis zum nächsten Update nicht mehr anzeigen\",\n        \"edit_entity_title\": \"Bearbeiten von {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Zugehörige Objekte in den Export einbeziehen\",\n        \"export_title\": \"Export\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Spalten\",\n                \"description\": \"Spalten- oder Reihenlayout.\",\n                \"row\": \"Zeilen\"\n            },\n            \"margin_desc\": \"Anzahl der Marge (in Pixeln) um jedes Bild.\"\n        },\n        \"lightbox\": {\n            \"delay\": \"Verzögerung (Sek)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Horizontal anpassen\",\n                \"fit_to_screen\": \"Vollbild\",\n                \"label\": \"Anzeigemodus\",\n                \"original\": \"Original\"\n            },\n            \"options\": \"Optionen\",\n            \"page_header\": \"Seite {page} / {total}\",\n            \"reset_zoom_on_nav\": \"Zoomstufe beim Bildwechsel zurücksetzen\",\n            \"scale_up\": {\n                \"description\": \"Skaliere kleinere Bilder auf Bildschirmgröße\",\n                \"label\": \"Skalieren, um zu passen\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Halte die Umschalttaste gedrückt, um einen anderen Modus vorübergehend zu verwenden.\",\n                \"label\": \"Scroll-Modus\",\n                \"pan_y\": \"Schwenkung Y\",\n                \"zoom\": \"Zoomen\"\n            },\n            \"disable_animation\": \"Deaktivieren Übergangsanimation zwischen Bildern\"\n        },\n        \"merge\": {\n            \"destination\": \"Ziel\",\n            \"empty_results\": \"Die Werte der Zielfelder bleiben unverändert.\",\n            \"source\": \"Quelle\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {Weise {singularEntity} neu zu} other {Weise {pluralEntity} neu zu}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Neu zuweisen an\"\n        },\n        \"scene_gen\": {\n            \"clip_previews\": \"Bild-Clip Vorschau\",\n            \"covers\": \"Szene-Cover\",\n            \"force_transcodes\": \"Transcode Erzeugung erzwingen\",\n            \"force_transcodes_tooltip\": \"Standardmäßig werden Transkodierungen nur erzeugt, wenn die Videodatei im Browser nicht unterstützt wird. Wenn diese Option aktiviert ist, werden Transkodierungen auch dann erstellt, wenn die Videodatei vom Browser unterstützt zu werden scheint.\",\n            \"image_previews\": \"Animierte Bildvorschauen\",\n            \"image_previews_tooltip\": \"Animierte WebP-Vorschaubilder, nur erforderlich, wenn der Vorschautyp auf Animiertes Bild eingestellt ist.\",\n            \"interactive_heatmap_speed\": \"Erzeugen von Heatmaps und Geschwindigkeiten für interaktive Szenen\",\n            \"marker_image_previews\": \"Animierte Vorschau für Markierungen\",\n            \"marker_image_previews_tooltip\": \"Animierte WebP-Vorschau für Markierungen, nur erforderlich, wenn der Vorschautyp auf Animiertes Bild eingestellt ist.\",\n            \"marker_screenshots\": \"Screenshots für Markierungen\",\n            \"marker_screenshots_tooltip\": \"Statische JPG-Bilder für Markierungen\",\n            \"markers\": \"Vorschau für Markierungen\",\n            \"markers_tooltip\": \"20-Sekunden-Videos, die zum angegebenen Zeitpunkt beginnen.\",\n            \"override_preview_generation_options\": \"Überschreibe Optionen zur Erstellung von Vorschauen\",\n            \"override_preview_generation_options_desc\": \"Überschreibe die Optionen zur Erstellung von Vorschauen für diesen Vorgang. Die Standardeinstellungen werden unter System -> \\tVorschau-Generierung festgelegt.\",\n            \"overwrite\": \"Vorhandene Dateien überschreiben\",\n            \"phash\": \"Perzeptuelle Hashes (zur Deduplizierung)\",\n            \"preview_exclude_end_time_desc\": \"Schließen Sie die letzten x Sekunden von der Szenenvorschau aus. Dies kann ein Wert in Sekunden oder ein Prozentsatz (zB 2%) der gesamten Szenendauer sein.\",\n            \"preview_exclude_end_time_head\": \"Endzeit ausschließen\",\n            \"preview_exclude_start_time_desc\": \"Schließen Sie die ersten x Sekunden von der Szenenvorschau aus. Dies kann ein Wert in Sekunden oder ein Prozentsatz (zB 2%) der gesamten Szenendauer sein.\",\n            \"preview_exclude_start_time_head\": \"Startzeit ausschließen\",\n            \"preview_generation_options\": \"Optionen für die Erstellung von Vorschauen\",\n            \"preview_options\": \"Vorschauoptionen\",\n            \"preview_preset_desc\": \"Die Voreinstellung regelt Größe, Qualität und Encoding-Zeit der Vorschaugenerierung. Einstellungen jenseits von „slow“ haben vernachlässigbare Vorteile und werden nicht empfohlen.\",\n            \"preview_preset_head\": \"Vorschau-Kodierungseinstellung\",\n            \"preview_seg_count_desc\": \"Anzahl der Segmente in Vorschaudateien.\",\n            \"preview_seg_count_head\": \"Anzahl der Segmente in der Vorschau\",\n            \"preview_seg_duration_desc\": \"Dauer jedes Vorschausegments in Sekunden.\",\n            \"preview_seg_duration_head\": \"Vorschau der Segmentdauer\",\n            \"sprites\": \"Szenen-Scrubber Sprites\",\n            \"sprites_tooltip\": \"Die Reihe von Bildern, die unter dem Video-Player angezeigt wird, um eine einfache Navigation zu ermöglichen.\",\n            \"transcodes\": \"Transkodierung\",\n            \"transcodes_tooltip\": \"MP4-Konvertierung von nicht unterstützten Videoformaten\",\n            \"video_previews\": \"Vorschau\",\n            \"video_previews_tooltip\": \"Videovorschauen, die abgespielt werden, wenn man den Mauszeiger über eine Szene bewegt\",\n            \"image_thumbnails\": \"Vorschaubilder\",\n            \"phash_tooltip\": \"Zur Duplikatbereinigung und Szenenerkennung\"\n        },\n        \"scenes_found\": \"{count} Szenen gefunden\",\n        \"scrape_entity_query\": \"{entity_type} Scrape-Abfrage\",\n        \"scrape_entity_title\": \"{entity_type} Scrape-Ergebnisse\",\n        \"scrape_results_existing\": \"Vorhanden\",\n        \"scrape_results_scraped\": \"Gescraped\",\n        \"set_image_url_title\": \"Bild URL\",\n        \"unsaved_changes\": \"Nicht gespeicherte Änderungen. Bist du sicher dass du die Seite verlassen willst?\",\n        \"clear_play_history_confirm\": \"Bist du sicher, dass du den Wiedergabeverlauf löschen möchtest?\",\n        \"performers_found\": \"{count} Darsteller:innen gefunden\",\n        \"clear_o_history_confirm\": \"Möchten Sie wirklich den O-Verlauf löschen?\",\n        \"overwrite_filter_warning\": \"Der gespeicherte Filter \\\"{entityName}\\\" wird überschrieben.\",\n        \"set_default_filter_confirm\": \"Sind Sie sicher, dass Sie diesen Filter als Standard festlegen möchten?\",\n        \"clear_o_history_confirm_sfw\": \"Bist du dir sicher das du den Verlauf löschen willst?\",\n        \"tags_found\": \"{count} Tags gefunden\",\n        \"stashid_exists_warning\": \"Die existierende Stash ID für diese Stash-Box wird ersetzt.\",\n        \"studios_found\": \"{count} Studios gefunden\",\n        \"scrape_results_missing\": \"fehlend\",\n        \"delete_alert_to_trash\": \"Folgendes {count, plural, one {{singularEntity}} other {{pluralEntity}}} wird in den Papierkorb verschoben:\"\n    },\n    \"dimensions\": \"Maße\",\n    \"director\": \"Regisseur\",\n    \"disambiguation\": \"Begriffsklärung\",\n    \"display_mode\": {\n        \"grid\": \"Gitter\",\n        \"list\": \"Liste\",\n        \"tagger\": \"Tagger\",\n        \"unknown\": \"Unbekannt\",\n        \"wall\": \"Wand\",\n        \"label_current\": \"Anzeigemodus: {current}\"\n    },\n    \"donate\": \"Spenden\",\n    \"dupe_check\": {\n        \"description\": \"Bei Levels unterhalb von 'Exact' kann die Berechnung länger dauern. Bei niedrigeren Genauigkeitsstufen können auch falsch positive Ergebnisse zurückgegeben werden.\",\n        \"duration_diff\": \"Maximale Laufzeitdifferenz\",\n        \"duration_options\": {\n            \"any\": \"Jede\",\n            \"equal\": \"Gleich\"\n        },\n        \"found_sets\": \"{setCount, plural, one{# Satz von Duplikaten gefunden.} other {# Sätze von Duplikaten gefunden.}}\",\n        \"options\": {\n            \"exact\": \"Genau\",\n            \"high\": \"Hoch\",\n            \"low\": \"Niedrig\",\n            \"medium\": \"Mittel\"\n        },\n        \"search_accuracy_label\": \"Suchgenauigkeit\",\n        \"title\": \"Szenen-Duplikate\",\n        \"only_select_matching_codecs\": \"Nur auswählen, wenn alle Codecs in der Duplikatgruppe übereinstimmen\",\n        \"select_all_but_largest_file\": \"Wähle jede Datei in jeder Duplikatgruppe aus, außer der größten Datei\",\n        \"select_all_but_largest_resolution\": \"Wähle jede Datei in jeder Duplikatgruppe aus, außer der Datei mit der höchsten Auflösung\",\n        \"select_none\": \"Nichts auswählen\",\n        \"select_oldest\": \"Wähle die älteste Datei in der Duplikatgruppe aus\",\n        \"select_options\": \"Optionen auswählen…\",\n        \"select_youngest\": \"Wähle die jüngste Datei in der Duplikatgruppe aus\"\n    },\n    \"duplicated_phash\": \"Dopplung (phash)\",\n    \"duration\": \"Dauer\",\n    \"effect_filters\": {\n        \"aspect\": \"Seitenverhältnis\",\n        \"blue\": \"Blau\",\n        \"blur\": \"Unschärfe\",\n        \"brightness\": \"Helligkeit\",\n        \"contrast\": \"Kontrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Grün\",\n        \"hue\": \"Farbton\",\n        \"name\": \"Filter\",\n        \"name_transforms\": \"Transformierung\",\n        \"red\": \"Rot\",\n        \"reset_filters\": \"Filter zurücksetzen\",\n        \"reset_transforms\": \"Transformationen zurücksetzen\",\n        \"rotate\": \"Drehen\",\n        \"rotate_left_and_scale\": \"Nach links drehen und skalieren\",\n        \"rotate_right_and_scale\": \"Nach rechts drehen und skalieren\",\n        \"saturation\": \"Sättigung\",\n        \"scale\": \"Skalieren\",\n        \"warmth\": \"Wärme\"\n    },\n    \"empty_server\": \"Fügen Sie Ihrem Server einige Szenen hinzu, um Empfehlungen auf dieser Seite anzuzeigen.\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"Bilderindex muss größer 0 sein\",\n        \"lazy_component_error_help\": \"Sollten Sie kürzlich ein Update für Stash durchgeführt haben, laden Sie bitte die Seite neu oder löschen Sie den Browser-Cache.\",\n        \"something_went_wrong\": \"Etwas ist schief gelaufen.\",\n        \"header\": \"Fehler\",\n        \"invalid_json_string\": \"Ungültiger JSON-Text: {error}\",\n        \"custom_fields\": {\n            \"duplicate_field\": \"Der Feldname muss einzigartig sein\",\n            \"field_name_length\": \"Der Feldname muss weniger als 65 Zeichen lang sein\",\n            \"field_name_required\": \"Der Feldname ist erforderlich\",\n            \"field_name_whitespace\": \"Feldname kann nicht mit Leerzeichen anfangen oder aufhören\"\n        },\n        \"invalid_javascript_string\": \"Ungültiger JavaScript-Code: {error}\",\n        \"loading_type\": \"Ladefehler {type}\"\n    },\n    \"ethnicity\": \"Ethnizität\",\n    \"existing_value\": \"vorhandener Wert\",\n    \"eye_color\": \"Augenfarbe\",\n    \"fake_tits\": \"Brustvergrößerungen\",\n    \"false\": \"Falsch\",\n    \"favourite\": \"Favorit\",\n    \"file\": \"Datei\",\n    \"file_count\": \"Dateianzahl\",\n    \"file_info\": \"Datei\",\n    \"file_mod_time\": \"Dateiänderungszeit\",\n    \"files\": \"Dateien\",\n    \"files_amount\": \"{value} Dateien\",\n    \"filesize\": \"Dateigröße\",\n    \"filter\": \"Filter\",\n    \"filter_name\": \"Filtername\",\n    \"filters\": \"Filter\",\n    \"folder\": \"Ordner\",\n    \"framerate\": \"Bildrate\",\n    \"frames_per_second\": \"{value} Bilder pro Sekunde\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Vorgefertigte Filter\",\n            \"saved_filter\": \"Gespeicherte Filter\"\n        }\n    },\n    \"galleries\": \"Galerien\",\n    \"gallery\": \"Galerie\",\n    \"gallery_count\": \"Galerienanzahl\",\n    \"gender\": \"Geschlecht\",\n    \"gender_types\": {\n        \"FEMALE\": \"Weiblich\",\n        \"INTERSEX\": \"Intersexuell\",\n        \"MALE\": \"Männlich\",\n        \"NON_BINARY\": \"Nicht-Binär\",\n        \"TRANSGENDER_FEMALE\": \"Trans* weiblich\",\n        \"TRANSGENDER_MALE\": \"Trans* männlich\"\n    },\n    \"hair_color\": \"Haarfarbe\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Verbindet\",\n        \"disconnected\": \"Getrennt\",\n        \"error\": \"Fehler bei der Verbindung zu Handy\",\n        \"missing\": \"Fehlt\",\n        \"ready\": \"Bereit\",\n        \"syncing\": \"Synchronisiert mit Server\",\n        \"uploading\": \"Skript wird hochgeladen\"\n    },\n    \"hasChapters\": \"Kapitel\",\n    \"hasMarkers\": \"Markierungen\",\n    \"height\": \"Größe\",\n    \"height_cm\": \"Höhe (cm)\",\n    \"help\": \"Hilfe\",\n    \"ignore_auto_tag\": \"Auto-Tag ignorieren\",\n    \"image\": \"Bild\",\n    \"image_count\": \"Bilderanzahl\",\n    \"image_index\": \"Bild #\",\n    \"images\": \"Bilder\",\n    \"include_parent_tags\": \"Übergeordnete Tags einbeziehen\",\n    \"include_sub_studios\": \"Untergeordnete Studios einbeziehen\",\n    \"include_sub_tags\": \"Untergeordnete Tags einbeziehen\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktiv\",\n    \"interactive_speed\": \"Interaktive Geschwindigkeit\",\n    \"isMissing\": \"Fehlt\",\n    \"last_played_at\": \"Zuletzt Abgespielt Am\",\n    \"library\": \"Bibliothek\",\n    \"loading\": {\n        \"generic\": \"Wird geladen…\",\n        \"plugins\": \"Lade Plugins…\"\n    },\n    \"marker_count\": \"Anzahl an Markierungen\",\n    \"markers\": \"Markierungen\",\n    \"measurements\": \"Maße\",\n    \"media_info\": {\n        \"audio_codec\": \"Audio-Codec\",\n        \"downloaded_from\": \"Heruntergeladen von\",\n        \"interactive_speed\": \"Interaktive Geschwindigkeit\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} bei der Produktion\"\n        },\n        \"phash\": \"PHashwert\",\n        \"play_count\": \"Anzahl Wiedergaben\",\n        \"play_duration\": \"Abspielzeit\",\n        \"stream\": \"Stream\",\n        \"video_codec\": \"Video-Codec\",\n        \"o_count\": \"O Anzahl\"\n    },\n    \"megabits_per_second\": \"{value} Megabit pro Sekunde\",\n    \"metadata\": \"Metadaten\",\n    \"name\": \"Name\",\n    \"new\": \"Neu\",\n    \"none\": \"Keiner\",\n    \"operations\": \"Operationen\",\n    \"organized\": \"Organisiert\",\n    \"pagination\": {\n        \"first\": \"Erste\",\n        \"last\": \"Letzte\",\n        \"next\": \"Nächste\",\n        \"previous\": \"Vorherige\",\n        \"current_total\": \"{current} von {total}\"\n    },\n    \"parent_of\": \"Übergeordnet von {children}\",\n    \"parent_studios\": \"Übergeordnete Studios\",\n    \"parent_tag_count\": \"Anzahl übergeordneter Tags\",\n    \"parent_tags\": \"Übergeordnete Tags\",\n    \"part_of\": \"Übergeordnet von {parent}\",\n    \"path\": \"Pfad\",\n    \"penis\": \"Penis\",\n    \"penis_length\": \"Penislänge\",\n    \"penis_length_cm\": \"Penislänge (cm)\",\n    \"perceptual_similarity\": \"Wahrnehmungsähnlichkeit (phash)\",\n    \"performer\": \"Darsteller\",\n    \"performer_age\": \"Alter der Darsteller\",\n    \"performer_count\": \"Darstelleranzahl\",\n    \"performer_favorite\": \"Darsteller favorisiert\",\n    \"performer_image\": \"Darsteller-Bild\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Neue Darsteller hinzufügen\",\n        \"any_names_entered_will_be_queried\": \"Alle eingetragenen Namen werden bei der stash-box Instanz nachgeschlagen und hinzugefügt, wenn gefunden. Nur exakte Übereinstimmungen werden als Treffer gewertet.\",\n        \"batch_add_performers\": \"Stapelverarbeitung für Darsteller\",\n        \"batch_update_performers\": \"Stapelverarbeitungsaktualisierung für Darsteller\",\n        \"current_page\": \"Aktuelle Seite\",\n        \"failed_to_save_performer\": \"Fehler beim Speichern der Darsteller \\\"{performer}\\\"\",\n        \"name_already_exists\": \"Name bereits vergeben\",\n        \"network_error\": \"Netzwerkfehler\",\n        \"no_results_found\": \"Keine Ergebnisse gefunden.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} Darsteller werden verarbeitet\",\n        \"performer_already_tagged\": \"Darsteller bereits getagged\",\n        \"performer_selection\": \"Darstellerauswahl\",\n        \"performer_successfully_tagged\": \"Darsteller erfolgreich getagged:\",\n        \"query_all_performers_in_the_database\": \"Alle Darsteller in der Datenbank\",\n        \"refresh_tagged_performers\": \"Aktualisieren getaggter Darsteller\",\n        \"refreshing_will_update_the_data\": \"Bei der Aktualisierung werden die Metadaten aller getaggten Darsteller über die stash-box-Instanz aktualisiert.\",\n        \"status_tagging_job_queued\": \"Status: Tagging-Auftrag in der Warteschlange\",\n        \"status_tagging_performers\": \"Status: Tagge Darsteller\",\n        \"tag_status\": \"Tag Status\",\n        \"to_use_the_performer_tagger\": \"Um den Darsteller-Tagger zu benutzen, muss eine stash-box Instanz konfiguriert sein.\",\n        \"untagged_performers\": \"Nicht getaggte Darsteller\",\n        \"update_performer\": \"Darsteller aktualisieren\",\n        \"update_performers\": \"Darsteller aktualisieren\",\n        \"updating_untagged_performers_description\": \"Bei der Aktualisierung von nicht getaggten Darstellern wird versucht die Metadaten alle Darsteller, welche keine StashID haben, zu aktualisieren.\",\n        \"performer_names_or_stashids_separated_by_comma\": \"Darstellernamen oder StashIDs, durch Komma getrennt\"\n    },\n    \"performer_tags\": \"Darsteller-Tags\",\n    \"performers\": \"Darsteller\",\n    \"piercings\": \"Piercings\",\n    \"play_count\": \"Anzahl der Videowiedergaben\",\n    \"play_duration\": \"Abspiellänge\",\n    \"primary_file\": \"Primäre Datei\",\n    \"queue\": \"Playlist\",\n    \"random\": \"Zufällig\",\n    \"rating\": \"Wertung\",\n    \"recently_added_objects\": \"Kürzlich hinzugefügte {objects}\",\n    \"recently_released_objects\": \"Kürzlich erschienene {objects}\",\n    \"release_notes\": \"Versionshinweise\",\n    \"resolution\": \"Auflösung\",\n    \"resume_time\": \"Zeit fortsetzen\",\n    \"scene\": \"Szene\",\n    \"sceneTagger\": \"Szenen-Tagger\",\n    \"scene_code\": \"Studio Code\",\n    \"scene_count\": \"Szenenanzahl\",\n    \"scene_created_at\": \"Szene angelegt am\",\n    \"scene_date\": \"Datum der Szene\",\n    \"scene_id\": \"Szenen-ID\",\n    \"scene_tags\": \"Szenen-Tags\",\n    \"scene_updated_at\": \"Szene geändert am\",\n    \"scenes\": \"Szenen\",\n    \"scenes_updated_at\": \"Szene aktualisiert am\",\n    \"search_filter\": {\n        \"edit_filter\": \"Filter editieren\",\n        \"name\": \"Filter\",\n        \"saved_filters\": \"Gespeicherte Filter\",\n        \"update_filter\": \"Filter aktualisieren\",\n        \"more_filter_criteria\": \"+{count} mehr\",\n        \"search_term\": \"Suchbegriff\"\n    },\n    \"second\": \"Sekunde\",\n    \"seconds\": \"Sekunden\",\n    \"settings\": \"Einstellungen\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Wir sind fast bereit die Konfiguration abzuschließen. Bitte bestätige die folgenden Einstellungen. Du kannst auf Zurück klicken, um etwas Falsches zu ändern. Wenn alles gut aussieht, klicke auf Bestätigen, um dein System zu erstellen.\",\n            \"blobs_directory\": \"Binärdaten-Verzeichnis\",\n            \"cache_directory\": \"Cache-Verzeichnis\",\n            \"configuration_file_location\": \"Ort der Konfigurationsdatei:\",\n            \"database_file_path\": \"Dateipfad der Datenbank\",\n            \"generated_directory\": \"Ordner der generierten Hilfsdateien\",\n            \"nearly_there\": \"Fast geschafft!\",\n            \"stash_library_directories\": \"Stash Bibliotheks-Ordner\",\n            \"blobs_use_database\": \"<benutzt Datenbank>\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Erstelle dein System\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Oh nein! Etwas ist schief gelaufen!\",\n            \"something_went_wrong_description\": \"Es sieht so aus, als gäbe es Probleme mit deinen Eingaben, klicke Zurück und repariere sie. Falls du nicht weißt was du falsch gemacht hast, helfen wir gerne auf {discordLink}. Solltest du dir sicher sein einen Bug gefunden zu haben, schau doch mal auf {githubLink} vorbei.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Etwas lief bei der Erstellung des Systems falsch. Hier ist die Fehlermeldung: {error}\",\n            \"unable_to_retrieve_system_status\": \"Systemstatus konnte nicht abgerufen werden: {error}\",\n            \"unexpected_error\": \"Ein unerwarteter Error ist aufgetreten: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Dateipfad\",\n            \"up_dir\": \"Ein Verzeichnis hoch\"\n        },\n        \"github_repository\": \"Github Repository\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Backup Datenbank Pfad (Leer lassen, um Backups aus zu schalten):\",\n            \"backup_recommended\": \"Es wird dringend empfohlen ein Backup deiner Datenbank vor der Migration anzufertigen. Wir können das für dich erledigen, indem wir eine Kopie deiner Datenbank in <code>{defaultBackupPath}</code> anfertigen.\",\n            \"migrating_database\": \"Migration der Datenbank im Gange\",\n            \"migration_failed\": \"Migration der Datenbank fehlgeschlagen\",\n            \"migration_failed_error\": \"Der folgende Fehler ist bei der Migration der Datenbank aufgetreten:\",\n            \"migration_failed_help\": \"Bitte führe nötige Korrekturen durch und probiere es erneut. Falls du nicht weißt was du falsch gemacht hast, helfen wir gerne auf {discordLink}. Solltest du dir sicher sein einen Bug gefunden zu haben, schau doch mal auf {githubLink} vorbei.\",\n            \"migration_irreversible_warning\": \"Der Migrationsprozess des Datenbankschemas ist irreversibel. Nachdem sie ausgeführt wurde, ist deine Datenbank inkompatibel mit älteren Versionen von Stash.\",\n            \"migration_notes\": \"Anmerkungen zur Migration\",\n            \"migration_required\": \"Migration nötig\",\n            \"perform_schema_migration\": \"Führe Migration des Datenbankschemas durch\",\n            \"schema_too_old\": \"Das Schema deiner aktuellen Stash-Datenbank ist Version <strong>{databaseSchema}</strong> und muss auf Version <strong>{appSchema}</strong> migriert werden. Die aktuelle Version von Stash wird nicht ohne Migration der Datenbank funktionieren können. Wenn Sie nicht migrieren möchten, müssen Sie ein Downgrade auf eine Version durchführen, die Ihrem Datenbankschema entspricht.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"Datenbank-Dateiname (Leer für Standardwert)\",\n            \"description\": \"Als nächstes müssen wir festhalten, wo wir deine Sammlung finden können und wo wir unsere Datenbank, generierten Hilfsdateien und Cache speichern dürfen. Diese Einstellungen lassen sich später auch noch ändern.\",\n            \"path_to_cache_directory_empty_for_default\": \"Pfad zum Cache-Verzeichnis (leer für Voreinstellung)\",\n            \"path_to_generated_directory_empty_for_default\": \"Pfad zum Ordner der Hilfsdateien (Leer für Standardwert)\",\n            \"set_up_your_paths\": \"Setze die Dateipfade\",\n            \"stash_alert\": \"Es wurde kein Bibliotheks-Pfad gesetzt. Somit werden keine Dateien in Stash eingescannt. Bist du dir sicher?\",\n            \"where_can_stash_store_blobs\": \"Wo darf Stash die Binärdaten-Blobs speichern?\",\n            \"where_can_stash_store_blobs_description\": \"Stash kann Binärdaten wie Szene-Cover, Darsteller-, Studio- und Tag-Bilder entweder in der Datenbank oder auf dem Dateisystem speichern. Als Voreinstellung wird Stash ein Verzeichnis <code>blobs</code> im Ordner erstellen in dem auch die Konfigurationsdatei gespeichert ist. Wenn Sie dies ändern möchten, geben Sie bitte einen absoluten oder relativen Pfad an. Stash wird dieses Verzeichnis für Sie erstellen, sollte es nicht bereits existieren.\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Wenn Sie alternativ die Daten in der Datenbank speichern wollen, dann lassen Sie das Feld leer. <strong>Notiz:</strong> Dies wird die Datenbank start vergrößern und Migrierungsaufgaben werden länger dauern.\",\n            \"where_can_stash_store_cache_files\": \"Wo darf Stash Cache-Dateien zwischenspeichern?\",\n            \"where_can_stash_store_cache_files_description\": \"Um einige Funktionen wie HLS/DASH Live-Transkodierung zu nutzen, muss Stash über ein Cache-Verzeichnis als temporären Zwischenspeicher verfügen. Als Voreinstellung wird Stash ein Verzeichnis <code>cache</code> im Ordner erstellen in dem auch die Konfigurationsdatei gespeichert ist. Wenn Sie dies ändern möchten, geben Sie bitte einen absoluten oder relativen Pfad an. Stash wird dieses Verzeichnis für Sie erstellen, sollte es nicht bereits existieren.\",\n            \"where_can_stash_store_its_database\": \"Wo darf Stash seine Datenbank abspeichern?\",\n            \"where_can_stash_store_its_database_description\": \"Stash nutzt eine SQLite-Datenbank, um Metadaten über deine Sammlung zu speichern. Standardmäßig wird diese als <code>stash-go.sqlite</code> in dem Ordner gespeichert, in dem auf deine Konfigurationsdatei liegt. Wenn du das ändern möchtest, gebe bitte einen absoluten oder relativen (gegenüber der aktuellen working directory) Pfad mit Dateinamen an.\",\n            \"where_can_stash_store_its_database_warning\": \"ACHTUNG: Ein Speicherort abseits des Systems auf dem Stash ausgeführt wird (z.B. speichern der Datenbank auf einem Netzwerkspeicher während Stash auf einem anderen Computer ausgeführt wird) ist <strong>nicht unterstützt</strong>! SQLite ist nicht für Nutzung über das Netzwerk ausgelegt und der Versuch, dies zu tun, kann sehr leicht dazu führen, dass Ihre gesamte Datenbank beschädigt wird.\",\n            \"where_can_stash_store_its_generated_content\": \"Wo darf Stash seine generierten Hilfsdateien abspeichern?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Um Thumbnails, Previews und Sprites zur Verfügung zu stellen, generiert Stash diese aus deinen Videos und Bildern. Das schließt auch Transkodierungen von nicht unterstützten Dateiformaten mit ein. Standardmäßig wird Stash diese im Ordner <code>generated</code> abspeichern, der sich am Ort der Konfigurationsdatei befindet. Wenn du das ändern möchtest, gebe bitte einen absoluten oder relativen (gegenüber der aktuellen working directory) Pfad an. Stash wird den Ordner erstellen, sollte er noch nicht existieren.\",\n            \"where_is_your_porn_located\": \"Wo finden wir deine Sammlung?\",\n            \"where_is_your_porn_located_description\": \"Füge Ordner hinzu, in denen sich deine Videos und Bilder befinden. Stash wird diese Ordner nutzen, um Videos und Bilder in das System einzupflegen.\",\n            \"path_to_blobs_directory_empty_for_default\": \"Pfad zum Verzeichnis der blobs (standardmäßig leer)\",\n            \"store_blobs_in_database\": \"blobs in der Datenbank speichern\",\n            \"sfw_content_settings\": \"Benutzt du stash auch für SFW Inhalte?\",\n            \"sfw_content_settings_description\": \"Stash kann verwendet werden, um SFW Inhalte wie Fotografie, Kunst, Comics und mehr zu verwalten. Wenn du diese Option aktivierst, wird die Oberfläche für SFW Inhalte angepasst.\",\n            \"use_sfw_content_mode\": \"Benutze den Modus für SFW Inhalte\"\n        },\n        \"stash_setup_wizard\": \"Einrichtungshelfer für Stash\",\n        \"success\": {\n            \"getting_help\": \"Hilfe\",\n            \"help_links\": \"Solltest du Probleme , Fragen oder Anregungen haben, öffne gerne eine issue auf {githubLink} oder teile sie der Community auf {discordLink} mit.\",\n            \"in_app_manual_explained\": \"Du wirst angehalten das In-App-Benutzerhandbuch aufzusuchen, welches du über den {icon}-Icon in der oberen rechten Ecke findest\",\n            \"next_config_step_one\": \"Wir bringen dich als nächstes zu den Optionen von Stash. Diese Seiten erlauben es dir zu bestimmen, welche Dateien du einpflegen möchtest oder eben auch nicht, einen Benutzernamen und Passwort anzulegen um dein System zu schützen und haben außerdem noch viele weitere Optionen.\",\n            \"next_config_step_two\": \"Wenn du mit deinen Angaben zufrieden bist, kannst du anfangen, indem du Stash deine Dateien einpflegen lässt. Dazu klicke zunächst auf <code>{localized_task}</code> und dann auf <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Schau doch mal auf unserer {open_collective_link} vorbei, um herauszufinden, wie du zu der fortwährenden Entwicklung von Stash beitragen kannst.\",\n            \"support_us\": \"Unterstütze uns\",\n            \"thanks_for_trying_stash\": \"Danke fürs Ausprobieren von Stash!\",\n            \"welcome_contrib\": \"Außerdem sind Beiträge in Form von Code (Bug-Fixes, Verbesserungen, Features), Tests, Bug-Reports, Ideen für Features und Verbesserungen, sowie User-Support immer willkommen. Details dazu im entsprechenden Kapitel des In-App-Benutzerhandbuchs.\",\n            \"your_system_has_been_created\": \"Geschafft! Dein System wurde erstellt!\",\n            \"download_ffmpeg\": \"ffmpeg herunterladen\",\n            \"missing_ffmpeg\": \"Die erforderliche <code>ffmpeg</code>-Binärdatei fehlt. Du kannst sie automatisch in deinem Konfigurationsverzeichnis herunterladen, indem du das Kästchen unten auswählst. Alternativ kannst du Pfade zu den <code>ffmpeg</code>- und <code>ffprobe</code>-Binärdateien in den Systemeinstellungen angeben. Diese Binärdateien müssen vorhanden sein, damit Stash funktioniert.\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash versucht zunächst seine Konfigurationsdatei (<code>config.yml</code>) in dem aktuellen Arbeitsverzeichnis zu finden, wenn das nicht gelingt fällt es auf <code>$HOME/.stash/config.yml</code> (bei Windows ist das <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>) zurück. Du kannst Stash auch einen Pfad beim Start durch die Kommandozeilen-Option <code>-c '<path to config file>'</code> or <code>--config '<path to config file>'</code> vorgeben.\",\n            \"in_current_stash_directory\": \"Im Verzeichnis <code>$HOME/.stash</code>:\",\n            \"in_the_current_working_directory\": \"Im <code>{path}</code>, dem derzeitigen Arbeitsverzeichnis:\",\n            \"next_step\": \"Nachdem das alles aus dem Weg ist, sind wir jetzt bereit ein neues System zu erstellen. Wähle dazu zunächst aus wo du die Konfigurationsdatei speichern möchtest und klicke auf Weiter.\",\n            \"store_stash_config\": \"Wo möchtest du die Stash Konfigurationsdatei speichern?\",\n            \"unable_to_locate_config\": \"Wenn du das hier liest, konnte Stash keine existierende Konfiguration finden. Dieser Wizard wird dich deshalb durch den Prozess führen, eine neue Konfiguration anzulegen.\",\n            \"unexpected_explained\": \"Wenn du diesen Wizard nicht erwartest, starte Stash im korrekten Arbeitsverzeichnis neu oder setze den Pfad zur Konfigurationsdatei mit der Kommandozeilenoption <code>-c</code>.\",\n            \"in_the_current_working_directory_disabled_macos\": \"Nicht unterstützt, wenn <code>Stash.app</code> ausgeführt wird<br></br>Führe <code>stash-macos</code> aus, um im Arbeitsverzeichnis einzurichten\",\n            \"in_the_current_working_directory_disabled\": \"Im <code>{path}</code>, dem Arbeitsverzeichnis:\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash wird den folgenden Pfad für die Konfigurationsdatei verwenden: <code>{path}</code>\",\n            \"next_step\": \"Wenn du bereit bist ein neues System anzulegen, klicke Weiter.\",\n            \"unable_to_locate_specified_config\": \"Wenn du das hier liest, konnte Stash die Konfigurationsdatei, welche spezifiziert wurde, nicht finden. Dieser Wizard wird dich deshalb durch den Prozess führen, eine neue Konfiguration anzulegen.\"\n        },\n        \"welcome_to_stash\": \"Willkommen zu Stash\"\n    },\n    \"stash_id\": \"Stash-ID\",\n    \"stash_id_endpoint\": \"Stash ID Endpunkt URL\",\n    \"stash_ids\": \"Stash IDs\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Gehe zu {endpoint_name}, um Entwurf zu begutachten.\",\n        \"selected_stash_box\": \"Ausgewählter Stash-Box Endpunkt\",\n        \"submission_failed\": \"Einreichen fehlgeschlagen\",\n        \"submission_successful\": \"Einreichen erfolgreich\",\n        \"submit_update\": \"Existiert bereits in {endpoint_name}\",\n        \"source\": \"Stash-Box Quelle\"\n    },\n    \"statistics\": \"Statistiken\",\n    \"stats\": {\n        \"image_size\": \"Bildspeicher\",\n        \"scenes_duration\": \"Szenendauer\",\n        \"scenes_size\": \"Szenenspeicher\",\n        \"scenes_played\": \"Szenen Abgespielt\",\n        \"total_o_count\": \"Insgesamt Anzahl der Os\",\n        \"total_play_count\": \"Gesamtanzahl der Wiedergaben\",\n        \"total_play_duration\": \"Spieldauer insgesamt\",\n        \"total_o_count_sfw\": \"Gefällt-Mir gesamt\"\n    },\n    \"status\": \"Status: {statusText}\",\n    \"studio\": \"Studio\",\n    \"studio_depth\": \"Ebenen (leer für alle)\",\n    \"studios\": \"Studios\",\n    \"sub_tag_count\": \"Anzahl an untergeordneten Tags\",\n    \"sub_tag_of\": \"Sub-Tag von {parent}\",\n    \"sub_tags\": \"Untergeordnete Tags\",\n    \"subsidiary_studios\": \"Untergeordnete Studios\",\n    \"synopsis\": \"Zusammenfassung\",\n    \"tag\": \"Tag\",\n    \"tag_count\": \"Tag-Anzahl\",\n    \"tags\": \"Tags\",\n    \"tattoos\": \"Tätowierungen\",\n    \"title\": \"Titel\",\n    \"toast\": {\n        \"added_entity\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} hinzugefügt\",\n        \"added_generation_job_to_queue\": \"Generierungsaufgabe zur Warteschlange hinzugefügt\",\n        \"created_entity\": \"{entity} erstellt\",\n        \"default_filter_set\": \"Standardfiltersatz\",\n        \"delete_past_tense\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} gelöscht\",\n        \"generating_screenshot\": \"Screenshot wird erstellt…\",\n        \"image_index_too_large\": \"Fehler: Bild-Index ist größer als die Anzahl der Bilder der Gallerie\",\n        \"merged_scenes\": \"Zusammengefasste Szene\",\n        \"merged_tags\": \"Zusammengeführte Tags\",\n        \"reassign_past_tense\": \"Datei neu zugewiesen\",\n        \"removed_entity\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} entfernt\",\n        \"rescanning_entity\": \"Erneutes Scannen von {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"{entity} gespeichert\",\n        \"started_auto_tagging\": \"Automatisches Tagging gestartet\",\n        \"started_generating\": \"Generierung gestartet\",\n        \"started_importing\": \"Import gestartet\",\n        \"updated_entity\": \"{entity} aktualisiert\",\n        \"merged_performers\": \"Zusammengefügte Künstler\"\n    },\n    \"total\": \"Gesamt\",\n    \"true\": \"Wahr\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Typ\",\n    \"updated_at\": \"Aktualisiert am\",\n    \"url\": \"URL\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} muss die Form YYY,YYY-MM, oder YYYY-MM-DD haben\",\n        \"required\": \"${path} ist ein notwendiges Feld\",\n        \"end_time_before_start_time\": \"Der Endzeitpunkt muss nach oder am Startzeitpunkt sein\",\n        \"unique\": \"${path} muss einzigartig sein\",\n        \"blank\": \"${path} darf nicht leer sein\"\n    },\n    \"videos\": \"Videos\",\n    \"view_all\": \"Alle ansehen\",\n    \"weight\": \"Gewicht\",\n    \"weight_kg\": \"Gewicht (kg)\",\n    \"years_old\": \"Jahre alt\",\n    \"zip_file_count\": \"Anzahl der Zip-Dateien\",\n    \"audio_codec\": \"Audio Codec\",\n    \"group\": \"Gruppe\",\n    \"groups\": \"Gruppen\",\n    \"time_end\": \"Endzeitpunkt\",\n    \"studio_tagger\": {\n        \"create_or_tag_parent_studios\": \"Fehlende übergeordnete Studios erstellen oder bestehende übergeordnete Studios taggen\",\n        \"update_studio\": \"Studio Updaten\",\n        \"any_names_entered_will_be_queried\": \"Alle eingetragenen Namen werden bei der stash-box Instanz nachgeschlagen und hinzugefügt, wenn gefunden. Nur exakte Übereinstimmungen werden als Treffer gewertet.\",\n        \"config\": {\n            \"create_parent_desc\": \"Erstelle fehlende übergeordnete Studios oder tagge und aktualisiere Daten/Bilder für bestehende übergeordnete Studios mit genau passenden Namen\",\n            \"create_parent_label\": \"Übergeordnete Studios erstellen\"\n        },\n        \"network_error\": \"Netzwerkfehler\",\n        \"query_all_studios_in_the_database\": \"Alle Studios in der Datenbank\",\n        \"refresh_tagged_studios\": \"Getaggte Studios aktualisieren\",\n        \"refreshing_will_update_the_data\": \"Aktualisieren wird die daten von allen getaggten Studios der stash-box Instanz updaten.\",\n        \"add_new_studios\": \"Neue Studios hinzufügen\",\n        \"batch_add_studios\": \"Stapelverarbeitung: Studios hinzufügen\",\n        \"batch_update_studios\": \"Stapelverarbeitung: Studios updaten\",\n        \"current_page\": \"Aktuelle Seite\",\n        \"failed_to_save_studio\": \"Speichern des Studios \\\"{studio}\\\" Fehlgeschlagen\",\n        \"status_tagging_job_queued\": \"Status: Tagging Job in der Warteschlange\",\n        \"studio_already_tagged\": \"Studio schon getaggt\",\n        \"studio_selection\": \"Ausgewählte Studios\",\n        \"to_use_the_studio_tagger\": \"Um den Studiotagger zu benutzen, muss eine stash-box Instanz konfiguriert werden.\",\n        \"untagged_studios\": \"Nicht getaggte Studios\",\n        \"update_studios\": \"Studios Updaten\",\n        \"updating_untagged_studios_description\": \"Das Aktualisieren von nicht getaggten Studios versucht, alle Studios abzugleichen, die keine stashid haben, und deren Metadaten zu aktualisieren.\",\n        \"status_tagging_studios\": \"Status: Studios am Taggen\",\n        \"number_of_studios_will_be_processed\": \"Es wird/werden {studio_count} Studio(s) verarbeitet\",\n        \"studio_successfully_tagged\": \"Studios erfolgreich getaggt\",\n        \"tag_status\": \"Tag Status\",\n        \"name_already_exists\": \"Name existiert bereits\",\n        \"no_results_found\": \"Keine Ergebnisse gefunden.\",\n        \"studio_names_or_stashids_separated_by_comma\": \"Studionamen oder StashID, getrennt durch Komma\"\n    },\n    \"parent_studio\": \"Übergeordnetes Studio\",\n    \"package_manager\": {\n        \"edit_source\": \"Quelle bearbeiten\",\n        \"no_packages\": \"Keine Pakete gefunden\",\n        \"package\": \"Paket\",\n        \"selected_only\": \"Nur Auswahl\",\n        \"show_all\": \"Zeige Alles\",\n        \"confirm_uninstall\": \"Bist du sicher, dass du {number} Pakete deinstallieren möchtest?\",\n        \"description\": \"Beschreibung\",\n        \"hide_unselected\": \"Nicht ausgewählte ausblenden\",\n        \"install\": \"Installieren\",\n        \"installed_version\": \"Installierte Version\",\n        \"latest_version\": \"Aktuellste Version\",\n        \"no_sources\": \"Es sind keine Quellen konfiguriert\",\n        \"no_upgradable\": \"Keine aktualisierbaren Pakete gefunden\",\n        \"required_by\": \"Von {packages} benötigt\",\n        \"source\": {\n            \"local_path\": {\n                \"heading\": \"Lokaler Dateipfad\",\n                \"description\": \"Relativer Pfad zum Speichern von Paketen für diese Quelle. Beachten Sie, dass eine Änderung, das manuelle verschieben der Pakete erfordert.\"\n            },\n            \"name\": \"Name\",\n            \"url\": \"Quellen URL\"\n        },\n        \"uninstall\": \"Deinstallieren\",\n        \"unknown\": \"<unbekannt>\",\n        \"update\": \"Update\",\n        \"version\": \"Version\",\n        \"check_for_updates\": \"Nach Updates suchen\",\n        \"confirm_delete_source\": \"Sind Sie sicher, dass Sie die Quelle {name} ({url}) löschen wollen?\",\n        \"add_source\": \"Quelle hinzufügen\"\n    },\n    \"photographer\": \"Fotograf\",\n    \"playdate_recorded_no\": \"Keine Wiedergabedaten gespeichert\",\n    \"urls\": \"URLs\",\n    \"play_history\": \"Wiedergabeverlauf\",\n    \"plays\": \"{value} wiedergaben\",\n    \"primary_tag\": \"Haupt-Tag\",\n    \"studio_count\": \"Studio Anzahl\",\n    \"unknown_date\": \"Unbekanntes Datum\",\n    \"criterion_modifier_values\": {\n        \"only\": \"Einzige\",\n        \"any\": \"Beliebig\",\n        \"any_of\": \"Irgendeine von\",\n        \"none\": \"Keine\"\n    },\n    \"o_count\": \"O Anzahl\",\n    \"studio_tags\": \"Studio Tags\",\n    \"custom_fields\": {\n        \"title\": \"Benutzerdefinierte Felder\",\n        \"value\": \"Wert\",\n        \"field\": \"Feld\",\n        \"criteria_format_string\": \"{criterion} (custom field) {modifierString} {valueString}\",\n        \"criteria_format_string_others\": \"{criterion} (custom field) {modifierString} {valueString} (+{others} others)\"\n    },\n    \"distance\": \"Distanz\",\n    \"group_count\": \"Gruppenanzahl\",\n    \"group_scene_number\": \"Szenennummer\",\n    \"include_sub_group_content\": \"Inhalt von Untergruppen einschließen\",\n    \"sub_group\": \"Untergruppe\",\n    \"sub_group_count\": \"Untergruppen Anzahl\",\n    \"sub_group_of\": \"Untergruppen von {parent}\",\n    \"sub_group_order\": \"Untergruppen Ordnung\",\n    \"subsidiary_studio_count\": \"Anzahl der Tochterstudios\",\n    \"sub_groups\": \"Untergruppen\",\n    \"tag_parent_tooltip\": \"Hat die übergeordneten Tags\",\n    \"time\": \"Zeit\",\n    \"video_codec\": \"Video Codec\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"WebSocket-Verbindung konnte nicht hergestellt werden: Sieh dir die Browser-Konsole für Details an\",\n        \"websocket_connection_reestablished\": \"Websocket-Verbindung wiederhergestellt\"\n    },\n    \"containing_group\": \"Enthaltende Gruppe\",\n    \"containing_groups\": \"Enthaltende Gruppen\",\n    \"include_sub_tag_content\": \"Inhalt von Untertags einschließen\",\n    \"index_of_total\": \"{index} von {total}\",\n    \"include_sub_studio_content\": \"Inhalt von Unterstudios einschließen\",\n    \"last_o_at\": \"Letztes O\",\n    \"o_history\": \"O Verlauf\",\n    \"odate_recorded_no\": \"Kein O Datum Aufgezeichnet\",\n    \"orientation\": \"Orientierung\",\n    \"containing_group_count\": \"Enthaltende Gruppen Anzahl\",\n    \"history\": \"Verlauf\",\n    \"tag_sub_tag_tooltip\": \"Hat Untertags\",\n    \"include_sub_groups\": \"Untergruppen einbeziehen\",\n    \"studio_and_parent\": \"Studio & Mutterstudio\",\n    \"eta\": \"Edited to add\",\n    \"login\": {\n        \"login\": \"Login\",\n        \"internal_error\": \"Unvorhergesehener interner Fehler. Weitere Details in den Logs\",\n        \"password\": \"Passwort\",\n        \"invalid_credentials\": \"Ungültiger Nutzername oder Passwort\",\n        \"username\": \"Benutzername\"\n    },\n    \"age_on_date\": \"bei Produktion\",\n    \"sort_name\": \"Namen sortieren\",\n    \"scenes_duration\": \"Szenen Dauer\",\n    \"last_o_at_sfw\": \"Letztes mal ein Gefällt mir gegeben am\",\n    \"o_count_sfw\": \"Gefällt mir\",\n    \"o_history_sfw\": \"Gefällt mir Verlauf\",\n    \"odate_recorded_no_sfw\": \"Kein Gefällt mir Datum vermerkt\",\n    \"stashbox_search\": {\n        \"header\": \"Suche {entityType} von StashBox\",\n        \"no_results\": \"Keine Ergebnisse gfunden.\",\n        \"placeholder_name_or_id\": \"{entityType} Name oder StashID...\",\n        \"select_stashbox\": \"StashBox auswählen...\"\n    },\n    \"duplicated\": \"Dupliziert\",\n    \"unsupported_criteria\": \"ungültige Kriterien\",\n    \"career_end\": \"Ende der Karriere\",\n    \"career_start\": \"Beginn der Karriere\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/en-GB.json",
    "content": "{\n  \"actions\": {\n    \"add\": \"Add\",\n    \"add_directory\": \"Add directory\",\n    \"add_entity\": \"Add {entityType}\",\n    \"add_manual_date\": \"Add manual date\",\n    \"add_sub_groups\": \"Add Sub-Groups\",\n    \"add_o\": \"Add O\",\n    \"add_play\": \"Add play\",\n    \"add_stash_id\": \"Add Stash ID\",\n    \"add_to_entity\": \"Add to {entityType}\",\n    \"allow\": \"Allow\",\n    \"allow_temporarily\": \"Allow temporarily\",\n    \"anonymise\": \"Anonymise\",\n    \"apply\": \"Apply\",\n    \"assign_stashid_to_parent_studio\": \"Assign Stash ID to existing parent studio and update metadata\",\n    \"auto_tag\": \"Auto tag\",\n    \"backup\": \"Backup\",\n    \"browse_for_image\": \"Browse for image…\",\n    \"cancel\": \"Cancel\",\n    \"choose_date\": \"Choose a date\",\n    \"clean\": \"Clean\",\n    \"clean_generated\": \"Clean generated files\",\n    \"clear\": \"Clear\",\n    \"clear_back_image\": \"Clear back image\",\n    \"clear_date_data\": \"Clear date data\",\n    \"clear_front_image\": \"Clear front image\",\n    \"clear_image\": \"Clear Image\",\n    \"close\": \"Close\",\n    \"confirm\": \"Confirm\",\n    \"continue\": \"Continue\",\n    \"copy_to_clipboard\": \"Copy to clipboard\",\n    \"create\": \"Create\",\n    \"create_chapters\": \"Create Chapter\",\n    \"create_entity\": \"Create {entityType}\",\n    \"create_marker\": \"Create Marker\",\n    \"create_new\": \"Create new\",\n    \"create_parent_studio\": \"Create parent studio\",\n    \"create_parent_tag\": \"Create parent tag\",\n    \"created_entity\": \"Created {entity_type}: {entity_name}\",\n    \"customise\": \"Customise\",\n    \"delete\": \"Delete\",\n    \"delete_entity\": \"Delete {entityType}\",\n    \"delete_file\": \"Delete file\",\n    \"delete_file_and_funscript\": \"Delete file (and funscript)\",\n    \"delete_generated_supporting_files\": \"Delete generated supporting files\",\n    \"disable\": \"Disable\",\n    \"disallow\": \"Disallow\",\n    \"download\": \"Download\",\n    \"download_anonymised\": \"Download anonymised\",\n    \"download_backup\": \"Download backup\",\n    \"edit\": \"Edit\",\n    \"edit_entity\": \"Edit {entityType}\",\n    \"enable\": \"Enable\",\n    \"encoding_image\": \"Encoding image…\",\n    \"exclude_lowercase\": \"exclude\",\n    \"export\": \"Export\",\n    \"export_all\": \"Export all…\",\n    \"find\": \"Find\",\n    \"finish\": \"Finish\",\n    \"from_clipboard\": \"From clipboard\",\n    \"from_file\": \"From file…\",\n    \"from_url\": \"From URL…\",\n    \"full_export\": \"Full export\",\n    \"full_import\": \"Full import\",\n    \"generate\": \"Generate\",\n    \"generate_thumb_default\": \"Generate default thumbnail\",\n    \"generate_thumb_from_current\": \"Generate thumbnail from current\",\n    \"hash_migration\": \"hash migration\",\n    \"hide\": \"Hide\",\n    \"hide_configuration\": \"Hide Configuration\",\n    \"identify\": \"Identify\",\n    \"ignore\": \"Ignore\",\n    \"import\": \"Import…\",\n    \"import_from_file\": \"Import from file\",\n    \"load\": \"Load\",\n    \"load_filter\": \"Load filter\",\n    \"logout\": \"Log out\",\n    \"make_primary\": \"Make Primary\",\n    \"merge\": \"Merge\",\n    \"migrate_blobs\": \"Migrate blobs\",\n    \"migrate_scene_screenshots\": \"Migrate scene screenshots\",\n    \"next_action\": \"Next\",\n    \"not_running\": \"not running\",\n    \"open_in_external_player\": \"Open in external player\",\n    \"open_random\": \"Open Random\",\n    \"optimise_database\": \"Optimise database\",\n    \"overwrite\": \"Overwrite\",\n    \"play\": \"Play\",\n    \"play_random\": \"Play Random\",\n    \"play_selected\": \"Play selected\",\n    \"preview\": \"Preview\",\n    \"previous_action\": \"Back\",\n    \"reassign\": \"Reassign\",\n    \"refresh\": \"Refresh\",\n    \"reload\": \"Reload\",\n    \"reload_plugins\": \"Reload plugins\",\n    \"reload_scrapers\": \"Reload scrapers\",\n    \"remove\": \"Remove\",\n    \"remove_date\": \"Remove date\",\n    \"remove_from_containing_group\": \"Remove from Group\",\n    \"remove_from_gallery\": \"Remove from Gallery\",\n    \"rename_gen_files\": \"Rename generated files\",\n    \"reveal_in_file_manager\": \"Reveal in File Manager\",\n    \"rescan\": \"Rescan\",\n    \"reset_play_duration\": \"Reset play duration\",\n    \"reset_resume_time\": \"Reset resume time\",\n    \"reset_cover\": \"Restore Default Cover\",\n    \"reshuffle\": \"Reshuffle\",\n    \"running\": \"running\",\n    \"save\": \"Save\",\n    \"save_and_new\": \"Save & New\",\n    \"save_delete_settings\": \"Use these options by default when deleting\",\n    \"save_filter\": \"Save filter\",\n    \"scan\": \"Scan\",\n    \"scrape\": \"Scrape\",\n    \"scrape_query\": \"Scrape query\",\n    \"scrape_scene_fragment\": \"Scrape by fragment\",\n    \"scrape_with\": \"Scrape with…\",\n    \"search\": \"Search\",\n    \"select_all\": \"Select All\",\n    \"select_directory\": \"Select directory\",\n    \"select_entity\": \"Select {entityType}\",\n    \"select_folders\": \"Select folders\",\n    \"select_none\": \"Select None\",\n    \"invert_selection\": \"Invert Selection\",\n    \"selective_auto_tag\": \"Selective auto tag\",\n    \"selective_clean\": \"Selective clean\",\n    \"selective_generate\": \"Selective generate\",\n    \"selective_scan\": \"Selective scan\",\n    \"set_as_default\": \"Set as default\",\n    \"set_back_image\": \"Back image…\",\n    \"set_cover\": \"Set as Cover\",\n    \"set_front_image\": \"Front image…\",\n    \"set_image\": \"Set image…\",\n    \"show\": \"Show\",\n    \"show_configuration\": \"Show Configuration\",\n    \"show_results\": \"Show results\",\n    \"show_count_results\": \"Show {count} results\",\n    \"sidebar\": {\n      \"close\": \"Close sidebar\",\n      \"open\": \"Open sidebar\",\n      \"toggle\": \"Toggle sidebar\"\n    },\n    \"skip\": \"Skip\",\n    \"split\": \"Split\",\n    \"stop\": \"Stop\",\n    \"submit\": \"Submit\",\n    \"submit_stash_box\": \"Submit to Stash-Box\",\n    \"submit_update\": \"Submit update\",\n    \"swap\": \"Swap\",\n    \"tasks\": {\n      \"clean_confirm_message\": \"Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.\",\n      \"dry_mode_selected\": \"Dry Mode selected. No actual deleting will take place, only logging.\",\n      \"import_warning\": \"Are you sure you want to import? This will delete the database and re-import from your exported metadata.\"\n    },\n    \"temp_disable\": \"Disable temporarily…\",\n    \"temp_enable\": \"Enable temporarily…\",\n    \"unset\": \"Unset\",\n    \"use_default\": \"Use default\",\n    \"view_history\": \"View history\",\n    \"view_random\": \"View Random\"\n  },\n  \"actions_name\": \"Actions\",\n  \"age\": \"Age\",\n  \"age_on_date\": \"{age} at production\",\n  \"aliases\": \"Aliases\",\n  \"all\": \"all\",\n  \"also_known_as\": \"Also known as\",\n  \"appears_with\": \"Appears With\",\n  \"ascending\": \"Ascending\",\n  \"audio_codec\": \"Audio Codec\",\n  \"average_resolution\": \"Average Resolution\",\n  \"between_and\": \"and\",\n  \"birth_year\": \"Birth Year\",\n  \"birthdate\": \"Birthdate\",\n  \"bitrate\": \"Bit Rate\",\n  \"blobs_storage_type\": {\n    \"database\": \"Database\",\n    \"filesystem\": \"Filesystem\"\n  },\n  \"captions\": \"Captions\",\n  \"career_end\": \"Career End\",\n  \"career_length\": \"Career Length\",\n  \"career_start\": \"Career Start\",\n  \"chapters\": \"Chapters\",\n  \"circumcised\": \"Circumcised\",\n  \"circumcised_types\": {\n    \"CUT\": \"Cut\",\n    \"UNCUT\": \"Uncut\"\n  },\n  \"component_tagger\": {\n    \"config\": {\n      \"active_instance\": \"Active stash-box instance:\",\n      \"blacklist_desc\": \"Blacklist items are excluded from queries. Note that they are regular expressions and also case-insensitive. Certain characters must be escaped with a backslash: {chars_require_escape}\",\n      \"blacklist_label\": \"Blacklist\",\n      \"errors\": {\n        \"blacklist_duplicate\": \"Duplicate blacklist item\"\n      },\n      \"mark_organized_desc\": \"Immediately mark the scene as Organized after the Save button is clicked.\",\n      \"mark_organized_label\": \"Mark as Organized on save\",\n      \"performer_genders\": {\n        \"heading\": \"Performer genders\",\n        \"description\": \"Performers with these genders will be shown when tagging scenes.\"\n      },\n      \"query_mode_auto\": \"Auto\",\n      \"query_mode_auto_desc\": \"Uses metadata if present, or filename\",\n      \"query_mode_dir\": \"Dir\",\n      \"query_mode_dir_desc\": \"Only uses parent directory of video file\",\n      \"query_mode_filename\": \"Filename\",\n      \"query_mode_filename_desc\": \"Only uses filename\",\n      \"query_mode_label\": \"Query Mode\",\n      \"query_mode_metadata\": \"Metadata\",\n      \"query_mode_metadata_desc\": \"Only uses metadata\",\n      \"query_mode_path\": \"Path\",\n      \"query_mode_path_desc\": \"Uses entire file path\",\n      \"set_cover_desc\": \"Replace the scene cover if one is found.\",\n      \"set_cover_label\": \"Set scene cover image\",\n      \"set_tag_desc\": \"Attach tags to scene, either by overwriting or merging with existing tags on scene.\",\n      \"set_tag_label\": \"Set tags\",\n      \"source\": \"Source\"\n    },\n    \"noun_query\": \"Query\",\n    \"results\": {\n      \"duration_off\": \"Duration off by at least {number}s\",\n      \"duration_unknown\": \"Duration unknown\",\n      \"fp_found\": \"{fpCount, plural, =0 {No new fingerprint matches found} other {# new fingerprint matches found}}\",\n      \"fp_matches\": \"Duration is a match\",\n      \"fp_matches_multi\": \"Duration matches {matchCount}/{durationsLength} fingerprints\",\n      \"hash_matches\": \"{hash_type} is a match\",\n      \"match_failed_already_tagged\": \"Scene already tagged\",\n      \"match_failed_no_result\": \"No results found\",\n      \"match_success\": \"Scene successfully tagged\",\n      \"phash_matches\": \"{count} PHashes match\",\n      \"unnamed\": \"Unnamed\"\n    },\n    \"verb_add_as_alias\": \"Add scraped name as alias\",\n    \"verb_link_existing\": \"Link to existing\",\n    \"verb_match_fp\": \"Match Fingerprints\",\n    \"verb_match_tag\": \"Match Tag\",\n    \"verb_matched\": \"Matched\",\n    \"verb_scrape_all\": \"Scrape All\",\n    \"verb_scrape_selected\": \"Scrape Selected\",\n    \"verb_submit_fp\": \"Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}\",\n    \"verb_toggle_config\": \"{toggle} {configuration}\",\n    \"verb_toggle_unmatched\": \"{toggle} unmatched scenes\"\n  },\n  \"config\": {\n    \"about\": {\n      \"build_hash\": \"Build hash:\",\n      \"build_time\": \"Build time:\",\n      \"check_for_new_version\": \"Check for new version\",\n      \"latest_version\": \"Latest Version\",\n      \"latest_version_build_hash\": \"Latest Version Build Hash:\",\n      \"new_version_notice\": \"[NEW]\",\n      \"release_date\": \"Release date:\",\n      \"stash_discord\": \"Join our {url} channel\",\n      \"stash_home\": \"Stash home at {url}\",\n      \"stash_open_collective\": \"Support us through {url}\",\n      \"stash_wiki\": \"Stash {url} page\",\n      \"version\": \"Version\"\n    },\n    \"advanced_mode\": \"Advanced mode\",\n    \"application_paths\": {\n      \"heading\": \"Application Paths\"\n    },\n    \"categories\": {\n      \"about\": \"About\",\n      \"changelog\": \"Changelog\",\n      \"interface\": \"Interface\",\n      \"logs\": \"Logs\",\n      \"metadata_providers\": \"Metadata Providers\",\n      \"plugins\": \"Plugins\",\n      \"scraping\": \"Scraping\",\n      \"security\": \"Security\",\n      \"services\": \"Services\",\n      \"system\": \"System\",\n      \"tasks\": \"Tasks\",\n      \"tools\": \"Tools\"\n    },\n    \"changelog\": {\n      \"header\": \"Changelog\"\n    },\n    \"dlna\": {\n      \"allow_temp_ip\": \"Allow {tempIP}\",\n      \"allowed_ip_addresses\": \"Allowed IP addresses\",\n      \"allowed_ip_temporarily\": \"Allowed IP temporarily\",\n      \"default_ip_whitelist\": \"Default IP whitelist\",\n      \"default_ip_whitelist_desc\": \"Default IP addresses allow to access DLNA. Use {wildcard} to allow all IP addresses.\",\n      \"disabled_dlna_temporarily\": \"Disabled DLNA temporarily\",\n      \"disallowed_ip\": \"Disallowed IP\",\n      \"enabled_by_default\": \"Enabled by default\",\n      \"enabled_dlna_temporarily\": \"Enabled DLNA temporarily\",\n      \"network_interfaces\": \"Interfaces\",\n      \"network_interfaces_desc\": \"Interfaces to expose DLNA server on. An empty list results in running on all interfaces. Requires DLNA restart after changing.\",\n      \"recent_ip_addresses\": \"Recent IP addresses\",\n      \"server_display_name\": \"Server display name\",\n      \"server_display_name_desc\": \"Display name for the DLNA server. Defaults to {server_name} if empty.\",\n      \"server_port\": \"Server port\",\n      \"server_port_desc\": \"Port to run the DLNA server on. Requires DLNA restart after changing.\",\n      \"successfully_cancelled_temporary_behaviour\": \"Successfully cancelled temporary behaviour\",\n      \"until_restart\": \"until restart\",\n      \"video_sort_order\": \"Default video sort order\",\n      \"video_sort_order_desc\": \"Order to sort videos by default.\"\n    },\n    \"general\": {\n      \"auth\": {\n        \"api_key\": \"API key\",\n        \"api_key_desc\": \"API key for external systems. Only required when username/password is configured. Username must be saved before generating API key.\",\n        \"authentication\": \"Authentication\",\n        \"clear_api_key\": \"Clear API key\",\n        \"credentials\": {\n          \"description\": \"Credentials to restrict access to Stash.\",\n          \"heading\": \"Credentials\"\n        },\n        \"generate_api_key\": \"Generate API key\",\n        \"log_file\": \"Log file\",\n        \"log_file_desc\": \"Path to the file to output logging to. Blank to disable file logging. Requires restart.\",\n        \"log_http\": \"Log HTTP access\",\n        \"log_http_desc\": \"Logs HTTP access to the terminal. Requires restart.\",\n        \"log_to_terminal\": \"Log to terminal\",\n        \"log_to_terminal_desc\": \"Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart.\",\n        \"log_file_max_size\": \"Maximum log size\",\n        \"log_file_max_size_desc\": \"Maximum size in megabytes of the log file before it is compressed. 0MB is disabled. Requires restart.\",\n        \"maximum_session_age\": \"Maximum session age\",\n        \"maximum_session_age_desc\": \"Maximum idle time before a login session is expired, in seconds. Requires restart.\",\n        \"password\": \"Password\",\n        \"password_desc\": \"Password to access Stash. Leave blank to disable user authentication\",\n        \"stash-box_integration\": \"Stash-box integration\",\n        \"username\": \"Username\",\n        \"username_desc\": \"Username to access Stash. Leave blank to disable user authentication\"\n      },\n      \"backup_directory_path\": {\n        \"description\": \"Directory location for SQLite database file backups.\",\n        \"heading\": \"Backup directory path\"\n      },\n      \"delete_trash_path\": {\n        \"description\": \"Path where deleted files will be moved to instead of being permanently deleted. Leave empty to permanently delete files.\",\n        \"heading\": \"Trash path\"\n      },\n      \"blobs_path\": {\n        \"description\": \"Where in the filesystem to store binary data. Applicable only when using the Filesystem blob storage type. WARNING: changing this requires manually moving existing data.\",\n        \"heading\": \"Binary data filesystem path\"\n      },\n      \"blobs_storage\": {\n        \"description\": \"Where to store binary data such as scene covers, performer, studio and tag images. After changing this value, the existing data must be migrated using the Migrate blobs tasks. See Tasks page for migration.\",\n        \"heading\": \"Binary data storage type\"\n      },\n      \"cache_location\": \"Directory location of the cache. Required if streaming using HLS (such as on Apple devices) or DASH.\",\n      \"cache_path_head\": \"Cache path\",\n      \"calculate_md5_and_ohash_desc\": \"Calculate MD5 checksum in addition to oshash. Enabling will cause initial scans to be slower. File naming hash must be set to oshash to disable MD5 calculation.\",\n      \"calculate_md5_and_ohash_label\": \"Calculate MD5 for videos\",\n      \"check_for_insecure_certificates\": \"Check for insecure certificates\",\n      \"check_for_insecure_certificates_desc\": \"Some sites use insecure SSL certificates. When unticked the scraper skips the insecure certificates check and allows scraping of those sites. If you get a certificate error when scraping untick this.\",\n      \"chrome_cdp_path\": \"Chrome CDP path\",\n      \"chrome_cdp_path_desc\": \"File path to the Chrome executable, or a remote address (starting with http:// or https://, for example http://localhost:9222/json/version) to a Chrome instance.\",\n      \"create_galleries_from_folders_desc\": \"If true, creates galleries from folders containing images by default. Create a file called .forcegallery or .nogallery in a folder to override this setting.\",\n      \"create_galleries_from_folders_label\": \"Create galleries from folders containing images\",\n      \"database\": \"Database\",\n      \"db_path_head\": \"Database path\",\n      \"directory_locations_to_your_content\": \"Directory locations to your content\",\n      \"excluded_image_gallery_patterns_desc\": \"Regexps of image and gallery files/paths to exclude from Scan and add to Clean tasks.\",\n      \"excluded_image_gallery_patterns_head\": \"Excluded image/gallery patterns\",\n      \"excluded_video_patterns_desc\": \"Regexps of video files/paths to exclude from Scan and add to Clean tasks.\",\n      \"excluded_video_patterns_head\": \"Excluded video patterns\",\n      \"ffmpeg\": {\n        \"download_ffmpeg\": {\n          \"description\": \"Downloads FFmpeg into the configuration directory and clears the ffmpeg and ffprobe paths to resolve from the configuration directory.\",\n          \"heading\": \"Download FFmpeg\"\n        },\n        \"ffmpeg_path\": {\n          \"description\": \"Path to the ffmpeg executable (not just the folder). If empty, ffmpeg will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash.\",\n          \"heading\": \"FFmpeg executable path\"\n        },\n        \"ffprobe_path\": {\n          \"description\": \"Path to the ffprobe executable (not just the folder). If empty, ffprobe will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash.\",\n          \"heading\": \"FFprobe executable path\"\n        },\n        \"hardware_acceleration\": {\n          \"desc\": \"Uses available hardware to encode video for live transcoding.\",\n          \"heading\": \"FFmpeg hardware encoding\"\n        },\n        \"live_transcode\": {\n          \"input_args\": {\n            \"desc\": \"Advanced: Additional arguments to pass to ffmpeg before the input field when live transcoding video.\",\n            \"heading\": \"FFmpeg live transcode input arguments\"\n          },\n          \"output_args\": {\n            \"desc\": \"Advanced: Additional arguments to pass to ffmpeg before the output field when live transcoding video.\",\n            \"heading\": \"FFmpeg live transcode output arguments\"\n          }\n        },\n        \"transcode\": {\n          \"input_args\": {\n            \"desc\": \"Advanced: Additional arguments to pass to ffmpeg before the input field when generating video.\",\n            \"heading\": \"FFmpeg transcode input arguments\"\n          },\n          \"output_args\": {\n            \"desc\": \"Advanced: Additional arguments to pass to ffmpeg before the output field when generating video.\",\n            \"heading\": \"FFmpeg transcode output arguments\"\n          }\n        }\n      },\n      \"funscript_heatmap_draw_range\": \"Include range in generated heatmaps\",\n      \"funscript_heatmap_draw_range_desc\": \"Draw range of motion on the y-axis of the generated heatmap. Existing heatmaps will need to be regenerated after changing.\",\n      \"gallery_cover_regex_desc\": \"Regexps used to identify an image as gallery cover.\",\n      \"gallery_cover_regex_label\": \"Gallery cover pattern\",\n      \"gallery_ext_desc\": \"Comma-delimited list of file extensions that will be identified as gallery ZIP files.\",\n      \"gallery_ext_head\": \"Gallery ZIP extensions\",\n      \"generated_file_naming_hash_desc\": \"Use MD5 or oshash for generated file naming. Changing this requires that all scenes have the applicable MD5/oshash value populated. After changing this value, existing generated files will need to be migrated or regenerated. See Tasks page for migration.\",\n      \"generated_file_naming_hash_head\": \"Generated file naming hash\",\n      \"generated_files_location\": \"Directory location for the generated files (scene markers, scene previews, sprites, etc).\",\n      \"generated_path_head\": \"Generated path\",\n      \"hashing\": \"Hashing\",\n      \"heatmap_generation\": \"Funscript Heatmap Generation\",\n      \"image_ext_desc\": \"Comma-delimited list of file extensions that will be identified as images.\",\n      \"image_ext_head\": \"Image extensions\",\n      \"include_audio_desc\": \"Includes audio stream when generating previews.\",\n      \"include_audio_head\": \"Include audio\",\n      \"logging\": \"Logging\",\n      \"maximum_streaming_transcode_size_desc\": \"Maximum size for transcoded streams.\",\n      \"maximum_streaming_transcode_size_head\": \"Maximum streaming transcode size\",\n      \"maximum_transcode_size_desc\": \"Maximum size for generated transcodes.\",\n      \"maximum_transcode_size_head\": \"Maximum transcode size\",\n      \"metadata_path\": {\n        \"description\": \"Directory location used when performing a full export or import.\",\n        \"heading\": \"Metadata path\"\n      },\n      \"number_of_parallel_task_for_scan_generation_desc\": \"Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% CPU utilisation will decrease performance and potentially cause other issues.\",\n      \"number_of_parallel_task_for_scan_generation_head\": \"Number of parallel task for scan/generation\",\n      \"parallel_scan_head\": \"Parallel Scan/Generation\",\n      \"plugins_path\": {\n        \"description\": \"Directory location of plugin configuration files.\",\n        \"heading\": \"Plugins path\"\n      },\n      \"preview_generation\": \"Preview Generation\",\n      \"python_path\": {\n        \"description\": \"Path to the python executable (not just the folder). Used for script scrapers and plugins. If blank, Python will be resolved from the environment.\",\n        \"heading\": \"Python executable path\"\n      },\n      \"scraper_user_agent\": \"Scraper User-Agent\",\n      \"scraper_user_agent_desc\": \"User-Agent string used during scrape HTTP requests.\",\n      \"scrapers_path\": {\n        \"description\": \"Directory location of scraper configuration files.\",\n        \"heading\": \"Scrapers path\"\n      },\n      \"scraping\": \"Scraping\",\n      \"sprite_generation_head\": \"Sprite Generation\",\n      \"sprite_interval_desc\": \"Time between each generated sprite in seconds.\",\n      \"sprite_interval_head\": \"Sprite interval\",\n      \"sprite_maximum_desc\": \"Maximum number of sprites to be generated for a scene. Set to 0 to disable the limit.\",\n      \"sprite_maximum_head\": \"Maximum sprites\",\n      \"sprite_minimum_desc\": \"Minimum number of sprites to be generated for a scene\",\n      \"sprite_minimum_head\": \"Minimum sprites\",\n      \"sprite_screenshot_size_desc\": \"Desired size of each sprite in pixels.\",\n      \"sprite_screenshot_size_head\": \"Sprite size\",\n      \"sqlite_location\": \"File location for the SQLite database (requires restart). WARNING: storing the database on a different system to where the Stash server is run from (i.e. over the network) is unsupported!\",\n      \"use_custom_sprite_interval_head\": \"Use custom sprite interval\",\n      \"use_custom_sprite_interval_desc\": \"Enable the custom sprite interval according to the settings below.\",\n      \"video_ext_desc\": \"Comma-delimited list of file extensions that will be identified as videos.\",\n      \"video_ext_head\": \"Video extensions\",\n      \"video_head\": \"Video\"\n    },\n    \"library\": {\n      \"exclusions\": \"Exclusions\",\n      \"gallery_and_image_options\": \"Gallery and Image Options\",\n      \"media_content_extensions\": \"Media Content Extensions\"\n    },\n    \"logs\": {\n      \"log_level\": \"Log level\"\n    },\n    \"plugins\": {\n      \"available_plugins\": \"Available Plugins\",\n      \"hooks\": \"Hooks\",\n      \"installed_plugins\": \"Installed Plugins\",\n      \"triggers_on\": \"Triggers on\"\n    },\n    \"scraping\": {\n      \"available_scrapers\": \"Available Scrapers\",\n      \"entity_metadata\": \"{entityType} Metadata\",\n      \"entity_scrapers\": \"{entityType} scrapers\",\n      \"excluded_tag_patterns_desc\": \"Regexps of tag names to exclude from scraping results.\",\n      \"excluded_tag_patterns_head\": \"Excluded tag patterns\",\n      \"installed_scrapers\": \"Installed Scrapers\",\n      \"scraper\": \"Scraper\",\n      \"scrapers\": \"Scrapers\",\n      \"search_by_name\": \"Search by name\",\n      \"supported_types\": \"Supported types\",\n      \"supported_urls\": \"URLs\"\n    },\n    \"stashbox\": {\n      \"add_instance\": \"Add stash-box instance\",\n      \"api_key\": \"API key\",\n      \"description\": \"Stash-box facilitates automated tagging of scenes and performers based on fingerprints and filenames.\\nEndpoint and API key can be found on your account page on the stash-box instance. Names are required when more than one instance is added.\",\n      \"endpoint\": \"Endpoint\",\n      \"graphql_endpoint\": \"GraphQL endpoint\",\n      \"max_requests_per_minute\": \"Max requests per minute\",\n      \"max_requests_per_minute_description\": \"Uses default value of {defaultValue} if set to 0\",\n      \"name\": \"Name\",\n      \"title\": \"Stash-box Endpoints\"\n    },\n    \"system\": {\n      \"transcoding\": \"Transcoding\"\n    },\n    \"tasks\": {\n      \"added_job_to_queue\": \"Added {operation_name} to job queue\",\n      \"anonymise_and_download\": \"Makes an anonymised copy of the database and downloads the resulting file.\",\n      \"anonymise_database\": \"Makes a copy of the database to the backups directory, anonymising all sensitive data. This can then be provided to others for troubleshooting and debugging purposes. The original database is not modified. Anonymised database uses the filename format {filename_format}.\",\n      \"anonymising_database\": \"Anonymising database\",\n      \"auto_tag\": {\n        \"auto_tagging_all_paths\": \"Auto tagging all paths\",\n        \"auto_tagging_paths\": \"Auto tagging the following paths\"\n      },\n      \"auto_tag_based_on_filenames\": \"Auto tag content based on file paths.\",\n      \"auto_tagging\": \"Auto tagging\",\n      \"backing_up_database\": \"Backing up database\",\n      \"backup_and_download\": \"Performs a backup of the database and downloads the resulting file.\",\n      \"backup_database\": {\n        \"description\": \"Performs a backup of the database and blob files.\",\n        \"destination\": \"Destination\",\n        \"download\": \"Download backup\",\n        \"include_blobs\": \"Include blobs in backup\",\n        \"include_blobs_desc\": \"Disable to only backup the SQLite database file.\",\n        \"sqlite\": \"Backup file will be a copy of the SQLite database file, with the filename {filename_format}\",\n        \"to_directory\": \"To {directory}\",\n        \"warning_blobs\": \"Blob files will not be included in the backup. This means that to succesfully restore from the backup, the blob files must be present in the blob storage location.\",\n        \"zip\": \"SQLite database file and blob files will be zipped into a single file, with the filename {filename_format}\"\n      },\n      \"cleanup_desc\": \"Check for missing files and remove them from the database. This is a destructive action.\",\n      \"clean_ignore_zip_contents\": \"Ignore zip file contents\",\n      \"clean_ignore_zip_contents_desc\": \"Faster but will miss files removed inside zip files. Safe to enable if you don't delete files within zips.\",\n      \"clean_generated\": {\n        \"blob_files\": \"Blob files\",\n        \"description\": \"Removes generated files without a corresponding database entry.\",\n        \"image_thumbnails\": \"Image thumbnails\",\n        \"image_thumbnails_desc\": \"Image thumbnails and clips\",\n        \"markers\": \"Marker previews\",\n        \"previews\": \"Scene previews\",\n        \"previews_desc\": \"Scene previews and thumbnails\",\n        \"sprites\": \"Scene sprites\",\n        \"transcodes\": \"Scene transcodes\"\n      },\n      \"data_management\": \"Data management\",\n      \"defaults_set\": \"Defaults have been set and will be used when clicking the {action} button on the Tasks page.\",\n      \"dont_include_file_extension_as_part_of_the_title\": \"Don't include file extension as part of the title\",\n      \"empty_queue\": \"No tasks are currently running.\",\n      \"export_to_json\": \"Exports the database content into JSON format in the metadata directory.\",\n      \"generate\": {\n        \"generating_from_paths\": \"Generating for scenes from the following paths\",\n        \"generating_scenes\": \"Generating for {num} {scene}\"\n      },\n      \"generate_clip_previews_during_scan\": \"Generate previews for image clips\",\n      \"generate_desc\": \"Generate supporting image, sprite, video, vtt and other files.\",\n      \"generate_image_phashes_during_scan\": \"Generate image perceptual hashes\",\n      \"generate_image_phashes_during_scan_tooltip\": \"For deduplication and identification.\",\n      \"generate_phashes_during_scan\": \"Generate video perceptual hashes\",\n      \"generate_phashes_during_scan_tooltip\": \"For deduplication and scene identification.\",\n      \"generate_previews_during_scan\": \"Generate animated image previews\",\n      \"generate_previews_during_scan_tooltip\": \"Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.\",\n      \"generate_sprites_during_scan\": \"Generate scrubber sprites\",\n      \"generate_sprites_during_scan_tooltip\": \"The set of images displayed below the video player for easy navigation.\",\n      \"generate_thumbnails_during_scan\": \"Generate thumbnails for images\",\n      \"generate_video_covers_during_scan\": \"Generate scene covers\",\n      \"generate_video_previews_during_scan\": \"Generate previews\",\n      \"generate_video_previews_during_scan_tooltip\": \"Generate video previews which play when hovering over a scene\",\n      \"generated_content\": \"Generated Content\",\n      \"identify\": {\n        \"and_create_missing\": \"and create missing\",\n        \"create_missing\": \"Create missing\",\n        \"default_options\": \"Default Options\",\n        \"description\": \"Automatically set scene metadata using stash-box and scraper sources.\",\n        \"explicit_set_description\": \"The following options will be used where not overridden in the source-specific options.\",\n        \"field\": \"Field\",\n        \"field_behaviour\": \"{strategy} {field}\",\n        \"field_options\": \"Field Options\",\n        \"heading\": \"Identify\",\n        \"identifying_from_paths\": \"Identifying scenes from the following paths\",\n        \"identifying_scenes\": \"Identifying {num} {scene}\",\n        \"include_male_performers\": \"Include male performers\",\n        \"performer_genders\": \"Performer genders\",\n        \"performer_genders_desc\": \"Performers with selected genders will be included during identification.\",\n        \"set_cover_images\": \"Set cover images\",\n        \"set_organized\": \"Set organised flag\",\n        \"skip_multiple_matches\": \"Skip matches that have more than one result\",\n        \"skip_multiple_matches_tooltip\": \"If this is not enabled and more than one result is returned, one will be randomly chosen to match\",\n        \"skip_single_name_performers\": \"Skip single name performers with no disambiguation\",\n        \"skip_single_name_performers_tooltip\": \"If this is not enabled, performers that are often generic like Samantha or Olga will be matched\",\n        \"source\": \"Source\",\n        \"source_options\": \"{source} Options\",\n        \"sources\": \"Sources\",\n        \"strategy\": \"Strategy\",\n        \"tag_skipped_matches\": \"Tag skipped matches with\",\n        \"tag_skipped_matches_tooltip\": \"Create a tag like 'Identify: Multiple Matches' that you can filter for in the Scene Tagger view and choose the correct match by hand\",\n        \"tag_skipped_performer_tooltip\": \"Create a tag like 'Identify: Single Name Performer' that you can filter for in the Scene Tagger view and choose how you want to handle these performers\",\n        \"tag_skipped_performers\": \"Tag skipped performers with\"\n      },\n      \"import_from_exported_json\": \"Import from exported JSON in the metadata directory. Wipes the existing database.\",\n      \"incremental_import\": \"Incremental import from a supplied export zip file.\",\n      \"job_queue\": \"Task Queue\",\n      \"maintenance\": \"Maintenance\",\n      \"migrate_blobs\": {\n        \"delete_old\": \"Delete old data\",\n        \"description\": \"Migrate blobs to the current blob storage system. This migration should be run after changing the blob storage system. Can optionally delete the old data after migration.\"\n      },\n      \"migrate_hash_files\": \"Used after changing the Generated file naming hash to rename existing generated files to the new hash format.\",\n      \"migrate_scene_screenshots\": {\n        \"delete_files\": \"Delete screenshot files\",\n        \"description\": \"Migrate scene screenshots into the new blob storage system. This migration should be run after migrating an existing system to 0.20. Can optionally delete the old screenshots after migration.\",\n        \"overwrite_existing\": \"Overwrite existing blobs with screenshot data\"\n      },\n      \"migrations\": \"Migrations\",\n      \"only_dry_run\": \"Only perform a dry run. Don't remove anything\",\n      \"optimise_database\": \"Attempt to improve performance by analysing and then rebuilding the entire database file.\",\n      \"optimise_database_warning\": \"Warning: while this task is running, any operations that modify the database will fail, and depending on your database size, it could take several minutes to complete. It also requires at the very minimum as much free disk space as your database is large, but 1.5x is recommended.\",\n      \"plugin_tasks\": \"Plugin Tasks\",\n      \"rescan\": \"Rescan files\",\n      \"rescan_tooltip\": \"Rescan every file in the path. Used to force update file metadata and rescan zip files.\",\n      \"scan\": {\n        \"scanning_all_paths\": \"Scanning all paths\",\n        \"scanning_paths\": \"Scanning the following paths\"\n      },\n      \"scan_for_content_desc\": \"Scan for new content and add it to the database.\",\n      \"set_name_date_details_from_metadata_if_present\": \"Set name, date, details from embedded file metadata\"\n    },\n    \"tools\": {\n      \"graphql_playground\": \"GraphQL playground\",\n      \"heading\": \"Tools\",\n      \"scene_duplicate_checker\": \"Scene duplicate checker\",\n      \"scene_filename_parser\": {\n        \"add_field\": \"Add Field\",\n        \"capitalize_title\": \"Capitalize title\",\n        \"display_fields\": \"Display fields\",\n        \"escape_chars\": \"Use \\\\ to escape literal characters\",\n        \"filename\": \"Filename\",\n        \"filename_pattern\": \"Filename Pattern\",\n        \"ignore_organized\": \"Ignore organized scenes\",\n        \"ignored_words\": \"Ignored words\",\n        \"matches_with\": \"Matches with {i}\",\n        \"select_parser_recipe\": \"Select Parser Recipe\",\n        \"title\": \"Scene filename parser\",\n        \"whitespace_chars\": \"Whitespace characters\",\n        \"whitespace_chars_desc\": \"These characters will be replaced with whitespace in the title\"\n      },\n      \"scene_tools\": \"Scene Tools\"\n    },\n    \"ui\": {\n      \"abbreviate_counters\": {\n        \"description\": \"Abbreviate counters in cards and details view pages, for example \\\"1831\\\" will get formated to \\\"1.8K\\\".\",\n        \"heading\": \"Abbreviate counters\"\n      },\n      \"basic_settings\": \"Basic Settings\",\n      \"custom_css\": {\n        \"description\": \"Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom CSS and future releases of Stash.\",\n        \"heading\": \"Custom CSS\",\n        \"option_label\": \"Custom CSS enabled\"\n      },\n      \"troubleshooting_mode\": {\n        \"button\": \"Troubleshooting mode\",\n        \"dialog_title\": \"Enable troubleshooting mode\",\n        \"dialog_description\": \"This will temporarily disable all customizations to help diagnose issues:\",\n        \"dialog_item_plugins\": \"All plugins\",\n        \"dialog_item_css\": \"Custom CSS\",\n        \"dialog_item_js\": \"Custom JavaScript\",\n        \"dialog_item_locales\": \"Custom locales\",\n        \"dialog_log_level\": \"Log level will be set to Debug for detailed diagnostics.\",\n        \"dialog_reload_note\": \"The page will reload automatically.\",\n        \"enable\": \"Enable & Reload\",\n        \"overlay_message\": \"Troubleshooting mode is active - all customizations are disabled\",\n        \"exit\": \"Exit\"\n      },\n      \"custom_javascript\": {\n        \"description\": \"Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom JavaScript and future releases of Stash.\",\n        \"heading\": \"Custom JavaScript\",\n        \"option_label\": \"Custom JavaScript enabled\"\n      },\n      \"custom_locales\": {\n        \"description\": \"Override individual locale strings. See https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for the master list. Page must be reloaded for changes to take effect.\",\n        \"heading\": \"Custom localisation\",\n        \"option_label\": \"Custom localisation enabled\"\n      },\n      \"custom_title\": {\n        \"description\": \"Custom text to append to the page title. If empty, defaults to 'Stash'.\",\n        \"heading\": \"Custom title\"\n      },\n      \"delete_options\": {\n        \"description\": \"Default settings when deleting images, galleries, and scenes.\",\n        \"heading\": \"Delete Options\",\n        \"options\": {\n          \"delete_file\": \"Delete file by default\",\n          \"delete_generated_supporting_files\": \"Delete generated supporting files by default\"\n        }\n      },\n      \"desktop_integration\": {\n        \"desktop_integration\": \"Desktop Integration\",\n        \"notifications_enabled\": \"Enable notifications\",\n        \"send_desktop_notifications_for_events\": \"Send desktop notifications for events.\",\n        \"skip_opening_browser\": \"Skip opening browser\",\n        \"skip_opening_browser_on_startup\": \"Skip auto-opening browser during startup.\"\n      },\n      \"detail\": {\n        \"compact_expanded_details\": {\n          \"description\": \"When enabled, this option will present expanded details while maintaining a compact presentation.\",\n          \"heading\": \"Compact expanded details\"\n        },\n        \"enable_background_image\": {\n          \"description\": \"Display background image on detail page.\",\n          \"heading\": \"Enable background image\"\n        },\n        \"heading\": \"Detail Page\",\n        \"show_all_details\": {\n          \"description\": \"When enabled, all content details will be shown by default and each detail item will fit under a single column.\",\n          \"heading\": \"Show all details\"\n        }\n      },\n      \"editing\": {\n        \"disable_dropdown_create\": {\n          \"description\": \"Remove the ability to create new objects from the dropdown selectors.\",\n          \"heading\": \"Disable dropdown create\"\n        },\n        \"heading\": \"Editing\",\n        \"max_options_shown\": {\n          \"label\": \"Maximum number of items to show in select dropdowns\"\n        },\n        \"rating_system\": {\n          \"star_precision\": {\n            \"label\": \"Rating Star Precision\",\n            \"options\": {\n              \"full\": \"Full\",\n              \"half\": \"Half\",\n              \"quarter\": \"Quarter\",\n              \"tenth\": \"Tenth\"\n            }\n          },\n          \"type\": {\n            \"label\": \"Rating system type\",\n            \"options\": {\n              \"decimal\": \"Decimal\",\n              \"stars\": \"Stars\"\n            }\n          }\n        }\n      },\n      \"funscript_offset\": {\n        \"description\": \"Time offset in milliseconds for interactive scripts playback.\",\n        \"heading\": \"Funscript offset (ms)\"\n      },\n      \"handy_connection\": {\n        \"connect\": \"Connect\",\n        \"server_offset\": {\n          \"heading\": \"Server Offset\"\n        },\n        \"status\": {\n          \"heading\": \"Handy Connection Status\"\n        },\n        \"sync\": \"Sync\"\n      },\n      \"handy_connection_key\": {\n        \"description\": \"Handy connection key to use for interactive scenes. Setting this key will allow Stash to share your current scene information with handyfeeling.com.\",\n        \"heading\": \"Handy connection key\"\n      },\n      \"image_lightbox\": {\n        \"heading\": \"Image Lightbox\"\n      },\n      \"image_wall\": {\n        \"direction\": \"Direction\",\n        \"heading\": \"Image Wall\",\n        \"margin\": \"Margin (pixels)\"\n      },\n      \"images\": {\n        \"heading\": \"Images\",\n        \"options\": {\n          \"create_image_clips_from_videos\": {\n            \"description\": \"When a library has Videos disabled, video files (see Video extensions) will be scanned as image clips.\",\n            \"heading\": \"Scan video extensions as image clips\"\n          },\n          \"write_image_thumbnails\": {\n            \"description\": \"Write image thumbnails to disk when generated on-the-fly.\",\n            \"heading\": \"Write image thumbnails\"\n          }\n        }\n      },\n      \"interactive_options\": \"Interactive Options\",\n      \"language\": {\n        \"heading\": \"Language\"\n      },\n      \"max_loop_duration\": {\n        \"description\": \"Maximum scene duration where scene player will loop the video. Set 0 to disable.\",\n        \"heading\": \"Maximum loop duration\"\n      },\n      \"menu_items\": {\n        \"description\": \"Show or hide different types of content on the navigation bar.\",\n        \"heading\": \"Menu items\"\n      },\n      \"minimum_play_percent\": {\n        \"description\": \"The percentage of time in which a scene must be played before its play count is incremented.\",\n        \"heading\": \"Minimum play percent\"\n      },\n      \"performers\": {\n        \"options\": {\n          \"image_location\": {\n            \"description\": \"Custom path for default performer images. Leave empty to use built-in defaults.\",\n            \"heading\": \"Custom performer image path\"\n          }\n        }\n      },\n      \"preview_type\": {\n        \"description\": \"The default option is video (mp4) previews. For less CPU usage when browsing, you can use the animated image (webp) previews. However they must be generated in addition to the video previews and are larger files.\",\n        \"heading\": \"Preview type\",\n        \"options\": {\n          \"animated\": \"Animated image\",\n          \"static\": \"Static image\",\n          \"video\": \"Video\"\n        }\n      },\n      \"scene_list\": {\n        \"heading\": \"Grid View\",\n        \"options\": {\n          \"show_studio_as_text\": \"Display studio overlay as text\"\n        }\n      },\n      \"scene_player\": {\n        \"heading\": \"Scene Player\",\n        \"options\": {\n          \"always_start_from_beginning\": \"Always start video from beginning\",\n          \"auto_start_video\": \"Auto-start video\",\n          \"auto_start_video_on_play_selected\": {\n            \"description\": \"Auto-start scene videos when playing from queue, or playing selected or random from Scenes page.\",\n            \"heading\": \"Auto-start video when playing selected\"\n          },\n          \"continue_playlist_default\": {\n            \"description\": \"Play next scene in queue when video finishes.\",\n            \"heading\": \"Continue playlist by default\"\n          },\n          \"disable_mobile_media_auto_rotate\": \"Disable auto-rotate of fullscreen media on mobile\",\n          \"enable_chromecast\": \"Enable Chromecast\",\n          \"show_ab_loop_controls\": \"Show AB loop controls\",\n          \"show_scrubber\": \"Show scrubber\",\n          \"show_range_markers\": \"Show range markers\",\n          \"track_activity\": \"Enable scene play history\",\n          \"vr_tag\": {\n            \"description\": \"The VR button will only be displayed for scenes with this tag.\",\n            \"heading\": \"VR tag\"\n          }\n        }\n      },\n      \"scene_view\": {\n        \"heading\": \"Scene View\",\n        \"options\": {\n          \"preview_volume\": {\n            \"description\": \"Volume of the preview when hovering over a scene. Set to 0 to mute.\",\n            \"heading\": \"Preview volume\"\n          }\n        }\n      },\n      \"scene_wall\": {\n        \"heading\": \"Scene / Marker Wall\",\n        \"options\": {\n          \"display_title\": \"Display title and tags\",\n          \"toggle_sound\": \"Enable sound on preview hover\"\n        }\n      },\n      \"scroll_attempts_before_change\": {\n        \"description\": \"Number of times to attempt to scroll before moving to the next/previous item. Only applies for Pan Y scroll mode.\",\n        \"heading\": \"Scroll attempts before transition\"\n      },\n      \"sfw_mode\": {\n        \"description\": \"Enable if using stash to store SFW content. Hides or changes some adult-content-related aspects of the UI.\",\n        \"heading\": \"SFW content mode\"\n      },\n      \"show_tag_card_on_hover\": {\n        \"description\": \"Show tag card when hovering tag badges.\",\n        \"heading\": \"Tag card tooltips\"\n      },\n      \"slideshow_delay\": {\n        \"description\": \"Slideshow is available in galleries when in wall view mode.\",\n        \"heading\": \"Slideshow delay (seconds)\"\n      },\n      \"studio_panel\": {\n        \"heading\": \"Studio View\",\n        \"options\": {\n          \"show_child_studio_content\": {\n            \"description\": \"In the studio view, display content from the sub-studios as well.\",\n            \"heading\": \"Display sub-studios content\"\n          }\n        }\n      },\n      \"performer_list\": {\n        \"heading\": \"Performer List\",\n        \"options\": {\n          \"show_links_on_grid_card\": {\n            \"heading\": \"Display links on performer grid cards\"\n          }\n        }\n      },\n      \"tag_panel\": {\n        \"heading\": \"Tag View\",\n        \"options\": {\n          \"show_child_tagged_content\": {\n            \"description\": \"In the tag view, display content from the sub-tags as well.\",\n            \"heading\": \"Display sub-tag content\"\n          }\n        }\n      },\n      \"title\": \"User Interface\",\n      \"use_stash_hosted_funscript\": {\n        \"description\": \"When enabled, funscripts will be served directly from Stash to your Handy device without using the third party Handy server. Requires that Stash be accessible from your Handy device, and that an API key is generated if Stash has credentials configured.\",\n        \"heading\": \"Serve funscripts directly\"\n      }\n    }\n  },\n  \"configuration\": \"Configuration\",\n  \"connection_monitor\": {\n    \"websocket_connection_failed\": \"Unable to make websocket connection: see browser console for details\",\n    \"websocket_connection_reestablished\": \"Websocket connection re-established\"\n  },\n  \"containing_group\": \"Containing Group\",\n  \"containing_group_count\": \"Containing Group Count\",\n  \"containing_groups\": \"Containing Groups\",\n  \"countables\": {\n    \"files\": \"{count, plural, one {File} other {Files}}\",\n    \"galleries\": \"{count, plural, one {Gallery} other {Galleries}}\",\n    \"groups\": \"{count, plural, one {Group} other {Groups}}\",\n    \"images\": \"{count, plural, one {Image} other {Images}}\",\n    \"markers\": \"{count, plural, one {Marker} other {Markers}}\",\n    \"performers\": \"{count, plural, one {Performer} other {Performers}}\",\n    \"scenes\": \"{count, plural, one {Scene} other {Scenes}}\",\n    \"studios\": \"{count, plural, one {Studio} other {Studios}}\",\n    \"tags\": \"{count, plural, one {Tag} other {Tags}}\"\n  },\n  \"country\": \"Country\",\n  \"cover_image\": \"Cover Image\",\n  \"created_at\": \"Created At\",\n  \"criterion\": {\n    \"greater_than\": \"Greater than\",\n    \"less_than\": \"Less than\",\n    \"unsupported\": \"{type} (unsupported)\",\n    \"value\": \"Value\"\n  },\n  \"criterion_modifier\": {\n    \"between\": \"between\",\n    \"equals\": \"is\",\n    \"excludes\": \"excludes\",\n    \"format_string\": \"{criterion} {modifierString} {valueString}\",\n    \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})\",\n    \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (excludes {excludedString})\",\n    \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (excludes {excludedString}) (+{depth, plural, =-1 {all} other {{depth}}})\",\n    \"greater_than\": \"is greater than\",\n    \"includes\": \"includes\",\n    \"includes_all\": \"includes all\",\n    \"is_null\": \"is null\",\n    \"less_than\": \"is less than\",\n    \"matches_regex\": \"matches regex\",\n    \"not_between\": \"not between\",\n    \"not_equals\": \"is not\",\n    \"not_matches_regex\": \"not matches regex\",\n    \"not_null\": \"is not null\"\n  },\n  \"criterion_modifier_values\": {\n    \"any\": \"Any\",\n    \"any_of\": \"Any of\",\n    \"none\": \"None\",\n    \"only\": \"Only\"\n  },\n  \"custom\": \"Custom\",\n  \"custom_fields\": {\n    \"criteria_format_string\": \"{criterion} (custom field) {modifierString} {valueString}\",\n    \"criteria_format_string_others\": \"{criterion} (custom field) {modifierString} {valueString} (+{others} others)\",\n    \"field\": \"Field\",\n    \"title\": \"Custom Fields\",\n    \"value\": \"Value\"\n  },\n  \"date\": \"Date\",\n  \"date_format\": \"YYYY-MM-DD\",\n  \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n  \"death_date\": \"Death Date\",\n  \"death_year\": \"Death Year\",\n  \"descending\": \"Descending\",\n  \"description\": \"Description\",\n  \"detail\": \"Detail\",\n  \"details\": \"Details\",\n  \"developmentVersion\": \"Development Version\",\n  \"dialogs\": {\n    \"clear_o_history_confirm\": \"Are you sure you want to clear the O history?\",\n    \"clear_o_history_confirm_sfw\": \"Are you sure you want to clear the like history?\",\n    \"clear_play_history_confirm\": \"Are you sure you want to clear the play history?\",\n    \"create_new_entity\": \"Create new {entity}\",\n    \"delete_alert\": \"The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:\",\n    \"delete_alert_to_trash\": \"The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be moved to trash:\",\n    \"delete_confirm\": \"Are you sure you want to delete {entityName}?\",\n    \"delete_entity_desc\": \"{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}\",\n    \"delete_entity_simple_desc\": \"{count, plural, one {Are you sure you want to delete this {singularEntity}?} other {Are you sure you want to delete these {pluralEntity}?}}\",\n    \"delete_entity_title\": \"{count, plural, one {Delete {singularEntity}} other {Delete {pluralEntity}}}\",\n    \"delete_galleries_extra\": \"…plus any image files not attached to any other gallery.\",\n    \"delete_gallery_files\": \"Delete gallery folder/zip file and any images not attached to any other gallery.\",\n    \"delete_object_desc\": \"Are you sure you want to delete {count, plural, one {this {singularEntity}} other {these {pluralEntity}}}?\",\n    \"delete_object_overflow\": \"…and {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n    \"delete_object_title\": \"Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n    \"dont_show_until_updated\": \"Don't show until next update\",\n    \"edit_entity_title\": \"Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n    \"edit_entity_count_title\": \"Edit {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n    \"export_include_related_objects\": \"Include related objects in export\",\n    \"export_title\": \"Export\",\n    \"imagewall\": {\n      \"direction\": {\n        \"column\": \"Column\",\n        \"description\": \"Column or row based layout.\",\n        \"row\": \"Row\"\n      },\n      \"margin_desc\": \"Number of margin pixels around each entire image.\"\n    },\n    \"lightbox\": {\n      \"delay\": \"Delay (Sec)\",\n      \"disable_animation\": \"Disable transition animation between images\",\n      \"display_mode\": {\n        \"fit_horizontally\": \"Fit horizontally\",\n        \"fit_to_screen\": \"Fit to screen\",\n        \"label\": \"Display mode\",\n        \"original\": \"Original\"\n      },\n      \"options\": \"Options\",\n      \"page_header\": \"Page {page} / {total}\",\n      \"reset_zoom_on_nav\": \"Reset zoom level when changing image\",\n      \"scale_up\": {\n        \"description\": \"Scale smaller images up to fill screen.\",\n        \"label\": \"Scale up to fit\"\n      },\n      \"scroll_mode\": {\n        \"description\": \"Hold shift to temporarily use other mode.\",\n        \"label\": \"Scroll mode\",\n        \"pan_y\": \"Pan Y\",\n        \"zoom\": \"Zoom\"\n      }\n    },\n    \"merge\": {\n      \"combined\": \"Combined\",\n      \"destination\": \"Destination\",\n      \"empty_results\": \"Destination field values will be unchanged.\",\n      \"source\": \"Source\"\n    },\n    \"overwrite_filter_warning\": \"Saved filter \\\"{entityName}\\\" will be overwritten.\",\n    \"performers_found\": \"{count} performers found\",\n    \"reassign_entity_title\": \"{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}\",\n    \"stashid_exists_warning\": \"The existing stash id for this stash-box will be replaced.\",\n    \"reassign_files\": {\n      \"destination\": \"Reassign to\"\n    },\n    \"scene_gen\": {\n      \"clip_previews\": \"Image clip previews\",\n      \"covers\": \"Scene covers\",\n      \"force_transcodes\": \"Force Transcode generation\",\n      \"force_transcodes_tooltip\": \"By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.\",\n      \"image_phash\": \"Image perceptual hashes\",\n      \"image_phash_tooltip\": \"For deduplication and identification\",\n      \"image_previews\": \"Animated image previews\",\n      \"image_previews_tooltip\": \"Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.\",\n      \"image_thumbnails\": \"Image thumbnails\",\n      \"interactive_heatmap_speed\": \"Generate heatmaps and speeds for interactive scenes\",\n      \"marker_image_previews\": \"Marker animated image previews\",\n      \"marker_image_previews_tooltip\": \"Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.\",\n      \"marker_screenshots\": \"Marker screenshots\",\n      \"marker_screenshots_tooltip\": \"Marker static JPG images\",\n      \"markers\": \"Marker previews\",\n      \"markers_tooltip\": \"20 second videos which begin at the given timecode.\",\n      \"override_preview_generation_options\": \"Override preview generation options\",\n      \"override_preview_generation_options_desc\": \"Override preview generation options for this operation. Defaults are set in System -> Preview Generation.\",\n      \"overwrite\": \"Overwrite existing files\",\n      \"phash\": \"Video perceptual hashes\",\n      \"phash_tooltip\": \"For deduplication and scene identification\",\n      \"preview_exclude_end_time_desc\": \"Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.\",\n      \"preview_exclude_end_time_head\": \"Exclude end time\",\n      \"preview_exclude_start_time_desc\": \"Exclude the first x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.\",\n      \"preview_exclude_start_time_head\": \"Exclude start time\",\n      \"preview_generation_options\": \"Preview generation options\",\n      \"preview_options\": \"Preview Options\",\n      \"preview_preset_desc\": \"The preset regulates size, quality and encoding time of preview generation. Presets beyond “slow” have diminishing returns and are not recommended.\",\n      \"preview_preset_head\": \"Preview encoding preset\",\n      \"preview_seg_count_desc\": \"Number of segments in preview files.\",\n      \"preview_seg_count_head\": \"Number of segments in preview\",\n      \"preview_seg_duration_desc\": \"Duration of each preview segment, in seconds.\",\n      \"preview_seg_duration_head\": \"Preview segment duration\",\n      \"sprites\": \"Scene scrubber sprites\",\n      \"sprites_tooltip\": \"The set of images displayed below the video player for easy navigation.\",\n      \"transcodes\": \"Transcodes\",\n      \"transcodes_tooltip\": \"MP4 transcodes will be pre-generated for all content; useful for slow CPUs but requires much more disk space\",\n      \"video_previews\": \"Previews\",\n      \"video_previews_tooltip\": \"Video previews which play when hovering over a scene\"\n    },\n    \"scenes_found\": \"{count} scenes found\",\n    \"studios_found\": \"{count} studios found\",\n    \"tags_found\": \"{count} tags found\",\n    \"scrape_entity_query\": \"{entity_type} Scrape Query\",\n    \"scrape_entity_title\": \"{entity_type} Scrape Results\",\n    \"scrape_results_existing\": \"Existing\",\n    \"scrape_results_missing\": \"Missing\",\n    \"scrape_results_scraped\": \"Scraped\",\n    \"set_default_filter_confirm\": \"Are you sure you want to set this filter as the default?\",\n    \"set_image_url_title\": \"Image URL\",\n    \"unsaved_changes\": \"Unsaved changes. Are you sure you want to leave?\"\n  },\n  \"dimensions\": \"Dimensions\",\n  \"director\": \"Director\",\n  \"disambiguation\": \"Disambiguation\",\n  \"display_mode\": {\n    \"grid\": \"Grid\",\n    \"label_current\": \"Display Mode: {current}\",\n    \"list\": \"List\",\n    \"tagger\": \"Tagger\",\n    \"unknown\": \"Unknown\",\n    \"wall\": \"Wall\"\n  },\n  \"distance\": \"Distance\",\n  \"donate\": \"Donate\",\n  \"dupe_check\": {\n    \"description\": \"Levels below 'Exact' can take longer to calculate. False positives might also be returned on lower accuracy levels.\",\n    \"duration_diff\": \"Maximum Duration Difference\",\n    \"duration_options\": {\n      \"any\": \"Any\",\n      \"equal\": \"Equal\"\n    },\n    \"found_sets\": \"{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}\",\n    \"only_select_matching_codecs\": \"Only select if all codecs match in the duplicate group\",\n    \"options\": {\n      \"exact\": \"Exact\",\n      \"high\": \"High\",\n      \"low\": \"Low\",\n      \"medium\": \"Medium\"\n    },\n    \"search_accuracy_label\": \"Search Accuracy\",\n    \"select_all_but_largest_file\": \"Select every file in each duplicated group, except the largest file\",\n    \"select_all_but_largest_resolution\": \"Select every file in each duplicated group, except the file with highest resolution\",\n    \"select_none\": \"Select None\",\n    \"select_oldest\": \"Select the oldest file in the duplicate group\",\n    \"select_options\": \"Select Options…\",\n    \"select_youngest\": \"Select the youngest file in the duplicate group\",\n    \"title\": \"Duplicate Scenes\"\n  },\n  \"duplicated\": \"Duplicated\",\n  \"duplicated_phash\": \"Duplicated (pHash)\",\n  \"duplicated_stash_id\": \"Duplicated (Stash ID)\",\n  \"duplicated_title\": \"Duplicated (Title)\",\n  \"duration\": \"Duration\",\n  \"effect_filters\": {\n    \"aspect\": \"Aspect\",\n    \"blue\": \"Blue\",\n    \"blur\": \"Blur\",\n    \"brightness\": \"Brightness\",\n    \"contrast\": \"Contrast\",\n    \"gamma\": \"Gamma\",\n    \"green\": \"Green\",\n    \"hue\": \"Hue\",\n    \"name\": \"Filters\",\n    \"name_transforms\": \"Transforms\",\n    \"red\": \"Red\",\n    \"reset_filters\": \"Reset Filters\",\n    \"reset_transforms\": \"Reset Transforms\",\n    \"rotate\": \"Rotate\",\n    \"rotate_left_and_scale\": \"Rotate Left & Scale\",\n    \"rotate_right_and_scale\": \"Rotate Right & Scale\",\n    \"saturation\": \"Saturation\",\n    \"scale\": \"Scale\",\n    \"warmth\": \"Warmth\"\n  },\n  \"empty_server\": \"Add some scenes to your server to view recommendations on this page.\",\n  \"empty_value\": \"empty\",\n  \"errors\": {\n    \"custom_fields\": {\n      \"duplicate_field\": \"Field name must be unique\",\n      \"field_name_length\": \"Field name must fewer than 65 characters\",\n      \"field_name_required\": \"Field name is required\",\n      \"field_name_whitespace\": \"Field name cannot have leading or trailing whitespace\"\n    },\n    \"header\": \"Error\",\n    \"image_index_greater_than_zero\": \"Image index must be greater than 0\",\n    \"invalid_javascript_string\": \"Invalid javascript code: {error}\",\n    \"invalid_json_string\": \"Invalid JSON string: {error}\",\n    \"lazy_component_error_help\": \"If you recently upgraded Stash, please reload the page or clear your browser cache.\",\n    \"loading_type\": \"Error loading {type}\",\n    \"something_went_wrong\": \"Something went wrong.\"\n  },\n  \"eta\": \"ETA\",\n  \"ethnicity\": \"Ethnicity\",\n  \"existing_value\": \"existing value\",\n  \"eye_color\": \"Eye Colour\",\n  \"fake_tits\": \"Fake Tits\",\n  \"false\": \"False\",\n  \"favourite\": \"Favourite\",\n  \"file\": \"file\",\n  \"file_count\": \"File Count\",\n  \"file_info\": \"File Info\",\n  \"file_mod_time\": \"File Modification Time\",\n  \"files\": \"files\",\n  \"files_amount\": \"{value} files\",\n  \"filesize\": \"File Size\",\n  \"filter\": \"Filter\",\n  \"filter_name\": \"Filter name\",\n  \"filters\": \"Filters\",\n  \"folder\": \"Folder\",\n  \"framerate\": \"Frame Rate\",\n  \"frames_per_second\": \"{value} fps\",\n  \"front_page\": {\n    \"types\": {\n      \"premade_filter\": \"Premade Filter\",\n      \"saved_filter\": \"Saved Filter\"\n    }\n  },\n  \"galleries\": \"Galleries\",\n  \"gallery\": \"Gallery\",\n  \"gallery_count\": \"Gallery Count\",\n  \"gender\": \"Gender\",\n  \"gender_types\": {\n    \"FEMALE\": \"Female\",\n    \"INTERSEX\": \"Intersex\",\n    \"MALE\": \"Male\",\n    \"NON_BINARY\": \"Non-Binary\",\n    \"TRANSGENDER_FEMALE\": \"Transgender Female\",\n    \"TRANSGENDER_MALE\": \"Transgender Male\"\n  },\n  \"group\": \"Group\",\n  \"group_count\": \"Group Count\",\n  \"group_scene_number\": \"Scene Number\",\n  \"groups\": \"Groups\",\n  \"hair_color\": \"Hair Colour\",\n  \"handy_connection_status\": {\n    \"connecting\": \"Connecting\",\n    \"disconnected\": \"Disconnected\",\n    \"error\": \"Error connecting to Handy\",\n    \"missing\": \"Missing\",\n    \"ready\": \"Ready\",\n    \"syncing\": \"Syncing with server\",\n    \"uploading\": \"Uploading script\"\n  },\n  \"hasChapters\": \"Chapters\",\n  \"hasMarkers\": \"Markers\",\n  \"height\": \"Height\",\n  \"height_cm\": \"Height (cm)\",\n  \"help\": \"Help\",\n  \"history\": \"History\",\n  \"ignore_auto_tag\": \"Ignore auto tag\",\n  \"image\": \"Image\",\n  \"image_count\": \"Image Count\",\n  \"image_index\": \"Image #\",\n  \"images\": \"Images\",\n  \"include_parent_tags\": \"Include parent tags\",\n  \"include_sub_folders\": \"Include sub-folders\",\n  \"include_sub_group_content\": \"Include sub-group content\",\n  \"include_sub_groups\": \"Include sub-groups\",\n  \"include_sub_studio_content\": \"Include sub-studio content\",\n  \"include_sub_studios\": \"Include subsidiary studios\",\n  \"include_sub_tag_content\": \"Include sub-tag content\",\n  \"include_sub_tags\": \"Include sub-tags\",\n  \"index_of_total\": \"{index} of {total}\",\n  \"instagram\": \"Instagram\",\n  \"interactive\": \"Interactive\",\n  \"interactive_speed\": \"Interactive Speed\",\n  \"isMissing\": \"Is Missing\",\n  \"last_o_at\": \"Last O At\",\n  \"last_o_at_sfw\": \"Last Like At\",\n  \"last_played_at\": \"Last Played At\",\n  \"latest_scene\": \"Latest Scene\",\n  \"library\": \"Library\",\n  \"loading\": {\n    \"generic\": \"Loading…\",\n    \"plugins\": \"Loading plugins…\"\n  },\n  \"login\": {\n    \"login\": \"Login\",\n    \"username\": \"Username\",\n    \"password\": \"Password\",\n    \"invalid_credentials\": \"Invalid username or password\",\n    \"internal_error\": \"Unexpected internal error. See logs for more details\"\n  },\n  \"marker_count\": \"Marker Count\",\n  \"markers\": \"Markers\",\n  \"measurements\": \"Measurements\",\n  \"media_info\": {\n    \"audio_codec\": \"Audio Codec\",\n    \"downloaded_from\": \"Downloaded From\",\n    \"interactive_speed\": \"Interactive Speed\",\n    \"md5\": \"MD5 Checksum\",\n    \"o_count\": \"O Count\",\n    \"oshash\": \"oshash\",\n    \"oshash_meaning\": \"OpenSubtitles Hash\",\n    \"performer_card\": {\n      \"age\": \"{age} {years_old}\",\n      \"age_context\": \"{age} {years_old} at production\"\n    },\n    \"phash\": \"PHash\",\n    \"phash_meaning\": \"Perceptual Hash\",\n    \"play_count\": \"Play Count\",\n    \"play_duration\": \"Play Duration\",\n    \"stream\": \"Stream\",\n    \"video_codec\": \"Video Codec\"\n  },\n  \"megabits_per_second\": \"{value} mbps\",\n  \"metadata\": \"Metadata\",\n  \"name\": \"Name\",\n  \"sort_name\": \"Sort Name\",\n  \"new\": \"New\",\n  \"none\": \"None\",\n  \"o_count\": \"O Count\",\n  \"o_count_sfw\": \"Likes\",\n  \"o_history\": \"O History\",\n  \"o_history_sfw\": \"Like History\",\n  \"odate_recorded_no\": \"No O Date Recorded\",\n  \"odate_recorded_no_sfw\": \"No Like Date Recorded\",\n  \"operations\": \"Operations\",\n  \"organized\": \"Organised\",\n  \"orientation\": \"Orientation\",\n  \"package_manager\": {\n    \"add_source\": \"Add source\",\n    \"check_for_updates\": \"Check for updates\",\n    \"confirm_delete_source\": \"Are you sure you want to delete source {name} ({url})?\",\n    \"confirm_uninstall\": \"Are you sure you want to uninstall {number} packages?\",\n    \"description\": \"Description\",\n    \"edit_source\": \"Edit source\",\n    \"hide_unselected\": \"Hide unselected\",\n    \"install\": \"Install\",\n    \"installed_version\": \"Installed version\",\n    \"latest_version\": \"Latest version\",\n    \"no_packages\": \"No packages found\",\n    \"no_sources\": \"No sources configured\",\n    \"no_upgradable\": \"No upgradable packages found\",\n    \"package\": \"Package\",\n    \"required_by\": \"Required by {packages}\",\n    \"selected_only\": \"Selected only\",\n    \"show_all\": \"Show all\",\n    \"source\": {\n      \"local_path\": {\n        \"description\": \"Relative path to store packages for this source. Note that changing this requires the packages to be moved manually.\",\n        \"heading\": \"Local path\"\n      },\n      \"name\": \"Name\",\n      \"url\": \"Source URL\"\n    },\n    \"uninstall\": \"Uninstall\",\n    \"unknown\": \"<unknown>\",\n    \"update\": \"Update\",\n    \"version\": \"Version\"\n  },\n  \"pagination\": {\n    \"current_total\": \"{current} of {total}\",\n    \"first\": \"First\",\n    \"last\": \"Last\",\n    \"next\": \"Next\",\n    \"previous\": \"Previous\"\n  },\n  \"parent_folder\": \"Parent Folder\",\n  \"parent_of\": \"Parent of {children}\",\n  \"parent_studio\": \"Parent Studio\",\n  \"parent_studios\": \"Parent Studios\",\n  \"parent_tag_count\": \"Parent Tag Count\",\n  \"parent_tags\": \"Parent Tags\",\n  \"part_of\": \"Part of {parent}\",\n  \"path\": \"Path\",\n  \"penis\": \"Penis\",\n  \"penis_length\": \"Penis Length\",\n  \"penis_length_cm\": \"Penis Length (cm)\",\n  \"perceptual_similarity\": \"Perceptual Similarity (pHash)\",\n  \"performer\": \"Performer\",\n  \"performer_age\": \"Performer Age\",\n  \"performer_count\": \"Performer Count\",\n  \"performer_favorite\": \"Performer Favourited\",\n  \"performer_image\": \"Performer Image\",\n  \"performer_tagger\": {\n    \"add_new_performers\": \"Add New Performers\",\n    \"any_names_entered_will_be_queried\": \"Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.\",\n    \"batch_add_performers\": \"Batch Add Performers\",\n    \"batch_update_performers\": \"Batch Update Performers\",\n    \"current_page\": \"Current page\",\n    \"failed_to_save_performer\": \"Failed to save performer \\\"{performer}\\\"\",\n    \"name_already_exists\": \"Name already exists\",\n    \"network_error\": \"Network Error\",\n    \"no_results_found\": \"No results found.\",\n    \"number_of_performers_will_be_processed\": \"{performer_count} performers will be processed\",\n    \"performer_already_tagged\": \"Performer already tagged\",\n    \"performer_names_or_stashids_separated_by_comma\": \"Performer names or StashIDs separated by comma\",\n    \"performer_selection\": \"Performer selection\",\n    \"performer_successfully_tagged\": \"Performer successfully tagged:\",\n    \"query_all_performers_in_the_database\": \"All performers in the database\",\n    \"refresh_tagged_performers\": \"Refresh tagged performers\",\n    \"refreshing_will_update_the_data\": \"Refreshing will update the data of any tagged performers from the stash-box instance.\",\n    \"status_tagging_job_queued\": \"Status: Tagging job queued\",\n    \"status_tagging_performers\": \"Status: Tagging performers\",\n    \"tag_status\": \"Tag Status\",\n    \"to_use_the_performer_tagger\": \"To use the performer tagger a stash-box instance needs to be configured.\",\n    \"untagged_performers\": \"Untagged performers\",\n    \"update_performer\": \"Update Performer\",\n    \"update_performers\": \"Update Performers\",\n    \"updating_untagged_performers_description\": \"Updating untagged performers will try to match any performers that lack a stashid and update the metadata.\"\n  },\n  \"performer_tags\": \"Performer Tags\",\n  \"performers\": \"Performers\",\n  \"photographer\": \"Photographer\",\n  \"piercings\": \"Piercings\",\n  \"play_count\": \"Play Count\",\n  \"play_duration\": \"Play Duration\",\n  \"play_history\": \"Play History\",\n  \"playdate_recorded_no\": \"No Play Date Recorded\",\n  \"plays\": \"{value} plays\",\n  \"primary_file\": \"Primary file\",\n  \"primary_tag\": \"Primary Tag\",\n  \"queue\": \"Queue\",\n  \"random\": \"Random\",\n  \"rating\": \"Rating\",\n  \"recently_added_objects\": \"Recently Added {objects}\",\n  \"recently_released_objects\": \"Recently Released {objects}\",\n  \"refer_to\": \"Please see {link}.\",\n  \"release_notes\": \"Release Notes\",\n  \"resolution\": \"Resolution\",\n  \"resume_time\": \"Resume Time\",\n  \"scene\": \"Scene\",\n  \"sceneTagger\": \"Scene Tagger\",\n  \"scene_code\": \"Studio Code\",\n  \"scene_count\": \"Scene Count\",\n  \"scenes_duration\": \"Scene Duration\",\n  \"scenes_size\": \"Scene Size\",\n  \"scene_created_at\": \"Scene Created At\",\n  \"scene_date\": \"Date of Scene\",\n  \"scene_id\": \"Scene ID\",\n  \"scene_tags\": \"Scene Tags\",\n  \"scene_updated_at\": \"Scene Updated At\",\n  \"scenes\": \"Scenes\",\n  \"scenes_updated_at\": \"Scene Updated At\",\n  \"search_filter\": {\n    \"edit_filter\": \"Edit Filter\",\n    \"name\": \"Filter\",\n    \"saved_filters\": \"Saved filters\",\n    \"search_term\": \"Search term\",\n    \"update_filter\": \"Update Filter\",\n    \"more_filter_criteria\": \"+{count} more\"\n  },\n  \"second\": \"Second\",\n  \"seconds\": \"Seconds\",\n  \"settings\": \"Settings\",\n  \"setup\": {\n    \"confirm\": {\n      \"almost_ready\": \"We're almost ready to complete the configuration. Please confirm the following settings. You can click back to change anything incorrect. If everything looks good, click Confirm to create your system.\",\n      \"blobs_directory\": \"Binary data directory\",\n      \"blobs_use_database\": \"<using database>\",\n      \"cache_directory\": \"Cache directory\",\n      \"configuration_file_location\": \"Configuration file location:\",\n      \"database_file_path\": \"Database file path\",\n      \"generated_directory\": \"Generated directory\",\n      \"nearly_there\": \"Nearly there!\",\n      \"stash_library_directories\": \"Stash library directories\"\n    },\n    \"creating\": {\n      \"creating_your_system\": \"Creating your system\"\n    },\n    \"errors\": {\n      \"something_went_wrong\": \"Oh no! Something went wrong!\",\n      \"something_went_wrong_description\": \"If this looks like a problem with your inputs, go ahead and click back to fix them up. Otherwise, raise a bug on the {githubLink} or seek help in the {discordLink}.\",\n      \"something_went_wrong_while_setting_up_your_system\": \"Something went wrong while setting up your system. Here is the error we received: {error}\",\n      \"unable_to_retrieve_system_status\": \"Unable to retrieve system status: {error}\",\n      \"unexpected_error\": \"An unexpected error occurred: {error}\"\n    },\n    \"folder\": {\n      \"file_path\": \"File path\",\n      \"up_dir\": \"Up a directory\"\n    },\n    \"github_repository\": \"Github repository\",\n    \"migrate\": {\n      \"backup_database_path_leave_empty_to_disable_backup\": \"Backup database path (leave empty to disable backup):\",\n      \"backup_recommended\": \"It is recommended that you backup your existing database before you migrate. We can do this for you, by making a copy of your database to <code>{defaultBackupPath}</code>.\",\n      \"migrating_database\": \"Migrating database\",\n      \"migration_failed\": \"Migration failed\",\n      \"migration_failed_error\": \"The following error was encountered while migrating the database:\",\n      \"migration_failed_help\": \"Please make any necessary corrections and try again. Otherwise, raise a bug on the {githubLink} or seek help in the {discordLink}.\",\n      \"migration_irreversible_warning\": \"The schema migration process is not reversible. Once the migration is performed, your database will be incompatible with previous versions of stash.\",\n      \"migration_notes\": \"Migration Notes\",\n      \"migration_required\": \"Migration required\",\n      \"perform_schema_migration\": \"Perform schema migration\",\n      \"schema_too_old\": \"Your current stash database is schema version <strong>{databaseSchema}</strong> and needs to be migrated to version <strong>{appSchema}</strong>. This version of Stash will not function without migrating the database. If you do not wish to migrate, you will need to downgrade to a version that matches your database schema.\"\n    },\n    \"paths\": {\n      \"database_filename_empty_for_default\": \"database filename (empty for default)\",\n      \"description\": \"Next up, we need to determine where to find your content, and where to store the Stash database, generated files and cache files. These settings can be changed later if needed.\",\n      \"path_to_blobs_directory_empty_for_default\": \"path to blobs directory (empty for default)\",\n      \"path_to_cache_directory_empty_for_default\": \"path to cache directory (empty for default)\",\n      \"path_to_generated_directory_empty_for_default\": \"path to generated directory (empty for default)\",\n      \"set_up_your_paths\": \"Set up your paths\",\n      \"sfw_content_settings\": \"Using stash for SFW content?\",\n      \"sfw_content_settings_description\": \"stash can be used to manage SFW content such as photography, art, comics, and more. Enabling this option will adjust some UI behaviour to be more appropriate for SFW content.\",\n      \"stash_alert\": \"No library paths have been selected. No media will be able to be scanned into Stash. Are you sure?\",\n      \"store_blobs_in_database\": \"Store blobs in database\",\n      \"use_sfw_content_mode\": \"Use SFW content mode\",\n      \"where_can_stash_store_blobs\": \"Where can Stash store database binary data?\",\n      \"where_can_stash_store_blobs_description\": \"Stash can store binary data such as scene covers, performer, studio and tag images either in the database or in the filesystem. By default, it will store this data in the filesystem in the subdirectory <code>blobs</code> within the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.\",\n      \"where_can_stash_store_blobs_description_addendum\": \"Alternatively, you can store this data in the database. <strong>Note:</strong> This will increase the size of your database file, and will increase database migration times.\",\n      \"where_can_stash_store_cache_files\": \"Where can Stash store cache files?\",\n      \"where_can_stash_store_cache_files_description\": \"In order for some functionality like HLS/DASH live transcoding to function, Stash requires a cache directory for temporary files. By default, Stash will create a <code>cache</code> directory within the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.\",\n      \"where_can_stash_store_its_database\": \"Where can Stash store its database?\",\n      \"where_can_stash_store_its_database_description\": \"Stash uses an SQLite database to store your content metadata. By default, this will be created as <code>stash-go.sqlite</code> in the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) filename.\",\n      \"where_can_stash_store_its_database_warning\": \"WARNING: storing the database on a different system to where Stash is run from (e.g. storing the database on a NAS while running the Stash server on another computer) is <strong>unsupported</strong>! SQLite is not intended for use across a network, and attempting to do so can very easily cause your entire database to become corrupted.\",\n      \"where_can_stash_store_its_generated_content\": \"Where can Stash store its generated content?\",\n      \"where_can_stash_store_its_generated_content_description\": \"In order to provide thumbnails, previews and sprites, Stash generates images and videos. This also includes transcodes for unsupported file formats. By default, Stash will create a <code>generated</code> directory within the directory containing your config file. If you want to change where this generated media will be stored, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.\",\n      \"where_is_your_porn_located\": \"Where is your content located?\",\n      \"where_is_your_porn_located_description\": \"Add directories containing your videos and images. Stash will use these directories to find videos and images during scanning.\"\n    },\n    \"stash_setup_wizard\": \"Stash Setup Wizard\",\n    \"success\": {\n      \"download_ffmpeg\": \"Download ffmpeg\",\n      \"getting_help\": \"Getting help\",\n      \"help_links\": \"If you run into issues or have any questions or suggestions, feel free to open an issue in the {githubLink}, or ask the community in the {discordLink}.\",\n      \"in_app_manual_explained\": \"You are encouraged to check out the in-app manual which can be accessed from the icon in the top-right corner of the screen that looks like this: {icon}\",\n      \"missing_ffmpeg\": \"You are missing the required <code>ffmpeg</code> binary. You can automatically download it into your configuration directory by checking the box below. Alternatively, you can supply paths to the <code>ffmpeg</code> and <code>ffprobe</code> binaries in the System Settings. These binaries must be present for Stash to function.\",\n      \"next_config_step_one\": \"You will be taken to the Configuration page next. This page will allow you to customize what files to include and exclude, set a username and password to protect your system, and a whole bunch of other options.\",\n      \"next_config_step_two\": \"When you are satisfied with these settings, you can begin scanning your content into Stash by clicking on <code>{localized_task}</code>, then <code>{localized_scan}</code>.\",\n      \"open_collective\": \"Check out our {open_collective_link} to see how you can contribute to the continued development of Stash.\",\n      \"support_us\": \"Support us\",\n      \"thanks_for_trying_stash\": \"Thanks for trying Stash!\",\n      \"welcome_contrib\": \"We also welcome contributions in the form of code (bug fixes, improvements and new features), testing, bug reports, improvement and feature requests, and user support. Details can be found in the Contribution section of the in-app manual.\",\n      \"your_system_has_been_created\": \"Success! Your system has been created!\"\n    },\n    \"welcome\": {\n      \"config_path_logic_explained\": \"Stash tries to find its configuration file (<code>config.yml</code>) from the current working directory first, and if it does not find it there, it falls back to <code>{fallback_path}</code>. You can also make Stash read from a specific configuration file by running it with the <code>-c '<path to config file>'</code> or <code>--config '<path to config file>'</code> options.\",\n      \"in_current_stash_directory\": \"In the <code>{path}</code> directory:\",\n      \"in_the_current_working_directory\": \"In <code>{path}</code>, the working directory, currently:\",\n      \"in_the_current_working_directory_disabled\": \"In <code>{path}</code>, the working directory:\",\n      \"in_the_current_working_directory_disabled_macos\": \"Unsupported when running <code>Stash.app</code>,<br></br>run <code>stash-macos</code> to set up in the working directory\",\n      \"next_step\": \"With all of that out of the way, if you're ready to proceed with setting up a new system, please choose where you'd like to store your configuration file.\",\n      \"store_stash_config\": \"Where do you want to store your Stash configuration?\",\n      \"unable_to_locate_config\": \"If you're reading this, then Stash couldn't find an existing configuration. This wizard will guide you through the process of setting up a new configuration.\",\n      \"unexpected_explained\": \"If you're getting this screen unexpectedly, please try restarting Stash in the correct working directory or with the <code>-c</code> flag.\"\n    },\n    \"welcome_specific_config\": {\n      \"config_path\": \"Stash will use the following configuration file path: <code>{path}</code>\",\n      \"next_step\": \"When you're ready to proceed with setting up a new system, click Next.\",\n      \"unable_to_locate_specified_config\": \"If you're reading this, then Stash couldn't find the configuration file specified at the command line or the environment. This wizard will guide you through the process of setting up a new configuration.\"\n    },\n    \"welcome_to_stash\": \"Welcome to Stash\"\n  },\n  \"stash_id\": \"Stash ID\",\n  \"stash_id_count\": \"Stash ID Count\",\n  \"stash_id_endpoint\": \"Stash ID Endpoint URL\",\n  \"stash_ids\": \"Stash IDs\",\n  \"stashbox_search\": {\n    \"header\": \"Search {entityType} from StashBox\",\n    \"no_results\": \"No results found.\",\n    \"placeholder_name_or_id\": \"{entityType} name or StashID...\",\n    \"select_stashbox\": \"Select StashBox...\"\n  },\n  \"stashbox\": {\n    \"go_review_draft\": \"Go to {endpoint_name} to review draft.\",\n    \"selected_stash_box\": \"Selected Stash-Box endpoint\",\n    \"source\": \"Stash-Box Source\",\n    \"submission_failed\": \"Submission failed\",\n    \"submission_successful\": \"Submission successful\",\n    \"submit_update\": \"Already exists in {endpoint_name}\"\n  },\n  \"statistics\": \"Statistics\",\n  \"stats\": {\n    \"image_size\": \"Images size\",\n    \"scenes_duration\": \"Scenes duration\",\n    \"scenes_played\": \"Scenes Played\",\n    \"scenes_size\": \"Scenes size\",\n    \"total_o_count\": \"Total O-Count\",\n    \"total_o_count_sfw\": \"Total Likes\",\n    \"total_play_count\": \"Total Play Count\",\n    \"total_play_duration\": \"Total Play Duration\"\n  },\n  \"status\": \"Status: {statusText}\",\n  \"studio\": \"Studio\",\n  \"studio_and_parent\": \"Studio & Parent\",\n  \"studio_count\": \"Studio Count\",\n  \"studio_depth\": \"Levels (empty for all)\",\n  \"studio_tagger\": {\n    \"add_new_studios\": \"Add New Studios\",\n    \"any_names_entered_will_be_queried\": \"Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.\",\n    \"batch_add_studios\": \"Batch Add Studios\",\n    \"batch_update_studios\": \"Batch Update Studios\",\n    \"config\": {\n      \"create_parent_desc\": \"Create missing parent studios, or tag and update data/image for existing parent studios with exact name matches\",\n      \"create_parent_label\": \"Create parent studios\"\n    },\n    \"create_or_tag_parent_studios\": \"Create missing or tag existing parent studios\",\n    \"current_page\": \"Current page\",\n    \"failed_to_save_studio\": \"Failed to save studio \\\"{studio}\\\"\",\n    \"name_already_exists\": \"Name already exists\",\n    \"network_error\": \"Network Error\",\n    \"no_results_found\": \"No results found.\",\n    \"number_of_studios_will_be_processed\": \"{studio_count} studios will be processed\",\n    \"query_all_studios_in_the_database\": \"All studios in the database\",\n    \"refresh_tagged_studios\": \"Refresh tagged studios\",\n    \"refreshing_will_update_the_data\": \"Refreshing will update the data of any tagged studios from the stash-box instance.\",\n    \"status_tagging_job_queued\": \"Status: Tagging job queued\",\n    \"status_tagging_studios\": \"Status: Tagging studios\",\n    \"studio_already_tagged\": \"Studio already tagged\",\n    \"studio_names_or_stashids_separated_by_comma\": \"Studio names or StashIDs separated by comma\",\n    \"studio_selection\": \"Studio selection\",\n    \"studio_successfully_tagged\": \"Studio successfully tagged\",\n    \"tag_status\": \"Tag Status\",\n    \"to_use_the_studio_tagger\": \"To use the studio tagger a stash-box instance needs to be configured.\",\n    \"untagged_studios\": \"Untagged studios\",\n    \"update_studio\": \"Update Studio\",\n    \"update_studios\": \"Update Studios\",\n    \"updating_untagged_studios_description\": \"Updating untagged studios will try to match any studios that lack a stashid and update the metadata.\"\n  },\n  \"studio_tags\": \"Studio Tags\",\n  \"studios\": \"Studios\",\n  \"sub_folder_depth\": \"Sub folder depth (empty for all)\",\n  \"sub_folders\": \"Sub folders\",\n  \"sub_group\": \"Sub-Group\",\n  \"sub_group_count\": \"Sub-Group Count\",\n  \"sub_group_of\": \"Sub-group of {parent}\",\n  \"sub_group_order\": \"Sub-Group Order\",\n  \"sub_groups\": \"Sub-Groups\",\n  \"sub_tag_count\": \"Sub-Tag Count\",\n  \"sub_tag_of\": \"Sub-tag of {parent}\",\n  \"sub_tags\": \"Sub-Tags\",\n  \"subsidiary_studio_count\": \"Subsidiary Studio Count\",\n  \"subsidiary_studios\": \"Subsidiary Studios\",\n  \"synopsis\": \"Synopsis\",\n  \"tag\": \"Tag\",\n  \"tag_count\": \"Tag Count\",\n  \"tag_parent_tooltip\": \"Has parent tags\",\n  \"tag_sub_tag_tooltip\": \"Has sub-tags\",\n  \"tag_tagger\": {\n    \"add_new_tags\": \"Add New Tags\",\n    \"any_names_entered_will_be_queried\": \"Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.\",\n    \"batch_add_tags\": \"Batch Add Tags\",\n    \"batch_update_tags\": \"Batch Update Tags\",\n    \"config\": {\n      \"create_parent_desc\": \"Create missing parent tags from stash-box categories, or tag existing parent tags with exact name matches\",\n      \"create_parent_label\": \"Create parent tags\"\n    },\n    \"create_or_tag_parent_tags\": \"Create missing or tag existing parent tags\",\n    \"current_page\": \"Current page\",\n    \"failed_to_save_tag\": \"Failed to save tag \\\"{tag}\\\"\",\n    \"name_already_exists\": \"Name already exists\",\n    \"network_error\": \"Network Error\",\n    \"no_results_found\": \"No results found.\",\n    \"number_of_tags_will_be_processed\": \"{tag_count} tags will be processed\",\n    \"query_all_tags_in_the_database\": \"All tags in the database\",\n    \"refresh_tagged_tags\": \"Refresh tagged tags\",\n    \"refreshing_will_update_the_data\": \"Refreshing will update the data of any tagged tags from the stash-box instance.\",\n    \"status_tagging_job_queued\": \"Status: Tagging job queued\",\n    \"status_tagging_tags\": \"Status: Tagging tags\",\n    \"tag_already_tagged\": \"Tag already tagged\",\n    \"tag_names_or_stashids_separated_by_comma\": \"Tag names or StashIDs separated by comma\",\n    \"tag_selection\": \"Tag selection\",\n    \"tag_successfully_tagged\": \"Tag successfully tagged\",\n    \"tag_status\": \"Tag Status\",\n    \"to_use_the_tag_tagger\": \"To use the tag tagger a stash-box instance needs to be configured.\",\n    \"untagged_tags\": \"Untagged tags\",\n    \"update_tags\": \"Update Tags\",\n    \"updating_untagged_tags_description\": \"Updating untagged tags will try to match any tags that lack a stashid and update the metadata.\"\n  },\n  \"tagger\": {\n    \"config\": {\n      \"active_stash-box_instance\": \"Active stash-box instance:\",\n      \"edit_excluded_fields\": \"Edit Excluded Fields\",\n      \"excluded_fields\": \"Excluded fields:\",\n      \"fields_will_not_be_changed\": \"These fields will not be changed when updating {entity}.\",\n      \"no_fields_are_excluded\": \"No fields are excluded\",\n      \"no_instances_found\": \"No instances found\"\n    }\n  },\n  \"tags\": \"Tags\",\n  \"tattoos\": \"Tattoos\",\n  \"time\": \"Time\",\n  \"time_end\": \"End Time\",\n  \"title\": \"Title\",\n  \"toast\": {\n    \"added_entity\": \"Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n    \"added_generation_job_to_queue\": \"Added generation job to queue\",\n    \"clipboard_access_denied\": \"Clipboard access denied. Check your browser permissions\",\n    \"clipboard_image_pasted\": \"Image pasted from clipboard\",\n    \"clipboard_no_image\": \"No image found in clipboard\",\n    \"created_entity\": \"Created {entity}\",\n    \"default_filter_set\": \"Default filter set\",\n    \"delete_past_tense\": \"Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n    \"generating_screenshot\": \"Generating screenshot…\",\n    \"image_index_too_large\": \"Error: Image index is larger than the number of images in the Gallery\",\n    \"merged_performers\": \"Merged performers\",\n    \"merged_scenes\": \"Merged scenes\",\n    \"merged_tags\": \"Merged tags\",\n    \"reassign_past_tense\": \"File reassigned\",\n    \"removed_entity\": \"Removed {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n    \"rescanning_entity\": \"Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n    \"saved_entity\": \"Saved {entity}\",\n    \"started_auto_tagging\": \"Started auto tagging\",\n    \"started_generating\": \"Started generating\",\n    \"started_importing\": \"Started importing\",\n    \"updated_entity\": \"Updated {entity}\"\n  },\n  \"total\": \"Total\",\n  \"true\": \"True\",\n  \"twitter\": \"Twitter\",\n  \"type\": \"Type\",\n  \"unknown_date\": \"Unknown date\",\n  \"unsupported_criteria\": \"Unsupported criteria: {criteria}\",\n  \"updated_at\": \"Updated At\",\n  \"url\": \"URL\",\n  \"urls\": \"URLs\",\n  \"validation\": {\n    \"blank\": \"${path} must not be blank\",\n    \"date_invalid_form\": \"${path} must be in YYYY, YYYY-MM, or YYYY-MM-DD form\",\n    \"end_time_before_start_time\": \"End time must be greater than or equal to start time\",\n    \"required\": \"${path} is a required field\",\n    \"unique\": \"${path} must be unique\"\n  },\n  \"video_codec\": \"Video Codec\",\n  \"videos\": \"Videos\",\n  \"view_all\": \"View All\",\n  \"weight\": \"Weight\",\n  \"weight_kg\": \"Weight (kg)\",\n  \"years_old\": \"years old\",\n  \"zip_file_count\": \"Zip File Count\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/en-US.json",
    "content": "{\n    \"actions\": {\n        \"anonymise\": \"Anonymize\",\n        \"download_anonymised\": \"Download anonymized\",\n        \"customise\": \"Customize\",\n        \"optimise_database\": \"Optimize Database\"\n    },\n    \"config\": {\n        \"tools\": {\n            \"scene_filename_parser\": {\n                \"ignore_organized\": \"Ignore organized scenes\"\n            }\n        },\n        \"ui\": {\n            \"custom_locales\": {\n                \"heading\": \"Custom localization\",\n                \"option_label\": \"Custom localization enabled\"\n            }\n        }\n    },\n    \"eye_color\": \"Eye Color\",\n    \"favourite\": \"Favorite\",\n    \"hair_color\": \"Hair Color\",\n    \"organized\": \"Organized\",\n    \"performer_favorite\": \"Performer Favorited\",\n    \"component_tagger\": {\n        \"config\": {\n            \"mark_organized_label\": \"Mark as Organized on save\"\n        }\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/es-ES.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Añadir\",\n        \"add_directory\": \"Añadir directorio\",\n        \"add_entity\": \"Añadir {entityType}\",\n        \"add_to_entity\": \"Añadir a {entityType}\",\n        \"allow\": \"Permitir\",\n        \"allow_temporarily\": \"Permitir temporalmente\",\n        \"anonymise\": \"Anonimizar\",\n        \"apply\": \"Aplicar\",\n        \"auto_tag\": \"Etiquetado automático\",\n        \"backup\": \"Copia de seguridad\",\n        \"browse_for_image\": \"Explorar imagen…\",\n        \"cancel\": \"Cancelar\",\n        \"clean\": \"Limpiar\",\n        \"clear\": \"Eliminar\",\n        \"clear_back_image\": \"Eliminar contraportada\",\n        \"clear_front_image\": \"Eliminar portada\",\n        \"clear_image\": \"Eliminar imagen\",\n        \"close\": \"Cerrar\",\n        \"confirm\": \"Confirmar\",\n        \"continue\": \"Continuar\",\n        \"create\": \"Crear\",\n        \"create_chapters\": \"Crear capítulo\",\n        \"create_entity\": \"Crear {entityType}\",\n        \"create_marker\": \"Crear marcador\",\n        \"created_entity\": \"{entity_type} creado: {entity_name}\",\n        \"customise\": \"Personalizar\",\n        \"delete\": \"Eliminar\",\n        \"delete_entity\": \"Eliminar {entityType}\",\n        \"delete_file\": \"Eliminar archivo\",\n        \"delete_file_and_funscript\": \"Eliminar archivo (y funscript)\",\n        \"delete_generated_supporting_files\": \"Eliminar ficheros de soporte generados\",\n        \"disallow\": \"No permitir/Denegar\",\n        \"download\": \"Descargar\",\n        \"download_anonymised\": \"Descargar anonimizado\",\n        \"download_backup\": \"Descargar copia de seguridad\",\n        \"edit\": \"Editar\",\n        \"edit_entity\": \"Editar {entityType}\",\n        \"export\": \"Exportar\",\n        \"export_all\": \"Exportar todo…\",\n        \"find\": \"Buscar\",\n        \"finish\": \"Terminar\",\n        \"from_file\": \"Desde archivo…\",\n        \"from_url\": \"Desde dirección web…\",\n        \"full_export\": \"Exportación completa\",\n        \"full_import\": \"Importación completa\",\n        \"generate\": \"Generar\",\n        \"generate_thumb_default\": \"Generar miniatura por defecto\",\n        \"generate_thumb_from_current\": \"Generar miniatura del frame actual\",\n        \"hash_migration\": \"Migración hash\",\n        \"hide\": \"Ocultar\",\n        \"hide_configuration\": \"Ocultar configuración\",\n        \"identify\": \"Identificar\",\n        \"ignore\": \"Ignorar\",\n        \"import\": \"Importar…\",\n        \"import_from_file\": \"Importar desde archivo\",\n        \"logout\": \"Cerrar sesión\",\n        \"make_primary\": \"Establecer como primario\",\n        \"merge\": \"Unir\",\n        \"migrate_blobs\": \"Migrar blobs\",\n        \"migrate_scene_screenshots\": \"Migrar capturas de pantalla\",\n        \"next_action\": \"Próximo\",\n        \"not_running\": \"Apagado\",\n        \"open_in_external_player\": \"Abrir en reproductor externo\",\n        \"open_random\": \"Abrir aleatoria\",\n        \"overwrite\": \"Sobreescribir\",\n        \"play_random\": \"Reproducir aleatoria\",\n        \"play_selected\": \"Reproducir seleccionada\",\n        \"preview\": \"Vista previa\",\n        \"previous_action\": \"Atrás\",\n        \"reassign\": \"Reasignar\",\n        \"refresh\": \"Actualizar\",\n        \"reload_plugins\": \"Recargar complementos\",\n        \"reload_scrapers\": \"Recargar rastreadores\",\n        \"remove\": \"Borrar\",\n        \"remove_from_gallery\": \"Eliminar de la galería\",\n        \"rename_gen_files\": \"Renombrar archivos generados\",\n        \"rescan\": \"Volver a escanear\",\n        \"reshuffle\": \"Reorganizar\",\n        \"running\": \"En funcionamiento\",\n        \"save\": \"Guardar\",\n        \"save_delete_settings\": \"Usar estas opciones por defecto durante la eliminación\",\n        \"save_filter\": \"Guardar filtro\",\n        \"scan\": \"Escanear\",\n        \"scrape\": \"Rastrear\",\n        \"scrape_query\": \"Cadena de consulta para el rastreo\",\n        \"scrape_scene_fragment\": \"Rastrear por fragmento de vídeo\",\n        \"scrape_with\": \"Rastrear con…\",\n        \"search\": \"Buscar\",\n        \"select_all\": \"Seleccionar todo\",\n        \"select_entity\": \"Seleccionar {entityType}\",\n        \"select_folders\": \"Seleccionar carpetas\",\n        \"select_none\": \"Deseleccionar todo\",\n        \"selective_auto_tag\": \"Etiquetado automático selectivo\",\n        \"selective_clean\": \"Limpieza selectiva\",\n        \"selective_scan\": \"Escaneo selectivo\",\n        \"set_as_default\": \"Establecer por defecto\",\n        \"set_back_image\": \"Contraportada…\",\n        \"set_front_image\": \"Portada…\",\n        \"set_image\": \"Subir imagen…\",\n        \"show\": \"Mostrar\",\n        \"show_configuration\": \"Mostrar configuración\",\n        \"skip\": \"Saltar\",\n        \"split\": \"Dividir\",\n        \"stop\": \"Parar\",\n        \"submit\": \"Enviar\",\n        \"submit_stash_box\": \"Enviar a Stash-Box\",\n        \"submit_update\": \"Enviar Actualización\",\n        \"swap\": \"Intercambia\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"¿Estás seguro que quieres iniciar la limpieza? Esto eliminará la información en la base de datos, y el contenido generado para todas las escenas y galerías que ya no estén disponibles en el sistema de ficheros.\",\n            \"dry_mode_selected\": \"Modo de simulación seleccionado. No se eliminará información, solo se guardarán registros de las acciones a realizar.\",\n            \"import_warning\": \"¿Estás seguro que quieres llevar a cabo la importación? Esta acción eliminará la base de datos e importará la información desde los metadatos previamente exportados.\"\n        },\n        \"temp_disable\": \"Deshabilitar temporalmente…\",\n        \"temp_enable\": \"Habilitar temporalmente…\",\n        \"unset\": \"Desactivar\",\n        \"use_default\": \"Predeterminada\",\n        \"view_random\": \"Ver aleatoria\",\n        \"add_manual_date\": \"Añadir fecha manualmente\",\n        \"add_o\": \"Añadir O\",\n        \"add_play\": \"Agregar reproducir\",\n        \"reload\": \"Recargar\",\n        \"disable\": \"Desactivar\",\n        \"optimise_database\": \"Optimizar la base de datos\",\n        \"encoding_image\": \"Codificando imagen…\",\n        \"assign_stashid_to_parent_studio\": \"Asignar Stash ID al estudio matriz existente y actualizar metadatos\",\n        \"choose_date\": \"Elija una fecha\",\n        \"clean_generated\": \"Limpiar archivos generados\",\n        \"clear_date_data\": \"Eliminar datos de fecha\",\n        \"copy_to_clipboard\": \"Copiar al portapapeles\",\n        \"create_parent_studio\": \"Crear estudio matriz\",\n        \"enable\": \"Activar\",\n        \"remove_date\": \"Eliminar fecha\",\n        \"view_history\": \"Ver historial\",\n        \"add_sub_groups\": \"Añadir subgrupos\",\n        \"remove_from_containing_group\": \"Eliminar del grupo\",\n        \"reset_play_duration\": \"Reiniciar la duración de la reproducción\",\n        \"load\": \"Cargar\",\n        \"load_filter\": \"Cargar el filtro\",\n        \"play\": \"Reproducir\",\n        \"reset_resume_time\": \"Restablecer el tiempo de reanudación\",\n        \"reset_cover\": \"Restaurar portada por defecto\",\n        \"set_cover\": \"Establecer como portada\",\n        \"show_results\": \"Mostrar resultados\",\n        \"show_count_results\": \"Mostrar {count} resultados\",\n        \"sidebar\": {\n            \"close\": \"Cerrar barra lateral\",\n            \"open\": \"Abrir barra lateral\",\n            \"toggle\": \"Alternar barra lateral\"\n        },\n        \"add_stash_id\": \"Añadir ID de Stash\",\n        \"create_new\": \"Crear nuevo\",\n        \"save_and_new\": \"Guardar y nuevo\",\n        \"invert_selection\": \"Invertir selección\",\n        \"select_directory\": \"Seleccionar directorio\",\n        \"reveal_in_file_manager\": \"Mostrar en el Administrador de archivos\",\n        \"exclude_lowercase\": \"excluir\",\n        \"from_clipboard\": \"Desde el portapapeles\",\n        \"selective_generate\": \"Generación selectiva\",\n        \"create_parent_tag\": \"Crear etiqueta principal\"\n    },\n    \"actions_name\": \"Acciones\",\n    \"age\": \"Edad\",\n    \"aliases\": \"Pseudónimos\",\n    \"all\": \"todos\",\n    \"also_known_as\": \"También conocida como\",\n    \"ascending\": \"Ascendente\",\n    \"average_resolution\": \"Resolución media\",\n    \"between_and\": \"y\",\n    \"birth_year\": \"Año de nacimiento\",\n    \"birthdate\": \"Cumpleaños\",\n    \"bitrate\": \"Tasa de bits\",\n    \"blobs_storage_type\": {\n        \"database\": \"Base de datos\",\n        \"filesystem\": \"Sistema de ficheros\"\n    },\n    \"captions\": \"Subtítulos\",\n    \"career_length\": \"Años en activo\",\n    \"chapters\": \"Capítulos\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Instancia activa de stash-box:\",\n            \"blacklist_desc\": \"Los elementos de la lista negra son excluidos de las consultas. Nota: los elementos son expresiones regulares y son insensitivos a las mayúsculas. Ciertos caracteres deben ser denotados con una barra invertida: {chars_require_escape}\",\n            \"blacklist_label\": \"Lista de exclusiones\",\n            \"query_mode_auto\": \"Auto\",\n            \"query_mode_auto_desc\": \"Usa metadatos, si están presentes, o el nombre del archivo\",\n            \"query_mode_dir\": \"Directorio\",\n            \"query_mode_dir_desc\": \"Solo usa el directorio que contiene el archivo de vídeo\",\n            \"query_mode_filename\": \"Nombre de archivo\",\n            \"query_mode_filename_desc\": \"Solo usa el nombre del fichero\",\n            \"query_mode_label\": \"Modo de consulta\",\n            \"query_mode_metadata\": \"Metadatos\",\n            \"query_mode_metadata_desc\": \"Solo usa metadatos\",\n            \"query_mode_path\": \"Ruta\",\n            \"query_mode_path_desc\": \"Emplea la ruta completa del archivo\",\n            \"set_cover_desc\": \"Reemplazar la carátula de la escena si alguna es encontrada.\",\n            \"set_cover_label\": \"Seleccionar carátula de la escena\",\n            \"set_tag_desc\": \"Adjuntar etiquetas a la escena, ya sea sobreescribiéndolas (elimina las que existieran previamente) o fusionando las nuevas con las que ya existían previamente.\",\n            \"set_tag_label\": \"Seleccionar etiquetas\",\n            \"source\": \"Fuente\",\n            \"mark_organized_desc\": \"Marcar la escena como organizada tras pulsar el botón de Guardar.\",\n            \"mark_organized_label\": \"Marcar como organizado al guardar\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Elemento duplicado en la lista negra\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Géneros de los actores\",\n                \"description\": \"Los actores con estos géneros se mostrarán al etiquetar escenas.\"\n            }\n        },\n        \"noun_query\": \"Consulta\",\n        \"results\": {\n            \"duration_off\": \"La duración se diferencia en, al menos, {number} segundos\",\n            \"duration_unknown\": \"Duración desconocida\",\n            \"fp_found\": \"{fpCount, plural, =0 {No se han encontrado nuevas huellas dactilares} other {# resultados de huellas dactilares encontrados}}\",\n            \"fp_matches\": \"La duración coincide\",\n            \"fp_matches_multi\": \"{matchCount}/{durationsLength} cincidencias de fingerprint(s)\",\n            \"hash_matches\": \"{hash_type} coincide\",\n            \"match_failed_already_tagged\": \"Escena ya etiquetada\",\n            \"match_failed_no_result\": \"No se han encontrado resultados\",\n            \"match_success\": \"Escena etiquetada correctamente\",\n            \"phash_matches\": \"{count} PHash(es) coincide(n)\",\n            \"unnamed\": \"Sin nombre\"\n        },\n        \"verb_match_fp\": \"huellas dactilares\",\n        \"verb_matched\": \"Coincidencia\",\n        \"verb_scrape_all\": \"Rastrear todo\",\n        \"verb_submit_fp\": \"Enviar {fpCount, plural, one{# huella dactilar} other{# huellas dactilares}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} escenas sin coincidencia\",\n        \"verb_add_as_alias\": \"Añadir el nombre extraído como alias\",\n        \"verb_link_existing\": \"Enlace a existente\",\n        \"verb_match_tag\": \"Coincidir etiqueta\",\n        \"verb_scrape_selected\": \"Proveedor de metadatos seleccionado\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Hash de la versión instalada:\",\n            \"build_time\": \"Fecha de la versión instalada:\",\n            \"check_for_new_version\": \"Buscar versión actualizada\",\n            \"latest_version\": \"Última Versión\",\n            \"latest_version_build_hash\": \"Hash de la última versión:\",\n            \"new_version_notice\": \"[NUEVA]\",\n            \"release_date\": \"Fecha de publicación:\",\n            \"stash_discord\": \"Únete a nuestro canal {url}\",\n            \"stash_home\": \"Página principal del proyecto en {url}\",\n            \"stash_open_collective\": \"Donaciones al proyecto a través de {url}\",\n            \"stash_wiki\": \"Documentación del proyecto en nuestro {url}\",\n            \"version\": \"Versión\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Rutas de la Aplicación\"\n        },\n        \"categories\": {\n            \"about\": \"Acerca de\",\n            \"changelog\": \"Registro de cambios\",\n            \"interface\": \"Diseño\",\n            \"logs\": \"Registros\",\n            \"metadata_providers\": \"Proveedores de Metadatos\",\n            \"plugins\": \"Complementos\",\n            \"scraping\": \"Rastreo\",\n            \"security\": \"Seguridad\",\n            \"services\": \"Servicios\",\n            \"system\": \"Sistema\",\n            \"tasks\": \"Tareas\",\n            \"tools\": \"Herramientas\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Permitir {tempIP}\",\n            \"allowed_ip_addresses\": \"Direcciones IP permitidas\",\n            \"allowed_ip_temporarily\": \"IP permitida temporalmente\",\n            \"default_ip_whitelist\": \"Lista de direcciones IP permitidas\",\n            \"default_ip_whitelist_desc\": \"Direcciones IP que tienen acceso al servidor DLNA por defecto. Escribe {wildcard} para permitir todas las direcciones IP.\",\n            \"disabled_dlna_temporarily\": \"Deshabilitar DLNA temporalmente\",\n            \"disallowed_ip\": \"IP no permitida\",\n            \"enabled_by_default\": \"Activado por defecto\",\n            \"enabled_dlna_temporarily\": \"Habilitar DLNA temporalmente\",\n            \"network_interfaces\": \"Interfaces\",\n            \"network_interfaces_desc\": \"Interfaces en las que transmitirá el servidor DLNA. Si no se selecciona ninguna se mostrará para todas las interfaces disponibles. Los cambios se guardarán tras reiniciar el servidor DLNA.\",\n            \"recent_ip_addresses\": \"Direcciones IP recientes\",\n            \"server_display_name\": \"Nombre del servidor\",\n            \"server_display_name_desc\": \"Nombre del servidor DLNA. Si no se indica un valor se usará {server_name} por defecto.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Comportamiento temporal eliminado correctamente\",\n            \"until_restart\": \"hasta reinicio\",\n            \"video_sort_order\": \"Orden predeterminado de clasificación de vídeos\",\n            \"video_sort_order_desc\": \"Forma de organizar videos por defecto.\",\n            \"server_port\": \"Puerto del servidor\",\n            \"server_port_desc\": \"Puerto para ejecutar el servidor DLNA. Requiere reinicio de DLNA después del cambio.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"Clave API\",\n                \"api_key_desc\": \"Clave API para sistemas externos. Requiere la configuración de usuario y contraseña. El nombre de usuario debe ser guardado antes de generar una clave API.\",\n                \"authentication\": \"Autentificación\",\n                \"clear_api_key\": \"Borrar clave API\",\n                \"credentials\": {\n                    \"description\": \"Credenciales para restringir acceso a Stash.\",\n                    \"heading\": \"Credenciales\"\n                },\n                \"generate_api_key\": \"Generar clave API\",\n                \"log_file\": \"Archivo de registro\",\n                \"log_file_desc\": \"Ruta del archivo en el que se volcarán los registros de eventos. En blanco deshabilita el fichero de registro. Los cambios se guardarán tras reiniciar la aplicación.\",\n                \"log_http\": \"Registro de accesos HTTP\",\n                \"log_http_desc\": \"Registro de accesos HTTP en consola. Requiere reinicio de la aplicación.\",\n                \"log_to_terminal\": \"Registro de eventos en consola\",\n                \"log_to_terminal_desc\": \"Registro de eventos en la consola de Stash además del respaldo en fichero. Habilitado si el registro a fichero está deshabilitado. Requiere reinicio de la aplicación.\",\n                \"maximum_session_age\": \"Tiempo máximo de sesión\",\n                \"maximum_session_age_desc\": \"Tiempo máximo de inactividad antes de que expire una sesión de inicio de sesión, en segundos. Requiere reinicio.\",\n                \"password\": \"Contraseña\",\n                \"password_desc\": \"Contraseña para acceder a Stash. Dejar en blanco para deshabilitar la exigencia de identificación para acceder a la aplicación\",\n                \"stash-box_integration\": \"Integración Stash-box\",\n                \"username\": \"Usuario\",\n                \"username_desc\": \"Usuario para acceder a Stash. Dejar en blanco para deshabilitar la exigencia de identificación para acceder a la aplicación\",\n                \"log_file_max_size\": \"Tamaño máximo del registro\",\n                \"log_file_max_size_desc\": \"Tamaño máximo en megabytes del archivo de registro antes de comprimirlo. 0 MB está desactivado. Requiere reiniciar.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Ubicación del directorio para copias de seguridad de archivos de bases de datos SQLite.\",\n                \"heading\": \"Ruta del directorio de la copia de seguridad\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Donde almacenar ficheros binarios. Solo aplicable cuando el sistema de archivos es del tipo \\\"blob\\\". AVISO: Cambiar este parámetro requiere mover manualmente los ficheros.\",\n                \"heading\": \"Ruta de sistema de archivos binario\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Donde almacenar información binaria como por ejemplo imágenes de escenas, actores, estudios y etiquetas. Tras cambiar este valor, los datos existentes tienen que ser migrados usando la tarea \\\"Migrar blobs\\\". Ver la página \\\"Tareas\\\" para la migración.\",\n                \"heading\": \"Almacenamiento de datos binario\"\n            },\n            \"cache_location\": \"Ruta de la caché. Requerido para utilizar streaming mediante HLS (por ejemplo dispositivos Apple) o DASH.\",\n            \"cache_path_head\": \"Ruta relativa para la caché\",\n            \"calculate_md5_and_ohash_desc\": \"Calcular comprobación MD5 en adición a oshash. Habilitar esta opción puede provocar que los escaneos iniciales resulten más lentos. El cálculo de hash del nombre del fichero debe establecerse en oshash para deshabilitar el cálculo MD5.\",\n            \"calculate_md5_and_ohash_label\": \"Calcular MD5 para los vídeos\",\n            \"check_for_insecure_certificates\": \"Comprobación para certificados no seguros\",\n            \"check_for_insecure_certificates_desc\": \"Algunos sitios utilizan certificados SSL inseguros. Cuando la casilla esté desmarcada el rastreador se saltará la comprobación de certificados inseguros y permitirá el rastreo de esos sitios web. Si el resultado es un error de certificado al rastrear entonces desmarca esta opción.\",\n            \"chrome_cdp_path\": \"Ruta Chrome CDP\",\n            \"chrome_cdp_path_desc\": \"Ruta del archivo del ejecutable Chrome o una dirección remota (comenzando por http:// o https://, por ejemplo, http://localhost:9222/json/version) para una instancia Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Si esta opción está marcada, se crearán galerías de aquellos directorios que contienen imágenes de forma predeterminada. Crea un archivo llamado .forcegallery o .nogallery en una carpeta para anular esta configuración.\",\n            \"create_galleries_from_folders_label\": \"Crear galerías desde directorios con imágenes\",\n            \"database\": \"Base de datos\",\n            \"db_path_head\": \"Ruta de la base de datos\",\n            \"directory_locations_to_your_content\": \"Ruta relativa de los directorios que almacenan el contenido\",\n            \"excluded_image_gallery_patterns_desc\": \"Expresiones regulares de archivos/rutas de imágenes que serán excluidos del escaneo y que serán añadidos a la tarea de depuración/limpieza.\",\n            \"excluded_image_gallery_patterns_head\": \"Patrones de imágenes/galerías excluidos\",\n            \"excluded_video_patterns_desc\": \"Expresiones regulares de archivos/rutas de vídeos que serán excluidos del escaneo y que serán añadidos a la tarea de depuración/limpieza.\",\n            \"excluded_video_patterns_head\": \"Patrones de vídeo excluidos\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"Utiliza el hardware disponible para encodificar el video en tiempo real.\",\n                    \"heading\": \"Encodificación hardware FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avanzado: Argumentos adicionales para ffmpeg antes del campo de entrada al transcodificar vídeos en tiempo real.\",\n                        \"heading\": \"Argumentos entrada transcodificación FFmpeg en tiempo real\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avanzado: Argumentos adicionales para ffmpeg antes del campo de salida al transcodificar vídeos en tiempo real.\",\n                        \"heading\": \"Argumentos salida transcodificación FFmpeg en tiempo real\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avanzado: Argumentos adicionales para ffmpeg antes del campo de entrada al generar vídeos.\",\n                        \"heading\": \"Argumentos entrada transcodificación FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avanzado: Argumentos adicionales para ffmpeg antes del campo de salida al generar vídeos.\",\n                        \"heading\": \"Argumentos salida transcodificación FFmpeg\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"Descargar FFmpeg\",\n                    \"description\": \"Descarga FFmpeg en el directorio de configuración y borra las rutas de ffmpeg y ffprobe para resolverlas desde el directorio de configuración.\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"Ruta del ejecutable FFmpeg\",\n                    \"description\": \"Ruta al ejecutable ffmpeg (no solo la carpeta). Si está vacía, ffmpeg se resolverá desde el entorno a través de $PATH, el directorio de configuración o $HOME/.stash.\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Ruta al ejecutable ffprobe (no solo la carpeta). Si está vacía, ffprobe se resolverá desde el entorno a través de $PATH, el directorio de configuración o $HOME/.stash.\",\n                    \"heading\": \"Ruta del ejecutable FFprobe\"\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"Inluir rango en los mapas de calor generados\",\n            \"funscript_heatmap_draw_range_desc\": \"Dibujar rango de movimiento en el eje \\\"y\\\" del mapa de calor generado. Los mapas de calor existentes tendrán que ser generados de nuevo tras el cambio.\",\n            \"gallery_cover_regex_desc\": \"Expresión regular utilizada para identificar una imagen como carátula de una galería.\",\n            \"gallery_cover_regex_label\": \"Patrón carátula galería\",\n            \"gallery_ext_desc\": \"Lista delimitada por comas de extensiones de archivo que serán identificados como archivos de galería en formato ZIP.\",\n            \"gallery_ext_head\": \"Extensiones de galería ZIP\",\n            \"generated_file_naming_hash_desc\": \"Usar MD5 o oshash para la los nombres de archivo generados. Cambiar esta opción requiere que todas las escenas tengan relleno el correspondiente valor MD5/oshash. Después de cambiar este valor los ficheros generados existentes tendrán que ser migrados o regenerados. Ver la página de tareas para llevar a cabo la migración.\",\n            \"generated_file_naming_hash_head\": \"Hash de nombre de archivo generado\",\n            \"generated_files_location\": \"Ruta relativa del directorio para los ficheros generados (marcadores de escena, vistas previas de escena, conjuntos de imágenes o “sprites”, etc).\",\n            \"generated_path_head\": \"Ruta para el directorio de archivos generados\",\n            \"hashing\": \"Función hash\",\n            \"heatmap_generation\": \"Generación mapa de calor Funscript\",\n            \"image_ext_desc\": \"Lista delimitada por comas de las extensiones de archivo que serán identificadas como imágenes.\",\n            \"image_ext_head\": \"Extensiones de imagen\",\n            \"include_audio_desc\": \"Incluye flujo de audio cuando se generen vistas previas.\",\n            \"include_audio_head\": \"Incluir audio\",\n            \"logging\": \"Registros\",\n            \"maximum_streaming_transcode_size_desc\": \"Tamaño máximo para la transcodificación de archivos de vídeo en streaming.\",\n            \"maximum_streaming_transcode_size_head\": \"Tamaño máximo de transcodificación en streaming\",\n            \"maximum_transcode_size_desc\": \"Tamaño máximo para las transcodificaciones generadas.\",\n            \"maximum_transcode_size_head\": \"Tamaño máximo de transcodificación\",\n            \"metadata_path\": {\n                \"description\": \"Ubicación del directorio utilizada al realizar una exportación o importación completa.\",\n                \"heading\": \"Ruta de metadatos\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Seleccionar 0 para detección automática. Advertencia: lanzar más tareas de las requeridas puede provocar una elevada utilización de la CPU pudiendo provocar lentitud en el sistema y otros problemas.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Número de tareas en paralelo para escaneo/generación de ficheros\",\n            \"parallel_scan_head\": \"Escaneo/Generación en paralelo\",\n            \"preview_generation\": \"Previsualizar generación de ficheros\",\n            \"python_path\": {\n                \"description\": \"Ruta al ejecutable python (no sólo la carpeta). Usado para script scrapers y plugins. Si está en blanco, Python se resolverá desde el entorno.\",\n                \"heading\": \"Ruta del ejecutable Python\"\n            },\n            \"scraper_user_agent\": \"Identificador del agente de usuario del navegador para rastreo\",\n            \"scraper_user_agent_desc\": \"Identificador del agente de usuario del navegador web (User-Agent) usado durante el rastreo mediante peticiones HTTP.\",\n            \"scrapers_path\": {\n                \"description\": \"Ubicación del directorio de los archivos de configuración del scraper.\",\n                \"heading\": \"Ruta de rastreadores\"\n            },\n            \"scraping\": \"Rastreo\",\n            \"sqlite_location\": \"Ruta relativa para la base de datos SQLite (requiere reinicio). AVISO: Almacenar la base de datos en un sistema distinto al servidor Stash (por ejemplo a través de la red) no está soportado!\",\n            \"video_ext_desc\": \"Lista delimitada por comas de las extensiones de archivo que serán identificadas como vídeos.\",\n            \"video_ext_head\": \"Extensiones de vídeo\",\n            \"video_head\": \"Vídeo\",\n            \"plugins_path\": {\n                \"description\": \"Ubicación del directorio de los archivos de configuración de complementos.\",\n                \"heading\": \"Ruta de complementos\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"Ruta a la que se moverán los archivos eliminados en lugar de eliminarlos permanentemente. Déjela vacía para eliminar los archivos de forma permanente.\",\n                \"heading\": \"Ruta de la papelera\"\n            },\n            \"sprite_generation_head\": \"Generación de sprites\",\n            \"sprite_interval_desc\": \"Tiempo entre cada sprite generado en segundos.\",\n            \"sprite_interval_head\": \"Intervalo de sprite\",\n            \"sprite_maximum_desc\": \"Número máximo de sprites que se pueden generar para una escena. Establezca el valor en 0 para desactivar el límite.\",\n            \"sprite_maximum_head\": \"Máximo de sprites\",\n            \"sprite_minimum_desc\": \"Número mínimo de sprites que se generarán para una escena\",\n            \"sprite_minimum_head\": \"Mínimo de sprites\",\n            \"sprite_screenshot_size_desc\": \"Tamaño deseado de cada sprite en píxeles.\",\n            \"sprite_screenshot_size_head\": \"Tamaño del sprite\",\n            \"use_custom_sprite_interval_head\": \"Usar intervalo de sprites personalizado\",\n            \"use_custom_sprite_interval_desc\": \"Habilita el intervalo de sprites personalizado según la configuración siguiente.\"\n        },\n        \"library\": {\n            \"exclusions\": \"Exclusiones\",\n            \"gallery_and_image_options\": \"Opciones para Imágenes y Galerías\",\n            \"media_content_extensions\": \"Extensiones para Contenido Multimedia\"\n        },\n        \"logs\": {\n            \"log_level\": \"Nivel de registro\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Hooks\",\n            \"triggers_on\": \"Se lleva a cabo durante\",\n            \"installed_plugins\": \"Complementos instalados\",\n            \"available_plugins\": \"Complementos disponibles\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"Metadatos de {entityType}\",\n            \"entity_scrapers\": \"Rastreadores de {entityType}\",\n            \"excluded_tag_patterns_desc\": \"Expresiones regulares de nombres de etiquetas para excluir de los resultados del rastreo.\",\n            \"excluded_tag_patterns_head\": \"Patrones de etiquetas excluidos\",\n            \"scraper\": \"Rastreador\",\n            \"scrapers\": \"Rastreadores\",\n            \"search_by_name\": \"Búsqueda por nombre\",\n            \"supported_types\": \"Tipos admitidos\",\n            \"supported_urls\": \"Direcciones web\",\n            \"available_scrapers\": \"Rastreadores disponibles\",\n            \"installed_scrapers\": \"Rastreadores instalados\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Añadir instancia stash-box\",\n            \"api_key\": \"Clave API\",\n            \"description\": \"Stash-box permite automatizar el etiquetado de escenas, actores y actrices, basado en huellas dactilares y nombres de archivo.\\nEl terminal de red y la clave API pueden ser encontrados en tu página de usuario de la instancia stash-box. Los nombres son requeridos cuando se agregue más de una instancia stash-box.\",\n            \"endpoint\": \"Terminal de red\",\n            \"graphql_endpoint\": \"Terminal de red GraphQL\",\n            \"name\": \"Nombre\",\n            \"title\": \"Terminal de red Stash-box\",\n            \"max_requests_per_minute\": \"Máximas peticiones por minuto\",\n            \"max_requests_per_minute_description\": \"Utiliza el valor predeterminado {defaultValue} si se establece en 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transcodificación\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Añadido/a {operation_name} a la cola de trabajo\",\n            \"anonymise_and_download\": \"Realiza una copia aninimizada de la base de datos y descarga el fichero resultante.\",\n            \"anonymise_database\": \"Hace una copia de la base de datos al directorio de copias de seguridad anonimizando toda la información sensible. Esta copia se puede proveer a terceros para solucionar y depurar problemas. La base de datos original no es modificada. La base de datos anonimizada se almacena con el formato {filename_format}.\",\n            \"anonymising_database\": \"Aninimizando base de datos\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Etiquetar automáticamente todas las rutas\",\n                \"auto_tagging_paths\": \"Etiquetar automáticamente las siguientes rutas\"\n            },\n            \"auto_tag_based_on_filenames\": \"Etiquetado automatizado del contenido basado en nombres de archivo.\",\n            \"auto_tagging\": \"Etiquetado automático\",\n            \"backing_up_database\": \"Guardando respaldo de la base de datos\",\n            \"backup_and_download\": \"Lleva a cabo una copia de seguridad de la base de datos y la guarda en un fichero de respaldo.\",\n            \"cleanup_desc\": \"Buscar ficheros eliminados del sistema de archivos y eliminarlos de la base de datos. PRECAUCIÓN: esta es una acción destructiva.\",\n            \"data_management\": \"Gestión de datos\",\n            \"defaults_set\": \"Las opciones por defecto se han guardado y serán usadas cada vez que pulses el botoón de {action} en la página de Tareas.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"No incluir la extensión del archivo como parte del título\",\n            \"empty_queue\": \"Actualmente no hay tareas en ejecución.\",\n            \"export_to_json\": \"Exporta el contenido de la base de datos en formato JSON en el directorio de metadatos.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Generando multimedia de soporte para las escenas en las siguientes rutas\",\n                \"generating_scenes\": \"Generando multimedia de soporte para {num} {scene}\"\n            },\n            \"generate_desc\": \"Generar imagen de soporte, conjuntos de imágenes, vídeo, vtt y resto de archivos.\",\n            \"generate_phashes_during_scan\": \"Generar hash perceptuales de vídeo\",\n            \"generate_phashes_during_scan_tooltip\": \"Para búsqueda de duplicados e identificación de escenas.\",\n            \"generate_previews_during_scan\": \"Generar imágenes previas animadas\",\n            \"generate_previews_during_scan_tooltip\": \"También genera vistas previas animadas (webp), solo necesarias cuando el tipo de vista previa del Muro de Escena/Marcador está configurado en Imagen animada. Al navegar, consumen menos CPU que las vistas previas de vídeo, pero se generan además de estas y son archivos más grandes.\",\n            \"generate_sprites_during_scan\": \"Generar miniaturas de imagen del reproductor de vídeo\",\n            \"generate_thumbnails_during_scan\": \"Generar miniaturas de las imágenes\",\n            \"generate_video_covers_during_scan\": \"Generar carátulas de escenas\",\n            \"generate_video_previews_during_scan\": \"Generar vistas previas\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generar vistas previas en vídeo que se reproducen al pasar el ratón por encima de una escena\",\n            \"generated_content\": \"Contenido generado\",\n            \"identify\": {\n                \"and_create_missing\": \"y crear no existentes\",\n                \"create_missing\": \"Crear no existentes\",\n                \"default_options\": \"Opciones por defecto\",\n                \"description\": \"Obtener automáticamente metadatos para las escenas utilizando una instancia stash-box y los rastreadores instalados.\",\n                \"explicit_set_description\": \"Estas opciones se usarán predeterminadamente si no se sobreescriben en las opciones específicas para cada fuente.\",\n                \"field\": \"Campo\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Opciones de campo\",\n                \"heading\": \"Identificar\",\n                \"identifying_from_paths\": \"Identificación de las escenas que se encuentren en las siguientes rutas\",\n                \"identifying_scenes\": \"Identificando {num} {scene}\",\n                \"include_male_performers\": \"Incluir actores varones\",\n                \"set_cover_images\": \"Selección automática de carátula de escena\",\n                \"set_organized\": \"Marcar escena como \\\"clasificada\\\"\",\n                \"source\": \"Fuente\",\n                \"source_options\": \"Opciones de {source}\",\n                \"sources\": \"Fuentes\",\n                \"strategy\": \"Estrategia\",\n                \"skip_multiple_matches\": \"Omitir coincidencias que tengan más de un resultado\",\n                \"skip_multiple_matches_tooltip\": \"Si está desactivado y se obtiene más de un resultado, se elegirá una coincidencia al azar\",\n                \"skip_single_name_performers\": \"Saltar intérpretes con un solo nombre sin desambiguación\",\n                \"skip_single_name_performers_tooltip\": \"Si esto no está habilitado, se emparejarán artistas que a menudo son genéricos como Samantha u Olga\",\n                \"tag_skipped_performer_tooltip\": \"Crea una etiqueta como \\\"Identificar: Artista con un nombre\\\" que puedes filtrar en el etiquetador de escenas y elegir cómo quieres tratar a estos artistas\",\n                \"tag_skipped_matches\": \"Etiquetar coincidencias omitidas con\",\n                \"tag_skipped_matches_tooltip\": \"Cree una etiqueta como 'Identificar: coincidencias múltiples' que pueda filtrar en la vista Etiquetador de escenas y elija la coincidencia correcta manualmente\",\n                \"tag_skipped_performers\": \"Etiqueta a los intérpretes omitidos con\",\n                \"performer_genders\": \"Género del intérprete\",\n                \"performer_genders_desc\": \"Los artistas con los géneros seleccionados serán incluidos en la identificación.\"\n            },\n            \"import_from_exported_json\": \"Importar desde el fichero JSON exportado en el directorio de metadatos. Borrará la base de datos existente.\",\n            \"incremental_import\": \"Importación gradual o progresiva desde un archivo zip de exportación aportado por el usuario.\",\n            \"job_queue\": \"Cola de tareas\",\n            \"maintenance\": \"Mantenimiento\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Borrar datos antiguos\",\n                \"description\": \"Migrar blobs al sistema de almacenamiento de blobs actual. Esta migración debería ejecutarse tras cambiar el sistema de almacenamiento de blobs. Opcionalmente se pueden borrar los datos antiguos tras la migración.\"\n            },\n            \"migrate_hash_files\": \"Se ejecutará tras realizar un cambio de tipo de hash para renombrar los ficheros generados al nuevo formato hash.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Borrar ficheros de capturas de pantalla\",\n                \"description\": \"Migrar capturas de pantalla de escenas al nuevo sistema de almacenamiento blob. Esta migración debería ejecutarse migrar un sistema existente a la versión 0.20. Opcionalmente se pueden borrar las capturas de pantalla antiguas tras la migración.\",\n                \"overwrite_existing\": \"Sobreescribir blobs existentes con datos de capturas de pantalla\"\n            },\n            \"migrations\": \"Migraciones\",\n            \"only_dry_run\": \"Marca esta casilla para ejecutar en MODO DE SIMULACIÓN. No se eliminará información alguna, solo se mostrarán en el registro las acciones a realizar\",\n            \"plugin_tasks\": \"Tareas de complementos\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Escaneando todas las rutas\",\n                \"scanning_paths\": \"Escaneando las siguientes rutas\"\n            },\n            \"scan_for_content_desc\": \"Buscar contenido nuevo y añadirlo a la base de datos.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Establecer el nombre, fecha y detalles desde los metadatos embebidos en el archivo\",\n            \"generate_clip_previews_during_scan\": \"Generar vistas previas para clips de imágenes\",\n            \"clean_generated\": {\n                \"image_thumbnails\": \"Miniaturas de imágenes\",\n                \"image_thumbnails_desc\": \"Miniaturas de imágenes y clips\",\n                \"markers\": \"Vista previa de marcadores\",\n                \"previews\": \"Vista previa de escenas\",\n                \"previews_desc\": \"Vista previa de escenas y miniaturas\",\n                \"sprites\": \"Sprites de escenas\",\n                \"transcodes\": \"Transcodificaciones de escenas\",\n                \"description\": \"Elimina los archivos generados sin una entrada correspondiente en la base de datos.\",\n                \"blob_files\": \"Archivos Blob\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"El conjunto de imágenes que se muestra bajo el reproductor de vídeo para facilitar la navegación.\",\n            \"optimise_database_warning\": \"Advertencia: mientras se esté ejecutando esta tarea, cualquier operación que modifique la base de datos fallará, y dependiendo del tamaño de su base de datos, podría tardar varios minutos en completarse. También requiere como mínimo tanto espacio libre en disco como grande sea su base de datos, pero se recomienda 1.5 veces más.\",\n            \"optimise_database\": \"Intentar mejorar el rendimiento mediante el análisis y posterior reconstrucción del archivo de base de datos completo.\",\n            \"rescan_tooltip\": \"Volver a analizar todos los archivos en la ruta. Se usa para forzar la actualización de metadatos y contenidos de archivos ZIP.\",\n            \"rescan\": \"Reanalizar archivos\",\n            \"generate_image_phashes_during_scan\": \"Generar hash perceptuales de imágenes\",\n            \"generate_image_phashes_during_scan_tooltip\": \"Para la deduplicación y la identificación.\",\n            \"backup_database\": {\n                \"description\": \"Realiza una copia de seguridad de la base de datos y los archivos blob.\",\n                \"destination\": \"Destino\",\n                \"download\": \"Descargar copia de seguridad\",\n                \"include_blobs\": \"Incluir blobs en la copia de seguridad\",\n                \"include_blobs_desc\": \"Desactivar para realizar únicamente una copia de seguridad del archivo de base de datos SQLite.\",\n                \"sqlite\": \"El archivo de copia de seguridad será una copia del archivo de base de datos SQLite, con el nombre de archivo {filename_format}\",\n                \"to_directory\": \"A {directory}\",\n                \"warning_blobs\": \"Los archivos blob no se incluirán en la copia de seguridad. Esto significa que, para restaurar correctamente desde la copia de seguridad, los archivos blob deben estar presentes en la ubicación de almacenamiento de blobs.\",\n                \"zip\": \"El archivo de base de datos SQLite y los archivos blob se comprimirán en un único archivo, con el nombre de archivo {filename_format}\"\n            }\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Comprobación de escenas duplicadas\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Añadir campo\",\n                \"capitalize_title\": \"Capitalizar título (todas las palabras comienzan por mayúscula)\",\n                \"display_fields\": \"Mostrar campos\",\n                \"escape_chars\": \"Usar \\\\ para evitar caracteres literales\",\n                \"filename\": \"Nombre de archivo\",\n                \"filename_pattern\": \"Patrón de nombres de ficheros\",\n                \"ignore_organized\": \"Ignorar escenas ya clasificadas\",\n                \"ignored_words\": \"Palabras ignoradas\",\n                \"matches_with\": \"Coincide con {i}\",\n                \"select_parser_recipe\": \"Seleccionar fórmula para el analizador sintáctico\",\n                \"title\": \"Analizador sintáctico del título de escena\",\n                \"whitespace_chars\": \"Espacios en blanco\",\n                \"whitespace_chars_desc\": \"Estos caracteres se reemplazarán en el título por espacios en blanco\"\n            },\n            \"scene_tools\": \"Herramientas de escenas\",\n            \"graphql_playground\": \"Entorno de pruebas de GraphQL\",\n            \"heading\": \"Herramientas\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Abrevie los contadores en las tarjetas y en las páginas de vista de detalles, por ejemplo, \\\"1831\\\" tendrá el formato \\\"1.8K\\\".\",\n                \"heading\": \"Abreviar contadores\"\n            },\n            \"basic_settings\": \"Ajustes básicos\",\n            \"custom_css\": {\n                \"description\": \"La página debe ser recargada para que se lleven a cabo los cambios realizados. Futuras versiones de Stash pueden no ser compatibles con CSS personalizados.\",\n                \"heading\": \"CSS personalizado\",\n                \"option_label\": \"Habilitar CSS personalizado\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"La página debe ser refrescada para que los cambios tomen efecto. Futuras versiones de Stash pueden no ser compatibles con JavaScript personalizados.\",\n                \"heading\": \"JavaScript personalizado\",\n                \"option_label\": \"JavaScript personalizado activada\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Sobreescribir traducciones individuales. El listado original se puede encontrar aquí: https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json. La página debe ser refrescada para reflejar los cambios realizados.\",\n                \"heading\": \"Localización personalizada\",\n                \"option_label\": \"Traducción personalizada activada\"\n            },\n            \"delete_options\": {\n                \"description\": \"Opciones por defecto al borrar escenas, imágenes y galerías.\",\n                \"heading\": \"Opciones de eliminación\",\n                \"options\": {\n                    \"delete_file\": \"Eliminar fichero por defecto\",\n                    \"delete_generated_supporting_files\": \"Eliminar por defecto ficheros generados\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Integración de escritorio\",\n                \"notifications_enabled\": \"Habilitar notificaciones\",\n                \"send_desktop_notifications_for_events\": \"Enviar notificaciones de escritorio de eventos.\",\n                \"skip_opening_browser\": \"Saltar apertura del navegador\",\n                \"skip_opening_browser_on_startup\": \"Saltar auto-apertura del navegador durante el inicio.\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Eliminar la capacidad de crear nuevos recursos desde los selectores de formulario desplegables.\",\n                    \"heading\": \"Deshabilitar creación en menú desplegable\"\n                },\n                \"heading\": \"Edición\",\n                \"max_options_shown\": {\n                    \"label\": \"Número máximo de elementos a mostrar en menús desplegables\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Precisión estrellas valoraciones\",\n                        \"options\": {\n                            \"full\": \"Lleno\",\n                            \"half\": \"Medias\",\n                            \"quarter\": \"Cuartos\",\n                            \"tenth\": \"Décimas\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Sistema de valoración\",\n                        \"options\": {\n                            \"decimal\": \"Decimal\",\n                            \"stars\": \"Estrellas\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Tiempo de compensación en milisegundos para la reproducción de scripts interactivos.\",\n                \"heading\": \"Desplazamiento de Funscript (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Conectar\",\n                \"server_offset\": {\n                    \"heading\": \"Tiempo compensación servidor\"\n                },\n                \"status\": {\n                    \"heading\": \"Estado conexión móvil\"\n                },\n                \"sync\": \"Sincronizar\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Clave para conexión práctica que se usará en las escenas interactivas. Configurar esta clave permitirá a Stash compartir la información actual de las escenas con handyfeeling.com.\",\n                \"heading\": \"Clave de conexión para Handy\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Lightbox para imágenes\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Dirección\",\n                \"heading\": \"Imagen de fondo\",\n                \"margin\": \"Margen (píxeles)\"\n            },\n            \"images\": {\n                \"heading\": \"Imágenes\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Guardar miniaturas de imagen en el sistema de archivos cuando son generadas al vuelo.\",\n                        \"heading\": \"Guardar miniaturas de imágenes\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"heading\": \"Escanear extensiones de vídeo como clips de imagen\",\n                        \"description\": \"Cuando una biblioteca tiene los videos desactivados, los archivos de video (archivos que terminan con una extensión de video) se escanearán como clips de imagen.\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Opciones Interactivas\",\n            \"language\": {\n                \"heading\": \"Lenguaje\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Duración máxima de la escena en la que el reproductor de escenas reproducirá el vídeo en bucle. Establezca 0 para desactivarlo.\",\n                \"heading\": \"Máxima duración del bucle\"\n            },\n            \"menu_items\": {\n                \"description\": \"Mostrar u ocultar los diferentes tipos de contenido del menú de navegación.\",\n                \"heading\": \"Elementos del menú\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Porcentaje de tiempo a reproducir una escena antes de incrementar su contador de visionados.\",\n                \"heading\": \"Porcentaje mínimo de reproducción\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Ruta personalizada para las imágenes de perfil de actrices/actores. Dejar en blanco para usar la ruta por defecto de la aplicación.\",\n                        \"heading\": \"Ruta para las imágenes personalizadas de actrices/actores\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"La opción predeterminada son las vistas previas de video (mp4). Para un menor uso de la CPU al navegar, puedes usar las vistas previas de imagen animada (webp). Sin embargo, deben generarse además de las vistas previas de video y son archivos más grandes.\",\n                \"heading\": \"Tipo de previsualización\",\n                \"options\": {\n                    \"animated\": \"Imagen animada\",\n                    \"static\": \"Imagen estática\",\n                    \"video\": \"Vídeo\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Vista de cuadrícula\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Mostrar superposición de estudio como texto\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Reproductor de vídeo\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Siempre iniciar vídeo desde el inicio\",\n                    \"auto_start_video\": \"Iniciar vídeo automáticamente\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Iniciar automáticamente los vídeos de escenas al reproducirlos desde la cola, o al reproducirlos de forma seleccionada o aleatoria desde la página Escenas.\",\n                        \"heading\": \"Comenzar automáticamente el vídeo cuando \\\"reproducir\\\" esté seleccionado\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Reproducir la siguiente escena cuando el fichero en reproducción finalice.\",\n                        \"heading\": \"(Por defecto) Continuar lista de reproducción\"\n                    },\n                    \"show_scrubber\": \"Mostrar depurador\",\n                    \"track_activity\": \"Habilitar el historial de reproducción de video\",\n                    \"disable_mobile_media_auto_rotate\": \"Desactivar la rotación automática de medios en pantalla completa en dispositivos móviles\",\n                    \"vr_tag\": {\n                        \"description\": \"El botón de VR solo se mostrará para escenas con esta etiqueta.\",\n                        \"heading\": \"Etiqueta de RV\"\n                    },\n                    \"enable_chromecast\": \"Habilitar Chromecast\",\n                    \"show_ab_loop_controls\": \"Mostrar controles de bucle AB\",\n                    \"show_range_markers\": \"Mostrar marcadores de rango\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Muro de escenas/Marcadores\",\n                \"options\": {\n                    \"display_title\": \"Mostrar título y etiquetas\",\n                    \"toggle_sound\": \"Habilitar sonido\"\n                }\n            },\n            \"slideshow_delay\": {\n                \"description\": \"La presentación de diapositivas estará disponible en las galerías al seleccionar el muro como modo de visualización.\",\n                \"heading\": \"Retardo en la presentación de diapositivas (segundos)\"\n            },\n            \"title\": \"Interfaz de usuario\",\n            \"show_tag_card_on_hover\": {\n                \"heading\": \"Sugerencias emergentes de la tarjeta de etiqueta\",\n                \"description\": \"Mostrar tarjeta de etiqueta al pasar el cursor sobre las insignias de etiquetas.\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Vista de estudio\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"En la vista de estudio, mostrar también el contenido de los subestudios.\",\n                        \"heading\": \"Mostar contenido de estudios filiales\"\n                    }\n                }\n            },\n            \"detail\": {\n                \"show_all_details\": {\n                    \"heading\": \"Mostrar todos los detalles\",\n                    \"description\": \"Si está habilitado, todo el contenido de detalles se mostrará por defecto y cada elemento se ajustará a una columna.\"\n                },\n                \"compact_expanded_details\": {\n                    \"heading\": \"Detalles expandidos compactos\",\n                    \"description\": \"Si está habilitada, esta opción mostrará el detalle completo manteniendo una presentación compacta.\"\n                },\n                \"enable_background_image\": {\n                    \"heading\": \"Habilitar imagen de fondo\",\n                    \"description\": \"Mostrar la imagen de fondo en la página de detalles.\"\n                },\n                \"heading\": \"Página de detalle\"\n            },\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"Cuando se activa, los funscripts se servirán directamente desde Stash a tu dispositivo Handy sin utilizar el servidor Handy de terceros. Requiere que Stash sea accesible desde tu dispositivo Handy, y que se genere una clave API si Stash tiene las credenciales configuradas.\",\n                \"heading\": \"Servir funscripts directamente\"\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Número de intentos de desplazamiento antes de pasar al siguiente/anterior elemento. Solo se aplica para el modo de desplazamiento Pan Y.\",\n                \"heading\": \"Intentos de desplazamiento antes de la transición\"\n            },\n            \"tag_panel\": {\n                \"heading\": \"Vista de etiquetas\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"Mostrar también contenido de subetiquetas en la vista de etiquetas.\",\n                        \"heading\": \"Mostrar contenido de subetiquetas\"\n                    }\n                }\n            },\n            \"sfw_mode\": {\n                \"heading\": \"Modo de contenido SFW\",\n                \"description\": \"Actívelo si utiliza Stash para almacenar contenido SFW. Oculta o modifica algunos aspectos de la interfaz de usuario relacionados con contenido para adultos.\"\n            },\n            \"performer_list\": {\n                \"heading\": \"Lista de actrices\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"Mostrar enlaces en las tarjetas de la cuadrícula de actores\"\n                    }\n                }\n            },\n            \"custom_title\": {\n                \"description\": \"Texto personalizado para añadir al título de la página. Si está vacío, el valor predeterminado es «Stash».\",\n                \"heading\": \"Título personalizado\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"Modo de resolución de problemas\",\n                \"dialog_title\": \"Habilitar modo de resolución de problemas\",\n                \"dialog_description\": \"Esto desactivará temporalmente todas las personalizaciones para ayudar a diagnosticar problemas:\",\n                \"dialog_item_plugins\": \"Todos los complementos\",\n                \"dialog_item_css\": \"CSS personalizado\",\n                \"dialog_item_js\": \"JavaScript personalizado\",\n                \"dialog_item_locales\": \"Configuraciones regionales personalizadas\",\n                \"dialog_log_level\": \"El nivel de registro se establecerá en Depuración para obtener diagnósticos detallados.\",\n                \"dialog_reload_note\": \"La página se recargará automáticamente.\",\n                \"enable\": \"Habilitar y recargar\",\n                \"overlay_message\": \"El modo de resolución de problemas está activo: todas las personalizaciones están desactivadas\",\n                \"exit\": \"Salir\"\n            }\n        },\n        \"advanced_mode\": \"Modo avanzado\",\n        \"changelog\": {\n            \"header\": \"Registro de cambios\"\n        }\n    },\n    \"configuration\": \"Configuración\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Archivo} other {Archivos}}\",\n        \"galleries\": \"{count, plural, one {Galería} other {Galerías}}\",\n        \"images\": \"{count, plural, one {Imagen} other {Imágenes}}\",\n        \"markers\": \"{count, plural, one {Marcador} other {Marcadores}}\",\n        \"performers\": \"{count, plural, one {Actriz/Actor} other {Actrices/Actores}}\",\n        \"scenes\": \"{count, plural, one {Escena} other {Escenas}}\",\n        \"studios\": \"{count, plural, one {Estudio} other {Estudios}}\",\n        \"tags\": \"{count, plural, one {Etiqueta} other {Etiquetas}}\",\n        \"groups\": \"{count, plural, one {Grupo} other {Grupos}}\"\n    },\n    \"country\": \"País\",\n    \"cover_image\": \"Carátula\",\n    \"created_at\": \"Fecha de creación\",\n    \"criterion\": {\n        \"greater_than\": \"Mayor que\",\n        \"less_than\": \"Menor que\",\n        \"value\": \"Valor\",\n        \"unsupported\": \"{type} (no compatible)\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"está entre\",\n        \"equals\": \"es\",\n        \"excludes\": \"no incluye\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"es mayor que\",\n        \"includes\": \"incluye\",\n        \"includes_all\": \"incluye todas\",\n        \"is_null\": \"es nulo\",\n        \"less_than\": \"es menor que\",\n        \"matches_regex\": \"coincide con la expresión regular\",\n        \"not_between\": \"no está entre\",\n        \"not_equals\": \"no es\",\n        \"not_matches_regex\": \"no coincide con la expresión regular\",\n        \"not_null\": \"no es nulo\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (excluyendo {excludedString})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (excluyendo {excludedString}) (+{depth, plural, =-1 {all} otro {{depth}}})\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})\"\n    },\n    \"custom\": \"Personalizado\",\n    \"date\": \"Fecha\",\n    \"death_date\": \"Fallecimiento\",\n    \"death_year\": \"Año de fallecimiento\",\n    \"descending\": \"Descendente\",\n    \"detail\": \"Detalle\",\n    \"details\": \"Detalles\",\n    \"developmentVersion\": \"Versión de desarrollo\",\n    \"dialogs\": {\n        \"delete_alert\": \"El/los siguiente/s {count, plural, one {{singularEntity}} other {{pluralEntity}}} se eliminará/n de forma permanente:\",\n        \"delete_confirm\": \"¿Estás seguro que deseas eliminar {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {¿Estás seguro que deseas eliminar esta {singularEntity}? Hasta que el archivo sea eliminado también, esta {singularEntity} se volverá a añadir cuando se lleve a cabo un escaneo.} other {¿Estás seguro que deseas eliminar {pluralEntity}? Hasta que los archivos sean eliminados del sistema de ficheros también, estas {pluralEntity} se volverán a añadir cuando se lleve a cabo un escaneo.}}\",\n        \"delete_entity_title\": \"{count, plural, one {Eliminar {singularEntity}} other {Eliminar {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"...y cualquier imagen no adjunta a alguna otra galería.\",\n        \"delete_gallery_files\": \"Eliminar directorio, fichero zip y cualquier imagen que no hayan sido adjuntadas a alguna galería.\",\n        \"delete_object_desc\": \"¿Estás seguro que deseas eliminar {count, plural, one {esta {singularEntity}} other {estas {pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…y {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Eliminar {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"edit_entity_title\": \"Editar {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Incluir objetos relacionados en la exportación\",\n        \"export_title\": \"Exportar\",\n        \"lightbox\": {\n            \"delay\": \"Retardo (seg)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Ajustar horizontalmente\",\n                \"fit_to_screen\": \"Ajustar a pantalla\",\n                \"label\": \"Modo de visualización\",\n                \"original\": \"Original\"\n            },\n            \"options\": \"Opciones\",\n            \"reset_zoom_on_nav\": \"Restaurar nivel de zoom al cambiar de imagen\",\n            \"scale_up\": {\n                \"description\": \"Aumentar imágenes pequeñas hasta rellenar la pantalla.\",\n                \"label\": \"Ampliación hasta ajuste\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Mantener pulsado la tecla shift para cambiar de modo temporalmente.\",\n                \"label\": \"Modo de desplazamiento\",\n                \"pan_y\": \"Panorámica eje Y\",\n                \"zoom\": \"Zoom\"\n            },\n            \"page_header\": \"Página {page} / {total}\",\n            \"disable_animation\": \"Desactivar la animación de transición entre imágenes\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Forzar generación de transcodificación\",\n            \"force_transcodes_tooltip\": \"Por defecto las transcodificaciones son solo generadas cuando el archivo de vídeo no es soportado por el navegador. Cuando están habilitadas, las transcodificaciones se generarán incluso cuando el fichero de vídeo sea soportado por el navegador.\",\n            \"image_previews\": \"Vistas previas en formato de imagen animada\",\n            \"image_previews_tooltip\": \"Generar también vistas previas animadas (webp), solo requeridas cuando el tipo de vista previa del Muro de Escenas/Marcadores está configurado como imagen animada. Al navegar, utilizan menos CPU que las vistas previas de video, pero se generan además de ellas y son archivos más grandes.\",\n            \"interactive_heatmap_speed\": \"Generar mapas de calor y velocidades para escenas interactivas\",\n            \"marker_image_previews\": \"Vistas previas de marcadores en formato de imagen animada\",\n            \"marker_image_previews_tooltip\": \"Generar también vistas previas animadas (webp), solo requeridas cuando el tipo de vista previa del Muro de Escena/Marcador está configurado como Imagen Animada. Al navegar, consumen menos CPU que las vistas previas de video, pero se generan además de ellas y son archivos más grandes.\",\n            \"marker_screenshots\": \"Capturas de pantalla de marcadores\",\n            \"marker_screenshots_tooltip\": \"Imágenes estáticas JPG de marcadores\",\n            \"markers\": \"Vistas previas de marcadores\",\n            \"markers_tooltip\": \"Vídeos de 20 segundos de duración que comienzan a partir del tiempo seleccionado.\",\n            \"override_preview_generation_options\": \"Sobrescribir opciones para la generación de vistas previas\",\n            \"override_preview_generation_options_desc\": \"Sobrescribir opciones de generación de vistas previas para esta operación. Las opciones por defecto se configuran en Sistema -> Generación de vistas previas.\",\n            \"overwrite\": \"Sobreescribir archivos generados ya existentes\",\n            \"phash\": \"Hashes de video perceptuales\",\n            \"preview_exclude_end_time_desc\": \"Excluir los últimos X segundos de la vista previa de la escena. Puede ser un valor en segundos o un porcentaje (p.ej. 2%) de la duración total de la escena.\",\n            \"preview_exclude_end_time_head\": \"Excluir desde el final del vídeo\",\n            \"preview_exclude_start_time_desc\": \"Excluir los primeros X segundos de la vista previa de la escena. Puede ser un valor en segundos o un porcentaje (p.ej. 2%) de la duración total de la escena.\",\n            \"preview_exclude_start_time_head\": \"Excluir desde el inicio del vídeo\",\n            \"preview_generation_options\": \"Opciones de generación de vistas previas\",\n            \"preview_options\": \"Opciones de previsualización\",\n            \"preview_preset_desc\": \"La configuración predeterminada regula el tamaño, calidad y tiempo de codificación de las generación de vistas previas. Ajustes por encima de “lento” pueden ralentizar el proceso y no son recomendables.\",\n            \"preview_preset_head\": \"Codificación predeterminada de vistas previas\",\n            \"preview_seg_count_desc\": \"Número de segmentos en la vista previa de los ficheros.\",\n            \"preview_seg_count_head\": \"Número de segmentos en la previsualización\",\n            \"preview_seg_duration_desc\": \"Duración en segundos de cada segmento de la vista previa.\",\n            \"preview_seg_duration_head\": \"Duración del segmento en la vista previa\",\n            \"sprites\": \"Conjunto de imágenes o “sprites” del depurador de escenas\",\n            \"sprites_tooltip\": \"Son el conjunto de imágenes que se muestran en la barra de vídeo para un desplazamiento más sencillo por el vídeo.\",\n            \"transcodes\": \"Transcodificaciones\",\n            \"transcodes_tooltip\": \"Las transcodificaciones MP4 se generarán previamente para todo el contenido; útil para CPUs lentas pero requiere mucho más espacio en disco\",\n            \"video_previews\": \"Vistas previas\",\n            \"video_previews_tooltip\": \"Vistas previas en vídeo que se reproducen al pasar el puntero del ratón sobre una escena\",\n            \"clip_previews\": \"Vistas previas de clips de imagen\",\n            \"covers\": \"Imagen de la escena\",\n            \"image_thumbnails\": \"Miniaturas de imágenes\",\n            \"phash_tooltip\": \"Para la búsqueda de duplicados e identificación de escenas\",\n            \"image_phash\": \"Hashes perceptuales de imágenes\",\n            \"image_phash_tooltip\": \"Para la deduplicación y la identificación\"\n        },\n        \"scenes_found\": \"{count} escenas encontradas\",\n        \"scrape_entity_query\": \"Consulta de rastreo de {entity_type}\",\n        \"scrape_entity_title\": \"Resultados de rastreo de {entity_type}\",\n        \"scrape_results_existing\": \"Existente\",\n        \"scrape_results_scraped\": \"Rastreado\",\n        \"set_image_url_title\": \"Dirección web de la imagen\",\n        \"unsaved_changes\": \"Los cambios no han sido guardados. ¿Estás seguro que quieres salir?\",\n        \"performers_found\": \"{count} actrices/actores encontrados\",\n        \"merge\": {\n            \"destination\": \"Destino\",\n            \"empty_results\": \"Los valores del campo de destino permanecerán sin cambios.\",\n            \"source\": \"Fuente\"\n        },\n        \"create_new_entity\": \"Crear nuevo {entity}\",\n        \"imagewall\": {\n            \"direction\": {\n                \"description\": \"Diseño en columna o fila.\",\n                \"row\": \"Fila\",\n                \"column\": \"Columna\"\n            },\n            \"margin_desc\": \"Número de píxeles de margen alrededor de cada imagen completa.\"\n        },\n        \"clear_o_history_confirm\": \"¿Estás seguro que quieres limpiar el historial O?\",\n        \"clear_play_history_confirm\": \"¿Estás seguro que quieres limpiar el historial de reproducciones?\",\n        \"dont_show_until_updated\": \"No mostrar hasta la próxima actualización\",\n        \"reassign_files\": {\n            \"destination\": \"Reasignar a\"\n        },\n        \"delete_entity_simple_desc\": \"{count, plural, one {¿Estás seguro de que quieres eliminar esta {singularEntity}?} other {¿Estás seguro de que quieres eliminar estas {pluralEntity}?}}\",\n        \"reassign_entity_title\": \"{count, plural, one {Reasignar {singularEntity}} other {Reasignar {pluralEntity}}}\",\n        \"clear_o_history_confirm_sfw\": \"¿Estás seguro de que quieres borrar el historial de «Me gusta»?\",\n        \"overwrite_filter_warning\": \"El filtro guardado \\\"{entityName}\\\" se sobrescribirá.\",\n        \"set_default_filter_confirm\": \"¿Estás seguro de que deseas establecer este filtro como predeterminado?\",\n        \"delete_alert_to_trash\": \"Los siguientes {count, plural, one {{singularEntity}} other {{pluralEntity}}} se moverán a la papelera:\",\n        \"stashid_exists_warning\": \"Se sustituirá el identificador de stash existente para este stash-box.\",\n        \"studios_found\": \"{count} estudios encontrados\",\n        \"tags_found\": \"{count} etiquetas encontradas\",\n        \"scrape_results_missing\": \"Faltante\",\n        \"edit_entity_count_title\": \"Editar {count} {count, plural, una {{singularEntity}} otra {{pluralEntity}}}\"\n    },\n    \"dimensions\": \"Dimensiones\",\n    \"director\": \"Director\",\n    \"display_mode\": {\n        \"grid\": \"Cuadrícula\",\n        \"list\": \"Lista\",\n        \"tagger\": \"Etiquetadora\",\n        \"unknown\": \"Desconocido/a\",\n        \"wall\": \"Muro\",\n        \"label_current\": \"Modo de visualización: {current}\"\n    },\n    \"donate\": \"Donar\",\n    \"dupe_check\": {\n        \"description\": \"El cálculo en los niveles por debajo de “Exacto” puede llevar más tiempo. En los niveles de menor exactitud además pueden aparecer mayor número de falsos positivos en las coincidencias.\",\n        \"found_sets\": \"{setCount, plural, one{# conjunto de duplicados encontrado.} other {# conjuntos de duplicados encontrados.}}\",\n        \"options\": {\n            \"exact\": \"Exacto\",\n            \"high\": \"Alto\",\n            \"low\": \"Bajo\",\n            \"medium\": \"Medio\"\n        },\n        \"search_accuracy_label\": \"Precisión de búsqueda\",\n        \"title\": \"Escenas duplicadas\",\n        \"duration_diff\": \"Diferencia de duración máxima\",\n        \"duration_options\": {\n            \"equal\": \"Igual\",\n            \"any\": \"Cualquiera\"\n        },\n        \"only_select_matching_codecs\": \"Seleccionar solo si todos los códecs coinciden en el grupo de duplicados\",\n        \"select_all_but_largest_resolution\": \"Seleccionar cada archivo en cada grupo duplicado, excepto el archivo con mayor resolución\",\n        \"select_all_but_largest_file\": \"Seleccionar cada archivo en cada grupo duplicado, excepto el archivo más grande\",\n        \"select_none\": \"Seleccionar ninguno\",\n        \"select_oldest\": \"Seleccionar el fichero más antiguo en el grupo de duplicados\",\n        \"select_options\": \"Seleccionar opciones…\",\n        \"select_youngest\": \"Seleccionar el fichero más reciente en el grupo de duplicados\"\n    },\n    \"duplicated_phash\": \"Duplicados (pHash)\",\n    \"duration\": \"Duración\",\n    \"effect_filters\": {\n        \"aspect\": \"Relación aspecto\",\n        \"blue\": \"Azul\",\n        \"blur\": \"Desenfoque\",\n        \"brightness\": \"Brillo\",\n        \"contrast\": \"Contraste\",\n        \"gamma\": \"Exposición\",\n        \"green\": \"Verde\",\n        \"hue\": \"Color\",\n        \"name\": \"Filtro\",\n        \"name_transforms\": \"Transformaciones de vídeo\",\n        \"red\": \"Rojo\",\n        \"reset_filters\": \"Restablecer filtros\",\n        \"reset_transforms\": \"Restablecer transformaciones\",\n        \"rotate\": \"Rotación\",\n        \"rotate_left_and_scale\": \"Rotar a la izquierda y escalar\",\n        \"rotate_right_and_scale\": \"Rotar a la derecha y escalar\",\n        \"saturation\": \"Saturación\",\n        \"scale\": \"Escala\",\n        \"warmth\": \"Calidez\"\n    },\n    \"ethnicity\": \"Origen étnico\",\n    \"existing_value\": \"valor existente\",\n    \"eye_color\": \"Color de ojos\",\n    \"fake_tits\": \"Pecho operado\",\n    \"false\": \"No\",\n    \"favourite\": \"Favorita\",\n    \"file\": \"fichero\",\n    \"file_info\": \"Info\",\n    \"file_mod_time\": \"Fecha de modificación del fichero\",\n    \"files\": \"ficheros\",\n    \"filesize\": \"Tamaño de archivo\",\n    \"filter\": \"Filtro\",\n    \"filter_name\": \"Filtrar por nombre\",\n    \"filters\": \"Filtros\",\n    \"framerate\": \"Tasa de frames\",\n    \"frames_per_second\": \"{value} frames por segundo (fps)\",\n    \"galleries\": \"Galerías\",\n    \"gallery\": \"Galería\",\n    \"gallery_count\": \"Número de galerías\",\n    \"gender\": \"Género\",\n    \"gender_types\": {\n        \"FEMALE\": \"Mujer\",\n        \"INTERSEX\": \"Intersexual\",\n        \"MALE\": \"Varón\",\n        \"NON_BINARY\": \"No binario\",\n        \"TRANSGENDER_FEMALE\": \"Mujer transgénero\",\n        \"TRANSGENDER_MALE\": \"Varón transgénero\"\n    },\n    \"hair_color\": \"Color de pelo\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Conectando\",\n        \"disconnected\": \"Desconectado\",\n        \"missing\": \"El archivo no esta disponsible\",\n        \"ready\": \"Conexion Preparada\",\n        \"syncing\": \"Sincronizando con el servidor\",\n        \"uploading\": \"Subiendo script\",\n        \"error\": \"Error al conectar con Handy\"\n    },\n    \"hasMarkers\": \"Marcadores\",\n    \"height\": \"Estatura\",\n    \"help\": \"Ayuda\",\n    \"ignore_auto_tag\": \"Ignorar etiquetado automático\",\n    \"image\": \"Imagen\",\n    \"image_count\": \"Número de imágenes\",\n    \"images\": \"Imágenes\",\n    \"include_parent_tags\": \"Incluir etiquetas de la matriz\",\n    \"include_sub_studios\": \"Incluir estudios filiales\",\n    \"include_sub_tags\": \"Incluir sub-etiquetas\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interactivo\",\n    \"interactive_speed\": \"Velocidad interactiva\",\n    \"isMissing\": \"Falta\",\n    \"library\": \"Biblioteca\",\n    \"loading\": {\n        \"generic\": \"Cargando…\",\n        \"plugins\": \"Cargando complementos…\"\n    },\n    \"marker_count\": \"Número de marcadores\",\n    \"markers\": \"Marcadores\",\n    \"measurements\": \"Medidas\",\n    \"media_info\": {\n        \"audio_codec\": \"Códec de audio\",\n        \"downloaded_from\": \"Descargado de\",\n        \"interactive_speed\": \"Velocidad interactiva\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} durante la producción\"\n        },\n        \"phash\": \"Función de hash perceptual\",\n        \"stream\": \"Transmisión\",\n        \"video_codec\": \"Códec de vídeo\",\n        \"play_count\": \"Contador de reproducciones\",\n        \"play_duration\": \"Tiempo de reproducción\",\n        \"o_count\": \"Contador de orgasmos\",\n        \"md5\": \"Suma de comprobación MD5\",\n        \"oshash\": \"oshash\",\n        \"oshash_meaning\": \"Hash de OpenSubtitles\",\n        \"phash_meaning\": \"Hash perceptual\"\n    },\n    \"megabits_per_second\": \"{value} megabits por segundo (mbps)\",\n    \"metadata\": \"Metadatos\",\n    \"name\": \"Nombre\",\n    \"new\": \"Añadir\",\n    \"none\": \"Ninguno/a\",\n    \"operations\": \"Acciones\",\n    \"organized\": \"Clasificadas\",\n    \"pagination\": {\n        \"first\": \"Primera\",\n        \"last\": \"Última\",\n        \"next\": \"Siguiente\",\n        \"previous\": \"Anterior\",\n        \"current_total\": \"{current} de {total}\"\n    },\n    \"parent_of\": \"Matriz de {children}\",\n    \"parent_studios\": \"Estudio matriz\",\n    \"parent_tag_count\": \"Contador de etiqueta matriz\",\n    \"parent_tags\": \"Etiqueta matriz\",\n    \"part_of\": \"Parte de {parent}\",\n    \"path\": \"Ruta\",\n    \"perceptual_similarity\": \"Similaridad perceptiva (pHash)\",\n    \"performer\": \"Actriz/Actor\",\n    \"performer_age\": \"Edad de la actriz/actor\",\n    \"performer_count\": \"Número de actrices/actores\",\n    \"performer_favorite\": \"Actriz/actor favorita/o\",\n    \"performer_image\": \"Imagen de actriz/actor\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Añadir nuevas/os actrices/actores\",\n        \"any_names_entered_will_be_queried\": \"Cualquier nombre introducido será consultado desde una instancia Stash-Box remota y añadido si es encontrado. Solo se aceptan coincidencias exactas.\",\n        \"batch_add_performers\": \"Adición automatizada de actrices/actores\",\n        \"batch_update_performers\": \"Actualización automatizada de actrices/actores\",\n        \"current_page\": \"Página actual\",\n        \"failed_to_save_performer\": \"Error al guardar \\\"{performer}\\\"\",\n        \"name_already_exists\": \"El nombre ya existe\",\n        \"network_error\": \"Error de red\",\n        \"no_results_found\": \"No se han encontrado resultados.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} actrices/actores serán procesados\",\n        \"performer_already_tagged\": \"Actriz/actor ya etiquetada/o\",\n        \"performer_selection\": \"Selección de actriz/actor\",\n        \"performer_successfully_tagged\": \"Actriz/actor etiquetada/o correctamente:\",\n        \"query_all_performers_in_the_database\": \"Todas las actrices/actores en la base de datos\",\n        \"refresh_tagged_performers\": \"Recargar actrices/actores etiquetadas/os\",\n        \"refreshing_will_update_the_data\": \"Recargar actualizará los datos de cualquier actriz/actor desde la instancia stash-box.\",\n        \"status_tagging_job_queued\": \"Estado: trabajo de etiquetado añadido a la cola\",\n        \"status_tagging_performers\": \"Estado: etiquetando actrices/actores\",\n        \"tag_status\": \"Estado de etiquetado\",\n        \"to_use_the_performer_tagger\": \"Para usar el etiquetador de actrices/actores se requiere configurar una instancia stash-box.\",\n        \"untagged_performers\": \"Actrices/actores no etiquetadas/os\",\n        \"update_performer\": \"Actualizar actriz/actor\",\n        \"update_performers\": \"Actualizar actrices/actores\",\n        \"updating_untagged_performers_description\": \"Actualizar las actrices/actores no etiquetados intentará seleccionar cualquier actriz/actor que carecen de un StashID y actualizará los metadatos.\",\n        \"performer_names_or_stashids_separated_by_comma\": \"Nombres de actores o StashIDs separados por comas\"\n    },\n    \"performer_tags\": \"Etiquetas de actriz/actor\",\n    \"performers\": \"Actrices/Actores\",\n    \"piercings\": \"Piercings\",\n    \"queue\": \"Cola\",\n    \"random\": \"Aleatoria\",\n    \"rating\": \"Puntuación\",\n    \"resolution\": \"Resolución\",\n    \"scene\": \"Escena\",\n    \"sceneTagger\": \"Etiquetador de escenas\",\n    \"scene_count\": \"Número de escenas\",\n    \"scene_id\": \"Indentificador de escena\",\n    \"scene_tags\": \"Etiquetas de escena\",\n    \"scenes\": \"Escenas\",\n    \"scenes_updated_at\": \"Fecha de actualización de la escena\",\n    \"search_filter\": {\n        \"name\": \"Filtro\",\n        \"saved_filters\": \"Filtros guardados\",\n        \"update_filter\": \"Actualizar filtro\",\n        \"edit_filter\": \"Editar filtro\",\n        \"search_term\": \"Término de búsqueda\",\n        \"more_filter_criteria\": \"+{count} más\"\n    },\n    \"seconds\": \"Segundos\",\n    \"settings\": \"Preferencias\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Casi estamos listos para completar la configuración. Por favor, confirma las siguientes opciones. Puedes hacer clic en volver para cambiar cualquier dato incorrecto. Si todo parece estar bien pulsa en Confirmar para crear tu nuevo entorno.\",\n            \"configuration_file_location\": \"Ruta relativa para el fichero de configuración:\",\n            \"database_file_path\": \"Ruta para la base de datos\",\n            \"generated_directory\": \"Directorio donde se almacenan los ficheros multimedia de soporte generados por Stash\",\n            \"nearly_there\": \"¡Casi estamos!\",\n            \"stash_library_directories\": \"Directorio/s que contiene/n la librería de archivos de Stash\",\n            \"cache_directory\": \"Carpeta de la caché\",\n            \"blobs_directory\": \"Directorio de datos binarios\",\n            \"blobs_use_database\": \"<uso de base de datos>\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Creando tu nuevo entorno\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"¡OH NO! ¡Algo ha ido mal!\",\n            \"something_went_wrong_description\": \"Si sospechas que puede haber un error con los datos aportados, por favor, haz clic en volver para arreglarlos. De lo contrario, abre una incidencia en {githubLink} o busca ayuda en {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Algo ha salido mal mientras configurábamos tu entorno. Éste es el mensaje de error recibido: {error}\",\n            \"unable_to_retrieve_system_status\": \"No se puede recuperar el estado del sistema: {error}\",\n            \"unexpected_error\": \"Se ha producido un error inesperado: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Ruta relativa del fichero\",\n            \"up_dir\": \"Ascender en el árbol de directorios\"\n        },\n        \"github_repository\": \"Repositorio en Github\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Ruta para las copias de seguridad (dejar en blanco para deshabilitar las copias de seguridad):\",\n            \"backup_recommended\": \"Es recomendable que realices una copia de seguridad de tu base de datos existente antes de la migración. Podemos encargarnos nosotros en tu lugar haciendo una copia de seguridad de tu base de datos en <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Migrando base de datos\",\n            \"migration_failed\": \"Migración fallida\",\n            \"migration_failed_error\": \"Se ha producido el siguiente error mientras se migraba la base de datos:\",\n            \"migration_failed_help\": \"Por favor, haz las correcciones oportunas y vuelve a intentarlo. Si no, puedes avisarnos del error en {githubLink} o pedir ayuda en {discordLink}.\",\n            \"migration_irreversible_warning\": \"El proceso de migración no es reversible. Una vez se lleve a cabo la migración tu base de datos será incompatible con las versiones previas de Stash.\",\n            \"migration_required\": \"Migración requerida\",\n            \"perform_schema_migration\": \"Realizar migración\",\n            \"schema_too_old\": \"La versión de la base de datos es <strong>{databaseSchema}</strong> y necesita ser actualizada a la <strong>{appSchema}</strong>. Esta versión de Stash no funcionará sin la migración de la base de datos.\",\n            \"migration_notes\": \"Notas de la migración\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"nombre para el archivo de la base de datos (en blanco para usar opción por defecto)\",\n            \"description\": \"A continuación necesitamos saber dónde se encuentra tu contenido, y dónde guardar la base de datos de Stash y los ficheros multimedia de soporte generados. Estos ajustes se pueden modificar posteriormente.\",\n            \"path_to_generated_directory_empty_for_default\": \"ruta al directorio de ficheros multimedia generados (dejar en blanco para usar opción por defecto)\",\n            \"set_up_your_paths\": \"Selecciona tus rutas\",\n            \"stash_alert\": \"No se han seleccionado rutas para tu biblioteca. Ningún fichero multimedia podrá ser seleccionado para su inclusión en Stash. ¿Estás seguro?\",\n            \"where_can_stash_store_its_database\": \"¿Dónde guarda Stash su base de datos?\",\n            \"where_can_stash_store_its_database_description\": \"Stash emplea una base de datos SQLite para almacenar los metadatos de tu colección. Por defecto será creada como <code>stash-go.sqlite</code> en el directorio en el que se encuentra tu archivo de configuración. Si quieres cambiar esto, por favor, introduce un nombre de archivo con ruta absoluta o relativa al directorio de trabajo actual.\",\n            \"where_can_stash_store_its_generated_content\": \"¿Dónde guarda Stash los ficheros multimedia de soporte generados?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Para poder ofrecerte miniaturas, vistas previas y conjuntos de imágenes animadas, Stash genera imágenes y vídeos. Esto incluye también transcodificaciones para formatos de vídeo no soportados. Por defecto, Stash creará el directorio <code>generated</code> en el directorio que contiene tu archivo de configuración. Si quieres cambiar dónde se almacenarán estos archivos generados, por favor, introduce una ruta absoluta o relativa (al directorio de trabajo actual). Stash creará este directorio si no existe.\",\n            \"where_is_your_porn_located\": \"¿Dónde guardas tu contenido?\",\n            \"where_is_your_porn_located_description\": \"Añade los directorios que contienen tus imágenes y vídeos. Stash usará estos directorios para buscar imágenes y vídeos durante el escaneo.\",\n            \"path_to_cache_directory_empty_for_default\": \"ruta al directorio de la caché (dejar en blanco para usar el directorio por defecto)\",\n            \"store_blobs_in_database\": \"Almacenar blobs en la base de datos\",\n            \"path_to_blobs_directory_empty_for_default\": \"ruta al directorio con los blobs (dejar en blanco para el valor por defecto)\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Alternativamente, puedes almacenar estos datos en la base de datos. <strong>Nota:</strong> esto aumentará el tamaño de tu archivo de base de datos y los tiempos de migración de la base de datos.\",\n            \"where_can_stash_store_cache_files\": \"¿Dónde puede Stash almacenar los archivos de caché?\",\n            \"where_can_stash_store_its_database_warning\": \"ADVERTENCIA: ¡almacenar la base de datos en un sistema diferente al que Stash se ejecuta (por ejemplo, almacenar la base de datos en un NAS mientras se ejecuta el servidor Stash en otro equipo) no es <strong>compatible</strong>! SQLite no está diseñado para su uso a través de una red e intentar hacerlo puede corromper fácilmente toda tu base de datos.\",\n            \"where_can_stash_store_blobs\": \"¿Dónde puede Stash guardar los datos binarios de la base de datos?\",\n            \"where_can_stash_store_blobs_description\": \"Stash puede almacenar datos binarios como portadas de escenas, imágenes de intérpretes, estudios y etiquetas ya sea en la base de datos o en el sistema de archivos. Por defecto, almacenará estos datos en el sistema de archivos en el subdirectorio <code>blobs</code> dentro del directorio que contiene tu archivo de configuración. Si deseas cambiar esto, por favor ingresa una ruta absoluta o relativa (respecto al directorio de trabajo actual). Stash creará este directorio si aún no existe.\",\n            \"where_can_stash_store_cache_files_description\": \"Para que algunas funciones como la transcodificación en vivo de HLS/DASH funcionen, Stash requiere un directorio de caché para archivos temporales. Por defecto, Stash creará un directorio <code>cache</code> dentro del directorio que contiene tu archivo de configuración. Si deseas cambiar esto, por favor ingresa una ruta absoluta o relativa (respecto al directorio de trabajo actual). Stash creará este directorio si aún no existe.\",\n            \"sfw_content_settings\": \"¿Usar Stash para contenido SFW?\",\n            \"sfw_content_settings_description\": \"Stash se puede utilizar para gestionar contenido SFW, como fotografía, arte, cómics y mucho más. Al habilitar esta opción, se ajustará el comportamiento de la interfaz de usuario para que sea más adecuado para el contenido SFW.\",\n            \"use_sfw_content_mode\": \"Utilizar el modo de contenido SFW\"\n        },\n        \"stash_setup_wizard\": \"Asistente de configuración de Stash\",\n        \"success\": {\n            \"getting_help\": \"Obtener ayuda\",\n            \"help_links\": \"Si tienes algún problema, pregunta o sugerencia puedes abrir una inicidencia en {githubLink} o preguntar a nuestra comunidad de usuarios en {discordLink}.\",\n            \"in_app_manual_explained\": \"Te recomendamos que consultes el manual integrado en la aplicación, al que se puede acceder desde el icono en la esquina superior derecha de la pantalla y que tiene este aspecto: {icon}\",\n            \"next_config_step_one\": \"A continuación serás dirigido a la página de Configuración donde podrás seleccionar qué archivos se incluirán o se excluirán, proteger tu sistema con un usuario y contraseña, y un montón de opciones más.\",\n            \"next_config_step_two\": \"Cuando estés satisfecho con estas opciones puedes empezar a escanear tu contenido para su adición en Stash haciendo clic en <code>{localized_task}</code> - <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Visite {open_collective_link} para ver cómo puede contribuir al desarrollo de Stash.\",\n            \"support_us\": \"Apóyanos\",\n            \"thanks_for_trying_stash\": \"¡Gracias por usar Stash!\",\n            \"welcome_contrib\": \"También agradecemos las contribuciones en forma de código de Stash (corrección de errores, mejoras y nuevas funcionalidades), prueba de versiones beta, informe de errores, solicitud de mejoras y nuevas funciones, y asistencia al usuario. Puedes ver más detalles en el apartado Contribución del manual de ayuda de la aplicación.\",\n            \"your_system_has_been_created\": \"¡Todo correcto! ¡Tu entorno ha sido creado!\",\n            \"download_ffmpeg\": \"Descargar ffmpeg\",\n            \"missing_ffmpeg\": \"Te falta el binario <code>ffmpeg</code> necesario. Puedes descargarlo automáticamente en tu directorio de configuración marcando la casilla a continuación. Alternativamente, puedes proporcionar rutas a los binarios <code>ffmpeg</code> y <code>ffprobe</code> en la Configuración del Sistema. Estos binarios deben estar presentes para que Stash funcione.\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash intenta encontrar primero su archivo de configuración (<code>config.yml</code>) en el directorio actual de trabajo y, en caso de no encontrarlo, lo intenta en <code>{fallback_path}</code>. También puedes hacer que Stash obtenga su configuración de un archivo de configuración lanzando la aplicación con las opciones <code>-c '<ruta personalizada al archivo de configuración>'</code> o <code>--config '<ruta personalizada al archivo de configuración>'</code>.\",\n            \"in_current_stash_directory\": \"En el directorio <code>{path}</code>:\",\n            \"in_the_current_working_directory\": \"En <code>{path}</code>, el directorio de trabajo actual, actualmente:\",\n            \"next_step\": \"Si estás listo para crear un nuevo entorno, por favor, selecciona donde quieres guardar tu archivo de configuración.\",\n            \"store_stash_config\": \"¿Dónde quieres guardar el fichero de configuración de Stash?\",\n            \"unable_to_locate_config\": \"Si estás leyendo esto es que Stash no ha podido encontrar una configuración existente. Este asistente te guiará durante el proceso de creación de una nueva configuración.\",\n            \"unexpected_explained\": \"Si has llegado hasta esta ventana de forma inesperada, por favor, intenta reiniciar Stash en el directorio de trabajo correcto o con la opción <code>-c</code> .\",\n            \"in_the_current_working_directory_disabled\": \"En <code>{path}</code>, el directorio de trabajo:\",\n            \"in_the_current_working_directory_disabled_macos\": \"No es compatible al ejecutar <code>Stash.app</code>,<br></br>ejecuta <code>stash-macos</code> para configurar en el directorio de trabajo\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash usará la siguiente ruta para el fichero de configuración: <code>{path}</code>\",\n            \"next_step\": \"Cuando estés preparado para la creación de un nuevo entorno pulsa Siguiente.\",\n            \"unable_to_locate_specified_config\": \"Si estás leyendo esto es que Stash no ha podido encontrar el fichero de configuración especificado en la línea de comandos o en el entorno en el que está instalado. Este asistente te guiará durante el proceso de creación de una nueva configuración.\"\n        },\n        \"welcome_to_stash\": \"Bienvenido a Stash\"\n    },\n    \"stash_id\": \"Identificador único Stash\",\n    \"stash_ids\": \"Stash IDs\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Ir a {endpoint_name} para revisar el borrador.\",\n        \"selected_stash_box\": \"URL de la entidad Stash-Box seleccionada\",\n        \"submission_failed\": \"Envío fallido\",\n        \"submission_successful\": \"Enviado correctamente\",\n        \"source\": \"Fuente para el Stash-Box\",\n        \"submit_update\": \"Ya existe en {endpoint_name}\"\n    },\n    \"stats\": {\n        \"image_size\": \"Tamaño de las imágenes\",\n        \"scenes_duration\": \"Duración de las escenas\",\n        \"scenes_size\": \"Tamaño de las escenas\",\n        \"total_play_count\": \"Contador total de reproducciones\",\n        \"total_o_count\": \"Total de orgasmos\",\n        \"total_play_duration\": \"Tiempo total de reproducciones\",\n        \"scenes_played\": \"Escenas reproducidas\",\n        \"total_o_count_sfw\": \"Total de «Me gusta»\"\n    },\n    \"status\": \"Estado: {statusText}\",\n    \"studio\": \"Estudio\",\n    \"studio_depth\": \"Niveles (en blanco para mostrar todos)\",\n    \"studios\": \"Estudios\",\n    \"sub_tag_count\": \"Número de etiquetas secundarias\",\n    \"sub_tag_of\": \"Sub-etiquetas de {parent}\",\n    \"sub_tags\": \"Etiquetas secundarias\",\n    \"subsidiary_studios\": \"Estudios afiliados/filiales\",\n    \"synopsis\": \"Sinopsis\",\n    \"tag\": \"Etiqueta\",\n    \"tag_count\": \"Número de etiquetas\",\n    \"tags\": \"Etiquetas\",\n    \"tattoos\": \"Tatuajes\",\n    \"title\": \"Título\",\n    \"toast\": {\n        \"added_entity\": \"{entity} añadida\",\n        \"added_generation_job_to_queue\": \"Tarea de generación añadida a la cola\",\n        \"created_entity\": \"{entity} creada\",\n        \"default_filter_set\": \"Establecer como filtro por defecto\",\n        \"delete_past_tense\": \"Borrado {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Generando captura…\",\n        \"merged_tags\": \"Etiquetas combinadas\",\n        \"rescanning_entity\": \"Reescanear {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"{entity} guardada\",\n        \"started_auto_tagging\": \"Auto-etiquetado iniciado\",\n        \"started_generating\": \"Generación de ficheros multimedia iniciada\",\n        \"started_importing\": \"Importación iniciada\",\n        \"updated_entity\": \"{entity} actualizada\",\n        \"image_index_too_large\": \"Error: El índice de imagen es mayor que el número de imágenes en la galería\",\n        \"removed_entity\": \"Eliminado {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"reassign_past_tense\": \"Archivo reasignado\",\n        \"merged_scenes\": \"Escenas fusionadas\",\n        \"merged_performers\": \"Actores fusionados\",\n        \"clipboard_access_denied\": \"Acceso al portapapeles denegado. Comprueba los permisos de tu navegador\",\n        \"clipboard_image_pasted\": \"Imagen pegada desde el portapapeles\",\n        \"clipboard_no_image\": \"No se ha encontrado ninguna imagen en el portapapeles\"\n    },\n    \"total\": \"Total\",\n    \"true\": \"Sí\",\n    \"twitter\": \"Twitter\",\n    \"updated_at\": \"Fecha de modificación\",\n    \"url\": \"URL\",\n    \"videos\": \"Vídeos\",\n    \"weight\": \"Peso\",\n    \"years_old\": \"años\",\n    \"appears_with\": \"Aparece con\",\n    \"circumcised_types\": {\n        \"UNCUT\": \"Sin cortar\",\n        \"CUT\": \"Cortar\"\n    },\n    \"audio_codec\": \"Códec de audio\",\n    \"circumcised\": \"Circuncidado\",\n    \"description\": \"Descripción\",\n    \"distance\": \"Distancia\",\n    \"empty_server\": \"Añade algunas escenas a tu servidor para ver recomendaciones en esta página.\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"El índice de imagen debe ser mayor que 0\",\n        \"header\": \"Error\",\n        \"lazy_component_error_help\": \"Si ractualizaste Stash recientemente, por favor recarga la página o limpia la caché de tu navegador.\",\n        \"something_went_wrong\": \"Algo salió mal.\",\n        \"loading_type\": \"Error cargando {type}\",\n        \"invalid_javascript_string\": \"Código javascript no válido: {error}\",\n        \"invalid_json_string\": \"Cadena JSON no válida: {error}\",\n        \"custom_fields\": {\n            \"field_name_required\": \"Se requiere el nombre del campo\",\n            \"field_name_whitespace\": \"El nombre del campo no puede tener espacios al principio o al final\",\n            \"duplicate_field\": \"El nombre del campo debe ser único\",\n            \"field_name_length\": \"El nombre del campo debe tener menos de 65 caracteres\"\n        }\n    },\n    \"file_count\": \"Conteo de archivos\",\n    \"hasChapters\": \"Capítulos\",\n    \"index_of_total\": \"{index} de {total}\",\n    \"last_played_at\": \"Última reproducción el\",\n    \"package_manager\": {\n        \"confirm_uninstall\": \"¿Estás seguro de querer desinstalar {number} paquetes?\",\n        \"description\": \"Descripción\",\n        \"install\": \"Instalar\",\n        \"installed_version\": \"Versión instalada\",\n        \"latest_version\": \"Última versión\",\n        \"package\": \"Paquete\",\n        \"unknown\": \"<desconocido>\",\n        \"update\": \"Actualizar\",\n        \"add_source\": \"Añadir fuente\",\n        \"check_for_updates\": \"Comprobar actualizaciones\",\n        \"confirm_delete_source\": \"¿Estás seguro que deseas borrar la fuente {name} ({url})?\",\n        \"edit_source\": \"Editar fuente\",\n        \"no_packages\": \"No se han encontrado paquetes\",\n        \"no_sources\": \"No hay fuentes configuradas\",\n        \"no_upgradable\": \"No se han encontrado paquetes actualizables\",\n        \"required_by\": \"Requerido por {packages}\",\n        \"selected_only\": \"Solo los seleccionados\",\n        \"show_all\": \"Mostrar todo\",\n        \"source\": {\n            \"local_path\": {\n                \"description\": \"Ruta relativa para almacenar los paquetes para esta fuente. Ten en cuenta que cambiar esto requiere que los paquetes se muevan manualmente.\",\n                \"heading\": \"Ruta local\"\n            },\n            \"url\": \"URL de la fuente\",\n            \"name\": \"Nombre\"\n        },\n        \"uninstall\": \"Desinstalar\",\n        \"version\": \"Versión\",\n        \"hide_unselected\": \"Ocultar no seleccionados\"\n    },\n    \"penis\": \"Pene\",\n    \"plays\": \"{value} reproducciones\",\n    \"resume_time\": \"Tiempo de reanudación\",\n    \"date_format\": \"YYYY-MM-DD\",\n    \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n    \"disambiguation\": \"Disambiguación\",\n    \"o_count\": \"Contador de orgasmos\",\n    \"o_history\": \"Historial de orgasmos\",\n    \"orientation\": \"Orientación\",\n    \"penis_length_cm\": \"Longitud del pene (cm)\",\n    \"play_count\": \"Contador de reproducciones\",\n    \"release_notes\": \"Notas de la versión\",\n    \"studio_tagger\": {\n        \"add_new_studios\": \"Añadir nuevos estudios\",\n        \"query_all_studios_in_the_database\": \"Todos los estudios en la base de datos\",\n        \"create_or_tag_parent_studios\": \"Crear estudios principales faltantes o etiquetar los existentes\",\n        \"current_page\": \"Página actual\",\n        \"failed_to_save_studio\": \"Error al guardar el estudio \\\"{studio}\\\"\",\n        \"name_already_exists\": \"Ya existe ese nombre\",\n        \"no_results_found\": \"No se han encontrado resultados.\",\n        \"any_names_entered_will_be_queried\": \"Cualquier nombre ingresado será consultado desde la instancia remota de Stash-Box y se agregará si se encuentra. Solo se considerarán coincidencias exactas como una coincidencia.\",\n        \"batch_add_studios\": \"Agregar estudios en lote\",\n        \"config\": {\n            \"create_parent_desc\": \"Crear estudios padres faltantes, o etiquetar y actualizar datos/imagen para estudios padres existentes con coincidencias exactas de nombre\",\n            \"create_parent_label\": \"Crear estudios principales\"\n        },\n        \"number_of_studios_will_be_processed\": \"{studio_count} estudios serán procesados\",\n        \"refreshing_will_update_the_data\": \"Recargar actualizará los datos de cualquier estudio etiquetado desde la instancia de stash-box.\",\n        \"status_tagging_job_queued\": \"Estado: trabajo de etiquetado en cola\",\n        \"status_tagging_studios\": \"Estado: etiquetando estudios\",\n        \"studio_already_tagged\": \"Estudio ya etiquetado\",\n        \"untagged_studios\": \"Estudios no etiquetados\",\n        \"studio_successfully_tagged\": \"Estudio etiquetado correctamente\",\n        \"tag_status\": \"Estado de etiquetado\",\n        \"to_use_the_studio_tagger\": \"Para usar el etiquetador de estudios una instancia de stash-box debe ser configurada.\",\n        \"update_studio\": \"Actualizar estudio\",\n        \"update_studios\": \"Actualizar estudios\",\n        \"updating_untagged_studios_description\": \"Actualizar los estudios sin etiquetar intentará hacer coincidir cualquier estudio que carezca de un stashid y actualizará los metadatos.\",\n        \"refresh_tagged_studios\": \"Recargar estudios etiquetados\",\n        \"studio_selection\": \"Selección de estudio\",\n        \"batch_update_studios\": \"Actualizar estudios en lote\",\n        \"network_error\": \"Error de red\",\n        \"studio_names_or_stashids_separated_by_comma\": \"Nombres de estudio o StashID separados por comas\"\n    },\n    \"weight_kg\": \"Peso (kg)\",\n    \"penis_length\": \"Longitud del pene\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Filtro prefabricado\",\n            \"saved_filter\": \"Filtro guardado\"\n        }\n    },\n    \"last_o_at\": \"Último orgasmo a las\",\n    \"parent_studio\": \"Estudio principal\",\n    \"primary_file\": \"Archivo principal\",\n    \"recently_added_objects\": \"{objects} añadidas recientemente\",\n    \"view_all\": \"Ver todo\",\n    \"scene_date\": \"Fecha de la escena\",\n    \"photographer\": \"Fotógrafo\",\n    \"play_duration\": \"Tiempo de reproducción\",\n    \"play_history\": \"Historial de reproducción\",\n    \"playdate_recorded_no\": \"No hay guardada ninguna fecha de reproducción\",\n    \"recently_released_objects\": \"{objects} publicadas recientemente\",\n    \"video_codec\": \"Códec de vídeo\",\n    \"files_amount\": \"{value} archivos\",\n    \"folder\": \"Carpeta\",\n    \"primary_tag\": \"Etiqueta principal\",\n    \"scene_created_at\": \"Escena creada el\",\n    \"statistics\": \"Estadísticas\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"No se puede establecer conexión de websocket: consulte la consola del navegador para más detalles\",\n        \"websocket_connection_reestablished\": \"Conexión de websocket restablecida\"\n    },\n    \"height_cm\": \"Altura (cm)\",\n    \"history\": \"Historial\",\n    \"image_index\": \"Imagen #\",\n    \"scene_code\": \"Código de estudio\",\n    \"scene_updated_at\": \"Escena actualizada el\",\n    \"second\": \"Segundo\",\n    \"validation\": {\n        \"blank\": \"${path} no puede estar vacío\",\n        \"date_invalid_form\": \"${path} debe estar en formato AAAA, AAAA-MM o AAAA-MM-DD\",\n        \"required\": \"${path} es un campo requerido\",\n        \"unique\": \"${path} tiene que ser único\",\n        \"end_time_before_start_time\": \"El tiempo de finalización debe ser mayor o igual que el tiempo de inicio\"\n    },\n    \"subsidiary_studio_count\": \"Número de estudios secundarios\",\n    \"tag_parent_tooltip\": \"Tiene etiquetas primarias\",\n    \"tag_sub_tag_tooltip\": \"Tiene sub-etiquetas\",\n    \"time\": \"Hora\",\n    \"type\": \"Tipo\",\n    \"odate_recorded_no\": \"No hay registro de orgasmos grabado\",\n    \"urls\": \"URLs\",\n    \"zip_file_count\": \"Números de archivos zip\",\n    \"unknown_date\": \"Fecha desconocida\",\n    \"stash_id_endpoint\": \"URL del punto final de identificador (ID) de Stash\",\n    \"studio_and_parent\": \"Estudio y ancestro\",\n    \"group\": \"Grupo\",\n    \"group_count\": \"Recuento de grupos\",\n    \"group_scene_number\": \"Número de escena\",\n    \"groups\": \"Grupos\",\n    \"studio_count\": \"Recuento de estudios\",\n    \"studio_tags\": \"Etiquetas de estudio\",\n    \"custom_fields\": {\n        \"field\": \"Campo\",\n        \"value\": \"Valor\",\n        \"title\": \"Campos personalizados\",\n        \"criteria_format_string\": \"{criterion} (custom field) {modifierString} {valueString}\",\n        \"criteria_format_string_others\": \"{criterion} (custom field) {modifierString} {valueString} (+{others} others)\"\n    },\n    \"sub_group_count\": \"Recuento de subgrupos\",\n    \"sub_group_of\": \"Subgrupo de {parent}\",\n    \"sub_group\": \"Subgrupo\",\n    \"sub_groups\": \"Subgrupos\",\n    \"criterion_modifier_values\": {\n        \"any\": \"Cualquiera\",\n        \"any_of\": \"Cualquiera de\",\n        \"none\": \"Ninguno\",\n        \"only\": \"Solo\"\n    },\n    \"include_sub_group_content\": \"Incluir contenido de subgrupos\",\n    \"include_sub_groups\": \"Incluir subgrupos\",\n    \"eta\": \"Tiempo estimado\",\n    \"containing_group\": \"Grupo contenedor\",\n    \"containing_group_count\": \"Contador del grupo contenedor\",\n    \"containing_groups\": \"Grupo de contenedores\",\n    \"login\": {\n        \"username\": \"Nombre de usuario\",\n        \"password\": \"Contraseña\",\n        \"invalid_credentials\": \"Nombre de usuario o contraseña incorrecto\",\n        \"login\": \"Iniciar sesión\",\n        \"internal_error\": \"Error interno inesperado. Consulte los registros para obtener más detalles\"\n    },\n    \"age_on_date\": \"{age} durante la producción\",\n    \"include_sub_studio_content\": \"Incluir contenido de subestudios\",\n    \"include_sub_tag_content\": \"Incluir contenido de subetiquetas\",\n    \"last_o_at_sfw\": \"Último «Me gusta» en\",\n    \"sort_name\": \"Ordenar por nombre\",\n    \"o_count_sfw\": \"Me gusta\",\n    \"o_history_sfw\": \"Historial de Me gusta\",\n    \"odate_recorded_no_sfw\": \"Sin fecha de Me gusta registrada\",\n    \"scenes_duration\": \"Duración de la escena\",\n    \"sub_group_order\": \"Orden de subgrupo\",\n    \"time_end\": \"Hora de finalización\",\n    \"stashbox_search\": {\n        \"header\": \"Buscar {entityType} en StashBox\",\n        \"no_results\": \"No se han encontrado resultados.\",\n        \"placeholder_name_or_id\": \"Nombre de {entityType} o StashID...\",\n        \"select_stashbox\": \"Seleccionar StashBox...\"\n    },\n    \"latest_scene\": \"Última escena\",\n    \"stash_id_count\": \"Recuento de ID de Stash\",\n    \"duplicated\": \"Duplicado\",\n    \"duplicated_stash_id\": \"Duplicado (ID de Stash)\",\n    \"duplicated_title\": \"Duplicado (Título)\",\n    \"career_end\": \"Fin de la carrera\",\n    \"career_start\": \"Inicio de la carrera\",\n    \"tag_tagger\": {\n        \"add_new_tags\": \"Añadir nuevas etiquetas\",\n        \"any_names_entered_will_be_queried\": \"Cualquier nombre introducido se consultará en la instancia remota de Stash-Box y se añadirá si se encuentra. Solo se considerarán coincidencias las coincidencias exactas.\",\n        \"batch_add_tags\": \"Añadir etiquetas por lotes\",\n        \"batch_update_tags\": \"Actualización por lotes de etiquetas\",\n        \"current_page\": \"Página actual\",\n        \"failed_to_save_tag\": \"No se pudo guardar la etiqueta «{tag}»\",\n        \"name_already_exists\": \"El nombre ya existe\",\n        \"network_error\": \"Error de red\",\n        \"no_results_found\": \"No se han encontrado resultados.\",\n        \"number_of_tags_will_be_processed\": \"Se procesarán {tag_count} etiquetas\",\n        \"query_all_tags_in_the_database\": \"Todas las etiquetas de la base de datos\",\n        \"refresh_tagged_tags\": \"Actualizar etiquetas etiquetadas\",\n        \"refreshing_will_update_the_data\": \"Al actualizar se actualizarán los datos de cualquier etiqueta etiquetada de la instancia stash-box.\",\n        \"status_tagging_job_queued\": \"Estado: Trabajo de etiquetado en cola\",\n        \"status_tagging_tags\": \"Estado: Etiquetado de etiquetas\",\n        \"tag_already_tagged\": \"Etiqueta ya etiquetada\",\n        \"tag_names_or_stashids_separated_by_comma\": \"Nombres de etiquetas o StashID separados por comas\",\n        \"tag_selection\": \"Selección de etiquetas\",\n        \"tag_successfully_tagged\": \"Etiqueta etiquetada correctamente\",\n        \"tag_status\": \"Estado de la etiqueta\",\n        \"to_use_the_tag_tagger\": \"Para utilizar el etiquetador, es necesario configurar una instancia de stash-box.\",\n        \"untagged_tags\": \"Etiquetas sin etiquetar\",\n        \"update_tags\": \"Actualizar etiquetas\",\n        \"updating_untagged_tags_description\": \"Al actualizar las etiquetas sin etiquetar, se intentará encontrar cualquier etiqueta que carezca de un stashid y se actualizarán los metadatos.\",\n        \"config\": {\n            \"create_parent_desc\": \"Crear etiquetas principales que falten a partir de las categorías de stash-box, o etiquetar las etiquetas principales existentes con nombres que coincidan exactamente\",\n            \"create_parent_label\": \"Crear etiquetas principales\"\n        },\n        \"create_or_tag_parent_tags\": \"Crear las etiquetas principales que falten o etiquetar las ya existentes\"\n    },\n    \"tagger\": {\n        \"config\": {\n            \"active_stash-box_instance\": \"Instancia activa del almacén oculto:\",\n            \"edit_excluded_fields\": \"Editar campos excluidos\",\n            \"excluded_fields\": \"Campos excluidos:\",\n            \"fields_will_not_be_changed\": \"Estos campos no se modificarán al actualizar {entity}.\",\n            \"no_fields_are_excluded\": \"No se excluye ningún campo\",\n            \"no_instances_found\": \"No se han encontrado instancias\"\n        }\n    },\n    \"unsupported_criteria\": \"Criterios no admitidos: {criteria}\",\n    \"include_sub_folders\": \"Incluir subcarpetas\",\n    \"parent_folder\": \"Carpeta principal\",\n    \"sub_folder_depth\": \"Profundidad de la subcarpeta (vacía para todas)\",\n    \"sub_folders\": \"Subcarpetas\",\n    \"empty_value\": \"vacío\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/et-EE.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Lisa\",\n        \"add_directory\": \"Lisa kaust\",\n        \"add_entity\": \"Lisa {entityType}\",\n        \"add_to_entity\": \"Lisa {entityType}-sse\",\n        \"allow\": \"Luba\",\n        \"allow_temporarily\": \"Luba ajutiselt\",\n        \"anonymise\": \"Anonüümseks Muutmine\",\n        \"apply\": \"Rakenda\",\n        \"auto_tag\": \"Märgi automaatselt\",\n        \"backup\": \"Varunda\",\n        \"browse_for_image\": \"Otsi pilti…\",\n        \"cancel\": \"Tühista\",\n        \"clean\": \"Puhasta\",\n        \"clear\": \"Eemalda\",\n        \"clear_back_image\": \"Eemalda tagapilt\",\n        \"clear_front_image\": \"Eemalda esipilt\",\n        \"clear_image\": \"Eemalda Pilt\",\n        \"close\": \"Sulge\",\n        \"confirm\": \"Kinnita\",\n        \"continue\": \"Jätka\",\n        \"create\": \"Loo\",\n        \"create_chapters\": \"Loo Peatükk\",\n        \"create_entity\": \"Loo {entityType}\",\n        \"create_marker\": \"Loo Marker\",\n        \"created_entity\": \"Loodud {entity_type}: {entity_name}\",\n        \"customise\": \"Kohanda\",\n        \"delete\": \"Kustuta\",\n        \"delete_entity\": \"Kustuta {entityType}\",\n        \"delete_file\": \"Kustuta fail\",\n        \"delete_file_and_funscript\": \"Kustuta fail (ja funscript)\",\n        \"delete_generated_supporting_files\": \"Kustuta genereeritud toetusfailid\",\n        \"disallow\": \"Keela\",\n        \"download\": \"Lae alla\",\n        \"download_anonymised\": \"Lae alla anonümiseeritult\",\n        \"download_backup\": \"Lae varundus alla\",\n        \"edit\": \"Muuda\",\n        \"edit_entity\": \"Muuda {entityType}\",\n        \"export\": \"Ekspordi\",\n        \"export_all\": \"Ekspordi kõik…\",\n        \"find\": \"Otsi\",\n        \"finish\": \"Lõpeta\",\n        \"from_file\": \"Failist…\",\n        \"from_url\": \"URL-ilt…\",\n        \"full_export\": \"Täielik eksportimine\",\n        \"full_import\": \"Täielik importimine\",\n        \"generate\": \"Genereeri\",\n        \"generate_thumb_default\": \"Genereri vaikepisipilt\",\n        \"generate_thumb_from_current\": \"Genereeri pisipilt praegusest\",\n        \"hash_migration\": \"räsi migratsioon\",\n        \"hide\": \"Peida\",\n        \"hide_configuration\": \"Peida Seadistus\",\n        \"identify\": \"Tuvasta\",\n        \"ignore\": \"Ignoreeri\",\n        \"import\": \"Impordi…\",\n        \"import_from_file\": \"Impordi failist\",\n        \"logout\": \"Logi välja\",\n        \"make_primary\": \"Määra Peamiseks\",\n        \"merge\": \"Liida\",\n        \"migrate_blobs\": \"Migreeri blobid\",\n        \"migrate_scene_screenshots\": \"Migreeri stseenide ekraanipildid\",\n        \"next_action\": \"Järgmine\",\n        \"not_running\": \"ei jookse\",\n        \"open_in_external_player\": \"Ava välises mängijas\",\n        \"open_random\": \"Ava Suvaline\",\n        \"overwrite\": \"Kirjuta üle\",\n        \"play_random\": \"Mängi Suvaline\",\n        \"play_selected\": \"Mängi valitud\",\n        \"preview\": \"Eelvaade\",\n        \"previous_action\": \"Tagasi\",\n        \"reassign\": \"Määra Ümber\",\n        \"refresh\": \"Värskenda\",\n        \"reload_plugins\": \"Lae pluginad uuesti\",\n        \"reload_scrapers\": \"Lae kraapijad uuesti\",\n        \"remove\": \"Eemalda\",\n        \"remove_from_gallery\": \"Eemalda Galeriist\",\n        \"rename_gen_files\": \"Nimeta genereeritud failid ümber\",\n        \"rescan\": \"Skaneeri uuesti\",\n        \"reshuffle\": \"Sega uuesti\",\n        \"running\": \"jookseb\",\n        \"save\": \"Salvesta\",\n        \"save_delete_settings\": \"Kasuta neid sätteid kustutamisel tavasätetena\",\n        \"save_filter\": \"Salvesta filter\",\n        \"scan\": \"Skaneeri\",\n        \"scrape\": \"Kraabi\",\n        \"scrape_query\": \"Kraapimispäring\",\n        \"scrape_scene_fragment\": \"Kraabi fragmentide kaupa\",\n        \"scrape_with\": \"Kraabi kasutades…\",\n        \"search\": \"Otsi\",\n        \"select_all\": \"Vali Kõik\",\n        \"select_entity\": \"Vali {entityType}\",\n        \"select_folders\": \"Vali kaustad\",\n        \"select_none\": \"Vali Mitte Midagi\",\n        \"selective_auto_tag\": \"Valikuline automaatne märkija\",\n        \"selective_clean\": \"Valikuline puhastus\",\n        \"selective_scan\": \"Valikuline skaneerimine\",\n        \"set_as_default\": \"Määra vaikeväärtuseks\",\n        \"set_back_image\": \"Tagapilt…\",\n        \"set_front_image\": \"Esipilt…\",\n        \"set_image\": \"Seadista pilt…\",\n        \"show\": \"Näita\",\n        \"show_configuration\": \"Näita Seadistust\",\n        \"skip\": \"Jäta vahele\",\n        \"split\": \"Jaga Kaheks\",\n        \"stop\": \"Stop\",\n        \"submit\": \"Esita\",\n        \"submit_stash_box\": \"Esita Stash-Kasti\",\n        \"submit_update\": \"Esita uuendus\",\n        \"swap\": \"Vaheta\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Kas oled kindel, et tahad Puhastada? See kustutab andmebaasi ja genereeritud sisu kõikide stseenide ja galeriide jaoks, mida enam failisüsteemis ei leidu.\",\n            \"dry_mode_selected\": \"Kuiv režiim valitud. Tegelikku kustutamist ei toimu, ainult logidesse kirjutamine.\",\n            \"import_warning\": \"Kas oled kindel, et tahad importida? See kustutab andmebaasi ja impordib ekporditud metaandmed uuesti.\"\n        },\n        \"temp_disable\": \"Keela ajutiselt…\",\n        \"temp_enable\": \"Luba ajutiselt…\",\n        \"unset\": \"Tühista\",\n        \"use_default\": \"Kasuta vaikeseadet\",\n        \"view_random\": \"Vaata Suvalist\",\n        \"reload\": \"Laadi Uuesti\",\n        \"disable\": \"Keela\",\n        \"enable\": \"Luba\",\n        \"assign_stashid_to_parent_studio\": \"Määra eksisteerivale vanemstuudiole Stash ID ja uuenda metaandmeid\",\n        \"create_parent_studio\": \"Loo vanemstuudio\",\n        \"encoding_image\": \"Pildi kodeerimine…\",\n        \"optimise_database\": \"Optimiseeri andmebaasi\",\n        \"clean_generated\": \"Puhasta genereeritud faile\",\n        \"choose_date\": \"Vali kuupäev\",\n        \"copy_to_clipboard\": \"Kopeeri lõikelauale\",\n        \"reset_cover\": \"Lähtesta Vaikimise Kaanepilt\",\n        \"reset_play_duration\": \"Lähtesta vaatamise kestus\",\n        \"reset_resume_time\": \"Lähtesta jätkamise aeg\",\n        \"set_cover\": \"Märgi Kaanepildiks\",\n        \"add_sub_groups\": \"Lisa Alam-grupid\",\n        \"remove_date\": \"Eemalda kuupäev\",\n        \"add_manual_date\": \"Lisa manuaalne kuupäev\",\n        \"add_o\": \"Lisa O\",\n        \"add_play\": \"Lisa mängimine\",\n        \"clear_date_data\": \"Eemalda kuupäeva andmed\",\n        \"view_history\": \"Vaata ajalugu\",\n        \"remove_from_containing_group\": \"Eemalda Grupist\",\n        \"sidebar\": {\n            \"open\": \"Ava külgriba\",\n            \"close\": \"Sulge külgriba\",\n            \"toggle\": \"Lülita külgriba sisse/välja\"\n        },\n        \"play\": \"Esita\",\n        \"show_results\": \"Näita tulemusi\",\n        \"show_count_results\": \"Näita {count} tulemust\",\n        \"load\": \"Lae\",\n        \"load_filter\": \"Lae filter\",\n        \"add_stash_id\": \"Lisa Stash ID\",\n        \"create_new\": \"Loo uus\",\n        \"save_and_new\": \"Salvesta & Uus\",\n        \"invert_selection\": \"Inverteeri Valik\",\n        \"reveal_in_file_manager\": \"Kuva Failihalduris\",\n        \"select_directory\": \"Vali kaust\",\n        \"exclude_lowercase\": \"välista\",\n        \"from_clipboard\": \"Lõikelaualt\",\n        \"selective_generate\": \"Valiv genereerimine\",\n        \"create_parent_tag\": \"Loo vanemsilt\"\n    },\n    \"actions_name\": \"Tegevused\",\n    \"age\": \"Vanus\",\n    \"aliases\": \"Varjunimed\",\n    \"all\": \"kõik\",\n    \"also_known_as\": \"Tuntud ka kui\",\n    \"appears_with\": \"Esineb Koos\",\n    \"ascending\": \"Kasvav\",\n    \"average_resolution\": \"Keskmine Resolutsioon\",\n    \"between_and\": \"ja\",\n    \"birth_year\": \"Sünniaasta\",\n    \"birthdate\": \"Sünnikuupäev\",\n    \"bitrate\": \"Bitikiirus\",\n    \"blobs_storage_type\": {\n        \"database\": \"Andmebaas\",\n        \"filesystem\": \"Failisüsteem\"\n    },\n    \"captions\": \"Subtiitrid\",\n    \"career_length\": \"Karjääri Pikkus\",\n    \"chapters\": \"Peatükid\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktiivne stash-kasti eksemplar:\",\n            \"blacklist_desc\": \"Musta nimekirja üksused on päringutest välja jäetud. Pane tähele, et need on regulaaravaldised ja tõstutundetud. Teatud tähemärgid tuleb eemaldada kaldkriipsuga: {chars_require_escape}\",\n            \"blacklist_label\": \"Must nimekiri\",\n            \"query_mode_auto\": \"Auto\",\n            \"query_mode_auto_desc\": \"Kasutab metaandmeid, kui need olemas on, või failinime\",\n            \"query_mode_dir\": \"Kaust\",\n            \"query_mode_dir_desc\": \"Kasutab ainult videofaili kausta\",\n            \"query_mode_filename\": \"Failinimi\",\n            \"query_mode_filename_desc\": \"Kasutab ainult failinime\",\n            \"query_mode_label\": \"Päringurežiim\",\n            \"query_mode_metadata\": \"Metaandmed\",\n            \"query_mode_metadata_desc\": \"Kasutab ainult metaandmeid\",\n            \"query_mode_path\": \"Failitee\",\n            \"query_mode_path_desc\": \"Kasutab kogu failiteed\",\n            \"set_cover_desc\": \"Asenda stseeni kaanepilt, kui seda õnnestub leida.\",\n            \"set_cover_label\": \"Määra stseeni kaanepilt\",\n            \"set_tag_desc\": \"Ühenda stseenile külge silte, kas olemasolevate siltide ülekirjutamise või liitmise kaudu.\",\n            \"set_tag_label\": \"Määra sildid\",\n            \"source\": \"Allikas\",\n            \"mark_organized_desc\": \"Märgi stseen koheselt Orgainiseerituks peale Salvesta nupu vajutamist.\",\n            \"mark_organized_label\": \"Märgi salvestamisel Organiseerituks\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Dubleeritud musta nimekirja ese\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Näitlejate sood\",\n                \"description\": \"Stseene märgistades näidatakse nende sugudega näitlejaid.\"\n            }\n        },\n        \"noun_query\": \"Päring\",\n        \"results\": {\n            \"duration_off\": \"Kestus on vähemalt {number}s vale\",\n            \"duration_unknown\": \"Kestus teadmata\",\n            \"fp_found\": \"{fpCount, plural, =0 {Uusi sõrmejälje kattuvusi ei leitud} other {# uut sõrmejälje kattuvust leitud}}\",\n            \"fp_matches\": \"Kestus klapib\",\n            \"fp_matches_multi\": \"Kestus klapib {matchCount}/{durationsLength} sõrmejälgedel\",\n            \"hash_matches\": \"{hash_type} klapib\",\n            \"match_failed_already_tagged\": \"Stseen juba sildistatud\",\n            \"match_failed_no_result\": \"Vasteid ei leitud\",\n            \"match_success\": \"Stseen edukalt sildistatud\",\n            \"phash_matches\": \"{count} PHashi kattuvust\",\n            \"unnamed\": \"Nimeta\"\n        },\n        \"verb_match_fp\": \"Leia Sõrmejälje Kattuvusi\",\n        \"verb_matched\": \"Kokkusobitatud\",\n        \"verb_scrape_all\": \"Kraabi Kõikjalt\",\n        \"verb_submit_fp\": \"Esita {fpCount, plural, one{# Sõrmejälg} other{# Sõrmejälge}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} kokkusobitamata stseenid\",\n        \"verb_add_as_alias\": \"Lisa nimi alisena\",\n        \"verb_link_existing\": \"Ühenda eksisteerivaga\",\n        \"verb_match_tag\": \"Sobita Silt\",\n        \"verb_scrape_selected\": \"Otsi Valitute Metaandmeid\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Ehituse räsi:\",\n            \"build_time\": \"Ehituse aeg:\",\n            \"check_for_new_version\": \"Kontrolli värskendusi\",\n            \"latest_version\": \"Uusim Versioon\",\n            \"latest_version_build_hash\": \"Uusima Versiooni Ehituse Räsi:\",\n            \"new_version_notice\": \"[UUS]\",\n            \"release_date\": \"Väljalaskekuupäev:\",\n            \"stash_discord\": \"Liitu meie {url}i kanaliga\",\n            \"stash_home\": \"Stashi kodu {url}-is\",\n            \"stash_open_collective\": \"Toeta meid läbi {url}-i\",\n            \"stash_wiki\": \"Stashi {url} leht\",\n            \"version\": \"Versioon\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Rakenduse Failiteed\"\n        },\n        \"categories\": {\n            \"about\": \"Lisainfo\",\n            \"changelog\": \"Muudatuste nimekiri\",\n            \"interface\": \"Kasutajaliides\",\n            \"logs\": \"Logid\",\n            \"metadata_providers\": \"Metaandmete Pakkujad\",\n            \"plugins\": \"Pluginad\",\n            \"scraping\": \"Kraapimine\",\n            \"security\": \"Turvalisus\",\n            \"services\": \"Teenused\",\n            \"system\": \"Süsteem\",\n            \"tasks\": \"Ülesanded\",\n            \"tools\": \"Tööriistad\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Luba {tempIP}\",\n            \"allowed_ip_addresses\": \"Lubatud IP aadressid\",\n            \"allowed_ip_temporarily\": \"Lubatud IP ajutiselt\",\n            \"default_ip_whitelist\": \"Vaikimisi IP valge nimekiri\",\n            \"default_ip_whitelist_desc\": \"Vaikimisi IP aadressid lubavad DLNA ligipääsu. Kasuta {wildcard}, et lubada kõiki IP aadresse.\",\n            \"disabled_dlna_temporarily\": \"DLNA ajutiselt keelatud\",\n            \"disallowed_ip\": \"Keelatud IP\",\n            \"enabled_by_default\": \"Vaikimisi lubatud\",\n            \"enabled_dlna_temporarily\": \"DLNA ajutiselt lubatud\",\n            \"network_interfaces\": \"Kasutajaliidesed\",\n            \"network_interfaces_desc\": \"Kasutajaliidesed DLNA serveri paljastamiseks. Tühi nimekiri lubab jooksutamist kõigil kasutajaliidestel. Vajalik DLNA taaskäivitus peale muutmist.\",\n            \"recent_ip_addresses\": \"Hiljutised IP aadressid\",\n            \"server_display_name\": \"Serveri kuvanimi\",\n            \"server_display_name_desc\": \"DLNA server nimi. Vaikimisi {server_name}, kui midagi pole sisestatud.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Edukalt tühistatud ajutine käitumine\",\n            \"until_restart\": \"restardini\",\n            \"video_sort_order\": \"Videote sorteerimise vaikeväärtus\",\n            \"video_sort_order_desc\": \"Viis, kuidas vaikimisi videoid sorteerida.\",\n            \"server_port\": \"Serveri port\",\n            \"server_port_desc\": \"Port DLNA serveri jaoks. Vajab peale muutmist DLNA taaskäivitust.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API võti\",\n                \"api_key_desc\": \"API võti välistele süsteemidele. Nõutud ainult siis, kui kasutajanimi/parool on sätitud. Kasutajanimi peab olema salvestatud enne API võtme genereerimist.\",\n                \"authentication\": \"Autentimine\",\n                \"clear_api_key\": \"Puhasta API võti\",\n                \"credentials\": {\n                    \"description\": \"Mandaat stashile ligipääsu piiramiseks.\",\n                    \"heading\": \"Mandaat\"\n                },\n                \"generate_api_key\": \"Genereeri API võti\",\n                \"log_file\": \"Logi fail\",\n                \"log_file_desc\": \"Failitee failini, kuhu logid sisestada. Jäta tühjaks, kui soovid logide salvestamise välja lülitada. Vajab taaskäivitust.\",\n                \"log_http\": \"Logi HTTP ligipääs\",\n                \"log_http_desc\": \"Avaldab HTTP ligipääsu logid terminali. Vajab taaskäivitust.\",\n                \"log_to_terminal\": \"Logi terminali\",\n                \"log_to_terminal_desc\": \"Avaldab logid lisaks failile ka terminalis. Alati sisselülitatud, kui logimine faili on keelatud. Vajab taaskäivitust.\",\n                \"maximum_session_age\": \"Maksimaalne sessiooni vanus\",\n                \"maximum_session_age_desc\": \"Maksimaalne paigalseisuaeg enne, kui sessioon aegub, sekundites. Vajab taaskäivitust.\",\n                \"password\": \"Parool\",\n                \"password_desc\": \"Parool Stashi pääsemiseks. Jäta tühjaks, kui soovid sisselogimise keelata\",\n                \"stash-box_integration\": \"Stash-kasti integratsioon\",\n                \"username\": \"Kasutajanimi\",\n                \"username_desc\": \"Kasutajanimi Stashi pääsemiseks. Jäta tühjaks, kui soovid sisselogimise keelata\",\n                \"log_file_max_size\": \"Maksimaalne logi suurus\",\n                \"log_file_max_size_desc\": \"Maksimaalne logifaili suurus megabaitides enne tihendamist. 0MB on väljalülitatud. Nõuab taaskäivitust.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Failitee SQLite andmebaasi varundusfailide jaoks.\",\n                \"heading\": \"Varunduse failitee\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Kus kohas hoida binaarseid andmeid failisüsteemis. Kehtib ainult kui kasutad Failisüsteem blob salvestustüüpi. HOIATUS: selle muutmine nõuab olemasolevate andmete manuaalset liigutamist.\",\n                \"heading\": \"Binaarseete andmete failisüsteemi tee\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Kus hoida binaarseid andmeid nagu stseeni kaanepildid, näitlejate, stuudiote ja siltide pilte. Peale selle väärtuse muutmist tuleb olemasolevad andmed migreerida kasutades Migreeri blobe ülesannet. Vaata Ülesannete lehele migreerimiseks.\",\n                \"heading\": \"Binaarsete andmete hoiustamistüüp\"\n            },\n            \"cache_location\": \"Failitee vahemäluni. Nõutud kui striimimiseks kasutatakse HLSi (näiteks Apple seadetel) või DASHi.\",\n            \"cache_path_head\": \"Vahemälu failitee\",\n            \"calculate_md5_and_ohash_desc\": \"Kalkuleeri MD5 checksum lisaks oshashile. Lubamine põhjustab aeglasemat esmast skaneerimist. Faili nimetuse räsi peab olema sätitud oshashiks, et keelata MD5 kalkuleerimine.\",\n            \"calculate_md5_and_ohash_label\": \"Kalkuleeri MD5 videote jaoks\",\n            \"check_for_insecure_certificates\": \"Otsi ebaturvalisi sertifikaate\",\n            \"check_for_insecure_certificates_desc\": \"Mõned lehed kasutavad ebaturvalisi SSL sertifikaate. Kui märkimata, kraapija jätab sertifikaadi kontrollimise vahele ning võimaldab nendelt lehtedelt andmeid kraapida. Kui kraapimise ajal esineb sertifikaadivigu, eemalda linnuke.\",\n            \"chrome_cdp_path\": \"Chrome CDP tee\",\n            \"chrome_cdp_path_desc\": \"Failitee Chrome käivitajani, või kaugaadress (algab http:// või https:// -iga, näiteks http://localhost:9222/json/version) Chrome'i eksemplarini.\",\n            \"create_galleries_from_folders_desc\": \"Kui lubatud, loob vaikeväärtusena galeriisid pilte sisaldavatest kaustadest. Loo kasutas fail nimega .forcegallery või .nogallery, et seda seadet üle kirjutada.\",\n            \"create_galleries_from_folders_label\": \"Loo galeriisid kaustadest, mis sisaldavad pilte\",\n            \"database\": \"Andmebaas\",\n            \"db_path_head\": \"Andmebaasi failitee\",\n            \"directory_locations_to_your_content\": \"Failitee asukohad sisule\",\n            \"excluded_image_gallery_patterns_desc\": \"Pildi- ja galeriifailide/teede regexpid, mida skannimisest välja jätta ja Clean'i ülesannetesse lisada.\",\n            \"excluded_image_gallery_patterns_head\": \"Välistatud pildi/galerii mustrid\",\n            \"excluded_video_patterns_desc\": \"Videofailide/teede regexpid, mida skannimisest välja jätta ja Clean'i ülesannetesse lisada.\",\n            \"excluded_video_patterns_head\": \"Välistatud video mustrid\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"Kasutab olemasolevat riistvara reaalajas video transkodeerimiseks.\",\n                    \"heading\": \"FFmpeg riistvara enkodeerimine\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Edasijõudnutele: Lisaargumendid mida edastada ffmpegi sisendväljale live video transkodeerimise ajal.\",\n                        \"heading\": \"FFmpeg live transkodeerimise sisendargumendid\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Edasijõudnutele: Lisaargumendid mida edastada ffmpegi väljundväljale live video transkodeerimise ajal.\",\n                        \"heading\": \"FFmpeg live transkodeerimise väljundargumendid\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Edasijõudnutele: Lisaargumendid mida edastada ffmpegi sisendväljale video genereerimisel.\",\n                        \"heading\": \"FFmpeg transkodeerimise sisendargumendid\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Edasijõudnutele: Lisaargumendid mida edastada ffmpegi väljundväljale video genereerimisel.\",\n                        \"heading\": \"FFmpeg transkodeerimise väljundargumendid\"\n                    }\n                },\n                \"ffprobe_path\": {\n                    \"heading\": \"FFprobe käivitava faili tee\",\n                    \"description\": \"ffprobe käivitatava faili tee (mitte ainult kaust). Kui see on tühi, lahendatakse ffprobe keskkonnast $PATHi, konfiguratsioonikataloogi või $HOME/.stash kaudu.\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"FFmpegi käivitava faili tee\",\n                    \"description\": \"ffmpegi käivitatava faili tee (mitte ainult kaust). Kui see on tühi, lahendatakse ffmpeg keskkonnast $PATHi, konfiguratsioonikataloogi või $HOME/.stash kaudu.\"\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"Laadib FFmpegi alla konfiguratsioonikataloogi ja tühjendab ffmpegi ja ffprobe'i teed konfiguratsioonikataloogist lahendamiseks.\",\n                    \"heading\": \"Lae alla FFmpeg\"\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"Kaasa vahemik genereeritud kuumkaartidel\",\n            \"funscript_heatmap_draw_range_desc\": \"Joonista liikumisvahemik genereeritud kuumkaardi y-teljel. Olemasolevad kuumkaardid tuleb peale muutmist uuesti genereerida.\",\n            \"gallery_cover_regex_desc\": \"Regexpte kasutakse, et tuvastada pilti kui galerii kaanepildina.\",\n            \"gallery_cover_regex_label\": \"Galerii kaanepildi muster\",\n            \"gallery_ext_desc\": \"Komadega eraldatud faililaiendite loend, mis tuvastatakse galerii ZIP-failidena.\",\n            \"gallery_ext_head\": \"Galerii ZIP laiendused\",\n            \"generated_file_naming_hash_desc\": \"Kasutage failide nimetamiseks MD5 või oshashi. Selle muutmiseks on vaja, et kõikides stseenides oleks kohaldatav MD5/oshash väärtus täidetud. Pärast selle väärtuse muutmist tuleb olemasolevad loodud failid migreerida või uuesti genereerida. Vaadake üleviimise kohta lehekülge Ülesanded.\",\n            \"generated_file_naming_hash_head\": \"Genereeritud faili nimetamise räsi\",\n            \"generated_files_location\": \"Loodud failide (stseenimarkerid, stseeni eelvaated, spraidid jne) asukoht failiteel.\",\n            \"generated_path_head\": \"Genereeritud failitee\",\n            \"hashing\": \"Räsimine\",\n            \"heatmap_generation\": \"Funscripti Kuumkaardi Genereerimine\",\n            \"image_ext_desc\": \"Komadega eraldatud faililaiendite loend, mis tuvastatakse piltidena.\",\n            \"image_ext_head\": \"Pildilaiendused\",\n            \"include_audio_desc\": \"Kaasa eelvaadete loomisel helivoog.\",\n            \"include_audio_head\": \"Kaasa heli\",\n            \"logging\": \"Logimine\",\n            \"maximum_streaming_transcode_size_desc\": \"Transkodeeritud voogude maksimaalne suurus.\",\n            \"maximum_streaming_transcode_size_head\": \"Maksimaalne voogesituse ümberkodeerimise suurus\",\n            \"maximum_transcode_size_desc\": \"Loodud ümberkoodimiste maksimaalne suurus.\",\n            \"maximum_transcode_size_head\": \"Maksimaalne ümberkodeerimise suurus\",\n            \"metadata_path\": {\n                \"description\": \"Kataloogi asukoht, mida kasutatakse täieliku ekspordi või impordi teostamisel.\",\n                \"heading\": \"Metaandmete failitee\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Automaatse tuvastamise jaoks määra 0. Hoiatus: kui tehakse rohkem toiminguid, kui on vaja 100% CPU kasutuse saavutamiseks, väheneb jõudlus ja võib esineda muid probleeme.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Paralleelsete skaneerimise/genereerimise ülesannete arv\",\n            \"parallel_scan_head\": \"Paralleelne Skaneerimine/Generatsioon\",\n            \"preview_generation\": \"Eelvaate Genereerimine\",\n            \"python_path\": {\n                \"description\": \"Pythoni käivitataja faili tee (mitte ainult kaust). Kasutatakse skriptipõhiste kraapijate ja pluginate jaoks. Kui see on tühi, lahendatakse Python keskkonnast.\",\n                \"heading\": \"Pythoni käivitaja failitee\"\n            },\n            \"scraper_user_agent\": \"Kraapija Kasutaja-Agent\",\n            \"scraper_user_agent_desc\": \"Kasutajaagendi string, mida kasutatakse kraapimise HTTP-päringute käigus.\",\n            \"scrapers_path\": {\n                \"description\": \"Failitee kraapijate sättefailide jaoks.\",\n                \"heading\": \"Kraapijate failitee\"\n            },\n            \"scraping\": \"Kraapimine\",\n            \"sqlite_location\": \"Failitee asukoht SQLite andmebaasi jaoks (vajab taaskäivitust). HOIATUS: andmebaasi kasutamine, mis ei jookse samal süsteemil, kui Stash (nt üle võrgu) ei ole toetatud!\",\n            \"video_ext_desc\": \"Komadega eraldatud faililaiendite loend, mis tuvastatakse videotena.\",\n            \"video_ext_head\": \"Videolaiendused\",\n            \"video_head\": \"Video\",\n            \"plugins_path\": {\n                \"description\": \"Plugina konfiguratsioonifailide asukoht kataloogis.\",\n                \"heading\": \"Pluginate failitee\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"Tee, kuhu liigutatakse kustutatud failide lõpliku kustutamise asemel. Jäta tühjaks, et alati faile lõplikult kustutada.\",\n                \"heading\": \"Prügi failitee\"\n            },\n            \"sprite_generation_head\": \"Spraitide Genereerimine\",\n            \"sprite_interval_desc\": \"Iga genereeritud spraidi vaheline aeg sekundites.\",\n            \"sprite_interval_head\": \"Spraitide intervall\",\n            \"sprite_maximum_desc\": \"Stseeni jaoks genereeritavate spraitide maksimaalne arv. Piirangu keelamiseks määrake väärtuseks 0.\",\n            \"sprite_maximum_head\": \"Maksimaalne spraitide arv\",\n            \"sprite_minimum_desc\": \"Stseeni jaoks genereeritavate spraitide minimaalne arv\",\n            \"sprite_minimum_head\": \"Minimaalne spraitide arv\",\n            \"sprite_screenshot_size_desc\": \"Iga spraidi soovitud suurus pikslites.\",\n            \"sprite_screenshot_size_head\": \"Spraidi suurus\",\n            \"use_custom_sprite_interval_head\": \"Kasuta kohandatud spraitide intervalli\",\n            \"use_custom_sprite_interval_desc\": \"Luba kohandatud spraitide intervall vastavalt allolevatele sätetele.\"\n        },\n        \"library\": {\n            \"exclusions\": \"Välistused\",\n            \"gallery_and_image_options\": \"Galerii ja Piltide Sätted\",\n            \"media_content_extensions\": \"Mediasisu Laiendused\"\n        },\n        \"logs\": {\n            \"log_level\": \"Logimise tase\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Hookid\",\n            \"triggers_on\": \"Sisselülitatud päästikud\",\n            \"available_plugins\": \"Saadaval Pluginad\",\n            \"installed_plugins\": \"Installitud Pluginad\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metaandmed\",\n            \"entity_scrapers\": \"{entityType} kraapijad\",\n            \"excluded_tag_patterns_desc\": \"Sildinimede regexpid, mida kraapise tulemustest välja jätta.\",\n            \"excluded_tag_patterns_head\": \"Välistatud siltide mustrid\",\n            \"scraper\": \"Kraapija\",\n            \"scrapers\": \"Kraapijad\",\n            \"search_by_name\": \"Otsi nime järgi\",\n            \"supported_types\": \"Toetatud tüübid\",\n            \"supported_urls\": \"URL-id\",\n            \"installed_scrapers\": \"Installitud Kraapijad\",\n            \"available_scrapers\": \"Saadaval Kraapijad\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Lisa stash-kasti eksemplar\",\n            \"api_key\": \"API võti\",\n            \"description\": \"Stash-box hõlbustab stseenide ja esinejate automaatset märgistamist sõrmejälgede ja failinimede põhjal.\\nLõpp-punkti ja API võtme leiad oma konto lehelt stash-kasti eksemplaris. Nimed on nõutavad, kui lisatakse rohkem kui üks eksemplar.\",\n            \"endpoint\": \"Lõpp-punkt\",\n            \"graphql_endpoint\": \"GraphQL lõpp-punkt\",\n            \"name\": \"Nimi\",\n            \"title\": \"Stash-kasti Lõpp-punktid\",\n            \"max_requests_per_minute\": \"Maksimaalne arv päringuid minutis\",\n            \"max_requests_per_minute_description\": \"Kasutab vaikimisi väärtust {defaultValue}, kui on 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Ümbertöötlemine\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"{operation_name} lisatud tööde järjekorda\",\n            \"anonymise_and_download\": \"Loob anonüümse koopia andmebaasist ja laeb väljundfaili alla.\",\n            \"anonymise_database\": \"Loob andmebaasist tagavarakoopia tagavara koopiate kausta, muudab tundliku teabe anonüümseks. Seda saab jagada teistega abistamise ja probleemide analüüsimise eesmärgil. Originaalset andmebaasi ei muudeta. Anonüümne andmebaas kasutab failinime formaati {filename_format}.\",\n            \"anonymising_database\": \"Muudan andmebaasi anonüümseks\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Automaatne märkimine kõikidel failiteedel\",\n                \"auto_tagging_paths\": \"Automaatne märkimine järgnevatel failiteedel\"\n            },\n            \"auto_tag_based_on_filenames\": \"Märgi sisu automaatselt vastavalt failinimedele.\",\n            \"auto_tagging\": \"Automaatne märkimine\",\n            \"backing_up_database\": \"Andmebaasi varundamine\",\n            \"backup_and_download\": \"Teeb andmebaasist varukoopia ja laadib saadud faili alla.\",\n            \"cleanup_desc\": \"Kontrolli puuduvaid faile ja eemalda need andmebaasist. See on hävitav tegevus.\",\n            \"data_management\": \"Andmehaldus\",\n            \"defaults_set\": \"Vaikesätted on määratud ja neid kasutatakse, kui klõpsate lehel Ülesanded nupul {action}.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Ärge lisage pealkirja osana faililaiendit\",\n            \"empty_queue\": \"Praegu ei tööta ühtegi ülesannet.\",\n            \"export_to_json\": \"Ekspordib andmebaasi sisu metaandmete failiteele JSON-vormingus.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Genereeri stseenide jaoks järgnevatelt failiteedelt\",\n                \"generating_scenes\": \"Genereerimine {num} {scene} jaoks\"\n            },\n            \"generate_desc\": \"Genereeri toetavad pildi-, sprite-, video-, vtt- ja muud failid.\",\n            \"generate_phashes_during_scan\": \"Videote tajutavate räsi genereerimine\",\n            \"generate_phashes_during_scan_tooltip\": \"Duplikaatide eemaldamiseks ja stseenide tuvastamiseks.\",\n            \"generate_previews_during_scan\": \"Genereeri animeeritud eelvaateid\",\n            \"generate_previews_during_scan_tooltip\": \"Genereeri ka animeeritud (webp) eelvaateid, nõutav ainult siis, kui Stseenide/Markeri Seinte eelvaate tüüp on seatud väärtusele Animeeritud pilt. Sirvides kasutavad need vähem CPUd, kui video eelvaated, kuid need genereeritakse lisaks põhifailidele ja on suuremad.\",\n            \"generate_sprites_during_scan\": \"Genereeri puhastusspriite\",\n            \"generate_thumbnails_during_scan\": \"Genereeri piltide jaoks pisipilte\",\n            \"generate_video_covers_during_scan\": \"Genereeri stseeni kaanepidid\",\n            \"generate_video_previews_during_scan\": \"Genereeri eelvaateid\",\n            \"generate_video_previews_during_scan_tooltip\": \"Genereeri video eelvaateid, mis esitatakse kursorit stseeni kohal hoides\",\n            \"generated_content\": \"Genereeritud Sisu\",\n            \"identify\": {\n                \"and_create_missing\": \"ja loo puuduv\",\n                \"create_missing\": \"Loo puuduv\",\n                \"default_options\": \"Vaikesätted\",\n                \"description\": \"Stseeni metaandmete automaatne määramine stash-kasti ja kraapija allikate abil.\",\n                \"explicit_set_description\": \"Kui allikaspetsiifilistes suvandites neid ei alistata, kasutatakse järgmisi valikuid.\",\n                \"field\": \"Väli\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Välja Valikud\",\n                \"heading\": \"Tuvastamine\",\n                \"identifying_from_paths\": \"Stseenide tuvastamine järgmistelt failiteedelt\",\n                \"identifying_scenes\": \"Tuvastan {num} {scene}\",\n                \"include_male_performers\": \"Kaasa meesnäitlejaid\",\n                \"set_cover_images\": \"Määra kaanepildid\",\n                \"set_organized\": \"Määra organiseeritud silt\",\n                \"source\": \"Allikas\",\n                \"source_options\": \"{source} Sätted\",\n                \"sources\": \"Allikad\",\n                \"strategy\": \"Strateegia\",\n                \"skip_multiple_matches_tooltip\": \"Kui see ei ole lubatud ja vastuseks tuleb rohkem, kui üks tulem, valitakse juhuslikult üks\",\n                \"skip_multiple_matches\": \"Jäta vahele rohkem kui ühe vastusega tulemid\",\n                \"skip_single_name_performers\": \"Jäta ühenimelised esitajad ilma üheselt mõistetamata vahele\",\n                \"skip_single_name_performers_tooltip\": \"Kui see ei ole lubatud, näitlejad, kellel on tihtiesinevad nimed nagu Samantha või olga, saavad tulemi\",\n                \"tag_skipped_performer_tooltip\": \"Loo märgend, nagu „Identifitseeri: Ühe Nimega Esineja”, mille saate stseenisildistaja vaates filtreerida, ja valida, kuidas soovite neid esinejaid käsitleda\",\n                \"tag_skipped_performers\": \"Märgista vahelejäetud esinejad\",\n                \"tag_skipped_matches\": \"Märgista vahelejäänud vasteid\",\n                \"tag_skipped_matches_tooltip\": \"Loo märgend, nagu „Identifitseeri: Mitu Vastet”, mida saate stseenisildistaja vaates filtreerida, ja valige käsitsi õige vaste\",\n                \"performer_genders\": \"Näitlejate sood\",\n                \"performer_genders_desc\": \"Identifitseerimise käigus kaasatakse valitud sooga näitlejad.\"\n            },\n            \"import_from_exported_json\": \"Impordi eksporditud JSON-ist metaandmete kataloogist. Kustutab olemasoleva andmebaasi.\",\n            \"incremental_import\": \"Järkjärguline import esitatud eksporditud ZIP-failist.\",\n            \"job_queue\": \"Ülesannete Järjekord\",\n            \"maintenance\": \"Hooldus\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Kustuta vanad andmed\",\n                \"description\": \"Migreeri blobid praegusele blobi andmesüsteemile. Seeda migratsiooni tuleb jooksutada peale blobi andmesalvestussüsteemi muutmist. Saab valikuliselt kustutada vanad andmed peale migratsiooni.\"\n            },\n            \"migrate_hash_files\": \"Kasutatakse pärast Genereeritud faili nimetamise räsi muutmist olemasolevate loodud failide ümbernimetamiseks uuele räsi-vormingule.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Kustuta ekraanipiltide failid\",\n                \"description\": \"Migreeri stseeni ekranipildid uute blobi andmesüsteemi. Seda migratsuiooni peaks jooksutama peale olemasoleva süsteemi migreerimist 0.20le. Saab valikuselt kustutada vanu ekraanipilte peale migratsiooni.\",\n                \"overwrite_existing\": \"Kirjuta üle eksisteerivad blobid koos ekraanipiltide andmetega\"\n            },\n            \"migrations\": \"Migreerimised\",\n            \"only_dry_run\": \"Tee ainult kuivjooks. Ära eemalda midagi\",\n            \"plugin_tasks\": \"Plugina Ülesanded\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Kõikide failiteede skaneerimine\",\n                \"scanning_paths\": \"Järgnevate failiteede skaneerimine\"\n            },\n            \"scan_for_content_desc\": \"Skaneeri uue sisu leidmiseks ja andmebaasi lisamiseks.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Määra nimi, kuupäev, detailid failisisestest metaandmetest\",\n            \"generate_clip_previews_during_scan\": \"Loo pildiklippide eelvaateid\",\n            \"generate_sprites_during_scan_tooltip\": \"Pildivalik video mängija all lihtsaks navigeerimiseks.\",\n            \"optimise_database\": \"Proovige jõudlust parandada, analüüsides ja seejärel kogu andmebaasifaili uuesti üles ehitades.\",\n            \"optimise_database_warning\": \"Hoiatus: selle ülesande täitmise ajal nurjuvad kõik andmebaasi muutvad toimingud ja sõltuvalt teie andmebaasi suurusest võib selle lõpuleviimiseks kuluda mitu minutit. See nõuab ka minimaalselt nii palju vaba kettaruumi, kui suur on teie andmebaas, kuid soovitatav on 1,5x.\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blobi failid\",\n                \"markers\": \"Markeri eelvaated\",\n                \"image_thumbnails\": \"Piltide pisipildid\",\n                \"image_thumbnails_desc\": \"Piltide pisipildid ning klipid\",\n                \"previews\": \"Stseenide eelvaated\",\n                \"previews_desc\": \"Stseenide eelvaated ja pisipildid\",\n                \"sprites\": \"Stseeni spraidid\",\n                \"transcodes\": \"Stseeni transkodeeringud\",\n                \"description\": \"Eemaldab genereeritud failid ilma vastava andmebaasi kirjeta.\"\n            },\n            \"rescan\": \"Skaneeri failid uuesti\",\n            \"rescan_tooltip\": \"Kontrollige uuesti kõiki teel olevaid faile. Kasutatakse faili metaandmete värskendamiseks ja ZIP-failide uuesti skannimiseks.\",\n            \"backup_database\": {\n                \"description\": \"Varundab andmebaasi ja blob-failid.\",\n                \"destination\": \"Sihtkoht\",\n                \"download\": \"Laadi alla varukoopia\",\n                \"include_blobs\": \"Lisa varukoopiasse blob-id\",\n                \"include_blobs_desc\": \"Keela, et varundada ainult SQLite'i andmebaasifaili.\",\n                \"sqlite\": \"Varukoopiafail on SQLite'i andmebaasifaili koopia failinimega {filename_format}\",\n                \"to_directory\": \"Kausta {directory}\",\n                \"warning_blobs\": \"Blob-faile varukoopiasse ei lisata. See tähendab, et varukoopiast edukaks taastamiseks peavad blob-failid olema blob-salvestuskohas.\",\n                \"zip\": \"SQLite'i andmebaasifail ja blob-failid pakitakse ühte faili nimega {filename_format}\"\n            },\n            \"generate_image_phashes_during_scan\": \"Kujutise tajutavate räsi genereerimine\",\n            \"generate_image_phashes_during_scan_tooltip\": \"Deduplikatsiooni ja identifitseerimise jaoks.\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Duplikaatstseenide kontroll\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Lisa Väli\",\n                \"capitalize_title\": \"Pealkirja kirjutamine suurtähtedega\",\n                \"display_fields\": \"Kuva väljad\",\n                \"escape_chars\": \"Literaalsete märkide vältimiseks kasutage \\\\\",\n                \"filename\": \"Failinimi\",\n                \"filename_pattern\": \"Failinime Muster\",\n                \"ignore_organized\": \"Ignoreeri organiseeritud stseene\",\n                \"ignored_words\": \"Ignoreeritud sõnad\",\n                \"matches_with\": \"Kattub {i}-ga\",\n                \"select_parser_recipe\": \"Valige Parser-i Retsept\",\n                \"title\": \"Stseeni failinimede parser\",\n                \"whitespace_chars\": \"Tühikumärgid\",\n                \"whitespace_chars_desc\": \"Need märgid asendatakse pealkirjas tühikutega\"\n            },\n            \"scene_tools\": \"Stseeni Tööriistad\",\n            \"graphql_playground\": \"GraphQL mänguplats\",\n            \"heading\": \"Tööriistad\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Lühenda loendureid kaartidel ja üksikasjade vaatamise lehtedel, näiteks \\\"1831\\\" vormindatakse kujule \\\"1,8K\\\".\",\n                \"heading\": \"Loendurite lühendamine\"\n            },\n            \"basic_settings\": \"Põhisätted\",\n            \"custom_css\": {\n                \"description\": \"Muudatuste jõustumiseks tuleb leht uuesti laadida. Pole garantiid, et kohandatud CSS töötab ka tuleviku Stashi uuendustes.\",\n                \"heading\": \"Kohandatud CSS\",\n                \"option_label\": \"Kohandatud CSS lubatud\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Pead muudatuste nägemiseks lehe uuesti laadima. Pole garantiid, et kohandatud JavaScript töötab ka tuleviku Stashi uuendustes.\",\n                \"heading\": \"Kohandatud JavaScript\",\n                \"option_label\": \"Kohandatud JavaScript lubatud\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Muutke üksikuid keele stringe. Põhiloendi leiate aadressilt https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json. Muudatuste jõustumiseks tuleb leht uuesti laadida.\",\n                \"heading\": \"Kohandatud tõlge\",\n                \"option_label\": \"Kohandatud tõlge lubatud\"\n            },\n            \"delete_options\": {\n                \"description\": \"Vaikesätted piltide, galeriide ja stseenide kustutamisel.\",\n                \"heading\": \"Kustutamissätted\",\n                \"options\": {\n                    \"delete_file\": \"Kustuta fail vaikesättena\",\n                    \"delete_generated_supporting_files\": \"Kustuta genereeritud toetusfailid vaikesättena\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Töölaua Integratsioon\",\n                \"notifications_enabled\": \"Luba teavitused\",\n                \"send_desktop_notifications_for_events\": \"Saada töölaua teated sündmuste korral.\",\n                \"skip_opening_browser\": \"Jäta brauseri avamine vahele\",\n                \"skip_opening_browser_on_startup\": \"Jäta käivitamise ajal brauseri automaatne avamine vahele.\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Eemaldage rippmenüü valijatest võimalus luua uusi objekte.\",\n                    \"heading\": \"Keela rippmenüü loomine\"\n                },\n                \"heading\": \"Redigeerimine\",\n                \"max_options_shown\": {\n                    \"label\": \"Maksimaalne number esemeid mida näidata valikmenüüdes\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Tähtedega Hindamise Täpsus\",\n                        \"options\": {\n                            \"full\": \"Täis\",\n                            \"half\": \"Pool\",\n                            \"quarter\": \"Neljandik\",\n                            \"tenth\": \"Kümme\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Hindamissüsteemi tüüp\",\n                        \"options\": {\n                            \"decimal\": \"Komakohaga\",\n                            \"stars\": \"Tähed\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Interaktiivsete skriptide taasesituse aja nihe millisekundites.\",\n                \"heading\": \"Funscripti nihe (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Ühenda\",\n                \"server_offset\": {\n                    \"heading\": \"Serveri Nihe\"\n                },\n                \"status\": {\n                    \"heading\": \"Handy Ühenduse Staatus\"\n                },\n                \"sync\": \"Sünkroniseeri\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Handy ühendusvõti interaktiivsete stseenide jaoks. Selle määramine võimaldab Stashil jagada teie praeguse stseeni teavet saidiga handyfeeling.com.\",\n                \"heading\": \"Handy ühendusvõti\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Pildi Valguskast\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Direktsioon\",\n                \"heading\": \"Pildisein\",\n                \"margin\": \"Marginaalid (pikslid)\"\n            },\n            \"images\": {\n                \"heading\": \"Pildid\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Kirjuta piltide pisipildid kettale, kui need luuakse käigupealt.\",\n                        \"heading\": \"Kirjutage piltide pisipildid\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"Kui teegis on videod keelatud, skannitakse videofailid (videolaiendiga lõppevad failid) pildiklipina.\",\n                        \"heading\": \"Skanni videolaiendeid pildiklipina\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Interaktiivsed Valikud\",\n            \"language\": {\n                \"heading\": \"Keel\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Stseeni maksimaalne kestus, mille jooksul stseenimängija videot uuesti mängib. Määra 0 keelamiseks.\",\n                \"heading\": \"Silmuse maksimaalne kestus\"\n            },\n            \"menu_items\": {\n                \"description\": \"Saad navigeerimisribal kuvada või peita erinevat tüüpi sisu.\",\n                \"heading\": \"Menüüelemendid\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Aja protsent, kui kaua stseeni tuleb esitada, enne kui selle esitamiste arvu suurendatakse.\",\n                \"heading\": \"Minimaalne esitusprotsent\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Esitaja vaikekujutiste kohandatud failitee. Sisseehitatud vaikeseadete kasutamiseks jätke tühjaks.\",\n                        \"heading\": \"Näitlejate kohandatud pilditee\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Vaikimisi on video (mp4) eelvaated. Kui soovite sirvimisel CPUd vähem kasutada, saate kasutada animeeritud piltide (veebi) eelvaateid. Kuid need tuleb genereerida lisaks video eelvaadetele ja need on suuremad failid.\",\n                \"heading\": \"Eelvaate tüüp\",\n                \"options\": {\n                    \"animated\": \"Animeeritud pilt\",\n                    \"static\": \"Staatiline pilt\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Ruudustiku Vaade\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Kuva stuudio ülekate tekstina\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Stseenimängija\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Alusta videot alati algusest\",\n                    \"auto_start_video\": \"Video automaatne alustamine\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Stseenivideote automaatne esitamine järjekorrast esitamisel või stseenide lehelt valitud või juhusliku esitamise korral.\",\n                        \"heading\": \"Video automaatne esitamine valitud esitamisel\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Kui video lõppeb, esitage järjekorras järgmine stseen.\",\n                        \"heading\": \"Esitusloendi jätkamine vaikimisi\"\n                    },\n                    \"show_scrubber\": \"Näita detailide otsijat\",\n                    \"track_activity\": \"Luba stseeni vaatamiste ajalugu\",\n                    \"disable_mobile_media_auto_rotate\": \"Keela täisekraani meedia automaatne pööramine mobiilis\",\n                    \"enable_chromecast\": \"Luba Chromecast\",\n                    \"show_ab_loop_controls\": \"Näita AB loopi juhtnuppe\",\n                    \"vr_tag\": {\n                        \"description\": \"VR-nupp kuvatakse ainult selle sildiga stseenide puhul.\",\n                        \"heading\": \"VR silt\"\n                    },\n                    \"show_range_markers\": \"Näita vahemiku märke\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Stseenide/Markerite Sein\",\n                \"options\": {\n                    \"display_title\": \"Kuva pealkiri ja sildid\",\n                    \"toggle_sound\": \"Luba heli\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Kerimise katsete arv enne järgmise/eelmise üksuse juurde liikumist. Kehtib ainult Pan Y kerimisrežiimi puhul.\",\n                \"heading\": \"Kerimiskatsed enne üleminekut\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Kuva märgendi kaarti, kui hõljute sildi märkidel.\",\n                \"heading\": \"Sildikaardi tööriistanäpunäited\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Slaidiseanss on galeriides seinavaaterežiimi korral saadaval.\",\n                \"heading\": \"Slaidiseansi viivitus (sekundites)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Studio Vaade\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"Stuudiovaates kuvage ka alamstuudiote sisu.\",\n                        \"heading\": \"Kuvage alamstuudiote sisu\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Siltide Vaade\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"Sildivaates kuvage ka alam-märgendite sisu.\",\n                        \"heading\": \"Kuva alam-sildi sisu\"\n                    }\n                }\n            },\n            \"title\": \"Kasutajaliides\",\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"Kui see on lubatud, edastatakse funscriptid otse Stashist teie Handy seadmesse ilma kolmanda osapoole Handy serverit kasutamata. See nõuab, et Stash oleks teie Handy seadmest juurdepääsetav ja API võti on genereeritud, kui Stashil on ligipääs piiratud.\",\n                \"heading\": \"Serveeri funscripte otse\"\n            },\n            \"detail\": {\n                \"show_all_details\": {\n                    \"description\": \"Kui see on lubatud, kuvatakse vaikimisi kõik sisu üksikasjad ja iga üksikasjade üksus mahub ühte veergu.\",\n                    \"heading\": \"Näita kõiki detaile\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Taustapildi kuvamine üksikasjade lehel.\",\n                    \"heading\": \"Luba taustapildid\"\n                },\n                \"compact_expanded_details\": {\n                    \"description\": \"Kui lubatud, kuvab see suvand laiendatud üksikasjad, säilitades samas kompaktse esitluse.\",\n                    \"heading\": \"Kompaktselt laiendatud detailid\"\n                },\n                \"heading\": \"Detailide Leht\"\n            },\n            \"performer_list\": {\n                \"heading\": \"Näitlejate Nimekiri\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"Kuva linke näitlejate ruudustiku kaartidel\"\n                    }\n                }\n            },\n            \"sfw_mode\": {\n                \"description\": \"Luba, kui kasutad stashi SFW sisu jaoks. Peidab või muudab kasutajaliidese mõningaid täiskasvanutele mõeldud sisuga seotud aspekte.\",\n                \"heading\": \"SFW režiim\"\n            },\n            \"custom_title\": {\n                \"description\": \"Kohandatav tekst mis lisatakse lehe tiitlile. Kui tühi, siis on vaikimisi \\\"Stash\\\".\",\n                \"heading\": \"Kohandatud tiitel\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"Veaotsingu režiim\",\n                \"dialog_title\": \"Luba veaotsingu režiim\",\n                \"dialog_description\": \"See keelab ajutiselt kõik kohandused, et aidata probleeme diagnoosida:\",\n                \"dialog_item_plugins\": \"Kõik pluginad\",\n                \"dialog_item_css\": \"Kohandatud CSS\",\n                \"dialog_item_js\": \"Kohandatud JavaScript\",\n                \"dialog_item_locales\": \"Kohandatud keeled\",\n                \"dialog_log_level\": \"Üksikasjaliku diagnostika jaoks määratakse logimise tasemeks Debug.\",\n                \"dialog_reload_note\": \"Leht värskendab ennast automaatselt.\",\n                \"enable\": \"Luba & Värskenda\",\n                \"overlay_message\": \"Veaotsingu režiim on aktiivne – kõik kohandused on keelatud\",\n                \"exit\": \"Välju\"\n            }\n        },\n        \"advanced_mode\": \"Täpsem režiim\",\n        \"changelog\": {\n            \"header\": \"Muudatuste logi\"\n        }\n    },\n    \"configuration\": \"Konfiguratsioon\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Fail} other {Faili}}\",\n        \"galleries\": \"{count, plural, one {Galerii} other {Galleriid}}\",\n        \"images\": \"{count, plural, one {Pilt} other {Pilti}}\",\n        \"markers\": \"{count, plural, one {Marker} other {Markerit}}\",\n        \"performers\": \"{count, plural, one {Näitleja} other {Näitlejat}}\",\n        \"scenes\": \"{count, plural, one {Stseen} other {Stseeni}}\",\n        \"studios\": \"{count, plural, one {Stuudio} other {Stuudiot}}\",\n        \"tags\": \"{count, plural, one {Silt} other {Silti}}\",\n        \"groups\": \"{count, plural, one {Grupp} other {Grupid}}\"\n    },\n    \"country\": \"Riik\",\n    \"cover_image\": \"Kaanepilt\",\n    \"created_at\": \"Loodud\",\n    \"criterion\": {\n        \"greater_than\": \"Suurem kui\",\n        \"less_than\": \"Väiksem kui\",\n        \"value\": \"Väärtus\",\n        \"unsupported\": \"{type} (ilma toeta)\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"vahel\",\n        \"equals\": \"on\",\n        \"excludes\": \"välistab\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"on suurem kui\",\n        \"includes\": \"sisaldab\",\n        \"includes_all\": \"sisaldab kõiki\",\n        \"is_null\": \"on null\",\n        \"less_than\": \"on vähem kui\",\n        \"matches_regex\": \"katub regexiga\",\n        \"not_between\": \"ei ole vahemikus\",\n        \"not_equals\": \"ei ole\",\n        \"not_matches_regex\": \"ei kattu regexiga\",\n        \"not_null\": \"ei ole null\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (väljaarvatud {excludedString}) (+{depth, plural, =-1 {kõik} other {{sügavus}}})\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {kõik} other {{sügavus}}})\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (väljaarvatud {excludedString})\"\n    },\n    \"custom\": \"Kohandatud\",\n    \"date\": \"Kuupäev\",\n    \"date_format\": \"AAAA-KK-PP\",\n    \"datetime_format\": \"AAAA-KK-PP TT:MM\",\n    \"death_date\": \"Surmakuupäev\",\n    \"death_year\": \"Surma-aasta\",\n    \"descending\": \"Langev\",\n    \"description\": \"Kirjeldus\",\n    \"detail\": \"Detail\",\n    \"details\": \"Detailid\",\n    \"developmentVersion\": \"Arendusversioon\",\n    \"dialogs\": {\n        \"create_new_entity\": \"Loo uus {entity}\",\n        \"delete_alert\": \"Järgnev {count, plural, one {{singularEntity}} other {{pluralEntity}}} kustutatakse lõplikult:\",\n        \"delete_confirm\": \"Kas oled kindel, et soovid kustutada {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Kas oled kindel, et soovid kustutada {singularEntity}? Kui sa faili ei kustutata, siis {singularEntity} lisatakse skaneerimise käigus uuesti.} other {Kas oled kindel, et soovid kustutada {pluralEntity}? Kui sa faile ei kustutata, siis {pluralEntity} lisatakse skaneerimise käigus uuesti.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Kas oled kindel, et soovid kustutada {singularEntity}?} other {Kas oled kindel, et soovid kustutada {pluralEntity}?}}\",\n        \"delete_entity_title\": \"{count, plural, one {Kustuta {singularEntity}} other {Kustuta {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"...lisaks kõik pildifailid, mida pole lisatud ühelegi teisele galeriile.\",\n        \"delete_gallery_files\": \"Kustutage galerii kaust/zip-fail ja kõik pildid, mis pole ühelegi teise galeriisse lisatud.\",\n        \"delete_object_desc\": \"Kas oled kindel, et soovid kustutada {count, plural, one {seda {singularEntity}} other {neid {pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…ja {count} teist {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Kustuta {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Ära näita kuni järgmise värskenduseni\",\n        \"edit_entity_title\": \"Redigeeri {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Kaasa seotud objektid eksporti\",\n        \"export_title\": \"Ekspordi\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Kolumn\",\n                \"description\": \"Kolumni või ridade põhine välimus.\",\n                \"row\": \"Rida\"\n            },\n            \"margin_desc\": \"Marginaali pikslite arv ümber iga täispildi.\"\n        },\n        \"lightbox\": {\n            \"delay\": \"Viivitus (s)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Mahuta horisontaalselt\",\n                \"fit_to_screen\": \"Mahuta ekraanile\",\n                \"label\": \"Kuvamisrežiim\",\n                \"original\": \"Originaal\"\n            },\n            \"options\": \"Sätted\",\n            \"page_header\": \"Leht {page} / {total}\",\n            \"reset_zoom_on_nav\": \"Pildi muutumisel lähtesta suumi tase\",\n            \"scale_up\": {\n                \"description\": \"Suurendage väiksemaid pilte ekraani täitmiseks.\",\n                \"label\": \"Suurenda ekraanile mahtumiseks\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Teise režiimi ajutiselt kasutamiseks hoidke all shift klahvi.\",\n                \"label\": \"Kerimisrežiim\",\n                \"pan_y\": \"Liiguta Y\",\n                \"zoom\": \"Suum\"\n            },\n            \"disable_animation\": \"Lülita piltide vahetamise animatsioon välja\"\n        },\n        \"merge\": {\n            \"destination\": \"Siihtkoht\",\n            \"empty_results\": \"Sihtkoha välja väärtusi ei muudeta.\",\n            \"source\": \"Allikas\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {Määra Ümber {singularEntity}} other {Määra Ümber {pluralEntity}-d/id}}\",\n        \"reassign_files\": {\n            \"destination\": \"Määra Ümber\"\n        },\n        \"scene_gen\": {\n            \"covers\": \"Stseeni kaaned\",\n            \"force_transcodes\": \"Sunni Ümbertöötlemise genereerimine\",\n            \"force_transcodes_tooltip\": \"Vaikimisi genereeritakse ümbertöötlemisi ainult siis, kui brauser videofaili ei toeta. Kui see on lubatud, genereeritakse ümbertöötlemisi isegi siis, kui videofail näib olevat brauseris toetatud.\",\n            \"image_previews\": \"Animeeritud piltide eelvaated\",\n            \"image_previews_tooltip\": \"Looge ka animeeritud (webp) eelvaateid, mis on nõutavad ainult siis, kui Stseeni/Markeri Seina Eelvaate tüüp on seatud väärtusele Animeeritud pilt. Sirvimisel kasutavad nad vähem CPUd kui video eelvaated, kuid genereeritakse neile lisaks ja need on suuremad failid.\",\n            \"interactive_heatmap_speed\": \"Looge interaktiivsete stseenide jaoks soojuskaarte ja kiirusi\",\n            \"marker_image_previews\": \"Markeri animeeritud piltide eelvaated\",\n            \"marker_image_previews_tooltip\": \"Looge ka animeeritud (webp) eelvaateid, mis on nõutavad ainult siis, kui Stseeni/Markeri Seina Eelvaate tüüp on seatud väärtusele Animeeritud pilt. Sirvimisel kasutavad nad vähem CPUd kui video eelvaated, kuid genereeritakse neile lisaks ja need on suuremad failid.\",\n            \"marker_screenshots\": \"Markeri ekraanipildid\",\n            \"marker_screenshots_tooltip\": \"Markeeri staatilised JPG-kujutised\",\n            \"markers\": \"Markeri eelvaated\",\n            \"markers_tooltip\": \"20-sekundilised videod, mis algavad etteantud ajakoodiga.\",\n            \"override_preview_generation_options\": \"Eelvaate genereerimise valikute ülekirjutamine\",\n            \"override_preview_generation_options_desc\": \"Eelvaate genereerimise sätete üle kirjutamine selle operatsiooni jaoks. Vaikeseaded määratakse jaotises Süsteem -> Eelvaate Genereerimine.\",\n            \"overwrite\": \"Kirjuta üle olemasolevad failid\",\n            \"phash\": \"Videote tajutav räsi\",\n            \"preview_exclude_end_time_desc\": \"Välista stseeni eelvaadetest viimased x sekundid. See võib olla väärtus sekundites või protsent (nt 2%) stseeni kogukestusest.\",\n            \"preview_exclude_end_time_head\": \"Välista lõpuaeg\",\n            \"preview_exclude_start_time_desc\": \"Välista esimesed x sekundid stseeni eelvaadetest. See võib olla väärtus sekundites või protsent (nt 2%) stseeni kogukestusest.\",\n            \"preview_exclude_start_time_head\": \"Välista algusaeg\",\n            \"preview_generation_options\": \"Eelvaate genereerimise sätted\",\n            \"preview_options\": \"Eelvaate Sätted\",\n            \"preview_preset_desc\": \"Eelseadistus reguleerib eelvaate genereerimise suurust, kvaliteeti ja kodeerimisaega. Eelseadistused peale „aeglase” on väheneva tootlikkusega ja neid ei soovitata.\",\n            \"preview_preset_head\": \"Eelvaate kodeeringu eelseadistus\",\n            \"preview_seg_count_desc\": \"Eelvaatefailide segmentide arv.\",\n            \"preview_seg_count_head\": \"Eelvaates olevate segmentide arv\",\n            \"preview_seg_duration_desc\": \"Iga eelvaate lõigu kestus sekundites.\",\n            \"preview_seg_duration_head\": \"Eelvaate segmendi kestus\",\n            \"sprites\": \"Stseeniotsingu spraidid\",\n            \"sprites_tooltip\": \"Videopleieri all kuvatav piltide komplekt hõlpsaks navigeerimiseks.\",\n            \"transcodes\": \"Ümbertöötlemine\",\n            \"transcodes_tooltip\": \"MP4 ümberkoodeeringud luuakse kogu sisu jaoks; kasulik aeglaste CPUde jaoks, kuid nõuab palju rohkem kettaruumi\",\n            \"video_previews\": \"Eelvaated\",\n            \"video_previews_tooltip\": \"Video eelvaated, mis esitatakse kursorit stseeni kohal hoides\",\n            \"clip_previews\": \"Pildiklipi eelvaated\",\n            \"phash_tooltip\": \"De-dubleerimiseks ja stseeni tuvastamiseks\",\n            \"image_thumbnails\": \"Piltide pisipildid\",\n            \"image_phash\": \"Kujutiste tajutav räsi\",\n            \"image_phash_tooltip\": \"Deduplikatsiooni ja identifitseerimise jaoks\"\n        },\n        \"scenes_found\": \"Leiti {count} stseeni\",\n        \"scrape_entity_query\": \"{entity_type} Kraapija Päring\",\n        \"scrape_entity_title\": \"{entity_type} Kraapija Tulemused\",\n        \"scrape_results_existing\": \"Eksisteeriv\",\n        \"scrape_results_scraped\": \"Kraabitud\",\n        \"set_image_url_title\": \"Pildi URL\",\n        \"unsaved_changes\": \"Salvestamata muudatused. Kas soovid kindlasti lahkuda?\",\n        \"performers_found\": \"Leiti {count} esinejat\",\n        \"clear_o_history_confirm\": \"Kas oled kindel, et soovid puhastada O ajaloo?\",\n        \"clear_play_history_confirm\": \"Kas oled kindel, et soovid puhastada vaatamise ajaloo?\",\n        \"set_default_filter_confirm\": \"Kas oled kindel, et soovid määrata seda filtrit vaikimisi valikuks?\",\n        \"overwrite_filter_warning\": \"Salvestatud filter \\\"{entityName}\\\" kirjutatakse üle.\",\n        \"clear_o_history_confirm_sfw\": \"Kas oled kindel, et tahad meeldimiste ajalugu tühjendada?\",\n        \"delete_alert_to_trash\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} liigutatakse prügilasse:\",\n        \"stashid_exists_warning\": \"Olemasolev stash id asendatakse selle stash-kasti jaoks.\",\n        \"studios_found\": \"{count} stuudiot leitud\",\n        \"tags_found\": \"{count} silti leitud\",\n        \"scrape_results_missing\": \"Puudub\"\n    },\n    \"dimensions\": \"Dimensioonid\",\n    \"director\": \"Režissöör\",\n    \"disambiguation\": \"Ühesõnastamine\",\n    \"display_mode\": {\n        \"grid\": \"Võrgustik\",\n        \"list\": \"Nimekiri\",\n        \"tagger\": \"Sildistaja\",\n        \"unknown\": \"Teadmata\",\n        \"wall\": \"Sein\",\n        \"label_current\": \"Kuvarežiim: {current}\"\n    },\n    \"donate\": \"Anneta\",\n    \"dupe_check\": {\n        \"description\": \"Täpsetest madalamate tasemete arvutamine võib võtta kauem aega. Valepositiivsed tulemused võidakse tagastada ka madalamal täpsustasemel.\",\n        \"duration_diff\": \"Maksimaalse Pikkuse Vahe\",\n        \"duration_options\": {\n            \"any\": \"Kõik\",\n            \"equal\": \"Võrdne\"\n        },\n        \"found_sets\": \"{setCount, plural, one{# duplikaat leitud.} other {# duplikaati leitud.}}\",\n        \"options\": {\n            \"exact\": \"Täpselt\",\n            \"high\": \"Kõrge\",\n            \"low\": \"Madal\",\n            \"medium\": \"Keskmine\"\n        },\n        \"search_accuracy_label\": \"Otsingu Täpsus\",\n        \"title\": \"Duplikaatstseenid\",\n        \"only_select_matching_codecs\": \"Valige ainult siis, kui kõik koodekid on duplikaatrühmas samad\",\n        \"select_all_but_largest_file\": \"Valige igas dubleeritud rühmas kõik failid, välja arvatud suurim fail\",\n        \"select_oldest\": \"Valige duplikaatrühmast vanim fail\",\n        \"select_all_but_largest_resolution\": \"Valige igas dubleeritud rühmas kõik failid, välja arvatud kõrgeima eraldusvõimega fail\",\n        \"select_options\": \"Valige Valikud…\",\n        \"select_youngest\": \"Valige duplikaatrühma noorim fail\",\n        \"select_none\": \"Valige Puudub\"\n    },\n    \"duplicated_phash\": \"Duplikeeritud (pHash)\",\n    \"duration\": \"Pikkus\",\n    \"effect_filters\": {\n        \"aspect\": \"Aspekt\",\n        \"blue\": \"Sinine\",\n        \"blur\": \"Hägusta\",\n        \"brightness\": \"Eredus\",\n        \"contrast\": \"Kontrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Roheline\",\n        \"hue\": \"Värvitoon\",\n        \"name\": \"Filtrid\",\n        \"name_transforms\": \"Muutused\",\n        \"red\": \"Punane\",\n        \"reset_filters\": \"Lähtesta Filtrid\",\n        \"reset_transforms\": \"Lähesta Muutused\",\n        \"rotate\": \"Pööra\",\n        \"rotate_left_and_scale\": \"Pööra Vasakule & Skaleeri\",\n        \"rotate_right_and_scale\": \"Pööra Paremale & Skaleeri\",\n        \"saturation\": \"Saturatsioon\",\n        \"scale\": \"Suurus\",\n        \"warmth\": \"Soojus\"\n    },\n    \"empty_server\": \"Sellel lehel soovituste nägemiseks lisage oma serverisse mõned stseenid.\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"Pildi indeks peab olema suurem kui 0\",\n        \"lazy_component_error_help\": \"Kui uuendasid Stashi hiljuti, palun taaslae leht või tühjenda oma brauseri cache.\",\n        \"something_went_wrong\": \"Midagi läks valesti.\",\n        \"loading_type\": \"Viga {type} laadimisel\",\n        \"header\": \"Viga\",\n        \"invalid_javascript_string\": \"Vigane JavaScripti kood: {error}\",\n        \"invalid_json_string\": \"Vigane JSON-string: {error}\",\n        \"custom_fields\": {\n            \"duplicate_field\": \"Välja nimi peab olema unikaalne\",\n            \"field_name_length\": \"Välja nimi peab olema lühem kui 65 tähemärki\",\n            \"field_name_required\": \"Välja nimi on nõutud\",\n            \"field_name_whitespace\": \"Välja nimi ei tohi alata ega lõppeda tühikuga\"\n        }\n    },\n    \"ethnicity\": \"Rahvus\",\n    \"existing_value\": \"eksisteeriv väärtus\",\n    \"eye_color\": \"Silmavärv\",\n    \"fake_tits\": \"Võltsrinnad\",\n    \"false\": \"Väär\",\n    \"favourite\": \"Lemmik\",\n    \"file\": \"fail\",\n    \"file_count\": \"Failide Arv\",\n    \"file_info\": \"Faili Info\",\n    \"file_mod_time\": \"Faili Muutmise Aeg\",\n    \"files\": \"failid\",\n    \"files_amount\": \"{value} faili\",\n    \"filesize\": \"Faili Suurus\",\n    \"filter\": \"Filter\",\n    \"filter_name\": \"Filtri nimi\",\n    \"filters\": \"Filtrid\",\n    \"folder\": \"Kaust\",\n    \"framerate\": \"Kaadrisagedus\",\n    \"frames_per_second\": \"{value} kaadrit sekundis\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Eelsätestatud Filter\",\n            \"saved_filter\": \"Salvestatud Filter\"\n        }\n    },\n    \"galleries\": \"Galeriid\",\n    \"gallery\": \"Galerii\",\n    \"gallery_count\": \"Galeriide Arv\",\n    \"gender\": \"Sugu\",\n    \"gender_types\": {\n        \"FEMALE\": \"Naine\",\n        \"INTERSEX\": \"Intersooline\",\n        \"MALE\": \"Mees\",\n        \"NON_BINARY\": \"Mittebinaarne\",\n        \"TRANSGENDER_FEMALE\": \"Transnaine\",\n        \"TRANSGENDER_MALE\": \"Transmees\"\n    },\n    \"hair_color\": \"Juuksevärv\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Ühendan\",\n        \"disconnected\": \"Lahti ühendatud\",\n        \"error\": \"Handyga ühendamisel tekkis viga\",\n        \"missing\": \"Kadunud\",\n        \"ready\": \"Valmis\",\n        \"syncing\": \"Serveriga sünkroniseerimine\",\n        \"uploading\": \"Skripti üleslaadimine\"\n    },\n    \"hasChapters\": \"Peatükid\",\n    \"hasMarkers\": \"Markerid\",\n    \"height\": \"Pikkus\",\n    \"height_cm\": \"Pikkus (cm)\",\n    \"help\": \"Abi\",\n    \"ignore_auto_tag\": \"Ignoneeri automaatset märkimist\",\n    \"image\": \"Pilt\",\n    \"image_count\": \"Pildiarv\",\n    \"image_index\": \"Pilt #\",\n    \"images\": \"Pildid\",\n    \"include_parent_tags\": \"Kaasa vanem-silte\",\n    \"include_sub_studios\": \"Kaasa tütarstuudioid\",\n    \"include_sub_tags\": \"Kaasa alamsilte\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktiivne\",\n    \"interactive_speed\": \"Interaktiivne kiirus\",\n    \"isMissing\": \"On Kadunud\",\n    \"last_played_at\": \"Viimati Esitatud\",\n    \"library\": \"Kogu\",\n    \"loading\": {\n        \"generic\": \"Laen…\",\n        \"plugins\": \"Pluginate laadimine…\"\n    },\n    \"marker_count\": \"Markerite Arv\",\n    \"markers\": \"Markerid\",\n    \"measurements\": \"Mõõdud\",\n    \"media_info\": {\n        \"audio_codec\": \"Heli Koodek\",\n        \"downloaded_from\": \"Allalaetud Asukohast\",\n        \"interactive_speed\": \"Interaktiivne kiirus\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} filmimisel\"\n        },\n        \"phash\": \"PHash\",\n        \"play_count\": \"Esituste Arv\",\n        \"play_duration\": \"Esitamisaeg\",\n        \"stream\": \"Striim\",\n        \"video_codec\": \"Video Koodek\",\n        \"o_count\": \"O Arv\",\n        \"oshash\": \"oshash\",\n        \"oshash_meaning\": \"OpenSubtitles'i Räsi\",\n        \"phash_meaning\": \"Tajutav Räsi\",\n        \"md5\": \"MD5 Kontrollsumma\"\n    },\n    \"megabits_per_second\": \"{value} mbps\",\n    \"metadata\": \"Metaandmed\",\n    \"name\": \"Nimi\",\n    \"new\": \"Uus\",\n    \"none\": \"Mitte ükski\",\n    \"operations\": \"Operatsioonid\",\n    \"organized\": \"Organiseeritud\",\n    \"pagination\": {\n        \"first\": \"Esimene\",\n        \"last\": \"Viimane\",\n        \"next\": \"Järgmine\",\n        \"previous\": \"Eelmine\",\n        \"current_total\": \"{current} {total}st\"\n    },\n    \"parent_of\": \"{children} vanem-silt\",\n    \"parent_studios\": \"Vanem-stuudiod\",\n    \"parent_tag_count\": \"Vanem-siltide Arv\",\n    \"parent_tags\": \"Vanem-sildid\",\n    \"part_of\": \"Osa {parent}-st\",\n    \"path\": \"Failitee\",\n    \"perceptual_similarity\": \"Tajutav Sarnasus (pHash)\",\n    \"performer\": \"Näitleja\",\n    \"performer_age\": \"Näitleja Vanus\",\n    \"performer_count\": \"Näitlejate Arv\",\n    \"performer_favorite\": \"Lemmiknäitleja\",\n    \"performer_image\": \"Näitleja Pilt\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Lisa Uusi Näitlejaid\",\n        \"any_names_entered_will_be_queried\": \"Kõigi sisestatud nimede kohta päritakse Stash-kastii kaugeksemplari ja lisatakse, kui need leitakse. Vastena loetakse ainult täpseid vasteid.\",\n        \"batch_add_performers\": \"Lisa Näitlejaid Hunnikus\",\n        \"batch_update_performers\": \"Uuenda Näitlejaid Hunnikus\",\n        \"current_page\": \"Käesolev lehekülg\",\n        \"failed_to_save_performer\": \"Näitleja \\\"{performer}\\\" salvestamine ebaõnnestus\",\n        \"name_already_exists\": \"Nimi juba eksisteerib\",\n        \"network_error\": \"Võrguviga\",\n        \"no_results_found\": \"Tulemusi ei leitud.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} näitlejat töödeldakse\",\n        \"performer_already_tagged\": \"Näitleja juba märgitud\",\n        \"performer_selection\": \"Näitlejate valik\",\n        \"performer_successfully_tagged\": \"Näitleja edukalt märgitud:\",\n        \"query_all_performers_in_the_database\": \"Kõik andmebaasis olevad näitlejad\",\n        \"refresh_tagged_performers\": \"Värskenda märgitud näitlejaid\",\n        \"refreshing_will_update_the_data\": \"Värskendamine uuendab kõigi märgitud näitlejate informatsiooni stash-kasti eksemplaris.\",\n        \"status_tagging_job_queued\": \"Staatus: Märkimise töö lisatud järjekorda\",\n        \"status_tagging_performers\": \"Staatus: Märgin näitlejaid\",\n        \"tag_status\": \"Märgi Staatus\",\n        \"to_use_the_performer_tagger\": \"Näitleja märgistamise kasutamiseks peab stash-kasti eksemplar olema konfigureeritud.\",\n        \"untagged_performers\": \"Märkimata näitlejad\",\n        \"update_performer\": \"Uuenda Näitlejat\",\n        \"update_performers\": \"Uuenda Näitlejaid\",\n        \"updating_untagged_performers_description\": \"Märgistamata esinejate värskendamisel püütakse leida vasteid esinejatele, kellel puudub stashid, ja värskendatakse metaandmeid.\",\n        \"performer_names_or_stashids_separated_by_comma\": \"Näitlejate nimed või StashID-d eraldatud komadega\"\n    },\n    \"performer_tags\": \"Näitleja Sildid\",\n    \"performers\": \"Näitlejad\",\n    \"piercings\": \"Augustused\",\n    \"play_count\": \"Esitamisarv\",\n    \"play_duration\": \"Esitamisaeg\",\n    \"primary_file\": \"Põhifail\",\n    \"queue\": \"Järjekord\",\n    \"random\": \"Suvaline\",\n    \"rating\": \"Hinnang\",\n    \"recently_added_objects\": \"Hiljuti Lisatud {objects}\",\n    \"recently_released_objects\": \"Hiljuti Avaldatud {objects}\",\n    \"release_notes\": \"Väljalaske Märkmed\",\n    \"resolution\": \"Resolutsioon\",\n    \"resume_time\": \"Jätkamisaeg\",\n    \"scene\": \"Stseen\",\n    \"sceneTagger\": \"Stseeni Sildistaja\",\n    \"scene_code\": \"Stuudio Kood\",\n    \"scene_count\": \"Stseenide Arv\",\n    \"scene_created_at\": \"Stseen Loodud\",\n    \"scene_date\": \"Stseeni Kuupäev\",\n    \"scene_id\": \"Stseeni ID\",\n    \"scene_tags\": \"Stseeni Sildid\",\n    \"scene_updated_at\": \"Stseen Uuendatud\",\n    \"scenes\": \"Stseenid\",\n    \"scenes_updated_at\": \"Stseen Uuendatud\",\n    \"search_filter\": {\n        \"edit_filter\": \"Muuda Filtrit\",\n        \"name\": \"Filter\",\n        \"saved_filters\": \"Salvestatud filtrid\",\n        \"update_filter\": \"Uuenda Filtrit\",\n        \"more_filter_criteria\": \"+{count} rohkem\",\n        \"search_term\": \"Otsi sõnet\"\n    },\n    \"second\": \"Sekund\",\n    \"seconds\": \"Sekundit\",\n    \"settings\": \"Sätted\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Oleme seadistamise lõpuleviimiseks peaaegu valmis. Vaata üle järgmised sätted. Valede väärtuste muutmiseks võite klõpsata tagasi. Kui kõik tundub õige, klõpsa süsteemi loomiseks nuppu Kinnita.\",\n            \"blobs_directory\": \"Binaarsete andmete kaust\",\n            \"cache_directory\": \"Cache kaust\",\n            \"configuration_file_location\": \"Konfiguratsioonifaili asukoht:\",\n            \"database_file_path\": \"Andmebaasi faili failitee\",\n            \"generated_directory\": \"Genereeritud kaust\",\n            \"nearly_there\": \"Peaaegu kohal!\",\n            \"stash_library_directories\": \"Stashi kogu kaustad\",\n            \"blobs_use_database\": \"<kasutades andmebaasi>\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Loon sulle süsteemi\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Oi ei! Midagi läks valesti!\",\n            \"something_went_wrong_description\": \"Kui see näib olevat sisenditega seotud probleem, jätka ja klõpsa nende parandamiseks nuppu Tagasi. Vastasel juhul loo viga meie {githubLink}-s või otsi abi kanalist {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Süsteemi seadistamisel läks midagi valesti. Saime järgmise vea: {error}\",\n            \"unable_to_retrieve_system_status\": \"Ei saanud süsteemi staatust: {error}\",\n            \"unexpected_error\": \"Esines ootamatu tõrge: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Failitee\",\n            \"up_dir\": \"Kaust üles\"\n        },\n        \"github_repository\": \"Githubi hoidla\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Andmebaasi varundamise tee (varundamise keelamiseks jäta tühjaks):\",\n            \"backup_recommended\": \"Enne migreerimist on soovitatav olemasolev andmebaas varundada. Saame seda sinu eest teha, tehes andmebaasist koopia kausta <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Andmebaasi migreerimine\",\n            \"migration_failed\": \"Migreerimine ebaõnnestus\",\n            \"migration_failed_error\": \"Andmebaasi migreerimisel ilmnes järgmine tõrge:\",\n            \"migration_failed_help\": \"Te vajalikud parandused ja proovige uuesti. Vastasel juhul ava viga meie {githubLink}-is või otsi abi kanalist {discordLink}.\",\n            \"migration_irreversible_warning\": \"Skeemi migratsiooniprotsess ei ole taastatav. Kui migratsioon on tehtud, ei ühildu andmebaas enam stashi varasemate versioonidega.\",\n            \"migration_notes\": \"Migratsioonimärkmed\",\n            \"migration_required\": \"Migratsioon on nõutud\",\n            \"perform_schema_migration\": \"Skeemi migreerimine\",\n            \"schema_too_old\": \"Praegune stashi andmebaas on skeemi <strong>{databaseSchema}</strong> versioonil ja see tuleb üle viia versioonile <strong>{appSchema}</strong>. See Stashi versioon ei tööta ilma andmebaasi migreerimata. Kui sa ei soovi migreerida, pead üle minema versioonile, mis vastab teie andmebaasi skeemile.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"andmebaasi failinimi (vaikimisi tühi)\",\n            \"description\": \"Järgmisena peame kindlaks määrama, kust leida su sisu ja kuhu salvestada stashi andmebaas, genereeritud failid ja cache. Neid sätteid saab hiljem vajadusel muuta.\",\n            \"path_to_cache_directory_empty_for_default\": \"tee cache kaustani (tühi vaikeseadeks)\",\n            \"path_to_generated_directory_empty_for_default\": \"genereeritud kataloogi tee (vaikimisi tühi)\",\n            \"set_up_your_paths\": \"Seadista oma failiteed\",\n            \"stash_alert\": \"Ühtegi kogu teed pole valitud. Stash ei saa skannida mitte ühtegi meediumifaili. Oled sa kindel?\",\n            \"where_can_stash_store_blobs\": \"Kus saab Stash hoida oma andmebaasi binaarseid andmeid?\",\n            \"where_can_stash_store_blobs_description\": \"Stash saab hoida binaarseid andmeid nagu stseeni kaanepilte, esinejate, stuudiote ja siltide pilte kas andmebaasis või failisüsteemis. Vaikimisi salvestab Stash neid andmeid failsüsteemi alamkausta <code>blobs</code>. Kui tahad seda muuta, palun sisesta absoluutne või relatiivne (hetke töökaustaga) tee. Stash loob selle kausta, kui seda juba ei eksisteeri.\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Alternatiivselt, kui tahad hoida neid anmeid andmebaasis, saad jätta selle välja tühjaks. <strong>NB:</strong> See suurendab su andmebaasi fail ja suurendab andmebaasi migratsiooni aega.\",\n            \"where_can_stash_store_cache_files\": \"Kus saab Stash hoida cache faile?\",\n            \"where_can_stash_store_cache_files_description\": \"Mõne funktsionaalsuse, nagu HLS/DASH reaalas transkodeerimine, töötamiseks vajab Stash cache kausta ajutiste failide jaoks. Vaikimisi loob Stash <code>cache</code> kausta mis asub konfiguratsioonifailiga samas kaustas. Kui tahad seda muuta, palun sisesta absoluutne või relatiivne (töökaustaga) tee. Stash loob selle kausta kui seda juba ei eksisteeri.\",\n            \"where_can_stash_store_its_database\": \"Kuhu saab Stash oma andmebaasi salvestada?\",\n            \"where_can_stash_store_its_database_description\": \"Stash kasutab su sisu metaandmete salvestamiseks SQLite'i andmebaasi. Vaikimisi luuakse see konfiguratsioonifaili sisaldavasse kataloogi kui <code>stash-go.sqlite</code>. Kui soovid seda muuta, sisesta absoluutne või suhteline failinimi (praeguse töökataloogi suhtes).\",\n            \"where_can_stash_store_its_database_warning\": \"HOIATUS: hoides andmebaasi erineval süsteemil kui millel Stash jookseb (nt hoides andmebaasi NASil kui Stash jookseb teisel arvutil) on <strong>mitte toetatud</strong>! SQLite ei ole mõeldud kasutamiseks üle võrgu ja selle proovimine võib väga kergesti viia andmebaasi korrupeerumiseni.\",\n            \"where_can_stash_store_its_generated_content\": \"Kus saab Stash oma genereeritud sisu salvestada?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Pisipiltide, eelvaadete ja spraitide pakkumiseks loob Stash pilte ja videoid. See hõlmab ka toetamata failivormingute ümbertöötlemist. Vaikimisi loob Stash konfiguratsioonifaili sisaldavas kaustas <code>genereeritud</code> kausta. Kui soovid muuta seda, kus see loodud meedium salvestatakse, sisesta absoluutne või suhteline failitee (praeguse töökataloogi suhtes). Stash loob selle kausta, kui seda veel pole.\",\n            \"where_is_your_porn_located\": \"Kus su sisu asub?\",\n            \"where_is_your_porn_located_description\": \"Lisage oma videoid ja pilte sisaldavad kataloogid. Stash kasutab neid katalooge skanimise ajal videote ja piltide otsimiseks.\",\n            \"path_to_blobs_directory_empty_for_default\": \"blobsi kataloogi tee (vaikimisi tühi)\",\n            \"store_blobs_in_database\": \"Salvesta blobid andmebaasi\",\n            \"sfw_content_settings\": \"Kasutad stashi SFW sisu jaoks?\",\n            \"sfw_content_settings_description\": \"stashi saab kasutada SFW-sisu, näiteks fotograafia, kunsti, koomiksite ja muu haldamiseks. Selle valiku lubamine muudab kasutajaliidese käitumist SFW-sisu jaoks sobivamaks.\",\n            \"use_sfw_content_mode\": \"Kasuta SFW sisu režiimi\"\n        },\n        \"stash_setup_wizard\": \"Stashi Ülessättimise Viisard\",\n        \"success\": {\n            \"getting_help\": \"Abi saamine\",\n            \"help_links\": \"Kui tekib probleeme või on küsimusi või soovitusi, ava viga lehel {githubLink} või küsi kogukonnalt abi kanalis {discordLink}.\",\n            \"in_app_manual_explained\": \"Soovitame tutvuda rakendusesisese juhendiga, millele pääseb juurde ekraani paremas ülanurgas olevast ikoonist, mis näeb välja järgmine: {icon}\",\n            \"next_config_step_one\": \"Järgmisena suuname su konfiguratsioonilehele. See leht võimaldab sul kohandada, milliseid faile lisada ja välja jätta, määrata oma süsteemi kaitsmiseks kasutajanime ja parooli ning palju muud.\",\n            \"next_config_step_two\": \"Kui oled seadetega rahul, võite alustada sisu Stashi skannimist, klõpsates valikul <code>{localized_task}</code> ja seejärel valikul <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Vaadake meie {open_collective_link}, et näha, kuidas saad aidata kaasa Stashi jätkuvale arendamisele.\",\n            \"support_us\": \"Toeta meid\",\n            \"thanks_for_trying_stash\": \"Aitäh Stashi proovimise eest!\",\n            \"welcome_contrib\": \"Ootame ka panust koodi (veaparandused, täiustused ja uued funktsioonid), testimise, veaaruannete, parendus- ja funktsioonitaotluste ning kasutajatoe kujul. Üksikasjad leiad rakendusesisese juhendi jaotisest Contribution.\",\n            \"your_system_has_been_created\": \"Edukas! Su süsteem on loodud!\",\n            \"download_ffmpeg\": \"Lae ffmpeg alla\",\n            \"missing_ffmpeg\": \"Teil puudub nõutav binaarfail <code>ffmpeg</code>. Saate selle automaatselt oma konfiguratsioonikataloogi alla laadida, märkides alloleva kasti. Teise võimalusena saate süsteemiseadetes anda teed binaarfailidele <code>ffmpeg</code> ja <code>ffprobe</code>. Stashi toimimiseks peavad need kahendfailid olemas olema.\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash püüab esmalt leida oma konfiguratsioonifaili (<code>config.yml</code>) praegusest töökataloogist ja kui ta seda sealt ei leia, läheb tagasi kausta <code>{fallback_path}</code>. Samuti saad panna Stashi lugema konkreetsest konfiguratsioonifailist, käivitades selle suvanditega <code>-c '<konfiguratsioonifaili tee>'</code> või <code>--config '<konfiguratsioonifaili tee>'</code>.\",\n            \"in_current_stash_directory\": \"Kaustas <code>{path}</code>:\",\n            \"in_the_current_working_directory\": \"Töökataloogis <code>{path}</code> on praegu:\",\n            \"next_step\": \"Kui oled valmis uue süsteemi seadistamisega alustama, palun vali, kuhu soovid oma konfiguratsioonifaili salvestada.\",\n            \"store_stash_config\": \"Kuhu soovid oma Stashi konfiguratsiooni salvestada?\",\n            \"unable_to_locate_config\": \"Kui sa seda näed, siis ei leidnud Stash olemasolevat konfiguratsiooni. See viisard juhendab sind uue konfiguratsiooni seadistamise protsessis.\",\n            \"unexpected_explained\": \"Kui said selle ekraani ootamatult, proovi Stash uuesti käivitada õiges töökataloogis või lipuga <code>-c</code>.\",\n            \"in_the_current_working_directory_disabled\": \"Töökataloogis <code>{path}</code>:\",\n            \"in_the_current_working_directory_disabled_macos\": \"Ei toetata <code>Stash.app</code> käivitamisel,<br></br>käivita <code>stash-macos</code>, et seadistada töökataloogi\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash kasutab järgmist konfiguratsioonifaili teed: <code>{path}</code>\",\n            \"next_step\": \"Kui oled valmis uue süsteemi seadistamisega jätkama, klõpsa nuppu Edasi.\",\n            \"unable_to_locate_specified_config\": \"Kui seda näed, siis ei leidnud Stash käsureal ega keskkonnas määratud konfiguratsioonifaili. See viisard juhendab sind uue konfiguratsiooni seadistamise protsessis.\"\n        },\n        \"welcome_to_stash\": \"Teretulemast Stashi\"\n    },\n    \"stash_id\": \"Stashi ID\",\n    \"stash_id_endpoint\": \"Stash ID Lõpp-punkti URL\",\n    \"stash_ids\": \"Stashi ID-d\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Mustandi ülevaatamiseks mine saidile {endpoint_name}.\",\n        \"selected_stash_box\": \"Valitud Stash-Kasti lõpp-punkt\",\n        \"submission_failed\": \"Esitamine ebaõnnestus\",\n        \"submission_successful\": \"Esitamine õnnestus\",\n        \"submit_update\": \"On juba olemas kohas {endpoint_name}\",\n        \"source\": \"Stash-Boxi Allikas\"\n    },\n    \"statistics\": \"Statistika\",\n    \"stats\": {\n        \"image_size\": \"Piltide suurus\",\n        \"scenes_duration\": \"Stseenide pikkus\",\n        \"scenes_size\": \"Stseenide suurus\",\n        \"scenes_played\": \"Mängitud Stseenid\",\n        \"total_o_count\": \"Kokku O-Arv\",\n        \"total_play_count\": \"Mängimiste Koguarv\",\n        \"total_play_duration\": \"Mängimiste Kogukestus\",\n        \"total_o_count_sfw\": \"Meeldimisi kokku\"\n    },\n    \"status\": \"Staatus: {statusText}\",\n    \"studio\": \"Stuudio\",\n    \"studio_depth\": \"Tasemed (tühi kõige jaoks)\",\n    \"studios\": \"Stuudiod\",\n    \"sub_tag_count\": \"Alam-Siltide Arv\",\n    \"sub_tag_of\": \"{parent}-i alam-silt\",\n    \"sub_tags\": \"Alam-Sildid\",\n    \"subsidiary_studios\": \"Tütarstuudiod\",\n    \"synopsis\": \"Sisukokkuvõte\",\n    \"tag\": \"Silt\",\n    \"tag_count\": \"Siltide Arv\",\n    \"tags\": \"Sildid\",\n    \"tattoos\": \"Tatoveeringud\",\n    \"title\": \"Pealkiri\",\n    \"toast\": {\n        \"added_entity\": \"Lisatud {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Genereerimistöö lisatud järjekorda\",\n        \"created_entity\": \"Loodud {entity}\",\n        \"default_filter_set\": \"Vaikimisi filtrikomplekt\",\n        \"delete_past_tense\": \"Kustutatud {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Ekraanipildi genereerimine…\",\n        \"image_index_too_large\": \"Error: Pildi index on suurem kui Galeriis olevate piltide arv\",\n        \"merged_scenes\": \"Ühendatud stseenid\",\n        \"merged_tags\": \"Sildid ühendatud\",\n        \"reassign_past_tense\": \"Fail ümbermääratud\",\n        \"removed_entity\": \"Eemaldatud {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"rescanning_entity\": \"Skannin uuesti {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"Salvestatud {entity}\",\n        \"started_auto_tagging\": \"Alustasin automaatset märkimist\",\n        \"started_generating\": \"Alustasin genereerimist\",\n        \"started_importing\": \"Alustasin importimist\",\n        \"updated_entity\": \"Uuendatud {entity}\",\n        \"merged_performers\": \"Näitlejad kokku seotud\",\n        \"clipboard_access_denied\": \"Lõikelaua ligipääs on keelatud. Vaata brauseri õigused üle\",\n        \"clipboard_image_pasted\": \"Pilt kleebitud lõikelaualt\",\n        \"clipboard_no_image\": \"Lõukelaualt ei leitud pilti\"\n    },\n    \"total\": \"Kokku\",\n    \"true\": \"Tõene\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Tüüp\",\n    \"updated_at\": \"Viimati Uuendatud\",\n    \"url\": \"URL\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} peab olema AAAA, AAAA-KK või AAAA-KK-PP vormis\",\n        \"required\": \"${path} on nõutud väli\",\n        \"blank\": \"${path} ei tohi olla tühi\",\n        \"unique\": \"${path} peab olema kordumatu\",\n        \"end_time_before_start_time\": \"Lõpuaeg peab olema suurem või võrdne algusajaga\"\n    },\n    \"videos\": \"Videod\",\n    \"view_all\": \"Vaata Kõiki\",\n    \"weight\": \"Kaal\",\n    \"weight_kg\": \"Kaal (kg)\",\n    \"years_old\": \"aastat vana\",\n    \"zip_file_count\": \"Zip Failide Arv\",\n    \"audio_codec\": \"Heli Koodek\",\n    \"circumcised\": \"Ümberlõigatud\",\n    \"distance\": \"Kaugus\",\n    \"index_of_total\": \"{index}/{total}\",\n    \"orientation\": \"Orientatsioon\",\n    \"package_manager\": {\n        \"confirm_delete_source\": \"Kas olete kindel, et soovite allika {name} ({url}) kustutada?\",\n        \"confirm_uninstall\": \"Kas olete kindel, et soovite desinstallida {number} paketti?\",\n        \"description\": \"Kirjeldus\",\n        \"edit_source\": \"Redigeeri allikat\",\n        \"install\": \"Installi\",\n        \"latest_version\": \"Uusim versioon\",\n        \"no_packages\": \"Ühtegi paketti ei leitud\",\n        \"no_sources\": \"Ühtegi Allikat pole seadistatud\",\n        \"package\": \"Pakett\",\n        \"required_by\": \"Nõutav: {packages}\",\n        \"selected_only\": \"Ainult valitud\",\n        \"show_all\": \"Näita kõiki\",\n        \"source\": {\n            \"local_path\": {\n                \"description\": \"Selle allika pakettide salvestamise suhteline tee. Pange tähele, et selle muutmiseks tuleb paketid käsitsi teisaldada.\",\n                \"heading\": \"Kohalik tee\"\n            },\n            \"name\": \"Nimi\",\n            \"url\": \"Allika URL\"\n        },\n        \"unknown\": \"<teadmata>\",\n        \"update\": \"Uuenda\",\n        \"uninstall\": \"Uninstalli\",\n        \"version\": \"Versioon\",\n        \"add_source\": \"Lisa allikas\",\n        \"check_for_updates\": \"Kontrolli uuendusi\",\n        \"hide_unselected\": \"Peida valimata\",\n        \"installed_version\": \"Installitud versioon\",\n        \"no_upgradable\": \"Uuendatavaid pakette ei leitud\"\n    },\n    \"penis_length_cm\": \"Peenise Pikkus (cm)\",\n    \"parent_studio\": \"Vanemstuudio\",\n    \"photographer\": \"Fotograaf\",\n    \"plays\": \"{value} mängimist\",\n    \"primary_tag\": \"Esmane Märge\",\n    \"circumcised_types\": {\n        \"CUT\": \"Lõigatud\",\n        \"UNCUT\": \"Lõikamata\"\n    },\n    \"penis\": \"Peenis\",\n    \"penis_length\": \"Peenise Pikkus\",\n    \"studio_tagger\": {\n        \"any_names_entered_will_be_queried\": \"Kõigi sisestatud nimede kohta päritakse Stash-Box-ist ja lisatakse, kui need leitakse. Vastena loetakse ainult täpseid vasteid.\",\n        \"batch_add_studios\": \"Mitmete Stuudiote Lisamine\",\n        \"config\": {\n            \"create_parent_label\": \"Loo vanemstuudioid\",\n            \"create_parent_desc\": \"Looge puuduvad vanemstuudiod või märgistage ja värskendage olemasolevate vanemstuudiote andmeid/pilti täpsete nimede vastetega\"\n        },\n        \"current_page\": \"Praegune leht\",\n        \"failed_to_save_studio\": \"Stuudio \\\"{studio}\\\" salvestamine ebaõnnestus\",\n        \"name_already_exists\": \"Nimi juba eksisteerib\",\n        \"create_or_tag_parent_studios\": \"Looge puuduvad või märkige olemasolevad vanemstuudiod\",\n        \"network_error\": \"Võrgu Viga\",\n        \"no_results_found\": \"Tulemusi ei leitud.\",\n        \"number_of_studios_will_be_processed\": \"{studio_count} stuudiot töödeldakse\",\n        \"studio_already_tagged\": \"Stuudio on juba märgistatud\",\n        \"studio_selection\": \"Stuudio valik\",\n        \"studio_successfully_tagged\": \"Stuudio märgistamine õnnestus\",\n        \"query_all_studios_in_the_database\": \"Kõik stuudiod andmebaasis\",\n        \"refresh_tagged_studios\": \"Värskenda märgistatud stuudioid\",\n        \"status_tagging_job_queued\": \"Staatus: Märgistamistöö on järjekorras\",\n        \"to_use_the_studio_tagger\": \"Stuudio sildistaja kasutamiseks tuleb konfigureerida stash-boxi eksemplar.\",\n        \"untagged_studios\": \"Märgistamata stuudiod\",\n        \"update_studio\": \"Uuenda Stuudiot\",\n        \"update_studios\": \"Uuenda Stuudioid\",\n        \"add_new_studios\": \"Lisa Uusi Stuudioid\",\n        \"batch_update_studios\": \"Mitmete Stuudiote Uuendamine\",\n        \"refreshing_will_update_the_data\": \"Värskendamisel värskendatakse kõigi stash-boxi eksemplari märgistatud stuudiote andmeid.\",\n        \"status_tagging_studios\": \"Staatus: Stuudiote märgistamine\",\n        \"tag_status\": \"Märke Staatus\",\n        \"updating_untagged_studios_description\": \"Märgistamata stuudiote värskendamisel püütakse sobitada kõik stuudiod, millel puudub stashid, ja värskendatakse metaandmeid.\",\n        \"studio_names_or_stashids_separated_by_comma\": \"Stuudio nimes või StashID-d eraldatud komadega\"\n    },\n    \"urls\": \"URLid\",\n    \"video_codec\": \"Video Koodek\",\n    \"studio_and_parent\": \"Stuudio & Vanem\",\n    \"subsidiary_studio_count\": \"Alamstuudiote Arv\",\n    \"tag_parent_tooltip\": \"Sellel on vanemamärked\",\n    \"tag_sub_tag_tooltip\": \"Sellel on alammärked\",\n    \"time\": \"Aeg\",\n    \"o_history\": \"O Ajalugu\",\n    \"containing_group_count\": \"Sisalduvate Rühmade Arv\",\n    \"containing_groups\": \"Sisalduvad Rühmad\",\n    \"group\": \"Grupp\",\n    \"last_o_at\": \"Viimane O\",\n    \"odate_recorded_no\": \"O Kuupäevi Pole Salvestatud\",\n    \"play_history\": \"Vaatamise Ajalugu\",\n    \"playdate_recorded_no\": \"Vaatamise Kuupäevi Pole Salvestatud\",\n    \"studio_count\": \"Stuudiote Arv\",\n    \"studio_tags\": \"Stuudio Sildid\",\n    \"unknown_date\": \"Teadmata Kuupäev\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"Websocketiga ei saa ühendust luua: vaadake üksikasju brauseri konsoolist\",\n        \"websocket_connection_reestablished\": \"Websocketi ühendus taastatud\"\n    },\n    \"containing_group\": \"Sisaldab Rühma\",\n    \"group_count\": \"Gruppide Arv\",\n    \"group_scene_number\": \"Stseeni Number\",\n    \"groups\": \"Grupid\",\n    \"history\": \"Ajalugu\",\n    \"include_sub_group_content\": \"Sisalda alamgruppide sisu\",\n    \"sub_group\": \"Alamgrupp\",\n    \"sub_group_count\": \"Alamgruppide Arv\",\n    \"sub_group_of\": \"{parent}i Alamgrupp\",\n    \"sub_group_order\": \"Alamgruppide Järjekord\",\n    \"sub_groups\": \"Alamgrupid\",\n    \"include_sub_tag_content\": \"Sisalda alamsiltide sisu\",\n    \"include_sub_studio_content\": \"Sisalda alamstuudiote sisu\",\n    \"o_count\": \"O Arv\",\n    \"include_sub_groups\": \"Sisalda alam-gruppe\",\n    \"time_end\": \"Lõpuaeg\",\n    \"criterion_modifier_values\": {\n        \"any\": \"Kõik\",\n        \"any_of\": \"Järgnevast\",\n        \"none\": \"Mitte Ükski\",\n        \"only\": \"Ainult\"\n    },\n    \"custom_fields\": {\n        \"field\": \"Väli\",\n        \"title\": \"Kohandatud Väli\",\n        \"value\": \"Väärtus\",\n        \"criteria_format_string_others\": \"{criterion} (kohandatud väli) {modifierString} {valueString} (+{others} teist)\",\n        \"criteria_format_string\": \"{criterion} (kohandatud väli) {modifierString} {valueString}\"\n    },\n    \"eta\": \"ETA\",\n    \"login\": {\n        \"username\": \"Kasutajanimi\",\n        \"password\": \"Parool\",\n        \"internal_error\": \"Ootamatu sisemine viga. Vaata logisid rohkemate detailide jaoks\",\n        \"invalid_credentials\": \"Vale kasutajanimi või parool\",\n        \"login\": \"Logi Sisse\"\n    },\n    \"age_on_date\": \"{age} filmimisel\",\n    \"sort_name\": \"Sorteeritud Nimi\",\n    \"scenes_duration\": \"Stseeni Pikkus\",\n    \"last_o_at_sfw\": \"Viimane Meeldimine\",\n    \"o_count_sfw\": \"Meeldimisi\",\n    \"o_history_sfw\": \"Meeldimiste Ajalugu\",\n    \"odate_recorded_no_sfw\": \"Meeldimise Kuupäeva Pole Salvestatud\",\n    \"stashbox_search\": {\n        \"header\": \"Otsi {entityType} StashBoxist\",\n        \"no_results\": \"Vasteid ei leitud.\",\n        \"placeholder_name_or_id\": \"{entityType} nimi või StashID...\",\n        \"select_stashbox\": \"Vali StashBox...\"\n    },\n    \"career_end\": \"Karjääri Lõpp\",\n    \"career_start\": \"Karjääri Algus\",\n    \"duplicated\": \"Duplikaat\",\n    \"duplicated_stash_id\": \"Duplikaat (Stash ID)\",\n    \"duplicated_title\": \"Duplikaat (Tiitel)\",\n    \"latest_scene\": \"Viimatine Stseen\",\n    \"stash_id_count\": \"Stash ID Arv\",\n    \"tag_tagger\": {\n        \"add_new_tags\": \"Lisa Uusi Silte\",\n        \"any_names_entered_will_be_queried\": \"Kõik sisestatud nimed päritakse Stash-Boxi kaug-instantsist ja lisatakse, kui need leitakse. Ainult täpseid vasteid loetakse vasteks.\",\n        \"batch_add_tags\": \"Siltide Partii Lisamine\",\n        \"batch_update_tags\": \"Partii Siltide Uuendamine\",\n        \"current_page\": \"Hetke leht\",\n        \"failed_to_save_tag\": \"Sildi \\\"{tag}\\\" salvestamine ebaõnnestus\",\n        \"name_already_exists\": \"Nimi juba eksisteerib\",\n        \"network_error\": \"Võrgu Viga\",\n        \"no_results_found\": \"Tulemusi ei leitud.\",\n        \"number_of_tags_will_be_processed\": \"töödeldakse {tag_count} silti\",\n        \"query_all_tags_in_the_database\": \"Kõik andmebaasis olevad sildid\",\n        \"refresh_tagged_tags\": \"Värskenda märgistatud silte\",\n        \"refreshing_will_update_the_data\": \"Värskendamine uuendab kõigi stash-boxi eksemplari siltidega siltide andmeid.\",\n        \"status_tagging_job_queued\": \"Staatus: Sildistamistöö on järjekorras\",\n        \"status_tagging_tags\": \"Staatus: Siltide märgistamine\",\n        \"tag_already_tagged\": \"Silt juba märgistatud\",\n        \"tag_names_or_stashids_separated_by_comma\": \"Siltide nimed või StashID-d eraldatud komadega\",\n        \"tag_selection\": \"Siltide valik\",\n        \"tag_successfully_tagged\": \"Silt edukalt märgistatud\",\n        \"tag_status\": \"Sildi Staatus\",\n        \"to_use_the_tag_tagger\": \"Sildistaja kasutamiseks tuleb seadistada stash-boxi eksemplar.\",\n        \"untagged_tags\": \"Märgistamata sildid\",\n        \"update_tags\": \"Uuenda Silte\",\n        \"updating_untagged_tags_description\": \"Märgendamata siltide uuendamine püüab leida vasteid siltidele, millel puudub stashid, ja uuendab metaandmeid.\",\n        \"config\": {\n            \"create_parent_label\": \"Loo vanemsildid\"\n        }\n    },\n    \"tagger\": {\n        \"config\": {\n            \"active_stash-box_instance\": \"Aktiivne stash-boxi instants:\",\n            \"edit_excluded_fields\": \"Redigeeri Välistatud Välju\",\n            \"excluded_fields\": \"Välistatud väljad:\",\n            \"fields_will_not_be_changed\": \"Neid välju {entity} värskendamisel ei muudeta.\",\n            \"no_fields_are_excluded\": \"Mitte ühtegi välja pole välistatud\",\n            \"no_instances_found\": \"Instanse ei leitud\"\n        }\n    },\n    \"include_sub_folders\": \"Sisalda alam-kaustu\",\n    \"parent_folder\": \"Pea-Kaust\",\n    \"sub_folder_depth\": \"Alamkausta sügavus (tühi kõigi jaoks)\",\n    \"sub_folders\": \"Alamkaustad\",\n    \"unsupported_criteria\": \"Ilma toeata kriteeriumid: {criteria}\",\n    \"empty_value\": \"tühi\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/fa-IR.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"افزودن\",\n        \"add_directory\": \"افزودن پوشه\",\n        \"add_entity\": \"افزودن {entityType}\",\n        \"add_to_entity\": \"اضافه‌کردن به {entityType}\",\n        \"allow\": \"اجازه دادن\",\n        \"allow_temporarily\": \"موقتا اجازه دهید\",\n        \"add_sub_groups\": \"افزودن زیرگروه\",\n        \"browse_for_image\": \"پیدا کردن عکس…\",\n        \"cancel\": \"لغو\",\n        \"choose_date\": \"انتخاب تاریخ\",\n        \"clean\": \"پاکسازی\",\n        \"create_entity\": \"ایجاد {entityType}\",\n        \"add_manual_date\": \"افزودن دستی تاریخ\",\n        \"add_play\": \"افزودن نمایش\",\n        \"apply\": \"اعمال\",\n        \"backup\": \"پشتیبان گیری\",\n        \"auto_tag\": \"تگ زدن خودکار\",\n        \"clear_front_image\": \"پاک کردن عکس جلویی\",\n        \"clean_generated\": \"پاکسازی فایل های تولید شده\",\n        \"clear\": \"پاک کردن\",\n        \"clear_back_image\": \"پاک کردن عکس پشتی\",\n        \"clear_date_data\": \"پاک کردن دیتای تاریخ\",\n        \"clear_image\": \"پاک کردن عکس\",\n        \"close\": \"بستن\",\n        \"confirm\": \"تایید\",\n        \"continue\": \"ادامه\",\n        \"copy_to_clipboard\": \"کپی به کلیپبورد\",\n        \"create\": \"ایجاد\",\n        \"create_parent_studio\": \"ایجاد استادیو والد\",\n        \"anonymise\": \"بی نام کردن\",\n        \"create_chapters\": \"ایجاد فصل\"\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/fi-FI.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Lisää\",\n        \"add_directory\": \"Lisää hakemisto\",\n        \"add_entity\": \"Lisää {entityType}\",\n        \"add_to_entity\": \"Lisää kohteeseen {entityType}\",\n        \"allow\": \"Salli\",\n        \"allow_temporarily\": \"Salli väliaikaisesti\",\n        \"anonymise\": \"Anonymisoi\",\n        \"apply\": \"Käytä\",\n        \"auto_tag\": \"Lisää tunnisteet automaattisesti\",\n        \"backup\": \"Varmuuskopioi\",\n        \"browse_for_image\": \"Lisää kuva…\",\n        \"cancel\": \"Peruuta\",\n        \"clean\": \"Puhdista\",\n        \"clear\": \"Tyhjennä\",\n        \"clear_back_image\": \"Tyhjennä takakannen kuva\",\n        \"clear_front_image\": \"Tyhjennä etukannen kuva\",\n        \"clear_image\": \"Tyhjennä kuva\",\n        \"close\": \"Sulje\",\n        \"confirm\": \"Vahvista\",\n        \"continue\": \"Jatka\",\n        \"create\": \"Luo\",\n        \"create_chapters\": \"Luo kappale\",\n        \"create_entity\": \"Luo {entityType}\",\n        \"create_marker\": \"Luo Merkkaus\",\n        \"created_entity\": \"Luotu: {entityType}: {entity_name}\",\n        \"customise\": \"Kustomoi\",\n        \"delete\": \"Poista\",\n        \"delete_entity\": \"Poista {entityType}\",\n        \"delete_file\": \"Poista tiedosto\",\n        \"delete_file_and_funscript\": \"Poista tiedosto (ja funscript)\",\n        \"delete_generated_supporting_files\": \"Poista generoidut lisätiedostot\",\n        \"disallow\": \"Kiellä\",\n        \"download\": \"Lataa\",\n        \"download_anonymised\": \"Lataa anonymisoitu\",\n        \"download_backup\": \"Lataa varmuuskopio\",\n        \"edit\": \"Muokkaa\",\n        \"edit_entity\": \"Muokkaa {entityType}\",\n        \"export\": \"Vie\",\n        \"export_all\": \"Vie kaikki…\",\n        \"find\": \"Etsi\",\n        \"finish\": \"Valmis\",\n        \"from_file\": \"Tiedostosta…\",\n        \"from_url\": \"URL -osoitteesta…\",\n        \"full_export\": \"Täysi vienti\",\n        \"full_import\": \"Täysi tuonti\",\n        \"generate\": \"Generoi\",\n        \"generate_thumb_default\": \"Generoi oletusesikatselukuva\",\n        \"generate_thumb_from_current\": \"Generoi esikatselukuva nykyisestä\",\n        \"hash_migration\": \"Tiivisteen yhdistäminen\",\n        \"hide\": \"Piilota\",\n        \"hide_configuration\": \"Piilota Asetus\",\n        \"identify\": \"Tunnista\",\n        \"ignore\": \"Jätä huomiotta\",\n        \"import\": \"Tuo…\",\n        \"import_from_file\": \"Tuo tiedostosta\",\n        \"logout\": \"Kirjaudu ulos\",\n        \"make_primary\": \"Tee ensisijaikseksi\",\n        \"merge\": \"Yhdistä\",\n        \"next_action\": \"Seuraava\",\n        \"not_running\": \"ei käynnissä\",\n        \"open_in_external_player\": \"Avaa ulkoisessa soittimessa\",\n        \"open_random\": \"Avaa satunnainen\",\n        \"overwrite\": \"Ylikirjoita\",\n        \"play_random\": \"Toista satunnainen\",\n        \"play_selected\": \"Toista valittu\",\n        \"preview\": \"Esikatselu\",\n        \"previous_action\": \"Takaisin\",\n        \"reassign\": \"Määritä uudelleen\",\n        \"refresh\": \"Päivitä\",\n        \"reload_plugins\": \"Lataa lisäosat uudelleen\",\n        \"reload_scrapers\": \"Lataa kaapija uudelleen\",\n        \"remove\": \"Poista\",\n        \"remove_from_gallery\": \"Poista galleriasta\",\n        \"rename_gen_files\": \"Nimeä generoidut tiedostot uudelleen\",\n        \"rescan\": \"Skannaa uudelleen\",\n        \"reshuffle\": \"Sekoita uudelleen\",\n        \"running\": \"käynnissä\",\n        \"save\": \"Tallenna\",\n        \"save_delete_settings\": \"Käytä näitä asetuksia oletuksena kun poistat sisältöä\",\n        \"save_filter\": \"Tallenna suodatin\",\n        \"scan\": \"Skannaa\",\n        \"scrape\": \"Kaavi\",\n        \"scrape_query\": \"Kaavintajono\",\n        \"scrape_scene_fragment\": \"Kaavi osan perusteella\",\n        \"scrape_with\": \"Kaavi…\",\n        \"search\": \"Hae\",\n        \"select_all\": \"Valitse Kaikki\",\n        \"select_entity\": \"Valitse {entityType}\",\n        \"select_folders\": \"Valitse kansiot\",\n        \"select_none\": \"Peruuta Valinta\",\n        \"selective_auto_tag\": \"Valikoiva automaattinen tunnisteiden asetus\",\n        \"selective_clean\": \"Valikoiva Puhdistus\",\n        \"selective_scan\": \"Valikoiva skannaus\",\n        \"set_as_default\": \"Aseta oletukseksi\",\n        \"set_back_image\": \"Takakansi…\",\n        \"set_front_image\": \"Etukansi…\",\n        \"set_image\": \"Aseta kuva…\",\n        \"show\": \"Näytä\",\n        \"show_configuration\": \"Näytä Asetus\",\n        \"skip\": \"Ohita\",\n        \"split\": \"Jaa osiin\",\n        \"stop\": \"Pysäytä\",\n        \"submit\": \"Lähetä\",\n        \"submit_stash_box\": \"Lähetä Stash-Boxiin\",\n        \"submit_update\": \"Lähetä päivitys\",\n        \"swap\": \"Vaihda\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Haluatko varmasti puhdistaa? Tämä poistaa tietokannan tiedot ja poistaa kaikki generoidut tukitiedostot kaikista kohtauksista ja gallerioista, eikä niitä enää ole löydettävissä levyltä.\",\n            \"dry_mode_selected\": \"Kuivatila käytössä. Poistoa ei oikeasti tehdä, vain lokikirjaus.\",\n            \"import_warning\": \"Oletko varma että haluat tuoda? Tämä poistaa tietokannan ja tuo sen uudelleen aiemmin viedyistä tiedoista.\"\n        },\n        \"temp_disable\": \"Poista käytöstä väliaikaisesti…\",\n        \"temp_enable\": \"Ota käyttöön väliaikaisesti…\",\n        \"unset\": \"Poista valinta\",\n        \"use_default\": \"Käytä oletusta\",\n        \"view_random\": \"Näytä satunnainen\",\n        \"assign_stashid_to_parent_studio\": \"Liitä Stash ID nykyiseen emostudioon ja päivitä metadata\",\n        \"copy_to_clipboard\": \"Kopioi leikepöydälle\",\n        \"create_parent_studio\": \"Luo emostudio\",\n        \"optimise_database\": \"Optimoi tietokanta\",\n        \"reload\": \"Lataa uudelleen\",\n        \"choose_date\": \"Valitse päivä\",\n        \"disable\": \"Poista käytöstä\",\n        \"clean_generated\": \"Puhdista generoidut tiedostot\",\n        \"enable\": \"Ota käyttöön\",\n        \"remove_date\": \"Poista päivä\",\n        \"encoding_image\": \"Enkoodataan kuvaa…\",\n        \"clear_date_data\": \"Tyhjennä päivämäärä\",\n        \"add_o\": \"Lisää O\",\n        \"add_manual_date\": \"Lisää päivämäärä käsin\",\n        \"add_play\": \"Lisää toistokerta\",\n        \"view_history\": \"Näytä historia\",\n        \"reset_cover\": \"Palauta oletuskansi\",\n        \"set_cover\": \"Aseta kanneksi\",\n        \"reset_resume_time\": \"Nollaa jatkamisaika\",\n        \"remove_from_containing_group\": \"Poista ryhmästä\",\n        \"add_sub_groups\": \"Lisää aliryhmiä\",\n        \"migrate_blobs\": \"Siirrä blobit\",\n        \"migrate_scene_screenshots\": \"Siirrä kohtauksen kuvakaappaukset\",\n        \"reset_play_duration\": \"Nollaa toiston kesto\",\n        \"load\": \"Ladataan\",\n        \"load_filter\": \"Lataa filtteri\",\n        \"play\": \"Toista\",\n        \"show_results\": \"Näytä tulokset\",\n        \"show_count_results\": \"Näytä {count} tulosta\",\n        \"sidebar\": {\n            \"close\": \"Sulje sivupalkki\",\n            \"open\": \"Avaa sivupalkki\",\n            \"toggle\": \"näytä/piilota sivupalkki\"\n        },\n        \"add_stash_id\": \"Lisää Stash ID\",\n        \"create_new\": \"Lisää uusi\"\n    },\n    \"actions_name\": \"Toiminnot\",\n    \"age\": \"Ikä\",\n    \"aliases\": \"Aliakset\",\n    \"all\": \"kaikki\",\n    \"also_known_as\": \"Esiintyy myös nimillä\",\n    \"appears_with\": \"Esiintyy yhdessä\",\n    \"ascending\": \"Nouseva\",\n    \"average_resolution\": \"Keskimääräinen resoluutio\",\n    \"between_and\": \"ja\",\n    \"birth_year\": \"Syntymävuosi\",\n    \"birthdate\": \"Syntymäpäivä\",\n    \"bitrate\": \"Bittinopeus\",\n    \"blobs_storage_type\": {\n        \"database\": \"Tietokanta\",\n        \"filesystem\": \"Tiedostojärjestelmä\"\n    },\n    \"captions\": \"Tekstitykset\",\n    \"career_length\": \"Uran pituus\",\n    \"chapters\": \"Kappaleet\",\n    \"circumcised\": \"Ympärileikattu\",\n    \"circumcised_types\": {\n        \"CUT\": \"Leikattu\",\n        \"UNCUT\": \"Ympäreileikkaamaton\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktiivinen stash-box -instanssi:\",\n            \"blacklist_desc\": \"Mustalle listalle laitettuja kohteita ei sisällytetä kirjastoihin. Huomaathan, että kohteet ovat säännöllisiä lausekkeita ja niiden kirjainkoko on merkitsevä. Jotkin merkit täytyy päättää kenoviivalla: {chars_require_escape}\",\n            \"blacklist_label\": \"Musta lista\",\n            \"query_mode_auto\": \"Automaattinen\",\n            \"query_mode_auto_desc\": \"Käyttää metadataa jos saatavilla tai tiedoston nimeä\",\n            \"query_mode_dir\": \"Hakemisto\",\n            \"query_mode_dir_desc\": \"Käyttää vain kasiota jossa videotiedosto sijaitsee\",\n            \"query_mode_filename\": \"Tiedoston nimi\",\n            \"query_mode_filename_desc\": \"Käyttää vain tiedoston nimeä\",\n            \"query_mode_label\": \"Hakutila\",\n            \"query_mode_metadata\": \"Metadata\",\n            \"query_mode_metadata_desc\": \"Käyttää vain metadataa\",\n            \"query_mode_path\": \"Polku\",\n            \"query_mode_path_desc\": \"Käyttää koko polkua\",\n            \"set_cover_desc\": \"Korvaa kohtauksen kansi jos sellainen löydetään.\",\n            \"set_cover_label\": \"Aseta kohtauksen kansikuva\",\n            \"set_tag_desc\": \"Liitä tunnisteet kohtaukseen, joko ylikirjoittamalla nykyiset tai yhdistämällä ne jo olemassa oleviin tunnisteihin.\",\n            \"set_tag_label\": \"Aseta tunnisteet\",\n            \"source\": \"Lähde\",\n            \"mark_organized_label\": \"Merkitse järjestetyksi kun tallennetaan\",\n            \"mark_organized_desc\": \"Merkitsee kohtauksen järjestellyksi hetki kun Tallenna -painiketta on painettu.\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Monista musta lista -kohde\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Esiintyjien sukupuolet\",\n                \"description\": \"Esiintyjät näillä sukupuolilla näytetään lisättäessä tunnisteita kohtauksille.\"\n            }\n        },\n        \"noun_query\": \"Haku\",\n        \"results\": {\n            \"duration_off\": \"Kesto eroaa vähintään {number}s\",\n            \"duration_unknown\": \"Tuntematon kesto\",\n            \"fp_found\": \"{fpCount, plural, =0 {Uusia sormenjälkiä ei löytynyt} other {Löytyi # uutta sormenjälkeä}}\",\n            \"fp_matches\": \"Kesto täsmää\",\n            \"fp_matches_multi\": \"Kesto täsmää {matchCount}/{durationsLength} sormenjälkiin\",\n            \"hash_matches\": \"{hash_type} täsmää\",\n            \"match_failed_already_tagged\": \"Kohtaukselle on jo asetettu tunnisteet\",\n            \"match_failed_no_result\": \"Ei tuloksia\",\n            \"match_success\": \"Kohtaukselle on nyt asetettu tunnisteet\",\n            \"phash_matches\": \"{count} PHashia täsmää\",\n            \"unnamed\": \"Nimetön\"\n        },\n        \"verb_match_fp\": \"Sormenjäljet täsmäävät\",\n        \"verb_matched\": \"Täsmätty\",\n        \"verb_scrape_all\": \"Kaavi kaikki\",\n        \"verb_submit_fp\": \"Lähetä {fpCount, plural, one{# sormenjäki} other{# sormenjälkeä}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} täsmäämättömät kohtaukset\",\n        \"verb_add_as_alias\": \"Lisää kaavittu nimi aliaksena\",\n        \"verb_link_existing\": \"Linkitä olemassaolevaan\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Version tiiviste:\",\n            \"build_time\": \"Version päiväys:\",\n            \"check_for_new_version\": \"Tarkista onko uutta versiota saatavilla\",\n            \"latest_version\": \"Viimeisin Versio\",\n            \"latest_version_build_hash\": \"Uusimman version tiiviste:\",\n            \"new_version_notice\": \"[UUSI]\",\n            \"release_date\": \"Julkaisupäivä:\",\n            \"stash_discord\": \"Liity {url} kanavallemme\",\n            \"stash_home\": \"Stashin kotisivut {url}\",\n            \"stash_open_collective\": \"Tue meitä {url}\",\n            \"stash_wiki\": \"Stashin {url} -sivu\",\n            \"version\": \"Versio\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Sovellukset Polut\"\n        },\n        \"categories\": {\n            \"about\": \"Tietoja\",\n            \"changelog\": \"Muutosloki\",\n            \"interface\": \"Käyttöliittymä\",\n            \"logs\": \"Lokit\",\n            \"metadata_providers\": \"Metadatan tarjoajat\",\n            \"plugins\": \"Lisäosat\",\n            \"scraping\": \"Kaavinta\",\n            \"security\": \"Turvallisuus\",\n            \"services\": \"Palvelut\",\n            \"system\": \"Järjestelmä\",\n            \"tasks\": \"Tehtävät\",\n            \"tools\": \"Työkalut\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Salli {tempIP}\",\n            \"allowed_ip_addresses\": \"Sallitut IP -osoitteet\",\n            \"allowed_ip_temporarily\": \"Väliaikaisesti sallittu IP\",\n            \"default_ip_whitelist\": \"Oletus IP Whitelist\",\n            \"default_ip_whitelist_desc\": \"Oletus IP -osoitteet, joilla on DLNA -pääsy. Käytä {wildcard} salliaksesi kaikki IP -osoitteet.\",\n            \"disabled_dlna_temporarily\": \"DLNA poistettu käytöstä väliaikaisesti\",\n            \"disallowed_ip\": \"Estetty IP\",\n            \"enabled_by_default\": \"Sallittu oletuksena\",\n            \"enabled_dlna_temporarily\": \"DNLA päällä väliaikaisesti\",\n            \"network_interfaces\": \"Rajapinnat\",\n            \"network_interfaces_desc\": \"Rajapinnat, joille DLNA palvelin näytetään. Tyhjä lista sallii kaikki rajapinnat. Vaatii DLNA -palvelimen uudelleenkäynnistyksen, mikäli asetusta muokataan.\",\n            \"recent_ip_addresses\": \"Viimeisimmät IP -osoitteet\",\n            \"server_display_name\": \"Palvelimen näyttönimi\",\n            \"server_display_name_desc\": \"DLNA -palvelimen nimi. Oletuksena {server_name}, jos tyhjä.\",\n            \"until_restart\": \"uudelleenkäynnistykseen asti\",\n            \"video_sort_order\": \"Videoiden oletusjärjestys\",\n            \"video_sort_order_desc\": \"Videoiden oletusjärjestys.\",\n            \"server_port\": \"Palvelimen portti\",\n            \"server_port_desc\": \"Portti, jota DLNA palvelin kuuntelee. DLNA palvelin pitää käynnistää uudelleen muuttamisen jälkeen.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Väliaikainen toiminta peruutettiin onnistuneesti\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API -avain\",\n                \"api_key_desc\": \"API -avain ulkoisille järjestelmille. Tarvitaan vain jos käyttäjätunnus ja salasana on määritelty. Käyttäjätunnus taytyy tallentaa ennen API -avaimen luomista.\",\n                \"authentication\": \"Autentikointi\",\n                \"clear_api_key\": \"Puhdista API -avain\",\n                \"credentials\": {\n                    \"description\": \"Tunnukset, joiden pääsyä stashiin rajoitetaan.\",\n                    \"heading\": \"Tunnukset\"\n                },\n                \"generate_api_key\": \"Luo API -avain\",\n                \"log_file\": \"Lokitiedosto\",\n                \"log_file_desc\": \"Polku lokitiedostoon. Jos jätetty tyhjäksi, ei lokitiedostoa tehdä. Vaatii uudelleenkäynnistyksen.\",\n                \"log_http\": \"Kirjaa http käyttö\",\n                \"log_http_desc\": \"Kirjaa http -pyynnöt komentoriville. Vaatii uudelleenkäynnistyksen.\",\n                \"log_to_terminal\": \"Kirjoita loki komentoriville\",\n                \"log_to_terminal_desc\": \"Kirjaa lokin komentoriville lokitiedoston sijaan. Aina päällä jos lokitiedosto ei ole käytössä. Vaatii uudelleenkäynnistyksen.\",\n                \"maximum_session_age\": \"Istunnon keston yläraja\",\n                \"maximum_session_age_desc\": \"Yläraja toimettomalle ajalle sekunneissa ennen kuin kirjautuminen vanhenee. Vaatii udelleenkäynnistyksen.\",\n                \"password\": \"Salasana\",\n                \"password_desc\": \"Salasana Stashiin. Jätä tyhjäksi, mikäli et halua autentikointia\",\n                \"stash-box_integration\": \"Stash-box integraatio\",\n                \"username\": \"Käyttäjätunnus\",\n                \"username_desc\": \"Käyttäjätunnus Stashiin. Jätä tyhjäksi, mikäli et halua autentikointia\",\n                \"log_file_max_size\": \"Lokin maksimikoko\",\n                \"log_file_max_size_desc\": \"Lokin maksimikoko megatavuina ennen pakkaamista. 0MB laittaa pois käytöstä. Vaatii uudelleenkäynnistyksen.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"SQLite -tietokannan varmuuskopiokansion sijainti\",\n                \"heading\": \"Varmuuskopiokansion polku\"\n            },\n            \"cache_location\": \"Välimuistin kansion sijainti. Vaadittu kun suoratoistossa käytetään HLS:ää (kuten Apple -laitteissa) tai DASH:iä.\",\n            \"cache_path_head\": \"Välimuistin polku\",\n            \"calculate_md5_and_ohash_desc\": \"Laske MD5 -tarkistussumma oshashin lisäksi. Jos laitat tämän päälle, se tekee skannauksista hitaampia. Tiedostojen nimien tiivisteeksi pitää valita oshash MD5:n laskemisen laittamiseksi pois päältä.\",\n            \"calculate_md5_and_ohash_label\": \"Laske MD5 videoille\",\n            \"check_for_insecure_certificates\": \"Tarkista turvattomien varmenteiden varalta\",\n            \"check_for_insecure_certificates_desc\": \"Jotkin sivut käyttävät turvattomia ssl -sertifikaatteja. Jos ei valittu, kaapija ohittaa turvattomien varmenteiden tarkistuksen ja sallii näiden sivujen kaapimisen. Jos saat sertifikaattivirheen kaavinnan aikana, ota valinta pois tästä.\",\n            \"chrome_cdp_path\": \"Chrome CDP -polku\",\n            \"chrome_cdp_path_desc\": \"Tiedostopolku Chromeen, tai etäpolku (alkaa http:// tai https://, esimerkiksi http://localhost:9222/json/version) Chrome -instanssiin.\",\n            \"create_galleries_from_folders_desc\": \"Jos valittu, luodaan galleria kansion sisältämistä kuvista.\",\n            \"create_galleries_from_folders_label\": \"Luo galleria kansion kuvista\",\n            \"database\": \"Tietokanta\",\n            \"db_path_head\": \"Tietokannan polku\",\n            \"directory_locations_to_your_content\": \"Kansiot, joissa sisältösi sijaitsee\",\n            \"excluded_image_gallery_patterns_desc\": \"Säännöllinen lauseke kuvien ja gallerioiden nimille/poluille, jotka jätetään huomioimatta skannauksessa\",\n            \"excluded_image_gallery_patterns_head\": \"Poisjätetyt kuvien ja gallerioiden merkkijonot\",\n            \"excluded_video_patterns_desc\": \"Säännöllinen lauseke videoiden nimille/poluille, jotka jätetään huomioimatta skannauksessa\",\n            \"excluded_video_patterns_head\": \"Poisjätetyt videoiden merkkijonot\",\n            \"gallery_ext_desc\": \"Pilkuilla erotettu lista tiedostopäätteistä, mitkä tulkitaan pakatuksi zip -galleriatiedostoksi.\",\n            \"gallery_ext_head\": \"Gallerian zip -päätteet\",\n            \"generated_file_naming_hash_desc\": \"Käytä MD5:ttä tai oshashia tiedostojen nimien generoimiseen. Tämän muuttaminen vaatii, että kaikilla kohtauksilla on MD5 tai oshash. Tämän valinnan muuttamisen jälkeen kaikki generoidut tukitiedostot täytyy joko migraatioida tai generoida uudelleen. Katso lisää migraatiosta tehtävät -sivulta.\",\n            \"generated_file_naming_hash_head\": \"Generoitu tiiviste tiedoston nimeämistä varten\",\n            \"generated_files_location\": \"Kansion polku generoiduille tiedostoille (kohtauksen merkit, kohtauksien esikatselut, tms)\",\n            \"generated_path_head\": \"Generoitujen tiedostojen polku\",\n            \"hashing\": \"Tiivisteen luominen\",\n            \"image_ext_desc\": \"Pilkuilla erotettu lista tiedostopäätteistä, jotka tulkitaan kuviksi.\",\n            \"image_ext_head\": \"Kuvien tiedostopäätteet\",\n            \"include_audio_desc\": \"Sisällyttää äänen generoituihin esikatseluihin.\",\n            \"include_audio_head\": \"SIsällytä ääni\",\n            \"logging\": \"Kirjaus\",\n            \"maximum_streaming_transcode_size_desc\": \"Suurin koko transkoodatulle suoratoistolle\",\n            \"maximum_streaming_transcode_size_head\": \"Transkoodatun suoratoiston suurin koko\",\n            \"maximum_transcode_size_desc\": \"Suurin koko generoiduille transkoodauksille\",\n            \"maximum_transcode_size_head\": \"Suurin koko transkoodaukselle\",\n            \"metadata_path\": {\n                \"description\": \"Kansion sijainti kun tehdään täydellinen vienti tai tuonti\",\n                \"heading\": \"Metadatan polku\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Automaattinen tunnistus on käytössä jos asetat 0. Varoitus: jos sallit enemmän tehtäviä kuin mitä suoritimesi kykenee suorittamaan voi järjestelmän suorituskyky laskea tai se voi aiheuttaa muita ongelmia.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Rinnakkaisten skannaus- ja generointitehtävien määrä\",\n            \"parallel_scan_head\": \"Rinnakkainen skannaus ja generointi\",\n            \"preview_generation\": \"Esikatselun generointi\",\n            \"python_path\": {\n                \"heading\": \"Pythonin polku\",\n                \"description\": \"Polku python-suoritettavaan (ei vain kansioon). Käytetään skriptien kaavinta ja laajennuksia varten. Jos tyhjä, python ratkaistaan ympäristöstä\"\n            },\n            \"scraper_user_agent\": \"Kaapijan käyttäjäagentti\",\n            \"scraper_user_agent_desc\": \"Käyttäjäagenttikenttä, jota kaavittaessa käytetään http pyynnöissä\",\n            \"scrapers_path\": {\n                \"description\": \"Kaapijan konfiguraatiotiedostojen kansion sijainti\",\n                \"heading\": \"Kaapijoiden polku\"\n            },\n            \"scraping\": \"Kaapiminen\",\n            \"sqlite_location\": \"SQLite -tietokannan tiedostosijainti (vaatii uudelleenkäynnistyksen) VAROITUS: tietokannan tallentaminen eri järjestelmään kuin missä Stash on käynnissä (kuten verkon yli) ei ole tuettu!\",\n            \"video_ext_desc\": \"Pilkuilla erotettu lista tiedostopäätteistä, jotka tulkitaan videoiksi.\",\n            \"video_ext_head\": \"Videoiden päätteet\",\n            \"video_head\": \"Video\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"heading\": \"FFmpeg laitteistokoodaus\",\n                    \"desc\": \"Käyttää saatavilla olevaa laitteistoa videon koodaamiseen reaaliaikaista transkoodausta varten.\"\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"Lataa FFmpeg\",\n                    \"description\": \"Lataa FFmpegin asetushakemistoon ja tyhjentää ffmpeg- ja ffprobe-polut määrityshakemistosta ratkaistavaksi.\"\n                },\n                \"transcode\": {\n                    \"output_args\": {\n                        \"desc\": \"Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen tuloskenttää videota luotaessa.\",\n                        \"heading\": \"FFmpeg:n muunnoksen ulostuloparametrit\"\n                    },\n                    \"input_args\": {\n                        \"desc\": \"Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen syöttökenttää videota luotaessa.\",\n                        \"heading\": \"FFmpeg:n muunnoksen syöteparametrit\"\n                    }\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"Polku ffmpeg-suoritettavaan (ei vain kansioon). Jos se on tyhjä, ffmpeg ratkaistaan ympäristöstä $PATH:n, asetushakemiston tai $HOME/.stash kautta\",\n                    \"heading\": \"FFmpeg-suoritettavan polku\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Polku ffprobe-suoritettavaan(ei vain kansioon). Jos se on tyhjä, ffprobe ratkaistaan ympäristöstä $PATH:n, asetushakemiston tai $HOME/.stash kautta\",\n                    \"heading\": \"FFprobe-suoritettavan polku\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen syöttökenttää, kun videota muutetaan livenä.\",\n                        \"heading\": \"FFmpeg:n live-muunnoksen syöteparametrit\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Edistynyt: Lisäargumentit, jotka siirretään ffmpegille ennen lähtökenttää, kun videota muutetaan livenä.\",\n                        \"heading\": \"FFmpeg:n live-muunnoksen ulostuloparametrit﻿\"\n                    }\n                }\n            },\n            \"blobs_path\": {\n                \"heading\": \"Binaaridatan tiedostojärjestelmän polku\",\n                \"description\": \"Mihin tiedostojärjestelmässä binaaridataa tallennetaan. Käytettävissä vain käytettäessä tiedostojärjestelmän blob-tallennustyyppiä. Varoitus: tämän muuttaminen edellyttää olemassa olevien tietojen manuaalista siirtämistä.\"\n            },\n            \"plugins_path\": {\n                \"heading\": \"Liitännäisten polku\",\n                \"description\": \"Liitännäisten määritystiedostojen sijainti hakemistossa\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Mihin tallentaa binaaridataa, kuten kohtauksen kansia, esiintyjä-, studio- ja tagikuvia. Tämän arvon muuttamisen jälkeen olemassa olevat tiedot on siirrettävä käyttämällä Migrate Blobs -tehtäviä. Katso Tehtävät-sivulta siirtyminen.\",\n                \"heading\": \"Binääritietojen tallennustyyppi\"\n            },\n            \"funscript_heatmap_draw_range_desc\": \"Piirrä liikealue generoidun lämpökartan y-akselille. Olemassa olevat lämpökartat on luotava uudelleen vaihtamisen jälkeen.\",\n            \"funscript_heatmap_draw_range\": \"Sisällytä alue luotuihin lämpökarttoihin\",\n            \"gallery_cover_regex_desc\": \"Regexp käytetään kuvaamaan gallerian kansikuvaa\",\n            \"gallery_cover_regex_label\": \"Gallerian kansikuvio\",\n            \"heatmap_generation\": \"Funscript-lämpökarttageneraattori﻿\",\n            \"delete_trash_path\": {\n                \"description\": \"Polku johon poistetut tiedostot siirretään sen sijaan, että ne poistettaisiin lopullisesti. Jätä tyhjäksi poistaaksesi tiedostot lopullisesti.\",\n                \"heading\": \"Roskakorin polku\"\n            }\n        },\n        \"library\": {\n            \"exclusions\": \"Poisjättäminen\",\n            \"gallery_and_image_options\": \"Galleria- ja kuva-asetukset\",\n            \"media_content_extensions\": \"Mediasisällön tiedostopäätteet\"\n        },\n        \"logs\": {\n            \"log_level\": \"Lokin taso\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} metadata\",\n            \"entity_scrapers\": \"{entityType} kaapijat\",\n            \"excluded_tag_patterns_desc\": \"Säännöllinen lauseke tunnisteista, jotka jätetään pois kaapijan tuloksista\",\n            \"excluded_tag_patterns_head\": \"Poisjätetyt tunnisteet\",\n            \"scraper\": \"Kaapija\",\n            \"scrapers\": \"Kaapijat\",\n            \"search_by_name\": \"Etsi nimellä\",\n            \"supported_types\": \"Tuetut tyypit\",\n            \"supported_urls\": \"URL -osoitteet\",\n            \"installed_scrapers\": \"Asennetut kaapijat\",\n            \"available_scrapers\": \"Saatavilla olevat kaapijat\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Lisää stash-box instanssi\",\n            \"api_key\": \"API -avain\",\n            \"description\": \"Stash-box helpottaa kohtauksien ja esiintyjien tunnistamista sormenjälkien ja tiedostonimien perusteella.\\nPäätepiste ja API -avain löytyy stash-boxin tilisi tiedoista. Nimiä vaaditaan jos yhdistät useampaan kuin yhteen stash-box -instanssiin.\",\n            \"endpoint\": \"Päätepiste\",\n            \"graphql_endpoint\": \"GraphQL päätepiste\",\n            \"name\": \"Nimi\",\n            \"title\": \"Stash-box päätepisteet\",\n            \"max_requests_per_minute\": \"Enimmäispyynnöt minuutissa\",\n            \"max_requests_per_minute_description\": \"Käyttää oletusarvoa {defaultValue}, jos se on asetettu 0:ksi\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transkoodaus\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Lisättiin {operation_name} tehtäväjonoon\",\n            \"anonymise_and_download\": \"Tekee anonymisoidun kopion tietokannasta ja lataa sen.\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Asetetaan tunnisteet automaattisesti kaikkiin polkuihin\",\n                \"auto_tagging_paths\": \"Asetetaan tunnisteet automaattisesti seuraaviin polkuihin\"\n            },\n            \"auto_tag_based_on_filenames\": \"Aseta tunnisteet automaattisesti tiedostopolkujen perusteella.\",\n            \"auto_tagging\": \"Automaattinen tunnisteiden asetus\",\n            \"backing_up_database\": \"Varmuuskopioidaan tietokantaa\",\n            \"backup_and_download\": \"Suorittaa tietokannan varmuuskopioinnin ja lataa luodun tiedoston.\",\n            \"cleanup_desc\": \"Tarkistaa puuttuvat tiedostot ja poistaa ne tietokannasta. Tämä on tuhoava toimi.\",\n            \"data_management\": \"Datan hallinta\",\n            \"defaults_set\": \"Oletukset on asetettu ja niitä käytetään kun painat {action} -painiketta Tehtävät -sivulla.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Elä sisällytä tiedostopäätettä tiedoston nimeen\",\n            \"empty_queue\": \"Tehtäviä ei ole menossa.\",\n            \"export_to_json\": \"Vie tietokannan sisällön JSON -formaatissa samaan kansioon kuin missä tietokanta on.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Generoidaan kohtauksille seuraavista poluista\",\n                \"generating_scenes\": \"Generoidaan {num} {scene}\"\n            },\n            \"generate_desc\": \"Generoi kuvat, esikatselut ja muut tukitiedostot.\",\n            \"generate_phashes_during_scan\": \"Generoi phash -tiivisteet\",\n            \"generate_phashes_during_scan_tooltip\": \"Kaksoiskappaleiden löytämiseen ja kohtauksien tunnistamiseen.\",\n            \"generate_previews_during_scan\": \"Generoi animoidut esikatselukuvat\",\n            \"generate_previews_during_scan_tooltip\": \"Generoi myös animoidun (webp) esikatselun, vaaditaan vain jos kohtausseinän esikatselun tyypiksi on valittu animoitu kuva. Ne käyttävät selatessa vähemmän suoritintehoa kuin videoesikatselut, mutta ne luodaan videoesikatselun lisäksi ja ne ovat isoja tiedostoja.\",\n            \"generate_thumbnails_during_scan\": \"Generoi esikatselukuva skannauksen aikana\",\n            \"generate_video_previews_during_scan\": \"Generoi esikatselu\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generoi videoesikatselun, joka näytetään kun osoitin menee kohtauksen päälle\",\n            \"generated_content\": \"Generoitu sisältö\",\n            \"identify\": {\n                \"and_create_missing\": \"ja luo puuttuvat\",\n                \"create_missing\": \"Luo puuttuvat\",\n                \"default_options\": \"Oletusasetukset\",\n                \"description\": \"Aseta kohtauksen metadata automaattisesti käyttäen apuna stash-boxia ja kaapimia.\",\n                \"explicit_set_description\": \"Seuraavia asetuksia käytetään, mikäli lähdekohtaisia asetuksia ei ole.\",\n                \"field\": \"Kenttä\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Kentän asetukset\",\n                \"heading\": \"Tunnista\",\n                \"identifying_from_paths\": \"Tunnistetaan kohtauksia seuraavista poluista\",\n                \"identifying_scenes\": \"Tunnistetaan {num} {scene}\",\n                \"include_male_performers\": \"Sisällytä miesesiintyjät\",\n                \"set_cover_images\": \"Aseta kansikuvat\",\n                \"set_organized\": \"Aseta järjestelty -tunniste\",\n                \"source\": \"Lähde\",\n                \"source_options\": \"{source} asetukset\",\n                \"sources\": \"Lähteet\",\n                \"strategy\": \"Strategia\",\n                \"skip_multiple_matches\": \"Ohita vastaavat, joilla on useampi kuin yksi tulos\",\n                \"skip_multiple_matches_tooltip\": \"Jos tämä ei ole otettu käyttöön ja palautetaan useampi kuin yksi tulos, yksi valitaan satunnaisesti vastaamaan\",\n                \"skip_single_name_performers\": \"Ohita yksinimiset esiintyjät ilman tarkennusta\",\n                \"skip_single_name_performers_tooltip\": \"Jos tämä ei ole käytössä, usein geneeriset esiintyjät kuten Samantha tai Olga yhdistetään\",\n                \"tag_skipped_matches\": \"Merkitse ohitetut osumat seuraavalla\",\n                \"tag_skipped_matches_tooltip\": \"Luo tunniste, kuten 'Tunnista: Useita osumia', jota voit suodattaa Scene Tagger -näkymässä ja valita oikean osuman käsin\",\n                \"tag_skipped_performer_tooltip\": \"Luo tunniste, kuten 'Identify: Single Name Performer', jota voit suodattaa Scene Tagger -näkymässä ja valita, miten haluat käsitellä näitä esiintyjiä\",\n                \"tag_skipped_performers\": \"Merkitse ohitetut esiintyjät seuraavalla\"\n            },\n            \"import_from_exported_json\": \"Tuo viedystä JSON -tiedoista, jotka ovat samassa kansiossa kuin metadata. Pyyhkii olemassaolevan tietokannan.\",\n            \"incremental_import\": \"Lisäävä tuonti valitusta zip -tiedostosta.\",\n            \"job_queue\": \"Tehtäväjono\",\n            \"maintenance\": \"Ylläpito\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Poista vanhat tiedot\",\n                \"description\": \"Siirrä blobit nykyiseen blob-tallennusjärjestelmään. Tämä siirto tulisi suorittaa blob-tallennusjärjestelmän vaihdon jälkeen. Vanhojen tietojen poistaminen siirron jälkeen on valinnainen.\"\n            },\n            \"migrate_hash_files\": \"Käytetään kun muutetaan generoitua tiedoston nimeämistiivistettä jo generoitujen tiedostojen uudelleennimeämiseen uuteen muotoon.\",\n            \"migrations\": \"Migraatiot\",\n            \"only_dry_run\": \"Suorita kuivaharjoittelu. Elä poista mitään\",\n            \"plugin_tasks\": \"Lisäosien tehtävät\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Skannataan kaikki polut\",\n                \"scanning_paths\": \"Skannataan seuraavia polkuja\"\n            },\n            \"scan_for_content_desc\": \"Skannaa uusi sisältö ja lisää se tietokantaan.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Aseta nimi, päivä ja tiedot metadatasta\",\n            \"clean_generated\": {\n                \"markers\": \"Merkkien esikatselut\",\n                \"previews\": \"Kohtauksien esikatselut\",\n                \"previews_desc\": \"Kohtauksien esikatselut ja pienoiskuvat\",\n                \"blob_files\": \"Blob-tiedostot\",\n                \"description\": \"Poistaa luodut tiedostot ilman vastaavaa tietokantatietuetta.\",\n                \"image_thumbnails\": \"Kuvien pikkukuvat\",\n                \"image_thumbnails_desc\": \"Kuvien pikkukuvat ja pätkät\",\n                \"sprites\": \"Kohtauksien sprite-kuvat\",\n                \"transcodes\": \"Kohtauksien muunnokset\"\n            },\n            \"anonymising_database\": \"Anonymisoidaan tietokantaa\",\n            \"anonymise_database\": \"Tekee kopion tietokannasta varmuuskopioiden hakemistoon anonymisoimalla kaikki arkaluontoiset tiedot. Tämä voidaan sitten tarjota muille vianmääritys- ja viankorjaustarkoituksiin. Alkuperäistä tietokantaa ei ole muokattu. Anonymisoitu tietokanta käyttää tiedostonimimuotoa {filename_format}.\",\n            \"generate_sprites_during_scan_tooltip\": \"Videosoittimen alla näkyvät kuvat navigoinnin helpottamiseksi.\",\n            \"generate_video_covers_during_scan\": \"Luo kohtausten kannet\",\n            \"generate_clip_previews_during_scan\": \"Luo esikatselukuvat kuvaklippejä varten\",\n            \"generate_sprites_during_scan\": \"Luo pyyhkijän kuvasarjat﻿\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Poista näyttötallenteiden tiedostot\",\n                \"description\": \"Siirrä kohtauksen näyttökuvat uuteen blob-tallennusjärjestelmään. Tämä siirto tulisi suorittaa olemassa olevan järjestelmän päivittämisen jälkeen versioon 0.20. Vanhojen näyttökuvien poistaminen siirron jälkeen on valinnainen.\",\n                \"overwrite_existing\": \"Korvaa olemassa olevat blobit näyttötallennetiedoilla\"\n            },\n            \"optimise_database\": \"Yritä parantaa suorituskykyä analysoimalla ja koko tietokantatiedoston uudelleen rakentamalla.\",\n            \"optimise_database_warning\": \"Varoitus: tämän tehtävän ollessa käynnissä kaikki tietokantaa muokkaavat toiminnot epäonnistuvat, ja tietokannan koosta riippuen suoritus voi kestää useita minuutteja. Tarvitset lisäksi vähintään yhtä paljon vapaata levytilaa kuin tietokantasi koko on, mutta 1,5-kertainen määrä on suositeltavaa.\",\n            \"rescan\": \"Uudelleenskannaa tiedostot\",\n            \"rescan_tooltip\": \"Uudelleenskannaa kaikki tiedostot polussa. Käytetään tiedoston metatietojen pakolliseen päivitykseen ja zip-tiedostojen uudelleenskannaukseen.\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Kohtauksien kaksoiskappaleiden tarkistus\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Lisää kenttä\",\n                \"capitalize_title\": \"Otsikko kapiteelein\",\n                \"display_fields\": \"Näytä kentät\",\n                \"filename\": \"Tiedostonimi\",\n                \"ignore_organized\": \"Jätä järjestellyt kohtaukset huomiotta\",\n                \"ignored_words\": \"Huomiotta jätetyt sanat\",\n                \"matches_with\": \"Täsmää seuraavan kanssa {i}\",\n                \"whitespace_chars_desc\": \"Nämä merkit korvataan välilyönnillä otsikossa\",\n                \"escape_chars\": \"Käytä \\\\ merkin edessä, kun haluat käsitellä merkin kirjaimellisena merkkinä\",\n                \"filename_pattern\": \"Tiedostonimen malli\",\n                \"select_parser_recipe\": \"Valitse jäsentämisen ohjeistus, joka määrittää tiedon purkamisen ja käsittelyn\",\n                \"title\": \"Kohteen tiedostonimen jäsentäjä\",\n                \"whitespace_chars\": \"välilyöntimerkit\"\n            },\n            \"scene_tools\": \"Kohtauksen työkalut\",\n            \"graphql_playground\": \"GraphQL-kokeiluympäristö\",\n            \"heading\": \"Työkalut\"\n        },\n        \"ui\": {\n            \"basic_settings\": \"Perusasetukset\",\n            \"custom_css\": {\n                \"description\": \"Sivu täytyy ladata uudelleen, jotta muutokset tulevat voimaan. Yhteensopivuutta muokatun CSS:n ja tulevien Stash -versioiden kanssa ei voida taata.\",\n                \"heading\": \"Mukautettu CSS\",\n                \"option_label\": \"Mukautettu CSS käytössä\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Sivu täytyy ladata uudelleen, jotta muutokset astuvat voimaan. Yhteensopivuutta muokatun JavaScriptin ja tulevien Stash -versioiden kanssa ei voida taata.\",\n                \"heading\": \"Mukautettu Javascript\",\n                \"option_label\": \"Mukautettu Javascript käytössä\"\n            },\n            \"custom_locales\": {\n                \"heading\": \"Mukautettu lokalisointi\",\n                \"option_label\": \"Mukautettu lokalisointi käytössä\",\n                \"description\": \"Ylikirjoita yksittäisiä paikallisia merkkijonoja. Katso https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json master-lista. Sivun lataus on tehtävä uudelleen, jotta muutokset tulevat voimaan.\"\n            },\n            \"delete_options\": {\n                \"description\": \"Oletusasetukset kun poistetaan kuvia, gallerioita ja kohtauksia.\",\n                \"heading\": \"Poistovalinnat\",\n                \"options\": {\n                    \"delete_file\": \"Poista tiedosto oletuksena\",\n                    \"delete_generated_supporting_files\": \"Poista generoidut tukitiedostot oletuksena\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Työpöytäintegraatio\",\n                \"notifications_enabled\": \"Laita ilmoitukset päälle\",\n                \"send_desktop_notifications_for_events\": \"Lähetä työpöytäilmoituksia tapahtumista\",\n                \"skip_opening_browser\": \"Ohita selaimen avaus\",\n                \"skip_opening_browser_on_startup\": \"Ohita selaimen automaattinen avaus käynnistäessä\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Poistaa mahdollisuuden luoda uusia kohteita alasvetovalikoista\",\n                    \"heading\": \"Poista luominen alasvetovalikoista\"\n                },\n                \"heading\": \"Muokkaaminen\",\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Arvostelutähtien tarkkuus\",\n                        \"options\": {\n                            \"full\": \"Kokonainen\",\n                            \"half\": \"Puolikas\",\n                            \"quarter\": \"Neljäsosa\",\n                            \"tenth\": \"Kymmenes\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Arvostelujärjestelmä\",\n                        \"options\": {\n                            \"decimal\": \"Desimaalit\",\n                            \"stars\": \"Tähdet\"\n                        }\n                    }\n                },\n                \"max_options_shown\": {\n                    \"label\": \"Valintavalikoissa näytettävien kohteiden enimmäismäärä\"\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Viive millisekunneissa interaktiivisille skripteille kun toistetaan.\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Yhdistä\",\n                \"sync\": \"Synkronoi\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Suunta\",\n                \"heading\": \"Kuvaseinä\"\n            },\n            \"images\": {\n                \"heading\": \"Kuvat\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Kirjoita kuvien esikatselut levylle kun generoidaan\",\n                        \"heading\": \"Kirjoita kuvien esikatselut\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Interaktiivisuuden asetukset\",\n            \"language\": {\n                \"heading\": \"Kieli\"\n            },\n            \"menu_items\": {\n                \"description\": \"Näytä tai piilota sisältötyypit päävalikosta\",\n                \"heading\": \"Päävalikon kohteet\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Mukautettu polku esiintyjien kuville. Jätä tyhjäksi käyttääksesi oletusasetuksia\",\n                        \"heading\": \"Mukautettu esiintyjien kuvien polku\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Oletuksena on videoesikatselut (mp4). Jos haluat vähentää suorittimen käyttöä kun selaillaan kirjastoa, käytä animoituja kuvia (webp). Ne pitää kuitenkin generoida erikseen videoesikatseluiden lisäksi ja ovat kooltaan isompia.\",\n                \"heading\": \"Esikatselun tyyppi\",\n                \"options\": {\n                    \"animated\": \"Animoitu kuva\",\n                    \"static\": \"Staattinen kuva\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Ruudukkonäkymä\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Näytä studiot tekstinä\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Kohtauksien soitin\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Aloita video aina alusta\",\n                    \"auto_start_video\": \"Aloita video automaattisesti\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Aloita video automaattisesti kun kohtaus valitaan tai käytetään satunnainen kohtaus -sivua\",\n                        \"heading\": \"Aloita video automaattisesti kun valittu\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Toista seuraava kohtaus jonossa kun video loppuu\",\n                        \"heading\": \"Jatka soittolistaa automaattisesti\"\n                    }\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Kohtaus- ja merkkiseinä\",\n                \"options\": {\n                    \"display_title\": \"Näytä otsikko ja tunnisteet\",\n                    \"toggle_sound\": \"Käytä ääntä\"\n                }\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Kuvaesitys käytössä gallerioissa seinätilassa\",\n                \"heading\": \"Kuvaesityksen viive (sekunteina)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Studionäkymä\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"Näytä alistudiot studionäkymässä\",\n                        \"heading\": \"Näytä alistudioiden sisältö\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Tunnistenäkymä\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"Näytä tunnistenäkymässä myös alitunnisteiden sisältö\",\n                        \"heading\": \"Näytä alitunnisteiden sisältö\"\n                    }\n                }\n            },\n            \"title\": \"Käyttöliittymä\",\n            \"abbreviate_counters\": {\n                \"description\": \"Lyhennä lukujen esitystapaa korteissa ja yksityiskohtien näkymissä, esimerkiksi luku \\\"1831\\\" esitetään muodossa \\\"1,8K\\\".\",\n                \"heading\": \"Lukujen esitysmuodon lyhentäminen\"\n            },\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"description\": \"Kun tämä asetus on otettu käyttöön, se näyttää laajennetut tiedot säilyttäen samalla kompaktin esityksen.\",\n                    \"heading\": \"Tiivistetyt laajennetut tiedot\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Näytä taustakuva yksityiskohtasivulla.\",\n                    \"heading\": \"Ota taustakuva käyttöön\"\n                },\n                \"heading\": \"Lisätietosivu\",\n                \"show_all_details\": {\n                    \"description\": \"Kun tämä on otettu käyttöön, kaikki sisällön tiedot näytetään oletuksena ja jokainen tietoelementti mahtuu yhden sarakkeen alle.\",\n                    \"heading\": \"Näytä kaikki tiedot\"\n                }\n            },\n            \"sfw_mode\": {\n                \"heading\": \"SFW -sisältötila\"\n            },\n            \"performer_list\": {\n                \"heading\": \"Esiintyjälista\"\n            }\n        },\n        \"advanced_mode\": \"Edistynyt tila\",\n        \"plugins\": {\n            \"installed_plugins\": \"Asennetut lisäosat\",\n            \"available_plugins\": \"Saatavilla olevat liitännäiset\",\n            \"hooks\": \"Koukut\",\n            \"triggers_on\": \"Aktivoituu kun\"\n        }\n    },\n    \"configuration\": \"Konfiguraatio\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Tiedosto} other {Tiedostoa}}\",\n        \"galleries\": \"{count, plural, one {Galleria} other {Galleriaa}}\",\n        \"images\": \"{count, plural, one {Kuva} other {Kuvaa}}\",\n        \"markers\": \"{count, plural, one {Merkki} other {Merkkiä}}\",\n        \"performers\": \"{count, plural, one {Esiintyjä} other {Esiintyjät}}\",\n        \"scenes\": \"{count, plural, one {Kohtaus} other {Kohtausta}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Studiota}}\",\n        \"tags\": \"{count, plural, one {Tunniste} other {Tunnistetta}}\"\n    },\n    \"country\": \"Maa\",\n    \"cover_image\": \"Kansikuva\",\n    \"created_at\": \"Luotu\",\n    \"criterion\": {\n        \"greater_than\": \"Suurempi kuin\",\n        \"less_than\": \"Pienempi kuin\",\n        \"value\": \"Arvo\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"väliltä\",\n        \"equals\": \"on\",\n        \"excludes\": \"poisluettuna\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"on suurempi kuin\",\n        \"includes\": \"sisältää\",\n        \"includes_all\": \"sisältää kaikki\",\n        \"is_null\": \"on tyhjä\",\n        \"less_than\": \"on vähemmän kuin\",\n        \"matches_regex\": \"täsmää säännölliseen lausekkeeseen\",\n        \"not_between\": \"ei väliltä\",\n        \"not_equals\": \"ei ole\",\n        \"not_matches_regex\": \"ei täsmää säännölliseen lausekkeeseen\",\n        \"not_null\": \"ei ole tyhjä\"\n    },\n    \"custom\": \"Mukautettu\",\n    \"date\": \"Päivä\",\n    \"death_date\": \"Kuolinpäivä\",\n    \"death_year\": \"Kuolinvuosi\",\n    \"descending\": \"Laskeva\",\n    \"description\": \"Kuvaus\",\n    \"detail\": \"Lisätiedot\",\n    \"details\": \"Lisätiedot\",\n    \"developmentVersion\": \"Kehitysversio\",\n    \"dialogs\": {\n        \"create_new_entity\": \"Luo uus {entity}\",\n        \"delete_alert\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} poistetaan pysyvästi:\",\n        \"delete_confirm\": \"Haluatko varmasti poistaa {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Haluatko varmasti, että {singularEntity} poistetaan? Jos tiedostoa ei poisteta {singularEntity} lisätään uudelleen heti kun uusi skannaus suoritetaan.} other {Haluatko varmasti, että {pluralEntity} poistetaan? Jos tiedostoja ei poisteta, {pluralEntity} lisätään uudelleen heti kun uusi skannaus suoritetaan.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Haluatko varmasti poistaa tämän {singularEntity}?} other {Haluatko varmasti poistaa nämä {pluralEntity}?}}\",\n        \"delete_entity_title\": \"{count, plural, one {Poista {singularEntity}} other {Poista {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"...ja kaikki kuvatiedostot, jotka eivät kuulu mihinkään galleriaan.\",\n        \"delete_gallery_files\": \"Poista gallerian kansio/zip -tiedosto ja kaikki kuvat, jotka eivät kuulu mihinkään muuhun galleriaan.\",\n        \"delete_object_desc\": \"Halutatko varmasti poistaa {count, plural, one {{singularEntity}} other {{pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…ja {count} muuta {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Poista {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Elä näytä ennen seuraavaa päivitystä\",\n        \"edit_entity_title\": \"Muokkaa {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Sisällytä liittyvät objektit vientiin\",\n        \"export_title\": \"Vie\",\n        \"lightbox\": {\n            \"delay\": \"Viive (sek)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Sovita sivusuunnassa\",\n                \"fit_to_screen\": \"Sovita ruudulle\",\n                \"label\": \"Näyttötila\",\n                \"original\": \"Alkuperäinen\"\n            },\n            \"options\": \"Asetukset\",\n            \"reset_zoom_on_nav\": \"Resetoi zoomaus kun kuvaa vaihdetaan\",\n            \"scale_up\": {\n                \"description\": \"Skaalaa pienempiä kuvia, jotta ne täyttävät ruudun\",\n                \"label\": \"Sovita skaalaamalla\"\n            },\n            \"scroll_mode\": {\n                \"label\": \"Vieritystila\",\n                \"pan_y\": \"Panoroi Y\",\n                \"zoom\": \"Zoomaus\"\n            }\n        },\n        \"merge\": {\n            \"destination\": \"Kohde\",\n            \"source\": \"Lähde\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Pakota transkoodaus\",\n            \"force_transcodes_tooltip\": \"Oletuksena transkoodaus tehdään vain, mikäli selain ei tue videotiedostoa. Jos tämä valinta on päällä, transkoodaus tehdään vaikka selain näyttäisi tukevan videotiedostoa.\",\n            \"image_previews\": \"Animoidut esikatselukuvat\",\n            \"image_previews_tooltip\": \"Animoidut WebP esikatselut, vaaditaan vain jos esikatselun tyyppi on Animoitu kuva.\",\n            \"marker_image_previews\": \"Animoidut merkkien esikatselukuvat\",\n            \"marker_image_previews_tooltip\": \"Animoidut merkkien WebP esikatselut, vaaditaan vain jos esikatselun tyypiksi on valittu Animoitu kuva.\",\n            \"marker_screenshots\": \"Merkkien esikatselukuvat\",\n            \"marker_screenshots_tooltip\": \"Staattiset JPG-kuvat merkintöihin\",\n            \"markers\": \"Merkkien esikatselut\",\n            \"markers_tooltip\": \"20 sekunnin video jokaisen aikakoodin alusta.\",\n            \"override_preview_generation_options\": \"Ohita Esikatselun generoinnin asetukset\",\n            \"override_preview_generation_options_desc\": \"Ohita Esikatselun generoinnin asetukset tälle tehtävälle. Oletukset voi asettaa Järjestelmä -> Esikatselun generointi.\",\n            \"overwrite\": \"Ylikirjoita olemassaolevat tiedostot\",\n            \"phash\": \"Tiiviste (kaksoiskappaileiden tunnistamiseen)\",\n            \"preview_exclude_end_time_desc\": \"Jätä viimeiset x sekuntia pois esikatseluista. Arvo voi olla sekunneissa tai prosenteissa (esim. 2%) kohtauksen kestosta.\",\n            \"preview_exclude_end_time_head\": \"Lopusta poisjätettävä aika\",\n            \"preview_exclude_start_time_desc\": \"Jätä ensimmäiset x sekuntia pois esikatseluista. Arvo voi olla sekunneissa tai prosenteissa (esim. 2%) kohtauksen kestosta.\",\n            \"preview_exclude_start_time_head\": \"Alusta poisjätettävä aika\",\n            \"preview_generation_options\": \"Esikatselun generoinnin asetukset\",\n            \"preview_options\": \"Esikatseluasetukset\",\n            \"preview_preset_head\": \"Esikatseluidun enkoodaus\",\n            \"transcodes\": \"Transkoodaus\",\n            \"transcodes_tooltip\": \"MP4 muunto niille tiedostomuodoille, joita ei tueta\",\n            \"video_previews\": \"Esikatselu\",\n            \"video_previews_tooltip\": \"Esikatseluviedo, joka näytetään kun hiiri on kohtauksen päällä\"\n        },\n        \"scenes_found\": \"{count} kohtausta löydetty\",\n        \"scrape_entity_query\": \"{entity_type}kaavintajono\",\n        \"scrape_entity_title\": \"{entity_type}kaavinnan tulokset\",\n        \"scrape_results_existing\": \"Olemassaoleva\",\n        \"scrape_results_scraped\": \"Kaavittu\",\n        \"set_image_url_title\": \"Kuvan URL\",\n        \"unsaved_changes\": \"Tallentamattomia muutoksia. Haluatko varmasti poistua?\",\n        \"scrape_results_missing\": \"Puuttuu\",\n        \"stashid_exists_warning\": \"Olemassaoleva stash ID tälle stash-boxille korvataan.\"\n    },\n    \"dimensions\": \"Mitat\",\n    \"director\": \"Ohjaaja\",\n    \"display_mode\": {\n        \"grid\": \"Ruudukko\",\n        \"list\": \"Lista\",\n        \"tagger\": \"Tunnistetila\",\n        \"unknown\": \"Tuntematon\",\n        \"wall\": \"Seinä\"\n    },\n    \"donate\": \"Lahjoita\",\n    \"dupe_check\": {\n        \"description\": \"Muiden kuin 'Täsmälleen sama' laskeminen vie enemmän aikaa. Väärät tulokset voivat olla myös mahdollisia kun käytetään pienempää tarkkuutta.\",\n        \"found_sets\": \"{setCount, plural, one{# kaksoiskappale löydetty.} other {# kaksoiskappaletta löydetty.}}\",\n        \"options\": {\n            \"exact\": \"Täsmälleen sama\",\n            \"high\": \"Korkea\",\n            \"low\": \"Matala\",\n            \"medium\": \"Keskitaso\"\n        },\n        \"search_accuracy_label\": \"Haun tarkkuus\",\n        \"title\": \"Kohtauksien kaksoiskappaleet\"\n    },\n    \"duplicated_phash\": \"Kaksoiskappale (phash)\",\n    \"duration\": \"Kesto\",\n    \"effect_filters\": {\n        \"aspect\": \"Kuvasuhde\",\n        \"blue\": \"Sininen\",\n        \"blur\": \"Sumennus\",\n        \"brightness\": \"Kirkkaus\",\n        \"contrast\": \"Kontrasti\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Vihreä\",\n        \"hue\": \"Sävy\",\n        \"name\": \"Suodin\",\n        \"name_transforms\": \"Kuvamuutokset\",\n        \"red\": \"Punainen\",\n        \"reset_filters\": \"Nollaa suotimet\",\n        \"reset_transforms\": \"Nollaa kuvamuutokset\",\n        \"rotate\": \"Kierrä\",\n        \"rotate_left_and_scale\": \"Kierrä vasemmalle ja skaalaa\",\n        \"rotate_right_and_scale\": \"Kierrä oikealle ja skaalaa\",\n        \"saturation\": \"Värikylläisyys\",\n        \"scale\": \"Skaalaa\",\n        \"warmth\": \"Lämpötila\"\n    },\n    \"empty_server\": \"Lisää kohtauksia palvelimeesi niin näet suosituksia tällä sivulla.\",\n    \"ethnicity\": \"Etninen tausta\",\n    \"existing_value\": \"nykyinen arvo\",\n    \"eye_color\": \"Silmien väri\",\n    \"fake_tits\": \"Tekorinnat\",\n    \"false\": \"Ei\",\n    \"favourite\": \"Suosikki\",\n    \"file\": \"tiedosto\",\n    \"file_count\": \"Tiedostojen määrä\",\n    \"file_info\": \"Tiedoston tiedot\",\n    \"file_mod_time\": \"Tiedostoa muokattu\",\n    \"files\": \"tiedostoa\",\n    \"files_amount\": \"{value} tiedostoa\",\n    \"filesize\": \"Tiedoston koko\",\n    \"filter\": \"Suodatin\",\n    \"filter_name\": \"Suodattimen nimi\",\n    \"filters\": \"Suodattimet\",\n    \"folder\": \"Kansio\",\n    \"framerate\": \"Kuvataajuus\",\n    \"frames_per_second\": \"{value} kuvaa sekunnissa\",\n    \"front_page\": {\n        \"types\": {\n            \"saved_filter\": \"Tallennettu suodatin\"\n        }\n    },\n    \"galleries\": \"Galleriat\",\n    \"gallery\": \"Galleria\",\n    \"gallery_count\": \"Gallerioiden määrä\",\n    \"gender\": \"Sukupuoli\",\n    \"gender_types\": {\n        \"FEMALE\": \"Nainen\",\n        \"INTERSEX\": \"Intersukupuolinen\",\n        \"MALE\": \"Mies\",\n        \"NON_BINARY\": \"Ei-Binäärinen\",\n        \"TRANSGENDER_FEMALE\": \"Transnainen\",\n        \"TRANSGENDER_MALE\": \"Transmies\"\n    },\n    \"hair_color\": \"Hiusten väri\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Yhdistetään\",\n        \"disconnected\": \"Ei yhdistetty\",\n        \"error\": \"Virhe yhdistettäessä Handyyn\",\n        \"missing\": \"Puuttuu\",\n        \"ready\": \"Valmis\",\n        \"syncing\": \"Synkronoidaan palvelimelle\",\n        \"uploading\": \"Ladataan skriptiä\"\n    },\n    \"hasMarkers\": \"Merkit\",\n    \"height\": \"Pituus\",\n    \"height_cm\": \"Pituus (cm)\",\n    \"help\": \"Apua\",\n    \"image\": \"Kuva\",\n    \"image_count\": \"Kuvien määrä\",\n    \"images\": \"Kuvat\",\n    \"include_parent_tags\": \"Sisällytä tunnisteiden ylitunnisteet\",\n    \"include_sub_studios\": \"Sisällytä alistudiot\",\n    \"include_sub_tags\": \"Sisällytä alitunnisteet\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktiivinen\",\n    \"interactive_speed\": \"Interaktiivinen nopeus\",\n    \"isMissing\": \"Puuttuu\",\n    \"last_played_at\": \"Viimeksi toistettu\",\n    \"library\": \"Kirjasto\",\n    \"loading\": {\n        \"generic\": \"Ladataan…\"\n    },\n    \"marker_count\": \"Merkkien määrä\",\n    \"markers\": \"Merkit\",\n    \"measurements\": \"Mitat\",\n    \"media_info\": {\n        \"audio_codec\": \"Audiokodekki\",\n        \"downloaded_from\": \"Ladattu kohteesta\",\n        \"interactive_speed\": \"Interaktiivinen nopeus\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} tuotantovaiheessa\"\n        },\n        \"phash\": \"PHash\",\n        \"play_count\": \"Toistokerrat\",\n        \"play_duration\": \"Toistettu aika\",\n        \"stream\": \"Suoratoisto\",\n        \"video_codec\": \"Videokodekki\"\n    },\n    \"megabits_per_second\": \"{value} megabittiä sekunnissa\",\n    \"metadata\": \"Metadata\",\n    \"name\": \"Nimi\",\n    \"new\": \"Uusi\",\n    \"none\": \"Ei mitään\",\n    \"operations\": \"Operaatiot\",\n    \"organized\": \"Järjestelty\",\n    \"pagination\": {\n        \"first\": \"Ensimmäinen\",\n        \"last\": \"Viimeinen\",\n        \"next\": \"Seuraava\",\n        \"previous\": \"Edellinen\"\n    },\n    \"parent_of\": \"Emostudio seuraaville: {children}\",\n    \"parent_studios\": \"Emostudiot\",\n    \"parent_tag_count\": \"Ylätunnisteiden määrä\",\n    \"parent_tags\": \"Ylätunnisteet\",\n    \"part_of\": \"Osa {parent}\",\n    \"path\": \"Polku\",\n    \"perceptual_similarity\": \"Aistinvarainen samankaltaisuus (phash)\",\n    \"performer\": \"Esiintyjä\",\n    \"performer_age\": \"Esiintyjän ikä\",\n    \"performer_count\": \"Esiintyjien määrä\",\n    \"performer_favorite\": \"Esiintyjä suosikeissa\",\n    \"performer_image\": \"Esiintyjän kuva\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Lisää uusia esiintyjiä\",\n        \"batch_add_performers\": \"Lisää joukko esiintyjiä\",\n        \"batch_update_performers\": \"Päivitä joukko esiintyjiä\",\n        \"current_page\": \"Nykyinen sivu\",\n        \"failed_to_save_performer\": \"Esiintyjän \\\"{performer}\\\" tallentaminen ei onnistunut\",\n        \"name_already_exists\": \"Nimi on jo olemassa\",\n        \"network_error\": \"Verkkovirhe\",\n        \"no_results_found\": \"Ei tuloksia.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} esintyjää prosessoidaan\",\n        \"performer_already_tagged\": \"Esiintyjälle on jo asetettu tunnisteet\",\n        \"performer_selection\": \"Esiintyjän valinta\",\n        \"performer_successfully_tagged\": \"Esiintyjälle on asetettu tunnisteet:\",\n        \"query_all_performers_in_the_database\": \"Kaikki esiintyjät tietokannassa\",\n        \"refresh_tagged_performers\": \"Päivitä tunnistetut esiintyjät\",\n        \"status_tagging_job_queued\": \"Tila: Tunnisteiden asettaminen laitettu jonoon\",\n        \"status_tagging_performers\": \"Tila: Asetetaan esiintyjien tunnisteita\",\n        \"untagged_performers\": \"Esiintyjät joille ei ole asetettu tunnisteita\",\n        \"update_performer\": \"Päivitä esiintyjä\",\n        \"update_performers\": \"Päivitä esiintyjät\"\n    },\n    \"performer_tags\": \"Esiintyjien tunnisteet\",\n    \"performers\": \"Esiintyjät\",\n    \"piercings\": \"Lävistykset\",\n    \"queue\": \"Jono\",\n    \"random\": \"Satunnainen\",\n    \"rating\": \"Arvio\",\n    \"recently_added_objects\": \"Viimeksi lisätyt {objects}\",\n    \"recently_released_objects\": \"Viimeksi julkaistut {objects}\",\n    \"resolution\": \"Resoluutio\",\n    \"scene\": \"Kohtaus\",\n    \"sceneTagger\": \"Kohtauksien tunnistetila\",\n    \"scene_code\": \"Studiokoodi\",\n    \"scene_count\": \"Kohtauksien määrä\",\n    \"scene_created_at\": \"Kohtaus luotu\",\n    \"scene_date\": \"Kohtauksen päiväys\",\n    \"scene_id\": \"Kohtauksen ID\",\n    \"scene_tags\": \"Kohtauksen tunnisteet\",\n    \"scene_updated_at\": \"Kohtaus päivitetty\",\n    \"scenes\": \"Kohtaukset\",\n    \"scenes_updated_at\": \"Kohtaus päivitetty\",\n    \"search_filter\": {\n        \"name\": \"Suodatin\",\n        \"saved_filters\": \"Tallennetut suodattimet\",\n        \"update_filter\": \"Päivitä suodatin\"\n    },\n    \"seconds\": \"sekuntia\",\n    \"settings\": \"Asetukset\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Olemme melkein valmiita. Tarkista vielä asetukset. Voit palata takaisin jos haluat muuttaa jotain, mikä on väärin. Jos kaikki näyttää hyvältä, vahvista ja järjestelmä luodaan.\",\n            \"configuration_file_location\": \"Konfiguraatiotiedoston sijainti:\",\n            \"database_file_path\": \"Tietokannan polku\",\n            \"generated_directory\": \"Generoitujen tiedostojen kansio\",\n            \"nearly_there\": \"Melkein perillä!\",\n            \"stash_library_directories\": \"Stash -kirjaston kansiot\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Luodaan järjestelmää\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Voi ei! Jotain meni vikaan!\",\n            \"something_went_wrong_description\": \"Näyttää siltä, että olet syöttänyt jotain omituista. Palaa takaisin korjataksesi ne. Muussa tapauksessa tee ilmoitus bugista {githubLink} tai pyydä apua {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Järjestelmän asettamisessa meni jotain vikaan. Tässä on virhe jonka saimme: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Tiedostopolku\",\n            \"up_dir\": \"Ylös\"\n        },\n        \"github_repository\": \"Github repository\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Tietokannan varmuuskopion polku (jätä tyhjäksi jos et halua varmuuskopiointia):\",\n            \"backup_recommended\": \"On suositeltavaa, että varmuuskopioit nykyisen tietokannan ennen kuin teet migraation. Voimme tehdä tämän puolestasi. Kopioimme tietokantasi tänne: <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Tietokannan migraatio\",\n            \"migration_failed\": \"Migraatio ei onnistunut\",\n            \"migration_failed_error\": \"Seuraava virhe tuli tietokannan migraatiossa:\",\n            \"migration_failed_help\": \"Tee tarvittavat korjaukset. Muussa tapauksessa tee ilmoitus bugista {githubLink} tai pyydä apua {discordLink}.\",\n            \"migration_irreversible_warning\": \"Migraatio ei ole peruutettavissa. Kun migraatio on suoritettu, tietokanta ei ole enää yhteensopiva stashin vanhempien versioiden kanssa.\",\n            \"migration_required\": \"Migraatio vaaditaan\",\n            \"perform_schema_migration\": \"Suorita migraatio\",\n            \"schema_too_old\": \"Tämänhetkinen stash -tietokannan muodon versio on <strong>{databaseSchema}</strong> ja se pitää muuttaa versioon <strong>{appSchema}</strong>. Tämä versio Stashista ei toimi ilman tietokannan migraatiota. Jos et halua tehdä tätä, sinun pitää palata aikaisempaan versioon stashista.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"tietokannan tiedostonimi (oletus jos tyhjä)\",\n            \"description\": \"Seuraavaksi meidän pitää tietää mistä etsiä sisältösi ja mihin tallennetaa tietokanta, välimuisti ja generoidut tiedostot. Nämä asetukset ovat muutettavissa myöhemmin.\",\n            \"path_to_generated_directory_empty_for_default\": \"polku generated -kansioon (käytetään oletussijaintia jos tyhjä)\",\n            \"set_up_your_paths\": \"Aseta polut\",\n            \"stash_alert\": \"Et ole valinnut yhtään polkua kirjastoon. Mediaa ei voida skannata Stashiin. Oletko aivan varma?\",\n            \"where_can_stash_store_its_database\": \"Mihin Stash voi tallentaa tietokantansa?\",\n            \"where_can_stash_store_its_database_description\": \"Stash käyttää SQLite -tietokantaa sisällön metadatan tallennukseen. Oletuksena <code>stash-go.sqlite</code> luodaan samaan kansioon konfiguraatiotiedoston kanssa. Jos haluat muuttaa tätä, anna absoluuttinen tai relatiivinen tiedostonimi.\",\n            \"where_can_stash_store_its_generated_content\": \"Mihin Stash voi tallentaa generoimansa sisällön?\",\n            \"where_is_your_porn_located\": \"Missä sisältösi on?\",\n            \"where_is_your_porn_located_description\": \"Lisää hakemistoja joissa videot ja kuvat sijaitsevat. Stash käyttää näitä hakemistoja etsiessään videoita ja kuvia.\"\n        },\n        \"stash_setup_wizard\": \"Stashin asetusvelho\",\n        \"success\": {\n            \"getting_help\": \"Apua\",\n            \"help_links\": \"Jos kohtaat ongelmia tai sinulla on kysymyksiä tai ehdotuksia, avaa toki ongelma {githubLink}, tai kysy yhteisöltä {discordLink}.\",\n            \"open_collective\": \"Tutustu {open_collective_link} nähdäksesi miten voit osallistua Stashin jatkuvaan kehittämiseen.\",\n            \"support_us\": \"Tue meitä\",\n            \"thanks_for_trying_stash\": \"Kiitos kun kokeilit Stashia!\",\n            \"your_system_has_been_created\": \"Kaikki hyvin! Järjestelmä on nyt luotu!\"\n        },\n        \"welcome\": {\n            \"in_current_stash_directory\": \"<code>{path}</code> kansioon:\",\n            \"store_stash_config\": \"Minne haluat tallentaa Stash -konfiguraation?\",\n            \"unable_to_locate_config\": \"Mikäli luet tätä, Stash ei löydä olemassaolevaa konfiguraatiota. Tämä velho auttaa sinua uuden konfiguraation luomisessa.\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash käyttää seuraavaa polkua konfiguraatiotiedostolle: <code>{path}</code>\",\n            \"next_step\": \"Kun olet valmis etenemään järjestelmän luontiin, paina Seuraava.\",\n            \"unable_to_locate_specified_config\": \"Mikäli luet tätä, Stash ei löydä konfiguraatiotiedostoa, joka on määritelty joko komentorivillä tai muualla. Tämä velho auttaa sinua uuden konfiguraation luomisessa.\"\n        },\n        \"welcome_to_stash\": \"Tervetuloa Stashiin\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_ids\": \"Stash ID:t\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Mene {endpoint_name} katsoaksesi luonnosta.\",\n        \"submission_failed\": \"Lähettäminen ei onnistunut\",\n        \"submission_successful\": \"Lähettäminen onnistui\",\n        \"submit_update\": \"On jo kohteessa {endpoint_name}\"\n    },\n    \"statistics\": \"Tilastot\",\n    \"stats\": {\n        \"image_size\": \"Kuvien koko\",\n        \"scenes_duration\": \"Kohtausten kesto\",\n        \"scenes_size\": \"Kohtausten koko\",\n        \"scenes_played\": \"Toistetut kohtaukset\",\n        \"total_play_duration\": \"Toiston kokonaiskesto\",\n        \"total_o_count\": \"O-luku yhteensä\",\n        \"total_play_count\": \"Toistomäärä yhteensä\"\n    },\n    \"status\": \"Tila: {statusText}\",\n    \"studio\": \"Studio\",\n    \"studio_depth\": \"Tasot (kaikki: jätä tyhjäksi)\",\n    \"studios\": \"Studiot\",\n    \"sub_tag_count\": \"Alitunnisteiden määrä\",\n    \"sub_tag_of\": \"Tunnisteen {parent} alitunniste\",\n    \"sub_tags\": \"Alitunnisteet\",\n    \"subsidiary_studios\": \"Alistudiot\",\n    \"synopsis\": \"Tiivistelmä\",\n    \"tag\": \"Tunniste\",\n    \"tag_count\": \"Tunnisteiden määrä\",\n    \"tags\": \"Tunnisteet\",\n    \"tattoos\": \"Tatuoinnit\",\n    \"title\": \"Otsikko\",\n    \"toast\": {\n        \"added_entity\": \"Lisätty {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Lisätty generointi työjonoon\",\n        \"created_entity\": \"Luotiin {entity}\",\n        \"default_filter_set\": \"Oletussuodattimet\",\n        \"delete_past_tense\": \"Poistettu {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Generoidaan näyttökuvaa…\",\n        \"merged_tags\": \"Yhdistetyt tunnisteet\",\n        \"rescanning_entity\": \"Skannataan {count, plural, one {{singularEntity}} other {{pluralEntity}}} uudelleen…\",\n        \"saved_entity\": \"Tallennettu {entity}\",\n        \"started_auto_tagging\": \"Aloiteettin automaattinen tunnisteiden asetus\",\n        \"started_generating\": \"Aloitettiin generointi\",\n        \"started_importing\": \"Aloitettiin tuonti\",\n        \"updated_entity\": \"{entity} päivitetty\",\n        \"merged_scenes\": \"Yhdistetyt kohtaukset\",\n        \"removed_entity\": \"Poistettu {count, plural, one {{singularEntity}} other {{pluralEntity}}}\"\n    },\n    \"total\": \"Yhteensä\",\n    \"true\": \"On\",\n    \"twitter\": \"Twitter\",\n    \"updated_at\": \"Päivitetty\",\n    \"url\": \"URL\",\n    \"videos\": \"Videot\",\n    \"view_all\": \"Näytä kaikki\",\n    \"weight\": \"Paino\",\n    \"weight_kg\": \"Paino (kg)\",\n    \"years_old\": \"-vuotias\",\n    \"audio_codec\": \"Audiokodekki\",\n    \"play_count\": \"Toistomäärä\",\n    \"play_duration\": \"Toistettu aika\",\n    \"primary_file\": \"Ensisijainen tiedosto\",\n    \"studio_tagger\": {\n        \"network_error\": \"Verkkovirhe\",\n        \"name_already_exists\": \"Nimi on jo olemassa\",\n        \"failed_to_save_studio\": \"Ei voitu tallentaa studiota \\\"{studio}\\\"\",\n        \"current_page\": \"Nykyinen sivu\",\n        \"query_all_studios_in_the_database\": \"Kaikki studiot tietokannassa\",\n        \"no_results_found\": \"Ei tuloksia.\",\n        \"studio_successfully_tagged\": \"Studion tunnisteiden asettaminen onnistui\",\n        \"studio_selection\": \"Studion valinta\",\n        \"update_studios\": \"Päivitä studiot\",\n        \"update_studio\": \"Päivitä studio\",\n        \"untagged_studios\": \"Studiot ilman tunnisteita\",\n        \"add_new_studios\": \"Lisää uusia studioita\",\n        \"any_names_entered_will_be_queried\": \"Kaikki syötetyt nimet kysytään Stash-Box-etäilmentymästä ja lisätään, jos ne löytyvät. Vain tarkat vastaavuudet katsotaan osuviksi.\",\n        \"batch_update_studios\": \"Erä Päivitä studioita\",\n        \"batch_add_studios\": \"Erä Lisää studioita\"\n    },\n    \"studio_tags\": \"Studion tunnisteet\",\n    \"tag_sub_tag_tooltip\": \"On alitunnisteita\",\n    \"tag_parent_tooltip\": \"On ylätunniste\",\n    \"time\": \"Aika\",\n    \"urls\": \"URLit\",\n    \"unknown_date\": \"Tuntematon päivä\",\n    \"type\": \"Tyyppi\",\n    \"studio_and_parent\": \"Studio ja vanhempi\",\n    \"studio_count\": \"Studion määrä\",\n    \"age_on_date\": \"{age} tuotannossa\",\n    \"stashbox_search\": {\n        \"no_results\": \"Ei tuloksia.\",\n        \"header\": \"Etsi {entityType} StashBoxista\"\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/fr-FR.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Ajouter\",\n        \"add_directory\": \"Ajouter un répertoire\",\n        \"add_entity\": \"Ajout de {entityType}\",\n        \"add_to_entity\": \"Ajouter à {entityType}\",\n        \"allow\": \"Autoriser\",\n        \"allow_temporarily\": \"Autoriser temporairement\",\n        \"anonymise\": \"Anonymiser\",\n        \"apply\": \"Appliquer\",\n        \"assign_stashid_to_parent_studio\": \"Attribuer l'identifiant Stash au studio parent existant et actualiser les métadonnées\",\n        \"auto_tag\": \"Étiquetage automatique\",\n        \"backup\": \"Sauvegarder\",\n        \"browse_for_image\": \"Sélectionner une image…\",\n        \"cancel\": \"Annuler\",\n        \"clean\": \"Nettoyer\",\n        \"clear\": \"Effacer\",\n        \"clear_back_image\": \"Effacer l'image verso\",\n        \"clear_front_image\": \"Effacer l'image recto\",\n        \"clear_image\": \"Effacer l'image\",\n        \"close\": \"Fermer\",\n        \"confirm\": \"Confirmer\",\n        \"continue\": \"Continuer\",\n        \"create\": \"Créer\",\n        \"create_chapters\": \"Créer un chapitre\",\n        \"create_entity\": \"Créer {entityType}\",\n        \"create_marker\": \"Créer un marqueur\",\n        \"create_parent_studio\": \"Créer un studio parent\",\n        \"created_entity\": \"Créé {entity_type} : {entity_name}\",\n        \"customise\": \"Personnaliser\",\n        \"delete\": \"Supprimer\",\n        \"delete_entity\": \"Supprimer {entityType}\",\n        \"delete_file\": \"Supprimer le fichier\",\n        \"delete_file_and_funscript\": \"Supprimer le fichier (et script interactif)\",\n        \"delete_generated_supporting_files\": \"Supprimer les fichiers générés associés\",\n        \"disallow\": \"Refuser\",\n        \"download\": \"Télécharger\",\n        \"download_anonymised\": \"Téléchargement anonymisé\",\n        \"download_backup\": \"Télécharger une sauvegarde\",\n        \"edit\": \"Éditer\",\n        \"edit_entity\": \"Éditer {entityType}\",\n        \"encoding_image\": \"Encodage de l'image…\",\n        \"export\": \"Exporter\",\n        \"export_all\": \"Tout exporter…\",\n        \"find\": \"Rechercher\",\n        \"finish\": \"Terminer\",\n        \"from_file\": \"A partir du fichier…\",\n        \"from_url\": \"A partir de l'URL…\",\n        \"full_export\": \"Export complet\",\n        \"full_import\": \"Import complet\",\n        \"generate\": \"Générer\",\n        \"generate_thumb_default\": \"Générer une vignette par défaut\",\n        \"generate_thumb_from_current\": \"Générer une vignette à partir du contenu actuel\",\n        \"hash_migration\": \"Migration des empreintes\",\n        \"hide\": \"Masquer\",\n        \"hide_configuration\": \"Masquer la configuration\",\n        \"identify\": \"Identifier\",\n        \"ignore\": \"Ignorer\",\n        \"import\": \"Importer…\",\n        \"import_from_file\": \"Importation depuis un fichier\",\n        \"logout\": \"Déconnecter\",\n        \"make_primary\": \"Rendre principal\",\n        \"merge\": \"Fusionner\",\n        \"migrate_blobs\": \"Migrer les blobs\",\n        \"migrate_scene_screenshots\": \"Migrer les vignettes de scène\",\n        \"next_action\": \"Suivant\",\n        \"not_running\": \"arrêt\",\n        \"open_in_external_player\": \"Ouvrir dans un lecteur externe\",\n        \"open_random\": \"Ouvrir au hasard\",\n        \"optimise_database\": \"Optimiser la base de données\",\n        \"overwrite\": \"Écraser\",\n        \"play_random\": \"Lecture aléatoire\",\n        \"play_selected\": \"Lire la sélection\",\n        \"preview\": \"Aperçu\",\n        \"previous_action\": \"Précédent\",\n        \"reassign\": \"Réaffecter\",\n        \"refresh\": \"Rafraichir\",\n        \"reload_plugins\": \"Recharger les plugins\",\n        \"reload_scrapers\": \"Recharger les extracteurs de contenu\",\n        \"remove\": \"Supprimer\",\n        \"remove_from_gallery\": \"Supprimer de la galerie\",\n        \"rename_gen_files\": \"Renommer les fichiers générés\",\n        \"rescan\": \"Analyser à nouveau\",\n        \"reshuffle\": \"Mélanger à nouveau\",\n        \"running\": \"en cours d'exécution\",\n        \"save\": \"Sauvegarder\",\n        \"save_delete_settings\": \"Utiliser ces options par défaut lors de la suppression\",\n        \"save_filter\": \"Sauvegarder le filtre\",\n        \"scan\": \"Analyser\",\n        \"scrape\": \"Extraire\",\n        \"scrape_query\": \"Requête d'extraction\",\n        \"scrape_scene_fragment\": \"Extraire par fragment\",\n        \"scrape_with\": \"Extraire avec…\",\n        \"search\": \"Recherche\",\n        \"select_all\": \"Tout sélectionner\",\n        \"select_entity\": \"Sélectionner {entityType}\",\n        \"select_folders\": \"Sélectionner des répertoires\",\n        \"select_none\": \"Ne rien sélectionner\",\n        \"selective_auto_tag\": \"Étiquetage automatique sélectif\",\n        \"selective_clean\": \"Nettoyage sélectif\",\n        \"selective_scan\": \"Analyse sélective\",\n        \"set_as_default\": \"Définir par défaut\",\n        \"set_back_image\": \"Image verso…\",\n        \"set_front_image\": \"Image recto…\",\n        \"set_image\": \"Définir l'image…\",\n        \"show\": \"Montrer\",\n        \"show_configuration\": \"Afficher la configuration\",\n        \"skip\": \"Passer\",\n        \"split\": \"Diviser\",\n        \"stop\": \"Stop\",\n        \"submit\": \"Soumettre\",\n        \"submit_stash_box\": \"Soumettre à Stash-Box\",\n        \"submit_update\": \"Soumettre une mise à jour\",\n        \"swap\": \"Permuter\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Êtes-vous sûr de vouloir nettoyer ? Cette opération supprimera les informations de la base de données et le contenu généré pour toutes les scènes et galeries qui ne se trouvent plus dans le système de fichiers.\",\n            \"dry_mode_selected\": \"Essais à blanc. Aucune suppression réelle n'aura lieu, seulement une journalisation.\",\n            \"import_warning\": \"Êtes-vous sûr de vouloir importer ? Cela supprimera la base de données et la réimportera à partir de vos métadonnées exportées.\"\n        },\n        \"temp_disable\": \"Désactiver temporairement…\",\n        \"temp_enable\": \"Activer temporairement…\",\n        \"unset\": \"Retirer\",\n        \"use_default\": \"Utiliser par défaut\",\n        \"view_random\": \"Visionner au hasard\",\n        \"add_manual_date\": \"Ajouter une date manuellement\",\n        \"add_o\": \"Ajouter un O\",\n        \"add_play\": \"Ajouter une lecture\",\n        \"choose_date\": \"Choisir une date\",\n        \"clear_date_data\": \"Effacer les données de date\",\n        \"copy_to_clipboard\": \"Copier dans le presse-papier\",\n        \"disable\": \"Désactiver\",\n        \"enable\": \"Activer\",\n        \"reload\": \"Actualiser\",\n        \"remove_date\": \"Supprimer la date\",\n        \"clean_generated\": \"Nettoyer les fichiers générés\",\n        \"view_history\": \"Voir l'historique\",\n        \"reset_cover\": \"Rétablir la vignette par défaut\",\n        \"reset_play_duration\": \"Réinitialiser la durée de lecture\",\n        \"reset_resume_time\": \"Réinitialiser le temps de reprise\",\n        \"set_cover\": \"Définir comme vignette\",\n        \"remove_from_containing_group\": \"Supprimer du groupe\",\n        \"add_sub_groups\": \"Ajouter des groupes affiliés\",\n        \"sidebar\": {\n            \"close\": \"Fermer la barre latérale\",\n            \"open\": \"Ouvrir la barre latérale\",\n            \"toggle\": \"Barre latérale\"\n        },\n        \"show_count_results\": \"Afficher {count} résultats\",\n        \"show_results\": \"Afficher les résultats\",\n        \"play\": \"Lecture\",\n        \"load\": \"Charger\",\n        \"load_filter\": \"Charger un filtre\",\n        \"add_stash_id\": \"Ajouter un identifiant Stash\",\n        \"create_new\": \"Créer un nouveau\",\n        \"save_and_new\": \"Enregistrer et nouveau\",\n        \"invert_selection\": \"Inverser la sélection\",\n        \"select_directory\": \"Sélectionner un répertoire\",\n        \"reveal_in_file_manager\": \"Afficher dans le gestionnaire de fichiers\",\n        \"from_clipboard\": \"Depuis le presse-papiers\",\n        \"selective_generate\": \"Génération sélective\",\n        \"exclude_lowercase\": \"exclure\",\n        \"create_parent_tag\": \"Créer une étiquette parente\"\n    },\n    \"actions_name\": \"Actions\",\n    \"age\": \"Âge\",\n    \"aliases\": \"Alias\",\n    \"all\": \"tout\",\n    \"also_known_as\": \"Également connu comme\",\n    \"appears_with\": \"Apparaît avec\",\n    \"ascending\": \"Ascendant\",\n    \"audio_codec\": \"Codec audio\",\n    \"average_resolution\": \"Résolution moyenne\",\n    \"between_and\": \"et\",\n    \"birth_year\": \"Année de naissance\",\n    \"birthdate\": \"Date de naissance\",\n    \"bitrate\": \"Débit\",\n    \"blobs_storage_type\": {\n        \"database\": \"Base de données\",\n        \"filesystem\": \"Système de fichier\"\n    },\n    \"captions\": \"Sous-titres\",\n    \"career_length\": \"Durée de carrière\",\n    \"chapters\": \"Chapitres\",\n    \"circumcised\": \"Circoncis\",\n    \"circumcised_types\": {\n        \"CUT\": \"Coupé\",\n        \"UNCUT\": \"Non coupé\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Instance Stash-Box active :\",\n            \"blacklist_desc\": \"Les éléments de la liste noire sont exclus des requêtes. Notez que ce sont des expressions régulières insensibles à la casse. Certains caractères doivent être échappés par une barre oblique inversée : {chars_require_escape}\",\n            \"blacklist_label\": \"Liste noire\",\n            \"mark_organized_desc\": \"Marquer immédiatement la scène comme Organisée après avoir cliqué sur le bouton Enregistrer.\",\n            \"mark_organized_label\": \"Marquer comme Organisé lors de l'enregistrement\",\n            \"query_mode_auto\": \"Automatique\",\n            \"query_mode_auto_desc\": \"Utilise les métadonnées si présentes, ou le nom de fichier\",\n            \"query_mode_dir\": \"Répertoire\",\n            \"query_mode_dir_desc\": \"Utilise uniquement le répertoire parent du fichier vidéo\",\n            \"query_mode_filename\": \"Nom de fichier\",\n            \"query_mode_filename_desc\": \"Utilise uniquement le nom du fichier\",\n            \"query_mode_label\": \"Mode de requête\",\n            \"query_mode_metadata\": \"Métadonnées\",\n            \"query_mode_metadata_desc\": \"Utilise uniquement les métadonnées\",\n            \"query_mode_path\": \"Chemin\",\n            \"query_mode_path_desc\": \"Utilise le chemin complet du fichier\",\n            \"set_cover_desc\": \"Remplacer la vignette de la scène si une est trouvée.\",\n            \"set_cover_label\": \"Définir la vignette de la scène\",\n            \"set_tag_desc\": \"Attache des étiquettes à la scène, en écrasant ou en fusionnant avec des étiquettes existantes.\",\n            \"set_tag_label\": \"Définir les étiquettes\",\n            \"source\": \"Source\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Élément de liste noire en double\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Genres des performeurs\",\n                \"description\": \"Les performeurs de ces genres seront affichés lors du marquage des scènes.\"\n            }\n        },\n        \"noun_query\": \"Requête\",\n        \"results\": {\n            \"duration_off\": \"Durée différente d'au moins {number}s\",\n            \"duration_unknown\": \"Durée inconnue\",\n            \"fp_found\": \"{fpCount, plural, =0 {Aucune nouvelle correspondance d'empreinte trouvée} other {# nouvelles correspondances d'empreintes trouvées}}\",\n            \"fp_matches\": \"La durée correspond\",\n            \"fp_matches_multi\": \"La durée correspond à {matchCount}/{durationsLength} empreintes\",\n            \"hash_matches\": \"{hash_type} est une correspondance\",\n            \"match_failed_already_tagged\": \"Scène déjà étiquetée\",\n            \"match_failed_no_result\": \"Aucun résultat trouvé\",\n            \"match_success\": \"Scène étiquetée avec succès\",\n            \"phash_matches\": \"{count} empreintes correspondantes\",\n            \"unnamed\": \"Sans nom\"\n        },\n        \"verb_match_fp\": \"Empreintes correspondantes\",\n        \"verb_matched\": \"Associé\",\n        \"verb_scrape_all\": \"Extraire tout\",\n        \"verb_submit_fp\": \"Soumettre {fpCount, plural, one{# Empreinte} other{# Empreintes}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} scènes incomparables\",\n        \"verb_add_as_alias\": \"Ajouter le nom récupéré comme alias\",\n        \"verb_link_existing\": \"Lien vers existant\",\n        \"verb_match_tag\": \"Étiquette correspondante\",\n        \"verb_scrape_selected\": \"Extraire la sélection\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Empreinte de construction :\",\n            \"build_time\": \"Date de construction :\",\n            \"check_for_new_version\": \"Rechercher une nouvelle version\",\n            \"latest_version\": \"Dernière version\",\n            \"latest_version_build_hash\": \"Empreinte de construction de la dernière version :\",\n            \"new_version_notice\": \"[Nouveautés]\",\n            \"release_date\": \"Date de sortie :\",\n            \"stash_discord\": \"Rejoignez notre chaine {url}\",\n            \"stash_home\": \"Accueil de Stash sur {url}\",\n            \"stash_open_collective\": \"Soutenez-nous via {url}\",\n            \"stash_wiki\": \"{url} de Stash\",\n            \"version\": \"Version actuelle\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Chemins de l'application\"\n        },\n        \"categories\": {\n            \"about\": \"A propos\",\n            \"changelog\": \"Journal des modifications\",\n            \"interface\": \"Interface\",\n            \"logs\": \"Journaux\",\n            \"metadata_providers\": \"Fournisseurs de métadonnées\",\n            \"plugins\": \"Plugins\",\n            \"scraping\": \"Extraction de données\",\n            \"security\": \"Sécurité\",\n            \"services\": \"Services\",\n            \"system\": \"Système\",\n            \"tasks\": \"Tâches\",\n            \"tools\": \"Outils\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Autoriser {tempIP}\",\n            \"allowed_ip_addresses\": \"Adresses IP autorisées\",\n            \"allowed_ip_temporarily\": \"IP autorisée temporairement\",\n            \"default_ip_whitelist\": \"Liste blanche d'adresses IP\",\n            \"default_ip_whitelist_desc\": \"Adresses IP par défaut autorisées pour accéder à DLNA. Utiliser {wildcard} pour autoriser toutes les adresses IP.\",\n            \"disabled_dlna_temporarily\": \"Désactivation temporaire de DLNA\",\n            \"disallowed_ip\": \"IP non autorisé\",\n            \"enabled_by_default\": \"Activé par défaut\",\n            \"enabled_dlna_temporarily\": \"Activation temporaire de DLNA\",\n            \"network_interfaces\": \"Interfaces\",\n            \"network_interfaces_desc\": \"Interfaces sur lesquelles exposer le serveur DLNA. Une liste vide entraîne l'exécution sur toutes les interfaces. Nécessite le redémarrage de DLNA après modification.\",\n            \"recent_ip_addresses\": \"Adresses IP récentes\",\n            \"server_display_name\": \"Nom d'affichage du serveur\",\n            \"server_display_name_desc\": \"Nom d'affichage du serveur DLNA. Par défaut {server_name} si vide.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Le comportement temporaire a été annulé avec succès\",\n            \"until_restart\": \"jusqu'au redémarrage\",\n            \"video_sort_order\": \"Ordre de tri vidéo par défaut\",\n            \"video_sort_order_desc\": \"Commande pour trier les vidéos par défaut.\",\n            \"server_port\": \"Port du serveur\",\n            \"server_port_desc\": \"Port sur lequel le serveur DLNA doit fonctionner. Nécessite un redémarrage du DLNA après modification.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"Clé API\",\n                \"api_key_desc\": \"Clé API pour les systèmes externes. Nécessaire uniquement lorsque le nom d'utilisateur/mot de passe est configuré. Le nom d'utilisateur doit être enregistré avant de générer la clé API.\",\n                \"authentication\": \"Authentification\",\n                \"clear_api_key\": \"Effacer la clé API\",\n                \"credentials\": {\n                    \"description\": \"Identifiants pour restreindre l'accès à Stash.\",\n                    \"heading\": \"Identifiants\"\n                },\n                \"generate_api_key\": \"Générer une clé API\",\n                \"log_file\": \"Fichier journal\",\n                \"log_file_desc\": \"Chemin d'accès au fichier de sortie de journalisation. Vide pour désactiver la journalisation du fichier. Nécessite un redémarrage.\",\n                \"log_http\": \"Journaliser les accès HTTP\",\n                \"log_http_desc\": \"Journalise les accès HTTP dans le terminal. Nécessite un redémarrage.\",\n                \"log_to_terminal\": \"Journaliser dans le terminal\",\n                \"log_to_terminal_desc\": \"Journalise dans le terminal en complément d'un fichier. Toujours valide si la journalisation des fichiers est désactivée. Nécessite un redémarrage.\",\n                \"maximum_session_age\": \"Durée maximum de session\",\n                \"maximum_session_age_desc\": \"Temps d'inactivité maximal avant expiration d'une session de connexion, en secondes. Nécessite un redémarrage.\",\n                \"password\": \"Mot de passe\",\n                \"password_desc\": \"Mot de passe pour accéder à Stash. Laisser vide pour désactiver l'authentification utilisateur\",\n                \"stash-box_integration\": \"Intégration de Stash-Box\",\n                \"username\": \"Nom d'utilisateur\",\n                \"username_desc\": \"Nom d'utilisateur pour accéder à Stash. Laisser vide pour désactiver l'authentification utilisateur\",\n                \"log_file_max_size\": \"Taille maximale du journal\",\n                \"log_file_max_size_desc\": \"Taille maximale en mégaoctets du fichier journal avant compression. 0 Mo est désactivé. Nécessite un redémarrage.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Emplacement de sauvegarde des bases de données SQLite.\",\n                \"heading\": \"Chemin du répertoire de sauvegarde\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Emplacement de stockage des données binaires dans le système de fichiers. Uniquement applicable lors de l'utilisation du type de stockage blob du système de fichiers. AVERTISSEMENT : La modification de ce paramètre nécessite le déplacement manuel des données existantes.\",\n                \"heading\": \"Chemin du système de fichiers des données binaires\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Emplacement de stockage des données binaires telles que les vignettes de scènes, images de performeurs, studios et étiquettes. Après avoir modifié cette valeur, les données existantes doivent être migrées à l'aide de la tâche Migrer les blobs. Voir la page Tâches de migration.\",\n                \"heading\": \"Type d'enregistrement de données binaires\"\n            },\n            \"cache_location\": \"Emplacement du cache. Requis si le flux est diffusé à l'aide de HLS (comme sur les appareils Apple) ou de DASH.\",\n            \"cache_path_head\": \"Chemin du cache\",\n            \"calculate_md5_and_ohash_desc\": \"Calculer la somme de contrôle MD5 en complément de oshash. Son activation entraîne un ralentissement des analyses initiales. Le hachage du nom de fichier doit être défini sur oshash pour désactiver le calcul MD5.\",\n            \"calculate_md5_and_ohash_label\": \"Calculer le MD5 pour les vidéos\",\n            \"check_for_insecure_certificates\": \"Vérifier les certificats non sécurisés\",\n            \"check_for_insecure_certificates_desc\": \"Certains sites utilisent des certificats SSL non sécurisés. Lorsque cette option est décochée, l'extracteur de contenu ignore la vérification des certificats non sécurisés et autorise l'extraction de ces sites. Si vous obtenez une erreur de certificat lors de l'extraction, décochez cette option.\",\n            \"chrome_cdp_path\": \"Chemin Chrome CDP (Chrome Debugging Protocol)\",\n            \"chrome_cdp_path_desc\": \"Chemin de l'exécutable Chrome, ou adresse distante (commençant par http:// ou https://, par exemple http://localhost:9222/json/version) d'une instance de Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Si vrai, crée par défaut des galeries à partir des répertoires contenant des images. Créer un fichier appelé .forcegallery ou .nogallery dans un répertoire pour remplacer ce paramètre.\",\n            \"create_galleries_from_folders_label\": \"Créer des galeries à partir de répertoires contenant des images\",\n            \"database\": \"Base de données\",\n            \"db_path_head\": \"Chemin de la base de données\",\n            \"directory_locations_to_your_content\": \"Emplacements de votre contenu\",\n            \"excluded_image_gallery_patterns_desc\": \"Expressions régulières de fichiers images et galeries ou de chemins d'accès à exclure de l'analyse et à ajouter aux tâches de nettoyage.\",\n            \"excluded_image_gallery_patterns_head\": \"Modèles d'image ou galerie exclus\",\n            \"excluded_video_patterns_desc\": \"Expressions régulières de fichiers vidéo ou de chemins d'accès à exclure de l'analyse et à ajouter aux tâches de nettoyage.\",\n            \"excluded_video_patterns_head\": \"Modèles de vidéo exclus\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"Utiliser le matériel disponible pour encoder la vidéo en vue d'un transcodage en temps réel.\",\n                    \"heading\": \"Encodage matériel FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avancé : Arguments supplémentaires à passer à FFmpeg avant le champ d'entrée lors du transcodage vidéo en temps réel.\",\n                        \"heading\": \"Arguments d'entrée du transcodage FFmpeg en temps réel\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avancé : Arguments supplémentaires à passer à FFmpeg avant le champ de sortie lors du transcodage vidéo en temps réel.\",\n                        \"heading\": \"Arguments de sortie du transcodage FFmpeg en temps réel\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avancé : Arguments additionnels à transmettre à FFmpeg avant le champ d'entrée lors de la génération de la vidéo.\",\n                        \"heading\": \"Arguments d'entrée de FFmpeg transcode\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avancé : Arguments supplémentaires à passer à FFmpeg avant le champ de sortie lors de la génération de la vidéo.\",\n                        \"heading\": \"Arguments de sortie du transcodage FFmpeg\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"Télécharger FFmpeg\",\n                    \"description\": \"Télécharge FFmpeg dans le répertoire de configuration et nettoie les chemins de FFmpeg et FFprobe pour les résoudre depuis le répertoire de configuration.\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"Chemin de l'exécutable FFmpeg\",\n                    \"description\": \"Chemin vers l'exécutable de FFmpeg (pas seulement le répertoire). Si vide, FFmpeg sera résolu à partir de l'environnement via $PATH, le répertoire de configuration, ou depuis $HOME/.stash.\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Chemin vers l'exécutable de FFprobe (pas seulement le répertoire). Si vide, FFprobe sera résolu à partir de l'environnement via $PATH, le répertoire de configuration, ou depuis $HOME/.stash.\",\n                    \"heading\": \"Chemin de l'exécutable FFprobe\"\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"Inclure l'amplitude dans les cartes thermiques générées\",\n            \"funscript_heatmap_draw_range_desc\": \"Dessiner l'étendue du mouvement sur l'axe des ordonnées de la carte thermique générée. Les cartes thermiques existantes devront être régénérées après modifications.\",\n            \"gallery_cover_regex_desc\": \"Expressions régulières utilisées pour identifier une image comme vignette de la galerie.\",\n            \"gallery_cover_regex_label\": \"Modèle de vignette de la galerie\",\n            \"gallery_ext_desc\": \"Liste d'extensions de fichiers séparées par des virgules qui seront reconnues comme des archives ZIP de la galerie.\",\n            \"gallery_ext_head\": \"Extensions ZIP de la galerie\",\n            \"generated_file_naming_hash_desc\": \"Utilisez MD5 ou oshash pour le nommage des fichiers générés. Le modifier exige que toutes les scènes soient renseignées avec une valeur MD5/oshash appropriée. Après avoir modifié cette valeur, les fichiers générés existants devront être migrés ou régénérés. Voir la page Tâches pour la migration.\",\n            \"generated_file_naming_hash_head\": \"Empreinte pour le nommage des fichiers générés\",\n            \"generated_files_location\": \"Emplacement des fichiers générés (marqueurs de scène, aperçus de scène, sprites, etc.).\",\n            \"generated_path_head\": \"Chemin des fichiers générés\",\n            \"hashing\": \"Hachage\",\n            \"heatmap_generation\": \"Génération de la carte thermique du script interactif\",\n            \"image_ext_desc\": \"Liste d'extensions de fichiers séparées par des virgules qui seront reconnues comme des images.\",\n            \"image_ext_head\": \"Extensions des images\",\n            \"include_audio_desc\": \"Inclure le flux audio lors de la génération des aperçus.\",\n            \"include_audio_head\": \"Inclure l'audio\",\n            \"logging\": \"Journalisation\",\n            \"maximum_streaming_transcode_size_desc\": \"Résolution maximale pour les flux transcodés.\",\n            \"maximum_streaming_transcode_size_head\": \"Résolution maximale du flux transcodé\",\n            \"maximum_transcode_size_desc\": \"Résolution maximale pour les transcodes générés.\",\n            \"maximum_transcode_size_head\": \"Résolution maximale de transcodage\",\n            \"metadata_path\": {\n                \"description\": \"Emplacement utilisé lors d'une exportation ou d'une importation complète.\",\n                \"heading\": \"Chemin des métadonnées\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Définir à 0 pour une détection automatique. Avertissement exécuter plus de tâches que ce qui est nécessaire pour atteindre une utilisation à 100% du CPU diminuera les performances et pourra causer d'autres problèmes.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Nombre de tâches parallèles pour l'analyse et la génération\",\n            \"parallel_scan_head\": \"Analyse ou génération en parallèle\",\n            \"preview_generation\": \"Génération d'aperçu\",\n            \"python_path\": {\n                \"description\": \"Chemin de l'exécutable python (pas uniquement le répertoire). Utilisé par les extracteurs de contenu et les plugins. Si vide, Python sera résolu à partir de l'environnement.\",\n                \"heading\": \"Chemin de l'exécutable Python\"\n            },\n            \"scraper_user_agent\": \"Agent utilisateur de l'extracteur\",\n            \"scraper_user_agent_desc\": \"Chaîne agent utilisateur utilisée par les requêtes HTTP lors de l'extraction de contenu.\",\n            \"scrapers_path\": {\n                \"description\": \"Emplacement des fichiers de configuration des extracteurs de contenu.\",\n                \"heading\": \"Chemin des extracteurs de contenu\"\n            },\n            \"scraping\": \"Extraction de données\",\n            \"sqlite_location\": \"Emplacement du fichier de base de données SQLite (nécessite un redémarrage). AVERTISSEMENT : Le stockage de la base de données sur un système différent de celui qui exécute le serveur Stash (c'est-à-dire sur le réseau) n'est pas pris en charge !\",\n            \"video_ext_desc\": \"Liste d'extensions de fichiers séparées par des virgules qui seront reconnues comme des vidéos.\",\n            \"video_ext_head\": \"Extensions de fichiers vidéo\",\n            \"video_head\": \"Vidéo\",\n            \"plugins_path\": {\n                \"description\": \"Emplacement du répertoire des fichiers de configuration des plugins.\",\n                \"heading\": \"Chemin des plugins\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"Chemin où les fichiers supprimés seront déplacés au lieu d'être définitivement supprimés. Laissez ce champ vide pour supprimer définitivement les fichiers.\",\n                \"heading\": \"Chemin de la corbeille\"\n            },\n            \"sprite_generation_head\": \"Génération de sprites\",\n            \"sprite_interval_desc\": \"Temps en secondes entre chaque sprite généré.\",\n            \"sprite_interval_head\": \"Intervalle entre sprites\",\n            \"sprite_maximum_desc\": \"Nombre maximal de sprites à générer pour une scène. Définir sur 0 pour désactiver la limite.\",\n            \"sprite_maximum_head\": \"Sprites maximums\",\n            \"sprite_minimum_desc\": \"Nombre minimum de sprites à générer pour une scène\",\n            \"sprite_minimum_head\": \"Sprites minimums\",\n            \"sprite_screenshot_size_desc\": \"Taille désirée pour chaque sprite en pixels.\",\n            \"sprite_screenshot_size_head\": \"Taille des sprites\",\n            \"use_custom_sprite_interval_head\": \"Utiliser un intervalle de sprite personnalisé\",\n            \"use_custom_sprite_interval_desc\": \"Active l'intervalle de sprite personnalisé selon les paramètres ci-dessous.\"\n        },\n        \"library\": {\n            \"exclusions\": \"Exceptions\",\n            \"gallery_and_image_options\": \"Options des galeries et images\",\n            \"media_content_extensions\": \"Extensions du contenu multimédia\"\n        },\n        \"logs\": {\n            \"log_level\": \"Niveau de journalisation\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Accroches\",\n            \"triggers_on\": \"Déclenche sur\",\n            \"installed_plugins\": \"Plugins installés\",\n            \"available_plugins\": \"Plugins disponibles\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"Métadonnées {entityType}\",\n            \"entity_scrapers\": \"Extracteurs de {entityType}\",\n            \"excluded_tag_patterns_desc\": \"Expressions régulières de noms d'étiquettes à exclure des résultats de l'extraction.\",\n            \"excluded_tag_patterns_head\": \"Modèles d'étiquette excluse\",\n            \"scraper\": \"Extracteur\",\n            \"scrapers\": \"Extracteurs\",\n            \"search_by_name\": \"Recherche par nom\",\n            \"supported_types\": \"Types supportés\",\n            \"supported_urls\": \"Adresses Web\",\n            \"installed_scrapers\": \"Extracteurs de contenu installés\",\n            \"available_scrapers\": \"Extracteurs de contenu disponibles\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Ajouter une instance Stash-Box\",\n            \"api_key\": \"Clé API\",\n            \"description\": \"Stash-Box simplifie l'étiquetage automatique des scènes et performeurs en se basant sur des empreintes numériques et noms de fichiers.\\nLe point de terminaison et la clé API se trouvent sur la page de votre compte d'instance stash-box. Les noms sont requis lorsque plusieurs instances sont ajoutées.\",\n            \"endpoint\": \"Point de terminaison\",\n            \"graphql_endpoint\": \"Point de terminaison GraphQL\",\n            \"name\": \"Nom\",\n            \"title\": \"Points de terminaison Stash-Box\",\n            \"max_requests_per_minute\": \"Requêtes maximales par minute\",\n            \"max_requests_per_minute_description\": \"Utiliser la valeur par défaut de {defaultValue} si définie à 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transcodage\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"{operation_name} ajouté·e à la liste des tâches\",\n            \"anonymise_and_download\": \"Effectue une copie anonymisée de la base de données et télécharge le fichier résultant.\",\n            \"anonymise_database\": \"Faire une copie de la base de données dans le répertoire de sauvegarde, en anonymisant toutes données sensibles. Celle-ci peut être transmise aux autres pour diagnostic et débogage. La base de données originale n'est pas modifiée. La base de données anonymisée utilise le format de nom de fichier {filename_format}.\",\n            \"anonymising_database\": \"Anonymisation de la base de données\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Étiquetage automatique de tous les chemins\",\n                \"auto_tagging_paths\": \"Étiquetage automatique des chemins suivants\"\n            },\n            \"auto_tag_based_on_filenames\": \"Étiquetage automatique du contenu basé sur les chemins d'accès des fichiers.\",\n            \"auto_tagging\": \"Étiquetage automatique\",\n            \"backing_up_database\": \"Sauvegarde de la base de données\",\n            \"backup_and_download\": \"Effectue une sauvegarde de la base de données et télécharge le fichier résultant.\",\n            \"cleanup_desc\": \"Vérifier les fichiers manquants et les supprimer de la base de données. Cette action est irréversible.\",\n            \"data_management\": \"Gestion des données\",\n            \"defaults_set\": \"Les valeurs par défaut ont été définies et seront utilisées en cliquant sur le bouton {action} de la page Tâches.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Ne pas inclure l'extension du fichier dans le titre\",\n            \"empty_queue\": \"Aucune tâche en cours d'exécution.\",\n            \"export_to_json\": \"Exporter le contenu de la base de données au format JSON dans le répertoire des métadonnées.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Génération pour les scènes à partir des chemins suivants\",\n                \"generating_scenes\": \"Génération pour {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"Générer des aperçus pour les séquences d'images\",\n            \"generate_desc\": \"Générer les supports images, sprites, vidéos, vtt et autres fichiers associés.\",\n            \"generate_phashes_during_scan\": \"Générer les empreintes perceptuelles de la vidéo\",\n            \"generate_phashes_during_scan_tooltip\": \"Pour la déduplication et l'identification des scènes.\",\n            \"generate_previews_during_scan\": \"Générer des aperçus d'images animées\",\n            \"generate_previews_during_scan_tooltip\": \"Génère également des aperçus animés (webp), uniquement requis lorsque le mode de prévisualisation Scène/Mur de marqueurs est défini sur Image animée. Lors de la navigation, ils utilisent moins de ressources CPU que les aperçus vidéo, mais sont générés en complément de ceux-ci et constituent des fichiers plus volumineux.\",\n            \"generate_sprites_during_scan\": \"Générer les sprites de progression\",\n            \"generate_thumbnails_during_scan\": \"Générer des vignettes pour les images\",\n            \"generate_video_covers_during_scan\": \"Générer les vignettes de scène\",\n            \"generate_video_previews_during_scan\": \"Générer les aperçus\",\n            \"generate_video_previews_during_scan_tooltip\": \"Générer des aperçus vidéo joués lors du survol d'une scène\",\n            \"generated_content\": \"Contenu généré\",\n            \"identify\": {\n                \"and_create_missing\": \"et créer les manquants\",\n                \"create_missing\": \"Créer les manquants\",\n                \"default_options\": \"Options par défaut\",\n                \"description\": \"Définir automatiquement les métadonnées des scènes en utilisant les sources Stash-Box et extracteur de contenu.\",\n                \"explicit_set_description\": \"Les options suivantes seront utilisées si elles ne sont pas remplacées par les options spécifiques à la source.\",\n                \"field\": \"Champ\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Options de champ\",\n                \"heading\": \"Identifier\",\n                \"identifying_from_paths\": \"Identifier des scènes à partir des chemins suivants\",\n                \"identifying_scenes\": \"Identifier {num} {scene}\",\n                \"include_male_performers\": \"Inclure les performeurs masculins\",\n                \"set_cover_images\": \"Définir les vignettes\",\n                \"set_organized\": \"Définir le drapeau organisé\",\n                \"skip_multiple_matches\": \"Ignorer les correspondances qui ont plusieurs résultats\",\n                \"skip_multiple_matches_tooltip\": \"Si cette option n'est pas activée et que plusieurs résultats sont retournés, un seul sera aléatoirement choisi pour correspondre\",\n                \"skip_single_name_performers\": \"Ignorer les performeurs à nom unique sans désambiguïsation\",\n                \"skip_single_name_performers_tooltip\": \"Si cette option n'est pas activée, les performeurs qui sont souvent génériques, comme Samantha ou Olga, correspondront\",\n                \"source\": \"Source\",\n                \"source_options\": \"Options pour {source}\",\n                \"sources\": \"Sources\",\n                \"strategy\": \"Stratégie\",\n                \"tag_skipped_matches\": \"Étiqueter les correspondances ignorées avec\",\n                \"tag_skipped_matches_tooltip\": \"Créer une étiquette telle que \\\"Identifier : plusieurs correspondances\\\" filtrable dans la vue Étiquetage de scène et déterminer la bonne correspondance manuellement\",\n                \"tag_skipped_performer_tooltip\": \"Créer une étiquette telle que \\\"Identifier : Performeur à nom unique\\\" filtrable dans la vue Étiquetage de scène et déterminer comment les traiter\",\n                \"tag_skipped_performers\": \"Étiqueter les performeurs ignorés avec\",\n                \"performer_genders\": \"Genres des performeurs\",\n                \"performer_genders_desc\": \"Les performeurs dont le genre a été sélectionné seront inclus lors de l'identification.\"\n            },\n            \"import_from_exported_json\": \"Importation à partir du JSON exporté dans le répertoire des métadonnées. Efface la base de données existante.\",\n            \"incremental_import\": \"Importation incrémentielle à partir d'un fichier zip d'exportation fourni.\",\n            \"job_queue\": \"File d'attente des tâches\",\n            \"maintenance\": \"Maintenance\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Supprimer les anciennes données\",\n                \"description\": \"Migrer les blobs vers le système de stockage de blobs actuel. Cette migration doit être exécutée après avoir modifié le système de stockage des blobs. Il est possible de supprimer les anciennes données après migration.\"\n            },\n            \"migrate_hash_files\": \"Utilisé après modification de l'empreinte des fichiers générés pour renommer les existants au nouveau format.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Supprimer les fichiers de vignette\",\n                \"description\": \"Migrer les vignettes de scène dans le nouveau système de stockage blob. Cette migration doit être exécutée après avoir migré un système existant vers la version 0.20. Il est possible de supprimer les anciennes vignettes après migration.\",\n                \"overwrite_existing\": \"Remplacer les blobs existants par les données de vignettes\"\n            },\n            \"migrations\": \"Migrations\",\n            \"only_dry_run\": \"Effectuer un essai à blanc. Ne rien supprimer\",\n            \"optimise_database\": \"Essayer d'améliorer les performances en analysant et en reconstruisant l'ensemble de la base de données.\",\n            \"optimise_database_warning\": \"Attention : pendant l'exécution de cette tâche, toute opération modifiant la base de données échouera et, selon la taille de la base de données, elle peut prendre plusieurs minutes pour aboutir. Elle requiert au minimum autant d'espace disque libre que la taille de votre base de données, mais 1.5x est recommandé.\",\n            \"plugin_tasks\": \"Tâches de Plugin\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Analyse tous les chemins\",\n                \"scanning_paths\": \"Analyse les chemins suivants\"\n            },\n            \"scan_for_content_desc\": \"Analyser le nouveau contenu et l'ajouter à la base de données.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Définir le nom, date, détails à partir des métadonnées intégrées au fichier\",\n            \"clean_generated\": {\n                \"blob_files\": \"Fichiers Blob\",\n                \"description\": \"Supprime les fichiers générés sans entrée correspondante dans la base de données.\",\n                \"image_thumbnails_desc\": \"Vignettes et séquences d'images\",\n                \"markers\": \"Aperçu des marqueurs\",\n                \"image_thumbnails\": \"Vignettes d'images\",\n                \"previews\": \"Aperçu des scènes\",\n                \"previews_desc\": \"Aperçu des scènes et vignettes\",\n                \"sprites\": \"Sprites de scène\",\n                \"transcodes\": \"Transcodes de scènes\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"L'ensemble des images affichées en dessous du lecteur vidéo pour une navigation aisée.\",\n            \"rescan\": \"Réanalyse des fichiers\",\n            \"rescan_tooltip\": \"Réanalyse chaque fichier contenu dans le chemin d'accès. Utilisé pour forcer la mise à jour des métadonnées des fichiers et réanalyser les archives zip.\",\n            \"generate_image_phashes_during_scan\": \"Générer les empreintes perceptuelles de l'image\",\n            \"generate_image_phashes_during_scan_tooltip\": \"Pour déduplication et identification.\",\n            \"backup_database\": {\n                \"description\": \"Effectue une sauvegarde de la base de données et des fichiers blob.\",\n                \"destination\": \"Destination\",\n                \"download\": \"Télécharger une sauvegarde\",\n                \"include_blobs\": \"Inclure les blobs dans la sauvegarde\",\n                \"include_blobs_desc\": \"Désactiver pour ne sauvegarder que le fichier de base de données SQLite.\",\n                \"sqlite\": \"Le fichier de sauvegarde sera une copie du fichier de base de données SQLite, avec comme nom de fichier {filename_format}\",\n                \"to_directory\": \"Vers {directory}\",\n                \"warning_blobs\": \"Les fichiers blob ne seront pas inclus dans la sauvegarde. Cela signifie que pour réussir une restauration à partir de sauvegarde, les fichiers blob doivent être présents dans l'emplacement de stockage blob.\",\n                \"zip\": \"Les fichiers de base de données SQLite et les fichiers blob seront compressés en un seul fichier, avec comme nom de fichier {filename_format}\"\n            }\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Vérificateur de doublons de scènes\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Ajouter un champ\",\n                \"capitalize_title\": \"Titre en majuscule\",\n                \"display_fields\": \"Afficher les champs\",\n                \"escape_chars\": \"Utiliser \\\\ pour échapper les caractères littéraux\",\n                \"filename\": \"Nom de fichier\",\n                \"filename_pattern\": \"Modèle de nom de fichier\",\n                \"ignore_organized\": \"Ignorer les scènes organisées\",\n                \"ignored_words\": \"Mots ignorés\",\n                \"matches_with\": \"Correspond à {i}\",\n                \"select_parser_recipe\": \"Sélectionner une formule d'analyse\",\n                \"title\": \"Analyseur de noms de fichiers de scènes\",\n                \"whitespace_chars\": \"Caractères d'espacement\",\n                \"whitespace_chars_desc\": \"Ces caractères seront remplacés par un espace dans le titre\"\n            },\n            \"scene_tools\": \"Outils de scène\",\n            \"heading\": \"Outils\",\n            \"graphql_playground\": \"Implémentation GraphQL\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Abréger les compteurs sur les fiches et pages de détails, par exemple \\\"1831\\\" sera formaté en \\\"1.8K\\\".\",\n                \"heading\": \"Abréger les compteurs\"\n            },\n            \"basic_settings\": \"Paramètres de base\",\n            \"custom_css\": {\n                \"description\": \"La page doit être rafraichie pour que les changements prennent effet. La compatibilité entre le CSS personnalisé et les futures versions de Stash n'est pas garantie.\",\n                \"heading\": \"CSS personnalisé\",\n                \"option_label\": \"Activer le CSS personnalisé\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"La page doit être actualisée pour que les changements prennent effet. La compatibilité entre le JavaScript personnalisé et les futures versions de Stash n'est pas garantie.\",\n                \"heading\": \"JavaScript personnalisé\",\n                \"option_label\": \"Activer le JavaScript personnalisé\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Remplacer individuellement des chaines linguistiques. Voir https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/fr-FR.json pour la liste principale. La page doit être actualisée pour que les changements prennent effet.\",\n                \"heading\": \"Traduction personnalisée\",\n                \"option_label\": \"Activer la traduction personnalisée\"\n            },\n            \"delete_options\": {\n                \"description\": \"Réglages par défaut lors de la suppression d'images, galeries, et scènes.\",\n                \"heading\": \"Options de suppression\",\n                \"options\": {\n                    \"delete_file\": \"Supprimer le fichier par défaut\",\n                    \"delete_generated_supporting_files\": \"Supprimer par défaut les fichiers générés associés\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Intégration au bureau\",\n                \"notifications_enabled\": \"Activer les notifications\",\n                \"send_desktop_notifications_for_events\": \"Envoyer des notifications sur le bureau en cas d'événements.\",\n                \"skip_opening_browser\": \"Ne pas ouvrir de navigateur\",\n                \"skip_opening_browser_on_startup\": \"Ignorer l'ouverture automatique du navigateur lors du démarrage.\"\n            },\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"description\": \"Activée, cette option présentera des détails plus étendus en préservant une présentation compacte.\",\n                    \"heading\": \"Détails étendus compacts\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Afficher l'image d'arrière-plan sur la page Détail.\",\n                    \"heading\": \"Activer l'image d'arrière-plan\"\n                },\n                \"heading\": \"Page Détail\",\n                \"show_all_details\": {\n                    \"description\": \"Activée, tous les détails du contenu seront affichés par défaut et chaque élément détaillé tiendra dans une seule colonne.\",\n                    \"heading\": \"Montrer tous les détails\"\n                }\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Supprimer la possibilité de créer de nouveaux objets à partir des sélecteurs de liste déroulante.\",\n                    \"heading\": \"Désactiver la création depuis la liste déroulante\"\n                },\n                \"heading\": \"Édition\",\n                \"max_options_shown\": {\n                    \"label\": \"Nombre maximal d'éléments à afficher dans les listes déroulantes de sélection\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Précision de la notation\",\n                        \"options\": {\n                            \"full\": \"Complète\",\n                            \"half\": \"Moitié\",\n                            \"quarter\": \"Quart\",\n                            \"tenth\": \"Dixième\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Mode de notation\",\n                        \"options\": {\n                            \"decimal\": \"Décimal\",\n                            \"stars\": \"Étoiles\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Décalage temporel en millisecondes pour la lecture des scripts interactifs.\",\n                \"heading\": \"Décalage du script interactif (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Connecter\",\n                \"server_offset\": {\n                    \"heading\": \"Offset serveur\"\n                },\n                \"status\": {\n                    \"heading\": \"Statut de connexion Handy\"\n                },\n                \"sync\": \"Synchroniser\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Clé de connexion Handy à utiliser pour les scènes interactives. En définissant cette clé, vous permettez à Stash de partager les informations de votre scène actuelle avec handyfeeling.com.\",\n                \"heading\": \"Clé de connexion Handy\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Visionneuse d'images\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Orientation\",\n                \"heading\": \"Mur d'images\",\n                \"margin\": \"Marge (pixels)\"\n            },\n            \"images\": {\n                \"heading\": \"Images\",\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"Lorsqu'une bibliothèque a des vidéos désactivées, les fichiers vidéo (voir extension vidéo) seront analysés comme séquences d'images.\",\n                        \"heading\": \"Analyser les extensions vidéo comme séquences d'images\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Écrire les vignettes des images sur disque lorsqu'elles sont générées à la volée.\",\n                        \"heading\": \"Enregistrer les vignettes des images\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Options Interactives\",\n            \"language\": {\n                \"heading\": \"Langue\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Durée maximale de la scène pendant laquelle le lecteur bouclera la vidéo. Définir sur 0 pour désactiver.\",\n                \"heading\": \"Durée maximale de la boucle\"\n            },\n            \"menu_items\": {\n                \"description\": \"Afficher ou masquer différents types de contenus dans la barre de navigation.\",\n                \"heading\": \"Éléments de la barre de navigation\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Pourcentage de temps pendant lequel une scène doit être jouée avant que son compteur de lecture ne soit incrémenté.\",\n                \"heading\": \"Pourcentage de lecture minimum\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Emplacement personnalisé pour les images de performeurs par défaut. Laisser vide pour utiliser les réglages intégrés par défaut.\",\n                        \"heading\": \"Chemin de l'image personnalisée du performeur\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"L'option par défaut est l'aperçu vidéo (mp4). Pour réduire la charge du CPU lors de la navigation, vous pouvez utiliser les aperçus d'images animées (webp). Cependant, elles doivent être générées en complément des aperçus vidéo et sont des fichiers plus volumineux.\",\n                \"heading\": \"Mode d'aperçu\",\n                \"options\": {\n                    \"animated\": \"Image animée\",\n                    \"static\": \"Image fixe\",\n                    \"video\": \"Vidéo\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Vue en grille\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Afficher en texte la superposition du studio\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Lecteur de scènes\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Toujours démarrer la vidéo depuis le début\",\n                    \"auto_start_video\": \"Démarrer automatiquement la vidéo\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Démarrage automatique des scènes vidéo lorsqu'elles sont lues à partir de la file d'attente, d'une sélection ou aléatoires à partir de la page Scènes.\",\n                        \"heading\": \"Démarrer automatiquement la vidéo lorsque lecture est sélectionnée\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Lire la scène suivante de la file d'attente lorsque une vidéo se termine.\",\n                        \"heading\": \"Continuer la liste de lecture par défaut\"\n                    },\n                    \"disable_mobile_media_auto_rotate\": \"Désactiver la rotation automatique des médias en plein écran sur mobile\",\n                    \"enable_chromecast\": \"Activer Chromecast\",\n                    \"show_ab_loop_controls\": \"Afficher les commandes de boucle AB\",\n                    \"show_scrubber\": \"Montrer la barre de progression\",\n                    \"track_activity\": \"Activer l'historique de lecture des scènes\",\n                    \"vr_tag\": {\n                        \"description\": \"Le bouton VR est uniquement affiché pour les scènes comportant cette étiquette.\",\n                        \"heading\": \"Étiquette VR\"\n                    },\n                    \"show_range_markers\": \"Afficher les marqueurs prolongés\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Mur de scènes et marqueurs\",\n                \"options\": {\n                    \"display_title\": \"Afficher le titre et étiquettes\",\n                    \"toggle_sound\": \"Activer le son\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Nombre de tentatives de défilement avant de passer à l'élément suivant/précédent. S'applique uniquement au mode de défilement Panoramique Y.\",\n                \"heading\": \"Tentatives de défilement avant transition\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Afficher une fiche d'identification lors du survol des badges d'étiquettes.\",\n                \"heading\": \"Infobulles d'identification\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Le diaporama est disponible dans galerie en mode de vue mural.\",\n                \"heading\": \"Délai du diaporama (secondes)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Vue studios\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"Dans la vue studios, afficher également le contenu des studios affiliés.\",\n                        \"heading\": \"Afficher le contenu des studios affiliés\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Vue étiquettes\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"Dans la vue étiquettes, afficher également le contenu des étiquettes affiliées.\",\n                        \"heading\": \"Afficher le contenu des étiquettes affiliées\"\n                    }\n                }\n            },\n            \"title\": \"Interface utilisateur\",\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"Activée, les scripts interactifs sont transmis directement de Stash à votre dispositif Handy sans recourir au serveur Handy de tierce partie. Nécessite que Stash soit accessible depuis votre dispositif Handy, et qu'une clé API soit générée si Stash a des informations d'identification configurées.\",\n                \"heading\": \"Transmettre directement les funscripts\"\n            },\n            \"performer_list\": {\n                \"heading\": \"Liste de performeurs\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"Afficher les liens sur les fiches des performeurs\"\n                    }\n                }\n            },\n            \"sfw_mode\": {\n                \"description\": \"Activez cette option si vous utilisez Stash pour stocker du contenu SFW. Masque ou modifie certains aspects de l'interface utilisateur liés au contenu pour adultes.\",\n                \"heading\": \"Mode contenu SFW\"\n            },\n            \"custom_title\": {\n                \"description\": \"Texte personnalisé à ajouter au titre de la page. Si vide, \\\"Stash\\\" par défaut.\",\n                \"heading\": \"Titre personnalisé\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"Mode dépannage\",\n                \"dialog_title\": \"Activer le mode dépannage\",\n                \"dialog_description\": \"Cela désactivera temporairement toutes les personnalisations afin de faciliter le diagnostic des problèmes :\",\n                \"dialog_item_plugins\": \"Tous les plugins\",\n                \"dialog_item_css\": \"CSS personnalisé\",\n                \"dialog_item_js\": \"JavaScript personnalisé\",\n                \"dialog_item_locales\": \"Paramètres régionaux personnalisés\",\n                \"dialog_log_level\": \"Le niveau de journalisation sera défini sur Débogage pour des diagnostics détaillés.\",\n                \"dialog_reload_note\": \"La page s'actualisera automatiquement.\",\n                \"enable\": \"Activer et actualiser\",\n                \"overlay_message\": \"Le mode dépannage est actif. Toutes les personnalisations sont désactivées.\",\n                \"exit\": \"Sortir\"\n            }\n        },\n        \"advanced_mode\": \"Mode avancé\",\n        \"changelog\": {\n            \"header\": \"Journal de modifications\"\n        }\n    },\n    \"configuration\": \"Configuration\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Fichier} other {Fichiers}}\",\n        \"galleries\": \"{count, plural, one {Galerie} other {Galeries}}\",\n        \"images\": \"{count, plural, one {Image} other {Images}}\",\n        \"markers\": \"{count, plural, one {Marqueur} other {Marqueurs}}\",\n        \"performers\": \"{count, plural, one {Performeur} other {Performeurs}}\",\n        \"scenes\": \"{count, plural, one {Scène} other {Scènes}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Studios}}\",\n        \"tags\": \"{count, plural, one {Étiquette} other {Étiquettes}}\",\n        \"groups\": \"{count, plural, one {Groupe} other {Groupes}}\"\n    },\n    \"country\": \"Pays\",\n    \"cover_image\": \"Vignette\",\n    \"created_at\": \"Créé le\",\n    \"criterion\": {\n        \"greater_than\": \"Supérieur à\",\n        \"less_than\": \"Inférieur à\",\n        \"value\": \"Valeur\",\n        \"unsupported\": \"{type} (non supporté)\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"entre\",\n        \"equals\": \"est égal à\",\n        \"excludes\": \"ne contient pas\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (exclus {excludedString})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (exclut {excludedString}) (+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"greater_than\": \"est plus grand que\",\n        \"includes\": \"contient\",\n        \"includes_all\": \"contient tout\",\n        \"is_null\": \"est nul\",\n        \"less_than\": \"est plus petit que\",\n        \"matches_regex\": \"correspond à l'expression régulière\",\n        \"not_between\": \"en dehors\",\n        \"not_equals\": \"n'est pas égal à\",\n        \"not_matches_regex\": \"ne correspond pas à l'expression régulière\",\n        \"not_null\": \"n'est pas nul\"\n    },\n    \"custom\": \"Personnalisé\",\n    \"date\": \"Date\",\n    \"date_format\": \"AAAA-MM-JJ\",\n    \"datetime_format\": \"AAAA-MM-JJ HH:MM\",\n    \"death_date\": \"Date du décès\",\n    \"death_year\": \"Année du décès\",\n    \"descending\": \"Descendant\",\n    \"description\": \"Description\",\n    \"detail\": \"Détail\",\n    \"details\": \"Détails\",\n    \"developmentVersion\": \"Version de développement\",\n    \"dialogs\": {\n        \"create_new_entity\": \"Créer un nouveau {entity}\",\n        \"delete_alert\": \"{count, plural, one {Le {singularEntity} suivant sera supprimé} other {Les {pluralEntity} suivants seront supprimés}} définitivement :\",\n        \"delete_confirm\": \"Êtes-vous sûr de vouloir supprimer {entityName} ?\",\n        \"delete_entity_desc\": \"{count, plural, one {Êtes-vous sûr de vouloir supprimer cette {singularEntity} ? Si le fichier n'est pas également supprimé, cette {singularEntity} sera ajoutée à nouveau lors de la prochaine analyse.} other {Êtes-vous sûr de vouloir supprimer ces {pluralEntity} ? Si les fichiers ne sont pas également supprimés, ces {pluralEntity} seront ajoutées à nouveau lors de la prochaine analyse.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Êtes-vous sûr de vouloir supprimer ce {singularEntity} ?} other {Êtes-vous sûr de vouloir supprimer ces {pluralEntity} ?}}\",\n        \"delete_entity_title\": \"Supprimer {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"delete_galleries_extra\": \"…ainsi que tous fichiers image qui ne sont pas associés à une autre galerie.\",\n        \"delete_gallery_files\": \"Supprime le répertoire ou l'archive zip de la galerie et toutes images qui ne sont pas associées à une autre galerie.\",\n        \"delete_object_desc\": \"Êtes-vous sûr de vouloir supprimer {count, plural, one {ce {singularEntity}} other {ces {pluralEntity}}} ?\",\n        \"delete_object_overflow\": \"…et {count} {count, plural, one {autre {singularEntity}} other {autres {pluralEntity}}}.\",\n        \"delete_object_title\": \"Supprimer {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Ne pas montrer avant la prochaine mise à jour\",\n        \"edit_entity_title\": \"Éditer {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Inclure les objets liés dans l'exportation\",\n        \"export_title\": \"Exporter\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Colonne\",\n                \"description\": \"Agencement en colonnes ou en lignes.\",\n                \"row\": \"Ligne\"\n            },\n            \"margin_desc\": \"Nombre de pixels de marge autour de chaque image entière.\"\n        },\n        \"lightbox\": {\n            \"delay\": \"Délai (Secondes)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Ajustement horizontal\",\n                \"fit_to_screen\": \"Adapter à l'écran\",\n                \"label\": \"Mode d'affichage\",\n                \"original\": \"Original\"\n            },\n            \"options\": \"Options\",\n            \"page_header\": \"Page {page} / {total}\",\n            \"reset_zoom_on_nav\": \"Réinitialisation du facteur de zoom lors d'un changement d'image\",\n            \"scale_up\": {\n                \"description\": \"Redimensionner les petites images pour les adapter à l'écran.\",\n                \"label\": \"Mise à l'échelle\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Maintenir la touche shift pour utiliser temporairement l'autre mode.\",\n                \"label\": \"Mode de défilement\",\n                \"pan_y\": \"Panoramique Y\",\n                \"zoom\": \"Zoom\"\n            },\n            \"disable_animation\": \"Désactiver l'animation de transition entre les images\"\n        },\n        \"merge\": {\n            \"destination\": \"Destination\",\n            \"empty_results\": \"Les valeurs des champs de destination seront inchangées.\",\n            \"source\": \"Source\"\n        },\n        \"performers_found\": \"{count} performeurs trouvés\",\n        \"reassign_entity_title\": \"{count, plural, one {Réaffecté {singularEntity}} other {Réaffectés {pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Réaffecter à\"\n        },\n        \"scene_gen\": {\n            \"clip_previews\": \"Aperçu des séquences d'images\",\n            \"covers\": \"Vignettes de scène\",\n            \"force_transcodes\": \"Forcer la génération du transcodage\",\n            \"force_transcodes_tooltip\": \"Par défaut, les transcodes ne sont générés que lorsque le fichier vidéo n'est pas pris en charge par le navigateur. Activé, les transcodes seront générés même si le fichier vidéo semble être pris en charge par le navigateur.\",\n            \"image_previews\": \"Aperçus d'images animées\",\n            \"image_previews_tooltip\": \"Génère également des aperçus animés (webp), uniquement requis lorsque le mode de prévisualisation Scène/Mur de marqueurs est défini sur Image animée. Lors de la navigation, ils utilisent moins de ressources CPU que les aperçus vidéo, mais sont générés en complément de ceux-ci et constituent des fichiers plus volumineux.\",\n            \"interactive_heatmap_speed\": \"Générer des cartes thermiques et vitesses pour les scènes interactives\",\n            \"marker_image_previews\": \"Aperçus animés des marqueurs\",\n            \"marker_image_previews_tooltip\": \"Génère également des aperçus animés (webp), uniquement requis lorsque le mode de prévisualisation Scène/Mur de marqueurs est défini sur Image animée. Lors de la navigation, ils utilisent moins de ressources CPU que les aperçus vidéo, mais sont générés en complément de ceux-ci et constituent des fichiers plus volumineux.\",\n            \"marker_screenshots\": \"Captures d'écran des marqueurs\",\n            \"marker_screenshots_tooltip\": \"Images JPG statiques des marqueurs\",\n            \"markers\": \"Aperçus des marqueurs\",\n            \"markers_tooltip\": \"Vidéos de 20 secondes qui débutent au repère temporel donné.\",\n            \"override_preview_generation_options\": \"Remplacer les options de génération d'aperçu\",\n            \"override_preview_generation_options_desc\": \"Remplacer les options de génération d'aperçu pour cette opération. Les valeurs par défaut sont définies dans Système -> Génération d'aperçus.\",\n            \"overwrite\": \"Remplacer les fichiers existants\",\n            \"phash\": \"Empreintes perceptuelles de la vidéo\",\n            \"preview_exclude_end_time_desc\": \"Exclure les x dernières secondes des aperçus de la scène. Cela peut être une valeur en secondes, ou un pourcentage (par exemple 2%) de la durée totale de la scène.\",\n            \"preview_exclude_end_time_head\": \"Exclure le temps de fin\",\n            \"preview_exclude_start_time_desc\": \"Exclure les x premières secondes des aperçus de la scène. Cela peut être une valeur en secondes, ou un pourcentage (par exemple 2%) de la durée totale de la scène.\",\n            \"preview_exclude_start_time_head\": \"Exclure le temps de départ\",\n            \"preview_generation_options\": \"Options de génération d'aperçus\",\n            \"preview_options\": \"Options d'aperçu\",\n            \"preview_preset_desc\": \"Le préréglage règle la taille, la qualité et le temps d'encodage de la génération d'aperçu. Les préréglages autres que \\\"slow\\\" ont un résultat moindre et ne sont pas recommandés.\",\n            \"preview_preset_head\": \"Préréglage de l'encodage d'aperçu\",\n            \"preview_seg_count_desc\": \"Nombre de segments dans les fichiers de l'aperçu.\",\n            \"preview_seg_count_head\": \"Nombre de segments dans l'aperçu\",\n            \"preview_seg_duration_desc\": \"Durée de chaque segment d'aperçu, en secondes.\",\n            \"preview_seg_duration_head\": \"Durée du segment d'aperçu\",\n            \"sprites\": \"Sprites de progression de scène\",\n            \"sprites_tooltip\": \"L'ensemble des images affichées en dessous du lecteur vidéo pour une navigation aisée.\",\n            \"transcodes\": \"Transcoder\",\n            \"transcodes_tooltip\": \"Les transcodes MP4 seront générés à l'avance pour tous les contenus ; utile pour les processeurs lents, mais nécessite beaucoup plus d'espace disque\",\n            \"video_previews\": \"Aperçus\",\n            \"video_previews_tooltip\": \"Prévisualisation de la vidéo lors du survol d'une scène\",\n            \"phash_tooltip\": \"Pour la déduplication et l'identification des scènes\",\n            \"image_thumbnails\": \"Vignettes d'images\",\n            \"image_phash\": \"Empreintes perceptuelles de l'image\",\n            \"image_phash_tooltip\": \"Pour déduplication et identification\"\n        },\n        \"scenes_found\": \"{count} scènes trouvées\",\n        \"scrape_entity_query\": \"Requête d'extraction {entity_type}\",\n        \"scrape_entity_title\": \"Résultats de l'extraction {entity_type}\",\n        \"scrape_results_existing\": \"Existant\",\n        \"scrape_results_scraped\": \"Extraits\",\n        \"set_image_url_title\": \"URL de l'image\",\n        \"unsaved_changes\": \"Modifications non sauvegardées. Vous êtes sûr de vouloir quitter ?\",\n        \"clear_o_history_confirm\": \"Êtes-vous sûr de vouloir effacer l'historique des O ?\",\n        \"clear_play_history_confirm\": \"Êtes-vous sûr de vouloir effacer l'historique de lecture ?\",\n        \"overwrite_filter_warning\": \"Le filtre enregistré \\\"{entityName}\\\" sera remplacé.\",\n        \"set_default_filter_confirm\": \"Êtes-vous sûr de vouloir définir ce filtre par défaut ?\",\n        \"clear_o_history_confirm_sfw\": \"Êtes-vous sûr de vouloir effacer l'historique des \\\"J'aime\\\" ?\",\n        \"delete_alert_to_trash\": \"Les éléments suivants {count, plural, one {{singularEntity}} other {{pluralEntity}}} seront déplacés vers la corbeille :\",\n        \"stashid_exists_warning\": \"L'identifiant Stash existant pour cette Stash-Box sera remplacé.\",\n        \"studios_found\": \"{count} studios trouvés\",\n        \"tags_found\": \"{count} étiquettes trouvées\",\n        \"scrape_results_missing\": \"Manquant\",\n        \"edit_entity_count_title\": \"Éditer {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}\"\n    },\n    \"dimensions\": \"Dimensions\",\n    \"director\": \"Réalisateur\",\n    \"disambiguation\": \"Désambiguïsation\",\n    \"display_mode\": {\n        \"grid\": \"Grille\",\n        \"list\": \"Liste\",\n        \"tagger\": \"Étiqueteuse\",\n        \"unknown\": \"Inconnu\",\n        \"wall\": \"Mur\",\n        \"label_current\": \"Mode d'affichage : {current}\"\n    },\n    \"donate\": \"Faire un don\",\n    \"dupe_check\": {\n        \"description\": \"Les niveaux en-deça de \\\"Exacte\\\" peuvent prendre plus de temps à calculer. Des faux positifs peuvent également être retournés à de faibles précisions.\",\n        \"duration_diff\": \"Écart maximum de temps\",\n        \"duration_options\": {\n            \"any\": \"Tous\",\n            \"equal\": \"Égal\"\n        },\n        \"found_sets\": \"{setCount, plural, one{# ensemble de doublons trouvé.} other {# ensembles de doublons trouvés.}}\",\n        \"options\": {\n            \"exact\": \"Exacte\",\n            \"high\": \"Haute\",\n            \"low\": \"Basse\",\n            \"medium\": \"Moyenne\"\n        },\n        \"search_accuracy_label\": \"Pertinence de recherche\",\n        \"title\": \"Scènes dupliquées\",\n        \"only_select_matching_codecs\": \"Sélectionner uniquement si tous les codecs correspondent dans le groupe des doublons\",\n        \"select_all_but_largest_resolution\": \"Sélectionner tous les fichiers de chaque groupe dupliqué, à l'exception du fichier avec la plus haute résolution\",\n        \"select_oldest\": \"Sélectionner le ficher le plus ancien dans le groupe dupliqué\",\n        \"select_options\": \"Sélectionner Options…\",\n        \"select_youngest\": \"Sélectionner le fichier le plus récent dans le groupe dupliqué\",\n        \"select_all_but_largest_file\": \"Sélectionner tous les fichiers de chaque groupe dupliqué, à l'exception du fichier le plus volumineux\",\n        \"select_none\": \"Sélectionner Aucun\"\n    },\n    \"duplicated_phash\": \"Dupliqué (Empreinte)\",\n    \"duration\": \"Durée\",\n    \"effect_filters\": {\n        \"aspect\": \"Rapport d'image\",\n        \"blue\": \"Bleu\",\n        \"blur\": \"Flou\",\n        \"brightness\": \"Luminosité\",\n        \"contrast\": \"Contraste\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Vert\",\n        \"hue\": \"Teinte\",\n        \"name\": \"Filtres\",\n        \"name_transforms\": \"Transformations\",\n        \"red\": \"Rouge\",\n        \"reset_filters\": \"Rétablir les filtres\",\n        \"reset_transforms\": \"Rétablir les transformations\",\n        \"rotate\": \"Rotation\",\n        \"rotate_left_and_scale\": \"Rotation à gauche et mise à l'échelle\",\n        \"rotate_right_and_scale\": \"Rotation à droite et mise à l'échelle\",\n        \"saturation\": \"Saturation\",\n        \"scale\": \"Mise à l'échelle\",\n        \"warmth\": \"Température\"\n    },\n    \"empty_server\": \"Ajoutez quelques scènes à votre serveur pour afficher les recommandations sur cette page.\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"L'index de l'image doit être supérieur à 0\",\n        \"lazy_component_error_help\": \"Si vous avez récemment mis à jour Stash, merci de recharger la page ou de vider le cache de votre navigateur.\",\n        \"loading_type\": \"Erreur de chargement de {type}\",\n        \"something_went_wrong\": \"Quelque chose n'a pas fonctionné.\",\n        \"header\": \"Erreur\",\n        \"invalid_javascript_string\": \"Code javascript invalide : {error}\",\n        \"invalid_json_string\": \"Chaine JSON invalide : {error}\",\n        \"custom_fields\": {\n            \"duplicate_field\": \"Le nom du champ doit être unique\",\n            \"field_name_whitespace\": \"Le nom du champ ne doit pas contenir d'espace en début ou en fin de ligne\",\n            \"field_name_length\": \"Le nom du champ doit comporter moins de 65 caractères\",\n            \"field_name_required\": \"Le nom du champ est requis\"\n        }\n    },\n    \"ethnicity\": \"Ethnicité\",\n    \"existing_value\": \"valeur existante\",\n    \"eye_color\": \"Couleur des yeux\",\n    \"fake_tits\": \"Faux seins\",\n    \"false\": \"Faux\",\n    \"favourite\": \"Favoris\",\n    \"file\": \"fichier\",\n    \"file_count\": \"Nombre de fichiers\",\n    \"file_info\": \"Infos fichier\",\n    \"file_mod_time\": \"Date de modification du fichier\",\n    \"files\": \"fichiers\",\n    \"files_amount\": \"{value} fichiers\",\n    \"filesize\": \"Poids du fichier\",\n    \"filter\": \"Filtre\",\n    \"filter_name\": \"Nom du filtre\",\n    \"filters\": \"Filtres\",\n    \"folder\": \"Répertoire\",\n    \"framerate\": \"Fréquence\",\n    \"frames_per_second\": \"{value} ips\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Filtre prédéfini\",\n            \"saved_filter\": \"Filtre sauvegardé\"\n        }\n    },\n    \"galleries\": \"Galeries\",\n    \"gallery\": \"Galerie\",\n    \"gallery_count\": \"Nombre de galeries\",\n    \"gender\": \"Genre\",\n    \"gender_types\": {\n        \"FEMALE\": \"Femme\",\n        \"INTERSEX\": \"Intersexe\",\n        \"MALE\": \"Homme\",\n        \"NON_BINARY\": \"Non binaire\",\n        \"TRANSGENDER_FEMALE\": \"Femme transgenre\",\n        \"TRANSGENDER_MALE\": \"Homme transgenre\"\n    },\n    \"hair_color\": \"Couleur des cheveux\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Connexion\",\n        \"disconnected\": \"Déconnecté\",\n        \"error\": \"Erreur de connexion à Handy\",\n        \"missing\": \"Manquant\",\n        \"ready\": \"Prêt\",\n        \"syncing\": \"Synchronisation avec le serveur\",\n        \"uploading\": \"Script de chargement\"\n    },\n    \"hasChapters\": \"Chapitres\",\n    \"hasMarkers\": \"Marqueurs\",\n    \"height\": \"Taille\",\n    \"height_cm\": \"Taille (cm)\",\n    \"help\": \"Aide\",\n    \"ignore_auto_tag\": \"Ignorer l'étiquetage automatique\",\n    \"image\": \"Image\",\n    \"image_count\": \"Nombre d'Images\",\n    \"image_index\": \"Image #\",\n    \"images\": \"Images\",\n    \"include_parent_tags\": \"Inclure les étiquettes parentes\",\n    \"include_sub_studios\": \"Inclure les studios affiliés\",\n    \"include_sub_tags\": \"Inclure les étiquettes affiliées\",\n    \"index_of_total\": \"{index} de {total}\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interactif\",\n    \"interactive_speed\": \"Vitesse interactive\",\n    \"isMissing\": \"Est manquant\",\n    \"last_played_at\": \"Dernière lecture le\",\n    \"library\": \"Bibliothèque\",\n    \"loading\": {\n        \"generic\": \"Chargement…\",\n        \"plugins\": \"Chargement des plugins…\"\n    },\n    \"marker_count\": \"Nombre de marqueurs\",\n    \"markers\": \"Marqueurs\",\n    \"measurements\": \"Mensurations\",\n    \"media_info\": {\n        \"audio_codec\": \"Codec audio\",\n        \"downloaded_from\": \"Téléchargé depuis\",\n        \"interactive_speed\": \"Vitesse interactive\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} à la production\"\n        },\n        \"phash\": \"Empreinte\",\n        \"play_count\": \"Compteur de lecture\",\n        \"play_duration\": \"Temps de lecture\",\n        \"stream\": \"Flux\",\n        \"video_codec\": \"Codec vidéo\",\n        \"o_count\": \"Nombre d'O\",\n        \"md5\": \"Somme de contrôle MD5\",\n        \"oshash\": \"oshash\",\n        \"oshash_meaning\": \"Empreinte OpenSubtitles\",\n        \"phash_meaning\": \"Empreinte perceptuelle\"\n    },\n    \"megabits_per_second\": \"{value} mbps\",\n    \"metadata\": \"Métadonnées\",\n    \"name\": \"Nom\",\n    \"new\": \"Nouveau\",\n    \"none\": \"Aucun\",\n    \"operations\": \"Opérations\",\n    \"organized\": \"Organisé\",\n    \"pagination\": {\n        \"first\": \"Première\",\n        \"last\": \"Dernière\",\n        \"next\": \"Suivante\",\n        \"previous\": \"Précédente\",\n        \"current_total\": \"{current} sur {total}\"\n    },\n    \"parent_of\": \"Parent de {children}\",\n    \"parent_studio\": \"Studio parent\",\n    \"parent_studios\": \"Studio parent\",\n    \"parent_tag_count\": \"Nombre d'étiquettes parentes\",\n    \"parent_tags\": \"Étiquettes parentes\",\n    \"part_of\": \"Fait partie de {parent}\",\n    \"path\": \"Chemin\",\n    \"penis\": \"Pénis\",\n    \"penis_length\": \"Longueur du pénis\",\n    \"penis_length_cm\": \"Longueur du pénis (cm)\",\n    \"perceptual_similarity\": \"Similitude perceptuelle (Empreinte)\",\n    \"performer\": \"Performeurs\",\n    \"performer_age\": \"Âge du performeur\",\n    \"performer_count\": \"Nombre de performeurs\",\n    \"performer_favorite\": \"Performeur favori\",\n    \"performer_image\": \"Image du performeur\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Ajouter de nouveaux performeurs\",\n        \"any_names_entered_will_be_queried\": \"Tout nom saisi sera demandé à l'instance distante StashBox et ajouté si trouvé. Seules les correspondances exactes seront considérées comme équivalentes.\",\n        \"batch_add_performers\": \"Ajouter des performeurs par lots\",\n        \"batch_update_performers\": \"Mises à jour des performeurs par lots\",\n        \"current_page\": \"Page actuelle\",\n        \"failed_to_save_performer\": \"Échec pour sauvegarder le performeur \\\"{performer}\\\"\",\n        \"name_already_exists\": \"Le nom existe déjà\",\n        \"network_error\": \"Erreur réseau\",\n        \"no_results_found\": \"Aucun résultat trouvé.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} performeurs seront traités\",\n        \"performer_already_tagged\": \"Performeur déjà étiqueté\",\n        \"performer_selection\": \"Sélection du performeur\",\n        \"performer_successfully_tagged\": \"Performeur étiqueté avec succès :\",\n        \"query_all_performers_in_the_database\": \"Tous les performeurs dans la base de données\",\n        \"refresh_tagged_performers\": \"Actualiser les performeurs étiquetés\",\n        \"refreshing_will_update_the_data\": \"Une actualisation mettra à jour les données de tous les performeurs étiquetés de l'instance stash-box.\",\n        \"status_tagging_job_queued\": \"Statut : Tâche d'étiquetage en file d'attente\",\n        \"status_tagging_performers\": \"Statut : Étiquetage des performeurs\",\n        \"tag_status\": \"Statut de l'étiquette\",\n        \"to_use_the_performer_tagger\": \"Pour utiliser l'étiqueteuse de performeurs, une instance stash-box doit être configurée.\",\n        \"untagged_performers\": \"Performeurs non étiquetés\",\n        \"update_performer\": \"Mise à jour du performeur\",\n        \"update_performers\": \"Mise à jour des performeurs\",\n        \"updating_untagged_performers_description\": \"Une mise à jour des performeurs non étiquetés essaiera de faire correspondre tous les performeurs qui n'ont pas d'identifiant Stash et actualisera les métadonnées.\",\n        \"performer_names_or_stashids_separated_by_comma\": \"Noms des performeurs ou identifiants Stash séparés par des virgules\"\n    },\n    \"performer_tags\": \"Étiquettes de performeur\",\n    \"performers\": \"Performeurs\",\n    \"piercings\": \"Piercings\",\n    \"play_count\": \"Compteur de lecture\",\n    \"play_duration\": \"Temps de lecture\",\n    \"primary_file\": \"Fichier principal\",\n    \"queue\": \"Liste de lecture\",\n    \"random\": \"Aléatoire\",\n    \"rating\": \"Note\",\n    \"recently_added_objects\": \"{objects} récemment ajoutés\",\n    \"recently_released_objects\": \"{objects} récemment ajoutées\",\n    \"release_notes\": \"Notes de publication\",\n    \"resolution\": \"Résolution\",\n    \"resume_time\": \"Reprendre le temps\",\n    \"scene\": \"Scène\",\n    \"sceneTagger\": \"Étiqueteuse de scènes\",\n    \"scene_code\": \"Code studio\",\n    \"scene_count\": \"Nombre de scènes\",\n    \"scene_created_at\": \"Scène créée le\",\n    \"scene_date\": \"Date de la scène\",\n    \"scene_id\": \"ID de scène\",\n    \"scene_tags\": \"Étiquettes de la scène\",\n    \"scene_updated_at\": \"Scène mise à jour le\",\n    \"scenes\": \"Scènes\",\n    \"scenes_updated_at\": \"Scène actualisée le\",\n    \"search_filter\": {\n        \"edit_filter\": \"Modifier le filtre\",\n        \"name\": \"Filtre\",\n        \"saved_filters\": \"Filtres sauvegardés\",\n        \"update_filter\": \"Filtre actualisé\",\n        \"more_filter_criteria\": \"+{count} de plus\",\n        \"search_term\": \"Terme recherché\"\n    },\n    \"second\": \"Deuxième\",\n    \"seconds\": \"Secondes\",\n    \"settings\": \"Paramètres\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Nous sommes presque prêts à terminer la configuration. Confirmez les paramètres suivants. Vous pouvez revenir en arrière pour modifier toute erreur. Si tout semble correct, cliquez sur Confirmer pour créer votre système.\",\n            \"blobs_directory\": \"Répertoire de données binaires\",\n            \"blobs_use_database\": \"<utiliser la base de données>\",\n            \"cache_directory\": \"Répertoire du cache\",\n            \"configuration_file_location\": \"Emplacement du fichier de configuration :\",\n            \"database_file_path\": \"Chemin du fichier de base de données\",\n            \"generated_directory\": \"Répertoire généré\",\n            \"nearly_there\": \"Nous y sommes presque !\",\n            \"stash_library_directories\": \"Répertoire de la bibliothèque Stash\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Création de votre système\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Oh non ! Quelque chose a mal tourné !\",\n            \"something_went_wrong_description\": \"Si cela ressemble à un problème avec vos saisies, continuez et cliquez sur retour pour les corriger. Sinon, créez un bogue sur {githubLink} ou demandez de l'aide sur {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Un problème est survenu lors de la configuration de votre système. Voici l'erreur que nous avons reçue : {error}\",\n            \"unexpected_error\": \"Une erreur inattendue s'est produite : {error}\",\n            \"unable_to_retrieve_system_status\": \"Impossible de récupérer l'état du système : {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Chemin de fichier\",\n            \"up_dir\": \"Remonter d'un répertoire\"\n        },\n        \"github_repository\": \"Dépôt Github\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Chemin de sauvegarde de la base de données (laisser vide pour désactiver la sauvegarde) :\",\n            \"backup_recommended\": \"Il est recommandé de sauvegarder votre base de données existante avant de procéder à la migration. Nous pouvons le faire pour vous, en faisant une copie de votre base de données dans <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Migration de la base de données\",\n            \"migration_failed\": \"Migration échouée\",\n            \"migration_failed_error\": \"L'erreur suivante a été rencontrée lors de la migration de la base de données :\",\n            \"migration_failed_help\": \"Veuillez apporter les corrections nécessaires et réessayer. Sinon, signalez un bogue sur {githubLink} ou demandez de l'aide sur {discordLink}.\",\n            \"migration_irreversible_warning\": \"Le processus de migration des schémas est irréversible. Une fois la migration effectuée, votre base de données sera incompatible avec les versions précédentes de Stash.\",\n            \"migration_notes\": \"Notes de migration\",\n            \"migration_required\": \"Migration requise\",\n            \"perform_schema_migration\": \"Procéder à la migration du schéma\",\n            \"schema_too_old\": \"La version du schéma de votre base de données Stash actuelle est <strong>{databaseSchema}</strong> et doit être migrée vers la version <strong>{appSchema}</strong>. Cette version de Stash ne fonctionnera pas sans migration de la base de données. Si vous ne souhaitez pas migrer, vous devrez rétrograder vers une version qui correspond au schéma de votre base de données.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"Nom de fichier de la base de données (vide par défaut)\",\n            \"description\": \"Ensuite, nous devons déterminer où trouver votre contenu, et où stocker la base de données Stash, les fichiers générés et les fichiers cache. Ces paramètres peuvent être modifiés ultérieurement si nécessaire.\",\n            \"path_to_blobs_directory_empty_for_default\": \"chemin vers le répertoire des blobs (vide par défaut)\",\n            \"path_to_cache_directory_empty_for_default\": \"chemin du répertoire du cache (vide par défaut)\",\n            \"path_to_generated_directory_empty_for_default\": \"Chemin vers le répertoire généré (vide par défaut)\",\n            \"set_up_your_paths\": \"Configurez vos chemins\",\n            \"stash_alert\": \"Aucun chemin de bibliothèque n'a été sélectionné. Aucun média ne pourra être analysé dans Stash. En êtes-vous sûr ?\",\n            \"store_blobs_in_database\": \"Stocker les blobs dans la base de données\",\n            \"where_can_stash_store_blobs\": \"Où Stash peut-il stocker les données binaires de la base de données ?\",\n            \"where_can_stash_store_blobs_description\": \"Stash peut stocker des données binaires telles que les vignettes de scènes, les images de performeurs, de studios et de tags, soit dans la base de données, soit dans le système de fichiers. Par défaut, ces données sont stockées dans le système de fichiers, dans le sous-répertoire <code>blobs</code>. Si vous souhaitez modifier cela, veuillez saisir un chemin absolu ou relatif (par rapport au répertoire de travail actuel). Stash créera ce sous-répertoire s'il n'existe pas déjà.\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Alternativement, si vous souhaitez stocker ces données dans la base de données, vous pouvez laisser ce champ vide. <strong>Remarque :</strong> cela augmentera la taille de votre base de données et son temps de migration.\",\n            \"where_can_stash_store_cache_files\": \"Où Stash peut-il stocker les fichiers cache ?\",\n            \"where_can_stash_store_cache_files_description\": \"Pour que certaines fonctionnalités telles que le transcodage en temps réel HLS/DASH puissent fonctionner, Stash a besoin d'un répertoire de cache pour les fichiers temporaires. Par défaut, Stash créera un sous-répertoire <code>cache</code> dans le répertoire contenant votre fichier de configuration. Si vous souhaitez le modifier, merci de saisir un chemin absolu ou relatif (par rapport au répertoire de travail actuel). Stash créera ce sous-répertoire s'il n'existe pas déjà.\",\n            \"where_can_stash_store_its_database\": \"Où Stash peut-il stocker sa base de données ?\",\n            \"where_can_stash_store_its_database_description\": \"Stash utilise une base de données SQLite pour stocker vos métadonnées de contenu. Par défaut, cette base sera créée en tant que <code>stash-go.sqlite</code> dans le répertoire contenant votre fichier de configuration. Si vous souhaitez modifier cela, saisissez un nom de fichier absolu ou relatif ( vers le répertoire de travail actuel).\",\n            \"where_can_stash_store_its_database_warning\": \"AVERTISSEMENT : Le stockage de la base de données sur un système différent de celui à partir duquel Stash est exécuté (par exemple, le stockage de la base de données sur un NAS tout en exécutant le serveur Stash sur un autre ordinateur) est <strong>non pris en charge</strong> ! SQLite n'est pas conçu pour être utilisé sur un réseau, et toute tentative de le faire peut très facilement entraîner la corruption de l'ensemble de votre base de données.\",\n            \"where_can_stash_store_its_generated_content\": \"Où Stash peut-il stocker son contenu généré ?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Afin de produire les vignettes, aperçus et sprites, Stash génère des images et des vidéos. Cela inclut également les transcodes pour les formats de fichiers non pris en charge. Par défaut, Stash crée un répertoire <code>generated</code> dans le répertoire contenant votre fichier de configuration. Si vous souhaitez modifier l'emplacement où seront stockés les médias générés, veuillez saisir un chemin absolu ou relatif ( vers le répertoire de travail actuel). Stash créera ce répertoire s'il n'existe pas déjà.\",\n            \"where_is_your_porn_located\": \"Où se trouve votre contenu ?\",\n            \"where_is_your_porn_located_description\": \"Ajoutez des répertoires contenant vos vidéos et images. Stash utilisera ces répertoires pour rechercher les vidéos et les images lors de l'analyse.\",\n            \"sfw_content_settings\": \"Utiliser Stash pour du contenu SFW ?\",\n            \"sfw_content_settings_description\": \"Stash peut être utilisé pour gérer du contenu SFW tel que des photographies, des illustrations, des bandes dessinées, et plus. L'activation de cette option modifiera certains comportements de l'interface utilisateur pour les rendre plus appropriés au contenu SFW.\",\n            \"use_sfw_content_mode\": \"Utiliser le mode de contenu SFW\"\n        },\n        \"stash_setup_wizard\": \"Assistant de configuration de Stash\",\n        \"success\": {\n            \"getting_help\": \"Obtenir de l'aide\",\n            \"help_links\": \"Si vous rencontrez des problèmes ou avez des questions ou des suggestions, n'hésitez pas à ouvrir un incident sur {githubLink}, ou demandez à la communauté sur {discordLink}.\",\n            \"in_app_manual_explained\": \"Nous vous encourageons à consulter le manuel de l'application, accessible à partir de l'icône située dans le coin supérieur droit de l'écran, qui ressemble à ceci : {icon}\",\n            \"next_config_step_one\": \"Vous serez ensuite dirigé vers la page de configuration suivante. Cette page vous permettra de personnaliser les fichiers à inclure et à exclure, de définir un nom d'utilisateur et un mot de passe pour protéger votre système, ainsi qu'un grand nombre d'autres options.\",\n            \"next_config_step_two\": \"Lorsque vous êtes satisfait de ces paramètres, vous pouvez commencer à analyser votre contenu dans Stash en cliquant sur <code>{localized_task}</code>, puis sur <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Consultez notre {open_collective_link} pour voir comment vous pouvez contribuer au développement continu de Stash.\",\n            \"support_us\": \"Soutenez-nous\",\n            \"thanks_for_trying_stash\": \"Merci d'avoir essayé Stash !\",\n            \"welcome_contrib\": \"Nous accueillons également les contributions sous forme de code (corrections de bogues, améliorations et nouvelles fonctionnalités), de tests, de rapports de bogues, de demandes d'améliorations et de fonctionnalités, et d'assistance utilisateurs. Vous trouverez plus de précisions dans la section \\\"Contribution\\\" du manuel de l'application.\",\n            \"your_system_has_been_created\": \"Bravo ! Votre système a été créé !\",\n            \"download_ffmpeg\": \"Télécharger FFmpeg\",\n            \"missing_ffmpeg\": \"Il manque le binaire <code>FFmpeg</code> requis. Vous pouvez le télécharger automatiquement dans votre répertoire de configuration en cochant la case ci-dessous. Vous pouvez également saisir les chemins d'accès aux binaires <code>FFmpeg</code> et <code>FFprobe</code> dans les paramètres systèmes. Ces binaires doivent être présent pour le fonctionnement de Stash.\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash essaie d'abord de trouver son fichier de configuration (<code>config.yml</code>) dans le répertoire de travail courant, et s'il ne le trouve pas, il se reporte sur <code>{fallback_path}</code>. Vous pouvez également faire en sorte que Stash lise un fichier de configuration spécifique en le lançant avec les options <code>-c '<path to config file>'</code> ou <code>--config '<path to config file>'</code>.\",\n            \"in_current_stash_directory\": \"Dans le répertoire <code>{path}</code> :\",\n            \"in_the_current_working_directory\": \"Dans <code>{path}</code>, le répertoire de travail, courant :\",\n            \"next_step\": \"Une fois que tout est réglé, si vous êtes prêt à procéder à la configuration d'un nouveau système, s'il vous plaît choisissez l'emplacement où vous souhaitez stocker votre fichier de configuration.\",\n            \"store_stash_config\": \"Où voulez-vous stocker votre configuration de Stash ?\",\n            \"unable_to_locate_config\": \"Si vous lisez ceci, Stash n'a pas pu trouver de configuration existante. Cet assistant vous guidera dans le processus de paramétrage d'une nouvelle configuration.\",\n            \"unexpected_explained\": \"Si vous obtenez cet écran de manière inattendue, essayez de redémarrer Stash dans le bon répertoire de travail ou avec le drapeau <code>-c</code>.\",\n            \"in_the_current_working_directory_disabled_macos\": \"Non supporté lors de l'exécution de <code>Stash.app</code>,<br></br>exécuter <code>stash-macos</code> pour le configurer dans le répertoire de travail\",\n            \"in_the_current_working_directory_disabled\": \"Dans <code>{path}</code>, le répertoire de travail :\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash utilisera le chemin suivant pour le fichier de configuration : <code>{path}</code>\",\n            \"next_step\": \"Lorsque vous êtes prêt à procéder à la configuration d'un nouveau système, cliquez sur Suivant.\",\n            \"unable_to_locate_specified_config\": \"Si vous lisez ceci, Stash n'a pas pu trouver le fichier de configuration spécifié en ligne de commande ou dans l'environnement. Cet assistant vous guidera dans le processus de paramétrage d'une nouvelle configuration.\"\n        },\n        \"welcome_to_stash\": \"Bienvenue sur Stash\"\n    },\n    \"stash_id\": \"identifiant Stash\",\n    \"stash_id_endpoint\": \"URL du point de terminaison de l'identifiant Stash\",\n    \"stash_ids\": \"identifiants Stash\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Allez sur {endpoint_name} pour examiner l'ébauche.\",\n        \"selected_stash_box\": \"Point de terminaison Stash-Box sélectionné\",\n        \"source\": \"Source Stash-Box\",\n        \"submission_failed\": \"Envoi échoué\",\n        \"submission_successful\": \"Envoi réussi\",\n        \"submit_update\": \"Existe déjà dans {endpoint_name}\"\n    },\n    \"statistics\": \"Statistiques\",\n    \"stats\": {\n        \"image_size\": \"Poids des images\",\n        \"scenes_duration\": \"Durée des scènes\",\n        \"scenes_played\": \"Scènes visionnées\",\n        \"scenes_size\": \"Poids des scènes\",\n        \"total_o_count\": \"Nombre total de O-\",\n        \"total_play_count\": \"Nombre de visionnage total\",\n        \"total_play_duration\": \"Temps de visionnage total\",\n        \"total_o_count_sfw\": \"Total de J'aime\"\n    },\n    \"status\": \"Statut : {statusText}\",\n    \"studio\": \"Studio\",\n    \"studio_and_parent\": \"Studio & Parent\",\n    \"studio_depth\": \"Niveaux (vides pour tous)\",\n    \"studio_tagger\": {\n        \"add_new_studios\": \"Ajouter des nouveaux studios\",\n        \"any_names_entered_will_be_queried\": \"Tous les noms saisis seront interrogés depuis l'instance Stash-Box distante et ajoutés si trouvés. Seules les correspondances exactes seront considérées comme telles.\",\n        \"batch_add_studios\": \"Ajouter des studios par lots\",\n        \"batch_update_studios\": \"Mise à jour des studios par lots\",\n        \"config\": {\n            \"create_parent_desc\": \"Créer les studios parents manquants, ou étiqueter et mettre à jour les données/image pour les studios parents existants avec des correspondances de noms exactes\",\n            \"create_parent_label\": \"Créer les studios parents\"\n        },\n        \"create_or_tag_parent_studios\": \"Créer les studios parents manquants ou étiqueter les existants\",\n        \"current_page\": \"Page actuelle\",\n        \"failed_to_save_studio\": \"Enregistrement du studio \\\"{studio}\\\" échoué\",\n        \"name_already_exists\": \"Nom déjà existant\",\n        \"network_error\": \"Erreur réseau\",\n        \"no_results_found\": \"Aucuns résultats trouvés.\",\n        \"number_of_studios_will_be_processed\": \"{studio_count} studios seront traités\",\n        \"query_all_studios_in_the_database\": \"Tous les studios de la base de données\",\n        \"refresh_tagged_studios\": \"Rafraîchir les studios étiquetés\",\n        \"refreshing_will_update_the_data\": \"Un rafraîchissement mettra à jour les données de tous les studios étiquetés depuis l'instance stash-box.\",\n        \"status_tagging_job_queued\": \"Statut : Étiquetage en file d'attente\",\n        \"status_tagging_studios\": \"Statut : Étiquetage des studios\",\n        \"studio_already_tagged\": \"Studio déjà étiqueté\",\n        \"studio_selection\": \"Sélection de studio\",\n        \"studio_successfully_tagged\": \"Studio étiqueté avec succès\",\n        \"tag_status\": \"Statut de l'étiquette\",\n        \"to_use_the_studio_tagger\": \"Pour utiliser l’étiqueteur de studio, une instance stash-box doit être configurée.\",\n        \"untagged_studios\": \"Studios non étiquetés\",\n        \"update_studio\": \"Actualiser le studio\",\n        \"update_studios\": \"Actualiser les studios\",\n        \"updating_untagged_studios_description\": \"Une mise à jour des studios non étiquetés essaiera de faire correspondre les studios qui n'ont pas d'identifiant Stash et actualisera les métadonnées.\",\n        \"studio_names_or_stashids_separated_by_comma\": \"Noms de studios ou identifiants Stash séparés par des virgules\"\n    },\n    \"studios\": \"Studios\",\n    \"sub_tag_count\": \"Nombre d'étiquettes affiliées\",\n    \"sub_tag_of\": \"Étiquette affiliée de {parent}\",\n    \"sub_tags\": \"Étiquettes affiliées\",\n    \"subsidiary_studios\": \"Studios affiliés\",\n    \"synopsis\": \"Résumé\",\n    \"tag\": \"Étiquette\",\n    \"tag_count\": \"Nombre d'étiquettes\",\n    \"tags\": \"Étiquettes\",\n    \"tattoos\": \"Tatouages\",\n    \"title\": \"Titre\",\n    \"toast\": {\n        \"added_entity\": \"{count, plural, one {{singularEntity} ajouté} other {{pluralEntity} ajoutés}}\",\n        \"added_generation_job_to_queue\": \"Ajout d'une tâche de génération en file d'attente\",\n        \"created_entity\": \"{entity} créé·e\",\n        \"default_filter_set\": \"Filtre par défaut défini\",\n        \"delete_past_tense\": \"{count, plural, one {{singularEntity} supprimé} other {{pluralEntity} supprimés}}\",\n        \"generating_screenshot\": \"Génération de la capture d'écran…\",\n        \"image_index_too_large\": \"Erreur : L'index de l'image est plus grand que le nombre d'images dans la galerie\",\n        \"merged_scenes\": \"Scènes fusionnées\",\n        \"merged_tags\": \"Étiquettes fusionnées\",\n        \"reassign_past_tense\": \"Fichier réaffecté\",\n        \"removed_entity\": \"{count, plural, one {{singularEntity} retiré} other {{pluralEntity} retirés}}\",\n        \"rescanning_entity\": \"Réanalyse de {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"{entity} sauvegardé·e\",\n        \"started_auto_tagging\": \"Début de l'étiquetage automatique\",\n        \"started_generating\": \"Début de la génération\",\n        \"started_importing\": \"Début d'importation\",\n        \"updated_entity\": \"{entity} mis·e à jour\",\n        \"merged_performers\": \"Performeurs fusionnés\",\n        \"clipboard_access_denied\": \"Accès au presse-papiers refusé. Vérifiez les autorisations de votre navigateur.\",\n        \"clipboard_image_pasted\": \"Image collée depuis le presse-papiers\",\n        \"clipboard_no_image\": \"Aucune image trouvée dans le presse-papiers\"\n    },\n    \"total\": \"Total\",\n    \"true\": \"Vrai\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Type\",\n    \"updated_at\": \"Actualisé le\",\n    \"url\": \"URL\",\n    \"urls\": \"URLs\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} doit être au format AAAA, AAAA-MM, ou AAAA-MM-JJ\",\n        \"required\": \"${path} est un champ requis\",\n        \"blank\": \"${path} ne doit pas être vide\",\n        \"unique\": \"${path} doit être unique\",\n        \"end_time_before_start_time\": \"L'heure de fin doit être supérieure ou égale à l'heure de début\"\n    },\n    \"video_codec\": \"Codec vidéo\",\n    \"videos\": \"Vidéos\",\n    \"view_all\": \"Tout voir\",\n    \"weight\": \"Poids\",\n    \"weight_kg\": \"Poids (kg)\",\n    \"years_old\": \"ans\",\n    \"zip_file_count\": \"Nombre de fichiers Zip\",\n    \"orientation\": \"Orientation\",\n    \"distance\": \"Distance\",\n    \"package_manager\": {\n        \"description\": \"Description\",\n        \"installed_version\": \"Version installée\",\n        \"latest_version\": \"Dernière version\",\n        \"source\": {\n            \"url\": \"URL source\",\n            \"name\": \"Nom\",\n            \"local_path\": {\n                \"heading\": \"Chemin local\",\n                \"description\": \"Chemin relatif pour stocker les paquets de cette source. A noter que ce changement nécessite le déplacement manuel des paquets.\"\n            }\n        },\n        \"uninstall\": \"Désinstaller\",\n        \"confirm_delete_source\": \"Êtes-vous sûr de vouloir supprimer la source {name} ({url}) ?\",\n        \"confirm_uninstall\": \"Êtes-vous sûr de vouloir désinstaller {number} paquets ?\",\n        \"hide_unselected\": \"Masquer les non sélectionnés\",\n        \"package\": \"Paquet\",\n        \"no_upgradable\": \"Pas de paquets à mettre à jour trouvés\",\n        \"no_packages\": \"Aucun paquet trouvé\",\n        \"no_sources\": \"Aucune source configurée\",\n        \"version\": \"Version\",\n        \"check_for_updates\": \"Vérifier les mises à jour\",\n        \"required_by\": \"Requis par {packages}\",\n        \"selected_only\": \"Seulement sélectionnés\",\n        \"show_all\": \"Tout montrer\",\n        \"update\": \"Mettre à jour\",\n        \"add_source\": \"Ajouter une source\",\n        \"edit_source\": \"Modifier la source\",\n        \"install\": \"Installer\",\n        \"unknown\": \"<inconnu>\"\n    },\n    \"tag_parent_tooltip\": \"A des étiquettes parentes\",\n    \"primary_tag\": \"Étiquette principale\",\n    \"connection_monitor\": {\n        \"websocket_connection_reestablished\": \"Connexion websocket rétablie\",\n        \"websocket_connection_failed\": \"Impossible d'établir une connexion websocket : voir la console du navigateur pour plus de détails\"\n    },\n    \"last_o_at\": \"Dernier O le\",\n    \"o_count\": \"Nombre d'O\",\n    \"o_history\": \"Historique d'O\",\n    \"odate_recorded_no\": \"Aucun O daté enregistré\",\n    \"subsidiary_studio_count\": \"Nombre de studios affiliés\",\n    \"tag_sub_tag_tooltip\": \"A des étiquettes affiliées\",\n    \"time\": \"Temps\",\n    \"photographer\": \"Photographe\",\n    \"play_history\": \"Historique de Lecture\",\n    \"playdate_recorded_no\": \"Aucune lecture datée enregistrée\",\n    \"plays\": \"{value} lectures\",\n    \"unknown_date\": \"Date inconnue\",\n    \"history\": \"Historique\",\n    \"group_count\": \"Nombre de groupes\",\n    \"group_scene_number\": \"Numéro de scène\",\n    \"groups\": \"Groupes\",\n    \"group\": \"Groupe\",\n    \"studio_count\": \"Nombre de studios\",\n    \"studio_tags\": \"Étiquettes du studio\",\n    \"include_sub_studio_content\": \"Inclure le contenu des studios affiliés\",\n    \"include_sub_tag_content\": \"Inclure le contenu des étiquettes affiliées\",\n    \"containing_group\": \"Groupe contenant\",\n    \"containing_group_count\": \"Nombre de groupes contenant\",\n    \"containing_groups\": \"Groupes contenant\",\n    \"include_sub_group_content\": \"Inclure le contenu des groupes affiliés\",\n    \"sub_group_order\": \"Ordre de groupe affilié\",\n    \"sub_groups\": \"Groupes affiliés\",\n    \"sub_group_of\": \"Groupe affilié de {parent}\",\n    \"sub_group\": \"Groupe affilié\",\n    \"sub_group_count\": \"Nombre de groupes affiliés\",\n    \"include_sub_groups\": \"Inclure les groupes affiliés\",\n    \"criterion_modifier_values\": {\n        \"none\": \"Aucun\",\n        \"only\": \"Uniquement\",\n        \"any_of\": \"Tous les\",\n        \"any\": \"Tous\"\n    },\n    \"time_end\": \"Heure de fin\",\n    \"custom_fields\": {\n        \"field\": \"Champ\",\n        \"title\": \"Champs personnalisés\",\n        \"value\": \"Valeur\",\n        \"criteria_format_string_others\": \"{criterion} (champ personnalisé) {modifierString} {valueString} (+{others} autres)\",\n        \"criteria_format_string\": \"{criterion} (custom field) {modifierString} {valueString}\"\n    },\n    \"eta\": \"TAE\",\n    \"sort_name\": \"Nom de tri\",\n    \"age_on_date\": \"{age} à la production\",\n    \"login\": {\n        \"password\": \"Mot de passe\",\n        \"invalid_credentials\": \"Nom d'utilisateur ou mot de passe incorrect\",\n        \"internal_error\": \"Erreur interne inattendue. Consulter le journal pour plus de détails\",\n        \"login\": \"Identification\",\n        \"username\": \"Nom d'utilisateur\"\n    },\n    \"scenes_duration\": \"Durée de la scène\",\n    \"last_o_at_sfw\": \"Dernier J'aime\",\n    \"o_count_sfw\": \"J'aime\",\n    \"odate_recorded_no_sfw\": \"Aucun “J’aime” daté enregistré\",\n    \"o_history_sfw\": \"Historique des \\\"J’aime\\\"\",\n    \"stashbox_search\": {\n        \"header\": \"Rechercher {entityType} à partir de Stash-Box\",\n        \"no_results\": \"Aucun résultat trouvé.\",\n        \"placeholder_name_or_id\": \"{entityType} nom ou identifiant Stash...\",\n        \"select_stashbox\": \"Sélectionner une Stash-Box...\"\n    },\n    \"latest_scene\": \"Dernière scène\",\n    \"stash_id_count\": \"Nombre d'identifiants Stash\",\n    \"duplicated\": \"Dupliqué\",\n    \"duplicated_stash_id\": \"Dupliqué (identifiant Stash)\",\n    \"duplicated_title\": \"Dupliqué (Titre)\",\n    \"career_end\": \"Fin de carrière\",\n    \"career_start\": \"Début de carrière\",\n    \"tag_tagger\": {\n        \"add_new_tags\": \"Ajouter de nouvelles étiquettes\",\n        \"any_names_entered_will_be_queried\": \"Tous les noms saisis seront recherchés dans l'instance Stash-Box distante et ajoutés si trouvés. Seules les correspondances exactes seront considérées comme valides.\",\n        \"batch_add_tags\": \"Ajouter des étiquettes par lot\",\n        \"batch_update_tags\": \"Mise à jour groupée d'étiquettes\",\n        \"current_page\": \"Page actuelle\",\n        \"failed_to_save_tag\": \"Impossible d'enregistrer l'étiquette \\\"{tag}\\\"\",\n        \"name_already_exists\": \"Nom déjà existant\",\n        \"network_error\": \"Erreur réseau\",\n        \"no_results_found\": \"Aucun résultat trouvé.\",\n        \"number_of_tags_will_be_processed\": \"{tag_count} étiquettes seront traitées\",\n        \"query_all_tags_in_the_database\": \"Toutes les étiquettes dans la base de données\",\n        \"refresh_tagged_tags\": \"Actualiser les étiquettes marquées\",\n        \"refreshing_will_update_the_data\": \"Une actualisation mettra à jour les données de toutes les étiquettes marquées à partir de l'instance Stash-Box.\",\n        \"status_tagging_job_queued\": \"Statut : Tâche d'étiquetage en attente\",\n        \"status_tagging_tags\": \"Statut : Marquage des étiquettes\",\n        \"tag_already_tagged\": \"Étiquette déjà marquée\",\n        \"tag_names_or_stashids_separated_by_comma\": \"Noms d'étiquettes ou identifiants Stash séparés par des virgules\",\n        \"tag_selection\": \"Sélection d'étiquettes\",\n        \"tag_successfully_tagged\": \"Étiquette marquée avec succès\",\n        \"tag_status\": \"Statut étiquette\",\n        \"to_use_the_tag_tagger\": \"Pour utiliser le marqueur d'étiquettes, une instance Stash-Box doit être configurée.\",\n        \"untagged_tags\": \"Étiquettes non marquées\",\n        \"update_tags\": \"Mise à jour des étiquettes\",\n        \"updating_untagged_tags_description\": \"Une mise à jour des étiquettes non marquées tentera de faire correspondre toutes les étiquettes sans identifiant Stash et d'actualiser les métadonnées.\",\n        \"config\": {\n            \"create_parent_desc\": \"Créer les étiquettes parentes manquantes à partir des catégories de Stash-Box, ou attribuer des étiquettes parentes existantes dont le nom correspond exactement\",\n            \"create_parent_label\": \"Créer des étiquettes parentes\"\n        },\n        \"create_or_tag_parent_tags\": \"Créer les étiquettes parentes manquantes ou les associer aux étiquettes existantes\"\n    },\n    \"tagger\": {\n        \"config\": {\n            \"active_stash-box_instance\": \"Instance active Stash-Box :\",\n            \"edit_excluded_fields\": \"Éditer les champs exclus\",\n            \"excluded_fields\": \"Champs exclus :\",\n            \"fields_will_not_be_changed\": \"Ces champs ne seront pas modifiés lors de la mise à jour de {entity}.\",\n            \"no_fields_are_excluded\": \"Aucun champ n'est exclu\",\n            \"no_instances_found\": \"Aucune instance trouvée\"\n        }\n    },\n    \"unsupported_criteria\": \"Critère non supporté : {criteria}\",\n    \"include_sub_folders\": \"Inclure les sous-répertoires\",\n    \"parent_folder\": \"Répertoire parent\",\n    \"sub_folder_depth\": \"Profondeur des sous-répertoires (vide pour tous)\",\n    \"sub_folders\": \"Sous-répertoires\",\n    \"empty_value\": \"vide\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/hi-IN.json",
    "content": "{\n    \"actions\": {\n        \"copy_to_clipboard\": \"क्लिपबोर्ड पर कॉपी करें\",\n        \"clear_front_image\": \"सामने की इमेज हटाएं\",\n        \"add_to_entity\": \"{entityType} में जोड़ें\",\n        \"add_manual_date\": \"तारीख जोड़ें\",\n        \"add_o\": \"O जोड़ें\",\n        \"add_play\": \"प्ले जोड़ें\",\n        \"allow\": \"स्वीकार करें\",\n        \"apply\": \"लगाएं\",\n        \"auto_tag\": \"स्वचालित टैग\",\n        \"backup\": \"बैकअप\",\n        \"browse_for_image\": \"इमेज के लिए ब्राउज़ करें…\",\n        \"cancel\": \"रद्द करें\",\n        \"anonymise\": \"अज्ञात करें\",\n        \"choose_date\": \"तारीख चुनें\",\n        \"clean\": \"साफ़ करें\",\n        \"clear\": \"हटाएं\",\n        \"clear_back_image\": \"पीछे की इमेज हटाएं\",\n        \"clear_date_data\": \"तारीख डेटा को हटाएं\",\n        \"clean_generated\": \"उत्पन्न हुई फ़ाइलें साफ़ करें\",\n        \"clear_image\": \"इमेज हटाएं\",\n        \"confirm\": \"पुष्टि करें\",\n        \"continue\": \"जारी रखें\",\n        \"create\": \"बनाएं\",\n        \"create_chapters\": \"चैप्टर बनाएं\",\n        \"create_parent_studio\": \"पैरेंट स्टूडियो बनाएं\",\n        \"created_entity\": \"बनाया गया {entity_type}: {entity_name}\",\n        \"customise\": \"कस्टमाइज करें\",\n        \"delete_entity\": \"{entityType} मिटाएं\",\n        \"disable\": \"अक्षम करें\",\n        \"delete_generated_supporting_files\": \"उत्पन्न की गई सहायक फ़ाइलें मिटाएं\",\n        \"download\": \"डाउनलोड करें\",\n        \"download_backup\": \"बैकअप डाउनलोड करें\",\n        \"edit_entity\": \"{entityType} सुधारें\",\n        \"enable\": \"सक्षम करें\",\n        \"export\": \"निर्यात करें\",\n        \"export_all\": \"सभी निर्यात करें…\",\n        \"logout\": \"लॉग आउट\",\n        \"merge\": \"मिलाएं\",\n        \"previous_action\": \"पीछे जाएं\",\n        \"reload\": \"पुनः लोड करें\",\n        \"remove\": \"निकालें\",\n        \"assign_stashid_to_parent_studio\": \"मौजूदा पैरेंट स्टूडियो को स्टैश आईडी निर्दिष्ट करें और मेटाडेटा अपडेट करें\",\n        \"close\": \"बंद करें\",\n        \"edit\": \"सुधारें\",\n        \"encoding_image\": \"इमेज को एन्कोड किया जा रहा है…\",\n        \"hide\": \"छिपाएं\",\n        \"next_action\": \"अगला\",\n        \"add\": \"जोड़ें\",\n        \"add_directory\": \"फ़ोल्डर जोड़ें\",\n        \"delete\": \"मिटाएं\",\n        \"delete_file\": \"फ़ाइल मिटाएं\",\n        \"delete_file_and_funscript\": \"फ़ाइल मिटाएं (और फ़नस्क्रिप्ट)\",\n        \"download_anonymised\": \"अज्ञात रूप से डाउनलोड करें\",\n        \"import\": \"आयात करें…\",\n        \"add_entity\": \"{entityType} जोड़ें\",\n        \"allow_temporarily\": \"कुछ समय के लिये स्वीकार करें\",\n        \"create_entity\": \"{entityType} बनाएं\",\n        \"create_marker\": \"मार्कर बनाएं\",\n        \"disallow\": \"अस्वीकार करें\",\n        \"find\": \"खोजें\",\n        \"ignore\": \"नजरअंदाज करें\",\n        \"generate\": \"उत्पन्न करें\",\n        \"preview\": \"पूर्वदर्शन\",\n        \"refresh\": \"ताजा करें\"\n    },\n    \"config\": {\n        \"general\": {\n            \"blobs_storage\": {\n                \"heading\": \"rgfe\"\n            },\n            \"database\": \"डेटाबेस\"\n        }\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/hr-HR.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Dodaj\",\n        \"add_directory\": \"Dodaj mapu\",\n        \"add_entity\": \"Dodaj {entityType}\",\n        \"add_to_entity\": \"Dodaj u {entityType}\",\n        \"allow\": \"Dopusti\",\n        \"allow_temporarily\": \"Dopusti privremeno\",\n        \"apply\": \"Primijeni\",\n        \"auto_tag\": \"Automatsko označavanje\",\n        \"backup\": \"Sigurnosna kopija\",\n        \"browse_for_image\": \"Potražite sliku…\",\n        \"cancel\": \"Odustani\",\n        \"clean\": \"Očisti\",\n        \"clear\": \"Očisti\",\n        \"clear_image\": \"Očisti sliku\",\n        \"close\": \"Zatvori\",\n        \"confirm\": \"Prihvati\",\n        \"continue\": \"Nastavi\",\n        \"create\": \"Napravi\",\n        \"create_entity\": \"Napravi {entityType}\",\n        \"create_marker\": \"Napravi Marker\",\n        \"created_entity\": \"Stvoren {entity_type}: {entity_name}\",\n        \"delete\": \"Izbrisati\",\n        \"delete_entity\": \"Izbrišite {entityType}\",\n        \"delete_file\": \"Izbrišite datoteku\",\n        \"delete_generated_supporting_files\": \"Izbrišite generirane datoteke\",\n        \"disallow\": \"Zabrani\",\n        \"download\": \"Preuzmi\",\n        \"download_backup\": \"Preuzmi Sigurnostnu Kopiju\",\n        \"edit\": \"Uredi\",\n        \"edit_entity\": \"Uredi {entityType}\",\n        \"export\": \"Izvezi\",\n        \"export_all\": \"Izvezi sve…\",\n        \"find\": \"Pronađi\",\n        \"finish\": \"Završi\",\n        \"from_file\": \"Iz datoteke…\",\n        \"from_url\": \"S poveznice…\",\n        \"full_export\": \"Izvoz svega\",\n        \"full_import\": \"Uvoz svega\",\n        \"generate\": \"Generiraj\",\n        \"generate_thumb_default\": \"Generiraj zadanu sliku\",\n        \"generate_thumb_from_current\": \"Generiraj sliku iz trenutnog\",\n        \"hide\": \"Sakrij\",\n        \"hide_configuration\": \"Sakrij konfiguraciju\",\n        \"identify\": \"Identificiraj\",\n        \"ignore\": \"Ignoriraj\",\n        \"import\": \"Uvezi…\",\n        \"import_from_file\": \"Uvezi iz datoteke\",\n        \"logout\": \"Odjava\",\n        \"merge\": \"Spoji\",\n        \"next_action\": \"Dalje\",\n        \"not_running\": \"nije pokrenuto\",\n        \"open_in_external_player\": \"Otvori u vanjskom playeru\",\n        \"open_random\": \"Otvori slučajnu stavku\",\n        \"play_random\": \"Pokreni slučajnu stavku\",\n        \"play_selected\": \"Pokreni odabrano\",\n        \"preview\": \"Pretpregled\",\n        \"previous_action\": \"Natrag\",\n        \"refresh\": \"Osvježi\",\n        \"reload_plugins\": \"Ponovno učitaj dodatke\",\n        \"remove\": \"Ukloni\",\n        \"remove_from_gallery\": \"Ukloni iz Galerije\",\n        \"rename_gen_files\": \"Preimenuj generirane datoteke\",\n        \"rescan\": \"Ponovno skeniraj\",\n        \"running\": \"pokrenuto\",\n        \"save\": \"Spremi\",\n        \"save_delete_settings\": \"Zadano koristi ove opcije pri brisanju\",\n        \"save_filter\": \"Spremi filter\",\n        \"scan\": \"Skeniraj\",\n        \"search\": \"Traži\",\n        \"select_all\": \"Odaberi sve\",\n        \"select_entity\": \"Odaberi {entityType}\",\n        \"select_folders\": \"Odaberi mape\",\n        \"select_none\": \"Odznači sve\",\n        \"selective_auto_tag\": \"Djelomično auto-označavanje\",\n        \"selective_clean\": \"Djelomično čišćenje\",\n        \"selective_scan\": \"Djelomično skeniranje\",\n        \"set_as_default\": \"Postavi kao zadano\",\n        \"set_image\": \"Postavi sliku…\",\n        \"show\": \"Prikaži\",\n        \"show_configuration\": \"Prikaži konfiguraciju\",\n        \"skip\": \"Preskoči\",\n        \"split\": \"Razdjeli\",\n        \"stop\": \"Zaustavi\",\n        \"submit\": \"Potvrdi\",\n        \"submit_stash_box\": \"Prenesi na Stash-Box\",\n        \"submit_update\": \"Podnesi nadopunu\",\n        \"swap\": \"Zamijeni\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Jeste li sigurni da želite započeti čišćenje? Ovaj će postupak obrisati podatke iz baze podataka i sav generirani sadržaj za sve scene i galerije čije su izvorne datoteke obrisane.\",\n            \"dry_mode_selected\": \"Odabran je probni način rada. Ništa neće biti obrisano, datoteke koje više ne postoje će se samo zapisati u konzolu.\",\n            \"import_warning\": \"Da li ste sigurni da želite uvesti? Ovo će izbrisati bazu podataka i ponovno je uvesti iz vaših izvezenih metapodataka.\"\n        },\n        \"temp_disable\": \"Privremeno isključi…\",\n        \"temp_enable\": \"Privremeno uključi…\",\n        \"use_default\": \"Koristi zadane vrijednosti\",\n        \"view_random\": \"Vidi nasumično\",\n        \"add_manual_date\": \"Dodaj ručni datum\",\n        \"add_sub_groups\": \"Dodaj podgrupu\",\n        \"add_o\": \"Dodaj O\",\n        \"add_play\": \"Dodaj reproduciranje\",\n        \"anonymise\": \"Anonimiziraj\",\n        \"assign_stashid_to_parent_studio\": \"Dodijeli Stash ID na postojeći matični studio i ažuriraj metapodatke\",\n        \"choose_date\": \"Odaberi datum\",\n        \"clean_generated\": \"Očisti generirane datoteke\",\n        \"clear_back_image\": \"Obriši stražnju sliku\",\n        \"clear_date_data\": \"Očisti podatke o datumu\",\n        \"clear_front_image\": \"Očisti prednju sliku\",\n        \"copy_to_clipboard\": \"Kopiraj u međuspremnik\",\n        \"create_chapters\": \"Stvori poglavlje\",\n        \"create_parent_studio\": \"Napravi matični studio\",\n        \"customise\": \"Prilagodi\",\n        \"delete_file_and_funscript\": \"Izbriši datoteku (i funscript)\",\n        \"disable\": \"Onemogući\",\n        \"download_anonymised\": \"Preuzmi anonimno\",\n        \"enable\": \"Omogući\",\n        \"encoding_image\": \"Kodiranje slike…\",\n        \"hash_migration\": \"migracija hash-a\",\n        \"load\": \"Učitaj\",\n        \"load_filter\": \"Učitaj filter\",\n        \"make_primary\": \"Učini Primarnim\",\n        \"migrate_blobs\": \"Spoji Blobs\",\n        \"migrate_scene_screenshots\": \"Migriraj Snimke Zaslona Scene\",\n        \"optimise_database\": \"Optimiziraj Bazu Podataka\",\n        \"overwrite\": \"Prebriši\",\n        \"play\": \"Pokreni\",\n        \"reassign\": \"Preraspodjeli\",\n        \"reload\": \"Ponovno Učitaj\",\n        \"reload_scrapers\": \"Ponovno učitaj scraper-e\",\n        \"remove_date\": \"Ukloni datum\",\n        \"remove_from_containing_group\": \"Ukloni iz Grupe\",\n        \"reset_play_duration\": \"Resetiraj duljinu reprodukcije\",\n        \"reset_resume_time\": \"Resetiraj vrijeme nastavka\",\n        \"reset_cover\": \"Vrati Zadanu Naslovnicu\",\n        \"reshuffle\": \"Promješaj\",\n        \"set_back_image\": \"Stražnja slika…\",\n        \"set_cover\": \"Postavi kao Naslovnicu\",\n        \"set_front_image\": \"Prednja slika…\",\n        \"show_results\": \"Prikaži rezultate\",\n        \"show_count_results\": \"Prikaži {count} rezultata\",\n        \"sidebar\": {\n            \"close\": \"Zatvori bočnu traku\",\n            \"open\": \"Otvori bočnu traku\",\n            \"toggle\": \"Uključi/Isključi bočnu traku\"\n        },\n        \"view_history\": \"Vidi povijest\"\n    },\n    \"actions_name\": \"Radnje\",\n    \"age\": \"Dob\",\n    \"age_on_date\": \"{age} u produkciji\",\n    \"aliases\": \"Pseudonimi\",\n    \"all\": \"sve\",\n    \"also_known_as\": \"Također poznat kao\",\n    \"appears_with\": \"Pojavljuje se sa\",\n    \"ascending\": \"Uzlazno\",\n    \"audio_codec\": \"Audio Kodek\",\n    \"average_resolution\": \"Prosječna Rezolucija\",\n    \"between_and\": \"i\",\n    \"birth_year\": \"Godina Rođenja\",\n    \"birthdate\": \"Datum rođenja\",\n    \"bitrate\": \"Brzina Prijenosa Podataka\",\n    \"blobs_storage_type\": {\n        \"database\": \"Baza podataka\",\n        \"filesystem\": \"Datotečni sustav\"\n    },\n    \"captions\": \"Natpisi\",\n    \"career_length\": \"Duljina Karijere\",\n    \"chapters\": \"Poglavlja\",\n    \"circumcised\": \"Obrezan\",\n    \"circumcised_types\": {\n        \"CUT\": \"Izrezan\",\n        \"UNCUT\": \"Neizrezan\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktivna stash-box instanca:\",\n            \"blacklist_desc\": \"Stavke crne liste isključene su iz upita. Imajte na umu da su to regularni izrazi i da nisu osjetljivi na velika i mala slova. Određeni znakovi moraju se izbjeći obrnutom kosom crtom: {chars_require_escape}\",\n            \"blacklist_label\": \"Crna Lista\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Duplikat stavke crne liste\"\n            },\n            \"mark_organized_desc\": \"Odmah označi scenu kao Organiziranu nakon što je tipka Spremi stisnuta.\",\n            \"mark_organized_label\": \"Označi kao organizirano pri spremanju\",\n            \"query_mode_auto\": \"Automatski\",\n            \"query_mode_auto_desc\": \"Koristi metapodatke ako postoje, ili ime datoteke\",\n            \"query_mode_dir\": \"Dir\",\n            \"query_mode_dir_desc\": \"Koristi samo nadređeni direktorij video datoteke\",\n            \"query_mode_filename\": \"Ime datoteke\",\n            \"query_mode_filename_desc\": \"Koristi samo ime datoteke\",\n            \"query_mode_label\": \"Query Mod\",\n            \"query_mode_metadata\": \"Metapodaci\",\n            \"query_mode_metadata_desc\": \"Koristi samo metapodatke\",\n            \"query_mode_path\": \"Putanja\",\n            \"query_mode_path_desc\": \"Koristi cijelu putanju datoteke\",\n            \"set_cover_desc\": \"Zamijeni sliku naslovnice scene ako je pronađena.\",\n            \"set_cover_label\": \"Postavi sliku naslovnice scene\",\n            \"set_tag_desc\": \"Priložite oznake sceni, bilo prepisivanjem ili spajanjem s postojećim oznakama na sceni.\",\n            \"set_tag_label\": \"Postavi oznake\",\n            \"source\": \"Izvor\"\n        },\n        \"noun_query\": \"Upit\",\n        \"results\": {\n            \"duration_unknown\": \"Trajanje nepoznato\",\n            \"fp_matches\": \"Trajanje se podudara\",\n            \"fp_matches_multi\": \"Trajanje odgovara otiscima {matchCount}/{durationsLength}\",\n            \"hash_matches\": \"{hash_type} se podudara\",\n            \"match_failed_already_tagged\": \"Scena već označena\",\n            \"match_failed_no_result\": \"Nisu pronađeni rezultati\",\n            \"match_success\": \"Scena uspješno označena\",\n            \"unnamed\": \"Neimenovano\"\n        },\n        \"verb_matched\": \"Podudara se\",\n        \"verb_toggle_unmatched\": \"{toggle} neusklađene scene\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Izradi hash:\",\n            \"build_time\": \"Vrijeme izrade:\",\n            \"check_for_new_version\": \"Provjeri za novu verziju\",\n            \"latest_version\": \"Zadnja Verzija\",\n            \"latest_version_build_hash\": \"Izrađen Hash Zadnje Verzije:\",\n            \"new_version_notice\": \"[NOVO]\",\n            \"release_date\": \"Datum izdanja:\",\n            \"stash_discord\": \"Pridruži se našem {url} kanalu\",\n            \"stash_home\": \"Stash početna stranica na {url}\",\n            \"stash_open_collective\": \"Podrži nas kroz {url}\",\n            \"stash_wiki\": \"Stash {url} stranica\",\n            \"version\": \"Verzija\"\n        },\n        \"advanced_mode\": \"Napredan Način\",\n        \"application_paths\": {\n            \"heading\": \"Putanje Aplikacije\"\n        },\n        \"categories\": {\n            \"about\": \"O nama\",\n            \"changelog\": \"Zapisnik promjena\",\n            \"interface\": \"Sučelje\",\n            \"logs\": \"Zapisnici\",\n            \"metadata_providers\": \"Pružatelji Metapodataka\",\n            \"plugins\": \"Dodaci\",\n            \"security\": \"Sigurnost\",\n            \"services\": \"Usluge\",\n            \"system\": \"Sistem\",\n            \"tasks\": \"Zadaci\",\n            \"tools\": \"Alati\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Dopusti {tempIP}\",\n            \"allowed_ip_addresses\": \"Dopuštene IP adrese\",\n            \"allowed_ip_temporarily\": \"Dopušten IP privremeno\",\n            \"default_ip_whitelist\": \"Zadana bijela lista IP adresa\",\n            \"default_ip_whitelist_desc\": \"Zadane IP adrese omogućuju pristup DLNA. Koristi {wildcard} za dopuštanje svih IP adresa.\",\n            \"disabled_dlna_temporarily\": \"Onemogućen DLNA privremeno\",\n            \"disallowed_ip\": \"Nedopušen IP\",\n            \"enabled_by_default\": \"Omogućeno prema zadanim postavkama\",\n            \"enabled_dlna_temporarily\": \"Omogućen DLNA privremeno\",\n            \"network_interfaces\": \"Sučelja\",\n            \"network_interfaces_desc\": \"Sučelja na kojima će se izložiti DLNA poslužitelj. Prazan popis rezultira pokretanjem na svim sučeljima. Zahtijeva ponovno pokretanje DLNA nakon promjene.\",\n            \"recent_ip_addresses\": \"Nedavne IP adrese\",\n            \"server_display_name\": \"Prikazni Naziv Poslužitelja\",\n            \"server_display_name_desc\": \"Prikazni naziv za DLNA poslužitelj. Zadano je {server_name} ako je prazno.\",\n            \"server_port\": \"Port Poslužitelja\",\n            \"server_port_desc\": \"Port na kojem će se pokretati DLNA poslužitelj. Zahtijeva ponovno pokretanje DLNA nakon promjene.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Uspješno otkazano privremeno ponašanje\",\n            \"until_restart\": \"do ponovnog pokretanja\",\n            \"video_sort_order\": \"Zadani Redoslijed Sortiranja Videozapisa\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API Ključ\",\n                \"api_key_desc\": \"API ključ za vanjske sustave. Potreban je samo kada je konfigurirano korisničko ime/lozinka. Korisničko ime mora biti spremljeno prije generiranja API ključa.\",\n                \"authentication\": \"Autentifikacija\",\n                \"clear_api_key\": \"Obriši API ključ\",\n                \"credentials\": {\n                    \"description\": \"Vjerodajnice za ograničavanje pristupa zalihama.\",\n                    \"heading\": \"Vjerodajnice\"\n                },\n                \"generate_api_key\": \"Generiraj API ključ\",\n                \"log_file\": \"Datoteka zapisnika\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/hu-HU.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Hozzáadás\",\n        \"add_directory\": \"Mappa Hozzáadása\",\n        \"add_entity\": \"{entityType} Hozzáadása\",\n        \"add_to_entity\": \"{entityType}hoz Adás\",\n        \"allow\": \"Engedélyez\",\n        \"allow_temporarily\": \"Időszakosan Engedélyez\",\n        \"anonymise\": \"Anonimizálás\",\n        \"apply\": \"Alkalmaz\",\n        \"auto_tag\": \"Automatikus Címkézés\",\n        \"backup\": \"Biztonsági Mentés\",\n        \"browse_for_image\": \"Kép tallózása…\",\n        \"cancel\": \"Mégsem\",\n        \"clean\": \"Tisztítás\",\n        \"clear\": \"Törlés\",\n        \"clear_back_image\": \"Hátsó kép törlése\",\n        \"clear_front_image\": \"Első kép törlése\",\n        \"clear_image\": \"Kép Törlése\",\n        \"close\": \"Bezár\",\n        \"confirm\": \"Jóváhagyás\",\n        \"continue\": \"Folytatás\",\n        \"create\": \"Létrehoz\",\n        \"create_entity\": \"{entityType} Létrehozása\",\n        \"delete\": \"Törlés\",\n        \"delete_entity\": \"{entityType} Törlése\",\n        \"delete_file\": \"Fájl Törlése\",\n        \"delete_generated_supporting_files\": \"Létrehozott kiegészítő fájlok törlése\",\n        \"disallow\": \"Tiltás\",\n        \"download\": \"Letöltés\",\n        \"download_backup\": \"Biztonsági Mentés Letöltése\",\n        \"edit\": \"Módosít\",\n        \"edit_entity\": \"{entityType} Módosítása\",\n        \"export\": \"Exportálás\",\n        \"export_all\": \"Összes exportálása…\",\n        \"find\": \"Keresés\",\n        \"finish\": \"Befejez\",\n        \"from_file\": \"Fáljból…\",\n        \"from_url\": \"URL-ből…\",\n        \"full_export\": \"Teljes Export\",\n        \"full_import\": \"Teljes Import\",\n        \"generate\": \"Generálás\",\n        \"generate_thumb_default\": \"Alapértelmezett bélyegkép generálása\",\n        \"generate_thumb_from_current\": \"Bélyegkép generálása a jelenlegiből\",\n        \"hide\": \"Elrejtés\",\n        \"hide_configuration\": \"Beállítások Elrejtése\",\n        \"identify\": \"Beazonosítás\",\n        \"ignore\": \"Mellőz\",\n        \"import\": \"Importálás…\",\n        \"import_from_file\": \"Importálás fájlból\",\n        \"logout\": \"Kijelentkezés\",\n        \"merge\": \"Egyesítés\",\n        \"next_action\": \"Következő\",\n        \"not_running\": \"nem fut\",\n        \"open_random\": \"Véletlenszerű Megnyitása\",\n        \"overwrite\": \"Felülír\",\n        \"play_random\": \"Véletlenszerű Lejátszása\",\n        \"play_selected\": \"Kiválasztott Lejátszása\",\n        \"preview\": \"Előnézet\",\n        \"previous_action\": \"Vissza\",\n        \"refresh\": \"Frissítés\",\n        \"reload_plugins\": \"Pluginek újratöltése\",\n        \"reload_scrapers\": \"Scrapperek újratöltése\",\n        \"remove\": \"Eltávolítás\",\n        \"rename_gen_files\": \"Generált fájlok átnevezése\",\n        \"rescan\": \"Újra Szkennelés\",\n        \"reshuffle\": \"Újrakeverés\",\n        \"running\": \"fut\",\n        \"save\": \"Mentés\",\n        \"save_filter\": \"Szűrő mentése\",\n        \"scan\": \"Szkennelés\",\n        \"search\": \"Keresés\",\n        \"select_all\": \"Mind Kijelölése\",\n        \"select_entity\": \"{entityType} Kijelölése\",\n        \"select_folders\": \"Mappák kijelölése\",\n        \"select_none\": \"Kijelölés Törlése\",\n        \"set_as_default\": \"Beállítás alapértelmezettként\",\n        \"set_image\": \"Kép beállítása…\",\n        \"show\": \"Megjelenít\",\n        \"show_configuration\": \"Beállítások Megjelenítése\",\n        \"skip\": \"Kihagyás\",\n        \"stop\": \"Megállít\",\n        \"submit\": \"Beküldés\",\n        \"submit_stash_box\": \"Stash-Box-ba Beküldés\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Biztos vagy benne hogy el akarod végezni a Tisztítást? Ez a művelet le fogja törölni a fáljrendszerben már nem megtalálható összes jelenet és galéria adatbázis információit és generált tartalmait.\",\n            \"import_warning\": \"Biztos vagy benne, hogy importálni akarsz? Ez a művelet le fogja törölni az adatbázist és újraimportálja az kiexportált metaadatok alapján.\",\n            \"dry_mode_selected\": \"\\\"Száraz mód\\\" került kiválasztásra. Valós törlés nem fog történni, csak naplózás.\"\n        },\n        \"use_default\": \"Alapértelmezett használata\",\n        \"disable\": \"Letiltás\",\n        \"assign_stashid_to_parent_studio\": \"Stash azonosító hozzárendelése a meglévő szülő stúdióhoz és a metaadatok frissítése\",\n        \"create_chapters\": \"Fejezet hozzáadása\",\n        \"create_marker\": \"Jelölő létrehozása\",\n        \"create_parent_studio\": \"Szülő stúdió létrehozása\",\n        \"created_entity\": \"Létrehozott {entity_type}: {entity_name}\",\n        \"customise\": \"Testreszabás\",\n        \"delete_file_and_funscript\": \"Fájl törlése (és funscript)\",\n        \"download_anonymised\": \"Letöltés névtelenül\",\n        \"encoding_image\": \"Kép kódolása…\",\n        \"hash_migration\": \"hash-migráció\",\n        \"make_primary\": \"Beállítás elsődlegesnek\",\n        \"migrate_blobs\": \"Blobok migrálása\",\n        \"migrate_scene_screenshots\": \"Jelenet képernyőképek migrálása\",\n        \"optimise_database\": \"Adatbázis optimalizálása\",\n        \"reassign\": \"Átcsoportosítás\",\n        \"remove_from_gallery\": \"Eltávolítás a galériából\",\n        \"save_delete_settings\": \"Törléskor alapértelmezés szerint ezeket a beállításokat használja\",\n        \"scrape\": \"Kinyerés\",\n        \"scrape_query\": \"Lekérés kinyerése\",\n        \"scrape_scene_fragment\": \"Kinyerés egység szerint\",\n        \"scrape_with\": \"Kinyerés…\",\n        \"selective_auto_tag\": \"Automatikus címkézés\",\n        \"selective_clean\": \"Szelektív tisztítás\",\n        \"selective_scan\": \"Szelektív szkennelés\",\n        \"set_back_image\": \"Hátlapi kép…\",\n        \"set_front_image\": \"Borítókép…\",\n        \"split\": \"Darabolás\",\n        \"submit_update\": \"Frissítés elfogadása\",\n        \"swap\": \"Csere\",\n        \"temp_disable\": \"Letiltás ideiglenesen…\",\n        \"temp_enable\": \"Engedélyezés ideiglenesen…\",\n        \"unset\": \"Visszavon\",\n        \"view_random\": \"Véletlenszerű megtekintés\",\n        \"enable\": \"Engedélyezés\",\n        \"open_in_external_player\": \"Megnyitás külső lejátszóban\",\n        \"reload\": \"Újratöltés\",\n        \"add_sub_groups\": \"Al kategóriák hozzá adása\",\n        \"add_manual_date\": \"Dátum Manuális hozzá adása\",\n        \"add_o\": \"O hozzá adása\",\n        \"choose_date\": \"Válassz egy dátumot\",\n        \"clean_generated\": \"Generált fájlok takarítása\",\n        \"clear_date_data\": \"Dátum adatok törlése\",\n        \"copy_to_clipboard\": \"Vágólapra másolás\"\n    },\n    \"age\": \"Kor\",\n    \"aliases\": \"Álnevek\",\n    \"all\": \"mind\",\n    \"ascending\": \"Növekvő\",\n    \"average_resolution\": \"Átlagos Felbontás\",\n    \"birth_year\": \"Születési Év\",\n    \"birthdate\": \"Születési Dátum\",\n    \"bitrate\": \"Bitráta\",\n    \"career_length\": \"Karier Hossza\",\n    \"component_tagger\": {\n        \"config\": {\n            \"blacklist_label\": \"Tiltólista\",\n            \"query_mode_auto\": \"Automatikus\",\n            \"query_mode_dir\": \"Mappa\",\n            \"query_mode_filename\": \"Fájlnév\",\n            \"query_mode_metadata\": \"Metaadat\",\n            \"query_mode_path\": \"Elérési út\",\n            \"source\": \"Forrás\",\n            \"active_instance\": \"Aktív stash-box példány:\",\n            \"blacklist_desc\": \"A tiltólistás elemek nem látszódnak a lekérésekben. Fontos tudni, hogy a regex kifejezések érzékenyek a kis-és nagybetűkre. Bizonyos karakterek után kötelező a \\\\ : {chars_require_escape}\",\n            \"mark_organized_desc\": \"Azonnal megjelöli a jelenetet \\\"Rendezettnek\\\", amint a \\\"Mentés\\\" gomb lenyomásra került.\",\n            \"mark_organized_label\": \"Mentéskor jelölje rendezettnek\",\n            \"query_mode_auto_desc\": \"Ha léteznek metaadatok, használja azokat vagy a fájlnevet\",\n            \"query_mode_dir_desc\": \"Csak a videó szülőkönyvtárának használata\",\n            \"query_mode_filename_desc\": \"Csak a fájlnév használata\",\n            \"query_mode_label\": \"Lekérő mód\",\n            \"query_mode_metadata_desc\": \"Csak metaadatok használata\",\n            \"query_mode_path_desc\": \"A teljes elérési út használata\",\n            \"set_cover_desc\": \"Kicseréli a jelenet borítóképét ha talál egyet.\",\n            \"set_cover_label\": \"A borítókép cseréje\",\n            \"set_tag_desc\": \"Tagek hozzáadása a jelenethez, felülírva vagy egybeolvasztva a már meglévőekkel.\",\n            \"set_tag_label\": \"Tagek beállítása\"\n        },\n        \"results\": {\n            \"duration_unknown\": \"Ismeretlen hossz\",\n            \"unnamed\": \"Névtelen\",\n            \"fp_matches\": \"Az időhossz stimmel\",\n            \"hash_matches\": \"{hash_type} egyezik\",\n            \"match_failed_already_tagged\": \"A jelenet már tartalmaz tageket\",\n            \"match_failed_no_result\": \"Nincsenek találatok\",\n            \"match_success\": \"A jelenet tagelése sikeres\"\n        },\n        \"verb_matched\": \"Egyezik\",\n        \"noun_query\": \"Lekérés\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} nem egyező jelenetek\"\n    },\n    \"config\": {\n        \"about\": {\n            \"latest_version\": \"Legfrissebb Verzió\",\n            \"version\": \"Verzió\",\n            \"latest_version_build_hash\": \"Legújabb verzió build hash-e:\",\n            \"check_for_new_version\": \"Új verzió keresése\",\n            \"new_version_notice\": \"[ÚJ]\",\n            \"release_date\": \"Megjelenés dátuma:\",\n            \"stash_discord\": \"Csatlakozz a csatornához {url}\"\n        },\n        \"categories\": {\n            \"logs\": \"Logok\",\n            \"metadata_providers\": \"Metaadat Szolgáltatók\",\n            \"plugins\": \"Pluginek\",\n            \"security\": \"Biztonság\",\n            \"services\": \"Szolgáltatások\",\n            \"system\": \"Rendszer\",\n            \"tasks\": \"Feladatok\",\n            \"tools\": \"Eszközök\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"{tempIP} Engedélyezése\",\n            \"allowed_ip_addresses\": \"Engedélyezett IP-címek\",\n            \"default_ip_whitelist\": \"Alapértelmezett IP Engedélyezőlista\",\n            \"disallowed_ip\": \"Letiltott IP\",\n            \"enabled_by_default\": \"Alapértelmezetten Engedélyezve\",\n            \"network_interfaces\": \"Kezelőfelületek\",\n            \"recent_ip_addresses\": \"Legutóbbi IP-címek\",\n            \"server_display_name\": \"Szerver Megjelenített Neve\",\n            \"until_restart\": \"újraindításig\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API Kulcs\",\n                \"authentication\": \"Hitelesítés\",\n                \"clear_api_key\": \"API kulcs törlése\",\n                \"generate_api_key\": \"API kulcs generálása\",\n                \"log_file\": \"Log fájl\",\n                \"password\": \"Jelszó\",\n                \"username\": \"Felhasználónév\"\n            },\n            \"calculate_md5_and_ohash_label\": \"MD5 kiszámítása a videókhoz\",\n            \"chrome_cdp_path\": \"Chrome CDP elérési út\",\n            \"create_galleries_from_folders_desc\": \"Igaz esetén galériákat készít a képeket tartalmazó mappákból.\",\n            \"create_galleries_from_folders_label\": \"Galériák készítése a képeket tartalmazó mappákból\",\n            \"db_path_head\": \"Adatbázis Elérési Út\",\n            \"image_ext_desc\": \"Képként értelmezendő fájlkiterjesztések vesszővel elválasztott listája.\",\n            \"include_audio_desc\": \"Hozzáadja a hangsávot a generált bemutatókhoz.\",\n            \"include_audio_head\": \"Hangsáv hozzáadása\",\n            \"logging\": \"Logolás\",\n            \"metadata_path\": {\n                \"heading\": \"Metaadatok Elérési Útja\"\n            },\n            \"preview_generation\": \"Bemutató Generálás\",\n            \"python_path\": {\n                \"heading\": \"Python Elérési Út\"\n            },\n            \"sqlite_location\": \"SQLite adatbázis-fájl elérési útja (újraindítás szükséges)\",\n            \"video_ext_desc\": \"Videóként értelmezendő fájlkiterjesztések vesszővel elválasztott listája.\",\n            \"video_head\": \"Videó\"\n        },\n        \"library\": {\n            \"exclusions\": \"Kivételek\",\n            \"gallery_and_image_options\": \"Galéria és Kép beállítások\"\n        },\n        \"logs\": {\n            \"log_level\": \"Logolás Szintje\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metaadatok\",\n            \"search_by_name\": \"Keresés név szerint\",\n            \"supported_types\": \"Támogatott típusok\",\n            \"supported_urls\": \"URL-ek\"\n        },\n        \"stashbox\": {\n            \"api_key\": \"API kulcs\",\n            \"name\": \"Név\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"{operation_name} hozzáadva a feladatlistához\",\n            \"backing_up_database\": \"Adatbázis biztonsági mentése folyamatban\",\n            \"backup_and_download\": \"Biztonsági mentést hajt végre az adatbázison és letölti a fájlt.\",\n            \"cleanup_desc\": \"Ellenőrzi hogy hiányoznak-e fájlok, és eltávolítja őket az adatbázisból. Ez egy visszavonhatatlan művelet.\",\n            \"data_management\": \"Adatkezelés\",\n            \"defaults_set\": \"Az alapértelmezett értékek be lettek állítva, és ezek lesznek használva a Feladatok oldalon a {action} gomb megnyomásakor.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Ne csatolja a fájlkiterjesztést a címhez\",\n            \"empty_queue\": \"Jelenleg nem fut feladat.\",\n            \"generate_thumbnails_during_scan\": \"Bélyegképek generálása a képekhez\",\n            \"generate_video_previews_during_scan\": \"Bemutatók generálása\",\n            \"generated_content\": \"Legenerált Tartalom\",\n            \"identify\": {\n                \"default_options\": \"Alapértelmezett Beállítások\",\n                \"field\": \"Mező\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Mező Beállítások\",\n                \"heading\": \"Azonosítás\",\n                \"identifying_scenes\": \"{num} {scene} azonosítása\",\n                \"set_cover_images\": \"Borítoképek beállítása\",\n                \"source\": \"Forrás\",\n                \"source_options\": \"{source} Beállítás\",\n                \"sources\": \"Források\",\n                \"strategy\": \"Stratégia\"\n            },\n            \"job_queue\": \"Feladatlista\",\n            \"maintenance\": \"Karbantartás\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Összes elérési út szkennelése\"\n            }\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Dupla Jelenet Ellenőrző\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Mező Hozzáadása\",\n                \"capitalize_title\": \"Nagybetűs cím\",\n                \"display_fields\": \"Megjelenített mezők\",\n                \"filename\": \"Fájlnév\",\n                \"filename_pattern\": \"Fájlnév Minta\",\n                \"ignored_words\": \"Figyelmen kívül hagyott szavak\"\n            },\n            \"scene_tools\": \"Jelenet Eszközök\"\n        },\n        \"ui\": {\n            \"basic_settings\": \"Alapvető Beállítások\",\n            \"custom_css\": {\n                \"description\": \"Az oldalt újra be kell tölteni hogy a változtatások életbe lépjenek.\",\n                \"heading\": \"Egyéni CSS\",\n                \"option_label\": \"Egyéni CSS engedélyezve\"\n            },\n            \"delete_options\": {\n                \"description\": \"Alapértelmezett beállítások képek, galériák és jelenetek törlése esetén.\",\n                \"heading\": \"Törlési Beállítások\",\n                \"options\": {\n                    \"delete_file\": \"Fájl törlése alapértelmezettként\",\n                    \"delete_generated_supporting_files\": \"Generált kiegészítő fájlok törlése alapértelmezettként\"\n                }\n            },\n            \"desktop_integration\": {\n                \"notifications_enabled\": \"Értesítések Engedélyezése\"\n            },\n            \"editing\": {\n                \"heading\": \"Szerkesztés\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Csatlakozás\"\n            },\n            \"images\": {\n                \"heading\": \"Képek\"\n            },\n            \"interactive_options\": \"Interaktív Beállítások\",\n            \"language\": {\n                \"heading\": \"Nyelv\"\n            },\n            \"preview_type\": {\n                \"options\": {\n                    \"animated\": \"Mozgókép\",\n                    \"static\": \"Állókép\",\n                    \"video\": \"Videó\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Jelenetlista\"\n            },\n            \"scene_player\": {\n                \"heading\": \"Jelenet-lejátszó\",\n                \"options\": {\n                    \"auto_start_video\": \"Automatikus videóindítás\"\n                }\n            },\n            \"scene_wall\": {\n                \"options\": {\n                    \"toggle_sound\": \"Hang engedélyezése\"\n                }\n            },\n            \"title\": \"Felhasználói Felület\"\n        }\n    },\n    \"configuration\": \"Beállítások\",\n    \"country\": \"Ország\",\n    \"cover_image\": \"Borítókép\",\n    \"created_at\": \"Létrehozva\",\n    \"criterion\": {\n        \"greater_than\": \"Nagyobb mint\",\n        \"less_than\": \"Kisebb mint\",\n        \"value\": \"Érték\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"között\",\n        \"equals\": \"egyenlő\",\n        \"excludes\": \"kivéve\",\n        \"includes\": \"beleértve\",\n        \"includes_all\": \"mindet belevéve\",\n        \"is_null\": \"egyenlő null\",\n        \"matches_regex\": \"megfelel regex-nek\",\n        \"not_between\": \"nincs közte\",\n        \"not_equals\": \"nem egyenlő\"\n    },\n    \"custom\": \"Egyéni\",\n    \"date\": \"Dátum\",\n    \"death_date\": \"Halál Dátuma\",\n    \"death_year\": \"Halál Éve\",\n    \"descending\": \"Csökkenő\",\n    \"detail\": \"Részlet\",\n    \"details\": \"Részletek\",\n    \"developmentVersion\": \"Fejlesztői Verzió\",\n    \"dialogs\": {\n        \"delete_object_desc\": \"Biztos hogy törölni akarod {count, plural, one {ezt a {singularEntity}} ezeket a {these {pluralEntity}}}?\",\n        \"export_title\": \"Exportálás\",\n        \"lightbox\": {\n            \"display_mode\": {\n                \"original\": \"Eredeti\"\n            },\n            \"options\": \"Beállítások\",\n            \"scroll_mode\": {\n                \"zoom\": \"Nagyítás\"\n            }\n        },\n        \"scene_gen\": {\n            \"preview_exclude_end_time_desc\": \"Az utolsó x másodperc kihagyása a jelenetek bemutatóiból. Ez az érték lehet másodpercben, vagy a jelenet teljes hosszának százalékában (pl. 2%) is megadva.\",\n            \"preview_exclude_start_time_desc\": \"Az első x másodperc kihagyása a jelenetek bemutatóiból. Ez az érték lehet másodpercben, vagy a jelenet teljes hosszának százalékában (pl. 2%) is megadva.\",\n            \"video_previews\": \"Előnézetek\"\n        },\n        \"scrape_results_existing\": \"Létező\",\n        \"set_image_url_title\": \"Kép URL\"\n    },\n    \"director\": \"Rendező\",\n    \"display_mode\": {\n        \"grid\": \"Háló\",\n        \"list\": \"Lista\",\n        \"unknown\": \"Ismeretlen\",\n        \"wall\": \"Fal\"\n    },\n    \"donate\": \"Adomány\",\n    \"dupe_check\": {\n        \"description\": \"'Pontos' alatti szintek kiszámítása tovább tarthat. Hibás találatok is megjelenhetnek alacsonyabb pontossági szinteken.\",\n        \"options\": {\n            \"exact\": \"Pontos\",\n            \"high\": \"Magas\",\n            \"low\": \"Alacsony\",\n            \"medium\": \"Közepes\"\n        },\n        \"search_accuracy_label\": \"Keresési Pontosság\",\n        \"title\": \"Megkettőzött Jelenetek\",\n        \"duration_options\": {\n            \"any\": \"Akármelyik\"\n        }\n    },\n    \"duplicated_phash\": \"Megkettőzőtt (phash)\",\n    \"duration\": \"Hossz\",\n    \"effect_filters\": {\n        \"blue\": \"Kék\",\n        \"blur\": \"Elmosás\",\n        \"brightness\": \"Fényerő\",\n        \"contrast\": \"Kontraszt\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Zöld\",\n        \"hue\": \"Árnyalat\",\n        \"name\": \"Szűrők\",\n        \"red\": \"Piros\",\n        \"reset_filters\": \"Szűrők Törlése\",\n        \"rotate\": \"Forgat\",\n        \"saturation\": \"Szaturáció\",\n        \"scale\": \"Méretarány\",\n        \"warmth\": \"Melegség\"\n    },\n    \"ethnicity\": \"Etnikum\",\n    \"eye_color\": \"Szemszín\",\n    \"fake_tits\": \"Szilikonmellek\",\n    \"false\": \"Hamis\",\n    \"favourite\": \"Kedvenc\",\n    \"file\": \"fájl\",\n    \"file_info\": \"Fájl Információ\",\n    \"files\": \"fájlok\",\n    \"filesize\": \"Fájl Méret\",\n    \"filter\": \"Szűrő\",\n    \"filters\": \"Szűrők\",\n    \"galleries\": \"Galériák\",\n    \"gallery\": \"Galéria\",\n    \"gallery_count\": \"Galéria Száma\",\n    \"gender\": \"Nem\",\n    \"gender_types\": {\n        \"FEMALE\": \"Nő\",\n        \"INTERSEX\": \"Nemek közti\",\n        \"MALE\": \"Férfi\"\n    },\n    \"hair_color\": \"Hajszín\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Kapcsolódás\",\n        \"disconnected\": \"Szétkapcsolt\",\n        \"missing\": \"Hiányzó\",\n        \"ready\": \"Kész\",\n        \"uploading\": \"Szkript feltöltése\"\n    },\n    \"height\": \"Magasság\",\n    \"help\": \"Segítség\",\n    \"image\": \"Kép\",\n    \"image_count\": \"Képek Száma\",\n    \"images\": \"Képek\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktív\",\n    \"interactive_speed\": \"Interaktív sebesség\",\n    \"isMissing\": \"Hiányzik\",\n    \"library\": \"Könyvtár\",\n    \"loading\": {\n        \"generic\": \"Betöltés…\"\n    },\n    \"media_info\": {\n        \"downloaded_from\": \"Letöltés Forrása\",\n        \"interactive_speed\": \"Interaktív sebesség\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\"\n        }\n    },\n    \"metadata\": \"Metaadatok\",\n    \"name\": \"Név\",\n    \"new\": \"Új\",\n    \"none\": \"Nincs\",\n    \"operations\": \"Műveletek\",\n    \"organized\": \"Rendezve\",\n    \"pagination\": {\n        \"first\": \"Első\",\n        \"last\": \"Utolsó\",\n        \"next\": \"Következő\",\n        \"previous\": \"Előző\"\n    },\n    \"parent_tags\": \"Szülő-címkék\",\n    \"path\": \"Elérési Út\",\n    \"performer\": \"Szereplő\",\n    \"performer_age\": \"Szereplő Kora\",\n    \"performer_count\": \"Szereplők Száma\",\n    \"performer_favorite\": \"Szereplő Kedvencek Közt\",\n    \"performer_image\": \"Szereplő Képe\",\n    \"performer_tagger\": {\n        \"current_page\": \"Jelenlegi oldal\",\n        \"network_error\": \"Hálózati Hiba\",\n        \"tag_status\": \"Címke Státusza\",\n        \"update_performer\": \"Szereplő Frissítése\",\n        \"update_performers\": \"Szereplők Frissítése\",\n        \"name_already_exists\": \"A név már létezik\"\n    },\n    \"performer_tags\": \"Szereplő Címkék\",\n    \"performers\": \"Szereplők\",\n    \"piercings\": \"Piercingek\",\n    \"queue\": \"Sor\",\n    \"random\": \"Véletlenszerű\",\n    \"rating\": \"Értékelés\",\n    \"resolution\": \"Felbontás\",\n    \"scene\": \"Jelenet\",\n    \"sceneTagger\": \"Jelenetcímkéző\",\n    \"scene_count\": \"Jelenetszám\",\n    \"scene_id\": \"Jelenet ID\",\n    \"scene_tags\": \"Jelenetcímkék\",\n    \"scenes\": \"Jelenetek\",\n    \"search_filter\": {\n        \"name\": \"Szűrő\",\n        \"saved_filters\": \"Mentett szűrők\",\n        \"update_filter\": \"Szűrő Frissítése\"\n    },\n    \"seconds\": \"Másodperc\",\n    \"settings\": \"Beállítások\",\n    \"setup\": {\n        \"confirm\": {\n            \"nearly_there\": \"Már majdnem kész!\"\n        },\n        \"errors\": {\n            \"something_went_wrong_while_setting_up_your_system\": \"Valami hiba történt a rendszer beállításakor. Itt a hibaüzenet: {error}\",\n            \"something_went_wrong\": \"Valami elromlott\"\n        },\n        \"folder\": {\n            \"file_path\": \"Fájl elérési út\"\n        },\n        \"migrate\": {\n            \"backup_recommended\": \"Ajánlott az adatbázis biztonsági mentése az áttelepítés előtt.Meg tudjuk ezt tenni neked az adatbázis átmásolásával a <code>{defaultBackupPath}</code> címre.\",\n            \"migrating_database\": \"Adatbázis áttelepítése\",\n            \"migration_failed\": \"Sikertelen áttelepítés\",\n            \"migration_irreversible_warning\": \"A séma áttelepítése nem visszafordítható folyamat. Amint az áttelepítés elkezdődik, az adatbázis összeegyeztethetetlen lesz a Stash előző verzióival.\",\n            \"migration_required\": \"Áttelepítés szükséges\",\n            \"schema_too_old\": \"A jelenlegi Stash adatbázis verziója <strong>{databaseSchema}</strong> , amit át kell telepíteni <strong>{appSchema}</strong> verzióra. A Stash ezen verziója nem fog működni az adatbázis áttelepítése nélkül.\",\n            \"perform_schema_migration\": \"sémamigráció végre hajtása\"\n        },\n        \"success\": {\n            \"help_links\": \"Ha problémába ütközöl, kérdésed, vagy javaslatod van, nyugodtan jelezd {githubLink}, vagy kérdezd meg a közösségtől {discordLink}.\",\n            \"support_us\": \"Támogass minket\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"A Stash elöszőr a jelenlegi munkakönyvtárban próbálja a konfigurációs fájlját (<code>config.yml</code>) megkeresni. Ha ott nem találja, akkor a következő helyen próbálkozik: <code>$HOME/.stash/config.yml</code> (Windows rendszeren: <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>). Meghatározhatja hogy a Stash egy bizonyos konfigurációs fájlt használjon, ammennyiben a <code>-c '<path to config file>'</code> or <code>--config '<path to config file>'</code> paraméterekkel indítja.\",\n            \"unexpected_explained\": \"Amennyiben ez a képernyő váratlanul bukkant fel, próbáld újraindítani a Stasht a megfelelő munkakönyvtárban, vagy a <code>-c</code> flag-gel.\"\n        },\n        \"welcome_specific_config\": {\n            \"unable_to_locate_specified_config\": \"Ha ezt olvasod, akkor a Stash nem találta meg a konfigurációs fájlt, amit megadtál a parancssorban. Ez a varázsló végigvezet a lépéseken, hogy új konfigurációs fájlt tudj beállítani.\",\n            \"config_path\": \"A Stash a következő konfigurációs fájl útvonalat fogja használni: <code>{path}</code>\"\n        },\n        \"paths\": {\n            \"where_is_your_porn_located\": \"Hol található a pornód?\"\n        }\n    },\n    \"stashbox\": {\n        \"submission_failed\": \"Beküldés sikertelen\",\n        \"submission_successful\": \"Beküldés sikeres\"\n    },\n    \"statistics\": \"Statisztikák\",\n    \"stats\": {\n        \"image_size\": \"Képek mérete\",\n        \"scenes_duration\": \"Jelenetek hossza\",\n        \"scenes_size\": \"Jelenetek mérete\"\n    },\n    \"status\": \"Státusz: {statusText}\",\n    \"studio\": \"Stúdió\",\n    \"studios\": \"Stúdiók\",\n    \"tag\": \"Címke\",\n    \"tag_count\": \"Címkék Száma\",\n    \"tags\": \"Címkék\",\n    \"tattoos\": \"Tetoválások\",\n    \"title\": \"Cím\",\n    \"toast\": {\n        \"added_entity\": \"{entity} Hozzáadva\",\n        \"created_entity\": \"{entity} Létrehozva\",\n        \"merged_tags\": \"Összevont címkék\",\n        \"saved_entity\": \"{entity} Mentve\",\n        \"updated_entity\": \"{entity} Frissítve\",\n        \"started_generating\": \"Generálás megkezdve\",\n        \"started_importing\": \"Importálás megkezdve\",\n        \"default_filter_set\": \"Alapvető szűrő beállítva\"\n    },\n    \"total\": \"Összesen\",\n    \"true\": \"Igaz\",\n    \"twitter\": \"Twitter\",\n    \"updated_at\": \"Frissítés Ideje\",\n    \"url\": \"URL\",\n    \"videos\": \"Videók\",\n    \"view_all\": \"Mindegyik Megjelenítése\",\n    \"weight\": \"Súly\",\n    \"years_old\": \"éves\",\n    \"actions_name\": \"Tevékenységek\",\n    \"audio_codec\": \"Audió kodek\",\n    \"circumcised_types\": {\n        \"CUT\": \"Körülmetélt\",\n        \"UNCUT\": \"Nem körülmetélt\"\n    },\n    \"also_known_as\": \"Más néven ismert\",\n    \"appears_with\": \"Megjelenik a következővel\",\n    \"between_and\": \"és\",\n    \"blobs_storage_type\": {\n        \"database\": \"Adatbázis\",\n        \"filesystem\": \"Fájlrendszer\"\n    },\n    \"captions\": \"Feliratok\",\n    \"chapters\": \"Fejezetek\",\n    \"circumcised\": \"Körülmetélt\",\n    \"weight_kg\": \"Súly (kg)\",\n    \"sub_tags\": \"Al címkék\",\n    \"sub_group\": \"Al csoport\",\n    \"unknown_date\": \"Ismeretlen dátum\",\n    \"urls\": \"URL-ek\",\n    \"validation\": {\n        \"blank\": \"${path} nem lehet üres\"\n    },\n    \"studio_tags\": \"Stúdió címkék\",\n    \"type\": \"Típus\",\n    \"measurements\": \"Mérések\",\n    \"package_manager\": {\n        \"install\": \"Letöltés\",\n        \"version\": \"Verzió\"\n    },\n    \"folder\": \"mappa\",\n    \"second\": \"Második\",\n    \"studio_tagger\": {\n        \"current_page\": \"Jelenlegi oldal\",\n        \"network_error\": \"Hálózati hiba\",\n        \"tag_status\": \"Címke státusz\"\n    },\n    \"history\": \"Történet\",\n    \"sub_groups\": \"Al csoportok\",\n    \"sub_group_count\": \"Al csoportok száma\",\n    \"primary_file\": \"Elsődleges fájl\",\n    \"groups\": \"Csoportok\",\n    \"penis_length_cm\": \"Pénisz hosszúság (cm)\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/id-ID.json",
    "content": "{\n    \"setup\": {\n        \"welcome_to_stash\": \"Selamat datang di Stash\",\n        \"paths\": {\n            \"where_is_your_porn_located\": \"Di mana lokasi porno Anda?\",\n            \"description\": \"Selanjutnya, kita perlu menentukan di mana lokasi koleksi bokep Anda, dan di mana menyimpan pangkalan data Stash, berkas yang dihasilkan, dan berkas tembolok. Pengaturan ini dapat diubah nanti jika diperlukan.\"\n        },\n        \"success\": {\n            \"thanks_for_trying_stash\": \"Terima kasih sudah mencoba Stash!\",\n            \"support_us\": \"Dukung kami\"\n        }\n    },\n    \"type\": \"Tipe\",\n    \"actions\": {\n        \"add\": \"Tambah\",\n        \"apply\": \"Terapkan\",\n        \"add_o\": \"Tambah Crot\",\n        \"add_play\": \"Tambah pemutaran\",\n        \"backup\": \"Cadangan\",\n        \"add_manual_date\": \"Tambah manual tanggal\",\n        \"assign_stashid_to_parent_studio\": \"Tetapkan Stash ID ke studio induk yang ada dan perbarui metadata\",\n        \"auto_tag\": \"Tag Otomatis\",\n        \"browse_for_image\": \"Telusuri gambar…\",\n        \"clean\": \"Bersihkan\",\n        \"clear\": \"Kosongkan\",\n        \"clear_front_image\": \"Kosongkan gambar depan\",\n        \"close\": \"Tutup\",\n        \"confirm\": \"Konfirmasi\",\n        \"continue\": \"Lanjutkan\",\n        \"copy_to_clipboard\": \"Salin ke papan klip\",\n        \"create\": \"Buat\",\n        \"create_chapters\": \"Buat Bagian\",\n        \"create_entity\": \"Buat {entityType}\",\n        \"create_marker\": \"Buat Tanda\",\n        \"create_parent_studio\": \"Buat studio induk\",\n        \"created_entity\": \"Telah dibuat {entity_type}: {entity_name}\",\n        \"customise\": \"Sesuaikan\",\n        \"delete\": \"Hapus\",\n        \"delete_entity\": \"Hapus {entityType}\",\n        \"delete_file\": \"Hapus berkas\",\n        \"delete_file_and_funscript\": \"Hapus berkas (dan funscript)\",\n        \"delete_generated_supporting_files\": \"Hapus file pendukung yang dihasilkan\",\n        \"disable\": \"Matikan\",\n        \"disallow\": \"Larang\",\n        \"download_anonymised\": \"Unduh sebagai anonim\",\n        \"download_backup\": \"Unduh Cadangan\",\n        \"edit_entity\": \"Sunting {entityType}\",\n        \"enable\": \"Nyalakan\",\n        \"find\": \"Cari\",\n        \"finish\": \"Selesai\",\n        \"from_file\": \"Dari berkas…\",\n        \"from_url\": \"Dari URL…\",\n        \"full_export\": \"Ekspor Penuh\",\n        \"full_import\": \"Impor Penuh\",\n        \"generate\": \"Hasilkan\",\n        \"allow_temporarily\": \"Izinkan sementara\",\n        \"anonymise\": \"Buat anonim\",\n        \"clear_back_image\": \"Kosongkan gambar belakang\",\n        \"clear_image\": \"Kosongkan Gambar\",\n        \"cancel\": \"Batal\",\n        \"download\": \"Unduh\",\n        \"edit\": \"Sunting\",\n        \"export\": \"Ekspor\",\n        \"export_all\": \"Ekspor semua…\",\n        \"choose_date\": \"Pilih tanggal\",\n        \"clear_date_data\": \"Bersihkan data tanggal\",\n        \"encoding_image\": \"Mengodekan gambar…\",\n        \"generate_thumb_default\": \"Hasilkan keluku bawaan\",\n        \"generate_thumb_from_current\": \"Hasilkan keluku dari saat ini\",\n        \"hash_migration\": \"Migrasi hash\",\n        \"hide\": \"Sembunyikan\",\n        \"import\": \"Impor…\",\n        \"logout\": \"Keluar\",\n        \"make_primary\": \"Jadikan Utama\",\n        \"merge\": \"Gabung\",\n        \"migrate_blobs\": \"Migrasi Blob\",\n        \"next_action\": \"Selanjutnya\",\n        \"not_running\": \"tidak berjalan\",\n        \"open_in_external_player\": \"Buka di pemutar eksternal\",\n        \"open_random\": \"Buka Acak\",\n        \"optimise_database\": \"Optimisasi Pangakalan Data\",\n        \"overwrite\": \"Timpa\",\n        \"play_random\": \"Putar Acak\",\n        \"play_selected\": \"Putar dipilih\",\n        \"preview\": \"Pratinjau\",\n        \"previous_action\": \"Kembali\",\n        \"reassign\": \"Tetapkan ulang\",\n        \"remove\": \"Hapus\",\n        \"remove_date\": \"Hapus tanggal\",\n        \"remove_from_gallery\": \"Hapus dari Galeri\",\n        \"rename_gen_files\": \"Ubah nama berkas yang dihasilkan\",\n        \"select_none\": \"Pilih Tak Satupun\",\n        \"selective_auto_tag\": \"Tag Otomatis Selektif\",\n        \"selective_clean\": \"Pembersihan Selektif\",\n        \"selective_scan\": \"Pemindaian Selektif\",\n        \"set_as_default\": \"Set sebagai bawaan\",\n        \"set_back_image\": \"Gambar belakang…\",\n        \"set_front_image\": \"Gambar depan…\",\n        \"show\": \"Tampilkan\",\n        \"show_configuration\": \"Tampilkan Konfigurasi\",\n        \"skip\": \"Lewati\",\n        \"split\": \"Pisah\",\n        \"stop\": \"Stop\",\n        \"submit\": \"Kirim\",\n        \"submit_stash_box\": \"Kirim ke Stash-Box\",\n        \"submit_update\": \"Kirim pembaruan\",\n        \"swap\": \"Tukar\",\n        \"clean_generated\": \"Bersihkan berkas yang dihasilkan\",\n        \"identify\": \"Identifikasi\",\n        \"import_from_file\": \"Impor dari berkas\",\n        \"hide_configuration\": \"Sembunyikan Konfigurasi\",\n        \"ignore\": \"Abaikan\",\n        \"refresh\": \"Segarkan\",\n        \"reload\": \"Muat ulang\",\n        \"reload_plugins\": \"Muat ulang plugin\",\n        \"rescan\": \"Pindai ulang\",\n        \"reshuffle\": \"Kocok\",\n        \"running\": \"berjalan\",\n        \"save\": \"Simpan\",\n        \"save_delete_settings\": \"Gunakan opsi ini secara bawaan saat menghapus\",\n        \"save_filter\": \"Simpan filter\",\n        \"scan\": \"Pindai\",\n        \"search\": \"Cari\",\n        \"select_all\": \"Pilih Semua\",\n        \"select_entity\": \"Pilih {entityType}\",\n        \"select_folders\": \"Pilih folder\",\n        \"add_directory\": \"Tambah Direktori\",\n        \"add_to_entity\": \"Tambah ke {entityType}\",\n        \"add_entity\": \"Tambah {entityType}\",\n        \"allow\": \"Izinkan\",\n        \"migrate_scene_screenshots\": \"Migrasi Tangkapan Layar Adegan\",\n        \"reload_scrapers\": \"Muat ulang penggali data\",\n        \"scrape\": \"Gali Data\",\n        \"scrape_query\": \"Kueri penggali data\",\n        \"scrape_scene_fragment\": \"Gali data dengan fragmen\",\n        \"scrape_with\": \"Gali data dengan…\",\n        \"set_image\": \"Tetapkan gambar…\",\n        \"tasks\": {\n            \"dry_mode_selected\": \"Mode Kering dipilih. Tidak ada penghapusan riil yang akan dilakukan, hanya pencatatan.\",\n            \"clean_confirm_message\": \"Apakah Anda yakin ingin Membersihkan? Ini akan menghapus informasi pangkalan data dan konten yang dihasilkan untuk semua adegan dan galeri yang tidak lagi ditemukan di berkas sistem.\",\n            \"import_warning\": \"Apakah Anda yakin ingin mengimpor? Ini akan menghapus pangkalan data dan mengimpor ulang metadata yang Anda ekspor.\"\n        },\n        \"temp_disable\": \"Nonaktifkan sementara…\",\n        \"temp_enable\": \"Aktifkan sementara…\",\n        \"use_default\": \"Gunakan bawaan\",\n        \"view_random\": \"Lihat Acak\",\n        \"unset\": \"Tidak disetel\",\n        \"view_history\": \"Lihat histori\",\n        \"reset_cover\": \"Pulihkan Sampul Bawaan\",\n        \"set_cover\": \"Atur sebagai Sampul\",\n        \"reset_resume_time\": \"Atur ulang waktu lanjut\",\n        \"reset_play_duration\": \"Atur ulang durasi putar\",\n        \"add_sub_groups\": \"Tambah Subgrup\",\n        \"remove_from_containing_group\": \"Hapus dari Grup\",\n        \"load\": \"Muat\",\n        \"load_filter\": \"Muat filter\",\n        \"play\": \"Mainkan\",\n        \"show_results\": \"Lihat hasil\",\n        \"show_count_results\": \"Lihat {count} hasil\",\n        \"sidebar\": {\n            \"close\": \"Tutup bilah samping\",\n            \"open\": \"Buka bilah samping\",\n            \"toggle\": \"Alihkan bilah samping\"\n        },\n        \"add_stash_id\": \"Tambah ID Stash\",\n        \"create_new\": \"Buat Baru\"\n    },\n    \"circumcised_types\": {\n        \"CUT\": \"Disunat\",\n        \"UNCUT\": \"Belum Disunat\"\n    },\n    \"hair_color\": \"Warna Rambut\",\n    \"gender_types\": {\n        \"TRANSGENDER_FEMALE\": \"Transpuan\",\n        \"TRANSGENDER_MALE\": \"Transpria\",\n        \"MALE\": \"Pria\",\n        \"NON_BINARY\": \"Non-biner\",\n        \"FEMALE\": \"Wanita\",\n        \"INTERSEX\": \"Interseks\"\n    },\n    \"measurements\": \"Ukuran\",\n    \"penis\": \"Kontol\",\n    \"penis_length\": \"Panjang Kontol\",\n    \"statistics\": \"Statistik\",\n    \"stats\": {\n        \"image_size\": \"Ukuran gambar\",\n        \"scenes_duration\": \"Durasi adegan\",\n        \"scenes_played\": \"Adegan diputar\",\n        \"total_play_count\": \"Total Jumlah Pemutaran\",\n        \"scenes_size\": \"Ukuran adegan\",\n        \"total_play_duration\": \"Total Durasi Pemutaran\",\n        \"total_o_count\": \"Total Jumlah Crot\"\n    },\n    \"studio\": \"Studio\",\n    \"studio_tagger\": {\n        \"add_new_studios\": \"Tambah Studio Baru\"\n    },\n    \"title\": \"Judul\",\n    \"twitter\": \"Twitter\",\n    \"studios\": \"Studio\",\n    \"fake_tits\": \"Toket Palsu\",\n    \"circumcised\": \"Disunat\",\n    \"penis_length_cm\": \"Panjang Kontol (cm)\",\n    \"gender\": \"Jenis Kelamin\",\n    \"height\": \"Tinggi Badan\",\n    \"height_cm\": \"Tinggi Badan (cm)\",\n    \"history\": \"Histori\",\n    \"help\": \"Bantuan\",\n    \"eye_color\": \"Warna Mata\",\n    \"true\": \"Benar\",\n    \"total\": \"Total\",\n    \"toast\": {\n        \"started_importing\": \"Memulai pengimporan\",\n        \"started_generating\": \"Mulai menghasilkan\",\n        \"merged_scenes\": \"Adegan yang digabungkan\"\n    },\n    \"also_known_as\": \"Juga dikenal sebagai\",\n    \"appears_with\": \"Muncul Bersama\",\n    \"component_tagger\": {\n        \"config\": {\n            \"query_mode_metadata\": \"Metadata\",\n            \"active_instance\": \"Instansi aktif stash-box:\",\n            \"blacklist_desc\": \"Butir daftar hitam dikecualikan dari kueri. Perhatikan bahwa ini adalah ekspresi reguler dan juga tidak peka huruf besar-kecil. Karakter tertentu harus di-escape dengan garis miring terbalik: {chars_require_escape}\",\n            \"blacklist_label\": \"Daftar hitam\",\n            \"mark_organized_desc\": \"Segera tandai adegan sebagai Terorganisir setelah tombol Simpan diklik.\",\n            \"mark_organized_label\": \"Tandai sebagai Terorganisir saat disimpan\",\n            \"query_mode_auto\": \"Otomatis\",\n            \"query_mode_auto_desc\": \"Menggunakan metadata jika ada, atau nama berkas\",\n            \"query_mode_dir\": \"Dir.\",\n            \"query_mode_dir_desc\": \"Hanya menggunakan direktori induk berkas video\",\n            \"query_mode_filename\": \"Nama berkas\",\n            \"query_mode_filename_desc\": \"Hanya menggunakan nama berkas\",\n            \"query_mode_label\": \"Mode Kueri\",\n            \"query_mode_metadata_desc\": \"Hanya menggunakan metadata\",\n            \"query_mode_path\": \"Lokasi\",\n            \"query_mode_path_desc\": \"Menggunakan seluruh jalur berkas\",\n            \"set_cover_desc\": \"Ganti gambar adegan jika cover ditemukan.\",\n            \"set_cover_label\": \"Tetapkan gambar cover adegan\",\n            \"set_tag_label\": \"Tetapkan tag\",\n            \"set_tag_desc\": \"Lampirkan tag ke adegan, baik dengan menimpa atau menggabungkan dengan tag yang sudah ada.\",\n            \"source\": \"Sumber\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Duplikat item daftar hitam\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Kelamin pemain\",\n                \"description\": \"Pemain dengan kelamin ini akan ditampilkan ketika memberi tanda pada adegan.\"\n            }\n        },\n        \"results\": {\n            \"duration_unknown\": \"Durasi tidak diketahui\",\n            \"duration_off\": \"Durasi berkurang setidaknya {number} dtk\",\n            \"fp_matches\": \"Durasinya cocok\",\n            \"hash_matches\": \"{hash_type} cocok\",\n            \"match_failed_already_tagged\": \"Adegan sudah ditag\",\n            \"match_failed_no_result\": \"Tidak menemukan hasil\",\n            \"match_success\": \"Adegan berhasil ditag\",\n            \"phash_matches\": \"{count} PHash cocok\",\n            \"unnamed\": \"Belum dinamai\",\n            \"fp_matches_multi\": \"Durasi cocok {matchCount}/{durationsLength} sidik jari\",\n            \"fp_found\": \"{fpCount, plural, =0 {Tidak ditemukan kecocokan sidik jari baru} other {# kecocokan sidik jari baru ditemukan}}\"\n        },\n        \"verb_scrape_all\": \"Gali Semua\",\n        \"noun_query\": \"Kueri\",\n        \"verb_matched\": \"Dicocokkan\",\n        \"verb_toggle_unmatched\": \"{toggle} adegan yang tidak cocok\",\n        \"verb_match_fp\": \"Cocokkan Sidik Jari\",\n        \"verb_submit_fp\": \"Masukkan {fpCount, plural, one{# Sidik jari} other{# Sidik jari}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\"\n    },\n    \"config\": {\n        \"categories\": {\n            \"tasks\": \"Tugas\",\n            \"tools\": \"Alat\",\n            \"system\": \"Sistem\",\n            \"metadata_providers\": \"Penyedia Metadata\",\n            \"logs\": \"Catatan\",\n            \"about\": \"Tentang\",\n            \"changelog\": \"Catatan Perubahan\",\n            \"interface\": \"Antarmuka\",\n            \"plugins\": \"Pengaya\",\n            \"scraping\": \"Menggali Data\",\n            \"security\": \"Sekuriti\",\n            \"services\": \"Layanan\"\n        },\n        \"dlna\": {\n            \"network_interfaces\": \"Antarmuka\",\n            \"allow_temp_ip\": \"Izinkan {tempIP}\",\n            \"server_display_name_desc\": \"Nama tampilan untuk server DLNA. Bawaannya adalah {server_name} jika kosong.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Berhasil membatalkan perilaku sementara\",\n            \"until_restart\": \"sampai dimulai ulang\",\n            \"video_sort_order\": \"Urutan Pengurutan Video Bawaan\",\n            \"video_sort_order_desc\": \"Urutan untuk mengurutkan video secara bawaan.\",\n            \"allowed_ip_addresses\": \"Alamat IP yang diizinkan\",\n            \"allowed_ip_temporarily\": \"IP yang diizinkan sementara\",\n            \"default_ip_whitelist\": \"Daftar Putih IP Bawaan\",\n            \"default_ip_whitelist_desc\": \"Alamat IP bawaan yang diizinkan untuk akses DLNA. Gunakan {wildcard} untuk mengizinkan semua alamat IP.\",\n            \"disabled_dlna_temporarily\": \"DLNA yang dinonaktifkan sementara\",\n            \"disallowed_ip\": \"IP yang tidak diizinkan\",\n            \"enabled_by_default\": \"Diaktifkan secara bawaan\",\n            \"enabled_dlna_temporarily\": \"DLNA yang diaktifkan sementara\",\n            \"network_interfaces_desc\": \"Antarmuka untuk mengekspos server DLNA. Daftar kosong menghasilkan berjalannya di semua antarmuka. Dibutuhkan mulai ulang DLNA setelah perubahan.\",\n            \"recent_ip_addresses\": \"Alamat IP terbaru\",\n            \"server_display_name\": \"Nama Tampilan Server\",\n            \"server_port_desc\": \"Port untuk menjalankan server DLNA. Harus memulai ulang DLNA setelah diganti.\",\n            \"server_port\": \"Port Server\"\n        },\n        \"general\": {\n            \"logging\": \"Pencatatan\",\n            \"auth\": {\n                \"authentication\": \"Autentikasi\",\n                \"credentials\": {\n                    \"heading\": \"Kredensial\",\n                    \"description\": \"Kredensial untuk membatasi akses ke stash.\"\n                },\n                \"password\": \"Kata sandi\",\n                \"username\": \"Nama pengguna\",\n                \"api_key\": \"Kunci API\",\n                \"api_key_desc\": \"Kunci API untuk sistem eksternal. Hanya diperlukan ketika nama pengguna/kata sandi dikonfigurasi. Nama pengguna harus disimpan sebelum membuat kunci API.\",\n                \"clear_api_key\": \"Hapus kunci API\",\n                \"log_file\": \"Berkas catatan\",\n                \"generate_api_key\": \"Hasilkan kunci API\",\n                \"log_file_desc\": \"Jalur ke berkas tujuan keluaran pencatatan. Kosong untuk menonaktifkan pencatatan berkas. Perlu dimulai ulang.\",\n                \"log_http\": \"Catat akses http\",\n                \"log_http_desc\": \"Mencatat akses http ke terminal. Perlu dimulai ulang.\",\n                \"log_to_terminal\": \"Catat ke terminal\",\n                \"log_to_terminal_desc\": \"Catat ke terminal selain berkas. Selalu benar jika pencatatan berkas dinonaktifkan. Perlu dimulai ulang.\",\n                \"maximum_session_age\": \"Usia Sesi Maksimum\",\n                \"maximum_session_age_desc\": \"Waktu diam maksimum sebelum sesi login berakhir, dalam hitungan detik. Perlu dimulai ulang.\",\n                \"password_desc\": \"Kata sandi untuk mengakses Stash. Biarkan kosong untuk menonaktifkan otentikasi pengguna\",\n                \"stash-box_integration\": \"Integrasi Stash-box\",\n                \"username_desc\": \"Nama pengguna untuk mengakses Stash. Biarkan kosong untuk menonaktifkan otentikasi pengguna\"\n            },\n            \"database\": \"Pangkalan data\",\n            \"hashing\": \"Hashing\",\n            \"scraping\": \"Penggalian Data\",\n            \"video_head\": \"Video\",\n            \"blobs_storage\": {\n                \"description\": \"Tempat menyimpan data biner seperti gambar sampul adegan, pemain, studio, dan tag. Setelah mengubah nilai ini, data yang ada harus dimigrasikan menggunakan tugas Migrate Blobs. Lihat halaman Tugas untuk migrasi.\",\n                \"heading\": \"Tipe penyimpanan data biner\"\n            },\n            \"funscript_heatmap_draw_range_desc\": \"Gambarkan rentang gerak pada sumbu y dari peta panas yang dihasilkan. Peta panas yang ada perlu dibuat ulang setelah diubah.\",\n            \"gallery_cover_regex_desc\": \"Regexp digunakan untuk mengidentifikasi gambar sebagai sampul galeri\",\n            \"gallery_cover_regex_label\": \"Pola sampul galeri\",\n            \"gallery_ext_desc\": \"Daftar ekstensi berkas yang dibatasi koma yang akan diidentifikasi sebagai berkas zip galeri.\",\n            \"gallery_ext_head\": \"Ekstensi zip galeri\",\n            \"python_path\": {\n                \"heading\": \"Jalur Eksekusi Python\",\n                \"description\": \"Lokasi executable Python (bukan hanya folder). Digunakan untuk skrip penggali data dan plugin. Jika kosong, Python akan diambil dari environment\"\n            },\n            \"scraper_user_agent\": \"Agen Pengguna Penggali\",\n            \"db_path_head\": \"Jalur Pangkalan Data\",\n            \"backup_directory_path\": {\n                \"description\": \"Lokasi direktori untuk cadangan berkas pangkalan data SQLite\",\n                \"heading\": \"Jalur Direktori Cadangan\"\n            },\n            \"funscript_heatmap_draw_range\": \"Sertakan rentang dalam peta panas yang dihasilkan\",\n            \"generated_file_naming_hash_head\": \"Hash penamaan berkas yang dihasilkan\",\n            \"generated_files_location\": \"Lokasi direktori untuk berkas yang dihasilkan (penanda adegan, pratinjau adegan, sprite, dll.)\",\n            \"generated_path_head\": \"Jalur yang Dihasilkan\",\n            \"heatmap_generation\": \"Pembuatan Peta Panas Funscript\",\n            \"image_ext_head\": \"Ekstensi Gambar\",\n            \"include_audio_desc\": \"Termasuk aliran audio saat membuat pratinjau.\",\n            \"include_audio_head\": \"Sertakan audio\",\n            \"maximum_streaming_transcode_size_desc\": \"Ukuran maksimum untuk aliran yang ditranskode\",\n            \"maximum_streaming_transcode_size_head\": \"Ukuran maksimum transkode aliran\",\n            \"maximum_transcode_size_desc\": \"Ukuran maksimum untuk transkode yang dihasilkan\",\n            \"maximum_transcode_size_head\": \"Ukuran transkode maksimum\",\n            \"metadata_path\": {\n                \"description\": \"Lokasi direktori yang digunakan saat melakukan ekspor atau impor penuh\",\n                \"heading\": \"Jalur Metadata\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Set ke 0 untuk deteksi otomatis. Peringatan menjalankan lebih banyak tugas daripada yang diperlukan untuk mencapai penggunaan CPU 100% akan menurunkan kinerja dan berpotensi menyebabkan masalah lain.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Jumlah tugas paralel untuk pemindaian/penghasilan\",\n            \"parallel_scan_head\": \"Pemindaian/Penghasilan Paralel\",\n            \"preview_generation\": \"Penghasilan Pratinjau\",\n            \"scrapers_path\": {\n                \"description\": \"Lokasi direktori berkas konfigurasi penggali data\",\n                \"heading\": \"Jalur Penggali\"\n            },\n            \"scraper_user_agent_desc\": \"String Agen-Pengguna yang digunakan selama permintaan http penggalian data\",\n            \"video_ext_head\": \"Ekstensi Video\",\n            \"blobs_path\": {\n                \"description\": \"Dimana pada sistem berkas untuk menyimpan data biner. Hanya berlaku saat menggunakan jenis penyimpanan blob Sistem Berkas. PERINGATAN: mengubah ini memerlukan pemindahan data yang ada secara manual.\",\n                \"heading\": \"Jalur sistem berkas data biner\"\n            },\n            \"check_for_insecure_certificates\": \"Cek untuk sertifikat tidak aman\",\n            \"cache_location\": \"Direktori lokasi cache. Harus diisi jika streaming menggunakan HLS (seperti pada perangkat Apple) atau DASH.\",\n            \"calculate_md5_and_ohash_label\": \"Kalkulasi MD5 untuk video\",\n            \"cache_path_head\": \"Lokasi Cache\",\n            \"calculate_md5_and_ohash_desc\": \"Kalkulasi checksum MD5 untuk tambahan oshash. Jika diaktifkan akan memperlambat proses scan awal. Hash penamaan file harus diatur menjadi oshash untuk menonaktifkan kalkulasi MD5.\",\n            \"check_for_insecure_certificates_desc\": \"Beberapa situs menggunakan sertifikat ssl yang tidak aman. Jika dinonaktifkan, scraper akan mengabaikan sertifikat yang tidak aman dan akan melanjutkan scraping. Jika anda mendapatkan error saat scraping, nonaktifkan ini.\",\n            \"chrome_cdp_path\": \"Lokasi Chrome CDP\",\n            \"chrome_cdp_path_desc\": \"Lokasi file eksekutabel Chrome, atau alamat remote (dimulai dengan http:// atau https://, sebagai contoh http://localhost:9222/json/version) sebuah instansi Chrome.\",\n            \"ffmpeg\": {\n                \"live_transcode\": {\n                    \"output_args\": {\n                        \"desc\": \"Lanjutan: Argumen tambahan untuk ditambahkan pada ffmpeg sebelum isian output saat mentranscode video secara langsung.\",\n                        \"heading\": \"Argumen untuk Output Transcode Langsung pada FFmpeg\"\n                    },\n                    \"input_args\": {\n                        \"desc\": \"Lanjutan: Argumen tambahan untuk diteruskan ke ffmpeg sebelum field input saat melakukan transcoding video langsung.\",\n                        \"heading\": \"FFmpeg Live Transcode Input Args\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Lanjutan: Argumen tambahan untuk ditambahkann pada ffmpeg sebelum isian saat menghasilkan video.\",\n                        \"heading\": \"Argumen Input Transcode FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Lanjutan: Argumen tambahan untuk ditambahkan pada ffmpeg sebelum output saat menghasilkan video.\",\n                        \"heading\": \"Argumen Output Transcode FFmpeg\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"Mengunduh FFmpeg ke dalam direktori konfigurasi dan mengosongkan lokasi ffmpeg serta ffprobe untuk diambil dari direktori konfigurasi.\",\n                    \"heading\": \"Unduh FFmpeg\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"Lokasi ke executable ffmpeg (bukan hanya folder). Jika kosong, ffmpeg akan diambil dari environment melalui $PATH, direktori konfigurasi, atau dari $HOME/.stash\",\n                    \"heading\": \"Lokasi Executable FFmpeg\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Lokasi executable ffprobe (bukan hanya folder). Jika kosong, ffprobe akan diambil dari environment melalui $PATH, direktori konfigurasi, atau dari $HOME/.stash\",\n                    \"heading\": \"Lokasi Executable FFprobe\"\n                },\n                \"hardware_acceleration\": {\n                    \"desc\": \"Menggunakan perangkat keras yang tersedia untuk melakukan encoding video dalam transcoding langsung.\",\n                    \"heading\": \"Encoding perangkat keras FFmpeg\"\n                }\n            },\n            \"create_galleries_from_folders_label\": \"Buat galeri dari folder yang berisi gambar\",\n            \"directory_locations_to_your_content\": \"Lokasi direktori konten Anda\",\n            \"create_galleries_from_folders_desc\": \"Jika true, secara bawaan membuat galeri dari folder yang berisi gambar. Buat sebuah file bernama .forcegallery atau .nogallery di dalam folder untuk memaksa/mencegah hal ini.\",\n            \"excluded_image_gallery_patterns_desc\": \"Regexp file gambar dan galeri/lokasi yang akan dikecualikan dari Pemindaian dan ditambahkan ke Pembersihan\",\n            \"excluded_image_gallery_patterns_head\": \"Pola Gambar/Galeri yang Dikecualikan\",\n            \"excluded_video_patterns_desc\": \"Regexp file video/lokasi yang akan dikecualikan dari Pemindaian dan ditambahkan ke Pembersihan\",\n            \"excluded_video_patterns_head\": \"Pola Video yang Dikecualikan\",\n            \"generated_file_naming_hash_desc\": \"Gunakan MD5 atau oshash untuk penamaan file yang dihasilkan. Mengubah ini memerlukan semua adegan memiliki nilai MD5/oshash yang sesuai. Setelah mengubah nilai ini, file yang sudah dihasilkan perlu dimigrasikan atau dibuat ulang. Lihat halaman Tugas untuk migrasi.\",\n            \"image_ext_desc\": \"Daftar ekstensi file yang dipisahkan koma dan akan dikenali sebagai gambar.\",\n            \"plugins_path\": {\n                \"description\": \"Lokasi direktori file konfigurasi plugin\",\n                \"heading\": \"Lokasi Plugin\"\n            },\n            \"video_ext_desc\": \"Daftar ekstensi file yang dipisahkan koma dan akan dikenali sebagai video.\"\n        },\n        \"library\": {\n            \"exclusions\": \"Pengecualian\",\n            \"gallery_and_image_options\": \"Opsi Galeri dan Gambar\",\n            \"media_content_extensions\": \"Ekstensi konten media\"\n        },\n        \"tasks\": {\n            \"identify\": {\n                \"sources\": \"Sumber\",\n                \"field\": \"Bidang\",\n                \"heading\": \"Identifikasi\",\n                \"source\": \"Sumber\",\n                \"strategy\": \"Strategi\"\n            },\n            \"maintenance\": \"Pemeliharaan\",\n            \"migrations\": \"Migrasi\",\n            \"backing_up_database\": \"Mencadangkan pangkalan data\",\n            \"anonymise_and_download\": \"Membuat salinan pangkalan data yang dianonimkan dan mengunduh berkas yang dihasilkan.\",\n            \"anonymising_database\": \"Menganonimkan pangkalan data\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Memberi Tag Otomatis pada semua jalur\",\n                \"auto_tagging_paths\": \"Memberi Tag Otomatis pada jalur berikut\"\n            },\n            \"auto_tag_based_on_filenames\": \"Memberi tag otomatis pada konten berdasarkan jalur berkas.\",\n            \"auto_tagging\": \"Pemberian Tag Otomatis\",\n            \"clean_generated\": {\n                \"markers\": \"Pratinjau Penanda\",\n                \"image_thumbnails\": \"Keluku Gambar\",\n                \"image_thumbnails_desc\": \"Keluku dan klip gambar\",\n                \"previews\": \"Pratinjau Adegan\",\n                \"previews_desc\": \"Pratinjau adegan dan keluku\"\n            },\n            \"added_job_to_queue\": \"{operation_name} ditambahkan ke antrean tugas\",\n            \"data_management\": \"Manajemen data\"\n        },\n        \"ui\": {\n            \"editing\": {\n                \"heading\": \"Pengeditan\",\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"options\": {\n                            \"tenth\": \"Persepuluh\",\n                            \"full\": \"Penuh\",\n                            \"half\": \"Setengah\",\n                            \"quarter\": \"Seperempat\"\n                        }\n                    },\n                    \"type\": {\n                        \"options\": {\n                            \"decimal\": \"Desimal\",\n                            \"stars\": \"Bintang\"\n                        }\n                    }\n                }\n            },\n            \"handy_connection\": {\n                \"connect\": \"Sambung\",\n                \"sync\": \"Singkronisasi\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Arah\"\n            },\n            \"images\": {\n                \"heading\": \"Gambar\"\n            },\n            \"language\": {\n                \"heading\": \"Bahasa\"\n            },\n            \"preview_type\": {\n                \"options\": {\n                    \"video\": \"Video\"\n                }\n            }\n        },\n        \"advanced_mode\": \"Mode Tingkat Lanjut\",\n        \"application_paths\": {\n            \"heading\": \"Jalur Aplikasi\"\n        },\n        \"about\": {\n            \"latest_version\": \"Versi Mutakhir\",\n            \"release_date\": \"Tanggal rilis:\",\n            \"new_version_notice\": \"[BARU]\",\n            \"stash_discord\": \"Gabung ke saluran {url} kami\",\n            \"stash_home\": \"Beranda Stash di {url}\",\n            \"stash_open_collective\": \"Dukung kami melalui {url}\",\n            \"version\": \"Versi\",\n            \"check_for_new_version\": \"Periksa versi terbaru\",\n            \"build_time\": \"Waktu dibangun::\",\n            \"latest_version_build_hash\": \"Build Hash Versi Terbaru:\",\n            \"build_hash\": \"Versi hash:\",\n            \"stash_wiki\": \"Halaman {url} Stash\"\n        },\n        \"scraping\": {\n            \"scraper\": \"Penggali data\",\n            \"scrapers\": \"Penggali data\",\n            \"supported_urls\": \"URL\",\n            \"installed_scrapers\": \"Penggali Terpasang\",\n            \"supported_types\": \"Tipe yang didukung\",\n            \"search_by_name\": \"Cari berdasarkan nama\",\n            \"available_scrapers\": \"Penggali Tersedia\",\n            \"entity_scrapers\": \"Penggali {entityType}\",\n            \"excluded_tag_patterns_head\": \"Pola Tag Terkecualikan\"\n        },\n        \"stashbox\": {\n            \"endpoint\": \"Titik akhir\",\n            \"name\": \"Nama\",\n            \"graphql_endpoint\": \"Titik akhir GraphQL\",\n            \"title\": \"Titik Akhir Stash-box\",\n            \"api_key\": \"Kunci API\",\n            \"max_requests_per_minute\": \"Maks permintaan per menit\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transkode\"\n        },\n        \"tools\": {\n            \"scene_filename_parser\": {\n                \"filename\": \"Nama berkas\"\n            }\n        },\n        \"logs\": {\n            \"log_level\": \"Level Log\"\n        },\n        \"plugins\": {\n            \"available_plugins\": \"Plugin Tersedia\",\n            \"installed_plugins\": \"Plugin Terinstal\"\n        }\n    },\n    \"criterion\": {\n        \"value\": \"Nilai\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"antara\",\n        \"equals\": \"adalah\",\n        \"excludes\": \"tidak termasuk\",\n        \"includes\": \"termasuk\"\n    },\n    \"date_format\": \"YYYY-MM-DD\",\n    \"dialogs\": {\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Kolom\",\n                \"row\": \"Baris\"\n            }\n        },\n        \"export_title\": \"Ekspor\",\n        \"lightbox\": {\n            \"display_mode\": {\n                \"original\": \"Asli\"\n            },\n            \"options\": \"Opsi\",\n            \"scroll_mode\": {\n                \"zoom\": \"Perbesar\"\n            },\n            \"page_header\": \"Halaman {page} / {total}\"\n        },\n        \"merge\": {\n            \"destination\": \"Destinasi\",\n            \"source\": \"Sumber\"\n        },\n        \"scene_gen\": {\n            \"transcodes\": \"Transkode\",\n            \"video_previews\": \"Pratinjau\"\n        },\n        \"scrape_results_existing\": \"Yang sudah ada\",\n        \"scrape_results_scraped\": \"Digali\",\n        \"performers_found\": \"{count} pemain ditemukan\",\n        \"dont_show_until_updated\": \"Sembunyikan hingga pembaruan selanjutnya\"\n    },\n    \"effect_filters\": {\n        \"brightness\": \"Kecerahan\",\n        \"contrast\": \"Kontras\",\n        \"green\": \"Hijau\",\n        \"warmth\": \"Kehangatan\",\n        \"aspect\": \"Aspek\",\n        \"blue\": \"Biru\",\n        \"blur\": \"Buram\",\n        \"gamma\": \"Gamma\",\n        \"name\": \"Filter\",\n        \"name_transforms\": \"Ubah\",\n        \"red\": \"Merah\",\n        \"rotate\": \"Putar\",\n        \"saturation\": \"Saturasi\",\n        \"scale\": \"Skala\"\n    },\n    \"filter\": \"Filter\",\n    \"images\": \"Gambar\",\n    \"markers\": \"Penanda\",\n    \"name\": \"Nama\",\n    \"new\": \"Baru\",\n    \"none\": \"Tidak ada\",\n    \"package_manager\": {\n        \"description\": \"Deskripsi\",\n        \"install\": \"Pasang\",\n        \"package\": \"Paket\",\n        \"source\": {\n            \"name\": \"Nama\"\n        },\n        \"uninstall\": \"Copot pemasangan\",\n        \"update\": \"Perbarui\",\n        \"version\": \"Versi\",\n        \"unknown\": \"<tidak diketahui>\",\n        \"installed_version\": \"Versi Terpasang\",\n        \"latest_version\": \"Versi Terkini\"\n    },\n    \"pagination\": {\n        \"next\": \"Berikutnya\",\n        \"previous\": \"Sebelumnya\",\n        \"last\": \"Terakhir\",\n        \"first\": \"Pertama\",\n        \"current_total\": \"{current} dari {total}\"\n    },\n    \"performer\": \"Pemain\",\n    \"performers\": \"Pemain\",\n    \"photographer\": \"Fotografer\",\n    \"queue\": \"Antrean\",\n    \"random\": \"Acak\",\n    \"scenes\": \"Adegan\",\n    \"rating\": \"Rating\",\n    \"resolution\": \"Resolusi\",\n    \"scene\": \"Adegan\",\n    \"search_filter\": {\n        \"name\": \"Filter\"\n    },\n    \"second\": \"Detik\",\n    \"settings\": \"Pengaturan\",\n    \"seconds\": \"Detik\",\n    \"synopsis\": \"Sinopsis\",\n    \"tags\": \"Tag\",\n    \"tattoos\": \"Tato\",\n    \"path\": \"Jalur\",\n    \"piercings\": \"Tindik\",\n    \"time\": \"Waktu\",\n    \"videos\": \"Video\",\n    \"weight\": \"Berat\",\n    \"url\": \"URL\",\n    \"urls\": \"URL\",\n    \"errors\": {\n        \"header\": \"Galat\"\n    },\n    \"ethnicity\": \"Etnis\",\n    \"image\": \"Gambar\",\n    \"actions_name\": \"Aksi\",\n    \"age\": \"Umur\",\n    \"aliases\": \"Alias\",\n    \"audio_codec\": \"Kodek Audio\",\n    \"average_resolution\": \"Resolusi Rata-rata\",\n    \"between_and\": \"dan\",\n    \"birth_year\": \"Tahun Lahir\",\n    \"birthdate\": \"Tanggal Lahir\",\n    \"bitrate\": \"Kecepatan Bit\",\n    \"blobs_storage_type\": {\n        \"database\": \"Pangkalan data\",\n        \"filesystem\": \"Berkas sistem\"\n    },\n    \"career_length\": \"Panjang Karir\",\n    \"chapters\": \"Bab\",\n    \"captions\": \"Keterangan\",\n    \"configuration\": \"Konfigurasi\",\n    \"country\": \"Negara\",\n    \"custom\": \"Kustom\",\n    \"date\": \"Tanggal\",\n    \"descending\": \"Urut turun\",\n    \"description\": \"Deskripsi\",\n    \"detail\": \"Detail\",\n    \"details\": \"Detail\",\n    \"dimensions\": \"Dimensi\",\n    \"director\": \"Sutradara\",\n    \"disambiguation\": \"Disambiguasi\",\n    \"display_mode\": {\n        \"grid\": \"Kisi\",\n        \"list\": \"Daftar\",\n        \"tagger\": \"Penanda\",\n        \"unknown\": \"Tidak diketahui\",\n        \"wall\": \"Tembok\"\n    },\n    \"distance\": \"Jarak\",\n    \"donate\": \"Donasi\",\n    \"dupe_check\": {\n        \"duration_options\": {\n            \"any\": \"Apapun\",\n            \"equal\": \"Setara\"\n        },\n        \"options\": {\n            \"exact\": \"Persis\",\n            \"high\": \"Tinggi\",\n            \"low\": \"Rendah\",\n            \"medium\": \"Sedang\"\n        }\n    },\n    \"duration\": \"Durasi\",\n    \"favourite\": \"Favorit\",\n    \"file\": \"berkas\",\n    \"files\": \"berkas\",\n    \"filters\": \"Filter\",\n    \"folder\": \"Folder\",\n    \"galleries\": \"Galeri\",\n    \"gallery\": \"Galeri\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Menghubungkan\",\n        \"disconnected\": \"Terputus\",\n        \"missing\": \"Hilang\",\n        \"ready\": \"Siap\"\n    },\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktif\",\n    \"library\": \"Koleksi\",\n    \"loading\": {\n        \"generic\": \"Memuat…\"\n    },\n    \"tag\": \"Tag\",\n    \"operations\": \"Operasi\",\n    \"organized\": \"Terorganisir\",\n    \"orientation\": \"Orientasi\",\n    \"all\": \"semua\",\n    \"ascending\": \"Urut naik\",\n    \"age_on_date\": \"{age} saat produksi\",\n    \"performer_age\": \"Umur Pemain\",\n    \"performer_count\": \"Jumlah Pemain\",\n    \"performer_favorite\": \"Pemain Difavorit\",\n    \"performer_image\": \"Foto Pemain\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Tambah Pemain Baru\"\n    },\n    \"part_of\": \"Bagian dari {parent}\",\n    \"cover_image\": \"Foto Sampul\",\n    \"death_date\": \"Tanggal Kematian\",\n    \"death_year\": \"Tahun Kematian\",\n    \"last_o_at\": \"Terakhir Crot Pada\",\n    \"last_played_at\": \"Terakhir Dimainkan Pada\",\n    \"login\": {\n        \"username\": \"Nama Pengguna\",\n        \"password\": \"Kata Sandi\",\n        \"login\": \"Masuk\",\n        \"invalid_credentials\": \"Nama pengguna atau kata sandi salah\"\n    },\n    \"marker_count\": \"Jumlah Penanda\",\n    \"media_info\": {\n        \"o_count\": \"Jumlah Crot\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} saat produksi\"\n        },\n        \"phash\": \"PHash\"\n    },\n    \"o_count\": \"Jumlah Crot\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/index.ts",
    "content": "import Countries from \"i18n-iso-countries\";\n\nexport const localeCountries = {\n  af: () => import(\"i18n-iso-countries/langs/af.json\"),\n  ar: () => import(\"i18n-iso-countries/langs/ar.json\"),\n  bg: () => import(\"i18n-iso-countries/langs/bg.json\"),\n  bn: () => import(\"i18n-iso-countries/langs/bn.json\"),\n  ca: () => import(\"i18n-iso-countries/langs/ca.json\"),\n  cs: () => import(\"i18n-iso-countries/langs/cs.json\"),\n  da: () => import(\"i18n-iso-countries/langs/da.json\"),\n  de: () => import(\"i18n-iso-countries/langs/de.json\"),\n  en: () => import(\"i18n-iso-countries/langs/en.json\"),\n  es: () => import(\"i18n-iso-countries/langs/es.json\"),\n  et: () => import(\"i18n-iso-countries/langs/et.json\"),\n  fa: () => import(\"i18n-iso-countries/langs/fa.json\"),\n  fi: () => import(\"i18n-iso-countries/langs/fi.json\"),\n  fr: () => import(\"i18n-iso-countries/langs/fr.json\"),\n  hi: () => import(\"i18n-iso-countries/langs/hi.json\"),\n  hu: () => import(\"i18n-iso-countries/langs/hu.json\"),\n  hr: () => import(\"i18n-iso-countries/langs/hr.json\"),\n  id: () => import(\"i18n-iso-countries/langs/id.json\"),\n  it: () => import(\"i18n-iso-countries/langs/it.json\"),\n  ja: () => import(\"i18n-iso-countries/langs/ja.json\"),\n  ko: () => import(\"i18n-iso-countries/langs/ko.json\"),\n  lt: () => import(\"i18n-iso-countries/langs/lt.json\"),\n  lv: () => import(\"i18n-iso-countries/langs/lv.json\"),\n  nb: () => import(\"i18n-iso-countries/langs/nb.json\"),\n  nl: () => import(\"i18n-iso-countries/langs/nl.json\"),\n  nn: () => import(\"i18n-iso-countries/langs/nn.json\"),\n  pl: () => import(\"i18n-iso-countries/langs/pl.json\"),\n  pt: () => import(\"i18n-iso-countries/langs/pt.json\"),\n  ro: () => import(\"i18n-iso-countries/langs/ro.json\"),\n  ru: () => import(\"i18n-iso-countries/langs/ru.json\"),\n  sk: () => import(\"i18n-iso-countries/langs/sk.json\"),\n  sv: () => import(\"i18n-iso-countries/langs/sv.json\"),\n  th: () => import(\"i18n-iso-countries/langs/th.json\"),\n  tr: () => import(\"i18n-iso-countries/langs/tr.json\"),\n  ur: () => import(\"i18n-iso-countries/langs/ur.json\"),\n  uk: () => import(\"i18n-iso-countries/langs/uk.json\"),\n  vi: () => import(\"i18n-iso-countries/langs/vi.json\"),\n  zh: () => import(\"i18n-iso-countries/langs/zh.json\"),\n  tw: () => import(\"src/locales/countryNames/zh-TW.json\"),\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n} as { [key: string]: any };\n\nexport const getLocaleCode = (code: string) => {\n  if (code === \"zh-CN\") return \"zh\";\n  if (code === \"zh-TW\") return \"tw\";\n  return code.slice(0, 2);\n};\n\nexport async function registerCountry(locale: string) {\n  const localeCode = getLocaleCode(locale);\n  const countries = await localeCountries[localeCode]();\n  Countries.registerLocale(countries);\n}\n\nexport const localeLoader = {\n  afZA: () => import(\"./af-ZA.json\"),\n  ar: () => import(\"./ar.json\"),\n  bgBG: () => import(\"./bg-BG.json\"),\n  bnBD: () => import(\"./bn-BD.json\"),\n  caES: () => import(\"./ca-ES.json\"),\n  csCZ: () => import(\"./cs-CZ.json\"),\n  daDK: () => import(\"./da-DK.json\"),\n  deDE: () => import(\"./de-DE.json\"),\n  enGB: () => import(\"./en-GB.json\"),\n  enUS: () => import(\"./en-US.json\"),\n  esES: () => import(\"./es-ES.json\"),\n  etEE: () => import(\"./et-EE.json\"),\n  faIR: () => import(\"./fa-IR.json\"),\n  fiFI: () => import(\"./fi-FI.json\"),\n  frFR: () => import(\"./fr-FR.json\"),\n  hiIN: () => import(\"./hi-IN.json\"),\n  hrHR: () => import(\"./hr-HR.json\"),\n  huHU: () => import(\"./hu-HU.json\"),\n  idID: () => import(\"./id-ID.json\"),\n  itIT: () => import(\"./it-IT.json\"),\n  jaJP: () => import(\"./ja-JP.json\"),\n  koKR: () => import(\"./ko-KR.json\"),\n  ltLT: () => import(\"./lt-LT.json\"),\n  lvLV: () => import(\"./lv-LV.json\"),\n  nbNO: () => import(\"./nb-NO.json\"),\n  // neNP: () => import(\"./ne-NP.json\"),\n  nnNO: () => import(\"./nn-NO.json\"),\n  nlNL: () => import(\"./nl-NL.json\"),\n  plPL: () => import(\"./pl-PL.json\"),\n  ptBR: () => import(\"./pt-BR.json\"),\n  roRO: () => import(\"./ro-RO.json\"),\n  ruRU: () => import(\"./ru-RU.json\"),\n  skSK: () => import(\"./sk-SK.json\"),\n  svSE: () => import(\"./sv-SE.json\"),\n  thTH: () => import(\"./th-TH.json\"),\n  trTR: () => import(\"./tr-TR.json\"),\n  urPK: () => import(\"./ur-PK.json\"),\n  ukUA: () => import(\"./uk-UA.json\"),\n  viVN: () => import(\"./vi-VN.json\"),\n  zhCN: () => import(\"./zh-CN.json\"),\n  zhTW: () => import(\"./zh-TW.json\"),\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n} as { [key: string]: any };\n\nexport default localeLoader;\n"
  },
  {
    "path": "ui/v2.5/src/locales/it-IT.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Aggiungi\",\n        \"add_directory\": \"Aggiungi Cartella\",\n        \"add_entity\": \"Aggiungi {entityType}\",\n        \"add_to_entity\": \"Aggiungi a {entityType}\",\n        \"allow\": \"Acconsenti\",\n        \"allow_temporarily\": \"Acconsenti temporaneamente\",\n        \"anonymise\": \"Anonimizza\",\n        \"apply\": \"Applica\",\n        \"auto_tag\": \"Tag Automatico\",\n        \"backup\": \"Backup\",\n        \"browse_for_image\": \"Sfoglia per immagine…\",\n        \"cancel\": \"Cancella\",\n        \"clean\": \"Pulisci\",\n        \"clear\": \"Elimina\",\n        \"clear_back_image\": \"Elimina immagine retro\",\n        \"clear_front_image\": \"Elimina immagine fronte\",\n        \"clear_image\": \"Elimina Immagine\",\n        \"close\": \"Chiudi\",\n        \"confirm\": \"Conferma\",\n        \"continue\": \"Continua\",\n        \"create\": \"Crea\",\n        \"create_chapters\": \"Crea Capitolo\",\n        \"create_entity\": \"Crea {entityType}\",\n        \"create_marker\": \"Crea Marker\",\n        \"created_entity\": \"Creata/o {entity_type}: {entity_name}\",\n        \"customise\": \"Personalizza\",\n        \"delete\": \"Cancella\",\n        \"delete_entity\": \"Cancella {entityType}\",\n        \"delete_file\": \"Cancella file\",\n        \"delete_file_and_funscript\": \"Cancella file (e funscript)\",\n        \"delete_generated_supporting_files\": \"Cancella file di supporto creati\",\n        \"disallow\": \"Non Acconsentire\",\n        \"download\": \"Scarica\",\n        \"download_anonymised\": \"Scarica anonimamente\",\n        \"download_backup\": \"Scarica Backup\",\n        \"edit\": \"Edita\",\n        \"edit_entity\": \"Modifica {entityType}\",\n        \"export\": \"Esporta\",\n        \"export_all\": \"Esporta tutto…\",\n        \"find\": \"Trova\",\n        \"finish\": \"Finito\",\n        \"from_file\": \"Dal file…\",\n        \"from_url\": \"Dall'URL…\",\n        \"full_export\": \"Esporta Completo\",\n        \"full_import\": \"Importa Completo\",\n        \"generate\": \"Genera\",\n        \"generate_thumb_default\": \"Genera thumbnail predefinito\",\n        \"generate_thumb_from_current\": \"Genera thumbnail dall'attuale schermata\",\n        \"hash_migration\": \"migrazione hash\",\n        \"hide\": \"Nascondi\",\n        \"hide_configuration\": \"Nascondi Configurazione\",\n        \"identify\": \"Identifica\",\n        \"ignore\": \"Ignora\",\n        \"import\": \"Importa…\",\n        \"import_from_file\": \"Importa dal file\",\n        \"logout\": \"Esci\",\n        \"make_primary\": \"Rendi Primario\",\n        \"merge\": \"Unisci\",\n        \"next_action\": \"Prossima\",\n        \"not_running\": \"non in funzione\",\n        \"open_in_external_player\": \"Aprire nel lettore esterno\",\n        \"open_random\": \"Apri Casuale\",\n        \"overwrite\": \"Sovrascrivi\",\n        \"play_random\": \"Avvia Random\",\n        \"play_selected\": \"Avvia selezionato\",\n        \"preview\": \"Anteprima\",\n        \"previous_action\": \"Precedente\",\n        \"reassign\": \"Riassegna\",\n        \"refresh\": \"Aggiorna\",\n        \"reload_plugins\": \"Ricarica plugin\",\n        \"reload_scrapers\": \"Ricarica scraper\",\n        \"remove\": \"Rimuovi\",\n        \"remove_from_gallery\": \"Cancellare dalla Galleria\",\n        \"rename_gen_files\": \"Rinomina file creati\",\n        \"rescan\": \"Riscansiona\",\n        \"reshuffle\": \"Rimescola\",\n        \"running\": \"in funzione\",\n        \"save\": \"Salva\",\n        \"save_delete_settings\": \"Usa queste opzioni predefinite in cancellazione\",\n        \"save_filter\": \"Salva filtro\",\n        \"scan\": \"Scansiona\",\n        \"scrape\": \"Scrape\",\n        \"scrape_query\": \"Scrape query\",\n        \"scrape_scene_fragment\": \"Scrape per frammento\",\n        \"scrape_with\": \"Scrape con…\",\n        \"search\": \"Cerca\",\n        \"select_all\": \"Seleziona Tutto\",\n        \"select_entity\": \"Seleziona {entityType}\",\n        \"select_folders\": \"Seleziona cartelle\",\n        \"select_none\": \"Deseleziona Tutto\",\n        \"selective_auto_tag\": \"Tag Automatico Selettivo\",\n        \"selective_clean\": \"Pulizia Selettiva\",\n        \"selective_scan\": \"Scansione Selettiva\",\n        \"set_as_default\": \"Imposta come Predefinito\",\n        \"set_back_image\": \"Immagine Retro…\",\n        \"set_front_image\": \"Immagine Frontale…\",\n        \"set_image\": \"Imposta immagine…\",\n        \"show\": \"Mostra\",\n        \"show_configuration\": \"Mostra Configurazione\",\n        \"skip\": \"Salta\",\n        \"split\": \"Dividi\",\n        \"stop\": \"Stop\",\n        \"submit\": \"Invia\",\n        \"submit_stash_box\": \"Invia a Stash-Box\",\n        \"submit_update\": \"Invia aggiornamento\",\n        \"swap\": \"Scambia\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Sei sicuro di voler Pulire? Questa azione cancellerà informazioni e contenuto creato dal database per tutte le scene e gallerie che non si trovano più nel file system.\",\n            \"dry_mode_selected\": \"Dry Mode selezionato. Nessuna cancellazione avverrà, solo log.\",\n            \"import_warning\": \"Sei sicuro di voler importare? Questa azione cancellerà il database e lo reimporterà dai tuoi metadati esportati.\"\n        },\n        \"temp_disable\": \"Disabilita temporaneamente…\",\n        \"temp_enable\": \"Abilita temporaneamente…\",\n        \"unset\": \"Rimuovere Impostazione\",\n        \"use_default\": \"Usa predefinito\",\n        \"view_random\": \"Guarda Casuale\",\n        \"add_o\": \"Aggiungi O\",\n        \"choose_date\": \"Scegli una data\",\n        \"add_play\": \"Aggiungi riproduzione\",\n        \"disable\": \"Disabilita\",\n        \"encoding_image\": \"Conversione immagine…\",\n        \"clean_generated\": \"Rimuovi file generati\",\n        \"copy_to_clipboard\": \"Copia negli appunti\",\n        \"enable\": \"Abilita\",\n        \"add_manual_date\": \"Aggiungi Manualmente Data\",\n        \"assign_stashid_to_parent_studio\": \"Assegna StashID ad uno Studio esistente e aggiorna i dati\",\n        \"clear_date_data\": \"Cancella le date\",\n        \"create_parent_studio\": \"Crea Studio Padre\",\n        \"migrate_blobs\": \"Migra i Blobs (Oggetti di grandi dimensioni)\",\n        \"migrate_scene_screenshots\": \"Migra gli Screenshot delle scene\",\n        \"optimise_database\": \"Ottimizza il Database\",\n        \"reload\": \"Ricarica\",\n        \"remove_date\": \"Rimuovi la data\",\n        \"view_history\": \"Visualizza cronologia\",\n        \"add_sub_groups\": \"Aggiungi Sottogruppi\",\n        \"add_stash_id\": \"Aggiungi ID Stash\",\n        \"create_new\": \"Crea nuovo\",\n        \"load\": \"Carica\",\n        \"load_filter\": \"Carica filtro\",\n        \"play\": \"Riproduci\",\n        \"remove_from_containing_group\": \"Rimuovi dal Gruppo\",\n        \"reset_play_duration\": \"Reimposta la durata di riproduzione\",\n        \"reset_cover\": \"Ripristina Copertina Predefinita\",\n        \"select_directory\": \"Seleziona cartella\",\n        \"invert_selection\": \"Inverti Selezione\",\n        \"set_cover\": \"Imposta come Copertina\",\n        \"show_results\": \"Mostra risultati\",\n        \"show_count_results\": \"Mostra risultati {count}\",\n        \"sidebar\": {\n            \"close\": \"Chiudi barra laterale\",\n            \"open\": \"Apri barra laterale\",\n            \"toggle\": \"Attiva/disattiva barra laterale\"\n        }\n    },\n    \"actions_name\": \"Azioni\",\n    \"age\": \"Età\",\n    \"aliases\": \"Alias\",\n    \"all\": \"tutto\",\n    \"also_known_as\": \"Anche conosciuto/a come\",\n    \"appears_with\": \"Appare Con\",\n    \"ascending\": \"Ascendente\",\n    \"average_resolution\": \"Risoluzione Media\",\n    \"between_and\": \"e\",\n    \"birth_year\": \"Anno di Nascita\",\n    \"birthdate\": \"Compleanno\",\n    \"bitrate\": \"Bit Rate\",\n    \"blobs_storage_type\": {\n        \"database\": \"Database\",\n        \"filesystem\": \"Filesystem\"\n    },\n    \"captions\": \"Sottotitoli\",\n    \"career_length\": \"Lunghezza Carriera\",\n    \"chapters\": \"Capitoli\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Istanza stash-box Attiva:\",\n            \"blacklist_desc\": \"Oggetti nella Lista Nera sono esclusi dalle ricerche. Notare che sono espressioni regolari e non tengono conto delle maiuscole. Alcuni caratteri devono essere in 'sequenza di escape' con una barra rovesciata: {chars_require_escape}\",\n            \"blacklist_label\": \"Lista Nera\",\n            \"query_mode_auto\": \"Automatico\",\n            \"query_mode_auto_desc\": \"Usa metadati se presenti, o nome file\",\n            \"query_mode_dir\": \"Elenco\",\n            \"query_mode_dir_desc\": \"Usa solo la cartella che contiene il file video\",\n            \"query_mode_filename\": \"Nome file\",\n            \"query_mode_filename_desc\": \"Usa solo il nome file\",\n            \"query_mode_label\": \"Modalità Query\",\n            \"query_mode_metadata\": \"Metadati\",\n            \"query_mode_metadata_desc\": \"Usa solo metadati\",\n            \"query_mode_path\": \"Percorso\",\n            \"query_mode_path_desc\": \"Usa l'intero percorso del file\",\n            \"set_cover_desc\": \"Sostituisce la copertina della scena se viene trovata.\",\n            \"set_cover_label\": \"Imposta la copertina della scena\",\n            \"set_tag_desc\": \"Attacca tag alla scena, sovrascrivendoli o unendoli a quelli esistenti sulla scena.\",\n            \"set_tag_label\": \"Imposta i tags\",\n            \"source\": \"Sorgente\",\n            \"mark_organized_desc\": \"Contrassegna immediatamente la scena come \\\"ordinata\\\" quando si clicca sul pulsante Save.\",\n            \"mark_organized_label\": \"Contrassegna come \\\"ordinato\\\" al salvataggio\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Duplica oggetto blacklistato\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Genere dell'attore/attrice\",\n                \"description\": \"Attori con questi generi saranno mostrati durante il tag delle scene.\"\n            }\n        },\n        \"noun_query\": \"Query\",\n        \"results\": {\n            \"duration_off\": \"Lunghezza diversa di almeno {number}sec\",\n            \"duration_unknown\": \"Lunghezza sconosciuta\",\n            \"fp_found\": \"{fpCount, plural, =0 {Nuove impronte uguali non trovate} other {# nuove impronte uguali trovate}}\",\n            \"fp_matches\": \"La lunghezza è uguale\",\n            \"fp_matches_multi\": \"La lunghezza è uguale {matchCount}/{durationsLength} impronta/e\",\n            \"hash_matches\": \"{hash_type} è uguale\",\n            \"match_failed_already_tagged\": \"Scena già taggato\",\n            \"match_failed_no_result\": \"Nessun risultato trovato\",\n            \"match_success\": \"Scena taggata con successo\",\n            \"phash_matches\": \"{count} PHashes uguali\",\n            \"unnamed\": \"Senza nome\"\n        },\n        \"verb_match_fp\": \"Compara Impronte\",\n        \"verb_matched\": \"Comparate\",\n        \"verb_scrape_all\": \"Scrape Tutto\",\n        \"verb_submit_fp\": \"Invia {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} scene non uguali\",\n        \"verb_add_as_alias\": \"aggiungi nomi ottenuti con scrape come alias\",\n        \"verb_match_tag\": \"Compara Tag\",\n        \"verb_scrape_selected\": \"Scrape Selezionato\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Hash della build:\",\n            \"build_time\": \"Data della build:\",\n            \"check_for_new_version\": \"Cerca nuova versione\",\n            \"latest_version\": \"Ultima Versione\",\n            \"latest_version_build_hash\": \"Hash dell'ultima Versione:\",\n            \"new_version_notice\": \"[NUOVA]\",\n            \"release_date\": \"Data di rilascio:\",\n            \"stash_discord\": \"Unisciti al nostro canale {url}\",\n            \"stash_home\": \"Stash homepage su {url}\",\n            \"stash_open_collective\": \"Supportaci attraverso {url}\",\n            \"stash_wiki\": \"Pagina {url} di Stash\",\n            \"version\": \"Versione\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Percorsi Applicazione\"\n        },\n        \"categories\": {\n            \"about\": \"Chi siamo\",\n            \"changelog\": \"Registro dei cambiamenti\",\n            \"interface\": \"Interfaccia\",\n            \"logs\": \"Log\",\n            \"metadata_providers\": \"Provider dei Metadata\",\n            \"plugins\": \"Plugin\",\n            \"scraping\": \"Scraping\",\n            \"security\": \"Sicurezza\",\n            \"services\": \"Servizi\",\n            \"system\": \"Sistema\",\n            \"tasks\": \"Attività\",\n            \"tools\": \"Strumenti\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Acconsenti {tempIP}\",\n            \"allowed_ip_addresses\": \"Indirizzi IP con Accesso\",\n            \"allowed_ip_temporarily\": \"Permettere IP temporaneamente\",\n            \"default_ip_whitelist\": \"Lista Bianca degli indirizzi IP predefiniti\",\n            \"default_ip_whitelist_desc\": \"Indirizzi IP predefiniti con accesso DLNA. Usare {wildcard} per consentire tutti gli inditizzi IP.\",\n            \"disabled_dlna_temporarily\": \"Disabilitare DLNA temporaneamente\",\n            \"disallowed_ip\": \"IP Non consentito\",\n            \"enabled_by_default\": \"Attivo in modo predefinito\",\n            \"enabled_dlna_temporarily\": \"Abilitare DLNA temporaneamente\",\n            \"network_interfaces\": \"Interfacce\",\n            \"network_interfaces_desc\": \"Interfacce sulle quali esporre il server DLNA. Una lista vuota implicherà l'uso di tutte le interfacce. Richiede il riavvio del server DLNA dopo le modifiche.\",\n            \"recent_ip_addresses\": \"Indirizzi IP Recenti\",\n            \"server_display_name\": \"Nome del Server\",\n            \"server_display_name_desc\": \"Nome visualizzato per il server DLNA. Predefinito come {server_name} se lasciato vuoto.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Funzionamento temporaneo cancellato con successo\",\n            \"until_restart\": \"fino al riavvio\",\n            \"video_sort_order\": \"Ordinamento dei video di default\",\n            \"server_port\": \"Porta del server\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"Chiave API\",\n                \"api_key_desc\": \"Chiave API per sistemi esterni. Richiesta solo se nome untente/password sono configurati. Il nome utente dev'essere salvato prima di poter generare la chiave API.\",\n                \"authentication\": \"Autenticazione\",\n                \"clear_api_key\": \"Elimina chiave API\",\n                \"credentials\": {\n                    \"description\": \"Credenziali per restringere l'accesso a Stash.\",\n                    \"heading\": \"Credenziali\"\n                },\n                \"generate_api_key\": \"Genera chiave API\",\n                \"log_file\": \"File di Log\",\n                \"log_file_desc\": \"Percorso al file di log. Vuoto per disabilitare log su file. Richiede riavvio.\",\n                \"log_http\": \"Salva log accesso http\",\n                \"log_http_desc\": \"Salva log accesso http sul terminale. Richiede riavvio.\",\n                \"log_to_terminal\": \"Salva log sul terminale\",\n                \"log_to_terminal_desc\": \"Salva log sul terminale oltre che sul file. Sempre attivo se il logging su file è disabilitato. Richiede riavvio.\",\n                \"maximum_session_age\": \"Tempo Massimo Sessione\",\n                \"maximum_session_age_desc\": \"Massimo tempo di inutilizzo prima che la sessione espiri, in secondi.\",\n                \"password\": \"Password\",\n                \"password_desc\": \"Password per accedere a Stash. Lasciare vuota per disabilitare l'autenticazione utente\",\n                \"stash-box_integration\": \"Integrazione Stash-box\",\n                \"username\": \"Nome Utente\",\n                \"username_desc\": \"Nome Utente per accedere a Stash. Lasciare vuoto per disabilitare l'autenticazione\",\n                \"log_file_max_size\": \"Massima grandezza log\",\n                \"log_file_max_size_desc\": \"Massima grandezza in megabytes del file di log prima che sia compresso. 0MB è disabilitato. Richiede riavvio.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Percorso della directory per i file di backup del database SQLite\",\n                \"heading\": \"Percorso Directory di Backup\"\n            },\n            \"blobs_path\": {\n                \"heading\": \"Percorso dei dati binari\",\n                \"description\": \"Dove salvare dati binari nel filesystem. Applicabile solo quando si utilizza Filesystem con archiviazione Blob. ATTENZIONE: cambiare questo richiede spostare manualmente i dati esistenti.\"\n            },\n            \"cache_location\": \"Percorso della Cartella cache\",\n            \"cache_path_head\": \"Percorso Cache\",\n            \"calculate_md5_and_ohash_desc\": \"Calcola l'MD5 checksum oltre l'oshash. Attivare la funzione causerà una prima scansione più lenta. L'hash dei nomi file dev'essere impostata su oshash per disabilitare il calcolo MD5.\",\n            \"calculate_md5_and_ohash_label\": \"Calcolare l'MD5 per i video\",\n            \"check_for_insecure_certificates\": \"Controlla certificati non sicuri\",\n            \"check_for_insecure_certificates_desc\": \"Alcuni siti usano certificati ssl non sicuri. Quando non selezionato lo scraper salta il controllo e acconsente lo scraping di questi siti. Se ricevete un errore di certificato durante lo scraping spuntate la casella.\",\n            \"chrome_cdp_path\": \"Percorso al Chrome CDP\",\n            \"chrome_cdp_path_desc\": \"Percorso all'eseguibile di Chrome, o indirizzo remoto (iniziando con http:// o https://, per esempio http://localhost:9222/json/version) verso un'istanza Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Se spuntato, crea gallerie dalle cartelle che contengono immagini.\",\n            \"create_galleries_from_folders_label\": \"Crea gallerie dalle cartelle con immagini\",\n            \"db_path_head\": \"Percorso del Database\",\n            \"directory_locations_to_your_content\": \"Percorso della Cartella del tuo contenuto\",\n            \"excluded_image_gallery_patterns_desc\": \"Espressioni Regolari di file/percorsi di immagini e gallerie per escluderle dalla Scansione e aggiungerle alla Pulizia\",\n            \"excluded_image_gallery_patterns_head\": \"Schema Immagini/Gallerie Escluse\",\n            \"excluded_video_patterns_desc\": \"Espressioni Regolari di file/percorsi di video per escluderli dalla Scansione e aggiungerli alla Pulizia\",\n            \"excluded_video_patterns_head\": \"Schema Video Esclusi\",\n            \"gallery_cover_regex_desc\": \"Espressione regolare usata per identificare un immagine come copertina di galleria\",\n            \"gallery_cover_regex_label\": \"Schema copertina di galleria\",\n            \"gallery_ext_desc\": \"Lista di estensioni delimitate da Virgola che saranno identificate come gallerie in file compressi/zip.\",\n            \"gallery_ext_head\": \"Estensioni Gallerie zip\",\n            \"generated_file_naming_hash_desc\": \"Usa l'MD5 o oshas per i nomi dei file creati. Cambiarlo richiede che tutte le scene abbiano il valore MD5/oshash ripopolato. Dopo la modifica, i file esistenti necessiteranno di essere migrati o ricreati. Vedere la pagina Attività per la migrazione.\",\n            \"generated_file_naming_hash_head\": \"Hash dei nomi file creati\",\n            \"generated_files_location\": \"Locazione per i file creati (marker scene, anteprime scene, sprites, etc.)\",\n            \"generated_path_head\": \"Path Creati\",\n            \"hashing\": \"Hash\",\n            \"image_ext_desc\": \"Lista di estensioni delimitate da Virgola che saranno identificate come immagini.\",\n            \"image_ext_head\": \"Estensioni Immagine\",\n            \"include_audio_desc\": \"Includere flusso audio quando si creano le anteprime.\",\n            \"include_audio_head\": \"Includere audio\",\n            \"logging\": \"Log\",\n            \"maximum_streaming_transcode_size_desc\": \"Dimensione massima per i flussi transcodificati\",\n            \"maximum_streaming_transcode_size_head\": \"Massima dimensione transcodifica flusso\",\n            \"maximum_transcode_size_desc\": \"Dimensione massima transcodificazioni create\",\n            \"maximum_transcode_size_head\": \"Massima dimensione transcodificazioni\",\n            \"metadata_path\": {\n                \"description\": \"Percorso della Cartella usata quando si esegue un'esportazione/importazione completa\",\n                \"heading\": \"Percordo dei Metadati\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Impostare a 0 per rilevamento automatico. Attenzione permettere più attività di quelle necessarie per raggiungere un utilizzo CPU 100% diminuirà le prestazioni e potrebbe potenzialmente causare altri problemi.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Numero delle attività parallele per scansione/creazione\",\n            \"parallel_scan_head\": \"Scansione/Creazione Parallela\",\n            \"preview_generation\": \"Creazione Anteprime\",\n            \"python_path\": {\n                \"description\": \"Posizione dell'eseguibile python. Usato per gli script degli scraper e plugin. Se lasciato vuoto, verrà determinato dall'ambiente\",\n                \"heading\": \"Percorso Python\"\n            },\n            \"scraper_user_agent\": \"Scraper User Agent\",\n            \"scraper_user_agent_desc\": \"Stringa User-Agent usata durante le richieste http scrape\",\n            \"scrapers_path\": {\n                \"description\": \"Cartella dei file di configurazione degli scraper\",\n                \"heading\": \"Percorso Scraper\"\n            },\n            \"scraping\": \"Scraping\",\n            \"sqlite_location\": \"Locazione del file per il database SQLite (richiede riavvio) ATTENZIONE: salvare il database su un sistema differente rispetto a dove il server di stash è in funzione (p.es. sulla rete) non è supportato!\",\n            \"video_ext_desc\": \"Lista di estensioni delimitate da Virgola che saranno identificate come video.\",\n            \"video_ext_head\": \"Estensioni Video\",\n            \"video_head\": \"Video\",\n            \"delete_trash_path\": {\n                \"description\": \"Percorso dove i file cancellati saranno spostati invece di essere cancellati permanentemente. Lasciare vuoto per cancellare permanentemente i file.\",\n                \"heading\": \"Percorso cestino\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Dove salvare dati binari come copertine di scene, attori, studio e tag di immagini. Dopo aver cambiato questo valore, i dati esistenti devono essere migrati usando l'attività \\\"Migrare Blobs\\\". Guarda la pagina Attività per la migrazione.\",\n                \"heading\": \"Tipo storage per dati binari\"\n            },\n            \"database\": \"Database\",\n            \"ffmpeg\": {\n                \"download_ffmpeg\": {\n                    \"heading\": \"Scarica FFmpeg\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"Percorso per l'eseguibile di FFmpeg (non solo la cartella). Se vuoto, ffmpeg verrà determinato dall'ambiente tramite $PATH, dalla cartella di configurazione, o da $HOME/.stash.\",\n                    \"heading\": \"Percorso eseguibile FFmpeg\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Percorso per l'eseguibile di FFprobe (non solo la cartella). Se vuoto, ffprobe verrà determinato dall'ambiente tramite $PATH, dalla cartella di configurazione, o da $HOME/.stash.\",\n                    \"heading\": \"Percorso eseguibile FFprobe\"\n                },\n                \"hardware_acceleration\": {\n                    \"desc\": \"Usa l'Hardware disponibile per codificare video per la transcodifica in tempo reale.\",\n                    \"heading\": \"Codifica hardware FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avanzato: Arguments aggiuntivi da passare a FFmpeg nel campo di input prima della transcodifica video in tempo reale\"\n                    }\n                }\n            }\n        },\n        \"library\": {\n            \"exclusions\": \"Esclusioni\",\n            \"gallery_and_image_options\": \"Opzioni Galleria e Immagine\",\n            \"media_content_extensions\": \"Estensioni contenuto media\"\n        },\n        \"logs\": {\n            \"log_level\": \"Livello di Log\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Hooks\",\n            \"triggers_on\": \"Triggers on\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metadati\",\n            \"entity_scrapers\": \"{entityType} scraper\",\n            \"excluded_tag_patterns_desc\": \"Espressioni Regolari di nomi tag per escluderli dai risultati dello scraping\",\n            \"excluded_tag_patterns_head\": \"Schema Tag esclusi\",\n            \"scraper\": \"Scraper\",\n            \"scrapers\": \"Scraper\",\n            \"search_by_name\": \"Cerca per nome\",\n            \"supported_types\": \"Tipi supportati\",\n            \"supported_urls\": \"URL\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Aggiungi istanza stash-box\",\n            \"api_key\": \"Chiave API\",\n            \"description\": \"Stash-box facilita il tag automatico delle scene e degli attori basandosi sulle impronte e nomi file.\\nL'endpoint e la chiave API possono essere trovati sul tuo account sull'istanza stash-box. I nomi sono richiesti quando più di un'istanza è aggiunta..\",\n            \"endpoint\": \"Endpoint\",\n            \"graphql_endpoint\": \"Endpoint del GraphQL\",\n            \"name\": \"Nome\",\n            \"title\": \"Endpoint della Stash-box\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transcodificando\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Aggiunto/a {operation_name} alla coda lavori\",\n            \"anonymise_and_download\": \"Crea una copia anonima del database e scarica il file creato.\",\n            \"anonymise_database\": \"Crea una copia del database nella cartella di backup, anonimizzando tutti i dati sensibili. Questo file può essere inviato ad altri come aiuto nella risoluzione dei problemi. Il database anonimizzato viene salvato in formato {filename_format}.\",\n            \"anonymising_database\": \"Anonimizzando il database\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Taggare Automaticamente tutti i percorsi\",\n                \"auto_tagging_paths\": \"Taggare Automaticamente i seguenti percorsi\"\n            },\n            \"auto_tag_based_on_filenames\": \"Tag automatico del contenuto basato sui nomi file.\",\n            \"auto_tagging\": \"Tag Automatico\",\n            \"backing_up_database\": \"Backup del database\",\n            \"backup_and_download\": \"Esegue il backup del database e scarica il file risultante.\",\n            \"cleanup_desc\": \"Controlla il database per file mancanti e li rimuove. Questa è un'azione distruttiva.\",\n            \"data_management\": \"Gestione dati\",\n            \"defaults_set\": \"I default sono stati scelti e saranno usati quando si clicca il bottone {action} nella pagina Attività.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Non includere l'estensione file come parte del titolo\",\n            \"empty_queue\": \"Nessuna attività in esecuzione.\",\n            \"export_to_json\": \"Esporta il contenuto del database in formato JSON nella cartella metadati.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Creando per le scene dai seguenti percorsi\",\n                \"generating_scenes\": \"Creando per {num} {scene}\"\n            },\n            \"generate_desc\": \"Crea i file di supporto immagini, sprite, video, vtt e altri file.\",\n            \"generate_phashes_during_scan\": \"Crea i phash\",\n            \"generate_phashes_during_scan_tooltip\": \"Per deduplicazione e identificazione scena.\",\n            \"generate_previews_during_scan\": \"Crea le anteprime immagini animate\",\n            \"generate_previews_during_scan_tooltip\": \"Crea anteprime animate WebP, richiesto solo se Immagine Animata è selezionata come Tipo Anteprima.\",\n            \"generate_sprites_during_scan\": \"Crea gli sprite per lo scrubber\",\n            \"generate_thumbnails_during_scan\": \"Crea thumbnail per le immagini\",\n            \"generate_video_previews_during_scan\": \"Crea anteprime\",\n            \"generate_video_previews_during_scan_tooltip\": \"Crea anteprime video che vengono riprodotte quando si passa il mouse su una scena\",\n            \"generated_content\": \"Contenuto Creato\",\n            \"identify\": {\n                \"and_create_missing\": \"e crea mancante\",\n                \"create_missing\": \"Crea mancante\",\n                \"default_options\": \"Opzioni Predefinite\",\n                \"description\": \"Imposta automaticamente i metadata della scena usando stash-box e risorse scraper.\",\n                \"explicit_set_description\": \"Le seguenti opzioni saranno usate dove non sovrascritte nelle specifiche opzioni sorgente.\",\n                \"field\": \"Campo\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Opzioni Campo\",\n                \"heading\": \"Identifica\",\n                \"identifying_from_paths\": \"Identificando scene dai seguenti percorsi\",\n                \"identifying_scenes\": \"Identificando {num} {scene}\",\n                \"include_male_performers\": \"Includere attori maschi\",\n                \"set_cover_images\": \"Imposta immagini copertina\",\n                \"set_organized\": \"Imposta flag organizzato\",\n                \"source\": \"Sorgente\",\n                \"source_options\": \"{source} Opzioni\",\n                \"sources\": \"Sorgenti\",\n                \"strategy\": \"Strategia\"\n            },\n            \"import_from_exported_json\": \"Importa dal JSON esportato nella cartella metadati. Rimuove il database esistente.\",\n            \"incremental_import\": \"Importazione incrementale da un file esporto zip fornito.\",\n            \"job_queue\": \"Coda Attività\",\n            \"maintenance\": \"Manutenzione\",\n            \"migrate_hash_files\": \"Usato dopo aver cambiato l'hash dei Nomi Creati per rinominare i file creati esistenti nel nuovo formato hash.\",\n            \"migrations\": \"Migrazioni\",\n            \"only_dry_run\": \"Esegue solo una Dry Run. Non cancella niente\",\n            \"plugin_tasks\": \"Attività Plugin\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Scansione di tutti i percorsi\",\n                \"scanning_paths\": \"Scansione dei seguenti percorsi\"\n            },\n            \"scan_for_content_desc\": \"Scansiona per cercare nuovo contenuto e lo aggiunge al database.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Imposta il nome, data e dettagli dai metadati\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Controllo Scene Duplicate\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Aggiungi Campo\",\n                \"capitalize_title\": \"Titolo in maiuscole\",\n                \"display_fields\": \"Visualizza campi\",\n                \"escape_chars\": \"Usare \\\\ per 'sequenza di escape' dei caratteri letterali\",\n                \"filename\": \"Nome file\",\n                \"filename_pattern\": \"Schema Nome file\",\n                \"ignore_organized\": \"Ignorare scene ordinate\",\n                \"ignored_words\": \"Ignorare parole\",\n                \"matches_with\": \"Comparare con {i}\",\n                \"select_parser_recipe\": \"Scegliere Parser Recipe\",\n                \"title\": \"Parser Nome file Scena\",\n                \"whitespace_chars\": \"Caratteri spazio vuoto\",\n                \"whitespace_chars_desc\": \"Questi caratteri saranno sostituiti con spazi vuoti nel titolo\"\n            },\n            \"scene_tools\": \"Strumenti Scena\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Abbrevia i contatori nelle carte e dettagli delle pagine, per esempio \\\"1831\\\" sarà formattato in \\\"1.8K\\\".\",\n                \"heading\": \"Abbrevia contatori\"\n            },\n            \"basic_settings\": \"Opzioni Base\",\n            \"custom_css\": {\n                \"description\": \"La pagina dev'essere ricaricata per applicare i cambiamenti.\",\n                \"heading\": \"CSS Personalizzato\",\n                \"option_label\": \"CSS Personalizzato Attivo\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"La pagina dev'essere ricaricata per applicare i cambiamenti.\",\n                \"heading\": \"Javascript Personalizzato\",\n                \"option_label\": \"Javascript personalizzato attivo\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Sovrascrive stringhe individuali locali. Vedere https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json per la lista principale. La pagina dev'essere ricaricata per far sì che i cambiamenti abbiano effetto.\",\n                \"heading\": \"Localizzazione personalizzata\",\n                \"option_label\": \"Localizzazione personalizzata attiva\"\n            },\n            \"delete_options\": {\n                \"description\": \"Opzioni predefinite quando si cancellano immagini, gallerie e scene.\",\n                \"heading\": \"Opzioni di Cancellazione\",\n                \"options\": {\n                    \"delete_file\": \"Cancella il file per impostazione predefinita\",\n                    \"delete_generated_supporting_files\": \"Cancella i file di supporto creati per impostazione predefinita\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Integrazione Desktop\",\n                \"notifications_enabled\": \"Attiva Notifiche\",\n                \"send_desktop_notifications_for_events\": \"Invia notifiche desktop per gli eventi\",\n                \"skip_opening_browser\": \"Salta l'Apertura del Browser\",\n                \"skip_opening_browser_on_startup\": \"Salta l'apertura automatica del browser durante l'avvio\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Rimuove la possibilità di creare nuovi oggetti dai menù a tendina\",\n                    \"heading\": \"Disabilita crezione menù a tendina\"\n                },\n                \"heading\": \"Modifiche\",\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Precisione Stelle Valutazione\",\n                        \"options\": {\n                            \"full\": \"Pieno\",\n                            \"half\": \"Metà\",\n                            \"quarter\": \"Un quarto\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Tipo di Sistema di Valutazione\",\n                        \"options\": {\n                            \"decimal\": \"Decimale\",\n                            \"stars\": \"Stelle\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Offset in millisecondi per gli script interattivi.\",\n                \"heading\": \"Funscript Offset (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Connetti\",\n                \"server_offset\": {\n                    \"heading\": \"Offset del Server\"\n                },\n                \"status\": {\n                    \"heading\": \"Stato Connessione Handy\"\n                },\n                \"sync\": \"Sincronizza\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Chiave di connessione Handy per l'uso con scene interattive. Impostare questa chiave permetterà a Stash di condividere le informazioni della scena attuale con handyfeeling.com\",\n                \"heading\": \"Chiave Connessione Handy\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Immagine Lightbox\"\n            },\n            \"images\": {\n                \"heading\": \"Immagini\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Scrive gli thumbnail sul disco quando vengono creati al volo\",\n                        \"heading\": \"Scrive thumbnail immagini\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Opzioni Interattive\",\n            \"language\": {\n                \"heading\": \"Lingua\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Massima lunghezza della scena per quando il lettore lo leggerà in loop - 0 per disabilitare\",\n                \"heading\": \"Lunghezza di loop massima\"\n            },\n            \"menu_items\": {\n                \"description\": \"Mostra o nasconde differenti tipi di contenuti nella barra navigazione\",\n                \"heading\": \"Oggetti Menù\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Percentuale di tempo in cui una scena dev'essere letta prima che il contatore visualizzazioni aumenti.\",\n                \"heading\": \"Minima Percentuale Lettura\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Percorso personalizzato per le immagini predefinite degli attori. Lasciare vuoto per usare il predefinito interno\",\n                        \"heading\": \"Percorso Immagine Attore/Attrice Personalizzato\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Configurazione per gli oggetti sul muro\",\n                \"heading\": \"Tipo di Anteprima\",\n                \"options\": {\n                    \"animated\": \"Immagine Animata\",\n                    \"static\": \"Immagine Statica\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Lista Scene\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Mostra gli Studio come testo\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Lettore Scene\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Inizia sempre il video dall'inizio\",\n                    \"auto_start_video\": \"Inizia a Leggere Automaticamente il video\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Avvio automatico dei video quando avviati dalla coda, quando \\\"Avvia selezionato\\\" o casuale dalla pagina Scene\",\n                        \"heading\": \"Avvio automatico del video 'Avvia selezionato'\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Avvia la prossima scena in coda quando il video finisce\",\n                        \"heading\": \"Continua la playlist per impostazione predefinita\"\n                    },\n                    \"show_scrubber\": \"Mostra Scrubber\",\n                    \"track_activity\": \"Traccia l'Attività\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Scena / Marcatore Muro\",\n                \"options\": {\n                    \"display_title\": \"Mostra titolo e tag\",\n                    \"toggle_sound\": \"Abilita suono\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Numero di tentativi di scorrimento prima di passare al prossimo/precedente oggetto. Si applica solo al modo di scorrimento Pan Y.\",\n                \"heading\": \"Tentativi di scorrimento prima della transizione\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Mostra la carta della tag quando si passa il mouse sopra le badge tag\",\n                \"heading\": \"Carta tag popup\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"La presentazione è disponibile nelle gallerie quando in modalità muro\",\n                \"heading\": \"Ritardo Presentazione (secondi)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Vista Studio\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"Nella vista studio, visualizza anche il contenuto dei sub-studio\",\n                        \"heading\": \"Visualizza contenuto sub-studio\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Vista Tag\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"Nella vista tag, visualizza anche il contenuto delle subtag\",\n                        \"heading\": \"Visualizza contenuto subtag\"\n                    }\n                }\n            },\n            \"title\": \"Interfaccia Utente\"\n        },\n        \"advanced_mode\": \"Modalità Avanzata\",\n        \"changelog\": {\n            \"header\": \"Registro delle modifiche\"\n        }\n    },\n    \"configuration\": \"Configurazione\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {File} other {File}}\",\n        \"galleries\": \"{count, plural, one {Galleria} other {Gallerie}}\",\n        \"images\": \"{count, plural, one {Immagine} other {Immagini}}\",\n        \"markers\": \"{count, plural, one {Marcatore} other {Marcatori}}\",\n        \"performers\": \"{count, plural, one {Attore/Attrice} other {Attori/Attrici}}\",\n        \"scenes\": \"{count, plural, one {Scena} other {Scene}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Studi}}\",\n        \"tags\": \"{count, plural, one {Tag} other {Tag}}\"\n    },\n    \"country\": \"Paese\",\n    \"cover_image\": \"Copertina\",\n    \"created_at\": \"Creato/a Nel\",\n    \"criterion\": {\n        \"greater_than\": \"Maggiore di\",\n        \"less_than\": \"Minore di\",\n        \"value\": \"Valore\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"tra\",\n        \"equals\": \"è\",\n        \"excludes\": \"esclude\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"è maggiore di\",\n        \"includes\": \"include\",\n        \"includes_all\": \"include tutto\",\n        \"is_null\": \"è nullo\",\n        \"less_than\": \"è minore di\",\n        \"matches_regex\": \"regex uguale\",\n        \"not_between\": \"non tra\",\n        \"not_equals\": \"non è\",\n        \"not_matches_regex\": \"non soddisfa la regex\",\n        \"not_null\": \"non è nullo\"\n    },\n    \"custom\": \"Personalizzato\",\n    \"date\": \"Data\",\n    \"death_date\": \"Data Morte\",\n    \"death_year\": \"Anno della Morte\",\n    \"descending\": \"Discendente\",\n    \"description\": \"Descrizione\",\n    \"detail\": \"Dettaglio\",\n    \"details\": \"Dettagli\",\n    \"developmentVersion\": \"Versione Sviluppo\",\n    \"dialogs\": {\n        \"create_new_entity\": \"Crea nuovo/a {entity}\",\n        \"delete_alert\": \"Il seguente/I seguenti {count, plural, one {{singularEntity}} other {{pluralEntity}}} sarà/saranno cancellati permanentemente:\",\n        \"delete_confirm\": \"Sei sicuro di voler cancellare {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Sei sicuro/a di voler cancellare questo/a {singularEntity}? A meno che anche il file venga cancellato, questo/a {singularEntity} sarà riaggiunto quando la scansione verrà effettuata.} other {Sei sicuro/a di voler cancellare questi/e {pluralEntity}? A meno che anche i file vengano cancellati, questi/e {pluralEntity} verranno riaggiunti quando la scansione verrà effettuata.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Sei sicuro di voler cancellare questa/o {singularEntity}?} other {Sei sicuro di voler cancellare questi/e {pluralEntity}?}}\",\n        \"delete_entity_title\": \"{count, plural, one {Cancellazione {singularEntity}} other {Cancellazione {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"...in più qualsiasi immagine non inclusa in nessun'altra galleria.\",\n        \"delete_gallery_files\": \"Cancella gallerie cartella/zip e qualsiasi immagino non inclusa in altre gallerie.\",\n        \"delete_object_desc\": \"Sei sicuro/a di voler cancellare {count, plural, one {questo/a {singularEntity}} other {questi/e {pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…e {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Cancellazione {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Non mostrare fino al prossimo aggiornamento\",\n        \"edit_entity_title\": \"Modifica {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Include gli oggetti collegati nell'esportazione\",\n        \"export_title\": \"Esportazione\",\n        \"lightbox\": {\n            \"delay\": \"Ritardo (Sec)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Adatta Orizzontalmente\",\n                \"fit_to_screen\": \"Adatta allo Schermo\",\n                \"label\": \"Modo di Visualizzazione\",\n                \"original\": \"Originale\"\n            },\n            \"options\": \"Opzioni\",\n            \"reset_zoom_on_nav\": \"Reimposta il livello di zoom quando si cambia immagine\",\n            \"scale_up\": {\n                \"description\": \"Aumenta lo zoom per le immagini piccole per riempire lo schermo\",\n                \"label\": \"Aumento per adattare\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Tener premuto Shift per usare temporaneamente l'altro modo.\",\n                \"label\": \"Modo di Scorrimento\",\n                \"pan_y\": \"Pan Y\",\n                \"zoom\": \"Zoom\"\n            }\n        },\n        \"merge\": {\n            \"destination\": \"Destinazione\",\n            \"empty_results\": \"Il valore del campo destinazione rimarranno inalterati.\",\n            \"source\": \"Sorgente\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {Riassegna {singularEntity}} other {Riassegna {pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Riassegna a\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Forza la creazione delle Transcodifiche\",\n            \"force_transcodes_tooltip\": \"Per standard, le transcodifiche vengono generate solamente quando il file video non è supportato dal browser. Quando abilitato, le transcodifiche verranno generate anche quando il file sembra essere supportato dal browser.\",\n            \"image_previews\": \"Anteprime Immagini Animate\",\n            \"image_previews_tooltip\": \"Anteprime Animate WebP, richiesto solamente se Immagine Animata è selezionata come Tipo Anteprima.\",\n            \"interactive_heatmap_speed\": \"Crea le mappe di calore e velocità per le scene interattive\",\n            \"marker_image_previews\": \"Marcatori Anteprime Immagini Animate\",\n            \"marker_image_previews_tooltip\": \"Anteprime marcatore animato WebP, richiesto solo se Immagine Animata è selezionata come Tipo Anteprima.\",\n            \"marker_screenshots\": \"Schermate Marcatori\",\n            \"marker_screenshots_tooltip\": \"Immagini marcatore statico JPG, richiesto solo se Immagine Statica è selezionata come Tipo Anteprima.\",\n            \"markers\": \"Anteprime Marcatori\",\n            \"markers_tooltip\": \"Video di 20 secondi che iniziano ad un determinato momento.\",\n            \"override_preview_generation_options\": \"Opzioni di Override della Creazione Anteprime\",\n            \"override_preview_generation_options_desc\": \"Opzioni di Override per la Creazione Anteprime per questa operazione. I prefediniti sono impostati in Sistema -> Creazione Anteprime.\",\n            \"overwrite\": \"Sovrascrive gli esistenti file creati\",\n            \"phash\": \"Perceptual hashes/Hash Percettivo (per i duplicati)\",\n            \"preview_exclude_end_time_desc\": \"Esclude gli ultimi x secondi dall'anteprima scena. Può essere un valore in secondi, o una percentuale (es. 2%) della lunghezza totale della scena.\",\n            \"preview_exclude_end_time_head\": \"Esclude Tempo Finale\",\n            \"preview_exclude_start_time_desc\": \"Esclude i primi x secondi dall'anteprima scena. Può essere un valore in secondi, o una percentuale (es. 2%) della lunghezza totale della scena.\",\n            \"preview_exclude_start_time_head\": \"Esclude Tempo Iniziale\",\n            \"preview_generation_options\": \"Opzioni Creazione Anteprime\",\n            \"preview_options\": \"Opzioni Anteprima\",\n            \"preview_preset_desc\": \"Regola la dimensione, qualità e tempo di codifica della creazione delle anteprime. Impostazioni oltre 'lento' hanno rendimenti marginali e non sono raccomandati.\",\n            \"preview_preset_head\": \"Impostazione codifica Anteprima\",\n            \"preview_seg_count_desc\": \"Numero di segmenti dei file anteprima.\",\n            \"preview_seg_count_head\": \"Numero di segmenti nell'anteprima\",\n            \"preview_seg_duration_desc\": \"Lunghezza di ogni segmento anteprima, in secondi.\",\n            \"preview_seg_duration_head\": \"Lunghezza segmento anteprima\",\n            \"sprites\": \"Sprites Scena Scrubber\",\n            \"sprites_tooltip\": \"Sprite (per lo scrubber delle scene)\",\n            \"transcodes\": \"Transcodifiche\",\n            \"transcodes_tooltip\": \"Conversioni in MP4 di formati video non supportati\",\n            \"video_previews\": \"Anteprime\",\n            \"video_previews_tooltip\": \"Anteprime video che vengono riprodotte quando si passa il mouse sopra una scena\"\n        },\n        \"scenes_found\": \"{count} scene trovate\",\n        \"scrape_entity_query\": \"{entity_type} Richiesta Scrape\",\n        \"scrape_entity_title\": \"{entity_type} Risultati dello Scrape\",\n        \"scrape_results_existing\": \"Esistenti\",\n        \"scrape_results_scraped\": \"Scrape Effettuato\",\n        \"set_image_url_title\": \"URL Immagine\",\n        \"unsaved_changes\": \"Modifiche non salvate. Sei sicuro di voler lasciare la pagina?\",\n        \"clear_play_history_confirm\": \"Sei sicuro/a di voler resettare la cronologia delle riproduzioni?\",\n        \"clear_o_history_confirm\": \"Sei sicuro/a di voler resettare la cronologia di O?\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Colonna\"\n            }\n        }\n    },\n    \"dimensions\": \"Dimensioni\",\n    \"director\": \"Regista\",\n    \"disambiguation\": \"Disambiguità\",\n    \"display_mode\": {\n        \"grid\": \"Griglia\",\n        \"list\": \"Lista\",\n        \"tagger\": \"Tagger\",\n        \"unknown\": \"Sconosciuto/a\",\n        \"wall\": \"Muro\"\n    },\n    \"donate\": \"Dona\",\n    \"dupe_check\": {\n        \"description\": \"Livelli sotto 'Esatto' possono richiedere più tempo di calcolo. Si possono anche trovare falsi positivi con livelli d'accuratezza inferiore.\",\n        \"found_sets\": \"{setCount, plural, one{# set di duplicati trovati.} other {# set di duplicati trovati.}}\",\n        \"options\": {\n            \"exact\": \"Esatto\",\n            \"high\": \"Alto\",\n            \"low\": \"Basso\",\n            \"medium\": \"Medio\"\n        },\n        \"search_accuracy_label\": \"Accuratezza Ricerca\",\n        \"title\": \"Scene Duplicate\"\n    },\n    \"duplicated_phash\": \"Duplicato (phash)\",\n    \"duration\": \"Lunghezza\",\n    \"effect_filters\": {\n        \"aspect\": \"Aspetto\",\n        \"blue\": \"Blu\",\n        \"blur\": \"Sfumatura\",\n        \"brightness\": \"Luminosità\",\n        \"contrast\": \"Contrasto\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Verde\",\n        \"hue\": \"Tonalità\",\n        \"name\": \"Filtri\",\n        \"name_transforms\": \"Trasformazione\",\n        \"red\": \"Rosso\",\n        \"reset_filters\": \"Reimposta Filtri\",\n        \"reset_transforms\": \"Reimposta Trasformazioni\",\n        \"rotate\": \"Ruota\",\n        \"rotate_left_and_scale\": \"Ruota a Sinistra & Scala\",\n        \"rotate_right_and_scale\": \"Ruota a Destra & Scala\",\n        \"saturation\": \"Saturazione\",\n        \"scale\": \"Scala\",\n        \"warmth\": \"Temperatura Colore\"\n    },\n    \"empty_server\": \"Aggiungi alcune scene al tuo server per vedere suggerimenti in questa pagina.\",\n    \"ethnicity\": \"Etnia\",\n    \"existing_value\": \"valore esistente\",\n    \"eye_color\": \"Colore Occhi\",\n    \"fake_tits\": \"Tette Finte\",\n    \"false\": \"Falso\",\n    \"favourite\": \"Favorita\",\n    \"file\": \"file\",\n    \"file_count\": \"Numero File\",\n    \"file_info\": \"Informazioni File\",\n    \"file_mod_time\": \"Tempo Modifica del File\",\n    \"files\": \"file\",\n    \"files_amount\": \"{value} file\",\n    \"filesize\": \"Dimensione File\",\n    \"filter\": \"Filtro\",\n    \"filter_name\": \"Nome Filtro\",\n    \"filters\": \"Filtri\",\n    \"folder\": \"Cartella\",\n    \"framerate\": \"Frequenza dei Fotogrammi\",\n    \"frames_per_second\": \"{value} fotogrammi per secondo\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Filtro Già Creato\",\n            \"saved_filter\": \"Filtro Salvato\"\n        }\n    },\n    \"galleries\": \"Gallerie\",\n    \"gallery\": \"Galleria\",\n    \"gallery_count\": \"Numero Gallerie\",\n    \"gender\": \"Genere\",\n    \"gender_types\": {\n        \"FEMALE\": \"Donna\",\n        \"INTERSEX\": \"Intersessualità\",\n        \"MALE\": \"Uomo\",\n        \"NON_BINARY\": \"Non-Binario\",\n        \"TRANSGENDER_FEMALE\": \"Donna Transgender\",\n        \"TRANSGENDER_MALE\": \"Uomo Transgender\"\n    },\n    \"hair_color\": \"Colore Capelli\",\n    \"handy_connection_status\": {\n        \"connecting\": \"In connessione\",\n        \"disconnected\": \"Disconnesso\",\n        \"error\": \"Errore di connessione a Handy\",\n        \"missing\": \"Mancante\",\n        \"ready\": \"Pronto\",\n        \"syncing\": \"Sincronizzando col server\",\n        \"uploading\": \"Script in upload\"\n    },\n    \"hasMarkers\": \"Ha Marcatori\",\n    \"height\": \"Altezza\",\n    \"height_cm\": \"Altezza (cm)\",\n    \"help\": \"Aiuto\",\n    \"ignore_auto_tag\": \"Ignora Auto Tag\",\n    \"image\": \"Immagine\",\n    \"image_count\": \"Numero Immagini\",\n    \"images\": \"Immagini\",\n    \"include_parent_tags\": \"Include tag principali\",\n    \"include_sub_studios\": \"Includi Studi sussidiari\",\n    \"include_sub_tags\": \"Include sotto-tags\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interattivo\",\n    \"interactive_speed\": \"Velocità interattiva\",\n    \"isMissing\": \"Manca di\",\n    \"last_played_at\": \"Ultima Visualizzazione Al\",\n    \"library\": \"Libreria\",\n    \"loading\": {\n        \"generic\": \"Caricamento…\"\n    },\n    \"marker_count\": \"Numero Marcatori\",\n    \"markers\": \"Marcatori\",\n    \"measurements\": \"Misure\",\n    \"media_info\": {\n        \"audio_codec\": \"Codec Audio\",\n        \"downloaded_from\": \"Scaricato Da\",\n        \"interactive_speed\": \"Velocità interattiva\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} in questa scena\"\n        },\n        \"phash\": \"PHash\",\n        \"play_count\": \"Contatore Visualizzazioni\",\n        \"play_duration\": \"Durata Visualizzazione\",\n        \"stream\": \"Flusso\",\n        \"video_codec\": \"Codec Video\"\n    },\n    \"megabits_per_second\": \"{value} megabits per secondo\",\n    \"metadata\": \"Metadati\",\n    \"name\": \"Nome\",\n    \"new\": \"Nuovo\",\n    \"none\": \"Nessuno/a\",\n    \"operations\": \"Operazioni\",\n    \"organized\": \"Ordinato\",\n    \"pagination\": {\n        \"first\": \"Prima\",\n        \"last\": \"Ultima\",\n        \"next\": \"Prossima\",\n        \"previous\": \"Precedente\"\n    },\n    \"parent_of\": \"Principale di {children}\",\n    \"parent_studios\": \"Studi Principali\",\n    \"parent_tag_count\": \"Numero Tag Principali\",\n    \"parent_tags\": \"Tag Principali\",\n    \"part_of\": \"Parte di {parent}\",\n    \"path\": \"Percorso\",\n    \"perceptual_similarity\": \"Somiglianza percettiva (phash)\",\n    \"performer\": \"Attore/Attrice\",\n    \"performer_age\": \"Età Attore/Attrice\",\n    \"performer_count\": \"Numero Attori\",\n    \"performer_favorite\": \"Attore/Attrice Favorito\",\n    \"performer_image\": \"Immagine Attore/Attrice\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Aggiungi Nuovi Attori\",\n        \"any_names_entered_will_be_queried\": \"Qualsiasi nome inserito sarà richiesto dall'istanza remota Stash-Box e aggiunto se trovato. Solo corrispondenze esatte saranno considerate corrispondenze.\",\n        \"batch_add_performers\": \"Aggiungi Attori in Blocco\",\n        \"batch_update_performers\": \"Aggiorna Attori in Blocco\",\n        \"current_page\": \"Pagina corrente\",\n        \"failed_to_save_performer\": \"Salvataggio attore/attrice \\\"{performer}\\\" fallito\",\n        \"name_already_exists\": \"Nome già esistente\",\n        \"network_error\": \"Errore di Rete\",\n        \"no_results_found\": \"Nessun risultato trovato.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} attori saranno processati\",\n        \"performer_already_tagged\": \"Attore/Attrice già taggato/a\",\n        \"performer_selection\": \"Selezione attore/attrice\",\n        \"performer_successfully_tagged\": \"Attore/Attrice taggato/a con successo:\",\n        \"query_all_performers_in_the_database\": \"Tutti gli attori nel database\",\n        \"refresh_tagged_performers\": \"Aggiorna attori taggati\",\n        \"refreshing_will_update_the_data\": \"Aggiornare aggiornerà i dati di qualsiasi attore/attrice taggato dall'istanza stash-box.\",\n        \"status_tagging_job_queued\": \"Stato: Lavoro tag in coda\",\n        \"status_tagging_performers\": \"Stato: Taggando attori\",\n        \"tag_status\": \"Stato tag\",\n        \"to_use_the_performer_tagger\": \"Per usare un tagger per attore/attrice un'istanza stash-box dev'essere configurata.\",\n        \"untagged_performers\": \"Attori non taggati\",\n        \"update_performer\": \"Aggiornare Attore/Attrice\",\n        \"update_performers\": \"Aggiorna Attori\",\n        \"updating_untagged_performers_description\": \"Aggiornare gli attori non taggati cercherà di abbinare qualsiasi attore/attrice senza stashid e aggiornerà i metadata.\"\n    },\n    \"performer_tags\": \"Tag Attore/Attrice\",\n    \"performers\": \"Attori\",\n    \"piercings\": \"Piercing\",\n    \"play_count\": \"Contatore Visualizzazioni\",\n    \"play_duration\": \"Durata Visualizzazioni\",\n    \"primary_file\": \"File primario\",\n    \"queue\": \"Coda\",\n    \"random\": \"Casuale\",\n    \"rating\": \"Classif.\",\n    \"recently_added_objects\": \"{objects} Aggiunto Recentemente\",\n    \"recently_released_objects\": \"{objects} Recentemente Distribuito\",\n    \"release_notes\": \"Note Versione\",\n    \"resolution\": \"Risoluzione\",\n    \"resume_time\": \"Tempo Continuazione\",\n    \"scene\": \"Scena\",\n    \"sceneTagger\": \"Tagger Scena\",\n    \"scene_code\": \"Codice dello Studio\",\n    \"scene_count\": \"Numero Scene\",\n    \"scene_created_at\": \"Scena Creata Al\",\n    \"scene_date\": \"Data della Scena\",\n    \"scene_id\": \"ID Scena\",\n    \"scene_tags\": \"Tag Scena\",\n    \"scene_updated_at\": \"Scena Aggiornata Al\",\n    \"scenes\": \"Scene\",\n    \"scenes_updated_at\": \"Scena Aggiornata Al\",\n    \"search_filter\": {\n        \"name\": \"Filtro\",\n        \"saved_filters\": \"Filtri Salvati\",\n        \"update_filter\": \"Aggiorna Filtro\"\n    },\n    \"seconds\": \"Secondi\",\n    \"settings\": \"Impostazioni\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Siamo quasi pronti a completare la configurazione. Per favore confermate le seguenti opzioni. Potete selezionare indietro per cambiare qualsiasi inesattezza. Se tutto sembra ok, premere Conferma per creare il vostro sistema.\",\n            \"configuration_file_location\": \"Locazione del file di configurazione:\",\n            \"database_file_path\": \"Percorso al file database\",\n            \"generated_directory\": \"Cartella 'generated'\",\n            \"nearly_there\": \"Quasi arrivati!\",\n            \"stash_library_directories\": \"Cartelle libreria di Stash\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Creando il tuo sistema\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Oh no! Qualcosa è andato storto!\",\n            \"something_went_wrong_description\": \"Se questo sembra essere un problema con i dati inseriti, premete indietro per correggerli. Altrimenti, aprite un bug report su {githubLink} o cercare aiuto su {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Qualcosa è andato storto durante la configurazione del tuo sistema. Questo è l'errore che abbiamo ricevuto: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Percorso file\",\n            \"up_dir\": \"Sali una cartella\"\n        },\n        \"github_repository\": \"Github repository\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Percorsa per il file di backup del database (lasciare vuoto per disabilitare il backup):\",\n            \"backup_recommended\": \"È raccomandato fare il backup del database esistente prima di migrare. Possiamo farlo per te, facendo una copia del tuo scrivendo un backup in <code>{defaultBackupPath}</code> se necessario.\",\n            \"migrating_database\": \"Migrando il database\",\n            \"migration_failed\": \"Migrazione fallita\",\n            \"migration_failed_error\": \"Il seguente errore è stato riscontrato mentre si migrava il database:\",\n            \"migration_failed_help\": \"Per favore fate ogni correzione necessaria e provate di nuovo. Altrimenti, compilate un bug report su {githubLink} o cercate aiuto su {discordLink}.\",\n            \"migration_irreversible_warning\": \"La migrazione 'schema' non è reversibile. Una volta attuata, il vostro database sarà incompatibile con le versioni precedenti di Stash.\",\n            \"migration_notes\": \"Note Migrazione\",\n            \"migration_required\": \"Migrazione richiesta\",\n            \"perform_schema_migration\": \"Esegue la migrazione 'schema'\",\n            \"schema_too_old\": \"Lo 'schema version' del tuo attuale database di Stash è <strong>{databaseSchema}</strong> e dev'essere migrato alla versione <strong>{appSchema}</strong>. Questa versione di Stash non funzionerà senza aver migrato il database. Se non si desidera migrare, si dovrà fare il downgrade ad una versione che corrisponda allo schema attuale.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"nome file del database (vuoto per predefinito)\",\n            \"description\": \"Prossimo passo, dobbiamo determinare dove si trova la tua collezione porno, dove salvare il database di Stash e i file creati. Queste opzioni possono essere cambiate in seguito se necessario.\",\n            \"path_to_generated_directory_empty_for_default\": \"percorso alla cartella 'generated' (vuoto per predefinito)\",\n            \"set_up_your_paths\": \"Imposta i tuoi percorsi\",\n            \"stash_alert\": \"Nessun percorso alla libreria selezionato. Nessun media sarà scansionato in Stash. Siete sicuri?\",\n            \"where_can_stash_store_its_database\": \"Dove può Stash salvare il suo database?\",\n            \"where_can_stash_store_its_database_description\": \"Stash usa un database sqlite per salvare i metadata dei tuoi porno. In modo predefinito, questo sarà creato come <code>stash-go.sqlite</code> nella cartella che contiene il tuo file di configurazione. Se volete cambiare, per favore inserite un nome file assoluto o relativo (all'attuale cartella di lavoro).\",\n            \"where_can_stash_store_its_generated_content\": \"Dove può Stash salvare il suo contenuto creato?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Per fornire i thumbnails, anteprime e sprite, Stash crea immagini e video. Questo inoltre include le transcodifiche per i formato file non supportati. In modo predefinito, Stash creerà una cartella <code>generated</code> dentro la cartella contenente il tuo file di configurazione. Se volete cambiare questa locazione, per favore inserite un percorso assoluto o relativo (all'attuale cartella di lavoro). Stash creerà questa cartella se non esiste già.\",\n            \"where_is_your_porn_located\": \"Dove si trova il tuo porno?\",\n            \"where_is_your_porn_located_description\": \"Aggiungete cartelle che contengono i vostri video e immagini porno. Stash userà queste cartelle per trovare video e immagini durante la scansione.\"\n        },\n        \"stash_setup_wizard\": \"Procedura Guidata Stash\",\n        \"success\": {\n            \"getting_help\": \"Ricevere aiuto\",\n            \"help_links\": \"Se incorrete in dei problemi o avete domande o suggerimenti, sentitevi liberi di aprire un 'issue' su {githubLink}, o chiedete alla comunità su {discordLink}.\",\n            \"in_app_manual_explained\": \"Siete incoraggiati a controllare il manuale in-app al quale si può accedere dall'icona in alto a destra dello schermo che assomiglia a questo: {icon}\",\n            \"next_config_step_one\": \"Ora sarete portati sulla pagina Configurazione. Questa pagina vi permetterà di customizzare quali file includere ed escludere, impostare un nome utente e password per proteggere il vostro sistema e un sacco di altre opzioni.\",\n            \"next_config_step_two\": \"Quando siete soddisfatti con queste opzioni, potete iniziare a scansire il vostro contenuto dentro Stash cliccando su <code>{localized_task}</code>, e poi <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Controllate il nostro {open_collective_link} per vedere come potete contribuire al continuo sviluppo di Stash.\",\n            \"support_us\": \"Supportaci\",\n            \"thanks_for_trying_stash\": \"Grazie di provare Stash!\",\n            \"welcome_contrib\": \"Contributi in forma di codice (correzione bug, miglioramenti e nuove opzioni, test, report bug, miglioramenti e richieste di caratteristiche e supporto utente sono benvenuti. I dettagli possono essere trovati nella sezione Contributi nel manuale in-app.\",\n            \"your_system_has_been_created\": \"Successo! Il vostro sistema è stato creato!\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash cerca di trovare il file di configurazione (<code>config.yml</code>) dall'attuale cartella di lavoro inizialmente e se non lo trova lì, cerca in <code>$HOME/.stash/config.yml</code> (sotto Windows, sarà <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>). Potete far leggere a Stash un specifico file di configurazione lanciandolo con l'opzione <code>-c '<percorso al file>'</code> o <code>--config '<percorso al file>'</code>.\",\n            \"in_current_stash_directory\": \"Nella cartella <code>{path}</code>:\",\n            \"in_the_current_working_directory\": \"In <code>{path}</code> , attuale cartella di lavoro:\",\n            \"next_step\": \"Dopo tutto questo, se siete pronti a procedere con la configurazione di un nuovo sistema, scegliete dove vorreste salvare il file di configurazione e premete Prossimo.\",\n            \"store_stash_config\": \"Dove volete salvare la vostra configurazione di Stash?\",\n            \"unable_to_locate_config\": \"Se potete leggere questo, allora Stash non ha potuto trovare una configurazione esistente. Questa procedura guidata vi guiderà attraverso il processo per impostare una nuova configurazione.\",\n            \"unexpected_explained\": \"Se avete raggiunto questa schermata inaspettatamente, per favore provare a riavviare Stash nella corretta cartella di lavoro o con il flag <code>-c</code>.\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash userà il seguente percorso per il file di configurazione: <code>{path}</code>\",\n            \"next_step\": \"Quando siete pronti a procedere all'impostazione di un nuovo sistema, cliccate Prossimo.\",\n            \"unable_to_locate_specified_config\": \"Se state leggendo questo, allora Stash non ha potuto trovare il file di configurazione specificato nella linea di comando o ambiente. Questa procedura guidata vi guiderà attraverso il processo di impostazione di una nuova configurazione.\"\n        },\n        \"welcome_to_stash\": \"Benvenuti su Stash\"\n    },\n    \"stash_id\": \"ID Stash\",\n    \"stash_id_endpoint\": \"Stash Endpoint ID\",\n    \"stash_ids\": \"ID Stash\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Vai al {endpoint_name} per revisionare la bozza.\",\n        \"selected_stash_box\": \"Endpoint Stash-Box selezionato\",\n        \"submission_failed\": \"Invio fallito\",\n        \"submission_successful\": \"Invio riuscito\",\n        \"submit_update\": \"Già esistente in {endpoint_name}\"\n    },\n    \"statistics\": \"Statistiche\",\n    \"stats\": {\n        \"image_size\": \"Dimensione immagini\",\n        \"scenes_duration\": \"Lunghezza scene\",\n        \"scenes_size\": \"Dimensione scene\"\n    },\n    \"status\": \"Stato: {statusText}\",\n    \"studio\": \"Studio\",\n    \"studio_depth\": \"Livelli (vuoto per tutti)\",\n    \"studios\": \"Studi\",\n    \"sub_tag_count\": \"Numero Sotto-Tag\",\n    \"sub_tag_of\": \"Sotto-tag di {parent}\",\n    \"sub_tags\": \"Sotto-Tags\",\n    \"subsidiary_studios\": \"Filiali\",\n    \"synopsis\": \"Sinossi\",\n    \"tag\": \"Tag\",\n    \"tag_count\": \"Numero Tag\",\n    \"tags\": \"Tag\",\n    \"tattoos\": \"Tatuaggi\",\n    \"title\": \"Titolo\",\n    \"toast\": {\n        \"added_entity\": \"Aggiunto {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Aggiunto lavoro di creazione alla coda\",\n        \"created_entity\": \"Creato/a {entity}\",\n        \"default_filter_set\": \"Filtro predefinito impostato\",\n        \"delete_past_tense\": \"Cancellato/a {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Sto creando la schermata…\",\n        \"merged_scenes\": \"Scene unite\",\n        \"merged_tags\": \"Tag unite\",\n        \"reassign_past_tense\": \"File riassegnato\",\n        \"removed_entity\": \"Rimosso {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"rescanning_entity\": \"Riscansionando {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"Salvato/a {entity}\",\n        \"started_auto_tagging\": \"Iniziato il tag automatico\",\n        \"started_generating\": \"Creazione iniziata\",\n        \"started_importing\": \"Importazione iniziata\",\n        \"updated_entity\": \"Aggiornato/a {entity}\"\n    },\n    \"total\": \"Totale\",\n    \"true\": \"Vero\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Tipo\",\n    \"updated_at\": \"Aggiornato Al\",\n    \"url\": \"URL\",\n    \"videos\": \"Video\",\n    \"view_all\": \"Vedi Tutto\",\n    \"weight\": \"Peso\",\n    \"weight_kg\": \"Peso (kg)\",\n    \"years_old\": \"anni\",\n    \"zip_file_count\": \"Numero File Zip\",\n    \"video_codec\": \"Codec Video\",\n    \"unknown_date\": \"Data sconosciuta\",\n    \"urls\": \"URL\",\n    \"validation\": {\n        \"blank\": \"${path} non può essere vuoto\",\n        \"date_invalid_form\": \"${path} deve essere nel formato AAAA-MM-GG\"\n    },\n    \"datetime_format\": \"AAAA-MM-GG OO:MM\",\n    \"date_format\": \"AAAA-MM-GG\",\n    \"audio_codec\": \"Codec Audio\",\n    \"circumcised\": \"Circonciso\",\n    \"circumcised_types\": {\n        \"CUT\": \"Taglia\",\n        \"UNCUT\": \"Annulla taglio\"\n    },\n    \"studio_tags\": \"Tag dello Studio\",\n    \"sub_group\": \"Sottogruppo\",\n    \"sub_group_count\": \"Numero di Sottogruppi\",\n    \"sub_group_of\": \"Sottogruppo di {parent}\",\n    \"sub_group_order\": \"Ordine Sottogruppi\",\n    \"sub_groups\": \"Sottogruppi\",\n    \"subsidiary_studio_count\": \"Numero di Studi sussidiari\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/ja-JP.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"追加\",\n        \"add_directory\": \"ディレクトリを追加\",\n        \"add_entity\": \"{entityType}を追加\",\n        \"add_to_entity\": \"{entityType}に追加\",\n        \"allow\": \"許可\",\n        \"allow_temporarily\": \"一時的に許可\",\n        \"anonymise\": \"匿名にする\",\n        \"apply\": \"適用\",\n        \"auto_tag\": \"自動タグ付け\",\n        \"backup\": \"バックアップ\",\n        \"browse_for_image\": \"画像を参照…\",\n        \"cancel\": \"キャンセル\",\n        \"clean\": \"クリーニング\",\n        \"clear\": \"クリア\",\n        \"clear_back_image\": \"背景画像を削除\",\n        \"clear_front_image\": \"ジャケット画像をクリア\",\n        \"clear_image\": \"画像をクリア\",\n        \"close\": \"閉じる\",\n        \"confirm\": \"確認\",\n        \"continue\": \"続行\",\n        \"create\": \"作成\",\n        \"create_entity\": \"{entityType}を作成\",\n        \"create_marker\": \"マーカーを作成\",\n        \"created_entity\": \"{entity_type}を作成しました: {entity_name}\",\n        \"customise\": \"カスタマイズ\",\n        \"delete\": \"削除\",\n        \"delete_entity\": \"{entityType}を削除\",\n        \"delete_file\": \"ファイルを削除\",\n        \"delete_file_and_funscript\": \"ファイルを削除 (ファンスクリプトを含む)\",\n        \"delete_generated_supporting_files\": \"生成済みのサポートファイルを削除\",\n        \"disallow\": \"拒否\",\n        \"download\": \"ダウンロード\",\n        \"download_anonymised\": \"匿名でダウンロード\",\n        \"download_backup\": \"バックアップをダウンロード\",\n        \"edit\": \"編集\",\n        \"edit_entity\": \"{entityType}を編集\",\n        \"export\": \"エクスポート\",\n        \"export_all\": \"全てエクスポート…\",\n        \"find\": \"探す\",\n        \"finish\": \"完了\",\n        \"from_file\": \"ファイルから…\",\n        \"from_url\": \"URLから…\",\n        \"full_export\": \"完全エクスポート\",\n        \"full_import\": \"完全インポート\",\n        \"generate\": \"生成\",\n        \"generate_thumb_default\": \"デフォルトサムネイルの生成\",\n        \"generate_thumb_from_current\": \"現在のものからサムネイルを生成\",\n        \"hash_migration\": \"ハッシュ移行\",\n        \"hide\": \"非表示\",\n        \"hide_configuration\": \"設定を非表示\",\n        \"identify\": \"識別\",\n        \"ignore\": \"無視\",\n        \"import\": \"インポート…\",\n        \"import_from_file\": \"ファイルからインポート\",\n        \"logout\": \"ログアウト\",\n        \"make_primary\": \"メインに設定\",\n        \"merge\": \"マージ\",\n        \"next_action\": \"次へ\",\n        \"not_running\": \"未実行\",\n        \"open_in_external_player\": \"外部プレーヤーで開く\",\n        \"open_random\": \"ランダムで開く\",\n        \"overwrite\": \"上書き\",\n        \"play_random\": \"ランダム再生\",\n        \"play_selected\": \"選択したものを再生\",\n        \"preview\": \"プレビュー\",\n        \"previous_action\": \"戻る\",\n        \"reassign\": \"入れ替える\",\n        \"refresh\": \"更新\",\n        \"reload_plugins\": \"プラグインを再読み込み\",\n        \"reload_scrapers\": \"スクレイパーを再読み込み\",\n        \"remove\": \"削除\",\n        \"remove_from_gallery\": \"ギャラリーから削除\",\n        \"rename_gen_files\": \"生成済みのファイルの名前を変更\",\n        \"rescan\": \"再スキャン\",\n        \"reshuffle\": \"再シャッフル\",\n        \"running\": \"実行中\",\n        \"save\": \"保存\",\n        \"save_delete_settings\": \"削除時にこれらの設定をデフォルトで使用する\",\n        \"save_filter\": \"フィルターを保存\",\n        \"scan\": \"スキャン\",\n        \"scrape\": \"スクレイプ\",\n        \"scrape_query\": \"スクレイプクエリ\",\n        \"scrape_scene_fragment\": \"フラグメントでスクレイプ\",\n        \"scrape_with\": \"次でスクレイプ…\",\n        \"search\": \"検索\",\n        \"select_all\": \"全て選択\",\n        \"select_entity\": \"{entityType}を選択\",\n        \"select_folders\": \"フォルダーを選択\",\n        \"select_none\": \"選択なし\",\n        \"selective_auto_tag\": \"選択して自動タグ付け\",\n        \"selective_clean\": \"選択してクリーニング\",\n        \"selective_scan\": \"選択してスキャン\",\n        \"set_as_default\": \"デフォルトに設定\",\n        \"set_back_image\": \"背景画像…\",\n        \"set_front_image\": \"ジャケット画像…\",\n        \"set_image\": \"画像を設定…\",\n        \"show\": \"表示\",\n        \"show_configuration\": \"設定を表示\",\n        \"skip\": \"スキップ\",\n        \"split\": \"分割\",\n        \"stop\": \"停止\",\n        \"submit\": \"送信\",\n        \"submit_stash_box\": \"Stash-Boxに送信\",\n        \"submit_update\": \"更新を送信\",\n        \"swap\": \"入れ替える\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"クリーニングを実行してもよろしいですか？この操作により、ファイルシステムで利用されていないすべてのシーンとギャラリーから生成されたコンテンツとデータベース情報が削除されます。\",\n            \"dry_mode_selected\": \"ドライモードが選択されています。実際の削除は実施されず、ログ処理だけが実行されます。\",\n            \"import_warning\": \"インポートしてもよろしいですか？この操作により、データベースが削除され、エクスポートされているメタデータから改めてインポートされます。\"\n        },\n        \"temp_disable\": \"一時的に無効…\",\n        \"temp_enable\": \"一時的に有効…\",\n        \"unset\": \"未設定\",\n        \"use_default\": \"デフォルトを使用\",\n        \"view_random\": \"ランダムで表示\",\n        \"assign_stashid_to_parent_studio\": \"存在する親スタジオにStash IDを割り当ててメタデータを更新する\",\n        \"encoding_image\": \"画像をエンコード中…\",\n        \"disable\": \"無効\",\n        \"reload\": \"再読み込み\",\n        \"clear_date_data\": \"日付データを削除\",\n        \"migrate_blobs\": \"Blobsを移行\",\n        \"migrate_scene_screenshots\": \"シーンのスクリーンショットを移行\",\n        \"add_manual_date\": \"手動で日付を追加\",\n        \"add_o\": \"Oを追加\",\n        \"add_play\": \"再生を追加\",\n        \"choose_date\": \"日付を選択\",\n        \"clean_generated\": \"生成済みのファイルを削除\",\n        \"copy_to_clipboard\": \"クリップボードにコピー\",\n        \"create_chapters\": \"チャプターを生成\",\n        \"enable\": \"有効\",\n        \"remove_date\": \"日付を削除\",\n        \"create_parent_studio\": \"親スタジオを作成\",\n        \"optimise_database\": \"データベースを最適化\",\n        \"reset_play_duration\": \"プレイ時間をリセット\",\n        \"add_sub_groups\": \"サブグループを追加\",\n        \"remove_from_containing_group\": \"グループから削除\",\n        \"set_cover\": \"カバーをセット\",\n        \"view_history\": \"履歴を表示する\",\n        \"reset_resume_time\": \"再開時間をリセットする\",\n        \"reset_cover\": \"標準カバーに復元\",\n        \"sidebar\": {\n            \"close\": \"サイドバーを閉じる\",\n            \"open\": \"サイドバーを開く\",\n            \"toggle\": \"サイドバーを切り替え\"\n        },\n        \"play\": \"再生\",\n        \"show_results\": \"結果を表示\",\n        \"show_count_results\": \"{count}件の結果を表示\",\n        \"load\": \"読み込み\",\n        \"add_stash_id\": \"Stash IDを追加\",\n        \"create_new\": \"新規作成\",\n        \"load_filter\": \"フィルタを読み込む\",\n        \"invert_selection\": \"選択を反転\"\n    },\n    \"actions_name\": \"操作\",\n    \"age\": \"年齢\",\n    \"aliases\": \"別名\",\n    \"all\": \"全て\",\n    \"also_known_as\": \"A.K.A\",\n    \"ascending\": \"昇順\",\n    \"average_resolution\": \"平均的な解像度\",\n    \"between_and\": \"と\",\n    \"birth_year\": \"誕生年\",\n    \"birthdate\": \"誕生日\",\n    \"bitrate\": \"ビットレート\",\n    \"captions\": \"字幕\",\n    \"career_length\": \"キャリア歴\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"アクティブなStash-boxのインスタンス：\",\n            \"blacklist_desc\": \"ブラックリストに指定したアイテムはクエリから除外されます。これらは正規表現かつ大文字と小文字は区別されません。特定の文字はバックスラッシュでエスケープする必要があります: {chars_require_escape}\",\n            \"blacklist_label\": \"ブラックリスト\",\n            \"query_mode_auto\": \"自動\",\n            \"query_mode_auto_desc\": \"利用可能であればメタデータまたはファイル名を使用\",\n            \"query_mode_dir\": \"ディレクトリ\",\n            \"query_mode_dir_desc\": \"動画ファイルの親ディレクトリのみを使用します\",\n            \"query_mode_filename\": \"ファイル名\",\n            \"query_mode_filename_desc\": \"ファイル名のみを使用します\",\n            \"query_mode_label\": \"クエリモード\",\n            \"query_mode_metadata\": \"メタデータ\",\n            \"query_mode_metadata_desc\": \"メタデータのみを使用します\",\n            \"query_mode_path\": \"パス\",\n            \"query_mode_path_desc\": \"ファイルパス全体を使用します\",\n            \"set_cover_desc\": \"見つかった場合はシーンのカバー画像を置き換えます。\",\n            \"set_cover_label\": \"シーンのカバー画像を設定\",\n            \"set_tag_desc\": \"シーン上の既存のタグを上書きまたはマージすることで、シーンにタグを付与します。\",\n            \"set_tag_label\": \"タグを設定\",\n            \"source\": \"ソース\",\n            \"mark_organized_desc\": \"保存ボタンをクリック後に、すぐにシーンが「分類済み」になります。\",\n            \"mark_organized_label\": \"分類済みにして保存\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"ブラックリスト項目が重複しています\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"出演者の性別\"\n            }\n        },\n        \"noun_query\": \"クエリ\",\n        \"results\": {\n            \"duration_off\": \"最低{number}秒間オフ\",\n            \"duration_unknown\": \"長さ不明\",\n            \"fp_found\": \"{fpCount, plural, =0 {No new fingerprint matches found} other {# new fingerprint matches found}}\",\n            \"fp_matches\": \"長さが一致しました\",\n            \"fp_matches_multi\": \"長さが一致しました {matchCount}/{durationsLength}件のハッシュ値\",\n            \"hash_matches\": \"{hash_type}が一致しました\",\n            \"match_failed_already_tagged\": \"シーンはすでにタグ付けされています\",\n            \"match_failed_no_result\": \"結果が見つかりませんでした\",\n            \"match_success\": \"シーンのタグ付けに成功しました\",\n            \"phash_matches\": \"{count}件のPハッシュが一致しました\",\n            \"unnamed\": \"名称未設定\"\n        },\n        \"verb_match_fp\": \"ハッシュ値を突き合わせる\",\n        \"verb_matched\": \"一致\",\n        \"verb_scrape_all\": \"全てスクレイプ\",\n        \"verb_submit_fp\": \"送信 {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} 一致しないシーン\",\n        \"verb_add_as_alias\": \"スクレイピングした名前をエイリアスとして追加\",\n        \"verb_link_existing\": \"既存のものにリンク\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"ビルドハッシュ:\",\n            \"build_time\": \"ビルド日時:\",\n            \"check_for_new_version\": \"新しいバージョンを確認\",\n            \"latest_version\": \"最新バージョン\",\n            \"latest_version_build_hash\": \"最新バージョンのビルドハッシュ:\",\n            \"new_version_notice\": \"[NEW]\",\n            \"release_date\": \"リリース日:\",\n            \"stash_discord\": \"私たちの{url}チャンネルへ参加しませんか\",\n            \"stash_home\": \"Stashのすべては{url}にあります\",\n            \"stash_open_collective\": \"{url}からStashの開発を支援\",\n            \"stash_wiki\": \"Stashの{url}で疑問を解決\",\n            \"version\": \"バージョン\"\n        },\n        \"application_paths\": {\n            \"heading\": \"アプリケーションパス\"\n        },\n        \"categories\": {\n            \"about\": \"Stashについて\",\n            \"changelog\": \"変更履歴\",\n            \"interface\": \"インターフェース\",\n            \"logs\": \"ログ\",\n            \"metadata_providers\": \"メタデータのプロバイダー\",\n            \"plugins\": \"プラグイン\",\n            \"scraping\": \"スクレイピング\",\n            \"security\": \"セキュリティー\",\n            \"services\": \"サービス\",\n            \"system\": \"システム\",\n            \"tasks\": \"タスク\",\n            \"tools\": \"ツール\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"{tempIP}を許可\",\n            \"allowed_ip_addresses\": \"許可済みのIPアドレス\",\n            \"allowed_ip_temporarily\": \"一時的に許可するIPアドレス\",\n            \"default_ip_whitelist\": \"デフォルトIPアドレスのホワイトリスト\",\n            \"default_ip_whitelist_desc\": \"デフォルトIPアドレスからのDLNAへのアクセスを許可します。すべてのIPアドレスを許可するには、{wildcard}を使用してください。\",\n            \"disabled_dlna_temporarily\": \"一時的にDLNAを無効にする\",\n            \"disallowed_ip\": \"無効なIPアドレス\",\n            \"enabled_by_default\": \"デフォルトで有効\",\n            \"enabled_dlna_temporarily\": \"一時的にDLNAを有効にする\",\n            \"network_interfaces\": \"インターフェース\",\n            \"network_interfaces_desc\": \"DLNAサーバーを公開するためのネットワークインターフェースです。リストが空の場合は、すべてのインターフェースで実行されます。 変更後にDLNAを再起動する必要があります。\",\n            \"recent_ip_addresses\": \"最近のIPアドレス\",\n            \"server_display_name\": \"サーバー表示名\",\n            \"server_display_name_desc\": \"DLNAサーバーの表示名を設定できます。空欄の場合は、デフォルトの{server_name}が設定されます。\",\n            \"successfully_cancelled_temporary_behaviour\": \"一時的な挙動のキャンセルに成功しました\",\n            \"until_restart\": \"再起動まで\",\n            \"video_sort_order\": \"デフォルトの動画ソート順序\",\n            \"video_sort_order_desc\": \"動画をデフォルト順でソートします。\",\n            \"server_port\": \"サーバーポート番号\",\n            \"server_port_desc\": \"DLNA サーバーのポートを変更後は、 DLNA を再起動する必要があります。\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"APIキー\",\n                \"api_key_desc\": \"外部システムのためのAPIキーです。ユーザー名/パスワードが設定されている場合のみ必要です。ユーザー名はAPIキーを生成する前に保存されている必要があります。\",\n                \"authentication\": \"認証\",\n                \"clear_api_key\": \"APIキーをクリア\",\n                \"credentials\": {\n                    \"description\": \"Stashへのアクセスを制限するための認証情報です。\",\n                    \"heading\": \"認証情報\"\n                },\n                \"generate_api_key\": \"APIキーを生成\",\n                \"log_file\": \"ログファイル\",\n                \"log_file_desc\": \"ログを出力するファイルのパスを指定してください。空白の場合は、ファイルへのログ出力が無効になります。設定後は再起動が必要です。\",\n                \"log_http\": \"http accessのログ\",\n                \"log_http_desc\": \"http accessログをターミナルへ出力します。設定後は再起動が必要です。\",\n                \"log_to_terminal\": \"ターミナルへログ出力\",\n                \"log_to_terminal_desc\": \"ファイルに加えて、ターミナルへログ出力します。ファイルへのログ出力が無効であっても有効になります。設定後は再起動が必要です。\",\n                \"maximum_session_age\": \"最大セッション期限\",\n                \"maximum_session_age_desc\": \"ログインセッションが無効になるまでの最大待機時間を”秒”単位で指定できます。再起動が必要です。\",\n                \"password\": \"パスワード\",\n                \"password_desc\": \"Stashにアクセスするためのパスワードです。空白にすると、ユーザー認証を無効にします\",\n                \"stash-box_integration\": \"Stash-boxの連携\",\n                \"username\": \"ユーザー名\",\n                \"username_desc\": \"Stashにアクセスするためのユーザー名です。空白にすると、ユーザー認証を無効にします\",\n                \"log_file_max_size\": \"最大ログサイズ\",\n                \"log_file_max_size_desc\": \"ログファイルが圧縮される前の最大サイズ(MB)。0MBの場合は無効。再起動が必要。\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"SQLiteデータベースファイルのバックアップ場所\",\n                \"heading\": \"バックアップディレクトリパス\"\n            },\n            \"cache_location\": \"キャッシュのディレクトリ\",\n            \"cache_path_head\": \"キャッシュのパス\",\n            \"calculate_md5_and_ohash_desc\": \"oshashに加えてMD5チェックサムを計算します。有効にすると、初期スキャンが少々遅くなります。MD5計算を無効にするには、ファイル名のハッシュをoshashに設定する必要があります。\",\n            \"calculate_md5_and_ohash_label\": \"動画のMD5を計算\",\n            \"check_for_insecure_certificates\": \"安全でない証明書をチェック\",\n            \"check_for_insecure_certificates_desc\": \"一部のサイトは安全でないSSL証明書を使用している場合があります。チェックを外すと、スクレイパーは安全でない証明書のチェックをスキップし、それらのサイトのスクレイピングを許可します。 スクレイピング時に証明書エラーが発生した場合は、これのチェックを外してください。\",\n            \"chrome_cdp_path\": \"Chrome CDPのパス\",\n            \"chrome_cdp_path_desc\": \"Chrome実行ファイルへのパスまたはChromeインスタンスへのリモートアドレス(http://またはhttps://から始まるもの、例えばhttp://localhost:9222/json/version)を指定してください。\",\n            \"create_galleries_from_folders_desc\": \"有効な場合、画像を含むフォルダーからギャラリーを作成します。\",\n            \"create_galleries_from_folders_label\": \"画像を含むフォルダーからギャラリーを作成\",\n            \"db_path_head\": \"データベースパス\",\n            \"directory_locations_to_your_content\": \"コンテンツの場所\",\n            \"excluded_image_gallery_patterns_desc\": \"スキャンから除外し、クリーニングに追加する画像とギャラリーのファイル/パスの正規表現を指定できます\",\n            \"excluded_image_gallery_patterns_head\": \"除外する画像/ギャラリーの規則\",\n            \"excluded_video_patterns_desc\": \"スキャンから除外し、クリーニングに追加する動画のファイル/パスの正規表現を指定できます\",\n            \"excluded_video_patterns_head\": \"除外する動画の規則\",\n            \"ffmpeg\": {\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"詳細: 動画をライブ変換する際に、ffmpegに通す、input欄の前に付与する追加の引数を指定できます。\",\n                        \"heading\": \"ffmpegライブ変換入力引数\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"詳細: 動画をライブ変換する際に、ffmpegに通す、output欄の前に付与する追加の引数を指定できます。\",\n                        \"heading\": \"ffmpegライブ変換出力引数\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"詳細: 動画を生成する際に、ffmpegに通す、input欄の前に付与する追加の引数を指定できます。\",\n                        \"heading\": \"ffmpeg変換入力引数\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"詳細: 動画をライブ変換する際に、ffmpegに通す、output欄の前に付与する追加の引数を指定できます。\",\n                        \"heading\": \"ffmpeg変換出力引数\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"configurationディレクトリにFFmpegをダウンロードし、configurationディレクトリのFFmpegを利用するために既存のパスを削除してください。\",\n                    \"heading\": \"FFmpegをダウンロード\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"実行可能なffmpegバイナリへのパス（フォルダではありません）です。空欄の場合、$PATHで環境に定義されたパス、configurationディレクトリまたは$HOME/.stashからffmpegを利用します\",\n                    \"heading\": \"FFmpegを実行可能なパス\"\n                },\n                \"ffprobe_path\": {\n                    \"heading\": \"FFprobeを実行可能なパス\",\n                    \"description\": \"実行可能なffprobeバイナリへのパス（フォルダではありません）です。空欄の場合、$PATHで環境に定義されたパス、configurationディレクトリまたは$HOME/.stashからffprobeを利用します\"\n                },\n                \"hardware_acceleration\": {\n                    \"desc\": \"利用可能な場合、ライブ・トランスコードにハードウェアエンコードを利用します。\",\n                    \"heading\": \"FFmpeg ハードウェアエンコード\"\n                }\n            },\n            \"gallery_ext_desc\": \"ギャラリーzipファイルとして認識させるファイル拡張子のコンマ区切りリストです。\",\n            \"gallery_ext_head\": \"ギャラリーzipの拡張子\",\n            \"generated_file_naming_hash_desc\": \"生成されたファイルの命名にMD5またはoshashを使用します。これを変更するには、すべてのシーンに該当するMD5/oshash値が適用されている必要があります。この値を変更後、既に存在する生成済みのファイルを移行または再生成する必要があります。遺構についてはタスクページをご確認ください。\",\n            \"generated_file_naming_hash_head\": \"生成ファイルの命名ハッシュ\",\n            \"generated_files_location\": \"生成ファイル(シーンマーカー、シーンプレビュー、スプライトイメージなど)を保存するディレクトリを指定してください\",\n            \"generated_path_head\": \"生成ファイルパス\",\n            \"hashing\": \"ハッシュ\",\n            \"image_ext_desc\": \"画像として認識させるファイル拡張子のコンマ区切りリストです。\",\n            \"image_ext_head\": \"画像の拡張子\",\n            \"include_audio_desc\": \"プレビューを生成する際に、音声を含めます。\",\n            \"include_audio_head\": \"音声を含める\",\n            \"logging\": \"ロギング\",\n            \"maximum_streaming_transcode_size_desc\": \"トランスコードストリームの最大サイズ\",\n            \"maximum_streaming_transcode_size_head\": \"ストリーミングトランスコードの最大サイズ\",\n            \"maximum_transcode_size_desc\": \"生成ファイルのトランスコードの最大サイズ\",\n            \"maximum_transcode_size_head\": \"最大トランスコードサイズ\",\n            \"metadata_path\": {\n                \"description\": \"完全エクスポートまたはインポートを実行する際に使用されるディレクトリ\",\n                \"heading\": \"メタデータのパス\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"自動検出にする場合は0を設定してください。CPU使用率が100%に達するタスク数以上の値を設定すると、パフォーマンスの低下やその他の問題が発生する場合があります。\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"スキャン/生成の同時実行タスク数\",\n            \"parallel_scan_head\": \"同時スキャン/生成\",\n            \"preview_generation\": \"プレビューの生成\",\n            \"python_path\": {\n                \"description\": \"Pythonが配置されており、実行可能なパス（フォルダだけではなく）を指定してください。スクレイパーとプラグインスクリプトを実行するために使用されます。空欄の場合、環境変数からPythonのパスを取得します\",\n                \"heading\": \"実行可能なPytyonパス\"\n            },\n            \"scraper_user_agent\": \"スクレイパーのユーザーエージェント\",\n            \"scraper_user_agent_desc\": \"httpリクエストによるスクレイプを実行する際に使用するユーザーエージェント\",\n            \"scrapers_path\": {\n                \"description\": \"スクレイパーの設定ファイルを保存するディレクトリ\",\n                \"heading\": \"スクレイパーのパス\"\n            },\n            \"scraping\": \"スクレイピング\",\n            \"sqlite_location\": \"SQLiteデータベースファイルの保存場所(再起動が必要です)\",\n            \"video_ext_desc\": \"動画として認識させるファイル拡張子のコンマ区切りリストです。\",\n            \"video_ext_head\": \"動画の拡張子\",\n            \"video_head\": \"動画\",\n            \"blobs_path\": {\n                \"heading\": \"バイナリデータのパス\",\n                \"description\": \"バイナリデータを保存するファイルシステムを指定できます。blobストレージタイプを使用したファイルシステムの場合にのみ適用されます。警告: この設定を変更した場合、既に存在するデータは手動で移動する必要があります。\"\n            },\n            \"database\": \"データベース\",\n            \"funscript_heatmap_draw_range_desc\": \"生成されたヒートマップのY軸上に可動範囲を描画します。既存のヒートマップは、変更後に再生成する必要があります。\",\n            \"blobs_storage\": {\n                \"description\": \"シーンのカバー画像、出演者・スタジオ・タグの画像などのバイナリデータを保存する場所を指定できます。この値を変更すると、既に存在するデータはBlobsの移行タスクを使用して移行する必要があります。移行については、タスクページを確認してください。\",\n                \"heading\": \"バイナリデータのストレージタイプ\"\n            },\n            \"gallery_cover_regex_label\": \"ギャラリーのカバーパターン\",\n            \"gallery_cover_regex_desc\": \"画像をギャラリーのカバーとして識別するために使用される正規表現\",\n            \"heatmap_generation\": \"Funscriptのヒートマップ生成\",\n            \"plugins_path\": {\n                \"description\": \"プラグイン設定ファイルのディレクトリ\",\n                \"heading\": \"プラグインパス\"\n            },\n            \"funscript_heatmap_draw_range\": \"生成済みのヒートマップに範囲を含める\",\n            \"delete_trash_path\": {\n                \"heading\": \"ゴミ箱のパス\"\n            }\n        },\n        \"library\": {\n            \"exclusions\": \"除外\",\n            \"gallery_and_image_options\": \"ギャラリーと画像オプション\",\n            \"media_content_extensions\": \"メディアコンテンツの拡張子\"\n        },\n        \"logs\": {\n            \"log_level\": \"ログレベル\"\n        },\n        \"plugins\": {\n            \"hooks\": \"フック\",\n            \"triggers_on\": \"ONにするトリガー\",\n            \"available_plugins\": \"利用可能なプラグイン\",\n            \"installed_plugins\": \"インストール済みのプラグイン\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType}のメタデータ\",\n            \"entity_scrapers\": \"{entityType}のスクレイパー\",\n            \"excluded_tag_patterns_desc\": \"スクレイピング結果から除外するタグ名の正規表現\",\n            \"excluded_tag_patterns_head\": \"除外タグの規則\",\n            \"scraper\": \"スクレイパー\",\n            \"scrapers\": \"スクレイパー\",\n            \"search_by_name\": \"名前で検索\",\n            \"supported_types\": \"サポートされているタイプ\",\n            \"supported_urls\": \"URL\",\n            \"installed_scrapers\": \"インストール済みのスクレイパー\",\n            \"available_scrapers\": \"利用可能なスクレイパー\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"stash-boxインスタンスを追加\",\n            \"api_key\": \"APIキー\",\n            \"description\": \"stash-boxはフィンガープリントとファイル名をもとに、シーンと出演者のタグ付けを自動的に行います。\\nエンドポイントとAPIキーは、stash-boxインスタンス上のアカウントページからご確認いただけます。2インスタンス以上を追加する場合は、名前の設定が必要になります。\",\n            \"endpoint\": \"エンドポイント\",\n            \"graphql_endpoint\": \"GraphQL エンドポイント\",\n            \"name\": \"名前\",\n            \"title\": \"Stash-box エンドポイント\",\n            \"max_requests_per_minute\": \"1分あたりの最大リクエスト数\"\n        },\n        \"system\": {\n            \"transcoding\": \"トランスコード\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"{operation_name}がジョブキューに追加されました\",\n            \"anonymise_and_download\": \"データベースを匿名化し、結果をダウンロードします。\",\n            \"anonymise_database\": \"センシティブな全データを匿名化して、バックアップ先にデータベースのコピーを保存します。これは、他者にトラブルシューティングを依頼したり、デバッグ目的の際に役立ちます。オリジナルのデータベースは一切変更されません。匿名化されたデータベースには次のファイル名規則が利用されます:{filename_format}\",\n            \"anonymising_database\": \"匿名データベース\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"すべてのパスを自動タグ付け\",\n                \"auto_tagging_paths\": \"次のパスを自動タグ付け：\"\n            },\n            \"auto_tag_based_on_filenames\": \"ファイル名をもとにコンテンツを自動タグ付けします。\",\n            \"auto_tagging\": \"自動タグ付け\",\n            \"backing_up_database\": \"データベースをバックアップ\",\n            \"backup_and_download\": \"データベースのバックアップを実施し、結果ファイルをダウンロードします。\",\n            \"cleanup_desc\": \"不明なファイルを確認し、データベースから削除します。この操作はもとに戻せません。\",\n            \"data_management\": \"データ管理\",\n            \"defaults_set\": \"デフォルトが設定されており、タスクページの{action}ボタンをクリックした際に使用されます。\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"タイトルの一部にファイル拡張子を含めない\",\n            \"empty_queue\": \"現在実行中のタスクはありません。\",\n            \"export_to_json\": \"メタデータディレクトリ内にJSONフォーマットでデータベースコンテンツをエクスポートします。\",\n            \"generate\": {\n                \"generating_from_paths\": \"次のパスからシーンを生成中\",\n                \"generating_scenes\": \"{num} {scene}を生成中\"\n            },\n            \"generate_desc\": \"サポートされている画像、スプライトイメージ、動画、vttとその他ファイルを生成します。\",\n            \"generate_phashes_during_scan\": \"知覚的ハッシュを生成\",\n            \"generate_phashes_during_scan_tooltip\": \"重複排除とシーン検知で使用されます。\",\n            \"generate_previews_during_scan\": \"アニメーション形式の画像プレビューを生成\",\n            \"generate_previews_during_scan_tooltip\": \"アニメーション(WebP)プレビューも生成します。これは、「シーン/マーカーウォールのプレビュータイプ」が「アニメーション画像」に設定されている場合にのみ必要です。ブラウジングの際に、動画のプレビューよりもCPUの使用量は少なくなりますが、追加で生成されるため、ファイルのサイズが大きくなります。\",\n            \"generate_sprites_during_scan\": \"ザッピング用のスプライトイメージを生成\",\n            \"generate_thumbnails_during_scan\": \"画像のサムネイルを生成\",\n            \"generate_video_previews_during_scan\": \"プレビューを生成\",\n            \"generate_video_previews_during_scan_tooltip\": \"シーンにマウスカーソルを当てている際に再生されるプレビュー動画を生成\",\n            \"generated_content\": \"生成コンテンツ\",\n            \"identify\": {\n                \"and_create_missing\": \"と不足コンテンツを作成\",\n                \"create_missing\": \"不足コンテンツを作成\",\n                \"default_options\": \"デフォルト設定\",\n                \"description\": \"stash-boxとスクレイパーソースを使用してシーンにメタデータを自動設定します。\",\n                \"explicit_set_description\": \"これらの設定は、ソース固有の設定を上書きできない場合に使用されます。\",\n                \"field\": \"フィールド\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"フィールド設定\",\n                \"heading\": \"識別\",\n                \"identifying_from_paths\": \"次のパスからシーンを識別中\",\n                \"identifying_scenes\": \"{num} {scene}を識別中\",\n                \"include_male_performers\": \"男優を含める\",\n                \"set_cover_images\": \"カバー画像を設定\",\n                \"set_organized\": \"分類フラグを設定\",\n                \"source\": \"ソース\",\n                \"source_options\": \"{source}設定\",\n                \"sources\": \"ソース\",\n                \"strategy\": \"戦略\",\n                \"skip_single_name_performers_tooltip\": \"これが有効になっていない場合は、サマンサやオルガなどの一般的な出演者が一致するようになります\",\n                \"skip_single_name_performers\": \"曖昧さを明確にせずに単一名の出演者をスキップする\",\n                \"tag_skipped_performers\": \"スキップされた出演者にタグをつける:\",\n                \"tag_skipped_matches\": \"スキップされた一致にタグをつける:\",\n                \"skip_multiple_matches\": \"複数の結果がある一致をスキップする\",\n                \"skip_multiple_matches_tooltip\": \"これが有効でない場合に、複数の結果が返却された場合は、一致する結果がランダムに選択されます\",\n                \"tag_skipped_performer_tooltip\": \"「識別: 単一名の出演者」のようなタグを作成します。このタグをシーンタグビューでフィルタリングし、これらの出演者の処理方法を選択できます\",\n                \"tag_skipped_matches_tooltip\": \"「識別: 複数の一致」のようなタグを作成します。このタグをシーンタグビューでフィルタリングし、期待する一致を手動で選択できます\"\n            },\n            \"import_from_exported_json\": \"メタデータライブラリ内のエクスポート済みのJSONファイルをインポートします。既に存在するデータベースは削除されます。\",\n            \"incremental_import\": \"エクスポートされたzipファイルから差分インポートします。\",\n            \"job_queue\": \"タスクキュー\",\n            \"maintenance\": \"メンテナンス\",\n            \"migrate_hash_files\": \"生成ファイルの命名ハッシュを変更後、既に存在する生成ファイルを新しいハッシュ形式にリネームする際に使用されます。\",\n            \"migrations\": \"移行\",\n            \"only_dry_run\": \"ドライランモードで実行します。何も削除されません\",\n            \"plugin_tasks\": \"プラグインのタスク\",\n            \"scan\": {\n                \"scanning_all_paths\": \"すべてのパスをスキャン中\",\n                \"scanning_paths\": \"次のパスをスキャン中\"\n            },\n            \"scan_for_content_desc\": \"新しいコンテンツをスキャンし、データベースに追加します。\",\n            \"set_name_date_details_from_metadata_if_present\": \"ファイルのメタデータに埋め込まれている情報から名前、日付、詳細を設定する\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"古いデータを削除\",\n                \"description\": \"blobsを現在のblobsストレージシステムに移行します。この移行は、blobストレージ システムを変更した後に実行する必要があります。必要に応じて、移行後に古いデータを削除できます。\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"ナビゲーションを容易にするために、ビデオ プレーヤーの下に表示される一連の画像です。\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blobファイル\",\n                \"image_thumbnails\": \"画像のサムネイル\",\n                \"image_thumbnails_desc\": \"画像のサムネイルとクリップ\",\n                \"markers\": \"マーカーのプレビュー\",\n                \"transcodes\": \"シーンのトランスコード\",\n                \"previews\": \"シーンのプレビュー\",\n                \"previews_desc\": \"シーンのプレビューとサムネイル\",\n                \"sprites\": \"シーンのスプライト画像\",\n                \"description\": \"対応するデータベースエントリのない生成されたファイルを削除します。\"\n            },\n            \"generate_clip_previews_during_scan\": \"画像クリップのプレビューを生成\",\n            \"generate_video_covers_during_scan\": \"シーンカバー画像の生成\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"スクリーンショットファイルを削除\",\n                \"description\": \"シーンのスクリーンショットを現在のblobsストレージシステムに移行します。この移行は、既存のシステムを0.20に変更した後に実行する必要があります。必要に応じて、移行後に古いスクリーンショットを削除できます。\",\n                \"overwrite_existing\": \"既存のblobsをスクリーンショットデータで上書きする\"\n            },\n            \"optimise_database\": \"データベースファイル全体を分析して再構築することで、パフォーマンスの向上を試みます。\",\n            \"optimise_database_warning\": \"警告: このタスクの実行中、データベースを変更する操作はすべて失敗します。データベースのサイズによっては、完了までに数分かかる場合があります。また、最低でもデータベースのサイズと同等のディスクの空き容量が必要ですが、1.5倍程度が推奨されます。\",\n            \"rescan\": \"ファイルのリスキャン\",\n            \"rescan_tooltip\": \"パス内のすべてのファイルを再スキャンします。ファイルのメタデータを強制的に更新し、zip ファイルを再スキャンするために使用されます。\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"シーン重複チェッカー\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"フィールドを追加\",\n                \"capitalize_title\": \"タイトルを大文字にする\",\n                \"display_fields\": \"表示するフィールド\",\n                \"escape_chars\": \"リテラル文字をエスケープするには \\\\ を使用します\",\n                \"filename\": \"ファイル名\",\n                \"filename_pattern\": \"ファイル名の規則\",\n                \"ignore_organized\": \"分類されたシーンを無視\",\n                \"ignored_words\": \"無視する単語\",\n                \"matches_with\": \"{i}と一致\",\n                \"select_parser_recipe\": \"解析のレシピを選択\",\n                \"title\": \"シーンファイル名の解析\",\n                \"whitespace_chars\": \"空白文字\",\n                \"whitespace_chars_desc\": \"タイトルに含まれるこれらの文字は空白文字で置き換えられます\"\n            },\n            \"scene_tools\": \"シーンツール\",\n            \"heading\": \"ツール\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"カードと詳細表示ページのカウンターを短縮します。有効にすると、\\\"1831\\\"という値が\\\"1.8K\\\"のように短縮して表示されます。\",\n                \"heading\": \"カウンターを短縮\"\n            },\n            \"basic_settings\": \"基本設定\",\n            \"custom_css\": {\n                \"description\": \"変更を適用するにはページを更新する必要があります。カスタムCSSは、将来のStashリリースとの互換性を保証しません。\",\n                \"heading\": \"カスタムCSS\",\n                \"option_label\": \"カスタムCSSを有効にする\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"エフェクトの変更を反映させるためにはページをリロードしてください。カスタムJavaScriptは、将来のStashリリースとの互換性を保証しません。\",\n                \"heading\": \"カスタムJavascript\",\n                \"option_label\": \"カスタムJavascriptを有効化する\"\n            },\n            \"custom_locales\": {\n                \"description\": \"個別の言語文字列を上書きします。マスターとなっているリストについては、https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json をご確認ください。変更を反映するには、ページを再読み込みする必要があります。\",\n                \"heading\": \"カスタム翻訳\",\n                \"option_label\": \"カスタム翻訳が有効です\"\n            },\n            \"delete_options\": {\n                \"description\": \"画像、ギャラリー、シーンを削除するときのデフォルト設定です。\",\n                \"heading\": \"削除オプション\",\n                \"options\": {\n                    \"delete_file\": \"ファイルの削除をデフォルトにする\",\n                    \"delete_generated_supporting_files\": \"サポートされているファイルの生成ファイル削除をデフォルトにする\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"デスクトップ連携\",\n                \"notifications_enabled\": \"通知を有効にする\",\n                \"send_desktop_notifications_for_events\": \"イベント発生時にデスクトップ通知を送信\",\n                \"skip_opening_browser\": \"ブラウザーの起動をスキップ\",\n                \"skip_opening_browser_on_startup\": \"起動時のブラウザーの自動起動をスキップする\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"ドロップダウンセレクターからの新規オブジェクトの生成を禁止する\",\n                    \"heading\": \"ドロップダウンの生成を無効にする\"\n                },\n                \"heading\": \"編集中\",\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"評価の精度\",\n                        \"options\": {\n                            \"full\": \"全て\",\n                            \"half\": \"半分\",\n                            \"quarter\": \"4分の1\",\n                            \"tenth\": \"10番目\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"評価制度の方法\",\n                        \"options\": {\n                            \"decimal\": \"数値\",\n                            \"stars\": \"星\"\n                        }\n                    }\n                },\n                \"max_options_shown\": {\n                    \"label\": \"選択ドロップダウンメニューに表示するアイテムの最大数\"\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"インタラクティブスクリプトの実行までのオフセットをミリ秒で指定できます。\",\n                \"heading\": \"Funscriptオフセット (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"接続\",\n                \"server_offset\": {\n                    \"heading\": \"サーバーのオフセット\"\n                },\n                \"status\": {\n                    \"heading\": \"Handy接続状況\"\n                },\n                \"sync\": \"同期\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Handy connection keyは、インタラクティブなシーンで使用されます。このキーを設定すると、Stashが現在のシーン情報をhandyfeeling.comと共有することを許可します\",\n                \"heading\": \"Handy Connection Key\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"画像のLightbox\"\n            },\n            \"images\": {\n                \"heading\": \"画像\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"画像のサムネイルをオンザフライでディスクに書き込みます\",\n                        \"heading\": \"画像のサムネイルを書き込む\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"heading\": \"ビデオ拡張子を画像クリップとしてスキャン\",\n                        \"description\": \"ライブラリでビデオが無効になっている場合、ビデオ ファイル (ビデオ拡張子で終わるファイル) はイメージ クリップとしてスキャンされます。\"\n                    }\n                }\n            },\n            \"interactive_options\": \"インタラクティブ設定\",\n            \"language\": {\n                \"heading\": \"言語\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"シーンプレーヤーが動画をループ再生するシーン数の最大を指定できます - 「0」を設定すると無効になります\",\n                \"heading\": \"最大ループ回数\"\n            },\n            \"menu_items\": {\n                \"description\": \"タイプナビゲーションバー上の異なるコンテンツタイプの表示または非表示を切り替えます\",\n                \"heading\": \"メニューアイテム\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"再生回数を増やすために再生しなければならないシーンの時間割合を指します。\",\n                \"heading\": \"最低再生割合\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"出演者のデフォルト画像が保存されているカスタムパスを設定します。空白にすると、内蔵のデフォルト画像が使用されます\",\n                        \"heading\": \"出演者のデフォルト画像パス\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"ウォールアイテムの設定\",\n                \"heading\": \"プレビュータイプ\",\n                \"options\": {\n                    \"animated\": \"アニメーション画像\",\n                    \"static\": \"静止画\",\n                    \"video\": \"動画\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"シーンリスト\",\n                \"options\": {\n                    \"show_studio_as_text\": \"スタジオをテキストで表示\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"シーンプレーヤー\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"毎回最初から動画をスタートさせる\",\n                    \"auto_start_video\": \"動画を自動再生\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"キュー、選択されたものからの再生またはシーンページからのランダム再生時に動画を自動再生します\",\n                        \"heading\": \"選択したものを再生した際に動画を自動再生\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"動画の再生が終了した際にキューに入っている次の動画を再生します\",\n                        \"heading\": \"デフォルトでプレイリストを続行\"\n                    },\n                    \"show_scrubber\": \"スクラバーを表示\",\n                    \"track_activity\": \"アクティビティを追跡\",\n                    \"disable_mobile_media_auto_rotate\": \"モバイル機器でフル画面再生時の画面回転を無効化\",\n                    \"enable_chromecast\": \"クロームキャスト機能の有効化\",\n                    \"vr_tag\": {\n                        \"heading\": \"VRタグ\",\n                        \"description\": \"VRボタンはこのタグがついたシーンにのみ表示されます。\"\n                    },\n                    \"show_ab_loop_controls\": \"ABループプラグインのコントロールを表示\",\n                    \"show_range_markers\": \"範囲マーカーを表示\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"シーン / マーカーウォール\",\n                \"options\": {\n                    \"display_title\": \"タイトルとタグを表示\",\n                    \"toggle_sound\": \"音声を有効にする\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"前後のアイテムに移動する前にスクロールを試行する回数を指定できます。Y座標のスクロールモードにのみ適用されます。\",\n                \"heading\": \"遷移前のスクロール試行\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"タグバッジにカーソルをホバーしているときにタグカードを表示する\",\n                \"heading\": \"タグカードヒント\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"ウォールビューモードの際にギャラリーのスライドショーが利用できます\",\n                \"heading\": \"スライドショーの遅延時間 (秒)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"スタジオビュー\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"スタジオビューで、サブスタジオのコンテンツが表示されるようになります\",\n                        \"heading\": \"サブスタジオのコンテンツを表示する\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"タグビュー\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"タグビューで、サブタグのコンテンツを表示されるようになります\",\n                        \"heading\": \"サブタグのコンテンツを表示する\"\n                    }\n                }\n            },\n            \"title\": \"ユーザーインターフェース\",\n            \"detail\": {\n                \"enable_background_image\": {\n                    \"description\": \"詳細ページに背景画像を表示します。\",\n                    \"heading\": \"背景画像を有効にする\"\n                },\n                \"compact_expanded_details\": {\n                    \"description\": \"この設定を有効にすると、コンパクトな表示を維持しながらより高度な詳細が表示されます\",\n                    \"heading\": \"コンパクトな高度な詳細\"\n                },\n                \"show_all_details\": {\n                    \"heading\": \"全ての詳細情報を表示\",\n                    \"description\": \"この設定を有効にすると、デフォルトで全てのコンテンツの詳細が表示され、各詳細項目が 1 つのカラムに収まり、コンパクトな表示を維持しながらより高度な詳細が表示されます\"\n                },\n                \"heading\": \"詳細ページ\"\n            },\n            \"image_wall\": {\n                \"direction\": \"方向\",\n                \"heading\": \"画像ウォール\",\n                \"margin\": \"マージン (px単位)\"\n            },\n            \"performer_list\": {\n                \"heading\": \"AV女優・男優リスト\"\n            },\n            \"sfw_mode\": {\n                \"description\": \"健全なコンテンツの為にstashを使用している場合に有効化しましょう。 アダルトコンテンツに関連したUIは隠されるか健全化されて表示されます。\",\n                \"heading\": \"SFWコンテンツモード\"\n            },\n            \"custom_title\": {\n                \"heading\": \"カスタムタイトル\",\n                \"description\": \"ページタイトルに追加するカスタムテキスト。空欄の場合は'Stash'がデフォルトになります。\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"トラブルシューティングモード\",\n                \"dialog_title\": \"トラブルシューティングモードを有効化\",\n                \"dialog_description\": \"問題の診断を行うため、すべてのカスタマイズを一時的に無効化します。\",\n                \"dialog_item_plugins\": \"すべてのプラグイン\",\n                \"dialog_item_css\": \"カスタムCSS\",\n                \"dialog_item_js\": \"カスタムJavaScript\",\n                \"dialog_item_locales\": \"カスタムロケール\",\n                \"dialog_log_level\": \"詳細な診断のため、ログレベルがDebugに設定されます。\",\n                \"dialog_reload_note\": \"このページは自動的にリロードされます。\",\n                \"enable\": \"有効化してリロード\",\n                \"overlay_message\": \"トラブルシューティングモードが有効 - すべてのカスタマイズが無効化\",\n                \"exit\": \"終了\"\n            }\n        },\n        \"advanced_mode\": \"高度なモード\"\n    },\n    \"configuration\": \"設定\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {File} other {Files}}\",\n        \"galleries\": \"{count, plural, one {Gallery} other {Galleries}}\",\n        \"images\": \"{count, plural, one {Image} other {Images}}\",\n        \"markers\": \"{count, plural, one {Marker} other {Markers}}\",\n        \"performers\": \"{count, plural, one {Performer} other {Performers}}\",\n        \"scenes\": \"{count, plural, one {Scene} other {Scenes}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Studios}}\",\n        \"tags\": \"{count, plural, one {Tag} other {Tags}}\"\n    },\n    \"country\": \"国\",\n    \"cover_image\": \"カバー画像\",\n    \"created_at\": \"作成日\",\n    \"criterion\": {\n        \"greater_than\": \"より大きい\",\n        \"less_than\": \"より小さい\",\n        \"value\": \"値\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"の間\",\n        \"equals\": \"である\",\n        \"excludes\": \"除く\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"が次より大きい\",\n        \"includes\": \"含む\",\n        \"includes_all\": \"すべて含む\",\n        \"is_null\": \"がnull\",\n        \"less_than\": \"が次より小さい\",\n        \"matches_regex\": \"が次の正規表現に一致\",\n        \"not_between\": \"の間でない\",\n        \"not_equals\": \"でない\",\n        \"not_matches_regex\": \"が正規表現に一致しない\",\n        \"not_null\": \"がnullでない\"\n    },\n    \"custom\": \"カスタム\",\n    \"date\": \"日付\",\n    \"death_date\": \"没日\",\n    \"death_year\": \"没年\",\n    \"descending\": \"降順\",\n    \"description\": \"概要\",\n    \"detail\": \"詳細\",\n    \"details\": \"詳細\",\n    \"developmentVersion\": \"開発者バージョン\",\n    \"dialogs\": {\n        \"create_new_entity\": \"{entity}を新規作成する\",\n        \"delete_alert\": \"次の{count, plural, one {{singularEntity}} other {{pluralEntity}}}は完全に削除されます:\",\n        \"delete_confirm\": \"本当に{entityName}を削除してよろしいですか？\",\n        \"delete_entity_desc\": \"{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {本当にこの{singularEntity}を削除してもよろしいですか？} other {本当にこれらの{pluralEntity}を削除してもよろしいですか？}}\",\n        \"delete_entity_title\": \"{count, plural, one {Delete {singularEntity}} other {Delete {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"...加えて、画像ファイルが他のどのギャラリーにも属していません。\",\n        \"delete_gallery_files\": \"ギャラリーフォルダー/zipファイルとギャラリーに属していない全ての画像を削除します。\",\n        \"delete_object_desc\": \"本当に{count, plural, one {this {singularEntity}} other {these {pluralEntity}}}を削除してもよろしいですか？\",\n        \"delete_object_overflow\": \"…加えて、{count}件とその他{count, plural, one {{singularEntity}} other {{pluralEntity}}}が含まれます。\",\n        \"delete_object_title\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}}を削除\",\n        \"dont_show_until_updated\": \"次の更新まで表示しない\",\n        \"edit_entity_title\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}}を編集\",\n        \"export_include_related_objects\": \"関連するオブジェクトをエクスポートに含める\",\n        \"export_title\": \"エクスポート\",\n        \"lightbox\": {\n            \"delay\": \"遅延時間 (秒)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"水平に合わせる\",\n                \"fit_to_screen\": \"画面に合わせる\",\n                \"label\": \"表示モード\",\n                \"original\": \"オリジナル\"\n            },\n            \"options\": \"オプション\",\n            \"reset_zoom_on_nav\": \"画像を変更した際のズームレベルをリセットする\",\n            \"scale_up\": {\n                \"description\": \"小さい画像を画面いっぱいに拡大する\",\n                \"label\": \"フィットするように拡大\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"一時的に他のモードを使用するには、Shiftキーを押し続けてください。\",\n                \"label\": \"スクロールモード\",\n                \"pan_y\": \"Yにパン\",\n                \"zoom\": \"拡大\"\n            },\n            \"page_header\": \"ページ {page} / {total}\"\n        },\n        \"merge\": {\n            \"destination\": \"宛先\",\n            \"empty_results\": \"宛先欄の値は変更されません。\",\n            \"source\": \"ソース\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {{singularEntity}を再割り当て} other {{pluralEntity}を再割り当て}}\",\n        \"reassign_files\": {\n            \"destination\": \"次に再割り当て:\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"強制的にトランスコード済みファイルを生成\",\n            \"force_transcodes_tooltip\": \"初期設定では、トランスコード済みファイルはブラウザーがサポートしていない動画ファイルであった場合にのみ生成されます。有効にすると、ブラウザーがサポートする動画ファイルであった場合でもトランスコード済みファイルを生成します。\",\n            \"image_previews\": \"アニメーション画像によるプレビュー\",\n            \"image_previews_tooltip\": \"WebP形式のアニメーションプレビューは、プレビュータイプがアニメーション画像に設定されている場合のみ必要です。\",\n            \"interactive_heatmap_speed\": \"インタラクティブなシーン向けにヒートマップとスピードを生成\",\n            \"marker_image_previews\": \"マーカーにアニメーション画像プレビューを使用\",\n            \"marker_image_previews_tooltip\": \"WebP形式のアニメーションマーカープレビューは、プレビュータイプがアニメーション画像に設定されている場合のみ必要です。\",\n            \"marker_screenshots\": \"マーカーのスクリーンショット\",\n            \"marker_screenshots_tooltip\": \"マーカーのJPG画像は、プレビュータイプが静止画に設定されている場合のみ必要です。\",\n            \"markers\": \"マーカーのプレビュー\",\n            \"markers_tooltip\": \"マーカーに設定されたタイムコードから２０秒間の動画を生成します。\",\n            \"override_preview_generation_options\": \"プレビュー生成の設定を上書きする\",\n            \"override_preview_generation_options_desc\": \"この操作に限り、プレビュー生成の設定を上書きします。デフォルト設定は、システム -> プレビュー生成から変更できます。\",\n            \"overwrite\": \"既に生成済みの生成ファイルを上書きする\",\n            \"phash\": \"視覚的ハッシュ (重複排除用)\",\n            \"preview_exclude_end_time_desc\": \"シーンプレビューから最後のX秒を除外します。値は、秒またはシーンの再生時間の割合(2%など)で指定できます。\",\n            \"preview_exclude_end_time_head\": \"除外する動画の終了時間\",\n            \"preview_exclude_start_time_desc\": \"シーンプレビューから最初のX秒を除外します。値は、秒またはシーンの再生時間の割合(2%など)で指定できます。\",\n            \"preview_exclude_start_time_head\": \"除外する開始時間\",\n            \"preview_generation_options\": \"プレビューの生成オプション\",\n            \"preview_options\": \"プレビューオプション\",\n            \"preview_preset_desc\": \"エンコードプリセットは、プレビュー生成のサイズ、品質、およびエンコード時間を左右します。 「slow」を超えるプリセットは費用対効果が薄いため、推奨されません。\",\n            \"preview_preset_head\": \"プレビューのエンコードプリセット\",\n            \"preview_seg_count_desc\": \"プレビューファイルのセグメント数を設定します。\",\n            \"preview_seg_count_head\": \"プレビューのセグメント数\",\n            \"preview_seg_duration_desc\": \"各プレビューセグメントの長さを秒で指定します。\",\n            \"preview_seg_duration_head\": \"プレビューセグメントの長さ\",\n            \"sprites\": \"シーンのザッピング用スプライトイメージ\",\n            \"sprites_tooltip\": \"スプライトイメージ (シーンのザッピング用)\",\n            \"transcodes\": \"トランスコード\",\n            \"transcodes_tooltip\": \"サポートされていない動画フォーマットをMP4に変換します\",\n            \"video_previews\": \"プレビュー\",\n            \"video_previews_tooltip\": \"シーンにマウスカーソルを置いた時に再生されるビデオプレビュー\",\n            \"clip_previews\": \"画像のプレビュー\",\n            \"covers\": \"シーンカバー\",\n            \"image_thumbnails\": \"サムネ画像\"\n        },\n        \"scenes_found\": \"{count}シーンが見つかりました\",\n        \"scrape_entity_query\": \"{entity_type}スクレイプクエリ\",\n        \"scrape_entity_title\": \"{entity_type} スクレイプ結果\",\n        \"scrape_results_existing\": \"存在します\",\n        \"scrape_results_scraped\": \"スクレイプ済み\",\n        \"set_image_url_title\": \"画像URL\",\n        \"unsaved_changes\": \"変更が保存されていません。本当に移動してよろしいですか？\",\n        \"clear_play_history_confirm\": \"再生履歴を本当に削除しますか？\",\n        \"performers_found\": \"{count}人の出演者が見つかりました\"\n    },\n    \"dimensions\": \"寸法\",\n    \"director\": \"監督\",\n    \"disambiguation\": \"用語解説\",\n    \"display_mode\": {\n        \"grid\": \"グリッド\",\n        \"list\": \"リスト\",\n        \"tagger\": \"一括タグ付け\",\n        \"unknown\": \"不明\",\n        \"wall\": \"ウォール\"\n    },\n    \"donate\": \"寄付\",\n    \"dupe_check\": {\n        \"description\": \"「正確」より下のレベルは、計算に時間がかかる場合があります。 誤検知は、精度レベルが低い場合にも返却される可能性があります。\",\n        \"found_sets\": \"{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}\",\n        \"options\": {\n            \"exact\": \"正確\",\n            \"high\": \"高\",\n            \"low\": \"低\",\n            \"medium\": \"中\"\n        },\n        \"search_accuracy_label\": \"検索精度\",\n        \"title\": \"重複シーン\",\n        \"select_youngest\": \"重複グループの中で最も新しいファイルを選択\"\n    },\n    \"duplicated_phash\": \"重複 (phash)\",\n    \"duration\": \"長さ\",\n    \"effect_filters\": {\n        \"aspect\": \"アスペクト比\",\n        \"blue\": \"青\",\n        \"blur\": \"ぼかし\",\n        \"brightness\": \"明るさ\",\n        \"contrast\": \"コントラスト\",\n        \"gamma\": \"ガンマ\",\n        \"green\": \"緑\",\n        \"hue\": \"色\",\n        \"name\": \"フィルター\",\n        \"name_transforms\": \"変換\",\n        \"red\": \"赤\",\n        \"reset_filters\": \"フィルターをリセット\",\n        \"reset_transforms\": \"変換をリセット\",\n        \"rotate\": \"回転\",\n        \"rotate_left_and_scale\": \"左に回転とスケール\",\n        \"rotate_right_and_scale\": \"右に回転とスケール\",\n        \"saturation\": \"彩度\",\n        \"scale\": \"スケール\",\n        \"warmth\": \"暖かさ\"\n    },\n    \"empty_server\": \"このページでおすすめを表示するためには、サーバーにいくつかシーンを追加してみましょう。\",\n    \"ethnicity\": \"民族性\",\n    \"existing_value\": \"存在する値\",\n    \"eye_color\": \"瞳の色\",\n    \"fake_tits\": \"偽乳\",\n    \"false\": \"無効\",\n    \"favourite\": \"お気に入り\",\n    \"file\": \"ファイル\",\n    \"file_count\": \"ファイルカウント\",\n    \"file_info\": \"ファイル情報\",\n    \"file_mod_time\": \"ファイル変更日時\",\n    \"files\": \"ファイル\",\n    \"files_amount\": \"{value}ファイル\",\n    \"filesize\": \"ファイルサイズ\",\n    \"filter\": \"フィルター\",\n    \"filter_name\": \"フィルター名\",\n    \"filters\": \"フィルター\",\n    \"folder\": \"フォルダー\",\n    \"framerate\": \"フレームレート\",\n    \"frames_per_second\": \"{value}FPS\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"既製フィルター\",\n            \"saved_filter\": \"保存済みフィルター\"\n        }\n    },\n    \"galleries\": \"ギャラリー\",\n    \"gallery\": \"ギャラリー\",\n    \"gallery_count\": \"ギャラリー数\",\n    \"gender\": \"性別\",\n    \"gender_types\": {\n        \"FEMALE\": \"女性\",\n        \"INTERSEX\": \"間性\",\n        \"MALE\": \"男性\",\n        \"NON_BINARY\": \"ノンバイナリー\",\n        \"TRANSGENDER_FEMALE\": \"トランスジェンダーの女性\",\n        \"TRANSGENDER_MALE\": \"トランスジェンダーの男性\"\n    },\n    \"hair_color\": \"髪の色\",\n    \"handy_connection_status\": {\n        \"connecting\": \"接続中\",\n        \"disconnected\": \"切断済み\",\n        \"error\": \"Handyに接続中にエラーが発生しました\",\n        \"missing\": \"ありません\",\n        \"ready\": \"準備完了\",\n        \"syncing\": \"サーバーと同期中\",\n        \"uploading\": \"スクリプトをアップロード中\"\n    },\n    \"hasMarkers\": \"マーカーあり？\",\n    \"height\": \"身長\",\n    \"height_cm\": \"身長(cm)\",\n    \"help\": \"ヘルプ\",\n    \"ignore_auto_tag\": \"自動タグを無視する\",\n    \"image\": \"画像\",\n    \"image_count\": \"画像数\",\n    \"images\": \"画像\",\n    \"include_parent_tags\": \"親タグを含める\",\n    \"include_sub_studios\": \"子会社のスタジオを含める\",\n    \"include_sub_tags\": \"サブタグを含める\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"インタラクティブ\",\n    \"interactive_speed\": \"インタラクティブ速度\",\n    \"isMissing\": \"見つからない？\",\n    \"last_played_at\": \"前回再生した日時\",\n    \"library\": \"ライブラリー\",\n    \"loading\": {\n        \"generic\": \"読み込み中…\"\n    },\n    \"marker_count\": \"マーカー数\",\n    \"markers\": \"マーカー\",\n    \"measurements\": \"スリーサイズ\",\n    \"media_info\": {\n        \"audio_codec\": \"音声コーデック\",\n        \"downloaded_from\": \"ダウンロード元\",\n        \"interactive_speed\": \"インタラクティブ速度\",\n        \"performer_card\": {\n            \"age\": \"{age}{years_old}\",\n            \"age_context\": \"{age} {years_old}(撮影当時)\"\n        },\n        \"phash\": \"PHash\",\n        \"play_count\": \"再生回数\",\n        \"play_duration\": \"再生時間\",\n        \"stream\": \"ストリーム\",\n        \"video_codec\": \"動画コーデック\"\n    },\n    \"megabits_per_second\": \"{value}Mbps\",\n    \"metadata\": \"メタデータ\",\n    \"name\": \"名前\",\n    \"new\": \"新規作成\",\n    \"none\": \"なし\",\n    \"operations\": \"オペレーション\",\n    \"organized\": \"分類済み\",\n    \"pagination\": {\n        \"first\": \"最初\",\n        \"last\": \"最後\",\n        \"next\": \"次へ\",\n        \"previous\": \"前へ\",\n        \"current_total\": \"{total} 中 {current}\"\n    },\n    \"parent_of\": \"{children}の親\",\n    \"parent_studios\": \"親スタジオ\",\n    \"parent_tag_count\": \"親タグ数\",\n    \"parent_tags\": \"親タグ\",\n    \"part_of\": \"{parent}の一部\",\n    \"path\": \"パス\",\n    \"perceptual_similarity\": \"知覚的類似性 (phash)\",\n    \"performer\": \"出演者\",\n    \"performer_age\": \"出演者の年齢\",\n    \"performer_count\": \"出演者数\",\n    \"performer_favorite\": \"出演者をお気に入り済み\",\n    \"performer_image\": \"出演者画像\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"新しい出演者を追加\",\n        \"any_names_entered_will_be_queried\": \"入力された全ての名前は外部のStash-Boxインスタンスで検索され、見つかった場合追加されます。完全一致のみ、一致とみなされます。\",\n        \"batch_add_performers\": \"出演者を一括追加\",\n        \"batch_update_performers\": \"出演者の一括更新\",\n        \"current_page\": \"現在のページ\",\n        \"failed_to_save_performer\": \"出演者\\\"{performer}\\\"の保存に失敗しました\",\n        \"name_already_exists\": \"既に使用されている名前です\",\n        \"network_error\": \"ネットワークエラー\",\n        \"no_results_found\": \"結果が見つかりませんでした。\",\n        \"number_of_performers_will_be_processed\": \"{performer_count}人の出演者が処理されます\",\n        \"performer_already_tagged\": \"出演者は既にタグ付けされています\",\n        \"performer_selection\": \"出演者の選択\",\n        \"performer_successfully_tagged\": \"出演者のタグ付けに成功しました:\",\n        \"query_all_performers_in_the_database\": \"データベース内の全出演者\",\n        \"refresh_tagged_performers\": \"タグ付けされている出演者を更新\",\n        \"refreshing_will_update_the_data\": \"更新機能を使用すると、stash-boxインスタンスからタグ付け済みの出演者のデータを更新します。\",\n        \"status_tagging_job_queued\": \"状態: タグ付けをキューに追加済み\",\n        \"status_tagging_performers\": \"状態: 出演者をタグ付け中\",\n        \"tag_status\": \"タグの状態\",\n        \"to_use_the_performer_tagger\": \"stash-boxインスタンスからの出演者タグ付け機能を使用するには、設定が必要です。\",\n        \"untagged_performers\": \"タグ付けされていない出演者\",\n        \"update_performer\": \"出演者を更新\",\n        \"update_performers\": \"出演者を更新\",\n        \"updating_untagged_performers_description\": \"タグ付けされていない出演者の更新機能により、stash IDがない出演者のマッチングを試み、メタデータを更新します。\"\n    },\n    \"performer_tags\": \"出演者タグ\",\n    \"performers\": \"出演者\",\n    \"piercings\": \"ピアス\",\n    \"play_count\": \"再生回数\",\n    \"play_duration\": \"再生時間\",\n    \"primary_file\": \"メインファイル\",\n    \"queue\": \"キュー\",\n    \"random\": \"ランダム\",\n    \"rating\": \"評価\",\n    \"recently_added_objects\": \"最近追加された{objects}\",\n    \"recently_released_objects\": \"最近リリースされた{objects}\",\n    \"release_notes\": \"リリースノート\",\n    \"resolution\": \"解像度\",\n    \"resume_time\": \"レジューム時間\",\n    \"scene\": \"シーン\",\n    \"sceneTagger\": \"シーン一括タグ付け\",\n    \"scene_code\": \"スタジオコード\",\n    \"scene_count\": \"シーン数\",\n    \"scene_created_at\": \"シーンの作成日時\",\n    \"scene_date\": \"シーンの日付\",\n    \"scene_id\": \"シーンID\",\n    \"scene_tags\": \"シーンタグ\",\n    \"scene_updated_at\": \"シーンの更新日時\",\n    \"scenes\": \"シーン\",\n    \"scenes_updated_at\": \"シーンの更新日：\",\n    \"search_filter\": {\n        \"name\": \"フィルター\",\n        \"saved_filters\": \"保存済みのフィルター\",\n        \"update_filter\": \"フィルターを更新\"\n    },\n    \"seconds\": \"秒\",\n    \"settings\": \"設定\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"設定はほぼ完了です。次の設定をご確認ください。間違いがあった場合は「戻る」をクリックして変更できます。問題ない場合は、「確認」をクリックしてシステムの構築を開始してください。\",\n            \"configuration_file_location\": \"設定ファイルの場所:\",\n            \"database_file_path\": \"データベースのファイルパス\",\n            \"generated_directory\": \"生成ファイルのディレクトリ\",\n            \"nearly_there\": \"もうすぐです！\",\n            \"stash_library_directories\": \"Stashライブラリーのディレクトリ\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"システムを構築中\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"問題が発生したようです！\",\n            \"something_went_wrong_description\": \"原因が間違った設定による場合、「戻る」をクリックして修正してください。それ以外の場合は、{githubLink}にバグを報告するか、{discordLink}で質問してみてください。\",\n            \"something_went_wrong_while_setting_up_your_system\": \"システムを設定中に問題が発生しました。次のエラーを受け取りました: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"ファイルパス\",\n            \"up_dir\": \"上の階層へ\"\n        },\n        \"github_repository\": \"Githubのリポジトリ\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"データベースのバックアップパス (バックアップを無効にする場合は空白):\",\n            \"backup_recommended\": \"移行する前に、既に存在するデータベースをバックアップすることを推奨します。こちらで、<code>{defaultBackupPath}</code>にコピーを生成することも可能です。\",\n            \"migrating_database\": \"データベースを移行中\",\n            \"migration_failed\": \"移行失敗\",\n            \"migration_failed_error\": \"データベースの移行中に次のエラーが発生しました:\",\n            \"migration_failed_help\": \"必要な修正を加えてから再度実行してみてください。それでも問題が起きる場合は、{githubLink}にバグを報告するか、{discordLink}で質問してみてください。\",\n            \"migration_irreversible_warning\": \"スキーマの移行作業は元に戻せません。移行を開始した後は、お使いのデータベースは以前のバージョンのStashと互換性がなくなります。\",\n            \"migration_notes\": \"移行ノート\",\n            \"migration_required\": \"移行が必要です\",\n            \"perform_schema_migration\": \"スキーマ移行を実施する\",\n            \"schema_too_old\": \"お使いのStashデータベースのスキーマバージョンは、<strong>{databaseSchema}</strong> であり、バージョン<strong>{appSchema}</strong>への移行が必要です。このバージョンのStashは、データベースの移行を実施しないと動作しません。移行を実施しない場合、お使いのデータベーススキーマに合致するバージョンにダウングレードする必要があります。\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"データベースファイル名 (空白でデフォルトを使用)\",\n            \"description\": \"続いて、あなたのコレクションの保存場所、データベースと生成ファイルを保存する場所を教えていただく必要があります。これらの設定は、後で必要に応じて変更が可能です。\",\n            \"path_to_generated_directory_empty_for_default\": \"生成ファイルの保存パス (空白でデフォルト使用)\",\n            \"set_up_your_paths\": \"パスをセットアップ\",\n            \"stash_alert\": \"ライブラリーパスが選択されていません。Stashはメディアをスキャンできませんがよろしいですか？\",\n            \"where_can_stash_store_its_database\": \"Stashはどこにデータベースを保存すればよいですか？\",\n            \"where_can_stash_store_its_database_description\": \"Stashは、コレクションのメタデータを保存するためにsqliteデータベースを使用しています。初期設定では、設定ファイルが保存されているディレクトリに<code> stash-go.sqlite</code>という名称で保存されます。変更したい場合は、絶対または現在の作業ディレクトリまでの相対パスを含めたファイル名を入力してください。\",\n            \"where_can_stash_store_its_generated_content\": \"Stashはどこに生成コンテンツを保存すればよいですか？\",\n            \"where_can_stash_store_its_generated_content_description\": \"サムネイル、プレビューとスプライトイメージを使用できるようにするため、Stashは画像と動画を生成します。これには、未サポートのファイル形式を変換したものも含まれます。初期設定では、Stashは、設定ファイルが保存されているディレクトリに<code> generated</code>ディレクトリを作成します。この生成メディアの保存場所を変更したい場合は、絶対または現在の作業ディレクトリまでの相対パスを含めたパスを入力してください。ディレクトリが存在しない場合、Stashが自動的に作成します。\",\n            \"where_is_your_porn_located\": \"お宝はどこに眠っていますか？\",\n            \"where_is_your_porn_located_description\": \"あなたのお宝動画と画像が保存されているディレクトリを追加してください。Stashは、動画と画像のスキャン時にこれらのディレクトリを使用します。\"\n        },\n        \"stash_setup_wizard\": \"Stash セットアップウィザード\",\n        \"success\": {\n            \"getting_help\": \"ヘルプを参照\",\n            \"help_links\": \"問題に直面または質問や提案がある場合、お気軽に{githubLink}からIssueをオープンしていただくか、{discordLink}からコミュニエティに質問してみてください。\",\n            \"in_app_manual_explained\": \"次のような画面の右上にあるアイコンからアクセスできるアプリ内マニュアルを確認することもお勧めします: {icon}\",\n            \"next_config_step_one\": \"続いて、設定ページに移動します。 このページでは、コレクションに含めるファイルと除外するファイルをカスタマイズしたり、システムを不正なアクセスから保護するためのユーザー名とパスワードを設定したり、その他のさまざまなオプションを設定できます。\",\n            \"next_config_step_two\": \"これらの設定で問題ない場合は、<code>{localized_task}</code>へ進み、<code>{localized_scan}</code>をクリックすることで、コンテンツのスキャンを開始できます。\",\n            \"open_collective\": \"{open_collective_link}から、あなたがStashの継続的な開発にどのように貢献できるかを確認いただけます。\",\n            \"support_us\": \"Stashの開発をサポート\",\n            \"thanks_for_trying_stash\": \"Stashをご利用いただきありがとうございます！\",\n            \"welcome_contrib\": \"また、コード（バグ修正、機能向上、新機能）、テスト、バグ報告、改善と機能のリクエスト、ユーザーサポートの貢献も歓迎いたします。 詳細については、アプリ内マニュアルの「Contribution」セクションをご覧ください。\",\n            \"your_system_has_been_created\": \"成功しました！システムの構築が完了しました！\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stashは、まず初めに現在の作業ディレクトリに設定ファイル(<code>config.yml</code>)がないかどうか探し、存在しない場合は、<code>$HOME/.stash/config.yml</code>(Windowsは <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>)にフォールバックします。<code>-c '<設定ファイルへのパス>'</code> または <code>--config '<設定ファイルへのパス>'</code>オプションをつけて起動させることで、特定の設定ファイルを読み込ませることもできます。\",\n            \"in_current_stash_directory\": \"<code>$HOME/.stash</code> ディレクトリ内\",\n            \"in_the_current_working_directory\": \"現在の作業ディレクトリ内\",\n            \"next_step\": \"これで、新しいシステムのセットアップを続行する準備ができました。構成ファイルを保存する場所を選択して、「次へ」をクリックしてください。\",\n            \"store_stash_config\": \"Stashの設定はどこに保存すればよいですか？\",\n            \"unable_to_locate_config\": \"このメッセージをお読みいただいている場合、Stashは既存の構成を見つけることができませんでした。 このウィザードで、新しい構成をセットアップするプロセスをご案内します。\",\n            \"unexpected_explained\": \"この画面が予期せず表示される場合は、正しい作業ディレクトリまたは<code>-c</code>フラグを使用してStashを再起動してみてください。\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stashは次の設定ファイルパスを使用します: <code>{path}</code>\",\n            \"next_step\": \"新しいシステムの構築準備が整ったら、次へをクリックしてください。\",\n            \"unable_to_locate_specified_config\": \"このメッセージをお読みいただいている場合、Stashはコマンドラインまたは環境変数で指定された構成ファイルを見つけることができませんでした。 このウィザードで、新しい構成をセットアップするプロセスをご案内します。\"\n        },\n        \"welcome_to_stash\": \"Stashへようこそ\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_id_endpoint\": \"Stash IDエンドポイント\",\n    \"stash_ids\": \"Stash ID\",\n    \"stashbox\": {\n        \"go_review_draft\": \"下書きを確認するには、{endpoint_name}に移動してください。\",\n        \"selected_stash_box\": \"選択済みのStash-Boxエンドポイント\",\n        \"submission_failed\": \"送信に失敗しました\",\n        \"submission_successful\": \"送信完了しました\",\n        \"submit_update\": \"{endpoint_name}に既に存在します\"\n    },\n    \"statistics\": \"統計\",\n    \"stats\": {\n        \"image_size\": \"画像サイズ\",\n        \"scenes_duration\": \"シーンの再生時間\",\n        \"scenes_size\": \"シーンサイズ\",\n        \"scenes_played\": \"再生済みシーン\",\n        \"total_o_count\": \"O-Count数\",\n        \"total_o_count_sfw\": \"健全O-Count数\",\n        \"total_play_count\": \"総再生数\",\n        \"total_play_duration\": \"総再生時間\"\n    },\n    \"status\": \"状態: {statusText}\",\n    \"studio\": \"スタジオ\",\n    \"studio_depth\": \"レベル (空白で全て)\",\n    \"studios\": \"スタジオ\",\n    \"sub_tag_count\": \"サブタグ数\",\n    \"sub_tag_of\": \"{parent}のサブタグ\",\n    \"sub_tags\": \"サブタグ\",\n    \"subsidiary_studios\": \"子会社のスタジオ\",\n    \"synopsis\": \"概要\",\n    \"tag\": \"タグ\",\n    \"tag_count\": \"タグ数\",\n    \"tags\": \"タグ\",\n    \"tattoos\": \"タトゥー\",\n    \"title\": \"タイトル\",\n    \"toast\": {\n        \"added_entity\": \"{count, plural, one {{singularEntity}}と、その他{{pluralEntity}}}を追加しました\",\n        \"added_generation_job_to_queue\": \"キューに生成ジョブが追加されました\",\n        \"created_entity\": \"{entity}が作成されました\",\n        \"default_filter_set\": \"デフォルトのフィルターセット\",\n        \"delete_past_tense\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}}を削除しました\",\n        \"generating_screenshot\": \"スクリーンショットを生成中…\",\n        \"merged_scenes\": \"マージされたシーン\",\n        \"merged_tags\": \"マージされたタグ\",\n        \"reassign_past_tense\": \"ファイルが再割り当てされました\",\n        \"removed_entity\": \"{count, plural, one {{singularEntity}}と、その他{{pluralEntity}}}を削除しました\",\n        \"rescanning_entity\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}}を再スキャン中…\",\n        \"saved_entity\": \"{entity}が保存されました\",\n        \"started_auto_tagging\": \"自動タグ付けを開始しました\",\n        \"started_generating\": \"生成を開始しました\",\n        \"started_importing\": \"インポートを開始しました\",\n        \"updated_entity\": \"{entity}を更新しました\"\n    },\n    \"total\": \"合計\",\n    \"true\": \"有効\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"タイプ\",\n    \"updated_at\": \"更新日:\",\n    \"url\": \"URL\",\n    \"videos\": \"動画\",\n    \"view_all\": \"全て表示\",\n    \"weight\": \"幅\",\n    \"weight_kg\": \"体重(kg)\",\n    \"years_old\": \"歳\",\n    \"zip_file_count\": \"zipファイルカウント\",\n    \"appears_with\": \"次で登場:\",\n    \"audio_codec\": \"音声コーデック\",\n    \"circumcised\": \"割礼\",\n    \"blobs_storage_type\": {\n        \"database\": \"データベース\",\n        \"filesystem\": \"ファイルシステム\"\n    },\n    \"chapters\": \"チャプター\",\n    \"circumcised_types\": {\n        \"CUT\": \"切除\",\n        \"UNCUT\": \"未切除\"\n    },\n    \"date_format\": \"YYYY -MM-DD\",\n    \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n    \"criterion_modifier_values\": {\n        \"none\": \"ない\",\n        \"only\": \"のみ\"\n    },\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"WebSocket接続ができません：詳細はブラウザのコンソールを確認してください\",\n        \"websocket_connection_reestablished\": \"WebSocket接続が再確立されました\"\n    },\n    \"custom_fields\": {\n        \"field\": \"フィールド\",\n        \"title\": \"カスタムフィールド\",\n        \"value\": \"値\"\n    },\n    \"distance\": \"距離\",\n    \"age_on_date\": \"撮影時の年齢 {age}歳\",\n    \"containing_group\": \"含まれるグループ\",\n    \"penis_length\": \"ペニスの長さ\",\n    \"parent_studio\": \"親スタジオ\",\n    \"sort_name\": \"ソート用の名称\",\n    \"groups\": \"グループ\",\n    \"include_sub_groups\": \"サブグループを含める\",\n    \"sub_groups\": \"サブグループ\",\n    \"history\": \"履歴\",\n    \"group_scene_number\": \"シーン番号\",\n    \"stashbox_search\": {\n        \"select_stashbox\": \"StashBoxを選択\",\n        \"no_results\": \"結果が見つかりません。\"\n    },\n    \"studio_tagger\": {\n        \"add_new_studios\": \"スタジオを新規追加\",\n        \"current_page\": \"現在のページ\",\n        \"failed_to_save_studio\": \"スタジオの保存に失敗\",\n        \"name_already_exists\": \"名前が既に存在します\",\n        \"network_error\": \"ネットワークエラー\",\n        \"no_results_found\": \"結果が見付かりません\"\n    },\n    \"video_codec\": \"ビデオコーデック\",\n    \"errors\": {\n        \"custom_fields\": {\n            \"duplicate_field\": \"フィールド名は一意である必要があります\",\n            \"field_name_length\": \"フィールド名は65文字未満である必要があります\",\n            \"field_name_required\": \"フィールド名が必要です\",\n            \"field_name_whitespace\": \"フィールド名の前後に空白を含めることはできません\"\n        },\n        \"header\": \"エラー\",\n        \"image_index_greater_than_zero\": \"画像インデックスは0より大きい必要があります\",\n        \"invalid_javascript_string\": \"無効なJavaScriptコードです: {error}\",\n        \"invalid_json_string\": \"無効なJSON文字列です: {error}\",\n        \"lazy_component_error_help\": \"最近Stashをアップグレードした場合は、ページを再読み込みするか、ブラウザのキャッシュをクリアしてください。\",\n        \"loading_type\": \"{type} の読み込みでエラーが発生しました\",\n        \"something_went_wrong\": \"問題が発生しました。\"\n    },\n    \"group\": \"グループ\",\n    \"group_count\": \"グループ数\",\n    \"hasChapters\": \"チャプター\",\n    \"image_index\": \"画像番号\",\n    \"include_sub_group_content\": \"サブグループの内容を含める\",\n    \"include_sub_studio_content\": \"サブスタジオの内容を含める\",\n    \"include_sub_tag_content\": \"サブタグの内容を含める\",\n    \"index_of_total\": \"{total} 中 {index}\",\n    \"package_manager\": {\n        \"confirm_delete_source\": \"ソース {name} ({url}) を削除してもよろしいですか？\",\n        \"description\": \"説明\",\n        \"edit_source\": \"ソースを編集\",\n        \"hide_unselected\": \"未選択を隠す\",\n        \"install\": \"インストール\",\n        \"installed_version\": \"インストールバージョン\",\n        \"latest_version\": \"最新バージョン\",\n        \"no_packages\": \"パッケージが見つかりません\",\n        \"no_upgradable\": \"更新可能なパッケージが見つかりません\",\n        \"package\": \"パッケージ\",\n        \"show_all\": \"すべて表示\",\n        \"source\": {\n            \"local_path\": {\n                \"heading\": \"ローカルパス\"\n            },\n            \"name\": \"名前\",\n            \"url\": \"ソースURL\"\n        },\n        \"uninstall\": \"アンインストール\",\n        \"update\": \"アップデート\",\n        \"version\": \"バージョン\"\n    },\n    \"penis\": \"ペニス\",\n    \"penis_length_cm\": \"ペニスの長さ (cm)\",\n    \"photographer\": \"写真家\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/ko-KR.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"추가\",\n        \"add_directory\": \"경로 추가\",\n        \"add_entity\": \"{entityType} 추가\",\n        \"add_to_entity\": \"{entityType}에 추가\",\n        \"allow\": \"허용\",\n        \"allow_temporarily\": \"임시 허용\",\n        \"anonymise\": \"데이터 익명화\",\n        \"apply\": \"적용\",\n        \"auto_tag\": \"자동 태깅\",\n        \"backup\": \"백업\",\n        \"browse_for_image\": \"이미지 가져오기…\",\n        \"cancel\": \"취소\",\n        \"clean\": \"데이터베이스 정리\",\n        \"clear\": \"삭제\",\n        \"clear_back_image\": \"이전 이미지 삭제\",\n        \"clear_front_image\": \"처음 이미지 삭제\",\n        \"clear_image\": \"이미지 삭제\",\n        \"close\": \"닫기\",\n        \"confirm\": \"확인\",\n        \"continue\": \"계속하기\",\n        \"create\": \"생성\",\n        \"create_chapters\": \"챕터 생성\",\n        \"create_entity\": \"{entityType} 생성\",\n        \"create_marker\": \"마커 생성\",\n        \"created_entity\": \"{entity_type}을 생성했습니다. ({entity_name})\",\n        \"customise\": \"사용자 정의\",\n        \"delete\": \"삭제\",\n        \"delete_entity\": \"{entityType} 삭제\",\n        \"delete_file\": \"파일 삭제\",\n        \"delete_file_and_funscript\": \"파일 삭제 (funscript 포함)\",\n        \"delete_generated_supporting_files\": \"생성된 컨텐츠 파일들까지 삭제\",\n        \"disallow\": \"금지\",\n        \"download\": \"다운로드\",\n        \"download_anonymised\": \"익명화된 데이터베이스 사본 다운로드\",\n        \"download_backup\": \"백업 다운로드\",\n        \"edit\": \"수정\",\n        \"edit_entity\": \"{entityType} 수정\",\n        \"export\": \"내보내기\",\n        \"export_all\": \"모두 내보내기…\",\n        \"find\": \"찾기\",\n        \"finish\": \"완료\",\n        \"from_file\": \"파일로 불러오기…\",\n        \"from_url\": \"URL로 불러오기…\",\n        \"full_export\": \"전부 내보내기\",\n        \"full_import\": \"전부 불러오기\",\n        \"generate\": \"생성\",\n        \"generate_thumb_default\": \"기본 썸네일 생성\",\n        \"generate_thumb_from_current\": \"현재 화면으로 썸네일 생성\",\n        \"hash_migration\": \"해쉬 값 마이그레이션\",\n        \"hide\": \"숨기기\",\n        \"hide_configuration\": \"설정 숨기기\",\n        \"identify\": \"식별\",\n        \"ignore\": \"무시\",\n        \"import\": \"불러오기…\",\n        \"import_from_file\": \"파일 불러오기\",\n        \"logout\": \"로그아웃\",\n        \"make_primary\": \"첫 번째로 만들기\",\n        \"merge\": \"병합\",\n        \"migrate_blobs\": \"Blob 마이그레이션\",\n        \"migrate_scene_screenshots\": \"영상 스크린샷 마이그레이션\",\n        \"next_action\": \"다음\",\n        \"not_running\": \"실행 중이 아님\",\n        \"open_in_external_player\": \"외부 플레이어에서 열기\",\n        \"open_random\": \"랜덤 열기\",\n        \"overwrite\": \"덮어쓰기\",\n        \"play_random\": \"랜덤 영상 재생\",\n        \"play_selected\": \"선택된 영상 재생\",\n        \"preview\": \"미리보기\",\n        \"previous_action\": \"뒤로\",\n        \"reassign\": \"재할당\",\n        \"refresh\": \"새로고침\",\n        \"reload_plugins\": \"플러그인 새로고침\",\n        \"reload_scrapers\": \"스크레이퍼 다시 불러오기\",\n        \"remove\": \"삭제\",\n        \"remove_from_gallery\": \"갤러리에서 삭제\",\n        \"rename_gen_files\": \"생성된 컨텐츠 파일 이름 바꾸기\",\n        \"rescan\": \"재스캔\",\n        \"reshuffle\": \"다시 섞기\",\n        \"running\": \"실행 중\",\n        \"save\": \"저장\",\n        \"save_delete_settings\": \"삭제할 때 이 설정을 기본값으로 사용\",\n        \"save_filter\": \"필터 저장\",\n        \"scan\": \"스캔\",\n        \"scrape\": \"스크레이핑하기\",\n        \"scrape_query\": \"쿼리 스크레이핑하기\",\n        \"scrape_scene_fragment\": \"개별 스크레이핑하기\",\n        \"scrape_with\": \"스크레이핑하기…\",\n        \"search\": \"검색\",\n        \"select_all\": \"모두 선택\",\n        \"select_entity\": \"{entityType} 선택\",\n        \"select_folders\": \"폴더 선택\",\n        \"select_none\": \"모두 선택 해제\",\n        \"selective_auto_tag\": \"선택적 자동 태깅\",\n        \"selective_clean\": \"선택적 데이터베이스 정리\",\n        \"selective_scan\": \"선택적 스캔\",\n        \"set_as_default\": \"기본값으로 설정\",\n        \"set_back_image\": \"이전 사진…\",\n        \"set_front_image\": \"처음 사진…\",\n        \"set_image\": \"사진 설정…\",\n        \"show\": \"보여주기\",\n        \"show_configuration\": \"설정 보여주기\",\n        \"skip\": \"건너뛰기\",\n        \"split\": \"분할\",\n        \"stop\": \"정지\",\n        \"submit\": \"제출\",\n        \"submit_stash_box\": \"Stash-Box에 제출하기\",\n        \"submit_update\": \"업데이트 제출하기\",\n        \"swap\": \"바꾸기\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"정말로 데이터베이스 정리를 하시겠습니까? 파일 시스템에 존재하지 않는 파일의 데이터베이스 정보와 컨텐츠가 삭제될 것입니다.\",\n            \"dry_mode_selected\": \"삭제하지 않기 모드가 선택되었습니다. 삭제를 진행하지 않고, 로깅만 할 것입니다.\",\n            \"import_warning\": \"정말 불러오기를 하시겠습니까? 데이터베이스를 삭제하고 불러온 메타데이터로 덮어쓰게 됩니다.\"\n        },\n        \"temp_disable\": \"임시 비활성화…\",\n        \"temp_enable\": \"임시 활성화…\",\n        \"unset\": \"설정 해제\",\n        \"use_default\": \"기본값 사용\",\n        \"view_random\": \"랜덤 보기\",\n        \"add_manual_date\": \"직접 날짜 추가\",\n        \"choose_date\": \"날짜 선택\",\n        \"encoding_image\": \"이미지 인코딩 중…\",\n        \"create_parent_studio\": \"부모 스튜디오 만들기\",\n        \"optimise_database\": \"데이터베이스 최적화\",\n        \"disable\": \"비활성화\",\n        \"enable\": \"활성화\",\n        \"add_play\": \"재생 기록 추가\",\n        \"add_o\": \"싸버린 기록 추가\",\n        \"assign_stashid_to_parent_studio\": \"기존 부모 스튜디오에 Stash ID를 할당하고 메타데이터를 업데이트합니다\",\n        \"clean_generated\": \"생성된 파일들 정리\",\n        \"clear_date_data\": \"날짜 데이터 삭제\",\n        \"copy_to_clipboard\": \"클립보드에 복사\",\n        \"reload\": \"새로고침\",\n        \"remove_date\": \"날짜 삭제\",\n        \"view_history\": \"기록 보기\",\n        \"set_cover\": \"커버로 설정\",\n        \"reset_cover\": \"커버를 기본값으로 되돌리기\",\n        \"reset_play_duration\": \"재생 시간 초기화\",\n        \"reset_resume_time\": \"마지막 재생 위치 초기화\",\n        \"add_sub_groups\": \"서브그룹 추가\",\n        \"remove_from_containing_group\": \"그룹에서 제거\",\n        \"sidebar\": {\n            \"close\": \"사이드바 닫기\",\n            \"open\": \"사이드바 열기\",\n            \"toggle\": \"사이드바 토글\"\n        },\n        \"play\": \"재생\",\n        \"show_results\": \"결과 표시\",\n        \"show_count_results\": \"{count}개 결과 표시\",\n        \"load\": \"불러오기\",\n        \"load_filter\": \"필터 불러오기\",\n        \"add_stash_id\": \"Stash ID 추가\",\n        \"create_new\": \"신규 생성\",\n        \"save_and_new\": \"저장 & 신규\",\n        \"invert_selection\": \"선택 영역 반전\",\n        \"select_directory\": \"경로 선택\",\n        \"reveal_in_file_manager\": \"파일 관리자에서 열기\",\n        \"exclude_lowercase\": \"제외\",\n        \"from_clipboard\": \"클립보드에서 불러오기\",\n        \"selective_generate\": \"선택적 생성\",\n        \"create_parent_tag\": \"부모 태그 만들기\"\n    },\n    \"actions_name\": \"액션\",\n    \"age\": \"나이\",\n    \"aliases\": \"별명\",\n    \"all\": \"모두\",\n    \"also_known_as\": \"별명\",\n    \"appears_with\": \"같이 작품을 찍은 배우들\",\n    \"ascending\": \"오름차순\",\n    \"average_resolution\": \"평균 해상도\",\n    \"between_and\": \"그리고\",\n    \"birth_year\": \"태어난 년도\",\n    \"birthdate\": \"생년월일\",\n    \"bitrate\": \"비트레이트\",\n    \"blobs_storage_type\": {\n        \"database\": \"데이터베이스\",\n        \"filesystem\": \"파일시스템\"\n    },\n    \"captions\": \"자막\",\n    \"career_length\": \"배우 경력\",\n    \"chapters\": \"챕터\",\n    \"circumcised\": \"포경수술 여부\",\n    \"circumcised_types\": {\n        \"CUT\": \"포경\",\n        \"UNCUT\": \"노포\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"stash-box 개체 활성화:\",\n            \"blacklist_desc\": \"블랙리스트에 있는 아이템들은 쿼리에서 제외됩니다. (주의: 아이템들은 정규 표현식으로 적혀 있어야 하며 대소문자를 구별합니다. 다음 문자들의 앞에는 백슬래쉬(\\\\)를 넣어주어야 합니다: {chars_require_escape})\",\n            \"blacklist_label\": \"블랙리스트\",\n            \"query_mode_auto\": \"자동\",\n            \"query_mode_auto_desc\": \"메타데이터가 있다면 사용하고, 그렇지 않다면 파일 이름을 사용합니다\",\n            \"query_mode_dir\": \"디렉토리\",\n            \"query_mode_dir_desc\": \"비디오 파일의 상위 경로만 사용\",\n            \"query_mode_filename\": \"파일 이름\",\n            \"query_mode_filename_desc\": \"파일 이름만 사용\",\n            \"query_mode_label\": \"쿼리 모드\",\n            \"query_mode_metadata\": \"메타데이터\",\n            \"query_mode_metadata_desc\": \"메타데이터만 사용\",\n            \"query_mode_path\": \"경로\",\n            \"query_mode_path_desc\": \"전체 파일 경로 사용\",\n            \"set_cover_desc\": \"영상 커버가 있다면 그 이미지로 교체합니다.\",\n            \"set_cover_label\": \"영상 커버 이미지 설정\",\n            \"set_tag_desc\": \"영상에 이미 존재하는 태그들을 덮어쓰거나 병합함으로써 태그를 영상에 추가합니다.\",\n            \"set_tag_label\": \"태그 설정\",\n            \"source\": \"출처\",\n            \"mark_organized_desc\": \"저장 버튼을 클릭하면 곧바로 영상을 '정리됨' 상태로 만듭니다.\",\n            \"mark_organized_label\": \"저장 시 '정리됨' 상태로 만들기\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"블랙리스트 항목이 중복되었습니다\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"배우 성별\",\n                \"description\": \"영상을 태깅할 때, 이 성별에 해당하는 배우들이 표시됩니다.\"\n            }\n        },\n        \"noun_query\": \"쿼리\",\n        \"results\": {\n            \"duration_off\": \"영상 길이가 최소 {number}초 차이남\",\n            \"duration_unknown\": \"영상 길이 알 수 없음\",\n            \"fp_found\": \"{fpCount, plural, =0 {일치하는 새로운 식별값을 찾지 못했습니다.} other {# 일치하는 새로운 식별값을 찾았습니다.}}\",\n            \"fp_matches\": \"영상 길이가 일치함\",\n            \"fp_matches_multi\": \"영상 길이가 {durationsLength}개 중 {matchCount}개의 식별값과 일치합니다\",\n            \"hash_matches\": \"{hash_type}이 일치함\",\n            \"match_failed_already_tagged\": \"이미 태깅된 영상\",\n            \"match_failed_no_result\": \"결과 없음\",\n            \"match_success\": \"영상 태깅 성공\",\n            \"phash_matches\": \"{count}개의 PHash가 일치함\",\n            \"unnamed\": \"이름 없음\"\n        },\n        \"verb_match_fp\": \"식별값 비교하기\",\n        \"verb_matched\": \"일치함\",\n        \"verb_scrape_all\": \"모두 스크레이핑하기\",\n        \"verb_submit_fp\": \"{fpCount, plural, one{# 식별값} other{# 식별값들}} 제출하기\",\n        \"verb_toggle_config\": \"{configuration} {toggle}\",\n        \"verb_toggle_unmatched\": \"일치하지 않는 영상 {toggle}\",\n        \"verb_add_as_alias\": \"스크레이핑된 이름을 별칭으로 추가\",\n        \"verb_link_existing\": \"외부 링크\",\n        \"verb_match_tag\": \"태그 비교하기\",\n        \"verb_scrape_selected\": \"선택된 항목 스크레이핑\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"빌드 해쉬 값:\",\n            \"build_time\": \"빌드된 시간:\",\n            \"check_for_new_version\": \"새로운 버전 체크\",\n            \"latest_version\": \"최신 버전\",\n            \"latest_version_build_hash\": \"최신 버전 빌드 해쉬:\",\n            \"new_version_notice\": \"[새 버전]\",\n            \"release_date\": \"출시일:\",\n            \"stash_discord\": \"디스코드: {url}\",\n            \"stash_home\": \"깃허브: {url}\",\n            \"stash_open_collective\": \"후원: {url}\",\n            \"stash_wiki\": \"Stash 위키: {url}\",\n            \"version\": \"버전\"\n        },\n        \"application_paths\": {\n            \"heading\": \"앱 경로\"\n        },\n        \"categories\": {\n            \"about\": \"프로그램 정보\",\n            \"changelog\": \"패치노트\",\n            \"interface\": \"인터페이스\",\n            \"logs\": \"로그\",\n            \"metadata_providers\": \"메타데이터\",\n            \"plugins\": \"플러그인\",\n            \"scraping\": \"스크레이핑\",\n            \"security\": \"보안\",\n            \"services\": \"서비스\",\n            \"system\": \"시스템\",\n            \"tasks\": \"작업\",\n            \"tools\": \"도구\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"{tempIP} 허용\",\n            \"allowed_ip_addresses\": \"허용된 IP 주소\",\n            \"allowed_ip_temporarily\": \"임시로 허용된 IP\",\n            \"default_ip_whitelist\": \"IP 화이트리스트 기본값\",\n            \"default_ip_whitelist_desc\": \"기본 IP 주소들은 DLNA에 접근할 수 있습니다. 모든 IP 주소들을 허용하려면 {wildcard} 문자를 사용하세요.\",\n            \"disabled_dlna_temporarily\": \"임시로 DNLA를 비활성화했습니다\",\n            \"disallowed_ip\": \"금지된 IP\",\n            \"enabled_by_default\": \"기본값으로 활성화됨\",\n            \"enabled_dlna_temporarily\": \"임시로 DLNA를 활성화함\",\n            \"network_interfaces\": \"인터페이스\",\n            \"network_interfaces_desc\": \"DLNA 서버를 노출시키기 위한 인터페이스입니다. 빈 리스트로 두면 모든 인터페이스에서 작동하게 됩니다. 변경 후 DLNA를 재시작해야 합니다.\",\n            \"recent_ip_addresses\": \"최근 IP 주소\",\n            \"server_display_name\": \"서버 이름 (display name)\",\n            \"server_display_name_desc\": \"DLNA 서버를 위한 이름(display name)입니다. 빈 칸으로 두면 기본값으로 {server_name}를 사용합니다.\",\n            \"successfully_cancelled_temporary_behaviour\": \"임시 설정을 취소하는 데에 성공했습니다\",\n            \"until_restart\": \"재시작 전까지\",\n            \"video_sort_order\": \"기본 비디오 정렬 순서\",\n            \"video_sort_order_desc\": \"비디오를 정렬할 기본값 순서입니다.\",\n            \"server_port\": \"서버 포트\",\n            \"server_port_desc\": \"DLNA 서버를 동작시킬 포트입니다. 변경 이후 DLNA 재시작이 필요합니다.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API 키\",\n                \"api_key_desc\": \"외부 시스템을 위한 API 키입니다. 아이디/비밀번호가 설정되었을 때에만 필요합니다. 아이디는 API 키 생성 전에 저장되어야만 합니다.\",\n                \"authentication\": \"인증\",\n                \"clear_api_key\": \"API 키 삭제\",\n                \"credentials\": {\n                    \"description\": \"Stash로의 접속을 제한하기 위한 자격 요건입니다.\",\n                    \"heading\": \"자격증명서\"\n                },\n                \"generate_api_key\": \"API 키 생성\",\n                \"log_file\": \"로그 파일\",\n                \"log_file_desc\": \"로그 파일 경로입니다. 파일 로깅을 하지 않으려면 빈 칸으로 두십시오. 설정 변경 후 재시작해야 합니다.\",\n                \"log_http\": \"HTTP 접근 로그\",\n                \"log_http_desc\": \"HTTP 접근을 터미널에 로깅합니다. 설정 변경 후 재시작해야 합니다.\",\n                \"log_to_terminal\": \"터미널에 로깅하기\",\n                \"log_to_terminal_desc\": \"파일뿐만 아니라 터미널에도 로깅을 합니다. 파일 로깅이 비활성화되어있다면 이 기능이 항상 켜집니다. 설정 변경 후 재시작해야 합니다.\",\n                \"maximum_session_age\": \"최대 접속 시간\",\n                \"maximum_session_age_desc\": \"사용되지 않을 때 자동으로 로그아웃되기까지의 시간입니다 (단위: 초). 설정하려면 재시작해야 합니다.\",\n                \"password\": \"비밀번호\",\n                \"password_desc\": \"Stash에 접속하기 위한 비밀번호입니다. 로그인 과정을 생략하려면 빈 칸으로 두십시오\",\n                \"stash-box_integration\": \"Stash-box 통합\",\n                \"username\": \"아이디\",\n                \"username_desc\": \"Stash에 접속하기 위한 아이디입니다. 로그인을 생략하려면 빈 칸으로 두십시오\",\n                \"log_file_max_size\": \"최대 로그 크기\",\n                \"log_file_max_size_desc\": \"압축 전 로그 파일의 최대 크기(MB)입니다. 0MB를 입력하면 비활성화됩니다. 설정 변경 후 재시작해야 합니다.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"SQLite 데이터베이스 백업 파일을 위한 폴더 경로.\",\n                \"heading\": \"백업 폴더 경로\"\n            },\n            \"blobs_path\": {\n                \"description\": \"바이너리 데이터를 저장할 파일 시스템 위치입니다. 파일시스템 blob 스토리지 타입을 선택했을 때에만 적용 가능합니다. 경고: 이 위치를 바꾸면 기존의 데이터를 수동으로 옮겨야 합니다.\",\n                \"heading\": \"바이너리 데이터 파일시스템 경로\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"영상 커버, 배우, 스튜디오, 그리고 태그 이미지들과 같은 바이너리 데이터를 저장하는 위치입니다. 이 값을 바꾼 후에는, 기존 데이터가 'Blob 마이그레이션' 작업을 사용하여 마이그레이션되어야 합니다. 마이그레이션을 하려면 '작업' 페이지를 참고하세요.\",\n                \"heading\": \"바이너리 데이터 저장 타입\"\n            },\n            \"cache_location\": \"캐시 폴더 경로입니다. HLS(애플 기기 등)이나 DASH로 스트리밍할 때 필요합니다.\",\n            \"cache_path_head\": \"캐시 경로\",\n            \"calculate_md5_and_ohash_desc\": \"oshash 외에 MD5 체크섬도 계산합니다. 활성화하면 초기 스캔을 더 느리게 만들 것입니다. MD5 계산을 사용하지 않으려면 파일 이름 해쉬를 oshash로 설정해야 합니다.\",\n            \"calculate_md5_and_ohash_label\": \"비디오 MD5 계산하기\",\n            \"check_for_insecure_certificates\": \"안전하지 않은 자격증명 검사\",\n            \"check_for_insecure_certificates_desc\": \"일부 사이트에서는 안전하지 않은 SSL 인증서를 사용합니다. 스크레이퍼를 선택하지 않으면 안전하지 않은 인증서 검사를 건너뛰고 해당 사이트를 스크레이핑할 수 있습니다. 스크레이핑 시 인증서 오류가 발생하면 이 체크 표시를 해제하세요.\",\n            \"chrome_cdp_path\": \"Chrome CDP 경로\",\n            \"chrome_cdp_path_desc\": \"Chrome 실행 파일의 경로, 또는 Chrome 인스턴스의 원격 주소입니다(http:// 또는 https://로 시작합니다. 예시: http://localhost:9222/json/version).\",\n            \"create_galleries_from_folders_desc\": \"체크하면, 기본값으로 이미지를 포함한 폴더들로부터 갤러리를 생성합니다. .forcegallery 또는 .nogallery라는 이름의 파일을 폴더 안에 만듦으로써, 갤러리를 강제 생성하거나, 생성되지 않도록 할 수 있습니다.\",\n            \"create_galleries_from_folders_label\": \"이미지가 들어있는 폴더로부터 갤러리 생성\",\n            \"database\": \"데이터베이스\",\n            \"db_path_head\": \"데이터베이스 경로\",\n            \"directory_locations_to_your_content\": \"컨텐츠가 있는 폴더 위치\",\n            \"excluded_image_gallery_patterns_desc\": \"스캔과 데이터베이스 정리 작업에서 제외할 이미지와 갤러리 파일/경로의 정규표현식.\",\n            \"excluded_image_gallery_patterns_head\": \"제외된 이미지/갤러리 패턴\",\n            \"excluded_video_patterns_desc\": \"스캔과 데이터베이스 정리 작업에서 제외할 비디오 파일/경로의 정규표현식.\",\n            \"excluded_video_patterns_head\": \"제외된 비디오 패턴\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"가능한 하드웨어를 인코딩에 사용하여 실시간 트랜스코딩을 합니다.\",\n                    \"heading\": \"FFmpeg 하드웨어 인코딩\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"고급 설정: 실시간 트랜스코딩을 할 때, 입력 필드 이전에 ffmpeg에 전달될 추가적인 매개변수입니다.\",\n                        \"heading\": \"FFmpeg 실시간 트랜스코딩 입력 매개변수\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"고급 설정: 실시간 트랜스코딩을 할 때, 출력 필드 이전에 ffmpeg에 전달될 추가적인 매개변수입니다.\",\n                        \"heading\": \"FFmpeg 실시간 트랜스코딩 출력 매개변수\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"고급 설정: 비디오를 생성할 때, 입력 필드 이전에 ffmpeg에 전달될 추가적인 매개변수입니다.\",\n                        \"heading\": \"FFmpeg 트랜스코딩 입력 매개변수\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"고급 설정: 비디오를 생성할 때, 출력 필드 이전에 ffmpeg에 전달될 추가적인 매개변수입니다.\",\n                        \"heading\": \"FFmpeg 트랜스코딩 출력 매개변수\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"FFmpeg 다운로드\",\n                    \"description\": \"FFmpeg를 설정 폴더에 다운로드하고 ffmpeg와 ffprobe 경로를 초기화해, Stash가 이 둘을 설정 폴더에서 찾을 수 있도록 합니다.\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"ffmpeg 실행파일의 경로입니다(ffmpeg가 포함된 폴더의 경로가 아님). 만약 비어 있다면, ffmpeg의 위치를 환경변수로부터 찾습니다($PATH, 설정 폴더, $HOME/.stash).\",\n                    \"heading\": \"FFmpeg 실행파일 경로\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"ffprobe 실행파일의 경로입니다(ffprobe가 포함된 폴더의 경로가 아님). 만약 비어 있다면, ffprobe의 위치를 환경변수로부터 찾습니다($PATH, 설정 폴더, $HOME/.stash).\",\n                    \"heading\": \"FFprobe 실행파일 경로\"\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"히트맵에 범위 포함\",\n            \"funscript_heatmap_draw_range_desc\": \"히트맵의 y축에 움직임의 범위를 그립니다. 기존 히트맵은 이 설정을 바꾼 후 재생성되어야 합니다.\",\n            \"gallery_cover_regex_desc\": \"이미지를 갤러리 커버로 인식하는 데에 사용되는 정규표현식.\",\n            \"gallery_cover_regex_label\": \"갤러리 커버 패턴\",\n            \"gallery_ext_desc\": \"갤러리 ZIP 파일로 인식될 파일 확장자입니다 (쉼표로 구분합니다).\",\n            \"gallery_ext_head\": \"갤러리 ZIP 확장자\",\n            \"generated_file_naming_hash_desc\": \"생성되는 컨텐츠 파일 이름을 정할 때 MD5 또는 oshash를 사용합니다. 이를 변경하려면 모든 영상에 해당 MD5/osash 값이 채워져 있어야 합니다. 이 값을 변경한 후에는 기존에 생성된 파일을 마이그레이션하거나 재생성해야 합니다. 마이그레이션은 '작업' 페이지를 참조하세요.\",\n            \"generated_file_naming_hash_head\": \"생성된 컨텐츠 파일 이름 해쉬\",\n            \"generated_files_location\": \"생성된 컨텐츠 파일들의 폴더 위치 (영상 마커, 영상 미리보기, 스프라이트 등등).\",\n            \"generated_path_head\": \"생성된 컨텐츠 파일 경로\",\n            \"hashing\": \"해싱\",\n            \"heatmap_generation\": \"Funscript 히트맵 생성\",\n            \"image_ext_desc\": \"이미지로 인식될 파일 확장자입니다 (쉼표로 구분합니다).\",\n            \"image_ext_head\": \"이미지 확장자\",\n            \"include_audio_desc\": \"미리보기를 생성할 때 소리를 포함합니다.\",\n            \"include_audio_head\": \"소리 포함\",\n            \"logging\": \"로깅\",\n            \"maximum_streaming_transcode_size_desc\": \"트랜스코딩된 스트림의 최대 크기.\",\n            \"maximum_streaming_transcode_size_head\": \"최대 스트리밍 트랜스코드 크기\",\n            \"maximum_transcode_size_desc\": \"트랜스코드의 최대 크기.\",\n            \"maximum_transcode_size_head\": \"최대 트랜스코드 크기\",\n            \"metadata_path\": {\n                \"description\": \"전체 내보내기 또는 전체 불러오기를 실행할 때 사용되는 폴더 위치.\",\n                \"heading\": \"메타데이터 경로\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"자동으로 설정하려면 0을 입력하세요. 경고: 100% CPU 활용률을 달성하는 데 필요한 작업보다 더 많은 작업을 실행하면 성능이 저하되고 잠재적으로 다른 문제가 발생할 수 있습니다.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"스캔/생성 병렬 작업 수\",\n            \"parallel_scan_head\": \"병렬 스캔/생성\",\n            \"preview_generation\": \"미리보기 생성\",\n            \"python_path\": {\n                \"description\": \"파이썬 실행파일의 위치입니다. (실행파일이 들어 있는 폴더가 아님) 스크립트 스크레이퍼와 플러그인의 실행에 사용됩니다. 빈 칸으로 두면, 시스템 환경 설정으로부터 위치를 받아 옵니다.\",\n                \"heading\": \"Python 실행파일 경로\"\n            },\n            \"scraper_user_agent\": \"스크레이퍼 사용자 에이전트\",\n            \"scraper_user_agent_desc\": \"HTTP 요청 스크레이핑 중 사용되는 사용자 에이전트 문자열.\",\n            \"scrapers_path\": {\n                \"description\": \"스크레이퍼 설정 파일의 폴더 위치.\",\n                \"heading\": \"스크레이퍼 경로\"\n            },\n            \"scraping\": \"스크레이핑\",\n            \"sqlite_location\": \"SQLite 데이터베이스의 파일 위치입니다 (설정 변경 후 재시작이 필요합니다). 경고: 데이터베이스를 Stash 서버가 실행되는 곳이 아닌 다른 시스템에 저장하는 것(즉, 네트워크 바깥)은 지원되지 않습니다!\",\n            \"video_ext_desc\": \"비디오로 인식될 파일 확장자입니다 (쉼표로 구분합니다).\",\n            \"video_ext_head\": \"비디오 확장 프로그램\",\n            \"video_head\": \"비디오\",\n            \"plugins_path\": {\n                \"description\": \"플러그인 설정 파일의 폴더 위치.\",\n                \"heading\": \"플러그인 경로\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"삭제된 파일들이 영구 삭제되는 대신에 옮겨지게 될 경로입니다. 파일들을 영구 삭제하려면 빈 칸으로 두십시오.\",\n                \"heading\": \"휴지통 경로\"\n            },\n            \"sprite_generation_head\": \"스프라이트 생성\",\n            \"sprite_interval_desc\": \"생성된 각각의 스프라이트 사이의 시간 간격(초).\",\n            \"sprite_interval_head\": \"스프라이트 시간 간격\",\n            \"sprite_maximum_desc\": \"하나의 영상에 대해 생성되는 최대 스프라이트 개수입니다. 0으로 설정하면 제한을 해제합니다.\",\n            \"sprite_maximum_head\": \"최대 스프라이트 개수\",\n            \"sprite_minimum_desc\": \"하나의 영상에 대해 생성되는 최소 스프라이트 개수\",\n            \"sprite_minimum_head\": \"최소 스프라이트 개수\",\n            \"sprite_screenshot_size_desc\": \"각각의 스프라이트의 원하는 크기를 지정합니다 (픽셀).\",\n            \"sprite_screenshot_size_head\": \"스프라이트 크기\",\n            \"use_custom_sprite_interval_head\": \"사용자 지정 스프라이트 시간 간격 사용\",\n            \"use_custom_sprite_interval_desc\": \"사용자 지정 스프라이트 시간 간격을, 아래 설정과 같이 활성화합니다.\"\n        },\n        \"library\": {\n            \"exclusions\": \"제외\",\n            \"gallery_and_image_options\": \"갤러리와 이미지 설정\",\n            \"media_content_extensions\": \"미디어 컨텐츠 확장자\"\n        },\n        \"logs\": {\n            \"log_level\": \"로그 수준\"\n        },\n        \"plugins\": {\n            \"hooks\": \"후크\",\n            \"triggers_on\": \"작동 조건\",\n            \"available_plugins\": \"사용 가능한 플러그인\",\n            \"installed_plugins\": \"설치된 플러그인\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} 메타데이터\",\n            \"entity_scrapers\": \"{entityType} 스크레이퍼\",\n            \"excluded_tag_patterns_desc\": \"스크레이핑 결과에서 제외할 태그 이름의 정규표현식.\",\n            \"excluded_tag_patterns_head\": \"제외된 태그 패턴\",\n            \"scraper\": \"스크레이퍼\",\n            \"scrapers\": \"스크레이퍼\",\n            \"search_by_name\": \"이름으로 찾기\",\n            \"supported_types\": \"지원되는 형식\",\n            \"supported_urls\": \"URL\",\n            \"available_scrapers\": \"사용 가능한 스크레이퍼\",\n            \"installed_scrapers\": \"설치된 스크레이퍼\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"stash-box 인스턴스 추가\",\n            \"api_key\": \"API 키\",\n            \"description\": \"Stash-box는 식별값 및 파일 이름을 기반으로 영상 및 배우를 자동 태깅합니다.\\n엔드포인트 및 API 키는 Stash-Box 인스턴스의 계정 페이지에서 찾을 수 있습니다. 인스턴스를 두 개 이상 추가할 경우 이름이 필요합니다.\",\n            \"endpoint\": \"엔드포인트\",\n            \"graphql_endpoint\": \"GraphQL 엔드포인트\",\n            \"name\": \"이름\",\n            \"title\": \"Stash-box 엔드포인트\",\n            \"max_requests_per_minute\": \"분당 최대 요청 수\",\n            \"max_requests_per_minute_description\": \"0으로 설정할 경우 기본값({defaultValue})을 사용합니다\"\n        },\n        \"system\": {\n            \"transcoding\": \"트랜스코딩\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"작업 대기열에 {operation_name}을 추가했습니다\",\n            \"anonymise_and_download\": \"익명화된 데이터베이스의 복사본을 만들고 결과 파일을 다운로드합니다.\",\n            \"anonymise_database\": \"모든 민감한 데이터를 익명화한 채로, 데이터베이스의 복사본을 백업 폴더에 저장합니다. 데이터베이스 복사본을 다른 사람들에게 제공함으로써 문제 해결이나 버그 해결 목적으로 사용될 수 있습니다. 데이터베이스 원본은 변경되지 않습니다. 익명화된 데이터베이스는 {filename_format}의 파일 이름 형식을 사용합니다.\",\n            \"anonymising_database\": \"데이터베이스 익명화 중\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"모든 경로 자동 태깅 중\",\n                \"auto_tagging_paths\": \"다음 경로 자동 태깅 중\"\n            },\n            \"auto_tag_based_on_filenames\": \"파일 경로를 통해 컨텐츠에 자동으로 태깅합니다.\",\n            \"auto_tagging\": \"자동 태깅\",\n            \"backing_up_database\": \"데이터베이스 백업 중\",\n            \"backup_and_download\": \"데이터베이스를 백업하고 결과 파일을 다운로드합니다.\",\n            \"cleanup_desc\": \"있어야 할 곳에 없는 파일이 있는지 검사한 후, 데이터베이스에서 삭제합니다. 데이터베이스의 일부 내용이 삭제될 수 있습니다.\",\n            \"data_management\": \"데이터 관리\",\n            \"defaults_set\": \"기본값이 설정되었습니다. '작업' 페이지의 {action} 버튼을 클릭할 때 사용됩니다.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"제목에 파일 확장자 포함하지 않기\",\n            \"empty_queue\": \"실행 중인 작업이 없습니다.\",\n            \"export_to_json\": \"메타데이터 폴더에 데이터베이스 컨텐츠를 JSON 형식으로 내보냅니다.\",\n            \"generate\": {\n                \"generating_from_paths\": \"다음 경로에서 영상 생성 중\",\n                \"generating_scenes\": \"{num}개의 {scene} 생성 중\"\n            },\n            \"generate_clip_previews_during_scan\": \"이미지 클립 미리보기 생성\",\n            \"generate_desc\": \"이미지, 스프라이트, 비디오, vtt 등 파일을 생성합니다.\",\n            \"generate_phashes_during_scan\": \"비디오 해쉬 값 생성\",\n            \"generate_phashes_during_scan_tooltip\": \"중복된 파일 확인과 영상 식별에 사용됩니다.\",\n            \"generate_previews_during_scan\": \"움직이는 이미지 미리보기 생성\",\n            \"generate_previews_during_scan_tooltip\": \"애니메이션(webp) 미리보기 또한 생성합니다. 영상/마커 월의 미리보기 유형이 '애니메이션 이미지'로 설정된 경우에만 필요합니다. '애니메이션 미리보기'는 '비디오 미리보기'보다 CPU를 덜 사용하지만, '비디오 미리보기'에 추가적으로 '애니메이션 미리보기'가 생성되기 때문에 파일 크기가 커집니다.\",\n            \"generate_sprites_during_scan\": \"스크러버 스프라이트 생성\",\n            \"generate_thumbnails_during_scan\": \"이미지 썸네일 생성\",\n            \"generate_video_covers_during_scan\": \"영상 커버 생성\",\n            \"generate_video_previews_during_scan\": \"미리보기 생성\",\n            \"generate_video_previews_during_scan_tooltip\": \"마우스를 위에 올려놓았을 때 재생되는 비디오 미리보기 생성\",\n            \"generated_content\": \"컨텐츠 생성\",\n            \"identify\": {\n                \"and_create_missing\": \"또한 누락된 항목 생성\",\n                \"create_missing\": \"누락된 항목 생성\",\n                \"default_options\": \"기본값 옵션\",\n                \"description\": \"Stash-Box 및 스크레이퍼 소스를 사용하여 영상 메타데이터를 자동으로 설정합니다.\",\n                \"explicit_set_description\": \"소스 별 옵션에서 재정의되지 않는 경우 다음 옵션이 사용됩니다.\",\n                \"field\": \"항목\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"항목 옵션\",\n                \"heading\": \"식별\",\n                \"identifying_from_paths\": \"다음 경로에서 영상 식별 중\",\n                \"identifying_scenes\": \"{num}개의 {scene} 식별 중\",\n                \"include_male_performers\": \"남성 배우 포함\",\n                \"set_cover_images\": \"커버 이미지 설정\",\n                \"set_organized\": \"'정리됨' 상태로 설정\",\n                \"source\": \"소스\",\n                \"source_options\": \"{source} 옵션\",\n                \"sources\": \"소스\",\n                \"strategy\": \"방법\",\n                \"skip_single_name_performers_tooltip\": \"만약 이 옵션이 설정되지 않은 경우, 'Samantha' 혹은 'Olga'와 같은 흔한 이름들이 매칭될 것입니다\",\n                \"tag_skipped_performer_tooltip\": \"이 옵션에 해당하는 배우들에 대해, 나중에 영상 태거 뷰에서 배우 정보를 원하는 대로 다룰 수 있도록, '식별: 한 단어 이름 배우' 등과 같은 태그를 만듭니다\",\n                \"skip_multiple_matches_tooltip\": \"만약 이 옵션이 설정되지 않은 상태에서 여러 개의 결과가 도출된 경우, 여러 개의 결과 중 무작위로 하나가 선택될 것입니다\",\n                \"skip_single_name_performers\": \"다른 배우의 이름과 겹치지 않으면서도 한 단어의 이름으로 이뤄진 배우의 경우, 처리하지 않고 건너뛰기\",\n                \"skip_multiple_matches\": \"여러 개의 매칭 결과가 나왔을 때, 처리하지 않고 건너뛰기\",\n                \"tag_skipped_matches\": \"처리하지 않고 건너뛴 항목들에 대해 다음과 같이 태깅하기\",\n                \"tag_skipped_matches_tooltip\": \"다수 식별 결과가 도출된 항목들을 대상으로, 실제로 일치하는 식별 결과를 영상 태거 뷰에서 직접 고를 수 있도록, '식별: 다수 매칭' 등과 같은 태그를 만듭니다\",\n                \"tag_skipped_performers\": \"처리하지 않고 건너뛴 배우들에 대해 다음과 같이 태깅하기\",\n                \"performer_genders\": \"배우 성별\",\n                \"performer_genders_desc\": \"선택된 성별에 해당되는 배우들은, 식별 과정 중 포함됩니다.\"\n            },\n            \"import_from_exported_json\": \"메타데이터 폴더에서 내보낸 JSON 파일에서 가져오기 작업을 합니다. 기존 데이터베이스를 지웁니다.\",\n            \"incremental_import\": \"내보낸 zip 파일에서 증가한 부분만 가져옵니다.\",\n            \"job_queue\": \"작업 대기열\",\n            \"maintenance\": \"관리\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"오래된 데이터 삭제\",\n                \"description\": \"Blob 파일들을 현재의 blob 저장 시스템으로 마이그레이션합니다. 이 마이그레이션 작업은 blob 저장 시스템을 변경한 이후 실행되어야 합니다. 마이그레이션 후 과거 데이터를 선택적으로 삭제 가능합니다.\"\n            },\n            \"migrate_hash_files\": \"생성된 컨텐츠 파일 이름 해쉬를 변경한 후, 기존 생성된 컨텐츠 파일의 이름을 새 해쉬 형식으로 바꾸기 위해 사용됩니다.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"스크린샷 파일 삭제\",\n                \"description\": \"영상 스크린샷을 새로운 blob 저장 시스템으로 마이그레이션합니다. 이 마이그레이션 작업은 Stash 시스템을 0.20으로 마이그레이션 한 후에 실행되어야 합니다. 마이그레이션 후 과거 스크린샷을 선택적으로 삭제 가능합니다.\",\n                \"overwrite_existing\": \"기존 blob을 스크린샷 데이터로 덮어쓰기\"\n            },\n            \"migrations\": \"마이그레이션\",\n            \"only_dry_run\": \"체크만 합니다. 아무 것도 삭제하지 않습니다\",\n            \"plugin_tasks\": \"플러그인 작업\",\n            \"scan\": {\n                \"scanning_all_paths\": \"모든 경로 스캔 중\",\n                \"scanning_paths\": \"다음 경로 스캔 중\"\n            },\n            \"scan_for_content_desc\": \"새로운 컨텐츠를 스캔하고 데이터베이스에 추가합니다.\",\n            \"set_name_date_details_from_metadata_if_present\": \"파일 속성값으로 이름, 날짜, 세부 사항을 설정\",\n            \"clean_generated\": {\n                \"image_thumbnails\": \"이미지 썸네일\",\n                \"image_thumbnails_desc\": \"이미지 썸네일과 클립\",\n                \"markers\": \"마커 미리보기\",\n                \"previews\": \"영상 미리보기\",\n                \"previews_desc\": \"영상 미리보기와 썸네일\",\n                \"sprites\": \"영상 스프라이트\",\n                \"transcodes\": \"영상 트랜스코드\",\n                \"blob_files\": \"Blob 파일\",\n                \"description\": \"생성된 파일들에 대응되는 항목이 데이터베이스에 없을 경우, 생성된 파일들을 삭제합니다.\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"영상에서 원하는 위치를 찾기 쉽게 하기 위해, 비디오 플레이어 아래에 표시되는 이미지들의 모음입니다.\",\n            \"optimise_database_warning\": \"주의: 이 작업이 수행되는 동안, 데이터베이스를 수정하는 다른 모든 작업들은 실패할 것이고, 데이터베이스의 크기에 따라 작업 종료까지 몇 분이 걸릴 수 있습니다. 그리고 이 작업은 적어도 데이터베이스 크기만큼의 디스크 공간이 필요한데, 데이터베이스 크기의 1.5배의 디스크 공간을 확보하는 것을 권장합니다.\",\n            \"optimise_database\": \"모든 데이터베이스 파일을 분석하고 다시 만듦으로써, 성능을 향상시키려고 시도합니다.\",\n            \"rescan_tooltip\": \"경로 내의 모든 파일을 재스캔합니다. 파일 메타데이터를 강제 업데이트하고 zip 파일들을 재스캔하기 위해 사용됩니다.\",\n            \"rescan\": \"파일 재스캔\",\n            \"generate_image_phashes_during_scan\": \"이미지 해쉬 값 생성\",\n            \"generate_image_phashes_during_scan_tooltip\": \"중복 제거 및 식별 목적.\",\n            \"backup_database\": {\n                \"description\": \"데이터베이스와 blob 파일의 백업을 수행합니다.\",\n                \"destination\": \"백업 파일의 위치\",\n                \"download\": \"백업 파일 다운로드\",\n                \"include_blobs\": \"백업 파일에 blob 포함\",\n                \"include_blobs_desc\": \"SQLite 데이터베이스 파일만을 백업하려면 비활성화하세요.\",\n                \"sqlite\": \"백업 파일은 SQLite 데이터베이스 파일의 복제본이 될 것입니다. (백업 파일 이름: {filename_format})\",\n                \"to_directory\": \"{directory} 경로로\",\n                \"warning_blobs\": \"blob 파일들은 백업 파일에 포함되지 않을 것입니다. 이는, 백업을 완전히 복원하려면 blob 저장 위치에 blob 파일이 존재해야 함을 의미합니다.\",\n                \"zip\": \"SQLite 데이터베이스 파일과 blob 파일은 하나의 파일로 압축될 것입니다. (압축 파일 이름: {filename_format})\"\n            }\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"영상 중복 체크 도구\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"항목 추가\",\n                \"capitalize_title\": \"제목 앞 글자를 대문자로\",\n                \"display_fields\": \"항목 표시하기\",\n                \"escape_chars\": \"\\\\를 사용하여 리터럴 문자를 이스케이프합니다\",\n                \"filename\": \"파일 이름\",\n                \"filename_pattern\": \"파일 이름 패턴\",\n                \"ignore_organized\": \"'정리됨' 상태의 영상을 무시\",\n                \"ignored_words\": \"무시된 단어들\",\n                \"matches_with\": \"{i}와 일치\",\n                \"select_parser_recipe\": \"파서 레시피 선택\",\n                \"title\": \"영상 파일 이름 분석기\",\n                \"whitespace_chars\": \"공백 문자\",\n                \"whitespace_chars_desc\": \"이 문자들은 제목에서 공백으로 대체됩니다\"\n            },\n            \"scene_tools\": \"영상 도구\",\n            \"heading\": \"도구\",\n            \"graphql_playground\": \"GraphQL 플레이그라운드\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"카드와 세부 페이지에서 숫자를 축약하여 나타냅니다(예시: \\\"1831\\\"이 \\\"1.8K\\\"로 표현됩니다).\",\n                \"heading\": \"숫자 축약\"\n            },\n            \"basic_settings\": \"기본 설정\",\n            \"custom_css\": {\n                \"description\": \"변화된 사항을 확인하려면 페이지를 새로고침해야 합니다. 커스텀 CSS가 Stash의 이후 버전과 호환될 것이라는 보장은 없습니다.\",\n                \"heading\": \"커스텀 CSS\",\n                \"option_label\": \"커스텀 CSS 활성화\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"변화된 사항을 확인하려면 페이지를 새로고침해야 합니다. 커스텀 Javascript가 Stash의 이후 버전과 호환될 것이라는 보장은 없습니다.\",\n                \"heading\": \"커스텀 JavaScript\",\n                \"option_label\": \"커스텀 JavaScript 활성화\"\n            },\n            \"custom_locales\": {\n                \"description\": \"커스텀 번역으로 기존 번역을 대신합니다. https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json 에서 단어 리스트를 확인하세요. 페이지를 새로고침해야 변동사항이 적용됩니다.\",\n                \"heading\": \"커스텀 번역\",\n                \"option_label\": \"커스텀 번역 활성화\"\n            },\n            \"delete_options\": {\n                \"description\": \"이미지, 갤러리, 영상을 삭제할 때의 설정 기본값입니다.\",\n                \"heading\": \"삭제 설정\",\n                \"options\": {\n                    \"delete_file\": \"기본값으로 파일 지우기\",\n                    \"delete_generated_supporting_files\": \"기본값으로 생성된 컨텐츠 파일 삭제\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"데스크탑 통합\",\n                \"notifications_enabled\": \"알림 활성화\",\n                \"send_desktop_notifications_for_events\": \"이벤트가 발생했을 때 데스크탑 알림을 보냅니다.\",\n                \"skip_opening_browser\": \"브라우저 자동 열기 해제\",\n                \"skip_opening_browser_on_startup\": \"Stash를 시작할 때 자동으로 브라우저를 열지 않습니다.\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"선택창에서 새로운 오브젝트를 추가할 수 없도록 합니다.\",\n                    \"heading\": \"선택창 비활성화\"\n                },\n                \"heading\": \"수정하기\",\n                \"max_options_shown\": {\n                    \"label\": \"선택창에 표시되는 최대 개수\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"평점별 정확도\",\n                        \"options\": {\n                            \"full\": \"1점\",\n                            \"half\": \"0.5점\",\n                            \"quarter\": \"0.25점\",\n                            \"tenth\": \"0.1점\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"평점 시스템 종류\",\n                        \"options\": {\n                            \"decimal\": \"소수점\",\n                            \"stars\": \"별\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"인터랙티브 스크립트 재생의 시간 오프셋(밀리초)입니다.\",\n                \"heading\": \"Funscript 오프셋 (단위: 밀리초)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"접속\",\n                \"server_offset\": {\n                    \"heading\": \"서버 오프셋\"\n                },\n                \"status\": {\n                    \"heading\": \"Handy 연결 상태\"\n                },\n                \"sync\": \"동기화\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"인터랙티브 영상에 사용할 수 있는 Handy 연결 키입니다. 이 키를 설정하면 현재 장면 정보를 handyfeeling.com과 공유할 수 있습니다.\",\n                \"heading\": \"Handy 연결 키\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"이미지 라이트박스\"\n            },\n            \"image_wall\": {\n                \"direction\": \"방향\",\n                \"heading\": \"이미지 월\",\n                \"margin\": \"가장자리 여백 (픽셀)\"\n            },\n            \"images\": {\n                \"heading\": \"이미지\",\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"라이브러리에서 비디오가 비활성화되었을 때, 비디오 파일들(비디오 확장자 참조)은 이미지 클립으로 스캔될 것입니다.\",\n                        \"heading\": \"비디오 확장자를 이미지 클립으로 스캔\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"description\": \"즉시 생성된 경우 디스크에 이미지 썸네일 쓰기.\",\n                        \"heading\": \"이미지 썸네일 디스크에 저장하기\"\n                    }\n                }\n            },\n            \"interactive_options\": \"인터랙티브 설정\",\n            \"language\": {\n                \"heading\": \"언어\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"영상 플레이어가 비디오를 루프하는 최대 영상 지속 시간. 0을 입력하면 비활성화합니다.\",\n                \"heading\": \"최대 구간 길이\"\n            },\n            \"menu_items\": {\n                \"description\": \"탐색 바에 여러 종류의 컨텐츠들이 보여지게 하거나 숨깁니다.\",\n                \"heading\": \"메뉴 항목\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"재생 횟수를 증가시키기 위해 재생되어야 하는 최소 영상 시간(백분율)입니다.\",\n                \"heading\": \"최소 재생 시간(%)\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"배우의 기본 이미지를 저장하기 위한 경로입니다. 빈 칸으로 두면 기본값을 사용합니다.\",\n                        \"heading\": \"커스텀 배우 이미지 경로\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"옵션 기본값은 비디오(mp4) 미리보기입니다. CPU 사용량을 줄이려면 애니메이션(webp) 미리보기를 사용할 수 있습니다. 하지만 '애니메이션 미리보기'는 '비디오 미리보기'에 추가로 생성되어야 하기 때문에 파일 크기가 커집니다.\",\n                \"heading\": \"미리보기 형식\",\n                \"options\": {\n                    \"animated\": \"움직이는 이미지\",\n                    \"static\": \"일반 이미지\",\n                    \"video\": \"비디오\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"그리드 뷰\",\n                \"options\": {\n                    \"show_studio_as_text\": \"스튜디오 로고를 텍스트로 표시\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"영상 플레이어\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"항상 처음부터 비디오 시작\",\n                    \"auto_start_video\": \"비디오 자동 재생\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"대기열, 또는 '영상' 페이지에서 (랜덤)선택한 영상을 자동 재생.\",\n                        \"heading\": \"선택한 항목을 재생할 때 비디오 자동 시작\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"비디오가 끝나면 대기열에 있는 다음 영상을 재생합니다.\",\n                        \"heading\": \"플레이리스트 이어보기\"\n                    },\n                    \"show_scrubber\": \"스크러버 표시\",\n                    \"track_activity\": \"영상 재생 기록 활성화\",\n                    \"vr_tag\": {\n                        \"description\": \"VR 버튼은 이 태그를 가진 영상에서만 보여질 것입니다.\",\n                        \"heading\": \"VR 태그\"\n                    },\n                    \"enable_chromecast\": \"크롬캐스트 활성화\",\n                    \"disable_mobile_media_auto_rotate\": \"모바일 환경에서 전체화면 시 자동 방향 회전 비활성화\",\n                    \"show_ab_loop_controls\": \"구간반복 기능 활성화\",\n                    \"show_range_markers\": \"범위 마커 표시\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"영상 / 마커 미리보기\",\n                \"options\": {\n                    \"display_title\": \"제목과 태그 표시\",\n                    \"toggle_sound\": \"소리 켜기\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"이전/다음 항목으로 이동하기 전에 스크롤을 시도하는 횟수입니다. Y축 스크롤 허용 모드에만 적용됩니다.\",\n                \"heading\": \"전환 전 스크롤 시도 횟수\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"태그 뱃지 위에 마우스 커서를 올리면 태그 카드를 보여줍니다.\",\n                \"heading\": \"태그 카드 툴팁\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"월 보기 모드일 때 갤러리에서 슬라이드 쇼를 사용할 수 있습니다.\",\n                \"heading\": \"슬라이드쇼 딜레이 (단위: 초)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"스튜디오 창\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"스튜디오 창에서, 하위 스튜디오의 컨텐츠도 보여줍니다.\",\n                        \"heading\": \"하위 스튜디오의 컨텐츠 보이기\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"태그 창\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"태그 창에서, 하위 태그들도 보여줍니다.\",\n                        \"heading\": \"서브태그 컨텐츠 보이기\"\n                    }\n                }\n            },\n            \"title\": \"UI\",\n            \"use_stash_hosted_funscript\": {\n                \"heading\": \"funscript 직접 전달\",\n                \"description\": \"활성화되면, 서드 파티 Handy 서버를 사용하지 않고 Stash로부터 Handy 디바이스로 곧바로 funscript가 전달될 것입니다. Stash가 Handy 디바이스에 접근 가능한 상태여야 하고, Stash에서 인증이 설정된 상태라면 API 키가 생성되어 있어야 합니다.\"\n            },\n            \"detail\": {\n                \"enable_background_image\": {\n                    \"description\": \"세부사항 페이지에서 배경 이미지를 보여줍니다.\",\n                    \"heading\": \"배경 이미지 활성화\"\n                },\n                \"heading\": \"세부사항 페이지\",\n                \"compact_expanded_details\": {\n                    \"description\": \"활성화되면, 더 간결한 형태로 확장된 세부사항이 보여집니다.\",\n                    \"heading\": \"확장된 세부사항들 간결화\"\n                },\n                \"show_all_details\": {\n                    \"description\": \"활성화되면, 모든 컨텐츠 세부사항이 기본값으로 보여지게 되고, 각각의 세부사항들이 하나의 열에 위아래로 정렬됩니다.\",\n                    \"heading\": \"모든 세부사항 보여주기\"\n                }\n            },\n            \"performer_list\": {\n                \"heading\": \"배우 목록\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"배우 그리드 카드에 링크 표시\"\n                    }\n                }\n            },\n            \"sfw_mode\": {\n                \"description\": \"건전한 컨텐츠를 저장하기 위해 Stash를 사용한다면 활성화하세요. 성인 컨텐츠와 관련된 UI 요소들을 숨기거나 변경시킵니다.\",\n                \"heading\": \"건전 컨텐츠 모드\"\n            },\n            \"custom_title\": {\n                \"description\": \"페이지 제목에 추가되는 커스텀 텍스트입니다. 공백일 경우 기본값 'Stash'가 사용됩니다.\",\n                \"heading\": \"커스텀 타이틀\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"트러블슈팅 모드\",\n                \"dialog_title\": \"트러블슈팅 모드 활성화\",\n                \"dialog_description\": \"문제 진단을 위해 모든 사용자 지정 설정을 일시적으로 비활성화합니다:\",\n                \"dialog_item_plugins\": \"모든 플러그인\",\n                \"dialog_item_css\": \"커스텀 CSS\",\n                \"dialog_item_js\": \"커스텀 JavaScript\",\n                \"dialog_item_locales\": \"커스텀 로케일\",\n                \"dialog_log_level\": \"상세 진단을 위해 로그 수준이 디버그(Debug)로 설정됩니다.\",\n                \"dialog_reload_note\": \"페이지를 자동으로 다시 불러옵니다.\",\n                \"enable\": \"활성화 & 다시 불러오기\",\n                \"overlay_message\": \"트러블슈팅 모드 활성화 - 모든 사용자 지정 설정이 비활성화됨\",\n                \"exit\": \"나가기\"\n            }\n        },\n        \"advanced_mode\": \"고급 설정 모드\",\n        \"changelog\": {\n            \"header\": \"변경 내역\"\n        }\n    },\n    \"configuration\": \"설정\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {파일} other {파일들}}\",\n        \"galleries\": \"{count, plural, one {갤러리} other {갤러리들}}\",\n        \"images\": \"{count, plural, one {이미지} other {이미지들}}\",\n        \"markers\": \"{count, plural, one {마커} other {마커들}}\",\n        \"performers\": \"{count, plural, one {배우} other {배우들}}\",\n        \"scenes\": \"{count, plural, one {영상} other {영상들}}\",\n        \"studios\": \"{count, plural, one {스튜디오} other {스튜디오들}}\",\n        \"tags\": \"{count, plural, one {태그} other {태그들}}\",\n        \"groups\": \"{count, plural, one {그룹} other {그룹들}}\"\n    },\n    \"country\": \"국적\",\n    \"cover_image\": \"커버 이미지\",\n    \"created_at\": \"만든 날짜\",\n    \"criterion\": {\n        \"greater_than\": \"초과\",\n        \"less_than\": \"미만\",\n        \"value\": \"값\",\n        \"unsupported\": \"{type} (공식 지원되지 않음)\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"구간\",\n        \"equals\": \"=\",\n        \"excludes\": \"제외\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \">\",\n        \"includes\": \"포함\",\n        \"includes_all\": \"모두 포함\",\n        \"is_null\": \"값 없음\",\n        \"less_than\": \"<\",\n        \"matches_regex\": \"정규표현식 일치\",\n        \"not_between\": \"구간 밖\",\n        \"not_equals\": \"≠\",\n        \"not_matches_regex\": \"정규표현식 불일치\",\n        \"not_null\": \"값 존재함\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} ({excludedString} 제외)\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} ({excludedString} 제외) (+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}}수준)\"\n    },\n    \"custom\": \"커스텀\",\n    \"date\": \"날짜\",\n    \"date_format\": \"YYYY년 MM월 DD일\",\n    \"datetime_format\": \"YYYY년 MM월 DD일 HH시 MM분\",\n    \"death_date\": \"사망 날짜\",\n    \"death_year\": \"사망 년도\",\n    \"descending\": \"내림차순\",\n    \"description\": \"설명\",\n    \"detail\": \"세부사항\",\n    \"details\": \"세부사항\",\n    \"developmentVersion\": \"개발 버전\",\n    \"dialogs\": {\n        \"create_new_entity\": \"새로운 {entity} 생성\",\n        \"delete_alert\": \"다음 {count, plural, one {{singularEntity}이(가)} other {{pluralEntity}들이}} 영구 삭제될 것입니다:\",\n        \"delete_confirm\": \"정말 {entityName}을 삭제하시겠습니까?\",\n        \"delete_entity_desc\": \"{count, plural, one {정말로 이 {singularEntity}을(를) 삭제하시겠습니까? 원본 파일 또한 삭제하지 않으면, 스캔을 할 때 이 {singularEntity}이(가) 다시 추가될 것입니다.} other {정말로 이 {pluralEntity}들을 삭제하시겠습니까? 원본 파일 또한 삭제하지 않으면, 스캔을 할 때 이 {pluralEntity}들이 다시 추가될 것입니다.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {정말 이 {singularEntity}을(를) 삭제하시겠습니까?} other {정말 이 {pluralEntity}을(를) 삭제하시겠습니까?}}\",\n        \"delete_entity_title\": \"{count, plural, one {{singularEntity} 삭제} other {{pluralEntity} 삭제}}\",\n        \"delete_galleries_extra\": \"…그리고 다른 어떤 갤러리에도 없는 이미지 파일들까지.\",\n        \"delete_gallery_files\": \"갤러리 폴더/zip 파일 및 다른 어떤 갤러리에도 존재하지 않는 이미지를 삭제합니다.\",\n        \"delete_object_desc\": \"정말로 {count, plural, one {이 {singularEntity}을(를)} other {이 {pluralEntity}}들을} 삭제하시겠습니까?\",\n        \"delete_object_overflow\": \"...그리고 {count} 개의 {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} 삭제\",\n        \"dont_show_until_updated\": \"다음 업데이트까지 보지 않기\",\n        \"edit_entity_title\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} 수정\",\n        \"export_include_related_objects\": \"내보내기 할 때 관련된 개체를 포함합니다\",\n        \"export_title\": \"내보내기\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"열\",\n                \"description\": \"열 또는 행 기반 레이아웃입니다.\",\n                \"row\": \"행\"\n            },\n            \"margin_desc\": \"각각의 전체 이미지 가장자리 여백 픽셀 값입니다.\"\n        },\n        \"lightbox\": {\n            \"delay\": \"딜레이 (단위: 초)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"가로로 맞추기\",\n                \"fit_to_screen\": \"스크린 크기에 맞추기\",\n                \"label\": \"표시 모드\",\n                \"original\": \"기본 모드\"\n            },\n            \"options\": \"옵션\",\n            \"page_header\": \"{total} 페이지 중 {page} 페이지\",\n            \"reset_zoom_on_nav\": \"이미지를 바꿀 때 줌 수준 초기화\",\n            \"scale_up\": {\n                \"description\": \"작은 이미지들이 화면을 채우도록 확대합니다.\",\n                \"label\": \"화면에 딱 맞춰 확대\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"임시로 다른 모드를 사용하려면 Shift 키를 누르세요.\",\n                \"label\": \"스크롤 모드\",\n                \"pan_y\": \"수직 스크롤 모드\",\n                \"zoom\": \"확대\"\n            },\n            \"disable_animation\": \"이미지 간 전환 애니메이션 비활성화하기\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {{singularEntity} 재할당} other {{pluralEntity} 재할당}}\",\n        \"scene_gen\": {\n            \"clip_previews\": \"이미지 클립 미리보기\",\n            \"covers\": \"영상 커버\",\n            \"force_transcodes\": \"강제 트랜스코드 생성\",\n            \"force_transcodes_tooltip\": \"기본적으로 트랜스코드는 비디오 파일이 브라우저에서 지원되지 않는 경우에만 생성됩니다. 이 옵션을 선택하면 비디오 파일이 브라우저에서 지원되는 것으로 보이는 경우에도 트랜스코드가 생성됩니다.\",\n            \"image_previews\": \"움직이는 이미지 미리보기\",\n            \"image_previews_tooltip\": \"애니메이션(webp) 미리보기도 생성합니다. 영상/마커 월 미리보기 유형이 '애니메이션 이미지'로 설정된 경우에만 필요합니다. '비디오 미리보기'보다 CPU를 덜 사용하지만, '비디오 미리보기'에 추가적으로 생성되기 때문에 파일 크기가 커집니다.\",\n            \"interactive_heatmap_speed\": \"인터랙티브 영상을 위한 히트맵 및 스피드 생성\",\n            \"marker_image_previews\": \"마커 움직이는 이미지 미리보기\",\n            \"marker_image_previews_tooltip\": \"애니메이션(webp) 미리보기도 생성합니다. 영상/마커 월 미리보기 유형이 '애니메이션 이미지'로 설정된 경우에만 필요합니다. '비디오 미리보기'보다 CPU를 덜 사용하지만, '비디오 미리보기'에 추가적으로 생성되기 때문에 파일 크기가 커집니다.\",\n            \"marker_screenshots\": \"마커 스크린샷\",\n            \"marker_screenshots_tooltip\": \"마커 고정 JPG 이미지\",\n            \"markers\": \"마커 미리보기\",\n            \"markers_tooltip\": \"주어진 시간 코드에서 시작하는 20초 짜리 비디오입니다.\",\n            \"override_preview_generation_options\": \"미리보기 생성 옵션 재정의\",\n            \"override_preview_generation_options_desc\": \"이 작업에 대한 미리보기 생성 옵션을 재정의합니다. 기본값은 '시스템' -> '미리보기 생성'에서 설정됩니다.\",\n            \"overwrite\": \"기존 파일들 덮어쓰기\",\n            \"phash\": \"비디오 해쉬\",\n            \"preview_exclude_end_time_desc\": \"영상 미리보기에서 마지막 x 초를 제외합니다. 초 단위, 혹은 전체 영상 재생 길이 중 비율(예: 2%)로 나타낼 수 있습니다.\",\n            \"preview_exclude_end_time_head\": \"마지막 영상 부분 제외\",\n            \"preview_exclude_start_time_desc\": \"영상 미리보기에서 처음 x 초를 제외합니다. 초 단위, 혹은 전체 영상 재생 길이 중 비율(예: 2%)로 나타낼 수 있습니다.\",\n            \"preview_exclude_start_time_head\": \"처음 영상 부분 제외\",\n            \"preview_generation_options\": \"미리보기 생성 옵션\",\n            \"preview_options\": \"옵션 미리보기\",\n            \"preview_preset_desc\": \"이 설정은 영상 미리보기의 크기, 품질, 미리보기 생성 인코딩 시간을 조절합니다. 설정값을 높인다고 해서 결과가 비례하여 좋아지는 것이 아니므로, \\\"느림\\\" 이상으로 설정하는 것을 추천하지 않습니다.\",\n            \"preview_preset_head\": \"인코딩 프리셋 미리보기\",\n            \"preview_seg_count_desc\": \"미리보기 파일에서의 사진 개수입니다.\",\n            \"preview_seg_count_head\": \"미리보기의 사진 개수\",\n            \"preview_seg_duration_desc\": \"미리보기 사진이 표시되는 시간입니다 (초).\",\n            \"preview_seg_duration_head\": \"미리보기 사진 길이\",\n            \"sprites\": \"영상 스크러버 스프라이트\",\n            \"sprites_tooltip\": \"원하는 위치를 쉽게 찾기 위해, 비디오 플레이어 아래에 표시되는 이미지들의 모음입니다.\",\n            \"transcodes\": \"트랜스코딩\",\n            \"transcodes_tooltip\": \"모든 컨텐츠에 대해 MP4 트랜스코드 파일이 미리 생성될 것입니다. 느린 컴퓨터에서 유용하지만 디스크 용량을 더 차지합니다\",\n            \"video_previews\": \"미리보기\",\n            \"video_previews_tooltip\": \"영상 위로 마우스를 올렸을 때 표시되는 비디오 미리보기\",\n            \"image_thumbnails\": \"이미지 썸네일\",\n            \"phash_tooltip\": \"중복 방지와 영상 식별에 사용됩니다\",\n            \"image_phash\": \"이미지 해쉬 값\",\n            \"image_phash_tooltip\": \"중복 제거 및 식별 목적\"\n        },\n        \"scenes_found\": \"{count}개 영상 발견됨\",\n        \"scrape_entity_query\": \"{entity_type} 스크레이핑 쿼리\",\n        \"scrape_entity_title\": \"{entity_type} 스크레이핑 결과\",\n        \"scrape_results_existing\": \"존재\",\n        \"scrape_results_scraped\": \"스크레이핑됨\",\n        \"set_image_url_title\": \"이미지 URL\",\n        \"unsaved_changes\": \"저장되지 않은 변경 사항들이 있습니다. 그래도 나가겠습니까?\",\n        \"performers_found\": \"{count} 명의 배우들을 찾았습니다\",\n        \"clear_o_history_confirm\": \"정말 싸버린 기록을 삭제하시겠습니까?\",\n        \"clear_play_history_confirm\": \"정말 재생 기록을 삭제하시겠습니까?\",\n        \"merge\": {\n            \"destination\": \"~으로 병합 (병합 결과)\",\n            \"source\": \"~을 (병합 대상)\",\n            \"empty_results\": \"병합 결과 값이 바뀌지 않을 것입니다.\"\n        },\n        \"reassign_files\": {\n            \"destination\": \"~으로 재지정\"\n        },\n        \"overwrite_filter_warning\": \"저장된 필터 \\\"{entityName}\\\"은 덮어쓰기될 것입니다.\",\n        \"set_default_filter_confirm\": \"정말로 이 필터를 기본값으로 설정하시겠습니까?\",\n        \"clear_o_history_confirm_sfw\": \"정말 좋아요 기록을 삭제하시겠습니까?\",\n        \"delete_alert_to_trash\": \"다음 {count, plural, one {{singularEntity}} other {{pluralEntity}}}가 휴지통으로 옮겨질 것입니다:\",\n        \"stashid_exists_warning\": \"이 Stash-Box의 기존 Stash ID가 교체될 것입니다.\",\n        \"studios_found\": \"{count}개 스튜디오 발견됨\",\n        \"tags_found\": \"{count}개 태그 발견됨\",\n        \"scrape_results_missing\": \"없음\",\n        \"edit_entity_count_title\": \"{count}개의 {count, plural, one {{singularEntity}} other {{pluralEntity}}} 수정\"\n    },\n    \"dimensions\": \"해상도\",\n    \"director\": \"감독\",\n    \"disambiguation\": \"대표 별명\",\n    \"display_mode\": {\n        \"grid\": \"격자\",\n        \"list\": \"목록\",\n        \"tagger\": \"태거\",\n        \"unknown\": \"알 수 없음\",\n        \"wall\": \"월 모드\",\n        \"label_current\": \"디스플레이 모드: {current}\"\n    },\n    \"donate\": \"후원\",\n    \"dupe_check\": {\n        \"description\": \"'정확' 이하의 수준에서는 계산이 오래 걸릴 수 있습니다. 낮은 정밀도 수준에서는 부정확한 결과가 함께 나올 수 있습니다.\",\n        \"duration_diff\": \"최대 영상 길이 차이\",\n        \"duration_options\": {\n            \"any\": \"상관 없음\",\n            \"equal\": \"같음\"\n        },\n        \"found_sets\": \"{setCount, plural, one{# 개의 중복된 파일을 찾았습니다.} other {# 개의 중복된 파일들을 찾았습니다.}}\",\n        \"options\": {\n            \"exact\": \"정확\",\n            \"high\": \"높음\",\n            \"low\": \"낮음\",\n            \"medium\": \"중간\"\n        },\n        \"search_accuracy_label\": \"검색 정밀도\",\n        \"title\": \"중복된 영상\",\n        \"select_oldest\": \"각 중복 그룹 내에서 가장 오래된 파일 선택\",\n        \"only_select_matching_codecs\": \"중복 그룹 내에서 코덱이 모두 일치하는 경우에만 선택\",\n        \"select_all_but_largest_file\": \"각 중복 그룹 내에서 가장 큰 파일을 제외한 모든 파일 선택\",\n        \"select_all_but_largest_resolution\": \"각 중복 그룹 내에서 가장 해상도가 높은 파일을 제외한 모든 파일 선택\",\n        \"select_youngest\": \"각 중복 그룹 내에서 가장 최근 파일 선택\",\n        \"select_options\": \"옵션 선택…\",\n        \"select_none\": \"아무 것도 선택하지 않음\"\n    },\n    \"duplicated_phash\": \"중복됨 (pHash)\",\n    \"duration\": \"길이\",\n    \"effect_filters\": {\n        \"aspect\": \"방향\",\n        \"blue\": \"청색\",\n        \"blur\": \"흐리게\",\n        \"brightness\": \"밝기\",\n        \"contrast\": \"대비\",\n        \"gamma\": \"감마\",\n        \"green\": \"녹색\",\n        \"hue\": \"색상\",\n        \"name\": \"필터\",\n        \"name_transforms\": \"변형\",\n        \"red\": \"적색\",\n        \"reset_filters\": \"필터 초기화\",\n        \"reset_transforms\": \"변형 초기화\",\n        \"rotate\": \"회전\",\n        \"rotate_left_and_scale\": \"왼쪽으로 회전 후 크기 조정\",\n        \"rotate_right_and_scale\": \"오른쪽으로 회전 후 크기 조정\",\n        \"saturation\": \"채도\",\n        \"scale\": \"크기\",\n        \"warmth\": \"따뜻함\"\n    },\n    \"empty_server\": \"이 페이지에서 추천 영상들을 확인하려면 영상을 추가하세요.\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"이미지 번호는 0보다 커야 합니다\",\n        \"lazy_component_error_help\": \"만약 최근에 Stash를 업그레이드했다면, 웹페이지를 새로고침하거나 브라우저 캐시를 삭제해주세요.\",\n        \"something_went_wrong\": \"오류가 발생했습니다.\",\n        \"header\": \"오류\",\n        \"loading_type\": \"{type}을(를) 로딩하는 중 오류가 발생했습니다\",\n        \"invalid_javascript_string\": \"유효하지 않은 자바스크립트 코드입니다: {error}\",\n        \"invalid_json_string\": \"유효하지 않은 JSON 문자열입니다: {error}\",\n        \"custom_fields\": {\n            \"field_name_required\": \"항목 이름이 필요합니다\",\n            \"field_name_whitespace\": \"항목 이름의 전후에 공백이 없어야 합니다\",\n            \"duplicate_field\": \"항목 이름은 중복될 수 없습니다\",\n            \"field_name_length\": \"항목 이름의 글자 수는 65글자보다 작아야 합니다\"\n        }\n    },\n    \"ethnicity\": \"인종\",\n    \"existing_value\": \"존재하는 값\",\n    \"eye_color\": \"눈동자 색\",\n    \"fake_tits\": \"가짜 가슴\",\n    \"false\": \"거짓\",\n    \"favourite\": \"즐겨찾기\",\n    \"file\": \"파일\",\n    \"file_count\": \"파일 개수\",\n    \"file_info\": \"파일 정보\",\n    \"file_mod_time\": \"파일 변경 시각\",\n    \"files\": \"파일\",\n    \"files_amount\": \"{value} 파일\",\n    \"filesize\": \"파일 크기\",\n    \"filter\": \"필터\",\n    \"filter_name\": \"필터 이름\",\n    \"filters\": \"필터\",\n    \"folder\": \"폴더\",\n    \"framerate\": \"프레임 레이트\",\n    \"frames_per_second\": \"fps: {value}\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"사전에 생성된 필터\",\n            \"saved_filter\": \"저장된 필터\"\n        }\n    },\n    \"galleries\": \"갤러리\",\n    \"gallery\": \"갤러리\",\n    \"gallery_count\": \"갤러리 개수\",\n    \"gender\": \"성별\",\n    \"gender_types\": {\n        \"FEMALE\": \"여성\",\n        \"INTERSEX\": \"인터섹스\",\n        \"MALE\": \"남성\",\n        \"NON_BINARY\": \"논바이너리\",\n        \"TRANSGENDER_FEMALE\": \"트랜스젠더 여성\",\n        \"TRANSGENDER_MALE\": \"트랜스젠더 남성\"\n    },\n    \"hair_color\": \"머리카락 색\",\n    \"handy_connection_status\": {\n        \"connecting\": \"접속 중\",\n        \"disconnected\": \"접속 끊김\",\n        \"error\": \"Handy에 접속 중 오류 발생\",\n        \"missing\": \"연결 끊김\",\n        \"ready\": \"준비됨\",\n        \"syncing\": \"서버와 동기화 중\",\n        \"uploading\": \"스크립트 업로드 중\"\n    },\n    \"hasChapters\": \"챕터\",\n    \"hasMarkers\": \"마커\",\n    \"height\": \"키\",\n    \"height_cm\": \"키 (cm)\",\n    \"help\": \"도움말\",\n    \"ignore_auto_tag\": \"자동 태깅 무시하기\",\n    \"image\": \"이미지\",\n    \"image_count\": \"이미지 개수\",\n    \"image_index\": \"이미지 번호\",\n    \"images\": \"이미지\",\n    \"include_parent_tags\": \"상위 태그 포함\",\n    \"include_sub_studios\": \"자회사 스튜디오 포함\",\n    \"include_sub_tags\": \"하위 태그 포함\",\n    \"instagram\": \"인스타그램\",\n    \"interactive\": \"인터랙티브\",\n    \"interactive_speed\": \"인터랙티브 속도\",\n    \"isMissing\": \"데이터 누락됨\",\n    \"last_played_at\": \"마지막 재생 날짜\",\n    \"library\": \"라이브러리\",\n    \"loading\": {\n        \"generic\": \"로드 중…\",\n        \"plugins\": \"플러그인 로딩 중…\"\n    },\n    \"marker_count\": \"마커 개수\",\n    \"markers\": \"마커\",\n    \"measurements\": \"치수\",\n    \"media_info\": {\n        \"audio_codec\": \"오디오 코덱\",\n        \"downloaded_from\": \"다운로드 출처\",\n        \"interactive_speed\": \"인터랙티브 속도\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"제작 당시 {age} {years_old}\"\n        },\n        \"phash\": \"PHash\",\n        \"play_count\": \"재생된 횟수\",\n        \"play_duration\": \"재생된 길이\",\n        \"stream\": \"스트림\",\n        \"video_codec\": \"비디오 코덱\",\n        \"o_count\": \"싼 횟수\",\n        \"md5\": \"MD5 체크섬\",\n        \"oshash\": \"oshash\",\n        \"oshash_meaning\": \"파일 일치 여부 확인 해시 (OpenSubtitles Hash)\",\n        \"phash_meaning\": \"시각적 유사성 비교 해시 (Perceptual Hash)\"\n    },\n    \"megabits_per_second\": \"{value} mbps\",\n    \"metadata\": \"메타데이터\",\n    \"name\": \"이름\",\n    \"new\": \"새로 만들기\",\n    \"none\": \"없음\",\n    \"operations\": \"작업\",\n    \"organized\": \"정리됨\",\n    \"pagination\": {\n        \"first\": \"처음\",\n        \"last\": \"마지막\",\n        \"next\": \"다음\",\n        \"previous\": \"이전\",\n        \"current_total\": \"{current} / {total}\"\n    },\n    \"parent_of\": \"{children}의 상위 태그\",\n    \"parent_studios\": \"모회사 스튜디오\",\n    \"parent_tag_count\": \"상위 태그 개수\",\n    \"parent_tags\": \"상위 태그\",\n    \"part_of\": \"{parent}의 하위 태그\",\n    \"path\": \"경로\",\n    \"penis\": \"자지\",\n    \"penis_length\": \"자지 크기\",\n    \"penis_length_cm\": \"자지 크기 (cm)\",\n    \"perceptual_similarity\": \"유사도 (pHash)\",\n    \"performer\": \"배우\",\n    \"performer_age\": \"배우 나이\",\n    \"performer_count\": \"배우 수\",\n    \"performer_favorite\": \"즐겨찾기한 배우\",\n    \"performer_image\": \"배우 이미지\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"새 배우 추가\",\n        \"any_names_entered_will_be_queried\": \"입력되는 이름들은, 원격 Stash-Box 개체에 존재하면 추가됩니다. 정확하게 일치해야만 합니다.\",\n        \"batch_add_performers\": \"배우 일괄 추가\",\n        \"batch_update_performers\": \"배우 일괄 수정\",\n        \"current_page\": \"현재 페이지\",\n        \"failed_to_save_performer\": \"\\\"{performer}\\\" 배우를 저장하는 데에 실패했습니다\",\n        \"name_already_exists\": \"이름이 이미 존재합니다\",\n        \"network_error\": \"네트워크 오류\",\n        \"no_results_found\": \"결과가 없습니다.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count}명의 배우들이 처리됩니다\",\n        \"performer_already_tagged\": \"배우가 이미 태깅되어 있음\",\n        \"performer_selection\": \"배우 선택\",\n        \"performer_successfully_tagged\": \"배우 태깅에 성공했습니다:\",\n        \"query_all_performers_in_the_database\": \"데이터베이스의 모든 배우\",\n        \"refresh_tagged_performers\": \"태깅된 배우 새로고침\",\n        \"refreshing_will_update_the_data\": \"'새로고침'을 통해, Stash-box 인스턴스로부터 태깅된 배우들의 데이터를 업데이트합니다.\",\n        \"status_tagging_job_queued\": \"상태: 태그 작업 대기열 추가됨\",\n        \"status_tagging_performers\": \"상태: 배우 태깅 중\",\n        \"tag_status\": \"태그 상태\",\n        \"to_use_the_performer_tagger\": \"배우 태거를 사용하기 위해서는 stash-box 인스턴스가 설정되어야 합니다.\",\n        \"untagged_performers\": \"태깅되지 않은 배우\",\n        \"update_performer\": \"배우 업데이트\",\n        \"update_performers\": \"배우 업데이트\",\n        \"updating_untagged_performers_description\": \"'태깅되지 않은 배우 업데이트'를 통해, Stash ID가 없는 배우들에 대한 데이터를 찾아보고, 가능하다면 이를 이용해 배우를 업데이트합니다.\",\n        \"performer_names_or_stashids_separated_by_comma\": \"배우 이름 또는 Stash ID (쉼표(,)로 구분)\"\n    },\n    \"performer_tags\": \"배우 태그\",\n    \"performers\": \"배우\",\n    \"piercings\": \"피어싱\",\n    \"play_count\": \"재생 횟수\",\n    \"play_duration\": \"재생 길이\",\n    \"primary_file\": \"대표 파일\",\n    \"queue\": \"대기열\",\n    \"random\": \"랜덤\",\n    \"rating\": \"별점\",\n    \"recently_added_objects\": \"최근 추가된 {objects}\",\n    \"recently_released_objects\": \"최근 발매된 {objects}\",\n    \"release_notes\": \"업데이트 내역\",\n    \"resolution\": \"해상도\",\n    \"resume_time\": \"재시작 시간\",\n    \"scene\": \"영상\",\n    \"sceneTagger\": \"영상 태거\",\n    \"scene_code\": \"스튜디오 코드\",\n    \"scene_count\": \"영상 개수\",\n    \"scene_created_at\": \"영상 생성 날짜\",\n    \"scene_date\": \"영상 촬영 날짜\",\n    \"scene_id\": \"영상 ID\",\n    \"scene_tags\": \"영상 태그\",\n    \"scene_updated_at\": \"영상 수정 날짜\",\n    \"scenes\": \"영상\",\n    \"scenes_updated_at\": \"영상 수정 날짜\",\n    \"search_filter\": {\n        \"edit_filter\": \"필터 수정\",\n        \"name\": \"필터\",\n        \"saved_filters\": \"저장된 필터\",\n        \"update_filter\": \"필터 업데이트\",\n        \"more_filter_criteria\": \"외 {count} 개\",\n        \"search_term\": \"검색어\"\n    },\n    \"second\": \"초\",\n    \"seconds\": \"초\",\n    \"settings\": \"설정\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"거의 설정을 완료했습니다. 아래 설정들을 확인해주세요. 틀린 내용이 있다면 이전으로 돌아가 변경할 수 있습니다. 내용이 모두 맞다면, '확인'을 눌러 시스템을 생성하세요.\",\n            \"blobs_directory\": \"바이너리 데이터 경로\",\n            \"cache_directory\": \"캐시 파일 경로\",\n            \"configuration_file_location\": \"설정 파일 위치:\",\n            \"database_file_path\": \"데이터베이스 파일 경로\",\n            \"generated_directory\": \"생성된 컨텐츠 폴더\",\n            \"nearly_there\": \"거의 끝났습니다!\",\n            \"stash_library_directories\": \"Stash 라이브러리 폴더\",\n            \"blobs_use_database\": \"<데이터베이스 사용 중>\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"시스템 생성 중\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"오류가 발생했습니다!\",\n            \"something_went_wrong_description\": \"작성했던 내용에 문제가 있는 것 같다면, 뒤로 가서 수정해주세요. 그렇지 않다면, {githubLink}에 버그를 제보하거나 {discordLink}에서 해결 방법을 찾아보세요.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"시스템을 설정하던 도중 오류가 발생했습니다. 오류 내용은 다음과 같습니다: {error}\",\n            \"unable_to_retrieve_system_status\": \"시스템 상태를 복구할 수 없습니다: {error}\",\n            \"unexpected_error\": \"예상치 못한 오류가 발생했습니다: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"파일 경로\",\n            \"up_dir\": \"상위 폴더로\"\n        },\n        \"github_repository\": \"깃허브 저장소\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"백업 데이터베이스 경로 (백업을 하지 않으려면 빈 칸으로 두세요):\",\n            \"backup_recommended\": \"마이그레이션 하기 전 원래 데이터베이스를 백업하는 것을 추천합니다. <code>{defaultBackupPath}</code>에 데이터베이스 복사본을 만들어 드릴 수 있습니다.\",\n            \"migrating_database\": \"데이터베이스 마이그레이션 중\",\n            \"migration_failed\": \"마이그레이션 실패\",\n            \"migration_failed_error\": \"데이터베이스를 마이그레이션 하는 동안 다음 에러가 발생했습니다:\",\n            \"migration_failed_help\": \"올바른 내용을 입력했는지 확인하고 수정한 뒤 다시 시도해보세요. 그렇지 않다면, {githubLink}에 버그를 제보하거나 {discordLink}에서 도움이 될 만한 정보를 찾아보세요.\",\n            \"migration_irreversible_warning\": \"스키마 마이그레이션 작업은 돌이킬 수 없습니다. 마이그레이션이 진행된 이후에는, 데이터베이스가 이전 버전의 Stash와 호환되지 않을 것입니다.\",\n            \"migration_notes\": \"마이그레이션 내역\",\n            \"migration_required\": \"마이그레이션 필요\",\n            \"perform_schema_migration\": \"스키마 마이그레이션 실행\",\n            \"schema_too_old\": \"현재 Stash 데이터베이스의 스키마 버전은 <strong>{databaseSchema}</strong>이고, <strong>{appSchema}</strong> 버전으로 마이그레이션되어야 합니다.이 Stash 버전은 데이터베이스 마이그레이션 없이는 동작하지 않을 것입니다. 마이그레이션을 원하지 않는다면, 데이터베이스 스키마와 일치하는 버전으로 Stash를 다운그레이드해야 합니다.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"데이터베이스 파일 이름 (빈 칸으로 두면 기본값을 사용합니다)\",\n            \"description\": \"다음으로, 컨텐츠를 찾을 위치를 정하고, Stash 데이터베이스, 생성 파일, 캐시 파일이 저장될 위치를 정해야 합니다. 이 설정은 나중에 필요할 때 언제든 바꿀 수 있습니다.\",\n            \"path_to_cache_directory_empty_for_default\": \"캐시 폴더 경로 (빈 칸으로 두면 기본값을 사용합니다)\",\n            \"path_to_generated_directory_empty_for_default\": \"생성되는 파일들을 저장할 폴더 경로 (빈 칸으로 두면 기본값을 사용합니다)\",\n            \"set_up_your_paths\": \"경로를 설정하세요\",\n            \"stash_alert\": \"라이브러리 경로가 선택되지 않았습니다. Stash에 아무 것도 스캔되지 않을 것입니다. 계속 진행하겠습니까?\",\n            \"where_can_stash_store_blobs\": \"어디에 바이너리 데이터를 저장할까요?\",\n            \"where_can_stash_store_blobs_description\": \"Stash는 영상 커버, 배우, 스튜디오, 태그 이미지와 같은 바이너리 데이터를 데이터베이스 혹은 파일 시스템에 저장할 수 있습니다. 기본값으로, Stash는 바이너리 데이터를 <code>blobs</code>라는 파일 시스템 안에 저장합니다. 이것을 변경하고 싶다면, 절대 경로 혹은 (현재 경로의) 상대 경로를 입력하세요. 입력된 경로에 해당 폴더가 없다면 자동으로 생성됩니다.\",\n            \"where_can_stash_store_blobs_description_addendum\": \"또는, 바이너리 데이터를 데이터베이스에 저장하고 싶다면, 이 칸을 빈 칸으로 둘 수 있습니다. <strong>주의:</strong> 이렇게 하면 데이터베이스 파일의 크기가 커지고, 데이터베이스 마이그레이션 시간이 증가될 것입니다.\",\n            \"where_can_stash_store_cache_files\": \"어디에 캐시 파일을 저장할까요?\",\n            \"where_can_stash_store_cache_files_description\": \"HLS/DASH 실시간 스트리밍과 같은 기능들이 동작하기 위해서는, 임시 파일을 저장할 캐시 폴더가 필요합니다. 기본값으로는, 설정 파일이 저장된 폴더 안에 <code>cache</code>라는 폴더가 만들어질 것입니다. 만약 이것을 바꾸고 싶다면, 절대 경로 혹은 (현재 경로의) 상대 경로를 입력해주세요. 입력된 경로에 해당 폴더가 없다면 자동으로 생성됩니다.\",\n            \"where_can_stash_store_its_database\": \"어디에 Stash 데이터베이스를 저장할까요?\",\n            \"where_can_stash_store_its_database_description\": \"Stash는 SQLite 데이터베이스를 사용하여 컨텐츠의 메타데이터를 저장합니다. 기본값으로, 데이터베이스 파일은 설정 파일이 포함된 폴더 안에 <code>stash-go.sqlite</code>라는 이름으로 생성될 것입니다. 데이터베이스 파일 이름을 바꾸고 싶다면, 절대 경로 파일 이름, 혹은 (현재 경로의) 상대 경로 파일 이름을 입력하세요.\",\n            \"where_can_stash_store_its_database_warning\": \"경고: Stash가 동작하는 곳이 아닌 다른 시스템에 데이터베이스를 저장하는 것은 <strong>지원되지 않습니다</strong>! (예시: 데이터베이스를 NAS에 저장하면서 Stash 서버를 다른 컴퓨터에서 돌리는 행위) SQLite는 네트워크를 넘어 사용되도록 만들어지지 않았으며, 이런 행위를 함으로써 데이터베이스가 아주 쉽게 망가지게 될 수 있습니다.\",\n            \"where_can_stash_store_its_generated_content\": \"생성된 컨텐츠를 어디에 저장할까요?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Stash에서는 썸네일, 미리보기, 스프라이트로 사용할 이미지와 비디오 파일을 생성합니다. 여기에는 지원되지 않는 파일 형식들의 변환본도 포함됩니다. Stash에서는 기본값으로, 설정 파일이 위치한 폴더 안에 <code>generated</code> 폴더를 만들 것입니다. 생성된 미디어 파일들이 저장되는 위치를 변경하고 싶다면, 절대 경로 혹은 상대 경로(현재 폴더 기준)를 적어주세요. 적혀진 경로에 해당 폴더가 없다면 자동으로 생성됩니다.\",\n            \"where_is_your_porn_located\": \"컨텐츠가 있는 위치가 어딘가요?\",\n            \"where_is_your_porn_located_description\": \"영상과 이미지가 들어있는 폴더를 추가하세요. 이 폴더들을 스캔하여 비디오와 이미지를 찾을 것입니다.\",\n            \"path_to_blobs_directory_empty_for_default\": \"Blob 폴더 경로 (빈 칸으로 두면 기본값을 사용합니다)\",\n            \"store_blobs_in_database\": \"데이터베이스에 Blob 저장\",\n            \"sfw_content_settings\": \"Stash를 성인물 등이 아닌 건전한 컨텐츠 저장 용도로 사용하시나요?\",\n            \"sfw_content_settings_description\": \"Stash는 사진, 그림, 만화 등등의 건전한 컨텐츠를 관리하기 위해 사용될 수 있습니다. 이 옵션을 활성화하면 일부 UI 동작이 건전한 컨텐츠에 더 적합하도록 조정됩니다.\",\n            \"use_sfw_content_mode\": \"건전 컨텐츠 모드 사용\"\n        },\n        \"stash_setup_wizard\": \"Stash 설정 마법사\",\n        \"success\": {\n            \"getting_help\": \"도움 받기\",\n            \"help_links\": \"문제가 발생하거나 질문, 제안할 점이 있다면, {githubLink}에 이슈를 만들거나, {discordLink}의 커뮤니티를 방문하세요.\",\n            \"in_app_manual_explained\": \"상단 우측에 있는 아이콘({icon})을 통해 매뉴얼을 확인해보세요\",\n            \"next_config_step_one\": \"다음으로 설정 페이지에 갈 것입니다. 설정 페이지에서는 포함하거나 제외시킬 파일 설정, 시스템을 보호할 아이디와 비밀번호 설정, 그리고 그 외 여러 가지 옵션들을 설정합니다.\",\n            \"next_config_step_two\": \"이 설정에 만족한다면, <code>{localized_task}</code> 버튼과 <code>{localized_scan}</code> 버튼을 눌러 여러분의 컨텐츠를 스캔할 수 있습니다.\",\n            \"open_collective\": \"Stash가 지속적으로 업데이트되도록 하기 위해 어떻게 기여할 수 있는지 보려면 {open_collective_link}를 확인해보세요.\",\n            \"support_us\": \"후원\",\n            \"thanks_for_trying_stash\": \"Stash를 사용해주셔서 감사합니다!\",\n            \"welcome_contrib\": \"프로그래밍, 테스팅, 버그 제보, 개선 또는 기능 추가 요청, 사용자 지원 등에 기여하는 것을 환영합니다. Stash 인앱 매뉴얼의 '기여' 항목에서 세부사항을 확인할 수 있습니다.\",\n            \"your_system_has_been_created\": \"성공했습니다! 시스템이 생성되었습니다!\",\n            \"missing_ffmpeg\": \"필수 실행파일인 <code>ffmpeg</code>가 없습니다. 아래 체크박스에 체크함으로써 설정 파일에 ffmpeg를 자동으로 다운로드할 수 있습니다. 또는, '시스템 설정'에서 <code>ffmpeg</code>와 <code>ffprobe</code> 실행파일의 경로를 따로 적어줄 수도 있습니다. 이 실행파일들은 Stash가 작동하는 데에 필수적입니다.\",\n            \"download_ffmpeg\": \"ffmpeg 다운로드\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash에서는 설정 파일(<code>config.yml</code>)을 현재 폴더에서 먼저 찾아보고, 없다면 <code>{fallback_path}</code>에서 찾아봅니다. 또는 Stash를 <code>-c '<설정 파일 경로>'</code> 또는 <code>--config '<설정 파일 경로>'</code> 옵션을 사용해 실행시켜 Stash가 특정한 설정 파일을 읽도록 할 수 있습니다.\",\n            \"in_current_stash_directory\": \"<code>{path}</code> 폴더 안:\",\n            \"in_the_current_working_directory\": \"<code>{path}</code> 안, 현재 작업 폴더:\",\n            \"next_step\": \"그 모든 것을 제외하고, 새로운 시스템 설정을 시작할 준비가 되었다면, 어디에 설정 파일을 저장할지 선택해주세요.\",\n            \"store_stash_config\": \"어디에 Stash 설정 파일을 저장할까요?\",\n            \"unable_to_locate_config\": \"이 화면이 나온다면, 설정 파일을 찾는 데에 실패한 것입니다. 새로운 설정 파일을 만드는 과정을 거쳐야 합니다.\",\n            \"unexpected_explained\": \"예상치 못하게 이 화면이 나온다면, 올바른 폴더에서 Stash를 재실행하거나, 터미널의 경우 <code>-c</code> 플래그와 함께 실행해보세요.\",\n            \"in_the_current_working_directory_disabled\": \"<code>{path}</code> 안, 작업 폴더:\",\n            \"in_the_current_working_directory_disabled_macos\": \"<code>Stash.app</code>의 실행에 지원되지 않습니다,<br></br> <code>stash-macos</code>를 실행해 작업 중인 폴더 안에서 설정하세요\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash에서 다음 설정 파일 경로를 사용합니다: <code>{path}</code>\",\n            \"next_step\": \"새로운 시스템을 설정할 준비가 되었다면, '다음'을 누르세요.\",\n            \"unable_to_locate_specified_config\": \"이 오류 문구가 출력되었다면, 명령어 또는 환경에서 지정된 설정 파일을 찾지 못한 것입니다. 설정 마법사가 새로운 설정 파일을 만드는 과정을 도와줄 것입니다.\"\n        },\n        \"welcome_to_stash\": \"Stash에 오신 것을 환영합니다\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_id_endpoint\": \"Stash ID 엔드포인트 URL\",\n    \"stash_ids\": \"Stash IDs\",\n    \"stashbox\": {\n        \"go_review_draft\": \"초안을 검토하려면 {endpoint_name}(으)로 이동하십시오.\",\n        \"selected_stash_box\": \"Stash-Box 엔드포인트를 선택했습니다\",\n        \"submission_failed\": \"데이터 제출 실패\",\n        \"submission_successful\": \"데이터 제출 성공\",\n        \"submit_update\": \"이미 {endpoint_name}에 있음\",\n        \"source\": \"Stash-Box 소스\"\n    },\n    \"statistics\": \"통계\",\n    \"stats\": {\n        \"image_size\": \"전체 이미지 크기\",\n        \"scenes_duration\": \"전체 영상 길이\",\n        \"scenes_size\": \"전체 영상 크기\",\n        \"scenes_played\": \"재생된 영상\",\n        \"total_o_count\": \"싸버린 총 횟수\",\n        \"total_play_count\": \"총 재생 횟수\",\n        \"total_play_duration\": \"총 재생 길이\",\n        \"total_o_count_sfw\": \"좋아요 총 횟수\"\n    },\n    \"status\": \"상태: {statusText}\",\n    \"studio\": \"스튜디오\",\n    \"studio_depth\": \"수준 (빈 칸으로 두면 전체 선택)\",\n    \"studios\": \"스튜디오\",\n    \"sub_tag_count\": \"하위 태그 개수\",\n    \"sub_tag_of\": \"{parent}의 하위 태그\",\n    \"sub_tags\": \"하위 태그\",\n    \"subsidiary_studios\": \"자회사 스튜디오\",\n    \"synopsis\": \"개요\",\n    \"tag\": \"태그\",\n    \"tag_count\": \"태그 개수\",\n    \"tags\": \"태그\",\n    \"tattoos\": \"문신\",\n    \"title\": \"제목\",\n    \"toast\": {\n        \"added_entity\": \"{singularEntity}을(를) 추가했습니다\",\n        \"added_generation_job_to_queue\": \"컨텐츠 생성 작업을 대기열에 추가했습니다\",\n        \"created_entity\": \"{entity}를 생성했습니다\",\n        \"default_filter_set\": \"기본 필터가 설정되었습니다\",\n        \"delete_past_tense\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}}이(가) 삭제되었습니다\",\n        \"generating_screenshot\": \"스크린샷을 생성하는 중…\",\n        \"image_index_too_large\": \"오류: 이미지 번호가 갤러리의 이미지 개수보다 큽니다\",\n        \"merged_scenes\": \"영상이 병합되었습니다\",\n        \"merged_tags\": \"태그가 병합되었습니다\",\n        \"reassign_past_tense\": \"파일이 재할당되었습니다\",\n        \"removed_entity\": \"{singularEntity}을(를) 제거했습니다\",\n        \"rescanning_entity\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} 다시 스캔하는 중…\",\n        \"saved_entity\": \"{entity}를 저장했습니다\",\n        \"started_auto_tagging\": \"자동 태깅을 시작했습니다\",\n        \"started_generating\": \"컨텐츠 생성을 시작했습니다\",\n        \"started_importing\": \"불러오기를 시작했습니다\",\n        \"updated_entity\": \"{entity}를 수정했습니다\",\n        \"merged_performers\": \"배우가 병합되었습니다\",\n        \"clipboard_access_denied\": \"클립보드 접근 거부됨. 브라우저 권한을 확인하십시오\",\n        \"clipboard_image_pasted\": \"클립보드에서 이미지 복사됨\",\n        \"clipboard_no_image\": \"클립보드에 이미지 없음\"\n    },\n    \"total\": \"전체\",\n    \"true\": \"참\",\n    \"twitter\": \"트위터\",\n    \"type\": \"유형\",\n    \"updated_at\": \"수정 날짜\",\n    \"url\": \"URL\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path}는 YYYY, YYYY-MM, YYYY-MM-DD 형식 중 하나여야 합니다\",\n        \"required\": \"${path}는 필수 항목입니다\",\n        \"unique\": \"${path}은(는) 유일해야 합니다\",\n        \"blank\": \"${path}를 빈 칸으로 둘 수 없습니다\",\n        \"end_time_before_start_time\": \"종료 시간은 시작 시간 이후여야 합니다\"\n    },\n    \"videos\": \"비디오\",\n    \"view_all\": \"모두 보기\",\n    \"weight\": \"몸무게\",\n    \"weight_kg\": \"무게 (kg)\",\n    \"years_old\": \"살\",\n    \"zip_file_count\": \"zip 파일 개수\",\n    \"studio_tagger\": {\n        \"config\": {\n            \"create_parent_label\": \"부모 스튜디오 생성\",\n            \"create_parent_desc\": \"누락된 부모 스튜디오를 만들거나, 정확히 이름이 일치하며 이미 존재하는 부모 스튜디오를 태깅하고 데이터/이미지를 업데이트합니다\"\n        },\n        \"batch_update_studios\": \"스튜디오 일괄 업데이트\",\n        \"name_already_exists\": \"이름이 이미 존재합니다\",\n        \"studio_already_tagged\": \"스튜디오가 이미 태깅되었음\",\n        \"updating_untagged_studios_description\": \"태깅되지 않은 스튜디오를 업데이트하면, stashid가 없는 스튜디오들을 확인하고 메타데이터를 업데이트할 것입니다.\",\n        \"current_page\": \"현재 페이지\",\n        \"failed_to_save_studio\": \"\\\"{studio}\\\" 스튜디오를 저장하는 데에 실패했습니다\",\n        \"refresh_tagged_studios\": \"태깅된 스튜디오 새로고침\",\n        \"add_new_studios\": \"새 스튜디오 추가\",\n        \"any_names_entered_will_be_queried\": \"기입된 이름은 원격 Stash-Box 인스턴스에 검색되고, 만약 해당하는 이름의 스튜디오가 있으면 추가합니다. 이름이 정확히 같은 경우만 고려합니다.\",\n        \"create_or_tag_parent_studios\": \"존재하지 않는 부모 스튜디오를 만드거나, 이미 존재하는 부모 스튜디오 태깅\",\n        \"network_error\": \"네트워크 오류\",\n        \"no_results_found\": \"결과가 없습니다.\",\n        \"number_of_studios_will_be_processed\": \"{studio_count}개의 스튜디오가 처리됩니다\",\n        \"query_all_studios_in_the_database\": \"데이터베이스의 모든 스튜디오\",\n        \"refreshing_will_update_the_data\": \"stash-box 인스턴스로부터 태깅된 스튜디오의 데이터를 업데이트합니다.\",\n        \"studio_successfully_tagged\": \"스튜디오 태깅 성공\",\n        \"tag_status\": \"태그 상태\",\n        \"batch_add_studios\": \"스튜디오 일괄 추가\",\n        \"status_tagging_job_queued\": \"상태: 태깅 작업 대기열 추가됨\",\n        \"status_tagging_studios\": \"상태: 스튜디오 태깅 중\",\n        \"to_use_the_studio_tagger\": \"스튜디오 태거를 사용하려면, stash-box 인스턴스가 설정되어야 합니다.\",\n        \"update_studios\": \"스튜디오 업데이트\",\n        \"untagged_studios\": \"태깅되지 않은 스튜디오\",\n        \"update_studio\": \"스튜디오 업데이트\",\n        \"studio_selection\": \"스튜디오 선택\",\n        \"studio_names_or_stashids_separated_by_comma\": \"스튜디오 이름 또는 StashID (쉼표로 구분)\"\n    },\n    \"audio_codec\": \"오디오 코덱\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"웹소켓 연결 불가: 세부적인 이유는 브라우저의 콘솔을 확인하세요 (F12 -> Console)\",\n        \"websocket_connection_reestablished\": \"웹소켓이 다시 연결되었습니다\"\n    },\n    \"plays\": \"{value}회 재생\",\n    \"photographer\": \"사진가\",\n    \"history\": \"기록\",\n    \"index_of_total\": \"총 {total}개 중 {index}\",\n    \"o_history\": \"싸버린 기록\",\n    \"odate_recorded_no\": \"싸버린 날짜 기록 없음\",\n    \"package_manager\": {\n        \"no_upgradable\": \"업그레이드 가능한 패키지 없음\",\n        \"package\": \"패키지\",\n        \"uninstall\": \"삭제\",\n        \"unknown\": \"<알 수 없음>\",\n        \"confirm_delete_source\": \"정말 패키지 소스 {name} ({url})을 삭제하시겠습니까?\",\n        \"confirm_uninstall\": \"정말 {number} 개의 패키지를 삭제하시겠습니까?\",\n        \"description\": \"설명\",\n        \"edit_source\": \"패키지 소스 수정\",\n        \"hide_unselected\": \"선택되지 않은 요소 숨기기\",\n        \"source\": {\n            \"name\": \"이름\",\n            \"url\": \"패키지 소스 URL\",\n            \"local_path\": {\n                \"description\": \"이 패키지 소스의 패키지들을 저장하기 위한 상대경로입니다. 이 항목을 변경하면 패키지들을 수동으로 옮겨주어야 합니다.\",\n                \"heading\": \"로컬 경로\"\n            }\n        },\n        \"install\": \"설치\",\n        \"add_source\": \"패키지 소스 추가\",\n        \"check_for_updates\": \"업데이트 확인\",\n        \"installed_version\": \"설치된 버전\",\n        \"latest_version\": \"최신 버전\",\n        \"no_packages\": \"패키지 없음\",\n        \"no_sources\": \"패키지 소스 없음\",\n        \"version\": \"버전\",\n        \"show_all\": \"모두 보여주기\",\n        \"update\": \"업데이트\",\n        \"selected_only\": \"선택된 것만\",\n        \"required_by\": \"{packages}가 정상 동작하기 위해 설치되어야 함\"\n    },\n    \"o_count\": \"싼 횟수\",\n    \"orientation\": \"방향\",\n    \"parent_studio\": \"부모 스튜디오\",\n    \"subsidiary_studio_count\": \"자회사 스튜디오 개수\",\n    \"time\": \"시간\",\n    \"video_codec\": \"비디오 코덱\",\n    \"last_o_at\": \"마지막으로 싼 날짜\",\n    \"playdate_recorded_no\": \"재생 날짜 기록 없음\",\n    \"play_history\": \"재생 기록\",\n    \"primary_tag\": \"주 태그\",\n    \"unknown_date\": \"날짜 미상\",\n    \"urls\": \"URL\",\n    \"distance\": \"거리\",\n    \"studio_and_parent\": \"스튜디오 & 모회사\",\n    \"tag_parent_tooltip\": \"상위 태그 존재 여부\",\n    \"tag_sub_tag_tooltip\": \"하위 태그 존재 여부\",\n    \"group\": \"그룹\",\n    \"group_count\": \"그룹 개수\",\n    \"group_scene_number\": \"영상 번호\",\n    \"groups\": \"그룹\",\n    \"studio_tags\": \"스튜디오 태그\",\n    \"containing_groups\": \"그룹 포함\",\n    \"containing_group_count\": \"그룹 개수 포함\",\n    \"studio_count\": \"스튜디오 개수\",\n    \"containing_group\": \"그룹 포함\",\n    \"include_sub_group_content\": \"서브그룹 컨텐츠 포함\",\n    \"sub_group_count\": \"서브그룹 개수\",\n    \"sub_group_order\": \"서브그룹 순서\",\n    \"sub_groups\": \"서브그룹\",\n    \"sub_group\": \"서브그룹\",\n    \"sub_group_of\": \"{parent}의 서브그룹\",\n    \"include_sub_studio_content\": \"서브스튜디오 컨텐츠 포함\",\n    \"include_sub_tag_content\": \"서브태그 컨텐츠 포함\",\n    \"time_end\": \"종료 시간\",\n    \"include_sub_groups\": \"서브그룹 포함\",\n    \"custom_fields\": {\n        \"value\": \"값\",\n        \"field\": \"항목\",\n        \"title\": \"커스텀 항목\",\n        \"criteria_format_string\": \"{criterion} (커스텀 항목) {modifierString} {valueString}\",\n        \"criteria_format_string_others\": \"{criterion} (커스텀 항목) {modifierString} {valueString} (+{others} 기타)\"\n    },\n    \"login\": {\n        \"password\": \"비밀번호\",\n        \"invalid_credentials\": \"유효하지 않은 사용자 이름 또는 비밀번호입니다\",\n        \"login\": \"로그인\",\n        \"internal_error\": \"예상치 못한 내부 에러입니다. 로그에서 세부 사항을 확인하세요\",\n        \"username\": \"사용자 이름\"\n    },\n    \"age_on_date\": \"제작 당시 {age}살\",\n    \"sort_name\": \"이름 (sort name)\",\n    \"criterion_modifier_values\": {\n        \"none\": \"값 없음\",\n        \"only\": \"해당 값만 존재\",\n        \"any\": \"값 존재\",\n        \"any_of\": \"해당 값 중 일부 포함\"\n    },\n    \"eta\": \"예상 소요 시간\",\n    \"scenes_duration\": \"영상 길이\",\n    \"last_o_at_sfw\": \"마지막 좋아요 날짜\",\n    \"o_count_sfw\": \"좋아요\",\n    \"o_history_sfw\": \"좋아요 기록\",\n    \"odate_recorded_no_sfw\": \"좋아요 날짜 기록 없음\",\n    \"stashbox_search\": {\n        \"header\": \"StashBox로부터 {entityType} 탐색\",\n        \"no_results\": \"탐색 결과가 없습니다.\",\n        \"placeholder_name_or_id\": \"({entityType} 이름 또는 StashID를 입력하세요)\",\n        \"select_stashbox\": \"StashBox 선택...\"\n    },\n    \"latest_scene\": \"최근 영상\",\n    \"stash_id_count\": \"Stash ID 카운트\",\n    \"career_end\": \"경력 종료\",\n    \"career_start\": \"경력 시작\",\n    \"duplicated\": \"중복됨\",\n    \"duplicated_stash_id\": \"중복됨 (Stash ID)\",\n    \"duplicated_title\": \"중복됨 (제목)\",\n    \"tag_tagger\": {\n        \"status_tagging_tags\": \"상태: 표준화 작업 중\",\n        \"tag_already_tagged\": \"태그가 이미 표준화됨\",\n        \"tag_names_or_stashids_separated_by_comma\": \"태그 이름 또는 StashID (\\\",\\\"로 구분)\",\n        \"tag_selection\": \"태그 선택\",\n        \"tag_successfully_tagged\": \"태그 표준화됨\",\n        \"tag_status\": \"태그 상태\",\n        \"to_use_the_tag_tagger\": \"태그 표준화 도구를 사용하려면 stash-box 인스턴스를 구성해야 합니다.\",\n        \"untagged_tags\": \"비표준 태그\",\n        \"update_tags\": \"태그 업데이트\",\n        \"updating_untagged_tags_description\": \"비표준 태그 업데이트 작업을 하면, StashId가 없는 비표준 태그의 매칭을 시도하고 메타데이터를 업데이트합니다.\",\n        \"add_new_tags\": \"새로운 태그 추가\",\n        \"any_names_entered_will_be_queried\": \"입력된 이름은 원격 Stash-Box 인스턴스에서 조회되어, 발견되면 추가됩니다. 정확히 일치하는 이름만 일치하는 것으로 간주됩니다.\",\n        \"batch_add_tags\": \"여러 개의 태그 추가\",\n        \"batch_update_tags\": \"여러 개의 태그 수정\",\n        \"current_page\": \"현재 페이지\",\n        \"failed_to_save_tag\": \"태그 \\\"{tag}\\\"를 저장하는 데에 실패했습니다\",\n        \"name_already_exists\": \"이름이 이미 존재합니다\",\n        \"network_error\": \"네트워크 오류\",\n        \"no_results_found\": \"결과가 없습니다.\",\n        \"number_of_tags_will_be_processed\": \"{tag_count}개의 태그가 처리됩니다\",\n        \"query_all_tags_in_the_database\": \"데이터베이스 내 모든 태그\",\n        \"refresh_tagged_tags\": \"표준 태그 새로고침\",\n        \"refreshing_will_update_the_data\": \"새로고침하면, Stash-Box 인스턴스로부터 표준 태그의 데이터를 업데이트할 것입니다.\",\n        \"status_tagging_job_queued\": \"상태: 표준화 작업 대기열 추가됨\",\n        \"config\": {\n            \"create_parent_desc\": \"stash-box 카테고리에서 누락된 부모 태그를 만들거나, 기존 부모 태그를 정확한 이름과 연결하는 태그를 지정합니다\",\n            \"create_parent_label\": \"부모 태그 만들기\"\n        },\n        \"create_or_tag_parent_tags\": \"누락된 부모 태그 생성 또는 기존 태그 지정\"\n    },\n    \"tagger\": {\n        \"config\": {\n            \"active_stash-box_instance\": \"활성 stash-box 인스턴스:\",\n            \"edit_excluded_fields\": \"제외된 항목 편집\",\n            \"excluded_fields\": \"제외된 항목:\",\n            \"fields_will_not_be_changed\": \"{entity}를 업데이트할 때 이 항목은 변경되지 않습니다.\",\n            \"no_fields_are_excluded\": \"제외된 항목 없음\",\n            \"no_instances_found\": \"인스턴스 없음\"\n        }\n    },\n    \"unsupported_criteria\": \"지원되지 않는 기준: {criteria}\",\n    \"include_sub_folders\": \"하위 폴더 포함\",\n    \"parent_folder\": \"상위 폴더\",\n    \"sub_folder_depth\": \"하위 폴더 깊이 (전체일 경우 비워둠)\",\n    \"sub_folders\": \"하위 폴더\",\n    \"empty_value\": \"공백\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/lt-LT.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Pridėti\",\n        \"add_directory\": \"Pridėti katalogą\",\n        \"add_entity\": \"Pridėti {entityType}\",\n        \"add_manual_date\": \"Įvesti datą rankini būdu\",\n        \"add_sub_groups\": \"Pridėti pogrupius\",\n        \"add_o\": \"Pridėti O\",\n        \"add_to_entity\": \"Pridėti prie {entityType}\",\n        \"allow\": \"Leisti\",\n        \"allow_temporarily\": \"Leisti laikinai\",\n        \"anonymise\": \"Anonimizuoti\",\n        \"apply\": \"Taikyti\",\n        \"assign_stashid_to_parent_studio\": \"Priskirti Stash ID esamai pagrindinei studijai ir atnaujinti metaduomenis\",\n        \"auto_tag\": \"Auto žymė\",\n        \"browse_for_image\": \"Ieškoti vaizdo…\",\n        \"cancel\": \"Atšaukti\",\n        \"choose_date\": \"Pasirinkti datą\",\n        \"clean\": \"Valyti\",\n        \"clean_generated\": \"Išvalyti sugeneruotus failus\",\n        \"clear\": \"Valyti\",\n        \"clear_date_data\": \"Valyti datos duomenis\",\n        \"clear_front_image\": \"Valyti priekinį vaizdą\",\n        \"clear_back_image\": \"Valyti galinį vaizdą\",\n        \"clear_image\": \"Valyti vaizdą\",\n        \"close\": \"Uždaryti\",\n        \"confirm\": \"Patvirtinti\",\n        \"continue\": \"Tęsti\",\n        \"copy_to_clipboard\": \"Kopijuoti į iškarpinę\",\n        \"create\": \"Sukurti\",\n        \"create_chapters\": \"Sukurti skyrių\",\n        \"create_entity\": \"Sukurti {entityType}\",\n        \"create_marker\": \"Sukurti žymeklį\",\n        \"create_parent_studio\": \"Sukurti pagrindinę studiją\",\n        \"created_entity\": \"Sukurtas {entity_type}: {entity_name}\",\n        \"customise\": \"Tinkinti\",\n        \"delete\": \"Ištrinti\",\n        \"delete_entity\": \"Ištrinti {entityType}\",\n        \"delete_file\": \"Ištrinti failą\",\n        \"delete_file_and_funscript\": \"Ištrinti failą (ir funscript)\",\n        \"delete_generated_supporting_files\": \"Ištrinti sugeneruotus pagalbinius failus\",\n        \"disable\": \"Išjungti\",\n        \"disallow\": \"Neleisti\",\n        \"download\": \"Atsisiųsti\",\n        \"download_anonymised\": \"Atsisiųsti anonimizuotą\",\n        \"download_backup\": \"Atsisiųsti atsarginę kopiją\",\n        \"edit\": \"Redaguoti\",\n        \"edit_entity\": \"Redaguoti {entityType}\",\n        \"enable\": \"Įjungti\",\n        \"encoding_image\": \"Koduojamas vaizdas…\",\n        \"export\": \"Eksportuoti\",\n        \"export_all\": \"Eksportuoti visus…\",\n        \"find\": \"Rasti\",\n        \"finish\": \"Baigti\",\n        \"from_file\": \"Iš failo…\",\n        \"from_url\": \"Iš URL…\",\n        \"full_export\": \"Pilnas eksportas\",\n        \"full_import\": \"Pilnas importas\",\n        \"generate\": \"Generuoti\",\n        \"generate_thumb_default\": \"Sugeneruoti numatytąją miniatiūrą\",\n        \"generate_thumb_from_current\": \"Sugeneruoti miniatiūrą iš dabartinio\",\n        \"hide\": \"Paslėpti\",\n        \"hide_configuration\": \"Paslėpti konfiguraciją\",\n        \"identify\": \"Atpažinti\",\n        \"ignore\": \"Ignoruoti\",\n        \"import\": \"Importuoti…\",\n        \"import_from_file\": \"Importuoti iš failo\",\n        \"load\": \"Krauti\",\n        \"load_filter\": \"Užkrauti filtrą\",\n        \"logout\": \"Atsijungti\",\n        \"make_primary\": \"Padaryti pirminiu\",\n        \"merge\": \"Sulieti\",\n        \"next_action\": \"Kitas\",\n        \"not_running\": \"Nevykdomas\",\n        \"open_in_external_player\": \"Atidaryti išoriniame grotuve\",\n        \"open_random\": \"Atidaryti atsitiktinį\",\n        \"optimise_database\": \"Optimizuoti duomenų bazę\",\n        \"overwrite\": \"Perrašyti\",\n        \"play\": \"Groti\",\n        \"play_random\": \"Groti atsitiktinį\",\n        \"play_selected\": \"Groti pasirinktą\",\n        \"preview\": \"Peržiūra\",\n        \"previous_action\": \"Atgal\",\n        \"reassign\": \"Priskirti iš naujo\",\n        \"refresh\": \"Atnaujinti\",\n        \"reload\": \"Perkrauti\",\n        \"reload_plugins\": \"Perkrauti papildinius\",\n        \"reload_scrapers\": \"Perkrauti skreperius\",\n        \"remove\": \"Šalinti\",\n        \"remove_date\": \"Šalinti datą\",\n        \"remove_from_containing_group\": \"Šalinti iš grupės\",\n        \"remove_from_gallery\": \"Šalinti iš galerijos\",\n        \"rename_gen_files\": \"Pervadinti sugeneruotus failus\",\n        \"rescan\": \"Pakartotinai nuskaityti\",\n        \"reset_play_duration\": \"Atstatyti grojimo trukmę\",\n        \"reset_resume_time\": \"Atstatyti tęsimo laiką\",\n        \"reset_cover\": \"Atkurti numatytąjį viršelį\",\n        \"reshuffle\": \"Permaišyti\",\n        \"running\": \"Vykdoma\",\n        \"save\": \"Išsaugoti\",\n        \"save_delete_settings\": \"Naudoti šias parinktis kaip numatytąsias trinant\",\n        \"save_filter\": \"Išsaugoti filtrą\",\n        \"scan\": \"Skenuoti\",\n        \"search\": \"Ieškoti\",\n        \"select_all\": \"Pasirinkti visus\",\n        \"select_entity\": \"Pasirinkti {entityType}\",\n        \"select_folders\": \"Pasirinkti katalogus\",\n        \"select_none\": \"Atžymėti viską\"\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/lv-LV.json",
    "content": "{\n    \"true\": \"Patiess\",\n    \"total\": \"Kopā\",\n    \"toast\": {\n        \"updated_entity\": \"Atjaunināta {entītija}\",\n        \"started_generating\": \"Sākta ģenerēšana\",\n        \"started_auto_tagging\": \"Sākta automātiskā marķēšana\",\n        \"saved_entity\": \"Saglabāta {entity}\",\n        \"rescanning_entity\": \"Notiek {count, plural, one {{singularEntity}} cita {{pluralEntity}}} atkārtota skenēšana …\",\n        \"started_importing\": \"Sākta importēšana\"\n    },\n    \"view_all\": \"Skatīt visu\",\n    \"videos\": \"Video\",\n    \"video_codec\": \"Video Kodekss\",\n    \"validation\": {\n        \"unique\": \"${path} jābūt unikālai\",\n        \"required\": \"${path} ir nepieciešams laiks\",\n        \"blank\": \"${path} nedrīkst būt tukša\",\n        \"date_invalid_form\": \"${path} jābūt GGGG-MM-DD formā\"\n    },\n    \"urls\": \"Saites\",\n    \"url\": \"Saite\",\n    \"actions\": {\n        \"cancel\": \"Atcelt\",\n        \"delete\": \"Dzēst\",\n        \"add\": \"Pievienot\",\n        \"add_play\": \"Pievienot atskaņošanu\",\n        \"delete_generated_supporting_files\": \"Dzēst ģenerētos atbalsta failus\",\n        \"clean_generated\": \"Iztīrīt izveidotos failus\",\n        \"copy_to_clipboard\": \"Kopēt starpliktuvē\",\n        \"add_directory\": \"Pievienot direktoriju\",\n        \"add_entity\": \"Pievienot {EntityType}\",\n        \"clear_image\": \"Notīrīt bildi\",\n        \"create\": \"Izveidot\",\n        \"assign_stashid_to_parent_studio\": \"Piešķirt pagaidu ID esošajai vecākstudijai un atjaunināt metadatus\",\n        \"delete_entity\": \"Dzēst {EntityType}\",\n        \"create_parent_studio\": \"Izveidot pamatstudiju\",\n        \"clear_date_data\": \"Notīrīt visus datus\",\n        \"add_manual_date\": \"Pievienot manuālo datumu\",\n        \"add_o\": \"Pievienot O\",\n        \"add_to_entity\": \"Pievienot {EntityType}\",\n        \"allow\": \"Atļaut\",\n        \"allow_temporarily\": \"Atļaut īslaicīgi\",\n        \"anonymise\": \"Anonimizēt\",\n        \"apply\": \"Lietot\",\n        \"auto_tag\": \"Automātiskā atzīme\",\n        \"backup\": \"Dublēšana\",\n        \"browse_for_image\": \"Meklēt attēlu …\",\n        \"choose_date\": \"Izvēlieties datumu\",\n        \"clean\": \"Tīrīt\",\n        \"clear\": \"Notīrīt\",\n        \"clear_back_image\": \"Notīrīt aizmugurējo bildi\",\n        \"clear_front_image\": \"Notīrīt priekšējo bildi\",\n        \"close\": \"Aizvērt\",\n        \"confirm\": \"Apstiprināt\",\n        \"continue\": \"Turpināt\",\n        \"create_chapters\": \"Izveidot nodaļu\",\n        \"create_entity\": \"Izveidot {EntityType}\",\n        \"create_marker\": \"Izveidot marķieri\",\n        \"created_entity\": \"Izveidots {entity_type}: {entity_name}\",\n        \"customise\": \"Pielāgot\",\n        \"delete_file\": \"Dzēst failu\",\n        \"delete_file_and_funscript\": \"Dzēst failu (un funskriptu)\",\n        \"add_sub_groups\": \"Pievienot apakšgrupas\",\n        \"from_file\": \"No Faila…\",\n        \"from_url\": \"No URL…\",\n        \"disallow\": \"Aizliegt\",\n        \"download\": \"Lejupielādēt\",\n        \"download_anonymised\": \"Lejupielādēt anonīmi\",\n        \"download_backup\": \"Lejupielādēt Dublējumu\",\n        \"edit\": \"Rediģēt\",\n        \"edit_entity\": \"Rediģēt {entityType}\",\n        \"enable\": \"Iespējot\",\n        \"encoding_image\": \"Konstruē bildi…\",\n        \"export_all\": \"Eksportēt visu…\",\n        \"find\": \"Atrast\",\n        \"finish\": \"Pabeigt\",\n        \"generate\": \"Ģenerēt\",\n        \"hash_migration\": \"hasha migrācija\",\n        \"hide_configuration\": \"Paslēpt Konfigurāciju\",\n        \"identify\": \"Identificēt\",\n        \"ignore\": \"Ignorēt\",\n        \"import\": \"Ievietot…\",\n        \"hide\": \"Paslēpt\",\n        \"make_primary\": \"Padarīt primāro\",\n        \"migrate_blobs\": \"Migrēt Blobus\",\n        \"next_action\": \"Nākamais\",\n        \"not_running\": \"nestrādā\",\n        \"open_in_external_player\": \"Atvērt ārējā atskaņotājā\",\n        \"open_random\": \"Atvērt nejaušu\",\n        \"optimise_database\": \"Optimizēt Datubāzi\",\n        \"overwrite\": \"Pārrakstīt\",\n        \"play_random\": \"Atskaņot nejaušu\",\n        \"play_selected\": \"Atskaņot izvēlēto\",\n        \"preview\": \"Priekšskatīt\",\n        \"previous_action\": \"Atpakaļ\",\n        \"reload\": \"Pārlādēt\",\n        \"reload_plugins\": \"Pārlādēt spraudņus\",\n        \"refresh\": \"Atsvaidzināt\",\n        \"disable\": \"Atspējot\",\n        \"export\": \"Izgūt\",\n        \"logout\": \"Izrakstīties\",\n        \"full_export\": \"Pilns Eksports\",\n        \"full_import\": \"Pilns Imports\",\n        \"generate_thumb_default\": \"Ģenerēt Noklusējuma Sīktēlu\",\n        \"generate_thumb_from_current\": \"Ģenerēt sīktēlu no pašreizējā\",\n        \"import_from_file\": \"Importēt no faila\",\n        \"merge\": \"Apvienot\",\n        \"migrate_scene_screenshots\": \"Migrēt Video Ekrānšāviņus\",\n        \"save\": \"Saglabāt\",\n        \"search\": \"Meklēt\",\n        \"skip\": \"Izlaist\",\n        \"split\": \"Sadalīt\",\n        \"stop\": \"Apturēt\",\n        \"submit\": \"Iesniegt\",\n        \"remove\": \"Noņemt\",\n        \"rescan\": \"Skenēt pa jaunu\",\n        \"scan\": \"Skenēt\",\n        \"show\": \"Rādīt\"\n    },\n    \"unknown_date\": \"Nezināms datums\",\n    \"twitter\": \"Twitter\",\n    \"updated_at\": \"Atjaunināts plkst.\",\n    \"type\": \"Tips\",\n    \"zip_file_count\": \"Zip Failu Skaits\",\n    \"weight_kg\": \"Svars (kg)\",\n    \"weight\": \"Svars\",\n    \"years_old\": \"Gadus vecs\",\n    \"component_tagger\": {\n        \"config\": {\n            \"query_mode_filename\": \"Datnes nosaukums\",\n            \"blacklist_label\": \"Melnais saraksts\",\n            \"query_mode_dir\": \"Mape\",\n            \"query_mode_metadata\": \"Metadati\"\n        }\n    },\n    \"actions_name\": \"Darbības\",\n    \"age\": \"Vecums\",\n    \"aliases\": \"Aizstājvārdi\",\n    \"all\": \"visi\",\n    \"ascending\": \"Augošā secībā\",\n    \"between_and\": \"un\",\n    \"birthdate\": \"Dzimšanas datums\",\n    \"blobs_storage_type\": {\n        \"filesystem\": \"Datņsistēma\",\n        \"database\": \"Datubāze\"\n    },\n    \"captions\": \"Subtitri\",\n    \"chapters\": \"Nodaļas\",\n    \"config\": {\n        \"categories\": {\n            \"security\": \"Drošība\",\n            \"tools\": \"Rīki\",\n            \"changelog\": \"Izmaiņu žurnāls\",\n            \"plugins\": \"Spraudņi\"\n        },\n        \"general\": {\n            \"plugins_path\": {\n                \"description\": \"Ceļš uz spraudņu konfigurācijas mapi\",\n                \"heading\": \"Spraudņu mape\"\n            }\n        },\n        \"plugins\": {\n            \"available_plugins\": \"Pieejamie spraudņi\",\n            \"installed_plugins\": \"Uzstādītie spraudņi\"\n        },\n        \"tasks\": {\n            \"cleanup_desc\": \"Meklēt trūkstošos failus un noņemt tos no datubāzes. Šī darbība ir neatgriezeniska.\"\n        },\n        \"about\": {\n            \"check_for_new_version\": \"Pārbaudīt, vai pieejama jauna versija\"\n        }\n    },\n    \"donate\": \"Ziedot\",\n    \"package_manager\": {\n        \"check_for_updates\": \"Pārbaudīt, vai pieejami atjauninājumi\"\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/nb-NO.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Legg til\",\n        \"anonymise\": \"Anonymiser\",\n        \"confirm\": \"Bekreft\",\n        \"continue\": \"Fortsett\",\n        \"close\": \"Lukk\",\n        \"reset_cover\": \"Tilbakestill Standard Forsidebilde\",\n        \"remove\": \"Fjern\",\n        \"running\": \"kjører\",\n        \"submit_stash_box\": \"Send til Stash-Box\",\n        \"delete_generated_supporting_files\": \"Slett genererte støttende filer\",\n        \"select_entity\": \"Velg {entityType}\",\n        \"copy_to_clipboard\": \"Kopier til utklippstavle\",\n        \"delete_file_and_funscript\": \"Slett fil (og funscript)\",\n        \"clear_front_image\": \"Fjern front bilde\",\n        \"next_action\": \"Neste\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Er du sikker du vil Rydde opp? Dette vil slette databaseinformasjon og generert innhold for alle scener og gallerier som ikke lenger finnes i filsystemet.\",\n            \"dry_mode_selected\": \"Tørrmodus valgt. Ingen faktisk sletting vil finne sted, kun logging.\",\n            \"import_warning\": \"Er du sikker på at du vil importere? Dette vil slette databasen og re-importere fra dine eksporterte metadata.\"\n        },\n        \"generate_thumb_default\": \"Generer standard miniatyrbilde\",\n        \"scrape_query\": \"Skrape forespørsel\",\n        \"reset_play_duration\": \"Tilbakestill avspillingsvarigheten\",\n        \"reset_resume_time\": \"Tilbakestill gjenoppta tid\",\n        \"save\": \"Lagre\",\n        \"save_delete_settings\": \"Bruk disse alternativene som standard når du sletter\",\n        \"save_filter\": \"Lagre filter\",\n        \"scan\": \"Skann\",\n        \"scrape\": \"Skrap\",\n        \"create\": \"Opprett\",\n        \"create_chapters\": \"Opprett Kapittel\",\n        \"create_marker\": \"Opprett Markør\",\n        \"delete\": \"Slett\",\n        \"delete_file\": \"Slett fil\",\n        \"disable\": \"Deaktiver\",\n        \"download\": \"Last ned\",\n        \"download_backup\": \"Last ned Sikkerhetskopi\",\n        \"edit\": \"Rediger\",\n        \"edit_entity\": \"Rediger {entityType}\",\n        \"enable\": \"Aktiver\",\n        \"export\": \"Eksporter\",\n        \"find\": \"Finn\",\n        \"finish\": \"Fullfør\",\n        \"from_file\": \"Fra fil…\",\n        \"from_url\": \"Fra URL…\",\n        \"generate\": \"Generer\",\n        \"generate_thumb_from_current\": \"Generer miniatyrbilde fra nåværende\",\n        \"hide\": \"Skjul\",\n        \"hide_configuration\": \"Skjul Konfigurasjon\",\n        \"identify\": \"Identifiser\",\n        \"ignore\": \"Ignorer\",\n        \"import\": \"Importer…\",\n        \"add_sub_groups\": \"Legg til undergrupper\",\n        \"create_entity\": \"Opprett {entityType}\",\n        \"delete_entity\": \"Slett {entityType}\",\n        \"encoding_image\": \"Omsetter bilde til kode…\",\n        \"merge\": \"Slå sammen\",\n        \"created_entity\": \"Opprettet {entity_type}: {entity_name}\",\n        \"clean_generated\": \"Rydd opp i genererte filer\",\n        \"clear\": \"Fjern\",\n        \"clear_back_image\": \"Fjern bakbilde\",\n        \"clear_date_data\": \"Fjern dato data\",\n        \"clear_image\": \"Fjern Bilde\",\n        \"create_parent_studio\": \"Opprett foreldre studio\",\n        \"customise\": \"Tilpass\",\n        \"disallow\": \"Forby\",\n        \"download_anonymised\": \"Last ned anonymisert\",\n        \"export_all\": \"Eksporter alle…\",\n        \"full_export\": \"Eksporter alle\",\n        \"full_import\": \"Importer alle\",\n        \"hash_migration\": \"hash migrering\",\n        \"make_primary\": \"Gjør til Primær\",\n        \"previous_action\": \"Tilbake\",\n        \"refresh\": \"Oppdater\",\n        \"reload\": \"Last inn på nytt\",\n        \"not_running\": \"Kjører ikke\",\n        \"open_in_external_player\": \"Åpne i ekstern spiller\",\n        \"remove_date\": \"Fjern dato\",\n        \"remove_from_containing_group\": \"Fjern fra Gruppe\",\n        \"remove_from_gallery\": \"Fjern fra Galleri\",\n        \"scrape_with\": \"Skrap med…\",\n        \"search\": \"Søk\",\n        \"select_all\": \"Velg Alle\",\n        \"select_folders\": \"Velg mapper\",\n        \"select_none\": \"Velg Ingen\",\n        \"selective_scan\": \"Selektiv Skann\",\n        \"set_as_default\": \"Sett som standard\",\n        \"set_front_image\": \"Frontbilde…\",\n        \"show\": \"Vis\",\n        \"show_configuration\": \"Vis Konfigurasjon\",\n        \"skip\": \"Hopp over\",\n        \"split\": \"Splitt\",\n        \"stop\": \"Stopp\",\n        \"submit_update\": \"Send inn oppdatering\",\n        \"submit\": \"Send inn\",\n        \"swap\": \"Bytt\",\n        \"temp_disable\": \"Deaktiver midlertidig…\",\n        \"temp_enable\": \"Aktiver midlertidig…\",\n        \"unset\": \"Velg bort\",\n        \"use_default\": \"Bruk standard\",\n        \"view_history\": \"Visningshistorikk\",\n        \"view_random\": \"Vis Tilfeldig\",\n        \"migrate_blobs\": \"Migrer Blobs\",\n        \"migrate_scene_screenshots\": \"Flytt Scene Skjermbilder\",\n        \"reassign\": \"Tilordne på nytt\",\n        \"reload_plugins\": \"Last inn programtillegg på nytt\",\n        \"reload_scrapers\": \"Last inn skrapere på nytt\",\n        \"scrape_scene_fragment\": \"Skrap etter fragment\",\n        \"set_back_image\": \"Baksidebilde…\",\n        \"set_cover\": \"Velg som Omslag\",\n        \"allow\": \"Tillat\",\n        \"allow_temporarily\": \"Tillat midlertidig\",\n        \"backup\": \"Sikkerhetskopi\",\n        \"browse_for_image\": \"Bla gjennom bilder…\",\n        \"cancel\": \"Avbryt\",\n        \"apply\": \"Bruk\",\n        \"assign_stashid_to_parent_studio\": \"Tildel Stash ID til eksisterende foreldre studio og oppdater metadata\",\n        \"add_to_entity\": \"Legg til {entityType}\",\n        \"add_entity\": \"Legg til {entityType}\",\n        \"add_manual_date\": \"Legg til manuell dato\",\n        \"add_directory\": \"Legg til mappe\",\n        \"add_o\": \"Legg til O\",\n        \"add_play\": \"Legg til avspilling\",\n        \"auto_tag\": \"Automatisk Tagging\",\n        \"choose_date\": \"Velg en dato\",\n        \"clean\": \"Rydd opp\",\n        \"import_from_file\": \"Importer fra fil\",\n        \"logout\": \"Logg ut\",\n        \"overwrite\": \"Overskriv\",\n        \"preview\": \"Forhåndsvis\",\n        \"optimise_database\": \"Optimaliser Database\",\n        \"play_random\": \"Spill av Tilfeldig\",\n        \"open_random\": \"Åpne tilfeldig\",\n        \"play_selected\": \"Spill av valgte\",\n        \"rescan\": \"Skann på nytt\",\n        \"reshuffle\": \"Stokk om\",\n        \"rename_gen_files\": \"Gi genererte filer nytt navn\",\n        \"selective_auto_tag\": \"Selektiv Auto Tag\",\n        \"set_image\": \"Velg bilde…\",\n        \"selective_clean\": \"Selektiv Fjerning\",\n        \"sidebar\": {\n            \"close\": \"lukk sidebar\",\n            \"open\": \"åpne sidebar\",\n            \"toggle\": \"Endre sidepanelet\"\n        },\n        \"show_results\": \"Vis resultater\",\n        \"show_count_results\": \"Vis {count} resultater\",\n        \"play\": \"Spill av\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"mark_organized_desc\": \"Marker scenen som Organisert umiddelbart etter klikk på Lagre-knappen.\",\n            \"mark_organized_label\": \"Merk som Organisert ved lagring\",\n            \"query_mode_auto_desc\": \"Bruk metadata hvis tilstede, eller filnavn\",\n            \"blacklist_label\": \"Svarteliste\",\n            \"query_mode_auto\": \"Auto\",\n            \"query_mode_dir_desc\": \"Bruker kun mappen som inneholder videofilen\",\n            \"query_mode_filename\": \"Filnavn\",\n            \"query_mode_label\": \"Forespørselsmodus\",\n            \"active_instance\": \"Aktiv stash-box instans:\",\n            \"blacklist_desc\": \"Svartelistede elementer blir ekskludert fra forespørsler. Merk at disse er regulære uttrykk som ikke skiller mellom store og små bokstaver. Enkelte tegn må angis ved hjelp av en omvendt skråstrek: {chars_require_escape}\",\n            \"query_mode_dir\": \"Mappe\",\n            \"query_mode_filename_desc\": \"Bruker kun filnavn\",\n            \"query_mode_metadata\": \"Metadata\",\n            \"query_mode_metadata_desc\": \"Bruker kun metadata\",\n            \"query_mode_path\": \"Filsti\",\n            \"query_mode_path_desc\": \"Bruker hele filstien\",\n            \"set_cover_desc\": \"Bytt ut scenens omslagsbilde hvis det finnes.\",\n            \"source\": \"Kilde\",\n            \"set_cover_label\": \"Angi omslagsbilde for scenen\",\n            \"set_tag_label\": \"Angi tagger\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Dupliser element fra svarteliste\"\n            },\n            \"set_tag_desc\": \"Tilknytt tagger til scenen, enten ved å overskrive eller flette med eksisterende tagger på scenen.\"\n        },\n        \"noun_query\": \"Forespørsel\",\n        \"results\": {\n            \"duration_off\": \"Varighet avviker fra forventet verdi med minst {number}s\",\n            \"duration_unknown\": \"Ukjent varighet\",\n            \"phash_matches\": \"{count} PHash-er stemmer overens\",\n            \"unnamed\": \"Uten navn\",\n            \"hash_matches\": \"{hash_type} stemmer overens\",\n            \"match_failed_already_tagged\": \"Scenen er allerede tagget\",\n            \"match_failed_no_result\": \"Ingen resultater funnet\",\n            \"fp_found\": \"{fpCount, plural, =0 {Ingen nye fingeravtrykksmatch funnet} other {# nye fingeravtrykksmatch funnet}}\",\n            \"fp_matches\": \"Varighet stemmer overens\",\n            \"fp_matches_multi\": \"Varighet samsvarer med {matchCount} av {durationsLength} fingeravtrykk\",\n            \"match_success\": \"Scenen ble tagget vellykket\"\n        },\n        \"verb_match_fp\": \"Match Fingeravtrykk\",\n        \"verb_matched\": \"Matchet\",\n        \"verb_scrape_all\": \"Skrap alle\",\n        \"verb_submit_fp\": \"Send inn {fpCount, plural, one{# Fingeravtrykk} andre{# Fingerprints}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} vis scener uten treff\"\n    },\n    \"config\": {\n        \"dlna\": {\n            \"default_ip_whitelist\": \"Standard IP hviteliste\",\n            \"allowed_ip_temporarily\": \"Tillatt IP midlertidig\",\n            \"default_ip_whitelist_desc\": \"Standard IP-adresser gir tilgang til DLNA. Bruk {wildcard} for å tillatte alle IP-adresser.\",\n            \"recent_ip_addresses\": \"Nylige IP-adresser\",\n            \"disabled_dlna_temporarily\": \"Deaktivert DLNA midlertidig\",\n            \"disallowed_ip\": \"Ikke tillatt IP\",\n            \"enabled_by_default\": \"Aktivert som standard\",\n            \"enabled_dlna_temporarily\": \"Aktiverte DLNA midlertidig\",\n            \"network_interfaces\": \"Grensesnitt\",\n            \"allow_temp_ip\": \"Tillatt {tempIP}\",\n            \"allowed_ip_addresses\": \"Tillatt IP adresser\",\n            \"server_port\": \"Serverport\",\n            \"server_display_name\": \"Server Visningsnavn\",\n            \"server_display_name_desc\": \"Visningsnavn for DLNA-serveren. Standard til {server_navn} hvis tom.\",\n            \"server_port_desc\": \"Port for å kjøre DLNA-serveren på. Krever omstart av DLNA etter endring.\",\n            \"until_restart\": \"frem til omstart\",\n            \"network_interfaces_desc\": \"Grensesnitt for å eksponere DLNA-server på. En tom liste resulterer i å kjøre på alle grensesnitt. Krever omstart av DLNA etter endring.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Vellykket kansellert midlertidig oppførsel\",\n            \"video_sort_order_desc\": \"Rekkefølgen som videoer sorteres etter som standard.\",\n            \"video_sort_order\": \"Standard videosortering\"\n        },\n        \"about\": {\n            \"stash_open_collective\": \"Støtt oss gjennom {url}\",\n            \"version\": \"Versjon\",\n            \"new_version_notice\": \"[NY]\",\n            \"release_date\": \"Utgivelsesdato:\",\n            \"stash_discord\": \"Bli med på vår {url} kanal\",\n            \"check_for_new_version\": \"Sjekk for ny versjon\",\n            \"latest_version\": \"Siste versjon\",\n            \"latest_version_build_hash\": \"Siste Versjon Build Hash:\",\n            \"build_time\": \"Kompileringstid:\",\n            \"stash_wiki\": \"stash {url} side\",\n            \"build_hash\": \"bygg hash:\",\n            \"stash_home\": \"Stash-startside på {url}\"\n        },\n        \"advanced_mode\": \"Avansert Modus\",\n        \"categories\": {\n            \"about\": \"Om\",\n            \"changelog\": \"Endringslogg\",\n            \"interface\": \"Grensesnitt\",\n            \"logs\": \"Logger\",\n            \"plugins\": \"Plugins\",\n            \"security\": \"Sikkerhet\",\n            \"services\": \"Tjenester\",\n            \"system\": \"System\",\n            \"metadata_providers\": \"Metadataleverandører\",\n            \"scraping\": \"Skarping\",\n            \"tools\": \"Verktøy\",\n            \"tasks\": \"Oppgaver\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"username\": \"Brukernavn\",\n                \"api_key\": \"API-nøkkel\",\n                \"log_file\": \"Log-fil\",\n                \"generate_api_key\": \"Generer API-nøkkel\",\n                \"log_to_terminal\": \"Log til terminal\",\n                \"password\": \"Passord\",\n                \"log_http_desc\": \"Logger HTTP-tilgang til terminalen. Krever omstart.\",\n                \"authentication\": \"Autentisering\",\n                \"clear_api_key\": \"Fjern API-nøkkel\",\n                \"credentials\": {\n                    \"description\": \"Brukernavn og passord for å sikre tilgang til Stash.\",\n                    \"heading\": \"Påloggingsinformasjon\"\n                },\n                \"log_file_desc\": \"Sti til filen hvor logg skal lagres. La feltet stå tomt for å deaktivere fillogging. Krever omstart.\",\n                \"log_http\": \"Logg HTTP-tilgang\",\n                \"log_to_terminal_desc\": \"Logger til terminalen i tillegg til en fil. Er alltid aktivert hvis fillogging er deaktivert. Krever omstart.\",\n                \"maximum_session_age\": \"Maksimal levetid for økt\",\n                \"maximum_session_age_desc\": \"Maksimal inaktiv tid før en innloggingsøkt utløper, i sekunder. Krever omstart.\",\n                \"password_desc\": \"Passord for å få tilgang til Stash. La stå tomt for å deaktivere brukergodkjenning\",\n                \"stash-box_integration\": \"ntegrasjon med Stash-box\",\n                \"username_desc\": \"Brukernavn for å få tilgang til Stash. La stå tomt for å deaktivere brukergodkjenning\",\n                \"api_key_desc\": \"API-nøkkel for eksterne systemer. Kun nødvendig når brukernavn/passord er konfigurert. Brukernavn må lagres før API-nøkkel kan genereres.\"\n            },\n            \"db_path_head\": \"Database filbane\",\n            \"ffmpeg\": {\n                \"download_ffmpeg\": {\n                    \"heading\": \"Last ned FFmpeg\",\n                    \"description\": \"Laster ned FFmpeg til konfigurasjonsmappen og tilbakestiller ffmpeg- og ffprobe-banene til å bruke konfigurasjonsmappen i stedet.\"\n                },\n                \"hardware_acceleration\": {\n                    \"heading\": \"Maskinvareakselerert koding med FFmpeg\",\n                    \"desc\": \"Bruker tilgjengelig maskinvare til å kode video for sanntids-transkoding.\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"Bane til ffmpeg-programmet (ikke bare mappen). Hvis den er tom, vil ffmpeg bli funnet fra miljøet via $PATH, konfigurasjonsmappen eller fra $HOME/.stash\",\n                    \"heading\": \"FFmpeg kjørbar filbane\"\n                },\n                \"ffprobe_path\": {\n                    \"heading\": \"FFmpeg kjørbar filbane\",\n                    \"description\": \"Bane til ffprobe-kjørbar fil (ikke bare mappen). Hvis feltet er tomt, vil ffprobe bli hentet fra miljøet via $PATH, konfigurasjonsmappen eller fra $HOME/.stash\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"heading\": \"FFmpeg sanntids-transkoding inndata-argumenter\",\n                        \"desc\": \"Avansert: Ekstra argumenter som sendes til ffmpeg før inndatafeltet ved sanntids-transkoding av video.\"\n                    },\n                    \"output_args\": {\n                        \"heading\": \"FFmpeg sanntids-transkoding utdata-argumenter\",\n                        \"desc\": \"Avansert: Ekstra argumenter som sendes til ffmpeg før utdatafeltet ved sanntids-transkoding av video.\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avansert: Ekstra argumenter som sendes til ffmpeg før inndatafeltet ved generering av video.\",\n                        \"heading\": \"FFmpeg transkoding inndata-argumenter\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avansert: Ekstra argumenter som sendes til ffmpeg før utdatafeltet ved generering av video.\",\n                        \"heading\": \"FFmpeg transkoding utdata-argumenter\"\n                    }\n                }\n            },\n            \"database\": \"Database\",\n            \"backup_directory_path\": {\n                \"heading\": \"Sti til sikkerhetskopimappe\",\n                \"description\": \"Mappeplassering for sikkerhetskopier av SQLite-databasefilen\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Hvor binærdata som scenecovers, bilder av skuespillere, studioer og tagger skal lagres. Etter at denne verdien endres, må eksisterende data migreres ved hjelp av oppgaven “Migrer blobs”. Se oppgavesiden for migrering.\",\n                \"heading\": \"Lagringstype for binærdata\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Hvor i filsystemet binærdata skal lagres. Gjelder kun når filsystem er valgt som lagringstype for blob-data. ADVARSEL: Endring av dette krever at eksisterende data flyttes manuelt.\",\n                \"heading\": \"Sti for lagring av binærdata\"\n            },\n            \"cache_path_head\": \"Sti til hurtigbuffer\",\n            \"chrome_cdp_path\": \"Sti til Chrome CDP\",\n            \"cache_location\": \"Mappeplassering for hurtigbufferen. Påkrevd ved strømming med HLS (f.eks. på Apple-enheter) eller DASH.\",\n            \"calculate_md5_and_ohash_desc\": \"Beregn MD5-sjekksum i tillegg til oshash. Å aktivere dette vil gjøre innledende skanninger tregere. Filnavn-hash må være satt til oshash for å deaktivere MD5-beregning.\",\n            \"calculate_md5_and_ohash_label\": \"Beregn MD5 for videoer\",\n            \"check_for_insecure_certificates\": \"Sjekk etter usikre sertifikater\",\n            \"check_for_insecure_certificates_desc\": \"Noen nettsteder bruker usikre SSL-sertifikater. Når dette er avhuket, hopper skraperen over kontrollen av sertifikater og tillater skraping av slike nettsteder. Hvis du får en sertifikatfeil under skraping, fjern avhukingen her.\",\n            \"chrome_cdp_path_desc\": \"Filsti til Chrome-kjørbar fil, eller en ekstern adresse (som begynner med http:// eller https://, for eksempel http://localhost:9222/json/version) til en Chrome-instans.\",\n            \"create_galleries_from_folders_desc\": \"Hvis aktivert, opprettes gallerier automatisk fra mapper som inneholder bilder. Opprett en fil kalt .forcegallery eller .nogallery i en mappe for å henholdsvis tvinge eller hindre dette.\",\n            \"create_galleries_from_folders_label\": \"Lag gallerier automatisk fra bildemapper\",\n            \"directory_locations_to_your_content\": \"Stier til innholdskataloger\",\n            \"excluded_image_gallery_patterns_desc\": \"Regexps av bilde og galleri filer/filbaner å utelukke fra Scan og legge til Clean\",\n            \"excluded_video_patterns_desc\": \"Regexps av video filer/filbaner å utelukke fra Scan og legge til Clean\",\n            \"excluded_image_gallery_patterns_head\": \"Utelukkede Bilde-/Gallerimønstre\",\n            \"excluded_video_patterns_head\": \"Utelukkede Videomønstre\",\n            \"image_ext_head\": \"Bilde-filendelser\",\n            \"plugins_path\": {\n                \"description\": \"Mappestedsplassering for plugin-konfigurasjonsfiler\",\n                \"heading\": \"Plugin-bane\"\n            },\n            \"scrapers_path\": {\n                \"heading\": \"Scraper-bane\",\n                \"description\": \"Mappestedsplassering for scraper-konfigurasjonsfiler\"\n            },\n            \"scraping\": \"Scraping\",\n            \"logging\": \"Logging\",\n            \"maximum_transcode_size_head\": \"Maksimal transkodingsstørrelse\",\n            \"funscript_heatmap_draw_range\": \"Inkluder område i genererte varmekart\",\n            \"funscript_heatmap_draw_range_desc\": \"Tegn bevegelsesområde på y-aksen i det genererte varmekartet. Eksisterende varmekart må genereres på nytt etter endring.\",\n            \"generated_file_naming_hash_head\": \"Hash for generert filnavngivning\",\n            \"generated_path_head\": \"Generert bane\",\n            \"maximum_streaming_transcode_size_head\": \"Maksimal størrelse for strømmingstranskoding\",\n            \"maximum_transcode_size_desc\": \"Maksimal størrelse for genererte transkodinger\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Antall parallelle oppgaver for skanning/generering\",\n            \"scraper_user_agent_desc\": \"User-Agent-streng som brukes under HTTP-forespørsler for scraping\",\n            \"gallery_cover_regex_desc\": \"Regexp som brukes for å identifisere et bilde som galleriomslag\",\n            \"include_audio_desc\": \"Inkluderer lydstrøm ved generering av forhåndsvisninger.\",\n            \"include_audio_head\": \"Inkluder lyd\",\n            \"maximum_streaming_transcode_size_desc\": \"Maksimal størrelse for transkodede strømmer\",\n            \"metadata_path\": {\n                \"heading\": \"Metadatabane\",\n                \"description\": \"Mappestedsplassering som brukes ved full eksport eller import\"\n            },\n            \"python_path\": {\n                \"description\": \"Bane til Python-kjørbar fil (ikke bare mappen). Brukes for skript-skapere og plugins. Hvis tomt, vil Python bli hentet fra miljøet\",\n                \"heading\": \"Python kjørbar filbane\"\n            },\n            \"generated_file_naming_hash_desc\": \"Bruk MD5 eller oshash for generert filnavngivning. Endring av dette krever at alle scener har den aktuelle MD5/oshash-verdien fylt ut. Etter å ha endret denne verdien, må eksisterende genererte filer migreres eller genereres på nytt. Se Oppgaver-siden for migrering.\",\n            \"heatmap_generation\": \"Funscript-varmekartgenerering\",\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Sett til 0 for automatisk deteksjon. Advarsel: Å kjøre flere oppgaver enn nødvendig for å oppnå 100 % CPU-utnyttelse vil redusere ytelsen og kan potensielt forårsake andre problemer.\",\n            \"sqlite_location\": \"Filplassering for SQLite-databasen (krever omstart). ADVARSEL: Å lagre databasen på et annet system enn der Stash-serveren kjører (f.eks. over nettverket) støttes ikke!\",\n            \"image_ext_desc\": \"Kommaseparert liste over filendelser som vil bli identifisert som bilder.\",\n            \"parallel_scan_head\": \"Parallell skanning/generering\",\n            \"video_ext_desc\": \"Kommaseparert liste over filendelser som vil bli identifisert som videoer.\",\n            \"gallery_cover_regex_label\": \"Mønster for galleriomslag\",\n            \"gallery_ext_desc\": \"Kommaseparert liste over filendelser som vil bli identifisert som galleri-zip-filer.\",\n            \"gallery_ext_head\": \"Galleri-zip-filendelser\",\n            \"generated_files_location\": \"Mappestedsplassering for de genererte filene (scene-markører, scene-forhåndsvisninger, sprites, osv.)\",\n            \"hashing\": \"Hashing\",\n            \"preview_generation\": \"Forhåndsvisningsgenerering\",\n            \"scraper_user_agent\": \"Scraper-brukeragent\",\n            \"video_ext_head\": \"Video-filendelser\",\n            \"video_head\": \"Video\"\n        },\n        \"application_paths\": {\n            \"heading\": \"applikasjon baner\"\n        },\n        \"tasks\": {\n            \"plugin_tasks\": \"Plugin-oppgaver\",\n            \"generate_thumbnails_during_scan\": \"Generer miniatyrbilder for bilder\",\n            \"identify\": {\n                \"source\": \"Kilde\",\n                \"tag_skipped_matches\": \"Tagg hoppet over treff med\",\n                \"and_create_missing\": \"og skape mangler\",\n                \"create_missing\": \"Lag mangler\",\n                \"description\": \"Angi automatisk scenemetadata ved hjelp av stash-box- og scraper-kilder.\",\n                \"field\": \"Felt\",\n                \"identifying_scenes\": \"Identifisering av {num} {scene}\",\n                \"include_male_performers\": \"Inkluder mannlige utøvere\",\n                \"set_cover_images\": \"Sett forsidebilder\",\n                \"tag_skipped_performers\": \"Tagg hoppet over utøvere med\",\n                \"default_options\": \"Standardalternativer\",\n                \"set_organized\": \"Sett organisert flagg\",\n                \"identifying_from_paths\": \"Identifisere scener fra følgende stier\",\n                \"tag_skipped_performer_tooltip\": \"Opprett en tagg som «Identifiser: Enkeltnavnsutøver» som du kan filtrere etter i scenetaggervisningen, og velg hvordan du vil håndtere disse utøverne\",\n                \"explicit_set_description\": \"Følgende alternativer vil bli brukt der de ikke overstyres i de kildespesifikke alternativene.\",\n                \"field_behaviour\": \"{strategi} {felt}\",\n                \"field_options\": \"Feltalternativer\",\n                \"heading\": \"Identifisere\",\n                \"skip_multiple_matches\": \"Hopp over treff som har mer enn ett resultat\",\n                \"skip_multiple_matches_tooltip\": \"Hvis dette ikke er aktivert og mer enn ett resultat returneres, vil ett bli tilfeldig valgt for å matche\",\n                \"skip_single_name_performers\": \"Hopp over utøvere med ett enkelt navn uten entydighet\",\n                \"skip_single_name_performers_tooltip\": \"Hvis dette ikke er aktivert, vil utøvere som ofte er generiske, som Samantha eller Olga, bli matchet\",\n                \"source_options\": \"{kilde} Alternativer\",\n                \"sources\": \"Kilder\",\n                \"strategy\": \"Strategi\",\n                \"tag_skipped_matches_tooltip\": \"Opprett en tagg som «Identifiser: Flere treff» som du kan filtrere etter i Scene Tagger-visningen og velge riktig treff manuelt\"\n            },\n            \"rescan\": \"Skann filer på nytt\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Automatisk tagging av alle stier\",\n                \"auto_tagging_paths\": \"Automatisk tagging av følgende stier\"\n            },\n            \"auto_tag_based_on_filenames\": \"Automatisk tagging av innhold basert på filstier.\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blob-filer\",\n                \"description\": \"Fjerner genererte filer uten en tilhørende databaseoppføring.\",\n                \"image_thumbnails\": \"Bildeminiatyrer\",\n                \"markers\": \"Markørforhåndsvisninger\",\n                \"previews\": \"Sceneforhåndsvisninger\",\n                \"sprites\": \"Scenesprites\",\n                \"transcodes\": \"Scenetranskodinger\",\n                \"image_thumbnails_desc\": \"Bildeminiatyrer og klipp\",\n                \"previews_desc\": \"Sceneforhåndsvisninger og miniatyrbilder\"\n            },\n            \"dont_include_file_extension_as_part_of_the_title\": \"Ikke inkluder filtypen som en del av tittelen\",\n            \"generate_video_previews_during_scan\": \"Generer forhåndsvisninger\",\n            \"generated_content\": \"Generert innhold\",\n            \"import_from_exported_json\": \"Importer fra eksportert JSON i metadatakatalogen. Sletter den eksisterende databasen.\",\n            \"incremental_import\": \"Trinnvis import fra en levert eksport-zip-fil.\",\n            \"job_queue\": \"Oppgavekø\",\n            \"maintenance\": \"Vedlikehold\",\n            \"anonymising_database\": \"Anonymisering av database\",\n            \"backing_up_database\": \"Sikkerhetskopiering av database\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generer forhåndsvisninger av videoer som spilles av når du holder musepekeren over en scene\",\n            \"added_job_to_queue\": \"La til {operation_name} i jobbkøen\",\n            \"anonymise_and_download\": \"Lager en anonymisert kopi av databasen og laster ned den resulterende filen.\",\n            \"anonymise_database\": \"Lager en kopi av databasen i backup-mappen, og anonymiserer all sensitiv informasjon. Denne kan deretter deles med andre for feilsøking og debugging. Den opprinnelige databasen blir ikke endret. Den anonymiserte databasen bruker filnavnformatet {filename_format}.\",\n            \"cleanup_desc\": \"Sjekk etter manglende filer og fjern dem fra databasen. Dette er en destruktiv handling.\",\n            \"defaults_set\": \"Standardinnstillinger er satt og vil bli brukt når du klikker på {action}-knappen på Oppgaver-siden.\",\n            \"migrate_scene_screenshots\": {\n                \"description\": \"Migrer skjermbilder av scener til det nye blob-lagringssystemet. Denne migreringen bør kjøres etter at et eksisterende system er migrert til 0.20. Kan eventuelt slette de gamle skjermbildene etter migreringen.\",\n                \"delete_files\": \"Slett skjermbildefiler\",\n                \"overwrite_existing\": \"Overskriv eksisterende blobs med skjermbildedata\"\n            },\n            \"data_management\": \"Databehandling\",\n            \"generate_previews_during_scan_tooltip\": \"Generer også animerte (webp) forhåndsvisninger, som bare er nødvendig når Forhåndsvisningstype for scene/markørvegg er satt til Animert bilde. Når du surfer, bruker de mindre CPU enn videoforhåndsvisningene, men genereres i tillegg til dem og er større filer.\",\n            \"generate_video_covers_during_scan\": \"Generer sceneomslag\",\n            \"optimise_database_warning\": \"Advarsel: Mens denne oppgaven kjører, vil alle operasjoner som endrer databasen mislykkes, og avhengig av databasestørrelsen kan det ta flere minutter å fullføre. Den krever også minst like mye ledig diskplass som databasen er stor, men 1,5 ganger anbefales.\",\n            \"auto_tagging\": \"Automatisk tagging\",\n            \"backup_and_download\": \"Utfører en sikkerhetskopi av databasen og laster ned den resulterende filen.\",\n            \"empty_queue\": \"Ingen oppgaver kjører for øyeblikket.\",\n            \"export_to_json\": \"Eksporterer databaseinnholdet til JSON-format i metadata-mappen.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Genererer for scener fra følgende stier\",\n                \"generating_scenes\": \"Genererer for {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"Generer forhåndsvisninger for bildeklipp\",\n            \"generate_desc\": \"Generer støttefiler for bilde, sprite, video, vtt og andre filer.\",\n            \"generate_phashes_during_scan\": \"perseptuelle\",\n            \"generate_phashes_during_scan_tooltip\": \"For deduplisering og sceneidentifikasjon.\",\n            \"generate_previews_during_scan\": \"Generer animerte bildeforhåndsvisninger\",\n            \"generate_sprites_during_scan\": \"Generer scrubber-sprites\",\n            \"generate_sprites_during_scan_tooltip\": \"Bildesettet som vises under videospilleren for enkel navigering.\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Slett gamle data\",\n                \"description\": \"Migrer blober til gjeldende blob-lagringssystem. Denne migreringen bør kjøres etter at blob-lagringssystemet er endret. Kan eventuelt slette gamle data etter migrering.\"\n            },\n            \"migrate_hash_files\": \"Brukes etter endring av den genererte filnavngivningshashen for å gi eksisterende genererte filer nytt navn til det nye hashformatet.\",\n            \"migrations\": \"Migrasjoner\",\n            \"only_dry_run\": \"Utfør kun en prøvekjøring. Ikke fjern noe\",\n            \"optimise_database\": \"Forsøk å forbedre ytelsen ved å analysere og deretter gjenoppbygge hele databasefilen.\",\n            \"rescan_tooltip\": \"Skann alle filer i banen på nytt. Brukes til å tvinge frem oppdatering av filmetadata og skanne zip-filer på nytt.\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Skanner alle stier\",\n                \"scanning_paths\": \"Skanner følgende stier\"\n            },\n            \"scan_for_content_desc\": \"Skann etter nytt innhold og legg det til i databasen.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Angi navn, dato og detaljer fra innebygde filmetadata\"\n        },\n        \"ui\": {\n            \"scene_player\": {\n                \"options\": {\n                    \"vr_tag\": {\n                        \"heading\": \"VR Tag\",\n                        \"description\": \"VR-knappen vises bare for scener med denne taggen.\"\n                    },\n                    \"always_start_from_beginning\": \"Start alltid videoen fra begynnelsen\",\n                    \"auto_start_video\": \"Autostart video\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Start scenevideoer automatisk når du spiller av fra køen, eller spiller av valgte eller tilfeldige scener fra scenesiden\",\n                        \"heading\": \"Start video automatisk når den valgte videoen spilles av\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Spill av neste scene i køen når videoen er ferdig\",\n                        \"heading\": \"Fortsett spilleliste som standard\"\n                    },\n                    \"disable_mobile_media_auto_rotate\": \"Deaktiver automatisk rotasjon av fullskjermsmedier på mobil\",\n                    \"enable_chromecast\": \"Aktiver Chromecast\",\n                    \"show_ab_loop_controls\": \"Vis AB Loop-plugin-kontroller\",\n                    \"show_scrubber\": \"Vis Scrubber\",\n                    \"show_range_markers\": \"Vis avstandsmarkører\",\n                    \"track_activity\": \"Aktiver Scene Play-logg\"\n                },\n                \"heading\": \"Scenespiller\"\n            },\n            \"tag_panel\": {\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"heading\": \"Vis subtag-innhold\",\n                        \"description\": \"I tag-visningen kan du også vise innhold fra undertaggene\"\n                    }\n                },\n                \"heading\": \"Tag-visning\"\n            },\n            \"editing\": {\n                \"heading\": \"Redigering\",\n                \"disable_dropdown_create\": {\n                    \"heading\": \"Deaktiver oppretting av rullegardinmenyen\",\n                    \"description\": \"Fjern muligheten til å opprette nye objekter fra rullegardinmenyene\"\n                },\n                \"max_options_shown\": {\n                    \"label\": \"Maksimalt antall elementer som skal vises i utvalgte rullegardinmenyer\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Rangeringsstjernepresisjon\",\n                        \"options\": {\n                            \"full\": \"Full\",\n                            \"half\": \"Halv\",\n                            \"quarter\": \"Fjerdedel\",\n                            \"tenth\": \"Tiende\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Rangeringssystemtype\",\n                        \"options\": {\n                            \"decimal\": \"Desimal\",\n                            \"stars\": \"Stjerner\"\n                        }\n                    }\n                }\n            },\n            \"interactive_options\": \"Interaktive alternativer\",\n            \"studio_panel\": {\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"heading\": \"Vis innhold fra understudioer\",\n                        \"description\": \"I studiovisningen kan du også vise innhold fra understudioene\"\n                    }\n                },\n                \"heading\": \"Studio utsikt\"\n            },\n            \"abbreviate_counters\": {\n                \"heading\": \"Forkort tellere\",\n                \"description\": \"Forkort tellere i kort- og detaljvisningssider, for eksempel vil «1831» bli formatert til «1,8K».\"\n            },\n            \"basic_settings\": \"Grunnleggende innstillinger\",\n            \"custom_css\": {\n                \"heading\": \"Egendefinert CSS\",\n                \"option_label\": \"Egendefinert CSS aktivert\",\n                \"description\": \"Siden må lastes inn på nytt for at endringene skal tre i kraft. Det er ingen garanti for kompatibilitet mellom tilpasset CSS og fremtidige utgivelser av Stash.\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Siden må lastes inn på nytt for at endringene skal tre i kraft. Det er ingen garanti for kompatibilitet mellom tilpasset Javascript og fremtidige versjoner av Stash.\",\n                \"heading\": \"Egendefinert Javascript\",\n                \"option_label\": \"Egendefinert Javascript aktivert\"\n            },\n            \"custom_locales\": {\n                \"heading\": \"Tilpasset lokalisering\",\n                \"option_label\": \"Egendefinert lokalisering aktivert\",\n                \"description\": \"Overstyr individuelle språkstrenger. Se https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for hovedlisten. Siden må lastes inn på nytt for at endringene skal tre i kraft.\"\n            },\n            \"delete_options\": {\n                \"description\": \"Standardinnstillinger ved sletting av bilder, gallerier og scener.\",\n                \"heading\": \"Slett alternativer\",\n                \"options\": {\n                    \"delete_file\": \"Slett fil som standard\",\n                    \"delete_generated_supporting_files\": \"Slett genererte støttefiler som standard\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Desktop-integrasjon\",\n                \"notifications_enabled\": \"Aktiver varsler\",\n                \"send_desktop_notifications_for_events\": \"Send skrivebordsvarsler for hendelser\",\n                \"skip_opening_browser\": \"Hopp over å åpne nettleseren\",\n                \"skip_opening_browser_on_startup\": \"Hopp over automatisk åpning av nettleser under oppstart\"\n            },\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"heading\": \"Kompakte utvidede detaljer\",\n                    \"description\": \"Når dette alternativet er aktivert, vil det presentere utvidede detaljer samtidig som presentasjonen blir kompakt\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Vis bakgrunnsbilde på detaljsiden.\",\n                    \"heading\": \"Aktiver bakgrunnsbilde\"\n                },\n                \"heading\": \"Detaljside\",\n                \"show_all_details\": {\n                    \"description\": \"Når den er aktivert, vises alle innholdsdetaljer som standard, og hvert detaljelement får plass under én kolonne\",\n                    \"heading\": \"Vis alle detaljer\"\n                }\n            },\n            \"funscript_offset\": {\n                \"heading\": \"Funksjonsskriptforskyvning (ms)\",\n                \"description\": \"Tidsforskyvning i millisekunder for avspilling av interaktive skript.\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Koble til\",\n                \"server_offset\": {\n                    \"heading\": \"Serveroffset\"\n                },\n                \"status\": {\n                    \"heading\": \"Praktisk tilkoblingsstatus\"\n                },\n                \"sync\": \"Synkroniser\"\n            },\n            \"handy_connection_key\": {\n                \"heading\": \"Handy tilkoblingsnøkkel\",\n                \"description\": \"Praktisk tilkoblingsnøkkel for bruk for interaktive scener. Hvis du angir denne knappen, kan Stash dele informasjon om gjeldende scene med handyfeeling.com\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Bilde lysboks\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Retning\",\n                \"heading\": \"Bildevegg\",\n                \"margin\": \"Margin (piksler)\"\n            },\n            \"images\": {\n                \"heading\": \"Bilder\",\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"Når et bibliotek har deaktivert videoer, vil videofiler (filer som slutter på videoendelsen) skannes som bildeklipp.\",\n                        \"heading\": \"Skann videoutvidelser som bildeklipp\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"heading\": \"Skriv miniatyrbilder\",\n                        \"description\": \"Skriv miniatyrbilder av bilder til disk når de genereres på farten\"\n                    }\n                }\n            },\n            \"language\": {\n                \"heading\": \"Språk\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Maksimal scenevarighet der scenespilleren vil spille av videoen i loop - 0 for å deaktivere\",\n                \"heading\": \"Maksimal sløyfevarighet\"\n            },\n            \"menu_items\": {\n                \"heading\": \"Menyelementer\",\n                \"description\": \"Vis eller skjul ulike typer innhold på navigasjonslinjen\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Prosentandelen av tiden en scene må spilles av før avspillingstallene økes.\",\n                \"heading\": \"Minimum Spilleprosent\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"heading\": \"Tilpasset utøverbildebane\",\n                        \"description\": \"Tilpasset sti for standard utøverbilder. La stå tomt for å bruke innebygde standardinnstillinger\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"heading\": \"Forhåndsvisningstype\",\n                \"options\": {\n                    \"animated\": \"Animert bilde\",\n                    \"static\": \"Statisk bilde\",\n                    \"video\": \"Video\"\n                },\n                \"description\": \"Standardalternativet er forhåndsvisninger av videoer (mp4). For mindre CPU-bruk når du surfer, kan du bruke forhåndsvisninger av animerte bilder (webp). Disse må imidlertid genereres i tillegg til forhåndsvisningene av videoer, og de er større filer.\"\n            },\n            \"scene_list\": {\n                \"heading\": \"Rutenettvisning\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Vis studiooverlegg som tekst\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Scene-/markørvegg\",\n                \"options\": {\n                    \"toggle_sound\": \"Aktiver lyd\",\n                    \"display_title\": \"Vis tittel og tagger\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"heading\": \"Rullforsøk før overgang\",\n                \"description\": \"Antall forsøk på å bla før man går til neste/forrige element. Gjelder kun for Pan Y-rullemodus.\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Vis tagkort når du holder musepekeren over tag-merkene\",\n                \"heading\": \"Verktøytips for tagkort\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Lysbildefremvisning er tilgjengelig i gallerier i veggvisningsmodus\",\n                \"heading\": \"Forsinkelse av lysbildefremvisning (sekunder)\"\n            },\n            \"title\": \"Brukergrensesnitt\",\n            \"use_stash_hosted_funscript\": {\n                \"heading\": \"Server funscripts direkte\",\n                \"description\": \"Når dette er aktivert, vil funscripts bli servert direkte fra Stash til Handy-enheten din uten å bruke tredjeparts Handy-serveren. Krever at Stash er tilgjengelig fra Handy-enheten din, og at en API-nøkkel genereres hvis stash har konfigurert legitimasjon.\"\n            }\n        },\n        \"scraping\": {\n            \"available_scrapers\": \"Tilgjengelige scrapers\",\n            \"search_by_name\": \"Søk etter navn\",\n            \"supported_types\": \"Støttede typer\",\n            \"installed_scrapers\": \"Installerte scrapers\",\n            \"scraper\": \"Skraper\",\n            \"entity_scrapers\": \"{entityType} scrapers\",\n            \"excluded_tag_patterns_desc\": \"Regulære uttrykk for tag-navn som skal ekskluderes fra scraperesultater\",\n            \"scrapers\": \"Skrapere\",\n            \"entity_metadata\": \"{entityType} Metadata\",\n            \"excluded_tag_patterns_head\": \"Ekskluderte tag-mønstre\",\n            \"supported_urls\": \"URLs\"\n        },\n        \"plugins\": {\n            \"installed_plugins\": \"Installerte plugins\",\n            \"hooks\": \"Hooks\",\n            \"triggers_on\": \"Utløsere aktivert\",\n            \"available_plugins\": \"Tilgjengelige plugins\"\n        },\n        \"library\": {\n            \"media_content_extensions\": \"Filendelser for medieinnhold\",\n            \"gallery_and_image_options\": \"Galleri- og bildeinnstillinger\",\n            \"exclusions\": \"Eksklusjoner\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Sceneduplikatkontrollør\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Legg til felt\",\n                \"capitalize_title\": \"Bruk stor bokstav i tittelen\",\n                \"display_fields\": \"Vis felt\",\n                \"escape_chars\": \"Bruk \\\\ for å escape-tegn\",\n                \"filename\": \"Filnavn\",\n                \"ignore_organized\": \"Ignorer organiserte scener\",\n                \"ignored_words\": \"Ignorerte ord\",\n                \"matches_with\": \"Samsvarer med {i}\",\n                \"select_parser_recipe\": \"Velg parseroppskrift\",\n                \"title\": \"Scenefilnavnparser\",\n                \"whitespace_chars_desc\": \"Disse tegnene vil bli erstattet med mellomrom i tittelen\",\n                \"filename_pattern\": \"Filnavnmønster\",\n                \"whitespace_chars\": \"Mellomromstegn\"\n            },\n            \"graphql_playground\": \"GraphQL lekeplass\",\n            \"heading\": \"Verktøy\",\n            \"scene_tools\": \"Sceneverktøy\"\n        },\n        \"stashbox\": {\n            \"endpoint\": \"Endepunkt\",\n            \"description\": \"Stash-box muliggjør automatisk tagging av scener og utøvere basert på fingeravtrykk og filnavn.\\nEndepunkt og API-nøkkel finnes på kontosiden din på Stash-box-instansen. Navn kreves når mer enn én instans legges til.\",\n            \"graphql_endpoint\": \"GraphQL-endepunkt\",\n            \"name\": \"Navn\",\n            \"add_instance\": \"Legg til Stash-box-instans\",\n            \"api_key\": \"API-nøkkel\",\n            \"max_requests_per_minute\": \"Maks forespørsler per minutt\",\n            \"max_requests_per_minute_description\": \"Bruker standardverdi på {defaultValue} hvis satt til 0\",\n            \"title\": \"Stash-box-endepunkter\"\n        },\n        \"logs\": {\n            \"log_level\": \"Loggnivå\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transkoding\"\n        }\n    },\n    \"appears_with\": \"Opptrer med\",\n    \"ascending\": \"Stigende\",\n    \"also_known_as\": \"Også kjent som\",\n    \"audio_codec\": \"Lyd Codec\",\n    \"average_resolution\": \"Gjennomsnittlig Oppløsning\",\n    \"actions_name\": \"Handlinger\",\n    \"age\": \"Alder\",\n    \"aliases\": \"Aliaser\",\n    \"birthdate\": \"Fødselsdato\",\n    \"bitrate\": \"Bithastighet\",\n    \"blobs_storage_type\": {\n        \"database\": \"Database\",\n        \"filesystem\": \"Filsystem\"\n    },\n    \"captions\": \"Undertekster\",\n    \"career_length\": \"Karrierelengde\",\n    \"chapters\": \"Kapitler\",\n    \"circumcised\": \"Omskåret\",\n    \"between_and\": \"og\",\n    \"circumcised_types\": {\n        \"CUT\": \"Omskåret\",\n        \"UNCUT\": \"Ikke omskåret\"\n    },\n    \"birth_year\": \"Fødselsår\",\n    \"all\": \"alt\",\n    \"media_info\": {\n        \"performer_card\": {\n            \"age\": \"{alder} {år_gammel}\",\n            \"age_context\": \"{age} {years_old} ved produksjon\"\n        },\n        \"video_codec\": \"Video Kodek\",\n        \"audio_codec\": \"Lydkodek\",\n        \"downloaded_from\": \"Lastet ned fra\",\n        \"interactive_speed\": \"Interaktiv hastighet\",\n        \"o_count\": \"0 Antall\",\n        \"phash\": \"PHash\",\n        \"play_count\": \"Avspilt\",\n        \"play_duration\": \"Spillevarighet\",\n        \"stream\": \"Strøm\"\n    },\n    \"age_on_date\": \"{age} ved produksjon\",\n    \"search_filter\": {\n        \"update_filter\": \"Oppdater Filtre\",\n        \"saved_filters\": \"Lagrede filter\",\n        \"edit_filter\": \"Rediger filter\",\n        \"name\": \"Filter\",\n        \"more_filter_criteria\": \"+{tell} flere\"\n    },\n    \"history\": \"Historie\",\n    \"play_count\": \"Antall Avspillinger\",\n    \"play_history\": \"Avspillinger\",\n    \"release_notes\": \"Utgivelsesnotater\",\n    \"scene\": \"Scene\",\n    \"path\": \"Sti\",\n    \"package_manager\": {\n        \"installed_version\": \"Installert versjon\",\n        \"required_by\": \"Kreves av {pakker}\",\n        \"add_source\": \"Legg til kilde\",\n        \"check_for_updates\": \"Se etter oppdateringer\",\n        \"confirm_delete_source\": \"Er du sikker på at du vil slette kilden {name} ({url})?\",\n        \"description\": \"Beskrivelse\",\n        \"edit_source\": \"Rediger Kilde\",\n        \"hide_unselected\": \"Skjul uvalgt\",\n        \"latest_version\": \"Siste versjon\",\n        \"no_packages\": \"Ingen pakker funnet\",\n        \"no_sources\": \"Ingen kilder er konfigurert\",\n        \"no_upgradable\": \"Ingen oppgraderbare pakker funnet\",\n        \"package\": \"Pakke\",\n        \"selected_only\": \"Kun valgte\",\n        \"show_all\": \"Vis alle\",\n        \"source\": {\n            \"local_path\": {\n                \"heading\": \"Lokal Sti\",\n                \"description\": \"Relativ sti til lagringspakker for denne kilden. Merk at endring av dette krever at pakkene flyttes manuelt.\"\n            },\n            \"name\": \"Navn\",\n            \"url\": \"Kilde URL\"\n        },\n        \"uninstall\": \"Avinstallere\",\n        \"unknown\": \"<ukjent>\",\n        \"update\": \"Oppdatere\",\n        \"version\": \"Versjon\",\n        \"install\": \"Installere\",\n        \"confirm_uninstall\": \"Er du sikker på at du vil avinstallere {number} pakker?\"\n    },\n    \"rating\": \"Vurdering\",\n    \"queue\": \"Kø\",\n    \"studio_tagger\": {\n        \"status_tagging_job_queued\": \"Status: Taggejobb i kø\",\n        \"batch_update_studios\": \"Batch Oppdater Studio\",\n        \"failed_to_save_studio\": \"Kunne ikke lagre studioet «{studio}»\",\n        \"add_new_studios\": \"Legg til nye studioer\",\n        \"batch_add_studios\": \"Batch Legg Til Studio\",\n        \"config\": {\n            \"create_parent_label\": \"Lag foreldrestudioer\",\n            \"create_parent_desc\": \"Opprett manglende foreldrestudioer, eller tagg og oppdater data/bilde for eksisterende foreldrestudioer med nøyaktige navnesamsvar\"\n        },\n        \"create_or_tag_parent_studios\": \"Opprett manglende eller tagg eksisterende foreldrestudioer\",\n        \"current_page\": \"Gjeldende side\",\n        \"name_already_exists\": \"Navnet finnes allerede\",\n        \"network_error\": \"Nettverksfeil\",\n        \"no_results_found\": \"Ingen resultater funnet.\",\n        \"number_of_studios_will_be_processed\": \"{studio_count} studioer vil bli behandlet\",\n        \"query_all_studios_in_the_database\": \"Alle studioer i databasen\",\n        \"refreshing_will_update_the_data\": \"Oppdatering vil oppdatere dataene til alle taggede studioer fra stash-box-instansen.\",\n        \"studio_already_tagged\": \"Studio allerede merket\",\n        \"studio_selection\": \"Studio utvalg\",\n        \"studio_successfully_tagged\": \"Studio er tagget\",\n        \"any_names_entered_will_be_queried\": \"Alle navn som legges inn vil bli spørt fra den eksterne Stash-Box-instansen og lagt til hvis de blir funnet. Bare eksakte treff vil bli ansett som treff.\",\n        \"refresh_tagged_studios\": \"Oppdater taggede studioer\",\n        \"status_tagging_studios\": \"Status: Merking av studioer\",\n        \"tag_status\": \"Tag status\",\n        \"to_use_the_studio_tagger\": \"For å bruke studio-taggeren må en stash-box-instans konfigureres.\",\n        \"untagged_studios\": \"Umerkede studioer\",\n        \"update_studio\": \"Oppdater Studio\",\n        \"update_studios\": \"Oppdater Studio\",\n        \"updating_untagged_studios_description\": \"Oppdatering av utaggede studioer vil prøve å matche alle studioer som mangler en stashid og oppdatere metadataene.\"\n    },\n    \"effect_filters\": {\n        \"blue\": \"Blå\",\n        \"name_transforms\": \"Forvandles\",\n        \"red\": \"Rød\",\n        \"aspect\": \"Aspekt\",\n        \"blur\": \"Uskarphet\",\n        \"brightness\": \"Lysstyrke\",\n        \"contrast\": \"Kontrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Grønn\",\n        \"hue\": \"Hue\",\n        \"rotate\": \"Rotere\",\n        \"rotate_left_and_scale\": \"Roter til venstre og skaler\",\n        \"rotate_right_and_scale\": \"Roter til høyre og skaler\",\n        \"saturation\": \"Metning\",\n        \"scale\": \"Skala\",\n        \"warmth\": \"Varme\",\n        \"reset_filters\": \"Tilbakestill filtre\",\n        \"reset_transforms\": \"Tilbakestill Transforms\",\n        \"name\": \"Filtre\"\n    },\n    \"megabits_per_second\": \"{verdi} mbps\",\n    \"stats\": {\n        \"scenes_duration\": \"Varighet av scener\",\n        \"image_size\": \"Bildestørrelse\",\n        \"total_o_count\": \"Total O-telling\",\n        \"scenes_played\": \"Scener spilt\",\n        \"scenes_size\": \"Scenestørrelse\",\n        \"total_play_count\": \"Totale Avspillinger\",\n        \"total_play_duration\": \"Total Spilletid\"\n    },\n    \"studio_depth\": \"Nivåer (tomt for alle)\",\n    \"custom_fields\": {\n        \"title\": \"Egendefinerte felt\",\n        \"criteria_format_string\": \"{kriterium} (egendefinert felt) {modifierString} {valueString}\",\n        \"value\": \"Verdi\",\n        \"criteria_format_string_others\": \"{kriterium} (egendefinert felt) {modifierString} {valueString} (+{andre} andre)\",\n        \"field\": \"Felt\"\n    },\n    \"dialogs\": {\n        \"scene_gen\": {\n            \"marker_screenshots\": \"Marker skjermbilder\",\n            \"video_previews\": \"Forhåndsvisninger\",\n            \"video_previews_tooltip\": \"Videoforhåndsvisninger som spilles av når du holder musepekeren over en scene\",\n            \"covers\": \"Scenebilder\",\n            \"image_previews\": \"Forhåndsvisninger av animerte bilder\",\n            \"image_previews_tooltip\": \"Generer også animerte (webp) forhåndsvisninger, som bare er nødvendig når Forhåndsvisningstype for scene/markørvegg er satt til Animert bilde. Når du surfer, bruker de mindre CPU enn videoforhåndsvisningene, men genereres i tillegg til dem og er større filer.\",\n            \"image_thumbnails\": \"Miniatyrbilder\",\n            \"interactive_heatmap_speed\": \"Generer varmekart og hastigheter for interaktive scener\",\n            \"marker_image_previews\": \"Forhåndsvisning av animerte bilder for markører\",\n            \"marker_screenshots_tooltip\": \"Marker statiske JPG-bilder\",\n            \"markers\": \"Marker forhåndsvisning\",\n            \"markers_tooltip\": \"20 sekunders videoer som starter på den gitte tidskoden.\",\n            \"override_preview_generation_options\": \"Overstyr forhåndsvisningsgenereringsalternativer\",\n            \"override_preview_generation_options_desc\": \"Overstyr forhåndsvisningsgenereringsalternativer for denne operasjonen. Standardverdier angis i System -> Forhåndsvisningsgenerering.\",\n            \"overwrite\": \"Overskriv eksisterende filer\",\n            \"phash\": \"Perseptuelle hasher\",\n            \"phash_tooltip\": \"For deduplisering og sceneidentifikasjon\",\n            \"preview_exclude_end_time_head\": \"Ekskluder sluttid\",\n            \"preview_exclude_start_time_desc\": \"Ekskluder de første x sekundene fra sceneforhåndsvisninger. Dette kan være en verdi i sekunder, eller en prosentandel (f.eks. 2 %) av den totale scenevarigheten.\",\n            \"preview_exclude_start_time_head\": \"Ekskluder starttidspunkt\",\n            \"preview_generation_options\": \"Forhåndsvisningsgenereringsalternativer\",\n            \"preview_options\": \"Forhåndsvisningsalternativer\",\n            \"preview_preset_head\": \"Forhåndsinnstilling av koding\",\n            \"preview_seg_count_desc\": \"Antall segmenter i forhåndsvisningsfiler.\",\n            \"preview_seg_count_head\": \"Antall segmenter i forhåndsvisning\",\n            \"preview_seg_duration_desc\": \"Varigheten av hvert forhåndsvisningssegment, i sekunder.\",\n            \"preview_seg_duration_head\": \"Forhåndsvis segmentets varighet\",\n            \"sprites\": \"Scene Scrubber Sprites\",\n            \"sprites_tooltip\": \"Bildesettet som vises under videospilleren for enkel navigering.\",\n            \"transcodes\": \"Transkoder\",\n            \"force_transcodes\": \"Tving generering av transkode\",\n            \"marker_image_previews_tooltip\": \"Generer også animerte (webp) forhåndsvisninger, som bare er nødvendig når Forhåndsvisningstype for scene/markørvegg er satt til Animert bilde. Når du surfer, bruker de mindre CPU enn videoforhåndsvisningene, men genereres i tillegg til dem og er større filer.\",\n            \"force_transcodes_tooltip\": \"Som standard genereres transkoder bare når videofilen ikke støttes i nettleseren. Når dette er aktivert, genereres transkoder selv om videofilen ser ut til å være støttet i nettleseren.\",\n            \"preview_preset_desc\": \"Forhåndsinnstillingen regulerer størrelse, kvalitet og kodingstid for forhåndsvisningsgenerering. Forhåndsinnstillinger utover «treg» har avtagende avkastning og anbefales ikke.\",\n            \"preview_exclude_end_time_desc\": \"Ekskluder de siste x sekundene fra sceneforhåndsvisninger. Dette kan være en verdi i sekunder, eller en prosentandel (f.eks. 2 %) av den totale scenevarigheten.\",\n            \"clip_previews\": \"Forhåndsvisninger av bildeklipp\",\n            \"transcodes_tooltip\": \"MP4-transkoder vil bli forhåndsgenerert for alt innhold; nyttig for trege CPU-er, men krever mye mer diskplass\"\n        },\n        \"scenes_found\": \"{count} scener funnet\",\n        \"scrape_results_scraped\": \"Skrapet\",\n        \"set_image_url_title\": \"Bilde-URL\",\n        \"lightbox\": {\n            \"scroll_mode\": {\n                \"label\": \"Rullemodus\",\n                \"pan_y\": \"Pan Y\",\n                \"zoom\": \"Zoom\",\n                \"description\": \"Hold Shift-tasten nede for å midlertidig bruke en annen modus.\"\n            },\n            \"delay\": \"Forsinkelse (sek)\",\n            \"display_mode\": {\n                \"fit_to_screen\": \"Tilpass til skjermen\",\n                \"label\": \"Visningsmodus\",\n                \"original\": \"Orginalt\",\n                \"fit_horizontally\": \"Passer horisontalt\"\n            },\n            \"options\": \"Alternativer\",\n            \"reset_zoom_on_nav\": \"Tilbakestill zoomnivå når du bytter bilde\",\n            \"scale_up\": {\n                \"description\": \"Skaler mindre bilder opp for å fylle skjermen\",\n                \"label\": \"Skaler opp for å passe\"\n            },\n            \"page_header\": \"Side {side} / {totalt}\"\n        },\n        \"merge\": {\n            \"empty_results\": \"Verdiene i destinasjonsfeltet vil forbli uendret.\",\n            \"destination\": \"Destinasjon\",\n            \"source\": \"Kilde\"\n        },\n        \"imagewall\": {\n            \"margin_desc\": \"Antall margpiksler rundt hvert bilde.\",\n            \"direction\": {\n                \"column\": \"Kolonne\",\n                \"description\": \"Kolonne- eller radbasert oppsett.\",\n                \"row\": \"Rad\"\n            }\n        },\n        \"delete_confirm\": \"Er du sikker på at du vil slette {entityName}?\",\n        \"clear_o_history_confirm\": \"Er du sikker på at du vil slette O-historikken?\",\n        \"clear_play_history_confirm\": \"Er du sikker på at du vil slette avspillingshistorikken?\",\n        \"create_new_entity\": \"Opprett ny {entity}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Er du sikker på at du vil slette denne {singularEntity}?} other {Er du sikker på at du vil slette disse {pluralEntity}?}}\",\n        \"delete_entity_title\": \"{antall, flertall, én {Slett {entallEntitet}} annet {Slett {flertallEntitet}}}\",\n        \"delete_galleries_extra\": \"... pluss eventuelle bildefiler som ikke er knyttet til noe annet galleri.\",\n        \"delete_object_desc\": \"Er du sikker på at du vil slette {count, plural, one {this {singularEntity}} other {these {pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…og {count} other {count, flertall, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Slett {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Ikke vis før neste oppdatering\",\n        \"export_include_related_objects\": \"Inkluder relaterte objekter i eksporten\",\n        \"export_title\": \"Eksport\",\n        \"performers_found\": \"{count} utøvere funnet\",\n        \"reassign_entity_title\": \"{antall, flertall, én {Reassign {singularEntity}} annen {Reassign {pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Tilordne på nytt til\"\n        },\n        \"scrape_entity_query\": \"Skrapeforespørsel fra {entity_type}\",\n        \"scrape_entity_title\": \"{entity_type} Skrape Resultater\",\n        \"scrape_results_existing\": \"Eksisterende\",\n        \"delete_entity_desc\": \"{count, plural, one {Er du sikker på at du vil slette denne {singularEntity}? Med mindre filen også slettes, vil denne {singularEntity} bli lagt til på nytt når skanningen utføres.} other {Er du sikker på at du vil slette disse {pluralEntity}? Med mindre filene også slettes, vil disse {pluralEntity} bli lagt til på nytt når skanningen utføres.}}\",\n        \"delete_gallery_files\": \"Slett gallerimappen/zip-filen og alle bilder som ikke er knyttet til noe annet galleri.\",\n        \"edit_entity_title\": \"Rediger {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}\",\n        \"delete_alert\": \"Følgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} vil bli slettet permanent:\",\n        \"overwrite_filter_warning\": \"Det lagrede filteret «{entityName}» vil bli overskrevet.\",\n        \"unsaved_changes\": \"Ulagrede endringer. Er du sikker på at du vil avslutte?\",\n        \"set_default_filter_confirm\": \"Er du sikker på at du vil angi dette filteret som standard?\"\n    },\n    \"director\": \"Regissør\",\n    \"display_mode\": {\n        \"unknown\": \"Ukjent\",\n        \"wall\": \"Vegg\",\n        \"tagger\": \"Tagger\",\n        \"list\": \"Liste\",\n        \"label_current\": \"Visningsmodus: {gjeldende}\",\n        \"grid\": \"Rutenett\"\n    },\n    \"distance\": \"Lengde\",\n    \"dupe_check\": {\n        \"only_select_matching_codecs\": \"Velg bare hvis alle kodeker samsvarer i duplikatgruppen\",\n        \"options\": {\n            \"exact\": \"Nøyaktig\",\n            \"high\": \"Høy\",\n            \"low\": \"Lav\",\n            \"medium\": \"Medium\"\n        },\n        \"search_accuracy_label\": \"Søkenøyaktighet\",\n        \"duration_diff\": \"Maksimal varighetsforskjell\",\n        \"duration_options\": {\n            \"equal\": \"Lik\",\n            \"any\": \"Noen\"\n        },\n        \"select_none\": \"Velg Ingen\",\n        \"select_oldest\": \"Velg den eldste filen i duplikatgruppen\",\n        \"select_options\": \"Velg Alternativer…\",\n        \"select_youngest\": \"Velg den yngste filen i duplikatgruppen\",\n        \"title\": \"Dupliserte scener\",\n        \"description\": \"Nivåer under «Nøyaktig» kan ta lengre tid å beregne. Falske positive resultater kan også returneres ved lavere nøyaktighetsnivåer.\",\n        \"found_sets\": \"{setCount, flertall, one{# sett med duplikater funnet.} other {# sett med duplikater funnet.}}\",\n        \"select_all_but_largest_file\": \"Velg alle filer i hver dupliserte gruppe, unntatt den største filen\",\n        \"select_all_but_largest_resolution\": \"Velg alle filer i hver dupliserte gruppe, unntatt filen med høyest oppløsning\"\n    },\n    \"duplicated_phash\": \"Duplisert (pHash)\",\n    \"errors\": {\n        \"invalid_javascript_string\": \"Ugyldig javascript-kode: {error}\",\n        \"custom_fields\": {\n            \"duplicate_field\": \"Feltnavnet må være unikt\",\n            \"field_name_length\": \"Feltnavnet må inneholde færre enn 65 tegn\",\n            \"field_name_required\": \"Feltnavn er obligatorisk\",\n            \"field_name_whitespace\": \"Feltnavnet kan ikke ha innledende eller etterfølgende mellomrom\"\n        },\n        \"header\": \"Feil\",\n        \"invalid_json_string\": \"Ugyldig JSON-streng: {error}\",\n        \"loading_type\": \"Feil ved lasting av {type}\",\n        \"something_went_wrong\": \"Noe gikk galt.\",\n        \"image_index_greater_than_zero\": \"Bildeindeksen må være større enn 0\",\n        \"lazy_component_error_help\": \"Hvis du nylig har oppgradert Stash, må du laste inn siden på nytt eller tømme nettleserens hurtigbuffer.\"\n    },\n    \"file_info\": \"Filinformasjon\",\n    \"group_scene_number\": \"Scenenummer\",\n    \"groups\": \"Grupper\",\n    \"image_index\": \"Bilde #\",\n    \"last_o_at\": \"Siste O Kl\",\n    \"library\": \"Bibliotek\",\n    \"penis\": \"Penis\",\n    \"part_of\": \"En del av {forelder}\",\n    \"performer_tagger\": {\n        \"batch_update_performers\": \"Batch Oppdater Skuespillere\",\n        \"network_error\": \"Nettverksfeil\",\n        \"performer_already_tagged\": \"Skuespilleren er allerede tagget\",\n        \"number_of_performers_will_be_processed\": \"{skuespiller_antall} skuespillere vil bli behandlet\",\n        \"batch_add_performers\": \"Batch Legg Til Skuespillere\",\n        \"current_page\": \"Gjeldende side\",\n        \"failed_to_save_performer\": \"Kunne ikke lagre skuespillere «{skuespiller}»\",\n        \"name_already_exists\": \"Navnet finnes allerede\",\n        \"no_results_found\": \"Ingen resultater funnet.\",\n        \"performer_selection\": \"Utvalg av skuespillere\",\n        \"performer_successfully_tagged\": \"Skuespiller er tagget:\",\n        \"query_all_performers_in_the_database\": \"Alle skuespillere i databasen\",\n        \"refresh_tagged_performers\": \"Oppdater taggede skuespillere\",\n        \"status_tagging_job_queued\": \"Status: Taggejobb i kø\",\n        \"status_tagging_performers\": \"Status: Tagger skuespillere\",\n        \"tag_status\": \"Tagging Status\",\n        \"to_use_the_performer_tagger\": \"For å bruke skuespiller-taggeren må en stash-box-instans konfigureres.\",\n        \"untagged_performers\": \"Utaggede skuespillere\",\n        \"update_performer\": \"Oppdater Skuespiller\",\n        \"update_performers\": \"Oppdater Skuespillere\",\n        \"add_new_performers\": \"Legg til ny skuespiller\",\n        \"updating_untagged_performers_description\": \"Oppdatering av utaggede skuespillere vil forsøke å matche skuespillere som mangler en stashid og oppdatere metadataene.\",\n        \"refreshing_will_update_the_data\": \"Oppdatering vil oppdatere dataene til alle taggede skuespillere fra stash-box-instansen.\",\n        \"any_names_entered_will_be_queried\": \"Alle navn som legges inn vil bli spørt fra den eksterne Stash-Box-instansen og lagt til hvis de blir funnet. Bare eksakte treff vil bli ansett som treff.\"\n    },\n    \"setup\": {\n        \"welcome_specific_config\": {\n            \"next_step\": \"Når du er klar til å fortsette med å sette opp et nytt system, klikker du på Neste.\",\n            \"config_path\": \"Stash vil bruke følgende konfigurasjonsfilsti: <code>{path}</code>\",\n            \"unable_to_locate_specified_config\": \"Hvis du leser dette, fant ikke Stash konfigurasjonsfilen som er angitt på kommandolinjen eller i miljøet. Denne veiviseren vil veilede deg gjennom prosessen med å sette opp en ny konfigurasjon.\"\n        },\n        \"paths\": {\n            \"set_up_your_paths\": \"Sett opp dine veier\",\n            \"database_filename_empty_for_default\": \"databasefilnavn (tomt for standard)\",\n            \"description\": \"Deretter må vi finne ut hvor du finner pornosamlingen din, og hvor du skal lagre Stash-databasen, genererte filer og hurtigbufferfiler. Disse innstillingene kan endres senere om nødvendig.\",\n            \"path_to_generated_directory_empty_for_default\": \"sti til generert katalog (tom som standard)\",\n            \"stash_alert\": \"Ingen bibliotekstier er valgt. Ingen medier kan skannes inn i Stash. Er du sikker?\",\n            \"store_blobs_in_database\": \"Lagre blober i databasen\",\n            \"where_can_stash_store_blobs\": \"Hvor kan Stash lagre binære databasedata?\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Alternativt kan du lagre disse dataene i databasen. <strong>Merk:</strong> Dette vil øke størrelsen på databasefilen din, og det vil øke migreringstiden for databasen.\",\n            \"where_can_stash_store_cache_files\": \"Hvor kan Stash lagre hurtigbufferfiler?\",\n            \"where_can_stash_store_its_database_description\": \"Stash bruker en SQLite-database for å lagre pornometadataene dine. Som standard opprettes dette som <code>stash-go.sqlite</code> i katalogen som inneholder konfigurasjonsfilen din. Hvis du vil endre dette, må du skrive inn et absolutt eller relativt (til gjeldende arbeidskatalog) filnavn.\",\n            \"where_can_stash_store_its_database_warning\": \"ADVARSEL: lagring av databasen på et annet system enn der Stash kjøres fra (f.eks. lagring av databasen på en NAS mens Stash-serveren kjøres på en annen datamaskin) er <strong>ikke støttet</strong>! SQLite er ikke ment for bruk på tvers av et nettverk, og forsøk på å gjøre det kan lett føre til at hele databasen blir ødelagt.\",\n            \"where_can_stash_store_its_generated_content\": \"Hvor kan Stash lagre det genererte innholdet?\",\n            \"where_is_your_porn_located\": \"Hvor ligger pornoen din?\",\n            \"where_is_your_porn_located_description\": \"Legg til kataloger som inneholder pornovideoene og bildene dine. Stash vil bruke disse katalogene til å finne videoer og bilder under skanning.\",\n            \"path_to_blobs_directory_empty_for_default\": \"sti til blobs-katalogen (tom som standard)\",\n            \"where_can_stash_store_its_database\": \"Hvor kan Stash lagre databasen sin?\",\n            \"where_can_stash_store_cache_files_description\": \"For at funksjonalitet som HLS/DASH live-transkoding skal fungere, krever Stash en hurtigbufferkatalog for midlertidige filer. Som standard oppretter Stash en <code>cache</code>-katalog i katalogen som inneholder konfigurasjonsfilen din. Hvis du vil endre dette, må du angi en absolutt eller relativ (til gjeldende arbeidskatalog) sti. Stash oppretter denne katalogen hvis den ikke allerede finnes.\",\n            \"where_can_stash_store_its_generated_content_description\": \"For å kunne tilby miniatyrbilder, forhåndsvisninger og sprites genererer Stash bilder og videoer. Dette inkluderer også transkoder for filformater som ikke støttes. Som standard vil Stash opprette en <code>generert</code> katalog i katalogen som inneholder konfigurasjonsfilen din. Hvis du vil endre hvor dette genererte mediet skal lagres, må du angi en absolutt eller relativ (til gjeldende arbeidskatalog) sti. Stash vil opprette denne katalogen hvis den ikke allerede finnes.\",\n            \"path_to_cache_directory_empty_for_default\": \"sti til hurtigbufferkatalog (tom som standard)\",\n            \"where_can_stash_store_blobs_description\": \"Stash kan lagre binære data som scenecovere, utøver, studio og tag-bilder enten i databasen eller i filsystemet. Som standard lagrer den disse dataene i filsystemet i underkatalogen <code>blobs</code> i katalogen som inneholder konfigurasjonsfilen din. Hvis du vil endre dette, må du angi en absolutt eller relativ (til gjeldende arbeidskatalog) sti. Stash vil opprette denne katalogen hvis den ikke allerede finnes.\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Oppretter systemet ditt\"\n        },\n        \"errors\": {\n            \"something_went_wrong_while_setting_up_your_system\": \"Noe gikk galt under oppsettet av systemet ditt. Her er feilen vi mottok: {feil}\",\n            \"unable_to_retrieve_system_status\": \"Kan ikke hente systemstatus: {feil}\",\n            \"something_went_wrong\": \"Å nei! Noe gikk galt!\",\n            \"unexpected_error\": \"Det oppsto en uventet feil: {feil}\",\n            \"something_went_wrong_description\": \"Hvis dette ser ut som et problem med inndataene dine, kan du klikke tilbake for å fikse dem. Ellers kan du rapportere en feil på {githubLink} eller søke hjelp på {discordLink}.\"\n        },\n        \"folder\": {\n            \"file_path\": \"Filbane\",\n            \"up_dir\": \"Opp en mappe\"\n        },\n        \"github_repository\": \"Github-depot\",\n        \"migrate\": {\n            \"migration_failed\": \"Migrering mislyktes\",\n            \"migration_irreversible_warning\": \"Skjemamigreringsprosessen er ikke reversibel. Når migreringen er utført, vil databasen din være inkompatibel med tidligere versjoner av stash.\",\n            \"migration_notes\": \"Migrasjonsnotater\",\n            \"migration_required\": \"Migrering kreves\",\n            \"perform_schema_migration\": \"Utfør skjemamigrering\",\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Sti til sikkerhetskopieringsdatabase (la stå tomt for å deaktivere sikkerhetskopiering):\",\n            \"migration_failed_error\": \"Følgende feil oppsto under migrering av databasen:\",\n            \"schema_too_old\": \"Din nåværende stash-database er skjemaversjon <strong>{databaseSchema}</strong> og må migreres til versjon <strong>{appSchema}</strong>. Denne versjonen av Stash vil ikke fungere uten at databasen migreres. Hvis du ikke ønsker å migrere, må du nedgradere til en versjon som samsvarer med databaseskjemaet ditt.\",\n            \"backup_recommended\": \"Det anbefales at du sikkerhetskopierer den eksisterende databasen din før du migrerer. Vi kan gjøre dette for deg ved å lage en kopi av databasen din til <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Migrerer database\",\n            \"migration_failed_help\": \"Gjør nødvendige rettelser og prøv på nytt. Ellers kan du melde fra om en feil på {githubLink} eller søke hjelp på {discordLink}.\"\n        },\n        \"confirm\": {\n            \"almost_ready\": \"Vi er nesten klare til å fullføre konfigurasjonen. Vennligst bekreft følgende innstillinger. Du kan klikke tilbake for å endre noe som er feil. Hvis alt ser bra ut, klikker du på Bekreft for å opprette systemet ditt.\",\n            \"blobs_directory\": \"Binær datakatalog\",\n            \"blobs_use_database\": \"<bruker database>\",\n            \"cache_directory\": \"Cache-katalog\",\n            \"configuration_file_location\": \"konfigurasjonsfilplassering:\",\n            \"database_file_path\": \"Banen til databasefilen\",\n            \"generated_directory\": \"Generert katalog\",\n            \"nearly_there\": \"Nesten der!\",\n            \"stash_library_directories\": \"Stash bibliotekkataloger\"\n        },\n        \"stash_setup_wizard\": \"Stash Setup Wizard\",\n        \"success\": {\n            \"download_ffmpeg\": \"Last ned ffmpeg\",\n            \"getting_help\": \"Får hjelp\",\n            \"help_links\": \"Hvis du støter på problemer eller har spørsmål eller forslag, kan du gjerne åpne en sak i {githubLink} eller spørre fellesskapet i {discordLink}.\",\n            \"next_config_step_one\": \"Du blir deretter tatt til konfigurasjonssiden. Denne siden lar deg tilpasse hvilke filer som skal inkluderes og ekskluderes, angi et brukernavn og passord for å beskytte systemet ditt, og en hel rekke andre alternativer.\",\n            \"next_config_step_two\": \"Når du er fornøyd med disse innstillingene, kan du begynne å skanne innholdet ditt inn i Stash ved å klikke på <code>{localized_task}</code>, deretter <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Sjekk ut vår {open_collective_link} for å se hvordan du kan bidra til den fortsatte utviklingen av Stash.\",\n            \"support_us\": \"Støtt oss\",\n            \"thanks_for_trying_stash\": \"Takk for at du prøvde Stash!\",\n            \"your_system_has_been_created\": \"Suksess! Systemet ditt er opprettet!\",\n            \"in_app_manual_explained\": \"Du oppfordres til å sjekke ut manualen i appen, som du finner via ikonet øverst til høyre på skjermen. Det ser slik ut: {icon}\",\n            \"missing_ffmpeg\": \"Du mangler den nødvendige <code>ffmpeg</code>-binærfilen. Du kan laste den ned automatisk til konfigurasjonskatalogen din ved å merke av i boksen nedenfor. Alternativt kan du oppgi stier til <code>ffmpeg</code>- og <code>ffprobe</code>-binærfilene i systeminnstillingene. Disse binærfilene må være tilstede for at Stash skal fungere.\",\n            \"welcome_contrib\": \"Vi tar også imot bidrag i form av kode (feilrettinger, forbedringer og nye funksjoner), testing, feilrapporter, forbedrings- og funksjonsforespørsler og brukerstøtte. Detaljer finner du i bidragsdelen i appens brukerhåndbok.\"\n        },\n        \"welcome\": {\n            \"in_current_stash_directory\": \"I katalogen <code>{path}</code>:\",\n            \"in_the_current_working_directory\": \"I <code>{path}</code>, arbeidskatalogen, for øyeblikket:\",\n            \"in_the_current_working_directory_disabled\": \"I <code>{path}</code>, arbeidskatalogen:\",\n            \"next_step\": \"Når alt dette er avklart, og du er klar til å fortsette med å sette opp et nytt system, må du velge hvor du vil lagre konfigurasjonsfilen.\",\n            \"store_stash_config\": \"Hvor vil du lagre Stash-konfigurasjonen din?\",\n            \"unexpected_explained\": \"Hvis du får denne skjermen uventet, kan du prøve å starte Stash på nytt i riktig arbeidsmappe eller med <code>-c</code>-flagget.\",\n            \"in_the_current_working_directory_disabled_macos\": \"Støttes ikke når du kjører <code>Stash.app</code>,<br></br>kjør <code>stash-macos</code> for å sette opp i arbeidskatalogen\",\n            \"config_path_logic_explained\": \"Stash prøver først å finne konfigurasjonsfilen sin (<code>config.yml</code>) fra gjeldende arbeidsmappe, og hvis den ikke finner den der, går den tilbake til <code>{fallback_path}</code>. Du kan også få Stash til å lese fra en spesifikk konfigurasjonsfil ved å kjøre den med alternativene <code>-c '<sti til konfigurasjonsfil>'</code> eller <code>--config '<sti til konfigurasjonsfil>'</code>.\",\n            \"unable_to_locate_config\": \"Hvis du leser dette, fant ikke Stash en eksisterende konfigurasjon. Denne veiviseren vil veilede deg gjennom prosessen med å sette opp en ny konfigurasjon.\"\n        },\n        \"welcome_to_stash\": \"Velkommen til Stash\"\n    },\n    \"stashbox\": {\n        \"submission_successful\": \"Innlevering vellykket\",\n        \"go_review_draft\": \"Gå til {endpoint_name} for å se gjennom utkastet.\",\n        \"selected_stash_box\": \"Valgt Stash-Box-endepunkt\",\n        \"source\": \"Stash-Box-kilde\",\n        \"submission_failed\": \"Innsending mislyktes\",\n        \"submit_update\": \"Finnes allerede i {endpoint_name}\"\n    },\n    \"performer_image\": \"Skuespiller Bilde\",\n    \"countables\": {\n        \"images\": \"{antall, flertall, ett {bilde} andre {bilder}}\",\n        \"files\": \"{antall, flertall, én {fil} andre {filer}}\",\n        \"galleries\": \"{antall, flertall, ett {galleri} andre {gallerier}}\",\n        \"markers\": \"{antall, flertall, én {markør} andre {markører}}\",\n        \"scenes\": \"{antall, flertall, én {Scene} andre {Scener}}\",\n        \"studios\": \"{antall, flertall, ett {studio} andre {studioer}}\",\n        \"tags\": \"{antall, flertall, én {Tag} andre {Tags}}\",\n        \"groups\": \"{antall, flertall, én {gruppe} andre {grupper}}\",\n        \"performers\": \"{antall, flertall, én {utøver} andre {utøvere}}\"\n    },\n    \"sort_name\": \"Sorter Navn\",\n    \"include_sub_studios\": \"Inkluder datterselskapsstudioer\",\n    \"include_sub_tag_content\": \"Inkluder innhold i undertagger\",\n    \"include_sub_tags\": \"Inkluder undertagger\",\n    \"include_sub_studio_content\": \"Inkluder innhold fra understudioer\",\n    \"dimensions\": \"Dimensjoner\",\n    \"criterion_modifier_values\": {\n        \"any_of\": \"noen av\",\n        \"none\": \"Ingen\",\n        \"only\": \"Bare\",\n        \"any\": \"Hvilken som helst\"\n    },\n    \"configuration\": \"Konfigurasjon\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"Klarte ikke å opprette websocket-tilkobling: se nettleserkonsollen for detaljer\",\n        \"websocket_connection_reestablished\": \"Websocket-tilkoblingen er gjenopprettet\"\n    },\n    \"containing_group\": \"Inneholder gruppe\",\n    \"containing_group_count\": \"Inneholder gruppeantall\",\n    \"containing_groups\": \"Inneholder grupper\",\n    \"country\": \"Land\",\n    \"cover_image\": \"Forsidebilde\",\n    \"created_at\": \"Opprettet\",\n    \"criterion\": {\n        \"greater_than\": \"Større enn\",\n        \"less_than\": \"Mindre enn\",\n        \"value\": \"Verdi\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"mellom\",\n        \"equals\": \"Er\",\n        \"format_string\": \"{kriterium} {modifikatorString} {verdiString}\",\n        \"format_string_excludes\": \"{kriterium} {modifierString} {valueString} (ekskluderer {excludedString})\",\n        \"format_string_excludes_depth\": \"{kriterium} {modifierString} {valueString} (ekskluderer {excludedString}) (+{dybde, flertall, =-1 {all} andre {{dybde}}})\",\n        \"includes\": \"inkluderer\",\n        \"includes_all\": \"inkluderer alle\",\n        \"is_null\": \"er null\",\n        \"less_than\": \"er mindre enn\",\n        \"matches_regex\": \"samsvarer med regulært uttrykk\",\n        \"not_between\": \"ikke mellom\",\n        \"not_equals\": \"er ikke\",\n        \"greater_than\": \"er større enn\",\n        \"format_string_depth\": \"{kriterium} {modifierString} {valueString} (+{dybde, flertall, =-1 {all} other {{dybde}}})\",\n        \"excludes\": \"ekskluderer\",\n        \"not_matches_regex\": \"samsvarer ikke med regex\",\n        \"not_null\": \"er ikke null\"\n    },\n    \"custom\": \"Tilpasset\",\n    \"date\": \"Dato\",\n    \"date_format\": \"ÅÅÅÅ-MM-DD\",\n    \"datetime_format\": \"ÅÅÅÅ-MM-DD TT:MM\",\n    \"death_date\": \"Dødsdato\",\n    \"death_year\": \"Dødsår\",\n    \"descending\": \"Synkende\",\n    \"description\": \"Beskrivelse\",\n    \"detail\": \"Detalj\",\n    \"details\": \"Detaljer\",\n    \"developmentVersion\": \"Utviklingsversjon\",\n    \"disambiguation\": \"Presisering\",\n    \"duration\": \"Varighet\",\n    \"ethnicity\": \"Etnisitet\",\n    \"existing_value\": \"eksisterende verdi\",\n    \"eye_color\": \"Øyefarge\",\n    \"fake_tits\": \"Falske pupper\",\n    \"false\": \"Falsk\",\n    \"favourite\": \"Favoritt\",\n    \"file_count\": \"Antall filer\",\n    \"file_mod_time\": \"Filendringstid\",\n    \"files\": \"Filer\",\n    \"files_amount\": \"{value} filer\",\n    \"filesize\": \"Filstørrelse\",\n    \"filter\": \"Filter\",\n    \"filter_name\": \"Filternavn\",\n    \"filters\": \"Filtre\",\n    \"folder\": \"Mappe\",\n    \"framerate\": \"Bildefrekvens\",\n    \"frames_per_second\": \"{verdi} fps\",\n    \"front_page\": {\n        \"types\": {\n            \"saved_filter\": \"Lagrede filter\",\n            \"premade_filter\": \"Forhåndslaget filter\"\n        }\n    },\n    \"galleries\": \"Gallerier\",\n    \"gallery\": \"Galleri\",\n    \"gallery_count\": \"Antall Galleri\",\n    \"gender\": \"Kjønn\",\n    \"gender_types\": {\n        \"FEMALE\": \"Kvinne\",\n        \"INTERSEX\": \"Intersex\",\n        \"MALE\": \"Mann\",\n        \"NON_BINARY\": \"Ikke Binær\",\n        \"TRANSGENDER_MALE\": \"Transgender Mann\",\n        \"TRANSGENDER_FEMALE\": \"Transgender kvinne\"\n    },\n    \"group\": \"Gruppe\",\n    \"group_count\": \"Gruppetall\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Kobler til\",\n        \"disconnected\": \"Koblet fra\",\n        \"missing\": \"Mangler\",\n        \"ready\": \"Klar\",\n        \"syncing\": \"Synkroniserer med server\",\n        \"uploading\": \"Laster opp skript\",\n        \"error\": \"Feil ved tilkobling til Handy\"\n    },\n    \"hasChapters\": \"Har Kapitler\",\n    \"hasMarkers\": \"Har Markører\",\n    \"height\": \"Høyde\",\n    \"height_cm\": \"Høyde (cm)\",\n    \"help\": \"Hjelp\",\n    \"ignore_auto_tag\": \"Ignorer Automatisk Tagging\",\n    \"image\": \"Bilde\",\n    \"image_count\": \"Antall Bilder\",\n    \"images\": \"Bilder\",\n    \"include_parent_tags\": \"Inkluder overordnede tagger\",\n    \"include_sub_groups\": \"Inkluder undergrupper\",\n    \"index_of_total\": \"{indeks} av {totalt}\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktiv\",\n    \"interactive_speed\": \"Interaktiv hastighet\",\n    \"isMissing\": \"Mangler\",\n    \"last_played_at\": \"Sist Spilt\",\n    \"loading\": {\n        \"generic\": \"Laster inn …\",\n        \"plugins\": \"Laster inn plugins …\"\n    },\n    \"login\": {\n        \"login\": \"Logg inn\",\n        \"username\": \"Brukernavn\",\n        \"password\": \"Passord\",\n        \"invalid_credentials\": \"Ugyldig brukernavn eller passord\",\n        \"internal_error\": \"Uventet intern feil. Se loggene for mer informasjon\"\n    },\n    \"marker_count\": \"Antall markører\",\n    \"markers\": \"Markører\",\n    \"measurements\": \"Mål\",\n    \"metadata\": \"Metadata\",\n    \"name\": \"Navn\",\n    \"new\": \"Ny\",\n    \"none\": \"Ingen\",\n    \"o_history\": \"O Historie\",\n    \"odate_recorded_no\": \"Ingen O-dato registrert\",\n    \"operations\": \"Operasjoner\",\n    \"organized\": \"Organisert\",\n    \"orientation\": \"Orientering\",\n    \"pagination\": {\n        \"current_total\": \"{nåværende} av {totalt}\",\n        \"first\": \"Første\",\n        \"last\": \"Siste\",\n        \"next\": \"Neste\",\n        \"previous\": \"Tilbake\"\n    },\n    \"parent_of\": \"Forelder til {barn}\",\n    \"parent_studio\": \"Foreldrestudio\",\n    \"parent_studios\": \"Foreldrestudio\",\n    \"parent_tag_count\": \"Antall foreldremerker\",\n    \"parent_tags\": \"Foreldre Tagger\",\n    \"penis_length_cm\": \"Penis Lengde (cm)\",\n    \"perceptual_similarity\": \"Perseptuell Likhet (pHash)\",\n    \"performer\": \"Skuespiller\",\n    \"performer_age\": \"Skuespiller Alder\",\n    \"performer_count\": \"Skuespiller Antall\",\n    \"performers\": \"Skuespillere\",\n    \"photographer\": \"Fotograf\",\n    \"piercings\": \"Piercinger\",\n    \"playdate_recorded_no\": \"Ingen avspillningsdatodato registrert\",\n    \"plays\": \"{verdi} avspillinger\",\n    \"primary_file\": \"Primær fil\",\n    \"primary_tag\": \"Primær Tag\",\n    \"random\": \"Tilfeldig\",\n    \"recently_added_objects\": \"Nylig lagt til {objekter}\",\n    \"recently_released_objects\": \"Nylig utgitte {objekter}\",\n    \"resolution\": \"Oppløsning\",\n    \"resume_time\": \"Gjenoppta tid\",\n    \"sceneTagger\": \"Scene Tagger\",\n    \"scene_code\": \"Studio Kode\",\n    \"scene_count\": \"Antall Scener\",\n    \"scene_created_at\": \"Scene opprettet\",\n    \"scene_date\": \"Dato for scenen\",\n    \"scene_id\": \"Scene-ID\",\n    \"scene_tags\": \"Scene Tagger\",\n    \"scene_updated_at\": \"Scene Oppdatert\",\n    \"scenes\": \"Scener\",\n    \"scenes_updated_at\": \"Scener Oppdatert\",\n    \"second\": \"Sekund\",\n    \"seconds\": \"Sekunder\",\n    \"settings\": \"Innstillinger\",\n    \"stash_id\": \"Stash ID\",\n    \"stash_id_endpoint\": \"Stash ID-sluttpunkt\",\n    \"stash_ids\": \"Stash-ID-er\",\n    \"statistics\": \"Statistikk\",\n    \"status\": \"Status: {statusTekst}\",\n    \"studio\": \"Studio\",\n    \"studio_and_parent\": \"Studio og foreldre\",\n    \"studio_count\": \"Antall Studio\",\n    \"donate\": \"Donere\",\n    \"file\": \"fil\",\n    \"empty_server\": \"Legg til noen scener på serveren din for å se anbefalinger på denne siden.\",\n    \"penis_length\": \"Penis Lengde\",\n    \"performer_favorite\": \"Favoritt Skuespiller\",\n    \"eta\": \"ETA\",\n    \"performer_tags\": \"Skuespiller Tagger\",\n    \"play_duration\": \"Spillevarighet\",\n    \"hair_color\": \"Hårfarge\",\n    \"include_sub_group_content\": \"Inkluder innhold i undergrupper\",\n    \"o_count\": \"O Antall\",\n    \"toast\": {\n        \"merged_tags\": \"Sammenslåtte tagger\",\n        \"generating_screenshot\": \"Genererer skjermbilde …\",\n        \"added_generation_job_to_queue\": \"Lagt til genereringsjobb i køen\",\n        \"created_entity\": \"Opprettet {entity}\",\n        \"default_filter_set\": \"Standard filtersett\",\n        \"delete_past_tense\": \"Slettet {count, flertall, en {{singularEntity}} annen {{pluralEntity}}}\",\n        \"image_index_too_large\": \"Feil: Bildeindeksen er større enn antallet bilder i galleriet\",\n        \"merged_scenes\": \"Sammenslåtte scener\",\n        \"rescanning_entity\": \"Skanner på nytt {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}…\",\n        \"saved_entity\": \"Lagret {entity}\",\n        \"started_generating\": \"Begynte å generere\",\n        \"started_importing\": \"Begynte å importere\",\n        \"updated_entity\": \"Oppdatert {entity}\",\n        \"started_auto_tagging\": \"Startet automatisk merking\",\n        \"removed_entity\": \"Fjernet {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}\",\n        \"added_entity\": \"Lagt til {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}\",\n        \"reassign_past_tense\": \"Filen er tildelt på nytt\"\n    },\n    \"time_end\": \"Sluttid\",\n    \"sub_tags\": \"Undertagger\",\n    \"tag_count\": \"Antall Tagger\",\n    \"total\": \"Total\",\n    \"updated_at\": \"Oppdatert\",\n    \"url\": \"URL\",\n    \"urls\": \"URLs\",\n    \"validation\": {\n        \"blank\": \"${path} må ikke være tomt\",\n        \"end_time_before_start_time\": \"Sluttiden må være større enn eller lik starttiden\",\n        \"required\": \"${path} er et obligatorisk felt\",\n        \"unique\": \"${path} må være unik\",\n        \"date_invalid_form\": \"${path} må være i formatet ÅÅÅÅ-MM-DD\"\n    },\n    \"video_codec\": \"Videokodek\",\n    \"videos\": \"Videoer\",\n    \"view_all\": \"Vis Alle\",\n    \"weight\": \"Vekt\",\n    \"weight_kg\": \"Vekt (kg)\",\n    \"studio_tags\": \"Studio Tagger\",\n    \"studios\": \"Studioer\",\n    \"sub_group_count\": \"Antall undergrupper\",\n    \"sub_group_of\": \"Undergruppe av {forelder}\",\n    \"sub_group_order\": \"Undergruppeordre\",\n    \"sub_groups\": \"Undergrupper\",\n    \"sub_tag_count\": \"Antall undertagger\",\n    \"sub_tag_of\": \"Undertagg av {parent}\",\n    \"subsidiary_studio_count\": \"Antall datterselskaper i studioer\",\n    \"subsidiary_studios\": \"Datterselskapsstudioer\",\n    \"synopsis\": \"Synopsis\",\n    \"tag\": \"Tag\",\n    \"tag_sub_tag_tooltip\": \"Har under-tagger\",\n    \"tags\": \"Tagger\",\n    \"tattoos\": \"Tatoveringer\",\n    \"time\": \"Tid\",\n    \"title\": \"Tittel\",\n    \"true\": \"Sant\",\n    \"twitter\": \"X\",\n    \"type\": \"Type\",\n    \"unknown_date\": \"Ukjent dato\",\n    \"years_old\": \"år gammel\",\n    \"zip_file_count\": \"Antall zip-filer\",\n    \"tag_parent_tooltip\": \"Har foreldretagger\",\n    \"sub_group\": \"Undergruppe\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/ne-NP.json",
    "content": "{}\n"
  },
  {
    "path": "ui/v2.5/src/locales/nl-NL.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Toevoegen\",\n        \"add_directory\": \"Nieuwe map\",\n        \"add_entity\": \"{entityType} toevoegen\",\n        \"add_to_entity\": \"Toevoegen aan {entityType}\",\n        \"allow\": \"Toestaan\",\n        \"allow_temporarily\": \"Tijdelijk toestaan\",\n        \"apply\": \"Toepassen\",\n        \"auto_tag\": \"Automatisch labelen\",\n        \"backup\": \"Reservekopie\",\n        \"browse_for_image\": \"Kies een afbeelding…\",\n        \"cancel\": \"Annuleren\",\n        \"clean\": \"Opruimen\",\n        \"clear\": \"Wissen\",\n        \"clear_back_image\": \"Achtergrondafbeelding wissen\",\n        \"clear_front_image\": \"Voorgrondafbeelding wissen\",\n        \"clear_image\": \"Afbeelding wissen\",\n        \"close\": \"Sluiten\",\n        \"confirm\": \"Bevestigen\",\n        \"continue\": \"Doorgaan\",\n        \"create\": \"Maken\",\n        \"create_entity\": \"{entityType} maken\",\n        \"create_marker\": \"Markering toevoegen\",\n        \"created_entity\": \"{entity_type} aangemaakt: {entity_name}\",\n        \"customise\": \"Aanpassen\",\n        \"delete\": \"Verwijderen\",\n        \"delete_entity\": \"{entityType} verwijderen\",\n        \"delete_file\": \"Bestand verwijderen\",\n        \"delete_file_and_funscript\": \"Bestand (en funscript) verwijderen\",\n        \"delete_generated_supporting_files\": \"Gegenereerde ondersteuningsbestanden verwijderen\",\n        \"disallow\": \"Niet toestaan\",\n        \"download\": \"Downloaden\",\n        \"download_backup\": \"Reservekopie ophalen\",\n        \"edit\": \"Bewerken\",\n        \"edit_entity\": \"{entityType} bewerken\",\n        \"export\": \"Exporteren\",\n        \"export_all\": \"Alles exporteren…\",\n        \"find\": \"Zoeken\",\n        \"finish\": \"Klaar\",\n        \"from_file\": \"Uit bestand…\",\n        \"from_url\": \"Van url…\",\n        \"full_export\": \"Volledige export\",\n        \"full_import\": \"Volledige import\",\n        \"generate\": \"Genereren\",\n        \"generate_thumb_default\": \"Standaardminiatuur genereren\",\n        \"generate_thumb_from_current\": \"Miniatuur van huidige genereren\",\n        \"hash_migration\": \"controlesommigratie\",\n        \"hide\": \"Verbergen\",\n        \"hide_configuration\": \"Instellingen verbergen\",\n        \"identify\": \"Identificeren\",\n        \"ignore\": \"Negeren\",\n        \"import\": \"Importeren…\",\n        \"import_from_file\": \"Importeren uit bestand\",\n        \"logout\": \"Uitloggen\",\n        \"merge\": \"Samenvoegen\",\n        \"next_action\": \"Volgende\",\n        \"not_running\": \"niet actief\",\n        \"open_in_external_player\": \"Openen in externe speler\",\n        \"open_random\": \"Willekeurig openen\",\n        \"overwrite\": \"Overschrijven\",\n        \"play_random\": \"Willekeurig afspelen\",\n        \"play_selected\": \"Selectie afspelen\",\n        \"preview\": \"Voorvertoning\",\n        \"previous_action\": \"Terug\",\n        \"refresh\": \"Vernieuwen\",\n        \"reload_plugins\": \"Plug-ins herladen\",\n        \"reload_scrapers\": \"Scrapers herladen\",\n        \"remove\": \"Verwijderen\",\n        \"remove_from_gallery\": \"Verwijderen uit galerij\",\n        \"rename_gen_files\": \"Gegenereerde bestandsnamen wĳzigen\",\n        \"rescan\": \"Opnieuw doorzoeken\",\n        \"reshuffle\": \"Willekeurige volgorde\",\n        \"running\": \"actief\",\n        \"save\": \"Opslaan\",\n        \"save_delete_settings\": \"Opties standaard gebruiken bij verwijderen\",\n        \"save_filter\": \"Filter bewaren\",\n        \"scan\": \"Doorzoeken\",\n        \"scrape\": \"Scrapen\",\n        \"scrape_query\": \"Scrapeopdracht\",\n        \"scrape_scene_fragment\": \"Scrapen op fragment\",\n        \"scrape_with\": \"Scrapen met…\",\n        \"search\": \"Zoeken\",\n        \"select_all\": \"Alles selecteren\",\n        \"select_entity\": \"{entityType} selecteren\",\n        \"select_folders\": \"Mappen selecteren\",\n        \"select_none\": \"Niets selecteren\",\n        \"selective_auto_tag\": \"Selectief automatisch label\",\n        \"selective_clean\": \"Selectief opruimen\",\n        \"selective_scan\": \"Selectief doorzoeken\",\n        \"set_as_default\": \"Instellen als standaard\",\n        \"set_back_image\": \"Achtergrondafbeelding…\",\n        \"set_front_image\": \"Voorgrondafbeelding…\",\n        \"set_image\": \"Afbeelding instellen…\",\n        \"show\": \"Tonen\",\n        \"show_configuration\": \"Instellingen tonen\",\n        \"skip\": \"Overslaan\",\n        \"stop\": \"Stoppen\",\n        \"submit\": \"Opslaan\",\n        \"submit_stash_box\": \"Opslaan in Stash-Box\",\n        \"submit_update\": \"Update opslaan\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Weet je zeker dat je wilt opruimen? Hierdoor wordt de databankinformatie en gegenereerde bestanden opgeruimd van alle scènes en galerijen die niet meer op de schijf zijn aangetroffen.\",\n            \"dry_mode_selected\": \"Voorvertoningsmodus gekozen. Er zal niets verwijderd worden; alleen gelogd.\",\n            \"import_warning\": \"Weet je zeker dat je wilt importeren? Hierdoor wordt de databank verwijderd en opnieuw geïmporteerd uit geëxporteerde metagegevens.\"\n        },\n        \"temp_disable\": \"Tijdelijk uitschakelen…\",\n        \"temp_enable\": \"Tijdelijk inschakelen…\",\n        \"unset\": \"Uitgeschakeld\",\n        \"use_default\": \"Standaard gebruiken\",\n        \"view_random\": \"Willekeurig tonen\",\n        \"reset_play_duration\": \"Afspeelduur herstellen\",\n        \"reset_resume_time\": \"Hervattijd herstellen\",\n        \"reset_cover\": \"Standaardomslag herstellen\",\n        \"anonymise\": \"Anonimiseren\",\n        \"assign_stashid_to_parent_studio\": \"Stash-id toevoegen aan studio en metagegevens bijwerken\",\n        \"add_sub_groups\": \"Voeg subgroepen toe\",\n        \"choose_date\": \"Kies een datum\",\n        \"clear_date_data\": \"Datumgegevens wissen\",\n        \"create_chapters\": \"Hoofdstuk maken\",\n        \"create_parent_studio\": \"Hoofdstudio toevoegen\",\n        \"disable\": \"Uitschakelen\",\n        \"download_anonymised\": \"Anoniem downloaden\",\n        \"enable\": \"Inschakelen\",\n        \"encoding_image\": \"Bezig met converteren…\",\n        \"migrate_blobs\": \"Blobs migreren\",\n        \"migrate_scene_screenshots\": \"Scèneschermfoto's migreren\",\n        \"optimise_database\": \"Databank optimaliseren\",\n        \"reassign\": \"Opnieuw toewijzen\",\n        \"remove_date\": \"Datum wissen\",\n        \"remove_from_containing_group\": \"Verwijderen uit groep\",\n        \"set_cover\": \"Gebruiken als omslag\",\n        \"split\": \"Splitsen\",\n        \"view_history\": \"Geschiedenis tonen\",\n        \"add_play\": \"Toevoegen aan afspeellijst\",\n        \"add_manual_date\": \"Voeg handmatige datum toe\",\n        \"add_o\": \"O toevoegen\",\n        \"clean_generated\": \"Gegenereerde bestanden wissen\",\n        \"make_primary\": \"Als primair aanduiden\",\n        \"reload\": \"Herladen\",\n        \"copy_to_clipboard\": \"Kopiëren naar klembord\",\n        \"swap\": \"Omwisselen\",\n        \"sidebar\": {\n            \"close\": \"Sluit zijbalk\",\n            \"open\": \"Open zijbalk\",\n            \"toggle\": \"Schakelaar Zijbalk\"\n        },\n        \"show_results\": \"Resultaat tonen\",\n        \"show_count_results\": \"{count} resultaten tonen\",\n        \"play\": \"Afspelen\",\n        \"load_filter\": \"Laad filter\",\n        \"load\": \"Laden\",\n        \"add_stash_id\": \"Stash ID toevoegen\"\n    },\n    \"actions_name\": \"Acties\",\n    \"age\": \"Leeftijd\",\n    \"aliases\": \"Aliassen\",\n    \"all\": \"alle\",\n    \"also_known_as\": \"Pseudoniem\",\n    \"ascending\": \"Oplopend\",\n    \"average_resolution\": \"Gemiddelde resolutie\",\n    \"birth_year\": \"Geboortejaar\",\n    \"birthdate\": \"Geboortedatum\",\n    \"bitrate\": \"Bitsnelheid\",\n    \"captions\": \"Bijschriften\",\n    \"career_length\": \"Duur van carrière\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Actieve stash-boxinstallatie:\",\n            \"blacklist_desc\": \"Items op de zwarte lijst worden uitgesloten van zoekopdrachten. Merk op dat het reguliere uitdrukkingen zijn en hoofdletterongevoelig. Bepaalde tekens hebben een achterwaartse schuine streep nodig: {chars_require_escape}\",\n            \"blacklist_label\": \"Zwarte lijst\",\n            \"query_mode_auto\": \"Automatisch\",\n            \"query_mode_auto_desc\": \"Gebruikt metagegevens (indien aanwezig) of bestandsnaam\",\n            \"query_mode_dir\": \"Map\",\n            \"query_mode_dir_desc\": \"Gebruikt enkel de map van het videobestand\",\n            \"query_mode_filename\": \"Bestandsnaam\",\n            \"query_mode_filename_desc\": \"Gebruikt alleen de bestandsnaam\",\n            \"query_mode_label\": \"Opvraagmodus\",\n            \"query_mode_metadata\": \"Metagegevens\",\n            \"query_mode_metadata_desc\": \"Gebruikt alleen de metagegevens\",\n            \"query_mode_path\": \"Locatie\",\n            \"query_mode_path_desc\": \"Gebruikt de volledige bestandslocatie\",\n            \"set_cover_desc\": \"Vervang de scèneomslag als er een aangetroffen is.\",\n            \"set_cover_label\": \"Scèneomslag instellen\",\n            \"set_tag_desc\": \"Voorzie een scène van labels door ze te overschrijven of samen te voegen met reeds aanwezige.\",\n            \"set_tag_label\": \"Labels instellen\",\n            \"source\": \"Bron\",\n            \"mark_organized_label\": \"Markeren als geordend na opslaan\",\n            \"mark_organized_desc\": \"Markeer een scène als geordend na klikken op opslaan.\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Dubbel zwarte lijst item\"\n            }\n        },\n        \"noun_query\": \"Zoekvraag\",\n        \"results\": {\n            \"duration_off\": \"De duur is minstens {number}s\",\n            \"duration_unknown\": \"Onbekende duur\",\n            \"fp_found\": \"{fpCount, plural, =0 {Geen nieuwe vingerafdrukken gevonden} other {# nieuwe vingerafdrukken gevonden}}\",\n            \"fp_matches\": \"De duur komt overeen\",\n            \"fp_matches_multi\": \"De duur komt overeen {matchCount}/{durationsLength} met het aantal vingerafdrukken\",\n            \"hash_matches\": \"{hash_type} komt overeen\",\n            \"match_failed_already_tagged\": \"Deze scène is al gelabeld\",\n            \"match_failed_no_result\": \"Er zijn geen zoekresultaten\",\n            \"match_success\": \"De scène is gelabeld\",\n            \"phash_matches\": \"{count} PHashes komen overeen\",\n            \"unnamed\": \"Naamloos\"\n        },\n        \"verb_match_fp\": \"Overeenkomen met vingerafdrukken\",\n        \"verb_matched\": \"Overeenkomstig\",\n        \"verb_scrape_all\": \"Alles scrapen\",\n        \"verb_submit_fp\": \"{fpCount, plural, one{# vingerafdruk} other{# vingerafdrukken}} indienen\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} niet-overeenkomende scènes\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Controlesom van bouwsel:\",\n            \"build_time\": \"Bouwtijd:\",\n            \"check_for_new_version\": \"Controleren op updates\",\n            \"latest_version\": \"Nieuwste versie\",\n            \"latest_version_build_hash\": \"Controlesome van nieuwste versie:\",\n            \"new_version_notice\": \"[NIEUW]\",\n            \"stash_discord\": \"Neem deel aan ons {url}-kanaal\",\n            \"stash_home\": \"Stash thuis bij {url}\",\n            \"stash_open_collective\": \"Steun ons via {url}\",\n            \"stash_wiki\": \"Stash {url}-pagina\",\n            \"version\": \"Versie\",\n            \"release_date\": \"Uitgebracht op:\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Programmalocaties\"\n        },\n        \"categories\": {\n            \"about\": \"Over\",\n            \"interface\": \"Vormgeving\",\n            \"logs\": \"Logboeken\",\n            \"metadata_providers\": \"Metagegevensdiensten\",\n            \"plugins\": \"Plug-ins\",\n            \"scraping\": \"Scraping\",\n            \"security\": \"Beveiliging\",\n            \"services\": \"Diensten\",\n            \"system\": \"Systeem\",\n            \"tasks\": \"Taken\",\n            \"tools\": \"Hulpmiddelen\",\n            \"changelog\": \"Wijzigingslog\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"{tempIP} toestaan\",\n            \"allowed_ip_addresses\": \"Toegestane ip-adressen\",\n            \"allowed_ip_temporarily\": \"Tijdelijk toegestaan ip-adres\",\n            \"default_ip_whitelist\": \"Standaard witte lijst met ip-adressen\",\n            \"default_ip_whitelist_desc\": \"Standaard ip-adressen hebben toegang tot DLNA. Gebruik {wildcard} om alle ip-adressen toe te staan.\",\n            \"disabled_dlna_temporarily\": \"DLNA tijdelijk uitschakelen\",\n            \"disallowed_ip\": \"Geweigerd ip-adres\",\n            \"enabled_by_default\": \"Standaard ingeschakeld\",\n            \"enabled_dlna_temporarily\": \"DLNA tijdelijk toegestaan\",\n            \"network_interfaces\": \"Interfaces\",\n            \"network_interfaces_desc\": \"Interfaces om de DLNA-server aan bloot te stellen. Een lege lijst resulteert in uitvoering op alle interfaces. Herstart DLNA om de wijzigingen toe te passen.\",\n            \"recent_ip_addresses\": \"Recente ip-adressen\",\n            \"server_display_name\": \"Servernaam\",\n            \"server_display_name_desc\": \"De weergavenaam van de DLNA-server. Wordt standaard ingesteld op {server_naam} indien leeg.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Tijdelijk gedrag geannuleerd\",\n            \"until_restart\": \"tot aan herstart\",\n            \"server_port_desc\": \"De poort waarop de DLNA-server draait. Herstart DLNA om de wijzigingen toe te passen.\",\n            \"video_sort_order\": \"Standaard sorteervolgorde\",\n            \"video_sort_order_desc\": \"De standaard sorteervolgorde van video's.\",\n            \"server_port\": \"Serverpoort\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"Api-sleutel\",\n                \"api_key_desc\": \"De api-sleutel van externe systemen. Alleen vereist indien gebruikersnaam en wachtwoord zijn ingesteld. De gebruikersnaam moet worden bewaard voordat api-sleutel wordt gegenereerd.\",\n                \"authentication\": \"Verificatie\",\n                \"clear_api_key\": \"Api-sleutel wissen\",\n                \"credentials\": {\n                    \"description\": \"Inloggegevens om de toegang tot je verzameling te beperken.\",\n                    \"heading\": \"Inloggegevens\"\n                },\n                \"generate_api_key\": \"Api-sleutel genereren\",\n                \"log_file\": \"Logboek\",\n                \"log_file_desc\": \"De locatie van het bestand waarin gelogd dient te worden. Laat leeg om niet te loggen. Herstart vereist.\",\n                \"log_http\": \"Http-toegang loggen\",\n                \"log_http_desc\": \"Log http-toegangsmeldingen naar de terminal. Herstart vereist.\",\n                \"log_to_terminal\": \"Loggen naar terminal\",\n                \"log_to_terminal_desc\": \"Log zowel naar de terminal als naar een bestand. Altijd aan als loggen naar bestand is uitgeschakeld. Herstart vereist.\",\n                \"maximum_session_age\": \"Maximale ouderdom van sessie\",\n                \"maximum_session_age_desc\": \"De maximale tijd voordat een sessie verloopt, in seconden. Herstart vereist.\",\n                \"password\": \"Wachtwoord\",\n                \"password_desc\": \"Wachtwoord om je verzameling te openen. Laat leeg om inloggen uit te schakelen\",\n                \"stash-box_integration\": \"Stash-boxintegratie\",\n                \"username\": \"Gebruikersnaam\",\n                \"username_desc\": \"Gebruikersnaam om je verzameling te openen. Laat leeg om inloggen uit te schakelen\",\n                \"log_file_max_size\": \"Maximale loggrootte\",\n                \"log_file_max_size_desc\": \"Maximale grootte in megabytes van het logbestand voordat het wordt gecomprimeerd. 0 MB is uitgeschakeld. Vereist herstart.\"\n            },\n            \"cache_location\": \"Locatie van de cachemap. Vereist als je streamt via HLS (zoals op Apple apparaten) of DASH.\",\n            \"cache_path_head\": \"Cache pad\",\n            \"calculate_md5_and_ohash_desc\": \"Bereken MD5-checksum naast de oshash. Inschakelen zal de eerste scans trager maken. Bestandsnaam hash moet op oshash staan om MD5 hash uit te schakelen.\",\n            \"calculate_md5_and_ohash_label\": \"Bereken MD5 voor video's\",\n            \"check_for_insecure_certificates\": \"Controleer op onveilige certificaten\",\n            \"check_for_insecure_certificates_desc\": \"Sommige websites gebruiken onveilige SSL-certificaten. Uitgevinkt slaat de schraper de controle van de certificaten over. Als je een certificaat-foutmelding krijgt, vink dit uit.\",\n            \"chrome_cdp_path\": \"Chrome CDP pad\",\n            \"chrome_cdp_path_desc\": \"Bestandspad naar Chrome's uitvoerbaar bestand, of een adres op afstand (beginnend met http:// of https://, bijvoorbeeld http://localhost:9222/json/version) naar een Chrome installatie.\",\n            \"create_galleries_from_folders_desc\": \"Als aangevinkt, maak galerijen van mappen met afbeeldingen in.\",\n            \"create_galleries_from_folders_label\": \"Maak galerijen van mappen waar afbeeldingen in staan\",\n            \"db_path_head\": \"Pad databank\",\n            \"directory_locations_to_your_content\": \"Paden met jouw inhoud\",\n            \"excluded_image_gallery_patterns_desc\": \"Reguliere expressie van afbeelding- en galerij-paden uitsluiten van Scan and toevoegen bij opruimen\",\n            \"excluded_image_gallery_patterns_head\": \"Uitgesloten afbeelding/galerij expressies\",\n            \"excluded_video_patterns_desc\": \"Reguliere expressies van video bestanden of paden uit te sluiten van scan en toe te voegen aan opruimen\",\n            \"excluded_video_patterns_head\": \"Uitgesloten video expressies\",\n            \"gallery_ext_desc\": \"Komma-gescheiden lijst van bestandsextenties die als zip-galerijen zullen worden herkend.\",\n            \"gallery_ext_head\": \"Galerij zip bestandsextensies\",\n            \"generated_file_naming_hash_desc\": \"Gebruik MD5 of oshash voor gegenereerde bestandsnamen. Dit aanpassen vereist dat alle scenes de toepasselijke MD5 of oshash waarde hebben ingevuld. Na dit te veranderen zullen reeds bestaande gegenereerde bestanden moeten worden hernoemd of hergenereerd. Zie de pagina Taken voor deze migratie.\",\n            \"generated_file_naming_hash_head\": \"Gebruikte hash voor gegenereerde bestandsnamen\",\n            \"generated_files_location\": \"Bestandspad voor gegenereerde bestanden (scene markers, scene voorbeelden, afbeeldingen, enz.)\",\n            \"generated_path_head\": \"Pad voor gegenereerde bestanden\",\n            \"hashing\": \"Hashing\",\n            \"image_ext_desc\": \"Komma gescheiden lijst van bestandsextensies die als afbeeldingen worden gezien.\",\n            \"image_ext_head\": \"Bestandsextensies afbeeldingen\",\n            \"include_audio_desc\": \"Voeg audio toe aan gegenereerde voorbeelden.\",\n            \"include_audio_head\": \"Inclusief audio\",\n            \"logging\": \"Loggen\",\n            \"maximum_streaming_transcode_size_desc\": \"Maximaal formaat voor getranscodeerde streams\",\n            \"maximum_streaming_transcode_size_head\": \"Maximaal formaat voor stream transcoderen\",\n            \"maximum_transcode_size_desc\": \"Maximaal formaat voor gegenereerde transcodering\",\n            \"maximum_transcode_size_head\": \"Maximale transcodeer formaat\",\n            \"metadata_path\": {\n                \"description\": \"Bestandspad voor de volledige export of import\",\n                \"heading\": \"Bestandspad voor metadata\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Zet op 0 voor automatische detectie. Waarschuwing: Als het aantal taken 100% van de CPU capaciteit in beslag neemt, zal dit leiden tot verminderde performance en mogelijk andere problemen.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Aantal parallelle taken voor scannen/genereren\",\n            \"parallel_scan_head\": \"Parallel scannen/genereren\",\n            \"preview_generation\": \"Voorbeeld genereren\",\n            \"python_path\": {\n                \"description\": \"Locatie van Python executable. Gebruikt voor scriptschrapers en plug-ins. Indien leeg, wordt python opgelost vanuit de omgeving\",\n                \"heading\": \"Python pad\"\n            },\n            \"scraper_user_agent\": \"User Agent van de schraper\",\n            \"scraper_user_agent_desc\": \"User-agent gebruikt tijdens het schrapen van HTTP verzoeken\",\n            \"scrapers_path\": {\n                \"description\": \"Map locatie van schraper configuratie bestanden\",\n                \"heading\": \"Schrapers Pad\"\n            },\n            \"scraping\": \"Scraping\",\n            \"sqlite_location\": \"Bestandslocatie voor de SQLite-database (herstart vereist). WAARSCHUWING: het opslaan van de database op een ander systeem dan waar de Stash-server wordt uitgevoerd (bijv. via het netwerk) wordt niet ondersteund!\",\n            \"video_ext_desc\": \"Komma gescheiden lijst van bestandsextensie die worden aangemerkt als video.\",\n            \"video_ext_head\": \"Video extensies\",\n            \"video_head\": \"Video\",\n            \"backup_directory_path\": {\n                \"description\": \"Maplocatie met SQLite-databankreservekopieën\",\n                \"heading\": \"Reservekopiemap\"\n            },\n            \"database\": \"Database\",\n            \"ffmpeg\": {\n                \"download_ffmpeg\": {\n                    \"heading\": \"FFmpeg downloaden\",\n                    \"description\": \"Downloadt FFmpeg naar de configuratiemap en wist de ffmpeg- en ffprobe-paden zodat deze uit de configuratiemap kunnen worden opgehaald.\"\n                },\n                \"hardware_acceleration\": {\n                    \"heading\": \"FFmpeg hardwarecodering\",\n                    \"desc\": \"Gebruikt beschikbare hardware om video te coderen voor live transcodering.\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"FFmpeg uitvoerbaar pad\",\n                    \"description\": \"Pad naar het uitvoerbare bestand van ffmpeg (niet alleen de map). Indien leeg, wordt ffmpeg vanuit de omgeving opgelost via $PATH, de configuratiemap of vanuit $HOME/.stash\"\n                },\n                \"ffprobe_path\": {\n                    \"heading\": \"FFprobe uitvoerbaar pad\",\n                    \"description\": \"Pad naar het uitvoerbare bestand ffprobe (niet alleen de map). Indien leeg, wordt ffprobe vanuit de omgeving opgelost via $PATH, de configuratiemap of vanuit $HOME/.stash\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Geavanceerd: Extra argumenten om door te geven aan ffmpeg vóór het invoerveld bij het live transcoderen van video.\",\n                        \"heading\": \"FFmpeg Live Transcode Invoer Argumenten\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Geavanceerd: Extra argumenten om door te geven aan ffmpeg vóór het uitvoerveld bij het live transcoderen van video.\",\n                        \"heading\": \"FFmpeg Live Transcode Uitvoer Argumenten\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Geavanceerd: Extra argumenten om door te geven aan ffmpeg vóór het invoerveld bij het genereren van video.\",\n                        \"heading\": \"FFmpeg Transcode Invoer Argumenten\"\n                    },\n                    \"output_args\": {\n                        \"heading\": \"FFmpeg Transcode Uitvoer Argumenten\",\n                        \"desc\": \"Geavanceerd: Extra argumenten die aan ffmpeg moeten worden doorgegeven vóór het uitvoerveld bij het genereren van video.\"\n                    }\n                }\n            },\n            \"blobs_storage\": {\n                \"heading\": \"Type binaire gegevensopslag\",\n                \"description\": \"Waar binaire gegevens zoals scènecovers, performer-, studio- en tagafbeeldingen worden opgeslagen. Nadat u deze waarde hebt gewijzigd, moeten de bestaande gegevens worden gemigreerd met behulp van de taken Blobs migreren. Zie de pagina Taken voor meer informatie over migratie.\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Locatie in het bestandssysteem waar binaire gegevens worden opgeslagen. Alleen van toepassing bij gebruik van het Bestandssysteem blob-opslagtype. WAARSCHUWING: om dit te wijzigen, moeten bestaande gegevens handmatig worden verplaatst.\",\n                \"heading\": \"Pad naar binair gegevensbestandssysteem\"\n            },\n            \"heatmap_generation\": \"Funscript Heatmap Generatie\",\n            \"gallery_cover_regex_desc\": \"Regexp gebruikt om een afbeelding te identificeren als galerijomslag\",\n            \"plugins_path\": {\n                \"description\": \"Map locatie van plugin-configuratiebestanden\",\n                \"heading\": \"Plugin Pad\"\n            },\n            \"gallery_cover_regex_label\": \"Galerie omslagpatroon\",\n            \"funscript_heatmap_draw_range\": \"Bereik opnemen in gegenereerde heatmaps\",\n            \"funscript_heatmap_draw_range_desc\": \"Teken het bewegingsbereik op de y-as van de gegenereerde heatmap. Bestaande heatmaps moeten na wijziging opnieuw worden gegenereerd.\"\n        },\n        \"library\": {\n            \"exclusions\": \"Uitzonderingen\",\n            \"gallery_and_image_options\": \"Gallerij en Foto opties\",\n            \"media_content_extensions\": \"Media content extensies\"\n        },\n        \"logs\": {\n            \"log_level\": \"Log niveau\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Triggers\",\n            \"triggers_on\": \"Reageert op\",\n            \"available_plugins\": \"Beschikbare Plugins\",\n            \"installed_plugins\": \"Geïnstalleerde Plugins\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metadata\",\n            \"entity_scrapers\": \"{entityType} schrapers\",\n            \"excluded_tag_patterns_desc\": \"Regexps van tag namen die moeten worden uitgesloten van de schraper resultaten\",\n            \"excluded_tag_patterns_head\": \"Uitgesloten Tag patronen\",\n            \"scraper\": \"Schraper\",\n            \"scrapers\": \"Schrapers\",\n            \"search_by_name\": \"Zoek op naam\",\n            \"supported_types\": \"Ondersteunde types\",\n            \"supported_urls\": \"URL's\",\n            \"installed_scrapers\": \"Geïnstalleerde Schrapers\",\n            \"available_scrapers\": \"Beschikbare schrapers\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Voeg stash-box instance toe\",\n            \"api_key\": \"API sleutel\",\n            \"description\": \"Stash-box faciliteert geautomatiseerde tagging van scenes en artiesten op basis van fingerprints en bestandsnamen.\\nEndpoint en API sleutel kunnen op de account pagina worden gevonden op de stash-box instance. Namen zijn verplicht als er meer dan één instance wordt toegevoegd.\",\n            \"endpoint\": \"Eindpunt\",\n            \"graphql_endpoint\": \"GraphQL eindpunt\",\n            \"name\": \"Naam\",\n            \"title\": \"Stash-box Eindpunten\",\n            \"max_requests_per_minute_description\": \"Gebruikt de standaardwaarde van {defaultValue} als deze is ingesteld op 0\",\n            \"max_requests_per_minute\": \"Max aanvragen per minuut\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transcoderen\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"{operation_name} aan takenrij toegevoegd\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Alle paden automatisch taggen\",\n                \"auto_tagging_paths\": \"De volgende paden automatisch taggen\"\n            },\n            \"auto_tag_based_on_filenames\": \"Automatisch taggen baseren op bestandsnamen.\",\n            \"auto_tagging\": \"Automatisch taggen\",\n            \"backing_up_database\": \"Database back-uppen\",\n            \"backup_and_download\": \"Voert een backup uit van de database en download het backup bestand.\",\n            \"cleanup_desc\": \"Controleer op missende bestanden en verwijder deze uit de database. Dit is een destructieve handeling.\",\n            \"data_management\": \"Opslagbeheer\",\n            \"defaults_set\": \"Standaarden zijn ingesteld en zullen gebruikt worden wanneer de {action} knop op de Taken pagina ingedrukt wordt.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Neem geen bestandsextensie op als onderdeel van de titel\",\n            \"empty_queue\": \"Er zijn momenteel geen taken uitgevoerd.\",\n            \"export_to_json\": \"Exporteert de database-inhoud in JSON-formaat naar de metadata map.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Genereren voor scènes uit de volgende paden\",\n                \"generating_scenes\": \"Genereren voor {num} {scene}\"\n            },\n            \"generate_desc\": \"Genereer ondersteunend foto, sprite, video, vtt en andere bestanden.\",\n            \"generate_phashes_during_scan\": \"Genereer perceptuele hashes\",\n            \"generate_phashes_during_scan_tooltip\": \"Voor deduplicatie en scène-identificatie.\",\n            \"generate_previews_during_scan\": \"Genereer geanimeerde afbeelding previews\",\n            \"generate_previews_during_scan_tooltip\": \"Genereer geanimeerde webp-previews, alleen vereist als het voorbeeldtype is ingesteld op geanimeerde afbeelding.\",\n            \"generate_sprites_during_scan\": \"Genereer scrubber sprites\",\n            \"generate_thumbnails_during_scan\": \"Genereer miniaturen voor afbeeldingen\",\n            \"generate_video_previews_during_scan\": \"Previews genereren\",\n            \"generate_video_previews_during_scan_tooltip\": \"Genereer video-previews die afspelen bij het zweven over een scène\",\n            \"generated_content\": \"Gegenereerde inhoud\",\n            \"identify\": {\n                \"and_create_missing\": \"en creëer missende\",\n                \"create_missing\": \"Missende aanmaken\",\n                \"default_options\": \"Standaard instellingen\",\n                \"description\": \"Stel automatisch scène metadata in met behulp van stash-box en schraperbronnen.\",\n                \"explicit_set_description\": \"De volgende opties worden gebruikt waar niet wordt opgeheven in de bronspecifieke opties.\",\n                \"field\": \"Veld\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Veld Opties\",\n                \"heading\": \"Identificeer\",\n                \"identifying_from_paths\": \"Scènes identificeren uit de volgende paden\",\n                \"identifying_scenes\": \"Identificeren {num} {scene}\",\n                \"include_male_performers\": \"Inclusief mannelijke artiesten\",\n                \"set_cover_images\": \"Set Cover-afbeeldingen\",\n                \"set_organized\": \"Georganiseerde vlag instellen\",\n                \"source\": \"Bron\",\n                \"source_options\": \"{source} Opties\",\n                \"sources\": \"Bronnen\",\n                \"strategy\": \"Strategie\",\n                \"skip_multiple_matches\": \"Sla overeenkomsten met meer dan één resultaat over\"\n            },\n            \"import_from_exported_json\": \"Import van geëxporteerde JSON in de map metadata. Maakt de bestaande database leeg.\",\n            \"incremental_import\": \"Incrementele import uit een meegeleverde export zip-bestand.\",\n            \"job_queue\": \"Taakwachtrij\",\n            \"maintenance\": \"Onderhoud\",\n            \"migrate_hash_files\": \"Gebruikt na het wijzigen van de gegenereerde bestandsnaaming Hash om bestaande gegenereerde bestanden te hernoemen naar het nieuwe hash-formaat.\",\n            \"migrations\": \"Migraties\",\n            \"only_dry_run\": \"Voer alleen een testronde uit. Verwijder niets\",\n            \"plugin_tasks\": \"Plugin Taken\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Alle paden scannen\",\n                \"scanning_paths\": \"Scannen van de volgende paden\"\n            },\n            \"scan_for_content_desc\": \"Scan naar nieuwe inhoud en voeg deze toe aan de database.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Stel de naam, datum, details in vanuit Embedded File Metadata\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blob bestanden\",\n                \"image_thumbnails_desc\": \"Afbeeldingsminiaturen en clips\",\n                \"markers\": \"Markeer Voorbeelden\",\n                \"previews\": \"Scene Voorbeelden\",\n                \"sprites\": \"Scène Sprites\",\n                \"transcodes\": \"Scène Transcoderingen\",\n                \"description\": \"Verwijdert gegenereerde bestanden zonder bijbehorende database-invoer.\",\n                \"previews_desc\": \"Scene voorbeelden en afbeeldingsminiaturen\",\n                \"image_thumbnails\": \"Afbeeldingsminiaturen\"\n            },\n            \"anonymising_database\": \"Anonimiseer database\",\n            \"anonymise_database\": \"Maakt een kopie van de database naar de backups-map, waarbij alle gevoelige gegevens anoniem worden gemaakt. Deze kan vervolgens aan anderen worden verstrekt voor probleemoplossing en debugging. De originele database wordt niet gewijzigd. De geanonimiseerde database gebruikt de bestandsnaamindeling {filename_format}.\",\n            \"anonymise_and_download\": \"Maakt een geanonimiseerde kopie van de database en downloadt het resulterende bestand.\",\n            \"generate_clip_previews_during_scan\": \"Genereer voorbeelden van Afbeelingsfragmenten\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Verwijder oude gegevens\"\n            },\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Verwijder screenshotbestanden\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"De set afbeeldingen die onder de videospeler worden weergegeven voor eenvoudige navigatie.\",\n            \"generate_video_covers_during_scan\": \"Scène-covers genereren\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Scène Duplicator Checker\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Veld toevoegen\",\n                \"capitalize_title\": \"Kapitaliseer de titel\",\n                \"display_fields\": \"Weergavevelden\",\n                \"escape_chars\": \"Gebruik \\\\ om letterlijke karakters te ontsnappen\",\n                \"filename\": \"Bestandsnaam\",\n                \"filename_pattern\": \"Bestandsnaam patroon\",\n                \"ignore_organized\": \"Negeer georganiseerde scenes\",\n                \"ignored_words\": \"Genegeerde woorden\",\n                \"matches_with\": \"Komt overeen met {i}\",\n                \"select_parser_recipe\": \"Selecteer Parser Recept\",\n                \"title\": \"Scène-bestandsnaam parser\",\n                \"whitespace_chars\": \"WhiteSpace-tekens\",\n                \"whitespace_chars_desc\": \"Deze tekens worden vervangen door witruimte in de titel\"\n            },\n            \"scene_tools\": \"Scene gereedschap\",\n            \"graphql_playground\": \"GraphQL speeltuin\",\n            \"heading\": \"Hulpmiddelen\"\n        },\n        \"ui\": {\n            \"basic_settings\": \"Basis instellingen\",\n            \"custom_css\": {\n                \"description\": \"Pagina moet worden herladen voordat wijzigingen van kracht worden.\",\n                \"heading\": \"Aangepaste CSS\",\n                \"option_label\": \"Aangepaste CSS ingeschakeld\"\n            },\n            \"delete_options\": {\n                \"description\": \"Standaardinstellingen bij het verwijderen van afbeeldingen, galerijen en scènes.\",\n                \"heading\": \"Verwijder opties\",\n                \"options\": {\n                    \"delete_file\": \"Verwijder het bestand standaard\",\n                    \"delete_generated_supporting_files\": \"Verwijder gegenereerde ondersteunende bestanden standaard\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Desktop-integratie\",\n                \"notifications_enabled\": \"Meldingen inschakelen\",\n                \"send_desktop_notifications_for_events\": \"Desktop meldingen voor evenementen\",\n                \"skip_opening_browser\": \"Sla het openen van een browser over\",\n                \"skip_opening_browser_on_startup\": \"Sla het automatisch openen van een browser over tijdens het opstarten\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Verwijder de mogelijkheid om nieuwe objecten te maken uit de dropdown menu\",\n                    \"heading\": \"Schakel het maken van dropdowns uit\"\n                },\n                \"heading\": \"Aanpassen\",\n                \"rating_system\": {\n                    \"type\": {\n                        \"options\": {\n                            \"decimal\": \"Decimaal\",\n                            \"stars\": \"Sterren\"\n                        }\n                    },\n                    \"star_precision\": {\n                        \"options\": {\n                            \"full\": \"Vol\",\n                            \"half\": \"Half\",\n                            \"quarter\": \"Kwart\",\n                            \"tenth\": \"Tiende\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Time Offset in milliseconden voor het afspelen van interactieve scripts.\",\n                \"heading\": \"\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Connecteer\",\n                \"server_offset\": {\n                    \"heading\": \"Server-offset\"\n                },\n                \"status\": {\n                    \"heading\": \"Handige verbindings status\"\n                },\n                \"sync\": \"Synchroniseer\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Handy connection key om te gebruiken voor interactieve scènes. Instellen van deze sleutel staat Stash toe om uw huidige scène-informatie met HandyFeeling.com te delen\",\n                \"heading\": \"\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Afbeelding Lightbox\"\n            },\n            \"images\": {\n                \"heading\": \"Afbeeldingen\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Schrijf de beeldminiaturen naar de schijf wanneer on-the-fly is gegenereerd\",\n                        \"heading\": \"Schrijf beeldminiaturen\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Interactieve Opties\",\n            \"language\": {\n                \"heading\": \"Taal\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Maximale scène-duur waarbij scènespeler de video loopt - 0 om uit te schakelen\",\n                \"heading\": \"Maximale lusduur\"\n            },\n            \"menu_items\": {\n                \"description\": \"Toon of verberg verschillende soorten inhoud op de navigatiebalk\",\n                \"heading\": \"Menu Items\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Aangepast pad voor standaard performers. Laat leeg om in gebouwde standaardinstellingen te gebruiken\",\n                        \"heading\": \"Aangepaste Performer afbeelding pad\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"De standaardoptie is videovoorbeeld (mp4). Voor minder CPU-gebruik tijdens het browsen kunt u de geanimeerde afbeeldingsvoorbeelden (webp) gebruiken. Deze moeten echter worden gegenereerd naast de videovoorbeelden en zijn grotere bestanden.\",\n                \"heading\": \"Voorbeeld Type\",\n                \"options\": {\n                    \"animated\": \"Geanimeerde afbeelding\",\n                    \"static\": \"Statische afbeelding\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Scene lijst\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Laat studio's als text zien\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Scènespeler\",\n                \"options\": {\n                    \"auto_start_video\": \"Auto-start video\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"scene automatisch starten bij het afspelen van geselecteerde of willekeurige van scènes pagina\",\n                        \"heading\": \"Auto-start video bij het afspelen van geselecteerde\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Speel de volgende scène in de wachtrij wanneer video is voltooid\",\n                        \"heading\": \"Ga Standaard door met de afspeellijst\"\n                    },\n                    \"always_start_from_beginning\": \"Start video altijd vanaf het begin\",\n                    \"enable_chromecast\": \"Chromecast inschakelen\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Scène / markeermuur\",\n                \"options\": {\n                    \"display_title\": \"Titel en tags weergeven\",\n                    \"toggle_sound\": \"Geluid inschakelen\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Aantal keren dat moet worden geprobeerd te bladeren voordat naar het volgende/vorige item wordt gegaan. Geldt alleen voor Pan Y scroll-modus.\",\n                \"heading\": \"Scrollpogingen voor de overgang\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Diavoorstelling is beschikbaar in galerijen in de muurweergavemodus\",\n                \"heading\": \"Diavoorstellingsvertraging (in seconden)\"\n            },\n            \"title\": \"Gebruikers interface\",\n            \"custom_javascript\": {\n                \"heading\": \"Aangepaste JavaScript\",\n                \"option_label\": \"Aangepaste JavaScript ingeschakeld\"\n            },\n            \"custom_locales\": {\n                \"heading\": \"Aangepaste lokalisatie\",\n                \"option_label\": \"Aangepaste lokalisatie ingeschakeld\"\n            },\n            \"detail\": {\n                \"enable_background_image\": {\n                    \"description\": \"Achtergrondfoto op detailscherm weergeven.\",\n                    \"heading\": \"Achtergrondfoto inschakelen\"\n                },\n                \"heading\": \"Detailscherm\",\n                \"show_all_details\": {\n                    \"heading\": \"Alle details weergeven\"\n                }\n            },\n            \"image_wall\": {\n                \"margin\": \"Marge (pixels)\",\n                \"direction\": \"Richting\"\n            }\n        },\n        \"advanced_mode\": \"Geavanceerde modus\"\n    },\n    \"configuration\": \"Configuratie\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Bestand} other {Bestanden}}\",\n        \"galleries\": \"{count, plural, one {Galerij} other {Galerijen}}\",\n        \"images\": \"{count, plural, one {Afbeelding} other {Afbeeldingen}}\",\n        \"markers\": \"\",\n        \"performers\": \"\",\n        \"scenes\": \"\",\n        \"studios\": \"\",\n        \"tags\": \"\"\n    },\n    \"country\": \"Land\",\n    \"cover_image\": \"Cover afbeelding\",\n    \"created_at\": \"Gemaakt op\",\n    \"criterion\": {\n        \"greater_than\": \"Groter dan\",\n        \"less_than\": \"Minder dan\",\n        \"value\": \"Waarde\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"tussen\",\n        \"equals\": \"is\",\n        \"excludes\": \"uitsluitend\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"is groter dan\",\n        \"includes\": \"omvat\",\n        \"includes_all\": \"omvat alles\",\n        \"is_null\": \"is null\",\n        \"less_than\": \"is minder dan\",\n        \"matches_regex\": \"komt overeen met regex\",\n        \"not_between\": \"niet tussen\",\n        \"not_equals\": \"is geen\",\n        \"not_matches_regex\": \"komt niet overeen met regex\",\n        \"not_null\": \"is geen null\"\n    },\n    \"custom\": \"Aangepast\",\n    \"date\": \"Datum\",\n    \"death_date\": \"Sterfdatum\",\n    \"death_year\": \"Sterfjaar\",\n    \"descending\": \"Aflopend\",\n    \"detail\": \"Detail\",\n    \"details\": \"Details\",\n    \"developmentVersion\": \"Ontwikkelingsversie\",\n    \"dialogs\": {\n        \"delete_alert\": \"De volgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} zal permanent verwijderd worden:\",\n        \"delete_confirm\": \"Weet je zeker dat je {entityName} wilt verwijderen?\",\n        \"delete_entity_desc\": \"{count, plural, one {Weet u zeker dat u deze {singularEntity} wilt verwijderen? Tenzij het bestand ook wordt verwijderd, wordt deze {singularEntity} opnieuw toegevoegd wanneer de scan wordt uitgevoerd.} other {Weet u zeker dat u deze {pluralEntity} wilt verwijderen? Tenzij de bestanden ook worden verwijderd, worden deze {pluralEntity} opnieuw toegevoegd wanneer de scan wordt uitgevoerd.}}\",\n        \"delete_entity_title\": \"{count, plural, one {Verwijder{singularEntity}} other {Verwijder{pluralEntity}}}\",\n        \"delete_galleries_extra\": \"... plus alle afbeeldingsbestanden die niet aan een andere galerij zijn gekoppeld.\",\n        \"delete_gallery_files\": \"Verwijder de galerijmap/zip-bestand en alle afbeeldingen die niet aan een andere galerij zijn gekoppeld.\",\n        \"delete_object_desc\": \"Weet u zeker dat u {count, plural, one {this {singularEntity}} other {these {pluralEntity}}} wilt gaan verwijderen?\",\n        \"delete_object_overflow\": \"…en {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Verwijder {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"edit_entity_title\": \"Wijzig {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Verwante objecten opnemen met het exporteren\",\n        \"export_title\": \"Exporteer\",\n        \"lightbox\": {\n            \"delay\": \"Vertraging (Sec)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Pas horizontaal\",\n                \"fit_to_screen\": \"Pas naar scherm\",\n                \"label\": \"Weergave modus\",\n                \"original\": \"Orgineel\"\n            },\n            \"options\": \"Opties\",\n            \"reset_zoom_on_nav\": \"Zoomniveau resetten bij het wijzigen van afbeelding\",\n            \"scale_up\": {\n                \"description\": \"Schaal kleinere afbeeldingen omhoog om het scherm te vullen\",\n                \"label\": \"Opschalen om te passen\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Houd shift ingedrukt om tijdelijk een andere modus te gebruiken.\",\n                \"label\": \"Scroll modus\",\n                \"pan_y\": \"Pan Y\",\n                \"zoom\": \"Zoom\"\n            }\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Genereren van transcode forceren\",\n            \"force_transcodes_tooltip\": \"Standaard worden transcodes alleen gegenereerd als het videobestand niet wordt ondersteund in de browser. Indien ingeschakeld, worden transcodes gegenereerd, zelfs als het videobestand in de browser lijkt te worden ondersteund.\",\n            \"image_previews\": \"Geanimeerde afbeelding voorbeelden\",\n            \"image_previews_tooltip\": \"Geanimeerde WebP-voorbeelden, alleen vereist als Voorbeeldtype is ingesteld op Geanimeerde afbeelding.\",\n            \"interactive_heatmap_speed\": \"Genereer heatmaps en snelheden voor interactieve scènes\",\n            \"marker_image_previews\": \"Voorvertoningen van geanimeerde markeringen\",\n            \"marker_image_previews_tooltip\": \"Geanimeerde markering WebP-voorbeelden, alleen vereist als Voorbeeldtype is ingesteld op Geanimeerde afbeelding.\",\n            \"marker_screenshots\": \"Markeer Screenshots\",\n            \"marker_screenshots_tooltip\": \"Markeer statische JPG-afbeeldingen\",\n            \"markers\": \"Marker Voorbeelden\",\n            \"markers_tooltip\": \"Video's van 20 seconden die beginnen op de opgegeven tijdcode.\",\n            \"override_preview_generation_options\": \"Opties voor het genereren van voorvertoningen overschrijven\",\n            \"override_preview_generation_options_desc\": \"Overschrijf opties voor het genereren van voorbeelden voor deze bewerking. De standaardwaarden worden ingesteld in Systeem -> Voorbeeld genereren.\",\n            \"overwrite\": \"Bestaande gegenereerde bestanden overschrijven\",\n            \"phash\": \"Perceptuele hashes (voor deduplicatie)\",\n            \"preview_exclude_end_time_desc\": \"Sluit de laatste x seconden uit van scènevoorbeelden. Dit kan een waarde in seconden zijn, of een percentage (bijv. 2%) van de totale duur van de scène.\",\n            \"preview_exclude_end_time_head\": \"Eindtijd uitsluiten\",\n            \"preview_exclude_start_time_desc\": \"Sluit de eerste x seconden uit van scènevoorbeelden. Dit kan een waarde in seconden zijn, of een percentage (bijv. 2%) van de totale duur van de scène.\",\n            \"preview_exclude_start_time_head\": \"Starttijd uitsluiten\",\n            \"preview_generation_options\": \"Opties voor het genereren van voorbeelden\",\n            \"preview_options\": \"Voorbeeld opties\",\n            \"preview_preset_desc\": \"De voorinstelling regelt de grootte, kwaliteit en coderingstijd van het genereren van voorbeelden. Voorinstellingen die verder gaan dan \\\"langzaam\\\" hebben een afnemend rendement en worden niet aanbevolen.\",\n            \"preview_preset_head\": \"Voorbeeld van codering voorinstelling\",\n            \"preview_seg_count_desc\": \"Aantal segmenten in voorbeeldbestanden.\",\n            \"preview_seg_count_head\": \"Aantal segmenten in voorbeeld\",\n            \"preview_seg_duration_desc\": \"Duur van elk voorbeeldsegment, in seconden.\",\n            \"preview_seg_duration_head\": \"Voorbeeld Segment Duur\",\n            \"sprites\": \"Scène Scrubber Sprites\",\n            \"sprites_tooltip\": \"De set afbeeldingen die onder de videospeler worden weergegeven voor eenvoudige navigatie.\",\n            \"transcodes\": \"Transcoderingen\",\n            \"transcodes_tooltip\": \"MP4-conversies van niet-ondersteunde video-indelingen\",\n            \"video_previews\": \"Voorbeelden\",\n            \"video_previews_tooltip\": \"Videovoorbeelden die worden afgespeeld wanneer u over een scène beweegt\",\n            \"covers\": \"Scène-covers\",\n            \"image_thumbnails\": \"Afbeeldingsminiaturen\"\n        },\n        \"scenes_found\": \"{count} scenes gevonden\",\n        \"scrape_entity_query\": \"{entity_type} Schraper Query\",\n        \"scrape_entity_title\": \"{entity_type} Schraper Resultaten\",\n        \"scrape_results_existing\": \"Bestaande\",\n        \"scrape_results_scraped\": \"Geschraapt\",\n        \"set_image_url_title\": \"Afbeelding URL\",\n        \"unsaved_changes\": \"Niet-opgeslagen wijzigingen gaan verloren. Weet je zeker dat je wilt vertrekken?\",\n        \"merge\": {\n            \"destination\": \"Bestemming\",\n            \"source\": \"Bron\"\n        },\n        \"performers_found\": \"{count} artiesten gevonden\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Kolom\",\n                \"row\": \"Rij\"\n            }\n        }\n    },\n    \"dimensions\": \"Dimensies\",\n    \"director\": \"Regisseur\",\n    \"display_mode\": {\n        \"grid\": \"Rooster\",\n        \"list\": \"Lijst\",\n        \"tagger\": \"Label\",\n        \"unknown\": \"Onbekend\",\n        \"wall\": \"Muur\"\n    },\n    \"donate\": \"Doneer\",\n    \"dupe_check\": {\n        \"description\": \"Niveaus onder 'Exact' kunnen langer duren om te berekenen. Valse positieven kunnen ook worden geretourneerd bij lagere nauwkeurigheidsniveaus.\",\n        \"found_sets\": \"{setCount, plural, one{# set duplicaten gevonden.} other {# sets van duplicaten gevonden.}}\",\n        \"options\": {\n            \"exact\": \"Precies\",\n            \"high\": \"Hoog\",\n            \"low\": \"Laag\",\n            \"medium\": \"Medium\"\n        },\n        \"search_accuracy_label\": \"Zoek accuraatheid\",\n        \"title\": \"Dubbele Scènes\",\n        \"duration_options\": {\n            \"equal\": \"Gelijk\"\n        },\n        \"select_none\": \"Niets selecteren\",\n        \"select_options\": \"Selecteer Opties…\"\n    },\n    \"duplicated_phash\": \"Gedupliceerd (phash)\",\n    \"duration\": \"Looptijd\",\n    \"effect_filters\": {\n        \"aspect\": \"Aspect\",\n        \"blue\": \"Blauw\",\n        \"blur\": \"Waas\",\n        \"brightness\": \"Helderheid\",\n        \"contrast\": \"Contrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Groen\",\n        \"hue\": \"Tint\",\n        \"name\": \"Filters\",\n        \"name_transforms\": \"Transformeren\",\n        \"red\": \"Rood\",\n        \"reset_filters\": \"Reset Filters\",\n        \"reset_transforms\": \"Reset Transformaties\",\n        \"rotate\": \"Roteren\",\n        \"rotate_left_and_scale\": \"Naar links draaien en schalen\",\n        \"rotate_right_and_scale\": \"Naar rechts draaien en schalen\",\n        \"saturation\": \"Saturatie\",\n        \"scale\": \"Schaal\",\n        \"warmth\": \"Warmte\"\n    },\n    \"empty_server\": \"Voeg enkele scènes toe aan uw server om aanbevelingen op deze pagina te bekijken.\",\n    \"ethnicity\": \"Etniciteit\",\n    \"existing_value\": \"bestaande waarde\",\n    \"eye_color\": \"Oogkleur\",\n    \"fake_tits\": \"Neppe Tieten\",\n    \"false\": \"Vals\",\n    \"favourite\": \"Favoriet\",\n    \"file\": \"bestand\",\n    \"file_info\": \"Bestandsinformatie\",\n    \"file_mod_time\": \"Bestandsmodificatie tijd\",\n    \"files\": \"bestanden\",\n    \"filesize\": \"Bestands Groote\",\n    \"filter\": \"Filter\",\n    \"filter_name\": \"Filter Naam\",\n    \"filters\": \"Filters\",\n    \"framerate\": \"Frame snelheid\",\n    \"frames_per_second\": \"{value} fps\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Vooraf gemaakte filter\",\n            \"saved_filter\": \"Opgeslagen filter\"\n        }\n    },\n    \"galleries\": \"Galerijen\",\n    \"gallery\": \"Galerij\",\n    \"gallery_count\": \"Galerij aantal\",\n    \"gender\": \"Geslacht\",\n    \"gender_types\": {\n        \"FEMALE\": \"Vrouw\",\n        \"INTERSEX\": \"Intersex\",\n        \"MALE\": \"Man\",\n        \"NON_BINARY\": \"Non-binair\",\n        \"TRANSGENDER_FEMALE\": \"Transgender Vrouw\",\n        \"TRANSGENDER_MALE\": \"Transgender Man\"\n    },\n    \"hair_color\": \"Haarkleur\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Verbinden\",\n        \"disconnected\": \"Verbinding verbroken\",\n        \"error\": \"Fout bij het verbinden met Handy\",\n        \"missing\": \"Ontbrekend\",\n        \"ready\": \"Klaar\",\n        \"syncing\": \"Synchroniseren met server\",\n        \"uploading\": \"Script uploaden\"\n    },\n    \"hasMarkers\": \"Markeringen\",\n    \"height\": \"Hoogte\",\n    \"help\": \"Help\",\n    \"ignore_auto_tag\": \"Negeer automatische tag\",\n    \"image\": \"Afbeelding\",\n    \"image_count\": \"Afbeelding aantal\",\n    \"images\": \"Afbeeldingen\",\n    \"include_parent_tags\": \"Bovenliggende tags opnemen\",\n    \"include_sub_studios\": \"Dochteronderneming studio's opnemen\",\n    \"include_sub_tags\": \"Neem sub-tags op\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interactief\",\n    \"interactive_speed\": \"Interactieve snelheid\",\n    \"isMissing\": \"Is Missende\",\n    \"library\": \"Bibliotheek\",\n    \"loading\": {\n        \"generic\": \"Laden…\",\n        \"plugins\": \"Plugins laden…\"\n    },\n    \"marker_count\": \"Marker Aantal\",\n    \"markers\": \"Markeringen\",\n    \"measurements\": \"Afmetingen\",\n    \"media_info\": {\n        \"audio_codec\": \"Audio Codec\",\n        \"downloaded_from\": \"Gedownload van\",\n        \"interactive_speed\": \"Interactieve snelheid\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} bij productie\"\n        },\n        \"phash\": \"PHash\",\n        \"stream\": \"Stream\",\n        \"video_codec\": \"Video Codec\"\n    },\n    \"megabits_per_second\": \"{value} mbps\",\n    \"metadata\": \"Metadata\",\n    \"name\": \"Naam\",\n    \"new\": \"Nieuw\",\n    \"none\": \"Geen\",\n    \"operations\": \"Operaties\",\n    \"organized\": \"Georganiseerd\",\n    \"pagination\": {\n        \"first\": \"Eerste\",\n        \"last\": \"Laatste\",\n        \"next\": \"Volgende\",\n        \"previous\": \"Vorige\"\n    },\n    \"parent_of\": \"Ouder van {children}\",\n    \"parent_studios\": \"Ouderstudio's\",\n    \"parent_tag_count\": \"Aantal bovenliggende tags\",\n    \"parent_tags\": \"Bovenlagentlabels\",\n    \"part_of\": \"Onderdeel van {parent}\",\n    \"path\": \"Pad\",\n    \"perceptual_similarity\": \"Perceptuele gelijkenis (phash)\",\n    \"performer\": \"Performer\",\n    \"performer_age\": \"Leeftijd artiest\",\n    \"performer_count\": \"Performer Aantal\",\n    \"performer_favorite\": \"Artiest favoriet\",\n    \"performer_image\": \"Performer Afbeelding\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Nieuwe artiesten toevoegen\",\n        \"any_names_entered_will_be_queried\": \"Alle ingevoerde namen worden opgevraagd vanuit de externe Stash-Box-instantie en toegevoegd als ze worden gevonden. Alleen exacte overeenkomsten worden als een overeenkomst beschouwd.\",\n        \"batch_add_performers\": \"Batch Artiesten toevoegen\",\n        \"batch_update_performers\": \"Artiesten in batch bewerken\",\n        \"current_page\": \"Huidige pagina\",\n        \"failed_to_save_performer\": \"Kan artiest \\\"{performer}\\\" niet opslaan\",\n        \"name_already_exists\": \"Naam bestaat al\",\n        \"network_error\": \"Netwerkfout\",\n        \"no_results_found\": \"Geen resultaten gevonden.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} artiest(en) word(en) verwerkt\",\n        \"performer_already_tagged\": \"Artiest al getagd\"\n    },\n    \"performer_tags\": \"Peformer Labels\",\n    \"performers\": \"Performers\",\n    \"piercings\": \"Piercings\",\n    \"queue\": \"Wachtrij\",\n    \"random\": \"Willekeurig\",\n    \"rating\": \"Beoordeling\",\n    \"resolution\": \"Resolutie\",\n    \"scene\": \"Scène\",\n    \"sceneTagger\": \"Scene Labelen\",\n    \"scene_count\": \"Scene Aantal\",\n    \"scene_id\": \"Scene ID\",\n    \"scene_tags\": \"Scene Labels\",\n    \"scenes\": \"Scènes\",\n    \"scenes_updated_at\": \"Scène geüpdatet op\",\n    \"search_filter\": {\n        \"name\": \"Filter\",\n        \"saved_filters\": \"Opgeslagen filters\",\n        \"update_filter\": \"Filter Updaten\"\n    },\n    \"seconds\": \"Seconden\",\n    \"settings\": \"Instellingen\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"We zijn bijna klaar om de configuratie te voltooien. Bevestig de volgende instellingen. U kunt op terug klikken om iets onjuists te wijzigen. Als alles er goed uitziet, klikt u op Bevestigen om uw systeem aan te maken.\",\n            \"configuration_file_location\": \"Locatie configuratiebestand:\",\n            \"database_file_path\": \"Pad naar databasebestand\",\n            \"generated_directory\": \"Genereerde map\",\n            \"nearly_there\": \"Bijna Daar!\",\n            \"stash_library_directories\": \"Stash bibliotheekmappen\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Uw systeem aanmaken\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Oh nee! Er is iets fout gegaan!\",\n            \"something_went_wrong_description\": \"Als dit lijkt op een probleem met je invoer, ga je gang en klik je op Terug om ze op te lossen. Breng anders een bug aan op {githubLink} of zoek hulp in de {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Er is iets misgegaan tijdens het instellen van uw systeem. Dit is de fout die we hebben ontvangen: {error}\"\n        },\n        \"folder\": {\n            \"up_dir\": \"Een directory omhoog\"\n        },\n        \"github_repository\": \"Github repository\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Pad naar back-up database (leeg laten om back-up uit te schakelen):\",\n            \"backup_recommended\": \"Het wordt aanbevolen een back-up van uw bestaande database te maken voordat u migreert. We kunnen dit voor je doen door een kopie van je database te maken naar <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Database migreren\",\n            \"migration_failed\": \"Migratie gefaald\",\n            \"migration_failed_error\": \"De volgende fout is opgetreden tijdens het migreren van de database:\",\n            \"migration_failed_help\": \"Breng de nodige correcties aan en probeer het opnieuw. Breng anders een bug aan op {githubLink} of zoek hulp in de {discordLink}.\",\n            \"migration_irreversible_warning\": \"Het schemamigratieproces is niet omkeerbaar. Zodra de migratie is uitgevoerd, is uw database incompatibel met eerdere versies van stash.\",\n            \"migration_required\": \"Migratie benodigd\",\n            \"perform_schema_migration\": \"Schemamigratie uitvoeren\",\n            \"schema_too_old\": \"Uw huidige stashdatabase is schemaversie <strong>{databaseSchema}</strong> en moet worden gemigreerd naar versie <strong>{appSchema}</strong>. Deze versie van Stash werkt niet zonder de database te migreren.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"database bestandsnaam (leeg als standaard)\",\n            \"description\": \"Vervolgens moeten we bepalen waar we je collectie kunnen vinden, waar we de stash-database en gegenereerde bestanden kunnen opslaan. Deze instellingen kunnen indien nodig later worden gewijzigd.\",\n            \"path_to_generated_directory_empty_for_default\": \"pad naar gegenereerde map (standaard leeg)\",\n            \"set_up_your_paths\": \"Stel je paden in\",\n            \"stash_alert\": \"Er zijn geen bibliotheekpaden geselecteerd. Er kan dan geen media worden gescand in Stash. Weet je zeker dat?\",\n            \"where_can_stash_store_its_database\": \"Waar kan Stash zijn database opslaan?\",\n            \"where_can_stash_store_its_database_description\": \"Stash gebruikt een sqlite-database om je porno-metadata op te slaan. Standaard wordt dit aangemaakt als <code>stash-go.sqlite</code> in de map die je configuratiebestand bevat. Als u dit wilt wijzigen, voert u een absolute of relatieve (ten opzichte van de huidige werkdirectory) bestandsnaam in.\",\n            \"where_can_stash_store_its_generated_content\": \"Waar kan Stash de gegenereerde inhoud opslaan?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Om thumbnails, previews en sprites aan te bieden, genereert Stash afbeeldingen en video's. Dit omvat ook transcodes voor niet-ondersteunde bestandsindelingen. Standaard zal Stash een <code>generated</code> directory aanmaken in de directory die uw configuratiebestand bevat. Als u wilt wijzigen waar deze gegenereerde media wordt opgeslagen, voert u een absoluut of relatief (ten opzichte van de huidige werkmap) pad in. Stash maakt deze map aan als deze nog niet bestaat.\",\n            \"where_is_your_porn_located\": \"Waar staat je porno?\",\n            \"where_is_your_porn_located_description\": \"Voeg mappen toe die uw video's en afbeeldingen bevatten. Stash gebruikt deze mappen om video's en afbeeldingen te vinden tijdens het scannen.\"\n        },\n        \"stash_setup_wizard\": \"Stash-installatiewizard\",\n        \"success\": {\n            \"getting_help\": \"Help\",\n            \"help_links\": \"Als je problemen tegenkomt of vragen of suggesties hebt, open dan gerust een issue in de {githubLink}, of vraag het de community in de {discordLink}.\",\n            \"in_app_manual_explained\": \"Het is aanbevolen om de in-app handleiding te bekijken, het is raadpleegbaar via het {icon} icoontje rechts-boven\",\n            \"next_config_step_one\": \"U wordt vervolgens naar de configuratiepagina geleid. Op deze pagina kunt u aanpassen welke bestanden u wilt opnemen en uitsluiten, een gebruikersnaam en wachtwoord instellen om uw systeem te beschermen en een heleboel andere opties.\",\n            \"next_config_step_two\": \"Als je tevreden bent met deze instellingen, kun je beginnen met het scannen van je inhoud naar Stash door op <code>{localized_task}</code> te klikken en vervolgens op <code>{localized_scan}</code> te klikken.\",\n            \"open_collective\": \"Bekijk onze {open_collective_link} om te zien hoe jij kunt bijdragen aan de verdere ontwikkeling van Stash.\",\n            \"support_us\": \"Ondersteun ons\",\n            \"thanks_for_trying_stash\": \"Bedankt voor het proberen van Stash!\",\n            \"welcome_contrib\": \"We verwelkomen ook bijdragen in de vorm van code (bugfixes, verbeteringen en nieuwe functies), testen, bugrapporten, verbeterings- en functieverzoeken en gebruikersondersteuning. Details zijn te vinden in het gedeelte Bijdrage van de in-app-handleiding.\",\n            \"your_system_has_been_created\": \"Succes! Uw systeem is aangemaakt!\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash probeert eerst zijn configuratiebestand (<code>config.yml</code>) uit de huidige werkdirectory te vinden, en als het daar niet gevonden wordt, valt het terug naar <code>$HOME/.stash/config. yml</code> (in Windows is dit <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>). Je kunt Stash ook laten lezen uit een specifiek configuratiebestand door het uit te voeren met de opties <code>-c '<pad naar configuratie bestand>'</code> of <code>--config '<pad naar configuratie bestand>'</code>.\",\n            \"in_current_stash_directory\": \"In de <code>{path}</code> map:\",\n            \"in_the_current_working_directory\": \"In <code>{path}</code>, de werkmap, momenteel:\",\n            \"next_step\": \"Met dat alles uit de weg, als u klaar bent om door te gaan met het opzetten van een nieuw systeem, kiest u waar u uw configuratiebestand wilt opslaan en klikt u op Volgende.\",\n            \"store_stash_config\": \"Waar wil jij je Stash-configuratie opslaan?\",\n            \"unable_to_locate_config\": \"Als je dit leest, kan Stash geen bestaande configuratie vinden. Deze wizard leidt u door het proces van het opzetten van een nieuwe configuratie.\",\n            \"unexpected_explained\": \"Als je dit scherm onverwachts krijgt, probeer dan Stash opnieuw op te starten in de juiste werkmap of met de <code>-c</code> vlag.\",\n            \"in_the_current_working_directory_disabled\": \"In <code>{path}</code>, de werkmap:\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash gebruikt het volgende configuratiebestandspad: <code>{path}</code>\",\n            \"next_step\": \"Wanneer u klaar bent om door te gaan met het instellen van een nieuw systeem, klikt u op Volgende.\",\n            \"unable_to_locate_specified_config\": \"Als je dit leest, kan Stash het opgegeven configuratiebestand op de opdrachtregel of in de omgeving niet vinden. Deze wizard leidt u door het proces van het opzetten van een nieuwe configuratiebestand.\"\n        },\n        \"welcome_to_stash\": \"Welkom bij Stash\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_ids\": \"Stash IDs\",\n    \"stats\": {\n        \"image_size\": \"Afbeelding groote\",\n        \"scenes_duration\": \"Scene duur\",\n        \"scenes_size\": \"Scene groote\",\n        \"scenes_played\": \"Scènes gespeeld\"\n    },\n    \"status\": \"Status: {statusText}\",\n    \"studio\": \"Studio\",\n    \"studio_depth\": \"Niveaus (leeg voor iedereen)\",\n    \"studios\": \"Studios\",\n    \"sub_tag_count\": \"Sub-Label aantal\",\n    \"sub_tag_of\": \"Sub-tag van {parent}\",\n    \"sub_tags\": \"Sub-Tags\",\n    \"subsidiary_studios\": \"Onderliggende Studio's\",\n    \"synopsis\": \"Synopsis\",\n    \"tag\": \"Label\",\n    \"tag_count\": \"Label Aantal\",\n    \"tags\": \"Labels\",\n    \"tattoos\": \"Tattoos\",\n    \"title\": \"Titel\",\n    \"toast\": {\n        \"added_entity\": \"Toegevoegd {count, plural, one {{singularEntity}} andere {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Generatietaak toegevoegd aan wachtrij\",\n        \"created_entity\": \"{entity} Aangemaakt\",\n        \"default_filter_set\": \"Standaard filterset\",\n        \"delete_past_tense\": \"Verwijderd {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Screenshot Genereren…\",\n        \"merged_tags\": \"Samengevoegde Labels\",\n        \"rescanning_entity\": \"Opnieuw scannen van {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"Opgeslagen {entity}\",\n        \"started_auto_tagging\": \"Autotagging gestart\",\n        \"started_generating\": \"Genereren gestart\",\n        \"started_importing\": \"Importeren gestart\",\n        \"updated_entity\": \"Ge-updatet {entity}\"\n    },\n    \"total\": \"Totaal\",\n    \"true\": \"Waar\",\n    \"twitter\": \"Twitter\",\n    \"updated_at\": \"Bijgewerkt op\",\n    \"url\": \"URL\",\n    \"videos\": \"Video's\",\n    \"view_all\": \"Alles weergeven\",\n    \"weight\": \"Gewicht\",\n    \"years_old\": \"jaar oud\",\n    \"appears_with\": \"Te zien met\",\n    \"audio_codec\": \"Audiocodec\",\n    \"between_and\": \"en\",\n    \"blobs_storage_type\": {\n        \"database\": \"Databank\",\n        \"filesystem\": \"Bestandssysteem\"\n    },\n    \"chapters\": \"Hoofdstukken\",\n    \"circumcised\": \"Besneden\",\n    \"circumcised_types\": {\n        \"CUT\": \"Ja\",\n        \"UNCUT\": \"Nee\"\n    },\n    \"folder\": \"Folder\",\n    \"hasChapters\": \"Hoofdstukken\",\n    \"image_index\": \"Afbeelding #\",\n    \"include_sub_groups\": \"Inclusief subgroepen\",\n    \"index_of_total\": \"{index} van {total}\",\n    \"orientation\": \"Oriëntatie\",\n    \"group\": \"Groep\",\n    \"group_count\": \"Aantal groepen\",\n    \"height_cm\": \"Lengte (cm)\",\n    \"history\": \"Geschiedenis\",\n    \"groups\": \"Groepen\",\n    \"file_count\": \"Aantal bestanden\",\n    \"files_amount\": \"{value} bestanden\",\n    \"include_sub_group_content\": \"Inclusief subgroep inhoud\",\n    \"login\": {\n        \"username\": \"Gebruikersnaam\",\n        \"password\": \"Wachtwoord\",\n        \"login\": \"Login\",\n        \"invalid_credentials\": \"Ongeldige gebruikersnaam of wachtwoord\"\n    },\n    \"group_scene_number\": \"Scènenummer\",\n    \"include_sub_studio_content\": \"Inclusief substudio inhoud\",\n    \"last_played_at\": \"Laatst gespeeld op\",\n    \"include_sub_tag_content\": \"Inclusief sub-tag inhoud\",\n    \"age_on_date\": \"{age} tijdens productie\",\n    \"studio_tagger\": {\n        \"query_all_studios_in_the_database\": \"Alle studio's in de database\",\n        \"current_page\": \"Huidige pagina\",\n        \"status_tagging_job_queued\": \"Status: Tagging-taak in de wachtrij\",\n        \"failed_to_save_studio\": \"Opslaan van studio “{studio}” mislukt\",\n        \"name_already_exists\": \"Naam bestaat reeds\",\n        \"network_error\": \"Netwerkfout\",\n        \"no_results_found\": \"Geen resultaten gevonden.\",\n        \"number_of_studios_will_be_processed\": \"{studio_count} studio's worden verwerkt\",\n        \"refresh_tagged_studios\": \"Vernieuwen getagde studio's\",\n        \"refreshing_will_update_the_data\": \"Vernieuwen zal de gegevens van alle getagde studio's van de stash-box bijwerken.\",\n        \"create_or_tag_parent_studios\": \"Maak ontbrekende of label bestaande moederstudio's\"\n    },\n    \"zip_file_count\": \"Aantal Zipbestanden\",\n    \"unknown_date\": \"Onbekende datum\",\n    \"urls\": \"URL's\",\n    \"date_format\": \"JJJJ-MM-DD\",\n    \"description\": \"Omschrĳving\",\n    \"distance\": \"Afstand\",\n    \"package_manager\": {\n        \"description\": \"Omschrĳving\",\n        \"install\": \"Installeer\",\n        \"package\": \"Pakket\",\n        \"source\": {\n            \"name\": \"Naam\"\n        },\n        \"uninstall\": \"Verwijderen\",\n        \"unknown\": \"<onbekend>\",\n        \"update\": \"Updaten\",\n        \"version\": \"Versie\"\n    },\n    \"penis\": \"Penis\",\n    \"photographer\": \"Fotograaf\",\n    \"second\": \"Seconde\",\n    \"statistics\": \"Statistieken\",\n    \"time\": \"Tĳd\",\n    \"criterion_modifier_values\": {\n        \"none\": \"Geen\"\n    },\n    \"custom_fields\": {\n        \"field\": \"Veld\",\n        \"value\": \"Waarde\"\n    },\n    \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n    \"sub_group\": \"Subgroep\",\n    \"sub_groups\": \"Subgroepen\",\n    \"weight_kg\": \"Gewicht (kg)\",\n    \"type\": \"Type\",\n    \"time_end\": \"Eindtijd\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/nn-NO.json",
    "content": "{\n    \"actions\": {\n        \"add_entity\": \"Legg til {entityType}\",\n        \"add_manual_date\": \"Legg til dato manuelt\",\n        \"add_o\": \"Legg til O\",\n        \"add_play\": \"Legg til avspeling\",\n        \"add_to_entity\": \"\",\n        \"allow\": \"Tillat\",\n        \"allow_temporarily\": \"Tillat mellombels\",\n        \"apply\": \"Bruk\",\n        \"auto_tag\": \"Set merkelapp automatisk\",\n        \"browse_for_image\": \"Bla gjennom etter bilete …\",\n        \"cancel\": \"Avbryt\",\n        \"choose_date\": \"Vel ein dato\",\n        \"clean\": \"Rydd opp\",\n        \"clean_generated\": \"Rydd opp i genererte filer\",\n        \"close\": \"Lukk\",\n        \"confirm\": \"Stadfest\",\n        \"copy_to_clipboard\": \"Kopier til utklippstavla\",\n        \"clear_date_data\": \"Tøm dato-data\",\n        \"continue\": \"Hald fram\",\n        \"add\": \"Legg til\",\n        \"backup\": \"Ta reservekopi\",\n        \"create\": \"Opprett\",\n        \"create_chapters\": \"Opprett kapittel\",\n        \"create_entity\": \"Opprett {entityType}\",\n        \"create_marker\": \"Opprett merke\",\n        \"create_parent_studio\": \"Opprett forelderstudio\",\n        \"created_entity\": \"Oppretta {entity_type}: {entity_name}\",\n        \"customise\": \"Tilpass\",\n        \"delete\": \"Slett\",\n        \"delete_entity\": \"Slett {entityType}\",\n        \"delete_file\": \"Slett fil\",\n        \"disable\": \"Slå av\",\n        \"remove_date\": \"Fjern dato\",\n        \"open_random\": \"Opna tilfeldig\",\n        \"play_random\": \"Spel av tilfeldig\",\n        \"submit_update\": \"Send inn oppdatering\",\n        \"view_random\": \"Vis tilfeldig\",\n        \"add_directory\": \"Legg til mappe\",\n        \"add_sub_groups\": \"Legg til undergrupper\",\n        \"clear_image\": \"Fjern bilete\",\n        \"find\": \"Finn\",\n        \"finish\": \"Fullfør\",\n        \"from_file\": \"Frå fil …\",\n        \"from_url\": \"Frå nettadresse …\",\n        \"full_export\": \"Fullstendig eksportering\",\n        \"full_import\": \"Fullstendig importering\",\n        \"generate\": \"Generer\",\n        \"hash_migration\": \"hash-migrering\",\n        \"next_action\": \"Neste\",\n        \"not_running\": \"køyrer ikkje\",\n        \"open_in_external_player\": \"Opna i ekstern avspelar\",\n        \"optimise_database\": \"Optimiser database\",\n        \"overwrite\": \"Skriv over\",\n        \"play_selected\": \"Spel av valde\",\n        \"preview\": \"Førehandsvis\",\n        \"previous_action\": \"Tilbake\",\n        \"reassign\": \"Tilordna på nytt\",\n        \"refresh\": \"Oppdater\",\n        \"reload_plugins\": \"Last inn tillegg på nytt\",\n        \"reload\": \"Last på nytt\",\n        \"remove_from_containing_group\": \"Fjern frå gruppe\",\n        \"remove_from_gallery\": \"Fjern frå galleri\",\n        \"rename_gen_files\": \"Endra namn på genererte filer\",\n        \"rescan\": \"Skann på nytt\",\n        \"anonymise\": \"Anonymiser\",\n        \"clear_back_image\": \"Fjern baksidebilete\",\n        \"clear_front_image\": \"Fjern framsidebilete\",\n        \"download\": \"Last ned\",\n        \"download_backup\": \"Last ned reservekopi\",\n        \"edit\": \"Rediger\",\n        \"edit_entity\": \"Rediger {entityType}\",\n        \"enable\": \"Slå på\",\n        \"export\": \"Eksporter\",\n        \"export_all\": \"Eksporter alle …\",\n        \"ignore\": \"Ignorer\",\n        \"import\": \"Importer …\",\n        \"identify\": \"Identifiser\",\n        \"import_from_file\": \"Importer frå fil\",\n        \"logout\": \"Logg ut\",\n        \"make_primary\": \"Set som føretrekt\",\n        \"merge\": \"Flett\",\n        \"migrate_scene_screenshots\": \"Migrer skjermbilete av scener\",\n        \"download_anonymised\": \"Last ned anonymisert\",\n        \"disallow\": \"Ikkje tillat\"\n    },\n    \"countables\": {\n        \"groups\": \"{count, plural, one {Gruppe} other {Grupper}}\",\n        \"files\": \"{count, plural, one {Fil} other {Filer}}\",\n        \"images\": \"{count, plural, one {Bilete} other {Bilete}}\",\n        \"performers\": \"{count, plural, one {Utøvar} other {Utøvarar}}\",\n        \"tags\": \"{count, plural, one {Merkelapp} other {Merkelappar}}\",\n        \"markers\": \"{count, plural, one {Merke} other {Merke}}\",\n        \"scenes\": \"{count, plural, one {Scene} other {Scener}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Studio}}\"\n    },\n    \"dialogs\": {\n        \"delete_object_desc\": \"Er du sikker på at du vil sletta {count, plural, one {denne {singularEntity}} other {desse {pluralEntity}}}?\",\n        \"performers_found\": \"Fann {count} utøvarar\",\n        \"delete_entity_title\": \"{count, plural, one {Slett {singularEntity}} other {Slett {pluralEntity}}}\",\n        \"scenes_found\": \"Fann {count} scener\",\n        \"dont_show_until_updated\": \"Ikkje vis før neste oppdatering\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Kolonne\",\n                \"row\": \"Rad\"\n            }\n        }\n    },\n    \"date\": \"Dato\",\n    \"bitrate\": \"Bitrate\",\n    \"performer_tagger\": {\n        \"number_of_performers_will_be_processed\": \"{performer_count} utøvarar vert handsama\",\n        \"batch_update_performers\": \"Oppdater fleire utøvarar samtidig\",\n        \"update_performer\": \"Oppdater utøvar\",\n        \"update_performers\": \"Oppdater utøvarar\"\n    },\n    \"studio_tagger\": {\n        \"number_of_studios_will_be_processed\": \"{studio_count} studio vert handsama\",\n        \"batch_update_studios\": \"Oppdater fleire studio samtidig\",\n        \"update_studios\": \"Oppdater studio\"\n    },\n    \"config\": {\n        \"tasks\": {\n            \"cleanup_desc\": \"Sjå etter manglande filer og fjern dei frå databasen. Dette kan ikkje angrast.\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blob-filer\",\n                \"description\": \"Fjernar genererte filer som ikkje har ei tilhøyrande databaseoppføring.\",\n                \"image_thumbnails\": \"Miniatyrbilete\",\n                \"markers\": \"Førehandsvisingar av merke\",\n                \"previews\": \"Førehandsvisingar av scener\",\n                \"previews_desc\": \"Førehandsvisingar og miniatyrbilete av scener\",\n                \"image_thumbnails_desc\": \"Miniatyrbilete og -klipp\",\n                \"transcodes\": \"Omkodingar av scener\"\n            },\n            \"anonymise_and_download\": \"Opprettar ein anonymisert kopi av databasen og lastar ned den ferdige fila.\",\n            \"anonymise_database\": \"Anonymiserer sensitive data og opprettar ein kopi av databasen i mappa «backups». Han kan då delast med andre for å kunna brukast til feilsøking. Den opphavlege databasen vert ikkje endra. Filnamnet til den anonymiserte databasen vert i formatet {filename_format}.\"\n        },\n        \"ui\": {\n            \"editing\": {\n                \"rating_system\": {\n                    \"type\": {\n                        \"options\": {\n                            \"stars\": \"Stjerner\",\n                            \"decimal\": \"Desimal\"\n                        }\n                    }\n                }\n            },\n            \"images\": {\n                \"heading\": \"Bilete\"\n            }\n        },\n        \"about\": {\n            \"release_date\": \"Utgjevingsdato:\"\n        },\n        \"categories\": {\n            \"plugins\": \"Tillegg\"\n        }\n    },\n    \"dupe_check\": {\n        \"duration_diff\": \"Maksgrense for lengdeskilnad\",\n        \"duration_options\": {\n            \"any\": \"Vilkårleg\",\n            \"equal\": \"Er like\"\n        }\n    },\n    \"birthdate\": \"Fødselsdato\",\n    \"component_tagger\": {\n        \"config\": {\n            \"query_mode_path\": \"Sti\"\n        },\n        \"results\": {\n            \"duration_off\": \"Lengdeskilnad er på {number} s\",\n            \"duration_unknown\": \"Ukjend lengd\",\n            \"fp_matches\": \"Lengd samsvarer\",\n            \"fp_matches_multi\": \"Lengd samsvarer med {matchCount}/{durationsLength} fingeravtrykk\",\n            \"phash_matches\": \"{count} PHashes samsvarer\"\n        },\n        \"verb_submit_fp\": \"Send inn {fpCount, plural, one{# fingeravtrykk} other{# fingeravtrykk}}\"\n    },\n    \"country\": \"Land\",\n    \"date_format\": \"ÅÅÅÅ-MM-DD\",\n    \"datetime_format\": \"ÅÅÅÅ-MM-DD TT:MM\",\n    \"death_date\": \"Dødsdato\",\n    \"duration\": \"Lengd\",\n    \"file_count\": \"Tal på filer\",\n    \"filesize\": \"Filstorleik\",\n    \"framerate\": \"Biletrate\",\n    \"interactive\": \"\",\n    \"last_o_at\": \"Siste O\",\n    \"gallery_count\": \"Tal på galleri\",\n    \"group_count\": \"Tal på grupper\",\n    \"marker_count\": \"Tal på merke\",\n    \"media_info\": {\n        \"play_duration\": \"Avspelingslengd\",\n        \"play_count\": \"Tal på avspelingar\",\n        \"o_count\": \"Tal på O\"\n    },\n    \"package_manager\": {\n        \"check_for_updates\": \"Sjå etter oppdateringar\"\n    },\n    \"parent_tag_count\": \"Tal på overordna merkelappar\",\n    \"pagination\": {\n        \"last\": \"Siste\"\n    },\n    \"o_count\": \"Tal på O\",\n    \"organized\": \"Organisert\",\n    \"playdate_recorded_no\": \"Ingen avspelingsdato er registrert\",\n    \"play_duration\": \"Avspelingslengd\",\n    \"performer_count\": \"Tal på utøvarar\",\n    \"play_count\": \"Tal på avspelingar\",\n    \"rating\": \"Karakter\",\n    \"stats\": {\n        \"total_o_count\": \"Tal på O\"\n    },\n    \"toast\": {\n        \"generating_screenshot\": \"Genererer skjermbilete …\",\n        \"updated_entity\": \"Oppdaterte {entity}\"\n    },\n    \"sub_tag_count\": \"Tal på underordna merkelappar\",\n    \"tag_count\": \"Tal på merkelappar\",\n    \"odate_recorded_no\": \"Ingen O-dato er registrert\",\n    \"unknown_date\": \"Ukjend dato\",\n    \"updated_at\": \"Oppdatert den\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} må vera i formatet ÅÅÅÅ-MM-DD\"\n    },\n    \"zip_file_count\": \"Tal på zippa filer\",\n    \"image_count\": \"Tal på bilete\",\n    \"scene_count\": \"Tal på scener\",\n    \"sub_groups\": \"Undergrupper\",\n    \"include_sub_groups\": \"Ta med undergrupper\",\n    \"sub_group\": \"Undergruppe\",\n    \"sub_group_count\": \"Tal på undergrupper\",\n    \"sub_group_of\": \"Undergruppe av {parent}\",\n    \"sub_group_order\": \"Undergruppesortert\",\n    \"groups\": \"Grupper\",\n    \"performers\": \"Utøvarar\",\n    \"studios\": \"Studio\",\n    \"image\": \"Bilete\",\n    \"images\": \"Bilete\",\n    \"scene\": \"Scene\",\n    \"group\": \"Gruppe\",\n    \"galleries\": \"Galleri\",\n    \"scenes\": \"Scener\",\n    \"studio\": \"Studio\",\n    \"performer\": \"Utøvar\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/pl-PL.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Dodaj\",\n        \"add_directory\": \"Dodaj katalog\",\n        \"add_entity\": \"Dodaj {entityType}\",\n        \"add_to_entity\": \"Dodaj do {entityType}\",\n        \"allow\": \"Zezwól\",\n        \"allow_temporarily\": \"Zezwól tymczasowo\",\n        \"anonymise\": \"Zanonimizuj\",\n        \"apply\": \"Zastosuj\",\n        \"auto_tag\": \"Automatyczne tagowanie\",\n        \"backup\": \"Kopia zapasowa\",\n        \"browse_for_image\": \"Przeglądaj w poszukiwaniu obrazu…\",\n        \"cancel\": \"Anuluj\",\n        \"clean\": \"Wyczyść\",\n        \"clear\": \"Usuń\",\n        \"clear_back_image\": \"Usuń tylną okładkę\",\n        \"clear_front_image\": \"Usuń przednią okładkę\",\n        \"clear_image\": \"Usuń obraz\",\n        \"close\": \"Zamknij\",\n        \"confirm\": \"Zatwierdź\",\n        \"continue\": \"Kontynuuj\",\n        \"create\": \"Utwórz\",\n        \"create_chapters\": \"Utwórz rozdział\",\n        \"create_entity\": \"Utwórz {entityType}\",\n        \"create_marker\": \"Utwórz znacznik\",\n        \"created_entity\": \"Utworzono {entity_type}: {entity_name}\",\n        \"customise\": \"Dostosuj\",\n        \"delete\": \"Usuń\",\n        \"delete_entity\": \"Usuń {entityType}\",\n        \"delete_file\": \"Usuń plik\",\n        \"delete_file_and_funscript\": \"Usuń plik (i skrypt funscript)\",\n        \"delete_generated_supporting_files\": \"Usuń wygenerowane pliki pomocnicze\",\n        \"disallow\": \"Nie zezwalaj\",\n        \"download\": \"Pobierz\",\n        \"download_anonymised\": \"Pobierz zanonimizowane\",\n        \"download_backup\": \"Pobierz kopię zapasową\",\n        \"edit\": \"Edytuj\",\n        \"edit_entity\": \"Edytuj {entityType}\",\n        \"export\": \"Eksportuj\",\n        \"export_all\": \"Eksportuj wszystko…\",\n        \"find\": \"Znajdź\",\n        \"finish\": \"Zakończ\",\n        \"from_file\": \"Z pliku…\",\n        \"from_url\": \"Z URL…\",\n        \"full_export\": \"Pełny eksport\",\n        \"full_import\": \"Pełny import\",\n        \"generate\": \"Generuj\",\n        \"generate_thumb_default\": \"Generuj domyślną miniaturkę\",\n        \"generate_thumb_from_current\": \"Wygeneruj miniaturkę z bieżącego podglądu\",\n        \"hash_migration\": \"migracja skrótów\",\n        \"hide\": \"Ukryj\",\n        \"hide_configuration\": \"Ukryj konfigurację\",\n        \"identify\": \"Identyfikuj\",\n        \"ignore\": \"Ignoruj\",\n        \"import\": \"Importuj…\",\n        \"import_from_file\": \"Importuj z pliku\",\n        \"logout\": \"Wyloguj\",\n        \"make_primary\": \"Ustaw jako główny\",\n        \"merge\": \"Scal\",\n        \"migrate_blobs\": \"Migruj bloby\",\n        \"migrate_scene_screenshots\": \"Migruj zrzuty ekranu ze scen\",\n        \"next_action\": \"Dalej\",\n        \"not_running\": \"nieuruchomiony\",\n        \"open_in_external_player\": \"Otwórz w odtwarzaczu zewnętrznym\",\n        \"open_random\": \"Otwórz losowo\",\n        \"overwrite\": \"Nadpisz\",\n        \"play_random\": \"Odtwórz losowo\",\n        \"play_selected\": \"Odtwórz wybrane\",\n        \"preview\": \"Podgląd\",\n        \"previous_action\": \"Wstecz\",\n        \"reassign\": \"Przypisz ponownie\",\n        \"refresh\": \"Odśwież\",\n        \"reload_plugins\": \"Przeładuj wtyczki\",\n        \"reload_scrapers\": \"Przeładuj zbieracze\",\n        \"remove\": \"Usuń\",\n        \"remove_from_gallery\": \"Usuń z galerii\",\n        \"rename_gen_files\": \"Zmień nazwy wygenerowanych plików\",\n        \"rescan\": \"Skanuj ponownie\",\n        \"reshuffle\": \"Przetasuj\",\n        \"running\": \"uruchomiony\",\n        \"save\": \"Zapisz\",\n        \"save_delete_settings\": \"Użyj tych opcji jako domyślnych podczas usuwania\",\n        \"save_filter\": \"Zapisz filtr\",\n        \"scan\": \"Skanuj\",\n        \"scrape\": \"Zbierz\",\n        \"scrape_query\": \"Zapytanie zbierania\",\n        \"scrape_scene_fragment\": \"Zbieranie po fragmencie\",\n        \"scrape_with\": \"Zbierz za pomocą…\",\n        \"search\": \"Szukaj\",\n        \"select_all\": \"Zaznacz wszystko\",\n        \"select_entity\": \"Wybierz {entityType}\",\n        \"select_folders\": \"Wybierz foldery\",\n        \"select_none\": \"Odznacz wszystko\",\n        \"selective_auto_tag\": \"Selektywne automatyczne tagowanie\",\n        \"selective_clean\": \"Czyszczenie selektywne\",\n        \"selective_scan\": \"Skanowanie selektywne\",\n        \"set_as_default\": \"Ustaw jako domyślne\",\n        \"set_back_image\": \"Tylna okładka…\",\n        \"set_front_image\": \"Przednia okładka…\",\n        \"set_image\": \"Ustaw obraz…\",\n        \"show\": \"Pokaż\",\n        \"show_configuration\": \"Pokaż konfigurację\",\n        \"skip\": \"Pomiń\",\n        \"split\": \"Rozdziel\",\n        \"stop\": \"Zatrzymaj\",\n        \"submit\": \"Wyślij\",\n        \"submit_stash_box\": \"Wyślij do Stash-Box\",\n        \"submit_update\": \"Prześlij aktualizację\",\n        \"swap\": \"Zamień\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Czy na pewno chcesz przeprowadzić oczyszczanie? Spowoduje to usunięcie informacji z bazy danych i wygenerowanej zawartości dla wszystkich scen i galerii, które nie znajdują się już w systemie plików.\",\n            \"dry_mode_selected\": \"Wybrano tryb próby na sucho. Nie nastąpi faktyczne usunięcie, a jedynie zapisanie w dzienniku.\",\n            \"import_warning\": \"Czy na pewno chcesz zaimportować? Spowoduje to usunięcie bazy danych i ponowne zaimportowanie wyeksportowanych metadanych.\"\n        },\n        \"temp_disable\": \"Wyłącz tymczasowo…\",\n        \"temp_enable\": \"Włącz tymczasowo…\",\n        \"unset\": \"Nieustawiony\",\n        \"use_default\": \"Użyj domyślnych\",\n        \"view_random\": \"Pokaż losowe\",\n        \"optimise_database\": \"Optymalizuj bazę danych\",\n        \"copy_to_clipboard\": \"Kopiuj do schowka\",\n        \"disable\": \"Wyłącz\",\n        \"encoding_image\": \"Kodowanie obrazu…\",\n        \"reload\": \"Przeładuj\",\n        \"enable\": \"Włącz\",\n        \"add_o\": \"Dodaj O\",\n        \"add_manual_date\": \"Dodaj datę ręcznie\",\n        \"add_play\": \"Dodaj odtworzenie\",\n        \"assign_stashid_to_parent_studio\": \"Przypisz Stash ID do istniejącego studia nadrzędnego i zaktualizuj metadane\",\n        \"choose_date\": \"Wybierz datę\",\n        \"clean_generated\": \"Wyczyść wygenerowane pliki\",\n        \"clear_date_data\": \"Wyczyść dane daty\",\n        \"create_parent_studio\": \"Utwórz studio nadrzędne\",\n        \"remove_date\": \"Usuń datę\",\n        \"reset_play_duration\": \"Resetuj czas odtwarzania\",\n        \"reset_resume_time\": \"Resetuj czas wznowienia\",\n        \"set_cover\": \"Ustaw jako okładkę\",\n        \"remove_from_containing_group\": \"Usuń z grupy\",\n        \"reset_cover\": \"Przywróć domyślną okładkę\",\n        \"add_sub_groups\": \"Dodaj podgrupy\",\n        \"view_history\": \"Zobacz historię\",\n        \"play\": \"Odtwarzaj\",\n        \"show_results\": \"Pokaż wyniki\",\n        \"add_stash_id\": \"Dodaj Stash ID\",\n        \"create_new\": \"Utwórz nowy\",\n        \"load\": \"Wczytaj\",\n        \"load_filter\": \"Wczytaj filtr\",\n        \"sidebar\": {\n            \"close\": \"Zamknij pasek boczny\",\n            \"open\": \"Otwórz pasek boczny\",\n            \"toggle\": \"Przełącz pasek boczny\"\n        }\n    },\n    \"actions_name\": \"Działania\",\n    \"age\": \"Wiek\",\n    \"aliases\": \"Inne nazwy\",\n    \"all\": \"wszystkie\",\n    \"also_known_as\": \"Znany/a również jako\",\n    \"appears_with\": \"Pojawia się z\",\n    \"ascending\": \"Rosnąco\",\n    \"average_resolution\": \"Średnia rozdzielczość\",\n    \"between_and\": \"i\",\n    \"birth_year\": \"Rok urodzenia\",\n    \"birthdate\": \"Data urodzenia\",\n    \"bitrate\": \"Szybkość transmisji\",\n    \"blobs_storage_type\": {\n        \"database\": \"Baza Danych\",\n        \"filesystem\": \"System plików\"\n    },\n    \"captions\": \"Napisy\",\n    \"career_length\": \"Długość kariery\",\n    \"chapters\": \"Rozdziały\",\n    \"circumcised\": \"Obrzezanie\",\n    \"circumcised_types\": {\n        \"CUT\": \"Obrzezany\",\n        \"UNCUT\": \"Nieobrzezany\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktywna instancja stash-box:\",\n            \"blacklist_desc\": \"Elementy na czarnej liście są wykluczane z zapytań. Należy pamiętać, że są to wyrażenia regularne i nie uwzględniają wielkości liter. Niektóre znaki muszą być usunięte za pomocą odwrotnego ukośnika: {chars_require_escape}\",\n            \"blacklist_label\": \"Czarna lista\",\n            \"query_mode_auto\": \"Automatyczny\",\n            \"query_mode_auto_desc\": \"Używane są metadane, jeśli są obecne, lub nazwy plików\",\n            \"query_mode_dir\": \"Katalog\",\n            \"query_mode_dir_desc\": \"Używany jest tylko katalog nadrzędny pliku wideo\",\n            \"query_mode_filename\": \"Nazwa pliku\",\n            \"query_mode_filename_desc\": \"Używana jest tylko nazwa pliku\",\n            \"query_mode_label\": \"Tryb zapytań\",\n            \"query_mode_metadata\": \"Metadane\",\n            \"query_mode_metadata_desc\": \"Używane są tylko metadane\",\n            \"query_mode_path\": \"Ścieżka\",\n            \"query_mode_path_desc\": \"Używana jest cała ścieżka dostępu do pliku\",\n            \"set_cover_desc\": \"Zamień okładkę sceny, jeśli zostanie znaleziona.\",\n            \"set_cover_label\": \"Ustawianie obrazu okładki sceny\",\n            \"set_tag_desc\": \"Dołączanie tagów do sceny poprzez nadpisywanie lub łączenie z istniejącymi tagami w scenie.\",\n            \"set_tag_label\": \"Ustaw tagi\",\n            \"source\": \"Źródło\",\n            \"mark_organized_desc\": \"Po kliknięciu przycisku Zapisz scena zostaje natychmiast oznaczona jako zorganizowana.\",\n            \"mark_organized_label\": \"Oznacz jako zorganizowane przy zapisywaniu\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Duplikuj element czarnej listy\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Płeć wykonawcy\",\n                \"description\": \"Wykonawcy o tych płciach będą wyświetlani podczas tagowania scen.\"\n            }\n        },\n        \"noun_query\": \"Zapytanie\",\n        \"results\": {\n            \"duration_off\": \"Czas trwania przesunięty o co najmniej {number}s\",\n            \"duration_unknown\": \"Czas trwania nieznany\",\n            \"fp_found\": \"{fpCount, plural, =0 {Nie znaleziono nowych dopasowań odcisków palców} one {Znaleziono # nowe dopasowanie odcisków palców} few {Znaleziono # nowe dopasowania odcisków palców} other {Znaleziono # nowych dopasowań odcisków palców}}\",\n            \"fp_matches\": \"Czas trwania jest zgodny\",\n            \"fp_matches_multi\": \"Pasujące czasy trwania dla odcisków palców {matchCount}/{durationsLength}\",\n            \"hash_matches\": \"{hash_type} jest zgodny\",\n            \"match_failed_already_tagged\": \"Scena już otagowana\",\n            \"match_failed_no_result\": \"Nie znaleziono żadnych wyników\",\n            \"match_success\": \"Scena pomyślnie otagowana\",\n            \"phash_matches\": \"{count} zgodnych PHashy\",\n            \"unnamed\": \"Bez nazwy\"\n        },\n        \"verb_match_fp\": \"Dopasuj odciski palców\",\n        \"verb_matched\": \"Dopasowane\",\n        \"verb_scrape_all\": \"Zbierz wszystko\",\n        \"verb_submit_fp\": \"Wyślij {fpCount, plural, one{# odcisk palca} few{# odciski palców} other{# odcisków palców}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} niedopasowane sceny\",\n        \"verb_add_as_alias\": \"Dodaj zebraną nazwę jako alias\",\n        \"verb_link_existing\": \"Połącz z istniejącym\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Hash kompilacji:\",\n            \"build_time\": \"Czas kompilacji:\",\n            \"check_for_new_version\": \"Sprawdź, czy jest nowa wersja\",\n            \"latest_version\": \"Najnowsza wersja\",\n            \"latest_version_build_hash\": \"Hash kompilacji najnowszej wersji:\",\n            \"new_version_notice\": \"[NOWA]\",\n            \"release_date\": \"Data wydania:\",\n            \"stash_discord\": \"Dołącz do naszego kanału {url}\",\n            \"stash_home\": \"Dom Stasha na stronie {url}\",\n            \"stash_open_collective\": \"Wesprzyj nas poprzez {url}\",\n            \"stash_wiki\": \"Strona Stash {url}\",\n            \"version\": \"Wersja\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Ścieżki aplikacji\"\n        },\n        \"categories\": {\n            \"about\": \"O aplikacji\",\n            \"changelog\": \"Lista zmian\",\n            \"interface\": \"Interfejs\",\n            \"logs\": \"Logi\",\n            \"metadata_providers\": \"Dostawcy metadanych\",\n            \"plugins\": \"Wtyczki\",\n            \"scraping\": \"Scrapowanie\",\n            \"security\": \"Bezpieczeństwo\",\n            \"services\": \"Usługi\",\n            \"system\": \"System\",\n            \"tasks\": \"Zadania\",\n            \"tools\": \"Narzędzia\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Pozwalaj {tempIP}\",\n            \"allowed_ip_addresses\": \"Dozwolone adresy IP\",\n            \"allowed_ip_temporarily\": \"Tymczasowo dozwolony adres IP\",\n            \"default_ip_whitelist\": \"Domyślna biała lista adresów IP\",\n            \"default_ip_whitelist_desc\": \"Domyślne adresy IP z dostępem do urządzenia DLNA. Użyj {wildcard}, aby zezwolić na dostęp wszystkim adresom IP.\",\n            \"disabled_dlna_temporarily\": \"Tymczasowe wyłączenie funkcji DLNA\",\n            \"disallowed_ip\": \"Niedozwolony adres IP\",\n            \"enabled_by_default\": \"Domyślnie włączone\",\n            \"enabled_dlna_temporarily\": \"Tymczasowe włączenie funkcji DLNA\",\n            \"network_interfaces\": \"Interfejsy\",\n            \"network_interfaces_desc\": \"Interfejsy, na których ma działać serwer DLNA. Pusta lista spowoduje, że serwer będzie działał na wszystkich interfejsach. Wymaga ponownego uruchomienia DLNA po zmianie.\",\n            \"recent_ip_addresses\": \"Ostatnie adresy IP\",\n            \"server_display_name\": \"Wyświetlana nazwa serwera\",\n            \"server_display_name_desc\": \"Nazwa wyświetlana dla serwera DLNA. Domyślnie {server_name}, jeśli jest pusta.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Pomyślnie anulowano zachowanie tymczasowe\",\n            \"until_restart\": \"do ponownego uruchomienia\",\n            \"video_sort_order\": \"Domyślna kolejność sortowania wideo\",\n            \"video_sort_order_desc\": \"Ustaw domyślną kolejność sortowania filmów.\",\n            \"server_port\": \"Port serwera\",\n            \"server_port_desc\": \"Port, na którym uruchomiony będzie serwer DLNA.\\nWymaga restartu DLNA w przypadku zmiany.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"Klucz API\",\n                \"api_key_desc\": \"Klucz API dla systemów zewnętrznych. Wymagane tylko wtedy, gdy skonfigurowana jest nazwa użytkownika/hasło. Nazwa użytkownika musi zostać zapisana przed wygenerowaniem klucza API.\",\n                \"authentication\": \"Uwierzytelnianie\",\n                \"clear_api_key\": \"Wyczyść klucz API\",\n                \"credentials\": {\n                    \"description\": \"Dane uwierzytelniające ograniczające dostęp do skrytki.\",\n                    \"heading\": \"Poświadczenia\"\n                },\n                \"generate_api_key\": \"Wygeneruj klucz API\",\n                \"log_file\": \"Plik dziennika\",\n                \"log_file_desc\": \"Ścieżka do pliku, do którego mają być zapisywane logi. Puste, aby wyłączyć zapisywanie do pliku. Wymaga ponownego uruchomienia.\",\n                \"log_http\": \"Logi dostępu http\",\n                \"log_http_desc\": \"Loguje dostęp http do terminala. Wymaga ponownego uruchomienia.\",\n                \"log_to_terminal\": \"Logi do terminala\",\n                \"log_to_terminal_desc\": \"Przekazuje logi do terminala oprócz logów do pliku. Zawsze prawdziwe, jeśli logowanie do pliku jest wyłączone. Wymaga ponownego uruchomienia.\",\n                \"maximum_session_age\": \"Maksymalny czas trwania sesji\",\n                \"maximum_session_age_desc\": \"Maksymalny czas bezczynności przed wygaśnięciem sesji logowania, w sekundach.\",\n                \"password\": \"Hasło\",\n                \"password_desc\": \"Hasło dostępu do aplikacji Stash. Pozostaw puste, aby wyłączyć uwierzytelnianie użytkownika\",\n                \"stash-box_integration\": \"Integracja ze Stash-box\",\n                \"username\": \"Nazwa użytkownika\",\n                \"username_desc\": \"Nazwa użytkownika umożliwiająca dostęp do Stash. Pozostaw puste, aby wyłączyć uwierzytelnianie użytkownika\",\n                \"log_file_max_size\": \"Maksymalny rozmiar pliku loga\",\n                \"log_file_max_size_desc\": \"Maksymalny rozmiar pliku loga w megabajtach przed jego skompresowaniem. 0MB wyłącza tę funkcję. Wymaga ponownego uruchomienia.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Lokalizacja katalogu kopii zapasowych plików bazy danych SQLite\",\n                \"heading\": \"Ścieżka katalogu kopii zapasowych\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Wskazuje miejsce na systemie plików w którym mają być przechowywane pliki binarne. Uwzględniane tylko jeśli wybrany został binarny tryb zapisu danych. UWAGA: zmiana wymaga ręcznego przeniesienia danych.\",\n                \"heading\": \"Ścieżka dla plików binarnych\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Gdzie przechowywać dane binarne, takie jak okładki scen, obrazy wykonawców, studia i znaczników. Po zmianie tej wartości istniejące dane muszą zostać zmigrowane przy użyciu zadania Migruj bloby. Informacje na temat migracji znajdują się na stronie Zadania.\",\n                \"heading\": \"Tryb zapisu plików binarnych\"\n            },\n            \"cache_location\": \"Lokalizacja katalogu pamięci podręcznej. Wymagane jeśli używany jest HLS (na przykład na urządzeniach Apple) lub DASH.\",\n            \"cache_path_head\": \"Ścieżka pamięci podręcznej\",\n            \"calculate_md5_and_ohash_desc\": \"Oblicz sumę kontrolną MD5 jako dodatek do oshash. Włączenie spowoduje, że początkowe skanowanie będzie wolniejsze. Aby wyłączyć obliczanie MD5, hash nazwy pliku musi być ustawiony na oshash.\",\n            \"calculate_md5_and_ohash_label\": \"Obliczanie MD5 dla filmów\",\n            \"check_for_insecure_certificates\": \"Sprawdź, czy nie ma nieprawidłowych certyfikatów\",\n            \"check_for_insecure_certificates_desc\": \"Niektóre witryny używają nieprawidłowych certyfikatów ssl. Jeśli opcja ta nie jest zaznaczona, scraper pomija sprawdzanie niezapewniających bezpieczeństwa certyfikatów i pozwala na skrobanie tych witryn. Jeśli podczas skrobania pojawia się błąd certyfikatu, usuń zaznaczenie tej opcji.\",\n            \"chrome_cdp_path\": \"Ścieżka Chrome CDP\",\n            \"chrome_cdp_path_desc\": \"Ścieżka do pliku wykonywalnego Chrome lub adres zdalny (zaczynający się od http:// lub https://, na przykład http://localhost:9222/json/version) do instancji Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Jeśli włączone, domyślnie tworzy galerie z folderów zawierających obrazy. Utwórz plik o nazwie .forcegallery lub .nogallery w folderze, aby wymusić/zapobiec temu.\",\n            \"create_galleries_from_folders_label\": \"Tworzenie galerii z folderów zawierających obrazy\",\n            \"database\": \"Baza danych\",\n            \"db_path_head\": \"Ścieżka bazy danych\",\n            \"directory_locations_to_your_content\": \"Lokalizacje katalogów z Twoimi danymi\",\n            \"excluded_image_gallery_patterns_desc\": \"Wyrażenia regularne dotyczące plików/ścieżek obrazów i galerii do wykluczenia ze skanowania i dodania do czyszczenia\",\n            \"excluded_image_gallery_patterns_head\": \"Wykluczone wzorce obrazów/galerii\",\n            \"excluded_video_patterns_desc\": \"Wyrażenia regularne plików/ścieżek wideo do wykluczenia ze skanowania i dodania do czyszczenia\",\n            \"excluded_video_patterns_head\": \"Wykluczone wzorce wideo\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"Pozwala na transkodowanie wideo na żywo przy użyciu dostępnego sprzętu.\",\n                    \"heading\": \"Kodowanie przy użyciu FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Zaawansowane: Dodatkowe argumenty do przekazania do FFmpeg przed polem wejściowym podczas transkodowania wideo na żywo.\",\n                        \"heading\": \"Argumenty wejściowe dla transkodowani na żywo przy użyciou FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Zaawansowane: Dodatkowe argumenty do przekazania do FFmpeg przed polem wyjściowym podczas transkodowania wideo na żywo.\",\n                        \"heading\": \"Argumenty wyjścia dla transkodowania na żywo z użyciem FFmpeg\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Zaawansowane: Dodatkowe argumenty do przekazania do FFmpeg przed polem wejściowym podczas generowania wideo.\",\n                        \"heading\": \"Argumenty wejściowe dla transkodowani przy użyciu FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Zaawansowane: Dodatkowe argumenty do przekazania do FFmpeg przed polem wyjściowym podczas generowania wideo.\",\n                        \"heading\": \"Argumenty wyjścia dla transkodowania z użyciem FFmpeg\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"Pobierz FFmpeg\",\n                    \"description\": \"Pobiera FFmpeg do katalogu konfiguracji i czyści ścieżki do ffmpeg oraz ffprobe, aby były powiązane z katalogiem konfiguracji.\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"Ścieżka pliku wykonywalnego FFmpeg\",\n                    \"description\": \"Ścieżka do pliku wykonywalnego ffmpeg (nie do folderu). Jeśli pole jest puste, ffmpeg zostanie odnaleziony na podstawie zmiennej środowiskowej $PATH, katalogu konfiguracji lub katalogu $HOME/.stash\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Ścieżka do pliku wykonywalnego ffprobe (nie do folderu). Jeśli pole jest puste, ffprobe zostanie odnalezione na podstawie zmiennej środowiskowej $PATH, katalogu konfiguracji lub katalogu $HOME/.stash\",\n                    \"heading\": \"Ścieżka pliku wykonywalnego FFprobe\"\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"Bierz pod uwagę zakres dla wygenerowanych heatmap\",\n            \"funscript_heatmap_draw_range_desc\": \"Narysuj zakres ruchu na osi y generowanej heatmapy. Istniejące heatmapy będą musiały zostać ponownie wygenerowane po zmianie.\",\n            \"gallery_cover_regex_desc\": \"Regexp używany do identyfikacji obrazu jako okładki galerii\",\n            \"gallery_cover_regex_label\": \"Wzór dla okładki galerii\",\n            \"gallery_ext_desc\": \"Rozdzielona przecinkami lista rozszerzeń plików, które będą identyfikowane jako pliki galerii zip.\",\n            \"gallery_ext_head\": \"Rozszerzenia galerii zip\",\n            \"generated_file_naming_hash_desc\": \"Użyj MD5 lub oshash dla wygenerowanych nazw plików. Zmiana tego ustawienia wymaga, aby wszystkie sceny miały uzupełnioną odpowiednią wartość MD5/oshash. Po zmianie tej wartości, istniejące wygenerowane pliki będą musiały zostać zmigrowane lub zregenerowane. Zobacz stronę Zadania, aby uzyskać informacje na temat migracji.\",\n            \"generated_file_naming_hash_head\": \"Wygenerowany hash nazwy pliku\",\n            \"generated_files_location\": \"Lokalizacja katalogu z wygenerowanymi plikami (znaczniki scen, podglądy scen, sprite'y itp.)\",\n            \"generated_path_head\": \"Ścieżka wygenerowanych plików\",\n            \"hashing\": \"Hashowanie\",\n            \"heatmap_generation\": \"Generowanie heatmap dla Funscript\",\n            \"image_ext_desc\": \"Rozdzielona przecinkami lista rozszerzeń plików, które będą identyfikowane jako obrazy.\",\n            \"image_ext_head\": \"Rozszerzenia obrazów\",\n            \"include_audio_desc\": \"Uwzględnia strumień audio podczas generowania podglądu.\",\n            \"include_audio_head\": \"Dołącz dźwięk\",\n            \"logging\": \"Logowanie\",\n            \"maximum_streaming_transcode_size_desc\": \"Maksymalny rozmiar transkodowanych strumieni\",\n            \"maximum_streaming_transcode_size_head\": \"Maksymalny rozmiar transkodowania strumienia\",\n            \"maximum_transcode_size_desc\": \"Maksymalny rozmiar generowanych transkodów\",\n            \"maximum_transcode_size_head\": \"Maksymalny rozmiar transkodowania\",\n            \"metadata_path\": {\n                \"description\": \"Lokalizacja katalogu używana podczas wykonywania pełnego eksportu lub importu\",\n                \"heading\": \"Ścieżka metadanych\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Ustaw 0 dla automatycznego wykrywania. Ostrzeżenie Uruchamianie większej liczby zadań, niż jest to wymagane do osiągnięcia 100% wykorzystania procesora, spowoduje spadek wydajności i potencjalnie inne problemy.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Liczba zadań równoległych dla skanowania/generowania\",\n            \"parallel_scan_head\": \"Skanowanie/generowanie równoległe\",\n            \"preview_generation\": \"Generacja podglądu\",\n            \"python_path\": {\n                \"description\": \"Lokalizacja pliku wykonywalnego Pythona. Używane dla skryptów i wtyczek. Jeśli jest puste, python zostanie pobrany ze środowiska\",\n                \"heading\": \"Ścieżka Pythona\"\n            },\n            \"scraper_user_agent\": \"Agent użytkownika dla zbieracza\",\n            \"scraper_user_agent_desc\": \"Ciąg „User-Agent” używany podczas zapytań HTTP zbierania\",\n            \"scrapers_path\": {\n                \"description\": \"Położenie katalogu z plikami konfiguracyjnymi zbieracza\",\n                \"heading\": \"Ścieżka do zbieraczy\"\n            },\n            \"scraping\": \"Scrapowanie\",\n            \"sqlite_location\": \"Lokalizacja pliku dla bazy danych SQLite (wymaga ponownego uruchomienia). UWAGA: przechowywanie bazy danych w systemie innym niż ten na którym uruchomiony jest serwer Stash (np. w lokalizacji sieciowej) nie jest wspierane!\",\n            \"video_ext_desc\": \"Rozdzielona przecinkami lista rozszerzeń plików, które będą identyfikowane jako pliki wideo.\",\n            \"video_ext_head\": \"Rozszerzenia wideo\",\n            \"video_head\": \"Wideo\",\n            \"delete_trash_path\": {\n                \"description\": \"Ścieżka, do której będą przenoszone usuwane pliki zamiast ich trwałego usunięcia. Pozostaw puste, aby usuwać pliki na stałe.\",\n                \"heading\": \"Ścieżka kosza\"\n            },\n            \"plugins_path\": {\n                \"description\": \"Lokalizacja katalogu plików konfiguracyjnych wtyczek\",\n                \"heading\": \"Ścieżka wtyczek\"\n            }\n        },\n        \"library\": {\n            \"exclusions\": \"Wykluczenia\",\n            \"gallery_and_image_options\": \"Opcje galerii i obrazów\",\n            \"media_content_extensions\": \"Rozszerzenia treści multimedialnych\"\n        },\n        \"logs\": {\n            \"log_level\": \"Poziom rejestrowania\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Punkty zaczepienia\",\n            \"triggers_on\": \"Wyzwalacze \\\"w przypadku\\\"\",\n            \"available_plugins\": \"Dostępne wtyczki\",\n            \"installed_plugins\": \"Zainstalowane wtyczki\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"Metadane - {entityType}\",\n            \"entity_scrapers\": \"Zbieracze – {entityType}\",\n            \"excluded_tag_patterns_desc\": \"Wyrażenia regularne nazw tagów do wykluczenia z wyników scrapowania\",\n            \"excluded_tag_patterns_head\": \"Wykluczone wzorce tagów\",\n            \"scraper\": \"Zbieracz\",\n            \"scrapers\": \"Zbieracze\",\n            \"search_by_name\": \"Wyszukiwanie według nazwy\",\n            \"supported_types\": \"Obsługiwane typy\",\n            \"supported_urls\": \"Adresy URL\",\n            \"available_scrapers\": \"Dostępne zbieracze\",\n            \"installed_scrapers\": \"Zainstalowane zbieracze\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Dodaj instancję stash-box\",\n            \"api_key\": \"Klucz API\",\n            \"description\": \"Stash-box ułatwia automatyczne tagowanie scen i aktorów na podstawie odcisków palców i nazw plików.\\nPunkt końcowy oraz klucz API można znaleźć na stronie swojego konta w instancji stash-box. Nazwy są wymagane, gdy dodawana jest więcej niż jedna instancja.\",\n            \"endpoint\": \"Punkt końcowy\",\n            \"graphql_endpoint\": \"Punkt końcowy GraphQL\",\n            \"name\": \"Nazwa\",\n            \"title\": \"Punkty końcowe stash-box\",\n            \"max_requests_per_minute\": \"Maksymalna liczba żądań na minutę\",\n            \"max_requests_per_minute_description\": \"Używa wartości domyślnej {defaultValue}, jeśli ustawiono 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transkodowanie\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Dodano {operation_name} do kolejki zadań\",\n            \"anonymise_and_download\": \"Wykonuje zanonimizowaną kopię bazy danych i pobiera plik wynikowy.\",\n            \"anonymise_database\": \"Wykonuje kopię bazy danych do katalogu kopii zapasowych, anonimizując wszystkie wrażliwe dane. Kopia ta może być udostępniona innym osobom w celu rozwiązywania problemów i usuwania usterek. Oryginalna baza danych nie jest modyfikowana. Zanonimizowana baza danych używa formatu nazwy pliku {filename_format}.\",\n            \"anonymising_database\": \"Anonimizowanie bazy danych\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Automatyczne tagowanie wszystkich ścieżek\",\n                \"auto_tagging_paths\": \"Automatyczne tagowanie następujących ścieżek\"\n            },\n            \"auto_tag_based_on_filenames\": \"Automatyczne tagowanie zawartości na podstawie ścieżek plików.\",\n            \"auto_tagging\": \"Automatyczne tagowanie\",\n            \"backing_up_database\": \"Tworzenie kopii zapasowej bazy danych\",\n            \"backup_and_download\": \"Wykonuje kopię zapasową bazy danych i pobiera plik wynikowy.\",\n            \"cleanup_desc\": \"Sprawdza, czy nie ma brakujących plików i usuwa je z bazy danych. Jest to działanie destrukcyjne.\",\n            \"data_management\": \"Zarządzanie danymi\",\n            \"defaults_set\": \"Zostały ustawione wartości domyślne, które będą używane po kliknięciu przycisku {action} na stronie Zadania.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Nie dołączaj rozszerzenia pliku jako części tytułu\",\n            \"empty_queue\": \"Obecnie nie są wykonywane żadne zadania.\",\n            \"export_to_json\": \"Eksportuje zawartość bazy danych do formatu JSON w katalogu metadanych.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Generowanie dla scen z następujących ścieżek\",\n                \"generating_scenes\": \"Generowanie dla {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"Generowanie podglądu klipów obrazów\",\n            \"generate_desc\": \"Generowanie pomocniczych plików graficznych, sprite'ów, wideo, vtt i innych.\",\n            \"generate_phashes_during_scan\": \"Generowanie hashy percepcyjnych\",\n            \"generate_phashes_during_scan_tooltip\": \"Do deduplikacji i identyfikacji scen.\",\n            \"generate_previews_during_scan\": \"Generowanie obrazów do animowanych podglądów\",\n            \"generate_previews_during_scan_tooltip\": \"Generowanie animowanych podglądów WebP, wymagane tylko wtedy, gdy opcja Typ podglądu jest ustawiona na Animowane obrazy.\",\n            \"generate_sprites_during_scan\": \"Generowanie sprite'ów scrubberów\",\n            \"generate_thumbnails_during_scan\": \"Generowanie miniatur dla obrazów\",\n            \"generate_video_covers_during_scan\": \"Generuj okładki dla scen\",\n            \"generate_video_previews_during_scan\": \"Generowanie podglądów\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generowanie podglądów wideo, które są odtwarzane po najechaniu kursorem myszy na scenę\",\n            \"generated_content\": \"Zawartość wygenerowana\",\n            \"identify\": {\n                \"and_create_missing\": \"i utwórz brakujące\",\n                \"create_missing\": \"Utwórz brakujące\",\n                \"default_options\": \"Ustawienia domyślne\",\n                \"description\": \"Automatycznie ustaw metadane sceny ze źródeł stash-box i zbieraczy.\",\n                \"explicit_set_description\": \"Następujące opcje będą używane, jeśli nie zostały nadpisane w opcjach specyficznych dla źródła.\",\n                \"field\": \"Pole\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Opcje pól\",\n                \"heading\": \"Identyfikacja\",\n                \"identifying_from_paths\": \"Identyfikuj sceny z następujących ścieżek\",\n                \"identifying_scenes\": \"Identyfikuj {num} {scene}\",\n                \"include_male_performers\": \"Uwzględnij aktorów płci męskiej\",\n                \"set_cover_images\": \"Ustaw obrazy okładek\",\n                \"set_organized\": \"Ustaw flagę, zorganizowane\",\n                \"source\": \"Źródło\",\n                \"source_options\": \"Opcje {source}\",\n                \"sources\": \"Źródła\",\n                \"strategy\": \"Strategia\",\n                \"skip_multiple_matches\": \"Pomiń dopasowania, które mają więcej niż jeden wynik\",\n                \"skip_multiple_matches_tooltip\": \"Jeśli ta opcja nie jest włączona i zwrócono więcej niż jeden wynik, jeden z nich zostanie losowo wybrany do dopasowania\",\n                \"skip_single_name_performers\": \"Pomiń wykonawców z jedną nazwą bez dodatkowego rozróżnienia\",\n                \"tag_skipped_matches\": \"Otaguj pominięte dopasowania\",\n                \"tag_skipped_performers\": \"Otaguj pominiętych wykonawców\",\n                \"skip_single_name_performers_tooltip\": \"Bez włączenia tej opcji wykonawcy o popularnych imionach (np. Samantha, Olga) będą dopasowywani\"\n            },\n            \"import_from_exported_json\": \"Import z wyeksportowanego pliku JSON w katalogu metadanych. Wymazuje istniejącą bazę danych.\",\n            \"incremental_import\": \"Import przyrostowy z dostarczonego wyeksportowanego pliku zip.\",\n            \"job_queue\": \"Kolejka zadań\",\n            \"maintenance\": \"Konserwacja\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Usuń stare dane\",\n                \"description\": \"Migracja blobów do aktualnego systemu przechowywania blobów. Ta migracja powinna być uruchomiona po zmianie systemu przechowywania blobów. Może opcjonalnie usunąć stare dane po migracji.\"\n            },\n            \"migrate_hash_files\": \"Używany po zmianie hasha nazw generowanych plików w celu zmiany nazwy istniejących wygenerowanych plików na nowy format hasha.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Usuń pliki ze zrzutami ekranu ze scen\",\n                \"description\": \"Migracja zrzutów ekranu sceny do nowego systemu przechowywania blobów. Ta migracja powinna być uruchomiona po migracji istniejącego systemu do wersji 0.20. Opcjonalnie można usunąć stare zrzuty ekranu po migracji.\",\n                \"overwrite_existing\": \"Nadpisz istniejące BLOBy danymi ze zrzutów ekranu ze scen\"\n            },\n            \"migrations\": \"Migracje\",\n            \"only_dry_run\": \"Wykonaj tylko próbę na sucho. Nie usuwa niczego\",\n            \"plugin_tasks\": \"Zadania wtyczek\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Skanowanie wszystkich ścieżek\",\n                \"scanning_paths\": \"Skanowanie następujących ścieżek\"\n            },\n            \"scan_for_content_desc\": \"Skanowanie w poszukiwaniu nowych treści i dodawanie ich do bazy danych.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Ustawianie nazwy, daty i szczegółów z metadanych osadzonego pliku\",\n            \"clean_generated\": {\n                \"blob_files\": \"Pliki blob\",\n                \"description\": \"Usuwa wygenerowane pliki, które nie mają odpowiadającego wpisu w bazie danych.\",\n                \"image_thumbnails\": \"Miniatury obrazów\",\n                \"image_thumbnails_desc\": \"Miniatury obrazów i klipy\",\n                \"markers\": \"Podglądy znaczników\",\n                \"previews\": \"Podglądy scen\",\n                \"previews_desc\": \"Podglądy scen i miniatury\",\n                \"sprites\": \"Klipy scen\",\n                \"transcodes\": \"Transkodowania scen\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"Obrazy wyświetlane pod odtwarzaczem wideo ułatwiające nawigację.\",\n            \"optimise_database\": \"Popraw wydajność, analizując i przebudowując całą bazę danych.\",\n            \"rescan\": \"Przeskanuj pliki ponownie\",\n            \"rescan_tooltip\": \"Przeskanuj ponownie każdy plik w ścieżce. Używane do wymuszenia aktualizacji metadanych plików i ponownego skanowania plików ZIP.\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Sprawdzacz duplikatów scen\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Dodaj pole\",\n                \"capitalize_title\": \"Zapisuj tytuł wielkimi literami\",\n                \"display_fields\": \"Wyświetl pola\",\n                \"escape_chars\": \"Użyj \\\\, aby uniknąć literalnych znaków\",\n                \"filename\": \"Nazwa pliku\",\n                \"filename_pattern\": \"Wzór nazwy pliku\",\n                \"ignore_organized\": \"Ignoruj uporządkowane sceny\",\n                \"ignored_words\": \"Ignorowane słowa\",\n                \"matches_with\": \"Dopasowania z {i}\",\n                \"select_parser_recipe\": \"Wybierz szablon parsera\",\n                \"title\": \"Parser nazw plików scen\",\n                \"whitespace_chars\": \"Białe znaki\",\n                \"whitespace_chars_desc\": \"Te znaki zostaną zastąpione spacją w tytule\"\n            },\n            \"scene_tools\": \"Narzędzia scen\",\n            \"graphql_playground\": \"Środowisko testowe GraphQL\",\n            \"heading\": \"Narzędzia\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Skracanie liczników na kartach i stronach podglądu szczegółów, na przykład \\\"1831\\\" zostanie skrócone do \\\"1.8K\\\".\",\n                \"heading\": \"Skracaj liczby\"\n            },\n            \"basic_settings\": \"Ustawienia podstawowe\",\n            \"custom_css\": {\n                \"description\": \"Strona musi zostać ponownie załadowana, aby zmiany zaczęły obowiązywać. Nie ma gwarancji kompatybilności między niestandardowym CSS a przyszłymi wydaniami Stash.\",\n                \"heading\": \"Własny CSS\",\n                \"option_label\": \"Włączony własny CSS\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Strona musi zostać ponownie załadowana, aby zmiany zaczęły obowiązywać. Nie ma gwarancji zgodności między niestandardowym Javascriptem a przyszłymi wydaniami Stash.\",\n                \"heading\": \"Własny Javascript\",\n                \"option_label\": \"Własny Javascript włączony\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Nadpisywanie poszczególnych ciągów lokalnie. Zobacz https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json, aby uzyskać główną listę. Aby zmiany weszły w życie, strona musi zostać ponownie załadowana.\",\n                \"heading\": \"Niestandardowa lokalizacja\",\n                \"option_label\": \"Niestandardowa lokalizacja włączona\"\n            },\n            \"delete_options\": {\n                \"description\": \"Ustawienia domyślne przy usuwaniu obrazów, galerii i scen.\",\n                \"heading\": \"Opcje usuwania\",\n                \"options\": {\n                    \"delete_file\": \"Domyślnie usuń plik\",\n                    \"delete_generated_supporting_files\": \"Domyślnie usuwaj wygenerowane pliki pomocnicze\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Integracja z pulpitem\",\n                \"notifications_enabled\": \"Włącz powiadomienia\",\n                \"send_desktop_notifications_for_events\": \"Wysyłanie powiadomień o zdarzeniach na pulpit\",\n                \"skip_opening_browser\": \"Pomiń otwieranie przeglądarki\",\n                \"skip_opening_browser_on_startup\": \"Pomiń automatyczne otwieranie przeglądarki podczas uruchamiania\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Usunięcie możliwości tworzenia nowych obiektów z poziomu listy rozwijanej\",\n                    \"heading\": \"Wyłącz tworzenie za pomocą rozwijanej listy\"\n                },\n                \"heading\": \"Edytowanie\",\n                \"max_options_shown\": {\n                    \"label\": \"Maksymalna ilość obiektów pokazywanych w listach wyboru\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Precyzja oceniania gwiazdkami\",\n                        \"options\": {\n                            \"full\": \"Cała\",\n                            \"half\": \"Pół\",\n                            \"quarter\": \"Ćwierć\",\n                            \"tenth\": \"Części dziesiętne\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Rodzaj systemu oceny\",\n                        \"options\": {\n                            \"decimal\": \"Liczba\",\n                            \"stars\": \"Gwiazdki\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Przesunięcie czasowe w milisekundach dla odtwarzania skryptów interaktywnych.\",\n                \"heading\": \"Przesunięcie Funscript (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Połącz\",\n                \"server_offset\": {\n                    \"heading\": \"Przesunięcie serwera\"\n                },\n                \"status\": {\n                    \"heading\": \"Stan podłączenia Handy\"\n                },\n                \"sync\": \"Synchronizuj\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Klucz połączenia Handy używany w scenach interaktywnych. Ustawienie tego klucza umożliwi Stash udostępnianie informacji o bieżącej scenie w witrynie handyfeeling.com\",\n                \"heading\": \"Klucz połączenia Handy\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Pole podglądu obrazu\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Kierunek\",\n                \"heading\": \"Ściana z obrazami\",\n                \"margin\": \"Margines (w pikselach)\"\n            },\n            \"images\": {\n                \"heading\": \"Obrazy\",\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"Gdy biblioteka ma wyłączone pliki wideo, pliki wideo (pliki kończące się rozszerzeniem wideo) będą skanowane jako klipy obrazów.\",\n                        \"heading\": \"Skanuj rozszerzenia wideo jako klipy obrazów\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Zapisywanie miniatur obrazów na dysku, gdy są one generowane w locie\",\n                        \"heading\": \"Zapisywanie miniatur obrazów\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Opcje interaktywne\",\n            \"language\": {\n                \"heading\": \"Język\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Maksymalny czas trwania sceny, podczas którego odtwarzacz scen będzie zapętlał obraz wideo - 0, aby wyłączyć\",\n                \"heading\": \"Maksymalny czas trwania pętli\"\n            },\n            \"menu_items\": {\n                \"description\": \"Pokaż lub ukryj różne typy zawartości na pasku nawigacyjnym\",\n                \"heading\": \"Pozycje menu\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Procent czasu, w którym scena musi zostać odtworzona, zanim jej licznik zostanie zwiększony.\",\n                \"heading\": \"Minimalny czas odtwarzania w procentach\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Niestandardowa ścieżka dostępu do domyślnych obrazów aktorów. Pozostaw puste, aby użyć domyślnych ustawień wbudowanych\",\n                        \"heading\": \"Niestandardowa ścieżka obrazów aktorów\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Domyślną opcją są podglądy wideo (MP4). Aby zmniejszyć obciążenie CPU podczas przeglądania, możesz używać podglądów w formacie animowanego obrazu (WebP). Jednakże muszą one zostać dodatkowo wygenerowane i zajmują więcej miejsca.\",\n                \"heading\": \"Typ podglądu\",\n                \"options\": {\n                    \"animated\": \"Animowany obraz\",\n                    \"static\": \"Statyczny obraz\",\n                    \"video\": \"Wideo\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Lista scen\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Pokaż Studia jako tekst\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Odtwarzacz scen\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Zawsze odtwarzaj film od początku\",\n                    \"auto_start_video\": \"Automatyczne odtwarzanie wideo\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Automatyczne odtwarzaj wideo z kolejki albo z wybranych lub losowych scen ze strony Sceny\",\n                        \"heading\": \"Automatyczne odtwarzanie wideo podczas odtwarzania wybranych\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Odtwarzaj następną scenę w kolejce po zakończeniu odtwarzania bieżącej\",\n                        \"heading\": \"Domyślnie kontynuuj odtwarzanie playlisty\"\n                    },\n                    \"show_scrubber\": \"Pokaż Scrubber\",\n                    \"track_activity\": \"Śledzenie aktywności\",\n                    \"vr_tag\": {\n                        \"description\": \"Przycisk VR będzie wyświetlany tylko dla scen z tym znacznikiem.\",\n                        \"heading\": \"Tag VR\"\n                    },\n                    \"enable_chromecast\": \"Włącz Chromecast\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Ściana scen/znaczników\",\n                \"options\": {\n                    \"display_title\": \"Wyświetl tytuł i tagi\",\n                    \"toggle_sound\": \"Włącz dźwięk\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Ilość prób przewijania przed przejściem do następnej/poprzedniej pozycji. Ma zastosowanie tylko w trybie Pan Y.\",\n                \"heading\": \"Próby przewijania przed przejściem\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Pokazuj kartę tagu po najechaniu na plakietkę tagu\",\n                \"heading\": \"Karty tagów\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Pokaz slajdów jest dostępny w galeriach w trybie widoku ściany\",\n                \"heading\": \"Opóźnienie pokazu slajdów (sekundy)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Widok studia\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"W widoku studia wyświetlaj także zawartość z podstudiów\",\n                        \"heading\": \"Wyświetlaj zawartość z podstudiów\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Widok tagu\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"W widoku tagu wyświetlaj również zawartość z podtagów\",\n                        \"heading\": \"Wyświetlaj zawartość z podtagów\"\n                    }\n                }\n            },\n            \"title\": \"Interfejs użytkownika\",\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"description\": \"Po włączeniu ta opcja wyświetli rozszerzone szczegóły przy zachowaniu kompaktowego układu\",\n                    \"heading\": \"Kompaktowy widok szczegółów\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Pokaż tło na stronie szczegółów.\",\n                    \"heading\": \"Włącz obraz tła\"\n                },\n                \"heading\": \"Strona szczegółów\",\n                \"show_all_details\": {\n                    \"description\": \"Po włączeniu wszystkie szczegóły zawartości będą domyślnie wyświetlane, każdy element szczegółów w jednej kolumnie\",\n                    \"heading\": \"Pokaż wszystkie szczegóły\"\n                }\n            },\n            \"performer_list\": {\n                \"heading\": \"Lista wykonawców\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"Wyświetl linki na kartach siatki wykonawców\"\n                    }\n                }\n            },\n            \"sfw_mode\": {\n                \"heading\": \"Tryb treści SFW\"\n            }\n        },\n        \"advanced_mode\": \"Tryb zaawansowany\"\n    },\n    \"configuration\": \"Konfiguracja\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Plik} few {Pliki} other {Plików}}\",\n        \"galleries\": \"{count, plural, one {Galeria} few {Galerie} other {Galerii}}\",\n        \"images\": \"{count, plural, one {Obraz} few {Obrazy} other {Obrazów}}\",\n        \"markers\": \"{count, plural, one {Znacznik} few {Znaczniki} other {Znaczników}}\",\n        \"performers\": \"{count, plural, one {Aktor} few {Aktorów} other {Aktorów}}\",\n        \"scenes\": \"{count, plural, one {Scena} few {Sceny} other {Scen}}\",\n        \"studios\": \"{count, plural, one {Studio} few {Studia} other {Studiów}}\",\n        \"tags\": \"{count, plural, one {Tag} few {Tagi} other {Tagów}}\"\n    },\n    \"country\": \"Kraj\",\n    \"cover_image\": \"Okładka\",\n    \"created_at\": \"Utworzono\",\n    \"criterion\": {\n        \"greater_than\": \"Większy niż\",\n        \"less_than\": \"Mniejszy niż\",\n        \"value\": \"Wartość\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"pomiędzy\",\n        \"equals\": \"jest\",\n        \"excludes\": \"nie zawiera\",\n        \"format_string\": \"{criterion} {modifierString}: {valueString}\",\n        \"greater_than\": \"jest większy niż\",\n        \"includes\": \"zawiera\",\n        \"includes_all\": \"zawiera wszystkie\",\n        \"is_null\": \"jest pusty\",\n        \"less_than\": \"jest mniejszy niż\",\n        \"matches_regex\": \"pasuje do wyrażenia regularnego\",\n        \"not_between\": \"nie jest pomiędzy\",\n        \"not_equals\": \"nie jest\",\n        \"not_matches_regex\": \"nie pasuje do wyrażenia regularnego\",\n        \"not_null\": \"nie jest pusty\"\n    },\n    \"custom\": \"Własne\",\n    \"date\": \"Data\",\n    \"date_format\": \"RRRR-MM-DD\",\n    \"datetime_format\": \"RRRR-MM-DD GG:MM\",\n    \"death_date\": \"Data śmierci\",\n    \"death_year\": \"Rok śmierci\",\n    \"descending\": \"Malejąco\",\n    \"description\": \"Opis\",\n    \"detail\": \"Szczegół\",\n    \"details\": \"Szczegóły\",\n    \"developmentVersion\": \"Wersja deweloperska\",\n    \"dialogs\": {\n        \"create_new_entity\": \"Dodaj {entity}\",\n        \"delete_alert\": \"Następujące elementy {count, plural, one {{singularEntity}} other {{pluralEntity}}} zostaną trwale usunięte:\",\n        \"delete_confirm\": \"Czy na pewno chcesz usunąć {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Czy na pewno chcesz usunąć {singularEntity}? Jeśli plik nie zostanie usunięty, {singularEntity} zostanie ponownie dodane podczas skanowania.} other {Czy na pewno chcesz usunąć {pluralEntity}? Jeśli te pliki nie zostaną usunięte, {pluralEntity} zostaną ponownie dodane podczas skanowania.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Czy na pewno chcesz usunąć {singularEntity}?} other {Czy na pewno chcesz usunąć {pluralEntity}?}}\",\n        \"delete_entity_title\": \"{count, plural, one {Usuń {singularEntity}} other {Usuń {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"…oraz wszystkie pliki obrazów, które nie są dołączone do żadnej innej galerii.\",\n        \"delete_gallery_files\": \"Usuń folder galerii/plik zip i wszystkie obrazy, które nie są dołączone do żadnej innej galerii..\",\n        \"delete_object_desc\": \"Czy na pewno chcesz usunąć {count, plural, one {{singularEntity}} other {{pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…i jeszcze {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Usuń {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Nie pokazuj do następnej aktualizacji\",\n        \"edit_entity_title\": \"Edytuj {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Uwzględnij powiązane obiekty w eksporcie\",\n        \"export_title\": \"Eksportuj\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Kolumna\",\n                \"description\": \"Układ oparty na kolumnach lub wierszach.\",\n                \"row\": \"Rząd\"\n            },\n            \"margin_desc\": \"Liczba pikseli marginesu wokół całego obrazu.\"\n        },\n        \"lightbox\": {\n            \"delay\": \"Opóźnienie (s)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Dopasuj w poziomie\",\n                \"fit_to_screen\": \"Dopasuj do ekranu\",\n                \"label\": \"Tryb wyświetlania\",\n                \"original\": \"Oryginalny\"\n            },\n            \"options\": \"Opcje\",\n            \"page_header\": \"Strona {page} / {total}\",\n            \"reset_zoom_on_nav\": \"Resetuj poziom powiększenia przy zmianie obrazu\",\n            \"scale_up\": {\n                \"description\": \"Skaluj mniejsze obrazy, aby wypełnić ekran\",\n                \"label\": \"Skaluj aby wypełnić\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Przytrzymaj shift, aby tymczasowo użyć innego trybu.\",\n                \"label\": \"Tryb przewijania\",\n                \"pan_y\": \"Przesunięcie Y\",\n                \"zoom\": \"Powiększenie\"\n            },\n            \"disable_animation\": \"Wyłącz animację przejścia między obrazami\"\n        },\n        \"merge\": {\n            \"destination\": \"Cel\",\n            \"empty_results\": \"Wartości pola \\\"cel\\\" nie zostaną zmienione.\",\n            \"source\": \"Źródło\"\n        },\n        \"reassign_entity_title\": \"Przypisz {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Przypisz ponownie do\"\n        },\n        \"scene_gen\": {\n            \"clip_previews\": \"Podgląd klipu obrazu\",\n            \"covers\": \"Okładki scen\",\n            \"force_transcodes\": \"Wymuś generowanie transkodu\",\n            \"force_transcodes_tooltip\": \"Domyślnie transkody są generowane tylko wtedy, gdy plik wideo nie jest obsługiwany przez przeglądarkę. Po włączeniu tej funkcji transkod będzie generowany nawet wtedy, gdy plik wideo wydaje się być obsługiwany przez przeglądarkę.\",\n            \"image_previews\": \"Podglądy - Animowane obrazy\",\n            \"image_previews_tooltip\": \"Animowane podglądy WebP, wymagane tylko wtedy, gdy opcja Typ podglądu jest ustawiona na Animowane obrazy.\",\n            \"interactive_heatmap_speed\": \"Generowanie heatmap i prędkości dla scen interaktywnych\",\n            \"marker_image_previews\": \"Podgląd znaczników - Animowane obrazy\",\n            \"marker_image_previews_tooltip\": \"Animowane podglądy znaczników WebP, wymagane tylko wtedy, gdy opcja Typ podglądu jest ustawiony na Animowane obrazy.\",\n            \"marker_screenshots\": \"Podgląd znaczników - Statyczny obraz\",\n            \"marker_screenshots_tooltip\": \"Marker statycznych obrazów JPG, wymagany tylko wtedy, gdy Typ podglądu jest ustawiony na Statyczny obraz.\",\n            \"markers\": \"Podglądy znaczników\",\n            \"markers_tooltip\": \"20-sekundowe filmy rozpoczynające się od podanego kodu czasowego.\",\n            \"override_preview_generation_options\": \"Zastąp opcje generowania podglądu\",\n            \"override_preview_generation_options_desc\": \"Nadpisanie opcji generowania podglądu dla tej operacji. Domyślne wartości ustawia się w System -> Generowanie podglądu.\",\n            \"overwrite\": \"Nadpisywanie istniejących plików\",\n            \"phash\": \"Hasze percepcyjne (do deduplikacji)\",\n            \"preview_exclude_end_time_desc\": \"Wyklucz z podglądu sceny ostatnie x sekund. Może to być wartość wyrażona w sekundach lub procent (np. 2%) całkowitego czasu trwania sceny.\",\n            \"preview_exclude_end_time_head\": \"Pomiń koniec\",\n            \"preview_exclude_start_time_desc\": \"Wyklucz z podglądu sceny pierwsze x sekund. Może to być wartość wyrażona w sekundach lub procent (np. 2%) całkowitego czasu trwania sceny.\",\n            \"preview_exclude_start_time_head\": \"Pomiń początek\",\n            \"preview_generation_options\": \"Opcje generowania podglądu\",\n            \"preview_options\": \"Opcje podglądu\",\n            \"preview_preset_desc\": \"Ustawienie wstępne reguluje rozmiar, jakość i czas kodowania generowania podglądu. Ustawienia wstępne powyżej \\\"Wolno\\\" przynoszą coraz mniejsze korzyści i nie są zalecane.\",\n            \"preview_preset_head\": \"Wstępne ustawienie kodowania podglądu\",\n            \"preview_seg_count_desc\": \"Liczba segmentów w plikach podglądu.\",\n            \"preview_seg_count_head\": \"Liczba segmentów w podglądzie\",\n            \"preview_seg_duration_desc\": \"Czas trwania każdego segmentu podglądu, w sekundach.\",\n            \"preview_seg_duration_head\": \"Czas trwania segmentu podglądu\",\n            \"sprites\": \"Sprite'y sceny\",\n            \"sprites_tooltip\": \"Obrazy wyświetlane pod odtwarzaczem wideo ułatwiające nawigację.\",\n            \"transcodes\": \"Transkodowanie\",\n            \"transcodes_tooltip\": \"Konwersja do MP4 z nieobsługiwanych formatów wideo\",\n            \"video_previews\": \"Podglądy\",\n            \"video_previews_tooltip\": \"Podgląd wideo odtwarzany po najechaniu kursorem myszy na scenę\",\n            \"image_thumbnails\": \"Miniatury obrazów\",\n            \"phash_tooltip\": \"Do deduplikacji i identyfikacji scen\"\n        },\n        \"scenes_found\": \"Znaleziono {count} scen\",\n        \"scrape_entity_query\": \"Zapytanie zbieracza – {entity_type}\",\n        \"scrape_entity_title\": \"Wyniki zbierania {entity_type}\",\n        \"scrape_results_existing\": \"Istniejący\",\n        \"scrape_results_scraped\": \"Zebrany\",\n        \"set_image_url_title\": \"Adres URL obrazu\",\n        \"unsaved_changes\": \"Niezapisane zmiany. Czy na pewno chcesz wyjść?\",\n        \"clear_o_history_confirm\": \"Czy na pewno chcesz wyczyścić historię O?\",\n        \"clear_play_history_confirm\": \"Czy na pewno chcesz wyczyścić historię odtwarzania?\",\n        \"overwrite_filter_warning\": \"Zapisany filtr \\\"{entityName}\\\" zostanie zastąpiony.\",\n        \"studios_found\": \"Znalezione studia: {count}\",\n        \"tags_found\": \"Znalezione tagi: {count}\",\n        \"set_default_filter_confirm\": \"Czy na pewno chcesz ustawić ten filtr jako domyślny?\",\n        \"clear_o_history_confirm_sfw\": \"Czy na pewno chcesz wyczyścić historię polubień?\",\n        \"performers_found\": \"Znalezieni wykonawcy: {count}\",\n        \"stashid_exists_warning\": \"Istniejący stash id dla tego stash-box zostanie zastąpiony.\",\n        \"scrape_results_missing\": \"Brakuje\"\n    },\n    \"dimensions\": \"Wymiary\",\n    \"director\": \"Reżyser\",\n    \"disambiguation\": \"Ujednoznacznienie\",\n    \"display_mode\": {\n        \"grid\": \"Siatka\",\n        \"list\": \"Lista\",\n        \"tagger\": \"Otagowywacz\",\n        \"unknown\": \"Nieznany\",\n        \"wall\": \"Ściana\",\n        \"label_current\": \"Tryb wyświetlania: {current}\"\n    },\n    \"donate\": \"Przekaż darowiznę\",\n    \"dupe_check\": {\n        \"description\": \"Obliczenia na poziomach poniżej \\\"Dokładna\\\" mogą trwać dłużej. Na niższych poziomach dokładności mogą być również zwracane wyniki fałszywie dodatnie.\",\n        \"duration_diff\": \"Maksymalna różnica czasu trwania\",\n        \"duration_options\": {\n            \"any\": \"Dowolna\",\n            \"equal\": \"Brak (takiej samej długości)\"\n        },\n        \"found_sets\": \"Znaleziono {setCount, plural, one{# zestaw} few {# zestawy} other {# zestawów}} duplikatów.\",\n        \"options\": {\n            \"exact\": \"Dokładna\",\n            \"high\": \"Wysoka\",\n            \"low\": \"Niska\",\n            \"medium\": \"Średnia\"\n        },\n        \"search_accuracy_label\": \"Dokładność wyszukiwania\",\n        \"title\": \"Duplikaty scen\",\n        \"select_all_but_largest_file\": \"Zaznacz wszystkie pliki w każdej grupie duplikatów, z wyjątkiem największego pliku\",\n        \"select_all_but_largest_resolution\": \"Zaznacz wszystkie pliki w każdej grupie duplikatów, z wyjątkiem pliku o najwyższej rozdzielczości\",\n        \"only_select_matching_codecs\": \"Zaznacz tylko wtedy, gdy wszystkie kodeki w grupie duplikatów się zgadzają\",\n        \"select_oldest\": \"Zaznacz najstarszy plik w grupie duplikatów\",\n        \"select_none\": \"Odznacz wszystko\",\n        \"select_options\": \"Ustawienia zaznaczania…\",\n        \"select_youngest\": \"Zaznacz najmłodszy plik w grupie duplikatów\"\n    },\n    \"duplicated_phash\": \"Duplikaty (phash)\",\n    \"duration\": \"Czas trwania\",\n    \"effect_filters\": {\n        \"aspect\": \"Aspekt\",\n        \"blue\": \"Niebieski\",\n        \"blur\": \"Rozmycie\",\n        \"brightness\": \"Jasność\",\n        \"contrast\": \"Kontrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Zielony\",\n        \"hue\": \"Odcień\",\n        \"name\": \"Filtry\",\n        \"name_transforms\": \"Transformacje\",\n        \"red\": \"Czerwony\",\n        \"reset_filters\": \"Resetuj filtry\",\n        \"reset_transforms\": \"Resetuj transformacje\",\n        \"rotate\": \"Obrót\",\n        \"rotate_left_and_scale\": \"Obróć w lewo i przeskaluj\",\n        \"rotate_right_and_scale\": \"Obróć w prawo i przeskaluj\",\n        \"saturation\": \"Nasycenie\",\n        \"scale\": \"Skala\",\n        \"warmth\": \"Ciepło\"\n    },\n    \"empty_server\": \"Aby zobaczyć rekomendacje na tej stronie dodaj sceny do serwera.\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"Indeks obrazu musi być większy niż 0\",\n        \"lazy_component_error_help\": \"Jeśli Stash został niedawno zaktualizowany, proszę przeładować stronę lub wyczyścić pamięć podręczną przeglądarki.\",\n        \"something_went_wrong\": \"Coś się zepsuło.\",\n        \"custom_fields\": {\n            \"duplicate_field\": \"Nazwa pola musi być unikalna\",\n            \"field_name_length\": \"Nazwa pola musi mieć mniej niż 65 znaków\",\n            \"field_name_required\": \"Nazwa pola jest wymagana\",\n            \"field_name_whitespace\": \"Nazwa pola nie może zawierać spacji na początku ani na końcu\"\n        },\n        \"header\": \"Błąd\",\n        \"invalid_javascript_string\": \"Nieprawidłowy kod JavaScript: {error}\",\n        \"invalid_json_string\": \"Nieprawidłowy ciąg JSON: {error}\",\n        \"loading_type\": \"Błąd podczas ładowania {type}\"\n    },\n    \"ethnicity\": \"Pochodzenie etniczne\",\n    \"existing_value\": \"istniejąca wartość\",\n    \"eye_color\": \"Kolor oczu\",\n    \"fake_tits\": \"Sztuczne cycki\",\n    \"false\": \"Nie\",\n    \"favourite\": \"Ulubione\",\n    \"file\": \"plik\",\n    \"file_count\": \"Liczba plików\",\n    \"file_info\": \"Info o pliku\",\n    \"file_mod_time\": \"Czas modyfikacji pliku\",\n    \"files\": \"pliki\",\n    \"files_amount\": \"{value} plików\",\n    \"filesize\": \"Rozmiar pliku\",\n    \"filter\": \"Filtr\",\n    \"filter_name\": \"Nazwa filtra\",\n    \"filters\": \"Filtry\",\n    \"folder\": \"Folder\",\n    \"framerate\": \"Liczba klatek na sekundę\",\n    \"frames_per_second\": \"{value} klatek na sekundę\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Gotowy filtr\",\n            \"saved_filter\": \"Zapisany filtr\"\n        }\n    },\n    \"galleries\": \"Galerie\",\n    \"gallery\": \"Galeria\",\n    \"gallery_count\": \"Liczba Galerii\",\n    \"gender\": \"Płeć\",\n    \"gender_types\": {\n        \"FEMALE\": \"Kobieta\",\n        \"INTERSEX\": \"Interpłciowy\",\n        \"MALE\": \"Mężczyzna\",\n        \"NON_BINARY\": \"Niebinarny\",\n        \"TRANSGENDER_FEMALE\": \"Transgenderowa kobieta\",\n        \"TRANSGENDER_MALE\": \"Transgenderowy mężczyzna\"\n    },\n    \"hair_color\": \"Kolor włosów\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Podłączanie\",\n        \"disconnected\": \"Rozłączono\",\n        \"error\": \"Błąd połączenia z Handy\",\n        \"missing\": \"Brak\",\n        \"ready\": \"Podłączono\",\n        \"syncing\": \"Synchronizacja z serwerem\",\n        \"uploading\": \"Wgrywanie skryptu\"\n    },\n    \"hasChapters\": \"Rozdziały\",\n    \"hasMarkers\": \"Znaczniki\",\n    \"height\": \"Wzrost\",\n    \"height_cm\": \"Wzrost (cm)\",\n    \"help\": \"Help\",\n    \"ignore_auto_tag\": \"Ignoruj automatyczne tagowanie\",\n    \"image\": \"Obraz\",\n    \"image_count\": \"Liczba obrazów\",\n    \"image_index\": \"Obraz nr\",\n    \"images\": \"Obrazy\",\n    \"include_parent_tags\": \"Uwzględnij tagi nadrzędne\",\n    \"include_sub_studios\": \"Uwzględnić studia zależne\",\n    \"include_sub_tags\": \"Uwzględnij podtagi\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktywny\",\n    \"interactive_speed\": \"Prędkość interaktywna\",\n    \"isMissing\": \"Brakuje\",\n    \"last_played_at\": \"Ostatnio odtwarzano\",\n    \"library\": \"Biblioteka\",\n    \"loading\": {\n        \"generic\": \"Ładowanie…\",\n        \"plugins\": \"Ładowanie wtyczek…\"\n    },\n    \"marker_count\": \"Liczba Znaczników\",\n    \"markers\": \"Znaczniki\",\n    \"measurements\": \"Wymiary\",\n    \"media_info\": {\n        \"audio_codec\": \"Kodek audio\",\n        \"downloaded_from\": \"Pobrano z\",\n        \"interactive_speed\": \"Szybkość interaktywna\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} w momencie produkcji\"\n        },\n        \"phash\": \"PHash\",\n        \"play_count\": \"Liczba odtworzeń\",\n        \"play_duration\": \"Czas odtwarzania\",\n        \"stream\": \"Stream\",\n        \"video_codec\": \"Kodek wideo\",\n        \"o_count\": \"Licznik O\"\n    },\n    \"megabits_per_second\": \"{value} megabitów na sekundę\",\n    \"metadata\": \"Metadane\",\n    \"name\": \"Nazwa\",\n    \"new\": \"Dodaj\",\n    \"none\": \"Brak\",\n    \"operations\": \"Operacje\",\n    \"organized\": \"Uporządkowany\",\n    \"pagination\": {\n        \"first\": \"Pierwsza\",\n        \"last\": \"Ostatnia\",\n        \"next\": \"Następna\",\n        \"previous\": \"Poprzednia\",\n        \"current_total\": \"{current} z {total}\"\n    },\n    \"parent_of\": \"Rodzic {children}\",\n    \"parent_studios\": \"Studia nadrzędne\",\n    \"parent_tag_count\": \"Liczba tagów podrzędnych\",\n    \"parent_tags\": \"Tagi nadrzędne\",\n    \"part_of\": \"Część {parent}\",\n    \"path\": \"Ścieżka\",\n    \"penis\": \"Penis\",\n    \"penis_length\": \"Długość penisa\",\n    \"penis_length_cm\": \"Długość penisa (cm)\",\n    \"perceptual_similarity\": \"Podobieństwo percepcyjne (phash)\",\n    \"performer\": \"Aktor\",\n    \"performer_age\": \"Wiek aktora\",\n    \"performer_count\": \"Liczba aktorów\",\n    \"performer_favorite\": \"Ulubiony aktor\",\n    \"performer_image\": \"Zdjęcie aktora\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Dodaj nowych aktorów\",\n        \"any_names_entered_will_be_queried\": \"Wszystkie wprowadzone nazwy zostaną wyszukane w zdalnej instancji Stash-Box i dodane, jeśli zostaną znalezione. Tylko dokładne dopasowania będą uznawane za dopasowanie.\",\n        \"batch_add_performers\": \"Grupowe dodawanie aktorów\",\n        \"batch_update_performers\": \"Grupowe aktualizowanie aktorów\",\n        \"current_page\": \"Bieżąca strona\",\n        \"failed_to_save_performer\": \"Nie udało się zapisać aktora \\\"{performer}\\\"\",\n        \"name_already_exists\": \"Nazwa już istnieje\",\n        \"network_error\": \"Błąd sieci\",\n        \"no_results_found\": \"Nie znaleziono wyników.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} wykonawców zostanie przetworzonych\",\n        \"performer_already_tagged\": \"Aktor już otagowany\",\n        \"performer_selection\": \"Wybór aktorów\",\n        \"performer_successfully_tagged\": \"Aktor pomyślnie otagowany:\",\n        \"query_all_performers_in_the_database\": \"Wszyscy aktorzy w bazie danych\",\n        \"refresh_tagged_performers\": \"Odświeżanie otagowanych aktorów\",\n        \"refreshing_will_update_the_data\": \"Odświeżenie spowoduje aktualizację danych wszystkich oznaczonych aktorów z instancji stash-box.\",\n        \"status_tagging_job_queued\": \"Status: Zadanie tagowania dodane do kolejki\",\n        \"status_tagging_performers\": \"Status: Oznaczanie aktorów\",\n        \"tag_status\": \"Status tagu\",\n        \"to_use_the_performer_tagger\": \"Aby korzystać z otagowywacza aktorów, należy skonfigurować instancję stash-box.\",\n        \"untagged_performers\": \"Nieotagowany aktor\",\n        \"update_performer\": \"Aktualizacja aktora\",\n        \"update_performers\": \"Zaktualizowano aktorów\",\n        \"updating_untagged_performers_description\": \"Aktualizowanie nieotagowanych aktorów spróbuje dopasować aktorów, którym brakuje stashid i zaktualizować metadane.\",\n        \"performer_names_or_stashids_separated_by_comma\": \"Nazwiska wykonawców lub StashID oddzielone przecinkiem\"\n    },\n    \"performer_tags\": \"Tagi aktorów\",\n    \"performers\": \"Aktorzy\",\n    \"piercings\": \"Kolczyki\",\n    \"play_count\": \"Liczba odtworzeń\",\n    \"play_duration\": \"Czas odtwarzania\",\n    \"primary_file\": \"Główny plik\",\n    \"queue\": \"Kolejka\",\n    \"random\": \"Losowo\",\n    \"rating\": \"Ocena\",\n    \"recently_added_objects\": \"Ostatnio dodane {objects}\",\n    \"recently_released_objects\": \"Ostatnio wydane {objects}\",\n    \"release_notes\": \"Informacje o wydaniu\",\n    \"resolution\": \"Rozdzielczość\",\n    \"resume_time\": \"Rozpocznij od\",\n    \"scene\": \"Scena\",\n    \"sceneTagger\": \"Otagowywacz scen\",\n    \"scene_code\": \"Kod studia\",\n    \"scene_count\": \"Liczba scen\",\n    \"scene_created_at\": \"Scena utworzona\",\n    \"scene_date\": \"Data sceny\",\n    \"scene_id\": \"ID sceny\",\n    \"scene_tags\": \"Tagi sceny\",\n    \"scene_updated_at\": \"Scena zaktualizowana\",\n    \"scenes\": \"Sceny\",\n    \"scenes_updated_at\": \"Scena aktualizowana\",\n    \"search_filter\": {\n        \"edit_filter\": \"Filtr Edycji\",\n        \"name\": \"Filtr\",\n        \"saved_filters\": \"Zapisane filtry\",\n        \"update_filter\": \"Aktualizuj filtr\",\n        \"search_term\": \"Szukana fraza\",\n        \"more_filter_criteria\": \"+{count} więcej\"\n    },\n    \"second\": \"Drugi\",\n    \"seconds\": \"Sekund\",\n    \"settings\": \"Ustawienia\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Jesteśmy prawie gotowi do zakończenia konfiguracji. Prosimy o potwierdzenie następujących ustawień. Możesz kliknąć przycisk Wstecz, aby zmienić wszystkie nieprawidłowe ustawienia. Jeśli wszystko wygląda dobrze, kliknij przycisk Zatwierdź, aby utworzyć system.\",\n            \"blobs_directory\": \"Katalog dla plików binarnych\",\n            \"cache_directory\": \"Katalog pamięci podręcznej\",\n            \"configuration_file_location\": \"Lokalizacja pliku konfiguracyjnego:\",\n            \"database_file_path\": \"Ścieżka do pliku bazy danych\",\n            \"generated_directory\": \"Folder z wygenerowaną zawartością\",\n            \"nearly_there\": \"Już prawie gotowe!\",\n            \"stash_library_directories\": \"Katalogi biblioteki Stash\",\n            \"blobs_use_database\": \"<używa bazy danych>\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Tworzenie systemu\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"O nie! Coś poszło nie tak!\",\n            \"something_went_wrong_description\": \"Wygląda to na problem z twoimi danymi wejściowymi, kliknij Wstecz, aby je poprawić. W przeciwnym razie zgłoś błąd na stronie {githubLink} lub poszukaj pomocy na {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Coś poszło nie tak podczas konfigurowania systemu. Oto błąd, który otrzymaliśmy: {error}\",\n            \"unable_to_retrieve_system_status\": \"Nie można uzyskać statusu systemu: {error}\",\n            \"unexpected_error\": \"Wystąpił nieoczekiwany błąd: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Ścieżka do pliku\",\n            \"up_dir\": \"Katalog wyżej\"\n        },\n        \"github_repository\": \"Repozytorium na Githubie\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Ścieżka do tworzenia kopii zapasowej bazy danych (pozostaw pustą, aby wyłączyć tworzenie kopii zapasowych):\",\n            \"backup_recommended\": \"Zaleca się wykonanie kopii zapasowej istniejącej bazy danych przed migracją. Możemy to zrobić dla Ciebie, wykonując kopię bazy danych do <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Migracja bazy danych\",\n            \"migration_failed\": \"Migracja nie powiodła się\",\n            \"migration_failed_error\": \"Podczas migracji bazy danych napotkano następujący błąd:\",\n            \"migration_failed_help\": \"Wprowadź wszelkie niezbędne poprawki i spróbuj ponownie. W przeciwnym razie zgłoś błąd na stronie {githubLink} lub poszukaj pomocy na {discordLink}.\",\n            \"migration_irreversible_warning\": \"Proces migracji schematu jest nieodwracalny. Po przeprowadzeniu migracji baza danych będzie niekompatybilna z poprzednimi wersjami programu stash.\",\n            \"migration_notes\": \"Uwagi dotyczące migracji\",\n            \"migration_required\": \"Wymagana migracja\",\n            \"perform_schema_migration\": \"Wykonaj migrację schematów\",\n            \"schema_too_old\": \"Twoja obecna baza danych Stash to schemat w wersji <strong>{databaseSchema}</strong> i należy ją zmigrować do wersji <strong>{appSchema}</strong>. Ta wersja Stash nie będzie działać bez migracji bazy danych. Jeśli nie chcesz migrować, musisz obniżyć wersję do wersji zgodnej ze schematem bazy danych.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"nazwa pliku bazy danych (domyślnie puste)\",\n            \"description\": \"Następnie należy określić, gdzie ma się znajdować Twoja zawartość oraz gdzie ma być przechowywana baza danych Stash, wygenerowane pliki i pliki pamięci podręcznej. Ustawienia te można zmienić później, jeśli zajdzie taka potrzeba.\",\n            \"path_to_cache_directory_empty_for_default\": \"ścieżka do katalogu cache (domyślnie pusta)\",\n            \"path_to_generated_directory_empty_for_default\": \"ścieżka do katalogu z wygenerowanymi plikami (domyślnie pusta)\",\n            \"set_up_your_paths\": \"Ustaw ścieżki\",\n            \"stash_alert\": \"Nie wybrano żadnych ścieżek biblioteki. Żaden materiał nie będzie mógł zostać zeskanowany przez aplikację Stash. Czy jesteś pewien?\",\n            \"where_can_stash_store_blobs\": \"Gdzie Stash może przechowywać dane binarne bazy danych?\",\n            \"where_can_stash_store_blobs_description\": \"Stash może przechowywać dane binarne, takie jak okładki scen, obrazy wykonawców, studia i znaczników, w bazie danych lub w systemie plików. Domyślnie przechowuje te dane w systemie plików w podkatalogu <code>blobs</code>. Jeśli chcesz to zmienić, podaj ścieżkę bezwzględną lub względną (do bieżącego katalogu roboczego). Stash utworzy ten katalog, jeśli jeszcze nie istnieje.\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Alternatywnie, jeśli chcesz przechowywać te dane w bazie danych, możesz pozostawić to pole puste. <strong>Uwaga:</strong> Zwiększy to rozmiar pliku bazy danych i wydłuży czas migracji bazy danych.\",\n            \"where_can_stash_store_cache_files\": \"Gdzie Stash może przechowywać pliki pamięci podręcznej?\",\n            \"where_can_stash_store_cache_files_description\": \"Aby niektóre funkcjonalności takie jak transkodowanie na żywo HLS/DASH mogły działać, Stash wymaga katalogu cache dla plików tymczasowych. Domyślnie, Stash utworzy katalog <code>cache</code> w katalogu zawierającym twój plik konfiguracyjny. Jeśli chcesz to zmienić, podaj ścieżkę bezwzględną lub względną (do bieżącego katalogu roboczego). Stash utworzy ten katalog, jeśli jeszcze nie istnieje.\",\n            \"where_can_stash_store_its_database\": \"Gdzie Stash może przechowywać swoją bazę danych?\",\n            \"where_can_stash_store_its_database_description\": \"Stash używa bazy danych SQLite do przechowywania metadanych zawartości. Domyślnie zostanie ona utworzona jako <code>stash-go.sqlite</code> w katalogu zawierającym Twój plik konfiguracyjny. Jeśli chcesz to zmienić, podaj bezwzględną lub względną (względem bieżącego katalogu roboczego) nazwę pliku.\",\n            \"where_can_stash_store_its_database_warning\": \"UWAGA: przechowywanie bazy danych w innym systemie niż ten, z którego uruchamiany jest Stash (np. przechowywanie bazy danych na dysku NAS przy jednoczesnym uruchomieniu serwera Stash na innym komputerze) jest <strong>nieobsługiwane</strong>! SQLite nie jest przeznaczony do pracy w sieci, a próba takiego działania może bardzo łatwo spowodować uszkodzenie całej bazy danych.\",\n            \"where_can_stash_store_its_generated_content\": \"Gdzie Stash może przechowywać wygenerowaną zawartość?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Aby zapewnić miniatury, podglądy i sprite'y, Stash generuje obrazy i wideo. Obejmuje to również transkodowanie dla nieobsługiwanych formatów plików. Domyślnie Stash tworzy katalog <code>generated</code> w katalogu zawierającym plik konfiguracyjny. Jeśli chcesz zmienić miejsce przechowywania wygenerowanych multimediów, podaj ścieżkę bezwzględną lub względną (do bieżącego katalogu roboczego). Stash utworzy ten katalog, jeśli jeszcze nie istnieje.\",\n            \"where_is_your_porn_located\": \"Gdzie znajduje się Twoja zawartość?\",\n            \"where_is_your_porn_located_description\": \"Dodaj katalogi zawierające filmy i obrazy. Stash będzie używał tych katalogów do wyszukiwania filmów i obrazów podczas skanowania..\",\n            \"path_to_blobs_directory_empty_for_default\": \"ścieżka do katalogu blobs (domyślnie pusta)\",\n            \"sfw_content_settings\": \"Korzystasz ze stash do treści SFW?\",\n            \"sfw_content_settings_description\": \"Stash może być używany do zarządzania treściami SFW, takimi jak fotografia, sztuka, komiksy i inne. Włączenie tej opcji dostosuje niektóre elementy interfejsu, aby były bardziej odpowiednie dla treści SFW.\",\n            \"store_blobs_in_database\": \"Przechowuj bloby w bazie danych\",\n            \"use_sfw_content_mode\": \"Użyj trybu treści SFW\"\n        },\n        \"stash_setup_wizard\": \"Kreator konfiguracji Stash\",\n        \"success\": {\n            \"getting_help\": \"Uzyskiwanie pomocy\",\n            \"help_links\": \"Jeśli napotkasz problemy, masz pytania lub sugestie, możesz otworzyć problem na stronie {githubLink} lub zapytać społeczność na {discordLink}.\",\n            \"in_app_manual_explained\": \"Zachęcamy do zapoznania się z podręcznikiem w aplikacji, do którego można przejść, korzystając z ikony w prawym górnym rogu ekranu, która wygląda następująco: {icon}\",\n            \"next_config_step_one\": \"Następnie zostaniesz przeniesiony na stronę Konfiguracja. Na tej stronie można określić, jakie pliki mają być uwzględniane i wykluczane, ustawić nazwę użytkownika i hasło w celu ochrony systemu oraz wiele innych opcji.\",\n            \"next_config_step_two\": \"Jeśli jesteś zadowolony z tych ustawień, możesz rozpocząć skanowanie zawartości do Stasha, klikając <code>{localized_task}</code>, a następnie <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Sprawdź stronę {open_collective_link}, aby dowiedzieć się, w jaki sposób możesz przyczynić się do dalszego rozwoju Stasha.\",\n            \"support_us\": \"Wesprzyj nas\",\n            \"thanks_for_trying_stash\": \"Dzięki za wypróbowanie Stasha!\",\n            \"welcome_contrib\": \"Mile widziany jest również wkład w postaci kodu (poprawki błędów, ulepszenia i nowe funkcje), testowania, raportów o błędach, próśb o ulepszenia i funkcje oraz wsparcia użytkowników. Szczegóły można znaleźć w sekcji Wkład w instrukcję obsługi aplikacji.\",\n            \"your_system_has_been_created\": \"Sukces! Twój system został utworzony!\",\n            \"download_ffmpeg\": \"Pobierz ffmpeg\",\n            \"missing_ffmpeg\": \"Brakuje wymaganego pliku wykonywalnego<code>ffmpeg</code>. Możesz go automatycznie pobrać do katalogu konfiguracji, zaznaczając poniższe pole. Alternatywnie, możesz podać ścieżki do plików binarnych <code>ffmpeg</code> i <code>ffprobe</code> w Ustawieniach Systemu. Te pliki binarne muszą być obecne, aby Stash działał prawidłowo.\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash próbuje najpierw znaleźć swój plik konfiguracyjny (<code>config.yml</code>) w bieżącym katalogu roboczym, a jeśli go tam nie znajdzie, wraca do <code>$HOME/.stash/config.yml</code> (w systemie Windows będzie to <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>). Można również sprawić, że Stash będzie odczytywał dane z określonego pliku konfiguracyjnego, uruchamiając go z opcją opcji <code>-c '<ścieżka do pliku konfiguracyjnego>'</code> lub <code>--config '<ścieżka do pliku konfiguracyjnego>'</code>.\",\n            \"in_current_stash_directory\": \"W katalogu <code>$HOME/.stash</code>:\",\n            \"in_the_current_working_directory\": \"W katalogu roboczym <code>{path}</code>, obecnie:\",\n            \"next_step\": \"Jeśli jesteś gotowy do skonfigurowania nowego systemu, wybierz miejsce, w którym chcesz przechowywać plik konfiguracyjny i kliknij przycisk Dalej.\",\n            \"store_stash_config\": \"Gdzie ma być przechowywana konfiguracja Stasha?\",\n            \"unable_to_locate_config\": \"Jeśli to czytasz, to znaczy, że Stash nie mógł znaleźć istniejącej konfiguracji. Kreator poprowadzi Cię przez proces tworzenia nowej konfiguracji.\",\n            \"unexpected_explained\": \"Jeśli ten ekran pojawia się niespodziewanie, spróbuj ponownie uruchomić Stash we właściwym katalogu roboczym lub z flagą <code>-c</code>.\",\n            \"in_the_current_working_directory_disabled\": \"W katalogu roboczym <code>{path}</code>:\",\n            \"in_the_current_working_directory_disabled_macos\": \"Nieobsługiwane podczas uruchamiania <code>Stash.app</code>.<br></br>Uruchom <code>stash-macos</code>, aby skonfigurować katalog roboczy\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash będzie używał następującej ścieżki do pliku konfiguracyjnego: <code>{path}</code>\",\n            \"next_step\": \"Jeśli jesteś gotowy do skonfigurowania nowego systemu, kliknij przycisk Dalej.\",\n            \"unable_to_locate_specified_config\": \"Jeśli to czytasz, to znaczy, że Stash nie mógł znaleźć pliku konfiguracyjnego określonego w wierszu poleceń lub w środowisku. Kreator poprowadzi Cię przez proces tworzenia nowej konfiguracji.\"\n        },\n        \"welcome_to_stash\": \"Witamy w Stashu\"\n    },\n    \"stash_id\": \"ID Stasha\",\n    \"stash_id_endpoint\": \"Punkt końcowy Stash ID\",\n    \"stash_ids\": \"ID Stasha\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Przejdź do {endpoint_name}, aby zapoznać się z wersją roboczą.\",\n        \"selected_stash_box\": \"Wybrany punkt końcowy Stash-Box\",\n        \"submission_failed\": \"Zgłoszenie nie powiodło się\",\n        \"submission_successful\": \"Zgłoszenie przesłano sukcesem\",\n        \"submit_update\": \"Już istnieje w {endpoint_name}\",\n        \"source\": \"Źródło Stash-Box\"\n    },\n    \"statistics\": \"Statystyki\",\n    \"stats\": {\n        \"image_size\": \"Rozmiar obrazów\",\n        \"scenes_duration\": \"Czas trwania scen\",\n        \"scenes_size\": \"Rozmiar scen\",\n        \"scenes_played\": \"Odtworzone sceny\",\n        \"total_o_count\": \"Łączna ilość O\",\n        \"total_o_count_sfw\": \"Łączna ilość polubień\",\n        \"total_play_count\": \"Łączna liczba odtworzeń\",\n        \"total_play_duration\": \"Łączny czas odtwarzania\"\n    },\n    \"status\": \"Status: {statusText}\",\n    \"studio\": \"Studio\",\n    \"studio_depth\": \"Poziomy (puste dla wszystkich)\",\n    \"studios\": \"Studia\",\n    \"sub_tag_count\": \"Liczba podtagów\",\n    \"sub_tag_of\": \"Podtag {parent}\",\n    \"sub_tags\": \"Podtagi\",\n    \"subsidiary_studios\": \"Studia zależne\",\n    \"synopsis\": \"Streszczenie\",\n    \"tag\": \"Tag\",\n    \"tag_count\": \"Liczba tagów\",\n    \"tags\": \"Tagi\",\n    \"tattoos\": \"Tatuaże\",\n    \"title\": \"Tytuł\",\n    \"toast\": {\n        \"added_entity\": \"Dodano {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Dodano zadanie generowania do kolejki\",\n        \"created_entity\": \"Utworzono {entity}\",\n        \"default_filter_set\": \"Domyślny zestaw filtrów\",\n        \"delete_past_tense\": \"Usuń {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Generowanie zrzutu ekranu…\",\n        \"image_index_too_large\": \"Błąd: Indeks obrazu jest większy niż ilość obrazów w Galerii\",\n        \"merged_scenes\": \"Połączone sceny\",\n        \"merged_tags\": \"Połączone tagi\",\n        \"reassign_past_tense\": \"Plik przypisany ponownie\",\n        \"removed_entity\": \"Usunięto {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"rescanning_entity\": \"Ponowne skanowanie {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"Zapisano {entity}\",\n        \"started_auto_tagging\": \"Uruchomiono automatyczne tagowanie\",\n        \"started_generating\": \"Rozpoczęto generowanie\",\n        \"started_importing\": \"Rozpoczęto importowanie\",\n        \"updated_entity\": \"Zaktualizowano {entity}\"\n    },\n    \"total\": \"Łącznie\",\n    \"true\": \"Tak\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Typ\",\n    \"updated_at\": \"Zaktualizowano\",\n    \"url\": \"URL\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} musi być w formacie RRRR, RRRR-MM lub RRRR-MM-DD\",\n        \"required\": \"${path} jest wymagana\",\n        \"unique\": \"${path} musi być unikalny\",\n        \"blank\": \"${path} nie może być puste\",\n        \"end_time_before_start_time\": \"Czas zakończenia musi być większy lub równy czasowi rozpoczęcia\"\n    },\n    \"videos\": \"Filmy wideo\",\n    \"view_all\": \"Pokaż wszystko\",\n    \"weight\": \"Waga\",\n    \"weight_kg\": \"Waga (kg)\",\n    \"years_old\": \"lat(a)\",\n    \"zip_file_count\": \"Liczba plików zip\",\n    \"audio_codec\": \"Kodek audio\",\n    \"sub_group\": \"Podgrupa\",\n    \"sub_group_count\": \"Ilość Podgrup\",\n    \"sub_group_of\": \"Podgrupa {parent}\",\n    \"sub_group_order\": \"Kolejność podgrup\",\n    \"sub_groups\": \"Podgrupy\",\n    \"unknown_date\": \"Nieznana data\",\n    \"video_codec\": \"Kodek Wideo\",\n    \"distance\": \"Dystans\",\n    \"eta\": \"ETA\",\n    \"group_count\": \"Ilość grup\",\n    \"group_scene_number\": \"Numer sceny\",\n    \"groups\": \"Grupy\",\n    \"group\": \"Grupa\",\n    \"history\": \"Historia\",\n    \"include_sub_group_content\": \"Uwzględnij zawartość podgrupy\",\n    \"include_sub_groups\": \"Uwzględnij podgrupy\",\n    \"index_of_total\": \"{index} z {total}\",\n    \"last_o_at\": \"Ostatnie O\",\n    \"login\": {\n        \"login\": \"Logowanie\",\n        \"username\": \"Użytkownik\",\n        \"password\": \"Hasło\",\n        \"invalid_credentials\": \"Nieprawidłowa nazwa użytkownika lub hasło\",\n        \"internal_error\": \"Nieoczekiwany błąd wewnętrzny. Szczegóły znajdziesz w logach\"\n    },\n    \"o_count\": \"Licznik O\",\n    \"o_count_sfw\": \"Polubienia\",\n    \"o_history\": \"Historia O\",\n    \"o_history_sfw\": \"Historia polubień\",\n    \"odate_recorded_no\": \"Nie zarejestrowano O\",\n    \"orientation\": \"Orientacja\",\n    \"package_manager\": {\n        \"add_source\": \"Dodaj źródło\",\n        \"check_for_updates\": \"Sprawdź dostępność aktualizacji\",\n        \"confirm_delete_source\": \"Czy na pewno chcesz usunąć źródło {name} ({url})?\",\n        \"confirm_uninstall\": \"Czy na pewno chcesz odinstalować {number} pakietów?\",\n        \"description\": \"Opis\",\n        \"edit_source\": \"Edytuj źródło\",\n        \"hide_unselected\": \"Ukryj niezaznaczone\",\n        \"install\": \"Instaluj\",\n        \"installed_version\": \"Zainstalowana wersja\",\n        \"latest_version\": \"Najnowsza wersja\",\n        \"no_packages\": \"Nie znaleziono pakietów\",\n        \"no_sources\": \"Nie skonfigurowano żadnych źródeł\",\n        \"no_upgradable\": \"Brak pakietów do aktualizacji\",\n        \"package\": \"Pakiet\",\n        \"required_by\": \"Wymagane przez {packages}\",\n        \"selected_only\": \"Tylko zaznaczone\",\n        \"show_all\": \"Pokaż wszystko\",\n        \"source\": {\n            \"local_path\": {\n                \"description\": \"Ścieżka względna do przechowywania pakietów dla tego źródła. Uwaga: zmiana tej ścieżki wymaga ręcznego przeniesienia pakietów.\",\n                \"heading\": \"Ścieżka lokalna\"\n            },\n            \"name\": \"Nazwa\",\n            \"url\": \"URL źródła\"\n        },\n        \"uninstall\": \"Odinstaluj\",\n        \"unknown\": \"<nieznany>\",\n        \"update\": \"Aktualizuj\",\n        \"version\": \"Wersja\"\n    },\n    \"parent_studio\": \"Studio nadrzędne\",\n    \"photographer\": \"Fotograf\",\n    \"play_history\": \"Historia odtwarzania\",\n    \"playdate_recorded_no\": \"Nie zarejestrowano odtworzeń\",\n    \"plays\": \"Odtworzenia: {value}\",\n    \"primary_tag\": \"Tag nadrzędny\",\n    \"scenes_duration\": \"Czas trwania sceny\",\n    \"stashbox_search\": {\n        \"no_results\": \"Nie znaleziono wyników.\",\n        \"select_stashbox\": \"Wybierz StashBox...\"\n    },\n    \"studio_tagger\": {\n        \"add_new_studios\": \"Dodaj nowe studia\",\n        \"config\": {\n            \"create_parent_desc\": \"Utwórz brakujące studia nadrzędne lub otaguj i zaktualizuj dane/obraz dla istniejących studiów nadrzędnych o dokładnie takich samych nazwach\",\n            \"create_parent_label\": \"Utwórz studia nadrzędne\"\n        },\n        \"any_names_entered_will_be_queried\": \"Wszystkie wprowadzone nazwy zostaną wyszukane w zdalnej instancji Stash-Box i dodane, jeśli zostaną znalezione. Tylko dokładne dopasowania będą uznawane za dopasowanie.\",\n        \"batch_add_studios\": \"Dodaj studia zbiorowo\",\n        \"batch_update_studios\": \"Aktualizuj studia zbiorowo\",\n        \"create_or_tag_parent_studios\": \"Utwórz brakujące studia nadrzędne lub otaguj istniejące\",\n        \"current_page\": \"Aktualna strona\",\n        \"failed_to_save_studio\": \"Nie udało się zapisać studia \\\"{studio}\\\"\",\n        \"name_already_exists\": \"Nazwa już istnieje\",\n        \"network_error\": \"Błąd sieci\",\n        \"no_results_found\": \"Nie znaleziono wyników.\",\n        \"query_all_studios_in_the_database\": \"Wszystkie studia w bazie danych\",\n        \"number_of_studios_will_be_processed\": \"{studio_count} studiów zostanie przetworzonych\"\n    },\n    \"studio_and_parent\": \"Studio i Studio nadrzędne\",\n    \"studio_count\": \"Liczba studiów\",\n    \"studio_tags\": \"Tagi studia\",\n    \"tag_parent_tooltip\": \"Ma tagi nadrzędne\",\n    \"tag_sub_tag_tooltip\": \"Ma podtagi\",\n    \"time\": \"Czas\",\n    \"time_end\": \"Czas zakończenia\",\n    \"urls\": \"Adresy URL\",\n    \"age_on_date\": \"{age} w momencie produkcji\",\n    \"criterion_modifier_values\": {\n        \"any\": \"Którykolwiek\",\n        \"any_of\": \"Którykolwiek z\",\n        \"only\": \"Wyłącznie\"\n    },\n    \"custom_fields\": {\n        \"field\": \"Pole\",\n        \"title\": \"Pola niestandardowe\",\n        \"value\": \"Wartość\"\n    },\n    \"include_sub_studio_content\": \"Uwzględnij zawartość podstudia\",\n    \"include_sub_tag_content\": \"Uwzględnij zawartość podtaga\",\n    \"odate_recorded_no_sfw\": \"Nie zarejestrowano polubień\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/pt-BR.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Adicionar\",\n        \"add_directory\": \"Adicionar diretório\",\n        \"add_entity\": \"Adicionar {entityType}\",\n        \"add_to_entity\": \"Adicionar em {entityType}\",\n        \"allow\": \"Permitir\",\n        \"allow_temporarily\": \"Permitir temporariamente\",\n        \"anonymise\": \"Anonimizar\",\n        \"apply\": \"Aplicar\",\n        \"assign_stashid_to_parent_studio\": \"Atribua o Stash ID ao estúdio principal existente e atualize os metadados\",\n        \"auto_tag\": \"Etiquetamento automático\",\n        \"backup\": \"Backup\",\n        \"browse_for_image\": \"Procurar por imagem…\",\n        \"cancel\": \"Cancelar\",\n        \"clean\": \"Limpar\",\n        \"clear\": \"Limpar\",\n        \"clear_back_image\": \"Limpar imagem de fundo\",\n        \"clear_front_image\": \"Limpar imagem frontal\",\n        \"clear_image\": \"Limpar imagem\",\n        \"close\": \"Fechar\",\n        \"confirm\": \"Confirmar\",\n        \"continue\": \"Continuar\",\n        \"create\": \"Criar\",\n        \"create_chapters\": \"Criar Capítulo\",\n        \"create_entity\": \"Criar {entityType}\",\n        \"create_marker\": \"Criar marcador\",\n        \"create_parent_studio\": \"Criar estúdio principal\",\n        \"created_entity\": \"Criar {entity_type}: {entity_name}\",\n        \"customise\": \"Customizar\",\n        \"delete\": \"Apagar\",\n        \"delete_entity\": \"Apagar {entityType}\",\n        \"delete_file\": \"Apagar arquivo\",\n        \"delete_file_and_funscript\": \"Deletar arquivo (e funscript)\",\n        \"delete_generated_supporting_files\": \"Apagar arquivos gerados de suporte\",\n        \"disallow\": \"Não permitir\",\n        \"download\": \"Download\",\n        \"download_anonymised\": \"Download Anonimizado\",\n        \"download_backup\": \"Download backup\",\n        \"edit\": \"Editar\",\n        \"edit_entity\": \"Editar {entityType}\",\n        \"export\": \"Exportar\",\n        \"export_all\": \"Exportar tudo…\",\n        \"find\": \"Encontrar\",\n        \"finish\": \"Finalizar\",\n        \"from_file\": \"Do arquivo…\",\n        \"from_url\": \"Da URL…\",\n        \"full_export\": \"Exportação completa\",\n        \"full_import\": \"Importação completa\",\n        \"generate\": \"Gerar\",\n        \"generate_thumb_default\": \"Gerar thumbnail padrão\",\n        \"generate_thumb_from_current\": \"Gerar thumbnail do atual\",\n        \"hash_migration\": \"migrar hash\",\n        \"hide\": \"Esconder\",\n        \"hide_configuration\": \"Esconder Configuração\",\n        \"identify\": \"Identificar\",\n        \"ignore\": \"Ignorar\",\n        \"import\": \"Importar…\",\n        \"import_from_file\": \"Importar de arquivo\",\n        \"logout\": \"Sair\",\n        \"make_primary\": \"Tornar Primário\",\n        \"merge\": \"Unir\",\n        \"migrate_blobs\": \"Migrar Blobs\",\n        \"migrate_scene_screenshots\": \"Migrar Print da Cena\",\n        \"next_action\": \"Próximo\",\n        \"not_running\": \"não realizado\",\n        \"open_in_external_player\": \"Abrir em um reprodutor externo\",\n        \"open_random\": \"Abrir aleatório\",\n        \"optimise_database\": \"Otimizar banco de dados\",\n        \"overwrite\": \"Sobrescrever\",\n        \"play_random\": \"Tocar aleatório\",\n        \"play_selected\": \"Tocar selecionado\",\n        \"preview\": \"Previsualizar\",\n        \"previous_action\": \"Voltar\",\n        \"reassign\": \"Reatribuir\",\n        \"refresh\": \"Atualizar\",\n        \"reload_plugins\": \"Recarregar plugins\",\n        \"reload_scrapers\": \"Recarregar scrapers\",\n        \"remove\": \"Remover\",\n        \"remove_from_gallery\": \"Remover da galeria\",\n        \"rename_gen_files\": \"Renomear arquivos gerados\",\n        \"rescan\": \"Reescanear\",\n        \"reshuffle\": \"Reembaralhar\",\n        \"running\": \"rodando\",\n        \"save\": \"Salvar\",\n        \"save_delete_settings\": \"Usar essas opções por padrão quando deletar\",\n        \"save_filter\": \"Salvar filtro\",\n        \"scan\": \"Escanear\",\n        \"scrape\": \"Buscar\",\n        \"scrape_query\": \"Query de busca\",\n        \"scrape_scene_fragment\": \"Buscar por fragmento\",\n        \"scrape_with\": \"Scrape com…\",\n        \"search\": \"Buscar\",\n        \"select_all\": \"Selecionar todos\",\n        \"select_entity\": \"Selecionar {entityType}\",\n        \"select_folders\": \"Selecionar pastas\",\n        \"select_none\": \"Selecionar nenhum\",\n        \"selective_auto_tag\": \"Etiquetamento automático seletivo\",\n        \"selective_clean\": \"Limpeza Seletiva\",\n        \"selective_scan\": \"Escaneamento seletivo\",\n        \"set_as_default\": \"Aplicar como padrão\",\n        \"set_back_image\": \"Imagem de fundo…\",\n        \"set_front_image\": \"Imagem frontal…\",\n        \"set_image\": \"Aplicar imagem…\",\n        \"show\": \"Mostrar\",\n        \"show_configuration\": \"Exibir Configuração\",\n        \"skip\": \"Pular\",\n        \"split\": \"Dividir\",\n        \"stop\": \"Parar\",\n        \"submit\": \"Enviar\",\n        \"submit_stash_box\": \"Enviar para o Stash-Box\",\n        \"submit_update\": \"Enviar atualização\",\n        \"swap\": \"Trocar\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Tem certeza de que quer limpar? Isto irá apagar as informações do banco de dados e conteúdos gerados de todas as cenas e galerias que não são mais encontradas no sistema.\",\n            \"dry_mode_selected\": \"Modo não destrutivo. Nenhum arquivo será apagado, apenas registrado.\",\n            \"import_warning\": \"Tem certeza de que quer importar? Isto irá apagar o banco de dados e re-importar de seus metadados exportados.\"\n        },\n        \"temp_disable\": \"Desabilitar temporariamente…\",\n        \"temp_enable\": \"Habilitar temporariamente…\",\n        \"unset\": \"Desaplicar\",\n        \"use_default\": \"Usar padrão\",\n        \"view_random\": \"Mostrar aleatoriamente\",\n        \"remove_date\": \"Remover data\",\n        \"view_history\": \"Visualizar Histórico\",\n        \"add_manual_date\": \"Inserir data manualmente\",\n        \"add_sub_groups\": \"Adicionar sub-grupos\",\n        \"add_o\": \"Adicionar O\",\n        \"add_play\": \"Adicionar play\",\n        \"add_stash_id\": \"Adicionar Stash ID\",\n        \"choose_date\": \"Escolher uma data\",\n        \"clean_generated\": \"Limpar os arquivos gerados\",\n        \"clear_date_data\": \"Limpar dados de datas\",\n        \"create_new\": \"Criar novo\",\n        \"disable\": \"Desabilitar\",\n        \"enable\": \"Habilitar\",\n        \"load\": \"Carregar\",\n        \"load_filter\": \"Carregar filtro\",\n        \"play\": \"Assistir\",\n        \"remove_from_containing_group\": \"Remover do Grupo\",\n        \"reset_play_duration\": \"Redefinir duração\",\n        \"reset_resume_time\": \"Redefinir tempo de retomada\",\n        \"reset_cover\": \"Restaurar a capa padrão\",\n        \"copy_to_clipboard\": \"Copiar para a área de transferência\",\n        \"encoding_image\": \"Codificando imagem…\",\n        \"reload\": \"Recarregar\",\n        \"set_cover\": \"Definir como Capa\",\n        \"show_results\": \"Mostrar resultados\",\n        \"show_count_results\": \"Mostrar {count} resultados\",\n        \"sidebar\": {\n            \"close\": \"Fechar barra lateral\",\n            \"open\": \"Abrir barra lateral\",\n            \"toggle\": \"Alternar barra lateral\"\n        },\n        \"invert_selection\": \"Inverter Seleção\"\n    },\n    \"actions_name\": \"Ações\",\n    \"age\": \"Idade\",\n    \"aliases\": \"Apelidos\",\n    \"all\": \"todos\",\n    \"also_known_as\": \"Também conhecido(a) como\",\n    \"appears_with\": \"Aparece com\",\n    \"ascending\": \"Ascendente\",\n    \"audio_codec\": \"Codec de áudio\",\n    \"average_resolution\": \"Resolução média\",\n    \"between_and\": \"e\",\n    \"birth_year\": \"Ano de nascimento\",\n    \"birthdate\": \"Data de nascimento\",\n    \"bitrate\": \"Taxa de bits\",\n    \"blobs_storage_type\": {\n        \"database\": \"Banco de Dados\",\n        \"filesystem\": \"Arquivo de Sistema\"\n    },\n    \"captions\": \"Legendas\",\n    \"career_length\": \"Duração da carreira\",\n    \"chapters\": \"Capítulos\",\n    \"circumcised\": \"Circuncidado\",\n    \"circumcised_types\": {\n        \"CUT\": \"Cortado\",\n        \"UNCUT\": \"Não cortado\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Instância do stash-box ativa:\",\n            \"blacklist_desc\": \"Os itens da lista negra são excluídos das consultas. Observe que são expressões regulares e também não fazem distinção entre maiúsculas e minúsculas. Certos caracteres devem ser escritos com uma barra invertida: {chars_require_escape}\",\n            \"blacklist_label\": \"Lista negra\",\n            \"mark_organized_desc\": \"Marque imediatamente a cena como Organizada depois que o botão Salvar for clicado.\",\n            \"mark_organized_label\": \"Marcar como Organizado ao salvar\",\n            \"query_mode_auto\": \"Automático\",\n            \"query_mode_auto_desc\": \"Usa metadados se existentes, ou nome do arquivo\",\n            \"query_mode_dir\": \"Diretório\",\n            \"query_mode_dir_desc\": \"Usa apenas o diretório pai do arquivo de vídeo\",\n            \"query_mode_filename\": \"Nome do arquivo\",\n            \"query_mode_filename_desc\": \"Usa apenas o nome do arquivo\",\n            \"query_mode_label\": \"Modo de consulta\",\n            \"query_mode_metadata\": \"Metadados\",\n            \"query_mode_metadata_desc\": \"Usa apenas metadados\",\n            \"query_mode_path\": \"Caminho\",\n            \"query_mode_path_desc\": \"Usa o caminho inteiro do arquivo\",\n            \"set_cover_desc\": \"Substitua a capa da cena se alguma for encontrada.\",\n            \"set_cover_label\": \"Definir imagem da capa da cena\",\n            \"set_tag_desc\": \"Anexar etiquetas à cena, sobrescrevendo ou mesclando com as etiquetas existentes na cena.\",\n            \"set_tag_label\": \"Definir etiquetas\",\n            \"source\": \"Fonte\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Lista negra duplicada\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Gêneros de artistas\"\n            }\n        },\n        \"noun_query\": \"Query\",\n        \"results\": {\n            \"duration_off\": \"Duração fora por pelo menos {number}s\",\n            \"duration_unknown\": \"Duração desconhecida\",\n            \"fp_found\": \"{fpCount, plural, =0 {Nenhuma nova correspondência de impressão digital encontrada} other{# novas correspondências de impressão digital encontradas}}\",\n            \"fp_matches\": \"Duração é uma correspondência\",\n            \"fp_matches_multi\": \"Duração corresponde {matchCount}/{durationsLength} impressão digital(s)\",\n            \"hash_matches\": \"{hash_type} é uma correspondência\",\n            \"match_failed_already_tagged\": \"Cena já etiquetada\",\n            \"match_failed_no_result\": \"Nenhum resultado encontrado\",\n            \"match_success\": \"Cena etiquetada com sucesso\",\n            \"phash_matches\": \"{count} PHashes coincide(m)\",\n            \"unnamed\": \"Sem nome\"\n        },\n        \"verb_match_fp\": \"Combine as impressões digitais\",\n        \"verb_matched\": \"Combinado\",\n        \"verb_scrape_all\": \"Buscar tudo\",\n        \"verb_submit_fp\": \"Enviar {fpCount, plural, one{# impressão digital} other{# impressões digitais}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} cenas incomparáveis\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Hash do executável:\",\n            \"build_time\": \"Horário de criação do executável:\",\n            \"check_for_new_version\": \"Verificar se há uma nova versão\",\n            \"latest_version\": \"Última versão\",\n            \"latest_version_build_hash\": \"Hash do executável da última versão:\",\n            \"new_version_notice\": \"[NOVA]\",\n            \"release_date\": \"Data de Lançamento:\",\n            \"stash_discord\": \"Junte-se ao nosso servidor no {url}\",\n            \"stash_home\": \"Stash home no {url}\",\n            \"stash_open_collective\": \"Apoie-nos através de {url}\",\n            \"stash_wiki\": \"Página da Stash {url}\",\n            \"version\": \"Versão\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Caminhos da Aplicação\"\n        },\n        \"categories\": {\n            \"about\": \"Sobre\",\n            \"changelog\": \"Registro de alterações\",\n            \"interface\": \"Interface\",\n            \"logs\": \"Registros\",\n            \"metadata_providers\": \"Provedores de Meta-dados\",\n            \"plugins\": \"Plugins\",\n            \"scraping\": \"Extração\",\n            \"security\": \"Segurança\",\n            \"services\": \"Serviços\",\n            \"system\": \"Sistema\",\n            \"tasks\": \"Tarefas\",\n            \"tools\": \"Ferramentas\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Permitir {tempIP}\",\n            \"allowed_ip_addresses\": \"Endereços de IP permitidos\",\n            \"allowed_ip_temporarily\": \"IP permitido temporariamente\",\n            \"default_ip_whitelist\": \"Lista branca de IP padrão\",\n            \"default_ip_whitelist_desc\": \"Endereços IP padrão permitidos a acessar DLNA. Use {wildcard} para permitir todos endereços de IP.\",\n            \"disabled_dlna_temporarily\": \"DLNA desativado temporariamente\",\n            \"disallowed_ip\": \"IP não permitido\",\n            \"enabled_by_default\": \"Ativado por padrão\",\n            \"enabled_dlna_temporarily\": \"DLNA ativado temporariamente\",\n            \"network_interfaces\": \"Interfaces\",\n            \"network_interfaces_desc\": \"Interfaces para expor servidor DLNA ativo. Uma lista vazia resulta em execução em todas as interfaces. Requer DLNA ser reiniciado depois de alterar.\",\n            \"recent_ip_addresses\": \"Endereços de IP recentes\",\n            \"server_display_name\": \"Nome de exibição do servidor\",\n            \"server_display_name_desc\": \"Nome de exibição do servidor DLNA. Padrão de {server_name} se vazio.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Comportamento temporário cancelado com sucesso\",\n            \"until_restart\": \"até reiniciar\",\n            \"video_sort_order\": \"Ordem de classificação de vídeo padrão\",\n            \"video_sort_order_desc\": \"Ordem para classificar os vídeos por padrão.\",\n            \"server_port\": \"Porta do Servidor\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"Chave de API\",\n                \"api_key_desc\": \"Chave de API para sistemas externos. Exigido apenas quando username/senha está configurado. Username deve ser salvo antes de gerar uma chave de API.\",\n                \"authentication\": \"Autenticação\",\n                \"clear_api_key\": \"Limpar Chave de API\",\n                \"credentials\": {\n                    \"description\": \"Credenciais para restringir o acesso ao Stash.\",\n                    \"heading\": \"Credenciais\"\n                },\n                \"generate_api_key\": \"Gerar Chave de API\",\n                \"log_file\": \"Arquivo de registro\",\n                \"log_file_desc\": \"Caminho para o arquivo para mandar os registros. Deixe em branco para desativar o arquivo de registro. Requer reinicialização.\",\n                \"log_http\": \"Registrar acesso http\",\n                \"log_http_desc\": \"Registrar acesso http para o terminal. Requer reinicialização.\",\n                \"log_to_terminal\": \"Registrar para o terminal\",\n                \"log_to_terminal_desc\": \"Registrar para o terminal além do arquivo. Sempre ativo se o arquivo de registro estiver desativado. Requer reinicialização.\",\n                \"maximum_session_age\": \"Tempo máximo da sessão\",\n                \"maximum_session_age_desc\": \"Tempo ocioso máximo antes de uma sessão de login expirar, em segundos.\",\n                \"password\": \"Senha\",\n                \"password_desc\": \"Senha para acesso Stash. Deixe em branco para desativar a autenticação do usuário\",\n                \"stash-box_integration\": \"Integração com Stash-box\",\n                \"username\": \"Usuário\",\n                \"username_desc\": \"Username para acessar o Stash. Deixe em branco para desativar a autenticação do usuário\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Ditetório para aquivos de backup do banco de dados SQLite\",\n                \"heading\": \"Diretório de Backup\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Onde no sistema de arquivos para armazenar dados binários. Aplicável somente ao usar o tipo de armazenamento blob do sistema de arquivos. AVISO: para alterar isso, é necessário mover manualmente os dados existentes.\",\n                \"heading\": \"Caminho do sistema de arquivos de dados binários\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Onde armazenar dados binários, como capas de cenas, artista, estúdio e imagens de tags. Após alterar esse valor, os dados existentes devem ser migrados usando as tarefas Migração de blobs. Consulte a página Tarefas para migração.\",\n                \"heading\": \"Tipo de armazenamento de dados binários\"\n            },\n            \"cache_location\": \"Localização do diretório do cache\",\n            \"cache_path_head\": \"Caminho do cache\",\n            \"calculate_md5_and_ohash_desc\": \"Calcular MD5 checksum além do oshash. A ativação fará com que as escaneamentos iniciais sejam mais lentos. Nomeação de arquivo Hash deve ser definido para oshash para desabilitar o cálculo MD5.\",\n            \"calculate_md5_and_ohash_label\": \"Calcular MD5 para vídeos\",\n            \"check_for_insecure_certificates\": \"Verifique se há certificados inseguros\",\n            \"check_for_insecure_certificates_desc\": \"Alguns sites usam ssl certificados inseguros. Quando desmarcado o scraper pula a verificação de certificados inseguros e permite o scraping desses sites. Se você receber um erro de certificado quando scraping desmarque isto.\",\n            \"chrome_cdp_path\": \"Caminho do Chrome CDP\",\n            \"chrome_cdp_path_desc\": \"Caminho do arquivo para o executavel do Chrome, ou um endereço remoto (começando com http:// ou https://, por exemplo http://localhost:9222/json/version) para uma instância do Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Se marcado, cria galerias de pastas contendo imagens.\",\n            \"create_galleries_from_folders_label\": \"Crie galerias de pastas contendo imagens\",\n            \"database\": \"Banco de Dados\",\n            \"db_path_head\": \"Caminho do banco de dados\",\n            \"directory_locations_to_your_content\": \"Locais de diretório para o seu conteúdo\",\n            \"excluded_image_gallery_patterns_desc\": \"Regexps de imagem e galeria de arquivos/caminhos para excluir do escaneamento e adicionar para limpar\",\n            \"excluded_image_gallery_patterns_head\": \"Padrões de imagem/galeria excluidos\",\n            \"excluded_video_patterns_desc\": \"Regexps de video arquivos/caminhos para excluir do escaneamento e adicionar para limpar\",\n            \"excluded_video_patterns_head\": \"Padrões de vídeo excluidos\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"Usa o hardware disponível para codificar vídeo para transcodificação ao vivo.\",\n                    \"heading\": \"Codificação de hardware do FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avançado: Argumentos adicionais para passar para o ffmpeg antes do campo de entrada ao transcodificar vídeo ao vivo.\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"Baixar o FFmpeg\"\n                }\n            },\n            \"gallery_ext_desc\": \"Lista delimitada por vírgulas de extensões de arquivo que serão identificadas como arquivos ZIP da galeria.\",\n            \"gallery_ext_head\": \"Extensões zip da galeria\",\n            \"generated_file_naming_hash_desc\": \"Use MD5 ou oshash para nomeação de arquivos gerados. Mudando isso requer que todas as cenas tenham o valor MD5/oshash populado. Depois de alterar este valor, arquivos gerados existentes precisarão ser migrados ou regenerados. Veja a página de tarefas para migração.\",\n            \"generated_file_naming_hash_head\": \"Hash de nomeação de arquivo gerado\",\n            \"generated_files_location\": \"Local de diretório para os arquivos gerados (marcadores de cena, pré visualizações de cena, sprites, etc)\",\n            \"generated_path_head\": \"Caminho gerado\",\n            \"hashing\": \"Criação de Hash\",\n            \"image_ext_desc\": \"Lista delimitada por vírgulas de extensões de arquivo que serão identificadas como imagens.\",\n            \"image_ext_head\": \"Extensões de imagem\",\n            \"include_audio_desc\": \"Inclui stream de áudio quando gerar pré-visualizações.\",\n            \"include_audio_head\": \"Incluir áudio\",\n            \"logging\": \"Registro\",\n            \"maximum_streaming_transcode_size_desc\": \"Tamanho máximo para streams transcodificadas\",\n            \"maximum_streaming_transcode_size_head\": \"Tamanho máximo de transcodação de streaming\",\n            \"maximum_transcode_size_desc\": \"Tamanho máximo para transcodificações geradas\",\n            \"maximum_transcode_size_head\": \"Tamanho máximo de transcodificação\",\n            \"metadata_path\": {\n                \"description\": \"Localização do diretório usado durante importação ou exportação completa dos meta-dados\",\n                \"heading\": \"Caminho dos Metadados\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Defina como 0 para detecção automática. AVISO Execução de mais tarefas do que é necessário para obter 100% de utilização da CPU diminuirá o desempenho e potencialmente causar outros problemas.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Número de tarefas paralelas para escaneamento/geração\",\n            \"parallel_scan_head\": \"Escaneamento/geração paralela\",\n            \"preview_generation\": \"Geração de pré-visualização\",\n            \"python_path\": {\n                \"description\": \"Caminho do executável do Python. Utilizado para scripts de scrape e plugins. Se em branco, o caminho do Python será resolvido a partir do ambiente\",\n                \"heading\": \"Caminho do Python\"\n            },\n            \"scraper_user_agent\": \"Agente de Usuário do Extrator\",\n            \"scraper_user_agent_desc\": \"User-Agent string usado durante solicitações http do scrape\",\n            \"scrapers_path\": {\n                \"description\": \"Caminho para o diretório para os arquivos de configuração de scrapers\",\n                \"heading\": \"Caminho dos scrapers\"\n            },\n            \"scraping\": \"Extração\",\n            \"sqlite_location\": \"Localização do arquivo para o banco de dados SQLite (requer reinicialização)\",\n            \"video_ext_desc\": \"Lista delimitada por vírgulas de extensões de arquivo que serão identificadas como vídeos.\",\n            \"video_ext_head\": \"Extensões de vídeo\",\n            \"video_head\": \"Vídeo\"\n        },\n        \"library\": {\n            \"exclusions\": \"Exclusões\",\n            \"gallery_and_image_options\": \"Opções de Imagem e Galeria\",\n            \"media_content_extensions\": \"Extensões de arquivo de mídia\"\n        },\n        \"logs\": {\n            \"log_level\": \"Nível de registro\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Ganchos\",\n            \"triggers_on\": \"Triggers on\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} metadados\",\n            \"entity_scrapers\": \"Scrapers de {entityType}\",\n            \"excluded_tag_patterns_desc\": \"Expressões regulares de etiquetas para excluir dos resultados da busca\",\n            \"excluded_tag_patterns_head\": \"Padrões de etiqueta excluídos\",\n            \"scraper\": \"Extrator\",\n            \"scrapers\": \"Extratores\",\n            \"search_by_name\": \"Buscar por nome\",\n            \"supported_types\": \"Tipos suportados\",\n            \"supported_urls\": \"URLs\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Adicionar uma instância stash-box\",\n            \"api_key\": \"Chave de API\",\n            \"description\": \"Stash-box facilita o etiquetamento automático de cenas e artistas baseados em 'impressões digitais' e nomes de arquivos.\\nEndpoint e chave de API pode ser encontrado na sua página de conta na instancia stash-box. Os nomes são necessários quando mais de uma instância são adicionados.\",\n            \"endpoint\": \"Ponto Final\",\n            \"graphql_endpoint\": \"Endpoint GraphQL\",\n            \"name\": \"Nome\",\n            \"title\": \"Endpoints do Stash-box\"\n        },\n        \"system\": {\n            \"transcoding\": \"Transcodificação\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"{operation_name} adicionada para a fila de trabalho\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Etiquetar automaticamente todos os caminhos\",\n                \"auto_tagging_paths\": \"Etiquetar automaticamente os seguintes caminhos\"\n            },\n            \"auto_tag_based_on_filenames\": \"Etiquetar automaticamente conteúdo baseado em nomes de arquivos.\",\n            \"auto_tagging\": \"Etiquetamento automático\",\n            \"backing_up_database\": \"Backup do banco de dados\",\n            \"backup_and_download\": \"Executa um backup do banco de dados e baixa do arquivo resultante.\",\n            \"cleanup_desc\": \"Verifique os arquivos ausentes removendo-os do banco de dados. Esta é uma ação destrutiva.\",\n            \"data_management\": \"Gestão de dados\",\n            \"defaults_set\": \"Opções padrão foram definidas e serão usadas quando clicar o botão {action} na página de Tarefas.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Não incluir extensão de arquivo como parte do título\",\n            \"empty_queue\": \"Não há tarefas atualmente em execução.\",\n            \"export_to_json\": \"Exporta o conteúdo do banco de dados para o formato JSON no diretório de metadados.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Gerando mídia de suporte para as cenas dos seguintes diretórios\",\n                \"generating_scenes\": \"Gerando mídia de suporta para {num} {scene}\"\n            },\n            \"generate_desc\": \"Gerar imagem de suporte, sprite, video, vtt e outros arquivos.\",\n            \"generate_phashes_during_scan\": \"Gerar hashes perceptivos (phash)\",\n            \"generate_phashes_during_scan_tooltip\": \"Para desduplicação e identificação de cenas.\",\n            \"generate_previews_during_scan\": \"Gerar pré-visualizações de imagem animada\",\n            \"generate_previews_during_scan_tooltip\": \"Gerar pré-visualizações WebP animadas, necessário apenas de o Tipo de Pré-visualização estiver configurado para Imagem Animada.\",\n            \"generate_sprites_during_scan\": \"Gerar sprites para o scrubber de cena\",\n            \"generate_thumbnails_during_scan\": \"Gerar miniaturas das imagens\",\n            \"generate_video_previews_during_scan\": \"Gerar pré-visualizações\",\n            \"generate_video_previews_during_scan_tooltip\": \"Gerar pré-visualizações de vídeo que são exibidos ao passar o mouse sobre uma cena\",\n            \"generated_content\": \"Conteúdo gerado\",\n            \"identify\": {\n                \"and_create_missing\": \"e criar ausentes\",\n                \"create_missing\": \"Criar ausentes\",\n                \"default_options\": \"Opções padrão\",\n                \"description\": \"Defina meta-dados de cena automaticamente usando stash-box e fontes de scrapers.\",\n                \"explicit_set_description\": \"As opções a seguir serão usadas se não sobrescreverem as opções específicas de cada fonte.\",\n                \"field\": \"Campo\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Opções de campo\",\n                \"heading\": \"Identificar\",\n                \"identifying_from_paths\": \"Identificação de cenas nos seguintes caminhos\",\n                \"identifying_scenes\": \"Identificando {num} {scene}\",\n                \"include_male_performers\": \"Incluir atores\",\n                \"set_cover_images\": \"Definir imagens de capa\",\n                \"set_organized\": \"Marcar cena como \\\"organizada\\\"\",\n                \"source\": \"Fonte\",\n                \"source_options\": \"Opções de {source}\",\n                \"sources\": \"Fontes\",\n                \"strategy\": \"Estratégia\",\n                \"skip_single_name_performers\": \"Pular artistas com nome único que não possuem desambiguação\",\n                \"skip_single_name_performers_tooltip\": \"Se isso não estiver ativado, artistas que costumam ser genéricos, como Samantha ou Olga, vão ser combinados\",\n                \"tag_skipped_performers\": \"Marcar artistas ignorados com\"\n            },\n            \"import_from_exported_json\": \"Importação de JSON exportado no diretório de metadados. Limpa o banco de dados existente.\",\n            \"incremental_import\": \"Importação incremental de um arquivo zip de exportação fornecido.\",\n            \"job_queue\": \"Fila de tarefas\",\n            \"maintenance\": \"Manutenção\",\n            \"migrate_hash_files\": \"Usado depois de alterar o hash gerado de nomeação de arquivos para renomear arquivos gerados existentes para o novo formato hash.\",\n            \"migrations\": \"Migrações\",\n            \"only_dry_run\": \"Executar em modo não destrutivo. Não remova nada\",\n            \"plugin_tasks\": \"Tarefas de plugin\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Escaneando todos os caminhos\",\n                \"scanning_paths\": \"Escaneando os seguintes caminhos\"\n            },\n            \"scan_for_content_desc\": \"Escaneie por novos conteúdos e os adicione ao banco de dados.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Definir nome, data e detalhes a partir dos meta-dados do arquivo\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Verificador de cena duplicada\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Adicionar campo\",\n                \"capitalize_title\": \"Capitalizar o título\",\n                \"display_fields\": \"Exibir os campos\",\n                \"escape_chars\": \"Use \\\\ para digitar caracteres literais\",\n                \"filename\": \"Nome do arquivo\",\n                \"filename_pattern\": \"Padrão de nome de arquivo\",\n                \"ignore_organized\": \"Ignorar cenas organizadas\",\n                \"ignored_words\": \"Palavras ignoradas\",\n                \"matches_with\": \"Corresponde com {i}\",\n                \"select_parser_recipe\": \"Selecionar a fórmula do analisador sintático\",\n                \"title\": \"Parser de nome de arquivo de cena\",\n                \"whitespace_chars\": \"Caracteres de espaço em branco\",\n                \"whitespace_chars_desc\": \"Esses caracteres serão substituídos pelo espaço em branco no título\"\n            },\n            \"scene_tools\": \"Ferramentas de cena\"\n        },\n        \"ui\": {\n            \"basic_settings\": \"Configurações básicas\",\n            \"custom_css\": {\n                \"description\": \"A página deve ser recarregada para alterações para terem efeito.\",\n                \"heading\": \"CSS customizado\",\n                \"option_label\": \"CSS customizado habilitado\"\n            },\n            \"delete_options\": {\n                \"description\": \"Opções padrão ao deletar imagens, galerias e cenas.\",\n                \"heading\": \"Opções de deleção\",\n                \"options\": {\n                    \"delete_file\": \"Deletar arquivo por padrão\",\n                    \"delete_generated_supporting_files\": \"Deletar arquivos de suporte gerados por padrão\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Integração com o Desktop\",\n                \"notifications_enabled\": \"Ativar Notificações\",\n                \"send_desktop_notifications_for_events\": \"Enviar notificações desktop para eventos\",\n                \"skip_opening_browser\": \"Pular abertura do navegador\",\n                \"skip_opening_browser_on_startup\": \"Pular abertura automática do navegador durante a inicialização\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Remover a capacidade de criar novos objetos a partir dos seletores dropdown\",\n                    \"heading\": \"Desabilitar criação no menu dropdown\"\n                },\n                \"heading\": \"Edição\"\n            },\n            \"funscript_offset\": {\n                \"description\": \"Compensação de tempo em milissegundos para a reprodução de scripts interativos.\",\n                \"heading\": \"Compensação de tempo Funscript (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Conectar\",\n                \"server_offset\": {\n                    \"heading\": \"Compensação do Servidor\"\n                },\n                \"status\": {\n                    \"heading\": \"Estado da conexão do Handy\"\n                },\n                \"sync\": \"Sincronizar\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Chave de conexão para usar em cenas interativas. Ativar esta chave permitirá o Stash a compartilhar as informações da cena atual com handyfeeling.com\",\n                \"heading\": \"Chave de conexão\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Galeria de imagem\"\n            },\n            \"images\": {\n                \"heading\": \"Imagens\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Salvar miniaturas de imagem no disco quando geradas em tempo real\",\n                        \"heading\": \"Salvar miniaturas de imagem\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Opções interativas\",\n            \"language\": {\n                \"heading\": \"Idioma\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Duração máxima da cena onde o player realizará o loop do vídeo - 0 para desabilitar\",\n                \"heading\": \"Duração máxima do loop\"\n            },\n            \"menu_items\": {\n                \"description\": \"Mostrar ou ocultar diferentes tipos de conteúdo na barra de navegação\",\n                \"heading\": \"Itens do menu\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Caminho personalizado para imagens de atrizes/atores. Deixe em branco para usar o padrão da aplicação\",\n                        \"heading\": \"Caminho para imagens personalizadas de atrizes/atores\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Configuração para itens do paredão\",\n                \"heading\": \"Tipo de visualização\",\n                \"options\": {\n                    \"animated\": \"Imagem animada\",\n                    \"static\": \"Imagem estática\",\n                    \"video\": \"Vídeo\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Lista de cenas\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Mostrar estúdios como texto\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Player de cenas\",\n                \"options\": {\n                    \"auto_start_video\": \"Começar vídeos automaticamente\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Começar reprodução de vídeos de cenas automaticamente quando reproduzindo da fila, reproduzindo o selecionado ou aleatório a partir da página de Cenas\",\n                        \"heading\": \"Começar vídeo automaticamente quando reproduzindo selecionado\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Reproduzir próxima cena na fila quando o vídeo finalizar\",\n                        \"heading\": \"Continuar playlist por padrão\"\n                    },\n                    \"show_scrubber\": \"Mostrar Scrubber\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Muro de cenas/marcadores\",\n                \"options\": {\n                    \"display_title\": \"Exibir título e etiquetas\",\n                    \"toggle_sound\": \"Habilitar som\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Número de vezes para tentar rolar antes de passar para o próximo/prévio item. Só se aplica ao modo de rolagem Movimentar Y.\",\n                \"heading\": \"Tentativas de rolagem antes da transição\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Slideshow está disponível em galerias quando no modo de exibição de paredão\",\n                \"heading\": \"Atraso do slideshow (segundos)\"\n            },\n            \"title\": \"Interface de usuário\",\n            \"performer_list\": {\n                \"heading\": \"Lista de artistas\"\n            }\n        },\n        \"advanced_mode\": \"Modo Avançado\"\n    },\n    \"configuration\": \"Configuração\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Arquivo} other {Arquivos}}\",\n        \"galleries\": \"{count, plural, one {Galeria} other {Galerias}}\",\n        \"images\": \"{count, plural, one {Imagem} other {Imagens}}\",\n        \"markers\": \"{count, plural, one {Marcador} other {Marcadores}}\",\n        \"performers\": \"{count, plural, one {Artista} other {Artistas}}\",\n        \"scenes\": \"{count, plural, one {Cena} other {Cenas}}\",\n        \"studios\": \"{count, plural, one {Estúdio} other {Estúdios}}\",\n        \"tags\": \"{count, plural, one {Etiqueta} other {Etiquetas}}\"\n    },\n    \"country\": \"País\",\n    \"cover_image\": \"Imagem de capa\",\n    \"created_at\": \"Criado em\",\n    \"criterion\": {\n        \"greater_than\": \"Maior que\",\n        \"less_than\": \"Menor que\",\n        \"value\": \"Valor\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"está entre\",\n        \"equals\": \"é\",\n        \"excludes\": \"exclui\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"é maior que\",\n        \"includes\": \"inclui\",\n        \"includes_all\": \"inclui tudo\",\n        \"is_null\": \"é nulo\",\n        \"less_than\": \"é menor que\",\n        \"matches_regex\": \"regex combina com\",\n        \"not_between\": \"não está entre\",\n        \"not_equals\": \"não é\",\n        \"not_matches_regex\": \"regex não combina com\",\n        \"not_null\": \"não é nulo\"\n    },\n    \"custom\": \"Personalizado\",\n    \"date\": \"Data\",\n    \"death_date\": \"Data de óbito\",\n    \"death_year\": \"Ano da morte\",\n    \"descending\": \"Descendente\",\n    \"detail\": \"Detalhe\",\n    \"details\": \"Detalhes\",\n    \"developmentVersion\": \"Versão de desenvolvimento\",\n    \"dialogs\": {\n        \"delete_alert\": \"Os seguintes {count, plural, one {{singularEntity}} other {{pluralEntity}}} serão deletados permanentemente:\",\n        \"delete_confirm\": \"Tem certeza de que deseja excluir {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Tem certeza de que deseja excluir este(a) {singularEntity}? A menos que o arquivo também esteja excluído, este(a) {singularEntity} será re-adicionado quando a escaneamento for executado.} other {Tem certeza de que deseja excluir estes(as) {pluralEntity}? A menos que os arquivos também estejam excluídos, estes(as) {pluralEntity} serão re-adicionados quando o escaneamento for executado.}}\",\n        \"delete_entity_title\": \"{count, plural, one {Excluir {singularEntity}} other {Excluir {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"...e qualquer imagem não anexada a uma galeria.\",\n        \"delete_gallery_files\": \"Deletar diretório, arquivo zip ou imagem não anexada a alguma galeria.\",\n        \"delete_object_desc\": \"Tem certeza de que deseja excluir {count, plural, one {este(a) {singularEntity}} other {estes(as) {pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…e {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Excluir {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Não mostrar novamente até a próxima atualização\",\n        \"edit_entity_title\": \"Editar {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Inclua objetos relacionados na exportação\",\n        \"export_title\": \"Exportar\",\n        \"lightbox\": {\n            \"delay\": \"Delay (seg)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Ajustar horizontalmente\",\n                \"fit_to_screen\": \"Ajustar à tela\",\n                \"label\": \"Modo de visualização\",\n                \"original\": \"Original\"\n            },\n            \"options\": \"Opções\",\n            \"reset_zoom_on_nav\": \"Restaurar nível de zoom ao trocar de imagem\",\n            \"scale_up\": {\n                \"description\": \"Aumentar imagens menores até que preencham a tela\",\n                \"label\": \"Aumentar até caber\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Mantenha shift pressionado para usar outro modo temporariamente.\",\n                \"label\": \"Modo de rolagem\",\n                \"pan_y\": \"Movimentar Y\",\n                \"zoom\": \"Zoom\"\n            }\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Forçar geração de transcodificação\",\n            \"force_transcodes_tooltip\": \"Por padrão, transcodificações são geradas apenas quando o arquivo de vídeo não é suportado pelo navegador. Quando ativado, transcodificações serão geradas mesmo quando o vídeo parecer ser suportado no navegador.\",\n            \"image_previews\": \"Pré-visualizações de imagem animada\",\n            \"image_previews_tooltip\": \"Pré-visualizações WebP animadas, necessárias somente se o Tipo de Pré-visualização estiver configurado para Imagem Animada.\",\n            \"interactive_heatmap_speed\": \"Gerar heatmaps e velocidades para cenas interativas\",\n            \"marker_image_previews\": \"Pré-visualizações de Imagem Animada para Marcadores\",\n            \"marker_image_previews_tooltip\": \"Pré-visualizações WebP animadas para marcadores, necessário apenas de o Tipo de Pré-visualização estiver configurado para Imagem Animada.\",\n            \"marker_screenshots\": \"Capturas de tela de Marcadores\",\n            \"marker_screenshots_tooltip\": \"Imagens JPG estáticas para marcadores, necessário apenas se o Tipo de Pré-visualização estiver configurado para Imagem Estática.\",\n            \"markers\": \"Pré-visualizações de Marcadores\",\n            \"markers_tooltip\": \"Vídeos de 20 segundos que iniciam em dado código de tempo.\",\n            \"override_preview_generation_options\": \"Sobrepor as opções da geração de pré-visualização\",\n            \"override_preview_generation_options_desc\": \"Sobrepor as opções da geração de pré-visualização. Os padrões são definidos em Sistema -> Geração de pré-visualização.\",\n            \"overwrite\": \"Substituir arquivos gerados existentes\",\n            \"phash\": \"Hashes perceptivos (para desduplicação)\",\n            \"preview_exclude_end_time_desc\": \"Excluir os últimos x segundos de pré-visualizações de cena. Isso pode ser um valor em segundos, ou uma porcentagem (p. ex. 2%) da duração total da cena.\",\n            \"preview_exclude_end_time_head\": \"Excluir tempo de término\",\n            \"preview_exclude_start_time_desc\": \"Excluir os primeiros x segundos de pré-visualizações de cena. Isso pode ser um valor em segundos, ou uma porcentagem (p. ex. 2%) da duração total da cena.\",\n            \"preview_exclude_start_time_head\": \"Excluir tempo de início\",\n            \"preview_generation_options\": \"Opções para Geração de Pré-visualizações\",\n            \"preview_options\": \"Opções de pré-visualização\",\n            \"preview_preset_desc\": \"A predefinição regula o tamanho, a qualidade e o tempo de codificação da geração de pré-visualização. Predefinições além de “lenta” tem retornos diminuindo e não são recomendados.\",\n            \"preview_preset_head\": \"Codificação predefinida de pré-visualização\",\n            \"preview_seg_count_desc\": \"Número de segmentos em arquivos de pré-visualização.\",\n            \"preview_seg_count_head\": \"Número de segmentos em pré-visualização\",\n            \"preview_seg_duration_desc\": \"Duração de cada segmento de pré-visualização, em segundos.\",\n            \"preview_seg_duration_head\": \"Duração do segmento de pré-visualização\",\n            \"sprites\": \"Sprites do scrubber de cena\",\n            \"sprites_tooltip\": \"Sprites (para o scrubber de cena)\",\n            \"transcodes\": \"Transcodificações\",\n            \"transcodes_tooltip\": \"Conversões MP4 de formatos de vídeo não suportados\",\n            \"video_previews\": \"Pré-visualizações\",\n            \"video_previews_tooltip\": \"Pré-visualizações de vídeo que são exibidas ao passar o mouse sobre uma cena\"\n        },\n        \"scenes_found\": \"{count} cenas encontradas\",\n        \"scrape_entity_query\": \"Query de busca de {entity_type}\",\n        \"scrape_entity_title\": \"{entity_type} resultados de scrape\",\n        \"scrape_results_existing\": \"Existem\",\n        \"scrape_results_scraped\": \"Scrape finalizado\",\n        \"set_image_url_title\": \"URL da imagem\",\n        \"unsaved_changes\": \"Mudanças não salvas. Você tem certeza de que quer sair?\",\n        \"performers_found\": \"{count} artistas encontrados\"\n    },\n    \"dimensions\": \"Dimensões\",\n    \"director\": \"Diretor(a)\",\n    \"display_mode\": {\n        \"grid\": \"Grade\",\n        \"list\": \"Lista\",\n        \"tagger\": \"Etiquetador\",\n        \"unknown\": \"Desconhecido(a)\",\n        \"wall\": \"Paredão\"\n    },\n    \"donate\": \"Doar\",\n    \"dupe_check\": {\n        \"description\": \"Níveis abaixo de 'Exato' podem demorar mais para calcular. Falsos positivos também podem ser encontrados em níveis de precisão mais baixos.\",\n        \"found_sets\": \"{setCount, plural, one{# conjunto de duplicatas encontrados.} other {# conjuntos de duplicatas encontrados.}}\",\n        \"options\": {\n            \"exact\": \"Exato\",\n            \"high\": \"Alto\",\n            \"low\": \"Baixo\",\n            \"medium\": \"Médio\"\n        },\n        \"search_accuracy_label\": \"Precisão de pesquisa\",\n        \"title\": \"Cenas duplicadas\"\n    },\n    \"duplicated_phash\": \"Duplicado (phash)\",\n    \"duration\": \"Duração\",\n    \"effect_filters\": {\n        \"aspect\": \"Aspecto\",\n        \"blue\": \"Azul\",\n        \"blur\": \"Borrão\",\n        \"brightness\": \"Brilho\",\n        \"contrast\": \"Contraste\",\n        \"gamma\": \"Gama\",\n        \"green\": \"Verde\",\n        \"hue\": \"Matiz\",\n        \"name\": \"Filtros\",\n        \"name_transforms\": \"Transformadores\",\n        \"red\": \"Vermelho\",\n        \"reset_filters\": \"Redefinir filtros\",\n        \"reset_transforms\": \"Redefinir transformadores\",\n        \"rotate\": \"Girar\",\n        \"rotate_left_and_scale\": \"Girar para a esquerda e escalar\",\n        \"rotate_right_and_scale\": \"Girar a direita e escalar\",\n        \"saturation\": \"Saturação\",\n        \"scale\": \"Escala\",\n        \"warmth\": \"Calor\"\n    },\n    \"empty_server\": \"Adicione algumas cenas ao seu servidor para ver as recomendações nesta página.\",\n    \"ethnicity\": \"Etnicidade\",\n    \"existing_value\": \"valor existente\",\n    \"eye_color\": \"Cor dos olhos\",\n    \"fake_tits\": \"Peitos falsos\",\n    \"false\": \"Falso\",\n    \"favourite\": \"Favorito(a)\",\n    \"file\": \"arquivo\",\n    \"file_count\": \"Contagem de arquivos\",\n    \"file_info\": \"Informações do arquivo\",\n    \"file_mod_time\": \"Tempo de modificação do arquivo\",\n    \"files\": \"arquivos\",\n    \"filesize\": \"Tamanho do arquivo\",\n    \"filter\": \"Filtro\",\n    \"filter_name\": \"Nome do filtro\",\n    \"filters\": \"Filtros\",\n    \"folder\": \"Diretório\",\n    \"framerate\": \"Taxa de quadros\",\n    \"frames_per_second\": \"{value} quadros por segundo\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Filtro pré-pronto\",\n            \"saved_filter\": \"Filtro salvo\"\n        }\n    },\n    \"galleries\": \"Galerias\",\n    \"gallery\": \"Galeria\",\n    \"gallery_count\": \"Contagem de galeria\",\n    \"gender\": \"Gênero\",\n    \"gender_types\": {\n        \"FEMALE\": \"Feminino\",\n        \"INTERSEX\": \"Intersexo\",\n        \"MALE\": \"Masculino\",\n        \"NON_BINARY\": \"Não-Binário\",\n        \"TRANSGENDER_FEMALE\": \"Transgênero Feminino\",\n        \"TRANSGENDER_MALE\": \"Transgênero Masculino\"\n    },\n    \"hair_color\": \"Cor do cabelo\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Conectando\",\n        \"disconnected\": \"Desconectado\",\n        \"error\": \"Erro conectando ao Handy\",\n        \"missing\": \"Faltando\",\n        \"ready\": \"Pronto\",\n        \"syncing\": \"Sincronizando com o servidor\",\n        \"uploading\": \"Enviando script\"\n    },\n    \"hasMarkers\": \"Possui marcadores\",\n    \"height\": \"Altura\",\n    \"help\": \"Ajuda\",\n    \"ignore_auto_tag\": \"Ignorar etiquetamento automático\",\n    \"image\": \"Imagem\",\n    \"image_count\": \"Contagem de imagem\",\n    \"images\": \"Imagens\",\n    \"include_parent_tags\": \"Incluir etiquetas pai\",\n    \"include_sub_studios\": \"Incluem estúdios filho\",\n    \"include_sub_tags\": \"Incluir sub-etiquetas\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interativo\",\n    \"interactive_speed\": \"Velocidade interativa\",\n    \"isMissing\": \"Está faltando\",\n    \"library\": \"Biblioteca\",\n    \"loading\": {\n        \"generic\": \"Carregando…\"\n    },\n    \"marker_count\": \"Contagem de marcadores\",\n    \"markers\": \"Marcadores\",\n    \"measurements\": \"Medidas\",\n    \"media_info\": {\n        \"audio_codec\": \"Codec de áudio\",\n        \"downloaded_from\": \"Baixado de\",\n        \"interactive_speed\": \"Velocidade interativa\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} nesta cena\"\n        },\n        \"phash\": \"PHash\",\n        \"stream\": \"Transmissão\",\n        \"video_codec\": \"Codec de vídeo\"\n    },\n    \"megabits_per_second\": \"{value} megabits por segundo\",\n    \"metadata\": \"Metadados\",\n    \"name\": \"Nome\",\n    \"new\": \"Novo\",\n    \"none\": \"Nenhum\",\n    \"operations\": \"Operações\",\n    \"organized\": \"Organizado\",\n    \"pagination\": {\n        \"first\": \"Primeiro\",\n        \"last\": \"Último\",\n        \"next\": \"Próximo\",\n        \"previous\": \"Anterior\"\n    },\n    \"parent_of\": \"Pai de {children}\",\n    \"parent_studios\": \"Estúdios pai\",\n    \"parent_tag_count\": \"Contador de etiquetas pai\",\n    \"parent_tags\": \"Etiquetas pai\",\n    \"part_of\": \"Parte de {parent}\",\n    \"path\": \"Caminho\",\n    \"perceptual_similarity\": \"Semelhança Perceptiva (phash)\",\n    \"performer\": \"Artista\",\n    \"performer_age\": \"Idade do Artista\",\n    \"performer_count\": \"Contagem de artistas\",\n    \"performer_favorite\": \"Artista Favoritado\",\n    \"performer_image\": \"Imagem do(a) artita\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Adicionar Novos Artistas\",\n        \"any_names_entered_will_be_queried\": \"Quaisquer nomes inseridos serão consultados na instância remota do Stash-Box e adicionados caso encontrados. Apenas correspondências exatas serão consideradas.\",\n        \"batch_add_performers\": \"Adicionar Artistas em Lote\",\n        \"batch_update_performers\": \"Atualizar Artistas em Lote\",\n        \"current_page\": \"Página atual\",\n        \"failed_to_save_performer\": \"Falha ao salvar artista \\\"{performer}\\\"\",\n        \"name_already_exists\": \"Nome já existe\",\n        \"network_error\": \"Erro de rede\",\n        \"no_results_found\": \"Nenhum resultado encontrado.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} artistas serão processados\",\n        \"performer_already_tagged\": \"Artista já etiquetado\",\n        \"performer_selection\": \"Seleção de artista\",\n        \"performer_successfully_tagged\": \"Artista etiquetado com sucesso:\",\n        \"query_all_performers_in_the_database\": \"Todos os artistas no banco de dados\",\n        \"refresh_tagged_performers\": \"Recarregar artistas etiquetados\",\n        \"refreshing_will_update_the_data\": \"Recarregar irá atualizar os dados de qualquer artista etiquetado da instância do stash-box.\",\n        \"status_tagging_job_queued\": \"Status: Etiquetamento adicionado à fila\",\n        \"status_tagging_performers\": \"Status: Etiquetando artistas\",\n        \"tag_status\": \"Status da etiqueta\",\n        \"to_use_the_performer_tagger\": \"Para usar o etiquetador de artistas, uma instância do stash-box deve ser configurada.\",\n        \"untagged_performers\": \"Artistas sem etiqueta\",\n        \"update_performer\": \"Atualizar Artista\",\n        \"update_performers\": \"Atualizar Artistas\",\n        \"updating_untagged_performers_description\": \"A atualização de artistas sem etiqueta tentará corresponder a qualquer artista sem um stashid e atualizar os metadados.\"\n    },\n    \"performer_tags\": \"Etiquetas de artistas\",\n    \"performers\": \"Artistas\",\n    \"piercings\": \"Piercings\",\n    \"queue\": \"Fila\",\n    \"random\": \"Aleatória\",\n    \"rating\": \"Avaliação\",\n    \"recently_added_objects\": \"{objects} Recentemente Adicionados\",\n    \"recently_released_objects\": \"{objects} Recentemente Lançados\",\n    \"release_notes\": \"Notas de lançamento\",\n    \"resolution\": \"Resolução\",\n    \"scene\": \"Cena\",\n    \"sceneTagger\": \"Etiquetador de cena\",\n    \"scene_count\": \"Contagem de cena\",\n    \"scene_id\": \"Cena ID\",\n    \"scene_tags\": \"Etiquetas da cena\",\n    \"scenes\": \"Cenas\",\n    \"scenes_updated_at\": \"Cena atualizada em\",\n    \"search_filter\": {\n        \"name\": \"Filtro\",\n        \"saved_filters\": \"Filtros salvos\",\n        \"update_filter\": \"Atualizar filtro\"\n    },\n    \"seconds\": \"Segundos\",\n    \"settings\": \"Definições\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Estamos quase prontos para concluis a configuração. Por favor confirme as configurações a seguir. Você pode clicar em voltar para alterar qualquer dado incorreto. Se tudo parece certo, clique Confirmar para criar seu novo sistema.\",\n            \"configuration_file_location\": \"Caminho para o arquivo de configuração:\",\n            \"database_file_path\": \"Caminho para o banco de dados\",\n            \"generated_directory\": \"Diretório contendo arquivos de mídia de suporte gerados pelo Stash\",\n            \"nearly_there\": \"Quase lá!\",\n            \"stash_library_directories\": \"Diretórios de biblioteca do Stash\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Criando seu sistema\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Ah não! Algo deu errado!\",\n            \"something_went_wrong_description\": \"Se isso parece um problema com os dados fornecidos, clique no em Voltar para corrigi-los. Caso contrário, reporte um bug em {githubLink} ou busque ajuda em {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Algo deu errado enquanto configurávamos seu sistema. Aqui está o erro que recebemos: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Caminho do arquivo\",\n            \"up_dir\": \"Subir um diretório\"\n        },\n        \"github_repository\": \"Repositório do Github\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Caminho para o backup do banco de dados (deixe em branco para desabilitar o backup):\",\n            \"backup_recommended\": \"É recomendado que você faça o backup do banco de dados existente antes de migrar. Podemos fazer isso por você, fazendo uma cópia do seu banco de dados para <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Migrando banco de dados\",\n            \"migration_failed\": \"Falha na migração\",\n            \"migration_failed_error\": \"Foi encontrado o seguinte erro durante a migração do banco de dados:\",\n            \"migration_failed_help\": \"Por favor, faça qualquer correção necessária e tente novamente. Caso contrário, reporte um bug em {githubLink} ou busque ajuda em {discordLink}.\",\n            \"migration_irreversible_warning\": \"O processo de migração não é reversível. Uma vez que a migração seja concluída, seu banco de dados será incompatível com versões anteriores do Stash.\",\n            \"migration_notes\": \"Notas de migração\",\n            \"migration_required\": \"Migração necessária\",\n            \"perform_schema_migration\": \"Fazer migração\",\n            \"schema_too_old\": \"A versão atual de seu banco de dados é <strong>{databaseSchema}</strong> e necessita ser migrada para a versão <strong>{appSchema}</strong>. Esta versão do Stash não funcionará sem migrar o banco de dados. Se você não deseja efetuar a migração, você irá precisar rebaixar para uma versão do Stash que corresponde à sua versão do banco de dados.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"nome do arquivo do banco de dados (em branco para usar padrão)\",\n            \"description\": \"A seguir, precisamos determinar onde achar sua coleção pornô, onde armazenar o banco de dados do Stash e arquivos de suporte gerados. Estas configurações podem ser alteradas posteriormente se necessário.\",\n            \"path_to_generated_directory_empty_for_default\": \"caminho para o diretório de arquivos de suporte gerados (deixar em branco para usar padrão)\",\n            \"set_up_your_paths\": \"Configure seus diretórios\",\n            \"stash_alert\": \"Nenhum caminho para sua biblioteca foi selecionado. Nenhum arquivo poderá ser escaneado para o Stash. Tem certeza?\",\n            \"where_can_stash_store_its_database\": \"Onde o Stash pode armazenar seu banco de dados?\",\n            \"where_can_stash_store_its_database_description\": \"Stash usa um banco de dados sqlite para armazenar os meta-dados de sua coleção. Por padrão ele será criado como <code>stash-go.sqlite</code> no diretório contendo seu arquivo de configuração. Se deseja alterar isto, por favor indique um arquivo com caminho absoluto ou relativo ao diretório de trabalho atual.\",\n            \"where_can_stash_store_its_generated_content\": \"Onde o Stash pode armazenar os arquivos de suporte que forem gerados?\",\n            \"where_can_stash_store_its_generated_content_description\": \"A fim de fornecer miniaturas, pré-visualizações e sprites, Stash gera imagens e vídeos. Isto também inclui transcodificações para formatos de arquivos não suportados. Por padrão, o Stash irá criar o diretório <code>generated</code> no diretório contento seu arquivo de configuração. Se deseja alterar onde esses arquivos gerados serão armazenados, por favor indique um caminho absoluto ou relativo ao diretório de trabalho atual. O stash irá criar este diretório caso ele já não exista.\",\n            \"where_is_your_porn_located\": \"Onde seu pornô está localizado?\",\n            \"where_is_your_porn_located_description\": \"Adicione diretórios contendo seus vídeos e imagens pornô. O Stash irá usar esses diretórios para encontrar vídeos e imagens durante o scan.\"\n        },\n        \"stash_setup_wizard\": \"Assistente de configuração Stash\",\n        \"success\": {\n            \"getting_help\": \"Obter ajuda\",\n            \"help_links\": \"Se tiver algum problema ou sugestão, sinta-se à vontade para abrir uma issue no {githubLink} ou pergunte à comunidade em {discordLink}.\",\n            \"in_app_manual_explained\": \"Recomendados checar o manual integrado à aplicação, que pode ser acessado pelo ícone no canto superior direito da tela que parece com isso: {icon}\",\n            \"next_config_step_one\": \"A seguir você será levado à página de Configuração. Essa página permitirá que você personalize quais arquivos incluir ou excluir, definir um nome de usuário e senha para proteger seu sistema, e várias outras opções.\",\n            \"next_config_step_two\": \"Quando estiver satisfeito com estas configurações, você pode começar a escanear seu conteúdo para o Stash clicando em <code>{localized_task}</code>, e então <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Visite {open_collective_link} para saber como você pode contribuir com o desenvolvimento do Stash.\",\n            \"support_us\": \"Apoie-nos\",\n            \"thanks_for_trying_stash\": \"Obrigado por usar Stash!\",\n            \"welcome_contrib\": \"Também agradecemos contribuições em forma de código (correções de bug, melhorias e novas funcionalidades), testes, reports de bugs, pedidos de melhorias e funcionalidades e suporte de usuário. Detalhes podem ser encontrados na seção Contribuição do manual da aplicação.\",\n            \"your_system_has_been_created\": \"Sucesso! Seu sistema foi criado!\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash tenta encontrar o arquivo de configuração (<code>config.yml</code>) a partir do diretório de trabalho atual, caso não o encontre lá, tentará encontra-lo em <code>$HOME/.stash/config.yml</code> (no Windows, será <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>). Você também pode fazer o Stash ler um arquivo de configuração específico ao executar a aplicação com as opções <code>-c '<caminho para o arquivo de configuração>'</code> ou <code>--config '<caminho para o arquivo de configuração>'</code>.\",\n            \"in_current_stash_directory\": \"No diretório <code>$HOME/.stash</code>\",\n            \"in_the_current_working_directory\": \"No diretório de trabalho atual\",\n            \"next_step\": \"Caso esteja pronto para criar um novo sistema, escolha onde você deseja armazenar seu arquivo de configuração e clique Próximo.\",\n            \"store_stash_config\": \"Onde quer armazenar a configuração do Stash?\",\n            \"unable_to_locate_config\": \"Caso esteja lendo isto, então o Stash não pôde encontrar uma configuração existente. Este assistente te guiará durante o processo de criar uma nova configuração.\",\n            \"unexpected_explained\": \"Se chegou à esta tela inesperadamente, por favor tente reiniciar o Stash no diretório de trabalho correto ou com a opção <code>-c</code>.\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash usará o seguinte caminho para o arquivo de configuração: <code>{path}</code>\",\n            \"next_step\": \"Quando estiver pronto para prosseguir com a criação do novo sistema, clique Próximo.\",\n            \"unable_to_locate_specified_config\": \"Se está lendo isto, então o Stash não pôde encontrar o arquivo de configuração especificado na linha de comando ou no ambiente. Este assistente irá te guiar durante o processo de criação de uma nova configuração.\"\n        },\n        \"welcome_to_stash\": \"Bem-vindo ao Stash\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_ids\": \"Stash IDs\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Vá para {endpoint_name} para revisar rascunho.\",\n        \"selected_stash_box\": \"Endpoint do Stash-Box selecionado\",\n        \"submission_failed\": \"Falha no envio\",\n        \"submission_successful\": \"Envio bem-sucedido\",\n        \"submit_update\": \"Já existe no {endpoint_name}\"\n    },\n    \"statistics\": \"Estatísticas\",\n    \"stats\": {\n        \"image_size\": \"Tamanho das imagens\",\n        \"scenes_duration\": \"Duração das cenas\",\n        \"scenes_size\": \"Tamanho de cenas\"\n    },\n    \"status\": \"Status: {statusText}\",\n    \"studio\": \"Estúdio\",\n    \"studio_depth\": \"Níveis (vazio para todos)\",\n    \"studios\": \"Estúdios\",\n    \"sub_tag_count\": \"Contagem de sub-etiquetas\",\n    \"sub_tag_of\": \"Sub-etiqueta de {parent}\",\n    \"sub_tags\": \"Sub-etiquetas\",\n    \"subsidiary_studios\": \"Estúdios filhos\",\n    \"synopsis\": \"Sinopse\",\n    \"tag\": \"Etiqueta\",\n    \"tag_count\": \"Contagem de etiquetas\",\n    \"tags\": \"Etiquetas\",\n    \"tattoos\": \"Tatuagens\",\n    \"title\": \"Título\",\n    \"toast\": {\n        \"added_entity\": \"{entity} adicionado(a)\",\n        \"added_generation_job_to_queue\": \"Trabalho de geração adicionado para fila\",\n        \"created_entity\": \"Criar {entity}\",\n        \"default_filter_set\": \"Filtragem padrão definada\",\n        \"delete_past_tense\": \"Excluída {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Gerando captura de tela…\",\n        \"merged_tags\": \"Etiquetas mescladas\",\n        \"rescanning_entity\": \"Reescaneando {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"{entity} salvo(a)\",\n        \"started_auto_tagging\": \"Etiquetamento automático iniciado\",\n        \"started_generating\": \"Geração de arquivos multimídia iniciada\",\n        \"started_importing\": \"Importação iniciada\",\n        \"updated_entity\": \"{entity} atualizado(a)\",\n        \"merged_scenes\": \"Cenas mescladas\"\n    },\n    \"total\": \"Total\",\n    \"true\": \"Verdadeiro\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Tipo\",\n    \"updated_at\": \"Atualizado em\",\n    \"url\": \"URL\",\n    \"videos\": \"Vídeos\",\n    \"view_all\": \"Ver todos\",\n    \"weight\": \"Peso\",\n    \"years_old\": \"anos\",\n    \"age_on_date\": \"{age} na produção\",\n    \"weight_kg\": \"Peso (kg)\",\n    \"unknown_date\": \"Data desconhecida\",\n    \"urls\": \"URLs\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/ro-RO.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Adaugă\",\n        \"add_directory\": \"Adaugă directorul\",\n        \"add_entity\": \"Adaugă {entityType}\",\n        \"add_to_entity\": \"Adaugă la {entityType}\",\n        \"allow\": \"Permite\",\n        \"allow_temporarily\": \"Permite temporar\",\n        \"apply\": \"Aplică\",\n        \"auto_tag\": \"Etichetă automată\",\n        \"backup\": \"Backup\",\n        \"browse_for_image\": \"Răsfoiți pentru imagine…\",\n        \"cancel\": \"Anulați\",\n        \"clean\": \"Curăță\",\n        \"clear\": \"Șterge\",\n        \"clear_image\": \"Curăță Imaginea\",\n        \"close\": \"Închide\",\n        \"confirm\": \"Confirmare\",\n        \"continue\": \"Continuă\",\n        \"create\": \"Creați\",\n        \"create_entity\": \"Creați {entityType}\",\n        \"create_marker\": \"Creează marcator\",\n        \"created_entity\": \"S-a creat {entity_type}: {entity_name}\",\n        \"customise\": \"Personalizează\",\n        \"delete\": \"Șterge\",\n        \"delete_entity\": \"Șterge {entityType}\",\n        \"delete_file\": \"Șterge fişier\",\n        \"delete_file_and_funscript\": \"Șterge fişier (şi funscript)\",\n        \"disallow\": \"Nu se permite\",\n        \"download\": \"Descarcă\",\n        \"download_backup\": \"Descarcă Backup\",\n        \"edit\": \"Editare\",\n        \"edit_entity\": \"Editează {entityType}\",\n        \"export\": \"Export\",\n        \"export_all\": \"Exportă tot…\",\n        \"find\": \"Găsire\",\n        \"finish\": \"Terminare\",\n        \"from_file\": \"Din fișier…\",\n        \"from_url\": \"Din URL…\",\n        \"full_export\": \"Export Total\",\n        \"full_import\": \"Importă Tot\",\n        \"generate\": \"Generare\",\n        \"generate_thumb_default\": \"Genereaza miniatură implicită\",\n        \"generate_thumb_from_current\": \"Generează miniatura de la momentul curent\",\n        \"hide\": \"Ascunde\",\n        \"hide_configuration\": \"Ascunde Configurația\",\n        \"identify\": \"Identificare\",\n        \"ignore\": \"Ignorare\",\n        \"import\": \"Import…\",\n        \"import_from_file\": \"Importă din Fișier\",\n        \"logout\": \"Deconectare\",\n        \"merge\": \"Îmbină\",\n        \"next_action\": \"Următorul\",\n        \"not_running\": \"nu rulează\",\n        \"open_in_external_player\": \"Deschide in player extern\",\n        \"open_random\": \"Deschide Aleatoriu\",\n        \"overwrite\": \"Suprascriere\",\n        \"play_random\": \"Redă Aleatoriu\",\n        \"play_selected\": \"Redă selecția\",\n        \"preview\": \"Previzualizare\",\n        \"previous_action\": \"Înapoi\",\n        \"refresh\": \"Reîmprospătare\",\n        \"reload_plugins\": \"Reîncărcați plugin-urile\",\n        \"reload_scrapers\": \"Reîncărcați scraperele\",\n        \"remove\": \"Eliminare\",\n        \"remove_from_gallery\": \"Șterge din Galerie\",\n        \"rename_gen_files\": \"Redenumiți fișierele generate\",\n        \"rescan\": \"Rescanare\",\n        \"running\": \"rulează\",\n        \"save\": \"Salvare\",\n        \"save_delete_settings\": \"Folosiți aceste opțiuni în mod implicit atunci cănd ștergeți\",\n        \"save_filter\": \"Salvați filtru\",\n        \"scan\": \"Scanare\",\n        \"scrape\": \"Extrage\",\n        \"search\": \"Căutare\",\n        \"select_all\": \"Selectare totală\",\n        \"select_entity\": \"Selectează {entityType}\",\n        \"select_folders\": \"Selectare foldere\",\n        \"select_none\": \"Selectare niciuna\",\n        \"set_as_default\": \"Stabilire ca implicit\",\n        \"set_image\": \"Setează imaginea…\",\n        \"show\": \"Afișare\",\n        \"show_configuration\": \"Arată Configurația\",\n        \"skip\": \"Ignorare\",\n        \"stop\": \"Oprire\",\n        \"submit\": \"Trimite\",\n        \"submit_stash_box\": \"Trimite către Stash-Box\",\n        \"submit_update\": \"Trimite actualizare\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Ești sigur că vrei să cureți? Acest lucru va șterge informațiile din baza de date și conținutul generat pentru toate scenele și galeriile care nu se mai găsesc în sistemul de fișiere.\",\n            \"import_warning\": \"Ești sigur că vrei să imporți? Asta va șterge baza de date si va reimporta din metadatele tale exporatate.\",\n            \"dry_mode_selected\": \"\\\"Modul uscat\\\" selectat. Nu se va șterge nimic, doar se va loga.\"\n        },\n        \"temp_disable\": \"Dezactivează temporar…\",\n        \"temp_enable\": \"Activează temporar…\",\n        \"use_default\": \"Folosește implicit\",\n        \"view_random\": \"Vezi Aleatoriu\",\n        \"reset_cover\": \"Revino la coperta inițială\",\n        \"selective_auto_tag\": \"Tag Selectiv Automat\",\n        \"reset_play_duration\": \"Resetează durata de redare\",\n        \"add_sub_groups\": \"Adaugă subgrupuri\",\n        \"copy_to_clipboard\": \"Copiază in clipboard\",\n        \"download_anonymised\": \"Descarcă anonim\",\n        \"enable\": \"Activează\",\n        \"encoding_image\": \"Imagine encodare…\",\n        \"optimise_database\": \"Optimizează baza de date\",\n        \"reassign\": \"Reasignează\",\n        \"reload\": \"Reîncarcă\",\n        \"remove_from_containing_group\": \"Șterge din grup\",\n        \"reset_resume_time\": \"Resetează poziția de redare\",\n        \"add_manual_date\": \"Adaugă data manual\",\n        \"add_o\": \"Adauga O\",\n        \"add_play\": \"Adaugă redare\",\n        \"anonymise\": \"Anonimizează\",\n        \"assign_stashid_to_parent_studio\": \"Asociază Stash ID la studio principal existent și actualizează informațiile\",\n        \"choose_date\": \"Alege o dată\",\n        \"clean_generated\": \"Curăță filșierele generate\",\n        \"clear_back_image\": \"Șterge spatele copertei\",\n        \"clear_date_data\": \"Șterge data\",\n        \"clear_front_image\": \"Șterge imaginea de copertă\",\n        \"create_chapters\": \"Creează Capitol\",\n        \"create_parent_studio\": \"Creează studio principal\",\n        \"disable\": \"Dezactivează\",\n        \"delete_generated_supporting_files\": \"Șterge fișierele suport generate\",\n        \"hash_migration\": \"migrare hash\",\n        \"make_primary\": \"Definește ca primar\",\n        \"migrate_blobs\": \"Migrează obiect binar (blob)\",\n        \"migrate_scene_screenshots\": \"Migrează Capturi Scene\",\n        \"remove_date\": \"Șterge data\",\n        \"reshuffle\": \"Reamestecă\",\n        \"scrape_query\": \"Extrage date\",\n        \"scrape_scene_fragment\": \"Extrage fragment cu fragment\",\n        \"scrape_with\": \"Extrage cu…\",\n        \"selective_clean\": \"Curățare selectivă\",\n        \"selective_scan\": \"Scanare selectivă\",\n        \"set_cover\": \"Setează ca fundal\",\n        \"swap\": \"Schimbă\",\n        \"unset\": \"Nesetat\",\n        \"view_history\": \"Vezi istoric\",\n        \"sidebar\": {\n            \"close\": \"Închide bara laterală\",\n            \"open\": \"Deschide bara laterală\"\n        },\n        \"split\": \"Împarte\"\n    },\n    \"actions_name\": \"Acțiuni\",\n    \"age\": \"Vârstă\",\n    \"all\": \"toate\",\n    \"also_known_as\": \"Cunoscut/ă și ca\",\n    \"ascending\": \"Ascendent\",\n    \"average_resolution\": \"Rezoluție medie\",\n    \"birth_year\": \"Anul Nașterii\",\n    \"birthdate\": \"Data nașterii\",\n    \"bitrate\": \"Bit Rate\",\n    \"career_length\": \"Durata carierei\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Instanță stash-box activă:\",\n            \"blacklist_label\": \"Lista neagră\",\n            \"query_mode_auto\": \"Automat\",\n            \"query_mode_auto_desc\": \"Folosește metadatele, dacă sunt prezente, sau numele fișierului\",\n            \"query_mode_dir\": \"Director\",\n            \"query_mode_dir_desc\": \"Folosește numai directorul părinte al fișierului video\",\n            \"query_mode_filename\": \"Numele fișierului\",\n            \"query_mode_filename_desc\": \"Folosește numai numele fișierului\",\n            \"query_mode_metadata_desc\": \"Folosește numai metadate\",\n            \"query_mode_path\": \"Cale\",\n            \"query_mode_path_desc\": \"Utilizează întreaga cale a fișierului\",\n            \"set_cover_desc\": \"Înlocuiți coperta scenei, dacă se găsește una.\",\n            \"set_cover_label\": \"Setează imaginea de copertă a scenei\",\n            \"set_tag_desc\": \"Atașați etichete scenei, fie prin suprascriere, fie prin fuziune cu etichetele existente pe scenă.\",\n            \"set_tag_label\": \"Setați etichete\",\n            \"source\": \"Sursă\",\n            \"query_mode_metadata\": \"Date meta\",\n            \"mark_organized_label\": \"Marchează ca Organizat la salvare\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Lucru de pe lista neagră duplicat\"\n            },\n            \"mark_organized_desc\": \"Marchează scena ca Organizată imediat după ce butonul de Salvare este apăsat.\",\n            \"query_mode_label\": \"Mod de căutare\",\n            \"blacklist_desc\": \"Lucrurile din lista neagră sunt excluse din căutări. De reținut, căutările sunt expresii si diferențiază între litere mari și mici. Înaintea anumitor caractere, trebuie pus caracterul backslash: {chars_require_escape}\"\n        },\n        \"results\": {\n            \"duration_unknown\": \"Durată necunoscută\",\n            \"match_failed_already_tagged\": \"Scena este deja etichetată\",\n            \"match_failed_no_result\": \"Nu s-au găsit rezultate\",\n            \"match_success\": \"Scena a fost etichetată cu succes\",\n            \"unnamed\": \"Fără denumire\",\n            \"fp_matches\": \"Durația este aceeași\",\n            \"duration_off\": \"Durația diferă cu cel puțin {number}s\"\n        },\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"noun_query\": \"Căutare\"\n    },\n    \"config\": {\n        \"about\": {\n            \"check_for_new_version\": \"Verifică pentru o versiune nouă\",\n            \"latest_version\": \"Ultima Versiune\",\n            \"new_version_notice\": \"[NOU]\",\n            \"stash_discord\": \"Alăturați-vă {url} canalului nostru\",\n            \"stash_open_collective\": \"Susține-ne prin {url}\",\n            \"version\": \"Versiune\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Cale Aplicație\"\n        },\n        \"categories\": {\n            \"about\": \"Despre\",\n            \"interface\": \"Interfață\",\n            \"metadata_providers\": \"Furnizori Metadate\",\n            \"plugins\": \"Plugin-uri\",\n            \"security\": \"Securitate\",\n            \"services\": \"Servicii\",\n            \"system\": \"Sistem\",\n            \"tasks\": \"Sarcini\",\n            \"tools\": \"Unelte\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Permite {tempIP}\",\n            \"allowed_ip_addresses\": \"Adrese IP permise\",\n            \"allowed_ip_temporarily\": \"IP permis temporar\",\n            \"default_ip_whitelist_desc\": \"Adresele IP implicite permit accesul la DLNA. Utilizați {wildcard} pentru a permite toate adresele IP.\",\n            \"disabled_dlna_temporarily\": \"Dezactivat temporar DLNA\",\n            \"disallowed_ip\": \"IP nepermis\",\n            \"enabled_by_default\": \"Activat în mod implicit\",\n            \"enabled_dlna_temporarily\": \"Activat DLNA temporar\",\n            \"network_interfaces\": \"Intefețe\",\n            \"network_interfaces_desc\": \"Interfețe pentru a expune serverul DLNA. O listă goală are ca rezultat rularea pe toate interfețele. Necesită repornirea DLNA după modificare.\",\n            \"recent_ip_addresses\": \"Adrese IP recente\",\n            \"successfully_cancelled_temporary_behaviour\": \"S-a anulat cu succes comportamentul temporar\",\n            \"until_restart\": \"până la repornire\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"Cheie API\",\n                \"api_key_desc\": \"Cheia API pentru sistemele externe. Necesară numai atunci când este configurat numele de utilizator/parola. Numele de utilizator trebuie salvat înainte de a genera cheia API.\",\n                \"authentication\": \"Autentificare\",\n                \"clear_api_key\": \"Ștergeți cheia API\",\n                \"credentials\": {\n                    \"description\": \"Credențiale pentru a restricționa accesul la Stash.\",\n                    \"heading\": \"Credențiale\"\n                },\n                \"generate_api_key\": \"Generează cheie API\"\n            },\n            \"cache_path_head\": \"Cale Cache\",\n            \"calculate_md5_and_ohash_label\": \"Calculați MD5 pentru videouri\"\n        },\n        \"tasks\": {\n            \"auto_tagging\": \"Etichetare Automată\",\n            \"cleanup_desc\": \"Verifică dacă există fișiere lipsă și le elimină din baza de date. Aceasta este o acțiune distructivă.\"\n        },\n        \"tools\": {\n            \"scene_filename_parser\": {\n                \"capitalize_title\": \"Capitalizați titlul\"\n            }\n        },\n        \"ui\": {\n            \"basic_settings\": \"Setări de bază\",\n            \"handy_connection\": {\n                \"connect\": \"Conectare\"\n            },\n            \"preview_type\": {\n                \"options\": {\n                    \"animated\": \"Imagine Animată\"\n                }\n            }\n        }\n    },\n    \"configuration\": \"Configurație\",\n    \"country\": \"Țară\",\n    \"cover_image\": \"Imagine de Copertă\",\n    \"created_at\": \"Creat La\",\n    \"dialogs\": {\n        \"delete_confirm\": \"Ești sigur ca vrei să ștergi {entityName}?\",\n        \"scene_gen\": {\n            \"force_transcodes_tooltip\": \"În mod implicit, transcodurile sunt generate numai atunci când fișierul video nu este acceptat în browser. Atunci când este activată, transcodurile vor fi generate chiar și atunci când fișierul video pare a fi acceptat în browser.\",\n            \"image_previews\": \"Imagini animate de previzualizare\",\n            \"image_previews_tooltip\": \"Previzualizări WebP animate, necesare numai dacă tipul de previzualizare este setat pe Imagine animată.\"\n        }\n    },\n    \"director\": \"Director\",\n    \"display_mode\": {\n        \"grid\": \"Grilă\",\n        \"list\": \"Listă\",\n        \"unknown\": \"Necunoscut\",\n        \"wall\": \"Perete\"\n    },\n    \"donate\": \"Donează\",\n    \"dupe_check\": {\n        \"options\": {\n            \"exact\": \"Precis\",\n            \"high\": \"Sus\",\n            \"low\": \"Jos\",\n            \"medium\": \"Mediu\"\n        },\n        \"search_accuracy_label\": \"Precizia căutării\",\n        \"title\": \"Scene Duplicate\"\n    },\n    \"duration\": \"Durată\",\n    \"effect_filters\": {\n        \"aspect\": \"Aspect\",\n        \"blue\": \"Albastru\",\n        \"blur\": \"Blur\",\n        \"brightness\": \"Luminozitate\",\n        \"contrast\": \"Contrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Verde\",\n        \"hue\": \"Nuanța culorii\",\n        \"name\": \"Filtre\",\n        \"name_transforms\": \"Transformări\",\n        \"red\": \"Roșu\",\n        \"reset_filters\": \"Resetează Filtre\",\n        \"reset_transforms\": \"Resetează Transformări\",\n        \"rotate\": \"Rotire\",\n        \"saturation\": \"Saturație\",\n        \"scale\": \"Scală\",\n        \"warmth\": \"Căldură\"\n    },\n    \"empty_server\": \"Adăugați câteva scene pe serverul dvs. pentru a vedea recomandările de pe această pagină.\",\n    \"ethnicity\": \"Etnie\",\n    \"existing_value\": \"valoare existentă\",\n    \"eye_color\": \"Culoarea Ochilor\",\n    \"fake_tits\": \"Sâni falși\",\n    \"false\": \"Fals\",\n    \"favourite\": \"Favorit\",\n    \"file\": \"fișier\",\n    \"file_info\": \"Informații Fișier\",\n    \"files\": \"fișiere\",\n    \"filesize\": \"Dimensiune Fișier\",\n    \"filter\": \"Filtru\",\n    \"filter_name\": \"Nume Filtru\",\n    \"filters\": \"Filtre\",\n    \"framerate\": \"Rata de cadre\",\n    \"frames_per_second\": \"{value} cadre pe secundă\",\n    \"front_page\": {\n        \"types\": {\n            \"saved_filter\": \"Filtru Salvat\"\n        }\n    },\n    \"galleries\": \"Galerii\",\n    \"gallery\": \"Galerie\",\n    \"gallery_count\": \"Numărul de galerii\",\n    \"gender\": \"Gen\",\n    \"gender_types\": {\n        \"FEMALE\": \"Feminin\",\n        \"INTERSEX\": \"Intersex\",\n        \"MALE\": \"Masculin\",\n        \"NON_BINARY\": \"Non-Binar\",\n        \"TRANSGENDER_FEMALE\": \"Femeie Transgender\",\n        \"TRANSGENDER_MALE\": \"Bărbat Transgender\"\n    },\n    \"hair_color\": \"Culoarea Părului\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Se conectează\",\n        \"disconnected\": \"Deconectat\",\n        \"missing\": \"Lipsă\",\n        \"ready\": \"Pregătit\",\n        \"syncing\": \"Se sincronizează cu serverul\"\n    },\n    \"height\": \"Inălțime\",\n    \"help\": \"Ajutor\",\n    \"ignore_auto_tag\": \"Ignoră Etichetele Automate\",\n    \"image\": \"Imagine\",\n    \"image_count\": \"Număr Imagine\",\n    \"images\": \"Imagini\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interactiv\",\n    \"interactive_speed\": \"Viteză interactivă\",\n    \"isMissing\": \"Lipsește\",\n    \"library\": \"Librărie\",\n    \"loading\": {\n        \"generic\": \"Se încarcă…\"\n    },\n    \"markers\": \"Marcatori\",\n    \"measurements\": \"Măsurători\",\n    \"media_info\": {\n        \"audio_codec\": \"Codec Audio\",\n        \"interactive_speed\": \"Viteză Interactivă\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} în această scenă\"\n        },\n        \"phash\": \"PHash\",\n        \"stream\": \"Flux\",\n        \"video_codec\": \"Codec Video\"\n    },\n    \"megabits_per_second\": \"{value} megabiți pe secundă\",\n    \"metadata\": \"Metadate\",\n    \"name\": \"Nume\",\n    \"new\": \"Nou\",\n    \"operations\": \"Operațiuni\",\n    \"organized\": \"Organizat\",\n    \"pagination\": {\n        \"first\": \"Primul\",\n        \"last\": \"Ultimul\",\n        \"next\": \"Următorul\",\n        \"previous\": \"Anterior\"\n    },\n    \"parent_of\": \"Părintele {children}\",\n    \"path\": \"Cale\",\n    \"performer\": \"Interpret\",\n    \"performer_age\": \"Vârstă Interpret\",\n    \"performer_image\": \"Imagine Interpret\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Adaugă Interpreți Noi\",\n        \"current_page\": \"Pagina curentă\",\n        \"failed_to_save_performer\": \"Nu s-a reușit salvarea interpretului \\\"{performer}\\\"\",\n        \"name_already_exists\": \"Numele există deja\",\n        \"network_error\": \"Eroare Rețea\",\n        \"no_results_found\": \"Nu s-a găsit niciun rezultat.\",\n        \"performer_already_tagged\": \"Interpret deja etichetat\",\n        \"performer_successfully_tagged\": \"Interpretul a fost etichetat cu succes:\",\n        \"query_all_performers_in_the_database\": \"Toti interpreții din baza de date\",\n        \"refresh_tagged_performers\": \"Reîmprospătați interpeții etichetați\",\n        \"tag_status\": \"Stare Etichetă\",\n        \"untagged_performers\": \"Interpreți neetichetați\",\n        \"update_performer\": \"Actualizați Interpret\",\n        \"update_performers\": \"Actualizați Interpreți\"\n    },\n    \"performer_tags\": \"Eticheta Interpret\",\n    \"performers\": \"Interpreți\",\n    \"piercings\": \"Piercing-uri\",\n    \"queue\": \"Coadă\",\n    \"random\": \"Aleatoriu\",\n    \"rating\": \"Evaluare\",\n    \"recently_added_objects\": \"Recent Adăugat {objects}\",\n    \"recently_released_objects\": \"Recent lansate {obiecte}\",\n    \"resolution\": \"Rezoluție\",\n    \"scene\": \"Scenă\",\n    \"scene_id\": \"ID Scenă\",\n    \"scenes\": \"Scene\",\n    \"scenes_updated_at\": \"Scenă Actualizată La\",\n    \"search_filter\": {\n        \"name\": \"Filtru\",\n        \"saved_filters\": \"Filtre Salvate\",\n        \"update_filter\": \"Actualizează Filtru\"\n    },\n    \"seconds\": \"Secunde\",\n    \"settings\": \"Setări\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Suntem aproape gata să finalizăm configurația. Vă rugăm să confirmați următoarele setări. Puteți face clic înapoi pentru a modifica orice lucru incorect. Dacă totul pare în regulă, faceți clic pe Confirm (Confirmare) pentru a vă crea sistemul.\",\n            \"configuration_file_location\": \"Locația fișierului de configurare:\",\n            \"database_file_path\": \"Cale fișier bază de date\",\n            \"generated_directory\": \"Director generat\",\n            \"nearly_there\": \"Aproape de final!\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Crearea sistemului dvs\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"O nu! Ceva nu a mers bine!\",\n            \"something_went_wrong_description\": \"Dacă aceasta pare a fi o problemă cu intrările dvs., dați click înapoi pentru a le repara. În caz contrar, creați un bug pe {githubLink} sau căutați ajutor în {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Ceva nu a mers bine in timp ce configuram sistemul tău. Aceasta e eroarea: {error}\"\n        },\n        \"github_repository\": \"Repozitoriu Github\",\n        \"success\": {\n            \"open_collective\": \"Consultați {open_collective_link} pentru a vedea cum puteți contribui la dezvoltarea continuă a Stash.\"\n        }\n    },\n    \"stash_id\": \"ID Stash\",\n    \"stash_ids\": \"ID-uri Stash\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Mergi la {endpoint_name} pentru a revizui schița.\",\n        \"submission_failed\": \"Trimiterea a eșuat\",\n        \"submission_successful\": \"Depunere reușită\",\n        \"submit_update\": \"Deja există în {endpoint_name}\"\n    },\n    \"statistics\": \"Statistici\",\n    \"stats\": {\n        \"scenes_duration\": \"Durată scene\",\n        \"scenes_size\": \"Dimensiuni scene\"\n    },\n    \"status\": \"Stare: {statusText}\",\n    \"studio\": \"Studio\",\n    \"studio_depth\": \"Niveluri (lasați liber pentru toate)\",\n    \"studios\": \"Studiouri\",\n    \"synopsis\": \"Sinopsis\",\n    \"tag\": \"Etichetă\",\n    \"tag_count\": \"Număr Etichete\",\n    \"tags\": \"Etichete\",\n    \"tattoos\": \"Tatuaje\",\n    \"title\": \"Titlu\",\n    \"toast\": {\n        \"added_entity\": \"Adăugat {entity}\",\n        \"added_generation_job_to_queue\": \"S-a adăugat sarcina de generare la coadă\",\n        \"created_entity\": \"S-a creat {entity}\",\n        \"generating_screenshot\": \"Se genereaza captura de ecran…\",\n        \"merged_tags\": \"Etichete fuzionate\",\n        \"saved_entity\": \"Salvat {entity}\",\n        \"started_auto_tagging\": \"A început etichetarea automată\",\n        \"started_generating\": \"A început generarea\",\n        \"started_importing\": \"A început importarea\",\n        \"updated_entity\": \"Actualizat {entity}\"\n    },\n    \"total\": \"Total\",\n    \"true\": \"Adevărat\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Tip\",\n    \"updated_at\": \"Actualizat La\",\n    \"url\": \"URL\",\n    \"videos\": \"Videouri\",\n    \"view_all\": \"Vezi Toate\",\n    \"weight\": \"Greutate\",\n    \"years_old\": \"ani\",\n    \"containing_group\": \"Grup aparținător\",\n    \"aliases\": \"Porecle\",\n    \"appears_with\": \"Apare cu\",\n    \"audio_codec\": \"Codec Audio\",\n    \"between_and\": \"și\",\n    \"blobs_storage_type\": {\n        \"database\": \"Bază de date\",\n        \"filesystem\": \"Sistem de fișiere\"\n    },\n    \"captions\": \"Subtitrări\",\n    \"chapters\": \"Capitole\",\n    \"circumcised_types\": {\n        \"CUT\": \"Circumcis\",\n        \"UNCUT\": \"Necircumcis\"\n    },\n    \"circumcised\": \"Circumcizie\",\n    \"age_on_date\": \"{age} la producție\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/ru-RU.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Добавить\",\n        \"add_directory\": \"Добавить Папку\",\n        \"add_entity\": \"Добавить {entityType}\",\n        \"add_to_entity\": \"Добавить к {entityType}\",\n        \"allow\": \"Разрешить\",\n        \"allow_temporarily\": \"Временно разрешить\",\n        \"anonymise\": \"Анонимизировать\",\n        \"apply\": \"Применить\",\n        \"auto_tag\": \"Автоматически пометить тегом\",\n        \"backup\": \"Резервная копия\",\n        \"browse_for_image\": \"Открыть изображение…\",\n        \"cancel\": \"Отмена\",\n        \"clean\": \"Очистить\",\n        \"clear\": \"Очистить\",\n        \"clear_back_image\": \"Очистить заднее изображение\",\n        \"clear_front_image\": \"Убрать переднее изображение\",\n        \"clear_image\": \"Удалить изображение\",\n        \"close\": \"Закрыть\",\n        \"confirm\": \"Подтвердить\",\n        \"continue\": \"Продолжить\",\n        \"create\": \"Создать\",\n        \"create_chapters\": \"Создать раздел\",\n        \"create_entity\": \"Создать {entityType}\",\n        \"create_marker\": \"Создать Маркер\",\n        \"created_entity\": \"Создано {entity_type}: {entity_name}\",\n        \"customise\": \"Настроить\",\n        \"delete\": \"Удалить\",\n        \"delete_entity\": \"Удалить {entityType}\",\n        \"delete_file\": \"Удалить файл\",\n        \"delete_file_and_funscript\": \"Удалить файл (вместе с funscript)\",\n        \"delete_generated_supporting_files\": \"Удалить сгенерированные вспомогательные файлы\",\n        \"disallow\": \"Запретить\",\n        \"download\": \"Скачать\",\n        \"download_anonymised\": \"Скачать анонимно\",\n        \"download_backup\": \"Скачать резервную копию\",\n        \"edit\": \"Изменить\",\n        \"edit_entity\": \"Изменить {entityType}\",\n        \"export\": \"Экспортировать\",\n        \"export_all\": \"Экспортировать все…\",\n        \"find\": \"Найти\",\n        \"finish\": \"Завершить\",\n        \"from_file\": \"Из файла…\",\n        \"from_url\": \"Из ссылки…\",\n        \"full_export\": \"Полный экспорт\",\n        \"full_import\": \"Полный импорт\",\n        \"generate\": \"Сгенерировать\",\n        \"generate_thumb_default\": \"Сгенерировать миниатюру по умолчанию\",\n        \"generate_thumb_from_current\": \"Сгенерировать миниатюру из текущей\",\n        \"hash_migration\": \"миграция хэшей\",\n        \"hide\": \"Скрыть\",\n        \"hide_configuration\": \"Скрыть конфигурацию\",\n        \"identify\": \"Идентифицировать\",\n        \"ignore\": \"Игнорировать\",\n        \"import\": \"Импорт…\",\n        \"import_from_file\": \"Импорт из файла\",\n        \"logout\": \"Выйти\",\n        \"make_primary\": \"Сделать основным\",\n        \"merge\": \"Слияние\",\n        \"migrate_blobs\": \"Перенос блоков\",\n        \"migrate_scene_screenshots\": \"Перенос скриншотов сцены\",\n        \"next_action\": \"Вперёд\",\n        \"not_running\": \"не запущен\",\n        \"open_in_external_player\": \"Открыть во внешнем проигрывателе\",\n        \"open_random\": \"Открыть Случайный\",\n        \"overwrite\": \"Перезаписать\",\n        \"play_random\": \"Воспроизвести случайный файл\",\n        \"play_selected\": \"Воспроизвести выбранные файлы\",\n        \"preview\": \"Предпросмотр\",\n        \"previous_action\": \"Назад\",\n        \"reassign\": \"Переназначить\",\n        \"refresh\": \"Обновить\",\n        \"reload_plugins\": \"Перезагрузить плагины\",\n        \"reload_scrapers\": \"Перезагрузить скрейперы\",\n        \"remove\": \"Удалить\",\n        \"remove_from_gallery\": \"Удалить из галереи\",\n        \"rename_gen_files\": \"Переименовать сгенерированные файлы\",\n        \"rescan\": \"Сканировать заново\",\n        \"reshuffle\": \"Перемешать\",\n        \"running\": \"выполняется\",\n        \"save\": \"Сохранить\",\n        \"save_delete_settings\": \"Использовать эти настройки по умолчанию во время удаления\",\n        \"save_filter\": \"Сохранить фильтр\",\n        \"scan\": \"Сканировать\",\n        \"scrape\": \"Скрейпить\",\n        \"scrape_query\": \"Запрос скрейпера\",\n        \"scrape_scene_fragment\": \"Скрейпить по фрагменту\",\n        \"scrape_with\": \"Скрейпить используя…\",\n        \"search\": \"Поиск\",\n        \"select_all\": \"Выбрать все\",\n        \"select_entity\": \"Выбрать {entityType}\",\n        \"select_folders\": \"Выбрать папки\",\n        \"select_none\": \"Отменить выделение\",\n        \"selective_auto_tag\": \"Выборочная автоматическая метка тегами\",\n        \"selective_clean\": \"Выборочная чистка\",\n        \"selective_scan\": \"Выборочнное сканирование\",\n        \"set_as_default\": \"Установить по умолчанию\",\n        \"set_back_image\": \"Заднее изображение…\",\n        \"set_front_image\": \"Лицевое изображение…\",\n        \"set_image\": \"Выбрать изображение…\",\n        \"show\": \"Показать\",\n        \"show_configuration\": \"Показать конфигурацию\",\n        \"skip\": \"Пропустить\",\n        \"split\": \"Разделить\",\n        \"stop\": \"Остановить\",\n        \"submit\": \"Отправить\",\n        \"submit_stash_box\": \"Отправить в Stash-Box\",\n        \"submit_update\": \"Отправить изменение\",\n        \"swap\": \"Заменить\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Вы уверены что хотите провести очистку? Это удалит информацию из базы данных, вместе с созданным содержимым о всех сценах и галереях, которые больше не находятся в файловой системе.\",\n            \"dry_mode_selected\": \"Выбран режим симуляции. Фактического удаления не будет, только запись в журнал.\",\n            \"import_warning\": \"Вы уверены, что хотите импортировать? Это приведет к удалению базы данных и повторному импорту из экспортированных метаданных.\"\n        },\n        \"temp_disable\": \"Временно отключить…\",\n        \"temp_enable\": \"Временно включить…\",\n        \"unset\": \"Отменить\",\n        \"use_default\": \"Использовать по умолчанию\",\n        \"view_random\": \"Смотреть случайный\",\n        \"reload\": \"Перезагрузить\",\n        \"create_parent_studio\": \"Создать родительскую студию\",\n        \"enable\": \"Включить\",\n        \"encoding_image\": \"Перекодирую изображение…\",\n        \"optimise_database\": \"Оптимизировать базу данных\",\n        \"assign_stashid_to_parent_studio\": \"Присвоить Stash ID для текущей родительской студии и обновить метаданные\",\n        \"add_manual_date\": \"Добавить дату вручную\",\n        \"add_o\": \"Добавить О\",\n        \"add_play\": \"Добавить воспроизведение\",\n        \"choose_date\": \"Выбрать дату\",\n        \"clean_generated\": \"Очистить сгенерированные файлы\",\n        \"clear_date_data\": \"Очистить информацию о дате\",\n        \"copy_to_clipboard\": \"Скопировать в буфер обмена\",\n        \"disable\": \"Выключить\",\n        \"remove_date\": \"Удалить дату\",\n        \"view_history\": \"Смотреть просмотренные\",\n        \"reset_cover\": \"Восстановить обложку по умолчанию\",\n        \"set_cover\": \"Установить как обложку\",\n        \"add_sub_groups\": \"Добавить подгруппы\",\n        \"remove_from_containing_group\": \"Удалить из группы\",\n        \"reset_play_duration\": \"Сбросить время воспроизведения\",\n        \"reset_resume_time\": \"Сбросить точку продолжения\",\n        \"show_results\": \"Показать результаты\",\n        \"sidebar\": {\n            \"toggle\": \"Показать/скрыть боковую панель\",\n            \"open\": \"Открыть панель\",\n            \"close\": \"Закрыть панель\"\n        },\n        \"show_count_results\": \"Показать {count} результат(ов)\",\n        \"play\": \"Воспроизвести\",\n        \"load\": \"Загрузить\",\n        \"load_filter\": \"Загрузить фильтр\",\n        \"add_stash_id\": \"Добавить Stash ID\",\n        \"create_new\": \"Создать новый\"\n    },\n    \"actions_name\": \"Действия\",\n    \"age\": \"Возраст\",\n    \"aliases\": \"Псевдонимы\",\n    \"all\": \"все\",\n    \"also_known_as\": \"Также известна/-ен как\",\n    \"ascending\": \"По возрастанию\",\n    \"average_resolution\": \"Среднее разрешение\",\n    \"between_and\": \"и\",\n    \"birth_year\": \"Год рождения\",\n    \"birthdate\": \"Дата рождения\",\n    \"bitrate\": \"Битрейт\",\n    \"blobs_storage_type\": {\n        \"database\": \"База данных\",\n        \"filesystem\": \"Файловая система\"\n    },\n    \"captions\": \"Субтитры\",\n    \"career_length\": \"Продолжительность карьеры\",\n    \"chapters\": \"Разделы\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Активная инстанция Stash-устройства:\",\n            \"blacklist_desc\": \"Элементы черного списка исключаются из запросов. Обратите внимание, что они являются регулярными выражениями и нечувствительны к регистру. Некоторые символы должны быть экранированы обратной косой чертой: {chars_require_escape}\",\n            \"blacklist_label\": \"Черный список\",\n            \"query_mode_auto\": \"Автоматически\",\n            \"query_mode_auto_desc\": \"Использует метаданные, если они есть, или имя файла\",\n            \"query_mode_dir\": \"Директория\",\n            \"query_mode_dir_desc\": \"Использует только родительский каталог видеофайла\",\n            \"query_mode_filename\": \"Имя файла\",\n            \"query_mode_filename_desc\": \"Использует только название файла\",\n            \"query_mode_label\": \"Режим Очереди\",\n            \"query_mode_metadata\": \"Метаданные\",\n            \"query_mode_metadata_desc\": \"Только используя метадату\",\n            \"query_mode_path\": \"Путь\",\n            \"query_mode_path_desc\": \"Используя полный путь до файла\",\n            \"set_cover_desc\": \"Заменить обложку сцены, если она будет найдена.\",\n            \"set_cover_label\": \"Выставить обложку для данной сцены\",\n            \"set_tag_desc\": \"Прикрепить теги к сцене, перезаписав или соединив с существующими тегами на сцене.\",\n            \"set_tag_label\": \"Установить теги\",\n            \"source\": \"Источник\",\n            \"mark_organized_label\": \"Отметить как Организованную при сохранении\",\n            \"mark_organized_desc\": \"Сразу же отметить сцену как Организованную после нажатия кнопки Сохранить.\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Дублировать элемент чёрного списка\"\n            }\n        },\n        \"noun_query\": \"Запрос\",\n        \"results\": {\n            \"duration_off\": \"Продолжительность отличается не менее чем на {number} сек\",\n            \"duration_unknown\": \"Продолжительность неизвестна\",\n            \"fp_found\": \"{fpCount, plural, =0 {Новых совпадений не найдено} other {# новых совпадений найдено}}\",\n            \"fp_matches\": \"Продолжительность совпадает\",\n            \"fp_matches_multi\": \"Продолжительность совпадает с {matchCount}/{durationsLength} отпечатками файла(-ов)\",\n            \"hash_matches\": \"{hash_type} совпадает\",\n            \"match_failed_already_tagged\": \"Сцена уже помечена\",\n            \"match_failed_no_result\": \"Ничего не найдено\",\n            \"match_success\": \"Сцена успешно помечена\",\n            \"phash_matches\": \"{count} PHashes совпадают\",\n            \"unnamed\": \"Безымянный\"\n        },\n        \"verb_match_fp\": \"Сопоставить отпечатки файла\",\n        \"verb_matched\": \"Совпавший\",\n        \"verb_scrape_all\": \"Убрать всё\",\n        \"verb_submit_fp\": \"Отправить {fpCount, plural, one{# Совпадение} other{# совпадения}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} не назначенные сцены\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Контрольная сумма сборки:\",\n            \"build_time\": \"Дата сборки:\",\n            \"check_for_new_version\": \"Проверить наличие обновления\",\n            \"latest_version\": \"Последняя версия\",\n            \"latest_version_build_hash\": \"Контрольная сумма последний версии сборки:\",\n            \"new_version_notice\": \"[НОВЫЙ]\",\n            \"stash_discord\": \"Присоединяйтесь к нашему {url} серверу\",\n            \"stash_home\": \"Stash страница на {url}\",\n            \"stash_open_collective\": \"Поддержи нас через {url}\",\n            \"stash_wiki\": \"Stash {url} страница\",\n            \"version\": \"Версия\",\n            \"release_date\": \"Дата выхода:\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Пути программы и приложений\"\n        },\n        \"categories\": {\n            \"about\": \"О программе\",\n            \"changelog\": \"Список изменений\",\n            \"interface\": \"Интерфейс\",\n            \"logs\": \"Журнал\",\n            \"metadata_providers\": \"Поставщик метаданных\",\n            \"plugins\": \"Плагины\",\n            \"scraping\": \"Скрейпинг\",\n            \"security\": \"Безопасность\",\n            \"services\": \"Сервисы\",\n            \"system\": \"Система\",\n            \"tasks\": \"Задачи\",\n            \"tools\": \"Инструменты\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Разрешить {tempIP}\",\n            \"allowed_ip_addresses\": \"Разрешенные IP-адреса\",\n            \"allowed_ip_temporarily\": \"Временно разрешенные IP-адреса\",\n            \"default_ip_whitelist\": \"Белый список IP по умолчанию\",\n            \"default_ip_whitelist_desc\": \"Разрешенные IP адреса для доступа к DLNA. Используйте {wildcard}, чтобы разрешить все IP-адреса.\",\n            \"disabled_dlna_temporarily\": \"DLNA временно отключен\",\n            \"disallowed_ip\": \"Запрещенные IP\",\n            \"enabled_by_default\": \"Включено по умолчанию\",\n            \"enabled_dlna_temporarily\": \"DLNA временно включен\",\n            \"network_interfaces\": \"Интерфейсы\",\n            \"network_interfaces_desc\": \"Сетевые интерфейсы на которых сервер DLNA будет доступен. Пустой список приведёт к запуску на всех интерфейсах. Требуется перезапуск DLNA после изменения.\",\n            \"recent_ip_addresses\": \"Последние IP адреса\",\n            \"server_display_name\": \"Отображаемое имя сервера\",\n            \"server_display_name_desc\": \"Отображаемое название сервера DLNA. По умолчанию {server_name}, если не задано.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Временно запущенный сервис DLNA, успешно отключен\",\n            \"until_restart\": \"до перезагрузки\",\n            \"video_sort_order\": \"Порядок сортировки видео по умолчанию\",\n            \"video_sort_order_desc\": \"Порядок сортировки видео по умолчанию.\",\n            \"server_port_desc\": \"Порт DLNA-сервера. После изменения требуется перезапуск DLNA.\",\n            \"server_port\": \"Порт DLNA\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API ключ\",\n                \"api_key_desc\": \"API ключ для внешних систем. Необходимо только когда настроены логин и пароль. Логин должен быть сохранён перед генерацией ключа API.\",\n                \"authentication\": \"Аутентификация\",\n                \"clear_api_key\": \"Стереть ключ API\",\n                \"credentials\": {\n                    \"description\": \"Учетные данные для ограничения доступа к программе.\",\n                    \"heading\": \"Учетные данные пользователя для входа\"\n                },\n                \"generate_api_key\": \"Сгенерировать ключ API\",\n                \"log_file\": \"Файл журнала\",\n                \"log_file_desc\": \"Путь к файлу журнала. Оставьте пустым, чтобы отключить ведение журнала. Требуется перезапуск.\",\n                \"log_http\": \"Вести учет http доступа\",\n                \"log_http_desc\": \"Ведет учет http запросов в окне командной строки. Необходим перезапуск.\",\n                \"log_to_terminal\": \"Вести журнал в командной строке\",\n                \"log_to_terminal_desc\": \"Вывод журнала событий в командную строку помимо сохранения в файл. Всегда включено если ведение журнала в файл отключено. Необходим перезапуск.\",\n                \"maximum_session_age\": \"Максимальное время активной сессии\",\n                \"maximum_session_age_desc\": \"Максимальное время ожидания, в секундах, до истечения срока действия сессии. Требуется перезапуск stash.\",\n                \"password\": \"Пароль\",\n                \"password_desc\": \"Пароль для доступа к Stash. Оставьте пустым для отключения аутентификации\",\n                \"stash-box_integration\": \"Stash-box интеграция\",\n                \"username\": \"Имя пользователя\",\n                \"username_desc\": \"Имя для доступа к Stash. Оставьте пустым для отключения аутентификации\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Местоположение каталога для резервных копий базы данных SQLite\",\n                \"heading\": \"Путь к каталогу резервного копирования\"\n            },\n            \"cache_location\": \"Папка для расположения кэша. Требуется для стриминга HLS (например для устройств Apple) или DASH.\",\n            \"cache_path_head\": \"Путь кэша\",\n            \"calculate_md5_and_ohash_desc\": \"Рассчитать контрольные суммы MD5 в дополнение к oshash. Включение опции приведет к замедлению изначальных сканирований. Для хеширования названий файлов должно быть выбрано - oshash, чтобы выключить расчёт MD5.\",\n            \"calculate_md5_and_ohash_label\": \"Просчитать MD5 для видеофайлов\",\n            \"check_for_insecure_certificates\": \"Проверить на незащищённость сертификата\",\n            \"check_for_insecure_certificates_desc\": \"Некоторые сайты используют небезопасные ssl-сертификаты. Если флажок снят, скрейпер пропускает проверку небезопасных сертификатов и разрешает сбор данных с этих сайтов. Если вы получаете ошибку сертификата при сборе, снимите этот флажок.\",\n            \"chrome_cdp_path\": \"Chrome CDP путь\",\n            \"chrome_cdp_path_desc\": \"Путь к исполняемому файлу Chrome, или удаленный адрес (начинающийся с http:// или https://, к примеру http://localhost:9222/json/version) к экземпляру Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Если включено, генерирует галлереи из папок с картинками.\",\n            \"create_galleries_from_folders_label\": \"Генерация галлереи из папок с картинками\",\n            \"db_path_head\": \"Путь к базе данных\",\n            \"directory_locations_to_your_content\": \"Адреса папок с вашим контентом\",\n            \"excluded_image_gallery_patterns_desc\": \"Регулярные выражения файлов изображений и галерей/путей которые будут исключены при Сканировании, и добавлены в Очистку\",\n            \"excluded_image_gallery_patterns_head\": \"Шаблоны исключений изображений/галерей\",\n            \"excluded_video_patterns_desc\": \"Регулярные выражения видеофайлов/путей которые будут исключены при Сканировании, и добавлены в Очистку\",\n            \"excluded_video_patterns_head\": \"Шаблоны исключенные видео\",\n            \"ffmpeg\": {\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Расширенные настройки: Дополнительные входные параметры для генерации видео при помощи ffmpeg.\",\n                        \"heading\": \"Входные параметры транскодирования FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"heading\": \"Выходные параметры транскодирования FFmpeg\",\n                        \"desc\": \"Расширенные настройки: Дополнительные выходные параметры для генерации видео при помощи ffmpeg.\"\n                    }\n                },\n                \"hardware_acceleration\": {\n                    \"heading\": \"Аппаратное ускорение кодирования FFmpeg\",\n                    \"desc\": \"Использовать доступное оборудование для аппаратного ускорения кодирования видео в реальном времени.\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Расширенные настройки: Параметры для передачи в ffmpeg перед полем ввода при кодировании видео в реальном времени.\",\n                        \"heading\": \"Входные параметры FFmpeg Live Transcode\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Расширенные настройки: Параметры для передачи в ffmpeg перед полем вывода при кодировании видео в реальном времени.\",\n                        \"heading\": \"Выходные параметры FFmpeg Live Transcode\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"Скачивает FFmpeg в каталог конфигурации и очищает пути ffmpeg и ffprobe.\",\n                    \"heading\": \"Скачать FFmpeg\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"Путь к исполнимому файлу FFmpeg\",\n                    \"description\": \"Путь к исполнимому файлу ffmpeg (включая имя файла). При пустом значении, путь будет взят из переменной $PATH, каталога конфигурации или из $HOME/.stash\"\n                },\n                \"ffprobe_path\": {\n                    \"heading\": \"Путь к исполнимому файлу FFprobe\",\n                    \"description\": \"Путь к исполнимому файлу ffprobe (включая имя файла). При пустом значении, путь будет взят из переменной $PATH, каталога конфигурации или из $HOME/.stash\"\n                }\n            },\n            \"gallery_ext_desc\": \"Список расширений через запятую, которые будут распознаны как архивы с картинками.\",\n            \"gallery_ext_head\": \"Расширения архивов галерей\",\n            \"generated_file_naming_hash_desc\": \"Использовать MD5 или oshash для имен сгенерированных файлов. Меняя это, необходимо чтобы у всех сцен имелись соответствующие MD5/oshash значение. После изменения значения, существующие сгенерированные файлы нужно будет перенести или сгенерировать повторно. Подробнее о переносе на странице Задач.\",\n            \"generated_file_naming_hash_head\": \"Хеш значения для имен сгенерированных файлов\",\n            \"generated_files_location\": \"Директория для сгенерированных файлов (маркеры сцен, превью сцен, спрайты, и т. п.)\",\n            \"generated_path_head\": \"Путь к сгенерированному контенту\",\n            \"hashing\": \"Хэширование\",\n            \"image_ext_desc\": \"Список расширений через запятую, которые будут распознаны как картинки.\",\n            \"image_ext_head\": \"Расширения изображений\",\n            \"include_audio_desc\": \"Добавляет аудио дорожку для сгенерированных превью файлов.\",\n            \"include_audio_head\": \"Включать аудио\",\n            \"logging\": \"Ведение журнала\",\n            \"maximum_streaming_transcode_size_desc\": \"Максимальный размер транскодируемых потоков\",\n            \"maximum_streaming_transcode_size_head\": \"Максимальное разрешение потокового транскодирования\",\n            \"maximum_transcode_size_desc\": \"Максимальный размер генерируемых транскодов\",\n            \"maximum_transcode_size_head\": \"Максимальное разрешение транскодирования\",\n            \"metadata_path\": {\n                \"description\": \"Местоположение каталога, используемое при выполнении полного экспорта или импорта\",\n                \"heading\": \"Путь к метаданным\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Установите 0 для автоматического определения. Предупреждение: запуск большего количества задач, чем требуется для достижения 100% загрузки процессора, снизит производительность и может вызвать другие проблемы.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Количество параллельных задач сканирования/генерации\",\n            \"parallel_scan_head\": \"Параллельное сканирование/генерация\",\n            \"preview_generation\": \"Создание превью\",\n            \"python_path\": {\n                \"description\": \"Путь исполняемого файл Python. Используется для скриптов скрейперов и плагинов. Если оставить пустым, путь будет взят из переменной окружения ОС\",\n                \"heading\": \"Путь к исполняемому файлу Python\"\n            },\n            \"scraper_user_agent\": \"User Agent скрейпера\",\n            \"scraper_user_agent_desc\": \"User-Agent используемый во время сбора через http запросы\",\n            \"scrapers_path\": {\n                \"description\": \"Путь к директории, где находятся файлы конфигураций скрейперов\",\n                \"heading\": \"Путь скрейперов\"\n            },\n            \"scraping\": \"Скрейпинг\",\n            \"sqlite_location\": \"Путь к файлу базы данных SQLite (требуется перезапуск). ВНИМАНИЕ: использование базы данных на другом сервере (то есть по удаленно сети) не поддерживается!\",\n            \"video_ext_desc\": \"Список расширений через запятую, которые будут распознаны как видео.\",\n            \"video_ext_head\": \"Расширения видео\",\n            \"video_head\": \"Видео\",\n            \"gallery_cover_regex_desc\": \"Регулярное выражение для идентификации изображения в качестве обложки галереи\",\n            \"gallery_cover_regex_label\": \"Шаблон обложки галереи\",\n            \"heatmap_generation\": \"Генерация тепловых карт Funscript\",\n            \"blobs_path\": {\n                \"description\": \"Место в файловой системе для хранения бинарных данных. Применимо только при использовании файловой системы типа blob. ВНИМАНИЕ: изменение этого параметра требует ручного перемещения существующих данных.\",\n                \"heading\": \"Путь к бинарным данным\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Место хранения бинарных данных, таких как обложки, информация об исполнителях, студиях, тэгах. После изменения данного параметра, существующие данные должны быть перемещены с помощью задачи Миграции Блобов. См. страницу Задачи.\",\n                \"heading\": \"Тип хранилища бинарных данных\"\n            },\n            \"database\": \"База данных\",\n            \"funscript_heatmap_draw_range\": \"Включить диапазон в сгенерированные тепловые карты\",\n            \"funscript_heatmap_draw_range_desc\": \"Нарисовать диапазон движения по оси Y в сгенерированной тепловой карте. Существующие тепловые карты нужно сгенерировать заново после изменения настройки.\",\n            \"plugins_path\": {\n                \"heading\": \"Путь к плагинам\",\n                \"description\": \"Путь к папке конфигурации плагинов\"\n            }\n        },\n        \"library\": {\n            \"exclusions\": \"Исключения\",\n            \"gallery_and_image_options\": \"Параметры галереи и изображений\",\n            \"media_content_extensions\": \"Расширения мультимедиа-содержимого\"\n        },\n        \"logs\": {\n            \"log_level\": \"Уровень ведения журнала\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Триггеры\",\n            \"triggers_on\": \"Срабатывает при\",\n            \"available_plugins\": \"Доступные плагины\",\n            \"installed_plugins\": \"Установленные Плагины\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} метаданные\",\n            \"entity_scrapers\": \"{entityType} Scraper\",\n            \"excluded_tag_patterns_desc\": \"Регулярные выражения для исключения тегов при сборе информации\",\n            \"excluded_tag_patterns_head\": \"Шаблон исключаемых тегов\",\n            \"scraper\": \"Скрейпер\",\n            \"scrapers\": \"Скрейперы\",\n            \"search_by_name\": \"Поиск по названию\",\n            \"supported_types\": \"Поддерживаемые типы\",\n            \"supported_urls\": \"Ссылки\",\n            \"available_scrapers\": \"Доступные Скрейперы\",\n            \"installed_scrapers\": \"Установленные Скрейперы\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Добавить stash-box instance экземпляр\",\n            \"api_key\": \"API ключ\",\n            \"description\": \"Stash-box обеспечивает автоматическую расстановку тегов для сцен и актеров используя данные о файле и его название.\\nКонечная точка и API ключ может быть найдены на странице вашего профиля в stash-box. Название необходимо, если добавляется более одного экземпляра.\",\n            \"endpoint\": \"Конечная точка\",\n            \"graphql_endpoint\": \"Конечная точка GraphQL\",\n            \"name\": \"Имя\",\n            \"title\": \"Конечные точки Stash-box\",\n            \"max_requests_per_minute\": \"Максимальное количество запросов в минуту\",\n            \"max_requests_per_minute_description\": \"Использует значение по умолчанию {defaultValue}, если задано 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Транскодирование\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Добавлено {operation_name} в очередь задач\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Автоматическая расстановка тегов для всех путей\",\n                \"auto_tagging_paths\": \"Автоматическая расстановка тегов для следующих путей\"\n            },\n            \"auto_tag_based_on_filenames\": \"Автоматически помечать тегами контент на основе имен файлов.\",\n            \"auto_tagging\": \"Автоматическая расстановка тегов\",\n            \"backing_up_database\": \"Резервное копирование базы данных\",\n            \"backup_and_download\": \"Создает резервную копию базы данных, и скачивает файл.\",\n            \"cleanup_desc\": \"Проверить наличие отсутствующих файлов и удалить их из базы данных. Данное действие безвозвратно.\",\n            \"data_management\": \"Управление данными\",\n            \"defaults_set\": \"Значения по умолчанию установлены и будут использоваться при нажатии кнопки {action} на странице Задач.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Не включать расширение файла в название\",\n            \"empty_queue\": \"В настоящее время задачи не выполняются.\",\n            \"export_to_json\": \"Экспортировать содержимое базы данных в JSON формате в директорию метаданных.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Генерация для сцен из следующих путей\",\n                \"generating_scenes\": \"Генерация для {num} {scene}\"\n            },\n            \"generate_desc\": \"Создавать вспомогательные изображения, спрайты, видео, vtt и другие файлы.\",\n            \"generate_phashes_during_scan\": \"Генерировать воспринимаемые хэши (phash)\",\n            \"generate_phashes_during_scan_tooltip\": \"Для дедупликации и идентификации сцен.\",\n            \"generate_previews_during_scan\": \"Создавать превью в виде анимированных изображений\",\n            \"generate_previews_during_scan_tooltip\": \"Создавать анимированные (WebP) превью. Только в случае, если тип превью настроен на WebP. Данный формат использует меньше системных ресурсов для просмотра, но создается дополнительно к видео превью, и занимает больше места.\",\n            \"generate_sprites_during_scan\": \"Генерировать спрайты для полосы перемотки видео\",\n            \"generate_thumbnails_during_scan\": \"Создать миниатюры для изображений\",\n            \"generate_video_previews_during_scan\": \"Создавать превью\",\n            \"generate_video_previews_during_scan_tooltip\": \"Создавать превью видео, которые воспроизводятся при наведении курсора на сцену\",\n            \"generated_content\": \"Сгенерированный контент\",\n            \"identify\": {\n                \"and_create_missing\": \"и создать недостающие\",\n                \"create_missing\": \"Создать недостающие\",\n                \"default_options\": \"Настройки по умолчанию\",\n                \"description\": \"Автоматически добавлять метаданные к сценам используя stash-box и источники скрейперов.\",\n                \"explicit_set_description\": \"Следующие параметры будут использоваться там, где они не переопределены в параметрах источника.\",\n                \"field\": \"Поле\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Параметры поля\",\n                \"heading\": \"Идентифицировать\",\n                \"identifying_from_paths\": \"Определение сцен по следующим путям\",\n                \"identifying_scenes\": \"Определение {num} {scene}\",\n                \"include_male_performers\": \"Включать мужских исполнителей\",\n                \"set_cover_images\": \"Установить изображения обложки\",\n                \"set_organized\": \"Установить флаг \\\"Упорядочен\\\"\",\n                \"source\": \"Источник\",\n                \"source_options\": \"{source} Опции\",\n                \"sources\": \"Источники\",\n                \"strategy\": \"Стратегия\",\n                \"tag_skipped_performer_tooltip\": \"Присвоить тег вида: 'Identify: Single Name Performer', который может использоваться для фильтра в Теггере Сцен для выбора правильного варианта вручную\",\n                \"skip_multiple_matches\": \"Пропускать совпадение с несколькими результатами\",\n                \"skip_multiple_matches_tooltip\": \"Если не активировать эту опцию, то при совпадении метаданных сцены с несколькими результатами будет выбран случайный вариант\",\n                \"skip_single_name_performers_tooltip\": \"Если не активировать эту опцию, то в данные сцены добавятся имена актеров вроде Samantha или Olga без привязки к конкретному исполнителю\",\n                \"tag_skipped_matches_tooltip\": \"Присвоить тег вида: 'Identify: Multiple Matches', который может использоваться для фильтра в Теггере Сцен для выбора правильного варианта вручную\",\n                \"tag_skipped_matches\": \"Тег для пропущенных совпадений\",\n                \"tag_skipped_performers\": \"Тег для пропущенных имен исполнителей\",\n                \"skip_single_name_performers\": \"Пропускать псевдонимы исполнителей из одного слова (например Olga)\"\n            },\n            \"import_from_exported_json\": \"Импортировать экспортированный JSON в каталог метаданных. Сотрет существующую базу данных.\",\n            \"incremental_import\": \"Инкрементальный импорт из предоставленного ZIP-файла экспорта.\",\n            \"job_queue\": \"Очередь задач\",\n            \"maintenance\": \"Техническое обслуживание\",\n            \"migrate_hash_files\": \"Используется после изменения параметра \\\"Хеш значения для имен сгенерированных файлов\\\" для изменения названий уже сгенерированных файлов в новый хеш формат.\",\n            \"migrations\": \"Миграции\",\n            \"only_dry_run\": \"Выполнить только пробный прогон. Пользовательские файлы останутся нетронутыми, ничего не будет удалено\",\n            \"plugin_tasks\": \"Задачи плагинов\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Сканирование всех путей\",\n                \"scanning_paths\": \"Сканирование следующих путей\"\n            },\n            \"scan_for_content_desc\": \"Поиск нового контента и добавление его в базу данных.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Задать имя, дату, детали из встроенных метаданных файла\",\n            \"optimise_database\": \"Попытаться улучшить производительность путем анализа и перестройки базы данных.\",\n            \"migrate_scene_screenshots\": {\n                \"description\": \"Переместить скриншоты сцен в новую систему хранения. Данную задачу необходимо выполнить после обновления существующей системы хранения до версии 0.20. Старые скриншоты можно удалить после перемещения.\",\n                \"overwrite_existing\": \"Перезаписать существующие скриншоты\",\n                \"delete_files\": \"Удалить скриншоты\"\n            },\n            \"migrate_blobs\": {\n                \"description\": \"Переместить бинарные данные в текущую систему хранения. Данную задачу необходимо выполнить после изменения системы хранения бинарных данных. Старые данные можно удалить после перемещения.\",\n                \"delete_old\": \"Удалить старые данные\"\n            },\n            \"optimise_database_warning\": \"Внимание: во время работы этой задачи любые операции по модификации базы данных не выполнятся. В зависимости от размера базы данных выполнение задача может занять до нескольких минут. Для выполнения задачи также необходимо свободное место на диске, равное размеру БД (рекомендуется в 1.5 раза больше).\",\n            \"anonymise_and_download\": \"Создать анонимную копию базы данных и скачать файл с результатами.\",\n            \"generate_clip_previews_during_scan\": \"Создавать превью для сцен\",\n            \"generate_video_covers_during_scan\": \"Создать обложки сцен\",\n            \"generate_sprites_during_scan_tooltip\": \"Скриншоты сцены, отображаемые под проигрывателем видео, для быстрой навигации.\",\n            \"anonymising_database\": \"Анонимизировать базу данных\",\n            \"anonymise_database\": \"Создать копию базы данных в папке с резервными копиями с анонимизацией всех чувствительных данных. Данная копия может быть предоставлена третьим лицам с целью поиска ошибок. Изменения в рабочую базу данных внесены не будут. Формат имени анонимной резервной копии базы данных {filename_format}.\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blob файлы\",\n                \"description\": \"Удалить созданные файлы без отображения в базе данных.\",\n                \"image_thumbnails\": \"Превью изображений\",\n                \"image_thumbnails_desc\": \"Превью изображений и сцен\",\n                \"markers\": \"Превью Маркеров\",\n                \"previews\": \"Превью Сцен\",\n                \"previews_desc\": \"Превью сцен и изображений\",\n                \"sprites\": \"Спрайты Сцен\",\n                \"transcodes\": \"Транскоды Сцен\"\n            },\n            \"rescan\": \"Пересканировать файлы\",\n            \"rescan_tooltip\": \"Повторно просканировать все файлы в пути. Используется для обновления метаданных и сканирования ZIP-файлов.\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Проверка сцен на дубликаты\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Добавить поле\",\n                \"capitalize_title\": \"Название с заглавной буквы\",\n                \"display_fields\": \"Отобразить поля\",\n                \"escape_chars\": \"Используйте \\\\ для экранирования буквенных символов\",\n                \"filename\": \"Имя файла\",\n                \"filename_pattern\": \"Шаблон имени файла\",\n                \"ignore_organized\": \"Игнорировать уже упорядоченные сцены\",\n                \"ignored_words\": \"Игнорируемые слова\",\n                \"matches_with\": \"Совпадает с {i}\",\n                \"select_parser_recipe\": \"Выбрать \\\"рецепт\\\" анализатора\",\n                \"title\": \"Анализатор названий файлов для сцен\",\n                \"whitespace_chars\": \"Символы пробелов\",\n                \"whitespace_chars_desc\": \"Эти символы будут заменены пробелами в названии\"\n            },\n            \"scene_tools\": \"Инструменты видео\",\n            \"graphql_playground\": \"Песочница GraphQL\",\n            \"heading\": \"Инструменты\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Сократить счетчики на страницах карточек и детального просмотра, например, «1831» сокращенно будет отображено как «1,8K».\",\n                \"heading\": \"Сокращенные счетчиков количества\"\n            },\n            \"basic_settings\": \"Основные настройки\",\n            \"custom_css\": {\n                \"description\": \"Чтобы изменения вступили в силу, необходимо перезагрузить страницу.\",\n                \"heading\": \"Пользовательский CSS\",\n                \"option_label\": \"Пользовательский CSS включен\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Страница должна быть перезагружена для применений изменений.\",\n                \"heading\": \"Пользовательский Javascript\",\n                \"option_label\": \"Пользовательский Javascript включен\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Замена отдельных строк локализации. Смотрите https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json для основного списка. Чтобы изменения вступили в силу, необходимо перезагрузить страницу.\",\n                \"heading\": \"Пользовательская локализация\",\n                \"option_label\": \"Пользовательская локализация включена\"\n            },\n            \"delete_options\": {\n                \"description\": \"Настройки по умолчанию при удалении изображений, галерей и сцен.\",\n                \"heading\": \"Опции удаления\",\n                \"options\": {\n                    \"delete_file\": \"По умолчанию удалить файлы\",\n                    \"delete_generated_supporting_files\": \"По умолчанию удалять сгенерированные вспомогательные файлы\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Интеграция с рабочим столом\",\n                \"notifications_enabled\": \"Включить уведомления\",\n                \"send_desktop_notifications_for_events\": \"Отправка уведомлений на рабочий стол о событиях\",\n                \"skip_opening_browser\": \"Не открывать страницу в браузере\",\n                \"skip_opening_browser_on_startup\": \"Пропустить автоматическое открытие страницы в браузере при запуске\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Убрать возможность создавать новые объекты из выпадающих селекторов\",\n                    \"heading\": \"Отключить создание из раскрывающегося списка\"\n                },\n                \"heading\": \"Редактирование\",\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Точность системы оценок\",\n                        \"options\": {\n                            \"full\": \"Полный\",\n                            \"half\": \"Половина\",\n                            \"quarter\": \"Четверть\",\n                            \"tenth\": \"1/10\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Тип рейтинговой системы\",\n                        \"options\": {\n                            \"decimal\": \"Десятичный\",\n                            \"stars\": \"Звезды\"\n                        }\n                    }\n                },\n                \"max_options_shown\": {\n                    \"label\": \"Максимальное количество элементов для отображения в выпадающем списке\"\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Смещение времени в миллисекундах для воспроизведения интерактивных скриптов.\",\n                \"heading\": \"Funscript смещение (миллисекунды)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Подключить\",\n                \"server_offset\": {\n                    \"heading\": \"Серверная компенсация\"\n                },\n                \"status\": {\n                    \"heading\": \"Статус соединения Handy\"\n                },\n                \"sync\": \"Синхронизировать\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Ключ соединения с сервисом Handy используемый для интерактивных сцен. Установка этого ключа позволит Stash делится информацией о вашей текущий сценой с сайтом handyfeeling.com\",\n                \"heading\": \"Ключ подключения Handy\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Окно просмотра изображений\"\n            },\n            \"images\": {\n                \"heading\": \"Изображения\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Записывать эскизы изображений на диск сразу при создании на лету\",\n                        \"heading\": \"Сохранить миниатюры изображений\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"При отключении видео в библиотеке, видеофайлы (с расширениями файлов видео форматов) будут сканированы как галереи изображений.\",\n                        \"heading\": \"Сканировать файлы с видео расширениями как галереи изображений\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Интерактивные опции\",\n            \"language\": {\n                \"heading\": \"Язык\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Максимальная продолжительность сцены, при которой проигрыватель сцен будет зацикливать видео - 0 для отключения\",\n                \"heading\": \"Максимальная продолжительность повторяющегося сегмента\"\n            },\n            \"menu_items\": {\n                \"description\": \"Показать или скрыть различные типы контента на панели навигации\",\n                \"heading\": \"Пункты меню\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Процентная часть сцены, после которой счетчик воспроизведения будет увеличен.\",\n                \"heading\": \"Минимальный процент проигрывания\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Пользовательский путь к изображениям актеров. Оставьте пустым, чтобы использовать стандартные значения\",\n                        \"heading\": \"Пользовательский путь к изображениям актеров\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Опция по умолчанию - видео (mp4) превью. Для уменьшения нагрузки можно использовать превью в виде webp. Их необходимо генерировать в дополнение к видео превью, что увеличит размеры файлов.\",\n                \"heading\": \"Тип предварительного просмотра\",\n                \"options\": {\n                    \"animated\": \"Анимированное изображение\",\n                    \"static\": \"Статичное изображение\",\n                    \"video\": \"Видео\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Сеточный вид\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Отображать названия студии как текст\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Проигрыватель видео\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Всегда начинать воспроизведение видео сначало\",\n                    \"auto_start_video\": \"Автозапуск видео\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Автоматически запускать видео из очереди или при воспроизведении выбранных или случайных видео со страницы \\\"Сцены\\\"\",\n                        \"heading\": \"Автоматическое воспроизведение выбранных видео\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Запускать следующую сцену в очереди после окончания видео\",\n                        \"heading\": \"Продолжить плейлист по умолчанию\"\n                    },\n                    \"show_scrubber\": \"Показывать полосу перемотки видео с превью (Show Scrubber)\",\n                    \"track_activity\": \"Отслеживать активность\",\n                    \"enable_chromecast\": \"Включить Chromecast\",\n                    \"show_ab_loop_controls\": \"Отобразить управление плагином AB Loop\",\n                    \"vr_tag\": {\n                        \"description\": \"Кнопка VR будет показана только для сцен с данным тегом.\",\n                        \"heading\": \"Тег VR\"\n                    },\n                    \"disable_mobile_media_auto_rotate\": \"Отключить автоповорот в полноэкранном режиме на мобильных устройствах\",\n                    \"show_range_markers\": \"Показать маркеры диапазона\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Стена сцен / маркеров\",\n                \"options\": {\n                    \"display_title\": \"Отображать названия и теги\",\n                    \"toggle_sound\": \"Включить звук\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Количество попыток прокрутки перед переходом к следующему/предыдущему элементу. Применяется только для режима прокрутки наклона Y.\",\n                \"heading\": \"Количество попыток прокрутки до перехода\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Показывать карточку тега, при наведении курсора на значок тега\",\n                \"heading\": \"Подсказки к карточкам тегов\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Слайд-шоу доступно в галереях, в режиме просмотра стены\",\n                \"heading\": \"Задержка показа слайдов (сек.)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Просмотр Студий\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"В режиме просмотра студий, показывать контент и из под-студий\",\n                        \"heading\": \"Показывать контент под-студий\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Вид тегов\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"В режиме тегов, также будет показан контент и от подтегов\",\n                        \"heading\": \"Отображать контент под-тегов\"\n                    }\n                }\n            },\n            \"title\": \"Пользовательский интерфейс\",\n            \"detail\": {\n                \"enable_background_image\": {\n                    \"heading\": \"Включить подложку\",\n                    \"description\": \"Отображать подложку на странице Подробной информации.\"\n                },\n                \"show_all_details\": {\n                    \"description\": \"Отобразить всю доступную информацию по умолчанию в виде таблицы\",\n                    \"heading\": \"Показать всю информацию\"\n                },\n                \"heading\": \"Страница Подробная информация\",\n                \"compact_expanded_details\": {\n                    \"description\": \"Отобразить расширенную информацию в компактном представлении\",\n                    \"heading\": \"Сократить расширенную информацию\"\n                }\n            },\n            \"image_wall\": {\n                \"heading\": \"Стена изображений\",\n                \"direction\": \"Направление\",\n                \"margin\": \"Отступ (в пикселях)\"\n            },\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"Активация работы funscript напрямую из Stash для ваших Handy устройств без использования дополнительных серверов Handy. Необходимо, чтобы Stash был доступен с устройства Handy.\",\n                \"heading\": \"Прямое управление funscripts\"\n            }\n        },\n        \"advanced_mode\": \"Продвинутый Режим\"\n    },\n    \"configuration\": \"Настройки\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Файл} other {Файлы}}\",\n        \"galleries\": \"{count, plural, one {Галерея} other {Галереи}}\",\n        \"images\": \"{count, plural, one {Изображение} other {Изображения}}\",\n        \"markers\": \"{count, plural, one {Маркер} other {Маркеры}}\",\n        \"performers\": \"{count, plural, one {Актер} other {Актеры}}\",\n        \"scenes\": \"{count, plural, one {Сцена} other {Сцены}}\",\n        \"studios\": \"{count, plural, one {Студия} other {Студии}}\",\n        \"tags\": \"{count, plural, one {Тег} other {Теги}}\",\n        \"groups\": \"{count, plural, one {Группа} other {Группы}}\"\n    },\n    \"country\": \"Страна\",\n    \"cover_image\": \"Изображение обложки\",\n    \"created_at\": \"Создано\",\n    \"criterion\": {\n        \"greater_than\": \"Больше чем\",\n        \"less_than\": \"Меньше чем\",\n        \"value\": \"Значение\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"между\",\n        \"equals\": \"есть\",\n        \"excludes\": \"исключает\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"больше, чем\",\n        \"includes\": \"включает\",\n        \"includes_all\": \"включает все\",\n        \"is_null\": \"is null\",\n        \"less_than\": \"меньше чем\",\n        \"matches_regex\": \"соответствует регулярному выражению\",\n        \"not_between\": \"не между\",\n        \"not_equals\": \"является не\",\n        \"not_matches_regex\": \"не соответствует регулярному выражению\",\n        \"not_null\": \"не пуст\",\n        \"format_string_excludes_depth\": \"{критерий} {строкаМодификатор} {строкаЗначений} (excludes {строкаИсключений}) (+{depth, plural, =-1 {все} other {{глубина}}})\",\n        \"format_string_depth\": \"{критерий} {строкаМодификатор} {строкаЗначений} (+{depth, plural, =-1 {все} other {{глубина}}})\",\n        \"format_string_excludes\": \"{критерий} {строкаМодификатор} {строкаЗначение} (excludes {строкаИсключение})\"\n    },\n    \"custom\": \"Пользовательский\",\n    \"date\": \"Дата\",\n    \"death_date\": \"Дата смерти\",\n    \"death_year\": \"Год смерти\",\n    \"descending\": \"По убыванию\",\n    \"description\": \"Описание\",\n    \"detail\": \"Дополнительная информация\",\n    \"details\": \"Подробности\",\n    \"developmentVersion\": \"Версия разработки\",\n    \"dialogs\": {\n        \"create_new_entity\": \"Создать новую запись в {entity}\",\n        \"delete_alert\": \"Следующие {count, plural, one {{singularEntity}} other {{pluralEntity}}} будут удалены безвозвратно:\",\n        \"delete_confirm\": \"Вы уверены что хотите удалить {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Вы уверены, что хотите удалить этот {singularEntity}? Если файл также не будет удален, этот {singularEntity} будет повторно добавлен при сканировании.} other {Вы уверены, что хотите удалить эти {pluralEntity}? Если файлы также не будут удалены, эти {pluralEntity} будут повторно добавлены при выполнении сканирования.}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Вы уверены, что хотите удалить {singularEntity}?} other {Вы уверены, что хотите удалить эти {pluralEntity}?}}\",\n        \"delete_entity_title\": \"{count, plural, one {Удалить {singularEntity}} other {Удалить {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"…плюс любые файлы изображений, не прикрепленные ни к какой другой галерее.\",\n        \"delete_gallery_files\": \"Удалите папку/архив галереи и любые изображения, не прикрепленные к какой-либо другой галерее.\",\n        \"delete_object_desc\": \"Вы уверены что хотите удалить {count, plural, one {эту {singularEntity}} other {эти{pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…и {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Удалить {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Не показывать до следующего обновления\",\n        \"edit_entity_title\": \"Редактировать {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Включить связанные объекты в экспорт\",\n        \"export_title\": \"Экспорт\",\n        \"lightbox\": {\n            \"delay\": \"Задержка (сек)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Выровнять по горизонтали\",\n                \"fit_to_screen\": \"По размеру экрана\",\n                \"label\": \"Режим отображения\",\n                \"original\": \"Оригинал\"\n            },\n            \"options\": \"Параметры\",\n            \"reset_zoom_on_nav\": \"Сбросить масштабирование при смене изображения\",\n            \"scale_up\": {\n                \"description\": \"Масштабировать небольшие изображения до заполнения экрана\",\n                \"label\": \"Масштабировать, чтобы уместить\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Зажмите shift чтобы временно использовать другой режим.\",\n                \"label\": \"Режим прокрутки\",\n                \"pan_y\": \"Наклон по Y\",\n                \"zoom\": \"Увеличить\"\n            },\n            \"page_header\": \"Страница {page} / {total}\"\n        },\n        \"merge\": {\n            \"destination\": \"Цель\",\n            \"empty_results\": \"Значения в полях целевого файла останутся без изменений.\",\n            \"source\": \"Источник\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {Переназначить {singularEntity}} other {Переназначить {pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Переназначить на\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Принудительно создавать перекодированные файлы\",\n            \"force_transcodes_tooltip\": \"По умолчанию перекодированные файлы генерируется только в том случае, если видеофайл не поддерживается браузером. Если этот параметр включен, перекодирование будет генерироваться, даже если видеофайл поддерживается браузером.\",\n            \"image_previews\": \"Анимированные изображения как превью\",\n            \"image_previews_tooltip\": \"Создавать анимированные (WebP) превью. Только если в качестве типа предпросмотра выбран - Анимированные изображения. WebP использует меньше ресурсов системы во время просмотра, но занимает больше места на диске.\",\n            \"interactive_heatmap_speed\": \"Создание тепловых карт и скоростей скриптов (funscript) для интерактивных сцен\",\n            \"marker_image_previews\": \"Анимированный предварительный просмотр для маркеров\",\n            \"marker_image_previews_tooltip\": \"Создавать анимированные (WebP) превью. Только если в качестве типа предпросмотра выбран - Анимированные изображения. WebP использует меньше ресурсов системы во время просмотра, но занимает больше места на диске.\",\n            \"marker_screenshots\": \"Скриншоты для маркеров\",\n            \"marker_screenshots_tooltip\": \"Статические JPG изображения маркеров\",\n            \"markers\": \"Превью маркера\",\n            \"markers_tooltip\": \"20-секундные видео, которые начинаются с заданного тайм-кода.\",\n            \"override_preview_generation_options\": \"Пользовательские параметры превью видео файлов\",\n            \"override_preview_generation_options_desc\": \"Пользовательские параметры для создания превью. Значения по умолчанию находятся в Система -> Создание Превью.\",\n            \"overwrite\": \"Перезаписать существующие сгенерированные файлы\",\n            \"phash\": \"Перцептуальные хэши phash\",\n            \"preview_exclude_end_time_desc\": \"Исключить последние x секунд из предварительного просмотра сцен. Это может быть значение в секундах или процент (например, 2%) от общей продолжительности сцены.\",\n            \"preview_exclude_end_time_head\": \"Исключенное время с конца файла\",\n            \"preview_exclude_start_time_desc\": \"Исключить первые x секунд из предварительного просмотра сцен. Это может быть значение в секундах или процент (например, 2%) от общей продолжительности сцены.\",\n            \"preview_exclude_start_time_head\": \"Исключенное время с начала файла\",\n            \"preview_generation_options\": \"Параметры создания\\\\генерации превью\",\n            \"preview_options\": \"Параметры превью\",\n            \"preview_preset_desc\": \"Предустановка влияет на размер, качество и время кодирования для создания файлов предварительного просмотра. Предустановки с значением выше «slow» имеют незначительные преимущества и не рекомендуются.\",\n            \"preview_preset_head\": \"Настройка кодировки предварительного просмотра\",\n            \"preview_seg_count_desc\": \"Количество сегментов в файлах предварительного просмотра.\",\n            \"preview_seg_count_head\": \"Количество сегментов в предварительном просмотре\",\n            \"preview_seg_duration_desc\": \"Продолжительность каждого сегмента в файлах предварительного просмотра, в секундах.\",\n            \"preview_seg_duration_head\": \"Продолжительность сегмента предварительного просмотра\",\n            \"sprites\": \"Спрайты сцен для полосы перемотки видео\",\n            \"sprites_tooltip\": \"Небольшие превью на временной шкалы видео для упрощения навигации по сцене.\",\n            \"transcodes\": \"Перекодированные файлы\",\n            \"transcodes_tooltip\": \"Конвертация в MP4 формат всего контента. Используется для слабых систем, но требует больше места на диске\",\n            \"video_previews\": \"Превью\",\n            \"video_previews_tooltip\": \"Видео превью которые проигрываются при наведении курсора на сцену\",\n            \"covers\": \"Обложки сцен\",\n            \"phash_tooltip\": \"Для удаления дубликатов и идентификации сцен\",\n            \"clip_previews\": \"Превью галерей изображений\",\n            \"image_thumbnails\": \"Миниатюры изображений\"\n        },\n        \"scenes_found\": \"{count} сцен найдено\",\n        \"scrape_entity_query\": \"{entity_type} Scrape запрос\",\n        \"scrape_entity_title\": \"{entity_type} Scrape результаты\",\n        \"scrape_results_existing\": \"Существующий\",\n        \"scrape_results_scraped\": \"Собрано\",\n        \"set_image_url_title\": \"Ссылка изображения\",\n        \"unsaved_changes\": \"Имеются несохраненные изменений. Вы действительно хотите выйти?\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Столбец\",\n                \"description\": \"Отображение в виде строк или столбцов.\",\n                \"row\": \"Строка\"\n            },\n            \"margin_desc\": \"Количество пикселей рамки вокруг изображения.\"\n        },\n        \"clear_play_history_confirm\": \"Вы уверены, что хотите удалить историю просмотра?\",\n        \"performers_found\": \"{count} исполнителей найдено\",\n        \"clear_o_history_confirm\": \"Вы уверены, что хотите очистить историю О?\",\n        \"overwrite_filter_warning\": \"Сохранённый фильтр «{entityName}» будет перезаписан.\",\n        \"set_default_filter_confirm\": \"Вы уверены, что хотите установить этот фильтр по умолчанию?\"\n    },\n    \"dimensions\": \"Размер\",\n    \"director\": \"Режиссер\",\n    \"disambiguation\": \"Многозначность\",\n    \"display_mode\": {\n        \"grid\": \"Сетка\",\n        \"list\": \"Список\",\n        \"tagger\": \"Теггер\",\n        \"unknown\": \"Неизвестный\",\n        \"wall\": \"Стена\",\n        \"label_current\": \"Режим отображения: {current}\"\n    },\n    \"donate\": \"Пожертвование\",\n    \"dupe_check\": {\n        \"description\": \"Расчет уровней ниже «Exact» может занять больше времени. Ложные срабатывания возможны при более низких уровнях точности.\",\n        \"found_sets\": \"{setCount, plural, one{# набор дубликатов найден.} other {# наборы дубликатов найдены.}}\",\n        \"options\": {\n            \"exact\": \"Точный\",\n            \"high\": \"Высокий\",\n            \"low\": \"Нижний\",\n            \"medium\": \"Средний\"\n        },\n        \"search_accuracy_label\": \"Точность поиска\",\n        \"title\": \"Дубликаты сцен\",\n        \"select_youngest\": \"Выбрать самый новый файл в группе дубликатов\",\n        \"duration_diff\": \"Максимальное отличие в длительности\",\n        \"duration_options\": {\n            \"any\": \"Любой\",\n            \"equal\": \"Равный\"\n        },\n        \"only_select_matching_codecs\": \"Выбирать только если все кодеки совпадают с кодеками в группе дубликата\",\n        \"select_all_but_largest_file\": \"Выбрать каждый файл в каждой группе дубликатов, исключая самый большой файл\",\n        \"select_all_but_largest_resolution\": \"Выбрать каждый файл в каждой группе дубликатов, исключая файл с наибольшим разрешением\",\n        \"select_none\": \"Не выбирать ничего\",\n        \"select_oldest\": \"Выбрать самый старый файл в группе дубликатов\",\n        \"select_options\": \"Опции…\"\n    },\n    \"duplicated_phash\": \"Повтор (pHash)\",\n    \"duration\": \"Продолжительность\",\n    \"effect_filters\": {\n        \"aspect\": \"Аспект\",\n        \"blue\": \"Синий\",\n        \"blur\": \"Размытие\",\n        \"brightness\": \"Яркость\",\n        \"contrast\": \"Контраст\",\n        \"gamma\": \"Гамма\",\n        \"green\": \"Зеленый\",\n        \"hue\": \"Оттенок\",\n        \"name\": \"Фильтры\",\n        \"name_transforms\": \"Трансформация\",\n        \"red\": \"Красный\",\n        \"reset_filters\": \"Сбросить фильтры\",\n        \"reset_transforms\": \"Сбросить трансформации\",\n        \"rotate\": \"Поворот\",\n        \"rotate_left_and_scale\": \"Повернуть влево и масштабировать\",\n        \"rotate_right_and_scale\": \"Повернуть вправо и масштабировать\",\n        \"saturation\": \"Насыщенность\",\n        \"scale\": \"Масштаб\",\n        \"warmth\": \"Тепло\"\n    },\n    \"empty_server\": \"Добавьте несколько сцен на свой сервер, чтобы просмотреть рекомендации на этой странице.\",\n    \"ethnicity\": \"Этническая принадлежность\",\n    \"existing_value\": \"существующее значение\",\n    \"eye_color\": \"Цвет глаз\",\n    \"fake_tits\": \"Искусственная грудь\",\n    \"false\": \"Нет\",\n    \"favourite\": \"Избранный\",\n    \"file\": \"файл\",\n    \"file_count\": \"Количество файлов\",\n    \"file_info\": \"Информация о файле\",\n    \"file_mod_time\": \"Время модификации файла\",\n    \"files\": \"файлы\",\n    \"files_amount\": \"{value} файлов\",\n    \"filesize\": \"Размер файла\",\n    \"filter\": \"Фильтр\",\n    \"filter_name\": \"Название фильтра\",\n    \"filters\": \"Фильтры\",\n    \"folder\": \"Папка\",\n    \"framerate\": \"Частота кадров\",\n    \"frames_per_second\": \"{value} кадров в секунду\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Заготовленный фильтр\",\n            \"saved_filter\": \"Сохраненный фильтр\"\n        }\n    },\n    \"galleries\": \"Галереи\",\n    \"gallery\": \"Галерея\",\n    \"gallery_count\": \"Количество галерей\",\n    \"gender\": \"Пол\",\n    \"gender_types\": {\n        \"FEMALE\": \"Женщина\",\n        \"INTERSEX\": \"Интерсекс\",\n        \"MALE\": \"Мужчина\",\n        \"NON_BINARY\": \"Не бинарный\",\n        \"TRANSGENDER_FEMALE\": \"Женщина трансгендер\",\n        \"TRANSGENDER_MALE\": \"Мужчина трансгендер\"\n    },\n    \"hair_color\": \"Цвет волос\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Подключение\",\n        \"disconnected\": \"Отключен\",\n        \"error\": \"Ошибка при подключении к телефону\",\n        \"missing\": \"Отсутствует\",\n        \"ready\": \"Готов\",\n        \"syncing\": \"Синхронизация с сервером\",\n        \"uploading\": \"Отправка скрипта\"\n    },\n    \"hasMarkers\": \"Имеет маркеры\",\n    \"height\": \"Рост\",\n    \"height_cm\": \"Рост (см)\",\n    \"help\": \"Помощь\",\n    \"ignore_auto_tag\": \"Игнорировать автоматическую пометку тегами\",\n    \"image\": \"Изображение\",\n    \"image_count\": \"Количество изображений\",\n    \"images\": \"Изображения\",\n    \"include_parent_tags\": \"Включить родительские теги\",\n    \"include_sub_studios\": \"Включая дочерние студии\",\n    \"include_sub_tags\": \"Включить вложенные теги\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Интерактивно\",\n    \"interactive_speed\": \"Интерактивная скорость\",\n    \"isMissing\": \"Отсутствует\",\n    \"last_played_at\": \"Воспроизводился в последний раз\",\n    \"library\": \"Библиотека\",\n    \"loading\": {\n        \"generic\": \"Загрузка…\",\n        \"plugins\": \"Загрузка плагинов…\"\n    },\n    \"marker_count\": \"Количество маркеров\",\n    \"markers\": \"Маркеры\",\n    \"measurements\": \"Размеры\",\n    \"media_info\": {\n        \"audio_codec\": \"Аудио кодек\",\n        \"downloaded_from\": \"Скачан с\",\n        \"interactive_speed\": \"Интерактивная скорость\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} на момент съемки\"\n        },\n        \"phash\": \"PHash значение\",\n        \"play_count\": \"Счетчик воспроизведений\",\n        \"play_duration\": \"Продолжительность\",\n        \"stream\": \"Стрим\",\n        \"video_codec\": \"Видео кодек\",\n        \"o_count\": \"Счетчик О\"\n    },\n    \"megabits_per_second\": \"{value} мегабит в секунду\",\n    \"metadata\": \"Метаданные\",\n    \"name\": \"Имя\",\n    \"new\": \"Новый\",\n    \"none\": \"Отсутствует\",\n    \"operations\": \"Операции\",\n    \"organized\": \"Организован\",\n    \"pagination\": {\n        \"first\": \"Первая\",\n        \"last\": \"Последняя\",\n        \"next\": \"Следующая\",\n        \"previous\": \"Предыдущий\",\n        \"current_total\": \"{current} из {total}\"\n    },\n    \"parent_of\": \"Родитель {children}\",\n    \"parent_studios\": \"Родительские студии\",\n    \"parent_tag_count\": \"Количество родительских тегов\",\n    \"parent_tags\": \"Родительские теги\",\n    \"part_of\": \"Является частью {parent}\",\n    \"path\": \"Путь\",\n    \"perceptual_similarity\": \"Воспринимаемое сходство (phash)\",\n    \"performer\": \"Актер\",\n    \"performer_age\": \"Возраст актера\",\n    \"performer_count\": \"Количество актеров\",\n    \"performer_favorite\": \"Участник добавлен в избранное\",\n    \"performer_image\": \"Изображение актера\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Добавить новых актеров\",\n        \"any_names_entered_will_be_queried\": \"Любые введенные имена будут проверены при помощи Stash-Box и позже добавлены, если они будут найдены точные совпадения.\",\n        \"batch_add_performers\": \"Пакетное добавление участников\",\n        \"batch_update_performers\": \"Пакетное обновление участников\",\n        \"current_page\": \"Текущая страница\",\n        \"failed_to_save_performer\": \"Ошибка сохранения актера \\\"{performer}\\\"\",\n        \"name_already_exists\": \"Такое имя уже существует\",\n        \"network_error\": \"Ошибка сети\",\n        \"no_results_found\": \"Ничего не найдено.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} актер(-ы) будут обработаны\",\n        \"performer_already_tagged\": \"Актер уже помечен тегом\",\n        \"performer_selection\": \"Выбор актеров\",\n        \"performer_successfully_tagged\": \"Актер успешно помечен тегом:\",\n        \"query_all_performers_in_the_database\": \"Все актеры в базе данных\",\n        \"refresh_tagged_performers\": \"Обновить помеченных тегами актеров\",\n        \"refreshing_will_update_the_data\": \"Обновление обновит данные у всех помеченных тегами актеров, используя stash-box.\",\n        \"status_tagging_job_queued\": \"Статус: Пометка тегами добавлена в очередь\",\n        \"status_tagging_performers\": \"Статус: Пометка тегами актеров\",\n        \"tag_status\": \"Статус тега\",\n        \"to_use_the_performer_tagger\": \"Для пометки актеров тэгами, stash-box должен быть настроен.\",\n        \"untagged_performers\": \"Не помеченные актеры\",\n        \"update_performer\": \"Обновить актера\",\n        \"update_performers\": \"Обновить актеров\",\n        \"updating_untagged_performers_description\": \"При обновлении непомеченных актеров, будет предпринята попытка обновить метаданные всех участников, у которых нет StashID.\"\n    },\n    \"performer_tags\": \"Теги актера\",\n    \"performers\": \"Исполнители\",\n    \"piercings\": \"Пирсинг\",\n    \"play_count\": \"Счетчик воспроизведений\",\n    \"play_duration\": \"Продолжительность воспроизведения\",\n    \"primary_file\": \"Первичный файл\",\n    \"queue\": \"Очередь\",\n    \"random\": \"Случайный\",\n    \"rating\": \"Рейтинг\",\n    \"recently_added_objects\": \"Недавно добавленные {objects}\",\n    \"recently_released_objects\": \"Недавно выпущенные {objects}\",\n    \"release_notes\": \"Информация о сборке\",\n    \"resolution\": \"Разрешение\",\n    \"resume_time\": \"Таймкод воспроизведения\",\n    \"scene\": \"Сцена\",\n    \"sceneTagger\": \"Пометка сцен тэгами\",\n    \"scene_code\": \"Идентификатор сцены\",\n    \"scene_count\": \"Количество сцен\",\n    \"scene_created_at\": \"Сцена создана\",\n    \"scene_date\": \"Дата сцены\",\n    \"scene_id\": \"ID сцены\",\n    \"scene_tags\": \"Тэги сцен\",\n    \"scene_updated_at\": \"Сцена обновлена\",\n    \"scenes\": \"Сцены\",\n    \"scenes_updated_at\": \"Сцена обновлена в\",\n    \"search_filter\": {\n        \"name\": \"Фильтр\",\n        \"saved_filters\": \"Сохраненные фильтры\",\n        \"update_filter\": \"Обновить фильтр\",\n        \"edit_filter\": \"Изменить фильтр\",\n        \"more_filter_criteria\": \"+ещё {count}\",\n        \"search_term\": \"Поисковый запрос\"\n    },\n    \"seconds\": \"Секунды\",\n    \"settings\": \"Настройки\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Мы почти завершили настройку. Подтвердите следующие настройки. Вы можете щелкнуть назад, чтобы изменить что-либо неправильное. Если все выглядит хорошо, нажмите «Подтвердить», чтобы создать свою систему.\",\n            \"configuration_file_location\": \"Путь к файлу конфигурации:\",\n            \"database_file_path\": \"Путь файла базы данных\",\n            \"generated_directory\": \"Папка сгенерированных вспомогательных файлов\",\n            \"nearly_there\": \"Почти готово!\",\n            \"stash_library_directories\": \"Библиотека каталогов Stash\",\n            \"blobs_directory\": \"Папка хранения бинарных данных\",\n            \"blobs_use_database\": \"<используя базу данных>\",\n            \"cache_directory\": \"Каталог кэша\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Создаем вашу систему\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"О, нет! Что-то пошло не так!\",\n            \"something_went_wrong_description\": \"Если это похоже на проблему с вашими входными данными, нажмите «Назад», чтобы исправить их. В противном случае сообщите об ошибке на {githubLink} или обратитесь за помощью в {discordLink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Что-то пошло не так при настройке вашей системы. Мы получили следующую ошибку: {error}\",\n            \"unexpected_error\": \"Произошла непредвиденная ошибка: {error}\",\n            \"unable_to_retrieve_system_status\": \"Не удалось получить статус системы: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Путь файла\",\n            \"up_dir\": \"Каталогом выше\"\n        },\n        \"github_repository\": \"Репозиторий Github\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Путь к резервной копии базы данных (оставьте пустым чтобы отключить резервное копирование):\",\n            \"backup_recommended\": \"Рекомендуется создать резервную копию перед тем как выполнять перенос. Мы может сделать это за вас, создав копию базы данных по пути <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Перенос базы данных\",\n            \"migration_failed\": \"Ошибка переноса\",\n            \"migration_failed_error\": \"При переносе базы данных возникла следующая ошибка:\",\n            \"migration_failed_help\": \"Внесите необходимые исправления и повторите попытку. В противном случае сообщите об ошибке в {githubLink} или обратитесь за помощью в {discordLink}.\",\n            \"migration_irreversible_warning\": \"Процесс переноса схемы необратим. После выполнения ваша база данных будет несовместима с предыдущими версиями stash.\",\n            \"migration_notes\": \"Заметки переноса\",\n            \"migration_required\": \"Перенос необходим\",\n            \"perform_schema_migration\": \"Выполнить миграцию схемы\",\n            \"schema_too_old\": \"Ваша текущая Stash база данных имеет версию схемы <strong>{databaseSchema}</strong>, и ее необходимо перенести на версию <strong>{appSchema}</strong>. Текущая версия Stash не будет работать без переноса базы данных.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"название файла базы данных (пусто по умолчанию)\",\n            \"description\": \"Дальше нам нужно определить где сможем найти вашу коллекцию порно, где хранить базу данных Stash и генерированные файлы. Эти настройки при необходимости позже возможно будет изменить.\",\n            \"path_to_generated_directory_empty_for_default\": \"Путь к каталогу с генерированными данными (оставить пустым для значения по умолчанию)\",\n            \"set_up_your_paths\": \"Задайте ваши пути\",\n            \"stash_alert\": \"Пути к файлам не выбраны. Для Stash никакие медиа файлы не будут отсканированы и добавлены. Вы уверены?\",\n            \"where_can_stash_store_its_database\": \"Где Stash может хранить свою базу данных?\",\n            \"where_can_stash_store_its_database_description\": \"Stash использует sqlite базу данных для хранения метаданных вашего порно. По умолчанию, она будет создана как <code>stash-go.sqlite</code> в директории где находится ваш файл конфигурации. Если хотите изменить это, пожалуйста введите абсолютный или относительный путь к файлу.\",\n            \"where_can_stash_store_its_generated_content\": \"Где Stash сможет хранить сгенерированный контент?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Чтобы предоставить эскизы, превью и спрайты, Stash генерирует изображения и видео. Сюда также входят перекодированные файлы для неподдерживаемых форматов файлов. По умолчанию Stash создает каталог <code>generated</code> в каталоге, содержащем ваш файл конфигурации. Если вы хотите изменить место хранения сгенерированного мультимедиа, введите абсолютный или относительный (по отношению к текущему рабочему каталогу) путь. Stash создаст этот каталог, если он еще не существует.\",\n            \"where_is_your_porn_located\": \"Где находится ваше порно?\",\n            \"where_is_your_porn_located_description\": \"Добавьте каталоги, содержащие ваши порно видео и изображения. Stash будет использовать эти каталоги для поиска видео и изображений во время сканирования.\",\n            \"where_can_stash_store_blobs_description\": \"Stash сохраняет бинарные данные (обложки сцен, фото исполнителей, логотипы студий и картинки тегов) в базе данных либо в файловой системе. По умолчанию Stash использует для хранения этих данных каталог <code>blobs</code> в каталоге, содержащем конфигурационный файл. Для изменения каталога с бинарными данными введите абсолютный или относительный (к текущему рабочему каталогу) путь. Stash создаст каталог, если он уже не существует.\",\n            \"where_can_stash_store_its_database_warning\": \"ВНИМАНИЕ: хранение базы данных в системе, отличающейся от той, в которой запускается Stash (то есть хранение файлов БД на NAS, тогда как Stash запускается на другом компьютере) <strong>НЕ ПОДДЕРЖИВАЕТСЯ</strong>! SQLite не разрабатывался для применения в сетевом окружении и попытки такого использования могут привести к повреждению базы данных.\",\n            \"path_to_blobs_directory_empty_for_default\": \"Путь к каталогу бинарных данных (оставить пустым для значения по умолчанию)\",\n            \"path_to_cache_directory_empty_for_default\": \"Путь к каталогу кэша (оставить пустым для значения по умолчанию)\",\n            \"where_can_stash_store_blobs_description_addendum\": \"В качестве альтернативы вы можете хранить эти данные в базе данных. <strong>Внимание:</strong> Данный вариант хранения бинарных данных увеличит время миграции и размер базы данных.\",\n            \"where_can_stash_store_cache_files_description\": \"Для применения некоторых функций, таких как потоковое вещание HLS/DASH, Stash должен использовать каталог для кэша временных файлов. По умолчанию Stash создаст каталог <code>cache</code> в каталоге, содержащем конфигурационный файл. Для изменения каталога кэша временных файлов введите абсолютный или относительный (к текущему рабочему каталогу) путь. Stash создаст каталог, если он еще не существует.\",\n            \"store_blobs_in_database\": \"Сохранять бинарные данные в базе данных\",\n            \"where_can_stash_store_blobs\": \"Где Stash будет хранить бинарные данные БД?\",\n            \"where_can_stash_store_cache_files\": \"Какой каталог Stash будет использовать для хранения файлов кэша?\"\n        },\n        \"stash_setup_wizard\": \"Мастер установки Stash\",\n        \"success\": {\n            \"getting_help\": \"Помощь\",\n            \"help_links\": \"Если у вас возникнут проблемы, какие-либо вопросы или предложения, не стесняйтесь добавить описание проблемы в {githubLink} или задать вопрос сообществу в {discordLink}.\",\n            \"in_app_manual_explained\": \"Вам рекомендуется ознакомиться с руководством в приложении, доступ к которому можно получить с помощью значка в правом верхнем углу экрана, который выглядит следующим образом: {icon}\",\n            \"next_config_step_one\": \"Дальше вы будете направлены на страницу Конфигурации. Эта страница позволит вам настроить какие файлы включать, какие - исключить, задать имя пользователя и пароль для защиты системы, а также уйму других пунктов.\",\n            \"next_config_step_two\": \"Если вас устраивают эти настройки, вы можете начать сканировать содержимое в Stash, нажав <code>{localized_task}</code>, а затем <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Посетите {open_collective_link}, чтобы узнать, как вы можете внести свой вклад в дальнейшее развитие Stash.\",\n            \"support_us\": \"Поддержите нас\",\n            \"thanks_for_trying_stash\": \"Спасибо, что попробовали Stash!\",\n            \"welcome_contrib\": \"Кроме того, всегда приветствуются привносимый вклад в виде кода (исправления ошибок, улучшения и новые функции), тестирование, отчетов об ошибках, идей для функций и улучшений, а также поддержка пользователей. Подробности об этом в соответствующем разделе встроенного в приложение руководства пользователя.\",\n            \"your_system_has_been_created\": \"Готово! Ваша система создана!\",\n            \"download_ffmpeg\": \"Скачать ffmpeg\",\n            \"missing_ffmpeg\": \"Отсутствует необходимый исполнимый файл <code>ffmpeg</code>. Вы можете автоматически скачать его в каталог конфигурации, выбрав опцию ниже. В качестве альтернативы, можно задать пути к исполняемым файлам <code>ffmpeg</code> и <code>ffprobe</code> в Системных настройках. Файлы ffmpeg и ffprobe необходимы для работы Stash.\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash сначала пытается найти свой файл конфигурации (<code>config.yml</code>) в текущем рабочем каталоге, и, если он не находит его там, возвращается к <code>$HOME/.stash/config.yml</code>. Вы также можете заставить Stash использовать определенный файл конфигурации, запустив его с параметрами <code>-c '<путь к файлу конфигурации>'</code> или <code>--config '<путь к файлу конфигурации>'</code>.\",\n            \"in_current_stash_directory\": \"В <code>{path}</code> каталоге:\",\n            \"in_the_current_working_directory\": \"В <code>{path}</code> текущем рабочем каталоге, сейчас:\",\n            \"next_step\": \"После всего этого, если вы готовы приступить к настройке новой системы, выберите, где вы хотите сохранить файл конфигурации.\",\n            \"store_stash_config\": \"Где вы хотите хранить конфигурацию Stash?\",\n            \"unable_to_locate_config\": \"Если вы читаете это, значит Stash не смог найти существующую конфигурацию. Этот мастер проведет вас через процесс настройки новой конфигурации.\",\n            \"unexpected_explained\": \"Если вы неожиданно видите этот экран, попробуйте перезапустить Stash в правильном рабочем каталоге или с флагом <code>-c</code>.\",\n            \"in_the_current_working_directory_disabled\": \"В <code>{path}</code> рабочем каталоге:\",\n            \"in_the_current_working_directory_disabled_macos\": \"Не поддерживается при запуске <code>Stash.app</code>,<br></br>запустите <code>stash-macos</code> для настройки рабочего каталога\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash будет использовать следующий путь к файлу конфигурации: <code>{path}</code>\",\n            \"next_step\": \"Когда вы будете готовы приступить к настройке новой системы, нажмите «Далее».\",\n            \"unable_to_locate_specified_config\": \"Если вы читаете это, значит Stash не смог найти файл конфигурации, указанный в командной строке или в среде. Этот мастер проведет вас через процесс настройки новой конфигурации.\"\n        },\n        \"welcome_to_stash\": \"Добро пожаловать в Stash\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_id_endpoint\": \"Конечная точка Stash ID\",\n    \"stash_ids\": \"Stash ID-ы\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Перейдите к {endpoint_name}, чтобы просмотреть черновик.\",\n        \"selected_stash_box\": \"Выбранный Stash-Box экземпляр\",\n        \"submission_failed\": \"Отправка не удалась\",\n        \"submission_successful\": \"Отправка прошла успешно\",\n        \"submit_update\": \"Уже существует в {endpoint_name}\",\n        \"source\": \"Источник Stash-Box\"\n    },\n    \"statistics\": \"Статистика\",\n    \"stats\": {\n        \"image_size\": \"Объем изображений\",\n        \"scenes_duration\": \"Продолжительность сцен\",\n        \"scenes_size\": \"Объем сцен\",\n        \"scenes_played\": \"Количество показанных сцен\",\n        \"total_o_count\": \"Значение О-счетчика\",\n        \"total_play_count\": \"Общее количество просмотров\",\n        \"total_play_duration\": \"Общая длительность просмотров\"\n    },\n    \"status\": \"Статус: {statusText}\",\n    \"studio\": \"Студия\",\n    \"studio_depth\": \"Уровни (пусто для всех)\",\n    \"studios\": \"Студии\",\n    \"sub_tag_count\": \"Количество под-тегов\",\n    \"sub_tag_of\": \"Под-тег от {parent}\",\n    \"sub_tags\": \"Под-Теги\",\n    \"subsidiary_studios\": \"Дочерние студии\",\n    \"synopsis\": \"Резюме\",\n    \"tag\": \"Тег\",\n    \"tag_count\": \"Количество тегов\",\n    \"tags\": \"Теги\",\n    \"tattoos\": \"Татуировки\",\n    \"title\": \"Заголовок\",\n    \"toast\": {\n        \"added_entity\": \"Добавлен {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Задание генерации добавлено в очередь\",\n        \"created_entity\": \"Создан {entity}\",\n        \"default_filter_set\": \"Стандартный набор фильтров\",\n        \"delete_past_tense\": \"Удален {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Создание скриншота…\",\n        \"merged_scenes\": \"Объединенные сцены\",\n        \"merged_tags\": \"Объединенные тэги\",\n        \"reassign_past_tense\": \"Файл переназначен\",\n        \"removed_entity\": \"Удален {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"rescanning_entity\": \"Повторное сканирование {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"Сохранено {entity}\",\n        \"started_auto_tagging\": \"Автоматическая пометка тэгами запущена\",\n        \"started_generating\": \"Генерация запущена\",\n        \"started_importing\": \"Импортирование начато\",\n        \"updated_entity\": \"Обновлен {entity}\",\n        \"image_index_too_large\": \"Ошибка: индекс изображения больше, чем количество изображений в Галерее\"\n    },\n    \"total\": \"Всего\",\n    \"true\": \"Да\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Тип\",\n    \"updated_at\": \"Обновлено\",\n    \"url\": \"Ссылка\",\n    \"videos\": \"Видео\",\n    \"view_all\": \"Показать все\",\n    \"weight\": \"Вес\",\n    \"weight_kg\": \"Вес (кг)\",\n    \"years_old\": \"лет\",\n    \"zip_file_count\": \"Количество zip-файлов\",\n    \"penis_length_cm\": \"Длина пениса (см)\",\n    \"studio_tagger\": {\n        \"tag_status\": \"Статус тегирования\",\n        \"untagged_studios\": \"Нетегированные студии\",\n        \"batch_add_studios\": \"Пакетное добавление студий\",\n        \"add_new_studios\": \"Добавить новую студию\",\n        \"config\": {\n            \"create_parent_label\": \"Создать родительские студии\",\n            \"create_parent_desc\": \"Создать отсутствующие родительские студии или связать и обновить данные\\\\логотипы текущих студий с точным совпадением имени\"\n        },\n        \"create_or_tag_parent_studios\": \"Создать отсутствующие или связать существующие родительские студии\",\n        \"failed_to_save_studio\": \"Не удалось сохранить студию \\\"{studio}\\\"\",\n        \"name_already_exists\": \"Имя уже используется\",\n        \"network_error\": \"Сетевая ошибка\",\n        \"any_names_entered_will_be_queried\": \"Все введенные имена будут обработаны Stash-Box и добавлены при совпадении. Учитываются только точные совпадения.\",\n        \"current_page\": \"Текущая страница\",\n        \"query_all_studios_in_the_database\": \"Все студии в базе данных\",\n        \"no_results_found\": \"Результаты не найдены.\",\n        \"number_of_studios_will_be_processed\": \"Будут обработаны {studio_count} студий\",\n        \"refreshing_will_update_the_data\": \"Обновление затронет данные всех тегированных студий в данной инсталляции stash-box.\",\n        \"status_tagging_studios\": \"Статус: Тегирование студий\",\n        \"studio_already_tagged\": \"Студия уже тегирована\",\n        \"studio_selection\": \"Выбор студии\",\n        \"studio_successfully_tagged\": \"Студия успешно тегирована\",\n        \"to_use_the_studio_tagger\": \"Для использования тегирования студий stash-box нужно настроить.\",\n        \"update_studios\": \"Обновить студии\",\n        \"update_studio\": \"Обновить студию\",\n        \"updating_untagged_studios_description\": \"Попытка обновить нетегированные студии для поиска отсутсвующих stashid и обновления метаданных.\",\n        \"batch_update_studios\": \"Пакетное обновление студий\",\n        \"refresh_tagged_studios\": \"Обновить тегированные студии\",\n        \"status_tagging_job_queued\": \"Статус: Задача тегирования в очереди\"\n    },\n    \"appears_with\": \"Совместно С\",\n    \"audio_codec\": \"Аудио Кодек\",\n    \"hasChapters\": \"Имеет разделы\",\n    \"index_of_total\": \"{index} из {total}\",\n    \"orientation\": \"Ориентация\",\n    \"package_manager\": {\n        \"add_source\": \"Добавить источник\",\n        \"check_for_updates\": \"Проверка обновлений\",\n        \"confirm_delete_source\": \"Вы уверены, что хотите удалить источник {name} ({url})?\",\n        \"description\": \"Описание\",\n        \"installed_version\": \"Установленная версия\",\n        \"latest_version\": \"Последняя версия\",\n        \"no_sources\": \"На настроены источники\",\n        \"package\": \"Пакет\",\n        \"required_by\": \"Требуется для {packages}\",\n        \"selected_only\": \"Только выбранные\",\n        \"show_all\": \"Показать все\",\n        \"hide_unselected\": \"Скрыть не выбранное\",\n        \"install\": \"Установить\",\n        \"no_packages\": \"Не найдены пакеты\",\n        \"confirm_uninstall\": \"Вы уверены, что хотите удалить {number} пакетов?\",\n        \"edit_source\": \"Изменить источник\",\n        \"no_upgradable\": \"Нет пакетов для обновления\",\n        \"source\": {\n            \"local_path\": {\n                \"description\": \"Путь для сохранения пакетов для данного источника. Изменение пути потребует перемещение пакетов вручную.\",\n                \"heading\": \"Локальный путь\"\n            },\n            \"name\": \"Имя\",\n            \"url\": \"URL источника\"\n        },\n        \"uninstall\": \"Удалить\",\n        \"update\": \"Обновить\",\n        \"version\": \"Версия\",\n        \"unknown\": \"<unknown>\"\n    },\n    \"parent_studio\": \"Родительская студия\",\n    \"primary_tag\": \"Основной\",\n    \"tag_parent_tooltip\": \"Есть родительские теги\",\n    \"circumcised_types\": {\n        \"UNCUT\": \"Необрезанный\",\n        \"CUT\": \"Обрезанный\"\n    },\n    \"errors\": {\n        \"lazy_component_error_help\": \"Если вы недавно обновили Stash, перезагрузите страницу или очистите кэш браузера.\",\n        \"image_index_greater_than_zero\": \"Индекс изображения должен быть больше 0\",\n        \"header\": \"Ошибка\",\n        \"loading_type\": \"Ошибка загрузки {type}\",\n        \"something_went_wrong\": \"Что-то пошло по пизде.\",\n        \"custom_fields\": {\n            \"field_name_length\": \"Имя поля должно содержать меньше 65 символов\",\n            \"field_name_required\": \"Имя поля обязательно\",\n            \"field_name_whitespace\": \"Имя поля не должно начинаться или заканчиваться пробелом\",\n            \"duplicate_field\": \"Имя поля должно быть уникальным\"\n        },\n        \"invalid_javascript_string\": \"Недопустимый код JavaScript: {error}\",\n        \"invalid_json_string\": \"Недопустимая JSON-строка: {error}\"\n    },\n    \"date_format\": \"ГГГГ-ММ-ДД\",\n    \"datetime_format\": \"ГГГГ-ММ-ДД ЧЧ:ММ\",\n    \"penis\": \"Член\",\n    \"penis_length\": \"Длина члена\",\n    \"second\": \"Секунда\",\n    \"tag_sub_tag_tooltip\": \"Есть под-теги\",\n    \"last_o_at\": \"Последний О на\",\n    \"o_history\": \"История О\",\n    \"odate_recorded_no\": \"Нет записанной даты О\",\n    \"play_history\": \"История просмотров\",\n    \"playdate_recorded_no\": \"Нет сохраненной даты просмотра\",\n    \"subsidiary_studio_count\": \"Количество дочерних студий\",\n    \"time\": \"Время\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} должен быть в формате ГГГГ-ММ-ДД\",\n        \"blank\": \"${path} не должен быть пустым\",\n        \"required\": \"${path} обязательное поле\",\n        \"unique\": \"${path} должен быть уникальным\",\n        \"end_time_before_start_time\": \"Время окончания должно быть больше или равно времени начала\"\n    },\n    \"unknown_date\": \"Неизвестная дата\",\n    \"urls\": \"URLы\",\n    \"video_codec\": \"Видеокодек\",\n    \"photographer\": \"Фотограф\",\n    \"plays\": \"{value} проигрывание\",\n    \"circumcised\": \"Обрезание\",\n    \"distance\": \"Дистанция\",\n    \"history\": \"История\",\n    \"image_index\": \"Изображение #\",\n    \"studio_and_parent\": \"Студия & Родительская студия\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"Не удается выполнить соединение websocket: подробности в консоли браузера\",\n        \"websocket_connection_reestablished\": \"Соединение websocket восстановлено\"\n    },\n    \"o_count\": \"Количество О\",\n    \"age_on_date\": \"{age} на момент съемки\",\n    \"sub_group_count\": \"Кол-во подгрупп\",\n    \"sub_group\": \"Подгруппа\",\n    \"studio_tags\": \"Теги студии\",\n    \"criterion_modifier_values\": {\n        \"any\": \"Любой\",\n        \"any_of\": \"Любой из\",\n        \"none\": \"Отсутствует\",\n        \"only\": \"Только\"\n    },\n    \"containing_groups\": \"Группы, в которые входит объект\",\n    \"custom_fields\": {\n        \"criteria_format_string\": \"criterion} (пользовательское поле) {modifierString} {valueString}\",\n        \"criteria_format_string_others\": \"{criterion} (пользовательское поле) {modifierString} {valueString} (и ещё {others})\",\n        \"field\": \"Поле\",\n        \"title\": \"Настраиваемые поля\",\n        \"value\": \"Значение\"\n    },\n    \"containing_group\": \"Содержащая группа\",\n    \"containing_group_count\": \"Количество содержащих групп\",\n    \"time_end\": \"Время окончания\",\n    \"eta\": \"Ожидаемое время завершения\",\n    \"login\": {\n        \"username\": \"Имя пользователя\",\n        \"password\": \"Пароль\",\n        \"login\": \"Логин\",\n        \"internal_error\": \"Произошла внутренняя ошибка. Подробности смотрите в логах.\",\n        \"invalid_credentials\": \"Неверное имя пользователя или пароль\"\n    },\n    \"groups\": \"Группы\",\n    \"include_sub_tag_content\": \"Учитывать содержимое вложенных тегов\",\n    \"sort_name\": \"Сортировать по имени\",\n    \"include_sub_group_content\": \"Включать содержимое подгрупп\",\n    \"include_sub_studio_content\": \"Включить данные дочерних студий\",\n    \"group\": \"Группа\",\n    \"group_count\": \"Количество групп\",\n    \"group_scene_number\": \"Номер сцены\",\n    \"include_sub_groups\": \"Включать подгруппы\",\n    \"studio_count\": \"Количество студий\",\n    \"sub_group_of\": \"Входит в группу {parent}\",\n    \"sub_group_order\": \"Сортировка подгрупп\",\n    \"sub_groups\": \"Подгруппы\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/sk-SK.json",
    "content": "{\n    \"actions\": {\n        \"cancel\": \"Zrušiť\",\n        \"next_action\": \"Ďaľšie\",\n        \"add_directory\": \"Pridať priečinok\",\n        \"add_sub_groups\": \"Pridať podskupiny\",\n        \"allow\": \"Povoliť\",\n        \"allow_temporarily\": \"Dočasne povoliť\",\n        \"anonymise\": \"Zanonymizovať\",\n        \"backup\": \"Záloha\",\n        \"choose_date\": \"Vybrať dátum\",\n        \"confirm\": \"Potvrdiť\",\n        \"continue\": \"Pokračovať\",\n        \"create\": \"Vytvoriť\",\n        \"create_chapters\": \"Nová kapitola\",\n        \"delete\": \"Vymazať\",\n        \"find\": \"Nájsť\",\n        \"identify\": \"Identifikovať\",\n        \"logout\": \"Odhlásiť\",\n        \"optimise_database\": \"Optimalizovať databázu\",\n        \"overwrite\": \"Prepísať\",\n        \"add\": \"Pridať\",\n        \"customise\": \"Prispôsobiť\",\n        \"export\": \"Exportovať\",\n        \"export_all\": \"Exportovať všetko…\",\n        \"add_entity\": \"Pridať {entityType}\",\n        \"add_manual_date\": \"Pridať dátum manuálne\",\n        \"add_o\": \"Pridať O\",\n        \"add_to_entity\": \"Pridať do {entityType}\",\n        \"apply\": \"Použiť\",\n        \"assign_stashid_to_parent_studio\": \"Priraďte Stash ID existujúcemu rodičovskému štúdiu a aktualizujte metadáta\"\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/sv-SE.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Lägg till\",\n        \"add_directory\": \"Lägg till mapp\",\n        \"add_entity\": \"Lägg till {entityType}\",\n        \"add_to_entity\": \"Lägg till {entityType}\",\n        \"allow\": \"Tillåt\",\n        \"allow_temporarily\": \"Tillåt tillfälligt\",\n        \"anonymise\": \"Anonymisera\",\n        \"apply\": \"Tillämpa\",\n        \"assign_stashid_to_parent_studio\": \"Tilldela Stash ID till existerande överordnad studio och uppdatera metadata\",\n        \"auto_tag\": \"Tagga automatiskt\",\n        \"backup\": \"Säkerhetskopiera\",\n        \"browse_for_image\": \"Bläddra efter bild…\",\n        \"cancel\": \"Avbryt\",\n        \"clean\": \"Rensa\",\n        \"clear\": \"Rensa\",\n        \"clear_back_image\": \"Rensa bakre bild\",\n        \"clear_front_image\": \"Rensa främre bild\",\n        \"clear_image\": \"Rensa bild\",\n        \"close\": \"Stäng\",\n        \"confirm\": \"Bekräfta\",\n        \"continue\": \"Fortsätt\",\n        \"create\": \"Skapa\",\n        \"create_chapters\": \"Skapa Kapitel\",\n        \"create_entity\": \"Skapa {entityType}\",\n        \"create_marker\": \"Skapa markör\",\n        \"create_parent_studio\": \"Skapa överordnad studio\",\n        \"created_entity\": \"Skapade {entity_type}: {entity_name}\",\n        \"customise\": \"Ändra\",\n        \"delete\": \"Radera\",\n        \"delete_entity\": \"Radera {entityType}\",\n        \"delete_file\": \"Radera fil\",\n        \"delete_file_and_funscript\": \"Radera fil (och funskript)\",\n        \"delete_generated_supporting_files\": \"Radera genererade filer\",\n        \"disallow\": \"Tillåt ej\",\n        \"download\": \"Ladda ner\",\n        \"download_anonymised\": \"Ladda ner anonymiserad\",\n        \"download_backup\": \"Ladda ner säkerhetskopia\",\n        \"edit\": \"Redigera\",\n        \"edit_entity\": \"Redigera {entityType}\",\n        \"encoding_image\": \"Omkodar bild…\",\n        \"export\": \"Exportera\",\n        \"export_all\": \"Exportera alla…\",\n        \"find\": \"Sök\",\n        \"finish\": \"Slutför\",\n        \"from_file\": \"Från fil…\",\n        \"from_url\": \"Från URL…\",\n        \"full_export\": \"Fullständig export\",\n        \"full_import\": \"Fullständig import\",\n        \"generate\": \"Generera\",\n        \"generate_thumb_default\": \"Generera miniatyrbild\",\n        \"generate_thumb_from_current\": \"Generera miniatyrbild från nuvarande\",\n        \"hash_migration\": \"Hash-migration\",\n        \"hide\": \"Dölj\",\n        \"hide_configuration\": \"Göm konfigurering\",\n        \"identify\": \"Identifiera\",\n        \"ignore\": \"Ignorera\",\n        \"import\": \"Importera…\",\n        \"import_from_file\": \"Importera från fil\",\n        \"logout\": \"Logga ut\",\n        \"make_primary\": \"Gör primär\",\n        \"merge\": \"Slå samman\",\n        \"migrate_blobs\": \"Migrera blobbar\",\n        \"migrate_scene_screenshots\": \"Migrera scenbilder\",\n        \"next_action\": \"Nästa\",\n        \"not_running\": \"körs ej\",\n        \"open_in_external_player\": \"Öppna i extern spelare\",\n        \"open_random\": \"Öppna slumpad\",\n        \"optimise_database\": \"Optimera databas\",\n        \"overwrite\": \"Ersätt\",\n        \"play_random\": \"Spela slumpad\",\n        \"play_selected\": \"Spela vald\",\n        \"preview\": \"Förhandsvisa\",\n        \"previous_action\": \"Backa\",\n        \"reassign\": \"Omplacera\",\n        \"refresh\": \"Uppdatera\",\n        \"reload_plugins\": \"Ladda om tillägg\",\n        \"reload_scrapers\": \"Ladda om skrapare\",\n        \"remove\": \"Ta bort\",\n        \"remove_from_gallery\": \"Ta bort från Galleri\",\n        \"rename_gen_files\": \"Döp om genererade filer\",\n        \"rescan\": \"Skanna om\",\n        \"reshuffle\": \"Blanda om\",\n        \"running\": \"körs\",\n        \"save\": \"Spara\",\n        \"save_delete_settings\": \"Använd dessa inställningar som standard för radering\",\n        \"save_filter\": \"Spara filter\",\n        \"scan\": \"Skanna\",\n        \"scrape\": \"Skrapa\",\n        \"scrape_query\": \"Skrapa text\",\n        \"scrape_scene_fragment\": \"Skrapa med fragment\",\n        \"scrape_with\": \"Skrapa med…\",\n        \"search\": \"Sök\",\n        \"select_all\": \"Välj alla\",\n        \"select_entity\": \"Välj {entityType}\",\n        \"select_folders\": \"Välj mappar\",\n        \"select_none\": \"Välj inga\",\n        \"selective_auto_tag\": \"Selektiv autotagg\",\n        \"selective_clean\": \"Selektiv städning\",\n        \"selective_scan\": \"Selektiv skanning\",\n        \"set_as_default\": \"Välj som standard\",\n        \"set_back_image\": \"Bakbild…\",\n        \"set_front_image\": \"Frambild…\",\n        \"set_image\": \"Välj bild…\",\n        \"show\": \"Visa\",\n        \"show_configuration\": \"Visa konfigureringen\",\n        \"skip\": \"Hoppa över\",\n        \"split\": \"Dela\",\n        \"stop\": \"Stoppa\",\n        \"submit\": \"Skicka\",\n        \"submit_stash_box\": \"Skicka till Stash-Box\",\n        \"submit_update\": \"Skicka uppdatering\",\n        \"swap\": \"Byt\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Är du säker att du vill rensa? Detta kommer radera databasinformation och genererade filer för alla scener och gallerier som inte längre finns på filsystemet.\",\n            \"dry_mode_selected\": \"Torrt läge valt. Inget kommer raderas utan bara loggning kommer ske.\",\n            \"import_warning\": \"Är du säker att du vill importera? Detta kommer radera databasen och importera från din tidigare exporterade metadata.\"\n        },\n        \"temp_disable\": \"Inaktivera tillfälligt…\",\n        \"temp_enable\": \"Aktivera tillfälligt…\",\n        \"unset\": \"Oinställ\",\n        \"use_default\": \"Använd standard\",\n        \"view_random\": \"Visa slumpad\",\n        \"disable\": \"Avaktivera\",\n        \"enable\": \"Aktivera\",\n        \"reload\": \"Ladda om\",\n        \"copy_to_clipboard\": \"Kopiera till urklipp\",\n        \"add_manual_date\": \"Lägg till manuellt datum\",\n        \"add_o\": \"Lägg till O\",\n        \"add_play\": \"Lägg till spelning\",\n        \"choose_date\": \"Välj ett datum\",\n        \"clear_date_data\": \"Rensa datumdata\",\n        \"clean_generated\": \"Rensa genererade filer\",\n        \"remove_date\": \"Ta bort datum\",\n        \"view_history\": \"Visningshistorik\",\n        \"reset_cover\": \"Återställ Standardomslag\",\n        \"reset_play_duration\": \"Återställ uppspelad tid\",\n        \"add_sub_groups\": \"Lägg Till Undergrupper\",\n        \"remove_from_containing_group\": \"Ta bort från Grupp\",\n        \"reset_resume_time\": \"Återställ återupptagningstid\",\n        \"set_cover\": \"Välj som Omslag\",\n        \"play\": \"Spela\",\n        \"show_count_results\": \"Visa {count} resultat\",\n        \"sidebar\": {\n            \"toggle\": \"Ändra sidolisten\",\n            \"close\": \"Stäng sidolisten\",\n            \"open\": \"Öppna sidolisten\"\n        },\n        \"show_results\": \"Visa resultat\",\n        \"load\": \"Ladda\",\n        \"load_filter\": \"Ladda filter\",\n        \"add_stash_id\": \"Lägg till Stash ID\",\n        \"create_new\": \"Skapa ny\",\n        \"save_and_new\": \"Spara & ny\",\n        \"invert_selection\": \"Invertera markering\",\n        \"reveal_in_file_manager\": \"Visa i filhanteraren\",\n        \"select_directory\": \"Välj mapp\"\n    },\n    \"actions_name\": \"Handlingar\",\n    \"age\": \"Ålder\",\n    \"aliases\": \"Alias\",\n    \"all\": \"Allt\",\n    \"also_known_as\": \"Även känd som\",\n    \"appears_with\": \"Uppträder Med\",\n    \"ascending\": \"Stigande\",\n    \"audio_codec\": \"Ljudcodec\",\n    \"average_resolution\": \"Genomsnittlig upplösning\",\n    \"between_and\": \"och\",\n    \"birth_year\": \"Födelseår\",\n    \"birthdate\": \"Födelsedatum\",\n    \"bitrate\": \"Bithastighet\",\n    \"blobs_storage_type\": {\n        \"database\": \"Databas\",\n        \"filesystem\": \"Filsystem\"\n    },\n    \"captions\": \"Undertexter\",\n    \"career_length\": \"Karriärlängd\",\n    \"chapters\": \"Kapitel\",\n    \"circumcised\": \"Omskuren\",\n    \"circumcised_types\": {\n        \"CUT\": \"Omskuren\",\n        \"UNCUT\": \"Ej omskuren\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktiv stash-box instans:\",\n            \"blacklist_desc\": \"Svartlistade objekt exkluderas från sökningar. Notera att de är regex och skiftlägeskänsliga. Vissa karaktärer måste unvikas med ett snedstreck: {chars_require_escape}\",\n            \"blacklist_label\": \"Svartlista\",\n            \"mark_organized_desc\": \"Markera scenen som Organiserad direkt efter att Spara-knappen trycks.\",\n            \"mark_organized_label\": \"Markera som Organiserad vid sparning\",\n            \"query_mode_auto\": \"Auto\",\n            \"query_mode_auto_desc\": \"Använder metadata om det finns, annars filnamn\",\n            \"query_mode_dir\": \"Mapp\",\n            \"query_mode_dir_desc\": \"Använder enbart videofilens mapp\",\n            \"query_mode_filename\": \"Filnamn\",\n            \"query_mode_filename_desc\": \"Använder enbart filnamn\",\n            \"query_mode_label\": \"Sökläge\",\n            \"query_mode_metadata\": \"Metadata\",\n            \"query_mode_metadata_desc\": \"Använder enbart metadata\",\n            \"query_mode_path\": \"Filsökväg\",\n            \"query_mode_path_desc\": \"Använder fullständing filsökväg\",\n            \"set_cover_desc\": \"Ersätt miniatyrbild om en hittas.\",\n            \"set_cover_label\": \"Välj scenens miniatyrbild\",\n            \"set_tag_desc\": \"Tagga scenen antingen genom att skriva över eller slå samman med de redan existerande.\",\n            \"set_tag_label\": \"Tagga\",\n            \"source\": \"Källa\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Duplicera svartlistat objekt\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Stjärnors kön\",\n                \"description\": \"Stjärnor med dessa kön kommer visas när scener taggas.\"\n            }\n        },\n        \"noun_query\": \"Query\",\n        \"results\": {\n            \"duration_off\": \"Speltiden fel med minst {number}s\",\n            \"duration_unknown\": \"Okänd speltid\",\n            \"fp_found\": \"{fpCount, plural, =0 {Inga nya fingeravtryck hittades} other {# Nya fingeravtryck hittades}}\",\n            \"fp_matches\": \"Speltid matchar\",\n            \"fp_matches_multi\": \"Speltiden matchar {matchCount}/{durationsLength} fingeravtryck\",\n            \"hash_matches\": \"{hash_type} matchar\",\n            \"match_failed_already_tagged\": \"Scen redan taggad\",\n            \"match_failed_no_result\": \"Inga resultat hittades\",\n            \"match_success\": \"Scen taggad\",\n            \"phash_matches\": \"{count} PHashar matchar\",\n            \"unnamed\": \"Namnlös\"\n        },\n        \"verb_match_fp\": \"Matcha fingeravtryck\",\n        \"verb_matched\": \"Matchad\",\n        \"verb_scrape_all\": \"Skrapa alla\",\n        \"verb_submit_fp\": \"Skicka {fpCount, plural, one{# Fingeravtryck} other{# Fingeravtryck}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} ej matchade scener\",\n        \"verb_add_as_alias\": \"Lägg til skrapat namn som alias\",\n        \"verb_link_existing\": \"Länk till existerande\",\n        \"verb_match_tag\": \"Matcha Tagg\",\n        \"verb_scrape_selected\": \"Skrapa utvalda\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Bygghash:\",\n            \"build_time\": \"Byggtid:\",\n            \"check_for_new_version\": \"Kolla efter ny version\",\n            \"latest_version\": \"Senaste version\",\n            \"latest_version_build_hash\": \"Senaste versionens bygghash:\",\n            \"new_version_notice\": \"[Ny]\",\n            \"release_date\": \"Utgivningsdatum:\",\n            \"stash_discord\": \"Gå med i vår {url}-kanal\",\n            \"stash_home\": \"Stash hemma vid {url}\",\n            \"stash_open_collective\": \"Stötta oss genom {url}\",\n            \"stash_wiki\": \"Stash {url}-sida\",\n            \"version\": \"Version\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Applikationssökväg\"\n        },\n        \"categories\": {\n            \"about\": \"Om\",\n            \"changelog\": \"Ändringslogg\",\n            \"interface\": \"Gränssnitt\",\n            \"logs\": \"Loggar\",\n            \"metadata_providers\": \"Metadataleverantörer\",\n            \"plugins\": \"Tillägg\",\n            \"scraping\": \"Skrapare\",\n            \"security\": \"Säkerhet\",\n            \"services\": \"Tjänster\",\n            \"system\": \"System\",\n            \"tasks\": \"Uppgifter\",\n            \"tools\": \"Verktyg\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Tillåt {tempIP}\",\n            \"allowed_ip_addresses\": \"Tillåtna IP-adresser\",\n            \"allowed_ip_temporarily\": \"Tillåter IP temporärt\",\n            \"default_ip_whitelist\": \"Standard IP-vitlista\",\n            \"default_ip_whitelist_desc\": \"Standard IP-adress för att komma åt DLNA. Använd {wildcard} för att tillåta alla IP-adresser.\",\n            \"disabled_dlna_temporarily\": \"Inaktivera DLNA temporärt\",\n            \"disallowed_ip\": \"Otillåtna IP\",\n            \"enabled_by_default\": \"På som standard\",\n            \"enabled_dlna_temporarily\": \"Aktivera DLNA temporärt\",\n            \"network_interfaces\": \"Nätverksgränssnitt\",\n            \"network_interfaces_desc\": \"Nätverksgränssnitt att visa DLNA på. En tom lista gör att DLNA kör på alla gränssnitt. Kräver DLNA-omstart efter ändring.\",\n            \"recent_ip_addresses\": \"Senaste IP-adresser\",\n            \"server_display_name\": \"Serverns visningsnamn\",\n            \"server_display_name_desc\": \"Visningsnamnet för DLNA-servern. Återgår till standard {server_name} om tom.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Lyckad avbrytning av temporärt beteende\",\n            \"until_restart\": \"tills omstart\",\n            \"video_sort_order\": \"Standard scen sorteringsordning\",\n            \"video_sort_order_desc\": \"Ordningen som scener sorteras i som standard.\",\n            \"server_port_desc\": \"Port att köra DLNA-servern från. Kräver omstart av DLNA efter ändring.\",\n            \"server_port\": \"Serverport\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API-nyckel\",\n                \"api_key_desc\": \"API-nyckel för externa system. Krävs bara när användarnamn/lösenord är satt. Användarnamn måste vara sparat innan API-nyckeln genereras.\",\n                \"authentication\": \"Autentisering\",\n                \"clear_api_key\": \"Rensa API-nyckel\",\n                \"credentials\": {\n                    \"description\": \"Uppgifter för att begränsa tillgången till Stash.\",\n                    \"heading\": \"Uppgifter\"\n                },\n                \"generate_api_key\": \"Generera API-nyckel\",\n                \"log_file\": \"Loggfil\",\n                \"log_file_desc\": \"Sökväg till en fil att logga till. Tom för att inte logga till fil. Kräver omstart.\",\n                \"log_http\": \"Logga HTTP-åtkomst\",\n                \"log_http_desc\": \"Loggar HTTP-åtkomst till terminalen. Kräver omstart.\",\n                \"log_to_terminal\": \"Logga till terminal\",\n                \"log_to_terminal_desc\": \"Loggar till terminalen samt en fil. Alltid sant om filloggning är avstängt. Kräver omstart.\",\n                \"maximum_session_age\": \"Maximal inloggningstid\",\n                \"maximum_session_age_desc\": \"Maximal väntetid innan inloggingen upphör, i sekunder. Kräver omstart.\",\n                \"password\": \"Lösenord\",\n                \"password_desc\": \"Lösenord till Stash. Lämna tom för att inaktivera användarautentisering\",\n                \"stash-box_integration\": \"Integration med Stash-box\",\n                \"username\": \"Användarnamn\",\n                \"username_desc\": \"Användarnamn till Stash. Lämna tom för att inaktivera användarautentisering\",\n                \"log_file_max_size\": \"Maximal loggstorlek\",\n                \"log_file_max_size_desc\": \"Maximal storlek i megabytes för loggfilen innan den komprimeras. 0MB tolkas som avstängt. Kräver omstart.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Filsökväg för SQLite-databasens backupfil.\",\n                \"heading\": \"Backup filsökväg\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Var i filsystemet som binär data ska lagras. Används bara när Blobbar lagras i Filsystemet. VARNING: ändring kräver manuell flytt av redan existerande data.\",\n                \"heading\": \"Binär data filsystemssökväg\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"Var binär data som scenomslag, stjärnor-, studio-, och taggbilder ska lagras. Efter ändring måste den redan existerande datan migreras genom Migrera blobbar-uppgiften. Se Uppgifter-sidan för migrering.\",\n                \"heading\": \"Binär data lagringstyp\"\n            },\n            \"cache_location\": \"Mappsökväg till cache. Krävs om strömning använder HLS (som på Apple-enheter) eller DASH.\",\n            \"cache_path_head\": \"Cache-sökväg\",\n            \"calculate_md5_and_ohash_desc\": \"Beräkna MD5 checksumma i tillägg med ohash. Aktivering kan sakta ner första skanningar. Hashen måste vara vald till ohash för att avaktivera MD5-beräkning.\",\n            \"calculate_md5_and_ohash_label\": \"Beräkna MD5 för videor\",\n            \"check_for_insecure_certificates\": \"Kolla efter osäkra certifikat\",\n            \"check_for_insecure_certificates_desc\": \"Vissa webbplatser använder osäkra SSL-certifikat. När detta är avstängt kommer skraparen att kunna skrapa webbplatser med osäkra certifikat. Stäng av detta om du får certifikatfel vid skrapning.\",\n            \"chrome_cdp_path\": \"Chrome CDP-sökväg\",\n            \"chrome_cdp_path_desc\": \"Sökväg till Chrome-programfilen, eller en fjärradress (börjar med http:// eller https://, till exempel http://localhost:9222/json/version) till en Chrome-instans.\",\n            \"create_galleries_from_folders_desc\": \"Om sant, skapar gallerier från mappar som innehåller bilder. Skapa en fil med namn .forcegalllery i en mapp för att ignorera denna inställning.\",\n            \"create_galleries_from_folders_label\": \"Skapa gallerier från mappar som innehåller bilder\",\n            \"database\": \"Databas\",\n            \"db_path_head\": \"Databassökväg\",\n            \"directory_locations_to_your_content\": \"Sökväg till ditt innehåll\",\n            \"excluded_image_gallery_patterns_desc\": \"Regexps av bilder och gallery filer/sökväg att exkludera från Skanna och lägga till på Rensa-uppgifterna.\",\n            \"excluded_image_gallery_patterns_head\": \"Mönster för bild/galleri ignorering\",\n            \"excluded_video_patterns_desc\": \"Regexps av video filer/sökväg att exkludera från Skanna och lägga till på Rensa-uppgifter.\",\n            \"excluded_video_patterns_head\": \"Exluderade video-mönster\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"Använder tillgänglig hårdvara för liveomkodning av video.\",\n                    \"heading\": \"FFmpeg hårdvaruomkodning\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avancerat: Ytterligare argument att skicka till ffmpeg innan input-fältet vid live videogenerering.\",\n                        \"heading\": \"FFmpeg liveomkodning input argument\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avancerat: Ytterligare argument att skicka till ffmpeg innan output-fältet vid live videogenerering.\",\n                        \"heading\": \"FFmpeg liveomkodning output argument\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Avancerat: Ytterligare argument att skicka till ffmpeg innan input-fältet vid videogeneration.\",\n                        \"heading\": \"FFmpeg omkodning input argument\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Avancerat: Ytterligare argument att skicka till ffmpeg innan output-fältet vid videogenerering.\",\n                        \"heading\": \"FFmpeg omkodning output argument\"\n                    }\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"FFmpeg program sökväg\",\n                    \"description\": \"Sökväg till ffmpeg-programmet (inte bara mappen). Om tom kommer ffmpeg att hittas från miljön via $PATH, konfigurationsmappen, eller från $HOME/.stash.\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Sökväg till ffprobe-programmet (inte bara mappen). Om tom kommer ffprobe att hittas från miljön via $PATH, konfigurationsmappen, eller från $HOME/.stash.\",\n                    \"heading\": \"FFprobe program sökväg\"\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"Laddar ner FFmpeg till konfigurationsmappen och nollställer ffmpeg- och ffprobe-sökvägarna för att istället använda konfigurationsmappen.\",\n                    \"heading\": \"Ladda ner FFmpeg\"\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"Inkludera räckvidd i genererade värmekartor\",\n            \"funscript_heatmap_draw_range_desc\": \"Visa y-axelns rörelseräckvidd på den genererade värmekartan. Existerande värmekartor kommer behöva genereras om efter ändring.\",\n            \"gallery_cover_regex_desc\": \"Regexps som används för att identifiera en bild som galleriomslag.\",\n            \"gallery_cover_regex_label\": \"Galleriomslagsmönster\",\n            \"gallery_ext_desc\": \"Komma-avgränsad lista av filtillägg som kommer identifieras som galleri ZIP-filer.\",\n            \"gallery_ext_head\": \"Galleri ZIP-tillägg\",\n            \"generated_file_naming_hash_desc\": \"Använd MD5 eller ohash för att döpa genererade filer. Ett byte kräver att alla scener har ett värde för MD5/oshash. Efter ett byte måste existerande genererade filer migreras eller återgenereras. Se Job-sidan för migration.\",\n            \"generated_file_naming_hash_head\": \"Genererad fil namn-hash\",\n            \"generated_files_location\": \"Mappsökväg för genererade filer (markörer, förhandsvisningar, sprites, m.m.).\",\n            \"generated_path_head\": \"Genererad filsökväg\",\n            \"hashing\": \"Hashande\",\n            \"heatmap_generation\": \"Funscript Värmekarta Generering\",\n            \"image_ext_desc\": \"Kommaavgränsad lista av filändelser som kommer identifieras som bilder.\",\n            \"image_ext_head\": \"Bildfiländelser\",\n            \"include_audio_desc\": \"Inkluderar ljud vid förhandsvisningsgeneration.\",\n            \"include_audio_head\": \"Inkludera ljud\",\n            \"logging\": \"Loggning\",\n            \"maximum_streaming_transcode_size_desc\": \"Maximal storlek av transkodade strömmar.\",\n            \"maximum_streaming_transcode_size_head\": \"Maximal transkodningsstorlek på strömmar\",\n            \"maximum_transcode_size_desc\": \"Maximal storlek för genererade transkodningar.\",\n            \"maximum_transcode_size_head\": \"Maximal transkodningsstorlek\",\n            \"metadata_path\": {\n                \"description\": \"Mappsökväg att använda vid en komplett export eller import.\",\n                \"heading\": \"Metadatasökväg\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Ställ på 0 för auto. Varning-Att köra fler jobb än vad som krävs för att nå 100% CPU-användning kommer försämra prestandan och kan orsaka andra problem.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Antal parallella jobb för skanning/generering\",\n            \"parallel_scan_head\": \"Parallell Skan/Generering\",\n            \"preview_generation\": \"Generera förhandsvisningar\",\n            \"python_path\": {\n                \"description\": \"Sökväg till python-executablen (inte mappen). Används för skriptade skrapare och plugins. Om blank, kommer Python att hanteras från miljön.\",\n                \"heading\": \"Python program sökväg\"\n            },\n            \"scraper_user_agent\": \"Användaragent för skrapning\",\n            \"scraper_user_agent_desc\": \"Användaragent-sträng som används under HTTP-förfrågningar under skrapning.\",\n            \"scrapers_path\": {\n                \"description\": \"Mappsökväg till skraparnas konfigurationsfiler.\",\n                \"heading\": \"Skraparnas sökväg\"\n            },\n            \"scraping\": \"Skrapning\",\n            \"sqlite_location\": \"Filsökväg för SQLite databasen (kräver omstart). VARNING: lagring av databasen på ett annat system än det som Stash körs ifrån (t.ex. över nätvärket) är inte stöttat!\",\n            \"video_ext_desc\": \"Kommaavgränsad lista av filändelser som kommer identifieras som videor.\",\n            \"video_ext_head\": \"Videofiltillägg\",\n            \"video_head\": \"Video\",\n            \"plugins_path\": {\n                \"description\": \"Sökväg till pluginkonfigurationsfiler.\",\n                \"heading\": \"Pluginsökväg\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"Sökväg dit raderade filer kommer flyttas till instället för att permanent raderas. Lämna blankt för att permanent radera filer.\",\n                \"heading\": \"Skräpsökväg\"\n            },\n            \"sprite_generation_head\": \"Sprite-generering\",\n            \"sprite_interval_desc\": \"Tid i sekunder mellan varje genererad sprite.\",\n            \"sprite_interval_head\": \"Sprite-intervall\",\n            \"sprite_maximum_desc\": \"Maximalt antal sprites att generera för en scen. Sätt till 0 för att inte begränsa.\",\n            \"sprite_maximum_head\": \"Maximalt antal sprites\",\n            \"sprite_minimum_desc\": \"Lägst antal sprites att generera för en scen\",\n            \"sprite_minimum_head\": \"Lägst antal sprites\",\n            \"sprite_screenshot_size_desc\": \"Önskad storlek i pixlar för varje sprite.\",\n            \"sprite_screenshot_size_head\": \"Sprite-storlek\",\n            \"use_custom_sprite_interval_head\": \"Använd egen sprite-intervall\",\n            \"use_custom_sprite_interval_desc\": \"Aktivera den egna sprite-intervallen enligt inställningarna nedan.\"\n        },\n        \"library\": {\n            \"exclusions\": \"Exkludera\",\n            \"gallery_and_image_options\": \"Galleri- och Bildinställningar\",\n            \"media_content_extensions\": \"Mediafiltillägg\"\n        },\n        \"logs\": {\n            \"log_level\": \"Loggnivå\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Krokar\",\n            \"triggers_on\": \"Triggar på\",\n            \"installed_plugins\": \"Installerade Plugins\",\n            \"available_plugins\": \"Tillgängliga Plugins\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metadata\",\n            \"entity_scrapers\": \"{entityType} skrapare\",\n            \"excluded_tag_patterns_desc\": \"Regexps av tagg-namn att exkludera från skrapresultat.\",\n            \"excluded_tag_patterns_head\": \"Exkluderade tagg-mönster\",\n            \"scraper\": \"Skrapare\",\n            \"scrapers\": \"Skrapare\",\n            \"search_by_name\": \"Sök med namn\",\n            \"supported_types\": \"Stöttade typer\",\n            \"supported_urls\": \"URL:er\",\n            \"available_scrapers\": \"Tillgängliga Skrapare\",\n            \"installed_scrapers\": \"Installerade Skrapare\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Lägg till stash-box instans\",\n            \"api_key\": \"API-nyckel\",\n            \"description\": \"Stash-box möjliggör automatiskt taggande baserat på fingeravtryck och filnamn.\\nAdress och API-nyckel kan hittas på din kontosida på stash-box instansen. Namn är obligatoriskt när flera instanser läggs till.\",\n            \"endpoint\": \"Adress\",\n            \"graphql_endpoint\": \"GraphQL-adress\",\n            \"name\": \"Namn\",\n            \"title\": \"Stash-box Adresser\",\n            \"max_requests_per_minute\": \"Högsta antal förfrågningar per minut\",\n            \"max_requests_per_minute_description\": \"Använder standardvärdet {defaultValue} om detta är 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Omkodning\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Köade {operation_name}\",\n            \"anonymise_and_download\": \"Skapar en anonymiserad kopia av databasen och laddar ner kopian.\",\n            \"anonymise_database\": \"Skapar en kopia av databasen till backup-sökvägen och anonymiserar all känslig data. Denna kan sedan skickas till andra för felsökning och problemlösning. Originaldatabasen påverkas ej. Anonymiserad databas använder filnamnsformatet {filename_format}.\",\n            \"anonymising_database\": \"Anonymiserar databas\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Autotagga alla sökvägar\",\n                \"auto_tagging_paths\": \"Autotagga följande sökvägar\"\n            },\n            \"auto_tag_based_on_filenames\": \"Tagga innehåll automatiskt baserat på sökvägar.\",\n            \"auto_tagging\": \"Automatisk taggning\",\n            \"backing_up_database\": \"Säkerhetskopierar databas\",\n            \"backup_and_download\": \"Säkerhetskopierar databasen och laddar ner den resulterande filen.\",\n            \"cleanup_desc\": \"Kollar efter saknade filer och raderar dem från databasen. Detta är en destruktiv handling.\",\n            \"data_management\": \"Datahantering\",\n            \"defaults_set\": \"Standard har valts och kommer användas när {action} trycks på Jobbsidan.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Inkludera inte filändelse som del av titeln\",\n            \"empty_queue\": \"Inga jobb körs nu.\",\n            \"export_to_json\": \"Exporterar databasens innehåll i JSON format till metadatamappen.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Genererar för scener från följande sökvägar\",\n                \"generating_scenes\": \"Genererar för {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"Generera förhandsvisningar för bildklipp\",\n            \"generate_desc\": \"Generera stöttande bilder, sprites, videor, vtt och andra filer.\",\n            \"generate_phashes_during_scan\": \"Generera videoperceptuella hashkoder\",\n            \"generate_phashes_during_scan_tooltip\": \"För deduplikation och identifiering av scener.\",\n            \"generate_previews_during_scan\": \"Generera animerade bildförhandsvisningar\",\n            \"generate_previews_during_scan_tooltip\": \"Generera också animerade (webp) förhandsvisningar, krävs bara när Förhandsvisning i Scen/Markörväggen är Animerad bild. Vid surfande använder de mindre CPU än videoförhandsvisngar, men genereras bredvid de och är större filer.\",\n            \"generate_sprites_during_scan\": \"Generera sprites för videoskrubbaren\",\n            \"generate_thumbnails_during_scan\": \"Generera miniatyrer för bilder\",\n            \"generate_video_covers_during_scan\": \"Generera scenomslag\",\n            \"generate_video_previews_during_scan\": \"Generera förhandsvisningar\",\n            \"generate_video_previews_during_scan_tooltip\": \"Generera videoförhandsvisningar som spelas när man håller över en video\",\n            \"generated_content\": \"Genererat Material\",\n            \"identify\": {\n                \"and_create_missing\": \"och skapa saknade\",\n                \"create_missing\": \"Skapa saknade\",\n                \"default_options\": \"Standardalternativ\",\n                \"description\": \"Ställ in scenmetadata automatiskt från stash-box och skraparkällor.\",\n                \"explicit_set_description\": \"Följande alternativ kommer användas om de inte åsidosätts av källspecifika alternativ.\",\n                \"field\": \"Fält\",\n                \"field_behaviour\": \"{strategy} [field}\",\n                \"field_options\": \"Alternativ för fält\",\n                \"heading\": \"Identifiera\",\n                \"identifying_from_paths\": \"Identifierar scener från följande sökvägar\",\n                \"identifying_scenes\": \"Identifierar {num} {scene}\",\n                \"include_male_performers\": \"Inkludera manliga stjärnor\",\n                \"set_cover_images\": \"Ställ in omslagsbild\",\n                \"set_organized\": \"Ställ in organiserad-flagga\",\n                \"skip_multiple_matches\": \"Skippa matchningar som har fler än ett resultat\",\n                \"skip_multiple_matches_tooltip\": \"Om detta är avstängt och mer än ett resultat hittas, kommer en att slumpmässigt väljas som match\",\n                \"skip_single_name_performers\": \"Skippa singelnamnsstjärnor som saknar förtydling\",\n                \"skip_single_name_performers_tooltip\": \"Om detta är avstängt kommer stjärnor med vanliga namn som t.ex. Samantha eller Olga att matchas\",\n                \"source\": \"Källa\",\n                \"source_options\": \"{source} Alternativ\",\n                \"sources\": \"Källor\",\n                \"strategy\": \"Strategi\",\n                \"tag_skipped_matches\": \"Tagga skippade matchningar med\",\n                \"tag_skipped_matches_tooltip\": \"Skapa en tagg t.ex. \\\"Identifiera: Flera Matchningar\\\" som man kan filtrera i Scentaggvyn och välja korrekt matchning manuellt\",\n                \"tag_skipped_performer_tooltip\": \"Skapa en tagg t.ex. \\\"Identifiera: Singelnamnsstjärnor\\\" som man kan filtrera i Scentaggvyn och välj hur man hanterar dessa stjärnor\",\n                \"tag_skipped_performers\": \"Tagga skippade stjärnor med\",\n                \"performer_genders\": \"Stjärnors kön\",\n                \"performer_genders_desc\": \"Stjärnor med de valda könen kommer att inkluderas i identifieringen.\"\n            },\n            \"import_from_exported_json\": \"Importera från den exporterade JSON-filen i metadatamappen. Rensar den existerande databasen.\",\n            \"incremental_import\": \"Stegvis import från en exporterad zip-fil.\",\n            \"job_queue\": \"Uppgiftskö\",\n            \"maintenance\": \"Underhåll\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Radera gammal data\",\n                \"description\": \"Migrera blobbar till det nuvarande bloblagringssystemet. Denna migrering borde köras efter ändring av bloblagringssystemet. Kan också radera den gamla datan efter migreringen.\"\n            },\n            \"migrate_hash_files\": \"Används efter att genererade filers namn-hash har ändrats för att döpa om existerande filer till ny namnhash.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Radera skärmbildsfiler\",\n                \"description\": \"Migrera skärmbilder till det nuvarande bloblagringssystemet. Denna migrering borde köras efter uppdatering av ett äldre system till 0.20. Kan också radera de gamla skärmbilderna efter migreringen.\",\n                \"overwrite_existing\": \"Skriv över redan existerande blobbar med skärmbildsdata\"\n            },\n            \"migrations\": \"Migration\",\n            \"only_dry_run\": \"Kör bara en torr rensning. Radera ingenting\",\n            \"optimise_database\": \"Försök att förbättra prestandan genom att analysera och sedan bygga om hela databasfilen.\",\n            \"optimise_database_warning\": \"Varning: medan detta körs kommer alla handlingar som förändrar databasen misslyckas, och beroende på storleken av databasen kan det ta flera minuter. Det krävs också minst lika mycket tomt lagringsutrymme som databasen är stor men 1.5 ggr större är rekommenderat.\",\n            \"plugin_tasks\": \"Tilläggsuppgifter\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Skannar alla sökvägar\",\n                \"scanning_paths\": \"Skannar följande sökvägar\"\n            },\n            \"scan_for_content_desc\": \"Skannar för nytt innehåll och lägger till det i databasen.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Sätt namn, datum, beskrivning från medföljande metadata\",\n            \"generate_sprites_during_scan_tooltip\": \"Bilderna som visas under videospelaren för enkel navigation.\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blobbfiler\",\n                \"description\": \"Tar bort genererade filer som saknar en matchande databasfil.\",\n                \"image_thumbnails\": \"Bildförhandsvisningar\",\n                \"image_thumbnails_desc\": \"Bildförhandsvisningar och korta videor\",\n                \"markers\": \"Markörförhandsvisningar\",\n                \"previews\": \"Scenförhandsvisningar\",\n                \"previews_desc\": \"Scenförhandsvisningar och omslagsbilder\",\n                \"sprites\": \"Scensprites\",\n                \"transcodes\": \"Scenomkodningar\"\n            },\n            \"rescan\": \"Skanna om filer\",\n            \"rescan_tooltip\": \"Skanna om alla filer i sökvägen. Används för att tvångsuppdatera filmetadata och att skanna om zip-filer.\",\n            \"generate_image_phashes_during_scan\": \"Generera bildperceptuella hashkoder\",\n            \"generate_image_phashes_during_scan_tooltip\": \"För deduplicering och identifiering.\",\n            \"backup_database\": {\n                \"description\": \"Genomför en säkerhetskopia av databasen och blobb-filerna.\",\n                \"destination\": \"Destination\",\n                \"download\": \"Ladda ner säkerhetskopia\",\n                \"include_blobs\": \"Inkludera blobbar i säkerhetskopia\",\n                \"include_blobs_desc\": \"Stäng av för att endast säkerhetskopiera SQLites databasfil.\",\n                \"sqlite\": \"Säkerhetskopiafilen kommer vara en kopia av SQLites databasfil, med namnet {filename_format}\",\n                \"to_directory\": \"Till {directory}\",\n                \"warning_blobs\": \"Blobb-filer kommer inte inkluderas i säkerhetskopian. Detta betyder att för att komplett återställa från en säkerhetskopia, så måste blobb-filena vara närvarande på blobb-förvaringsplatsen.\",\n                \"zip\": \"SQLite-databasfilen och blobb-filerna kommer zippas till en enda fil med namnet {filename_format}\"\n            }\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Scen duplikatkontroll\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Lägg till fält\",\n                \"capitalize_title\": \"Använd versaler i titel\",\n                \"display_fields\": \"Visa fält\",\n                \"escape_chars\": \"Använd \\\\ för att undvika speciella karaktärer\",\n                \"filename\": \"Filnamn\",\n                \"filename_pattern\": \"Filnamnsmönster\",\n                \"ignore_organized\": \"Ignorera organiserade filer\",\n                \"ignored_words\": \"Ignorerade ord\",\n                \"matches_with\": \"Matchar med {i}\",\n                \"select_parser_recipe\": \"Välj Parser Recept\",\n                \"title\": \"Scen filnamnsparser\",\n                \"whitespace_chars\": \"Blankstegstecken\",\n                \"whitespace_chars_desc\": \"Dessa tecken kommer ersättas med blanksteg i titeln\"\n            },\n            \"scene_tools\": \"Scenverktyg\",\n            \"graphql_playground\": \"GraphQL lekplats\",\n            \"heading\": \"Verktyg\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Förkorta siffror i kort- och detaljvyn, t.ex. \\\"1831\\\" kommer formateras \\\"1.8k\\\".\",\n                \"heading\": \"Förkorta siffror\"\n            },\n            \"basic_settings\": \"Grundinställningar\",\n            \"custom_css\": {\n                \"description\": \"Sidan måste laddas om för att ändringar ska ta effekt. Det finns ingen garanti att anpassad CSS kommer vara kompatibel med framtida versioner av Stash.\",\n                \"heading\": \"Anpassad CSS\",\n                \"option_label\": \"Använd anpassad CSS\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Sidan måste laddas om för att ändringar ska ta effekt. Det finns ingen garanti att anpassad JavaScript kommer vara kompatibel med framtida versioner av Stash.\",\n                \"heading\": \"Anpassad JavaScript\",\n                \"option_label\": \"Använd anpassad JavaScript\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Ändra individuella lokala strängar. Se https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json för kompletta listan. Sidan måste laddas om för att ändringar ska ske.\",\n                \"heading\": \"Anpassad översättning\",\n                \"option_label\": \"Använd anpassad översättning\"\n            },\n            \"delete_options\": {\n                \"description\": \"Standardalternativ vid radering av bilder, gallerier och scener.\",\n                \"heading\": \"Alternativ för radering\",\n                \"options\": {\n                    \"delete_file\": \"Radera fil som standard\",\n                    \"delete_generated_supporting_files\": \"Radera genererade stödfiler som standard\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Desktopintegration\",\n                \"notifications_enabled\": \"Aktivera notifikationer\",\n                \"send_desktop_notifications_for_events\": \"Skicka skrivbordsnotifikationer för händelser.\",\n                \"skip_opening_browser\": \"Öppna inte webbläsare\",\n                \"skip_opening_browser_on_startup\": \"Öppna inte webbläsaren under serverstart.\"\n            },\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"description\": \"När aktiverat, kommer detaljerna automatiskt expanderas medan en kompakt presentation behålls.\",\n                    \"heading\": \"Kompakta expanderade detaljer\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Visa bakgrundsbild på detaljsidor.\",\n                    \"heading\": \"Aktivera bakgrundsbild\"\n                },\n                \"heading\": \"Detaljsida\",\n                \"show_all_details\": {\n                    \"description\": \"När aktiverat kommer alla detaljer visas och varje detalj kommer passa i en enda kolumn.\",\n                    \"heading\": \"Visa alla detaljer\"\n                }\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Ta bort möjligheten att skapa nya objekt från dropdown-väljare.\",\n                    \"heading\": \"Stäng av skapande via dropdowns\"\n                },\n                \"heading\": \"Redigering\",\n                \"max_options_shown\": {\n                    \"label\": \"Maximalt antal objekt att visa i dropdowns\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Betygstjärnors precision\",\n                        \"options\": {\n                            \"full\": \"Hel\",\n                            \"half\": \"Halv\",\n                            \"quarter\": \"Fjärdedel\",\n                            \"tenth\": \"Tiondel\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Typ av betygsystem\",\n                        \"options\": {\n                            \"decimal\": \"Decimal\",\n                            \"stars\": \"Stjärnor\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Tidsfördröjning i millisekunder för interaktiva skripter.\",\n                \"heading\": \"Funscript-fördröjning (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Koppla ihop\",\n                \"server_offset\": {\n                    \"heading\": \"Serverförskjutning\"\n                },\n                \"status\": {\n                    \"heading\": \"Handy Uppkopplingsstatus\"\n                },\n                \"sync\": \"Synka\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Handy anslutningsnyckel för interaktiva scener. Denna nyckel kommer låta Stash dela din nuvarande sceninformation med handyfeeling.com.\",\n                \"heading\": \"Handy uppkopplingsnyckel\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Bild Ljuslåda\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Riktning\",\n                \"heading\": \"Bildvägg\",\n                \"margin\": \"Åtskiljare (pixlar)\"\n            },\n            \"images\": {\n                \"heading\": \"Bilder\",\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"När ett bibliotek har Videor avstängt kommer videofiler (se videofiltillägg) att skannas som bildklipp..\",\n                        \"heading\": \"Skanna Vvdeofiltillägg som bildklipp\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Skriv miniatyrer till disk när de genereras.\",\n                        \"heading\": \"Skriv bildminiatyrer\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Interaktiva Inställningar\",\n            \"language\": {\n                \"heading\": \"Språk\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Maximal scenspeltid där spelaren kommer loopa videon . Sätt till 0 för att avaktivera.\",\n                \"heading\": \"Maximal loopvaraktighet\"\n            },\n            \"menu_items\": {\n                \"description\": \"Visa eller göm olika flikar på navigationsremsan.\",\n                \"heading\": \"Menyobjekt\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"I procent, hur mycket av scenen som måste spelas innan visningsräknaren ökar.\",\n                \"heading\": \"Minimal visningsprocent\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Sökväg för egna standardstjärnbilder. Lämna blank för att använda inbyggd standard.\",\n                        \"heading\": \"Sökväg för egna stjärnbilder\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Standardvalet är video (mp4) förhandsvisningar. För mindre CPU-använding, kan animerad bild (webp) förhandsvisningar användas. Dessa måste dock genereras bredvid videoförhandsvisningarna och de är större filer.\",\n                \"heading\": \"Förhandsvisningstyp\",\n                \"options\": {\n                    \"animated\": \"Animerad bild\",\n                    \"static\": \"Statisk bild\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Rutnätsvy\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Visa studionamn som text\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Scenspelaren\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"Starta alltid video från början\",\n                    \"auto_start_video\": \"Starta videouppspelning automatiskt\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Autostarta uppspelning från kön, valda eller slumpade från Scener-sidan.\",\n                        \"heading\": \"Autostarta video när valda spelas\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Spela nästa köade scen efter scenens slut.\",\n                        \"heading\": \"Fortsätt spellista som standard\"\n                    },\n                    \"disable_mobile_media_auto_rotate\": \"Inaktivera automatisk rotering av helskärmsmedia på mobil\",\n                    \"enable_chromecast\": \"Aktivera Chromecast\",\n                    \"show_ab_loop_controls\": \"Visa AB-loop kontroller\",\n                    \"show_scrubber\": \"Visa skrubbaren\",\n                    \"track_activity\": \"Spåra scenvisningshistorik\",\n                    \"vr_tag\": {\n                        \"description\": \"VR-knappen kommer endast visas för scener med denna tagg.\",\n                        \"heading\": \"VR-tagg\"\n                    },\n                    \"show_range_markers\": \"Visa räckviddsmarkörer\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Scen / Markörvägg\",\n                \"options\": {\n                    \"display_title\": \"Visa titel och taggar\",\n                    \"toggle_sound\": \"Aktivera ljud\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Antal gånger att försöka bläddra innan flytt till nästa/tidigare objekt. Används bara för Pan Y-läget.\",\n                \"heading\": \"Bläddringsförsök innan flytt\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Visa taggkort när pekaren är på taggmärken.\",\n                \"heading\": \"Taggkort\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Bildspel är tillgängligt för gallerier i väggvisningsläge.\",\n                \"heading\": \"Bildspelsfördröjning (sekunder)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Studiovy\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"Visa också innehåll från underordnade studior i studiovyn.\",\n                        \"heading\": \"Visa underordnade studiors innehåll\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Taggvy\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"Visa också innehåll från underordnade taggar i taggvyn.\",\n                        \"heading\": \"Visa underordande taggars innehåll\"\n                    }\n                }\n            },\n            \"title\": \"Användargränssnitt\",\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"När aktiverat kommer funscripts att skickas direkt från Stash till din Handy-enhet utan att använda tredjeparts Handy-servern. Kräver att Stash kan nås från din Handy-enhet och att en API-nyckel är genererad om Stash har lösenord aktiverat.\",\n                \"heading\": \"Skicka funscripts direkt\"\n            },\n            \"performer_list\": {\n                \"heading\": \"Stjärnlista\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"Visa länkar på stjärnors kort\"\n                    }\n                }\n            },\n            \"sfw_mode\": {\n                \"description\": \"Aktivera om stash inte används för vuxet innehåll. Döljer eller ändrar gränssnitt som är mer fokuserade på vuxet innehåll.\",\n                \"heading\": \"SFW-innehållsläge\"\n            },\n            \"custom_title\": {\n                \"description\": \"Anpassad text som ska läggas till i sidtiteln. Om tomt används standardvärdet \\\"Stash\\\".\",\n                \"heading\": \"Anpassad titel\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"Felsökningsläge\",\n                \"dialog_title\": \"Aktivera felsökningsläge\",\n                \"dialog_description\": \"Detta inaktiverar tillfälligt alla anpassningar för att hjälpa till att diagnostisera problem:\",\n                \"dialog_item_plugins\": \"Alla insticksmoduler\",\n                \"dialog_item_css\": \"Anpassad CSS\",\n                \"dialog_item_js\": \"Anpassad JavaScript\",\n                \"dialog_item_locales\": \"Anpassade lokaler\",\n                \"dialog_log_level\": \"Loggnivån kommer att ställas in på Felsökning för detaljerad diagnostik.\",\n                \"dialog_reload_note\": \"Sidan laddas om automatiskt.\",\n                \"enable\": \"Aktivera & ladda om\",\n                \"overlay_message\": \"Felsökningsläget är aktivt – alla egna anpassningar är avstängda\",\n                \"exit\": \"Avsluta\"\n            }\n        },\n        \"advanced_mode\": \"Avancerat läge\",\n        \"changelog\": {\n            \"header\": \"Uppdateringsnyheter\"\n        }\n    },\n    \"configuration\": \"Konfiguration\",\n    \"countables\": {\n        \"files\": \"{count, plural}, en {File} andra {Files}}\",\n        \"galleries\": \"{count, plural, one {Galleri} other {Gallerier}}\",\n        \"images\": \"{count, plural, one {Bild} other {Bilder}}\",\n        \"markers\": \"{count, plural, one {Markör} other {Markörer}}\",\n        \"performers\": \"{count, plural, one {Stjärna} other {Stjärnor}}\",\n        \"scenes\": \"{count, plural, one {Scen} other {Scener}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Studior}}\",\n        \"tags\": \"{count, plural, one {Tagg} other {Taggar}}\",\n        \"groups\": \"{count, plural, one {Grupp} other {Grupper}}\"\n    },\n    \"country\": \"Land\",\n    \"cover_image\": \"Omslagsbild\",\n    \"created_at\": \"Skapad vid\",\n    \"criterion\": {\n        \"greater_than\": \"Större än\",\n        \"less_than\": \"Mindre än\",\n        \"value\": \"Värde\",\n        \"unsupported\": \"{type} (ej stöttat)\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"mellan\",\n        \"equals\": \"är\",\n        \"excludes\": \"exkluderar\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {allt} other {{djup}}})\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (exkluderar {excludedString})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (exkluderar {excludedString}) (+{depth, plural, =-1 {allt} other {{djup}}})\",\n        \"greater_than\": \"är större än\",\n        \"includes\": \"inkluderar\",\n        \"includes_all\": \"inkluderar allt\",\n        \"is_null\": \"är null\",\n        \"less_than\": \"är mindre än\",\n        \"matches_regex\": \"matchar regex\",\n        \"not_between\": \"inte mellan\",\n        \"not_equals\": \"är inte\",\n        \"not_matches_regex\": \"matchar ej regex\",\n        \"not_null\": \"är inte null\"\n    },\n    \"custom\": \"Anpassad\",\n    \"date\": \"Datum\",\n    \"date_format\": \"ÅÅÅÅ-MM-DD\",\n    \"datetime_format\": \"ÅÅÅÅ-MM-DD HH:MM\",\n    \"death_date\": \"Dödsdatum\",\n    \"death_year\": \"Dödsår\",\n    \"descending\": \"Fallande\",\n    \"description\": \"Beskrivning\",\n    \"detail\": \"Beskrivning\",\n    \"details\": \"Beskrivning\",\n    \"developmentVersion\": \"Utvecklingsversion\",\n    \"dialogs\": {\n        \"create_new_entity\": \"Skapa ny {entity}\",\n        \"delete_alert\": \"De följande {count, plural, one {{singularEntity}} andra {{pluralEntity}}} kommer raderas permanent:\",\n        \"delete_confirm\": \"Är du säker på att du vill radera {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Är du säker att du vill radera detta {singularEntity}? Sålänge filen inte också raderas, kommer {singularEntity} att läggas till igen vid nästa skanning.} other {Är du säker att du vill radera dessa {pluralEntity}? Sålänge filen inte också raderas, kommer dessa {pluralEntity} att läggas till igen vid nästa skanning.}}\",\n        \"delete_entity_simple_desc\": \"{count,plural,one {Är du säker att du vill radera denna {singularEntity}?} other {Är du säker att du vill radera dessa {pluralEntity}?}}\",\n        \"delete_entity_title\": \"{count, plural, one {Radera {singularEntity}} other {Radera {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"... och alla bildfiler som inte är kopplade till något annat galleri.\",\n        \"delete_gallery_files\": \"Radera gallerimapp/zip-fil och alla bilder som inte är kopplade till något annat galleri.\",\n        \"delete_object_desc\": \"Är du säker på att du vill radera {count, plural, one {denna {singularEntity}} other {dessa {pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…och {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"Radera {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"Visa inte tills nästa uppdatering\",\n        \"edit_entity_title\": \"Redigera {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Inkludera relaterade objekt i exporten\",\n        \"export_title\": \"Exportera\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Kolumn\",\n                \"description\": \"Kolumn eller radbaserad layout.\",\n                \"row\": \"Rad\"\n            },\n            \"margin_desc\": \"Antal åtskiljande pixlar runt varje bild.\"\n        },\n        \"lightbox\": {\n            \"delay\": \"Fördröjning (Sekund)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Passa horisontellt\",\n                \"fit_to_screen\": \"Passa till skärmen\",\n                \"label\": \"Visningsläge\",\n                \"original\": \"Original\"\n            },\n            \"options\": \"Alternativ\",\n            \"page_header\": \"Sida {page} / {total}\",\n            \"reset_zoom_on_nav\": \"Återställ zoom vid bildbyte\",\n            \"scale_up\": {\n                \"description\": \"Skala mindre bilder så att de fyller skärmen.\",\n                \"label\": \"Skala upp för att passa\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Håll shift för att tillfälligt använda annat läge.\",\n                \"label\": \"Bläddringsläge\",\n                \"pan_y\": \"Panorera Y\",\n                \"zoom\": \"Zooma\"\n            },\n            \"disable_animation\": \"Stäng av övergångsanimationen mellan bilder\"\n        },\n        \"merge\": {\n            \"destination\": \"Destination\",\n            \"empty_results\": \"Destinationens värden kommer ej ändras.\",\n            \"source\": \"Källa\"\n        },\n        \"performers_found\": \"{count} stjärnor hittade\",\n        \"reassign_entity_title\": \"{count, plural, one {Omplacera {singularEntity}} other {Omplacera {pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Omplacera till\"\n        },\n        \"scene_gen\": {\n            \"clip_previews\": \"Bildklippsförhandsvisning\",\n            \"covers\": \"Scenomslag\",\n            \"force_transcodes\": \"Tvinga omkodning\",\n            \"force_transcodes_tooltip\": \"Som standard blir videofiler bara omkodade om formatet inte stöds i webbläsaren. Vid aktivering kommer alla videofiler bli omkodande, även om de antas vara stöttade i webbläsaren.\",\n            \"image_previews\": \"Animerad bildförhandsvisning\",\n            \"image_previews_tooltip\": \"Generera också animerade (webp) förhandsvisningar, krävs enbart när Förhandsvisning i Scen/Markörvägg är Animerad bild. Vid surfande använder de mindre CPU än videoförhandsvisngar, men genereras bredvid de och är större filer.\",\n            \"interactive_heatmap_speed\": \"Generera värmekartor och hastigheter för interaktiva scener\",\n            \"marker_image_previews\": \"Markörer animerad bildförhandsvisning\",\n            \"marker_image_previews_tooltip\": \"Generera också Animerad (webp) föhandsvisning, krävs bara när Förhandsvisning i Scen/Markörvägg är Animerad bild. Vid surfande använder de mindre CPU än videoförhandsvisngar, men genereras bredvid de och är större filer.\",\n            \"marker_screenshots\": \"Markörskärmklipp\",\n            \"marker_screenshots_tooltip\": \"Markör statiska JPG-bilder\",\n            \"markers\": \"Markörförhandsvisningar\",\n            \"markers_tooltip\": \"20-sekunders videor som börjar vid angiven tidsstämpel.\",\n            \"override_preview_generation_options\": \"Åsidosätt inställningar för förhandsvisningsgeneration\",\n            \"override_preview_generation_options_desc\": \"Åsidosätt inställningar för förhandsvisningsgeneration. Standarden ställs in i System -> Förhandsvisningsgeneration.\",\n            \"overwrite\": \"Ersätt existerande filer\",\n            \"phash\": \"Videoperceptuella hashkoder\",\n            \"preview_exclude_end_time_desc\": \"Exkludera de sista x sekunderna från videoförhandsvisning. Detta kan vara ett värde i sekunder, eller en procent (ex. 2%) av scenes totala speltid.\",\n            \"preview_exclude_end_time_head\": \"Exludera sluttid\",\n            \"preview_exclude_start_time_desc\": \"Exkludera de första x sekunderna från videoförhandsvisning. Detta kan vara ett värde i sekunder, eller en procent (ex. 2%) av scenes totala speltid.\",\n            \"preview_exclude_start_time_head\": \"Exkludera starttid\",\n            \"preview_generation_options\": \"Inställningar för förhandsvisningsgeneration\",\n            \"preview_options\": \"Förhandsvisningsinställningar\",\n            \"preview_preset_desc\": \"Förinställningarna bestämmer storlek, kvalite och kodningstiden of förhandsvisningsgeneration. Förinställningar bortom “långsam” har minskande vinst och är inte rekommenderade.\",\n            \"preview_preset_head\": \"Förinställningar för förhandsvisningskodning\",\n            \"preview_seg_count_desc\": \"Antal segment i förhandsvisningsfiler.\",\n            \"preview_seg_count_head\": \"Antal segment i förhandsvisning\",\n            \"preview_seg_duration_desc\": \"Varaktighet av varje segment i sekunder.\",\n            \"preview_seg_duration_head\": \"Segmentvaraktighet\",\n            \"sprites\": \"Scenskrubbarsprites\",\n            \"sprites_tooltip\": \"Bilderna som visas under videospelaren för enkel navigation.\",\n            \"transcodes\": \"Omkodning\",\n            \"transcodes_tooltip\": \"MP4 omkodningar för-genereras för allt innehåll; användbart för långsamma CPU:er men kräver mycket mer lagringsutrymme\",\n            \"video_previews\": \"Förhandsvisning\",\n            \"video_previews_tooltip\": \"Videoförhandsvisning som visas när man håller över en scen\",\n            \"phash_tooltip\": \"För deduplicering och scenidentifiering\",\n            \"image_thumbnails\": \"Bildomslagsbilder\",\n            \"image_phash\": \"Bildperceptuella hashkoder\",\n            \"image_phash_tooltip\": \"För deduplicering och identifiering\"\n        },\n        \"scenes_found\": \"{count} scener hittades\",\n        \"scrape_entity_query\": \"{entity_type} Skrapa text\",\n        \"scrape_entity_title\": \"{entity_type} Skrapade resultat\",\n        \"scrape_results_existing\": \"Existerande\",\n        \"scrape_results_scraped\": \"Skrapade\",\n        \"set_image_url_title\": \"URL till bild\",\n        \"unsaved_changes\": \"Osparade ändringar. Är du säker att du vill lämna?\",\n        \"clear_o_history_confirm\": \"Är du säker på att du vill radera O-historiken?\",\n        \"clear_play_history_confirm\": \"Är du säker på att du vill radera uppspelningshistoriken?\",\n        \"overwrite_filter_warning\": \"Sparat filter \\\"{entityName}\\\" kommer skrivas över.\",\n        \"set_default_filter_confirm\": \"Är du säker att du vill ställa in detta filter som standard?\",\n        \"clear_o_history_confirm_sfw\": \"Är du säker att du vill radera gillningshistoriken?\",\n        \"delete_alert_to_trash\": \"De följande {count, plural, one {{singularEntity}} other {{pluralEntity}}} kommer flyttas till skräpet:\",\n        \"stashid_exists_warning\": \"Det existerande stash id:et för denna stash-box kommer ersättas.\",\n        \"studios_found\": \"{count} studior hittades\",\n        \"tags_found\": \"{count} taggar hittades\",\n        \"scrape_results_missing\": \"Saknat\"\n    },\n    \"dimensions\": \"Mått\",\n    \"director\": \"Regissör\",\n    \"disambiguation\": \"Särskiljning\",\n    \"display_mode\": {\n        \"grid\": \"Rutnät\",\n        \"list\": \"Lista\",\n        \"tagger\": \"Taggaren\",\n        \"unknown\": \"Okänd\",\n        \"wall\": \"Vägg\",\n        \"label_current\": \"Visningsläge: {current}\"\n    },\n    \"donate\": \"Donera\",\n    \"dupe_check\": {\n        \"description\": \"Nivåer under 'Exakt' kan ta längre tid att beräkna. Falskt positiva svar riskeras också genom att välja en lägre nivå.\",\n        \"duration_diff\": \"Maximal Speltidsskillnad\",\n        \"duration_options\": {\n            \"any\": \"Allt\",\n            \"equal\": \"Lika\"\n        },\n        \"found_sets\": \"{setCount, plural, one{# kopia hittades.} other {# antal kopior hittades.}}\",\n        \"options\": {\n            \"exact\": \"Exakt\",\n            \"high\": \"Hög\",\n            \"low\": \"Låg\",\n            \"medium\": \"Medium\"\n        },\n        \"search_accuracy_label\": \"Sökprecision\",\n        \"title\": \"Duplikata scener\",\n        \"only_select_matching_codecs\": \"Välj bara om alla kodekar matchas i duplikatgruppen\",\n        \"select_all_but_largest_file\": \"Välj alla filer i varje duplikatgrupp, förutom den största filen\",\n        \"select_all_but_largest_resolution\": \"Välj alla filer i duplikatgruppen, förutom filen med högst upplösning\",\n        \"select_none\": \"Välj Ingen\",\n        \"select_options\": \"Valalternativ…\",\n        \"select_youngest\": \"Välj den yngsta filen i duplikatgruppen\",\n        \"select_oldest\": \"Välj den äldsta filen i duplikatgruppen\"\n    },\n    \"duplicated_phash\": \"Duplikat (pHash)\",\n    \"duration\": \"Varaktighet\",\n    \"effect_filters\": {\n        \"aspect\": \"Bildförhållande\",\n        \"blue\": \"Blå\",\n        \"blur\": \"Suddighet\",\n        \"brightness\": \"Ljusstyrka\",\n        \"contrast\": \"Kontrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Grön\",\n        \"hue\": \"Nyans\",\n        \"name\": \"Filter\",\n        \"name_transforms\": \"Förvandlingar\",\n        \"red\": \"Röd\",\n        \"reset_filters\": \"Återställ filter\",\n        \"reset_transforms\": \"Återställ förvandlingar\",\n        \"rotate\": \"Rotera\",\n        \"rotate_left_and_scale\": \"Rotera vänster och skala\",\n        \"rotate_right_and_scale\": \"Rotera höger och skala\",\n        \"saturation\": \"Mättnad\",\n        \"scale\": \"Skala\",\n        \"warmth\": \"Värme\"\n    },\n    \"empty_server\": \"Lägg till några scener i Stash för att se rekommendationer på denna sida.\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"Bildindex måste vara större än 0\",\n        \"lazy_component_error_help\": \"Om du nyligen uppdaterade Stash ladda om sidan eller rensa din webbläsares cache.\",\n        \"loading_type\": \"Fel vid laddning av {type}\",\n        \"something_went_wrong\": \"Något gick fel.\",\n        \"header\": \"Fel\",\n        \"invalid_javascript_string\": \"Ogiltig javascriptkod: {error}\",\n        \"invalid_json_string\": \"Ogiltig JSON-sträng: {error}\",\n        \"custom_fields\": {\n            \"duplicate_field\": \"Fältnamn måste vara unikt\",\n            \"field_name_length\": \"Fältnamnet måste vara kortare än 65 karaktärer\",\n            \"field_name_required\": \"Fältnamn är obligatoriskt\",\n            \"field_name_whitespace\": \"Fältnamn kan inte börja eller sluta med blanksteg\"\n        }\n    },\n    \"ethnicity\": \"Etnicitet\",\n    \"existing_value\": \"existerande värde\",\n    \"eye_color\": \"Ögonfärg\",\n    \"fake_tits\": \"Fejkbröst\",\n    \"false\": \"Falsk\",\n    \"favourite\": \"Favorit\",\n    \"file\": \"fil\",\n    \"file_count\": \"Antal Filer\",\n    \"file_info\": \"Filinfo\",\n    \"file_mod_time\": \"Filens Modifikationstid\",\n    \"files\": \"filer\",\n    \"files_amount\": \"{value} filer\",\n    \"filesize\": \"Filstorlek\",\n    \"filter\": \"Filter\",\n    \"filter_name\": \"Filternamn\",\n    \"filters\": \"Filter\",\n    \"folder\": \"Mapp\",\n    \"framerate\": \"Bildhastighet\",\n    \"frames_per_second\": \"{value} fps\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Förhandsgjorda filter\",\n            \"saved_filter\": \"Sparade Filter\"\n        }\n    },\n    \"galleries\": \"Gallerier\",\n    \"gallery\": \"Galleri\",\n    \"gallery_count\": \"Antal Gallerier\",\n    \"gender\": \"Kön\",\n    \"gender_types\": {\n        \"FEMALE\": \"Kvinna\",\n        \"INTERSEX\": \"Intersex\",\n        \"MALE\": \"Man\",\n        \"NON_BINARY\": \"Icke-binär\",\n        \"TRANSGENDER_FEMALE\": \"Trans Kvinna\",\n        \"TRANSGENDER_MALE\": \"Trans Man\"\n    },\n    \"hair_color\": \"Hårfärg\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Kopplar ihop\",\n        \"disconnected\": \"Kopplad isär\",\n        \"error\": \"Fel vid koppling till Handy\",\n        \"missing\": \"Saknas\",\n        \"ready\": \"Redo\",\n        \"syncing\": \"Synkar med server\",\n        \"uploading\": \"Laddar upp skript\"\n    },\n    \"hasChapters\": \"Kapitel\",\n    \"hasMarkers\": \"Markörer\",\n    \"height\": \"Längd\",\n    \"height_cm\": \"Längd (cm)\",\n    \"help\": \"Hjälp\",\n    \"ignore_auto_tag\": \"Ignorera autotagg\",\n    \"image\": \"Bild\",\n    \"image_count\": \"Antal Bilder\",\n    \"image_index\": \"Bild #\",\n    \"images\": \"Bilder\",\n    \"include_parent_tags\": \"Inkludera överordnade taggar\",\n    \"include_sub_studios\": \"Inkludera underordnade studior\",\n    \"include_sub_tags\": \"Inkludera underordnade taggar\",\n    \"index_of_total\": \"{index} av {total}\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Interaktiv\",\n    \"interactive_speed\": \"Interaktiv Hastighet\",\n    \"isMissing\": \"Saknas\",\n    \"last_played_at\": \"Senast spelad\",\n    \"library\": \"Bibliotek\",\n    \"loading\": {\n        \"generic\": \"Laddar…\",\n        \"plugins\": \"Laddar tillägg…\"\n    },\n    \"marker_count\": \"Antal Markörer\",\n    \"markers\": \"Markörer\",\n    \"measurements\": \"Mått\",\n    \"media_info\": {\n        \"audio_codec\": \"Ljudkodek\",\n        \"downloaded_from\": \"Nedladdad från\",\n        \"interactive_speed\": \"Interaktiv Hastighet\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"{age} {years_old} vid produktion\"\n        },\n        \"phash\": \"PHash\",\n        \"play_count\": \"Visningar\",\n        \"play_duration\": \"Uppspelad Tid\",\n        \"stream\": \"Ström\",\n        \"video_codec\": \"Videokodek\",\n        \"o_count\": \"Antal O\",\n        \"md5\": \"MD5 Checksumma\",\n        \"oshash\": \"oshash\",\n        \"oshash_meaning\": \"OpenSubtitles Hash\",\n        \"phash_meaning\": \"Perceptuell Hash\"\n    },\n    \"megabits_per_second\": \"{value} mbps\",\n    \"metadata\": \"Metadata\",\n    \"name\": \"Namn\",\n    \"new\": \"Ny\",\n    \"none\": \"Ingen\",\n    \"operations\": \"Operationer\",\n    \"organized\": \"Organiserad\",\n    \"pagination\": {\n        \"first\": \"Första\",\n        \"last\": \"Sista\",\n        \"next\": \"Nästa\",\n        \"previous\": \"Föregående\",\n        \"current_total\": \"{current} av {total}\"\n    },\n    \"parent_of\": \"Överordnad till {children}\",\n    \"parent_studio\": \"Överordnad Studio\",\n    \"parent_studios\": \"Överordnad studio\",\n    \"parent_tag_count\": \"Antal Överordnade Taggar\",\n    \"parent_tags\": \"Överordnade Taggar\",\n    \"part_of\": \"Del av {parent}\",\n    \"path\": \"Sökväg\",\n    \"penis\": \"Penis\",\n    \"penis_length\": \"Penislängd\",\n    \"penis_length_cm\": \"Penislängd (cm)\",\n    \"perceptual_similarity\": \"Perceptuell likhet (pHash)\",\n    \"performer\": \"Stjärna\",\n    \"performer_age\": \"Ålder på stjärna\",\n    \"performer_count\": \"Antal Stjärnor\",\n    \"performer_favorite\": \"Favoritiserad stjärna\",\n    \"performer_image\": \"Stjärnbild\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"Lägg till stjärnor\",\n        \"any_names_entered_will_be_queried\": \"Alla namn kommer skickas till Stash-Box-instansen och läggas till om de hittas. Endast exakta matchningar kommer övervägas.\",\n        \"batch_add_performers\": \"Lägg till flera stjärnor\",\n        \"batch_update_performers\": \"Uppdatera flera stjärnor\",\n        \"current_page\": \"Nuvarande sida\",\n        \"failed_to_save_performer\": \"Misslyckades med att spara \\\"{performer}\\\"\",\n        \"name_already_exists\": \"Namnet finns redan\",\n        \"network_error\": \"Nätverksfel\",\n        \"no_results_found\": \"Inga resultat hittades.\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} stjärnor kommer behandlas\",\n        \"performer_already_tagged\": \"Stjärna som redan är taggad\",\n        \"performer_selection\": \"Val av stjärna\",\n        \"performer_successfully_tagged\": \"Lyckad taggning av stjärna:\",\n        \"query_all_performers_in_the_database\": \"Alla stjärnor i databasen\",\n        \"refresh_tagged_performers\": \"Återuppdatera redan taggade stjärnor\",\n        \"refreshing_will_update_the_data\": \"Återuppdatering kommer uppdatera redan taggade stjärnor med data från stash-box-instansen.\",\n        \"status_tagging_job_queued\": \"Status: Taggande köat\",\n        \"status_tagging_performers\": \"Status: Taggar stjärnor\",\n        \"tag_status\": \"Tagg-status\",\n        \"to_use_the_performer_tagger\": \"För att använda stjärntaggaren krävs en konfigurerad stash-box-instans.\",\n        \"untagged_performers\": \"Ej taggade stjärnor\",\n        \"update_performer\": \"Uppdatera stjärna\",\n        \"update_performers\": \"Uppdatera stjärnor\",\n        \"updating_untagged_performers_description\": \"Uppdatering av ej taggade stjärnor kommer försöka att matcha alla stjärnor som saknar StashID och uppdatera metadatan.\",\n        \"performer_names_or_stashids_separated_by_comma\": \"Stjärnors namn eller StashID separerade med komma\"\n    },\n    \"performer_tags\": \"Stjärntagg\",\n    \"performers\": \"Stjärnor\",\n    \"piercings\": \"Piercingar\",\n    \"play_count\": \"Visningar\",\n    \"play_duration\": \"Uppspelad Tid\",\n    \"primary_file\": \"Primär fil\",\n    \"queue\": \"Kö\",\n    \"random\": \"Slumpad\",\n    \"rating\": \"Betyg\",\n    \"recently_added_objects\": \"Nyligen Tillagda {objects}\",\n    \"recently_released_objects\": \"Nyligen Släppta {objects}\",\n    \"release_notes\": \"Versionsfakta\",\n    \"resolution\": \"Upplösning\",\n    \"resume_time\": \"Återupptagningstid\",\n    \"scene\": \"Scen\",\n    \"sceneTagger\": \"Scentaggaren\",\n    \"scene_code\": \"Studiokod\",\n    \"scene_count\": \"Antal Scener\",\n    \"scene_created_at\": \"Scenen Skapad\",\n    \"scene_date\": \"Scenens Datum\",\n    \"scene_id\": \"Scenens ID\",\n    \"scene_tags\": \"Scentaggar\",\n    \"scene_updated_at\": \"Scenen Uppdaterad\",\n    \"scenes\": \"Scener\",\n    \"scenes_updated_at\": \"Scen Uppdaterad Vid\",\n    \"search_filter\": {\n        \"edit_filter\": \"Ändra Filter\",\n        \"name\": \"Filter\",\n        \"saved_filters\": \"Sparade filter\",\n        \"update_filter\": \"Uppdatera filter\",\n        \"more_filter_criteria\": \"+{count} fler\",\n        \"search_term\": \"Sökterm\"\n    },\n    \"second\": \"Sekund\",\n    \"seconds\": \"Sekunder\",\n    \"settings\": \"Inställningar\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Vi är nästan redo att slutfärdiga konfigurationen. Bekräfta följande inställningar. Du kan trycka bakåt för att ändra något inkorrekt. Tryck på Bekräfta om allting ser korrekt ut.\",\n            \"blobs_directory\": \"Binär data mappsökväg\",\n            \"blobs_use_database\": \"<använder databas>\",\n            \"cache_directory\": \"Cache mappsökväg\",\n            \"configuration_file_location\": \"Konfiguration filsökväg:\",\n            \"database_file_path\": \"Databas filsökväg\",\n            \"generated_directory\": \"Genererat sökväg\",\n            \"nearly_there\": \"Nästan framme!\",\n            \"stash_library_directories\": \"Stash bibliotekssökväg\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Skapar ditt system\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Nej! Något gick fel!\",\n            \"something_went_wrong_description\": \"Om det här ser ut som ett problem med dina inputs, klicka bakåt för att fixa det. Annars, vänligen skapa en buggvarning på {githublink} eller hitta hjälp på {githublink}.\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Något gick fel i uppstarten av ditt system. Här är felet vi fick: {error}\",\n            \"unexpected_error\": \"Ett oförväntat fel inträffade: {error}\",\n            \"unable_to_retrieve_system_status\": \"Misslyckades med att hämta systemstatus: {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"Filsökväg\",\n            \"up_dir\": \"Upp en mapp\"\n        },\n        \"github_repository\": \"Github förvaring\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Sökväg för databas-säkerhetskopiering (lämna blank för att inaktivera säkerhetskopiering):\",\n            \"backup_recommended\": \"Det rekommenderas att du skapar en kopia av din existerande databas innan du migrerar. Vi kan göra detta för dig genom att skapa en kopia av din databas till <code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"Migrerar databas\",\n            \"migration_failed\": \"Migration misslyckades\",\n            \"migration_failed_error\": \"Följande fel stöttes på under migrationen av databasen:\",\n            \"migration_failed_help\": \"Vänligen gör alla nödvändiga korrektioner och testa igen. Annars, skapa en buggrapport på {githubLink} eller sök hjälp på {githubLink}.\",\n            \"migration_irreversible_warning\": \"Processen för schemamigration är ej omvändbar. När migrationen har skett kommer din databas inte längre vara kompatibel med tidigare versioner av Stash.\",\n            \"migration_notes\": \"Migrationsfakta\",\n            \"migration_required\": \"Migration krävs\",\n            \"perform_schema_migration\": \"Genomför schemamigration\",\n            \"schema_too_old\": \"Din nuvarande databas använder schemaversion <strong>{databaseSchema}</strong> och behöver migreras till version <strong>{appSchema}</strong>. Denna version av Stash kommer inte fungera utan en databasmigration. Om du inte vill migrera kommer du behöva nedgradera till en version som matcher din databasschema.\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"databasfilnamn (blank för standard)\",\n            \"description\": \"Härnäst, måste vi avgöra var Stash hittar din media, och var databasen, de genererade filerna, och cachen ska lagras . Om det skulle behövas kan dessa inställningar ändras senare.\",\n            \"path_to_blobs_directory_empty_for_default\": \"sökväg för blobmapp (tom för standard)\",\n            \"path_to_cache_directory_empty_for_default\": \"sökväg till cachemappen (tomt för standard)\",\n            \"path_to_generated_directory_empty_for_default\": \"sökväg till mappen för genererade filer (blank för standard)\",\n            \"set_up_your_paths\": \"Ställ in dina sökvägar\",\n            \"stash_alert\": \"Inga bibliotekssökvägar har valts. Ingen media kommer kunna skannas in i Stash. Är du säker?\",\n            \"store_blobs_in_database\": \"Lagra blobbar i databas\",\n            \"where_can_stash_store_blobs\": \"Var kan Stash lagra databasens binära data?\",\n            \"where_can_stash_store_blobs_description\": \"Stash kan lagra binär data som scenomslag, stjärn-, studio-, och taggbilder i antingen databasen eller på filsystemet. Som standard, kommer datan lagras på filsystemet i mappen <code>Blobbar</code>. Om du vill ändra detta skriv en absolut eller relativ (till den nuvarande platsen) sökväg. Stash kommer skapa mappen om den inte redan finns på platsen.\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Alternativt om du vill lagra denna data i databasen kan du lämna detta fält tomt. <strong>Notis:</strong> Detta kommer öka storleken på databasfilen och kommer förlänga databasmigrationstider.\",\n            \"where_can_stash_store_cache_files\": \"Var kan Stash lagra cachefiler?\",\n            \"where_can_stash_store_cache_files_description\": \"För att viss funktionalitet som HLS/DASH-liveomkodning ska fungera kräver Stash en cache-mapp för tillfälliga filer. Som standard kommer Stash skapa en <code>Cache</code> mapp i mappen som innehåller konfigurationsfilen. Om du vill ändra detta skriv en absolut eller relativ (till den nuvarande platsen) sökväg. Stash kommer skapa mappen om den inte redan finns på platsen.\",\n            \"where_can_stash_store_its_database\": \"Var kan Stash lagra sin databas?\",\n            \"where_can_stash_store_its_database_description\": \"Stash använder en SQLite-databas för att spara metadata till din media. Som standard kommer databasen att skapas som <code>stash-go.sqlite</code> i mappen som innehåller din konfigurationsfil. Om du vill ändra detta, ange en absolut eller relativ (till den nuvarande arbetsmappen) filnamn.\",\n            \"where_can_stash_store_its_database_warning\": \"VARNING: att lagra databasen på ett annat system än det som Stash körs ifrån (t.ex. databasen på en NAS medan Stash körs från en annan dator) är <strong>inte stöttat</strong>! SQLite är inte avsett för att använding över nätverket, och att försöka oavsett kan väldigt enkelt korrumpera hela databasen.\",\n            \"where_can_stash_store_its_generated_content\": \"Var kan Stash spara sitt genererade innehåll?\",\n            \"where_can_stash_store_its_generated_content_description\": \"För att kunna erbjuda miniatyrbilder, förhandsvisningar och sprites måste Stash generera bilder och videor. Detta inkluderar också omkodning av ej stöttade filformat. Som standard skapar Stash en <code>generated</code> mapp i mappen som innehåller din konfigurationsfil. Om du vill ändra detta, ange en absolut eller relativ (till din nuvarande arbetsmapp) sökväg. Stash kommer skapa denna mapp om den inte redan finns.\",\n            \"where_is_your_porn_located\": \"Var är din media lagrad?\",\n            \"where_is_your_porn_located_description\": \"Lägg till mappar som innehåller dina videor och bilder. Stash kommer använda dessa mappar för att hitta videor och bilder under skanningen.\",\n            \"sfw_content_settings\": \"Används stash inte för vuxet innehåll?\",\n            \"sfw_content_settings_description\": \"stash kan användas för att hantera icke-vuxen media som fotografier, konst, serietidningar e.t.c.. Aktivering av detta kommer anpassa gränssnittet till att vara mer passande för icke-vuxen media.\",\n            \"use_sfw_content_mode\": \"Använd icke-vuxet läge\"\n        },\n        \"stash_setup_wizard\": \"Stash Starthjälp\",\n        \"success\": {\n            \"getting_help\": \"Få hjälp\",\n            \"help_links\": \"Om du stöter på problem eller har några frågor eller förslag, var vänlig och öppna en problemrapport på {githubLink} eller fråga gemenskapen på {discordLink}.\",\n            \"in_app_manual_explained\": \"Du uppmanas att undersöka den inbyggda manualen som kan nås genom denna ikon i det övre högra hörnet: {icon}\",\n            \"next_config_step_one\": \"Du kommer tas till Konfigurationsidan härnäst. Denna sida kommer låta dig ändra vilka filer som ska inkluderas och exkluderas, välja ett användernamn och lösenord för att skydda ditt system, och en massa andra alternativ.\",\n            \"next_config_step_two\": \"När du är nöjd med dessa inställningar, kan du börja att skanna in din porr till Stash genom att trycka på <code>{localized_task}</code>, och sedan <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Kolla in våran {open_collective_link} för att se hur du kan bidra till den fortsatta utvecklingen Stash.\",\n            \"support_us\": \"Stötta oss\",\n            \"thanks_for_trying_stash\": \"Tack för att du testar Stash!\",\n            \"welcome_contrib\": \"Vi välkomnar också starkt bidrag i form av kod (buggfixar, förbättringar och nya funktioner), testande, buggrapportering, förslag på förbättringar och funktioner, översättningar,och användarstöd. Mer detaljer kan hittas i Bidragsfliken i manualen.\",\n            \"your_system_has_been_created\": \"Framgång! Ditt system har skapats!\",\n            \"download_ffmpeg\": \"Ladda ner ffmpeg\",\n            \"missing_ffmpeg\": \"Du saknar den obligatoriska <code>ffmpeg</code>-programmet. Du kan automatiskt ladda ner den till konfigurationsmappen genom att bocka i rutan under. Alternativt, kan du skriva sökvägarna till <code>ffmpeg</code>- och <code>ffprobe</code>-programmen i Systeminställningarna. Dessa program måste finnas för att Stash ska fungera.\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash försöker att hitta sin konfigurationsfil (<code>config.yml</code>) från den nuvarande arbetsmappen först, och om den inte finns där, letar den i <code>{fallback_path}</code>. Du kan också låta Stash läsa en specifik konfigurationsfil genom att starta med flaggan <code>-c '<path to config file>'</code> eller <code>--config '<path to config file>'</code>.\",\n            \"in_current_stash_directory\": \"I mappen <code>{path}</code>:\",\n            \"in_the_current_working_directory\": \"I <code>{path}</code>, den nuvarande arbetsmappen:\",\n            \"next_step\": \"Med allt det färdigt, kan vi fortsätta med uppstarten om du är redo, vänligen välj var du skulle vilja lagra din konfigurationsfil.\",\n            \"store_stash_config\": \"Var vill du lagra din Stash-konfiguration?\",\n            \"unable_to_locate_config\": \"Om du läser detta kunde Stash inte hitta en existerande konfiguration. Denna hjälp kommer guida dig genom processen av att ställa in en ny konfiguration.\",\n            \"unexpected_explained\": \"Om du plötsligt får denna skärm, försök att starta om Stash i den korrekta arbetsmappen eller med <code>-c</code> flaggan.\",\n            \"in_the_current_working_directory_disabled\": \"I <code>{path}</code>, arbetsmappen:\",\n            \"in_the_current_working_directory_disabled_macos\": \"Stöttas inte när <code>Stash.app</code>,<br></br>kör <code>stash-macos</code> för att starta i arbetsmappen\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash kommer använda följande filsökväg till konfigurationen: <code>{path}</code>\",\n            \"next_step\": \"När du är redo att fortsätta med uppstarten av ett nytt system tryck på Nästa.\",\n            \"unable_to_locate_specified_config\": \"Om du läser detta så kunde Stash inte hitta konfigurationsfilen som specifierades via kommandoraden eller miljön. Denna hjälp kommer guida dig genom processen av att ställa in en ny konfiguration.\"\n        },\n        \"welcome_to_stash\": \"Välkommen till Stash\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_id_endpoint\": \"Stash ID Slutpunkt URL\",\n    \"stash_ids\": \"Stash ID:er\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Gå till {endpoint_name} för att granska utkast.\",\n        \"selected_stash_box\": \"Vald stash-box adress\",\n        \"source\": \"Stash-Box Källa\",\n        \"submission_failed\": \"Misslyckad inskickning\",\n        \"submission_successful\": \"Lyckad inskickning\",\n        \"submit_update\": \"Existerar redan i {endpoint_name}\"\n    },\n    \"statistics\": \"Statistik\",\n    \"stats\": {\n        \"image_size\": \"Storlek på bilder\",\n        \"scenes_duration\": \"Total speltid\",\n        \"scenes_played\": \"Scener Spelade\",\n        \"scenes_size\": \"Storlek på scener\",\n        \"total_o_count\": \"Total O-Räkning\",\n        \"total_play_count\": \"Totalt Antal Spelningar\",\n        \"total_play_duration\": \"Total Spelad Tid\",\n        \"total_o_count_sfw\": \"Totala Gillningar\"\n    },\n    \"status\": \"Status: {statusText}\",\n    \"studio\": \"Studio\",\n    \"studio_depth\": \"Nivåer (tom för allt)\",\n    \"studio_tagger\": {\n        \"add_new_studios\": \"Lägg Till Nya Studior\",\n        \"any_names_entered_will_be_queried\": \"Skrivna namn kommer sökas i Stash-Box instansen och läggas till om de hittas. Bara exakta matchningar kommer räknas om en matchning.\",\n        \"batch_add_studios\": \"Lägg Till Flera Studior\",\n        \"batch_update_studios\": \"Uppdatera Flera Studior\",\n        \"config\": {\n            \"create_parent_desc\": \"Skapa saknade överordnad studio, eller tagga och uppdatera data/bild för existerande överordnad studio med exakt namnmatchning\",\n            \"create_parent_label\": \"Skapa överordnande studior\"\n        },\n        \"create_or_tag_parent_studios\": \"Skapa saknad eller tagga existerande överordnade studior\",\n        \"current_page\": \"Nuvarande sida\",\n        \"failed_to_save_studio\": \"Misslyckades med att spara studio \\\"{studio}\\\"\",\n        \"name_already_exists\": \"Namnet finns redan\",\n        \"network_error\": \"Nätverksfel\",\n        \"no_results_found\": \"Inga resultat hittades.\",\n        \"number_of_studios_will_be_processed\": \"{studio_count} studior kommer behandlas\",\n        \"query_all_studios_in_the_database\": \"Alla studior i databasen\",\n        \"refresh_tagged_studios\": \"Förnya taggade studior\",\n        \"refreshing_will_update_the_data\": \"Förnyelse kommer uppdatera datan hos alla taggade studior från Stash-Box instansen.\",\n        \"status_tagging_job_queued\": \"Status: Taggjob köat\",\n        \"status_tagging_studios\": \"Status: Taggar studior\",\n        \"studio_already_tagged\": \"Studio redan taggad\",\n        \"studio_selection\": \"Studioval\",\n        \"studio_successfully_tagged\": \"Studiotaggning lyckades\",\n        \"tag_status\": \"Taggstatus\",\n        \"to_use_the_studio_tagger\": \"För att använda studiotaggaren behöver en Stash-Box instans vara konfigurerad.\",\n        \"untagged_studios\": \"Otaggade studior\",\n        \"update_studio\": \"Uppdatera Studio\",\n        \"update_studios\": \"Uppdatera Studior\",\n        \"updating_untagged_studios_description\": \"Uppdatera otaggade studior kommer försöka matcha studior som saknar Stash ID och uppdatera metadata.\",\n        \"studio_names_or_stashids_separated_by_comma\": \"Studionamn eller StashID separerade med komma\"\n    },\n    \"studios\": \"Studior\",\n    \"sub_tag_count\": \"Antal Underordnade Taggar\",\n    \"sub_tag_of\": \"Underordnad tagg till {parent}\",\n    \"sub_tags\": \"Underordnade taggar\",\n    \"subsidiary_studios\": \"Underordnade studior\",\n    \"synopsis\": \"Sammanfattning\",\n    \"tag\": \"Tagg\",\n    \"tag_count\": \"Antal Taggar\",\n    \"tags\": \"Taggar\",\n    \"tattoos\": \"Tatueringar\",\n    \"title\": \"Titel\",\n    \"toast\": {\n        \"added_entity\": \"Lade till {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Köade genereringsjobb\",\n        \"created_entity\": \"Skapade {entity}\",\n        \"default_filter_set\": \"Standardfilter valt\",\n        \"delete_past_tense\": \"Raderade {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"Genererar skärmbild…\",\n        \"image_index_too_large\": \"Fel: Bildindex är större än antalet bilder i Galleriet\",\n        \"merged_scenes\": \"Sammanslagna scener\",\n        \"merged_tags\": \"Slog samman taggar\",\n        \"reassign_past_tense\": \"Fil omplacerad\",\n        \"removed_entity\": \"Tog bort {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"rescanning_entity\": \"Återskannar {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"saved_entity\": \"Sparade {entity}\",\n        \"started_auto_tagging\": \"Började auto-tagga\",\n        \"started_generating\": \"Började generera\",\n        \"started_importing\": \"Började importera\",\n        \"updated_entity\": \"Uppdaterade {entity}\",\n        \"merged_performers\": \"Sammanslagna stjärnor\"\n    },\n    \"total\": \"Total\",\n    \"true\": \"Sant\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Typ\",\n    \"updated_at\": \"Uppdaterad Vid\",\n    \"url\": \"URL\",\n    \"urls\": \"URL:er\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} måste vara i formatet ÅÅÅÅ, ÅÅÅÅ-MM, eller ÅÅÅÅ-MM-DD\",\n        \"required\": \"${path} är ett obligatoriskt fält\",\n        \"unique\": \"${path} måste vara unik\",\n        \"blank\": \"${path} får inte lämnas tom\",\n        \"end_time_before_start_time\": \"Sluttiden måste var större eller lika med starttiden\"\n    },\n    \"video_codec\": \"Videocodec\",\n    \"videos\": \"Videor\",\n    \"view_all\": \"Visa Allt\",\n    \"weight\": \"Vikt\",\n    \"weight_kg\": \"Vikt (kg)\",\n    \"years_old\": \"år gammal\",\n    \"zip_file_count\": \"Antal Zip-filer\",\n    \"tag_parent_tooltip\": \"Har överordnade taggar\",\n    \"package_manager\": {\n        \"check_for_updates\": \"Kolla efter uppdateringar\",\n        \"hide_unselected\": \"Dölj ovalda\",\n        \"source\": {\n            \"local_path\": {\n                \"description\": \"Relativ sökväg för att lagra paket från denna källa. Notera att ändring av detta kräver att paketen manuellt flyttas.\",\n                \"heading\": \"Lokal sökväg\"\n            },\n            \"name\": \"Namn\",\n            \"url\": \"Källa URL\"\n        },\n        \"add_source\": \"Lägg till källa\",\n        \"confirm_delete_source\": \"Är du säker på att du vill radera källa {name} ({url})?\",\n        \"confirm_uninstall\": \"Är du säker på att du vill avinstallera {number} paket?\",\n        \"description\": \"Beskrivning\",\n        \"edit_source\": \"Ändra källa\",\n        \"install\": \"Installera\",\n        \"installed_version\": \"Installerad version\",\n        \"latest_version\": \"Senaste version\",\n        \"no_packages\": \"Inga paket hittades\",\n        \"no_sources\": \"Inga konfigurerade källor\",\n        \"package\": \"Paket\",\n        \"required_by\": \"Krävs av {packages}\",\n        \"selected_only\": \"Enbart valda\",\n        \"show_all\": \"Visa alla\",\n        \"uninstall\": \"Avinstallera\",\n        \"update\": \"Uppdatera\",\n        \"version\": \"Version\",\n        \"unknown\": \"<okänd>\",\n        \"no_upgradable\": \"Inga paket kan uppdateras\"\n    },\n    \"distance\": \"Avstånd\",\n    \"orientation\": \"Orientering\",\n    \"photographer\": \"Fotograf\",\n    \"plays\": \"{value} spelningar\",\n    \"primary_tag\": \"Primär Tagg\",\n    \"studio_and_parent\": \"Studio & Överordnad\",\n    \"subsidiary_studio_count\": \"Antal Underordnade Studior\",\n    \"tag_sub_tag_tooltip\": \"Har underordnade taggar\",\n    \"time\": \"Tid\",\n    \"history\": \"Historik\",\n    \"o_history\": \"O Historik\",\n    \"odate_recorded_no\": \"Inga Registrerade O-datum\",\n    \"playdate_recorded_no\": \"Inga Registrerade Uppspelningsdatum\",\n    \"unknown_date\": \"Okänt datum\",\n    \"play_history\": \"Uppspelningshistorik\",\n    \"last_o_at\": \"Senaste O Vid\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"Kan ej etablera en websocket-uppkoppling: se webbläsarkonsolen för mer info\",\n        \"websocket_connection_reestablished\": \"Websocket-uppkoppling återetablerad\"\n    },\n    \"o_count\": \"Antal O\",\n    \"group\": \"Grupp\",\n    \"group_count\": \"Antal Grupper\",\n    \"group_scene_number\": \"Scennummer\",\n    \"groups\": \"Grupper\",\n    \"studio_count\": \"Antal Studior\",\n    \"studio_tags\": \"Studiotaggar\",\n    \"include_sub_studio_content\": \"Inkludera innehåll från underordnade studior\",\n    \"include_sub_tag_content\": \"Inkludera innehåll från underordnade taggar\",\n    \"containing_group\": \"Innehållande grupp\",\n    \"containing_group_count\": \"Antal Innehållande Grupper\",\n    \"containing_groups\": \"Innehållande Grupper\",\n    \"include_sub_group_content\": \"Inkludera innehåll från undergrupper\",\n    \"sub_group_count\": \"Antal Undergrupper\",\n    \"sub_group_of\": \"Undergrupp av {parent}\",\n    \"sub_group_order\": \"Ordning av Undergrupper\",\n    \"sub_groups\": \"Undergrupper\",\n    \"sub_group\": \"Undergrupp\",\n    \"include_sub_groups\": \"Inkludera undergrupper\",\n    \"time_end\": \"Sluttid\",\n    \"criterion_modifier_values\": {\n        \"any\": \"Någon\",\n        \"any_of\": \"Någon av\",\n        \"none\": \"Ingen\",\n        \"only\": \"Bara\"\n    },\n    \"custom_fields\": {\n        \"field\": \"Fält\",\n        \"title\": \"Egna Fält\",\n        \"value\": \"Värde\",\n        \"criteria_format_string\": \"{criterion} (eget fält) {modifierString} {valueString}\",\n        \"criteria_format_string_others\": \"{criterion} (eget fält) {modifierString} {valueString} (+{others} andra)\"\n    },\n    \"eta\": \"Uppskattad återstående tid\",\n    \"sort_name\": \"Sorteringsnamn\",\n    \"login\": {\n        \"username\": \"Användarnamn\",\n        \"password\": \"Lösenord\",\n        \"internal_error\": \"Oväntad internt fel. Se loggen för mer information\",\n        \"login\": \"Inlogg\",\n        \"invalid_credentials\": \"Ogiltigt användarnamn eller lösenord\"\n    },\n    \"age_on_date\": \"{age} vid produktion\",\n    \"scenes_duration\": \"Scen Speltid\",\n    \"last_o_at_sfw\": \"Senaste Gillning Vid\",\n    \"o_count_sfw\": \"Gillningar\",\n    \"o_history_sfw\": \"Gillningshistorik\",\n    \"odate_recorded_no_sfw\": \"Inget Gillningsdatum Sparat\",\n    \"stashbox_search\": {\n        \"header\": \"Sök {entityType} från StashBox\",\n        \"no_results\": \"Inga resultat hittades.\",\n        \"placeholder_name_or_id\": \"{entityType} namn eller StashID...\",\n        \"select_stashbox\": \"Välj StashBox...\"\n    },\n    \"latest_scene\": \"Senaste scen\",\n    \"stash_id_count\": \"Stash ID-räkning\",\n    \"career_end\": \"Karriär Avslutades\",\n    \"career_start\": \"Karriär Började\",\n    \"duplicated\": \"Duplicerad\",\n    \"duplicated_stash_id\": \"Duplicerad (Stash ID)\",\n    \"duplicated_title\": \"Duplicerad (Titel)\",\n    \"tag_tagger\": {\n        \"add_new_tags\": \"Lägg till Nya Taggar\",\n        \"any_names_entered_will_be_queried\": \"Alla namn kommer efterfrågas från Stash-Box-instansen och läggas till om de hittas. Bara exakta matchningar kommer räknas som en match.\",\n        \"batch_add_tags\": \"Lägg till Flera Taggar\",\n        \"batch_update_tags\": \"Uppdatera Flera Taggar\",\n        \"current_page\": \"Nuvarande sida\",\n        \"failed_to_save_tag\": \"Misslyckade att spara tagg \\\"{tag}\\\"\",\n        \"name_already_exists\": \"Namnet finns redan\",\n        \"network_error\": \"Nätverksfel\",\n        \"no_results_found\": \"Inga resultat hittades.\",\n        \"number_of_tags_will_be_processed\": \"{tag_count} taggar kommer behandlas\",\n        \"query_all_tags_in_the_database\": \"Alla taggar i databasen\",\n        \"refresh_tagged_tags\": \"Förnya taggade taggar\",\n        \"refreshing_will_update_the_data\": \"Förnyande kommer uppdatera datan för alla taggar som är taggade från stash-box instansen.\",\n        \"status_tagging_job_queued\": \"Status: Taggande uppgift köad\",\n        \"status_tagging_tags\": \"Status: Taggar taggar\",\n        \"tag_already_tagged\": \"Tagg redan taggad\",\n        \"tag_names_or_stashids_separated_by_comma\": \"Tagg-namn eller StashID separerat med komma\",\n        \"tag_selection\": \"Taggval\",\n        \"tag_successfully_tagged\": \"Taggning av tagg lyckades\",\n        \"tag_status\": \"Taggstatus\",\n        \"to_use_the_tag_tagger\": \"För att använda taggtaggaren så måste en stash-box-instans konfigureras.\",\n        \"untagged_tags\": \"Otaggade taggar\",\n        \"update_tags\": \"Uppdatera Taggar\",\n        \"updating_untagged_tags_description\": \"Uppdatering av otaggade taggar kommer att försöka matcha alla taggar som saknar StashID och uppdatera deras metadata.\"\n    },\n    \"tagger\": {\n        \"config\": {\n            \"active_stash-box_instance\": \"Aktiv stash-box-instans:\",\n            \"edit_excluded_fields\": \"Ändra Exkluderade Fält\",\n            \"excluded_fields\": \"Exkluderade fält:\",\n            \"fields_will_not_be_changed\": \"De här fälten kommer inte ändras när {entity} uppdateras.\",\n            \"no_fields_are_excluded\": \"Inga fält är exkluderad\",\n            \"no_instances_found\": \"Inga instanser hittades\"\n        }\n    },\n    \"unsupported_criteria\": \"Ej stöttat kriterium: {critera}\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/th-TH.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"เพิ่ม\",\n        \"add_directory\": \"เพิ่มไดเร็กทอรี\",\n        \"add_entity\": \"เพิ่ม{entityType}\",\n        \"add_to_entity\": \"เพิ่มไปยัง{entityType}\",\n        \"allow\": \"อนุญาต\",\n        \"allow_temporarily\": \"อนุญาตชั่วคราว\",\n        \"apply\": \"ใช้งาน\",\n        \"auto_tag\": \"แท็กอัตโนมัติ\",\n        \"backup\": \"สำรองข้อมูล\",\n        \"browse_for_image\": \"เลือกรูป…\",\n        \"cancel\": \"ยกเลิก\",\n        \"clean\": \"เก็บกวาด\",\n        \"clear\": \"ล้างค่า\",\n        \"clear_back_image\": \"ล้างค่าภาพปกหลัง\",\n        \"clear_front_image\": \"ล้างค่าภาพปกหน้า\",\n        \"clear_image\": \"ล้างค่ารูปภาพ\",\n        \"close\": \"ปิด\",\n        \"confirm\": \"ยืนยัน\",\n        \"continue\": \"ถัดไป\",\n        \"create\": \"สร้าง\",\n        \"create_entity\": \"สร้าง{entityType}\",\n        \"create_marker\": \"สร้างมาร์คเกอร์\",\n        \"created_entity\": \"สร้าง{entity_type}: {entity_name}\",\n        \"customise\": \"ปรับแต่ง\",\n        \"delete\": \"ลบ\",\n        \"delete_entity\": \"ลบ{entityType}\",\n        \"delete_file\": \"ลบไฟล์\",\n        \"delete_file_and_funscript\": \"ลบไฟล์ (และ funscript)\",\n        \"delete_generated_supporting_files\": \"ลบไฟล์ที่เกี่ยวข้อง\",\n        \"disallow\": \"ไม่อนุญาต\",\n        \"download\": \"ดาวน์โหลด\",\n        \"download_backup\": \"ดาวน์โหลดข้อมูลสำรอง\",\n        \"edit\": \"แก้ไข\",\n        \"edit_entity\": \"แก้ไข{entityType}\",\n        \"export\": \"ส่งออก\",\n        \"export_all\": \"ส่งออกทั้งหมด…\",\n        \"find\": \"ค้นหา\",\n        \"finish\": \"เสร็จ\",\n        \"from_file\": \"จากไฟล์…\",\n        \"from_url\": \"จาก URL…\",\n        \"full_export\": \"ส่งออกทั้งหมด\",\n        \"full_import\": \"นำเข้าทั้งหมด\",\n        \"generate\": \"ผลิต\",\n        \"generate_thumb_default\": \"ผลิตรูปขนาดย่อตั้งต้น\",\n        \"generate_thumb_from_current\": \"สร้างรูปภาพตัวอย่างจากจุดนี้\",\n        \"hash_migration\": \"ย้าย Hash\",\n        \"hide\": \"ซ่อน\",\n        \"hide_configuration\": \"ซ่อนการตั้งค่า\",\n        \"identify\": \"ระบุตัวตน\",\n        \"ignore\": \"ไม่สนใจ\",\n        \"import\": \"นำเข้า…\",\n        \"import_from_file\": \"นำเข้าด้วยไฟล์\",\n        \"logout\": \"ล็อกเอาท์\",\n        \"merge\": \"รวม\",\n        \"next_action\": \"ต่อไป\",\n        \"not_running\": \"ไม่สามารถรันได้\",\n        \"open_in_external_player\": \"เปิดด้วย Player อื่น\",\n        \"open_random\": \"เปิดการสุ่ม\",\n        \"overwrite\": \"เขียนทับ\",\n        \"play_random\": \"เล่นแบบสุ่ม\",\n        \"play_selected\": \"เล่นที่เลือก\",\n        \"preview\": \"พรีวิว\",\n        \"previous_action\": \"กลับ\",\n        \"refresh\": \"รีเฟรซ\",\n        \"reload_plugins\": \"รีโหลดปลั๊กอิน\",\n        \"reload_scrapers\": \"รีโหลดสแครปเปอร์\",\n        \"remove\": \"ลบ\",\n        \"remove_from_gallery\": \"เอาออกจาก Gallery\",\n        \"rename_gen_files\": \"เปลี่ยนชื่อไฟล์ที่ถูก Generated แล้ว\",\n        \"rescan\": \"สแกนอีกครั้ง\",\n        \"reshuffle\": \"สับเปลี่ยน\",\n        \"running\": \"กำลังเล่น\",\n        \"save\": \"เซฟ\",\n        \"save_delete_settings\": \"ใช้ออปชั่นนี้เป็นค่าเริ่มต้นสำหรับการลบ\",\n        \"save_filter\": \"บันทึกตัวกรอง\",\n        \"scan\": \"สแกน\",\n        \"scrape\": \"สแครป\",\n        \"scrape_query\": \"สแครป์คิวรี่\",\n        \"scrape_scene_fragment\": \"สแครปด้วยชิ้นส่วน\",\n        \"scrape_with\": \"สแครปด้วย…\",\n        \"search\": \"ค้นหา\",\n        \"select_all\": \"เลือกทั้งหมด\",\n        \"select_entity\": \"เลือก {entityType}\",\n        \"select_folders\": \"เลือกโฟลเดอร์\",\n        \"select_none\": \"ไม่เลือก\",\n        \"selective_auto_tag\": \"เลือกออโต้แท็ก\",\n        \"selective_clean\": \"ล้างตัวเลือก\",\n        \"selective_scan\": \"ล้างการสแกน\",\n        \"set_as_default\": \"คืนค่าเริ่มต้น\",\n        \"set_back_image\": \"รูปด้่านหลัง…\",\n        \"set_front_image\": \"รูปด้านหน้า…\",\n        \"set_image\": \"ตั้งรูปภาพ…\",\n        \"show\": \"แสดง\",\n        \"show_configuration\": \"แสดงการตั้งค่า\",\n        \"skip\": \"ข้าม\",\n        \"stop\": \"หยุด\",\n        \"submit\": \"ยืนยัน\",\n        \"submit_stash_box\": \"ยืนยันไปยัง Stash-Box\",\n        \"submit_update\": \"ยืนยันการอัปเดต\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"คุณแน่ใจแล้วนะว่าจะล้าง? การกระทำนี้จะลบข้อมูลในฐานข้อมูลและเนื้อหาที่ถูกสร้างขึ้นของ scene และ gallery ที่ไม่มีอยู่ในไฟล์ระบบทั้งหมด\",\n            \"dry_mode_selected\": \"คุณเลือกโหมดซักซ้อมไว้ จะไม่มีการลบเกิดขึ้น มีเพียงการบันทึกล็อกเท่านั้น\",\n            \"import_warning\": \"คุณแน่ใจแล้วนะว่าจะทำการนำเข้าข้อมูล? การกระทำนี้จะลบข้อมูลในฐานข้อมูลและทำการอิมพอร์ต metadata จากข้อมูลของคุณแทน\"\n        },\n        \"temp_disable\": \"ปิดชั่วคราว…\",\n        \"temp_enable\": \"เปิดชั่วคราว…\",\n        \"unset\": \"ยกเลิกการตั้งค่า\",\n        \"use_default\": \"ใช้ค่าเริ่มต้น\",\n        \"view_random\": \"ดูแบบสุ่ม\",\n        \"download_anonymised\": \"ดาวน์โหลดข้อมูลที่ปิดบังตัวตนแล้ว\",\n        \"optimise_database\": \"ปรับแต่งฐานข้อมูลให้ดีขึ้น\",\n        \"assign_stashid_to_parent_studio\": \"ระบุ Stash ID ให้กับสตูดิโอบริษัทแม่พร้อมกับอัปเดต metadata\",\n        \"create_chapters\": \"สร้างฉาก\",\n        \"swap\": \"สลับ\",\n        \"reassign\": \"ตั้งค่าใหม่\",\n        \"reload\": \"รีโหลด\",\n        \"split\": \"แยก\",\n        \"view_history\": \"ประวัติการดู\",\n        \"add_manual_date\": \"เพิ่มวันที่\",\n        \"add_o\": \"เพิ่ม O\",\n        \"add_play\": \"เพิ่มจำนวนครั้งที่เล่น\",\n        \"anonymise\": \"ปิดบังตัวตน\",\n        \"choose_date\": \"เลือกวันที่\",\n        \"clean_generated\": \"ลบไฟล์ที่ถูกสร้าง\",\n        \"clear_date_data\": \"ล้างค่าข้อมูลวันที่\",\n        \"copy_to_clipboard\": \"ทำสำเนา\",\n        \"create_parent_studio\": \"สร้างสตูดิโอบริษัทแม่\",\n        \"disable\": \"ปิดใช้งาน\",\n        \"enable\": \"เปิดใช้งาน\",\n        \"encoding_image\": \"กำลังเข้ารหัสรูปภาพ…\",\n        \"make_primary\": \"ตั้งให้เป็นไฟล์หลัก\",\n        \"migrate_blobs\": \"ย้าย blob\",\n        \"migrate_scene_screenshots\": \"ย้าย scene screenshots\",\n        \"remove_date\": \"ลบวันที่\"\n    },\n    \"actions_name\": \"Actions\",\n    \"age\": \"อายุ\",\n    \"aliases\": \"ใกล้เคียง\",\n    \"all\": \"ทั้งหมด\",\n    \"also_known_as\": \"AKA\",\n    \"ascending\": \"จากน้อยไปมาก\",\n    \"average_resolution\": \"ความละเอียดโดยเฉลี่ย\",\n    \"birth_year\": \"ปีเกิด\",\n    \"birthdate\": \"วันเกิด\",\n    \"bitrate\": \"บิตเรท\",\n    \"captions\": \"แคปชัน\",\n    \"career_length\": \"ระยะเวลาในวงการ\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Stash-box ที่ใช้งานอยู่:\",\n            \"blacklist_desc\": \"รายการบัญชีดำจะไม่รวมอยู่ในการสืบค้น โปรดทราบว่าเป็นนิพจน์ทั่วไปและไม่คำนึงถึงขนาดตัวพิมพ์ อักขระบางตัวต้องหลีกหนีด้วยแบ็กสแลช: {chars_require_escape}\",\n            \"blacklist_label\": \"บัญชีดำ\",\n            \"query_mode_auto\": \"ออโต้\",\n            \"query_mode_auto_desc\": \"ใช้ข้อมูล metadata หากมี หรือใช้ชื่อไฟล์แทน\",\n            \"query_mode_dir\": \"Directory\",\n            \"query_mode_dir_desc\": \"ใช้โฟลเดอร์ที่มีอยู่สำหรับไฟล์วิดีโอเท่านั้น\",\n            \"query_mode_filename\": \"ชื่อไฟล์\",\n            \"query_mode_filename_desc\": \"ใช้ชื่อไฟล์เท่านั้น\",\n            \"query_mode_label\": \"โหมดคิวรี่\",\n            \"query_mode_metadata\": \"Metadata\",\n            \"query_mode_metadata_desc\": \"ใช้ metadata เท่านั้น\",\n            \"query_mode_path\": \"พาร์ธ (Path)\",\n            \"query_mode_path_desc\": \"ใช้พาร์ธของไฟล์ทั้งหมด (Path)\",\n            \"set_cover_desc\": \"เปลี่ยน Cover ฉากหากมีการพบ\",\n            \"set_cover_label\": \"ตั้ง Cover รูปภาพของฉาก\",\n            \"set_tag_desc\": \"แนบแท็กกับฉาก โดยเขียนทับหรือรวมกับแท็กที่มีอยู่ในฉาก\",\n            \"set_tag_label\": \"ตั้งแท็ก\",\n            \"source\": \"ต้นทาง\",\n            \"mark_organized_desc\": \"ตั้งค่า scene เป็น organized ทันทีที่กดบันทึกข้อมูล\",\n            \"mark_organized_label\": \"ตั้งค่าเป็น organized เมื่อบันทึกข้อมูล\"\n        },\n        \"noun_query\": \"คิวรี่\",\n        \"results\": {\n            \"duration_off\": \"ระยะเวลาปิดอย่างน้อย {number}s\",\n            \"duration_unknown\": \"ระยะเวลาไม่ทราบ\",\n            \"fp_found\": \"{fpCount, plural, =0 {ไม่พบข้อมูลอัตลักษณ์ที่ตรงกัน} other {# พบข้อมูลอัตลักษณ์ที่ตรงกัน}}\",\n            \"fp_matches\": \"ระยะเวลาตรงกัน\",\n            \"fp_matches_multi\": \"ระยะเวลาตรงกัน {matchCount}/{durationsLength} ชุดข้อมูล\",\n            \"hash_matches\": \"{hash_type} ตรงกัน\",\n            \"match_failed_already_tagged\": \"มีข้อมูลซีนนี้แล้ว\",\n            \"match_failed_no_result\": \"ไม่พบผลลัพธ์\",\n            \"match_success\": \"เพิ่มข้อมูลซีนสำเร็จแล้ว\",\n            \"phash_matches\": \"{count} PHashes ตรงกัน\",\n            \"unnamed\": \"ไม่มีชื่อ\"\n        },\n        \"verb_match_fp\": \"ข้อมูลอัตลักษณ์ตรงกัน\",\n        \"verb_matched\": \"ตรงกัน\",\n        \"verb_scrape_all\": \"สแครปทั้งหมด\",\n        \"verb_submit_fp\": \"ยืนยัน {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} ไม่พบฉากที่ตรงกัน\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"สร้าง hash:\",\n            \"build_time\": \"ระยะเวลาสร้าง :\",\n            \"check_for_new_version\": \"ตรวจสอบเวอร์ชันใหม่\",\n            \"latest_version\": \"เวอร์ชันล่าสุด\",\n            \"latest_version_build_hash\": \"เวอร์ชันล่าสุด Hash:\",\n            \"new_version_notice\": \"[ใหม่]\",\n            \"stash_discord\": \"เข้าร่วมดิสคอร์ดของเรา {url}\",\n            \"stash_home\": \"หน้าเว็บหลักของ Stash {url}\",\n            \"stash_open_collective\": \"สนับสนุนเราได้ที่ {url}\",\n            \"stash_wiki\": \"หน้าเว็บ {url}\",\n            \"version\": \"เวอร์ชัน\",\n            \"release_date\": \"วันที่วางขาย:\"\n        },\n        \"application_paths\": {\n            \"heading\": \"ที่อยู่ของแอปพลิเคชัน\"\n        },\n        \"categories\": {\n            \"about\": \"เกี่ยวกับ\",\n            \"interface\": \"อินเตอร์เฟส\",\n            \"logs\": \"ล็อกไฟล์\",\n            \"metadata_providers\": \"ผู้ให้บริการข้อมูล metadata\",\n            \"plugins\": \"ปลั๊กอิน\",\n            \"scraping\": \"สแครปปิ้ง\",\n            \"security\": \"ความปลอดภัย\",\n            \"services\": \"เซอร์วิส\",\n            \"system\": \"ระบบ\",\n            \"tasks\": \"งาน\",\n            \"tools\": \"เครื่องมือ\",\n            \"changelog\": \"บันทึกความเปลี่ยนแปลง\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"อนุญาต {tempIP}\",\n            \"allowed_ip_addresses\": \"อนุญาต IP addresses\",\n            \"allowed_ip_temporarily\": \"อนุญาติ IP ชั่วคราว\",\n            \"default_ip_whitelist\": \"ค่าเริ่มต้น IP Whitelist\",\n            \"default_ip_whitelist_desc\": \"ที่อยู่ IP เริ่มต้นอนุญาตให้เข้าถึง DLNA ใช้ {wildcard} เพื่ออนุญาตที่อยู่ IP ทั้งหมด\",\n            \"disabled_dlna_temporarily\": \"ปิดใช้งาน DLNA ชั่วคราว\",\n            \"disallowed_ip\": \"ยกเลิกอนุญาต IP\",\n            \"enabled_by_default\": \"เปิดใช้งานโดยค่าเริ่มต้น\",\n            \"enabled_dlna_temporarily\": \"เปิดใช้งาน DLNA ชั่วคราว\",\n            \"network_interfaces\": \"อินเตอร์เฟส\",\n            \"network_interfaces_desc\": \"อินเทอร์เฟซสำหรับเปิดเซิร์ฟเวอร์ DLNA รายการว่างส่งผลให้รันบนอินเทอร์เฟซทั้งหมด ต้องรีสตาร์ท DLNA หลังจากเปลี่ยนการตั้งค่า\",\n            \"recent_ip_addresses\": \"IP addresses ล่าสุด\",\n            \"server_display_name\": \"ชื่อ Server ที่จะทำการแสดง\",\n            \"server_display_name_desc\": \"ชื่อที่แสดงสำหรับเซิร์ฟเวอร์ DLNA ค่าเริ่มต้นเป็น {server_name} เป็นค่าว่าง\",\n            \"successfully_cancelled_temporary_behaviour\": \"ยกเลิกพฤติกรรมชั่วคราวเรียบร้อยแล้ว\",\n            \"until_restart\": \"จนกว่าจะรีสตาร์ท\",\n            \"video_sort_order_desc\": \"เลือกชนิดลำดับที่ต้องการเรียงลำดับวิดีโอเป็นค่าปริยาย\",\n            \"video_sort_order\": \"ค่าปริยายการเรียงลำดับวิดีโอ\",\n            \"server_port\": \"พอร์ตเซิร์ฟเวอร์\",\n            \"server_port_desc\": \"พอร์ตที่จะเรียกใช้เซิร์ฟเวอร์ DLNA ต้องรีสตาร์ท DLNA เมื่อเปลี่ยนแปลงค่า\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"รหัส API (KEY)\",\n                \"api_key_desc\": \"คีย์ API สำหรับระบบภายนอก จำเป็นเฉพาะเมื่อมีการกำหนดค่าชื่อผู้ใช้/รหัสผ่าน ต้องบันทึกชื่อผู้ใช้ก่อนสร้างคีย์ API\",\n                \"authentication\": \"การยืนยันตัวตน\",\n                \"clear_api_key\": \"ล้าง API key\",\n                \"credentials\": {\n                    \"description\": \"ข้อมูลประจำตัวเพื่อจำกัดการเข้าถึง Stash\",\n                    \"heading\": \"ข้อมูลประจำตัว\"\n                },\n                \"generate_api_key\": \"สร้าง API Key\",\n                \"log_file\": \"ล็อกไฟล์\",\n                \"log_file_desc\": \"พาร์ธไปยังไฟล์เพื่อบันทึกเอาต์พุตนั้นว่างเปล่าเพื่อปิดใช้งานการบันทึกไฟล์ จำเป็นต้องรีสตาร์ท\",\n                \"log_http\": \"ล็อกไฟล์ http access\",\n                \"log_http_desc\": \"ล็อกไฟล์สำหรับ http access ไปยัง Terminal จำเป็นต้อง Restart\",\n                \"log_to_terminal\": \"ล็อกไฟล์ไปยัง Terminal\",\n                \"log_to_terminal_desc\": \"บันทึกไปยังTerminal เพิ่มเติมจากไฟล์ เป็น True เสมอหากปิดใช้งานการบันทึกไฟล์ จำเป็นต้องรีสตาร์ท\",\n                \"maximum_session_age\": \"ค่าสูงสุดของอายุ Session\",\n                \"maximum_session_age_desc\": \"ระบบจะล็อกเอาท์เมื่อไม่มีกิจกรรมใดๆ และเวลาผ่านไปตามที่ระบุ (หน่วยวินาที) (บังคับรีสตาร์ท)\",\n                \"password\": \"พาสเวิร์ด\",\n                \"password_desc\": \"รหัสผ่านเพื่อเข้าถึง Stash เว้นว่างไว้เพื่อปิดใช้งานการตรวจสอบสิทธิ์ผู้ใช้\",\n                \"stash-box_integration\": \"ทำการรวม Stash-box\",\n                \"username\": \"ยูเซอร์เนม\",\n                \"username_desc\": \"ยูเซอร์เนมสำหรับเข้าถึง Stash เว้นค่าว่างเพื่อปิดการยืนยันตัวตน\"\n            },\n            \"cache_location\": \"ตำแหน่งไดเรกทอรีของแคช\",\n            \"cache_path_head\": \"พาร์ธของ Cache\",\n            \"calculate_md5_and_ohash_desc\": \"คำนวณเช็คซัม MD5 นอกเหนือจาก oshash การเปิดใช้งานจะทำให้การสแกนครั้งแรกช้าลง ต้องตั้งค่าแฮชการตั้งชื่อไฟล์เป็น oshash เพื่อปิดการคำนวณ MD5\",\n            \"calculate_md5_and_ohash_label\": \"คำนวณ MD5 สำหรับวิดีโอ\",\n            \"check_for_insecure_certificates\": \"ตรวจสอบใบรับรองที่ไม่ปลอดภัย\",\n            \"check_for_insecure_certificates_desc\": \"บางไซต์ใช้ใบรับรอง SSL ที่ไม่ปลอดภัย เมื่อยกเลิกการเลือกมีดโกนจะข้ามการตรวจสอบใบรับรองที่ไม่ปลอดภัยและอนุญาตให้คัดลอกไซต์เหล่านั้นได้ หากคุณได้รับข้อผิดพลาดใบรับรองเมื่อ Scraping ให้ยกเลิกการเลือกนี้\",\n            \"chrome_cdp_path\": \"Google Chrome CDP path\",\n            \"chrome_cdp_path_desc\": \"พาร์ธของไฟล์ไปยังไฟล์สั่งการของ Chrome หรือที่อยู่ระยะไกล (เริ่มต้นด้วย http:// หรือ https:// เช่น http://localhost:9222/json/version) ไปยังอินสแตนซ์ของ Chrome\",\n            \"create_galleries_from_folders_desc\": \"หากเป็นจริง สร้างแกลเลอรี่จากโฟลเดอร์ที่มีรูปภาพ\",\n            \"create_galleries_from_folders_label\": \"สร้างแกลเลอรี่จากโฟลเดอร์ที่มีรูปภาพ\",\n            \"db_path_head\": \"พาร์ธของ Database\",\n            \"directory_locations_to_your_content\": \"ตำแหน่งไดเรกทอรีไปยังเนื้อหาของคุณ\",\n            \"excluded_image_gallery_patterns_desc\": \"Regexps ของไฟล์รูปภาพและแกลเลอรี/พาร์ธที่จะแยกออกจากการสแกนและเพิ่มใน Clean\",\n            \"excluded_image_gallery_patterns_head\": \"รูปแบบรูปภาพ/แกลเลอรีที่ยกเว้น\",\n            \"excluded_video_patterns_desc\": \"Regexps ของไฟล์วิดีโอ/พาร์ธที่จะแยกออกจากการสแกนและเพิ่มใน Clean\",\n            \"excluded_video_patterns_head\": \"รูปแบบวิดีโอที่ยกเว้น\",\n            \"gallery_ext_desc\": \"รายการนามสกุลไฟล์ซึ่งจะถูกนับว่าเป็นไฟล์ zip ของแกลเลอรี คั่นด้วยเครื่องหมายจุลภาค\",\n            \"gallery_ext_head\": \"นามสกุลไฟล์ zip แกลเลอรี่\",\n            \"generated_file_naming_hash_desc\": \"ใช้ MD5 หรือ oshash สำหรับการตั้งชื่อไฟล์ การเปลี่ยนแปลงค่านี้จะทำให้ต้องอ่านค่า MD5/ohash จาก scene ทั้งหมดอีกครั้ง ทำได้โดยการใช้เครื่องมือสำหรับย้ายข้อมูลในหน้างาน\",\n            \"generated_file_naming_hash_head\": \"แฮชการตั้งชื่อไฟล์ที่สร้าง\",\n            \"generated_files_location\": \"ตำแหน่งไดเรกทอรีสำหรับไฟล์ที่สร้างขึ้น (ตัวทำเครื่องหมายฉาก ตัวอย่างฉาก สไปรท์ ฯลฯ)\",\n            \"generated_path_head\": \"พาร์ธที่สร้างขึ้น\",\n            \"hashing\": \"กำลัง Hash\",\n            \"image_ext_desc\": \"รายการนามสกุลไฟล์ที่จะระบุเป็นรูปภาพ คั่นด้วยเครื่องหมายจุลภาค\",\n            \"image_ext_head\": \"นามสกุลไฟล์รูปภาพ\",\n            \"include_audio_desc\": \"รวมสตรีมเสียงเมื่อสร้างตัวอย่าง\",\n            \"include_audio_head\": \"รวมเสียง\",\n            \"logging\": \"การบันทึก\",\n            \"maximum_streaming_transcode_size_desc\": \"ขนาดสูงสุดสำหรับสตรีมที่แปลงรหัส\",\n            \"maximum_streaming_transcode_size_head\": \"ขนาดทรานส์โค้ดการสตรีมสูงสุด\",\n            \"maximum_transcode_size_desc\": \"ขนาดสูงสุดสำหรับการแปลงรหัสที่สร้างขึ้น\",\n            \"maximum_transcode_size_head\": \"ขนาดการแปลงสูงสุด\",\n            \"metadata_path\": {\n                \"description\": \"ไดเร็กทอรีที่ใช้เมื่อส่งออกหรือนำเข้าข้อมูลเต็มรูปแบบ\",\n                \"heading\": \"ตำแหน่งข้อมูล metadata\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"ตั้งค่าเป็น 0 สำหรับการตั้งค่าอัตโนมัติ คำเตือน: การสั่งงานมากเกินจำเป็นอาจทำให้ระบบใช้งาน CPU สูงถึง 100% ซึ่งจะส่งผลต่อประสิทธิภาพการทำงานของระบบและอาจทำให้เกิดปัญหาอื่นๆ ได้\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"จำนวนงานคู่ขนานสำหรับการสแกน/การสร้าง\",\n            \"parallel_scan_head\": \"การสแกน/การสร้างแบบขนาน\",\n            \"preview_generation\": \"ดูตัวอย่างการสร้าง\",\n            \"python_path\": {\n                \"description\": \"ตำแหน่งไฟล์ python (ไม่ใช่โฟลเดอร์) สำหรับใช้งานสคริปต์ scraper และปลั๊กอินต่างๆ หากเว้นว่างไว้ระบบจะดึงข้อมูลตำแหน่ง python จากวินโดส์\",\n                \"heading\": \"ตำแหน่งไฟล์ Python\"\n            },\n            \"scraper_user_agent\": \"Scraper User-Agent\",\n            \"scraper_user_agent_desc\": \"User-Agentสตริงที่ใช้ระหว่างการร้องขอ http scrape\",\n            \"scrapers_path\": {\n                \"description\": \"ตำแหน่งไดเร็กทอรีของไฟล์การกำหนดค่า Scraper\",\n                \"heading\": \"พาร์ธ Scrapers\"\n            },\n            \"scraping\": \"การ Scrap\",\n            \"sqlite_location\": \"ตำแหน่งไฟล์สำหรับฐานข้อมูล SQLite (ต้องรีสตาร์ท)<br>คำเตือน: ไม่รองรับการจัดเก็บฐานข้อมูลในที่อื่นๆ ที่ไม่ใช่เครื่องเซิร์ฟเวอร์ Stash (เช่นในไดรฟ์เครือข่าย)!\",\n            \"video_ext_desc\": \"รายการนามสกุลไฟล์ซึ่งจะถูกระบุว่าเป็นวิดีโอ คั่นด้วยเครื่องหมายจุลภาค\",\n            \"video_ext_head\": \"นามสกุลไฟล์วิดีโอ\",\n            \"video_head\": \"วิดีโอ\",\n            \"blobs_path\": {\n                \"description\": \"ตำแหน่งในไฟล์ระบบที่ใช้จัดเก็บข้อมูล binary มีผลเฉพาะเมื่อตั้งค่าประเภทการจัดเก็บเป็นแบบไฟล์ระบบเท่านั้น คำเตือน: ต้องทำการย้ายข้อมูลด้วยตนเองหากต้องการเปลี่ยนแปลงค่านี้\",\n                \"heading\": \"ตำแหน่งไฟล์ระบบสำหรับข้อมูล binary\"\n            },\n            \"blobs_storage\": {\n                \"heading\": \"ชนิดการจัดเก็บข้อมูล binary\",\n                \"description\": \"ตำแหน่งจัดเก็บข้อมูล binary เช่นภาพหน้าปก ข้อมูลนักแสดง และอื่นๆ หลังเปลี่ยนแปลงการตั้งค่านี้ ต้องทำการย้ายข้อมูลปัจจุบันด้วยเครื่องมือย้าย blob ในหน้างาน\"\n            },\n            \"ffmpeg\": {\n                \"ffprobe_path\": {\n                    \"description\": \"ตำแหน่งไฟล์ FFprobe (ไม่ใช่โฟลเดอร์) หากเว้นว่างไว้ ระบบจะดึงข้อมูลจากวินโดส์ผ่าน $PATH หรือไดเร็กทอรีการตั้งค่า หรือจาก $HOME/.stash\",\n                    \"heading\": \"ตำแหน่งไฟล์ FFprobe\"\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"heading\": \"การตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวิดีโอ\",\n                        \"desc\": \"ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวิดีโอ\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวิดีโอ\",\n                        \"heading\": \"การตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวิดีโอ\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"ดาวน์โหลด FFmpeg ไว้ที่ไดเร็กทอรีการตั้งค่าและล้างค่าตำแหน่งไฟล์ ffmpeg และ ffprobe เดิมเพื่อใช้ค่าใหม่\",\n                    \"heading\": \"ดาวน์โหลด FFmpeg\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"ตำแหน่งไฟล์ FFmpeg (ไม่ใช่โฟลเดอร์) หากเว้นว่างไว้ ระบบจะดึงข้อมูลจากวินโดส์ผ่าน $PATH หรือไดเร็กทอรีการตั้งค่า หรือจาก $HOME/.stash\",\n                    \"heading\": \"ตำแหน่งไฟล์ FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"output_args\": {\n                        \"heading\": \"การตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการ live transcode\",\n                        \"desc\": \"ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการ live transcode\"\n                    },\n                    \"input_args\": {\n                        \"desc\": \"ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนเริ่ม live transcode\",\n                        \"heading\": \"การตั้งค่า FFmpeg เพิ่มเติมสำหรับก่อนเริ่ม live transcode\"\n                    }\n                },\n                \"hardware_acceleration\": {\n                    \"desc\": \"ใช้ฮาร์ดแวร์เข้ารหัสวิดีโอสำหรับการ live transcode\",\n                    \"heading\": \"ใช้ฮาร์ดแวร์เข้ารหัสวิดีโอสำหรับ FFmpeg\"\n                }\n            },\n            \"funscript_heatmap_draw_range_desc\": \"ระบุค่าสูงต่ำ (ตามแกน y) ในการสร้าง heatmaps เมื่อเปลี่ยนแปลงค่านี้ต้องทำการสร้าง heatmaps ขึ้นใหม่\",\n            \"plugins_path\": {\n                \"description\": \"ไดเร็กทอรีสำหรับไฟล์การตั้งค่าปลั๊กอิน\",\n                \"heading\": \"ตำแหน่งปลั๊กอิน\"\n            },\n            \"backup_directory_path\": {\n                \"heading\": \"ตำแหน่งไดเร็กทอรีของไฟล์สำรองข้อมูล\",\n                \"description\": \"ไดเด็กทอรีสำหรับการสำรองฐานข้อมูล SQLite\"\n            },\n            \"database\": \"ฐานข้อมูล\",\n            \"funscript_heatmap_draw_range\": \"ระบุค่าสูงต่ำในการสร้าง heatmaps\",\n            \"gallery_cover_regex_desc\": \"ข้อมูล regexp ที่จะใช้ระบุภาพให้เป็นภาพหน้าปกแกลเลอรี\",\n            \"gallery_cover_regex_label\": \"รูปแบบชื่อหน้าปกแกลเลอรี\",\n            \"heatmap_generation\": \"การสร้าง heatmap Funscript\"\n        },\n        \"library\": {\n            \"exclusions\": \"ไม่รวม\",\n            \"gallery_and_image_options\": \"ตัวเลือกแกลเลอรี่และรูปภาพ\",\n            \"media_content_extensions\": \"นามสกุลไฟล์เนื้อหา\"\n        },\n        \"logs\": {\n            \"log_level\": \"ระดับ Log\"\n        },\n        \"plugins\": {\n            \"hooks\": \"การใช้ Hooks\",\n            \"triggers_on\": \"Triggers บน\",\n            \"available_plugins\": \"ปลั๊กอินที่มีให้ใช้งาน\",\n            \"installed_plugins\": \"ปลั๊กอินที่ติดตั้งไว้\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Metadata\",\n            \"entity_scrapers\": \"{entityType} สแครปเปอร์\",\n            \"excluded_tag_patterns_desc\": \"Regexps ของชื่อแท็กที่จะแยกออกจากผลลัพธ์การสแครปปิ้ง\",\n            \"excluded_tag_patterns_head\": \"รูปแบบแท็กที่ยกเว้น\",\n            \"scraper\": \"สแครปปิ้ง\",\n            \"scrapers\": \"สแครปเปอร์\",\n            \"search_by_name\": \"ค้นหาตามชื่อ\",\n            \"supported_types\": \"ประเภทที่รองรับ\",\n            \"supported_urls\": \"URL\",\n            \"available_scrapers\": \"Scraper ที่มีให้ใช้งาน\",\n            \"installed_scrapers\": \"Scraper ที่ติดตั้งไว้\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"เพิ่มอินสแตนซ์ Stash-Box\",\n            \"api_key\": \"คีย์ API\",\n            \"description\": \"Stash-Box ของอํานวยความสะดวกในการติดแท็กฉากและนักแสดงโดยอัตโนมัติตามลายนิ้วมือและชื่อไฟล์\\nคุณสามารถดูคีย์ปลายทางและ API ได้ในหน้าบัญชีของคุณบนอินสแตนซ์ที่ Stash-box ต้องใช้ชื่อเมื่อมีการเพิ่มอินสแตนซ์มากกว่าหนึ่งรายการ\",\n            \"endpoint\": \"เอนพอยต์\",\n            \"graphql_endpoint\": \"GraphQL เอนพอยต์\",\n            \"name\": \"ชื่อ\",\n            \"title\": \"Stash-box เอนพอยต์\"\n        },\n        \"system\": {\n            \"transcoding\": \"การแปลงรหัส\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"เพิ่ม {operation_name} ในคิวงาน\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"ติดแท็กอัตโนมัติทุกตำแหน่งไฟล์\",\n                \"auto_tagging_paths\": \"ติดแท็กอัตโนมัติตามตำแหน่งไฟล์ต่อไปนี้\"\n            },\n            \"auto_tag_based_on_filenames\": \"ติดแท็กเนื้อหาอัตโนมัติตามตำแหน่งไฟล์\",\n            \"auto_tagging\": \"การแท็กอัตโนมัติ\",\n            \"backing_up_database\": \"กำลังสำรองฐานข้อมูล\",\n            \"backup_and_download\": \"ทำการสำรองฐานข้อมูลและดาวน์โหลดไฟล์\",\n            \"cleanup_desc\": \"ตรวจสอบไฟล์ที่หายไปและลบออกจากฐานข้อมูล นี่คือการกระทำที่ทำลายล้าง\",\n            \"data_management\": \"การจัดการเดต้า\",\n            \"defaults_set\": \"มีการตั้งค่าเริ่มต้นและจะใช้เมื่อคลิกปุ่ม {action} บนหน้างาน\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"ไม่นับรวมนามสกุลไฟล์เป็นส่วนหนึ่งของชื่อ\",\n            \"empty_queue\": \"ไม่มีงานใดๆ ในคิว\",\n            \"export_to_json\": \"ส่งออกฐานข้อมูลในรูปแบบ JSON ในไดเร็กทอรี metadata\",\n            \"generate_thumbnails_during_scan\": \"สร้างภาพขนาดเล็กสำหรับไฟล์ภาพ\",\n            \"generate_previews_during_scan_tooltip\": \"สร้างภาพเคลื่อนไหวตัวอย่างเพิ่มเติม (webp) ต้องเปิดใช้งานเมื่อตั้งค่า Scene/Marker Wall Preview Type เป็นภาพเคลื่อนไหวเท่านั้น มีข้อดีคือใช้งาน CPU น้อยกว่าวิดีโอตัวอย่าง แต่มีข้อเสียคือต้องสร้างไฟล์เพิ่มเติมและมีขนาดใหญ่กว่า\",\n            \"anonymise_database\": \"ทำสำเนาฐานข้อมูลแบบปิดบังตัวตนและบันทึกไว้ในไดเร็กทอรีสำรองข้อมูล สามารถแจกจ่ายไฟล์ฐานข้อมูลแก้ผู้อื่นเพื่อใช้ในการแก้ไขปัญหาต่างๆ และง่ายต่อการ debug และไม่กระทบฐานข้อมูลหลัก โดยมีรูปแบบชื่อไฟล์ {filename_format}\",\n            \"clean_generated\": {\n                \"markers\": \"ภาพตัวอย่างมาร์คเกอร์\",\n                \"previews_desc\": \"ภาพตัวอย่าง scene และภาพขนาดเล็ก\",\n                \"transcodes\": \"ไฟล์วิดีโอพร้อมใช้ของ scene\",\n                \"blob_files\": \"ไฟล์ blob\",\n                \"description\": \"ลบไฟล์ที่ถูกสร้างขึ้นที่ไม่มีอยู่ในฐานข้อมูล\",\n                \"previews\": \"ภาพตัวอย่าง scene\",\n                \"image_thumbnails\": \"ภาพขนาดเล็ก\",\n                \"image_thumbnails_desc\": \"ภาพขนาดเล็กและคลิป\",\n                \"sprites\": \"Scene Sprites\"\n            },\n            \"generate\": {\n                \"generating_from_paths\": \"กำลังสร้างไฟล์สำหรับ scene ตามตำแหน่งไฟล์ดังนี้\",\n                \"generating_scenes\": \"กำลังสร้างไฟล์สำหรับ {num} {scene}\"\n            },\n            \"generate_desc\": \"กำลังสร้างภาพอื่นๆ sprite วิดีโอ vtt และไฟล์อื่นๆ\",\n            \"generate_phashes_during_scan\": \"กำลังสร้าง perceptual hashes\",\n            \"generate_previews_during_scan\": \"กำลังสร้างภาพเคลื่อนไหวตัวอย่าง\",\n            \"identify\": {\n                \"default_options\": \"ค่าปริยาย\",\n                \"explicit_set_description\": \"ใช้การตั้งค่าต่อไปนี้ ยกเว้นจะมีการตั้งค่าจากแหล่งข้อมูลต้นทาง\",\n                \"field_options\": \"ตัวเลือก Field\",\n                \"heading\": \"ค้นหาและระบุข้อมูล\",\n                \"set_cover_images\": \"ตั้งค่าภาพปก\",\n                \"set_organized\": \"ตั้งค่าเป็น organized\",\n                \"skip_single_name_performers_tooltip\": \"เมื่อปิดใช้งานตัวเลือกนี้ นักแสดงที่มีชื่อทั่วไปเช่น Samantha หรือ Olga จะถูกเลือกเป็นผลลัพธ์\",\n                \"sources\": \"แหล่งข้อมูล\",\n                \"identifying_from_paths\": \"ระบุ scene จากตำแหน่งต่อไปนี้\",\n                \"skip_multiple_matches_tooltip\": \"เมื่อปิดใช้งานตัวเลือกนี้ ผลการเปรียบเทียบข้อมูลจะเป็นแบบสุ่มหากมีผลลัพธ์มากกว่าหนึ่ง\",\n                \"tag_skipped_matches_tooltip\": \"สร้างแท็กพิเศษ (เช่น 'Identify: Multiple Matches') เพื่อง่ายในการค้นหาและตั้งแท็กที่เหมาะสมด้วยตนเอง\",\n                \"description\": \"ตั้งค่า scene metadata อัตโนมัติโดยใช้แหล่งข้อมูลจาก stash-box และ scraper\",\n                \"and_create_missing\": \"และสร้างเนื้อหาที่ไม่มี\",\n                \"create_missing\": \"สร้างขึ้นใหม่หากไม่มี\",\n                \"identifying_scenes\": \"กำลังระบุ {num} {scene}\",\n                \"include_male_performers\": \"ระบุนักแสดงชายด้วย\",\n                \"skip_multiple_matches\": \"ข้ามการเปรียบเทียบข้อมูลหากมีผลลัพธ์มากกว่าหนึ่ง\",\n                \"skip_single_name_performers\": \"ข้ามนักแสดงที่มีเพียงชื่อเดียวและไม่มีการระบุตัวตน\",\n                \"source\": \"แหล่งข้อมูล\",\n                \"source_options\": \"ตัวเลือก {source}\",\n                \"strategy\": \"วิธีการ\",\n                \"tag_skipped_matches\": \"แท็กไฟล์ที่ถูกข้ามการเปรียบเทียบข้อมูลด้วยแท็กพิเศษ\",\n                \"tag_skipped_performers\": \"แท็กการข้ามการจับคู่นักแสดงด้วยแท็ก\",\n                \"field\": \"ประเภทข้อมูล\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"tag_skipped_performer_tooltip\": \"สร้างแท็กพิเศษ (เช่น 'Identify: Single Name Performer') เพื่อง่ายในการค้นหาและเลือกชื่อที่เหมาะสมด้วยตนเอง\"\n            },\n            \"generate_sprites_during_scan_tooltip\": \"ชุดภาพนิ่งใต้ตัวเล่นวิดีโอ ช่วยให้การกรอวิดีโอง่ายขึ้น\",\n            \"generate_video_previews_during_scan\": \"สร้างวิดีโอตัวอย่าง\",\n            \"scan_for_content_desc\": \"สแกนเนื้อหาใหม่เพื่อเพิ่มเข้าสู่ฐานข้อมูล\",\n            \"incremental_import\": \"นำเข้าข้อมูลบางส่วนจากไฟล์ zip ที่เลือก\",\n            \"migrate_hash_files\": \"ใช้เครื่องมือนี้หลังจากเปลี่ยนค่ารูปแบบชื่อไฟล์ hash เพื่อให้ตรงกับรูปแบบใหม่\",\n            \"optimise_database\": \"ทำการวิเคราะห์และปรับปรุงโครงสร้างไฟล์ฐานข้อมูลเพื่อเพิ่มประสิทธิภาพ\",\n            \"migrate_scene_screenshots\": {\n                \"description\": \"ย้ายภาพตัวอย่างของ scene เข้าสู่ระบบจัดเก็บ blob ใหม่ ควรทำการย้ายหลังจากการอัปเกรดระบบเป็นเวอร์ชัน 0.20 สามารถลบข้อมูลเก่าทิ้งได้หลังเสร็จสิ้นการย้ายแล้ว\",\n                \"delete_files\": \"ลบไฟล์ภาพตัวอย่าง\",\n                \"overwrite_existing\": \"เขียนทับข้อมูลภาพตัวอย่างใน blob ปัจจุบัน\"\n            },\n            \"optimise_database_warning\": \"คำเตือน: ในขณะที่กำลังทำงานนี้จะไม่สามารถเปลี่ยนแปลงค่าใดๆ ในฐานข้อมูลได้ ระยะเวลาที่ใช้ขึ้นอยู่กับขนาดของฐานข้อมูล และต้องการพื้นที่ว่างในไดรฟ์เท่ากับหรือมากกว่าขนาดของฐานข้อมูล แนะนำให้มีพื้นที่ว่างมากกว่า 1.5 เท่า\",\n            \"set_name_date_details_from_metadata_if_present\": \"ตั้งค่าชื่อ วันที่ และรายละเอียดจากข้อมูล metadata ที่แนบมาด้วย\",\n            \"generate_sprites_during_scan\": \"สร้าง scrubber sprites\",\n            \"anonymising_database\": \"กำลังปิดบังตัวตนฐานข้อมูล\",\n            \"generate_video_previews_during_scan_tooltip\": \"สร้างวิดีโอตัวอย่างที่จะเล่นอัตโนมัติเมื่อวางเคอร์เซอร์ไว้บน scene\",\n            \"anonymise_and_download\": \"ทำสำเนาฐานข้อมูลแบบปิดบังตัวตนและดาวน์โหลด\",\n            \"generate_clip_previews_during_scan\": \"กำลังสร้างภาพตัวอย่างสำหรับคลิปภาพ\",\n            \"generate_phashes_during_scan_tooltip\": \"มีประโยชน์ช่วยลดไฟล์ซ้ำซ้อนและระบุ scene\",\n            \"generate_video_covers_during_scan\": \"สร้างภาพหน้าปก scene\",\n            \"generated_content\": \"การสร้างเนื้อหาสนับสนุน\",\n            \"import_from_exported_json\": \"นำเข้าข้อมูลจากไฟล์ JSON ของคุณในไดเร็กทอรี metadata และแทนที่ฐานข้อมูลปัจจุบัน\",\n            \"job_queue\": \"คิวงาน\",\n            \"maintenance\": \"การดูแลรักษา\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"ลบข้อมูลเก่า\",\n                \"description\": \"ย้าย blob เข้าสู่ระบบปัจจุบัน ควรทำการย้ายหลังจากเปลี่ยนแปลงการตั้งค่าวิธีจัดเก็บ blob ในระบบ สามารถลบข้อมูลเก่าทิ้งได้หลังเสร็จสิ้นการย้ายแล้ว\"\n            },\n            \"migrations\": \"การย้ายข้อมูล\",\n            \"only_dry_run\": \"เก็บกวาดในโหมดซักซ้อม จะไม่มีการลบข้อมูลใดๆ\",\n            \"plugin_tasks\": \"งานของปลั๊กอิน\",\n            \"scan\": {\n                \"scanning_all_paths\": \"สแกนตำแหน่งไฟล์ทั้งหมด\",\n                \"scanning_paths\": \"สแกนตำแหน่งไฟล์ดังนี้\"\n            }\n        },\n        \"ui\": {\n            \"images\": {\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"หากตัวเลือกวิดีโอถูกปิดใช้งานในไลบราลี ไฟล์วิดีโอจะถูกสแกนเป็นคลิปภาพแทน\",\n                        \"heading\": \"สแกนไฟล์วิดีโอเป็นคลิปภาพ\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"heading\": \"บันทึกภาพตัวอย่าง\",\n                        \"description\": \"บันทึกภาพตัวอย่างที่ถูกสร้างขึ้นลงบนดิสก์\"\n                    }\n                },\n                \"heading\": \"ภาพ\"\n            },\n            \"custom_css\": {\n                \"description\": \"ต้องรีโหลดหน้าเพจใหม่ทุกครั้งเพื่อแสดงผลการตั้งค่า ไม่รับรองความเข้ากันได้ระหว่าง custom CSS และอัพเดตใหม่ของ Stash\",\n                \"heading\": \"Custom CSS\",\n                \"option_label\": \"เปิดใช้งาน custom CSS\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"ต้องรีโหลดหน้าเพจใหม่ทุกครั้งเพื่อแสดงผลการตั้งค่า ไม่รับรองความเข้ากันได้ระหว่าง custom Javascript และอัพเดตใหม่ของ Stash\",\n                \"heading\": \"Custom Javascript\",\n                \"option_label\": \"เปิดใช้งาน custom Javascript\"\n            },\n            \"custom_locales\": {\n                \"description\": \"ข้อความที่ต้องการใช้งานแทนที่ชุดภาษาที่เลือก ดูรายการข้อความเพิ่มเติมที่ ttps://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json ต้องรีโหลดหน้าเพจใหม่เพื่อแสดงผลการตั้งค่า\",\n                \"option_label\": \"เปิดใช้งานชุดภาษากำหนดเอง\",\n                \"heading\": \"ชุดภาษากำหนดเอง\"\n            },\n            \"desktop_integration\": {\n                \"send_desktop_notifications_for_events\": \"ส่งการแจ้งเตือนเหตุการณ์ต่างๆ ให้กับเดสก์ท็อป\",\n                \"skip_opening_browser_on_startup\": \"ข้ามการเปิดบราวเซอร์โดยอัตโนมัติเมื่อเปิดเครื่อง\",\n                \"notifications_enabled\": \"เปิดใช้งานการแจ้งเตือน\",\n                \"desktop_integration\": \"การทำงานร่วมกันกับเดสก์ท็อป\",\n                \"skip_opening_browser\": \"ข้ามการเปิดบราวเซอร์\"\n            },\n            \"detail\": {\n                \"enable_background_image\": {\n                    \"heading\": \"ภาพพื้นหลัง\",\n                    \"description\": \"แสดงภาพพื้นหลังในหน้าแสดงรายละเอียด\"\n                },\n                \"heading\": \"หน้าแสดงรายละเอียด\",\n                \"show_all_details\": {\n                    \"description\": \"เปิดใช้งานตัวเลือกนี้เพื่อแสดงรายละเอียดทั้งหมดเสมอ ข้อมูลจะถูกแสดงผลในหนึ่งคอลัมน์\",\n                    \"heading\": \"แสดงข้อมูลทั้งหมด\"\n                },\n                \"compact_expanded_details\": {\n                    \"description\": \"เปิดใช้งานตัวเลือกนี้เพื่อแสดงข้อมูลนักแสดงเพิ่มเติมโดยยังใช้พื้นที่แสดงผลน้อยเท่าเดิม\",\n                    \"heading\": \"แสดงรายละเอียดเพิ่มเติมในพื้นที่เท่าเดิม\"\n                }\n            },\n            \"editing\": {\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"options\": {\n                            \"half\": \"ครึ่งดาว\",\n                            \"quarter\": \"หนึ่งในสี่ดาว\",\n                            \"full\": \"เต็มดาว\",\n                            \"tenth\": \"หนึ่งในสิบดาว\"\n                        },\n                        \"label\": \"ความละเอียดการติดดาว\"\n                    },\n                    \"type\": {\n                        \"label\": \"ระบบการให้คะแนน\",\n                        \"options\": {\n                            \"decimal\": \"ตัวเลขทศนิยม\",\n                            \"stars\": \"ดาว\"\n                        }\n                    }\n                },\n                \"heading\": \"การแก้ไขข้อมูล\",\n                \"disable_dropdown_create\": {\n                    \"description\": \"ปิดไม่ให้สร้างเนื้อหาใหม่จากกล่องตัวเลือก\",\n                    \"heading\": \"ไม่ให้สร้างเนื้อหาจากกล่องตัวเลือก\"\n                },\n                \"max_options_shown\": {\n                    \"label\": \"จำนวนข้อมูลมากสุดที่จะแสดงผลในกล่องตัวเลือก\"\n                }\n            },\n            \"abbreviate_counters\": {\n                \"heading\": \"ย่อข้อมูลตัวเลข\",\n                \"description\": \"ย่อข้อมูลตัวเลข เช่นจาก \\\"1831\\\" จะถูกย่อเป็น \\\"1.8K\\\"\"\n            },\n            \"basic_settings\": \"การตั้งค่าพื้นฐาน\",\n            \"delete_options\": {\n                \"description\": \"ค่าปริยายเมื่อทำการลบภาพ แกลเลอรี และซีน\",\n                \"heading\": \"ตัวเลือกการลบ\",\n                \"options\": {\n                    \"delete_file\": \"ลบไฟล์จากเครื่องคอมพิวเตอร์ของคุณเสมอ\",\n                    \"delete_generated_supporting_files\": \"ลบไฟล์เนื้อหาที่ถูกสร้างขึ้นเสมอ\"\n                }\n            },\n            \"menu_items\": {\n                \"description\": \"ซ่อนหรือแสดงเมนูเนื้อหาต่างๆ บนแถบนำทาง\",\n                \"heading\": \"รายการเมนู\"\n            },\n            \"interactive_options\": \"ตัวเลือกอุปกรณ์อินเตอร์แอ็คทีฟ\",\n            \"preview_type\": {\n                \"description\": \"ค่าปริยายชนิดภาพตัวอย่างคือไฟล์วิดีโอ mp4 เลือกใช้ภาพเคลื่อนไหว (webp) หากต้องการลดการใช้ทรัพยากรระบบ โดยภาพเคลื่อนไหวจะถูกสร้างขึ้นเพิ่มเติมจากไฟล์วิดีโอและจะมีขนาดใหญ่กว่า\",\n                \"heading\": \"ชนิดภาพตัวอย่าง\",\n                \"options\": {\n                    \"animated\": \"ภาพเคลื่อนไหว\",\n                    \"video\": \"วิดีโอ\",\n                    \"static\": \"ภาพนิ่ง\"\n                }\n            },\n            \"scene_player\": {\n                \"options\": {\n                    \"always_start_from_beginning\": \"เริ่มเล่นวิดีโอจากจุดเริ่มต้นเสมอ\",\n                    \"auto_start_video\": \"เริ่มเล่นวิดีโออัตโนมัติ\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"เริ่มเล่นวิดีโอโดยอัตโนมัติเมื่อสั่งเล่นจากคิว จากไฟล์ที่เลือก หรือจากการสุ่ม\",\n                        \"heading\": \"เริ่มเล่นวิดีโอโดยอัตโนมัติเมื่อสั่งเล่นจากไฟล์ที่เลือก\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"heading\": \"เล่นไฟล์ถัดไปในเพลย์ลิสต์เสมอ\",\n                        \"description\": \"เล่นซีนถัดไปในคิวเมื่อจบวิดีโอ\"\n                    },\n                    \"disable_mobile_media_auto_rotate\": \"ปิดไม่ให้หมุนหน้าจออัตโนมัติเมื่อใช้งานบนอุปกรณ์พกพาแบบขยายเต็มจอ\",\n                    \"show_ab_loop_controls\": \"แสดงแผงควบคุมปลั๊กอิน AB Loop\",\n                    \"show_scrubber\": \"แสดงแผงกรอวิดีโอ\",\n                    \"enable_chromecast\": \"เปิดใช้งาน Chromecast\",\n                    \"track_activity\": \"เปิดใช้งานประวัติการดูซีน\",\n                    \"vr_tag\": {\n                        \"description\": \"แสดงปุ่ม VR เมื่อซีนมีแท็กเหล่านี้\",\n                        \"heading\": \"ปุ่ม VR\"\n                    }\n                },\n                \"heading\": \"เครื่องเล่นซีน\"\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"จำนวนครั้งที่ต้อง scroll ก่อนจะเปลี่ยนภาพ มีผลเฉพาะเมื่อเปิดใช้ตัวเลือก Pan Y\",\n                \"heading\": \"จำนวนครั้งที่ต้อง scroll ก่อนเปลี่ยนภาพ\"\n            },\n            \"slideshow_delay\": {\n                \"heading\": \"ระยะเวลาเปลี่ยนภาพสไลด์โชว์ (วินาที)\",\n                \"description\": \"ใช้งานสไลด์โชว์ในหน้าแกลเลอรีได้เมื่ออยู่ในโหมดกำแพงภาพ\"\n            },\n            \"tag_panel\": {\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"heading\": \"แสดงเนื้อหาจากแท็กย่อย\",\n                        \"description\": \"ในหน้ามุมมองแท็ก ให้แสดงเนื้อหาจากแท็กย่อยด้วย\"\n                    }\n                },\n                \"heading\": \"มุมมองแท็ก\"\n            },\n            \"use_stash_hosted_funscript\": {\n                \"heading\": \"เปิดใช้เรียกใช้ funscripts ได้โดยตรง\",\n                \"description\": \"เมื่อเปิดใช้งานตัวเลือกนี้ อุปกรณ์ Handy จะสามารถเรียกใช้ funscripts จาก Stash ได้โดยตรงโดยไม่ต้องผ่านเซิร์ฟเวอร์เจ้าอื่น โดย Stash จำเป็นต้องเข้าถึงได้จากอุปกรณ์ และต้องใช้กุญแจ API หากตั้งค่าความปลอดภัยไว้\"\n            },\n            \"title\": \"ส่วนติดต่อผู้ใช้\",\n            \"studio_panel\": {\n                \"heading\": \"มุมมองสตูดิโอ\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"ในมุมมองสตูดิโอ ให้แสดงเนื้อหาจากสตูดิโอลูกด้วย\",\n                        \"heading\": \"แสดงเนื้อหาจากสตูดิโอลูก\"\n                    }\n                }\n            },\n            \"image_wall\": {\n                \"direction\": \"ทิศทาง\",\n                \"heading\": \"กำแพงภาพ\",\n                \"margin\": \"ขนาดขอบ (พิกเซล)\"\n            },\n            \"language\": {\n                \"heading\": \"ภาษา\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"ร้อยละของเวลาที่ผ่านไปของซีนที่กำลังดูที่จะถูกนับจำนวนครั้งที่เล่นไฟล์\",\n                \"heading\": \"ร้อยละของเวลาดูวิดีโอ\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"กำหนดตำแหน่งจัดเก็บภาพถ่ายนักแสดงด้วยตัวเอง หรือเว้นว่างเพื่อใช้ค่าปริยาย\",\n                        \"heading\": \"กำหนดตำแหน่งจัดเก็บภาพถ่ายนักแสดง\"\n                    }\n                }\n            },\n            \"image_lightbox\": {\n                \"heading\": \"กล่องภาพ\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"ระยะเวลามากสุดก่อนที่ตัวเล่นวิดีโอจะวนลูป - ใส่ 0 เพื่อปิดใช้งาน\",\n                \"heading\": \"ระยะเวลาก่อนวนลูป\"\n            },\n            \"scene_list\": {\n                \"heading\": \"มุมมองกริด\",\n                \"options\": {\n                    \"show_studio_as_text\": \"แสดงชื่อสตูดิโอบนหน้าปกเป็นข้อความ\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"กำแพงซีน / มาร์คเกอร์\",\n                \"options\": {\n                    \"display_title\": \"แสดงชื่อเรื่องและแท็ก\",\n                    \"toggle_sound\": \"เปิดเสียง\"\n                }\n            },\n            \"show_tag_card_on_hover\": {\n                \"heading\": \"กล่องแท็ก\",\n                \"description\": \"แสดงกล่องแท็กเมื่อวางเคอร์เซอร์บนแท็ก\"\n            },\n            \"funscript_offset\": {\n                \"heading\": \"หน่วงเวลา Funscript\",\n                \"description\": \"หน่วงเวลาการเล่นสคริปต์อินเตอร์แอ็คทีฟ หน่วยมิลลิวินาที\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"เชื่อมต่อ\",\n                \"server_offset\": {\n                    \"heading\": \"หน่วงเวลาเซิร์ฟเวอร์\"\n                },\n                \"status\": {\n                    \"heading\": \"สถานะการเชื่อมต่ออุปกรณ์ Handy\"\n                },\n                \"sync\": \"ซิงค์\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"ระบุคีย์สำหรับเชื่อมต่ออุปกรณ์ Handy และแบ่งปันข้อมูลการเล่นซีนกับเว็บไซต์ handyfeeling.com\",\n                \"heading\": \"คีย์สำหรับเชื่อมต่อ Handy\"\n            }\n        },\n        \"tools\": {\n            \"scene_filename_parser\": {\n                \"ignore_organized\": \"ไม่สนใจ scene ที่ organize แล้ว\",\n                \"ignored_words\": \"ไม่สนใจคำต่อไปนี้\",\n                \"title\": \"เครื่องมือตั้งชื่อไฟล์ scene\",\n                \"whitespace_chars\": \"การแทนที่อักขระ whitespace\",\n                \"add_field\": \"เพิ่ม Field\",\n                \"capitalize_title\": \"อักษรตัวแรกเป็นตัวพิมพ์ใหญ่\",\n                \"display_fields\": \"แสดง fields\",\n                \"escape_chars\": \"ใช้ \\\\ เพื่อหยุดการตรวจจับอักขระ\",\n                \"filename\": \"ชื่อไฟล์\",\n                \"filename_pattern\": \"รูปแบบชื่อไฟล์\",\n                \"select_parser_recipe\": \"เลือกสูตรการตั้งชื่อ\",\n                \"whitespace_chars_desc\": \"อักขระที่จะใช้แทนที่อักขระ whitespace ในชื่อไฟล์\",\n                \"matches_with\": \"เปรียบเทียบคำโดยตรง\"\n            },\n            \"scene_duplicate_checker\": \"เครื่องมือตรวจสอบ scene ซ้ำ\",\n            \"scene_tools\": \"เครื่องมือเกี่ยวกับ scene\"\n        },\n        \"advanced_mode\": \"โหมดปรับแต่งขั้นสูง\"\n    },\n    \"studio_tagger\": {\n        \"updating_untagged_studios_description\": \"อัพเดตสตูดิโอที่ยังไม่มีข้อมูลโดยการค้นหาสตูดิโอที่ยังไม่มี stashid และทำการอัปเดตข้อมูล metadata\",\n        \"config\": {\n            \"create_parent_label\": \"สร้างสตูดิโอบริษัทแม่\",\n            \"create_parent_desc\": \"สร้างสตูดิโอบริษัทแม่หรือแท็กที่เกี่ยวข้องหากยังไม่มี พร้อมอัปเดตข้อมูลและรูปภาพให้กับรายการที่มีอยู่แล้ว\"\n        },\n        \"create_or_tag_parent_studios\": \"สร้างสตูดิโอบริษัทแม่หรือแท็กที่เกียวข้องหากยังไม่มี\",\n        \"untagged_studios\": \"สตูดิโอที่ยังไม่มีข้อมูล\",\n        \"failed_to_save_studio\": \"บันทึกสตูดิโอ \\\"{studio}\\\" ไม่สำเร็จ\",\n        \"network_error\": \"พบข้อผิดพลาดทางเน็ตเวิร์ค\",\n        \"refresh_tagged_studios\": \"รีเฟรชสตูดิโอที่มีข้อมูลแล้ว\",\n        \"batch_add_studios\": \"เพิ่มสตูดิโอพร้อมกันหลายแห่ง\",\n        \"any_names_entered_will_be_queried\": \"รายชื่อสตูดิโอจะถูกเรียกหาข้อมูลจาก stash-box ที่เลือกไว้ โดยจะเลือกเฉพาะข้อมูลที่ตรงกันเท่านั้น\",\n        \"batch_update_studios\": \"อัพเดตสตูดิโอพร้อมกันหลายแห่ง\",\n        \"no_results_found\": \"ไม่พบผลลัพธ์\",\n        \"status_tagging_job_queued\": \"สถานะ: เพิ่มงานเพิ่มข้อมูลแล้ว\",\n        \"to_use_the_studio_tagger\": \"ต้องทำการตั้งค่า stash-box ก่อนถึงจะใช้งานเครื่องมือเพิ่มข้อมูลสตูดิโอได้\",\n        \"status_tagging_studios\": \"สถานะ: กำลังเพิ่มข้อมูลสตูดิโอ\",\n        \"studio_already_tagged\": \"มีข้อมูลสตูดิโอนี้แล้ว\",\n        \"add_new_studios\": \"เพิ่มสตูดิโอ\",\n        \"current_page\": \"หน้าปัจจุบัน\",\n        \"name_already_exists\": \"พบสตูดิโอที่ใช้ชื่อนี้แล้ว\",\n        \"number_of_studios_will_be_processed\": \"จะอัปเดตสตูดิโอจำนวน {studio_count} แห่ง\",\n        \"query_all_studios_in_the_database\": \"สตูดิโอทั้งหมดในฐานข้อมูล\",\n        \"refreshing_will_update_the_data\": \"การรีเฟรชจะอัปเดตสตูดิโอที่มีข้อมูลแล้วด้วยข้อมูลจาก stash-box ที่เลือก\",\n        \"update_studio\": \"อัพเดตสตูดิโอ\",\n        \"studio_selection\": \"การเลือกสตูดิโอ\",\n        \"studio_successfully_tagged\": \"เพิ่มข้อมูลสตูดิโอสำเร็จแล้ว\",\n        \"tag_status\": \"สถานะการเพิ่มข้อมูล\",\n        \"update_studios\": \"อัพเดตสตูดิโอ\"\n    },\n    \"circumcised_types\": {\n        \"UNCUT\": \"ยกเลิกการตัด\",\n        \"CUT\": \"ตัด\"\n    },\n    \"setup\": {\n        \"paths\": {\n            \"where_can_stash_store_its_database_description\": \"Stash ใช้ระบบฐานข้อมูล SQLite เพื่อจัดเก็บข้อมูล metadata ของกรุหนังของคุณ หากไม่ได้ระบุเป็นอย่างอื่น ไฟล์ <code>stash-go.sqlite</code> จะถูกสร้างขึ้นในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งและชื่อไฟล์ที่ต้องการแบบ absolute หรือ relative\",\n            \"stash_alert\": \"ไม่ได้ตั้งค่าตำแหน่งไลบรารีไว้ จะไม่มีการเพิ่มเนื้อหาใดๆ เข้าสู่ Stash คุณแน่ใจหรือไม่?\",\n            \"database_filename_empty_for_default\": \"ชื่อฐานข้อมูล (เว้นว่างไว้เพื่อใช้ค่าปริยาย)\",\n            \"path_to_cache_directory_empty_for_default\": \"ไดเร็กทอรีสำหรับไฟล์แคช (เว้นว่างไว้เพื่อใช้ค่าปริยาย)\",\n            \"description\": \"ถัดไปเป็นการตั้งค่าตำแหน่งที่ต้องการเก็บไฟล์หนังของคุณ และตำแหน่งสำหรับบันทึกฐานข้อมูล ไฟล์ที่ถูกสร้าง และไฟล์แคช โดยคุณสามารถเปลี่ยนแปลงค่าเหล่านี้ได้ภายหลัง\",\n            \"set_up_your_paths\": \"ระบุตำแหน่งไฟล์\",\n            \"path_to_generated_directory_empty_for_default\": \"ไดเร็กทอรีสำหรับไฟล์ที่ถูกสร้างขึ้น (เว้นว่างไว้เพื่อใช้ค่าปริยาย)\",\n            \"path_to_blobs_directory_empty_for_default\": \"ไดเร็กทอรีสำหรับไฟล์ blob (เว้นว่างไว้เพื่อใช้ค่าปริยาย)\",\n            \"store_blobs_in_database\": \"จัดเก็บ blob ในฐานข้อมูล\",\n            \"where_can_stash_store_blobs\": \"คุณต้องการให้ Stash จัดเก็บฐานข้อมูลที่ไหน?\",\n            \"where_can_stash_store_its_database\": \"ต้องการให้ Stash เก็บไฟล์ฐานข้อมูลที่ไหน?\",\n            \"where_can_stash_store_cache_files\": \"คุณต้องการให้ Stash เก็บไฟล์แคชที่ไหน?\",\n            \"where_can_stash_store_blobs_description_addendum\": \"อีกทางเลือกหนึ่งคือคุณสามารถตั้งค่าให้เก็บบันทึกข้อมูลในฐานข้อมูลก็ได้เช่นกัน <strong>หมายเหตุ:</strong> ทางเลือกนี้จะทำให้ขนาดไฟล์ฐานข้อมูลใหญ่ขึ้นและใช้เวลาในการโยกย้ายนานขึ้นด้วย\",\n            \"where_can_stash_store_cache_files_description\": \"Stash ต้องการที่เก็บแคชสำหรับไฟล์ใช้งานชั่วคราวเพื่อให้ฟังก์ชันการแปลงวิดีโอสดแบบ HSL/DASH ทำงานได้ หากไม่ได้ระบุเป็นอย่างอื่น Stash จะสร้างไดเร็กทอรี <code>cache</code> ไว้ในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้\",\n            \"where_can_stash_store_blobs_description\": \"Stash สามารถบันทึกข้อมูลไบนารี เช่น หน้าปกซีน นักแสดง สตูดิโอ และภาพประกอบแท็ก ในฐานข้อมูลหรือในไฟล์ระบบก็ได้ หากไม่ได้ระบุเป็นอย่างอื่น Stash จะจัดเก็บในไฟล์ระบบที่ไดเร็กทอรีย่อย <code>blobs</code> ซึ่งอยู่ในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีในรูปแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้\",\n            \"where_can_stash_store_its_generated_content\": \"ต้องการให้ Stash เก็บเนื้อหาที่ถูกสร้างขึ้นที่ไหน?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Stash จะสร้างไฟล์รูปภาพและวิดีโอขึ้นเพื่อใช้เป็นไฟล์ตัวอย่างสำหรับเนื้อหา รวมถึงการแปลงไฟล์จากรูปแบบที่ไม่รองรับด้วย หากไม่ได้ระบุเป็นอย่างอื่น Stash จะสร้างไดเร็กทอรี <code>generated</code> ขึ้นในไดเร็กทอรีเดียวกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีในรูปแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้\",\n            \"where_is_your_porn_located\": \"ไฟล์หนังชมพูของคุณอยู่ที่ไหน?\",\n            \"where_can_stash_store_its_database_warning\": \"คำเตือน: Stash <strong>ไม่รองรับ</strong>การจัดเก็บฐานข้อมูลในที่อื่นๆ ที่ไม่ใช่เซิร์ฟเวอร์ Stash! (เช่นจัดเก็บฐานข้อมูลบน NAS แต่เซิร์ฟเวอร์อยู่ที่คอมพิวเตอร์เครื่องอื่น) ระบบฐานข้อมูล SQLite ไม่ได้ถูกออกแบบมาสำหรับการทำงานบนเน็ตเวิร์คและอาจทำให้ฐานข้อมูลพังได้\",\n            \"where_is_your_porn_located_description\": \"เพิ่มไดเร็กทอรีที่มีไฟล์หนังชมพูและรูปภาพชมพูของคุณ Stash จะสแกนและเพิ่มไฟล์เหล่านั้นเข้าสู่ฐานข้อมูล\"\n        },\n        \"confirm\": {\n            \"stash_library_directories\": \"ไดเร็กทอรีสำหรับไลบรารี Stash\",\n            \"almost_ready\": \"การตั้งค่าใกล้สำเร็จ กรุณายืนยันค่าต่อไปนี้ คุณสามารถคลิกย้อนกลับเพื่อกลับไปเปลี่ยนแปลงค่าได้ หากข้อมูลทุกอย่างถูกต้องคลิดยืนยันเพื่อเริ่มสร้างระบบ\",\n            \"blobs_use_database\": \"<using database>\",\n            \"nearly_there\": \"เกือบเสร็จแล้ว!\",\n            \"cache_directory\": \"ไดเร็กทอรีสำหรับแคช\",\n            \"blobs_directory\": \"ไดเร็กทอรีสำหรับข้อมูล binary\",\n            \"configuration_file_location\": \"ตำแหน่งไฟล์บันทึกการตั้งค่า:\",\n            \"database_file_path\": \"ตำแหน่งไฟล์ฐานข้อมูล\",\n            \"generated_directory\": \"ไดเร็กทอรีสำหรับไฟล์ที่ถูกสร้างขึ้น\"\n        },\n        \"migrate\": {\n            \"migration_irreversible_warning\": \"การย้ายฐานข้อมูลแบบ schema เป็นการย้ายแบบถาวร เมื่อย้ายสำเร็จฐานข้อมูลนี้จะไม่สามารถใช้งานกับ stash รุ่นก่อนหน้าได้\",\n            \"migration_notes\": \"Migration Notes\",\n            \"migration_required\": \"จำเป็นต้องย้ายฐานข้อมูล\",\n            \"migration_failed_error\": \"พบเจอปัญหาต่อไปนี้ระหว่างการย้ายฐานข้อมูล:\",\n            \"schema_too_old\": \"ฐานข้อมูลปัจจุบันของคุณเป็นรุ่น <strong>{databaseSchema}</strong> ซึ่งจำเป็นต้องทำการย้ายขึ้นไปเป็นรุ่น <strong>{appSchema}</strong> คุณไม่สามารถใช้งาน Stash รุ่นนี้ได้โดยไม่ทำการย้ายฐานข้อมูล หากไม่ต้องการคุณสามารถดาวน์เกรดกลับไปใช้รุ่นก่อนหน้าที่เข้ากันได้กับฐานข้อมูลของคุณ\",\n            \"backup_recommended\": \"การสำรองไฟล์ฐานข้อมูลก่อนทำการย้ายฐานข้อมูลเป็นแนวทางปฏิบัติที่ดิ เราช่วยทำให้คุณได้โดยจะบันทึกไฟล์สำรองไว้ที่ <code>{defaultBackupPath}</code>\",\n            \"backup_database_path_leave_empty_to_disable_backup\": \"ตำแหน่งสำหรับสำรองไฟล์ฐานข้อมูล (เว้นว่างไว้หากไม่ต้องการสำรองข้อมูล):\",\n            \"migrating_database\": \"กำลังย้ายฐานข้อมูล\",\n            \"migration_failed\": \"การย้ายฐานข้อมูลไม่สำเร็จ\",\n            \"migration_failed_help\": \"กรุณาแก้ไขก่อนลองอีกครั้ง หากไม่สำเร็จคุณสามารถรายงานบั๊กได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}\",\n            \"perform_schema_migration\": \"เริ่มการย้ายฐานข้อมูลแบบ schema\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"กำลังสร้างระบบ\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"ไม่นะ! เกิดข้อผิดพลาดบางอย่าง!\",\n            \"something_went_wrong_description\": \"หากปัญหาเกิดขึ้นจากการตั้งค่าไม่ถูกต้อง คลิกย้อนกลับเพื่อกลับไปแก้ไขให้ถูกต้อง หากเป็นกรณีอื่นคุณสามารถรายงานบั๊กได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}\",\n            \"something_went_wrong_while_setting_up_your_system\": \"เกิดข้อผิดพลาดระหว่างสร้างระบบ ข้อมูลความผิดพลาดคือ: {error}\"\n        },\n        \"folder\": {\n            \"up_dir\": \"ย้อนไดเร็กทอรีขึ้นไปหนึ่งระดับ\",\n            \"file_path\": \"ตำแหน่งไฟล์\"\n        },\n        \"github_repository\": \"Github repository\",\n        \"success\": {\n            \"support_us\": \"สนับสนุนเรา\",\n            \"thanks_for_trying_stash\": \"ขอบคุณที่เลือกใช้ Stash!\",\n            \"help_links\": \"หากพบเจอปัญหา มีข้อสงสัย หรือข้อเสนอแนะ สามารถรายงานปัญหาได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}\",\n            \"in_app_manual_explained\": \"เราแนะนำให้ศึกษาคู่มือการใช้งานภายใน Stash โดยคลิกที่ไอคอน {icon} ทางด้านขวาบนของหน้าจอ\",\n            \"missing_ffmpeg\": \"ไม่พบไฟล์ <code>ffmpeg</code> คุณสามารถดาวน์โหลดและติดตั้งในไดเร็กทอรีการตั้งค่าได้ทันทีโดยติ๊กถูกที่กล่องด้านล่างนี้ หรือคุณสามารถระบุตำแหน่งไฟล์ <code>ffmpeg</code> และ <code>ffprobe</code> ได้ด้วยตนเองในหน้าระบบ\",\n            \"next_config_step_one\": \"หน้าถัดไปเป็นหน้าการตั้งค่าต่างๆ ของระบบ เช่นปรับแต่งการค้นหาไฟล์ ตั้งรหัสล็อกอิน เป็นต้น\",\n            \"next_config_step_two\": \"หากตั้งค่าเสร็จสิ้นแล้ว คุณสามารถเริ่มสแกนเนื้อหาได้โดยไปที่หน้า <code>{localized_task}</code> แล้วคลิก <code>{localized_scan}</code>\",\n            \"welcome_contrib\": \"เรายินดีต้อนรับผู้อยากสนับสนุนเราในทุกรูปแบบเสมอ ไม่ว่าจะเป็นการโค้ด (แก้บั๊ก การปรับปรุงโค้ด หรือการเพิ่มความสามารถระบบ) ร่วมทดสอบระบบ รายงานบั๊ก เสนอข้อปรับปรุง เสนอขอความสามารถใหม่ๆ หรือการขอความช่วยเหลือก็ตาม โดยสามารถดูรายละเอียดเพิ่มเติมได้ในคู่มือภายใน Stash\",\n            \"your_system_has_been_created\": \"การติดตั้งสำเร็จ! ระบบถูกสร้างเรียบร้อยแล้ว!\",\n            \"download_ffmpeg\": \"ดาวน์โหลด FFmpeg\",\n            \"getting_help\": \"ขอความช่วยเหลือ\",\n            \"open_collective\": \"อย่าลืมแวะไปที่ {open_collective_link} เพื่อร่วมเป็นส่วนหนึ่งในการพัฒนา Stash ของเรา\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash จะใช้ตำแหน่งเก็บไฟล์การตั้งค่าดังนี้: <code>{path}</code>\",\n            \"unable_to_locate_specified_config\": \"Stash ไม่พบไฟล์การตั้งค่าที่ตำแหน่งที่ระบุไว้ ตัวช่วยการติดตั้งนี้จะช่วยดำเนินการปรับแต่งการตั้งค่าให้คุณ\",\n            \"next_step\": \"คลิกต่อไปเมื่อพร้อมดำเนินการต่อ\"\n        },\n        \"welcome\": {\n            \"in_the_current_working_directory\": \"ภายในไดเร็กทอรีที่ทำงานอยู่ <code>{path}</code>:\",\n            \"in_the_current_working_directory_disabled_macos\": \"ไม่สามารถทำงานได้ในขณะที่ <code>Stash.app</code> ทำงานอยู่<br></br>เรียกใช้ <code>stash-macos</code> เพื่อติดตั้งในไดเร็กทอรีที่ทำงานอยู่\",\n            \"in_the_current_working_directory_disabled\": \"ภายในไดเร็กทอรีที่ทำงานอยู่ <code>{path}</code>:\",\n            \"store_stash_config\": \"ต้องการเก็บไฟล์การตั้งค่าไว้ที่ไหน?\",\n            \"config_path_logic_explained\": \"Stash จะมองหาไฟล์การตั้งค่า (<code>config.yml</code>) จากไดเร็กทอรีที่ทำงานอยู่ก่อนเสมอ หากไม่เจอจึงจะใช้ค่า <code>{fallback_path}</code> แทน คุณสามารถตั้งให้ Stash มองหาไฟล์จากตำแหน่งที่ต้องการได้โดยการระบุ <code>-c '<path to config file>'</code> or <code>--config '<path to config file>'</code>\",\n            \"unable_to_locate_config\": \"ไม่พบไฟล์การตั้งค่าที่มีอยู่ ตัวช่วยการติดตั้งนี้จะช่วยดำเนินการปรับแต่งการตั้งค่าให้คุณ\",\n            \"in_current_stash_directory\": \"ภายในไดเร็กทอรี <code>{path}</code>:\",\n            \"next_step\": \"กรุณาระบุตำแหน่งสำหรับจัดเก็บไฟล์การตั้งค่า\",\n            \"unexpected_explained\": \"พบปัญหาบางอย่าง กรุณารีสตาร์ท Stash ในไดเร็กทอรีที่ถูกต้องด้วยธง <code>-c</code>\"\n        },\n        \"welcome_to_stash\": \"ยินดีต้อนรับสู่ Stash\",\n        \"stash_setup_wizard\": \"ตัวช่วยการติดตั้ง Stash\"\n    },\n    \"chapters\": \"ฉาก\",\n    \"studio_and_parent\": \"สตูดิโอและบริษัทแม่\",\n    \"metadata\": \"Metadata\",\n    \"parent_studio\": \"สตูดิโอบริษัทแม่\",\n    \"performer_tagger\": {\n        \"updating_untagged_performers_description\": \"อัปเดตนักแสดงที่ยังไม่มีข้อมูลโดยการค้นหานักแสดงที่ไม่มี stashid และทำการอัพเดตข้อมูล metadata\",\n        \"untagged_performers\": \"นักแสดงที่ยังไม่มีข้อมูล\",\n        \"add_new_performers\": \"เพิ่มนักแสดง\",\n        \"performer_selection\": \"การเลือกนักแสดง\",\n        \"performer_successfully_tagged\": \"เพิ่มข้อมูลนักแสดงสำเร็จแล้ว:\",\n        \"tag_status\": \"สถานะการเพิ่มข้อมูล\",\n        \"refresh_tagged_performers\": \"รีเฟรชนักแสดงที่มีข้อมูลแล้ว\",\n        \"batch_add_performers\": \"เพิ่มนักแสดงพร้อมกันหลายคน\",\n        \"batch_update_performers\": \"อัปเดตข้อมูลนักแสดงพร้อมกันหลายคน\",\n        \"network_error\": \"พบปัญหาเน็ตเวิร์ค\",\n        \"any_names_entered_will_be_queried\": \"รายชื่อนักแสดงจะถูกเรียกหาข้อมูลจาก stash-box ที่เลือกไว้ โดยจะเลือกเฉพาะข้อมูลที่ตรงกันเท่านั้น\",\n        \"failed_to_save_performer\": \"ไม่สามารถบันทึกข้อมูลนักแสดง \\\"{performer}\\\" ได้\",\n        \"no_results_found\": \"ไม่พบผลลัพธ์\",\n        \"update_performer\": \"เครื่องมืออัปเดตข้อมูลนักแสดง\",\n        \"to_use_the_performer_tagger\": \"ต้องทำการตั้งค่า stash-box ก่อนถึงจะใช้งานเครื่องมือเพิ่มข้อมูลนักแสดงได้\",\n        \"refreshing_will_update_the_data\": \"การรีเฟรชจะอัปเดตนักแสดงที่มีข้อมูลแล้วด้วยข้อมูลจาก stash-box ที่เลือก\",\n        \"name_already_exists\": \"พบนักแสดงที่ใช้ชื่อนี้แล้ว\",\n        \"number_of_performers_will_be_processed\": \"จะอัปเดตนักแสดงจำนวน {performer_count} คน\",\n        \"status_tagging_performers\": \"สถานะ: กำลังเพิ่มข้อมูลนักแสดง\",\n        \"update_performers\": \"เครื่องมืออัปเดตข้อมูลนักแสดง\",\n        \"status_tagging_job_queued\": \"สถานะ: งานเพิ่มข้อมูลถูกเพิ่มเข้าคิว\",\n        \"current_page\": \"หน้าปัจจุบัน\",\n        \"performer_already_tagged\": \"มีข้อมูลนักแสดงคนนี้แล้ว\",\n        \"query_all_performers_in_the_database\": \"นักแสดงทั้งหมดในฐานข้อมูล\"\n    },\n    \"library\": \"ไลบราลี\",\n    \"audio_codec\": \"โคเด็คเสียง\",\n    \"blobs_storage_type\": {\n        \"database\": \"ฐานข้อมูล\",\n        \"filesystem\": \"ระบบไฟล์\"\n    },\n    \"circumcised\": \"ขลิบ\",\n    \"appears_with\": \"แสดงร่วมกับ\",\n    \"parent_studios\": \"สตูดิโอบริษัทแม่\",\n    \"between_and\": \"และ\",\n    \"criterion_modifier\": {\n        \"greater_than\": \"มากกว่า\",\n        \"not_null\": \"ไม่ถูกเว้นว่าง\",\n        \"is_null\": \"ถูกเว้นว่าง\",\n        \"matches_regex\": \"ตรงกับ regex\",\n        \"equals\": \"คือ\",\n        \"between\": \"ระหว่าง\",\n        \"includes\": \"มี\",\n        \"includes_all\": \"มีทั้งหมด\",\n        \"less_than\": \"น้อยกว่า\",\n        \"not_between\": \"ไม่อยู่ระหว่าง\",\n        \"not_equals\": \"ไม่ใช่\",\n        \"not_matches_regex\": \"ไม่ตรงกับ regex\",\n        \"excludes\": \"ยกเว้น\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (excludes {excludedString})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (excludes {excludedString}) (+{depth, plural, =-1 {all} other {{depth}}})\"\n    },\n    \"country\": \"ประเทศ\",\n    \"dialogs\": {\n        \"clear_play_history_confirm\": \"คุณแน่ใจว่าต้องการล้างประวัติการเล่นใช่หรือไม่?\",\n        \"clear_o_history_confirm\": \"คุณแน่ใจว่าต้องการล้างประวัติ O ใช่หรือไม่?\",\n        \"edit_entity_title\": \"แก้ไข{count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"lightbox\": {\n            \"delay\": \"การหน่วงเวลา (วินาที)\",\n            \"scroll_mode\": {\n                \"pan_y\": \"Pan Y\",\n                \"label\": \"โหมดการเลื่อนเปลี่ยนภาพ\",\n                \"zoom\": \"ซูม\",\n                \"description\": \"กดปุ่ม shift ค้างไว้เพื่อใช้สลับใช้งานอีกโหมดชั่วคราว\"\n            },\n            \"display_mode\": {\n                \"label\": \"โหมดแสดงภาพ\",\n                \"fit_to_screen\": \"ขนาดพอดีขนาดจอ\",\n                \"original\": \"ขนาดตามจริง\",\n                \"fit_horizontally\": \"ขนาดพอดีความกว้างจอ\"\n            },\n            \"options\": \"ตัวเลือก\",\n            \"page_header\": \"หน้าที่ {page} / {total}\",\n            \"reset_zoom_on_nav\": \"รีเซ็ตระดับการซูมเมื่อเปลี่ยนภาพ\",\n            \"scale_up\": {\n                \"description\": \"ขยายภาพที่มีขนาดเล็กให้พอดีหน้าจอ\",\n                \"label\": \"ขยายภาพให้พอดีจอ\"\n            }\n        },\n        \"scene_gen\": {\n            \"image_previews_tooltip\": \"สร้างภาพเคลื่อนไหวตัวอย่าง (webp) เปิดใช้งานเฉพาะเมื่อเลือกประเภทของภาพตัวอย่างสำหรับกำแพงซีน/มาร์คเกอร์เป็นแบบภาพเคลื่อนไหวเท่านั้น ใช้ทรัพยากร CPU น้อยกว่าวิดีโอตัวอย่าง แต่จะใช้เนื้อที่เพิ่มขึ้น\",\n            \"marker_image_previews_tooltip\": \"สร้างภาพเคลื่อนไหวตัวอย่าง (webp) เปิดใช้งานเฉพาะเมื่อเลือกประเภทของภาพตัวอย่างสำหรับกำแพงซีน/มาร์คเกอร์เป็นแบบภาพเคลื่อนไหวเท่านั้น ใช้ทรัพยากร CPU น้อยกว่าวิดีโอตัวอย่าง แต่จะใช้เนื้อที่เพิ่มขึ้น\",\n            \"preview_exclude_start_time_desc\": \"ไม่รวม x วินาทีแรกของวิดีโอในพรีวิว สามารถระบุค่าเป็นวินาทีหรือร้อยละของระยะเวลาทั้งหมดของซีน\",\n            \"preview_preset_desc\": \"พรีเซ็ตมีผลกับขนาด คุณภาพ และระยะเวลาในการเข้ารหัสไฟล์พรีวิว ไม่แนะนำให้ใช้พรีเซ็ตที่ช้ากว่า \\\"ช้า\\\" เพราะแทบไม่เห็นความแตกต่างในคุณภาพ\",\n            \"transcodes_tooltip\": \"วิดีโอทั้งหมดจะถูกแปลงเป็น MP4 ไว้ล่วงหน้า มีประโยชน์กับคอมพิวเตอร์ที่มี CPU ไม่แรง แต่กินพื้นที่ดิสก์มาก\",\n            \"overwrite\": \"เขียนทับไฟล์\",\n            \"clip_previews\": \"คลิปภาพตัวอย่าง\",\n            \"force_transcodes_tooltip\": \"โดยทั่วไปไฟล์จะถูก transcode เมื่อบราวเซอร์ไม่รองรับประเภทไฟล์ที่ต้องการเล่น เมื่อเปิดใช้งานตัวเลือกนี้ไฟล์ที่เล่นจะถูก transcode เสมอแม้บราวเซอร์จะรองรับ\",\n            \"image_thumbnails\": \"ภาพตัวอย่างขนาดเล็ก\",\n            \"marker_screenshots_tooltip\": \"สร้างภาพนิ่ง JPG สำหรับมาร์คเกอร์ ใช้เมื่อตั้งค่ารูปแบบการพรีวิวเป็นภาพนิ่ง\",\n            \"markers\": \"การพรีวิวมาร์คเกอร์\",\n            \"override_preview_generation_options\": \"ไม่สนใจการตั้งค่าตัวเลือกการสร้างพรีวิวของระบบ\",\n            \"preview_exclude_end_time_desc\": \"ไม่รวม x วินาทีสุดท้ายของวิดีโอในพรีวิว สามารถระบุค่าเป็นวินาทีหรือร้อยละของระยะเวลาทั้งหมดของซีน\",\n            \"preview_exclude_end_time_head\": \"ไม่รวมช่วงท้ายวิดีโอ\",\n            \"preview_options\": \"ตัวเลือกพรีวิว\",\n            \"transcodes\": \"แปลงไฟล์\",\n            \"preview_seg_duration_desc\": \"ระยะเวลาของแต่ละตอนในพรีวิว หน่วยเป็นวินาที\",\n            \"preview_seg_duration_head\": \"ระยะเวลาของตอน\",\n            \"sprites\": \"ภาพนิ่งในแถบกรอวิดีโอ\",\n            \"phash\": \"Perceptual hashes\",\n            \"preview_exclude_start_time_head\": \"ไม่รวมช่วงต้นวิดีโอ\",\n            \"covers\": \"หน้าปกซีน\",\n            \"image_previews\": \"ภาพเคลื่อนไหวตัวอย่าง\",\n            \"interactive_heatmap_speed\": \"สร้าง heatmaps และกราฟความเร็วสำหรับซีนอินเตอร์แอ็คทีฟ\",\n            \"marker_image_previews\": \"สร้างภาพเคลื่อนไหวตัวอย่างสำหรับมาร์คเกอร์\",\n            \"marker_screenshots\": \"ภาพหน้าจอมาร์คเกอร์\",\n            \"override_preview_generation_options_desc\": \"ไม่สนใจการตั้งค่าตัวเลือกการสร้างพรีวิวของระบบ เปลี่ยนค่าปริยายที่หน้า ระบบ -> การสร้างพริวิว\",\n            \"preview_generation_options\": \"ตัวเลือกการสร้างพรีวิว\",\n            \"preview_preset_head\": \"พรีเซ็ตการเข้ารหัสพรีวิว\",\n            \"preview_seg_count_desc\": \"จำนวนตอนในไฟล์พรีวิว\",\n            \"preview_seg_count_head\": \"จำนวนตอนในไฟล์พรีวิว\",\n            \"sprites_tooltip\": \"กลุ่มภาพนิ่งที่ใช้แสดงในแถบกรอวิดีโอเพื่อช่วยในการกรอ\",\n            \"video_previews\": \"พรีวิว\",\n            \"video_previews_tooltip\": \"วิดีโอพรีวิวที่จะเล่นเมื่อวางเคอร์เซอร์บนซีน\",\n            \"force_transcodes\": \"บังคับให้ transcode\",\n            \"markers_tooltip\": \"วิดีโอตัวอย่างยาว 20 วินาที\",\n            \"phash_tooltip\": \"ช่วยค้นหาไฟล์ซ้ำและการจำแนกซีน\"\n        },\n        \"scrape_entity_query\": \"คำค้นหาเพื่อ scrape {entity_type}\",\n        \"dont_show_until_updated\": \"ไม่แสดงผลอีกจนถึงอัปเดตถัดไป\",\n        \"merge\": {\n            \"destination\": \"ปลายทาง\",\n            \"empty_results\": \"ค่าปลายทางจะไม่มีการเปลี่ยนแปลง\",\n            \"source\": \"ต้นทาง\"\n        },\n        \"delete_alert\": \"ไฟล์{count, plural, one {{singularEntity}} other {{pluralEntity}}}ต่อไปนี้จะถูกลบอย่างถาวร:\",\n        \"delete_entity_desc\": \"{count, plural, one {คุณแน่ใจว่าต้องการลบ{singularEntity}ใช่หรือไม่? หากไม่เลือกให้ลบไฟล์ทิ้งด้วย {singularEntity}เหล่านี้จะถูกเพิ่มกลับเข้ามาใหม่เมื่อทำการสแกนอีกครั้ง} other {คุณแน่ใจว่าต้องการลบ{pluralEntity}ใช่หรือไม่? หากไม่เลือกให้ลบไฟล์ทิ้งด้วย {pluralEntity} เหล่านี้จะถูกเพิ่มกลับเข้ามาใหม่เมื่อทำการสแกนอีกครั้ง}}\",\n        \"delete_confirm\": \"คุณแน่ใจว่าต้องการลบ{entityName}ใช่หรือไม่?\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {คุณแน่ใจว่าต้องการลบ{singularEntity}ใช่หรือไม่?} other {คุณแน่ใจว่าต้องการลบ{pluralEntity}ใช่หรือไม่?}}\",\n        \"delete_entity_title\": \"{count, plural, one {ลบ{singularEntity}} other {ลบ{pluralEntity}}}\",\n        \"export_include_related_objects\": \"รวมไฟล์อื่นๆ ที่เกี่ยวข้องในการส่งออกข้อมูลด้วย\",\n        \"export_title\": \"ส่งออกข้อมูล\",\n        \"imagewall\": {\n            \"direction\": {\n                \"description\": \"ทิศทางเลย์เอาท์ทางตั้งหรือทางนอน\",\n                \"column\": \"ทางตั้ง\",\n                \"row\": \"ทางนอน\"\n            },\n            \"margin_desc\": \"ขนาดพื้นที่ขอบรอบรูปภาพ\"\n        },\n        \"delete_object_desc\": \"คุณแน่ใจว่าต้องการลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}นี้ใช่หรือไม่?\",\n        \"delete_object_title\": \"ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"create_new_entity\": \"สร้าง {entity} ใหม่\",\n        \"delete_galleries_extra\": \"…รวมถึงไฟล์ภาพต่างๆ ที่ไม่เกี่ยวข้องกับแกลเลอรีอื่นๆ ด้วย\",\n        \"delete_gallery_files\": \"ลบโฟลเดอร์แกลเลอรี/ไฟล์ซิปและรูปภาพอื่นๆ ที่ไม่เกี่ยวข้องกับแกลเลอรีอื่นๆ\",\n        \"delete_object_overflow\": \"…และอีก {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"ย้ายไปที่\"\n        },\n        \"scenes_found\": \"พบซีน {count} ซีน\",\n        \"scrape_entity_title\": \"ผลการค้นหาการ scape {entity_type}\",\n        \"scrape_results_existing\": \"ข้อมูลที่มีอยู่แล้ว\",\n        \"scrape_results_scraped\": \"ข้อมูลที่พบ\",\n        \"set_image_url_title\": \"URL ภาพ\",\n        \"unsaved_changes\": \"ยังไม่ได้บันทึกการเปลี่ยนแปลง คุณแน่ใจว่าต้องการออกจากหน้านี้ใช่หรือไม่?\",\n        \"performers_found\": \"พบนักแสดง {count} คน\",\n        \"reassign_entity_title\": \"{count, plural, one {ย้าย{singularEntity}} other {ย้าย{pluralEntity}}}\"\n    },\n    \"cover_image\": \"ภาพหน้าปก\",\n    \"dimensions\": \"ขนาด\",\n    \"director\": \"ผู้กำกับ\",\n    \"dupe_check\": {\n        \"duration_diff\": \"ความต่างของระยะเวลาวิดีโอสูงสุด\",\n        \"duration_options\": {\n            \"equal\": \"ระยะเวลาต้องเท่ากัน\",\n            \"any\": \"เท่าไหร่ก็ได้\"\n        },\n        \"only_select_matching_codecs\": \"ทำการเลือกถ้าโคเด็คของทุกไฟล์ในกลุ่มตรงกัน\",\n        \"select_none\": \"ไม่เลือกไฟล์ใดๆ\",\n        \"select_all_but_largest_resolution\": \"เลือกทุกไฟล์ยกเว้นไฟล์ที่มีความละเอียดสูงสุด\",\n        \"options\": {\n            \"low\": \"ต่ำ\",\n            \"medium\": \"ปานกลาง\",\n            \"exact\": \"แม่นยำ\",\n            \"high\": \"สูง\"\n        },\n        \"title\": \"ซีนที่ซ้ำ\",\n        \"description\": \"ตัวเลือกที่ต่ำกว่า \\\"แม่นยำ\\\" จะใช้เวลาในการประมวลผลมากขึ้น และจะได้ผลลัพธ์ที่ไม่ตรงมากขึ้นเช่นกัน\",\n        \"found_sets\": \"{setCount, plural, one{พบไฟล์ซ้ำ # กลุ่ม} other {พบไฟล์ซ้ำ # กลุ่ม}}\",\n        \"search_accuracy_label\": \"ความแม่นยำในการค้นหา\",\n        \"select_all_but_largest_file\": \"เลือกทุกไฟล์ยกเว้นไฟล์ที่มีขนาดใหญ่สุด\",\n        \"select_oldest\": \"เลือกไฟล์ที่เก่าที่สุด\",\n        \"select_options\": \"ตัวเลือกการเลือกไฟล์…\",\n        \"select_youngest\": \"เลือกไฟล์ที่ใหม่ที่สุด\"\n    },\n    \"effect_filters\": {\n        \"rotate_right_and_scale\": \"หมุนขวาและปรับขนาด\",\n        \"blur\": \"เบลอ\",\n        \"brightness\": \"ความสว่าง\",\n        \"contrast\": \"คอนทราสต์\",\n        \"aspect\": \"ขนาดภาพ\",\n        \"saturation\": \"ความอิ่มสี\",\n        \"scale\": \"ขนาด\",\n        \"hue\": \"เนื้อสี\",\n        \"name\": \"ฟิลเตอร์\",\n        \"name_transforms\": \"ปรับขนาด\",\n        \"rotate_left_and_scale\": \"หมุนซ้ายและปรับขนาด\",\n        \"gamma\": \"แกมมา\",\n        \"green\": \"เขียว\",\n        \"red\": \"แดง\",\n        \"reset_filters\": \"รีเซ็ตฟิลเตอร์\",\n        \"reset_transforms\": \"รีเซ็ตการปรับขนาด\",\n        \"warmth\": \"โทนอุ่น\",\n        \"blue\": \"น้ำเงิน\",\n        \"rotate\": \"หมุน\"\n    },\n    \"empty_server\": \"เพิ่มเนื้อหาเข้าสู่เซิร์ฟเวอร์เพื่อแสดงผลไฟล์แนะนำในหน้านี้\",\n    \"existing_value\": \"ค่าที่มีอยู่\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"สารบัญภาพต้องมากกว่า 0\",\n        \"something_went_wrong\": \"มีบางอย่างผิดพลาด\",\n        \"lazy_component_error_help\": \"หากคุณอัปเดต Stash เมื่อไม่นานมานี้ กรุณารีโหลดหน้านี้หรือล้างแคชของบราวเซอร์\",\n        \"header\": \"ข้อผิดพลาด\",\n        \"loading_type\": \"พบข้อผิดพลาดในการโหลด{type}\"\n    },\n    \"eye_color\": \"สีตา\",\n    \"fake_tits\": \"หน้าอก\",\n    \"false\": \"ปลอม\",\n    \"favourite\": \"ชอบ\",\n    \"file_count\": \"จำนวนไฟล์\",\n    \"gender\": \"เพศสภาพ\",\n    \"gender_types\": {\n        \"MALE\": \"ชาย\",\n        \"INTERSEX\": \"กำกวม\",\n        \"TRANSGENDER_FEMALE\": \"หญิงข้ามเพศ\",\n        \"FEMALE\": \"หญิง\",\n        \"NON_BINARY\": \"นอน-ไบนารี\",\n        \"TRANSGENDER_MALE\": \"ชายข้ามเพศ\"\n    },\n    \"images\": \"รูปภาพ\",\n    \"filesize\": \"ขนาดไฟล์\",\n    \"markers\": \"มาร์คเกอร์\",\n    \"marker_count\": \"จำนวนมาร์คเกอร์\",\n    \"media_info\": {\n        \"audio_codec\": \"โคเด็คเสียง\",\n        \"phash\": \"PHash\",\n        \"play_count\": \"จำนวนครั้งที่เล่น\",\n        \"o_count\": \"จำนวน O\",\n        \"performer_card\": {\n            \"age\": \"อายุ {age} {years_old}\",\n            \"age_context\": \"อายุ {age} {years_old} ในเรื่องนี้\"\n        },\n        \"downloaded_from\": \"ดาวน์โหลดมาจาก\",\n        \"play_duration\": \"ระยะเวลาที่เล่น\",\n        \"stream\": \"สตรีม\",\n        \"video_codec\": \"โคเด็ควิดีโอ\",\n        \"interactive_speed\": \"ความเร็วอินเตอร์แอ็คทีฟ\"\n    },\n    \"package_manager\": {\n        \"add_source\": \"เพิ่มแหล่งข้อมูล\",\n        \"source\": {\n            \"url\": \"URL แหล่งข้อมูล\",\n            \"name\": \"ชื่อเรื่อง\",\n            \"local_path\": {\n                \"description\": \"ตำแหน่งไฟล์แบบ relative เพื่อจัดเก็บแพ็กเกจของแหล่งข้อมูลนี้ หากเปลี่ยนแปลงค่าต้องทำการย้ายไฟล์แพ็กเกจด้วยตนเอง\",\n                \"heading\": \"ตำแหน่งในเครื่องคอมพิวเตอร์\"\n            }\n        },\n        \"edit_source\": \"แก้ไขแหล่งข้อมูล\",\n        \"install\": \"ติดตั้ง\",\n        \"confirm_uninstall\": \"คุณแน่ใจหรือไม่ว่าต้องการยกเลิกการติดตั้งแพ็กเกจ {number} แพ็กเกจ?\",\n        \"description\": \"คำอธิบาย\",\n        \"show_all\": \"แสดงทั้งหมด\",\n        \"uninstall\": \"ถอนการติดตั้ง\",\n        \"unknown\": \"<unknown>\",\n        \"no_upgradable\": \"ไม่มีแพ็กเกจที่ต้องอัปเดต\",\n        \"required_by\": \"ถูกใช้งานโดย {packages}\",\n        \"selected_only\": \"เฉพาะที่เลือกไว้\",\n        \"update\": \"อัปเดต\",\n        \"check_for_updates\": \"ตรวจสอบอัปเดต\",\n        \"confirm_delete_source\": \"คุณแน่ใจว่าต้องการลบแหล่งข้อมูล {name} ({url}) ใช่หรือไม่?\",\n        \"hide_unselected\": \"ซ่อนรายการที่ไม่ได้เลือก\",\n        \"installed_version\": \"เวอร์ชันที่ติดตั้ง\",\n        \"latest_version\": \"เวอร์ชันล่าสุด\",\n        \"no_packages\": \"ไม่พบแพ็กเกจ\",\n        \"no_sources\": \"ไม่ได้ตั้งค่าแหล่งข้อมูล\",\n        \"package\": \"แพ็กเกจ\",\n        \"version\": \"เวอร์ชัน\"\n    },\n    \"perceptual_similarity\": \"Perceptual Similarity (pHash)\",\n    \"performer_age\": \"อายุ\",\n    \"performer_image\": \"รูปถ่ายนักแสดง\",\n    \"configuration\": \"ปรับแต่งการตั้งค่า\",\n    \"developmentVersion\": \"เวอร์ชันของการพัฒนา\",\n    \"file_info\": \"ข้อมูลไฟล์\",\n    \"file_mod_time\": \"เวลาแก้ไขไฟล์ล่าสุด\",\n    \"front_page\": {\n        \"types\": {\n            \"saved_filter\": \"ฟิลเตอร์ที่บันทึกไว้\",\n            \"premade_filter\": \"ฟิลเตอร์สำเร็จรูป\"\n        }\n    },\n    \"help\": \"ความช่วยเหลือ\",\n    \"last_played_at\": \"เล่นครั้งสุดท้ายเมื่อ\",\n    \"measurements\": \"สัดส่วน\",\n    \"folder\": \"โฟลเดอร์\",\n    \"framerate\": \"อัตราเฟรม\",\n    \"frames_per_second\": \"{value} เฟรมต่อวินาที\",\n    \"penis_length_cm\": \"ความยาวองคชาต (ซม.)\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"ไม่สามารถเชื่อมต่อ websocket ได้ ตรวจสอบรายละเอียดเพิ่มเติมที่คอนโซลของบราวเซอร์\",\n        \"websocket_connection_reestablished\": \"เชื่อมต่อ websocket สำเร็จ\"\n    },\n    \"created_at\": \"เวลาที่สร้าง\",\n    \"criterion\": {\n        \"greater_than\": \"มากกว่า\",\n        \"less_than\": \"น้อยกว่า\",\n        \"value\": \"ข้อความ\"\n    },\n    \"custom\": \"กำหนดเอง\",\n    \"date\": \"วันที่\",\n    \"date_format\": \"YYYY-MM-DD\",\n    \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n    \"death_date\": \"วันที่เสียชีวิต\",\n    \"death_year\": \"ปีที่เสียชีวิต\",\n    \"descending\": \"เรียกจากมากไปน้อย\",\n    \"description\": \"คำอธิบาย\",\n    \"detail\": \"รายละเอียด\",\n    \"details\": \"รายละเอียด\",\n    \"display_mode\": {\n        \"unknown\": \"ไม่มีข้อมูล\",\n        \"grid\": \"กริด\",\n        \"list\": \"รายการ\",\n        \"tagger\": \"เครื่องมือแท็ก\",\n        \"wall\": \"กำแพง\"\n    },\n    \"duplicated_phash\": \"ไฟล์ที่ซ้ำ (pHash)\",\n    \"filter_name\": \"ชื่อฟิลเตอร์\",\n    \"filters\": \"ฟิลเตอร์\",\n    \"height_cm\": \"ความสูง (ซม.)\",\n    \"history\": \"ประวัติ\",\n    \"image\": \"รูปภาพ\",\n    \"image_count\": \"จำนวนรูปภาพ\",\n    \"image_index\": \"รูปภาพที่ #\",\n    \"megabits_per_second\": \"{value} mbps\",\n    \"o_count\": \"จำนวน O\",\n    \"orientation\": \"ทิศทาง\",\n    \"parent_of\": \"บริษัทแม่ของ {children}\",\n    \"pagination\": {\n        \"previous\": \"ก่อนหน้า\",\n        \"first\": \"หน้าแรก\",\n        \"last\": \"หน้าสุดท้าย\",\n        \"next\": \"ถัดไป\"\n    },\n    \"gallery\": \"แกลเลอรี\",\n    \"gallery_count\": \"จำนวนแกลเลอรี\",\n    \"handy_connection_status\": {\n        \"missing\": \"ไม่พบอุปกรณ์\",\n        \"connecting\": \"กำลังเชื่อมต่อ\",\n        \"disconnected\": \"ตัดการเชื่อมต่อ\",\n        \"error\": \"พบข้อผิดพลาดในการติดต่อ Handy\",\n        \"ready\": \"พร้อมใช้งาน\",\n        \"syncing\": \"กำลังซิงค์กับเซิร์ฟเวอร์\",\n        \"uploading\": \"กำลังอัปโหลดสคริปท์\"\n    },\n    \"index_of_total\": \"รายการที่ {index} จาก {total}\",\n    \"galleries\": \"แกลเลอรี\",\n    \"interactive_speed\": \"ความเร็วอินเตอร์แอ็คทีฟ\",\n    \"include_sub_studios\": \"นับรวมสตูดิโอลูกด้วย\",\n    \"isMissing\": \"ที่ไม่มี\",\n    \"include_sub_tags\": \"นับรวมแท็กย่อยด้วย\",\n    \"last_o_at\": \"O ครั้งสุดท้ายเมื่อ\",\n    \"instagram\": \"อินสตาแกรม\",\n    \"interactive\": \"อินเตอร์แอ็คทีฟ\",\n    \"performer\": \"นักแสดง\",\n    \"name\": \"ชื่อเรื่อง\",\n    \"new\": \"เพิ่ม\",\n    \"none\": \"ไม่มี\",\n    \"o_history\": \"ประวัติ O\",\n    \"organized\": \"จัดระเบียบแล้ว\",\n    \"disambiguation\": \"แก้ความกำกวม\",\n    \"distance\": \"ระยะทาง\",\n    \"duration\": \"ความยาว\",\n    \"ethnicity\": \"เชื้อชาติ\",\n    \"file\": \"ไฟล์\",\n    \"files\": \"ไฟล์\",\n    \"files_amount\": \"{value} ไฟล์\",\n    \"filter\": \"ฟิลเตอร์\",\n    \"hair_color\": \"สีผม\",\n    \"hasChapters\": \"มีฉาก\",\n    \"hasMarkers\": \"มีมาร์คเกอร์\",\n    \"height\": \"ความสูง\",\n    \"ignore_auto_tag\": \"ไม่สนใจการแท็กอัตโนมัติ\",\n    \"include_parent_tags\": \"นับรวมแท็กหลักด้วย\",\n    \"loading\": {\n        \"generic\": \"กำลังโหลด…\"\n    },\n    \"odate_recorded_no\": \"ไม่มีประวัติ O\",\n    \"parent_tag_count\": \"จำนวนแท็กย่อย\",\n    \"parent_tags\": \"แท็กหลัก\",\n    \"part_of\": \"เป็นส่วนหนึ่งของ {parent}\",\n    \"path\": \"ตำแหน่ง\",\n    \"penis\": \"องคชาต\",\n    \"penis_length\": \"ความยาวองคชาต\",\n    \"performer_count\": \"จำนวนนักแสดง\",\n    \"donate\": \"บริจาค\",\n    \"search_filter\": {\n        \"update_filter\": \"อัปเดตฟิลเตอร์\",\n        \"name\": \"ฟิลเตอร์ค้นหา\",\n        \"saved_filters\": \"ฟิลเตอร์ที่บันทึกไว้\",\n        \"edit_filter\": \"แก้ไขฟิลเตอร์ค้นหา\"\n    },\n    \"seconds\": \"วินาที\",\n    \"piercings\": \"การเจาะ\",\n    \"play_count\": \"จำนวนครั้งที่เล่น\",\n    \"play_duration\": \"รายะเวลาที่เล่น\",\n    \"play_history\": \"ประวัติการเล่น\",\n    \"scene_created_at\": \"เวลาที่ถูกสร้าง\",\n    \"resume_time\": \"เวลาที่จะเริ่มเล่น\",\n    \"rating\": \"คะแนน\",\n    \"recently_added_objects\": \"{objects} ที่เพิ่งถูกเพิ่ม\",\n    \"random\": \"สุ่ม\",\n    \"scene\": \"ซีน\",\n    \"scenes\": \"ซีน\",\n    \"second\": \"วินาที\",\n    \"settings\": \"การตั้งค่า\",\n    \"recently_released_objects\": \"{objects} ที่เพิ่งวางขาย\",\n    \"release_notes\": \"Release Notes\",\n    \"sceneTagger\": \"เครื่องมือเพิ่มข้อมูลซีน\",\n    \"scene_tags\": \"แท็กของซีน\",\n    \"performer_favorite\": \"ชอบใจ\",\n    \"performer_tags\": \"แท็กของนักแสดง\",\n    \"performers\": \"นักแสดง\",\n    \"photographer\": \"ช่างภาพ\",\n    \"playdate_recorded_no\": \"ไม่พบบันทึกการเล่น\",\n    \"plays\": \"เล่นแล้ว {value} ครั้ง\",\n    \"primary_file\": \"ไฟล์หลัก\",\n    \"primary_tag\": \"แท็กหลัก\",\n    \"queue\": \"คิว\",\n    \"resolution\": \"ความละเอียด\",\n    \"scene_code\": \"รหัสไฟล์\",\n    \"scene_count\": \"จำนวนซีน\",\n    \"scene_id\": \"รหัสซีน\",\n    \"scene_updated_at\": \"วันที่อัปเดตซีน\",\n    \"scenes_updated_at\": \"วันที่อัปเดตซีน\",\n    \"scene_date\": \"วันที่ปล่อยซีน\",\n    \"stash_ids\": \"Stash ID\",\n    \"stash_id_endpoint\": \"Stash ID Endpoint\",\n    \"stats\": {\n        \"total_play_duration\": \"ระยะเวลาที่เล่นรวม\",\n        \"total_o_count\": \"จำนวน O-Count ทั้งหมด\",\n        \"total_play_count\": \"จำนวนครั้งที่เล่นรวม\",\n        \"scenes_played\": \"จำนวนซีนที่เล่นแล้ว\",\n        \"image_size\": \"ขนาดรูปภาพ\",\n        \"scenes_duration\": \"ระยะเวลารวมทุกซีน\",\n        \"scenes_size\": \"ขนาดซีนทั้งหมด\"\n    },\n    \"studio\": \"สตูดิโอ\",\n    \"studios\": \"สตูดิโอ\",\n    \"true\": \"จริง\",\n    \"twitter\": \"ทวิตเตอร์\",\n    \"video_codec\": \"โคเด็ควิดีโอ\",\n    \"weight_kg\": \"น้ำหนัก (กก.)\",\n    \"years_old\": \"ปี\",\n    \"zip_file_count\": \"จำนวนไฟล์ซิป\",\n    \"countables\": {\n        \"images\": \"{count, plural, one {รูปภาพ} other {รูปภาพ}}\",\n        \"scenes\": \"{count, plural, one {ซีน} other {ซีน}}\",\n        \"studios\": \"{count, plural, one {สตูดิโอ} other {สตูดิโอ}}\",\n        \"tags\": \"{count, plural, one {แท็ก} other {แท็ก}}\",\n        \"files\": \"{count, plural, one {ไฟล์} other {ไฟล์}}\",\n        \"galleries\": \"{count, plural, one {แกลเลอรี} other {แกลเลอรี}}\",\n        \"markers\": \"{count, plural, one {มาร์คเกอร์} other {มาร์คเกอร์}}\",\n        \"performers\": \"{count, plural, one {นักแสดง} other {นักแสดง}}\"\n    },\n    \"sub_tags\": \"แท็กย่อย\",\n    \"synopsis\": \"เรื่องย่อ\",\n    \"tag\": \"แท็ก\",\n    \"toast\": {\n        \"merged_scenes\": \"รวมซีนแล้ว\",\n        \"delete_past_tense\": \"ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว\",\n        \"added_generation_job_to_queue\": \"เพิ่มงานสร้างไฟล์เพิ่มเติมในคิวแล้ว\",\n        \"generating_screenshot\": \"กำลังสร้างภาพหน้าจอ…\",\n        \"rescanning_entity\": \"กำลังสแกน{count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"started_auto_tagging\": \"เริ่มงานเพิ่มข้อมูลอัตโนมัติแล้ว\",\n        \"started_importing\": \"เริ่มการนำเข้าข้อมูลแล้ว\",\n        \"added_entity\": \"เพิ่ม{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว\",\n        \"created_entity\": \"สร้าง{entity}แล้ว\",\n        \"default_filter_set\": \"ชุดฟิลเตอร์พื้นฐาน\",\n        \"image_index_too_large\": \"พบความผิดพลาด: index รูปภาพมีขนาดใหญ่กว่าจำนวนรูปภาพในแกลเลอรี\",\n        \"merged_tags\": \"รวมแท็กแล้ว\",\n        \"reassign_past_tense\": \"ย้ายไฟล์แล้ว\",\n        \"removed_entity\": \"ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว\",\n        \"saved_entity\": \"บันทึก{entity}แล้ว\",\n        \"started_generating\": \"เริ่มสร้างไฟล์เพิ่มเติมแล้ว\",\n        \"updated_entity\": \"อัปเดต{entity}แล้ว\"\n    },\n    \"stashbox\": {\n        \"submit_update\": \"มีอยู่แล้วที่ {endpoint_name}\",\n        \"go_review_draft\": \"ไปที่ {endpoint_name} เพื่อตรวจสอบการตั้งค่า\",\n        \"selected_stash_box\": \"Stash-Box endpoint ที่เลือกไว้\",\n        \"source\": \"แหล่งข้อมูล Stash-Box\",\n        \"submission_failed\": \"การส่งข้อมูลไม่สำเร็จ\",\n        \"submission_successful\": \"การส่งข้อมูลสำเร็จ\"\n    },\n    \"studio_depth\": \"ระดับ (เว้นว่างเพื่อนับทั้งหมด)\",\n    \"type\": \"ประเภท\",\n    \"url\": \"URL\",\n    \"urls\": \"URLs\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} ต้องอยู่ในรูปแบบ YYYY-MM-DD\",\n        \"required\": \"จำเป็นต้องระบุ ${path}\",\n        \"blank\": \"${path} ต้องไม่เว้นว่างไว้\",\n        \"unique\": \"${path} ต้องไม่ซ้ำ\"\n    },\n    \"operations\": \"ปฏิบัติการ\",\n    \"statistics\": \"สถิติ\",\n    \"status\": \"สถานะ: {statusText}\",\n    \"subsidiary_studio_count\": \"จำนวนสตูดิโอย่อย\",\n    \"subsidiary_studios\": \"สตูดิโอย่อย\",\n    \"tag_count\": \"จำนวนแท็ก\",\n    \"tag_parent_tooltip\": \"มีแท็กหลัก\",\n    \"sub_tag_count\": \"จำนวนแท็กย่อย\",\n    \"sub_tag_of\": \"เป็นแท็กย่อยของ {parent}\",\n    \"tag_sub_tag_tooltip\": \"มีแท็กย่อย\",\n    \"tags\": \"แท็ก\",\n    \"tattoos\": \"รอยสัก\",\n    \"time\": \"เวลา\",\n    \"title\": \"ชื่อเรื่อง\",\n    \"updated_at\": \"อัปเดตเมื่อ\",\n    \"total\": \"ทั้งหมด\",\n    \"unknown_date\": \"ไม่ระบุวันที่\",\n    \"view_all\": \"ดูทั้งหมด\",\n    \"weight\": \"น้ำหนัก\",\n    \"videos\": \"วิดีโอ\",\n    \"stash_id\": \"Stash ID\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/tr-TR.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Ekle\",\n        \"add_directory\": \"Dizin Ekle\",\n        \"add_entity\": \"{entityType} Ekle\",\n        \"add_to_entity\": \"Buraya Ekle: {entityType}\",\n        \"allow\": \"İzin Ver\",\n        \"allow_temporarily\": \"Geçici olarak izin ver\",\n        \"apply\": \"Uygula\",\n        \"auto_tag\": \"Otomatik Etiket\",\n        \"backup\": \"Yedekle\",\n        \"browse_for_image\": \"Resim için gözat…\",\n        \"cancel\": \"İptal\",\n        \"clean\": \"Temizle\",\n        \"clear\": \"Temizle\",\n        \"clear_back_image\": \"Arka kapak resmini kaldır\",\n        \"clear_front_image\": \"Ön kapak resmini kaldır\",\n        \"clear_image\": \"Resmi Kaldır\",\n        \"close\": \"Kapat\",\n        \"confirm\": \"Onayla\",\n        \"continue\": \"Devam et\",\n        \"create\": \"Oluştur\",\n        \"create_entity\": \"{entityType} Oluştur\",\n        \"create_marker\": \"Yer İmi Oluştur\",\n        \"created_entity\": \"{entity_type}: {entity_name} oluşturuldu\",\n        \"delete\": \"Sil\",\n        \"delete_entity\": \"{entityType} Sil\",\n        \"delete_file\": \"Dosyayı sil\",\n        \"delete_generated_supporting_files\": \"Oluşturulan ek dosyaları sil\",\n        \"disallow\": \"İzin verme\",\n        \"download\": \"İndir\",\n        \"download_backup\": \"Yedekleme Dosyasını İndir\",\n        \"edit\": \"Düzenle\",\n        \"export\": \"Dışa Aktar\",\n        \"export_all\": \"Tümünü dışa aktar…\",\n        \"find\": \"Bul\",\n        \"finish\": \"Bitir\",\n        \"from_file\": \"Dosyadan…\",\n        \"from_url\": \"URL'den…\",\n        \"full_export\": \"Tam Dışa Aktarım\",\n        \"full_import\": \"Tam İçe Aktarma\",\n        \"generate\": \"Oluştur\",\n        \"generate_thumb_default\": \"Varsayılan küçük resim oluştur\",\n        \"generate_thumb_from_current\": \"Mevcut görüntüden küçük resim oluştur\",\n        \"hash_migration\": \"hash taşıma\",\n        \"hide\": \"Gizle\",\n        \"identify\": \"Tanımla\",\n        \"ignore\": \"Yok Say\",\n        \"import\": \"İçe Aktar…\",\n        \"import_from_file\": \"Dosyadan içe aktar\",\n        \"merge\": \"Birleştir\",\n        \"next_action\": \"Sonraki\",\n        \"not_running\": \"çalışmıyor\",\n        \"open_random\": \"Rastgele Aç\",\n        \"overwrite\": \"Üzerine Yaz\",\n        \"play_random\": \"Rastgele Oynat\",\n        \"play_selected\": \"Seçileni oynat\",\n        \"preview\": \"Önizleme\",\n        \"previous_action\": \"Geri\",\n        \"refresh\": \"Yenile\",\n        \"reload_plugins\": \"Eklentileri yeniden yükle\",\n        \"reload_scrapers\": \"Veri toplayıcıları yeniden yükle\",\n        \"remove\": \"Kaldır\",\n        \"rename_gen_files\": \"Oluşturulan dosyaları yeniden adlandır\",\n        \"rescan\": \"Yeniden tara\",\n        \"reshuffle\": \"Tekrar karıştır\",\n        \"running\": \"çalışıyor\",\n        \"save\": \"Kaydet\",\n        \"save_delete_settings\": \"Artık silerken bu seçenekleri kullan\",\n        \"save_filter\": \"Filtreyi kaydet\",\n        \"scan\": \"Tara\",\n        \"scrape\": \"Veri Topla\",\n        \"scrape_query\": \"Veri Toplama sorgusu\",\n        \"scrape_scene_fragment\": \"Parçalara göre veri topla\",\n        \"scrape_with\": \"Bununla Veri Topla…\",\n        \"search\": \"Ara\",\n        \"select_all\": \"Tümünü Seç\",\n        \"select_folders\": \"Dizinleri seç\",\n        \"select_none\": \"Hiçbirini Seçme\",\n        \"selective_auto_tag\": \"Seçerek Otomatik Etiketle\",\n        \"selective_clean\": \"Seçerek Temizle\",\n        \"selective_scan\": \"Seçerek Tara\",\n        \"set_as_default\": \"Varsayılan olarak ayarla\",\n        \"set_back_image\": \"Arka kapak resmi…\",\n        \"set_front_image\": \"Ön kapak resmi…\",\n        \"set_image\": \"Resim seç…\",\n        \"show\": \"Göster\",\n        \"skip\": \"Geç\",\n        \"stop\": \"Dur\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"Temizlemek istediğinize emin misiniz? Dosya sisteminde bulunamayan tüm içerikler için veritabanı bilgileri ve oluşturulan verilerin tamamı temizlenecek.\",\n            \"dry_mode_selected\": \"Deneme Modu aktif. Silme işlemi yapılmayacak, sadece ne yapılacağı gösterilecek.\",\n            \"import_warning\": \"İçe aktarmak istediğinize emin misiniz? Bu, veritabanını silecek ve dışa aktarılan üstverilerinizden yeniden içe aktaracaktır.\"\n        },\n        \"temp_disable\": \"Geçici olarak devre dışı bırak…\",\n        \"temp_enable\": \"Geçici olarak etkinleştir…\",\n        \"use_default\": \"Varsayılanı kullan\",\n        \"view_random\": \"Rastgele Göster\",\n        \"reset_cover\": \"Varsayılan Kapağı Geri Yükle\",\n        \"reset_play_duration\": \"Oynatma süresini sıfırla\",\n        \"add_sub_groups\": \"Alt Grup Ekle\",\n        \"copy_to_clipboard\": \"Panoya kopyala\",\n        \"enable\": \"Etkinleştir\",\n        \"create_chapters\": \"Bölüm Oluştur\",\n        \"edit_entity\": \"Düzenle {entityType}\",\n        \"remove_date\": \"Tarihi kaldır\",\n        \"reassign\": \"Yeniden ata\",\n        \"remove_from_gallery\": \"Galeriden Kaldır\",\n        \"select_entity\": \"Seç {entityType}\",\n        \"set_cover\": \"Kapak Olarak Ayarla\",\n        \"show_configuration\": \"Yapılandırmayı Göster\",\n        \"submit\": \"Gönder\",\n        \"submit_stash_box\": \"Stash-Box'a Gönder\",\n        \"submit_update\": \"Güncellemeyi gönder\",\n        \"view_history\": \"Geçmişi görüntüle\",\n        \"anonymise\": \"Anonimleştir\",\n        \"customise\": \"Özelleştir\",\n        \"delete_file_and_funscript\": \"Dosyayı sil (funscript dahil)\",\n        \"disable\": \"Devre dışı bırak\",\n        \"hide_configuration\": \"Yapılandırmayı Gizle\",\n        \"logout\": \"Çıkış yap\",\n        \"make_primary\": \"Birincil Yap\",\n        \"choose_date\": \"Bir tarih seç\",\n        \"reload\": \"Yeniden yükle\",\n        \"remove_from_containing_group\": \"Gruptan Kaldır\",\n        \"clean_generated\": \"Oluşturulan dosyaları temizle\",\n        \"open_in_external_player\": \"Harici oynatıcıda aç\",\n        \"clear_date_data\": \"Tarih verisini temizle\",\n        \"optimise_database\": \"Veritabanını Optimize Et\",\n        \"create_parent_studio\": \"Ana stüdyo oluştur\",\n        \"migrate_scene_screenshots\": \"Sahne Ekran Görüntülerini Aktar\",\n        \"add_o\": \"O Ekle\",\n        \"add_play\": \"Oynatma ekle\",\n        \"migrate_blobs\": \"Blob'ları Aktar\",\n        \"assign_stashid_to_parent_studio\": \"Mevcut ana stüdyoya Stash ID atayın ve üstverileri güncelleyin\",\n        \"download_anonymised\": \"Anonim olarak indir\",\n        \"add_manual_date\": \"Elle tarih ekle\",\n        \"reset_resume_time\": \"Devam etme süresini sıfırla\",\n        \"split\": \"Ayır\",\n        \"swap\": \"Değiştir\",\n        \"encoding_image\": \"Resim kodlanıyor…\",\n        \"sidebar\": {\n            \"close\": \"Kenar çubuğunu kapat\",\n            \"open\": \"Kenar çubuğunu aç\",\n            \"toggle\": \"Kenar çubuğunu aç/kapat\"\n        },\n        \"play\": \"Oynat\",\n        \"show_results\": \"Sonuçları göster\",\n        \"show_count_results\": \"{count} sonucu göster\",\n        \"load\": \"Yükle\",\n        \"load_filter\": \"Filtre yükle\"\n    },\n    \"actions_name\": \"Eylemler\",\n    \"age\": \"Yaş\",\n    \"aliases\": \"Diğer Adlar\",\n    \"all\": \"tümü\",\n    \"also_known_as\": \"Diğer adıyla\",\n    \"ascending\": \"Artan\",\n    \"average_resolution\": \"Ortalama Çözünürlük\",\n    \"birth_year\": \"Doğum Yılı\",\n    \"birthdate\": \"Doğum Tarihi\",\n    \"bitrate\": \"Bit Hızı\",\n    \"career_length\": \"Kariyer Uzunluğu\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Aktif stash-box oturumu:\",\n            \"blacklist_desc\": \"Kara listeye alınan kelimeler sorguya eklenmez. Sözkonusu kelimeler kurallı ifadelerdir (regex) ve büyük-küçük harf ayrımına duyarlı değillerdir. Belirli karakterler ters bölü işaretiyle ayrılmalıdır: {chars_require_escape}\",\n            \"blacklist_label\": \"Kara liste\",\n            \"query_mode_auto\": \"Otomatik\",\n            \"query_mode_auto_desc\": \"Varsa üstverileri veya dosya adını kullanır\",\n            \"query_mode_dir\": \"Dizin\",\n            \"query_mode_dir_desc\": \"Yalnızca video dosyasının dizinini kullanır\",\n            \"query_mode_filename\": \"Dosya adı\",\n            \"query_mode_filename_desc\": \"Sadece dosya adını kullanır\",\n            \"query_mode_label\": \"Sorgu Modu\",\n            \"query_mode_metadata\": \"Üst veri\",\n            \"query_mode_metadata_desc\": \"Sadece üstverileri kullanır\",\n            \"query_mode_path\": \"Yol\",\n            \"query_mode_path_desc\": \"Tüm dosya yolunu kullanır\",\n            \"set_cover_desc\": \"Eğer bulunursa sahne kapak resmini değiştir.\",\n            \"set_cover_label\": \"Sahne için kapak resmi seç\",\n            \"set_tag_desc\": \"Sahneye etiket ekle (varolan etiketlerle birleştir veya üzerine yaz).\",\n            \"set_tag_label\": \"Etiketleri düzenle\",\n            \"source\": \"Kaynak\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Yinelenen kara liste öğesi\"\n            }\n        },\n        \"noun_query\": \"Sorgu\",\n        \"results\": {\n            \"duration_off\": \"Süre en az {number} saniye hatalı\",\n            \"duration_unknown\": \"Süre bilinmiyor\",\n            \"fp_found\": \"{fpCount, plural, =0 {Yeni parmak izi eşleşmesi bulunamadı} other {# yeni parmak izi eşleşmesi bulundu}}\",\n            \"fp_matches\": \"Süre eşleşiyor\",\n            \"fp_matches_multi\": \"Süre {matchCount}/{durationsLength} parmak iziyle eşleşiyor\",\n            \"hash_matches\": \"{hash_type} eşleşiyor\",\n            \"match_failed_already_tagged\": \"Sahne zaten etiketlendi\",\n            \"match_failed_no_result\": \"Sonuç bulunamadı\",\n            \"match_success\": \"Sahne başarıyla etiketlendi\",\n            \"phash_matches\": \"{count} PHash eşleşiyor\",\n            \"unnamed\": \"İsimsiz\"\n        },\n        \"verb_match_fp\": \"Parmak İzlerini Eşleştir\",\n        \"verb_matched\": \"Eşleşti\",\n        \"verb_scrape_all\": \"Tümü için Veri Topla\",\n        \"verb_submit_fp\": \"{fpCount, plural, one{# Parmak izi} other{# Parmak izlerini}} gönder\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} eşleşmeyen sahneler\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"Sürüm imzası:\",\n            \"build_time\": \"Sürüm zamanı:\",\n            \"check_for_new_version\": \"Yeni sürümü kontrol et\",\n            \"latest_version\": \"Son Sürüm\",\n            \"latest_version_build_hash\": \"Son Sürüm İmzası:\",\n            \"new_version_notice\": \"[YENİ]\",\n            \"stash_discord\": \"{url} kanalımıza katılın\",\n            \"stash_home\": \"{url} üzerinde Stash sayfası\",\n            \"stash_open_collective\": \"Bizi {url} aracılığıyla destekleyin\",\n            \"stash_wiki\": \"Stash {url} sayfası\",\n            \"version\": \"Sürüm\",\n            \"release_date\": \"Yayın tarihi:\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Uygulama Yolları\"\n        },\n        \"categories\": {\n            \"about\": \"Hakkında\",\n            \"interface\": \"Arayüz\",\n            \"logs\": \"Kayıtlar\",\n            \"metadata_providers\": \"Üstveri Sağlayıcılar\",\n            \"plugins\": \"Eklentiler\",\n            \"scraping\": \"Veri Toplama\",\n            \"security\": \"Güvenlik\",\n            \"services\": \"Servisler\",\n            \"system\": \"Sistem\",\n            \"tasks\": \"Görevler\",\n            \"tools\": \"Araçlar\",\n            \"changelog\": \"Değişiklik Günlüğü\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"{tempIP} IP adresine izin ver\",\n            \"allowed_ip_addresses\": \"İzinli IP adresleri\",\n            \"default_ip_whitelist\": \"Varsayılan IP Beyaz Listesi\",\n            \"default_ip_whitelist_desc\": \"Varsayılan IP adresleri DLNA'e erişme izni verir. Tüm IP adreslerine izin vermek için {wildcard} kullanın.\",\n            \"enabled_by_default\": \"Varsayılan olarak etkin\",\n            \"network_interfaces\": \"Arayüzler\",\n            \"network_interfaces_desc\": \"DLNA sunucusunu göstermek için arayüzler. Listeyi boş bırakmak tüm arabirimler üzerinde çalışmayla sonuçlanır. Değişiklikten sonra DLNA'in yeniden başlatılması gerekir.\",\n            \"recent_ip_addresses\": \"Son IP adresleri\",\n            \"server_display_name\": \"Sunucunun Görünen Adı\",\n            \"server_display_name_desc\": \"DLNA sunucusu için kullanılacak isim. Boş ise {server_name} kullanılır.\",\n            \"until_restart\": \"yeniden başlatana kadar\",\n            \"disallowed_ip\": \"İzin verilmeyen IP\",\n            \"successfully_cancelled_temporary_behaviour\": \"Geçici davranış başarıyla iptal edildi\",\n            \"video_sort_order\": \"Varsayılan Video Sıralama Düzeni\",\n            \"server_port\": \"Sunucu Bağlantı Noktası\",\n            \"disabled_dlna_temporarily\": \"DLNA geçici olarak devre dışı bırakıldı\",\n            \"enabled_dlna_temporarily\": \"DLNA geçici olarak etkinleştirildi\",\n            \"video_sort_order_desc\": \"Videoları varsayılan olarak sıralayın.\",\n            \"server_port_desc\": \"DLNA sunucusunun çalıştırılacağı bağlantı noktası. Değiştirdikten sonra DLNA'in yeniden başlatılması gerekir.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API Anahtarı\",\n                \"api_key_desc\": \"Harici sistemler için API anahtarı. Yalnızca kullanıcı adı ya da şifre yapılandırıldığında gereklidir. API anahtarı oluşturulmadan önce kullanıcı adı kaydedilmelidir.\",\n                \"authentication\": \"Kimlik Doğrulama\",\n                \"clear_api_key\": \"API anahtarını temizle\",\n                \"credentials\": {\n                    \"description\": \"Stash erişimini kısıtlamak için giriş bilgileri.\",\n                    \"heading\": \"Giriş Bilgileri\"\n                },\n                \"generate_api_key\": \"API anahtarı oluştur\",\n                \"log_file\": \"Kayıt dosyası\",\n                \"log_file_desc\": \"Kayıtların tutulacağı dosyanın yolu. Kayıt tutmayı kapatmak için boş bırakın. Yeniden başlatma gerektirir.\",\n                \"log_http\": \"HTTP erişimini kayıt altına al\",\n                \"log_http_desc\": \"HTTP erişimini terminalde gösterir. Yeniden başlatma gerektirir.\",\n                \"log_to_terminal\": \"Terminale kaydet\",\n                \"log_to_terminal_desc\": \"Dosyanın yanı sıra terminale de kayıt gösterir. Dosyaya kayıt kapalı ise her zaman etkindir. Yeniden başlatma gerektirir.\",\n                \"maximum_session_age\": \"Maksimum Oturum Süresi\",\n                \"maximum_session_age_desc\": \"Oturumun süresi dolmadan önce maksimum boşta kalma süresi (saniye cinsinden). Yeniden başlatma gerektirir.\",\n                \"password\": \"Parola\",\n                \"password_desc\": \"Stash'e erişim için parola. Kullanıcı kimlik doğrulamasını devre dışı bırakmak için boş bırakın\",\n                \"stash-box_integration\": \"Stash-box entegrasyonu\",\n                \"username\": \"Kullanıcı adı\",\n                \"username_desc\": \"Stash'e erişim için kullanıcı adı. Kullanıcı kimlik doğrulamasını devre dışı bırakmak için boş bırakın\"\n            },\n            \"cache_location\": \"Önbellek dizininin konumudur. HLS (Apple cihazlarında olduğu gibi) veya DASH kullanılarak akış yapılıyorsa gereklidir.\",\n            \"cache_path_head\": \"Önbellek Yolu\",\n            \"calculate_md5_and_ohash_desc\": \"oshash'in yanısıra MD5 checksum'ı da hesapla. Bu özelliği etkinleştirmek tarama işlemini yavaşlatacaktır. MD5 hesaplamasını devre dışı bırakmak için dosya adı imzası (hash) oshash olarak ayarlanmalıdır.\",\n            \"calculate_md5_and_ohash_label\": \"Videolar için MD5 hesapla\",\n            \"check_for_insecure_certificates\": \"Güvensiz sertifikaları kontrol et\",\n            \"check_for_insecure_certificates_desc\": \"Bazı siteler güvensiz SSL sertifikası kullanıyor olabilir. Bu seçenek kapalı ise veri toplayıcı sertifika doğrulama adımını yapmaz. Veri toplarken sertifika hatası alıyorsanız bu işareti kaldırabilirsiniz.\",\n            \"chrome_cdp_path\": \"Chrome CDP yolu\",\n            \"chrome_cdp_path_desc\": \"Chrome yürütülebilir dosyasının dosya yolu veya bir Chrome örneğinin uzak adresi (http:// veya https:// ile başlayan, örneğin http://localhost:9222/json/version).\",\n            \"create_galleries_from_folders_desc\": \"Seçili ise resim içeren dizinlerden galeriler oluşturur.\",\n            \"create_galleries_from_folders_label\": \"Resim içeren dizinlerden galeri oluştur\",\n            \"db_path_head\": \"Veritabanı Yolu\",\n            \"directory_locations_to_your_content\": \"İçeriğiniz için dizin konumları\",\n            \"excluded_image_gallery_patterns_desc\": \"Tarama ve Temizleme işlemine eklenmeyecek Resim ve Galeri dosyaları/dosya konumları için kurallı ifadeler (Regexp)\",\n            \"excluded_image_gallery_patterns_head\": \"Dışta tutulan Resim/Galeri Kuralları\",\n            \"excluded_video_patterns_desc\": \"Tarama ve Temizleme işlemine eklenmeyecek Video dosyaları/dosya konumları için kurallı ifadeler (Regexp)\",\n            \"excluded_video_patterns_head\": \"Dışta tutulan Video Kuralları\",\n            \"gallery_ext_desc\": \"Galeri ZIP dosyaları olarak tanımlanacak dosya uzantıları listesi (virgülle ayrılmış).\",\n            \"gallery_ext_head\": \"Galeri ZIP Uzantıları\",\n            \"generated_file_naming_hash_desc\": \"Oluşturulacak dosya isimleri için MD5 veya oshash kullanın. Bu değeri değiştirmek, tüm sahneler için MD5/oshash hesaplaması gerektirir. Bu değeri değiştirdikten sonra mevcut tüm ek dosyalar yeniden oluşturulacak veya yer değiştirecektir. Yer değiştirme işlemleri için Görevler sayfasını ziyaret edin.\",\n            \"generated_file_naming_hash_head\": \"Oluşturulan dosya adı imzası\",\n            \"generated_files_location\": \"Oluşturulan ek dosyalar için dizin konumu (yer işaretleri, sahne önizlemeler, küçük resimler vb.)\",\n            \"generated_path_head\": \"Oluşturulan Yolu\",\n            \"hashing\": \"Dosya imzası hesaplama (Hashing)\",\n            \"image_ext_desc\": \"Resim olarak tanımlanacak dosya uzantıları listesi (virgülle ayrılmış).\",\n            \"image_ext_head\": \"Resim Dosyası Uzantıları\",\n            \"include_audio_desc\": \"Önizleme oluştururken videodan sesleri de ekler.\",\n            \"include_audio_head\": \"Sesi ekle\",\n            \"logging\": \"Kayıt Tutma\",\n            \"maximum_streaming_transcode_size_desc\": \"Dönüştürülmüş video yayınları için maksimum boyut\",\n            \"maximum_streaming_transcode_size_head\": \"Maksimum yayınlanacak dönüştürülmüş video boyutu\",\n            \"maximum_transcode_size_desc\": \"Dönüştürülmüş videolar için maksimum boyut\",\n            \"maximum_transcode_size_head\": \"Maksimum dönüştürülmüş video boyutu\",\n            \"metadata_path\": {\n                \"description\": \"Tam bir dışa veya içe aktarma gerçekleştirirken kullanılan dizin konumu\",\n                \"heading\": \"Üstveri Yolu\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Otomatik algılama için 0 olarak ayarlayın. Uyarı: %100 işlemci kullanımına ulaşmak için gerekenden daha fazla görev çalıştırmak, performansı düşürecek ve potansiyel olarak başka sorunlara neden olacaktır.\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"Tarama/oluşturma için paralel görev sayısı\",\n            \"parallel_scan_head\": \"Paralel Tarama/Oluşturma\",\n            \"preview_generation\": \"Önizleme Oluşturma\",\n            \"scraper_user_agent\": \"Veri Toplama Kimliği\",\n            \"scraper_user_agent_desc\": \"Veri Toplama sırasında HTTP istekleri için kullanılacak Kimliği (User Agent)\",\n            \"scrapers_path\": {\n                \"description\": \"Veri toplayıcı yapılandırma dosyalarının dizin konumu\",\n                \"heading\": \"Veri Toplayıcı Yolu\"\n            },\n            \"scraping\": \"Veri Toplama\",\n            \"sqlite_location\": \"SQLite veritabanı için dosya konumu (yeniden başlatma gerektirir). UYARI: Veritabanını, Stash sunucusunun çalıştığı sistemden farklı bir sistemde (yani ağ üzerinden) depolamak desteklenmemektedir!\",\n            \"video_ext_desc\": \"Video olarak işlem görecek dosya uzantı listesi (virgülle ayrılmış).\",\n            \"video_ext_head\": \"Video Uzantıları\",\n            \"video_head\": \"Video\",\n            \"database\": \"Veritabanı\",\n            \"funscript_heatmap_draw_range\": \"Oluşturulan ısı haritalarına aralığı dahil et\",\n            \"blobs_storage\": {\n                \"heading\": \"İkili veri depolama türü\"\n            },\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"heading\": \"FFmpeg donanım kodlaması\",\n                    \"desc\": \"Canlı video dönüştürme için mevcut donanımı kullanır.\"\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"FFmpeg'i indir\",\n                    \"description\": \"FFmpeg'i yapılandırma dizinine indirir ve ffmpeg ve ffprobe yollarını yapılandırma dizininden çözümlemek için temizler.\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"FFmpeg Yürütülebilir Yolu\",\n                    \"description\": \"Ffmpeg yürütülebilir dosyasının yolu (yalnızca klasörün değil). Boşsa, ffmpeg ortamdan $PATH yapılandırma dizini veya $HOME/.stash aracılığıyla çözümlenecektir\"\n                },\n                \"ffprobe_path\": {\n                    \"heading\": \"FFprobe Yürütülebilir Yolu\",\n                    \"description\": \"Ffprobe yürütülebilir dosyasının yolu (yalnızca klasörün değil). Boşsa, ffprobe ortamdan $PATH yapılandırma dizini veya $HOME/.stash aracılığıyla çözümlenecektir\"\n                },\n                \"transcode\": {\n                    \"output_args\": {\n                        \"heading\": \"FFmpeg Video Dönüştürme Çıkış Değişkenleri\",\n                        \"desc\": \"Gelişmiş: Video oluştururken çıkış alanından önce ffmpeg'e iletilecek ek değişkenler.\"\n                    },\n                    \"input_args\": {\n                        \"desc\": \"Gelişmiş: Video oluştururken giriş alanından önce ffmpeg'e iletilecek ek değişkenler.\",\n                        \"heading\": \"FFmpeg Video Dönüştürme Giriş Değişkenleri\"\n                    }\n                },\n                \"live_transcode\": {\n                    \"output_args\": {\n                        \"heading\": \"FFmpeg Canlı Video Dönüştürme Çıkış Değişkenleri\",\n                        \"desc\": \"Gelişmiş: Canlı video dönüştürme sırasında çıkış alanından önce ffmpeg'e iletilecek ek değişkenler.\"\n                    },\n                    \"input_args\": {\n                        \"heading\": \"FFmpeg Canlı Video Dönüştürme Giriş Değişkenleri\",\n                        \"desc\": \"Gelişmiş: Canlı video dönüştürme sırasında giriş alanından önce ffmpeg'e iletilecek ek değişkenler.\"\n                    }\n                }\n            },\n            \"gallery_cover_regex_label\": \"Galeri kapak deseni\",\n            \"plugins_path\": {\n                \"description\": \"Eklenti yapılandırma dosyalarının dizin konumu\",\n                \"heading\": \"Eklenti Yolu\"\n            },\n            \"blobs_path\": {\n                \"heading\": \"İkili veri dosya sistemi yolu\"\n            },\n            \"gallery_cover_regex_desc\": \"Regexp, bir resmi galeri kapağı olarak tanımlamak için kullanılır\",\n            \"backup_directory_path\": {\n                \"heading\": \"Yedek Dizin Yolu\",\n                \"description\": \"SQLite veritabanı dosya yedekleri için dizin konumu\"\n            },\n            \"python_path\": {\n                \"heading\": \"Python Yürütülebilir Yolu\",\n                \"description\": \"Python yürütülebilir dosyasının yolu (yalnızca klasörün değil). Komut dosyası veri kazıyıcılar ve eklentiler için kullanılır. Boşsa, python ortamdan çözümlenecektir\"\n            },\n            \"heatmap_generation\": \"Funscript Isı Haritası Oluşturma\",\n            \"funscript_heatmap_draw_range_desc\": \"Oluşturulan ısı haritasının y ekseninde hareket aralığını çizin. Değişiklik yapıldıktan sonra mevcut ısı haritalarının yeniden oluşturulması gerekecektir.\"\n        },\n        \"library\": {\n            \"exclusions\": \"Dışta Tutulanlar\",\n            \"gallery_and_image_options\": \"Galeri ve Resim seçenekleri\",\n            \"media_content_extensions\": \"Medya İçeriği Uzantıları\"\n        },\n        \"logs\": {\n            \"log_level\": \"Kayıt Tutma Seviyesi\"\n        },\n        \"plugins\": {\n            \"hooks\": \"Kancalar\",\n            \"triggers_on\": \"Şunda tetiklenir\",\n            \"installed_plugins\": \"Yüklü Eklentiler\",\n            \"available_plugins\": \"Kullanılabilir Eklentiler\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType} Üst Verisi\",\n            \"entity_scrapers\": \"{entityType} veri toplayıcıları\",\n            \"excluded_tag_patterns_desc\": \"Veri toplama sonuçlarına eklenmeyecek etiketler için kurallı ifadeler\",\n            \"excluded_tag_patterns_head\": \"Dışta Tutulan Etiket Kuralları\",\n            \"scraper\": \"Veri Toplayıcı\",\n            \"scrapers\": \"Veri Toplayıcılar\",\n            \"search_by_name\": \"Ada göre ara\",\n            \"supported_types\": \"Desteklenen türler\",\n            \"supported_urls\": \"İnternet Adresleri\",\n            \"installed_scrapers\": \"Yüklü Veri Toplayıcılar\",\n            \"available_scrapers\": \"Kullanılabilir Veri Toplayıcılar\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Stash-box oturumu ekle\",\n            \"api_key\": \"API anahtarı\",\n            \"description\": \"Stash-box, sahne ve oyuncuları parmak izleri ve dosya adlarına göre otomatik olarak tanımlamaya yardımcı olur.\\nBağlantı noktası and API anahtarı, hesabınız sekmesinde stash-box oturumu bölümünden bulunabilir. Birden fazla oturum açacaksanız isim eklemeniz gerekmektedir.\",\n            \"endpoint\": \"Bağlantı Noktası\",\n            \"graphql_endpoint\": \"GraphQL bağlantı noktası\",\n            \"name\": \"Ad\",\n            \"title\": \"Stash-box Bağlantı Noktaları\",\n            \"max_requests_per_minute\": \"Dakika başına maksimum istek\",\n            \"max_requests_per_minute_description\": \"0 olarak ayarlanırsa, varsayılan değer olan {defaultValue} kullanılır\"\n        },\n        \"system\": {\n            \"transcoding\": \"Video Dönüştürme\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"{operation_name} işlem kuyruğuna eklendi\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Tüm yollar otomatik etiketleniyor\",\n                \"auto_tagging_paths\": \"Aşağıdaki yollar otomatik etiketleniyor\"\n            },\n            \"auto_tag_based_on_filenames\": \"İçeriği dosya adlarına göre otomatik etiketle.\",\n            \"auto_tagging\": \"Otomatik Etiketleme\",\n            \"backing_up_database\": \"Veritabanı yedekleniyor\",\n            \"backup_and_download\": \"Veritabanını yedekler ve yedek dosyasını kaydetmenizi sağlar.\",\n            \"cleanup_desc\": \"Eksik dosyaları kontrol eder ve veritabanından siler. Bu geri döndürülemez bir işlemdir.\",\n            \"data_management\": \"Veri yönetimi\",\n            \"defaults_set\": \"Varsayılanlar ayarlandı. Bundan sonra Görevler sayfasındaki {action} düğmesi bu varsayılanları kullanacak.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Dosya uzantısını başlıkta gösterme\",\n            \"empty_queue\": \"Şu anda çalışan bir görev yok.\",\n            \"export_to_json\": \"Veritabanı içeriğini üstveri dizininde JSON biçiminde dışa aktarır.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Bu dizinlerdeki sahneler için oluşturuluyor\",\n                \"generating_scenes\": \"{num} {scene} için oluşturuluyor\"\n            },\n            \"generate_desc\": \"Resim, hareketli resim, video, vtt ve diğer ek dosyaları oluştur.\",\n            \"generate_phashes_during_scan\": \"Algısal dosya imzası oluştur\",\n            \"generate_phashes_during_scan_tooltip\": \"Tekrarlayan verilerin silinmesi ve sahne tanımlaması için.\",\n            \"generate_previews_during_scan\": \"Hareketli resim önizlemeleri oluştur\",\n            \"generate_previews_during_scan_tooltip\": \"Ayrıca hareketli (webp) önizlemeler oluşturun, yalnızca Sahne/İşaretleyici Duvarı Önizleme Türü, Hareketli Görüntü olarak ayarlandığında gereklidir. Gezinirken video önizlemelerinden daha az CPU kullanırlar, ancak bunlara ek olarak oluşturulurlar ve daha büyük dosyalardır.\",\n            \"generate_sprites_during_scan\": \"Basit hareketli resimler oluştur\",\n            \"generate_thumbnails_during_scan\": \"Resimler için küçük önizleme resimleri oluştur\",\n            \"generate_video_previews_during_scan\": \"Önizlemeler oluştur\",\n            \"generate_video_previews_during_scan_tooltip\": \"Fare sahne üzerinde iken önizleme için hareketli video oluştur\",\n            \"generated_content\": \"Oluşturulan İçerik\",\n            \"identify\": {\n                \"and_create_missing\": \"ve eksik olanı oluştur\",\n                \"create_missing\": \"Eksik olanı oluştur\",\n                \"default_options\": \"Varsayılan Seçenekler\",\n                \"description\": \"Sahne üstverisini stash-box ve veri toplama kaynaklarını kullanarak otomatik olarak belirle.\",\n                \"explicit_set_description\": \"Bu seçenekler yok saymayı özellikle belirtmediğiniz sürece kullanılacaktır.\",\n                \"field\": \"Alan\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Alan Seçenekleri\",\n                \"heading\": \"Tanımla\",\n                \"identifying_from_paths\": \"Bu dizin konumlarından sahneler tanımlanıyor\",\n                \"identifying_scenes\": \"{num} {scene} tanımlanıyor\",\n                \"include_male_performers\": \"Erkek oyuncuları dahil et\",\n                \"set_cover_images\": \"Kapak resimlerini ayarla\",\n                \"set_organized\": \"Düzenlenmiş olarak işaretle\",\n                \"source\": \"Kaynak\",\n                \"source_options\": \"{source} Seçenekleri\",\n                \"sources\": \"Kaynaklar\",\n                \"strategy\": \"Strateji\",\n                \"skip_multiple_matches\": \"Birden fazla sonucu olan eşleşmeleri atla\",\n                \"skip_multiple_matches_tooltip\": \"Bu etkinleştirilmezse ve birden fazla sonuç döndürülürse, eşleşmesi için rastgele bir sonuç seçilecektir\"\n            },\n            \"import_from_exported_json\": \"Üstveri dizinindeki dışa aktarılan JSON'dan içe aktarır. Mevcut veritabanını siler.\",\n            \"incremental_import\": \"Sağlanan bir dışa aktarma zip dosyasından artımlı içe aktarır.\",\n            \"job_queue\": \"Görev Kuyruğu\",\n            \"maintenance\": \"Bakım\",\n            \"migrate_hash_files\": \"Oluşturulan dosya adı imzası, varolan dosya imza biçimleriyle değiştirildikten sonra kullanıldı.\",\n            \"migrations\": \"Yer Değiştirmeler\",\n            \"only_dry_run\": \"Sadece genel tarama yap. Hiçbir şeyi silme\",\n            \"plugin_tasks\": \"Eklenti Görevleri\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Tüm yollar taranıyor\",\n                \"scanning_paths\": \"Aşağıdaki yollar taranıyor\"\n            },\n            \"scan_for_content_desc\": \"Yeni içerik için tara ve veritabanına ekle.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Gömülü dosya üstverilerinden ad, tarih ve ayrıntıları ayarlayın\",\n            \"clean_generated\": {\n                \"description\": \"İlgili veritabanı girişi olmadan oluşturulan dosyaları kaldırır.\",\n                \"markers\": \"İşaretleyici Önizlemeleri\",\n                \"previews\": \"Sahne Önizlemeleri\",\n                \"blob_files\": \"Blob dosyaları\",\n                \"transcodes\": \"Sahne Video Dönüştürmeleri\",\n                \"image_thumbnails\": \"Resim Küçük Resimleri\",\n                \"previews_desc\": \"Sahne önizlemeleri ve küçük resimler\",\n                \"image_thumbnails_desc\": \"Resim küçük resimleri ve klipler\"\n            },\n            \"migrate_blobs\": {\n                \"delete_old\": \"Eski verileri sil\"\n            },\n            \"rescan\": \"Dosyaları yeniden tara\",\n            \"generate_video_covers_during_scan\": \"Sahne kapakları oluştur\",\n            \"generate_sprites_during_scan_tooltip\": \"Kolay gezinme için video oynatıcının altında görüntülenen resim dizisi.\",\n            \"optimise_database\": \"Tüm veritabanı dosyasını analiz ederek ve ardından yeniden oluşturarak performansı artırmaya çalışın.\",\n            \"optimise_database_warning\": \"Uyarı: Bu görev çalışırken, veritabanını değiştiren tüm işlemler başarısız olacaktır ve veritabanınızın boyutuna bağlı olarak tamamlanması birkaç dakika sürebilir. Ayrıca veritabanınızın büyüklüğü kadar boş disk alanı gerektirir, ancak 1,5 katı önerilir.\",\n            \"anonymise_and_download\": \"Veritabanının anonimleştirilmiş bir kopyasını oluşturur ve elde edilen dosyayı indirir.\",\n            \"anonymise_database\": \"Tüm hassas verileri anonimleştirerek veritabanının bir kopyasını yedekler dizinine alır. Bu daha sonra sorun giderme ve hata ayıklama amacıyla başkalarına sağlanabilir. Orijinal veritabanı değiştirilmez. Anonimleştirilmiş veritabanı {filename_format} dosya adı biçimini kullanır.\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Ekran görüntülerini sil\",\n                \"overwrite_existing\": \"Mevcut blob'ları ekran görüntüsü verileriyle üzerine yaz\"\n            },\n            \"rescan_tooltip\": \"Yoldaki her dosyayı yeniden tarayın. Dosya üstverilerini güncellemeyi zorlamak ve zip dosyalarını yeniden taramak için kullanılır.\",\n            \"generate_clip_previews_during_scan\": \"Resim klipleri için önizlemeler oluştur\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Yinelenen Sahne Denetleyicisi\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Alan Ekle\",\n                \"capitalize_title\": \"Başlığın ilk harflerini büyük harf yap\",\n                \"display_fields\": \"Alanları göster\",\n                \"escape_chars\": \"Özel harfler ve karakterler için \\\\ kullan\",\n                \"filename\": \"Dosya adı\",\n                \"filename_pattern\": \"Dosya adı Kuralı\",\n                \"ignore_organized\": \"Düzenlenmiş sahneleri yoksay\",\n                \"ignored_words\": \"Yok sayılan kelimeler\",\n                \"matches_with\": \"{i} ile eşleşen\",\n                \"select_parser_recipe\": \"Derleyici Tarifi Seç\",\n                \"title\": \"Sahne Dosya Adı Derleyicisi\",\n                \"whitespace_chars\": \"Boşluk karakterleri\",\n                \"whitespace_chars_desc\": \"Bu karakterler başlıkta boşluk karakteri ile değiştirilecektir\"\n            },\n            \"scene_tools\": \"Sahne Araçları\",\n            \"graphql_playground\": \"GraphQL oyun alanı\",\n            \"heading\": \"Araçlar\"\n        },\n        \"ui\": {\n            \"basic_settings\": \"Temel Seçenekler\",\n            \"custom_css\": {\n                \"description\": \"Değişikliklerin geçerli olması için sayfa yeniden yüklenmelidir. Özel CSS ile Stash'in gelecekteki sürümleri arasında uyumluluk garantisi yoktur.\",\n                \"heading\": \"Özel CSS\",\n                \"option_label\": \"Özel CSS etkin\"\n            },\n            \"delete_options\": {\n                \"description\": \"Resimleri, galerileri ve sahneleri silerken kullanılacak varsayılan seçenekler.\",\n                \"heading\": \"Silme Seçenekleri\",\n                \"options\": {\n                    \"delete_file\": \"Varsayılan olarak dosyayı her zaman sil\",\n                    \"delete_generated_supporting_files\": \"Varsayılan olarak ek dosyaları her zaman sil\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Masaüstü Entegrasyonu\",\n                \"skip_opening_browser\": \"Tarayıcıyı Açmayı Atla\",\n                \"skip_opening_browser_on_startup\": \"Başlangıçta tarayıcıyı otomatik açmayı atla\",\n                \"notifications_enabled\": \"Bildirimleri Etkinleştir\",\n                \"send_desktop_notifications_for_events\": \"Etkinlikler için masaüstü bildirimleri gönder\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Açılır menülerden yeni nesne oluşturma özelliğini kaldır\",\n                    \"heading\": \"Açılır menüden yeni veri oluşturmayı devre dışı bırak\"\n                },\n                \"heading\": \"Düzenleme\",\n                \"rating_system\": {\n                    \"type\": {\n                        \"label\": \"Derecelendirme Sistemi Türü\",\n                        \"options\": {\n                            \"decimal\": \"Ondalık\",\n                            \"stars\": \"Yıldızlar\"\n                        }\n                    },\n                    \"star_precision\": {\n                        \"options\": {\n                            \"half\": \"Yarım\",\n                            \"quarter\": \"Çeyrek\",\n                            \"tenth\": \"Onda bir\",\n                            \"full\": \"Tam\"\n                        },\n                        \"label\": \"Derecelendirme Yıldızı Hassasiyeti\"\n                    }\n                },\n                \"max_options_shown\": {\n                    \"label\": \"Seçili açılır menüde gösterilecek maksimum öğe sayısı\"\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Etkileşimli komut oynatmaları için milisaniye cinsinden süre farkı.\",\n                \"heading\": \"Funscript Süre Farkı (ms)\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Etkileşimli sahneler içinHandy bağlantı anahtarı. Bu anahtarı kullanırsanız, Stash izlediğiniz sahne bilgisini handyfeeling.com ile paylaşacaktır\",\n                \"heading\": \"Handy Bağlantı Anahtarı\"\n            },\n            \"images\": {\n                \"heading\": \"Resimler\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Resim önizlemelerini oluşturulma anında diske kaydet\",\n                        \"heading\": \"Resim önizlemelerini kaydet\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"heading\": \"Video Uzantılarını Resim Klibi Olarak Tara\",\n                        \"description\": \"Bir kütüphanede videolar devre dışı bırakıldığında, video dosyaları (video uzantısı ile biten dosyalar) Resim Klibi olarak taranacaktır.\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Etkileşimli Seçenekler\",\n            \"language\": {\n                \"heading\": \"Dil\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Sahne oynatıcısı videoyu yeniden oynatırken ara vereceği süre - Aralıksız yeniden oynatmak için 0 seçin\",\n                \"heading\": \"Maksimum döngü süresi\"\n            },\n            \"menu_items\": {\n                \"description\": \"Gezinti çubuğunda farklı içerik türlerini göster veya gizle\",\n                \"heading\": \"Menü Öğeleri\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Varsayılan oyuncu resimleri için özel yol. Yerleşik varsayılanları kullanmak için boş bırak\",\n                        \"heading\": \"Özel Oyuncu Resmi Yolu\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Varsayılan seçenek video (mp4) önizlemeleridir. Gezinirken daha az CPU kullanımı için hareketli görüntü (webp) önizlemelerini kullanabilirsiniz. Ancak bunların video önizlemelerine ek olarak oluşturulması gerekir ve daha büyük dosyalardır.\",\n                \"heading\": \"Önizleme Türü\",\n                \"options\": {\n                    \"animated\": \"Hareketli Resim\",\n                    \"static\": \"Hareketsiz Resim\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Sahne Listesi\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Stüdyo katmanını metin olarak görüntüle\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"Sahne Oynatıcı\",\n                \"options\": {\n                    \"auto_start_video\": \"Videoları otomatik başlat\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Seçili olanı veya Sahneler sayfasından rastgele seçilen videoları otomatik başlat\",\n                        \"heading\": \"Seçilen oynatılırken videoyu otomatik başlat\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Video bitince sıradaki sahneyi oynat\",\n                        \"heading\": \"Varsayılan olarak oynatma listesine devam et\"\n                    },\n                    \"always_start_from_beginning\": \"Videoyu her zaman baştan başlat\",\n                    \"vr_tag\": {\n                        \"heading\": \"VR Etiketi\",\n                        \"description\": \"VR butonu yalnızca bu etikete sahip sahneler için görüntülenecektir.\"\n                    },\n                    \"track_activity\": \"Sahne Oynatma Geçmişi'ni etkinleştir\",\n                    \"enable_chromecast\": \"Chromecast'i Etkinleştir\",\n                    \"show_ab_loop_controls\": \"AB Loop eklenti kontrollerini göster\",\n                    \"show_scrubber\": \"Video İlerleme Çubuğunu Göster\",\n                    \"disable_mobile_media_auto_rotate\": \"Mobil cihazlarda tam ekran medyanın otomatik döndürülmesini devre dışı bırak\",\n                    \"show_range_markers\": \"Zaman İşaretleyicilerini Göster\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"Sahne / İşaretleyici Duvarı\",\n                \"options\": {\n                    \"display_title\": \"Başlık ve etiketleri göster\",\n                    \"toggle_sound\": \"Sesi etkinleştir\"\n                }\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Galeri sayfasında Duvar görünümü seçilirse slayt gösterisi yapılabilir\",\n                \"heading\": \"Slayt Gösterisi Gecikmesi (saniye)\"\n            },\n            \"title\": \"Kullanıcı Arayüzü\",\n            \"detail\": {\n                \"heading\": \"Ayrıntı Sayfası\",\n                \"enable_background_image\": {\n                    \"heading\": \"Arka plan resmini etkinleştir\",\n                    \"description\": \"Ayrıntı sayfasında arka plan resmini göster.\"\n                },\n                \"show_all_details\": {\n                    \"heading\": \"Tüm ayrıntıları göster\",\n                    \"description\": \"Etkinleştirildiğinde, varsayılan olarak tüm içerik ayrıntıları gösterilecek ve her ayrıntı öğesi tek bir sütuna sığacak\"\n                },\n                \"compact_expanded_details\": {\n                    \"heading\": \"Kompakt genişletilmiş ayrıntılar\",\n                    \"description\": \"Bu seçenek etkinleştirildiğinde, kompakt görünüm korunurken genişletilmiş ayrıntılar gösterilir\"\n                }\n            },\n            \"custom_javascript\": {\n                \"heading\": \"Özel Javascript\",\n                \"option_label\": \"Özel Javascript etkin\",\n                \"description\": \"Değişikliklerin etkili olması için sayfanın yeniden yüklenmesi gerekir. Özel Javascript ile Stash'in gelecekteki sürümleri arasında uyumluluk garantisi yoktur.\"\n            },\n            \"custom_locales\": {\n                \"heading\": \"Özel Yerelleştirme\",\n                \"option_label\": \"Özel yerelleştirme etkin\",\n                \"description\": \"Bireysel yerel ayar dizelerini geçersiz kılın. Ana liste için https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json adresine bakın. Değişikliklerin etkili olması için sayfanın yeniden yüklenmesi gerekir.\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Stüdyo Görünümü\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"heading\": \"Alt stüdyo içeriğini görüntüle\",\n                        \"description\": \"Stüdyo görünümündeyken alt stüdyolardaki içeriği de görüntüleyin\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"heading\": \"Alt etiket içeriğini görüntüle\",\n                        \"description\": \"Etiket görünümündeyken alt etiketlerdeki içeriği de görüntüleyin\"\n                    }\n                },\n                \"heading\": \"Etiket Görünümü\"\n            },\n            \"abbreviate_counters\": {\n                \"heading\": \"Sayaçları kısalt\",\n                \"description\": \"Kartlarda ve ayrıntı görünümü sayfalarında sayaçları kısalt, örneğin “1831”, “1.8K” olarak biçimlendirilecektir.\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Bir sahnenin oynatma sayısı artırılmadan önce oynatılması gereken sürenin yüzdesi.\",\n                \"heading\": \"Minimum Oynatma Yüzdesi\"\n            },\n            \"image_wall\": {\n                \"heading\": \"Resim Duvarı\",\n                \"margin\": \"Kenar boşluğu (piksel)\",\n                \"direction\": \"Yön\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Bağlan\",\n                \"status\": {\n                    \"heading\": \"Handy Bağlantı Durumu\"\n                },\n                \"server_offset\": {\n                    \"heading\": \"Sunucu Zaman Farkı\"\n                },\n                \"sync\": \"Senkronize et\"\n            },\n            \"use_stash_hosted_funscript\": {\n                \"heading\": \"Funscript'leri doğrudan sun\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"heading\": \"Etiket kartı araç ipuçları\"\n            },\n            \"scroll_attempts_before_change\": {\n                \"heading\": \"Geçiş Öncesi Kaydırma Denemeleri\"\n            }\n        },\n        \"advanced_mode\": \"Gelişmiş Mod\"\n    },\n    \"configuration\": \"Yapılandırma\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Dosya} other {Dosyalar}}\",\n        \"galleries\": \"{count, plural, one {Galeri} other {Galeriler}}\",\n        \"images\": \"{count, plural, one {Resim} other {Resimler}}\",\n        \"markers\": \"{count, plural, one {İşaretleyici} other {İşaretleyiciler}}\",\n        \"performers\": \"{count, plural, one {Oyuncu} other {Oyuncular}}\",\n        \"scenes\": \"{count, plural, one {Sahne} other {Sahneler}}\",\n        \"studios\": \"{count, plural, one {Stüdyo} other {Stüdyolar}}\",\n        \"tags\": \"{count, plural, one {Etiket} other {Etiketler}}\",\n        \"groups\": \"{count, plural, one {Grup} other {Gruplar}}\"\n    },\n    \"country\": \"Ülke\",\n    \"cover_image\": \"Kapak Resmi\",\n    \"created_at\": \"Oluşturulma Tarihi\",\n    \"criterion\": {\n        \"greater_than\": \"Büyüktür\",\n        \"less_than\": \"Küçüktür\",\n        \"value\": \"Değer\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"arasında\",\n        \"equals\": \"eşittir\",\n        \"excludes\": \"içermeyen\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"greater_than\": \"büyüktür\",\n        \"includes\": \"içeren\",\n        \"includes_all\": \"tümünü içeren\",\n        \"is_null\": \"boş olan\",\n        \"less_than\": \"küçüktür\",\n        \"matches_regex\": \"kurallı ifadeyle eşleşen\",\n        \"not_between\": \"arasında olmayan\",\n        \"not_equals\": \"eşit değildir\",\n        \"not_matches_regex\": \"kurallı ifadeyle eşleşmeyen\",\n        \"not_null\": \"boş olmayan\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (hariç {excludedString})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (hariç {excludedString}) (+{depth, plural, =-1 {tüm} other {{depth}}})\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {tüm} other {{derinlik}}})\"\n    },\n    \"custom\": \"İsteğe Bağlı\",\n    \"date\": \"Tarih\",\n    \"death_date\": \"Ölüm Tarihi\",\n    \"death_year\": \"Ölüm Yılı\",\n    \"descending\": \"Azalan\",\n    \"detail\": \"Ayrıntı\",\n    \"details\": \"Ayrıntılar\",\n    \"developmentVersion\": \"Geliştirme Sürümü\",\n    \"dialogs\": {\n        \"delete_alert\": \"Bu {count, plural, one {{singularEntity}} other {{pluralEntity}}} kalıcı olarak silinecektir:\",\n        \"delete_confirm\": \"Bunu silmek istediğinize emin misiniz: {entityName}?\",\n        \"delete_entity_desc\": \"{count, plural, one {Bunu silmek istediğinizden emin misiniz: {entityName}? Dosyayı bilgisayarınızdan silmediğiniz sürece, yeniden tarama işleminde bu {singularEntity} tekrar veritabanına eklenecektir.} other {Bunları silmek istediğinizden emin misiniz: {pluralEntity}? Dosyayı bilgisayarınızdan silmediğiniz sürece, yeniden tarama işleminde bu {pluralEntity} tekrar veritabanına eklenecektir.}}\",\n        \"delete_entity_title\": \"{count, plural, one {{singularEntity} Sil} other {{pluralEntity} Sil}}\",\n        \"delete_galleries_extra\": \"...ek olarak herhangi bir galeriye eklenmemiş tüm resim dosyaları.\",\n        \"delete_gallery_files\": \"Herhangi bir galeriye eklenmemiş tüm galeri dizinlerini ve ZIP dosyalarını sil.\",\n        \"delete_object_desc\": \"{count, plural, one {Bunu: {singularEntity}} other {Bunları: {pluralEntity}}} silmek istediğinize emin misiniz?\",\n        \"delete_object_overflow\": \"…ve {count} diğer {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} Sil\",\n        \"edit_entity_title\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} Düzenle\",\n        \"export_include_related_objects\": \"Dışa aktarırken bağlantılı nesneleri de ekle\",\n        \"export_title\": \"Dışa Aktar\",\n        \"lightbox\": {\n            \"delay\": \"Gecikme (Saniye)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Yatay olarak sığdır\",\n                \"fit_to_screen\": \"Ekrana sığdır\",\n                \"label\": \"Görüntüleme Modu\",\n                \"original\": \"Orijinal\"\n            },\n            \"options\": \"Seçenekler\",\n            \"reset_zoom_on_nav\": \"Resim değiştirirken yakınlaştırma seviyesini sıfırla\",\n            \"scale_up\": {\n                \"description\": \"Küçük resimleri ekrana sığdırmak için büyüt\",\n                \"label\": \"Sığdırmak için büyüt\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Geçici olarak diğer modu kullanmak için Shift tuşuna basılı tutun.\",\n                \"label\": \"Kaydırma Modu\",\n                \"pan_y\": \"Y Ekseninde Çevir\",\n                \"zoom\": \"Yakınlaştırma\"\n            },\n            \"page_header\": \"Sayfa {page} / {total}\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"Dönüştürülmüş video oluşturmayı zorla\",\n            \"force_transcodes_tooltip\": \"Varsayılan olarak, video yalnızca video dosyası tarayıcı tarafından desteklenmediğinde dönüştürülür. Etkinleştirildiğinde, video dosyası tarayıcı tarafından destekleniyorsa bile video dönüştürülür.\",\n            \"image_previews\": \"Hareketli Resim Önizlemeleri\",\n            \"image_previews_tooltip\": \"Ayrıca hareketli (webp) önizlemeler oluşturun, yalnızca Sahne/İşaretleyici Duvarı Önizleme Türü, Hareketli Görüntü olarak ayarlandığında gereklidir. Gezinirken video önizlemelerinden daha az CPU kullanırlar, ancak bunlara ek olarak oluşturulurlar ve daha büyük dosyalardır.\",\n            \"interactive_heatmap_speed\": \"Etkileşimli sahneler için ısı haritaları ve hız kayıtları oluştur\",\n            \"marker_image_previews\": \"İşaretleyici Hareketli Resim Önizlemeleri\",\n            \"marker_image_previews_tooltip\": \"Hareketli Yer İmi WebP önizlemeleri, Önizleme Türü sadece Hareketli Resim olarak seçilmişse gereklidir.\",\n            \"marker_screenshots\": \"İşaretleyici Ekran Görüntüleri\",\n            \"marker_screenshots_tooltip\": \"Yer İmi hareketsiz JPG resimleri\",\n            \"markers\": \"İşaretleyici Önizlemeleri\",\n            \"markers_tooltip\": \"Belirlenen zamandan itibaren başlayan 20 saniyelik videolar.\",\n            \"overwrite\": \"Varolan oluşturulmuş dosyaların üzerine yaz\",\n            \"phash\": \"Algısal dosya imzaları (kopya dosyaları tespit için)\",\n            \"preview_exclude_end_time_desc\": \"Sahne önizlemelerinde son x saniyeyi hariç tut. Bu, saniye cinsinden bir değer veya toplam sahne süresinin yüzdesi (örneğin %2) olabilir.\",\n            \"preview_exclude_end_time_head\": \"Bitiş zamanını hariç tut\",\n            \"preview_exclude_start_time_desc\": \"Sahne önizlemelerinde ilk x saniyeyi hariç tut. Bu, saniye cinsinden bir değer veya toplam sahne süresinin yüzdesi (örneğin %2) olabilir.\",\n            \"preview_exclude_start_time_head\": \"Başlangıç zamanını hariç tut\",\n            \"preview_generation_options\": \"Önizleme Oluşturma Seçenekleri\",\n            \"preview_options\": \"Önizleme Seçenekleri\",\n            \"preview_preset_desc\": \"Ön ayar; önizleme oluşturmanın boyutunu, kalitesini ve kodlama süresini düzenler. “Yavaş” dışındaki ön ayarların azalan getirileri vardır ve tavsiye edilmez.\",\n            \"preview_preset_head\": \"Düzenleme ön ayarı önizlemesi\",\n            \"preview_seg_count_desc\": \"Önizleme dosyalarındaki bölüm sayısı.\",\n            \"preview_seg_count_head\": \"Önizlemedeki bölüm sayısı\",\n            \"preview_seg_duration_desc\": \"Her önizleme bölümünün saniye cinsinden süresi.\",\n            \"preview_seg_duration_head\": \"Bölüm önizleme süresi\",\n            \"sprites\": \"Basit Sahne Hareketli Görüntüleri\",\n            \"sprites_tooltip\": \"Kolay gezinme için video oynatıcının altında görüntülenen resim seti.\",\n            \"transcodes\": \"Dönüştürülmüş Videolar\",\n            \"transcodes_tooltip\": \"MP4 video dönüştürmeleri tüm içerikler için önceden oluşturulur; yavaş CPU'lar için kullanışlıdır ancak çok daha fazla disk alanı gerektirir\",\n            \"video_previews\": \"Önizlemeler\",\n            \"video_previews_tooltip\": \"Fare sahne üzerinde iken video önizlemeleri\",\n            \"covers\": \"Sahne kapakları\",\n            \"override_preview_generation_options\": \"Önizleme Oluşturma Seçeneklerini Geçersiz Kıl\",\n            \"image_thumbnails\": \"Resim Önizlemeleri\",\n            \"override_preview_generation_options_desc\": \"Bu işlem için Önizleme Oluşturma Seçeneklerini geçersiz kılın. Varsayılanlar Sistem -> Önizleme Oluşturma bölümünde ayarlanır.\",\n            \"clip_previews\": \"Resim Klibi Önizlemeleri\"\n        },\n        \"scenes_found\": \"{count} sahne bulundu\",\n        \"scrape_entity_query\": \"{entity_type} Veri Toplama Sorgusu\",\n        \"scrape_entity_title\": \"{entity_type} Veri Toplama Sonuçları\",\n        \"scrape_results_existing\": \"Mevcut\",\n        \"scrape_results_scraped\": \"Toplanan\",\n        \"set_image_url_title\": \"Resim Internet Adresi\",\n        \"unsaved_changes\": \"Değişiklikler kaydedilmedi. Sayfadan ayrılmak istediğinize emin misiniz?\",\n        \"clear_play_history_confirm\": \"Oynatma geçmişini temizlemek istediğinize emin misiniz?\",\n        \"merge\": {\n            \"source\": \"Kaynak\",\n            \"destination\": \"Hedef\",\n            \"empty_results\": \"Hedef alan değerleri değişmeyecektir.\"\n        },\n        \"dont_show_until_updated\": \"Sonraki güncellemeye kadar gösterme\",\n        \"clear_o_history_confirm\": \"O geçmişini temizlemek istediğinize emin misiniz?\",\n        \"imagewall\": {\n            \"direction\": {\n                \"description\": \"Sütun veya satır tabanlı düzen.\",\n                \"row\": \"Satır\",\n                \"column\": \"Sütun\"\n            },\n            \"margin_desc\": \"Her resmin etrafındaki kenar boşluğu piksellerinin sayısı.\"\n        },\n        \"performers_found\": \"{count} oyuncu bulundu\",\n        \"create_new_entity\": \"Yeni {entity} oluştur\",\n        \"reassign_files\": {\n            \"destination\": \"Şuraya yeniden ata\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {Yeniden ata {singularEntity}} other {Yeniden ata {pluralEntity}}}\",\n        \"set_default_filter_confirm\": \"Bu filtreyi varsayılan olarak ayarlamak istediğinize emin misiniz?\"\n    },\n    \"dimensions\": \"Boyutlar\",\n    \"director\": \"Yönetmen\",\n    \"display_mode\": {\n        \"grid\": \"Izgara\",\n        \"list\": \"Liste\",\n        \"tagger\": \"Etiketleyici\",\n        \"unknown\": \"Bilinmeyen\",\n        \"wall\": \"Duvar\",\n        \"label_current\": \"Görüntüleme Modu: {current}\"\n    },\n    \"donate\": \"Bağış Yap\",\n    \"dupe_check\": {\n        \"description\": \"'Mutlak' seviyesinin altındaki seviyeleri hesaplamak çok daha uzun sürebilir. Ayrıca düşük kesinlik seviyelerinde yanlış sonuçlar da alabilirsiniz.\",\n        \"found_sets\": \"{setCount, plural, one{# kopya bulundu.} other {# kopya bulundu.}}\",\n        \"options\": {\n            \"exact\": \"Mutlak\",\n            \"high\": \"Yüksek\",\n            \"low\": \"Düşük\",\n            \"medium\": \"Orta\"\n        },\n        \"search_accuracy_label\": \"Arama Kesinliği\",\n        \"title\": \"Yinelenen Sahneler\",\n        \"duration_options\": {\n            \"equal\": \"Eşit\",\n            \"any\": \"Herhangi\"\n        },\n        \"duration_diff\": \"Maksimum Süre Farkı\",\n        \"select_all_but_largest_file\": \"En büyük dosya hariç, yinelenen her gruptaki her dosyayı seç\",\n        \"select_all_but_largest_resolution\": \"En yüksek çözünürlüğe sahip dosya hariç, yinelenen her gruptaki her dosyayı seç\",\n        \"select_none\": \"Hiçbirini seçme\",\n        \"select_oldest\": \"Yinelenen gruptaki en eski dosyayı seç\",\n        \"select_options\": \"Seçme Ayarları…\",\n        \"select_youngest\": \"Yinelenen gruptaki en yeni dosyayı seç\",\n        \"only_select_matching_codecs\": \"Yalnızca yinelenen gruptaki tüm kodlayıcılar eşleşiyorsa seç\"\n    },\n    \"duration\": \"Süre\",\n    \"effect_filters\": {\n        \"aspect\": \"Yön\",\n        \"blue\": \"Mavi\",\n        \"blur\": \"Bulanıklık\",\n        \"brightness\": \"Parlaklık\",\n        \"contrast\": \"Kontrast\",\n        \"gamma\": \"Gamma\",\n        \"green\": \"Yeşil\",\n        \"hue\": \"Renk tonu\",\n        \"name\": \"Filtreler\",\n        \"name_transforms\": \"Dönüşümler\",\n        \"red\": \"Kırmızı\",\n        \"reset_filters\": \"Filtreleri Sıfırla\",\n        \"reset_transforms\": \"Dönüşümleri Sıfırla\",\n        \"rotate\": \"Döndür\",\n        \"rotate_left_and_scale\": \"Sola Döndür & Boyutlandır\",\n        \"rotate_right_and_scale\": \"Sağa Döndür & Boyutlandır\",\n        \"saturation\": \"Doygunluk\",\n        \"scale\": \"Boyutlandır\",\n        \"warmth\": \"Sıcaklık\"\n    },\n    \"ethnicity\": \"Etnik Köken\",\n    \"eye_color\": \"Göz Rengi\",\n    \"fake_tits\": \"Takma Göğüs\",\n    \"false\": \"Yanlış\",\n    \"favourite\": \"Favori\",\n    \"file\": \"dosya\",\n    \"file_info\": \"Dosya Bilgisi\",\n    \"file_mod_time\": \"Dosya Değiştirme Tarihi\",\n    \"files\": \"dosyalar\",\n    \"filesize\": \"Dosya Boyutu\",\n    \"filter\": \"Filtre\",\n    \"filter_name\": \"Filtre adı\",\n    \"filters\": \"Filtreler\",\n    \"framerate\": \"Kare Hızı\",\n    \"frames_per_second\": \"{value} fps\",\n    \"galleries\": \"Galeriler\",\n    \"gallery\": \"Galeri\",\n    \"gallery_count\": \"Galeri Sayısı\",\n    \"hair_color\": \"Saç Rengi\",\n    \"hasMarkers\": \"İşaretleyicileri Var\",\n    \"height\": \"Boy\",\n    \"help\": \"Yardım\",\n    \"image\": \"Resim\",\n    \"image_count\": \"Resim Sayısı\",\n    \"images\": \"Resimler\",\n    \"include_parent_tags\": \"Bir üst etiketleri de ekle\",\n    \"include_sub_studios\": \"Bağlı stüdyoları da ekle\",\n    \"include_sub_tags\": \"Alt etiketleri dahil et\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"Etkileşimli\",\n    \"interactive_speed\": \"Etkileşimli Hız\",\n    \"isMissing\": \"Eksik\",\n    \"library\": \"Kütüphane\",\n    \"loading\": {\n        \"generic\": \"Yükleniyor…\",\n        \"plugins\": \"Eklentiler yükleniyor…\"\n    },\n    \"marker_count\": \"İşaretleyici Sayısı\",\n    \"markers\": \"İşaretleyiciler\",\n    \"measurements\": \"Beden Ölçüleri\",\n    \"media_info\": {\n        \"audio_codec\": \"Ses Kodlayıcı\",\n        \"downloaded_from\": \"İndirildiği Yer\",\n        \"interactive_speed\": \"Etkileşimli Hız\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"Bu sahnede {age} {years_old}\"\n        },\n        \"phash\": \"PHash\",\n        \"stream\": \"Yayınla\",\n        \"video_codec\": \"Video Kodlayıcı\",\n        \"play_duration\": \"Oynatma Süresi\",\n        \"play_count\": \"Oynatma Sayısı\",\n        \"o_count\": \"O Sayısı\"\n    },\n    \"megabits_per_second\": \"{value} mbps\",\n    \"metadata\": \"Üst Veri\",\n    \"name\": \"Ad\",\n    \"new\": \"Yeni\",\n    \"none\": \"Hiçbiri\",\n    \"operations\": \"İşlemler\",\n    \"organized\": \"Düzenlendi\",\n    \"pagination\": {\n        \"first\": \"İlk\",\n        \"last\": \"Son\",\n        \"next\": \"Sonraki\",\n        \"previous\": \"Önceki\",\n        \"current_total\": \"{current} / {total}\"\n    },\n    \"parent_of\": \"{children} öğesinin üstü\",\n    \"parent_studios\": \"Üst Stüdyolar\",\n    \"parent_tag_count\": \"Üst Etiket Sayısı\",\n    \"parent_tags\": \"Üst Etiketler\",\n    \"part_of\": \"{parent} öğesinin parçası\",\n    \"path\": \"Yol\",\n    \"performer\": \"Oyuncu\",\n    \"performer_count\": \"Oyuncu Sayısı\",\n    \"performer_image\": \"Oyuncu Resmi\",\n    \"performer_tags\": \"Oyuncu Etiketleri\",\n    \"performers\": \"Oyuncular\",\n    \"piercings\": \"Piercing'ler\",\n    \"queue\": \"Oynatma Listesi\",\n    \"random\": \"Rastgele\",\n    \"rating\": \"Derecelendirme\",\n    \"resolution\": \"Çözünürlük\",\n    \"scene\": \"Sahne\",\n    \"sceneTagger\": \"Sahne Etiketleyici\",\n    \"scene_count\": \"Sahne Sayısı\",\n    \"scene_id\": \"Sahne Kimliği (ID)\",\n    \"scene_tags\": \"Sahne Etiketleri\",\n    \"scenes\": \"Sahneler\",\n    \"scenes_updated_at\": \"Sahne Güncelleme Tarihi\",\n    \"search_filter\": {\n        \"name\": \"Filtre\",\n        \"saved_filters\": \"Kaydedilmiş filtreler\",\n        \"update_filter\": \"Filtreyi Güncelle\",\n        \"edit_filter\": \"Filtreyi Düzenle\",\n        \"search_term\": \"Arama terimi\"\n    },\n    \"seconds\": \"Saniye\",\n    \"settings\": \"Ayarlar\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"Ayarlamaları neredeyse tamamlamak üzereyiz. Lütfen bu ayarları onaylayın. Eğer düzenleme yapmak isterseniz geri giderek ayarları değiştirebilirsiniz. Herşey tamamsa Onayla tuşuna basın.\",\n            \"configuration_file_location\": \"Yapılandırma dosyası konumu:\",\n            \"database_file_path\": \"Veritabanı dosya yolu\",\n            \"generated_directory\": \"Oluşturulan dizin\",\n            \"nearly_there\": \"Bitmek üzere!\",\n            \"stash_library_directories\": \"Stash kütüphane dizinleri\",\n            \"blobs_directory\": \"İkili veri dizini\",\n            \"cache_directory\": \"Önbellek dizini\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"Sisteminiz oluşturuluyor\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"Hata! Birşeyler yanlış gitti!\",\n            \"something_went_wrong_description\": \"Hatanın sizin belirlediğiniz özel ayarlardan kaynaklanıyor olabilir, lütfen geriye giderek o ayarları değiştirin. Tekrar hata alırsanız {githubLink} veya {discordLink} adreslerini kullanarak sorunu bildirin (Şimdilik yalnızca İngilizce).\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Sisteminizi ayarlarken birşeyler yanlış gitti ve bu hata mesajını aldık: {error}\",\n            \"unexpected_error\": \"Beklenmeyen bir hata oluştu: {error}\",\n            \"unable_to_retrieve_system_status\": \"Sistem durumu alınamıyor: {error}\"\n        },\n        \"folder\": {\n            \"up_dir\": \"Bir dizin üste çık\",\n            \"file_path\": \"Dosya dizini\"\n        },\n        \"github_repository\": \"Github deposu\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Veritabanı yedekleme konumu (yedeklemeyi devre dışı bırakmak için bu alanı boş bırakın):\",\n            \"backup_recommended\": \"Yer değiştirme işlemi yapmadan önce varolan veritabanınızı yedeklemeniz önerilir. Eğer isterseniz otomatik olarak <code>{defaultBackupPath}</code> konumuna veritabanınızın bir yedeğini oluşturabiliriz.\",\n            \"migrating_database\": \"Veritabanı yer değiştirme\",\n            \"migration_failed\": \"Yer değiştirme başarısız\",\n            \"migration_failed_error\": \"Veritabanı yer değiştirilirken bu hatayla karşılaşıldı:\",\n            \"migration_failed_help\": \"Lütfen gerekli düzeltmeleri yapın ve yeniden deneyin. Tekrar hata alırsanız {githubLink} veya {discordLink} adreslerini kullanarak sorunu bildirin (Şimdilik yalnızca İngilizce).\",\n            \"migration_irreversible_warning\": \"Veritabanı şema yer değiştirme işlemi geri alınamaz. Veritabanınız yer değiştirme işlemi tamamlandıktan sonra önceki Stash sürümleriyle uyumsuz olacaktır.\",\n            \"migration_required\": \"Yer değiştirme işlemi gerekli\",\n            \"perform_schema_migration\": \"Şema yer değiştirme işlemi uygula\",\n            \"schema_too_old\": \"Mevcut Stash veritabanı şema sürümünüz <strong>{databaseSchema}</strong> ve <strong>{appSchema}</strong> sürümü ile değiştirilmesi gerekiyor. Stash'ın bu sürümü değiştirme işlemi yapılmadan çalışmayacaktır.\",\n            \"migration_notes\": \"Geçiş Notları\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"veritabanı adı (varsayılan için boş bırakın)\",\n            \"description\": \"Sırada, porno koleksiyonunuzun nerede bulunacağını ve Stash veritabanının, oluşturulan dosyaların ve önbellek dosyalarının nerede depolanacağını belirlememiz gerekiyor. Bu ayarlar daha sonra gerekirse değiştirilebilir.\",\n            \"path_to_generated_directory_empty_for_default\": \"oluşturulan ek dosyalar için dizin konumu (varsayılan için boş bırakın)\",\n            \"set_up_your_paths\": \"Yollarınızı ayarlayın\",\n            \"stash_alert\": \"Herhangi bir kütüphane konumu seçilmedi. Hiçbir medya Stash'e taranamayacak. Emin misiniz?\",\n            \"where_can_stash_store_its_database\": \"Stash veritabanı nereye kaydedilsin?\",\n            \"where_can_stash_store_its_database_description\": \"Stash porno arşiviniz için SQLite veritabanı kullanır. Bu veritabanı, varsayılan olarak <code>stash-go.sqlite</code> dizininde, yapılandırma dosyasıyla aynı yerde oluşturulur. Bu dizini değiştirmek isterseniz, yapılandırma dosyasının bulunduğu dizinin tam konumunu girin.\",\n            \"where_can_stash_store_its_generated_content\": \"Stash için oluşturulan ek dosyalar nereye kaydedilsin?\",\n            \"where_can_stash_store_its_generated_content_description\": \"Stash, önizlemeler için küçük hareketli resimler ve videolar oluşturur. Ayrıca web tarayıcınızın desteklemediği video dosyaları için dönüştürülmüş video dosyaları da oluşturulur. Bu amaçla, varsayılan olarak yapılandırma dosyasının bulunduğu <code>generated</code> dizini kullanılır. Bu dizini değiştirmek isterseniz, yapılandırma dosyasının bulunduğu dizinin tam konumunu girin. Eğer dizin bulunamazsa otomatik olarak oluşturulacaktır.\",\n            \"where_is_your_porn_located\": \"Porno koleksiyonunuz hangi dizinde?\",\n            \"where_is_your_porn_located_description\": \"Fotoğraf ve videoların bulunduğu dizinleri ekleyin. Stash, tarama sırasında bu dizinleri kullanacaktır.\",\n            \"where_can_stash_store_blobs\": \"Stash veritabanı ikili verilerini nerede saklayabilir?\",\n            \"where_can_stash_store_cache_files\": \"Stash önbellek dosyalarını nerede saklayabilir?\",\n            \"path_to_cache_directory_empty_for_default\": \"önbellek dizini yolu (varsayılan için boş bırakın)\",\n            \"store_blobs_in_database\": \"Blob'ları veritabanında depola\",\n            \"path_to_blobs_directory_empty_for_default\": \"blobs dizini yolu (varsayılan için boş bırakın)\",\n            \"where_can_stash_store_cache_files_description\": \"Stash, HLS/DASH canlı video dönüştürme gibi bazı işlevlerin çalışabilmesi için geçici dosyalara yönelik bir önbellek dizini gerektirir. Varsayılan olarak, Stash yapılandırma dosyanızı içeren dizin içinde bir <code>cache</code> dizini oluşturacaktır. Bunu değiştirmek istiyorsanız, lütfen mutlak veya göreceli (geçerli çalışma dizinine) bir yol girin. Mevcut değilse, Stash bu dizini oluşturacaktır.\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Alternatif olarak bu verileri veritabanında saklayabilirsiniz. <strong>Not:</strong>Bu işlem veritabanınızın boyutunu artıracak ve veritabanı taşıma süresini uzatacaktır.\"\n        },\n        \"stash_setup_wizard\": \"Stash Kurulum Sihirbazı\",\n        \"success\": {\n            \"getting_help\": \"Yardım alın\",\n            \"help_links\": \"Hata mesajları, soru-yorum-görüş-önerileriniz için lütfen {githubLink} veya {discordLink} adreslerini kullanarak bizimle iletişime geçin (Şimdilik yalnızca İngilizce).\",\n            \"in_app_manual_explained\": \"Sağ üst köşede bulunan {icon} simgesine basarak Yardım dokümanını incelemeniz tavsiye edilir (Şimdilik yalnızca İngilizce)\",\n            \"next_config_step_one\": \"Sırada Yapılandırma sayfası var. Bu sayfada hangi dosyaların eklenip eklenmeyeceğini belirleyebilir, güvenlik amacıyla kullanıcı adı ve şifre seçebilir ve daha bir çok seçeneği ayarlayabilirsiniz.\",\n            \"next_config_step_two\": \"Herşey tamamsa <code>{localized_task}</code>, sonrasında <code>{localized_scan}</code> düğmelerine tıklayarak arşivinizi Stash'e ekleyebilirsiniz.\",\n            \"open_collective\": \"Stash geliştiricilerine destek sağlamak için {open_collective_link} adresini ziyaret edebilirsiniz.\",\n            \"support_us\": \"Bizi destekleyin\",\n            \"thanks_for_trying_stash\": \"Stash'i denediğiniz için teşekkürler!\",\n            \"welcome_contrib\": \"Ayrıca her türlü kod katkınızı (hata düzeltme, geliştirme, yeni özellikler ekleme), uygulamayla ilgili her türlü görüş-öneri-yorum-sorunuzu bekliyoruz. Ayrıntıları uygulama içindeki Yardım belgesinde bulabilirsiniz.\",\n            \"your_system_has_been_created\": \"Sistem oluşturma başarılı!\",\n            \"download_ffmpeg\": \"Ffmpeg'i indir\",\n            \"missing_ffmpeg\": \"Gerekli <code>ffmpeg</code> ikili dosyası eksik. Aşağıdaki kutuyu işaretleyerek yapılandırma dizininize otomatik olarak indirebilirsiniz. Alternatif olarak, Sistem Ayarlarında <code>ffmpeg</code> ve <code>ffprobe</code> ikili dosyalarına yollar sağlayabilirsiniz. Stash'in çalışması için bu ikili dosyaların mevcut olması gerekir.\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash (<code>config.yml</code>) yapılandırma dosyasını ilk olarak mevcut dizinde bulmaya çalışır. Eğer bulamazsa, <code>$HOME/.stash/config.yml</code> dizinini (Windows işletim sistemi için <code>%USERPROFILE%\\\\.stash\\\\config.yml</code> dizini) araştırır. Öte yandan <code>-c '<path to config file>'</code> veya <code>--config '<path to config file>'</code> seçeneklerini kullanarak özelleştirilmiş bir yapılandırma dosyası da kullanabilirsiniz.\",\n            \"in_current_stash_directory\": \"<code>{path}</code> yolunda:\",\n            \"in_the_current_working_directory\": \"\",\n            \"next_step\": \"Eğer yeni bir sistem oluşturmak için hazırsanız, yapılandırma dosyasının nereye kaydedileceğini seçin ve Sonraki düğmesine basın.\",\n            \"store_stash_config\": \"Stash yapılandırmasını nereye kaydetmek istiyorsunuz?\",\n            \"unable_to_locate_config\": \"Eğer bunu okuyorsanız, Stash herhangi bir mevcut yapılandırma bulamamış demektir. Bu sihirbaz yeni bir yapılandırma sırasında size yol gösterecektir.\",\n            \"unexpected_explained\": \"Eğer beklenmedik bir şekilde bu ekranı gördüyseniz, Stash uygulamasını doğru dizinden başlatın veya başlatma komutuna <code>-c</code> değişkenini ekleyin.\",\n            \"in_the_current_working_directory_disabled\": \"<code>{path}</code> yolundaki çalışma dizini:\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash, yapılandırma dosyası için bu dizini kullanacak: <code>{path}</code>\",\n            \"next_step\": \"Yeni bir sistem oluşturmak için hazır olduğunuzda Sonraki düğmesine basın.\",\n            \"unable_to_locate_specified_config\": \"Eğer bunu okuyorsanız, Stash yapılandırma dosyasını bulamamış demektir. Bu sihirbaz yeni bir yapılandırma sırasında size yol gösterecektir.\"\n        },\n        \"welcome_to_stash\": \"Stash uygulamasına hoşgeldiniz\"\n    },\n    \"stash_id\": \"Stash Kimliği\",\n    \"stash_ids\": \"Stash Kimliği\",\n    \"stats\": {\n        \"image_size\": \"Toplam resim boyutu\",\n        \"scenes_duration\": \"Toplam sahne süresi\",\n        \"scenes_size\": \"Toplam sahne boyutu\",\n        \"total_play_count\": \"Toplam Oynatma Sayısı\",\n        \"total_play_duration\": \"Toplam Oynatma Süresi\",\n        \"scenes_played\": \"Oynatılan Sahneler\",\n        \"total_o_count\": \"Toplam O Sayısı\"\n    },\n    \"status\": \"Durum: {statusText}\",\n    \"studio\": \"Stüdyo\",\n    \"studio_depth\": \"Seviyeler (tümü için boş bırakın)\",\n    \"studios\": \"Stüdyolar\",\n    \"sub_tag_count\": \"Alt Etiket Sayısı\",\n    \"sub_tag_of\": \"{parent} öğesinin alt etiketi\",\n    \"sub_tags\": \"Alt Etiketler\",\n    \"subsidiary_studios\": \"Bağlı Stüdyolar\",\n    \"synopsis\": \"Özet\",\n    \"tag\": \"Etiket\",\n    \"tag_count\": \"Etiket Sayısı\",\n    \"tags\": \"Etiketler\",\n    \"tattoos\": \"Dövmeler\",\n    \"title\": \"Başlık\",\n    \"toast\": {\n        \"added_entity\": \"{entity} eklendi\",\n        \"added_generation_job_to_queue\": \"Oluşturma işlemi kuyruğa eklendi\",\n        \"created_entity\": \"{entity} oluşturuldu\",\n        \"default_filter_set\": \"Varsayılan filtre ayarla\",\n        \"delete_past_tense\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} silindi\",\n        \"generating_screenshot\": \"Ekran görüntüsü oluşturuluyor…\",\n        \"merged_tags\": \"Etiketler birleştirildi\",\n        \"rescanning_entity\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} yeniden taranıyor…\",\n        \"saved_entity\": \"{entity} kaydedildi\",\n        \"started_auto_tagging\": \"Otomatik etiketleme başladı\",\n        \"started_generating\": \"Oluşturma işlemi başladı\",\n        \"started_importing\": \"İçe aktarma başladı\",\n        \"updated_entity\": \"{entity} güncellendi\",\n        \"merged_scenes\": \"Birleştirilmiş sahneler\",\n        \"reassign_past_tense\": \"Dosya yeniden atandı\",\n        \"removed_entity\": \"{count, plural, one {{singularEntity}} other {{pluralEntity}}} kaldırıldı\"\n    },\n    \"total\": \"Toplam\",\n    \"true\": \"Doğru\",\n    \"twitter\": \"Twitter\",\n    \"updated_at\": \"Güncellenme Tarihi\",\n    \"url\": \"URL\",\n    \"videos\": \"Videolar\",\n    \"weight\": \"Kilo\",\n    \"years_old\": \"yaşında\",\n    \"between_and\": \"ve\",\n    \"connection_monitor\": {\n        \"websocket_connection_reestablished\": \"Websocket bağlantısı yeniden kuruldu\",\n        \"websocket_connection_failed\": \"Websocket bağlantısı kurulamıyor: Ayrıntılar için tarayıcı konsoluna bakın\"\n    },\n    \"image_index\": \"Resim #\",\n    \"stash_id_endpoint\": \"Stash ID Uç Noktası\",\n    \"studio_tagger\": {\n        \"add_new_studios\": \"Yeni Stüdyo Ekle\",\n        \"batch_add_studios\": \"Toplu Stüdyo Ekle\",\n        \"config\": {\n            \"create_parent_label\": \"Ana stüdyo oluştur\"\n        },\n        \"batch_update_studios\": \"Stüdyoları Toplu Güncelle\",\n        \"failed_to_save_studio\": \"Stüdyo kaydedilemedi \\\"{studio}\\\"\",\n        \"query_all_studios_in_the_database\": \"Veritabanındaki tüm stüdyolar\",\n        \"status_tagging_studios\": \"Durum: Stüdyolar etiketleniyor\",\n        \"current_page\": \"Geçerli sayfa\",\n        \"create_or_tag_parent_studios\": \"Eksik olan ana stüdyoları oluştur ya da mevcut ana stüdyoları etiketle\",\n        \"no_results_found\": \"Sonuç bulunamadı.\",\n        \"number_of_studios_will_be_processed\": \"{studio_count} stüdyo işlenecek\",\n        \"network_error\": \"Ağ Hatası\",\n        \"refresh_tagged_studios\": \"Etiketli stüdyoları yenile\",\n        \"update_studio\": \"Stüdyoyu Güncelle\",\n        \"studio_already_tagged\": \"Stüdyo zaten etiketlenmiş\",\n        \"update_studios\": \"Stüdyoları Güncelle\",\n        \"studio_selection\": \"Stüdyo seçimi\",\n        \"studio_successfully_tagged\": \"Stüdyo başarıyla etiketlendi\",\n        \"tag_status\": \"Etiket Durumu\",\n        \"untagged_studios\": \"Etiketlenmemiş stüdyolar\",\n        \"updating_untagged_studios_description\": \"Etiketlenmemiş stüdyoları güncellemek, stashid'si olmayan tüm stüdyoları eşleştirmeye ve üstverileri güncellemeye çalışacaktır.\",\n        \"name_already_exists\": \"Ad zaten mevcut\",\n        \"status_tagging_job_queued\": \"Durum: Etiketleme işi sıraya alındı\",\n        \"any_names_entered_will_be_queried\": \"Girilen tüm isimler karşıdaki Stash-Box oturumundan sorgulanacak ve bulunursa eklenecektir. Yalnızca tam eşleşmeler bir eşleşme olarak kabul edilecektir.\",\n        \"refreshing_will_update_the_data\": \"Yenileme işlemi stash-box oturumundaki tüm etiketli stüdyoların verilerini güncelleyecektir.\",\n        \"to_use_the_studio_tagger\": \"Stüdyo etiketleyiciyi kullanmak için bir stash-box oturumunun yapılandırılması gerekir.\"\n    },\n    \"blobs_storage_type\": {\n        \"database\": \"Veritabanı\",\n        \"filesystem\": \"Dosya sistemi\"\n    },\n    \"orientation\": \"Yönelim\",\n    \"package_manager\": {\n        \"add_source\": \"Kaynak Ekle\",\n        \"check_for_updates\": \"Güncelleştirmeleri Denetle\",\n        \"description\": \"Açıklama\",\n        \"edit_source\": \"Kaynağı Düzenle\",\n        \"hide_unselected\": \"Seçili olmayanları gizle\",\n        \"no_packages\": \"Paket bulunamadı\",\n        \"install\": \"Yükle\",\n        \"no_sources\": \"Hiçbir kaynak yapılandırılamadı\",\n        \"installed_version\": \"Kurulu Sürüm\",\n        \"latest_version\": \"Son Sürüm\",\n        \"no_upgradable\": \"Yükseltilebilir paket bulunamadı\",\n        \"package\": \"Paket\",\n        \"show_all\": \"Tümünü göster\",\n        \"source\": {\n            \"name\": \"Ad\",\n            \"url\": \"Kaynak URL'si\",\n            \"local_path\": {\n                \"heading\": \"Yerel Dizin\",\n                \"description\": \"Bu kaynak için paketlerin depolanacağı ilgili yol. Bunu değiştirmenin, paketlerin elle taşınmasını gerektirdiğini unutmayın.\"\n            }\n        },\n        \"uninstall\": \"Kaldır\",\n        \"unknown\": \"<bilinmeyen>\",\n        \"update\": \"Güncelle\",\n        \"version\": \"Sürüm\",\n        \"confirm_delete_source\": \"Kaynağı silmek istediğinize emin misiniz {name} ({url})?\",\n        \"required_by\": \"{packages} için gerekli\",\n        \"confirm_uninstall\": \"{number} paketi kaldırmak istediğinize emin misiniz?\"\n    },\n    \"penis\": \"Penis\",\n    \"penis_length\": \"Penis Uzunluğu\",\n    \"penis_length_cm\": \"Penis Uzunluğu (cm)\",\n    \"performer_age\": \"Oyuncu Yaşı\",\n    \"performer_tagger\": {\n        \"current_page\": \"Geçerli sayfa\",\n        \"network_error\": \"Ağ Hatası\",\n        \"no_results_found\": \"Sonuç bulunamadı.\",\n        \"tag_status\": \"Etiket Durumu\",\n        \"status_tagging_job_queued\": \"Durum: Etiketleme işi kuyruğa alındı\",\n        \"batch_add_performers\": \"Toplu Oyuncu Ekle\",\n        \"batch_update_performers\": \"Toplu Oyuncu Güncelle\",\n        \"failed_to_save_performer\": \"Oyuncu kaydedilemedi \\\"{performer}\\\"\",\n        \"number_of_performers_will_be_processed\": \"{performer_count} oyuncu işlenecek\",\n        \"performer_selection\": \"Oyuncu seçimi\",\n        \"performer_successfully_tagged\": \"Oyuncu başarıyla etiketlendi:\",\n        \"update_performer\": \"Oyuncuyu Güncelle\",\n        \"update_performers\": \"Oyuncuları Güncelle\",\n        \"untagged_performers\": \"Etiketlenmemiş oyuncular\",\n        \"query_all_performers_in_the_database\": \"Veritabanındaki tüm oyuncular\",\n        \"name_already_exists\": \"Ad zaten mevcut\",\n        \"status_tagging_performers\": \"Durum: Oyuncular etiketleniyor\",\n        \"updating_untagged_performers_description\": \"Etiketlenmemiş oyuncuları güncellemek, stashid'si olmayan tüm oyuncuları eşleştirmeye ve üstverileri güncellemeye çalışacaktır.\",\n        \"add_new_performers\": \"Yeni Oyuncular Ekle\",\n        \"refresh_tagged_performers\": \"Etiketlenmiş oyuncuları yenile\",\n        \"performer_already_tagged\": \"Oyuncu zaten etikelenmiş\",\n        \"any_names_entered_will_be_queried\": \"Girilen tüm isimler karşıdaki Stash-Box oturumundan sorgulanacak ve bulunursa eklenecektir. Yalnızca tam eşleşmeler bir eşleşme olarak kabul edilecektir.\",\n        \"refreshing_will_update_the_data\": \"Yenileme işlemi stash-box oturumundaki tüm etiketli oyuncuların verisini güncelleyecektir.\"\n    },\n    \"photographer\": \"Fotoğrafçı\",\n    \"play_count\": \"Oynatma Sayısı\",\n    \"play_duration\": \"Oynatma Süresi\",\n    \"play_history\": \"Oynatma Geçmişi\",\n    \"plays\": \"{value} oynatma\",\n    \"primary_file\": \"Birincil dosya\",\n    \"release_notes\": \"Sürüm Notları\",\n    \"scene_code\": \"Stüdyo Kodu\",\n    \"scene_created_at\": \"Sahne Oluşturulma Tarihi\",\n    \"scene_date\": \"Sahne Tarihi\",\n    \"stashbox\": {\n        \"submission_failed\": \"Gönderim başarısız oldu\",\n        \"submission_successful\": \"Gönderim başarılı\",\n        \"selected_stash_box\": \"Seçilen Stash-Box uç noktası\",\n        \"source\": \"Stash-Box Kaynağı\",\n        \"go_review_draft\": \"Taslağı incelemek için {endpoint_name} adresine gidin.\"\n    },\n    \"statistics\": \"İstatistikler\",\n    \"studio_count\": \"Stüdyo Sayısı\",\n    \"primary_tag\": \"Birincil Etiket\",\n    \"include_sub_studio_content\": \"Alt stüdyo içeriğini dahil et\",\n    \"include_sub_tag_content\": \"Alt etiket içeriğini dahil et\",\n    \"include_sub_groups\": \"Alt grupları dahil et\",\n    \"include_sub_group_content\": \"Alt grup içeriğini dahil et\",\n    \"second\": \"Saniye\",\n    \"file_count\": \"Dosya Sayısı\",\n    \"files_amount\": \"{value} dosya\",\n    \"height_cm\": \"Boy (cm)\",\n    \"errors\": {\n        \"header\": \"Hata\",\n        \"invalid_javascript_string\": \"Geçersiz javascript kodu: {error}\",\n        \"loading_type\": \"{type} yüklenirken hata oluştu\",\n        \"something_went_wrong\": \"Bir şeyler ters gitti.\",\n        \"lazy_component_error_help\": \"Stash'i yakın zamanda güncellediyseniz, lütfen sayfayı yeniden yükleyin ya da tarayıcınızın önbelleğini temizleyin.\",\n        \"invalid_json_string\": \"Geçersiz JSON dizesi: {error}\",\n        \"custom_fields\": {\n            \"duplicate_field\": \"Alan adı benzersiz olmalıdır\",\n            \"field_name_required\": \"Alan adı gereklidir\",\n            \"field_name_whitespace\": \"Alan adının başında veya sonunda boşluk bulunamaz\",\n            \"field_name_length\": \"Alan adı 65 karakterden az olmalıdır\"\n        }\n    },\n    \"sub_group_order\": \"Alt Grup Sırası\",\n    \"validation\": {\n        \"end_time_before_start_time\": \"Bitiş zamanı, başlangıç zamanından büyük veya ona eşit olmalıdır\",\n        \"blank\": \"${path} boş olmamalıdır\",\n        \"date_invalid_form\": \"${path} YYYY-AA-GG biçiminde olmalıdır\",\n        \"required\": \"${path} zorunlu bir alandır\",\n        \"unique\": \"${path} benzersiz olmalıdır\"\n    },\n    \"description\": \"Açıklama\",\n    \"sub_groups\": \"Alt Gruplar\",\n    \"sub_group\": \"Alt Grup\",\n    \"sub_group_count\": \"Alt Grup Sayısı\",\n    \"date_format\": \"YYYY-AA-GG\",\n    \"datetime_format\": \"YYYY-AA-GG SS:DD\",\n    \"folder\": \"Klasör\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Hazır Filtre\",\n            \"saved_filter\": \"Kayıtlı Filtre\"\n        }\n    },\n    \"handy_connection_status\": {\n        \"connecting\": \"Bağlanıyor\",\n        \"disconnected\": \"Bağlantı kesildi\",\n        \"error\": \"Handy'e bağlanırken hata oluştu\",\n        \"ready\": \"Hazır\",\n        \"uploading\": \"Komut dosyası yükleniyor\",\n        \"syncing\": \"Sunucu ile senkronize ediliyor\"\n    },\n    \"o_count\": \"O Sayısı\",\n    \"o_history\": \"O Geçmişi\",\n    \"parent_studio\": \"Ana Stüdyo\",\n    \"perceptual_similarity\": \"Algısal Benzerlik (pHash)\",\n    \"recently_released_objects\": \"Yeni Çıkan {objects}\",\n    \"studio_and_parent\": \"Stüdyo & Ana Stüdyo\",\n    \"studio_tags\": \"Stüdyo Etiketleri\",\n    \"subsidiary_studio_count\": \"Bağlı Stüdyo Sayısı\",\n    \"type\": \"Tür\",\n    \"urls\": \"İnternet Adresleri\",\n    \"unknown_date\": \"Bilinmeyen tarih\",\n    \"video_codec\": \"Görüntü Çözücü\",\n    \"view_all\": \"Tümünü Görüntüle\",\n    \"weight_kg\": \"Ağırlık (kg)\",\n    \"containing_group\": \"İçeren Grup\",\n    \"containing_groups\": \"İçerdiği Gruplar\",\n    \"empty_server\": \"Bu sayfadaki önerileri görüntülemek için sunucunuza bazı sahneler ekleyin.\",\n    \"recently_added_objects\": \"Son Eklenen {objects}\",\n    \"zip_file_count\": \"Zip Dosyası Sayısı\",\n    \"last_played_at\": \"Son Oynatma Tarihi\",\n    \"criterion_modifier_values\": {\n        \"only\": \"Sadece\",\n        \"none\": \"Hiçbiri\",\n        \"any_of\": \"Herhangi biri\"\n    },\n    \"history\": \"Geçmiş\",\n    \"existing_value\": \"mevcut değer\",\n    \"gender\": \"Cinsiyet\",\n    \"gender_types\": {\n        \"FEMALE\": \"Kadın\",\n        \"INTERSEX\": \"İnterseks\",\n        \"MALE\": \"Erkek\",\n        \"NON_BINARY\": \"Non-Binary\",\n        \"TRANSGENDER_FEMALE\": \"Transseksüel Kadın\",\n        \"TRANSGENDER_MALE\": \"Transseksüel Erkek\"\n    },\n    \"group\": \"Grup\",\n    \"group_count\": \"Grup Sayısı\",\n    \"group_scene_number\": \"Sahne Numarası\",\n    \"groups\": \"Gruplar\",\n    \"audio_codec\": \"Ses Kodlayıcı\",\n    \"chapters\": \"Bölümler\",\n    \"circumcised\": \"Sünnet\",\n    \"appears_with\": \"Birlikte Oynadıkları\",\n    \"circumcised_types\": {\n        \"CUT\": \"Sünnetli\",\n        \"UNCUT\": \"Sünnetsiz\"\n    },\n    \"index_of_total\": \"{index}/{total}\",\n    \"performer_favorite\": \"Oyuncu Favorilere Eklendi\",\n    \"scene_updated_at\": \"Sahne Güncelleme Tarihi\",\n    \"ignore_auto_tag\": \"Otomatik Etiketi Yok Say\",\n    \"playdate_recorded_no\": \"Oynatma Tarihi Kaydedilmedi\",\n    \"duplicated_phash\": \"Yinelenen (pHash)\",\n    \"captions\": \"Alt yazılar\",\n    \"containing_group_count\": \"İçerdiği Grup Sayısı\",\n    \"last_o_at\": \"Son O Tarihi\",\n    \"sub_group_of\": \"{parent} öğesinin alt grubu\",\n    \"time_end\": \"Bitiş Zamanı\",\n    \"custom_fields\": {\n        \"value\": \"Değer\",\n        \"field\": \"Alan\",\n        \"title\": \"Özel Alanlar\",\n        \"criteria_format_string\": \"{criterion} (özel alan) {modifierString} {valueString}\"\n    },\n    \"eta\": \"Tahmini Kalan Süre\",\n    \"login\": {\n        \"password\": \"Şifre\",\n        \"internal_error\": \"Beklenmeyen dahili hata. Daha fazla ayrıntı için günlüklere bakın\",\n        \"login\": \"Giriş Yap\",\n        \"username\": \"Kullanıcı Adı\",\n        \"invalid_credentials\": \"Geçersiz kullanıcı adı veya şifre\"\n    },\n    \"age_on_date\": \"Videoda {age} yaşında\",\n    \"time\": \"Başlangıç Zamanı\",\n    \"disambiguation\": \"Ad Ayrımı\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/uk-UA.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"Додати\",\n        \"add_directory\": \"Додати каталог\",\n        \"add_entity\": \"Додати {entityType}\",\n        \"add_to_entity\": \"Додати до {entityType}\",\n        \"allow\": \"Дозволити\",\n        \"allow_temporarily\": \"Дозволити тимчасово\",\n        \"apply\": \"Застосувати\",\n        \"auto_tag\": \"Авто тегування\",\n        \"backup\": \"Резервне копіювання\",\n        \"browse_for_image\": \"Вибрати зображення…\",\n        \"cancel\": \"Скасувати\",\n        \"clean\": \"Очистити\",\n        \"clear\": \"Очистити\",\n        \"clear_back_image\": \"Очистити заднє зображення\",\n        \"clear_front_image\": \"Очистити переднє зображення\",\n        \"clear_image\": \"Очистити зображення\",\n        \"close\": \"Закрити\",\n        \"confirm\": \"Підтвердити\",\n        \"continue\": \"Продовжити\",\n        \"create\": \"Створити\",\n        \"create_entity\": \"Створити {entityType}\",\n        \"create_marker\": \"Створити позначку\",\n        \"created_entity\": \"{entity_type} {entity_name} створено\",\n        \"customise\": \"Налаштувати\",\n        \"delete\": \"Видалити\",\n        \"delete_entity\": \"Видалити {entityType}\",\n        \"delete_file\": \"Видалити файл\",\n        \"delete_file_and_funscript\": \"Видалити файл (і фанскрипт)\",\n        \"delete_generated_supporting_files\": \"Видалити згенеровані допоміжні файли\",\n        \"disallow\": \"Заборонити\",\n        \"download\": \"Завантажити\",\n        \"download_backup\": \"Завантажити резервну копію\",\n        \"edit\": \"Редагувати\",\n        \"edit_entity\": \"Редагувати {entityType}\",\n        \"export\": \"Експортувати\",\n        \"export_all\": \"Експортувати все…\",\n        \"find\": \"Знайти\",\n        \"finish\": \"Завершити\",\n        \"from_file\": \"З файлу…\",\n        \"from_url\": \"З URL…\",\n        \"full_export\": \"Повний експорт\",\n        \"full_import\": \"Повний імпорт\",\n        \"generate\": \"Згенерувати\",\n        \"generate_thumb_default\": \"Згенерувати стандартну мініатюру\",\n        \"generate_thumb_from_current\": \"Згенерувати мініатюру з поточного\",\n        \"hash_migration\": \"міграція хешу\",\n        \"hide\": \"Приховати\",\n        \"hide_configuration\": \"Приховати налаштування\",\n        \"identify\": \"Розпізнати\",\n        \"ignore\": \"Ігнорувати\",\n        \"import\": \"Імпортувати…\",\n        \"import_from_file\": \"Імпортувати з файлу\",\n        \"logout\": \"Вийти\",\n        \"make_primary\": \"Зробити головним\",\n        \"merge\": \"Об'єднати\",\n        \"next_action\": \"Наступний\",\n        \"not_running\": \"не працює\",\n        \"open_in_external_player\": \"Відкрити у зовнішньому програвачі\",\n        \"open_random\": \"Відкрити випадкове\",\n        \"overwrite\": \"Перезаписати\",\n        \"play_random\": \"Відтворити випадкове\",\n        \"play_selected\": \"Відтворити вибране\",\n        \"preview\": \"Попередній перегляд\",\n        \"previous_action\": \"Назад\",\n        \"refresh\": \"Оновити\",\n        \"reload_plugins\": \"Перезавантажити плагіни\",\n        \"reload_scrapers\": \"Перезавантажити скрейпери\",\n        \"remove\": \"Вилучити\",\n        \"remove_from_gallery\": \"Вилучити з галереї\",\n        \"rename_gen_files\": \"Перейменувати згенеровані файли\",\n        \"rescan\": \"Пересканувати\",\n        \"reshuffle\": \"Перемішати\",\n        \"running\": \"працює\",\n        \"save\": \"Зберегти\",\n        \"save_delete_settings\": \"Використовувати ці параметри видалення за замовчуванням\",\n        \"save_filter\": \"Зберегти фільтр\",\n        \"scan\": \"Сканувати\",\n        \"scrape\": \"Скрейпити\",\n        \"scrape_query\": \"Скрейп-запит\",\n        \"scrape_scene_fragment\": \"Скрейпити за фрагментом\",\n        \"scrape_with\": \"Скрейпити за допомогою…\",\n        \"search\": \"Пошук\",\n        \"select_all\": \"Вибрати все\",\n        \"select_entity\": \"Вибрати {entityType}\",\n        \"select_folders\": \"Вибрати папки\",\n        \"select_none\": \"Зняти виділення\",\n        \"selective_auto_tag\": \"Вибіркове автотегування\",\n        \"selective_clean\": \"Вибіркове очищення\",\n        \"selective_scan\": \"Вибіркове сканування\",\n        \"set_as_default\": \"Зробити типовим\",\n        \"set_image\": \"Встановити зображення…\",\n        \"show\": \"Показати\",\n        \"show_configuration\": \"Показати конфігурацію\",\n        \"skip\": \"Пропустити\",\n        \"stop\": \"Зупинити\",\n        \"submit\": \"Надіслати\",\n        \"submit_update\": \"Надіслати оновлення\",\n        \"temp_disable\": \"Тимчасово вимкнути…\",\n        \"temp_enable\": \"Тимчасово увімкнути…\",\n        \"use_default\": \"Використовувати типове\",\n        \"view_random\": \"Переглянути випадкове\",\n        \"swap\": \"Поміняти місцями\",\n        \"add_sub_groups\": \"Додати підгрупи\",\n        \"encoding_image\": \"Кодування зображення…\",\n        \"migrate_scene_screenshots\": \"Мігрувати скріншоти сцен\",\n        \"optimise_database\": \"Оптимізувати базу даних\",\n        \"add_manual_date\": \"Вказати дату вручну\",\n        \"add_o\": \"Додати оргазм\",\n        \"add_play\": \"Додати перегляд\",\n        \"anonymise\": \"Анонімізувати\",\n        \"create_chapters\": \"Створити розділ\",\n        \"create_parent_studio\": \"Створити батьківську студію\",\n        \"download_anonymised\": \"Завантажити анонімно\",\n        \"enable\": \"Увімкнути\",\n        \"disable\": \"Вимкнути\",\n        \"migrate_blobs\": \"Мігрувати Blobs\",\n        \"reload\": \"Перезавантажити\",\n        \"remove_date\": \"Видалити дату\",\n        \"split\": \"Розділити\",\n        \"submit_stash_box\": \"Надіслати до Stash-Box\",\n        \"view_history\": \"Переглянути історію\",\n        \"reassign\": \"Перепризначити\",\n        \"set_cover\": \"Встановити як обкладинку\",\n        \"reset_play_duration\": \"Скинути тривалість відтворення\",\n        \"reset_resume_time\": \"Скинути час відновлення\",\n        \"reset_cover\": \"Відновити обкладинку за замовчуванням\",\n        \"tasks\": {\n            \"import_warning\": \"Ви впевнені, що хочете імпортувати? Це видалить базу даних і виконає повторний імпорт з ваших експортованих метаданих.\",\n            \"clean_confirm_message\": \"Ви впевнені, що бажаєте виконати очищення? Це видалить інформацію з бази даних та згенерований вміст для всіх сцен і галерей, які більше не знайдені у файловій системі.\",\n            \"dry_mode_selected\": \"Вибрано тестовий режим. Видалення не буде виконано, лише запис у журнал.\"\n        },\n        \"remove_from_containing_group\": \"Вилучити з групи\",\n        \"choose_date\": \"Обрати дату\",\n        \"assign_stashid_to_parent_studio\": \"Присвоїти Stash ID наявній батьківській студії та оновити метадані\",\n        \"clean_generated\": \"Очистити згенеровані файли\",\n        \"clear_date_data\": \"Очистити дату\",\n        \"copy_to_clipboard\": \"Копіювати в буфер обміну\",\n        \"set_back_image\": \"Заднє зображення…\",\n        \"set_front_image\": \"Переднє зображення…\",\n        \"unset\": \"Зняти\",\n        \"load\": \"Завантажити\",\n        \"load_filter\": \"Завантажити фільтр\",\n        \"play\": \"Відтворити\",\n        \"show_results\": \"Показати результати\",\n        \"show_count_results\": \"Показати {count} результатів\",\n        \"sidebar\": {\n            \"close\": \"Сховати бічну панель\",\n            \"open\": \"Відкрити бічну панель\",\n            \"toggle\": \"Перемкнути бічну панель\"\n        },\n        \"add_stash_id\": \"Додати Stash ID\",\n        \"create_new\": \"Створити нове\",\n        \"save_and_new\": \"Зберегти та створити\",\n        \"invert_selection\": \"Інвертувати виділення\",\n        \"select_directory\": \"Вибрати каталог\",\n        \"reveal_in_file_manager\": \"Показати в файловому менеджері\",\n        \"selective_generate\": \"Вибіркове генерування\",\n        \"from_clipboard\": \"З буфера обміну\",\n        \"exclude_lowercase\": \"виключити\",\n        \"create_parent_tag\": \"Створити головний тег\"\n    },\n    \"actions_name\": \"Дії\",\n    \"age\": \"Вік\",\n    \"aliases\": \"Псевдоніми\",\n    \"all\": \"всі\",\n    \"also_known_as\": \"Також відомий як\",\n    \"birth_year\": \"Рік народження\",\n    \"birthdate\": \"Дата народження\",\n    \"bitrate\": \"Бітрейт\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"Активний екземпляр stash-box:\",\n            \"blacklist_label\": \"Чорний список\",\n            \"query_mode_auto\": \"Авто\",\n            \"query_mode_auto_desc\": \"Використовує метадані, якщо вони є, або назву файлу\",\n            \"query_mode_dir_desc\": \"Використовує лише батьківський каталог відеофайлу\",\n            \"query_mode_filename\": \"Назва файлу\",\n            \"query_mode_filename_desc\": \"Використовує лише назву файлу\",\n            \"query_mode_metadata\": \"Метадані\",\n            \"query_mode_metadata_desc\": \"Використовує лише метадані\",\n            \"query_mode_path\": \"Шлях\",\n            \"query_mode_path_desc\": \"Використовує повний шлях до файлу\",\n            \"set_tag_label\": \"Встановити теги\",\n            \"source\": \"Джерело\",\n            \"blacklist_desc\": \"Елементи чорного списку виключаються із запитів. Зауважте, що це регулярні вирази, нечутливі до регістру. Певні символи необхідно екранувати зворотним слешем: {chars_require_escape}\",\n            \"mark_organized_desc\": \"Негайно позначати сцену як організовану після натискання кнопки Зберегти.\",\n            \"mark_organized_label\": \"Позначати як організоване під час збереження\",\n            \"query_mode_dir\": \"Каталог\",\n            \"query_mode_label\": \"Режим запиту\",\n            \"set_tag_desc\": \"Додати теги до сцени, перезаписуючи або об'єднуючи з існуючими тегами.\",\n            \"set_cover_desc\": \"Замінити обкладинку сцени, якщо вона знайдена.\",\n            \"set_cover_label\": \"Встановити обкладинку сцени\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Дублікат у чорному списку\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"Стать виконавців\",\n                \"description\": \"Виконавці з обраною статтю будуть показані під час тегування сцен.\"\n            }\n        },\n        \"results\": {\n            \"duration_unknown\": \"Тривалість невідома\",\n            \"match_failed_no_result\": \"Результатів не знайдено\",\n            \"phash_matches\": \"{count} PHashes збігаються\",\n            \"unnamed\": \"Без назви\",\n            \"duration_off\": \"Тривалість відрізняється щонайменше на {number}с\",\n            \"fp_matches\": \"Тривалість збігається\",\n            \"fp_matches_multi\": \"Тривалість збігається з {matchCount}/{durationsLength} відбитками\",\n            \"hash_matches\": \"{hash_type} збігається\",\n            \"match_failed_already_tagged\": \"Сцена вже має теги\",\n            \"match_success\": \"Теги успішно додано до сцени\",\n            \"fp_found\": \"{fpCount, plural, =0 {Нових збігів відбитків не знайдено} other {Знайдено # нових збігів відбитків}}\"\n        },\n        \"verb_match_fp\": \"Зіставити відбитки\",\n        \"verb_matched\": \"Зіставлено\",\n        \"verb_scrape_all\": \"Скрейпити все\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} незіставлені сцени\",\n        \"verb_submit_fp\": \"Надіслати {fpCount, plural, one{відбиток} few{відбитки} other{відбитків}}\",\n        \"noun_query\": \"Запит\",\n        \"verb_add_as_alias\": \"Додати отриману назву як псевдонім\",\n        \"verb_link_existing\": \"Прив'язати до наявного\",\n        \"verb_match_tag\": \"Зіставити тег\",\n        \"verb_scrape_selected\": \"Скрейпити вибране\"\n    },\n    \"config\": {\n        \"about\": {\n            \"check_for_new_version\": \"Перевірити наявність нової версії\",\n            \"latest_version\": \"Остання Версія\",\n            \"stash_discord\": \"Приєднуйтесь до нашого каналу в {url}\",\n            \"stash_open_collective\": \"Підтримайте нас через {url}\",\n            \"version\": \"Версія\",\n            \"release_date\": \"Дата випуску:\",\n            \"stash_home\": \"Домашня сторінка Stash: {url}\",\n            \"build_time\": \"Час збірки:\",\n            \"build_hash\": \"Хеш збірки:\",\n            \"latest_version_build_hash\": \"Хеш збірки останньої версії:\",\n            \"new_version_notice\": \"[НОВЕ]\",\n            \"stash_wiki\": \"Сторінка Stash на {url}\"\n        },\n        \"categories\": {\n            \"changelog\": \"Список змін\",\n            \"interface\": \"Інтерфейс\",\n            \"logs\": \"Журнали\",\n            \"metadata_providers\": \"Джерела метаданих\",\n            \"plugins\": \"Плагіни\",\n            \"security\": \"Безпека\",\n            \"services\": \"Сервіси\",\n            \"system\": \"Система\",\n            \"tasks\": \"Завдання\",\n            \"tools\": \"Інструменти\",\n            \"about\": \"Про програму\",\n            \"scraping\": \"Скрейпінг\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Дозволити {tempIP}\",\n            \"allowed_ip_addresses\": \"Дозволені IP-адреси\",\n            \"allowed_ip_temporarily\": \"Тимчасово дозволені IP\",\n            \"default_ip_whitelist\": \"Білий список IP за замовчуванням\",\n            \"default_ip_whitelist_desc\": \"IP-адреси за замовчуванням для доступу до DLNA. Використовуйте {wildcard}, щоб дозволити всі IP-адреси.\",\n            \"disallowed_ip\": \"Заборонені IP\",\n            \"enabled_by_default\": \"Увімкнено за замовчуванням\",\n            \"network_interfaces\": \"Мережеві інтерфейси\",\n            \"recent_ip_addresses\": \"Недавні IP-адреси\",\n            \"server_display_name_desc\": \"Відображуване ім'я для сервера DLNA. Якщо порожнє, використовується {server_name}.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Тимчасовий режим успішно скасовано\",\n            \"until_restart\": \"до рестарту\",\n            \"enabled_dlna_temporarily\": \"DLNA тимчасово увімкнено\",\n            \"disabled_dlna_temporarily\": \"DLNA тимчасово вимкнено\",\n            \"server_display_name\": \"Відображуване ім'я сервера\",\n            \"server_port\": \"Порт сервера\",\n            \"network_interfaces_desc\": \"Інтерфейси, на яких працюватиме сервер DLNA. Порожній список означає запуск на всіх інтерфейсах. Зміна потребує перезапуску DLNA.\",\n            \"server_port_desc\": \"Порт для роботи сервера DLNA. Зміна потребує перезапуску DLNA.\",\n            \"video_sort_order_desc\": \"Порядок сортування відео за замовчуванням.\",\n            \"video_sort_order\": \"Порядок сортування відео за замовчуванням\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API-ключ\",\n                \"api_key_desc\": \"API-ключ для зовнішніх систем. Потрібен лише якщо ім'я користувача/пароль налаштовані. Ім'я користувача має бути збережена перед генерацією API-ключа.\",\n                \"authentication\": \"Аутентифікація\",\n                \"clear_api_key\": \"Видалити API-ключ\",\n                \"generate_api_key\": \"Згенерувати API-ключ\",\n                \"log_file\": \"Файл логів\",\n                \"log_file_desc\": \"Шлях до файлу, в який будуть записуватись логи. Залишити пустим, щоб відключити логування в файл. Потребує перезапуску.\",\n                \"log_http\": \"Логувати HTTP-доступ\",\n                \"log_http_desc\": \"Логувати HTTP-дії до терміналу. Потребує перезапуску.\",\n                \"log_to_terminal\": \"Логувати до терміналу\",\n                \"log_to_terminal_desc\": \"Логи в термінал в додаток до логування в файл. Завжди вмикнено якщо вимкнено логування до файлу. Потребує перезапуску.\",\n                \"maximum_session_age\": \"Максимальній вік сессії\",\n                \"password\": \"Пароль\",\n                \"stash-box_integration\": \"Інтеграція stash-box\",\n                \"username\": \"Ім'я користувача\",\n                \"username_desc\": \"Ім'я користувача для доступу до Stash. Залишіть пустим, щоб вимкнути аутентифікацію\",\n                \"maximum_session_age_desc\": \"Максимальний час простою до завершення сесії входу, в секундах. Потрібен перезапуск.\",\n                \"credentials\": {\n                    \"description\": \"Облікові дані для обмеження доступу до Stash.\",\n                    \"heading\": \"Облікові дані\"\n                },\n                \"password_desc\": \"Пароль для доступу до Stash. Залиште порожнім, щоб вимкнути аутентифікацію користувача\",\n                \"log_file_max_size\": \"Максимальний розмір журналу\",\n                \"log_file_max_size_desc\": \"Максимальний розмір файлу журналу в мегабайтах перед стисненням. 0 МБ — вимкнено. Потребує перезапуску.\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"Директорія для зберігання резервних копій бази даних SQLite.\",\n                \"heading\": \"Шлях до директорії з резервними копіями\"\n            },\n            \"cache_location\": \"Розташування каталогу кешу. Обов'язково, якщо використовується потокова передача через HLS (наприклад, на пристроях Apple) або DASH.\",\n            \"cache_path_head\": \"Шлях кешу\",\n            \"calculate_md5_and_ohash_label\": \"Рахувати контрольну суму MD5 для відео\",\n            \"check_for_insecure_certificates\": \"Перевірити на ненадійні сертифікати\",\n            \"chrome_cdp_path\": \"Шлях до Chrome CDP\",\n            \"chrome_cdp_path_desc\": \"Шлях до виконуваного файлу Chrome, або видаленна адреса (починається з http:// або https://, наприклад, http://localhost:9222/json/version) до екземпляру Chrome.\",\n            \"create_galleries_from_folders_desc\": \"Якщо значення true, за замовчуванням створює галереї з папок, що містять зображення. Створіть файл з назвою .forcegallery або .nogallery у папці, щоб перезаписати це налаштування.\",\n            \"create_galleries_from_folders_label\": \"Створити галереї з папок із зображеннями\",\n            \"db_path_head\": \"Шлях до бази даних\",\n            \"excluded_image_gallery_patterns_head\": \"Виключені паттерни Зображень/Галерей\",\n            \"excluded_video_patterns_head\": \"Виключені паттерни для відео\",\n            \"gallery_ext_desc\": \"Розділений комою список розширень файлів, які можуть бути ідентифіковані, як ZIP-файли галерей.\",\n            \"gallery_ext_head\": \"Розширення ZIP-файлів з галереями\",\n            \"generated_path_head\": \"Згенерований Шлях\",\n            \"image_ext_desc\": \"Розділений комою список розширень файлів, які можуть бути ідентифікованими, як зображення.\",\n            \"image_ext_head\": \"Розширення зображень\",\n            \"include_audio_head\": \"Включити аудіо\",\n            \"metadata_path\": {\n                \"heading\": \"Шлях до мета-інформації\",\n                \"description\": \"Розташування каталогу, що використовується під час виконання повного експорту або імпорту.\"\n            },\n            \"number_of_parallel_task_for_scan_generation_head\": \"Кількість паралельних задач для сканування/генерації\",\n            \"parallel_scan_head\": \"Паралельне сканування/генерація\",\n            \"preview_generation\": \"Генерація прев'ю\",\n            \"scraper_user_agent_desc\": \"Рядок User-Agent, що використовується під час виконання HTTP-запитів сканування.\",\n            \"scrapers_path\": {\n                \"description\": \"Розташування каталогу файлів конфігурації сканерів.\",\n                \"heading\": \"Шлях до сканерів\"\n            },\n            \"scraping\": \"Збирання даних\",\n            \"generated_file_naming_hash_desc\": \"Використовувати MD5 або oshash для іменування згенерованих файлів. Зміна цього параметра вимагає, щоб усі сцени мали заповнене відповідне значення MD5/oshash. Після зміни цього значення існуючі згенеровані файли потрібно буде мігрувати або згенерувати заново. Дивіться сторінку «Завдання» для міграції.\",\n            \"sqlite_location\": \"Розташування файлу для бази даних SQLite (вимагає перезапуску). УВАГА: зберігання бази даних на іншій системі, ніж сервер Stash (наприклад, через мережу), не підтримується!\",\n            \"blobs_storage\": {\n                \"description\": \"Де зберігати бінарні дані, такі як обкладинки сцен, зображення виконавців, студій і тегів. Після зміни цього значення існуючі дані потрібно мігрувати, використовуючи завдання «Міграція Blobs». Дивіться сторінку «Завдання» для виконання міграції.\",\n                \"heading\": \"Тип зберігання бінарних даних\"\n            },\n            \"blobs_path\": {\n                \"description\": \"Де у файловій системі зберігати двійкові дані. Застосовується лише при використанні типу зберігання блобів Filesystem. УВАГА: зміна цього параметра вимагає ручного переміщення існуючих даних.\",\n                \"heading\": \"Шлях до файлової системи бінарних даних\"\n            },\n            \"calculate_md5_and_ohash_desc\": \"Обчислювати контрольну суму MD5 додатково до oshash. Увімкнення цієї функції сповільнить початкове сканування. Хеш для назви файлу повинен бути встановлений на oshash, щоб вимкнути обчислення MD5.\",\n            \"ffmpeg\": {\n                \"ffmpeg_path\": {\n                    \"description\": \"Шлях до виконуваного файлу ffmpeg (а не лише до папки). Якщо залишити порожнім, ffmpeg буде визначено з середовища через $PATH, каталог конфігурації або $HOME/.stash.\",\n                    \"heading\": \"Шлях до виконуваного файлу FFmpeg\"\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"Завантажує FFmpeg до каталогу конфігурації та очищує шляхи ffmpeg і ffprobe, щоб вони визначалися з каталогу конфігурації.\",\n                    \"heading\": \"Завантажити FFmpeg\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Шлях до виконуваного файлу ffprobe (а не лише до папки). Якщо залишити порожнім, ffprobe буде визначено з середовища через $PATH, каталог конфігурації або $HOME/.stash.\",\n                    \"heading\": \"Шлях до виконуваного файлу FFprobe\"\n                },\n                \"transcode\": {\n                    \"output_args\": {\n                        \"desc\": \"Розширені: Додаткові аргументи для передачі ffmpeg перед полем виводу під час генерації відео.\",\n                        \"heading\": \"Вихідні аргументи для транскодування FFmpeg\"\n                    },\n                    \"input_args\": {\n                        \"desc\": \"Розширені: Додаткові аргументи для передачі ffmpeg перед полем вводу під час генерації відео.\",\n                        \"heading\": \"Вхідні аргументи для транскодування FFmpeg\"\n                    }\n                },\n                \"hardware_acceleration\": {\n                    \"desc\": \"Використовує доступне обладнання для кодування відео для живого транскодування.\",\n                    \"heading\": \"Апаратне кодування FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Розширені: Додаткові аргументи для передачі ffmpeg перед полем вводу під час живого транскодування відео.\",\n                        \"heading\": \"Вхідні аргументи для Live Transcode FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"Розширені: Додаткові аргументи для передачі ffmpeg перед полем виводу під час живого транскодування відео.\",\n                        \"heading\": \"Вихідні аргументи для Live Transcode FFmpeg\"\n                    }\n                }\n            },\n            \"funscript_heatmap_draw_range_desc\": \"Відобразити діапазон руху на осі y створеної теплової карти. Існуючі теплові карти потрібно буде перегенерувати після зміни.\",\n            \"maximum_transcode_size_desc\": \"Максимальний розмір для згенерованих транскодувань.\",\n            \"generated_files_location\": \"Розташування каталогу для згенерованих файлів (позначки сцен, попередні перегляди сцен, спрайти тощо).\",\n            \"maximum_transcode_size_head\": \"Максимальний розмір транскодування\",\n            \"check_for_insecure_certificates_desc\": \"Деякі сайти використовують ненадійні SSL-сертифікати. Якщо цей прапорець знято, скрейпер пропускає перевірку ненадійних сертифікатів і дозволяє скрейпінг таких сайтів. Якщо ви отримуєте помилку сертифіката під час скрейпінгу, зніміть цей прапорець.\",\n            \"excluded_image_gallery_patterns_desc\": \"Регулярні вирази для файлів/шляхів зображень і галерей, які потрібно виключити зі сканування та додати до очищення.\",\n            \"directory_locations_to_your_content\": \"Розташування каталогів із вашим контентом\",\n            \"database\": \"База даних\",\n            \"excluded_video_patterns_desc\": \"Регулярні вирази для відеофайлів/шляхів, які потрібно виключити зі сканування та додати до очищення.\",\n            \"funscript_heatmap_draw_range\": \"Включити діапазон у згенеровані теплові карти\",\n            \"gallery_cover_regex_desc\": \"Регулярний вираз, що використовується для ідентифікації зображення як обкладинки галереї.\",\n            \"gallery_cover_regex_label\": \"Шаблон обкладинки галереї\",\n            \"generated_file_naming_hash_head\": \"Геш імені згенерованого файлу\",\n            \"include_audio_desc\": \"Включати аудіопотік під час створення попередніх переглядів.\",\n            \"maximum_streaming_transcode_size_desc\": \"Максимальний розмір для транскодованих потоків.\",\n            \"logging\": \"Журналювання\",\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Встановіть 0 для автоматичного визначення. Увага: виконання більшої кількості завдань, ніж потрібно для досягнення 100% завантаження CPU, знижує продуктивність і може викликати інші проблеми.\",\n            \"python_path\": {\n                \"description\": \"Шлях до виконуваного файлу python (а не лише до папки). Використовується для скриптів-збирачів та плагінів. Якщо залишити порожнім, python буде визначено з середовища.\",\n                \"heading\": \"Шлях до виконуваного файлу Python\"\n            },\n            \"plugins_path\": {\n                \"description\": \"Розташування каталогу файлів конфігурації плагінів.\",\n                \"heading\": \"Шлях до плагінів\"\n            },\n            \"video_ext_head\": \"Розширення відео\",\n            \"hashing\": \"Гешування\",\n            \"heatmap_generation\": \"Генерація теплової карти для Funscript\",\n            \"maximum_streaming_transcode_size_head\": \"Максимальний розмір транскодування для стрімінгу\",\n            \"video_head\": \"Відео\",\n            \"scraper_user_agent\": \"Користувацький агент-скрейпер\",\n            \"video_ext_desc\": \"Список розширень файлів, розділений комами, які будуть ідентифіковані як відео.\",\n            \"delete_trash_path\": {\n                \"description\": \"Шлях, куди переміщуватимуться видалені файли замість остаточного видалення. Залиште порожнім, щоб видаляти файли назавжди.\",\n                \"heading\": \"Шлях до смітника\"\n            },\n            \"sprite_generation_head\": \"Генерація спрайтів\",\n            \"sprite_interval_desc\": \"Інтервал між кадрами спрайтів (у секундах).\",\n            \"sprite_interval_head\": \"Інтервал спрайтів\",\n            \"sprite_maximum_desc\": \"Максимальна кількість спрайтів, яку можна згенерувати для сцени. Встановіть значення 0, щоб вимкнути обмеження.\",\n            \"sprite_maximum_head\": \"Максимальна кількість спрайтів\",\n            \"sprite_minimum_desc\": \"Мінімальна кількість спрайтів, яку потрібно згенерувати для сцени\",\n            \"sprite_minimum_head\": \"Мінімальна кількість спрайтів\",\n            \"sprite_screenshot_size_desc\": \"Бажаний розмір кожного спрайта в пікселях.\",\n            \"sprite_screenshot_size_head\": \"Розмір спрайта\",\n            \"use_custom_sprite_interval_head\": \"Використовувати власний інтервал спрайтів\",\n            \"use_custom_sprite_interval_desc\": \"Увімкніть користувацький інтервал спрайтів відповідно до налаштувань нижче.\"\n        },\n        \"advanced_mode\": \"Розширений режим\",\n        \"tasks\": {\n            \"identify\": {\n                \"source_options\": \"Опції {source}\",\n                \"strategy\": \"Стратегія\",\n                \"and_create_missing\": \"та створити відсутні\",\n                \"description\": \"Автоматично встановлювати метадані сцени, використовуючи джерела Stash-box та скрейперів.\",\n                \"create_missing\": \"Створити відсутні\",\n                \"default_options\": \"Опції за замовчуванням\",\n                \"explicit_set_description\": \"Наступні параметри будуть використані там, де вони не перевизначені в налаштуваннях конкретного джерела.\",\n                \"field\": \"Поле\",\n                \"heading\": \"Ідентифікація\",\n                \"identifying_from_paths\": \"Ідентифікація сцен за наступними шляхами\",\n                \"identifying_scenes\": \"Ідентифікація {num} {scene}\",\n                \"include_male_performers\": \"Включати виконавців-чоловіків\",\n                \"skip_multiple_matches_tooltip\": \"Якщо вимкнено і знайдено більше одного результату, буде випадково обрано один для зіставлення\",\n                \"skip_multiple_matches\": \"Пропускати збіги, які мають більше одного результату\",\n                \"source\": \"Джерело\",\n                \"skip_single_name_performers_tooltip\": \"Якщо вимкнено, виконавці з поширеними іменами (наприклад, Samantha або Olga) будуть зіставлені\",\n                \"skip_single_name_performers\": \"Пропускати виконавців з одним ім'ям без уточнень\",\n                \"tag_skipped_matches_tooltip\": \"Створіть тег (наприклад, 'Identify: Multiple Matches'), за яким можна фільтрувати у Scene Tagger для ручного вибору правильного збігу\",\n                \"tag_skipped_performer_tooltip\": \"Створіть тег (наприклад, 'Identify: Single Name Performer'), за яким можна фільтрувати у Scene Tagger для вибору способу обробки таких виконавців\",\n                \"sources\": \"Джерела\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field_options\": \"Опції полів\",\n                \"tag_skipped_matches\": \"Позначити пропущені збіги тегом\",\n                \"set_organized\": \"Встановити мітку \\\"Впорядковано\\\"\",\n                \"set_cover_images\": \"Встановити обкладинки\",\n                \"tag_skipped_performers\": \"Позначити пропущених виконавців тегом\",\n                \"performer_genders\": \"Стать виконавців\",\n                \"performer_genders_desc\": \"Виконавців вибраної статі буде включено під час ідентифікації.\"\n            },\n            \"generate_previews_during_scan_tooltip\": \"Також генерувати анімовані (webp) попередні перегляди. Це потрібно лише тоді, коли тип попереднього перегляду для стіни сцен/позначок встановлено як \\\"Анімоване зображення\\\". Під час перегляду вони використовують менше ресурсів ЦП, ніж відео-прев’ю, але створюються додатково до них і займають більше місця.\",\n            \"anonymise_database\": \"Створює копію бази даних у каталозі резервних копій, анонімізуючи всі чутливі дані. Цю копію можна надати іншим для діагностики та налагодження. Оригінальна база даних не змінюється. Анонімізована база даних використовує формат імені файлу {filename_format}.\",\n            \"generate_video_previews_during_scan\": \"Генерувати попередні перегляди\",\n            \"generate_sprites_during_scan_tooltip\": \"Набір зображень, що відображаються під відеоплеєром для зручної навігації.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Встановити назву, дату та деталі з вбудованих метаданих файлу\",\n            \"anonymise_and_download\": \"Створює анонімізовану копію бази даних та завантажує отриманий файл.\",\n            \"added_job_to_queue\": \"Додано {operation_name} до черги завдань\",\n            \"anonymising_database\": \"Анонімізація бази даних\",\n            \"auto_tag_based_on_filenames\": \"Автоматично тегувати контент на основі шляхів до файлів.\",\n            \"auto_tag\": {\n                \"auto_tagging_paths\": \"Автотегування наступних шляхів\",\n                \"auto_tagging_all_paths\": \"Автотегування всіх шляхів\"\n            },\n            \"auto_tagging\": \"Автотегування\",\n            \"backing_up_database\": \"Резервне копіювання бази даних\",\n            \"backup_and_download\": \"Виконує резервне копіювання бази даних і завантажує отриманий файл.\",\n            \"clean_generated\": {\n                \"description\": \"Видаляє згенеровані файли, які не мають відповідного запису в базі даних.\",\n                \"blob_files\": \"Blob-файли\",\n                \"image_thumbnails\": \"Мініатюри зображень\",\n                \"markers\": \"Попередні перегляди позначок\",\n                \"image_thumbnails_desc\": \"Мініатюри зображень та кліпи\",\n                \"sprites\": \"Спрайти сцен\",\n                \"previews_desc\": \"Попередні перегляди сцен та мініатюри\",\n                \"previews\": \"Попередні перегляди сцен\",\n                \"transcodes\": \"Транскодовані файли сцен\"\n            },\n            \"cleanup_desc\": \"Перевірити наявність відсутніх файлів та видалити їх з бази даних. Це необоротна дія.\",\n            \"defaults_set\": \"Налаштування за замовчуванням збережено. Вони будуть використані при натисканні кнопки {action} на сторінці завдань.\",\n            \"empty_queue\": \"Наразі немає активних завдань.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Не включати розширення файлу до заголовка\",\n            \"export_to_json\": \"Експортує вміст бази даних у формат JSON у каталозі метаданих.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Генерація для сцен із наступних шляхів\",\n                \"generating_scenes\": \"Генерація для {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"Генерувати попередній перегляд для кліпів із зображеннями\",\n            \"generate_desc\": \"Генерувати допоміжні зображення, спрайти, відео, vtt та інші файли.\",\n            \"generate_phashes_during_scan\": \"Генерувати перцептуальні хеші відео\",\n            \"generate_phashes_during_scan_tooltip\": \"Для дедуплікації та ідентифікації сцен.\",\n            \"generate_previews_during_scan\": \"Генерувати анімований попередній перегляд зображень\",\n            \"generate_sprites_during_scan\": \"Генерувати спрайти для смуги прокрутки\",\n            \"generate_video_covers_during_scan\": \"Генерувати обкладинки сцен\",\n            \"generate_video_previews_during_scan_tooltip\": \"Генерувати попередні перегляди відео, які відтворюються при наведенні на сцену\",\n            \"generate_thumbnails_during_scan\": \"Генерувати мініатюри для зображень\",\n            \"generated_content\": \"Згенерований контент\",\n            \"maintenance\": \"Обслуговування\",\n            \"incremental_import\": \"Інкрементальний імпорт із наданого ZIP-архіву експорту.\",\n            \"import_from_exported_json\": \"Імпорт з експортованого JSON у каталозі метаданих. Повністю очищає існуючу базу даних.\",\n            \"migrate_hash_files\": \"Використовується після зміни налаштувань хешування імен файлів для перейменування існуючих файлів у новий формат.\",\n            \"migrate_scene_screenshots\": {\n                \"description\": \"Міграція скріншотів сцен до нової системи зберігання blobs. Слід виконати після оновлення системи до версії 0.20. Опціонально можна видалити старі скріншоти після міграції.\",\n                \"overwrite_existing\": \"Перезаписати існуючі blobs даними скріншотів\",\n                \"delete_files\": \"Видалити файли скріншотів\"\n            },\n            \"migrations\": \"Міграції\",\n            \"optimise_database\": \"Спроба покращити продуктивність шляхом аналізу та повної перебудови файлу бази даних.\",\n            \"only_dry_run\": \"Виконати лише тестовий запуск (Dry run). Нічого не видаляти\",\n            \"optimise_database_warning\": \"Увага: під час виконання цього завдання будь-які операції зміни бази даних будуть недоступні. Це може зайняти кілька хвилин. Необхідно мати вільного місця на диску щонайменше як розмір вашої БД (рекомендовано 1.5x).\",\n            \"scan_for_content_desc\": \"Сканувати новий контент та додати його до бази даних.\",\n            \"rescan_tooltip\": \"Пересканувати кожен файл у вказаному шляху. Використовується для примусового оновлення метаданих та пересканування ZIP-архівів.\",\n            \"migrate_blobs\": {\n                \"description\": \"Міграція blobs до поточної системи зберігання. Слід виконати після зміни системи зберігання blobs. Опціонально можна видалити старі дані після міграції.\",\n                \"delete_old\": \"Видалити старі дані\"\n            },\n            \"data_management\": \"Керування даними\",\n            \"job_queue\": \"Черга завдань\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Сканування всіх шляхів\",\n                \"scanning_paths\": \"Сканування наступних шляхів\"\n            },\n            \"plugin_tasks\": \"Завдання плагінів\",\n            \"rescan\": \"Пересканувати файли\",\n            \"generate_image_phashes_during_scan\": \"Генерувати перцептуальні хеші зображень\",\n            \"generate_image_phashes_during_scan_tooltip\": \"Для дедуплікації та ідентифікації.\",\n            \"backup_database\": {\n                \"description\": \"Виконує резервне копіювання бази даних та BLOB-файлів.\",\n                \"destination\": \"Місце призначення\",\n                \"download\": \"Завантажити резервну копію\",\n                \"include_blobs\": \"Включити блоби до резервної копії\",\n                \"include_blobs_desc\": \"Вимкнути, щоб створювати резервну копію лише файлу бази даних SQLite.\",\n                \"sqlite\": \"Файл резервної копії буде копією файлу бази даних SQLite з назвою файлу {filename_format}\",\n                \"to_directory\": \"До {каталогу}\",\n                \"warning_blobs\": \"Blob-файли не будуть включені до резервної копії. Це означає, що для успішного відновлення з резервної копії blob-файли мають бути присутніми в місці сховища blob-об'єктів.\",\n                \"zip\": \"Файл бази даних SQLite та blob-файли будуть стиснуті в один файл з назвою {filename_format}\"\n            }\n        },\n        \"library\": {\n            \"exclusions\": \"Виключення\",\n            \"gallery_and_image_options\": \"Опції галереї та зображення\",\n            \"media_content_extensions\": \"Розширення медіа контенту\"\n        },\n        \"tools\": {\n            \"scene_filename_parser\": {\n                \"escape_chars\": \"Використовуйте \\\\ для екранування літералів\",\n                \"filename\": \"Ім'я файлу\",\n                \"whitespace_chars_desc\": \"Ці символи будуть замінені на пробіли в заголовку\",\n                \"capitalize_title\": \"Заголовок з великої літери\",\n                \"display_fields\": \"Відображати поля\",\n                \"add_field\": \"Додати поле\",\n                \"filename_pattern\": \"Шаблон імені файлу\",\n                \"ignore_organized\": \"Ігнорувати впорядковані сцени\",\n                \"ignored_words\": \"Ігноровані слова\",\n                \"whitespace_chars\": \"Символи пробілу\",\n                \"matches_with\": \"Збігається з {i}\",\n                \"select_parser_recipe\": \"Вибрати рецепт парсера\",\n                \"title\": \"Парсер імен файлів сцен\"\n            },\n            \"scene_tools\": \"Інструменти сцен\",\n            \"scene_duplicate_checker\": \"Пошук дублікатів сцен\",\n            \"graphql_playground\": \"GraphQL Playground\",\n            \"heading\": \"Інструменти\"\n        },\n        \"ui\": {\n            \"scene_player\": {\n                \"options\": {\n                    \"continue_playlist_default\": {\n                        \"description\": \"Відтворювати наступну сцену в черзі після завершення відео.\",\n                        \"heading\": \"Продовжувати плейлист за замовчуванням\"\n                    },\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Автозапуск відео при відтворенні з черги, або при відтворенні обраних чи випадкових сцен.\",\n                        \"heading\": \"Автозапуск при відтворенні обраного\"\n                    },\n                    \"disable_mobile_media_auto_rotate\": \"Вимкнути автоповорот медіа в повноекранному режимі на мобільних\",\n                    \"show_ab_loop_controls\": \"Показувати керування плагіном AB Loop\",\n                    \"vr_tag\": {\n                        \"description\": \"Кнопка VR відображатиметься лише для сцен із цим тегом.\",\n                        \"heading\": \"Тег VR\"\n                    },\n                    \"always_start_from_beginning\": \"Завжди починати відео з початку\",\n                    \"enable_chromecast\": \"Увімкнути Chromecast\",\n                    \"show_scrubber\": \"Показати смугу прокрутки\",\n                    \"track_activity\": \"Увімкнути історію перегляду сцен\",\n                    \"auto_start_video\": \"Автозапуск відео\",\n                    \"show_range_markers\": \"Показати маркери діапазону\"\n                },\n                \"heading\": \"Програвач сцени\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"Ключ підключення Handy для інтерактивних сцен. Встановлення цього ключа дозволить Stash ділитися інформацією про поточну сцену з handyfeeling.com.\",\n                \"heading\": \"Ключ підключення Handy\"\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"Кількість спроб прокрутки перед переходом до наступного/попереднього елемента. Застосовується лише для режиму прокрутки Pan Y.\",\n                \"heading\": \"Спроби прокрутки перед переходом\"\n            },\n            \"custom_locales\": {\n                \"description\": \"Перевизначення окремих рядків локалізації. Див. список за посиланням. Потрібне перезавантаження сторінки.\",\n                \"heading\": \"Власна локалізація\",\n                \"option_label\": \"Увімкнути власну локалізацію\"\n            },\n            \"scene_list\": {\n                \"options\": {\n                    \"show_studio_as_text\": \"Відображати накладення студії як текст\"\n                },\n                \"heading\": \"Вигляд сітки\"\n            },\n            \"editing\": {\n                \"max_options_shown\": {\n                    \"label\": \"Максимальна кількість елементів у випадаючих списках\"\n                },\n                \"disable_dropdown_create\": {\n                    \"description\": \"Вимкнути можливість створювати нові об'єкти з випадаючих списків.\",\n                    \"heading\": \"Вимкнути створення у випадаючому списку\"\n                },\n                \"heading\": \"Редагування\",\n                \"rating_system\": {\n                    \"type\": {\n                        \"label\": \"Тип системи оцінювання\",\n                        \"options\": {\n                            \"decimal\": \"Десяткова\",\n                            \"stars\": \"Зірки\"\n                        }\n                    },\n                    \"star_precision\": {\n                        \"options\": {\n                            \"tenth\": \"Десята (0.1)\",\n                            \"half\": \"Половина\",\n                            \"full\": \"Ціла\",\n                            \"quarter\": \"Чверть\"\n                        },\n                        \"label\": \"Точність рейтингу (зірки)\"\n                    }\n                }\n            },\n            \"studio_panel\": {\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"У перегляді студії відображати контент також з підстудій.\",\n                        \"heading\": \"Відображати контент підстудій\"\n                    }\n                },\n                \"heading\": \"Перегляд студії\"\n            },\n            \"images\": {\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"heading\": \"Сканувати відеофайли як кліпи зображень\",\n                        \"description\": \"Коли у бібліотеці вимкнено Відео, відеофайли (файли з відеорозширеннями) будуть скануватися як кліпи зображень.\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Записувати мініатюри зображень на диск при генерації \\\"на льоту\\\".\",\n                        \"heading\": \"Записувати мініатюри зображень\"\n                    }\n                },\n                \"heading\": \"Зображення\"\n            },\n            \"delete_options\": {\n                \"options\": {\n                    \"delete_generated_supporting_files\": \"Видаляти згенеровані допоміжні файли за замовчуванням\",\n                    \"delete_file\": \"Видаляти файл за замовчуванням\"\n                },\n                \"description\": \"Налаштування за замовчуванням для видалення зображень, галерей та сцен.\",\n                \"heading\": \"Налаштування видалення\"\n            },\n            \"funscript_offset\": {\n                \"description\": \"Зсув часу в мілісекундах для відтворення інтерактивних скриптів.\",\n                \"heading\": \"Зсув Funscript (мс)\"\n            },\n            \"abbreviate_counters\": {\n                \"description\": \"Скорочувати лічильники на картках і в деталях (наприклад, \\\"1831\\\" буде показано як \\\"1.8K\\\").\",\n                \"heading\": \"Скорочувати лічильники\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Перезавантажте сторінку для застосування змін. Сумісність власного Javascript з майбутніми версіями Stash не гарантується.\",\n                \"heading\": \"Власний Javascript\",\n                \"option_label\": \"Увімкнути власний Javascript\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Відсоток часу, протягом якого сцена має бути відтворена, щоб лічильник переглядів збільшився.\",\n                \"heading\": \"Мінімальний відсоток відтворення\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Показувати картку тегу при наведенні на значок тегу.\",\n                \"heading\": \"Підказки карток тегів\"\n            },\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"Якщо ввімкнено, funscripts передаватимуться безпосередньо зі Stash на ваш пристрій Handy без використання стороннього сервера Handy. Вимагає, щоб Stash був доступний з вашого пристрою Handy, а також згенерованого API-ключа, якщо у Stash налаштована авторизація.\",\n                \"heading\": \"Роздавати funscripts напряму\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"Слайд-шоу доступне в галереях у режимі перегляду стіни.\",\n                \"heading\": \"Затримка слайд-шоу (сек)\"\n            },\n            \"custom_css\": {\n                \"description\": \"Перезавантажте сторінку для застосування змін. Сумісність власного CSS з майбутніми версіями Stash не гарантується.\",\n                \"heading\": \"Власний CSS\",\n                \"option_label\": \"Увімкнути власний CSS\"\n            },\n            \"desktop_integration\": {\n                \"send_desktop_notifications_for_events\": \"Надсилати сповіщення на робочий стіл про події.\",\n                \"skip_opening_browser_on_startup\": \"Не відкривати браузер автоматично під час запуску.\",\n                \"desktop_integration\": \"Інтеграція з робочим столом\",\n                \"notifications_enabled\": \"Увімкнути сповіщення\",\n                \"skip_opening_browser\": \"Не відкривати браузер\"\n            },\n            \"menu_items\": {\n                \"description\": \"Показати або приховати різні типи контенту на панелі навігації.\",\n                \"heading\": \"Пункти меню\"\n            },\n            \"tag_panel\": {\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"У перегляді тегів відображати контент також з підтегів.\",\n                        \"heading\": \"Відображати контент підтегів\"\n                    }\n                },\n                \"heading\": \"Перегляд тегів\"\n            },\n            \"detail\": {\n                \"show_all_details\": {\n                    \"description\": \"Якщо увімкнено, всі деталі контенту будуть показані за замовчуванням в одну колонку.\",\n                    \"heading\": \"Показати всі деталі\"\n                },\n                \"compact_expanded_details\": {\n                    \"description\": \"Якщо увімкнено, розширені деталі будуть відображатися в компактному вигляді.\",\n                    \"heading\": \"Компактні розширені деталі\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Відображати фонове зображення на сторінці деталей.\",\n                    \"heading\": \"Увімкнути фонове зображення\"\n                },\n                \"heading\": \"Сторінка деталей\"\n            },\n            \"preview_type\": {\n                \"description\": \"За замовчуванням використовуються відео (mp4). Для зменшення навантаження на ЦП можна використовувати анімовані зображення (webp). Однак їх потрібно генерувати додатково, і вони займають більше місця.\",\n                \"options\": {\n                    \"animated\": \"Анімоване зображення\",\n                    \"static\": \"Статичне зображення\",\n                    \"video\": \"Відео\"\n                },\n                \"heading\": \"Тип попереднього перегляду\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"Максимальна тривалість сцени, яку програвач буде зациклювати. 0 — вимкнути.\",\n                \"heading\": \"Макс. тривалість зациклення\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Власний шлях для зображень виконавців за замовчуванням. Залиште порожнім, щоб використовувати вбудовані.\",\n                        \"heading\": \"Власний шлях до зображень виконавців\"\n                    }\n                }\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Лайтбокс зображень\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Напрямок\",\n                \"heading\": \"Стіна зображень\",\n                \"margin\": \"Відступ (пікселі)\"\n            },\n            \"interactive_options\": \"Інтерактивні опції\",\n            \"language\": {\n                \"heading\": \"Мова\"\n            },\n            \"basic_settings\": \"Основні налаштування\",\n            \"handy_connection\": {\n                \"server_offset\": {\n                    \"heading\": \"Зсув сервера\"\n                },\n                \"connect\": \"Підключити\",\n                \"status\": {\n                    \"heading\": \"Статус з'єднання Handy\"\n                },\n                \"sync\": \"Синхронізувати\"\n            },\n            \"scene_wall\": {\n                \"heading\": \"Стіна сцен / маркерів\",\n                \"options\": {\n                    \"display_title\": \"Відображати заголовок і теги\",\n                    \"toggle_sound\": \"Увімкнути звук\"\n                }\n            },\n            \"title\": \"Інтерфейс користувача\",\n            \"performer_list\": {\n                \"heading\": \"Список виконавців\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"Відображати посилання на картках виконавців\"\n                    }\n                }\n            },\n            \"custom_title\": {\n                \"description\": \"Власний текст для заголовка сторінки. Якщо порожньо, використовується 'Stash'.\",\n                \"heading\": \"Власний заголовок\"\n            },\n            \"sfw_mode\": {\n                \"description\": \"Увімкніть, якщо використовуєте Stash для SFW-контенту. Приховує або змінює деякі аспекти інтерфейсу, пов'язані з дорослим контентом.\",\n                \"heading\": \"Режим SFW-контенту\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"Режим діагностики\",\n                \"dialog_title\": \"Увімкнути режим діагностики\",\n                \"dialog_description\": \"Це тимчасово вимкне всі власні налаштування, щоб допомогти діагностувати проблеми:\",\n                \"dialog_item_plugins\": \"Всі плагіни\",\n                \"dialog_item_css\": \"Власний CSS\",\n                \"dialog_item_js\": \"Власний JavaScript\",\n                \"dialog_item_locales\": \"Власна локалізація\",\n                \"dialog_log_level\": \"Рівень журналу буде встановлено на Debug для детальної діагностики.\",\n                \"dialog_reload_note\": \"Сторінка автоматично перезавантажиться.\",\n                \"enable\": \"Увімкнути та перезавантажити\",\n                \"overlay_message\": \"Режим діагностики активний — всі власні налаштування вимкнено\",\n                \"exit\": \"Вийти\"\n            }\n        },\n        \"plugins\": {\n            \"hooks\": \"Хуки\",\n            \"available_plugins\": \"Доступні плагіни\",\n            \"installed_plugins\": \"Встановлені плагіни\",\n            \"triggers_on\": \"Тригери увімкнені\"\n        },\n        \"logs\": {\n            \"log_level\": \"Рівень журналу\"\n        },\n        \"scraping\": {\n            \"excluded_tag_patterns_desc\": \"Регулярні вирази імен тегів для виключення з результатів збирання даних.\",\n            \"available_scrapers\": \"Доступні сканери\",\n            \"entity_metadata\": \"Метадані {entityType}\",\n            \"entity_scrapers\": \"Сканери {entityType}\",\n            \"scraper\": \"Сканер\",\n            \"scrapers\": \"Сканери\",\n            \"excluded_tag_patterns_head\": \"Виключені шаблони тегів\",\n            \"installed_scrapers\": \"Встановлені сканери\",\n            \"supported_types\": \"Підтримувані типи\",\n            \"supported_urls\": \"URL-адреси\",\n            \"search_by_name\": \"Пошук за назвою\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"Додати екземпляр stash-box\",\n            \"title\": \"Кінцеві точки Stash-box\",\n            \"description\": \"Stash-box забезпечує автоматичне додавання тегів до сцен та виконавців на основі відбитків і назв файлів.\\nАдресу кінцевої точки (Endpoint) та API-ключ можна знайти на сторінці вашого облікового запису в інстанції Stash-box. Імена обов'язкові, якщо додано більше однієї інстанції.\",\n            \"endpoint\": \"Кінцева точка\",\n            \"name\": \"Назва\",\n            \"graphql_endpoint\": \"Кінцева точка GraphQL\",\n            \"api_key\": \"API ключ\",\n            \"max_requests_per_minute\": \"Макс запитів у хвилину\",\n            \"max_requests_per_minute_description\": \"Викорситовує значення за замовчуванням {defaultValue} якщо встановлено в 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Транскодування\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Шляхи до застосунків\"\n        },\n        \"changelog\": {\n            \"header\": \"Список змін\"\n        }\n    },\n    \"video_codec\": \"Відеокодек\",\n    \"videos\": \"Відео\",\n    \"weight\": \"Вага\",\n    \"average_resolution\": \"Середня роздільна здатність\",\n    \"between_and\": \"та\",\n    \"ascending\": \"За зростанням\",\n    \"audio_codec\": \"Аудіокодек\",\n    \"blobs_storage_type\": {\n        \"database\": \"База даних\",\n        \"filesystem\": \"Файлова система\"\n    },\n    \"captions\": \"Субтитри\",\n    \"sub_tag_of\": \"Підтег для {parent}\",\n    \"dupe_check\": {\n        \"options\": {\n            \"exact\": \"Точна\",\n            \"high\": \"Висока\",\n            \"low\": \"Низька\",\n            \"medium\": \"Середня\"\n        },\n        \"only_select_matching_codecs\": \"Вибирати тільки якщо всі кодеки збігаються в групі дублікатів\",\n        \"select_oldest\": \"Вибрати найстаріший файл у групі дублікатів\",\n        \"description\": \"Рівні нижче \\\"Точна\\\" можуть вимагати більше часу для обчислення. На рівнях з нижчою точністю також можуть виникати хибні збіги.\",\n        \"found_sets\": \"{setCount, plural, one{Знайдено # набір дублікатів.} other {Знайдено # наборів дублікатів.}}\",\n        \"select_all_but_largest_resolution\": \"Вибрати всі файли в кожній групі дублікатів, крім файлу з найвищою роздільною здатністю\",\n        \"select_youngest\": \"Вибрати найновіший файл у групі дублікатів\",\n        \"select_all_but_largest_file\": \"Вибрати всі файли в кожній групі дублікатів, крім найбільшого файлу\",\n        \"select_options\": \"Опції вибору…\",\n        \"select_none\": \"Зняти виділення\",\n        \"duration_diff\": \"Максимальна різниця тривалості\",\n        \"search_accuracy_label\": \"Точність пошуку\",\n        \"title\": \"Дублікати сцен\",\n        \"duration_options\": {\n            \"any\": \"Будь-яка\",\n            \"equal\": \"Однакова\"\n        }\n    },\n    \"sub_tags\": \"Підтеги\",\n    \"package_manager\": {\n        \"uninstall\": \"Видалити\",\n        \"description\": \"Опис\",\n        \"package\": \"Пакунок\",\n        \"unknown\": \"<невідомо>\",\n        \"update\": \"Оновити\",\n        \"version\": \"Версія\",\n        \"install\": \"Встановити\",\n        \"source\": {\n            \"name\": \"Назва\",\n            \"local_path\": {\n                \"description\": \"Відносний шлях для зберігання пакунків для цього джерела. Зверніть увагу, що зміна цього параметра вимагає переміщення пакунків вручну.\",\n                \"heading\": \"Локальний шлях\"\n            },\n            \"url\": \"URL джерела\"\n        },\n        \"confirm_uninstall\": \"Ви впевнені, що хочете видалити {number} пакунків?\",\n        \"confirm_delete_source\": \"Ви впевнені, що хочете видалити джерело {name} ({url})?\",\n        \"no_upgradable\": \"Пакунків для оновлення не знайдено\",\n        \"no_packages\": \"Пакунків не знайдено\",\n        \"no_sources\": \"Джерела не налаштовані\",\n        \"required_by\": \"Вимагається для {packages}\",\n        \"check_for_updates\": \"Перевірити наявність оновлень\",\n        \"add_source\": \"Додати джерело\",\n        \"edit_source\": \"Редагувати джерело\",\n        \"hide_unselected\": \"Приховати невибрані\",\n        \"installed_version\": \"Встановлена версія\",\n        \"latest_version\": \"Остання версія\",\n        \"selected_only\": \"Лише вибрані\",\n        \"show_all\": \"Показати всі\"\n    },\n    \"effect_filters\": {\n        \"name_transforms\": \"Трансформації\",\n        \"red\": \"Червоний\",\n        \"rotate\": \"Поворот\",\n        \"hue\": \"Відтінок\",\n        \"name\": \"Фільтри\",\n        \"saturation\": \"Насиченість\",\n        \"scale\": \"Масштаб\",\n        \"warmth\": \"Теплота\",\n        \"blur\": \"Розмиття\",\n        \"brightness\": \"Яскравість\",\n        \"contrast\": \"Контраст\",\n        \"gamma\": \"Гама\",\n        \"green\": \"Зелений\",\n        \"blue\": \"Синій\",\n        \"rotate_left_and_scale\": \"Повернути ліворуч і масштабувати\",\n        \"rotate_right_and_scale\": \"Повернути праворуч і масштабувати\",\n        \"reset_filters\": \"Скинути фільтри\",\n        \"reset_transforms\": \"Скинути трансформації\",\n        \"aspect\": \"Пропорції\"\n    },\n    \"duration\": \"Тривалість\",\n    \"errors\": {\n        \"header\": \"Помилка\",\n        \"image_index_greater_than_zero\": \"Індекс зображення має бути більше 0\",\n        \"lazy_component_error_help\": \"Якщо ви нещодавно оновили Stash, перезавантажте сторінку або очистіть кеш браузера.\",\n        \"custom_fields\": {\n            \"field_name_whitespace\": \"Назва поля не може мати пробіли на початку або в кінці\",\n            \"field_name_required\": \"Назва поля обов'язкова\",\n            \"field_name_length\": \"Назва поля має містити менше 65 символів\",\n            \"duplicate_field\": \"Назва поля має бути унікальною\"\n        },\n        \"invalid_javascript_string\": \"Недійсний код Javascript: {error}\",\n        \"invalid_json_string\": \"Недійсний рядок JSON: {error}\",\n        \"loading_type\": \"Помилка завантаження {type}\",\n        \"something_went_wrong\": \"Щось пішло не так.\"\n    },\n    \"pagination\": {\n        \"first\": \"Перша\",\n        \"next\": \"Наступна\",\n        \"previous\": \"Попередня\",\n        \"last\": \"Остання\",\n        \"current_total\": \"{current} з {total}\"\n    },\n    \"ethnicity\": \"Етнічна приналежність\",\n    \"false\": \"Ні\",\n    \"favourite\": \"Улюблене\",\n    \"file\": \"файл\",\n    \"files\": \"файли\",\n    \"filter\": \"Фільтр\",\n    \"filters\": \"Фільтри\",\n    \"folder\": \"Папка\",\n    \"galleries\": \"Галереї\",\n    \"gallery\": \"Галерея\",\n    \"gender\": \"Стать\",\n    \"gender_types\": {\n        \"FEMALE\": \"Жіноча\",\n        \"MALE\": \"Чоловіча\",\n        \"INTERSEX\": \"Інтерсекс\",\n        \"NON_BINARY\": \"Небінарна\",\n        \"TRANSGENDER_FEMALE\": \"Транс-жінка\",\n        \"TRANSGENDER_MALE\": \"Транс-чоловік\"\n    },\n    \"groups\": \"Групи\",\n    \"handy_connection_status\": {\n        \"connecting\": \"Підключення\",\n        \"disconnected\": \"Відключено\",\n        \"ready\": \"Готово\",\n        \"error\": \"Помилка підключення до Handy\",\n        \"syncing\": \"Синхронізація з сервером\",\n        \"missing\": \"Відсутній\",\n        \"uploading\": \"Завантаження скрипту\"\n    },\n    \"height\": \"Зріст\",\n    \"help\": \"Допомога\",\n    \"history\": \"Історія\",\n    \"images\": \"Зображення\",\n    \"interactive\": \"Інтерактив\",\n    \"library\": \"Бібліотека\",\n    \"markers\": \"Позначки\",\n    \"measurements\": \"Параметри\",\n    \"media_info\": {\n        \"phash\": \"Перцептивний хеш\",\n        \"stream\": \"Потік\",\n        \"performer_card\": {\n            \"age_context\": \"{age} {years_old} на момент зйомок\",\n            \"age\": \"{age} {years_old}\"\n        },\n        \"audio_codec\": \"Аудіокодек\",\n        \"downloaded_from\": \"Завантажено з\",\n        \"interactive_speed\": \"Швидкість інтерактиву\",\n        \"o_count\": \"Кількість оргазмів\",\n        \"play_count\": \"Кількість переглядів\",\n        \"play_duration\": \"Тривалість відтворення\",\n        \"video_codec\": \"Відеокодек\",\n        \"md5\": \"Контрольна сума MD5\",\n        \"phash_meaning\": \"Перцептивний хеш\",\n        \"oshash_meaning\": \"Хеш OpenSubtitles\",\n        \"oshash\": \"Хеш OSHash\"\n    },\n    \"metadata\": \"Метадані\",\n    \"new\": \"Новий\",\n    \"name\": \"Ім'я\",\n    \"none\": \"Нічого\",\n    \"operations\": \"Операції\",\n    \"orientation\": \"Орієнтація\",\n    \"path\": \"Шлях\",\n    \"penis\": \"Пеніс\",\n    \"performer\": \"Виконавець\",\n    \"performers\": \"Виконавці\",\n    \"photographer\": \"Фотограф\",\n    \"piercings\": \"Пірсинг\",\n    \"queue\": \"Черга\",\n    \"random\": \"Випадково\",\n    \"rating\": \"Рейтинг\",\n    \"scenes\": \"Сцени\",\n    \"statistics\": \"Статистика\",\n    \"studio_tagger\": {\n        \"tag_status\": \"Статус тегу\",\n        \"update_studios\": \"Оновити студії\",\n        \"update_studio\": \"Оновити студію\",\n        \"to_use_the_studio_tagger\": \"Для використання тегера студій необхідно налаштувати екземпляр Stash-Box.\",\n        \"any_names_entered_will_be_queried\": \"Будь-які введені імена будуть запитані у віддаленому екземплярі Stash-Box і додані, якщо знайдені. Враховуються лише точні збіги.\",\n        \"config\": {\n            \"create_parent_desc\": \"Створити відсутні батьківські студії або тегувати та оновити дані/зображення для існуючих при повному збігу імен\",\n            \"create_parent_label\": \"Створити батьківські студії\"\n        },\n        \"updating_untagged_studios_description\": \"Оновлення спробує знайти відповідність для студій без StashID та оновити їх метадані.\",\n        \"refreshing_will_update_the_data\": \"Оновлення оновить дані будь-яких тегованих студій з екземпляра Stash-Box.\",\n        \"create_or_tag_parent_studios\": \"Створити відсутні або тегувати існуючі батьківські студії\",\n        \"failed_to_save_studio\": \"Не вдалося зберегти студію \\\"{studio}\\\"\",\n        \"number_of_studios_will_be_processed\": \"Буде оброблено {studio_count} студій\",\n        \"query_all_studios_in_the_database\": \"Усі студії в базі даних\",\n        \"add_new_studios\": \"Додати нові студії\",\n        \"name_already_exists\": \"Ім'я вже існує\",\n        \"refresh_tagged_studios\": \"Оновити теговані студії\",\n        \"studio_successfully_tagged\": \"Студію успішно теговано\",\n        \"network_error\": \"Помилка мережі\",\n        \"current_page\": \"Поточна сторінка\",\n        \"untagged_studios\": \"Нетеговані студії\",\n        \"studio_selection\": \"Вибір студії\",\n        \"studio_already_tagged\": \"Студія вже має тег\",\n        \"batch_update_studios\": \"Пакетне оновлення студій\",\n        \"batch_add_studios\": \"Пакетне додавання студій\",\n        \"status_tagging_studios\": \"Статус: Тегування студій\",\n        \"status_tagging_job_queued\": \"Статус: Завдання тегування в черзі\",\n        \"no_results_found\": \"Результатів не знайдено.\",\n        \"studio_names_or_stashids_separated_by_comma\": \"Назви студій або StashID, розділені комами\"\n    },\n    \"sub_tag_count\": \"Кількість підтегів\",\n    \"studios\": \"Студії\",\n    \"studio_tags\": \"Теги студії\",\n    \"time\": \"Час\",\n    \"title\": \"Заголовок\",\n    \"tag_sub_tag_tooltip\": \"Має підтеги\",\n    \"tattoos\": \"Татуювання\",\n    \"tags\": \"Теги\",\n    \"group\": \"Група\",\n    \"image\": \"Зображення\",\n    \"loading\": {\n        \"generic\": \"Завантаження…\",\n        \"plugins\": \"Завантаження плагінів…\"\n    },\n    \"performer_tagger\": {\n        \"status_tagging_job_queued\": \"Статус: Завдання тегування в черзі\",\n        \"number_of_performers_will_be_processed\": \"Буде оброблено {performer_count} виконавців\",\n        \"any_names_entered_will_be_queried\": \"Будь-які введені імена будуть запитані у віддаленому екземплярі Stash-Box і додані, якщо знайдені. Враховуються лише точні збіги.\",\n        \"failed_to_save_performer\": \"Не вдалося зберегти виконавця \\\"{performer}\\\"\",\n        \"refreshing_will_update_the_data\": \"Оновлення оновить дані будь-яких тегованих виконавців з екземпляра Stash-Box.\",\n        \"query_all_performers_in_the_database\": \"Усі виконавці в базі даних\",\n        \"updating_untagged_performers_description\": \"Оновлення спробує знайти відповідність для виконавців без StashID та оновити їх метадані.\",\n        \"to_use_the_performer_tagger\": \"Для використання тегера виконавців необхідно налаштувати екземпляр Stash-Box.\",\n        \"update_performers\": \"Оновити виконавців\",\n        \"add_new_performers\": \"Додати нових виконавців\",\n        \"batch_update_performers\": \"Пакетне оновлення виконавців\",\n        \"batch_add_performers\": \"Пакетне додавання виконавців\",\n        \"current_page\": \"Поточна сторінка\",\n        \"name_already_exists\": \"Ім'я вже існує\",\n        \"network_error\": \"Помилка мережі\",\n        \"no_results_found\": \"Результатів не знайдено.\",\n        \"performer_selection\": \"Вибір виконавця\",\n        \"refresh_tagged_performers\": \"Оновити тегованих виконавців\",\n        \"tag_status\": \"Статус тегу\",\n        \"status_tagging_performers\": \"Статус: Тегування виконавців\",\n        \"update_performer\": \"Оновити виконавця\",\n        \"untagged_performers\": \"Нетеговані виконавці\",\n        \"performer_successfully_tagged\": \"Виконавця успішно теговано:\",\n        \"performer_already_tagged\": \"Виконавець вже має тег\",\n        \"performer_names_or_stashids_separated_by_comma\": \"Імена виконавців або StashID, розділені комою\"\n    },\n    \"resolution\": \"Роздільна здатність\",\n    \"scene\": \"Сцена\",\n    \"search_filter\": {\n        \"name\": \"Фільтр\",\n        \"saved_filters\": \"Збережені фільтри\",\n        \"edit_filter\": \"Редагувати фільтр\",\n        \"update_filter\": \"Оновити фільтр\",\n        \"search_term\": \"Пошуковий запит\",\n        \"more_filter_criteria\": \"ще +{count}\"\n    },\n    \"studio\": \"Студія\",\n    \"total\": \"Всього\",\n    \"true\": \"Так\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"Тип\",\n    \"url\": \"URL\",\n    \"urls\": \"URL-адреси\",\n    \"sub_groups\": \"Підгрупи\",\n    \"tag_parent_tooltip\": \"Має батьківські теги\",\n    \"tag_count\": \"Кількість тегів\",\n    \"instagram\": \"Instagram\",\n    \"sub_group\": \"Підгрупа\",\n    \"criterion_modifier_values\": {\n        \"only\": \"Лише\",\n        \"any\": \"Будь-яке\",\n        \"none\": \"Жодного\",\n        \"any_of\": \"Будь-який з\"\n    },\n    \"second\": \"Секунда\",\n    \"seconds\": \"Секунди\",\n    \"settings\": \"Налаштування\",\n    \"tag\": \"Тег\",\n    \"synopsis\": \"Синопсис\",\n    \"dialogs\": {\n        \"delete_gallery_files\": \"Видалити папку галереї/zip-файл та всі зображення, які не прив'язані до жодної іншої галереї.\",\n        \"scene_gen\": {\n            \"marker_image_previews_tooltip\": \"Також генерувати анімовані (webp) попередні перегляди. Потрібно, лише якщо тип перегляду для стіни встановлено на \\\"Анімоване зображення\\\". Використовують менше ЦП, але займають більше місця.\",\n            \"transcodes_tooltip\": \"MP4-транскодування будуть попередньо згенеровані для всього контенту; корисно для повільних ЦП, але потребує значно більше місця на диску\",\n            \"preview_preset_desc\": \"Шаблон регулює розмір, якість та час кодування генерації попереднього перегляду. Шаблони повільніші за \\\"slow\\\" не рекомендуються через низьку ефективність.\",\n            \"override_preview_generation_options_desc\": \"Перевизначити параметри генерації попереднього перегляду для цієї операції. Стандартні налаштування знаходяться в Система -> Генерація попереднього перегляду.\",\n            \"phash_tooltip\": \"Для дедуплікації та ідентифікації сцен\",\n            \"preview_seg_duration_desc\": \"Тривалість кожного сегмента попереднього перегляду в секундах.\",\n            \"video_previews_tooltip\": \"Відео-прев'ю, що відтворюються при наведенні на сцену\",\n            \"markers\": \"Попередні перегляди маркерів\",\n            \"preview_exclude_start_time_desc\": \"Виключити перші X секунд із попереднього перегляду сцени. Це може бути значення в секундах або відсоток (наприклад, 2%) від загальної тривалості.\",\n            \"image_previews_tooltip\": \"Також генерувати анімовані (webp) попередні перегляди. Потрібно, лише якщо тип перегляду для стіни встановлено на \\\"Анімоване зображення\\\". Вони використовують менше ЦП при перегляді, але генеруються додатково і займають більше місця.\",\n            \"force_transcodes_tooltip\": \"За замовчуванням транскодування відбувається лише тоді, коли відеофайл не підтримується браузером. Якщо увімкнено, транскодування відбуватиметься навіть якщо файл начебто підтримується.\",\n            \"interactive_heatmap_speed\": \"Генерувати теплові карти та швидкість для інтерактивних сцен\",\n            \"preview_exclude_end_time_desc\": \"Виключити останні X секунд із попереднього перегляду сцени. Це може бути значення в секундах або відсоток (наприклад, 2%) від загальної тривалості.\",\n            \"preview_seg_count_desc\": \"Кількість сегментів у файлах попереднього перегляду.\",\n            \"markers_tooltip\": \"20-секундні відео, що починаються з вказаного таймкоду.\",\n            \"sprites_tooltip\": \"Набір зображень під відеоплеєром для зручної навігації.\",\n            \"preview_seg_count_head\": \"Кількість сегментів у попередньому перегляді\",\n            \"override_preview_generation_options\": \"Перевизначити параметри генерації перегляду\",\n            \"phash\": \"Перцептуальні хеші відео\",\n            \"marker_screenshots_tooltip\": \"Статичні JPG зображення маркерів\",\n            \"preview_exclude_start_time_head\": \"Виключити час на початку\",\n            \"preview_seg_duration_head\": \"Тривалість сегмента попереднього перегляду\",\n            \"sprites\": \"Спрайти смуги прокрутки\",\n            \"overwrite\": \"Перезаписати існуючі файли\",\n            \"covers\": \"Обкладинки сцен\",\n            \"preview_options\": \"Параметри перегляду\",\n            \"image_thumbnails\": \"Мініатюри зображень\",\n            \"marker_image_previews\": \"Анімовані попередні перегляди маркерів\",\n            \"clip_previews\": \"Попередні перегляди кліпів зображень\",\n            \"force_transcodes\": \"Примусова генерація транскодування\",\n            \"image_previews\": \"Попередні перегляди анімованих зображень\",\n            \"preview_exclude_end_time_head\": \"Виключити час у кінці\",\n            \"preview_generation_options\": \"Параметри генерації попереднього перегляду\",\n            \"preview_preset_head\": \"Шаблон кодування попереднього перегляду\",\n            \"marker_screenshots\": \"Скріншоти маркерів\",\n            \"transcodes\": \"Транскодування\",\n            \"video_previews\": \"Попередні перегляди\",\n            \"image_phash\": \"Перцептуальні хеші зображень\",\n            \"image_phash_tooltip\": \"Для дедуплікації та ідентифікації\"\n        },\n        \"delete_entity_simple_desc\": \"{count, plural, one {Ви впевнені, що хочете видалити цей {singularEntity}?} other {Ви впевнені, що хочете видалити ці {pluralEntity}?}}\",\n        \"delete_object_desc\": \"Ви впевнені, що хочете видалити {count, plural, one {цей {singularEntity}} other {ці {pluralEntity}}}?\",\n        \"delete_object_title\": \"Видалити {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"delete_galleries_extra\": \"…плюс будь-які зображення, які не прикріплені до жодної іншої галереї.\",\n        \"clear_play_history_confirm\": \"Ви впевнені, що хочете очистити історію переглядів?\",\n        \"edit_entity_title\": \"Редагувати {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"Включити пов'язані об'єкти в експорт\",\n        \"imagewall\": {\n            \"margin_desc\": \"Кількість пікселів відступу навколо кожного зображення.\",\n            \"direction\": {\n                \"description\": \"Макет на основі стовпців або рядків.\",\n                \"column\": \"Стовпець\",\n                \"row\": \"Рядок\"\n            }\n        },\n        \"lightbox\": {\n            \"scale_up\": {\n                \"description\": \"Масштабувати менші зображення для заповнення екрана.\",\n                \"label\": \"Збільшити, щоб вписати\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"Утримуйте Shift, щоб тимчасово використати інший режим.\",\n                \"label\": \"Режим прокрутки\",\n                \"pan_y\": \"Прокрутка по Y\",\n                \"zoom\": \"Масштабування\"\n            },\n            \"reset_zoom_on_nav\": \"Скинути рівень масштабування при зміні зображення\",\n            \"delay\": \"Затримка (сек)\",\n            \"display_mode\": {\n                \"label\": \"Режим відображення\",\n                \"fit_to_screen\": \"Підігнати до екрана\",\n                \"fit_horizontally\": \"Підігнати по горизонталі\",\n                \"original\": \"Оригінал\"\n            },\n            \"page_header\": \"Сторінка {page} / {total}\",\n            \"disable_animation\": \"Вимкнути анімацію переходу між зображеннями\",\n            \"options\": \"Опції\"\n        },\n        \"unsaved_changes\": \"Незбережені зміни. Ви впевнені, що хочете піти?\",\n        \"delete_alert\": \"Наступні {count, plural, one {{singularEntity}} other {{pluralEntity}}} будуть видалені назавжди:\",\n        \"reassign_entity_title\": \"{count, plural, one {Перепризначити {singularEntity}} other {Перепризначити {pluralEntity}}}\",\n        \"merge\": {\n            \"empty_results\": \"Значення поля призначення залишаться без змін.\",\n            \"destination\": \"Призначення\",\n            \"source\": \"Джерело\"\n        },\n        \"clear_o_history_confirm\": \"Ви впевнені, що хочете очистити історію оргазмів?\",\n        \"delete_entity_desc\": \"{count, plural, one {Ви впевнені, що хочете видалити цей {singularEntity}? Якщо файл також не видалено, цей {singularEntity} буде знову додано під час сканування.} other {Ви впевнені, що хочете видалити ці {pluralEntity}? Якщо файли також не видалені, ці {pluralEntity} будуть знову додані під час сканування.}}\",\n        \"delete_confirm\": \"Ви впевнені, що хочете видалити {entityName}?\",\n        \"dont_show_until_updated\": \"Не показувати до наступного оновлення\",\n        \"performers_found\": \"Знайдено {count} виконавців\",\n        \"create_new_entity\": \"Створити новий {entity}\",\n        \"set_image_url_title\": \"URL зображення\",\n        \"reassign_files\": {\n            \"destination\": \"Перепризначити на\"\n        },\n        \"scenes_found\": \"Знайдено {count} сцен\",\n        \"scrape_entity_query\": \"Запит на скрейпінг {entity_type}\",\n        \"scrape_entity_title\": \"Результати скрейпінгу {entity_type}\",\n        \"clear_o_history_confirm_sfw\": \"Ви впевнені, що хочете очистити історію вподобань?\",\n        \"delete_alert_to_trash\": \"Наступні {count, plural, one {{singularEntity}} other {{pluralEntity}}} будуть переміщені до смітника:\",\n        \"delete_entity_title\": \"{count, plural, one {Видалити {singularEntity}} other {Видалити {pluralEntity}}}\",\n        \"delete_object_overflow\": \"…та {count} інших {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"export_title\": \"Експорт\",\n        \"overwrite_filter_warning\": \"Збережений фільтр \\\"{entityName}\\\" буде перезаписано.\",\n        \"stashid_exists_warning\": \"Наявний Stash ID для цього Stash-box буде замінено.\",\n        \"studios_found\": \"Знайдено {count} студій\",\n        \"tags_found\": \"Знайдено {count} тегів\",\n        \"scrape_results_existing\": \"Наявні\",\n        \"scrape_results_missing\": \"Відсутні\",\n        \"scrape_results_scraped\": \"Отримані\",\n        \"set_default_filter_confirm\": \"Ви впевнені, що хочете встановити цей фільтр як типовий?\",\n        \"edit_entity_count_title\": \"Редагувати {count} {count, plural, one {{singularEntity}} інший {{pluralEntity}}}\"\n    },\n    \"setup\": {\n        \"errors\": {\n            \"unable_to_retrieve_system_status\": \"Не вдалося отримати стан системи: {error}\",\n            \"unexpected_error\": \"Сталася несподівана помилка: {error}\",\n            \"something_went_wrong_description\": \"Якщо це схоже на помилку введення, натисніть «Назад», щоб виправити її. В іншому випадку створіть звіт про помилку на {githubLink} або зверніться за допомогою в {discordLink}.\",\n            \"something_went_wrong\": \"Ой ні! Щось пішло не так!\",\n            \"something_went_wrong_while_setting_up_your_system\": \"Щось пішло не так під час налаштування системи. Отримана помилка: {error}\"\n        },\n        \"paths\": {\n            \"where_can_stash_store_blobs_description\": \"Stash може зберігати бінарні дані, такі як обкладинки сцен, зображення виконавців, студій і тегів, або в базі даних, або у файловій системі. За замовчуванням ці дані зберігатимуться у файловій системі в підкаталозі <code>blobs</code>, розташованому в каталозі з вашим конфігураційним файлом. Якщо ви хочете змінити це, введіть абсолютний або відносний (щодо поточного робочого каталогу) шлях. Stash створить цей каталог, якщо він ще не існує.\",\n            \"where_can_stash_store_its_generated_content_description\": \"Для створення мініатюр, попередніх переглядів і спрайтів Stash генерує зображення та відео. Це також включає транскодування для непідтримуваних форматів файлів. За замовчуванням Stash створить каталог <code>generated</code> у директорії з вашим конфігураційним файлом. Якщо ви хочете змінити місце зберігання цього згенерованого контенту, введіть абсолютний або відносний (щодо поточного робочого каталогу) шлях. Stash створить цей каталог, якщо він ще не існує.\",\n            \"where_can_stash_store_its_database_warning\": \"УВАГА: зберігання бази даних на іншій системі, ніж та, на якій запущено Stash (наприклад, зберігання бази даних на NAS при запуску сервера Stash на іншому комп’ютері), <strong>не підтримується</strong>! SQLite не призначена для використання в мережі, і спроба зробити це може дуже легко призвести до пошкодження всієї вашої бази даних.\",\n            \"stash_alert\": \"Не вибрано жодного шляху до бібліотеки. Медіафайли не будуть скануватися в Stash. Ви впевнені?\",\n            \"database_filename_empty_for_default\": \"ім'я файлу бази даних (пусто — за замовчуванням)\",\n            \"description\": \"Далі нам потрібно визначити, де знаходиться ваш контент, а також де зберігати базу даних Stash, згенеровані файли та кеш. Ці налаштування можна змінити пізніше.\",\n            \"path_to_blobs_directory_empty_for_default\": \"шлях до каталогу blobs (пусто — за замовчуванням)\",\n            \"path_to_cache_directory_empty_for_default\": \"шлях до каталогу cache (пусто — за замовчуванням)\",\n            \"path_to_generated_directory_empty_for_default\": \"шлях до каталогу generated (пусто — за замовчуванням)\",\n            \"where_can_stash_store_blobs_description_addendum\": \"Альтернативно ви можете зберігати ці дані в базі даних. <strong>Примітка:</strong> Це збільшить розмір файлу бази даних і час міграції бази даних.\",\n            \"where_can_stash_store_blobs\": \"Де Stash може зберігати бінарні дані?\",\n            \"where_can_stash_store_cache_files\": \"Де Stash може зберігати файли кешу?\",\n            \"where_can_stash_store_its_database\": \"Де Stash може зберігати свою базу даних?\",\n            \"where_can_stash_store_its_database_description\": \"Stash використовує базу даних SQLite для зберігання метаданих вашої колекції. За замовчуванням вона буде створена як <code>stash-go.sqlite</code> у каталозі, що містить ваш конфігураційний файл. Якщо ви хочете змінити це, введіть абсолютне або відносне (щодо поточної робочої директорії) ім'я файлу.\",\n            \"where_is_your_porn_located\": \"Де знаходиться ваш контент?\",\n            \"where_is_your_porn_located_description\": \"Додайте каталоги з вашими відео та зображеннями. Stash використовуватиме їх для пошуку медіафайлів під час сканування.\",\n            \"where_can_stash_store_cache_files_description\": \"Для роботи деяких функцій, таких як HLS/DASH транскодування в реальному часі, Stash потребує каталог кешу для тимчасових файлів. За замовчуванням Stash створює каталог <code>cache</code> у директорії з вашим конфігураційним файлом. Якщо ви хочете змінити це, введіть абсолютний або відносний (щодо поточної робочої директорії) шлях. Stash створить цей каталог, якщо він ще не існує.\",\n            \"where_can_stash_store_its_generated_content\": \"Де Stash може зберігати згенерований контент?\",\n            \"store_blobs_in_database\": \"Зберігати блоби в базі даних\",\n            \"set_up_your_paths\": \"Налаштуйте ваші шляхи\",\n            \"sfw_content_settings\": \"Використовувати Stash для SFW-контенту?\",\n            \"sfw_content_settings_description\": \"stash можна використовувати для керування SFW-контентом, таким як фотографії, арт, комікси тощо. Увімкнення цієї опції адаптує інтерфейс для SFW-контенту.\",\n            \"use_sfw_content_mode\": \"Використовувати режим SFW-контенту\"\n        },\n        \"success\": {\n            \"in_app_manual_explained\": \"Рекомендуємо переглянути вбудований посібник, доступний через іконку у верхньому правому куті екрана: {icon}\",\n            \"next_config_step_two\": \"Коли ви будете задоволені цими налаштуваннями, ви можете почати сканування вашого контенту в Stash, натиснувши <code>{localized_task}</code>, а потім <code>{localized_scan}</code>.\",\n            \"open_collective\": \"Перегляньте {open_collective_link}, щоб дізнатися, як підтримати подальший розвиток Stash.\",\n            \"welcome_contrib\": \"Ми також вітаємо внесок у вигляді коду (виправлення помилок, нові функції), тестування, звітів про баги, запитів на покращення та підтримки користувачів. Деталі у розділі \\\"Contribution\\\" вбудованого посібника.\",\n            \"your_system_has_been_created\": \"Успіх! Вашу систему створено!\",\n            \"missing_ffmpeg\": \"Відсутній необхідний двійковий файл <code>ffmpeg</code>. Ви можете автоматично завантажити його у вашу конфігураційну директорію, відзначивши прапорець нижче. Альтернативно, ви можете вказати шляхи до двійкових файлів <code>ffmpeg</code> та <code>ffprobe</code> у системних налаштуваннях. Ці файли повинні бути присутніми для коректної роботи Stash.\",\n            \"help_links\": \"Якщо виникнуть проблеми або питання, не соромтеся створити запит на {githubLink} або запитати спільноту в {discordLink}.\",\n            \"next_config_step_one\": \"Далі ви перейдете на сторінку Конфігурації. Там можна налаштувати фільтри файлів, встановити логін і пароль для захисту системи та багато іншого.\",\n            \"download_ffmpeg\": \"Завантажити ffmpeg\",\n            \"getting_help\": \"Отримання допомоги\",\n            \"support_us\": \"Підтримайте нас\",\n            \"thanks_for_trying_stash\": \"Дякуємо, що спробували Stash!\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash буде використовувати наступний шлях до файлу конфігурації: <code>{path}</code>\",\n            \"unable_to_locate_specified_config\": \"Stash не знайшов файл конфігурації, вказаний у командному рядку або середовищі. Цей майстер допоможе вам налаштувати нову конфігурацію.\",\n            \"next_step\": \"Коли будете готові налаштувати нову систему, натисніть Далі.\"\n        },\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"Шлях для резервної копії БД (пусто — не створювати):\",\n            \"backup_recommended\": \"Рекомендується зробити резервну копію вашої існуючої бази даних перед міграцією. Ми можемо зробити це за вас, створивши копію вашої бази даних у <code>{defaultBackupPath}</code>.\",\n            \"migration_failed_help\": \"Будь ласка, внесіть необхідні виправлення та спробуйте ще раз. Інакше повідомте про помилку на {githubLink} або зверніться за допомогою до {discordLink}.\",\n            \"migration_irreversible_warning\": \"Процес міграції схеми незворотний. Після виконання міграції ваша база даних буде несумісна з попередніми версіями Stash.\",\n            \"schema_too_old\": \"Ваша поточна база даних Stash має версію схеми <strong>{databaseSchema}</strong> і потребує міграції до версії <strong>{appSchema}</strong>. Ця версія Stash не працюватиме без міграції бази даних. Якщо ви не бажаєте виконувати міграцію, вам доведеться повернутися до версії, яка відповідає схемі вашої бази даних.\",\n            \"migration_failed_error\": \"Під час міграції бази даних сталася така помилка:\",\n            \"perform_schema_migration\": \"Виконати міграцію схеми\",\n            \"migration_required\": \"Потрібна міграція\",\n            \"migration_notes\": \"Примітки до міграції\",\n            \"migrating_database\": \"Міграція бази даних\",\n            \"migration_failed\": \"Міграція не вдалася\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash спочатку шукає файл конфігурації (<code>config.yml</code>) у поточному робочому каталозі. Якщо не знаходить, використовує <code>{fallback_path}</code>. Ви можете вказати конкретний файл, запустивши Stash з параметром <code>-c '<шлях до файлу>'</code> або <code>--config '<шлях до файлу>'</code>.\",\n            \"in_the_current_working_directory\": \"У <code>{path}</code>, робочий каталог, наразі:\",\n            \"next_step\": \"Тепер, якщо ви готові продовжити налаштування нової системи, оберіть місце для збереження файлу конфігурації.\",\n            \"in_the_current_working_directory_disabled_macos\": \"Не підтримується під час запуску <code>Stash.app</code>,<br></br>запустіть <code>stash-macos</code>, щоб налаштувати в робочому каталозі\",\n            \"store_stash_config\": \"Де ви хочете зберігати конфігурацію Stash?\",\n            \"in_the_current_working_directory_disabled\": \"У <code>{path}</code>, робочий каталог:\",\n            \"unable_to_locate_config\": \"Якщо ви це читаєте, Stash не знайшов існуючої конфігурації. Цей майстер допоможе вам налаштувати нову.\",\n            \"unexpected_explained\": \"Якщо цей екран з'явився несподівано, спробуйте перезапустити Stash у правильному робочому каталозі або з прапором <code>-c</code>.\",\n            \"in_current_stash_directory\": \"У директорії <code>{path}</code>:\"\n        },\n        \"confirm\": {\n            \"almost_ready\": \"Ми майже готові завершити налаштування. Будь ласка, перевірте наступні параметри. Ви можете натиснути «Назад», щоб виправити помилки. Якщо все вірно, натисніть «Підтвердити» для створення вашої системи.\",\n            \"blobs_directory\": \"Каталог бінарних даних\",\n            \"cache_directory\": \"Каталог кешу\",\n            \"configuration_file_location\": \"Розташування файлу конфігурації:\",\n            \"stash_library_directories\": \"Каталоги бібліотеки Stash\",\n            \"nearly_there\": \"Майже готово!\",\n            \"generated_directory\": \"Каталог згенерованих файлів\",\n            \"database_file_path\": \"Шлях до файлу бази даних\",\n            \"blobs_use_database\": \"<використовується база даних>\"\n        },\n        \"stash_setup_wizard\": \"Майстер налаштування Stash\",\n        \"creating\": {\n            \"creating_your_system\": \"Створення вашої системи\"\n        },\n        \"folder\": {\n            \"file_path\": \"Шлях до файлу\",\n            \"up_dir\": \"На рівень вище\"\n        },\n        \"welcome_to_stash\": \"Ласкаво просимо до Stash\",\n        \"github_repository\": \"Репозиторій Github\"\n    },\n    \"validation\": {\n        \"required\": \"${path} є обов'язковим полем\",\n        \"date_invalid_form\": \"${path} має бути у форматі YYYY, YYYY-MM або YYYY-MM-DD\",\n        \"end_time_before_start_time\": \"Час завершення має бути більшим або рівним часу початку\",\n        \"blank\": \"${path} не може бути порожнім\",\n        \"unique\": \"${path} має бути унікальним\"\n    },\n    \"countables\": {\n        \"performers\": \"{count, plural, one {Виконавець} other {Виконавці}}\",\n        \"studios\": \"{count, plural, one {Студія} other {Студії}}\",\n        \"tags\": \"{count, plural, one {Тег} other {Теги}}\",\n        \"galleries\": \"{count, plural, one {Галерея} other {Галереї}}\",\n        \"groups\": \"{count, plural, one {Група} other {Групи}}\",\n        \"scenes\": \"{count, plural, one {Сцена} other {Сцени}}\",\n        \"files\": \"{count, plural, one {Файл} other {Файли}}\",\n        \"images\": \"{count, plural, one {Зображення} other {Зображення}}\",\n        \"markers\": \"{count, plural, one {Позначка} other {Позначки}}\"\n    },\n    \"criterion_modifier\": {\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {всі} other {{depth}}})\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"matches_regex\": \"відповідає регулярному виразу\",\n        \"not_between\": \"не між\",\n        \"less_than\": \"менше ніж\",\n        \"not_null\": \"не порожнє\",\n        \"not_matches_regex\": \"не відповідає регулярному виразу\",\n        \"not_equals\": \"не дорівнює\",\n        \"greater_than\": \"більше ніж\",\n        \"includes_all\": \"містить всі\",\n        \"is_null\": \"порожнє\",\n        \"between\": \"між\",\n        \"excludes\": \"не містить\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (крім {excludedString})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (крім {excludedString}) (+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"includes\": \"містить\",\n        \"equals\": \"дорівнює\"\n    },\n    \"toast\": {\n        \"rescanning_entity\": \"Пересканування {count, plural, one {{singularEntity}} other {{pluralEntity}}}…\",\n        \"added_entity\": \"Додано {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"Додано завдання генерації в чергу\",\n        \"image_index_too_large\": \"Помилка: Індекс зображення більший за кількість зображень у Галереї\",\n        \"delete_past_tense\": \"Видалено {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"removed_entity\": \"Вилучено {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"created_entity\": \"Створено {entity}\",\n        \"started_auto_tagging\": \"Розпочато автотегування\",\n        \"started_generating\": \"Розпочато генерацію\",\n        \"saved_entity\": \"Збережено {entity}\",\n        \"reassign_past_tense\": \"Файл перепризначено\",\n        \"merged_tags\": \"Теги об'єднано\",\n        \"started_importing\": \"Розпочато імпорт\",\n        \"updated_entity\": \"Оновлено {entity}\",\n        \"merged_scenes\": \"Сцени об'єднано\",\n        \"generating_screenshot\": \"Генерація скріншоту…\",\n        \"default_filter_set\": \"Встановлено типовий фільтр\",\n        \"merged_performers\": \"Виконавців об'єднано\",\n        \"clipboard_access_denied\": \"Доступ до буфера обміну заборонено. Перевірте дозволи браузера\",\n        \"clipboard_image_pasted\": \"Зображення вставлено з буфера обміну\",\n        \"clipboard_no_image\": \"У буфері обміну не знайдено зображення\"\n    },\n    \"empty_server\": \"Додайте кілька сцен на свій сервер, щоб побачити рекомендації на цій сторінці.\",\n    \"appears_with\": \"З'являється разом із\",\n    \"chapters\": \"Розділи\",\n    \"circumcised_types\": {\n        \"CUT\": \"Обрізаний\",\n        \"UNCUT\": \"Необрізаний\"\n    },\n    \"circumcised\": \"Обрізаний\",\n    \"career_length\": \"Тривалість кар'єри\",\n    \"hasMarkers\": \"Має маркери\",\n    \"stashbox\": {\n        \"go_review_draft\": \"Перейдіть до {endpoint_name}, щоб переглянути чернетку.\",\n        \"selected_stash_box\": \"Вибрана кінцева точка Stash-Box\",\n        \"source\": \"Джерело Stash-Box\",\n        \"submission_successful\": \"Надсилання успішне\",\n        \"submit_update\": \"Вже існує в {endpoint_name}\",\n        \"submission_failed\": \"Не вдалося надіслати\"\n    },\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"Не вдалося встановити з'єднання websocket: дивіться консоль браузера для деталей\",\n        \"websocket_connection_reestablished\": \"З'єднання websocket відновлено\"\n    },\n    \"time_end\": \"Час завершення\",\n    \"created_at\": \"Створено\",\n    \"criterion\": {\n        \"greater_than\": \"Більше ніж\",\n        \"less_than\": \"Менше ніж\",\n        \"value\": \"Значення\",\n        \"unsupported\": \"{type} (непідтримуваний)\"\n    },\n    \"containing_group\": \"Батьківська група\",\n    \"cover_image\": \"Обкладинка\",\n    \"duplicated_phash\": \"Дубльовано (pHash)\",\n    \"custom_fields\": {\n        \"title\": \"Користувацькі поля\",\n        \"field\": \"Поле\",\n        \"value\": \"Значення\",\n        \"criteria_format_string\": \"{criterion} (користувацьке поле) {modifierString} {valueString}\",\n        \"criteria_format_string_others\": \"{criterion} (користувацьке поле) {modifierString} {valueString} (+{others} інших)\"\n    },\n    \"death_date\": \"Дата смерті\",\n    \"developmentVersion\": \"Версія для розробників\",\n    \"datetime_format\": \"РРРР-ММ-ДД ГГ:ХХ\",\n    \"existing_value\": \"наявне значення\",\n    \"ignore_auto_tag\": \"Ігнорувати авто тегування\",\n    \"performer_image\": \"Зображення виконавця\",\n    \"sceneTagger\": \"Тегувальник сцен\",\n    \"stats\": {\n        \"total_play_count\": \"Загальна кількість переглядів\",\n        \"total_o_count\": \"Всього оргазмів\",\n        \"scenes_size\": \"Розмір сцен\",\n        \"scenes_played\": \"Переглянуто сцен\",\n        \"scenes_duration\": \"Тривалість сцен\",\n        \"image_size\": \"Розмір зображень\",\n        \"total_play_duration\": \"Загальна тривалість перегляду\",\n        \"total_o_count_sfw\": \"Всього лайків\"\n    },\n    \"studio_depth\": \"Рівні (пусто — усі)\",\n    \"studio_count\": \"Кількість студій\",\n    \"studio_and_parent\": \"Студія та батьківська компанія\",\n    \"sub_group_of\": \"Підгрупа {parent}\",\n    \"sub_group_count\": \"Кількість підгруп\",\n    \"updated_at\": \"Оновлено\",\n    \"unknown_date\": \"Невідома дата\",\n    \"years_old\": \"років\",\n    \"weight_kg\": \"Вага (кг)\",\n    \"view_all\": \"Переглянути все\",\n    \"zip_file_count\": \"Кількість Zip-файлів\",\n    \"eye_color\": \"Колір очей\",\n    \"fake_tits\": \"Штучні груди\",\n    \"file_count\": \"Кількість файлів\",\n    \"file_mod_time\": \"Час зміни файлу\",\n    \"files_amount\": \"{value} файлів\",\n    \"include_parent_tags\": \"Включати батьківські теги\",\n    \"include_sub_studios\": \"Включати дочірні студії\",\n    \"last_o_at\": \"Останній оргазм\",\n    \"last_played_at\": \"Останній перегляд\",\n    \"parent_studio\": \"Батьківська студія\",\n    \"parent_studios\": \"Батьківські студії\",\n    \"parent_tag_count\": \"Кількість батьківських тегів\",\n    \"parent_tags\": \"Батьківські теги\",\n    \"penis_length_cm\": \"Довжина пеніса (см)\",\n    \"penis_length\": \"Довжина пеніса\",\n    \"part_of\": \"Частина {parent}\",\n    \"performer_age\": \"Вік виконавця\",\n    \"perceptual_similarity\": \"Перцептивна схожість (pHash)\",\n    \"performer_count\": \"Кількість виконавців\",\n    \"performer_favorite\": \"Улюблений виконавець\",\n    \"performer_tags\": \"Теги виконавця\",\n    \"playdate_recorded_no\": \"Дата відтворення не записана\",\n    \"play_history\": \"Історія відтворень\",\n    \"play_duration\": \"Тривалість відтворення\",\n    \"recently_added_objects\": \"Нещодавно додані {objects}\",\n    \"primary_tag\": \"Основний тег\",\n    \"scene_tags\": \"Теги сцени\",\n    \"stash_id_endpoint\": \"URL кінцевої точки Stash ID\",\n    \"stash_id\": \"Stash ID\",\n    \"stash_ids\": \"Ідентифікатори Stash\",\n    \"include_sub_group_content\": \"Включати вміст підгрупи\",\n    \"parent_of\": \"Батьківський для {children}\",\n    \"scene_updated_at\": \"Сцену оновлено\",\n    \"scenes_updated_at\": \"Сцена оновлена\",\n    \"include_sub_tag_content\": \"Включати вміст підтегів\",\n    \"include_sub_studio_content\": \"Включати вміст підстудії\",\n    \"containing_groups\": \"Батьківські групи\",\n    \"containing_group_count\": \"Кількість батьківських груп\",\n    \"death_year\": \"Рік смерті\",\n    \"file_info\": \"Інформація про файл\",\n    \"resume_time\": \"Час відновлення\",\n    \"release_notes\": \"Примітки до випуску\",\n    \"recently_released_objects\": \"Нещодавно випущені {objects}\",\n    \"play_count\": \"Кількість відтворень\",\n    \"scene_created_at\": \"Сцену створено\",\n    \"scene_count\": \"Кількість сцен\",\n    \"scene_code\": \"Код студії\",\n    \"status\": \"Статус: {statusText}\",\n    \"sub_group_order\": \"Порядок підгрупи\",\n    \"index_of_total\": \"{index} з {total}\",\n    \"include_sub_groups\": \"Включати підгрупи\",\n    \"odate_recorded_no\": \"Дата оргазму не записана\",\n    \"scene_id\": \"ID сцени\",\n    \"scene_date\": \"Дата сцени\",\n    \"primary_file\": \"Основний файл\",\n    \"plays\": \"{value} відтворень\",\n    \"subsidiary_studios\": \"Дочірні студії\",\n    \"subsidiary_studio_count\": \"Кількість дочірніх студій\",\n    \"age_on_date\": \"{age} років на момент зйомок\",\n    \"configuration\": \"Конфігурація\",\n    \"country\": \"Країна\",\n    \"custom\": \"Користувацьке\",\n    \"date\": \"Дата\",\n    \"date_format\": \"РРРР-ММ-ДД\",\n    \"descending\": \"За спаданням\",\n    \"description\": \"Опис\",\n    \"detail\": \"Деталі\",\n    \"details\": \"Деталі\",\n    \"dimensions\": \"Розміри\",\n    \"director\": \"Режисер\",\n    \"disambiguation\": \"Уточнення\",\n    \"display_mode\": {\n        \"grid\": \"Сітка\",\n        \"label_current\": \"Режим відображення: {current}\",\n        \"list\": \"Список\",\n        \"tagger\": \"Тегувальник\",\n        \"unknown\": \"Невідомо\",\n        \"wall\": \"Стіна\"\n    },\n    \"distance\": \"Відстань\",\n    \"donate\": \"Підтримати\",\n    \"eta\": \"Розрахунковий час\",\n    \"filesize\": \"Розмір файлу\",\n    \"filter_name\": \"Назва фільтру\",\n    \"framerate\": \"Частота кадрів\",\n    \"frames_per_second\": \"{value} к/с\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"Готовий фільтр\",\n            \"saved_filter\": \"Збережений фільтр\"\n        }\n    },\n    \"gallery_count\": \"Кількість галерей\",\n    \"group_count\": \"Кількість груп\",\n    \"group_scene_number\": \"Номер сцени\",\n    \"hair_color\": \"Колір волосся\",\n    \"hasChapters\": \"Має розділи\",\n    \"height_cm\": \"Зріст (см)\",\n    \"image_count\": \"Кількість зображень\",\n    \"image_index\": \"№ зображення\",\n    \"include_sub_tags\": \"Включати підтеги\",\n    \"interactive_speed\": \"Швидкість інтерактиву\",\n    \"isMissing\": \"Відсутній\",\n    \"last_o_at_sfw\": \"Останній лайк\",\n    \"login\": {\n        \"login\": \"Вхід\",\n        \"username\": \"Ім'я користувача\",\n        \"password\": \"Пароль\",\n        \"invalid_credentials\": \"Невірне ім'я користувача або пароль\",\n        \"internal_error\": \"Неочікувана внутрішня помилка. Дивіться журнал для деталей\"\n    },\n    \"marker_count\": \"Кількість позначок\",\n    \"megabits_per_second\": \"{value} Мбіт/с\",\n    \"sort_name\": \"Назва для сортування\",\n    \"o_count\": \"Кількість оргазмів\",\n    \"o_count_sfw\": \"Лайки\",\n    \"o_history\": \"Історія оргазмів\",\n    \"o_history_sfw\": \"Історія лайків\",\n    \"odate_recorded_no_sfw\": \"Дата лайку не записана\",\n    \"organized\": \"Впорядковано\",\n    \"scenes_duration\": \"Тривалість сцени\",\n    \"stashbox_search\": {\n        \"header\": \"Пошук {entityType} у StashBox\",\n        \"no_results\": \"Результатів не знайдено.\",\n        \"placeholder_name_or_id\": \"Ім'я {entityType} або StashID...\",\n        \"select_stashbox\": \"Оберіть StashBox...\"\n    },\n    \"latest_scene\": \"Остання сцена\",\n    \"stash_id_count\": \"Кількість Stash ID\",\n    \"duplicated_stash_id\": \"Дубльований (ідентифікатор Stash)\",\n    \"duplicated_title\": \"Дубльовано (Назва)\",\n    \"duplicated\": \"Дубльовано\",\n    \"career_end\": \"Кінець кар'єри\",\n    \"career_start\": \"Початок кар'єри\",\n    \"tag_tagger\": {\n        \"add_new_tags\": \"Додати нові теги\",\n        \"any_names_entered_will_be_queried\": \"Будь-які введені імена будуть запитуватися з віддаленого екземпляра Stash-Box та додаватися, якщо будуть знайдені. Збігом вважатимуться лише точні збіги.\",\n        \"batch_add_tags\": \"Додавання тегів пакетом\",\n        \"no_results_found\": \"Результатів не знайдено.\",\n        \"query_all_tags_in_the_database\": \"Усі теги в базі даних\",\n        \"refresh_tagged_tags\": \"Оновити позначені теги\",\n        \"current_page\": \"Поточна сторінка\",\n        \"failed_to_save_tag\": \"Не вдалося зберегти тег \\\"{tag}\\\"\",\n        \"name_already_exists\": \"Ім'я вже існує\",\n        \"network_error\": \"Помилка мережі\",\n        \"number_of_tags_will_be_processed\": \"{tag_count} теги будуть оброблені\",\n        \"refreshing_will_update_the_data\": \"Оновлення оновить дані будь-яких позначених тегами з екземпляра stash-box.\",\n        \"status_tagging_job_queued\": \"Статус: Завдання додавання тегів поставлено в чергу\",\n        \"status_tagging_tags\": \"Статус: Позначення тегами\",\n        \"tag_already_tagged\": \"Тег уже позначено\",\n        \"tag_names_or_stashids_separated_by_comma\": \"Назви тегів або StashID, розділені комами\",\n        \"tag_selection\": \"Вибір тегу\",\n        \"tag_successfully_tagged\": \"Тег успішно позначено тегом\",\n        \"tag_status\": \"Стан тегу\",\n        \"to_use_the_tag_tagger\": \"Щоб використовувати теґер, потрібно налаштувати екземпляр stash-box.\",\n        \"untagged_tags\": \"Нетеговані теги\",\n        \"update_tags\": \"Оновити теги\",\n        \"updating_untagged_tags_description\": \"Оновлення нетегованих тегів спробує знайти будь-які теги, яким бракує stashid, та оновити метадані.\",\n        \"batch_update_tags\": \"Теги пакетного оновлення\",\n        \"config\": {\n            \"create_parent_desc\": \"Створити відсутні головні теги з категорій stash-box або прив'язати наявні головні теги за точним збігом назви\",\n            \"create_parent_label\": \"Створити головні теги\"\n        },\n        \"create_or_tag_parent_tags\": \"Створити відсутні або прив'язати наявні головні теги\"\n    },\n    \"tagger\": {\n        \"config\": {\n            \"active_stash-box_instance\": \"Активний екземпляр сховища:\",\n            \"edit_excluded_fields\": \"Редагувати виключені поля\",\n            \"excluded_fields\": \"Виключені поля:\",\n            \"fields_will_not_be_changed\": \"Ці поля не будуть змінені під час оновлення {entity}.\",\n            \"no_fields_are_excluded\": \"Жодне поле не виключено\",\n            \"no_instances_found\": \"Не знайдено жодних екземплярів\"\n        }\n    },\n    \"unsupported_criteria\": \"Непідтримувані критерії: {criteria}\",\n    \"include_sub_folders\": \"Включити підпапки\",\n    \"sub_folder_depth\": \"Глибина підпапок (порожня для всіх)\",\n    \"sub_folders\": \"Підпапки\",\n    \"parent_folder\": \"Основна папка\",\n    \"empty_value\": \"порожній\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/ur-PK.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"شامل کریں\",\n        \"allow\": \"اجازت دیں\",\n        \"add_directory\": \"ڈکشنری میں شامل کریں\",\n        \"cancel\": \"منسوخ کریں\",\n        \"add_manual_date\": \"تاریخ شامل کریں\",\n        \"add_o\": \"مٹھ کی گنتئ بڑہاین\",\n        \"add_play\": \"پلۓ شامل کریں\",\n        \"allow_temporarily\": \"وقتئ اجازت دیں\",\n        \"anonymise\": \"بےنام کریں\",\n        \"apply\": \"لاگو کریں\"\n    }\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/vi-VN.json",
    "content": "{\n    \"actions\": {\n        \"download_backup\": \"Tải xuống sao lưu\",\n        \"edit\": \"Hiệu chỉnh\",\n        \"edit_entity\": \"Hiệu chỉnh {entityType}\",\n        \"enable\": \"Cho phép\",\n        \"add_directory\": \"Thêm Thư mục\",\n        \"add_entity\": \"Thêm {entityType}\",\n        \"add_sub_groups\": \"Thêm nhóm phụ đề\",\n        \"add_o\": \"Thêm O\",\n        \"add_play\": \"Thêm Phát\",\n        \"add_to_entity\": \"Thêm vào {entityType}\",\n        \"allow\": \"Cho phép\",\n        \"allow_temporarily\": \"Cho phép tạm thời\",\n        \"anonymise\": \"Ẩn danh\",\n        \"apply\": \"Áp dụng\",\n        \"auto_tag\": \"Tag tự động\",\n        \"backup\": \"Sao lưu\",\n        \"browse_for_image\": \"Đường dẫn ảnh…\",\n        \"cancel\": \"Hủy bỏ\",\n        \"choose_date\": \"Chọn ngày\",\n        \"clean\": \"Làm sạch\",\n        \"clean_generated\": \"Làm sạch files đã tạo\",\n        \"clear_date_data\": \"Xóa dữ liệu ngày\",\n        \"clear_front_image\": \"Xóa ảnh bìa trước\",\n        \"clear_image\": \"Xóa ảnh\",\n        \"close\": \"Đóng\",\n        \"confirm\": \"Xác nhận\",\n        \"continue\": \"Tiếp tục\",\n        \"copy_to_clipboard\": \"Chép vào bộ nhớ tạm\",\n        \"create\": \"Tạo\",\n        \"create_chapters\": \"Tạo chapter\",\n        \"create_marker\": \"Tạo Maker\",\n        \"create_parent_studio\": \"Tạo studio\",\n        \"created_entity\": \"Tạo {entity_type}: {entity_name}\",\n        \"customise\": \"Tùy chỉnh\",\n        \"delete\": \"Xóa\",\n        \"delete_entity\": \"Xóa {entityType}\",\n        \"delete_file\": \"Xóa file\",\n        \"delete_file_and_funscript\": \"Xóa file (và script)\",\n        \"disable\": \"Vô hiệu hóa\",\n        \"disallow\": \"Không cho phép\",\n        \"download\": \"Tải xuống\",\n        \"export\": \"Xuất\",\n        \"find\": \"Tìm kiếm\",\n        \"finish\": \"Hoàn thành\",\n        \"from_file\": \"Từ file…\",\n        \"from_url\": \"Từ URL…\",\n        \"full_export\": \"Xuất đầy đủ\",\n        \"delete_generated_supporting_files\": \"Xóa các tập tin hỗ trợ đã tạo\",\n        \"generate\": \"Tạo\",\n        \"assign_stashid_to_parent_studio\": \"Gán Stash ID cho studio hiện có và cập nhật dữ liệu\",\n        \"full_import\": \"Nhập đầy đủ\",\n        \"import\": \"Nhập…\",\n        \"migrate_scene_screenshots\": \"Nối ảnh chụp Scene\",\n        \"generate_thumb_default\": \"Tạo thumbnail mặc định\",\n        \"add\": \"Thêm\",\n        \"clear_back_image\": \"Xóa ảnh bìa\",\n        \"download_anonymised\": \"Tải xuống ẩn danh\",\n        \"encoding_image\": \"Đang mã hóa ảnh…\",\n        \"export_all\": \"Xuất tất cả…\",\n        \"remove_from_gallery\": \"Xóa từ Thư viện\",\n        \"generate_thumb_from_current\": \"Tạo thumbnail từ hiện tại\",\n        \"hash_migration\": \"nối hash\",\n        \"hide\": \"Ẩn\",\n        \"hide_configuration\": \"Ẩn cấu hình\",\n        \"identify\": \"Xác thực\",\n        \"ignore\": \"Bỏ qua\",\n        \"import_from_file\": \"Nhập từ file\",\n        \"logout\": \"Đăng xuất\",\n        \"make_primary\": \"Chính\",\n        \"merge\": \"Nối\",\n        \"migrate_blobs\": \"Nối Blobs\",\n        \"next_action\": \"Kế tiếp\",\n        \"not_running\": \"không khởi chạy\",\n        \"open_in_external_player\": \"Mở bằng trình phát bên ngoài\",\n        \"open_random\": \"Mở ngẫu nhiên\",\n        \"optimise_database\": \"Tối ưu Database\",\n        \"overwrite\": \"Ghi đè\",\n        \"play_random\": \"Phát ngẫu nhiên\",\n        \"play_selected\": \"Phát tệp đã chọn\",\n        \"preview\": \"Xem trước\",\n        \"previous_action\": \"Quay lại\",\n        \"refresh\": \"Làm tươi\",\n        \"reload\": \"Tải lại\",\n        \"reload_plugins\": \"Tải lại plugins\",\n        \"reload_scrapers\": \"Tải lại scrapers\",\n        \"remove\": \"Xóa\",\n        \"remove_date\": \"Xóa ngày\",\n        \"remove_from_containing_group\": \"Xóa từ Nhóm\",\n        \"rename_gen_files\": \"Đổi tên file đã tạo\",\n        \"rescan\": \"Quét lại\",\n        \"reset_play_duration\": \"Reset thời gian đã phát\",\n        \"reset_resume_time\": \"Reset thời gian phát tiếp\",\n        \"reset_cover\": \"Khôi phục ảnh bìa mặc định\",\n        \"reshuffle\": \"Xáo trộn lại\",\n        \"running\": \"đang chạy\",\n        \"save\": \"Lưu\",\n        \"save_filter\": \"Lưu bộ lọc\",\n        \"scan\": \"Quét\",\n        \"scrape\": \"Scrape\",\n        \"scrape_query\": \"Truy vấn scrape\",\n        \"scrape_scene_fragment\": \"Scrape theo mảnh\",\n        \"scrape_with\": \"Scrape với…\",\n        \"search\": \"Tìm kiếm\",\n        \"select_all\": \"Chọn tất cả\",\n        \"add_manual_date\": \"Thêm ngày thủ công\",\n        \"clear\": \"Xóa\",\n        \"create_entity\": \"Tạo {entityType}\",\n        \"reassign\": \"Gán lại\",\n        \"save_delete_settings\": \"Sử dụng các tùy chọn này theo mặc định khi xóa\",\n        \"select_folders\": \"Chọn thư mục\",\n        \"select_none\": \"Không chọn\",\n        \"selective_auto_tag\": \"Chọn Tag tự động\",\n        \"selective_clean\": \"Không chọn\",\n        \"selective_scan\": \"Chọn Scan\",\n        \"set_as_default\": \"Đặt mặc định\",\n        \"set_back_image\": \"Ảnh sau…\",\n        \"set_cover\": \"Đặt làm ảnh bìa\",\n        \"set_image\": \"Đặt ảnh…\",\n        \"show\": \"Hiển thị\",\n        \"show_configuration\": \"Hiển thị cấu hình\",\n        \"skip\": \"Bỏ qua\",\n        \"split\": \"Chia\",\n        \"stop\": \"Dừng\",\n        \"submit\": \"Gửi\",\n        \"submit_stash_box\": \"Gửi đến Stash-Box\",\n        \"submit_update\": \"Gửi cập nhật\",\n        \"swap\": \"Đổi\",\n        \"tasks\": {\n            \"dry_mode_selected\": \"Chế độ thử nghiệm đã được chọn. Sẽ không có việc xóa thực tế nào diễn ra, chỉ ghi lại nhật ký.\",\n            \"clean_confirm_message\": \"Bạn có chắc chắn muốn làm sạch không? Thao tác này sẽ xóa thông tin cơ sở dữ liệu và nội dung đã tạo cho tất cả các cảnh và bộ sưu tập không còn tồn tại trong hệ thống tệp.\",\n            \"import_warning\": \"Bạn có chắc chắn muốn nhập không? Thao tác này sẽ xóa cơ sở dữ liệu và nhập lại từ siêu dữ liệu đã xuất của bạn.\"\n        },\n        \"temp_disable\": \"Vô hiệu hóa tạm thời…\",\n        \"temp_enable\": \"Kích hoạt tạm thời…\",\n        \"unset\": \"Bỏ thiết lập\",\n        \"use_default\": \"Dùng thiết lập mặc định\",\n        \"view_history\": \"Xem lịch sử\",\n        \"view_random\": \"Xem ngẫu nhiên\",\n        \"set_front_image\": \"Ảnh trước…\",\n        \"select_entity\": \"Chọn {entityType}\",\n        \"play\": \"Phát\",\n        \"show_results\": \"Hiển thị kết quả\",\n        \"show_count_results\": \"Hiển thị {count} kết quả\",\n        \"sidebar\": {\n            \"toggle\": \"Bật thanh bên\",\n            \"open\": \"Mở thanh bên\",\n            \"close\": \"Đóng thanh bên\"\n        },\n        \"load\": \"Nạp\",\n        \"load_filter\": \"Nạp bộ lọc\"\n    },\n    \"actions_name\": \"Hành động\",\n    \"age\": \"Tuổi\",\n    \"aliases\": \"Bí danh\",\n    \"all\": \"tất cả\",\n    \"also_known_as\": \"Còn được biết đến là\",\n    \"appears_with\": \"Xuất hiện với\",\n    \"ascending\": \"Tăng dần\",\n    \"audio_codec\": \"Bộ giải mã âm thanh\",\n    \"average_resolution\": \"Độ phân giải trung bình\",\n    \"between_and\": \"và\",\n    \"birth_year\": \"Năm sinh\",\n    \"birthdate\": \"Ngày sinh\",\n    \"bitrate\": \"Bit Rate\",\n    \"blobs_storage_type\": {\n        \"database\": \"Cơ sở dữ liệu\",\n        \"filesystem\": \"Tập tin hệ thống\"\n    },\n    \"captions\": \"Tiêu đề\",\n    \"career_length\": \"Tuổi nghề\",\n    \"chapters\": \"Chương\",\n    \"circumcised_types\": {\n        \"CUT\": \"Cắt\",\n        \"UNCUT\": \"Không cắt\"\n    },\n    \"circumcised\": \"Đã cắt bao quy đầu\",\n    \"component_tagger\": {\n        \"config\": {\n            \"blacklist_desc\": \"Các mục trong danh sách đen sẽ bị loại trừ khỏi các truy vấn. Lưu ý rằng chúng là các biểu thức chính quy và không phân biệt chữ hoa chữ thường. Một số ký tự cần phải được thoát bằng dấu gạch chéo ngược: {chars_require_escape}\",\n            \"blacklist_label\": \"Danh sách đen\",\n            \"mark_organized_desc\": \"Ngay lập tức đánh dấu cảnh là Đã sắp xếp sau khi nhấn nút Lưu.\",\n            \"active_instance\": \"Phiên bản stash-box đang hoạt động:\",\n            \"mark_organized_label\": \"Đánh dấu là Đã sắp xếp khi lưu.\",\n            \"query_mode_auto\": \"Tự động\",\n            \"query_mode_auto_desc\": \"Sử dụng siêu dữ liệu nếu có, hoặc tên tệp\",\n            \"query_mode_filename\": \"Tên tệp tin\",\n            \"query_mode_filename_desc\": \"Chỉ dùng tên tệp tin\",\n            \"query_mode_label\": \"Chế độ truy vấn\",\n            \"query_mode_path\": \"Đường dẫn\",\n            \"query_mode_path_desc\": \"Dùng toàn bộ đường dẫn tệp tin\",\n            \"set_cover_desc\": \"Thay thế bìa nền nếu đã được tìm thấy.\",\n            \"set_cover_label\": \"Đặt ảnh bìa nền\",\n            \"set_tag_label\": \"Đặt các thẻ\",\n            \"query_mode_dir_desc\": \"Chỉ dùng thư mục chính của file video\",\n            \"set_tag_desc\": \"Đính kèm các thẻ vào cảnh, bằng cách ghi đè hoặc ghép với các thẻ có sẵn trên cảnh.\",\n            \"source\": \"Nguồn\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"Các mục bị trùng lặp trong danh sách đen\"\n            },\n            \"query_mode_dir\": \"Danh sách\",\n            \"query_mode_metadata_desc\": \"Chỉ dùng dữ liệu mô tả\",\n            \"query_mode_metadata\": \"Thông tin mô tả\"\n        },\n        \"noun_query\": \"Truy vấn\",\n        \"results\": {\n            \"duration_unknown\": \"Thời gian không xác định\",\n            \"fp_matches\": \"Thời lượng tương ứng\",\n            \"fp_matches_multi\": \"Thời lượng khớp {matchCount}/{durationsLength} dấu vân tay\",\n            \"hash_matches\": \"Khớp với {hash_type}\",\n            \"match_failed_already_tagged\": \"Phân cảnh đã được gắn thẻ\",\n            \"match_failed_no_result\": \"Không có kết quả được tìm thấy\",\n            \"match_success\": \"Phân cảnh đã được gán thẻ thành công\",\n            \"phash_matches\": \"Khớp với {count} PHashes\",\n            \"duration_off\": \"Thời gian tắt ít nhất {number} giây\",\n            \"fp_found\": \"{fpCount, plural, =0 {Không có dấu vết trùng khớp mới được tìm thấy} other {# dấu vết trùng khớp mới đã được tìm thấy}}\",\n            \"unnamed\": \"Chưa được đặt tên\"\n        },\n        \"verb_match_fp\": \"Các dấu vân tay trùng khớp\",\n        \"verb_matched\": \"Trùng khớp\",\n        \"verb_scrape_all\": \"Loại bỏ tất cả\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} phân cảnh không trùng khớp\",\n        \"verb_submit_fp\": \"Gửi {fpCount, plural, one{# Fingerprint} other{# Fingerprint}}\"\n    },\n    \"config\": {\n        \"about\": {\n            \"new_version_notice\": \"[MỚI]\",\n            \"build_hash\": \"Mã băm bản xây dựng:\",\n            \"check_for_new_version\": \"Kiểm tra phiên bản mới\",\n            \"latest_version\": \"Phiên bản mới nhất\",\n            \"latest_version_build_hash\": \"Mã bản dựng mới nhất:\",\n            \"release_date\": \"Ngày phát hành:\",\n            \"stash_discord\": \"Tham gia vào kênh {url} của chúng tôi\",\n            \"stash_open_collective\": \"Giúp đỡ chúng tôi qua {url}\",\n            \"version\": \"Phiên bản\",\n            \"build_time\": \"Thời điểm tạo:\",\n            \"stash_wiki\": \"Trang {url} của Stash\",\n            \"stash_home\": \"Trang chủ Stash tại {url}\"\n        },\n        \"application_paths\": {\n            \"heading\": \"Đường dẫn tới ứng dụng\"\n        },\n        \"categories\": {\n            \"about\": \"Về\",\n            \"changelog\": \"Nhật ký thay đổi\",\n            \"interface\": \"Giao diện\",\n            \"logs\": \"Tập nhật ký\",\n            \"plugins\": \"Các phần bổ trợ\",\n            \"security\": \"Bảo mật\",\n            \"services\": \"Các dịch vụ\",\n            \"system\": \"Hệ thống\",\n            \"tasks\": \"Các công việc\",\n            \"tools\": \"Các công cụ\",\n            \"metadata_providers\": \"Các bên cung cấp thông tin dữ liệu\",\n            \"scraping\": \"Đang quét dữ liệu\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"Cho phép {tempIP}\",\n            \"allowed_ip_addresses\": \"Các địa chỉ IP được cho phép\",\n            \"default_ip_whitelist\": \"Danh sách các IP mặc định được cho phép\",\n            \"disabled_dlna_temporarily\": \"Vô hiệu DLNA tạm thời\",\n            \"disallowed_ip\": \"Các IP bị cấm\",\n            \"enabled_by_default\": \"Mặc định được kích hoạt\",\n            \"enabled_dlna_temporarily\": \"Kích hoạt DLNA tạm thời\",\n            \"network_interfaces\": \"Các giao diện\",\n            \"recent_ip_addresses\": \"Các địa chỉ IP gần đây\",\n            \"server_display_name\": \"Tên hiển thị của máy chủ\",\n            \"server_display_name_desc\": \"Tên hiển thị cho máy chủ DLNA. Mặc định là {server_name} nếu để trống.\",\n            \"server_port\": \"Cổng máy chủ\",\n            \"server_port_desc\": \"Cổng cho máy chủ DLNA dùng. Yêu cầu khởi động lại DLNA sau khi sửa đổi.\",\n            \"successfully_cancelled_temporary_behaviour\": \"Hủy bỏ hành vi tạm thời thành công\",\n            \"until_restart\": \"cho tới lúc khởi động lại\",\n            \"video_sort_order\": \"Thứ tự sắp xếp video mặc định\",\n            \"video_sort_order_desc\": \"Thứ tự sắp xếp các video mặc định.\",\n            \"allowed_ip_temporarily\": \"Các địa chỉ IP được cho phép tạm thời\",\n            \"default_ip_whitelist_desc\": \"Các địa chỉ IP mặc định được phép sử dụng DLNA. Dùng {wildcard} để cho phép tất cả các địa chỉ IP.\",\n            \"network_interfaces_desc\": \"Các giao diện để hiển thị máy chủ DLNA. Danh sách trống nghĩa là chạy trên mọi máy chủ. Yêu cần khởi động lại DLNA sau khi sửa đổi.\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"Khóa API\",\n                \"authentication\": \"Xác thực\",\n                \"clear_api_key\": \"Xóa khóa API\",\n                \"credentials\": {\n                    \"heading\": \"Các thông tin xác thực\",\n                    \"description\": \"Tài khoản/mật khẩu dùng để kiểm soát quyền truy cập Stash.\"\n                },\n                \"generate_api_key\": \"Tạo khóa API\",\n                \"log_file\": \"File nhật ký\",\n                \"log_file_desc\": \"Đường dẫn tới file lưu nhật ký. Để trống để vô hiệu lưu nhật ký vào file. Yêu cầu khởi động lại.\",\n                \"log_http\": \"Nhật ký truy cập HTTP\",\n                \"log_to_terminal\": \"Xuất nhật ký lên terminal\",\n                \"maximum_session_age\": \"Thời gian phiên tối đa\",\n                \"maximum_session_age_desc\": \"Thời gian chờ tối đa trước khi phiên đăng nhập hết hạn, tính bằng giây. Yêu cầu khởi động lại.\",\n                \"password\": \"Mật khẩu\",\n                \"password_desc\": \"Mật khẩu để truy cập Stash. Để trống để tắt xác thực người dùng\",\n                \"stash-box_integration\": \"Tích hợp Stash-box\",\n                \"username\": \"Tên người dùng\",\n                \"username_desc\": \"Tên người dùng để truy cập Stash. Để trống để tắt xác thực người dùng\",\n                \"log_http_desc\": \"Xuất nhật ký truy cập HTTP lên terminal. Yêu cầu khởi động lại.\",\n                \"api_key_desc\": \"Khóa API cho các hệ thống ngoài. Chỉ yêu cầu khóa khi tên người dùng / mật khẩu được thiết lập. Tên người dùng phải được lưu trước khi tạo khóa API.\",\n                \"log_to_terminal_desc\": \"Đẩy nhật ký lên terminal bên cạnh việc lưu vào file. Luôn bật nếu đang vô hiệu lưu vào file. Yêu cầu khởi động lại.\"\n            },\n            \"backup_directory_path\": {\n                \"heading\": \"Đường dẫn tới thư mục sao lưu\",\n                \"description\": \"Vị trí thư mục để sao lưu file dữ liệu SQLite\"\n            },\n            \"blobs_path\": {\n                \"heading\": \"Đường dẫn tới hệ thống tập tin dữ liệu binary\",\n                \"description\": \"Chỗ nào trong hệ thống file để lưu dữ liệu binary. Chỉ áp dụng cho lựa chọn lưu trữ blob trong hệ thống tệp tin. CẢNH BÁO: thay đổi cài đặt này yêu cầu di chuyển thủ công các dữ liệu hiện tại.\"\n            },\n            \"blobs_storage\": {\n                \"heading\": \"Loại lưu trữ dữ liệu binary\",\n                \"description\": \"Chỗ nào để lưu trữ dữ liệu binary như bìa phân cảnh, người biểu diễn, studio và các nhãn ảnh. Sau khi thay đổi giá trị này, dữ liệu hiện tại phải được chuyển đổi bằng cách sử dụng các tác vụ Chuyển Đổi Các Blob. Xem trang Các Tác Vụ để chuyển đổi.\"\n            },\n            \"cache_location\": \"Vị trí thư mục cache. Yêu cầu dùng nếu đang truyền trực tiếp sử dụng HLS (như trên thiết bị Apple) hoặc DASH.\",\n            \"cache_path_head\": \"Đường dẫn cache\",\n            \"calculate_md5_and_ohash_desc\": \"Tính toán hàm băm MD5 bên cạnh oshash. Bật lên sẽ làm quá trình quét ban đầu diễn ra chậm hơn. Hàm băm tên file phải đặt về oshash để vô hiệu MD5.\",\n            \"calculate_md5_and_ohash_label\": \"Tính toán MD5 cho các video\",\n            \"check_for_insecure_certificates\": \"Kiểm tra các chứng chỉ không an toàn\",\n            \"check_for_insecure_certificates_desc\": \"Một số trang sử dụng chứng chỉ ssl không an toàn. Nếu bỏ tick, bộ thu thập sẽ bỏ qua quy trình kiểm tra tính an toàn của chứng chỉ và sẽ thu thập toàn bộ các trang đó. Nếu bạn gặp vấn đề về chứng chỉ khi thu thập dữ liệu thì hãy bỏ tick.\",\n            \"create_galleries_from_folders_desc\": \"Nếu bật, các thư mục chứa ảnh sẽ mặc định được tạo thành thư viện. Tạo một tệp có tên .forcegallery hoặc .nogallery trong thư mục để ép buộc hoặc ngăn việc tạo thư viện.\",\n            \"create_galleries_from_folders_label\": \"Tạo thư viện ảnh từ các thư mục chứa hình ảnh\",\n            \"database\": \"Cơ sở dữ liệu\",\n            \"db_path_head\": \"Vị trí tệp cơ sở dữ liệu\",\n            \"directory_locations_to_your_content\": \"Vị trí thư mục chứa nội dung của bạn\",\n            \"excluded_image_gallery_patterns_head\": \"Các mẫu tên ảnh/thư viện cần loại trừ\",\n            \"excluded_video_patterns_desc\": \"Biểu thức chính quy (regex) của các tệp video hoặc đường dẫn cần loại trừ khỏi quá trình Quét và thêm vào mục Dọn dẹp\",\n            \"excluded_video_patterns_head\": \"Các mẫu tên video cần loại trừ\",\n            \"ffmpeg\": {\n                \"download_ffmpeg\": {\n                    \"heading\": \"Tải xuống FFmpeg\",\n                    \"description\": \"Tải FFmpeg vào thư mục cấu hình và xóa đường dẫn ffmpeg và ffprobe hiện tại để sử dụng từ thư mục cấu hình.\"\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"Đường dẫn tệp FFmpeg\",\n                    \"description\": \"Đường dẫn đến tệp ffmpeg (không chỉ là thư mục). Nếu để trống, hệ thống sẽ tự tìm ffmpeg từ biến môi trường $PATH, thư mục cấu hình, hoặc từ $HOME/.stash\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"Đường dẫn đến tệp ffprobe (không chỉ là thư mục). Nếu để trống, hệ thống sẽ tự tìm ffprobe từ biến môi trường $PATH, thư mục cấu hình hoặc từ $HOME/.stash\",\n                    \"heading\": \"Đường dẫn tệp FFprobe\"\n                },\n                \"hardware_acceleration\": {\n                    \"desc\": \"Sử dụng phần cứng hiện có để mã hóa video trong quá trình chuyển mã trực tiếp.\",\n                    \"heading\": \"Mã hóa phần cứng bằng FFmpeg\"\n                },\n                \"live_transcode\": {\n                    \"output_args\": {\n                        \"heading\": \"Tham số đầu ra cho chuyển mã trực tiếp bằng FFmpeg\",\n                        \"desc\": \"Nâng cao: Các tham số bổ sung sẽ được truyền vào FFmpeg trước trường đầu ra khi chuyển mã video trực tiếp.\"\n                    },\n                    \"input_args\": {\n                        \"desc\": \"Nâng cao: Các tham số bổ sung sẽ được truyền vào FFmpeg trước trường đầu vào khi chuyển mã video trực tiếp.\",\n                        \"heading\": \"Tham số đầu vào cho chuyển mã trực tiếp bằng FFmpeg\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"Nâng cao: Các tham số bổ sung sẽ được truyền vào FFmpeg trước trường đầu vào khi tạo video.\",\n                        \"heading\": \"Tham số đầu vào khi chuyển mã bằng FFmpeg\"\n                    },\n                    \"output_args\": {\n                        \"heading\": \"Tham số đầu ra khi chuyển mã bằng FFmpeg\",\n                        \"desc\": \"Nâng cao: Các tham số bổ sung sẽ được truyền vào FFmpeg trước trường đầu ra khi tạo video.\"\n                    }\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"Bao gồm khoảng giá trị trong các bản đồ nhiệt được tạo ra\",\n            \"gallery_cover_regex_desc\": \"Biểu thức chính quy dùng để nhận diện ảnh bìa của thư viện\",\n            \"gallery_cover_regex_label\": \"Mẫu tên file ảnh dùng làm bìa thư viện\",\n            \"gallery_ext_desc\": \"Danh sách phần mở rộng tệp (file extensions), phân cách bằng dấu phẩy, sẽ được nhận diện là tệp thư viện dạng nén (zip).\",\n            \"gallery_ext_head\": \"Phần mở rộng tệp thư viện nén\",\n            \"generated_file_naming_hash_head\": \"Mã băm dùng để đặt tên cho các tệp được tạo ra\",\n            \"generated_files_location\": \"Vị trí thư mục lưu trữ các tệp được tạo (dấu cảnh, ảnh xem trước cảnh, ảnh sprite, v.v.)\",\n            \"generated_path_head\": \"Thư mục lưu trữ các tệp sinh ra tự động\",\n            \"hashing\": \"Băm (tạo mã băm)\",\n            \"heatmap_generation\": \"Tạo bản đồ nhiệt từ Funscript\",\n            \"image_ext_desc\": \"Danh sách phần mở rộng tệp được nhận diện là hình ảnh, ngăn cách bằng dấu phẩy.\",\n            \"image_ext_head\": \"Đuôi file ảnh\",\n            \"include_audio_desc\": \"Bao gồm luồng âm thanh khi tạo bản xem trước.\",\n            \"include_audio_head\": \"Bao gồm âm thanh\",\n            \"maximum_streaming_transcode_size_head\": \"Kích thước tối đa cho các luồng video đã chuyển mã\",\n            \"maximum_transcode_size_desc\": \"Kích thước tối đa cho các video chuyển mã được tạo ra\",\n            \"maximum_transcode_size_head\": \"Kích thước chuyển mã tối đa\",\n            \"metadata_path\": {\n                \"heading\": \"Vị trí thư mục chứa siêu dữ liệu\",\n                \"description\": \"Vị trí thư mục được sử dụng khi thực hiện xuất hoặc nhập toàn bộ dữ liệu\"\n            },\n            \"number_of_parallel_task_for_scan_generation_head\": \"Số lượng tác vụ song song cho quá trình quét/tạo dữ liệu\",\n            \"parallel_scan_head\": \"Quét/Tạo dữ liệu song song\",\n            \"plugins_path\": {\n                \"description\": \"Vị trí thư mục chứa các tệp cấu hình plugin\",\n                \"heading\": \"Đường dẫn plugins\"\n            },\n            \"preview_generation\": \"Tạo bản xem trước\",\n            \"python_path\": {\n                \"description\": \"Đường dẫn đến tệp Python (không chỉ là thư mục). Được sử dụng cho các trình quét (scraper) và plugin viết bằng script. Nếu để trống, hệ thống sẽ tự tìm Python từ môi trường\",\n                \"heading\": \"Đường dẫn tệp thực thi Python\"\n            },\n            \"scrapers_path\": {\n                \"description\": \"Vị trí thư mục chứa các tệp cấu hình của trình quét (scraper)\",\n                \"heading\": \"Đường dẫn đến thư mục chứa các scraper\"\n            },\n            \"scraping\": \"Thu thập dữ liệu từ website\",\n            \"video_ext_desc\": \"Danh sách phần mở rộng tệp sẽ được nhận diện là video, phân cách bằng dấu phẩy.\",\n            \"video_ext_head\": \"Định dạng của video\",\n            \"video_head\": \"Video\",\n            \"chrome_cdp_path\": \"Đường dẫn giao thức CDP của Chrome\",\n            \"chrome_cdp_path_desc\": \"Đường dẫn đến tệp thực thi của Chrome, hoặc một địa chỉ từ xa (bắt đầu bằng http:// hoặc https://, ví dụ http://localhost:9222/json/version) trỏ đến một phiên bản Chrome đang chạy.\",\n            \"excluded_image_gallery_patterns_desc\": \"Biểu thức chính quy (regex) của tệp ảnh và thư mục thư viện cần loại trừ khỏi quá trình Quét và thêm vào mục Dọn dẹp\",\n            \"funscript_heatmap_draw_range_desc\": \"Vẽ phạm vi chuyển động trên trục y của bản đồ nhiệt được tạo. Các bản đồ nhiệt hiện có sẽ cần được tạo lại sau khi thay đổi tùy chọn này.\",\n            \"generated_file_naming_hash_desc\": \"Sử dụng MD5 hoặc oshash để đặt tên cho các tệp được tạo. Việc thay đổi tùy chọn này yêu cầu tất cả các cảnh (scenes) phải có giá trị MD5/oshash tương ứng. Sau khi thay đổi, các tệp đã tạo trước đó sẽ cần được di chuyển hoặc tạo lại. Vui lòng xem trang Nhiệm vụ (Tasks) để thực hiện di chuyển.\",\n            \"maximum_streaming_transcode_size_desc\": \"Kích thước tối đa cho các luồng video đã chuyển mã\",\n            \"number_of_parallel_task_for_scan_generation_desc\": \"Đặt giá trị là 0 để hệ thống tự động phát hiện. Cảnh báo: chạy nhiều tác vụ hơn mức cần thiết để sử dụng 100% CPU sẽ làm giảm hiệu suất và có thể gây ra các sự cố khác.\",\n            \"scraper_user_agent\": \"User Agent cho trình quét (scraper)\",\n            \"scraper_user_agent_desc\": \"Chuỗi User-Agent được sử dụng trong các yêu cầu HTTP khi quét dữ liệu (scrape)\",\n            \"sqlite_location\": \"Vị trí tệp cơ sở dữ liệu SQLite (cần khởi động lại). CẢNH BÁO: Lưu cơ sở dữ liệu trên một hệ thống khác với nơi chạy Stash server (ví dụ: qua mạng) là không được hỗ trợ!\",\n            \"logging\": \"Ghi nhật ký\"\n        },\n        \"advanced_mode\": \"Chế độ nâng cao\",\n        \"stashbox\": {\n            \"name\": \"Tên\",\n            \"title\": \"Các điểm cuối Stash-box\",\n            \"add_instance\": \"Thêm một phiên bản Stash-box\",\n            \"api_key\": \"API key\",\n            \"endpoint\": \"Điểm cuối (Endpoint)\",\n            \"graphql_endpoint\": \"Điểm cuối GraphQL\",\n            \"description\": \"Stash-box hỗ trợ gán tag tự động cho cảnh và diễn viên dựa trên dấu vân tay (fingerprints) và tên tệp.\\nEndpoint và khóa API có thể được tìm thấy trong trang tài khoản của bạn trên phiên hoạt động của stash-box. Tên định danh là bắt buộc nếu bạn thêm nhiều hơn một phiên hoạt động.\",\n            \"max_requests_per_minute\": \"Số lượng yêu cầu tối đa mỗi phút\",\n            \"max_requests_per_minute_description\": \"Sử dụng giá trị mặc định là {defaultValue} nếu đặt là 0\"\n        },\n        \"system\": {\n            \"transcoding\": \"Chuyển đổi định dạng video\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"Đã thêm {operation_name} vào hàng đợi công việc\",\n            \"anonymising_database\": \"Đang ẩn danh cơ sở dữ liệu\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"Tự động gắn tag cho tất cả các đường dẫn\",\n                \"auto_tagging_paths\": \"Đang tự động gắn tag cho các đường dẫn sau\"\n            },\n            \"backing_up_database\": \"Sao lưu dữ liệu\",\n            \"backup_and_download\": \"Thực hiện sao lưu cơ sở dữ liệu và tải xuống tệp kết quả.\",\n            \"cleanup_desc\": \"Kiểm tra các tệp bị thiếu và xóa chúng khỏi cơ sở dữ liệu. Đây là hành động có tính phá hủy.\",\n            \"clean_generated\": {\n                \"blob_files\": \"Tệp nhị phân (blob)\",\n                \"description\": \"Xóa các tệp đã được tạo nhưng không còn bản ghi tương ứng trong cơ sở dữ liệu.\",\n                \"image_thumbnails\": \"Ảnh thu nhỏ\",\n                \"markers\": \"Xem trước điểm đánh dấu\",\n                \"previews\": \"Xem trước của cảnh\",\n                \"previews_desc\": \"Ảnh và đoạn xem trước cảnh\",\n                \"sprites\": \"Bản ghép khung hình của cảnh\",\n                \"transcodes\": \"Bản video đã chuyển mã của cảnh\",\n                \"image_thumbnails_desc\": \"Ảnh thu nhỏ và đoạn xem trước\"\n            },\n            \"data_management\": \"Quản lý dữ liệu\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"Không bao gồm phần đuôi tệp trong tiêu đề\",\n            \"empty_queue\": \"Hiện không có tác vụ nào đang chạy.\",\n            \"generate\": {\n                \"generating_from_paths\": \"Đang tạo dữ liệu cho các cảnh từ những đường dẫn sau\",\n                \"generating_scenes\": \"Đang tạo dữ liệu cho {num} {scene}\"\n            },\n            \"generate_clip_previews_during_scan\": \"Tạo bản xem trước cho các đoạn ảnh clip\",\n            \"generate_desc\": \"Tạo các tệp hỗ trợ bao gồm ảnh, sprite, video, vtt và các tệp khác.\",\n            \"generate_phashes_during_scan\": \"Tạo mã băm theo đặc điểm hình ảnh\",\n            \"generate_previews_during_scan\": \"Tạo ảnh xem trước động\",\n            \"anonymise_and_download\": \"Tạo một bản sao ẩn danh của cơ sở dữ liệu và tải xuống tệp kết quả.\",\n            \"generate_previews_during_scan_tooltip\": \"Cũng tạo các ảnh xem trước động (định dạng WebP), chỉ cần thiết khi kiểu xem trước Cảnh/Dấu mốc (Scene/Marker Wall Preview Type) được đặt thành Ảnh động. Khi duyệt nội dung, ảnh WebP tiêu tốn ít CPU hơn so với video preview, nhưng sẽ được tạo thêm bên cạnh video và có kích thước tệp lớn hơn.\",\n            \"anonymise_database\": \"Tạo một bản sao của cơ sở dữ liệu trong thư mục sao lưu (backups), đồng thời ẩn danh toàn bộ dữ liệu nhạy cảm. Bản sao này có thể được cung cấp cho người khác để hỗ trợ khắc phục sự cố và gỡ lỗi. Cơ sở dữ liệu gốc sẽ không bị thay đổi. Tệp cơ sở dữ liệu ẩn danh sẽ sử dụng định dạng tên file là {filename_format}.\",\n            \"generate_phashes_during_scan_tooltip\": \"Dùng để loại bỏ trùng lặp và nhận diện Scene.\",\n            \"defaults_set\": \"Giá trị mặc định đã được thiết lập và sẽ được sử dụng khi nhấn nút {action} trên trang Tác vụ (Tasks).\",\n            \"export_to_json\": \"Xuất nội dung cơ sở dữ liệu sang định dạng JSON trong thư mục metadata.\",\n            \"auto_tag_based_on_filenames\": \"Tự động gắn tag cho nội dung dựa trên đường dẫn tệp.\",\n            \"auto_tagging\": \"Gắn tag tự động\",\n            \"generate_sprites_during_scan\": \"Tạo ảnh xem trước cho thanh tua\",\n            \"generate_sprites_during_scan_tooltip\": \"Tập hợp hình ảnh được hiển thị bên dưới trình phát video để hỗ trợ điều hướng dễ dàng.\",\n            \"generate_thumbnails_during_scan\": \"Tạo ảnh thu nhỏ cho các hình ảnh\",\n            \"generate_video_covers_during_scan\": \"Tạo ảnh bìa cho từng cảnh quay\",\n            \"generate_video_previews_during_scan\": \"Tạo đoạn xem trước video\",\n            \"generate_video_previews_during_scan_tooltip\": \"Tạo video xem trước phát tự động khi rê chuột lên cảnh quay\",\n            \"generated_content\": \"Nội dung được tạo tự động\",\n            \"identify\": {\n                \"and_create_missing\": \"và tạo các mục còn thiếu\",\n                \"create_missing\": \"Tạo phần bị thiếu\",\n                \"default_options\": \"Tùy Chọn Mặc Định\",\n                \"description\": \"Tự động điền thông tin cảnh quay từ stash-box và các nguồn dữ liệu quét.\",\n                \"heading\": \"Nhận diện\",\n                \"identifying_from_paths\": \"Đang nhận diện các cảnh quay từ các đường dẫn sau\",\n                \"identifying_scenes\": \"Đang nhận diện {num} {scene}\",\n                \"include_male_performers\": \"Bao gồm diễn viên nam\",\n                \"set_cover_images\": \"Thiết lập ảnh bìa\",\n                \"set_organized\": \"Đánh dấu đã sắp xếp\",\n                \"skip_multiple_matches_tooltip\": \"Nếu tùy chọn này không được bật và có nhiều kết quả được trả về, một kết quả sẽ được chọn ngẫu nhiên để khớp\",\n                \"skip_single_name_performers_tooltip\": \"Nếu tùy chọn này không được bật, các diễn viên có tên chung chung như Samantha hoặc Olga vẫn sẽ được gán khớp\",\n                \"source\": \"Nguồn\",\n                \"source_options\": \"Tùy chọn cho {source}\",\n                \"sources\": \"Các nguồn\",\n                \"strategy\": \"Chiến lược\",\n                \"tag_skipped_matches\": \"Gắn thẻ cho các kết quả bị bỏ qua bằng\",\n                \"tag_skipped_performer_tooltip\": \"Tạo một thẻ như 'Nhận diện: Diễn viên một tên' để bạn có thể lọc trong chế độ xem Scene Tagger và tự chọn cách xử lý các diễn viên này\",\n                \"tag_skipped_performers\": \"Gắn thẻ cho các diễn viên bị bỏ qua bằng\",\n                \"field_options\": \"Tùy chọn trường dữ liệu\",\n                \"skip_single_name_performers\": \"Bỏ qua các diễn viên chỉ có một tên mà không có thông tin phân biệt rõ ràng\",\n                \"field_behaviour\": \"{strategy} {field}\",\n                \"field\": \"Trường dữ liệu\",\n                \"tag_skipped_matches_tooltip\": \"Tạo một thẻ như 'Nhận diện: Nhiều kết quả khớp' để bạn có thể lọc trong chế độ xem Scene Tagger và tự chọn kết quả đúng bằng tay\",\n                \"explicit_set_description\": \"Các thiết lập dưới đây sẽ áp dụng trừ khi có thiết lập riêng từ từng nguồn.\",\n                \"skip_multiple_matches\": \"Bỏ qua nếu tìm thấy nhiều kết quả trùng khớp\"\n            },\n            \"incremental_import\": \"Chỉ nhập những dữ liệu còn thiếu từ tệp ZIP được cung cấp.\",\n            \"job_queue\": \"Hàng đợi tác vụ\",\n            \"maintenance\": \"Công cụ bảo trì hệ thống\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"Xóa dữ liệu không còn sử dụng\",\n                \"description\": \"Di chuyển dữ liệu blob sang hệ thống lưu trữ blob hiện tại. Quá trình di chuyển này nên được thực hiện sau khi thay đổi hệ thống lưu trữ. Có thể chọn xóa dữ liệu cũ sau khi hoàn tất di chuyển.\"\n            },\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"Xóa các tệp ảnh chụp màn hình\",\n                \"overwrite_existing\": \"Ghi đè các blob hiện có bằng dữ liệu ảnh chụp màn hình\",\n                \"description\": \"Di chuyển ảnh chụp cảnh quay sang hệ thống lưu trữ blob mới. Quá trình này nên được thực hiện sau khi nâng cấp hệ thống hiện tại lên phiên bản 0.20. Có thể tùy chọn xóa các ảnh chụp cũ sau khi di chuyển.\"\n            },\n            \"migrations\": \"Chuyển đổi dữ liệu\",\n            \"only_dry_run\": \"Chỉ thực hiện chạy thử. Không xóa bất kỳ dữ liệu nào\",\n            \"optimise_database\": \"Cố gắng cải thiện hiệu suất bằng cách phân tích và sau đó tái tạo lại toàn bộ tệp cơ sở dữ liệu.\",\n            \"plugin_tasks\": \"Các tác vụ của tiện ích mở rộng\",\n            \"rescan\": \"Quét lại tất cả các tệp\",\n            \"rescan_tooltip\": \"Quét lại mọi tệp trong đường dẫn. Dùng để buộc cập nhật metadata của tệp và quét lại các tệp ZIP.\",\n            \"scan\": {\n                \"scanning_all_paths\": \"Đang quét tất cả các đường dẫn\",\n                \"scanning_paths\": \"Đang quét các đường dẫn sau\"\n            },\n            \"scan_for_content_desc\": \"Quét nội dung mới và thêm vào cơ sở dữ liệu.\",\n            \"set_name_date_details_from_metadata_if_present\": \"Thiết lập tên, ngày và thông tin chi tiết từ metadata được nhúng trong tệp\",\n            \"migrate_hash_files\": \"Được sử dụng sau khi thay đổi mã hash đặt tên tệp để đổi tên các tệp đã tạo sang định dạng hash mới.\",\n            \"import_from_exported_json\": \"Nhập dữ liệu từ tệp JSON đã xuất trong thư mục metadata. Hành động này sẽ xóa toàn bộ cơ sở dữ liệu hiện tại.\",\n            \"optimise_database_warning\": \"Cảnh báo: trong khi tác vụ này đang chạy, mọi thao tác có thay đổi cơ sở dữ liệu sẽ bị lỗi. Tùy vào kích thước cơ sở dữ liệu, quá trình này có thể mất vài phút để hoàn tất. Ngoài ra, bạn cần ít nhất dung lượng ổ đĩa trống tương đương với kích thước cơ sở dữ liệu, nhưng khuyến nghị là 1.5 lần để đảm bảo an toàn.\"\n        },\n        \"library\": {\n            \"exclusions\": \"Danh sách loại trừ\",\n            \"gallery_and_image_options\": \"Tùy chọn Thư viện và Hình ảnh\",\n            \"media_content_extensions\": \"Phần mở rộng của nội dung đa phương tiện\"\n        },\n        \"logs\": {\n            \"log_level\": \"Cấp độ ghi nhật ký\"\n        },\n        \"plugins\": {\n            \"available_plugins\": \"Plugin khả dụng\",\n            \"hooks\": \"Cơ chế kích hoạt tự động\",\n            \"installed_plugins\": \"Cài đặt plugin\",\n            \"triggers_on\": \"Kích hoạt khi\"\n        },\n        \"scraping\": {\n            \"available_scrapers\": \"Scraper khả dụng\",\n            \"entity_scrapers\": \"Thông tin mô tả cho {entityType}\",\n            \"excluded_tag_patterns_head\": \"Mẫu tag bị loại trừ\",\n            \"installed_scrapers\": \"Các trình quét đã cài đặt\",\n            \"scraper\": \"Trình quét dữ liệu tự động\",\n            \"scrapers\": \"Các trình quét dữ liệu\",\n            \"search_by_name\": \"Tìm kiếm theo tên\",\n            \"supported_types\": \"Các loại được hỗ trợ\",\n            \"supported_urls\": \"URLs\",\n            \"entity_metadata\": \"Siêu dữ liệu {entityType}\",\n            \"excluded_tag_patterns_desc\": \"Biểu thức chính quy của tên tag cần loại trừ khỏi kết quả quét dữ liệu\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"Kiểm tra cảnh quay bị trùng\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"Thêm trường dữ liệu\",\n                \"capitalize_title\": \"Tự động viết hoa chữ cái đầu của mỗi từ trong tiêu đề\",\n                \"display_fields\": \"Hiển thị các trường dữ liệu\",\n                \"escape_chars\": \"Sử dụng \\\\ để thoát các ký tự đặc biệt\",\n                \"filename\": \"Tên tệp\",\n                \"filename_pattern\": \"Mẫu tên tệp\",\n                \"ignore_organized\": \"Bỏ qua các cảnh đã được sắp xếp\",\n                \"ignored_words\": \"Từ bị bỏ qua\",\n                \"matches_with\": \"Khớp với {i}\",\n                \"title\": \"Trình phân tích tên tệp cảnh quay\",\n                \"whitespace_chars\": \"Ký tự khoảng trắng\",\n                \"select_parser_recipe\": \"Chọn công thức phân tích\",\n                \"whitespace_chars_desc\": \"Các ký tự này sẽ được thay thế bằng khoảng trắng trong tiêu đề\"\n            },\n            \"scene_tools\": \"Công cụ xử lý cảnh quay\",\n            \"graphql_playground\": \"Công cụ GraphGL\",\n            \"heading\": \"Công cụ\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"Rút gọn số đếm trong giao diện thẻ và trang chi tiết, ví dụ '1831' sẽ được định dạng thành '1.8K'.\",\n                \"heading\": \"Rút gọn số đếm\"\n            },\n            \"basic_settings\": \"Cài đặt cơ bản\",\n            \"custom_css\": {\n                \"heading\": \"CSS tùy chỉnh\",\n                \"option_label\": \"Đã bật CSS tùy chỉnh\",\n                \"description\": \"Trang cần được tải lại để các thay đổi có hiệu lực. Không có gì đảm bảo rằng CSS tùy chỉnh sẽ tương thích với các phiên bản Stash trong tương lai.\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"Hãy tải lại trang để áp dụng thay đổi. JavaScript tùy chỉnh có thể không tương thích với các bản cập nhật sau này.\",\n                \"option_label\": \"Đã bật JavaScript tùy chỉnh\",\n                \"heading\": \"JavaScript tùy chỉnh\"\n            },\n            \"custom_locales\": {\n                \"heading\": \"Bản địa hóa tùy chỉnh\",\n                \"option_label\": \"Đã bật bản địa hóa tùy chỉnh\",\n                \"description\": \"Ghi đè các chuỗi ngôn ngữ riêng lẻ. Xem danh sách chính tại https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json. Trang cần được tải lại để các thay đổi có hiệu lực.\"\n            },\n            \"delete_options\": {\n                \"description\": \"Thiết lập mặc định cho thao tác xóa ảnh, gallery và cảnh.\",\n                \"heading\": \"Tùy chọn xóa\",\n                \"options\": {\n                    \"delete_file\": \"Xóa tệp theo mặc định\",\n                    \"delete_generated_supporting_files\": \"Xóa các tệp hỗ trợ đã tạo theo mặc định\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"Tích hợp Desktop\",\n                \"notifications_enabled\": \"Cho phép Thông báo\",\n                \"send_desktop_notifications_for_events\": \"Gửi thông báo trên màn hình máy tính cho các sự kiện\",\n                \"skip_opening_browser\": \"Bỏ qua việc mở trình duyệt\",\n                \"skip_opening_browser_on_startup\": \"Bỏ qua việc tự động mở trình duyệt khi khởi động\"\n            },\n            \"detail\": {\n                \"compact_expanded_details\": {\n                    \"description\": \"Khi được bật, tùy chọn này sẽ hiển thị chi tiết mở rộng trong khi vẫn duy trì giao diện gọn gàng\",\n                    \"heading\": \"Thu gọn chi tiết mở rộng\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"Hiển thị ảnh nền trên trang chi tiết.\",\n                    \"heading\": \"Bật ảnh nền\"\n                },\n                \"heading\": \"Trang Chi tiết\",\n                \"show_all_details\": {\n                    \"heading\": \"Hiển thị tất cả chi tiết\",\n                    \"description\": \"Khi được bật, tất cả các chi tiết nội dung sẽ được hiển thị theo mặc định và mỗi mục chi tiết sẽ nằm gọn trong một cột duy nhất\"\n                }\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"Xóa bỏ khả năng tạo đối tượng mới từ các bộ chọn thả xuống\",\n                    \"heading\": \"Tắt chức năng tạo mới từ danh sách thả xuống\"\n                },\n                \"heading\": \"Chỉnh sửa\",\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"Độ chính xác của sao đánh giá\",\n                        \"options\": {\n                            \"full\": \"Đầy\",\n                            \"half\": \"Một nữa\",\n                            \"quarter\": \"1/4\",\n                            \"tenth\": \"1/10\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"Loại Hệ thống Đánh giá\",\n                        \"options\": {\n                            \"decimal\": \"Thập phân\",\n                            \"stars\": \"Sao\"\n                        }\n                    }\n                },\n                \"max_options_shown\": {\n                    \"label\": \"Số lượng mục tối đa hiển thị trong danh sách thả xuống\"\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"Độ lệch thời gian tính bằng mili giây cho việc phát lại các kịch bản tương tác.\",\n                \"heading\": \"Độ lệch Funscript (ms)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"Kết nối\",\n                \"status\": {\n                    \"heading\": \"Trạng thái Kết nối Handy\"\n                },\n                \"sync\": \"Đồng bộ hóa\",\n                \"server_offset\": {\n                    \"heading\": \"Độ lệch Máy chủ\"\n                }\n            },\n            \"handy_connection_key\": {\n                \"heading\": \"Khóa Kết nối Handy\",\n                \"description\": \"Khóa kết nối Handy để sử dụng cho các cảnh tương tác. Việc thiết lập khóa này sẽ cho phép Stash chia sẻ thông tin cảnh hiện tại của bạn với handyfeeling.com\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"Hộp đèn ảnh\"\n            },\n            \"image_wall\": {\n                \"direction\": \"Phương hướng\",\n                \"heading\": \"Tường ảnh\",\n                \"margin\": \"Khoảng lề (pixel)\"\n            },\n            \"images\": {\n                \"heading\": \"Hình ảnh\",\n                \"options\": {\n                    \"create_image_clips_from_videos\": {\n                        \"heading\": \"Quét các Phần mở rộng Video dưới dạng Clip hình ảnh\",\n                        \"description\": \"Khi một thư viện bị tắt Video, các Tệp video (các tệp có phần mở rộng là Video Extension) sẽ được quét dưới dạng Clip hình ảnh.\"\n                    },\n                    \"write_image_thumbnails\": {\n                        \"description\": \"Ghi hình thu nhỏ của ảnh ra đĩa khi chúng được tạo tự động\",\n                        \"heading\": \"Ghi hình thu nhỏ của ảnh\"\n                    }\n                }\n            },\n            \"interactive_options\": \"Tùy chọn Tương tác\",\n            \"menu_items\": {\n                \"description\": \"Hiển thị hoặc ẩn các loại nội dung khác nhau trên thanh điều hướng\",\n                \"heading\": \"Mục Menu\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"Phần trăm thời lượng cảnh phải được phát trước khi số lượt phát của nó được tăng lên.\",\n                \"heading\": \"Phần trăm Phát tối thiểu\"\n            },\n            \"scene_player\": {\n                \"options\": {\n                    \"enable_chromecast\": \"Bật Chromecast\",\n                    \"show_ab_loop_controls\": \"Hiển thị điều khiển plugin Vòng lặp AB\",\n                    \"show_scrubber\": \"Hiển thị Thanh điều khiển\",\n                    \"vr_tag\": {\n                        \"description\": \"Nút VR sẽ chỉ hiển thị cho các cảnh có gắn thẻ này.\",\n                        \"heading\": \"Thẻ VR\"\n                    },\n                    \"show_range_markers\": \"Hiện thị phạm vị của điểm đánh dấu\",\n                    \"track_activity\": \"Bật lịch sử phát cảnh quay\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"Tự động phát video cảnh quay khi phát từ hàng chờ, hoặc phát các cảnh được chọn hoặc ngẫu nhiên từ trang Cảnh Quay\",\n                        \"heading\": \"Tự động phát video khi phát các mục đã chọn\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"Phát cảnh tiếp theo trong hàng chờ khi video kết thúc\",\n                        \"heading\": \"Tiếp tục danh sách phát theo mặc định\"\n                    },\n                    \"disable_mobile_media_auto_rotate\": \"Tắt tự động xoay phương tiện toàn màn hình trên thiết bị Di động\",\n                    \"always_start_from_beginning\": \"Luôn phát video từ đầu\",\n                    \"auto_start_video\": \"Tự động phát video\"\n                },\n                \"heading\": \"Trình phát cảnh quay\"\n            },\n            \"scene_wall\": {\n                \"options\": {\n                    \"toggle_sound\": \"Bật âm thanh\",\n                    \"display_title\": \"Hiển thị tiêu đề và thẻ\"\n                },\n                \"heading\": \"Tường Cảnh Quay / Điểm Đánh Dấu\"\n            },\n            \"scroll_attempts_before_change\": {\n                \"heading\": \"Số lần thử cuộn trước khi chuyển đổi\",\n                \"description\": \"Số lần thử cuộn trước khi chuyển sang mục tiếp theo/trước đó. Chỉ áp dụng cho chế độ cuộn Pan Y.\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"Hiển thị thẻ thông tin thẻ khi di chuột qua các huy hiệu thẻ\",\n                \"heading\": \"Chú giải công cụ thẻ thông tin\"\n            },\n            \"slideshow_delay\": {\n                \"heading\": \"Độ trễ Trình chiếu (giây)\",\n                \"description\": \"Trình chiếu có sẵn trong các thư viện ảnh khi ở chế độ xem dạng tường\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"Chế độ xem Studio\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"Trong chế độ xem Studio, hãy hiển thị cả nội dung từ các studio phụ\",\n                        \"heading\": \"Hiển thị nội dung của các studio phụ\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"Chế độ xem Thẻ\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"heading\": \"Hiển thị nội dung của thẻ phụ\",\n                        \"description\": \"Trong chế độ xem thẻ, hãy hiển thị cả nội dung từ các thẻ phụ\"\n                    }\n                }\n            },\n            \"title\": \"Giao diện Người dùng\",\n            \"use_stash_hosted_funscript\": {\n                \"heading\": \"Phục vụ/Truyền funscript trực tiếp\",\n                \"description\": \"Khi được bật, funscript sẽ được phục vụ trực tiếp từ Stash đến thiết bị Handy của bạn mà không sử dụng máy chủ Handy của bên thứ ba. Yêu cầu Stash phải truy cập được từ thiết bị Handy của bạn và cần tạo khóa API nếu Stash có cấu hình thông tin xác thực.\"\n            },\n            \"max_loop_duration\": {\n                \"heading\": \"Thời lượng lặp tối đa\",\n                \"description\": \"Thời lượng cảnh tối đa (tính bằng giây) mà trình phát cảnh sẽ lặp lại video - 0 để tắt\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"Đường dẫn tùy chỉnh cho ảnh mặc định của người biểu diễn. Để trống để sử dụng các mặc định có sẵn\",\n                        \"heading\": \"Đường dẫn Ảnh Người biểu diễn Tùy chỉnh\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"Tùy chọn mặc định là xem trước video (mp4). Để giảm sử dụng CPU khi duyệt, bạn có thể dùng xem trước ảnh động (webp). Tuy nhiên, chúng phải được tạo ra ngoài các bản xem trước video và có kích thước tệp lớn hơn.\",\n                \"heading\": \"Kiểu Xem trước\",\n                \"options\": {\n                    \"animated\": \"Ảnh động\",\n                    \"static\": \"Ảnh tĩnh\",\n                    \"video\": \"Video\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"Chế độ xem dạng lưới\",\n                \"options\": {\n                    \"show_studio_as_text\": \"Hiển thị lớp phủ studio dưới dạng văn bản\"\n                }\n            },\n            \"language\": {\n                \"heading\": \"Ngôn ngữ\"\n            }\n        }\n    },\n    \"age_on_date\": \"{age} tại thời điểm sản xuất\",\n    \"description\": \"Mô Tả\",\n    \"custom_fields\": {\n        \"title\": \"Tùy Chỉnh Trường (dữ liệu)\",\n        \"criteria_format_string\": \"{Tiêu chí} (trường tùy chỉnh) {Điều kiện} {Giá trị}\",\n        \"value\": \"Giá trị\",\n        \"criteria_format_string_others\": \"{Tiêu chí} (trường tùy chỉnh) {Điều kiện} {Giá trị} (+{số lượng khác} mục khác)\",\n        \"field\": \"Trường (dữ liệu)\"\n    },\n    \"dialogs\": {\n        \"delete_entity_title\": \"{count, plural, one {Xóa {singularEntity}} other {Xóa {pluralEntity}}}\",\n        \"delete_galleries_extra\": \"...cộng với bất kỳ tệp ảnh nào không được đính kèm vào bất kỳ thư viện ảnh nào khác.\",\n        \"delete_object_title\": \"Xóa {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"lightbox\": {\n            \"scale_up\": {\n                \"description\": \"Phóng lớn ảnh nhỏ để lấp đầy màn hình\",\n                \"label\": \"Phóng lớn để vừa\"\n            },\n            \"delay\": \"Độ Trễ (Giây)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"Căn chỉnh vừa theo chiều ngang\",\n                \"fit_to_screen\": \"Vừa màn hình\",\n                \"label\": \"Chế độ hiển thị\",\n                \"original\": \"Bản gốc\"\n            },\n            \"options\": \"Tùy Chọn\",\n            \"page_header\": \"Trang {page} / {total}\",\n            \"reset_zoom_on_nav\": \"Đặt lại mức thu phóng khi đổi ảnh\",\n            \"scroll_mode\": {\n                \"description\": \"Giữ phím Shift để tạm thời dùng chế độ khác.\",\n                \"pan_y\": \"Pan (chiều) Y\",\n                \"label\": \"Chế độ cuộn\",\n                \"zoom\": \"Phóng\"\n            }\n        },\n        \"clear_o_history_confirm\": \"Bạn có chắc chắn muốn xóa lịch sử O không?\",\n        \"clear_play_history_confirm\": \"Bạn có chắc muốn xóa lịch sử phát không?\",\n        \"create_new_entity\": \"Tạo mới {entity}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {Bạn có chắc chắn muốn xóa {singularEntity} này không?} other {Bạn có chắc chắn muốn xóa {pluralEntity} này không?}}\",\n        \"delete_gallery_files\": \"Xóa thư mục/tệp zip thư viện ảnh và bất kỳ ảnh nào không được đính kèm vào thư viện ảnh khác.\",\n        \"delete_object_desc\": \"Bạn có chắc muốn xóa {count, plural, one {{singularEntity} này} other {{pluralEntity} này} } không?\",\n        \"dont_show_until_updated\": \"Không hiển thị cho đến bản cập nhật tiếp theo\",\n        \"export_include_related_objects\": \"Bao gồm các đối tượng liên quan khi xuất\",\n        \"export_title\": \"Xuất (dữ liệu)\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"Cột\",\n                \"description\": \"Bố cục dựa trên cột hoặc hàng.\",\n                \"row\": \"Hàng\"\n            },\n            \"margin_desc\": \"Số lượng pixel lề xung quanh mỗi ảnh.\"\n        },\n        \"edit_entity_title\": \"Chỉnh sửa {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"delete_alert\": \"Các {count, plural, one {{singularEntity}} other {{pluralEntity}}} sau đây sẽ bị xóa vĩnh viễn:\",\n        \"delete_confirm\": \"Bạn có chắc chắn muốn xóa {entityName} không?\",\n        \"delete_entity_desc\": \"{count, plural, one {Bạn có chắc chắn muốn xóa {singularEntity} này không? Trừ khi tệp cũng bị xóa, {singularEntity} này sẽ được thêm lại khi quá trình quét được thực hiện.} other {Bạn có chắc chắn muốn xóa {pluralEntity} này không? Trừ khi các tệp cũng bị xóa, {pluralEntity} này sẽ được thêm lại khi quá trình quét được thực hiện.}}\",\n        \"delete_object_overflow\": \"...và {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}} khác.\",\n        \"overwrite_filter_warning\": \"Bộ lọc đã lưu \\\"{entityName}\\\" sẽ bị ghi đè.\",\n        \"scene_gen\": {\n            \"clip_previews\": \"Xem trước Hình ảnh từ Clip\",\n            \"marker_screenshots\": \"Ảnh màn hình Điểm đánh dấu\",\n            \"marker_screenshots_tooltip\": \"Điểm đánh dấu ảnh tĩnh JPG\",\n            \"overwrite\": \"Ghi đè các tệp tin có sẫn\",\n            \"phash\": \"Băm (hash) nhận thức\",\n            \"preview_generation_options\": \"Lựa chọn Tạo ra Bản xem trước\",\n            \"covers\": \"Bìa cảnh quay\",\n            \"force_transcodes\": \"Ép chuyển mã\",\n            \"image_previews\": \"Xem trước Hình ảnh Động\",\n            \"image_thumbnails\": \"Hình thu nhỏ của Ảnh\",\n            \"interactive_heatmap_speed\": \"Tạo bản đồ nhiệt và tốc độ cho các cảnh tương tác\",\n            \"marker_image_previews\": \"Xem trước Điểm đánh dấu Ảnh động\",\n            \"marker_image_previews_tooltip\": \"Ngoài ra sẽ tạo nên những hình xem trước động (webp), chỉ bắt buộc khi Loại Cảnh/Dấu Tường Xem Trước được thành Ảnh Động. Khi lướt nó sử dụng ít CPU hơn video xem trước, nhưng khi tạo nên thì đi kèm với chúng và là những file lớn hơn.\",\n            \"markers\": \"Xem trước Điểm đánh dấu\",\n            \"markers_tooltip\": \"Video 20 giây bắt đầu tại điểm thời gian đã cho sẫn.\",\n            \"override_preview_generation_options\": \"Ghi đè các lựa chọn tạo ra Bản xem trước\",\n            \"phash_tooltip\": \"Cho việc khử trùng lặp và phát hiện cảnh quay\",\n            \"preview_exclude_end_time_desc\": \"Bỏ qua x giây cuối cùng từ các bản xem trước cảnh. Đây có thể là một giá trị bằng giây, hoặc phần trăm (ví dụ 2%) của tổng thời gian cảnh quay.\",\n            \"preview_exclude_start_time_head\": \"Bỏ qua thời gian bắt đầu\",\n            \"preview_options\": \"Lựa chọn Bản xem trước\",\n            \"preview_preset_desc\": \"Phần cài đặt trước điều chỉnh kích thước, chất lượng và thời gian mã hóa của bản xem trước. Các cài đặt trước vượt quá việc \\\"chậm\\\" có hiệu năng giảm dần và không được khuyến khích.\",\n            \"preview_preset_head\": \"Xem trước phần cài đặt trước cho việc mã hóa\",\n            \"preview_exclude_end_time_head\": \"Bỏ qua thời gian kết thúc\",\n            \"force_transcodes_tooltip\": \"Mặc định, việc chuyển mã sẽ được tạo ra khi file video không được hỗ trợ trong trình duyệt. Khi kích hoạt, việc chuyển mã sẽ bắt đầu kể cả khi file video có vẻ được hỗ trợ bởi trình duyệt.\",\n            \"image_previews_tooltip\": \"Ngoài ra sẽ tạo nên những hình xem trước động (webp), chỉ bắt buộc khi Loại Cảnh/Dấu Tường Xem Trước được thành Ảnh Động. Khi lướt nó sử dụng ít CPU hơn video xem trước, nhưng khi tạo nên thì đi kèm với chúng và là những file lớn hơn.\",\n            \"preview_exclude_start_time_desc\": \"Bỏ qua x giây đầu tiên từ các bản xem trước cảnh. Đây có thể là một giá trị bằng giây, hoặc phần trăm (ví dụ 2%) của tổng thời gian cảnh quay.\",\n            \"override_preview_generation_options_desc\": \"Ghi đè các lựa chọn tạo ra Bản xem trước cho hành động này. Những thiết lập mặc định được đặt trong Hệ thống -> Tạo ra Bản xem trước.\",\n            \"preview_seg_count_desc\": \"Số lượng phân đoạn trong các file bản xem trước.\",\n            \"preview_seg_count_head\": \"Số lượng phân đoạn trong bản xem trước\",\n            \"preview_seg_duration_desc\": \"Độ dài của mỗi đoạn xem trước, tính theo giây.\",\n            \"preview_seg_duration_head\": \"Độ dài đoạn xem trước\",\n            \"sprites\": \"Sprites của Phần tìm Phân cảnh\",\n            \"sprites_tooltip\": \"Danh sách ảnh hiển thị ở dưới video để dễ thao tác\",\n            \"transcodes\": \"Giải mã\",\n            \"video_previews\": \"Xem trước\",\n            \"transcodes_tooltip\": \"Mã chuyển đổi MP4 sẽ được tạo trước cho tất cả nội dung; hữu ích cho CPU chậm nhưng cần nhiều dung lượng hơn\",\n            \"video_previews_tooltip\": \"Bản xem trước video phát khi di chuột qua một cảnh\"\n        },\n        \"merge\": {\n            \"destination\": \"Đích đến\",\n            \"source\": \"Nguồn\",\n            \"empty_results\": \"Giá trị điền vào mục Đích đến sẽ không bị thay đổi.\"\n        },\n        \"performers_found\": \"Đã tìm thấy {count} diễn viên\",\n        \"reassign_entity_title\": \"{count, plural, one {Chỉnh lại {singularEntity}} other {Chỉnh lại {pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"Chỉnh lại đến\"\n        },\n        \"scrape_results_existing\": \"Hiện có\",\n        \"set_default_filter_confirm\": \"Bạn có chắc chắn muốn đặt bộ lọc này làm mặc định không?\",\n        \"set_image_url_title\": \"URL hình ảnh\",\n        \"unsaved_changes\": \"Thay đổi chưa được lưu. Bạn có chắc chắn muốn thoát không?\",\n        \"scenes_found\": \"tìm được {count} cảnh quay\"\n    },\n    \"configuration\": \"Cấu Hình\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"Không thể tạo kết nối websocket: xem bảng điều khiển trình duyệt để biết chi tiết\",\n        \"websocket_connection_reestablished\": \"Kết nối Websocket đã được thiết lập lại\"\n    },\n    \"containing_group\": \"Nhóm chứa\",\n    \"containing_groups\": \"Các Nhóm chứa\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {Tệp} other {Các tệp}}\",\n        \"groups\": \"{count, plural, one {Nhóm} other {Các nhóm}}\",\n        \"images\": \"{count, plural, one {Ảnh} other {Các ảnh}}\",\n        \"markers\": \"{count, plural, one {Dấu} other {Các dấu}}\",\n        \"scenes\": \"{count, plural, one {Cảnh} other {Các cảnh}}\",\n        \"studios\": \"{count, plural, one {Studio} other {Các studio}}\",\n        \"tags\": \"{count, plural, one {Thẻ} other {Các thẻ}}\",\n        \"performers\": \"{count, plural, one {Người biểu diễn} other {Các người biểu diễn}}\",\n        \"galleries\": \"{count, plural, one {Thư viện ảnh} other {Các thư viện ảnh}}\"\n    },\n    \"country\": \"Quốc Gia\",\n    \"cover_image\": \"Ảnh Bìa\",\n    \"created_at\": \"Thời gian tạo\",\n    \"criterion\": {\n        \"greater_than\": \"Lớn hơn\",\n        \"less_than\": \"Nhỏ hơn\",\n        \"value\": \"Giá trị\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"Giữa\",\n        \"equals\": \"Là\",\n        \"format_string\": \"{criterion} {modifierString} {valueString}\",\n        \"format_string_excludes\": \"{Tiêu chí} {Điều kiện} {Giá trị} (loại trừ {Ngoại lệ})\",\n        \"format_string_excludes_depth\": \"{Tiêu chí} {Điều kiện} {Giá trị} (loại trừ {Ngoại lệ}) (+{depth, plural, =-1 {tất cả} other {{depth}}})\",\n        \"greater_than\": \"lớn hơn\",\n        \"includes\": \"Bao gồm\",\n        \"includes_all\": \"Bao gồm tất cả\",\n        \"is_null\": \"rỗng\",\n        \"less_than\": \"nhỏ hơn\",\n        \"matches_regex\": \"Khớp biểu thức chính quy\",\n        \"not_between\": \"không nằm giữa\",\n        \"not_equals\": \"không phải\",\n        \"not_null\": \"không rỗng\",\n        \"format_string_depth\": \"{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"not_matches_regex\": \"Không khớp biểu thức chính quy\",\n        \"excludes\": \"Không bao gồm\"\n    },\n    \"death_date\": \"Ngày Mất\",\n    \"death_year\": \"Năm Mất\",\n    \"descending\": \"Giảm dần\",\n    \"details\": \"Thông tin chi tiết\",\n    \"developmentVersion\": \"Phiên bản Phát triển\",\n    \"criterion_modifier_values\": {\n        \"any_of\": \"bất kỳ nào trong số\",\n        \"none\": \"không gì cả\",\n        \"only\": \"Duy nhất\",\n        \"any\": \"bất kỳ\"\n    },\n    \"custom\": \"Tùy chỉnh\",\n    \"date\": \"Ngày\",\n    \"date_format\": \"YYYY-MM-DD\",\n    \"containing_group_count\": \"Số lượng Nhóm chứa\",\n    \"detail\": \"Chi tiết\",\n    \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n    \"dimensions\": \"Kích thước\",\n    \"director\": \"Đạo diễn\",\n    \"display_mode\": {\n        \"grid\": \"Lưới\",\n        \"list\": \"Danh sách\",\n        \"unknown\": \"Chưa biết\",\n        \"label_current\": \"Chế độ Hiển Thị: {current}\",\n        \"wall\": \"Tường\"\n    },\n    \"distance\": \"Khoảng cách\",\n    \"donate\": \"Ủng hộ\",\n    \"dupe_check\": {\n        \"duration_diff\": \"Chênh lệch thời lượng tối đa\",\n        \"only_select_matching_codecs\": \"Chỉ chọn nếu tất cả codec khớp với nhóm trùng lặp\",\n        \"options\": {\n            \"high\": \"Cao\",\n            \"low\": \"Thấp\",\n            \"medium\": \"Trung bình\",\n            \"exact\": \"Chính xác\"\n        },\n        \"search_accuracy_label\": \"Độ chính xác tìm kiếm\",\n        \"select_all_but_largest_file\": \"Chọn mọi tệp trong mỗi nhóm trùng lặp, ngoại trừ tệp lớn nhất\",\n        \"select_all_but_largest_resolution\": \"Chọn mọi tệp trong mỗi nhóm được sao chép, ngoại trừ tệp có độ phân giải cao nhất\",\n        \"select_none\": \"Chọn Không\",\n        \"select_oldest\": \"Chọn tệp cũ nhất trong nhóm trùng lặp\",\n        \"select_options\": \"Chọn Tùy chọn…\",\n        \"select_youngest\": \"Chọn tệp mớinhất trong nhóm trùng lặp\",\n        \"title\": \"Cảnh quay trùng lặp\",\n        \"duration_options\": {\n            \"any\": \"Bất kỳ\",\n            \"equal\": \"Tương đương\"\n        }\n    },\n    \"duplicated_phash\": \"Trùng lặp (pHash)\",\n    \"duration\": \"Thời lượng\",\n    \"effect_filters\": {\n        \"aspect\": \"Tỷ lệ\",\n        \"blur\": \"Mờ\",\n        \"brightness\": \"Độ sáng\",\n        \"contrast\": \"Tương phản\",\n        \"green\": \"Xanh lá\",\n        \"hue\": \"Sắc thái\",\n        \"name\": \"Bộ lọc\",\n        \"name_transforms\": \"Biến đổi\",\n        \"red\": \"Đỏ\",\n        \"reset_filters\": \"Đặt lại bộ lọc\",\n        \"reset_transforms\": \"Đặt lại chuyển đổi\",\n        \"rotate\": \"Xoay\",\n        \"rotate_left_and_scale\": \"Xoay trái và thay đổi tỷ lệ\",\n        \"rotate_right_and_scale\": \"Xoay phải và thay đổi tỷ lệ\",\n        \"saturation\": \"Độ bão hòa\",\n        \"scale\": \"Tỉ lệ\",\n        \"warmth\": \"Sự ấm áp\"\n    },\n    \"empty_server\": \"Thêm một số cảnh quay vào máy chủ của bạn để xem các đề xuất trên trang này.\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/zh-CN.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"新增\",\n        \"add_directory\": \"新增目录\",\n        \"add_entity\": \"新增{entityType}\",\n        \"add_to_entity\": \"新增至{entityType}\",\n        \"allow\": \"允许\",\n        \"allow_temporarily\": \"暂时允许\",\n        \"anonymise\": \"匿名化\",\n        \"apply\": \"应用\",\n        \"auto_tag\": \"自动标签\",\n        \"backup\": \"备份\",\n        \"browse_for_image\": \"浏览图片…\",\n        \"cancel\": \"取消\",\n        \"clean\": \"清理\",\n        \"clear\": \"清除\",\n        \"clear_back_image\": \"清除背面图片\",\n        \"clear_front_image\": \"清除正面图片\",\n        \"clear_image\": \"清除图片\",\n        \"close\": \"关闭\",\n        \"confirm\": \"确认\",\n        \"continue\": \"继续\",\n        \"create\": \"创建\",\n        \"create_chapters\": \"创建章节\",\n        \"create_entity\": \"创建{entityType}\",\n        \"create_marker\": \"创建标记\",\n        \"created_entity\": \"已创建{entity_type}: {entity_name}\",\n        \"customise\": \"自定义\",\n        \"delete\": \"删除\",\n        \"delete_entity\": \"删除{entityType}\",\n        \"delete_file\": \"删除文件\",\n        \"delete_file_and_funscript\": \"删除文件 (和 funscript)\",\n        \"delete_generated_supporting_files\": \"删除已生成的附加文件\",\n        \"disallow\": \"不允许\",\n        \"download\": \"下载\",\n        \"download_anonymised\": \"匿名下载\",\n        \"download_backup\": \"下载备份\",\n        \"edit\": \"编辑\",\n        \"edit_entity\": \"编辑{entityType}\",\n        \"export\": \"导出\",\n        \"export_all\": \"导出所有…\",\n        \"find\": \"查找\",\n        \"finish\": \"完成\",\n        \"from_file\": \"来自文件…\",\n        \"from_url\": \"来自网址…\",\n        \"full_export\": \"完整导出\",\n        \"full_import\": \"完整导入\",\n        \"generate\": \"生成\",\n        \"generate_thumb_default\": \"生成默认预览图\",\n        \"generate_thumb_from_current\": \"从现在的画面生成预览图\",\n        \"hash_migration\": \"识别码迁移\",\n        \"hide\": \"隐藏\",\n        \"hide_configuration\": \"隐藏设定\",\n        \"identify\": \"刮削\",\n        \"ignore\": \"忽略\",\n        \"import\": \"导入…\",\n        \"import_from_file\": \"从文件导入\",\n        \"logout\": \"登出\",\n        \"make_primary\": \"作为主要文件\",\n        \"merge\": \"合并\",\n        \"migrate_blobs\": \"迁移数据\",\n        \"migrate_scene_screenshots\": \"迁移场景截图\",\n        \"next_action\": \"下一步\",\n        \"not_running\": \"未运行\",\n        \"open_in_external_player\": \"由外部播放器打开\",\n        \"open_random\": \"开启随机\",\n        \"overwrite\": \"覆盖\",\n        \"play_random\": \"随机播放\",\n        \"play_selected\": \"播放所选\",\n        \"preview\": \"预览\",\n        \"previous_action\": \"上一步\",\n        \"reassign\": \"重新分配\",\n        \"refresh\": \"刷新\",\n        \"reload_plugins\": \"重载插件\",\n        \"reload_scrapers\": \"重载刮削器\",\n        \"remove\": \"移除\",\n        \"remove_from_gallery\": \"从图库中删除\",\n        \"rename_gen_files\": \"重命名已生成的文件\",\n        \"rescan\": \"重新扫描\",\n        \"reshuffle\": \"重新排列\",\n        \"running\": \"运行中\",\n        \"save\": \"保存\",\n        \"save_delete_settings\": \"在删除时使用以下默认选项\",\n        \"save_filter\": \"保存过滤条件\",\n        \"scan\": \"扫描\",\n        \"scrape\": \"刮削\",\n        \"scrape_query\": \"刮削查询关键字\",\n        \"scrape_scene_fragment\": \"以部分名称刮削\",\n        \"scrape_with\": \"使用刮削器…\",\n        \"search\": \"搜索\",\n        \"select_all\": \"选择所有\",\n        \"select_entity\": \"选择{entityType}\",\n        \"select_folders\": \"选择目录\",\n        \"select_none\": \"清除选择\",\n        \"selective_auto_tag\": \"选择性自动标签\",\n        \"selective_clean\": \"选择性清理\",\n        \"selective_scan\": \"选择性扫描\",\n        \"set_as_default\": \"设置为默认\",\n        \"set_back_image\": \"设置背面图…\",\n        \"set_front_image\": \"设置正面图…\",\n        \"set_image\": \"设置图片…\",\n        \"show\": \"显示\",\n        \"show_configuration\": \"显示设定\",\n        \"skip\": \"跳过\",\n        \"split\": \"分割\",\n        \"stop\": \"停止\",\n        \"submit\": \"提交\",\n        \"submit_stash_box\": \"提交给 Stash-Box\",\n        \"submit_update\": \"提交更新\",\n        \"swap\": \"交换\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"确定要清除吗？ 这将删除系统中不存在的所有短片和图库的数据库信息和已经生成的内容。\",\n            \"dry_mode_selected\": \"已经选择了模拟删除模式。不会实际删除文件，只会写下记录。\",\n            \"import_warning\": \"确定要导入吗？ 这将删除现有数据库并从导出的元数据中重新导入。\"\n        },\n        \"temp_disable\": \"暂时关闭…\",\n        \"temp_enable\": \"暂时启用…\",\n        \"unset\": \"重设\",\n        \"use_default\": \"使用默认\",\n        \"view_random\": \"随机查看\",\n        \"create_parent_studio\": \"创建上级工作室\",\n        \"optimise_database\": \"优化数据库\",\n        \"assign_stashid_to_parent_studio\": \"将Stash ID分配给现有的上级工作室并更新元数据\",\n        \"encoding_image\": \"图片转码中…\",\n        \"reload\": \"重新加载\",\n        \"disable\": \"禁用\",\n        \"enable\": \"启用\",\n        \"clear_date_data\": \"清除日期数据\",\n        \"choose_date\": \"选择一个日期\",\n        \"add_play\": \"添加播放记录\",\n        \"add_manual_date\": \"添加手动日期\",\n        \"copy_to_clipboard\": \"复制到剪贴板\",\n        \"remove_date\": \"去除日期\",\n        \"add_o\": \"添加高潮记录\",\n        \"clean_generated\": \"清除已生成的文件\",\n        \"view_history\": \"观看历史\",\n        \"reset_cover\": \"恢复默认封面\",\n        \"set_cover\": \"设置为封面\",\n        \"reset_play_duration\": \"重置播放时长\",\n        \"reset_resume_time\": \"重置恢复时间\",\n        \"add_sub_groups\": \"添加子集合\",\n        \"remove_from_containing_group\": \"从集合中移除\",\n        \"sidebar\": {\n            \"close\": \"关闭侧边栏\",\n            \"open\": \"打开侧边栏\",\n            \"toggle\": \"切换侧边栏\"\n        },\n        \"play\": \"播放\",\n        \"show_results\": \"显示结果\",\n        \"show_count_results\": \"显示{count}个结果\",\n        \"load\": \"加载\",\n        \"load_filter\": \"加载过滤器\",\n        \"add_stash_id\": \"添加Stash编号\",\n        \"create_new\": \"新建\",\n        \"save_and_new\": \"保存并新建\",\n        \"invert_selection\": \"反向选择\",\n        \"select_directory\": \"选择目录\",\n        \"reveal_in_file_manager\": \"在文件管理器中显示\",\n        \"exclude_lowercase\": \"排除\",\n        \"from_clipboard\": \"来自剪贴板\",\n        \"selective_generate\": \"选择性生成\"\n    },\n    \"actions_name\": \"操作\",\n    \"age\": \"年龄\",\n    \"aliases\": \"别名\",\n    \"all\": \"所有\",\n    \"also_known_as\": \"又称作\",\n    \"appears_with\": \"合作者\",\n    \"ascending\": \"升序\",\n    \"average_resolution\": \"平均分辨率\",\n    \"between_and\": \"以及\",\n    \"birth_year\": \"出生年份\",\n    \"birthdate\": \"出生日期\",\n    \"bitrate\": \"比特率\",\n    \"blobs_storage_type\": {\n        \"database\": \"数据库\",\n        \"filesystem\": \"文件系统\"\n    },\n    \"captions\": \"字幕\",\n    \"career_length\": \"工龄\",\n    \"chapters\": \"章节\",\n    \"circumcised\": \"割包皮\",\n    \"circumcised_types\": {\n        \"CUT\": \"已切\",\n        \"UNCUT\": \"未切\"\n    },\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"目前使用的 Stash-box：\",\n            \"blacklist_desc\": \"黑名单内的项目不会被查询。注意：它们是正则表达式且不分大小写。 某些字符必须用反斜杠转义: {chars_require_escape}\",\n            \"blacklist_label\": \"黑名单\",\n            \"query_mode_auto\": \"自动\",\n            \"query_mode_auto_desc\": \"使用元数据，无则用文件名\",\n            \"query_mode_dir\": \"目录名\",\n            \"query_mode_dir_desc\": \"仅使用视频文件的上级目录\",\n            \"query_mode_filename\": \"文件名\",\n            \"query_mode_filename_desc\": \"仅使用文件名\",\n            \"query_mode_label\": \"查询方式\",\n            \"query_mode_metadata\": \"元数据\",\n            \"query_mode_metadata_desc\": \"仅使用元数据\",\n            \"query_mode_path\": \"路径\",\n            \"query_mode_path_desc\": \"使用文件完整路径\",\n            \"set_cover_desc\": \"找到封面时替代原有短片的封面.\",\n            \"set_cover_label\": \"设置短片封面\",\n            \"set_tag_desc\": \"通过覆盖或与短片中的现有标签合并，将标签附加到短片。\",\n            \"set_tag_label\": \"设置标签\",\n            \"source\": \"源\",\n            \"mark_organized_label\": \"保存时标记为已整理\",\n            \"mark_organized_desc\": \"点击保存按钮后，立即将短片标记为 \\\"已整理\\\"。\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"重复黑名单项目\"\n            },\n            \"performer_genders\": {\n                \"heading\": \"演员性别\",\n                \"description\": \"这些性别的演员在标记场景时会被展示。\"\n            }\n        },\n        \"noun_query\": \"查询\",\n        \"results\": {\n            \"duration_off\": \"时长至少 相差{number}秒\",\n            \"duration_unknown\": \"时长未知\",\n            \"fp_found\": \"{fpCount, plural, =0 {没有新的匹配指纹} other {# 匹配到新的指纹}}\",\n            \"fp_matches\": \"时长符合\",\n            \"fp_matches_multi\": \"时长符合 {matchCount}/{durationsLength} 个指纹\",\n            \"hash_matches\": \"{hash_type} 数值符合\",\n            \"match_failed_already_tagged\": \"短片已经添加标签\",\n            \"match_failed_no_result\": \"没有找到\",\n            \"match_success\": \"短片成功添加标签\",\n            \"phash_matches\": \"{count} 个感知识别码 匹配\",\n            \"unnamed\": \"无名\"\n        },\n        \"verb_match_fp\": \"匹配指纹\",\n        \"verb_matched\": \"符合\",\n        \"verb_scrape_all\": \"挖掘所有\",\n        \"verb_submit_fp\": \"提交 {fpCount, plural, one{# 指纹} other{# 指纹}}\",\n        \"verb_toggle_config\": \"{toggle} {configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle} 未匹配的短片\",\n        \"verb_add_as_alias\": \"添加刮削到的名字作为别名\",\n        \"verb_link_existing\": \"链接到已存在的\",\n        \"verb_match_tag\": \"匹配标签\",\n        \"verb_scrape_selected\": \"已选刮削器\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"版本识别码:\",\n            \"build_time\": \"编译时间:\",\n            \"check_for_new_version\": \"检查新版本\",\n            \"latest_version\": \"最新版本\",\n            \"latest_version_build_hash\": \"最新版本识别码:\",\n            \"new_version_notice\": \"[新版本]\",\n            \"release_date\": \"发布日期：\",\n            \"stash_discord\": \"加入我们的 {url} 频道\",\n            \"stash_home\": \"Stash 主页在 {url}\",\n            \"stash_open_collective\": \"通过 {url} 支持我们\",\n            \"stash_wiki\": \"Stash {url} 页面\",\n            \"version\": \"版本\"\n        },\n        \"application_paths\": {\n            \"heading\": \"程序路径\"\n        },\n        \"categories\": {\n            \"about\": \"关于\",\n            \"changelog\": \"更新历史\",\n            \"interface\": \"界面\",\n            \"logs\": \"日志\",\n            \"metadata_providers\": \"元数据提供者\",\n            \"plugins\": \"插件\",\n            \"scraping\": \"挖掘\",\n            \"security\": \"安全性\",\n            \"services\": \"服务\",\n            \"system\": \"系统\",\n            \"tasks\": \"任务\",\n            \"tools\": \"工具\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"允许 {tempIP}\",\n            \"allowed_ip_addresses\": \"已经允许的 IP 地址\",\n            \"allowed_ip_temporarily\": \"暂时允许的 IP 地址\",\n            \"default_ip_whitelist\": \"默认 IP 白名单\",\n            \"default_ip_whitelist_desc\": \"默认IP地址允许连接到DLNA，使用 {wildcard} 以允许所有IP地址连接。\",\n            \"disabled_dlna_temporarily\": \"暂时禁止 DLNA\",\n            \"disallowed_ip\": \"不允许的 IP 地址\",\n            \"enabled_by_default\": \"默认启用\",\n            \"enabled_dlna_temporarily\": \"暂时允许 DLNA\",\n            \"network_interfaces\": \"网络接口\",\n            \"network_interfaces_desc\": \"选择要在哪个接口上暴露 DLNA 服务，如果为空，则表示所有接口都可用，更改之后需要重启DLNA。\",\n            \"recent_ip_addresses\": \"最近连接的IP地址\",\n            \"server_display_name\": \"服务器名称\",\n            \"server_display_name_desc\": \"DLAN服务器的名称。如果为空，则默认为 {server_name}。\",\n            \"successfully_cancelled_temporary_behaviour\": \"成功取消暂时的服务行为\",\n            \"until_restart\": \"直到服务重启\",\n            \"video_sort_order\": \"默认视频排序\",\n            \"video_sort_order_desc\": \"默认情况下对视频进行排序。\",\n            \"server_port\": \"服务器端口\",\n            \"server_port_desc\": \"运行DLNA服务器的端口。更改后需要重新启动DLNA。\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API 密钥\",\n                \"api_key_desc\": \"外部系统使用的 API 密钥，只有在设置了用户名和密码之后才需要。在生成之前必须先设置用户名。\",\n                \"authentication\": \"认证设置\",\n                \"clear_api_key\": \"清除 API 密钥\",\n                \"credentials\": {\n                    \"description\": \"用以限制使用Stash的账户凭证。\",\n                    \"heading\": \"账户凭证\"\n                },\n                \"generate_api_key\": \"生成 API 密钥\",\n                \"log_file\": \"日志文件\",\n                \"log_file_desc\": \"日志文件的路径，如果为空则表示关闭日志记录。更改之后需要重启。\",\n                \"log_http\": \"记录 HTTP 访问日志\",\n                \"log_http_desc\": \"输出 HTTP 访问日志到终端，更改之后需要重启。\",\n                \"log_to_terminal\": \"输出日志到终端\",\n                \"log_to_terminal_desc\": \"日志除了输出到文件外还输出到终端，如果关闭日志记录则始终开启。更改之后需要重启。\",\n                \"maximum_session_age\": \"最大会话有效期\",\n                \"maximum_session_age_desc\": \"无操作多久后登出, 单位为秒。重启生效。\",\n                \"password\": \"密码\",\n                \"password_desc\": \"登录 Stash 时所需的密码.留空表示关闭身份验证\",\n                \"stash-box_integration\": \"整合 Stash-box\",\n                \"username\": \"用户名\",\n                \"username_desc\": \"登录 Stash 时所需的用户名.留空表示关闭身份验证\",\n                \"log_file_max_size\": \"最大日志尺寸\",\n                \"log_file_max_size_desc\": \"日志文件压缩前的最大大小（以兆字节为单位）。0MB 表示禁用此功能。需要重启。\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"备份 SQLite 数据库文件的目录路径。\",\n                \"heading\": \"备份用的路径\"\n            },\n            \"blobs_path\": {\n                \"description\": \"在文件系统中存储二进制数据的位置。仅在使用 Filesystem blob 存储类型时适用。警告：更改此项需要手动移动现有数据。\",\n                \"heading\": \"二进制数据文件储存路径\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"存储二进制数据（如短片封面、表演者、工作室和标签图像）的地方。在改变这个值之后，必须使用迁移数据任务来迁移现有数据。参见迁移数据任务页面。\",\n                \"heading\": \"二进制数据存储类型\"\n            },\n            \"cache_location\": \"缓存的目录位置。如果使用 HLS（例如在 Apple 设备上）或 DASH 进行流传输，则需要该位置。\",\n            \"cache_path_head\": \"缓存路径\",\n            \"calculate_md5_and_ohash_desc\": \"除了快搜码外还计算 MD5 值。如果开启，初次扫描时速度会较慢。如果关闭 MD5 值计算，则必须将文件名识别码算法设置为快搜码.\",\n            \"calculate_md5_and_ohash_label\": \"计算影片MD5值\",\n            \"check_for_insecure_certificates\": \"检查证书安全性\",\n            \"check_for_insecure_certificates_desc\": \"某些网站所使用的 SSL 证书可能有安全问题。取消勾选之后挖掘器会跳过证书安全性检查，如果你遇到了证书错误的问题，可以取消此选项。\",\n            \"chrome_cdp_path\": \"谷歌浏览器 CDP 路径\",\n            \"chrome_cdp_path_desc\": \"谷歌浏览器 可执行文件的路径, 或者远端地址 (以 http:// 或 https:// 开头, 比如 http://localhost:9222/json/version)。\",\n            \"create_galleries_from_folders_desc\": \"如果勾选，则默认从包含图片的文件夹中创建画廊。在文件夹中创建一个名为 .forcegallery 或 .nogallery 的文件来覆盖此设置。\",\n            \"create_galleries_from_folders_label\": \"从包含图片的文件夹建立图库\",\n            \"database\": \"数据库\",\n            \"db_path_head\": \"数据库所在路径\",\n            \"directory_locations_to_your_content\": \"你的影片等收藏的路径\",\n            \"excluded_image_gallery_patterns_desc\": \"要从扫描中排除并会被[清除]功能所移除的图片及图库文件/路径的正则表达式。\",\n            \"excluded_image_gallery_patterns_head\": \"图片/图库排除规则\",\n            \"excluded_video_patterns_desc\": \"要从扫描中排除并会被[清除]功能所移除的视频文件/路径的正则表达式。\",\n            \"excluded_video_patterns_head\": \"视频排除规则\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"desc\": \"使用可用的硬件对视频进行编码来用于实时转码。\",\n                    \"heading\": \"FFmpeg 硬件编码\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"高级：当直播转码的视频时，在输入参数前要传给ffmpeg用的附加参数。\",\n                        \"heading\": \"FFmpeg直播转码用的输入参数\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"高级：当直播转码视频时，在输出视频参数前要传给ffmpeg的附加参数。\",\n                        \"heading\": \"FFmpeg直播转码输出参数\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"desc\": \"高级：当生成视频时，在输入视频参数前要传给ffmpeg的附加参数。\",\n                        \"heading\": \"FFmpeg 转码用的输入参数\"\n                    },\n                    \"output_args\": {\n                        \"desc\": \"高级：当生成视频时，在输出视频参数前要传给ffmpeg的附加参数。\",\n                        \"heading\": \"FFmpeg转码输出参数\"\n                    }\n                },\n                \"ffmpeg_path\": {\n                    \"heading\": \"FFmpeg可执行路径\",\n                    \"description\": \"ffmpeg可执行文件（不仅仅是文件夹）的路径。如果为空，ffmpeg将通过$PATH、配置目录或$HOME/. stash从环境中解析。\"\n                },\n                \"ffprobe_path\": {\n                    \"heading\": \"FFprobe 可执行路径\",\n                    \"description\": \"ffprobe可执行文件的路径（不仅仅是文件夹）。如果为空，ffprobe将通过$PATH、配置目录或$HOME/. stash从环境中解析。\"\n                },\n                \"download_ffmpeg\": {\n                    \"heading\": \"下载FFmpeg\",\n                    \"description\": \"将FFmpeg下载到配置目录并清除要从配置目录解析的ffmpeg和ffprobe路径。\"\n                }\n            },\n            \"funscript_heatmap_draw_range\": \"在生成的热图中包括范围\",\n            \"funscript_heatmap_draw_range_desc\": \"在生成的热图的y轴上绘制运动范围。更改后需要重新生成现有热图。\",\n            \"gallery_cover_regex_desc\": \"正则表达式用于将图像识别为图库封面。\",\n            \"gallery_cover_regex_label\": \"图库封面模式\",\n            \"gallery_ext_desc\": \"逗号(半角)分隔的文件扩展名列表，将被标识为基于压缩文件的图库。\",\n            \"gallery_ext_head\": \"图库压缩包扩展名\",\n            \"generated_file_naming_hash_desc\": \"使用 MD5 或快搜码为生成的文件命名。 更改此设置要求所有短片都有对应的 MD5/快搜码 值。 更改此值后，之前生成的数据需要迁移或重新生成。 请参阅 [迁移] 页面。\",\n            \"generated_file_naming_hash_head\": \"生成文件名识别码\",\n            \"generated_files_location\": \"生成数据的存储目录（短片标记，短片预览，预览图等）。\",\n            \"generated_path_head\": \"生成数据的存储路径\",\n            \"hashing\": \"识别码设置\",\n            \"heatmap_generation\": \"Funscript 热图生成\",\n            \"image_ext_desc\": \"逗号(半角)分隔的文件扩展名列表，将被标识为图片。\",\n            \"image_ext_head\": \"图片扩展名\",\n            \"include_audio_desc\": \"生成预览时包括音频流.\",\n            \"include_audio_head\": \"包括声音\",\n            \"logging\": \"日志设置\",\n            \"maximum_streaming_transcode_size_desc\": \"转码生成的串流最大值。\",\n            \"maximum_streaming_transcode_size_head\": \"转码生成的串流的最大清晰度\",\n            \"maximum_transcode_size_desc\": \"转码生成的影片的最大大小。\",\n            \"maximum_transcode_size_head\": \"最大的转码清晰度\",\n            \"metadata_path\": {\n                \"description\": \"整体导出或者导入时使用的路径。\",\n                \"heading\": \"元数据存储路径\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"设置为 0 以进行自动检测。 警告，当运行超过需要的多个任务使得 CPU 达到满负荷时，将降低性能并可能导致其他问题。\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"扫描/生成的并行任务数量\",\n            \"parallel_scan_head\": \"并行扫描/生成\",\n            \"preview_generation\": \"生成预览\",\n            \"python_path\": {\n                \"description\": \"Python 执行程序的路径。（不限于文件夹）给网页挖掘器和插件的源文件使用。如果没有，Python会从环境变量找到。\",\n                \"heading\": \"Python 可执行文件路径\"\n            },\n            \"scraper_user_agent\": \"刮削器用户代理 (User Agent)\",\n            \"scraper_user_agent_desc\": \"刮削器运行HTTP请求时的用户代理 (User Agent)。\",\n            \"scrapers_path\": {\n                \"description\": \"含有刮削器配置文件的路径。\",\n                \"heading\": \"刮削器路径\"\n            },\n            \"scraping\": \"刮削器设置\",\n            \"sqlite_location\": \"SQLite 数据库的位置(需要重启)。警告：不支持将数据库放在与 Stash 服务器以外的系统上(即通过网络)！\",\n            \"video_ext_desc\": \"逗号(半角)分隔的文件扩展名列表，将被标识为视频。\",\n            \"video_ext_head\": \"视频扩展名\",\n            \"video_head\": \"视频设置\",\n            \"plugins_path\": {\n                \"heading\": \"插件文件路径\",\n                \"description\": \"插件配置文件目录。\"\n            },\n            \"delete_trash_path\": {\n                \"description\": \"删除的文件将被移动到的路径，而不是永久删除。留空将永久删除文件。\",\n                \"heading\": \"回收站路径\"\n            },\n            \"sprite_generation_head\": \"时间轴缩略图生成\",\n            \"sprite_interval_desc\": \"时间轴缩略图之间的时间间隔（秒）。\",\n            \"sprite_interval_head\": \"时间轴缩略图间隔\",\n            \"sprite_maximum_desc\": \"短片中可生成的最大时间轴缩略图数量。设置为 0 可禁用此限制。\",\n            \"sprite_maximum_head\": \"时间轴缩略图最大数量\",\n            \"sprite_minimum_desc\": \"一个短片中最少生成的时间轴缩略图数量\",\n            \"sprite_minimum_head\": \"时间轴缩略图最少数量\",\n            \"sprite_screenshot_size_desc\": \"每个时间轴缩略图所需像素大小。\",\n            \"sprite_screenshot_size_head\": \"时间轴缩略图尺寸\",\n            \"use_custom_sprite_interval_head\": \"使用自定义时间轴缩略图间隔\",\n            \"use_custom_sprite_interval_desc\": \"根据以下设置启用自定义时间轴缩略图间隔。\"\n        },\n        \"library\": {\n            \"exclusions\": \"不包括\",\n            \"gallery_and_image_options\": \"图库和图片的选项\",\n            \"media_content_extensions\": \"媒体的文件扩展名\"\n        },\n        \"logs\": {\n            \"log_level\": \"日志等级\"\n        },\n        \"plugins\": {\n            \"hooks\": \"回调\",\n            \"triggers_on\": \"触发于\",\n            \"installed_plugins\": \"已安装插件\",\n            \"available_plugins\": \"可用插件\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType}元数据\",\n            \"entity_scrapers\": \"{entityType}刮削器\",\n            \"excluded_tag_patterns_desc\": \"从刮削结果中排除的标签名称的正则表达式。\",\n            \"excluded_tag_patterns_head\": \"排除标签的正则表达式\",\n            \"scraper\": \"刮削器\",\n            \"scrapers\": \"刮削器\",\n            \"search_by_name\": \"按名称搜索\",\n            \"supported_types\": \"支持类型\",\n            \"supported_urls\": \"支持链接\",\n            \"available_scrapers\": \"可用刮削器\",\n            \"installed_scrapers\": \"已安装刮削器\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"新增 Stash-Box 实例\",\n            \"api_key\": \"API 密钥\",\n            \"description\": \"Stash-box 根据指纹和文件名自动标记短片和演员。\\n入口和 API 密钥可以在您的帐户页面上的 stash-box 实例中找到。 添加多个实例时必须设置名称。\",\n            \"endpoint\": \"入口\",\n            \"graphql_endpoint\": \"GraphQL 入口\",\n            \"name\": \"名称\",\n            \"title\": \"Stash-box 入口\",\n            \"max_requests_per_minute\": \"每分钟最多可发起请求数量\",\n            \"max_requests_per_minute_description\": \"使用{defaultValue}的默认数值，当其数值设置为0时\"\n        },\n        \"system\": {\n            \"transcoding\": \"转码\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"已经添加 {operation_name} 到工作队列\",\n            \"anonymise_and_download\": \"建立一个匿名的数据库拷贝然后下载其文件。\",\n            \"anonymise_database\": \"建立一个数据库的拷贝到备份目录，匿名化所有敏感数据。这可以为其他人提供查找问题和去虫的方法。原本的数据库是没有改动的。匿名化数据库使用文件名格式 {filename_format}.\",\n            \"anonymising_database\": \"数据库匿名化\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"自动标签所有路径\",\n                \"auto_tagging_paths\": \"自动标签以下路径\"\n            },\n            \"auto_tag_based_on_filenames\": \"根据文件路径自动添加标签。\",\n            \"auto_tagging\": \"自动标签\",\n            \"backing_up_database\": \"自动备份数据中\",\n            \"backup_and_download\": \"备份数据库并下载其文件.\",\n            \"cleanup_desc\": \"检查缺失的文件并将它们的数据从数据库中删除。 注意，这是一个破坏性的动作。\",\n            \"data_management\": \"数据管理\",\n            \"defaults_set\": \"预设值已经设定好，将会在按下任务页面里的{action}按钮后生效.\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"不要把扩展名作为标题的一部分\",\n            \"empty_queue\": \"目前没有任务在运行.\",\n            \"export_to_json\": \"将数据库中元数据目录的内容导出为 JSON 文件格式.\",\n            \"generate\": {\n                \"generating_from_paths\": \"正在为以下路径的短片生成资料\",\n                \"generating_scenes\": \"正在为{num}个{scene}生成资料\"\n            },\n            \"generate_clip_previews_during_scan\": \"为图像短片生成预览图\",\n            \"generate_desc\": \"生成辅助的图片，预览，片段，字幕等其他文件。\",\n            \"generate_phashes_during_scan\": \"生成感知识别码\",\n            \"generate_phashes_during_scan_tooltip\": \"为了防止重复和短片甄别.\",\n            \"generate_previews_during_scan\": \"生成动态图片预览\",\n            \"generate_previews_during_scan_tooltip\": \"生成webp动画预览，仅适用于短片/标记墙预览类型设为动图的情况. 浏览时将更少CPU占用，但生成的是额外的且更大的文件。\",\n            \"generate_sprites_during_scan\": \"生成时间轴预览小图\",\n            \"generate_thumbnails_during_scan\": \"生成图片的缩略图\",\n            \"generate_video_covers_during_scan\": \"生成短片封面\",\n            \"generate_video_previews_during_scan\": \"生成预览\",\n            \"generate_video_previews_during_scan_tooltip\": \"产生视频预览，用以鼠标移到短片上时播放\",\n            \"generated_content\": \"生成的内容\",\n            \"identify\": {\n                \"and_create_missing\": \"和生成暂无的\",\n                \"create_missing\": \"生成暂无的\",\n                \"default_options\": \"默认选项\",\n                \"description\": \"使用stash-box和刮削器自动设立短片的元数据.\",\n                \"explicit_set_description\": \"如果源没有特别设定，下列选项将被使用。\",\n                \"field\": \"数据字段\",\n                \"field_behaviour\": \"{strategy}{field}\",\n                \"field_options\": \"数据选项\",\n                \"heading\": \"识别\",\n                \"identifying_from_paths\": \"正在识别以下路径的短片\",\n                \"identifying_scenes\": \"正在识别{num}{scene}\",\n                \"include_male_performers\": \"包括男性表演者\",\n                \"set_cover_images\": \"设立封面图\",\n                \"set_organized\": \"设定“已整理”标志\",\n                \"source\": \"源\",\n                \"source_options\": \"{source} 选项\",\n                \"sources\": \"源\",\n                \"strategy\": \"策略\",\n                \"skip_multiple_matches\": \"跳过有多个结果的匹配\",\n                \"skip_single_name_performers\": \"跳过无消歧义字段且名字为单个词的表演者\",\n                \"skip_multiple_matches_tooltip\": \"如果未启用此选项，且返回的结果不止一个，则将随机选择一个结果与之匹配\",\n                \"tag_skipped_performers\": \"标记跳过的演员\",\n                \"tag_skipped_matches\": \"标记跳过的匹配项\",\n                \"skip_single_name_performers_tooltip\": \"如果未启用此选项，则将匹配 Samantha 或 Olga 等经常被通用的表演者\",\n                \"tag_skipped_performer_tooltip\": \"创建类似“识别：单名表演者”的标签，您可以在短片标签视图中进行筛选，并选择如何处理这些表演者\",\n                \"tag_skipped_matches_tooltip\": \"创建类似“识别：多个匹配项”的标签，您可以在短片标签视图中进行筛选，并手动选择正确的匹配项\",\n                \"performer_genders\": \"表演者性别\",\n                \"performer_genders_desc\": \"识别时将包含选中性别的表演者。\"\n            },\n            \"import_from_exported_json\": \"导入之前导出的json格式元数据。 删除现有数据库内容。\",\n            \"incremental_import\": \"从导出的 zip 文件增量导入。\",\n            \"job_queue\": \"任务队列\",\n            \"maintenance\": \"维护\",\n            \"migrate_blobs\": {\n                \"delete_old\": \"删除旧数据\",\n                \"description\": \"将 blob 迁移到当前 blob 存储系统。应在更改 blob 存储系统之后运行此迁移。可以选择在迁移后删除旧数据。\"\n            },\n            \"migrate_hash_files\": \"使用更改之后的识别码算法重新命名已经存在的文件到新的识别码格式。\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"删除截图文件\",\n                \"description\": \"将短片屏幕截图迁移到新的 blob 存储系统中。应在将现有系统迁移到 0.20 之后运行此迁移。可以选择在迁移后删除旧的屏幕截图。\",\n                \"overwrite_existing\": \"用屏幕截图数据覆盖现有 Blob\"\n            },\n            \"migrations\": \"迁移\",\n            \"only_dry_run\": \"仅模拟运行，不要删除任何东西\",\n            \"plugin_tasks\": \"插件任务\",\n            \"scan\": {\n                \"scanning_all_paths\": \"正在扫描所有路径\",\n                \"scanning_paths\": \"正在扫描以下路径\"\n            },\n            \"scan_for_content_desc\": \"扫描新内容并添加至数据库中。\",\n            \"set_name_date_details_from_metadata_if_present\": \"使用文件内含的元数据的标题、日期、详情\",\n            \"generate_sprites_during_scan_tooltip\": \"视频播放器下方用于快速导航的缩略图集。\",\n            \"optimise_database\": \"尝试通过分析然后重建整个数据库文件来提高性能。\",\n            \"optimise_database_warning\": \"警告：当此任务运行时，任何修改数据库的操作都将失败，而且根据数据库的大小，可能需要几分钟才能完成。此外，它至少需要与数据库大小相当的可用磁盘空间，但建议使用 1.5 倍的空间。\",\n            \"clean_generated\": {\n                \"blob_files\": \"Blob文件\",\n                \"description\": \"移除没有相应数据库条目的已生成文件。\",\n                \"image_thumbnails\": \"图像缩略图\",\n                \"image_thumbnails_desc\": \"图像缩略图和片段\",\n                \"markers\": \"标记预览\",\n                \"previews\": \"短片预览\",\n                \"previews_desc\": \"短片预览和缩略图\",\n                \"sprites\": \"短片时间轴缩略图\",\n                \"transcodes\": \"短片转码\"\n            },\n            \"rescan\": \"重新扫描文件\",\n            \"rescan_tooltip\": \"重新扫描路径中的每个文件。用于强制更新文件元数据和重新扫描zip文件。\",\n            \"generate_image_phashes_during_scan\": \"生成图像感知哈希值\",\n            \"generate_image_phashes_during_scan_tooltip\": \"用于去重和识别。\",\n            \"backup_database\": {\n                \"description\": \"执行数据库和 blob 文件的备份。\",\n                \"destination\": \"目标位置\",\n                \"download\": \"下载备份\",\n                \"include_blobs\": \"在备份中包含blobs\",\n                \"include_blobs_desc\": \"禁用此功能后，仅备份 SQLite 数据库文件。\",\n                \"sqlite\": \"备份文件将是 SQLite 数据库文件的副本，文件名格式为 {filename_format}\",\n                \"to_directory\": \"到 {directory}\",\n                \"warning_blobs\": \"备份中不会包含 Blob 文件。这意味着，要成功从备份中恢复，Blob 文件必须存在于 Blob 存储位置。\",\n                \"zip\": \"SQLite 数据库文件和 blob 文件将被压缩到一个文件中，文件名格式为 {filename_format}\"\n            }\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"短片重复性检查工具\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"新增字段\",\n                \"capitalize_title\": \"标题大写显示\",\n                \"display_fields\": \"显示字段\",\n                \"escape_chars\": \"使用 \\\\ 作为转义特殊字符\",\n                \"filename\": \"文件名称\",\n                \"filename_pattern\": \"文件名规则\",\n                \"ignore_organized\": \"忽略已经整理的短片\",\n                \"ignored_words\": \"忽略字符\",\n                \"matches_with\": \"使用 {i} 进行匹配\",\n                \"select_parser_recipe\": \"选择需要分析的字段\",\n                \"title\": \"短片文件名规则\",\n                \"whitespace_chars\": \"空白字符\",\n                \"whitespace_chars_desc\": \"这些字符在标题中会替换为空白字符\"\n            },\n            \"scene_tools\": \"短片工具\",\n            \"heading\": \"工具\",\n            \"graphql_playground\": \"GraphQL试验场\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"缩减信息牌和详情页面上的统计数字，比方说“1831”会改成“1.8K”。\",\n                \"heading\": \"缩减统计数字\"\n            },\n            \"basic_settings\": \"基本设定\",\n            \"custom_css\": {\n                \"description\": \"必须重新加载页面才能使更改生效。不保证未来的Stash会和客制的CSS兼容。\",\n                \"heading\": \"自定义样式\",\n                \"option_label\": \"自定义样式已启用\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"必须重新加载页面才能生效。不保证未来的Stash和客制的JavaScript会兼容。\",\n                \"heading\": \"自定义 JavaScript\",\n                \"option_label\": \"自定义 JavaScript 已启用\"\n            },\n            \"custom_locales\": {\n                \"description\": \"强制使用个别特定用语。看 https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json 作为主要参考列表。页面必须更新以看到更改效果。\",\n                \"heading\": \"自定义本地设定\",\n                \"option_label\": \"使用自定义本地设定\"\n            },\n            \"delete_options\": {\n                \"description\": \"删除图片，图库和短片时的默认设置。\",\n                \"heading\": \"删除选项\",\n                \"options\": {\n                    \"delete_file\": \"默认删除文件\",\n                    \"delete_generated_supporting_files\": \"默认删除生成的附加文件\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"桌面一体化\",\n                \"notifications_enabled\": \"允许通知\",\n                \"send_desktop_notifications_for_events\": \"作为事件发送桌面通知。\",\n                \"skip_opening_browser\": \"跳过打开浏览器\",\n                \"skip_opening_browser_on_startup\": \"启动时不自动打开浏览器。\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"禁止下拉的选择器建立新的对象。\",\n                    \"heading\": \"禁止下拉菜单建立\"\n                },\n                \"heading\": \"编辑\",\n                \"max_options_shown\": {\n                    \"label\": \"在选择下拉列表中显示的最大项数\"\n                },\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"评分星的精度\",\n                        \"options\": {\n                            \"full\": \"完整\",\n                            \"half\": \"一半\",\n                            \"quarter\": \"四分之一\",\n                            \"tenth\": \"十分之一\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"评分系统类型\",\n                        \"options\": {\n                            \"decimal\": \"十进制\",\n                            \"stars\": \"星\"\n                        }\n                    }\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"交互式脚本播放的时间偏移量（以毫秒为单位）。\",\n                \"heading\": \"Funscript偏移量（毫秒）\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"连接\",\n                \"server_offset\": {\n                    \"heading\": \"服务器偏差值\"\n                },\n                \"status\": {\n                    \"heading\": \"Handy 连接状态\"\n                },\n                \"sync\": \"同步\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"用于互动场景的快速连接密钥。设定此密匙会允许Stash分享你当前的短片资料到handyfeeling.com。\",\n                \"heading\": \"Handy 连接密钥\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"图片灯箱\"\n            },\n            \"image_wall\": {\n                \"direction\": \"方向\",\n                \"heading\": \"图片墙\",\n                \"margin\": \"边距（像素）\"\n            },\n            \"images\": {\n                \"heading\": \"图片\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"即时生成时把图片缩略写入硬盘。\",\n                        \"heading\": \"写图片缩略\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"heading\": \"将视频扩展名的文件扫描为图像片段\",\n                        \"description\": \"当某个库中存在不可用视频，视频文件（可见视频扩展名）将被扫描为图像片段。\"\n                    }\n                }\n            },\n            \"interactive_options\": \"互动功能选项\",\n            \"language\": {\n                \"heading\": \"语言\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"播放器自动循环的最大短片长度。设置为0可以关闭此功能。\",\n                \"heading\": \"自动重播最大时长\"\n            },\n            \"menu_items\": {\n                \"description\": \"在导航栏上显示或隐藏不同类型的内容。\",\n                \"heading\": \"菜单项目\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"在播放量增加前短片必须要播放的百分比。\",\n                \"heading\": \"最少播放百分比\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"默认演员图像的自定义路径。 留空以使用内置默认值。\",\n                        \"heading\": \"自定义演员图像路径\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"默认选项是视频（mp4）预览。为了减少浏览时的CPU使用率，您可以使用动图（webp）预览。但是，它们必须在视频预览之外生成，并且是更大的文件。\",\n                \"heading\": \"预览类型\",\n                \"options\": {\n                    \"animated\": \"动图\",\n                    \"static\": \"静图\",\n                    \"video\": \"片段\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"网格视图\",\n                \"options\": {\n                    \"show_studio_as_text\": \"文本形式展示工作室名称覆层\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"短片播放器\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"视频一定从头开始播放\",\n                    \"auto_start_video\": \"自动播放\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"从视频序列，或从场景页面播放选择或随机视频时自动开始放视频。\",\n                        \"heading\": \"当播放选择视频时自动开始\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"当视频结束时播放下一短片。\",\n                        \"heading\": \"默认继续播放清单\"\n                    },\n                    \"show_scrubber\": \"显示预览轴\",\n                    \"track_activity\": \"启用短片播放历史记录\",\n                    \"vr_tag\": {\n                        \"description\": \"VR按钮仅在短片存在此标签时显示。\",\n                        \"heading\": \"VR标签\"\n                    },\n                    \"show_ab_loop_controls\": \"显示 AB 循环控制\",\n                    \"disable_mobile_media_auto_rotate\": \"在移动设备中针对全屏幕媒体禁用自动旋转\",\n                    \"enable_chromecast\": \"启用Chromecast\",\n                    \"show_range_markers\": \"展示范围标记\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"短片/标记 预览墙\",\n                \"options\": {\n                    \"display_title\": \"显示标题和标签\",\n                    \"toggle_sound\": \"播放声音\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"在转到下/上一项前需要尝试滑动的次数。仅适用于垂直滚动的模式。\",\n                \"heading\": \"转变所需的滑动尝试次数\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"当鼠标位于标签徽章上时显示标签牌。\",\n                \"heading\": \"标签牌的提示\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"在影音墙模式下，图库可用幻灯片功能。\",\n                \"heading\": \"幻灯片延迟（秒）\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"工作室显示\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"在工作室显示里，同时显示副工作室的内容。\",\n                        \"heading\": \"显示副工作室内容\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"标签显示\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"在标签页面中，同时显示子标签的内容。\",\n                        \"heading\": \"显示子标签内容\"\n                    }\n                }\n            },\n            \"title\": \"用户界面\",\n            \"detail\": {\n                \"heading\": \"详情页面\",\n                \"show_all_details\": {\n                    \"heading\": \"显示所有详情\",\n                    \"description\": \"启用后，默认情况下将显示所有详情，每个详情项目都将显示在单独一栏中。\"\n                },\n                \"compact_expanded_details\": {\n                    \"heading\": \"紧凑型扩展详情\",\n                    \"description\": \"启用后，该选项将显示扩展的详情，同时保持紧凑的显示效果。\"\n                },\n                \"enable_background_image\": {\n                    \"heading\": \"启用背景图像\",\n                    \"description\": \"在详情页面上显示背景图片。\"\n                }\n            },\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"启用后，funscript将直接从Stash提供到您的Handy设备，而无需使用第三方Handy服务器。要求可以从您的Handy设备访问Stash，并且如果stash配置了凭据，则会生成API密钥。\",\n                \"heading\": \"直接提供funscripts服务\"\n            },\n            \"performer_list\": {\n                \"heading\": \"表演者列表\",\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"在演员网格卡片上显示链接\"\n                    }\n                }\n            },\n            \"sfw_mode\": {\n                \"description\": \"如果使用 stash 存储SFW(工作场合安全)内容，请启用。它隐藏或更改了 UI 中与成人内容相关的某些方面。\",\n                \"heading\": \"SFW(工作场合安全)内容模式\"\n            },\n            \"custom_title\": {\n                \"description\": \"附加在页面标题后的自定义文本。如果是空的，默认是“Stash”。\",\n                \"heading\": \"用户标题\"\n            },\n            \"troubleshooting_mode\": {\n                \"button\": \"故障排查模式\",\n                \"dialog_title\": \"启用故障排查模式\",\n                \"dialog_description\": \"这将暂时禁用所有自定义功能，以帮助诊断问题：\",\n                \"dialog_item_plugins\": \"所有插件\",\n                \"dialog_item_css\": \"自定义CSS样式\",\n                \"dialog_item_js\": \"自定义JavaScript脚本\",\n                \"dialog_item_locales\": \"自定义本地化\",\n                \"dialog_log_level\": \"日志级别将设置为Debug（调试），以便进行详细诊断。\",\n                \"dialog_reload_note\": \"页面将自动重新加载。\",\n                \"enable\": \"启用并刷新\",\n                \"overlay_message\": \"故障排查模式已激活——所有自定义功能均被禁用\",\n                \"exit\": \"退出\"\n            }\n        },\n        \"advanced_mode\": \"高级模式\",\n        \"changelog\": {\n            \"header\": \"变更日志\"\n        }\n    },\n    \"configuration\": \"设置\",\n    \"countables\": {\n        \"files\": \"{count, plural, one {文件} other {文件} }\",\n        \"galleries\": \"{count, plural, one {图库} other {图库}}\",\n        \"images\": \"{count, plural, one {图片} other {图片}}\",\n        \"markers\": \"{count, plural, one {标记} other {标记}}\",\n        \"performers\": \"{count, plural, one {演员} other {演员}}\",\n        \"scenes\": \"{count, plural, one {短片} other {短片}}\",\n        \"studios\": \"{count, plural, one {工作室} other {工作室}}\",\n        \"tags\": \"{count, plural, one {标签} other {标签}}\",\n        \"groups\": \"{count, plural, one {集合} other {集合}}\"\n    },\n    \"country\": \"国家\",\n    \"cover_image\": \"封面图片\",\n    \"created_at\": \"创建于\",\n    \"criterion\": {\n        \"greater_than\": \"大于\",\n        \"less_than\": \"小于\",\n        \"value\": \"值\",\n        \"unsupported\": \"{type} (未受支持)\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"介于…之间\",\n        \"equals\": \"是\",\n        \"excludes\": \"不包含\",\n        \"format_string\": \"{criterion}{modifierString}{valueString}\",\n        \"greater_than\": \"大于\",\n        \"includes\": \"包含\",\n        \"includes_all\": \"包含所有\",\n        \"is_null\": \"为空\",\n        \"less_than\": \"小于\",\n        \"matches_regex\": \"符合正则表达式\",\n        \"not_between\": \"不在…之间\",\n        \"not_equals\": \"不是\",\n        \"not_matches_regex\": \"不符合正则表达式\",\n        \"not_null\": \"不为空\",\n        \"format_string_depth\": \"{criterion}{modifierString}{valueString}(+{depth, plural, =-1 {all} other {{depth}}})\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (不包括 {excludedString})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (包括 {excludedString}) (+{depth, plural, =-1 {全部} other {{depth}}})\"\n    },\n    \"custom\": \"自定义\",\n    \"date\": \"日期\",\n    \"date_format\": \"YYYY-MM-DD\",\n    \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n    \"death_date\": \"去世日期\",\n    \"death_year\": \"去世年份\",\n    \"descending\": \"降序\",\n    \"description\": \"描述\",\n    \"detail\": \"详情\",\n    \"details\": \"简介\",\n    \"developmentVersion\": \"开发版本\",\n    \"dialogs\": {\n        \"create_new_entity\": \"创建新{entity}\",\n        \"delete_alert\": \"以下{count, plural, one {{singularEntity}} other {{pluralEntity}}}将被永久删除：\",\n        \"delete_confirm\": \"确定要删除{entityName}吗?\",\n        \"delete_entity_desc\": \"{count, plural, one {确定要删除{singularEntity}吗？除非同时删除文件？否则下次扫描时{singularEntity}会重新被添加到数据库中。} other {确定要删除{pluralEntity}吗？除非同时删除文件？否则下次扫描时{pluralEntity}会重新被添加到数据库中。}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {你确定要删除这个{singularEntity}？} other {你确定要删除这些{pluralEntity}？}}\",\n        \"delete_entity_title\": \"{count, plural, one {删除{singularEntity}} other {删除{pluralEntity}}}\",\n        \"delete_galleries_extra\": \"...以及任何没有加入其它图库的图片。\",\n        \"delete_gallery_files\": \"删除图库的文件夹/压缩包和任何没有加入其它图库的图片.\",\n        \"delete_object_desc\": \"确定要删除{count, plural, one {这个{singularEntity}} other {这些{pluralEntity}}}?\",\n        \"delete_object_overflow\": \"…以及 {count} 个其他 {count, plural, one {{singularEntity}} other {{pluralEntity}}}.\",\n        \"delete_object_title\": \"删除 {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"下次更新前不再提示\",\n        \"edit_entity_title\": \"编辑 {count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"export_include_related_objects\": \"导出时包含相关的数据\",\n        \"export_title\": \"导出\",\n        \"imagewall\": {\n            \"direction\": {\n                \"column\": \"列\",\n                \"description\": \"基于列或行的布局。\",\n                \"row\": \"行\"\n            },\n            \"margin_desc\": \"每个完整图像周围的边距像素数。\"\n        },\n        \"lightbox\": {\n            \"delay\": \"延迟(秒)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"水平方向自适应尺寸\",\n                \"fit_to_screen\": \"屏幕自适应尺寸\",\n                \"label\": \"显示模式\",\n                \"original\": \"原图\"\n            },\n            \"options\": \"选项\",\n            \"page_header\": \"第 {page} 页，共 {total} 页\",\n            \"reset_zoom_on_nav\": \"图片改动时重设缩放度\",\n            \"scale_up\": {\n                \"description\": \"放大小图到整屏。\",\n                \"label\": \"放大图片以适应\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"按下shift键以暂时使用其它模式.\",\n                \"label\": \"卷屏模式\",\n                \"pan_y\": \"垂直卷动\",\n                \"zoom\": \"放大\"\n            },\n            \"disable_animation\": \"禁用图片之间的切换动画\"\n        },\n        \"merge\": {\n            \"destination\": \"目标\",\n            \"empty_results\": \"目标字段值将不变。\",\n            \"source\": \"源\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {Reassign {singularEntity}} 其它 {Reassign {pluralEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"重新指定至\"\n        },\n        \"scene_gen\": {\n            \"covers\": \"短片封面\",\n            \"force_transcodes\": \"强制生成转码文件\",\n            \"force_transcodes_tooltip\": \"默认情况下，转码文件只会在浏览器不支持的情况下生成。如果开启此选项，即使浏览器支持该视频，也会生成转码文件。\",\n            \"image_previews\": \"动图预览\",\n            \"image_previews_tooltip\": \"同时生成动图（webp）预览，仅当短片/标记墙预览类型设置为动图时才需要。浏览时，它们使用的CPU比视频预览少，但会额外生成更大的文件。\",\n            \"interactive_heatmap_speed\": \"产生热图和速度资料给有互动的短片\",\n            \"marker_image_previews\": \"标记动图预览\",\n            \"marker_image_previews_tooltip\": \"同时生成动图（webp）预览，仅当短片/标记墙预览类型设置为动图时才需要。浏览时，它们使用的CPU比视频预览少，但会额外生成更大的文件。\",\n            \"marker_screenshots\": \"标记的屏幕截图\",\n            \"marker_screenshots_tooltip\": \"标记使用静态JPG 图像\",\n            \"markers\": \"标记预览\",\n            \"markers_tooltip\": \"从给出的时间码开始20秒的视频.\",\n            \"override_preview_generation_options\": \"覆盖预览生成选项\",\n            \"override_preview_generation_options_desc\": \"覆盖此操作的“预览生成选项”。默认设置在“系统”->“生成预览”中。\",\n            \"overwrite\": \"覆盖现有文件\",\n            \"phash\": \"视频感知哈希值\",\n            \"preview_exclude_end_time_desc\": \"从短片预览中排除最后 x 秒。可以是一个以秒为单位的值，也可以是百分比（比如2%）。\",\n            \"preview_exclude_end_time_head\": \"排除结束时间\",\n            \"preview_exclude_start_time_desc\": \"从短片预览中排除最开始 x 秒。可以是一个以秒为单位的值，也可以是百分比（比如2%）。\",\n            \"preview_exclude_start_time_head\": \"排除开始时间\",\n            \"preview_generation_options\": \"预览生成的选项\",\n            \"preview_options\": \"预览选项\",\n            \"preview_preset_desc\": \"预设是用来调节预览生成的大小、质量和编码时间。 “slow”以下的选项不推荐使用。\",\n            \"preview_preset_head\": \"调整预设值\",\n            \"preview_seg_count_desc\": \"设置预览片段中的段数。\",\n            \"preview_seg_count_head\": \"预览片段段数\",\n            \"preview_seg_duration_desc\": \"每个预览片段的长度，以秒为单位。\",\n            \"preview_seg_duration_head\": \"预览片段长度\",\n            \"sprites\": \"短视频时间轴预览的小图\",\n            \"sprites_tooltip\": \"视频播放器下方用于快速导航的缩略图集。\",\n            \"transcodes\": \"转码\",\n            \"transcodes_tooltip\": \"将为所有内容提前生成MP4转码文件；对慢速CPU有用，但需要更多磁盘空间\",\n            \"video_previews\": \"预览\",\n            \"video_previews_tooltip\": \"鼠标悬停在短片上时播放的预览短片\",\n            \"phash_tooltip\": \"针对去重和短片识别\",\n            \"image_thumbnails\": \"图像缩略图\",\n            \"clip_previews\": \"图像片段预览\",\n            \"image_phash\": \"图像感知哈希值\",\n            \"image_phash_tooltip\": \"用于去重和识别\"\n        },\n        \"scenes_found\": \"找到 {count} 个短片\",\n        \"scrape_entity_query\": \"{entity_type} 挖掘 查询指令\",\n        \"scrape_entity_title\": \"{entity_type} 挖掘结果\",\n        \"scrape_results_existing\": \"现有信息\",\n        \"scrape_results_scraped\": \"挖取信息\",\n        \"set_image_url_title\": \"图片链接\",\n        \"unsaved_changes\": \"更改未保存，确定离开吗？\",\n        \"performers_found\": \"找到{count} 个演员\",\n        \"clear_o_history_confirm\": \"真的确定要清空高潮记录吗?\",\n        \"clear_play_history_confirm\": \"真的确定要清空播放历史?\",\n        \"set_default_filter_confirm\": \"你确定要设置这个过滤器为默认吗？\",\n        \"overwrite_filter_warning\": \"已保存的过滤器 \\\"{entityName}\\\" 将被覆盖。\",\n        \"clear_o_history_confirm_sfw\": \"你确定要清除点赞的历史?\",\n        \"delete_alert_to_trash\": \"如下的{count, plural, one {{singularEntity}} other {{pluralEntity}}} 将会被移动到回收站：\",\n        \"stashid_exists_warning\": \"此stash-box已存在的stash编号将会被取代。\",\n        \"studios_found\": \"找到{count} 个工作室\",\n        \"tags_found\": \"找到{count} 个标签\",\n        \"scrape_results_missing\": \"丢失\"\n    },\n    \"dimensions\": \"大小\",\n    \"director\": \"导演\",\n    \"disambiguation\": \"确定含义\",\n    \"display_mode\": {\n        \"grid\": \"格状显示\",\n        \"list\": \"列表显示\",\n        \"tagger\": \"标签工具\",\n        \"unknown\": \"未知\",\n        \"wall\": \"预览墙\",\n        \"label_current\": \"显示模式: {current}\"\n    },\n    \"donate\": \"赞助\",\n    \"dupe_check\": {\n        \"description\": \"低于“精确”的准确度需要更长的时间来计算，但使用较低的准确度可能会产生误报。\",\n        \"duration_diff\": \"最大持续时间差异\",\n        \"duration_options\": {\n            \"any\": \"任意\",\n            \"equal\": \"相等\"\n        },\n        \"found_sets\": \"{setCount, plural, one{# 个发现的重复数据。} other {# 个发现的重复数据。}}\",\n        \"options\": {\n            \"exact\": \"精确\",\n            \"high\": \"高\",\n            \"low\": \"低\",\n            \"medium\": \"中\"\n        },\n        \"search_accuracy_label\": \"搜索准确度\",\n        \"title\": \"重复短片\",\n        \"select_all_but_largest_file\": \"选择重复组中除最大文件外的每个文件\",\n        \"select_none\": \"清除选择\",\n        \"select_all_but_largest_resolution\": \"选择重复组中除最高分辨率文件外的每个文件\",\n        \"select_options\": \"选择选项…\",\n        \"select_oldest\": \"选择重复组中最旧文件\",\n        \"select_youngest\": \"选择重复组中最新文件\",\n        \"only_select_matching_codecs\": \"仅在重复组中所有编解码器匹配时选择\"\n    },\n    \"duplicated_phash\": \"重复的（感知码）\",\n    \"duration\": \"时长\",\n    \"effect_filters\": {\n        \"aspect\": \"比例\",\n        \"blue\": \"蓝色\",\n        \"blur\": \"模糊\",\n        \"brightness\": \"亮度\",\n        \"contrast\": \"对比\",\n        \"gamma\": \"伽马亮度\",\n        \"green\": \"绿色\",\n        \"hue\": \"色调\",\n        \"name\": \"滤镜\",\n        \"name_transforms\": \"视频变换\",\n        \"red\": \"红色\",\n        \"reset_filters\": \"重置滤镜\",\n        \"reset_transforms\": \"重置变换\",\n        \"rotate\": \"旋转\",\n        \"rotate_left_and_scale\": \"向左旋转 90 度加缩放\",\n        \"rotate_right_and_scale\": \"向右旋转 90 度加缩放\",\n        \"saturation\": \"饱和\",\n        \"scale\": \"大小\",\n        \"warmth\": \"色温\"\n    },\n    \"empty_server\": \"增加一些短片到服务器以看到本页面的推荐。\",\n    \"errors\": {\n        \"image_index_greater_than_zero\": \"图像索引必须大于 0\",\n        \"lazy_component_error_help\": \"如果您最近升级了 Stash，请重新加载页面或清除浏览器缓存。\",\n        \"something_went_wrong\": \"出了些问题。\",\n        \"header\": \"错误\",\n        \"loading_type\": \"加载 {type} 出错\",\n        \"invalid_javascript_string\": \"无效的javascript代码：{error}\",\n        \"invalid_json_string\": \"无效的JSON字符串：{error}\",\n        \"custom_fields\": {\n            \"field_name_required\": \"需要提供字段名称\",\n            \"field_name_whitespace\": \"字段名称不能有前导或尾随空格\",\n            \"duplicate_field\": \"字段名称必须唯一\",\n            \"field_name_length\": \"字段名称必须少于 65 个字符\"\n        }\n    },\n    \"ethnicity\": \"人种\",\n    \"existing_value\": \"现值\",\n    \"eye_color\": \"瞳孔颜色\",\n    \"fake_tits\": \"假奶\",\n    \"false\": \"否\",\n    \"favourite\": \"收藏\",\n    \"file\": \"文件\",\n    \"file_count\": \"文件数量\",\n    \"file_info\": \"文件信息\",\n    \"file_mod_time\": \"文件修改时间\",\n    \"files\": \"文件\",\n    \"files_amount\": \"{value} 个文件\",\n    \"filesize\": \"文件大小\",\n    \"filter\": \"过滤\",\n    \"filter_name\": \"过滤器名称\",\n    \"filters\": \"过滤器\",\n    \"folder\": \"文件夹\",\n    \"framerate\": \"帧率\",\n    \"frames_per_second\": \"{value} 帧每秒\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"预制的过滤器\",\n            \"saved_filter\": \"已保存的过滤器\"\n        }\n    },\n    \"galleries\": \"图库\",\n    \"gallery\": \"图库\",\n    \"gallery_count\": \"图库数量\",\n    \"gender\": \"性别\",\n    \"gender_types\": {\n        \"FEMALE\": \"女性\",\n        \"INTERSEX\": \"多性别\",\n        \"MALE\": \"男性\",\n        \"NON_BINARY\": \"非二元\",\n        \"TRANSGENDER_FEMALE\": \"跨性别女性\",\n        \"TRANSGENDER_MALE\": \"跨性别男性\"\n    },\n    \"hair_color\": \"头发颜色\",\n    \"handy_connection_status\": {\n        \"connecting\": \"连接中\",\n        \"disconnected\": \"连接已断开\",\n        \"error\": \"连接到 Handy 时出错\",\n        \"missing\": \"无\",\n        \"ready\": \"准备好\",\n        \"syncing\": \"正在和服务器同步\",\n        \"uploading\": \"上传脚本中\"\n    },\n    \"hasChapters\": \"章节\",\n    \"hasMarkers\": \"章节标记\",\n    \"height\": \"身高\",\n    \"height_cm\": \"高(cm)\",\n    \"help\": \"说明\",\n    \"ignore_auto_tag\": \"忽略自动标签\",\n    \"image\": \"图片\",\n    \"image_count\": \"图片数量\",\n    \"image_index\": \"图像 #\",\n    \"images\": \"图片\",\n    \"include_parent_tags\": \"包含上级标签\",\n    \"include_sub_studios\": \"包含子工作室\",\n    \"include_sub_tags\": \"包含子标签\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"互动\",\n    \"interactive_speed\": \"互动速度\",\n    \"isMissing\": \"缺失\",\n    \"last_played_at\": \"最后播放在\",\n    \"library\": \"收藏库\",\n    \"loading\": {\n        \"generic\": \"加载中…\",\n        \"plugins\": \"加载插件中……\"\n    },\n    \"marker_count\": \"标记数量\",\n    \"markers\": \"标记\",\n    \"measurements\": \"三围\",\n    \"media_info\": {\n        \"audio_codec\": \"声音编码\",\n        \"downloaded_from\": \"下载地址\",\n        \"interactive_speed\": \"互动速度\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"制作时 {age} {years_old}\"\n        },\n        \"phash\": \"感知码PHash\",\n        \"play_count\": \"播放量\",\n        \"play_duration\": \"播放长度\",\n        \"stream\": \"视频流地址\",\n        \"video_codec\": \"视频编码\",\n        \"o_count\": \"高潮次数\",\n        \"md5\": \"MD5 校验值\",\n        \"oshash\": \"oshash值\",\n        \"oshash_meaning\": \"OpenSubtitles 哈希值\",\n        \"phash_meaning\": \"感知哈希值\"\n    },\n    \"megabits_per_second\": \"{value} Mbps\",\n    \"metadata\": \"元数据\",\n    \"name\": \"名称\",\n    \"new\": \"新增\",\n    \"none\": \"空\",\n    \"operations\": \"操作\",\n    \"organized\": \"是否已经整理\",\n    \"pagination\": {\n        \"first\": \"首页\",\n        \"last\": \"尾页\",\n        \"next\": \"下一页\",\n        \"previous\": \"上一页\",\n        \"current_total\": \"第{current}页， 共 {total}页\"\n    },\n    \"parent_of\": \"{children}的上级\",\n    \"parent_studios\": \"上级工作室\",\n    \"parent_tag_count\": \"上级标签数量\",\n    \"parent_tags\": \"上级标签\",\n    \"part_of\": \"{parent} 的部分\",\n    \"path\": \"路径\",\n    \"perceptual_similarity\": \"感知相似度（pHash码）\",\n    \"performer\": \"演员\",\n    \"performer_age\": \"演员年龄\",\n    \"performer_count\": \"演员数量\",\n    \"performer_favorite\": \"演员已收藏\",\n    \"performer_image\": \"演员图片\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"添加演员\",\n        \"any_names_entered_will_be_queried\": \"任何输入的名字将会从远程的Stash-Box实例查询并且加入（如果找到）。只有完全符合的名字才会视为匹配。\",\n        \"batch_add_performers\": \"批量添加演员\",\n        \"batch_update_performers\": \"批量更新演员\",\n        \"current_page\": \"当前页面\",\n        \"failed_to_save_performer\": \"保存演员 \\\"{performer}\\\" 失败\",\n        \"name_already_exists\": \"演员名字已存在\",\n        \"network_error\": \"网路出错\",\n        \"no_results_found\": \"没有找到资料。\",\n        \"number_of_performers_will_be_processed\": \"有 {performer_count} 个演员将会被处理\",\n        \"performer_already_tagged\": \"演员已标签\",\n        \"performer_selection\": \"选择演员\",\n        \"performer_successfully_tagged\": \"演员成功标签：\",\n        \"query_all_performers_in_the_database\": \"查找所有在此数据库的演员\",\n        \"refresh_tagged_performers\": \"更新已标签的演员\",\n        \"refreshing_will_update_the_data\": \"重新载入会更新所有已从stash-box端点标签的演员的数据。\",\n        \"status_tagging_job_queued\": \"状态：标签工作入列\",\n        \"status_tagging_performers\": \"状态：标签演员当中\",\n        \"tag_status\": \"标签状态\",\n        \"to_use_the_performer_tagger\": \"使用演员标签的功能前，必须先设定好一个stash-box的端点。\",\n        \"untagged_performers\": \"未标签的演员\",\n        \"update_performer\": \"更新演员资料\",\n        \"update_performers\": \"更新演员们资料\",\n        \"updating_untagged_performers_description\": \"更新尚未标记的演员，会尝试搜寻没有stashid的演员并更新其元数据。\",\n        \"performer_names_or_stashids_separated_by_comma\": \"用逗号分隔的演员名称或Stash编号\"\n    },\n    \"performer_tags\": \"演员标签\",\n    \"performers\": \"演员\",\n    \"piercings\": \"穿洞\",\n    \"play_count\": \"播放量\",\n    \"play_duration\": \"播放长度\",\n    \"primary_file\": \"主要文件\",\n    \"queue\": \"序列\",\n    \"random\": \"随机\",\n    \"rating\": \"评分\",\n    \"recently_added_objects\": \"最近新增的 {objects}\",\n    \"recently_released_objects\": \"最近发行的 {objects}\",\n    \"release_notes\": \"更新历史\",\n    \"resolution\": \"分辨率\",\n    \"resume_time\": \"恢复时间\",\n    \"scene\": \"短片\",\n    \"sceneTagger\": \"短片标记器\",\n    \"scene_code\": \"工作室代码\",\n    \"scene_count\": \"短片数量\",\n    \"scene_created_at\": \"短片建立在\",\n    \"scene_date\": \"短片日期\",\n    \"scene_id\": \"短片ID\",\n    \"scene_tags\": \"短片标记\",\n    \"scene_updated_at\": \"短片修改在\",\n    \"scenes\": \"短片\",\n    \"scenes_updated_at\": \"短片更新时间\",\n    \"search_filter\": {\n        \"edit_filter\": \"编辑筛选器\",\n        \"name\": \"过滤\",\n        \"saved_filters\": \"保存过滤器\",\n        \"update_filter\": \"更新过滤器\",\n        \"more_filter_criteria\": \"+{count}个更多\",\n        \"search_term\": \"搜索词\"\n    },\n    \"second\": \"秒\",\n    \"seconds\": \"秒\",\n    \"settings\": \"设置\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"设置就快完成，请确认以下设定。你可以点“回去”去改变任何不正确的东西，如果一切看来都好，请按“确定”去建立你的系统。\",\n            \"blobs_directory\": \"二进制文件目录\",\n            \"cache_directory\": \"缓存目录\",\n            \"configuration_file_location\": \"配置文件的路径:\",\n            \"database_file_path\": \"数据库文件的路径\",\n            \"generated_directory\": \"生成资料的路径\",\n            \"nearly_there\": \"就快好了！\",\n            \"stash_library_directories\": \"Stash库的路径\",\n            \"blobs_use_database\": \"<数据库使用中>\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"建立你的系统当中\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"天啊！出错啦！\",\n            \"something_went_wrong_description\": \"如果看上去是你输入的问题，请点击“回去”以修正。又或者，在{githubLink}提出这个毛病，或者在{discordLink}寻求帮助。\",\n            \"something_went_wrong_while_setting_up_your_system\": \"在设立你的系统时出错。以下是错误信息：{error}\",\n            \"unable_to_retrieve_system_status\": \"无法检索系统状态： {error}\",\n            \"unexpected_error\": \"发生意外错误： {error}\"\n        },\n        \"folder\": {\n            \"file_path\": \"文件路径\",\n            \"up_dir\": \"上级目录\"\n        },\n        \"github_repository\": \"Github 代码仓库\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"数据库备份路径 (留空代表不备份):\",\n            \"backup_recommended\": \"在迁移前推荐你先备份现有数据库. 我们可以帮你做一备份到<code>{defaultBackupPath}</code>.\",\n            \"migrating_database\": \"数据库迁移当中\",\n            \"migration_failed\": \"迁移失败\",\n            \"migration_failed_error\": \"迁移数据库时遇到以下错误:\",\n            \"migration_failed_help\": \"请进行必要的改正，再尝试。要不然，在{githubLink}提出这毛病，或者在{discordLink}寻求帮助。\",\n            \"migration_irreversible_warning\": \"数据库结构迁移的过程是不可逆的。迁移完成后，你的数据库将会无法和更早版本的stash兼容。\",\n            \"migration_notes\": \"迁移历史\",\n            \"migration_required\": \"需要进行数据库迁移\",\n            \"perform_schema_migration\": \"进行数据库结构迁移\",\n            \"schema_too_old\": \"你当前的stash数据库结构是版本<strong>{databaseSchema}</strong>，需要迁移到版本<strong>{appSchema}</strong>. 这版本的Stash无法在没有迁移的数据库上工作。如果你不希望进行数据迁移，那你必须使用旧版本以适应你的数据库结构。\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"数据库文件名 (留空则用默认名)\",\n            \"description\": \"接下来，我们需要决定哪里找到你的内容，哪里存放 stash 数据库和产生资料文件。如果需要，这些设定可在以后再修改。\",\n            \"path_to_cache_directory_empty_for_default\": \"缓存目录的路径（默认为空）\",\n            \"path_to_generated_directory_empty_for_default\": \"生成资料的文件夹路径 (留空则使用默认路径)\",\n            \"set_up_your_paths\": \"设立你的路径\",\n            \"stash_alert\": \"没有选择任何影像库的路径。Stash将不会扫描到任何媒体文件。你确认吗？\",\n            \"where_can_stash_store_blobs\": \"Stash 在哪里可以存储数据库二进制数据？\",\n            \"where_can_stash_store_blobs_description\": \"Stash可以将场景封面、表演者、工作室和标记图像等二进制数据存储在数据库或文件系统中。默认情况下，它会将这些数据存储在包含您的配置文件的目录内的子目录<code>blob</code>中的文件系统中。如果您想更改此设置，请输入绝对或相对（到当前工作目录）路径。如果Stash不存在，它将创建此目录。\",\n            \"where_can_stash_store_blobs_description_addendum\": \"或者，如果要将此数据存储在数据库中，可以将此字段留空<strong>注意：</strong>这将增加数据库文件的大小，并增加数据库迁移时间。\",\n            \"where_can_stash_store_cache_files\": \"Stash可以在哪里存储缓存文件？\",\n            \"where_can_stash_store_cache_files_description\": \"为了使HLS/DASH实时转码等功能正常运行，Stash需要一个临时文件的缓存目录。默认情况下，Stash将在包含您的配置文件的目录中创建一个<code>cache</code>目录。如果您想更改此设置，请输入绝对或相对（与当前工作目录）路径。如果Stash不存在，它将创建此目录。\",\n            \"where_can_stash_store_its_database\": \"在哪里可以储存Stash的数据库？\",\n            \"where_can_stash_store_its_database_description\": \"Stash 使用 sqlite 数据库来存放你的内容的元数据。默认情况下，会建立<code>stash-go.sqlite</code>在包含有你配置文件的目录里。如果你想改动，请输入一个绝对，或者相对（对于当前目录）的文件名。\",\n            \"where_can_stash_store_its_database_warning\": \"警告：<strong>不支持</strong>将数据库存储在运行 Stash 的不同系统上（例如，在另一台计算机上运行 Stash 服务器时将数据库存储到 NAS 上）！SQLite 不适合在网络上使用，尝试这样做很容易导致整个数据库损坏。\",\n            \"where_can_stash_store_its_generated_content\": \"哪里可以存放Stash产生的资料？\",\n            \"where_can_stash_store_its_generated_content_description\": \"为了可以提供缩图，预览和浏览图，Stash生成图片和视频。同时也包括将不支持的文件转码后的视频。默认情况下，Stash会建立一个<code>generated</code>文件夹在含有你配置文件的目录中。如果你要修改生成媒体的地方，请输入一个绝对，或者相对(对于当前工作目录)的路径。如果此目录不存在，Stash会自动建立它。\",\n            \"where_is_your_porn_located\": \"你的内容存放在哪里？\",\n            \"where_is_your_porn_located_description\": \"添加含有你的视频和图片的目录。Stash会在扫描时使用这些目录去寻找视频和图片。\",\n            \"path_to_blobs_directory_empty_for_default\": \"blobs目录的路径（默认为空）\",\n            \"store_blobs_in_database\": \"将 blobs存储到数据库\",\n            \"sfw_content_settings\": \"为了SFW(工作场合安全)内容使用stash?\",\n            \"sfw_content_settings_description\": \"stash能被用来管理SFW(工作场合安全)内容，例如摄影、艺术、漫画等。启用此选项将调整部分界面行为，使其更适合SFW内容。\",\n            \"use_sfw_content_mode\": \"使用SFW(工作场合安全)模式\"\n        },\n        \"stash_setup_wizard\": \"Stash 设定向导\",\n        \"success\": {\n            \"getting_help\": \"获取帮助\",\n            \"help_links\": \"如果你遇到麻烦或者有任何的问题或者建议，请在{githubLink}新开一个“问题”，或者寻求社群{discordLink}的帮助。\",\n            \"in_app_manual_explained\": \"鼓励你看看内置的手册，你可以通过屏幕右上方的图标:{icon}来使用它\",\n            \"next_config_step_one\": \"接下来你会被带到配置页面。此页面会让你设定哪些文件被包括或者不包括，设定用户名和密码来保护你的系统，以及其它的一些选项。\",\n            \"next_config_step_two\": \"当你对这些选项都满意后，你可以开始按下<code>{localized_task}</code>，然后按下<code>{localized_scan}</code>来开始扫描你的收藏.\",\n            \"open_collective\": \"看看我们的{open_collective_link}来了解一下你可以如何对Stash的继续发展作出贡献。\",\n            \"support_us\": \"支持我们\",\n            \"thanks_for_trying_stash\": \"感激你尝试Stash!\",\n            \"welcome_contrib\": \"我们也欢迎以代码的方式来做出贡献(修正毛病，改善和新功能)，测试，毛病报告，改善和请求功能，以及用户支持。详情请看内置手册的“贡献”区。\",\n            \"your_system_has_been_created\": \"成功！你的系统建立了！\",\n            \"download_ffmpeg\": \"下载ffmpeg\",\n            \"missing_ffmpeg\": \"您缺少所需的<code>ffmpeg</code>二进制文件。您可以通过选中下面的框自动将其下载到您的配置目录中。或者，您可以在系统设置中提供<code>ffmpeg</code>和<code>ffprobe</code>二进制文件的路径。 这些二进制文件必须存在，Stash才能起作用。\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash首先尝试从当前工作目录中找配置文件 (<code>config.yml</code>)，如果没找到，会退回到 <code>{fallback_path}</code。 你也可以让Stash读特定的配置文件，只要运行它时加上<code>-c '<配置文件目录>'</code>或者<code>--config '<配置文件目录>'</code>的选项。\",\n            \"in_current_stash_directory\": \"在 <code>{path}</code>目录里：\",\n            \"in_the_current_working_directory\": \"在当前工作目录 <code>{path}</code>里：\",\n            \"next_step\": \"之前的问题都解决了，如果你准备好建立一个新的系统，请选择你想在哪里存放你的配置文件。\",\n            \"store_stash_config\": \"你想在哪里存放你的Stash配置？\",\n            \"unable_to_locate_config\": \"如果你看到这，就意味着Stash无法找到一个现有的配置。这个向导会指引你建立一个新的配置。\",\n            \"unexpected_explained\": \"如果你意外地看到这个屏幕显示，请在正确的工作目录下重新启动Stash，或者使用<code>-c</code>参数。\",\n            \"in_the_current_working_directory_disabled\": \"在<code>{path}</code>中, 工作目录:\",\n            \"in_the_current_working_directory_disabled_macos\": \"当正在运行 <code>Stash.app</code>时不支持,<br></br>在工作目录中运行 <code>stash-macos</code>来设置\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash会使用以下配置文件的路径:<code>{path}</code>\",\n            \"next_step\": \"当你准备好建立一个新系统时，点击“下一个”。\",\n            \"unable_to_locate_specified_config\": \"如果你看到这，就意味着Stash无法用命令行或者环境变量找到配置文件。这个向导将指引你去建立一个新的配置。\"\n        },\n        \"welcome_to_stash\": \"欢迎使用Stash\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_id_endpoint\": \"Stash ID 端点\",\n    \"stash_ids\": \"Stash IDs\",\n    \"stashbox\": {\n        \"go_review_draft\": \"到 {endpoint_name} 预览草稿。\",\n        \"selected_stash_box\": \"已选择的 Stash-Box 端点\",\n        \"submission_failed\": \"提交失败\",\n        \"submission_successful\": \"提交成功\",\n        \"submit_update\": \"已存在于 {endpoint_name}\",\n        \"source\": \"Stash-Box 源\"\n    },\n    \"statistics\": \"统计\",\n    \"stats\": {\n        \"image_size\": \"图片大小\",\n        \"scenes_duration\": \"短片长度\",\n        \"scenes_size\": \"短片大小\",\n        \"total_play_count\": \"总播放次数\",\n        \"total_play_duration\": \"总播放时长\",\n        \"scenes_played\": \"已播放的短片\",\n        \"total_o_count\": \"高潮总计\",\n        \"total_o_count_sfw\": \"总点赞数\"\n    },\n    \"status\": \"状态：{statusText}\",\n    \"studio\": \"工作室\",\n    \"studio_depth\": \"深度 (为空时显示全部)\",\n    \"studios\": \"工作室\",\n    \"sub_tag_count\": \"子标签数量\",\n    \"sub_tag_of\": \"{parent}的子标签\",\n    \"sub_tags\": \"子标签\",\n    \"subsidiary_studios\": \"子工作室\",\n    \"synopsis\": \"概要\",\n    \"tag\": \"标签\",\n    \"tag_count\": \"标签数量\",\n    \"tags\": \"标签\",\n    \"tattoos\": \"纹身\",\n    \"title\": \"标题\",\n    \"toast\": {\n        \"added_entity\": \"已添加{count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"added_generation_job_to_queue\": \"已添加生成工作至队列\",\n        \"created_entity\": \"已创建{entity}\",\n        \"default_filter_set\": \"默认过滤器\",\n        \"delete_past_tense\": \"已删除{count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"generating_screenshot\": \"生成截图中…\",\n        \"image_index_too_large\": \"错误：图像索引大于库中的图像数\",\n        \"merged_scenes\": \"合并的短片\",\n        \"merged_tags\": \"已合并的标签\",\n        \"reassign_past_tense\": \"已重新指定文件\",\n        \"removed_entity\": \"已删除{count, plural, one {{singularEntity}} other {{pluralEntity}}}\",\n        \"rescanning_entity\": \"正在重新扫描{singularEntity}…\",\n        \"saved_entity\": \"已保存{entity}\",\n        \"started_auto_tagging\": \"自动生成标签中\",\n        \"started_generating\": \"生成资料中\",\n        \"started_importing\": \"导入中\",\n        \"updated_entity\": \"已更新{entity}\",\n        \"merged_performers\": \"合并的表演者\",\n        \"clipboard_access_denied\": \"剪贴板访问受阻。检查你浏览器的权限\",\n        \"clipboard_image_pasted\": \"粘贴剪贴板图像\",\n        \"clipboard_no_image\": \"没有图像在剪贴板中找到\"\n    },\n    \"total\": \"总计\",\n    \"true\": \"是\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"种类\",\n    \"updated_at\": \"更新于\",\n    \"url\": \"链接\",\n    \"validation\": {\n        \"date_invalid_form\": \"${path} 的格式必须为四位年份(YYYY)、四位年份-两位月份(YYYY-MM)或四位年份-两位月份-两位日期(YYYY-MM-DD)\",\n        \"required\": \"${path} 是必填字段\",\n        \"blank\": \"${path}不能为空\",\n        \"unique\": \"${path}不能相同\",\n        \"end_time_before_start_time\": \"结束时间必须晚于或等于开始时间\"\n    },\n    \"videos\": \"视频\",\n    \"view_all\": \"查看全部\",\n    \"weight\": \"体重\",\n    \"weight_kg\": \"体重 (kg)\",\n    \"years_old\": \"岁\",\n    \"zip_file_count\": \"压缩文件数量\",\n    \"studio_tagger\": {\n        \"update_studios\": \"更新工作室\",\n        \"add_new_studios\": \"添加工作室\",\n        \"updating_untagged_studios_description\": \"更新未标记的工作室将尝试匹配缺少Stash ID的工作室，并更新元数据。\",\n        \"config\": {\n            \"create_parent_desc\": \"对于已存在且名称完全匹配的父（母）工作室创建缺失的父（母）工作室，或者标记并更新数据/图片\",\n            \"create_parent_label\": \"创建上级工作室\"\n        },\n        \"batch_add_studios\": \"批量添加工作室\",\n        \"batch_update_studios\": \"批量更新工作室\",\n        \"update_studio\": \"更新工作室\",\n        \"current_page\": \"当前页\",\n        \"no_results_found\": \"未查询到结果。\",\n        \"network_error\": \"网络错误\",\n        \"status_tagging_job_queued\": \"状态：标签设置任务排队中\",\n        \"tag_status\": \"标签状态\",\n        \"studio_successfully_tagged\": \"已成功标记工作室\",\n        \"to_use_the_studio_tagger\": \"使用工作室标记器需要配置一个stash-box实例。\",\n        \"untagged_studios\": \"未设置标签的工作室\",\n        \"number_of_studios_will_be_processed\": \"{studio_count} 个工作室将被修改\",\n        \"query_all_studios_in_the_database\": \"此数据库中所有工作室\",\n        \"status_tagging_studios\": \"状态：正在给工作室设置标签\",\n        \"refresh_tagged_studios\": \"刷新已标记的工作室\",\n        \"studio_already_tagged\": \"已标记工作室\",\n        \"name_already_exists\": \"名称已存在\",\n        \"failed_to_save_studio\": \"保存工作室\\\"{studio}\\\"失败\",\n        \"refreshing_will_update_the_data\": \"刷新后将更新所有已在此stash-box实例被标记的工作室。\",\n        \"create_or_tag_parent_studios\": \"创建缺失的上级工作室或标记已存在的上级工作室\",\n        \"any_names_entered_will_be_queried\": \"任何输入的名字将会从远程的Stash-Box实例查询并且加入（如果找到）。只有完全符合的名字才会视为匹配。\",\n        \"studio_selection\": \"工作室选择\",\n        \"studio_names_or_stashids_separated_by_comma\": \"逗号分隔的工作室名称或Stash编号\"\n    },\n    \"subsidiary_studio_count\": \"子工作室数量\",\n    \"urls\": \"链接\",\n    \"video_codec\": \"视频编码\",\n    \"audio_codec\": \"音频编码\",\n    \"package_manager\": {\n        \"installed_version\": \"已安装版本\",\n        \"uninstall\": \"卸载\",\n        \"update\": \"更新\",\n        \"install\": \"安装\",\n        \"latest_version\": \"最新版本\",\n        \"source\": {\n            \"local_path\": {\n                \"description\": \"相对路径，用于存储来源于此处的包文件。请注意，改变此设置需要手动移动包文件。\",\n                \"heading\": \"本地路径\"\n            },\n            \"url\": \"来源 URL\",\n            \"name\": \"名称\"\n        },\n        \"version\": \"版本\",\n        \"check_for_updates\": \"检查更新\",\n        \"add_source\": \"添加源\",\n        \"required_by\": \"{packages} 所需要\",\n        \"description\": \"描述\",\n        \"confirm_uninstall\": \"确定删除 {number} 个包？\",\n        \"package\": \"包文件\",\n        \"no_sources\": \"未配置源\",\n        \"no_packages\": \"未找到包文件\",\n        \"hide_unselected\": \"隐藏未选\",\n        \"edit_source\": \"编辑源\",\n        \"show_all\": \"显示全部\",\n        \"selected_only\": \"仅已选择\",\n        \"no_upgradable\": \"未找到可升级的包\",\n        \"unknown\": \"<未知>\",\n        \"confirm_delete_source\": \"真的确定要删除源{name} ({url})?\"\n    },\n    \"distance\": \"距离\",\n    \"photographer\": \"摄影师\",\n    \"primary_tag\": \"主要标签\",\n    \"tag_sub_tag_tooltip\": \"具有子标签\",\n    \"tag_parent_tooltip\": \"具有上级标签\",\n    \"parent_studio\": \"上级工作室\",\n    \"penis_length_cm\": \"阴茎长度（厘米）\",\n    \"penis_length\": \"阴茎长度\",\n    \"studio_and_parent\": \"工作室与其上级\",\n    \"penis\": \"阴茎\",\n    \"orientation\": \"方向\",\n    \"plays\": \"已播放{value}次\",\n    \"unknown_date\": \"未知日期\",\n    \"playdate_recorded_no\": \"没有播放日期记录\",\n    \"play_history\": \"播放历史\",\n    \"odate_recorded_no\": \"无已记录的高潮日期\",\n    \"o_history\": \"高潮记录\",\n    \"history\": \"历史记录\",\n    \"index_of_total\": \"第{index}项，共 {total}项\",\n    \"time\": \"时间\",\n    \"last_o_at\": \"最近一次高潮在\",\n    \"connection_monitor\": {\n        \"websocket_connection_reestablished\": \"Websocket连接已重新建立\",\n        \"websocket_connection_failed\": \"无法建立websocket连接：有关详细信息，请参阅浏览器控制台\"\n    },\n    \"o_count\": \"高潮次数\",\n    \"studio_tags\": \"工作室标签\",\n    \"studio_count\": \"工作室计数\",\n    \"group\": \"集合\",\n    \"group_count\": \"集合总计\",\n    \"group_scene_number\": \"短片序号\",\n    \"groups\": \"集合\",\n    \"include_sub_studio_content\": \"包括子工作室内容\",\n    \"include_sub_tag_content\": \"包括子标签内容\",\n    \"include_sub_group_content\": \"包括子集合内容\",\n    \"containing_group\": \"包含的集合\",\n    \"containing_group_count\": \"包含的集合计数\",\n    \"containing_groups\": \"被包含于集合\",\n    \"sub_group\": \"子集合\",\n    \"sub_group_count\": \"子集合计数\",\n    \"sub_group_of\": \"{parent}的子集合\",\n    \"sub_group_order\": \"子集合排序\",\n    \"sub_groups\": \"子集合\",\n    \"include_sub_groups\": \"包括子组\",\n    \"time_end\": \"结束时间\",\n    \"criterion_modifier_values\": {\n        \"any\": \"任意\",\n        \"any_of\": \"其中任意\",\n        \"none\": \"无\",\n        \"only\": \"仅\"\n    },\n    \"custom_fields\": {\n        \"field\": \"字段\",\n        \"title\": \"自定义字段\",\n        \"value\": \"数值\",\n        \"criteria_format_string_others\": \"{criterion} (用户字段) {modifierString} {valueString} (+{others} others)\",\n        \"criteria_format_string\": \"{criterion} (用户字段) {modifierString} {valueString}\"\n    },\n    \"eta\": \"预估剩余时间\",\n    \"sort_name\": \"排序用名\",\n    \"age_on_date\": \"在制作时{age}岁\",\n    \"login\": {\n        \"username\": \"用户名\",\n        \"password\": \"密码\",\n        \"invalid_credentials\": \"无效的用户名或密码\",\n        \"internal_error\": \"意外的内部错误。有关更多详细信息，请查看日志\",\n        \"login\": \"登录\"\n    },\n    \"scenes_duration\": \"场景持续时间\",\n    \"last_o_at_sfw\": \"最近一次点赞在\",\n    \"o_count_sfw\": \"点赞\",\n    \"o_history_sfw\": \"点赞历史\",\n    \"odate_recorded_no_sfw\": \"没有已记录的点赞日期\",\n    \"stashbox_search\": {\n        \"header\": \"在StashBox 搜索{entityType}\",\n        \"no_results\": \"没有结果被找到。\",\n        \"placeholder_name_or_id\": \"{entityType} 名称或Stash编号……\",\n        \"select_stashbox\": \"选择StashBox……\"\n    },\n    \"latest_scene\": \"最近的场景\",\n    \"stash_id_count\": \"Stash ID 统计\",\n    \"duplicated\": \"重复\",\n    \"duplicated_stash_id\": \"重复（Stash ID）\",\n    \"duplicated_title\": \"重复（标题）\",\n    \"career_end\": \"引退时间\",\n    \"career_start\": \"入行时间\",\n    \"tag_tagger\": {\n        \"add_new_tags\": \"增加新标签\",\n        \"any_names_entered_will_be_queried\": \"输入的任何名字都会从远程 Stash-Box 实例查询，并在找到后添加。只有完全匹配才算匹配。\",\n        \"batch_add_tags\": \"批量添加标签\",\n        \"batch_update_tags\": \"批量更新标签\",\n        \"current_page\": \"当前页面\",\n        \"failed_to_save_tag\": \"保存标签 \\\"{tag}\\\" 失败\",\n        \"name_already_exists\": \"名字已存在\",\n        \"network_error\": \"网络错误\",\n        \"no_results_found\": \"没有结果被找到。\",\n        \"number_of_tags_will_be_processed\": \"{tag_count} 个标签将被处理\",\n        \"query_all_tags_in_the_database\": \"数据库中的所有标签\",\n        \"refresh_tagged_tags\": \"刷新已标记的标签\",\n        \"refreshing_will_update_the_data\": \"刷新操作将会更新来自此 stash-box 实例中任何已被标记的标签。\",\n        \"status_tagging_job_queued\": \"状态：标记作业已入队列\",\n        \"status_tagging_tags\": \"状态：标记标签中\",\n        \"tag_already_tagged\": \"标签已被标记\",\n        \"tag_names_or_stashids_separated_by_comma\": \"英文逗号分隔的标签名称或StashID(可以是多个)\",\n        \"tag_selection\": \"标签选择\",\n        \"tag_successfully_tagged\": \"标签已被成功标记\",\n        \"tag_status\": \"标签状态\",\n        \"to_use_the_tag_tagger\": \"使用标记标签的功能，需要配置一个stash-box实例。\",\n        \"untagged_tags\": \"未标记的标签\",\n        \"update_tags\": \"更新标签\",\n        \"updating_untagged_tags_description\": \"更新未标记的标签时，会尝试匹配那些没有 stashid 的标签并更新元数据。\"\n    },\n    \"tagger\": {\n        \"config\": {\n            \"active_stash-box_instance\": \"使用中的 stash-box 实例：\",\n            \"edit_excluded_fields\": \"编辑被排除的字段\",\n            \"excluded_fields\": \"被排除的字段：\",\n            \"fields_will_not_be_changed\": \"更新 {entity} 时这些字段将不变。\",\n            \"no_fields_are_excluded\": \"没有字段被排除\",\n            \"no_instances_found\": \"没有找到实例\"\n        }\n    },\n    \"unsupported_criteria\": \"未支持标准：{criteria}\",\n    \"include_sub_folders\": \"包含自文件夹\",\n    \"parent_folder\": \"父母文件夹\",\n    \"sub_folder_depth\": \"子文件夹深度(留空为全部)\",\n    \"sub_folders\": \"子文件夹\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/locales/zh-TW.json",
    "content": "{\n    \"actions\": {\n        \"add\": \"新增\",\n        \"add_directory\": \"新增路徑\",\n        \"add_entity\": \"新增{entityType}\",\n        \"add_to_entity\": \"新增至{entityType}\",\n        \"allow\": \"允許\",\n        \"allow_temporarily\": \"暫時允許\",\n        \"anonymise\": \"匿名處理\",\n        \"apply\": \"套用\",\n        \"auto_tag\": \"自動套用標籤\",\n        \"backup\": \"備份\",\n        \"browse_for_image\": \"選擇圖像…\",\n        \"cancel\": \"取消\",\n        \"clean\": \"清理\",\n        \"clear\": \"清除\",\n        \"clear_back_image\": \"清除背面圖像\",\n        \"clear_front_image\": \"清除正面圖像\",\n        \"clear_image\": \"清除現有圖像\",\n        \"close\": \"關閉\",\n        \"confirm\": \"確認\",\n        \"continue\": \"繼續\",\n        \"create\": \"建立\",\n        \"create_chapters\": \"建立章節\",\n        \"create_entity\": \"建立{entityType}\",\n        \"create_marker\": \"建立標記\",\n        \"created_entity\": \"已建立{entity_type}：{entity_name}\",\n        \"customise\": \"自訂\",\n        \"delete\": \"刪除\",\n        \"delete_entity\": \"刪除{entityType}\",\n        \"delete_file\": \"刪除檔案\",\n        \"delete_file_and_funscript\": \"刪除檔案 (及其 Funscript)\",\n        \"delete_generated_supporting_files\": \"刪除已生成的資訊檔案\",\n        \"disallow\": \"不允許\",\n        \"download\": \"下載\",\n        \"download_anonymised\": \"下載匿名化版本資料庫\",\n        \"download_backup\": \"下載備份\",\n        \"edit\": \"編輯\",\n        \"edit_entity\": \"編輯{entityType}\",\n        \"export\": \"匯出\",\n        \"export_all\": \"匯出所有…\",\n        \"find\": \"搜尋\",\n        \"finish\": \"完成\",\n        \"from_file\": \"從檔案設定…\",\n        \"from_url\": \"從連結設定…\",\n        \"full_export\": \"全部匯出\",\n        \"full_import\": \"全部匯入\",\n        \"generate\": \"產生\",\n        \"generate_thumb_default\": \"產生預設預覽圖\",\n        \"generate_thumb_from_current\": \"從現在的畫面產生預覽圖\",\n        \"hash_migration\": \"雜湊值遷移\",\n        \"hide\": \"隱藏\",\n        \"hide_configuration\": \"隱藏設定\",\n        \"identify\": \"辨認\",\n        \"ignore\": \"忽略\",\n        \"import\": \"匯入…\",\n        \"import_from_file\": \"自檔案匯入\",\n        \"logout\": \"登出\",\n        \"make_primary\": \"設定為主檔案\",\n        \"merge\": \"合併\",\n        \"migrate_blobs\": \"遷移物件檔案\",\n        \"migrate_scene_screenshots\": \"遷移短片截圖\",\n        \"next_action\": \"下一步\",\n        \"not_running\": \"尚未執行\",\n        \"open_in_external_player\": \"透過外部播放器開啟\",\n        \"open_random\": \"隨機開啟\",\n        \"overwrite\": \"覆寫\",\n        \"play_random\": \"隨機播放\",\n        \"play_selected\": \"播放所選\",\n        \"preview\": \"預覽\",\n        \"previous_action\": \"上一步\",\n        \"reassign\": \"重新指定\",\n        \"refresh\": \"重新整理\",\n        \"reload_plugins\": \"重新整理外掛程式\",\n        \"reload_scrapers\": \"重新整理爬蟲\",\n        \"remove\": \"移除\",\n        \"remove_from_gallery\": \"自圖庫中移除\",\n        \"rename_gen_files\": \"重新命名已產生的檔案\",\n        \"rescan\": \"重新掃描\",\n        \"reshuffle\": \"重新隨機排列\",\n        \"running\": \"執行中\",\n        \"save\": \"儲存\",\n        \"save_delete_settings\": \"當刪除項目時，使用下列設定\",\n        \"save_filter\": \"儲存過濾條件\",\n        \"scan\": \"掃描\",\n        \"scrape\": \"爬取\",\n        \"scrape_query\": \"爬蟲搜尋關鍵字\",\n        \"scrape_scene_fragment\": \"部分爬取\",\n        \"scrape_with\": \"透過爬蟲取得資訊…\",\n        \"search\": \"搜尋\",\n        \"select_all\": \"全選\",\n        \"select_entity\": \"選擇{entityType}\",\n        \"select_folders\": \"選擇資料夾\",\n        \"select_none\": \"清除選擇\",\n        \"selective_auto_tag\": \"選擇性套用標籤\",\n        \"selective_clean\": \"選擇性清理\",\n        \"selective_scan\": \"選擇性掃描\",\n        \"set_as_default\": \"設為預設\",\n        \"set_back_image\": \"設定背面圖…\",\n        \"set_front_image\": \"設定正面圖…\",\n        \"set_image\": \"設定圖像…\",\n        \"show\": \"顯示\",\n        \"show_configuration\": \"顯示設定\",\n        \"skip\": \"跳過\",\n        \"split\": \"分開\",\n        \"stop\": \"停止\",\n        \"submit\": \"提交\",\n        \"submit_stash_box\": \"提交至 Stash-Box\",\n        \"submit_update\": \"提交更新\",\n        \"swap\": \"替換\",\n        \"tasks\": {\n            \"clean_confirm_message\": \"您確定要進行清理嗎？這將從資料庫及產生的文件中清除已不在的短片及圖庫。\",\n            \"dry_mode_selected\": \"已選擇了模擬作業模式。不會進行任何實際刪除作業，只會進行模擬記錄。\",\n            \"import_warning\": \"您確定要匯入此檔案嗎？這將刪除現有資料庫，並將已新匯入的內容重建資料。\"\n        },\n        \"temp_disable\": \"暫時關閉…\",\n        \"temp_enable\": \"暫時啟用…\",\n        \"unset\": \"重設\",\n        \"use_default\": \"使用預設選項\",\n        \"view_random\": \"隨機開啟\",\n        \"encoding_image\": \"圖片編碼中…\",\n        \"add_manual_date\": \"手動新增日期\",\n        \"add_o\": \"新增尻尻紀錄\",\n        \"add_play\": \"新增播放紀錄\",\n        \"reload\": \"重新整理\",\n        \"clear_date_data\": \"清除播放日期紀錄\",\n        \"copy_to_clipboard\": \"複製到剪貼簿\",\n        \"disable\": \"關閉\",\n        \"enable\": \"開啟\",\n        \"remove_date\": \"移除播放日期\",\n        \"assign_stashid_to_parent_studio\": \"將 Stash ID 指派到工作室並更新 Metadata\",\n        \"create_parent_studio\": \"新增母工作室\",\n        \"optimise_database\": \"最佳化資料庫\",\n        \"choose_date\": \"選擇日期\",\n        \"clean_generated\": \"清除已生成的文件\",\n        \"view_history\": \"查看歷史\",\n        \"reset_cover\": \"恢復預設封面\",\n        \"reset_play_duration\": \"重置播放時長\",\n        \"reset_resume_time\": \"重置恢復時間\",\n        \"set_cover\": \"設為封面\",\n        \"remove_from_containing_group\": \"從群組中刪除\",\n        \"add_sub_groups\": \"新增子分類\",\n        \"sidebar\": {\n            \"close\": \"關閉側邊攔\",\n            \"open\": \"開啟側邊攔\",\n            \"toggle\": \"切換側邊欄\"\n        },\n        \"show_results\": \"顯示結果\",\n        \"show_count_results\": \"顯示 {count} 筆結果\",\n        \"play\": \"播放\",\n        \"load\": \"載入\",\n        \"load_filter\": \"載入篩選結果\"\n    },\n    \"actions_name\": \"動作\",\n    \"age\": \"年齡\",\n    \"aliases\": \"別名\",\n    \"all\": \"所有\",\n    \"also_known_as\": \"又稱為\",\n    \"ascending\": \"升序\",\n    \"average_resolution\": \"平均解析度\",\n    \"between_and\": \"及\",\n    \"birth_year\": \"出生年分\",\n    \"birthdate\": \"出生日期\",\n    \"bitrate\": \"位元率\",\n    \"blobs_storage_type\": {\n        \"database\": \"資料庫\",\n        \"filesystem\": \"檔案系統\"\n    },\n    \"captions\": \"字幕\",\n    \"career_length\": \"活躍年代\",\n    \"chapters\": \"章節\",\n    \"component_tagger\": {\n        \"config\": {\n            \"active_instance\": \"目前使用的 Stash-box：\",\n            \"blacklist_desc\": \"搜尋資訊時，置於黑名單內的字串將被省略。請注意，由於黑名單使用正規表示式，搜尋字串皆不分大小寫，且使用下列字元需先使用反斜線逸出該字元：{chars_require_escape}\",\n            \"blacklist_label\": \"黑名單\",\n            \"query_mode_auto\": \"自動\",\n            \"query_mode_auto_desc\": \"以 Metadata 或檔案名稱為優先\",\n            \"query_mode_dir\": \"資料夾名稱\",\n            \"query_mode_dir_desc\": \"僅使用影片檔案的資料夾名稱\",\n            \"query_mode_filename\": \"檔案名稱\",\n            \"query_mode_filename_desc\": \"僅使用檔案名稱\",\n            \"query_mode_label\": \"搜尋模式\",\n            \"query_mode_metadata\": \"Metadata\",\n            \"query_mode_metadata_desc\": \"僅使用 metadata\",\n            \"query_mode_path\": \"路徑名稱\",\n            \"query_mode_path_desc\": \"使用整個檔案路徑名稱\",\n            \"set_cover_desc\": \"選擇搜尋時，如果有找到封面照片時，是否要使用該圖片。\",\n            \"set_cover_label\": \"設定短片封面\",\n            \"set_tag_desc\": \"選擇套用標籤時，該如何處理現有標籤。\",\n            \"set_tag_label\": \"標籤設定\",\n            \"source\": \"來源\",\n            \"mark_organized_desc\": \"點選儲存後立即將短片標為已整理。\",\n            \"mark_organized_label\": \"儲存時標記為已整理\",\n            \"errors\": {\n                \"blacklist_duplicate\": \"複製黑名單項目\"\n            }\n        },\n        \"noun_query\": \"關鍵字\",\n        \"results\": {\n            \"duration_off\": \"片長差至少 {number} 秒\",\n            \"duration_unknown\": \"未知片長\",\n            \"fp_found\": \"{fpCount, plural, =0 {尚未發現新且符合的} other {找到 # 個已知}}特徵碼\",\n            \"fp_matches\": \"長度符合現有特徵碼\",\n            \"fp_matches_multi\": \"長度符合資料庫中 {matchCount}/{durationsLength} 個現有特徵碼\",\n            \"hash_matches\": \"{hash_type} 符合資料庫中的雜湊值\",\n            \"match_failed_already_tagged\": \"短片先前已標記完畢\",\n            \"match_failed_no_result\": \"未找到結果\",\n            \"match_success\": \"已成功標記短片\",\n            \"phash_matches\": \"{count} 個 PHash 符合\",\n            \"unnamed\": \"未命名\"\n        },\n        \"verb_match_fp\": \"特徵碼辨別\",\n        \"verb_matched\": \"符合\",\n        \"verb_scrape_all\": \"爬取所有\",\n        \"verb_submit_fp\": \"{fpCount, plural, other{提交 # 個特徵碼}}\",\n        \"verb_toggle_config\": \"{toggle}{configuration}\",\n        \"verb_toggle_unmatched\": \"{toggle}不符合已知特徵碼的短片\"\n    },\n    \"config\": {\n        \"about\": {\n            \"build_hash\": \"編譯版本雜湊值：\",\n            \"build_time\": \"編譯時間：\",\n            \"check_for_new_version\": \"檢查新版本\",\n            \"latest_version\": \"最新版本\",\n            \"latest_version_build_hash\": \"最新版本的雜湊值：\",\n            \"new_version_notice\": \"[新版本]\",\n            \"release_date\": \"發布日期：\",\n            \"stash_discord\": \"加入我們的 {url} 頻道\",\n            \"stash_home\": \"Stash 的 {url} 專案\",\n            \"stash_open_collective\": \"透過 {url} 來支持本計畫的開發\",\n            \"stash_wiki\": \"Stash 的 {url} 頁面\",\n            \"version\": \"版本\"\n        },\n        \"application_paths\": {\n            \"heading\": \"應用程式路徑\"\n        },\n        \"categories\": {\n            \"about\": \"關於\",\n            \"changelog\": \"更新日誌\",\n            \"interface\": \"介面\",\n            \"logs\": \"日誌\",\n            \"metadata_providers\": \"Metadata 來源\",\n            \"plugins\": \"外掛程式\",\n            \"scraping\": \"爬蟲設定\",\n            \"security\": \"安全性\",\n            \"services\": \"服務\",\n            \"system\": \"系統\",\n            \"tasks\": \"排程\",\n            \"tools\": \"工具\"\n        },\n        \"dlna\": {\n            \"allow_temp_ip\": \"允許 {tempIP}\",\n            \"allowed_ip_addresses\": \"已允許的 IP 位址\",\n            \"allowed_ip_temporarily\": \"已暫時允許 IP 位址\",\n            \"default_ip_whitelist\": \"預設 IP 白名單\",\n            \"default_ip_whitelist_desc\": \"預設可存取 DLNA 的 IP 位址，使用 {wildcard} 以允許所有 IP 位址。\",\n            \"disabled_dlna_temporarily\": \"已暫時關閉 DLNA 伺服器\",\n            \"disallowed_ip\": \"已禁止的 IP 位址\",\n            \"enabled_by_default\": \"預設啟用\",\n            \"enabled_dlna_temporarily\": \"已暫時開啟 DLNA 伺服器\",\n            \"network_interfaces\": \"網路裝置\",\n            \"network_interfaces_desc\": \"選擇要在哪個網路裝置上開放 DLNA 連線。當此列表為空時，則會在所有網路裝置上聽取連線。需重啟。\",\n            \"recent_ip_addresses\": \"最近的 IP 位址\",\n            \"server_display_name\": \"伺服器顯示名稱\",\n            \"server_display_name_desc\": \"DLNA 伺服器的顯示名稱。如果為空，則預設為 {server_name}。\",\n            \"successfully_cancelled_temporary_behaviour\": \"已關閉暫時啟用伺服器的功能\",\n            \"until_restart\": \"直到重啟\",\n            \"video_sort_order\": \"預設的影片排序\",\n            \"video_sort_order_desc\": \"依照預設方式排序影片。\",\n            \"server_port\": \"伺服器埠\",\n            \"server_port_desc\": \"運行 DLNA 伺服器的埠。更改後需要重新啟動 DLNA。\"\n        },\n        \"general\": {\n            \"auth\": {\n                \"api_key\": \"API 金鑰\",\n                \"api_key_desc\": \"外部系統的 API 金鑰，有設定使用者名稱/密碼時才需要。在生成 API 金鑰之前必須先設定使用者名稱。\",\n                \"authentication\": \"驗證設定\",\n                \"clear_api_key\": \"清除 API 金鑰\",\n                \"credentials\": {\n                    \"description\": \"用以限制存取 stash 權限的帳戶資料。\",\n                    \"heading\": \"帳戶資料\"\n                },\n                \"generate_api_key\": \"生成 API 金鑰\",\n                \"log_file\": \"日誌檔案\",\n                \"log_file_desc\": \"輸出日誌記錄到的檔案路徑，留空以關閉日誌檔案記錄。需重啟。\",\n                \"log_http\": \"記錄 HTTP 訪問紀錄\",\n                \"log_http_desc\": \"將 HTTP 訪問紀錄記錄至終端機內。需重啟。\",\n                \"log_to_terminal\": \"紀錄日誌至終端機內\",\n                \"log_to_terminal_desc\": \"除了記錄至檔案外，也記錄到終端機內；如果關閉日誌檔案記錄，則該選項始終為真。需重啟。\",\n                \"maximum_session_age\": \"有效驗證時間\",\n                \"maximum_session_age_desc\": \"使用者閒置多久後登出，以秒為單位。套用後需重啟。\",\n                \"password\": \"密碼\",\n                \"password_desc\": \"使用 Stash 時所需的密碼，留空以關閉身份驗證\",\n                \"stash-box_integration\": \"整合 Stash-box\",\n                \"username\": \"使用者名稱\",\n                \"username_desc\": \"使用 Stash 時所需的使用者名稱，留空以關閉身份驗證\"\n            },\n            \"backup_directory_path\": {\n                \"description\": \"SQLite 資料庫備份的檔案位置\",\n                \"heading\": \"備份目錄位置\"\n            },\n            \"blobs_path\": {\n                \"description\": \"存取物件檔案的檔案系統路徑。僅適用於使用檔案系統格式的物件檔案。警告：更改此選項後須手動遷移現有檔案。\",\n                \"heading\": \"物件檔案檔案系統路徑\"\n            },\n            \"blobs_storage\": {\n                \"description\": \"選擇物件檔案存取路徑，這些檔案可能包括檔案如短片封面、演員、工作室、及標籤圖案等等。若您修改此選項，請記得透過『遷移物件檔案』來遷移所有相關檔案。詳情請見『排程』頁面。\",\n                \"heading\": \"物件檔案儲存類別\"\n            },\n            \"cache_location\": \"快取的檔案位置。透過 HLS 或 DASH 格式串流時所需的必要選項。\",\n            \"cache_path_head\": \"快取路徑\",\n            \"calculate_md5_and_ohash_desc\": \"除 oshash 外，同時也計算 MD5 的雜湊值。開啟後，可能會影響初次掃描的速度。若要關閉 MD5 計算，請將『生成檔案名所使用的雜湊演算法』設為 oshash。\",\n            \"calculate_md5_and_ohash_label\": \"計算影片 MD5\",\n            \"check_for_insecure_certificates\": \"檢查憑證安全性\",\n            \"check_for_insecure_certificates_desc\": \"某些網站所使用的 SSL 憑證可能有安全性問題。取消勾選後，爬蟲工具會跳過不安全的憑證檢查，以允許爬蟲進行作業。如果您在抓取資訊時遇到憑證錯誤，請取消勾選此選項。\",\n            \"chrome_cdp_path\": \"Chrome CDP 路徑\",\n            \"chrome_cdp_path_desc\": \"Chrome 執行檔的檔案路徑，或 Chrome 的遠端地址（以 http:// 或 https:// 開頭，例如 http://localhost:9222/json/version）。\",\n            \"create_galleries_from_folders_desc\": \"勾選後，則會從包含圖片的資料夾建立圖庫。\",\n            \"create_galleries_from_folders_label\": \"從包含圖片的資料夾建立圖庫\",\n            \"database\": \"資料庫\",\n            \"db_path_head\": \"資料庫路徑\",\n            \"directory_locations_to_your_content\": \"多媒體的檔案位置\",\n            \"excluded_image_gallery_patterns_desc\": \"要從掃描中排除，並會被『清理』功能所移除的圖片及圖庫檔案/路徑的正規表示式\",\n            \"excluded_image_gallery_patterns_head\": \"圖片/圖庫排除規則\",\n            \"excluded_video_patterns_desc\": \"要從掃描中排除，並會被『清理』功能所移除的影片檔案/路徑的正規表示式\",\n            \"excluded_video_patterns_head\": \"影片排除規則\",\n            \"ffmpeg\": {\n                \"hardware_acceleration\": {\n                    \"heading\": \"FFMpeg 硬體編碼\",\n                    \"desc\": \"使用可用的硬體進行即時串流轉碼。\"\n                },\n                \"live_transcode\": {\n                    \"input_args\": {\n                        \"heading\": \"FFMpeg 即時串流輸入選項\",\n                        \"desc\": \"進階：即時轉碼時在 \\\"input\\\" 欄位之前傳遞給 ffmpeg 的附加參數。\"\n                    },\n                    \"output_args\": {\n                        \"heading\": \"FFMpeg 即時串流輸出選項\",\n                        \"desc\": \"進階：即時轉碼時在 \\\"output\\\" 欄位之前傳遞給 ffmpeg 的附加參數。\"\n                    }\n                },\n                \"transcode\": {\n                    \"input_args\": {\n                        \"heading\": \"FFMpeg 即時串流輸入選項\",\n                        \"desc\": \"進階：產生影片時在 \\\"input\\\" 欄位之前傳遞給 ffmpeg 的附加參數。\"\n                    },\n                    \"output_args\": {\n                        \"heading\": \"FFMpeg 即時串流輸出選項\",\n                        \"desc\": \"進階：產生影片時在 \\\"output\\\" 欄位之前傳遞給 ffmpeg 的附加參數。\"\n                    }\n                },\n                \"download_ffmpeg\": {\n                    \"description\": \"將 FFmpeg 下載到配置目錄中，並從配置目錄中清除要解析的 ffmpeg 和 ffprobe 路徑。\",\n                    \"heading\": \"下載 FFmpeg\"\n                },\n                \"ffmpeg_path\": {\n                    \"description\": \"ffmpeg 可執行文件的路徑（而不僅僅是資料夾）。如果為空，則ffmpeg將通過 $PATH、配置目錄或 $HOME/.stash 從環境中解析\",\n                    \"heading\": \"FFmpeg 可執行文件路徑\"\n                },\n                \"ffprobe_path\": {\n                    \"description\": \"ffprobe 可執行檔（而不僅僅是資料夾）的路徑。如果為空，則ffprobe將通過 $PATH、配置目錄或 $HOME/.stash 從環境中解析\",\n                    \"heading\": \"FFprobe 可執行文件路徑\"\n                }\n            },\n            \"gallery_ext_desc\": \"以逗號分隔的副檔名名稱，這類檔案將視為圖庫。\",\n            \"gallery_ext_head\": \"視為圖庫的副檔名\",\n            \"generated_file_naming_hash_desc\": \"用 MD5 或 oshash 命名檔案。更改此設定後，短片須有先有對應的 MD5/oshash 雜湊值，先前已產生的檔案或許需要遷移或重新產生。請參閱『遷移』頁面。\",\n            \"generated_file_naming_hash_head\": \"生成檔案名所使用的雜湊演算法\",\n            \"generated_files_location\": \"生成文件的檔案位置（短片標記、短片預覽、預覽圖等）\",\n            \"generated_path_head\": \"生成檔案儲存路徑\",\n            \"hashing\": \"雜湊值設定\",\n            \"image_ext_desc\": \"以逗號分隔的副檔名名稱，這些檔案將視為圖片。\",\n            \"image_ext_head\": \"圖片副檔名\",\n            \"include_audio_desc\": \"產生預覽檔案時，順便產生音訊預覽。\",\n            \"include_audio_head\": \"包含音訊\",\n            \"logging\": \"日誌設定\",\n            \"maximum_streaming_transcode_size_desc\": \"轉檔生成的串流最大大小\",\n            \"maximum_streaming_transcode_size_head\": \"最大的串流轉檔解析度大小\",\n            \"maximum_transcode_size_desc\": \"轉檔生成的影片最大大小\",\n            \"maximum_transcode_size_head\": \"最大的轉檔解析度大小\",\n            \"metadata_path\": {\n                \"description\": \"進行完整匯出或匯入時所使用的檔案位置\",\n                \"heading\": \"Metadata 路徑\"\n            },\n            \"number_of_parallel_task_for_scan_generation_desc\": \"設定為 0 以自動偵測。請注意，執行比使用 100% CPU 使用率所需的排程數量，可能會降低性能並導致其他問題。\",\n            \"number_of_parallel_task_for_scan_generation_head\": \"掃描/生成的並行排程數量\",\n            \"parallel_scan_head\": \"平行掃描／生成\",\n            \"preview_generation\": \"預覽產生的檔案\",\n            \"python_path\": {\n                \"description\": \"Python 執行檔的完整路徑（而非僅為資料夾名稱），留空時將從作業系統的環境變數取得。用於爬蟲及外掛程式\",\n                \"heading\": \"Python 執行檔路徑\"\n            },\n            \"scraper_user_agent\": \"爬蟲的使用者代理 (User Agent)\",\n            \"scraper_user_agent_desc\": \"抓取 HTTP 資料時所用的使用者代理 (User-Agent)\",\n            \"scrapers_path\": {\n                \"description\": \"含有爬蟲設定檔的資料夾路徑\",\n                \"heading\": \"爬蟲路徑\"\n            },\n            \"scraping\": \"爬蟲設定\",\n            \"sqlite_location\": \"FSQLite 資料庫位置。需重啟。注意：不支援非本機的資料庫與 Stash 伺服器間的連線！\",\n            \"video_ext_desc\": \"以逗號分隔的副檔名名稱，這些檔案類型將視為影片。\",\n            \"video_ext_head\": \"影片副檔名\",\n            \"video_head\": \"影片設定\",\n            \"gallery_cover_regex_desc\": \"用正規表示式設定圖庫封面\",\n            \"plugins_path\": {\n                \"heading\": \"插件路徑\",\n                \"description\": \"插件設定檔的存放路徑\"\n            },\n            \"funscript_heatmap_draw_range\": \"產生涵蓋此範圍的熱圖\",\n            \"funscript_heatmap_draw_range_desc\": \"將移動範圍描繪在熱圖的Ｙ軸上；變更後需要重新產製現有的熱圖。\",\n            \"gallery_cover_regex_label\": \"圖庫封面的表示式\",\n            \"heatmap_generation\": \"Funscript 熱圖生成\"\n        },\n        \"library\": {\n            \"exclusions\": \"白名單\",\n            \"gallery_and_image_options\": \"圖庫及圖片選項\",\n            \"media_content_extensions\": \"多媒體檔案副檔名\"\n        },\n        \"logs\": {\n            \"log_level\": \"日誌檔級別\"\n        },\n        \"plugins\": {\n            \"hooks\": \"鉤子\",\n            \"triggers_on\": \"觸發於\",\n            \"available_plugins\": \"可使用的插件\",\n            \"installed_plugins\": \"已安裝的插件\"\n        },\n        \"scraping\": {\n            \"entity_metadata\": \"{entityType}資訊\",\n            \"entity_scrapers\": \"{entityType}爬蟲\",\n            \"excluded_tag_patterns_desc\": \"自爬蟲結果中，排除符合以下正規表示式的標籤\",\n            \"excluded_tag_patterns_head\": \"排除符合正規表示式的標籤\",\n            \"scraper\": \"爬蟲\",\n            \"scrapers\": \"爬蟲\",\n            \"search_by_name\": \"透過名稱搜尋\",\n            \"supported_types\": \"支援類型\",\n            \"supported_urls\": \"支援網址\",\n            \"available_scrapers\": \"可用的爬蟲\",\n            \"installed_scrapers\": \"已安裝的爬蟲\"\n        },\n        \"stashbox\": {\n            \"add_instance\": \"新增 Stash-box 端點\",\n            \"api_key\": \"API 金鑰\",\n            \"description\": \"Stash-box 可以根據影片的特徵碼和檔案名稱來自動標記短片及演員。\\n您可在您的 Stash-box 端點中的帳戶資訊內找到端點資訊以及您的 API 金鑰。如果您有多個端點數量的話，名稱欄位不可留空。\",\n            \"endpoint\": \"端點\",\n            \"graphql_endpoint\": \"GraphQL 端點\",\n            \"name\": \"名稱\",\n            \"title\": \"Stash-box 端點\",\n            \"max_requests_per_minute\": \"每分鐘請求上限\",\n            \"max_requests_per_minute_description\": \"當設為 0 時，會套用預設值 {defaultValue}\"\n        },\n        \"system\": {\n            \"transcoding\": \"轉檔\"\n        },\n        \"tasks\": {\n            \"added_job_to_queue\": \"已將『{operation_name}』加入至工作排程\",\n            \"anonymise_and_download\": \"產生並下載匿名化版本的資料庫。\",\n            \"anonymise_database\": \"建立一份去除敏感資訊且匿名化的資料庫檔案至備份資料夾中。此檔案可供其他人員幫助您排除相關問題以及除錯用。您現有的資料庫當案將不會被修改。匿名化版本的資料庫檔案命名為 {filename_format}。\",\n            \"anonymising_database\": \"匿名化資料庫中\",\n            \"auto_tag\": {\n                \"auto_tagging_all_paths\": \"為所有路徑自動套用標籤中\",\n                \"auto_tagging_paths\": \"為以下路徑自動套用標籤中\"\n            },\n            \"auto_tag_based_on_filenames\": \"根據檔案名稱自動標記內容。\",\n            \"auto_tagging\": \"自動套用標籤\",\n            \"backing_up_database\": \"備份資料庫中\",\n            \"backup_and_download\": \"執行資料庫備份，並下載其生成的檔案。\",\n            \"cleanup_desc\": \"檢查缺失的檔案，並將它們從資料庫中刪除。此動作具無法復原。\",\n            \"data_management\": \"資料管理\",\n            \"defaults_set\": \"已設定預設值；以後按下「{action}」按鈕時將會使用這些設定。\",\n            \"dont_include_file_extension_as_part_of_the_title\": \"不要在標題中附上檔案副檔名\",\n            \"empty_queue\": \"尚無排程執行中。\",\n            \"export_to_json\": \"將資料庫中的 Metadata 匯出為 JSON 檔。\",\n            \"generate\": {\n                \"generating_from_paths\": \"為以下路徑之短片生成檔案中\",\n                \"generating_scenes\": \"為{num}個{scene}生成檔案中\"\n            },\n            \"generate_desc\": \"產生輔助圖片、預覽、影片檔、VTT 字幕等檔案。\",\n            \"generate_phashes_during_scan\": \"產生 PHash\",\n            \"generate_phashes_during_scan_tooltip\": \"可用於辨認短片或偵測重複的短片。\",\n            \"generate_previews_during_scan\": \"產生動態預覽圖\",\n            \"generate_previews_during_scan_tooltip\": \"同時產生動圖 (webp) 作為預覽，只有在短片/標記預覽牆設定為『動圖』時才需使用此選項。當瀏覽這些多媒體時，相較影片預覽將會使用較低的 CPU 資源，但是，它們必須在影片預覽之外生成，並且是較大的檔案。\",\n            \"generate_sprites_during_scan\": \"產生時間軸預覽\",\n            \"generate_thumbnails_during_scan\": \"替圖片產生縮圖\",\n            \"generate_video_previews_during_scan\": \"產生影片預覽\",\n            \"generate_video_previews_during_scan_tooltip\": \"產生影片預覽，此預覽將於滑鼠移至影片上時自動播放\",\n            \"generated_content\": \"生成的內容\",\n            \"identify\": {\n                \"and_create_missing\": \"及建立尚有欄位\",\n                \"create_missing\": \"新增尚有的欄位\",\n                \"default_options\": \"預設選項\",\n                \"description\": \"自動套用 Stash-box 及爬蟲搜尋結果套用至短片資料中。\",\n                \"explicit_set_description\": \"當尚未指定來源選項時，將會使用下列設定。\",\n                \"field\": \"欄位\",\n                \"field_behaviour\": \"{strategy}{field}\",\n                \"field_options\": \"欄位選項\",\n                \"heading\": \"自動辨識檔案\",\n                \"identifying_from_paths\": \"辨認下列路徑中的短片\",\n                \"identifying_scenes\": \"辨認{num}{scene}中\",\n                \"include_male_performers\": \"包含男優\",\n                \"set_cover_images\": \"設定封面\",\n                \"set_organized\": \"設為『已整理』\",\n                \"source\": \"來源\",\n                \"source_options\": \"{source} 選項\",\n                \"sources\": \"來源\",\n                \"strategy\": \"方法\",\n                \"skip_multiple_matches\": \"跳過具有多個結果的匹配結果\",\n                \"skip_single_name_performers\": \"跳過單名且沒有歧義的演員\",\n                \"skip_multiple_matches_tooltip\": \"如果未啟用此功能且搜尋回傳多個結果，則會隨機選擇一個其中一個進行匹配\",\n                \"skip_single_name_performers_tooltip\": \"如果未啟用此功能，則會自動匹配 Samantha 或者 Olga 這種常見名稱的演員\",\n                \"tag_skipped_matches\": \"標記跳過的匹配項\",\n                \"tag_skipped_matches_tooltip\": \"創建一個標籤，例如「識別：多個匹配項」，您可以在短片標籤器視圖中過濾該標籤，並手動選擇正確的匹配項\",\n                \"tag_skipped_performer_tooltip\": \"創建一個標籤，例如“識別：單名表演者”，您可以在短片標籤器視圖中篩選該標籤，並選擇您希望如何處理這些表演者\",\n                \"tag_skipped_performers\": \"標記跳過的表演者\"\n            },\n            \"import_from_exported_json\": \"匯入先前從 Metadata 資料夾中匯出的 JSON 檔。此動作將清除現有資料庫中的內容。\",\n            \"incremental_import\": \"從匯出 ZIP 檔進行增量匯入。\",\n            \"job_queue\": \"工作排程\",\n            \"maintenance\": \"維護\",\n            \"migrate_hash_files\": \"在更改生成的檔案命名雜湊以將現有生成的文件重新命名為新的雜湊格式後使用。\",\n            \"migrations\": \"遷移\",\n            \"only_dry_run\": \"僅模擬作業，不要刪除任何東西\",\n            \"plugin_tasks\": \"外掛程式排程\",\n            \"scan\": {\n                \"scanning_all_paths\": \"掃描所有路徑中\",\n                \"scanning_paths\": \"掃描以下路徑中\"\n            },\n            \"scan_for_content_desc\": \"掃描新內容，並將其新增到資料庫中。\",\n            \"set_name_date_details_from_metadata_if_present\": \"使用多媒體檔案中內建的標題、日期、詳細資訊（如果適用的話）\",\n            \"generate_clip_previews_during_scan\": \"替圖像短片建立預覽\",\n            \"generate_sprites_during_scan_tooltip\": \"便於瀏覽顯示在影片播放器下方的一組預覽圖。\",\n            \"generate_video_covers_during_scan\": \"產生短片封面\",\n            \"migrate_scene_screenshots\": {\n                \"delete_files\": \"刪除截圖檔案\",\n                \"description\": \"將場景螢幕截圖遷移到新的 blob 儲存系統中。應在將現有系統遷移到 0.20 後運行此遷移。可以選擇在遷移後刪除舊的屏幕截圖。\",\n                \"overwrite_existing\": \"使用螢幕截圖資料覆蓋現有 blob\"\n            },\n            \"clean_generated\": {\n                \"blob_files\": \"Blob 檔案\",\n                \"image_thumbnails\": \"圖片縮圖\",\n                \"image_thumbnails_desc\": \"圖片縮圖及短片\",\n                \"description\": \"刪除無對應檔案的資料庫條目。\",\n                \"markers\": \"章節預覽\",\n                \"previews\": \"短片預覽\",\n                \"previews_desc\": \"短片預覽及縮圖\",\n                \"sprites\": \"短片拼合圖像\",\n                \"transcodes\": \"短片轉碼\"\n            },\n            \"migrate_blobs\": {\n                \"description\": \"將 Blob 遷移到當前的 Blob 儲存系統。 此遷移應在更改 Blob 儲存系統後執行。 遷移後可以選擇性的刪除舊資料。\",\n                \"delete_old\": \"刪除舊資料\"\n            },\n            \"optimise_database\": \"嘗試通過分析然後重建整個資料庫檔來提高性能。\",\n            \"optimise_database_warning\": \"警告：當此任務運行時，任何修改資料庫的操作都將失敗，並且根據資料庫大小，可能需要幾分鐘才能完成。它還至少需要與資料庫大小一樣多的可用磁碟空間，但建議使用1.5倍。\",\n            \"rescan\": \"重新掃描檔案\",\n            \"rescan_tooltip\": \"重新掃描路徑中的每個檔案。 用於強制更新檔案中繼資料和重新掃描zip檔案。\"\n        },\n        \"tools\": {\n            \"scene_duplicate_checker\": \"短片相近性檢查工具\",\n            \"scene_filename_parser\": {\n                \"add_field\": \"新增項目\",\n                \"capitalize_title\": \"將標題改為大寫\",\n                \"display_fields\": \"選擇顯示項目\",\n                \"escape_chars\": \"使用反斜線 (\\\\) 溢出字元\",\n                \"filename\": \"檔案名稱\",\n                \"filename_pattern\": \"檔案名稱規則\",\n                \"ignore_organized\": \"忽略已整理的短片\",\n                \"ignored_words\": \"忽略字串\",\n                \"matches_with\": \"使用 {i} 匹配\",\n                \"select_parser_recipe\": \"選擇預設分析字串\",\n                \"title\": \"短片名稱分析工具\",\n                \"whitespace_chars\": \"空白字元\",\n                \"whitespace_chars_desc\": \"這些字元將在標題中被空格取代\"\n            },\n            \"scene_tools\": \"短片工具\",\n            \"heading\": \"工具\",\n            \"graphql_playground\": \"GraphQL 測試環境\"\n        },\n        \"ui\": {\n            \"abbreviate_counters\": {\n                \"description\": \"簡化短片詳情中的點閱數（例：『1831』將會被簡化為『1.8K』）。\",\n                \"heading\": \"簡化數量\"\n            },\n            \"basic_settings\": \"一般設定\",\n            \"custom_css\": {\n                \"description\": \"如需套用，請重新整理頁面。\",\n                \"heading\": \"自訂 CSS\",\n                \"option_label\": \"啟用自訂 CSS\"\n            },\n            \"custom_javascript\": {\n                \"description\": \"必須重新整理頁面才能使更改生效。\",\n                \"heading\": \"自訂 JavaScript\",\n                \"option_label\": \"已啟用自訂 JavaScript\"\n            },\n            \"custom_locales\": {\n                \"description\": \"強制使用特定翻譯字串。主列表請參閱 https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json。必須重新整理頁面才能使更改生效。\",\n                \"heading\": \"自訂翻譯\",\n                \"option_label\": \"啟用自訂翻譯\"\n            },\n            \"delete_options\": {\n                \"description\": \"刪除圖片、圖庫及短片時的預設設定。\",\n                \"heading\": \"刪除選項\",\n                \"options\": {\n                    \"delete_file\": \"將『刪除檔案』設為預設選項\",\n                    \"delete_generated_supporting_files\": \"將『刪除已生成的資訊檔案』設為預設選項\"\n                }\n            },\n            \"desktop_integration\": {\n                \"desktop_integration\": \"桌面整合\",\n                \"notifications_enabled\": \"開啟通知\",\n                \"send_desktop_notifications_for_events\": \"當事件發生時發送通知\",\n                \"skip_opening_browser\": \"停用自動開啟瀏覽器\",\n                \"skip_opening_browser_on_startup\": \"伺服器啟動時，不要自動開啟瀏覽器\"\n            },\n            \"editing\": {\n                \"disable_dropdown_create\": {\n                    \"description\": \"關閉下拉選單建立物件功能\",\n                    \"heading\": \"關閉下拉選單建立\"\n                },\n                \"heading\": \"編輯\",\n                \"rating_system\": {\n                    \"star_precision\": {\n                        \"label\": \"星級精度\",\n                        \"options\": {\n                            \"full\": \"完整\",\n                            \"half\": \"一半\",\n                            \"quarter\": \"四分之一\",\n                            \"tenth\": \"十分之一\"\n                        }\n                    },\n                    \"type\": {\n                        \"label\": \"評分系統類別\",\n                        \"options\": {\n                            \"decimal\": \"數字\",\n                            \"stars\": \"星級\"\n                        }\n                    }\n                },\n                \"max_options_shown\": {\n                    \"label\": \"在選擇下拉式選單中顯示的最大項目數量\"\n                }\n            },\n            \"funscript_offset\": {\n                \"description\": \"互動式腳本的時間偏移量 (毫秒)。\",\n                \"heading\": \"Funscript 偏移量 (毫秒)\"\n            },\n            \"handy_connection\": {\n                \"connect\": \"連接\",\n                \"server_offset\": {\n                    \"heading\": \"伺服器誤差值\"\n                },\n                \"status\": {\n                    \"heading\": \"Handy 連線狀態\"\n                },\n                \"sync\": \"同步\"\n            },\n            \"handy_connection_key\": {\n                \"description\": \"播放支援互動性的短片時所用的 Handy 連線金鑰。設定此金鑰後，Stash 將可把目前短片中的對應資訊分享至 handyfeeling.com\",\n                \"heading\": \"Handy 連線金鑰\"\n            },\n            \"image_lightbox\": {\n                \"heading\": \"圖片燈箱\"\n            },\n            \"images\": {\n                \"heading\": \"圖片\",\n                \"options\": {\n                    \"write_image_thumbnails\": {\n                        \"description\": \"建立縮圖時，將檔案寫至磁碟中\",\n                        \"heading\": \"建立圖片縮圖\"\n                    },\n                    \"create_image_clips_from_videos\": {\n                        \"description\": \"當資料庫停用影片時，視訊檔（以視訊副檔名結尾的檔案）會被掃描為圖像剪輯。\",\n                        \"heading\": \"將視訊擴充檔掃描為圖片剪輯\"\n                    }\n                }\n            },\n            \"interactive_options\": \"互動性選項\",\n            \"language\": {\n                \"heading\": \"語言\"\n            },\n            \"max_loop_duration\": {\n                \"description\": \"如果影片長度低於該臨界值，則影片結束時，將自動重新播放 (適用於長度較短的影片)－設為 0 以關閉此功能\",\n                \"heading\": \"重新播放門檻\"\n            },\n            \"menu_items\": {\n                \"description\": \"顯示或隱藏導覽列表中的項目\",\n                \"heading\": \"選單項目\"\n            },\n            \"minimum_play_percent\": {\n                \"description\": \"在增加播放次數前必須播放的短片總長百分比。\",\n                \"heading\": \"最低播放百分比\"\n            },\n            \"performers\": {\n                \"options\": {\n                    \"image_location\": {\n                        \"description\": \"預設演員圖像的自訂路徑。留空以使用內建預設值\",\n                        \"heading\": \"自訂演員圖像路徑\"\n                    }\n                }\n            },\n            \"preview_type\": {\n                \"description\": \"預設選項是影片 (mp4) 預覽。 為了在瀏覽時減少 CPU 資源，您可以使用動畫影像 (webp) 預覽。 但是，它們必須在影片預覽之外生成，並且是較大的檔案。\",\n                \"heading\": \"預覽種類\",\n                \"options\": {\n                    \"animated\": \"動圖\",\n                    \"static\": \"靜態\",\n                    \"video\": \"影片\"\n                }\n            },\n            \"scene_list\": {\n                \"heading\": \"網格視圖\",\n                \"options\": {\n                    \"show_studio_as_text\": \"疊加顯示短片所屬工作室名稱\"\n                }\n            },\n            \"scene_player\": {\n                \"heading\": \"短片播放器\",\n                \"options\": {\n                    \"always_start_from_beginning\": \"永遠從頭開始播放影片\",\n                    \"auto_start_video\": \"自動播放\",\n                    \"auto_start_video_on_play_selected\": {\n                        \"description\": \"開啟佇列中或所選短片、或隨機播放時，自動開始播放影片\",\n                        \"heading\": \"自動播放所選短片\"\n                    },\n                    \"continue_playlist_default\": {\n                        \"description\": \"當影片播放完畢時，自動跳至下一個短片\",\n                        \"heading\": \"持續播放播放清單\"\n                    },\n                    \"show_scrubber\": \"顯示預覽軸\",\n                    \"track_activity\": \"追蹤使用活動\",\n                    \"vr_tag\": {\n                        \"heading\": \"VR 標籤\",\n                        \"description\": \"VR 按鈕只會顯示在有此標籤的場景中。\"\n                    },\n                    \"enable_chromecast\": \"啟用 Chromecast\",\n                    \"show_ab_loop_controls\": \"顯示AB循環插件控件\",\n                    \"disable_mobile_media_auto_rotate\": \"停用行動裝置上全螢幕媒體的自動旋轉功能\",\n                    \"show_range_markers\": \"顯示範圍標記\"\n                }\n            },\n            \"scene_wall\": {\n                \"heading\": \"短片 / 章節標記預覽牆\",\n                \"options\": {\n                    \"display_title\": \"顯示影片標題及標籤\",\n                    \"toggle_sound\": \"播放聲音\"\n                }\n            },\n            \"scroll_attempts_before_change\": {\n                \"description\": \"在移動到下一項/上一項之前嘗試滑動的次數。僅適用於『Y軸滑動』模式。\",\n                \"heading\": \"場景變換滑動嘗試次數\"\n            },\n            \"show_tag_card_on_hover\": {\n                \"description\": \"游標移至標籤上時，顯示標籤頁卡\",\n                \"heading\": \"顯示標籤頁卡\"\n            },\n            \"slideshow_delay\": {\n                \"description\": \"幻燈片功能僅適用於「圖庫」種類下的預覽牆模式\",\n                \"heading\": \"幻燈片延遲 (秒)\"\n            },\n            \"studio_panel\": {\n                \"heading\": \"檢視工作室\",\n                \"options\": {\n                    \"show_child_studio_content\": {\n                        \"description\": \"於工作室頁面中，同時顯示子工作室內容\",\n                        \"heading\": \"顯示子工作室內容\"\n                    }\n                }\n            },\n            \"tag_panel\": {\n                \"heading\": \"檢視標籤\",\n                \"options\": {\n                    \"show_child_tagged_content\": {\n                        \"description\": \"於標籤頁面中，同時顯示子標籤內容\",\n                        \"heading\": \"顯示子標籤內容\"\n                    }\n                }\n            },\n            \"title\": \"使用者介面\",\n            \"image_wall\": {\n                \"heading\": \"圖片預覽牆\",\n                \"direction\": \"方向\",\n                \"margin\": \"邊距（像素）\"\n            },\n            \"detail\": {\n                \"heading\": \"細節頁面\",\n                \"show_all_details\": {\n                    \"heading\": \"顯示所有細節\",\n                    \"description\": \"啟用後，預設將顯示所有內容的細節內容，並且每個詳細資訊項目將以單列呈現\"\n                },\n                \"compact_expanded_details\": {\n                    \"heading\": \"緊湊型擴充詳細資料\",\n                    \"description\": \"啟用時，此選項會呈現擴充的詳細資料，同時保持簡潔的呈現方式\"\n                },\n                \"enable_background_image\": {\n                    \"description\": \"在詳細頁面上顯示背景圖片。\",\n                    \"heading\": \"啟用背景影像\"\n                }\n            },\n            \"use_stash_hosted_funscript\": {\n                \"description\": \"啟用後，funscript 將直接從 Stash 傳送至您的 Handy 裝置，而無需使用第三方 Handy 伺服器。要求可從您的 Handy 裝置存取 Stash，且如果 stash 已設定憑證，則會產生 API 金鑰。\",\n                \"heading\": \"直接為 funscript 服務\"\n            },\n            \"performer_list\": {\n                \"options\": {\n                    \"show_links_on_grid_card\": {\n                        \"heading\": \"在表演者卡片上顯示連結\"\n                    }\n                },\n                \"heading\": \"表演者清單\"\n            }\n        },\n        \"advanced_mode\": \"進階模式\"\n    },\n    \"configuration\": \"設定\",\n    \"countables\": {\n        \"files\": \"檔案\",\n        \"galleries\": \"圖庫\",\n        \"images\": \"圖片\",\n        \"markers\": \"章節標記\",\n        \"performers\": \"演員\",\n        \"scenes\": \"短片\",\n        \"studios\": \"工作室\",\n        \"tags\": \"標籤\",\n        \"groups\": \"{count, plural, one {群組} other {群組}}\"\n    },\n    \"country\": \"國家\",\n    \"cover_image\": \"封面圖片\",\n    \"created_at\": \"建立於\",\n    \"criterion\": {\n        \"greater_than\": \"大於\",\n        \"less_than\": \"小於\",\n        \"value\": \"數值\"\n    },\n    \"criterion_modifier\": {\n        \"between\": \"與 ... 之間\",\n        \"equals\": \"是\",\n        \"excludes\": \"排除\",\n        \"format_string\": \"{criterion}{modifierString}{valueString}\",\n        \"greater_than\": \"大於\",\n        \"includes\": \"包含\",\n        \"includes_all\": \"所有包含\",\n        \"is_null\": \"為空\",\n        \"less_than\": \"小於\",\n        \"matches_regex\": \"符合正規表示式\",\n        \"not_between\": \"不與 ... 之間\",\n        \"not_equals\": \"不是\",\n        \"not_matches_regex\": \"不符合正規表示式\",\n        \"not_null\": \"不為空\",\n        \"format_string_excludes\": \"{criterion} {modifierString} {valueString} (排除{excludedString})\",\n        \"format_string_depth\": \"{criterion}{modifierString}{valueString}(+{depth, plural, =-1 {所有} other {{深度}}})\",\n        \"format_string_excludes_depth\": \"{criterion} {modifierString} {valueString} (排除 {excludedString}) (+{depth, plural, =-1 {所有} other {{深度}}})\"\n    },\n    \"custom\": \"自訂\",\n    \"date\": \"日期\",\n    \"death_date\": \"去世日期\",\n    \"death_year\": \"去世年分\",\n    \"descending\": \"降序\",\n    \"description\": \"敘述\",\n    \"detail\": \"詳情\",\n    \"details\": \"細節\",\n    \"developmentVersion\": \"開發版本\",\n    \"dialogs\": {\n        \"create_new_entity\": \"建立新{entity}\",\n        \"delete_alert\": \"以下{count, plural, one {{singularEntity}} other {{pluralEntity}}}將被永久刪除：\",\n        \"delete_confirm\": \"你確定要刪除 {entityName} 嗎？\",\n        \"delete_entity_desc\": \"{count, plural, one {你確定要刪除該{singularEntity}嗎？除非連同檔案一起刪除，否則，下次進行檔案掃描時，該{singularEntity}會被重新加到資料庫中。} other {你確定要刪除這些{pluralEntity}嗎？除非連同檔案一起刪除，否則，下次進行檔案掃描時，這些{pluralEntity}會被重新加到資料庫中。}}\",\n        \"delete_entity_simple_desc\": \"{count, plural, one {您確定要刪除此檔案嗎 {singularEntity}?} other {您確定要刪除這些檔案嗎 {pluralEntity}?}}\",\n        \"delete_entity_title\": \"{count, plural, other {刪除{pluralEntity}}}\",\n        \"delete_galleries_extra\": \"…及其他不在圖庫內的圖片檔案。\",\n        \"delete_gallery_files\": \"刪除所有不在任一圖庫內的圖庫資料夾、壓縮檔及圖檔。\",\n        \"delete_object_desc\": \"你確定要刪除{count, plural, =1 {這個{singularEntity}} other {這些{pluralEntity}}}嗎?\",\n        \"delete_object_overflow\": \"…以及 {count} 個其他 {count, plural, =1 {{singularEntity}} other {{pluralEntity}}}。\",\n        \"delete_object_title\": \"刪除{count, plural, =1 {{singularEntity}} other {{pluralEntity}}}\",\n        \"dont_show_until_updated\": \"下次更新前不顯示\",\n        \"edit_entity_title\": \"編輯{pluralEntity}\",\n        \"export_include_related_objects\": \"匯出所有相關物件\",\n        \"export_title\": \"匯出\",\n        \"lightbox\": {\n            \"delay\": \"延遲 (秒)\",\n            \"display_mode\": {\n                \"fit_horizontally\": \"橫向置放\",\n                \"fit_to_screen\": \"適應螢幕大小\",\n                \"label\": \"顯示模式\",\n                \"original\": \"原始\"\n            },\n            \"options\": \"選項\",\n            \"reset_zoom_on_nav\": \"當更換圖片時，重設縮放大小\",\n            \"scale_up\": {\n                \"description\": \"將較小的圖片放大，以填滿整個畫面\",\n                \"label\": \"縮放適應\"\n            },\n            \"scroll_mode\": {\n                \"description\": \"按住 Shift 以暫時使用其他模式。\",\n                \"label\": \"滑動模式\",\n                \"pan_y\": \"Y 軸滑動\",\n                \"zoom\": \"放大\"\n            },\n            \"page_header\": \"第 {page} 頁，共計{total}頁\"\n        },\n        \"merge\": {\n            \"destination\": \"目標\",\n            \"empty_results\": \"目標值將保持不變。\",\n            \"source\": \"源頭\"\n        },\n        \"reassign_entity_title\": \"{count, plural, one {重新指定{singularEntity}}}\",\n        \"reassign_files\": {\n            \"destination\": \"重新指定至\"\n        },\n        \"scene_gen\": {\n            \"force_transcodes\": \"強制產生轉檔檔案\",\n            \"force_transcodes_tooltip\": \"預設情況下，只有無法正常於瀏覽器中播放的影片檔會被轉檔成可播放的格式。開啟此設定後，即使是可正常播放的影片檔，也會被視為需轉檔的檔案。\",\n            \"image_previews\": \"動圖預覽\",\n            \"image_previews_tooltip\": \"產生 WebP 動圖作為預覽，僅適用於設定為『動圖』的預覽類型。\",\n            \"interactive_heatmap_speed\": \"替可互動的短片產生熱圖 (heatmaps) 及速度\",\n            \"marker_image_previews\": \"章節動圖預覽\",\n            \"marker_image_previews_tooltip\": \"產生 WebP 動圖作為章節預覽，僅適用於設定為『動圖』的預覽類型。\",\n            \"marker_screenshots\": \"章節截圖\",\n            \"marker_screenshots_tooltip\": \"靜態 JPG 預覽，僅適用於設定為『靜態』的預覽類型。\",\n            \"markers\": \"章節預覽\",\n            \"markers_tooltip\": \"自標記的時間點算起長度20秒的短片預覽。\",\n            \"override_preview_generation_options\": \"強制使用所選的預覽產生設定\",\n            \"override_preview_generation_options_desc\": \"在本次工作排程中，強制使用所選的預覽產生設定。預設設定請見『系統』->『預覽生成設定』。\",\n            \"overwrite\": \"覆蓋現有的生成檔案\",\n            \"phash\": \"PHash（用於偵測重複的影片檔案及辨認短片）\",\n            \"preview_exclude_end_time_desc\": \"從場景預覽中排除最後 x 秒。這可以是一個以秒為單位的值，也可以是整個場景持續時間的百分比（例如 2%）。\",\n            \"preview_exclude_end_time_head\": \"排除結束時間\",\n            \"preview_exclude_start_time_desc\": \"從場景預覽中排除前 x 秒。這可以是一個以秒為單位的值，也可以是整個場景持續時間的百分比（例如 2%）。\",\n            \"preview_exclude_start_time_head\": \"排除開始時間\",\n            \"preview_generation_options\": \"預覽生成設定\",\n            \"preview_options\": \"預覽選項\",\n            \"preview_preset_desc\": \"預設值會影響預覽影像的大小、品質和編碼時間。“slow” 以下的預設值會隨著層級降低整體CP值，不推薦使用。\",\n            \"preview_preset_head\": \"調整預設值 (Preset)\",\n            \"preview_seg_count_desc\": \"設定預覽影片中的段數。\",\n            \"preview_seg_count_head\": \"預覽影片段數\",\n            \"preview_seg_duration_desc\": \"每個預覽片段的長度，以秒為單位。\",\n            \"preview_seg_duration_head\": \"預覽片段長度\",\n            \"sprites\": \"時間軸預覽\",\n            \"sprites_tooltip\": \"時間軸預覽（用於短片中時間軸的預覽圖）。\",\n            \"transcodes\": \"轉檔\",\n            \"transcodes_tooltip\": \"將不支援的影片格式轉換成 MP4\",\n            \"video_previews\": \"影片預覽\",\n            \"video_previews_tooltip\": \"此預覽將於滑鼠移至影片上時自動播放\",\n            \"clip_previews\": \"影象剪輯預覽\",\n            \"covers\": \"短片封面\",\n            \"image_thumbnails\": \"圖片縮圖\",\n            \"phash_tooltip\": \"可用於辨認短片或偵測重複的短片\"\n        },\n        \"scenes_found\": \"已找到 {count} 個短片\",\n        \"scrape_entity_query\": \"{entity_type}爬蟲搜尋\",\n        \"scrape_entity_title\": \"{entity_type}爬取結果\",\n        \"scrape_results_existing\": \"現有資訊\",\n        \"scrape_results_scraped\": \"爬取資訊\",\n        \"set_image_url_title\": \"圖片連結\",\n        \"unsaved_changes\": \"尚有未儲存的更改。你確定要離開嗎？\",\n        \"imagewall\": {\n            \"direction\": {\n                \"row\": \"行\",\n                \"column\": \"縱列\",\n                \"description\": \"以列或以行為基礎的佈局。\"\n            },\n            \"margin_desc\": \"每個完整影像周圍的邊界像素數目。\"\n        },\n        \"clear_o_history_confirm\": \"您確定要清除尻尻紀錄嗎？\",\n        \"clear_play_history_confirm\": \"您確定要清除播放紀錄嗎？\",\n        \"performers_found\": \"找到{count} 個演員\",\n        \"set_default_filter_confirm\": \"是否確定設定這一個過濾條件為預設?\",\n        \"overwrite_filter_warning\": \"篩選器 \\\"{entityName}\\\" 已存在，將被覆蓋。\"\n    },\n    \"dimensions\": \"解析度\",\n    \"director\": \"導演\",\n    \"disambiguation\": \"消歧義\",\n    \"display_mode\": {\n        \"grid\": \"格狀顯示\",\n        \"list\": \"條列顯示\",\n        \"tagger\": \"標記工具\",\n        \"unknown\": \"未知\",\n        \"wall\": \"預覽牆顯示\",\n        \"label_current\": \"顯示模式：{current}\"\n    },\n    \"donate\": \"贊助\",\n    \"dupe_check\": {\n        \"description\": \"低於「精確」的準確度可能需要更長的時間來計算。誤報也比較可能在較低的準確度級別上。\",\n        \"found_sets\": \"{setCount, plural, other{找到 # 組相近的短片。}}\",\n        \"options\": {\n            \"exact\": \"精確\",\n            \"high\": \"高\",\n            \"low\": \"低\",\n            \"medium\": \"中\"\n        },\n        \"search_accuracy_label\": \"搜尋準確度\",\n        \"title\": \"相近的短片\",\n        \"duration_diff\": \"最長時間差\",\n        \"duration_options\": {\n            \"any\": \"任何\",\n            \"equal\": \"相等\"\n        },\n        \"only_select_matching_codecs\": \"僅在重複群組中的所有編解碼器都符合時才選擇\",\n        \"select_all_but_largest_file\": \"選取每個重複群組中的每個檔案，最大的檔案除外\",\n        \"select_none\": \"清除選擇\",\n        \"select_oldest\": \"選取重複群組中最舊的檔案\",\n        \"select_options\": \"選擇選項…\",\n        \"select_youngest\": \"選擇重複組中最新的檔案\",\n        \"select_all_but_largest_resolution\": \"選取每個重复的群組中的每個檔案，解析度最高的檔案除外\"\n    },\n    \"duplicated_phash\": \"重複的檔案 (PHash)\",\n    \"duration\": \"長度\",\n    \"effect_filters\": {\n        \"aspect\": \"比例\",\n        \"blue\": \"藍\",\n        \"blur\": \"模糊\",\n        \"brightness\": \"亮度\",\n        \"contrast\": \"對比\",\n        \"gamma\": \"伽瑪\",\n        \"green\": \"綠\",\n        \"hue\": \"色調\",\n        \"name\": \"影片濾鏡\",\n        \"name_transforms\": \"影片變形\",\n        \"red\": \"紅\",\n        \"reset_filters\": \"重設濾鏡\",\n        \"reset_transforms\": \"重設變形\",\n        \"rotate\": \"旋轉\",\n        \"rotate_left_and_scale\": \"向左旋轉 & 調整大小\",\n        \"rotate_right_and_scale\": \"向右旋轉 & 調整大小\",\n        \"saturation\": \"飽和\",\n        \"scale\": \"大小\",\n        \"warmth\": \"暖度\"\n    },\n    \"empty_server\": \"若要啟用影片推薦，請先於伺服器中新增一些短片。\",\n    \"ethnicity\": \"人種\",\n    \"existing_value\": \"現有值\",\n    \"eye_color\": \"眼睛顏色\",\n    \"fake_tits\": \"假奶\",\n    \"false\": \"否\",\n    \"favourite\": \"收藏\",\n    \"file\": \"檔案\",\n    \"file_count\": \"檔案數量\",\n    \"file_info\": \"檔案資訊\",\n    \"file_mod_time\": \"檔案修改於\",\n    \"files\": \"檔案\",\n    \"files_amount\": \"{value} 個檔案\",\n    \"filesize\": \"檔案大小\",\n    \"filter\": \"過濾\",\n    \"filter_name\": \"過濾條件名稱\",\n    \"filters\": \"過濾條件\",\n    \"folder\": \"資料夾\",\n    \"framerate\": \"幀率\",\n    \"frames_per_second\": \"{value} 幀/秒\",\n    \"front_page\": {\n        \"types\": {\n            \"premade_filter\": \"預製過濾條件\",\n            \"saved_filter\": \"已儲存過濾條件\"\n        }\n    },\n    \"galleries\": \"圖庫\",\n    \"gallery\": \"圖庫\",\n    \"gallery_count\": \"圖庫數量\",\n    \"gender\": \"性別\",\n    \"gender_types\": {\n        \"FEMALE\": \"女性\",\n        \"INTERSEX\": \"多性別\",\n        \"MALE\": \"男性\",\n        \"NON_BINARY\": \"非二元\",\n        \"TRANSGENDER_FEMALE\": \"跨性別女性\",\n        \"TRANSGENDER_MALE\": \"跨型別男性\"\n    },\n    \"hair_color\": \"頭髮顏色\",\n    \"handy_connection_status\": {\n        \"connecting\": \"連線中\",\n        \"disconnected\": \"已斷線\",\n        \"error\": \"連接至 Handy 時出錯\",\n        \"missing\": \"遺失\",\n        \"ready\": \"已準備\",\n        \"syncing\": \"與伺服器同步中\",\n        \"uploading\": \"上傳腳本中\"\n    },\n    \"hasMarkers\": \"章節標記\",\n    \"height\": \"身高\",\n    \"height_cm\": \"高度 (cm)\",\n    \"help\": \"說明\",\n    \"ignore_auto_tag\": \"忽略自動標籤\",\n    \"image\": \"圖片\",\n    \"image_count\": \"圖片數量\",\n    \"images\": \"圖片\",\n    \"include_parent_tags\": \"包含母標籤\",\n    \"include_sub_studios\": \"包含子工作室\",\n    \"include_sub_tags\": \"包含子標籤\",\n    \"instagram\": \"Instagram\",\n    \"interactive\": \"互動性支援\",\n    \"interactive_speed\": \"互動速度\",\n    \"isMissing\": \"缺失\",\n    \"last_played_at\": \"上次播放於\",\n    \"library\": \"收藏庫\",\n    \"loading\": {\n        \"generic\": \"載入中…\",\n        \"plugins\": \"正在載入外掛程式…\"\n    },\n    \"marker_count\": \"章節標記數量\",\n    \"markers\": \"章節標記\",\n    \"measurements\": \"三圍\",\n    \"media_info\": {\n        \"audio_codec\": \"音效編碼\",\n        \"downloaded_from\": \"下載於\",\n        \"interactive_speed\": \"互動速度\",\n        \"performer_card\": {\n            \"age\": \"{age} {years_old}\",\n            \"age_context\": \"這齣戲時 {age} {years_old}\"\n        },\n        \"phash\": \"PHash\",\n        \"play_count\": \"播放次數\",\n        \"play_duration\": \"播放長度\",\n        \"stream\": \"串流連結\",\n        \"video_codec\": \"影片編碼\",\n        \"o_count\": \"O 計數\"\n    },\n    \"megabits_per_second\": \"{value} megabits/秒\",\n    \"metadata\": \"Metadata\",\n    \"name\": \"名稱\",\n    \"new\": \"新增\",\n    \"none\": \"無\",\n    \"operations\": \"動作\",\n    \"organized\": \"是否已整理\",\n    \"pagination\": {\n        \"first\": \"最前一頁\",\n        \"last\": \"最後一頁\",\n        \"next\": \"下一頁\",\n        \"previous\": \"上一頁\",\n        \"current_total\": \"第{current}頁， 共 {total}頁\"\n    },\n    \"parent_of\": \"{children} 的母物件\",\n    \"parent_studios\": \"母工作室\",\n    \"parent_tag_count\": \"母標籤數量\",\n    \"parent_tags\": \"母標籤\",\n    \"part_of\": \"{parent} 的一部分\",\n    \"path\": \"路徑\",\n    \"perceptual_similarity\": \"感知相似度 (PHash)\",\n    \"performer\": \"演員\",\n    \"performer_age\": \"演員年齡\",\n    \"performer_count\": \"演員數量\",\n    \"performer_favorite\": \"已收藏的演員\",\n    \"performer_image\": \"演員圖像\",\n    \"performer_tagger\": {\n        \"add_new_performers\": \"新增演員\",\n        \"any_names_entered_will_be_queried\": \"如果輸入的名稱有在設定的 Stash-Box 端點上找到的話，則相對應的結果將會被自動新增。只有完全符合的結果才會被視為匹配。\",\n        \"batch_add_performers\": \"大量新增演員\",\n        \"batch_update_performers\": \"大量更新演員\",\n        \"current_page\": \"目前頁面\",\n        \"failed_to_save_performer\": \"無法新增演員「{performer}」\",\n        \"name_already_exists\": \"名稱已存在\",\n        \"network_error\": \"網路錯誤\",\n        \"no_results_found\": \"找不到結果。\",\n        \"number_of_performers_will_be_processed\": \"將自動處理 {performer_count} 個演員\",\n        \"performer_already_tagged\": \"演員資料早已新增\",\n        \"performer_selection\": \"選取演員\",\n        \"performer_successfully_tagged\": \"成功新增演員資料：\",\n        \"query_all_performers_in_the_database\": \"查詢所有資料庫中的演員\",\n        \"refresh_tagged_performers\": \"重新整理已新增的演員資料\",\n        \"refreshing_will_update_the_data\": \"重新整理資料將會把任何現有的演員資料重新與 Stash-Box 端點上的資料同步。\",\n        \"status_tagging_job_queued\": \"狀態：已排成資料標記\",\n        \"status_tagging_performers\": \"狀態：新增演員資料中\",\n        \"tag_status\": \"標記狀態\",\n        \"to_use_the_performer_tagger\": \"在使用演員標記工具前，請先設定 Stash-Box 端點。\",\n        \"untagged_performers\": \"未標記的演員\",\n        \"update_performer\": \"更新演員資料\",\n        \"update_performers\": \"更新演員\",\n        \"updating_untagged_performers_description\": \"更新未標記的演員將試著把尚有 stashid 的演員在 Stash-Box 上找尋對應的資料，並將其資料加入至本機的 Metadata 中。\"\n    },\n    \"performer_tags\": \"演員標籤\",\n    \"performers\": \"演員\",\n    \"piercings\": \"穿洞\",\n    \"play_count\": \"播放次數\",\n    \"play_duration\": \"播放時數\",\n    \"primary_file\": \"主檔案\",\n    \"queue\": \"佇列\",\n    \"random\": \"隨機\",\n    \"rating\": \"評比\",\n    \"recently_added_objects\": \"最近新增的{objects}\",\n    \"recently_released_objects\": \"最近釋出的{objects}\",\n    \"release_notes\": \"更新日誌\",\n    \"resolution\": \"解析度\",\n    \"resume_time\": \"恢復播放時間\",\n    \"scene\": \"短片\",\n    \"sceneTagger\": \"短片標籤器\",\n    \"scene_code\": \"番號\",\n    \"scene_count\": \"短片數量\",\n    \"scene_created_at\": \"短片建立於\",\n    \"scene_date\": \"短片日期\",\n    \"scene_id\": \"短片 ID\",\n    \"scene_tags\": \"短片標籤\",\n    \"scene_updated_at\": \"短片更新於\",\n    \"scenes\": \"短片\",\n    \"scenes_updated_at\": \"短片更新時間\",\n    \"search_filter\": {\n        \"name\": \"篩選\",\n        \"saved_filters\": \"已儲存的過濾條件\",\n        \"update_filter\": \"更新篩選\",\n        \"edit_filter\": \"編輯篩選器\",\n        \"more_filter_criteria\": \"+{count} 更多\",\n        \"search_term\": \"搜尋詞組\"\n    },\n    \"seconds\": \"秒\",\n    \"settings\": \"設定\",\n    \"setup\": {\n        \"confirm\": {\n            \"almost_ready\": \"設定即將完成，請再次確認以下設定是否正確。若有任何不正確的內容，您可以按下「上一步」以進行修改。若一切無誤，請點選下方的「確認」按鈕以完成設定。\",\n            \"configuration_file_location\": \"設定檔案路徑：\",\n            \"database_file_path\": \"資料庫檔案路徑\",\n            \"generated_directory\": \"生成媒體路徑\",\n            \"nearly_there\": \"快好了！\",\n            \"stash_library_directories\": \"Stash 多媒體檔案路徑\",\n            \"cache_directory\": \"快取目錄\",\n            \"blobs_directory\": \"二進位資料目錄\",\n            \"blobs_use_database\": \"<使用資料庫中>\"\n        },\n        \"creating\": {\n            \"creating_your_system\": \"建立系統中\"\n        },\n        \"errors\": {\n            \"something_went_wrong\": \"噢不！好像出了些問題！\",\n            \"something_went_wrong_description\": \"如果您所輸入的資料看起來有問題，請點選上一步返回以更正您的資料。否則，請在 {githubLink} 上提出錯誤或在 {discordLink} 中尋求幫助。\",\n            \"something_went_wrong_while_setting_up_your_system\": \"設定系統時出了些問題。以下是我們收到的錯誤：{error}\",\n            \"unexpected_error\": \"發生一個無法辨認的錯誤: {error}\",\n            \"unable_to_retrieve_system_status\": \"無法取得系統狀態\"\n        },\n        \"folder\": {\n            \"file_path\": \"檔案路徑\",\n            \"up_dir\": \"往上一層\"\n        },\n        \"github_repository\": \"GitHub 版本庫\",\n        \"migrate\": {\n            \"backup_database_path_leave_empty_to_disable_backup\": \"備份資料庫路徑（留空以關閉備份）：\",\n            \"backup_recommended\": \"在進行遷移之前，建議您先備份現有資料庫檔案。如果需要，我們可以為您完成此操作，並將您的備份儲存於 <code>{defaultBackupPath}</code>。\",\n            \"migrating_database\": \"遷移資料庫中\",\n            \"migration_failed\": \"遷移失敗\",\n            \"migration_failed_error\": \"遷移資料庫時遇到以下錯誤：\",\n            \"migration_failed_help\": \"請進行所需的更正並重試。否則，請在 {githubLink} 上提出問題或在 {discordLink} 中尋求協助。\",\n            \"migration_irreversible_warning\": \"架構遷移是不可逆的過程。遷移後，您的資料庫將無法與先前的 Stash 版本相容。\",\n            \"migration_notes\": \"遷移說明\",\n            \"migration_required\": \"需要遷移\",\n            \"perform_schema_migration\": \"執行架構遷移\",\n            \"schema_too_old\": \"您目前的資料庫版本為 <strong>{databaseSchema}</strong>，需要遷移至版本 <strong>{appSchema}</strong>。若不進行資料庫遷移，此版本的 Stash 將無法執行；若您仍不想進行資料庫遷移，您則需降級至與此資料庫版本相符的 Stash 版本。\"\n        },\n        \"paths\": {\n            \"database_filename_empty_for_default\": \"資料庫檔案名稱（留空以使用預設）\",\n            \"description\": \"接下來，我們需要確定可以在哪裡找到你的內容，在哪裡儲存資料庫及其生成檔案等等。如果需要，您稍後可以再更改這些設定。\",\n            \"path_to_generated_directory_empty_for_default\": \"生成媒體資料夾路徑（留空以使用預設）\",\n            \"set_up_your_paths\": \"設定你的路徑\",\n            \"stash_alert\": \"您尚未選取任何路徑，Stash 將無法掃描你的檔案。你確定要繼續嗎？\",\n            \"where_can_stash_store_its_database\": \"Stash 可以在哪裡儲存資料庫？\",\n            \"where_can_stash_store_its_database_description\": \"Stash 使用 SQLite 資料庫來儲存您內容的資料。預設情況下，Stash 將在您的設定檔路徑下以 <code>stash-go.sqlite</code> 這個檔案來儲存此資料庫內容。如果您想要更改此設定，請在此輸入您所想要的絕對或相對路徑（相對於目前工作目錄）。\",\n            \"where_can_stash_store_its_generated_content\": \"Stash 可以在哪裡儲存其生成內容？\",\n            \"where_can_stash_store_its_generated_content_description\": \"為提供縮圖、預覽和其他預覽資料，Stash 將自動生成圖片和影片資訊。這包括不支援的檔案格式之轉檔。預設情況下，Stash 將在包含您設定檔案的資料夾中建立一個新的 <code>generated</code> 資料夾。如果要更改此生成媒體的儲存位置，請在此輸入絕對或相對路徑（相對於目前工作目錄）。如果該資料夾不存在，Stash 將自動建立此目錄。\",\n            \"where_is_your_porn_located\": \"你的內容都藏哪？\",\n            \"where_is_your_porn_located_description\": \"在此選擇你視訊及圖片的資料夾，Stash 將在掃描影片及圖片時使用這些路徑。\",\n            \"path_to_blobs_directory_empty_for_default\": \"blobs 目錄的路徑 (預設為空)\",\n            \"path_to_cache_directory_empty_for_default\": \"快取目錄的路徑 (預設為空)\",\n            \"store_blobs_in_database\": \"將 blobs 儲存到資料庫\",\n            \"where_can_stash_store_blobs\": \"Stash 可以在哪裡儲存資料庫的二進位資料？\",\n            \"where_can_stash_store_blobs_description\": \"Stash可以在資料庫或檔案系統中儲存二進位制資料，如場景封面、表演者、工作室和標籤影象。預設情況下，它會將這些資料儲存在檔案系統中包含配置檔案的目錄中的子目錄 <code>blobs</code> 中。如果要更改此項，請輸入絕對或相對（到當前工作目錄）路徑。如果Stash不存在此目錄，它將建立此目錄。\",\n            \"where_can_stash_store_cache_files\": \"Stash可以在哪裡儲存快取檔案？\",\n            \"where_can_stash_store_blobs_description_addendum\": \"或者，您可以將此資料儲存在資料庫中。<strong>注意：</strong>這將增加資料庫檔案的大小，並增加資料庫遷移時間。\",\n            \"where_can_stash_store_cache_files_description\": \"為了使HLS/DASH實時轉碼等功能正常執行，Stash需要一個臨時檔案的快取目錄。預設情況下，Stash將在包含您的配置檔案的目錄中建立一個<code>cache</code>目錄。如果您想更改此設定，請輸入絕對或相對（與當前工作目錄）路徑。如果Stash不存在，它將建立此目錄。\",\n            \"where_can_stash_store_its_database_warning\": \"警告：將資料庫儲存在與執行Stash的系統不同的系統上（例如，將資料庫儲存在NAS上，同時在另一台計算機上執行Stash伺服器）是<strong>不受支援的</strong>！SQLite不適用於跨網路使用，嘗試這樣做很容易導致整個資料庫損壞。\"\n        },\n        \"stash_setup_wizard\": \"Stash 安裝精靈\",\n        \"success\": {\n            \"getting_help\": \"尋求協助\",\n            \"help_links\": \"如果您有任何問題或建議，請隨時在 {githubLink} 中建立新的議題（Issue），或在 {discordLink} 中詢求協助。\",\n            \"in_app_manual_explained\": \"我們鼓勵您查看本程式內建的說明手冊，您可在本程式的右上角的圖案中開啟此手冊，此圖案如下：{icon}\",\n            \"next_config_step_one\": \"接下來您將被帶到設定頁面。此頁面將允許您自訂要包含和排除的文件，設定使用者名稱和密碼以保護您的系統，以及一大堆其他選項。\",\n            \"next_config_step_two\": \"當您對這些設定感到滿意時，您可以點選 <code>{localized_task}</code> 開始將您的內容掃描到 Stash，然後點擊 <code>{localized_scan}</code>。\",\n            \"open_collective\": \"您可查看我們的 {open_collective_link}，了解您可以如何為 Stash 的持續發展做出貢獻。\",\n            \"support_us\": \"支持我們\",\n            \"thanks_for_trying_stash\": \"感謝您使用 Stash！\",\n            \"welcome_contrib\": \"我們也歡迎以程式碼（錯誤修復、改進和新功能實作）、測試、錯誤報告、改進和功能請求以及使用者支援的形式做出貢獻。詳情請見程式內說明的 Contribution（貢獻）頁面。\",\n            \"your_system_has_been_created\": \"成功！您的系統已安裝完成！\",\n            \"missing_ffmpeg\": \"您缺少所需的<code>ffmpeg</code>二進位制檔案。您可以通過選中下面的框自動將其下載到您的配置目錄中。或者，您可以在系統設定中提供<code>ffmpeg</code>和<code>ffprobe</code>二進位制檔案的路徑。Stash必須存在這些二進位制檔案才能執行。\",\n            \"download_ffmpeg\": \"下載 ffmpeg\"\n        },\n        \"welcome\": {\n            \"config_path_logic_explained\": \"Stash 於執行時，會先在執行目錄中找尋其設定檔案 (<code>config.yml</code>)，當找不到時，它將會再試著使用 <code>$HOME/.stash/config.yml</code>（於 Windows 中，此路徑為 <code>%USERPROFILE%\\\\.stash\\\\config.yml</code>）。您也可在執行 Stash 時透過 <code>-c '<設定檔路徑>'</code> 提供設定路徑，或者 <code>--config '<path to config file>'</code>。\",\n            \"in_current_stash_directory\": \"於 <code>{path}</code> 資料夾中：\",\n            \"in_the_current_working_directory\": \"於目前的工作路徑 <code>{path}</code>：\",\n            \"next_step\": \"如果您已準備好繼續安裝本程式，請選擇您想要儲存設定檔案的位置，然後點擊「下一步」。\",\n            \"store_stash_config\": \"您希望在哪裡儲存您的 Stash 設定呢？\",\n            \"unable_to_locate_config\": \"如果看到此畫面的話，則代表 Stash 無法找到先前的設定檔案。本安裝畫面將帶您建立新的設定檔案。\",\n            \"unexpected_explained\": \"如果您覺得此畫面不應該出現的話，請試著在正確的 Stash 資料夾中重新啟動本程式，或者將設定路徑設為 <code>-c</code>。\",\n            \"in_the_current_working_directory_disabled\": \"在<code>{path}</code>中，工作目錄：\",\n            \"in_the_current_working_directory_disabled_macos\": \"當正在執行 <code>Stash.app</code>時不支援,<br></br>在工作目錄中執行 <code>stash-macos</code>來設定\"\n        },\n        \"welcome_specific_config\": {\n            \"config_path\": \"Stash 將使用下列設定檔案路徑：<code>{path}</code>\",\n            \"next_step\": \"當您準備繼續設定時，點擊「下一步」。\",\n            \"unable_to_locate_specified_config\": \"如果看到此畫面的話，則代表 Stash 無法找到您在命令列所提供的設定檔路徑。本安裝畫面將帶您建立新的設定檔案。\"\n        },\n        \"welcome_to_stash\": \"歡迎使用 Stash\"\n    },\n    \"stash_id\": \"Stash ID\",\n    \"stash_id_endpoint\": \"Stash ID 端點\",\n    \"stash_ids\": \"Stash IDs\",\n    \"stashbox\": {\n        \"go_review_draft\": \"到 {endpoint_name} 預覽草稿。\",\n        \"selected_stash_box\": \"已選擇的 Stash-Box 端點\",\n        \"submission_failed\": \"提交失敗\",\n        \"submission_successful\": \"提交成功\",\n        \"submit_update\": \"已存在於 {endpoint_name}\",\n        \"source\": \"Stash-Box源\"\n    },\n    \"statistics\": \"統計資訊\",\n    \"stats\": {\n        \"image_size\": \"圖片大小\",\n        \"scenes_duration\": \"短片長度\",\n        \"scenes_size\": \"短片大小\",\n        \"scenes_played\": \"已播放的短片\",\n        \"total_o_count\": \"總O計數\",\n        \"total_play_count\": \"總播放數\",\n        \"total_play_duration\": \"總播放時長\"\n    },\n    \"status\": \"狀態：{statusText}\",\n    \"studio\": \"工作室\",\n    \"studio_depth\": \"深度 (留空則篩選全部)\",\n    \"studios\": \"工作室\",\n    \"sub_tag_count\": \"子標籤數量\",\n    \"sub_tag_of\": \"{parent} 的子標籤\",\n    \"sub_tags\": \"子標籤\",\n    \"subsidiary_studios\": \"子工作室\",\n    \"synopsis\": \"概要\",\n    \"tag\": \"標籤\",\n    \"tag_count\": \"標籤數量\",\n    \"tags\": \"標籤\",\n    \"tattoos\": \"刺青\",\n    \"title\": \"標題\",\n    \"toast\": {\n        \"added_entity\": \"已新增{singularEntity}\",\n        \"added_generation_job_to_queue\": \"已將『生成作業』加入至工作排程\",\n        \"created_entity\": \"已建立{entity}\",\n        \"default_filter_set\": \"已設定預設過濾選項\",\n        \"delete_past_tense\": \"已刪除{singularEntity}\",\n        \"generating_screenshot\": \"產生截圖中…\",\n        \"merged_scenes\": \"合併的短片\",\n        \"merged_tags\": \"已合併的標籤\",\n        \"reassign_past_tense\": \"已重新指定檔案\",\n        \"removed_entity\": \"已刪除{singularEntity}\",\n        \"rescanning_entity\": \"重新掃描{singularEntity}中…\",\n        \"saved_entity\": \"已儲存{entity}\",\n        \"started_auto_tagging\": \"自動套用標籤中\",\n        \"started_generating\": \"生成檔案中\",\n        \"started_importing\": \"匯入中\",\n        \"updated_entity\": \"已更新{entity}\",\n        \"image_index_too_large\": \"錯誤：圖片索引大於圖庫中的圖片數量\"\n    },\n    \"total\": \"總計\",\n    \"true\": \"是\",\n    \"twitter\": \"Twitter\",\n    \"type\": \"種類\",\n    \"updated_at\": \"更新於\",\n    \"url\": \"連結\",\n    \"videos\": \"影片\",\n    \"view_all\": \"顯示全部\",\n    \"weight\": \"體重\",\n    \"weight_kg\": \"體重 (kg)\",\n    \"years_old\": \"歲\",\n    \"zip_file_count\": \"壓縮檔內容數量\",\n    \"circumcised_types\": {\n        \"UNCUT\": \"未割\",\n        \"CUT\": \"已割\"\n    },\n    \"datetime_format\": \"YYYY-MM-DD HH:MM\",\n    \"appears_with\": \"共同演出\",\n    \"audio_codec\": \"音訊編碼\",\n    \"circumcised\": \"已切除包皮\",\n    \"date_format\": \"YYYY-MM-DD\",\n    \"connection_monitor\": {\n        \"websocket_connection_failed\": \"無法建立 websocket 連線：詳細資訊請參閱瀏覽器主控台\",\n        \"websocket_connection_reestablished\": \"Websocket 連線已重新建立\"\n    },\n    \"distance\": \"距離\",\n    \"errors\": {\n        \"header\": \"錯誤\",\n        \"image_index_greater_than_zero\": \"影象索引必須大於0\",\n        \"invalid_javascript_string\": \"無效的 javascript 程式碼: {error}\",\n        \"invalid_json_string\": \"無效的 JSON 字串： {error}\",\n        \"something_went_wrong\": \"出错了。\",\n        \"loading_type\": \"載入{type}時出錯\",\n        \"lazy_component_error_help\": \"如果您最近升級了 Stash，請重新載入頁面或清除瀏覽器cache。\",\n        \"custom_fields\": {\n            \"field_name_length\": \"欄位名稱必須少於 65 個字元\",\n            \"duplicate_field\": \"已經有其他同名的欄位\",\n            \"field_name_required\": \"必須輸入欄位名稱\",\n            \"field_name_whitespace\": \"欄位名稱的開頭與結尾不可有空白\"\n        }\n    },\n    \"group\": \"群組\",\n    \"group_count\": \"群組計數\",\n    \"group_scene_number\": \"短片編號\",\n    \"groups\": \"群組\",\n    \"hasChapters\": \"章節\",\n    \"history\": \"歷史紀錄\",\n    \"image_index\": \"圖片#\",\n    \"index_of_total\": \"第{index}個，共 {total}個\",\n    \"last_o_at\": \"最後 O 在\",\n    \"package_manager\": {\n        \"source\": {\n            \"name\": \"名稱\",\n            \"url\": \"源 URL\",\n            \"local_path\": {\n                \"heading\": \"本地路徑\",\n                \"description\": \"儲存此源的軟體包的相對路徑。請注意，變更此路徑需要手動移動軟體包。\"\n            }\n        },\n        \"uninstall\": \"解除安裝\",\n        \"unknown\": \"<未知>\",\n        \"update\": \"更新\",\n        \"version\": \"版本\",\n        \"add_source\": \"新增源\",\n        \"check_for_updates\": \"檢查更新\",\n        \"confirm_delete_source\": \"您確定要刪除源{name} ({url})嗎？\",\n        \"confirm_uninstall\": \"您確定要解除安裝{number}軟體包嗎？\",\n        \"description\": \"描述\",\n        \"edit_source\": \"編輯源\",\n        \"hide_unselected\": \"隱藏未選取的\",\n        \"install\": \"安装\",\n        \"installed_version\": \"已安裝版本\",\n        \"latest_version\": \"最新版本\",\n        \"no_packages\": \"未找到軟體包\",\n        \"no_sources\": \"未配置源\",\n        \"no_upgradable\": \"未找到可升級軟體包\",\n        \"package\": \"軟體包\",\n        \"required_by\": \"為 {packages} 所需\",\n        \"selected_only\": \"僅選定\",\n        \"show_all\": \"顯示全部\"\n    },\n    \"photographer\": \"攝影師\",\n    \"play_history\": \"播放歷史\",\n    \"playdate_recorded_no\": \"無播放日期記錄\",\n    \"second\": \"秒\",\n    \"studio_count\": \"工作室計數\",\n    \"studio_and_parent\": \"工作室與其上級\",\n    \"studio_tagger\": {\n        \"add_new_studios\": \"新增工作室\",\n        \"batch_update_studios\": \"批量更新工作室\",\n        \"config\": {\n            \"create_parent_desc\": \"建立缺失的母工作室，或使用確切的名稱匹配標記和更新現有母工作室的資料/影象\",\n            \"create_parent_label\": \"建立家長工作室\"\n        },\n        \"any_names_entered_will_be_queried\": \"如果輸入的名稱有在設定的 Stash-Box 端點上找到的話，則相對應的結果將會被自動新增。只有完全符合的結果才會被視為匹配。\",\n        \"batch_add_studios\": \"批量添加工作室\",\n        \"studio_already_tagged\": \"已經設定標籤的工作室\",\n        \"refreshing_will_update_the_data\": \"重新整理後將更新所有已在此stash-box例項被標記的工作室的资料。\",\n        \"status_tagging_job_queued\": \"狀態：標籤設定任務排隊中\",\n        \"status_tagging_studios\": \"狀態：給工作室設定標籤中\",\n        \"untagged_studios\": \"未設定標籤的工作室\",\n        \"update_studio\": \"更新工作室\",\n        \"update_studios\": \"更新工作室\",\n        \"updating_untagged_studios_description\": \"更新未標記的工作室將嘗試匹配任何缺乏stashid的工作室並更新metadata。\",\n        \"studio_selection\": \"工作室選擇\",\n        \"studio_successfully_tagged\": \"工作室已成功設定標籤\",\n        \"tag_status\": \"標籤狀態\",\n        \"to_use_the_studio_tagger\": \"使用工作室標籤器需要配置一個stash-box例項。\",\n        \"create_or_tag_parent_studios\": \"建立缺失的母工作室或標記已存在的母工作室\",\n        \"current_page\": \"目前頁面\",\n        \"failed_to_save_studio\": \"儲存工作室“{studio}”失敗\",\n        \"name_already_exists\": \"名稱已存在\",\n        \"network_error\": \"網路錯誤\",\n        \"number_of_studios_will_be_processed\": \"{studio_count}個工作室將被處理\",\n        \"query_all_studios_in_the_database\": \"此資料庫中的所有工作室\",\n        \"refresh_tagged_studios\": \"重新整理已設定標籤的工作室\",\n        \"no_results_found\": \"沒有找到結果。\"\n    },\n    \"include_sub_studio_content\": \"包括子工作室內容\",\n    \"include_sub_tag_content\": \"包含子標籤內容\",\n    \"o_count\": \"O 計數\",\n    \"o_history\": \"o 歷史記錄\",\n    \"odate_recorded_no\": \"無O記錄日期\",\n    \"orientation\": \"方向\",\n    \"parent_studio\": \"母工作室\",\n    \"penis\": \"陰莖\",\n    \"penis_length\": \"陰莖長度\",\n    \"penis_length_cm\": \"陰莖長度（公分）\",\n    \"plays\": \"{value} 次播放\",\n    \"primary_tag\": \"主要標籤\",\n    \"studio_tags\": \"工作室標籤\",\n    \"subsidiary_studio_count\": \"子工作室數量\",\n    \"tag_parent_tooltip\": \"有父母標籤\",\n    \"validation\": {\n        \"required\": \"${path}是必填欄位\",\n        \"date_invalid_form\": \"${path}必須是YYYY-MM-DD形式\",\n        \"unique\": \"${path}必須是唯一的\",\n        \"blank\": \"${path}不能為空\",\n        \"end_time_before_start_time\": \"結束時間必須大於或等於開始時間\"\n    },\n    \"video_codec\": \"影片編碼\",\n    \"tag_sub_tag_tooltip\": \"有子標籤\",\n    \"time\": \"時間\",\n    \"unknown_date\": \"未知日期\",\n    \"urls\": \"支援網址\",\n    \"containing_group_count\": \"包含群組計數\",\n    \"containing_groups\": \"包含群組\",\n    \"include_sub_group_content\": \"包括子組內容\",\n    \"sub_group_count\": \"子組計數\",\n    \"sub_group_of\": \"{parent}的子組\",\n    \"sub_group_order\": \"子組順序\",\n    \"sub_groups\": \"子組\",\n    \"containing_group\": \"包含群組\",\n    \"sub_group\": \"子組\",\n    \"criterion_modifier_values\": {\n        \"none\": \"不存在\",\n        \"only\": \"僅\",\n        \"any_of\": \"其中任意\",\n        \"any\": \"任意\"\n    },\n    \"include_sub_groups\": \"包括子組\",\n    \"time_end\": \"結束時間\",\n    \"custom_fields\": {\n        \"field\": \"欄位\",\n        \"title\": \"自訂欄位\",\n        \"value\": \"值\",\n        \"criteria_format_string_others\": \"{criterion} (custom field) 條件：{modifierString} {valueString} (+{others} others)\",\n        \"criteria_format_string\": \"{criterion} (custom field) 條件：{modifierString} {valueString}\"\n    },\n    \"login\": {\n        \"login\": \"登入\",\n        \"username\": \"使用者名稱\",\n        \"password\": \"密碼\",\n        \"invalid_credentials\": \"無效的使用者名稱或是密碼\",\n        \"internal_error\": \"系統發生未預期的內部錯誤。詳情請參考日誌\"\n    },\n    \"sort_name\": \"分類名稱\",\n    \"eta\": \"預估剩餘時間\",\n    \"age_on_date\": \"在{age}歲時製作\",\n    \"scenes_duration\": \"場景持續時間\"\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/captions.ts",
    "content": "import { CriterionModifier } from \"src/core/generated-graphql\";\nimport { languageMap, valueToCode } from \"src/utils/caption\";\nimport { ModifierCriterionOption, StringCriterion } from \"./criterion\";\n\nconst languageStrings = Array.from(languageMap.values());\n\nexport const CaptionsCriterionOption = new ModifierCriterionOption({\n  messageID: \"captions\",\n  type: \"captions\",\n  modifierOptions: [\n    CriterionModifier.Includes,\n    CriterionModifier.Excludes,\n    CriterionModifier.IsNull,\n    CriterionModifier.NotNull,\n  ],\n  defaultModifier: CriterionModifier.Includes,\n  options: languageStrings,\n  makeCriterion: () => new CaptionCriterion(),\n});\n\nexport class CaptionCriterion extends StringCriterion {\n  constructor() {\n    super(CaptionsCriterionOption);\n  }\n\n  public toCriterionInput() {\n    const value = valueToCode(this.value) ?? \"\";\n\n    return {\n      value,\n      modifier: this.modifier,\n    };\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/circumcised.ts",
    "content": "import {\n  CircumcisionCriterionInput,\n  CircumcisedEnum,\n  CriterionModifier,\n} from \"src/core/generated-graphql\";\nimport { circumcisedStrings, stringToCircumcised } from \"src/utils/circumcised\";\nimport { ModifierCriterionOption, MultiStringCriterion } from \"./criterion\";\n\nexport const CircumcisedCriterionOption = new ModifierCriterionOption({\n  messageID: \"circumcised\",\n  type: \"circumcised\",\n  modifierOptions: [\n    CriterionModifier.Includes,\n    CriterionModifier.Excludes,\n    CriterionModifier.IsNull,\n    CriterionModifier.NotNull,\n  ],\n  defaultModifier: CriterionModifier.Includes,\n  options: circumcisedStrings,\n  makeCriterion: () => new CircumcisedCriterion(),\n});\n\nexport class CircumcisedCriterion extends MultiStringCriterion {\n  constructor() {\n    super(CircumcisedCriterionOption);\n  }\n\n  public toCriterionInput(): CircumcisionCriterionInput {\n    const value = this.value.map((v) =>\n      stringToCircumcised(v)\n    ) as CircumcisedEnum[];\n\n    return {\n      value,\n      modifier: this.modifier,\n    };\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/country.ts",
    "content": "import { IntlShape } from \"react-intl\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\nimport { getCountryByISO } from \"src/utils/country\";\nimport { StringCriterion, StringCriterionOption } from \"./criterion\";\n\nexport const CountryCriterionOption = new StringCriterionOption({\n  messageID: \"country\",\n  type: \"country\",\n  makeCriterion: () => new CountryCriterion(),\n});\n\nexport class CountryCriterion extends StringCriterion {\n  constructor() {\n    super(CountryCriterionOption);\n  }\n\n  protected getLabelValue(intl: IntlShape) {\n    if (\n      this.modifier === CriterionModifier.Equals ||\n      this.modifier === CriterionModifier.NotEquals\n    ) {\n      return getCountryByISO(this.value, intl.locale) ?? this.value;\n    }\n\n    return super.getLabelValue(intl);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/criterion.ts",
    "content": "/* eslint @typescript-eslint/no-unused-vars: [\"error\", { \"argsIgnorePattern\": \"^_\" }] */\nimport { IntlShape } from \"react-intl\";\nimport {\n  CriterionModifier,\n  HierarchicalMultiCriterionInput,\n  IntCriterionInput,\n  MultiCriterionInput,\n  TimestampCriterionInput,\n  ConfigDataFragment,\n  DateCriterionInput,\n} from \"src/core/generated-graphql\";\nimport TextUtils from \"src/utils/text\";\nimport {\n  CriterionType,\n  IDuplicationValue,\n  IHierarchicalLabelValue,\n  ILabeledId,\n  INumberValue,\n  IOptionType,\n  IStashIDValue,\n  IDateValue,\n  ITimestampValue,\n  ILabeledValueListValue,\n  IPhashDistanceValue,\n  IRangeValue,\n} from \"../types\";\n\nexport type Option = string | number | IOptionType;\nexport type CriterionValue =\n  | string\n  | boolean\n  | string[]\n  | ILabeledId[]\n  | IHierarchicalLabelValue\n  | ILabeledValueListValue\n  | INumberValue\n  | IStashIDValue\n  | IDateValue\n  | ITimestampValue\n  | IPhashDistanceValue\n  | IDuplicationValue;\n\nexport interface ISavedCriterion<T> {\n  modifier: CriterionModifier;\n  value: T | undefined;\n}\n\nconst modifierMessageIDs = {\n  [CriterionModifier.Equals]: \"criterion_modifier.equals\",\n  [CriterionModifier.NotEquals]: \"criterion_modifier.not_equals\",\n  [CriterionModifier.GreaterThan]: \"criterion_modifier.greater_than\",\n  [CriterionModifier.LessThan]: \"criterion_modifier.less_than\",\n  [CriterionModifier.IsNull]: \"criterion_modifier.is_null\",\n  [CriterionModifier.NotNull]: \"criterion_modifier.not_null\",\n  [CriterionModifier.Includes]: \"criterion_modifier.includes\",\n  [CriterionModifier.IncludesAll]: \"criterion_modifier.includes_all\",\n  [CriterionModifier.Excludes]: \"criterion_modifier.excludes\",\n  [CriterionModifier.MatchesRegex]: \"criterion_modifier.matches_regex\",\n  [CriterionModifier.NotMatchesRegex]: \"criterion_modifier.not_matches_regex\",\n  [CriterionModifier.Between]: \"criterion_modifier.between\",\n  [CriterionModifier.NotBetween]: \"criterion_modifier.not_between\",\n};\n\nexport abstract class Criterion {\n  public criterionOption: CriterionOption;\n\n  constructor(type: CriterionOption) {\n    this.criterionOption = type;\n  }\n\n  public isValid(): boolean {\n    return true;\n  }\n\n  public clone() {\n    const ret = Object.assign(Object.create(Object.getPrototypeOf(this)), this);\n    ret.cloneValues();\n    return ret;\n  }\n\n  protected cloneValues() {}\n\n  public abstract getLabel(intl: IntlShape, sfwContentMode?: boolean): string;\n\n  public getId(): string {\n    return `${this.criterionOption.type}`;\n  }\n\n  public abstract toQueryParams(): Record<string, unknown>;\n\n  // fromDecodedParams is used to set the criterion from the query string\n  // i is the decoded parameter object\n  public abstract fromDecodedParams(i: Record<string, unknown>): void;\n\n  public abstract applyToCriterionInput(input: Record<string, unknown>): void;\n\n  public abstract applyToSavedCriterion(input: Record<string, unknown>): void;\n  public abstract setFromSavedCriterion(criterion: unknown): void;\n}\n\n// V = criterion value type\nexport abstract class ModifierCriterion<\n  V extends CriterionValue\n> extends Criterion {\n  protected _modifier!: CriterionModifier;\n  public get modifier(): CriterionModifier {\n    return this._modifier;\n  }\n  public set modifier(value: CriterionModifier) {\n    this._modifier = value;\n  }\n\n  protected _value!: V;\n  public get value(): V {\n    return this._value;\n  }\n  public set value(newValue: V) {\n    this._value = newValue;\n  }\n\n  public isValid(): boolean {\n    return true;\n  }\n\n  protected abstract getLabelValue(intl: IntlShape): string;\n\n  constructor(type: ModifierCriterionOption, value: V) {\n    super(type);\n    this.modifier = type.defaultModifier;\n    this.value = value;\n  }\n\n  public modifierCriterionOption() {\n    return this.criterionOption as ModifierCriterionOption;\n  }\n\n  public clone() {\n    const ret = Object.assign(Object.create(Object.getPrototypeOf(this)), this);\n    ret.cloneValues();\n    return ret;\n  }\n\n  protected cloneValues() {}\n\n  public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) {\n    const modifierMessageID = modifierMessageIDs[modifier];\n\n    return modifierMessageID\n      ? intl.formatMessage({ id: modifierMessageID })\n      : \"\";\n  }\n\n  public getLabel(intl: IntlShape, sfwContentMode: boolean = false): string {\n    const modifierString = ModifierCriterion.getModifierLabel(\n      intl,\n      this.modifier\n    );\n    let valueString = \"\";\n\n    if (\n      this.modifier !== CriterionModifier.IsNull &&\n      this.modifier !== CriterionModifier.NotNull\n    ) {\n      valueString = this.getLabelValue(intl);\n    }\n\n    const messageID = !sfwContentMode\n      ? this.criterionOption.messageID\n      : this.criterionOption.sfwMessageID ?? this.criterionOption.messageID;\n\n    return intl.formatMessage(\n      { id: \"criterion_modifier.format_string\" },\n      {\n        criterion: intl.formatMessage({ id: messageID }),\n        modifierString,\n        valueString,\n      }\n    );\n  }\n\n  public toQueryParams(): Record<string, unknown> {\n    let encodedCriterion: Record<string, unknown> = {\n      type: this.criterionOption.type,\n      modifier: this.modifier,\n    };\n\n    if (\n      this.modifier !== CriterionModifier.IsNull &&\n      this.modifier !== CriterionModifier.NotNull\n    ) {\n      encodedCriterion.value = this.encodeValue();\n    }\n\n    return encodedCriterion;\n  }\n\n  protected encodeValue(): unknown {\n    return this.value;\n  }\n\n  protected decodeValue(v: unknown) {\n    if (v !== undefined && v !== null) {\n      this.value = v as V;\n    }\n  }\n\n  public fromDecodedParams(i: unknown): void {\n    // use same logic as from saved criterion by default\n    const c = i as ISavedCriterion<V>;\n    this.modifier = c.modifier;\n    this.decodeValue(c.value);\n  }\n\n  public setFromSavedCriterion(criterion: unknown) {\n    const c = criterion as ISavedCriterion<V>;\n    if (c.value !== undefined && c.value !== null) {\n      this.value = c.value;\n    }\n    this.modifier = c.modifier;\n  }\n\n  public applyToCriterionInput(input: Record<string, unknown>) {\n    input[this.criterionOption.type] = this.toCriterionInput();\n  }\n\n  // TODO - saved criterion _should_ be criterion input\n  // kicking this can down the road a little further\n  public applyToSavedCriterion(input: Record<string, unknown>): void {\n    input[this.criterionOption.type] = {\n      value: this.value,\n      modifier: this.modifier,\n    };\n  }\n\n  protected toCriterionInput(): unknown {\n    return {\n      value: this.value,\n      modifier: this.modifier,\n    };\n  }\n}\n\nexport type InputType =\n  | \"number\"\n  | \"text\"\n  | \"performers\"\n  | \"studios\"\n  | \"tags\"\n  | \"performer_tags\"\n  | \"scenes\"\n  | \"scene_tags\"\n  | \"groups\"\n  | \"galleries\"\n  | \"folders\"\n  | undefined;\n\ntype MakeCriterionFn = (\n  o: CriterionOption,\n  config?: ConfigDataFragment\n) => Criterion;\n\ninterface ICriterionOptionParams {\n  messageID: string;\n  type: CriterionType;\n  makeCriterion: MakeCriterionFn;\n  hidden?: boolean;\n  sfwMessageID?: string;\n}\n\nexport class CriterionOption {\n  public readonly type: CriterionType;\n  public readonly messageID: string;\n  public readonly makeCriterionFn: MakeCriterionFn;\n  public readonly sfwMessageID?: string;\n\n  // used for legacy criteria that are not shown in the UI\n  public readonly hidden: boolean = false;\n\n  constructor(options: ICriterionOptionParams) {\n    this.type = options.type;\n    this.messageID = options.messageID;\n    this.makeCriterionFn = options.makeCriterion;\n    this.hidden = options.hidden ?? false;\n    this.sfwMessageID = options.sfwMessageID;\n  }\n\n  public makeCriterion(config?: ConfigDataFragment) {\n    return this.makeCriterionFn(this, config);\n  }\n}\n\ninterface IModifierCriterionOptionParams extends ICriterionOptionParams {\n  inputType?: InputType;\n  modifierOptions?: CriterionModifier[];\n  defaultModifier?: CriterionModifier;\n  options?: Option[];\n}\n\nexport class ModifierCriterionOption extends CriterionOption {\n  public readonly modifierOptions: CriterionModifier[];\n  public readonly defaultModifier: CriterionModifier;\n  public readonly options: Option[] | undefined;\n  public readonly inputType: InputType;\n\n  constructor(options: IModifierCriterionOptionParams) {\n    super(options);\n    this.modifierOptions = options.modifierOptions ?? [];\n    this.defaultModifier = options.defaultModifier ?? CriterionModifier.Equals;\n    this.options = options.options;\n    this.inputType = options.inputType;\n  }\n}\n\nexport class ILabeledIdCriterionOption extends ModifierCriterionOption {\n  constructor(\n    messageID: string,\n    value: CriterionType,\n    includeAll: boolean,\n    inputType: InputType,\n    makeCriterion?: () => ModifierCriterion<CriterionValue>\n  ) {\n    const modifierOptions = [\n      CriterionModifier.Includes,\n      CriterionModifier.Excludes,\n      CriterionModifier.IsNull,\n      CriterionModifier.NotNull,\n    ];\n\n    let defaultModifier = CriterionModifier.Includes;\n    if (includeAll) {\n      modifierOptions.unshift(CriterionModifier.IncludesAll);\n      defaultModifier = CriterionModifier.IncludesAll;\n    }\n\n    super({\n      messageID,\n      type: value,\n      modifierOptions,\n      defaultModifier,\n      inputType,\n      makeCriterion: makeCriterion\n        ? makeCriterion\n        : () => new ILabeledIdCriterion(this),\n    });\n  }\n}\n\nexport class ILabeledIdCriterion extends ModifierCriterion<ILabeledId[]> {\n  constructor(type: ModifierCriterionOption, value: ILabeledId[] = []) {\n    super(type, value);\n  }\n\n  public cloneValues() {\n    this.value = this.value.map((v) => ({ ...v }));\n  }\n\n  protected getLabelValue(_intl: IntlShape): string {\n    return this.value.map((v) => v.label).join(\", \");\n  }\n\n  public toCriterionInput(): MultiCriterionInput {\n    return {\n      value: this.value.map((v) => v.id),\n      modifier: this.modifier,\n    };\n  }\n\n  public isValid(): boolean {\n    if (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull\n    ) {\n      return true;\n    }\n\n    return this.value.length > 0;\n  }\n}\n\nexport class IHierarchicalLabeledIdCriterion extends ModifierCriterion<IHierarchicalLabelValue> {\n  constructor(\n    type: ModifierCriterionOption,\n    value: IHierarchicalLabelValue = {\n      items: [],\n      excluded: [],\n      depth: 0,\n    }\n  ) {\n    super(type, value);\n  }\n\n  public cloneValues() {\n    this.value = {\n      ...this.value,\n      items: this.value.items.map((v) => ({ ...v })),\n      excluded: this.value.excluded.map((v) => ({ ...v })),\n    };\n  }\n\n  override get modifier(): CriterionModifier {\n    return this._modifier;\n  }\n  override set modifier(value: CriterionModifier) {\n    this._modifier = value;\n\n    // excluded only makes sense for includes and includes all\n    // so reset it for other modifiers\n    if (\n      this.value &&\n      value !== CriterionModifier.Includes &&\n      value !== CriterionModifier.IncludesAll\n    ) {\n      this.value.excluded = [];\n    }\n  }\n\n  public setFromSavedCriterion(\n    criterion: ISavedCriterion<IHierarchicalLabelValue>\n  ) {\n    const { modifier, value } = criterion;\n\n    if (value !== undefined) {\n      this.value = {\n        items: value.items || [],\n        excluded: value.excluded || [],\n        depth: value.depth || 0,\n      };\n    }\n\n    const modifierOptions =\n      (this.criterionOption as ModifierCriterionOption).modifierOptions ?? [];\n\n    // if the previous modifier was excludes, replace it with the equivalent includes criterion\n    // this is what is done on the backend\n    // only replace if excludes is not a valid modifierOption\n    if (\n      modifier === CriterionModifier.Excludes &&\n      modifierOptions.find((m) => m === CriterionModifier.Excludes) ===\n        undefined\n    ) {\n      this.modifier = CriterionModifier.Includes;\n      this.value.excluded = [...this.value.excluded, ...this.value.items];\n      this.value.items = [];\n    } else {\n      this.modifier = modifier;\n    }\n  }\n\n  protected getLabelValue(_intl: IntlShape): string {\n    const labels = (this.value.items ?? []).map((v) => v.label).join(\", \");\n\n    if (this.value.depth === 0) {\n      return labels;\n    }\n\n    return `${labels} (+${this.value.depth > 0 ? this.value.depth : \"all\"})`;\n  }\n\n  public toCriterionInput(): HierarchicalMultiCriterionInput {\n    let excludes: string[] = [];\n\n    // if modifier is equals, depth must be 0\n    const depth =\n      this.modifier === CriterionModifier.Equals ? 0 : this.value.depth;\n\n    if (this.value.excluded) {\n      excludes = this.value.excluded.map((v) => v.id);\n    }\n    return {\n      value: this.value.items.map((v) => v.id),\n      excludes: excludes,\n      modifier: this.modifier,\n      depth,\n    };\n  }\n\n  public isValid(): boolean {\n    if (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull\n    ) {\n      return true;\n    }\n\n    return (\n      this.value.items.length > 0 ||\n      (this.value.excluded && this.value.excluded.length > 0)\n    );\n  }\n\n  public getLabel(intl: IntlShape, sfwContentMode?: boolean): string {\n    let id = \"criterion_modifier.format_string\";\n    let modifierString = ModifierCriterion.getModifierLabel(\n      intl,\n      this.modifier\n    );\n    let valueString = \"\";\n    let excludedString = \"\";\n\n    if (\n      this.modifier !== CriterionModifier.IsNull &&\n      this.modifier !== CriterionModifier.NotNull\n    ) {\n      valueString = this.value.items.map((v) => v.label).join(\", \");\n\n      if (this.value.excluded && this.value.excluded.length > 0) {\n        if (this.value.items.length === 0) {\n          modifierString = ModifierCriterion.getModifierLabel(\n            intl,\n            CriterionModifier.Excludes\n          );\n          valueString = this.value.excluded.map((v) => v.label).join(\", \");\n        } else {\n          id = \"criterion_modifier.format_string_excludes\";\n          excludedString = this.value.excluded.map((v) => v.label).join(\", \");\n        }\n      }\n\n      if (this.value.depth !== 0) {\n        id += \"_depth\";\n      }\n    }\n\n    const messageID = !sfwContentMode\n      ? this.criterionOption.messageID\n      : this.criterionOption.sfwMessageID ?? this.criterionOption.messageID;\n\n    return intl.formatMessage(\n      { id },\n      {\n        criterion: intl.formatMessage({ id: messageID }),\n        modifierString,\n        valueString,\n        excludedString,\n        depth: this.value.depth,\n      }\n    );\n  }\n}\n\nexport class StringCriterionOption extends ModifierCriterionOption {\n  constructor(\n    options: Partial<\n      Omit<IModifierCriterionOptionParams, \"messageID\" | \"type\">\n    > &\n      Pick<IModifierCriterionOptionParams, \"messageID\" | \"type\">\n  ) {\n    super({\n      modifierOptions: [\n        CriterionModifier.Equals,\n        CriterionModifier.NotEquals,\n        CriterionModifier.Includes,\n        CriterionModifier.Excludes,\n        CriterionModifier.IsNull,\n        CriterionModifier.NotNull,\n        CriterionModifier.MatchesRegex,\n        CriterionModifier.NotMatchesRegex,\n      ],\n      defaultModifier: CriterionModifier.Equals,\n      inputType: \"text\",\n      makeCriterion: () => new StringCriterion(this),\n      ...options,\n    });\n  }\n}\n\nexport function createStringCriterionOption(\n  type: CriterionType,\n  messageID?: string,\n  options?: { nsfw?: boolean }\n) {\n  return new StringCriterionOption({\n    messageID: messageID ?? type,\n    type,\n    ...options,\n  });\n}\n\nexport class MandatoryStringCriterionOption extends ModifierCriterionOption {\n  constructor(messageID: string, value: CriterionType) {\n    super({\n      messageID,\n      type: value,\n      modifierOptions: [\n        CriterionModifier.Equals,\n        CriterionModifier.NotEquals,\n        CriterionModifier.Includes,\n        CriterionModifier.Excludes,\n        CriterionModifier.MatchesRegex,\n        CriterionModifier.NotMatchesRegex,\n      ],\n      defaultModifier: CriterionModifier.Equals,\n      inputType: \"text\",\n      makeCriterion: () => new StringCriterion(this),\n    });\n  }\n}\n\nexport function createMandatoryStringCriterionOption(\n  value: CriterionType,\n  messageID?: string\n) {\n  return new MandatoryStringCriterionOption(messageID ?? value, value);\n}\n\nexport class StringCriterion extends ModifierCriterion<string> {\n  constructor(type: ModifierCriterionOption) {\n    super(type, \"\");\n  }\n\n  protected getLabelValue(_intl: IntlShape) {\n    return this.value;\n  }\n\n  public isValid(): boolean {\n    return (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull ||\n      this.value.length > 0\n    );\n  }\n}\n\nexport abstract class MultiStringCriterion extends ModifierCriterion<string[]> {\n  constructor(type: ModifierCriterionOption, value: string[] = []) {\n    super(type, value);\n  }\n\n  public cloneValues() {\n    this.value = this.value.slice();\n  }\n\n  protected getLabelValue(_intl: IntlShape) {\n    return this.value.join(\", \");\n  }\n\n  public isValid(): boolean {\n    return (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull ||\n      this.value.length > 0\n    );\n  }\n}\n\nexport class BooleanCriterionOption extends ModifierCriterionOption {\n  constructor(\n    messageID: string,\n    value: CriterionType,\n    makeCriterion?: () => ModifierCriterion<CriterionValue>\n  ) {\n    super({\n      messageID,\n      type: value,\n      modifierOptions: [],\n      defaultModifier: CriterionModifier.Equals,\n      options: [\"true\", \"false\"],\n      makeCriterion: makeCriterion\n        ? makeCriterion\n        : () => new BooleanCriterion(this),\n    });\n  }\n}\n\nexport function createBooleanCriterionOption(\n  value: CriterionType,\n  messageID?: string\n) {\n  return new BooleanCriterionOption(messageID ?? value, value);\n}\n\nexport class BooleanCriterion extends StringCriterion {\n  public toCriterionInput(): boolean {\n    return this.value === \"true\";\n  }\n\n  public isValid() {\n    return this.value === \"true\" || this.value === \"false\";\n  }\n}\n\nexport class StringBooleanCriterionOption extends ModifierCriterionOption {\n  constructor(\n    messageID: string,\n    value: CriterionType,\n    makeCriterion?: () => ModifierCriterion<CriterionValue>\n  ) {\n    super({\n      messageID,\n      type: value,\n      options: [\"true\", \"false\"],\n      makeCriterion: makeCriterion\n        ? makeCriterion\n        : () => new StringBooleanCriterion(this),\n    });\n  }\n}\n\nexport class StringBooleanCriterion extends StringCriterion {\n  public toCriterionInput(): string {\n    return this.value;\n  }\n\n  public isValid() {\n    return this.value === \"true\" || this.value === \"false\";\n  }\n}\n\nexport class NumberCriterionOption extends ModifierCriterionOption {\n  constructor(messageID: string, value: CriterionType) {\n    super({\n      messageID,\n      type: value,\n      modifierOptions: [\n        CriterionModifier.Equals,\n        CriterionModifier.NotEquals,\n        CriterionModifier.GreaterThan,\n        CriterionModifier.LessThan,\n        CriterionModifier.IsNull,\n        CriterionModifier.NotNull,\n        CriterionModifier.Between,\n        CriterionModifier.NotBetween,\n      ],\n      defaultModifier: CriterionModifier.Equals,\n      inputType: \"number\",\n      makeCriterion: () => new NumberCriterion(this),\n    });\n  }\n}\n\nexport function createNumberCriterionOption(\n  value: CriterionType,\n  messageID?: string\n) {\n  return new NumberCriterionOption(messageID ?? value, value);\n}\n\nexport class NullNumberCriterionOption extends ModifierCriterionOption {\n  constructor(\n    messageID: string,\n    value: CriterionType,\n    makeCriterion?: MakeCriterionFn\n  ) {\n    super({\n      messageID,\n      type: value,\n      modifierOptions: [\n        CriterionModifier.Equals,\n        CriterionModifier.NotEquals,\n        CriterionModifier.GreaterThan,\n        CriterionModifier.LessThan,\n        CriterionModifier.Between,\n        CriterionModifier.NotBetween,\n        CriterionModifier.IsNull,\n        CriterionModifier.NotNull,\n      ],\n      defaultModifier: CriterionModifier.Equals,\n      inputType: \"number\",\n      makeCriterion: makeCriterion\n        ? makeCriterion\n        : () => new NumberCriterion(this),\n    });\n  }\n}\n\nexport function createNullNumberCriterionOption(\n  value: CriterionType,\n  messageID?: string\n) {\n  return new NullNumberCriterionOption(messageID ?? value, value);\n}\n\nexport class MandatoryNumberCriterionOption extends ModifierCriterionOption {\n  constructor(\n    messageID: string,\n    value: CriterionType,\n    makeCriterion?: () => ModifierCriterion<CriterionValue>,\n    options?: { sfwMessageID?: string }\n  ) {\n    super({\n      messageID,\n      type: value,\n      modifierOptions: [\n        CriterionModifier.Equals,\n        CriterionModifier.NotEquals,\n        CriterionModifier.GreaterThan,\n        CriterionModifier.LessThan,\n        CriterionModifier.Between,\n        CriterionModifier.NotBetween,\n      ],\n      defaultModifier: CriterionModifier.Equals,\n      inputType: \"number\",\n      makeCriterion: makeCriterion\n        ? makeCriterion\n        : () => new NumberCriterion(this),\n      ...options,\n    });\n  }\n}\n\nexport function createMandatoryNumberCriterionOption(\n  value: CriterionType,\n  messageID?: string,\n  options?: { sfwMessageID?: string }\n) {\n  return new MandatoryNumberCriterionOption(\n    messageID ?? value,\n    value,\n    undefined,\n    options\n  );\n}\n\nexport function encodeRangeValue<V>(\n  modifier: CriterionModifier,\n  value: IRangeValue<V>\n): unknown {\n  // only encode value2 if modifier is between/not between\n  if (\n    modifier === CriterionModifier.Between ||\n    modifier === CriterionModifier.NotBetween\n  ) {\n    return { value: value.value, value2: value.value2 };\n  }\n\n  return { value: value.value };\n}\n\nexport function decodeRangeValue<V>(v: {\n  value: V | IRangeValue<V>;\n  value2?: V;\n}): IRangeValue<V> {\n  // handle backwards compatible value\n  if (typeof v.value === \"object\") {\n    return v.value as IRangeValue<V>;\n  } else {\n    return { value: v.value, value2: v.value2 };\n  }\n}\n\nexport class NumberCriterion extends ModifierCriterion<INumberValue> {\n  constructor(type: ModifierCriterionOption) {\n    super(type, { value: undefined, value2: undefined });\n  }\n\n  public cloneValues() {\n    this.value = { ...this.value };\n  }\n\n  public get value(): INumberValue {\n    return this._value;\n  }\n  public set value(newValue: number | INumberValue) {\n    // backwards compatibility - if this.value is a number, use that\n    if (typeof newValue !== \"object\") {\n      this._value = {\n        value: newValue,\n        value2: undefined,\n      };\n    } else {\n      this._value = newValue;\n    }\n  }\n\n  public toCriterionInput(): IntCriterionInput {\n    return {\n      modifier: this.modifier,\n      value: this.value?.value ?? 0,\n      value2: this.value?.value2,\n    };\n  }\n\n  public setFromSavedCriterion(c: {\n    modifier: CriterionModifier;\n    value: number | INumberValue;\n    value2?: number;\n  }) {\n    super.setFromSavedCriterion(c);\n    // this.value = decodeRangeValue(c);\n  }\n\n  protected encodeValue(): unknown {\n    return encodeRangeValue(this.modifier, this.value);\n  }\n\n  protected getLabelValue(_intl: IntlShape) {\n    const { value, value2 } = this.value;\n    if (\n      this.modifier === CriterionModifier.Between ||\n      this.modifier === CriterionModifier.NotBetween\n    ) {\n      return `${value}, ${value2 ?? 0}`;\n    } else {\n      return `${value}`;\n    }\n  }\n\n  public isValid(): boolean {\n    if (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull\n    ) {\n      return true;\n    }\n\n    const { value, value2 } = this.value;\n    if (value === undefined) {\n      return false;\n    }\n\n    if (\n      value2 === undefined &&\n      (this.modifier === CriterionModifier.Between ||\n        this.modifier === CriterionModifier.NotBetween)\n    ) {\n      return false;\n    }\n\n    return true;\n  }\n}\n\nexport class DurationCriterionOption extends MandatoryNumberCriterionOption {\n  constructor(messageID: string, value: CriterionType) {\n    super(messageID, value, () => new DurationCriterion(this));\n  }\n}\n\nexport function createDurationCriterionOption(\n  value: CriterionType,\n  messageID?: string\n) {\n  return new DurationCriterionOption(messageID ?? value, value);\n}\n\nexport class NullDurationCriterionOption extends NullNumberCriterionOption {\n  constructor(messageID: string, value: CriterionType) {\n    super(messageID, value, () => new DurationCriterion(this));\n  }\n}\n\nexport function createNullDurationCriterionOption(\n  value: CriterionType,\n  messageID?: string\n) {\n  return new NullDurationCriterionOption(messageID ?? value, value);\n}\n\nexport class DurationCriterion extends ModifierCriterion<INumberValue> {\n  constructor(type: ModifierCriterionOption) {\n    super(type, { value: undefined, value2: undefined });\n  }\n\n  public cloneValues() {\n    this.value = { ...this.value };\n  }\n\n  public toCriterionInput(): IntCriterionInput {\n    return {\n      modifier: this.modifier,\n      value: this.value?.value ?? 0,\n      value2: this.value?.value2,\n    };\n  }\n\n  public setFromSavedCriterion(c: {\n    modifier: CriterionModifier;\n    value: number | INumberValue;\n    value2?: number;\n  }) {\n    super.setFromSavedCriterion(c);\n    // this.value = decodeRangeValue(c);\n  }\n\n  protected encodeValue(): unknown {\n    return encodeRangeValue(this.modifier, this.value);\n  }\n\n  protected getLabelValue(_intl: IntlShape) {\n    const value = TextUtils.secondsToTimestamp(this.value.value ?? 0);\n    const value2 = TextUtils.secondsToTimestamp(this.value.value2 ?? 0);\n    if (\n      this.modifier === CriterionModifier.Between ||\n      this.modifier === CriterionModifier.NotBetween\n    ) {\n      return `${value}, ${value2}`;\n    } else {\n      return value;\n    }\n  }\n\n  public isValid(): boolean {\n    if (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull\n    ) {\n      return true;\n    }\n\n    const { value, value2 } = this.value;\n    if (value === undefined) {\n      return false;\n    }\n\n    if (\n      value2 === undefined &&\n      (this.modifier === CriterionModifier.Between ||\n        this.modifier === CriterionModifier.NotBetween)\n    ) {\n      return false;\n    }\n\n    return true;\n  }\n}\n\nexport class DateCriterionOption extends ModifierCriterionOption {\n  constructor(messageID: string, value: CriterionType) {\n    super({\n      messageID,\n      type: value,\n      modifierOptions: [\n        CriterionModifier.Equals,\n        CriterionModifier.NotEquals,\n        CriterionModifier.GreaterThan,\n        CriterionModifier.LessThan,\n        CriterionModifier.IsNull,\n        CriterionModifier.NotNull,\n        CriterionModifier.Between,\n        CriterionModifier.NotBetween,\n      ],\n      defaultModifier: CriterionModifier.Equals,\n      inputType: \"text\",\n      makeCriterion: () => new DateCriterion(this),\n    });\n  }\n}\n\nexport function createDateCriterionOption(value: CriterionType) {\n  return new DateCriterionOption(value, value);\n}\n\nexport class DateCriterion extends ModifierCriterion<IDateValue> {\n  constructor(type: ModifierCriterionOption) {\n    super(type, { value: \"\", value2: undefined });\n  }\n\n  public cloneValues() {\n    this.value = { ...this.value };\n  }\n\n  public setFromSavedCriterion(c: {\n    modifier: CriterionModifier;\n    value: string | IDateValue;\n    value2?: string;\n  }) {\n    super.setFromSavedCriterion(c);\n    // this.value = decodeRangeValue(c);\n  }\n\n  protected encodeValue(): unknown {\n    return encodeRangeValue(this.modifier, this.value);\n  }\n\n  public toCriterionInput(): DateCriterionInput {\n    return {\n      modifier: this.modifier,\n      value: this.value?.value ?? \"\",\n      value2: this.value?.value2,\n    };\n  }\n\n  protected getLabelValue() {\n    const { value } = this.value;\n    return this.modifier === CriterionModifier.Between ||\n      this.modifier === CriterionModifier.NotBetween\n      ? `${value}, ${this.value.value2}`\n      : `${value}`;\n  }\n\n  public isValid(): boolean {\n    if (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull\n    ) {\n      return true;\n    }\n\n    const { value, value2 } = this.value;\n    if (!value) {\n      return false;\n    }\n\n    if (\n      !value2 &&\n      (this.modifier === CriterionModifier.Between ||\n        this.modifier === CriterionModifier.NotBetween)\n    ) {\n      return false;\n    }\n\n    return true;\n  }\n}\n\nexport class TimestampCriterionOption extends ModifierCriterionOption {\n  constructor(messageID: string, value: CriterionType) {\n    super({\n      messageID,\n      type: value,\n      modifierOptions: [\n        CriterionModifier.GreaterThan,\n        CriterionModifier.LessThan,\n        CriterionModifier.IsNull,\n        CriterionModifier.NotNull,\n        CriterionModifier.Between,\n        CriterionModifier.NotBetween,\n      ],\n      defaultModifier: CriterionModifier.GreaterThan,\n      inputType: \"text\",\n      makeCriterion: () => new TimestampCriterion(this),\n    });\n  }\n}\n\nexport function createTimestampCriterionOption(value: CriterionType) {\n  return new TimestampCriterionOption(value, value);\n}\n\nexport class MandatoryTimestampCriterionOption extends ModifierCriterionOption {\n  constructor(messageID: string, value: CriterionType) {\n    super({\n      messageID,\n      type: value,\n      modifierOptions: [\n        CriterionModifier.GreaterThan,\n        CriterionModifier.LessThan,\n        CriterionModifier.Between,\n        CriterionModifier.NotBetween,\n      ],\n      defaultModifier: CriterionModifier.GreaterThan,\n      inputType: \"text\",\n      makeCriterion: () => new TimestampCriterion(this),\n    });\n  }\n}\n\nexport function createMandatoryTimestampCriterionOption(value: CriterionType) {\n  return new MandatoryTimestampCriterionOption(value, value);\n}\n\nexport class TimestampCriterion extends ModifierCriterion<ITimestampValue> {\n  constructor(type: ModifierCriterionOption) {\n    super(type, { value: \"\", value2: undefined });\n  }\n\n  public cloneValues() {\n    this.value = { ...this.value };\n  }\n\n  public toCriterionInput(): TimestampCriterionInput {\n    return {\n      modifier: this.modifier,\n      value: this.transformValueToInput(this.value.value ?? \"\"),\n      value2: this.value.value2\n        ? this.transformValueToInput(this.value.value2)\n        : null,\n    };\n  }\n\n  public setFromSavedCriterion(c: {\n    modifier: CriterionModifier;\n    value: string | ITimestampValue;\n    value2?: string;\n  }) {\n    super.setFromSavedCriterion(c);\n    this.value = decodeRangeValue(c);\n  }\n\n  protected encodeValue(): unknown {\n    return encodeRangeValue(this.modifier, this.value);\n  }\n\n  protected getLabelValue() {\n    const { value } = this.value;\n    return this.modifier === CriterionModifier.Between ||\n      this.modifier === CriterionModifier.NotBetween\n      ? `${value}, ${this.value.value2}`\n      : `${value}`;\n  }\n\n  private transformValueToInput(value: string): string {\n    value = value.trim();\n    if (/^\\d{4}-\\d{2}-\\d{2}(( |T)\\d{2}:\\d{2})?$/.test(value)) {\n      return value.replace(\" \", \"T\");\n    }\n\n    return \"\";\n  }\n\n  public isValid(): boolean {\n    if (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull\n    ) {\n      return true;\n    }\n\n    const { value, value2 } = this.value;\n    if (!value) {\n      return false;\n    }\n\n    if (\n      !value2 &&\n      (this.modifier === CriterionModifier.Between ||\n        this.modifier === CriterionModifier.NotBetween)\n    ) {\n      return false;\n    }\n\n    return true;\n  }\n}\n\nexport class UnsupportedCriterionOption extends StringCriterionOption {\n  constructor(type: string) {\n    super({\n      messageID: \"unsupported_criterion\",\n      type: type as CriterionType,\n      makeCriterion: () => new UnsupportedCriterion(this),\n    });\n  }\n}\n\nexport class UnsupportedCriterion extends StringCriterion {\n  public getLabel(intl: IntlShape): string {\n    const modifierString = ModifierCriterion.getModifierLabel(\n      intl,\n      this.modifier\n    );\n    let valueString = \"\";\n\n    if (\n      this.modifier !== CriterionModifier.IsNull &&\n      this.modifier !== CriterionModifier.NotNull\n    ) {\n      valueString = this.getLabelValue(intl);\n    }\n\n    return intl.formatMessage(\n      { id: \"criterion_modifier.format_string\" },\n      {\n        criterion: intl.formatMessage(\n          { id: \"criterion.unsupported\" },\n          { type: this.criterionOption.type }\n        ),\n        modifierString,\n        valueString,\n      }\n    );\n  }\n\n  public applyToCriterionInput(): void {\n    // do nothing\n  }\n\n  public applyToSavedCriterion(): void {\n    // do nothing\n  }\n\n  public setFromSavedCriterion(): void {\n    // do nothing\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/custom-fields.ts",
    "content": "import { IntlShape } from \"react-intl\";\nimport { Criterion, CriterionOption, ModifierCriterion } from \"./criterion\";\nimport {\n  CriterionModifier,\n  CustomFieldCriterionInput,\n} from \"src/core/generated-graphql\";\nimport { cloneDeep } from \"@apollo/client/utilities\";\n\nfunction valueToString(value: unknown[] | undefined | null) {\n  if (!value) return \"\";\n  return value.map((v) => v as string).join(\", \");\n}\n\nexport const CustomFieldsCriterionOption = new CriterionOption({\n  type: \"custom_fields\",\n  messageID: \"custom_fields.title\",\n  makeCriterion: () => new CustomFieldsCriterion(),\n});\n\nexport class CustomFieldsCriterion extends Criterion {\n  public value: CustomFieldCriterionInput[] = [];\n\n  constructor() {\n    super(CustomFieldsCriterionOption);\n  }\n\n  public isValid(): boolean {\n    return this.value.length > 0;\n  }\n\n  public applyToCriterionInput(input: Record<string, unknown>): void {\n    input.custom_fields = cloneDeep(this.value);\n  }\n\n  public applyToSavedCriterion(input: Record<string, unknown>): void {\n    input.custom_fields = cloneDeep(this.value);\n  }\n\n  public getLabel(intl: IntlShape): string {\n    // show first criterion\n    if (this.value.length === 0) {\n      return \"\";\n    }\n\n    const first = this.value[0];\n    let messageID;\n    let valueString = \"\";\n\n    if (\n      first.modifier !== CriterionModifier.IsNull &&\n      first.modifier !== CriterionModifier.NotNull &&\n      (first.value?.length ?? 0) > 0\n    ) {\n      valueString = valueToString(first.value);\n    }\n\n    const modifierString = ModifierCriterion.getModifierLabel(\n      intl,\n      first.modifier\n    );\n    const opts = {\n      criterion: first.field,\n      modifierString,\n      valueString,\n      others: \"\",\n    };\n\n    if (this.value.length === 1) {\n      messageID = \"custom_fields.criteria_format_string\";\n    } else {\n      messageID = \"custom_fields.criteria_format_string_others\";\n      opts.others = (this.value.length - 1).toString();\n    }\n\n    return intl.formatMessage({ id: messageID }, opts);\n  }\n\n  public getValueLabel(intl: IntlShape, v: CustomFieldCriterionInput): string {\n    let valueString = \"\";\n\n    if (\n      v.modifier !== CriterionModifier.IsNull &&\n      v.modifier !== CriterionModifier.NotNull &&\n      (v.value?.length ?? 0) > 0\n    ) {\n      valueString = valueToString(v.value);\n    }\n\n    const modifierString = ModifierCriterion.getModifierLabel(intl, v.modifier);\n    const opts = {\n      criterion: v.field,\n      modifierString,\n      valueString,\n    };\n\n    return intl.formatMessage(\n      { id: \"custom_fields.criteria_format_string\" },\n      opts\n    );\n  }\n\n  public toQueryParams(): Record<string, unknown> {\n    const encodedCriterion = {\n      type: this.criterionOption.type,\n      value: this.value,\n    };\n    return encodedCriterion;\n  }\n\n  public fromDecodedParams(i: unknown): void {\n    const criterion = i as { value: CustomFieldCriterionInput[] };\n    this.value = cloneDeep(criterion.value);\n  }\n\n  public setFromSavedCriterion(input: CustomFieldCriterionInput[]): void {\n    this.value = cloneDeep(input);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/favorite.ts",
    "content": "import { BooleanCriterion, BooleanCriterionOption } from \"./criterion\";\n\nexport const FavoritePerformerCriterionOption = new BooleanCriterionOption(\n  \"favourite\",\n  \"filter_favorites\",\n  () => new FavoritePerformerCriterion()\n);\n\nexport class FavoritePerformerCriterion extends BooleanCriterion {\n  constructor() {\n    super(FavoritePerformerCriterionOption);\n  }\n}\nexport const FavoriteTagCriterionOption = new BooleanCriterionOption(\n  \"favourite\",\n  \"favorite\",\n  () => new FavoriteTagCriterion()\n);\n\nexport class FavoriteTagCriterion extends BooleanCriterion {\n  constructor() {\n    super(FavoriteTagCriterionOption);\n  }\n}\n\nexport const FavoriteStudioCriterionOption = new BooleanCriterionOption(\n  \"favourite\",\n  \"favorite\",\n  () => new FavoriteStudioCriterion()\n);\n\nexport class FavoriteStudioCriterion extends BooleanCriterion {\n  constructor() {\n    super(FavoriteStudioCriterionOption);\n  }\n}\n\nexport const PerformerFavoriteCriterionOption = new BooleanCriterionOption(\n  \"performer_favorite\",\n  \"performer_favorite\",\n  () => new PerformerFavoriteCriterion()\n);\n\nexport class PerformerFavoriteCriterion extends BooleanCriterion {\n  constructor() {\n    super(PerformerFavoriteCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/folder.ts",
    "content": "import { CriterionModifier } from \"src/core/generated-graphql\";\nimport {\n  ModifierCriterionOption,\n  IHierarchicalLabeledIdCriterion,\n} from \"./criterion\";\n\nconst modifierOptions = [CriterionModifier.Includes];\n\nconst defaultModifier = CriterionModifier.Includes;\nconst inputType = \"folders\";\n\nexport const FolderCriterionOption = new ModifierCriterionOption({\n  messageID: \"folder\",\n  type: \"folder\",\n  modifierOptions,\n  defaultModifier,\n  inputType,\n  makeCriterion: () => new FolderCriterion(),\n});\n\n// for galleries, we should use parent folder to distinguish between gallery folder\n// and parent folder of the gallery folder\nexport const ParentFolderCriterionOption = new ModifierCriterionOption({\n  messageID: \"parent_folder\",\n  type: \"parent_folder\",\n  modifierOptions,\n  defaultModifier,\n  inputType,\n  makeCriterion: () => new ParentFolderCriterion(),\n});\n\nexport class FolderCriterion extends IHierarchicalLabeledIdCriterion {\n  constructor() {\n    super(FolderCriterionOption);\n  }\n\n  public applyToCriterionInput(input: Record<string, unknown>) {\n    input.files_filter = {\n      parent_folder: this.toCriterionInput(),\n    };\n  }\n}\n\nexport class ParentFolderCriterion extends IHierarchicalLabeledIdCriterion {\n  constructor() {\n    super(ParentFolderCriterionOption);\n  }\n\n  public applyToCriterionInput(input: Record<string, unknown>) {\n    input.parent_folder = this.toCriterionInput();\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/galleries.ts",
    "content": "import { ILabeledIdCriterion, ILabeledIdCriterionOption } from \"./criterion\";\n\nconst inputType = \"galleries\";\n\nexport const GalleriesCriterionOption = new ILabeledIdCriterionOption(\n  \"galleries\",\n  \"galleries\",\n  true,\n  inputType,\n  () => new GalleriesCriterion()\n);\n\nexport class GalleriesCriterion extends ILabeledIdCriterion {\n  constructor() {\n    super(GalleriesCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/gender.ts",
    "content": "import {\n  CriterionModifier,\n  GenderCriterionInput,\n  GenderEnum,\n} from \"src/core/generated-graphql\";\nimport { genderStrings, stringToGender } from \"src/utils/gender\";\nimport {\n  ModifierCriterionOption,\n  ISavedCriterion,\n  MultiStringCriterion,\n} from \"./criterion\";\n\nexport const GenderCriterionOption = new ModifierCriterionOption({\n  messageID: \"gender\",\n  type: \"gender\",\n  options: genderStrings,\n  modifierOptions: [\n    CriterionModifier.Includes,\n    CriterionModifier.Excludes,\n    CriterionModifier.IsNull,\n    CriterionModifier.NotNull,\n  ],\n  defaultModifier: CriterionModifier.Includes,\n  makeCriterion: () => new GenderCriterion(),\n});\n\nexport class GenderCriterion extends MultiStringCriterion {\n  constructor(value: string[] = []) {\n    super(GenderCriterionOption, value);\n  }\n\n  public toCriterionInput(): GenderCriterionInput {\n    const value = this.value.map((v) => stringToGender(v)) as GenderEnum[];\n\n    return {\n      value_list: value,\n      modifier: this.modifier,\n    };\n  }\n\n  public setFromSavedCriterion(criterion: ISavedCriterion<string[]>) {\n    // backwards compatibility - if the value is a string, convert it to an array\n    if (typeof criterion.value === \"string\") {\n      criterion = {\n        ...criterion,\n        value: [criterion.value],\n      };\n    }\n\n    super.setFromSavedCriterion(criterion);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/groups.ts",
    "content": "import { CriterionModifier } from \"src/core/generated-graphql\";\nimport {\n  ModifierCriterionOption,\n  IHierarchicalLabeledIdCriterion,\n} from \"./criterion\";\nimport { CriterionType } from \"../types\";\n\nconst inputType = \"groups\";\n\nconst modifierOptions = [\n  CriterionModifier.Includes,\n  CriterionModifier.Excludes,\n  CriterionModifier.IsNull,\n  CriterionModifier.NotNull,\n];\n\nconst defaultModifier = CriterionModifier.Includes;\n\nclass BaseGroupsCriterionOption extends ModifierCriterionOption {\n  constructor(messageID: string, type: CriterionType) {\n    super({\n      messageID,\n      type,\n      modifierOptions,\n      defaultModifier,\n      inputType,\n      makeCriterion: () => new GroupsCriterion(this),\n    });\n  }\n}\n\nexport const GroupsCriterionOption = new BaseGroupsCriterionOption(\n  \"groups\",\n  \"groups\"\n);\n\nexport class GroupsCriterion extends IHierarchicalLabeledIdCriterion {}\n\nexport const ContainingGroupsCriterionOption = new BaseGroupsCriterionOption(\n  \"containing_groups\",\n  \"containing_groups\"\n);\n\nexport const SubGroupsCriterionOption = new BaseGroupsCriterionOption(\n  \"sub_groups\",\n  \"sub_groups\"\n);\n\n// redirects to GroupsCriterion\nexport const LegacyMoviesCriterionOption = new ModifierCriterionOption({\n  messageID: \"groups\",\n  type: \"movies\",\n  modifierOptions,\n  defaultModifier,\n  inputType,\n  hidden: true,\n  makeCriterion: () => new GroupsCriterion(GroupsCriterionOption),\n});\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/has-chapters.ts",
    "content": "import {\n  StringBooleanCriterion,\n  StringBooleanCriterionOption,\n} from \"./criterion\";\n\nexport const HasChaptersCriterionOption = new StringBooleanCriterionOption(\n  \"hasChapters\",\n  \"has_chapters\",\n  () => new HasChaptersCriterion()\n);\n\nexport class HasChaptersCriterion extends StringBooleanCriterion {\n  constructor() {\n    super(HasChaptersCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/has-markers.ts",
    "content": "import {\n  StringBooleanCriterion,\n  StringBooleanCriterionOption,\n} from \"./criterion\";\n\nexport const HasMarkersCriterionOption = new StringBooleanCriterionOption(\n  \"hasMarkers\",\n  \"has_markers\",\n  () => new HasMarkersCriterion()\n);\n\nexport class HasMarkersCriterion extends StringBooleanCriterion {\n  constructor() {\n    super(HasMarkersCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/interactive.ts",
    "content": "import { BooleanCriterion, BooleanCriterionOption } from \"./criterion\";\n\nexport const InteractiveCriterionOption = new BooleanCriterionOption(\n  \"interactive\",\n  \"interactive\",\n  () => new InteractiveCriterion()\n);\n\nexport class InteractiveCriterion extends BooleanCriterion {\n  constructor() {\n    super(InteractiveCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/is-missing.ts",
    "content": "import { CriterionModifier } from \"src/core/generated-graphql\";\nimport { CriterionType } from \"../types\";\nimport { ModifierCriterionOption, StringCriterion, Option } from \"./criterion\";\n\nexport class IsMissingCriterion extends StringCriterion {\n  public toCriterionInput(): string {\n    return this.value;\n  }\n}\n\nclass IsMissingCriterionOption extends ModifierCriterionOption {\n  constructor(messageID: string, type: CriterionType, options: Option[]) {\n    super({\n      messageID,\n      type,\n      options,\n      modifierOptions: [],\n      defaultModifier: CriterionModifier.Equals,\n      makeCriterion: () => new IsMissingCriterion(this),\n    });\n  }\n}\n\nexport const SceneIsMissingCriterionOption = new IsMissingCriterionOption(\n  \"isMissing\",\n  \"is_missing\",\n  [\n    \"title\",\n    \"code\",\n    \"details\",\n    \"director\",\n    \"url\",\n    \"date\",\n    \"rating\",\n    \"cover\",\n    \"galleries\",\n    \"studio\",\n    \"group\",\n    \"performers\",\n    \"tags\",\n    \"stash_id\",\n  ]\n);\n\nexport const ImageIsMissingCriterionOption = new IsMissingCriterionOption(\n  \"isMissing\",\n  \"is_missing\",\n  [\n    \"title\",\n    \"details\",\n    \"photographer\",\n    \"url\",\n    \"date\",\n    \"code\",\n    \"rating\",\n    \"galleries\",\n    \"studio\",\n    \"performers\",\n    \"tags\",\n  ]\n);\n\nexport const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(\n  \"isMissing\",\n  \"is_missing\",\n  [\n    \"url\",\n    \"ethnicity\",\n    \"country\",\n    \"hair_color\",\n    \"eye_color\",\n    \"height\",\n    \"weight\",\n    \"measurements\",\n    \"fake_tits\",\n    \"penis_length\",\n    \"circumcised\",\n    \"career_start\",\n    \"career_end\",\n    \"tattoos\",\n    \"piercings\",\n    \"aliases\",\n    \"gender\",\n    \"birthdate\",\n    \"death_date\",\n    \"disambiguation\",\n    \"tags\",\n    \"image\",\n    \"details\",\n    \"rating\",\n    \"stash_id\",\n  ]\n);\n\nexport const GalleryIsMissingCriterionOption = new IsMissingCriterionOption(\n  \"isMissing\",\n  \"is_missing\",\n  [\n    \"title\",\n    \"code\",\n    \"details\",\n    \"photographer\",\n    \"url\",\n    \"date\",\n    \"rating\",\n    \"cover\",\n    \"studio\",\n    \"performers\",\n    \"tags\",\n    \"scenes\",\n  ]\n);\n\nexport const TagIsMissingCriterionOption = new IsMissingCriterionOption(\n  \"isMissing\",\n  \"is_missing\",\n  [\"image\", \"aliases\", \"description\", \"stash_id\"]\n);\n\nexport const StudioIsMissingCriterionOption = new IsMissingCriterionOption(\n  \"isMissing\",\n  \"is_missing\",\n  [\"image\", \"stash_id\", \"details\", \"url\", \"aliases\", \"tags\", \"rating\"]\n);\n\nexport const GroupIsMissingCriterionOption = new IsMissingCriterionOption(\n  \"isMissing\",\n  \"is_missing\",\n  [\n    \"aliases\",\n    \"description\",\n    \"director\",\n    \"date\",\n    \"url\",\n    \"rating\",\n    \"studio\",\n    \"performers\",\n    \"tags\",\n    \"front_image\",\n    \"back_image\",\n    \"scenes\",\n  ]\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/organized.ts",
    "content": "import { BooleanCriterion, BooleanCriterionOption } from \"./criterion\";\n\nexport const OrganizedCriterionOption = new BooleanCriterionOption(\n  \"organized\",\n  \"organized\",\n  () => new OrganizedCriterion()\n);\n\nexport class OrganizedCriterion extends BooleanCriterion {\n  constructor() {\n    super(OrganizedCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/orientation.ts",
    "content": "import { orientationStrings, stringToOrientation } from \"src/utils/orientation\";\nimport { CriterionType } from \"../types\";\nimport { ModifierCriterionOption, MultiStringCriterion } from \"./criterion\";\nimport {\n  OrientationCriterionInput,\n  OrientationEnum,\n} from \"src/core/generated-graphql\";\n\nexport class OrientationCriterion extends MultiStringCriterion {\n  public toCriterionInput(): OrientationCriterionInput {\n    return {\n      value: this.value\n        .map((v) => stringToOrientation(v))\n        .filter((v) => v) as OrientationEnum[],\n    };\n  }\n}\n\nclass BaseOrientationCriterionOption extends ModifierCriterionOption {\n  constructor(value: CriterionType) {\n    super({\n      messageID: value,\n      type: value,\n      options: orientationStrings,\n      makeCriterion: () => new OrientationCriterion(this),\n    });\n  }\n}\n\nexport const OrientationCriterionOption = new BaseOrientationCriterionOption(\n  \"orientation\"\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/path.ts",
    "content": "import { CriterionModifier } from \"src/core/generated-graphql\";\nimport { StringCriterion, StringCriterionOption } from \"./criterion\";\n\nexport const PathCriterionOption = new StringCriterionOption({\n  messageID: \"path\",\n  type: \"path\",\n  defaultModifier: CriterionModifier.Includes,\n  makeCriterion: () => new PathCriterion(),\n});\n\nexport class PathCriterion extends StringCriterion {\n  constructor() {\n    super(PathCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/performers.ts",
    "content": "/* eslint @typescript-eslint/no-unused-vars: [\"error\", { \"argsIgnorePattern\": \"^_\" }] */\nimport { IntlShape } from \"react-intl\";\nimport {\n  CriterionModifier,\n  MultiCriterionInput,\n} from \"src/core/generated-graphql\";\nimport { ILabeledId, ILabeledValueListValue } from \"../types\";\nimport {\n  ModifierCriterion,\n  ModifierCriterionOption,\n  ISavedCriterion,\n} from \"./criterion\";\n\nconst modifierOptions = [\n  CriterionModifier.IncludesAll,\n  CriterionModifier.Includes,\n  CriterionModifier.Equals,\n  CriterionModifier.IsNull,\n  CriterionModifier.NotNull,\n];\n\nconst defaultModifier = CriterionModifier.IncludesAll;\n\nconst inputType = \"performers\";\n\nexport const PerformersCriterionOption = new ModifierCriterionOption({\n  messageID: \"performers\",\n  type: \"performers\",\n  modifierOptions,\n  defaultModifier,\n  inputType,\n  makeCriterion: () => new PerformersCriterion(),\n});\n\nexport class PerformersCriterion extends ModifierCriterion<ILabeledValueListValue> {\n  constructor() {\n    super(PerformersCriterionOption, { items: [], excluded: [] });\n  }\n\n  public cloneValues() {\n    this.value = {\n      ...this.value,\n      items: this.value.items.map((v) => ({ ...v })),\n      excluded: this.value.excluded.map((v) => ({ ...v })),\n    };\n  }\n\n  override get modifier(): CriterionModifier {\n    return this._modifier;\n  }\n  override set modifier(value: CriterionModifier) {\n    this._modifier = value;\n\n    // excluded only makes sense for includes and includes all\n    // reset it for other modifiers\n    if (\n      value !== CriterionModifier.Includes &&\n      value !== CriterionModifier.IncludesAll\n    ) {\n      this.value.excluded = [];\n    }\n  }\n\n  public setFromSavedCriterion(\n    criterion: ISavedCriterion<ILabeledId[] | ILabeledValueListValue>\n  ) {\n    const { modifier, value } = criterion;\n\n    // #3619 - the format of performer value was changed from an array\n    // to an object. Check for both formats.\n    if (Array.isArray(value)) {\n      this.value = { items: value, excluded: [] };\n    } else if (value !== undefined) {\n      this.value = {\n        items: value.items || [],\n        excluded: value.excluded || [],\n      };\n    }\n\n    // if the previous modifier was excludes, replace it with the equivalent includes criterion\n    // this is what is done on the backend\n    if (modifier === CriterionModifier.Excludes) {\n      this.modifier = CriterionModifier.Includes;\n      this.value.excluded = [...this.value.excluded, ...this.value.items];\n      this.value.items = [];\n    } else {\n      this.modifier = modifier;\n    }\n  }\n\n  protected getLabelValue(_intl: IntlShape): string {\n    return this.value.items.map((v) => v.label).join(\", \");\n  }\n\n  public toCriterionInput(): MultiCriterionInput {\n    let excludes: string[] = [];\n    if (this.value.excluded) {\n      excludes = this.value.excluded.map((v) => v.id);\n    }\n    return {\n      value: this.value.items.map((v) => v.id),\n      excludes: excludes,\n      modifier: this.modifier,\n    };\n  }\n\n  public isValid(): boolean {\n    if (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull\n    ) {\n      return true;\n    }\n\n    return (\n      this.value.items.length > 0 ||\n      (this.value.excluded && this.value.excluded.length > 0)\n    );\n  }\n\n  public getLabel(intl: IntlShape): string {\n    let id = \"criterion_modifier.format_string\";\n    let modifierString = ModifierCriterion.getModifierLabel(\n      intl,\n      this.modifier\n    );\n    let valueString = \"\";\n    let excludedString = \"\";\n\n    if (\n      this.modifier !== CriterionModifier.IsNull &&\n      this.modifier !== CriterionModifier.NotNull\n    ) {\n      valueString = this.value.items.map((v) => v.label).join(\", \");\n\n      if (this.value.excluded && this.value.excluded.length > 0) {\n        if (this.value.items.length === 0) {\n          modifierString = ModifierCriterion.getModifierLabel(\n            intl,\n            CriterionModifier.Excludes\n          );\n          valueString = this.value.excluded.map((v) => v.label).join(\", \");\n        } else {\n          id = \"criterion_modifier.format_string_excludes\";\n          excludedString = this.value.excluded.map((v) => v.label).join(\", \");\n        }\n      }\n    }\n\n    return intl.formatMessage(\n      { id },\n      {\n        criterion: intl.formatMessage({ id: this.criterionOption.messageID }),\n        modifierString,\n        valueString,\n        excludedString,\n      }\n    );\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/phash.ts",
    "content": "import {\n  CriterionModifier,\n  PhashDistanceCriterionInput,\n  DuplicationCriterionInput,\n} from \"src/core/generated-graphql\";\nimport { IDuplicationValue, IPhashDistanceValue } from \"../types\";\nimport { ModifierCriterion, ModifierCriterionOption } from \"./criterion\";\nimport { IntlShape } from \"react-intl\";\n\n// Shared mapping of duplication field IDs to their i18n message IDs\nexport const DUPLICATION_FIELD_MESSAGE_IDS = {\n  phash: \"media_info.phash\",\n  stash_id: \"stash_id\",\n  title: \"title\",\n  url: \"url\",\n} as const;\n\nexport type DuplicationFieldId = keyof typeof DUPLICATION_FIELD_MESSAGE_IDS;\n\nexport const DUPLICATION_FIELD_IDS: DuplicationFieldId[] = [\n  \"phash\",\n  \"stash_id\",\n  \"title\",\n  \"url\",\n];\n\nexport const PhashCriterionOption = new ModifierCriterionOption({\n  messageID: \"media_info.phash\",\n  type: \"phash_distance\",\n  inputType: \"text\",\n  modifierOptions: [\n    CriterionModifier.Equals,\n    CriterionModifier.NotEquals,\n    CriterionModifier.IsNull,\n    CriterionModifier.NotNull,\n  ],\n  makeCriterion: () => new PhashCriterion(),\n});\n\nexport class PhashCriterion extends ModifierCriterion<IPhashDistanceValue> {\n  constructor() {\n    super(PhashCriterionOption, { value: \"\", distance: 0 });\n  }\n\n  public cloneValues() {\n    this.value = { ...this.value };\n  }\n\n  protected getLabelValue() {\n    const { value, distance } = this.value;\n    if (\n      (this.modifier === CriterionModifier.Equals ||\n        this.modifier === CriterionModifier.NotEquals) &&\n      distance\n    ) {\n      return `${value} (${distance})`;\n    } else {\n      return `${value}`;\n    }\n  }\n\n  public toCriterionInput(): PhashDistanceCriterionInput {\n    return {\n      value: this.value.value,\n      modifier: this.modifier,\n      distance: this.value.distance,\n    };\n  }\n}\n\nexport const DuplicatedCriterionOption = new ModifierCriterionOption({\n  messageID: \"duplicated\",\n  type: \"duplicated\",\n  modifierOptions: [], // No modifiers for this filter\n  defaultModifier: CriterionModifier.Equals,\n  makeCriterion: () => new DuplicatedCriterion(),\n});\n\nexport class DuplicatedCriterion extends ModifierCriterion<IDuplicationValue> {\n  constructor() {\n    super(DuplicatedCriterionOption, {});\n  }\n\n  public cloneValues() {\n    this.value = { ...this.value };\n  }\n\n  // Override getLabel to provide custom formatting for duplication fields\n  public getLabel(intl: IntlShape): string {\n    const parts: string[] = [];\n    const trueLabel = intl.formatMessage({ id: \"true\" });\n    const falseLabel = intl.formatMessage({ id: \"false\" });\n\n    for (const fieldId of DUPLICATION_FIELD_IDS) {\n      const fieldValue = this.value[fieldId];\n      if (fieldValue !== undefined) {\n        const label = intl.formatMessage({\n          id: DUPLICATION_FIELD_MESSAGE_IDS[fieldId],\n        });\n        parts.push(`${label}: ${fieldValue ? trueLabel : falseLabel}`);\n      }\n    }\n\n    // Handle legacy duplicated field\n    if (parts.length === 0 && this.value.duplicated !== undefined) {\n      const label = intl.formatMessage({ id: \"duplicated_phash\" });\n      return `${label}: ${this.value.duplicated ? trueLabel : falseLabel}`;\n    }\n\n    if (parts.length === 0) {\n      return intl.formatMessage({ id: \"duplicated\" });\n    }\n\n    return parts.join(\", \");\n  }\n\n  protected getLabelValue(intl: IntlShape): string {\n    // Required by abstract class - returns basic label when getLabel isn't overridden\n    return intl.formatMessage({ id: \"duplicated\" });\n  }\n\n  protected toCriterionInput(): DuplicationCriterionInput {\n    return {\n      duplicated: this.value.duplicated,\n      distance: this.value.distance,\n      phash: this.value.phash,\n      url: this.value.url,\n      stash_id: this.value.stash_id,\n      title: this.value.title,\n    };\n  }\n\n  // Override to handle legacy saved formats\n  public setFromSavedCriterion(criterion: unknown): void {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const c = criterion as any;\n\n    // Handle various saved formats\n    if (c.value !== undefined) {\n      // New format: { value: { phash: true, ... } }\n      if (typeof c.value === \"object\") {\n        this.value = c.value as IDuplicationValue;\n      } else if (typeof c.value === \"string\") {\n        // Legacy format: { value: \"true\" } - convert to phash\n        this.value = { phash: c.value === \"true\" };\n      }\n    } else if (typeof c === \"object\") {\n      // Direct value format\n      this.value = c as IDuplicationValue;\n    }\n\n    if (c.modifier) {\n      this.modifier = c.modifier;\n    }\n  }\n\n  public isValid(): boolean {\n    // Check if any duplication field is set\n    const hasFieldSet = DUPLICATION_FIELD_IDS.some(\n      (fieldId) => this.value[fieldId] !== undefined\n    );\n    return hasFieldSet || this.value.duplicated !== undefined;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/rating.ts",
    "content": "import {\n  convertFromRatingFormat,\n  convertToRatingFormat,\n  defaultRatingSystemOptions,\n  RatingSystemOptions,\n} from \"src/utils/rating\";\nimport {\n  ConfigDataFragment,\n  CriterionModifier,\n  IntCriterionInput,\n} from \"src/core/generated-graphql\";\nimport { INumberValue } from \"../types\";\nimport {\n  encodeRangeValue,\n  ModifierCriterion,\n  ModifierCriterionOption,\n} from \"./criterion\";\n\nconst modifierOptions = [\n  CriterionModifier.Equals,\n  CriterionModifier.NotEquals,\n  CriterionModifier.GreaterThan,\n  CriterionModifier.LessThan,\n  CriterionModifier.Between,\n  CriterionModifier.NotBetween,\n  CriterionModifier.IsNull,\n  CriterionModifier.NotNull,\n];\n\nfunction getRatingSystemOptions(config?: ConfigDataFragment) {\n  return config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;\n}\n\nexport const RatingCriterionOption = new ModifierCriterionOption({\n  messageID: \"rating\",\n  type: \"rating100\",\n  modifierOptions,\n  defaultModifier: CriterionModifier.Equals,\n  makeCriterion: (o, config) =>\n    new RatingCriterion(getRatingSystemOptions(config)),\n  inputType: \"number\",\n});\n\nexport class RatingCriterion extends ModifierCriterion<INumberValue> {\n  ratingSystem: RatingSystemOptions;\n\n  constructor(ratingSystem: RatingSystemOptions) {\n    super(RatingCriterionOption, { value: 0, value2: undefined });\n    this.ratingSystem = ratingSystem;\n  }\n\n  public cloneValues() {\n    this.value = { ...this.value };\n  }\n\n  public get value(): INumberValue {\n    return this._value;\n  }\n  public set value(newValue: number | INumberValue) {\n    // backwards compatibility - if this.value is a number, use that\n    if (typeof newValue !== \"object\") {\n      this._value = {\n        value: convertFromRatingFormat(newValue, this.ratingSystem.type),\n        value2: undefined,\n      };\n    } else {\n      this._value = newValue;\n    }\n  }\n\n  public toCriterionInput(): IntCriterionInput {\n    return {\n      modifier: this.modifier,\n      value: this.value.value ?? 0,\n      value2: this.value.value2,\n    };\n  }\n\n  public setFromSavedCriterion(c: {\n    modifier: CriterionModifier;\n    value: number | INumberValue;\n    value2?: number;\n  }) {\n    super.setFromSavedCriterion(c);\n    // this.value = decodeRangeValue(c);\n  }\n\n  protected encodeValue(): unknown {\n    return encodeRangeValue(this.modifier, this.value);\n  }\n\n  protected getLabelValue() {\n    const { value, value2 } = this.value;\n    if (\n      this.modifier === CriterionModifier.Between ||\n      this.modifier === CriterionModifier.NotBetween\n    ) {\n      return `${convertToRatingFormat(value, this.ratingSystem) ?? 0}, ${\n        convertToRatingFormat(value2, this.ratingSystem) ?? 0\n      }`;\n    } else {\n      return `${convertToRatingFormat(value, this.ratingSystem) ?? 0}`;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/resolution.ts",
    "content": "import {\n  ResolutionCriterionInput,\n  CriterionModifier,\n} from \"src/core/generated-graphql\";\nimport { stringToResolution, resolutionStrings } from \"src/utils/resolution\";\nimport { CriterionType } from \"../types\";\nimport {\n  ModifierCriterion,\n  ModifierCriterionOption,\n  CriterionValue,\n  StringCriterion,\n} from \"./criterion\";\n\nclass BaseResolutionCriterionOption extends ModifierCriterionOption {\n  constructor(\n    value: CriterionType,\n    makeCriterion: () => ModifierCriterion<CriterionValue>\n  ) {\n    super({\n      messageID: value,\n      type: value,\n      modifierOptions: [\n        CriterionModifier.Equals,\n        CriterionModifier.NotEquals,\n        CriterionModifier.GreaterThan,\n        CriterionModifier.LessThan,\n      ],\n      options: resolutionStrings,\n      makeCriterion,\n    });\n  }\n}\n\nclass BaseResolutionCriterion extends StringCriterion {\n  public toCriterionInput(): ResolutionCriterionInput | undefined {\n    const value = stringToResolution(this.value);\n\n    if (value !== undefined) {\n      return {\n        value,\n        modifier: this.modifier,\n      };\n    }\n  }\n}\n\nexport const ResolutionCriterionOption = new BaseResolutionCriterionOption(\n  \"resolution\",\n  () => new ResolutionCriterion()\n);\n\nexport class ResolutionCriterion extends BaseResolutionCriterion {\n  constructor() {\n    super(ResolutionCriterionOption);\n  }\n}\n\nexport const AverageResolutionCriterionOption =\n  new BaseResolutionCriterionOption(\n    \"average_resolution\",\n    () => new AverageResolutionCriterion()\n  );\n\nexport class AverageResolutionCriterion extends BaseResolutionCriterion {\n  constructor() {\n    super(AverageResolutionCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/scenes.ts",
    "content": "import {\n  ModifierCriterionOption,\n  ILabeledIdCriterion,\n  ILabeledIdCriterionOption,\n} from \"./criterion\";\nimport { CriterionModifier } from \"src/core/generated-graphql\";\n\nconst inputType = \"scenes\";\n\nexport const ScenesCriterionOption = new ILabeledIdCriterionOption(\n  \"scenes\",\n  \"scenes\",\n  true,\n  inputType,\n  () => new ScenesCriterion()\n);\n\nexport class ScenesCriterion extends ILabeledIdCriterion {\n  constructor() {\n    super(ScenesCriterionOption);\n  }\n}\n\nconst modifierOptions = [\n  CriterionModifier.Includes,\n  CriterionModifier.Excludes,\n];\n\nconst defaultModifier = CriterionModifier.Includes;\n\nexport const MarkersScenesCriterionOption = new ModifierCriterionOption({\n  messageID: \"scenes\",\n  type: \"scenes\",\n  modifierOptions,\n  defaultModifier,\n  inputType,\n  makeCriterion: () => new MarkersScenesCriterion(),\n});\n\nexport class MarkersScenesCriterion extends ILabeledIdCriterion {\n  constructor() {\n    super(MarkersScenesCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/stash-ids.ts",
    "content": "/* eslint @typescript-eslint/no-unused-vars: [\"error\", { \"argsIgnorePattern\": \"^_\" }] */\nimport { IntlShape } from \"react-intl\";\nimport {\n  CriterionModifier,\n  StashIdCriterionInput,\n} from \"src/core/generated-graphql\";\nimport { IStashIDValue } from \"../types\";\nimport {\n  ISavedCriterion,\n  ModifierCriterion,\n  ModifierCriterionOption,\n} from \"./criterion\";\n\nexport const StashIDCriterionOption = new ModifierCriterionOption({\n  messageID: \"stash_id\",\n  type: \"stash_id_endpoint\",\n  modifierOptions: [\n    CriterionModifier.Equals,\n    CriterionModifier.NotEquals,\n    CriterionModifier.IsNull,\n    CriterionModifier.NotNull,\n  ],\n  makeCriterion: () => new StashIDCriterion(),\n});\n\nexport class StashIDCriterion extends ModifierCriterion<IStashIDValue> {\n  constructor() {\n    super(StashIDCriterionOption, {\n      endpoint: \"\",\n      stashID: \"\",\n    });\n  }\n\n  public cloneValues() {\n    this.value = { ...this.value };\n  }\n\n  public get value(): IStashIDValue {\n    return this._value;\n  }\n\n  public set value(newValue: string | IStashIDValue) {\n    // backwards compatibility - if this.value is a string, use that as stash_id\n    if (typeof newValue !== \"object\") {\n      this._value = {\n        endpoint: \"\",\n        stashID: newValue,\n      };\n    } else {\n      this._value = newValue;\n    }\n  }\n\n  public toCriterionInput(): StashIdCriterionInput {\n    return {\n      endpoint: this.value.endpoint,\n      stash_id: this.value.stashID,\n      modifier: this.modifier,\n    };\n  }\n\n  public getLabel(intl: IntlShape): string {\n    const modifierString = ModifierCriterion.getModifierLabel(\n      intl,\n      this.modifier\n    );\n    let valueString = \"\";\n\n    if (\n      this.modifier !== CriterionModifier.IsNull &&\n      this.modifier !== CriterionModifier.NotNull\n    ) {\n      valueString = this.getLabelValue(intl);\n    } else if (this.value.endpoint) {\n      valueString = \"(\" + this.value.endpoint + \")\";\n    }\n\n    return intl.formatMessage(\n      { id: \"criterion_modifier.format_string\" },\n      {\n        criterion: intl.formatMessage({ id: this.criterionOption.messageID }),\n        modifierString,\n        valueString,\n      }\n    );\n  }\n\n  protected getLabelValue(_intl: IntlShape) {\n    let ret = this.value.stashID;\n    if (this.value.endpoint) {\n      ret += \" (\" + this.value.endpoint + \")\";\n    }\n\n    return ret;\n  }\n\n  public setFromSavedCriterion(\n    criterion: StashIdCriterionInput | ISavedCriterion<StashIdCriterionInput>\n  ) {\n    super.setFromSavedCriterion(criterion);\n\n    // const asStashIDValue = criterion as StashIdCriterionInput;\n    // const asSavedCriterion =\n    //   criterion as ISavedCriterion<StashIdCriterionInput>;\n    // if (asStashIDValue.endpoint || asStashIDValue.stash_id) {\n    //   this.value = {\n    //     endpoint: asStashIDValue.endpoint ?? \"\",\n    //     stashID: asStashIDValue.stash_id ?? \"\",\n    //   };\n    // } else if (asSavedCriterion.value) {\n    //   this.value = {\n    //     endpoint: asSavedCriterion.value.endpoint ?? \"\",\n    //     stashID: asSavedCriterion.value.stash_id ?? \"\",\n    //   };\n    // }\n  }\n\n  public toQueryParams(): Record<string, unknown> {\n    super.toQueryParams();\n    let encodedCriterion;\n    if (\n      (this.modifier === CriterionModifier.IsNull ||\n        this.modifier === CriterionModifier.NotNull) &&\n      !this.value.endpoint\n    ) {\n      encodedCriterion = {\n        type: this.criterionOption.type,\n        modifier: this.modifier,\n      };\n    } else {\n      encodedCriterion = {\n        type: this.criterionOption.type,\n        value: this.value,\n        modifier: this.modifier,\n      };\n    }\n    return encodedCriterion;\n  }\n\n  public isValid(): boolean {\n    return (\n      this.modifier === CriterionModifier.IsNull ||\n      this.modifier === CriterionModifier.NotNull ||\n      this.value.stashID.length > 0\n    );\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/studios.ts",
    "content": "import { CriterionModifier } from \"src/core/generated-graphql\";\nimport {\n  ModifierCriterionOption,\n  IHierarchicalLabeledIdCriterion,\n  ILabeledIdCriterion,\n  ILabeledIdCriterionOption,\n} from \"./criterion\";\n\nconst modifierOptions = [\n  CriterionModifier.Includes,\n  CriterionModifier.IsNull,\n  CriterionModifier.NotNull,\n];\n\nconst defaultModifier = CriterionModifier.Includes;\nconst inputType = \"studios\";\n\nexport const StudiosCriterionOption = new ModifierCriterionOption({\n  messageID: \"studios\",\n  type: \"studios\",\n  modifierOptions,\n  defaultModifier,\n  inputType,\n  makeCriterion: () => new StudiosCriterion(),\n});\n\nexport class StudiosCriterion extends IHierarchicalLabeledIdCriterion {\n  constructor() {\n    super(StudiosCriterionOption);\n  }\n}\n\nexport const ParentStudiosCriterionOption = new ILabeledIdCriterionOption(\n  \"parent_studios\",\n  \"parents\",\n  false,\n  inputType,\n  () => new ParentStudiosCriterion()\n);\n\nexport class ParentStudiosCriterion extends ILabeledIdCriterion {\n  constructor() {\n    super(ParentStudiosCriterionOption);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/criteria/tags.ts",
    "content": "import { CriterionModifier } from \"src/core/generated-graphql\";\nimport {\n  ModifierCriterionOption,\n  IHierarchicalLabeledIdCriterion,\n} from \"./criterion\";\nimport { CriterionType } from \"../types\";\n\nconst defaultModifierOptions = [\n  CriterionModifier.IncludesAll,\n  CriterionModifier.Includes,\n  CriterionModifier.Equals,\n  CriterionModifier.IsNull,\n  CriterionModifier.NotNull,\n];\n\nconst withoutEqualsModifierOptions = [\n  CriterionModifier.IncludesAll,\n  CriterionModifier.Includes,\n  CriterionModifier.IsNull,\n  CriterionModifier.NotNull,\n];\n\nconst defaultModifier = CriterionModifier.IncludesAll;\nconst inputType = \"tags\";\n\nclass BaseTagsCriterionOption extends ModifierCriterionOption {\n  constructor(\n    messageID: string,\n    type: CriterionType,\n    modifierOptions: CriterionModifier[]\n  ) {\n    super({\n      messageID,\n      type,\n      modifierOptions,\n      defaultModifier,\n      inputType,\n      makeCriterion: () => new TagsCriterion(this),\n    });\n  }\n}\n\nexport const TagsCriterionOption = new BaseTagsCriterionOption(\n  \"tags\",\n  \"tags\",\n  defaultModifierOptions\n);\n\nexport const SceneTagsCriterionOption = new BaseTagsCriterionOption(\n  \"scene_tags\",\n  \"scene_tags\",\n  defaultModifierOptions\n);\n\nexport const PerformerTagsCriterionOption = new BaseTagsCriterionOption(\n  \"performer_tags\",\n  \"performer_tags\",\n  withoutEqualsModifierOptions\n);\n\n// TODO - this requires using a nested studios_filter which needs to be added separately\n// export const StudioTagsCriterionOption = new BaseTagsCriterionOption(\n//   \"studio_tags\",\n//   \"studio_tags\",\n//   withoutEqualsModifierOptions\n// );\n\nexport const ParentTagsCriterionOption = new BaseTagsCriterionOption(\n  \"parent_tags\",\n  \"parents\",\n  withoutEqualsModifierOptions\n);\n\nexport const ChildTagsCriterionOption = new BaseTagsCriterionOption(\n  \"sub_tags\",\n  \"children\",\n  withoutEqualsModifierOptions\n);\n\nexport class TagsCriterion extends IHierarchicalLabeledIdCriterion {}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/factory.ts",
    "content": "import { FilterMode } from \"src/core/generated-graphql\";\nimport { ListFilterOptions } from \"./filter-options\";\nimport { GalleryListFilterOptions } from \"./galleries\";\nimport { ImageListFilterOptions } from \"./images\";\nimport { GroupListFilterOptions } from \"./groups\";\nimport { PerformerListFilterOptions } from \"./performers\";\nimport { SceneMarkerListFilterOptions } from \"./scene-markers\";\nimport { SceneListFilterOptions } from \"./scenes\";\nimport { StudioListFilterOptions } from \"./studios\";\nimport { TagListFilterOptions } from \"./tags\";\n\nexport function getFilterOptions(mode: FilterMode): ListFilterOptions {\n  switch (mode) {\n    case FilterMode.Scenes:\n      return SceneListFilterOptions;\n    case FilterMode.Performers:\n      return PerformerListFilterOptions;\n    case FilterMode.Studios:\n      return StudioListFilterOptions;\n    case FilterMode.Galleries:\n      return GalleryListFilterOptions;\n    case FilterMode.SceneMarkers:\n      return SceneMarkerListFilterOptions;\n    case FilterMode.Movies:\n    case FilterMode.Groups:\n      return GroupListFilterOptions;\n    case FilterMode.Tags:\n      return TagListFilterOptions;\n    case FilterMode.Images:\n      return ImageListFilterOptions;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/filter-options.ts",
    "content": "import { CriterionOption } from \"./criteria/criterion\";\nimport { DisplayMode } from \"./types\";\n\nexport interface ISortByOption {\n  messageID: string;\n  value: string;\n  sfwMessageID?: string;\n}\n\nexport const MediaSortByOptions = [\n  \"title\",\n  \"path\",\n  \"rating\",\n  \"file_mod_time\",\n  \"tag_count\",\n  \"performer_count\",\n  \"random\",\n];\n\nexport class ListFilterOptions {\n  public readonly defaultSortBy: string = \"\";\n  public readonly sortByOptions: ISortByOption[] = [];\n  public readonly displayModeOptions: DisplayMode[] = [];\n  public readonly criterionOptions: CriterionOption[] = [];\n\n  public static createSortBy(value: string): ISortByOption {\n    return {\n      messageID: value,\n      value,\n    };\n  }\n\n  constructor(\n    defaultSortBy: string,\n    sortByOptions: ISortByOption[],\n    displayModeOptions: DisplayMode[],\n    criterionOptions: CriterionOption[]\n  ) {\n    this.defaultSortBy = defaultSortBy;\n    this.sortByOptions = [\n      ...sortByOptions,\n      ListFilterOptions.createSortBy(\"created_at\"),\n      ListFilterOptions.createSortBy(\"updated_at\"),\n    ];\n    this.displayModeOptions = displayModeOptions;\n    this.criterionOptions = criterionOptions;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/filter.ts",
    "content": "import {\n  ConfigDataFragment,\n  FilterMode,\n  FindFilterType,\n  SavedFilterDataFragment,\n  SortDirectionEnum,\n} from \"src/core/generated-graphql\";\nimport { Criterion, UnsupportedCriterionOption } from \"./criteria/criterion\";\nimport { getFilterOptions } from \"./factory\";\nimport { CriterionType, DisplayMode, SavedUIOptions } from \"./types\";\nimport { ListFilterOptions } from \"./filter-options\";\nimport { CustomFieldsCriterion } from \"./criteria/custom-fields\";\n\ninterface IDecodedParams {\n  perPage?: number;\n  sortby?: string;\n  sortdir?: string;\n  disp?: DisplayMode;\n  q?: string;\n  p?: number;\n  z?: number;\n  c?: string[];\n}\n\ninterface IEncodedParams {\n  perPage?: string | null;\n  sortby?: string | null;\n  sortdir?: string | null;\n  disp?: string | null;\n  q?: string | null;\n  p?: string | null;\n  z?: string | null;\n  c?: string[];\n}\n\nconst DEFAULT_PARAMS = {\n  sortDirection: SortDirectionEnum.Asc,\n  displayMode: DisplayMode.Grid,\n  currentPage: 1,\n  itemsPerPage: 40,\n};\n\n// TODO: handle customCriteria\nexport class ListFilterModel {\n  public readonly mode: FilterMode;\n  public readonly options: ListFilterOptions;\n  private config?: ConfigDataFragment;\n  public searchTerm: string = \"\";\n  public currentPage = DEFAULT_PARAMS.currentPage;\n  public itemsPerPage = DEFAULT_PARAMS.itemsPerPage;\n  public sortDirection: SortDirectionEnum = DEFAULT_PARAMS.sortDirection;\n  public sortBy?: string;\n  public displayMode: DisplayMode = DEFAULT_PARAMS.displayMode;\n  public zoomIndex: number = 1;\n  public criteria: Array<Criterion> = [];\n  public randomSeed = -1;\n  private defaultZoomIndex: number = 1;\n\n  public constructor(\n    mode: FilterMode,\n    config?: ConfigDataFragment,\n    options?: {\n      defaultZoomIndex?: number;\n      defaultSortBy?: string;\n      defaultSortDir?: SortDirectionEnum;\n    }\n  ) {\n    this.mode = mode;\n    this.config = config;\n    this.options = getFilterOptions(mode);\n    const { defaultSortBy, displayModeOptions } = this.options;\n\n    if (options?.defaultSortBy) {\n      this.sortBy = options.defaultSortBy;\n      if (options.defaultSortDir) {\n        this.sortDirection = options.defaultSortDir;\n      }\n    } else {\n      this.sortBy = defaultSortBy;\n      if (this.sortBy === \"date\") {\n        this.sortDirection = SortDirectionEnum.Desc;\n      }\n    }\n    this.displayMode = displayModeOptions[0];\n    if (options?.defaultZoomIndex !== undefined) {\n      this.defaultZoomIndex = options.defaultZoomIndex;\n      this.zoomIndex = options.defaultZoomIndex;\n    }\n  }\n\n  public clone() {\n    const ret = Object.assign(\n      new ListFilterModel(this.mode, this.config),\n      this\n    );\n    ret.criteria = this.criteria.map((c) => c.clone());\n    return ret;\n  }\n\n  public empty() {\n    return new ListFilterModel(this.mode, this.config, {\n      defaultZoomIndex: this.defaultZoomIndex,\n    });\n  }\n\n  // returns a clone of the filter for metadata fetching\n  // this removes the sort, page size and page number and zoom index\n  public metadataInfo() {\n    const clone = this.clone();\n    clone.sortBy = undefined;\n    clone.randomSeed = -1;\n    clone.currentPage = 1;\n    clone.sortDirection = DEFAULT_PARAMS.sortDirection;\n    clone.itemsPerPage = 0;\n    clone.zoomIndex = 1;\n    clone.displayMode = DEFAULT_PARAMS.displayMode;\n    return clone;\n  }\n\n  // returns the number of filters applied\n  public count() {\n    // don't include search term\n    return this.criteria.length;\n  }\n\n  public configureFromDecodedParams(params: IDecodedParams) {\n    if (params.perPage !== undefined) {\n      this.itemsPerPage = params.perPage;\n    }\n    if (params.sortby !== undefined) {\n      this.sortBy = params.sortby;\n\n      // parse the random seed if provided\n      const match = this.sortBy.match(/^random_(\\d+)$/);\n      if (match) {\n        this.sortBy = \"random\";\n        this.randomSeed = Number.parseInt(match[1], 10);\n      }\n    }\n    if (params.sortdir !== undefined) {\n      this.sortDirection =\n        params.sortdir === \"desc\"\n          ? SortDirectionEnum.Desc\n          : SortDirectionEnum.Asc;\n    } else {\n      // #3193 - sortdir undefined means asc\n      // #3559 - unless sortby is date, then desc\n      this.sortDirection =\n        params.sortby === \"date\"\n          ? SortDirectionEnum.Desc\n          : SortDirectionEnum.Asc;\n    }\n    if (params.disp !== undefined) {\n      this.displayMode = params.disp;\n    }\n    if (params.q !== undefined) {\n      this.searchTerm = params.q;\n    }\n    this.currentPage = params.p ?? 1;\n    if (params.z !== undefined) {\n      this.zoomIndex = params.z;\n    }\n\n    this.criteria = [];\n    if (params.c !== undefined) {\n      for (const jsonString of params.c) {\n        try {\n          const { type: criterionType, ...savedCriterion } =\n            JSON.parse(jsonString);\n\n          const criterion = this.makeCriterion(criterionType);\n          criterion.fromDecodedParams(savedCriterion);\n\n          this.criteria.push(criterion);\n        } catch (err) {\n          // eslint-disable-next-line no-console\n          console.error(\"Failed to parse encoded criterion:\", err);\n        }\n      }\n    }\n  }\n\n  // Does not decode any URL-encoding, only type conversions\n  public static decodeParams(params: IEncodedParams): IDecodedParams {\n    const ret: IDecodedParams = {};\n\n    if (params.perPage) {\n      ret.perPage = Number.parseInt(params.perPage, 10);\n    }\n    if (params.sortby) {\n      ret.sortby = params.sortby;\n    }\n    if (params.sortdir) {\n      ret.sortdir = params.sortdir;\n    }\n    if (params.disp) {\n      ret.disp = Number.parseInt(params.disp, 10);\n    }\n    if (params.q) {\n      ret.q = params.q;\n    }\n    if (params.p) {\n      ret.p = Number.parseInt(params.p, 10);\n    }\n    if (params.z) {\n      const zoomIndex = Number.parseInt(params.z, 10);\n      if (zoomIndex >= 0) {\n        ret.z = zoomIndex;\n      }\n    }\n\n    if (params.c && params.c.length !== 0) {\n      ret.c = params.c.map((jsonString) =>\n        ListFilterModel.translateJSON(jsonString, true)\n      );\n    }\n\n    return ret;\n  }\n\n  private static translateJSON(jsonString: string, decoding: boolean) {\n    let inString = false;\n    let escape = false;\n    return [...jsonString]\n      .map((c) => {\n        if (escape) {\n          // this character has been escaped, skip\n          escape = false;\n          return c;\n        }\n\n        switch (c) {\n          case \"\\\\\":\n            // escape the next character if in a string\n            if (inString) {\n              escape = true;\n            }\n            break;\n          case '\"':\n            // unescaped quote, toggle inString\n            inString = !inString;\n            break;\n          case \"(\":\n            // decode only: restore ( to { if not in a string\n            if (decoding && !inString) {\n              return \"{\";\n            }\n            break;\n          case \")\":\n            // decode only: restore ) to } if not in a string\n            if (decoding && !inString) {\n              return \"}\";\n            }\n            break;\n          case \"{\":\n            // encode only: replace { with ( if not in a string\n            if (!decoding && !inString) {\n              return \"(\";\n            }\n            break;\n          case \"}\":\n            // encode only: replace } with ) if not in a string\n            if (!decoding && !inString) {\n              return \")\";\n            }\n            break;\n        }\n\n        return c;\n      })\n      .join(\"\");\n  }\n\n  public configureFromQueryString(queryString: string) {\n    const query = new URLSearchParams(queryString);\n    const params = {\n      perPage: query.get(\"perPage\"),\n      sortby: query.get(\"sortby\"),\n      sortdir: query.get(\"sortdir\"),\n      disp: query.get(\"disp\"),\n      q: query.get(\"q\"),\n      p: query.get(\"p\"),\n      z: query.get(\"z\"),\n      c: query.getAll(\"c\"),\n    };\n    const decoded = ListFilterModel.decodeParams(params);\n    this.configureFromDecodedParams(decoded);\n  }\n\n  public configureFromSavedFilter(savedFilter: SavedFilterDataFragment) {\n    const {\n      find_filter: findFilter,\n      object_filter: objectFilter,\n      ui_options: uiOptions,\n    } = savedFilter;\n\n    this.itemsPerPage = findFilter?.per_page ?? this.itemsPerPage;\n    this.sortBy = findFilter?.sort ?? this.sortBy;\n    // parse the random seed if provided\n    const match = this.sortBy?.match(/^random_(\\d+)$/);\n    if (match) {\n      this.sortBy = \"random\";\n      this.randomSeed = Number.parseInt(match[1], 10);\n    }\n    this.sortDirection = findFilter?.direction ?? this.sortDirection;\n    this.searchTerm = findFilter?.q ?? this.searchTerm;\n\n    this.displayMode = uiOptions?.display_mode ?? this.displayMode;\n    this.zoomIndex = uiOptions?.zoom_index ?? this.zoomIndex;\n\n    this.currentPage = 1;\n\n    this.criteria = [];\n    if (objectFilter) {\n      for (const [k, v] of Object.entries(objectFilter)) {\n        const criterion = this.makeCriterion(k as CriterionType);\n        criterion.setFromSavedCriterion(v);\n        this.criteria.push(criterion);\n      }\n    }\n  }\n\n  private setRandomSeed() {\n    if (this.sortBy === \"random\") {\n      // #321 - set the random seed if it is not set\n      if (this.randomSeed === -1) {\n        // generate 8-digit seed\n        this.randomSeed = Math.floor(Math.random() * 10 ** 8);\n      }\n    } else {\n      this.randomSeed = -1;\n    }\n  }\n\n  private getSortBy(): string | undefined {\n    this.setRandomSeed();\n\n    if (this.sortBy === \"random\") {\n      return `random_${this.randomSeed.toString()}`;\n    }\n\n    return this.sortBy;\n  }\n\n  // Returns query parameters with necessary parts URL-encoded\n  public getEncodedParams(): IEncodedParams {\n    const encodedCriteria: string[] = this.criteria.map((criterion) => {\n      const queryParams = criterion.toQueryParams();\n      let str = ListFilterModel.translateJSON(\n        JSON.stringify(queryParams),\n        false\n      );\n\n      // URL-encode other characters\n      str = encodeURI(str);\n\n      // only the reserved characters ?#&;=+ need to be URL-encoded\n      // as they have special meaning in query strings\n      str = str.replaceAll(\"?\", encodeURIComponent(\"?\"));\n      str = str.replaceAll(\"#\", encodeURIComponent(\"#\"));\n      str = str.replaceAll(\"&\", encodeURIComponent(\"&\"));\n      str = str.replaceAll(\";\", encodeURIComponent(\";\"));\n      str = str.replaceAll(\"=\", encodeURIComponent(\"=\"));\n      str = str.replaceAll(\"+\", encodeURIComponent(\"+\"));\n\n      return str;\n    });\n\n    return {\n      perPage:\n        this.itemsPerPage !== DEFAULT_PARAMS.itemsPerPage\n          ? String(this.itemsPerPage)\n          : undefined,\n      sortby: this.getSortBy(),\n      sortdir:\n        this.sortBy === \"date\"\n          ? this.sortDirection === SortDirectionEnum.Asc\n            ? \"asc\"\n            : undefined\n          : this.sortDirection === SortDirectionEnum.Desc\n          ? \"desc\"\n          : undefined,\n      disp:\n        this.displayMode !== DEFAULT_PARAMS.displayMode\n          ? String(this.displayMode)\n          : undefined,\n      q: this.searchTerm ? encodeURIComponent(this.searchTerm) : undefined,\n      p:\n        this.currentPage !== DEFAULT_PARAMS.currentPage\n          ? String(this.currentPage)\n          : undefined,\n      z:\n        this.zoomIndex !== this.defaultZoomIndex\n          ? String(this.zoomIndex)\n          : undefined,\n      c: encodedCriteria,\n    };\n  }\n\n  public makeQueryParameters(): string {\n    const query: string[] = [];\n    const params = this.getEncodedParams();\n\n    if (params.q) {\n      query.push(`q=${params.q}`);\n    }\n    if (params.c) {\n      for (const c of params.c) {\n        query.push(`c=${c}`);\n      }\n    }\n    if (params.sortby) {\n      query.push(`sortby=${params.sortby}`);\n    }\n    if (params.sortdir) {\n      query.push(`sortdir=${params.sortdir}`);\n    }\n    if (params.perPage) {\n      query.push(`perPage=${params.perPage}`);\n    }\n    if (params.disp) {\n      query.push(`disp=${params.disp}`);\n    }\n    if (params.z) {\n      query.push(`z=${params.z}`);\n    }\n    if (params.p) {\n      query.push(`p=${params.p}`);\n    }\n\n    return query.join(\"&\");\n  }\n\n  public makeCriterion(type: CriterionType) {\n    const { criterionOptions } = getFilterOptions(this.mode);\n\n    const option = criterionOptions.find((o) => o.type === type);\n\n    if (!option) {\n      return new UnsupportedCriterionOption(type).makeCriterion(this.config);\n    }\n\n    return option.makeCriterion(this.config);\n  }\n\n  public makeFindFilter(): FindFilterType {\n    return {\n      q: this.searchTerm,\n      page: this.currentPage,\n      per_page: this.itemsPerPage,\n      sort: this.getSortBy(),\n      direction: this.sortDirection,\n    };\n  }\n\n  public makeFilter() {\n    const output: Record<string, unknown> = {};\n    for (const c of this.criteria) {\n      c.applyToCriterionInput(output);\n    }\n    return output;\n  }\n\n  // TODO - this needs to just use makeFilter, but it needs a migration\n  public makeSavedFilter() {\n    const output: Record<string, unknown> = {};\n    for (const c of this.criteria) {\n      c.applyToSavedCriterion(output);\n    }\n    return output;\n  }\n\n  public makeSavedUIOptions(): SavedUIOptions {\n    return {\n      display_mode: this.displayMode,\n      zoom_index: this.zoomIndex,\n    };\n  }\n\n  public criteriaFor(type: CriterionType) {\n    return this.criteria.filter((c) => c.criterionOption.type === type);\n  }\n\n  public replaceCriteria(type: CriterionType, newCriteria: Criterion[]) {\n    const criteria = [\n      ...this.criteria.filter((c) => c.criterionOption.type !== type),\n      ...newCriteria,\n    ];\n\n    return this.setCriteria(criteria);\n  }\n\n  public clearCriteria(clearSearchTerm = false) {\n    const ret = this.clone();\n    if (clearSearchTerm) {\n      ret.searchTerm = \"\";\n    }\n    ret.criteria = [];\n    ret.currentPage = 1;\n    return ret;\n  }\n\n  public clearSearchTerm() {\n    const ret = this.clone();\n    ret.searchTerm = \"\";\n    ret.currentPage = 1; // reset to first page\n    return ret;\n  }\n\n  public setCriteria(criteria: Criterion[]) {\n    const ret = this.clone();\n    ret.criteria = criteria;\n    return ret;\n  }\n\n  public removeCriterion(type: CriterionType) {\n    const ret = this.clone();\n    const c = ret.criteria.find((cc) => cc.criterionOption.type === type);\n\n    if (!c) return ret;\n\n    const newCriteria = ret.criteria.filter((cc) => {\n      return cc.getId() !== c.getId();\n    });\n\n    ret.criteria = newCriteria;\n    ret.currentPage = 1;\n    return ret;\n  }\n\n  public removeCustomFieldCriterion(type: CriterionType, index: number) {\n    const ret = this.clone();\n    const c = ret.criteria.find((cc) => cc.criterionOption.type === type);\n\n    if (!c) return ret;\n\n    if (c instanceof CustomFieldsCriterion) {\n      const newCriteria = c.value.filter((_, i) => i !== index);\n      c.value = newCriteria;\n    }\n\n    return ret;\n  }\n\n  public setPageSize(pageSize: number) {\n    const ret = this.clone();\n    ret.itemsPerPage = pageSize;\n    ret.currentPage = 1; // reset to first page\n    return ret;\n  }\n\n  public setSortBy(sortBy: string | undefined) {\n    const ret = this.clone();\n    ret.sortBy = sortBy;\n    ret.currentPage = 1; // reset to first page\n    return ret;\n  }\n\n  public toggleSortDirection() {\n    const ret = this.clone();\n\n    if (ret.sortDirection === SortDirectionEnum.Asc) {\n      ret.sortDirection = SortDirectionEnum.Desc;\n    } else {\n      ret.sortDirection = SortDirectionEnum.Asc;\n    }\n\n    ret.currentPage = 1; // reset to first page\n    return ret;\n  }\n\n  public reshuffleRandomSort() {\n    const ret = this.clone();\n    ret.currentPage = 1;\n    ret.randomSeed = -1;\n    return ret;\n  }\n\n  public changePage(page: number) {\n    const ret = this.clone();\n    ret.currentPage = page;\n    return ret;\n  }\n\n  public setZoom(zoomIndex: number) {\n    const ret = this.clone();\n    ret.zoomIndex = zoomIndex;\n    return ret;\n  }\n\n  public setDisplayMode(displayMode: DisplayMode) {\n    const ret = this.clone();\n    ret.displayMode = displayMode;\n    return ret;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/galleries.ts",
    "content": "import {\n  createMandatoryNumberCriterionOption,\n  createStringCriterionOption,\n  createDateCriterionOption,\n  createMandatoryTimestampCriterionOption,\n} from \"./criteria/criterion\";\nimport { PerformerFavoriteCriterionOption } from \"./criteria/favorite\";\nimport { GalleryIsMissingCriterionOption } from \"./criteria/is-missing\";\nimport { OrganizedCriterionOption } from \"./criteria/organized\";\nimport { HasChaptersCriterionOption } from \"./criteria/has-chapters\";\nimport { PerformersCriterionOption } from \"./criteria/performers\";\nimport { AverageResolutionCriterionOption } from \"./criteria/resolution\";\nimport { ScenesCriterionOption } from \"./criteria/scenes\";\nimport { StudiosCriterionOption } from \"./criteria/studios\";\nimport {\n  PerformerTagsCriterionOption,\n  // StudioTagsCriterionOption,\n  TagsCriterionOption,\n} from \"./criteria/tags\";\nimport { ListFilterOptions, MediaSortByOptions } from \"./filter-options\";\nimport { DisplayMode } from \"./types\";\nimport { RatingCriterionOption } from \"./criteria/rating\";\nimport { PathCriterionOption } from \"./criteria/path\";\nimport { CustomFieldsCriterionOption } from \"./criteria/custom-fields\";\nimport { ParentFolderCriterionOption } from \"./criteria/folder\";\n\nconst defaultSortBy = \"path\";\n\nconst sortByOptions = [\"date\", ...MediaSortByOptions]\n  .map(ListFilterOptions.createSortBy)\n  .concat([\n    {\n      messageID: \"image_count\",\n      value: \"images_count\",\n    },\n    {\n      messageID: \"zip_file_count\",\n      value: \"file_count\",\n    },\n  ]);\n\nconst displayModeOptions = [\n  DisplayMode.Grid,\n  DisplayMode.List,\n  DisplayMode.Wall,\n];\n\nexport const PerformerAgeCriterionOption =\n  createMandatoryNumberCriterionOption(\"performer_age\");\n\nconst criterionOptions = [\n  createStringCriterionOption(\"title\"),\n  createStringCriterionOption(\"code\", \"scene_code\"),\n  createStringCriterionOption(\"details\"),\n  createStringCriterionOption(\"photographer\"),\n  PathCriterionOption,\n  ParentFolderCriterionOption,\n  createStringCriterionOption(\"checksum\", \"media_info.md5\"),\n  RatingCriterionOption,\n  OrganizedCriterionOption,\n  AverageResolutionCriterionOption,\n  GalleryIsMissingCriterionOption,\n  TagsCriterionOption,\n  HasChaptersCriterionOption,\n  createMandatoryNumberCriterionOption(\"tag_count\"),\n  PerformerTagsCriterionOption,\n  PerformersCriterionOption,\n  createMandatoryNumberCriterionOption(\"performer_count\"),\n  PerformerAgeCriterionOption,\n  PerformerFavoriteCriterionOption,\n  createMandatoryNumberCriterionOption(\"image_count\"),\n  // StudioTagsCriterionOption,\n  ScenesCriterionOption,\n  StudiosCriterionOption,\n  createStringCriterionOption(\"url\"),\n  createMandatoryNumberCriterionOption(\"file_count\", \"zip_file_count\"),\n  createDateCriterionOption(\"date\"),\n  createMandatoryTimestampCriterionOption(\"created_at\"),\n  createMandatoryTimestampCriterionOption(\"updated_at\"),\n  CustomFieldsCriterionOption,\n];\n\nexport const GalleryListFilterOptions = new ListFilterOptions(\n  defaultSortBy,\n  sortByOptions,\n  displayModeOptions,\n  criterionOptions\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/groups.ts",
    "content": "import {\n  createStringCriterionOption,\n  createDateCriterionOption,\n  createMandatoryTimestampCriterionOption,\n  createDurationCriterionOption,\n  createMandatoryNumberCriterionOption,\n} from \"./criteria/criterion\";\nimport { GroupIsMissingCriterionOption } from \"./criteria/is-missing\";\nimport { StudiosCriterionOption } from \"./criteria/studios\";\nimport { PerformersCriterionOption } from \"./criteria/performers\";\nimport { ListFilterOptions } from \"./filter-options\";\nimport { DisplayMode } from \"./types\";\nimport { RatingCriterionOption } from \"./criteria/rating\";\n// import { StudioTagsCriterionOption } from \"./criteria/tags\";\nimport { TagsCriterionOption } from \"./criteria/tags\";\nimport {\n  ContainingGroupsCriterionOption,\n  SubGroupsCriterionOption,\n} from \"./criteria/groups\";\nimport { CustomFieldsCriterionOption } from \"./criteria/custom-fields\";\n\nconst defaultSortBy = \"name\";\n\nconst sortByOptions = [\n  \"name\",\n  \"random\",\n  \"date\",\n  \"duration\",\n  \"rating\",\n  \"tag_count\",\n  \"sub_group_order\",\n]\n  .map(ListFilterOptions.createSortBy)\n  .concat([\n    {\n      messageID: \"scene_count\",\n      value: \"scenes_count\",\n    },\n    {\n      messageID: \"o_count\",\n      value: \"o_counter\",\n      sfwMessageID: \"o_count_sfw\",\n    },\n  ]);\nconst displayModeOptions = [DisplayMode.Grid];\nconst criterionOptions = [\n  // StudioTagsCriterionOption,\n  StudiosCriterionOption,\n  GroupIsMissingCriterionOption,\n  createStringCriterionOption(\"url\"),\n  createStringCriterionOption(\"name\"),\n  createStringCriterionOption(\"director\"),\n  createStringCriterionOption(\"synopsis\"),\n  createDurationCriterionOption(\"duration\"),\n  RatingCriterionOption,\n  PerformersCriterionOption,\n  createDateCriterionOption(\"date\"),\n  createMandatoryNumberCriterionOption(\"o_counter\", \"o_count\", {\n    sfwMessageID: \"o_count_sfw\",\n  }),\n  ContainingGroupsCriterionOption,\n  SubGroupsCriterionOption,\n  createMandatoryNumberCriterionOption(\"containing_group_count\"),\n  createMandatoryNumberCriterionOption(\"sub_group_count\"),\n  TagsCriterionOption,\n  createMandatoryNumberCriterionOption(\"tag_count\"),\n  createMandatoryNumberCriterionOption(\"scene_count\"),\n  createMandatoryTimestampCriterionOption(\"created_at\"),\n  createMandatoryTimestampCriterionOption(\"updated_at\"),\n  CustomFieldsCriterionOption,\n];\n\nexport const GroupListFilterOptions = new ListFilterOptions(\n  defaultSortBy,\n  sortByOptions,\n  displayModeOptions,\n  criterionOptions\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/images.ts",
    "content": "import {\n  createMandatoryNumberCriterionOption,\n  createMandatoryStringCriterionOption,\n  createStringCriterionOption,\n  createMandatoryTimestampCriterionOption,\n  createDateCriterionOption,\n} from \"./criteria/criterion\";\nimport { PerformerFavoriteCriterionOption } from \"./criteria/favorite\";\nimport { ImageIsMissingCriterionOption } from \"./criteria/is-missing\";\nimport { OrganizedCriterionOption } from \"./criteria/organized\";\nimport { PathCriterionOption } from \"./criteria/path\";\nimport { PerformersCriterionOption } from \"./criteria/performers\";\nimport { RatingCriterionOption } from \"./criteria/rating\";\nimport { ResolutionCriterionOption } from \"./criteria/resolution\";\nimport { OrientationCriterionOption } from \"./criteria/orientation\";\nimport { StudiosCriterionOption } from \"./criteria/studios\";\nimport {\n  PerformerTagsCriterionOption,\n  TagsCriterionOption,\n} from \"./criteria/tags\";\nimport { ListFilterOptions, MediaSortByOptions } from \"./filter-options\";\nimport { DisplayMode } from \"./types\";\nimport { GalleriesCriterionOption } from \"./criteria/galleries\";\nimport { PhashCriterionOption } from \"./criteria/phash\";\nimport { CustomFieldsCriterionOption } from \"./criteria/custom-fields\";\nimport { FolderCriterionOption } from \"./criteria/folder\";\n\nconst defaultSortBy = \"path\";\n\nconst sortByOptions = [\n  \"filesize\",\n  \"file_count\",\n  \"date\",\n  \"resolution\",\n  ...MediaSortByOptions,\n]\n  .map(ListFilterOptions.createSortBy)\n  .concat([\n    {\n      messageID: \"o_count\",\n      value: \"o_counter\",\n      sfwMessageID: \"o_count_sfw\",\n    },\n  ]);\nconst displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];\n\nexport const PerformerAgeCriterionOption =\n  createMandatoryNumberCriterionOption(\"performer_age\");\n\nconst criterionOptions = [\n  createStringCriterionOption(\"title\"),\n  createStringCriterionOption(\"code\", \"scene_code\"),\n  createStringCriterionOption(\"details\"),\n  createStringCriterionOption(\"photographer\"),\n  createMandatoryStringCriterionOption(\"checksum\", \"media_info.md5\"),\n  PhashCriterionOption,\n  PathCriterionOption,\n  FolderCriterionOption,\n  GalleriesCriterionOption,\n  OrganizedCriterionOption,\n  createMandatoryNumberCriterionOption(\"o_counter\", \"o_count\", {\n    sfwMessageID: \"o_count_sfw\",\n  }),\n  ResolutionCriterionOption,\n  OrientationCriterionOption,\n  ImageIsMissingCriterionOption,\n  TagsCriterionOption,\n  RatingCriterionOption,\n  createMandatoryNumberCriterionOption(\"tag_count\"),\n  PerformerTagsCriterionOption,\n  PerformersCriterionOption,\n  createMandatoryNumberCriterionOption(\"performer_count\"),\n  PerformerAgeCriterionOption,\n  PerformerFavoriteCriterionOption,\n  // StudioTagsCriterionOption,\n  StudiosCriterionOption,\n  createStringCriterionOption(\"url\"),\n  createDateCriterionOption(\"date\"),\n  createMandatoryNumberCriterionOption(\"file_count\"),\n  createMandatoryTimestampCriterionOption(\"created_at\"),\n  createMandatoryTimestampCriterionOption(\"updated_at\"),\n  CustomFieldsCriterionOption,\n];\nexport const ImageListFilterOptions = new ListFilterOptions(\n  defaultSortBy,\n  sortByOptions,\n  displayModeOptions,\n  criterionOptions\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/performers.ts",
    "content": "import {\n  createNumberCriterionOption,\n  createMandatoryNumberCriterionOption,\n  createStringCriterionOption,\n  createBooleanCriterionOption,\n  createDateCriterionOption,\n  createMandatoryTimestampCriterionOption,\n} from \"./criteria/criterion\";\nimport { FavoritePerformerCriterionOption } from \"./criteria/favorite\";\nimport { GenderCriterionOption } from \"./criteria/gender\";\nimport { CircumcisedCriterionOption } from \"./criteria/circumcised\";\nimport { PerformerIsMissingCriterionOption } from \"./criteria/is-missing\";\nimport { StashIDCriterionOption } from \"./criteria/stash-ids\";\nimport { StudiosCriterionOption } from \"./criteria/studios\";\nimport { TagsCriterionOption } from \"./criteria/tags\";\nimport { ListFilterOptions } from \"./filter-options\";\nimport { CriterionType, DisplayMode } from \"./types\";\nimport { CountryCriterionOption } from \"./criteria/country\";\nimport { RatingCriterionOption } from \"./criteria/rating\";\nimport { CustomFieldsCriterionOption } from \"./criteria/custom-fields\";\nimport { GroupsCriterionOption } from \"./criteria/groups\";\n\nconst defaultSortBy = \"name\";\nconst sortByOptions = [\n  \"name\",\n  \"height\",\n  \"birthdate\",\n  \"tag_count\",\n  \"random\",\n  \"rating\",\n  \"penis_length\",\n  \"play_count\",\n  \"last_played_at\",\n  \"latest_scene\",\n  \"career_start\",\n  \"career_end\",\n  \"weight\",\n  \"measurements\",\n  \"scenes_duration\",\n  \"scenes_size\",\n]\n  .map(ListFilterOptions.createSortBy)\n  .concat([\n    {\n      messageID: \"scene_count\",\n      value: \"scenes_count\",\n    },\n    {\n      messageID: \"image_count\",\n      value: \"images_count\",\n    },\n    {\n      messageID: \"gallery_count\",\n      value: \"galleries_count\",\n    },\n    {\n      messageID: \"o_count\",\n      value: \"o_counter\",\n      sfwMessageID: \"o_count_sfw\",\n    },\n    {\n      messageID: \"last_o_at\",\n      value: \"last_o_at\",\n      sfwMessageID: \"last_o_at_sfw\",\n    },\n  ]);\n\nconst displayModeOptions = [\n  DisplayMode.Grid,\n  DisplayMode.List,\n  DisplayMode.Tagger,\n];\n\nconst numberCriteria: CriterionType[] = [\n  \"birth_year\",\n  \"death_year\",\n  \"age\",\n  \"weight\",\n  \"penis_length\",\n];\n\nconst stringCriteria: CriterionType[] = [\n  \"name\",\n  \"disambiguation\",\n  \"details\",\n  \"ethnicity\",\n  \"hair_color\",\n  \"eye_color\",\n  \"measurements\",\n  \"fake_tits\",\n  \"tattoos\",\n  \"piercings\",\n  \"aliases\",\n];\n\nconst criterionOptions = [\n  FavoritePerformerCriterionOption,\n  GenderCriterionOption,\n  CircumcisedCriterionOption,\n  PerformerIsMissingCriterionOption,\n  TagsCriterionOption,\n  GroupsCriterionOption,\n  StudiosCriterionOption,\n  StashIDCriterionOption,\n  createStringCriterionOption(\"url\"),\n  RatingCriterionOption,\n  createMandatoryNumberCriterionOption(\"tag_count\"),\n  createMandatoryNumberCriterionOption(\"scene_count\"),\n  createMandatoryNumberCriterionOption(\"image_count\"),\n  createMandatoryNumberCriterionOption(\"gallery_count\"),\n  createMandatoryNumberCriterionOption(\"play_count\"),\n  createMandatoryNumberCriterionOption(\"o_counter\", \"o_count\", {\n    sfwMessageID: \"o_count_sfw\",\n  }),\n  createBooleanCriterionOption(\"ignore_auto_tag\"),\n  CountryCriterionOption,\n  createNumberCriterionOption(\"height_cm\", \"height\"),\n  ...numberCriteria.map((c) => createNumberCriterionOption(c)),\n  ...stringCriteria.map((c) => createStringCriterionOption(c)),\n  createDateCriterionOption(\"birthdate\"),\n  createDateCriterionOption(\"death_date\"),\n  createDateCriterionOption(\"career_start\"),\n  createDateCriterionOption(\"career_end\"),\n  createMandatoryTimestampCriterionOption(\"created_at\"),\n  createMandatoryTimestampCriterionOption(\"updated_at\"),\n  CustomFieldsCriterionOption,\n];\nexport const PerformerListFilterOptions = new ListFilterOptions(\n  defaultSortBy,\n  sortByOptions,\n  displayModeOptions,\n  criterionOptions\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/scene-markers.ts",
    "content": "import { PerformersCriterionOption } from \"./criteria/performers\";\nimport { MarkersScenesCriterionOption } from \"./criteria/scenes\";\nimport { SceneTagsCriterionOption, TagsCriterionOption } from \"./criteria/tags\";\nimport { ListFilterOptions } from \"./filter-options\";\nimport { DisplayMode } from \"./types\";\nimport {\n  createDateCriterionOption,\n  createMandatoryTimestampCriterionOption,\n  createNullDurationCriterionOption,\n} from \"./criteria/criterion\";\n\nconst defaultSortBy = \"title\";\nconst sortByOptions = [\n  \"duration\",\n  \"title\",\n  \"seconds\",\n  \"scene_id\",\n  \"random\",\n  \"scenes_updated_at\",\n].map(ListFilterOptions.createSortBy);\nconst displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];\nconst criterionOptions = [\n  TagsCriterionOption,\n  MarkersScenesCriterionOption,\n  SceneTagsCriterionOption,\n  PerformersCriterionOption,\n  createNullDurationCriterionOption(\"duration\"),\n  createMandatoryTimestampCriterionOption(\"created_at\"),\n  createMandatoryTimestampCriterionOption(\"updated_at\"),\n  createDateCriterionOption(\"scene_date\"),\n  createMandatoryTimestampCriterionOption(\"scene_created_at\"),\n  createMandatoryTimestampCriterionOption(\"scene_updated_at\"),\n];\n\nexport const SceneMarkerListFilterOptions = new ListFilterOptions(\n  defaultSortBy,\n  sortByOptions,\n  displayModeOptions,\n  criterionOptions\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/scenes.ts",
    "content": "import {\n  createMandatoryNumberCriterionOption,\n  createMandatoryStringCriterionOption,\n  createStringCriterionOption,\n  createDateCriterionOption,\n  createMandatoryTimestampCriterionOption,\n  createDurationCriterionOption,\n} from \"./criteria/criterion\";\nimport { HasMarkersCriterionOption } from \"./criteria/has-markers\";\nimport { SceneIsMissingCriterionOption } from \"./criteria/is-missing\";\nimport {\n  GroupsCriterionOption,\n  LegacyMoviesCriterionOption,\n} from \"./criteria/groups\";\nimport { GalleriesCriterionOption } from \"./criteria/galleries\";\nimport { OrganizedCriterionOption } from \"./criteria/organized\";\nimport { PerformersCriterionOption } from \"./criteria/performers\";\nimport { ResolutionCriterionOption } from \"./criteria/resolution\";\nimport { StudiosCriterionOption } from \"./criteria/studios\";\nimport { InteractiveCriterionOption } from \"./criteria/interactive\";\nimport {\n  PerformerTagsCriterionOption,\n  // StudioTagsCriterionOption,\n  TagsCriterionOption,\n} from \"./criteria/tags\";\nimport { ListFilterOptions, MediaSortByOptions } from \"./filter-options\";\nimport { DisplayMode } from \"./types\";\nimport {\n  DuplicatedCriterionOption,\n  PhashCriterionOption,\n} from \"./criteria/phash\";\nimport { PerformerFavoriteCriterionOption } from \"./criteria/favorite\";\nimport { CaptionsCriterionOption } from \"./criteria/captions\";\nimport { StashIDCriterionOption } from \"./criteria/stash-ids\";\nimport { RatingCriterionOption } from \"./criteria/rating\";\nimport { PathCriterionOption } from \"./criteria/path\";\nimport { OrientationCriterionOption } from \"./criteria/orientation\";\nimport { CustomFieldsCriterionOption } from \"./criteria/custom-fields\";\nimport { FolderCriterionOption } from \"./criteria/folder\";\n\nconst defaultSortBy = \"date\";\nconst sortByOptions = [\n  \"organized\",\n  \"date\",\n  \"file_count\",\n  \"filesize\",\n  \"duration\",\n  \"framerate\",\n  \"resolution\",\n  \"bitrate\",\n  \"last_played_at\",\n  \"resume_time\",\n  \"play_duration\",\n  \"play_count\",\n  \"interactive\",\n  \"interactive_speed\",\n  \"perceptual_similarity\",\n  \"performer_age\",\n  \"studio\",\n  ...MediaSortByOptions,\n]\n  .map(ListFilterOptions.createSortBy)\n  .concat([\n    {\n      messageID: \"o_count\",\n      value: \"o_counter\",\n      sfwMessageID: \"o_count_sfw\",\n    },\n    {\n      messageID: \"last_o_at\",\n      value: \"last_o_at\",\n      sfwMessageID: \"last_o_at_sfw\",\n    },\n    {\n      messageID: \"group_scene_number\",\n      value: \"group_scene_number\",\n    },\n    {\n      messageID: \"scene_code\",\n      value: \"code\",\n    },\n  ]);\nconst displayModeOptions = [\n  DisplayMode.Grid,\n  DisplayMode.List,\n  DisplayMode.Wall,\n  DisplayMode.Tagger,\n];\n\nexport const PerformerAgeCriterionOption =\n  createMandatoryNumberCriterionOption(\"performer_age\");\n\nexport const DurationCriterionOption =\n  createDurationCriterionOption(\"duration\");\n\nconst criterionOptions = [\n  createStringCriterionOption(\"title\"),\n  createStringCriterionOption(\"code\", \"scene_code\"),\n  PathCriterionOption,\n  FolderCriterionOption,\n  createStringCriterionOption(\"details\"),\n  createStringCriterionOption(\"director\"),\n  createMandatoryStringCriterionOption(\"oshash\", \"media_info.oshash\"),\n  createStringCriterionOption(\"checksum\", \"media_info.md5\"),\n  PhashCriterionOption,\n  DuplicatedCriterionOption,\n  OrganizedCriterionOption,\n  RatingCriterionOption,\n  createMandatoryNumberCriterionOption(\"o_counter\", \"o_count\", {\n    sfwMessageID: \"o_count_sfw\",\n  }),\n  ResolutionCriterionOption,\n  OrientationCriterionOption,\n  createMandatoryNumberCriterionOption(\"framerate\"),\n  createMandatoryNumberCriterionOption(\"bitrate\"),\n  createStringCriterionOption(\"video_codec\"),\n  createStringCriterionOption(\"audio_codec\"),\n  DurationCriterionOption,\n  createDurationCriterionOption(\"resume_time\"),\n  createDurationCriterionOption(\"play_duration\"),\n  createMandatoryNumberCriterionOption(\"play_count\"),\n  createMandatoryTimestampCriterionOption(\"last_played_at\"),\n  HasMarkersCriterionOption,\n  SceneIsMissingCriterionOption,\n  TagsCriterionOption,\n  createMandatoryNumberCriterionOption(\"tag_count\"),\n  PerformerTagsCriterionOption,\n  PerformersCriterionOption,\n  createMandatoryNumberCriterionOption(\"performer_count\"),\n  PerformerAgeCriterionOption,\n  PerformerFavoriteCriterionOption,\n  // StudioTagsCriterionOption,\n  StudiosCriterionOption,\n  GroupsCriterionOption,\n  LegacyMoviesCriterionOption,\n  GalleriesCriterionOption,\n  createStringCriterionOption(\"url\"),\n  StashIDCriterionOption,\n  createMandatoryNumberCriterionOption(\"stash_id_count\"),\n  InteractiveCriterionOption,\n  CaptionsCriterionOption,\n  createMandatoryNumberCriterionOption(\"interactive_speed\"),\n  createMandatoryNumberCriterionOption(\"file_count\"),\n  createDateCriterionOption(\"date\"),\n  createMandatoryTimestampCriterionOption(\"created_at\"),\n  createMandatoryTimestampCriterionOption(\"updated_at\"),\n  CustomFieldsCriterionOption,\n];\n\nexport const SceneListFilterOptions = new ListFilterOptions(\n  defaultSortBy,\n  sortByOptions,\n  displayModeOptions,\n  criterionOptions\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/studios.ts",
    "content": "import {\n  createBooleanCriterionOption,\n  createMandatoryNumberCriterionOption,\n  createMandatoryStringCriterionOption,\n  createStringCriterionOption,\n  createMandatoryTimestampCriterionOption,\n} from \"./criteria/criterion\";\nimport { FavoriteStudioCriterionOption } from \"./criteria/favorite\";\nimport { StudioIsMissingCriterionOption } from \"./criteria/is-missing\";\nimport { RatingCriterionOption } from \"./criteria/rating\";\nimport { StashIDCriterionOption } from \"./criteria/stash-ids\";\nimport { ParentStudiosCriterionOption } from \"./criteria/studios\";\nimport { TagsCriterionOption } from \"./criteria/tags\";\nimport { ListFilterOptions } from \"./filter-options\";\nimport { DisplayMode } from \"./types\";\nimport { CustomFieldsCriterionOption } from \"./criteria/custom-fields\";\n\nconst defaultSortBy = \"name\";\nconst sortByOptions = [\n  \"name\",\n  \"tag_count\",\n  \"random\",\n  \"rating\",\n  \"scenes_duration\",\n  \"scenes_size\",\n  \"latest_scene\",\n]\n  .map(ListFilterOptions.createSortBy)\n  .concat([\n    {\n      messageID: \"gallery_count\",\n      value: \"galleries_count\",\n    },\n    {\n      messageID: \"image_count\",\n      value: \"images_count\",\n    },\n    {\n      messageID: \"scene_count\",\n      value: \"scenes_count\",\n    },\n    {\n      messageID: \"subsidiary_studio_count\",\n      value: \"child_count\",\n    },\n  ]);\n\nconst displayModeOptions = [DisplayMode.Grid, DisplayMode.Tagger];\nconst criterionOptions = [\n  FavoriteStudioCriterionOption,\n  createMandatoryStringCriterionOption(\"name\"),\n  createStringCriterionOption(\"details\"),\n  ParentStudiosCriterionOption,\n  StudioIsMissingCriterionOption,\n  TagsCriterionOption,\n  RatingCriterionOption,\n  createBooleanCriterionOption(\"ignore_auto_tag\"),\n  createBooleanCriterionOption(\"organized\"),\n  createMandatoryNumberCriterionOption(\"tag_count\"),\n  createMandatoryNumberCriterionOption(\"scene_count\"),\n  createMandatoryNumberCriterionOption(\"image_count\"),\n  createMandatoryNumberCriterionOption(\"gallery_count\"),\n  createStringCriterionOption(\"url\"),\n  StashIDCriterionOption,\n  createStringCriterionOption(\"aliases\"),\n  createMandatoryNumberCriterionOption(\n    \"child_count\",\n    \"subsidiary_studio_count\"\n  ),\n  createMandatoryTimestampCriterionOption(\"created_at\"),\n  createMandatoryTimestampCriterionOption(\"updated_at\"),\n  CustomFieldsCriterionOption,\n];\n\nexport const StudioListFilterOptions = new ListFilterOptions(\n  defaultSortBy,\n  sortByOptions,\n  displayModeOptions,\n  criterionOptions\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/tags.ts",
    "content": "import {\n  createBooleanCriterionOption,\n  createMandatoryNumberCriterionOption,\n  createMandatoryStringCriterionOption,\n  createStringCriterionOption,\n  MandatoryNumberCriterionOption,\n  createMandatoryTimestampCriterionOption,\n} from \"./criteria/criterion\";\nimport { TagIsMissingCriterionOption } from \"./criteria/is-missing\";\nimport { ListFilterOptions } from \"./filter-options\";\nimport { DisplayMode } from \"./types\";\nimport {\n  ChildTagsCriterionOption,\n  ParentTagsCriterionOption,\n} from \"./criteria/tags\";\nimport { FavoriteTagCriterionOption } from \"./criteria/favorite\";\nimport { StashIDCriterionOption } from \"./criteria/stash-ids\";\nimport { CustomFieldsCriterionOption } from \"./criteria/custom-fields\";\n\nconst defaultSortBy = \"name\";\nconst sortByOptions = [\"name\", \"random\", \"scenes_duration\", \"scenes_size\"]\n  .map(ListFilterOptions.createSortBy)\n  .concat([\n    {\n      messageID: \"gallery_count\",\n      value: \"galleries_count\",\n    },\n    {\n      messageID: \"image_count\",\n      value: \"images_count\",\n    },\n    {\n      messageID: \"performer_count\",\n      value: \"performers_count\",\n    },\n    {\n      messageID: \"scene_count\",\n      value: \"scenes_count\",\n    },\n    {\n      messageID: \"group_count\",\n      value: \"groups_count\",\n    },\n    {\n      messageID: \"marker_count\",\n      value: \"scene_markers_count\",\n    },\n    {\n      messageID: \"studio_count\",\n      value: \"studios_count\",\n    },\n  ]);\n\nconst displayModeOptions = [\n  DisplayMode.Grid,\n  DisplayMode.List,\n  DisplayMode.Tagger,\n];\nconst criterionOptions = [\n  FavoriteTagCriterionOption,\n  createMandatoryStringCriterionOption(\"name\"),\n  createStringCriterionOption(\"sort_name\"),\n  TagIsMissingCriterionOption,\n  createStringCriterionOption(\"aliases\"),\n  createStringCriterionOption(\"description\"),\n  createBooleanCriterionOption(\"ignore_auto_tag\"),\n  StashIDCriterionOption,\n  createMandatoryNumberCriterionOption(\"scene_count\"),\n  createMandatoryNumberCriterionOption(\"image_count\"),\n  createMandatoryNumberCriterionOption(\"gallery_count\"),\n  createMandatoryNumberCriterionOption(\"performer_count\"),\n  createMandatoryNumberCriterionOption(\"studio_count\"),\n  createMandatoryNumberCriterionOption(\"group_count\"),\n  createMandatoryNumberCriterionOption(\"marker_count\"),\n  ParentTagsCriterionOption,\n  new MandatoryNumberCriterionOption(\"parent_tag_count\", \"parent_count\"),\n  ChildTagsCriterionOption,\n  new MandatoryNumberCriterionOption(\"sub_tag_count\", \"child_count\"),\n  createMandatoryTimestampCriterionOption(\"created_at\"),\n  createMandatoryTimestampCriterionOption(\"updated_at\"),\n  CustomFieldsCriterionOption,\n];\n\nexport const TagListFilterOptions = new ListFilterOptions(\n  defaultSortBy,\n  sortByOptions,\n  displayModeOptions,\n  criterionOptions\n);\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/types.ts",
    "content": "import { CriterionValue, ISavedCriterion } from \"./criteria/criterion\";\n\nexport type SavedObjectFilter = {\n  [K in CriterionType]?: ISavedCriterion<CriterionValue>;\n};\n\nexport type SavedUIOptions = {\n  display_mode?: DisplayMode;\n  zoom_index?: number;\n};\n\n// NOTE: add new enum values to the end, to ensure existing data\n// is not impacted\nexport enum DisplayMode {\n  Grid,\n  List,\n  Wall,\n  Tagger,\n}\n\nexport interface ILabeledId {\n  id: string;\n  label: string;\n}\n\nexport interface ILabeledValue {\n  label: string;\n  value: string;\n}\n\nexport interface ILabeledValueListValue {\n  items: ILabeledId[];\n  excluded: ILabeledId[];\n}\n\nexport interface IHierarchicalLabelValue {\n  items: ILabeledId[];\n  excluded: ILabeledId[];\n  depth: number;\n}\n\nexport interface IRangeValue<V> {\n  value: V | undefined;\n  value2: V | undefined;\n}\n\nexport type INumberValue = IRangeValue<number>;\nexport type IDateValue = IRangeValue<string>;\nexport type ITimestampValue = IRangeValue<string>;\nexport interface IDuplicationValue {\n  // Deprecated: Use phash field instead. Kept for backwards compatibility.\n  duplicated?: boolean;\n  // Currently not implemented. Intended for phash distance matching.\n  distance?: number;\n  phash?: boolean;\n  url?: boolean;\n  stash_id?: boolean;\n  title?: boolean;\n}\n\nexport interface IStashIDValue {\n  endpoint: string;\n  stashID: string;\n}\n\nexport interface IPhashDistanceValue {\n  value: string;\n  distance?: number;\n}\n\nexport function criterionIsHierarchicalLabelValue(\n  value: unknown\n): value is IHierarchicalLabelValue {\n  return (\n    typeof value === \"object\" && !!value && \"items\" in value && \"depth\" in value\n  );\n}\n\nexport function criterionIsNumberValue(value: unknown): value is INumberValue {\n  return (\n    typeof value === \"object\" &&\n    !!value &&\n    \"value\" in value &&\n    \"value2\" in value\n  );\n}\n\nexport function criterionIsStashIDValue(\n  value: unknown\n): value is IStashIDValue {\n  return (\n    typeof value === \"object\" &&\n    !!value &&\n    \"endpoint\" in value &&\n    \"stashID\" in value\n  );\n}\n\nexport function criterionIsDateValue(value: unknown): value is IDateValue {\n  return (\n    typeof value === \"object\" &&\n    !!value &&\n    \"value\" in value &&\n    \"value2\" in value\n  );\n}\n\nexport function criterionIsTimestampValue(\n  value: unknown\n): value is ITimestampValue {\n  return (\n    typeof value === \"object\" &&\n    !!value &&\n    \"value\" in value &&\n    \"value2\" in value\n  );\n}\n\nexport interface IOptionType {\n  id: string;\n  name?: string;\n  image_path?: string;\n}\n\nexport type CriterionType =\n  | \"path\"\n  | \"rating100\"\n  | \"organized\"\n  | \"o_counter\"\n  | \"resolution\"\n  | \"average_resolution\"\n  | \"framerate\"\n  | \"bitrate\"\n  | \"video_codec\"\n  | \"audio_codec\"\n  | \"duration\"\n  | \"filter_favorites\"\n  | \"favorite\"\n  | \"has_markers\"\n  | \"is_missing\"\n  | \"tags\"\n  | \"scene_tags\"\n  | \"performer_tags\"\n  | \"studio_tags\"\n  | \"tag_count\"\n  | \"performers\"\n  | \"studios\"\n  | \"scenes\"\n  | \"groups\"\n  | \"movies\" // legacy\n  | \"containing_groups\"\n  | \"containing_group_count\"\n  | \"sub_groups\"\n  | \"sub_group_count\"\n  | \"galleries\"\n  | \"birth_year\"\n  | \"age\"\n  | \"ethnicity\"\n  | \"country\"\n  | \"hair_color\"\n  | \"eye_color\"\n  | \"height_cm\"\n  | \"weight\"\n  | \"measurements\"\n  | \"fake_tits\"\n  | \"penis_length\"\n  | \"circumcised\"\n  | \"career_length\"\n  | \"career_start\"\n  | \"career_end\"\n  | \"tattoos\"\n  | \"piercings\"\n  | \"aliases\"\n  | \"gender\"\n  | \"parents\"\n  | \"children\"\n  | \"scene_count\"\n  | \"marker_count\"\n  | \"image_count\"\n  | \"gallery_count\"\n  | \"performer_count\"\n  | \"studio_count\"\n  | \"group_count\"\n  | \"death_year\"\n  | \"url\"\n  | \"interactive\"\n  | \"interactive_speed\"\n  | \"captions\"\n  | \"resume_time\"\n  | \"play_count\"\n  | \"play_duration\"\n  | \"last_played_at\"\n  | \"name\"\n  | \"details\"\n  | \"title\"\n  | \"oshash\"\n  | \"orientation\"\n  | \"checksum\"\n  | \"phash_distance\"\n  | \"director\"\n  | \"synopsis\"\n  | \"parent_count\"\n  | \"child_count\"\n  | \"performer_favorite\"\n  | \"favorite\"\n  | \"performer_age\"\n  | \"duplicated\"\n  | \"ignore_auto_tag\"\n  | \"file_count\"\n  | \"stash_id_endpoint\"\n  | \"stash_id_count\"\n  | \"date\"\n  | \"created_at\"\n  | \"updated_at\"\n  | \"birthdate\"\n  | \"death_date\"\n  | \"scene_date\"\n  | \"scene_created_at\"\n  | \"scene_updated_at\"\n  | \"description\"\n  | \"code\"\n  | \"photographer\"\n  | \"disambiguation\"\n  | \"has_chapters\"\n  | \"sort_name\"\n  | \"custom_fields\"\n  | \"folder\"\n  | \"parent_folder\";\n"
  },
  {
    "path": "ui/v2.5/src/models/list-filter/utils.ts",
    "content": "import { CriterionModifier } from \"src/core/generated-graphql\";\nimport { ModifierCriterion } from \"./criteria/criterion\";\nimport { ListFilterModel } from \"./filter\";\n\nexport function filterByStashID(filter: ListFilterModel, stashID: string) {\n  const stashCriterion = filter.makeCriterion(\n    \"stash_id_endpoint\"\n  ) as ModifierCriterion<{ endpoint: string; stashID: string }>;\n  stashCriterion.modifier = CriterionModifier.Equals;\n  stashCriterion.value = { endpoint: \"\", stashID: stashID.trim() };\n  filter.criteria = [stashCriterion];\n}\n"
  },
  {
    "path": "ui/v2.5/src/models/sceneQueue.ts",
    "content": "import { FilterMode, Scene } from \"src/core/generated-graphql\";\nimport { ListFilterModel } from \"./list-filter/filter\";\nimport { INamedObject } from \"src/utils/navigation\";\n\nexport type QueuedScene = Pick<Scene, \"id\" | \"title\" | \"date\" | \"paths\"> & {\n  performers?: INamedObject[] | null;\n  studio?: INamedObject | null;\n};\n\nexport interface IPlaySceneOptions {\n  sceneIndex?: number;\n  newPage?: number;\n  autoPlay?: boolean;\n  continue?: boolean;\n  start?: number;\n}\n\nexport class SceneQueue {\n  public query?: ListFilterModel;\n  public sceneIDs?: number[];\n  private originalQueryPage?: number;\n  private originalQueryPageSize?: number;\n\n  public static fromListFilterModel(filter: ListFilterModel) {\n    const ret = new SceneQueue();\n\n    const filterCopy = filter.clone();\n    filterCopy.itemsPerPage = 40;\n\n    ret.originalQueryPage = filter.currentPage;\n    ret.originalQueryPageSize = filter.itemsPerPage;\n\n    ret.query = filterCopy;\n    return ret;\n  }\n\n  public static fromSceneIDList(sceneIDs: string[]) {\n    const ret = new SceneQueue();\n    ret.sceneIDs = sceneIDs.map((v) => Number(v));\n    return ret;\n  }\n\n  private makeQueryParameters(sceneIndex?: number, page?: number) {\n    const ret: string[] = [];\n\n    if (this.query) {\n      const queryParams = this.query.getEncodedParams();\n\n      if (queryParams.sortby) {\n        ret.push(`qsort=${queryParams.sortby}`);\n      }\n      if (queryParams.sortdir) {\n        ret.push(`qsortd=${queryParams.sortdir}`);\n      }\n      if (queryParams.q) {\n        ret.push(`qfq=${queryParams.q}`);\n      }\n      for (const c of queryParams.c ?? []) {\n        ret.push(`qfc=${c}`);\n      }\n\n      let qfp = queryParams.p ?? \"1\";\n      if (page !== undefined) {\n        qfp = String(page);\n      } else if (\n        sceneIndex !== undefined &&\n        this.originalQueryPage !== undefined &&\n        this.originalQueryPageSize !== undefined\n      ) {\n        // adjust page to be correct for the index\n        const filterIndex =\n          sceneIndex +\n          (this.originalQueryPage - 1) * this.originalQueryPageSize;\n        const newPage = Math.floor(filterIndex / this.query.itemsPerPage) + 1;\n        qfp = String(newPage);\n      }\n      ret.push(`qfp=${qfp}`);\n    } else if (this.sceneIDs && this.sceneIDs.length > 0) {\n      for (const id of this.sceneIDs) {\n        ret.push(`qs=${id}`);\n      }\n    }\n\n    return ret.join(\"&\");\n  }\n\n  public static fromQueryParameters(params: URLSearchParams) {\n    const ret = new SceneQueue();\n\n    if (params.has(\"qfp\")) {\n      const translated = {\n        sortby: params.get(\"qsort\"),\n        sortdir: params.get(\"qsortd\"),\n        q: params.get(\"qfq\"),\n        p: params.get(\"qfp\"),\n        c: params.getAll(\"qfc\"),\n      };\n      const decoded = ListFilterModel.decodeParams(translated);\n      const query = new ListFilterModel(FilterMode.Scenes);\n      query.configureFromDecodedParams(decoded);\n      ret.query = query;\n    } else if (params.has(\"qs\")) {\n      // must be scene list\n      ret.sceneIDs = params.getAll(\"qs\").map((v) => Number(v));\n    }\n\n    return ret;\n  }\n\n  public makeLink(sceneID: string, options: IPlaySceneOptions) {\n    let params = [\n      this.makeQueryParameters(options.sceneIndex, options.newPage),\n    ];\n    if (options.autoPlay) {\n      params.push(\"autoplay=true\");\n    }\n    if (options.continue !== undefined) {\n      params.push(\"continue=\" + options.continue);\n    }\n    if (options.start !== undefined) {\n      params.push(\"t=\" + options.start);\n    }\n    return `/scenes/${sceneID}${params.length ? \"?\" + params.join(\"&\") : \"\"}`;\n  }\n}\n\nexport default SceneQueue;\n"
  },
  {
    "path": "ui/v2.5/src/patch.tsx",
    "content": "import React from \"react\";\n\nexport let components: Record<string, Function> = {};\n\nconst beforeFns: Record<string, Function[]> = {};\nconst insteadFns: Record<string, Function[]> = {};\nconst afterFns: Record<string, Function[]> = {};\n\n// patch functions\n// registers a patch to a function. Before functions are expected to return the\n// new arguments to be passed to the function.\nexport function before(component: string, fn: Function) {\n  if (!beforeFns[component]) {\n    beforeFns[component] = [];\n  }\n  beforeFns[component].push(fn);\n}\n\n// registers a patch to a function. Instead functions receive the original arguments,\n// plus the next function to call. In order for all instead functions to be called,\n// it is expected that the provided next() function will be called.\nexport function instead(component: string, fn: Function) {\n  if (!insteadFns[component]) {\n    insteadFns[component] = [];\n  }\n  insteadFns[component].push(fn);\n}\n\nexport function after(component: string, fn: Function) {\n  if (!afterFns[component]) {\n    afterFns[component] = [];\n  }\n  afterFns[component].push(fn);\n}\n\nexport function RegisterComponent<T extends Function>(\n  component: string,\n  fn: T\n) {\n  // register with the plugin api\n  if (components[component]) {\n    // only throw an error in production, in development we allow\n    // multiple registrations to allow for hot reloading of components\n    if (!import.meta.env.DEV) {\n      throw new Error(\n        \"Component \" + component + \" has already been registered\"\n      );\n    }\n  }\n\n  components[component] = fn;\n\n  return fn;\n}\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\nfunction runInstead(\n  fns: Function[],\n  targetFn: Function,\n  thisArg: any,\n  argArray: any[]\n) {\n  if (!fns.length) {\n    return targetFn.apply(thisArg, argArray);\n  }\n\n  let i = 1;\n  function next(): any {\n    if (i >= fns.length) {\n      return targetFn;\n    }\n\n    const thisTarget = fns[i++];\n    return new Proxy(thisTarget, {\n      apply: function (target, ctx, args) {\n        return target.apply(ctx, args.concat(next()));\n      },\n    });\n  }\n\n  return fns[0].apply(thisArg, argArray.concat(next()));\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n// patches a function to implement the before/instead/after functionality\nexport function PatchFunction<T extends Function>(name: string, fn: T) {\n  return new Proxy(fn, {\n    apply(target, ctx, args) {\n      let result;\n\n      for (const beforeFn of beforeFns[name] || []) {\n        args = beforeFn.apply(ctx, args);\n      }\n      if (insteadFns[name]) {\n        result = runInstead(insteadFns[name], target, ctx, args);\n      } else {\n        result = target.apply(ctx, args);\n      }\n      for (const afterFn of afterFns[name] || []) {\n        result = afterFn.apply(ctx, args.concat(result));\n      }\n      return result;\n    },\n  });\n}\n\n// patches a component and registers it in the pluginapi components object\nexport function PatchComponent<T>(\n  component: string,\n  fn: React.FC<T>\n): React.FC<T> {\n  const ret = PatchFunction(component, fn);\n\n  // register with the plugin api\n  RegisterComponent(component, ret);\n  return ret as React.FC<T>;\n}\n\n// patches a component and registers it in the pluginapi components object\nexport function PatchContainerComponent<T = {}>(\n  component: string\n): React.FC<React.PropsWithChildren<T>> {\n  const fn: React.FC<React.PropsWithChildren<T>> = (\n    props: React.PropsWithChildren<T>\n  ) => {\n    return <>{props.children}</>;\n  };\n\n  return PatchComponent(component, fn);\n}\n"
  },
  {
    "path": "ui/v2.5/src/pluginApi.d.ts",
    "content": "declare namespace PluginApi {\n  const React: typeof import(\"react\");\n  const ReactDOM: typeof import(\"react-dom\");\n  namespace GQL {\n    const AddGalleryImagesDocument: { [key: string]: any };\n    const AddTempDlnaipDocument: { [key: string]: any };\n    const AnonymiseDatabaseDocument: { [key: string]: any };\n    const AvailablePluginPackagesDocument: { [key: string]: any };\n    const AvailableScraperPackagesDocument: { [key: string]: any };\n    const BackupDatabaseDocument: { [key: string]: any };\n    const BlobsStorageType: { [key: string]: any };\n    const BulkGalleryUpdateDocument: { [key: string]: any };\n    const BulkImageUpdateDocument: { [key: string]: any };\n    const BulkGroupUpdateDocument: { [key: string]: any };\n    const BulkPerformerUpdateDocument: { [key: string]: any };\n    const BulkSceneUpdateDocument: { [key: string]: any };\n    const BulkUpdateIdMode: { [key: string]: any };\n    const CircumcisedEnum: { [key: string]: any };\n    const ConfigDataFragmentDoc: { [key: string]: any };\n    const ConfigDefaultSettingsDataFragmentDoc: { [key: string]: any };\n    const ConfigDlnaDataFragmentDoc: { [key: string]: any };\n    const ConfigGeneralDataFragmentDoc: { [key: string]: any };\n    const ConfigInterfaceDataFragmentDoc: { [key: string]: any };\n    const ConfigScrapingDataFragmentDoc: { [key: string]: any };\n    const ConfigurationDocument: { [key: string]: any };\n    const ConfigureDefaultsDocument: { [key: string]: any };\n    const ConfigureDlnaDocument: { [key: string]: any };\n    const ConfigureGeneralDocument: { [key: string]: any };\n    const ConfigureInterfaceDocument: { [key: string]: any };\n    const ConfigurePluginDocument: { [key: string]: any };\n    const ConfigureScrapingDocument: { [key: string]: any };\n    const ConfigureUiDocument: { [key: string]: any };\n    const CriterionModifier: { [key: string]: any };\n    const DeleteFilesDocument: { [key: string]: any };\n    const DestroySavedFilterDocument: { [key: string]: any };\n    const DirectoryDocument: { [key: string]: any };\n    const DisableDlnaDocument: { [key: string]: any };\n    const DlnaStatusDocument: { [key: string]: any };\n    const EnableDlnaDocument: { [key: string]: any };\n    const ExportObjectsDocument: { [key: string]: any };\n    const FilterMode: { [key: string]: any };\n    const FindDuplicateScenesDocument: { [key: string]: any };\n    const FindGalleriesDocument: { [key: string]: any };\n    const FindGalleriesForSelectDocument: { [key: string]: any };\n    const FindGalleryDocument: { [key: string]: any };\n    const FindImageDocument: { [key: string]: any };\n    const FindImagesDocument: { [key: string]: any };\n    const FindJobDocument: { [key: string]: any };\n    const FindGroupDocument: { [key: string]: any };\n    const FindGroupsDocument: { [key: string]: any };\n    const FindGroupsForSelectDocument: { [key: string]: any };\n    const FindPerformerDocument: { [key: string]: any };\n    const FindPerformersDocument: { [key: string]: any };\n    const FindPerformersForSelectDocument: { [key: string]: any };\n    const FindSavedFilterDocument: { [key: string]: any };\n    const FindSavedFiltersDocument: { [key: string]: any };\n    const FindSceneDocument: { [key: string]: any };\n    const FindSceneMarkerTagsDocument: { [key: string]: any };\n    const FindSceneMarkersDocument: { [key: string]: any };\n    const FindScenesByPathRegexDocument: { [key: string]: any };\n    const FindScenesDocument: { [key: string]: any };\n    const FindStudioDocument: { [key: string]: any };\n    const FindStudiosDocument: { [key: string]: any };\n    const FindStudiosForSelectDocument: { [key: string]: any };\n    const FindTagDocument: { [key: string]: any };\n    const FindTagsDocument: { [key: string]: any };\n    const FindTagsForSelectDocument: { [key: string]: any };\n    const FolderDataFragmentDoc: { [key: string]: any };\n    const GalleriesUpdateDocument: { [key: string]: any };\n    const GalleryChapterCreateDocument: { [key: string]: any };\n    const GalleryChapterDataFragmentDoc: { [key: string]: any };\n    const GalleryChapterDestroyDocument: { [key: string]: any };\n    const GalleryChapterUpdateDocument: { [key: string]: any };\n    const GalleryCreateDocument: { [key: string]: any };\n    const GalleryDataFragmentDoc: { [key: string]: any };\n    const GalleryDestroyDocument: { [key: string]: any };\n    const GalleryFileDataFragmentDoc: { [key: string]: any };\n    const GalleryUpdateDocument: { [key: string]: any };\n    const GenderEnum: { [key: string]: any };\n    const GenerateApiKeyDocument: { [key: string]: any };\n    const HashAlgorithm: { [key: string]: any };\n    const IdentifyFieldOptionsDataFragmentDoc: { [key: string]: any };\n    const IdentifyFieldStrategy: { [key: string]: any };\n    const IdentifyMetadataOptionsDataFragmentDoc: { [key: string]: any };\n    const ImageDataFragmentDoc: { [key: string]: any };\n    const ImageDecrementODocument: { [key: string]: any };\n    const ImageDestroyDocument: { [key: string]: any };\n    const ImageFileDataFragmentDoc: { [key: string]: any };\n    const ImageIncrementODocument: { [key: string]: any };\n    const ImageLightboxDisplayMode: { [key: string]: any };\n    const ImageLightboxScrollMode: { [key: string]: any };\n    const ImageResetODocument: { [key: string]: any };\n    const ImageUpdateDocument: { [key: string]: any };\n    const ImagesDestroyDocument: { [key: string]: any };\n    const ImagesUpdateDocument: { [key: string]: any };\n    const ImportDuplicateEnum: { [key: string]: any };\n    const ImportMissingRefEnum: { [key: string]: any };\n    const ImportObjectsDocument: { [key: string]: any };\n    const InstallPluginPackagesDocument: { [key: string]: any };\n    const InstallScraperPackagesDocument: { [key: string]: any };\n    const InstalledPluginPackagesDocument: { [key: string]: any };\n    const InstalledPluginPackagesStatusDocument: { [key: string]: any };\n    const InstalledScraperPackagesDocument: { [key: string]: any };\n    const InstalledScraperPackagesStatusDocument: { [key: string]: any };\n    const JobDataFragmentDoc: { [key: string]: any };\n    const JobQueueDocument: { [key: string]: any };\n    const JobStatus: { [key: string]: any };\n    const JobStatusUpdateType: { [key: string]: any };\n    const JobsSubscribeDocument: { [key: string]: any };\n    const LatestVersionDocument: { [key: string]: any };\n    const ListGalleryScrapersDocument: { [key: string]: any };\n    const ListGroupScrapersDocument: { [key: string]: any };\n    const ListPerformerScrapersDocument: { [key: string]: any };\n    const ListSceneScrapersDocument: { [key: string]: any };\n    const LogEntryDataFragmentDoc: { [key: string]: any };\n    const LogLevel: { [key: string]: any };\n    const LoggingSubscribeDocument: { [key: string]: any };\n    const LogsDocument: { [key: string]: any };\n    const MarkerStringsDocument: { [key: string]: any };\n    const MarkerWallDocument: { [key: string]: any };\n    const MetadataAutoTagDocument: { [key: string]: any };\n    const MetadataCleanDocument: { [key: string]: any };\n    const MetadataExportDocument: { [key: string]: any };\n    const MetadataGenerateDocument: { [key: string]: any };\n    const MetadataIdentifyDocument: { [key: string]: any };\n    const MetadataImportDocument: { [key: string]: any };\n    const MetadataScanDocument: { [key: string]: any };\n    const MigrateBlobsDocument: { [key: string]: any };\n    const MigrateDocument: { [key: string]: any };\n    const MigrateHashNamingDocument: { [key: string]: any };\n    const MigrateSceneScreenshotsDocument: { [key: string]: any };\n    const GroupCreateDocument: { [key: string]: any };\n    const GroupDataFragmentDoc: { [key: string]: any };\n    const GroupDestroyDocument: { [key: string]: any };\n    const GroupUpdateDocument: { [key: string]: any };\n    const GroupsDestroyDocument: { [key: string]: any };\n    const OptimiseDatabaseDocument: { [key: string]: any };\n    const OrientationEnum: { [key: string]: any };\n    const PackageDataFragmentDoc: { [key: string]: any };\n    const PackageType: { [key: string]: any };\n    const ParseSceneFilenamesDocument: { [key: string]: any };\n    const PerformerCreateDocument: { [key: string]: any };\n    const PerformerDataFragmentDoc: { [key: string]: any };\n    const PerformerDestroyDocument: { [key: string]: any };\n    const PerformerUpdateDocument: { [key: string]: any };\n    const PerformersDestroyDocument: { [key: string]: any };\n    const PluginSettingTypeEnum: { [key: string]: any };\n    const PluginTasksDocument: { [key: string]: any };\n    const PluginsDocument: { [key: string]: any };\n    const PreviewPreset: { [key: string]: any };\n    const ReloadPluginsDocument: { [key: string]: any };\n    const ReloadScrapersDocument: { [key: string]: any };\n    const RemoveGalleryImagesDocument: { [key: string]: any };\n    const RemoveTempDlnaipDocument: { [key: string]: any };\n    const ResolutionEnum: { [key: string]: any };\n    const RunPluginTaskDocument: { [key: string]: any };\n    const SaveFilterDocument: { [key: string]: any };\n    const SavedFilterDataFragmentDoc: { [key: string]: any };\n    const ScanCompleteSubscribeDocument: { [key: string]: any };\n    const SceneAssignFileDocument: { [key: string]: any };\n    const SceneCreateDocument: { [key: string]: any };\n    const SceneDataFragmentDoc: { [key: string]: any };\n    const SceneDecrementODocument: { [key: string]: any };\n    const SceneDestroyDocument: { [key: string]: any };\n    const SceneGenerateScreenshotDocument: { [key: string]: any };\n    const SceneIncrementODocument: { [key: string]: any };\n    const SceneIncrementPlayCountDocument: { [key: string]: any };\n    const SceneMarkerCreateDocument: { [key: string]: any };\n    const SceneMarkerDataFragmentDoc: { [key: string]: any };\n    const SceneMarkerDestroyDocument: { [key: string]: any };\n    const SceneMarkerUpdateDocument: { [key: string]: any };\n    const SceneMergeDocument: { [key: string]: any };\n    const SceneResetODocument: { [key: string]: any };\n    const SceneSaveActivityDocument: { [key: string]: any };\n    const SceneStreamsDocument: { [key: string]: any };\n    const SceneUpdateDocument: { [key: string]: any };\n    const SceneWallDocument: { [key: string]: any };\n    const ScenesDestroyDocument: { [key: string]: any };\n    const ScenesUpdateDocument: { [key: string]: any };\n    const ScrapeContentType: { [key: string]: any };\n    const ScrapeGalleryUrlDocument: { [key: string]: any };\n    const ScrapeGroupUrlDocument: { [key: string]: any };\n    const ScrapeMultiPerformersDocument: { [key: string]: any };\n    const ScrapeMultiScenesDocument: { [key: string]: any };\n    const ScrapePerformerUrlDocument: { [key: string]: any };\n    const ScrapeSceneUrlDocument: { [key: string]: any };\n    const ScrapeSingleGalleryDocument: { [key: string]: any };\n    const ScrapeSinglePerformerDocument: { [key: string]: any };\n    const ScrapeSingleSceneDocument: { [key: string]: any };\n    const ScrapeSingleStudioDocument: { [key: string]: any };\n    const ScrapeType: { [key: string]: any };\n    const ScrapedGalleryDataFragmentDoc: { [key: string]: any };\n    const ScrapedGroupDataFragmentDoc: { [key: string]: any };\n    const ScrapedGroupStudioDataFragmentDoc: { [key: string]: any };\n    const ScrapedPerformerDataFragmentDoc: { [key: string]: any };\n    const ScrapedSceneDataFragmentDoc: { [key: string]: any };\n    const ScrapedSceneGroupDataFragmentDoc: { [key: string]: any };\n    const ScrapedScenePerformerDataFragmentDoc: { [key: string]: any };\n    const ScrapedSceneStudioDataFragmentDoc: { [key: string]: any };\n    const ScrapedSceneTagDataFragmentDoc: { [key: string]: any };\n    const ScrapedStashBoxPerformerDataFragmentDoc: { [key: string]: any };\n    const ScrapedStashBoxSceneDataFragmentDoc: { [key: string]: any };\n    const ScrapedStudioDataFragmentDoc: { [key: string]: any };\n    const ScraperSourceDataFragmentDoc: { [key: string]: any };\n    const SelectGalleryDataFragmentDoc: { [key: string]: any };\n    const SelectGroupDataFragmentDoc: { [key: string]: any };\n    const SelectPerformerDataFragmentDoc: { [key: string]: any };\n    const SelectStudioDataFragmentDoc: { [key: string]: any };\n    const SelectTagDataFragmentDoc: { [key: string]: any };\n    const SetPluginsEnabledDocument: { [key: string]: any };\n    const SetupDocument: { [key: string]: any };\n    const SlimGalleryDataFragmentDoc: { [key: string]: any };\n    const SlimImageDataFragmentDoc: { [key: string]: any };\n    const SlimGroupDataFragmentDoc: { [key: string]: any };\n    const SlimPerformerDataFragmentDoc: { [key: string]: any };\n    const SlimSceneDataFragmentDoc: { [key: string]: any };\n    const SlimStudioDataFragmentDoc: { [key: string]: any };\n    const SlimTagDataFragmentDoc: { [key: string]: any };\n    const SortDirectionEnum: { [key: string]: any };\n    const StashBoxBatchPerformerTagDocument: { [key: string]: any };\n    const StashBoxBatchStudioTagDocument: { [key: string]: any };\n    const StatsDocument: { [key: string]: any };\n    const StopAllJobsDocument: { [key: string]: any };\n    const StopJobDocument: { [key: string]: any };\n    const StreamingResolutionEnum: { [key: string]: any };\n    const StudioCreateDocument: { [key: string]: any };\n    const StudioDataFragmentDoc: { [key: string]: any };\n    const StudioDestroyDocument: { [key: string]: any };\n    const StudioUpdateDocument: { [key: string]: any };\n    const StudiosDestroyDocument: { [key: string]: any };\n    const SubmitStashBoxFingerprintsDocument: { [key: string]: any };\n    const SubmitStashBoxPerformerDraftDocument: { [key: string]: any };\n    const SubmitStashBoxSceneDraftDocument: { [key: string]: any };\n    const SystemStatusDocument: { [key: string]: any };\n    const SystemStatusEnum: { [key: string]: any };\n    const TagCreateDocument: { [key: string]: any };\n    const TagDataFragmentDoc: { [key: string]: any };\n    const TagDestroyDocument: { [key: string]: any };\n    const TagUpdateDocument: { [key: string]: any };\n    const TagsDestroyDocument: { [key: string]: any };\n    const TagsMergeDocument: { [key: string]: any };\n    const UninstallPluginPackagesDocument: { [key: string]: any };\n    const UninstallScraperPackagesDocument: { [key: string]: any };\n    const UpdatePluginPackagesDocument: { [key: string]: any };\n    const UpdateScraperPackagesDocument: { [key: string]: any };\n    const ValidateStashBoxDocument: { [key: string]: any };\n    const VersionDocument: { [key: string]: any };\n    const VideoFileDataFragmentDoc: { [key: string]: any };\n    const VisualFileDataFragmentDoc: { [key: string]: any };\n    function refetchAvailablePluginPackagesQuery(...args: any[]): any;\n    function refetchAvailableScraperPackagesQuery(...args: any[]): any;\n    function refetchConfigurationQuery(...args: any[]): any;\n    function refetchDirectoryQuery(...args: any[]): any;\n    function refetchDlnaStatusQuery(...args: any[]): any;\n    function refetchFindDuplicateScenesQuery(...args: any[]): any;\n    function refetchFindGalleriesForSelectQuery(...args: any[]): any;\n    function refetchFindGalleriesQuery(...args: any[]): any;\n    function refetchFindGalleryQuery(...args: any[]): any;\n    function refetchFindImageQuery(...args: any[]): any;\n    function refetchFindImagesQuery(...args: any[]): any;\n    function refetchFindJobQuery(...args: any[]): any;\n    function refetchFindGroupQuery(...args: any[]): any;\n    function refetchFindGroupsForSelectQuery(...args: any[]): any;\n    function refetchFindGroupsQuery(...args: any[]): any;\n    function refetchFindPerformerQuery(...args: any[]): any;\n    function refetchFindPerformersForSelectQuery(...args: any[]): any;\n    function refetchFindPerformersQuery(...args: any[]): any;\n    function refetchFindSavedFilterQuery(...args: any[]): any;\n    function refetchFindSavedFiltersQuery(...args: any[]): any;\n    function refetchFindSceneMarkerTagsQuery(...args: any[]): any;\n    function refetchFindSceneMarkersQuery(...args: any[]): any;\n    function refetchFindSceneQuery(...args: any[]): any;\n    function refetchFindScenesByPathRegexQuery(...args: any[]): any;\n    function refetchFindScenesQuery(...args: any[]): any;\n    function refetchFindStudioQuery(...args: any[]): any;\n    function refetchFindStudiosForSelectQuery(...args: any[]): any;\n    function refetchFindStudiosQuery(...args: any[]): any;\n    function refetchFindTagQuery(...args: any[]): any;\n    function refetchFindTagsForSelectQuery(...args: any[]): any;\n    function refetchFindTagsQuery(...args: any[]): any;\n    function refetchInstalledPluginPackagesQuery(...args: any[]): any;\n    function refetchInstalledPluginPackagesStatusQuery(...args: any[]): any;\n    function refetchInstalledScraperPackagesQuery(...args: any[]): any;\n    function refetchInstalledScraperPackagesStatusQuery(...args: any[]): any;\n    function refetchJobQueueQuery(...args: any[]): any;\n    function refetchLatestVersionQuery(...args: any[]): any;\n    function refetchListGalleryScrapersQuery(...args: any[]): any;\n    function refetchListGroupScrapersQuery(...args: any[]): any;\n    function refetchListPerformerScrapersQuery(...args: any[]): any;\n    function refetchListSceneScrapersQuery(...args: any[]): any;\n    function refetchLogsQuery(...args: any[]): any;\n    function refetchMarkerStringsQuery(...args: any[]): any;\n    function refetchMarkerWallQuery(...args: any[]): any;\n    function refetchParseSceneFilenamesQuery(...args: any[]): any;\n    function refetchPluginTasksQuery(...args: any[]): any;\n    function refetchPluginsQuery(...args: any[]): any;\n    function refetchSceneStreamsQuery(...args: any[]): any;\n    function refetchSceneWallQuery(...args: any[]): any;\n    function refetchScrapeGalleryUrlQuery(...args: any[]): any;\n    function refetchScrapeGroupUrlQuery(...args: any[]): any;\n    function refetchScrapeMultiPerformersQuery(...args: any[]): any;\n    function refetchScrapeMultiScenesQuery(...args: any[]): any;\n    function refetchScrapePerformerUrlQuery(...args: any[]): any;\n    function refetchScrapeSceneUrlQuery(...args: any[]): any;\n    function refetchScrapeSingleGalleryQuery(...args: any[]): any;\n    function refetchScrapeSinglePerformerQuery(...args: any[]): any;\n    function refetchScrapeSingleSceneQuery(...args: any[]): any;\n    function refetchScrapeSingleStudioQuery(...args: any[]): any;\n    function refetchStatsQuery(...args: any[]): any;\n    function refetchSystemStatusQuery(...args: any[]): any;\n    function refetchValidateStashBoxQuery(...args: any[]): any;\n    function refetchVersionQuery(...args: any[]): any;\n    function useAddGalleryImagesMutation(...args: any[]): any;\n    function useAddTempDlnaipMutation(...args: any[]): any;\n    function useAnonymiseDatabaseMutation(...args: any[]): any;\n    function useAvailablePluginPackagesLazyQuery(...args: any[]): any;\n    function useAvailablePluginPackagesQuery(...args: any[]): any;\n    function useAvailablePluginPackagesSuspenseQuery(...args: any[]): any;\n    function useAvailableScraperPackagesLazyQuery(...args: any[]): any;\n    function useAvailableScraperPackagesQuery(...args: any[]): any;\n    function useAvailableScraperPackagesSuspenseQuery(...args: any[]): any;\n    function useBackupDatabaseMutation(...args: any[]): any;\n    function useBulkGalleryUpdateMutation(...args: any[]): any;\n    function useBulkImageUpdateMutation(...args: any[]): any;\n    function useBulkGroupUpdateMutation(...args: any[]): any;\n    function useBulkPerformerUpdateMutation(...args: any[]): any;\n    function useBulkSceneUpdateMutation(...args: any[]): any;\n    function useConfigurationLazyQuery(...args: any[]): any;\n    function useConfigurationQuery(...args: any[]): any;\n    function useConfigurationSuspenseQuery(...args: any[]): any;\n    function useConfigureDefaultsMutation(...args: any[]): any;\n    function useConfigureDlnaMutation(...args: any[]): any;\n    function useConfigureGeneralMutation(...args: any[]): any;\n    function useConfigureInterfaceMutation(...args: any[]): any;\n    function useConfigurePluginMutation(...args: any[]): any;\n    function useConfigureScrapingMutation(...args: any[]): any;\n    function useConfigureUiMutation(...args: any[]): any;\n    function useDeleteFilesMutation(...args: any[]): any;\n    function useDestroySavedFilterMutation(...args: any[]): any;\n    function useDirectoryLazyQuery(...args: any[]): any;\n    function useDirectoryQuery(...args: any[]): any;\n    function useDirectorySuspenseQuery(...args: any[]): any;\n    function useDisableDlnaMutation(...args: any[]): any;\n    function useDlnaStatusLazyQuery(...args: any[]): any;\n    function useDlnaStatusQuery(...args: any[]): any;\n    function useDlnaStatusSuspenseQuery(...args: any[]): any;\n    function useEnableDlnaMutation(...args: any[]): any;\n    function useExportObjectsMutation(...args: any[]): any;\n    function useFindDuplicateScenesLazyQuery(...args: any[]): any;\n    function useFindDuplicateScenesQuery(...args: any[]): any;\n    function useFindDuplicateScenesSuspenseQuery(...args: any[]): any;\n    function useFindGalleriesForSelectLazyQuery(...args: any[]): any;\n    function useFindGalleriesForSelectQuery(...args: any[]): any;\n    function useFindGalleriesForSelectSuspenseQuery(...args: any[]): any;\n    function useFindGalleriesLazyQuery(...args: any[]): any;\n    function useFindGalleriesQuery(...args: any[]): any;\n    function useFindGalleriesSuspenseQuery(...args: any[]): any;\n    function useFindGalleryLazyQuery(...args: any[]): any;\n    function useFindGalleryQuery(...args: any[]): any;\n    function useFindGallerySuspenseQuery(...args: any[]): any;\n    function useFindImageLazyQuery(...args: any[]): any;\n    function useFindImageQuery(...args: any[]): any;\n    function useFindImageSuspenseQuery(...args: any[]): any;\n    function useFindImagesLazyQuery(...args: any[]): any;\n    function useFindImagesQuery(...args: any[]): any;\n    function useFindImagesSuspenseQuery(...args: any[]): any;\n    function useFindJobLazyQuery(...args: any[]): any;\n    function useFindJobQuery(...args: any[]): any;\n    function useFindJobSuspenseQuery(...args: any[]): any;\n    function useFindGroupLazyQuery(...args: any[]): any;\n    function useFindGroupQuery(...args: any[]): any;\n    function useFindGroupSuspenseQuery(...args: any[]): any;\n    function useFindGroupsForSelectLazyQuery(...args: any[]): any;\n    function useFindGroupsForSelectQuery(...args: any[]): any;\n    function useFindGroupsForSelectSuspenseQuery(...args: any[]): any;\n    function useFindGroupsLazyQuery(...args: any[]): any;\n    function useFindGroupsQuery(...args: any[]): any;\n    function useFindGroupsSuspenseQuery(...args: any[]): any;\n    function useFindPerformerLazyQuery(...args: any[]): any;\n    function useFindPerformerQuery(...args: any[]): any;\n    function useFindPerformerSuspenseQuery(...args: any[]): any;\n    function useFindPerformersForSelectLazyQuery(...args: any[]): any;\n    function useFindPerformersForSelectQuery(...args: any[]): any;\n    function useFindPerformersForSelectSuspenseQuery(...args: any[]): any;\n    function useFindPerformersLazyQuery(...args: any[]): any;\n    function useFindPerformersQuery(...args: any[]): any;\n    function useFindPerformersSuspenseQuery(...args: any[]): any;\n    function useFindSavedFilterLazyQuery(...args: any[]): any;\n    function useFindSavedFilterQuery(...args: any[]): any;\n    function useFindSavedFilterSuspenseQuery(...args: any[]): any;\n    function useFindSavedFiltersLazyQuery(...args: any[]): any;\n    function useFindSavedFiltersQuery(...args: any[]): any;\n    function useFindSavedFiltersSuspenseQuery(...args: any[]): any;\n    function useFindSceneLazyQuery(...args: any[]): any;\n    function useFindSceneMarkerTagsLazyQuery(...args: any[]): any;\n    function useFindSceneMarkerTagsQuery(...args: any[]): any;\n    function useFindSceneMarkerTagsSuspenseQuery(...args: any[]): any;\n    function useFindSceneMarkersLazyQuery(...args: any[]): any;\n    function useFindSceneMarkersQuery(...args: any[]): any;\n    function useFindSceneMarkersSuspenseQuery(...args: any[]): any;\n    function useFindSceneQuery(...args: any[]): any;\n    function useFindSceneSuspenseQuery(...args: any[]): any;\n    function useFindScenesByPathRegexLazyQuery(...args: any[]): any;\n    function useFindScenesByPathRegexQuery(...args: any[]): any;\n    function useFindScenesByPathRegexSuspenseQuery(...args: any[]): any;\n    function useFindScenesLazyQuery(...args: any[]): any;\n    function useFindScenesQuery(...args: any[]): any;\n    function useFindScenesSuspenseQuery(...args: any[]): any;\n    function useFindStudioLazyQuery(...args: any[]): any;\n    function useFindStudioQuery(...args: any[]): any;\n    function useFindStudioSuspenseQuery(...args: any[]): any;\n    function useFindStudiosForSelectLazyQuery(...args: any[]): any;\n    function useFindStudiosForSelectQuery(...args: any[]): any;\n    function useFindStudiosForSelectSuspenseQuery(...args: any[]): any;\n    function useFindStudiosLazyQuery(...args: any[]): any;\n    function useFindStudiosQuery(...args: any[]): any;\n    function useFindStudiosSuspenseQuery(...args: any[]): any;\n    function useFindTagLazyQuery(...args: any[]): any;\n    function useFindTagQuery(...args: any[]): any;\n    function useFindTagSuspenseQuery(...args: any[]): any;\n    function useFindTagsForSelectLazyQuery(...args: any[]): any;\n    function useFindTagsForSelectQuery(...args: any[]): any;\n    function useFindTagsForSelectSuspenseQuery(...args: any[]): any;\n    function useFindTagsLazyQuery(...args: any[]): any;\n    function useFindTagsQuery(...args: any[]): any;\n    function useFindTagsSuspenseQuery(...args: any[]): any;\n    function useGalleriesUpdateMutation(...args: any[]): any;\n    function useGalleryChapterCreateMutation(...args: any[]): any;\n    function useGalleryChapterDestroyMutation(...args: any[]): any;\n    function useGalleryChapterUpdateMutation(...args: any[]): any;\n    function useGalleryCreateMutation(...args: any[]): any;\n    function useGalleryDestroyMutation(...args: any[]): any;\n    function useGalleryUpdateMutation(...args: any[]): any;\n    function useGenerateApiKeyMutation(...args: any[]): any;\n    function useImageDecrementOMutation(...args: any[]): any;\n    function useImageDestroyMutation(...args: any[]): any;\n    function useImageIncrementOMutation(...args: any[]): any;\n    function useImageResetOMutation(...args: any[]): any;\n    function useImageUpdateMutation(...args: any[]): any;\n    function useImagesDestroyMutation(...args: any[]): any;\n    function useImagesUpdateMutation(...args: any[]): any;\n    function useImportObjectsMutation(...args: any[]): any;\n    function useInstallPluginPackagesMutation(...args: any[]): any;\n    function useInstallScraperPackagesMutation(...args: any[]): any;\n    function useInstalledPluginPackagesLazyQuery(...args: any[]): any;\n    function useInstalledPluginPackagesQuery(...args: any[]): any;\n    function useInstalledPluginPackagesStatusLazyQuery(...args: any[]): any;\n    function useInstalledPluginPackagesStatusQuery(...args: any[]): any;\n    function useInstalledPluginPackagesStatusSuspenseQuery(...args: any[]): any;\n    function useInstalledPluginPackagesSuspenseQuery(...args: any[]): any;\n    function useInstalledScraperPackagesLazyQuery(...args: any[]): any;\n    function useInstalledScraperPackagesQuery(...args: any[]): any;\n    function useInstalledScraperPackagesStatusLazyQuery(...args: any[]): any;\n    function useInstalledScraperPackagesStatusQuery(...args: any[]): any;\n    function useInstalledScraperPackagesStatusSuspenseQuery(\n      ...args: any[]\n    ): any;\n    function useInstalledScraperPackagesSuspenseQuery(...args: any[]): any;\n    function useJobQueueLazyQuery(...args: any[]): any;\n    function useJobQueueQuery(...args: any[]): any;\n    function useJobQueueSuspenseQuery(...args: any[]): any;\n    function useJobsSubscribeSubscription(...args: any[]): any;\n    function useLatestVersionLazyQuery(...args: any[]): any;\n    function useLatestVersionQuery(...args: any[]): any;\n    function useLatestVersionSuspenseQuery(...args: any[]): any;\n    function useListGalleryScrapersLazyQuery(...args: any[]): any;\n    function useListGalleryScrapersQuery(...args: any[]): any;\n    function useListGalleryScrapersSuspenseQuery(...args: any[]): any;\n    function useListGroupScrapersLazyQuery(...args: any[]): any;\n    function useListGroupScrapersQuery(...args: any[]): any;\n    function useListGroupScrapersSuspenseQuery(...args: any[]): any;\n    function useListPerformerScrapersLazyQuery(...args: any[]): any;\n    function useListPerformerScrapersQuery(...args: any[]): any;\n    function useListPerformerScrapersSuspenseQuery(...args: any[]): any;\n    function useListSceneScrapersLazyQuery(...args: any[]): any;\n    function useListSceneScrapersQuery(...args: any[]): any;\n    function useListSceneScrapersSuspenseQuery(...args: any[]): any;\n    function useLoggingSubscribeSubscription(...args: any[]): any;\n    function useLogsLazyQuery(...args: any[]): any;\n    function useLogsQuery(...args: any[]): any;\n    function useLogsSuspenseQuery(...args: any[]): any;\n    function useMarkerStringsLazyQuery(...args: any[]): any;\n    function useMarkerStringsQuery(...args: any[]): any;\n    function useMarkerStringsSuspenseQuery(...args: any[]): any;\n    function useMarkerWallLazyQuery(...args: any[]): any;\n    function useMarkerWallQuery(...args: any[]): any;\n    function useMarkerWallSuspenseQuery(...args: any[]): any;\n    function useMetadataAutoTagMutation(...args: any[]): any;\n    function useMetadataCleanMutation(...args: any[]): any;\n    function useMetadataExportMutation(...args: any[]): any;\n    function useMetadataGenerateMutation(...args: any[]): any;\n    function useMetadataIdentifyMutation(...args: any[]): any;\n    function useMetadataImportMutation(...args: any[]): any;\n    function useMetadataScanMutation(...args: any[]): any;\n    function useMigrateBlobsMutation(...args: any[]): any;\n    function useMigrateHashNamingMutation(...args: any[]): any;\n    function useMigrateMutation(...args: any[]): any;\n    function useMigrateSceneScreenshotsMutation(...args: any[]): any;\n    function useGroupCreateMutation(...args: any[]): any;\n    function useGroupDestroyMutation(...args: any[]): any;\n    function useGroupUpdateMutation(...args: any[]): any;\n    function useGroupsDestroyMutation(...args: any[]): any;\n    function useOptimiseDatabaseMutation(...args: any[]): any;\n    function useParseSceneFilenamesLazyQuery(...args: any[]): any;\n    function useParseSceneFilenamesQuery(...args: any[]): any;\n    function useParseSceneFilenamesSuspenseQuery(...args: any[]): any;\n    function usePerformerCreateMutation(...args: any[]): any;\n    function usePerformerDestroyMutation(...args: any[]): any;\n    function usePerformerUpdateMutation(...args: any[]): any;\n    function usePerformersDestroyMutation(...args: any[]): any;\n    function usePluginTasksLazyQuery(...args: any[]): any;\n    function usePluginTasksQuery(...args: any[]): any;\n    function usePluginTasksSuspenseQuery(...args: any[]): any;\n    function usePluginsLazyQuery(...args: any[]): any;\n    function usePluginsQuery(...args: any[]): any;\n    function usePluginsSuspenseQuery(...args: any[]): any;\n    function useReloadPluginsMutation(...args: any[]): any;\n    function useReloadScrapersMutation(...args: any[]): any;\n    function useRemoveGalleryImagesMutation(...args: any[]): any;\n    function useRemoveTempDlnaipMutation(...args: any[]): any;\n    function useRunPluginTaskMutation(...args: any[]): any;\n    function useSaveFilterMutation(...args: any[]): any;\n    function useScanCompleteSubscribeSubscription(...args: any[]): any;\n    function useSceneAssignFileMutation(...args: any[]): any;\n    function useSceneCreateMutation(...args: any[]): any;\n    function useSceneDecrementOMutation(...args: any[]): any;\n    function useSceneDestroyMutation(...args: any[]): any;\n    function useSceneGenerateScreenshotMutation(...args: any[]): any;\n    function useSceneIncrementOMutation(...args: any[]): any;\n    function useSceneIncrementPlayCountMutation(...args: any[]): any;\n    function useSceneMarkerCreateMutation(...args: any[]): any;\n    function useSceneMarkerDestroyMutation(...args: any[]): any;\n    function useSceneMarkerUpdateMutation(...args: any[]): any;\n    function useSceneMergeMutation(...args: any[]): any;\n    function useSceneResetOMutation(...args: any[]): any;\n    function useSceneSaveActivityMutation(...args: any[]): any;\n    function useSceneStreamsLazyQuery(...args: any[]): any;\n    function useSceneStreamsQuery(...args: any[]): any;\n    function useSceneStreamsSuspenseQuery(...args: any[]): any;\n    function useSceneUpdateMutation(...args: any[]): any;\n    function useSceneWallLazyQuery(...args: any[]): any;\n    function useSceneWallQuery(...args: any[]): any;\n    function useSceneWallSuspenseQuery(...args: any[]): any;\n    function useScenesDestroyMutation(...args: any[]): any;\n    function useScenesUpdateMutation(...args: any[]): any;\n    function useScrapeGalleryUrlLazyQuery(...args: any[]): any;\n    function useScrapeGalleryUrlQuery(...args: any[]): any;\n    function useScrapeGalleryUrlSuspenseQuery(...args: any[]): any;\n    function useScrapeGroupUrlLazyQuery(...args: any[]): any;\n    function useScrapeGroupUrlQuery(...args: any[]): any;\n    function useScrapeGroupUrlSuspenseQuery(...args: any[]): any;\n    function useScrapeMultiPerformersLazyQuery(...args: any[]): any;\n    function useScrapeMultiPerformersQuery(...args: any[]): any;\n    function useScrapeMultiPerformersSuspenseQuery(...args: any[]): any;\n    function useScrapeMultiScenesLazyQuery(...args: any[]): any;\n    function useScrapeMultiScenesQuery(...args: any[]): any;\n    function useScrapeMultiScenesSuspenseQuery(...args: any[]): any;\n    function useScrapePerformerUrlLazyQuery(...args: any[]): any;\n    function useScrapePerformerUrlQuery(...args: any[]): any;\n    function useScrapePerformerUrlSuspenseQuery(...args: any[]): any;\n    function useScrapeSceneUrlLazyQuery(...args: any[]): any;\n    function useScrapeSceneUrlQuery(...args: any[]): any;\n    function useScrapeSceneUrlSuspenseQuery(...args: any[]): any;\n    function useScrapeSingleGalleryLazyQuery(...args: any[]): any;\n    function useScrapeSingleGalleryQuery(...args: any[]): any;\n    function useScrapeSingleGallerySuspenseQuery(...args: any[]): any;\n    function useScrapeSinglePerformerLazyQuery(...args: any[]): any;\n    function useScrapeSinglePerformerQuery(...args: any[]): any;\n    function useScrapeSinglePerformerSuspenseQuery(...args: any[]): any;\n    function useScrapeSingleSceneLazyQuery(...args: any[]): any;\n    function useScrapeSingleSceneQuery(...args: any[]): any;\n    function useScrapeSingleSceneSuspenseQuery(...args: any[]): any;\n    function useScrapeSingleStudioLazyQuery(...args: any[]): any;\n    function useScrapeSingleStudioQuery(...args: any[]): any;\n    function useScrapeSingleStudioSuspenseQuery(...args: any[]): any;\n    function useSetDefaultFilterMutation(...args: any[]): any;\n    function useSetPluginsEnabledMutation(...args: any[]): any;\n    function useSetupMutation(...args: any[]): any;\n    function useStashBoxBatchPerformerTagMutation(...args: any[]): any;\n    function useStashBoxBatchStudioTagMutation(...args: any[]): any;\n    function useStatsLazyQuery(...args: any[]): any;\n    function useStatsQuery(...args: any[]): any;\n    function useStatsSuspenseQuery(...args: any[]): any;\n    function useStopAllJobsMutation(...args: any[]): any;\n    function useStopJobMutation(...args: any[]): any;\n    function useStudioCreateMutation(...args: any[]): any;\n    function useStudioDestroyMutation(...args: any[]): any;\n    function useStudioUpdateMutation(...args: any[]): any;\n    function useStudiosDestroyMutation(...args: any[]): any;\n    function useSubmitStashBoxFingerprintsMutation(...args: any[]): any;\n    function useSubmitStashBoxPerformerDraftMutation(...args: any[]): any;\n    function useSubmitStashBoxSceneDraftMutation(...args: any[]): any;\n    function useSystemStatusLazyQuery(...args: any[]): any;\n    function useSystemStatusQuery(...args: any[]): any;\n    function useSystemStatusSuspenseQuery(...args: any[]): any;\n    function useTagCreateMutation(...args: any[]): any;\n    function useTagDestroyMutation(...args: any[]): any;\n    function useTagUpdateMutation(...args: any[]): any;\n    function useTagsDestroyMutation(...args: any[]): any;\n    function useTagsMergeMutation(...args: any[]): any;\n    function useUninstallPluginPackagesMutation(...args: any[]): any;\n    function useUninstallScraperPackagesMutation(...args: any[]): any;\n    function useUpdatePluginPackagesMutation(...args: any[]): any;\n    function useUpdateScraperPackagesMutation(...args: any[]): any;\n    function useValidateStashBoxLazyQuery(...args: any[]): any;\n    function useValidateStashBoxQuery(...args: any[]): any;\n    function useValidateStashBoxSuspenseQuery(...args: any[]): any;\n    function useVersionLazyQuery(...args: any[]): any;\n    function useVersionQuery(...args: any[]): any;\n    function useVersionSuspenseQuery(...args: any[]): any;\n  }\n  namespace libraries {\n    const Apollo: typeof import(\"@apollo/client\");\n    const Bootstrap: typeof import(\"react-bootstrap\");\n    const FontAwesomeRegular: typeof import(\"@fortawesome/free-regular-svg-icons\");\n    const FontAwesomeSolid: typeof import(\"@fortawesome/free-solid-svg-icons\");\n    const FontAwesomeBrands: typeof import(\"@fortawesome/free-brands-svg-icons\");\n    const Intl: typeof import(\"react-intl\");\n    const Mousetrap: typeof import(\"mousetrap\");\n    const ReactFontAwesome: typeof import(\"@fortawesome/react-fontawesome\");\n    const ReactSelect: typeof import(\"react-select\");\n    const ReactSlick: typeof import(\"@ant-design/react-slick\");\n\n    // @ts-expect-error\n    import { MousetrapStatic } from \"mousetrap\";\n    function MousetrapPause(mousetrap: MousetrapStatic): MousetrapStatic;\n\n    const ReactRouterDOM: typeof import(\"react-router-dom\");\n  }\n  namespace loadableComponents {\n    interface ISceneCardProps {\n      scene: any;\n      containerWidth?: number;\n      previewHeight?: number;\n      index?: number;\n      queue?: any;\n      compact?: boolean;\n      selecting?: boolean;\n      selected?: boolean | undefined;\n      zoomIndex?: number;\n      onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;\n    }\n    interface IScenePreviewProps {\n      isPortrait: boolean;\n      image?: string;\n      video?: string;\n      soundActive: boolean;\n      vttPath?: string;\n      onScrubberClick?: (timestamp: number) => void;\n    }\n    function SceneCard(): Promise<{\n      SceneCard: React.FC<ISceneCardProps>;\n      ScenePreview: React.FC<IScenePreviewProps>;\n    }>;\n  }\n  const components: {\n    AlertModal: React.FC<any>;\n    BackgroundImage: React.FC<any>;\n    BooleanSetting: React.FC<any>;\n    ChangeButtonSetting: React.FC<any>;\n    ConstantSetting: React.FC<any>;\n    CountrySelect: React.FC<any>;\n    CustomFieldInput: React.FC<any>;\n    CustomFields: React.FC<any>;\n    DateInput: React.FC<any>;\n    DetailImage: React.FC<any>;\n    ExternalLinkButtons: React.FC<any>;\n    ExternalLinksButton: React.FC<any>;\n    FilteredGalleryList: React.FC<any>;\n    FilteredGroupList: React.FC<any>;\n    FilteredImageList: React.FC<any>;\n    FilteredPerformerList: React.FC<any>;\n    FilteredSceneList: React.FC<any>;\n    FilteredSceneMarkerList: React.FC<any>;\n    FilteredStudioList: React.FC<any>;\n    FilteredTagList: React.FC<any>;\n    FolderSelect: React.FC<any>;\n    FrontPage: React.FC<any>;\n    GalleryCard: React.FC<any>;\n    \"GalleryCard.Details\": React.FC<any>;\n    \"GalleryCard.Image\": React.FC<any>;\n    \"GalleryCard.Overlays\": React.FC<any>;\n    \"GalleryCard.Popovers\": React.FC<any>;\n    GalleryCardGrid: React.FC<any>;\n    GalleryAddPanel: React.FC<any>;\n    GalleryIDSelect: React.FC<any>;\n    GalleryImagesPanel: React.FC<any>;\n    GalleryList: React.FC<any>;\n    GalleryRecommendationRow: React.FC<any>;\n    GallerySelect: React.FC<any>;\n    GridCard: React.FC<any>;\n    GroupCard: React.FC<any>;\n    GroupCardGrid: React.FC<any>;\n    GroupIDSelect: React.FC<any>;\n    GroupList: React.FC<any>;\n    GroupRecommendationRow: React.FC<any>;\n    GroupSelect: React.FC<any>;\n    GroupSubGroupsPanel: React.FC<any>;\n    HeaderImage: React.FC<any>;\n    HoverPopover: React.FC<any>;\n    Icon: React.FC<any>;\n    ImageCard: React.FC<any>;\n    ImageCardGrid: React.FC<any>;\n    ImageInput: React.FC<any>;\n    ImageList: React.FC<any>;\n    ImageRecommendationRow: React.FC<any>;\n    LightboxLink: React.FC<any>;\n    LoadingIndicator: React.FC<any>;\n    \"MainNavBar.MenuItems\": React.FC<any>;\n    \"MainNavBar.UtilityItems\": React.FC<any>;\n    ModalSetting: React.FC<any>;\n    NumberSetting: React.FC<any>;\n    PerformerAppearsWithPanel: React.FC<any>;\n    PerformerCard: React.FC<any>;\n    PerformerCardGrid: React.FC<any>;\n    \"PerformerCard.Details\": React.FC<any>;\n    \"PerformerCard.Image\": React.FC<any>;\n    \"PerformerCard.Overlays\": React.FC<any>;\n    \"PerformerCard.Popovers\": React.FC<any>;\n    \"PerformerCard.Title\": React.FC<any>;\n    PerformerDetailsPanel: React.FC<any>;\n    \"PerformerDetailsPanel.DetailGroup\": React.FC<any>;\n    PerformerGalleriesPanel: React.FC<any>;\n    PerformerGroupsPanel: React.FC<any>;\n    PerformerHeaderImage: React.FC<any>;\n    PerformerIDSelect: React.FC<any>;\n    PerformerImagesPanel: React.FC<any>;\n    PerformerList: React.FC<any>;\n    PerformerPage: React.FC<any>;\n    PerformerRecommendationRow: React.FC<any>;\n    PerformerScenesPanel: React.FC<any>;\n    PerformerSelect: React.FC<any>;\n    PluginSettings: React.FC<any>;\n    RatingNumber: React.FC<any>;\n    RatingStars: React.FC<any>;\n    RatingSystem: React.FC<any>;\n    RecommendationRow: React.FC<any>;\n    SceneFileInfoPanel: React.FC<any>;\n    SceneIDSelect: React.FC<any>;\n    ScenePage: React.FC<any>;\n    \"ScenePage.TabContent\": React.FC<any>;\n    \"ScenePage.Tabs\": React.FC<any>;\n    ScenePlayer: React.FC<any>;\n    SceneSelect: React.FC<any>;\n    \"SceneCard.Details\": React.FC<any>;\n    \"SceneCard.Image\": React.FC<any>;\n    \"SceneCard.Overlays\": React.FC<any>;\n    \"SceneCard.Popovers\": React.FC<any>;\n    \"SceneCard.SceneSpecs\": React.FC<any>;\n    SceneCardGrid: React.FC<any>;\n    SceneList: React.FC<any>;\n    SceneListOperations: React.FC<any>;\n    SceneMarkerCard: React.FC<any>;\n    \"SceneMarkerCard.Details\": React.FC<any>;\n    \"SceneMarkerCard.Image\": React.FC<any>;\n    \"SceneMarkerCard.Popovers\": React.FC<any>;\n    SceneMarkerCardGrid: React.FC<any>;\n    SceneMarkerList: React.FC<any>;\n    SceneMarkerRecommendationRow: React.FC<any>;\n    SceneRecommendationRow: React.FC<any>;\n    SelectSetting: React.FC<any>;\n    Setting: React.FC<any>;\n    SettingGroup: React.FC<any>;\n    SettingModal: React.FC<any>;\n    StringListSetting: React.FC<any>;\n    StringSetting: React.FC<any>;\n    StudioCard: React.FC<any>;\n    StudioCardGrid: React.FC<any>;\n    StudioDetailsPanel: React.FC<any>;\n    StudioIDSelect: React.FC<any>;\n    StudioList: React.FC<any>;\n    StudioRecommendationRow: React.FC<any>;\n    StudioSelect: React.FC<any>;\n    SweatDrops: React.FC<any>;\n    TabTitleCounter: React.FC<any>;\n    TagIDSelect: React.FC<any>;\n    \"TagCard.Details\": React.FC<any>;\n    \"TagCard.Image\": React.FC<any>;\n    \"TagCard.Overlays\": React.FC<any>;\n    \"TagCard.Popovers\": React.FC<any>;\n    \"TagCard.Title\": React.FC<any>;\n    TagCardGrid: React.FC<any>;\n    TagLink: React.FC<any>;\n    TagList: React.FC<any>;\n    TagRecommendationRow: React.FC<any>;\n    TagSelect: React.FC<any>;\n    TruncatedText: React.FC<any>;\n  };\n  type PatchableComponentNames = keyof typeof components | string;\n  namespace utils {\n    namespace NavUtils {\n      function makePerformerScenesUrl(...args: any[]): any;\n      function makePerformerImagesUrl(...args: any[]): any;\n      function makePerformerGalleriesUrl(...args: any[]): any;\n      function makePerformerGroupsUrl(...args: any[]): any;\n      function makePerformersCountryUrl(...args: any[]): any;\n      function makeStudioScenesUrl(...args: any[]): any;\n      function makeStudioImagesUrl(...args: any[]): any;\n      function makeStudioGalleriesUrl(...args: any[]): any;\n      function makeStudioGroupsUrl(...args: any[]): any;\n      function makeStudioPerformersUrl(...args: any[]): any;\n      function makeTagUrl(...args: any[]): any;\n      function makeParentTagsUrl(...args: any[]): any;\n      function makeChildTagsUrl(...args: any[]): any;\n      function makeTagSceneMarkersUrl(...args: any[]): any;\n      function makeTagScenesUrl(...args: any[]): any;\n      function makeTagPerformersUrl(...args: any[]): any;\n      function makeTagGalleriesUrl(...args: any[]): any;\n      function makeTagImagesUrl(...args: any[]): any;\n      function makeScenesPHashMatchUrl(...args: any[]): any;\n      function makeSceneMarkerUrl(...args: any[]): any;\n      function makeGroupScenesUrl(...args: any[]): any;\n      function makeChildStudiosUrl(...args: any[]): any;\n      function makeGalleryImagesUrl(...args: any[]): any;\n    }\n    namespace StashService {\n      function evictQueries(...args: any[]): any;\n      function getClient(...args: any[]): any;\n      function mutateAddGalleryImages(...args: any[]): any;\n      function mutateAnonymiseDatabase(...args: any[]): any;\n      function mutateBackupDatabase(...args: any[]): any;\n      function mutateCreateScene(...args: any[]): any;\n      function mutateDeleteFiles(...args: any[]): any;\n      function mutateExportObjects(...args: any[]): any;\n      function mutateGallerySetPrimaryFile(...args: any[]): any;\n      function mutateImageDecrementO(...args: any[]): any;\n      function mutateImageIncrementO(...args: any[]): any;\n      function mutateImageResetO(...args: any[]): any;\n      function mutateImageSetPrimaryFile(...args: any[]): any;\n      function mutateImportObjects(...args: any[]): any;\n      function mutateInstallPluginPackages(...args: any[]): any;\n      function mutateInstallScraperPackages(...args: any[]): any;\n      function mutateMetadataAutoTag(...args: any[]): any;\n      function mutateMetadataClean(...args: any[]): any;\n      function mutateMetadataExport(...args: any[]): any;\n      function mutateMetadataGenerate(...args: any[]): any;\n      function mutateMetadataIdentify(...args: any[]): any;\n      function mutateMetadataImport(...args: any[]): any;\n      function mutateMetadataScan(...args: any[]): any;\n      function mutateMigrate(...args: any[]): any;\n      function mutateMigrateBlobs(...args: any[]): any;\n      function mutateMigrateHashNaming(...args: any[]): any;\n      function mutateMigrateSceneScreenshots(...args: any[]): any;\n      function mutateOptimiseDatabase(...args: any[]): any;\n      function mutateReloadPlugins(...args: any[]): any;\n      function mutateReloadScrapers(...args: any[]): any;\n      function mutateRemoveGalleryImages(...args: any[]): any;\n      function mutateRunPluginTask(...args: any[]): any;\n      function mutateSceneAssignFile(...args: any[]): any;\n      function mutateSceneMerge(...args: any[]): any;\n      function mutateSceneSetPrimaryFile(...args: any[]): any;\n      function mutateSetPluginsEnabled(...args: any[]): any;\n      function mutateSetup(...args: any[]): any;\n      function mutateStashBoxBatchPerformerTag(...args: any[]): any;\n      function mutateStashBoxBatchStudioTag(...args: any[]): any;\n      function mutateStopJob(...args: any[]): any;\n      function mutateSubmitStashBoxPerformerDraft(...args: any[]): any;\n      function mutateSubmitStashBoxSceneDraft(...args: any[]): any;\n      function mutateUninstallPluginPackages(...args: any[]): any;\n      function mutateUninstallScraperPackages(...args: any[]): any;\n      function mutateUpdatePluginPackages(...args: any[]): any;\n      function mutateUpdateScraperPackages(...args: any[]): any;\n      function queryAvailablePluginPackages(...args: any[]): any;\n      function queryAvailableScraperPackages(...args: any[]): any;\n      function queryFindGalleries(...args: any[]): any;\n      function queryFindGalleriesByIDForSelect(...args: any[]): any;\n      function queryFindGalleriesForSelect(...args: any[]): any;\n      function queryFindImages(...args: any[]): any;\n      function queryFindGroups(...args: any[]): any;\n      function queryFindGroupsByIDForSelect(...args: any[]): any;\n      function queryFindGroupsForSelect(...args: any[]): any;\n      function queryFindPerformer(...args: any[]): any;\n      function queryFindPerformers(...args: any[]): any;\n      function queryFindPerformersByIDForSelect(...args: any[]): any;\n      function queryFindPerformersForSelect(...args: any[]): any;\n      function queryFindSceneMarkers(...args: any[]): any;\n      function queryFindScenes(...args: any[]): any;\n      function queryFindScenesByID(...args: any[]): any;\n      function queryFindStudio(...args: any[]): any;\n      function queryFindStudios(...args: any[]): any;\n      function queryFindStudiosByIDForSelect(...args: any[]): any;\n      function queryFindStudiosForSelect(...args: any[]): any;\n      function queryFindTags(...args: any[]): any;\n      function queryFindTagsByIDForSelect(...args: any[]): any;\n      function queryFindTagsForSelect(...args: any[]): any;\n      function queryLogs(...args: any[]): any;\n      function queryParseSceneFilenames(...args: any[]): any;\n      function querySceneByPathRegex(...args: any[]): any;\n      function queryScrapeGallery(...args: any[]): any;\n      function queryScrapeGalleryURL(...args: any[]): any;\n      function queryScrapeGroupURL(...args: any[]): any;\n      function queryScrapePerformer(...args: any[]): any;\n      function queryScrapePerformerURL(...args: any[]): any;\n      function queryScrapeScene(...args: any[]): any;\n      function queryScrapeSceneQuery(...args: any[]): any;\n      function queryScrapeSceneQueryFragment(...args: any[]): any;\n      function queryScrapeSceneURL(...args: any[]): any;\n      function stashBoxPerformerQuery(...args: any[]): any;\n      function stashBoxSceneBatchQuery(...args: any[]): any;\n      function stashBoxStudioQuery(...args: any[]): any;\n      function useAddTempDLNAIP(...args: any[]): any;\n      function useBulkGalleryUpdate(...args: any[]): any;\n      function useBulkImageUpdate(...args: any[]): any;\n      function useBulkGroupUpdate(...args: any[]): any;\n      function useBulkPerformerUpdate(...args: any[]): any;\n      function useBulkSceneUpdate(...args: any[]): any;\n      function useConfiguration(...args: any[]): any;\n      function useConfigureDLNA(...args: any[]): any;\n      function useConfigureDefaults(...args: any[]): any;\n      function useConfigureGeneral(...args: any[]): any;\n      function useConfigureInterface(...args: any[]): any;\n      function useConfigurePlugin(...args: any[]): any;\n      function useConfigureScraping(...args: any[]): any;\n      function useConfigureUI(...args: any[]): any;\n      function useDLNAStatus(...args: any[]): any;\n      function useDirectory(...args: any[]): any;\n      function useDisableDLNA(...args: any[]): any;\n      function useEnableDLNA(...args: any[]): any;\n      function useFindDefaultFilter(...args: any[]): any;\n      function useFindGalleries(...args: any[]): any;\n      function useFindGallery(...args: any[]): any;\n      function useFindImage(...args: any[]): any;\n      function useFindImages(...args: any[]): any;\n      function useFindGroup(...args: any[]): any;\n      function useFindGroups(...args: any[]): any;\n      function useFindPerformer(...args: any[]): any;\n      function useFindPerformers(...args: any[]): any;\n      function useFindSavedFilter(...args: any[]): any;\n      function useFindSavedFilters(...args: any[]): any;\n      function useFindScene(...args: any[]): any;\n      function useFindSceneMarkers(...args: any[]): any;\n      function useFindScenes(...args: any[]): any;\n      function useFindStudio(...args: any[]): any;\n      function useFindStudios(...args: any[]): any;\n      function useFindTag(...args: any[]): any;\n      function useFindTags(...args: any[]): any;\n      function useGalleryChapterCreate(...args: any[]): any;\n      function useGalleryChapterDestroy(...args: any[]): any;\n      function useGalleryChapterUpdate(...args: any[]): any;\n      function useGalleryCreate(...args: any[]): any;\n      function useGalleryDestroy(...args: any[]): any;\n      function useGalleryUpdate(...args: any[]): any;\n      function useGenerateAPIKey(...args: any[]): any;\n      function useImageDecrementO(...args: any[]): any;\n      function useImageIncrementO(...args: any[]): any;\n      function useImageResetO(...args: any[]): any;\n      function useImageUpdate(...args: any[]): any;\n      function useImagesDestroy(...args: any[]): any;\n      function useInstalledPluginPackages(...args: any[]): any;\n      function useInstalledScraperPackages(...args: any[]): any;\n      function useJobQueue(...args: any[]): any;\n      function useJobsSubscribe(...args: any[]): any;\n      function useLatestVersion(...args: any[]): any;\n      function useListGalleryScrapers(...args: any[]): any;\n      function useListGroupScrapers(...args: any[]): any;\n      function useListPerformerScrapers(...args: any[]): any;\n      function useListSceneScrapers(...args: any[]): any;\n      function useLoggingSubscribe(...args: any[]): any;\n      function useLogs(...args: any[]): any;\n      function useMarkerStrings(...args: any[]): any;\n      function useGroupCreate(...args: any[]): any;\n      function useGroupDestroy(...args: any[]): any;\n      function useGroupUpdate(...args: any[]): any;\n      function useGroupsDestroy(...args: any[]): any;\n      function usePerformerCreate(...args: any[]): any;\n      function usePerformerDestroy(...args: any[]): any;\n      function usePerformerUpdate(...args: any[]): any;\n      function usePerformersDestroy(...args: any[]): any;\n      function usePluginTasks(...args: any[]): any;\n      function usePlugins(...args: any[]): any;\n      function useRemoveTempDLNAIP(...args: any[]): any;\n      function useSaveFilter(...args: any[]): any;\n      function useSavedFilterDestroy(...args: any[]): any;\n      function useSceneDecrementO(...args: any[]): any;\n      function useSceneDestroy(...args: any[]): any;\n      function useSceneGenerateScreenshot(...args: any[]): any;\n      function useSceneIncrementO(...args: any[]): any;\n      function useSceneIncrementPlayCount(...args: any[]): any;\n      function useSceneMarkerCreate(...args: any[]): any;\n      function useSceneMarkerDestroy(...args: any[]): any;\n      function useSceneMarkerUpdate(...args: any[]): any;\n      function useSceneResetO(...args: any[]): any;\n      function useSceneSaveActivity(...args: any[]): any;\n      function useSceneStreams(...args: any[]): any;\n      function useSceneUpdate(...args: any[]): any;\n      function useScenesDestroy(...args: any[]): any;\n      function useScenesUpdate(...args: any[]): any;\n      function useScrapePerformerList(...args: any[]): any;\n      function useSetDefaultFilter(...args: any[]): any;\n      function useStats(...args: any[]): any;\n      function useStudioCreate(...args: any[]): any;\n      function useStudioDestroy(...args: any[]): any;\n      function useStudioUpdate(...args: any[]): any;\n      function useStudiosDestroy(...args: any[]): any;\n      function useSystemStatus(...args: any[]): any;\n      function useTagCreate(...args: any[]): any;\n      function useTagDestroy(...args: any[]): any;\n      function useTagUpdate(...args: any[]): any;\n      function useTagsDestroy(...args: any[]): any;\n      function useTagsMerge(...args: any[]): any;\n      function useVersion(...args: any[]): any;\n\n      const performerMutationImpactedQueries: { [key: string]: any }[];\n      const pluginMutationImpactedQueries: { [key: string]: any }[];\n      const scraperMutationImpactedQueries: { [key: string]: any }[];\n      const studioMutationImpactedQueries: { [key: string]: any }[];\n    }\n    function loadComponents(components: (() => Promise<unknown>)[]): void;\n  }\n  namespace hooks {\n    function useLoadComponents(toLoad: (() => Promise<unknown>)[]): boolean;\n    function useSpriteInfo(vttPath: string | undefined):\n      | {\n          url: string;\n          start: number;\n          end: number;\n          x: number;\n          y: number;\n          w: number;\n          h: number;\n        }\n      | undefined;\n\n    function useToast(): {\n      toast: any;\n      success(message: JSX.Element | string): void;\n      error(error: unknown): void;\n    };\n\n    function useSettings(): {\n      loading: boolean;\n      error: any | undefined;\n      general: any;\n      interface: any;\n      defaults: any;\n      scraping: any;\n      dlna: any;\n      ui: any;\n      plugins: any;\n\n      advancedMode: boolean;\n\n      // apikey isn't directly settable, so expose it here\n      apiKey: string;\n\n      saveGeneral: (input: any) => void;\n      saveInterface: (input: any) => void;\n      saveDefaults: (input: any) => void;\n      saveScraping: (input: any) => void;\n      saveDLNA: (input: any) => void;\n      saveUI: (input: any) => void;\n      savePluginSettings: (pluginID: string, input: {}) => void;\n      setAdvancedMode: (value: boolean) => void;\n\n      refetch: () => void;\n    };\n    export enum ConnectionState {\n      Missing,\n      Disconnected,\n      Error,\n      Connecting,\n      Syncing,\n      Uploading,\n      Ready,\n    }\n\n    type Handy = typeof import(\"thehandy\").default;\n    export type InteractiveAPI = {\n      readonly _connected: boolean;\n      readonly _playing: boolean;\n      readonly _scriptOffset: number;\n      readonly _handy: Handy;\n      readonly _useStashHostedFunscript: boolean;\n      connect(): Promise<void>;\n      set handyKey(key: string);\n      get handyKey(): string;\n      set useStashHostedFunscript(useStashHostedFunscript: boolean);\n      get useStashHostedFunscript(): boolean;\n      set scriptOffset(offset: number);\n      uploadScript(funscriptPath: string, apiKey?: string): Promise<void>;\n      sync(): Promise<number>;\n      setServerTimeOffset(offset: number): void;\n      play(position: number): Promise<void>;\n      pause(): Promise<void>;\n      ensurePlaying(position: number): Promise<void>;\n      setLooping(looping: boolean): Promise<void>;\n    };\n\n    function useInteractive(): {\n      interactive: InteractiveAPI;\n      state: ConnectionState;\n      serverOffset: number;\n      initialised: boolean;\n      currentScript?: string;\n      error?: string;\n      initialise: () => Promise<void>;\n      uploadScript: (funscriptPath: string) => Promise<void>;\n      sync: () => Promise<void>;\n    };\n\n    function useLightbox(): {\n      state: any;\n      chapters: any;\n    };\n\n    function useGalleryLightbox(): {\n      id: string;\n      chapters: any;\n    };\n  }\n  namespace patch {\n    function before(target: PatchableComponentNames, fn: Function): void;\n    function instead(target: PatchableComponentNames, fn: Function): void;\n    function after(target: PatchableComponentNames, fn: Function): void;\n  }\n  namespace register {\n    function route(path: string, component: React.FC<any>): void;\n  }\n}\n\ndeclare module \"mousetrap-pause\" {\n  import { MousetrapStatic } from \"mousetrap\";\n\n  function MousetrapPause(mousetrap: MousetrapStatic): MousetrapStatic;\n\n  export default MousetrapPause;\n\n  module \"mousetrap\" {\n    interface MousetrapStatic {\n      pause(): void;\n      unpause(): void;\n      pauseCombo(combo: string): void;\n      unpauseCombo(combo: string): void;\n    }\n    interface MousetrapInstance {\n      pause(): void;\n      unpause(): void;\n      pauseCombo(combo: string): void;\n      unpauseCombo(combo: string): void;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/pluginApi.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom\";\nimport * as ReactRouterDOM from \"react-router-dom\";\nimport Mousetrap from \"mousetrap\";\nimport MousetrapPause from \"mousetrap-pause\";\nimport NavUtils from \"./utils/navigation\";\nimport * as GQL from \"src/core/generated-graphql\";\nimport * as StashService from \"src/core/StashService\";\nimport * as Apollo from \"@apollo/client\";\nimport * as Bootstrap from \"react-bootstrap\";\nimport * as Intl from \"react-intl\";\nimport * as FontAwesomeSolid from \"@fortawesome/free-solid-svg-icons\";\nimport * as FontAwesomeRegular from \"@fortawesome/free-regular-svg-icons\";\nimport * as FontAwesomeBrands from \"@fortawesome/free-brands-svg-icons\";\nimport * as ReactFontAwesome from \"@fortawesome/react-fontawesome\";\nimport * as ReactSelect from \"react-select\";\nimport * as ReactSlick from \"@ant-design/react-slick\";\nimport { useSpriteInfo } from \"./hooks/sprite\";\nimport { useToast } from \"./hooks/Toast\";\nimport Event from \"./hooks/event\";\nimport { after, before, components, instead, RegisterComponent } from \"./patch\";\nimport { useSettings } from \"./components/Settings/context\";\nimport { useInteractive } from \"./hooks/Interactive/context\";\nimport InteractiveUtils from \"./hooks/Interactive/utils\";\nimport { useLightbox, useGalleryLightbox } from \"./hooks/Lightbox/hooks\";\n\n// due to code splitting, some components may not have been loaded when a plugin\n// page is loaded. This function will load all components passed to it.\n// The components need to be imported here. Any required imports will be added\n// to the loadableComponents object in the plugin api.\nasync function loadComponents(c: (() => Promise<unknown>)[]) {\n  await Promise.all(c.map((fn) => fn()));\n}\n\n// useLoadComponents is a hook that loads all components passed to it.\n// It returns a boolean indicating whether the components are still loading.\nfunction useLoadComponents(toLoad: (() => Promise<unknown>)[]) {\n  const [loading, setLoading] = React.useState(true);\n  const [componentList] = React.useState(toLoad);\n\n  async function load(c: (() => Promise<unknown>)[]) {\n    await loadComponents(c);\n    setLoading(false);\n  }\n\n  React.useEffect(() => {\n    setLoading(true);\n    load(componentList);\n  }, [componentList]);\n\n  return loading;\n}\n\nfunction registerRoute(path: string, component: React.FC) {\n  before(\"PluginRoutes\", function (props: React.PropsWithChildren<{}>) {\n    return [\n      {\n        children: (\n          <>\n            {props.children}\n            <ReactRouterDOM.Route path={path} component={component} />\n          </>\n        ),\n      },\n    ];\n  });\n}\n\nexport const PluginApi = {\n  React,\n  ReactDOM,\n  GQL,\n  libraries: {\n    ReactRouterDOM,\n    Bootstrap,\n    Apollo,\n    Intl,\n    FontAwesomeRegular,\n    FontAwesomeSolid,\n    FontAwesomeBrands,\n    Mousetrap,\n    MousetrapPause,\n    ReactFontAwesome,\n    ReactSelect,\n    ReactSlick,\n  },\n  register: {\n    // register a route to be added to the main router\n    route: registerRoute,\n    // register a component to be added to the components object\n    component: RegisterComponent,\n  },\n  loadableComponents: {\n    // add any lazy loaded imports here - this is coarse-grained and will load all components\n    // in the import\n    Performers: () => import(\"./components/Performers/Performers\"),\n    FrontPage: () => import(\"./components/FrontPage/FrontPage\"),\n    Scenes: () => import(\"./components/Scenes/Scenes\"),\n    Settings: () => import(\"./components/Settings/Settings\"),\n    Stats: () => import(\"./components/Stats\"),\n    Studios: () => import(\"./components/Studios/Studios\"),\n    Galleries: () => import(\"./components/Galleries/Galleries\"),\n    Groups: () => import(\"./components/Groups/Groups\"),\n    Tags: () => import(\"./components/Tags/Tags\"),\n    Images: () => import(\"./components/Images/Images\"),\n\n    SubmitStashBoxDraft: () => import(\"src/components/Dialogs/SubmitDraft\"),\n    GenerateDialog: () => import(\"./components/Dialogs/GenerateDialog\"),\n\n    ScenePlayer: () => import(\"src/components/ScenePlayer/ScenePlayer\"),\n\n    GalleryViewer: () => import(\"src/components/Galleries/GalleryViewer\"),\n\n    DeleteScenesDialog: () => import(\"./components/Scenes/DeleteScenesDialog\"),\n    SceneList: () => import(\"./components/Scenes/SceneList\"),\n    SceneMarkerList: () => import(\"./components/Scenes/SceneMarkerList\"),\n    Scene: () => import(\"./components/Scenes/SceneDetails/Scene\"),\n    SceneCreate: () => import(\"./components/Scenes/SceneDetails/SceneCreate\"),\n\n    ExternalPlayerButton: () =>\n      import(\"./components/Scenes/SceneDetails/ExternalPlayerButton\"),\n    QueueViewer: () => import(\"./components/Scenes/SceneDetails/QueueViewer\"),\n    SceneMarkersPanel: () =>\n      import(\"./components/Scenes/SceneDetails/SceneMarkersPanel\"),\n    SceneFileInfoPanel: () =>\n      import(\"./components/Scenes/SceneDetails/SceneFileInfoPanel\"),\n    SceneDetailPanel: () =>\n      import(\"./components/Scenes/SceneDetails/SceneDetailPanel\"),\n    SceneHistoryPanel: () =>\n      import(\"./components/Scenes/SceneDetails/SceneHistoryPanel\"),\n    SceneGroupPanel: () =>\n      import(\"./components/Scenes/SceneDetails/SceneGroupPanel\"),\n    SceneGalleriesPanel: () =>\n      import(\"./components/Scenes/SceneDetails/SceneGalleriesPanel\"),\n    SceneVideoFilterPanel: () =>\n      import(\"./components/Scenes/SceneDetails/SceneVideoFilterPanel\"),\n    SceneScrapeDialog: () =>\n      import(\"./components/Scenes/SceneDetails/SceneScrapeDialog\"),\n    SceneQueryModal: () =>\n      import(\"./components/Scenes/SceneDetails/SceneQueryModal\"),\n\n    LightboxComponent: () => import(\"src/hooks/Lightbox/Lightbox\"),\n\n    // intentionally omitting these for now\n    // Setup: () => import(\"./components/Setup/Setup\"),\n    // Migrate: () => import(\"./components/Setup/Migrate\"),\n    // SceneFilenameParser: () => import(\"./components/SceneFilenameParser/SceneFilenameParser\"),\n    // SceneDuplicateChecker: () => import(\"./components/SceneDuplicateChecker/SceneDuplicateChecker\"),\n    // Manual: () => import(\"./Manual\"),\n\n    // individual components here\n    // add components as needed for plugins that provide pages\n    SceneCard: () => import(\"./components/Scenes/SceneCard\"),\n    PerformerSelect: () => import(\"./components/Performers/PerformerSelect\"),\n    TagLink: () => import(\"./components/Shared/TagLink\"),\n    PerformerCard: () => import(\"./components/Performers/PerformerCard\"),\n  },\n  components,\n  utils: {\n    InteractiveUtils,\n    NavUtils,\n    StashService,\n    loadComponents,\n  },\n  hooks: {\n    useLoadComponents,\n    useSpriteInfo,\n    useToast,\n    useSettings,\n    useInteractive,\n    useLightbox,\n    useGalleryLightbox,\n  },\n  patch: {\n    // intercept the arguments of supported functions\n    // the provided function should accept the arguments and return the new arguments\n    before,\n    // replace a function with a new one implementation\n    // the provided function will be called with the arguments passed to the original function\n    // and the original function as the last argument\n    // only one instead function can be registered per component\n    instead,\n    // intercept the result of supported functions\n    // the provided function will be called with the arguments passed to the original function\n    // and the result of the original function\n    after,\n  },\n  Event: Event,\n};\n\nexport default PluginApi;\n\ninterface IWindow {\n  PluginApi: typeof PluginApi;\n}\n\nconst localWindow = window as unknown as IWindow;\n\n// export the plugin api to the window object\nlocalWindow.PluginApi = PluginApi;\n"
  },
  {
    "path": "ui/v2.5/src/plugins.tsx",
    "content": "import React, { useEffect } from \"react\";\nimport { PatchFunction } from \"./patch\";\nimport { usePlugins } from \"./core/StashService\";\nimport { useMemoOnce } from \"./hooks/state\";\nimport { uniq } from \"lodash-es\";\nimport useScript, { useCSS } from \"./hooks/useScript\";\nimport { PluginsQuery } from \"./core/generated-graphql\";\nimport { LoadingIndicator } from \"./components/Shared/LoadingIndicator\";\nimport { FormattedMessage } from \"react-intl\";\nimport { useToast } from \"./hooks/Toast\";\n\ntype PluginList = NonNullable<Required<PluginsQuery[\"plugins\"]>>;\n\n// sort plugins by their dependencies\nfunction sortPlugins(plugins: PluginList) {\n  type Node = { id: string; afters: string[] };\n\n  let nodes: Record<string, Node> = {};\n  let sorted: PluginList = [];\n  let visited: Record<string, boolean> = {};\n\n  plugins.forEach((v) => {\n    let from = v.id;\n\n    if (!nodes[from]) nodes[from] = { id: from, afters: [] };\n\n    v.requires?.forEach((to) => {\n      if (!nodes[to]) nodes[to] = { id: to, afters: [] };\n      if (!nodes[to].afters.includes(from)) nodes[to].afters.push(from);\n    });\n  });\n\n  function visit(idstr: string, ancestors: string[] = []) {\n    let node = nodes[idstr];\n    const { id } = node;\n\n    if (visited[idstr]) return;\n\n    ancestors.push(id);\n    visited[idstr] = true;\n    node.afters.forEach(function (afterID) {\n      if (ancestors.indexOf(afterID) >= 0)\n        throw new Error(\"closed chain : \" + afterID + \" is in \" + id);\n      visit(afterID.toString(), ancestors.slice());\n    });\n\n    const plugin = plugins.find((v) => v.id === id);\n    if (plugin) {\n      sorted.unshift(plugin);\n    }\n  }\n\n  Object.keys(nodes).forEach((n) => {\n    visit(n);\n  });\n\n  return sorted;\n}\n\n// load all plugins and their dependencies\n// returns true when all plugins are loaded, regardess of success or failure\n// if disableCustomizations is true, skip loading plugins entirely\nfunction useLoadPlugins(disableCustomizations?: boolean) {\n  const {\n    data: plugins,\n    loading: pluginsLoading,\n    error: pluginsError,\n  } = usePlugins();\n\n  const sortedPlugins = useMemoOnce(() => {\n    return [\n      sortPlugins(plugins?.plugins ?? []),\n      !pluginsLoading && !pluginsError,\n    ];\n  }, [plugins?.plugins, pluginsLoading, pluginsError]);\n\n  const pluginJavascripts = useMemoOnce(() => {\n    // Skip loading plugin JS if customizations are disabled.\n    // Note: We check inside useMemoOnce rather than early-returning from useLoadPlugins\n    // to comply with React's rules of hooks - hooks must be called unconditionally.\n    if (disableCustomizations) {\n      return [[], true];\n    }\n    return [\n      uniq(\n        sortedPlugins\n          ?.filter((plugin) => plugin.enabled && plugin.paths.javascript)\n          .map((plugin) => plugin.paths.javascript!)\n          .flat() ?? []\n      ),\n      !!sortedPlugins && !pluginsLoading && !pluginsError,\n    ];\n  }, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]);\n\n  const pluginCSS = useMemoOnce(() => {\n    if (disableCustomizations) {\n      return [[], true];\n    }\n    return [\n      uniq(\n        sortedPlugins\n          ?.filter((plugin) => plugin.enabled && plugin.paths.css)\n          .map((plugin) => plugin.paths.css!)\n          .flat() ?? []\n      ),\n      !!sortedPlugins && !pluginsLoading && !pluginsError,\n    ];\n  }, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]);\n\n  const pluginJavascriptLoaded = useScript(\n    pluginJavascripts ?? [],\n    !!pluginJavascripts && !pluginsLoading && !pluginsError\n  );\n  useCSS(pluginCSS ?? [], !pluginsLoading && !pluginsError);\n\n  return {\n    loading: !pluginsLoading && !!pluginJavascripts && pluginJavascriptLoaded,\n    error: pluginsError,\n  };\n}\n\ninterface IPluginsLoaderProps {\n  disableCustomizations?: boolean;\n}\n\nexport const PluginsLoader: React.FC<\n  React.PropsWithChildren<IPluginsLoaderProps>\n> = ({ disableCustomizations, children }) => {\n  const Toast = useToast();\n  const { loading: loaded, error } = useLoadPlugins(disableCustomizations);\n\n  useEffect(() => {\n    if (error) {\n      Toast.error(`Error loading plugins: ${error.message}`);\n    }\n  }, [Toast, error]);\n\n  if (!loaded && !error)\n    return (\n      <LoadingIndicator message={<FormattedMessage id=\"loading.plugins\" />} />\n    );\n\n  return <>{children}</>;\n};\n\nexport const PluginRoutes: React.FC<React.PropsWithChildren<{}>> =\n  PatchFunction(\"PluginRoutes\", (props: React.PropsWithChildren<{}>) => {\n    return <>{props.children}</>;\n  }) as React.FC;\n"
  },
  {
    "path": "ui/v2.5/src/polyfills.ts",
    "content": "import replaceAll from \"string.prototype.replaceall\";\nimport { shouldPolyfill as shouldPolyfillCanonicalLocales } from \"@formatjs/intl-getcanonicallocales/should-polyfill\";\nimport { shouldPolyfill as shouldPolyfillLocale } from \"@formatjs/intl-locale/should-polyfill\";\nimport { shouldPolyfill as shouldPolyfillNumberformat } from \"@formatjs/intl-numberformat/should-polyfill\";\nimport { shouldPolyfill as shouldPolyfillPluralRules } from \"@formatjs/intl-pluralrules/should-polyfill\";\n\n// needed for older safari versions\nimport \"event-target-polyfill\";\n\n// Required for browsers older than August 2020ish. Can be removed at some point.\nreplaceAll.shim();\n\nasync function checkPolyfills() {\n  if (shouldPolyfillCanonicalLocales()) {\n    await import(\"@formatjs/intl-getcanonicallocales/polyfill\");\n  }\n  if (shouldPolyfillLocale()) {\n    await import(\"@formatjs/intl-locale/polyfill\");\n  }\n  if (shouldPolyfillNumberformat()) {\n    await import(\"@formatjs/intl-numberformat/polyfill\");\n    await import(\"@formatjs/intl-numberformat/locale-data/en\");\n    await import(\"@formatjs/intl-numberformat/locale-data/en-GB\");\n  }\n  if (shouldPolyfillPluralRules()) {\n    await import(\"@formatjs/intl-pluralrules/polyfill\");\n    await import(\"@formatjs/intl-pluralrules/locale-data/en\");\n  }\n}\n\nexport const initPolyfills = async () => {\n  await checkPolyfills();\n};\n"
  },
  {
    "path": "ui/v2.5/src/serviceWorker.ts",
    "content": "/* eslint-disable no-console */\n\n// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the app load faster on subsequent visits in production, and gives\n// it offline capabilities. However, it also means that developers (and users)\n// will only see deployed updates on subsequent visits to a page, after all the\n// existing tabs open on the page have been closed, since previously cached\n// resources are updated in the background.\n\n// To learn more about the benefits of this model and instructions on how to\n// opt-in, read http://bit.ly/CRA-PWA\n\nconst isLocalhost = Boolean(\n  window.location.hostname === \"localhost\" ||\n    // [::1] is the IPv6 localhost address.\n    window.location.hostname === \"[::1]\" ||\n    // 127.0.0.1/8 is considered localhost for IPv4.\n    window.location.hostname.match(\n      /^127(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/\n    )\n);\n\ninterface IConfig {\n  onSuccess?: (registration: ServiceWorkerRegistration) => void;\n  onUpdate?: (registration: ServiceWorkerRegistration) => void;\n}\n\nfunction registerValidSW(swUrl: string, config?: IConfig) {\n  navigator.serviceWorker\n    .register(swUrl)\n    .then((registration) => {\n      // eslint-disable-next-line no-param-reassign\n      registration.onupdatefound = () => {\n        const installingWorker = registration.installing;\n        if (installingWorker == null) {\n          return;\n        }\n        installingWorker.onstatechange = () => {\n          if (installingWorker.state === \"installed\") {\n            if (navigator.serviceWorker.controller) {\n              // At this point, the updated precached content has been fetched,\n              // but the previous service worker will still serve the older\n              // content until all client tabs are closed.\n              console.log(\n                \"New content is available and will be used when all \" +\n                  \"tabs for this page are closed. See http://bit.ly/CRA-PWA.\"\n              );\n\n              // Execute callback\n              if (config && config.onUpdate) {\n                config.onUpdate(registration);\n              }\n            } else {\n              // At this point, everything has been precached.\n              // It's the perfect time to display a\n              // \"Content is cached for offline use.\" message.\n              console.log(\"Content is cached for offline use.\");\n\n              // Execute callback\n              if (config && config.onSuccess) {\n                config.onSuccess(registration);\n              }\n            }\n          }\n        };\n      };\n    })\n    .catch((error) => {\n      console.error(\"Error during service worker registration:\", error);\n    });\n}\n\nfunction checkValidServiceWorker(swUrl: string, config?: IConfig) {\n  // Check if the service worker can be found. If it can't reload the page.\n  fetch(swUrl)\n    .then((response) => {\n      // Ensure service worker exists, and that we really are getting a JS file.\n      const contentType = response.headers.get(\"content-type\");\n      if (\n        response.status === 404 ||\n        (contentType != null && contentType.indexOf(\"javascript\") === -1)\n      ) {\n        // No service worker found. Probably a different app. Reload the page.\n        navigator.serviceWorker.ready.then((registration) => {\n          registration.unregister().then(() => {\n            window.location.reload();\n          });\n        });\n      } else {\n        // Service worker found. Proceed as normal.\n        registerValidSW(swUrl, config);\n      }\n    })\n    .catch(() => {\n      console.log(\n        \"No internet connection found. App is running in offline mode.\"\n      );\n    });\n}\n\nexport function register(config?: IConfig) {\n  if (import.meta.env.PROD && \"serviceWorker\" in navigator) {\n    // The URL constructor is available in all browsers that support SW.\n    const publicUrl = new URL(import.meta.env.PUBLIC_URL, window.location.href);\n    if (publicUrl.origin !== window.location.origin) {\n      // Our service worker won't work if PUBLIC_URL is on a different origin\n      // from what our page is served on. This might happen if a CDN is used to\n      // serve assets; see https://github.com/facebook/create-react-app/issues/2374\n      return;\n    }\n\n    window.addEventListener(\"load\", () => {\n      const swUrl = `${import.meta.env.PUBLIC_URL}/service-worker.js`;\n\n      if (isLocalhost) {\n        // This is running on localhost. Let's check if a service worker still exists or not.\n        checkValidServiceWorker(swUrl, config);\n\n        // Add some additional logging to localhost, pointing developers to the\n        // service worker/PWA documentation.\n        navigator.serviceWorker.ready.then(() => {\n          console.log(\n            \"This web app is being served cache-first by a service \" +\n              \"worker. To learn more, visit http://bit.ly/CRA-PWA\"\n          );\n        });\n      } else {\n        // Is not localhost. Just register service worker\n        registerValidSW(swUrl, config);\n      }\n    });\n  }\n}\n\nexport function unregister() {\n  if (\"serviceWorker\" in navigator) {\n    navigator.serviceWorker.ready.then((registration) => {\n      registration.unregister();\n    });\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/sfw-mode.scss",
    "content": "// hide nsfw elements when in sfw-content mode\n// stylelint-disable selector-class-pattern\n.sfw-content-mode {\n  // hide adult-oriented performer fields in sort by select\n  .sort-by-select,\n  .performer-table {\n    [data-value=\"ethnicity\"],\n    [data-value=\"hair_color\"],\n    [data-value=\"eye_color\"],\n    [data-value=\"measurements\"],\n    [data-value=\"weight\"],\n    [data-value=\"weight_kg\"],\n    [data-value=\"penis_length\"],\n    [data-value=\"penis_length_cm\"],\n    [data-value=\"circumcised\"],\n    [data-value=\"fake_tits\"] {\n      display: none;\n    }\n  }\n\n  .performer-table {\n    td,\n    th {\n      &.ethnicity,\n      &.hair_color,\n      &.eye_color,\n      &.height,\n      &.measurements,\n      &.weight_kg,\n      &.penis_length_cm,\n      &.circumcised,\n      &.fake_tits {\n        &-head,\n        &-data {\n          display: none;\n        }\n      }\n    }\n  }\n\n  #performer-edit,\n  &.scrape-dialog {\n    [data-field=\"ethnicity\"],\n    [data-field=\"hair_color\"],\n    [data-field=\"eye_color\"],\n    [data-field=\"measurements\"],\n    [data-field=\"weight\"],\n    [data-field=\"penis_length\"],\n    [data-field=\"circumcised\"],\n    [data-field=\"fake_tits\"],\n    [data-field=\"tattoos\"],\n    [data-field=\"piercings\"] {\n      display: none;\n    }\n  }\n\n  &.edit-filter-dialog {\n    [data-type=\"ethnicity\"],\n    [data-type=\"hair_color\"],\n    [data-type=\"eye_color\"],\n    [data-type=\"measurements\"],\n    [data-type=\"weight\"],\n    [data-type=\"penis_length\"],\n    [data-type=\"circumcised\"],\n    [data-type=\"fake_tits\"],\n    [data-type=\"tattoos\"],\n    [data-type=\"piercings\"] {\n      display: none;\n    }\n  }\n\n  #performer-page {\n    .detail-item.ethnicity,\n    .detail-item.hair_color,\n    .detail-item.eye_color,\n    .detail-item.measurements,\n    .detail-item.weight,\n    .detail-item.penis_length,\n    .detail-item.circumcised,\n    .detail-item.fake_tits,\n    .detail-item.tattoos,\n    .detail-item.piercings {\n      display: none;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/styles/_range.scss",
    "content": "input[type=\"range\"] {\n  -webkit-appearance: none;\n  background-color: transparent;\n  border-color: transparent;\n  height: 22px;\n  margin: 10px 0;\n\n  &:focus {\n    background-color: transparent;\n    border: inherit;\n    border-color: transparent;\n    box-shadow: none;\n    outline: none;\n  }\n\n  &::-webkit-slider-runnable-track {\n    animate: 0.2s;\n    background: $primary;\n    border: 0 solid #000101;\n    border-radius: 25px;\n    box-shadow: 0 0 0 #000;\n    cursor: pointer;\n    height: 6px;\n    width: 100%;\n  }\n\n  &::-webkit-slider-thumb {\n    -webkit-appearance: none;\n    background: #394b59;\n    border: 0 solid #000;\n    border-radius: 10px;\n    box-shadow: 0 0 0 #000;\n    cursor: pointer;\n    height: 15px;\n    margin-top: -4px;\n    width: 15px;\n  }\n\n  &:focus::-webkit-slider-runnable-track {\n    background: #137cbd;\n  }\n\n  &::-moz-range-track {\n    animate: 0.2s;\n    background: $primary;\n    border: 0 solid #000101;\n    border-radius: 25px;\n    box-shadow: 0 0 0 #000;\n    cursor: pointer;\n    height: 6px;\n    width: 100%;\n  }\n\n  &::-moz-range-thumb {\n    background: #394b59;\n    border: 0 solid #000;\n    border-radius: 10px;\n    box-shadow: 0 0 0 #000;\n    cursor: pointer;\n    height: 15px;\n    width: 15px;\n  }\n\n  &::-ms-track {\n    animate: 0.2s;\n    background: transparent;\n    border-color: transparent;\n    color: transparent;\n    cursor: pointer;\n    height: 6px;\n    width: 100%;\n  }\n\n  &::-ms-fill-lower {\n    background: $primary;\n    border: 0 solid #000101;\n    border-radius: 50px;\n    box-shadow: 0 0 0 #000;\n  }\n\n  &::-ms-fill-upper {\n    background: $primary;\n    border: 0 solid #000101;\n    border-radius: 50px;\n    box-shadow: 0 0 0 #000;\n  }\n\n  &::-ms-thumb {\n    background: #394b59;\n    border: 0 solid #000;\n    border-radius: 10px;\n    box-shadow: 0 0 0 #000;\n    cursor: pointer;\n    height: 16px;\n    margin-top: 2px;\n    width: 16px;\n  }\n\n  &:focus::-ms-fill-lower {\n    background: #137cbd;\n  }\n\n  &:focus::-ms-fill-upper {\n    background: #137cbd;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/styles/_scrollbars.scss",
    "content": "/* scrollbars from semantic-ui */\n\n/* Site */\n\n::selection {\n  background-color: #cce2ff;\n  color: $dark-text;\n}\n\n/* Form */\n\ntextarea::selection,\ninput::selection {\n  background-color: rgba(100, 100, 100, 0.4);\n  color: $dark-text;\n}\n\n/* Force Simple Scrollbars */\n\nbody ::-webkit-scrollbar {\n  -webkit-appearance: none;\n  height: 10px;\n  width: 10px;\n}\n\nbody ::-webkit-scrollbar-track {\n  background: rgba(0, 0, 0, 0.1);\n  border-radius: 0;\n}\n\nbody ::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.25);\n  border-radius: 5px;\n  cursor: pointer;\n  transition: color 0.2s ease;\n}\n\nbody ::-webkit-scrollbar-thumb:window-inactive {\n  background: rgba(0, 0, 0, 0.15);\n}\n\nbody ::-webkit-scrollbar-thumb:hover {\n  background: rgba(128, 135, 139, 0.8);\n}\n"
  },
  {
    "path": "ui/v2.5/src/styles/_theme.scss",
    "content": "/* Blueprint dark theme */\n\n$secondary: #394b59;\n$muted-gray: #bfccd6;\n$success: #0f9960;\n$warning: #d9822b;\n$danger: #db3737;\n\n$theme-colors: (\n  primary: #137cbd,\n  secondary: $secondary,\n  success: $success,\n  warning: $warning,\n  danger: $danger,\n  dark: #394b59,\n);\n\n$body-bg: #202b33;\n$text-muted: $muted-gray;\n$link-color: #48aff0;\n$link-hover-color: $link-color;\n$text-color: #f5f8fa;\n$pre-color: $text-color;\n$navbar-dark-color: rgb(245, 248, 250);\n$popover-bg: $secondary;\n$dark-text: #182026;\n$textfield-bg: rgba(16, 22, 26, 0.3);\n$card-bg: #30404d;\n$card-cap-bg: rgba(#000, 0.03);\n\n@import \"node_modules/bootstrap/scss/bootstrap\";\n\n$red1: #a82a2a;\n$orange1: #a66321;\n$sepia1: #63411e;\n$dark-gray2: #202b33;\n$dark-gray5: #394b59;\n\n.btn.active:not(.disabled),\n.btn.active.minimal:not(.disabled) {\n  background-color: rgba(138, 155, 168, 0.3);\n  color: $text-color;\n}\n\na.minimal,\nbutton.minimal {\n  background: none;\n  border: none;\n  color: $text-color;\n  transition: none;\n\n  &:disabled {\n    background: none;\n    opacity: 0.5;\n  }\n\n  &:hover:not(:disabled) {\n    background: rgba(138, 155, 168, 0.15);\n    color: $text-color;\n  }\n\n  &:active:not(:disabled) {\n    background: rgba(138, 155, 168, 0.3);\n    color: $text-color;\n  }\n}\n\nhr {\n  margin: 5px 0;\n}\n\n:not(.show-carat) > .dropdown-toggle::after {\n  content: none;\n}\n\n.nav-link {\n  color: $text-color;\n}\n\n.nav-tabs {\n  border: none;\n  margin: auto;\n  margin-bottom: 1.5rem;\n\n  .nav-link {\n    border: none;\n    color: $text-color;\n    padding: 8px;\n\n    &.active {\n      border-bottom: 2px solid;\n      color: $link-color;\n\n      &:hover {\n        border-bottom-color: $link-color;\n        cursor: default;\n      }\n    }\n\n    &:hover {\n      border-bottom: 2px solid white;\n    }\n  }\n}\n\n.tab-content {\n  padding-bottom: 2rem;\n}\n\n.table-striped tr:nth-child(odd) td {\n  background: rgba(92, 112, 128, 0.15);\n}\n\n.table {\n  border: none;\n  color: $text-color;\n\n  thead {\n    th {\n      border-bottom: 1px solid #414c53;\n      border-right: none;\n      border-top: none;\n    }\n  }\n\n  td {\n    border: none;\n    border-color: #414c53;\n    padding: 0.25rem 0.75rem;\n  }\n}\n\n.popover {\n  max-width: inherit;\n}\n\n.card {\n  border: none;\n  margin: 5px;\n  overflow: hidden;\n}\n\n.form-control {\n  &-plaintext {\n    color: $text-color;\n    margin: 0;\n    padding: 0;\n\n    option {\n      color: black;\n    }\n\n    &::placeholder {\n      color: transparent;\n    }\n\n    &:hover {\n      cursor: default;\n    }\n  }\n}\n\n.popover {\n  &-body {\n    color: $text-color;\n\n    .btn {\n      color: $text-color;\n    }\n  }\n}\n\n.modal {\n  color: $text-color;\n\n  .close {\n    color: $text-color;\n  }\n\n  &-header,\n  &-body,\n  &-footer {\n    background-color: $card-bg;\n    color: $text-color;\n  }\n\n  &-content {\n    background-color: transparent;\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/apple.ts",
    "content": "import UAParser from \"ua-parser-js\";\n\nexport function isPlatformUniquelyRenderedByApple() {\n  // OS name on iPads show up as iOS or Max OS depending on the browser.\n  const isiOS = UAParser().os.name?.includes(\"iOS\");\n  const isMacOS = UAParser().os.name?.includes(\"Mac OS\");\n  const isSafari = UAParser().browser.name?.includes(\"Safari\");\n  return isiOS || (isMacOS && isSafari);\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/bulkUpdate.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\nimport isEqual from \"lodash-es/isEqual\";\nimport { IHasID } from \"./data\";\n\ninterface IHasRating {\n  rating100?: GQL.Maybe<number> | undefined;\n}\n\nexport function getAggregateRating(state: IHasRating[]) {\n  let ret: number | undefined;\n  let first = true;\n\n  state.forEach((o) => {\n    if (first) {\n      ret = o.rating100 ?? undefined;\n      first = false;\n    } else if (ret !== o.rating100) {\n      ret = undefined;\n    }\n  });\n\n  return ret;\n}\n\ninterface IHasStudio {\n  studio?: GQL.Maybe<IHasID> | undefined;\n}\n\nexport function getAggregateStudioId(state: IHasStudio[]) {\n  let ret: string | undefined;\n  let first = true;\n\n  state.forEach((o) => {\n    if (first) {\n      ret = o?.studio?.id;\n      first = false;\n    } else {\n      const studio = o?.studio?.id;\n      if (ret !== studio) {\n        ret = undefined;\n      }\n    }\n  });\n\n  return ret;\n}\n\nexport function getAggregateIds<T>(\n  sortedLists: T[][],\n  isEqualFn: (a: T[], b: T[]) => boolean = isEqual\n) {\n  let ret: T[] = [];\n  let first = true;\n\n  sortedLists.forEach((l) => {\n    if (first) {\n      ret = l;\n      first = false;\n    } else {\n      if (!isEqualFn(ret, l)) {\n        ret = [];\n      }\n    }\n  });\n\n  return ret;\n}\n\nexport function getAggregateGalleryIds(state: { galleries: IHasID[] }[]) {\n  const sortedLists = state.map((o) => o.galleries.map((oo) => oo.id).sort());\n  return getAggregateIds(sortedLists);\n}\n\nexport function getAggregatePerformerIds(state: { performers: IHasID[] }[]) {\n  const sortedLists = state.map((o) => o.performers.map((oo) => oo.id).sort());\n  return getAggregateIds(sortedLists);\n}\n\nexport function getAggregateTagIds(state: { tags: IHasID[] }[]) {\n  const sortedLists = state.map((o) => o.tags.map((oo) => oo.id).sort());\n  return getAggregateIds(sortedLists);\n}\n\nexport function getAggregateSceneIds(state: { scenes: IHasID[] }[]) {\n  const sortedLists = state.map((o) => o.scenes.map((oo) => oo.id).sort());\n  return getAggregateIds(sortedLists);\n}\n\ninterface IGroup {\n  group: IHasID;\n}\n\nexport function getAggregateGroupIds(state: { groups: IGroup[] }[]) {\n  const sortedLists = state.map((o) =>\n    o.groups.map((oo) => oo.group.id).sort()\n  );\n  return getAggregateIds(sortedLists);\n}\n\nexport function makeBulkUpdateIds(\n  ids: string[],\n  mode: GQL.BulkUpdateIdMode\n): GQL.BulkUpdateIds {\n  return {\n    mode,\n    ids,\n  };\n}\n\nexport function getAggregateInputValue<V>(\n  inputValue: V | null | undefined,\n  aggregateValue: V | null | undefined\n) {\n  if (inputValue === undefined) {\n    // and all objects have the same value, then we are unsetting the value.\n    if (aggregateValue !== undefined) {\n      // null to unset rating\n      return null;\n    }\n    // otherwise not setting the rating\n    return undefined;\n  } else {\n    // if value is set, then we are setting the value for all\n    return inputValue;\n  }\n}\n\n// TODO - remove - this is incorrect\nexport function getAggregateInputIDs(\n  mode: GQL.BulkUpdateIdMode,\n  inputIds: string[] | undefined,\n  aggregateIds: string[]\n) {\n  if (\n    mode === GQL.BulkUpdateIdMode.Set &&\n    (!inputIds || inputIds.length === 0)\n  ) {\n    // and all scenes have the same ids,\n    if (aggregateIds.length > 0) {\n      // then unset the performerIds, otherwise ignore\n      return makeBulkUpdateIds(inputIds || [], mode);\n    }\n  } else {\n    // if performerIds non-empty, then we are setting them\n    return makeBulkUpdateIds(inputIds || [], mode);\n  }\n\n  return undefined;\n}\n\nexport function getAggregateState<T, U>(\n  currentValue: T,\n  newValue: U,\n  first: boolean\n) {\n  if (!first && !isEqual(currentValue, newValue)) {\n    return undefined;\n  }\n\n  return newValue;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction setProperty<T, K extends keyof T>(obj: T, key: K, value: any) {\n  obj[key] = value;\n}\n\nfunction getProperty<T, K extends keyof T>(obj: T, key: K) {\n  return obj[key];\n}\n\nexport function getAggregateStateObject<O, I>(\n  output: O,\n  input: I,\n  fields: string[],\n  first: boolean\n) {\n  fields.forEach((key) => {\n    const outputKey = key as keyof O;\n    const inputKey = key as keyof I;\n\n    const currentValue = getProperty(output, outputKey);\n    const performerValue = getProperty(input, inputKey);\n\n    setProperty(\n      output,\n      outputKey,\n      getAggregateState(currentValue, performerValue, first)\n    );\n  });\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/caption.ts",
    "content": "export const languageMap = new Map<string, string>([\n  [\"de\", \"Deutsche\"],\n  [\"en\", \"English\"],\n  [\"es\", \"Español\"],\n  [\"fr\", \"Français\"],\n  [\"it\", \"Italiano\"],\n  [\"ja\", \"日本\"],\n  [\"ko\", \"한국인\"],\n  [\"nl\", \"Holandés\"],\n  [\"pt\", \"Português\"],\n  [\"ru\", \"Русский\"],\n  [\"00\", \"Unknown\"], // stash reserved language code\n]);\n\nexport const valueToCode = (value?: string | null) => {\n  if (!value) {\n    return undefined;\n  }\n\n  return Array.from(languageMap.keys()).find((v) => {\n    return languageMap.get(v) === value;\n  });\n};\n"
  },
  {
    "path": "ui/v2.5/src/utils/circumcised.ts",
    "content": "import * as GQL from \"../core/generated-graphql\";\n\nexport const stringCircumMap = new Map<string, GQL.CircumcisedEnum>([\n  [\"Uncut\", GQL.CircumcisedEnum.Uncut],\n  [\"Cut\", GQL.CircumcisedEnum.Cut],\n]);\n\nexport const circumcisedToString = (\n  value?: GQL.CircumcisedEnum | String | null\n) => {\n  if (!value) {\n    return undefined;\n  }\n\n  const foundEntry = Array.from(stringCircumMap.entries()).find((e) => {\n    return e[1] === value;\n  });\n\n  if (foundEntry) {\n    return foundEntry[0];\n  }\n};\n\nexport const stringToCircumcised = (\n  value?: string | null,\n  caseInsensitive?: boolean\n): GQL.CircumcisedEnum | undefined => {\n  if (!value) {\n    return undefined;\n  }\n\n  const existing = Object.entries(GQL.CircumcisedEnum).find(\n    (e) => e[1] === value\n  );\n  if (existing) return existing[1];\n\n  const ret = stringCircumMap.get(value);\n  if (ret || !caseInsensitive) {\n    return ret;\n  }\n  const asUpper = value.toUpperCase();\n  const foundEntry = Array.from(stringCircumMap.entries()).find((e) => {\n    return e[0].toUpperCase() === asUpper;\n  });\n\n  if (foundEntry) {\n    return foundEntry[1];\n  }\n};\n\nexport const circumcisedStrings = Array.from(stringCircumMap.keys());\n"
  },
  {
    "path": "ui/v2.5/src/utils/country.ts",
    "content": "import Countries from \"i18n-iso-countries\";\nimport { getLocaleCode } from \"src/locales\";\n\nexport const getCountryByISO = (\n  iso: string | null | undefined,\n  locale: string = \"en\"\n): string | undefined => {\n  if (!iso) return;\n\n  const ret = Countries.getName(iso, getLocaleCode(locale));\n  if (ret) {\n    return ret;\n  }\n\n  // fallback to english if locale is not en\n  if (locale !== \"en\") {\n    return Countries.getName(iso, \"en\");\n  }\n};\n\nexport const getCountries = (locale: string = \"en\") => {\n  let countries = Countries.getNames(getLocaleCode(locale));\n\n  if (!countries.length) {\n    countries = Countries.getNames(\"en\");\n  }\n\n  return Object.entries(countries).map(([code, name]) => ({\n    label: name,\n    value: code,\n  }));\n};\n"
  },
  {
    "path": "ui/v2.5/src/utils/data.ts",
    "content": "export const filterData = <T>(data?: (T | null | undefined)[] | null) =>\n  data ? (data.filter((item) => item) as T[]) : [];\n\nexport interface IHasID {\n  id: string;\n}\n\nexport interface ITypename {\n  __typename?: string;\n}\n\nconst hasTypename = (value: unknown): value is ITypename =>\n  !!(value as ITypename)?.__typename;\n\nconst processNoneObjValue = (value: unknown): unknown =>\n  Array.isArray(value)\n    ? value.map((v) =>\n        hasTypename(v) ? withoutTypename(v) : processNoneObjValue(v)\n      )\n    : value;\n\nexport function withoutTypename<T extends ITypename>(\n  o: T\n): Omit<T, \"__typename\"> {\n  const { __typename, ...data } = o;\n\n  return Object.entries(data).reduce(\n    (ret, [key, value]) => ({\n      ...ret,\n      [key]: hasTypename(value)\n        ? withoutTypename(value)\n        : processNoneObjValue(value),\n    }),\n    {} as Omit<T, \"__typename\">\n  );\n}\n\n// excludeFields removes fields from data that are in the excluded object\nexport function excludeFields(\n  data: { [index: string]: unknown },\n  excluded: Record<string, boolean>\n) {\n  Object.keys(data).forEach((k) => {\n    if (excluded[k] || !data[k]) {\n      data[k] = undefined;\n    }\n  });\n}\n\nexport interface IHasStoredID {\n  stored_id?: string | null;\n}\n\nexport function sortStoredIdObjects<T extends IHasStoredID>(\n  scrapedObjects?: T[]\n): T[] | undefined {\n  if (!scrapedObjects) {\n    return undefined;\n  }\n  const ret = scrapedObjects.filter((p) => !!p.stored_id);\n\n  if (ret.length === 0) {\n    return undefined;\n  }\n\n  // sort by id numerically\n  ret.sort((a, b) => {\n    return parseInt(a.stored_id!, 10) - parseInt(b.stored_id!, 10);\n  });\n\n  return ret;\n}\n\nexport function uniqIDStoredIDs<T extends IHasStoredID>(objs: T[]) {\n  return objs.filter((o, i) => {\n    return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i;\n  });\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/dlnaVideoSort.ts",
    "content": "export enum VideoSortOrder {\n  Created_At = \"created_at\",\n  Date = \"date\",\n  Random = \"random\",\n  Title = \"title\",\n  Updated_At = \"updated_at\",\n}\n\nexport const defaultVideoSort = VideoSortOrder.Title;\n\nexport const videoSortOrderIntlMap = new Map<VideoSortOrder, string>([\n  [VideoSortOrder.Created_At, \"created_at\"],\n  [VideoSortOrder.Date, \"date\"],\n  [VideoSortOrder.Random, \"random\"],\n  [VideoSortOrder.Title, \"title\"],\n  [VideoSortOrder.Updated_At, \"updated_at\"],\n]);\n"
  },
  {
    "path": "ui/v2.5/src/utils/download.ts",
    "content": "const downloadFile = (url: string) => {\n  const a = document.createElement(\"a\");\n  a.href = url;\n  a.click();\n};\n\nexport default downloadFile;\n"
  },
  {
    "path": "ui/v2.5/src/utils/errors.ts",
    "content": "import { ApolloError } from \"@apollo/client\";\n\nexport const apolloError = (error: unknown) =>\n  error instanceof ApolloError ? error.message : \"\";\n\nexport function errorToString(error: unknown) {\n  let message;\n  if (error instanceof Error) {\n    message = error.message;\n  }\n  if (!message) {\n    message = String(error);\n  }\n  if (!message) {\n    message = \"Unknown error\";\n  }\n\n  return message;\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/field.tsx",
    "content": "import React from \"react\";\nimport { FormattedMessage } from \"react-intl\";\nimport { Link } from \"react-router-dom\";\nimport { ExternalLink } from \"src/components/Shared/ExternalLink\";\nimport { TruncatedText } from \"src/components/Shared/TruncatedText\";\n\ninterface ITextField {\n  id?: string;\n  name?: string;\n  abbr?: string | null;\n  value?: string | null;\n  truncate?: boolean | null;\n}\n\nexport const TextField: React.FC<ITextField> = ({\n  id,\n  name,\n  value,\n  abbr,\n  truncate,\n  children,\n}) => {\n  if (!value && !children) {\n    return null;\n  }\n\n  const message = (\n    <>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>\n  );\n\n  return (\n    <>\n      <dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>\n      <dd>\n        {value ? truncate ? <TruncatedText text={value} /> : value : children}\n      </dd>\n    </>\n  );\n};\n\ninterface IURLField {\n  id?: string;\n  name?: string;\n  abbr?: string | null;\n  value?: string | null;\n  url?: string | null;\n  truncate?: boolean | null;\n  target?: string;\n  // an internal link (uses <Link to={url}>)\n  internal?: boolean;\n}\n\nexport const URLField: React.FC<IURLField> = ({\n  id,\n  name,\n  value,\n  url,\n  abbr,\n  truncate,\n  target = \"_blank\",\n  internal,\n}) => {\n  if (!value) {\n    return null;\n  }\n\n  const message = (\n    <>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>\n  );\n\n  function maybeRenderUrl() {\n    if (!url) return;\n\n    const children = truncate ? <TruncatedText text={value} /> : value;\n\n    if (internal) {\n      return (\n        <Link to={url} target={target}>\n          {children}\n        </Link>\n      );\n    } else {\n      return (\n        <ExternalLink href={url} target={target}>\n          {children}\n        </ExternalLink>\n      );\n    }\n  }\n\n  return (\n    <>\n      <dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>\n      <dd>{maybeRenderUrl()}</dd>\n    </>\n  );\n};\n\ninterface IURLsField {\n  id?: string;\n  name?: string;\n  abbr?: string | null;\n  urls?: string[] | null;\n  truncate?: boolean | null;\n  target?: string;\n  // an internal link (uses <Link to={url}>)\n  internal?: boolean;\n}\n\nexport const URLsField: React.FC<IURLsField> = ({\n  id,\n  name,\n  urls,\n  abbr,\n  truncate,\n  target = \"_blank\",\n  internal,\n}) => {\n  if (!urls || !urls.length) {\n    return null;\n  }\n\n  const message = (\n    <>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>\n  );\n\n  const renderUrls = () => {\n    return urls.map((url, i) => {\n      if (!url) return;\n\n      const children = truncate ? <TruncatedText text={url} /> : url;\n\n      if (internal) {\n        return (\n          <Link key={i} to={url} target={target}>\n            {children}\n          </Link>\n        );\n      } else {\n        return (\n          <ExternalLink key={i} href={url} target={target}>\n            {children}\n          </ExternalLink>\n        );\n      }\n    });\n  };\n\n  return (\n    <>\n      <dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>\n      <dd>\n        <dl>{renderUrls()}</dl>\n      </dd>\n    </>\n  );\n};\n"
  },
  {
    "path": "ui/v2.5/src/utils/flattenMessages.ts",
    "content": "type NestedMessage = { [key: string]: NestedMessage | string };\nconst flattenMessages = (nestedMessages: NestedMessage | null, prefix = \"\") => {\n  if (nestedMessages === null) {\n    return {};\n  }\n  return Object.keys(nestedMessages).reduce((messages, key) => {\n    const value = nestedMessages[key];\n    const prefixedKey = prefix ? `${prefix}.${key}` : key;\n\n    if (typeof value === \"string\") {\n      Object.assign(messages, { [prefixedKey]: value });\n    } else {\n      Object.assign(messages, flattenMessages(value, prefixedKey));\n    }\n\n    return messages;\n  }, {} as Record<string, string>);\n};\n\nexport default flattenMessages;\n"
  },
  {
    "path": "ui/v2.5/src/utils/focus.ts",
    "content": "import { useRef, useEffect, useCallback } from \"react\";\n\nconst useFocus = () => {\n  const htmlElRef = useRef<HTMLInputElement | null>(null);\n  const setFocus = useCallback((selectAll?: boolean) => {\n    const currentEl = htmlElRef.current;\n    if (currentEl) {\n      if (selectAll) {\n        currentEl.select();\n      } else {\n        currentEl.focus();\n      }\n    }\n  }, []);\n\n  // eslint-disable-next-line no-undef\n  return [htmlElRef, setFocus] as const;\n};\n\n// focuses on the element only once on mount\nexport const useFocusOnce = (active?: boolean, override?: boolean) => {\n  const [htmlElRef, setFocus] = useFocus();\n  const focused = useRef(false);\n\n  useEffect(() => {\n    if ((!focused.current || override) && active) {\n      setFocus();\n      focused.current = true;\n    }\n  }, [setFocus, active, override]);\n\n  return [htmlElRef, setFocus] as const;\n};\n\nexport default useFocus;\n"
  },
  {
    "path": "ui/v2.5/src/utils/form.tsx",
    "content": "import { faTrashAlt } from \"@fortawesome/free-solid-svg-icons\";\nimport { FormikValues, useFormik } from \"formik\";\nimport React, { InputHTMLAttributes, useEffect, useRef } from \"react\";\nimport {\n  Button,\n  Col,\n  ColProps,\n  Form,\n  FormControlProps,\n  FormLabelProps,\n  Row,\n} from \"react-bootstrap\";\nimport { IntlShape } from \"react-intl\";\nimport { DateInput } from \"src/components/Shared/DateInput\";\nimport { DurationInput } from \"src/components/Shared/DurationInput\";\nimport { Icon } from \"src/components/Shared/Icon\";\nimport { RatingSystem } from \"src/components/Shared/Rating/RatingSystem\";\nimport { LinkType, StashIDPill } from \"src/components/Shared/StashID\";\nimport { StringListInput } from \"src/components/Shared/StringListInput\";\nimport { URLListInput } from \"src/components/Shared/URLField\";\nimport * as GQL from \"src/core/generated-graphql\";\n\nfunction getLabelProps(labelProps?: FormLabelProps) {\n  let ret = labelProps;\n  if (!ret) {\n    ret = {\n      column: true,\n      xs: 3,\n    };\n  }\n\n  return ret;\n}\n\nexport function renderLabel(options: {\n  title: React.ReactNode;\n  labelProps?: FormLabelProps;\n}) {\n  return (\n    <Form.Label column {...getLabelProps(options.labelProps)}>\n      {options.title}\n    </Form.Label>\n  );\n}\n\n// useStopWheelScroll is a hook to provide a workaround for a bug in React/Chrome.\n// If a number field is focused and the mouse pointer is over the field, then scrolling\n// the mouse wheel will change the field value _and_ scroll the window.\n// This hook prevents the propagation that causes the window to scroll.\nexport function useStopWheelScroll(ref: React.RefObject<HTMLElement>) {\n  // removed the dependency array because the underlying ref value may change\n  useEffect(() => {\n    const { current } = ref;\n\n    function stopWheelScroll(e: WheelEvent) {\n      if (current) {\n        e.stopPropagation();\n      }\n    }\n\n    if (current) {\n      current.addEventListener(\"wheel\", stopWheelScroll);\n    }\n\n    return () => {\n      if (current) {\n        current.removeEventListener(\"wheel\", stopWheelScroll);\n      }\n    };\n  });\n}\n\n// NumberField is a wrapper around Form.Control that prevents wheel events from scrolling the window.\nexport const NumberField: React.FC<\n  InputHTMLAttributes<HTMLInputElement> & FormControlProps\n> = (props) => {\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useStopWheelScroll(inputRef);\n\n  return <Form.Control {...props} type=\"number\" ref={inputRef} />;\n};\n\ntype Formik<V extends FormikValues> = ReturnType<typeof useFormik<V>>;\n\ninterface IProps {\n  labelProps?: FormLabelProps;\n  fieldProps?: ColProps;\n}\n\nexport function formikUtils<V extends FormikValues>(\n  intl: IntlShape,\n  formik: Formik<V>,\n  {\n    labelProps = {\n      column: true,\n      sm: 3,\n      xl: 2,\n    },\n    fieldProps = {\n      sm: 9,\n      xl: 7,\n    },\n  }: IProps = {}\n) {\n  type Field = keyof V & string;\n  type ErrorMessage = string | undefined;\n\n  function renderFormControl(field: Field, type: string, placeholder: string) {\n    const formikProps = formik.getFieldProps({ name: field, type: type });\n    const error = formik.errors[field] as ErrorMessage;\n\n    let { value } = formikProps;\n    if (value === null) {\n      value = \"\";\n    }\n\n    let control: React.ReactNode;\n    if (type === \"checkbox\") {\n      control = (\n        <Form.Check\n          placeholder={placeholder}\n          {...formikProps}\n          value={value}\n          isInvalid={!!error}\n        />\n      );\n    } else if (type === \"textarea\") {\n      control = (\n        <Form.Control\n          as=\"textarea\"\n          className=\"text-input\"\n          placeholder={placeholder}\n          {...formikProps}\n          value={value}\n          isInvalid={!!error}\n        />\n      );\n    } else if (type === \"number\") {\n      control = (\n        <NumberField\n          type={type}\n          className=\"text-input\"\n          placeholder={placeholder}\n          {...formikProps}\n          value={value}\n          isInvalid={!!error}\n        />\n      );\n    } else {\n      control = (\n        <Form.Control\n          type={type}\n          className=\"text-input\"\n          placeholder={placeholder}\n          {...formikProps}\n          value={value}\n          isInvalid={!!error}\n        />\n      );\n    }\n\n    return (\n      <>\n        {control}\n        <Form.Control.Feedback type=\"invalid\">{error}</Form.Control.Feedback>\n      </>\n    );\n  }\n\n  function renderField(\n    field: Field,\n    title: string,\n    control: React.ReactNode,\n    props?: IProps\n  ) {\n    return (\n      <Form.Group controlId={field} as={Row} data-field={field}>\n        <Form.Label {...(props?.labelProps ?? labelProps)}>{title}</Form.Label>\n        <Col {...(props?.fieldProps ?? fieldProps)}>{control}</Col>\n      </Form.Group>\n    );\n  }\n\n  function renderInputField(\n    field: Field,\n    type: string = \"text\",\n    messageID: string = field,\n    props?: IProps\n  ) {\n    const title = intl.formatMessage({ id: messageID });\n    const control = renderFormControl(field, type, title);\n\n    return renderField(field, title, control, props);\n  }\n\n  function renderSelectField(\n    field: Field,\n    entries: Map<string, string>,\n    messageID: string = field,\n    props?: IProps\n  ) {\n    const formikProps = formik.getFieldProps(field);\n\n    let { value } = formikProps;\n    if (value === null) {\n      value = \"\";\n    }\n\n    const title = intl.formatMessage({ id: messageID });\n    const control = (\n      <Form.Control\n        as=\"select\"\n        className=\"input-control\"\n        {...formikProps}\n        value={value}\n      >\n        <option value=\"\" key=\"\"></option>\n        {Array.from(entries).map(([k, v]) => (\n          <option value={v} key={v}>\n            {k}\n          </option>\n        ))}\n      </Form.Control>\n    );\n\n    return renderField(field, title, control, props);\n  }\n\n  function renderDateField(\n    field: Field,\n    messageID: string = field,\n    props?: IProps\n  ) {\n    const value = formik.values[field] as string;\n    const error = formik.errors[field] as ErrorMessage;\n\n    const title = intl.formatMessage({ id: messageID });\n    const control = (\n      <DateInput\n        value={value}\n        onValueChange={(v) => formik.setFieldValue(field, v)}\n        error={error}\n      />\n    );\n\n    return renderField(field, title, control, props);\n  }\n\n  function renderDurationField(\n    field: Field,\n    messageID: string = field,\n    props?: IProps\n  ) {\n    const value = formik.values[field] as number | null;\n    const error = formik.errors[field] as ErrorMessage;\n\n    const title = intl.formatMessage({ id: messageID });\n    const control = (\n      <DurationInput\n        value={value}\n        setValue={(v) => formik.setFieldValue(field, v)}\n        error={error}\n      />\n    );\n\n    return renderField(field, title, control, props);\n  }\n\n  function renderRatingField(\n    field: Field,\n    messageID: string = field,\n    props?: IProps\n  ) {\n    const value = formik.values[field] as number | null;\n\n    const title = intl.formatMessage({ id: messageID });\n    const control = (\n      <RatingSystem\n        value={value}\n        onSetRating={(v) => formik.setFieldValue(field, v)}\n      />\n    );\n\n    return renderField(field, title, control, props);\n  }\n\n  // flattens a potential list of errors into a [errorMsg, errorIdx] tuple\n  // error messages are joined with newlines, and duplicate messages are skipped\n  function flattenError(\n    error: ErrorMessage[] | ErrorMessage\n  ): [string | undefined, number[] | undefined] {\n    if (Array.isArray(error)) {\n      let errors: string[] = [];\n      const errorIdx = [];\n      for (let i = 0; i < error.length; i++) {\n        const err = error[i];\n        if (err) {\n          if (!errors.includes(err)) {\n            errors.push(err);\n          }\n          errorIdx.push(i);\n        }\n      }\n      return [errors.join(\"\\n\"), errorIdx];\n    } else {\n      return [error, undefined];\n    }\n  }\n\n  interface IStringListProps extends IProps {\n    // defaults to true if not provided\n    orderable?: boolean;\n  }\n\n  function renderStringListField(\n    field: Field,\n    messageID: string = field,\n    props?: IStringListProps\n  ) {\n    const value = formik.values[field] as string[];\n    const error = formik.errors[field] as ErrorMessage[] | ErrorMessage;\n\n    const [errorMsg, errorIdx] = flattenError(error);\n\n    const title = intl.formatMessage({ id: messageID });\n    const control = (\n      <StringListInput\n        value={value}\n        setValue={(v) => formik.setFieldValue(field, v)}\n        errors={errorMsg}\n        errorIdx={errorIdx}\n        orderable={props?.orderable}\n      />\n    );\n\n    return renderField(field, title, control, props);\n  }\n\n  function renderURLListField(\n    field: Field,\n    onScrapeClick?: (url: string) => void,\n    urlScrapable?: (url: string) => boolean,\n    messageID: string = field,\n    props?: IProps\n  ) {\n    const value = formik.values[field] as string[];\n    const error = formik.errors[field] as ErrorMessage[] | ErrorMessage;\n\n    const [errorMsg, errorIdx] = flattenError(error);\n\n    const title = intl.formatMessage({ id: messageID });\n    const control = (\n      <URLListInput\n        value={value}\n        setValue={(v) => formik.setFieldValue(field, v)}\n        errors={errorMsg}\n        errorIdx={errorIdx}\n        onScrapeClick={onScrapeClick}\n        urlScrapable={urlScrapable}\n      />\n    );\n\n    return renderField(field, title, control, props);\n  }\n\n  function renderStashIDsField(\n    field: Field,\n    linkType: LinkType,\n    messageID: string = field,\n    props?: IProps,\n    addButton?: React.ReactNode\n  ) {\n    const values = formik.values[field] as GQL.StashIdInput[];\n\n    const title = intl.formatMessage({ id: messageID });\n\n    const removeStashID = (stashID: GQL.StashIdInput) => {\n      const v = values.filter((s) => s !== stashID);\n      formik.setFieldValue(field, v);\n    };\n\n    const control = (\n      <>\n        {values.length > 0 && (\n          <ul className=\"pl-0 mb-2\">\n            {values.map((stashID) => {\n              return (\n                <Row as=\"li\" key={stashID.stash_id} noGutters>\n                  <Button\n                    variant=\"danger\"\n                    className=\"mr-2 py-0\"\n                    title={intl.formatMessage(\n                      { id: \"actions.delete_entity\" },\n                      { entityType: intl.formatMessage({ id: \"stash_id\" }) }\n                    )}\n                    onClick={() => removeStashID(stashID)}\n                  >\n                    <Icon icon={faTrashAlt} />\n                  </Button>\n                  <StashIDPill stashID={stashID} linkType={linkType} />\n                </Row>\n              );\n            })}\n          </ul>\n        )}\n        {addButton}\n      </>\n    );\n\n    return renderField(field, title, control, props);\n  }\n\n  return {\n    renderFormControl,\n    renderField,\n    renderInputField,\n    renderSelectField,\n    renderDateField,\n    renderDurationField,\n    renderRatingField,\n    renderStringListField,\n    renderURLListField,\n    renderStashIDsField,\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/gender.ts",
    "content": "import * as GQL from \"../core/generated-graphql\";\n\nexport const stringGenderMap = new Map<string, GQL.GenderEnum>([\n  [\"Female\", GQL.GenderEnum.Female],\n  [\"Male\", GQL.GenderEnum.Male],\n  [\"Transgender Male\", GQL.GenderEnum.TransgenderMale],\n  [\"Transgender Female\", GQL.GenderEnum.TransgenderFemale],\n  [\"Intersex\", GQL.GenderEnum.Intersex],\n  [\"Non-Binary\", GQL.GenderEnum.NonBinary],\n]);\n\nexport const genderList = [\n  GQL.GenderEnum.Female,\n  GQL.GenderEnum.Male,\n  GQL.GenderEnum.TransgenderFemale,\n  GQL.GenderEnum.TransgenderMale,\n  GQL.GenderEnum.Intersex,\n  GQL.GenderEnum.NonBinary,\n];\n\nexport const genderToString = (value?: GQL.GenderEnum | string | null) => {\n  if (!value) {\n    return undefined;\n  }\n\n  const foundEntry = Array.from(stringGenderMap.entries()).find((e) => {\n    return e[1] === value;\n  });\n\n  if (foundEntry) {\n    return foundEntry[0];\n  }\n};\n\nexport const stringToGender = (\n  value?: string | null,\n  caseInsensitive?: boolean\n): GQL.GenderEnum | undefined => {\n  if (!value) {\n    return undefined;\n  }\n\n  const existing = Object.entries(GQL.GenderEnum).find((e) => e[1] === value);\n  if (existing) return existing[1];\n\n  const ret = stringGenderMap.get(value);\n  if (ret || !caseInsensitive) {\n    return ret;\n  }\n\n  const asUpper = value.toUpperCase();\n  const foundEntry = Array.from(stringGenderMap.entries()).find((e) => {\n    return e[0].toUpperCase() === asUpper;\n  });\n\n  if (foundEntry) {\n    return foundEntry[1];\n  }\n};\n\nexport const genderStrings = Array.from(stringGenderMap.keys());\n"
  },
  {
    "path": "ui/v2.5/src/utils/hamming.ts",
    "content": "const hexToBinary = (hex: string) =>\n  hex\n    .split(\"\")\n    .map((i) => parseInt(i, 16).toString(2).padStart(4, \"0\"))\n    .join(\"\");\n\nexport const distance = (a: string, b: string | undefined | null): number => {\n  if (!b || a.length !== b.length) return 32;\n\n  const aBinary = hexToBinary(a);\n  const bBinary = hexToBinary(b);\n\n  let counter = 0;\n  for (let i = 0; i < aBinary.length; i++) {\n    if (aBinary[i] !== bBinary[i]) counter++;\n  }\n\n  return counter;\n};\n"
  },
  {
    "path": "ui/v2.5/src/utils/history.ts",
    "content": "import { useHistory } from \"react-router-dom\";\n\ntype History = ReturnType<typeof useHistory>;\n\nexport function goBackOrReplace(history: History, defaultPath: string) {\n  if (history.length > 1) {\n    history.goBack();\n  } else {\n    history.replace(defaultPath);\n  }\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/image.tsx",
    "content": "import React, { useCallback, useEffect } from \"react\";\n\nconst blobToDataURL = (blob: Blob): Promise<string> =>\n  new Promise((resolve, reject) => {\n    const reader = new FileReader();\n    reader.onloadend = () => resolve(reader.result as string);\n    reader.onerror = reject;\n    reader.readAsDataURL(blob);\n  });\n\nconst readImage = (file: File, onLoadEnd: (imageData: string) => void) => {\n  // only proceed if no error encountered\n  blobToDataURL(file)\n    .then(onLoadEnd)\n    .catch(() => {});\n};\n\nconst onImageChange = (\n  event: React.FormEvent<HTMLInputElement>,\n  onLoadEnd: (imageData: string) => void\n) => {\n  const file = event?.currentTarget?.files?.[0];\n  if (file) readImage(file, onLoadEnd);\n};\n\nconst imageToDataURL = async (url: string) => {\n  const response = await fetch(url);\n  const blob = await response.blob();\n  return blobToDataURL(blob);\n};\n\n// uses event.clipboardData which works in all contexts including insecure HTTP\nconst pasteImage = (\n  event: ClipboardEvent,\n  onLoadEnd: (imageData: string) => void\n) => {\n  const files = event?.clipboardData?.files;\n  if (!files?.length) return;\n\n  if (document.activeElement instanceof HTMLInputElement) {\n    // don't interfere with pasting text into inputs\n    return;\n  }\n\n  const file = Array.from(files).find((f) => f.type.startsWith(\"image/\"));\n  if (file) readImage(file, onLoadEnd);\n};\n\n// uses Clipboard API which requires secure context (HTTPS or localhost)\nconst readClipboardImage = async (): Promise<string | null> => {\n  if (!window.isSecureContext) {\n    return null;\n  }\n\n  const items = await navigator.clipboard.read();\n  for (const item of items) {\n    const imageType = item.types.find((t) => t.startsWith(\"image/\"));\n    if (imageType) {\n      const blob = await item.getType(imageType);\n      return blobToDataURL(blob);\n    }\n  }\n  return null;\n};\n\nconst usePasteImage = (\n  onLoadEnd: (imageData: string) => void,\n  isActive: boolean = true\n) => {\n  const encodeImage = useCallback(\n    (data: string) => {\n      onLoadEnd(data);\n    },\n    [onLoadEnd]\n  );\n\n  useEffect(() => {\n    const paste = (event: ClipboardEvent) => pasteImage(event, encodeImage);\n    if (isActive) {\n      document.addEventListener(\"paste\", paste);\n    }\n\n    return () => document.removeEventListener(\"paste\", paste);\n  }, [isActive, encodeImage]);\n\n  return false;\n};\n\nconst ImageUtils = {\n  onImageChange,\n  usePasteImage,\n  imageToDataURL,\n  readClipboardImage,\n};\n\nexport default ImageUtils;\n"
  },
  {
    "path": "ui/v2.5/src/utils/imageWall.ts",
    "content": "export enum ImageWallDirection {\n  Column = \"column\",\n  Row = \"row\",\n}\n\nexport type ImageWallOptions = {\n  margin: number;\n  direction: ImageWallDirection;\n};\n\nexport const defaultImageWallDirection: ImageWallDirection =\n  ImageWallDirection.Row;\nexport const defaultImageWallMargin = 3;\n\nexport const imageWallDirectionIntlMap = new Map<ImageWallDirection, string>([\n  [ImageWallDirection.Column, \"dialogs.imagewall.direction.column\"],\n  [ImageWallDirection.Row, \"dialogs.imagewall.direction.row\"],\n]);\n\nexport const defaultImageWallOptions = {\n  margin: defaultImageWallMargin,\n  direction: defaultImageWallDirection,\n};\n"
  },
  {
    "path": "ui/v2.5/src/utils/index.ts",
    "content": "export * from \"./errors\";\n"
  },
  {
    "path": "ui/v2.5/src/utils/job.ts",
    "content": "import { useEffect, useState } from \"react\";\nimport { getWSClient, useWSState } from \"src/core/StashService\";\nimport {\n  Job,\n  JobStatus,\n  JobStatusUpdateType,\n  useFindJobQuery,\n  useJobsSubscribeSubscription,\n} from \"src/core/generated-graphql\";\n\nexport type JobFragment = Pick<\n  Job,\n  \"id\" | \"status\" | \"subTasks\" | \"description\" | \"progress\" | \"error\"\n>;\n\nexport const useMonitorJob = (\n  jobID: string | undefined | null,\n  onComplete?: (job?: JobFragment) => void\n) => {\n  const { state } = useWSState(getWSClient());\n\n  const jobsSubscribe = useJobsSubscribeSubscription({\n    skip: !jobID,\n  });\n  const {\n    data: jobData,\n    loading,\n    startPolling,\n    stopPolling,\n  } = useFindJobQuery({\n    variables: {\n      input: { id: jobID ?? \"\" },\n    },\n    fetchPolicy: \"network-only\",\n    skip: !jobID,\n  });\n\n  const [job, setJob] = useState<JobFragment | undefined>();\n\n  useEffect(() => {\n    if (!jobID) {\n      return;\n    }\n\n    if (loading) {\n      return;\n    }\n\n    const j = jobData?.findJob;\n    if (j) {\n      setJob(j);\n\n      if (\n        j.status === JobStatus.Finished ||\n        j.status === JobStatus.Failed ||\n        j.status === JobStatus.Cancelled\n      ) {\n        setJob(undefined);\n        onComplete?.(j);\n      }\n    } else {\n      // must've already finished\n      setJob(undefined);\n      onComplete?.();\n    }\n  }, [jobID, jobData, loading, onComplete]);\n\n  // monitor job\n  useEffect(() => {\n    if (!jobID) {\n      return;\n    }\n\n    if (!jobsSubscribe.data) {\n      return;\n    }\n\n    const event = jobsSubscribe.data.jobsSubscribe;\n    if (event.job.id !== jobID) {\n      return;\n    }\n\n    if (event.type !== JobStatusUpdateType.Remove) {\n      setJob(event.job);\n    } else {\n      setJob(undefined);\n      onComplete?.(event.job);\n    }\n  }, [jobsSubscribe, jobID, onComplete]);\n\n  // it's possible that the websocket connection isn't present\n  // in that case, we'll just poll the server\n  useEffect(() => {\n    if (!jobID) {\n      stopPolling();\n      return;\n    }\n\n    if (state === \"connected\") {\n      stopPolling();\n    } else {\n      const defaultPollInterval = 1000;\n      startPolling(defaultPollInterval);\n    }\n  }, [jobID, state, startPolling, stopPolling]);\n\n  return { job };\n};\n"
  },
  {
    "path": "ui/v2.5/src/utils/keyboard.ts",
    "content": "export function keyboardClickHandler(onClick: () => void) {\n  function onKeyDown(e: React.KeyboardEvent<HTMLAnchorElement>) {\n    if (e.key === \"Enter\" || e.key === \" \") {\n      onClick();\n    }\n  }\n\n  return onKeyDown;\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/lazyComponent.ts",
    "content": "import { lazy } from \"react\";\n\ninterface ILazyComponentError {\n  __lazyComponentError?: true;\n}\n\nexport const isLazyComponentError = (e: unknown) => {\n  return !!(e as ILazyComponentError).__lazyComponentError;\n};\n\nexport const lazyComponent = <Props extends object>(\n  factory: () => Promise<{ default: React.FC<Props> }>\n) => {\n  return lazy(async () => {\n    try {\n      return await factory();\n    } catch (e) {\n      // set flag to identify lazy component loading errors\n      (e as ILazyComponentError).__lazyComponentError = true;\n      throw e;\n    }\n  });\n};\n"
  },
  {
    "path": "ui/v2.5/src/utils/navigation.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\nimport { PerformersCriterion } from \"src/models/list-filter/criteria/performers\";\nimport { CountryCriterion } from \"src/models/list-filter/criteria/country\";\nimport {\n  StudiosCriterion,\n  ParentStudiosCriterion,\n} from \"src/models/list-filter/criteria/studios\";\nimport {\n  ChildTagsCriterionOption,\n  ParentTagsCriterionOption,\n  TagsCriterion,\n  TagsCriterionOption,\n} from \"src/models/list-filter/criteria/tags\";\nimport { ListFilterModel } from \"src/models/list-filter/filter\";\nimport {\n  ContainingGroupsCriterionOption,\n  GroupsCriterion,\n  GroupsCriterionOption,\n  SubGroupsCriterionOption,\n} from \"src/models/list-filter/criteria/groups\";\nimport {\n  ModifierCriterion,\n  ModifierCriterionOption,\n  CriterionValue,\n  StringCriterion,\n  createStringCriterionOption,\n  Criterion,\n} from \"src/models/list-filter/criteria/criterion\";\nimport { GalleriesCriterion } from \"src/models/list-filter/criteria/galleries\";\nimport { PhashCriterion } from \"src/models/list-filter/criteria/phash\";\nimport { ILabeledId } from \"src/models/list-filter/types\";\nimport { IntlShape } from \"react-intl\";\nimport { galleryTitle } from \"src/core/galleries\";\nimport { MarkersScenesCriterion } from \"src/models/list-filter/criteria/scenes\";\nimport { objectTitle } from \"src/core/files\";\n\nfunction addExtraCriteria(dest: Criterion[], src?: Criterion[]) {\n  if (src && src.length > 0) {\n    dest.push(...src);\n  }\n}\n\nconst makePerformerScenesUrl = (\n  performer: Partial<GQL.PerformerDataFragment>,\n  extraPerformer?: ILabeledId,\n  extraCriteria?: ModifierCriterion<CriterionValue>[]\n) => {\n  if (!performer.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);\n  const criterion = new PerformersCriterion();\n  criterion.value.items = [\n    { id: performer.id, label: performer.name || `Performer ${performer.id}` },\n  ];\n\n  if (extraPerformer) {\n    criterion.value.items.push(extraPerformer);\n  }\n\n  filter.criteria.push(criterion);\n  addExtraCriteria(filter.criteria, extraCriteria);\n  return `/scenes?${filter.makeQueryParameters()}`;\n};\n\nconst makePerformerImagesUrl = (\n  performer: Partial<GQL.PerformerDataFragment>,\n  extraPerformer?: ILabeledId,\n  extraCriteria?: ModifierCriterion<CriterionValue>[]\n) => {\n  if (!performer.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);\n  const criterion = new PerformersCriterion();\n  criterion.value.items = [\n    { id: performer.id, label: performer.name || `Performer ${performer.id}` },\n  ];\n\n  if (extraPerformer) {\n    criterion.value.items.push(extraPerformer);\n  }\n\n  filter.criteria.push(criterion);\n  addExtraCriteria(filter.criteria, extraCriteria);\n  return `/images?${filter.makeQueryParameters()}`;\n};\n\nexport interface INamedObject {\n  id: string;\n  name?: string;\n  sort_name?: string | null;\n}\n\nconst makePerformerGalleriesUrl = (\n  performer: INamedObject,\n  extraPerformer?: ILabeledId,\n  extraCriteria?: ModifierCriterion<CriterionValue>[]\n) => {\n  if (!performer.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined);\n  const criterion = new PerformersCriterion();\n  criterion.value.items = [\n    { id: performer.id, label: performer.name || `Performer ${performer.id}` },\n  ];\n\n  if (extraPerformer) {\n    criterion.value.items.push(extraPerformer);\n  }\n\n  filter.criteria.push(criterion);\n  addExtraCriteria(filter.criteria, extraCriteria);\n  return `/galleries?${filter.makeQueryParameters()}`;\n};\n\nconst makePerformerGroupsUrl = (\n  performer: Partial<GQL.PerformerDataFragment>,\n  extraPerformer?: ILabeledId,\n  extraCriteria?: ModifierCriterion<CriterionValue>[]\n) => {\n  if (!performer.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined);\n  const criterion = new PerformersCriterion();\n  criterion.value.items = [\n    { id: performer.id, label: performer.name || `Performer ${performer.id}` },\n  ];\n\n  if (extraPerformer) {\n    criterion.value.items.push(extraPerformer);\n  }\n\n  filter.criteria.push(criterion);\n  addExtraCriteria(filter.criteria, extraCriteria);\n  return `/groups?${filter.makeQueryParameters()}`;\n};\n\nconst makePerformerSceneMarkersUrl = (\n  performer: Partial<GQL.PerformerDataFragment>\n) => {\n  if (!performer.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers, undefined);\n  const criterion = new PerformersCriterion();\n  criterion.value.items = [\n    { id: performer.id, label: performer.name || `Performer ${performer.id}` },\n  ];\n\n  filter.criteria.push(criterion);\n  return `/scenes/markers?${filter.makeQueryParameters()}`;\n};\n\nconst makePerformersCountryUrl = (\n  performer: Partial<GQL.PerformerDataFragment>\n) => {\n  if (!performer.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Performers, undefined);\n  const criterion = new CountryCriterion();\n  criterion.value = `${performer.country}`;\n  filter.criteria.push(criterion);\n  return `/performers?${filter.makeQueryParameters()}`;\n};\n\nconst makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {\n  if (!studio.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);\n  const criterion = new StudiosCriterion();\n  criterion.value = {\n    items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/scenes?${filter.makeQueryParameters()}`;\n};\n\nconst makeStudioImagesUrl = (studio: Partial<GQL.StudioDataFragment>) => {\n  if (!studio.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);\n  const criterion = new StudiosCriterion();\n  criterion.value = {\n    items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/images?${filter.makeQueryParameters()}`;\n};\n\nconst makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {\n  if (!studio.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined);\n  const criterion = new StudiosCriterion();\n  criterion.value = {\n    items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/galleries?${filter.makeQueryParameters()}`;\n};\n\nconst makeStudioGroupsUrl = (studio: Partial<GQL.StudioDataFragment>) => {\n  if (!studio.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined);\n  const criterion = new StudiosCriterion();\n  criterion.value = {\n    items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/groups?${filter.makeQueryParameters()}`;\n};\n\nconst makeStudioPerformersUrl = (studio: Partial<GQL.StudioDataFragment>) => {\n  if (!studio.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Performers, undefined);\n  const criterion = new StudiosCriterion();\n  criterion.value = {\n    items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/performers?${filter.makeQueryParameters()}`;\n};\n\nconst makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => {\n  if (!studio.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Studios, undefined);\n  const criterion = new ParentStudiosCriterion();\n  criterion.value = [\n    { id: studio.id, label: studio.name || `Studio ${studio.id}` },\n  ];\n  filter.criteria.push(criterion);\n  return `/studios?${filter.makeQueryParameters()}`;\n};\n\nconst makeGroupScenesUrl = (group: Partial<GQL.GroupDataFragment>) => {\n  if (!group.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);\n  const criterion = new GroupsCriterion(GroupsCriterionOption);\n  criterion.value = {\n    items: [{ id: group.id, label: group.name || `Group ${group.id}` }],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/scenes?${filter.makeQueryParameters()}`;\n};\n\nconst makeTagUrl = (id: string) => {\n  return `/tags/${id}`;\n};\n\nconst makeParentTagsUrl = (tag: Partial<GQL.TagDataFragment>) => {\n  if (!tag.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Tags, undefined);\n  const criterion = new TagsCriterion(ChildTagsCriterionOption);\n  criterion.value = {\n    items: [\n      {\n        id: tag.id,\n        label: tag.name || `Tag ${tag.id}`,\n      },\n    ],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/tags?${filter.makeQueryParameters()}`;\n};\n\nconst makeChildTagsUrl = (tag: Partial<GQL.TagDataFragment>) => {\n  if (!tag.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Tags, undefined);\n  const criterion = new TagsCriterion(ParentTagsCriterionOption);\n  criterion.value = {\n    items: [\n      {\n        id: tag.id,\n        label: tag.name || `Tag ${tag.id}`,\n      },\n    ],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/tags?${filter.makeQueryParameters()}`;\n};\n\nfunction makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) {\n  const filter = new ListFilterModel(mode, undefined);\n  const criterion = new TagsCriterion(TagsCriterionOption);\n  criterion.value = {\n    items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return filter.makeQueryParameters();\n}\n\nconst makeTagScenesUrl = (tag: INamedObject) => {\n  return `/scenes?${makeTagFilter(GQL.FilterMode.Scenes, tag)}`;\n};\n\nconst makeTagPerformersUrl = (tag: INamedObject) => {\n  return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`;\n};\n\nconst makeTagStudiosUrl = (tag: INamedObject) => {\n  return `/studios?${makeTagFilter(GQL.FilterMode.Studios, tag)}`;\n};\n\nconst makeTagSceneMarkersUrl = (tag: INamedObject) => {\n  return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`;\n};\n\nconst makeTagGalleriesUrl = (tag: INamedObject) => {\n  return `/galleries?${makeTagFilter(GQL.FilterMode.Galleries, tag)}`;\n};\n\nconst makeTagImagesUrl = (tag: INamedObject) => {\n  return `/images?${makeTagFilter(GQL.FilterMode.Images, tag)}`;\n};\n\nconst makeTagGroupsUrl = (tag: INamedObject) => {\n  return `/groups?${makeTagFilter(GQL.FilterMode.Groups, tag)}`;\n};\n\ntype SceneMarkerDataFragment = Pick<GQL.SceneMarker, \"id\" | \"seconds\"> & {\n  scene: Pick<GQL.Scene, \"id\">;\n};\n\nconst makeSceneMarkerUrl = (sceneMarker: SceneMarkerDataFragment) => {\n  if (!sceneMarker.id || !sceneMarker.scene) return \"#\";\n  return `/scenes/${sceneMarker.scene.id}?t=${sceneMarker.seconds}`;\n};\n\nconst makeScenesPHashMatchUrl = (phash: GQL.Maybe<string> | undefined) => {\n  if (!phash) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);\n  const criterion = new PhashCriterion();\n  criterion.value = { value: phash };\n  filter.criteria.push(criterion);\n  return `/scenes?${filter.makeQueryParameters()}`;\n};\n\nconst makeImagesPHashMatchUrl = (phash: GQL.Maybe<string> | undefined) => {\n  if (!phash) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);\n  const criterion = new PhashCriterion();\n  criterion.value = { value: phash };\n  filter.criteria.push(criterion);\n  return `/images?${filter.makeQueryParameters()}`;\n};\n\nconst makeGalleryImagesUrl = (\n  gallery: Partial<GQL.GalleryDataFragment | GQL.SlimGalleryDataFragment>,\n  extraCriteria?: ModifierCriterion<CriterionValue>[]\n) => {\n  if (!gallery.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);\n  const criterion = new GalleriesCriterion();\n  criterion.value = [{ id: gallery.id, label: galleryTitle(gallery) }];\n  filter.criteria.push(criterion);\n  addExtraCriteria(filter.criteria, extraCriteria);\n  return `/images?${filter.makeQueryParameters()}`;\n};\n\nfunction stringEqualsCriterion(option: ModifierCriterionOption, value: string) {\n  const criterion = new StringCriterion(option);\n  criterion.modifier = GQL.CriterionModifier.Equals;\n  criterion.value = value;\n  return criterion;\n}\n\nconst makeDirectorScenesUrl = (director: string) => {\n  if (director.length == 0) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);\n  filter.criteria.push(\n    stringEqualsCriterion(createStringCriterionOption(\"director\"), director)\n  );\n  return `/scenes?${filter.makeQueryParameters()}`;\n};\n\nconst makeDirectorGroupsUrl = (director: string) => {\n  if (director.length == 0) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined);\n  filter.criteria.push(\n    stringEqualsCriterion(createStringCriterionOption(\"director\"), director)\n  );\n  return `/groups?${filter.makeQueryParameters()}`;\n};\n\nconst makePhotographerGalleriesUrl = (photographer: string) => {\n  if (photographer.length == 0) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined);\n  filter.criteria.push(\n    stringEqualsCriterion(\n      createStringCriterionOption(\"photographer\"),\n      photographer\n    )\n  );\n  return `/galleries?${filter.makeQueryParameters()}`;\n};\n\nconst makePhotographerImagesUrl = (photographer: string) => {\n  if (photographer.length == 0) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);\n  filter.criteria.push(\n    stringEqualsCriterion(\n      createStringCriterionOption(\"photographer\"),\n      photographer\n    )\n  );\n  return `/images?${filter.makeQueryParameters()}`;\n};\n\nconst makeGroupUrl = (id: string) => {\n  return `/groups/${id}`;\n};\n\nconst makeContainingGroupsUrl = (group: Partial<GQL.SlimGroupDataFragment>) => {\n  if (!group.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined);\n  const criterion = new GroupsCriterion(SubGroupsCriterionOption);\n  criterion.value = {\n    items: [\n      {\n        id: group.id,\n        label: group.name || `Group ${group.id}`,\n      },\n    ],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/groups?${filter.makeQueryParameters()}`;\n};\n\nconst makeSubGroupsUrl = (group: INamedObject) => {\n  if (!group.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined);\n  const criterion = new GroupsCriterion(ContainingGroupsCriterionOption);\n  criterion.value = {\n    items: [\n      {\n        id: group.id,\n        label: group.name || `Group ${group.id}`,\n      },\n    ],\n    excluded: [],\n    depth: 0,\n  };\n  filter.criteria.push(criterion);\n  return `/groups?${filter.makeQueryParameters()}`;\n};\n\nconst makeSceneMarkersSceneUrl = (scene: GQL.SceneMarkerSceneDataFragment) => {\n  if (!scene.id) return \"#\";\n  const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers, undefined);\n  const criterion = new MarkersScenesCriterion();\n  criterion.value = [{ id: scene.id, label: objectTitle(scene) }];\n  filter.criteria.push(criterion);\n  return `/scenes/markers?${filter.makeQueryParameters()}`;\n};\n\nexport function handleUnsavedChanges(\n  intl: IntlShape,\n  basepath: string,\n  id?: string\n) {\n  return function (location: { pathname: string }) {\n    // #2291 - don't prompt if we're navigating within the gallery being edited\n    if (id !== undefined && location.pathname === `/${basepath}/${id}`) {\n      return true;\n    }\n\n    return intl.formatMessage({ id: \"dialogs.unsaved_changes\" });\n  };\n}\n\nconst NavUtils = {\n  makePerformerScenesUrl,\n  makePerformerImagesUrl,\n  makePerformerGalleriesUrl,\n  makePerformerGroupsUrl,\n  makePerformerSceneMarkersUrl,\n  makePerformersCountryUrl,\n  makeStudioScenesUrl,\n  makeStudioImagesUrl,\n  makeStudioGalleriesUrl,\n  makeStudioGroupsUrl: makeStudioGroupsUrl,\n  makeStudioPerformersUrl,\n  makeTagUrl,\n  makeGroupUrl,\n  makeParentTagsUrl,\n  makeChildTagsUrl,\n  makeTagSceneMarkersUrl,\n  makeTagScenesUrl,\n  makeTagPerformersUrl,\n  makeTagStudiosUrl,\n  makeTagGalleriesUrl,\n  makeTagImagesUrl,\n  makeTagGroupsUrl,\n  makeScenesPHashMatchUrl,\n  makeSceneMarkerUrl,\n  makeImagesPHashMatchUrl,\n  makeGroupScenesUrl,\n  makeChildStudiosUrl,\n  makeGalleryImagesUrl,\n  makeDirectorScenesUrl,\n  makePhotographerGalleriesUrl,\n  makePhotographerImagesUrl,\n  makeDirectorGroupsUrl,\n  makeContainingGroupsUrl,\n  makeSubGroupsUrl,\n  makeSceneMarkersSceneUrl,\n};\n\nexport default NavUtils;\n"
  },
  {
    "path": "ui/v2.5/src/utils/orientation.ts",
    "content": "import { OrientationEnum } from \"src/core/generated-graphql\";\n\nconst stringOrientationMap = new Map<string, OrientationEnum>([\n  [\"Landscape\", OrientationEnum.Landscape],\n  [\"Portrait\", OrientationEnum.Portrait],\n  [\"Square\", OrientationEnum.Square],\n]);\n\nexport const stringToOrientation = (\n  value?: string | null,\n  caseInsensitive?: boolean\n) => {\n  if (!value) {\n    return undefined;\n  }\n\n  const ret = stringOrientationMap.get(value);\n  if (ret || !caseInsensitive) {\n    return ret;\n  }\n\n  const asUpper = value.toUpperCase();\n  const foundEntry = Array.from(stringOrientationMap.entries()).find((e) => {\n    return e[0].toUpperCase() === asUpper;\n  });\n\n  if (foundEntry) {\n    return foundEntry[1];\n  }\n};\n\nexport const orientationStrings = Array.from(stringOrientationMap.keys());\n"
  },
  {
    "path": "ui/v2.5/src/utils/percent.ts",
    "content": "const numberToString = (seconds: number) => {\n  return seconds + \"%\";\n};\n\nconst stringToNumber = (v?: string) => {\n  if (!v) {\n    return 0;\n  }\n\n  const numStr = v.replace(\"%\", \"\");\n  return parseInt(numStr, 10);\n};\n\nconst PercentUtils = {\n  numberToString,\n  stringToNumber,\n};\n\nexport default PercentUtils;\n"
  },
  {
    "path": "ui/v2.5/src/utils/query.ts",
    "content": "interface ISortable {\n  id: string;\n}\n\n// sortByRelevance is a function that sorts an array of objects by relevance to a query string.\n// It uses the following priorities:\n// 1. Exact matches\n// 2. Starts with\n// 3. Word matches\n// 4. Word starts with\n// 5. Includes\n// If aliases are provided, they are also checked in the same order, but with lower priority than\n// the name of the object.\nexport function sortByRelevance<T extends ISortable>(\n  query: string,\n  value: T[],\n  getName: (o: T) => string,\n  getAliases?: (o: T) => string[] | undefined\n) {\n  if (!query) {\n    return value;\n  }\n\n  query = query.toLowerCase();\n\n  interface ICacheEntry {\n    aliases?: string[];\n    aliasMatch?: boolean;\n    aliasStartsWith?: boolean;\n    wordIndex?: number;\n    wordStartsWithIndex?: number;\n    aliasWordIndex?: number;\n    aliasWordStartsWithIndex?: number;\n    aliasIncludesIndex?: number;\n  }\n\n  const cache: Record<string, ICacheEntry> = {};\n\n  function setCache(o: T, partial: Partial<ICacheEntry>) {\n    cache[o.id] = {\n      ...cache[o.id],\n      ...partial,\n    };\n  }\n\n  function getObjectAliases(o: T) {\n    const cached = cache[o.id]?.aliases;\n\n    if (cached !== undefined) {\n      return cached;\n    }\n\n    if (!getAliases) {\n      return [];\n    }\n\n    const aliases = getAliases(o)?.map((a) => a.trim().toLowerCase()) ?? [];\n    setCache(o, { aliases });\n\n    return aliases;\n  }\n\n  function aliasMatches(o: T) {\n    const cached = cache[o.id]?.aliasMatch;\n\n    if (cached !== undefined) {\n      return cached;\n    }\n\n    const aliases = getObjectAliases(o);\n    const aliasMatch = aliases.some((a) => a === query);\n    setCache(o, { aliasMatch });\n\n    return aliasMatch;\n  }\n\n  function aliasStartsWith(o: T) {\n    const cached = cache[o.id]?.aliasStartsWith;\n\n    if (cached !== undefined) {\n      return cached;\n    }\n\n    const aliases = getObjectAliases(o);\n    const startsWith = aliases.some((a) => a.startsWith(query));\n    setCache(o, { aliasStartsWith: startsWith });\n\n    return startsWith;\n  }\n\n  function getWords(n: string) {\n    // trim and remove any extra spaces\n    return n.trim().replace(/\\s\\s+/g, \" \").toLowerCase().split(\" \");\n  }\n\n  function getNameWords(o: T) {\n    return getWords(getName(o));\n  }\n\n  function getAliasWords(o: T) {\n    const aliases = getObjectAliases(o);\n    return aliases.map((a) => getWords(a)).flat();\n  }\n\n  function getWordIndex(o: T) {\n    const cached = cache[o.id]?.wordIndex;\n\n    if (cached !== undefined) {\n      return cached;\n    }\n\n    const words = getNameWords(o);\n    const wordIndex = words.findIndex((w) => w === query);\n    setCache(o, { wordIndex });\n\n    return wordIndex;\n  }\n\n  function getAliasWordIndex(o: T) {\n    const cached = cache[o.id]?.aliasWordIndex;\n\n    if (cached !== undefined) {\n      return cached;\n    }\n\n    const aliasWords = getAliasWords(o);\n    const aliasWordIndex = aliasWords.findIndex((w) => w === query);\n    setCache(o, { aliasWordIndex });\n\n    return aliasWordIndex;\n  }\n\n  function getWordStartsWithIndex(o: T) {\n    const cached = cache[o.id]?.wordStartsWithIndex;\n\n    if (cached !== undefined) {\n      return cached;\n    }\n\n    const words = getNameWords(o);\n    const wordStartsWithIndex = words.findIndex((w) => w.startsWith(query));\n    setCache(o, { wordStartsWithIndex });\n\n    return wordStartsWithIndex;\n  }\n\n  function getAliasWordStartsWithIndex(o: T) {\n    const cached = cache[o.id]?.aliasWordStartsWithIndex;\n\n    if (cached !== undefined) {\n      return cached;\n    }\n\n    const aliasWords = getAliasWords(o);\n    const aliasWordStartsWithIndex = aliasWords.findIndex((w) =>\n      w.startsWith(query)\n    );\n    setCache(o, { aliasWordStartsWithIndex });\n\n    return aliasWordStartsWithIndex;\n  }\n\n  function getAliasIncludesIndex(o: T) {\n    const cached = cache[o.id]?.aliasIncludesIndex;\n\n    if (cached !== undefined) {\n      return cached;\n    }\n\n    const aliases = getObjectAliases(o);\n    const aliasIncludesIndex = aliases.findIndex((a) => a.includes(query));\n    setCache(o, { aliasIncludesIndex });\n\n    return aliasIncludesIndex;\n  }\n\n  function compare(a: T, b: T) {\n    const aName = getName(a).trim().toLowerCase();\n    const bName = getName(b).trim().toLowerCase();\n\n    const aAlias = aliasMatches(a);\n    const bAlias = aliasMatches(b);\n\n    // exact matches first\n    if (aName === query && bName !== query) {\n      return -1;\n    }\n\n    if (aName !== query && bName === query) {\n      return 1;\n    }\n\n    if (aAlias && !bAlias) {\n      return -1;\n    }\n\n    if (!aAlias && bAlias) {\n      return 1;\n    }\n\n    // then starts with\n    if (aName.startsWith(query) && !bName.startsWith(query)) {\n      return -1;\n    }\n\n    if (!aName.startsWith(query) && bName.startsWith(query)) {\n      return 1;\n    }\n\n    const aAliasStartsWith = aliasStartsWith(a);\n    const bAliasStartsWith = aliasStartsWith(b);\n\n    if (aAliasStartsWith && !bAliasStartsWith) {\n      return -1;\n    }\n\n    if (!aAliasStartsWith && bAliasStartsWith) {\n      return 1;\n    }\n\n    // only check words if the query is a single word\n    if (!query.includes(\" \")) {\n      // word matches\n      {\n        const aWord = getWordIndex(a);\n        const bWord = getWordIndex(b);\n\n        if (aWord !== -1 && bWord === -1) {\n          return -1;\n        }\n\n        if (aWord === -1 && bWord !== -1) {\n          return 1;\n        }\n\n        if (aWord !== -1 && bWord !== -1) {\n          if (aWord === bWord) {\n            return aName.localeCompare(bName);\n          }\n\n          return aWord - bWord;\n        }\n\n        const aAliasWord = getAliasWordIndex(a);\n        const bAliasWord = getAliasWordIndex(b);\n\n        if (aAliasWord !== -1 && bAliasWord === -1) {\n          return -1;\n        }\n\n        if (aAliasWord === -1 && bAliasWord !== -1) {\n          return 1;\n        }\n\n        if (aAliasWord !== -1 && bAliasWord !== -1) {\n          if (aAliasWord === bAliasWord) {\n            return aName.localeCompare(bName);\n          }\n\n          return aAliasWord - bAliasWord;\n        }\n      }\n\n      // then start of word\n      {\n        const aWord = getWordStartsWithIndex(a);\n        const bWord = getWordStartsWithIndex(b);\n\n        if (aWord !== -1 && bWord === -1) {\n          return -1;\n        }\n\n        if (aWord === -1 && bWord !== -1) {\n          return 1;\n        }\n\n        if (aWord !== -1 && bWord !== -1) {\n          if (aWord === bWord) {\n            return aName.localeCompare(bName);\n          }\n\n          return aWord - bWord;\n        }\n\n        const aAliasWord = getAliasWordStartsWithIndex(a);\n        const bAliasWord = getAliasWordStartsWithIndex(b);\n\n        if (aAliasWord !== -1 && bAliasWord === -1) {\n          return -1;\n        }\n\n        if (aAliasWord === -1 && bAliasWord !== -1) {\n          return 1;\n        }\n\n        if (aAliasWord !== -1 && bAliasWord !== -1) {\n          if (aAliasWord === bAliasWord) {\n            return aName.localeCompare(bName);\n          }\n\n          return aAliasWord - bAliasWord;\n        }\n      }\n    }\n\n    // then contains\n    // performance of this is presumably fast enough to not require caching\n    const aNameIncludeIndex = aName.indexOf(query);\n    const bNameIncludeIndex = bName.indexOf(query);\n\n    if (aNameIncludeIndex !== -1 && bNameIncludeIndex === -1) {\n      return -1;\n    }\n\n    if (aNameIncludeIndex === -1 && bNameIncludeIndex !== -1) {\n      return 1;\n    }\n\n    if (aNameIncludeIndex !== -1 && bNameIncludeIndex !== -1) {\n      if (aNameIncludeIndex === bNameIncludeIndex) {\n        return aName.localeCompare(bName);\n      }\n\n      return aNameIncludeIndex - bNameIncludeIndex;\n    }\n\n    const aAliasIncludes = getAliasIncludesIndex(a);\n    const bAliasIncludes = getAliasIncludesIndex(b);\n\n    if (aAliasIncludes !== -1 && bAliasIncludes === -1) {\n      return -1;\n    }\n\n    if (aAliasIncludes === -1 && bAliasIncludes !== -1) {\n      return 1;\n    }\n\n    if (aAliasIncludes !== -1 && bAliasIncludes !== -1) {\n      if (aAliasIncludes === bAliasIncludes) {\n        return aName.localeCompare(bName);\n      }\n\n      return aAliasIncludes - bAliasIncludes;\n    }\n\n    return aName.localeCompare(bName);\n  }\n\n  return value.slice().sort(compare);\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/rating.ts",
    "content": "export enum RatingSystemType {\n  Stars = \"stars\",\n  Decimal = \"decimal\",\n}\n\nexport enum RatingStarPrecision {\n  Full = \"full\",\n  Half = \"half\",\n  Quarter = \"quarter\",\n  Tenth = \"tenth\",\n}\n\nexport const defaultRatingSystemType: RatingSystemType = RatingSystemType.Stars;\nexport const defaultRatingStarPrecision: RatingStarPrecision =\n  RatingStarPrecision.Full;\n\nexport const ratingSystemIntlMap = new Map<RatingSystemType, string>([\n  [\n    RatingSystemType.Stars,\n    \"config.ui.editing.rating_system.type.options.stars\",\n  ],\n  [\n    RatingSystemType.Decimal,\n    \"config.ui.editing.rating_system.type.options.decimal\",\n  ],\n]);\n\nexport const ratingStarPrecisionIntlMap = new Map<RatingStarPrecision, string>([\n  [\n    RatingStarPrecision.Full,\n    \"config.ui.editing.rating_system.star_precision.options.full\",\n  ],\n  [\n    RatingStarPrecision.Half,\n    \"config.ui.editing.rating_system.star_precision.options.half\",\n  ],\n  [\n    RatingStarPrecision.Quarter,\n    \"config.ui.editing.rating_system.star_precision.options.quarter\",\n  ],\n  [\n    RatingStarPrecision.Tenth,\n    \"config.ui.editing.rating_system.star_precision.options.tenth\",\n  ],\n]);\n\nexport type RatingSystemOptions = {\n  type: RatingSystemType;\n  starPrecision?: RatingStarPrecision;\n};\n\nexport const defaultRatingSystemOptions = {\n  type: defaultRatingSystemType,\n  starPrecision: defaultRatingStarPrecision,\n};\n\nfunction round(value: number, step: number) {\n  let denom = step;\n  if (!denom) {\n    denom = 1.0;\n  }\n  const inv = 1.0 / denom;\n  return Math.round(value * inv) / inv;\n}\n\nexport function getRatingPrecision(precision: RatingStarPrecision) {\n  switch (precision) {\n    case RatingStarPrecision.Full:\n      return 1;\n    case RatingStarPrecision.Half:\n      return 0.5;\n    case RatingStarPrecision.Quarter:\n      return 0.25;\n    case RatingStarPrecision.Tenth:\n      return 0.1;\n    default:\n      return 1;\n  }\n}\n\nexport function convertToRatingFormat(\n  rating: number | null | undefined,\n  ratingSystemOptions: RatingSystemOptions\n) {\n  if (!rating) {\n    return null;\n  }\n\n  const { type, starPrecision } = ratingSystemOptions;\n\n  const precision =\n    type === RatingSystemType.Decimal\n      ? 0.1\n      : getRatingPrecision(starPrecision ?? RatingStarPrecision.Full);\n  const maxValue = type === RatingSystemType.Decimal ? 10 : 5;\n  const denom = 100 / maxValue;\n\n  return round(rating / denom, precision);\n}\n\nexport function convertFromRatingFormat(\n  rating: number,\n  ratingSystem: RatingSystemType | undefined\n) {\n  const maxValue =\n    (ratingSystem ?? RatingSystemType.Stars) === RatingSystemType.Decimal\n      ? 10\n      : 5;\n  const factor = 100 / maxValue;\n\n  return Math.round(rating * factor);\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/resolution.ts",
    "content": "import { ResolutionEnum } from \"src/core/generated-graphql\";\n\nconst stringResolutionMap = new Map<string, ResolutionEnum>([\n  [\"144p\", ResolutionEnum.VeryLow],\n  [\"240p\", ResolutionEnum.Low],\n  [\"360p\", ResolutionEnum.R360P],\n  [\"480p\", ResolutionEnum.Standard],\n  [\"540p\", ResolutionEnum.WebHd],\n  [\"720p\", ResolutionEnum.StandardHd],\n  [\"1080p\", ResolutionEnum.FullHd],\n  [\"1440p\", ResolutionEnum.QuadHd],\n  // [\"1920p\", ResolutionEnum.VrHd],\n  [\"4k\", ResolutionEnum.FourK],\n  [\"5k\", ResolutionEnum.FiveK],\n  [\"6k\", ResolutionEnum.SixK],\n  [\"7k\", ResolutionEnum.SevenK],\n  [\"8k\", ResolutionEnum.EightK],\n  [\"Huge\", ResolutionEnum.Huge],\n]);\n\nexport const stringToResolution = (\n  value?: string | null,\n  caseInsensitive?: boolean\n) => {\n  if (!value) {\n    return undefined;\n  }\n\n  const ret = stringResolutionMap.get(value);\n  if (ret || !caseInsensitive) {\n    return ret;\n  }\n\n  const asUpper = value.toUpperCase();\n  const foundEntry = Array.from(stringResolutionMap.entries()).find((e) => {\n    return e[0].toUpperCase() === asUpper;\n  });\n\n  if (foundEntry) {\n    return foundEntry[1];\n  }\n};\n\nexport const resolutionStrings = Array.from(stringResolutionMap.keys());\n"
  },
  {
    "path": "ui/v2.5/src/utils/screen.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nconst isMobile = () =>\n  window.matchMedia(\"only screen and (max-width: 576px)\").matches;\n\nconst isTouch = () => window.matchMedia(\"(pointer: coarse)\").matches;\n\nfunction matchesMediaQuery(query: string) {\n  return window.matchMedia(query).matches;\n}\n\n// from: https://dev.to/salimzade/handle-media-query-in-react-with-hooks-3cp3\nexport const useMediaQuery = (query: string): boolean => {\n  const [matches, setMatches] = useState<boolean>(false);\n\n  useEffect(() => {\n    const media = window.matchMedia(query);\n    setMatches(media.matches);\n\n    // Define the listener as a separate function to avoid recreating it on each render\n    const listener = () => setMatches(media.matches);\n\n    // Use 'change' instead of 'resize' for better performance\n    media.addEventListener(\"change\", listener);\n\n    // Cleanup function to remove the event listener\n    return () => media.removeEventListener(\"change\", listener);\n  }, [query]); // Only recreate the listener when 'matches' or 'query' changes\n\n  return matches;\n};\n\nconst ScreenUtils = {\n  isMobile,\n  isTouch,\n  matchesMediaQuery,\n};\n\nexport default ScreenUtils;\n"
  },
  {
    "path": "ui/v2.5/src/utils/session.ts",
    "content": "import Cookies from \"universal-cookie\";\n\nconst isLoggedIn = () => {\n  return new Cookies().get(\"session\") !== undefined;\n};\n\nconst SessionUtils = {\n  isLoggedIn,\n};\n\nexport default SessionUtils;\n"
  },
  {
    "path": "ui/v2.5/src/utils/stashIds.ts",
    "content": "import * as GQL from \"src/core/generated-graphql\";\n\nexport const getStashIDs = (\n  ids?: { stash_id: string; endpoint: string; updated_at: string }[]\n) =>\n  (ids ?? []).map(({ stash_id, endpoint, updated_at }) => ({\n    stash_id,\n    endpoint,\n    updated_at,\n  }));\n\n// UUID regex pattern to detect StashIDs (supports v4 and v7)\nconst UUID_PATTERN =\n  /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\n\nexport function isUUID(input: string): boolean {\n  return UUID_PATTERN.test(input.trim());\n}\n\n/**\n * Separates a list of inputs into names and StashIDs based on UUID pattern matching\n * @param inputs - Array of strings that could be either names or StashIDs\n * @returns Object containing separate arrays for names and stashIds\n */\nexport const separateNamesAndStashIds = (\n  inputs: string[]\n): { names: string[]; stashIds: string[] } => {\n  const names: string[] = [];\n  const stashIds: string[] = [];\n\n  inputs.forEach((input) => {\n    if (isUUID(input)) {\n      stashIds.push(input);\n    } else {\n      names.push(input);\n    }\n  });\n\n  return { names, stashIds };\n};\n\n/**\n * Utility to add or update a StashID in an array.\n * If a StashID with the same endpoint exists, it will be replaced.\n * Otherwise, the new StashID will be appended.\n */\nexport const addUpdateStashID = (\n  existingStashIDs: GQL.StashIdInput[],\n  newItem: GQL.StashIdInput,\n  allowMultiple: boolean = false\n): GQL.StashIdInput[] => {\n  const existingIndex = existingStashIDs.findIndex(\n    (s) => s.endpoint === newItem.endpoint\n  );\n\n  if (!allowMultiple && existingIndex >= 0) {\n    const newStashIDs = [...existingStashIDs];\n    newStashIDs[existingIndex] = newItem;\n    return newStashIDs;\n  }\n\n  // ensure we don't add duplicates if allowMultiple is true\n  if (\n    allowMultiple &&\n    existingStashIDs.some(\n      (s) => s.endpoint === newItem.endpoint && s.stash_id === newItem.stash_id\n    )\n  ) {\n    return existingStashIDs;\n  }\n\n  return [...existingStashIDs, newItem];\n};\n"
  },
  {
    "path": "ui/v2.5/src/utils/stashbox.ts",
    "content": "import { StashIdInput } from \"src/core/generated-graphql\";\n\nexport function stashboxDisplayName(name: string, index: number) {\n  return name || `Stash-Box #${index + 1}`;\n}\n\nexport const getStashboxBase = (endpoint: string) =>\n  endpoint.match(/(https?:\\/\\/.*?\\/)graphql/)?.[1];\n\n// mergeStashIDs merges the src stash ID into the dest stash IDs.\n// If the src stash ID is already in dest, the src stash ID overwrites the dest stash ID.\nexport function mergeStashIDs(dest: StashIdInput[], src: StashIdInput[]) {\n  return dest\n    .filter((i) => !src.find((j) => i.endpoint === j.endpoint))\n    .concat(src);\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/text.ts",
    "content": "import { IntlShape } from \"react-intl\";\n\n// Typescript currently does not implement the intl Unit interface\ntype Unit =\n  | \"byte\"\n  | \"kibibyte\"\n  | \"mebibyte\"\n  | \"gibibyte\"\n  | \"tebibyte\"\n  | \"pebibyte\";\nconst Units: Unit[] = [\n  \"byte\",\n  \"kibibyte\",\n  \"mebibyte\",\n  \"gibibyte\",\n  \"tebibyte\",\n  \"pebibyte\",\n];\nconst shortUnits = [\"B\", \"KiB\", \"MiB\", \"GiB\", \"TiB\", \"PiB\"];\n\nconst fileSize = (bytes: number = 0) => {\n  if (Number.isNaN(parseFloat(String(bytes))) || !Number.isFinite(bytes))\n    return { size: 0, unit: Units[0] };\n\n  let unit = 0;\n  let count = bytes;\n  // calculating base 2 units\n  while (count >= 1024 && unit + 1 < Units.length) {\n    count /= 1024;\n    unit++;\n  }\n\n  return {\n    size: count,\n    unit: Units[unit],\n  };\n};\n\nclass DurationUnit {\n  static readonly SECOND: DurationUnit = new DurationUnit(\n    \"second\",\n    \"seconds\",\n    \"s\",\n    1\n  );\n  static readonly MINUTE: DurationUnit = new DurationUnit(\n    \"minute\",\n    \"minutes\",\n    \"m\",\n    60\n  );\n  static readonly HOUR: DurationUnit = new DurationUnit(\n    \"hour\",\n    \"hours\",\n    \"h\",\n    DurationUnit.MINUTE.secs * 60\n  );\n  static readonly DAY: DurationUnit = new DurationUnit(\n    \"day\",\n    \"days\",\n    \"D\",\n    DurationUnit.HOUR.secs * 24\n  );\n  static readonly WEEK: DurationUnit = new DurationUnit(\n    \"week\",\n    \"weeks\",\n    \"W\",\n    DurationUnit.DAY.secs * 7\n  );\n  static readonly MONTH: DurationUnit = new DurationUnit(\n    \"month\",\n    \"months\",\n    \"M\",\n    DurationUnit.DAY.secs * 30\n  );\n  static readonly YEAR: DurationUnit = new DurationUnit(\n    \"year\",\n    \"years\",\n    \"Y\",\n    DurationUnit.DAY.secs * 365\n  );\n\n  static readonly DURATIONS: DurationUnit[] = [\n    DurationUnit.SECOND,\n    DurationUnit.MINUTE,\n    DurationUnit.HOUR,\n    DurationUnit.DAY,\n    DurationUnit.WEEK,\n    DurationUnit.MONTH,\n    DurationUnit.YEAR,\n  ];\n\n  private constructor(\n    private readonly singular: string,\n    private readonly plural: string,\n    private readonly shortString: string,\n    public secs: number\n  ) {}\n\n  toString() {\n    return this.shortString;\n  }\n}\n\nclass DurationCount {\n  public constructor(\n    public readonly count: number,\n    public readonly duration: DurationUnit\n  ) {}\n\n  toString() {\n    return this.count.toString() + this.duration.toString();\n  }\n}\n\nconst secondsAsTime = (seconds: number = 0): DurationCount[] => {\n  if (Number.isNaN(parseFloat(String(seconds))) || !Number.isFinite(seconds))\n    return [new DurationCount(0, DurationUnit.DURATIONS[0])];\n\n  const result = [];\n  let remainingSeconds = seconds;\n  // Run down the possible durations and pull them out\n  for (let i = DurationUnit.DURATIONS.length - 1; i >= 0; i--) {\n    const q = Math.floor(remainingSeconds / DurationUnit.DURATIONS[i].secs);\n    if (q !== 0) {\n      remainingSeconds %= DurationUnit.DURATIONS[i].secs;\n      result.push(new DurationCount(q, DurationUnit.DURATIONS[i]));\n    }\n  }\n  return result;\n};\n\nconst secondsAsTimeString = (\n  seconds: number = 0,\n  maxUnitCount: number = 2\n): string => {\n  return secondsAsTime(seconds).slice(0, maxUnitCount).join(\" \");\n};\n\nconst formatFileSizeUnit = (u: Unit) => {\n  const i = Units.indexOf(u);\n  return shortUnits[i];\n};\n\n// returns the number of fractional digits to use when displaying file sizes\n// returns 0 for MB and under, 1 for GB and over.\nconst fileSizeFractionalDigits = (unit: Unit) => {\n  if (Units.indexOf(unit) >= 3) {\n    return 1;\n  }\n\n  return 0;\n};\n\n// Converts seconds to a [hh:]mm:ss[.ffff] where hh is only shown if hours is non-zero,\n// and ffff is shown only if frameRate is set, and the seconds includes a fractional component.\n// A negative input will result in a -hh:mm:ss or -mm:ss output.\nconst secondsToTimestamp = (secondsInput: number, includeMS?: boolean) => {\n  let neg = false;\n  if (secondsInput < 0) {\n    neg = true;\n    secondsInput = -secondsInput;\n  }\n\n  const fracSeconds = secondsInput % 1;\n  const ms = Math.round(fracSeconds * 1000);\n\n  let seconds = Math.trunc(secondsInput);\n\n  const s = seconds % 60;\n  seconds = (seconds - s) / 60;\n\n  const m = seconds % 60;\n  seconds = (seconds - m) / 60;\n\n  const h = seconds;\n\n  let ret = String(s).padStart(2, \"0\");\n  if (h === 0) {\n    ret = String(m) + \":\" + ret;\n  } else {\n    ret = String(m).padStart(2, \"0\") + \":\" + ret;\n    ret = String(h) + \":\" + ret;\n  }\n\n  if (includeMS && ms > 0) {\n    ret += \".\" + ms.toString().padStart(3, \"0\");\n  }\n\n  if (neg) {\n    return \"-\" + ret;\n  } else {\n    return ret;\n  }\n};\n\nconst formatTimestampRange = (start: number, end: number | undefined) => {\n  if (end === undefined) {\n    return secondsToTimestamp(start);\n  }\n  return `${secondsToTimestamp(start)}-${secondsToTimestamp(end)}`;\n};\n\nconst timestampToSeconds = (v: string | null | undefined) => {\n  if (!v) {\n    return null;\n  }\n\n  const splits = v.split(\":\");\n\n  if (splits.length > 3) {\n    return null;\n  }\n\n  let secondsPart = splits[splits.length - 1];\n  let msFrac = 0;\n  if (secondsPart.includes(\".\")) {\n    const secondsParts = secondsPart.split(\".\");\n    if (secondsParts.length !== 2) {\n      return null;\n    }\n\n    secondsPart = secondsParts[0];\n\n    const msPart = parseInt(secondsParts[1], 10);\n    if (Number.isNaN(msPart)) {\n      return null;\n    }\n\n    msFrac = msPart / 1000;\n  }\n\n  let seconds = 0;\n  let factor = 1;\n  while (splits.length > 0) {\n    const thisSplit = splits.pop();\n    if (thisSplit === undefined) {\n      return null;\n    }\n\n    const thisInt = parseInt(thisSplit, 10);\n    if (Number.isNaN(thisInt)) {\n      return null;\n    }\n\n    seconds += factor * thisInt;\n    factor *= 60;\n  }\n\n  return seconds + msFrac;\n};\n\nconst fileNameFromPath = (path: string) => {\n  if (!!path === false) return \"No File Name\";\n  return path.replace(/^.*[\\\\/]/, \"\");\n};\n\nconst stringToDate = (dateString: string) => {\n  if (!dateString) return null;\n\n  const parts = dateString.split(\"-\");\n  // Invalid date string\n  if (parts.length !== 3) return null;\n\n  const year = Number(parts[0]);\n  const monthIndex = Math.max(0, Number(parts[1]) - 1);\n  const day = Number(parts[2]);\n\n  return new Date(year, monthIndex, day, 0, 0, 0, 0);\n};\n\nconst stringToFuzzyDate = (dateString: string) => {\n  if (!dateString) return null;\n\n  const parts = dateString.split(\"-\");\n  // Invalid date string\n  let year = Number(parts[0]);\n  if (isNaN(year)) year = new Date().getFullYear();\n  let monthIndex = 0;\n  if (parts.length > 1) {\n    monthIndex = Math.max(0, Number(parts[1]) - 1);\n    if (monthIndex > 11 || isNaN(monthIndex)) monthIndex = 0;\n  }\n  let day = 1;\n  if (parts.length > 2) {\n    day = Number(parts[2]);\n    if (day > 31 || isNaN(day)) day = 1;\n  }\n\n  return new Date(year, monthIndex, day, 0, 0, 0, 0);\n};\n\nconst stringToFuzzyDateTime = (dateString: string) => {\n  if (!dateString) return null;\n\n  const dateTime = dateString.split(\" \");\n\n  let date: Date | null = null;\n  if (dateTime.length > 0) {\n    date = stringToFuzzyDate(dateTime[0]);\n  }\n\n  if (!date) {\n    date = new Date();\n  }\n\n  if (dateTime.length > 1) {\n    const timeParts = dateTime[1].split(\":\");\n    if (date && timeParts.length > 0) {\n      date.setHours(Number(timeParts[0]));\n    }\n    if (date && timeParts.length > 1) {\n      date.setMinutes(Number(timeParts[1]));\n    }\n    if (date && timeParts.length > 2) {\n      date.setSeconds(Number(timeParts[2]));\n    }\n  }\n\n  return date;\n};\n\nfunction dateToString(date: Date) {\n  return `${date.getFullYear()}-${(date.getMonth() + 1)\n    .toString()\n    .padStart(2, \"0\")}-${date.getDate().toString().padStart(2, \"0\")}`;\n}\n\nfunction dateTimeToString(date: Date) {\n  return `${dateToString(date)} ${date\n    .getHours()\n    .toString()\n    .padStart(2, \"0\")}:${date.getMinutes().toString().padStart(2, \"0\")}`;\n}\n\nconst getAge = (dateString?: string | null, fromDateString?: string | null) => {\n  if (!dateString) return 0;\n\n  const birthdate = stringToFuzzyDate(dateString);\n  const fromDate = fromDateString\n    ? stringToFuzzyDate(fromDateString)\n    : new Date();\n\n  if (!birthdate || !fromDate) return 0;\n\n  let age = fromDate.getFullYear() - birthdate.getFullYear();\n  if (\n    birthdate.getMonth() > fromDate.getMonth() ||\n    (birthdate.getMonth() >= fromDate.getMonth() &&\n      birthdate.getDate() > fromDate.getDate())\n  ) {\n    age -= 1;\n  }\n\n  return age;\n};\n\nconst bitRate = (bitrate: number) => {\n  const megabits = bitrate / 1000000;\n  return `${megabits.toFixed(2)} megabits per second`;\n};\n\nconst resolution = (width: number, height: number) => {\n  const number = width > height ? height : width;\n  if (number >= 6144) {\n    return \"HUGE\";\n  }\n  if (number >= 3840) {\n    return \"8K\";\n  }\n  if (number >= 3584) {\n    return \"7K\";\n  }\n  if (number >= 3000) {\n    return \"6K\";\n  }\n  if (number >= 2560) {\n    return \"5K\";\n  }\n  if (number >= 1920) {\n    return \"4K\";\n  }\n  if (number >= 1440) {\n    return \"1440p\";\n  }\n  if (number >= 1080) {\n    return \"1080p\";\n  }\n  if (number >= 720) {\n    return \"720p\";\n  }\n  if (number >= 540) {\n    return \"540p\";\n  }\n  if (number >= 480) {\n    return \"480p\";\n  }\n  if (number >= 360) {\n    return \"360p\";\n  }\n  if (number >= 240) {\n    return \"240p\";\n  }\n  if (number >= 144) {\n    return \"144p\";\n  }\n};\n\nconst sanitiseURL = (url?: string, siteURL?: URL) => {\n  if (!url) {\n    return url;\n  }\n\n  if (url.startsWith(\"http://\") || url.startsWith(\"https://\")) {\n    // just return the entire URL\n    return url;\n  }\n\n  if (siteURL) {\n    // if url starts with the site host, then prepend the protocol\n    if (url.startsWith(siteURL.host)) {\n      return `${siteURL.protocol}//${url}`;\n    }\n\n    // otherwise, construct the url from the protocol, host and passed url\n    return `${siteURL.protocol}//${siteURL.host}/${url}`;\n  }\n\n  // just prepend the protocol - assume https\n  return `https://${url}`;\n};\n\nconst domainFromURL = (urlString?: string, url?: URL) => {\n  if (url) {\n    return url.hostname;\n  } else if (urlString) {\n    var urlDomain = \"\";\n    try {\n      var sanitizedUrl = sanitiseURL(urlString);\n      if (sanitizedUrl) {\n        urlString = sanitizedUrl;\n      }\n      urlDomain = new URL(urlString).hostname;\n    } catch {\n      urlDomain = urlString; // We cant determine the hostname so we return the base string\n    }\n    return urlDomain;\n  } else {\n    return \"\";\n  }\n};\n\nconst formatDate = (intl: IntlShape, date?: string, utc = true) => {\n  if (!date) {\n    return \"\";\n  }\n\n  return intl.formatDate(date, {\n    format: \"long\",\n    timeZone: utc ? \"utc\" : undefined,\n  });\n};\n\nconst formatFuzzyDate = (intl: IntlShape, date?: string, utc = true) => {\n  if (!date) {\n    return \"\";\n  }\n\n  // handle year or year/month dates\n  const yearMatch = date.match(/^(\\d{4})$/);\n  if (yearMatch) {\n    const year = parseInt(yearMatch[1], 10);\n    return intl.formatDate(Date.UTC(year, 0), {\n      year: \"numeric\",\n      timeZone: utc ? \"utc\" : undefined,\n    });\n  }\n\n  const yearMonthMatch = date.match(/^(\\d{4})-(\\d{2})$/);\n  if (yearMonthMatch) {\n    const year = parseInt(yearMonthMatch[1], 10);\n    const month = parseInt(yearMonthMatch[2], 10) - 1;\n    return intl.formatDate(Date.UTC(year, month), {\n      year: \"numeric\",\n      month: \"long\",\n      timeZone: utc ? \"utc\" : undefined,\n    });\n  }\n\n  return intl.formatDate(date, {\n    format: \"long\",\n    timeZone: utc ? \"utc\" : undefined,\n  });\n};\n\nconst formatDateTime = (intl: IntlShape, dateTime?: string, utc = false) =>\n  `${formatDate(intl, dateTime, utc)} ${intl.formatTime(dateTime, {\n    timeZone: utc ? \"utc\" : undefined,\n  })}`;\n\ntype CountUnit = \"\" | \"K\" | \"M\" | \"B\";\nconst CountUnits: CountUnit[] = [\"\", \"K\", \"M\", \"B\"];\n\nconst abbreviateCounter = (counter: number = 0) => {\n  if (Number.isNaN(parseFloat(String(counter))) || !Number.isFinite(counter))\n    return { size: 0, unit: CountUnits[0] };\n\n  let unit = 0;\n  let digits = 0;\n  let count = counter;\n  while (count >= 1000 && unit + 1 < CountUnits.length) {\n    count /= 1000;\n    unit++;\n    digits = 1;\n  }\n\n  return {\n    size: count,\n    unit: CountUnits[unit],\n    digits: digits,\n  };\n};\n\n/*\n * Trims quotes if the text has leading/trailing quotes\n */\nconst stripQuotes = (text: string) => {\n  if (text.startsWith('\"') && text.endsWith('\"')) return text.slice(1, -1);\n  return text;\n};\n\n/*\n * Wraps string in quotes\n */\nconst addQuotes = (text: string) => `\"${text}\"`;\n\nconst TextUtils = {\n  fileSize,\n  formatFileSizeUnit,\n  fileSizeFractionalDigits,\n  secondsToTimestamp,\n  formatTimestampRange,\n  timestampToSeconds,\n  fileNameFromPath,\n  stringToDate,\n  stringToFuzzyDate,\n  stringToFuzzyDateTime,\n  dateToString,\n  dateTimeToString,\n  age: getAge,\n  bitRate,\n  resolution,\n  sanitiseURL,\n  domainFromURL,\n  formatDate,\n  formatFuzzyDate,\n  formatDateTime,\n  secondsAsTimeString,\n  abbreviateCounter,\n  stripQuotes,\n  addQuotes,\n};\n\nexport default TextUtils;\n"
  },
  {
    "path": "ui/v2.5/src/utils/units.ts",
    "content": "export function cmToImperial(cm: number) {\n  const cmInInches = 0.393700787;\n  const inchesInFeet = 12;\n  const inches = Math.round(cm * cmInInches);\n  const feet = Math.floor(inches / inchesInFeet);\n  return [feet, inches % inchesInFeet];\n}\n\nexport function kgToLbs(kg: number) {\n  return Math.round(kg * 2.20462262185);\n}\n\nexport function cmToInches(cm: number) {\n  const cmInInches = 0.393700787;\n  const inches = cm * cmInInches;\n  return inches;\n}\n\nexport function remToPx(rem: number) {\n  return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/visualFile.ts",
    "content": "import { Maybe } from \"src/core/generated-graphql\";\n\n// returns true if the file should be treated as a video in the UI\nexport function isVideo(o: {\n  __typename?: string;\n  video_codec?: Maybe<string>;\n}) {\n  return o.__typename == \"VideoFile\" && o.video_codec != \"gif\";\n}\n"
  },
  {
    "path": "ui/v2.5/src/utils/yup.ts",
    "content": "import { FormikErrors, yupToFormErrors } from \"formik\";\nimport { IntlShape } from \"react-intl\";\nimport * as yup from \"yup\";\n\n// equivalent to yup.array(yup.string().required())\n// except that error messages will be e.g.\n// 'urls must not be blank' instead of\n// 'urls[\"0\"] is a required field'\nexport function yupRequiredStringArray(intl: IntlShape) {\n  return yup\n    .array(\n      // we enforce that each string in the array is \"required\" in the outer test function\n      // so cast to avoid having to add a redundant `.required()` here\n      yup.string() as yup.StringSchema<string>\n    )\n    .test({\n      name: \"blank\",\n      test(value) {\n        if (!value || !value.length) return true;\n\n        const blanks: number[] = [];\n        for (let i = 0; i < value.length; i++) {\n          const s = value[i];\n          if (!s) {\n            blanks.push(i);\n          }\n        }\n        if (blanks.length === 0) return true;\n\n        // each error message is identical\n        const msg = yup.ValidationError.formatError(\n          intl.formatMessage({ id: \"validation.blank\" }),\n          {\n            label: this.schema.spec.label,\n            path: this.path,\n          }\n        );\n\n        // return multiple errors, one for each blank string\n        const errors = blanks.map(\n          (i) =>\n            new yup.ValidationError(\n              msg,\n              value[i],\n              // the path to this \"sub-error\": e.g. 'urls[\"0\"]'\n              `${this.path}[\"${i}\"]`,\n              \"blank\"\n            )\n        );\n\n        return new yup.ValidationError(errors, value, this.path, \"blank\");\n      },\n    });\n}\n\nexport function yupUniqueStringList(intl: IntlShape) {\n  return yupRequiredStringArray(intl)\n    .defined()\n    .test({\n      name: \"unique\",\n      test(value) {\n        const values: string[] = [];\n        const dupes: number[] = [];\n        for (let i = 0; i < value.length; i++) {\n          const s = value[i];\n          if (values.includes(s)) {\n            dupes.push(i);\n          } else {\n            values.push(s);\n          }\n        }\n        if (dupes.length === 0) return true;\n\n        const msg = yup.ValidationError.formatError(\n          intl.formatMessage({ id: \"validation.unique\" }),\n          {\n            label: this.schema.spec.label,\n            path: this.path,\n          }\n        );\n        const errors = dupes.map(\n          (i) =>\n            new yup.ValidationError(\n              msg,\n              value[i],\n              `${this.path}[\"${i}\"]`,\n              \"unique\"\n            )\n        );\n        return new yup.ValidationError(errors, value, this.path, \"unique\");\n      },\n    });\n}\n\nexport function validateDateString(value?: string) {\n  if (!value) return true;\n  // Allow YYYY, YYYY-MM, or YYYY-MM-DD formats\n  if (!value.match(/^\\d{4}(-\\d{2}(-\\d{2})?)?$/)) return false;\n  // Validate the date components\n  const parts = value.split(\"-\");\n  const year = parseInt(parts[0], 10);\n  if (year < 1 || year > 9999) return false;\n  if (parts.length >= 2) {\n    const month = parseInt(parts[1], 10);\n    if (month < 1 || month > 12) return false;\n  }\n  if (parts.length === 3) {\n    const day = parseInt(parts[2], 10);\n    if (day < 1 || day > 31) return false;\n    // Full date - validate it parses correctly\n    if (Number.isNaN(Date.parse(value))) return false;\n  }\n  return true;\n}\n\nexport function getDateError(\n  value: string | undefined | null,\n  intl: IntlShape\n) {\n  if (validateDateString(value ?? \"\")) return undefined;\n  return intl\n    .formatMessage({ id: \"validation.date_invalid_form\" })\n    .replace(\"${path}\", intl.formatMessage({ id: \"date\" }));\n}\n\nexport function yupDateString(intl: IntlShape) {\n  return yup\n    .string()\n    .ensure()\n    .test({\n      name: \"date\",\n      test(value) {\n        return validateDateString(value);\n      },\n      message: intl.formatMessage({ id: \"validation.date_invalid_form\" }),\n    });\n}\n\ntype StringEnum<T extends string> = {\n  [k: string]: T;\n};\n\n// Use yupInputEnum to validate a string enum from a <select>.\n// If \"\" is not a value in the enum, a \"\" input will be transformed to null.\nexport function yupInputEnum<T extends string>(e: StringEnum<T>) {\n  const enumValues = Object.values(e);\n  const schema = yup.string<T>().oneOf(enumValues);\n  if (enumValues.includes(\"\" as T)) {\n    return schema;\n  } else {\n    return schema.transform((v, o) => (o === \"\" ? null : v));\n  }\n}\n\n// Use yupInputNumber to validate a number from an <input type=\"number\">.\n// A \"\" input will be transformed to null.\nexport function yupInputNumber() {\n  return yup.number().transform((v, o) => (o === \"\" ? null : v));\n}\n\n// Formik converts \"\" into undefined when validating with a yup schema,\n// which prevents transformations from running.\n// Interfacing with yup ourselves avoids this.\n// https://github.com/jaredpalmer/formik/pull/2902#issuecomment-922492137\nexport function yupFormikValidate<T>(\n  schema: yup.AnySchema\n): (values: T) => Promise<FormikErrors<T>> {\n  return async function (values) {\n    try {\n      await schema.validate(values, { abortEarly: false });\n    } catch (err) {\n      return yupToFormErrors(err);\n    }\n    return {};\n  };\n}\n"
  },
  {
    "path": "ui/v2.5/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es2019\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"es2020\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"experimentalDecorators\": true,\n    \"baseUrl\": \".\",\n    \"sourceMap\": true,\n    \"allowJs\": true,\n    \"isolatedModules\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"useDefineForClassFields\": true,\n    \"types\": [\"vite/client\", \"dom-screen-wake-lock\"]\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "ui/v2.5/vite.config.js",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport legacy from \"@vitejs/plugin-legacy\";\nimport tsconfigPaths from \"vite-tsconfig-paths\";\nimport viteCompression from \"vite-plugin-compression\";\n\nconst nolegacy = process.env.VITE_APP_NOLEGACY === \"true\";\nconst sourcemap = process.env.VITE_APP_SOURCEMAPS === \"true\";\n\n// https://vitejs.dev/config/\nexport default defineConfig(() => {\n  let plugins = [\n    react({\n      babel: {\n        compact: true,\n      },\n    }),\n    tsconfigPaths(),\n    viteCompression({\n      algorithm: \"gzip\",\n      deleteOriginFile: true,\n      threshold: 0,\n      filter: /\\.(js|json|css|svg|md)$/i,\n    }),\n  ];\n\n  if (!nolegacy) {\n    plugins = [...plugins, legacy()];\n  }\n\n  return {\n    base: \"\",\n    build: {\n      outDir: \"build\",\n      sourcemap: sourcemap,\n      reportCompressedSize: false,\n    },\n    optimizeDeps: {\n      entries: \"src/index.tsx\",\n    },\n    server: {\n      port: 3000,\n      cors: false,\n    },\n    publicDir: \"public\",\n    assetsInclude: [\"**/*.md\"],\n    plugins,\n  };\n});\n"
  }
]